@townco/debugger 0.1.5 → 0.1.6
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/package.json +6 -3
- package/src/App.tsx +12 -3
- package/src/components/DebuggerHeader.tsx +85 -0
- package/src/components/DebuggerLayout.tsx +30 -0
- package/src/components/LogList.tsx +2 -2
- package/src/components/MessageBubble.tsx +28 -0
- package/src/components/SessionTraceList.tsx +38 -33
- package/src/components/SpanDetailsPanel.tsx +371 -0
- package/src/components/SpanIcon.tsx +42 -0
- package/src/components/SpanTimeline.tsx +450 -0
- package/src/components/SpanTree.tsx +28 -8
- package/src/components/TraceDetailContent.tsx +97 -68
- package/src/components/TurnHeader.tsx +50 -0
- package/src/components/TurnMetadataPanel.tsx +63 -0
- package/src/components/ui/tabs.tsx +50 -0
- package/src/db.ts +33 -10
- package/src/index.ts +7 -1
- package/src/lib/spanTypeDetector.ts +35 -0
- package/src/lib/timelineCalculator.ts +32 -0
- package/src/lib/turnExtractor.ts +132 -0
- package/src/pages/SessionList.tsx +142 -0
- package/src/pages/SessionView.tsx +8 -19
- package/src/pages/TraceDetail.tsx +5 -8
- package/src/pages/TraceList.tsx +66 -61
- package/src/server.ts +53 -1
- package/src/types.ts +23 -1
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { detectSpanType } from "../lib/spanTypeDetector";
|
|
3
|
+
import { calculateTimelineBar } from "../lib/timelineCalculator";
|
|
4
|
+
import type { Span, SpanNode } from "../types";
|
|
5
|
+
import { AttributeViewer } from "./AttributeViewer";
|
|
6
|
+
import { SpanDetailsPanel } from "./SpanDetailsPanel";
|
|
7
|
+
import { SpanIcon } from "./SpanIcon";
|
|
8
|
+
|
|
9
|
+
function parseAttributes(attrs: string | null): Record<string, unknown> {
|
|
10
|
+
if (!attrs) return {};
|
|
11
|
+
try {
|
|
12
|
+
return JSON.parse(attrs);
|
|
13
|
+
} catch {
|
|
14
|
+
return {};
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function buildSpanTree(spans: Span[]): SpanNode[] {
|
|
19
|
+
const spanMap = new Map<string, SpanNode>();
|
|
20
|
+
const roots: SpanNode[] = [];
|
|
21
|
+
|
|
22
|
+
// First pass: create nodes
|
|
23
|
+
for (const span of spans) {
|
|
24
|
+
spanMap.set(span.span_id, {
|
|
25
|
+
...span,
|
|
26
|
+
children: [],
|
|
27
|
+
depth: 0,
|
|
28
|
+
durationMs:
|
|
29
|
+
(span.end_time_unix_nano - span.start_time_unix_nano) / 1_000_000,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Second pass: build tree
|
|
34
|
+
for (const span of spans) {
|
|
35
|
+
const node = spanMap.get(span.span_id);
|
|
36
|
+
if (!node) continue;
|
|
37
|
+
|
|
38
|
+
if (span.parent_span_id && spanMap.has(span.parent_span_id)) {
|
|
39
|
+
const parent = spanMap.get(span.parent_span_id);
|
|
40
|
+
if (parent) {
|
|
41
|
+
node.depth = parent.depth + 1;
|
|
42
|
+
parent.children.push(node);
|
|
43
|
+
}
|
|
44
|
+
} else {
|
|
45
|
+
roots.push(node);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return roots;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface SpanRowProps {
|
|
53
|
+
span: SpanNode;
|
|
54
|
+
traceStart: number;
|
|
55
|
+
traceEnd: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function SpanRow({ span, traceStart, traceEnd }: SpanRowProps) {
|
|
59
|
+
const [expanded, setExpanded] = useState(false);
|
|
60
|
+
const [showRaw, setShowRaw] = useState(false);
|
|
61
|
+
|
|
62
|
+
const attrs = parseAttributes(span.attributes);
|
|
63
|
+
const spanType = detectSpanType(span);
|
|
64
|
+
|
|
65
|
+
// Determine span type
|
|
66
|
+
const isToolCall = span.name === "agent.tool_call";
|
|
67
|
+
const isChatSpan =
|
|
68
|
+
span.name.startsWith("chat") && "gen_ai.input.messages" in attrs;
|
|
69
|
+
const isSpecialSpan = isToolCall || isChatSpan;
|
|
70
|
+
|
|
71
|
+
// Helper to get display name for tool calls (with special handling for Task/subagent)
|
|
72
|
+
const getToolDisplayName = (): string => {
|
|
73
|
+
const toolName = attrs["tool.name"] as string;
|
|
74
|
+
if (toolName !== "Task") return toolName || span.name;
|
|
75
|
+
|
|
76
|
+
// Parse tool.input to extract agentName for subagent spans
|
|
77
|
+
try {
|
|
78
|
+
const toolInput = attrs["tool.input"];
|
|
79
|
+
const input =
|
|
80
|
+
typeof toolInput === "string" ? JSON.parse(toolInput) : toolInput;
|
|
81
|
+
if (input?.agentName) {
|
|
82
|
+
return `Subagent (${input.agentName})`;
|
|
83
|
+
}
|
|
84
|
+
} catch {
|
|
85
|
+
// Fall back to "Task" if parsing fails
|
|
86
|
+
}
|
|
87
|
+
return toolName || span.name;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
// Get display name based on span type
|
|
91
|
+
const displayName = isToolCall
|
|
92
|
+
? getToolDisplayName()
|
|
93
|
+
: isChatSpan
|
|
94
|
+
? (attrs["gen_ai.request.model"] as string) || span.name
|
|
95
|
+
: span.name;
|
|
96
|
+
|
|
97
|
+
const hasDetails =
|
|
98
|
+
(span.attributes && span.attributes !== "{}") ||
|
|
99
|
+
(span.events && span.events !== "[]") ||
|
|
100
|
+
(span.resource_attributes && span.resource_attributes !== "{}");
|
|
101
|
+
|
|
102
|
+
// Calculate timeline bar position and width
|
|
103
|
+
const timeline = calculateTimelineBar(
|
|
104
|
+
span.start_time_unix_nano,
|
|
105
|
+
span.end_time_unix_nano,
|
|
106
|
+
traceStart,
|
|
107
|
+
traceEnd,
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
// Color for timeline bar based on span type
|
|
111
|
+
const timelineColor =
|
|
112
|
+
spanType === "chat"
|
|
113
|
+
? "bg-blue-500/30"
|
|
114
|
+
: spanType === "tool_call"
|
|
115
|
+
? "bg-purple-500/30"
|
|
116
|
+
: spanType === "subagent"
|
|
117
|
+
? "bg-green-500/30"
|
|
118
|
+
: "bg-gray-500/30";
|
|
119
|
+
|
|
120
|
+
return (
|
|
121
|
+
<div>
|
|
122
|
+
<div
|
|
123
|
+
className={`py-1 px-2 ${hasDetails ? "cursor-pointer" : ""}`}
|
|
124
|
+
style={{ paddingLeft: span.depth * 20 + 8 }}
|
|
125
|
+
onClick={() => hasDetails && setExpanded(!expanded)}
|
|
126
|
+
>
|
|
127
|
+
<div className="flex items-center hover:bg-muted rounded">
|
|
128
|
+
<SpanIcon type={spanType} className="mr-2" />
|
|
129
|
+
<span className="font-medium flex-1 truncate">{displayName}</span>
|
|
130
|
+
<span className="text-muted-foreground text-sm ml-2">
|
|
131
|
+
{span.durationMs.toFixed(2)}ms
|
|
132
|
+
</span>
|
|
133
|
+
</div>
|
|
134
|
+
|
|
135
|
+
{/* Timeline bar */}
|
|
136
|
+
<div
|
|
137
|
+
className="mt-1 h-2 bg-muted/30 rounded relative"
|
|
138
|
+
style={{ marginLeft: span.depth * 20 }}
|
|
139
|
+
>
|
|
140
|
+
<div
|
|
141
|
+
className={`absolute h-full rounded ${timelineColor}`}
|
|
142
|
+
style={{
|
|
143
|
+
left: `${timeline.leftPercent}%`,
|
|
144
|
+
width: `${timeline.widthPercent}%`,
|
|
145
|
+
}}
|
|
146
|
+
title={`Start: ${new Date(span.start_time_unix_nano / 1_000_000).toLocaleString()}\nEnd: ${new Date(span.end_time_unix_nano / 1_000_000).toLocaleString()}`}
|
|
147
|
+
/>
|
|
148
|
+
</div>
|
|
149
|
+
</div>
|
|
150
|
+
|
|
151
|
+
{expanded && (
|
|
152
|
+
<div
|
|
153
|
+
className="py-2 px-4 bg-muted/50 rounded mb-1"
|
|
154
|
+
style={{ marginLeft: span.depth * 20 + 28 }}
|
|
155
|
+
>
|
|
156
|
+
<div className="text-xs text-muted-foreground mb-2">
|
|
157
|
+
{span.span_id}
|
|
158
|
+
</div>
|
|
159
|
+
|
|
160
|
+
{/* Tool call span: show input/output */}
|
|
161
|
+
{isToolCall && (
|
|
162
|
+
<>
|
|
163
|
+
<AttributeViewer
|
|
164
|
+
label="Input"
|
|
165
|
+
data={attrs["tool.input"] as string}
|
|
166
|
+
/>
|
|
167
|
+
<AttributeViewer
|
|
168
|
+
label="Output"
|
|
169
|
+
data={attrs["tool.output"] as string}
|
|
170
|
+
/>
|
|
171
|
+
</>
|
|
172
|
+
)}
|
|
173
|
+
|
|
174
|
+
{/* Chat span: show system instructions, input/output messages */}
|
|
175
|
+
{isChatSpan && (
|
|
176
|
+
<>
|
|
177
|
+
<AttributeViewer
|
|
178
|
+
label="System Instructions"
|
|
179
|
+
data={attrs["gen_ai.system_instructions"] as string}
|
|
180
|
+
/>
|
|
181
|
+
<AttributeViewer
|
|
182
|
+
label="Input Messages"
|
|
183
|
+
data={attrs["gen_ai.input.messages"] as string}
|
|
184
|
+
/>
|
|
185
|
+
<AttributeViewer
|
|
186
|
+
label="Output Messages"
|
|
187
|
+
data={attrs["gen_ai.output.messages"] as string}
|
|
188
|
+
/>
|
|
189
|
+
</>
|
|
190
|
+
)}
|
|
191
|
+
|
|
192
|
+
{/* For special spans, show raw attributes collapsed; for default, show them directly */}
|
|
193
|
+
{isSpecialSpan ? (
|
|
194
|
+
<div className="mt-2">
|
|
195
|
+
<button
|
|
196
|
+
type="button"
|
|
197
|
+
className="text-xs text-muted-foreground hover:text-foreground"
|
|
198
|
+
onClick={(e) => {
|
|
199
|
+
e.stopPropagation();
|
|
200
|
+
setShowRaw(!showRaw);
|
|
201
|
+
}}
|
|
202
|
+
>
|
|
203
|
+
{showRaw ? "▼ Hide" : "▶ Show"} raw attributes
|
|
204
|
+
</button>
|
|
205
|
+
{showRaw && (
|
|
206
|
+
<div className="mt-2">
|
|
207
|
+
<AttributeViewer label="Attributes" data={span.attributes} />
|
|
208
|
+
<AttributeViewer label="Events" data={span.events} />
|
|
209
|
+
<AttributeViewer
|
|
210
|
+
label="Resource"
|
|
211
|
+
data={span.resource_attributes}
|
|
212
|
+
/>
|
|
213
|
+
</div>
|
|
214
|
+
)}
|
|
215
|
+
</div>
|
|
216
|
+
) : (
|
|
217
|
+
<>
|
|
218
|
+
<AttributeViewer label="Attributes" data={span.attributes} />
|
|
219
|
+
<AttributeViewer label="Events" data={span.events} />
|
|
220
|
+
<AttributeViewer
|
|
221
|
+
label="Resource"
|
|
222
|
+
data={span.resource_attributes}
|
|
223
|
+
/>
|
|
224
|
+
</>
|
|
225
|
+
)}
|
|
226
|
+
|
|
227
|
+
{span.status_message && (
|
|
228
|
+
<div className="text-xs">
|
|
229
|
+
<span className="text-muted-foreground uppercase font-medium">
|
|
230
|
+
Status Message
|
|
231
|
+
</span>
|
|
232
|
+
<div className="text-red-500">{span.status_message}</div>
|
|
233
|
+
</div>
|
|
234
|
+
)}
|
|
235
|
+
</div>
|
|
236
|
+
)}
|
|
237
|
+
|
|
238
|
+
{span.children.map((child) => (
|
|
239
|
+
<SpanRow
|
|
240
|
+
key={child.span_id}
|
|
241
|
+
span={child}
|
|
242
|
+
traceStart={traceStart}
|
|
243
|
+
traceEnd={traceEnd}
|
|
244
|
+
/>
|
|
245
|
+
))}
|
|
246
|
+
</div>
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
interface SpanTimelineProps {
|
|
251
|
+
spans: Span[];
|
|
252
|
+
traceStart: number;
|
|
253
|
+
traceEnd: number;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export function SpanTimeline({
|
|
257
|
+
spans,
|
|
258
|
+
traceStart,
|
|
259
|
+
traceEnd,
|
|
260
|
+
}: SpanTimelineProps) {
|
|
261
|
+
const tree = buildSpanTree(spans);
|
|
262
|
+
const traceDurationMs = (traceEnd - traceStart) / 1_000_000;
|
|
263
|
+
const [selectedSpan, setSelectedSpan] = useState<SpanNode | null>(null);
|
|
264
|
+
|
|
265
|
+
if (tree.length === 0) {
|
|
266
|
+
return <div className="text-muted-foreground text-sm">No spans</div>;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Generate time markers for the timeline header
|
|
270
|
+
const timeMarkers = [0, 0.25, 0.5, 0.75, 1].map((fraction) => {
|
|
271
|
+
const timeMs = traceDurationMs * fraction;
|
|
272
|
+
return timeMs < 1000
|
|
273
|
+
? `${timeMs.toFixed(0)}ms`
|
|
274
|
+
: `${(timeMs / 1000).toFixed(1)}s`;
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
return (
|
|
278
|
+
<>
|
|
279
|
+
<div className="border border-border rounded-lg overflow-hidden bg-card">
|
|
280
|
+
{/* Table layout */}
|
|
281
|
+
<div className="flex">
|
|
282
|
+
{/* Left column: Process names */}
|
|
283
|
+
<div className="w-[276px] border-r border-border flex-shrink-0">
|
|
284
|
+
{/* Header */}
|
|
285
|
+
<div className="h-6 px-4 py-1 bg-muted border-b border-border flex items-center">
|
|
286
|
+
<span className="text-xs font-medium text-foreground">
|
|
287
|
+
Processes
|
|
288
|
+
</span>
|
|
289
|
+
</div>
|
|
290
|
+
{/* Rows */}
|
|
291
|
+
{tree.map((span) => (
|
|
292
|
+
<RenderSpanTreeRow
|
|
293
|
+
key={span.span_id}
|
|
294
|
+
span={span}
|
|
295
|
+
onSpanClick={setSelectedSpan}
|
|
296
|
+
/>
|
|
297
|
+
))}
|
|
298
|
+
</div>
|
|
299
|
+
|
|
300
|
+
{/* Right column: Timeline */}
|
|
301
|
+
<div className="flex-1 overflow-x-auto">
|
|
302
|
+
{/* Timeline header with time markers */}
|
|
303
|
+
<div className="h-6 px-4 py-1 bg-muted border-b border-border flex items-center text-[10px] text-muted-foreground gap-16">
|
|
304
|
+
{timeMarkers.map((marker, i) => (
|
|
305
|
+
<span key={i} className="w-[260px]">
|
|
306
|
+
{marker}
|
|
307
|
+
</span>
|
|
308
|
+
))}
|
|
309
|
+
</div>
|
|
310
|
+
{/* Timeline rows */}
|
|
311
|
+
<div className="relative">
|
|
312
|
+
{tree.map((span) => (
|
|
313
|
+
<RenderSpanTreeTimeline
|
|
314
|
+
key={span.span_id}
|
|
315
|
+
span={span}
|
|
316
|
+
traceStart={traceStart}
|
|
317
|
+
traceEnd={traceEnd}
|
|
318
|
+
onSpanClick={setSelectedSpan}
|
|
319
|
+
/>
|
|
320
|
+
))}
|
|
321
|
+
</div>
|
|
322
|
+
</div>
|
|
323
|
+
</div>
|
|
324
|
+
</div>
|
|
325
|
+
|
|
326
|
+
<SpanDetailsPanel
|
|
327
|
+
span={selectedSpan}
|
|
328
|
+
onClose={() => setSelectedSpan(null)}
|
|
329
|
+
allSpans={tree}
|
|
330
|
+
/>
|
|
331
|
+
</>
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Render the left column (process names) recursively
|
|
336
|
+
function RenderSpanTreeRow({
|
|
337
|
+
span,
|
|
338
|
+
onSpanClick,
|
|
339
|
+
}: {
|
|
340
|
+
span: SpanNode;
|
|
341
|
+
onSpanClick: (span: SpanNode) => void;
|
|
342
|
+
}) {
|
|
343
|
+
const attrs = parseAttributes(span.attributes);
|
|
344
|
+
const spanType = detectSpanType(span);
|
|
345
|
+
|
|
346
|
+
const isToolCall = span.name === "agent.tool_call";
|
|
347
|
+
const isChatSpan =
|
|
348
|
+
span.name.startsWith("chat") && "gen_ai.input.messages" in attrs;
|
|
349
|
+
|
|
350
|
+
const getToolDisplayName = (): string => {
|
|
351
|
+
const toolName = attrs["tool.name"] as string;
|
|
352
|
+
if (toolName !== "Task") return toolName || span.name;
|
|
353
|
+
|
|
354
|
+
try {
|
|
355
|
+
const toolInput = attrs["tool.input"];
|
|
356
|
+
const input =
|
|
357
|
+
typeof toolInput === "string" ? JSON.parse(toolInput) : toolInput;
|
|
358
|
+
if (input?.agentName) {
|
|
359
|
+
return `Subagent (${input.agentName})`;
|
|
360
|
+
}
|
|
361
|
+
} catch {
|
|
362
|
+
// Fall back
|
|
363
|
+
}
|
|
364
|
+
return toolName || span.name;
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
const displayName = isToolCall
|
|
368
|
+
? getToolDisplayName()
|
|
369
|
+
: isChatSpan
|
|
370
|
+
? (attrs["gen_ai.request.model"] as string) || span.name
|
|
371
|
+
: span.name;
|
|
372
|
+
|
|
373
|
+
return (
|
|
374
|
+
<>
|
|
375
|
+
<div
|
|
376
|
+
className="h-[38px] px-4 py-2 border-b border-border flex items-center gap-2 hover:bg-muted/50 cursor-pointer"
|
|
377
|
+
style={{ paddingLeft: `${span.depth * 20 + 16}px` }}
|
|
378
|
+
onClick={() => onSpanClick(span)}
|
|
379
|
+
>
|
|
380
|
+
<SpanIcon type={spanType} className="shrink-0" />
|
|
381
|
+
<span className="text-sm truncate flex-1">{displayName}</span>
|
|
382
|
+
</div>
|
|
383
|
+
{span.children.map((child) => (
|
|
384
|
+
<RenderSpanTreeRow
|
|
385
|
+
key={child.span_id}
|
|
386
|
+
span={child}
|
|
387
|
+
onSpanClick={onSpanClick}
|
|
388
|
+
/>
|
|
389
|
+
))}
|
|
390
|
+
</>
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Render the right column (timeline bars) recursively
|
|
395
|
+
function RenderSpanTreeTimeline({
|
|
396
|
+
span,
|
|
397
|
+
traceStart,
|
|
398
|
+
traceEnd,
|
|
399
|
+
onSpanClick,
|
|
400
|
+
}: {
|
|
401
|
+
span: SpanNode;
|
|
402
|
+
traceStart: number;
|
|
403
|
+
traceEnd: number;
|
|
404
|
+
onSpanClick: (span: SpanNode) => void;
|
|
405
|
+
}) {
|
|
406
|
+
const spanType = detectSpanType(span);
|
|
407
|
+
const timeline = calculateTimelineBar(
|
|
408
|
+
span.start_time_unix_nano,
|
|
409
|
+
span.end_time_unix_nano,
|
|
410
|
+
traceStart,
|
|
411
|
+
traceEnd,
|
|
412
|
+
);
|
|
413
|
+
|
|
414
|
+
const barColor =
|
|
415
|
+
spanType === "chat"
|
|
416
|
+
? "bg-orange-400"
|
|
417
|
+
: spanType === "tool_call"
|
|
418
|
+
? "bg-purple-500"
|
|
419
|
+
: spanType === "subagent"
|
|
420
|
+
? "bg-purple-500"
|
|
421
|
+
: "bg-blue-400";
|
|
422
|
+
|
|
423
|
+
return (
|
|
424
|
+
<>
|
|
425
|
+
<div className="h-[38px] border-b border-border relative flex items-center px-4 hover:bg-muted/50 cursor-pointer">
|
|
426
|
+
<div
|
|
427
|
+
className={`absolute h-[18px] rounded ${barColor} flex items-center px-1`}
|
|
428
|
+
style={{
|
|
429
|
+
left: `calc(${timeline.leftPercent}% + 16px)`,
|
|
430
|
+
width: `calc(${Math.max(timeline.widthPercent, 0.5)}% - 32px)`,
|
|
431
|
+
}}
|
|
432
|
+
onClick={() => onSpanClick(span)}
|
|
433
|
+
>
|
|
434
|
+
<span className="text-xs text-white whitespace-nowrap">
|
|
435
|
+
{span.durationMs.toFixed(2)}ms
|
|
436
|
+
</span>
|
|
437
|
+
</div>
|
|
438
|
+
</div>
|
|
439
|
+
{span.children.map((child) => (
|
|
440
|
+
<RenderSpanTreeTimeline
|
|
441
|
+
key={child.span_id}
|
|
442
|
+
span={child}
|
|
443
|
+
traceStart={traceStart}
|
|
444
|
+
traceEnd={traceEnd}
|
|
445
|
+
onSpanClick={onSpanClick}
|
|
446
|
+
/>
|
|
447
|
+
))}
|
|
448
|
+
</>
|
|
449
|
+
);
|
|
450
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useState } from "react";
|
|
2
2
|
import type { Span, SpanNode } from "../types";
|
|
3
3
|
import { AttributeViewer } from "./AttributeViewer";
|
|
4
|
+
import { SpanDetailsPanel } from "./SpanDetailsPanel";
|
|
4
5
|
|
|
5
6
|
function parseAttributes(attrs: string | null): Record<string, unknown> {
|
|
6
7
|
if (!attrs) return {};
|
|
@@ -45,7 +46,13 @@ export function buildSpanTree(spans: Span[]): SpanNode[] {
|
|
|
45
46
|
return roots;
|
|
46
47
|
}
|
|
47
48
|
|
|
48
|
-
function SpanRow({
|
|
49
|
+
function SpanRow({
|
|
50
|
+
span,
|
|
51
|
+
onSpanClick,
|
|
52
|
+
}: {
|
|
53
|
+
span: SpanNode;
|
|
54
|
+
onSpanClick: (span: SpanNode) => void;
|
|
55
|
+
}) {
|
|
49
56
|
const [expanded, setExpanded] = useState(false);
|
|
50
57
|
const [showRaw, setShowRaw] = useState(false);
|
|
51
58
|
|
|
@@ -100,7 +107,7 @@ function SpanRow({ span }: { span: SpanNode }) {
|
|
|
100
107
|
<div
|
|
101
108
|
className={`flex items-center py-1 px-2 hover:bg-muted rounded ${hasDetails ? "cursor-pointer" : ""}`}
|
|
102
109
|
style={{ paddingLeft: span.depth * 20 + 8 }}
|
|
103
|
-
onClick={() =>
|
|
110
|
+
onClick={() => onSpanClick(span)}
|
|
104
111
|
>
|
|
105
112
|
<span className={`${statusColor} mr-2`}>●</span>
|
|
106
113
|
<span className="font-medium flex-1 truncate">{displayName}</span>
|
|
@@ -197,7 +204,7 @@ function SpanRow({ span }: { span: SpanNode }) {
|
|
|
197
204
|
)}
|
|
198
205
|
|
|
199
206
|
{span.children.map((child) => (
|
|
200
|
-
<SpanRow key={child.span_id} span={child} />
|
|
207
|
+
<SpanRow key={child.span_id} span={child} onSpanClick={onSpanClick} />
|
|
201
208
|
))}
|
|
202
209
|
</div>
|
|
203
210
|
);
|
|
@@ -209,16 +216,29 @@ interface SpanTreeProps {
|
|
|
209
216
|
|
|
210
217
|
export function SpanTree({ spans }: SpanTreeProps) {
|
|
211
218
|
const tree = buildSpanTree(spans);
|
|
219
|
+
const [selectedSpan, setSelectedSpan] = useState<SpanNode | null>(null);
|
|
212
220
|
|
|
213
221
|
if (tree.length === 0) {
|
|
214
222
|
return <div className="text-muted-foreground text-sm">No spans</div>;
|
|
215
223
|
}
|
|
216
224
|
|
|
217
225
|
return (
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
226
|
+
<>
|
|
227
|
+
<div className="font-mono text-sm">
|
|
228
|
+
{tree.map((span) => (
|
|
229
|
+
<SpanRow
|
|
230
|
+
key={span.span_id}
|
|
231
|
+
span={span}
|
|
232
|
+
onSpanClick={setSelectedSpan}
|
|
233
|
+
/>
|
|
234
|
+
))}
|
|
235
|
+
</div>
|
|
236
|
+
|
|
237
|
+
<SpanDetailsPanel
|
|
238
|
+
span={selectedSpan}
|
|
239
|
+
onClose={() => setSelectedSpan(null)}
|
|
240
|
+
allSpans={tree}
|
|
241
|
+
/>
|
|
242
|
+
</>
|
|
223
243
|
);
|
|
224
244
|
}
|