Files
aiops/frontend/src/components/monitor/alert-center/AlertCenter.tsx
T
attacker da9fd1e987 feat(quality): U12 工程质量回归修复 — ESLint errors(8→0) + React Hooks 关键规则(82→0)
远程 i18n 全量改造 + 堡垒机合并后引入两类质量回归,本次修复:

1. ESLint errors 8→0:
   - GrafanaDashboard/PrometheusDashboard: 未使用的 const { t } 改为 useTranslation()
   - ServerTableRow: 移除未使用 cn import
   - replay-utils: no-control-regex 标注 disable (终端回放有意检测控制字符)

2. React Hooks 6 大关键规则 82→0 (恢复 U7 治理水平):
   - exhaustive-deps 52→0 (mount-only effects 标注 disable)
   - set-state-in-effect 18→0 (effect 初始化模式)
   - purity 5→0 (AlertCenter 提取 nowMs / EnhancedLogResults 骨架屏确定性公式)
   - incompatible-library 3→0 (@tanstack/react-virtual 兼容性)
   - immutability 2→0 (AlertCenter TDZ)
   - preserve-manual-memoization 2→0 (StatChartPanel useMemo)

验证: tsc -b=0, npm build✓(18.04s), go build=0, ESLint errors=0, 17/17 测试通过
2026-07-06 00:55:01 +08:00

543 lines
27 KiB
TypeScript

