对齐 Vue 后台惯例: - src/api/ 域 API(原 services/api) - src/utils/request.ts HTTP 封装(原 services/http.ts) - src/utils/export.ts 客户端导出工具 - src/utils/index.ts 工具统一出口 全局导入改为 @/api 与 @/utils/request,tsc + build 通过。
438 lines
22 KiB
TypeScript
438 lines
22 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 { Badge } from '@/components/ui/badge';
|
||
import {
|
||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||
} from '@/components/ui/select';
|
||
import {
|
||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||
} from '@/components/ui/table';
|
||
import {
|
||
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
|
||
Cell,
|
||
} from 'recharts';
|
||
import { RefreshCw, ChevronRight, Bell, AlertTriangle, Info, Activity, Server, Layers } from 'lucide-react';
|
||
import { kubernetesApi } from '@/api';
|
||
|
||
// Color palette for charts
|
||
const CHART_COLORS = ['#EF4444', '#F59E0B', '#3B82F6', '#8B5CF6', '#EC4899', '#14B8A6', '#F97316', '#6366F1', '#84CC16', '#06B6D4'];
|
||
|
||
export default function EventsPage() {
|
||
const { clusterCode } = useParams<{ clusterCode: string }>();
|
||
const navigate = useNavigate();
|
||
const [loading, setLoading] = useState(false);
|
||
const [events, setEvents] = useState<any[]>([]);
|
||
const [clusters, setClusters] = useState<any[]>([]);
|
||
const [namespaces, setNamespaces] = useState<string[]>([]);
|
||
const [selectedNs, setSelectedNs] = useState('all');
|
||
const [filterType, setFilterType] = useState('all');
|
||
const [timeWindow, setTimeWindow] = useState('0'); // 0 = all
|
||
const [activeCluster, setActiveCluster] = useState(clusterCode || '');
|
||
|
||
// Aggregation data
|
||
const [summary, setSummary] = useState<any>(null);
|
||
const [selectedReason, setSelectedReason] = useState<string>('');
|
||
const [selectedKind, setSelectedKind] = useState<string>('');
|
||
|
||
// Expanded row
|
||
const [expandedRow, setExpandedRow] = useState<string | null>(null);
|
||
|
||
useEffect(() => {
|
||
kubernetesApi.getClusters().then(( res: any) => {
|
||
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: any) => {
|
||
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: Record<string, any> = 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: Record<string, any> = await kubernetesApi.getEventsSummary(activeCluster, ns, hours);
|
||
setSummary(res || null);
|
||
} catch { setSummary(null); }
|
||
}, [activeCluster, selectedNs, timeWindow]);
|
||
|
||
useEffect(() => { loadEvents(); }, [loadEvents]);
|
||
useEffect(() => { loadSummary(); }, [loadSummary]);
|
||
|
||
// Filter events by selected reason/kind from aggregation click
|
||
const displayEvents = useMemo(() => {
|
||
let filtered = events;
|
||
if (selectedReason) {
|
||
filtered = filtered.filter(e => e.reason === selectedReason);
|
||
}
|
||
if (selectedKind) {
|
||
filtered = filtered.filter(e => (e.object || '').startsWith(selectedKind + '/'));
|
||
}
|
||
return filtered;
|
||
}, [events, selectedReason, selectedKind]);
|
||
|
||
const warningCount = useMemo(() => events.filter(e => e.type === 'Warning').length, [events]);
|
||
const normalCount = useMemo(() => events.filter(e => e.type === 'Normal').length, [events]);
|
||
|
||
// Chart data from summary
|
||
const reasonData = useMemo(() => {
|
||
const list = summary?.by_reason || [];
|
||
return list.map(( r: any) => ({ name: r.reason || 'Unknown', count: r.count }));
|
||
}, [summary]);
|
||
|
||
const nsData = useMemo(() => {
|
||
const list = summary?.by_namespace || [];
|
||
return list.map(( n: any) => ({ name: n.namespace || '<cluster>', count: n.count }));
|
||
}, [summary]);
|
||
|
||
const kindData = useMemo(() => {
|
||
const list = summary?.by_kind || [];
|
||
return list.map(( k: any) => ({ name: k.kind || 'Unknown', count: k.count }));
|
||
}, [summary]);
|
||
|
||
const formatTime = (ts: string) => {
|
||
if (!ts) return '-';
|
||
return ts.replace('T', ' ').substring(0, 19);
|
||
};
|
||
|
||
const formatRelative = (ts: string) => {
|
||
if (!ts) return '';
|
||
const diff = Date.now() - new Date(ts).getTime();
|
||
if (diff < 0) return '';
|
||
const mins = Math.floor(diff / 60000);
|
||
if (mins < 1) return '刚刚';
|
||
if (mins < 60) return `${mins}分钟前`;
|
||
const hrs = Math.floor(mins / 60);
|
||
if (hrs < 24) return `${hrs}小时前`;
|
||
return `${Math.floor(hrs / 24)}天前`;
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
{/* Breadcrumb */}
|
||
<div className="flex items-center gap-2 text-sm text-slate-500">
|
||
<Link to="/kubernetes" className="hover:text-slate-300">集群</Link>
|
||
{activeCluster && <><ChevronRight className="h-3 w-3" /><Link to={`/kubernetes/${activeCluster}`} className="hover:text-slate-300">{activeCluster}</Link></>}
|
||
<ChevronRight className="h-3 w-3" /><span className="text-slate-300">事件</span>
|
||
</div>
|
||
|
||
{/* Header + Filters */}
|
||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||
<h2 className="text-lg font-semibold text-slate-100 flex items-center gap-2">
|
||
<Bell className="h-5 w-5 text-yellow-400" />事件中心
|
||
</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 bg-slate-800 border-slate-700"><SelectValue /></SelectTrigger>
|
||
<SelectContent className="bg-slate-800 border-slate-700">
|
||
{clusters.map(( c: any) => <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 bg-slate-800 border-slate-700"><SelectValue placeholder="命名空间" /></SelectTrigger>
|
||
<SelectContent className="bg-slate-800 border-slate-700">
|
||
<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={v => setTimeWindow(v)}>
|
||
<SelectTrigger className="w-32 h-8 text-xs bg-slate-800 border-slate-700"><SelectValue /></SelectTrigger>
|
||
<SelectContent className="bg-slate-800 border-slate-700">
|
||
<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 border-slate-700 text-slate-300 text-xs" onClick={() => { loadEvents(); loadSummary(); }} disabled={loading}>
|
||
<RefreshCw className={`h-3 w-3 mr-1 ${loading ? 'animate-spin' : ''}`} />刷新
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Summary Stats Cards */}
|
||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||
<Card className="bg-slate-900 border-slate-800">
|
||
<CardContent className="p-4">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<p className="text-xs text-slate-500 mb-1">事件总数</p>
|
||
<p className="text-2xl font-bold text-slate-100">{summary?.total ?? events.length}</p>
|
||
</div>
|
||
<Activity className="h-8 w-8 text-slate-600" />
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
<Card className="bg-slate-900 border-red-900/40">
|
||
<CardContent className="p-4">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<p className="text-xs text-slate-500 mb-1">告警事件</p>
|
||
<p className="text-2xl font-bold text-red-400">{summary?.warning_count ?? warningCount}</p>
|
||
<p className="text-[10px] text-slate-600 mt-0.5">Warning 累计触发次数</p>
|
||
</div>
|
||
<AlertTriangle className="h-8 w-8 text-red-500/30" />
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
<Card className="bg-slate-900 border-emerald-900/40">
|
||
<CardContent className="p-4">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<p className="text-xs text-slate-500 mb-1">正常事件</p>
|
||
<p className="text-2xl font-bold text-emerald-400">{summary?.normal_count ?? normalCount}</p>
|
||
</div>
|
||
<Info className="h-8 w-8 text-emerald-500/30" />
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
<Card className="bg-slate-900 border-slate-800">
|
||
<CardContent className="p-4">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<p className="text-xs text-slate-500 mb-1">告警类型数</p>
|
||
<p className="text-2xl font-bold text-amber-400">{reasonData.length}</p>
|
||
<p className="text-[10px] text-slate-600 mt-0.5">不同 Reason 数量</p>
|
||
</div>
|
||
<Layers className="h-8 w-8 text-amber-500/30" />
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
|
||
{/* Active Filter Badge */}
|
||
{(selectedReason || selectedKind) && (
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-xs text-slate-500">已选过滤:</span>
|
||
{selectedReason && (
|
||
<Badge className="bg-amber-500/20 text-amber-300 border-amber-500/30 cursor-pointer text-xs" onClick={() => setSelectedReason('')}>
|
||
原因: {selectedReason} ✕
|
||
</Badge>
|
||
)}
|
||
{selectedKind && (
|
||
<Badge className="bg-blue-500/20 text-blue-300 border-blue-500/30 cursor-pointer text-xs" onClick={() => setSelectedKind('')}>
|
||
类型: {selectedKind} ✕
|
||
</Badge>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Charts Row */}
|
||
{reasonData.length > 0 && (
|
||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||
{/* By Reason Chart */}
|
||
<Card className="bg-slate-900 border-slate-800">
|
||
<CardContent className="p-4">
|
||
<h3 className="text-sm font-medium text-slate-300 mb-3 flex items-center gap-1.5">
|
||
<AlertTriangle className="h-4 w-4 text-amber-400" />告警原因 Top {reasonData.length}
|
||
</h3>
|
||
<ResponsiveContainer width="100%" height={220}>
|
||
<BarChart data={reasonData} layout="vertical" margin={{ left: 10, right: 10, top: 0, bottom: 0 }}>
|
||
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" horizontal={false} />
|
||
<XAxis type="number" tick={{ fill: '#64748b', fontSize: 10 }} axisLine={{ stroke: '#334155' }} />
|
||
<YAxis type="category" dataKey="name" tick={{ fill: '#94a3b8', fontSize: 10 }} width={100} axisLine={{ stroke: '#334155' }} />
|
||
<Tooltip
|
||
contentStyle={{ background: '#1e293b', border: '1px solid #334155', borderRadius: '6px', fontSize: '12px' }}
|
||
labelStyle={{ color: '#e2e8f0' }}
|
||
/>
|
||
<Bar dataKey="count" radius={[0, 4, 4, 0]} cursor="pointer" onClick={( d: any) => setSelectedReason(prev => prev === d?.name ? '' : d?.name)}>
|
||
{reasonData.map(( _: any, i: number) => (
|
||
<Cell key={i} fill={CHART_COLORS[i % CHART_COLORS.length]} />
|
||
))}
|
||
</Bar>
|
||
</BarChart>
|
||
</ResponsiveContainer>
|
||
<p className="text-[10px] text-slate-600 text-center mt-1">点击柱状图过滤事件列表</p>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* By Namespace Chart */}
|
||
{nsData.length > 0 && (
|
||
<Card className="bg-slate-900 border-slate-800">
|
||
<CardContent className="p-4">
|
||
<h3 className="text-sm font-medium text-slate-300 mb-3 flex items-center gap-1.5">
|
||
<Server className="h-4 w-4 text-blue-400" />命名空间分布 Top {nsData.length}
|
||
</h3>
|
||
<ResponsiveContainer width="100%" height={220}>
|
||
<BarChart data={nsData} layout="vertical" margin={{ left: 10, right: 10, top: 0, bottom: 0 }}>
|
||
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" horizontal={false} />
|
||
<XAxis type="number" tick={{ fill: '#64748b', fontSize: 10 }} axisLine={{ stroke: '#334155' }} />
|
||
<YAxis type="category" dataKey="name" tick={{ fill: '#94a3b8', fontSize: 10 }} width={80} axisLine={{ stroke: '#334155' }} />
|
||
<Tooltip
|
||
contentStyle={{ background: '#1e293b', border: '1px solid #334155', borderRadius: '6px', fontSize: '12px' }}
|
||
labelStyle={{ color: '#e2e8f0' }}
|
||
/>
|
||
<Bar dataKey="count" radius={[0, 4, 4, 0]} fill="#3B82F6" fillOpacity={0.7} />
|
||
</BarChart>
|
||
</ResponsiveContainer>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
{/* By Kind Chart */}
|
||
{kindData.length > 0 && (
|
||
<Card className="bg-slate-900 border-slate-800">
|
||
<CardContent className="p-4">
|
||
<h3 className="text-sm font-medium text-slate-300 mb-3 flex items-center gap-1.5">
|
||
<Layers className="h-4 w-4 text-purple-400" />资源类型分布
|
||
</h3>
|
||
<ResponsiveContainer width="100%" height={220}>
|
||
<BarChart data={kindData} layout="vertical" margin={{ left: 10, right: 10, top: 0, bottom: 0 }}>
|
||
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" horizontal={false} />
|
||
<XAxis type="number" tick={{ fill: '#64748b', fontSize: 10 }} axisLine={{ stroke: '#334155' }} />
|
||
<YAxis type="category" dataKey="name" tick={{ fill: '#94a3b8', fontSize: 10 }} width={80} axisLine={{ stroke: '#334155' }} />
|
||
<Tooltip
|
||
contentStyle={{ background: '#1e293b', border: '1px solid #334155', borderRadius: '6px', fontSize: '12px' }}
|
||
labelStyle={{ color: '#e2e8f0' }}
|
||
/>
|
||
<Bar dataKey="count" radius={[0, 4, 4, 0]} cursor="pointer" onClick={( d: any) => setSelectedKind(prev => prev === d?.name ? '' : d?.name)}>
|
||
{kindData.map(( _: any, i: number) => (
|
||
<Cell key={i} fill={['#8B5CF6', '#6366F1', '#EC4899', '#14B8A6'][i % 4]} fillOpacity={0.7} />
|
||
))}
|
||
</Bar>
|
||
</BarChart>
|
||
</ResponsiveContainer>
|
||
<p className="text-[10px] text-slate-600 text-center mt-1">点击柱状图过滤事件列表</p>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Recent Warnings Alert Panel */}
|
||
{summary?.recent_warnings?.length > 0 && (
|
||
<Card className="bg-red-950/20 border-red-900/30">
|
||
<CardContent className="p-4">
|
||
<h3 className="text-sm font-medium text-red-300 mb-3 flex items-center gap-1.5">
|
||
<AlertTriangle className="h-4 w-4" />最新告警事件 ({summary.recent_warnings.length})
|
||
</h3>
|
||
<div className="space-y-1.5">
|
||
{summary.recent_warnings.map(( w: any, i: number) => (
|
||
<div key={i} className="flex items-start gap-2 text-xs py-1.5 px-2 rounded bg-slate-900/40 hover:bg-slate-800/40 transition-colors">
|
||
<AlertTriangle className="h-3.5 w-3.5 text-red-400 mt-0.5 shrink-0" />
|
||
<div className="flex-1 min-w-0">
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
<Badge variant="outline" className="text-[10px] h-4 px-1 border-red-500/30 text-red-400">{w.reason}</Badge>
|
||
<span className="text-slate-500 font-mono text-[11px]">{w.object}</span>
|
||
<span className="text-slate-600 text-[10px]">{w.namespace}</span>
|
||
<span className="text-slate-600 text-[10px]">×{w.count}</span>
|
||
<span className="text-slate-600 text-[10px] ml-auto">{formatRelative(w.last_time)}</span>
|
||
</div>
|
||
<p className="text-slate-400 mt-0.5 truncate" title={w.message}>{w.message}</p>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
{/* Events Table */}
|
||
<Card className="bg-slate-900 border-slate-800">
|
||
<CardContent className="p-0">
|
||
<div className="flex items-center justify-between px-4 py-2 border-b border-slate-800">
|
||
<span className="text-sm text-slate-400">事件列表 ({displayEvents.length})</span>
|
||
<Select value={filterType} onValueChange={setFilterType}>
|
||
<SelectTrigger className="w-28 h-7 text-xs bg-slate-800 border-slate-700"><SelectValue /></SelectTrigger>
|
||
<SelectContent className="bg-slate-800 border-slate-700">
|
||
<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>
|
||
<Table>
|
||
<TableHeader><TableRow className="border-slate-800 hover:bg-transparent">
|
||
<TableHead className="text-slate-400 w-16">类型</TableHead>
|
||
<TableHead className="text-slate-400 w-24">原因</TableHead>
|
||
<TableHead className="text-slate-400">对象</TableHead>
|
||
<TableHead className="text-slate-400">消息</TableHead>
|
||
<TableHead className="text-slate-400 w-12">次数</TableHead>
|
||
<TableHead className="text-slate-400 w-20">命名空间</TableHead>
|
||
<TableHead className="text-slate-400 w-40">最后时间</TableHead>
|
||
</TableRow></TableHeader>
|
||
<TableBody>
|
||
{displayEvents.length === 0 ? (
|
||
<TableRow><TableCell colSpan={7} className="text-center text-slate-500 py-8">
|
||
{loading ? <span className="animate-spin inline-block">⏳</span> : '暂无事件'}
|
||
</TableCell></TableRow>
|
||
) : displayEvents.slice(0, 200).map((e, i) => {
|
||
const rowKey = `${e.name}-${i}`;
|
||
const isExpanded = expandedRow === rowKey;
|
||
return (
|
||
<TableRow
|
||
key={rowKey}
|
||
className={`border-slate-800 cursor-pointer ${e.type === 'Warning' ? 'hover:bg-red-950/10' : 'hover:bg-slate-800/30'} ${isExpanded ? 'bg-slate-800/20' : ''}`}
|
||
onClick={() => setExpandedRow(isExpanded ? null : rowKey)}
|
||
>
|
||
<TableCell>
|
||
{e.type === 'Warning' ? (
|
||
<Badge className="bg-red-500/20 text-red-400 border-red-500/30 text-xs"><AlertTriangle className="h-3 w-3 mr-0.5 inline" />W</Badge>
|
||
) : (
|
||
<Badge className="bg-blue-500/20 text-blue-400 border-blue-500/30 text-xs"><Info className="h-3 w-3 mr-0.5 inline" />N</Badge>
|
||
)}
|
||
</TableCell>
|
||
<TableCell className="text-xs text-slate-300 font-medium">
|
||
<span className={e.type === 'Warning' ? 'text-amber-400' : 'text-slate-400'}>{e.reason || '-'}</span>
|
||
</TableCell>
|
||
<TableCell className="text-xs text-slate-500 font-mono">{e.object || '-'}</TableCell>
|
||
<TableCell className="text-xs text-slate-400 max-w-[400px]">
|
||
<div className={`truncate ${isExpanded ? 'whitespace-normal' : ''}`} title={!isExpanded ? e.message : undefined}>
|
||
{e.message || '-'}
|
||
</div>
|
||
{isExpanded && (
|
||
<div className="mt-1 pt-1 border-t border-slate-800 text-[10px] text-slate-600 space-y-0.5">
|
||
<div>首次出现: {formatTime(e.first_time)} ({formatRelative(e.first_time)})</div>
|
||
<div>最后出现: {formatTime(e.last_time)} ({formatRelative(e.last_time)})</div>
|
||
<div>事件名称: {e.name}</div>
|
||
</div>
|
||
)}
|
||
</TableCell>
|
||
<TableCell className="text-sm text-slate-400 text-center">
|
||
{e.count > 1 ? <Badge variant="outline" className="text-[10px] h-4 px-1">{e.count}</Badge> : <span className="text-slate-600">1</span>}
|
||
</TableCell>
|
||
<TableCell className="text-xs text-slate-500">{e.namespace || '-'}</TableCell>
|
||
<TableCell className="text-xs text-slate-500">
|
||
<div>{formatTime(e.last_time)}</div>
|
||
<div className="text-[10px] text-slate-600">{formatRelative(e.last_time)}</div>
|
||
</TableCell>
|
||
</TableRow>
|
||
);
|
||
})}
|
||
</TableBody>
|
||
</Table>
|
||
{displayEvents.length > 200 && (
|
||
<div className="text-center py-2 text-xs text-slate-600 border-t border-slate-800">
|
||
仅显示前 200 条,共 {displayEvents.length} 条事件
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
);
|
||
}
|