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 集成测试通过
This commit is contained in:
attacker committed 2026-06-27 09:36:17 +08:00
1 parent e4f1018ca1
commit f2069fe3f0
11 files changed
+673 -330

No files matched your search

@@ -1,4 +1,7 @@
// deployment-view/PodsTab.tsx — 容器组 Tab // Logan.Li
// v2: 虚拟滚动,支持 1000+ Pod 场景流畅渲染
import { useRef } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
@@ -15,20 +18,39 @@ import { statusColor } from '../k8s-yaml-utils';
import { PodConditionsRow } from './helpers';
import type { DeploymentViewProps } from './types';
const GRID_COLS = '28px minmax(140px,1fr) 72px 100px 96px 48px 56px 88px';
const MIN_WIDTH = '740px';
const ROW_HEIGHT = 56;
export function PodsTab({
clusterCode, pods, onShowPodLogs, onShowPodTerminal, onShowPodYaml, onShowPodFileBrowser,
onDeletePod,
expandedPod, setExpandedPod,
}: DeploymentViewProps & { expandedPod: string | null; setExpandedPod: (v: string | null) => void }) {
const scrollRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: pods.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => ROW_HEIGHT,
overscan: 8,
});
if (pods.length === 0) {
return <div className="text-center text-[var(--dg-text-muted)] py-12">暂无容器组</div>;
}
const virtualRows = virtualizer.getVirtualItems();
const totalHeight = virtualizer.getTotalSize();
return (
<Card className="dg-card overflow-hidden">
<CardContent className="p-0 overflow-x-auto">
{/* 表头:操作列固定宽度,避免更多按钮溢出卡片 // Logan.Li */}
<div className="grid grid-cols-[28px_minmax(140px,1fr)_72px_100px_96px_48px_56px_88px] gap-2 min-w-[740px] px-4 py-2.5 border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]/40 text-[11px] font-medium text-[var(--dg-text-muted)] uppercase tracking-wide">
<CardContent className="p-0">
{/* 表头 */}
<div
className="grid gap-2 min-w-[740px] px-4 py-2.5 border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]/40 text-[11px] font-medium text-[var(--dg-text-muted)] uppercase tracking-wide sticky top-0 z-10"
style={{ gridTemplateColumns: GRID_COLS }}
>
<span />
<span>名称</span>
<span>状态</span>
@@ -39,130 +61,155 @@ export function PodsTab({
<span className="text-right">操作</span>
</div>
<div className="divide-y divide-[var(--dg-border)]">
{pods.map(pod => {
const expanded = expandedPod === pod.name;
const nodeName = pod.node_name || pod.node;
const sc = statusColor(pod.status);
const containers = (pod as Record<string, any>).containers as any[] | undefined;
{/* 虚拟滚动列表 */}
<div ref={scrollRef} style={{ height: 'min(600px, calc(100vh - 320px))', overflow: 'auto' }} className="overflow-x-auto">
<div style={{ height: totalHeight, position: 'relative', minWidth: MIN_WIDTH }}>
{virtualRows.map((vr) => {
const pod = pods[vr.index];
if (!pod) return null;
const expanded = expandedPod === pod.name;
const nodeName = pod.node_name || pod.node;
const sc = statusColor(pod.status);
const containers = (pod as unknown as Record<string, unknown>).containers as Record<string, unknown>[] | undefined;
return (
<div key={pod.name}>
{/* 列表行:紧凑信息,不重复展示完整时间线 // Logan.Li */}
return (
<div
className={`grid grid-cols-[28px_minmax(140px,1fr)_72px_100px_96px_48px_56px_88px] gap-2 min-w-[740px] items-center px-4 py-3 transition-colors ${
expanded ? 'bg-[var(--dg-bg-tertiary)]/30' : 'hover:bg-[var(--dg-bg-tertiary)]/20'
}`}
key={pod.name}
data-index={vr.index}
ref={virtualizer.measureElement}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${vr.start}px)`,
}}
className={expanded ? 'bg-[var(--dg-bg-tertiary)]/30' : ''}
>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-[var(--dg-text-muted)]"
onClick={() => setExpandedPod(expanded ? null : pod.name)}
{/* 列表行 */}
<div
className={`grid gap-2 items-center px-4 py-3 transition-colors ${
expanded ? '' : 'hover:bg-[var(--dg-bg-tertiary)]/20'
}`}
style={{ gridTemplateColumns: GRID_COLS }}
>
{expanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</Button>
<span className="font-mono text-sm text-[var(--dg-text-primary)] truncate" title={pod.name}>
{pod.name}
</span>
<Badge
className="w-fit text-[11px] font-normal"
style={{ background: `${sc}18`, color: sc, border: `1px solid ${sc}33` }}
>
{pod.status || 'Unknown'}
</Badge>
<button
className="text-xs text-[var(--dg-blue)] hover:underline truncate text-left"
title={nodeName}
onClick={() => nodeName && window.open(`/kubernetes/${clusterCode}/nodes/${nodeName}`, '_blank')}
>
{nodeName || '—'}
</button>
<span className="text-xs font-mono text-[var(--dg-text-muted)] truncate">
{pod.ip || pod.pod_ip || '—'}
</span>
<span className={`text-xs text-center font-mono ${(pod.restart_count || 0) > 0 ? 'text-amber-400' : 'text-[var(--dg-text-muted)]'}`}>
{pod.restart_count || 0}
</span>
<span className="text-xs text-[var(--dg-text-muted)] whitespace-nowrap">
{pod.age || '—'}
</span>
<div className="flex items-center justify-end gap-0.5 shrink-0 w-[88px]">
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 shrink-0 text-emerald-400" title="终端" onClick={() => onShowPodTerminal(pod)}>
<Terminal className="h-3.5 w-3.5" />
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-[var(--dg-text-muted)]"
onClick={() => setExpandedPod(expanded ? null : pod.name)}
>
{expanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</Button>
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 shrink-0 text-amber-400" title="文件" onClick={() => onShowPodFileBrowser(pod)}>
<HardDrive className="h-3.5 w-3.5" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 shrink-0 text-[var(--dg-text-muted)] hover:text-[var(--dg-blue)]"
title="更多"
aria-label="更多"
>
<MoreVertical className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<DropdownMenuItem onClick={() => onShowPodLogs(pod)} className="text-[var(--dg-text-primary)]">
<FileText className="h-3.5 w-3.5 mr-2" /> 日志
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onShowPodYaml(pod)} className="text-[var(--dg-text-primary)]">
<FileCode className="h-3.5 w-3.5 mr-2" /> YAML
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onDeletePod?.(pod)} className="text-red-400 focus:text-red-400">
<Trash2 className="h-3.5 w-3.5 mr-2" /> 删除
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<span className="font-mono text-sm text-[var(--dg-text-primary)] truncate" title={pod.name}>
{pod.name}
</span>
<Badge
className="w-fit text-[11px] font-normal"
style={{ background: `${sc}18`, color: sc, border: `1px solid ${sc}33` }}
>
{pod.status || 'Unknown'}
</Badge>
<button
className="text-xs text-[var(--dg-blue)] hover:underline truncate text-left"
title={nodeName}
onClick={() => nodeName && window.open(`/kubernetes/${clusterCode}/nodes/${nodeName}`, '_blank')}
>
{nodeName || '—'}
</button>
<span className="text-xs font-mono text-[var(--dg-text-muted)] truncate">
{pod.ip || pod.pod_ip || '—'}
</span>
<span className={`text-xs text-center font-mono ${(pod.restart_count || 0) > 0 ? 'text-amber-400' : 'text-[var(--dg-text-muted)]'}`}>
{pod.restart_count || 0}
</span>
<span className="text-xs text-[var(--dg-text-muted)] whitespace-nowrap">
{pod.age || '—'}
</span>
<div className="flex items-center justify-end gap-0.5 shrink-0 w-[88px]">
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 shrink-0 text-emerald-400" title="终端" onClick={() => onShowPodTerminal(pod)}>
<Terminal className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 shrink-0 text-amber-400" title="文件" onClick={() => onShowPodFileBrowser(pod)}>
<HardDrive className="h-3.5 w-3.5" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 shrink-0 text-[var(--dg-text-muted)] hover:text-[var(--dg-blue)]"
title="更多"
aria-label="更多"
>
<MoreVertical className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<DropdownMenuItem onClick={() => onShowPodLogs(pod)} className="text-[var(--dg-text-primary)]">
<FileText className="h-3.5 w-3.5 mr-2" /> 日志
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onShowPodYaml(pod)} className="text-[var(--dg-text-primary)]">
<FileCode className="h-3.5 w-3.5 mr-2" /> YAML
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onDeletePod?.(pod)} className="text-red-400 focus:text-red-400">
<Trash2 className="h-3.5 w-3.5 mr-2" /> 删除
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
{/* 展开详情:时间线与容器信息仅在此处展示 // Logan.Li */}
{expanded && (
<div className="px-4 pb-4 pt-1 space-y-3 bg-[var(--dg-bg-tertiary)]/20">
<PodConditionsRow pod={pod} />
{containers && containers.length > 0 && (
<div>
<p className="text-xs text-[var(--dg-text-muted)] mb-2">容器</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{containers.map((c: any, idx: number) => (
<div key={idx} className="rounded-md border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] px-3 py-2.5 text-xs">
<div className="flex items-center justify-between gap-2">
<span className="font-medium text-[var(--dg-text-primary)]">{c.name}</span>
<span className={c.ready ? 'text-emerald-400' : 'text-[var(--dg-text-muted)]'}>
{c.ready ? 'Ready' : 'Pending'}
</span>
</div>
<p className="text-[var(--dg-text-muted)] mt-1 truncate" title={c.image}>{c.image}</p>
{c.resources && (
<div className="mt-1.5 flex gap-4 text-[var(--dg-text-muted)]">
<span>CPU {c.resources?.requests?.cpu || '0'} / {c.resources?.limits?.cpu || '—'}</span>
<span>内存 {c.resources?.requests?.memory || '0'} / {c.resources?.limits?.memory || '—'}</span>
{/* 展开详情 */}
{expanded && (
<div className="px-4 pb-4 pt-1 space-y-3 bg-[var(--dg-bg-tertiary)]/20">
<PodConditionsRow pod={pod} />
{containers && containers.length > 0 && (
<div>
<p className="text-xs text-[var(--dg-text-muted)] mb-2">容器</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{containers.map((c, idx) => (
<div key={idx} className="rounded-md border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] px-3 py-2.5 text-xs">
<div className="flex items-center justify-between gap-2">
<span className="font-medium text-[var(--dg-text-primary)]">{String(c.name ?? '')}</span>
<span className={c.ready ? 'text-emerald-400' : 'text-[var(--dg-text-muted)]'}>
{c.ready ? 'Ready' : 'Pending'}
</span>
</div>
)}
</div>
))}
<p className="text-[var(--dg-text-muted)] mt-1 truncate" title={String(c.image ?? '')}>{String(c.image ?? '')}</p>
{c.resources && (
<div className="mt-1.5 flex gap-4 text-[var(--dg-text-muted)]">
<span>CPU {String((c.resources as Record<string, Record<string, string>>)?.requests?.cpu ?? '0')} / {String((c.resources as Record<string, Record<string, string>>)?.limits?.cpu ?? '—')}</span>
<span>内存 {String((c.resources as Record<string, Record<string, string>>)?.requests?.memory ?? '0')} / {String((c.resources as Record<string, Record<string, string>>)?.limits?.memory ?? '—')}</span>
</div>
)}
</div>
))}
</div>
</div>
</div>
)}
</div>
)}
</div>
);
})}
)}
</div>
)}
</div>
);
})}
</div>
</div>
{/* 行数统计 */}
{pods.length > 0 && (
<div className="text-center py-2 text-xs text-[var(--dg-text-muted)] border-t border-[var(--dg-border)]">
共 {pods.length} 个容器组 {pods.length > 200 && '· 虚拟滚动渲染'}
</div>
)}
</CardContent>
</Card>
);
}
}