@kopai/ui 0.7.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 +2451 -1157
- 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 +2399 -1099
- 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 +25 -14
- 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/OtelLogTimeline.tsx +9 -5
- 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 +4 -0
- package/src/hooks/use-kopai-data.ts +11 -0
- package/src/hooks/use-live-logs.test.ts +4 -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 +16 -12
- package/src/pages/observability.tsx +323 -245
- package/src/providers/kopai-provider.tsx +4 -0
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimap - Compressed overview of all spans with a draggable viewport.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { useRef, useCallback, useMemo, useEffect } from "react";
|
|
6
|
+
import type { ParsedTrace } from "../types.js";
|
|
7
|
+
import { getSpanBarColor } from "../utils/colors.js";
|
|
8
|
+
import { flattenAllSpans } from "../utils/flatten-tree.js";
|
|
9
|
+
|
|
10
|
+
export interface MinimapProps {
|
|
11
|
+
trace: ParsedTrace;
|
|
12
|
+
viewStart: number; // 0-1 fraction
|
|
13
|
+
viewEnd: number; // 0-1 fraction
|
|
14
|
+
onViewChange: (viewStart: number, viewEnd: number) => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const MINIMAP_HEIGHT = 40;
|
|
18
|
+
const SPAN_HEIGHT = 2;
|
|
19
|
+
const SPAN_GAP = 1;
|
|
20
|
+
const MIN_VIEWPORT_WIDTH = 0.02;
|
|
21
|
+
const HANDLE_WIDTH = 6;
|
|
22
|
+
|
|
23
|
+
type DragMode = "pan" | "resize-left" | "resize-right" | null;
|
|
24
|
+
|
|
25
|
+
export function Minimap({
|
|
26
|
+
trace,
|
|
27
|
+
viewStart,
|
|
28
|
+
viewEnd,
|
|
29
|
+
onViewChange,
|
|
30
|
+
}: MinimapProps) {
|
|
31
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
32
|
+
const dragRef = useRef<{
|
|
33
|
+
mode: DragMode;
|
|
34
|
+
startX: number;
|
|
35
|
+
origViewStart: number;
|
|
36
|
+
origViewEnd: number;
|
|
37
|
+
} | null>(null);
|
|
38
|
+
const cleanupRef = useRef<(() => void) | null>(null);
|
|
39
|
+
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
return () => {
|
|
42
|
+
cleanupRef.current?.();
|
|
43
|
+
};
|
|
44
|
+
}, []);
|
|
45
|
+
|
|
46
|
+
const allSpans = useMemo(
|
|
47
|
+
() => flattenAllSpans(trace.rootSpans),
|
|
48
|
+
[trace.rootSpans]
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
const traceDuration = trace.maxTimeMs - trace.minTimeMs;
|
|
52
|
+
|
|
53
|
+
const getFraction = useCallback((clientX: number): number => {
|
|
54
|
+
const el = containerRef.current;
|
|
55
|
+
if (!el) return 0;
|
|
56
|
+
const rect = el.getBoundingClientRect();
|
|
57
|
+
if (!rect.width) return 0;
|
|
58
|
+
return Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
|
|
59
|
+
}, []);
|
|
60
|
+
|
|
61
|
+
const clampView = useCallback(
|
|
62
|
+
(start: number, end: number): [number, number] => {
|
|
63
|
+
let s = Math.max(0, Math.min(1 - MIN_VIEWPORT_WIDTH, start));
|
|
64
|
+
let e = Math.max(s + MIN_VIEWPORT_WIDTH, Math.min(1, end));
|
|
65
|
+
if (e > 1) {
|
|
66
|
+
e = 1;
|
|
67
|
+
s = Math.max(0, e - Math.max(MIN_VIEWPORT_WIDTH, end - start));
|
|
68
|
+
}
|
|
69
|
+
return [s, e];
|
|
70
|
+
},
|
|
71
|
+
[]
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
const handleMouseDown = useCallback(
|
|
75
|
+
(e: React.MouseEvent, mode: DragMode) => {
|
|
76
|
+
e.preventDefault();
|
|
77
|
+
e.stopPropagation();
|
|
78
|
+
cleanupRef.current?.();
|
|
79
|
+
dragRef.current = {
|
|
80
|
+
mode,
|
|
81
|
+
startX: e.clientX,
|
|
82
|
+
origViewStart: viewStart,
|
|
83
|
+
origViewEnd: viewEnd,
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const handleMouseMove = (ev: MouseEvent) => {
|
|
87
|
+
const drag = dragRef.current;
|
|
88
|
+
if (!drag || !containerRef.current) return;
|
|
89
|
+
|
|
90
|
+
const rect = containerRef.current.getBoundingClientRect();
|
|
91
|
+
if (!rect.width) return;
|
|
92
|
+
const deltaFrac = (ev.clientX - drag.startX) / rect.width;
|
|
93
|
+
|
|
94
|
+
let newStart: number;
|
|
95
|
+
let newEnd: number;
|
|
96
|
+
|
|
97
|
+
if (drag.mode === "pan") {
|
|
98
|
+
const width = drag.origViewEnd - drag.origViewStart;
|
|
99
|
+
newStart = drag.origViewStart + deltaFrac;
|
|
100
|
+
newEnd = newStart + width;
|
|
101
|
+
if (newStart < 0) {
|
|
102
|
+
newStart = 0;
|
|
103
|
+
newEnd = width;
|
|
104
|
+
}
|
|
105
|
+
if (newEnd > 1) {
|
|
106
|
+
newEnd = 1;
|
|
107
|
+
newStart = 1 - width;
|
|
108
|
+
}
|
|
109
|
+
} else if (drag.mode === "resize-left") {
|
|
110
|
+
newStart = drag.origViewStart + deltaFrac;
|
|
111
|
+
newEnd = drag.origViewEnd;
|
|
112
|
+
} else {
|
|
113
|
+
newStart = drag.origViewStart;
|
|
114
|
+
newEnd = drag.origViewEnd + deltaFrac;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const [s, e] = clampView(newStart, newEnd);
|
|
118
|
+
onViewChange(s, e);
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const handleMouseUp = () => {
|
|
122
|
+
dragRef.current = null;
|
|
123
|
+
cleanupRef.current = null;
|
|
124
|
+
window.removeEventListener("mousemove", handleMouseMove);
|
|
125
|
+
window.removeEventListener("mouseup", handleMouseUp);
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
window.addEventListener("mousemove", handleMouseMove);
|
|
129
|
+
window.addEventListener("mouseup", handleMouseUp);
|
|
130
|
+
cleanupRef.current = handleMouseUp;
|
|
131
|
+
},
|
|
132
|
+
[viewStart, viewEnd, onViewChange, clampView]
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
const handleBackgroundClick = useCallback(
|
|
136
|
+
(e: React.MouseEvent) => {
|
|
137
|
+
if (dragRef.current) return;
|
|
138
|
+
if (e.target !== e.currentTarget) return;
|
|
139
|
+
const frac = getFraction(e.clientX);
|
|
140
|
+
const width = viewEnd - viewStart;
|
|
141
|
+
const half = width / 2;
|
|
142
|
+
const [s, eVal] = clampView(frac - half, frac + half);
|
|
143
|
+
onViewChange(s, eVal);
|
|
144
|
+
},
|
|
145
|
+
[viewStart, viewEnd, onViewChange, getFraction, clampView]
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
const handleKeyDown = useCallback(
|
|
149
|
+
(e: React.KeyboardEvent) => {
|
|
150
|
+
const step = 0.05;
|
|
151
|
+
const width = viewEnd - viewStart;
|
|
152
|
+
let newStart: number;
|
|
153
|
+
if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
|
|
154
|
+
newStart = viewStart - step;
|
|
155
|
+
} else if (e.key === "ArrowRight" || e.key === "ArrowDown") {
|
|
156
|
+
newStart = viewStart + step;
|
|
157
|
+
} else {
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
e.preventDefault();
|
|
161
|
+
const [s, eVal] = clampView(newStart, newStart + width);
|
|
162
|
+
onViewChange(s, eVal);
|
|
163
|
+
},
|
|
164
|
+
[viewStart, viewEnd, onViewChange, clampView]
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
const viewStartPct = viewStart * 100;
|
|
168
|
+
const viewEndPct = viewEnd * 100;
|
|
169
|
+
const viewWidthPct = viewEndPct - viewStartPct;
|
|
170
|
+
|
|
171
|
+
// Calculate span row height to fit within minimap
|
|
172
|
+
const totalRows = allSpans.length;
|
|
173
|
+
const availableHeight = MINIMAP_HEIGHT - 4; // 2px padding top/bottom
|
|
174
|
+
const rowHeight =
|
|
175
|
+
totalRows > 0
|
|
176
|
+
? Math.min(SPAN_HEIGHT + SPAN_GAP, availableHeight / totalRows)
|
|
177
|
+
: SPAN_HEIGHT;
|
|
178
|
+
|
|
179
|
+
return (
|
|
180
|
+
<div
|
|
181
|
+
ref={containerRef}
|
|
182
|
+
className="relative w-full border-b border-border bg-muted/30 select-none"
|
|
183
|
+
style={{ height: MINIMAP_HEIGHT }}
|
|
184
|
+
onClick={handleBackgroundClick}
|
|
185
|
+
onKeyDown={handleKeyDown}
|
|
186
|
+
role="slider"
|
|
187
|
+
tabIndex={0}
|
|
188
|
+
aria-label="Trace minimap viewport"
|
|
189
|
+
aria-valuemin={0}
|
|
190
|
+
aria-valuemax={100}
|
|
191
|
+
aria-valuenow={Math.round(viewStartPct)}
|
|
192
|
+
>
|
|
193
|
+
{/* Span bars */}
|
|
194
|
+
{traceDuration > 0 &&
|
|
195
|
+
allSpans.map(({ span }, i) => {
|
|
196
|
+
const left =
|
|
197
|
+
((span.startTimeUnixMs - trace.minTimeMs) / traceDuration) * 100;
|
|
198
|
+
const width = Math.max(0.2, (span.durationMs / traceDuration) * 100);
|
|
199
|
+
const color = getSpanBarColor(
|
|
200
|
+
span.serviceName,
|
|
201
|
+
span.status === "ERROR"
|
|
202
|
+
);
|
|
203
|
+
return (
|
|
204
|
+
<div
|
|
205
|
+
key={span.spanId}
|
|
206
|
+
className="absolute pointer-events-none"
|
|
207
|
+
style={{
|
|
208
|
+
left: `${left}%`,
|
|
209
|
+
width: `${width}%`,
|
|
210
|
+
top: 2 + i * rowHeight,
|
|
211
|
+
height: Math.max(1, rowHeight - SPAN_GAP),
|
|
212
|
+
backgroundColor: color,
|
|
213
|
+
opacity: 0.8,
|
|
214
|
+
borderRadius: 1,
|
|
215
|
+
}}
|
|
216
|
+
/>
|
|
217
|
+
);
|
|
218
|
+
})}
|
|
219
|
+
|
|
220
|
+
{/* Left overlay (outside viewport) */}
|
|
221
|
+
{viewStartPct > 0 && (
|
|
222
|
+
<div
|
|
223
|
+
className="absolute top-0 left-0 h-full bg-black/30 pointer-events-none"
|
|
224
|
+
style={{ width: `${viewStartPct}%` }}
|
|
225
|
+
/>
|
|
226
|
+
)}
|
|
227
|
+
|
|
228
|
+
{/* Right overlay (outside viewport) */}
|
|
229
|
+
{viewEndPct < 100 && (
|
|
230
|
+
<div
|
|
231
|
+
className="absolute top-0 h-full bg-black/30 pointer-events-none"
|
|
232
|
+
style={{ left: `${viewEndPct}%`, right: 0 }}
|
|
233
|
+
/>
|
|
234
|
+
)}
|
|
235
|
+
|
|
236
|
+
{/* Viewport rectangle */}
|
|
237
|
+
<div
|
|
238
|
+
className="absolute top-0 h-full border border-blue-500/50 bg-blue-500/10 cursor-grab active:cursor-grabbing"
|
|
239
|
+
style={{
|
|
240
|
+
left: `${viewStartPct}%`,
|
|
241
|
+
width: `${viewWidthPct}%`,
|
|
242
|
+
}}
|
|
243
|
+
onMouseDown={(e) => handleMouseDown(e, "pan")}
|
|
244
|
+
>
|
|
245
|
+
{/* Left resize handle */}
|
|
246
|
+
<div
|
|
247
|
+
className="absolute top-0 left-0 h-full cursor-ew-resize z-10"
|
|
248
|
+
style={{ width: HANDLE_WIDTH, marginLeft: -HANDLE_WIDTH / 2 }}
|
|
249
|
+
onMouseDown={(e) => handleMouseDown(e, "resize-left")}
|
|
250
|
+
/>
|
|
251
|
+
{/* Right resize handle */}
|
|
252
|
+
<div
|
|
253
|
+
className="absolute top-0 right-0 h-full cursor-ew-resize z-10"
|
|
254
|
+
style={{ width: HANDLE_WIDTH, marginRight: -HANDLE_WIDTH / 2 }}
|
|
255
|
+
onMouseDown={(e) => handleMouseDown(e, "resize-right")}
|
|
256
|
+
/>
|
|
257
|
+
</div>
|
|
258
|
+
</div>
|
|
259
|
+
);
|
|
260
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { useState, useCallback } from "react";
|
|
2
|
+
import type { SpanNode } from "../types.js";
|
|
3
|
+
import { formatDuration, formatRelativeTime } from "../utils/time.js";
|
|
4
|
+
import { formatAttributeValue } from "../utils/attributes.js";
|
|
5
|
+
import { getServiceColor } from "../utils/colors.js";
|
|
6
|
+
|
|
7
|
+
interface SpanDetailInlineProps {
|
|
8
|
+
span: SpanNode;
|
|
9
|
+
traceStartMs: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface CollapsibleSectionProps {
|
|
13
|
+
title: string;
|
|
14
|
+
count: number;
|
|
15
|
+
children: React.ReactNode;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function CollapsibleSection({
|
|
19
|
+
title,
|
|
20
|
+
count,
|
|
21
|
+
children,
|
|
22
|
+
}: CollapsibleSectionProps) {
|
|
23
|
+
const [open, setOpen] = useState(false);
|
|
24
|
+
|
|
25
|
+
if (count === 0) return null;
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<div>
|
|
29
|
+
<button
|
|
30
|
+
className="flex items-center gap-1 text-xs font-medium text-foreground hover:text-blue-600 dark:hover:text-blue-400 py-1"
|
|
31
|
+
onClick={(e) => {
|
|
32
|
+
e.stopPropagation();
|
|
33
|
+
setOpen((p) => !p);
|
|
34
|
+
}}
|
|
35
|
+
>
|
|
36
|
+
<span className="w-3 text-center">{open ? "▾" : "▸"}</span>
|
|
37
|
+
{title}
|
|
38
|
+
<span className="text-muted-foreground">({count})</span>
|
|
39
|
+
</button>
|
|
40
|
+
{open && <div className="ml-4 mt-1 space-y-1">{children}</div>}
|
|
41
|
+
</div>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function KeyValueRow({ k, v }: { k: string; v: unknown }) {
|
|
46
|
+
const formatted = formatAttributeValue(v);
|
|
47
|
+
return (
|
|
48
|
+
<div className="flex gap-2 text-xs font-mono py-0.5">
|
|
49
|
+
<span className="text-muted-foreground flex-shrink-0">{k}</span>
|
|
50
|
+
<span className="text-foreground">=</span>
|
|
51
|
+
<span className="text-foreground break-all">{formatted}</span>
|
|
52
|
+
</div>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function SpanDetailInline({
|
|
57
|
+
span,
|
|
58
|
+
traceStartMs,
|
|
59
|
+
}: SpanDetailInlineProps) {
|
|
60
|
+
const [copiedId, setCopiedId] = useState(false);
|
|
61
|
+
const serviceColor = getServiceColor(span.serviceName);
|
|
62
|
+
const relativeStartMs = span.startTimeUnixMs - traceStartMs;
|
|
63
|
+
|
|
64
|
+
const handleCopy = useCallback(async () => {
|
|
65
|
+
try {
|
|
66
|
+
await navigator.clipboard.writeText(span.spanId);
|
|
67
|
+
setCopiedId(true);
|
|
68
|
+
setTimeout(() => setCopiedId(false), 2000);
|
|
69
|
+
} catch {
|
|
70
|
+
/* clipboard unavailable */
|
|
71
|
+
}
|
|
72
|
+
}, [span.spanId]);
|
|
73
|
+
|
|
74
|
+
const spanAttrs = Object.entries(span.attributes).sort(([a], [b]) =>
|
|
75
|
+
a.localeCompare(b)
|
|
76
|
+
);
|
|
77
|
+
const resourceAttrs = Object.entries(span.resourceAttributes).sort(
|
|
78
|
+
([a], [b]) => a.localeCompare(b)
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<div
|
|
83
|
+
className="border-b border-border bg-muted/50 px-4 py-3"
|
|
84
|
+
style={{ borderLeft: `3px solid ${serviceColor}` }}
|
|
85
|
+
onClick={(e) => e.stopPropagation()}
|
|
86
|
+
>
|
|
87
|
+
{/* Header */}
|
|
88
|
+
<div className="mb-2">
|
|
89
|
+
<div className="text-sm font-medium text-foreground">{span.name}</div>
|
|
90
|
+
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground mt-1">
|
|
91
|
+
<span>
|
|
92
|
+
Service: <span className="text-foreground">{span.serviceName}</span>
|
|
93
|
+
</span>
|
|
94
|
+
<span>
|
|
95
|
+
Duration:{" "}
|
|
96
|
+
<span className="text-foreground">
|
|
97
|
+
{formatDuration(span.durationMs)}
|
|
98
|
+
</span>
|
|
99
|
+
</span>
|
|
100
|
+
<span>
|
|
101
|
+
Start:{" "}
|
|
102
|
+
<span className="text-foreground">
|
|
103
|
+
{formatDuration(relativeStartMs)}
|
|
104
|
+
</span>
|
|
105
|
+
</span>
|
|
106
|
+
<span>
|
|
107
|
+
Kind: <span className="text-foreground">{span.kind}</span>
|
|
108
|
+
</span>
|
|
109
|
+
{span.status !== "UNSET" && (
|
|
110
|
+
<span>
|
|
111
|
+
Status:{" "}
|
|
112
|
+
<span
|
|
113
|
+
className={
|
|
114
|
+
span.status === "ERROR" ? "text-red-500" : "text-foreground"
|
|
115
|
+
}
|
|
116
|
+
>
|
|
117
|
+
{span.status}
|
|
118
|
+
</span>
|
|
119
|
+
</span>
|
|
120
|
+
)}
|
|
121
|
+
</div>
|
|
122
|
+
</div>
|
|
123
|
+
|
|
124
|
+
{/* Collapsible sections */}
|
|
125
|
+
<div className="space-y-1">
|
|
126
|
+
<CollapsibleSection title="Tags" count={spanAttrs.length}>
|
|
127
|
+
{spanAttrs.map(([k, v]) => (
|
|
128
|
+
<KeyValueRow key={k} k={k} v={v} />
|
|
129
|
+
))}
|
|
130
|
+
</CollapsibleSection>
|
|
131
|
+
|
|
132
|
+
<CollapsibleSection title="Process" count={resourceAttrs.length}>
|
|
133
|
+
{resourceAttrs.map(([k, v]) => (
|
|
134
|
+
<KeyValueRow key={k} k={k} v={v} />
|
|
135
|
+
))}
|
|
136
|
+
</CollapsibleSection>
|
|
137
|
+
|
|
138
|
+
<CollapsibleSection title="Events" count={span.events.length}>
|
|
139
|
+
{span.events.map((event, i) => (
|
|
140
|
+
<div
|
|
141
|
+
key={i}
|
|
142
|
+
className="text-xs border-l-2 border-border pl-2 py-1.5 space-y-0.5"
|
|
143
|
+
>
|
|
144
|
+
<div className="flex items-center gap-2">
|
|
145
|
+
<span className="font-mono text-muted-foreground flex-shrink-0">
|
|
146
|
+
{formatRelativeTime(event.timeUnixMs, span.startTimeUnixMs)}
|
|
147
|
+
</span>
|
|
148
|
+
<span className="font-medium text-foreground">
|
|
149
|
+
{event.name}
|
|
150
|
+
</span>
|
|
151
|
+
</div>
|
|
152
|
+
{Object.entries(event.attributes).map(([k, v]) => (
|
|
153
|
+
<KeyValueRow key={k} k={k} v={v} />
|
|
154
|
+
))}
|
|
155
|
+
</div>
|
|
156
|
+
))}
|
|
157
|
+
</CollapsibleSection>
|
|
158
|
+
|
|
159
|
+
<CollapsibleSection title="Links" count={span.links.length}>
|
|
160
|
+
{span.links.map((link, i) => (
|
|
161
|
+
<div key={i} className="text-xs font-mono py-0.5">
|
|
162
|
+
<span className="text-muted-foreground">trace:</span>{" "}
|
|
163
|
+
{link.traceId}{" "}
|
|
164
|
+
<span className="text-muted-foreground">span:</span> {link.spanId}
|
|
165
|
+
</div>
|
|
166
|
+
))}
|
|
167
|
+
</CollapsibleSection>
|
|
168
|
+
</div>
|
|
169
|
+
|
|
170
|
+
{/* SpanID + copy */}
|
|
171
|
+
<div className="flex items-center justify-end gap-2 mt-2 pt-2 border-t border-border">
|
|
172
|
+
<span className="text-xs text-muted-foreground">SpanID:</span>
|
|
173
|
+
<code className="text-xs font-mono text-foreground">{span.spanId}</code>
|
|
174
|
+
<button
|
|
175
|
+
onClick={handleCopy}
|
|
176
|
+
className="text-xs text-muted-foreground hover:text-foreground"
|
|
177
|
+
aria-label="Copy span ID"
|
|
178
|
+
>
|
|
179
|
+
{copiedId ? "✓" : "Copy"}
|
|
180
|
+
</button>
|
|
181
|
+
</div>
|
|
182
|
+
</div>
|
|
183
|
+
);
|
|
184
|
+
}
|
|
@@ -2,6 +2,8 @@ import { memo } from "react";
|
|
|
2
2
|
import type { SpanNode } from "../types.js";
|
|
3
3
|
import { TimelineBar } from "./TimelineBar.js";
|
|
4
4
|
import { formatDuration } from "../utils/time.js";
|
|
5
|
+
import { getServiceColor } from "../utils/colors.js";
|
|
6
|
+
import { spanMatchesSearch } from "../utils/flatten-tree.js";
|
|
5
7
|
|
|
6
8
|
export interface SpanRowProps {
|
|
7
9
|
span: SpanNode;
|
|
@@ -16,22 +18,7 @@ export interface SpanRowProps {
|
|
|
16
18
|
onToggleCollapse: () => void;
|
|
17
19
|
onMouseEnter?: () => void;
|
|
18
20
|
onMouseLeave?: () => void;
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
function getHttpContext(span: SpanNode): string | null {
|
|
22
|
-
const attrs = span.attributes;
|
|
23
|
-
const method = attrs["http.method"];
|
|
24
|
-
const url = attrs["http.url"] || attrs["http.target"];
|
|
25
|
-
const statusCode = attrs["http.status_code"];
|
|
26
|
-
|
|
27
|
-
if (!method && !url) return null;
|
|
28
|
-
|
|
29
|
-
const parts: string[] = [];
|
|
30
|
-
if (method) parts.push(String(method));
|
|
31
|
-
if (url) parts.push(String(url));
|
|
32
|
-
if (statusCode) parts.push(`[${statusCode}]`);
|
|
33
|
-
|
|
34
|
-
return parts.join(" ");
|
|
21
|
+
uiFind?: string;
|
|
35
22
|
}
|
|
36
23
|
|
|
37
24
|
export const SpanRow = memo(function SpanRow({
|
|
@@ -46,10 +33,12 @@ export const SpanRow = memo(function SpanRow({
|
|
|
46
33
|
onToggleCollapse,
|
|
47
34
|
onMouseEnter,
|
|
48
35
|
onMouseLeave,
|
|
36
|
+
uiFind,
|
|
49
37
|
}: SpanRowProps) {
|
|
50
38
|
const hasChildren = span.children.length > 0;
|
|
51
39
|
const isError = span.status === "ERROR";
|
|
52
|
-
const
|
|
40
|
+
const serviceColor = getServiceColor(span.serviceName);
|
|
41
|
+
const isDimmed = uiFind ? !spanMatchesSearch(span, uiFind) : false;
|
|
53
42
|
|
|
54
43
|
return (
|
|
55
44
|
<div
|
|
@@ -58,6 +47,10 @@ export const SpanRow = memo(function SpanRow({
|
|
|
58
47
|
? "bg-blue-100 dark:bg-blue-900/30 hover:bg-blue-100 dark:hover:bg-blue-900/30"
|
|
59
48
|
: ""
|
|
60
49
|
}`}
|
|
50
|
+
style={{
|
|
51
|
+
borderLeft: `3px solid ${serviceColor}`,
|
|
52
|
+
opacity: isDimmed ? 0.4 : 1,
|
|
53
|
+
}}
|
|
61
54
|
onClick={onClick}
|
|
62
55
|
onMouseEnter={onMouseEnter}
|
|
63
56
|
onMouseLeave={onMouseLeave}
|
|
@@ -135,7 +128,10 @@ export const SpanRow = memo(function SpanRow({
|
|
|
135
128
|
</svg>
|
|
136
129
|
)}
|
|
137
130
|
|
|
138
|
-
<span
|
|
131
|
+
<span
|
|
132
|
+
className="text-xs flex-shrink-0 mr-2 font-medium"
|
|
133
|
+
style={{ color: serviceColor }}
|
|
134
|
+
>
|
|
139
135
|
{span.serviceName}
|
|
140
136
|
</span>
|
|
141
137
|
|
|
@@ -149,12 +145,6 @@ export const SpanRow = memo(function SpanRow({
|
|
|
149
145
|
</span>
|
|
150
146
|
)}
|
|
151
147
|
|
|
152
|
-
{httpContext && (
|
|
153
|
-
<span className="text-xs text-muted-foreground truncate ml-2 flex-shrink-0 max-w-xs">
|
|
154
|
-
{httpContext}
|
|
155
|
-
</span>
|
|
156
|
-
)}
|
|
157
|
-
|
|
158
148
|
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
|
|
159
149
|
{formatDuration(span.durationMs)}
|
|
160
150
|
</span>
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
export interface SpanSearchProps {
|
|
2
|
+
value: string;
|
|
3
|
+
onChange: (value: string) => void;
|
|
4
|
+
matchCount: number;
|
|
5
|
+
currentMatch: number;
|
|
6
|
+
onPrev: () => void;
|
|
7
|
+
onNext: () => void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function SpanSearch({
|
|
11
|
+
value,
|
|
12
|
+
onChange,
|
|
13
|
+
matchCount,
|
|
14
|
+
currentMatch,
|
|
15
|
+
onPrev,
|
|
16
|
+
onNext,
|
|
17
|
+
}: SpanSearchProps) {
|
|
18
|
+
return (
|
|
19
|
+
<div className="flex items-center gap-1 px-2 py-1 border-b border-border bg-background">
|
|
20
|
+
<input
|
|
21
|
+
type="text"
|
|
22
|
+
placeholder="Find..."
|
|
23
|
+
value={value}
|
|
24
|
+
onChange={(e) => onChange(e.target.value)}
|
|
25
|
+
className="bg-muted text-foreground text-sm px-2 py-0.5 rounded border border-border outline-none focus:border-blue-500 w-48"
|
|
26
|
+
/>
|
|
27
|
+
{value && (
|
|
28
|
+
<>
|
|
29
|
+
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
|
30
|
+
{matchCount > 0 ? `${currentMatch + 1}/${matchCount}` : "0 matches"}
|
|
31
|
+
</span>
|
|
32
|
+
<button
|
|
33
|
+
onClick={onPrev}
|
|
34
|
+
disabled={matchCount === 0}
|
|
35
|
+
className="p-0.5 text-muted-foreground hover:text-foreground disabled:opacity-30"
|
|
36
|
+
aria-label="Previous match"
|
|
37
|
+
>
|
|
38
|
+
<svg
|
|
39
|
+
className="w-3.5 h-3.5"
|
|
40
|
+
fill="none"
|
|
41
|
+
stroke="currentColor"
|
|
42
|
+
viewBox="0 0 24 24"
|
|
43
|
+
>
|
|
44
|
+
<path
|
|
45
|
+
strokeLinecap="round"
|
|
46
|
+
strokeLinejoin="round"
|
|
47
|
+
strokeWidth={2}
|
|
48
|
+
d="M5 15l7-7 7 7"
|
|
49
|
+
/>
|
|
50
|
+
</svg>
|
|
51
|
+
</button>
|
|
52
|
+
<button
|
|
53
|
+
onClick={onNext}
|
|
54
|
+
disabled={matchCount === 0}
|
|
55
|
+
className="p-0.5 text-muted-foreground hover:text-foreground disabled:opacity-30"
|
|
56
|
+
aria-label="Next match"
|
|
57
|
+
>
|
|
58
|
+
<svg
|
|
59
|
+
className="w-3.5 h-3.5"
|
|
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>
|
|
71
|
+
</button>
|
|
72
|
+
</>
|
|
73
|
+
)}
|
|
74
|
+
</div>
|
|
75
|
+
);
|
|
76
|
+
}
|