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:
attacker committed 2026-07-08 07:26:04 +08:00
1 parent 6f1c79fd51
commit 69447e4d66
9 files changed
+656 -461

No files matched your search

@@ -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<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;
}
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<Span[] | undefined> => {
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 <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 handleSelectSpan = (span: Span) => {
setSelectedSpan(span);
setSpanDetailOpen(true);
};
const getKindIcon = (kind: string) => {
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 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<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 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' ? (
<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="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder={t('monitor:traceMgr.searchPlaceholder')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
/>
</div>
<Select value={serviceFilter} onValueChange={setServiceFilter}>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder={t('monitor:traceService')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('monitor:traceMgr.allServices')}</SelectItem>
{serviceNames.map(s => (
<SelectItem key={s} value={s}>{s}</SelectItem>
))}
</SelectContent>
</Select>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[120px]">
<SelectValue placeholder={t('monitor:traceStatus')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('monitor:allStatus')}</SelectItem>
<SelectItem value="success">{t('common:success')}</SelectItem>
<SelectItem value="error">{t('monitor:traceError')}</SelectItem>
<SelectItem value="pending">{t('monitor:traceMgr.inProgress')}</SelectItem>
</SelectContent>
</Select>
<Select value={timeRange} onValueChange={setTimeRange}>
<SelectTrigger className="w-[120px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="15m">{t('monitor:traceMgr.min15')}</SelectItem>
<SelectItem value="1h">{t('monitor:traceMgr.hour1')}</SelectItem>
<SelectItem value="6h">{t('monitor:traceMgr.hour6')}</SelectItem>
<SelectItem value="24h">{t('monitor:traceMgr.hour24')}</SelectItem>
<SelectItem value="7d">{t('monitor:traceMgr.day7')}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2 flex-1 min-w-[400px]">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder={t('monitor:traceMgr.searchPlaceholder')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
/>
</div>
<Select value={serviceFilter} onValueChange={setServiceFilter}>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder={t('monitor:traceService')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('monitor:traceMgr.allServices')}</SelectItem>
{serviceNames.map((s) => (
<SelectItem key={s} value={s}>{s}</SelectItem>
))}
</SelectContent>
</Select>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[120px]">
<SelectValue placeholder={t('monitor:traceStatus')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('monitor:allStatus')}</SelectItem>
<SelectItem value="success">{t('common:success')}</SelectItem>
<SelectItem value="error">{t('monitor:traceError')}</SelectItem>
<SelectItem value="pending">{t('monitor:traceMgr.inProgress')}</SelectItem>
</SelectContent>
</Select>
<Select value={timeRange} onValueChange={setTimeRange}>
<SelectTrigger className="w-[120px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="15m">{t('monitor:traceMgr.min15')}</SelectItem>
<SelectItem value="1h">{t('monitor:traceMgr.hour1')}</SelectItem>
<SelectItem value="6h">{t('monitor:traceMgr.hour6')}</SelectItem>
<SelectItem value="24h">{t('monitor:traceMgr.hour24')}</SelectItem>
<SelectItem value="7d">{t('monitor:traceMgr.day7')}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" className="dg-btn-secondary" onClick={fetchTraces}>
<RefreshCw className="w-4 h-4 mr-2" />
@@ -353,251 +187,143 @@ const TraceManager: React.FC = () => {
>
{activeTab === 'traces' && (
<Card>
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-muted/50">
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full">
<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>
<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>
<td colSpan={7} className="px-4 py-2"><PageLoading label={t('monitor:traceMgr.loadingTraces')} /></td>
</tr>
</thead>
<tbody className="divide-y">
{loading ? (
<tr>
<td colSpan={7} className="px-4 py-2"><PageLoading label={t('monitor:traceMgr.loadingTraces')} /></td>
) : filteredTraces.length === 0 ? (
<tr>
<td colSpan={7} className="px-4 py-2"><PageEmpty title={t('monitor:traceMgr.noTraces')} className="py-8" /></td>
</tr>
) : (
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>
) : filteredTraces.length === 0 ? (
<tr>
<td colSpan={7} className="px-4 py-2"><PageEmpty title={t('monitor:traceMgr.noTraces')} className="py-8" /></td>
</tr>
) : (
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)}</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>
))
)}
</tbody>
</table>
</div>
</CardContent>
</Card>
)}
{activeTab === 'services' && (
<Card>
<CardHeader>
<CardTitle>{t('monitor:traceMgr.servicePerfTitle')}</CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-muted/50">
<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.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.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.p99Latency')}</th>
<Card>
<CardHeader>
<CardTitle>{t('monitor:traceMgr.servicePerfTitle')}</CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-muted/50">
<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.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.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.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>
</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>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
)}
{activeTab === 'metrics' && (
<div className="grid grid-cols-2 gap-4">
<Card>
<CardHeader>
<CardTitle>{t('monitor:traceMgr.latencyDist')}</CardTitle>
</CardHeader>
<CardContent className="h-[300px] flex items-center justify-center text-muted-foreground">
<BarChart3 className="w-16 h-16 mr-4" />
{t('monitor:traceMgr.latencyDistChart')}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{t('monitor:traceMgr.errorTrend')}</CardTitle>
</CardHeader>
<CardContent className="h-[300px] flex items-center justify-center text-muted-foreground">
<TrendingUp className="w-16 h-16 mr-4" />
{t('monitor:traceMgr.errorTrendChart')}
</CardContent>
</Card>
</div>
<div className="grid grid-cols-2 gap-4">
<Card>
<CardHeader>
<CardTitle>{t('monitor:traceMgr.latencyDist')}</CardTitle>
</CardHeader>
<CardContent className="h-[300px] flex items-center justify-center text-muted-foreground">
<BarChart3 className="w-16 h-16 mr-4" />
{t('monitor:traceMgr.latencyDistChart')}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{t('monitor:traceMgr.errorTrend')}</CardTitle>
</CardHeader>
<CardContent className="h-[300px] flex items-center justify-center text-muted-foreground">
<TrendingUp className="w-16 h-16 mr-4" />
{t('monitor:traceMgr.errorTrendChart')}
</CardContent>
</Card>
</div>
)}
{/* Trace详情对话框 */}
<Dialog open={traceDetailOpen} onOpenChange={setTraceDetailOpen}>
<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)}</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>
<TraceDetailDialog
open={traceDetailOpen}
onOpenChange={setTraceDetailOpen}
selectedTrace={selectedTrace}
onSelectSpan={handleSelectSpan}
/>
{/* Span详情对话框 */}
<Dialog open={spanDetailOpen} onOpenChange={setSpanDetailOpen}>
<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)}</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>
<SpanDetailDialog
open={spanDetailOpen}
onOpenChange={setSpanDetailOpen}
selectedSpan={selectedSpan}
/>
</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;
}