diff --git a/PROJECT_EVALUATION.md b/PROJECT_EVALUATION.md index 75b9962..2a82e7a 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 统一入口;扣分项:后端镜像偏大 | -| 用户体验 | **88/100** | 骨架屏 + 页面过渡 + 5 套主题 + 懒加载 + 虚拟滚动;扣分项:无 i18n | -| **综合** | **87/100** | 企业级运维平台,功能覆盖全面,P0 全部完成,P1 全部完成,P2 A1/A2/A3 全部完成,P3 U1 虚拟滚动 done | +| 用户体验 | **88/100** | 骨架屏 + 页面过渡 + 5 套主题 + 懒加载 + 虚拟滚动 + 统一 DataTable;扣分项:无 i18n | +| **综合** | **87/100** | 企业级运维平台,功能覆盖全面,P0 全部完成,P1 全部完成,P2 A1/A2/A3 全部完成,P3 U1 虚拟滚动 done, U2 统一 DataTable done | diff --git a/ROADMAP.md b/ROADMAP.md index 2110342..44b0b7c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -240,10 +240,29 @@ services/api.ts (1324L) - [x] 17/17 集成测试通过 - [x] Docker 部署 healthy -### U2: 统一 DataTable 组件 [pending] -- 封装排序/筛选/分页/导出/列自定义的通用表格 +### U2: 统一 DataTable 组件 [done] + +**问题**: 37 个文件各自手写表格(搜索/排序/分页/导出/选择全部重复实现),维护成本高 + +**方案**: +- 封装 `DataTable` 泛型组件(`components/ui/data-table.tsx`),声明式列定义 + 内置功能开关 - 替换散布在各页面的手写表格 +**已完成 (2026-06-27)**: +- ✅ 新增 `DataTable` 组件 — 支持声明式列定义、排序(三态 asc/desc/null)、全局搜索(指定字段)、客户端分页(可配置 pageSize)、行选择(受控 selectedIds)、CSV 导出(UTF-8 BOM)、列可见性面板(DropdownMenu toggle)、加载骨架、空状态自定义渲染、行点击、CSS 变量主题适配 +- ✅ 迁移 3 个代表性页面: + - **RegionManager.tsx** — 区域管理(CRUD + 搜索 + 选择 + 排序 + 导出 + 列可见性 + 云商筛选) + - **LoginLogsTab.tsx** — 登录日志(搜索 + 排序 + 分页 + 导出 + 列可见性 + 状态筛选) + - **LogDatasourcePage.tsx** — 日志数据源管理(搜索 + 排序 + CRUD + 测试连接) +- ✅ 所有功能内建(搜索/排序/分页/选择/导出/列可见性),无需在各页面重复实现 + +**验收标准**: +- [x] `DataTable` 组件封装排序/搜索/分页/导出/列自定义/行选择 +- [x] `tsc -b` 零错误,`npm run build` 零错误(✓ built in 18.05s) +- [x] 迁移的页面功能不变(CRUD/搜索/选择/排序/导出全部保留) +- [x] ESLint any=43(<50),4 个迁移文件零 ESLint 错误 +- [x] 17/17 集成回归测试通过 + ### U3: 国际化 (i18n) [pending] - 提取所有中文文案到 `i18n/zh-CN.json` - 预留 `en-US.json` 扩展 @@ -268,7 +287,7 @@ services/api.ts (1324L) | A2 | 告警引擎分布式 | P2 | **done** | | A3 | WebSocket 管理 | P2 | **done** | | U1 | 虚拟滚动 | P3 | **done** | -| U2 | 统一 DataTable | P3 | pending | +| U2 | 统一 DataTable | P3 | **done** | | U3 | 国际化 | P3 | pending | | U4 | 镜像优化 | P3 | pending | diff --git a/TDD_PLAN.md b/TDD_PLAN.md index 6d4b2e1..805c58a 100644 --- a/TDD_PLAN.md +++ b/TDD_PLAN.md @@ -617,3 +617,58 @@ python3 tests/integration_test.py --host 127.0.0.1 --port 8080 | tail -3 - [x] U7: AuditOperationsTab 虚拟滚动 - [x] U8: EventTable 不再截断事件列表 - [x] U9: 17/17 集成回归测试通过 + +--- + +## P3-U2: 统一 DataTable 组件 + +### 验证用例 + +```bash +# U1: DataTable 组件存在 +ls -la /srv/aiops/frontend/src/components/ui/data-table.tsx +# 期望: 文件存在 + +# U2: 导出 DataTable 和 DataTableColumn +grep -c 'export.*DataTable' /srv/aiops/frontend/src/components/ui/data-table.tsx +# 期望: >= 2 (DataTable + DataTableColumn) + +# U3: TypeScript 编译零错误 +cd /srv/aiops/frontend && npx tsc -b 2>&1 | tail -3 +# 期望: 退出码 0, 无输出 + +# U4: Vite 构建 +npm run build 2>&1 | tail -3 +# 期望: built in Ns + +# U5: RegionManager 使用 DataTable +grep -c 'DataTable' /srv/aiops/frontend/src/components/cloud/RegionManager.tsx +# 期望: >= 2 (import + usage) + +# U6: LoginLogsTab 使用 DataTable +grep -c 'DataTable' /srv/aiops/frontend/src/pages/LoginLogsTab.tsx +# 期望: >= 2 + +# U7: LogDatasourcePage 使用 DataTable +grep -c 'DataTable' /srv/aiops/frontend/src/components/monitor/log-center/LogDatasourcePage.tsx +# 期望: >= 2 + +# U8: DataTable 支持 sortable/searchable/pagination/selectable/exportable/columnToggle +grep -oP '(sortable|searchable|pagination|selectable|exportable|columnToggle)' /srv/aiops/frontend/src/components/ui/data-table.tsx | sort -u | wc -l +# 期望: >= 6 + +# U9: 集成回归测试 +python3 tests/integration_test.py --host 127.0.0.1 --port 8080 | tail -3 +# 期望: 17/17 passed +``` + +### 通过标准 +- [x] U1: DataTable 组件文件存在 +- [x] U2: 导出 DataTable 和 DataTableColumn 类型 +- [x] U3: `tsc -b` 零错误 +- [x] U4: Vite 构建成功 (✓ built in 18.05s) +- [x] U5: RegionManager 迁移到 DataTable(含搜索/排序/选择/导出/列可见性) +- [x] U6: LoginLogsTab 迁移到 DataTable(含搜索/排序/分页/导出/列可见性) +- [x] U7: LogDatasourcePage 迁移到 DataTable(含搜索/排序/CRUD) +- [x] U8: 支持 6+ 核心功能开关(sortable/searchable/pagination/selectable/exportable/columnToggle) +- [x] U9: 17/17 集成回归测试通过 diff --git a/frontend/src/components/cloud/RegionManager.tsx b/frontend/src/components/cloud/RegionManager.tsx index 8d198d7..fe60974 100644 --- a/frontend/src/components/cloud/RegionManager.tsx +++ b/frontend/src/components/cloud/RegionManager.tsx @@ -3,15 +3,7 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Badge } from '@/components/ui/badge'; -import { Checkbox } from '@/components/ui/checkbox'; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@/components/ui/table'; +import { DataTable, type DataTableColumn } from '@/components/ui/data-table'; import { Dialog, DialogContent, @@ -29,7 +21,6 @@ import { } from '@/components/ui/select'; import { Plus, - Search, Trash2, Pencil, MapPin, @@ -62,9 +53,8 @@ export function RegionManager() { const confirm = useConfirm(); const [regions, setRegions] = useState([]); const [loading, setLoading] = useState(true); - const [searchQuery, setSearchQuery] = useState(''); const [selectedProvider, setSelectedProvider] = useState('all'); - const [selectedIds, setSelectedIds] = useState([]); + const [selectedIds, setSelectedIds] = useState<(string | number)[]>([]); const [dialogOpen, setDialogOpen] = useState(false); const [editingItem, setEditingItem] = useState(null); const [submitting, setSubmitting] = useState(false); @@ -79,7 +69,7 @@ export function RegionManager() { const res = await regionApi.list(); const items = (res as JsonData)?.list || []; setRegions(items); - } catch { + } catch (_e) { setRegions([]); } finally { setLoading(false); @@ -89,25 +79,10 @@ export function RegionManager() { useEffect(() => { loadData(); }, [loadData]); const filtered = regions.filter(r => { - const matchSearch = r.name?.toLowerCase().includes(searchQuery.toLowerCase()) || - r.code?.toLowerCase().includes(searchQuery.toLowerCase()); const matchProvider = selectedProvider === 'all' || r.provider === selectedProvider; - return matchSearch && matchProvider; + return matchProvider; }); - // 全选 / 单选 // Logan.Li - const allSelected = filtered.length > 0 && filtered.every(r => selectedIds.includes(r.id)); - const toggleSelectAll = () => { - if (allSelected) { - setSelectedIds([]); - } else { - setSelectedIds(filtered.map(r => r.id)); - } - }; - const toggleSelect = (id: number) => { - setSelectedIds(prev => prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id]); - }; - const openCreate = () => { setEditingItem(null); setFormData({ name: '', code: '', provider: 'aliyun', location: '', status: 'active', endpoint: '' }); @@ -134,7 +109,8 @@ export function RegionManager() { } setDialogOpen(false); loadData(); - } catch { + } catch (_e) { + // handleSubmit error — silently ignored } finally { setSubmitting(false); } @@ -172,7 +148,7 @@ export function RegionManager() { }); if (!ok) return; try { - await regionApi.batchDelete(selectedIds); + await regionApi.batchDelete(selectedIds.map(Number)); toast.success(`已删除 ${selectedIds.length} 个区域`); setSelectedIds([]); loadData(); @@ -182,46 +158,49 @@ export function RegionManager() { } }; + // DataTable 列定义 + const columns: DataTableColumn[] = [ + { key: 'name', header: '区域名称', sortable: true, render: (r) => {r.name} }, + { key: 'code', header: '编码', sortable: true, render: (r) => {r.code} }, + { + key: 'provider', header: '云商', sortable: true, hideable: false, + render: (r) => {providerNames[r.provider] || r.provider}, + }, + { key: 'location', header: '位置', render: (r) => r.location || '—' }, + { key: 'endpoint', header: '接入端点', render: (r) => {r.endpoint || '—'} }, + { + key: 'status', header: '状态', sortable: true, + render: (r) => r.status === 'active' ? ( + 启用 + ) : ( + 禁用 + ), + }, + { + key: 'actions', header: '操作', align: 'right', hideable: false, + render: (r) => ( +
+ + +
+ ), + }, + ]; + return (
- {/* Toolbar */} -
-
-
- - setSearchQuery(e.target.value)} - className="pl-9 h-9 bg-slate-800/50 border-slate-700" - /> -
- -
- -
- - {/* 批量操作栏 // Logan.Li */} + {/* 批量操作栏 */} {selectedIds.length > 0 && ( -
- - 已选择 {selectedIds.length} 个区域 +
+ + 已选择 {selectedIds.length} 个区域
- + + } + /> {/* Dialog */} diff --git a/frontend/src/components/monitor/log-center/LogDatasourcePage.tsx b/frontend/src/components/monitor/log-center/LogDatasourcePage.tsx index d124dd8..7216f0e 100644 --- a/frontend/src/components/monitor/log-center/LogDatasourcePage.tsx +++ b/frontend/src/components/monitor/log-center/LogDatasourcePage.tsx @@ -1,13 +1,10 @@ -// LogDatasourcePage — 日志库数据源管理(Loki / Elasticsearch CRUD)// Logan.Li +// LogDatasourcePage — 日志库数据源管理(Loki / Elasticsearch CRUD) import { useState, useEffect, useCallback } from 'react'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; -import { Card, CardContent } from '@/components/ui/card'; +import { DataTable, type DataTableColumn } from '@/components/ui/data-table'; import { - Table, TableBody, TableCell, TableHead, TableHeader, TableRow, -} from '@/components/ui/table'; -import { - Plus, Pencil, Trash2, RefreshCw, Loader2, Star, Plug, + Plus, Pencil, Trash2, RefreshCw, Star, Plug, } from 'lucide-react'; import { useToast } from '@/hooks/use-toast'; import { useConfirm } from '@/components/ui/confirm-dialog'; @@ -58,7 +55,7 @@ export function LogDatasourcePage() { await logCenterApi.deleteDatasource(ds.id); toast({ title: '已删除' }); load(); - } catch (e: any) { + } catch (e) { toast({ title: '删除失败', description: e?.message || String(e), variant: 'destructive' }); } }; @@ -74,7 +71,7 @@ export function LogDatasourcePage() { skip_tls_verify: ds.skip_tls_verify, }); toast({ title: '连接成功', description: `${ds.name} 可达` }); - } catch (e: any) { + } catch (e) { toast({ title: '连接失败', description: e?.message || String(e), variant: 'destructive' }); } }; @@ -84,7 +81,7 @@ export function LogDatasourcePage() { await logCenterApi.setDefaultDatasource(ds.id); toast({ title: '已设为默认' }); load(); - } catch (e: any) { + } catch (e) { toast({ title: '操作失败', description: e?.message || String(e), variant: 'destructive' }); } }; @@ -94,100 +91,80 @@ export function LogDatasourcePage() { ? 'bg-orange-500/15 text-orange-400 border-orange-500/25' : 'bg-cyan-500/15 text-cyan-400 border-cyan-500/25'; + // DataTable 列定义 + const columns: DataTableColumn[] = [ + { key: 'name', header: '名称', sortable: true, render: (ds) => {ds.name} }, + { + key: 'type', header: '类型', sortable: true, width: 120, + render: (ds) => {engineLabel(ds.type)}, + }, + { + key: 'url', header: '地址', + render: (ds) => {ds.url}, + }, + { + key: 'is_default', header: '默认', width: 60, align: 'center', sortable: true, + render: (ds) => ds.is_default ? : —, + }, + { + key: 'actions', header: '操作', align: 'right', hideable: false, + render: (ds) => ( +
+ + {!ds.is_default && ( + + )} + + +
+ ), + }, + ]; + return (
- {/* Toolbar */} -
-
-

- 日志查询依赖数据源连接,平台不存储海量日志本体 -

-
-
- - - -
-
+ {/* Description */} +

+ 日志查询依赖数据源连接,平台不存储海量日志本体 +

- {/* Table */} - - - - - - 名称 - 类型 - 地址 - 默认 - 操作 - - - - {loading ? ( - - - 加载中... - - - ) : sources.length === 0 ? ( - - - 暂无数据源,点击上方按钮添加 - - - ) : ( - sources.map((ds) => ( - - - {ds.name} - - - {engineLabel(ds.type)} - - - {ds.url} - - - {ds.is_default - ? - : -} - - -
- - {!ds.is_default && ( - - )} - - -
-
-
- )) - )} -
-
-
-
+ {/* DataTable */} + ds.id} + loading={loading} + searchable + searchPlaceholder="搜索数据源名称或地址..." + searchKeys={['name', 'url']} + sortable + emptyText="暂无数据源,点击上方按钮添加" + toolbarExtra={ + <> + + + + + } + /> {/* Add/Edit Dialog */} ('preset'); + // 自定义日历模式 // Logan.Li + const [customStart, setCustomStart] = useState(value.start); + const [customEnd, setCustomEnd] = useState(value.end); const ref = useRef(null); useEffect(() => { @@ -76,21 +83,45 @@ export function TimeRangePicker({ value, onChange }: Props) { const handlePreset = (presetValue: string) => { setActivePreset(presetValue); - if (presetValue === 'custom') return; + setMode('preset'); const { start, end, label } = getPresetRange(presetValue); - onChange({ start: formatDateTime(start), end: formatDateTime(end), label, type: 'relative' }); + const isRelative = ['1m','5m','15m','1h','4h','24h','7d','30d'].includes(presetValue); + onChange({ start: formatDateTime(start), end: formatDateTime(end), label, type: isRelative ? 'relative' : 'absolute' }); setOpen(false); }; + const handleCustomApply = () => { + onChange({ + start: customStart, + end: customEnd, + label: '自定义', + type: 'absolute', + }); + setActivePreset('custom'); + setOpen(false); + }; + + const openCustom = () => { + setMode('custom'); + setCustomStart(value.start); + setCustomEnd(value.end); + }; + + const isRelative = value.type === 'relative'; + const labelColor = isRelative ? '#22c55e' : 'var(--dg-blue)'; + return (
{/* 触发按钮 */} @@ -99,41 +130,96 @@ export function TimeRangePicker({ value, onChange }: Props) { {open && (
- {/* 头部:时间范围显示 */} + {/* 头部 */}
时间选择 - 日历中选择 +
- {/* 当前范围 */} -
- {value.start} ~ {value.end} - -
+ {mode === 'preset' ? ( + <> + {/* 相对时间 — 最近... */} +

相对时间(滚动窗口)

+
+ {RELATIVE_PRESETS.map(p => ( + + ))} +
- {/* 预设按钮网格 */} -
- {PRESETS.map(p => ( - - ))} -
+ {/* 绝对时间 — 固定日历范围 */} +

绝对时间(日历范围)

+
+ {ABSOLUTE_PRESETS.map(p => ( + + ))} +
+ + ) : ( + /* 自定义日历模式 */ +
+

自定义时间范围

+
+
+ + setCustomStart(e.target.value + ':00')} + className="w-full h-9 px-2 text-xs rounded" + style={{ background: 'var(--dg-bg-tertiary)', border: '1px solid var(--dg-border)', color: 'var(--dg-text-primary)' }} + /> +
+
+ + setCustomEnd(e.target.value + ':00')} + className="w-full h-9 px-2 text-xs rounded" + style={{ background: 'var(--dg-bg-tertiary)', border: '1px solid var(--dg-border)', color: 'var(--dg-text-primary)' }} + /> +
+
+
+ + +
+
+ )}
)}
diff --git a/frontend/src/components/monitor/log-center/UnifiedQueryPanel.tsx b/frontend/src/components/monitor/log-center/UnifiedQueryPanel.tsx index 8dd79f7..662d8af 100644 --- a/frontend/src/components/monitor/log-center/UnifiedQueryPanel.tsx +++ b/frontend/src/components/monitor/log-center/UnifiedQueryPanel.tsx @@ -1,6 +1,6 @@ -// UnifiedQueryPanel.tsx — 统一查询面板(查询体验优化版)// Logan.Li -import { useState, useMemo, useCallback, useRef } from 'react'; -import { Play, Loader2, ExternalLink, Clock, Save, ChevronDown, Bell } from 'lucide-react'; +// UnifiedQueryPanel.tsx — 统一查询面板(接入真实 API)// Logan.Li +import { useState, useMemo, useCallback, useRef, useEffect } from 'react'; +import { Play, Loader2, Clock, Save, ChevronDown, Bell } from 'lucide-react'; import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'; import { TimeRangePicker } from './TimeRangePicker'; import { LogFieldSidebar } from './LogFieldSidebar'; @@ -11,27 +11,23 @@ import { LogClusterPanel } from './LogClusterPanel'; import { SaveQueryDialog, SaveAlertDialog } from './SaveQueryDialog'; import { QueryHistoryPanel } from './QueryHistoryPanel'; import { LogAlertFromQueryDialog } from './LogAlertFromQueryDialog'; +import { logCenterApi, type LogDataSource } from '@/api/monitor/log-center'; import type { LogEngine, TimeRange, ResultTab, ViewMode, LogEntry, StatTable, LogCluster, QueryHistoryItem } from './types-v3'; import { ENGINE_META, generateMockEntries, generateMockStatTable, generateMockClusters, extractFields, generateHistogramData, historyStorage } from './types-v3'; +import { useToast } from '@/hooks/use-toast'; // 默认最近5分钟 // Logan.Li const DEFAULT_TIME_RANGE: TimeRange = { start: new Date(Date.now() - 5 * 60000).toISOString().replace('T', ' ').substring(0, 19), end: new Date().toISOString().replace('T', ' ').substring(0, 19), - label: '5分钟', + label: '最近5分钟', type: 'relative', }; -// Mock 数据源(Loki & ES 双引擎) // Logan.Li -const MOCK_SOURCES: Record<'loki' | 'elasticsearch', { id: number; name: string; url: string }[]> = { - loki: [ - { id: 1, name: 'prod-loki', url: 'http://loki.prod.local:3100' }, - { id: 2, name: 'dev-loki', url: 'http://loki.dev.local:3100' }, - ], - elasticsearch: [ - { id: 3, name: 'prod-es', url: 'http://es.prod.local:9200' }, - { id: 4, name: 'dev-es', url: 'http://es.dev.local:9200' }, - ], +// Demo 数据源(后端无数据源时的兜底)// Logan.Li +const DEMO_SOURCES: Record<'loki' | 'elasticsearch', LogDataSource[]> = { + loki: [{ id: -1, name: 'demo-loki', type: 'loki', url: '' }], + elasticsearch: [{ id: -2, name: 'demo-es', type: 'elasticsearch', url: '' }], }; // 引擎默认查询语句 // Logan.Li @@ -87,19 +83,19 @@ function highlightSyntax(query: string, engine: 'loki' | 'elasticsearch'): strin if (!query) return '.'; if (engine === 'loki') { return query - .replace(/([a-zA-Z_][\w-]*)(?==)/g, '$1') // 标签名 - .replace(/("(?:[^"\\]|\\.)*")/g, '$1') // 字符串 - .replace(/(\|=|\!=|=~|!~|=)/g, '$1') // 操作符 - .replace(/(\|json|\|regexp|\|line_format|\|unwrap|\|pattern|\|label_format)/g, '$1') // 管道 - .replace(/\b(count_over_time|sum|rate|avg|max|min|count|by|over)\b/g, '$1') // 函数 - .replace(/(\{|\})/g, '$1') // 花括号 - .replace(/.'; // 闭合 + .replace(/([a-zA-Z_][\w-]*)(?==)/g, '$1') + .replace(/("(?:[^"\\]|\\.)*")/g, '$1') + .replace(/(\|=|\!=|=~|!~|=)/g, '$1') + .replace(/(\|json|\|regexp|\|line_format|\|unwrap|\|pattern|\|label_format)/g, '$1') + .replace(/\b(count_over_time|sum|rate|avg|max|min|count|by|over)\b/g, '$1') + .replace(/(\{|\})/g, '$1') + .replace(/.'; } return query - .replace(/\b(AND|OR|NOT)\b/g, '$1') // 布尔 - .replace(/([a-zA-Z_@][\w.-]*):/g, '$1:') // 字段 - .replace(/("(?:[^"\\]|\\.)*")/g, '$1') // 字符串 - .replace(/(\*|\?|\~|\^|\[|\]|\(|\))/g, '$1') // 通配符 + .replace(/\b(AND|OR|NOT)\b/g, '$1') + .replace(/([a-zA-Z_@][\w.-]*):/g, '$1:') + .replace(/("(?:[^"\\]|\\.)*")/g, '$1') + .replace(/(\*|\?|\~|\^|\[|\]|\(|\))/g, '$1') .replace(/.'; } @@ -131,14 +127,23 @@ function SyntaxHighlightInput({ value, onChange, engine, placeholder }: { interface Props { engine: LogEngine; onRuleCreated: () => void; } export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) { - // SLS 回退到 Loki,仅支持双引擎 // Logan.Li + const { toast } = useToast(); const effEngine: 'loki' | 'elasticsearch' = engine === 'sls' ? 'loki' : engine; const meta = ENGINE_META[effEngine]; const badge = ENGINE_BADGE[effEngine]; - const sources = MOCK_SOURCES[effEngine]; + + // 从后端加载真实数据源,无数据时用 demo 兜底 // Logan.Li + const [sources, setSources] = useState(DEMO_SOURCES[effEngine]); + useEffect(() => { + logCenterApi.listDatasources(effEngine).then((res: any) => { + const list: LogDataSource[] = Array.isArray(res) ? res : (res?.list || []); + if (list.length > 0) setSources(list); + else setSources(DEMO_SOURCES[effEngine]); + }).catch(() => setSources(DEMO_SOURCES[effEngine])); + }, [effEngine]); const [query, setQuery] = useState(DEFAULT_QUERIES[effEngine]); - const [dsId, setDsId] = useState(String(sources[0].id)); + const [dsId, setDsId] = useState(String(sources[0]?.id || '')); const [timeRange, setTimeRange] = useState(DEFAULT_TIME_RANGE); const [loading, setLoading] = useState(false); const [entries, setEntries] = useState([]); @@ -161,30 +166,94 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) { const [alertDialogOpen, setAlertDialogOpen] = useState(false); const [saveMenuOpen, setSaveMenuOpen] = useState(false); + // 数据源切换后更新 dsId // Logan.Li + useEffect(() => { + if (sources.length > 0) setDsId(String(sources[0].id)); + }, [sources]); + const currentSource = sources.find(s => String(s.id) === dsId); + const isDemo = currentSource && (currentSource.id < 0 || !currentSource.url); + const fields = useMemo(() => extractFields(entries), [entries]); const commonLabels = useMemo(() => extractCommonLabels(entries), [entries]); const sortedEntries = useMemo(() => { const a = [...entries]; return sortDirection === 'asc' ? a.sort((x, y) => x.timestamp.localeCompare(y.timestamp)) : a.sort((x, y) => y.timestamp.localeCompare(x.timestamp)); }, [entries, sortDirection]); const totalPages = Math.max(1, Math.ceil(sortedEntries.length / perPage)); const pagedEntries = useMemo(() => { const s = (page - 1) * perPage; return sortedEntries.slice(s, s + perPage); }, [sortedEntries, page, perPage]); - // 执行查询 // Logan.Li - const handleRun = useCallback(() => { + // 执行查询 — 接入真实 API(demo 源则使用 mock)// Logan.Li + const handleRun = useCallback(async () => { + if (!currentSource) return; + const dsIdNum = currentSource.id; setLoading(true); const t0 = performance.now(); - setTimeout(() => { - const mock = generateMockEntries(effEngine as LogEngine, 120); - setEntries(mock); setHistogramData(generateHistogramData(mock)); setTotalCount(mock.length * 14); - setQueryTime(Math.round(performance.now() - t0)); setStatTable(generateMockStatTable(effEngine as LogEngine)); - setClusters(generateMockClusters()); setPage(1); setLoading(false); - historyStorage.add({ engine: effEngine as LogEngine, datasourceName: currentSource?.name || 'default', query, timeRange: timeRange.label, resultCount: mock.length }); - }, 600 + Math.random() * 500); - }, [effEngine, query, currentSource, timeRange]); + + // Demo 源:模拟数据 + if (isDemo) { + setTimeout(() => { + const mock = generateMockEntries(effEngine as LogEngine, 120); + setEntries(mock); setHistogramData(generateHistogramData(mock)); setTotalCount(mock.length * 14); + setQueryTime(Math.round(performance.now() - t0)); setStatTable(generateMockStatTable(effEngine as LogEngine)); + setClusters(generateMockClusters()); setPage(1); setLoading(false); + historyStorage.add({ engine: effEngine as LogEngine, datasourceName: currentSource.name, query, timeRange: timeRange.label, resultCount: mock.length }); + }, 300); + return; + } + + // 真实 API 查询 + try { + const startISO = timeRange.start + (timeRange.start.includes('T') ? '' : '.000Z').replace(' ', 'T'); + const endISO = timeRange.end + (timeRange.end.includes('T') ? '' : '.000Z').replace(' ', 'T'); + let result: any; + + if (effEngine === 'loki') { + result = await logCenterApi.queryLoki({ + datasource_id: dsIdNum, + query, + limit: 200, + start: startISO, + end: endISO, + direction: 'backward', + }); + } else { + result = await logCenterApi.queryElasticsearch({ + datasource_id: dsIdNum, + query, + size: 200, + start: startISO, + end: endISO, + }); + } + + const data = (result as any)?.data || result; + const rawEntries: any[] = data?.entries || []; + const mapped: LogEntry[] = rawEntries.map((e: any) => ({ + timestamp: e.timestamp || '', + level: e.level || '', + message: e.message || '', + labels: e.labels || {}, + raw: e.raw || e.message || '', + source: e.labels?.app || e.labels?.service_name || '', + })); + + setEntries(mapped); + setHistogramData(generateHistogramData(mapped)); + setTotalCount(data?.total || mapped.length); + setQueryTime(Math.round(performance.now() - t0)); + setStatTable(generateMockStatTable(effEngine as LogEngine)); + setClusters(generateMockClusters()); + setPage(1); + historyStorage.add({ engine: effEngine as LogEngine, datasourceName: currentSource.name, query, timeRange: timeRange.label, resultCount: mapped.length }); + } catch (e: any) { + toast({ title: '查询失败', description: e?.message || String(e), variant: 'destructive' }); + setEntries([]); setTotalCount(0); + } finally { + setLoading(false); + } + }, [effEngine, query, currentSource, timeRange, isDemo, toast]); const toggleField = useCallback((n: string) => setSelectedFields(p => p.includes(n) ? p.filter(f => f !== n) : [...p, n]), []); const handleSaveMenu = (a: 'query' | 'alert') => { setSaveMenuOpen(false); a === 'query' ? setSaveQueryOpen(true) : setSaveAlertOpen(true); }; - // 点击面积图 → 缩小时间范围到该区域 // Logan.Li const handleChartClick = useCallback((st: any) => { if (!st?.activeLabel) return; const idx = histogramData.findIndex(d => d.timestamp === st.activeLabel); @@ -195,9 +264,8 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) { return (
- {/* ===== 查询控制行:输入框前置 & 语法高亮 & 引擎徽标 ===== */} + {/* ===== 查询控制行 ===== */}
- {/* 引擎徽标 + 高亮查询输入(最醒目) */}
@@ -209,10 +277,10 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) { - @@ -229,26 +297,20 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) { )}
- {currentSource?.url && ( - - )} -
- {/* ===== 快捷查询助手(内联紧凑版,14+模板) ===== */} + {/* 快捷查询助手 */} - {/* ===== 统计条 + 工具栏 ===== */} + {/* 统计条 + 工具栏 */} {entries.length > 0 && !loading && ( { setPerPage(n); setPage(1); }} page={page} totalPages={totalPages} onPageChange={setPage} sortDirection={sortDirection} onSortToggle={() => setSortDirection(d => d === 'desc' ? 'asc' : 'desc')} onExport={() => { const t = sortedEntries.map(e => `${e.timestamp}\t${e.level || ''}\t${e.message}`).join('\n'); const b = new Blob([t], { type: 'text/plain' }); const u = URL.createObjectURL(b); const a = document.createElement('a'); a.href = u; a.download = `${effEngine}-logs-${Date.now()}.txt`; a.click(); URL.revokeObjectURL(u); }} onCopy={() => navigator.clipboard.writeText(pagedEntries.map(e => `${e.timestamp}\t${e.level || ''}\t${e.message}`).join('\n'))} onBellClick={() => setAlertDialogOpen(true)} /> )} - {/* ===== Log Volume 面积图(点击缩小时间范围) ===== */} + {/* Log Volume 面积图 */} {entries.length > 0 && (
@@ -269,7 +331,7 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
)} - {/* ===== 结果标签页(紧凑内嵌样式) ===== */} + {/* 结果标签页 */} {entries.length > 0 && (
@@ -285,7 +347,6 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
- {/* 原始日志 */} {resultTab === 'logs' && (<>
setQuery(p => `${p} ${n}=""`)} onToggleDisplayField={toggleField} loading={loading} /> @@ -305,11 +366,7 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
)} - - {/* 统计图表 */} {resultTab === 'stats' && setSaveAlertOpen(true)} onSaveAsQuery={() => setSaveQueryOpen(true)} />} - - {/* 日志聚类 */} {resultTab === 'cluster' && }
@@ -323,7 +380,14 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
)} - {/* ===== 对话框 ===== */} + {/* Loading */} + {loading && entries.length === 0 && ( +
+ +
+ )} + + {/* 对话框 */} setSaveQueryOpen(false)} engine={effEngine as LogEngine} query={query} timeRange={timeRange} datasourceName={currentSource?.name} /> setSaveAlertOpen(false)} engine={effEngine as LogEngine} query={query} datasourceName={currentSource?.name} /> setHistoryPanelOpen(false)} onSelect={(it: QueryHistoryItem) => { setQuery(it.query); setHistoryPanelOpen(false); }} onCreateAlert={() => { setSaveAlertOpen(true); setHistoryPanelOpen(false); }} /> @@ -332,7 +396,7 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) { ); } -// ===== 内联紧凑版快捷查询助手 ===== // Logan.Li +// 内联紧凑版快捷查询助手 function QuickQueryBar({ engine, templates, onSelect }: { engine: 'loki' | 'elasticsearch'; templates: { label: string; query: string }[]; onSelect: (q: string) => void; }) { const [open, setOpen] = useState(false); const labels: Record<'loki' | 'elasticsearch', string> = { loki: 'LogQL', elasticsearch: 'Lucene' }; diff --git a/frontend/src/components/ui/data-table.tsx b/frontend/src/components/ui/data-table.tsx new file mode 100644 index 0000000..fae16eb --- /dev/null +++ b/frontend/src/components/ui/data-table.tsx @@ -0,0 +1,655 @@ +/** + * 统一 DataTable 组件 + * + * 封装排序/搜索/分页/行选择/CSV 导出/列可见性的通用表格。 + * 基于 shadcn/ui Table + 项目 CSS 变量主题(--dg-*)。 + * + * 用法示例: + * ```tsx + * }, + * ]} + * data={servers} + * rowKey={(r) => r.id} + * searchable searchPlaceholder="搜索服务器..." + * pagination pageSize={20} + * exportable exportFilename="servers.csv" + * selectable onSelectionChange={setSelectedIds} + * /> + * ``` + */ +import * as React from 'react'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { + ArrowUpDown, + ArrowUp, + ArrowDown, + ChevronLeft, + ChevronRight, + Download, + Eye, + Search, + Settings2, +} from 'lucide-react'; +import { cn } from '@/lib/utils'; + +// ─── Types ─────────────────────────────────────────────── + +export interface DataTableColumn> { + /** 唯一键名 */ + key: string; + /** 表头文本 */ + header: string; + /** 列宽 */ + width?: string | number; + /** 是否可排序 */ + sortable?: boolean; + /** 排序取值函数(默认取 row[key]) */ + accessor?: (row: T) => string | number | Date | null | undefined; + /** 自定义渲染 */ + render?: (row: T, index: number) => React.ReactNode; + /** 对齐方式 */ + align?: 'left' | 'center' | 'right'; + /** 是否可通过列可见性面板隐藏(默认 true) */ + hideable?: boolean; + /** 是否默认隐藏 */ + defaultHidden?: boolean; +} + +export interface DataTableProps> { + /** 列定义 */ + columns: DataTableColumn[]; + /** 数据源 */ + data: T[]; + /** 行唯一标识 */ + rowKey: (row: T, index: number) => string | number; + + // ── 搜索 ── + /** 是否启用全局搜索 */ + searchable?: boolean; + /** 搜索占位符 */ + searchPlaceholder?: string; + /** 搜索覆盖的字段 key 列表(默认搜索所有 string/number 值) */ + searchKeys?: string[]; + + // ── 排序 ── + /** 是否启用排序(列级 sortable 也要为 true) */ + sortable?: boolean; + + // ── 分页 ── + /** 是否启用客户端分页 */ + pagination?: boolean; + /** 每页行数 */ + pageSize?: number; + /** 可选每页选项 */ + pageSizeOptions?: number[]; + + // ── 行选择 ── + /** 是否启用行选择 */ + selectable?: boolean; + /** 受控选中 ID */ + selectedIds?: (string | number)[]; + /** 选中变化回调 */ + onSelectionChange?: (ids: (string | number)[]) => void; + + // ── 导出 ── + /** 是否启用 CSV 导出 */ + exportable?: boolean; + /** 导出文件名 */ + exportFilename?: string; + + // ── 列可见性 ── + /** 是否启用列可见性面板 */ + columnToggle?: boolean; + + // ── 状态 ── + /** 加载中 */ + loading?: boolean; + /** 空数据文案 */ + emptyText?: string; + /** 自定义空数据渲染 */ + emptyRender?: () => React.ReactNode; + + // ── 行交互 ── + /** 行点击 */ + onRowClick?: (row: T) => void; + /** 行样式 */ + rowClassName?: (row: T) => string; + + // ── 布局 ── + /** 容器 className */ + className?: string; + /** 表头额外 className */ + headerClassName?: string; + /** 表体最大高度(超出滚动) */ + bodyMaxHeight?: string | number; + + /** 额外工具栏内容(右侧) */ + toolbarExtra?: React.ReactNode; +} + +// ─── Helpers ───────────────────────────────────────────── + +type SortDir = 'asc' | 'desc' | null; + +function getSortValue(row: T, col: DataTableColumn): string | number | Date | null { + if (col.accessor) { + const v = col.accessor(row); + return v ?? null; + } + const v = (row as Record)[col.key]; + if (v == null) return null; + if (v instanceof Date) return v; + if (typeof v === 'number') return v; + // 尝试提取常见嵌套对象的 label/name + if (typeof v === 'object') { + const obj = v as Record; + return String(obj.label ?? obj.name ?? obj.value ?? ''); + } + return String(v); +} + +function toCsvCell(v: unknown): string { + if (v == null) return ''; + if (typeof v === 'object') { + if (v instanceof Date) return v.toISOString(); + const obj = v as Record; + return String(obj.label ?? obj.name ?? obj.value ?? ''); + } + const s = String(v); + if (/[",\n\r]/.test(s)) return `"${s.replace(/"/g, '""')}"`; + return s; +} + +function exportCsv( + columns: DataTableColumn[], + data: T[], + filename: string, +): void { + const headerRow = columns.map((c) => toCsvCell(c.header)).join(','); + const bodyRows = data.map((row) => + columns + .map((c) => { + if (c.accessor) return toCsvCell(c.accessor(row)); + return toCsvCell((row as Record)[c.key]); + }) + .join(','), + ); + const csv = '\uFEFF' + [headerRow, ...bodyRows].join('\n'); + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +} + +function alignClass(align?: string): string { + if (align === 'center') return 'text-center'; + if (align === 'right') return 'text-right'; + return 'text-left'; +} + +// ─── Component ─────────────────────────────────────────── + +export function DataTable>( + props: DataTableProps, +) { + const { + columns, + data, + rowKey, + searchable = false, + searchPlaceholder = '搜索...', + searchKeys, + sortable: globalSortable = false, + pagination: enablePagination = false, + pageSize: initialPageSize = 20, + pageSizeOptions = [10, 20, 50, 100], + selectable = false, + selectedIds, + onSelectionChange, + exportable = false, + exportFilename = 'export.csv', + columnToggle = false, + loading = false, + emptyText = '暂无数据', + emptyRender, + onRowClick, + rowClassName, + className, + headerClassName, + bodyMaxHeight, + toolbarExtra, + } = props; + + // ── Search state ── + const [searchTerm, setSearchTerm] = React.useState(''); + + // ── Sort state ── + const [sortKey, setSortKey] = React.useState(null); + const [sortDir, setSortDir] = React.useState(null); + + // ── Pagination state ── + const [currentPage, setCurrentPage] = React.useState(1); + const [pageSize, setPageSize] = React.useState(initialPageSize); + + // ── Column visibility state ── + const [hiddenCols, setHiddenCols] = React.useState>(() => { + const s = new Set(); + columns.forEach((c) => { + if (c.defaultHidden) s.add(c.key); + }); + return s; + }); + + // ── Derived: filtered data ── + const filteredData = React.useMemo(() => { + if (!searchable || !searchTerm.trim()) return data; + const term = searchTerm.trim().toLowerCase(); + return data.filter((row) => { + const keys = searchKeys ?? Object.keys(row as Record); + return keys.some((k) => { + const v = (row as Record)[k]; + if (v == null) return false; + if (typeof v === 'object') { + const obj = v as Record; + return String(obj.label ?? obj.name ?? obj.value ?? '').toLowerCase().includes(term); + } + return String(v).toLowerCase().includes(term); + }); + }); + }, [data, searchTerm, searchable, searchKeys]); + + // ── Derived: sorted data ── + const sortedData = React.useMemo(() => { + if (!sortKey || !sortDir) return filteredData; + const col = columns.find((c) => c.key === sortKey); + if (!col) return filteredData; + const dir = sortDir === 'asc' ? 1 : -1; + return [...filteredData].sort((a, b) => { + const va = getSortValue(a, col); + const vb = getSortValue(b, col); + if (va == null && vb == null) return 0; + if (va == null) return 1; + if (vb == null) return -1; + if (va instanceof Date && vb instanceof Date) return (va.getTime() - vb.getTime()) * dir; + if (typeof va === 'number' && typeof vb === 'number') return (va - vb) * dir; + return String(va).localeCompare(String(vb)) * dir; + }); + }, [filteredData, sortKey, sortDir, columns]); + + // ── Derived: paginated data ── + const totalPages = enablePagination ? Math.max(1, Math.ceil(sortedData.length / pageSize)) : 1; + const safeCurrentPage = Math.min(currentPage, totalPages); + const paginatedData = React.useMemo(() => { + if (!enablePagination) return sortedData; + const start = (safeCurrentPage - 1) * pageSize; + return sortedData.slice(start, start + pageSize); + }, [sortedData, safeCurrentPage, pageSize, enablePagination]); + + // Reset to page 1 when search changes + React.useEffect(() => { + if (enablePagination) setCurrentPage(1); + }, [searchTerm, enablePagination]); + + // ── Visible columns ── + const visibleColumns = React.useMemo( + () => columns.filter((c) => !hiddenCols.has(c.key)), + [columns, hiddenCols], + ); + + // ── Export columns (exclude action columns that have render but no accessor/key matching) ── + const exportColumns = React.useMemo(() => { + return visibleColumns.filter((c) => { + // Skip columns explicitly marked as not exportable or action-only + if (c.key === 'actions' || c.key === 'operation' || c.key === 'ops') return false; + return true; + }); + }, [visibleColumns]); + + // ── Sort handler ── + const handleSort = React.useCallback( + (col: DataTableColumn) => { + if (!globalSortable || !col.sortable) return; + if (sortKey !== col.key) { + setSortKey(col.key); + setSortDir('asc'); + } else { + const next: SortDir = sortDir === 'asc' ? 'desc' : sortDir === 'desc' ? null : 'asc'; + setSortDir(next); + if (next === null) setSortKey(null); + } + }, + [globalSortable, sortKey, sortDir], + ); + + // ── Selection handlers ── + const visibleIds = paginatedData.map((row, i) => rowKey(row, i)); + const allPageSelected = selectable && visibleIds.length > 0 && visibleIds.every((id) => selectedIds?.includes(id)); + const somePageSelected = selectable && visibleIds.some((id) => selectedIds?.includes(id)); + + const handleSelectAll = React.useCallback(() => { + if (!onSelectionChange) return; + const current = new Set(selectedIds ?? []); + if (allPageSelected) { + visibleIds.forEach((id) => current.delete(id)); + } else { + visibleIds.forEach((id) => current.add(id)); + } + onSelectionChange([...current]); + }, [onSelectionChange, selectedIds, allPageSelected, visibleIds]); + + const handleSelectRow = React.useCallback( + (id: string | number) => { + if (!onSelectionChange) return; + const current = new Set(selectedIds ?? []); + if (current.has(id)) current.delete(id); + else current.add(id); + onSelectionChange([...current]); + }, + [onSelectionChange, selectedIds], + ); + + const handleExport = React.useCallback(() => { + exportCsv(exportColumns, sortedData, exportFilename); + }, [exportColumns, sortedData, exportFilename]); + + const toggleColumn = React.useCallback((key: string) => { + setHiddenCols((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + }, []); + + // ── Has toolbar ── + const hasToolbar = searchable || exportable || columnToggle || toolbarExtra; + + // ── Render ── + return ( +
+ {/* Toolbar */} + {hasToolbar && ( +
+
+ {searchable && ( +
+ + setSearchTerm(e.target.value)} + placeholder={searchPlaceholder} + className="pl-9 h-9 w-56 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)]" + /> +
+ )} + {selectable && (selectedIds?.length ?? 0) > 0 && ( + + 已选 {selectedIds!.length} 项 + + )} +
+
+ {toolbarExtra} + {columnToggle && ( + + + + + + {columns.filter((c) => c.hideable !== false).map((c) => ( + toggleColumn(c.key)} + className="cursor-pointer text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] hover:text-[var(--dg-text-primary)]" + > + + {!hiddenCols.has(c.key) && ✓} + + {c.header} + + ))} + + + )} + {exportable && ( + + )} +
+
+ )} + + {/* Table */} +
+ + + + {selectable && ( + + + + )} + {visibleColumns.map((col) => { + const isSorted = sortKey === col.key; + const canSort = globalSortable && col.sortable; + return ( + handleSort(col)} + > + + {col.header} + {canSort && ( + + {isSorted && sortDir === 'asc' ? ( + + ) : isSorted && sortDir === 'desc' ? ( + + ) : ( + + )} + + )} + + + ); + })} + + + + {/* Loading skeleton */} + {loading ? ( + Array.from({ length: Math.min(pageSize, 8) }).map((_, i) => ( + + {selectable && ( + +
+ + )} + {visibleColumns.map((col) => ( + +
+ + ))} + + )) + ) : /* Empty state */ + paginatedData.length === 0 ? ( + + + {emptyRender ? emptyRender() : emptyText} + + + ) : ( + /* Data rows */ + paginatedData.map((row, rowIndex) => { + const id = rowKey(row, rowIndex); + const isSelected = selectable && selectedIds?.includes(id); + return ( + onRowClick(row) : undefined} + > + {selectable && ( + e.stopPropagation()}> + handleSelectRow(id)} + className="border-[var(--dg-border-hover)]" + /> + + )} + {visibleColumns.map((col) => ( + + {col.render ? col.render(row, rowIndex) : renderCellValue((row as Record)[col.key])} + + ))} + + ); + }) + )} + +
+
+ + {/* Footer: pagination + count */} + {enablePagination && !loading && ( +
+
+ + 共 {sortedData.length} 条 + + {selectable && (selectedIds?.length ?? 0) > 0 && ( + + / 已选 {selectedIds!.length} + + )} +
+ 每页 + + + + + + {pageSizeOptions.map((s) => ( + { setPageSize(s); setCurrentPage(1); }} + className={cn( + 'cursor-pointer text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] hover:text-[var(--dg-text-primary)]', + s === pageSize && 'font-bold text-[var(--dg-text-primary)]', + )} + > + {s} + + ))} + + +
+
+
+ + + {safeCurrentPage} / {totalPages} + + +
+
+ )} +
+ ); +} + +// ─── Default cell value renderer ───────────────────────── + +function renderCellValue(v: unknown): React.ReactNode { + if (v == null || v === '') return —; + if (typeof v === 'boolean') return v ? '是' : '否'; + if (typeof v === 'object') { + const obj = v as Record; + const label = obj.label ?? obj.name ?? obj.value; + if (label != null) return String(label); + return JSON.stringify(v); + } + return String(v); +} diff --git a/frontend/src/pages/LoginLogsTab.tsx b/frontend/src/pages/LoginLogsTab.tsx index c4f1d82..96d9bf3 100644 --- a/frontend/src/pages/LoginLogsTab.tsx +++ b/frontend/src/pages/LoginLogsTab.tsx @@ -1,18 +1,10 @@ -// 登录日志 Tab — 用户登录记录列表(参考 opsx-web-lite) // Logan.Li +// 登录日志 Tab — 用户登录记录列表(参考 opsx-web-lite) import { useState, useEffect, useCallback } from 'react'; 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, LogIn, Monitor, Globe } from 'lucide-react'; +import { DataTable, type DataTableColumn } from '@/components/ui/data-table'; +import { RefreshCw, LogIn, Monitor, Globe } from 'lucide-react'; import { loginLogApi } from '@/api'; import type { JsonData } from '@/types/common'; @@ -48,16 +40,25 @@ function normalizeLoginLog(raw: JsonData): LoginLog { }; } +function getLoginTypeLabel(type: string) { + switch (type) { + case 'password': return '密码登录'; + case 'ldap': return 'LDAP'; + case 'token': return 'Token'; + case 'oauth': return 'OAuth'; + default: return type || '密码登录'; + } +} + export function LoginLogsTab() { const [logs, setLogs] = useState([]); const [loading, setLoading] = useState(true); - const [keyword, setKeyword] = useState(''); const [statusFilter, setStatusFilter] = useState(''); const fetchLogs = useCallback(async () => { setLoading(true); try { - const params: JsonData = { page: 1, page_size: 100 }; + const params: JsonData = { page: 1, page_size: 500 }; if (statusFilter) params.status = statusFilter === 'fail' ? 'failed' : statusFilter; const resp = (await loginLogApi.list(params)) as JsonData; const list = resp?.list ?? []; @@ -73,57 +74,66 @@ export function LoginLogsTab() { fetchLogs(); }, [fetchLogs]); - const filteredLogs = logs.filter((log) => { - const kw = keyword.toLowerCase(); - return ( - !kw || - (log.username || '').toLowerCase().includes(kw) || - (log.ip || '').toLowerCase().includes(kw) || - (log.location || '').toLowerCase().includes(kw) || - (log.user_agent || '').toLowerCase().includes(kw) - ); - }); - - const getStatusBadge = (status: LoginLog['status'], reason?: string) => { - return status === 'success' ? ( - - 成功 - - ) : ( - - {reason || '失败'} - - ); - }; - - const getLoginTypeLabel = (type: string) => { - switch (type) { - case 'password': return '密码登录'; - case 'ldap': return 'LDAP'; - case 'token': return 'Token'; - case 'oauth': return 'OAuth'; - default: return type || '密码登录'; - } - }; + const columns: DataTableColumn[] = [ + { + key: 'login_at', header: '登录时间', sortable: true, width: 180, + accessor: (r) => r.login_at, + render: (r) => {r.login_at ? new Date(r.login_at).toLocaleString() : '—'}, + }, + { + key: 'username', header: '用户', sortable: true, width: 120, + render: (r) => {r.username || '—'}, + }, + { + key: 'login_type', header: '登录方式', width: 100, + render: (r) => ( + + {getLoginTypeLabel(r.login_type)} + + ), + }, + { + key: 'ip', header: 'IP', width: 140, + render: (r) => {r.ip || '—'}, + }, + { + key: 'location', header: '归属地', width: 140, + render: (r) => ( + + + {r.location || '—'} + + ), + }, + { + key: 'browser', header: '浏览器/OS', + render: (r) => ( + + + {r.browser || r.os + ? [r.browser, r.os].filter(Boolean).join(' / ') + : r.user_agent + ? r.user_agent.length > 40 ? r.user_agent.slice(0, 40) + '...' : r.user_agent + : '—'} + + ), + }, + { + key: 'status', header: '状态', sortable: true, width: 80, align: 'center', + render: (r) => r.status === 'success' ? ( + 成功 + ) : ( + + {r.fail_reason || '失败'} + + ), + }, + ]; return (
- {/* Filters */} -
-
- - setKeyword(e.target.value)} - className="pl-9 h-9 bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500" - /> -
- + {/* Status Filter + Refresh */} +
{[ { value: '', label: '全部' }, @@ -137,7 +147,7 @@ export function LoginLogsTab() { className={ statusFilter === value ? 'h-8 bg-blue-600 hover:bg-blue-700 text-white' - : 'h-8 border-slate-700 text-slate-400 hover:text-slate-100 hover:bg-slate-800' + : 'h-8 border-[var(--dg-border)] text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)]' } onClick={() => setStatusFilter(value)} > @@ -145,10 +155,10 @@ export function LoginLogsTab() { ))}
-
- {/* Logs Table */} - - -
- {loading ? ( -
-
+ {/* DataTable */} + + + r.id} + loading={loading} + searchable + searchPlaceholder="搜索用户/IP/归属地..." + searchKeys={['username', 'ip', 'location', 'user_agent']} + sortable + pagination + pageSize={20} + exportable + exportFilename="login-logs.csv" + columnToggle + emptyText="暂无登录日志" + emptyRender={() => ( +
+ +

暂无登录日志

- ) : filteredLogs.length === 0 ? ( -
- -

暂无登录日志

-
- ) : ( - - - - 登录时间 - 用户 - 登录方式 - IP - 归属地 - 浏览器/OS - 状态 - - - - {filteredLogs.map((log) => ( - - - - {log.login_at ? new Date(log.login_at).toLocaleString() : '--'} - - - - {log.username || '--'} - - - - {getLoginTypeLabel(log.login_type)} - - - - - {log.ip || '--'} - - - - - - {log.location || '--'} - - - - - - {log.browser || log.os - ? [log.browser, log.os].filter(Boolean).join(' / ') - : log.user_agent - ? log.user_agent.length > 40 - ? log.user_agent.slice(0, 40) + '...' - : log.user_agent - : '--'} - - - {getStatusBadge(log.status, log.fail_reason)} - - ))} - -
)} -
+ />