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

+137 -90
View File
@@ -1,9 +1,8 @@
// EventTable — K8s 事件列表(时间/次数/类型/原因/对象/来源/消息)// Logan.Li
import { useState } from 'react';
// v2: 虚拟滚动,移除 maxRows 限制,支持万级事件流畅渲染
import { useState, useRef } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import { Badge } from '@/components/ui/badge';
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from '@/components/ui/table';
import { Loader2 } from 'lucide-react';
import {
type NormalizedEvent,
@@ -15,110 +14,158 @@ 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 = '暂无事件',
maxRows,
showNamespace = false,
scrollHeight = 600,
}: EventTableProps) {
const [expandedRow, setExpandedRow] = useState<string | null>(null);
const isLoading = loading === true || loading === 'events';
const rows = maxRows ? events.slice(0, maxRows) : events;
const colSpan = showNamespace ? 10 : 9;
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 className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow className="border-[var(--dg-border)] hover:bg-transparent">
<TableHead className="text-[var(--dg-text-muted)] w-[148px]">最后时间</TableHead>
<TableHead className="text-[var(--dg-text-muted)] w-[148px]">首次时间</TableHead>
<TableHead className="text-[var(--dg-text-muted)] w-16 text-center">次数</TableHead>
<TableHead className="text-[var(--dg-text-muted)] w-20">重复</TableHead>
<TableHead className="text-[var(--dg-text-muted)] w-20">类型</TableHead>
<TableHead className="text-[var(--dg-text-muted)] w-28">原因</TableHead>
<TableHead className="text-[var(--dg-text-muted)] min-w-[120px]">对象</TableHead>
{showNamespace && <TableHead className="text-[var(--dg-text-muted)] w-24">命名空间</TableHead>}
<TableHead className="text-[var(--dg-text-muted)] w-28">来源</TableHead>
<TableHead className="text-[var(--dg-text-muted)] min-w-[200px]">消息</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={colSpan} className="text-center py-10">
<Loader2 className="h-5 w-5 animate-spin mx-auto text-[var(--dg-text-muted)]" />
</TableCell>
</TableRow>
) : rows.length === 0 ? (
<TableRow>
<TableCell colSpan={colSpan} className="text-center text-[var(--dg-text-muted)] py-10">{emptyText}</TableCell>
</TableRow>
) : rows.map((e, i) => {
const rowKey = `${e.name || e.object}-${i}`;
const isExpanded = expandedRow === rowKey;
const isWarning = e.type === 'Warning';
const last = formatEventTimeCell(e.lastTime);
const first = formatEventTimeCell(e.firstTime);
return (
<TableRow
key={rowKey}
className={`border-[var(--dg-border)] cursor-pointer hover:bg-[var(--dg-bg-tertiary)]/40 ${isExpanded ? 'bg-[var(--dg-bg-tertiary)]/30' : ''}`}
onClick={() => setExpandedRow(isExpanded ? null : rowKey)}
>
<TableCell className="text-xs text-[var(--dg-text-muted)]">
<div>{last.absolute}</div>
<div className="text-[10px]">{last.relative}</div>
</TableCell>
<TableCell className="text-xs text-[var(--dg-text-muted)]">
<div>{first.absolute}</div>
<div className="text-[10px]">{first.relative}</div>
</TableCell>
<TableCell className="text-sm text-center tabular-nums text-[var(--dg-text-secondary)]">{e.count}</TableCell>
<TableCell className="text-xs text-[var(--dg-text-muted)]">{formatEventRepeatSpan(e.firstTime, e.lastTime, e.count)}</TableCell>
<TableCell>
<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>
</TableCell>
<TableCell className="text-xs font-medium text-[var(--dg-text-secondary)]">{e.reason}</TableCell>
<TableCell className="text-xs font-mono text-[var(--dg-text-muted)]">
{e.objectKind ? (
<span><span className="text-[var(--dg-text-secondary)]">{e.objectKind}</span>/{e.objectName || e.object.split('/').pop()}</span>
) : e.object}
</TableCell>
{showNamespace && <TableCell className="text-xs text-[var(--dg-text-muted)]">{e.namespace || '-'}</TableCell>}
<TableCell className="text-xs text-[var(--dg-text-muted)] font-mono">{e.source}</TableCell>
<TableCell 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>
{/* 表头 — 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>
{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 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>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
{maxRows && events.length > maxRows && (
{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)]">
仅显示前 {maxRows} 条,共 {events.length} 条
共 {events.length} 条事件 {events.length > 500 && '· 虚拟滚动渲染'}
</div>
)}
</div>
);
}
}
@@ -296,7 +296,6 @@ export default function EventsPage() {
events={displayEvents}
loading={loading}
showNamespace
maxRows={200}
/>
</CardContent>
</Card>
@@ -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>
);
}
}
+74
View File
@@ -0,0 +1,74 @@
// useVirtualRows — 虚拟滚动 Hook,基于 @tanstack/react-virtual
// 用于大列表(1000+ 行)场景的性能优化,只渲染可见区域的行
// 用法:
// const { scrollRef, virtualRows, totalHeight } = useVirtualRows({
// count: data.length, estimateSize: () => 40, overscan: 10,
// });
import { useRef, useCallback } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
export interface VirtualRowsOptions {
count: number;
estimateSize: (index: number) => number;
overscan?: number;
/** 滚动容器的 padding 顶部像素(影响虚拟化偏移计算) */
paddingTop?: number;
}
export interface VirtualRowsResult {
/** 绑定到滚动容器 div 的 ref */
scrollRef: React.RefObject<HTMLDivElement>;
/** 当前可见+overscan 范围内的虚拟行 */
virtualRows: ReturnType<ReturnType<typeof useVirtualizer>['getVirtualItems']>;
/** 虚拟容器的总高度(用于设置 spacer 高度) */
totalHeight: number;
/** 滚动到指定 index */
scrollToIndex: (index: number) => void;
}
/**
* 虚拟滚动 Hook
*
* ```tsx
* const { scrollRef, virtualRows, totalHeight } = useVirtualRows({
* count: items.length, estimateSize: () => 40, overscan: 8,
* });
* return (
* <div ref={scrollRef} style={{ height: 500, overflow: 'auto' }}>
* <div style={{ height: totalHeight, position: 'relative' }}>
* {virtualRows.map(vr => (
* <div key={vr.key} style={{ position: 'absolute', top: vr.start, height: vr.size, width: '100%' }}>
* {renderRow(items[vr.index])}
* </div>
* ))}
* </div>
* </div>
* );
* ```
*/
export function useVirtualRows({
count,
estimateSize,
overscan = 8,
}: VirtualRowsOptions): VirtualRowsResult {
const scrollRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count,
getScrollElement: () => scrollRef.current,
estimateSize,
overscan,
});
const virtualRows = virtualizer.getVirtualItems();
const totalHeight = virtualizer.getTotalSize();
const scrollToIndex = useCallback(
(index: number) => {
virtualizer.scrollToIndex(index, { align: 'start' });
},
[virtualizer],
);
return { scrollRef, virtualRows, totalHeight, scrollToIndex };
}
+93 -67
View File
@@ -1,17 +1,11 @@
// 操作审计 Tab — 增强版审计日志列表,含筛选/导出/统计/清除
import { useState, useEffect, useCallback, useMemo } from 'react';
// v2: 虚拟滚动渲染,支持万级日志流畅查看
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
Search,
RefreshCw,
@@ -148,6 +142,20 @@ export function AuditOperationsTab() {
setDetailLog(log);
};
// 虚拟滚动
const scrollRef = useRef<HTMLDivElement>(null);
const GRID_COLS = 'minmax(140px,1fr) minmax(80px,1fr) minmax(100px,1fr) minmax(100px,1fr) minmax(140px,1fr) minmax(100px,1fr) minmax(80px,0.8fr) 48px';
const virtualizer = useVirtualizer({
count: filteredLogs.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => 52,
overscan: 10,
});
const virtualRows = virtualizer.getVirtualItems();
const totalHeight = virtualizer.getTotalSize();
return (
<div className="space-y-4">
{/* Filters */}
@@ -208,7 +216,7 @@ export function AuditOperationsTab() {
</Button>
</div>
{/* Logs Table */}
{/* Logs Table — 虚拟滚动 */}
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-0">
<div className="border-t border-slate-800">
@@ -222,64 +230,82 @@ export function AuditOperationsTab() {
<p className="text-sm">暂无操作日志</p>
</div>
) : (
<Table>
<TableHeader>
<TableRow className="border-slate-800 hover:bg-transparent">
<TableHead className="text-slate-400">时间</TableHead>
<TableHead className="text-slate-400">用户</TableHead>
<TableHead className="text-slate-400">模块</TableHead>
<TableHead className="text-slate-400">操作类型</TableHead>
<TableHead className="text-slate-400">资源</TableHead>
<TableHead className="text-slate-400">IP</TableHead>
<TableHead className="text-slate-400">结果</TableHead>
<TableHead className="text-slate-400 text-right">详情</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredLogs.map((log) => (
<TableRow key={log.id} className="border-slate-800 hover:bg-slate-800/50">
<TableCell>
<span className="text-sm text-slate-400">
{log.createdAt ? new Date(log.createdAt).toLocaleString() : '--'}
</span>
</TableCell>
<TableCell>
<span className="text-sm text-slate-200">{log.username || '--'}</span>
</TableCell>
<TableCell>
<span className="text-sm text-slate-300">{log.module || '--'}</span>
</TableCell>
<TableCell>
<Badge variant="outline" className="border-blue-500/30 text-blue-400 bg-blue-500/10">
{log.action || '--'}
</Badge>
</TableCell>
<TableCell>
<code className="text-xs text-slate-400 bg-slate-800 px-2 py-0.5 rounded">
{log.resource || '--'}
</code>
</TableCell>
<TableCell>
<span className="text-sm text-slate-400">{log.ip || '--'}</span>
</TableCell>
<TableCell>{getLogResultBadge(log.result)}</TableCell>
<TableCell>
<div className="flex items-center justify-end">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-slate-400 hover:text-blue-400 hover:bg-slate-800"
onClick={() => viewDetail(log)}
title="查看详情"
>
<Eye className="h-4 w-4" />
</Button>
<>
{/* 表头 */}
<div
className="grid gap-2 px-4 py-2 border-b border-slate-800 bg-slate-900 text-xs font-medium text-slate-400 sticky top-0 z-10"
style={{ gridTemplateColumns: GRID_COLS }}
>
<span>时间</span>
<span>用户</span>
<span>模块</span>
<span>操作类型</span>
<span>资源</span>
<span>IP</span>
<span>结果</span>
<span className="text-right">详情</span>
</div>
{/* 虚拟滚动列表 */}
<div ref={scrollRef} style={{ height: 'min(600px, calc(100vh - 340px))', overflow: 'auto' }}>
<div style={{ height: totalHeight, position: 'relative' }}>
{virtualRows.map((vr) => {
const log = filteredLogs[vr.index];
if (!log) return null;
return (
<div
key={log.id}
data-index={vr.index}
ref={virtualizer.measureElement}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${vr.start}px)`,
display: 'grid',
gridTemplateColumns: GRID_COLS,
gap: '0.5rem',
}}
className="px-4 py-2.5 border-b border-slate-800 hover:bg-slate-800/50"
>
<span className="text-sm text-slate-400 truncate">
{log.createdAt ? new Date(log.createdAt).toLocaleString() : '--'}
</span>
<span className="text-sm text-slate-200 truncate">{log.username || '--'}</span>
<span className="text-sm text-slate-300 truncate">{log.module || '--'}</span>
<div>
<Badge variant="outline" className="border-blue-500/30 text-blue-400 bg-blue-500/10">
{log.action || '--'}
</Badge>
</div>
<code className="text-xs text-slate-400 bg-slate-800 px-2 py-0.5 rounded truncate">
{log.resource || '--'}
</code>
<span className="text-sm text-slate-400 truncate">{log.ip || '--'}</span>
<div>{getLogResultBadge(log.result)}</div>
<div className="flex items-center justify-end">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-slate-400 hover:text-blue-400 hover:bg-slate-800"
onClick={() => viewDetail(log)}
title="查看详情"
>
<Eye className="h-4 w-4" />
</Button>
</div>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
})}
</div>
</div>
{/* 行数统计 */}
<div className="text-center py-2 text-xs text-slate-500 border-t border-slate-800">
共 {filteredLogs.length} 条日志 {filteredLogs.length > 200 && '· 虚拟滚动渲染'}
</div>
</>
)}
</div>
</CardContent>
@@ -1,21 +1,17 @@
// 终端审计 — 会话命令子面板 // Logan.Li
// v2: 虚拟滚动,支持万级命令记录流畅查看
import { useMemo, useState } from 'react';
import { useMemo, useState, useRef } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Search, RefreshCw } from 'lucide-react';
import { useSessionCommands } from '@/hooks/api';
import { formatSize } from './helpers';
const GRID_COLS = 'minmax(150px,1fr) minmax(80px,0.8fr) minmax(200px,2fr) minmax(100px,0.8fr) minmax(80px,0.5fr)';
export function SessionCommandsPanel() {
const [keyword, setKeyword] = useState('');
const [sessionIdInput, setSessionIdInput] = useState('');
@@ -23,7 +19,7 @@ export function SessionCommandsPanel() {
const queryParams = useMemo(
() => ({
page: 1,
page_size: 200,
page_size: 500,
...(sessionIdInput ? { session_id: Number(sessionIdInput) } : {}),
}),
[sessionIdInput],
@@ -40,6 +36,17 @@ export function SessionCommandsPanel() {
);
});
// 虚拟滚动
const scrollRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: filtered.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => 48,
overscan: 10,
});
const virtualRows = virtualizer.getVirtualItems();
const totalHeight = virtualizer.getTotalSize();
return (
<>
<div className="flex items-center gap-3 flex-wrap">
@@ -81,46 +88,66 @@ export function SessionCommandsPanel() {
<p className="text-sm">暂无会话命令记录</p>
</div>
) : (
<Table>
<TableHeader>
<TableRow className="border-slate-800 hover:bg-transparent">
<TableHead className="text-slate-400">时间</TableHead>
<TableHead className="text-slate-400">用户</TableHead>
<TableHead className="text-slate-400">命令</TableHead>
<TableHead className="text-slate-400">输出大小</TableHead>
<TableHead className="text-slate-400">关联会话</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.map((c) => (
<TableRow key={c.id} className="border-slate-800 hover:bg-slate-800/50">
<TableCell>
<span className="text-sm text-slate-400">
{c.created_at ? new Date(c.created_at).toLocaleString() : '--'}
</span>
</TableCell>
<TableCell>
<span className="text-sm text-slate-200">{c.username || '--'}</span>
</TableCell>
<TableCell>
<code className="text-xs text-slate-300 bg-slate-800 px-2 py-0.5 rounded max-w-[400px] truncate block">
{c.command || '--'}
</code>
</TableCell>
<TableCell>
<span className="text-sm text-slate-400">{formatSize(c.output_size)}</span>
</TableCell>
<TableCell>
<span className="text-xs text-slate-500">#{c.session_id}</span>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<>
{/* 表头 */}
<div
className="grid gap-2 px-4 py-2 border-b border-slate-800 bg-slate-900 text-xs font-medium text-slate-400"
style={{ gridTemplateColumns: GRID_COLS }}
>
<span>时间</span>
<span>用户</span>
<span>命令</span>
<span>输出大小</span>
<span>关联会话</span>
</div>
{/* 虚拟滚动列表 */}
<div ref={scrollRef} style={{ height: 'min(600px, calc(100vh - 340px))', overflow: 'auto' }}>
<div style={{ height: totalHeight, position: 'relative' }}>
{virtualRows.map((vr) => {
const c = filtered[vr.index];
if (!c) return null;
return (
<div
key={c.id}
data-index={vr.index}
ref={virtualizer.measureElement}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${vr.start}px)`,
display: 'grid',
gridTemplateColumns: GRID_COLS,
gap: '0.5rem',
}}
className="px-4 py-2.5 border-b border-slate-800 hover:bg-slate-800/50"
>
<span className="text-sm text-slate-400 truncate">
{c.created_at ? new Date(c.created_at).toLocaleString() : '--'}
</span>
<span className="text-sm text-slate-200 truncate">{c.username || '--'}</span>
<code className="text-xs text-slate-300 bg-slate-800 px-2 py-0.5 rounded max-w-[400px] truncate block">
{c.command || '--'}
</code>
<span className="text-sm text-slate-400">{formatSize(c.output_size)}</span>
<span className="text-xs text-slate-500">#{c.session_id}</span>
</div>
);
})}
</div>
</div>
{/* 行数统计 */}
<div className="text-center py-2 text-xs text-slate-500 border-t border-slate-800">
共 {filtered.length} 条命令 {filtered.length > 200 && '· 虚拟滚动渲染'}
</div>
</>
)}
</div>
</CardContent>
</Card>
</>
);
}
}