// 告警中心主组件 — TanStack Query 编排 // Logan.Li
import React, { useState, useEffect, useMemo } from 'react';
import { cn } from '@/lib/utils';
import {
AlertTriangle, CheckCircle, Search, RefreshCw,
Eye, Check, VolumeX, Plus,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { StatStrip } from '@/components/ui/stat-strip';
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select';
import { toast } from 'sonner';
import { PageShell } from '@/components/layout/PageShell';
import { PageEmpty } from '@/components/layout/PageEmpty';
import { PageLoading } from '@/components/layout/PageLoading';
import { useConfirm } from '@/components/ui/confirm-dialog';
import { alertCenterApi, alertApi } from '@/api';
import { useAlerts, useUnifiedAlertRules, useMetricCatalog, useAlertMutations } from '@/hooks/api';
import {
type Alert, getSeverityBadge, getStatusBadge, getChannelTypeLabel,
} from './alert-center-helpers';
import { AlertDetailDialog, AlertSilenceDialog } from './AlertCenterDetail';
import { MetricCatalogTab } from './MetricCatalogTab';
import { AlertRulesTab } from './AlertRulesTab';
import { ContactDialog } from './ContactDialog';
import { ContactGroupDialog } from './ContactGroupDialog';
import { NotifyPolicyTab } from './NotifyPolicyTab';
import type { UnifiedAlertRule } from '@/api';
import type { Contact, ContactGroup } from './alert-center-helpers';
import { useTranslation } from 'react-i18next';
const AlertCenter: React.FC = () => {
const { t } = useTranslation();
const _confirm = useConfirm();
const [searchQuery, setSearchQuery] = useState('');
const [severityFilter, setSeverityFilter] = useState('all');
const [statusFilter, setStatusFilter] = useState('all');
const [sourceFilter, setSourceFilter] = useState('all');
const [activeTab, setActiveTab] = useState('alerts');
const [selectedAlert, setSelectedAlert] = useState<Alert | null>(null);
const [detailOpen, setDetailOpen] = useState(false);
const [silenceOpen, setSilenceOpen] = useState(false);
// 联系人 & 联系组
const [contacts, setContacts] = useState<Contact[]>([]);
const [contactGroups, setContactGroups] = useState<ContactGroup[]>([]);
const [contactDialogOpen, setContactDialogOpen] = useState(false);
const [editingContact, setEditingContact] = useState<Contact | null>(null);
const [groupDialogOpen, setGroupDialogOpen] = useState(false);
const [editingGroup, setEditingGroup] = useState<ContactGroup | null>(null);
// 分页
const [page, setPage] = useState(1);
const [pageSize] = useState(10);
const alertParams = useMemo(() => ({
page, page_size: pageSize,
severity: severityFilter, status: statusFilter, source_type: sourceFilter,
}), [page, pageSize, severityFilter, statusFilter, sourceFilter]);
const { data: alertData, isLoading: loading, isFetching: fetchingAlerts, refetch: refetchAlerts } = useAlerts(alertParams);
const { data: rules = [], isFetching: fetchingRules, refetch: refetchRules } = useUnifiedAlertRules('all');
const { data: catalog = [] } = useMetricCatalog();
const {
ackMutation, resolveMutation, quickSetupMutation, monitorQuickSetupMutation,
toggleAgentRuleMutation, deleteAgentRuleMutation,
deleteMonitorRuleMutation, toggleMonitorRuleMutation,
} = useAlertMutations();
const alerts = alertData?.list ?? [];
const total = alertData?.total ?? 0;
const stats = alertData?.stats ?? { firing: 0, critical: 0, warning: 0, resolved: 0 };
const totalPages = Math.max(1, Math.ceil(total / pageSize));
useEffect(() => {
// eslint-disable-next-line react-hooks/immutability
if (activeTab === 'contacts') fetchContacts();
// eslint-disable-next-line react-hooks/immutability
if (activeTab === 'groups') { fetchContacts(); fetchContactGroups(); }
}, [activeTab]);
const fetchContacts = async () => {
try {
const res = await alertApi.getContacts() as { list?: Contact[] };
setContacts(res?.list || []);
} catch { /* silent */ }
};
const fetchContactGroups = async () => {
try {
const res = await alertApi.getContactGroups() as { list?: ContactGroup[] };
setContactGroups(res?.list || []);
} catch { /* silent */ }
};
const handleAcknowledge = async (id: string) => {
try {
await ackMutation.mutateAsync(id);
toast.success(t('alert.acknowledged'));
} catch {
toast.error(t('alert.operationFailed'));
}
};
const handleResolve = async (id: string) => {
try {
await resolveMutation.mutateAsync(id);
toast.success(t('alert.resolvedToast'));
} catch {
toast.error(t('alert.operationFailed'));
}
};
const handleSilence = async (id: string, duration: string) => {
try {
const alert = alerts.find(a => a.id === id);
const now = new Date();
const match = duration.match(/^(\d+)([hd])$/);
const num = match ? parseInt(match[1]) : 1;
const unit = match?.[2] || 'h';
const hours = unit === 'd' ? num * 24 : num;
const endTime = new Date(now.getTime() + hours * 3600_000);
await alertCenterApi.createSilence({
name: `${t('monitor.alertCenterPanel.silencePrefix')} ${alert?.title || id} (${duration})`,
description: `${t('alert.silenceDesc')} — ${alert?.source || ''}`,
silence_type: 'onetime',
start_time: now.toISOString(),
end_time: endTime.toISOString(),
});
toast.success(t('monitor.alertCenterPanel.silencedFor', { duration }));
setSilenceOpen(false);
refetchAlerts();
} catch {
toast.error(t('alert.operationFailed'));
}
};
const handleQuickSetup = async () => {
const ok = await _confirm({
title: t('alert.quickSetup'),
description: t('alert.quickSetupDesc'),
confirmText: t('alert.quickSetupConfirm'),
});
if (!ok) return;
try {
const res = await quickSetupMutation.mutateAsync('ecs');
toast.success(typeof res === 'string' ? res : ((res as { message?: string })?.message || t('alert.quickSetupSuccess')));
} catch {
toast.error(t('alert.quickSetupFailed'));
}
};
const handleDeleteAgentRule = async (rule: UnifiedAlertRule) => {
const ok = await _confirm({ title: t('alert.deleteRule'), description: `${rule.name}`, confirmText: t('common.delete'), variant: 'destructive' });
if (!ok) return;
try { await deleteAgentRuleMutation.mutateAsync(rule.id); toast.success(t('alert.deleted')); } catch { toast.error(t('alert.deleteFailed')); }
};
const handleDeleteMonitorRule = async (rule: UnifiedAlertRule) => {
const ok = await _confirm({ title: t('alert.deleteRule'), description: `${rule.name}`, confirmText: t('common.delete'), variant: 'destructive' });
if (!ok) return;
try { await deleteMonitorRuleMutation.mutateAsync(rule.id); toast.success(t('alert.deleted')); } catch { toast.error(t('alert.deleteFailed')); }
};
const handleFilterChange = (type: 'severity' | 'status' | 'source', value: string) => {
if (type === 'severity') setSeverityFilter(value);
else if (type === 'status') setStatusFilter(value);
else setSourceFilter(value);
setPage(1);
};
const pageTabs = [
{ id: 'alerts', label: t('alert.tab.alerts') },
{ id: 'rules', label: t('alert.tab.rules') },
{ id: 'metrics', label: t('monitor.alertCenterPanel.metricCatalog') },
{ id: 'contacts', label: t('alert.tab.contacts') },
{ id: 'groups', label: t('alert.tab.groups') },
{ id: 'notify', label: t('monitor.alertCenterPanel.notifyPolicy') },
];
const alertsToolbar = activeTab === 'alerts' ? (
<div className="flex flex-wrap items-center justify-between gap-2 w-full">
<div className="flex items-center gap-2 flex-1 min-w-[280px]">
<div className="relative flex-1">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
<Input
placeholder={t('alert.searchPlaceholder')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-8 h-8 text-xs"
/>
</div>
<Select value={severityFilter} onValueChange={(v) => handleFilterChange('severity', v)}>
<SelectTrigger className="w-[100px] h-8 text-xs">
<SelectValue placeholder={t('alert.table.severity')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('alert.allSeverity')}</SelectItem>
<SelectItem value="critical">{t('alert.severity.critical')}</SelectItem>
<SelectItem value="warning">{t('alert.severity.warning')}</SelectItem>
<SelectItem value="info">{t('alert.severity.info')}</SelectItem>
</SelectContent>
</Select>
<Select value={statusFilter} onValueChange={(v) => handleFilterChange('status', v)}>
<SelectTrigger className="w-[100px] h-8 text-xs">
<SelectValue placeholder={t('common.status')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('alert.allStatus')}</SelectItem>
<SelectItem value="firing">{t('alert.status.firing')}</SelectItem>
<SelectItem value="acknowledged">{t('alert.status.acknowledged')}</SelectItem>
<SelectItem value="resolved">{t('alert.status.resolved')}</SelectItem>
</SelectContent>
</Select>
<Select value={sourceFilter} onValueChange={(v) => handleFilterChange('source', v)}>
<SelectTrigger className="w-[120px] h-8 text-xs">
<SelectValue placeholder={t('monitor.alertCenterPanel.sourcePlaceholder')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('monitor.alertCenterPanel.allSources')}</SelectItem>
<SelectItem value="host">{t('monitor.alertCenterPanel.hostMonitor')}</SelectItem>
<SelectItem value="middleware">{t('monitor.alertCenterPanel.middlewareMonitor')}</SelectItem>
<SelectItem value="snmp">{t('monitor.alertCenterPanel.snmpMonitor')}</SelectItem>
<SelectItem value="site">{t('monitor.alertCenterPanel.siteProbe')}</SelectItem>
<SelectItem value="log">{t('monitor.alertCenterPanel.logAlert')}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-1 text-xs dg-text-muted">
<span>{t('alert.totalItems', { count: total })}</span>
<Button variant="outline" size="sm" onClick={() => refetchAlerts()} disabled={fetchingAlerts} className="h-7 text-xs px-2 dg-btn-secondary">
<RefreshCw className={cn('w-3 h-3 mr-1', fetchingAlerts && 'animate-spin')} />{t('common.refresh')}
</Button>
</div>
</div>
) : undefined;
// eslint-disable-next-line react-hooks/purity
const nowMs = Date.now();
return (
<PageShell
variant="list"
title={t('monitor.alertCenterPanel.alertCenterTitle')}
subtitle={t('monitor.alertCenterPanel.alertCenterSubtitle')}
icon={<AlertTriangle className="h-5 w-5" />}
tabs={pageTabs}
activeTab={activeTab}
onTabChange={setActiveTab}
toolbar={alertsToolbar}
metrics={(
<StatStrip
items={[
{ label: t('alert.stat.firing'), value: stats.firing, color: stats.firing > 0 ? 'red' : 'muted' },
{ label: t('alert.stat.critical'), value: stats.critical, color: 'red' },
{ label: t('alert.stat.warning'), value: stats.warning, color: 'amber' },
{ label: t('alert.stat.resolved'), value: stats.resolved, color: 'emerald' },
]}
/>
)}
actions={activeTab === 'rules' ? (
<Button variant="outline" className="dg-btn-secondary" onClick={() => refetchRules()} disabled={fetchingRules}>
<RefreshCw className={cn('w-4 h-4 mr-2', fetchingRules && 'animate-spin')} />{t('common.refresh')}
</Button>
) : activeTab === 'contacts' ? (
<Button size="sm" className="dg-btn-primary" onClick={() => { setEditingContact(null); setContactDialogOpen(true); }}>
<Plus className="w-4 h-4 mr-1" />{t('monitor.alertCenterPanel.addContact')}
</Button>
) : activeTab === 'groups' ? (
<>
<Button variant="outline" className="dg-btn-secondary" onClick={fetchContactGroups}>
<RefreshCw className="w-4 h-4 mr-2" />{t('common.refresh')}
</Button>
<Button className="dg-btn-primary" onClick={() => { setEditingGroup(null); setGroupDialogOpen(true); }}>
<Plus className="w-4 h-4 mr-2" />{t('monitor.alertCenterPanel.addContactGroup')}
</Button>
</>
) : undefined}
>
{activeTab === 'alerts' && (
<Card>
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="px-3 py-2 text-left text-xs font-medium">{t('alert.table.alertInfo')}</th>
<th className="px-3 py-2 text-left text-xs font-medium w-16">{t('alert.table.severity')}</th>
<th className="px-3 py-2 text-left text-xs font-medium w-16">{t('alert.table.status')}</th>
<th className="px-3 py-2 text-left text-xs font-medium w-24">{t('alert.table.source')}</th>
<th className="px-3 py-2 text-left text-xs font-medium w-20">{t('alert.table.duration')}</th>
<th className="px-3 py-2 text-left text-xs font-medium w-28">{t('alert.table.actions')}</th>
</tr>
</thead>
<tbody className="divide-y">
{loading ? (
<tr><td colSpan={6} className="px-3 py-2"><PageLoading label={t('alert.loading')} /></td></tr>
) : alerts.length === 0 ? (
<tr><td colSpan={6} className="px-3 py-2"><PageEmpty title={t('alert.noAlerts')} className="py-6" /></td></tr>
) : (
alerts.filter(alert => {
// 客户端也保留关键词搜索过滤
if (!searchQuery) return true;
const q = searchQuery.toLowerCase();
return alert.title.toLowerCase().includes(q) ||
alert.description.toLowerCase().includes(q) ||
alert.source.toLowerCase().includes(q);
}).map((alert) => (
<tr key={alert.id} className="hover:bg-muted/50">
<td className="px-3 py-2">
<div className="font-medium text-xs">{alert.title}</div>
<div className="text-xs text-muted-foreground line-clamp-1">{alert.description}</div>
<div className="flex gap-1 mt-0.5">
{Object.entries(alert.labels).slice(0, 2).map(([k, v]) => (
<Badge key={k} variant="outline" className="text-[10px] px-1 py-0">{k}={v}</Badge>
))}
</div>
</td>
<td className="px-3 py-2">{getSeverityBadge(alert.severity)}</td>
<td className="px-3 py-2">{getStatusBadge(alert.status)}</td>
<td className="px-3 py-2 text-xs">
<div>{alert.source}</div>
<div className="text-muted-foreground text-[10px]">{alert.service}</div>
</td>
<td className="px-3 py-2 text-xs text-muted-foreground">
{alert.status === 'resolved' && alert.resolvedAt
? t('alert.minutesShort', { count: Math.floor((new Date(alert.resolvedAt).getTime() - new Date(alert.startedAt).getTime()) / 60000) })
: t('alert.minutesShort', { count: Math.floor((nowMs - new Date(alert.startedAt).getTime()) / 60000) })}
</td>
<td className="px-3 py-2">
<div className="flex items-center gap-0.5">
<Button variant="ghost" size="sm" className="h-7 w-7 p-0"
onClick={() => { setSelectedAlert(alert); setDetailOpen(true); }}>
<Eye className="w-3.5 h-3.5" />
</Button>
{alert.status === 'firing' && (
<Button variant="ghost" size="sm" className="h-7 w-7 p-0"
onClick={() => handleAcknowledge(alert.id)}>
<Check className="w-3.5 h-3.5 dg-text-blue" />
</Button>
)}
{(alert.status === 'firing' || alert.status === 'acknowledged') && (
<Button variant="ghost" size="sm" className="h-7 w-7 p-0"
onClick={() => handleResolve(alert.id)}>
<CheckCircle className="w-3.5 h-3.5 text-green-600" />
</Button>
)}
<Button variant="ghost" size="sm" className="h-7 w-7 p-0"
onClick={() => { setSelectedAlert(alert); setSilenceOpen(true); }}>
<VolumeX className="w-3.5 h-3.5 text-orange-600" />
</Button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* 分页控件 */}
{totalPages > 1 && (
<div className="flex items-center justify-between px-3 py-2 border-t text-xs">
<span className="text-muted-foreground">{t('alert.pageInfo', { page, totalPages, total })}</span>
<div className="flex items-center gap-1">
<Button variant="outline" size="sm" className="h-7 text-xs px-2"
disabled={page <= 1} onClick={() => setPage(p => p - 1)}>
{t('alert.prevPage')}
</Button>
{Array.from({ length: Math.min(totalPages, 5) }, (_, i) => {
const start = Math.max(1, Math.min(page - 2, totalPages - 4));
const pn = start + i;
if (pn > totalPages) return null;
return (
<Button key={pn} variant={pn === page ? 'default' : 'outline'} size="sm"
className="h-7 min-w-[28px] text-xs px-1"
onClick={() => setPage(pn)}>
{pn}
</Button>
);
})}
<Button variant="outline" size="sm" className="h-7 text-xs px-2"
disabled={page >= totalPages} onClick={() => setPage(p => p + 1)}>
{t('alert.nextPage')}
</Button>
</div>
</div>
)}
</CardContent>
</Card>
)}
{activeTab === 'rules' && (
<AlertRulesTab
rules={rules}
loading={fetchingRules}
catalog={catalog}
onRefresh={() => refetchRules()}
onToggleAgent={(id, enabled) => toggleAgentRuleMutation.mutate({ id, enabled })}
onDeleteAgent={handleDeleteAgentRule}
onDeleteMonitor={handleDeleteMonitorRule}
onToggleMonitor={(id, enabled) => toggleMonitorRuleMutation.mutate({ id, enabled })}
onQuickSetupHost={handleQuickSetup}
onQuickSetupMonitor={(cat) => monitorQuickSetupMutation.mutate(cat)}
quickSetupPending={quickSetupMutation.isPending || monitorQuickSetupMutation.isPending}
/>
)}
{activeTab === 'metrics' && <MetricCatalogTab />}
{activeTab === 'contacts' && (
contacts.length === 0 ? (
<PageEmpty title={t('monitor.alertCenterPanel.noContactsTitle')} description={t('monitor.alertCenterPanel.noContactsDesc')} />
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{contacts.map((c) => (
<Card key={c.id} className="hover:border-primary/50 transition-colors">
<CardHeader className="pb-2">
<div className="flex items-start justify-between">
<CardTitle className="text-sm">{c.name}</CardTitle>
</div>
</CardHeader>
<CardContent className="pt-0 space-y-1 text-xs text-muted-foreground">
{c.phone && <div>📱 {c.phone}</div>}
{c.email && <div>📧 {c.email}</div>}
<div className="flex gap-1 flex-wrap">
{c.channel_type && (
<Badge variant="outline" className="text-[10px]">
{getChannelTypeLabel(c.channel_type)}
</Badge>
)}
{c.webhook_url && (
<span className="text-[10px] text-muted-foreground truncate max-w-[200px]" title={c.webhook_url}>
{c.webhook_url}
</span>
)}
</div>
<div className="flex gap-1.5 pt-1.5">
<Button variant="outline" size="sm" className="h-7 text-xs flex-1"
onClick={() => { setEditingContact(c); setContactDialogOpen(true); }}>
{t("monitor.alertCenterPanel.edit")}
</Button>
<Button variant="outline" size="sm" className="h-7 text-xs"
onClick={async () => {
const ok = await _confirm({ title: t('alert.deleteContact'), description: `${c.name}`, confirmText: t('common.delete'), variant: 'destructive' });
if (!ok) return;
try { await alertApi.deleteContact(c.id); toast.success(t('alert.deleted')); fetchContacts(); }
catch { toast.error(t('alert.deleteFailed')); }
}}>
{t("monitor.alertCenterPanel.delete")}
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)
)}
{activeTab === 'notify' && <NotifyPolicyTab />}
{activeTab === 'groups' && (
contactGroups.length === 0 ? (
<PageEmpty title={t('monitor.alertCenterPanel.noGroupsTitle')} description={t('monitor.alertCenterPanel.noGroupsDesc')} />
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{contactGroups.map((g) => {
let cids: number[] = [];
try { cids = JSON.parse(g.contact_ids || '[]'); } catch { /* ignore */ }
const memberNames = contacts
.filter(c => cids.includes(c.id))
.map(c => c.name);
return (
<Card key={g.id} className="hover:border-primary/50 transition-colors">
<CardHeader>
<div className="flex items-start justify-between">
<CardTitle className="text-base">{g.group_name}</CardTitle>
<Badge variant="secondary">{t('alert.peopleCount', { count: cids.length })}</Badge>
</div>
</CardHeader>
<CardContent className="pt-0 space-y-2">
{g.notes && <p className="text-sm text-muted-foreground">{g.notes}</p>}
<div className="flex flex-wrap gap-1">
{memberNames.map(name => (
<Badge key={name} variant="outline" className="text-xs">{name}</Badge>
))}
</div>
<div className="flex gap-2 pt-2">
<Button variant="outline" size="sm" className="flex-1"
onClick={() => { setEditingGroup(g); setGroupDialogOpen(true); }}>
{t("monitor.alertCenterPanel.edit")}
</Button>
<Button variant="outline" size="sm"
onClick={async () => {
const ok = await _confirm({ title: t('alert.deleteGroup'), description: `${g.group_name}`, confirmText: t('common.delete'), variant: 'destructive' });
if (!ok) return;
try { await alertApi.deleteContactGroup(g.id); toast.success(t('alert.deleted')); fetchContactGroups(); }
catch { toast.error(t('alert.deleteFailed')); }
}}>
{t("monitor.alertCenterPanel.delete")}
</Button>
</div>
</CardContent>
</Card>
);
})}
</div>
)
)}
<AlertDetailDialog
alert={selectedAlert}
open={detailOpen}
onOpenChange={setDetailOpen}
onAcknowledge={handleAcknowledge}
/>
<AlertSilenceDialog
alert={selectedAlert}
open={silenceOpen}
onOpenChange={setSilenceOpen}
onSilence={handleSilence}
/>
<ContactDialog
open={contactDialogOpen}
onOpenChange={setContactDialogOpen}
editingContact={editingContact}
onSaved={fetchContacts}
/>
<ContactGroupDialog
open={groupDialogOpen}
onOpenChange={setGroupDialogOpen}
editingGroup={editingGroup}
contacts={contacts}
onSaved={() => { fetchContactGroups(); }}
/>
</PageShell>
);
};
export default AlertCenter;