- Agent CLI: 多轮对话、SSH 长连接、流式推理、Evolver 按用户隔离、历史侧栏 - 终端审计: 回放播放器、命令书签、筛选分页导出、命令输出统计、录制落盘修复 - 修复 session_commands 表迁移、Kill 竞态导致录制丢失、无录制会话回放提示 - 审计历史页只读(移除删除),移除回放弹框录制搜索
304 lines
14 KiB
TypeScript
304 lines
14 KiB
TypeScript
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>
|
||
);
|
||
} |