远程 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 测试通过
176 lines
7.8 KiB
TypeScript
176 lines
7.8 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 { useTranslation } from 'react-i18next';
|
|
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 { t } = useTranslation();
|
|
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
|
|
? [t('k8s.event.colLast'), t('k8s.event.colFirst'), t('k8s.event.colCount'), t('k8s.event.colRepeat'), t('k8s.event.colType'), t('k8s.event.colReason'), t('k8s.event.colObject'), t('k8s.event.colNamespace'), t('k8s.event.colSource'), t('k8s.event.colMessage')]
|
|
: [t('k8s.event.colLast'), t('k8s.event.colFirst'), t('k8s.event.colCount'), t('k8s.event.colRepeat'), t('k8s.event.colType'), t('k8s.event.colReason'), t('k8s.event.colObject'), t('k8s.event.colSource'), t('k8s.event.colMessage')];
|
|
const resolvedEmpty = emptyText || t('k8s.event.empty');
|
|
|
|
// eslint-disable-next-line react-hooks/incompatible-library
|
|
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">{resolvedEmpty}</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-orange)] border-[var(--dg-orange)]/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)]">
|
|
{t('k8s.event.nameLabel')} {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)]">
|
|
{t('k8s.event.totalCount', { count: events.length })} {events.length > 500 ? `· ${t('k8s.event.virtualScroll')}` : ''}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|