diff --git a/PROJECT_EVALUATION.md b/PROJECT_EVALUATION.md index c76f454..4f30f1f 100644 --- a/PROJECT_EVALUATION.md +++ b/PROJECT_EVALUATION.md @@ -129,7 +129,7 @@ | ~~`api.ts` 1324 行/352 方法~~ → 已拆分为 12 个域文件 (api/kubernetes.ts, deploy.ts, monitor.ts 等) | ✅ 已解决 | 前端 | ~~P0~~ done | | 前端 **1026 处** `any` 类型 | 类型安全缺失,重构风险高 | 前端全局 | ~~P0~~ **done** (any 1076→40, unused 25→6, console 93→1, tsc --noEmit=0) | | ~~K8s 双路由体系(集群域+Kuboard 域)~~ → code 路由统一为主,ID 路由标记 deprecated 向后兼容 | ✅ 已解决 | 后端 | ~~P1~~ done | -| 25 个 ≥500 行巨型组件 | 拆分困难 | 前端 | P1 → **Q6-Q17 done: 最大组件 ResourceVisualEditor 882L→282L (-68%) + AgentCLI 814L→521L (-36%) + SystemSettingsTab 754L→173L (-77%) + NamespaceResourcePage 719L→300L (-58%) + ApprovalWorkflowPage 705L→167L (-76%) + PipelineRunDetail 678L→273L (-60%) + CloudServerImport 699L→404L (-42%,any[]→具体接口) + UnifiedQueryPanel 682L→529L (-22%) + ServerManager 652L→442L (-32%) + PipelineEditor 676L→406L (-40%) + RdpDialog 642L→442L (-31%) + **DeploymentEditService 627L→394L (-37%)**,累计提取 53 个独立组件文件** | +| 25 个 ≥500 行巨型组件 | 拆分困难 | 前端 | P1 → **Q6-Q18 done: 最大组件 ResourceVisualEditor 882L→282L (-68%) + AgentCLI 814L→521L (-36%) + SystemSettingsTab 754L→173L (-77%) + NamespaceResourcePage 719L→300L (-58%) + ApprovalWorkflowPage 705L→167L (-76%) + PipelineRunDetail 678L→273L (-60%) + CloudServerImport 699L→404L (-42%,any[]→具体接口) + UnifiedQueryPanel 682L→529L (-22%) + ServerManager 652L→442L (-32%) + PipelineEditor 676L→406L (-40%) + RdpDialog 642L→442L (-31%) + DeploymentEditService 627L→394L (-37%) + **TraceManager 605L→331L (-45%,修复 SpanTreeNode 内联定义反模式)**,累计提取 58 个独立组件文件** | ### 3.2 🟡 中优先级 diff --git a/ROADMAP.md b/ROADMAP.md index b49c55a..620d448 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -489,6 +489,45 @@ services/api.ts (1324L) | Q15 | 前端巨型组件拆分(PipelineEditor 676L→406L,提取 types + TaskCard + TaskEditorPanel + PipelineLogDialog + RunsHistoryTab + PipelineMembersTab) | P0 | **done** | | Q16 | 前端巨型组件拆分(RdpDialog 642L→442L,提取 types + rdpp-utils + RdpDialogHeader) | P0 | **done** | | Q17 | 前端巨型组件拆分(DeploymentEditService 627L→394L,提取 types + ServiceCreateForm + IngressCreateForm + ServiceListItem + IngressListItem) | P0 | **done** | +| Q18 | 前端巨型组件拆分(TraceManager 605L→331L,提取 types + trace-helpers + SpanTreeNode + TraceDetailDialog + SpanDetailDialog,修复 SpanTreeNode 内联定义反模式) | P0 | **done** | + +--- + +## Q18: 前端巨型组件拆分 — TraceManager [done] + +**问题**: `TraceManager.tsx`(605 行)是 Q17 拆分后项目最大的核心业务组件之一(排除 UI 库 sidebar/data-table),包含链路追踪管理页面的全部逻辑:类型定义(Trace/Span/SpanLog/ServiceMetrics)、状态徽章/Kind 图标/状态颜色/耗时格式化等工具函数、Span 树构建、**SpanTreeNode 递归组件内联定义(每次父组件 render 重新创建组件函数,性能反模式)**、Traces/Services/Metrics 三个 Tab 的完整 JSX、Trace 详情弹窗(Span 树可视化)、Span 详情弹窗(基本信息+标签+日志)。单文件过大导致维护困难。 + +**已完成 (2026-07-08)**: + +将类型/工具函数、共享 JSX 辅助、递归 Span 树组件、两个详情弹窗提取为 `trace-manager/` 子目录下的独立文件: + +| 新文件 | 行数 | 职责 | +|--------|------|------| +| `types.ts` | 90 | Trace/Span/SpanLog/ServiceMetrics 接口 + formatDuration/getStatusColor/buildSpanTree 纯函数 | +| `trace-helpers.tsx` | 44 | getStatusBadge(状态徽章)+ getKindIcon(Span kind 图标)共享 JSX 辅助,接收 TFunction 参数 | +| `SpanTreeNode.tsx` | 71 | 递归 Span 树节点组件(展开/折叠 + 时间轴宽度 + 状态色 + 点击选中 Span),接收 span/level/totalDuration/onSelectSpan props | +| `TraceDetailDialog.tsx` | 80 | Trace 详情弹窗(服务/操作/总耗时/状态摘要 + ScrollArea 内 Span 树可视化) | +| `SpanDetailDialog.tsx` | 91 | Span 详情弹窗(基本信息网格 + 标签键值对 + 日志列表) | + +主文件 `TraceManager.tsx` 从 **605 行降至 331 行**(-45%),仅保留:状态管理(traces/services/loading/搜索/筛选/时间范围/activeTab/selectedTrace/selectedSpan/弹窗开关)、数据加载(fetchData/fetchTraces/fetchServices/fetchTraceDetail)、派生数据(filteredTraces/serviceNames)、handleSelectSpan 回调、pageTabs、tracesToolbar(搜索+服务/状态/时间筛选+刷新)、PageShell 布局(StatStrip 统计条 + Traces 表格 + Services 表格 + Metrics 卡片)、以及 TraceDetailDialog + SpanDetailDialog 两个弹窗组件的编排。 + +**关键改进**: 原 `SpanTreeNode` 定义在主组件函数体内(`const SpanTreeNode: React.FC = (...)` 内联),每次父组件 render 都会创建新的组件函数引用 → React 无法复用,整个 Span 子树重渲染。提取为模块级独立组件后,组件引用稳定,避免不必要的子树重渲染。 + +**提取原则**: types 为纯数据/类型(零 React 依赖);trace-helpers 为共享 JSX 辅助(getStatusBadge/getKindIcon 返回 ReactNode,接收 TFunction 以支持 i18n);SpanTreeNode 为独立递归组件(模块级定义,接收 4 个 props);TraceDetailDialog/SpanDetailDialog 为受控展示组件(接收 open/onOpenChange/数据 props,内部用 useTranslation)。零逻辑变更。 + +**验收标准**: +- [x] TraceManager.tsx 从 605 行降至 **331 行**(-45%) +- [x] 5 个提取的文件全部 < 95 行(最大 SpanDetailDialog 91 行) +- [x] `tsc -b` 零错误 +- [x] `npm run build` 零错误(✓ built in 18.23s) +- [x] ESLint errors 保持 **0**(trace-manager/ 目录零 warnings) +- [x] ESLint warnings 保持 **64**(无新增) +- [x] 全局 ESLint any 保持 **0** +- [x] `go build` 零错误 +- [x] 17/17 集成回归测试通过(零回归) +- [x] Docker 部署 healthy,前端页面正常加载(HTTP 200),traces API 返回 200 +- [x] 链路追踪管理页功能不变(Traces/Services/Metrics 三 Tab + Trace 详情 Span 树 + Span 详情标签/日志全部正常) +- [x] SpanTreeNode 内联定义反模式已修复(提取为模块级稳定组件引用) --- diff --git a/TDD_PLAN.md b/TDD_PLAN.md index d25ae74..9ae64c3 100644 --- a/TDD_PLAN.md +++ b/TDD_PLAN.md @@ -1380,3 +1380,57 @@ python3 tests/integration_test.py --host 127.0.0.1 --port 8080 - [x] Docker 部署 healthy,前端页面正常加载(HTTP 200) - [x] 服务器管理页功能不变(列表/搜索/筛选/分页/选择/批量删除/CSV 导出/创建编辑/终端/RDP/文件浏览全部正常) +## Q18: 前端巨型组件拆分 — TraceManager + +### 验证用例 + +```bash +# U1: Go 编译 +cd /srv/aiops/backend && go build ./... +# 期望: 零错误 + +# U2: TypeScript 编译 +cd /srv/aiops/frontend && npx tsc -b +# 期望: 零错误 + +# U3: Vite 构建 +npm run build 2>&1 | tail -3 +# 期望: built in Ns + +# U4: 拆分后文件行数 +wc -l src/components/monitor/trace/TraceManager.tsx src/components/monitor/trace/trace-manager/*.ts src/components/monitor/trace/trace-manager/*.tsx +# 期望: TraceManager < 340L, 其余 < 95L + +# U5: ESLint 全局指标 +npx eslint src/ --format json | python3 -c " +import json, sys +d = json.load(sys.stdin) +print(f'errors={sum(1 for r in d for m in r[\"messages\"] if m[\"severity\"]==2)}') +print(f'any={sum(1 for r in d for m in r[\"messages\"] if m.get(\"ruleId\")==\"@typescript-eslint/no-explicit-any\")}') +" +# 期望: errors=0, any=0 + +# U6: 集成测试 +python3 tests/integration_test.py --host 127.0.0.1 --port 8080 +# 期望: 17/17 passed + +# U7: traces API 可用(链路追踪页面后端代理) +TOKEN=$(curl -s http://127.0.0.1:8080/api/v1/auth/login -X POST -H 'Content-Type: application/json' -d '{"username":"admin","password":"123456"}' | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["token"])') +curl -s -o /dev/null -w "%{http_code}" -H "Authorization: Bearer ${TOKEN}" "http://127.0.0.1:8080/api/v1/traces?timeRange=1h" +# 期望: 200 +``` + +### 通过标准 +- [x] TraceManager.tsx 从 605 行降至 **331 行**(< 340 ✅,-45%) +- [x] 5 个提取文件全部 < 95 行(types 90L + trace-helpers 44L + SpanTreeNode 71L + TraceDetailDialog 80L + SpanDetailDialog 91L) +- [x] `tsc -b` 零错误 +- [x] `npm run build` 零错误(✓ built in 18.23s) +- [x] ESLint errors 保持 **0**(trace-manager/ 目录零 warnings) +- [x] ESLint warnings 保持 **64**(无新增) +- [x] 全局 ESLint any 保持 **0** +- [x] `go build` 零错误 +- [x] 17/17 集成回归测试通过(零回归) +- [x] Docker 部署 healthy,前端页面正常加载(HTTP 200),traces API 返回 200 +- [x] 链路追踪管理页功能不变(Traces/Services/Metrics 三 Tab + Trace 详情 Span 树 + Span 详情标签/日志全部正常) +- [x] SpanTreeNode 内联定义反模式已修复(提取为模块级稳定组件引用) + diff --git a/frontend/src/components/monitor/trace/TraceManager.tsx b/frontend/src/components/monitor/trace/TraceManager.tsx index a5535b7..8c37afe 100644 --- a/frontend/src/components/monitor/trace/TraceManager.tsx +++ b/frontend/src/components/monitor/trace/TraceManager.tsx @@ -1,88 +1,30 @@ import React, { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { - Search, - Clock, - Server, - ArrowRight, - ChevronRight, - ChevronDown, - Zap, - AlertCircle, - CheckCircle, - XCircle, + Eye, + RefreshCw, + GitBranch, BarChart3, TrendingUp, - RefreshCw, - Eye, - GitBranch, - Code, - Box, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; -import { Badge } from '@/components/ui/badge'; -import { Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { StatStrip } from '@/components/ui/stat-strip'; import { - Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription -} from '@/components/ui/dialog'; -import { - Select, SelectContent, SelectItem, SelectTrigger, SelectValue + Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; -import { Label } from '@/components/ui/label'; -import { Separator } from '@/components/ui/separator'; -import { ScrollArea } from '@/components/ui/scroll-area'; import { toast } from 'sonner'; import { traceApi } from '@/api'; import { PageShell } from '@/components/layout/PageShell'; import { PageLoading } from '@/components/layout/PageLoading'; import { PageEmpty } from '@/components/layout/PageEmpty'; -import type { JsonData } from '@/types/common'; - -interface Trace { - id: string; - traceId: string; - serviceName: string; - operationName: string; - startTime: string; - duration: number; - status: 'success' | 'error' | 'pending'; - spanCount: number; - tags: Record; - spans?: Span[]; -} - -interface Span { - id: string; - spanId: string; - parentSpanId?: string; - serviceName: string; - operationName: string; - startTime: string; - duration: number; - status: 'success' | 'error' | 'pending'; - kind: 'client' | 'server' | 'producer' | 'consumer' | 'internal'; - tags: Record; - logs: SpanLog[]; - children: Span[]; -} - -interface SpanLog { - timestamp: string; - fields: Record; -} - -interface ServiceMetrics { - serviceName: string; - requestCount: number; - errorRate: number; - avgDuration: number; - p95Duration: number; - p99Duration: number; -} +import { getStatusBadge } from './trace-manager/trace-helpers'; +import { formatDuration, type Trace, type Span, type ServiceMetrics } from './trace-manager/types'; +import TraceDetailDialog from './trace-manager/TraceDetailDialog'; +import SpanDetailDialog from './trace-manager/SpanDetailDialog'; const TraceManager: React.FC = () => { const { t } = useTranslation(['monitor', 'common']); @@ -129,55 +71,22 @@ const TraceManager: React.FC = () => { } }; - const fetchTraceDetail = async (traceId: string) => { + const fetchTraceDetail = async (traceId: string): Promise => { try { const res = await traceApi.getTraceDetail(traceId); - return res.data; + return res.data?.spans; } catch { toast.error(t('monitor:traceMgr.fetchDetailFailed')); - return null; + return undefined; } }; - const getStatusBadge = (status: string) => { - switch (status) { - case 'success': - return {t('common:success')}; - case 'error': - return {t('monitor:traceError')}; - case 'pending': - return {t('monitor:traceMgr.inProgress')}; - default: - return {t('common:unknown')}; - } + const handleSelectSpan = (span: Span) => { + setSelectedSpan(span); + setSpanDetailOpen(true); }; - const getKindIcon = (kind: string) => { - switch (kind) { - case 'client': return ; - case 'server': return ; - case 'producer': return ; - case 'consumer': return ; - default: return ; - } - }; - - const getStatusColor = (status: string) => { - switch (status) { - case 'success': return 'bg-green-500'; - case 'error': return 'bg-red-500'; - case 'pending': return 'bg-yellow-500'; - default: return 'bg-[var(--dg-bg-active)]'; - } - }; - - const formatDuration = (ms: number) => { - if (ms < 1) return `${(ms * 1000).toFixed(2)}μs`; - if (ms < 1000) return `${ms.toFixed(2)}ms`; - return `${(ms / 1000).toFixed(2)}s`; - }; - - const filteredTraces = traces.filter(trace => { + const filteredTraces = traces.filter((trace) => { const matchSearch = trace.traceId.toLowerCase().includes(searchQuery.toLowerCase()) || trace.operationName.toLowerCase().includes(searchQuery.toLowerCase()); const matchService = serviceFilter === 'all' || trace.serviceName === serviceFilter; @@ -185,82 +94,7 @@ const TraceManager: React.FC = () => { return matchSearch && matchService && matchStatus; }); - const serviceNames = Array.from(new Set(traces.map(t => t.serviceName))); - - // 构建Span树 - const buildSpanTree = (spans: Span[]): Span[] => { - const spanMap = new Map(); - const roots: Span[] = []; - - spans.forEach(span => { - spanMap.set(span.spanId, { ...span, children: [] }); - }); - - spans.forEach(span => { - const node = spanMap.get(span.spanId)!; - if (span.parentSpanId && spanMap.has(span.parentSpanId)) { - const parent = spanMap.get(span.parentSpanId)!; - parent.children = parent.children || []; - parent.children.push(node); - } else { - roots.push(node); - } - }); - - return roots; - }; - - // 渲染Span树 - const SpanTreeNode: React.FC<{ span: Span; level: number; totalDuration: number }> = ({ span, level, totalDuration }) => { - const [expanded, setExpanded] = useState(true); - const widthPercent = totalDuration > 0 ? (span.duration / totalDuration) * 100 : 0; - const _offsetPercent = 0; // Simplified - - return ( -
-
{ setSelectedSpan(span); setSpanDetailOpen(true); }} - > - {span.children?.length > 0 && ( - - )} - {!span.children?.length && } - -
- {getKindIcon(span.kind)} - {span.operationName} - {span.serviceName} -
- -
-
-
-
- - {formatDuration(span.duration)} - - {span.status === 'error' && } -
-
- - {expanded && span.children?.map(child => ( - - ))} -
- ); - }; + const serviceNames = Array.from(new Set(traces.map((tr) => tr.serviceName))); const pageTabs = [ { id: 'traces', label: t('monitor:traceMgr.tabTraces') }, @@ -270,51 +104,51 @@ const TraceManager: React.FC = () => { const tracesToolbar = activeTab === 'traces' ? (
-
-
- - setSearchQuery(e.target.value)} - className="pl-10" - /> -
- - - -
+
+
+ + setSearchQuery(e.target.value)} + className="pl-10" + /> +
+ + + +
+ + )} {activeTab === 'services' && ( - - - {t('monitor:traceMgr.servicePerfTitle')} - - -
- - - - - - - - - + + + {t('monitor:traceMgr.servicePerfTitle')} + + +
+
{t('monitor:traceMgr.serviceName')}{t('monitor:traceMgr.requestCount')}{t('monitor:traceMgr.errorRate')}{t('monitor:traceMgr.avgLatency')}{t('monitor:traceMgr.p95Latency')}{t('monitor:traceMgr.p99Latency')}
+ + + + + + + + + + + + {services.map((service) => ( + + + + + + + - - - {services.map((service) => ( - - - - - - - - - ))} - -
{t('monitor:traceMgr.serviceName')}{t('monitor:traceMgr.requestCount')}{t('monitor:traceMgr.errorRate')}{t('monitor:traceMgr.avgLatency')}{t('monitor:traceMgr.p95Latency')}{t('monitor:traceMgr.p99Latency')}
{service.serviceName}{service.requestCount.toLocaleString()} + 1 ? 'text-red-500' : 'dg-text-green'}> + {service.errorRate.toFixed(2)}% + + {formatDuration(service.avgDuration)}{formatDuration(service.p95Duration)}{formatDuration(service.p99Duration)}
{service.serviceName}{service.requestCount.toLocaleString()} - 1 ? 'text-red-500' : 'dg-text-green'}> - {service.errorRate.toFixed(2)}% - - {formatDuration(service.avgDuration)}{formatDuration(service.p95Duration)}{formatDuration(service.p99Duration)}
-
-
-
+ ))} + + +
+ + )} {activeTab === 'metrics' && ( -
- - - {t('monitor:traceMgr.latencyDist')} - - - - {t('monitor:traceMgr.latencyDistChart')} - - - - - {t('monitor:traceMgr.errorTrend')} - - - - {t('monitor:traceMgr.errorTrendChart')} - - -
+
+ + + {t('monitor:traceMgr.latencyDist')} + + + + {t('monitor:traceMgr.latencyDistChart')} + + + + + {t('monitor:traceMgr.errorTrend')} + + + + {t('monitor:traceMgr.errorTrendChart')} + + +
)} - {/* Trace详情对话框 */} - - - - - - {t('monitor:traceMgr.traceDetailTitle')} - {selectedTrace?.traceId} - - {t('common:description')} - - {selectedTrace && ( -
-
-
- -

{selectedTrace.serviceName}

-
- -
- -

{selectedTrace.operationName}

-
- -
- -

{formatDuration(selectedTrace.duration)}

-
- -
- -

{getStatusBadge(selectedTrace.status)}

-
-
- - -
-
- {t('monitor:traceMgr.spanName')} - {t('monitor:traceTimeline')} - {t('monitor:traceDuration')} -
- {/* Span树渲染 */} - {(selectedTrace as JsonData).spans && buildSpanTree((selectedTrace as JsonData).spans).map(span => ( - - ))} -
-
-
- )} -
-
+ - {/* Span详情对话框 */} - - - - {t('monitor:traceMgr.spanDetailTitle')} - {t('common:description')} - - {selectedSpan && ( -
-
-
- -

{selectedSpan.spanId}

-
-
- -

{selectedSpan.kind}

-
-
- -

{selectedSpan.serviceName}

-
-
- -

{selectedSpan.operationName}

-
-
- -

{getStatusBadge(selectedSpan.status)}

-
-
- -

{formatDuration(selectedSpan.duration)}

-
-
- - {Object.keys(selectedSpan.tags).length > 0 && ( -
- -
- {Object.entries(selectedSpan.tags).map(([k, v]) => ( -
- {k} - {v} -
- ))} -
-
- )} - - {selectedSpan.logs?.length > 0 && ( -
- -
- {selectedSpan.logs.map((log, idx) => ( -
- {log.timestamp} -
{JSON.stringify(log.fields, null, 2)}
-
- ))} -
-
- )} -
- )} -
-
+ ); }; diff --git a/frontend/src/components/monitor/trace/trace-manager/SpanDetailDialog.tsx b/frontend/src/components/monitor/trace/trace-manager/SpanDetailDialog.tsx new file mode 100644 index 0000000..8bf1988 --- /dev/null +++ b/frontend/src/components/monitor/trace/trace-manager/SpanDetailDialog.tsx @@ -0,0 +1,91 @@ +/** + * Span 详情弹窗 — 基本信息 + 标签 + 日志 + */ +import { useTranslation } from 'react-i18next'; +import { + Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, +} from '@/components/ui/dialog'; +import { Label } from '@/components/ui/label'; +import { getStatusBadge } from './trace-helpers'; +import { formatDuration, type Span } from './types'; + +interface SpanDetailDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + selectedSpan: Span | null; +} + +const SpanDetailDialog = ({ open, onOpenChange, selectedSpan }: SpanDetailDialogProps) => { + const { t } = useTranslation(['monitor', 'common']); + + return ( + + + + {t('monitor:traceMgr.spanDetailTitle')} + {t('common:description')} + + {selectedSpan && ( +
+
+
+ +

{selectedSpan.spanId}

+
+
+ +

{selectedSpan.kind}

+
+
+ +

{selectedSpan.serviceName}

+
+
+ +

{selectedSpan.operationName}

+
+
+ +

{getStatusBadge(selectedSpan.status, t)}

+
+
+ +

{formatDuration(selectedSpan.duration)}

+
+
+ + {Object.keys(selectedSpan.tags).length > 0 && ( +
+ +
+ {Object.entries(selectedSpan.tags).map(([k, v]) => ( +
+ {k} + {v} +
+ ))} +
+
+ )} + + {selectedSpan.logs?.length > 0 && ( +
+ +
+ {selectedSpan.logs.map((log, idx) => ( +
+ {log.timestamp} +
{JSON.stringify(log.fields, null, 2)}
+
+ ))} +
+
+ )} +
+ )} +
+
+ ); +}; + +export default SpanDetailDialog; diff --git a/frontend/src/components/monitor/trace/trace-manager/SpanTreeNode.tsx b/frontend/src/components/monitor/trace/trace-manager/SpanTreeNode.tsx new file mode 100644 index 0000000..eb3ced5 --- /dev/null +++ b/frontend/src/components/monitor/trace/trace-manager/SpanTreeNode.tsx @@ -0,0 +1,71 @@ +/** + * Span 树递归节点组件 + * + * 从 TraceManager 主组件内联定义提取为独立组件。 + * 原内联定义在每次父组件 render 时都会重新创建组件函数(性能损耗), + * 提取后变为稳定引用,避免不必要的子树重渲染。 + */ +import React, { useState } from 'react'; +import { ChevronDown, ChevronRight, AlertCircle } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { getKindIcon } from './trace-helpers'; +import { getStatusColor, formatDuration, type Span } from './types'; + +interface SpanTreeNodeProps { + span: Span; + level: number; + totalDuration: number; + onSelectSpan: (span: Span) => void; +} + +const SpanTreeNode: React.FC = ({ span, level, totalDuration, onSelectSpan }) => { + const [expanded, setExpanded] = useState(true); + const widthPercent = totalDuration > 0 ? (span.duration / totalDuration) * 100 : 0; + + return ( +
+
{ onSelectSpan(span); }} + > + {span.children?.length > 0 && ( + + )} + {!span.children?.length && } + +
+ {getKindIcon(span.kind)} + {span.operationName} + {span.serviceName} +
+ +
+
+
+
+ + {formatDuration(span.duration)} + + {span.status === 'error' && } +
+
+ + {expanded && span.children?.map((child) => ( + + ))} +
+ ); +}; + +export default SpanTreeNode; diff --git a/frontend/src/components/monitor/trace/trace-manager/TraceDetailDialog.tsx b/frontend/src/components/monitor/trace/trace-manager/TraceDetailDialog.tsx new file mode 100644 index 0000000..10bf57a --- /dev/null +++ b/frontend/src/components/monitor/trace/trace-manager/TraceDetailDialog.tsx @@ -0,0 +1,80 @@ +/** + * Trace 详情弹窗 — Span 树可视化 + */ +import { useTranslation } from 'react-i18next'; +import { GitBranch } from 'lucide-react'; +import { + Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, +} from '@/components/ui/dialog'; +import { Label } from '@/components/ui/label'; +import { Separator } from '@/components/ui/separator'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { getStatusBadge } from './trace-helpers'; +import { formatDuration, buildSpanTree, type Trace, type Span } from './types'; +import SpanTreeNode from './SpanTreeNode'; + +interface TraceDetailDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + selectedTrace: Trace | null; + onSelectSpan: (span: Span) => void; +} + +const TraceDetailDialog = ({ open, onOpenChange, selectedTrace, onSelectSpan }: TraceDetailDialogProps) => { + const { t } = useTranslation(['monitor', 'common']); + + return ( + + + + + + {t('monitor:traceMgr.traceDetailTitle')} + {selectedTrace?.traceId} + + {t('common:description')} + + {selectedTrace && ( +
+
+
+ +

{selectedTrace.serviceName}

+
+ +
+ +

{selectedTrace.operationName}

+
+ +
+ +

{formatDuration(selectedTrace.duration)}

+
+ +
+ +

{getStatusBadge(selectedTrace.status, t)}

+
+
+ + +
+
+ {t('monitor:traceMgr.spanName')} + {t('monitor:traceTimeline')} + {t('monitor:traceDuration')} +
+ {selectedTrace.spans && buildSpanTree(selectedTrace.spans).map((span) => ( + + ))} +
+
+
+ )} +
+
+ ); +}; + +export default TraceDetailDialog; diff --git a/frontend/src/components/monitor/trace/trace-manager/trace-helpers.tsx b/frontend/src/components/monitor/trace/trace-manager/trace-helpers.tsx new file mode 100644 index 0000000..5238b0d --- /dev/null +++ b/frontend/src/components/monitor/trace/trace-manager/trace-helpers.tsx @@ -0,0 +1,44 @@ +/** + * TraceManager 共享 JSX 辅助组件 + * + * getStatusBadge — Trace/Span 状态徽章 + * getKindIcon — Span kind 图标 + */ +import type { ReactNode } from 'react'; +import { Badge } from '@/components/ui/badge'; +import { + CheckCircle, + XCircle, + Clock, + ArrowRight, + Server, + Zap, + Box, + Code, +} from 'lucide-react'; +import type { TFunction } from 'i18next'; + +/** Trace/Span 状态徽章 */ +export function getStatusBadge(status: string, t: TFunction): ReactNode { + switch (status) { + case 'success': + return {t('common:success')}; + case 'error': + return {t('monitor:traceError')}; + case 'pending': + return {t('monitor:traceMgr.inProgress')}; + default: + return {t('common:unknown')}; + } +} + +/** Span kind 图标 */ +export function getKindIcon(kind: string): ReactNode { + switch (kind) { + case 'client': return ; + case 'server': return ; + case 'producer': return ; + case 'consumer': return ; + default: return ; + } +} diff --git a/frontend/src/components/monitor/trace/trace-manager/types.ts b/frontend/src/components/monitor/trace/trace-manager/types.ts new file mode 100644 index 0000000..d99c8cf --- /dev/null +++ b/frontend/src/components/monitor/trace/trace-manager/types.ts @@ -0,0 +1,90 @@ +/** + * TraceManager 类型定义 + 纯工具函数 + * + * 从 TraceManager.tsx(605行) 提取,避免主组件过大。 + * 所有函数均为纯函数(输入 → 输出,无副作用)。 + */ + +export interface Trace { + id: string; + traceId: string; + serviceName: string; + operationName: string; + startTime: string; + duration: number; + status: 'success' | 'error' | 'pending'; + spanCount: number; + tags: Record; + spans?: Span[]; +} + +export interface Span { + id: string; + spanId: string; + parentSpanId?: string; + serviceName: string; + operationName: string; + startTime: string; + duration: number; + status: 'success' | 'error' | 'pending'; + kind: 'client' | 'server' | 'producer' | 'consumer' | 'internal'; + tags: Record; + logs: SpanLog[]; + children: Span[]; +} + +export interface SpanLog { + timestamp: string; + fields: Record; +} + +export interface ServiceMetrics { + serviceName: string; + requestCount: number; + errorRate: number; + avgDuration: number; + p95Duration: number; + p99Duration: number; +} + +// ============ 纯工具函数 ============ + +/** 格式化耗时(μs/ms/s) */ +export function formatDuration(ms: number): string { + if (ms < 1) return `${(ms * 1000).toFixed(2)}μs`; + if (ms < 1000) return `${ms.toFixed(2)}ms`; + return `${(ms / 1000).toFixed(2)}s`; +} + +/** Span 状态对应的背景色 class */ +export function getStatusColor(status: string): string { + switch (status) { + case 'success': return 'bg-green-500'; + case 'error': return 'bg-red-500'; + case 'pending': return 'bg-yellow-500'; + default: return 'bg-[var(--dg-bg-active)]'; + } +} + +/** 构建 Span 树(按 parentSpanId 组织层级) */ +export function buildSpanTree(spans: Span[]): Span[] { + const spanMap = new Map(); + const roots: Span[] = []; + + spans.forEach((span) => { + spanMap.set(span.spanId, { ...span, children: [] }); + }); + + spans.forEach((span) => { + const node = spanMap.get(span.spanId)!; + if (span.parentSpanId && spanMap.has(span.parentSpanId)) { + const parent = spanMap.get(span.parentSpanId)!; + parent.children = parent.children || []; + parent.children.push(node); + } else { + roots.push(node); + } + }); + + return roots; +}