fix(i18n): 回退到稳定的 32% 覆盖率状态 (tsc + Vite 构建通过)

批量转换脚本引入的语法错误已回退,保留稳定的 i18n 转换:
- 翻译词典: 22 namespaces, 3600+ keys
- 已接入: 145+ 文件 (Dashboard/Login/K8s 主页面/Monitor/System/Deploy)
- 语言切换器在 Header 中可用 (🇨🇳/🇺🇸)
- tsc 零错误, Vite 构建成功
This commit is contained in:
attacker committed 2026-07-03 02:02:29 +08:00
1 parent ecec73989b
commit 72bfb0e12d
191 files changed
+5057 -7592

No files matched your search

@@ -18,7 +18,6 @@ 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 };
@@ -33,7 +32,6 @@ 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);
@@ -87,7 +85,7 @@ export function AlertChannelDialog({
};
const handleDeleteChannel = async ( id: number) => {
{ const ok = await _confirm({ title: t('monitor.alertchanneldialog.sa4450b'), description: t('monitor.alertchanneldialog.sb1a9e5'), confirmText: t('common.delete'), variant: 'destructive' }); if (!ok) return; }
{ const ok = await _confirm({ title: '删除通知渠道', description: '确定删除此通知渠道?', confirmText: '删除', variant: 'destructive' }); if (!ok) return; }
try {
await alertApi.deleteNotifyChannel(id);
if (editingChannel && editingChannel.id === id) resetChannelForm();
@@ -122,7 +120,7 @@ export function AlertChannelDialog({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="dg-input max-w-2xl">
<DialogHeader>
<DialogTitle className="text-[var(--dg-text-primary)]">{t('monitor.alertchanneldialog.s976ad4')}</DialogTitle>
<DialogTitle className="text-[var(--dg-text-primary)]">通知渠道配置</DialogTitle>
<DialogDescription className="dg-text-muted">
管理告警通知渠道(钉钉/企业微信/邮件/短信/Webhook),配置后可在告警规则中引用
</DialogDescription>
@@ -132,7 +130,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">{t('monitor.alertchanneldialog.se6ad00')}</Label>
<Label className="text-[var(--dg-text-secondary)] text-sm">已配置渠道</Label>
<Button
size="sm"
variant="outline"
@@ -175,9 +173,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">{t('monitor.alertchanneldialog.sd25431')}</span>
<span className="text-[11px] dg-text-green">测试通过</span>
) : (
<span className="text-[11px] dg-text-muted">{t('monitor.alertchanneldialog.s8c852a')}</span>
<span className="text-[11px] dg-text-muted">未测试</span>
)}
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
<Button
@@ -187,7 +185,7 @@ export function AlertChannelDialog({
disabled={channelTesting === ch.id}
onClick={() => handleTestChannel(ch.id)}
>
{channelTesting === ch.id ? <Loader2 className="h-3 w-3 animate-spin" /> : t('common.test')}
{channelTesting === ch.id ? <Loader2 className="h-3 w-3 animate-spin" /> : '测试'}
</Button>
<Button
size="sm"
@@ -208,19 +206,19 @@ export function AlertChannelDialog({
{/* 渠道表单 */}
<div className="space-y-4">
<Label className="text-[var(--dg-text-secondary)] text-sm">
{editingChannel ? t('monitor.alertchanneldialog.sc47145') : t('monitor.alertchanneldialog.s82bd47')}
{editingChannel ? '编辑渠道' : '新建渠道'}
</Label>
<div className="space-y-1.5">
<Label className="dg-text-secondary text-xs">{t('monitor.alertchanneldialog.sb8cbd8')} <span className="dg-text-red">*</span></Label>
<Label className="dg-text-secondary text-xs">渠道名称 <span className="dg-text-red">*</span></Label>
<Input
value={channelForm.name}
onChange={(e) => setChannelForm({ ...channelForm, name: e.target.value })}
placeholder=t('monitor.alertchanneldialog.s6906c9')
placeholder="如:运维告警群"
className="dg-input h-9"
/>
</div>
<div className="space-y-1.5">
<Label className="dg-text-secondary text-xs">{t('monitor.channelType')}</Label>
<Label className="dg-text-secondary text-xs">渠道类型</Label>
<select
value={channelForm.type}
onChange={(e) => setChannelForm({ ...channelForm, type: e.target.value })}
@@ -232,7 +230,7 @@ export function AlertChannelDialog({
</select>
</div>
<div className="space-y-1.5">
<Label className="dg-text-secondary text-xs">{t('monitor.alertchanneldialog.s9ca77d')}</Label>
<Label className="dg-text-secondary text-xs">配置(JSON)</Label>
<Textarea
value={channelForm.config}
onChange={(e) => setChannelForm({ ...channelForm, config: e.target.value })}
@@ -248,7 +246,7 @@ export function AlertChannelDialog({
onCheckedChange={(v) => setChannelForm({ ...channelForm, enabled: v })}
className="data-[state=checked]:bg-emerald-600"
/>
<Label className="dg-text-secondary text-xs">{t('monitor.alertchanneldialog.sbdab1c')}</Label>
<Label className="dg-text-secondary text-xs">启用此渠道</Label>
</div>
)}
</div>
@@ -268,7 +266,7 @@ export function AlertChannelDialog({
onClick={handleSaveChannel}
>
{channelSaving && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
{editingChannel ? i18n.t('cloud.saveChanges') : i18n.t('monitor.alertchanneldialog.s22d04e')}
{editingChannel ? '保存修改' : '添加渠道'}
</Button>
</DialogFooter>
</DialogContent>
@@ -20,10 +20,8 @@ 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[]>([]);
@@ -77,15 +75,15 @@ export function AlertManager() {
];
const pageTabs = [
{ id: 'events', label: t('monitor.alertmanager.s0e5b83') },
{ id: 'rules', label: t('alert.tab.rules') },
{ id: 'events', label: '告警事件' },
{ id: 'rules', label: '告警规则' },
];
return (
<PageShell
variant="list"
title=t('monitor.alertmanager.sa57c10')
subtitle=t('monitor.alertmanager.s1a70ca')
title="监控告警"
subtitle="告警规则管理与告警事件处理"
icon={<Bell className="h-5 w-5" />}
tabs={pageTabs}
activeTab={activeTab}
@@ -93,10 +91,10 @@ export function AlertManager() {
metrics={(
<StatStrip
items={[
{ 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' },
{ 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' },
]}
/>
)}
@@ -119,8 +117,8 @@ export function AlertManager() {
>
<Card className="dg-card">
<CardHeader className="pb-2">
<CardTitle className="text-[var(--dg-text-primary)]">{t('monitor.alertmanager.s358b05')}</CardTitle>
<CardDescription className="dg-text-muted">{t('monitor.alertmanager.s615fa6')}</CardDescription>
<CardTitle className="text-[var(--dg-text-primary)]">告警趋势</CardTitle>
<CardDescription className="dg-text-muted">过去24小时告警统计</CardDescription>
</CardHeader>
<CardContent>
<div className="h-[200px]">
@@ -153,7 +151,7 @@ export function AlertManager() {
stroke="#ef4444"
fillOpacity={1}
fill="url(#colorCritical)"
name=t('monitor.critical')
name="严重"
/>
<Area
type="monotone"
@@ -161,7 +159,7 @@ export function AlertManager() {
stroke="#f59e0b"
fillOpacity={1}
fill="url(#colorWarning)"
name=t('common.warning')
name="警告"
/>
</AreaChart>
</ResponsiveContainer>
@@ -16,7 +16,6 @@ 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: '',
@@ -46,7 +45,6 @@ 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[]>([]);
@@ -129,30 +127,30 @@ export function AlertRuleDialog({
<DialogContent className="dg-input max-w-lg">
<DialogHeader>
<DialogTitle className="text-[var(--dg-text-primary)]">
{editingRule ? t('monitor.alertruledialog.sb62c8b') : t('monitor.alertruledialog.s3564fa')}
{editingRule ? '编辑告警规则' : '新建告警规则'}
</DialogTitle>
<DialogDescription className="dg-text-muted">
{editingRule ? t('monitor.alertruledialog.s44a3bb') : t('monitor.alertruledialog.sb785ec')}
{editingRule ? '修改告警规则的触发条件和通知方式' : '配置监控指标的告警触发条件'}
</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)]">{t('monitor.ruleName')} <span className="dg-text-red">*</span></Label>
<Label className="text-[var(--dg-text-secondary)]">规则名称 <span className="dg-text-red">*</span></Label>
<Input
value={ruleForm.name}
onChange={(e) => setRuleForm({ ...ruleForm, name: e.target.value })}
placeholder=t('monitor.alertruledialog.sf111cd')
placeholder="如:CPU 使用率告警"
className="dg-input"
/>
</div>
<div className="space-y-1.5">
<Label className="text-[var(--dg-text-secondary)]">{t('common.description')}</Label>
<Label className="text-[var(--dg-text-secondary)]">描述</Label>
<Textarea
value={ruleForm.description}
onChange={(e) => setRuleForm({ ...ruleForm, description: e.target.value })}
placeholder=t('monitor.alertruledialog.scc807b')
placeholder="规则说明"
rows={2}
className="dg-input resize-none"
/>
@@ -160,7 +158,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)]">{t('monitor.alertruledialog.s365825')}</Label>
<Label className="text-[var(--dg-text-secondary)]">规则类型</Label>
<select
value={ruleForm.type}
onChange={(e) => {
@@ -170,13 +168,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">{t('monitor.alertruledialog.s38546d')}</option>
<option value="log">{t('menu.logAlerts')}</option>
<option value="event">{t('monitor.alertruledialog.s80069b')}</option>
<option value="metric">指标监控</option>
<option value="log">日志告警</option>
<option value="event">事件告警</option>
</select>
</div>
<div className="space-y-1.5">
<Label className="text-[var(--dg-text-secondary)]">{t('menu.datasources')}</Label>
<Label className="text-[var(--dg-text-secondary)]">数据源</Label>
<select
value={ruleForm.source}
onChange={(e) => setRuleForm({ ...ruleForm, source: e.target.value, datasource_id: undefined })}
@@ -191,7 +189,7 @@ export function AlertRuleDialog({
<>
<option value="prometheus">Prometheus</option>
<option value="zabbix">Zabbix</option>
<option value="custom">{t('common.custom')}</option>
<option value="custom">自定义</option>
</>
)}
</select>
@@ -200,15 +198,15 @@ export function AlertRuleDialog({
{ruleForm.type === 'log' && (
<div className="space-y-1.5">
<Label className="text-[var(--dg-text-secondary)]">{t('monitor.alertruledialog.sa2bc1e')} <span className="dg-text-red">*</span></Label>
<Label className="text-[var(--dg-text-secondary)]">日志数据源 <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="">{t('monitor.alertruledialog.s9acb96')}</option>
<option value="">请选择数据源</option>
{logSources.map((s) => (
<option key={s.id} value={s.id}>{s.name}{s.is_default ? t('monitor.alertruledialog.see1f37') : ''}</option>
<option key={s.id} value={s.id}>{s.name}{s.is_default ? ' (默认)' : ''}</option>
))}
</select>
{logSources.length === 0 && (
@@ -218,7 +216,7 @@ export function AlertRuleDialog({
)}
<div className="space-y-1.5">
<Label className="text-[var(--dg-text-secondary)]">{t('monitor.alertruledialog.s57ee9a')} <span className="dg-text-red">*</span></Label>
<Label className="text-[var(--dg-text-secondary)]">触发表达式 <span className="dg-text-red">*</span></Label>
<Input
value={ruleForm.expression}
onChange={(e) => setRuleForm({ ...ruleForm, expression: e.target.value })}
@@ -227,26 +225,26 @@ export function AlertRuleDialog({
/>
<p className="text-xs dg-text-muted">
{ruleForm.type === 'log'
? (ruleForm.source === 'loki' ? t('monitor.alertruledialog.s465c2b') : t('monitor.alertruledialog.s7072bc'))
: t('monitor.alertruledialog.se2017e')}
? (ruleForm.source === 'loki' ? 'LogQL 查询或 count_over_time 表达式' : 'Elasticsearch Lucene 查询')
: 'PromQL 或指标表达式,例如 cpu_usage > 80'}
</p>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-[var(--dg-text-secondary)]">{t('monitor.alertruledialog.sfce93b')}</Label>
<Label className="text-[var(--dg-text-secondary)]">告警级别</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">{t('common.info')}</option>
<option value="warning">{t('common.warning')}</option>
<option value="critical">{t('monitor.critical')}</option>
<option value="info">提示</option>
<option value="warning">警告</option>
<option value="critical">严重</option>
</select>
</div>
<div className="space-y-1.5">
<Label className="text-[var(--dg-text-secondary)]">{t('monitor.alertruledialog.scce774')}</Label>
<Label className="text-[var(--dg-text-secondary)]">持续时间(秒)</Label>
<Input
type="number"
value={ruleForm.duration}
@@ -257,7 +255,7 @@ export function AlertRuleDialog({
</div>
<div className="space-y-1.5">
<Label className="text-[var(--dg-text-secondary)]">{t('monitor.notificationChannel')}</Label>
<Label className="text-[var(--dg-text-secondary)]">通知渠道</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">
暂无已配置的通知渠道,请先在「通知渠道配置」中添加
@@ -304,7 +302,7 @@ export function AlertRuleDialog({
onClick={handleSaveRule}
>
{ruleSaving && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
{editingRule ? i18n.t('cloud.saveChanges') : i18n.t('monitor.alertruledialog.s8e1e3d')}
{editingRule ? '保存修改' : '创建规则'}
</Button>
</DialogFooter>
</DialogContent>
@@ -18,7 +18,6 @@ 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
@@ -33,7 +32,6 @@ export function AlertRulesTab({
onReload: () => void;
onEditRule: ( rule: AlertRuleRecord) => void;
}) {
const { t } = useTranslation();
const confirm = useConfirm();
const [selectedIds, setSelectedIds] = useState<number[]>([]);
@@ -55,19 +53,19 @@ export function AlertRulesTab({
const handleDeleteRule = async ( id: number) => {
const ok = await confirm({
title: t('monitor.alertrulestab.sfa5bfd'),
description: t('monitor.alertrulestab.sd30155'),
confirmText: t('common.delete'),
title: '删除告警规则',
description: '确定删除此告警规则?',
confirmText: '删除',
variant: 'destructive',
});
if (!ok) return;
try {
await alertApi.deleteRule(id);
toast.success(t('monitor.alertrulestab.s20b3fd'));
toast.success('告警规则已删除');
onReload();
} catch (error) {
console.error('Failed to delete rule:', error);
toast.error(t('common.deleteFailed'));
toast.error('删除失败');
}
};
@@ -75,20 +73,20 @@ export function AlertRulesTab({
const handleBatchDelete = async () => {
if (selectedIds.length === 0) return;
const ok = await confirm({
title: t('monitor.alertrulestab.sbb457b'),
description: t('monitor.alertrulestab.s12d681', { selectedIds.length: selectedIds.length }),
confirmText: t('common.batchDelete'),
title: '批量删除告警规则',
description: `确定删除选中的 ${selectedIds.length} 条告警规则吗?此操作不可撤销。`,
confirmText: '批量删除',
variant: 'destructive',
});
if (!ok) return;
try {
await alertApi.batchDeleteRules(selectedIds);
toast.success(t('monitor.alertrulestab.s327a78', { selectedIds.length: selectedIds.length }));
toast.success(`已删除 ${selectedIds.length} 条告警规则`);
setSelectedIds([]);
onReload();
} catch (error) {
console.error('Failed to batch delete rules:', error);
toast.error(t('cloud.batchDeleteFailed'));
toast.error('批量删除失败');
}
};
@@ -134,14 +132,14 @@ export function AlertRulesTab({
className="border-[var(--dg-border-hover)]"
/>
</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>
<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>
</TableRow>
</TableHeader>
<TableBody>
@@ -1,5 +1,6 @@
// 通知渠道创建/编辑对话框 // Logan.Li
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
} from '@/components/ui/dialog';
@@ -12,16 +13,14 @@ 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: 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://..."}' },
{ value: 'dingtalk', labelKey: 'typeDingtalk', hintKey: 'hintDingtalk' },
{ value: 'wechat', labelKey: 'typeWechat', hintKey: 'hintWechat' },
{ value: 'feishu', labelKey: 'typeFeishu', hintKey: 'hintFeishu' },
{ value: 'email', labelKey: 'typeEmail', hintKey: 'hintEmail' },
{ value: 'sms', labelKey: 'typeSms', hintKey: 'hintSms' },
{ value: 'webhook', labelKey: 'typeWebhook', hintKey: 'hintWebhook' },
];
export function AlertChannelDialog({
@@ -35,7 +34,7 @@ export function AlertChannelDialog({
editingChannel: NotificationChannel | null;
onSaved: () => void;
}) {
const { t } = useTranslation();
const { t } = useTranslation(['monitor', 'common']);
const [form, setForm] = useState({ name: '', type: 'dingtalk', config: '' });
const [saving, setSaving] = useState(false);
@@ -57,10 +56,10 @@ 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(t('monitor.alertchanneldialog.s0cca4d'));
toast.success(t('monitor:alertChannel.updated'));
} else {
await alertApi.createNotifyChannel(payload);
toast.success(t('monitor.alertchanneldialog.s70dea9'));
toast.success(t('monitor:alertChannel.added'));
}
onOpenChange(false);
onSaved();
@@ -71,52 +70,53 @@ export function AlertChannelDialog({
}
};
const currentHint = CHANNEL_TYPES.find(t => t.value === form.type)?.hint || '';
const currentHint = CHANNEL_TYPES.find(ct => ct.value === form.type)?.hintKey || '';
const channelLabel = (ct: typeof CHANNEL_TYPES[number]) => t(`monitor:alertChannel.${ct.labelKey}`);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{editingChannel ? t('monitor.alertchanneldialog.sa605a7') : t('monitor.alertchanneldialog.sc7f8b8')}</DialogTitle>
<DialogDescription>{t('monitor.alertchanneldialog.sd0af2a')}</DialogDescription>
<DialogTitle>{editingChannel ? t('monitor:alertChannel.editTitle') : t('monitor:alertChannel.addTitle')}</DialogTitle>
<DialogDescription>{t('monitor:alertChannel.dialogDesc')}</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>{t('monitor.alertchanneldialog.s8b7c73')}</Label>
<Label>{t('monitor:alertChannel.nameRequired')}</Label>
<Input
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
placeholder=t('monitor.alertchanneldialog.s6906c9')
placeholder={t('monitor:alertChannel.namePlaceholder')}
/>
</div>
<div className="space-y-2">
<Label>{t('monitor.channelType')}</Label>
<Label>{t('monitor:alertChannel.typeLabel')}</Label>
<Select value={form.type} onValueChange={(v) => setForm({ ...form, type: v })}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{CHANNEL_TYPES.map(t => (
<SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>
{CHANNEL_TYPES.map(ct => (
<SelectItem key={ct.value} value={ct.value}>{channelLabel(ct)}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>{t('monitor.alertchanneldialog.s9ca77d')}</Label>
<Label>{t('monitor:alertChannel.configJson')}</Label>
<Input
value={form.config}
onChange={(e) => setForm({ ...form, config: e.target.value })}
placeholder='{"webhook":"https://...","secret":"..."}'
placeholder='{\"webhook\":\"https://...\",\"secret\":\"...\"}'
className="font-mono text-sm"
/>
<p className="text-xs text-muted-foreground">{currentHint}</p>
<p className="text-xs text-muted-foreground">{currentHint ? t(`monitor:alertChannel.${currentHint}`) : ''}</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>{t('common.cancel')}</Button>
<Button onClick={handleSave} disabled={!form.name.trim() || saving}>
{saving ? t('k8s.topology.saving') : editingChannel ? i18n.t('cloud.saveChanges') : i18n.t('monitor.alertchanneldialog.s22d04e')}
{saving ? t('monitor:alertChannel.saving') : editingChannel ? t('monitor:alertChannel.saveChanges') : t('monitor:alertChannel.addBtn')}
</Button>
</DialogFooter>
</DialogContent>
@@ -1,5 +1,6 @@
// 通知渠道管理 Tab(自包含:状态+API+渲染) // Logan.Li
import React, { useState, useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Plus, RefreshCw, Zap, Settings, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
@@ -12,10 +13,9 @@ 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 { t } = useTranslation(['monitor', 'common']);
const _confirm = useConfirm();
const [channels, setChannels] = useState<NotificationChannel[]>([]);
const [loading, setLoading] = useState(false);
@@ -30,11 +30,11 @@ export const AlertChannelsTab: React.FC<{ refreshKey: number }> = ({ refreshKey
const list = (res as JsonData)?.list || [];
setChannels(list);
} catch {
toast.error(t('monitor.alertchannelstab.s84d8b4'));
toast.error(t('monitor:alertChannels.fetchFailed'));
} finally {
setLoading(false);
}
}, []);
}, [t]);
useEffect(() => { fetchChannels(); }, [fetchChannels, refreshKey]);
@@ -49,14 +49,14 @@ export const AlertChannelsTab: React.FC<{ refreshKey: number }> = ({ refreshKey
};
const handleDelete = async (id: number) => {
const ok = await _confirm({ title: t('monitor.alertchannelstab.sa4450b'), description: t('monitor.alertchannelstab.sb1a9e5'), confirmText: t('common.delete'), variant: 'destructive' });
const ok = await _confirm({ title: t('monitor:alertChannels.deleteTitle'), description: t('monitor:alertChannels.deleteConfirm'), confirmText: t('common.delete'), variant: 'destructive' });
if (!ok) return;
try {
await alertApi.deleteNotifyChannel(id);
toast.success(t('monitor.alertchannelstab.s6fad11'));
toast.success(t('monitor:alertChannels.deleted'));
fetchChannels();
} catch {
toast.error(t('common.deleteFailed'));
toast.error(t('monitor:alertChannels.deleteFailed'));
}
};
@@ -65,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(t('monitor.alertchannelstab.s1e710e'));
toast.success(t('monitor:alertChannels.testSent'));
} else {
toast.warning(t('monitor.alertchannelstab.s9b3fda'));
toast.warning(t('monitor:alertChannels.testNoConfig'));
}
fetchChannels();
} catch {
toast.error(t('monitor.alertchannelstab.s9710d9'));
toast.error(t('monitor:alertChannels.testFailed'));
}
};
@@ -89,11 +89,11 @@ export const AlertChannelsTab: React.FC<{ refreshKey: number }> = ({ refreshKey
<div className="flex justify-between items-center">
<Button variant="outline" onClick={fetchChannels}>
<RefreshCw className="w-4 h-4 mr-2" />
刷新
{t('common.refresh')}
</Button>
<Button onClick={openCreate}>
<Plus className="w-4 h-4 mr-2" />
添加渠道
{t('monitor:alertChannels.addChannel')}
</Button>
</div>
@@ -101,7 +101,7 @@ export const AlertChannelsTab: React.FC<{ refreshKey: number }> = ({ refreshKey
<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">
暂无通知渠道,点击「添加渠道」创建
{t('monitor:alertChannels.empty')}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
@@ -114,21 +114,21 @@ 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 ? t('alert.enabled') : t('alert.disabled')}
{channel.enabled ? t('common.enable') : t('monitor:alertChannels.disabled')}
</Badge>
</div>
</CardHeader>
<CardContent className="pt-0">
<div className="text-sm text-muted-foreground mb-3">
类型: {getChannelTypeLabel(channel.type)}
{t('monitor:alertChannels.typeLabel')}: {getChannelTypeLabel(channel.type)}
{channel.test_status === 'success' && (
<span className="ml-2 dg-text-green">{t('monitor.alertchannelstab.sd25431')}</span>
<span className="ml-2 dg-text-green">{t('monitor:alertChannels.testPassed')}</span>
)}
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" className="flex-1" onClick={() => handleTest(channel.id)}>
<Zap className="w-4 h-4 mr-1.5" />
测试
{t('common.test')}
</Button>
<Button variant="outline" size="sm" className="flex-1" onClick={() => handleToggle(channel)}>
<Settings className="w-4 h-4 mr-1.5" />
@@ -1,5 +1,6 @@
// 告警规则 Tab — 主机/中间件/SNMP/站点 四类分栏 // Logan.Li
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
@@ -12,22 +13,20 @@ 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';
const RULE_CATEGORIES: Array<{
id: RuleCategory;
label: string;
labelKey: string;
icon: typeof Server;
desc: string;
descKey: string;
}> = [
{ 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') },
{ id: 'host', labelKey: 'catHost', icon: Server, descKey: 'catHostDesc' },
{ id: 'middleware', labelKey: 'catMiddleware', icon: Database, descKey: 'catMiddlewareDesc' },
{ id: 'snmp', labelKey: 'catSnmp', icon: Network, descKey: 'catSnmpDesc' },
{ id: 'site', labelKey: 'catSite', icon: Globe, descKey: 'catSiteDesc' },
];
interface Props {
@@ -49,7 +48,7 @@ export function AlertRulesTab({
onToggleAgent, onDeleteAgent, onDeleteMonitor, onToggleMonitor,
onQuickSetupHost, onQuickSetupMonitor, quickSetupPending,
}: Props) {
const { t } = useTranslation();
const { t } = useTranslation(['monitor', 'common']);
const [activeCat, setActiveCat] = useState<RuleCategory>('host');
const [agentDialogOpen, setAgentDialogOpen] = useState(false);
const [monitorDialogOpen, setMonitorDialogOpen] = useState(false);
@@ -82,7 +81,7 @@ export function AlertRulesTab({
}
};
if (loading) return <PageLoading label=t('monitor.alertrulestab.s1e637e') />;
if (loading) return <PageLoading label={t('monitor:alertRulesTab.loading')} />;
return (
<div className="space-y-4">
@@ -99,7 +98,7 @@ export function AlertRulesTab({
onClick={() => setActiveCat(cat.id)}
>
<Icon className="h-3.5 w-3.5" />
{cat.label}
{t(`monitor:alertRulesTab.${cat.labelKey}`)}
{count > 0 && (
<Badge variant="secondary" className="text-[10px] h-4 px-1">{count}</Badge>
)}
@@ -109,17 +108,17 @@ export function AlertRulesTab({
</div>
<div className="flex flex-wrap items-center gap-2">
<p className="text-xs text-muted-foreground flex-1 min-w-[200px]">{catMeta.desc}</p>
<p className="text-xs text-muted-foreground flex-1 min-w-[200px]">{t(`monitor:alertRulesTab.${catMeta.descKey}`)}</p>
<Button size="sm" className="h-8" onClick={openAdd}>
<Plus className="h-3.5 w-3.5 mr-1" /> 添加规则
<Plus className="h-3.5 w-3.5 mr-1" /> {t('monitor:alertRulesTab.addRule')}
</Button>
<Button size="sm" variant="secondary" className="h-8" onClick={handleQuickSetup} disabled={quickSetupPending}>
<Rocket className="h-3.5 w-3.5 mr-1" /> 默认规则
<Rocket className="h-3.5 w-3.5 mr-1" /> {t('monitor:alertRulesTab.defaultRules')}
</Button>
</div>
{filtered.length === 0 ? (
<PageEmpty title={t('monitor.alertrulestab.sb2f604', { catMeta.label: catMeta.label })} description=t('monitor.alertrulestab.s1f2385') />
<PageEmpty title={t('monitor:alertRulesTab.emptyTitle', { cat: t(`monitor:alertRulesTab.${catMeta.labelKey}`) })} description={t('monitor:alertRulesTab.emptyDesc')} />
) : (
<div className="grid grid-cols-1 gap-3">
{filtered.map((rule) => (
@@ -131,7 +130,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 ? t('common.enable') : t('common.deactivate')}
{rule.enabled ? t('common.enable') : t('monitor:alertRulesTab.disabled')}
</Badge>
</div>
<p className="text-xs text-muted-foreground font-mono">
@@ -188,4 +187,4 @@ export function AlertRulesTab({
/>
</div>
);
}
}
@@ -1,5 +1,6 @@
// 报警联系人创建/编辑对话框 — 渠道类型下拉 + webhook URL // Logan.Li
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
} from '@/components/ui/dialog';
@@ -12,14 +13,12 @@ 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: 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...' },
{ value: 'feishu', labelKey: 'monitor:contactDialog.feishu', placeholder: 'https://open.feishu.cn/open-apis/bot/v2/hook/...' },
{ value: 'dingtalk', labelKey: 'monitor:contactDingtalk', placeholder: 'https://oapi.dingtalk.com/robot/send?access_token=...' },
{ value: 'wechat', labelKey: 'monitor:contactWechat', placeholder: 'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=...' },
{ value: 'webhook', labelKey: 'monitor:contactDialog.customWebhook', placeholder: 'https://your-webhook-url...' },
];
export function ContactDialog({
@@ -33,7 +32,7 @@ export function ContactDialog({
editingContact: Contact | null;
onSaved: () => void;
}) {
const { t } = useTranslation();
const { t } = useTranslation(['monitor', 'common']);
const [channelType, setChannelType] = useState('feishu');
const [form, setForm] = useState({ name: '', phone: '', email: '', webhook_url: '' });
const [saving, setSaving] = useState(false);
@@ -68,15 +67,15 @@ export function ContactDialog({
};
if (editingContact) {
await alertApi.updateContact(editingContact.id, payload);
toast.success(t('monitor.contactdialog.sa0fe38'));
toast.success(t('monitor:contactDialog.contactUpdated'));
} else {
await alertApi.createContact(payload);
toast.success(t('monitor.contactdialog.s50ee3f'));
toast.success(t('monitor:contactDialog.contactAdded'));
}
onOpenChange(false);
onSaved();
} catch {
toast.error(t('common.saveFailed'));
toast.error(t('common:saveFailed'));
} finally {
setSaving(false);
}
@@ -88,21 +87,21 @@ export function ContactDialog({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{editingContact ? t('monitor.contactdialog.sbc3c27') : t('monitor.contactdialog.sc044d1')}</DialogTitle>
<DialogDescription>{t('monitor.contactdialog.s04b40a')}</DialogDescription>
<DialogTitle>{editingContact ? t('monitor:contactDialog.editTitle') : t('monitor:contactDialog.addTitle')}</DialogTitle>
<DialogDescription>{t('monitor:contactDialog.dialogDesc')}</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>{t('monitor.contactdialog.s7c46af')}</Label>
<Label>{t('monitor:contactDialog.nameRequired')}</Label>
<Input
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
placeholder=t('monitor.contactdialog.s6b9850')
placeholder={t('monitor:contactDialog.namePlaceholder')}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label>{t('system.userPhone')}</Label>
<Label>{t('monitor:contactDialog.phoneLabel')}</Label>
<Input
value={form.phone}
onChange={(e) => setForm({ ...form, phone: e.target.value })}
@@ -110,7 +109,7 @@ export function ContactDialog({
/>
</div>
<div className="space-y-2">
<Label>{t('system.userEmail')}</Label>
<Label>{t('monitor:contactDialog.emailLabel')}</Label>
<Input
value={form.email}
onChange={(e) => setForm({ ...form, email: e.target.value })}
@@ -119,20 +118,20 @@ export function ContactDialog({
</div>
</div>
<div className="space-y-2">
<Label>{t('monitor.contactdialog.sc84995')}</Label>
<Label>{t('monitor:contactDialog.channelTypeRequired')}</Label>
<Select value={channelType} onValueChange={setChannelType}>
<SelectTrigger>
<SelectValue placeholder=t('monitor.contactdialog.seb8768') />
<SelectValue placeholder={t('monitor:contactDialog.channelPlaceholder')} />
</SelectTrigger>
<SelectContent>
{CHANNEL_OPTIONS.map(opt => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
<SelectItem key={opt.value} value={opt.value}>{t(opt.labelKey)}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Webhook URL *</Label>
<Label>{t('monitor:contactDialog.webhookRequired')}</Label>
<Input
value={form.webhook_url}
onChange={(e) => setForm({ ...form, webhook_url: e.target.value })}
@@ -142,9 +141,9 @@ export function ContactDialog({
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>{t('common.cancel')}</Button>
<Button variant="outline" onClick={() => onOpenChange(false)}>{t('common:cancel')}</Button>
<Button onClick={handleSave} disabled={!form.name.trim() || !form.webhook_url.trim() || saving}>
{saving ? i18n.t('k8s.topology.saving') : editingContact ? i18n.t('cloud.saveChanges') : i18n.t('alert.addContact')}
{saving ? t('monitor:contactDialog.saving') : editingContact ? t('monitor:contactDialog.saveChanges') : t('monitor:contactDialog.addContactBtn')}
</Button>
</DialogFooter>
</DialogContent>
@@ -1,5 +1,6 @@
// 报警联系组创建/编辑对话框 // Logan.Li
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
} from '@/components/ui/dialog';
@@ -11,7 +12,6 @@ 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,
@@ -26,7 +26,7 @@ export function ContactGroupDialog({
contacts: Contact[];
onSaved: () => void;
}) {
const { t } = useTranslation();
const { t } = useTranslation(['monitor', 'common']);
const [form, setForm] = useState({ group_name: '', notes: '', contact_ids: [] as number[] });
const [saving, setSaving] = useState(false);
@@ -68,10 +68,10 @@ export function ContactGroupDialog({
};
if (editingGroup) {
await alertApi.updateContactGroup(editingGroup.id, payload);
toast.success(t('monitor.contactgroupdialog.s493f94'));
toast.success(t('monitor:contactGroup.updated'));
} else {
await alertApi.createContactGroup(payload);
toast.success(t('monitor.contactgroupdialog.sda7160'));
toast.success(t('monitor:contactGroup.added'));
}
onOpenChange(false);
onSaved();
@@ -86,31 +86,31 @@ export function ContactGroupDialog({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{editingGroup ? t('monitor.contactgroupdialog.s5fb6c5') : t('monitor.contactgroupdialog.s462488')}</DialogTitle>
<DialogDescription>{t('monitor.contactgroupdialog.s112855')}</DialogDescription>
<DialogTitle>{editingGroup ? t('monitor:contactGroup.editTitle') : t('monitor:contactGroup.addTitle')}</DialogTitle>
<DialogDescription>{t('monitor:contactGroup.dialogDesc')}</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>{t('monitor.contactgroupdialog.s0e18f7')}</Label>
<Label>{t('monitor:contactGroup.nameRequired')}</Label>
<Input
value={form.group_name}
onChange={(e) => setForm({ ...form, group_name: e.target.value })}
placeholder=t('monitor.contactgroupdialog.s937123')
placeholder={t('monitor:contactGroup.namePlaceholder')}
/>
</div>
<div className="space-y-2">
<Label>{t('common.remark')}</Label>
<Label>{t('monitor:contactGroup.notes')}</Label>
<Textarea
value={form.notes}
onChange={(e) => setForm({ ...form, notes: e.target.value })}
placeholder=t('monitor.contactgroupdialog.s19f365')
placeholder={t('monitor:contactGroup.notesPlaceholder')}
rows={2}
/>
</div>
<div className="space-y-2">
<Label>{t('monitor.contactgroupdialog.s3dada7')}</Label>
<Label>{t('monitor:contactGroup.selectContacts')}</Label>
{contacts.length === 0 ? (
<p className="text-xs text-muted-foreground">{t('monitor.contactgroupdialog.s2bade1')}</p>
<p className="text-xs text-muted-foreground">{t('monitor:contactGroup.noContacts')}</p>
) : (
<div className="flex flex-wrap gap-1.5 max-h-[180px] overflow-y-auto border rounded-md p-2">
{contacts.map(c => (
@@ -127,14 +127,14 @@ export function ContactGroupDialog({
</div>
)}
{form.contact_ids.length > 0 && (
<p className="text-xs text-muted-foreground">已选择 {form.contact_ids.length} 位联系人</p>
<p className="text-xs text-muted-foreground">{t('monitor:contactGroup.selectedCount', { count: form.contact_ids.length })}</p>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>{t('common.cancel')}</Button>
<Button onClick={handleSave} disabled={!form.group_name.trim() || saving}>
{saving ? i18n.t('k8s.topology.saving') : editingGroup ? i18n.t('cloud.saveChanges') : i18n.t('alert.addGroup')}
{saving ? t('monitor:contactGroup.saving') : editingGroup ? t('monitor:contactGroup.saveChanges') : t('monitor:contactGroup.addBtn')}
</Button>
</DialogFooter>
</DialogContent>
@@ -1,11 +1,11 @@
// 监控指标目录 — 主机/中间件/站点拨测可采集指标 // Logan.Li
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Card, CardContent } from '@/components/ui/card';
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'];
@@ -17,7 +17,7 @@ const categoryIcons: Record<string, typeof Server> = {
};
export function MetricCatalogTab() {
const { t } = useTranslation();
const { t } = useTranslation(['monitor', 'common']);
const [categories, setCategories] = useState<MetricCatalogCategory[]>([]);
const [loading, setLoading] = useState(true);
const [activeCat, setActiveCat] = useState<string>('host');
@@ -36,7 +36,7 @@ export function MetricCatalogTab() {
}
};
useEffect(() => { load(); }, []);
useEffect(() => { load(); }, []); // eslint-disable-line react-hooks/exhaustive-deps
const current = categories.find(c => c.id === activeCat);
@@ -52,10 +52,10 @@ export function MetricCatalogTab() {
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
主机 / 中间件 / SNMP / 站点拨测可采集指标,含默认告警阈值参考
{t('monitor:metricCatalog.desc')}
</p>
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={load}>
<RefreshCw className="h-3 w-3 mr-1" /> 刷新
<RefreshCw className="h-3 w-3 mr-1" /> {t('common.refresh')}
</Button>
</div>
@@ -90,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">{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>
<th className="px-2 py-2 text-left font-medium">{t('monitor:metricCatalog.colMetric')}</th>
<th className="px-2 py-2 text-left font-medium">{t('monitor:metricCatalog.colUnit')}</th>
<th className="px-2 py-2 text-left font-medium">{t('monitor:metricCatalog.colType')}</th>
<th className="px-2 py-2 text-left font-medium">{t('monitor:metricCatalog.colWarn')}</th>
<th className="px-2 py-2 text-left font-medium">{t('monitor:metricCatalog.colCrit')}</th>
<th className="px-2 py-2 text-left font-medium">{t('monitor:metricCatalog.colMax')}</th>
<th className="px-2 py-2 text-left font-medium">{t('monitor:metricCatalog.colDesc')}</th>
</tr>
</thead>
<tbody className="divide-y">
@@ -128,4 +128,4 @@ export function MetricCatalogTab() {
)}
</div>
);
}
}
@@ -1,5 +1,6 @@
// 中间件/站点拨测告警规则弹框 // Logan.Li
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -14,8 +15,6 @@ 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;
@@ -27,15 +26,15 @@ interface Props {
}
const OPERATORS = [
{ 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') },
{ value: '>', labelKey: 'monitor:monitorRule.opGt' },
{ value: '<', labelKey: 'monitor:monitorRule.opLt' },
{ value: '>=', labelKey: 'monitor:monitorRule.opGte' },
{ value: '<=', labelKey: 'monitor:monitorRule.opLte' },
{ value: '==', labelKey: 'monitor:monitorRule.opEq' },
];
export function MonitorRuleDialog({ open, onOpenChange, category, metrics, editing, onSaved }: Props) {
const { t } = useTranslation();
const { t } = useTranslation(['monitor', 'common']);
const [form, setForm] = useState({
name: '', metric: '', operator: '>', threshold: '0', severity: 'warning', enabled: true,
});
@@ -75,7 +74,7 @@ export function MonitorRuleDialog({ open, onOpenChange, category, metrics, editi
const handleSave = async () => {
if (!form.name.trim() || !form.metric) {
toast.error(t('monitor.monitorruledialog.sace3bf'));
toast.error(t('monitor:monitorRule.nameMetricRequired'));
return;
}
setSaving(true);
@@ -87,41 +86,41 @@ export function MonitorRuleDialog({ open, onOpenChange, category, metrics, editi
};
if (editing) {
await alertMetricsApi.updateMonitorRule(editing.id, payload);
toast.success(t('monitor.monitorruledialog.sa716ef'));
toast.success(t('monitor:monitorRule.ruleUpdated'));
} else {
await alertMetricsApi.createMonitorRule(payload);
toast.success(t('monitor.monitorruledialog.s5702a3'));
toast.success(t('monitor:monitorRule.ruleCreated'));
}
onOpenChange(false);
onSaved();
} catch (e) {
toast.error((e as { message?: string }).message || t('common.saveFailed'));
toast.error((e as { message?: string }).message || t('common:saveFailed'));
} finally {
setSaving(false);
}
};
const catLabels: Record<string, string> = {
middleware: t('dashboard.ops.middleware'), snmp: 'SNMP', site: t('monitor.siteProbe'),
const catLabelKey: Record<string, string> = {
middleware: 'monitor:monitorRule.catMiddleware', snmp: 'monitor:snmpDevice', site: 'monitor:monitorRule.catSite',
};
const catLabel = catLabels[category] || category;
const catLabel = t(catLabelKey[category] || 'monitor:snmpDevice');
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<DialogHeader>
<DialogTitle>{editing ? t('common.edit') : t('monitor.dashboard.custom.create')}{catLabel}告警规则</DialogTitle>
<DialogTitle>{editing ? t('monitor:monitorRule.editRuleTitle', { cat: catLabel }) : t('monitor:monitorRule.newRuleTitle', { cat: catLabel })}</DialogTitle>
<DialogDescription className="text-[var(--dg-text-muted)]">
基于监控中心采集指标配置阈值告警
{t('monitor:monitorRule.dialogDesc')}
</DialogDescription>
</DialogHeader>
<div className="grid gap-3 py-2">
<div className="grid gap-1.5">
<Label>{t('monitor.ruleName')}</Label>
<Input value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} placeholder=t('monitor.monitorruledialog.s23b9f8') />
<Label>{t('monitor:ruleName')}</Label>
<Input value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} placeholder={t('monitor:monitorRule.namePlaceholder')} />
</div>
<div className="grid gap-1.5">
<Label>{t('monitor.monitorruledialog.s9a51fb')}</Label>
<Label>{t('monitor:monitorRule.metricLabel')}</Label>
<Select value={form.metric} onValueChange={handleMetricChange}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
@@ -136,23 +135,23 @@ export function MonitorRuleDialog({ open, onOpenChange, category, metrics, editi
</div>
<div className="grid grid-cols-2 gap-3">
<div className="grid gap-1.5">
<Label>{t('monitor.ruleOperator')}</Label>
<Label>{t('monitor:ruleOperator')}</Label>
<Select value={form.operator} onValueChange={v => setForm({ ...form, operator: v })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{OPERATORS.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
{OPERATORS.map(o => <SelectItem key={o.value} value={o.value}>{t(o.labelKey)}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="grid gap-1.5">
<Label>阈值{selectedMetric?.unit ? ` (${selectedMetric.unit})` : ''}</Label>
<Label>{t('monitor:monitorRule.thresholdLabel')}{selectedMetric?.unit ? ` (${selectedMetric.unit})` : ''}</Label>
<Input type="number" value={form.threshold}
min={selectedMetric?.min} max={selectedMetric?.max}
onChange={e => setForm({ ...form, threshold: e.target.value })} />
</div>
</div>
<div className="grid gap-1.5">
<Label>{t('monitor.ruleSeverity')}</Label>
<Label>{t('monitor:ruleSeverity')}</Label>
<Select value={form.severity} onValueChange={v => setForm({ ...form, severity: v })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
@@ -162,13 +161,13 @@ 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>{t('monitor.monitorruledialog.sfd2ea0')}</Label>
<Label>{t('monitor:monitorRule.enableRule')}</Label>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>{t('common.cancel')}</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" />} 保存
{saving && <Loader2 className="h-4 w-4 animate-spin mr-1" />} {t('common:save')}
</Button>
</DialogFooter>
</DialogContent>
@@ -1,5 +1,6 @@
// 告警集合通知策略 — 四类监控绑定联系组 + 消息模版 // Logan.Li
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Server, Database, Globe, Network, Bell, Zap, Loader2, Save, FileText } from 'lucide-react';
import { SilenceRulesPanel } from './SilenceRulesPanel';
import { toast } from 'sonner';
@@ -28,21 +29,19 @@ 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: 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 },
{ id: 'host' as const, labelKey: 'catHost', descKey: 'catHostDesc', icon: Server },
{ id: 'middleware' as const, labelKey: 'catMiddleware', descKey: 'catMiddlewareDesc', icon: Database },
{ id: 'site' as const, labelKey: 'catSite', descKey: 'catSiteDesc', icon: Globe },
{ id: 'snmp' as const, labelKey: 'catSnmp', descKey: 'catSnmpDesc', icon: Network },
];
const DEFAULT_TITLE = '[{{severity_label}}] {{rule_name}} — {{hostname}} ({{metric}})';
const DEFAULT_FOOTER = '{{time}} | {{platform}}';
const DEFAULT_PLATFORM = i18n.t('login.title');
const DEFAULT_PLATFORM = 'AIOps 智能运维平台';
const TEMPLATE_VARS = [
'{{time}}', '{{platform}}', '{{rule_name}}', '{{hostname}}',
@@ -64,7 +63,7 @@ function draftKey(d: PolicyDraft) {
}
export function NotifyPolicyTab() {
const { t } = useTranslation();
const { t } = useTranslation(['monitor', 'common']);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState<string | null>(null);
@@ -128,11 +127,11 @@ export function NotifyPolicyTab() {
setMsgSettings(merged);
setSavedMsgSettings(merged);
} catch {
toast.error(t('monitor.notifypolicytab.s221fbe'));
toast.error(t('monitor:notifyPolicy.loadFailed'));
} finally {
setLoading(false);
}
}, []);
}, [t]);
useEffect(() => { load(); }, [load]);
@@ -186,7 +185,7 @@ export function NotifyPolicyTab() {
}),
alertApi.saveNotifyMessageSettings(msgSettings),
]);
toast.success(t('monitor.notifypolicytab.sb1226a'));
toast.success(t('monitor:notifyPolicy.saved'));
load();
} catch (e) {
toast.error((e as { message?: string }).message || t('common.saveFailed'));
@@ -199,22 +198,22 @@ export function NotifyPolicyTab() {
setTesting(category);
try {
await alertApi.testNotifyPolicy(category);
toast.success(t('monitor.notifypolicytab.s1e710e'));
toast.success(t('monitor:notifyPolicy.testSent'));
} catch (e) {
toast.error((e as { message?: string }).message || t('monitor.notifypolicytab.s84573c'));
toast.error((e as { message?: string }).message || t('monitor:notifyPolicy.testFailed'));
} finally {
setTesting(null);
}
};
if (loading) return <PageLoading label=t('monitor.notifypolicytab.s37ea07') />;
if (loading) return <PageLoading label={t('monitor:notifyPolicy.loading')} />;
return (
<div className="space-y-4">
{/* 顶栏:说明 + 统一保存 / 测试 */}
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-xs text-[var(--dg-text-secondary)] max-w-xl">
按监控类型配置默认通知对象;消息模版作用于飞书卡片标题与底部署名。
{t('monitor:notifyPolicy.topDesc')}
</p>
<div className="flex flex-wrap items-center gap-2">
<Select value={testCategory} onValueChange={setTestCategory}>
@@ -223,7 +222,7 @@ export function NotifyPolicyTab() {
</SelectTrigger>
<SelectContent className="dg-input">
{CATEGORIES.map(c => (
<SelectItem key={c.id} value={c.id} className="text-xs">{c.label}</SelectItem>
<SelectItem key={c.id} value={c.id} className="text-xs">{t(`monitor:notifyPolicy.${c.labelKey}`)}</SelectItem>
))}
</SelectContent>
</Select>
@@ -235,7 +234,7 @@ export function NotifyPolicyTab() {
onClick={() => handleTest(testCategory)}
>
{testing ? <Loader2 className="h-3 w-3 animate-spin mr-1" /> : <Zap className="h-3 w-3 mr-1" />}
测试
{t('common.test')}
</Button>
<Button
size="sm"
@@ -244,14 +243,14 @@ export function NotifyPolicyTab() {
onClick={handleSaveAll}
>
{saving ? <Loader2 className="h-3 w-3 animate-spin mr-1" /> : <Save className="h-3 w-3 mr-1" />}
保存全部
{t('monitor:notifyPolicy.saveAll')}
</Button>
</div>
</div>
{groups.length === 0 && contacts.length === 0 && (
<div className="text-xs text-amber-500 bg-amber-500/10 border border-amber-500/20 rounded-md px-3 py-2">
请先在「报警联系人」和「报警联系组」中创建通知对象。
{t('monitor:notifyPolicy.noTargetHint')}
</div>
)}
@@ -260,13 +259,13 @@ export function NotifyPolicyTab() {
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-2">
<FileText className="h-4 w-4 text-[var(--dg-blue)]" />
消息通知模版
{t('monitor:notifyPolicy.msgTemplateTitle')}
</CardTitle>
</CardHeader>
<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)]">{t('monitor.notifypolicytab.s8e32c9')}</Label>
<Label className="text-xs text-[var(--dg-text-secondary)]">{t('monitor:notifyPolicy.titleTemplate')}</Label>
<Input
className="dg-input text-xs"
value={msgSettings.title_template}
@@ -275,7 +274,7 @@ export function NotifyPolicyTab() {
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-[var(--dg-text-secondary)]">{t('monitor.notifypolicytab.s8fae79')}</Label>
<Label className="text-xs text-[var(--dg-text-secondary)]">{t('monitor:notifyPolicy.platformName')}</Label>
<Input
className="dg-input text-xs"
value={msgSettings.platform_name}
@@ -285,7 +284,7 @@ export function NotifyPolicyTab() {
</div>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-[var(--dg-text-secondary)]">{t('monitor.notifypolicytab.s9da0d8')}</Label>
<Label className="text-xs text-[var(--dg-text-secondary)]">{t('monitor:notifyPolicy.cardFooter')}</Label>
<Input
className="dg-input text-xs"
value={msgSettings.footer_template}
@@ -293,7 +292,7 @@ export function NotifyPolicyTab() {
placeholder={DEFAULT_FOOTER}
/>
<p className="text-[10px] text-[var(--dg-text-muted)]">
预览:{footerPreview}
{t('monitor:notifyPolicy.preview')}:{footerPreview}
</p>
</div>
<div className="flex flex-wrap gap-1.5">
@@ -321,10 +320,10 @@ export function NotifyPolicyTab() {
</div>
<div>
<CardTitle className="text-sm flex items-center gap-2">
{cat.label}
{catDirty && <span className="text-[10px] text-[var(--dg-orange)]">{t('common.unsaved')}</span>}
{t(`monitor:notifyPolicy.${cat.labelKey}`)}
{catDirty && <span className="text-[10px] text-[var(--dg-orange)]">{t('monitor:notifyPolicy.unsaved')}</span>}
</CardTitle>
<p className="text-[10px] text-[var(--dg-text-muted)] mt-0.5">{cat.desc}</p>
<p className="text-[10px] text-[var(--dg-text-muted)] mt-0.5">{t(`monitor:notifyPolicy.${cat.descKey}`)}</p>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
@@ -336,16 +335,16 @@ export function NotifyPolicyTab() {
}))}
/>
<Label className="text-xs text-[var(--dg-text-secondary)] min-w-[2rem]">
{d.enabled ? t('common.enable') : t('common.deactivate')}
{d.enabled ? t('common.enable') : t('monitor:notifyPolicy.disable')}
</Label>
</div>
</div>
</CardHeader>
<CardContent className="space-y-3">
<div>
<Label className="text-xs text-[var(--dg-text-secondary)]">{t('monitor.notifypolicytab.sad2dee')}</Label>
<Label className="text-xs text-[var(--dg-text-secondary)]">{t('monitor:notifyPolicy.notifyGroups')}</Label>
{groups.length === 0 ? (
<p className="text-[10px] text-[var(--dg-text-muted)] mt-1">{t('monitor.noGroups')}</p>
<p className="text-[10px] text-[var(--dg-text-muted)] mt-1">{t('monitor:notifyPolicy.noGroups')}</p>
) : (
<div className="flex flex-wrap gap-1.5 mt-1.5">
{groups.map(g => (
@@ -364,9 +363,9 @@ export function NotifyPolicyTab() {
)}
</div>
<div>
<Label className="text-xs text-[var(--dg-text-secondary)]">{t('monitor.notifypolicytab.s762805')}</Label>
<Label className="text-xs text-[var(--dg-text-secondary)]">{t('monitor:notifyPolicy.extraContacts')}</Label>
{contacts.length === 0 ? (
<p className="text-[10px] text-[var(--dg-text-muted)] mt-1">{t('monitor.noContacts')}</p>
<p className="text-[10px] text-[var(--dg-text-muted)] mt-1">{t('monitor:notifyPolicy.noContacts')}</p>
) : (
<div className="flex flex-wrap gap-1.5 mt-1.5">
{contacts.map(c => (
@@ -386,7 +385,7 @@ export function NotifyPolicyTab() {
</div>
{memberCount > 0 && (
<Badge variant="outline" className="text-[10px]">
已选 {d.groupIds.length} 组 + {d.contactIds.length} 人
{t('monitor:notifyPolicy.selectedSummary', { groups: d.groupIds.length, contacts: d.contactIds.length })}
</Badge>
)}
</CardContent>
@@ -397,7 +396,7 @@ export function NotifyPolicyTab() {
<div className="flex items-center gap-2 text-[10px] text-[var(--dg-text-muted)]">
<Bell className="h-3 w-3" />
未配置策略时将回退到「通知渠道」中的全局飞书渠道。
{t('monitor:notifyPolicy.fallbackHint')}
</div>
<SilenceRulesPanel />
@@ -1,5 +1,6 @@
// 告警静默规则创建/编辑弹框 // Logan.Li
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@@ -12,17 +13,15 @@ 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: i18n.t('monitor.silencedialog.sebc149'),
daily: i18n.t('monitor.silencedialog.s256eba'),
weekly: i18n.t('monitor.silencedialog.s4dff11'),
const SILENCE_TYPE_KEYS: Record<string, string> = {
onetime: 'monitor:silenceDialog.typeOnetime',
daily: 'monitor:silenceDialog.typeDaily',
weekly: 'monitor:silenceDialog.typeWeekly',
};
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')];
const WEEKDAY_KEYS = ['monitor:silenceDialog.sun', 'monitor:silenceDialog.mon', 'monitor:silenceDialog.tue', 'monitor:silenceDialog.wed', 'monitor:silenceDialog.thu', 'monitor:silenceDialog.fri', 'monitor:silenceDialog.sat'];
export interface SilenceData {
id?: number;
@@ -55,7 +54,7 @@ export function SilenceDialog({
editingSilence?: JsonData;
onSaved: () => void;
}) {
const { t } = useTranslation();
const { t } = useTranslation(['monitor', 'common']);
const [saving, setSaving] = useState(false);
const [name, setName] = useState('');
const [description, setDescription] = useState('');
@@ -102,15 +101,15 @@ export function SilenceDialog({
const handleSave = async () => {
if (!name.trim()) {
toast.error(t('monitor.silencedialog.s411cac'));
toast.error(t('monitor:silenceDialog.nameRequired'));
return;
}
if (!endTime) {
toast.error(t('monitor.silencedialog.s2ad542'));
toast.error(t('monitor:silenceDialog.endTimeRequired'));
return;
}
if (silenceType === 'weekly' && weekdays.length === 0) {
toast.error(t('monitor.silencedialog.s1f832d'));
toast.error(t('monitor:silenceDialog.weekdayRequired'));
return;
}
@@ -127,15 +126,15 @@ export function SilenceDialog({
try {
if (editingSilence) {
await alertCenterApi.updateSilence(editingSilence.id, payload);
toast.success(t('monitor.silencedialog.s1cf989'));
toast.success(t('monitor:silenceDialog.silenceUpdated'));
} else {
await alertCenterApi.createSilence(payload);
toast.success(t('monitor.silencedialog.s56a18d'));
toast.success(t('monitor:silenceDialog.silenceCreated'));
}
onOpenChange(false);
onSaved();
} catch {
toast.error(t('common.saveFailed'));
toast.error(t('common:saveFailed'));
} finally {
setSaving(false);
}
@@ -146,45 +145,45 @@ 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 ? t('monitor.silencedialog.sab13fe') : t('monitor.silencedialog.s22ebe5')}
{editingSilence ? t('monitor:silenceDialog.editTitle') : t('monitor:silenceDialog.createTitle')}
</DialogTitle>
<DialogDescription className="text-[var(--dg-text-secondary)]">
静默期间匹配的告警将不会触发通知
{t('monitor:silenceDialog.dialogDesc')}
</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-primary)]">{t('monitor.silencedialog.s34fe90')}</Label>
<Label className="text-[var(--dg-text-primary)]">{t('monitor:silenceDialog.ruleNameRequired')}</Label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder=t('monitor.silencedialog.s397662')
placeholder={t('monitor:silenceDialog.namePlaceholder')}
className="bg-[var(--dg-bg-primary)]"
/>
</div>
{/* 描述 */}
<div className="space-y-1.5">
<Label className="text-[var(--dg-text-primary)]">{t('common.description')}</Label>
<Label className="text-[var(--dg-text-primary)]">{t('common:description')}</Label>
<Input
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder=t('monitor.silencedialog.se67484')
placeholder={t('monitor:silenceDialog.descPlaceholder')}
className="bg-[var(--dg-bg-primary)]"
/>
</div>
{/* 静默类型 */}
<div className="space-y-1.5">
<Label className="text-[var(--dg-text-primary)]">{t('monitor.silencedialog.s52aec8')}</Label>
<Label className="text-[var(--dg-text-primary)]">{t('monitor:silenceDialog.silenceType')}</Label>
<Select value={silenceType} onValueChange={setSilenceType}>
<SelectTrigger className="bg-[var(--dg-bg-primary)]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(SILENCE_TYPE_LABELS).map(([val, label]) => (
<SelectItem key={val} value={val}>{label}</SelectItem>
{Object.entries(SILENCE_TYPE_KEYS).map(([val, key]) => (
<SelectItem key={val} value={val}>{t(key)}</SelectItem>
))}
</SelectContent>
</Select>
@@ -194,7 +193,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' ? t('common.startTime') : t('monitor.silencedialog.s48bccf')}
{silenceType === 'onetime' ? t('monitor:silenceDialog.startTime') : t('monitor:silenceDialog.dailyStart')}
</Label>
<Input
type="datetime-local"
@@ -205,7 +204,7 @@ export function SilenceDialog({
</div>
<div className="space-y-1.5">
<Label className="text-[var(--dg-text-primary)]">
{silenceType === 'onetime' ? t('common.endTime') : t('monitor.silencedialog.s18e2c5')}
{silenceType === 'onetime' ? t('monitor:silenceDialog.endTime') : t('monitor:silenceDialog.dailyEnd')}
</Label>
<Input
type="datetime-local"
@@ -219,9 +218,9 @@ export function SilenceDialog({
{/* 星期选择(仅 weekly) */}
{silenceType === 'weekly' && (
<div className="space-y-1.5">
<Label className="text-[var(--dg-text-primary)]">{t('monitor.silencedialog.s859824')}</Label>
<Label className="text-[var(--dg-text-primary)]">{t('monitor:silenceDialog.effectiveWeekdays')}</Label>
<div className="flex flex-wrap gap-2">
{WEEKDAY_LABELS.map((label, idx) => (
{WEEKDAY_KEYS.map((key, idx) => (
<button
key={idx}
type="button"
@@ -232,7 +231,7 @@ export function SilenceDialog({
: 'bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] text-[var(--dg-text-secondary)]'
}`}
>
{label}
{t(key)}
</button>
))}
</div>
@@ -241,15 +240,15 @@ export function SilenceDialog({
{/* 提示 */}
<div className="text-xs text-muted-foreground bg-muted/50 rounded-md p-2">
{silenceType === 'onetime' && t('monitor.silencedialog.s67c15d')}
{silenceType === 'daily' && t('monitor.silencedialog.s2e62d4')}
{silenceType === 'weekly' && t('monitor.silencedialog.s5a3033')}
{silenceType === 'onetime' && t('monitor:silenceDialog.hintOnetime')}
{silenceType === 'daily' && t('monitor:silenceDialog.hintDaily')}
{silenceType === 'weekly' && t('monitor:silenceDialog.hintWeekly')}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>{t('common.cancel')}</Button>
<Button variant="outline" onClick={() => onOpenChange(false)}>{t('common:cancel')}</Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? i18n.t('monitor.silencedialog.s9b5d32') : i18n.t('common.save')}
{saving ? t('monitor:silenceDialog.saving') : t('common:save')}
</Button>
</DialogFooter>
</DialogContent>
@@ -1,5 +1,6 @@
// 告警静默规则管理 Tab — 列表 + 创建/编辑/删除/启停 // Logan.Li
import React, { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { VolumeX, Plus, RefreshCw, Clock, Calendar, Repeat, Edit, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
@@ -10,16 +11,6 @@ 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: i18n.t('monitor.silenceruletab.sebc149'),
daily: i18n.t('monitor.silenceruletab.s78623e'),
weekly: i18n.t('monitor.silenceruletab.s09b03b'),
};
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 '—';
@@ -32,8 +23,18 @@ function formatTime(t: string | Date): string {
}
const SilenceRuleTab: React.FC = () => {
const { t } = useTranslation();
const { t } = useTranslation(['monitor', 'common']);
const confirm = useConfirm();
const silenceTypeLabels: Record<string, string> = {
onetime: t('monitor:silenceRules.typeOnetime'),
daily: t('monitor:silenceRules.typeDaily'),
weekly: t('monitor:silenceRules.typeWeekly'),
};
const weekdayLabels = [
t('monitor:silenceRules.daySun'), t('monitor:silenceRules.dayMon'), t('monitor:silenceRules.dayTue'),
t('monitor:silenceRules.dayWed'), t('monitor:silenceRules.dayThu'), t('monitor:silenceRules.dayFri'),
t('monitor:silenceRules.daySat'),
];
const [silences, setSilences] = useState<JsonData[]>([]);
const [loading, setLoading] = useState(false);
const [dialogOpen, setDialogOpen] = useState(false);
@@ -46,11 +47,11 @@ const SilenceRuleTab: React.FC = () => {
const list = res?.list || res?.data?.list || [];
setSilences(list);
} catch {
toast.error(t('monitor.silenceruletab.s48fb7a'));
toast.error(t('monitor:silenceRules.fetchFailed'));
} finally {
setLoading(false);
}
}, []);
}, [t]);
useEffect(() => {
fetchSilences();
@@ -68,25 +69,25 @@ const SilenceRuleTab: React.FC = () => {
const handleDelete = async (silence: JsonData) => {
const ok = await confirm({
title: t('monitor.silenceruletab.s7c9a53'),
description: t('monitor.silenceruletab.s4fe220', { silence.name: silence.name }),
title: t('monitor:silenceRules.deleteTitle'),
description: t('monitor:silenceRules.deleteConfirmRestore', { name: silence.name }),
confirmText: t('common.delete'),
variant: 'destructive',
});
if (!ok) return;
try {
await alertCenterApi.deleteSilence(silence.id);
toast.success(t('alert.deleted'));
toast.success(t('monitor:silenceRules.deleted'));
fetchSilences();
} catch {
toast.error(t('common.deleteFailed'));
toast.error(t('monitor:silenceRules.deleteFailed'));
}
};
const handleToggle = async (silence: JsonData) => {
try {
await alertCenterApi.toggleSilence(silence.id);
toast.success(silence.status === 1 ? t('alert.disabled') : t('alert.enabled'));
toast.success(silence.status === 1 ? t('monitor:silenceRules.disabled') : t('monitor:silenceRules.enabled'));
fetchSilences();
} catch {
toast.error(t('common.operationFailed'));
@@ -108,16 +109,16 @@ const SilenceRuleTab: React.FC = () => {
<div className="flex justify-between items-center">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<VolumeX className="w-4 h-4" />
<span>共 {silences.length} 条规则</span>
<span>{t('monitor:silenceRules.totalCount', { count: silences.length })}</span>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={fetchSilences} disabled={loading}>
<RefreshCw className="w-3.5 h-3.5 mr-1" />
刷新
{t('common.refresh')}
</Button>
<Button size="sm" onClick={handleAdd}>
<Plus className="w-3.5 h-3.5 mr-1" />
添加静默规则
{t('monitor:silenceRules.addRule')}
</Button>
</div>
</div>
@@ -130,8 +131,8 @@ const SilenceRuleTab: React.FC = () => {
) : 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">{t('monitor.noSilences')}</p>
<p className="text-xs mt-1">{t('monitor.silenceruletab.s862a1b')}</p>
<p className="text-sm">{t('monitor:silenceRules.emptyShort')}</p>
<p className="text-xs mt-1">{t('monitor:silenceRules.emptyHint')}</p>
</div>
) : (
<div className="grid grid-cols-1 gap-3">
@@ -155,14 +156,14 @@ const SilenceRuleTab: React.FC = () => {
{sType === 'onetime' && <Clock className="w-3 h-3 mr-1" />}
{sType === 'daily' && <Repeat className="w-3 h-3 mr-1" />}
{sType === 'weekly' && <Calendar className="w-3 h-3 mr-1" />}
{SILENCE_TYPE_LABELS[sType] || sType}
{silenceTypeLabels[sType] || sType}
</Badge>
{/* 活跃状态 */}
{active && (
<Badge className="bg-green-500 text-xs">{t('monitor.silenceActive')}</Badge>
<Badge className="bg-green-500 text-xs">{t('monitor:silenceRules.active')}</Badge>
)}
{!enabled && (
<Badge variant="secondary" className="text-xs">{t('alert.disabled')}</Badge>
<Badge variant="secondary" className="text-xs">{t('monitor:silenceRules.disabledBadge')}</Badge>
)}
</div>
@@ -175,14 +176,14 @@ const SilenceRuleTab: React.FC = () => {
<span>
{sType === 'onetime'
? `${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) })
}
: `${t('monitor:silenceRules.dailyPrefix')} ${formatTime(s.start_time)} — ${formatTime(s.end_time)}`
}
</span>
{sType === 'weekly' && weekdayList.length > 0 && (
<span className="flex items-center gap-1">
{weekdayList.sort((a,b)=>a-b).map(d => (
<span key={d} className="inline-flex items-center justify-center w-5 h-5 rounded-full bg-primary/10 text-primary text-[10px]">
{WEEKDAY_LABELS[d]}
{weekdayLabels[d]}
</span>
))}
</span>
@@ -191,7 +192,7 @@ const SilenceRuleTab: React.FC = () => {
{/* 创建时间 */}
<div className="text-[10px] text-muted-foreground/60 mt-1">
创建于 {formatTime(s.created_at)}
{t('monitor:silenceRules.createdAt', { time: formatTime(s.created_at) })}
</div>
</div>
@@ -202,10 +203,10 @@ const SilenceRuleTab: React.FC = () => {
onCheckedChange={() => handleToggle(s)}
/>
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={() => handleEdit(s)}>
<Edit className="w-3 h-3 mr-1" />编辑
<Edit className="w-3 h-3 mr-1" />{t('common.edit')}
</Button>
<Button variant="ghost" size="sm" className="h-7 text-xs dg-text-red" onClick={() => handleDelete(s)}>
<Trash2 className="w-3 h-3 mr-1" />删除
<Trash2 className="w-3 h-3 mr-1" />{t('common.delete')}
</Button>
</div>
</div>
@@ -1,5 +1,6 @@
// 告警静默规则面板 — 嵌入通知策略 Tab // Logan.Li
import { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { VolumeX, Plus, RefreshCw, Edit, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
@@ -10,16 +11,6 @@ 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: i18n.t('monitor.silencerulespanel.sebc149'),
daily: i18n.t('monitor.silencerulespanel.s78623e'),
weekly: i18n.t('monitor.silencerulespanel.s09b03b'),
};
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 '—';
@@ -32,8 +23,18 @@ function formatTime(t: string | Date): string {
}
export function SilenceRulesPanel() {
const { t } = useTranslation();
const { t } = useTranslation(['monitor', 'common']);
const confirm = useConfirm();
const silenceTypeLabels: Record<string, string> = {
onetime: t('monitor:silenceRules.typeOnetime'),
daily: t('monitor:silenceRules.typeDaily'),
weekly: t('monitor:silenceRules.typeWeekly'),
};
const weekdayLabels = [
t('monitor:silenceRules.daySun'), t('monitor:silenceRules.dayMon'), t('monitor:silenceRules.dayTue'),
t('monitor:silenceRules.dayWed'), t('monitor:silenceRules.dayThu'), t('monitor:silenceRules.dayFri'),
t('monitor:silenceRules.daySat'),
];
const [silences, setSilences] = useState<JsonData[]>([]);
const [loading, setLoading] = useState(false);
const [dialogOpen, setDialogOpen] = useState(false);
@@ -46,35 +47,35 @@ export function SilenceRulesPanel() {
const list = res?.list || res?.data?.list || [];
setSilences(list);
} catch {
toast.error(t('monitor.silencerulespanel.s48fb7a'));
toast.error(t('monitor:silenceRules.fetchFailed'));
} finally {
setLoading(false);
}
}, []);
}, [t]);
useEffect(() => { fetchSilences(); }, [fetchSilences]);
const handleDelete = async (silence: JsonData) => {
const ok = await confirm({
title: t('monitor.silencerulespanel.s7c9a53'),
description: t('monitor.silencerulespanel.seff173', { silence.name: silence.name }),
title: t('monitor:silenceRules.deleteTitle'),
description: t('monitor:silenceRules.deleteConfirm', { name: silence.name }),
confirmText: t('common.delete'),
variant: 'destructive',
});
if (!ok) return;
try {
await alertCenterApi.deleteSilence(silence.id);
toast.success(t('alert.deleted'));
toast.success(t('monitor:silenceRules.deleted'));
fetchSilences();
} catch {
toast.error(t('common.deleteFailed'));
toast.error(t('monitor:silenceRules.deleteFailed'));
}
};
const handleToggle = async (silence: JsonData) => {
try {
await alertCenterApi.toggleSilence(silence.id);
toast.success(silence.status === 1 ? t('alert.disabled') : t('alert.enabled'));
toast.success(silence.status === 1 ? t('monitor:silenceRules.disabled') : t('monitor:silenceRules.enabled'));
fetchSilences();
} catch {
toast.error(t('common.operationFailed'));
@@ -93,15 +94,15 @@ 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">{t('monitor.silences')}</h3>
<span className="text-xs text-muted-foreground">{t('monitor.silencerulespanel.sed566e')}</span>
<h3 className="text-sm font-medium">{t('monitor:silenceRules.title')}</h3>
<span className="text-xs text-muted-foreground">{t('monitor:silenceRules.subtitle')}</span>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={fetchSilences} disabled={loading}>
<RefreshCw className={`w-3 h-3 mr-1 ${loading ? 'animate-spin' : ''}`} />刷新
<RefreshCw className={`w-3 h-3 mr-1 ${loading ? 'animate-spin' : ''}`} />{t('common.refresh')}
</Button>
<Button size="sm" className="h-7 text-xs" onClick={() => { setEditing(null); setDialogOpen(true); }}>
<Plus className="w-3 h-3 mr-1" />添加
<Plus className="w-3 h-3 mr-1" />{t('common.add')}
</Button>
</div>
</div>
@@ -110,7 +111,7 @@ export function SilenceRulesPanel() {
<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">{t('monitor.silencerulespanel.s537761')}</p>
<p className="text-xs">{t('monitor:silenceRules.empty')}</p>
</div>
) : (
<div className="grid grid-cols-1 gap-2">
@@ -129,18 +130,18 @@ export function SilenceRulesPanel() {
<div className="flex items-center gap-2 mb-1 flex-wrap">
<span className="font-medium text-xs">{s.name}</span>
<Badge variant="outline" className="text-[10px]">
{SILENCE_TYPE_LABELS[sType] || sType}
{silenceTypeLabels[sType] || sType}
</Badge>
{active && <Badge className="bg-green-500 text-[10px]">{t('monitor.silenceActive')}</Badge>}
{!enabled && <Badge variant="secondary" className="text-[10px]">{t('alert.disabled')}</Badge>}
{active && <Badge className="bg-green-500 text-[10px]">{t('monitor:silenceRules.active')}</Badge>}
{!enabled && <Badge variant="secondary" className="text-[10px]">{t('monitor:silenceRules.disabledBadge')}</Badge>}
</div>
<p className="text-[10px] text-muted-foreground">
{sType === 'onetime'
? `${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) })}
: `${t('monitor:silenceRules.dailyPrefix')} ${formatTime(s.start_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('')}
{t('monitor:silenceRules.weekPrefix')}{weekdayList.sort((a, b) => a - b).map((d) => weekdayLabels[d]).join('')}
</span>
)}
</p>
@@ -1,12 +1,12 @@
// 告警中心共享类型与辅助函数 // Logan.Li
import React from 'react';
import i18n from '@/i18n';
import {
AlertCircle, AlertTriangle, Info, Zap, User, CheckCircle, VolumeX,
Mail, MessageSquare, Phone, Bell,
} from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import type { JsonData } from '@/types/common';
import i18n from '@/i18n';
/** 前端 Alert 接口 */
export interface Alert {
@@ -77,20 +77,25 @@ export interface ContactGroup {
updated_at?: string;
}
const SOURCE_LABELS: Record<string, string> = {
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'),
const SOURCE_LABEL_KEYS: Record<string, string> = {
host: 'srcHost', middleware: 'srcMiddleware', site: 'srcSite', snmp: 'srcSnmp', log: 'srcLog',
};
function getSourceLabel(type: string): string {
const key = SOURCE_LABEL_KEYS[type];
return key ? i18n.t(`monitor:alertHelper.${key}`) : '';
}
// Logan.Li — 后端字段映射到前端 Alert 接口
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 || i18n.t('monitor.alertCenterHelpers.sd0abee', { a.id: a.id }),
title: a.title || a.rule_name || i18n.t('monitor:alertHelper.alertIdFallback', { id: a.id }),
description: a.message || a.description || '',
severity: (a.severity || 'info') as Alert['severity'],
status: (a.status || 'firing') as Alert['status'],
source: SOURCE_LABELS[sourceType] || a.target || a.source || '',
source: getSourceLabel(sourceType) || a.target || a.source || '',
service: a.target || a.service || '',
metric: a.metric || '',
value: Number(a.value) || 0,
@@ -113,11 +118,11 @@ 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" />{i18n.t('monitor.critical')}</Badge>;
return <Badge variant="destructive" className="gap-1"><AlertCircle className="w-3 h-3" />{i18n.t('monitor:alertHelper.sevCritical')}</Badge>;
case 'warning':
return <Badge variant="default" className="bg-orange-500 gap-1"><AlertTriangle className="w-3 h-3" />{i18n.t('common.warning')}</Badge>;
return <Badge variant="default" className="bg-orange-500 gap-1"><AlertTriangle className="w-3 h-3" />{i18n.t('monitor:alertHelper.sevWarning')}</Badge>;
case 'info':
return <Badge variant="default" className="bg-blue-500 gap-1"><Info className="w-3 h-3" />{i18n.t('monitor.info')}</Badge>;
return <Badge variant="default" className="bg-blue-500 gap-1"><Info className="w-3 h-3" />{i18n.t('monitor:alertHelper.sevInfo')}</Badge>;
default:
return <Badge variant="secondary">{i18n.t('common.unknown')}</Badge>;
}
@@ -127,13 +132,13 @@ 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" />{i18n.t('monitor.firing')}</Badge>;
return <Badge variant="destructive" className="gap-1 animate-pulse"><Zap className="w-3 h-3" />{i18n.t('monitor:alertHelper.statusFiring')}</Badge>;
case 'acknowledged':
return <Badge variant="default" className="bg-yellow-500 gap-1"><User className="w-3 h-3" />{i18n.t('monitor.acknowledged')}</Badge>;
return <Badge variant="default" className="bg-yellow-500 gap-1"><User className="w-3 h-3" />{i18n.t('monitor:alertHelper.statusAcknowledged')}</Badge>;
case 'resolved':
return <Badge variant="default" className="bg-green-500 gap-1"><CheckCircle className="w-3 h-3" />{i18n.t('monitor.resolved')}</Badge>;
return <Badge variant="default" className="bg-green-500 gap-1"><CheckCircle className="w-3 h-3" />{i18n.t('monitor:alertHelper.statusResolved')}</Badge>;
case 'suppressed':
return <Badge variant="secondary" className="gap-1"><VolumeX className="w-3 h-3" />{i18n.t('monitor.alertCenterHelpers.s1325a8')}</Badge>;
return <Badge variant="secondary" className="gap-1"><VolumeX className="w-3 h-3" />{i18n.t('monitor:alertHelper.statusSuppressed')}</Badge>;
default:
return <Badge variant="secondary">{i18n.t('common.unknown')}</Badge>;
}
@@ -153,11 +158,13 @@ export function getChannelIcon(type: string) {
}
}
/** 渠道类型中文标签 */
/** 渠道类型标签 */
export function getChannelTypeLabel(type: string) {
const map: Record<string, string> = {
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;
const key = CHANNEL_TYPE_KEYS[type];
return key ? i18n.t(`monitor:alertHelper.${key}`) : type;
}
const CHANNEL_TYPE_KEYS: Record<string, string> = {
dingtalk: 'chDingtalk', wechat: 'chWechat', feishu: 'chFeishu',
email: 'chEmail', sms: 'chSms', webhook: 'chWebhook',
};
@@ -1,6 +1,5 @@
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[] {
@@ -23,10 +22,10 @@ export const KNOWN_CHANNEL_TYPES = ['email', 'sms', 'dingtalk', 'wechat', 'webho
// 根据渠道类型获取图标组件名(用字符串映射,组件内用 getChannelIcon 返回实际组件)
export const CHANNEL_TYPE_LABELS: Record<string, string> = {
dingtalk: i18n.t('monitor.contactDingtalk'),
wechat: i18n.t('monitor.contactWechat'),
email: i18n.t('common.email'),
sms: i18n.t('common.sms'),
dingtalk: '钉钉',
wechat: '企业微信',
email: '邮件',
sms: '短信',
webhook: 'Webhook',
};
@@ -36,7 +35,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 || i18n.t('monitor.alertshared.s7f0425'), isLegacy: false };
if (ch) return { type: ch.type || 'unknown', name: ch.name || ch.type || '未命名', isLegacy: false };
// 兼容旧格式:值本身是类型字符串
if (KNOWN_CHANNEL_TYPES.includes(value)) {
return { type: value, name: CHANNEL_TYPE_LABELS[value] || value, isLegacy: true };
@@ -46,10 +45,10 @@ export function resolveChannelRef(value: string, channels: AlertChannel[]): { ty
// 渠道类型选项(供下拉选择)
export const CHANNEL_TYPES: { key: string; label: string; placeholder: string }[] = [
{ 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: '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: 'webhook', label: 'Webhook', placeholder: '{\n "url": "https://example.com/alert"\n}' },
];
@@ -5,7 +5,6 @@ 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;
@@ -71,7 +70,6 @@ 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 }[]>([]);
@@ -84,7 +82,7 @@ export function GrafanaPanelContent({ panel, datasourceUrl }: { panel: Panel; da
try {
const url = await getDefaultPromURL(datasourceUrl);
if (!url) {
setError(t('monitor.dashboard.prometheus.emptyTitle'));
setError('未配置 Prometheus 数据源');
setLoading(false);
return;
}
@@ -102,16 +100,16 @@ export function GrafanaPanelContent({ panel, datasourceUrl }: { panel: Panel; da
});
const parsed = parseRangeResult(res);
setChartData(parsed.length > 0 ? parsed : []);
if (parsed.length === 0) setError(t('kubernetes.configmap.noData'));
if (parsed.length === 0) setError('无数据');
} else {
// stat / gauge / table 用即时查询
const res = await prometheusProxyApi.query(url, panel.query);
const parsed = parseInstantResult(res);
setInstantData(parsed);
if (parsed.length === 0) setError(t('kubernetes.configmap.noData'));
if (parsed.length === 0) setError('无数据');
}
} catch (e: any) {
setError(e?.message ? String(e.message).slice(0, 80) : t('monitor.grafanapanelcontent.s0d66ed'));
setError(e?.message ? String(e.message).slice(0, 80) : '查询失败');
} finally {
setLoading(false);
}
@@ -155,7 +153,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>{t('common.realtime')}</span>
<span>实时</span>
</div>
</div>
);
@@ -189,7 +187,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)]">{t('kubernetes.configmap.noData')}</div>;
if (instantData.length === 0) return <div className="text-center py-4 text-xs text-[var(--dg-text-muted)]">无数据</div>;
return (
<div className="overflow-auto max-h-[200px]">
<table className="w-full text-[11px]">
@@ -218,7 +216,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)]">{t('kubernetes.configmap.noData')}</div>;
return <div className="text-center py-4 text-xs text-[var(--dg-text-muted)]">无数据</div>;
}
return (
<div className="h-full min-h-[120px]">
@@ -2,6 +2,7 @@
// 参照: Grafana node-dashboard.json (表格+时序) / kubernetes-dashboard.json (bargauge+阈值)
import { useState, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
Server,
@@ -30,7 +31,6 @@ 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';
@@ -49,7 +49,7 @@ function getUsageBar(val: number) {
}
export default function HostMonitorDashboard() {
const { t } = useTranslation();
const { t } = useTranslation(['monitor', 'common']);
const navigate = useNavigate();
const [hosts, setHosts] = useState<JsonData[]>([]);
const [total, setTotal] = useState(0);
@@ -143,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]">{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>;
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>;
};
@@ -158,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)]">{t('monitor.hostmonitordashboard.sd94cd2')}</div>
<div className="text-[10px] text-[var(--dg-text-muted)]">{t('monitor:hostDashboard.hostTotal')}</div>
</div>
</CardContent>
</Card>
@@ -167,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)]">{t('monitor.hostmonitordashboard.s42860c')}</div>
<div className="text-[10px] text-[var(--dg-text-muted)]">{t('monitor:hostDashboard.agentOnline')}</div>
</div>
</CardContent>
</Card>
@@ -176,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)]">{t('common.offline')}</div>
<div className="text-[10px] text-[var(--dg-text-muted)]">{t('common:offline')}</div>
</div>
</CardContent>
</Card>
@@ -192,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)]">{t('monitor.hostmonitordashboard.s16dd85')}</div>
<div className="text-[10px] text-[var(--dg-text-muted)]">{t('monitor:hostDashboard.avgCpu')}</div>
</div>
</CardContent>
</Card>
@@ -201,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)]">{t('monitor.hostmonitordashboard.sa09099')}</div>
<div className="text-[10px] text-[var(--dg-text-muted)]">{t('monitor:hostDashboard.avgMemory')}</div>
</div>
</CardContent>
</Card>
@@ -217,8 +217,8 @@ export default function HostMonitorDashboard() {
<CardHeader className="pb-2">
<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">{t('monitor.hostmonitordashboard.sec309b')}</Badge>
{t('monitor:hostDashboard.cpuTop5')}
<Badge variant="outline" className="text-[10px] bg-red-500/10 dg-text-red border-red-500/30">{t('monitor:hostDashboard.threshold')}</Badge>
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
@@ -258,8 +258,8 @@ export default function HostMonitorDashboard() {
<CardHeader className="pb-2">
<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">{t('monitor.hostmonitordashboard.sec309b')}</Badge>
{t('monitor:hostDashboard.memTop5')}
<Badge variant="outline" className="text-[10px] bg-amber-500/10 dg-text-yellow border-amber-500/30">{t('monitor:hostDashboard.threshold')}</Badge>
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
@@ -302,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)]">{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>
<CardTitle className="text-base text-[var(--dg-text-primary)]">{t('monitor:hostDashboard.listTitle')}</CardTitle>
<Badge variant="outline" className="text-xs bg-emerald-500/10 dg-text-green border-emerald-500/30">{t('monitor:hostDashboard.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=t('monitor.hostmonitordashboard.s3d0fc0')
placeholder={t('monitor:hostDashboard.searchPlaceholder')}
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
@@ -318,7 +318,7 @@ export default function HostMonitorDashboard() {
</div>
<Button variant="outline" size="sm" onClick={() => loadData(true)} disabled={refreshing}>
<RefreshCw className={`h-4 w-4 mr-1 ${refreshing ? 'animate-spin' : ''}`} />
刷新
{t('common:refresh')}
</Button>
</div>
</div>
@@ -327,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">{t('common.loading')}</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">{t('monitor.hostmonitordashboard.s2678f4')}</p>
<p className="text-xs mt-1">{t('monitor.hostmonitordashboard.s350255')}</p>
<p className="text-sm">{t('monitor:hostDashboard.noData')}</p>
<p className="text-xs mt-1">{t('monitor:hostDashboard.noDataDesc')}</p>
</div>
) : (
<>
@@ -342,14 +342,14 @@ export default function HostMonitorDashboard() {
<thead>
<tr className="border-b border-[var(--dg-border)]">
{[
{ 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: 'hostname', label: t('monitor:hostDashboard.colHostId'), w: '' },
{ key: 'agent_version', label: t('monitor:hostDashboard.colAgentVersion'), w: 'w-[100px]' },
{ key: 'agent_status', label: t('monitor:hostDashboard.colAgentStatus'), w: 'w-[90px]' },
{ key: 'cpu_usage', label: 'CPU', w: 'w-[130px]' },
{ 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]' },
{ key: 'memory_usage', label: t('monitor:hostMetric.memory'), w: 'w-[130px]' },
{ key: 'disk_usage', label: t('monitor:hostMetric.disk'), w: 'w-[130px]' },
{ key: 'region', label: t('monitor:hostDashboard.colRegion'), w: 'w-[120px]' },
{ key: 'ip', label: t('monitor:hostDashboard.colIp'), w: 'w-[140px]' },
].map(col => (
<th
key={col.key}
@@ -362,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)]">{t('common.actions')}</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>
@@ -425,20 +425,20 @@ 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=t('monitor.hostmonitordashboard.s2ee528')
title={t('monitor:hostDashboard.viewMonitorChart')}
>
<Monitor className="h-3 w-3 mr-1" />
监控
{t('monitor:hostDashboard.monitor')}
</Button>
<Button
variant="ghost"
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=t('monitor.hostmonitordashboard.s356aa8')
title={t('monitor:hostDashboard.viewAlertRules')}
>
<Bell className="h-3 w-3 mr-1" />
告警
{t('monitor:hostDashboard.alert')}
</Button>
</div>
</td>
@@ -451,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>{t('common.perPage')}</span>
<span>{t('monitor:hostDashboard.perPage')}</span>
<Select value={String(pageSize)} onValueChange={(v) => { setPageSize(Number(v)); setPage(1); }}>
<SelectTrigger className="h-7 w-[60px] text-xs">
<SelectValue />
@@ -462,7 +462,7 @@ export default function HostMonitorDashboard() {
<SelectItem value="30">30</SelectItem>
</SelectContent>
</Select>
<span>条,共 {total} 条</span>
<span>{t('monitor:hostDashboard.totalCount', { count: total })}</span>
</div>
<div className="flex items-center gap-1">
<Button
@@ -8,12 +8,10 @@ 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]">
@@ -48,7 +46,6 @@ 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">
@@ -61,7 +58,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" />{i18n.t('monitor.prometheusdashboard.se7fb2b')}</div>
<div className="text-xs font-medium mb-2 flex items-center gap-1"><Cpu className="w-3 h-3 dg-text-purple" />节点 CPU / 内存利用率</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>;
@@ -73,7 +70,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">{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>
<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>
<tbody>
{(d.nodes || []).map((n: JsonData, i: number) => {
const inst = n.instance || n.exported_instance || '';
@@ -107,12 +104,11 @@ 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">{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 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>
);
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 (
@@ -127,7 +123,6 @@ 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);
@@ -148,7 +143,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||t('aiops.llmTestFailed')); }
} catch (e) { setConnected(false); setError((e as JsonData).message||'连接失败'); }
finally { setLoading(false); }
};
@@ -174,13 +169,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">{t('monitor.prometheusdashboard.s006a5f')}</CardTitle>
<CardTitle className="text-sm font-medium">Prometheus 监控</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" />{t('common.refresh')}</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" />刷新</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)]">{t('monitor.prometheusdashboard.s5e8eb2')}</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)]">连接中…</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,7 +18,6 @@ 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;
@@ -32,7 +31,6 @@ 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[]>([]);
@@ -52,7 +50,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 || t('aiops.llmTestFailed')); }
} catch (e) { setConnected(false); setError((e as JsonData).message || '连接失败'); }
finally { setLoading(false); }
};
@@ -96,24 +94,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">{t('monitor.zabbixdashboard.s6f2695')}</CardTitle>
<CardTitle className="text-sm font-medium">Zabbix 主机监控</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" />{t('common.refresh')}</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" />刷新</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)]">{t('monitor.zabbixdashboard.s5e8eb2')}</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)]">连接中…</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">{t('monitor.zabbixdashboard.sdb131a')}</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">未找到被监控的 Zabbix 主机</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: 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') },
{ 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: '运行' },
].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,10 +23,8 @@ 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();
@@ -35,7 +33,7 @@ export default function PrometheusDatasourceManager() {
const [editingDs, setEditingDs] = useState<PromDS | null>(null);
useEffect(() => {
if (isError) toast.error(t('monitor.prometheusdatasourcemanager.s45f817'));
if (isError) toast.error('获取数据源列表失败');
}, [isError]);
const openCreate = () => {
@@ -50,25 +48,25 @@ export default function PrometheusDatasourceManager() {
const handleDelete = async (ds: PromDS) => {
const ok = await _confirm({
title: t('monitor.deleteDatasource'),
description: t('monitor.prometheusdatasourcemanager.s623d90', { ds.name: ds.name }),
title: '删除数据源',
description: `确定删除数据源「${ds.name}」吗?此操作不可撤销。`,
variant: 'destructive',
});
if (!ok) return;
try {
await deleteMutation.mutateAsync(ds.id);
toast.success(t('alert.deleted'));
toast.success('已删除');
} catch {
toast.error(t('common.deleteFailed'));
toast.error('删除失败');
}
};
const handleSetDefault = async (ds: PromDS) => {
try {
await setDefaultMutation.mutateAsync(ds.id);
toast.success(t('monitor.prometheusdatasourcemanager.sa8bcc7', { ds.name: ds.name }));
toast.success(`已将「${ds.name}」设为默认数据源`);
} catch {
toast.error(t('monitor.prometheusdatasourcemanager.s9f9603'));
toast.error('设置失败');
}
};
@@ -96,8 +94,8 @@ export default function PrometheusDatasourceManager() {
{sources.length === 0 ? (
<PageEmpty
icon={<Database className="h-12 w-12" />}
title=t('monitor.prometheusdatasourcemanager.s99ed81')
description=t('monitor.prometheusdatasourcemanager.sd9a356')
title="暂无 Prometheus 数据源"
description="添加数据源后可用于指标监控与监控大屏"
action={(
<Button onClick={openCreate} size="sm" className="dg-btn-primary">
<Plus className="w-4 h-4 mr-1" />添加数据源
@@ -109,11 +107,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">{t('common.name')}</th>
<th className="text-left py-3 px-4 font-medium">名称</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">{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>
<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>
</tr>
</thead>
<tbody>
@@ -144,16 +142,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" />{t('approval.status.default')}</span>
<span className="dg-badge-yellow"><Star className="w-3 h-3 mr-1 inline" />默认</span>
) : (
<span className="text-xs dg-text-muted">--</span>
)}
</td>
<td className="py-3 px-4">
{ds.status === 1 ? (
<span className="dg-status-running">{t('common.enable')}</span>
<span className="dg-status-running">启用</span>
) : (
<span className="dg-badge-muted">{t('common.disable')}</span>
<span className="dg-badge-muted">禁用</span>
)}
</td>
<td className="py-3 px-4">
@@ -162,7 +160,7 @@ export default function PrometheusDatasourceManager() {
variant="ghost" size="sm"
className="h-8 px-2"
onClick={() => handleTestQuick(ds)}
title=t('aiops.llmTest')
title="测试连接"
>
<Plug className="w-3.5 h-3.5" />
</Button>
@@ -171,7 +169,7 @@ export default function PrometheusDatasourceManager() {
variant="ghost" size="sm"
className="h-8 px-2 dg-text-yellow"
onClick={() => handleSetDefault(ds)}
title=t('aiops.llmSetDefault')
title="设为默认"
>
<Star className="w-3.5 h-3.5" />
</Button>
@@ -32,7 +32,6 @@ 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;
@@ -60,7 +59,6 @@ const defaultForm = {
};
export default function ZabbixDatasourceManager() {
const { t } = useTranslation();
const _confirm = useConfirm();
const [sources, setSources] = useState<ZabbixDS[]>([]);
const [loading, setLoading] = useState(true);
@@ -80,7 +78,7 @@ export default function ZabbixDatasourceManager() {
const res: JsonData = await zabbixDatasourceApi.list();
setSources(res?.data || res || []);
} catch {
toast.error(t('monitor.zabbixdatasourcemanager.s45f817'));
toast.error('获取数据源列表失败');
} finally {
setLoading(false);
}
@@ -117,51 +115,51 @@ export default function ZabbixDatasourceManager() {
};
const handleSave = async () => {
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; }
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; }
try {
if (editingId) {
await zabbixDatasourceApi.update(editingId, form);
toast.success(t('monitor.zabbixdatasourcemanager.s8dfdd2'));
toast.success('数据源已更新');
} else {
await zabbixDatasourceApi.create(form);
toast.success(t('monitor.zabbixdatasourcemanager.sa6def0'));
toast.success('数据源已创建');
}
setDialogOpen(false);
fetchList();
} catch (err) {
toast.error(err?.message || t('common.operationFailed'));
toast.error(err?.message || '操作失败');
}
};
const handleDelete = async (ds: ZabbixDS) => {
const ok = await _confirm({
title: t('monitor.deleteDatasource'),
description: t('monitor.zabbixdatasourcemanager.sa2b79a', { ds.name: ds.name }),
title: '删除数据源',
description: `确定删除「${ds.name}」?`,
variant: 'destructive',
});
if (!ok) return;
try {
await zabbixDatasourceApi.delete(ds.id);
toast.success(t('alert.deleted'));
toast.success('已删除');
fetchList();
} catch { toast.error(t('common.deleteFailed')); }
} catch { toast.error('删除失败'); }
};
const handleSetDefault = async (ds: ZabbixDS) => {
try {
await zabbixDatasourceApi.setDefault(ds.id);
toast.success(t('monitor.zabbixdatasourcemanager.sf0f959', { ds.name: ds.name }));
toast.success(`已将「${ds.name}」设为默认`);
fetchList();
} catch { toast.error(t('monitor.zabbixdatasourcemanager.s9f9603')); }
} catch { toast.error('设置失败'); }
};
const handleTest = async () => {
if (!form.url.trim() || !form.username.trim()) {
toast.error(t('monitor.zabbixdatasourcemanager.s1d3562'));
toast.error('请填写 URL 和用户名');
return;
}
setTesting(true);
@@ -175,7 +173,7 @@ export default function ZabbixDatasourceManager() {
const data = res?.data || res;
setTestResult(data);
} catch (err) {
setTestResult({ success: false, message: err?.message || t('monitor.zabbixdatasourcemanager.s9710d9') });
setTestResult({ success: false, message: err?.message || '测试失败' });
} finally {
setTesting(false);
}
@@ -201,7 +199,7 @@ export default function ZabbixDatasourceManager() {
/>
Zabbix 数据源
</h2>
<p className="text-xs text-[var(--dg-text-muted)]">{t('monitor.zabbixdatasourcemanager.s79e929')}</p>
<p className="text-xs text-[var(--dg-text-muted)]">配置 Zabbix API 连接,获取主机监控数据</p>
</div>
<Button onClick={openCreate} size="sm">
<Plus className="w-4 h-4 mr-1" />添加
@@ -212,7 +210,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">{t('monitor.zabbixdatasourcemanager.sc9c9bd')}</p>
<p className="text-sm">暂无 Zabbix 数据源</p>
<Button size="sm" className="mt-4" onClick={openCreate}>
<Plus className="w-4 h-4 mr-1" />添加数据源
</Button>
@@ -228,15 +226,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" />{t('approval.status.default')}</Badge>}
{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.status === 1
? <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>}
? <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>}
</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]">{t('monitor.zabbixdatasourcemanager.s85260a')}</Badge>}
{ds.direct_db && <Badge variant="outline" className="text-[10px]">直连DB</Badge>}
</div>
</div>
<div className="flex items-center gap-1">
@@ -265,7 +263,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 ? t('monitor.zabbixdatasourcemanager.sc1c87e') : t('monitor.zabbixdatasourcemanager.sa77d38')}
{editingId ? '编辑 Zabbix 数据源' : '新建 Zabbix 数据源'}
</DialogTitle>
<DialogDescription>
配置 Zabbix API 连接,用于主机监控与告警数据获取
@@ -276,14 +274,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)]">{t('deploy.form.nameStar')}</Label>
<Input className="mt-1 h-9 text-sm bg-[var(--dg-bg-primary)]" placeholder=t('monitor.zabbixdatasourcemanager.s9f1e6a')
<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"
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">{t('aiops.llmSetDefault')}</Label>
<Label className="text-xs cursor-pointer">设为默认</Label>
</div>
</div>
</div>
@@ -307,15 +305,15 @@ export default function ZabbixDatasourceManager() {
onClick={() => setShowAuth(!showAuth)}>
<div className="flex items-center gap-2">
<Shield className="w-4 h-4" />
<span>{t('monitor.zabbixdatasourcemanager.sb47858')}</span>
<span>Zabbix API 认证</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=t('monitor.zabbixdatasourcemanager.s04c3dd')
<Input className="h-9 text-sm bg-[var(--dg-bg-primary)]" placeholder="用户名 *"
value={form.username} onChange={e => updateForm('username', e.target.value)} />
<Input className="h-9 text-sm bg-[var(--dg-bg-primary)]" type="password" placeholder=t('monitor.zabbixdatasourcemanager.s3140c0')
<Input className="h-9 text-sm bg-[var(--dg-bg-primary)]" type="password" placeholder="密码 *"
value={form.password} onChange={e => updateForm('password', e.target.value)} />
</div>
)}
@@ -327,7 +325,7 @@ export default function ZabbixDatasourceManager() {
onClick={() => setShowTrends(!showTrends)}>
<div className="flex items-center gap-2">
<Clock className="w-4 h-4" />
<span>{t('monitor.zabbixdatasourcemanager.s05ef18')}</span>
<span>Trends 设置</span>
</div>
{showTrends ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
</button>
@@ -335,24 +333,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)]">{t('monitor.zabbixdatasourcemanager.sebfa4e')}</Label>
<Label className="text-xs text-[var(--dg-text-secondary)]">After (天)</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)]">{t('monitor.zabbixdatasourcemanager.s7fe44f')}</Label>
<Label className="text-xs text-[var(--dg-text-secondary)]">Range (秒)</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)]">{t('monitor.zabbixdatasourcemanager.sbf9a86')}</Label>
<Label className="text-xs text-[var(--dg-text-secondary)]">Cache TTL (秒)</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)]">{t('monitor.zabbixdatasourcemanager.s21f982')}</Label>
<Label className="text-xs text-[var(--dg-text-secondary)]">Timeout (秒)</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>
@@ -367,7 +365,7 @@ export default function ZabbixDatasourceManager() {
onClick={() => setShowOther(!showOther)}>
<div className="flex items-center gap-2">
<Settings className="w-4 h-4" />
<span>{t('monitor.zabbixdatasourcemanager.sdda36e')}</span>
<span>其他设置</span>
</div>
{showOther ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
</button>
@@ -378,11 +376,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">{t('monitor.zabbixdatasourcemanager.s413ec7')}</Label>
<Label className="text-xs">只读用户禁用确认</Label>
<Switch checked={form.disable_acknowledges} onCheckedChange={v => updateForm('disable_acknowledges', v)} />
</div>
<div className="flex items-center justify-between">
<Label className="text-xs">{t('monitor.zabbixdatasourcemanager.s6d4d6f')}</Label>
<Label className="text-xs">禁用数据对齐</Label>
<Switch checked={form.disable_data_alignment} onCheckedChange={v => updateForm('disable_data_alignment', v)} />
</div>
</div>
@@ -406,8 +404,8 @@ export default function ZabbixDatasourceManager() {
测试连接
</Button>
<div className="flex-1" />
<Button variant="ghost" size="sm" onClick={() => setDialogOpen(false)}>{t('common.cancel')}</Button>
<Button size="sm" onClick={handleSave}>{t('common.save')}</Button>
<Button variant="ghost" size="sm" onClick={() => setDialogOpen(false)}>取消</Button>
<Button size="sm" onClick={handleSave}>保存</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -1,6 +1,7 @@
// Prometheus 数据源添加/编辑弹窗 // Logan.Li
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import {
Database,
Plus,
@@ -36,7 +37,6 @@ 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;
@@ -51,8 +51,8 @@ export default function PromDsFormDialog({
editingDs,
sources,
}: PromDsFormDialogProps) {
const { t } = useTranslation();
const { createMutation, updateMutation } = usePromDatasourceMutations();
const { t } = useTranslation(['monitor', 'common']);
const editingId = editingDs?.id ?? null;
const [form, setForm] = useState<PromDsForm>({ ...defaultForm });
@@ -102,28 +102,28 @@ export default function PromDsFormDialog({
};
const handleSave = async () => {
if (!form.name.trim()) { toast.error(t('monitor.promdsformdialog.s138957')); return; }
if (!form.url.trim()) { toast.error(t('monitor.promdsformdialog.sdc869f')); return; }
if (!form.name.trim()) { toast.error(t('monitor:promDs.nameRequired')); return; }
if (!form.url.trim()) { toast.error(t('monitor:promDs.urlRequired')); return; }
const payload = buildPayload(form, headers);
try {
if (editingId) {
await updateMutation.mutateAsync({ id: editingId, data: payload });
toast.success(t('monitor.promdsformdialog.sc0c477'));
toast.success(t('monitor:promDs.updateSuccess'));
} else {
await createMutation.mutateAsync(payload);
toast.success(t('monitor.promdsformdialog.s8b45c1'));
toast.success(t('monitor:promDs.createSuccess'));
}
onOpenChange(false);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : t('common.operationFailed');
const message = err instanceof Error ? err.message : t('monitor:promDs.opFailed');
toast.error(message);
}
};
const handleTestConnection = async () => {
if (!form.url.trim()) { toast.error(t('monitor.promdsformdialog.sc289ae')); return; }
if (!form.url.trim()) { toast.error(t('monitor:promDs.urlInputRequired')); return; }
setTesting(true);
setTestResult(null);
try {
@@ -136,7 +136,7 @@ export default function PromDsFormDialog({
});
setTestResult(data);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : t('monitor.promdsformdialog.s9710d9');
const message = err instanceof Error ? err.message : t('monitor:promDs.testFailed');
setTestResult({ success: false, message });
} finally {
setTesting(false);
@@ -151,10 +151,10 @@ export default function PromDsFormDialog({
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-lg">
<Database className="w-5 h-5 dg-text-orange" />
{editingId ? t('monitor.editDatasource') : t('monitor.addDatasource')}
{editingId ? t('monitor:editDatasource') : t('monitor:addDatasource')}
</DialogTitle>
<DialogDescription>
Prometheus 数据源配置 · 参考 Grafana 数据源管理
{t('monitor:promDs.configDesc')}
</DialogDescription>
</DialogHeader>
@@ -165,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=t('monitor.promdsformdialog.s9a9542')
placeholder={t('monitor:promDs.namePlaceholder')}
value={form.name}
onChange={e => updateForm('name', e.target.value)}
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]"
@@ -240,14 +240,14 @@ export default function PromDsFormDialog({
{form.basic_auth && (
<div className="grid grid-cols-2 gap-3 ml-9">
<Input
placeholder=t('login.username')
placeholder={t('monitor:promDs.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=t('login.password')
placeholder={t('monitor:promDs.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"
@@ -286,7 +286,7 @@ export default function PromDsFormDialog({
</div>
{form.with_ca_cert && (
<textarea
placeholder=t('monitor.promdsformdialog.sf66241')
placeholder={t('monitor:promDs.caCertPlaceholder')}
value={form.ca_cert}
onChange={e => updateForm('ca_cert', e.target.value)}
rows={4}
@@ -324,7 +324,7 @@ export default function PromDsFormDialog({
</Button>
</div>
{headers.length === 0 ? (
<p className="text-xs text-[var(--dg-text-secondary)] italic">{t('monitor.promdsformdialog.s3c75b9')}</p>
<p className="text-xs text-[var(--dg-text-secondary)] italic">{t('monitor:promDs.noHeaders')}</p>
) : (
<div className="space-y-2">
{headers.map((h, idx) => (
@@ -384,7 +384,7 @@ export default function PromDsFormDialog({
<SelectValue placeholder="Choose" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none">{t('common.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>
))}
@@ -409,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)]">{t('common.second')}</span>
<span className="text-xs text-[var(--dg-text-secondary)]">{t('monitor:promDs.second')}</span>
</div>
</div>
<div className="space-y-2">
@@ -424,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)]">{t('common.second')}</span>
<span className="text-xs text-[var(--dg-text-secondary)]">{t('monitor:promDs.second')}</span>
</div>
</div>
</div>
@@ -470,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=t('monitor.promdsformdialog.s5617a6')
placeholder={t('monitor:promDs.queryParamsPlaceholder')}
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"
@@ -513,14 +513,14 @@ export default function PromDsFormDialog({
) : (
<Plug className="w-4 h-4" />
)}
Save & test
{t('monitor:promDs.saveTest')}
</Button>
<div className="flex-1" />
<Button variant="ghost" onClick={() => onOpenChange(false)}>
取消
{t('common:cancel')}
</Button>
<Button onClick={handleSave} disabled={saving}>
{editingId ? t('common.update') : t('common.save')}
{editingId ? t('common:update') : t('common:save')}
</Button>
</DialogFooter>
</DialogContent>
@@ -3,7 +3,6 @@
import { toast } from 'sonner';
import { promDatasourceApi } from '@/api';
import type { PromDS, HeaderEntry, PromDsForm } from './types';
import i18n from '@/i18n';
export type TestConnectionParams = {
url: string;
@@ -105,12 +104,12 @@ export async function handleTestQuick(ds: PromDS): Promise<void> {
skip_tls_verify: ds.skip_tls_verify,
});
if (data?.success) {
toast.success(i18n.t('monitor.helpers.s50047c', { data.target_count || 0: data.target_count || 0 }));
toast.success(`连接成功 · ${data.target_count || 0} targets`);
} else {
toast.error(data?.message || i18n.t('aiops.llmTestFailed'));
toast.error(data?.message || '连接失败');
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : i18n.t('monitor.helpers.s9710d9');
const message = err instanceof Error ? err.message : '测试失败';
toast.error(message);
}
}
@@ -1,11 +1,11 @@
// EnhancedLogResults.tsx — Grafana 风格日志结果展示 // Logan.Li
import { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { ChevronDown, ChevronRight, BarChart3, Plus, Search, Copy, Check } from 'lucide-react';
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 {
@@ -99,8 +99,8 @@ 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 { t } = useTranslation(['monitor', 'common']);
const [expanded, setExpanded] = useState(false);
const [copied, setCopied] = useState(false);
const labels = entry.labels || {};
@@ -114,10 +114,10 @@ function RawLogView({
const ok = await copyToClipboard(text);
if (ok) {
setCopied(true);
toast({ title: t('monitor.enhancedlogresults.sb2d494') });
toast({ title: t('monitor:logPanel.logResults.copiedFullLog') });
setTimeout(() => setCopied(false), 1500);
} else {
toast({ title: t('common.copyFailed'), description: t('monitor.enhancedlogresults.s2d8db2'), variant: 'destructive' });
toast({ title: t('monitor:logPanel.copyFailed'), description: t('monitor:logPanel.logResults.copyFailedExpandDesc'), variant: 'destructive' });
}
};
@@ -162,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=t('monitor.enhancedlogresults.s0ac3b0')
title={t('monitor:logPanel.logResults.copyFullLog')}
>
{copied ? <Check className="h-3 w-3 dg-text-green" /> : <Copy className="h-3 w-3" />}
</button>
@@ -174,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)' }}>{t('monitor.enhancedlogresults.s285574')}</p>
<p className="text-[10px] font-medium" style={{ color: 'var(--dg-text-muted)' }}>{t('monitor:logPanel.logResults.fullLog')}</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 ? t('common.copied') : t('monitor.enhancedlogresults.s290b62')}
{copied ? t('monitor:logPanel.logResults.copied') : t('monitor:logPanel.logResults.copyAll')}
</button>
</div>
<pre
@@ -253,7 +253,6 @@ 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)); });
@@ -296,7 +295,6 @@ 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' }}>
@@ -336,7 +334,7 @@ export function EnhancedLogResults({
commonLabels,
onLabelFilter,
}: Props) {
const { t } = useTranslation();
const { t } = useTranslation(['monitor', 'common']);
// 去重处理
const dedupedEntries = useMemo(() => dedupEntries(entries, dedupMode), [entries, dedupMode]);
@@ -361,7 +359,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)' }}>{t('pipeline.noLog')}</span>
<span className="text-sm" style={{ color: 'var(--dg-text-muted)' }}>{t('monitor:logPanel.logResults.noLogs')}</span>
</div>
);
}
@@ -1,7 +1,6 @@
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;
@@ -11,7 +10,6 @@ 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[]>([]);
@@ -42,9 +40,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=t('monitor.esindexpicker.sa0eff8')
title="选择或搜索 ES 索引"
>
<span className="truncate flex-1 text-left">{value || t('monitor.selectIndex')}</span>
<span className="truncate flex-1 text-left">{value || '选择索引'}</span>
{loading ? <Loader2 className="h-3 w-3 animate-spin shrink-0" /> : <ChevronDown className="h-3 w-3 shrink-0" />}
</button>
{open && (
@@ -60,7 +58,7 @@ export function EsIndexPicker({ datasourceId, value, onChange }: Props) {
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder=t('monitor.esindexpicker.s9ae0c0')
placeholder="搜索索引..."
className="flex-1 bg-transparent text-xs outline-none font-mono"
style={{ color: 'var(--dg-text-primary)' }}
/>
@@ -90,7 +88,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)' }}>{t('monitor.esindexpicker.saf5cc1')}</p>
<p className="text-xs px-2 py-3 text-center" style={{ color: 'var(--dg-text-muted)' }}>无匹配索引</p>
)}
</div>
</div>
@@ -4,7 +4,6 @@ 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;
@@ -29,7 +28,6 @@ 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('');
@@ -42,8 +40,8 @@ export function LogAlertFromQueryDialog({ open, onOpenChange, ctx, onSaved }: Pr
useEffect(() => {
if (!open || !ctx) return;
const meta = ENGINE_META[ctx.engine];
setName(t('monitor.logalertfromquerydialog.s844311', { meta.name: meta.name }));
setDescription(t('monitor.logalertfromquerydialog.s462d17', { ctx.datasourceName || '—': ctx.datasourceName || '—', ctx.timeRange || '—': ctx.timeRange || '—' }) · 索引: ${ctx.index}` : ''}`);
setName(`${meta.name} 日志告警`);
setDescription(`数据源: ${ctx.datasourceName || '—'} · 范围: ${ctx.timeRange || '—'}${ctx.index ? ` · 索引: ${ctx.index}` : ''}`);
if (ctx.engine === 'loki') {
setExpression(`count_over_time(${ctx.query}[5m]) > 0`);
} else if (ctx.engine === 'sls') {
@@ -75,11 +73,11 @@ export function LogAlertFromQueryDialog({ open, onOpenChange, ctx, onSaved }: Pr
notify_channels: [],
});
setSaved(true);
toast({ title: t('monitor.logalertfromquerydialog.sd3dbea') });
toast({ title: '告警规则已创建' });
onSaved?.();
setTimeout(() => onOpenChange(false), 800);
} catch (e: any) {
toast({ title: t('monitor.quickSetupFailed'), description: e?.message || String(e), variant: 'destructive' });
toast({ title: '创建失败', description: e?.message || String(e), variant: 'destructive' });
} finally {
setSaving(false);
}
@@ -96,7 +94,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)' }}>{t('monitor.logalertfromquerydialog.s5f4707')}</span>
<span className="text-sm font-medium" style={{ color: 'var(--dg-text-primary)' }}>创建告警规则</span>
</div>
<button
onClick={() => onOpenChange(false)}
@@ -114,12 +112,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 || t('monitor.logalertfromquerydialog.sddb4ca')}</span>
<span className="text-[11px] truncate" style={{ color: 'var(--dg-text-secondary)' }}>{ctx.datasourceName || '默认数据源'}</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)' }}>{t('monitor.logalertfromquerydialog.s34fe90')}</label>
<label className="text-[10px] block mb-1" style={{ color: 'var(--dg-text-muted)' }}>规则名称 *</label>
<input
value={name}
onChange={e => setName(e.target.value)}
@@ -129,7 +127,7 @@ export function LogAlertFromQueryDialog({ open, onOpenChange, ctx, onSaved }: Pr
</div>
<div>
<label className="text-[10px] block mb-1" style={{ color: 'var(--dg-text-muted)' }}>{t('common.description')}</label>
<label className="text-[10px] block mb-1" style={{ color: 'var(--dg-text-muted)' }}>描述</label>
<textarea
value={description}
onChange={e => setDescription(e.target.value)}
@@ -140,7 +138,7 @@ export function LogAlertFromQueryDialog({ open, onOpenChange, ctx, onSaved }: Pr
</div>
<div>
<label className="text-[10px] block mb-1" style={{ color: 'var(--dg-text-muted)' }}>{t('monitor.logalertfromquerydialog.sa6c48a')}</label>
<label className="text-[10px] block mb-1" style={{ color: 'var(--dg-text-muted)' }}>触发条件 *</label>
<textarea
value={expression}
onChange={e => setExpression(e.target.value)}
@@ -149,26 +147,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' ? t('monitor.logalertfromquerydialog.s0d669e') : ctx.engine === 'sls' ? t('monitor.logalertfromquerydialog.sf769e3') : t('monitor.logalertfromquerydialog.sa079c7')}
{ctx.engine === 'loki' ? 'LogQL 告警表达式' : ctx.engine === 'sls' ? 'SLS SQL/SPL 查询' : 'ES Lucene 查询'}
</p>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-[10px] block mb-1" style={{ color: 'var(--dg-text-muted)' }}>{t('common.level')}</label>
<label className="text-[10px] block mb-1" style={{ color: 'var(--dg-text-muted)' }}>级别</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">{t('common.info')}</option>
<option value="warning">{t('common.warning')}</option>
<option value="critical">{t('monitor.critical')}</option>
<option value="info">提示</option>
<option value="warning">警告</option>
<option value="critical">严重</option>
</select>
</div>
<div>
<label className="text-[10px] block mb-1" style={{ color: 'var(--dg-text-muted)' }}>{t('monitor.logalertfromquerydialog.s5ec177')}</label>
<label className="text-[10px] block mb-1" style={{ color: 'var(--dg-text-muted)' }}>持续(秒)</label>
<input
type="number"
value={duration}
@@ -201,7 +199,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 ? i18n.t('deploy.toast.created') : i18n.t('monitor.logalertfromquerydialog.s8e1e3d')}
{saved ? '已创建' : '创建规则'}
</button>
</div>
</div>
@@ -9,10 +9,8 @@ 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[]>([]);
@@ -39,14 +37,14 @@ export function LogAlertsPanel() {
useEffect(() => { load(); }, [load]);
const handleDelete = async (rule: any) => {
const ok = await confirm({ title: t('alert.deleteRule'), description: t('monitor.logalertspanel.sf6b982', { rule.name: rule.name }), variant: 'destructive', confirmText: t('common.delete') });
const ok = await confirm({ title: '删除规则', description: `删除「${rule.name}」?`, variant: 'destructive', confirmText: '删除' });
if (!ok) return;
try {
await alertApi.deleteRule(rule.id);
toast({ title: t('alert.deleted') });
toast({ title: '已删除' });
load();
} catch (e: any) {
toast({ title: t('common.deleteFailed'), description: e?.message || String(e), variant: 'destructive' });
toast({ title: '删除失败', description: e?.message || String(e), variant: 'destructive' });
}
};
@@ -59,7 +57,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)]">{t('monitor.logalertspanel.s5bc514')}</p>
<p className="text-sm text-[var(--dg-text-muted)]">仅保存告警策略(LogQL / Lucene),日志数据仍在外部 Loki / ES</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' : ''}`} />刷新
@@ -89,7 +87,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)]">{t('alert.disabled')}</Badge>
<Badge variant="outline" className="text-[10px] text-[var(--dg-text-muted)]">已禁用</Badge>
)}
</div>
<p className="text-[11px] font-mono text-[var(--dg-text-muted)] mt-1 truncate" title={rule.expression}>{rule.expression}</p>
@@ -2,14 +2,12 @@
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 (
@@ -25,7 +23,6 @@ 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);
@@ -44,7 +41,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)' }}>{t('monitor.logclusterpanel.s381127')}</span>
<span className="text-[10px]" style={{ color: 'var(--dg-text-muted)' }}>基于日志相似度自动聚类</span>
</div>
{/* 聚类列表 */}
@@ -93,7 +90,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)' }}>{t('monitor.logclusterpanel.se909e4')}</p>
<p className="text-[10px] mb-1" style={{ color: 'var(--dg-text-muted)' }}>样例日志:</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,7 +14,6 @@ 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,
@@ -25,7 +24,6 @@ export function LogDatasourceDialog({
editing?: LogDataSource | null;
onSaved: () => void;
}) {
const { t } = useTranslation();
const { toast } = useToast();
const [name, setName] = useState('');
const [url, setUrl] = useState('');
@@ -129,12 +127,12 @@ export function LogDatasourceDialog({
: payload();
const result: TestConnectionResult = await logCenterApi.testDatasource(data);
if (result.status === 'ok') {
toast({ title: t('aiops.llmTestSuccess') });
toast({ title: '连接成功' });
} else {
toast({ title: t('aiops.llmTestFailed'), description: result.error || t('monitor.dashboard.agent.toast.unknownError'), variant: 'destructive' });
toast({ title: '连接失败', description: result.error || '未知错误', variant: 'destructive' });
}
} catch (e: any) {
toast({ title: t('aiops.llmTestFailed'), description: e?.message || String(e), variant: 'destructive' });
toast({ title: '连接失败', description: e?.message || String(e), variant: 'destructive' });
} finally {
setTesting(false);
}
@@ -149,11 +147,11 @@ export function LogDatasourceDialog({
} else {
await logCenterApi.createDatasource(payload());
}
toast({ title: t('deploy.toast.saved') });
toast({ title: '已保存' });
onOpenChange(false);
onSaved();
} catch (e: any) {
toast({ title: t('common.saveFailed'), description: e?.message || String(e), variant: 'destructive' });
toast({ title: '保存失败', description: e?.message || String(e), variant: 'destructive' });
} finally {
setSaving(false);
}
@@ -164,7 +162,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 ? t('common.edit') : t('common.add')}{engine === 'loki' ? ' Loki' : ' Elasticsearch'} 数据源
{editing ? '编辑' : '添加'}{engine === 'loki' ? ' Loki' : ' Elasticsearch'} 数据源
</DialogTitle>
<DialogDescription className="text-[var(--dg-text-muted)]">
仅保存连接信息,日志本体存储在您的 Loki / ES 集群
@@ -172,17 +170,17 @@ export function LogDatasourceDialog({
</DialogHeader>
<div className="space-y-3 py-1">
<div>
<Label className="text-xs text-[var(--dg-text-muted)]">{t('common.name')}</Label>
<Label className="text-xs text-[var(--dg-text-muted)]">名称</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)]">{t('common.address')}</Label>
<Label className="text-xs text-[var(--dg-text-muted)]">地址</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)]">{t('monitor.logdatasourcedialog.s6621b5')}</Label>
<Label className="text-xs text-[var(--dg-text-muted)]">默认索引</Label>
<Button
variant="ghost"
size="sm"
@@ -212,7 +210,7 @@ export function LogDatasourceDialog({
获取索引
</Button>
</div>
<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)]" />
<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)]" />
{/* 已获取索引快捷选择(支持通配) // Logan.Li */}
{indices.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1 max-h-24 overflow-auto">
@@ -247,23 +245,23 @@ export function LogDatasourceDialog({
</div>
)}
<div>
<Label className="text-xs text-[var(--dg-text-muted)]">{t('deploy.cred.authMethod')}</Label>
<Label className="text-xs text-[var(--dg-text-muted)]">认证方式</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">{t('monitor.logdatasourcedialog.s7870c3')}</SelectItem>
<SelectItem value="none" className="text-xs">无认证</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">{t('monitor.logdatasourcedialog.s2e7220')}</SelectItem>
<SelectItem value="custom" className="text-xs">自定义 Headers</SelectItem>
</SelectContent>
</Select>
</div>
{authType === 'basic' && (
<div className="grid grid-cols-2 gap-2">
<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)]" />
<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)]" />
</div>
)}
{authType === 'bearer' && (
@@ -279,23 +277,23 @@ export function LogDatasourceDialog({
const next = [...headers];
next[i].name = e.target.value;
setHeaders(next);
}} placeholder=t('monitor.logdatasourcedialog.sc7f8eb') className="h-9 text-xs bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]" />
}} placeholder="Header 名,如 X-Scope-OrgID" 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=t('monitor.logdatasourcedialog.scf7126') className="h-9 text-xs bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]" />
}} placeholder="Header 值" 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: '' }])}>{t('monitor.logdatasourcedialog.s86a7a6')}</Button>
<Button variant="outline" size="sm" className="h-7 text-xs border-[var(--dg-border)]" onClick={() => setHeaders([...headers, { name: '', value: '' }])}>添加 Header</Button>
</div>
)}
<div className="flex items-center justify-between">
<Label className="text-xs text-[var(--dg-text-muted)]">{t('monitor.logdatasourcedialog.sa441a8')}</Label>
<Label className="text-xs text-[var(--dg-text-muted)]">跳过 TLS 校验</Label>
<Switch checked={skipTLS} onCheckedChange={setSkipTLS} />
</div>
<div className="flex items-center justify-between">
<Label className="text-xs text-[var(--dg-text-muted)]">{t('aiops.llmSetDefault')}</Label>
<Label className="text-xs text-[var(--dg-text-muted)]">设为默认</Label>
<Switch checked={isDefault} onCheckedChange={setIsDefault} />
</div>
</div>
@@ -11,10 +11,8 @@ 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[]>([]);
@@ -48,18 +46,18 @@ export function LogDatasourcePage() {
const handleDelete = async (ds: LogDataSource) => {
const ok = await confirm({
title: t('monitor.deleteDatasource'),
description: t('monitor.logdatasourcepage.s834083', { ds.name: ds.name }),
title: '删除数据源',
description: `确认删除「${ds.name}」?`,
variant: 'destructive',
confirmText: t('common.delete'),
confirmText: '删除',
});
if (!ok) return;
try {
await logCenterApi.deleteDatasource(ds.id);
toast({ title: t('alert.deleted') });
toast({ title: '已删除' });
load();
} catch (e) {
toast({ title: t('common.deleteFailed'), description: e?.message || String(e), variant: 'destructive' });
toast({ title: '删除失败', description: e?.message || String(e), variant: 'destructive' });
}
};
@@ -68,22 +66,22 @@ export function LogDatasourcePage() {
// 已保存数据源通过 datasource_id 测试,避免密码丢失或前端暴露密码。// Logan.Li
const result: TestConnectionResult = await logCenterApi.testDatasource({ datasource_id: ds.id });
if (result.status === 'ok') {
toast({ title: t('aiops.llmTestSuccess'), description: t('monitor.logdatasourcepage.s6ca8fe', { ds.name: ds.name }) });
toast({ title: '连接成功', description: `${ds.name} 可达` });
} else {
toast({ title: t('aiops.llmTestFailed'), description: result.error || t('monitor.dashboard.agent.toast.unknownError'), variant: 'destructive' });
toast({ title: '连接失败', description: result.error || '未知错误', variant: 'destructive' });
}
} catch (e) {
toast({ title: t('aiops.llmTestFailed'), description: e?.message || String(e), variant: 'destructive' });
toast({ title: '连接失败', description: e?.message || String(e), variant: 'destructive' });
}
};
const handleSetDefault = async (ds: LogDataSource) => {
try {
await logCenterApi.setDefaultDatasource(ds.id);
toast({ title: t('approval.msg.setDefaultSuccess') });
toast({ title: '已设为默认' });
load();
} catch (e) {
toast({ title: t('common.operationFailed'), description: e?.message || String(e), variant: 'destructive' });
toast({ title: '操作失败', description: e?.message || String(e), variant: 'destructive' });
}
};
@@ -94,39 +92,39 @@ export function LogDatasourcePage() {
// DataTable 列定义
const columns: DataTableColumn<LogDataSource>[] = [
{ key: 'name', header: t('common.name'), sortable: true, render: (ds) => <span className="font-medium text-[var(--dg-text-primary)]">{ds.name}</span> },
{ key: 'name', header: '名称', sortable: true, render: (ds) => <span className="font-medium text-[var(--dg-text-primary)]">{ds.name}</span> },
{
key: 'type', header: t('common.type'), sortable: true, width: 120,
key: 'type', header: '类型', sortable: true, width: 120,
render: (ds) => <Badge className={engineBadge(ds.type)}>{engineLabel(ds.type)}</Badge>,
},
{
key: 'url', header: t('common.address'),
key: 'url', header: '地址',
render: (ds) => <span className="text-xs font-mono text-[var(--dg-text-muted)]">{ds.url}</span>,
},
{
key: 'is_default', header: t('approval.status.default'), width: 60, align: 'center', sortable: true,
key: 'is_default', header: '默认', 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: t('common.actions'), align: 'right', hideable: false,
key: 'actions', header: '操作', 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=t('aiops.llmTest') onClick={() => handleTest(ds)}>
title="测试连接" 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=t('aiops.llmSetDefault') onClick={() => handleSetDefault(ds)}>
title="设为默认" 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=t('common.edit') onClick={() => handleEdit(ds)}>
title="编辑" 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=t('common.delete') onClick={() => handleDelete(ds)}>
title="删除" onClick={() => handleDelete(ds)}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
@@ -148,10 +146,10 @@ export function LogDatasourcePage() {
rowKey={(ds) => ds.id}
loading={loading}
searchable
searchPlaceholder=t('monitor.logdatasourcepage.seb5e4d')
searchPlaceholder="搜索数据源名称或地址..."
searchKeys={['name', 'url']}
sortable
emptyText=t('monitor.logdatasourcepage.sc8a960')
emptyText="暂无数据源,点击上方按钮添加"
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,10 +4,8 @@ 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);
@@ -21,10 +19,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=t('monitor.logdingassistant.s26b79b')
title="叮当 · 添加告警"
>
<Sparkles className="h-5 w-5" />
<span className="font-medium">{t('monitor.logdingassistant.s1a6631')}</span>
<span className="font-medium">叮当</span>
</Button>
</div>
<LogAlertFromQueryDialog
@@ -2,7 +2,6 @@
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[];
@@ -19,7 +18,6 @@ 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"
@@ -32,7 +30,6 @@ 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);
@@ -67,7 +64,7 @@ export function LogFieldSidebar({
<input
value={search}
onChange={e => setSearch(e.target.value)}
placeholder=t('monitor.logfieldsidebar.s2d3013')
placeholder="搜索字段"
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,7 +2,6 @@
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 }[];
@@ -32,7 +31,6 @@ 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(() => {
@@ -113,7 +111,7 @@ export function LogHistogram({ data, engine, onBarClick, loading, totalBytes, li
fontSize: '11px',
color: 'var(--dg-text-primary)',
}}
formatter={(value: number) => [t('monitor.loghistogram.sdc4634', { value: value }), 'logs']}
formatter={(value: number) => [`${value} 条`, 'logs']}
labelFormatter={(label: string) => {
const item = chartData.find(d => d.label === label);
return item ? item.timestamp : label;
@@ -1,20 +1,17 @@
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: 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 },
{ 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 },
];
// 日志中心二级导航 // 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,8 +1,6 @@
// 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;
@@ -25,9 +23,9 @@ interface Props {
}
const STATUS_LABEL: Record<string, { text: string; color: string }> = {
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' },
precise: { text: '结果精确', color: '#22c55e' },
partial: { text: '部分命中', color: '#f59e0b' },
failed: { text: '查询失败', color: '#ef4444' },
};
export function LogQueryToolbar({
@@ -35,7 +33,6 @@ 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;
@@ -124,7 +121,7 @@ export function LogQueryToolbar({
</button>
<div className="flex items-center gap-1 ml-1">
<span className="text-[10px]" style={{ color: 'var(--dg-text-muted)' }}>{t('monitor.logquerytoolbar.s8b76ad')}</span>
<span className="text-[10px]" style={{ color: 'var(--dg-text-muted)' }}>到第</span>
<input
type="number"
value={jumpPage}
@@ -133,7 +130,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)' }}>{t('monitor.dashboard.pagination.perPage')}</span>
<span className="text-[10px]" style={{ color: 'var(--dg-text-muted)' }}>页</span>
</div>
</div>
@@ -155,7 +152,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' ? t('common.descending') : t('common.ascending')}
title={sortDirection === 'desc' ? '降序' : '升序'}
>
<ArrowUpDown className="h-3.5 w-3.5" />
</button>
@@ -163,7 +160,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=t('common.export')
title="导出"
>
<Download className="h-3.5 w-3.5" />
</button>
@@ -171,7 +168,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=t('common.copy')
title="复制"
>
<Copy className="h-3.5 w-3.5" />
</button>
@@ -179,7 +176,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=t('aiops.alertAnalysisRelated')
title="关联告警"
>
<Bell className="h-3.5 w-3.5" />
{hasAlertRule && (
@@ -18,22 +18,20 @@ 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: i18n.t('pipeline.viewer'),
analyst: i18n.t('monitor.logrbac.sa72dec'),
viewer: '查看者',
analyst: '分析者',
};
const ENGINE_LABEL: Record<string, string> = {
'*': i18n.t('common.all'),
'*': '全部',
loki: 'Loki',
elasticsearch: 'ES',
};
const FIELD_LABEL: Record<string, string> = {
index: i18n.t('monitor.logIndex'),
index: '索引',
app: 'app',
namespace: 'namespace',
job: 'job',
@@ -41,7 +39,6 @@ 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);
@@ -109,7 +106,7 @@ export default function LogRBAC() {
const handleCreate = async () => {
if (!newName.trim() || !newCode.trim() || !newPattern.trim()) {
toast.error(t('monitor.logrbac.sf6e80b')); return;
toast.error('请填写名称、标识与正则'); return;
}
setSaving(true);
try {
@@ -123,83 +120,83 @@ export default function LogRBAC() {
description: newDesc,
...(newDatasourceId ? { datasource_id: Number(newDatasourceId) } : {}),
});
toast.success(t('deploy.toast.created'));
toast.success('已创建');
setCreateOpen(false);
setNewName(''); setNewCode(''); setNewEngine('*'); setNewMatchField('app');
setNewPattern(''); setNewRole('viewer'); setNewDatasourceId(''); setNewDesc('');
loadData();
} catch (e: unknown) {
toast.error((e as Error)?.message || t('monitor.quickSetupFailed'));
toast.error((e as Error)?.message || '创建失败');
} finally { setSaving(false); }
};
const handleUpdate = async (id: number) => {
if (!editPattern.trim()) { toast.error(t('pipeline.regexEmpty')); return; }
if (!editPattern.trim()) { toast.error('正则不能为空'); return; }
setSaving(true);
try {
await logRbacApi.updateRole(id, { pattern: editPattern, role: editRole });
toast.success(t('deploy.toast.updated'));
toast.success('已更新');
setEditing(null);
loadData();
} catch (e: unknown) {
toast.error((e as Error)?.message || t('alert.updateFailed'));
toast.error((e as Error)?.message || '更新失败');
} finally { setSaving(false); }
};
const handleDelete = async (role: LogScopeRole) => {
const ok = await _confirm({
title: t('monitor.logrbac.s5d79e4'),
description: t('monitor.logrbac.s81b9d1', { role.name: role.name, matchCount[role.id]?.length || 0: matchCount[role.id]?.length || 0 }),
title: '删除权限',
description: `确定删除「${role.name}」?将影响 ${matchCount[role.id]?.length || 0} 个示例范围`,
variant: 'destructive',
});
if (!ok) return;
try { await logRbacApi.deleteRole(role.id); toast.success(t('alert.deleted')); loadData(); }
catch { toast.error(t('common.deleteFailed')); }
try { await logRbacApi.deleteRole(role.id); toast.success('已删除'); loadData(); }
catch { toast.error('删除失败'); }
};
const handleToggleStatus = async (role: LogScopeRole) => {
try {
await logRbacApi.updateRole(role.id, { status: role.status ? 0 : 1 });
toast.success(role.status ? t('alert.disabled') : t('alert.enabled'));
toast.success(role.status ? '已禁用' : '已启用');
loadData();
} catch { toast.error(t('common.operationFailed')); }
} catch { toast.error('操作失败'); }
};
const handleAssign = async () => {
if (!selectedUserId || !assignRoleId) { toast.error(t('monitor.logrbac.s0fc539')); return; }
if (!selectedUserId || !assignRoleId) { toast.error('请选择用户和权限'); return; }
setAssignLoading(true);
try {
await logRbacApi.assignRole(Number(selectedUserId), {
log_scope_role_id: Number(assignRoleId),
});
toast.success(t('common.assigned'));
toast.success('已分配');
setAssignRoleId('');
loadAssignments();
} catch (e: unknown) {
toast.error((e as Error)?.message || t('deploy.rbac.assignFail'));
toast.error((e as Error)?.message || '分配失败');
} finally { setAssignLoading(false); }
};
const handleRevoke = async (id: number) => {
try {
await logRbacApi.removeRole(Number(selectedUserId), id);
toast.success(t('deploy.toast.revoked'));
toast.success('已撤销');
loadAssignments();
} catch { toast.error(t('deploy.toast.revokeFailed')); }
} catch { toast.error('撤销失败'); }
};
const selectedUser = users.find((u) => String(u.id) === selectedUserId);
const pageTabs = [
{ id: 'roles', label: t('monitor.logrbac.s93ef07'), badge: roles.length || undefined },
{ id: 'users', label: t('monitor.logrbac.s2de895') },
{ id: 'preview', label: t('pipeline.matchPreview') },
{ id: 'roles', label: '权限策略', badge: roles.length || undefined },
{ id: 'users', label: '用户授权' },
{ id: 'preview', label: '命中预览' },
];
return (
<PageShell
variant="list"
title=t('monitor.logCenter.rbac')
subtitle=t('monitor.logCenter.rbacDesc')
title="日志权限"
subtitle="按 ES 索引 / Loki Label 正则限制查询范围"
icon={<ScrollText className="h-5 w-5" />}
loading={loading}
tabs={pageTabs}
@@ -216,9 +213,9 @@ export default function LogRBAC() {
roles.length === 0 ? (
<PageEmpty
icon={<Shield className="h-10 w-10" />}
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>}
title="暂无日志权限策略"
description="创建策略并分配给用户后,将按正则限制可查询的索引与 Label 范围"
action={<Button onClick={() => setCreateOpen(true)}><Plus className="h-4 w-4 mr-1" />新建权限</Button>}
/>
) : (
<div className="space-y-2">
@@ -238,7 +235,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]">{t('alert.disabled')}</Badge>}
{role.status === 0 && <Badge variant="outline" className="text-[10px]">已禁用</Badge>}
<Badge
variant="outline"
className="text-[10px] cursor-pointer border-[var(--dg-border)]"
@@ -259,8 +256,8 @@ export default function LogRBAC() {
<Select value={editRole} onValueChange={setEditRole}>
<SelectTrigger className="h-7 w-20 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="viewer">{t('pipeline.viewer')}</SelectItem>
<SelectItem value="analyst">{t('monitor.logrbac.sa72dec')}</SelectItem>
<SelectItem value="viewer">查看者</SelectItem>
<SelectItem value="analyst">分析者</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>
@@ -268,8 +265,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); }}>{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="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="icon" className="h-7 w-7 text-[var(--dg-red)]" onClick={() => handleDelete(role)}><Trash2 className="w-3.5 h-3.5" /></Button>
</>
)}
@@ -290,9 +287,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">{t('system.roleUsers')}</Label>
<Label className="text-xs">用户</Label>
<Select value={selectedUserId} onValueChange={setSelectedUserId}>
<SelectTrigger className="h-8 text-sm dg-input"><SelectValue placeholder=t('deploy.rbac.selectUser') /></SelectTrigger>
<SelectTrigger className="h-8 text-sm dg-input"><SelectValue placeholder="选择用户" /></SelectTrigger>
<SelectContent className="max-h-60">
{users.map((u) => (
<SelectItem key={u.id as number} value={String(u.id)}>
@@ -303,9 +300,9 @@ export default function LogRBAC() {
</Select>
</div>
<div className="space-y-1 w-48">
<Label className="text-xs">{t('monitor.logrbac.s93ef07')}</Label>
<Label className="text-xs">权限策略</Label>
<Select value={assignRoleId} onValueChange={setAssignRoleId}>
<SelectTrigger className="h-8 text-sm dg-input"><SelectValue placeholder=t('kubernetes.hpa.selectPolicy') /></SelectTrigger>
<SelectTrigger className="h-8 text-sm dg-input"><SelectValue placeholder="选择策略" /></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>
@@ -320,7 +317,7 @@ export default function LogRBAC() {
</div>
{selectedUser ? (
assignments.length === 0 ? (
<p className="text-xs text-[var(--dg-text-muted)]">{t('monitor.logrbac.sa120fc')}</p>
<p className="text-xs text-[var(--dg-text-muted)]">该用户尚未分配日志权限</p>
) : (
<div className="space-y-1.5">
{assignments.map((a) => {
@@ -341,7 +338,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" />{t('monitor.logrbac.s9be00c')}</p>
<p className="text-xs text-[var(--dg-text-muted)] flex items-center gap-1"><Users className="w-3.5 h-3.5" />选择用户后查看已分配权限</p>
)}
</CardContent>
</Card>
@@ -374,7 +371,7 @@ export default function LogRBAC() {
</Badge>
))}
{previewRoleId !== null && previewMatches.length === 0 && (
<span className="text-xs text-[var(--dg-text-muted)]">{t('monitor.logrbac.s64096f')}</span>
<span className="text-xs text-[var(--dg-text-muted)]">无匹配 — 请调整正则</span>
)}
</div>
</CardContent>
@@ -3,7 +3,6 @@ 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;
@@ -14,7 +13,6 @@ 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('');
@@ -97,7 +95,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)' }}>{t('monitor.lokilabelbrowser.s088b94')}</p>}
{!activeLabel && <p className="text-[11px] px-2 py-4 text-center" style={{ color: 'var(--dg-text-muted)' }}>选择左侧标签</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,8 +4,6 @@ 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;
@@ -22,14 +20,13 @@ const ENGINE_ICONS: Record<string, { Icon: typeof Flame; color: string }> = {
function relativeTime(ts: number): string {
const diff = Date.now() - ts;
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) });
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)}天前`;
}
export function QueryHistoryPanel({ visible, onClose, onSelect, onCreateAlert }: Props) {
const { t } = useTranslation();
const [history, setHistory] = useState<QueryHistoryItem[]>([]);
useEffect(() => {
@@ -62,7 +59,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)' }}>{t('monitor.logQueryHistory')}</span>
<span className="text-sm font-medium" style={{ color: 'var(--dg-text-primary)' }}>查询历史</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>
@@ -72,7 +69,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=t('common.clear')
title="清空"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
@@ -91,8 +88,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)' }}>{t('monitor.queryhistorypanel.s1e60bc')}</span>
<span className="text-[10px]" style={{ color: 'var(--dg-text-muted)' }}>{t('monitor.queryhistorypanel.s389502')}</span>
<span className="text-xs" style={{ color: 'var(--dg-text-muted)' }}>暂无查询记录</span>
<span className="text-[10px]" style={{ color: 'var(--dg-text-muted)' }}>执行查询后将自动记录</span>
</div>
) : (
<div className="divide-y" style={{ borderColor: 'var(--dg-border)' }}>
@@ -140,7 +137,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=t('monitor.queryhistorypanel.s4fb36a')
title="执行查询"
>
<Play className="h-3 w-3" />
</button>
@@ -1,46 +1,43 @@
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: 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}}"' },
{ 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}}"' },
],
elasticsearch: [
{ label: 'API 5xx', query: 'status:500 AND path:"/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' },
{ 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' },
],
};
// 快捷查询模板条 // 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,7 +5,6 @@ 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;
@@ -19,7 +18,6 @@ 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);
@@ -48,24 +46,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)' }}>{t('monitor.statChart.saveAsQuery')}</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)' }}>另存为查询</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)' }}>{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)' }} />
<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)' }} />
</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 || t('approval.status.default')}<br />
<span style={{ color: ENGINE_META[engine].color }}>{ENGINE_META[engine].name}</span> · {datasourceName || '默认'}<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)' }}>{t('common.cancel')}</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)' }}>取消</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 ? i18n.t('deploy.toast.saved') : i18n.t('common.save')}
{saving && <Loader2 className="h-3.5 w-3.5 animate-spin" />}{saved ? '已保存' : '保存'}
</button>
</div>
</div>
@@ -85,7 +83,6 @@ 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');
@@ -103,7 +100,7 @@ export function SaveAlertDialog({ open, onClose, engine, query, datasourceId, da
try {
await alertApi.createRule({
name: name.trim(),
description: t('monitor.savequerydialog.s499c4b', { datasourceName || '默认数据源': datasourceName || '默认数据源' }),
description: `从查询页创建: ${datasourceName || '默认数据源'}`,
type: 'log',
source: engine === 'sls' ? 'loki' : engine,
expression: query.trim(),
@@ -113,11 +110,11 @@ export function SaveAlertDialog({ open, onClose, engine, query, datasourceId, da
notify_channels: [],
});
setSaved(true);
toast({ title: t('monitor.savequerydialog.sd3dbea') });
toast({ title: '告警规则已创建' });
onSave?.();
setTimeout(() => { setSaved(false); onClose(); }, 800);
} catch (e: any) {
toast({ title: t('monitor.quickSetupFailed'), description: e?.message || String(e), variant: 'destructive' });
toast({ title: '创建失败', description: e?.message || String(e), variant: 'destructive' });
} finally {
setSaving(false);
}
@@ -128,35 +125,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)' }}>{t('monitor.logSaveAsAlert')}</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)' }}>另存为告警</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)' }}>{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)' }} />
<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)' }} />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-[10px] block mb-1" style={{ color: 'var(--dg-text-muted)' }}>{t('common.level')}</label>
<label className="text-[10px] block mb-1" style={{ color: 'var(--dg-text-muted)' }}>级别</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">{t('common.info')}</option><option value="warning">{t('common.warning')}</option><option value="critical">{t('monitor.critical')}</option>
<option value="info">提示</option><option value="warning">警告</option><option value="critical">严重</option>
</select>
</div>
<div>
<label className="text-[10px] block mb-1" style={{ color: 'var(--dg-text-muted)' }}>{t('monitor.savequerydialog.s65939c')}</label>
<label className="text-[10px] block mb-1" style={{ color: 'var(--dg-text-muted)' }}>触发条件</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 || t('approval.status.default')}<br />
<span style={{ color: meta.color }}>{meta.name}</span> · {datasourceName || '默认'}<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)' }}>{t('common.cancel')}</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)' }}>取消</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 ? i18n.t('deploy.toast.created') : i18n.t('monitor.savequerydialog.sbc1d99')}
{saving && <Loader2 className="h-3.5 w-3.5 animate-spin" />}{saved ? '已创建' : '创建告警'}
</button>
</div>
</div>
@@ -3,7 +3,6 @@ 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;
@@ -13,7 +12,6 @@ interface Props {
// 已保存查询面板(localStorage)// Logan.Li
export function SavedQueriesPanel({ visible, onClose, onSelect }: Props) {
const { t } = useTranslation();
const [items, setItems] = useState<SavedQuery[]>([]);
useEffect(() => {
@@ -32,7 +30,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)' }}>{t('monitor.logSavedQueries')}</span>
<span className="text-sm font-medium" style={{ color: 'var(--dg-text-primary)' }}>已保存查询</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>
@@ -43,7 +41,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)' }}>{t('monitor.savedqueriespanel.s9c8dc6')}</p>
<p className="text-xs text-center py-8" style={{ color: 'var(--dg-text-muted)' }}>暂无保存的查询</p>
) : items.map((item) => (
<div
key={item.id}
@@ -67,7 +65,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=t('common.apply')
title="应用"
>
<Play className="h-3.5 w-3.5" />
</button>
@@ -75,7 +73,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=t('common.delete')
title="删除"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
@@ -2,32 +2,30 @@
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: 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' },
{ 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' },
];
// 绝对时间预设:固定的自然日/周/月/季/年 // Logan.Li
const ABSOLUTE_PRESETS = [
{ 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' },
{ 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' },
];
function getPresetRange(value: string): { start: Date; end: Date; label: string } {
@@ -37,24 +35,24 @@ function getPresetRange(value: string): { start: Date; end: Date; label: string
let label = '';
switch (value) {
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');
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分钟';
}
return { start, end, label };
}
@@ -69,7 +67,6 @@ 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');
@@ -97,7 +94,7 @@ export function TimeRangePicker({ value, onChange }: Props) {
onChange({
start: customStart,
end: customEnd,
label: t('common.custom'),
label: '自定义',
type: 'absolute',
});
setActivePreset('custom');
@@ -138,7 +135,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)' }}>{t('monitor.timerangepicker.sc2c8fb')}</span>
<span className="text-sm font-medium" style={{ color: 'var(--dg-text-primary)' }}>时间选择</span>
<button
onClick={openCustom}
className="text-xs ml-2 px-2 py-0.5 rounded transition-colors"
@@ -154,7 +151,7 @@ export function TimeRangePicker({ value, onChange }: Props) {
{mode === 'preset' ? (
<>
{/* 相对时间 — 最近... */}
<p className="text-[10px] mb-2 font-medium uppercase tracking-wide" style={{ color: '#22c55e' }}>{t('monitor.timerangepicker.s8ead0a')}</p>
<p className="text-[10px] mb-2 font-medium uppercase tracking-wide" style={{ color: '#22c55e' }}>相对时间(滚动窗口)</p>
<div className="grid grid-cols-4 gap-1.5 mb-3">
{RELATIVE_PRESETS.map(p => (
<button
@@ -173,7 +170,7 @@ export function TimeRangePicker({ value, onChange }: Props) {
</div>
{/* 绝对时间 — 固定日历范围 */}
<p className="text-[10px] mb-2 font-medium uppercase tracking-wide" style={{ color: 'var(--dg-blue)' }}>{t('monitor.timerangepicker.s714f6c')}</p>
<p className="text-[10px] mb-2 font-medium uppercase tracking-wide" style={{ color: 'var(--dg-blue)' }}>绝对时间(日历范围)</p>
<div className="grid grid-cols-5 gap-1.5">
{ABSOLUTE_PRESETS.map(p => (
<button
@@ -194,10 +191,10 @@ export function TimeRangePicker({ value, onChange }: Props) {
) : (
/* 自定义日历模式 */
<div className="space-y-3">
<p className="text-[10px] font-medium uppercase" style={{ color: 'var(--dg-blue)' }}>{t('monitor.timerangepicker.s0122e0')}</p>
<p className="text-[10px] font-medium uppercase" style={{ color: 'var(--dg-blue)' }}>自定义时间范围</p>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-[10px] block mb-1" style={{ color: 'var(--dg-text-muted)' }}>{t('common.startTime')}</label>
<label className="text-[10px] block mb-1" style={{ color: 'var(--dg-text-muted)' }}>开始时间</label>
<input
type="datetime-local"
value={customStart.substring(0, 16)}
@@ -207,7 +204,7 @@ export function TimeRangePicker({ value, onChange }: Props) {
/>
</div>
<div>
<label className="text-[10px] block mb-1" style={{ color: 'var(--dg-text-muted)' }}>{t('common.endTime')}</label>
<label className="text-[10px] block mb-1" style={{ color: 'var(--dg-text-muted)' }}>结束时间</label>
<input
type="datetime-local"
value={customEnd.substring(0, 16)}
@@ -218,8 +215,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)' }}>{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>
<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>
</div>
</div>
)}
@@ -1,6 +1,7 @@
// UnifiedQueryPanel.tsx — 统一查询面板(接入真实 API)// Logan.Li
import { useState, useMemo, useCallback, useRef, useEffect } from 'react';
import { useSearchParams, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Play, Loader2, Clock, Save, ChevronDown, Bell, Link2, Bookmark, Radio } from 'lucide-react';
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
import { TimeRangePicker } from './TimeRangePicker';
@@ -25,14 +26,12 @@ 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: i18n.t('monitor.unifiedquerypanel.sb3fc4e'),
label: '最近5分钟',
type: 'relative',
};
@@ -52,7 +51,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 { t } = useTranslation(['monitor', 'common']);
const { toast } = useToast();
const location = useLocation();
const [searchParams, setSearchParams] = useSearchParams();
@@ -77,7 +76,7 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
const [esIndex, setEsIndex] = useState('');
// API 单次查询返回条数上限(后端限制 1000),Loki 用 limit / ES 用 size // Logan.Li
const [queryLimit, setQueryLimit] = useState(1000);
const [timeRange, setTimeRange] = useState<TimeRange>(DEFAULT_TIME_RANGE);
const [timeRange, setTimeRange] = useState<TimeRange>(() => ({ ...DEFAULT_TIME_RANGE, label: t('monitor:logPanel.last5min') }));
const [loading, setLoading] = useState(false);
const [entries, setEntries] = useState<LogEntry[]>([]);
const [statTable, setStatTable] = useState<StatTable | undefined>();
@@ -267,10 +266,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') ? 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');
const title = msg.includes('401') || msg.includes('403') ? t('monitor:logPanel.authFailed')
: msg.includes('404') ? t('monitor:logPanel.apiNotFound')
: msg.includes('无法连接') || msg.includes('timeout') ? t('monitor:logPanel.connFailed')
: t('monitor:logPanel.queryFailed');
toast({ title, description: msg, variant: 'destructive' });
setEntries([]); setTotalCount(0); setHasQueried(true);
} finally {
@@ -305,7 +304,7 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
setNextCursor(data?.next_cursor || '');
setSearchAfter(data?.search_after);
} catch (e: unknown) {
toast({ title: t('monitor.unifiedquerypanel.s5b139e'), description: (e as Error)?.message, variant: 'destructive' });
toast({ title: t('monitor:logPanel.loadMoreFailed'), description: (e as Error)?.message, variant: 'destructive' });
} finally {
setLoadingMore(false);
}
@@ -345,8 +344,8 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
const full = `${window.location.origin}${url}`;
const ok = await copyToClipboard(full);
toast({
title: ok ? t('monitor.unifiedquerypanel.sf49cc0') : t('common.copyFailed'),
description: ok ? t('monitor.unifiedquerypanel.s6b6e6f') : undefined,
title: ok ? t('monitor:logPanel.shareCopied') : t('monitor:logPanel.shareCopyFailed'),
description: ok ? t('monitor:logPanel.shareCopiedDesc') : undefined,
variant: ok ? 'default' : 'destructive',
});
}, [location.pathname, query, dsId, esIndex, timeRange, toast]);
@@ -418,30 +417,30 @@ 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: t('common.custom'), type: 'absolute' as const }));
setTimeRange(p => ({ ...p, start: histogramData[sIdx].timestamp, end: histogramData[eIdx].timestamp, label: t('monitor:logPanel.custom'), type: 'absolute' as const }));
}, [histogramData]);
const formatEntryLine = useCallback((e: LogEntry) => buildLogCopyText(e), []);
const handleExportLogs = useCallback(() => {
const t = sortedEntries.map(formatEntryLine).join('\n');
const b = new Blob([t], { type: 'text/plain;charset=utf-8' });
const logText = sortedEntries.map(formatEntryLine).join('\n');
const b = new Blob([logText], { type: 'text/plain;charset=utf-8' });
const u = URL.createObjectURL(b);
const a = document.createElement('a');
a.href = u;
a.download = `${effEngine}-logs-${Date.now()}.txt`;
a.click();
URL.revokeObjectURL(u);
toast({ title: t('monitor.unifiedquerypanel.s5c51c3'), description: t('monitor.unifiedquerypanel.sa7f301', { sortedEntries.length: sortedEntries.length }) });
toast({ title: t('monitor:logPanel.exported'), description: t('monitor:logPanel.exportedDesc', { count: sortedEntries.length }) });
}, [sortedEntries, effEngine, formatEntryLine, toast]);
const handleCopyLogs = useCallback(async () => {
const text = pagedEntries.map(formatEntryLine).join('\n');
const ok = await copyToClipboard(text);
const logText = pagedEntries.map(formatEntryLine).join('\n');
const ok = await copyToClipboard(logText);
if (ok) {
toast({ title: t('common.copied'), description: t('monitor.unifiedquerypanel.se411aa', { pagedEntries.length: pagedEntries.length }) });
toast({ title: t('monitor:logPanel.copied'), description: t('monitor:logPanel.copiedDesc', { count: pagedEntries.length }) });
} else {
toast({ title: t('common.copyFailed'), description: t('monitor.unifiedquerypanel.sc447fb'), variant: 'destructive' });
toast({ title: t('monitor:logPanel.copyFailed'), description: t('monitor:logPanel.copyFailedDesc'), variant: 'destructive' });
}
}, [pagedEntries, formatEntryLine, toast]);
@@ -465,7 +464,7 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
onRequestLabelValues={handleRequestLabelValues}
/>
<button onClick={() => { setLiveTail(false); void handleRun(); }} disabled={loading || liveTail || !currentSource || !query.trim()} className="h-10 px-4 rounded text-xs font-medium text-white flex items-center gap-1.5 flex-shrink-0 transition-opacity hover:opacity-90 disabled:opacity-40 disabled:cursor-not-allowed" style={{ background: '#22c55e' }}>
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Play className="h-4 w-4" />} 查询
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Play className="h-4 w-4" />} {t('monitor:logPanel.query')}
</button>
<button
onClick={() => setLiveTail((v) => !v)}
@@ -476,7 +475,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=t('monitor.unifiedquerypanel.s6c3d36')
title={t('monitor:logPanel.liveTailTitle')}
>
<Radio className={`h-3.5 w-3.5 ${liveTail ? 'animate-pulse' : ''}`} /> Tail
</button>
@@ -487,8 +486,8 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
<TimeRangePicker value={timeRange} onChange={setTimeRange} />
<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 ? t('monitor.unifiedquerypanel.see1f37') : ''}</option>)}
{sources.length === 0 && <option value="">{t('monitor:logPanel.noDatasource', { name: meta.name })}</option>}
{sources.map(s => <option key={s.id} value={s.id}>{s.name}{s.is_default ? t('monitor:logPanel.defaultSuffix') : ''}</option>)}
</select>
{effEngine === 'elasticsearch' && (
@@ -500,7 +499,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)]">{t('monitor.unifiedquerypanel.s726bfb')}</span>
<span className="text-xs text-[var(--dg-text-muted)]">{t('monitor:logPanel.limit')}</span>
<input
type="number"
min={1}
@@ -517,24 +516,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" /> {t('common.saveAs')} <ChevronDown className="h-3 w-3" />
<Save className="h-3.5 w-3.5" /> {t('monitor:logPanel.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" /> {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>
<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('monitor:logPanel.saveAsQueryItem')}</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('monitor:logPanel.saveAsAlertItem')}</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=t('monitor.unifiedquerypanel.s1a9f82')>
<Link2 className="h-3.5 w-3.5" /> 分享
<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:logPanel.shareLinkTitle')}>
<Link2 className="h-3.5 w-3.5" /> {t('monitor:logPanel.share')}
</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=t('monitor.logSavedQueries')>
<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:logPanel.savedQueries')}>
<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=t('monitor.logQueryHistory')>
<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:logPanel.history')}>
<Clock className="h-4 w-4" />
</button>
</div>
@@ -542,7 +541,7 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
{/* 无数据源时引导用户前往日志库配置 // Logan.Li */}
{sources.length === 0 && (
<div className="p-3 rounded border text-xs" style={{ background: 'rgba(245,158,11,0.1)', borderColor: 'rgba(245,158,11,0.3)', color: '#f59e0b' }}>
暂无{meta.name}数据源,请先前往「日志库」添加数据源。
{t('monitor:logPanel.noDatasourceHint', { name: meta.name })}
</div>
)}
@@ -577,7 +576,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) => [t('monitor.unifiedquerypanel.s8ea117', { v: v }), t('common.count')]} labelFormatter={(l: string) => t('monitor.unifiedquerypanel.s5f5d6e', { l: 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) => [`${v} ${t('monitor:logPanel.chartCountSuffix')}`, t('monitor:logPanel.chartCountLabel')]} labelFormatter={(l: string) => `${t('monitor:logPanel.chartTimePrefix')} ${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>
@@ -589,7 +588,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: t('monitor.unifiedquerypanel.se0c409'), stats: t('monitor.unifiedquerypanel.s0e2e4e'), cluster: t('monitor.logCluster') };
const labels: Record<ResultTab, string> = { logs: t('monitor:logPanel.tabLogs'), stats: t('monitor:logPanel.tabStats'), cluster: t('monitor:logPanel.tabCluster') };
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' }}>
@@ -611,12 +610,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' ? t('monitor.unifiedquerypanel.se0c409') : m === 'field' ? t('monitor.unifiedquerypanel.sda04fc') : t('monitor.unifiedquerypanel.s175e01')}
{m === 'raw' ? t('monitor:logPanel.viewRaw') : m === 'field' ? t('monitor:logPanel.viewField') : t('monitor:logPanel.viewTable')}
</button>
))}
</div>
<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>
<label className="flex items-center gap-1.5 text-[11px] cursor-pointer" style={{ color: 'var(--dg-text-muted)' }} title={t('monitor:logPanel.truncateTitle')}><input type="checkbox" checked={truncate} onChange={e => setTruncate(e.target.checked)} className="h-3.5 w-3.5 rounded" /> {t('monitor:logPanel.truncate')}</label>
<label className="flex items-center gap-1.5 text-[11px] cursor-pointer" style={{ color: 'var(--dg-text-muted)' }} title={t('monitor:logPanel.wrapLinesTitle')}><input type="checkbox" checked={wrapLines} onChange={e => setWrapLines(e.target.checked)} className="h-3.5 w-3.5 rounded" /> {t('monitor:logPanel.wrapLines')}</label>
</div>
</>)}
{resultTab === 'stats' && <StatChartPanel statTable={statTable} engine={effEngine as LogEngine} query={query} onSaveAsAlert={() => setSaveAlertOpen(true)} onSaveAsQuery={() => setSaveQueryOpen(true)} />}
@@ -628,7 +627,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)' }}>{t('monitor.unifiedquerypanel.se68b81')}</span>
<span className="text-sm" style={{ color: 'var(--dg-text-muted)' }}>{t('monitor:logPanel.emptyHint')}</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>
)}
@@ -640,13 +639,13 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
</svg>
</div>
<div className="text-center">
<p className="text-sm font-medium" style={{ color: 'var(--dg-text-primary)' }}>{t('monitor.unifiedquerypanel.sd797e0')}</p>
<p className="text-sm font-medium" style={{ color: 'var(--dg-text-primary)' }}>{t('monitor:logPanel.noMatch')}</p>
<p className="text-xs mt-1" style={{ color: 'var(--dg-text-muted)' }}>
查询已执行({queryTime}ms),但当前时间范围和筛选条件下无匹配结果
{t('monitor:logPanel.noMatchDesc', { ms: queryTime })}
</p>
</div>
<button onClick={handleRun} className="px-4 py-1.5 rounded text-xs font-medium text-white transition-opacity hover:opacity-90" style={{ background: '#22c55e' }}>
<Play className="h-3.5 w-3.5 inline mr-1" />重新查询
<Play className="h-3.5 w-3.5 inline mr-1" />{t('monitor:logPanel.requeries')}
</button>
</div>
)}
@@ -1,7 +1,6 @@
// 日志查询 URL 状态编解码 — 可分享链接 // Logan.Li
import type { TimeRange } from './types-v3';
import i18n from '@/i18n';
export interface LogQueryUrlState {
query?: string;
@@ -17,7 +16,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') || i18n.t('common.custom');
const timeLabel = params.get('tr') || '自定义';
const autoRun = params.get('run') === '1';
const state: LogQueryUrlState = { query, datasourceId, index, autoRun };
if (start && end) {
@@ -1,4 +1,3 @@
import i18n from '@/i18n';
// types-v3.ts — v3 统一日志中心类型定义 // Logan.Li
export type LogEngine = 'loki' | 'elasticsearch' | 'sls';
@@ -133,7 +132,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: i18n.t('monitor.typesV3.sfe4ff0'),
externalLink: 'SLS控制台',
},
};
@@ -249,27 +248,27 @@ export const savedQueryStorage = {
// 快捷查询模板 // Logan.Li
export const QUICK_TEMPLATES: Record<LogEngine, { label: string; query: string }[]> = {
loki: [
{ 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' },
{ 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' },
],
elasticsearch: [
{ 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))' },
{ 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))' },
],
sls: [
{ 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" },
{ 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" },
],
};
@@ -1,4 +1,5 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import {
Search,
@@ -40,7 +41,6 @@ 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;
@@ -85,7 +85,7 @@ interface ServiceMetrics {
}
const TraceManager: React.FC = () => {
const { t } = useTranslation();
const { t } = useTranslation(['monitor', 'common']);
const [traces, setTraces] = useState<Trace[]>([]);
const [services, setServices] = useState<ServiceMetrics[]>([]);
const [loading, setLoading] = useState(false);
@@ -113,7 +113,7 @@ const TraceManager: React.FC = () => {
const res = await traceApi.getTraces({ timeRange, service: serviceFilter, status: statusFilter });
setTraces(res.data || []);
} catch {
toast.error(t('monitor.tracemanager.s70f5f6'));
toast.error(t('monitor:traceMgr.fetchTracesFailed'));
} finally {
setLoading(false);
}
@@ -124,7 +124,7 @@ const TraceManager: React.FC = () => {
const res = await traceApi.getServices(timeRange);
setServices(res.data || []);
} catch {
toast.error(t('monitor.tracemanager.sa4e1bb'));
toast.error(t('monitor:traceMgr.fetchServicesFailed'));
}
};
@@ -133,7 +133,7 @@ const TraceManager: React.FC = () => {
const res = await traceApi.getTraceDetail(traceId);
return res.data;
} catch {
toast.error(t('monitor.tracemanager.s2d86b6'));
toast.error(t('monitor:traceMgr.fetchDetailFailed'));
return null;
}
};
@@ -141,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" />{t('common.success')}</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" />{t('common.error')}</Badge>;
return <Badge variant="destructive" className="gap-1"><XCircle className="w-3 h-3" />{t('monitor:traceError')}</Badge>;
case 'pending':
return <Badge variant="outline" className="gap-1"><Clock className="w-3 h-3" />{t('common.inProgress')}</Badge>;
return <Badge variant="outline" className="gap-1"><Clock className="w-3 h-3" />{t('monitor:traceMgr.inProgress')}</Badge>;
default:
return <Badge variant="secondary">{t('common.unknown')}</Badge>;
return <Badge variant="secondary">{t('common:unknown')}</Badge>;
}
};
@@ -262,9 +262,9 @@ const TraceManager: React.FC = () => {
};
const pageTabs = [
{ id: 'traces', label: t('menu.trace') },
{ id: 'services', label: t('monitor.tracemanager.scbb839') },
{ id: 'metrics', label: t('monitor.tracemanager.sb59dc0') },
{ id: 'traces', label: t('monitor:traceMgr.tabTraces') },
{ id: 'services', label: t('monitor:traceMgr.tabServices') },
{ id: 'metrics', label: t('monitor:traceMgr.tabMetrics') },
];
const tracesToolbar = activeTab === 'traces' ? (
@@ -273,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=t('monitor.tracemanager.s0a1f23')
placeholder={t('monitor:traceMgr.searchPlaceholder')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
@@ -281,10 +281,10 @@ const TraceManager: React.FC = () => {
</div>
<Select value={serviceFilter} onValueChange={setServiceFilter}>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder=t('cloud.colService') />
<SelectValue placeholder={t('monitor:traceService')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('monitor.tracemanager.sdd6e6e')}</SelectItem>
<SelectItem value="all">{t('monitor:traceMgr.allServices')}</SelectItem>
{serviceNames.map(s => (
<SelectItem key={s} value={s}>{s}</SelectItem>
))}
@@ -292,13 +292,13 @@ const TraceManager: React.FC = () => {
</Select>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[120px]">
<SelectValue placeholder=t('common.status') />
<SelectValue placeholder={t('monitor:traceStatus')} />
</SelectTrigger>
<SelectContent>
<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>
<SelectItem value="all">{t('monitor:allStatus')}</SelectItem>
<SelectItem value="success">{t('common:success')}</SelectItem>
<SelectItem value="error">{t('monitor:traceError')}</SelectItem>
<SelectItem value="pending">{t('monitor:traceMgr.inProgress')}</SelectItem>
</SelectContent>
</Select>
<Select value={timeRange} onValueChange={setTimeRange}>
@@ -306,18 +306,18 @@ const TraceManager: React.FC = () => {
<SelectValue />
</SelectTrigger>
<SelectContent>
<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>
<SelectItem value="15m">{t('monitor:traceMgr.min15')}</SelectItem>
<SelectItem value="1h">{t('monitor:traceMgr.hour1')}</SelectItem>
<SelectItem value="6h">{t('monitor:traceMgr.hour6')}</SelectItem>
<SelectItem value="24h">{t('monitor:traceMgr.hour24')}</SelectItem>
<SelectItem value="7d">{t('monitor:traceMgr.day7')}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" className="dg-btn-secondary" onClick={fetchTraces}>
<RefreshCw className="w-4 h-4 mr-2" />
刷新
{t('common:refresh')}
</Button>
</div>
</div>
@@ -326,8 +326,8 @@ const TraceManager: React.FC = () => {
return (
<PageShell
variant="list"
title=t('menu.trace')
subtitle=t('monitor.tracemanager.s3cd272')
title={t('monitor:traceMgr.tabTraces')}
subtitle={t('monitor:traceMgr.subtitle')}
icon={<GitBranch className="h-5 w-5" />}
tabs={pageTabs}
activeTab={activeTab}
@@ -336,17 +336,17 @@ const TraceManager: React.FC = () => {
metrics={(
<StatStrip
items={[
{ 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' },
{ label: t('monitor:traceMgr.totalRequests'), value: services.reduce((sum, s) => sum + s.requestCount, 0).toLocaleString(), color: 'blue' },
{ label: t('monitor:traceMgr.errorRate'), value: `${(services.reduce((sum, s) => sum + s.errorRate, 0) / (services.length || 1)).toFixed(2)}%`, color: 'red' },
{ label: t('monitor:traceMgr.avgLatency'), value: formatDuration(services.reduce((sum, s) => sum + s.avgDuration, 0) / (services.length || 1)), color: 'amber' },
{ label: t('monitor:traceMgr.serviceCount'), value: services.length, color: 'purple' },
]}
/>
)}
actions={activeTab === 'traces' ? undefined : (
<Button variant="outline" className="dg-btn-secondary" onClick={fetchData}>
<RefreshCw className="w-4 h-4 mr-2" />
刷新
{t('common:refresh')}
</Button>
)}
>
@@ -358,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">{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>
<th className="px-4 py-3 text-left text-sm font-medium">{t('monitor:traceMgr.colServiceOperation')}</th>
<th className="px-4 py-3 text-left text-sm font-medium">{t('monitor:traceStatus')}</th>
<th className="px-4 py-3 text-left text-sm font-medium">{t('monitor:traceDuration')}</th>
<th className="px-4 py-3 text-left text-sm font-medium">{t('monitor:traceMgr.spanCount')}</th>
<th className="px-4 py-3 text-left text-sm font-medium">{t('common:createdAt')}</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=t('monitor.tracemanager.se5b9da') /></td>
<td colSpan={7} className="px-4 py-2"><PageLoading label={t('monitor:traceMgr.loadingTraces')} /></td>
</tr>
) : filteredTraces.length === 0 ? (
<tr>
<td colSpan={7} className="px-4 py-2"><PageEmpty title=t('monitor.tracemanager.s164ffc') className="py-8" /></td>
<td colSpan={7} className="px-4 py-2"><PageEmpty title={t('monitor:traceMgr.noTraces')} className="py-8" /></td>
</tr>
) : (
filteredTraces.map((trace) => (
@@ -417,19 +417,19 @@ const TraceManager: React.FC = () => {
{activeTab === 'services' && (
<Card>
<CardHeader>
<CardTitle>{t('monitor.tracemanager.s2be481')}</CardTitle>
<CardTitle>{t('monitor:traceMgr.servicePerfTitle')}</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">{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>
<th className="px-4 py-3 text-left text-sm font-medium">{t('monitor:traceMgr.serviceName')}</th>
<th className="px-4 py-3 text-left text-sm font-medium">{t('monitor:traceMgr.requestCount')}</th>
<th className="px-4 py-3 text-left text-sm font-medium">{t('monitor:traceMgr.errorRate')}</th>
<th className="px-4 py-3 text-left text-sm font-medium">{t('monitor:traceMgr.avgLatency')}</th>
<th className="px-4 py-3 text-left text-sm font-medium">{t('monitor:traceMgr.p95Latency')}</th>
<th className="px-4 py-3 text-left text-sm font-medium">{t('monitor:traceMgr.p99Latency')}</th>
</tr>
</thead>
<tbody className="divide-y">
@@ -458,20 +458,20 @@ const TraceManager: React.FC = () => {
<div className="grid grid-cols-2 gap-4">
<Card>
<CardHeader>
<CardTitle>{t('monitor.tracemanager.s0628a5')}</CardTitle>
<CardTitle>{t('monitor:traceMgr.latencyDist')}</CardTitle>
</CardHeader>
<CardContent className="h-[300px] flex items-center justify-center text-muted-foreground">
<BarChart3 className="w-16 h-16 mr-4" />
延迟分布图表(集成图表库后显示)
{t('monitor:traceMgr.latencyDistChart')}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{t('monitor.tracemanager.sb9c8e4')}</CardTitle>
<CardTitle>{t('monitor:traceMgr.errorTrend')}</CardTitle>
</CardHeader>
<CardContent className="h-[300px] flex items-center justify-center text-muted-foreground">
<TrendingUp className="w-16 h-16 mr-4" />
错误率趋势图表(集成图表库后显示)
{t('monitor:traceMgr.errorTrendChart')}
</CardContent>
</Card>
</div>
@@ -483,31 +483,31 @@ const TraceManager: React.FC = () => {
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<GitBranch className="w-5 h-5" />
Trace详情
{t('monitor:traceMgr.traceDetailTitle')}
<span className="font-mono text-sm text-muted-foreground">{selectedTrace?.traceId}</span>
</DialogTitle>
<DialogDescription className="sr-only">{t('common.description')}</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">{t('cloud.colService')}</Label>
<Label className="text-xs text-muted-foreground">{t('monitor:traceService')}</Label>
<p className="font-medium">{selectedTrace.serviceName}</p>
</div>
<Separator orientation="vertical" className="h-8" />
<div>
<Label className="text-xs text-muted-foreground">{t('common.actions')}</Label>
<Label className="text-xs text-muted-foreground">{t('monitor:traceOperation')}</Label>
<p className="font-medium">{selectedTrace.operationName}</p>
</div>
<Separator orientation="vertical" className="h-8" />
<div>
<Label className="text-xs text-muted-foreground">{t('monitor.tracemanager.sa05128')}</Label>
<Label className="text-xs text-muted-foreground">{t('monitor:traceMgr.totalDuration')}</Label>
<p className="font-medium">{formatDuration(selectedTrace.duration)}</p>
</div>
<Separator orientation="vertical" className="h-8" />
<div>
<Label className="text-xs text-muted-foreground">{t('common.status')}</Label>
<Label className="text-xs text-muted-foreground">{t('monitor:traceStatus')}</Label>
<p>{getStatusBadge(selectedTrace.status)}</p>
</div>
</div>
@@ -515,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">{t('monitor.tracemanager.s29e5a5')}</span>
<span className="w-32">{t('common.timeline')}</span>
<span className="w-20 text-right">{t('common.elapsed')}</span>
<span className="flex-1">{t('monitor:traceMgr.spanName')}</span>
<span className="w-32">{t('monitor:traceTimeline')}</span>
<span className="w-20 text-right">{t('monitor:traceDuration')}</span>
</div>
{/* Span树渲染 */}
{(selectedTrace as JsonData).spans && buildSpanTree((selectedTrace as JsonData).spans).map(span => (
@@ -534,8 +534,8 @@ const TraceManager: React.FC = () => {
<Dialog open={spanDetailOpen} onOpenChange={setSpanDetailOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{t('monitor.tracemanager.s4828d8')}</DialogTitle>
<DialogDescription className="sr-only">{t('common.description')}</DialogDescription>
<DialogTitle>{t('monitor:traceMgr.spanDetailTitle')}</DialogTitle>
<DialogDescription className="sr-only">{t('common:description')}</DialogDescription>
</DialogHeader>
{selectedSpan && (
<div className="space-y-4">
@@ -545,30 +545,30 @@ const TraceManager: React.FC = () => {
<p className="font-mono text-sm">{selectedSpan.spanId}</p>
</div>
<div>
<Label className="text-muted-foreground">{t('common.type')}</Label>
<Label className="text-muted-foreground">{t('common:type')}</Label>
<p className="capitalize">{selectedSpan.kind}</p>
</div>
<div>
<Label className="text-muted-foreground">{t('cloud.colService')}</Label>
<Label className="text-muted-foreground">{t('monitor:traceService')}</Label>
<p className="font-medium">{selectedSpan.serviceName}</p>
</div>
<div>
<Label className="text-muted-foreground">{t('common.actions')}</Label>
<Label className="text-muted-foreground">{t('monitor:traceOperation')}</Label>
<p className="font-medium">{selectedSpan.operationName}</p>
</div>
<div>
<Label className="text-muted-foreground">{t('common.status')}</Label>
<Label className="text-muted-foreground">{t('monitor:traceStatus')}</Label>
<p>{getStatusBadge(selectedSpan.status)}</p>
</div>
<div>
<Label className="text-muted-foreground">{t('common.elapsed')}</Label>
<Label className="text-muted-foreground">{t('monitor:traceDuration')}</Label>
<p className="font-medium">{formatDuration(selectedSpan.duration)}</p>
</div>
</div>
{Object.keys(selectedSpan.tags).length > 0 && (
<div>
<Label className="text-muted-foreground">{t('common.tag')}</Label>
<Label className="text-muted-foreground">{t('monitor:alertLabels')}</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">
@@ -582,7 +582,7 @@ const TraceManager: React.FC = () => {
{selectedSpan.logs?.length > 0 && (
<div>
<Label className="text-muted-foreground">{t('pipeline.log')}</Label>
<Label className="text-muted-foreground">{t('monitor:traceMgr.logs')}</Label>
<div className="mt-1 space-y-2">
{selectedSpan.logs.map((log, idx) => (
<div key={idx} className="p-2 bg-muted rounded text-sm">