远程 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 测试通过
289 lines
14 KiB
TypeScript
289 lines
14 KiB
TypeScript
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||
import { useParams, useNavigate } from 'react-router-dom';
|
||
import { Card, CardContent } from '@/components/ui/card';
|
||
import { Button } from '@/components/ui/button';
|
||
import {
|
||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||
} from '@/components/ui/select';
|
||
import { RefreshCw, Bell, AlertTriangle, X } from 'lucide-react';
|
||
import { kubernetesApi } from '@/api';
|
||
import { EventTable } from './EventTable';
|
||
import { normalizeK8sEvent, formatEventRelative } from './k8s-event-utils';
|
||
import { PageShell } from '@/components/layout/PageShell';
|
||
import { ContentPanel } from '@/components/layout/ContentPanel';
|
||
import { StatStrip } from '@/components/ui/stat-strip';
|
||
import type { JsonData } from '@/types/common';
|
||
import i18n from '@/i18n';
|
||
|
||
interface RankItem { name: string; count: number }
|
||
|
||
// 简洁排名列表,替代花哨柱状图 // Logan.Li
|
||
function RankPanel({
|
||
title, items, selected, onSelect, emptyText,
|
||
}: {
|
||
title: string;
|
||
items: RankItem[];
|
||
selected?: string;
|
||
onSelect?: (name: string) => void;
|
||
emptyText?: string;
|
||
}) {
|
||
const max = Math.max(...items.map((i) => i.count), 1);
|
||
return (
|
||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||
<CardContent className="p-4">
|
||
<h3 className="text-sm font-medium text-[var(--dg-text-primary)] mb-3">{title}</h3>
|
||
{items.length === 0 ? (
|
||
<p className="text-xs text-[var(--dg-text-muted)] py-6 text-center">{emptyText || i18n.t('aiops.assistantNoData')}</p>
|
||
) : (
|
||
<div className="space-y-1.5 max-h-[220px] overflow-y-auto pr-1">
|
||
{items.map((item) => {
|
||
const active = selected === item.name;
|
||
return (
|
||
<button
|
||
key={item.name}
|
||
type="button"
|
||
className={`w-full flex items-center gap-2 rounded px-2 py-1.5 text-left transition-colors ${
|
||
active ? 'bg-[var(--dg-bg-tertiary)]' : 'hover:bg-[var(--dg-bg-tertiary)]/60'
|
||
}`}
|
||
onClick={() => onSelect?.(active ? '' : item.name)}
|
||
>
|
||
<span className="w-[88px] shrink-0 truncate text-xs text-[var(--dg-text-secondary)]" title={item.name}>
|
||
{item.name}
|
||
</span>
|
||
<div className="flex-1 h-1.5 rounded-full bg-[var(--dg-bg-tertiary)] overflow-hidden">
|
||
<div
|
||
className={`h-full rounded-full ${active ? 'bg-[var(--dg-blue)]' : 'bg-[var(--dg-text-muted)]/50'}`}
|
||
style={{ width: `${(item.count / max) * 100}%` }}
|
||
/>
|
||
</div>
|
||
<span className="w-8 text-right text-xs tabular-nums text-[var(--dg-text-muted)]">{item.count}</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
export default function EventsPage() {
|
||
const { clusterCode } = useParams<{ clusterCode: string }>();
|
||
const navigate = useNavigate();
|
||
const [loading, setLoading] = useState(false);
|
||
const [events, setEvents] = useState<JsonData[]>([]);
|
||
const [clusters, setClusters] = useState<JsonData[]>([]);
|
||
const [namespaces, setNamespaces] = useState<string[]>([]);
|
||
const [selectedNs, setSelectedNs] = useState('all');
|
||
const [filterType, setFilterType] = useState('all');
|
||
const [timeWindow, setTimeWindow] = useState('0');
|
||
const [activeCluster, setActiveCluster] = useState(clusterCode || '');
|
||
const [summary, setSummary] = useState<JsonData>(null);
|
||
const [selectedReason, setSelectedReason] = useState('');
|
||
const [selectedKind, setSelectedKind] = useState('');
|
||
|
||
|
||
useEffect(() => {
|
||
kubernetesApi.getClusters().then((res: JsonData) => {
|
||
const list = res?.list || [];
|
||
setClusters(list);
|
||
if (!activeCluster && list.length > 0) {
|
||
const code = list[0].code || String(list[0].id);
|
||
setActiveCluster(code);
|
||
navigate(`/kubernetes/${code}/events`, { replace: true });
|
||
}
|
||
}).catch(() => {});
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (!activeCluster) return;
|
||
kubernetesApi.getNamespaces(activeCluster).then((res: JsonData) => {
|
||
setNamespaces(Array.isArray(res) ? res : (res?.list || res?.data || []));
|
||
}).catch(() => {});
|
||
}, [activeCluster]);
|
||
|
||
const loadEvents = useCallback(async () => {
|
||
if (!activeCluster) return;
|
||
setLoading(true);
|
||
try {
|
||
const ns = selectedNs === 'all' ? undefined : selectedNs;
|
||
const ft = filterType === 'all' ? undefined : filterType;
|
||
const res: JsonData = await kubernetesApi.getEvents(activeCluster, ns, ft);
|
||
setEvents(Array.isArray(res) ? res : (res?.list || []));
|
||
} catch { setEvents([]); } finally { setLoading(false); }
|
||
}, [activeCluster, selectedNs, filterType]);
|
||
|
||
const loadSummary = useCallback(async () => {
|
||
if (!activeCluster) return;
|
||
try {
|
||
const ns = selectedNs === 'all' ? undefined : selectedNs;
|
||
const hours = parseInt(timeWindow) || undefined;
|
||
const res: JsonData = await kubernetesApi.getEventsSummary(activeCluster, ns, hours);
|
||
setSummary(res || null);
|
||
} catch { setSummary(null); }
|
||
}, [activeCluster, selectedNs, timeWindow]);
|
||
|
||
useEffect(() => { loadEvents(); }, [loadEvents]);
|
||
useEffect(() => { loadSummary(); }, [loadSummary]);
|
||
|
||
const displayEvents = useMemo(() => {
|
||
let filtered = events.map((e) => normalizeK8sEvent(e));
|
||
const hours = parseInt(timeWindow, 10);
|
||
if (hours > 0) {
|
||
const since = Date.now() - hours * 3600 * 1000;
|
||
filtered = filtered.filter((e) => !e.lastTime || new Date(e.lastTime).getTime() >= since);
|
||
}
|
||
if (selectedReason) filtered = filtered.filter((e) => e.reason === selectedReason);
|
||
if (selectedKind) {
|
||
filtered = filtered.filter((e) =>
|
||
e.objectKind === selectedKind || e.object.startsWith(`${selectedKind}/`),
|
||
);
|
||
}
|
||
return filtered;
|
||
}, [events, selectedReason, selectedKind, timeWindow]);
|
||
|
||
const warningCount = useMemo(() => events.filter((e) => e.type === 'Warning').length, [events]);
|
||
const normalCount = useMemo(() => events.filter((e) => e.type === 'Normal').length, [events]);
|
||
|
||
const reasonData = useMemo<RankItem[]>(() =>
|
||
(summary?.by_reason || []).map((r: JsonData) => ({ name: r.reason || 'Unknown', count: r.count })),
|
||
[summary]);
|
||
|
||
const nsData = useMemo<RankItem[]>(() =>
|
||
(summary?.by_namespace || []).map((n: JsonData) => ({ name: n.namespace || '<cluster>', count: n.count })),
|
||
[summary]);
|
||
|
||
const kindData = useMemo<RankItem[]>(() =>
|
||
(summary?.by_kind || []).map((k: JsonData) => ({ name: k.kind || 'Unknown', count: k.count })),
|
||
[summary]);
|
||
|
||
const clusterName = clusters.find((c: JsonData) => (c.code || String(c.id)) === activeCluster)?.name;
|
||
|
||
const filterToolbar = (selectedReason || selectedKind) ? (
|
||
<div className="flex items-center gap-2 text-xs">
|
||
<span className="text-[var(--dg-text-muted)]"> {i18n.t('kubernetes.eventspagetsx.s841389c5')} </span>
|
||
{selectedReason && (
|
||
<button type="button" className="inline-flex items-center gap-1 rounded border border-[var(--dg-border)] px-2 py-0.5 text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-tertiary)]" onClick={() => setSelectedReason('')}>
|
||
原因 {selectedReason} <X className="h-3 w-3" />
|
||
</button>
|
||
)}
|
||
{selectedKind && (
|
||
<button type="button" className="inline-flex items-center gap-1 rounded border border-[var(--dg-border)] px-2 py-0.5 text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-tertiary)]" onClick={() => setSelectedKind('')}>
|
||
类型 {selectedKind} <X className="h-3 w-3" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
) : undefined;
|
||
|
||
return (
|
||
<PageShell
|
||
variant="list"
|
||
title={i18n.t('kubernetes.eventspagetsx.s08bf79b4')}
|
||
subtitle={clusterName ? `${clusterName} · 集群事件与告警` : i18n.t('kubernetes.eventspagetsx.s6bef2ba0')}
|
||
icon={<Bell className="h-5 w-5" />}
|
||
metrics={!loading ? (
|
||
<StatStrip
|
||
items={[
|
||
{ label: i18n.t('kubernetes.eventspagetsx.s0d3047da'), value: summary?.total ?? events.length, color: 'muted' },
|
||
{ label: i18n.t('kubernetes.eventspagetsx.s0e5b83cc'), value: summary?.warning_count ?? warningCount, color: 'amber' },
|
||
{ label: i18n.t('kubernetes.eventspagetsx.s349c629f'), value: summary?.normal_count ?? normalCount, color: 'green' },
|
||
{ label: i18n.t('kubernetes.eventspagetsx.sc62e34c5'), value: reasonData.length, color: 'red' },
|
||
]}
|
||
/>
|
||
) : undefined}
|
||
toolbar={filterToolbar}
|
||
actions={(
|
||
<>
|
||
<Select value={activeCluster} onValueChange={(v) => { setActiveCluster(v); navigate(`/kubernetes/${v}/events`); }}>
|
||
<SelectTrigger className="w-40 h-9 dg-input"><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
{clusters.map((c: JsonData) => (
|
||
<SelectItem key={c.id} value={c.code || String(c.id)}>{c.name || c.code}</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
<Select value={selectedNs} onValueChange={(v) => { setSelectedNs(v); setSelectedReason(''); setSelectedKind(''); }}>
|
||
<SelectTrigger className="w-36 h-9 dg-input"><SelectValue placeholder={i18n.t('kubernetes.namespace.title')} /></SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="all"> {i18n.t('kubernetes.podmetricspaget.s58173b22')} </SelectItem>
|
||
{namespaces.map((n) => <SelectItem key={n} value={n}>{n}</SelectItem>)}
|
||
</SelectContent>
|
||
</Select>
|
||
<Select value={timeWindow} onValueChange={setTimeWindow}>
|
||
<SelectTrigger className="w-32 h-9 dg-input"><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="0"> {i18n.t('kubernetes.eventspagetsx.s98c99f62')} </SelectItem>
|
||
<SelectItem value="1"> {i18n.t('monitor.helpersts.sf972b7b1')} </SelectItem>
|
||
<SelectItem value="6"> {i18n.t('monitor.siteProtocol.rangeDefault')} </SelectItem>
|
||
<SelectItem value="24"> {i18n.t('monitor.helpersts.s6e99b786')} </SelectItem>
|
||
<SelectItem value="168"> {i18n.t('monitor.helpersts.sd765efc4')} </SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
<Button variant="outline" size="sm" className="dg-btn-secondary" onClick={() => { loadEvents(); loadSummary(); }} disabled={loading}>
|
||
<RefreshCw className={`h-4 w-4 mr-1 ${loading ? 'animate-spin' : ''}`} />
|
||
刷新
|
||
</Button>
|
||
</>
|
||
)}
|
||
>
|
||
{reasonData.length > 0 && (
|
||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||
<RankPanel title={i18n.t('kubernetes.eventspagetsx.s8c894faa')} items={reasonData} selected={selectedReason} onSelect={setSelectedReason} />
|
||
{nsData.length > 0 && <RankPanel title={i18n.t('kubernetes.namespace.title')} items={nsData} />}
|
||
{kindData.length > 0 && (
|
||
<RankPanel title={i18n.t('kubernetes.eventspagetsx.s14871aad')} items={kindData} selected={selectedKind} onSelect={setSelectedKind} />
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{summary?.recent_warnings?.length > 0 && (
|
||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] border-l-2 border-l-[var(--dg-orange)]">
|
||
<CardContent className="p-4">
|
||
<h3 className="text-sm font-medium text-[var(--dg-text-primary)] mb-3 flex items-center gap-1.5">
|
||
<AlertTriangle className="h-4 w-4 text-[var(--dg-orange)]" />
|
||
最新告警 ({summary.recent_warnings.length})
|
||
</h3>
|
||
<div className="space-y-1">
|
||
{summary.recent_warnings.map((w: JsonData, i: number) => (
|
||
<div key={i} className="text-xs py-1.5 px-2 rounded hover:bg-[var(--dg-bg-tertiary)]/50">
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
<span className="shrink-0 font-mono text-[var(--dg-orange)]">{w.reason}</span>
|
||
<span className="text-[var(--dg-text-muted)] font-mono truncate">{w.object}</span>
|
||
<span className="text-[var(--dg-text-muted)]">{w.namespace}</span>
|
||
{w.source && <span className="text-[var(--dg-text-muted)] font-mono">{w.source}</span>}
|
||
<span className="text-[var(--dg-text-muted)]">×{w.count}</span>
|
||
<span className="ml-auto text-[var(--dg-text-muted)]">{formatEventRelative(w.last_time)}</span>
|
||
</div>
|
||
<p className="text-[var(--dg-text-secondary)] truncate mt-0.5" title={w.message}>{w.message}</p>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
<ContentPanel
|
||
toolbar={(
|
||
<div className="flex items-center justify-between w-full">
|
||
<span className="text-sm dg-text-secondary">事件列表 ({displayEvents.length})</span>
|
||
<Select value={filterType} onValueChange={setFilterType}>
|
||
<SelectTrigger className="w-28 h-8 dg-input text-xs"><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="all"> {i18n.t('kubernetes.eventspagetsx.sfb995166')} </SelectItem>
|
||
<SelectItem value="Warning">Warning</SelectItem>
|
||
<SelectItem value="Normal">Normal</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
)}
|
||
>
|
||
<EventTable
|
||
events={displayEvents}
|
||
loading={loading}
|
||
showNamespace
|
||
/>
|
||
</ContentPanel>
|
||
</PageShell>
|
||
);
|
||
} |