Files
aiops/frontend/src/components/kubernetes/EventTable.tsx
T
attacker f2069fe3f0 feat(ux): U1 虚拟滚动 — Pod/事件/审计/命令列表大数据量渲染优化
- 安装 @tanstack/react-virtual@3.14.4
- 新增 hooks/useVirtualRows.ts 可复用虚拟滚动 Hook
- PodsTab: 全量渲染→虚拟滚动, 支持 1000+ Pod + 动态行高(展开行)
- EventTable: 移除 maxRows=200 截断, 万级事件流畅渲染
- AuditOperationsTab: shadcn Table→虚拟滚动 CSS Grid 布局
- SessionCommandsPanel: 虚拟滚动, page_size 200→500
- npm run build ✅, tsc --noEmit ✅, 17/17 集成测试通过
2026-06-27 09:36:17 +08:00

172 lines
7.3 KiB
TypeScript

// EventTable — K8s 事件列表(时间/次数/类型/原因/对象/来源/消息)// Logan.Li
// v2: 虚拟滚动,移除 maxRows 限制,支持万级事件流畅渲染
import { useState, useRef } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import { Badge } from '@/components/ui/badge';
import { Loader2 } from 'lucide-react';
import {
type NormalizedEvent,
formatEventRepeatSpan,
formatEventTimeCell,
} from './k8s-event-utils';
interface EventTableProps {
events: NormalizedEvent[];
loading?: boolean | string;
emptyText?: string;
/** @deprecated 虚拟滚动后不再需要截断,保留参数向后兼容但忽略 */
maxRows?: number;
showNamespace?: boolean;
/** 虚拟滚动容器高度 */
scrollHeight?: number;
}
const ROW_HEIGHT = 52;
const COLS_NS = '148px 148px 64px 80px 80px 112px minmax(120px,1fr) 96px 112px minmax(200px,1.5fr)';
const COLS = '148px 148px 64px 80px 80px 112px minmax(120px,1fr) 112px minmax(200px,1.5fr)';
const MIN_W_NS = '1080px';
const MIN_W = '980px';
export function EventTable({
events,
loading = false,
emptyText = '暂无事件',
showNamespace = false,
scrollHeight = 600,
}: EventTableProps) {
const [expandedRow, setExpandedRow] = useState<string | null>(null);
const isLoading = loading === true || loading === 'events';
const scrollRef = useRef<HTMLDivElement>(null);
const gridCols = showNamespace ? COLS_NS : COLS;
const minWidth = showNamespace ? MIN_W_NS : MIN_W;
const headers = showNamespace
? ['最后时间', '首次时间', '次数', '重复', '类型', '原因', '对象', '命名空间', '来源', '消息']
: ['最后时间', '首次时间', '次数', '重复', '类型', '原因', '对象', '来源', '消息'];
const virtualizer = useVirtualizer({
count: events.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => ROW_HEIGHT,
overscan: 10,
});
const virtualRows = virtualizer.getVirtualItems();
const totalHeight = virtualizer.getTotalSize();
return (
<div>
{/* 表头 — sticky 固定 */}
<div
className="grid gap-2 px-4 py-2.5 border-b border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] text-[11px] font-medium text-[var(--dg-text-muted)] uppercase tracking-wide"
style={{ gridTemplateColumns: gridCols, minWidth }}
>
{headers.map((h, i) => (
<span key={i} className={i === 2 ? 'text-center' : ''}>{h}</span>
))}
</div>
{/* 虚拟滚动体 */}
<div ref={scrollRef} style={{ height: scrollHeight, overflow: 'auto' }} className="overflow-x-auto">
{isLoading ? (
<div className="flex items-center justify-center" style={{ height: scrollHeight }}>
<Loader2 className="h-5 w-5 animate-spin text-[var(--dg-text-muted)]" />
</div>
) : events.length === 0 ? (
<div className="flex items-center justify-center text-[var(--dg-text-muted)] py-10">{emptyText}</div>
) : (
<div style={{ height: totalHeight, position: 'relative', minWidth }}>
{virtualRows.map((vr) => {
const e = events[vr.index];
if (!e) return null;
const rowKey = `${e.name || e.object}-${vr.index}`;
const isExpanded = expandedRow === rowKey;
const isWarning = e.type === 'Warning';
const last = formatEventTimeCell(e.lastTime);
const first = formatEventTimeCell(e.firstTime);
return (
<div
key={vr.key}
data-index={vr.index}
ref={virtualizer.measureElement}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${vr.start}px)`,
display: 'grid',
gridTemplateColumns: gridCols,
gap: '0.5rem',
}}
className={`px-4 py-2.5 border-b border-[var(--dg-border)] cursor-pointer transition-colors ${
isExpanded ? 'bg-[var(--dg-bg-tertiary)]/40' : 'hover:bg-[var(--dg-bg-tertiary)]/30'
}`}
onClick={() => setExpandedRow(isExpanded ? null : rowKey)}
>
{/* 最后时间 */}
<div className="text-xs text-[var(--dg-text-muted)]">
<div>{last.absolute}</div>
<div className="text-[10px]">{last.relative}</div>
</div>
{/* 首次时间 */}
<div className="text-xs text-[var(--dg-text-muted)]">
<div>{first.absolute}</div>
<div className="text-[10px]">{first.relative}</div>
</div>
{/* 次数 */}
<div className="text-sm text-center tabular-nums text-[var(--dg-text-secondary)]">{e.count}</div>
{/* 重复跨度 */}
<div className="text-xs text-[var(--dg-text-muted)]">{formatEventRepeatSpan(e.firstTime, e.lastTime, e.count)}</div>
{/* 类型 */}
<div>
<Badge
variant="outline"
className={isWarning
? 'text-[var(--dg-warning)] border-[var(--dg-warning)]/30 text-[10px]'
: 'text-[var(--dg-text-muted)] border-[var(--dg-border)] text-[10px]'}
>
{e.type}
</Badge>
</div>
{/* 原因 */}
<div className="text-xs font-medium text-[var(--dg-text-secondary)]">{e.reason}</div>
{/* 对象 */}
<div className="text-xs font-mono text-[var(--dg-text-muted)] truncate">
{e.objectKind ? (
<span><span className="text-[var(--dg-text-secondary)]">{e.objectKind}</span>/{e.objectName || e.object.split('/').pop()}</span>
) : e.object}
</div>
{/* 命名空间(可选) */}
{showNamespace && <div className="text-xs text-[var(--dg-text-muted)]">{e.namespace || '-'}</div>}
{/* 来源 */}
<div className="text-xs text-[var(--dg-text-muted)] font-mono">{e.source}</div>
{/* 消息 */}
<div className="text-xs text-[var(--dg-text-secondary)] max-w-[360px]">
<div className={isExpanded ? 'whitespace-normal break-words' : 'truncate'} title={!isExpanded ? e.message : undefined}>
{e.message}
</div>
{isExpanded && e.name && (
<div className="mt-1 pt-1 border-t border-[var(--dg-border)] text-[10px] text-[var(--dg-text-muted)]">
事件名: {e.name}
</div>
)}
</div>
</div>
);
})}
</div>
)}
</div>
{/* 行数统计 */}
{events.length > 0 && (
<div className="text-center py-2 text-xs text-[var(--dg-text-muted)] border-t border-[var(--dg-border)]">
共 {events.length} 条事件 {events.length > 500 && '· 虚拟滚动渲染'}
</div>
)}
</div>
);
}