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:
1 parent
e4f1018ca1
commit
f2069fe3f0
11 files changed
+673
-330
No files matched your search
@@ -203,5 +203,5 @@
|
|||||||
| 代码质量 | **82/100** | 后端规范好(0 panic, 1 TODO);前端 any 从 1026 降至 40 + tsc --noEmit 零错误,类型安全大幅提升 |
|
| 代码质量 | **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) |
|
| 架构设计 | **86/100** | 前后端分离清晰,自研轻量 K8s 客户端;kubernetes.go/api.ts 已拆分,K8s 双路由已统一为 code 路由为主 (A1 done),WebSocket 统一管理 (A3 done) |
|
||||||
| 部署运维 | **87/100** | 三种部署方式 + run.sh 统一入口;扣分项:后端镜像偏大 |
|
| 部署运维 | **87/100** | 三种部署方式 + run.sh 统一入口;扣分项:后端镜像偏大 |
|
||||||
| 用户体验 | **86/100** | 骨架屏 + 页面过渡 + 5 套主题 + 懒加载;扣分项:无 i18n,无虚拟滚动 |
|
| 用户体验 | **88/100** | 骨架屏 + 页面过渡 + 5 套主题 + 懒加载 + 虚拟滚动;扣分项:无 i18n |
|
||||||
| **综合** | **86/100** | 企业级运维平台,功能覆盖全面,P0 全部完成,P2 A1/A2/A3 全部完成 |
|
| **综合** | **87/100** | 企业级运维平台,功能覆盖全面,P0 全部完成,P1 全部完成,P2 A1/A2/A3 全部完成,P3 U1 虚拟滚动 done |
|
||||||
+21
-4
@@ -219,9 +219,26 @@ services/api.ts (1324L)
|
|||||||
|
|
||||||
## P3 — 用户体验提升
|
## P3 — 用户体验提升
|
||||||
|
|
||||||
### U1: 虚拟滚动 [pending]
|
### U1: 虚拟滚动 [done]
|
||||||
- Pod 列表(1000+ Pod 场景)使用 `@tanstack/react-virtual`
|
|
||||||
- 事件列表、审计日志列表
|
**问题**: 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]
|
### U2: 统一 DataTable 组件 [pending]
|
||||||
- 封装排序/筛选/分页/导出/列自定义的通用表格
|
- 封装排序/筛选/分页/导出/列自定义的通用表格
|
||||||
@@ -250,7 +267,7 @@ services/api.ts (1324L)
|
|||||||
| A1 | K8s 路由统一 | P2 | **done** |
|
| A1 | K8s 路由统一 | P2 | **done** |
|
||||||
| A2 | 告警引擎分布式 | P2 | **done** |
|
| A2 | 告警引擎分布式 | P2 | **done** |
|
||||||
| A3 | WebSocket 管理 | P2 | **done** |
|
| A3 | WebSocket 管理 | P2 | **done** |
|
||||||
| U1 | 虚拟滚动 | P3 | pending |
|
| U1 | 虚拟滚动 | P3 | **done** |
|
||||||
| U2 | 统一 DataTable | P3 | pending |
|
| U2 | 统一 DataTable | P3 | pending |
|
||||||
| U3 | 国际化 | P3 | pending |
|
| U3 | 国际化 | P3 | pending |
|
||||||
| U4 | 镜像优化 | P3 | pending |
|
| U4 | 镜像优化 | P3 | pending |
|
||||||
|
|||||||
+55
@@ -562,3 +562,58 @@ print("✅ U4: 5 连接并发注册成功")
|
|||||||
- [x] 心跳机制: ping=30s + pong_handler + read_deadline=60s
|
- [x] 心跳机制: ping=30s + pong_handler + read_deadline=60s
|
||||||
- [x] `go build` 零错误,17/17 回归测试通过
|
- [x] `go build` 零错误,17/17 回归测试通过
|
||||||
- [x] 6 个 WebSocket handler 共享统一 WSManager(webshell/kubectl/podexec/audit/metrics/pipeline)
|
- [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 集成回归测试通过
|
||||||
Generated
+52
-2
@@ -41,6 +41,7 @@
|
|||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@tanstack/react-query": "^5.95.2",
|
"@tanstack/react-query": "^5.95.2",
|
||||||
"@tanstack/react-query-devtools": "^5.95.2",
|
"@tanstack/react-query-devtools": "^5.95.2",
|
||||||
|
"@tanstack/react-virtual": "^3.14.4",
|
||||||
"@types/react-router-dom": "^5.3.3",
|
"@types/react-router-dom": "^5.3.3",
|
||||||
"@uiw/react-codemirror": "^4.25.10",
|
"@uiw/react-codemirror": "^4.25.10",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
@@ -132,6 +133,7 @@
|
|||||||
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.27.1",
|
"@babel/code-frame": "^7.27.1",
|
||||||
"@babel/generator": "^7.28.5",
|
"@babel/generator": "^7.28.5",
|
||||||
@@ -1953,6 +1955,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.1.tgz",
|
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.1.tgz",
|
||||||
"integrity": "sha512-+BIjw/AG3tDQ4pJgTLPYdAW25eDE66YsvM4LKyVPgGzVgZ4a9Wj1SRX8kPVKgBDdPt8oHtZ15F0qx7p0oOHdHw==",
|
"integrity": "sha512-+BIjw/AG3tDQ4pJgTLPYdAW25eDE66YsvM4LKyVPgGzVgZ4a9Wj1SRX8kPVKgBDdPt8oHtZ15F0qx7p0oOHdHw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/state": "^6.6.0",
|
"@codemirror/state": "^6.6.0",
|
||||||
"crelt": "^1.0.6",
|
"crelt": "^1.0.6",
|
||||||
@@ -4793,6 +4796,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.95.2.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.95.2.tgz",
|
||||||
"integrity": "sha512-/wGkvLj/st5Ud1Q76KF1uFxScV7WeqN1slQx5280ycwAyYkIPGaRZAEgHxe3bjirSd5Zpwkj6zNcR4cqYni/ZA==",
|
"integrity": "sha512-/wGkvLj/st5Ud1Q76KF1uFxScV7WeqN1slQx5280ycwAyYkIPGaRZAEgHxe3bjirSd5Zpwkj6zNcR4cqYni/ZA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/query-core": "5.95.2"
|
"@tanstack/query-core": "5.95.2"
|
||||||
},
|
},
|
||||||
@@ -4821,6 +4825,33 @@
|
|||||||
"react": "^18 || ^19"
|
"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": {
|
"node_modules/@types/babel__core": {
|
||||||
"version": "7.20.5",
|
"version": "7.20.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||||
@@ -4955,6 +4986,7 @@
|
|||||||
"integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==",
|
"integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.16.0"
|
"undici-types": "~7.16.0"
|
||||||
}
|
}
|
||||||
@@ -4964,6 +4996,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
|
||||||
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
|
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
}
|
}
|
||||||
@@ -4974,6 +5007,7 @@
|
|||||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.2.0"
|
||||||
}
|
}
|
||||||
@@ -5044,6 +5078,7 @@
|
|||||||
"integrity": "sha512-iIACsx8pxRnguSYhHiMn2PvhvfpopO9FXHyn1mG5txZIsAaB6F0KwbFnUQN3KCiG3Jcuad/Cao2FAs1Wp7vAyg==",
|
"integrity": "sha512-iIACsx8pxRnguSYhHiMn2PvhvfpopO9FXHyn1mG5txZIsAaB6F0KwbFnUQN3KCiG3Jcuad/Cao2FAs1Wp7vAyg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.52.0",
|
"@typescript-eslint/scope-manager": "8.52.0",
|
||||||
"@typescript-eslint/types": "8.52.0",
|
"@typescript-eslint/types": "8.52.0",
|
||||||
@@ -5348,6 +5383,7 @@
|
|||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -5611,6 +5647,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.9.0",
|
"baseline-browser-mapping": "^2.9.0",
|
||||||
"caniuse-lite": "^1.0.30001759",
|
"caniuse-lite": "^1.0.30001759",
|
||||||
@@ -6093,7 +6130,8 @@
|
|||||||
"version": "8.6.0",
|
"version": "8.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz",
|
||||||
"integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==",
|
"integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/embla-carousel-react": {
|
"node_modules/embla-carousel-react": {
|
||||||
"version": "8.6.0",
|
"version": "8.6.0",
|
||||||
@@ -6188,6 +6226,7 @@
|
|||||||
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.8.0",
|
"@eslint-community/eslint-utils": "^4.8.0",
|
||||||
"@eslint-community/regexpp": "^4.12.1",
|
"@eslint-community/regexpp": "^4.12.1",
|
||||||
@@ -6779,6 +6818,7 @@
|
|||||||
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"jiti": "bin/jiti.js"
|
"jiti": "bin/jiti.js"
|
||||||
}
|
}
|
||||||
@@ -7233,6 +7273,7 @@
|
|||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -7280,6 +7321,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.11",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
@@ -7486,6 +7528,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
|
||||||
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
|
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
@@ -7516,6 +7559,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
|
||||||
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
|
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"scheduler": "^0.27.0"
|
"scheduler": "^0.27.0"
|
||||||
},
|
},
|
||||||
@@ -7528,6 +7572,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.70.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.70.0.tgz",
|
||||||
"integrity": "sha512-COOMajS4FI3Wuwrs3GPpi/Jeef/5W1DRR84Yl5/ShlT3dKVFUfoGiEZ/QE6Uw8P4T2/CLJdcTVYKvWBMQTEpvw==",
|
"integrity": "sha512-COOMajS4FI3Wuwrs3GPpi/Jeef/5W1DRR84Yl5/ShlT3dKVFUfoGiEZ/QE6Uw8P4T2/CLJdcTVYKvWBMQTEpvw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
},
|
},
|
||||||
@@ -8089,6 +8134,7 @@
|
|||||||
"integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
|
"integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@alloc/quick-lru": "^5.2.0",
|
"@alloc/quick-lru": "^5.2.0",
|
||||||
"arg": "^5.0.2",
|
"arg": "^5.0.2",
|
||||||
@@ -8245,6 +8291,7 @@
|
|||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -8469,6 +8516,7 @@
|
|||||||
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
|
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.27.0",
|
"esbuild": "^0.27.0",
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
@@ -8575,7 +8623,8 @@
|
|||||||
"resolved": "https://registry.npmjs.org/xterm/-/xterm-5.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/xterm/-/xterm-5.3.0.tgz",
|
||||||
"integrity": "sha512-8QqjlekLUFTrU6x7xck1MsPzPA571K5zNqWm0M0oroYEWVOptZ0+ubQSkQ3uxIEhcIHRujJy6emDWX4A7qyFzg==",
|
"integrity": "sha512-8QqjlekLUFTrU6x7xck1MsPzPA571K5zNqWm0M0oroYEWVOptZ0+ubQSkQ3uxIEhcIHRujJy6emDWX4A7qyFzg==",
|
||||||
"deprecated": "This package is now deprecated. Move to @xterm/xterm instead.",
|
"deprecated": "This package is now deprecated. Move to @xterm/xterm instead.",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/xterm-addon-fit": {
|
"node_modules/xterm-addon-fit": {
|
||||||
"version": "0.8.0",
|
"version": "0.8.0",
|
||||||
@@ -8622,6 +8671,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz",
|
||||||
"integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==",
|
"integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,7 @@
|
|||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@tanstack/react-query": "^5.95.2",
|
"@tanstack/react-query": "^5.95.2",
|
||||||
"@tanstack/react-query-devtools": "^5.95.2",
|
"@tanstack/react-query-devtools": "^5.95.2",
|
||||||
|
"@tanstack/react-virtual": "^3.14.4",
|
||||||
"@types/react-router-dom": "^5.3.3",
|
"@types/react-router-dom": "^5.3.3",
|
||||||
"@uiw/react-codemirror": "^4.25.10",
|
"@uiw/react-codemirror": "^4.25.10",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
// EventTable — K8s 事件列表(时间/次数/类型/原因/对象/来源/消息)// Logan.Li
|
// 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 { Badge } from '@/components/ui/badge';
|
||||||
import {
|
|
||||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
|
||||||
} from '@/components/ui/table';
|
|
||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2 } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
type NormalizedEvent,
|
type NormalizedEvent,
|
||||||
@@ -15,110 +14,158 @@ interface EventTableProps {
|
|||||||
events: NormalizedEvent[];
|
events: NormalizedEvent[];
|
||||||
loading?: boolean | string;
|
loading?: boolean | string;
|
||||||
emptyText?: string;
|
emptyText?: string;
|
||||||
|
/** @deprecated 虚拟滚动后不再需要截断,保留参数向后兼容但忽略 */
|
||||||
maxRows?: number;
|
maxRows?: number;
|
||||||
showNamespace?: boolean;
|
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({
|
export function EventTable({
|
||||||
events,
|
events,
|
||||||
loading = false,
|
loading = false,
|
||||||
emptyText = '暂无事件',
|
emptyText = '暂无事件',
|
||||||
maxRows,
|
|
||||||
showNamespace = false,
|
showNamespace = false,
|
||||||
|
scrollHeight = 600,
|
||||||
}: EventTableProps) {
|
}: EventTableProps) {
|
||||||
const [expandedRow, setExpandedRow] = useState<string | null>(null);
|
const [expandedRow, setExpandedRow] = useState<string | null>(null);
|
||||||
const isLoading = loading === true || loading === 'events';
|
const isLoading = loading === true || loading === 'events';
|
||||||
const rows = maxRows ? events.slice(0, maxRows) : events;
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const colSpan = showNamespace ? 10 : 9;
|
|
||||||
|
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 (
|
return (
|
||||||
<div className="overflow-x-auto">
|
<div>
|
||||||
<Table>
|
{/* 表头 — sticky 固定 */}
|
||||||
<TableHeader>
|
<div
|
||||||
<TableRow className="border-[var(--dg-border)] hover:bg-transparent">
|
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"
|
||||||
<TableHead className="text-[var(--dg-text-muted)] w-[148px]">最后时间</TableHead>
|
style={{ gridTemplateColumns: gridCols, minWidth }}
|
||||||
<TableHead className="text-[var(--dg-text-muted)] w-[148px]">首次时间</TableHead>
|
>
|
||||||
<TableHead className="text-[var(--dg-text-muted)] w-16 text-center">次数</TableHead>
|
{headers.map((h, i) => (
|
||||||
<TableHead className="text-[var(--dg-text-muted)] w-20">重复</TableHead>
|
<span key={i} className={i === 2 ? 'text-center' : ''}>{h}</span>
|
||||||
<TableHead className="text-[var(--dg-text-muted)] w-20">类型</TableHead>
|
))}
|
||||||
<TableHead className="text-[var(--dg-text-muted)] w-28">原因</TableHead>
|
</div>
|
||||||
<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>
|
<div ref={scrollRef} style={{ height: scrollHeight, overflow: 'auto' }} className="overflow-x-auto">
|
||||||
<TableHead className="text-[var(--dg-text-muted)] min-w-[200px]">消息</TableHead>
|
{isLoading ? (
|
||||||
</TableRow>
|
<div className="flex items-center justify-center" style={{ height: scrollHeight }}>
|
||||||
</TableHeader>
|
<Loader2 className="h-5 w-5 animate-spin text-[var(--dg-text-muted)]" />
|
||||||
<TableBody>
|
</div>
|
||||||
{isLoading ? (
|
) : events.length === 0 ? (
|
||||||
<TableRow>
|
<div className="flex items-center justify-center text-[var(--dg-text-muted)] py-10">{emptyText}</div>
|
||||||
<TableCell colSpan={colSpan} className="text-center py-10">
|
) : (
|
||||||
<Loader2 className="h-5 w-5 animate-spin mx-auto text-[var(--dg-text-muted)]" />
|
<div style={{ height: totalHeight, position: 'relative', minWidth }}>
|
||||||
</TableCell>
|
{virtualRows.map((vr) => {
|
||||||
</TableRow>
|
const e = events[vr.index];
|
||||||
) : rows.length === 0 ? (
|
if (!e) return null;
|
||||||
<TableRow>
|
const rowKey = `${e.name || e.object}-${vr.index}`;
|
||||||
<TableCell colSpan={colSpan} className="text-center text-[var(--dg-text-muted)] py-10">{emptyText}</TableCell>
|
const isExpanded = expandedRow === rowKey;
|
||||||
</TableRow>
|
const isWarning = e.type === 'Warning';
|
||||||
) : rows.map((e, i) => {
|
const last = formatEventTimeCell(e.lastTime);
|
||||||
const rowKey = `${e.name || e.object}-${i}`;
|
const first = formatEventTimeCell(e.firstTime);
|
||||||
const isExpanded = expandedRow === rowKey;
|
|
||||||
const isWarning = e.type === 'Warning';
|
return (
|
||||||
const last = formatEventTimeCell(e.lastTime);
|
<div
|
||||||
const first = formatEventTimeCell(e.firstTime);
|
key={vr.key}
|
||||||
return (
|
data-index={vr.index}
|
||||||
<TableRow
|
ref={virtualizer.measureElement}
|
||||||
key={rowKey}
|
style={{
|
||||||
className={`border-[var(--dg-border)] cursor-pointer hover:bg-[var(--dg-bg-tertiary)]/40 ${isExpanded ? 'bg-[var(--dg-bg-tertiary)]/30' : ''}`}
|
position: 'absolute',
|
||||||
onClick={() => setExpandedRow(isExpanded ? null : rowKey)}
|
top: 0,
|
||||||
>
|
left: 0,
|
||||||
<TableCell className="text-xs text-[var(--dg-text-muted)]">
|
width: '100%',
|
||||||
<div>{last.absolute}</div>
|
transform: `translateY(${vr.start}px)`,
|
||||||
<div className="text-[10px]">{last.relative}</div>
|
display: 'grid',
|
||||||
</TableCell>
|
gridTemplateColumns: gridCols,
|
||||||
<TableCell className="text-xs text-[var(--dg-text-muted)]">
|
gap: '0.5rem',
|
||||||
<div>{first.absolute}</div>
|
}}
|
||||||
<div className="text-[10px]">{first.relative}</div>
|
className={`px-4 py-2.5 border-b border-[var(--dg-border)] cursor-pointer transition-colors ${
|
||||||
</TableCell>
|
isExpanded ? 'bg-[var(--dg-bg-tertiary)]/40' : 'hover:bg-[var(--dg-bg-tertiary)]/30'
|
||||||
<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>
|
onClick={() => setExpandedRow(isExpanded ? null : rowKey)}
|
||||||
<TableCell>
|
>
|
||||||
<Badge
|
{/* 最后时间 */}
|
||||||
variant="outline"
|
<div className="text-xs text-[var(--dg-text-muted)]">
|
||||||
className={isWarning
|
<div>{last.absolute}</div>
|
||||||
? 'text-[var(--dg-warning)] border-[var(--dg-warning)]/30 text-[10px]'
|
<div className="text-[10px]">{last.relative}</div>
|
||||||
: '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>
|
</div>
|
||||||
{isExpanded && e.name && (
|
{/* 首次时间 */}
|
||||||
<div className="mt-1 pt-1 border-t border-[var(--dg-border)] text-[10px] text-[var(--dg-text-muted)]">
|
<div className="text-xs text-[var(--dg-text-muted)]">
|
||||||
事件名: {e.name}
|
<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>
|
</div>
|
||||||
)}
|
{isExpanded && e.name && (
|
||||||
</TableCell>
|
<div className="mt-1 pt-1 border-t border-[var(--dg-border)] text-[10px] text-[var(--dg-text-muted)]">
|
||||||
</TableRow>
|
事件名: {e.name}
|
||||||
);
|
</div>
|
||||||
})}
|
)}
|
||||||
</TableBody>
|
</div>
|
||||||
</Table>
|
</div>
|
||||||
{maxRows && events.length > maxRows && (
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 行数统计 */}
|
||||||
|
{events.length > 0 && (
|
||||||
<div className="text-center py-2 text-xs text-[var(--dg-text-muted)] border-t border-[var(--dg-border)]">
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -296,7 +296,6 @@ export default function EventsPage() {
|
|||||||
events={displayEvents}
|
events={displayEvents}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
showNamespace
|
showNamespace
|
||||||
maxRows={200}
|
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
// deployment-view/PodsTab.tsx — 容器组 Tab // Logan.Li
|
// 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 { Card, CardContent } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
@@ -15,20 +18,39 @@ import { statusColor } from '../k8s-yaml-utils';
|
|||||||
import { PodConditionsRow } from './helpers';
|
import { PodConditionsRow } from './helpers';
|
||||||
import type { DeploymentViewProps } from './types';
|
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({
|
export function PodsTab({
|
||||||
clusterCode, pods, onShowPodLogs, onShowPodTerminal, onShowPodYaml, onShowPodFileBrowser,
|
clusterCode, pods, onShowPodLogs, onShowPodTerminal, onShowPodYaml, onShowPodFileBrowser,
|
||||||
onDeletePod,
|
onDeletePod,
|
||||||
expandedPod, setExpandedPod,
|
expandedPod, setExpandedPod,
|
||||||
}: DeploymentViewProps & { expandedPod: string | null; setExpandedPod: (v: string | null) => void }) {
|
}: 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) {
|
if (pods.length === 0) {
|
||||||
return <div className="text-center text-[var(--dg-text-muted)] py-12">暂无容器组</div>;
|
return <div className="text-center text-[var(--dg-text-muted)] py-12">暂无容器组</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const virtualRows = virtualizer.getVirtualItems();
|
||||||
|
const totalHeight = virtualizer.getTotalSize();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="dg-card overflow-hidden">
|
<Card className="dg-card overflow-hidden">
|
||||||
<CardContent className="p-0 overflow-x-auto">
|
<CardContent className="p-0">
|
||||||
{/* 表头:操作列固定宽度,避免更多按钮溢出卡片 // 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">
|
<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>名称</span>
|
||||||
<span>状态</span>
|
<span>状态</span>
|
||||||
@@ -39,130 +61,155 @@ export function PodsTab({
|
|||||||
<span className="text-right">操作</span>
|
<span className="text-right">操作</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="divide-y divide-[var(--dg-border)]">
|
{/* 虚拟滚动列表 */}
|
||||||
{pods.map(pod => {
|
<div ref={scrollRef} style={{ height: 'min(600px, calc(100vh - 320px))', overflow: 'auto' }} className="overflow-x-auto">
|
||||||
const expanded = expandedPod === pod.name;
|
<div style={{ height: totalHeight, position: 'relative', minWidth: MIN_WIDTH }}>
|
||||||
const nodeName = pod.node_name || pod.node;
|
{virtualRows.map((vr) => {
|
||||||
const sc = statusColor(pod.status);
|
const pod = pods[vr.index];
|
||||||
const containers = (pod as Record<string, any>).containers as any[] | undefined;
|
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 (
|
return (
|
||||||
<div key={pod.name}>
|
|
||||||
{/* 列表行:紧凑信息,不重复展示完整时间线 // Logan.Li */}
|
|
||||||
<div
|
<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 ${
|
key={pod.name}
|
||||||
expanded ? 'bg-[var(--dg-bg-tertiary)]/30' : 'hover:bg-[var(--dg-bg-tertiary)]/20'
|
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"
|
<div
|
||||||
size="icon"
|
className={`grid gap-2 items-center px-4 py-3 transition-colors ${
|
||||||
className="h-6 w-6 text-[var(--dg-text-muted)]"
|
expanded ? '' : 'hover:bg-[var(--dg-bg-tertiary)]/20'
|
||||||
onClick={() => setExpandedPod(expanded ? null : pod.name)}
|
}`}
|
||||||
|
style={{ gridTemplateColumns: GRID_COLS }}
|
||||||
>
|
>
|
||||||
{expanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
<Button
|
||||||
</Button>
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
<span className="font-mono text-sm text-[var(--dg-text-primary)] truncate" title={pod.name}>
|
className="h-6 w-6 text-[var(--dg-text-muted)]"
|
||||||
{pod.name}
|
onClick={() => setExpandedPod(expanded ? null : pod.name)}
|
||||||
</span>
|
>
|
||||||
|
{expanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||||
<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>
|
||||||
<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" />
|
<span className="font-mono text-sm text-[var(--dg-text-primary)] truncate" title={pod.name}>
|
||||||
</Button>
|
{pod.name}
|
||||||
<DropdownMenu>
|
</span>
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button
|
<Badge
|
||||||
variant="ghost"
|
className="w-fit text-[11px] font-normal"
|
||||||
size="sm"
|
style={{ background: `${sc}18`, color: sc, border: `1px solid ${sc}33` }}
|
||||||
className="h-7 w-7 p-0 shrink-0 text-[var(--dg-text-muted)] hover:text-[var(--dg-blue)]"
|
>
|
||||||
title="更多"
|
{pod.status || 'Unknown'}
|
||||||
aria-label="更多"
|
</Badge>
|
||||||
>
|
|
||||||
<MoreVertical className="h-3.5 w-3.5" />
|
<button
|
||||||
</Button>
|
className="text-xs text-[var(--dg-blue)] hover:underline truncate text-left"
|
||||||
</DropdownMenuTrigger>
|
title={nodeName}
|
||||||
<DropdownMenuContent align="end" className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
onClick={() => nodeName && window.open(`/kubernetes/${clusterCode}/nodes/${nodeName}`, '_blank')}
|
||||||
<DropdownMenuItem onClick={() => onShowPodLogs(pod)} className="text-[var(--dg-text-primary)]">
|
>
|
||||||
<FileText className="h-3.5 w-3.5 mr-2" /> 日志
|
{nodeName || '—'}
|
||||||
</DropdownMenuItem>
|
</button>
|
||||||
<DropdownMenuItem onClick={() => onShowPodYaml(pod)} className="text-[var(--dg-text-primary)]">
|
|
||||||
<FileCode className="h-3.5 w-3.5 mr-2" /> YAML
|
<span className="text-xs font-mono text-[var(--dg-text-muted)] truncate">
|
||||||
</DropdownMenuItem>
|
{pod.ip || pod.pod_ip || '—'}
|
||||||
<DropdownMenuItem onClick={() => onDeletePod?.(pod)} className="text-red-400 focus:text-red-400">
|
</span>
|
||||||
<Trash2 className="h-3.5 w-3.5 mr-2" /> 删除
|
|
||||||
</DropdownMenuItem>
|
<span className={`text-xs text-center font-mono ${(pod.restart_count || 0) > 0 ? 'text-amber-400' : 'text-[var(--dg-text-muted)]'}`}>
|
||||||
</DropdownMenuContent>
|
{pod.restart_count || 0}
|
||||||
</DropdownMenu>
|
</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>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 展开详情:时间线与容器信息仅在此处展示 // Logan.Li */}
|
{/* 展开详情 */}
|
||||||
{expanded && (
|
{expanded && (
|
||||||
<div className="px-4 pb-4 pt-1 space-y-3 bg-[var(--dg-bg-tertiary)]/20">
|
<div className="px-4 pb-4 pt-1 space-y-3 bg-[var(--dg-bg-tertiary)]/20">
|
||||||
<PodConditionsRow pod={pod} />
|
<PodConditionsRow pod={pod} />
|
||||||
{containers && containers.length > 0 && (
|
{containers && containers.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-[var(--dg-text-muted)] mb-2">容器</p>
|
<p className="text-xs text-[var(--dg-text-muted)] mb-2">容器</p>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||||
{containers.map((c: any, idx: number) => (
|
{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 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">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<span className="font-medium text-[var(--dg-text-primary)]">{c.name}</span>
|
<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)]'}>
|
<span className={c.ready ? 'text-emerald-400' : 'text-[var(--dg-text-muted)]'}>
|
||||||
{c.ready ? 'Ready' : 'Pending'}
|
{c.ready ? 'Ready' : 'Pending'}
|
||||||
</span>
|
</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>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
<p className="text-[var(--dg-text-muted)] mt-1 truncate" title={String(c.image ?? '')}>{String(c.image ?? '')}</p>
|
||||||
</div>
|
{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>
|
||||||
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -1,17 +1,11 @@
|
|||||||
// 操作审计 Tab — 增强版审计日志列表,含筛选/导出/统计/清除
|
// 操作审计 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 { Card, CardContent } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from '@/components/ui/table';
|
|
||||||
import {
|
import {
|
||||||
Search,
|
Search,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
@@ -148,6 +142,20 @@ export function AuditOperationsTab() {
|
|||||||
setDetailLog(log);
|
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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
@@ -208,7 +216,7 @@ export function AuditOperationsTab() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Logs Table */}
|
{/* Logs Table — 虚拟滚动 */}
|
||||||
<Card className="bg-slate-900 border-slate-800">
|
<Card className="bg-slate-900 border-slate-800">
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<div className="border-t border-slate-800">
|
<div className="border-t border-slate-800">
|
||||||
@@ -222,64 +230,82 @@ export function AuditOperationsTab() {
|
|||||||
<p className="text-sm">暂无操作日志</p>
|
<p className="text-sm">暂无操作日志</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Table>
|
<>
|
||||||
<TableHeader>
|
{/* 表头 */}
|
||||||
<TableRow className="border-slate-800 hover:bg-transparent">
|
<div
|
||||||
<TableHead className="text-slate-400">时间</TableHead>
|
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"
|
||||||
<TableHead className="text-slate-400">用户</TableHead>
|
style={{ gridTemplateColumns: GRID_COLS }}
|
||||||
<TableHead className="text-slate-400">模块</TableHead>
|
>
|
||||||
<TableHead className="text-slate-400">操作类型</TableHead>
|
<span>时间</span>
|
||||||
<TableHead className="text-slate-400">资源</TableHead>
|
<span>用户</span>
|
||||||
<TableHead className="text-slate-400">IP</TableHead>
|
<span>模块</span>
|
||||||
<TableHead className="text-slate-400">结果</TableHead>
|
<span>操作类型</span>
|
||||||
<TableHead className="text-slate-400 text-right">详情</TableHead>
|
<span>资源</span>
|
||||||
</TableRow>
|
<span>IP</span>
|
||||||
</TableHeader>
|
<span>结果</span>
|
||||||
<TableBody>
|
<span className="text-right">详情</span>
|
||||||
{filteredLogs.map((log) => (
|
</div>
|
||||||
<TableRow key={log.id} className="border-slate-800 hover:bg-slate-800/50">
|
|
||||||
<TableCell>
|
{/* 虚拟滚动列表 */}
|
||||||
<span className="text-sm text-slate-400">
|
<div ref={scrollRef} style={{ height: 'min(600px, calc(100vh - 340px))', overflow: 'auto' }}>
|
||||||
{log.createdAt ? new Date(log.createdAt).toLocaleString() : '--'}
|
<div style={{ height: totalHeight, position: 'relative' }}>
|
||||||
</span>
|
{virtualRows.map((vr) => {
|
||||||
</TableCell>
|
const log = filteredLogs[vr.index];
|
||||||
<TableCell>
|
if (!log) return null;
|
||||||
<span className="text-sm text-slate-200">{log.username || '--'}</span>
|
return (
|
||||||
</TableCell>
|
<div
|
||||||
<TableCell>
|
key={log.id}
|
||||||
<span className="text-sm text-slate-300">{log.module || '--'}</span>
|
data-index={vr.index}
|
||||||
</TableCell>
|
ref={virtualizer.measureElement}
|
||||||
<TableCell>
|
style={{
|
||||||
<Badge variant="outline" className="border-blue-500/30 text-blue-400 bg-blue-500/10">
|
position: 'absolute',
|
||||||
{log.action || '--'}
|
top: 0,
|
||||||
</Badge>
|
left: 0,
|
||||||
</TableCell>
|
width: '100%',
|
||||||
<TableCell>
|
transform: `translateY(${vr.start}px)`,
|
||||||
<code className="text-xs text-slate-400 bg-slate-800 px-2 py-0.5 rounded">
|
display: 'grid',
|
||||||
{log.resource || '--'}
|
gridTemplateColumns: GRID_COLS,
|
||||||
</code>
|
gap: '0.5rem',
|
||||||
</TableCell>
|
}}
|
||||||
<TableCell>
|
className="px-4 py-2.5 border-b border-slate-800 hover:bg-slate-800/50"
|
||||||
<span className="text-sm text-slate-400">{log.ip || '--'}</span>
|
>
|
||||||
</TableCell>
|
<span className="text-sm text-slate-400 truncate">
|
||||||
<TableCell>{getLogResultBadge(log.result)}</TableCell>
|
{log.createdAt ? new Date(log.createdAt).toLocaleString() : '--'}
|
||||||
<TableCell>
|
</span>
|
||||||
<div className="flex items-center justify-end">
|
<span className="text-sm text-slate-200 truncate">{log.username || '--'}</span>
|
||||||
<Button
|
<span className="text-sm text-slate-300 truncate">{log.module || '--'}</span>
|
||||||
variant="ghost"
|
<div>
|
||||||
size="icon"
|
<Badge variant="outline" className="border-blue-500/30 text-blue-400 bg-blue-500/10">
|
||||||
className="h-8 w-8 text-slate-400 hover:text-blue-400 hover:bg-slate-800"
|
{log.action || '--'}
|
||||||
onClick={() => viewDetail(log)}
|
</Badge>
|
||||||
title="查看详情"
|
</div>
|
||||||
>
|
<code className="text-xs text-slate-400 bg-slate-800 px-2 py-0.5 rounded truncate">
|
||||||
<Eye className="h-4 w-4" />
|
{log.resource || '--'}
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
</TableCell>
|
);
|
||||||
</TableRow>
|
})}
|
||||||
))}
|
</div>
|
||||||
</TableBody>
|
</div>
|
||||||
</Table>
|
|
||||||
|
{/* 行数统计 */}
|
||||||
|
<div className="text-center py-2 text-xs text-slate-500 border-t border-slate-800">
|
||||||
|
共 {filteredLogs.length} 条日志 {filteredLogs.length > 200 && '· 虚拟滚动渲染'}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -1,21 +1,17 @@
|
|||||||
// 终端审计 — 会话命令子面板 // Logan.Li
|
// 终端审计 — 会话命令子面板 // 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 { Card, CardContent } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from '@/components/ui/table';
|
|
||||||
import { Search, RefreshCw } from 'lucide-react';
|
import { Search, RefreshCw } from 'lucide-react';
|
||||||
import { useSessionCommands } from '@/hooks/api';
|
import { useSessionCommands } from '@/hooks/api';
|
||||||
import { formatSize } from './helpers';
|
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() {
|
export function SessionCommandsPanel() {
|
||||||
const [keyword, setKeyword] = useState('');
|
const [keyword, setKeyword] = useState('');
|
||||||
const [sessionIdInput, setSessionIdInput] = useState('');
|
const [sessionIdInput, setSessionIdInput] = useState('');
|
||||||
@@ -23,7 +19,7 @@ export function SessionCommandsPanel() {
|
|||||||
const queryParams = useMemo(
|
const queryParams = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
page: 1,
|
page: 1,
|
||||||
page_size: 200,
|
page_size: 500,
|
||||||
...(sessionIdInput ? { session_id: Number(sessionIdInput) } : {}),
|
...(sessionIdInput ? { session_id: Number(sessionIdInput) } : {}),
|
||||||
}),
|
}),
|
||||||
[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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center gap-3 flex-wrap">
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
@@ -81,46 +88,66 @@ export function SessionCommandsPanel() {
|
|||||||
<p className="text-sm">暂无会话命令记录</p>
|
<p className="text-sm">暂无会话命令记录</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Table>
|
<>
|
||||||
<TableHeader>
|
{/* 表头 */}
|
||||||
<TableRow className="border-slate-800 hover:bg-transparent">
|
<div
|
||||||
<TableHead className="text-slate-400">时间</TableHead>
|
className="grid gap-2 px-4 py-2 border-b border-slate-800 bg-slate-900 text-xs font-medium text-slate-400"
|
||||||
<TableHead className="text-slate-400">用户</TableHead>
|
style={{ gridTemplateColumns: GRID_COLS }}
|
||||||
<TableHead className="text-slate-400">命令</TableHead>
|
>
|
||||||
<TableHead className="text-slate-400">输出大小</TableHead>
|
<span>时间</span>
|
||||||
<TableHead className="text-slate-400">关联会话</TableHead>
|
<span>用户</span>
|
||||||
</TableRow>
|
<span>命令</span>
|
||||||
</TableHeader>
|
<span>输出大小</span>
|
||||||
<TableBody>
|
<span>关联会话</span>
|
||||||
{filtered.map((c) => (
|
</div>
|
||||||
<TableRow key={c.id} className="border-slate-800 hover:bg-slate-800/50">
|
|
||||||
<TableCell>
|
{/* 虚拟滚动列表 */}
|
||||||
<span className="text-sm text-slate-400">
|
<div ref={scrollRef} style={{ height: 'min(600px, calc(100vh - 340px))', overflow: 'auto' }}>
|
||||||
{c.created_at ? new Date(c.created_at).toLocaleString() : '--'}
|
<div style={{ height: totalHeight, position: 'relative' }}>
|
||||||
</span>
|
{virtualRows.map((vr) => {
|
||||||
</TableCell>
|
const c = filtered[vr.index];
|
||||||
<TableCell>
|
if (!c) return null;
|
||||||
<span className="text-sm text-slate-200">{c.username || '--'}</span>
|
return (
|
||||||
</TableCell>
|
<div
|
||||||
<TableCell>
|
key={c.id}
|
||||||
<code className="text-xs text-slate-300 bg-slate-800 px-2 py-0.5 rounded max-w-[400px] truncate block">
|
data-index={vr.index}
|
||||||
{c.command || '--'}
|
ref={virtualizer.measureElement}
|
||||||
</code>
|
style={{
|
||||||
</TableCell>
|
position: 'absolute',
|
||||||
<TableCell>
|
top: 0,
|
||||||
<span className="text-sm text-slate-400">{formatSize(c.output_size)}</span>
|
left: 0,
|
||||||
</TableCell>
|
width: '100%',
|
||||||
<TableCell>
|
transform: `translateY(${vr.start}px)`,
|
||||||
<span className="text-xs text-slate-500">#{c.session_id}</span>
|
display: 'grid',
|
||||||
</TableCell>
|
gridTemplateColumns: GRID_COLS,
|
||||||
</TableRow>
|
gap: '0.5rem',
|
||||||
))}
|
}}
|
||||||
</TableBody>
|
className="px-4 py-2.5 border-b border-slate-800 hover:bg-slate-800/50"
|
||||||
</Table>
|
>
|
||||||
|
<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>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user