Files
aiops/frontend/src/components/kubernetes/EventsPage.tsx
T
Logan 1acb0c49af feat: Agent CLI 与终端审计回放全面优化
- Agent CLI: 多轮对话、SSH 长连接、流式推理、Evolver 按用户隔离、历史侧栏
- 终端审计: 回放播放器、命令书签、筛选分页导出、命令输出统计、录制落盘修复
- 修复 session_commands 表迁移、Kill 竞态导致录制丢失、无录制会话回放提示
- 审计历史页只读(移除删除),移除回放弹框录制搜索
2026-07-01 12:13:53 +08:00

304 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useParams, useNavigate, Link } 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, ChevronRight, Bell, AlertTriangle, X } from 'lucide-react';
import { kubernetesApi } from '@/api';
import { EventTable } from './EventTable';
import { normalizeK8sEvent, formatEventRelative } from './k8s-event-utils';
import type { JsonData } from '@/types/common';
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 || '暂无数据'}</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>
);
}
function StatCell({ label, value, hint }: { label: string; value: number | string; hint?: string }) {
return (
<div className="px-4 py-3 border-r border-[var(--dg-border)] last:border-r-0">
<p className="text-xs text-[var(--dg-text-muted)]">{label}</p>
<p className="text-xl font-semibold tabular-nums text-[var(--dg-text-primary)] mt-0.5">{value}</p>
{hint && <p className="text-[10px] text-[var(--dg-text-muted)] mt-0.5">{hint}</p>}
</div>
);
}
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(() => {});
}, []);
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]);
return (
<div className="space-y-4">
<div className="flex items-center gap-2 text-sm text-[var(--dg-text-muted)]">
<Link to="/kubernetes" className="hover:text-[var(--dg-text-secondary)]">集群</Link>
{activeCluster && (
<>
<ChevronRight className="h-3 w-3" />
<Link to={`/kubernetes/${activeCluster}`} className="hover:text-[var(--dg-text-secondary)]">{activeCluster}</Link>
</>
)}
<ChevronRight className="h-3 w-3" />
<span className="text-[var(--dg-text-secondary)]">事件</span>
</div>
<div className="flex items-center justify-between flex-wrap gap-3">
<h2 className="text-lg font-semibold text-[var(--dg-text-primary)] flex items-center gap-2">
<Bell className="h-5 w-5 text-[var(--dg-text-muted)]" />
事件中心
</h2>
<div className="flex items-center gap-2 flex-wrap">
<Select value={activeCluster} onValueChange={(v) => { setActiveCluster(v); navigate(`/kubernetes/${v}/events`); }}>
<SelectTrigger className="w-40 h-8 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
{clusters.map((c: JsonData) => (
<SelectItem key={c.id} value={c.code || String(c.id)} className="text-xs">{c.name || c.code}</SelectItem>
))}
</SelectContent>
</Select>
<Select value={selectedNs} onValueChange={(v) => { setSelectedNs(v); setSelectedReason(''); setSelectedKind(''); }}>
<SelectTrigger className="w-36 h-8 text-xs"><SelectValue placeholder="命名空间" /></SelectTrigger>
<SelectContent>
<SelectItem value="all" className="text-xs">所有命名空间</SelectItem>
{namespaces.map((n) => <SelectItem key={n} value={n} className="text-xs">{n}</SelectItem>)}
</SelectContent>
</Select>
<Select value={timeWindow} onValueChange={setTimeWindow}>
<SelectTrigger className="w-32 h-8 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="0" className="text-xs">全部时间</SelectItem>
<SelectItem value="1" className="text-xs">近 1 小时</SelectItem>
<SelectItem value="6" className="text-xs">近 6 小时</SelectItem>
<SelectItem value="24" className="text-xs">近 24 小时</SelectItem>
<SelectItem value="168" className="text-xs">近 7 天</SelectItem>
</SelectContent>
</Select>
<Button variant="outline" size="sm" className="h-8 text-xs" onClick={() => { loadEvents(); loadSummary(); }} disabled={loading}>
<RefreshCw className={`h-3 w-3 mr-1 ${loading ? 'animate-spin' : ''}`} />
刷新
</Button>
</div>
</div>
{/* 统计条:统一边框,无彩色卡片 */}
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-0">
<div className="grid grid-cols-2 md:grid-cols-4">
<StatCell label="事件总数" value={summary?.total ?? events.length} />
<StatCell label="告警事件" value={summary?.warning_count ?? warningCount} hint="Warning" />
<StatCell label="正常事件" value={summary?.normal_count ?? normalCount} hint="Normal" />
<StatCell label="告警类型" value={reasonData.length} hint="不同 Reason" />
</div>
</CardContent>
</Card>
{(selectedReason || selectedKind) && (
<div className="flex items-center gap-2 text-xs">
<span className="text-[var(--dg-text-muted)]">筛选:</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>
)}
{reasonData.length > 0 && (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
<RankPanel title="告警原因" items={reasonData} selected={selectedReason} onSelect={setSelectedReason} />
{nsData.length > 0 && <RankPanel title="命名空间" items={nsData} />}
{kindData.length > 0 && (
<RankPanel title="资源类型" 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>
)}
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-0">
<div className="flex items-center justify-between px-4 py-2 border-b border-[var(--dg-border)]">
<span className="text-sm text-[var(--dg-text-secondary)]">事件列表 ({displayEvents.length})</span>
<Select value={filterType} onValueChange={setFilterType}>
<SelectTrigger className="w-28 h-7 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="all" className="text-xs">所有类型</SelectItem>
<SelectItem value="Warning" className="text-xs">Warning</SelectItem>
<SelectItem value="Normal" className="text-xs">Normal</SelectItem>
</SelectContent>
</Select>
</div>
<EventTable
events={displayEvents}
loading={loading}
showNamespace
/>
</CardContent>
</Card>
</div>
);
}