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
+670 -327

No files matched your search

+2 -2
View File
@@ -203,5 +203,5 @@
| 代码质量 | **82/100** | 后端规范好(0 panic, 1 TODO);前端 any 从 1026 降至 40 + tsc --noEmit 零错误,类型安全大幅提升 |
| 架构设计 | **86/100** | 前后端分离清晰,自研轻量 K8s 客户端;kubernetes.go/api.ts 已拆分,K8s 双路由已统一为 code 路由为主 (A1 done),WebSocket 统一管理 (A3 done) |
| 部署运维 | **87/100** | 三种部署方式 + run.sh 统一入口;扣分项:后端镜像偏大 |
| 用户体验 | **86/100** | 骨架屏 + 页面过渡 + 5 套主题 + 懒加载;扣分项:无 i18n,无虚拟滚动 |
| **综合** | **86/100** | 企业级运维平台,功能覆盖全面,P0 全部完成,P2 A1/A2/A3 全部完成 |
| 用户体验 | **88/100** | 骨架屏 + 页面过渡 + 5 套主题 + 懒加载 + 虚拟滚动;扣分项:无 i18n |
| **综合** | **87/100** | 企业级运维平台,功能覆盖全面,P0 全部完成,P1 全部完成,P2 A1/A2/A3 全部完成,P3 U1 虚拟滚动 done |
+21 -4
View File
@@ -219,9 +219,26 @@ services/api.ts (1324L)
## P3 — 用户体验提升
### U1: 虚拟滚动 [pending]
- Pod 列表(1000+ Pod 场景)使用 `@tanstack/react-virtual`
- 事件列表、审计日志列表
### U1: 虚拟滚动 [done]
**问题**: Pod 列表(1000+ Pod)、事件列表(maxRows=200 截断)、审计日志列表在大数据量场景下渲染卡顿
**已完成 (2026-06-27)**:
- ✅ 安装 `@tanstack/react-virtual@3.14.4`,新增 `hooks/useVirtualRows.ts` 可复用虚拟滚动 Hook
- ✅ **PodsTab** (deployment-view): 从 `.map()` 全量渲染 → `useVirtualizer` + `measureElement` 动态行高,支持展开行,自动统计总数
- ✅ **EventTable**: 移除 `maxRows=200` 截断限制 → 虚拟滚动渲染万级事件,CSS Grid 布局对齐表头
- ✅ **AuditOperationsTab**: 从 shadcn `<Table>` 全量渲染 → 虚拟滚动 CSS Grid 布局,保留所有筛选/导出/详情功能
- ✅ **SessionCommandsPanel**: 从 shadcn `<Table>` 全量渲染 → 虚拟滚动,page_size 从 200 提升至 500
**验收标准**:
- [x] `@tanstack/react-virtual` 已安装并集成
- [x] Pod 列表支持 1000+ Pod 虚拟滚动渲染(只渲染可见行 + overscan)
- [x] 事件列表移除 maxRows 限制,万级事件流畅渲染
- [x] 审计日志列表虚拟滚动(时间/用户/模块/操作/资源/IP/结果/详情)
- [x] 会话命令列表虚拟滚动
- [x] `npm run build` 零错误,`tsc --noEmit` 零错误
- [x] 17/17 集成测试通过
- [x] Docker 部署 healthy
### U2: 统一 DataTable 组件 [pending]
- 封装排序/筛选/分页/导出/列自定义的通用表格
@@ -250,7 +267,7 @@ services/api.ts (1324L)
| A1 | K8s 路由统一 | P2 | **done** |
| A2 | 告警引擎分布式 | P2 | **done** |
| A3 | WebSocket 管理 | P2 | **done** |
| U1 | 虚拟滚动 | P3 | pending |
| U1 | 虚拟滚动 | P3 | **done** |
| U2 | 统一 DataTable | P3 | pending |
| U3 | 国际化 | P3 | pending |
| U4 | 镜像优化 | P3 | pending |
+55
View File
@@ -562,3 +562,58 @@ print("✅ U4: 5 连接并发注册成功")
- [x] 心跳机制: ping=30s + pong_handler + read_deadline=60s
- [x] `go build` 零错误,17/17 回归测试通过
- [x] 6 个 WebSocket handler 共享统一 WSManager(webshell/kubectl/podexec/audit/metrics/pipeline)
---
## P3-U1: 虚拟滚动
### 验证用例
```bash
# U1: @tanstack/react-virtual 已安装
cd /srv/aiops/frontend && node -e "const p=require('./package.json'); console.log('@tanstack/react-virtual:', p.dependencies['@tanstack/react-virtual'])"
# 期望: ^3.14.4
# U2: useVirtualRows Hook 存在
ls -la src/hooks/useVirtualRows.ts
# 期望: 文件存在
# U3: TypeScript 编译零错误
npx tsc --noEmit 2>&1 | tail -3
# 期望: 退出码 0
# U4: Vite 构建
npm run build 2>&1 | tail -3
# 期望: built in Ns
# U5: PodsTab 使用 useVirtualizer
grep -c 'useVirtualizer' src/components/kubernetes/deployment-view/PodsTab.tsx
# 期望: >= 1
# U6: EventTable 使用 useVirtualizer
grep -c 'useVirtualizer' src/components/kubernetes/EventTable.tsx
# 期望: >= 1
# U7: AuditOperationsTab 使用 useVirtualizer
grep -c 'useVirtualizer' src/pages/AuditOperationsTab.tsx
# 期望: >= 1
# U8: EventTable 不再截断 (maxRows 已移除)
grep 'maxRows' src/components/kubernetes/EventTable.tsx | grep -v 'deprecated'
# 期望: 无实际使用 (仅 @deprecated 注释)
# U9: 集成回归测试
python3 tests/integration_test.py --host 127.0.0.1 --port 8080 | tail -3
# 期望: 17/17 passed
```
### 通过标准
- [x] U1: `@tanstack/react-virtual@3.14.4` 已安装
- [x] U2: `useVirtualRows` Hook 可复用
- [x] U3: `tsc --noEmit` 零错误
- [x] U4: Vite 构建成功 (✓ built in 18.62s)
- [x] U5: PodsTab 虚拟滚动(动态行高 + 展开行支持)
- [x] U6: EventTable 虚拟滚动(移除 maxRows 截断)
- [x] U7: AuditOperationsTab 虚拟滚动
- [x] U8: EventTable 不再截断事件列表
- [x] U9: 17/17 集成回归测试通过
+52 -2
View File
@@ -41,6 +41,7 @@
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.95.2",
"@tanstack/react-query-devtools": "^5.95.2",
"@tanstack/react-virtual": "^3.14.4",
"@types/react-router-dom": "^5.3.3",
"@uiw/react-codemirror": "^4.25.10",
"class-variance-authority": "^0.7.1",
@@ -132,6 +133,7 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@@ -1953,6 +1955,7 @@
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.1.tgz",
"integrity": "sha512-+BIjw/AG3tDQ4pJgTLPYdAW25eDE66YsvM4LKyVPgGzVgZ4a9Wj1SRX8kPVKgBDdPt8oHtZ15F0qx7p0oOHdHw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@codemirror/state": "^6.6.0",
"crelt": "^1.0.6",
@@ -4793,6 +4796,7 @@
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.95.2.tgz",
"integrity": "sha512-/wGkvLj/st5Ud1Q76KF1uFxScV7WeqN1slQx5280ycwAyYkIPGaRZAEgHxe3bjirSd5Zpwkj6zNcR4cqYni/ZA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@tanstack/query-core": "5.95.2"
},
@@ -4821,6 +4825,33 @@
"react": "^18 || ^19"
}
},
"node_modules/@tanstack/react-virtual": {
"version": "3.14.4",
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.4.tgz",
"integrity": "sha512-dZzAQP2uCDAd+9sAehqmx/DcU+B91Q4Gb0aDSM7t9bJvWDyGF9sapFNW5r1gNLsHs4wTb6ScZENJeYaHxJLiOw==",
"license": "MIT",
"dependencies": {
"@tanstack/virtual-core": "3.17.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tanstack/virtual-core": {
"version": "3.17.2",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.2.tgz",
"integrity": "sha512-w43MvWvmShpb6kIC9MOoLyUkLmRTLPjt61bHWs+X29hACSpX+n8DvgZ3qM7cUfflKlRRcHR9KVJE6TmcqnQvcA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -4955,6 +4986,7 @@
"integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
@@ -4964,6 +4996,7 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -4974,6 +5007,7 @@
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@@ -5044,6 +5078,7 @@
"integrity": "sha512-iIACsx8pxRnguSYhHiMn2PvhvfpopO9FXHyn1mG5txZIsAaB6F0KwbFnUQN3KCiG3Jcuad/Cao2FAs1Wp7vAyg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.52.0",
"@typescript-eslint/types": "8.52.0",
@@ -5348,6 +5383,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -5611,6 +5647,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -6093,7 +6130,8 @@
"version": "8.6.0",
"resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz",
"integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/embla-carousel-react": {
"version": "8.6.0",
@@ -6188,6 +6226,7 @@
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -6779,6 +6818,7 @@
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"jiti": "bin/jiti.js"
}
@@ -7233,6 +7273,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -7280,6 +7321,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -7486,6 +7528,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -7516,6 +7559,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -7528,6 +7572,7 @@
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.70.0.tgz",
"integrity": "sha512-COOMajS4FI3Wuwrs3GPpi/Jeef/5W1DRR84Yl5/ShlT3dKVFUfoGiEZ/QE6Uw8P4T2/CLJdcTVYKvWBMQTEpvw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
@@ -8089,6 +8134,7 @@
"integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
"arg": "^5.0.2",
@@ -8245,6 +8291,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -8469,6 +8516,7 @@
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@@ -8575,7 +8623,8 @@
"resolved": "https://registry.npmjs.org/xterm/-/xterm-5.3.0.tgz",
"integrity": "sha512-8QqjlekLUFTrU6x7xck1MsPzPA571K5zNqWm0M0oroYEWVOptZ0+ubQSkQ3uxIEhcIHRujJy6emDWX4A7qyFzg==",
"deprecated": "This package is now deprecated. Move to @xterm/xterm instead.",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/xterm-addon-fit": {
"version": "0.8.0",
@@ -8622,6 +8671,7 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz",
"integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
+1
View File
@@ -43,6 +43,7 @@
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.95.2",
"@tanstack/react-query-devtools": "^5.95.2",
"@tanstack/react-virtual": "^3.14.4",
"@types/react-router-dom": "^5.3.3",
"@uiw/react-codemirror": "^4.25.10",
"class-variance-authority": "^0.7.1",
+136 -89
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,108 +14,156 @@ 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,129 +61,154 @@ 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,42 +88,62 @@ 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>