- 新增 PageShell/PageTabs/ContentPanel/PageLoading/PageEmpty 页面骨架 - index.css 设计令牌文档化,布局令牌与纯色主按钮(去除渐变粉彩) - 全站路由页接入 PageShell,云账单三 Tab 为标杆 - 主题体系:跟随系统 + 主流/开发者分组,新增 Dracula/Tokyo Night,One Dark 暖灰差异化 - 登录页/侧栏/顶栏去渐变光球,企业克制风 - 修复 AIAlertAnalyzer JSX 与构建依赖导入问题
603 lines
24 KiB
TypeScript
603 lines
24 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|
import {
|
|
|
|
Search,
|
|
Clock,
|
|
Server,
|
|
ArrowRight,
|
|
ChevronRight,
|
|
ChevronDown,
|
|
Zap,
|
|
AlertCircle,
|
|
CheckCircle,
|
|
XCircle,
|
|
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 { StatStrip } from '@/components/ui/stat-strip';
|
|
import {
|
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription
|
|
} from '@/components/ui/dialog';
|
|
import {
|
|
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;
|
|
}
|
|
|
|
const TraceManager: React.FC = () => {
|
|
const [traces, setTraces] = useState<Trace[]>([]);
|
|
const [services, setServices] = useState<ServiceMetrics[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [serviceFilter, setServiceFilter] = useState<string>('all');
|
|
const [statusFilter, setStatusFilter] = useState<string>('all');
|
|
const [timeRange, setTimeRange] = useState<string>('1h');
|
|
const [activeTab, setActiveTab] = useState('traces');
|
|
const [selectedTrace, setSelectedTrace] = useState<Trace | null>(null);
|
|
const [selectedSpan, setSelectedSpan] = useState<Span | null>(null);
|
|
const [traceDetailOpen, setTraceDetailOpen] = useState(false);
|
|
const [spanDetailOpen, setSpanDetailOpen] = useState(false);
|
|
|
|
useEffect(() => {
|
|
fetchData();
|
|
}, [timeRange]);
|
|
|
|
const fetchData = async () => {
|
|
await Promise.all([fetchTraces(), fetchServices()]);
|
|
};
|
|
|
|
const fetchTraces = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const res = await traceApi.getTraces({ timeRange, service: serviceFilter, status: statusFilter });
|
|
setTraces(res.data || []);
|
|
} catch {
|
|
toast.error('获取链路数据失败');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const fetchServices = async () => {
|
|
try {
|
|
const res = await traceApi.getServices(timeRange);
|
|
setServices(res.data || []);
|
|
} catch {
|
|
toast.error('获取服务数据失败');
|
|
}
|
|
};
|
|
|
|
const fetchTraceDetail = async (traceId: string) => {
|
|
try {
|
|
const res = await traceApi.getTraceDetail(traceId);
|
|
return res.data;
|
|
} catch {
|
|
toast.error('获取链路详情失败');
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const getStatusBadge = (status: string) => {
|
|
switch (status) {
|
|
case 'success':
|
|
return <Badge variant="default" className="bg-green-500 gap-1"><CheckCircle className="w-3 h-3" />成功</Badge>;
|
|
case 'error':
|
|
return <Badge variant="destructive" className="gap-1"><XCircle className="w-3 h-3" />错误</Badge>;
|
|
case 'pending':
|
|
return <Badge variant="outline" className="gap-1"><Clock className="w-3 h-3" />进行中</Badge>;
|
|
default:
|
|
return <Badge variant="secondary">未知</Badge>;
|
|
}
|
|
};
|
|
|
|
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 matchSearch = trace.traceId.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
trace.operationName.toLowerCase().includes(searchQuery.toLowerCase());
|
|
const matchService = serviceFilter === 'all' || trace.serviceName === serviceFilter;
|
|
const matchStatus = statusFilter === 'all' || trace.status === statusFilter;
|
|
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 pageTabs = [
|
|
{ id: 'traces', label: '链路追踪' },
|
|
{ id: 'services', label: '服务拓扑' },
|
|
{ id: 'metrics', label: '性能指标' },
|
|
];
|
|
|
|
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="搜索Trace ID、操作名..."
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
className="pl-10"
|
|
/>
|
|
</div>
|
|
<Select value={serviceFilter} onValueChange={setServiceFilter}>
|
|
<SelectTrigger className="w-[150px]">
|
|
<SelectValue placeholder="服务" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">全部服务</SelectItem>
|
|
{serviceNames.map(s => (
|
|
<SelectItem key={s} value={s}>{s}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
|
<SelectTrigger className="w-[120px]">
|
|
<SelectValue placeholder="状态" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">全部状态</SelectItem>
|
|
<SelectItem value="success">成功</SelectItem>
|
|
<SelectItem value="error">错误</SelectItem>
|
|
<SelectItem value="pending">进行中</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<Select value={timeRange} onValueChange={setTimeRange}>
|
|
<SelectTrigger className="w-[120px]">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="15m">15分钟</SelectItem>
|
|
<SelectItem value="1h">1小时</SelectItem>
|
|
<SelectItem value="6h">6小时</SelectItem>
|
|
<SelectItem value="24h">24小时</SelectItem>
|
|
<SelectItem value="7d">7天</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" />
|
|
刷新
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
) : undefined;
|
|
|
|
return (
|
|
<PageShell
|
|
variant="list"
|
|
title="链路追踪"
|
|
subtitle="分布式链路查询与服务性能分析"
|
|
icon={<GitBranch className="h-5 w-5" />}
|
|
tabs={pageTabs}
|
|
activeTab={activeTab}
|
|
onTabChange={setActiveTab}
|
|
toolbar={tracesToolbar}
|
|
metrics={(
|
|
<StatStrip
|
|
items={[
|
|
{ label: '总请求', value: services.reduce((sum, s) => sum + s.requestCount, 0).toLocaleString(), color: 'blue' },
|
|
{ label: '错误率', value: `${(services.reduce((sum, s) => sum + s.errorRate, 0) / (services.length || 1)).toFixed(2)}%`, color: 'red' },
|
|
{ label: '平均延迟', value: formatDuration(services.reduce((sum, s) => sum + s.avgDuration, 0) / (services.length || 1)), color: 'amber' },
|
|
{ label: '服务数', value: services.length, color: 'purple' },
|
|
]}
|
|
/>
|
|
)}
|
|
actions={activeTab === 'traces' ? undefined : (
|
|
<Button variant="outline" className="dg-btn-secondary" onClick={fetchData}>
|
|
<RefreshCw className="w-4 h-4 mr-2" />
|
|
刷新
|
|
</Button>
|
|
)}
|
|
>
|
|
{activeTab === 'traces' && (
|
|
<Card>
|
|
<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">服务 / 操作</th>
|
|
<th className="px-4 py-3 text-left text-sm font-medium">状态</th>
|
|
<th className="px-4 py-3 text-left text-sm font-medium">耗时</th>
|
|
<th className="px-4 py-3 text-left text-sm font-medium">Span数</th>
|
|
<th className="px-4 py-3 text-left text-sm font-medium">时间</th>
|
|
<th className="px-4 py-3 text-left text-sm font-medium">操作</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y">
|
|
{loading ? (
|
|
<tr>
|
|
<td colSpan={7} className="px-4 py-2"><PageLoading label="加载链路数据..." /></td>
|
|
</tr>
|
|
) : filteredTraces.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={7} className="px-4 py-2"><PageEmpty title="暂无链路数据" 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>
|
|
)}
|
|
|
|
{activeTab === 'services' && (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>服务性能概览</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">服务名称</th>
|
|
<th className="px-4 py-3 text-left text-sm font-medium">请求数</th>
|
|
<th className="px-4 py-3 text-left text-sm font-medium">错误率</th>
|
|
<th className="px-4 py-3 text-left text-sm font-medium">平均延迟</th>
|
|
<th className="px-4 py-3 text-left text-sm font-medium">P95延迟</th>
|
|
<th className="px-4 py-3 text-left text-sm font-medium">P99延迟</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>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{activeTab === 'metrics' && (
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>延迟分布</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="h-[300px] flex items-center justify-center text-muted-foreground">
|
|
<BarChart3 className="w-16 h-16 mr-4" />
|
|
延迟分布图表(集成图表库后显示)
|
|
</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>错误率趋势</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="h-[300px] flex items-center justify-center text-muted-foreground">
|
|
<TrendingUp className="w-16 h-16 mr-4" />
|
|
错误率趋势图表(集成图表库后显示)
|
|
</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" />
|
|
Trace详情
|
|
<span className="font-mono text-sm text-muted-foreground">{selectedTrace?.traceId}</span>
|
|
</DialogTitle>
|
|
<DialogDescription className="sr-only">描述</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">服务</Label>
|
|
<p className="font-medium">{selectedTrace.serviceName}</p>
|
|
</div>
|
|
<Separator orientation="vertical" className="h-8" />
|
|
<div>
|
|
<Label className="text-xs text-muted-foreground">操作</Label>
|
|
<p className="font-medium">{selectedTrace.operationName}</p>
|
|
</div>
|
|
<Separator orientation="vertical" className="h-8" />
|
|
<div>
|
|
<Label className="text-xs text-muted-foreground">总耗时</Label>
|
|
<p className="font-medium">{formatDuration(selectedTrace.duration)}</p>
|
|
</div>
|
|
<Separator orientation="vertical" className="h-8" />
|
|
<div>
|
|
<Label className="text-xs text-muted-foreground">状态</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">Span名称</span>
|
|
<span className="w-32">时间线</span>
|
|
<span className="w-20 text-right">耗时</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详情对话框 */}
|
|
<Dialog open={spanDetailOpen} onOpenChange={setSpanDetailOpen}>
|
|
<DialogContent className="max-w-2xl">
|
|
<DialogHeader>
|
|
<DialogTitle>Span详情</DialogTitle>
|
|
<DialogDescription className="sr-only">描述</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">类型</Label>
|
|
<p className="capitalize">{selectedSpan.kind}</p>
|
|
</div>
|
|
<div>
|
|
<Label className="text-muted-foreground">服务</Label>
|
|
<p className="font-medium">{selectedSpan.serviceName}</p>
|
|
</div>
|
|
<div>
|
|
<Label className="text-muted-foreground">操作</Label>
|
|
<p className="font-medium">{selectedSpan.operationName}</p>
|
|
</div>
|
|
<div>
|
|
<Label className="text-muted-foreground">状态</Label>
|
|
<p>{getStatusBadge(selectedSpan.status)}</p>
|
|
</div>
|
|
<div>
|
|
<Label className="text-muted-foreground">耗时</Label>
|
|
<p className="font-medium">{formatDuration(selectedSpan.duration)}</p>
|
|
</div>
|
|
</div>
|
|
|
|
{Object.keys(selectedSpan.tags).length > 0 && (
|
|
<div>
|
|
<Label className="text-muted-foreground">标签</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">日志</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>
|
|
);
|
|
};
|
|
|
|
export default TraceManager;
|