@kopai/ui 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +2427 -1139
- package/dist/index.d.cts +36 -7
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +36 -7
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +2376 -1082
- package/dist/index.mjs.map +1 -1
- package/package.json +13 -13
- package/src/components/KeyboardShortcuts/KeyboardShortcutsProvider.tsx +7 -7
- package/src/components/observability/DynamicDashboard/DynamicDashboard.test.tsx +5 -0
- package/src/components/observability/LogTimeline/LogFilter.tsx +5 -1
- package/src/components/observability/LogTimeline/index.tsx +6 -2
- package/src/components/observability/MetricHistogram/index.tsx +20 -19
- package/src/components/observability/MetricTimeSeries/index.tsx +8 -9
- package/src/components/observability/ServiceList/shortcuts.ts +1 -1
- package/src/components/observability/TraceComparison/index.tsx +332 -0
- package/src/components/observability/TraceDetail/TraceDetail.stories.tsx +0 -4
- package/src/components/observability/TraceDetail/index.tsx +4 -3
- package/src/components/observability/TraceSearch/DurationBar.tsx +38 -0
- package/src/components/observability/TraceSearch/ScatterPlot.tsx +135 -0
- package/src/components/observability/TraceSearch/SearchForm.tsx +195 -0
- package/src/components/observability/TraceSearch/SortDropdown.tsx +32 -0
- package/src/components/observability/TraceSearch/index.tsx +211 -218
- package/src/components/observability/TraceTimeline/DetailPane/EventsTab.tsx +1 -7
- package/src/components/observability/TraceTimeline/FlamegraphView.tsx +232 -0
- package/src/components/observability/TraceTimeline/GraphView.tsx +322 -0
- package/src/components/observability/TraceTimeline/Minimap.tsx +260 -0
- package/src/components/observability/TraceTimeline/SpanDetailInline.tsx +184 -0
- package/src/components/observability/TraceTimeline/SpanRow.tsx +14 -24
- package/src/components/observability/TraceTimeline/SpanSearch.tsx +76 -0
- package/src/components/observability/TraceTimeline/StatisticsView.tsx +223 -0
- package/src/components/observability/TraceTimeline/TimeRuler.tsx +54 -0
- package/src/components/observability/TraceTimeline/TimelineBar.tsx +18 -2
- package/src/components/observability/TraceTimeline/TraceHeader.tsx +116 -51
- package/src/components/observability/TraceTimeline/ViewTabs.tsx +34 -0
- package/src/components/observability/TraceTimeline/index.tsx +254 -110
- package/src/components/observability/index.ts +15 -0
- package/src/components/observability/renderers/OtelTraceDetail.tsx +1 -4
- package/src/components/observability/shared/TooltipEntryList.tsx +25 -0
- package/src/components/observability/utils/flatten-tree.ts +15 -0
- package/src/components/observability/utils/time.ts +9 -0
- package/src/hooks/use-kopai-data.test.ts +3 -0
- package/src/hooks/use-kopai-data.ts +11 -0
- package/src/hooks/use-live-logs.test.ts +3 -0
- package/src/lib/__snapshots__/generate-prompt-instructions.test.ts.snap +1 -1
- package/src/lib/component-catalog.ts +15 -0
- package/src/pages/observability.test.tsx +5 -0
- package/src/pages/observability.tsx +314 -235
- package/src/providers/kopai-provider.tsx +3 -0
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { useMemo, useState } from "react";
|
|
2
|
+
import type { ParsedTrace, SpanNode } from "../types.js";
|
|
3
|
+
import { formatDuration } from "../utils/time.js";
|
|
4
|
+
import { flattenAllSpans } from "../utils/flatten-tree.js";
|
|
5
|
+
|
|
6
|
+
interface StatisticsViewProps {
|
|
7
|
+
trace: ParsedTrace;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface SpanStats {
|
|
11
|
+
key: string;
|
|
12
|
+
serviceName: string;
|
|
13
|
+
spanName: string;
|
|
14
|
+
count: number;
|
|
15
|
+
totalDuration: number;
|
|
16
|
+
avgDuration: number;
|
|
17
|
+
minDuration: number;
|
|
18
|
+
maxDuration: number;
|
|
19
|
+
selfTimeTotal: number;
|
|
20
|
+
selfTimeAvg: number;
|
|
21
|
+
selfTimeMin: number;
|
|
22
|
+
selfTimeMax: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
type SortField =
|
|
26
|
+
| "name"
|
|
27
|
+
| "count"
|
|
28
|
+
| "total"
|
|
29
|
+
| "avg"
|
|
30
|
+
| "min"
|
|
31
|
+
| "max"
|
|
32
|
+
| "selfTotal"
|
|
33
|
+
| "selfAvg"
|
|
34
|
+
| "selfMin"
|
|
35
|
+
| "selfMax";
|
|
36
|
+
|
|
37
|
+
function computeSelfTime(span: SpanNode): number {
|
|
38
|
+
const childrenTotal = span.children.reduce(
|
|
39
|
+
(sum, child) => sum + child.durationMs,
|
|
40
|
+
0
|
|
41
|
+
);
|
|
42
|
+
return Math.max(0, span.durationMs - childrenTotal);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function computeStats(trace: ParsedTrace): SpanStats[] {
|
|
46
|
+
const allFlattened = flattenAllSpans(trace.rootSpans);
|
|
47
|
+
const groups = new Map<string, { spans: SpanNode[]; selfTimes: number[] }>();
|
|
48
|
+
|
|
49
|
+
for (const { span } of allFlattened) {
|
|
50
|
+
const key = `${span.serviceName}:${span.name}`;
|
|
51
|
+
let group = groups.get(key);
|
|
52
|
+
if (!group) {
|
|
53
|
+
group = { spans: [], selfTimes: [] };
|
|
54
|
+
groups.set(key, group);
|
|
55
|
+
}
|
|
56
|
+
group.spans.push(span);
|
|
57
|
+
group.selfTimes.push(computeSelfTime(span));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const stats: SpanStats[] = [];
|
|
61
|
+
for (const [key, { spans, selfTimes }] of groups) {
|
|
62
|
+
const durations = spans.map((s) => s.durationMs);
|
|
63
|
+
const count = spans.length;
|
|
64
|
+
const totalDuration = durations.reduce((a, b) => a + b, 0);
|
|
65
|
+
const selfTimeTotal = selfTimes.reduce((a, b) => a + b, 0);
|
|
66
|
+
|
|
67
|
+
const firstSpan = spans[0];
|
|
68
|
+
if (!firstSpan) continue;
|
|
69
|
+
|
|
70
|
+
stats.push({
|
|
71
|
+
key,
|
|
72
|
+
serviceName: firstSpan.serviceName,
|
|
73
|
+
spanName: firstSpan.name,
|
|
74
|
+
count,
|
|
75
|
+
totalDuration,
|
|
76
|
+
avgDuration: totalDuration / count,
|
|
77
|
+
minDuration: Math.min(...durations),
|
|
78
|
+
maxDuration: Math.max(...durations),
|
|
79
|
+
selfTimeTotal,
|
|
80
|
+
selfTimeAvg: selfTimeTotal / count,
|
|
81
|
+
selfTimeMin: Math.min(...selfTimes),
|
|
82
|
+
selfTimeMax: Math.max(...selfTimes),
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return stats;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function getSortValue(stat: SpanStats, field: SortField): number | string {
|
|
90
|
+
switch (field) {
|
|
91
|
+
case "name":
|
|
92
|
+
return stat.key.toLowerCase();
|
|
93
|
+
case "count":
|
|
94
|
+
return stat.count;
|
|
95
|
+
case "total":
|
|
96
|
+
return stat.totalDuration;
|
|
97
|
+
case "avg":
|
|
98
|
+
return stat.avgDuration;
|
|
99
|
+
case "min":
|
|
100
|
+
return stat.minDuration;
|
|
101
|
+
case "max":
|
|
102
|
+
return stat.maxDuration;
|
|
103
|
+
case "selfTotal":
|
|
104
|
+
return stat.selfTimeTotal;
|
|
105
|
+
case "selfAvg":
|
|
106
|
+
return stat.selfTimeAvg;
|
|
107
|
+
case "selfMin":
|
|
108
|
+
return stat.selfTimeMin;
|
|
109
|
+
case "selfMax":
|
|
110
|
+
return stat.selfTimeMax;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const COLUMNS: { label: string; field: SortField }[] = [
|
|
115
|
+
{ label: "Name", field: "name" },
|
|
116
|
+
{ label: "Count", field: "count" },
|
|
117
|
+
{ label: "Total", field: "total" },
|
|
118
|
+
{ label: "Avg", field: "avg" },
|
|
119
|
+
{ label: "Min", field: "min" },
|
|
120
|
+
{ label: "Max", field: "max" },
|
|
121
|
+
{ label: "ST Total", field: "selfTotal" },
|
|
122
|
+
{ label: "ST Avg", field: "selfAvg" },
|
|
123
|
+
{ label: "ST Min", field: "selfMin" },
|
|
124
|
+
{ label: "ST Max", field: "selfMax" },
|
|
125
|
+
];
|
|
126
|
+
|
|
127
|
+
export function StatisticsView({ trace }: StatisticsViewProps) {
|
|
128
|
+
const [sortField, setSortField] = useState<SortField>("total");
|
|
129
|
+
const [sortAsc, setSortAsc] = useState(false);
|
|
130
|
+
|
|
131
|
+
const stats = useMemo(() => computeStats(trace), [trace]);
|
|
132
|
+
|
|
133
|
+
const sorted = useMemo(() => {
|
|
134
|
+
const copy = [...stats];
|
|
135
|
+
copy.sort((a, b) => {
|
|
136
|
+
const aVal = getSortValue(a, sortField);
|
|
137
|
+
const bVal = getSortValue(b, sortField);
|
|
138
|
+
let cmp: number;
|
|
139
|
+
if (typeof aVal === "string" && typeof bVal === "string") {
|
|
140
|
+
cmp = aVal.localeCompare(bVal);
|
|
141
|
+
} else if (typeof aVal === "number" && typeof bVal === "number") {
|
|
142
|
+
cmp = aVal - bVal;
|
|
143
|
+
} else {
|
|
144
|
+
cmp = 0;
|
|
145
|
+
}
|
|
146
|
+
return sortAsc ? cmp : -cmp;
|
|
147
|
+
});
|
|
148
|
+
return copy;
|
|
149
|
+
}, [stats, sortField, sortAsc]);
|
|
150
|
+
|
|
151
|
+
const handleSort = (field: SortField) => {
|
|
152
|
+
if (sortField === field) {
|
|
153
|
+
setSortAsc((p) => !p);
|
|
154
|
+
} else {
|
|
155
|
+
setSortField(field);
|
|
156
|
+
setSortAsc(false);
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
return (
|
|
161
|
+
<div className="flex-1 overflow-auto p-2">
|
|
162
|
+
<table className="w-full text-sm border-collapse">
|
|
163
|
+
<thead>
|
|
164
|
+
<tr className="border-b border-border">
|
|
165
|
+
{COLUMNS.map((col) => (
|
|
166
|
+
<th
|
|
167
|
+
key={col.field}
|
|
168
|
+
className="px-3 py-2 text-left text-xs font-medium text-muted-foreground cursor-pointer select-none hover:text-foreground whitespace-nowrap"
|
|
169
|
+
onClick={() => handleSort(col.field)}
|
|
170
|
+
>
|
|
171
|
+
{col.label}{" "}
|
|
172
|
+
{sortField === col.field ? (sortAsc ? "▲" : "▼") : ""}
|
|
173
|
+
</th>
|
|
174
|
+
))}
|
|
175
|
+
</tr>
|
|
176
|
+
</thead>
|
|
177
|
+
<tbody>
|
|
178
|
+
{sorted.map((stat, i) => (
|
|
179
|
+
<tr
|
|
180
|
+
key={stat.key}
|
|
181
|
+
className={`border-b border-border/50 ${i % 2 === 0 ? "bg-background" : "bg-muted/30"}`}
|
|
182
|
+
>
|
|
183
|
+
<td className="px-3 py-1.5 text-foreground font-mono text-xs whitespace-nowrap">
|
|
184
|
+
<span className="text-muted-foreground">
|
|
185
|
+
{stat.serviceName}
|
|
186
|
+
</span>
|
|
187
|
+
<span className="text-muted-foreground/50">:</span>{" "}
|
|
188
|
+
{stat.spanName}
|
|
189
|
+
</td>
|
|
190
|
+
<td className="px-3 py-1.5 text-foreground tabular-nums">
|
|
191
|
+
{stat.count}
|
|
192
|
+
</td>
|
|
193
|
+
<td className="px-3 py-1.5 text-foreground tabular-nums">
|
|
194
|
+
{formatDuration(stat.totalDuration)}
|
|
195
|
+
</td>
|
|
196
|
+
<td className="px-3 py-1.5 text-foreground tabular-nums">
|
|
197
|
+
{formatDuration(stat.avgDuration)}
|
|
198
|
+
</td>
|
|
199
|
+
<td className="px-3 py-1.5 text-foreground tabular-nums">
|
|
200
|
+
{formatDuration(stat.minDuration)}
|
|
201
|
+
</td>
|
|
202
|
+
<td className="px-3 py-1.5 text-foreground tabular-nums">
|
|
203
|
+
{formatDuration(stat.maxDuration)}
|
|
204
|
+
</td>
|
|
205
|
+
<td className="px-3 py-1.5 text-foreground tabular-nums">
|
|
206
|
+
{formatDuration(stat.selfTimeTotal)}
|
|
207
|
+
</td>
|
|
208
|
+
<td className="px-3 py-1.5 text-foreground tabular-nums">
|
|
209
|
+
{formatDuration(stat.selfTimeAvg)}
|
|
210
|
+
</td>
|
|
211
|
+
<td className="px-3 py-1.5 text-foreground tabular-nums">
|
|
212
|
+
{formatDuration(stat.selfTimeMin)}
|
|
213
|
+
</td>
|
|
214
|
+
<td className="px-3 py-1.5 text-foreground tabular-nums">
|
|
215
|
+
{formatDuration(stat.selfTimeMax)}
|
|
216
|
+
</td>
|
|
217
|
+
</tr>
|
|
218
|
+
))}
|
|
219
|
+
</tbody>
|
|
220
|
+
</table>
|
|
221
|
+
</div>
|
|
222
|
+
);
|
|
223
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { formatDuration } from "../utils/time.js";
|
|
2
|
+
|
|
3
|
+
export interface TimeRulerProps {
|
|
4
|
+
totalDurationMs: number;
|
|
5
|
+
leftColumnWidth: string;
|
|
6
|
+
offsetMs?: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const TICK_COUNT = 5;
|
|
10
|
+
|
|
11
|
+
export function TimeRuler({
|
|
12
|
+
totalDurationMs,
|
|
13
|
+
leftColumnWidth,
|
|
14
|
+
offsetMs = 0,
|
|
15
|
+
}: TimeRulerProps) {
|
|
16
|
+
const ticks = Array.from({ length: TICK_COUNT + 1 }, (_, i) => {
|
|
17
|
+
const fraction = i / TICK_COUNT;
|
|
18
|
+
return {
|
|
19
|
+
label: formatDuration(offsetMs + totalDurationMs * fraction),
|
|
20
|
+
percent: fraction * 100,
|
|
21
|
+
};
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<div className="flex border-b border-border bg-background">
|
|
26
|
+
<div className="flex-shrink-0" style={{ width: leftColumnWidth }} />
|
|
27
|
+
<div className="flex-1 relative h-6 px-2">
|
|
28
|
+
{ticks.map((tick) => (
|
|
29
|
+
<div
|
|
30
|
+
key={tick.percent}
|
|
31
|
+
className="absolute top-0 h-full flex flex-col justify-end"
|
|
32
|
+
style={{ left: `${tick.percent}%` }}
|
|
33
|
+
>
|
|
34
|
+
<div className="h-2 border-l border-muted-foreground/40" />
|
|
35
|
+
<span
|
|
36
|
+
className="text-[10px] text-muted-foreground font-mono -translate-x-1/2 absolute bottom-0 whitespace-nowrap"
|
|
37
|
+
style={{
|
|
38
|
+
left: 0,
|
|
39
|
+
transform:
|
|
40
|
+
tick.percent === 100
|
|
41
|
+
? "translateX(-100%)"
|
|
42
|
+
: tick.percent === 0
|
|
43
|
+
? "none"
|
|
44
|
+
: "translateX(-50%)",
|
|
45
|
+
}}
|
|
46
|
+
>
|
|
47
|
+
{tick.label}
|
|
48
|
+
</span>
|
|
49
|
+
</div>
|
|
50
|
+
))}
|
|
51
|
+
</div>
|
|
52
|
+
</div>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
@@ -19,21 +19,37 @@ export function TimelineBar({
|
|
|
19
19
|
|
|
20
20
|
const leftPercent = relativeStart * 100;
|
|
21
21
|
const widthPercent = Math.max(0.2, relativeDuration * 100);
|
|
22
|
+
const isWide = widthPercent > 8;
|
|
22
23
|
|
|
23
24
|
const tooltipText = `${span.name}\n${formatDuration(span.durationMs)}\nStatus: ${isError ? "ERROR" : "OK"}`;
|
|
25
|
+
const durationLabel = formatDuration(span.durationMs);
|
|
24
26
|
|
|
25
27
|
return (
|
|
26
28
|
<div className="relative h-full">
|
|
27
29
|
<Tooltip content={tooltipText}>
|
|
28
30
|
<div className="absolute inset-0">
|
|
29
31
|
<div
|
|
30
|
-
className="absolute top-1/2 -translate-y-1/2 h-2 rounded-sm cursor-pointer hover:opacity-80 transition-opacity"
|
|
32
|
+
className="absolute top-1/2 -translate-y-1/2 h-2 rounded-sm cursor-pointer hover:opacity-80 transition-opacity flex items-center"
|
|
31
33
|
style={{
|
|
32
34
|
left: `${leftPercent}%`,
|
|
33
35
|
width: `max(2px, ${widthPercent}%)`,
|
|
34
36
|
backgroundColor: barColor,
|
|
35
37
|
}}
|
|
36
|
-
|
|
38
|
+
>
|
|
39
|
+
{isWide && (
|
|
40
|
+
<span className="text-[10px] font-mono text-white px-1 truncate">
|
|
41
|
+
{durationLabel}
|
|
42
|
+
</span>
|
|
43
|
+
)}
|
|
44
|
+
</div>
|
|
45
|
+
{!isWide && (
|
|
46
|
+
<span
|
|
47
|
+
className="absolute top-1/2 -translate-y-1/2 text-[10px] font-mono text-muted-foreground whitespace-nowrap"
|
|
48
|
+
style={{ left: `calc(${leftPercent + widthPercent}% + 4px)` }}
|
|
49
|
+
>
|
|
50
|
+
{durationLabel}
|
|
51
|
+
</span>
|
|
52
|
+
)}
|
|
37
53
|
</div>
|
|
38
54
|
</Tooltip>
|
|
39
55
|
</div>
|
|
@@ -1,18 +1,40 @@
|
|
|
1
1
|
import { useState } from "react";
|
|
2
2
|
import type { ParsedTrace } from "../types.js";
|
|
3
3
|
import { formatDuration, formatTimestamp } from "../utils/time.js";
|
|
4
|
+
import { getServiceColor } from "../utils/colors.js";
|
|
4
5
|
|
|
5
6
|
export interface TraceHeaderProps {
|
|
6
7
|
trace: ParsedTrace;
|
|
8
|
+
services?: string[];
|
|
9
|
+
onHeaderToggle?: () => void;
|
|
10
|
+
isCollapsed?: boolean;
|
|
7
11
|
}
|
|
8
12
|
|
|
9
|
-
|
|
13
|
+
function computeMaxDepth(spans: ParsedTrace["rootSpans"]): number {
|
|
14
|
+
let max = 0;
|
|
15
|
+
function walk(nodes: ParsedTrace["rootSpans"], depth: number) {
|
|
16
|
+
for (const node of nodes) {
|
|
17
|
+
if (depth > max) max = depth;
|
|
18
|
+
walk(node.children, depth + 1);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
walk(spans, 1);
|
|
22
|
+
return max;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function TraceHeader({
|
|
26
|
+
trace,
|
|
27
|
+
services = [],
|
|
28
|
+
onHeaderToggle,
|
|
29
|
+
isCollapsed = false,
|
|
30
|
+
}: TraceHeaderProps) {
|
|
10
31
|
const [copied, setCopied] = useState(false);
|
|
11
32
|
|
|
12
33
|
const rootSpan = trace.rootSpans[0];
|
|
13
34
|
const rootServiceName = rootSpan?.serviceName ?? "unknown";
|
|
14
35
|
const rootSpanName = rootSpan?.name ?? "unknown";
|
|
15
36
|
const totalDuration = trace.maxTimeMs - trace.minTimeMs;
|
|
37
|
+
const maxDepth = computeMaxDepth(trace.rootSpans);
|
|
16
38
|
|
|
17
39
|
const handleCopyTraceId = async () => {
|
|
18
40
|
try {
|
|
@@ -26,63 +48,106 @@ export function TraceHeader({ trace }: TraceHeaderProps) {
|
|
|
26
48
|
|
|
27
49
|
return (
|
|
28
50
|
<div className="bg-background border-b border-border px-4 py-3">
|
|
29
|
-
<div className="flex items-center gap-
|
|
30
|
-
|
|
31
|
-
<span className="text-xs font-semibold text-muted-foreground">
|
|
32
|
-
Trace ID:
|
|
33
|
-
</span>
|
|
51
|
+
<div className="flex items-center gap-2 mb-1">
|
|
52
|
+
{onHeaderToggle && (
|
|
34
53
|
<button
|
|
35
|
-
onClick={
|
|
36
|
-
className="
|
|
37
|
-
|
|
54
|
+
onClick={onHeaderToggle}
|
|
55
|
+
className="p-0.5 text-muted-foreground hover:text-foreground"
|
|
56
|
+
aria-label={isCollapsed ? "Expand header" : "Collapse header"}
|
|
38
57
|
>
|
|
39
|
-
|
|
58
|
+
<svg
|
|
59
|
+
className={`w-4 h-4 transition-transform ${isCollapsed ? "-rotate-90" : ""}`}
|
|
60
|
+
fill="none"
|
|
61
|
+
stroke="currentColor"
|
|
62
|
+
viewBox="0 0 24 24"
|
|
63
|
+
>
|
|
64
|
+
<path
|
|
65
|
+
strokeLinecap="round"
|
|
66
|
+
strokeLinejoin="round"
|
|
67
|
+
strokeWidth={2}
|
|
68
|
+
d="M19 9l-7 7-7-7"
|
|
69
|
+
/>
|
|
70
|
+
</svg>
|
|
40
71
|
</button>
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
</div>
|
|
72
|
+
)}
|
|
73
|
+
<span className="text-sm font-semibold text-foreground">
|
|
74
|
+
{rootServiceName}: {rootSpanName}
|
|
75
|
+
</span>
|
|
76
|
+
</div>
|
|
47
77
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
78
|
+
{!isCollapsed && (
|
|
79
|
+
<>
|
|
80
|
+
<div className="flex items-center gap-6 flex-wrap">
|
|
81
|
+
<div className="flex items-center gap-2">
|
|
82
|
+
<span className="text-xs font-semibold text-muted-foreground">
|
|
83
|
+
Trace ID:
|
|
84
|
+
</span>
|
|
85
|
+
<button
|
|
86
|
+
onClick={handleCopyTraceId}
|
|
87
|
+
className="text-sm font-mono bg-muted px-2 py-1 rounded hover:bg-muted/80 transition-colors text-foreground"
|
|
88
|
+
title="Click to copy"
|
|
89
|
+
>
|
|
90
|
+
{trace.traceId.slice(0, 16)}...
|
|
91
|
+
</button>
|
|
92
|
+
{copied && (
|
|
93
|
+
<span className="text-xs text-green-600 dark:text-green-400 font-medium">
|
|
94
|
+
Copied!
|
|
95
|
+
</span>
|
|
96
|
+
)}
|
|
97
|
+
</div>
|
|
58
98
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
99
|
+
<div className="flex items-center gap-2">
|
|
100
|
+
<span className="text-xs font-semibold text-muted-foreground">
|
|
101
|
+
Duration:
|
|
102
|
+
</span>
|
|
103
|
+
<span className="text-sm font-medium text-foreground">
|
|
104
|
+
{formatDuration(totalDuration)}
|
|
105
|
+
</span>
|
|
106
|
+
</div>
|
|
67
107
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
108
|
+
<div className="flex items-center gap-2">
|
|
109
|
+
<span className="text-xs font-semibold text-muted-foreground">
|
|
110
|
+
Spans:
|
|
111
|
+
</span>
|
|
112
|
+
<span className="text-sm font-medium text-foreground">
|
|
113
|
+
{trace.totalSpanCount}
|
|
114
|
+
</span>
|
|
115
|
+
</div>
|
|
76
116
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
117
|
+
<div className="flex items-center gap-2">
|
|
118
|
+
<span className="text-xs font-semibold text-muted-foreground">
|
|
119
|
+
Depth:
|
|
120
|
+
</span>
|
|
121
|
+
<span className="text-sm font-medium text-foreground">
|
|
122
|
+
{maxDepth}
|
|
123
|
+
</span>
|
|
124
|
+
</div>
|
|
125
|
+
|
|
126
|
+
<div className="flex items-center gap-2">
|
|
127
|
+
<span className="text-xs font-semibold text-muted-foreground">
|
|
128
|
+
Started:
|
|
129
|
+
</span>
|
|
130
|
+
<span className="text-sm text-foreground">
|
|
131
|
+
{formatTimestamp(trace.minTimeMs)}
|
|
132
|
+
</span>
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
135
|
+
|
|
136
|
+
{services.length > 0 && (
|
|
137
|
+
<div className="flex items-center gap-3 mt-2 flex-wrap">
|
|
138
|
+
{services.map((svc) => (
|
|
139
|
+
<div key={svc} className="flex items-center gap-1.5">
|
|
140
|
+
<span
|
|
141
|
+
className="w-2.5 h-2.5 rounded-full flex-shrink-0"
|
|
142
|
+
style={{ backgroundColor: getServiceColor(svc) }}
|
|
143
|
+
/>
|
|
144
|
+
<span className="text-xs text-muted-foreground">{svc}</span>
|
|
145
|
+
</div>
|
|
146
|
+
))}
|
|
147
|
+
</div>
|
|
148
|
+
)}
|
|
149
|
+
</>
|
|
150
|
+
)}
|
|
86
151
|
</div>
|
|
87
152
|
);
|
|
88
153
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export const VIEWS = ["timeline", "graph", "statistics", "flamegraph"] as const;
|
|
2
|
+
export type ViewName = (typeof VIEWS)[number];
|
|
3
|
+
|
|
4
|
+
export interface ViewTabsProps {
|
|
5
|
+
activeView: ViewName;
|
|
6
|
+
onChange: (view: ViewName) => void;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const VIEW_LABELS: Record<string, string> = {
|
|
10
|
+
timeline: "Timeline",
|
|
11
|
+
graph: "Graph",
|
|
12
|
+
statistics: "Statistics",
|
|
13
|
+
flamegraph: "Flamegraph",
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function ViewTabs({ activeView, onChange }: ViewTabsProps) {
|
|
17
|
+
return (
|
|
18
|
+
<div className="flex border-b border-border bg-background">
|
|
19
|
+
{VIEWS.map((view) => (
|
|
20
|
+
<button
|
|
21
|
+
key={view}
|
|
22
|
+
onClick={() => onChange(view)}
|
|
23
|
+
className={`px-4 py-1.5 text-sm font-medium transition-colors ${
|
|
24
|
+
activeView === view
|
|
25
|
+
? "text-foreground border-b-2 border-blue-500"
|
|
26
|
+
: "text-muted-foreground hover:text-foreground"
|
|
27
|
+
}`}
|
|
28
|
+
>
|
|
29
|
+
{VIEW_LABELS[view]}
|
|
30
|
+
</button>
|
|
31
|
+
))}
|
|
32
|
+
</div>
|
|
33
|
+
);
|
|
34
|
+
}
|