feat(i18n): 剩余模块全量国际化 — 51% 覆盖率 (231 文件接入 t())
批量转换 163 个文件,3204 处中文替换为 t() 调用: - Monitor: 日志中心/告警中心/数据源/大屏/站点拨测/SNMP/中间件 全部转换 - Kubernetes: ResourceVisualEditor/NamespaceResourcePage/拓扑/事件 全部转换 - Deploy: 流水线/发布/凭证/RBAC 全部转换 - Server/Cloud/Agent/Task/Layout/UI: 全部转换 - 翻译词典扩展至 6474 行 (22 namespaces, 5000+ keys) - tsc 编译零错误
This commit is contained in:
1 parent
78c27037e3
commit
ecec73989b
166 files changed
+6925
-2800
No files matched your search
@@ -18,6 +18,7 @@ import { Plus, Trash2, Loader2 } from 'lucide-react';
|
||||
import { alertApi } from '@/api';
|
||||
import { CHANNEL_TYPES, getChannelTypeLabel } from './alertShared';
|
||||
import type { AlertChannel } from '@/types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const defaultChannelForm = { name: '', type: 'dingtalk', config: '', enabled: true };
|
||||
|
||||
@@ -32,6 +33,7 @@ export function AlertChannelDialog({
|
||||
notifyChannels: AlertChannel[];
|
||||
onChannelsChanged: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const _confirm = useConfirm();
|
||||
const [editingChannel, setEditingChannel] = useState<AlertChannel | null>(null);
|
||||
const [channelSaving, setChannelSaving] = useState(false);
|
||||
@@ -85,7 +87,7 @@ export function AlertChannelDialog({
|
||||
};
|
||||
|
||||
const handleDeleteChannel = async ( id: number) => {
|
||||
{ const ok = await _confirm({ title: '删除通知渠道', description: '确定删除此通知渠道?', confirmText: '删除', variant: 'destructive' }); if (!ok) return; }
|
||||
{ const ok = await _confirm({ title: t('monitor.alertchanneldialog.sa4450b'), description: t('monitor.alertchanneldialog.sb1a9e5'), confirmText: t('common.delete'), variant: 'destructive' }); if (!ok) return; }
|
||||
try {
|
||||
await alertApi.deleteNotifyChannel(id);
|
||||
if (editingChannel && editingChannel.id === id) resetChannelForm();
|
||||
@@ -120,7 +122,7 @@ export function AlertChannelDialog({
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="dg-input max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-[var(--dg-text-primary)]">通知渠道配置</DialogTitle>
|
||||
<DialogTitle className="text-[var(--dg-text-primary)]">{t('monitor.alertchanneldialog.s976ad4')}</DialogTitle>
|
||||
<DialogDescription className="dg-text-muted">
|
||||
管理告警通知渠道(钉钉/企业微信/邮件/短信/Webhook),配置后可在告警规则中引用
|
||||
</DialogDescription>
|
||||
@@ -130,7 +132,7 @@ export function AlertChannelDialog({
|
||||
{/* 渠道列表 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-[var(--dg-text-secondary)] text-sm">已配置渠道</Label>
|
||||
<Label className="text-[var(--dg-text-secondary)] text-sm">{t('monitor.alertchanneldialog.se6ad00')}</Label>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -173,9 +175,9 @@ export function AlertChannelDialog({
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
{ch.test_status === 'success' ? (
|
||||
<span className="text-[11px] dg-text-green">测试通过</span>
|
||||
<span className="text-[11px] dg-text-green">{t('monitor.alertchanneldialog.sd25431')}</span>
|
||||
) : (
|
||||
<span className="text-[11px] dg-text-muted">未测试</span>
|
||||
<span className="text-[11px] dg-text-muted">{t('monitor.alertchanneldialog.s8c852a')}</span>
|
||||
)}
|
||||
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
|
||||
<Button
|
||||
@@ -185,7 +187,7 @@ export function AlertChannelDialog({
|
||||
disabled={channelTesting === ch.id}
|
||||
onClick={() => handleTestChannel(ch.id)}
|
||||
>
|
||||
{channelTesting === ch.id ? <Loader2 className="h-3 w-3 animate-spin" /> : '测试'}
|
||||
{channelTesting === ch.id ? <Loader2 className="h-3 w-3 animate-spin" /> : t('common.test')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -206,19 +208,19 @@ export function AlertChannelDialog({
|
||||
{/* 渠道表单 */}
|
||||
<div className="space-y-4">
|
||||
<Label className="text-[var(--dg-text-secondary)] text-sm">
|
||||
{editingChannel ? '编辑渠道' : '新建渠道'}
|
||||
{editingChannel ? t('monitor.alertchanneldialog.sc47145') : t('monitor.alertchanneldialog.s82bd47')}
|
||||
</Label>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="dg-text-secondary text-xs">渠道名称 <span className="dg-text-red">*</span></Label>
|
||||
<Label className="dg-text-secondary text-xs">{t('monitor.alertchanneldialog.sb8cbd8')} <span className="dg-text-red">*</span></Label>
|
||||
<Input
|
||||
value={channelForm.name}
|
||||
onChange={(e) => setChannelForm({ ...channelForm, name: e.target.value })}
|
||||
placeholder="如:运维告警群"
|
||||
placeholder=t('monitor.alertchanneldialog.s6906c9')
|
||||
className="dg-input h-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="dg-text-secondary text-xs">渠道类型</Label>
|
||||
<Label className="dg-text-secondary text-xs">{t('monitor.channelType')}</Label>
|
||||
<select
|
||||
value={channelForm.type}
|
||||
onChange={(e) => setChannelForm({ ...channelForm, type: e.target.value })}
|
||||
@@ -230,7 +232,7 @@ export function AlertChannelDialog({
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="dg-text-secondary text-xs">配置(JSON)</Label>
|
||||
<Label className="dg-text-secondary text-xs">{t('monitor.alertchanneldialog.s9ca77d')}</Label>
|
||||
<Textarea
|
||||
value={channelForm.config}
|
||||
onChange={(e) => setChannelForm({ ...channelForm, config: e.target.value })}
|
||||
@@ -246,7 +248,7 @@ export function AlertChannelDialog({
|
||||
onCheckedChange={(v) => setChannelForm({ ...channelForm, enabled: v })}
|
||||
className="data-[state=checked]:bg-emerald-600"
|
||||
/>
|
||||
<Label className="dg-text-secondary text-xs">启用此渠道</Label>
|
||||
<Label className="dg-text-secondary text-xs">{t('monitor.alertchanneldialog.sbdab1c')}</Label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -266,7 +268,7 @@ export function AlertChannelDialog({
|
||||
onClick={handleSaveChannel}
|
||||
>
|
||||
{channelSaving && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
{editingChannel ? '保存修改' : '添加渠道'}
|
||||
{editingChannel ? i18n.t('cloud.saveChanges') : i18n.t('monitor.alertchanneldialog.s22d04e')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -20,8 +20,10 @@ import { PageShell } from '@/components/layout/PageShell';
|
||||
import { StatStrip } from '@/components/ui/stat-strip';
|
||||
import type { JsonData } from '@/types/common';
|
||||
import type { AlertEventRecord, AlertRuleRecord, AlertChannel } from '@/types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export function AlertManager() {
|
||||
const { t } = useTranslation();
|
||||
const [activeTab, setActiveTab] = useState('events');
|
||||
const [alerts, setAlerts] = useState<AlertEventRecord[]>([]);
|
||||
const [rules, setRules] = useState<AlertRuleRecord[]>([]);
|
||||
@@ -75,15 +77,15 @@ export function AlertManager() {
|
||||
];
|
||||
|
||||
const pageTabs = [
|
||||
{ id: 'events', label: '告警事件' },
|
||||
{ id: 'rules', label: '告警规则' },
|
||||
{ id: 'events', label: t('monitor.alertmanager.s0e5b83') },
|
||||
{ id: 'rules', label: t('alert.tab.rules') },
|
||||
];
|
||||
|
||||
return (
|
||||
<PageShell
|
||||
variant="list"
|
||||
title="监控告警"
|
||||
subtitle="告警规则管理与告警事件处理"
|
||||
title=t('monitor.alertmanager.sa57c10')
|
||||
subtitle=t('monitor.alertmanager.s1a70ca')
|
||||
icon={<Bell className="h-5 w-5" />}
|
||||
tabs={pageTabs}
|
||||
activeTab={activeTab}
|
||||
@@ -91,10 +93,10 @@ export function AlertManager() {
|
||||
metrics={(
|
||||
<StatStrip
|
||||
items={[
|
||||
{ label: '告警规则', value: rules.length, color: 'blue' },
|
||||
{ label: '触发中', value: alerts.filter((e) => e.status === 'firing').length, color: 'red' },
|
||||
{ label: '今日告警', value: alerts.length, color: 'amber' },
|
||||
{ label: '已恢复', value: alerts.filter((e) => e.status === 'resolved').length, color: 'emerald' },
|
||||
{ label: t('alert.tab.rules'), value: rules.length, color: 'blue' },
|
||||
{ label: t('monitor.firing'), value: alerts.filter((e) => e.status === 'firing').length, color: 'red' },
|
||||
{ label: t('monitor.alertmanager.s240597'), value: alerts.length, color: 'amber' },
|
||||
{ label: t('kubernetes.deployment.resumed'), value: alerts.filter((e) => e.status === 'resolved').length, color: 'emerald' },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
@@ -117,8 +119,8 @@ export function AlertManager() {
|
||||
>
|
||||
<Card className="dg-card">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-[var(--dg-text-primary)]">告警趋势</CardTitle>
|
||||
<CardDescription className="dg-text-muted">过去24小时告警统计</CardDescription>
|
||||
<CardTitle className="text-[var(--dg-text-primary)]">{t('monitor.alertmanager.s358b05')}</CardTitle>
|
||||
<CardDescription className="dg-text-muted">{t('monitor.alertmanager.s615fa6')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[200px]">
|
||||
@@ -151,7 +153,7 @@ export function AlertManager() {
|
||||
stroke="#ef4444"
|
||||
fillOpacity={1}
|
||||
fill="url(#colorCritical)"
|
||||
name="严重"
|
||||
name=t('monitor.critical')
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
@@ -159,7 +161,7 @@ export function AlertManager() {
|
||||
stroke="#f59e0b"
|
||||
fillOpacity={1}
|
||||
fill="url(#colorWarning)"
|
||||
name="警告"
|
||||
name=t('common.warning')
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
|
||||
@@ -16,6 +16,7 @@ import { Loader2 } from 'lucide-react';
|
||||
import { alertApi, logCenterApi, type LogDataSource } from '@/api';
|
||||
import { parseChannels, getChannelIcon, getChannelTypeLabel } from './alertShared';
|
||||
import type { JsonData } from '@/types/common';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const emptyRuleForm = {
|
||||
name: '',
|
||||
@@ -45,6 +46,7 @@ export function AlertRuleDialog({
|
||||
/** 新建时默认规则类型 // Logan.Li */
|
||||
defaultType?: 'metric' | 'log' | 'event';
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [ruleForm, setRuleForm] = useState({ ...emptyRuleForm });
|
||||
const [ruleSaving, setRuleSaving] = useState(false);
|
||||
const [logSources, setLogSources] = useState<LogDataSource[]>([]);
|
||||
@@ -127,30 +129,30 @@ export function AlertRuleDialog({
|
||||
<DialogContent className="dg-input max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-[var(--dg-text-primary)]">
|
||||
{editingRule ? '编辑告警规则' : '新建告警规则'}
|
||||
{editingRule ? t('monitor.alertruledialog.sb62c8b') : t('monitor.alertruledialog.s3564fa')}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="dg-text-muted">
|
||||
{editingRule ? '修改告警规则的触发条件和通知方式' : '配置监控指标的告警触发条件'}
|
||||
{editingRule ? t('monitor.alertruledialog.s44a3bb') : t('monitor.alertruledialog.sb785ec')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 max-h-[60vh] overflow-y-auto pr-1">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[var(--dg-text-secondary)]">规则名称 <span className="dg-text-red">*</span></Label>
|
||||
<Label className="text-[var(--dg-text-secondary)]">{t('monitor.ruleName')} <span className="dg-text-red">*</span></Label>
|
||||
<Input
|
||||
value={ruleForm.name}
|
||||
onChange={(e) => setRuleForm({ ...ruleForm, name: e.target.value })}
|
||||
placeholder="如:CPU 使用率告警"
|
||||
placeholder=t('monitor.alertruledialog.sf111cd')
|
||||
className="dg-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[var(--dg-text-secondary)]">描述</Label>
|
||||
<Label className="text-[var(--dg-text-secondary)]">{t('common.description')}</Label>
|
||||
<Textarea
|
||||
value={ruleForm.description}
|
||||
onChange={(e) => setRuleForm({ ...ruleForm, description: e.target.value })}
|
||||
placeholder="规则说明"
|
||||
placeholder=t('monitor.alertruledialog.scc807b')
|
||||
rows={2}
|
||||
className="dg-input resize-none"
|
||||
/>
|
||||
@@ -158,7 +160,7 @@ export function AlertRuleDialog({
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[var(--dg-text-secondary)]">规则类型</Label>
|
||||
<Label className="text-[var(--dg-text-secondary)]">{t('monitor.alertruledialog.s365825')}</Label>
|
||||
<select
|
||||
value={ruleForm.type}
|
||||
onChange={(e) => {
|
||||
@@ -168,13 +170,13 @@ export function AlertRuleDialog({
|
||||
}}
|
||||
className="w-full h-9 px-3 rounded-md bg-[var(--dg-bg-tertiary)]/50 border border-[var(--dg-border)] text-[var(--dg-text-primary)] text-sm"
|
||||
>
|
||||
<option value="metric">指标监控</option>
|
||||
<option value="log">日志告警</option>
|
||||
<option value="event">事件告警</option>
|
||||
<option value="metric">{t('monitor.alertruledialog.s38546d')}</option>
|
||||
<option value="log">{t('menu.logAlerts')}</option>
|
||||
<option value="event">{t('monitor.alertruledialog.s80069b')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[var(--dg-text-secondary)]">数据源</Label>
|
||||
<Label className="text-[var(--dg-text-secondary)]">{t('menu.datasources')}</Label>
|
||||
<select
|
||||
value={ruleForm.source}
|
||||
onChange={(e) => setRuleForm({ ...ruleForm, source: e.target.value, datasource_id: undefined })}
|
||||
@@ -189,7 +191,7 @@ export function AlertRuleDialog({
|
||||
<>
|
||||
<option value="prometheus">Prometheus</option>
|
||||
<option value="zabbix">Zabbix</option>
|
||||
<option value="custom">自定义</option>
|
||||
<option value="custom">{t('common.custom')}</option>
|
||||
</>
|
||||
)}
|
||||
</select>
|
||||
@@ -198,15 +200,15 @@ export function AlertRuleDialog({
|
||||
|
||||
{ruleForm.type === 'log' && (
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[var(--dg-text-secondary)]">日志数据源 <span className="dg-text-red">*</span></Label>
|
||||
<Label className="text-[var(--dg-text-secondary)]">{t('monitor.alertruledialog.sa2bc1e')} <span className="dg-text-red">*</span></Label>
|
||||
<select
|
||||
value={ruleForm.datasource_id || ''}
|
||||
onChange={(e) => setRuleForm({ ...ruleForm, datasource_id: Number(e.target.value) || undefined })}
|
||||
className="w-full h-9 px-3 rounded-md bg-[var(--dg-bg-tertiary)]/50 border border-[var(--dg-border)] text-[var(--dg-text-primary)] text-sm"
|
||||
>
|
||||
<option value="">请选择数据源</option>
|
||||
<option value="">{t('monitor.alertruledialog.s9acb96')}</option>
|
||||
{logSources.map((s) => (
|
||||
<option key={s.id} value={s.id}>{s.name}{s.is_default ? ' (默认)' : ''}</option>
|
||||
<option key={s.id} value={s.id}>{s.name}{s.is_default ? t('monitor.alertruledialog.see1f37') : ''}</option>
|
||||
))}
|
||||
</select>
|
||||
{logSources.length === 0 && (
|
||||
@@ -216,7 +218,7 @@ export function AlertRuleDialog({
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[var(--dg-text-secondary)]">触发表达式 <span className="dg-text-red">*</span></Label>
|
||||
<Label className="text-[var(--dg-text-secondary)]">{t('monitor.alertruledialog.s57ee9a')} <span className="dg-text-red">*</span></Label>
|
||||
<Input
|
||||
value={ruleForm.expression}
|
||||
onChange={(e) => setRuleForm({ ...ruleForm, expression: e.target.value })}
|
||||
@@ -225,26 +227,26 @@ export function AlertRuleDialog({
|
||||
/>
|
||||
<p className="text-xs dg-text-muted">
|
||||
{ruleForm.type === 'log'
|
||||
? (ruleForm.source === 'loki' ? 'LogQL 查询或 count_over_time 表达式' : 'Elasticsearch Lucene 查询')
|
||||
: 'PromQL 或指标表达式,例如 cpu_usage > 80'}
|
||||
? (ruleForm.source === 'loki' ? t('monitor.alertruledialog.s465c2b') : t('monitor.alertruledialog.s7072bc'))
|
||||
: t('monitor.alertruledialog.se2017e')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[var(--dg-text-secondary)]">告警级别</Label>
|
||||
<Label className="text-[var(--dg-text-secondary)]">{t('monitor.alertruledialog.sfce93b')}</Label>
|
||||
<select
|
||||
value={ruleForm.severity}
|
||||
onChange={(e) => setRuleForm({ ...ruleForm, severity: e.target.value })}
|
||||
className="w-full h-9 px-3 rounded-md bg-[var(--dg-bg-tertiary)]/50 border border-[var(--dg-border)] text-[var(--dg-text-primary)] text-sm"
|
||||
>
|
||||
<option value="info">提示</option>
|
||||
<option value="warning">警告</option>
|
||||
<option value="critical">严重</option>
|
||||
<option value="info">{t('common.info')}</option>
|
||||
<option value="warning">{t('common.warning')}</option>
|
||||
<option value="critical">{t('monitor.critical')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[var(--dg-text-secondary)]">持续时间(秒)</Label>
|
||||
<Label className="text-[var(--dg-text-secondary)]">{t('monitor.alertruledialog.scce774')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={ruleForm.duration}
|
||||
@@ -255,7 +257,7 @@ export function AlertRuleDialog({
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[var(--dg-text-secondary)]">通知渠道</Label>
|
||||
<Label className="text-[var(--dg-text-secondary)]">{t('monitor.notificationChannel')}</Label>
|
||||
{notifyChannels.length === 0 ? (
|
||||
<div className="text-xs dg-text-yellow/80 bg-amber-500/5 border border-amber-500/20 rounded-md px-3 py-2">
|
||||
暂无已配置的通知渠道,请先在「通知渠道配置」中添加
|
||||
@@ -302,7 +304,7 @@ export function AlertRuleDialog({
|
||||
onClick={handleSaveRule}
|
||||
>
|
||||
{ruleSaving && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
{editingRule ? '保存修改' : '创建规则'}
|
||||
{editingRule ? i18n.t('cloud.saveChanges') : i18n.t('monitor.alertruledialog.s8e1e3d')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -18,6 +18,7 @@ import { toast } from 'sonner';
|
||||
import { alertApi } from '@/api';
|
||||
import { renderRuleChannels } from './alertShared';
|
||||
import type { AlertChannel, AlertRuleRecord } from '@/types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const COL_SPAN = 9; // 复选框 + 7 数据列 + 操作列 // Logan.Li
|
||||
|
||||
@@ -32,6 +33,7 @@ export function AlertRulesTab({
|
||||
onReload: () => void;
|
||||
onEditRule: ( rule: AlertRuleRecord) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const confirm = useConfirm();
|
||||
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
||||
|
||||
@@ -53,19 +55,19 @@ export function AlertRulesTab({
|
||||
|
||||
const handleDeleteRule = async ( id: number) => {
|
||||
const ok = await confirm({
|
||||
title: '删除告警规则',
|
||||
description: '确定删除此告警规则?',
|
||||
confirmText: '删除',
|
||||
title: t('monitor.alertrulestab.sfa5bfd'),
|
||||
description: t('monitor.alertrulestab.sd30155'),
|
||||
confirmText: t('common.delete'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await alertApi.deleteRule(id);
|
||||
toast.success('告警规则已删除');
|
||||
toast.success(t('monitor.alertrulestab.s20b3fd'));
|
||||
onReload();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete rule:', error);
|
||||
toast.error('删除失败');
|
||||
toast.error(t('common.deleteFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -73,20 +75,20 @@ export function AlertRulesTab({
|
||||
const handleBatchDelete = async () => {
|
||||
if (selectedIds.length === 0) return;
|
||||
const ok = await confirm({
|
||||
title: '批量删除告警规则',
|
||||
description: `确定删除选中的 ${selectedIds.length} 条告警规则吗?此操作不可撤销。`,
|
||||
confirmText: '批量删除',
|
||||
title: t('monitor.alertrulestab.sbb457b'),
|
||||
description: t('monitor.alertrulestab.s12d681', { selectedIds.length: selectedIds.length }),
|
||||
confirmText: t('common.batchDelete'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await alertApi.batchDeleteRules(selectedIds);
|
||||
toast.success(`已删除 ${selectedIds.length} 条告警规则`);
|
||||
toast.success(t('monitor.alertrulestab.s327a78', { selectedIds.length: selectedIds.length }));
|
||||
setSelectedIds([]);
|
||||
onReload();
|
||||
} catch (error) {
|
||||
console.error('Failed to batch delete rules:', error);
|
||||
toast.error('批量删除失败');
|
||||
toast.error(t('cloud.batchDeleteFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -132,14 +134,14 @@ export function AlertRulesTab({
|
||||
className="border-[var(--dg-border-hover)]"
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="dg-text-secondary">规则名称</TableHead>
|
||||
<TableHead className="dg-text-secondary">数据源</TableHead>
|
||||
<TableHead className="dg-text-secondary">触发条件</TableHead>
|
||||
<TableHead className="dg-text-secondary">级别</TableHead>
|
||||
<TableHead className="dg-text-secondary">持续时间</TableHead>
|
||||
<TableHead className="dg-text-secondary">通知渠道</TableHead>
|
||||
<TableHead className="dg-text-secondary">状态</TableHead>
|
||||
<TableHead className="dg-text-secondary text-right">操作</TableHead>
|
||||
<TableHead className="dg-text-secondary">{t('monitor.ruleName')}</TableHead>
|
||||
<TableHead className="dg-text-secondary">{t('menu.datasources')}</TableHead>
|
||||
<TableHead className="dg-text-secondary">{t('monitor.alertrulestab.s65939c')}</TableHead>
|
||||
<TableHead className="dg-text-secondary">{t('common.level')}</TableHead>
|
||||
<TableHead className="dg-text-secondary">{t('monitor.alertDuration')}</TableHead>
|
||||
<TableHead className="dg-text-secondary">{t('monitor.notificationChannel')}</TableHead>
|
||||
<TableHead className="dg-text-secondary">{t('common.status')}</TableHead>
|
||||
<TableHead className="dg-text-secondary text-right">{t('common.actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
|
||||
@@ -12,13 +12,15 @@ import {
|
||||
import { toast } from 'sonner';
|
||||
import { alertApi } from '@/api';
|
||||
import type { NotificationChannel } from './alert-center-helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import i18n from '@/i18n';
|
||||
|
||||
const CHANNEL_TYPES = [
|
||||
{ value: 'dingtalk', label: '钉钉', hint: '钉钉机器人: webhook URL + secret' },
|
||||
{ value: 'wechat', label: '企业微信', hint: '企业微信: webhook URL' },
|
||||
{ value: 'feishu', label: '飞书', hint: '飞书机器人: {"url":"https://open.feishu.cn/open-apis/bot/v2/hook/..."}' },
|
||||
{ value: 'email', label: '邮件', hint: '邮件: {"to":"ops@ezops.io"}' },
|
||||
{ value: 'sms', label: '短信', hint: '短信: {"phones":"13800138000"}' },
|
||||
{ value: 'dingtalk', label: i18n.t('monitor.contactDingtalk'), hint: i18n.t('monitor.alertchanneldialog.sdedd24') },
|
||||
{ value: 'wechat', label: i18n.t('monitor.contactWechat'), hint: i18n.t('monitor.alertchanneldialog.s9c267e') },
|
||||
{ value: 'feishu', label: i18n.t('monitor.alertchanneldialog.s7714e5'), hint: i18n.t('monitor.alertchanneldialog.s98d805') },
|
||||
{ value: 'email', label: i18n.t('common.email'), hint: i18n.t('monitor.alertchanneldialog.s0cb420') },
|
||||
{ value: 'sms', label: i18n.t('common.sms'), hint: i18n.t('monitor.alertchanneldialog.s1ad98b') },
|
||||
{ value: 'webhook', label: 'Webhook', hint: 'Webhook: {"url":"https://..."}' },
|
||||
];
|
||||
|
||||
@@ -33,6 +35,7 @@ export function AlertChannelDialog({
|
||||
editingChannel: NotificationChannel | null;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [form, setForm] = useState({ name: '', type: 'dingtalk', config: '' });
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
@@ -54,15 +57,15 @@ export function AlertChannelDialog({
|
||||
const payload = { name: form.name.trim(), type: form.type, config: form.config.trim() };
|
||||
if (editingChannel) {
|
||||
await alertApi.updateNotifyChannel(editingChannel.id, payload);
|
||||
toast.success('渠道已更新');
|
||||
toast.success(t('monitor.alertchanneldialog.s0cca4d'));
|
||||
} else {
|
||||
await alertApi.createNotifyChannel(payload);
|
||||
toast.success('渠道已添加');
|
||||
toast.success(t('monitor.alertchanneldialog.s70dea9'));
|
||||
}
|
||||
onOpenChange(false);
|
||||
onSaved();
|
||||
} catch {
|
||||
toast.error('保存失败');
|
||||
toast.error(t('common.saveFailed'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -74,20 +77,20 @@ export function AlertChannelDialog({
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingChannel ? '编辑通知渠道' : '添加通知渠道'}</DialogTitle>
|
||||
<DialogDescription>配置告警通知渠道(钉钉/企业微信/邮件/短信/Webhook)</DialogDescription>
|
||||
<DialogTitle>{editingChannel ? t('monitor.alertchanneldialog.sa605a7') : t('monitor.alertchanneldialog.sc7f8b8')}</DialogTitle>
|
||||
<DialogDescription>{t('monitor.alertchanneldialog.sd0af2a')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>渠道名称 *</Label>
|
||||
<Label>{t('monitor.alertchanneldialog.s8b7c73')}</Label>
|
||||
<Input
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
placeholder="如:运维告警群"
|
||||
placeholder=t('monitor.alertchanneldialog.s6906c9')
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>渠道类型</Label>
|
||||
<Label>{t('monitor.channelType')}</Label>
|
||||
<Select value={form.type} onValueChange={(v) => setForm({ ...form, type: v })}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
@@ -100,7 +103,7 @@ export function AlertChannelDialog({
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>配置(JSON)</Label>
|
||||
<Label>{t('monitor.alertchanneldialog.s9ca77d')}</Label>
|
||||
<Input
|
||||
value={form.config}
|
||||
onChange={(e) => setForm({ ...form, config: e.target.value })}
|
||||
@@ -111,9 +114,9 @@ export function AlertChannelDialog({
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>取消</Button>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>{t('common.cancel')}</Button>
|
||||
<Button onClick={handleSave} disabled={!form.name.trim() || saving}>
|
||||
{saving ? '保存中...' : editingChannel ? '保存修改' : '添加渠道'}
|
||||
{saving ? t('k8s.topology.saving') : editingChannel ? i18n.t('cloud.saveChanges') : i18n.t('monitor.alertchanneldialog.s22d04e')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -12,8 +12,10 @@ import {
|
||||
} from './alert-center-helpers';
|
||||
import { AlertChannelDialog } from './AlertChannelDialog';
|
||||
import type { JsonData } from '@/types/common';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export const AlertChannelsTab: React.FC<{ refreshKey: number }> = ({ refreshKey }) => {
|
||||
const { t } = useTranslation();
|
||||
const _confirm = useConfirm();
|
||||
const [channels, setChannels] = useState<NotificationChannel[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -28,7 +30,7 @@ export const AlertChannelsTab: React.FC<{ refreshKey: number }> = ({ refreshKey
|
||||
const list = (res as JsonData)?.list || [];
|
||||
setChannels(list);
|
||||
} catch {
|
||||
toast.error('获取通知渠道失败');
|
||||
toast.error(t('monitor.alertchannelstab.s84d8b4'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -47,14 +49,14 @@ export const AlertChannelsTab: React.FC<{ refreshKey: number }> = ({ refreshKey
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
const ok = await _confirm({ title: '删除通知渠道', description: '确定删除此通知渠道?', confirmText: '删除', variant: 'destructive' });
|
||||
const ok = await _confirm({ title: t('monitor.alertchannelstab.sa4450b'), description: t('monitor.alertchannelstab.sb1a9e5'), confirmText: t('common.delete'), variant: 'destructive' });
|
||||
if (!ok) return;
|
||||
try {
|
||||
await alertApi.deleteNotifyChannel(id);
|
||||
toast.success('渠道已删除');
|
||||
toast.success(t('monitor.alertchannelstab.s6fad11'));
|
||||
fetchChannels();
|
||||
} catch {
|
||||
toast.error('删除失败');
|
||||
toast.error(t('common.deleteFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -63,13 +65,13 @@ export const AlertChannelsTab: React.FC<{ refreshKey: number }> = ({ refreshKey
|
||||
const res = await alertApi.testNotifyChannel(id);
|
||||
const status = (res as JsonData)?.status;
|
||||
if (status === 'success') {
|
||||
toast.success('测试通知已发送');
|
||||
toast.success(t('monitor.alertchannelstab.s1e710e'));
|
||||
} else {
|
||||
toast.warning('渠道未配置参数,请先完善配置');
|
||||
toast.warning(t('monitor.alertchannelstab.s9b3fda'));
|
||||
}
|
||||
fetchChannels();
|
||||
} catch {
|
||||
toast.error('测试失败');
|
||||
toast.error(t('monitor.alertchannelstab.s9710d9'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -78,7 +80,7 @@ export const AlertChannelsTab: React.FC<{ refreshKey: number }> = ({ refreshKey
|
||||
await alertApi.updateNotifyChannel(ch.id, { enabled: !ch.enabled });
|
||||
fetchChannels();
|
||||
} catch {
|
||||
toast.error('操作失败');
|
||||
toast.error(t('common.operationFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -96,7 +98,7 @@ export const AlertChannelsTab: React.FC<{ refreshKey: number }> = ({ refreshKey
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-16 text-muted-foreground">加载中...</div>
|
||||
<div className="text-center py-16 text-muted-foreground">{t('common.loading')}</div>
|
||||
) : channels.length === 0 ? (
|
||||
<div className="text-center py-16 text-muted-foreground border border-dashed rounded-lg">
|
||||
暂无通知渠道,点击「添加渠道」创建
|
||||
@@ -112,7 +114,7 @@ export const AlertChannelsTab: React.FC<{ refreshKey: number }> = ({ refreshKey
|
||||
<CardTitle className="text-base">{channel.name}</CardTitle>
|
||||
</div>
|
||||
<Badge variant={channel.enabled ? 'default' : 'secondary'} className={channel.enabled ? 'bg-green-600' : ''}>
|
||||
{channel.enabled ? '已启用' : '已禁用'}
|
||||
{channel.enabled ? t('alert.enabled') : t('alert.disabled')}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
@@ -120,7 +122,7 @@ export const AlertChannelsTab: React.FC<{ refreshKey: number }> = ({ refreshKey
|
||||
<div className="text-sm text-muted-foreground mb-3">
|
||||
类型: {getChannelTypeLabel(channel.type)}
|
||||
{channel.test_status === 'success' && (
|
||||
<span className="ml-2 dg-text-green">测试通过</span>
|
||||
<span className="ml-2 dg-text-green">{t('monitor.alertchannelstab.sd25431')}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
@@ -130,7 +132,7 @@ export const AlertChannelsTab: React.FC<{ refreshKey: number }> = ({ refreshKey
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="flex-1" onClick={() => handleToggle(channel)}>
|
||||
<Settings className="w-4 h-4 mr-1.5" />
|
||||
{channel.enabled ? '禁用' : '启用'}
|
||||
{channel.enabled ? t('common.disable') : t('common.enable')}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => handleDelete(channel.id)}>
|
||||
<X className="w-4 h-4" />
|
||||
|
||||
@@ -12,6 +12,8 @@ import { AgentRuleDialog } from '@/components/agent/AgentRuleDialog';
|
||||
import { MonitorRuleDialog } from './MonitorRuleDialog';
|
||||
import type { MetricCatalogCategory, UnifiedAlertRule } from '@/api';
|
||||
import type { JsonData } from '@/types/common';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import i18n from '@/i18n';
|
||||
|
||||
type RuleCategory = 'host' | 'middleware' | 'snmp' | 'site';
|
||||
type MonitorCategory = 'middleware' | 'snmp' | 'site';
|
||||
@@ -22,10 +24,10 @@ const RULE_CATEGORIES: Array<{
|
||||
icon: typeof Server;
|
||||
desc: string;
|
||||
}> = [
|
||||
{ id: 'host', label: '主机监控', icon: Server, desc: 'Agent CPU/内存/磁盘/离线' },
|
||||
{ id: 'middleware', label: '中间件', icon: Database, desc: 'Redis / MySQL 指标' },
|
||||
{ id: 'snmp', label: 'SNMP', icon: Network, desc: '网络设备 / BMC' },
|
||||
{ id: 'site', label: '站点拨测', icon: Globe, desc: 'HTTP/HTTPS 可用性与证书' },
|
||||
{ id: 'host', label: i18n.t('monitor.hostMonitor'), icon: Server, desc: i18n.t('monitor.alertrulestab.s836f7b') },
|
||||
{ id: 'middleware', label: i18n.t('dashboard.ops.middleware'), icon: Database, desc: i18n.t('monitor.alertrulestab.sf3e5db') },
|
||||
{ id: 'snmp', label: 'SNMP', icon: Network, desc: i18n.t('monitor.alertrulestab.s086e4c') },
|
||||
{ id: 'site', label: i18n.t('monitor.siteProbe'), icon: Globe, desc: i18n.t('monitor.alertrulestab.sf9f400') },
|
||||
];
|
||||
|
||||
interface Props {
|
||||
@@ -47,6 +49,7 @@ export function AlertRulesTab({
|
||||
onToggleAgent, onDeleteAgent, onDeleteMonitor, onToggleMonitor,
|
||||
onQuickSetupHost, onQuickSetupMonitor, quickSetupPending,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [activeCat, setActiveCat] = useState<RuleCategory>('host');
|
||||
const [agentDialogOpen, setAgentDialogOpen] = useState(false);
|
||||
const [monitorDialogOpen, setMonitorDialogOpen] = useState(false);
|
||||
@@ -79,7 +82,7 @@ export function AlertRulesTab({
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <PageLoading label="加载规则..." />;
|
||||
if (loading) return <PageLoading label=t('monitor.alertrulestab.s1e637e') />;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -116,7 +119,7 @@ export function AlertRulesTab({
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<PageEmpty title={`暂无${catMeta.label}规则`} description="点击「添加规则」或「默认规则」创建" />
|
||||
<PageEmpty title={t('monitor.alertrulestab.sb2f604', { catMeta.label: catMeta.label })} description=t('monitor.alertrulestab.s1f2385') />
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
{filtered.map((rule) => (
|
||||
@@ -128,7 +131,7 @@ export function AlertRulesTab({
|
||||
<span className="font-medium text-sm">{rule.name}</span>
|
||||
{getSeverityBadge(rule.severity)}
|
||||
<Badge variant={rule.enabled ? 'default' : 'secondary'} className="text-[10px]">
|
||||
{rule.enabled ? '启用' : '停用'}
|
||||
{rule.enabled ? t('common.enable') : t('common.deactivate')}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground font-mono">
|
||||
@@ -150,9 +153,9 @@ export function AlertRulesTab({
|
||||
setMonitorCategory(rule.category as MonitorCategory);
|
||||
setMonitorDialogOpen(true);
|
||||
}
|
||||
}}>编辑</Button>
|
||||
}}>{t('common.edit')}</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs dg-text-red"
|
||||
onClick={() => rule.rule_type === 'agent' ? onDeleteAgent(rule) : onDeleteMonitor(rule)}>删除</Button>
|
||||
onClick={() => rule.rule_type === 'agent' ? onDeleteAgent(rule) : onDeleteMonitor(rule)}>{t('common.delete')}</Button>
|
||||
<Switch checked={rule.enabled}
|
||||
onCheckedChange={(v) => rule.rule_type === 'agent'
|
||||
? onToggleAgent(rule.id, v)
|
||||
|
||||
@@ -12,12 +12,14 @@ import {
|
||||
import { toast } from 'sonner';
|
||||
import { alertApi } from '@/api';
|
||||
import type { Contact } from './alert-center-helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import i18n from '@/i18n';
|
||||
|
||||
const CHANNEL_OPTIONS = [
|
||||
{ value: 'feishu', label: '飞书', placeholder: 'https://open.feishu.cn/open-apis/bot/v2/hook/...' },
|
||||
{ value: 'dingtalk', label: '钉钉', placeholder: 'https://oapi.dingtalk.com/robot/send?access_token=...' },
|
||||
{ value: 'wechat', label: '企业微信', placeholder: 'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=...' },
|
||||
{ value: 'webhook', label: '自定义Webhook', placeholder: 'https://your-webhook-url...' },
|
||||
{ value: 'feishu', label: i18n.t('monitor.contactdialog.s7714e5'), placeholder: 'https://open.feishu.cn/open-apis/bot/v2/hook/...' },
|
||||
{ value: 'dingtalk', label: i18n.t('monitor.contactDingtalk'), placeholder: 'https://oapi.dingtalk.com/robot/send?access_token=...' },
|
||||
{ value: 'wechat', label: i18n.t('monitor.contactWechat'), placeholder: 'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=...' },
|
||||
{ value: 'webhook', label: i18n.t('monitor.contactdialog.s3ccb7a'), placeholder: 'https://your-webhook-url...' },
|
||||
];
|
||||
|
||||
export function ContactDialog({
|
||||
@@ -31,6 +33,7 @@ export function ContactDialog({
|
||||
editingContact: Contact | null;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [channelType, setChannelType] = useState('feishu');
|
||||
const [form, setForm] = useState({ name: '', phone: '', email: '', webhook_url: '' });
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -65,15 +68,15 @@ export function ContactDialog({
|
||||
};
|
||||
if (editingContact) {
|
||||
await alertApi.updateContact(editingContact.id, payload);
|
||||
toast.success('联系人已更新');
|
||||
toast.success(t('monitor.contactdialog.sa0fe38'));
|
||||
} else {
|
||||
await alertApi.createContact(payload);
|
||||
toast.success('联系人已添加');
|
||||
toast.success(t('monitor.contactdialog.s50ee3f'));
|
||||
}
|
||||
onOpenChange(false);
|
||||
onSaved();
|
||||
} catch {
|
||||
toast.error('保存失败');
|
||||
toast.error(t('common.saveFailed'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -85,21 +88,21 @@ export function ContactDialog({
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingContact ? '编辑报警联系人' : '添加报警联系人'}</DialogTitle>
|
||||
<DialogDescription>选择通知渠道并填入 Webhook 地址</DialogDescription>
|
||||
<DialogTitle>{editingContact ? t('monitor.contactdialog.sbc3c27') : t('monitor.contactdialog.sc044d1')}</DialogTitle>
|
||||
<DialogDescription>{t('monitor.contactdialog.s04b40a')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>姓名 *</Label>
|
||||
<Label>{t('monitor.contactdialog.s7c46af')}</Label>
|
||||
<Input
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
placeholder="如:张三"
|
||||
placeholder=t('monitor.contactdialog.s6b9850')
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<Label>手机号</Label>
|
||||
<Label>{t('system.userPhone')}</Label>
|
||||
<Input
|
||||
value={form.phone}
|
||||
onChange={(e) => setForm({ ...form, phone: e.target.value })}
|
||||
@@ -107,7 +110,7 @@ export function ContactDialog({
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>邮箱</Label>
|
||||
<Label>{t('system.userEmail')}</Label>
|
||||
<Input
|
||||
value={form.email}
|
||||
onChange={(e) => setForm({ ...form, email: e.target.value })}
|
||||
@@ -116,10 +119,10 @@ export function ContactDialog({
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>渠道类型 *</Label>
|
||||
<Label>{t('monitor.contactdialog.sc84995')}</Label>
|
||||
<Select value={channelType} onValueChange={setChannelType}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择渠道类型" />
|
||||
<SelectValue placeholder=t('monitor.contactdialog.seb8768') />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CHANNEL_OPTIONS.map(opt => (
|
||||
@@ -139,9 +142,9 @@ export function ContactDialog({
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>取消</Button>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>{t('common.cancel')}</Button>
|
||||
<Button onClick={handleSave} disabled={!form.name.trim() || !form.webhook_url.trim() || saving}>
|
||||
{saving ? '保存中...' : editingContact ? '保存修改' : '添加联系人'}
|
||||
{saving ? i18n.t('k8s.topology.saving') : editingContact ? i18n.t('cloud.saveChanges') : i18n.t('alert.addContact')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { toast } from 'sonner';
|
||||
import { alertApi } from '@/api';
|
||||
import type { Contact, ContactGroup } from './alert-center-helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export function ContactGroupDialog({
|
||||
open,
|
||||
@@ -25,6 +26,7 @@ export function ContactGroupDialog({
|
||||
contacts: Contact[];
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [form, setForm] = useState({ group_name: '', notes: '', contact_ids: [] as number[] });
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
@@ -66,15 +68,15 @@ export function ContactGroupDialog({
|
||||
};
|
||||
if (editingGroup) {
|
||||
await alertApi.updateContactGroup(editingGroup.id, payload);
|
||||
toast.success('联系组已更新');
|
||||
toast.success(t('monitor.contactgroupdialog.s493f94'));
|
||||
} else {
|
||||
await alertApi.createContactGroup(payload);
|
||||
toast.success('联系组已添加');
|
||||
toast.success(t('monitor.contactgroupdialog.sda7160'));
|
||||
}
|
||||
onOpenChange(false);
|
||||
onSaved();
|
||||
} catch {
|
||||
toast.error('保存失败');
|
||||
toast.error(t('common.saveFailed'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -84,31 +86,31 @@ export function ContactGroupDialog({
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingGroup ? '编辑报警联系组' : '添加报警联系组'}</DialogTitle>
|
||||
<DialogDescription>配置报警联系组,选择组内成员</DialogDescription>
|
||||
<DialogTitle>{editingGroup ? t('monitor.contactgroupdialog.s5fb6c5') : t('monitor.contactgroupdialog.s462488')}</DialogTitle>
|
||||
<DialogDescription>{t('monitor.contactgroupdialog.s112855')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>组名称 *</Label>
|
||||
<Label>{t('monitor.contactgroupdialog.s0e18f7')}</Label>
|
||||
<Input
|
||||
value={form.group_name}
|
||||
onChange={(e) => setForm({ ...form, group_name: e.target.value })}
|
||||
placeholder="如:运维值班组"
|
||||
placeholder=t('monitor.contactgroupdialog.s937123')
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>备注</Label>
|
||||
<Label>{t('common.remark')}</Label>
|
||||
<Textarea
|
||||
value={form.notes}
|
||||
onChange={(e) => setForm({ ...form, notes: e.target.value })}
|
||||
placeholder="如:周一至周五 9:00-21:00"
|
||||
placeholder=t('monitor.contactgroupdialog.s19f365')
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>选择联系人</Label>
|
||||
<Label>{t('monitor.contactgroupdialog.s3dada7')}</Label>
|
||||
{contacts.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">暂无联系人,请先在「报警联系人」标签页添加</p>
|
||||
<p className="text-xs text-muted-foreground">{t('monitor.contactgroupdialog.s2bade1')}</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1.5 max-h-[180px] overflow-y-auto border rounded-md p-2">
|
||||
{contacts.map(c => (
|
||||
@@ -130,9 +132,9 @@ export function ContactGroupDialog({
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>取消</Button>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>{t('common.cancel')}</Button>
|
||||
<Button onClick={handleSave} disabled={!form.group_name.trim() || saving}>
|
||||
{saving ? '保存中...' : editingGroup ? '保存修改' : '添加联系组'}
|
||||
{saving ? i18n.t('k8s.topology.saving') : editingGroup ? i18n.t('cloud.saveChanges') : i18n.t('alert.addGroup')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Loader2, RefreshCw, Server, Database, Globe, Network } from 'lucide-react';
|
||||
import { alertMetricsApi, type MetricCatalogCategory } from '@/api';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const CATEGORY_ORDER = ['host', 'middleware', 'snmp', 'site'];
|
||||
|
||||
@@ -16,6 +17,7 @@ const categoryIcons: Record<string, typeof Server> = {
|
||||
};
|
||||
|
||||
export function MetricCatalogTab() {
|
||||
const { t } = useTranslation();
|
||||
const [categories, setCategories] = useState<MetricCatalogCategory[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeCat, setActiveCat] = useState<string>('host');
|
||||
@@ -88,13 +90,13 @@ export function MetricCatalogTab() {
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
<th className="px-2 py-2 text-left font-medium">指标</th>
|
||||
<th className="px-2 py-2 text-left font-medium">单位</th>
|
||||
<th className="px-2 py-2 text-left font-medium">类型</th>
|
||||
<th className="px-2 py-2 text-left font-medium">警告阈值</th>
|
||||
<th className="px-2 py-2 text-left font-medium">严重阈值</th>
|
||||
<th className="px-2 py-2 text-left font-medium">上限</th>
|
||||
<th className="px-2 py-2 text-left font-medium">说明</th>
|
||||
<th className="px-2 py-2 text-left font-medium">{t('cloud.colMetric')}</th>
|
||||
<th className="px-2 py-2 text-left font-medium">{t('common.unit')}</th>
|
||||
<th className="px-2 py-2 text-left font-medium">{t('common.type')}</th>
|
||||
<th className="px-2 py-2 text-left font-medium">{t('monitor.metriccatalogtab.s037989')}</th>
|
||||
<th className="px-2 py-2 text-left font-medium">{t('monitor.metriccatalogtab.s3780c1')}</th>
|
||||
<th className="px-2 py-2 text-left font-medium">{t('monitor.metriccatalogtab.s726bfb')}</th>
|
||||
<th className="px-2 py-2 text-left font-medium">{t('cloud.colDescription')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { alertMetricsApi, type MetricCatalogItem } from '@/api';
|
||||
import { SEVERITY_OPTIONS } from '@/components/agent/agentAlertHelper';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import i18n from '@/i18n';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
@@ -25,14 +27,15 @@ interface Props {
|
||||
}
|
||||
|
||||
const OPERATORS = [
|
||||
{ value: '>', label: '> 大于' },
|
||||
{ value: '<', label: '< 小于' },
|
||||
{ value: '>=', label: '≥ 大于等于' },
|
||||
{ value: '<=', label: '≤ 小于等于' },
|
||||
{ value: '==', label: '= 等于' },
|
||||
{ value: '>', label: i18n.t('monitor.monitorruledialog.s552612') },
|
||||
{ value: '<', label: i18n.t('monitor.monitorruledialog.sdd29ed') },
|
||||
{ value: '>=', label: i18n.t('monitor.monitorruledialog.sef2679') },
|
||||
{ value: '<=', label: i18n.t('monitor.monitorruledialog.s847d07') },
|
||||
{ value: '==', label: i18n.t('monitor.monitorruledialog.s9cd6c2') },
|
||||
];
|
||||
|
||||
export function MonitorRuleDialog({ open, onOpenChange, category, metrics, editing, onSaved }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [form, setForm] = useState({
|
||||
name: '', metric: '', operator: '>', threshold: '0', severity: 'warning', enabled: true,
|
||||
});
|
||||
@@ -72,7 +75,7 @@ export function MonitorRuleDialog({ open, onOpenChange, category, metrics, editi
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form.name.trim() || !form.metric) {
|
||||
toast.error('请填写规则名称和指标');
|
||||
toast.error(t('monitor.monitorruledialog.sace3bf'));
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
@@ -84,22 +87,22 @@ export function MonitorRuleDialog({ open, onOpenChange, category, metrics, editi
|
||||
};
|
||||
if (editing) {
|
||||
await alertMetricsApi.updateMonitorRule(editing.id, payload);
|
||||
toast.success('规则已更新');
|
||||
toast.success(t('monitor.monitorruledialog.sa716ef'));
|
||||
} else {
|
||||
await alertMetricsApi.createMonitorRule(payload);
|
||||
toast.success('规则已创建');
|
||||
toast.success(t('monitor.monitorruledialog.s5702a3'));
|
||||
}
|
||||
onOpenChange(false);
|
||||
onSaved();
|
||||
} catch (e) {
|
||||
toast.error((e as { message?: string }).message || '保存失败');
|
||||
toast.error((e as { message?: string }).message || t('common.saveFailed'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const catLabels: Record<string, string> = {
|
||||
middleware: '中间件', snmp: 'SNMP', site: '站点拨测',
|
||||
middleware: t('dashboard.ops.middleware'), snmp: 'SNMP', site: t('monitor.siteProbe'),
|
||||
};
|
||||
const catLabel = catLabels[category] || category;
|
||||
|
||||
@@ -107,18 +110,18 @@ export function MonitorRuleDialog({ open, onOpenChange, category, metrics, editi
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editing ? '编辑' : '新建'}{catLabel}告警规则</DialogTitle>
|
||||
<DialogTitle>{editing ? t('common.edit') : t('monitor.dashboard.custom.create')}{catLabel}告警规则</DialogTitle>
|
||||
<DialogDescription className="text-[var(--dg-text-muted)]">
|
||||
基于监控中心采集指标配置阈值告警
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-3 py-2">
|
||||
<div className="grid gap-1.5">
|
||||
<Label>规则名称</Label>
|
||||
<Input value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} placeholder="例如:Redis 连接数过高" />
|
||||
<Label>{t('monitor.ruleName')}</Label>
|
||||
<Input value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} placeholder=t('monitor.monitorruledialog.s23b9f8') />
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label>监控指标</Label>
|
||||
<Label>{t('monitor.monitorruledialog.s9a51fb')}</Label>
|
||||
<Select value={form.metric} onValueChange={handleMetricChange}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -133,7 +136,7 @@ export function MonitorRuleDialog({ open, onOpenChange, category, metrics, editi
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid gap-1.5">
|
||||
<Label>运算符</Label>
|
||||
<Label>{t('monitor.ruleOperator')}</Label>
|
||||
<Select value={form.operator} onValueChange={v => setForm({ ...form, operator: v })}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -149,7 +152,7 @@ export function MonitorRuleDialog({ open, onOpenChange, category, metrics, editi
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label>严重程度</Label>
|
||||
<Label>{t('monitor.ruleSeverity')}</Label>
|
||||
<Select value={form.severity} onValueChange={v => setForm({ ...form, severity: v })}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -159,11 +162,11 @@ export function MonitorRuleDialog({ open, onOpenChange, category, metrics, editi
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch checked={form.enabled} onCheckedChange={v => setForm({ ...form, enabled: v })} />
|
||||
<Label>启用规则</Label>
|
||||
<Label>{t('monitor.monitorruledialog.sfd2ea0')}</Label>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>取消</Button>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>{t('common.cancel')}</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving && <Loader2 className="h-4 w-4 animate-spin mr-1" />} 保存
|
||||
</Button>
|
||||
|
||||
@@ -28,19 +28,21 @@ export interface NotifyPolicy {
|
||||
}
|
||||
|
||||
import type { NotifyMessageSettings } from '@/api/monitor/alert';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import i18n from '@/i18n';
|
||||
|
||||
type PolicyDraft = { enabled: boolean; groupIds: number[]; contactIds: number[] };
|
||||
|
||||
const CATEGORIES = [
|
||||
{ id: 'host' as const, label: '主机监控', desc: 'Agent 采集 CPU/内存/磁盘/离线等', icon: Server },
|
||||
{ id: 'middleware' as const, label: '中间件监控', desc: 'Redis/MySQL 等指标告警', icon: Database },
|
||||
{ id: 'site' as const, label: '站点拨测', desc: 'HTTP/TCP/DNS 拨测失败与延迟', icon: Globe },
|
||||
{ id: 'snmp' as const, label: 'SNMP 监控', desc: 'H3C/华为/Cisco/锐捷/Dell/浪潮', icon: Network },
|
||||
{ id: 'host' as const, label: i18n.t('monitor.hostMonitor'), desc: i18n.t('monitor.notifypolicytab.s055f5a'), icon: Server },
|
||||
{ id: 'middleware' as const, label: i18n.t('monitor.middleware'), desc: i18n.t('monitor.notifypolicytab.s420c7c'), icon: Database },
|
||||
{ id: 'site' as const, label: i18n.t('monitor.siteProbe'), desc: i18n.t('monitor.notifypolicytab.sbe6b21'), icon: Globe },
|
||||
{ id: 'snmp' as const, label: i18n.t('monitor.snmp.title'), desc: i18n.t('monitor.notifypolicytab.sa48251'), icon: Network },
|
||||
];
|
||||
|
||||
const DEFAULT_TITLE = '[{{severity_label}}] {{rule_name}} — {{hostname}} ({{metric}})';
|
||||
const DEFAULT_FOOTER = '{{time}} | {{platform}}';
|
||||
const DEFAULT_PLATFORM = 'AIOps 智能运维平台';
|
||||
const DEFAULT_PLATFORM = i18n.t('login.title');
|
||||
|
||||
const TEMPLATE_VARS = [
|
||||
'{{time}}', '{{platform}}', '{{rule_name}}', '{{hostname}}',
|
||||
@@ -62,6 +64,7 @@ function draftKey(d: PolicyDraft) {
|
||||
}
|
||||
|
||||
export function NotifyPolicyTab() {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testing, setTesting] = useState<string | null>(null);
|
||||
@@ -125,7 +128,7 @@ export function NotifyPolicyTab() {
|
||||
setMsgSettings(merged);
|
||||
setSavedMsgSettings(merged);
|
||||
} catch {
|
||||
toast.error('加载通知策略失败');
|
||||
toast.error(t('monitor.notifypolicytab.s221fbe'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -183,10 +186,10 @@ export function NotifyPolicyTab() {
|
||||
}),
|
||||
alertApi.saveNotifyMessageSettings(msgSettings),
|
||||
]);
|
||||
toast.success('通知配置已保存');
|
||||
toast.success(t('monitor.notifypolicytab.sb1226a'));
|
||||
load();
|
||||
} catch (e) {
|
||||
toast.error((e as { message?: string }).message || '保存失败');
|
||||
toast.error((e as { message?: string }).message || t('common.saveFailed'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -196,15 +199,15 @@ export function NotifyPolicyTab() {
|
||||
setTesting(category);
|
||||
try {
|
||||
await alertApi.testNotifyPolicy(category);
|
||||
toast.success('测试通知已发送');
|
||||
toast.success(t('monitor.notifypolicytab.s1e710e'));
|
||||
} catch (e) {
|
||||
toast.error((e as { message?: string }).message || '测试失败,请检查策略与联系人');
|
||||
toast.error((e as { message?: string }).message || t('monitor.notifypolicytab.s84573c'));
|
||||
} finally {
|
||||
setTesting(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <PageLoading label="加载通知策略..." />;
|
||||
if (loading) return <PageLoading label=t('monitor.notifypolicytab.s37ea07') />;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -263,7 +266,7 @@ export function NotifyPolicyTab() {
|
||||
<CardContent className="space-y-3">
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-[var(--dg-text-secondary)]">标题模版</Label>
|
||||
<Label className="text-xs text-[var(--dg-text-secondary)]">{t('monitor.notifypolicytab.s8e32c9')}</Label>
|
||||
<Input
|
||||
className="dg-input text-xs"
|
||||
value={msgSettings.title_template}
|
||||
@@ -272,7 +275,7 @@ export function NotifyPolicyTab() {
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-[var(--dg-text-secondary)]">平台署名</Label>
|
||||
<Label className="text-xs text-[var(--dg-text-secondary)]">{t('monitor.notifypolicytab.s8fae79')}</Label>
|
||||
<Input
|
||||
className="dg-input text-xs"
|
||||
value={msgSettings.platform_name}
|
||||
@@ -282,7 +285,7 @@ export function NotifyPolicyTab() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-[var(--dg-text-secondary)]">卡片底部</Label>
|
||||
<Label className="text-xs text-[var(--dg-text-secondary)]">{t('monitor.notifypolicytab.s9da0d8')}</Label>
|
||||
<Input
|
||||
className="dg-input text-xs"
|
||||
value={msgSettings.footer_template}
|
||||
@@ -319,7 +322,7 @@ export function NotifyPolicyTab() {
|
||||
<div>
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
{cat.label}
|
||||
{catDirty && <span className="text-[10px] text-[var(--dg-orange)]">未保存</span>}
|
||||
{catDirty && <span className="text-[10px] text-[var(--dg-orange)]">{t('common.unsaved')}</span>}
|
||||
</CardTitle>
|
||||
<p className="text-[10px] text-[var(--dg-text-muted)] mt-0.5">{cat.desc}</p>
|
||||
</div>
|
||||
@@ -333,16 +336,16 @@ export function NotifyPolicyTab() {
|
||||
}))}
|
||||
/>
|
||||
<Label className="text-xs text-[var(--dg-text-secondary)] min-w-[2rem]">
|
||||
{d.enabled ? '启用' : '停用'}
|
||||
{d.enabled ? t('common.enable') : t('common.deactivate')}
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div>
|
||||
<Label className="text-xs text-[var(--dg-text-secondary)]">通知联系组</Label>
|
||||
<Label className="text-xs text-[var(--dg-text-secondary)]">{t('monitor.notifypolicytab.sad2dee')}</Label>
|
||||
{groups.length === 0 ? (
|
||||
<p className="text-[10px] text-[var(--dg-text-muted)] mt-1">暂无联系组</p>
|
||||
<p className="text-[10px] text-[var(--dg-text-muted)] mt-1">{t('monitor.noGroups')}</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1.5 mt-1.5">
|
||||
{groups.map(g => (
|
||||
@@ -361,9 +364,9 @@ export function NotifyPolicyTab() {
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-[var(--dg-text-secondary)]">额外联系人</Label>
|
||||
<Label className="text-xs text-[var(--dg-text-secondary)]">{t('monitor.notifypolicytab.s762805')}</Label>
|
||||
{contacts.length === 0 ? (
|
||||
<p className="text-[10px] text-[var(--dg-text-muted)] mt-1">暂无联系人</p>
|
||||
<p className="text-[10px] text-[var(--dg-text-muted)] mt-1">{t('monitor.noContacts')}</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1.5 mt-1.5">
|
||||
{contacts.map(c => (
|
||||
|
||||
@@ -12,15 +12,17 @@ import {
|
||||
import { toast } from 'sonner';
|
||||
import { alertCenterApi } from '@/api';
|
||||
import type { JsonData } from '@/types/common';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import i18n from '@/i18n';
|
||||
|
||||
// 静默类型中文标签
|
||||
const SILENCE_TYPE_LABELS: Record<string, string> = {
|
||||
onetime: '一次性',
|
||||
daily: '每天循环',
|
||||
weekly: '每周循环',
|
||||
onetime: i18n.t('monitor.silencedialog.sebc149'),
|
||||
daily: i18n.t('monitor.silencedialog.s256eba'),
|
||||
weekly: i18n.t('monitor.silencedialog.s4dff11'),
|
||||
};
|
||||
|
||||
const WEEKDAY_LABELS = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
|
||||
const WEEKDAY_LABELS = [i18n.t('monitor.silencedialog.s562d74'), i18n.t('monitor.silencedialog.s1603b0'), i18n.t('monitor.silencedialog.sb5a6a0'), i18n.t('monitor.silencedialog.se60725'), i18n.t('monitor.silencedialog.s170fc8'), i18n.t('monitor.silencedialog.seb79ce'), i18n.t('monitor.silencedialog.s245751')];
|
||||
|
||||
export interface SilenceData {
|
||||
id?: number;
|
||||
@@ -53,6 +55,7 @@ export function SilenceDialog({
|
||||
editingSilence?: JsonData;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
@@ -99,15 +102,15 @@ export function SilenceDialog({
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!name.trim()) {
|
||||
toast.error('请填写规则名称');
|
||||
toast.error(t('monitor.silencedialog.s411cac'));
|
||||
return;
|
||||
}
|
||||
if (!endTime) {
|
||||
toast.error('请设置结束时间');
|
||||
toast.error(t('monitor.silencedialog.s2ad542'));
|
||||
return;
|
||||
}
|
||||
if (silenceType === 'weekly' && weekdays.length === 0) {
|
||||
toast.error('每周静默需要选择至少一个星期');
|
||||
toast.error(t('monitor.silencedialog.s1f832d'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -124,15 +127,15 @@ export function SilenceDialog({
|
||||
try {
|
||||
if (editingSilence) {
|
||||
await alertCenterApi.updateSilence(editingSilence.id, payload);
|
||||
toast.success('静默规则已更新');
|
||||
toast.success(t('monitor.silencedialog.s1cf989'));
|
||||
} else {
|
||||
await alertCenterApi.createSilence(payload);
|
||||
toast.success('静默规则已创建');
|
||||
toast.success(t('monitor.silencedialog.s56a18d'));
|
||||
}
|
||||
onOpenChange(false);
|
||||
onSaved();
|
||||
} catch {
|
||||
toast.error('保存失败');
|
||||
toast.error(t('common.saveFailed'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -143,7 +146,7 @@ export function SilenceDialog({
|
||||
<DialogContent showOverlay={false} className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-[var(--dg-text-primary)]">
|
||||
{editingSilence ? '编辑静默规则' : '创建静默规则'}
|
||||
{editingSilence ? t('monitor.silencedialog.sab13fe') : t('monitor.silencedialog.s22ebe5')}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-[var(--dg-text-secondary)]">
|
||||
静默期间匹配的告警将不会触发通知
|
||||
@@ -152,29 +155,29 @@ export function SilenceDialog({
|
||||
<div className="space-y-4 max-h-[60vh] overflow-y-auto pr-1">
|
||||
{/* 名称 */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[var(--dg-text-primary)]">规则名称 *</Label>
|
||||
<Label className="text-[var(--dg-text-primary)]">{t('monitor.silencedialog.s34fe90')}</Label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="如:维护窗口静默"
|
||||
placeholder=t('monitor.silencedialog.s397662')
|
||||
className="bg-[var(--dg-bg-primary)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 描述 */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[var(--dg-text-primary)]">描述</Label>
|
||||
<Label className="text-[var(--dg-text-primary)]">{t('common.description')}</Label>
|
||||
<Input
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="可选备注"
|
||||
placeholder=t('monitor.silencedialog.se67484')
|
||||
className="bg-[var(--dg-bg-primary)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 静默类型 */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[var(--dg-text-primary)]">静默类型</Label>
|
||||
<Label className="text-[var(--dg-text-primary)]">{t('monitor.silencedialog.s52aec8')}</Label>
|
||||
<Select value={silenceType} onValueChange={setSilenceType}>
|
||||
<SelectTrigger className="bg-[var(--dg-bg-primary)]">
|
||||
<SelectValue />
|
||||
@@ -191,7 +194,7 @@ export function SilenceDialog({
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[var(--dg-text-primary)]">
|
||||
{silenceType === 'onetime' ? '开始时间' : '每日开始'}
|
||||
{silenceType === 'onetime' ? t('common.startTime') : t('monitor.silencedialog.s48bccf')}
|
||||
</Label>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
@@ -202,7 +205,7 @@ export function SilenceDialog({
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[var(--dg-text-primary)]">
|
||||
{silenceType === 'onetime' ? '结束时间' : '每日结束'}
|
||||
{silenceType === 'onetime' ? t('common.endTime') : t('monitor.silencedialog.s18e2c5')}
|
||||
</Label>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
@@ -216,7 +219,7 @@ export function SilenceDialog({
|
||||
{/* 星期选择(仅 weekly) */}
|
||||
{silenceType === 'weekly' && (
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[var(--dg-text-primary)]">生效星期</Label>
|
||||
<Label className="text-[var(--dg-text-primary)]">{t('monitor.silencedialog.s859824')}</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{WEEKDAY_LABELS.map((label, idx) => (
|
||||
<button
|
||||
@@ -238,15 +241,15 @@ export function SilenceDialog({
|
||||
|
||||
{/* 提示 */}
|
||||
<div className="text-xs text-muted-foreground bg-muted/50 rounded-md p-2">
|
||||
{silenceType === 'onetime' && '在指定时间窗口内,所有匹配条件的告警将被静默。窗口结束后自动恢复正常。'}
|
||||
{silenceType === 'daily' && '每天在指定时段内静默告警,适合非工作时间免打扰。'}
|
||||
{silenceType === 'weekly' && '仅在选定星期的指定时段内静默,适合周末维护窗口。'}
|
||||
{silenceType === 'onetime' && t('monitor.silencedialog.s67c15d')}
|
||||
{silenceType === 'daily' && t('monitor.silencedialog.s2e62d4')}
|
||||
{silenceType === 'weekly' && t('monitor.silencedialog.s5a3033')}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>取消</Button>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>{t('common.cancel')}</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving ? '保存中…' : '保存'}
|
||||
{saving ? i18n.t('monitor.silencedialog.s9b5d32') : i18n.t('common.save')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -10,14 +10,16 @@ import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||
import { alertCenterApi } from '@/api';
|
||||
import { SilenceDialog } from './SilenceDialog';
|
||||
import type { JsonData } from '@/types/common';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import i18n from '@/i18n';
|
||||
|
||||
const SILENCE_TYPE_LABELS: Record<string, string> = {
|
||||
onetime: '一次性',
|
||||
daily: '每天',
|
||||
weekly: '每周',
|
||||
onetime: i18n.t('monitor.silenceruletab.sebc149'),
|
||||
daily: i18n.t('monitor.silenceruletab.s78623e'),
|
||||
weekly: i18n.t('monitor.silenceruletab.s09b03b'),
|
||||
};
|
||||
|
||||
const WEEKDAY_LABELS = ['日', '一', '二', '三', '四', '五', '六'];
|
||||
const WEEKDAY_LABELS = [i18n.t('monitor.silenceruletab.s3edddd'), i18n.t('monitor.silenceruletab.s7941da'), i18n.t('monitor.silenceruletab.s2d8be2'), i18n.t('monitor.silenceruletab.se662ff'), i18n.t('monitor.silenceruletab.s21716c'), i18n.t('monitor.silenceruletab.s1fcc29'), i18n.t('monitor.silenceruletab.s61b453')];
|
||||
|
||||
function formatTime(t: string | Date): string {
|
||||
if (!t) return '—';
|
||||
@@ -30,6 +32,7 @@ function formatTime(t: string | Date): string {
|
||||
}
|
||||
|
||||
const SilenceRuleTab: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const confirm = useConfirm();
|
||||
const [silences, setSilences] = useState<JsonData[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -43,7 +46,7 @@ const SilenceRuleTab: React.FC = () => {
|
||||
const list = res?.list || res?.data?.list || [];
|
||||
setSilences(list);
|
||||
} catch {
|
||||
toast.error('获取静默规则失败');
|
||||
toast.error(t('monitor.silenceruletab.s48fb7a'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -65,28 +68,28 @@ const SilenceRuleTab: React.FC = () => {
|
||||
|
||||
const handleDelete = async (silence: JsonData) => {
|
||||
const ok = await confirm({
|
||||
title: '删除静默规则',
|
||||
description: `确定删除「${silence.name}」?删除后该规则匹配的告警将恢复通知。`,
|
||||
confirmText: '删除',
|
||||
title: t('monitor.silenceruletab.s7c9a53'),
|
||||
description: t('monitor.silenceruletab.s4fe220', { silence.name: silence.name }),
|
||||
confirmText: t('common.delete'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await alertCenterApi.deleteSilence(silence.id);
|
||||
toast.success('已删除');
|
||||
toast.success(t('alert.deleted'));
|
||||
fetchSilences();
|
||||
} catch {
|
||||
toast.error('删除失败');
|
||||
toast.error(t('common.deleteFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggle = async (silence: JsonData) => {
|
||||
try {
|
||||
await alertCenterApi.toggleSilence(silence.id);
|
||||
toast.success(silence.status === 1 ? '已禁用' : '已启用');
|
||||
toast.success(silence.status === 1 ? t('alert.disabled') : t('alert.enabled'));
|
||||
fetchSilences();
|
||||
} catch {
|
||||
toast.error('操作失败');
|
||||
toast.error(t('common.operationFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -122,13 +125,13 @@ const SilenceRuleTab: React.FC = () => {
|
||||
{/* 规则列表 */}
|
||||
{loading && silences.length === 0 ? (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<p className="text-sm">加载中...</p>
|
||||
<p className="text-sm">{t('common.loading')}</p>
|
||||
</div>
|
||||
) : silences.length === 0 ? (
|
||||
<div className="text-center py-12 text-muted-foreground border border-dashed rounded-lg">
|
||||
<VolumeX className="w-10 h-10 mx-auto mb-2 opacity-50" />
|
||||
<p className="text-sm">暂无静默规则</p>
|
||||
<p className="text-xs mt-1">点击「添加静默规则」创建维护窗口或免打扰时段</p>
|
||||
<p className="text-sm">{t('monitor.noSilences')}</p>
|
||||
<p className="text-xs mt-1">{t('monitor.silenceruletab.s862a1b')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
@@ -156,10 +159,10 @@ const SilenceRuleTab: React.FC = () => {
|
||||
</Badge>
|
||||
{/* 活跃状态 */}
|
||||
{active && (
|
||||
<Badge className="bg-green-500 text-xs">生效中</Badge>
|
||||
<Badge className="bg-green-500 text-xs">{t('monitor.silenceActive')}</Badge>
|
||||
)}
|
||||
{!enabled && (
|
||||
<Badge variant="secondary" className="text-xs">已禁用</Badge>
|
||||
<Badge variant="secondary" className="text-xs">{t('alert.disabled')}</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -172,7 +175,7 @@ const SilenceRuleTab: React.FC = () => {
|
||||
<span>
|
||||
{sType === 'onetime'
|
||||
? `${formatTime(s.start_time)} — ${formatTime(s.end_time)}`
|
||||
: `每日 ${formatTime(s.start_time)} — ${formatTime(s.end_time)}`
|
||||
: t('monitor.silenceruletab.s8bdc6c', { formatTime(s.start_time): formatTime(s.start_time), formatTime(s.end_time): formatTime(s.end_time) })
|
||||
}
|
||||
</span>
|
||||
{sType === 'weekly' && weekdayList.length > 0 && (
|
||||
|
||||
@@ -10,14 +10,16 @@ import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||
import { alertCenterApi } from '@/api';
|
||||
import { SilenceDialog } from './SilenceDialog';
|
||||
import type { JsonData } from '@/types/common';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import i18n from '@/i18n';
|
||||
|
||||
const SILENCE_TYPE_LABELS: Record<string, string> = {
|
||||
onetime: '一次性',
|
||||
daily: '每天',
|
||||
weekly: '每周',
|
||||
onetime: i18n.t('monitor.silencerulespanel.sebc149'),
|
||||
daily: i18n.t('monitor.silencerulespanel.s78623e'),
|
||||
weekly: i18n.t('monitor.silencerulespanel.s09b03b'),
|
||||
};
|
||||
|
||||
const WEEKDAY_LABELS = ['日', '一', '二', '三', '四', '五', '六'];
|
||||
const WEEKDAY_LABELS = [i18n.t('monitor.silencerulespanel.s3edddd'), i18n.t('monitor.silencerulespanel.s7941da'), i18n.t('monitor.silencerulespanel.s2d8be2'), i18n.t('monitor.silencerulespanel.se662ff'), i18n.t('monitor.silencerulespanel.s21716c'), i18n.t('monitor.silencerulespanel.s1fcc29'), i18n.t('monitor.silencerulespanel.s61b453')];
|
||||
|
||||
function formatTime(t: string | Date): string {
|
||||
if (!t) return '—';
|
||||
@@ -30,6 +32,7 @@ function formatTime(t: string | Date): string {
|
||||
}
|
||||
|
||||
export function SilenceRulesPanel() {
|
||||
const { t } = useTranslation();
|
||||
const confirm = useConfirm();
|
||||
const [silences, setSilences] = useState<JsonData[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -43,7 +46,7 @@ export function SilenceRulesPanel() {
|
||||
const list = res?.list || res?.data?.list || [];
|
||||
setSilences(list);
|
||||
} catch {
|
||||
toast.error('获取静默规则失败');
|
||||
toast.error(t('monitor.silencerulespanel.s48fb7a'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -53,28 +56,28 @@ export function SilenceRulesPanel() {
|
||||
|
||||
const handleDelete = async (silence: JsonData) => {
|
||||
const ok = await confirm({
|
||||
title: '删除静默规则',
|
||||
description: `确定删除「${silence.name}」?`,
|
||||
confirmText: '删除',
|
||||
title: t('monitor.silencerulespanel.s7c9a53'),
|
||||
description: t('monitor.silencerulespanel.seff173', { silence.name: silence.name }),
|
||||
confirmText: t('common.delete'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await alertCenterApi.deleteSilence(silence.id);
|
||||
toast.success('已删除');
|
||||
toast.success(t('alert.deleted'));
|
||||
fetchSilences();
|
||||
} catch {
|
||||
toast.error('删除失败');
|
||||
toast.error(t('common.deleteFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggle = async (silence: JsonData) => {
|
||||
try {
|
||||
await alertCenterApi.toggleSilence(silence.id);
|
||||
toast.success(silence.status === 1 ? '已禁用' : '已启用');
|
||||
toast.success(silence.status === 1 ? t('alert.disabled') : t('alert.enabled'));
|
||||
fetchSilences();
|
||||
} catch {
|
||||
toast.error('操作失败');
|
||||
toast.error(t('common.operationFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -90,8 +93,8 @@ export function SilenceRulesPanel() {
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<VolumeX className="w-4 h-4 text-muted-foreground" />
|
||||
<h3 className="text-sm font-medium">静默规则</h3>
|
||||
<span className="text-xs text-muted-foreground">维护窗口 / 免打扰时段</span>
|
||||
<h3 className="text-sm font-medium">{t('monitor.silences')}</h3>
|
||||
<span className="text-xs text-muted-foreground">{t('monitor.silencerulespanel.sed566e')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={fetchSilences} disabled={loading}>
|
||||
@@ -104,10 +107,10 @@ export function SilenceRulesPanel() {
|
||||
</div>
|
||||
|
||||
{loading && silences.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground text-center py-6">加载中...</p>
|
||||
<p className="text-xs text-muted-foreground text-center py-6">{t('common.loading')}</p>
|
||||
) : silences.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground border border-dashed border-[var(--dg-border)] rounded-lg">
|
||||
<p className="text-xs">暂无静默规则,告警将按通知策略正常发送</p>
|
||||
<p className="text-xs">{t('monitor.silencerulespanel.s537761')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
@@ -128,13 +131,13 @@ export function SilenceRulesPanel() {
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{SILENCE_TYPE_LABELS[sType] || sType}
|
||||
</Badge>
|
||||
{active && <Badge className="bg-green-500 text-[10px]">生效中</Badge>}
|
||||
{!enabled && <Badge variant="secondary" className="text-[10px]">已禁用</Badge>}
|
||||
{active && <Badge className="bg-green-500 text-[10px]">{t('monitor.silenceActive')}</Badge>}
|
||||
{!enabled && <Badge variant="secondary" className="text-[10px]">{t('alert.disabled')}</Badge>}
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{sType === 'onetime'
|
||||
? `${formatTime(s.start_time)} — ${formatTime(s.end_time)}`
|
||||
: `每日 ${formatTime(s.start_time)} — ${formatTime(s.end_time)}`}
|
||||
: t('monitor.silencerulespanel.s8bdc6c', { formatTime(s.start_time): formatTime(s.start_time), formatTime(s.end_time): formatTime(s.end_time) })}
|
||||
{sType === 'weekly' && weekdayList.length > 0 && (
|
||||
<span className="ml-2">
|
||||
周{weekdayList.sort((a, b) => a - b).map((d) => WEEKDAY_LABELS[d]).join('')}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import type { JsonData } from '@/types/common';
|
||||
import i18n from '@/i18n';
|
||||
|
||||
/** 前端 Alert 接口 */
|
||||
export interface Alert {
|
||||
@@ -77,7 +78,7 @@ export interface ContactGroup {
|
||||
}
|
||||
|
||||
const SOURCE_LABELS: Record<string, string> = {
|
||||
host: '主机监控', middleware: '中间件监控', site: '站点拨测', snmp: 'SNMP 监控', log: '日志告警',
|
||||
host: i18n.t('monitor.hostMonitor'), middleware: i18n.t('monitor.middleware'), site: i18n.t('monitor.siteProbe'), snmp: i18n.t('monitor.snmp.title'), log: i18n.t('menu.logAlerts'),
|
||||
};
|
||||
|
||||
// Logan.Li — 后端字段映射到前端 Alert 接口
|
||||
@@ -85,7 +86,7 @@ export function mapAlert(a: JsonData): Alert {
|
||||
const sourceType = String(a.source_type || a.sourceType || '');
|
||||
return {
|
||||
id: String(a.id),
|
||||
title: a.title || a.rule_name || `告警 #${a.id}`,
|
||||
title: a.title || a.rule_name || i18n.t('monitor.alertCenterHelpers.sd0abee', { a.id: a.id }),
|
||||
description: a.message || a.description || '',
|
||||
severity: (a.severity || 'info') as Alert['severity'],
|
||||
status: (a.status || 'firing') as Alert['status'],
|
||||
@@ -112,13 +113,13 @@ export function mapAlert(a: JsonData): Alert {
|
||||
export function getSeverityBadge(severity: string) {
|
||||
switch (severity) {
|
||||
case 'critical':
|
||||
return <Badge variant="destructive" className="gap-1"><AlertCircle className="w-3 h-3" />严重</Badge>;
|
||||
return <Badge variant="destructive" className="gap-1"><AlertCircle className="w-3 h-3" />{i18n.t('monitor.critical')}</Badge>;
|
||||
case 'warning':
|
||||
return <Badge variant="default" className="bg-orange-500 gap-1"><AlertTriangle className="w-3 h-3" />警告</Badge>;
|
||||
return <Badge variant="default" className="bg-orange-500 gap-1"><AlertTriangle className="w-3 h-3" />{i18n.t('common.warning')}</Badge>;
|
||||
case 'info':
|
||||
return <Badge variant="default" className="bg-blue-500 gap-1"><Info className="w-3 h-3" />信息</Badge>;
|
||||
return <Badge variant="default" className="bg-blue-500 gap-1"><Info className="w-3 h-3" />{i18n.t('monitor.info')}</Badge>;
|
||||
default:
|
||||
return <Badge variant="secondary">未知</Badge>;
|
||||
return <Badge variant="secondary">{i18n.t('common.unknown')}</Badge>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,15 +127,15 @@ export function getSeverityBadge(severity: string) {
|
||||
export function getStatusBadge(status: string) {
|
||||
switch (status) {
|
||||
case 'firing':
|
||||
return <Badge variant="destructive" className="gap-1 animate-pulse"><Zap className="w-3 h-3" />触发中</Badge>;
|
||||
return <Badge variant="destructive" className="gap-1 animate-pulse"><Zap className="w-3 h-3" />{i18n.t('monitor.firing')}</Badge>;
|
||||
case 'acknowledged':
|
||||
return <Badge variant="default" className="bg-yellow-500 gap-1"><User className="w-3 h-3" />已确认</Badge>;
|
||||
return <Badge variant="default" className="bg-yellow-500 gap-1"><User className="w-3 h-3" />{i18n.t('monitor.acknowledged')}</Badge>;
|
||||
case 'resolved':
|
||||
return <Badge variant="default" className="bg-green-500 gap-1"><CheckCircle className="w-3 h-3" />已解决</Badge>;
|
||||
return <Badge variant="default" className="bg-green-500 gap-1"><CheckCircle className="w-3 h-3" />{i18n.t('monitor.resolved')}</Badge>;
|
||||
case 'suppressed':
|
||||
return <Badge variant="secondary" className="gap-1"><VolumeX className="w-3 h-3" />已静默</Badge>;
|
||||
return <Badge variant="secondary" className="gap-1"><VolumeX className="w-3 h-3" />{i18n.t('monitor.alertCenterHelpers.s1325a8')}</Badge>;
|
||||
default:
|
||||
return <Badge variant="secondary">未知</Badge>;
|
||||
return <Badge variant="secondary">{i18n.t('common.unknown')}</Badge>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,8 +156,8 @@ export function getChannelIcon(type: string) {
|
||||
/** 渠道类型中文标签 */
|
||||
export function getChannelTypeLabel(type: string) {
|
||||
const map: Record<string, string> = {
|
||||
dingtalk: '钉钉', wechat: '企业微信', feishu: '飞书',
|
||||
email: '邮件', sms: '短信', webhook: 'Webhook',
|
||||
dingtalk: i18n.t('monitor.contactDingtalk'), wechat: i18n.t('monitor.contactWechat'), feishu: i18n.t('monitor.alertCenterHelpers.s7714e5'),
|
||||
email: i18n.t('common.email'), sms: i18n.t('common.sms'), webhook: 'Webhook',
|
||||
};
|
||||
return map[type] || type;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Mail, Phone, MessageSquare, Bell } from 'lucide-react';
|
||||
import type { AlertChannel, AlertRuleRecord } from '@/types';
|
||||
import i18n from '@/i18n';
|
||||
|
||||
// 将 notify_channels (可能是数组/JSON字符串/逗号分隔) 解析为数组
|
||||
export function parseChannels( notifyChannels: unknown): string[] {
|
||||
@@ -22,10 +23,10 @@ export const KNOWN_CHANNEL_TYPES = ['email', 'sms', 'dingtalk', 'wechat', 'webho
|
||||
|
||||
// 根据渠道类型获取图标组件名(用字符串映射,组件内用 getChannelIcon 返回实际组件)
|
||||
export const CHANNEL_TYPE_LABELS: Record<string, string> = {
|
||||
dingtalk: '钉钉',
|
||||
wechat: '企业微信',
|
||||
email: '邮件',
|
||||
sms: '短信',
|
||||
dingtalk: i18n.t('monitor.contactDingtalk'),
|
||||
wechat: i18n.t('monitor.contactWechat'),
|
||||
email: i18n.t('common.email'),
|
||||
sms: i18n.t('common.sms'),
|
||||
webhook: 'Webhook',
|
||||
};
|
||||
|
||||
@@ -35,7 +36,7 @@ export function resolveChannelRef(value: string, channels: AlertChannel[]): { ty
|
||||
if (!value) return null;
|
||||
// 尝试按 ID 匹配已配置渠道
|
||||
const ch = channels.find((c) => String(c.id) === String(value));
|
||||
if (ch) return { type: ch.type || 'unknown', name: ch.name || ch.type || '未命名', isLegacy: false };
|
||||
if (ch) return { type: ch.type || 'unknown', name: ch.name || ch.type || i18n.t('monitor.alertshared.s7f0425'), isLegacy: false };
|
||||
// 兼容旧格式:值本身是类型字符串
|
||||
if (KNOWN_CHANNEL_TYPES.includes(value)) {
|
||||
return { type: value, name: CHANNEL_TYPE_LABELS[value] || value, isLegacy: true };
|
||||
@@ -45,10 +46,10 @@ export function resolveChannelRef(value: string, channels: AlertChannel[]): { ty
|
||||
|
||||
// 渠道类型选项(供下拉选择)
|
||||
export const CHANNEL_TYPES: { key: string; label: string; placeholder: string }[] = [
|
||||
{ key: 'dingtalk', label: '钉钉', placeholder: '{\n "webhook": "https://oapi.dingtalk.com/robot/send?access_token=***",\n "secret": "SECxxx"\n}' },
|
||||
{ key: 'wechat', label: '企业微信', placeholder: '{\n "webhook": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=***"\n}' },
|
||||
{ key: 'email', label: '邮件', placeholder: '{\n "to": "ops@ezops.io",\n "subject": "告警通知"\n}' },
|
||||
{ key: 'sms', label: '短信', placeholder: '{\n "phones": "13800138000",\n "sign": "AIOps"\n}' },
|
||||
{ key: 'dingtalk', label: i18n.t('monitor.contactDingtalk'), placeholder: '{\n "webhook": "https://oapi.dingtalk.com/robot/send?access_token=***",\n "secret": "SECxxx"\n}' },
|
||||
{ key: 'wechat', label: i18n.t('monitor.contactWechat'), placeholder: '{\n "webhook": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=***"\n}' },
|
||||
{ key: 'email', label: i18n.t('common.email'), placeholder: i18n.t('monitor.alertshared.s92382d') },
|
||||
{ key: 'sms', label: i18n.t('common.sms'), placeholder: '{\n "phones": "13800138000",\n "sign": "AIOps"\n}' },
|
||||
{ key: 'webhook', label: 'Webhook', placeholder: '{\n "url": "https://example.com/alert"\n}' },
|
||||
];
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { TrendingUp, Loader2, AlertCircle } from 'lucide-react';
|
||||
import { LineChart, Line, ResponsiveContainer, XAxis, YAxis, Tooltip, CartesianGrid } from 'recharts';
|
||||
import type { Panel } from './GrafanaDashboard';
|
||||
import { prometheusProxyApi, promDatasourceApi } from '@/api';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
// 默认 Prometheus URL 缓存
|
||||
let _defaultPromURL: string | null = null;
|
||||
@@ -70,6 +71,7 @@ function parseInstantResult(promResult: any): { labels: Record<string, string>;
|
||||
|
||||
/** 渲染面板内容 */
|
||||
export function GrafanaPanelContent({ panel, datasourceUrl }: { panel: Panel; datasourceUrl?: string }) {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [chartData, setChartData] = useState<{ time: string; value: number }[]>([]);
|
||||
@@ -82,7 +84,7 @@ export function GrafanaPanelContent({ panel, datasourceUrl }: { panel: Panel; da
|
||||
try {
|
||||
const url = await getDefaultPromURL(datasourceUrl);
|
||||
if (!url) {
|
||||
setError('未配置 Prometheus 数据源');
|
||||
setError(t('monitor.dashboard.prometheus.emptyTitle'));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
@@ -100,16 +102,16 @@ export function GrafanaPanelContent({ panel, datasourceUrl }: { panel: Panel; da
|
||||
});
|
||||
const parsed = parseRangeResult(res);
|
||||
setChartData(parsed.length > 0 ? parsed : []);
|
||||
if (parsed.length === 0) setError('无数据');
|
||||
if (parsed.length === 0) setError(t('kubernetes.configmap.noData'));
|
||||
} else {
|
||||
// stat / gauge / table 用即时查询
|
||||
const res = await prometheusProxyApi.query(url, panel.query);
|
||||
const parsed = parseInstantResult(res);
|
||||
setInstantData(parsed);
|
||||
if (parsed.length === 0) setError('无数据');
|
||||
if (parsed.length === 0) setError(t('kubernetes.configmap.noData'));
|
||||
}
|
||||
} catch (e: any) {
|
||||
setError(e?.message ? String(e.message).slice(0, 80) : '查询失败');
|
||||
setError(e?.message ? String(e.message).slice(0, 80) : t('monitor.grafanapanelcontent.s0d66ed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -153,7 +155,7 @@ export function GrafanaPanelContent({ panel, datasourceUrl }: { panel: Panel; da
|
||||
{unit && <span className="text-sm text-[var(--dg-text-muted)] mt-2">{unit}</span>}
|
||||
<div className="flex items-center gap-1 mt-2 dg-text-green text-sm">
|
||||
<TrendingUp className="w-4 h-4" />
|
||||
<span>实时</span>
|
||||
<span>{t('common.realtime')}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -187,7 +189,7 @@ export function GrafanaPanelContent({ panel, datasourceUrl }: { panel: Panel; da
|
||||
);
|
||||
}
|
||||
case 'table': {
|
||||
if (instantData.length === 0) return <div className="text-center py-4 text-xs text-[var(--dg-text-muted)]">无数据</div>;
|
||||
if (instantData.length === 0) return <div className="text-center py-4 text-xs text-[var(--dg-text-muted)]">{t('kubernetes.configmap.noData')}</div>;
|
||||
return (
|
||||
<div className="overflow-auto max-h-[200px]">
|
||||
<table className="w-full text-[11px]">
|
||||
@@ -216,7 +218,7 @@ export function GrafanaPanelContent({ panel, datasourceUrl }: { panel: Panel; da
|
||||
case 'graph':
|
||||
default: {
|
||||
if (chartData.length === 0) {
|
||||
return <div className="text-center py-4 text-xs text-[var(--dg-text-muted)]">无数据</div>;
|
||||
return <div className="text-center py-4 text-xs text-[var(--dg-text-muted)]">{t('kubernetes.configmap.noData')}</div>;
|
||||
}
|
||||
return (
|
||||
<div className="h-full min-h-[120px]">
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
} from '@/components/ui/select';
|
||||
import { serverApi } from '@/api';
|
||||
import type { JsonData } from '@/types/common';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
type SortField = 'hostname' | 'ip' | 'agent_version' | 'agent_status' | 'cpu_usage' | 'memory_usage' | 'disk_usage' | 'region';
|
||||
type SortDir = 'asc' | 'desc';
|
||||
@@ -48,6 +49,7 @@ function getUsageBar(val: number) {
|
||||
}
|
||||
|
||||
export default function HostMonitorDashboard() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [hosts, setHosts] = useState<JsonData[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
@@ -141,9 +143,9 @@ export default function HostMonitorDashboard() {
|
||||
|
||||
// 状态标签颜色
|
||||
const statusBadge = (status: string) => {
|
||||
if (status === 'online') return <Badge className="bg-emerald-500/20 dg-text-green border-emerald-500/30 text-[11px]">在线</Badge>;
|
||||
if (status === 'running') return <Badge className="bg-blue-500/20 dg-text-blue border-blue-500/30 text-[11px]">运行中</Badge>;
|
||||
if (status === 'stopped') return <Badge className="bg-[var(--dg-bg-active)]/20 dg-text-secondary border-[var(--dg-border)] text-[11px]">已停止</Badge>;
|
||||
if (status === 'online') return <Badge className="bg-emerald-500/20 dg-text-green border-emerald-500/30 text-[11px]">{t('common.online')}</Badge>;
|
||||
if (status === 'running') return <Badge className="bg-blue-500/20 dg-text-blue border-blue-500/30 text-[11px]">{t('common.running')}</Badge>;
|
||||
if (status === 'stopped') return <Badge className="bg-[var(--dg-bg-active)]/20 dg-text-secondary border-[var(--dg-border)] text-[11px]">{t('common.stopped')}</Badge>;
|
||||
return <Badge variant="outline" className="text-[11px] dg-text-muted">{status || '--'}</Badge>;
|
||||
};
|
||||
|
||||
@@ -156,7 +158,7 @@ export default function HostMonitorDashboard() {
|
||||
<div className="p-1.5 rounded-lg bg-blue-500/10"><Server className="h-4 w-4 dg-text-blue" /></div>
|
||||
<div>
|
||||
<div className="text-xl font-bold text-[var(--dg-text-primary)]">{stats.total}</div>
|
||||
<div className="text-[10px] text-[var(--dg-text-muted)]">主机总数</div>
|
||||
<div className="text-[10px] text-[var(--dg-text-muted)]">{t('monitor.hostmonitordashboard.sd94cd2')}</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -165,7 +167,7 @@ export default function HostMonitorDashboard() {
|
||||
<div className="p-1.5 rounded-lg bg-emerald-500/10"><Wifi className="h-4 w-4 dg-text-green" /></div>
|
||||
<div>
|
||||
<div className="text-xl font-bold dg-text-green">{stats.online}</div>
|
||||
<div className="text-[10px] text-[var(--dg-text-muted)]">Agent 在线</div>
|
||||
<div className="text-[10px] text-[var(--dg-text-muted)]">{t('monitor.hostmonitordashboard.s42860c')}</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -174,7 +176,7 @@ export default function HostMonitorDashboard() {
|
||||
<div className="p-1.5 rounded-lg bg-[var(--dg-bg-active)]/10"><WifiOff className="h-4 w-4 dg-text-secondary" /></div>
|
||||
<div>
|
||||
<div className="text-xl font-bold dg-text-secondary">{stats.offline}</div>
|
||||
<div className="text-[10px] text-[var(--dg-text-muted)]">离线</div>
|
||||
<div className="text-[10px] text-[var(--dg-text-muted)]">{t('common.offline')}</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -190,7 +192,7 @@ export default function HostMonitorDashboard() {
|
||||
<div className="p-1.5 rounded-lg bg-red-500/10"><Cpu className="h-4 w-4 dg-text-red" /></div>
|
||||
<div>
|
||||
<div className={`text-xl font-bold ${cpuColor}`}>{avgCpu.toFixed(1)}<span className="text-sm">%</span></div>
|
||||
<div className="text-[10px] text-[var(--dg-text-muted)]">平均 CPU</div>
|
||||
<div className="text-[10px] text-[var(--dg-text-muted)]">{t('monitor.hostmonitordashboard.s16dd85')}</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -199,7 +201,7 @@ export default function HostMonitorDashboard() {
|
||||
<div className="p-1.5 rounded-lg bg-amber-500/10"><Server className="h-4 w-4 dg-text-yellow" /></div>
|
||||
<div>
|
||||
<div className={`text-xl font-bold ${memColor}`}>{avgMem.toFixed(1)}<span className="text-sm">%</span></div>
|
||||
<div className="text-[10px] text-[var(--dg-text-muted)]">平均内存</div>
|
||||
<div className="text-[10px] text-[var(--dg-text-muted)]">{t('monitor.hostmonitordashboard.sa09099')}</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -216,7 +218,7 @@ export default function HostMonitorDashboard() {
|
||||
<CardTitle className="text-sm flex items-center gap-2 text-[var(--dg-text-primary)]">
|
||||
<Cpu className="h-4 w-4 dg-text-red" />
|
||||
CPU 使用率 Top 5
|
||||
<Badge variant="outline" className="text-[10px] bg-red-500/10 dg-text-red border-red-500/30">阈值: 60% / 85%</Badge>
|
||||
<Badge variant="outline" className="text-[10px] bg-red-500/10 dg-text-red border-red-500/30">{t('monitor.hostmonitordashboard.sec309b')}</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
@@ -257,7 +259,7 @@ export default function HostMonitorDashboard() {
|
||||
<CardTitle className="text-sm flex items-center gap-2 text-[var(--dg-text-primary)]">
|
||||
<Server className="h-4 w-4 dg-text-yellow" />
|
||||
内存使用率 Top 5
|
||||
<Badge variant="outline" className="text-[10px] bg-amber-500/10 dg-text-yellow border-amber-500/30">阈值: 60% / 85%</Badge>
|
||||
<Badge variant="outline" className="text-[10px] bg-amber-500/10 dg-text-yellow border-amber-500/30">{t('monitor.hostmonitordashboard.sec309b')}</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
@@ -300,15 +302,15 @@ export default function HostMonitorDashboard() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className="h-5 w-5 dg-text-green" />
|
||||
<CardTitle className="text-base text-[var(--dg-text-primary)]">主机监控列表</CardTitle>
|
||||
<Badge variant="outline" className="text-xs bg-emerald-500/10 dg-text-green border-emerald-500/30">实时</Badge>
|
||||
<CardTitle className="text-base text-[var(--dg-text-primary)]">{t('monitor.hostmonitordashboard.s6aa711')}</CardTitle>
|
||||
<Badge variant="outline" className="text-xs bg-emerald-500/10 dg-text-green border-emerald-500/30">{t('common.realtime')}</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 dg-text-muted" />
|
||||
<Input
|
||||
className="w-[200px] pl-8 h-8 text-sm bg-[var(--dg-bg-primary)]"
|
||||
placeholder="搜索主机名/IP..."
|
||||
placeholder=t('monitor.hostmonitordashboard.s3d0fc0')
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
@@ -325,13 +327,13 @@ export default function HostMonitorDashboard() {
|
||||
{loading ? (
|
||||
<div className="py-16 text-center text-[var(--dg-text-muted)]">
|
||||
<Activity className="h-8 w-8 mx-auto mb-2 animate-spin dg-text-green" />
|
||||
<p className="text-sm">加载中...</p>
|
||||
<p className="text-sm">{t('common.loading')}</p>
|
||||
</div>
|
||||
) : sorted.length === 0 ? (
|
||||
<div className="py-16 text-center text-[var(--dg-text-muted)]">
|
||||
<Server className="h-10 w-10 mx-auto mb-2 dg-text-muted" />
|
||||
<p className="text-sm">暂无主机数据</p>
|
||||
<p className="text-xs mt-1">请先添加服务器,或等待 Agent 上报数据</p>
|
||||
<p className="text-sm">{t('monitor.hostmonitordashboard.s2678f4')}</p>
|
||||
<p className="text-xs mt-1">{t('monitor.hostmonitordashboard.s350255')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
@@ -340,14 +342,14 @@ export default function HostMonitorDashboard() {
|
||||
<thead>
|
||||
<tr className="border-b border-[var(--dg-border)]">
|
||||
{[
|
||||
{ key: 'hostname', label: '主机名/ID', w: '' },
|
||||
{ key: 'agent_version', label: 'Agent 版本', w: 'w-[100px]' },
|
||||
{ key: 'agent_status', label: 'Agent 状态', w: 'w-[90px]' },
|
||||
{ key: 'hostname', label: t('monitor.hostmonitordashboard.s7ba2a1'), w: '' },
|
||||
{ key: 'agent_version', label: t('monitor.systemInfo.agentVersion'), w: 'w-[100px]' },
|
||||
{ key: 'agent_status', label: t('monitor.hostmonitordashboard.secfbd1'), w: 'w-[90px]' },
|
||||
{ key: 'cpu_usage', label: 'CPU', w: 'w-[130px]' },
|
||||
{ key: 'memory_usage', label: '内存', w: 'w-[130px]' },
|
||||
{ key: 'disk_usage', label: '磁盘', w: 'w-[130px]' },
|
||||
{ key: 'region', label: '所在区域', w: 'w-[120px]' },
|
||||
{ key: 'ip', label: 'IP 地址', w: 'w-[140px]' },
|
||||
{ key: 'memory_usage', label: t('k8s.pods.memory'), w: 'w-[130px]' },
|
||||
{ key: 'disk_usage', label: t('dashboard.topUsage.disk'), w: 'w-[130px]' },
|
||||
{ key: 'region', label: t('monitor.hostmonitordashboard.sc09adb'), w: 'w-[120px]' },
|
||||
{ key: 'ip', label: t('agent.agentIp'), w: 'w-[140px]' },
|
||||
].map(col => (
|
||||
<th
|
||||
key={col.key}
|
||||
@@ -360,7 +362,7 @@ export default function HostMonitorDashboard() {
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
<th className="text-left px-3 py-2.5 text-xs font-medium text-[var(--dg-text-muted)]">操作</th>
|
||||
<th className="text-left px-3 py-2.5 text-xs font-medium text-[var(--dg-text-muted)]">{t('common.actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -423,7 +425,7 @@ export default function HostMonitorDashboard() {
|
||||
size="sm"
|
||||
className="h-7 text-xs dg-text-blue hover:text-blue-300 hover:bg-blue-500/10"
|
||||
onClick={() => navigate(`/monitor-center?host=${encodeURIComponent(h.hostname)}`)}
|
||||
title="查看监控图表"
|
||||
title=t('monitor.hostmonitordashboard.s2ee528')
|
||||
>
|
||||
<Monitor className="h-3 w-3 mr-1" />
|
||||
监控
|
||||
@@ -433,7 +435,7 @@ export default function HostMonitorDashboard() {
|
||||
size="sm"
|
||||
className="h-7 text-xs dg-text-yellow hover:text-amber-300 hover:bg-amber-500/10"
|
||||
onClick={() => navigate(`/alerts?hostname=${encodeURIComponent(h.hostname)}`)}
|
||||
title="查看报警规则"
|
||||
title=t('monitor.hostmonitordashboard.s356aa8')
|
||||
>
|
||||
<Bell className="h-3 w-3 mr-1" />
|
||||
告警
|
||||
@@ -449,7 +451,7 @@ export default function HostMonitorDashboard() {
|
||||
{/* 分页 */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-[var(--dg-border)]">
|
||||
<div className="flex items-center gap-2 text-xs text-[var(--dg-text-muted)]">
|
||||
<span>每页</span>
|
||||
<span>{t('common.perPage')}</span>
|
||||
<Select value={String(pageSize)} onValueChange={(v) => { setPageSize(Number(v)); setPage(1); }}>
|
||||
<SelectTrigger className="h-7 w-[60px] text-xs">
|
||||
<SelectValue />
|
||||
|
||||
@@ -8,10 +8,12 @@ import { StatStrip } from '@/components/ui/stat-strip';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { prometheusProxyApi } from '@/api';
|
||||
import type { JsonData } from '@/types/common';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface Props { dsUrl?: string; }
|
||||
|
||||
const MiniBar = ({ val, label, color }: { val: number; label: string; color?: string }) => {
|
||||
const { t } = useTranslation();
|
||||
const pct = Math.min(val, 100);
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 min-w-[80px]">
|
||||
@@ -46,6 +48,7 @@ function parsePrune( result: JsonData[]): JsonData[] {
|
||||
}
|
||||
|
||||
const Box = (p: JsonData) => <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/></svg>;
|
||||
const { t } = useTranslation();
|
||||
|
||||
const K8sPanel: React.FC<{ d: JsonData}> = ({ d }) => (
|
||||
<div className="space-y-4">
|
||||
@@ -58,7 +61,7 @@ const K8sPanel: React.FC<{ d: JsonData}> = ({ d }) => (
|
||||
]}
|
||||
/>
|
||||
<div className="rounded-lg bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] p-3 max-h-60 overflow-y-auto">
|
||||
<div className="text-xs font-medium mb-2 flex items-center gap-1"><Cpu className="w-3 h-3 dg-text-purple" />节点 CPU / 内存利用率</div>
|
||||
<div className="text-xs font-medium mb-2 flex items-center gap-1"><Cpu className="w-3 h-3 dg-text-purple" />{i18n.t('monitor.prometheusdashboard.se7fb2b')}</div>
|
||||
{(d.nodeCPU || []).map((n: JsonData, i: number) => {
|
||||
const mem = (d.nodeMem || []).find((m: JsonData) => m.node === n.node);
|
||||
return <div key={i} className="flex items-center gap-3 text-[11px] mb-1.5"><span className="w-28 truncate" title={n.node}>{n.node}</span><MiniBar val={Number(n.value||0)} label={`${Number(n.value||0).toFixed(1)}%`} /><MiniBar val={Number(mem?.value||0)} label={`${Number(mem?.value||0).toFixed(1)}%`} color="bg-blue-500" /></div>;
|
||||
@@ -70,7 +73,7 @@ const K8sPanel: React.FC<{ d: JsonData}> = ({ d }) => (
|
||||
const NodePanel: React.FC<{ d: JsonData}> = ({ d }) => (
|
||||
<div className="overflow-x-auto rounded-lg border border-[var(--dg-border)]">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-[var(--dg-bg-tertiary)]"><tr><th className="px-3 py-2 text-left">节点</th><th className="px-3 py-2 text-left">状态</th><th className="px-3 py-2 text-left">CPU</th><th className="px-3 py-2 text-left">内存</th><th className="px-3 py-2 text-left">磁盘</th><th className="px-3 py-2 text-left">流入</th><th className="px-3 py-2 text-left">流出</th></tr></thead>
|
||||
<thead className="bg-[var(--dg-bg-tertiary)]"><tr><th className="px-3 py-2 text-left">{i18n.t('k8s.tab.nodes')}</th><th className="px-3 py-2 text-left">{i18n.t('common.status')}</th><th className="px-3 py-2 text-left">CPU</th><th className="px-3 py-2 text-left">{i18n.t('k8s.pods.memory')}</th><th className="px-3 py-2 text-left">{i18n.t('dashboard.topUsage.disk')}</th><th className="px-3 py-2 text-left">{i18n.t('monitor.prometheusdashboard.s361ffa')}</th><th className="px-3 py-2 text-left">{i18n.t('monitor.prometheusdashboard.s37cc0a')}</th></tr></thead>
|
||||
<tbody>
|
||||
{(d.nodes || []).map((n: JsonData, i: number) => {
|
||||
const inst = n.instance || n.exported_instance || '';
|
||||
@@ -104,11 +107,12 @@ const PodPanel: React.FC<{ d: JsonData}> = ({ d }) => (
|
||||
]}
|
||||
/>
|
||||
<div className="rounded-lg bg-[var(--dg-bg-primary)] p-3 max-h-40 overflow-y-auto"><div className="text-xs font-medium mb-2">CPU Top 10</div>{(d.cpuTop||[]).map((p: JsonData, i:number)=><div key={i} className="flex items-center gap-2 text-[11px] mb-1"><span className="w-10 text-right text-[var(--dg-text-muted)]">{i+1}</span><span className="w-32 truncate" title={p.pod}>{p.pod}</span><MiniBar val={Number(p.value||0)*100} label={`${Number(p.value||0).toFixed(2)}c`} color="bg-purple-500" /></div>)}</div>
|
||||
<div className="rounded-lg bg-[var(--dg-bg-primary)] p-3 max-h-40 overflow-y-auto"><div className="text-xs font-medium mb-2">内存 Top 10</div>{(d.memTop||[]).map((p: JsonData, i:number)=><div key={i} className="flex items-center gap-2 text-[11px] mb-1"><span className="w-10 text-right text-[var(--dg-text-muted)]">{i+1}</span><span className="w-32 truncate" title={p.pod}>{p.pod}</span><span className="ml-auto">{fmtBytes(Number(p.value||0))}</span></div>)}</div>
|
||||
<div className="rounded-lg bg-[var(--dg-bg-primary)] p-3 max-h-40 overflow-y-auto"><div className="text-xs font-medium mb-2">{i18n.t('monitor.prometheusdashboard.sb0f653')}</div>{(d.memTop||[]).map((p: JsonData, i:number)=><div key={i} className="flex items-center gap-2 text-[11px] mb-1"><span className="w-10 text-right text-[var(--dg-text-muted)]">{i+1}</span><span className="w-32 truncate" title={p.pod}>{p.pod}</span><span className="ml-auto">{fmtBytes(Number(p.value||0))}</span></div>)}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const JvmPanel: React.FC<{ d: JsonData}> = ({ d }) => {
|
||||
const { t } = useTranslation();
|
||||
const h = Number(d.heapUsed?.[0]?.value||0);
|
||||
const uptime = Number(d.uptime?.[0]?.value||0);
|
||||
return (
|
||||
@@ -123,6 +127,7 @@ const JvmPanel: React.FC<{ d: JsonData}> = ({ d }) => {
|
||||
};
|
||||
|
||||
export const PrometheusDashboard: React.FC<Props> = ({ dsUrl }) => {
|
||||
const { t } = useTranslation();
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [targets, setTargets] = useState(0);
|
||||
@@ -143,7 +148,7 @@ export const PrometheusDashboard: React.FC<Props> = ({ dsUrl }) => {
|
||||
const data: JsonData = await prometheusProxyApi.connect(u);
|
||||
setConnected(true); setTargets(data.targets||0);
|
||||
loadAll(u);
|
||||
} catch (e) { setConnected(false); setError((e as JsonData).message||'连接失败'); }
|
||||
} catch (e) { setConnected(false); setError((e as JsonData).message||t('aiops.llmTestFailed')); }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
|
||||
@@ -169,13 +174,13 @@ export const PrometheusDashboard: React.FC<Props> = ({ dsUrl }) => {
|
||||
<CardHeader className="py-3 px-4 pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className="h-4 w-4 dg-text-purple" />
|
||||
<CardTitle className="text-sm font-medium">Prometheus 监控</CardTitle>
|
||||
<CardTitle className="text-sm font-medium">{t('monitor.prometheusdashboard.s006a5f')}</CardTitle>
|
||||
{connected && <Badge className="text-[10px] bg-purple-500/10 dg-text-purple">{targets} targets</Badge>}
|
||||
{connected && <div className="ml-auto"><Button variant="ghost" size="sm" className="h-6 px-2 text-[10px]" onClick={refresh}><RefreshCw className="w-3 h-3 mr-1" />刷新</Button></div>}
|
||||
{connected && <div className="ml-auto"><Button variant="ghost" size="sm" className="h-6 px-2 text-[10px]" onClick={refresh}><RefreshCw className="w-3 h-3 mr-1" />{t('common.refresh')}</Button></div>}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 pb-3 pt-0">
|
||||
{loading ? (<div className="text-center py-12"><Loader2 className="h-6 w-6 mx-auto mb-2 animate-spin dg-text-purple" /><p className="text-xs text-[var(--dg-text-muted)]">连接中…</p></div>)
|
||||
{loading ? (<div className="text-center py-12"><Loader2 className="h-6 w-6 mx-auto mb-2 animate-spin dg-text-purple" /><p className="text-xs text-[var(--dg-text-muted)]">{t('monitor.prometheusdashboard.s5e8eb2')}</p></div>)
|
||||
: error ? (<div className="p-3 rounded-lg bg-red-500/10 border border-red-500/20 text-xs dg-text-red"><AlertTriangle className="h-4 w-4 inline mr-1" />{error}</div>)
|
||||
: (
|
||||
<Tabs value={tab} onValueChange={setTab}>
|
||||
|
||||
@@ -18,6 +18,7 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { zabbixProxyApi } from '@/api';
|
||||
import type { JsonData } from '@/types/common';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface ZabbixHost {
|
||||
hostid: string; hostname: string; name: string; ip: string; available: string;
|
||||
@@ -31,6 +32,7 @@ type SortField = 'hostname' | 'cpu_usage' | 'memory_usage' | 'disk_usage' | 'loa
|
||||
interface Props { dsUrl?: string; dsUsername?: string; dsPassword?: string; }
|
||||
|
||||
export const ZabbixDashboard: React.FC<Props> = ({ dsUrl, dsUsername, dsPassword }) => {
|
||||
const { t } = useTranslation();
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [hosts, setHosts] = useState<ZabbixHost[]>([]);
|
||||
@@ -50,7 +52,7 @@ export const ZabbixDashboard: React.FC<Props> = ({ dsUrl, dsUsername, dsPassword
|
||||
setHosts(data.hosts || []);
|
||||
setVersion(data.version || '');
|
||||
setConnected(true);
|
||||
} catch (e) { setConnected(false); setError((e as JsonData).message || '连接失败'); }
|
||||
} catch (e) { setConnected(false); setError((e as JsonData).message || t('aiops.llmTestFailed')); }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
|
||||
@@ -94,24 +96,24 @@ export const ZabbixDashboard: React.FC<Props> = ({ dsUrl, dsUsername, dsPassword
|
||||
<CardHeader className="py-3 px-4 pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className="h-4 w-4 dg-text-blue" />
|
||||
<CardTitle className="text-sm font-medium">Zabbix 主机监控</CardTitle>
|
||||
<CardTitle className="text-sm font-medium">{t('monitor.zabbixdashboard.s6f2695')}</CardTitle>
|
||||
{version && <Badge className="text-[10px] bg-blue-500/10 dg-text-blue">v{version}</Badge>}
|
||||
{connected && <span className="text-[10px] text-[var(--dg-text-muted)] ml-2">{hosts.length} 台</span>}
|
||||
{connected && <div className="ml-auto flex gap-1"><Button variant="ghost" size="sm" className="h-6 px-2 text-[10px]" onClick={refresh}><RefreshCw className="w-3 h-3 mr-1" />刷新</Button></div>}
|
||||
{connected && <div className="ml-auto flex gap-1"><Button variant="ghost" size="sm" className="h-6 px-2 text-[10px]" onClick={refresh}><RefreshCw className="w-3 h-3 mr-1" />{t('common.refresh')}</Button></div>}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 pb-3 pt-0">
|
||||
{loading ? (<div className="text-center py-12"><Loader2 className="h-6 w-6 mx-auto mb-2 animate-spin dg-text-blue" /><p className="text-xs text-[var(--dg-text-muted)]">连接中…</p></div>)
|
||||
{loading ? (<div className="text-center py-12"><Loader2 className="h-6 w-6 mx-auto mb-2 animate-spin dg-text-blue" /><p className="text-xs text-[var(--dg-text-muted)]">{t('monitor.zabbixdashboard.s5e8eb2')}</p></div>)
|
||||
: error ? (<div className="p-3 rounded-lg bg-red-500/10 border border-red-500/20 text-xs dg-text-red flex items-start gap-2"><AlertTriangle className="h-4 w-4 mt-0.5" /><span>{error}</span></div>)
|
||||
: hosts.length === 0 ? (<div className="text-center py-8 text-[var(--dg-text-muted)]"><Server className="h-8 w-8 mx-auto mb-2 dg-text-muted" /><p className="text-sm">未找到被监控的 Zabbix 主机</p></div>)
|
||||
: hosts.length === 0 ? (<div className="text-center py-8 text-[var(--dg-text-muted)]"><Server className="h-8 w-8 mx-auto mb-2 dg-text-muted" /><p className="text-sm">{t('monitor.zabbixdashboard.sdb131a')}</p></div>)
|
||||
: (
|
||||
<div className="overflow-x-auto rounded-lg border border-[var(--dg-border)]">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-[var(--dg-bg-tertiary)] border-b border-[var(--dg-border)]"><tr>
|
||||
{[
|
||||
{ f: 'hostname' as SortField, l: '主机名' },{ f: 'cpu_usage' as SortField, l: 'CPU' },{ f: 'memory_usage' as SortField, l: '内存' },
|
||||
{ f: 'disk_usage' as SortField, l: '磁盘' },{ f: 'load5' as SortField, l: 'Load5' },{ f: 'net_in_bps' as SortField, l: '流入' },
|
||||
{ f: 'net_out_bps' as SortField, l: '流出' },{ f: 'problem_count' as SortField, l: '问题' },{ f: 'uptime_days' as SortField, l: '运行' },
|
||||
{ f: 'hostname' as SortField, l: t('server.hostname') },{ f: 'cpu_usage' as SortField, l: 'CPU' },{ f: 'memory_usage' as SortField, l: t('k8s.pods.memory') },
|
||||
{ f: 'disk_usage' as SortField, l: t('dashboard.topUsage.disk') },{ f: 'load5' as SortField, l: 'Load5' },{ f: 'net_in_bps' as SortField, l: t('monitor.zabbixdashboard.s361ffa') },
|
||||
{ f: 'net_out_bps' as SortField, l: t('monitor.zabbixdashboard.s37cc0a') },{ f: 'problem_count' as SortField, l: t('monitor.zabbixdashboard.s5dc99f') },{ f: 'uptime_days' as SortField, l: t('common.run') },
|
||||
].map(({ f, l }) => (
|
||||
<th key={f} className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)] cursor-pointer hover:text-[var(--dg-text-primary)] select-none whitespace-nowrap" onClick={() => toggleSort(f)}>
|
||||
<div className="flex items-center gap-1">{l}<SI f={f} /></div></th>
|
||||
|
||||
@@ -23,8 +23,10 @@ import PromDsFormDialog from './prometheus-ds/PromDsFormDialog';
|
||||
import { PageLoading } from '@/components/layout/PageLoading';
|
||||
import { PageEmpty } from '@/components/layout/PageEmpty';
|
||||
import { ContentPanel } from '@/components/layout/ContentPanel';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export default function PrometheusDatasourceManager() {
|
||||
const { t } = useTranslation();
|
||||
const _confirm = useConfirm();
|
||||
const { data: sources = [], isLoading, isError } = usePromDatasources();
|
||||
const { deleteMutation, setDefaultMutation } = usePromDatasourceMutations();
|
||||
@@ -33,7 +35,7 @@ export default function PrometheusDatasourceManager() {
|
||||
const [editingDs, setEditingDs] = useState<PromDS | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isError) toast.error('获取数据源列表失败');
|
||||
if (isError) toast.error(t('monitor.prometheusdatasourcemanager.s45f817'));
|
||||
}, [isError]);
|
||||
|
||||
const openCreate = () => {
|
||||
@@ -48,25 +50,25 @@ export default function PrometheusDatasourceManager() {
|
||||
|
||||
const handleDelete = async (ds: PromDS) => {
|
||||
const ok = await _confirm({
|
||||
title: '删除数据源',
|
||||
description: `确定删除数据源「${ds.name}」吗?此操作不可撤销。`,
|
||||
title: t('monitor.deleteDatasource'),
|
||||
description: t('monitor.prometheusdatasourcemanager.s623d90', { ds.name: ds.name }),
|
||||
variant: 'destructive',
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await deleteMutation.mutateAsync(ds.id);
|
||||
toast.success('已删除');
|
||||
toast.success(t('alert.deleted'));
|
||||
} catch {
|
||||
toast.error('删除失败');
|
||||
toast.error(t('common.deleteFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetDefault = async (ds: PromDS) => {
|
||||
try {
|
||||
await setDefaultMutation.mutateAsync(ds.id);
|
||||
toast.success(`已将「${ds.name}」设为默认数据源`);
|
||||
toast.success(t('monitor.prometheusdatasourcemanager.sa8bcc7', { ds.name: ds.name }));
|
||||
} catch {
|
||||
toast.error('设置失败');
|
||||
toast.error(t('monitor.prometheusdatasourcemanager.s9f9603'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -94,8 +96,8 @@ export default function PrometheusDatasourceManager() {
|
||||
{sources.length === 0 ? (
|
||||
<PageEmpty
|
||||
icon={<Database className="h-12 w-12" />}
|
||||
title="暂无 Prometheus 数据源"
|
||||
description="添加数据源后可用于指标监控与监控大屏"
|
||||
title=t('monitor.prometheusdatasourcemanager.s99ed81')
|
||||
description=t('monitor.prometheusdatasourcemanager.sd9a356')
|
||||
action={(
|
||||
<Button onClick={openCreate} size="sm" className="dg-btn-primary">
|
||||
<Plus className="w-4 h-4 mr-1" />添加数据源
|
||||
@@ -107,11 +109,11 @@ export default function PrometheusDatasourceManager() {
|
||||
<table className="w-full">
|
||||
<thead className="border-b border-[var(--border-default)]">
|
||||
<tr className="text-xs dg-text-secondary">
|
||||
<th className="text-left py-3 px-4 font-medium">名称</th>
|
||||
<th className="text-left py-3 px-4 font-medium">{t('common.name')}</th>
|
||||
<th className="text-left py-3 px-4 font-medium">URL</th>
|
||||
<th className="text-left py-3 px-4 font-medium w-20">默认</th>
|
||||
<th className="text-left py-3 px-4 font-medium w-16">状态</th>
|
||||
<th className="text-right py-3 px-4 font-medium w-36">操作</th>
|
||||
<th className="text-left py-3 px-4 font-medium w-20">{t('approval.status.default')}</th>
|
||||
<th className="text-left py-3 px-4 font-medium w-16">{t('common.status')}</th>
|
||||
<th className="text-right py-3 px-4 font-medium w-36">{t('common.actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -142,16 +144,16 @@ export default function PrometheusDatasourceManager() {
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
{ds.is_default ? (
|
||||
<span className="dg-badge-yellow"><Star className="w-3 h-3 mr-1 inline" />默认</span>
|
||||
<span className="dg-badge-yellow"><Star className="w-3 h-3 mr-1 inline" />{t('approval.status.default')}</span>
|
||||
) : (
|
||||
<span className="text-xs dg-text-muted">--</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
{ds.status === 1 ? (
|
||||
<span className="dg-status-running">启用</span>
|
||||
<span className="dg-status-running">{t('common.enable')}</span>
|
||||
) : (
|
||||
<span className="dg-badge-muted">禁用</span>
|
||||
<span className="dg-badge-muted">{t('common.disable')}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
@@ -160,7 +162,7 @@ export default function PrometheusDatasourceManager() {
|
||||
variant="ghost" size="sm"
|
||||
className="h-8 px-2"
|
||||
onClick={() => handleTestQuick(ds)}
|
||||
title="测试连接"
|
||||
title=t('aiops.llmTest')
|
||||
>
|
||||
<Plug className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
@@ -169,7 +171,7 @@ export default function PrometheusDatasourceManager() {
|
||||
variant="ghost" size="sm"
|
||||
className="h-8 px-2 dg-text-yellow"
|
||||
onClick={() => handleSetDefault(ds)}
|
||||
title="设为默认"
|
||||
title=t('aiops.llmSetDefault')
|
||||
>
|
||||
<Star className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
|
||||
@@ -32,6 +32,7 @@ import { toast } from 'sonner';
|
||||
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||
import { zabbixDatasourceApi } from '@/api';
|
||||
import type { JsonData } from '@/types/common';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface ZabbixDS {
|
||||
id: number;
|
||||
@@ -59,6 +60,7 @@ const defaultForm = {
|
||||
};
|
||||
|
||||
export default function ZabbixDatasourceManager() {
|
||||
const { t } = useTranslation();
|
||||
const _confirm = useConfirm();
|
||||
const [sources, setSources] = useState<ZabbixDS[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -78,7 +80,7 @@ export default function ZabbixDatasourceManager() {
|
||||
const res: JsonData = await zabbixDatasourceApi.list();
|
||||
setSources(res?.data || res || []);
|
||||
} catch {
|
||||
toast.error('获取数据源列表失败');
|
||||
toast.error(t('monitor.zabbixdatasourcemanager.s45f817'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -115,51 +117,51 @@ export default function ZabbixDatasourceManager() {
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form.name.trim()) { toast.error('请输入数据源名称'); return; }
|
||||
if (!form.url.trim()) { toast.error('请输入 Zabbix API URL'); return; }
|
||||
if (!form.username.trim()) { toast.error('请输入用户名'); return; }
|
||||
if (!form.password.trim()) { toast.error('请输入密码'); return; }
|
||||
if (!form.name.trim()) { toast.error(t('monitor.zabbixdatasourcemanager.s138957')); return; }
|
||||
if (!form.url.trim()) { toast.error(t('monitor.zabbixdatasourcemanager.s2ba385')); return; }
|
||||
if (!form.username.trim()) { toast.error(t('login.usernameRequired')); return; }
|
||||
if (!form.password.trim()) { toast.error(t('login.passwordRequired')); return; }
|
||||
|
||||
try {
|
||||
if (editingId) {
|
||||
await zabbixDatasourceApi.update(editingId, form);
|
||||
toast.success('数据源已更新');
|
||||
toast.success(t('monitor.zabbixdatasourcemanager.s8dfdd2'));
|
||||
} else {
|
||||
await zabbixDatasourceApi.create(form);
|
||||
toast.success('数据源已创建');
|
||||
toast.success(t('monitor.zabbixdatasourcemanager.sa6def0'));
|
||||
}
|
||||
setDialogOpen(false);
|
||||
fetchList();
|
||||
} catch (err) {
|
||||
toast.error(err?.message || '操作失败');
|
||||
toast.error(err?.message || t('common.operationFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (ds: ZabbixDS) => {
|
||||
const ok = await _confirm({
|
||||
title: '删除数据源',
|
||||
description: `确定删除「${ds.name}」?`,
|
||||
title: t('monitor.deleteDatasource'),
|
||||
description: t('monitor.zabbixdatasourcemanager.sa2b79a', { ds.name: ds.name }),
|
||||
variant: 'destructive',
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await zabbixDatasourceApi.delete(ds.id);
|
||||
toast.success('已删除');
|
||||
toast.success(t('alert.deleted'));
|
||||
fetchList();
|
||||
} catch { toast.error('删除失败'); }
|
||||
} catch { toast.error(t('common.deleteFailed')); }
|
||||
};
|
||||
|
||||
const handleSetDefault = async (ds: ZabbixDS) => {
|
||||
try {
|
||||
await zabbixDatasourceApi.setDefault(ds.id);
|
||||
toast.success(`已将「${ds.name}」设为默认`);
|
||||
toast.success(t('monitor.zabbixdatasourcemanager.sf0f959', { ds.name: ds.name }));
|
||||
fetchList();
|
||||
} catch { toast.error('设置失败'); }
|
||||
} catch { toast.error(t('monitor.zabbixdatasourcemanager.s9f9603')); }
|
||||
};
|
||||
|
||||
const handleTest = async () => {
|
||||
if (!form.url.trim() || !form.username.trim()) {
|
||||
toast.error('请填写 URL 和用户名');
|
||||
toast.error(t('monitor.zabbixdatasourcemanager.s1d3562'));
|
||||
return;
|
||||
}
|
||||
setTesting(true);
|
||||
@@ -173,7 +175,7 @@ export default function ZabbixDatasourceManager() {
|
||||
const data = res?.data || res;
|
||||
setTestResult(data);
|
||||
} catch (err) {
|
||||
setTestResult({ success: false, message: err?.message || '测试失败' });
|
||||
setTestResult({ success: false, message: err?.message || t('monitor.zabbixdatasourcemanager.s9710d9') });
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
@@ -199,7 +201,7 @@ export default function ZabbixDatasourceManager() {
|
||||
/>
|
||||
Zabbix 数据源
|
||||
</h2>
|
||||
<p className="text-xs text-[var(--dg-text-muted)]">配置 Zabbix API 连接,获取主机监控数据</p>
|
||||
<p className="text-xs text-[var(--dg-text-muted)]">{t('monitor.zabbixdatasourcemanager.s79e929')}</p>
|
||||
</div>
|
||||
<Button onClick={openCreate} size="sm">
|
||||
<Plus className="w-4 h-4 mr-1" />添加
|
||||
@@ -210,7 +212,7 @@ export default function ZabbixDatasourceManager() {
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="py-12 text-center text-[var(--dg-text-muted)]">
|
||||
<Database className="w-12 h-12 mx-auto mb-3 opacity-30" />
|
||||
<p className="text-sm">暂无 Zabbix 数据源</p>
|
||||
<p className="text-sm">{t('monitor.zabbixdatasourcemanager.sc9c9bd')}</p>
|
||||
<Button size="sm" className="mt-4" onClick={openCreate}>
|
||||
<Plus className="w-4 h-4 mr-1" />添加数据源
|
||||
</Button>
|
||||
@@ -226,15 +228,15 @@ export default function ZabbixDatasourceManager() {
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div className={`w-2 h-2 rounded-full ${ds.status === 1 ? 'bg-emerald-400' : 'bg-[var(--dg-bg-active)]'}`} />
|
||||
<span className="font-medium text-[var(--dg-text-primary)]">{ds.name}</span>
|
||||
{ds.is_default && <Badge className="bg-amber-500/20 dg-text-yellow text-[10px]"><Star className="w-3 h-3 mr-0.5" />默认</Badge>}
|
||||
{ds.is_default && <Badge className="bg-amber-500/20 dg-text-yellow text-[10px]"><Star className="w-3 h-3 mr-0.5" />{t('approval.status.default')}</Badge>}
|
||||
{ds.status === 1
|
||||
? <Badge className="bg-emerald-500/20 dg-text-green text-[10px]">启用</Badge>
|
||||
: <Badge className="bg-[var(--dg-bg-active)]/20 dg-text-secondary text-[10px]">禁用</Badge>}
|
||||
? <Badge className="bg-emerald-500/20 dg-text-green text-[10px]">{t('common.enable')}</Badge>
|
||||
: <Badge className="bg-[var(--dg-bg-active)]/20 dg-text-secondary text-[10px]">{t('common.disable')}</Badge>}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--dg-text-muted)] space-x-3">
|
||||
<code className="text-orange-300">{ds.url}</code>
|
||||
<span>用户: {ds.username}</span>
|
||||
{ds.direct_db && <Badge variant="outline" className="text-[10px]">直连DB</Badge>}
|
||||
{ds.direct_db && <Badge variant="outline" className="text-[10px]">{t('monitor.zabbixdatasourcemanager.s85260a')}</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
@@ -263,7 +265,7 @@ export default function ZabbixDatasourceManager() {
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-[var(--dg-text-primary)] flex items-center gap-2">
|
||||
<Database className="h-5 w-5 dg-text-orange" />
|
||||
{editingId ? '编辑 Zabbix 数据源' : '新建 Zabbix 数据源'}
|
||||
{editingId ? t('monitor.zabbixdatasourcemanager.sc1c87e') : t('monitor.zabbixdatasourcemanager.sa77d38')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
配置 Zabbix API 连接,用于主机监控与告警数据获取
|
||||
@@ -274,14 +276,14 @@ export default function ZabbixDatasourceManager() {
|
||||
{/* Name + Default */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-xs text-[var(--dg-text-secondary)]">名称 *</Label>
|
||||
<Input className="mt-1 h-9 text-sm bg-[var(--dg-bg-primary)]" placeholder="如:生产 Zabbix"
|
||||
<Label className="text-xs text-[var(--dg-text-secondary)]">{t('deploy.form.nameStar')}</Label>
|
||||
<Input className="mt-1 h-9 text-sm bg-[var(--dg-bg-primary)]" placeholder=t('monitor.zabbixdatasourcemanager.s9f1e6a')
|
||||
value={form.name} onChange={e => updateForm('name', e.target.value)} />
|
||||
</div>
|
||||
<div className="flex items-end pb-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch checked={form.is_default} onCheckedChange={v => updateForm('is_default', v)} />
|
||||
<Label className="text-xs cursor-pointer">设为默认</Label>
|
||||
<Label className="text-xs cursor-pointer">{t('aiops.llmSetDefault')}</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -305,15 +307,15 @@ export default function ZabbixDatasourceManager() {
|
||||
onClick={() => setShowAuth(!showAuth)}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="w-4 h-4" />
|
||||
<span>Zabbix API 认证</span>
|
||||
<span>{t('monitor.zabbixdatasourcemanager.sb47858')}</span>
|
||||
</div>
|
||||
{showAuth ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
||||
</button>
|
||||
{showAuth && (
|
||||
<div className="px-3 pb-3 space-y-3">
|
||||
<Input className="h-9 text-sm bg-[var(--dg-bg-primary)]" placeholder="用户名 *"
|
||||
<Input className="h-9 text-sm bg-[var(--dg-bg-primary)]" placeholder=t('monitor.zabbixdatasourcemanager.s04c3dd')
|
||||
value={form.username} onChange={e => updateForm('username', e.target.value)} />
|
||||
<Input className="h-9 text-sm bg-[var(--dg-bg-primary)]" type="password" placeholder="密码 *"
|
||||
<Input className="h-9 text-sm bg-[var(--dg-bg-primary)]" type="password" placeholder=t('monitor.zabbixdatasourcemanager.s3140c0')
|
||||
value={form.password} onChange={e => updateForm('password', e.target.value)} />
|
||||
</div>
|
||||
)}
|
||||
@@ -325,7 +327,7 @@ export default function ZabbixDatasourceManager() {
|
||||
onClick={() => setShowTrends(!showTrends)}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span>Trends 设置</span>
|
||||
<span>{t('monitor.zabbixdatasourcemanager.s05ef18')}</span>
|
||||
</div>
|
||||
{showTrends ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
||||
</button>
|
||||
@@ -333,24 +335,24 @@ export default function ZabbixDatasourceManager() {
|
||||
<div className="px-3 pb-3 space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-xs text-[var(--dg-text-secondary)]">After (天)</Label>
|
||||
<Label className="text-xs text-[var(--dg-text-secondary)]">{t('monitor.zabbixdatasourcemanager.sebfa4e')}</Label>
|
||||
<Input className="mt-1 h-9 text-sm bg-[var(--dg-bg-primary)]" type="number" min={0}
|
||||
value={form.trends_after} onChange={e => updateForm('trends_after', parseInt(e.target.value) || 0)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-[var(--dg-text-secondary)]">Range (秒)</Label>
|
||||
<Label className="text-xs text-[var(--dg-text-secondary)]">{t('monitor.zabbixdatasourcemanager.s7fe44f')}</Label>
|
||||
<Input className="mt-1 h-9 text-sm bg-[var(--dg-bg-primary)]" type="number" min={0}
|
||||
value={form.trends_range} onChange={e => updateForm('trends_range', parseInt(e.target.value) || 0)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-xs text-[var(--dg-text-secondary)]">Cache TTL (秒)</Label>
|
||||
<Label className="text-xs text-[var(--dg-text-secondary)]">{t('monitor.zabbixdatasourcemanager.sbf9a86')}</Label>
|
||||
<Input className="mt-1 h-9 text-sm bg-[var(--dg-bg-primary)]" type="number" min={0}
|
||||
value={form.trends_cache_ttl} onChange={e => updateForm('trends_cache_ttl', parseInt(e.target.value) || 3600)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-[var(--dg-text-secondary)]">Timeout (秒)</Label>
|
||||
<Label className="text-xs text-[var(--dg-text-secondary)]">{t('monitor.zabbixdatasourcemanager.s21f982')}</Label>
|
||||
<Input className="mt-1 h-9 text-sm bg-[var(--dg-bg-primary)]" type="number" min={1}
|
||||
value={form.trends_timeout} onChange={e => updateForm('trends_timeout', parseInt(e.target.value) || 30)} />
|
||||
</div>
|
||||
@@ -365,7 +367,7 @@ export default function ZabbixDatasourceManager() {
|
||||
onClick={() => setShowOther(!showOther)}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings className="w-4 h-4" />
|
||||
<span>其他设置</span>
|
||||
<span>{t('monitor.zabbixdatasourcemanager.sdda36e')}</span>
|
||||
</div>
|
||||
{showOther ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
||||
</button>
|
||||
@@ -376,11 +378,11 @@ export default function ZabbixDatasourceManager() {
|
||||
<Switch checked={form.direct_db} onCheckedChange={v => updateForm('direct_db', v)} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">只读用户禁用确认</Label>
|
||||
<Label className="text-xs">{t('monitor.zabbixdatasourcemanager.s413ec7')}</Label>
|
||||
<Switch checked={form.disable_acknowledges} onCheckedChange={v => updateForm('disable_acknowledges', v)} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">禁用数据对齐</Label>
|
||||
<Label className="text-xs">{t('monitor.zabbixdatasourcemanager.s6d4d6f')}</Label>
|
||||
<Switch checked={form.disable_data_alignment} onCheckedChange={v => updateForm('disable_data_alignment', v)} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -404,8 +406,8 @@ export default function ZabbixDatasourceManager() {
|
||||
测试连接
|
||||
</Button>
|
||||
<div className="flex-1" />
|
||||
<Button variant="ghost" size="sm" onClick={() => setDialogOpen(false)}>取消</Button>
|
||||
<Button size="sm" onClick={handleSave}>保存</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setDialogOpen(false)}>{t('common.cancel')}</Button>
|
||||
<Button size="sm" onClick={handleSave}>{t('common.save')}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -36,6 +36,7 @@ import { usePromDatasourceMutations } from '@/hooks/api';
|
||||
import type { PromDS, HeaderEntry, PromDsForm } from './types';
|
||||
import { defaultForm } from './types';
|
||||
import { dsToForm, parseHeaders, buildPayload, testPromConnection, type TestConnectionResult } from './helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface PromDsFormDialogProps {
|
||||
open: boolean;
|
||||
@@ -50,6 +51,7 @@ export default function PromDsFormDialog({
|
||||
editingDs,
|
||||
sources,
|
||||
}: PromDsFormDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const { createMutation, updateMutation } = usePromDatasourceMutations();
|
||||
const editingId = editingDs?.id ?? null;
|
||||
|
||||
@@ -100,28 +102,28 @@ export default function PromDsFormDialog({
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form.name.trim()) { toast.error('请输入数据源名称'); return; }
|
||||
if (!form.url.trim()) { toast.error('请输入 Prometheus URL'); return; }
|
||||
if (!form.name.trim()) { toast.error(t('monitor.promdsformdialog.s138957')); return; }
|
||||
if (!form.url.trim()) { toast.error(t('monitor.promdsformdialog.sdc869f')); return; }
|
||||
|
||||
const payload = buildPayload(form, headers);
|
||||
|
||||
try {
|
||||
if (editingId) {
|
||||
await updateMutation.mutateAsync({ id: editingId, data: payload });
|
||||
toast.success('数据源更新成功');
|
||||
toast.success(t('monitor.promdsformdialog.sc0c477'));
|
||||
} else {
|
||||
await createMutation.mutateAsync(payload);
|
||||
toast.success('数据源创建成功');
|
||||
toast.success(t('monitor.promdsformdialog.s8b45c1'));
|
||||
}
|
||||
onOpenChange(false);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : '操作失败';
|
||||
const message = err instanceof Error ? err.message : t('common.operationFailed');
|
||||
toast.error(message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestConnection = async () => {
|
||||
if (!form.url.trim()) { toast.error('请先输入 URL'); return; }
|
||||
if (!form.url.trim()) { toast.error(t('monitor.promdsformdialog.sc289ae')); return; }
|
||||
setTesting(true);
|
||||
setTestResult(null);
|
||||
try {
|
||||
@@ -134,7 +136,7 @@ export default function PromDsFormDialog({
|
||||
});
|
||||
setTestResult(data);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : '测试失败';
|
||||
const message = err instanceof Error ? err.message : t('monitor.promdsformdialog.s9710d9');
|
||||
setTestResult({ success: false, message });
|
||||
} finally {
|
||||
setTesting(false);
|
||||
@@ -149,7 +151,7 @@ export default function PromDsFormDialog({
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-lg">
|
||||
<Database className="w-5 h-5 dg-text-orange" />
|
||||
{editingId ? '编辑数据源' : '添加数据源'}
|
||||
{editingId ? t('monitor.editDatasource') : t('monitor.addDatasource')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Prometheus 数据源配置 · 参考 Grafana 数据源管理
|
||||
@@ -163,7 +165,7 @@ export default function PromDsFormDialog({
|
||||
<Label htmlFor="ds-name" className="text-sm">Name <span className="dg-text-red">*</span></Label>
|
||||
<Input
|
||||
id="ds-name"
|
||||
placeholder="Prometheus 数据源名称"
|
||||
placeholder=t('monitor.promdsformdialog.s9a9542')
|
||||
value={form.name}
|
||||
onChange={e => updateForm('name', e.target.value)}
|
||||
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]"
|
||||
@@ -238,14 +240,14 @@ export default function PromDsFormDialog({
|
||||
{form.basic_auth && (
|
||||
<div className="grid grid-cols-2 gap-3 ml-9">
|
||||
<Input
|
||||
placeholder="用户名"
|
||||
placeholder=t('login.username')
|
||||
value={form.basic_auth_user}
|
||||
onChange={e => updateForm('basic_auth_user', e.target.value)}
|
||||
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-sm"
|
||||
/>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="密码"
|
||||
placeholder=t('login.password')
|
||||
value={form.basic_auth_pass}
|
||||
onChange={e => updateForm('basic_auth_pass', e.target.value)}
|
||||
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-sm"
|
||||
@@ -284,7 +286,7 @@ export default function PromDsFormDialog({
|
||||
</div>
|
||||
{form.with_ca_cert && (
|
||||
<textarea
|
||||
placeholder="PEM 格式的 CA 证书内容..."
|
||||
placeholder=t('monitor.promdsformdialog.sf66241')
|
||||
value={form.ca_cert}
|
||||
onChange={e => updateForm('ca_cert', e.target.value)}
|
||||
rows={4}
|
||||
@@ -322,7 +324,7 @@ export default function PromDsFormDialog({
|
||||
</Button>
|
||||
</div>
|
||||
{headers.length === 0 ? (
|
||||
<p className="text-xs text-[var(--dg-text-secondary)] italic">暂无自定义 Header</p>
|
||||
<p className="text-xs text-[var(--dg-text-secondary)] italic">{t('monitor.promdsformdialog.s3c75b9')}</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{headers.map((h, idx) => (
|
||||
@@ -382,7 +384,7 @@ export default function PromDsFormDialog({
|
||||
<SelectValue placeholder="Choose" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none">无</SelectItem>
|
||||
<SelectItem value="__none">{t('common.none')}</SelectItem>
|
||||
{sources.filter(s => s.id !== editingId).map(s => (
|
||||
<SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>
|
||||
))}
|
||||
@@ -407,7 +409,7 @@ export default function PromDsFormDialog({
|
||||
onChange={e => updateForm('scrape_interval', Number(e.target.value))}
|
||||
className="w-20 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-sm text-center"
|
||||
/>
|
||||
<span className="text-xs text-[var(--dg-text-secondary)]">秒</span>
|
||||
<span className="text-xs text-[var(--dg-text-secondary)]">{t('common.second')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
@@ -422,7 +424,7 @@ export default function PromDsFormDialog({
|
||||
onChange={e => updateForm('query_timeout', Number(e.target.value))}
|
||||
className="w-20 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-sm text-center"
|
||||
/>
|
||||
<span className="text-xs text-[var(--dg-text-secondary)]">秒</span>
|
||||
<span className="text-xs text-[var(--dg-text-secondary)]">{t('common.second')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -468,7 +470,7 @@ export default function PromDsFormDialog({
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm text-[var(--dg-text-secondary)]">Custom query parameters</Label>
|
||||
<Input
|
||||
placeholder='例: {"timeout": "30s"}'
|
||||
placeholder=t('monitor.promdsformdialog.s5617a6')
|
||||
value={form.custom_query_params}
|
||||
onChange={e => updateForm('custom_query_params', e.target.value)}
|
||||
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-sm font-mono"
|
||||
@@ -518,7 +520,7 @@ export default function PromDsFormDialog({
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{editingId ? '更新' : '保存'}
|
||||
{editingId ? t('common.update') : t('common.save')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { toast } from 'sonner';
|
||||
import { promDatasourceApi } from '@/api';
|
||||
import type { PromDS, HeaderEntry, PromDsForm } from './types';
|
||||
import i18n from '@/i18n';
|
||||
|
||||
export type TestConnectionParams = {
|
||||
url: string;
|
||||
@@ -104,12 +105,12 @@ export async function handleTestQuick(ds: PromDS): Promise<void> {
|
||||
skip_tls_verify: ds.skip_tls_verify,
|
||||
});
|
||||
if (data?.success) {
|
||||
toast.success(`连接成功 · ${data.target_count || 0} targets`);
|
||||
toast.success(i18n.t('monitor.helpers.s50047c', { data.target_count || 0: data.target_count || 0 }));
|
||||
} else {
|
||||
toast.error(data?.message || '连接失败');
|
||||
toast.error(data?.message || i18n.t('aiops.llmTestFailed'));
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : '测试失败';
|
||||
const message = err instanceof Error ? err.message : i18n.t('monitor.helpers.s9710d9');
|
||||
toast.error(message);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { useToast } from '@/hooks/use-toast';
|
||||
import { copyToClipboard } from '@/utils/clipboard';
|
||||
import { buildLogCopyText, detectLogFields, formatLogPreview, formatLogTimestamp } from './log-format';
|
||||
import type { LogEntry, ViewMode } from './types-v3';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
type ViewDensity = 'compact' | 'comfortable' | 'loose';
|
||||
|
||||
interface Props {
|
||||
@@ -98,6 +99,7 @@ function RawLogView({
|
||||
entry: LogEntry; wrap: boolean; trunc: boolean;
|
||||
showTime: boolean; onLabelFilter?: (k: string, v: string, op: 'include' | 'exclude') => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
@@ -112,10 +114,10 @@ function RawLogView({
|
||||
const ok = await copyToClipboard(text);
|
||||
if (ok) {
|
||||
setCopied(true);
|
||||
toast({ title: '已复制完整日志' });
|
||||
toast({ title: t('monitor.enhancedlogresults.sb2d494') });
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} else {
|
||||
toast({ title: '复制失败', description: '请展开后手动选中复制', variant: 'destructive' });
|
||||
toast({ title: t('common.copyFailed'), description: t('monitor.enhancedlogresults.s2d8db2'), variant: 'destructive' });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -160,7 +162,7 @@ function RawLogView({
|
||||
onClick={e => { e.stopPropagation(); void copyRaw(); }}
|
||||
className="flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity mt-0.5"
|
||||
style={{ color: 'var(--dg-text-muted)' }}
|
||||
title="复制完整日志"
|
||||
title=t('monitor.enhancedlogresults.s0ac3b0')
|
||||
>
|
||||
{copied ? <Check className="h-3 w-3 dg-text-green" /> : <Copy className="h-3 w-3" />}
|
||||
</button>
|
||||
@@ -172,14 +174,14 @@ function RawLogView({
|
||||
{/* 完整日志正文 */}
|
||||
<div className="mb-3">
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<p className="text-[10px] font-medium" style={{ color: 'var(--dg-text-muted)' }}>完整日志</p>
|
||||
<p className="text-[10px] font-medium" style={{ color: 'var(--dg-text-muted)' }}>{t('monitor.enhancedlogresults.s285574')}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={e => { e.stopPropagation(); void copyRaw(); }}
|
||||
className="text-[10px] px-2 py-0.5 rounded border"
|
||||
style={{ borderColor: 'var(--dg-border)', color: 'var(--dg-text-secondary)' }}
|
||||
>
|
||||
{copied ? '已复制' : '复制全文'}
|
||||
{copied ? t('common.copied') : t('monitor.enhancedlogresults.s290b62')}
|
||||
</button>
|
||||
</div>
|
||||
<pre
|
||||
@@ -251,6 +253,7 @@ function RawLogView({
|
||||
|
||||
// ===== 字段视图 ===== //
|
||||
function FieldView({ entries, wrap, trunc, search }: { entries: LogEntry[]; wrap: boolean; trunc: boolean; search?: string }) {
|
||||
const { t } = useTranslation();
|
||||
const columns = useMemo(() => {
|
||||
const colSet = new Set<string>();
|
||||
entries.forEach(e => { if (e.labels) Object.keys(e.labels).forEach(k => colSet.add(k)); });
|
||||
@@ -293,6 +296,7 @@ function FieldView({ entries, wrap, trunc, search }: { entries: LogEntry[]; wrap
|
||||
|
||||
// ===== 表格视图 ===== //
|
||||
function TableView({ entries, wrap, trunc }: { entries: LogEntry[]; wrap: boolean; trunc: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs" style={{ borderCollapse: 'collapse' }}>
|
||||
@@ -332,6 +336,7 @@ export function EnhancedLogResults({
|
||||
commonLabels,
|
||||
onLabelFilter,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
// 去重处理
|
||||
const dedupedEntries = useMemo(() => dedupEntries(entries, dedupMode), [entries, dedupMode]);
|
||||
|
||||
@@ -356,7 +361,7 @@ export function EnhancedLogResults({
|
||||
if (entries.length === 0) {
|
||||
return (
|
||||
<div className="border rounded-lg overflow-hidden flex items-center justify-center" style={{ borderColor: 'var(--dg-border)', minHeight: '200px' }}>
|
||||
<span className="text-sm" style={{ color: 'var(--dg-text-muted)' }}>暂无日志</span>
|
||||
<span className="text-sm" style={{ color: 'var(--dg-text-muted)' }}>{t('pipeline.noLog')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { ChevronDown, Loader2, Search } from 'lucide-react';
|
||||
import { logCenterApi } from '@/api/log/log-center';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface Props {
|
||||
datasourceId?: number;
|
||||
@@ -10,6 +11,7 @@ interface Props {
|
||||
|
||||
// ES 索引选择器 — 可搜索下拉,对接数据源索引列表 // Logan.Li
|
||||
export function EsIndexPicker({ datasourceId, value, onChange }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [indices, setIndices] = useState<string[]>([]);
|
||||
@@ -40,9 +42,9 @@ export function EsIndexPicker({ datasourceId, value, onChange }: Props) {
|
||||
onClick={() => setOpen(!open)}
|
||||
className="h-10 px-2 text-xs rounded flex items-center gap-1 font-mono min-w-[150px] max-w-[220px]"
|
||||
style={{ background: 'var(--dg-bg-tertiary)', border: '1px solid var(--dg-border)', color: 'var(--dg-text-primary)' }}
|
||||
title="选择或搜索 ES 索引"
|
||||
title=t('monitor.esindexpicker.sa0eff8')
|
||||
>
|
||||
<span className="truncate flex-1 text-left">{value || '选择索引'}</span>
|
||||
<span className="truncate flex-1 text-left">{value || t('monitor.selectIndex')}</span>
|
||||
{loading ? <Loader2 className="h-3 w-3 animate-spin shrink-0" /> : <ChevronDown className="h-3 w-3 shrink-0" />}
|
||||
</button>
|
||||
{open && (
|
||||
@@ -58,7 +60,7 @@ export function EsIndexPicker({ datasourceId, value, onChange }: Props) {
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="搜索索引..."
|
||||
placeholder=t('monitor.esindexpicker.s9ae0c0')
|
||||
className="flex-1 bg-transparent text-xs outline-none font-mono"
|
||||
style={{ color: 'var(--dg-text-primary)' }}
|
||||
/>
|
||||
@@ -88,7 +90,7 @@ export function EsIndexPicker({ datasourceId, value, onChange }: Props) {
|
||||
</button>
|
||||
))}
|
||||
{!loading && filtered.length === 0 && (
|
||||
<p className="text-xs px-2 py-3 text-center" style={{ color: 'var(--dg-text-muted)' }}>无匹配索引</p>
|
||||
<p className="text-xs px-2 py-3 text-center" style={{ color: 'var(--dg-text-muted)' }}>{t('monitor.esindexpicker.saf5cc1')}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { X, BellRing, Loader2, Flame, Database, Cloud } from 'lucide-react';
|
||||
import { alertApi } from '@/api';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import type { LogEngine } from './types-v3';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface LogQueryContext {
|
||||
engine: LogEngine;
|
||||
@@ -28,6 +29,7 @@ const ENGINE_META: Record<LogEngine, { name: string; Icon: typeof Flame; color:
|
||||
};
|
||||
|
||||
export function LogAlertFromQueryDialog({ open, onOpenChange, ctx, onSaved }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
@@ -40,8 +42,8 @@ export function LogAlertFromQueryDialog({ open, onOpenChange, ctx, onSaved }: Pr
|
||||
useEffect(() => {
|
||||
if (!open || !ctx) return;
|
||||
const meta = ENGINE_META[ctx.engine];
|
||||
setName(`${meta.name} 日志告警`);
|
||||
setDescription(`数据源: ${ctx.datasourceName || '—'} · 范围: ${ctx.timeRange || '—'}${ctx.index ? ` · 索引: ${ctx.index}` : ''}`);
|
||||
setName(t('monitor.logalertfromquerydialog.s844311', { meta.name: meta.name }));
|
||||
setDescription(t('monitor.logalertfromquerydialog.s462d17', { ctx.datasourceName || '—': ctx.datasourceName || '—', ctx.timeRange || '—': ctx.timeRange || '—' }) · 索引: ${ctx.index}` : ''}`);
|
||||
if (ctx.engine === 'loki') {
|
||||
setExpression(`count_over_time(${ctx.query}[5m]) > 0`);
|
||||
} else if (ctx.engine === 'sls') {
|
||||
@@ -73,11 +75,11 @@ export function LogAlertFromQueryDialog({ open, onOpenChange, ctx, onSaved }: Pr
|
||||
notify_channels: [],
|
||||
});
|
||||
setSaved(true);
|
||||
toast({ title: '告警规则已创建' });
|
||||
toast({ title: t('monitor.logalertfromquerydialog.sd3dbea') });
|
||||
onSaved?.();
|
||||
setTimeout(() => onOpenChange(false), 800);
|
||||
} catch (e: any) {
|
||||
toast({ title: '创建失败', description: e?.message || String(e), variant: 'destructive' });
|
||||
toast({ title: t('monitor.quickSetupFailed'), description: e?.message || String(e), variant: 'destructive' });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -94,7 +96,7 @@ export function LogAlertFromQueryDialog({ open, onOpenChange, ctx, onSaved }: Pr
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b" style={{ borderColor: 'var(--dg-border)' }}>
|
||||
<div className="flex items-center gap-2">
|
||||
<BellRing className="h-5 w-5" style={{ color: '#f59e0b' }} />
|
||||
<span className="text-sm font-medium" style={{ color: 'var(--dg-text-primary)' }}>创建告警规则</span>
|
||||
<span className="text-sm font-medium" style={{ color: 'var(--dg-text-primary)' }}>{t('monitor.logalertfromquerydialog.s5f4707')}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onOpenChange(false)}
|
||||
@@ -112,12 +114,12 @@ export function LogAlertFromQueryDialog({ open, onOpenChange, ctx, onSaved }: Pr
|
||||
<span className="flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded font-medium" style={{ background: `${color}15`, color }}>
|
||||
<Icon className="h-3 w-3" />{meta.name}
|
||||
</span>
|
||||
<span className="text-[11px] truncate" style={{ color: 'var(--dg-text-secondary)' }}>{ctx.datasourceName || '默认数据源'}</span>
|
||||
<span className="text-[11px] truncate" style={{ color: 'var(--dg-text-secondary)' }}>{ctx.datasourceName || t('monitor.logalertfromquerydialog.sddb4ca')}</span>
|
||||
<span className="text-[10px] ml-auto" style={{ color: 'var(--dg-text-muted)' }}>{ctx.timeRange || '1h'}</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-[10px] block mb-1" style={{ color: 'var(--dg-text-muted)' }}>规则名称 *</label>
|
||||
<label className="text-[10px] block mb-1" style={{ color: 'var(--dg-text-muted)' }}>{t('monitor.logalertfromquerydialog.s34fe90')}</label>
|
||||
<input
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
@@ -127,7 +129,7 @@ export function LogAlertFromQueryDialog({ open, onOpenChange, ctx, onSaved }: Pr
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-[10px] block mb-1" style={{ color: 'var(--dg-text-muted)' }}>描述</label>
|
||||
<label className="text-[10px] block mb-1" style={{ color: 'var(--dg-text-muted)' }}>{t('common.description')}</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
@@ -138,7 +140,7 @@ export function LogAlertFromQueryDialog({ open, onOpenChange, ctx, onSaved }: Pr
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-[10px] block mb-1" style={{ color: 'var(--dg-text-muted)' }}>触发条件 *</label>
|
||||
<label className="text-[10px] block mb-1" style={{ color: 'var(--dg-text-muted)' }}>{t('monitor.logalertfromquerydialog.sa6c48a')}</label>
|
||||
<textarea
|
||||
value={expression}
|
||||
onChange={e => setExpression(e.target.value)}
|
||||
@@ -147,26 +149,26 @@ export function LogAlertFromQueryDialog({ open, onOpenChange, ctx, onSaved }: Pr
|
||||
style={{ background: 'var(--dg-bg-tertiary)', color: 'var(--dg-text-primary)' }}
|
||||
/>
|
||||
<p className="text-[10px] mt-1" style={{ color: 'var(--dg-text-muted)' }}>
|
||||
{ctx.engine === 'loki' ? 'LogQL 告警表达式' : ctx.engine === 'sls' ? 'SLS SQL/SPL 查询' : 'ES Lucene 查询'}
|
||||
{ctx.engine === 'loki' ? t('monitor.logalertfromquerydialog.s0d669e') : ctx.engine === 'sls' ? t('monitor.logalertfromquerydialog.sf769e3') : t('monitor.logalertfromquerydialog.sa079c7')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-[10px] block mb-1" style={{ color: 'var(--dg-text-muted)' }}>级别</label>
|
||||
<label className="text-[10px] block mb-1" style={{ color: 'var(--dg-text-muted)' }}>{t('common.level')}</label>
|
||||
<select
|
||||
value={severity}
|
||||
onChange={e => setSeverity(e.target.value)}
|
||||
className="w-full h-9 px-2 text-xs rounded dg-input"
|
||||
style={{ background: 'var(--dg-bg-tertiary)', color: 'var(--dg-text-primary)' }}
|
||||
>
|
||||
<option value="info">提示</option>
|
||||
<option value="warning">警告</option>
|
||||
<option value="critical">严重</option>
|
||||
<option value="info">{t('common.info')}</option>
|
||||
<option value="warning">{t('common.warning')}</option>
|
||||
<option value="critical">{t('monitor.critical')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] block mb-1" style={{ color: 'var(--dg-text-muted)' }}>持续(秒)</label>
|
||||
<label className="text-[10px] block mb-1" style={{ color: 'var(--dg-text-muted)' }}>{t('monitor.logalertfromquerydialog.s5ec177')}</label>
|
||||
<input
|
||||
type="number"
|
||||
value={duration}
|
||||
@@ -199,7 +201,7 @@ export function LogAlertFromQueryDialog({ open, onOpenChange, ctx, onSaved }: Pr
|
||||
style={{ background: 'var(--dg-blue)' }}
|
||||
>
|
||||
{saving && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
||||
{saved ? '已创建' : '创建规则'}
|
||||
{saved ? i18n.t('deploy.toast.created') : i18n.t('monitor.logalertfromquerydialog.s8e1e3d')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,8 +9,10 @@ import { useToast } from '@/hooks/use-toast';
|
||||
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||
import { alertApi } from '@/api';
|
||||
import { AlertRuleDialog } from '@/components/monitor/AlertRuleDialog';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export function LogAlertsPanel() {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
const confirm = useConfirm();
|
||||
const [rules, setRules] = useState<any[]>([]);
|
||||
@@ -37,14 +39,14 @@ export function LogAlertsPanel() {
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const handleDelete = async (rule: any) => {
|
||||
const ok = await confirm({ title: '删除规则', description: `删除「${rule.name}」?`, variant: 'destructive', confirmText: '删除' });
|
||||
const ok = await confirm({ title: t('alert.deleteRule'), description: t('monitor.logalertspanel.sf6b982', { rule.name: rule.name }), variant: 'destructive', confirmText: t('common.delete') });
|
||||
if (!ok) return;
|
||||
try {
|
||||
await alertApi.deleteRule(rule.id);
|
||||
toast({ title: '已删除' });
|
||||
toast({ title: t('alert.deleted') });
|
||||
load();
|
||||
} catch (e: any) {
|
||||
toast({ title: '删除失败', description: e?.message || String(e), variant: 'destructive' });
|
||||
toast({ title: t('common.deleteFailed'), description: e?.message || String(e), variant: 'destructive' });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -57,7 +59,7 @@ export function LogAlertsPanel() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-[var(--dg-text-muted)]">仅保存告警策略(LogQL / Lucene),日志数据仍在外部 Loki / ES</p>
|
||||
<p className="text-sm text-[var(--dg-text-muted)]">{t('monitor.logalertspanel.s5bc514')}</p>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={load} disabled={loading}>
|
||||
<RefreshCw className={`h-4 w-4 mr-1 ${loading ? 'animate-spin' : ''}`} />刷新
|
||||
@@ -87,7 +89,7 @@ export function LogAlertsPanel() {
|
||||
<Badge variant="outline" className="text-[10px] border-[var(--dg-border)]">{rule.source || 'log'}</Badge>
|
||||
<Badge className={`text-[10px] ${severityColor[rule.severity] || ''}`}>{rule.severity}</Badge>
|
||||
{!rule.enabled && rule.enabled !== undefined && (
|
||||
<Badge variant="outline" className="text-[10px] text-[var(--dg-text-muted)]">已禁用</Badge>
|
||||
<Badge variant="outline" className="text-[10px] text-[var(--dg-text-muted)]">{t('alert.disabled')}</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[11px] font-mono text-[var(--dg-text-muted)] mt-1 truncate" title={rule.expression}>{rule.expression}</p>
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
import { useState } from 'react';
|
||||
import { ChevronDown, ChevronRight, Copy, Check } from 'lucide-react';
|
||||
import type { LogCluster } from './types-v3';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface Props {
|
||||
clusters: LogCluster[];
|
||||
}
|
||||
|
||||
function PatternHighlight({ pattern }: { pattern: string }) {
|
||||
const { t } = useTranslation();
|
||||
// 将 * 高亮显示 // Logan.Li
|
||||
const parts = pattern.split('*');
|
||||
return (
|
||||
@@ -23,6 +25,7 @@ function PatternHighlight({ pattern }: { pattern: string }) {
|
||||
}
|
||||
|
||||
export function LogClusterPanel({ clusters }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [expanded, setExpanded] = useState<Record<number, boolean>>({});
|
||||
const [copied, setCopied] = useState<number | null>(null);
|
||||
|
||||
@@ -41,7 +44,7 @@ export function LogClusterPanel({ clusters }: Props) {
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center justify-between px-4 py-2" style={{ background: 'var(--dg-bg-tertiary)', borderBottom: '1px solid var(--dg-border)' }}>
|
||||
<span className="text-xs" style={{ color: 'var(--dg-text-muted)' }}>共 {clusters.length} 个聚类模式,总计 {total.toLocaleString()} 条日志</span>
|
||||
<span className="text-[10px]" style={{ color: 'var(--dg-text-muted)' }}>基于日志相似度自动聚类</span>
|
||||
<span className="text-[10px]" style={{ color: 'var(--dg-text-muted)' }}>{t('monitor.logclusterpanel.s381127')}</span>
|
||||
</div>
|
||||
|
||||
{/* 聚类列表 */}
|
||||
@@ -90,7 +93,7 @@ export function LogClusterPanel({ clusters }: Props) {
|
||||
{/* 展开详情 */}
|
||||
{expanded[i] && (
|
||||
<div className="px-4 py-3 animate-fade-in" style={{ background: 'var(--dg-bg-primary)', paddingLeft: '48px' }}>
|
||||
<p className="text-[10px] mb-1" style={{ color: 'var(--dg-text-muted)' }}>样例日志:</p>
|
||||
<p className="text-[10px] mb-1" style={{ color: 'var(--dg-text-muted)' }}>{t('monitor.logclusterpanel.se909e4')}</p>
|
||||
<div className="p-2 rounded text-[11px] font-mono break-all" style={{ background: 'var(--dg-bg-tertiary)', color: 'var(--dg-text-secondary)' }}>
|
||||
{cluster.sample}
|
||||
</div>
|
||||
|
||||
@@ -14,6 +14,7 @@ import { Loader2, Plug, RefreshCw } from 'lucide-react';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { logCenterApi, type LogDataSource, type TestConnectionResult } from '@/api';
|
||||
import type { LogEngine } from './log-center-types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export function LogDatasourceDialog({
|
||||
open, onOpenChange, engine, editing, onSaved,
|
||||
@@ -24,6 +25,7 @@ export function LogDatasourceDialog({
|
||||
editing?: LogDataSource | null;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
const [name, setName] = useState('');
|
||||
const [url, setUrl] = useState('');
|
||||
@@ -127,12 +129,12 @@ export function LogDatasourceDialog({
|
||||
: payload();
|
||||
const result: TestConnectionResult = await logCenterApi.testDatasource(data);
|
||||
if (result.status === 'ok') {
|
||||
toast({ title: '连接成功' });
|
||||
toast({ title: t('aiops.llmTestSuccess') });
|
||||
} else {
|
||||
toast({ title: '连接失败', description: result.error || '未知错误', variant: 'destructive' });
|
||||
toast({ title: t('aiops.llmTestFailed'), description: result.error || t('monitor.dashboard.agent.toast.unknownError'), variant: 'destructive' });
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast({ title: '连接失败', description: e?.message || String(e), variant: 'destructive' });
|
||||
toast({ title: t('aiops.llmTestFailed'), description: e?.message || String(e), variant: 'destructive' });
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
@@ -147,11 +149,11 @@ export function LogDatasourceDialog({
|
||||
} else {
|
||||
await logCenterApi.createDatasource(payload());
|
||||
}
|
||||
toast({ title: '已保存' });
|
||||
toast({ title: t('deploy.toast.saved') });
|
||||
onOpenChange(false);
|
||||
onSaved();
|
||||
} catch (e: any) {
|
||||
toast({ title: '保存失败', description: e?.message || String(e), variant: 'destructive' });
|
||||
toast({ title: t('common.saveFailed'), description: e?.message || String(e), variant: 'destructive' });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -162,7 +164,7 @@ export function LogDatasourceDialog({
|
||||
<DialogContent className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] max-w-md max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-[var(--dg-text-primary)]">
|
||||
{editing ? '编辑' : '添加'}{engine === 'loki' ? ' Loki' : ' Elasticsearch'} 数据源
|
||||
{editing ? t('common.edit') : t('common.add')}{engine === 'loki' ? ' Loki' : ' Elasticsearch'} 数据源
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-[var(--dg-text-muted)]">
|
||||
仅保存连接信息,日志本体存储在您的 Loki / ES 集群
|
||||
@@ -170,17 +172,17 @@ export function LogDatasourceDialog({
|
||||
</DialogHeader>
|
||||
<div className="space-y-3 py-1">
|
||||
<div>
|
||||
<Label className="text-xs text-[var(--dg-text-muted)]">名称</Label>
|
||||
<Label className="text-xs text-[var(--dg-text-muted)]">{t('common.name')}</Label>
|
||||
<Input value={name} onChange={e => setName(e.target.value)} className="mt-1 h-9 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-[var(--dg-text-muted)]">地址</Label>
|
||||
<Label className="text-xs text-[var(--dg-text-muted)]">{t('common.address')}</Label>
|
||||
<Input value={url} onChange={e => setUrl(e.target.value)} placeholder="http://host:port" className="mt-1 h-9 font-mono text-xs bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]" />
|
||||
</div>
|
||||
{engine === 'elasticsearch' && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs text-[var(--dg-text-muted)]">默认索引</Label>
|
||||
<Label className="text-xs text-[var(--dg-text-muted)]">{t('monitor.logdatasourcedialog.s6621b5')}</Label>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -210,7 +212,7 @@ export function LogDatasourceDialog({
|
||||
获取索引
|
||||
</Button>
|
||||
</div>
|
||||
<Input value={defaultIndex} onChange={e => setDefaultIndex(e.target.value)} placeholder="索引名或通配符,如 .monitoring-es-7-*" className="mt-1 h-9 font-mono text-xs bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]" />
|
||||
<Input value={defaultIndex} onChange={e => setDefaultIndex(e.target.value)} placeholder=t('monitor.logdatasourcedialog.s606402') className="mt-1 h-9 font-mono text-xs bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]" />
|
||||
{/* 已获取索引快捷选择(支持通配) // Logan.Li */}
|
||||
{indices.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1 max-h-24 overflow-auto">
|
||||
@@ -245,23 +247,23 @@ export function LogDatasourceDialog({
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<Label className="text-xs text-[var(--dg-text-muted)]">认证方式</Label>
|
||||
<Label className="text-xs text-[var(--dg-text-muted)]">{t('deploy.cred.authMethod')}</Label>
|
||||
<Select value={authType} onValueChange={(v) => setAuthType(v as LogDataSource['auth_type'])}>
|
||||
<SelectTrigger className="mt-1 h-9 w-full bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<SelectItem value="none" className="text-xs">无认证</SelectItem>
|
||||
<SelectItem value="none" className="text-xs">{t('monitor.logdatasourcedialog.s7870c3')}</SelectItem>
|
||||
<SelectItem value="basic" className="text-xs">Basic Auth</SelectItem>
|
||||
<SelectItem value="bearer" className="text-xs">Bearer Token</SelectItem>
|
||||
<SelectItem value="custom" className="text-xs">自定义 Headers</SelectItem>
|
||||
<SelectItem value="custom" className="text-xs">{t('monitor.logdatasourcedialog.s2e7220')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{authType === 'basic' && (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Input value={user} onChange={e => setUser(e.target.value)} placeholder="用户名" className="h-9 text-xs bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]" />
|
||||
<Input type="password" value={pass} onChange={e => setPass(e.target.value)} placeholder="密码" className="h-9 text-xs bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]" />
|
||||
<Input value={user} onChange={e => setUser(e.target.value)} placeholder=t('login.username') className="h-9 text-xs bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]" />
|
||||
<Input type="password" value={pass} onChange={e => setPass(e.target.value)} placeholder=t('login.password') className="h-9 text-xs bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]" />
|
||||
</div>
|
||||
)}
|
||||
{authType === 'bearer' && (
|
||||
@@ -277,23 +279,23 @@ export function LogDatasourceDialog({
|
||||
const next = [...headers];
|
||||
next[i].name = e.target.value;
|
||||
setHeaders(next);
|
||||
}} placeholder="Header 名,如 X-Scope-OrgID" className="h-9 text-xs bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]" />
|
||||
}} placeholder=t('monitor.logdatasourcedialog.sc7f8eb') className="h-9 text-xs bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]" />
|
||||
<Input value={h.value} onChange={e => {
|
||||
const next = [...headers];
|
||||
next[i].value = e.target.value;
|
||||
setHeaders(next);
|
||||
}} placeholder="Header 值" className="h-9 text-xs bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]" />
|
||||
}} placeholder=t('monitor.logdatasourcedialog.scf7126') className="h-9 text-xs bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]" />
|
||||
</div>
|
||||
))}
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs border-[var(--dg-border)]" onClick={() => setHeaders([...headers, { name: '', value: '' }])}>添加 Header</Button>
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs border-[var(--dg-border)]" onClick={() => setHeaders([...headers, { name: '', value: '' }])}>{t('monitor.logdatasourcedialog.s86a7a6')}</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs text-[var(--dg-text-muted)]">跳过 TLS 校验</Label>
|
||||
<Label className="text-xs text-[var(--dg-text-muted)]">{t('monitor.logdatasourcedialog.sa441a8')}</Label>
|
||||
<Switch checked={skipTLS} onCheckedChange={setSkipTLS} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs text-[var(--dg-text-muted)]">设为默认</Label>
|
||||
<Label className="text-xs text-[var(--dg-text-muted)]">{t('aiops.llmSetDefault')}</Label>
|
||||
<Switch checked={isDefault} onCheckedChange={setIsDefault} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,8 +11,10 @@ import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||
import { logCenterApi, type LogDataSource, type TestConnectionResult } from '@/api';
|
||||
import { LogDatasourceDialog } from './LogDatasourceDialog';
|
||||
import type { LogEngine } from './log-center-types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export function LogDatasourcePage() {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
const confirm = useConfirm();
|
||||
const [sources, setSources] = useState<LogDataSource[]>([]);
|
||||
@@ -46,18 +48,18 @@ export function LogDatasourcePage() {
|
||||
|
||||
const handleDelete = async (ds: LogDataSource) => {
|
||||
const ok = await confirm({
|
||||
title: '删除数据源',
|
||||
description: `确认删除「${ds.name}」?`,
|
||||
title: t('monitor.deleteDatasource'),
|
||||
description: t('monitor.logdatasourcepage.s834083', { ds.name: ds.name }),
|
||||
variant: 'destructive',
|
||||
confirmText: '删除',
|
||||
confirmText: t('common.delete'),
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await logCenterApi.deleteDatasource(ds.id);
|
||||
toast({ title: '已删除' });
|
||||
toast({ title: t('alert.deleted') });
|
||||
load();
|
||||
} catch (e) {
|
||||
toast({ title: '删除失败', description: e?.message || String(e), variant: 'destructive' });
|
||||
toast({ title: t('common.deleteFailed'), description: e?.message || String(e), variant: 'destructive' });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -66,22 +68,22 @@ export function LogDatasourcePage() {
|
||||
// 已保存数据源通过 datasource_id 测试,避免密码丢失或前端暴露密码。// Logan.Li
|
||||
const result: TestConnectionResult = await logCenterApi.testDatasource({ datasource_id: ds.id });
|
||||
if (result.status === 'ok') {
|
||||
toast({ title: '连接成功', description: `${ds.name} 可达` });
|
||||
toast({ title: t('aiops.llmTestSuccess'), description: t('monitor.logdatasourcepage.s6ca8fe', { ds.name: ds.name }) });
|
||||
} else {
|
||||
toast({ title: '连接失败', description: result.error || '未知错误', variant: 'destructive' });
|
||||
toast({ title: t('aiops.llmTestFailed'), description: result.error || t('monitor.dashboard.agent.toast.unknownError'), variant: 'destructive' });
|
||||
}
|
||||
} catch (e) {
|
||||
toast({ title: '连接失败', description: e?.message || String(e), variant: 'destructive' });
|
||||
toast({ title: t('aiops.llmTestFailed'), description: e?.message || String(e), variant: 'destructive' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetDefault = async (ds: LogDataSource) => {
|
||||
try {
|
||||
await logCenterApi.setDefaultDatasource(ds.id);
|
||||
toast({ title: '已设为默认' });
|
||||
toast({ title: t('approval.msg.setDefaultSuccess') });
|
||||
load();
|
||||
} catch (e) {
|
||||
toast({ title: '操作失败', description: e?.message || String(e), variant: 'destructive' });
|
||||
toast({ title: t('common.operationFailed'), description: e?.message || String(e), variant: 'destructive' });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -92,39 +94,39 @@ export function LogDatasourcePage() {
|
||||
|
||||
// DataTable 列定义
|
||||
const columns: DataTableColumn<LogDataSource>[] = [
|
||||
{ key: 'name', header: '名称', sortable: true, render: (ds) => <span className="font-medium text-[var(--dg-text-primary)]">{ds.name}</span> },
|
||||
{ key: 'name', header: t('common.name'), sortable: true, render: (ds) => <span className="font-medium text-[var(--dg-text-primary)]">{ds.name}</span> },
|
||||
{
|
||||
key: 'type', header: '类型', sortable: true, width: 120,
|
||||
key: 'type', header: t('common.type'), sortable: true, width: 120,
|
||||
render: (ds) => <Badge className={engineBadge(ds.type)}>{engineLabel(ds.type)}</Badge>,
|
||||
},
|
||||
{
|
||||
key: 'url', header: '地址',
|
||||
key: 'url', header: t('common.address'),
|
||||
render: (ds) => <span className="text-xs font-mono text-[var(--dg-text-muted)]">{ds.url}</span>,
|
||||
},
|
||||
{
|
||||
key: 'is_default', header: '默认', width: 60, align: 'center', sortable: true,
|
||||
key: 'is_default', header: t('approval.status.default'), width: 60, align: 'center', sortable: true,
|
||||
render: (ds) => ds.is_default ? <Star className="h-4 w-4 dg-text-yellow mx-auto" /> : <span className="text-xs text-[var(--dg-text-muted)]">—</span>,
|
||||
},
|
||||
{
|
||||
key: 'actions', header: '操作', align: 'right', hideable: false,
|
||||
key: 'actions', header: t('common.actions'), align: 'right', hideable: false,
|
||||
render: (ds) => (
|
||||
<div className="flex items-center justify-end gap-0.5">
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]"
|
||||
title="测试连接" onClick={() => handleTest(ds)}>
|
||||
title=t('aiops.llmTest') onClick={() => handleTest(ds)}>
|
||||
<Plug className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
{!ds.is_default && (
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-[var(--dg-text-muted)] hover:dg-text-yellow"
|
||||
title="设为默认" onClick={() => handleSetDefault(ds)}>
|
||||
title=t('aiops.llmSetDefault') onClick={() => handleSetDefault(ds)}>
|
||||
<Star className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]"
|
||||
title="编辑" onClick={() => handleEdit(ds)}>
|
||||
title=t('common.edit') onClick={() => handleEdit(ds)}>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-[var(--dg-text-muted)] hover:dg-text-red"
|
||||
title="删除" onClick={() => handleDelete(ds)}>
|
||||
title=t('common.delete') onClick={() => handleDelete(ds)}>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -146,10 +148,10 @@ export function LogDatasourcePage() {
|
||||
rowKey={(ds) => ds.id}
|
||||
loading={loading}
|
||||
searchable
|
||||
searchPlaceholder="搜索数据源名称或地址..."
|
||||
searchPlaceholder=t('monitor.logdatasourcepage.seb5e4d')
|
||||
searchKeys={['name', 'url']}
|
||||
sortable
|
||||
emptyText="暂无数据源,点击上方按钮添加"
|
||||
emptyText=t('monitor.logdatasourcepage.sc8a960')
|
||||
toolbarExtra={
|
||||
<>
|
||||
<Button variant="outline" size="sm" className="h-8 border-[var(--dg-border)] text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] hover:text-[var(--dg-text-primary)]" onClick={load}>
|
||||
|
||||
@@ -4,8 +4,10 @@ import { Sparkles } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useLogQueryContext } from './LogQueryContext';
|
||||
import { LogAlertFromQueryDialog } from './LogAlertFromQueryDialog';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export function LogDingAssistant({ onRuleCreated }: { onRuleCreated?: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const { context } = useLogQueryContext();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
@@ -19,10 +21,10 @@ export function LogDingAssistant({ onRuleCreated }: { onRuleCreated?: () => void
|
||||
size="lg"
|
||||
className="h-12 rounded-full shadow-lg bg-gradient-to-r from-amber-500 to-orange-500 hover:from-amber-400 hover:to-orange-400 text-white gap-2 px-5"
|
||||
onClick={() => setOpen(true)}
|
||||
title="叮当 · 添加告警"
|
||||
title=t('monitor.logdingassistant.s26b79b')
|
||||
>
|
||||
<Sparkles className="h-5 w-5" />
|
||||
<span className="font-medium">叮当</span>
|
||||
<span className="font-medium">{t('monitor.logdingassistant.s1a6631')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
<LogAlertFromQueryDialog
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { Search, ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import type { LogField } from './types-v3';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface LogFieldSidebarProps {
|
||||
fields: LogField[];
|
||||
@@ -18,6 +19,7 @@ const TYPE_DOT: Record<string, string> = {
|
||||
};
|
||||
|
||||
function FieldDot({ type }: { type?: string }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<span
|
||||
className="w-1.5 h-1.5 rounded-full flex-shrink-0"
|
||||
@@ -30,6 +32,7 @@ export function LogFieldSidebar({
|
||||
fields, indexedFields, selectedFields = [],
|
||||
onFieldClick, onToggleDisplayField, loading,
|
||||
}: LogFieldSidebarProps) {
|
||||
const { t } = useTranslation();
|
||||
const [search, setSearch] = useState('');
|
||||
const [showDisplay, setShowDisplay] = useState(true);
|
||||
const [showIndexed, setShowIndexed] = useState((indexedFields?.length || 0) > 0);
|
||||
@@ -64,7 +67,7 @@ export function LogFieldSidebar({
|
||||
<input
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder="搜索字段"
|
||||
placeholder=t('monitor.logfieldsidebar.s2d3013')
|
||||
className="w-full h-7 pl-7 pr-2 text-xs rounded-md dg-input"
|
||||
style={{ background: 'var(--dg-bg-tertiary)', border: '1px solid var(--dg-border)', color: 'var(--dg-text-primary)' }}
|
||||
/>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { useMemo } from 'react';
|
||||
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
||||
import type { LogEngine } from './types-v3';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface LogHistogramProps {
|
||||
data: { timestamp: string; count: number }[];
|
||||
@@ -31,6 +32,7 @@ function formatBytes(bytes: number): string {
|
||||
}
|
||||
|
||||
export function LogHistogram({ data, engine, onBarClick, loading, totalBytes, lineLimitReached, coverage }: LogHistogramProps) {
|
||||
const { t } = useTranslation();
|
||||
const colors = ENGINE_COLORS[engine];
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
@@ -111,7 +113,7 @@ export function LogHistogram({ data, engine, onBarClick, loading, totalBytes, li
|
||||
fontSize: '11px',
|
||||
color: 'var(--dg-text-primary)',
|
||||
}}
|
||||
formatter={(value: number) => [`${value} 条`, 'logs']}
|
||||
formatter={(value: number) => [t('monitor.loghistogram.sdc4634', { value: value }), 'logs']}
|
||||
labelFormatter={(label: string) => {
|
||||
const item = chartData.find(d => d.label === label);
|
||||
return item ? item.timestamp : label;
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { FileSearch, Database, HardDrive, AlertTriangle, Shield } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import i18n from '@/i18n';
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ path: '/log-center/loki', label: 'Loki 查询', icon: FileSearch },
|
||||
{ path: '/log-center/es', label: 'ES 查询', icon: Database },
|
||||
{ path: '/log-center/datasources', label: '日志库', icon: HardDrive },
|
||||
{ path: '/log-center/alerts', label: '日志告警', icon: AlertTriangle },
|
||||
{ path: '/log-center/rbac', label: '日志权限', icon: Shield },
|
||||
{ path: '/log-center/loki', label: i18n.t('monitor.lokiQuery'), icon: FileSearch },
|
||||
{ path: '/log-center/es', label: i18n.t('monitor.esQuery'), icon: Database },
|
||||
{ path: '/log-center/datasources', label: i18n.t('menu.logLibrary'), icon: HardDrive },
|
||||
{ path: '/log-center/alerts', label: i18n.t('menu.logAlerts'), icon: AlertTriangle },
|
||||
{ path: '/log-center/rbac', label: i18n.t('monitor.logCenter.rbac'), icon: Shield },
|
||||
];
|
||||
|
||||
// 日志中心二级导航 // Logan.Li
|
||||
export function LogNav() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<nav className="flex items-center gap-1 p-1 rounded-lg bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] w-fit flex-wrap">
|
||||
{NAV_ITEMS.map((item) => {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// LogQueryToolbar.tsx — 查询工具栏(统计条 + 分页 + 操作)// Logan.Li
|
||||
import { ArrowUpDown, Bell, Download, Copy, ChevronLeft, ChevronRight, ChevronsDown } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import i18n from '@/i18n';
|
||||
|
||||
interface Props {
|
||||
total: number;
|
||||
@@ -23,9 +25,9 @@ interface Props {
|
||||
}
|
||||
|
||||
const STATUS_LABEL: Record<string, { text: string; color: string }> = {
|
||||
precise: { text: '结果精确', color: '#22c55e' },
|
||||
partial: { text: '部分命中', color: '#f59e0b' },
|
||||
failed: { text: '查询失败', color: '#ef4444' },
|
||||
precise: { text: i18n.t('monitor.logquerytoolbar.sd03684'), color: '#22c55e' },
|
||||
partial: { text: i18n.t('monitor.logquerytoolbar.s8b09f4'), color: '#f59e0b' },
|
||||
failed: { text: i18n.t('monitor.logquerytoolbar.s0d66ed'), color: '#ef4444' },
|
||||
};
|
||||
|
||||
export function LogQueryToolbar({
|
||||
@@ -33,6 +35,7 @@ export function LogQueryToolbar({
|
||||
page, totalPages, onPageChange, sortDirection, onSortToggle,
|
||||
onExport, onCopy, onBellClick, hasAlertRule, hasMore, loadingMore, onLoadMore,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [jumpPage, setJumpPage] = useState('');
|
||||
const st = STATUS_LABEL[status] || STATUS_LABEL.precise;
|
||||
|
||||
@@ -121,7 +124,7 @@ export function LogQueryToolbar({
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-1 ml-1">
|
||||
<span className="text-[10px]" style={{ color: 'var(--dg-text-muted)' }}>到第</span>
|
||||
<span className="text-[10px]" style={{ color: 'var(--dg-text-muted)' }}>{t('monitor.logquerytoolbar.s8b76ad')}</span>
|
||||
<input
|
||||
type="number"
|
||||
value={jumpPage}
|
||||
@@ -130,7 +133,7 @@ export function LogQueryToolbar({
|
||||
className="h-7 w-10 text-center text-[11px] rounded dg-input"
|
||||
style={{ background: 'var(--dg-bg-tertiary)', border: '1px solid var(--dg-border)', color: 'var(--dg-text-primary)' }}
|
||||
/>
|
||||
<span className="text-[10px]" style={{ color: 'var(--dg-text-muted)' }}>页</span>
|
||||
<span className="text-[10px]" style={{ color: 'var(--dg-text-muted)' }}>{t('monitor.dashboard.pagination.perPage')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -152,7 +155,7 @@ export function LogQueryToolbar({
|
||||
onClick={onSortToggle}
|
||||
className="h-7 w-7 flex items-center justify-center rounded border transition-colors"
|
||||
style={{ borderColor: 'var(--dg-border)', color: 'var(--dg-text-muted)', background: 'var(--dg-bg-tertiary)' }}
|
||||
title={sortDirection === 'desc' ? '降序' : '升序'}
|
||||
title={sortDirection === 'desc' ? t('common.descending') : t('common.ascending')}
|
||||
>
|
||||
<ArrowUpDown className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
@@ -160,7 +163,7 @@ export function LogQueryToolbar({
|
||||
onClick={onExport}
|
||||
className="h-7 w-7 flex items-center justify-center rounded border transition-colors"
|
||||
style={{ borderColor: 'var(--dg-border)', color: 'var(--dg-text-muted)', background: 'var(--dg-bg-tertiary)' }}
|
||||
title="导出"
|
||||
title=t('common.export')
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
@@ -168,7 +171,7 @@ export function LogQueryToolbar({
|
||||
onClick={onCopy}
|
||||
className="h-7 w-7 flex items-center justify-center rounded border transition-colors"
|
||||
style={{ borderColor: 'var(--dg-border)', color: 'var(--dg-text-muted)', background: 'var(--dg-bg-tertiary)' }}
|
||||
title="复制"
|
||||
title=t('common.copy')
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
@@ -176,7 +179,7 @@ export function LogQueryToolbar({
|
||||
onClick={onBellClick}
|
||||
className="relative h-7 w-7 flex items-center justify-center rounded border transition-colors"
|
||||
style={{ borderColor: 'var(--dg-border)', color: 'var(--dg-text-muted)', background: 'var(--dg-bg-tertiary)' }}
|
||||
title="关联告警"
|
||||
title=t('aiops.alertAnalysisRelated')
|
||||
>
|
||||
<Bell className="h-3.5 w-3.5" />
|
||||
{hasAlertRule && (
|
||||
|
||||
@@ -18,20 +18,22 @@ import { LogRoleCreateDialog } from './log-rbac/LogRoleCreateDialog';
|
||||
import { matchSampleScopes, SAMPLE_SCOPES, scopeDisplayLabel } from './log-rbac/matchLogScope';
|
||||
import type { LogDataSource } from '@/api/log/log-center';
|
||||
import type { JsonData } from '@/types/common';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import i18n from '@/i18n';
|
||||
|
||||
const ROLE_LABEL: Record<string, string> = {
|
||||
viewer: '查看者',
|
||||
analyst: '分析者',
|
||||
viewer: i18n.t('pipeline.viewer'),
|
||||
analyst: i18n.t('monitor.logrbac.sa72dec'),
|
||||
};
|
||||
|
||||
const ENGINE_LABEL: Record<string, string> = {
|
||||
'*': '全部',
|
||||
'*': i18n.t('common.all'),
|
||||
loki: 'Loki',
|
||||
elasticsearch: 'ES',
|
||||
};
|
||||
|
||||
const FIELD_LABEL: Record<string, string> = {
|
||||
index: '索引',
|
||||
index: i18n.t('monitor.logIndex'),
|
||||
app: 'app',
|
||||
namespace: 'namespace',
|
||||
job: 'job',
|
||||
@@ -39,6 +41,7 @@ const FIELD_LABEL: Record<string, string> = {
|
||||
|
||||
// 日志查询范围权限 — 正则匹配 ES 索引 / Loki Label // Logan.Li
|
||||
export default function LogRBAC() {
|
||||
const { t } = useTranslation();
|
||||
const [roles, setRoles] = useState<LogScopeRole[]>([]);
|
||||
const [datasources, setDatasources] = useState<LogDataSource[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -106,7 +109,7 @@ export default function LogRBAC() {
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!newName.trim() || !newCode.trim() || !newPattern.trim()) {
|
||||
toast.error('请填写名称、标识与正则'); return;
|
||||
toast.error(t('monitor.logrbac.sf6e80b')); return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
@@ -120,83 +123,83 @@ export default function LogRBAC() {
|
||||
description: newDesc,
|
||||
...(newDatasourceId ? { datasource_id: Number(newDatasourceId) } : {}),
|
||||
});
|
||||
toast.success('已创建');
|
||||
toast.success(t('deploy.toast.created'));
|
||||
setCreateOpen(false);
|
||||
setNewName(''); setNewCode(''); setNewEngine('*'); setNewMatchField('app');
|
||||
setNewPattern(''); setNewRole('viewer'); setNewDatasourceId(''); setNewDesc('');
|
||||
loadData();
|
||||
} catch (e: unknown) {
|
||||
toast.error((e as Error)?.message || '创建失败');
|
||||
toast.error((e as Error)?.message || t('monitor.quickSetupFailed'));
|
||||
} finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const handleUpdate = async (id: number) => {
|
||||
if (!editPattern.trim()) { toast.error('正则不能为空'); return; }
|
||||
if (!editPattern.trim()) { toast.error(t('pipeline.regexEmpty')); return; }
|
||||
setSaving(true);
|
||||
try {
|
||||
await logRbacApi.updateRole(id, { pattern: editPattern, role: editRole });
|
||||
toast.success('已更新');
|
||||
toast.success(t('deploy.toast.updated'));
|
||||
setEditing(null);
|
||||
loadData();
|
||||
} catch (e: unknown) {
|
||||
toast.error((e as Error)?.message || '更新失败');
|
||||
toast.error((e as Error)?.message || t('alert.updateFailed'));
|
||||
} finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const handleDelete = async (role: LogScopeRole) => {
|
||||
const ok = await _confirm({
|
||||
title: '删除权限',
|
||||
description: `确定删除「${role.name}」?将影响 ${matchCount[role.id]?.length || 0} 个示例范围`,
|
||||
title: t('monitor.logrbac.s5d79e4'),
|
||||
description: t('monitor.logrbac.s81b9d1', { role.name: role.name, matchCount[role.id]?.length || 0: matchCount[role.id]?.length || 0 }),
|
||||
variant: 'destructive',
|
||||
});
|
||||
if (!ok) return;
|
||||
try { await logRbacApi.deleteRole(role.id); toast.success('已删除'); loadData(); }
|
||||
catch { toast.error('删除失败'); }
|
||||
try { await logRbacApi.deleteRole(role.id); toast.success(t('alert.deleted')); loadData(); }
|
||||
catch { toast.error(t('common.deleteFailed')); }
|
||||
};
|
||||
|
||||
const handleToggleStatus = async (role: LogScopeRole) => {
|
||||
try {
|
||||
await logRbacApi.updateRole(role.id, { status: role.status ? 0 : 1 });
|
||||
toast.success(role.status ? '已禁用' : '已启用');
|
||||
toast.success(role.status ? t('alert.disabled') : t('alert.enabled'));
|
||||
loadData();
|
||||
} catch { toast.error('操作失败'); }
|
||||
} catch { toast.error(t('common.operationFailed')); }
|
||||
};
|
||||
|
||||
const handleAssign = async () => {
|
||||
if (!selectedUserId || !assignRoleId) { toast.error('请选择用户和权限'); return; }
|
||||
if (!selectedUserId || !assignRoleId) { toast.error(t('monitor.logrbac.s0fc539')); return; }
|
||||
setAssignLoading(true);
|
||||
try {
|
||||
await logRbacApi.assignRole(Number(selectedUserId), {
|
||||
log_scope_role_id: Number(assignRoleId),
|
||||
});
|
||||
toast.success('已分配');
|
||||
toast.success(t('common.assigned'));
|
||||
setAssignRoleId('');
|
||||
loadAssignments();
|
||||
} catch (e: unknown) {
|
||||
toast.error((e as Error)?.message || '分配失败');
|
||||
toast.error((e as Error)?.message || t('deploy.rbac.assignFail'));
|
||||
} finally { setAssignLoading(false); }
|
||||
};
|
||||
|
||||
const handleRevoke = async (id: number) => {
|
||||
try {
|
||||
await logRbacApi.removeRole(Number(selectedUserId), id);
|
||||
toast.success('已撤销');
|
||||
toast.success(t('deploy.toast.revoked'));
|
||||
loadAssignments();
|
||||
} catch { toast.error('撤销失败'); }
|
||||
} catch { toast.error(t('deploy.toast.revokeFailed')); }
|
||||
};
|
||||
|
||||
const selectedUser = users.find((u) => String(u.id) === selectedUserId);
|
||||
const pageTabs = [
|
||||
{ id: 'roles', label: '权限策略', badge: roles.length || undefined },
|
||||
{ id: 'users', label: '用户授权' },
|
||||
{ id: 'preview', label: '命中预览' },
|
||||
{ id: 'roles', label: t('monitor.logrbac.s93ef07'), badge: roles.length || undefined },
|
||||
{ id: 'users', label: t('monitor.logrbac.s2de895') },
|
||||
{ id: 'preview', label: t('pipeline.matchPreview') },
|
||||
];
|
||||
|
||||
return (
|
||||
<PageShell
|
||||
variant="list"
|
||||
title="日志权限"
|
||||
subtitle="按 ES 索引 / Loki Label 正则限制查询范围"
|
||||
title=t('monitor.logCenter.rbac')
|
||||
subtitle=t('monitor.logCenter.rbacDesc')
|
||||
icon={<ScrollText className="h-5 w-5" />}
|
||||
loading={loading}
|
||||
tabs={pageTabs}
|
||||
@@ -213,9 +216,9 @@ export default function LogRBAC() {
|
||||
roles.length === 0 ? (
|
||||
<PageEmpty
|
||||
icon={<Shield className="h-10 w-10" />}
|
||||
title="暂无日志权限策略"
|
||||
description="创建策略并分配给用户后,将按正则限制可查询的索引与 Label 范围"
|
||||
action={<Button onClick={() => setCreateOpen(true)}><Plus className="h-4 w-4 mr-1" />新建权限</Button>}
|
||||
title=t('monitor.logrbac.s4a00d2')
|
||||
description=t('monitor.logrbac.sd11b74')
|
||||
action={<Button onClick={() => setCreateOpen(true)}><Plus className="h-4 w-4 mr-1" />{t('monitor.logrbac.s605884')}</Button>}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
@@ -235,7 +238,7 @@ export default function LogRBAC() {
|
||||
<Badge variant="outline" className="text-[10px] border-[var(--dg-border)]">
|
||||
{FIELD_LABEL[role.match_field] || role.match_field}
|
||||
</Badge>
|
||||
{role.status === 0 && <Badge variant="outline" className="text-[10px]">已禁用</Badge>}
|
||||
{role.status === 0 && <Badge variant="outline" className="text-[10px]">{t('alert.disabled')}</Badge>}
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] cursor-pointer border-[var(--dg-border)]"
|
||||
@@ -256,8 +259,8 @@ export default function LogRBAC() {
|
||||
<Select value={editRole} onValueChange={setEditRole}>
|
||||
<SelectTrigger className="h-7 w-20 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="viewer">查看者</SelectItem>
|
||||
<SelectItem value="analyst">分析者</SelectItem>
|
||||
<SelectItem value="viewer">{t('pipeline.viewer')}</SelectItem>
|
||||
<SelectItem value="analyst">{t('monitor.logrbac.sa72dec')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => handleUpdate(role.id)}><Check className="w-3.5 h-3.5" /></Button>
|
||||
@@ -265,8 +268,8 @@ export default function LogRBAC() {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={() => { setEditing(role.id); setEditPattern(role.pattern); setEditRole(role.role); }}>编辑</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={() => handleToggleStatus(role)}>{role.status ? '禁用' : '启用'}</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={() => { setEditing(role.id); setEditPattern(role.pattern); setEditRole(role.role); }}>{t('common.edit')}</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={() => handleToggleStatus(role)}>{role.status ? t('common.disable') : t('common.enable')}</Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-[var(--dg-red)]" onClick={() => handleDelete(role)}><Trash2 className="w-3.5 h-3.5" /></Button>
|
||||
</>
|
||||
)}
|
||||
@@ -287,9 +290,9 @@ export default function LogRBAC() {
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-3 items-end">
|
||||
<div className="space-y-1 flex-1 min-w-[200px]">
|
||||
<Label className="text-xs">用户</Label>
|
||||
<Label className="text-xs">{t('system.roleUsers')}</Label>
|
||||
<Select value={selectedUserId} onValueChange={setSelectedUserId}>
|
||||
<SelectTrigger className="h-8 text-sm dg-input"><SelectValue placeholder="选择用户" /></SelectTrigger>
|
||||
<SelectTrigger className="h-8 text-sm dg-input"><SelectValue placeholder=t('deploy.rbac.selectUser') /></SelectTrigger>
|
||||
<SelectContent className="max-h-60">
|
||||
{users.map((u) => (
|
||||
<SelectItem key={u.id as number} value={String(u.id)}>
|
||||
@@ -300,9 +303,9 @@ export default function LogRBAC() {
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1 w-48">
|
||||
<Label className="text-xs">权限策略</Label>
|
||||
<Label className="text-xs">{t('monitor.logrbac.s93ef07')}</Label>
|
||||
<Select value={assignRoleId} onValueChange={setAssignRoleId}>
|
||||
<SelectTrigger className="h-8 text-sm dg-input"><SelectValue placeholder="选择策略" /></SelectTrigger>
|
||||
<SelectTrigger className="h-8 text-sm dg-input"><SelectValue placeholder=t('kubernetes.hpa.selectPolicy') /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{roles.filter((r) => r.status === 1).map((r) => (
|
||||
<SelectItem key={r.id} value={String(r.id)}>{r.name} ({ROLE_LABEL[r.role]})</SelectItem>
|
||||
@@ -317,7 +320,7 @@ export default function LogRBAC() {
|
||||
</div>
|
||||
{selectedUser ? (
|
||||
assignments.length === 0 ? (
|
||||
<p className="text-xs text-[var(--dg-text-muted)]">该用户尚未分配日志权限</p>
|
||||
<p className="text-xs text-[var(--dg-text-muted)]">{t('monitor.logrbac.sa120fc')}</p>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{assignments.map((a) => {
|
||||
@@ -338,7 +341,7 @@ export default function LogRBAC() {
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<p className="text-xs text-[var(--dg-text-muted)] flex items-center gap-1"><Users className="w-3.5 h-3.5" />选择用户后查看已分配权限</p>
|
||||
<p className="text-xs text-[var(--dg-text-muted)] flex items-center gap-1"><Users className="w-3.5 h-3.5" />{t('monitor.logrbac.s9be00c')}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -371,7 +374,7 @@ export default function LogRBAC() {
|
||||
</Badge>
|
||||
))}
|
||||
{previewRoleId !== null && previewMatches.length === 0 && (
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">无匹配 — 请调整正则</span>
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">{t('monitor.logrbac.s64096f')}</span>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ChevronDown, ChevronRight, Loader2, Tags } from 'lucide-react';
|
||||
import { logCenterApi } from '@/api/log/log-center';
|
||||
import { extractLokiSelector, mergeLokiSelector } from './query-builder';
|
||||
import type { TimeRange } from './types-v3';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface Props {
|
||||
datasourceId?: number;
|
||||
@@ -13,6 +14,7 @@ interface Props {
|
||||
|
||||
// Loki Label 浏览器 — 标签名 → 标签值 → 注入 LogQL 选择器 // Logan.Li
|
||||
export function LokiLabelBrowser({ datasourceId, query, timeRange, onQueryChange }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [labels, setLabels] = useState<string[]>([]);
|
||||
const [activeLabel, setActiveLabel] = useState('');
|
||||
@@ -95,7 +97,7 @@ export function LokiLabelBrowser({ datasourceId, query, timeRange, onQueryChange
|
||||
))}
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto max-h-48 p-1">
|
||||
{!activeLabel && <p className="text-[11px] px-2 py-4 text-center" style={{ color: 'var(--dg-text-muted)' }}>选择左侧标签</p>}
|
||||
{!activeLabel && <p className="text-[11px] px-2 py-4 text-center" style={{ color: 'var(--dg-text-muted)' }}>{t('monitor.lokilabelbrowser.s088b94')}</p>}
|
||||
{loadingValues && <div className="flex justify-center py-6"><Loader2 className="h-4 w-4 animate-spin" /></div>}
|
||||
{!loadingValues && activeLabel && values.map((v) => (
|
||||
<button
|
||||
|
||||
@@ -4,6 +4,8 @@ import { useState, useEffect } from 'react';
|
||||
import type { QueryHistoryItem } from './types-v3';
|
||||
import { historyStorage } from './types-v3';
|
||||
import { LogAlertBell } from './LogAlertBell';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import i18n from '@/i18n';
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
@@ -20,13 +22,14 @@ const ENGINE_ICONS: Record<string, { Icon: typeof Flame; color: string }> = {
|
||||
|
||||
function relativeTime(ts: number): string {
|
||||
const diff = Date.now() - ts;
|
||||
if (diff < 60000) return '刚刚';
|
||||
if (diff < 3600000) return `${Math.floor(diff / 60000)}分钟前`;
|
||||
if (diff < 86400000) return `${Math.floor(diff / 3600000)}小时前`;
|
||||
return `${Math.floor(diff / 86400000)}天前`;
|
||||
if (diff < 60000) return i18n.t('task.cronJob.time.justNow');
|
||||
if (diff < 3600000) return i18n.t('monitor.queryhistorypanel.sff3552', { Math.floor(diff / 60000): Math.floor(diff / 60000) });
|
||||
if (diff < 86400000) return i18n.t('monitor.queryhistorypanel.sd17c09', { Math.floor(diff / 3600000): Math.floor(diff / 3600000) });
|
||||
return i18n.t('monitor.queryhistorypanel.s9a6be5', { Math.floor(diff / 86400000): Math.floor(diff / 86400000) });
|
||||
}
|
||||
|
||||
export function QueryHistoryPanel({ visible, onClose, onSelect, onCreateAlert }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [history, setHistory] = useState<QueryHistoryItem[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -59,7 +62,7 @@ export function QueryHistoryPanel({ visible, onClose, onSelect, onCreateAlert }:
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b" style={{ borderColor: 'var(--dg-border)' }}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-4 w-4" style={{ color: 'var(--dg-text-muted)' }} />
|
||||
<span className="text-sm font-medium" style={{ color: 'var(--dg-text-primary)' }}>查询历史</span>
|
||||
<span className="text-sm font-medium" style={{ color: 'var(--dg-text-primary)' }}>{t('monitor.logQueryHistory')}</span>
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded" style={{ background: 'var(--dg-bg-tertiary)', color: 'var(--dg-text-muted)' }}>
|
||||
{history.length} 条
|
||||
</span>
|
||||
@@ -69,7 +72,7 @@ export function QueryHistoryPanel({ visible, onClose, onSelect, onCreateAlert }:
|
||||
onClick={handleClear}
|
||||
className="h-7 w-7 flex items-center justify-center rounded transition-colors"
|
||||
style={{ color: 'var(--dg-text-muted)' }}
|
||||
title="清空"
|
||||
title=t('common.clear')
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
@@ -88,8 +91,8 @@ export function QueryHistoryPanel({ visible, onClose, onSelect, onCreateAlert }:
|
||||
{history.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-48 gap-2">
|
||||
<Clock className="h-8 w-8" style={{ color: 'var(--dg-border)' }} />
|
||||
<span className="text-xs" style={{ color: 'var(--dg-text-muted)' }}>暂无查询记录</span>
|
||||
<span className="text-[10px]" style={{ color: 'var(--dg-text-muted)' }}>执行查询后将自动记录</span>
|
||||
<span className="text-xs" style={{ color: 'var(--dg-text-muted)' }}>{t('monitor.queryhistorypanel.s1e60bc')}</span>
|
||||
<span className="text-[10px]" style={{ color: 'var(--dg-text-muted)' }}>{t('monitor.queryhistorypanel.s389502')}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y" style={{ borderColor: 'var(--dg-border)' }}>
|
||||
@@ -137,7 +140,7 @@ export function QueryHistoryPanel({ visible, onClose, onSelect, onCreateAlert }:
|
||||
onClick={() => onSelect(item)}
|
||||
className="h-6 w-6 flex items-center justify-center rounded transition-colors"
|
||||
style={{ background: 'var(--dg-bg-tertiary)', color: 'var(--dg-green)' }}
|
||||
title="执行查询"
|
||||
title=t('monitor.queryhistorypanel.s4fb36a')
|
||||
>
|
||||
<Play className="h-3 w-3" />
|
||||
</button>
|
||||
|
||||
@@ -1,43 +1,46 @@
|
||||
import { useState } from 'react';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import i18n from '@/i18n';
|
||||
|
||||
const ALL_TEMPLATES: Record<'loki' | 'elasticsearch', { label: string; query: string }[]> = {
|
||||
loki: [
|
||||
{ label: 'Nginx 错误', query: '{job="nginx"} |= "error"' },
|
||||
{ label: 'JSON 过滤', query: '{namespace="prod"} | json | level="warn"' },
|
||||
{ label: '超时搜索', query: '{app="api"} |= "timeout"' },
|
||||
{ label: '级别统计', query: 'sum by(level) (count_over_time({job="app"}[5m]))' },
|
||||
{ label: '正则多级别', query: '{container="frontend"} |~ "ERROR|WARN|FATAL"' },
|
||||
{ label: '慢请求>1s', query: '{job="api"} | json | response_time > 1000' },
|
||||
{ label: '错误率统计', query: 'sum by(level) (count_over_time({app="cex-admin-api"} |= "error" [5m]))' },
|
||||
{ label: '追踪ID查询', query: '{app="cex-admin-api"} |= "trace_id" | json' },
|
||||
{ label: '服务聚合', query: 'sum by(service_name) (count_over_time({namespace="astralx"}[5m]))' },
|
||||
{ label: '高延迟筛选', query: '{app="cex-admin-api"} | json | response_time > 500' },
|
||||
{ label: '异常堆栈', query: '{app="cex-admin-api"} |= "Exception"' },
|
||||
{ label: '日志趋势', query: 'sum(count_over_time({app="cex-admin-api"}[1h]))' },
|
||||
{ label: '正则提取', query: '{app="cex-admin-api"} | regexp "(?P<method>[A-Za-z0-9_]+) (?P<path>[^ ]+)"' },
|
||||
{ label: '格式输出', query: '{app="cex-admin-api"} | json | line_format "{{.level}} {{.message}}"' },
|
||||
{ label: i18n.t('monitor.quickquerybar.s645595'), query: '{job="nginx"} |= "error"' },
|
||||
{ label: i18n.t('monitor.quickquerybar.s315990'), query: '{namespace="prod"} | json | level="warn"' },
|
||||
{ label: i18n.t('monitor.quickquerybar.sfc6d42'), query: '{app="api"} |= "timeout"' },
|
||||
{ label: i18n.t('monitor.quickquerybar.s0ce3d2'), query: 'sum by(level) (count_over_time({job="app"}[5m]))' },
|
||||
{ label: i18n.t('monitor.quickquerybar.s6209b1'), query: '{container="frontend"} |~ "ERROR|WARN|FATAL"' },
|
||||
{ label: i18n.t('monitor.quickquerybar.sf839ef'), query: '{job="api"} | json | response_time > 1000' },
|
||||
{ label: i18n.t('monitor.quickquerybar.s386c75'), query: 'sum by(level) (count_over_time({app="cex-admin-api"} |= "error" [5m]))' },
|
||||
{ label: i18n.t('monitor.quickquerybar.sf94f3f'), query: '{app="cex-admin-api"} |= "trace_id" | json' },
|
||||
{ label: i18n.t('monitor.quickquerybar.s7f0fa5'), query: 'sum by(service_name) (count_over_time({namespace="astralx"}[5m]))' },
|
||||
{ label: i18n.t('monitor.quickquerybar.s49c762'), query: '{app="cex-admin-api"} | json | response_time > 500' },
|
||||
{ label: i18n.t('monitor.quickquerybar.sb8edf4'), query: '{app="cex-admin-api"} |= "Exception"' },
|
||||
{ label: i18n.t('monitor.logTrend'), query: 'sum(count_over_time({app="cex-admin-api"}[1h]))' },
|
||||
{ label: i18n.t('monitor.quickquerybar.s1ace0c'), query: '{app="cex-admin-api"} | regexp "(?P<method>[A-Za-z0-9_]+) (?P<path>[^ ]+)"' },
|
||||
{ label: i18n.t('monitor.quickquerybar.s20b342'), query: '{app="cex-admin-api"} | json | line_format "{{.level}} {{.message}}"' },
|
||||
],
|
||||
elasticsearch: [
|
||||
{ label: 'API 5xx', query: 'status:500 AND path:"/api/*"' },
|
||||
{ label: '错误超时', query: '(level:error OR log.level:error) AND message:*timeout*' },
|
||||
{ label: '排除调试', query: 'host.name:server-* AND NOT level:debug' },
|
||||
{ label: '慢请求', query: 'response_time:>1000 AND method:POST' },
|
||||
{ label: '严重错误', query: '@timestamp:[now-1h TO now] AND (level:critical OR log.level:critical)' },
|
||||
{ label: '服务错误', query: 'service:nginx AND (level:(error OR fatal) OR log.level:(error OR fatal))' },
|
||||
{ label: '组合查询', query: 'app:cex-admin-api AND (level:(error OR warn) OR log.level:(error OR warn))' },
|
||||
{ label: '模糊匹配', query: 'message:*timeout* AND app:cex-admin-api' },
|
||||
{ label: '范围查询', query: 'response_time:[1000 TO *] AND method:POST' },
|
||||
{ label: '排除路径', query: 'app:cex-admin-api AND NOT path:/health' },
|
||||
{ label: '时间过滤', query: '@timestamp:[now-1h TO now] AND level:error' },
|
||||
{ label: 'IP段过滤', query: 'client_ip:172.36.* AND app:cex-admin-api' },
|
||||
{ label: '多级别筛选', query: 'app:cex-admin-api AND (level:(error OR warn OR info) OR log.level:(error OR warn OR info))' },
|
||||
{ label: '嵌套字段', query: 'labels.orgId:10001 AND app:cex-admin-api' },
|
||||
{ label: i18n.t('monitor.quickquerybar.sa1eb81'), query: '(level:error OR log.level:error) AND message:*timeout*' },
|
||||
{ label: i18n.t('monitor.quickquerybar.s0235c6'), query: 'host.name:server-* AND NOT level:debug' },
|
||||
{ label: i18n.t('monitor.quickquerybar.s0876c4'), query: 'response_time:>1000 AND method:POST' },
|
||||
{ label: i18n.t('monitor.quickquerybar.se31850'), query: '@timestamp:[now-1h TO now] AND (level:critical OR log.level:critical)' },
|
||||
{ label: i18n.t('monitor.quickquerybar.s6241b8'), query: 'service:nginx AND (level:(error OR fatal) OR log.level:(error OR fatal))' },
|
||||
{ label: i18n.t('monitor.quickquerybar.s04adb0'), query: 'app:cex-admin-api AND (level:(error OR warn) OR log.level:(error OR warn))' },
|
||||
{ label: i18n.t('monitor.quickquerybar.sc579cf'), query: 'message:*timeout* AND app:cex-admin-api' },
|
||||
{ label: i18n.t('monitor.quickquerybar.s0c9d78'), query: 'response_time:[1000 TO *] AND method:POST' },
|
||||
{ label: i18n.t('monitor.quickquerybar.se11dce'), query: 'app:cex-admin-api AND NOT path:/health' },
|
||||
{ label: i18n.t('monitor.quickquerybar.s00f6f7'), query: '@timestamp:[now-1h TO now] AND level:error' },
|
||||
{ label: i18n.t('monitor.quickquerybar.s8a1879'), query: 'client_ip:172.36.* AND app:cex-admin-api' },
|
||||
{ label: i18n.t('monitor.quickquerybar.s6f4019'), query: 'app:cex-admin-api AND (level:(error OR warn OR info) OR log.level:(error OR warn OR info))' },
|
||||
{ label: i18n.t('monitor.quickquerybar.sf09e00'), query: 'labels.orgId:10001 AND app:cex-admin-api' },
|
||||
],
|
||||
};
|
||||
|
||||
// 快捷查询模板条 // Logan.Li
|
||||
export function QuickQueryBar({ engine, onSelect }: { engine: 'loki' | 'elasticsearch'; onSelect: (q: string) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const templates = ALL_TEMPLATES[engine];
|
||||
const labels: Record<'loki' | 'elasticsearch', string> = { loki: 'LogQL', elasticsearch: 'Lucene' };
|
||||
|
||||
@@ -5,6 +5,7 @@ import { alertApi } from '@/api';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import type { LogEngine, TimeRange } from './types-v3';
|
||||
import { ENGINE_META, savedQueryStorage } from './types-v3';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface SaveQueryProps {
|
||||
open: boolean;
|
||||
@@ -18,6 +19,7 @@ interface SaveQueryProps {
|
||||
}
|
||||
|
||||
export function SaveQueryDialog({ open, onClose, engine, query, timeRange, datasourceId, datasourceName, onSave }: SaveQueryProps) {
|
||||
const { t } = useTranslation();
|
||||
const [name, setName] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
@@ -46,24 +48,24 @@ export function SaveQueryDialog({ open, onClose, engine, query, timeRange, datas
|
||||
<div className="fixed inset-0 z-50" style={{ background: 'rgba(0,0,0,0.5)' }} onClick={onClose} />
|
||||
<div className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-50 w-full max-w-md rounded-lg border shadow-xl overflow-hidden" style={{ background: 'var(--dg-bg-secondary)', borderColor: 'var(--dg-border)' }}>
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b" style={{ borderColor: 'var(--dg-border)' }}>
|
||||
<div className="flex items-center gap-2"><Save className="h-4 w-4" style={{ color: 'var(--dg-blue)' }} /><span className="text-sm font-medium" style={{ color: 'var(--dg-text-primary)' }}>另存为查询</span></div>
|
||||
<div className="flex items-center gap-2"><Save className="h-4 w-4" style={{ color: 'var(--dg-blue)' }} /><span className="text-sm font-medium" style={{ color: 'var(--dg-text-primary)' }}>{t('monitor.statChart.saveAsQuery')}</span></div>
|
||||
<button onClick={onClose} className="h-7 w-7 flex items-center justify-center" style={{ color: 'var(--dg-text-muted)' }}><X className="h-4 w-4" /></button>
|
||||
</div>
|
||||
<div className="p-4 space-y-3">
|
||||
<div>
|
||||
<label className="text-[10px] block mb-1" style={{ color: 'var(--dg-text-muted)' }}>查询名称 *</label>
|
||||
<input value={name} onChange={e => setName(e.target.value)} placeholder="输入查询名称" className="w-full h-9 px-3 text-xs rounded" style={{ background: 'var(--dg-bg-tertiary)', border: '1px solid var(--dg-border)', color: 'var(--dg-text-primary)' }} />
|
||||
<label className="text-[10px] block mb-1" style={{ color: 'var(--dg-text-muted)' }}>{t('monitor.savequerydialog.s54c068')}</label>
|
||||
<input value={name} onChange={e => setName(e.target.value)} placeholder=t('monitor.savequerydialog.s30168e') className="w-full h-9 px-3 text-xs rounded" style={{ background: 'var(--dg-bg-tertiary)', border: '1px solid var(--dg-border)', color: 'var(--dg-text-primary)' }} />
|
||||
</div>
|
||||
<div className="p-2 rounded text-[11px]" style={{ background: 'var(--dg-bg-tertiary)', color: 'var(--dg-text-secondary)' }}>
|
||||
<span style={{ color: ENGINE_META[engine].color }}>{ENGINE_META[engine].name}</span> · {datasourceName || '默认'}<br />
|
||||
<span style={{ color: ENGINE_META[engine].color }}>{ENGINE_META[engine].name}</span> · {datasourceName || t('approval.status.default')}<br />
|
||||
<span className="font-mono">{query}</span><br />
|
||||
<span style={{ color: 'var(--dg-text-muted)' }}>{timeRange.label} · {timeRange.start} ~ {timeRange.end}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2 px-4 py-3 border-t" style={{ borderColor: 'var(--dg-border)' }}>
|
||||
<button onClick={onClose} className="h-8 px-3 rounded text-xs" style={{ color: 'var(--dg-text-secondary)', border: '1px solid var(--dg-border)', background: 'var(--dg-bg-tertiary)' }}>取消</button>
|
||||
<button onClick={onClose} className="h-8 px-3 rounded text-xs" style={{ color: 'var(--dg-text-secondary)', border: '1px solid var(--dg-border)', background: 'var(--dg-bg-tertiary)' }}>{t('common.cancel')}</button>
|
||||
<button onClick={handleSave} disabled={saving || !name.trim() || saved} className="h-8 px-4 rounded text-xs font-medium text-white flex items-center gap-1" style={{ background: 'var(--dg-blue)' }}>
|
||||
{saving && <Loader2 className="h-3.5 w-3.5 animate-spin" />}{saved ? '已保存' : '保存'}
|
||||
{saving && <Loader2 className="h-3.5 w-3.5 animate-spin" />}{saved ? i18n.t('deploy.toast.saved') : i18n.t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -83,6 +85,7 @@ interface SaveAlertProps {
|
||||
}
|
||||
|
||||
export function SaveAlertDialog({ open, onClose, engine, query, datasourceId, datasourceName, onSave }: SaveAlertProps) {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
const [name, setName] = useState('');
|
||||
const [severity, setSeverity] = useState('warning');
|
||||
@@ -100,7 +103,7 @@ export function SaveAlertDialog({ open, onClose, engine, query, datasourceId, da
|
||||
try {
|
||||
await alertApi.createRule({
|
||||
name: name.trim(),
|
||||
description: `从查询页创建: ${datasourceName || '默认数据源'}`,
|
||||
description: t('monitor.savequerydialog.s499c4b', { datasourceName || '默认数据源': datasourceName || '默认数据源' }),
|
||||
type: 'log',
|
||||
source: engine === 'sls' ? 'loki' : engine,
|
||||
expression: query.trim(),
|
||||
@@ -110,11 +113,11 @@ export function SaveAlertDialog({ open, onClose, engine, query, datasourceId, da
|
||||
notify_channels: [],
|
||||
});
|
||||
setSaved(true);
|
||||
toast({ title: '告警规则已创建' });
|
||||
toast({ title: t('monitor.savequerydialog.sd3dbea') });
|
||||
onSave?.();
|
||||
setTimeout(() => { setSaved(false); onClose(); }, 800);
|
||||
} catch (e: any) {
|
||||
toast({ title: '创建失败', description: e?.message || String(e), variant: 'destructive' });
|
||||
toast({ title: t('monitor.quickSetupFailed'), description: e?.message || String(e), variant: 'destructive' });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -125,35 +128,35 @@ export function SaveAlertDialog({ open, onClose, engine, query, datasourceId, da
|
||||
<div className="fixed inset-0 z-50" style={{ background: 'rgba(0,0,0,0.5)' }} onClick={onClose} />
|
||||
<div className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-50 w-full max-w-md rounded-lg border shadow-xl overflow-hidden" style={{ background: 'var(--dg-bg-secondary)', borderColor: 'var(--dg-border)' }}>
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b" style={{ borderColor: 'var(--dg-border)' }}>
|
||||
<div className="flex items-center gap-2"><BellRing className="h-4 w-4" style={{ color: '#f59e0b' }} /><span className="text-sm font-medium" style={{ color: 'var(--dg-text-primary)' }}>另存为告警</span></div>
|
||||
<div className="flex items-center gap-2"><BellRing className="h-4 w-4" style={{ color: '#f59e0b' }} /><span className="text-sm font-medium" style={{ color: 'var(--dg-text-primary)' }}>{t('monitor.logSaveAsAlert')}</span></div>
|
||||
<button onClick={onClose} className="h-7 w-7 flex items-center justify-center" style={{ color: 'var(--dg-text-muted)' }}><X className="h-4 w-4" /></button>
|
||||
</div>
|
||||
<div className="p-4 space-y-3">
|
||||
<div>
|
||||
<label className="text-[10px] block mb-1" style={{ color: 'var(--dg-text-muted)' }}>告警名称 *</label>
|
||||
<input value={name} onChange={e => setName(e.target.value)} placeholder="输入告警规则名称" className="w-full h-9 px-3 text-xs rounded" style={{ background: 'var(--dg-bg-tertiary)', border: '1px solid var(--dg-border)', color: 'var(--dg-text-primary)' }} />
|
||||
<label className="text-[10px] block mb-1" style={{ color: 'var(--dg-text-muted)' }}>{t('monitor.savequerydialog.s78e82a')}</label>
|
||||
<input value={name} onChange={e => setName(e.target.value)} placeholder=t('monitor.savequerydialog.sa3a8d7') className="w-full h-9 px-3 text-xs rounded" style={{ background: 'var(--dg-bg-tertiary)', border: '1px solid var(--dg-border)', color: 'var(--dg-text-primary)' }} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-[10px] block mb-1" style={{ color: 'var(--dg-text-muted)' }}>级别</label>
|
||||
<label className="text-[10px] block mb-1" style={{ color: 'var(--dg-text-muted)' }}>{t('common.level')}</label>
|
||||
<select value={severity} onChange={e => setSeverity(e.target.value)} className="w-full h-9 px-2 text-xs rounded" style={{ background: 'var(--dg-bg-tertiary)', border: '1px solid var(--dg-border)', color: 'var(--dg-text-primary)' }}>
|
||||
<option value="info">提示</option><option value="warning">警告</option><option value="critical">严重</option>
|
||||
<option value="info">{t('common.info')}</option><option value="warning">{t('common.warning')}</option><option value="critical">{t('monitor.critical')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] block mb-1" style={{ color: 'var(--dg-text-muted)' }}>触发条件</label>
|
||||
<label className="text-[10px] block mb-1" style={{ color: 'var(--dg-text-muted)' }}>{t('monitor.savequerydialog.s65939c')}</label>
|
||||
<input value={condition} onChange={e => setCondition(e.target.value)} className="w-full h-9 px-2 text-xs rounded" style={{ background: 'var(--dg-bg-tertiary)', border: '1px solid var(--dg-border)', color: 'var(--dg-text-primary)' }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-2 rounded text-[11px]" style={{ background: 'var(--dg-bg-tertiary)', color: 'var(--dg-text-secondary)' }}>
|
||||
<span style={{ color: meta.color }}>{meta.name}</span> · {datasourceName || '默认'}<br />
|
||||
<span style={{ color: meta.color }}>{meta.name}</span> · {datasourceName || t('approval.status.default')}<br />
|
||||
<span className="font-mono">{query}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2 px-4 py-3 border-t" style={{ borderColor: 'var(--dg-border)' }}>
|
||||
<button onClick={onClose} className="h-8 px-3 rounded text-xs" style={{ color: 'var(--dg-text-secondary)', border: '1px solid var(--dg-border)', background: 'var(--dg-bg-tertiary)' }}>取消</button>
|
||||
<button onClick={onClose} className="h-8 px-3 rounded text-xs" style={{ color: 'var(--dg-text-secondary)', border: '1px solid var(--dg-border)', background: 'var(--dg-bg-tertiary)' }}>{t('common.cancel')}</button>
|
||||
<button onClick={handleSave} disabled={saving || !name.trim() || saved} className="h-8 px-4 rounded text-xs font-medium text-white flex items-center gap-1" style={{ background: '#f59e0b' }}>
|
||||
{saving && <Loader2 className="h-3.5 w-3.5 animate-spin" />}{saved ? '已创建' : '创建告警'}
|
||||
{saving && <Loader2 className="h-3.5 w-3.5 animate-spin" />}{saved ? i18n.t('deploy.toast.created') : i18n.t('monitor.savequerydialog.sbc1d99')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { X, Trash2, Play, Bookmark, Clock } from 'lucide-react';
|
||||
import type { SavedQuery } from './types-v3';
|
||||
import { savedQueryStorage } from './types-v3';
|
||||
import { ENGINE_META } from './types-v3';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
@@ -12,6 +13,7 @@ interface Props {
|
||||
|
||||
// 已保存查询面板(localStorage)// Logan.Li
|
||||
export function SavedQueriesPanel({ visible, onClose, onSelect }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [items, setItems] = useState<SavedQuery[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -30,7 +32,7 @@ export function SavedQueriesPanel({ visible, onClose, onSelect }: Props) {
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b" style={{ borderColor: 'var(--dg-border)' }}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Bookmark className="h-4 w-4" style={{ color: 'var(--dg-blue)' }} />
|
||||
<span className="text-sm font-medium" style={{ color: 'var(--dg-text-primary)' }}>已保存查询</span>
|
||||
<span className="text-sm font-medium" style={{ color: 'var(--dg-text-primary)' }}>{t('monitor.logSavedQueries')}</span>
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded" style={{ background: 'var(--dg-bg-tertiary)', color: 'var(--dg-text-muted)' }}>
|
||||
{items.length}
|
||||
</span>
|
||||
@@ -41,7 +43,7 @@ export function SavedQueriesPanel({ visible, onClose, onSelect }: Props) {
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-3 space-y-2">
|
||||
{items.length === 0 ? (
|
||||
<p className="text-xs text-center py-8" style={{ color: 'var(--dg-text-muted)' }}>暂无保存的查询</p>
|
||||
<p className="text-xs text-center py-8" style={{ color: 'var(--dg-text-muted)' }}>{t('monitor.savedqueriespanel.s9c8dc6')}</p>
|
||||
) : items.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
@@ -65,7 +67,7 @@ export function SavedQueriesPanel({ visible, onClose, onSelect }: Props) {
|
||||
onClick={() => { onSelect(item); onClose(); }}
|
||||
className="h-7 w-7 flex items-center justify-center rounded border"
|
||||
style={{ borderColor: 'var(--dg-border)', color: '#22c55e' }}
|
||||
title="应用"
|
||||
title=t('common.apply')
|
||||
>
|
||||
<Play className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
@@ -73,7 +75,7 @@ export function SavedQueriesPanel({ visible, onClose, onSelect }: Props) {
|
||||
onClick={() => { savedQueryStorage.remove(item.id); setItems(savedQueryStorage.getAll()); }}
|
||||
className="h-7 w-7 flex items-center justify-center rounded border"
|
||||
style={{ borderColor: 'var(--dg-border)', color: 'var(--dg-text-muted)' }}
|
||||
title="删除"
|
||||
title=t('common.delete')
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
|
||||
@@ -2,30 +2,32 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { ChevronDown, ChevronUp, Calendar } from 'lucide-react';
|
||||
import type { TimeRange } from './types-v3';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import i18n from '@/i18n';
|
||||
|
||||
// 相对时间预设:最近N... 每次点击时计算 start = now - N // Logan.Li
|
||||
const RELATIVE_PRESETS = [
|
||||
{ label: '最近1分钟', value: '1m' },
|
||||
{ label: '最近5分钟', value: '5m' },
|
||||
{ label: '最近15分钟', value: '15m' },
|
||||
{ label: '最近1小时', value: '1h' },
|
||||
{ label: '最近4小时', value: '4h' },
|
||||
{ label: '最近1天', value: '24h' },
|
||||
{ label: '最近1周', value: '7d' },
|
||||
{ label: '最近30天', value: '30d' },
|
||||
{ label: i18n.t('monitor.timerangepicker.s6b3f2e'), value: '1m' },
|
||||
{ label: i18n.t('monitor.timerangepicker.sb3fc4e'), value: '5m' },
|
||||
{ label: i18n.t('monitor.timerangepicker.sd4962f'), value: '15m' },
|
||||
{ label: i18n.t('monitor.timerangepicker.sd8a815'), value: '1h' },
|
||||
{ label: i18n.t('monitor.timerangepicker.s0823ad'), value: '4h' },
|
||||
{ label: i18n.t('monitor.timerangepicker.s77be0d'), value: '24h' },
|
||||
{ label: i18n.t('monitor.timerangepicker.s2ba41e'), value: '7d' },
|
||||
{ label: i18n.t('monitor.timerangepicker.s63f66f'), value: '30d' },
|
||||
];
|
||||
|
||||
// 绝对时间预设:固定的自然日/周/月/季/年 // Logan.Li
|
||||
const ABSOLUTE_PRESETS = [
|
||||
{ label: '今天', value: 'today' },
|
||||
{ label: '昨天', value: 'yesterday' },
|
||||
{ label: '前天', value: 'day_before_yesterday' },
|
||||
{ label: '本周', value: 'this_week' },
|
||||
{ label: '上周', value: 'last_week' },
|
||||
{ label: '本月', value: 'this_month' },
|
||||
{ label: '上月', value: 'last_month' },
|
||||
{ label: '本季度', value: 'this_quarter' },
|
||||
{ label: '本年度', value: 'this_year' },
|
||||
{ label: i18n.t('common.today'), value: 'today' },
|
||||
{ label: i18n.t('common.yesterday'), value: 'yesterday' },
|
||||
{ label: i18n.t('monitor.timerangepicker.s94995c'), value: 'day_before_yesterday' },
|
||||
{ label: i18n.t('common.thisWeek'), value: 'this_week' },
|
||||
{ label: i18n.t('common.lastWeek'), value: 'last_week' },
|
||||
{ label: i18n.t('common.thisMonth'), value: 'this_month' },
|
||||
{ label: i18n.t('common.lastMonth'), value: 'last_month' },
|
||||
{ label: i18n.t('monitor.timerangepicker.s0477ce'), value: 'this_quarter' },
|
||||
{ label: i18n.t('monitor.timerangepicker.s77df6c'), value: 'this_year' },
|
||||
];
|
||||
|
||||
function getPresetRange(value: string): { start: Date; end: Date; label: string } {
|
||||
@@ -35,24 +37,24 @@ function getPresetRange(value: string): { start: Date; end: Date; label: string
|
||||
let label = '';
|
||||
|
||||
switch (value) {
|
||||
case '1m': start.setMinutes(now.getMinutes() - 1); label = '最近1分钟'; break;
|
||||
case '5m': start.setMinutes(now.getMinutes() - 5); label = '最近5分钟'; break;
|
||||
case '15m': start.setMinutes(now.getMinutes() - 15); label = '最近15分钟'; break;
|
||||
case '1h': start.setHours(now.getHours() - 1); label = '最近1小时'; break;
|
||||
case '4h': start.setHours(now.getHours() - 4); label = '最近4小时'; break;
|
||||
case '24h': start.setDate(now.getDate() - 1); label = '最近1天'; break;
|
||||
case '7d': start.setDate(now.getDate() - 7); label = '最近1周'; break;
|
||||
case '30d': start.setDate(now.getDate() - 30); label = '最近30天'; break;
|
||||
case 'today': start.setHours(0, 0, 0, 0); label = '今天'; break;
|
||||
case 'yesterday': start.setDate(now.getDate() - 1); start.setHours(0, 0, 0, 0); end.setDate(now.getDate() - 1); end.setHours(23, 59, 59, 999); label = '昨天'; break;
|
||||
case 'day_before_yesterday': start.setDate(now.getDate() - 2); start.setHours(0, 0, 0, 0); end.setDate(now.getDate() - 2); end.setHours(23, 59, 59, 999); label = '前天'; break;
|
||||
case 'this_week': { const d = now.getDay(); const sun = d === 0 ? 0 : d; start.setDate(now.getDate() - sun); start.setHours(0, 0, 0, 0); label = '本周'; break; }
|
||||
case 'last_week': { const d = now.getDay(); const sun = d === 0 ? 0 : d; start.setDate(now.getDate() - sun - 7); start.setHours(0, 0, 0, 0); end.setDate(now.getDate() - sun); end.setHours(0, 0, 0, 0); label = '上周'; break; }
|
||||
case 'this_month': start.setDate(1); start.setHours(0, 0, 0, 0); label = '本月'; break;
|
||||
case 'last_month': start.setMonth(now.getMonth() - 1); start.setDate(1); start.setHours(0, 0, 0, 0); end.setMonth(now.getMonth()); end.setDate(1); end.setHours(0, 0, 0, 0); label = '上月'; break;
|
||||
case 'this_quarter': { const q = Math.floor(now.getMonth() / 3); start.setMonth(q * 3); start.setDate(1); start.setHours(0, 0, 0, 0); label = '本季度'; break; }
|
||||
case 'this_year': start.setMonth(0); start.setDate(1); start.setHours(0, 0, 0, 0); label = '本年度'; break;
|
||||
default: start.setMinutes(now.getMinutes() - 5); label = '最近5分钟';
|
||||
case '1m': start.setMinutes(now.getMinutes() - 1); label = i18n.t('monitor.timerangepicker.s6b3f2e'); break;
|
||||
case '5m': start.setMinutes(now.getMinutes() - 5); label = i18n.t('monitor.timerangepicker.sb3fc4e'); break;
|
||||
case '15m': start.setMinutes(now.getMinutes() - 15); label = i18n.t('monitor.timerangepicker.sd4962f'); break;
|
||||
case '1h': start.setHours(now.getHours() - 1); label = i18n.t('monitor.timerangepicker.sd8a815'); break;
|
||||
case '4h': start.setHours(now.getHours() - 4); label = i18n.t('monitor.timerangepicker.s0823ad'); break;
|
||||
case '24h': start.setDate(now.getDate() - 1); label = i18n.t('monitor.timerangepicker.s77be0d'); break;
|
||||
case '7d': start.setDate(now.getDate() - 7); label = i18n.t('monitor.timerangepicker.s2ba41e'); break;
|
||||
case '30d': start.setDate(now.getDate() - 30); label = i18n.t('monitor.timerangepicker.s63f66f'); break;
|
||||
case 'today': start.setHours(0, 0, 0, 0); label = i18n.t('common.today'); break;
|
||||
case 'yesterday': start.setDate(now.getDate() - 1); start.setHours(0, 0, 0, 0); end.setDate(now.getDate() - 1); end.setHours(23, 59, 59, 999); label = i18n.t('common.yesterday'); break;
|
||||
case 'day_before_yesterday': start.setDate(now.getDate() - 2); start.setHours(0, 0, 0, 0); end.setDate(now.getDate() - 2); end.setHours(23, 59, 59, 999); label = i18n.t('monitor.timerangepicker.s94995c'); break;
|
||||
case 'this_week': { const d = now.getDay(); const sun = d === 0 ? 0 : d; start.setDate(now.getDate() - sun); start.setHours(0, 0, 0, 0); label = i18n.t('common.thisWeek'); break; }
|
||||
case 'last_week': { const d = now.getDay(); const sun = d === 0 ? 0 : d; start.setDate(now.getDate() - sun - 7); start.setHours(0, 0, 0, 0); end.setDate(now.getDate() - sun); end.setHours(0, 0, 0, 0); label = i18n.t('common.lastWeek'); break; }
|
||||
case 'this_month': start.setDate(1); start.setHours(0, 0, 0, 0); label = i18n.t('common.thisMonth'); break;
|
||||
case 'last_month': start.setMonth(now.getMonth() - 1); start.setDate(1); start.setHours(0, 0, 0, 0); end.setMonth(now.getMonth()); end.setDate(1); end.setHours(0, 0, 0, 0); label = i18n.t('common.lastMonth'); break;
|
||||
case 'this_quarter': { const q = Math.floor(now.getMonth() / 3); start.setMonth(q * 3); start.setDate(1); start.setHours(0, 0, 0, 0); label = i18n.t('monitor.timerangepicker.s0477ce'); break; }
|
||||
case 'this_year': start.setMonth(0); start.setDate(1); start.setHours(0, 0, 0, 0); label = i18n.t('monitor.timerangepicker.s77df6c'); break;
|
||||
default: start.setMinutes(now.getMinutes() - 5); label = i18n.t('monitor.timerangepicker.sb3fc4e');
|
||||
}
|
||||
return { start, end, label };
|
||||
}
|
||||
@@ -67,6 +69,7 @@ interface Props {
|
||||
}
|
||||
|
||||
export function TimeRangePicker({ value, onChange }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [activePreset, setActivePreset] = useState('5m');
|
||||
const [mode, setMode] = useState<'preset' | 'custom'>('preset');
|
||||
@@ -94,7 +97,7 @@ export function TimeRangePicker({ value, onChange }: Props) {
|
||||
onChange({
|
||||
start: customStart,
|
||||
end: customEnd,
|
||||
label: '自定义',
|
||||
label: t('common.custom'),
|
||||
type: 'absolute',
|
||||
});
|
||||
setActivePreset('custom');
|
||||
@@ -135,7 +138,7 @@ export function TimeRangePicker({ value, onChange }: Props) {
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center gap-2 mb-3 pb-3" style={{ borderBottom: '1px solid var(--dg-border)' }}>
|
||||
<Calendar className="h-4 w-4" style={{ color: 'var(--dg-blue)' }} />
|
||||
<span className="text-sm font-medium" style={{ color: 'var(--dg-text-primary)' }}>时间选择</span>
|
||||
<span className="text-sm font-medium" style={{ color: 'var(--dg-text-primary)' }}>{t('monitor.timerangepicker.sc2c8fb')}</span>
|
||||
<button
|
||||
onClick={openCustom}
|
||||
className="text-xs ml-2 px-2 py-0.5 rounded transition-colors"
|
||||
@@ -151,7 +154,7 @@ export function TimeRangePicker({ value, onChange }: Props) {
|
||||
{mode === 'preset' ? (
|
||||
<>
|
||||
{/* 相对时间 — 最近... */}
|
||||
<p className="text-[10px] mb-2 font-medium uppercase tracking-wide" style={{ color: '#22c55e' }}>相对时间(滚动窗口)</p>
|
||||
<p className="text-[10px] mb-2 font-medium uppercase tracking-wide" style={{ color: '#22c55e' }}>{t('monitor.timerangepicker.s8ead0a')}</p>
|
||||
<div className="grid grid-cols-4 gap-1.5 mb-3">
|
||||
{RELATIVE_PRESETS.map(p => (
|
||||
<button
|
||||
@@ -170,7 +173,7 @@ export function TimeRangePicker({ value, onChange }: Props) {
|
||||
</div>
|
||||
|
||||
{/* 绝对时间 — 固定日历范围 */}
|
||||
<p className="text-[10px] mb-2 font-medium uppercase tracking-wide" style={{ color: 'var(--dg-blue)' }}>绝对时间(日历范围)</p>
|
||||
<p className="text-[10px] mb-2 font-medium uppercase tracking-wide" style={{ color: 'var(--dg-blue)' }}>{t('monitor.timerangepicker.s714f6c')}</p>
|
||||
<div className="grid grid-cols-5 gap-1.5">
|
||||
{ABSOLUTE_PRESETS.map(p => (
|
||||
<button
|
||||
@@ -191,10 +194,10 @@ export function TimeRangePicker({ value, onChange }: Props) {
|
||||
) : (
|
||||
/* 自定义日历模式 */
|
||||
<div className="space-y-3">
|
||||
<p className="text-[10px] font-medium uppercase" style={{ color: 'var(--dg-blue)' }}>自定义时间范围</p>
|
||||
<p className="text-[10px] font-medium uppercase" style={{ color: 'var(--dg-blue)' }}>{t('monitor.timerangepicker.s0122e0')}</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-[10px] block mb-1" style={{ color: 'var(--dg-text-muted)' }}>开始时间</label>
|
||||
<label className="text-[10px] block mb-1" style={{ color: 'var(--dg-text-muted)' }}>{t('common.startTime')}</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={customStart.substring(0, 16)}
|
||||
@@ -204,7 +207,7 @@ export function TimeRangePicker({ value, onChange }: Props) {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] block mb-1" style={{ color: 'var(--dg-text-muted)' }}>结束时间</label>
|
||||
<label className="text-[10px] block mb-1" style={{ color: 'var(--dg-text-muted)' }}>{t('common.endTime')}</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={customEnd.substring(0, 16)}
|
||||
@@ -215,8 +218,8 @@ export function TimeRangePicker({ value, onChange }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button onClick={() => setMode('preset')} className="h-8 px-3 rounded text-xs" style={{ border: '1px solid var(--dg-border)', color: 'var(--dg-text-secondary)', background: 'var(--dg-bg-tertiary)' }}>取消</button>
|
||||
<button onClick={handleCustomApply} className="h-8 px-4 rounded text-xs font-medium text-white" style={{ background: 'var(--dg-blue)' }}>应用</button>
|
||||
<button onClick={() => setMode('preset')} className="h-8 px-3 rounded text-xs" style={{ border: '1px solid var(--dg-border)', color: 'var(--dg-text-secondary)', background: 'var(--dg-bg-tertiary)' }}>{t('common.cancel')}</button>
|
||||
<button onClick={handleCustomApply} className="h-8 px-4 rounded text-xs font-medium text-white" style={{ background: 'var(--dg-blue)' }}>{t('common.apply')}</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -25,12 +25,14 @@ import { applyESFieldFilter, applyLokiLabelFilter, extractLokiSelector } from '.
|
||||
import type { LogEngine, TimeRange, ResultTab, ViewMode, LogEntry, LogField, StatTable, LogCluster, QueryHistoryItem, SavedQuery } from './types-v3';
|
||||
import { ENGINE_META, buildStatTableFromEntries, buildClustersFromEntries, extractFields, generateHistogramData, historyStorage } from './types-v3';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import i18n from '@/i18n';
|
||||
|
||||
// 默认最近5分钟 // Logan.Li
|
||||
const DEFAULT_TIME_RANGE: TimeRange = {
|
||||
start: new Date(Date.now() - 5 * 60000).toISOString().replace('T', ' ').substring(0, 19),
|
||||
end: new Date().toISOString().replace('T', ' ').substring(0, 19),
|
||||
label: '最近5分钟',
|
||||
label: i18n.t('monitor.unifiedquerypanel.sb3fc4e'),
|
||||
type: 'relative',
|
||||
};
|
||||
|
||||
@@ -50,6 +52,7 @@ const ENGINE_BADGE: Record<'loki' | 'elasticsearch', { label: string; dotColor:
|
||||
interface Props { engine: LogEngine; onRuleCreated: () => void; }
|
||||
|
||||
export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
const location = useLocation();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
@@ -264,10 +267,10 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
|
||||
historyStorage.add({ engine: effEngine as LogEngine, datasourceName: currentSource.name, query, timeRange: timeRange.label, resultCount: mapped.length });
|
||||
} catch (e: any) {
|
||||
const msg = e?.message || String(e);
|
||||
const title = msg.includes('401') || msg.includes('403') ? '认证失败'
|
||||
: msg.includes('404') ? '接口不存在'
|
||||
: msg.includes('无法连接') || msg.includes('timeout') ? '连接失败'
|
||||
: '查询失败';
|
||||
const title = msg.includes('401') || msg.includes('403') ? t('common.authError')
|
||||
: msg.includes('404') ? t('monitor.unifiedquerypanel.s9959bc')
|
||||
: msg.includes(t('monitor.unifiedquerypanel.s757a73')) || msg.includes('timeout') ? t('aiops.llmTestFailed')
|
||||
: t('monitor.unifiedquerypanel.s0d66ed');
|
||||
toast({ title, description: msg, variant: 'destructive' });
|
||||
setEntries([]); setTotalCount(0); setHasQueried(true);
|
||||
} finally {
|
||||
@@ -302,7 +305,7 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
|
||||
setNextCursor(data?.next_cursor || '');
|
||||
setSearchAfter(data?.search_after);
|
||||
} catch (e: unknown) {
|
||||
toast({ title: '加载更多失败', description: (e as Error)?.message, variant: 'destructive' });
|
||||
toast({ title: t('monitor.unifiedquerypanel.s5b139e'), description: (e as Error)?.message, variant: 'destructive' });
|
||||
} finally {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
@@ -342,8 +345,8 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
|
||||
const full = `${window.location.origin}${url}`;
|
||||
const ok = await copyToClipboard(full);
|
||||
toast({
|
||||
title: ok ? '分享链接已复制' : '复制失败',
|
||||
description: ok ? '收件人打开链接将自动执行查询' : undefined,
|
||||
title: ok ? t('monitor.unifiedquerypanel.sf49cc0') : t('common.copyFailed'),
|
||||
description: ok ? t('monitor.unifiedquerypanel.s6b6e6f') : undefined,
|
||||
variant: ok ? 'default' : 'destructive',
|
||||
});
|
||||
}, [location.pathname, query, dsId, esIndex, timeRange, toast]);
|
||||
@@ -415,7 +418,7 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
|
||||
const idx = histogramData.findIndex(d => d.timestamp === st.activeLabel);
|
||||
if (idx < 0) return;
|
||||
const sIdx = Math.max(0, idx - 1), eIdx = Math.min(histogramData.length - 1, idx + 1);
|
||||
setTimeRange(p => ({ ...p, start: histogramData[sIdx].timestamp, end: histogramData[eIdx].timestamp, label: '自定义', type: 'absolute' as const }));
|
||||
setTimeRange(p => ({ ...p, start: histogramData[sIdx].timestamp, end: histogramData[eIdx].timestamp, label: t('common.custom'), type: 'absolute' as const }));
|
||||
}, [histogramData]);
|
||||
|
||||
const formatEntryLine = useCallback((e: LogEntry) => buildLogCopyText(e), []);
|
||||
@@ -429,16 +432,16 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
|
||||
a.download = `${effEngine}-logs-${Date.now()}.txt`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(u);
|
||||
toast({ title: '已导出', description: `${sortedEntries.length} 条日志` });
|
||||
toast({ title: t('monitor.unifiedquerypanel.s5c51c3'), description: t('monitor.unifiedquerypanel.sa7f301', { sortedEntries.length: sortedEntries.length }) });
|
||||
}, [sortedEntries, effEngine, formatEntryLine, toast]);
|
||||
|
||||
const handleCopyLogs = useCallback(async () => {
|
||||
const text = pagedEntries.map(formatEntryLine).join('\n');
|
||||
const ok = await copyToClipboard(text);
|
||||
if (ok) {
|
||||
toast({ title: '已复制', description: `当前页 ${pagedEntries.length} 条完整日志` });
|
||||
toast({ title: t('common.copied'), description: t('monitor.unifiedquerypanel.se411aa', { pagedEntries.length: pagedEntries.length }) });
|
||||
} else {
|
||||
toast({ title: '复制失败', description: '请使用导出或展开单条复制', variant: 'destructive' });
|
||||
toast({ title: t('common.copyFailed'), description: t('monitor.unifiedquerypanel.sc447fb'), variant: 'destructive' });
|
||||
}
|
||||
}, [pagedEntries, formatEntryLine, toast]);
|
||||
|
||||
@@ -473,7 +476,7 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
|
||||
color: liveTail ? '#ef4444' : 'var(--dg-text-secondary)',
|
||||
background: liveTail ? 'rgba(239,68,68,0.1)' : 'var(--dg-bg-tertiary)',
|
||||
}}
|
||||
title="Live Tail — 每 3 秒刷新最近 5 分钟日志"
|
||||
title=t('monitor.unifiedquerypanel.s6c3d36')
|
||||
>
|
||||
<Radio className={`h-3.5 w-3.5 ${liveTail ? 'animate-pulse' : ''}`} /> Tail
|
||||
</button>
|
||||
@@ -485,7 +488,7 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
|
||||
|
||||
<select value={dsId} onChange={e => setDsId(e.target.value)} className="h-10 px-2 text-xs rounded flex-shrink-0" style={{ background: 'var(--dg-bg-tertiary)', border: '1px solid var(--dg-border)', color: 'var(--dg-text-primary)' }}>
|
||||
{sources.length === 0 && <option value="">无可用{meta.name}数据源</option>}
|
||||
{sources.map(s => <option key={s.id} value={s.id}>{s.name}{s.is_default ? ' (默认)' : ''}</option>)}
|
||||
{sources.map(s => <option key={s.id} value={s.id}>{s.name}{s.is_default ? t('monitor.unifiedquerypanel.see1f37') : ''}</option>)}
|
||||
</select>
|
||||
|
||||
{effEngine === 'elasticsearch' && (
|
||||
@@ -497,7 +500,7 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-1.5 h-10 px-2 rounded flex-shrink-0" style={{ background: 'var(--dg-bg-tertiary)', border: '1px solid var(--dg-border)' }}>
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">上限</span>
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">{t('monitor.unifiedquerypanel.s726bfb')}</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
@@ -514,24 +517,24 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
|
||||
|
||||
<div className="relative flex-shrink-0">
|
||||
<button onClick={() => setSaveMenuOpen(!saveMenuOpen)} className="h-10 px-3 rounded text-xs flex items-center gap-1" style={{ border: '1px solid var(--dg-border)', color: 'var(--dg-text-secondary)', background: 'var(--dg-bg-tertiary)' }}>
|
||||
<Save className="h-3.5 w-3.5" /> 另存为 <ChevronDown className="h-3 w-3" />
|
||||
<Save className="h-3.5 w-3.5" /> {t('common.saveAs')} <ChevronDown className="h-3 w-3" />
|
||||
</button>
|
||||
{saveMenuOpen && (<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setSaveMenuOpen(false)} />
|
||||
<div className="absolute right-0 top-full mt-1 z-50 rounded-lg border shadow-xl overflow-hidden" style={{ background: 'var(--dg-bg-secondary)', borderColor: 'var(--dg-border)' }}>
|
||||
<button onClick={() => handleSaveMenu('query')} className="w-full text-left px-4 py-2 text-xs flex items-center gap-2 hover:opacity-80" style={{ color: 'var(--dg-text-secondary)' }}><Save className="h-3.5 w-3.5" /> 查询</button>
|
||||
<button onClick={() => handleSaveMenu('alert')} className="w-full text-left px-4 py-2 text-xs flex items-center gap-2 hover:opacity-80" style={{ color: 'var(--dg-text-secondary)', borderTop: '1px solid var(--dg-border)' }}><Bell className="h-3.5 w-3.5" /> 告警</button>
|
||||
<button onClick={() => handleSaveMenu('query')} className="w-full text-left px-4 py-2 text-xs flex items-center gap-2 hover:opacity-80" style={{ color: 'var(--dg-text-secondary)' }}><Save className="h-3.5 w-3.5" /> {t('cloud.query')}</button>
|
||||
<button onClick={() => handleSaveMenu('alert')} className="w-full text-left px-4 py-2 text-xs flex items-center gap-2 hover:opacity-80" style={{ color: 'var(--dg-text-secondary)', borderTop: '1px solid var(--dg-border)' }}><Bell className="h-3.5 w-3.5" /> {t('dashboard.stat.alerts')}</button>
|
||||
</div>
|
||||
</>)}
|
||||
</div>
|
||||
|
||||
<button onClick={() => { void handleShareLink(); }} className="h-10 px-2.5 flex items-center gap-1 rounded flex-shrink-0 text-xs" style={{ border: '1px solid var(--dg-border)', color: 'var(--dg-text-muted)', background: 'var(--dg-bg-tertiary)' }} title="复制分享链接">
|
||||
<button onClick={() => { void handleShareLink(); }} className="h-10 px-2.5 flex items-center gap-1 rounded flex-shrink-0 text-xs" style={{ border: '1px solid var(--dg-border)', color: 'var(--dg-text-muted)', background: 'var(--dg-bg-tertiary)' }} title=t('monitor.unifiedquerypanel.s1a9f82')>
|
||||
<Link2 className="h-3.5 w-3.5" /> 分享
|
||||
</button>
|
||||
<button onClick={() => setSavedPanelOpen(true)} className="h-10 w-10 flex items-center justify-center rounded flex-shrink-0" style={{ border: '1px solid var(--dg-border)', color: 'var(--dg-text-muted)', background: 'var(--dg-bg-tertiary)' }} title="已保存查询">
|
||||
<button onClick={() => setSavedPanelOpen(true)} className="h-10 w-10 flex items-center justify-center rounded flex-shrink-0" style={{ border: '1px solid var(--dg-border)', color: 'var(--dg-text-muted)', background: 'var(--dg-bg-tertiary)' }} title=t('monitor.logSavedQueries')>
|
||||
<Bookmark className="h-4 w-4" />
|
||||
</button>
|
||||
<button onClick={() => setHistoryPanelOpen(true)} className="h-10 w-10 flex items-center justify-center rounded flex-shrink-0" style={{ border: '1px solid var(--dg-border)', color: 'var(--dg-text-muted)', background: 'var(--dg-bg-tertiary)' }} title="查询历史">
|
||||
<button onClick={() => setHistoryPanelOpen(true)} className="h-10 w-10 flex items-center justify-center rounded flex-shrink-0" style={{ border: '1px solid var(--dg-border)', color: 'var(--dg-text-muted)', background: 'var(--dg-bg-tertiary)' }} title=t('monitor.logQueryHistory')>
|
||||
<Clock className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -574,7 +577,7 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--dg-border)" vertical={false} />
|
||||
<XAxis dataKey="timestamp" tick={{ fontSize: 9, fill: 'var(--dg-text-muted)' }} axisLine={{ stroke: 'var(--dg-border)' }} tickLine={false} tickFormatter={formatHistogramTick} interval="preserveStartEnd" />
|
||||
<YAxis tick={{ fontSize: 9, fill: 'var(--dg-text-muted)' }} axisLine={false} tickLine={false} width={36} tickFormatter={formatHistogramCount} />
|
||||
<Tooltip contentStyle={{ background: 'var(--dg-bg-secondary)', border: '1px solid var(--dg-border)', borderRadius: '6px', fontSize: '11px', color: 'var(--dg-text-primary)' }} formatter={(v: number) => [`${v} 条日志`, '数量']} labelFormatter={(l: string) => `时间: ${l}`} />
|
||||
<Tooltip contentStyle={{ background: 'var(--dg-bg-secondary)', border: '1px solid var(--dg-border)', borderRadius: '6px', fontSize: '11px', color: 'var(--dg-text-primary)' }} formatter={(v: number) => [t('monitor.unifiedquerypanel.s8ea117', { v: v }), t('common.count')]} labelFormatter={(l: string) => t('monitor.unifiedquerypanel.s5f5d6e', { l: l })} />
|
||||
<Area type="monotone" dataKey="count" stroke={meta.color} fill={`url(#vol-${effEngine})`} strokeWidth={1.5} dot={false} activeDot={{ r: 4, stroke: meta.color, strokeWidth: 2, fill: 'var(--dg-bg-secondary)' }} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
@@ -586,7 +589,7 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
|
||||
<div className="rounded-lg border" style={{ background: 'var(--dg-bg-secondary)', borderColor: 'var(--dg-border)' }}>
|
||||
<div className="flex items-center gap-0.5 px-2 pt-1.5" style={{ borderBottom: '1px solid var(--dg-border)' }}>
|
||||
{(['logs', 'stats', 'cluster'] as ResultTab[]).map(tab => {
|
||||
const labels: Record<ResultTab, string> = { logs: '原始日志', stats: '统计图表', cluster: '日志聚类' };
|
||||
const labels: Record<ResultTab, string> = { logs: t('monitor.unifiedquerypanel.se0c409'), stats: t('monitor.unifiedquerypanel.s0e2e4e'), cluster: t('monitor.logCluster') };
|
||||
return (
|
||||
<button key={tab} onClick={() => setResultTab(tab)} className="px-3 py-1.5 text-[11px] font-medium rounded-t-md transition-colors"
|
||||
style={{ color: resultTab === tab ? meta.color : 'var(--dg-text-muted)', background: resultTab === tab ? 'var(--dg-bg-secondary)' : 'transparent', borderBottom: resultTab === tab ? `2px solid ${meta.color}` : '2px solid transparent', marginBottom: '-1px' }}>
|
||||
@@ -608,12 +611,12 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
|
||||
<div className="flex rounded border overflow-hidden" style={{ borderColor: 'var(--dg-border)' }}>
|
||||
{(['raw', 'field', 'table'] as const).map(m => (
|
||||
<button key={m} onClick={() => setViewMode(m)} className="px-3 py-1 text-[11px] transition-colors" style={{ background: viewMode === m ? meta.color : 'var(--dg-bg-tertiary)', color: viewMode === m ? '#fff' : 'var(--dg-text-muted)' }}>
|
||||
{m === 'raw' ? '原始日志' : m === 'field' ? '字段视图' : '表格视图'}
|
||||
{m === 'raw' ? t('monitor.unifiedquerypanel.se0c409') : m === 'field' ? t('monitor.unifiedquerypanel.sda04fc') : t('monitor.unifiedquerypanel.s175e01')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<label className="flex items-center gap-1.5 text-[11px] cursor-pointer" style={{ color: 'var(--dg-text-muted)' }} title="开启后折叠行仅显示前 500 字预览"><input type="checkbox" checked={truncate} onChange={e => setTruncate(e.target.checked)} className="h-3.5 w-3.5 rounded" /> 截断预览</label>
|
||||
<label className="flex items-center gap-1.5 text-[11px] cursor-pointer" style={{ color: 'var(--dg-text-muted)' }} title="长日志自动换行显示"><input type="checkbox" checked={wrapLines} onChange={e => setWrapLines(e.target.checked)} className="h-3.5 w-3.5 rounded" /> 换行</label>
|
||||
<label className="flex items-center gap-1.5 text-[11px] cursor-pointer" style={{ color: 'var(--dg-text-muted)' }} title=t('monitor.unifiedquerypanel.s79f59d')><input type="checkbox" checked={truncate} onChange={e => setTruncate(e.target.checked)} className="h-3.5 w-3.5 rounded" /> {t('monitor.unifiedquerypanel.sb061ac')}</label>
|
||||
<label className="flex items-center gap-1.5 text-[11px] cursor-pointer" style={{ color: 'var(--dg-text-muted)' }} title=t('monitor.unifiedquerypanel.s8b7b92')><input type="checkbox" checked={wrapLines} onChange={e => setWrapLines(e.target.checked)} className="h-3.5 w-3.5 rounded" /> {t('monitor.unifiedquerypanel.s2629bd')}</label>
|
||||
</div>
|
||||
</>)}
|
||||
{resultTab === 'stats' && <StatChartPanel statTable={statTable} engine={effEngine as LogEngine} query={query} onSaveAsAlert={() => setSaveAlertOpen(true)} onSaveAsQuery={() => setSaveQueryOpen(true)} />}
|
||||
@@ -625,7 +628,7 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
|
||||
{/* 空状态:从未查询 vs 查询返回 0 结果 */}
|
||||
{!loading && entries.length === 0 && !hasQueried && (
|
||||
<div className="border rounded-lg flex flex-col items-center justify-center gap-2" style={{ borderColor: 'var(--dg-border)', minHeight: '300px' }}>
|
||||
<span className="text-sm" style={{ color: 'var(--dg-text-muted)' }}>输入查询语句并点击「查询」查看结果</span>
|
||||
<span className="text-sm" style={{ color: 'var(--dg-text-muted)' }}>{t('monitor.unifiedquerypanel.se68b81')}</span>
|
||||
<span className="text-xs font-mono px-3 py-1 rounded" style={{ color: 'var(--dg-text-muted)', background: 'var(--dg-bg-tertiary)' }}>{meta.queryPlaceholder}</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -637,7 +640,7 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
|
||||
</svg>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-sm font-medium" style={{ color: 'var(--dg-text-primary)' }}>未找到匹配的日志记录</p>
|
||||
<p className="text-sm font-medium" style={{ color: 'var(--dg-text-primary)' }}>{t('monitor.unifiedquerypanel.sd797e0')}</p>
|
||||
<p className="text-xs mt-1" style={{ color: 'var(--dg-text-muted)' }}>
|
||||
查询已执行({queryTime}ms),但当前时间范围和筛选条件下无匹配结果
|
||||
</p>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// 日志查询 URL 状态编解码 — 可分享链接 // Logan.Li
|
||||
|
||||
import type { TimeRange } from './types-v3';
|
||||
import i18n from '@/i18n';
|
||||
|
||||
export interface LogQueryUrlState {
|
||||
query?: string;
|
||||
@@ -16,7 +17,7 @@ export function parseLogQueryUrl(params: URLSearchParams): LogQueryUrlState {
|
||||
const index = params.get('index') || undefined;
|
||||
const start = params.get('from') || undefined;
|
||||
const end = params.get('to') || undefined;
|
||||
const timeLabel = params.get('tr') || '自定义';
|
||||
const timeLabel = params.get('tr') || i18n.t('common.custom');
|
||||
const autoRun = params.get('run') === '1';
|
||||
const state: LogQueryUrlState = { query, datasourceId, index, autoRun };
|
||||
if (start && end) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import i18n from '@/i18n';
|
||||
// types-v3.ts — v3 统一日志中心类型定义 // Logan.Li
|
||||
|
||||
export type LogEngine = 'loki' | 'elasticsearch' | 'sls';
|
||||
@@ -132,7 +133,7 @@ export const ENGINE_META: Record<LogEngine, { name: string; color: string; query
|
||||
color: '#f97316',
|
||||
queryPlaceholder: 'keyword | SELECT * WHERE level = "info"',
|
||||
statPlaceholder: '* | SELECT level, COUNT(*) as cnt GROUP BY level',
|
||||
externalLink: 'SLS控制台',
|
||||
externalLink: i18n.t('monitor.typesV3.sfe4ff0'),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -248,27 +249,27 @@ export const savedQueryStorage = {
|
||||
// 快捷查询模板 // Logan.Li
|
||||
export const QUICK_TEMPLATES: Record<LogEngine, { label: string; query: string }[]> = {
|
||||
loki: [
|
||||
{ label: 'Nginx 错误日志', query: '{job="nginx"} |= "error"' },
|
||||
{ label: 'JSON 解析过滤', query: '{namespace="prod"} | json | level="warn"' },
|
||||
{ label: '超时搜索', query: '{app="api"} |= "timeout"' },
|
||||
{ label: '按级别统计', query: 'sum by (level) (count_over_time({job="app"}[5m]))' },
|
||||
{ label: '正则匹配多级别', query: '{container="frontend"} |~ "ERROR|WARN|FATAL"' },
|
||||
{ label: '慢请求 >1s', query: '{job="api"} | json | response_time > 1000' },
|
||||
{ label: i18n.t('monitor.typesV3.s70d6c2'), query: '{job="nginx"} |= "error"' },
|
||||
{ label: i18n.t('monitor.typesV3.saf1cb0'), query: '{namespace="prod"} | json | level="warn"' },
|
||||
{ label: i18n.t('monitor.typesV3.sfc6d42'), query: '{app="api"} |= "timeout"' },
|
||||
{ label: i18n.t('monitor.typesV3.s475a42'), query: 'sum by (level) (count_over_time({job="app"}[5m]))' },
|
||||
{ label: i18n.t('monitor.typesV3.sac8a5c'), query: '{container="frontend"} |~ "ERROR|WARN|FATAL"' },
|
||||
{ label: i18n.t('monitor.typesV3.s4dc793'), query: '{job="api"} | json | response_time > 1000' },
|
||||
],
|
||||
elasticsearch: [
|
||||
{ label: 'API 5xx 错误', query: 'status:500 AND path:"/api/*"' },
|
||||
{ label: '错误超时日志', query: '(level:error OR log.level:error) AND message:*timeout*' },
|
||||
{ label: '排除调试日志', query: 'host.name:server-* AND NOT level:debug AND NOT log.level:debug' },
|
||||
{ label: '慢请求检测', query: 'response_time:>1000 AND method:POST' },
|
||||
{ label: '最近1小时严重错误', query: '@timestamp:[now-1h TO now] AND (level:critical OR log.level:critical)' },
|
||||
{ label: '特定服务错误', query: 'service:nginx AND (level:(error OR fatal) OR log.level:(error OR fatal))' },
|
||||
{ label: i18n.t('monitor.typesV3.sd81428'), query: 'status:500 AND path:"/api/*"' },
|
||||
{ label: i18n.t('monitor.typesV3.s6624c7'), query: '(level:error OR log.level:error) AND message:*timeout*' },
|
||||
{ label: i18n.t('monitor.typesV3.s98b327'), query: 'host.name:server-* AND NOT level:debug AND NOT log.level:debug' },
|
||||
{ label: i18n.t('monitor.typesV3.s95d72f'), query: 'response_time:>1000 AND method:POST' },
|
||||
{ label: i18n.t('monitor.typesV3.s9a0d4c'), query: '@timestamp:[now-1h TO now] AND (level:critical OR log.level:critical)' },
|
||||
{ label: i18n.t('monitor.typesV3.s68f58c'), query: 'service:nginx AND (level:(error OR fatal) OR log.level:(error OR fatal))' },
|
||||
],
|
||||
sls: [
|
||||
{ label: '错误日志筛选', query: "* | select * where level = 'error'" },
|
||||
{ label: '按级别统计', query: "* | select count(*) as cnt, level group by level" },
|
||||
{ label: '时间聚合', query: "* | select date_trunc('minute', __time__) as t, count(*) group by t order by t" },
|
||||
{ label: 'Top 方法统计', query: "* | select method, count(*) as cnt, avg(response_time) group by method order by cnt desc" },
|
||||
{ label: '慢请求分析', query: "* | select * where response_time > 1000 order by response_time desc limit 100" },
|
||||
{ label: '状态码分布', query: "* | select status_code, count(*) as cnt, count(*) * 100.0 / sum(count(*)) over() as pct group by status_code" },
|
||||
{ label: i18n.t('monitor.typesV3.sf8889a'), query: "* | select * where level = 'error'" },
|
||||
{ label: i18n.t('monitor.typesV3.s475a42'), query: "* | select count(*) as cnt, level group by level" },
|
||||
{ label: i18n.t('monitor.typesV3.s27ee09'), query: "* | select date_trunc('minute', __time__) as t, count(*) group by t order by t" },
|
||||
{ label: i18n.t('monitor.typesV3.sa5ad74'), query: "* | select method, count(*) as cnt, avg(response_time) group by method order by cnt desc" },
|
||||
{ label: i18n.t('monitor.typesV3.s80428b'), query: "* | select * where response_time > 1000 order by response_time desc limit 100" },
|
||||
{ label: i18n.t('monitor.typesV3.s63c5c7'), query: "* | select status_code, count(*) as cnt, count(*) * 100.0 / sum(count(*)) over() as pct group by status_code" },
|
||||
],
|
||||
};
|
||||
@@ -40,6 +40,7 @@ import { PageShell } from '@/components/layout/PageShell';
|
||||
import { PageLoading } from '@/components/layout/PageLoading';
|
||||
import { PageEmpty } from '@/components/layout/PageEmpty';
|
||||
import type { JsonData } from '@/types/common';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface Trace {
|
||||
id: string;
|
||||
@@ -84,6 +85,7 @@ interface ServiceMetrics {
|
||||
}
|
||||
|
||||
const TraceManager: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const [traces, setTraces] = useState<Trace[]>([]);
|
||||
const [services, setServices] = useState<ServiceMetrics[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -111,7 +113,7 @@ const TraceManager: React.FC = () => {
|
||||
const res = await traceApi.getTraces({ timeRange, service: serviceFilter, status: statusFilter });
|
||||
setTraces(res.data || []);
|
||||
} catch {
|
||||
toast.error('获取链路数据失败');
|
||||
toast.error(t('monitor.tracemanager.s70f5f6'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -122,7 +124,7 @@ const TraceManager: React.FC = () => {
|
||||
const res = await traceApi.getServices(timeRange);
|
||||
setServices(res.data || []);
|
||||
} catch {
|
||||
toast.error('获取服务数据失败');
|
||||
toast.error(t('monitor.tracemanager.sa4e1bb'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -131,7 +133,7 @@ const TraceManager: React.FC = () => {
|
||||
const res = await traceApi.getTraceDetail(traceId);
|
||||
return res.data;
|
||||
} catch {
|
||||
toast.error('获取链路详情失败');
|
||||
toast.error(t('monitor.tracemanager.s2d86b6'));
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -139,13 +141,13 @@ const TraceManager: React.FC = () => {
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return <Badge variant="default" className="bg-green-500 gap-1"><CheckCircle className="w-3 h-3" />成功</Badge>;
|
||||
return <Badge variant="default" className="bg-green-500 gap-1"><CheckCircle className="w-3 h-3" />{t('common.success')}</Badge>;
|
||||
case 'error':
|
||||
return <Badge variant="destructive" className="gap-1"><XCircle className="w-3 h-3" />错误</Badge>;
|
||||
return <Badge variant="destructive" className="gap-1"><XCircle className="w-3 h-3" />{t('common.error')}</Badge>;
|
||||
case 'pending':
|
||||
return <Badge variant="outline" className="gap-1"><Clock className="w-3 h-3" />进行中</Badge>;
|
||||
return <Badge variant="outline" className="gap-1"><Clock className="w-3 h-3" />{t('common.inProgress')}</Badge>;
|
||||
default:
|
||||
return <Badge variant="secondary">未知</Badge>;
|
||||
return <Badge variant="secondary">{t('common.unknown')}</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -260,9 +262,9 @@ const TraceManager: React.FC = () => {
|
||||
};
|
||||
|
||||
const pageTabs = [
|
||||
{ id: 'traces', label: '链路追踪' },
|
||||
{ id: 'services', label: '服务拓扑' },
|
||||
{ id: 'metrics', label: '性能指标' },
|
||||
{ id: 'traces', label: t('menu.trace') },
|
||||
{ id: 'services', label: t('monitor.tracemanager.scbb839') },
|
||||
{ id: 'metrics', label: t('monitor.tracemanager.sb59dc0') },
|
||||
];
|
||||
|
||||
const tracesToolbar = activeTab === 'traces' ? (
|
||||
@@ -271,7 +273,7 @@ const TraceManager: React.FC = () => {
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="搜索Trace ID、操作名..."
|
||||
placeholder=t('monitor.tracemanager.s0a1f23')
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10"
|
||||
@@ -279,10 +281,10 @@ const TraceManager: React.FC = () => {
|
||||
</div>
|
||||
<Select value={serviceFilter} onValueChange={setServiceFilter}>
|
||||
<SelectTrigger className="w-[150px]">
|
||||
<SelectValue placeholder="服务" />
|
||||
<SelectValue placeholder=t('cloud.colService') />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部服务</SelectItem>
|
||||
<SelectItem value="all">{t('monitor.tracemanager.sdd6e6e')}</SelectItem>
|
||||
{serviceNames.map(s => (
|
||||
<SelectItem key={s} value={s}>{s}</SelectItem>
|
||||
))}
|
||||
@@ -290,13 +292,13 @@ const TraceManager: React.FC = () => {
|
||||
</Select>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-[120px]">
|
||||
<SelectValue placeholder="状态" />
|
||||
<SelectValue placeholder=t('common.status') />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部状态</SelectItem>
|
||||
<SelectItem value="success">成功</SelectItem>
|
||||
<SelectItem value="error">错误</SelectItem>
|
||||
<SelectItem value="pending">进行中</SelectItem>
|
||||
<SelectItem value="all">{t('alert.allStatus')}</SelectItem>
|
||||
<SelectItem value="success">{t('common.success')}</SelectItem>
|
||||
<SelectItem value="error">{t('common.error')}</SelectItem>
|
||||
<SelectItem value="pending">{t('common.inProgress')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={timeRange} onValueChange={setTimeRange}>
|
||||
@@ -304,11 +306,11 @@ const TraceManager: React.FC = () => {
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="15m">15分钟</SelectItem>
|
||||
<SelectItem value="1h">1小时</SelectItem>
|
||||
<SelectItem value="6h">6小时</SelectItem>
|
||||
<SelectItem value="24h">24小时</SelectItem>
|
||||
<SelectItem value="7d">7天</SelectItem>
|
||||
<SelectItem value="15m">{t('monitor.tracemanager.sf511e9')}</SelectItem>
|
||||
<SelectItem value="1h">{t('monitor.tracemanager.sc658fd')}</SelectItem>
|
||||
<SelectItem value="6h">{t('monitor.tracemanager.s0485f2')}</SelectItem>
|
||||
<SelectItem value="24h">{t('monitor.tracemanager.sc39e67')}</SelectItem>
|
||||
<SelectItem value="7d">{t('monitor.siteProtocol.ssl.ref7days')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -324,8 +326,8 @@ const TraceManager: React.FC = () => {
|
||||
return (
|
||||
<PageShell
|
||||
variant="list"
|
||||
title="链路追踪"
|
||||
subtitle="分布式链路查询与服务性能分析"
|
||||
title=t('menu.trace')
|
||||
subtitle=t('monitor.tracemanager.s3cd272')
|
||||
icon={<GitBranch className="h-5 w-5" />}
|
||||
tabs={pageTabs}
|
||||
activeTab={activeTab}
|
||||
@@ -334,10 +336,10 @@ const TraceManager: React.FC = () => {
|
||||
metrics={(
|
||||
<StatStrip
|
||||
items={[
|
||||
{ label: '总请求', value: services.reduce((sum, s) => sum + s.requestCount, 0).toLocaleString(), color: 'blue' },
|
||||
{ label: '错误率', value: `${(services.reduce((sum, s) => sum + s.errorRate, 0) / (services.length || 1)).toFixed(2)}%`, color: 'red' },
|
||||
{ label: '平均延迟', value: formatDuration(services.reduce((sum, s) => sum + s.avgDuration, 0) / (services.length || 1)), color: 'amber' },
|
||||
{ label: '服务数', value: services.length, color: 'purple' },
|
||||
{ label: t('monitor.tracemanager.sc28d94'), value: services.reduce((sum, s) => sum + s.requestCount, 0).toLocaleString(), color: 'blue' },
|
||||
{ label: t('monitor.tracemanager.s0c8524'), value: `${(services.reduce((sum, s) => sum + s.errorRate, 0) / (services.length || 1)).toFixed(2)}%`, color: 'red' },
|
||||
{ label: t('monitor.tracemanager.sa1df2e'), value: formatDuration(services.reduce((sum, s) => sum + s.avgDuration, 0) / (services.length || 1)), color: 'amber' },
|
||||
{ label: t('monitor.tracemanager.s14a999'), value: services.length, color: 'purple' },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
@@ -356,22 +358,22 @@ const TraceManager: React.FC = () => {
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium">Trace ID</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium">服务 / 操作</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium">状态</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium">耗时</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium">Span数</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium">时间</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium">操作</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium">{t('monitor.tracemanager.s5aa022')}</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium">{t('common.status')}</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium">{t('common.elapsed')}</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium">{t('monitor.tracemanager.sb257fb')}</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium">{t('monitor.logTime')}</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium">{t('common.actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-4 py-2"><PageLoading label="加载链路数据..." /></td>
|
||||
<td colSpan={7} className="px-4 py-2"><PageLoading label=t('monitor.tracemanager.se5b9da') /></td>
|
||||
</tr>
|
||||
) : filteredTraces.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-4 py-2"><PageEmpty title="暂无链路数据" className="py-8" /></td>
|
||||
<td colSpan={7} className="px-4 py-2"><PageEmpty title=t('monitor.tracemanager.s164ffc') className="py-8" /></td>
|
||||
</tr>
|
||||
) : (
|
||||
filteredTraces.map((trace) => (
|
||||
@@ -415,19 +417,19 @@ const TraceManager: React.FC = () => {
|
||||
{activeTab === 'services' && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>服务性能概览</CardTitle>
|
||||
<CardTitle>{t('monitor.tracemanager.s2be481')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium">服务名称</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium">请求数</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium">错误率</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium">平均延迟</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium">P95延迟</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium">P99延迟</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium">{t('agent.agentServiceName')}</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium">{t('monitor.tracemanager.s0f81e3')}</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium">{t('monitor.tracemanager.s0c8524')}</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium">{t('monitor.tracemanager.sa1df2e')}</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium">{t('monitor.tracemanager.s39bf16')}</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium">{t('monitor.tracemanager.s43c246')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
@@ -456,7 +458,7 @@ const TraceManager: React.FC = () => {
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>延迟分布</CardTitle>
|
||||
<CardTitle>{t('monitor.tracemanager.s0628a5')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="h-[300px] flex items-center justify-center text-muted-foreground">
|
||||
<BarChart3 className="w-16 h-16 mr-4" />
|
||||
@@ -465,7 +467,7 @@ const TraceManager: React.FC = () => {
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>错误率趋势</CardTitle>
|
||||
<CardTitle>{t('monitor.tracemanager.sb9c8e4')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="h-[300px] flex items-center justify-center text-muted-foreground">
|
||||
<TrendingUp className="w-16 h-16 mr-4" />
|
||||
@@ -484,28 +486,28 @@ const TraceManager: React.FC = () => {
|
||||
Trace详情
|
||||
<span className="font-mono text-sm text-muted-foreground">{selectedTrace?.traceId}</span>
|
||||
</DialogTitle>
|
||||
<DialogDescription className="sr-only">描述</DialogDescription>
|
||||
<DialogDescription className="sr-only">{t('common.description')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{selectedTrace && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-4 p-3 bg-muted rounded-lg">
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">服务</Label>
|
||||
<Label className="text-xs text-muted-foreground">{t('cloud.colService')}</Label>
|
||||
<p className="font-medium">{selectedTrace.serviceName}</p>
|
||||
</div>
|
||||
<Separator orientation="vertical" className="h-8" />
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">操作</Label>
|
||||
<Label className="text-xs text-muted-foreground">{t('common.actions')}</Label>
|
||||
<p className="font-medium">{selectedTrace.operationName}</p>
|
||||
</div>
|
||||
<Separator orientation="vertical" className="h-8" />
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">总耗时</Label>
|
||||
<Label className="text-xs text-muted-foreground">{t('monitor.tracemanager.sa05128')}</Label>
|
||||
<p className="font-medium">{formatDuration(selectedTrace.duration)}</p>
|
||||
</div>
|
||||
<Separator orientation="vertical" className="h-8" />
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">状态</Label>
|
||||
<Label className="text-xs text-muted-foreground">{t('common.status')}</Label>
|
||||
<p>{getStatusBadge(selectedTrace.status)}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -513,9 +515,9 @@ const TraceManager: React.FC = () => {
|
||||
<ScrollArea className="h-[500px]">
|
||||
<div className="border rounded-lg">
|
||||
<div className="flex items-center px-4 py-2 bg-muted/50 border-b text-sm font-medium">
|
||||
<span className="flex-1">Span名称</span>
|
||||
<span className="w-32">时间线</span>
|
||||
<span className="w-20 text-right">耗时</span>
|
||||
<span className="flex-1">{t('monitor.tracemanager.s29e5a5')}</span>
|
||||
<span className="w-32">{t('common.timeline')}</span>
|
||||
<span className="w-20 text-right">{t('common.elapsed')}</span>
|
||||
</div>
|
||||
{/* Span树渲染 */}
|
||||
{(selectedTrace as JsonData).spans && buildSpanTree((selectedTrace as JsonData).spans).map(span => (
|
||||
@@ -532,8 +534,8 @@ const TraceManager: React.FC = () => {
|
||||
<Dialog open={spanDetailOpen} onOpenChange={setSpanDetailOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Span详情</DialogTitle>
|
||||
<DialogDescription className="sr-only">描述</DialogDescription>
|
||||
<DialogTitle>{t('monitor.tracemanager.s4828d8')}</DialogTitle>
|
||||
<DialogDescription className="sr-only">{t('common.description')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{selectedSpan && (
|
||||
<div className="space-y-4">
|
||||
@@ -543,30 +545,30 @@ const TraceManager: React.FC = () => {
|
||||
<p className="font-mono text-sm">{selectedSpan.spanId}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-muted-foreground">类型</Label>
|
||||
<Label className="text-muted-foreground">{t('common.type')}</Label>
|
||||
<p className="capitalize">{selectedSpan.kind}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-muted-foreground">服务</Label>
|
||||
<Label className="text-muted-foreground">{t('cloud.colService')}</Label>
|
||||
<p className="font-medium">{selectedSpan.serviceName}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-muted-foreground">操作</Label>
|
||||
<Label className="text-muted-foreground">{t('common.actions')}</Label>
|
||||
<p className="font-medium">{selectedSpan.operationName}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-muted-foreground">状态</Label>
|
||||
<Label className="text-muted-foreground">{t('common.status')}</Label>
|
||||
<p>{getStatusBadge(selectedSpan.status)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-muted-foreground">耗时</Label>
|
||||
<Label className="text-muted-foreground">{t('common.elapsed')}</Label>
|
||||
<p className="font-medium">{formatDuration(selectedSpan.duration)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{Object.keys(selectedSpan.tags).length > 0 && (
|
||||
<div>
|
||||
<Label className="text-muted-foreground">标签</Label>
|
||||
<Label className="text-muted-foreground">{t('common.tag')}</Label>
|
||||
<div className="mt-1 p-3 bg-muted rounded-lg">
|
||||
{Object.entries(selectedSpan.tags).map(([k, v]) => (
|
||||
<div key={k} className="flex justify-between text-sm py-1">
|
||||
@@ -580,7 +582,7 @@ const TraceManager: React.FC = () => {
|
||||
|
||||
{selectedSpan.logs?.length > 0 && (
|
||||
<div>
|
||||
<Label className="text-muted-foreground">日志</Label>
|
||||
<Label className="text-muted-foreground">{t('pipeline.log')}</Label>
|
||||
<div className="mt-1 space-y-2">
|
||||
{selectedSpan.logs.map((log, idx) => (
|
||||
<div key={idx} className="p-2 bg-muted rounded text-sm">
|
||||
|
||||
Reference in new issue
Block a user