diff --git a/PROJECT_EVALUATION.md b/PROJECT_EVALUATION.md index f1eec72..75b9962 100644 --- a/PROJECT_EVALUATION.md +++ b/PROJECT_EVALUATION.md @@ -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 | diff --git a/ROADMAP.md b/ROADMAP.md index f5b6d0d..2110342 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 `` 全量渲染 → 虚拟滚动 CSS Grid 布局,保留所有筛选/导出/详情功能 +- ✅ **SessionCommandsPanel**: 从 shadcn `
` 全量渲染 → 虚拟滚动,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 | diff --git a/TDD_PLAN.md b/TDD_PLAN.md index cc33ee3..6d4b2e1 100644 --- a/TDD_PLAN.md +++ b/TDD_PLAN.md @@ -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 集成回归测试通过 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d321d20..fc78368 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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" } diff --git a/frontend/package.json b/frontend/package.json index 0ca0b78..332074e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/components/kubernetes/EventTable.tsx b/frontend/src/components/kubernetes/EventTable.tsx index 958895a..900c2c4 100644 --- a/frontend/src/components/kubernetes/EventTable.tsx +++ b/frontend/src/components/kubernetes/EventTable.tsx @@ -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(null); const isLoading = loading === true || loading === 'events'; - const rows = maxRows ? events.slice(0, maxRows) : events; - const colSpan = showNamespace ? 10 : 9; + const scrollRef = useRef(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 ( -
-
- - - 最后时间 - 首次时间 - 次数 - 重复 - 类型 - 原因 - 对象 - {showNamespace && 命名空间} - 来源 - 消息 - - - - {isLoading ? ( - - - - - - ) : rows.length === 0 ? ( - - {emptyText} - - ) : 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 ( - setExpandedRow(isExpanded ? null : rowKey)} - > - -
{last.absolute}
-
{last.relative}
-
- -
{first.absolute}
-
{first.relative}
-
- {e.count} - {formatEventRepeatSpan(e.firstTime, e.lastTime, e.count)} - - - {e.type} - - - {e.reason} - - {e.objectKind ? ( - {e.objectKind}/{e.objectName || e.object.split('/').pop()} - ) : e.object} - - {showNamespace && {e.namespace || '-'}} - {e.source} - -
- {e.message} +
+ {/* 表头 — sticky 固定 */} +
+ {headers.map((h, i) => ( + {h} + ))} +
+ + {/* 虚拟滚动体 */} +
+ {isLoading ? ( +
+ +
+ ) : events.length === 0 ? ( +
{emptyText}
+ ) : ( +
+ {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 ( +
setExpandedRow(isExpanded ? null : rowKey)} + > + {/* 最后时间 */} +
+
{last.absolute}
+
{last.relative}
- {isExpanded && e.name && ( -
- 事件名: {e.name} + {/* 首次时间 */} +
+
{first.absolute}
+
{first.relative}
+
+ {/* 次数 */} +
{e.count}
+ {/* 重复跨度 */} +
{formatEventRepeatSpan(e.firstTime, e.lastTime, e.count)}
+ {/* 类型 */} +
+ + {e.type} + +
+ {/* 原因 */} +
{e.reason}
+ {/* 对象 */} +
+ {e.objectKind ? ( + {e.objectKind}/{e.objectName || e.object.split('/').pop()} + ) : e.object} +
+ {/* 命名空间(可选) */} + {showNamespace &&
{e.namespace || '-'}
} + {/* 来源 */} +
{e.source}
+ {/* 消息 */} +
+
+ {e.message}
- )} - - - ); - })} - -
- {maxRows && events.length > maxRows && ( + {isExpanded && e.name && ( +
+ 事件名: {e.name} +
+ )} + + + ); + })} + + )} + + + {/* 行数统计 */} + {events.length > 0 && (
- 仅显示前 {maxRows} 条,共 {events.length} 条 + 共 {events.length} 条事件 {events.length > 500 && '· 虚拟滚动渲染'}
)} ); -} \ No newline at end of file +} diff --git a/frontend/src/components/kubernetes/EventsPage.tsx b/frontend/src/components/kubernetes/EventsPage.tsx index 8a544f0..d216c3c 100644 --- a/frontend/src/components/kubernetes/EventsPage.tsx +++ b/frontend/src/components/kubernetes/EventsPage.tsx @@ -296,7 +296,6 @@ export default function EventsPage() { events={displayEvents} loading={loading} showNamespace - maxRows={200} /> diff --git a/frontend/src/components/kubernetes/deployment-view/PodsTab.tsx b/frontend/src/components/kubernetes/deployment-view/PodsTab.tsx index 47fa69c..1b50c0e 100644 --- a/frontend/src/components/kubernetes/deployment-view/PodsTab.tsx +++ b/frontend/src/components/kubernetes/deployment-view/PodsTab.tsx @@ -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(null); + + const virtualizer = useVirtualizer({ + count: pods.length, + getScrollElement: () => scrollRef.current, + estimateSize: () => ROW_HEIGHT, + overscan: 8, + }); + if (pods.length === 0) { return
暂无容器组
; } + const virtualRows = virtualizer.getVirtualItems(); + const totalHeight = virtualizer.getTotalSize(); + return ( - - {/* 表头:操作列固定宽度,避免更多按钮溢出卡片 // Logan.Li */} -
+ + {/* 表头 */} +
名称 状态 @@ -39,130 +61,155 @@ export function PodsTab({ 操作
-
- {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).containers as any[] | undefined; + {/* 虚拟滚动列表 */} +
+
+ {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).containers as Record[] | undefined; - return ( -
- {/* 列表行:紧凑信息,不重复展示完整时间线 // Logan.Li */} + return (
- - - - {pod.name} - - - - {pod.status || 'Unknown'} - - - - - - {pod.ip || pod.pod_ip || '—'} - - - 0 ? 'text-amber-400' : 'text-[var(--dg-text-muted)]'}`}> - {pod.restart_count || 0} - - - - {pod.age || '—'} - - -
- - - - - - - - onShowPodLogs(pod)} className="text-[var(--dg-text-primary)]"> - 日志 - - onShowPodYaml(pod)} className="text-[var(--dg-text-primary)]"> - YAML - - onDeletePod?.(pod)} className="text-red-400 focus:text-red-400"> - 删除 - - - + + + {pod.name} + + + + {pod.status || 'Unknown'} + + + + + + {pod.ip || pod.pod_ip || '—'} + + + 0 ? 'text-amber-400' : 'text-[var(--dg-text-muted)]'}`}> + {pod.restart_count || 0} + + + + {pod.age || '—'} + + +
+ + + + + + + + onShowPodLogs(pod)} className="text-[var(--dg-text-primary)]"> + 日志 + + onShowPodYaml(pod)} className="text-[var(--dg-text-primary)]"> + YAML + + onDeletePod?.(pod)} className="text-red-400 focus:text-red-400"> + 删除 + + + +
-
- {/* 展开详情:时间线与容器信息仅在此处展示 // Logan.Li */} - {expanded && ( -
- - {containers && containers.length > 0 && ( -
-

容器

-
- {containers.map((c: any, idx: number) => ( -
-
- {c.name} - - {c.ready ? 'Ready' : 'Pending'} - -
-

{c.image}

- {c.resources && ( -
- CPU {c.resources?.requests?.cpu || '0'} / {c.resources?.limits?.cpu || '—'} - 内存 {c.resources?.requests?.memory || '0'} / {c.resources?.limits?.memory || '—'} + {/* 展开详情 */} + {expanded && ( +
+ + {containers && containers.length > 0 && ( +
+

容器

+
+ {containers.map((c, idx) => ( +
+
+ {String(c.name ?? '')} + + {c.ready ? 'Ready' : 'Pending'} +
- )} -
- ))} +

{String(c.image ?? '')}

+ {c.resources && ( +
+ CPU {String((c.resources as Record>)?.requests?.cpu ?? '0')} / {String((c.resources as Record>)?.limits?.cpu ?? '—')} + 内存 {String((c.resources as Record>)?.requests?.memory ?? '0')} / {String((c.resources as Record>)?.limits?.memory ?? '—')} +
+ )} +
+ ))} +
-
- )} -
- )} -
- ); - })} + )} +
+ )} +
+ ); + })} +
+ + {/* 行数统计 */} + {pods.length > 0 && ( +
+ 共 {pods.length} 个容器组 {pods.length > 200 && '· 虚拟滚动渲染'} +
+ )} ); -} \ No newline at end of file +} diff --git a/frontend/src/hooks/useVirtualRows.ts b/frontend/src/hooks/useVirtualRows.ts new file mode 100644 index 0000000..737589d1 --- /dev/null +++ b/frontend/src/hooks/useVirtualRows.ts @@ -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; + /** 当前可见+overscan 范围内的虚拟行 */ + virtualRows: ReturnType['getVirtualItems']>; + /** 虚拟容器的总高度(用于设置 spacer 高度) */ + totalHeight: number; + /** 滚动到指定 index */ + scrollToIndex: (index: number) => void; +} + +/** + * 虚拟滚动 Hook + * + * ```tsx + * const { scrollRef, virtualRows, totalHeight } = useVirtualRows({ + * count: items.length, estimateSize: () => 40, overscan: 8, + * }); + * return ( + *
+ *
+ * {virtualRows.map(vr => ( + *
+ * {renderRow(items[vr.index])} + *
+ * ))} + *
+ *
+ * ); + * ``` + */ +export function useVirtualRows({ + count, + estimateSize, + overscan = 8, +}: VirtualRowsOptions): VirtualRowsResult { + const scrollRef = useRef(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 }; +} diff --git a/frontend/src/pages/AuditOperationsTab.tsx b/frontend/src/pages/AuditOperationsTab.tsx index ed689f7..057f34e 100644 --- a/frontend/src/pages/AuditOperationsTab.tsx +++ b/frontend/src/pages/AuditOperationsTab.tsx @@ -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(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 (
{/* Filters */} @@ -208,7 +216,7 @@ export function AuditOperationsTab() {
- {/* Logs Table */} + {/* Logs Table — 虚拟滚动 */}
@@ -222,64 +230,82 @@ export function AuditOperationsTab() {

暂无操作日志

) : ( - - - - 时间 - 用户 - 模块 - 操作类型 - 资源 - IP - 结果 - 详情 - - - - {filteredLogs.map((log) => ( - - - - {log.createdAt ? new Date(log.createdAt).toLocaleString() : '--'} - - - - {log.username || '--'} - - - {log.module || '--'} - - - - {log.action || '--'} - - - - - {log.resource || '--'} - - - - {log.ip || '--'} - - {getLogResultBadge(log.result)} - -
- + <> + {/* 表头 */} +
+ 时间 + 用户 + 模块 + 操作类型 + 资源 + IP + 结果 + 详情 +
+ + {/* 虚拟滚动列表 */} +
+
+ {virtualRows.map((vr) => { + const log = filteredLogs[vr.index]; + if (!log) return null; + return ( +
+ + {log.createdAt ? new Date(log.createdAt).toLocaleString() : '--'} + + {log.username || '--'} + {log.module || '--'} +
+ + {log.action || '--'} + +
+ + {log.resource || '--'} + + {log.ip || '--'} +
{getLogResultBadge(log.result)}
+
+ +
- - - ))} - -
+ ); + })} +
+
+ + {/* 行数统计 */} +
+ 共 {filteredLogs.length} 条日志 {filteredLogs.length > 200 && '· 虚拟滚动渲染'} +
+ )}
diff --git a/frontend/src/pages/terminal-audit/SessionCommandsPanel.tsx b/frontend/src/pages/terminal-audit/SessionCommandsPanel.tsx index 3850e36..59bf5f3 100644 --- a/frontend/src/pages/terminal-audit/SessionCommandsPanel.tsx +++ b/frontend/src/pages/terminal-audit/SessionCommandsPanel.tsx @@ -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(null); + const virtualizer = useVirtualizer({ + count: filtered.length, + getScrollElement: () => scrollRef.current, + estimateSize: () => 48, + overscan: 10, + }); + const virtualRows = virtualizer.getVirtualItems(); + const totalHeight = virtualizer.getTotalSize(); + return ( <>
@@ -81,46 +88,66 @@ export function SessionCommandsPanel() {

暂无会话命令记录

) : ( - - - - 时间 - 用户 - 命令 - 输出大小 - 关联会话 - - - - {filtered.map((c) => ( - - - - {c.created_at ? new Date(c.created_at).toLocaleString() : '--'} - - - - {c.username || '--'} - - - - {c.command || '--'} - - - - {formatSize(c.output_size)} - - - #{c.session_id} - - - ))} - -
+ <> + {/* 表头 */} +
+ 时间 + 用户 + 命令 + 输出大小 + 关联会话 +
+ + {/* 虚拟滚动列表 */} +
+
+ {virtualRows.map((vr) => { + const c = filtered[vr.index]; + if (!c) return null; + return ( +
+ + {c.created_at ? new Date(c.created_at).toLocaleString() : '--'} + + {c.username || '--'} + + {c.command || '--'} + + {formatSize(c.output_size)} + #{c.session_id} +
+ ); + })} +
+
+ + {/* 行数统计 */} +
+ 共 {filtered.length} 条命令 {filtered.length > 200 && '· 虚拟滚动渲染'} +
+ )}
); -} \ No newline at end of file +}