refactor(quality): Q18 前端巨型组件拆分 — TraceManager 605L→331L (-45%), 提取 types + trace-helpers + SpanTreeNode + TraceDetailDialog + SpanDetailDialog 5 个独立文件, 修复 SpanTreeNode 内联定义反模式(每次 render 重建组件函数), 零逻辑变更, ESLint errors=0/warnings=64/any=0, tsc -b=0, npm build✓, 17/17 测试通过
This commit is contained in:
1 parent
6f1c79fd51
commit
69447e4d66
9 files changed
+656
-461
No files matched your search
@@ -129,7 +129,7 @@
|
|||||||
| ~~`api.ts` 1324 行/352 方法~~ → 已拆分为 12 个域文件 (api/kubernetes.ts, deploy.ts, monitor.ts 等) | ✅ 已解决 | 前端 | ~~P0~~ done |
|
| ~~`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) |
|
| 前端 **1026 处** `any` 类型 | 类型安全缺失,重构风险高 | 前端全局 | ~~P0~~ **done** (any 1076→40, unused 25→6, console 93→1, tsc --noEmit=0) |
|
||||||
| ~~K8s 双路由体系(集群域+Kuboard 域)~~ → code 路由统一为主,ID 路由标记 deprecated 向后兼容 | ✅ 已解决 | 后端 | ~~P1~~ done |
|
| ~~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 🟡 中优先级
|
### 3.2 🟡 中优先级
|
||||||
|
|
||||||
|
|||||||
+39
@@ -489,6 +489,45 @@ services/api.ts (1324L)
|
|||||||
| Q15 | 前端巨型组件拆分(PipelineEditor 676L→406L,提取 types + TaskCard + TaskEditorPanel + PipelineLogDialog + RunsHistoryTab + PipelineMembersTab) | P0 | **done** |
|
| Q15 | 前端巨型组件拆分(PipelineEditor 676L→406L,提取 types + TaskCard + TaskEditorPanel + PipelineLogDialog + RunsHistoryTab + PipelineMembersTab) | P0 | **done** |
|
||||||
| Q16 | 前端巨型组件拆分(RdpDialog 642L→442L,提取 types + rdpp-utils + RdpDialogHeader) | P0 | **done** |
|
| Q16 | 前端巨型组件拆分(RdpDialog 642L→442L,提取 types + rdpp-utils + RdpDialogHeader) | P0 | **done** |
|
||||||
| Q17 | 前端巨型组件拆分(DeploymentEditService 627L→394L,提取 types + ServiceCreateForm + IngressCreateForm + ServiceListItem + IngressListItem) | 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 内联定义反模式已修复(提取为模块级稳定组件引用)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+54
@@ -1380,3 +1380,57 @@ python3 tests/integration_test.py --host 127.0.0.1 --port 8080
|
|||||||
- [x] Docker 部署 healthy,前端页面正常加载(HTTP 200)
|
- [x] Docker 部署 healthy,前端页面正常加载(HTTP 200)
|
||||||
- [x] 服务器管理页功能不变(列表/搜索/筛选/分页/选择/批量删除/CSV 导出/创建编辑/终端/RDP/文件浏览全部正常)
|
- [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 内联定义反模式已修复(提取为模块级稳定组件引用)
|
||||||
|
|
||||||
@@ -1,88 +1,30 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
|
|
||||||
Search,
|
Search,
|
||||||
Clock,
|
Eye,
|
||||||
Server,
|
RefreshCw,
|
||||||
ArrowRight,
|
GitBranch,
|
||||||
ChevronRight,
|
|
||||||
ChevronDown,
|
|
||||||
Zap,
|
|
||||||
AlertCircle,
|
|
||||||
CheckCircle,
|
|
||||||
XCircle,
|
|
||||||
BarChart3,
|
BarChart3,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
RefreshCw,
|
|
||||||
Eye,
|
|
||||||
GitBranch,
|
|
||||||
Code,
|
|
||||||
Box,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
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 { 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 { StatStrip } from '@/components/ui/stat-strip';
|
||||||
import {
|
import {
|
||||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription
|
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||||
} from '@/components/ui/dialog';
|
|
||||||
import {
|
|
||||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue
|
|
||||||
} from '@/components/ui/select';
|
} 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 { toast } from 'sonner';
|
||||||
import { traceApi } from '@/api';
|
import { traceApi } from '@/api';
|
||||||
import { PageShell } from '@/components/layout/PageShell';
|
import { PageShell } from '@/components/layout/PageShell';
|
||||||
import { PageLoading } from '@/components/layout/PageLoading';
|
import { PageLoading } from '@/components/layout/PageLoading';
|
||||||
import { PageEmpty } from '@/components/layout/PageEmpty';
|
import { PageEmpty } from '@/components/layout/PageEmpty';
|
||||||
import type { JsonData } from '@/types/common';
|
import { getStatusBadge } from './trace-manager/trace-helpers';
|
||||||
|
import { formatDuration, type Trace, type Span, type ServiceMetrics } from './trace-manager/types';
|
||||||
interface Trace {
|
import TraceDetailDialog from './trace-manager/TraceDetailDialog';
|
||||||
id: string;
|
import SpanDetailDialog from './trace-manager/SpanDetailDialog';
|
||||||
traceId: string;
|
|
||||||
serviceName: string;
|
|
||||||
operationName: string;
|
|
||||||
startTime: string;
|
|
||||||
duration: number;
|
|
||||||
status: 'success' | 'error' | 'pending';
|
|
||||||
spanCount: number;
|
|
||||||
tags: Record<string, string>;
|
|
||||||
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<string, string>;
|
|
||||||
logs: SpanLog[];
|
|
||||||
children: Span[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SpanLog {
|
|
||||||
timestamp: string;
|
|
||||||
fields: Record<string, string>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ServiceMetrics {
|
|
||||||
serviceName: string;
|
|
||||||
requestCount: number;
|
|
||||||
errorRate: number;
|
|
||||||
avgDuration: number;
|
|
||||||
p95Duration: number;
|
|
||||||
p99Duration: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TraceManager: React.FC = () => {
|
const TraceManager: React.FC = () => {
|
||||||
const { t } = useTranslation(['monitor', 'common']);
|
const { t } = useTranslation(['monitor', 'common']);
|
||||||
@@ -129,55 +71,22 @@ const TraceManager: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchTraceDetail = async (traceId: string) => {
|
const fetchTraceDetail = async (traceId: string): Promise<Span[] | undefined> => {
|
||||||
try {
|
try {
|
||||||
const res = await traceApi.getTraceDetail(traceId);
|
const res = await traceApi.getTraceDetail(traceId);
|
||||||
return res.data;
|
return res.data?.spans;
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(t('monitor:traceMgr.fetchDetailFailed'));
|
toast.error(t('monitor:traceMgr.fetchDetailFailed'));
|
||||||
return null;
|
return undefined;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStatusBadge = (status: string) => {
|
const handleSelectSpan = (span: Span) => {
|
||||||
switch (status) {
|
setSelectedSpan(span);
|
||||||
case 'success':
|
setSpanDetailOpen(true);
|
||||||
return <Badge variant="default" className="bg-green-500 gap-1"><CheckCircle className="w-3 h-3" />{t('common:success')}</Badge>;
|
|
||||||
case 'error':
|
|
||||||
return <Badge variant="destructive" className="gap-1"><XCircle className="w-3 h-3" />{t('monitor:traceError')}</Badge>;
|
|
||||||
case 'pending':
|
|
||||||
return <Badge variant="outline" className="gap-1"><Clock className="w-3 h-3" />{t('monitor:traceMgr.inProgress')}</Badge>;
|
|
||||||
default:
|
|
||||||
return <Badge variant="secondary">{t('common:unknown')}</Badge>;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getKindIcon = (kind: string) => {
|
const filteredTraces = traces.filter((trace) => {
|
||||||
switch (kind) {
|
|
||||||
case 'client': return <ArrowRight className="w-4 h-4" />;
|
|
||||||
case 'server': return <Server className="w-4 h-4" />;
|
|
||||||
case 'producer': return <Zap className="w-4 h-4" />;
|
|
||||||
case 'consumer': return <Box className="w-4 h-4" />;
|
|
||||||
default: return <Code className="w-4 h-4" />;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
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 matchSearch = trace.traceId.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
const matchSearch = trace.traceId.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
trace.operationName.toLowerCase().includes(searchQuery.toLowerCase());
|
trace.operationName.toLowerCase().includes(searchQuery.toLowerCase());
|
||||||
const matchService = serviceFilter === 'all' || trace.serviceName === serviceFilter;
|
const matchService = serviceFilter === 'all' || trace.serviceName === serviceFilter;
|
||||||
@@ -185,82 +94,7 @@ const TraceManager: React.FC = () => {
|
|||||||
return matchSearch && matchService && matchStatus;
|
return matchSearch && matchService && matchStatus;
|
||||||
});
|
});
|
||||||
|
|
||||||
const serviceNames = Array.from(new Set(traces.map(t => t.serviceName)));
|
const serviceNames = Array.from(new Set(traces.map((tr) => tr.serviceName)));
|
||||||
|
|
||||||
// 构建Span树
|
|
||||||
const buildSpanTree = (spans: Span[]): Span[] => {
|
|
||||||
const spanMap = new Map<string, Span>();
|
|
||||||
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 (
|
|
||||||
<div className="select-none">
|
|
||||||
<div
|
|
||||||
className="flex items-center py-2 hover:bg-muted/50 cursor-pointer border-l-2 border-transparent hover:border-blue-500"
|
|
||||||
style={{ paddingLeft: `${level * 24 + 12}px` }}
|
|
||||||
onClick={() => { setSelectedSpan(span); setSpanDetailOpen(true); }}
|
|
||||||
>
|
|
||||||
{span.children?.length > 0 && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-4 w-4 p-0 mr-1"
|
|
||||||
onClick={(e) => { e.stopPropagation(); setExpanded(!expanded); }}
|
|
||||||
>
|
|
||||||
{expanded ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{!span.children?.length && <span className="w-5" />}
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
|
||||||
{getKindIcon(span.kind)}
|
|
||||||
<span className="font-medium truncate">{span.operationName}</span>
|
|
||||||
<span className="text-xs text-muted-foreground">{span.serviceName}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="w-32 h-4 bg-muted rounded overflow-hidden">
|
|
||||||
<div
|
|
||||||
className={`h-full ${getStatusColor(span.status)}`}
|
|
||||||
style={{ width: `${Math.max(widthPercent, 1)}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span className="text-sm text-muted-foreground w-20 text-right">
|
|
||||||
{formatDuration(span.duration)}
|
|
||||||
</span>
|
|
||||||
{span.status === 'error' && <AlertCircle className="w-4 h-4 text-red-500" />}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{expanded && span.children?.map(child => (
|
|
||||||
<SpanTreeNode key={child.spanId} span={child} level={level + 1} totalDuration={totalDuration} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const pageTabs = [
|
const pageTabs = [
|
||||||
{ id: 'traces', label: t('monitor:traceMgr.tabTraces') },
|
{ id: 'traces', label: t('monitor:traceMgr.tabTraces') },
|
||||||
@@ -270,51 +104,51 @@ const TraceManager: React.FC = () => {
|
|||||||
|
|
||||||
const tracesToolbar = activeTab === 'traces' ? (
|
const tracesToolbar = activeTab === 'traces' ? (
|
||||||
<div className="flex flex-wrap items-center justify-between gap-4 w-full">
|
<div className="flex flex-wrap items-center justify-between gap-4 w-full">
|
||||||
<div className="flex items-center gap-2 flex-1 min-w-[400px]">
|
<div className="flex items-center gap-2 flex-1 min-w-[400px]">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
placeholder={t('monitor:traceMgr.searchPlaceholder')}
|
placeholder={t('monitor:traceMgr.searchPlaceholder')}
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
className="pl-10"
|
className="pl-10"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Select value={serviceFilter} onValueChange={setServiceFilter}>
|
<Select value={serviceFilter} onValueChange={setServiceFilter}>
|
||||||
<SelectTrigger className="w-[150px]">
|
<SelectTrigger className="w-[150px]">
|
||||||
<SelectValue placeholder={t('monitor:traceService')} />
|
<SelectValue placeholder={t('monitor:traceService')} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">{t('monitor:traceMgr.allServices')}</SelectItem>
|
<SelectItem value="all">{t('monitor:traceMgr.allServices')}</SelectItem>
|
||||||
{serviceNames.map(s => (
|
{serviceNames.map((s) => (
|
||||||
<SelectItem key={s} value={s}>{s}</SelectItem>
|
<SelectItem key={s} value={s}>{s}</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||||
<SelectTrigger className="w-[120px]">
|
<SelectTrigger className="w-[120px]">
|
||||||
<SelectValue placeholder={t('monitor:traceStatus')} />
|
<SelectValue placeholder={t('monitor:traceStatus')} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">{t('monitor:allStatus')}</SelectItem>
|
<SelectItem value="all">{t('monitor:allStatus')}</SelectItem>
|
||||||
<SelectItem value="success">{t('common:success')}</SelectItem>
|
<SelectItem value="success">{t('common:success')}</SelectItem>
|
||||||
<SelectItem value="error">{t('monitor:traceError')}</SelectItem>
|
<SelectItem value="error">{t('monitor:traceError')}</SelectItem>
|
||||||
<SelectItem value="pending">{t('monitor:traceMgr.inProgress')}</SelectItem>
|
<SelectItem value="pending">{t('monitor:traceMgr.inProgress')}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Select value={timeRange} onValueChange={setTimeRange}>
|
<Select value={timeRange} onValueChange={setTimeRange}>
|
||||||
<SelectTrigger className="w-[120px]">
|
<SelectTrigger className="w-[120px]">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="15m">{t('monitor:traceMgr.min15')}</SelectItem>
|
<SelectItem value="15m">{t('monitor:traceMgr.min15')}</SelectItem>
|
||||||
<SelectItem value="1h">{t('monitor:traceMgr.hour1')}</SelectItem>
|
<SelectItem value="1h">{t('monitor:traceMgr.hour1')}</SelectItem>
|
||||||
<SelectItem value="6h">{t('monitor:traceMgr.hour6')}</SelectItem>
|
<SelectItem value="6h">{t('monitor:traceMgr.hour6')}</SelectItem>
|
||||||
<SelectItem value="24h">{t('monitor:traceMgr.hour24')}</SelectItem>
|
<SelectItem value="24h">{t('monitor:traceMgr.hour24')}</SelectItem>
|
||||||
<SelectItem value="7d">{t('monitor:traceMgr.day7')}</SelectItem>
|
<SelectItem value="7d">{t('monitor:traceMgr.day7')}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button variant="outline" className="dg-btn-secondary" onClick={fetchTraces}>
|
<Button variant="outline" className="dg-btn-secondary" onClick={fetchTraces}>
|
||||||
<RefreshCw className="w-4 h-4 mr-2" />
|
<RefreshCw className="w-4 h-4 mr-2" />
|
||||||
@@ -353,251 +187,143 @@ const TraceManager: React.FC = () => {
|
|||||||
>
|
>
|
||||||
{activeTab === 'traces' && (
|
{activeTab === 'traces' && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead className="bg-muted/50">
|
<thead className="bg-muted/50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-medium">Trace ID</th>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-medium">{t('monitor:traceMgr.colServiceOperation')}</th>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-medium">{t('monitor:traceStatus')}</th>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-medium">{t('monitor:traceDuration')}</th>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-medium">{t('monitor:traceMgr.spanCount')}</th>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-medium">{t('common:createdAt')}</th>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-medium">{t('common:actions')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y">
|
||||||
|
{loading ? (
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-4 py-3 text-left text-sm font-medium">Trace ID</th>
|
<td colSpan={7} className="px-4 py-2"><PageLoading label={t('monitor:traceMgr.loadingTraces')} /></td>
|
||||||
<th className="px-4 py-3 text-left text-sm font-medium">{t('monitor:traceMgr.colServiceOperation')}</th>
|
|
||||||
<th className="px-4 py-3 text-left text-sm font-medium">{t('monitor:traceStatus')}</th>
|
|
||||||
<th className="px-4 py-3 text-left text-sm font-medium">{t('monitor:traceDuration')}</th>
|
|
||||||
<th className="px-4 py-3 text-left text-sm font-medium">{t('monitor:traceMgr.spanCount')}</th>
|
|
||||||
<th className="px-4 py-3 text-left text-sm font-medium">{t('common:createdAt')}</th>
|
|
||||||
<th className="px-4 py-3 text-left text-sm font-medium">{t('common:actions')}</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
) : filteredTraces.length === 0 ? (
|
||||||
<tbody className="divide-y">
|
<tr>
|
||||||
{loading ? (
|
<td colSpan={7} className="px-4 py-2"><PageEmpty title={t('monitor:traceMgr.noTraces')} className="py-8" /></td>
|
||||||
<tr>
|
</tr>
|
||||||
<td colSpan={7} className="px-4 py-2"><PageLoading label={t('monitor:traceMgr.loadingTraces')} /></td>
|
) : (
|
||||||
|
filteredTraces.map((trace) => (
|
||||||
|
<tr key={trace.id} className="hover:bg-muted/50">
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className="font-mono text-sm dg-text-blue">{trace.traceId.slice(0, 16)}...</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="font-medium">{trace.operationName}</div>
|
||||||
|
<div className="text-sm text-muted-foreground">{trace.serviceName}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">{getStatusBadge(trace.status, t)}</td>
|
||||||
|
<td className="px-4 py-3 text-sm">{formatDuration(trace.duration)}</td>
|
||||||
|
<td className="px-4 py-3 text-sm">{trace.spanCount}</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-muted-foreground">{trace.startTime}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={async () => {
|
||||||
|
const spans = await fetchTraceDetail(trace.traceId);
|
||||||
|
if (spans) {
|
||||||
|
setSelectedTrace({ ...trace, spans });
|
||||||
|
setTraceDetailOpen(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Eye className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : filteredTraces.length === 0 ? (
|
))
|
||||||
<tr>
|
)}
|
||||||
<td colSpan={7} className="px-4 py-2"><PageEmpty title={t('monitor:traceMgr.noTraces')} className="py-8" /></td>
|
</tbody>
|
||||||
</tr>
|
</table>
|
||||||
) : (
|
</div>
|
||||||
filteredTraces.map((trace) => (
|
</CardContent>
|
||||||
<tr key={trace.id} className="hover:bg-muted/50">
|
</Card>
|
||||||
<td className="px-4 py-3">
|
|
||||||
<span className="font-mono text-sm dg-text-blue">{trace.traceId.slice(0, 16)}...</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<div className="font-medium">{trace.operationName}</div>
|
|
||||||
<div className="text-sm text-muted-foreground">{trace.serviceName}</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3">{getStatusBadge(trace.status)}</td>
|
|
||||||
<td className="px-4 py-3 text-sm">{formatDuration(trace.duration)}</td>
|
|
||||||
<td className="px-4 py-3 text-sm">{trace.spanCount}</td>
|
|
||||||
<td className="px-4 py-3 text-sm text-muted-foreground">{trace.startTime}</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={async () => {
|
|
||||||
const detail = await fetchTraceDetail(trace.traceId);
|
|
||||||
if (detail) {
|
|
||||||
setSelectedTrace({ ...trace, spans: detail.spans });
|
|
||||||
setTraceDetailOpen(true);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Eye className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'services' && (
|
{activeTab === 'services' && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{t('monitor:traceMgr.servicePerfTitle')}</CardTitle>
|
<CardTitle>{t('monitor:traceMgr.servicePerfTitle')}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead className="bg-muted/50">
|
<thead className="bg-muted/50">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-4 py-3 text-left text-sm font-medium">{t('monitor:traceMgr.serviceName')}</th>
|
<th className="px-4 py-3 text-left text-sm font-medium">{t('monitor:traceMgr.serviceName')}</th>
|
||||||
<th className="px-4 py-3 text-left text-sm font-medium">{t('monitor:traceMgr.requestCount')}</th>
|
<th className="px-4 py-3 text-left text-sm font-medium">{t('monitor:traceMgr.requestCount')}</th>
|
||||||
<th className="px-4 py-3 text-left text-sm font-medium">{t('monitor:traceMgr.errorRate')}</th>
|
<th className="px-4 py-3 text-left text-sm font-medium">{t('monitor:traceMgr.errorRate')}</th>
|
||||||
<th className="px-4 py-3 text-left text-sm font-medium">{t('monitor:traceMgr.avgLatency')}</th>
|
<th className="px-4 py-3 text-left text-sm font-medium">{t('monitor:traceMgr.avgLatency')}</th>
|
||||||
<th className="px-4 py-3 text-left text-sm font-medium">{t('monitor:traceMgr.p95Latency')}</th>
|
<th className="px-4 py-3 text-left text-sm font-medium">{t('monitor:traceMgr.p95Latency')}</th>
|
||||||
<th className="px-4 py-3 text-left text-sm font-medium">{t('monitor:traceMgr.p99Latency')}</th>
|
<th className="px-4 py-3 text-left text-sm font-medium">{t('monitor:traceMgr.p99Latency')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y">
|
||||||
|
{services.map((service) => (
|
||||||
|
<tr key={service.serviceName} className="hover:bg-muted/50">
|
||||||
|
<td className="px-4 py-3 font-medium">{service.serviceName}</td>
|
||||||
|
<td className="px-4 py-3">{service.requestCount.toLocaleString()}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className={service.errorRate > 1 ? 'text-red-500' : 'dg-text-green'}>
|
||||||
|
{service.errorRate.toFixed(2)}%
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">{formatDuration(service.avgDuration)}</td>
|
||||||
|
<td className="px-4 py-3">{formatDuration(service.p95Duration)}</td>
|
||||||
|
<td className="px-4 py-3">{formatDuration(service.p99Duration)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
))}
|
||||||
<tbody className="divide-y">
|
</tbody>
|
||||||
{services.map((service) => (
|
</table>
|
||||||
<tr key={service.serviceName} className="hover:bg-muted/50">
|
</div>
|
||||||
<td className="px-4 py-3 font-medium">{service.serviceName}</td>
|
</CardContent>
|
||||||
<td className="px-4 py-3">{service.requestCount.toLocaleString()}</td>
|
</Card>
|
||||||
<td className="px-4 py-3">
|
|
||||||
<span className={service.errorRate > 1 ? 'text-red-500' : 'dg-text-green'}>
|
|
||||||
{service.errorRate.toFixed(2)}%
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3">{formatDuration(service.avgDuration)}</td>
|
|
||||||
<td className="px-4 py-3">{formatDuration(service.p95Duration)}</td>
|
|
||||||
<td className="px-4 py-3">{formatDuration(service.p99Duration)}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'metrics' && (
|
{activeTab === 'metrics' && (
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{t('monitor:traceMgr.latencyDist')}</CardTitle>
|
<CardTitle>{t('monitor:traceMgr.latencyDist')}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="h-[300px] flex items-center justify-center text-muted-foreground">
|
<CardContent className="h-[300px] flex items-center justify-center text-muted-foreground">
|
||||||
<BarChart3 className="w-16 h-16 mr-4" />
|
<BarChart3 className="w-16 h-16 mr-4" />
|
||||||
{t('monitor:traceMgr.latencyDistChart')}
|
{t('monitor:traceMgr.latencyDistChart')}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{t('monitor:traceMgr.errorTrend')}</CardTitle>
|
<CardTitle>{t('monitor:traceMgr.errorTrend')}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="h-[300px] flex items-center justify-center text-muted-foreground">
|
<CardContent className="h-[300px] flex items-center justify-center text-muted-foreground">
|
||||||
<TrendingUp className="w-16 h-16 mr-4" />
|
<TrendingUp className="w-16 h-16 mr-4" />
|
||||||
{t('monitor:traceMgr.errorTrendChart')}
|
{t('monitor:traceMgr.errorTrendChart')}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Trace详情对话框 */}
|
<TraceDetailDialog
|
||||||
<Dialog open={traceDetailOpen} onOpenChange={setTraceDetailOpen}>
|
open={traceDetailOpen}
|
||||||
<DialogContent className="max-w-6xl max-h-[90vh]">
|
onOpenChange={setTraceDetailOpen}
|
||||||
<DialogHeader>
|
selectedTrace={selectedTrace}
|
||||||
<DialogTitle className="flex items-center gap-2">
|
onSelectSpan={handleSelectSpan}
|
||||||
<GitBranch className="w-5 h-5" />
|
/>
|
||||||
{t('monitor:traceMgr.traceDetailTitle')}
|
|
||||||
<span className="font-mono text-sm text-muted-foreground">{selectedTrace?.traceId}</span>
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription className="sr-only">{t('common:description')}</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
{selectedTrace && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex items-center gap-4 p-3 bg-muted rounded-lg">
|
|
||||||
<div>
|
|
||||||
<Label className="text-xs text-muted-foreground">{t('monitor:traceService')}</Label>
|
|
||||||
<p className="font-medium">{selectedTrace.serviceName}</p>
|
|
||||||
</div>
|
|
||||||
<Separator orientation="vertical" className="h-8" />
|
|
||||||
<div>
|
|
||||||
<Label className="text-xs text-muted-foreground">{t('monitor:traceOperation')}</Label>
|
|
||||||
<p className="font-medium">{selectedTrace.operationName}</p>
|
|
||||||
</div>
|
|
||||||
<Separator orientation="vertical" className="h-8" />
|
|
||||||
<div>
|
|
||||||
<Label className="text-xs text-muted-foreground">{t('monitor:traceMgr.totalDuration')}</Label>
|
|
||||||
<p className="font-medium">{formatDuration(selectedTrace.duration)}</p>
|
|
||||||
</div>
|
|
||||||
<Separator orientation="vertical" className="h-8" />
|
|
||||||
<div>
|
|
||||||
<Label className="text-xs text-muted-foreground">{t('monitor:traceStatus')}</Label>
|
|
||||||
<p>{getStatusBadge(selectedTrace.status)}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ScrollArea className="h-[500px]">
|
|
||||||
<div className="border rounded-lg">
|
|
||||||
<div className="flex items-center px-4 py-2 bg-muted/50 border-b text-sm font-medium">
|
|
||||||
<span className="flex-1">{t('monitor:traceMgr.spanName')}</span>
|
|
||||||
<span className="w-32">{t('monitor:traceTimeline')}</span>
|
|
||||||
<span className="w-20 text-right">{t('monitor:traceDuration')}</span>
|
|
||||||
</div>
|
|
||||||
{/* Span树渲染 */}
|
|
||||||
{(selectedTrace as JsonData).spans && buildSpanTree((selectedTrace as JsonData).spans).map(span => (
|
|
||||||
<SpanTreeNode key={span.spanId} span={span} level={0} totalDuration={selectedTrace.duration} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
{/* Span详情对话框 */}
|
<SpanDetailDialog
|
||||||
<Dialog open={spanDetailOpen} onOpenChange={setSpanDetailOpen}>
|
open={spanDetailOpen}
|
||||||
<DialogContent className="max-w-2xl">
|
onOpenChange={setSpanDetailOpen}
|
||||||
<DialogHeader>
|
selectedSpan={selectedSpan}
|
||||||
<DialogTitle>{t('monitor:traceMgr.spanDetailTitle')}</DialogTitle>
|
/>
|
||||||
<DialogDescription className="sr-only">{t('common:description')}</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
{selectedSpan && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<Label className="text-muted-foreground">Span ID</Label>
|
|
||||||
<p className="font-mono text-sm">{selectedSpan.spanId}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label className="text-muted-foreground">{t('common:type')}</Label>
|
|
||||||
<p className="capitalize">{selectedSpan.kind}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label className="text-muted-foreground">{t('monitor:traceService')}</Label>
|
|
||||||
<p className="font-medium">{selectedSpan.serviceName}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label className="text-muted-foreground">{t('monitor:traceOperation')}</Label>
|
|
||||||
<p className="font-medium">{selectedSpan.operationName}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label className="text-muted-foreground">{t('monitor:traceStatus')}</Label>
|
|
||||||
<p>{getStatusBadge(selectedSpan.status)}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label className="text-muted-foreground">{t('monitor:traceDuration')}</Label>
|
|
||||||
<p className="font-medium">{formatDuration(selectedSpan.duration)}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{Object.keys(selectedSpan.tags).length > 0 && (
|
|
||||||
<div>
|
|
||||||
<Label className="text-muted-foreground">{t('monitor:alertLabels')}</Label>
|
|
||||||
<div className="mt-1 p-3 bg-muted rounded-lg">
|
|
||||||
{Object.entries(selectedSpan.tags).map(([k, v]) => (
|
|
||||||
<div key={k} className="flex justify-between text-sm py-1">
|
|
||||||
<span className="text-muted-foreground">{k}</span>
|
|
||||||
<span className="font-mono">{v}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{selectedSpan.logs?.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<Label className="text-muted-foreground">{t('monitor:traceMgr.logs')}</Label>
|
|
||||||
<div className="mt-1 space-y-2">
|
|
||||||
{selectedSpan.logs.map((log, idx) => (
|
|
||||||
<div key={idx} className="p-2 bg-muted rounded text-sm">
|
|
||||||
<span className="text-muted-foreground">{log.timestamp}</span>
|
|
||||||
<pre className="mt-1 text-xs">{JSON.stringify(log.fields, null, 2)}</pre>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</PageShell>
|
</PageShell>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t('monitor:traceMgr.spanDetailTitle')}</DialogTitle>
|
||||||
|
<DialogDescription className="sr-only">{t('common:description')}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
{selectedSpan && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label className="text-muted-foreground">Span ID</Label>
|
||||||
|
<p className="font-mono text-sm">{selectedSpan.spanId}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-muted-foreground">{t('common:type')}</Label>
|
||||||
|
<p className="capitalize">{selectedSpan.kind}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-muted-foreground">{t('monitor:traceService')}</Label>
|
||||||
|
<p className="font-medium">{selectedSpan.serviceName}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-muted-foreground">{t('monitor:traceOperation')}</Label>
|
||||||
|
<p className="font-medium">{selectedSpan.operationName}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-muted-foreground">{t('monitor:traceStatus')}</Label>
|
||||||
|
<p>{getStatusBadge(selectedSpan.status, t)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-muted-foreground">{t('monitor:traceDuration')}</Label>
|
||||||
|
<p className="font-medium">{formatDuration(selectedSpan.duration)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{Object.keys(selectedSpan.tags).length > 0 && (
|
||||||
|
<div>
|
||||||
|
<Label className="text-muted-foreground">{t('monitor:alertLabels')}</Label>
|
||||||
|
<div className="mt-1 p-3 bg-muted rounded-lg">
|
||||||
|
{Object.entries(selectedSpan.tags).map(([k, v]) => (
|
||||||
|
<div key={k} className="flex justify-between text-sm py-1">
|
||||||
|
<span className="text-muted-foreground">{k}</span>
|
||||||
|
<span className="font-mono">{v}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedSpan.logs?.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<Label className="text-muted-foreground">{t('monitor:traceMgr.logs')}</Label>
|
||||||
|
<div className="mt-1 space-y-2">
|
||||||
|
{selectedSpan.logs.map((log, idx) => (
|
||||||
|
<div key={idx} className="p-2 bg-muted rounded text-sm">
|
||||||
|
<span className="text-muted-foreground">{log.timestamp}</span>
|
||||||
|
<pre className="mt-1 text-xs">{JSON.stringify(log.fields, null, 2)}</pre>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SpanDetailDialog;
|
||||||
@@ -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<SpanTreeNodeProps> = ({ span, level, totalDuration, onSelectSpan }) => {
|
||||||
|
const [expanded, setExpanded] = useState(true);
|
||||||
|
const widthPercent = totalDuration > 0 ? (span.duration / totalDuration) * 100 : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="select-none">
|
||||||
|
<div
|
||||||
|
className="flex items-center py-2 hover:bg-muted/50 cursor-pointer border-l-2 border-transparent hover:border-blue-500"
|
||||||
|
style={{ paddingLeft: `${level * 24 + 12}px` }}
|
||||||
|
onClick={() => { onSelectSpan(span); }}
|
||||||
|
>
|
||||||
|
{span.children?.length > 0 && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-4 w-4 p-0 mr-1"
|
||||||
|
onClick={(e) => { e.stopPropagation(); setExpanded(!expanded); }}
|
||||||
|
>
|
||||||
|
{expanded ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{!span.children?.length && <span className="w-5" />}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||||
|
{getKindIcon(span.kind)}
|
||||||
|
<span className="font-medium truncate">{span.operationName}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">{span.serviceName}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-32 h-4 bg-muted rounded overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full ${getStatusColor(span.status)}`}
|
||||||
|
style={{ width: `${Math.max(widthPercent, 1)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-muted-foreground w-20 text-right">
|
||||||
|
{formatDuration(span.duration)}
|
||||||
|
</span>
|
||||||
|
{span.status === 'error' && <AlertCircle className="w-4 h-4 text-red-500" />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{expanded && span.children?.map((child) => (
|
||||||
|
<SpanTreeNode key={child.spanId} span={child} level={level + 1} totalDuration={totalDuration} onSelectSpan={onSelectSpan} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SpanTreeNode;
|
||||||
@@ -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 (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-6xl max-h-[90vh]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<GitBranch className="w-5 h-5" />
|
||||||
|
{t('monitor:traceMgr.traceDetailTitle')}
|
||||||
|
<span className="font-mono text-sm text-muted-foreground">{selectedTrace?.traceId}</span>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="sr-only">{t('common:description')}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
{selectedTrace && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-4 p-3 bg-muted rounded-lg">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-muted-foreground">{t('monitor:traceService')}</Label>
|
||||||
|
<p className="font-medium">{selectedTrace.serviceName}</p>
|
||||||
|
</div>
|
||||||
|
<Separator orientation="vertical" className="h-8" />
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-muted-foreground">{t('monitor:traceOperation')}</Label>
|
||||||
|
<p className="font-medium">{selectedTrace.operationName}</p>
|
||||||
|
</div>
|
||||||
|
<Separator orientation="vertical" className="h-8" />
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-muted-foreground">{t('monitor:traceMgr.totalDuration')}</Label>
|
||||||
|
<p className="font-medium">{formatDuration(selectedTrace.duration)}</p>
|
||||||
|
</div>
|
||||||
|
<Separator orientation="vertical" className="h-8" />
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-muted-foreground">{t('monitor:traceStatus')}</Label>
|
||||||
|
<p>{getStatusBadge(selectedTrace.status, t)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollArea className="h-[500px]">
|
||||||
|
<div className="border rounded-lg">
|
||||||
|
<div className="flex items-center px-4 py-2 bg-muted/50 border-b text-sm font-medium">
|
||||||
|
<span className="flex-1">{t('monitor:traceMgr.spanName')}</span>
|
||||||
|
<span className="w-32">{t('monitor:traceTimeline')}</span>
|
||||||
|
<span className="w-20 text-right">{t('monitor:traceDuration')}</span>
|
||||||
|
</div>
|
||||||
|
{selectedTrace.spans && buildSpanTree(selectedTrace.spans).map((span) => (
|
||||||
|
<SpanTreeNode key={span.spanId} span={span} level={0} totalDuration={selectedTrace.duration} onSelectSpan={onSelectSpan} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TraceDetailDialog;
|
||||||
@@ -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 <Badge variant="default" className="bg-green-500 gap-1"><CheckCircle className="w-3 h-3" />{t('common:success')}</Badge>;
|
||||||
|
case 'error':
|
||||||
|
return <Badge variant="destructive" className="gap-1"><XCircle className="w-3 h-3" />{t('monitor:traceError')}</Badge>;
|
||||||
|
case 'pending':
|
||||||
|
return <Badge variant="outline" className="gap-1"><Clock className="w-3 h-3" />{t('monitor:traceMgr.inProgress')}</Badge>;
|
||||||
|
default:
|
||||||
|
return <Badge variant="secondary">{t('common:unknown')}</Badge>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Span kind 图标 */
|
||||||
|
export function getKindIcon(kind: string): ReactNode {
|
||||||
|
switch (kind) {
|
||||||
|
case 'client': return <ArrowRight className="w-4 h-4" />;
|
||||||
|
case 'server': return <Server className="w-4 h-4" />;
|
||||||
|
case 'producer': return <Zap className="w-4 h-4" />;
|
||||||
|
case 'consumer': return <Box className="w-4 h-4" />;
|
||||||
|
default: return <Code className="w-4 h-4" />;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, string>;
|
||||||
|
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<string, string>;
|
||||||
|
logs: SpanLog[];
|
||||||
|
children: Span[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpanLog {
|
||||||
|
timestamp: string;
|
||||||
|
fields: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, Span>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
Reference in new issue
Block a user