@spunto/design-system 0.33.0 → 0.35.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.
@@ -0,0 +1,493 @@
1
+ "use client"
2
+
3
+ import { useMemo, useState, type KeyboardEvent } from "react"
4
+ import { ChevronRightIcon } from "lucide-react"
5
+ import { cn } from "../../utils"
6
+ import { formatLatency } from "./latency"
7
+ import { LogLines, type TelemetryLogLine } from "./log-lines"
8
+ import type { TraceSpan } from "./types"
9
+
10
+ // ── Span waterfall ────────────────────────────────────────────────────────
11
+ // A trace as a tree of spans on a shared time axis. Purely presentational: it takes the spans,
12
+ // the app fetches them (and owns the loading/not-found states, which say things about *its*
13
+ // backend). Click a row for its attributes.
14
+
15
+ /**
16
+ * Colour of a span by what it is: agent RPC and jobs → run, database → ship, the rest (HTTP) →
17
+ * build, any error → destructive. Read from OTel semantic attributes first, from the span name
18
+ * as a fallback.
19
+ */
20
+ export function spanColor(s: TraceSpan): string {
21
+ if (s.error) return "var(--destructive)"
22
+ const n = s.name.toLowerCase()
23
+ const attrs = s.attributes ?? {}
24
+ if (n.startsWith("agent.cmd") || attrs["rpc.system"]) return "var(--run)"
25
+ if (n.startsWith("pg") || n.startsWith("sqlite") || attrs["db.system"]) return "var(--ship)"
26
+ if (n.startsWith("job.") || n.startsWith("worker.spawn")) return "var(--run)"
27
+ return "var(--build)"
28
+ }
29
+
30
+ const LEGEND: [label: string, color: string][] = [
31
+ ["http", "var(--build)"],
32
+ ["db", "var(--ship)"],
33
+ ["agent rpc / job", "var(--run)"],
34
+ ]
35
+
36
+ /**
37
+ * Depth-first order, children directly under their parent (a real tree, like Datadog) instead of
38
+ * the backend's start-time order which interleaves siblings' subtrees. Siblings by start time; a
39
+ * span whose parent isn't in the set is a root.
40
+ */
41
+ export function orderSpans<S extends TraceSpan>(spans: S[]): { span: S; depth: number }[] {
42
+ const ids = new Set(spans.map((s) => s.spanId))
43
+ const byParent = new Map<string | null, S[]>()
44
+ for (const s of spans) {
45
+ const key = s.parentSpanId && ids.has(s.parentSpanId) ? s.parentSpanId : null
46
+ const arr = byParent.get(key)
47
+ if (arr) arr.push(s)
48
+ else byParent.set(key, [s])
49
+ }
50
+ for (const arr of byParent.values()) arr.sort((a, b) => a.startTimeMs - b.startTimeMs)
51
+ const out: { span: S; depth: number }[] = []
52
+ const seen = new Set<string>()
53
+ const walk = (parentId: string | null, depth: number) => {
54
+ for (const s of byParent.get(parentId) ?? []) {
55
+ // A malformed trace (a span listed as its own ancestor) must not loop forever.
56
+ if (seen.has(s.spanId)) continue
57
+ seen.add(s.spanId)
58
+ out.push({ span: s, depth })
59
+ walk(s.spanId, depth + 1)
60
+ }
61
+ }
62
+ walk(null, 0)
63
+ return out
64
+ }
65
+
66
+ /** Per span of an `orderSpans` result: its parent in the tree, and what folding it would hide. */
67
+ export interface SpanTreeInfo {
68
+ parentId: string | null
69
+ /** Every span under it, at any depth. 0 = a leaf, nothing to fold. */
70
+ descendants: number
71
+ /** Whether one of those descendants is in error — so a folded row can still say so. */
72
+ descendantError: boolean
73
+ }
74
+
75
+ /** The tree `orderSpans` walked, indexed by span id. Linear: a subtree is a contiguous run. */
76
+ export function spanTree(ordered: { span: TraceSpan; depth: number }[]): Map<string, SpanTreeInfo> {
77
+ const info = new Map<string, SpanTreeInfo>()
78
+ const stack: { id: string; depth: number }[] = []
79
+ for (const { span, depth } of ordered) {
80
+ while (stack.length && stack[stack.length - 1].depth >= depth) stack.pop()
81
+ info.set(span.spanId, { parentId: stack[stack.length - 1]?.id ?? null, descendants: 0, descendantError: false })
82
+ for (const a of stack) {
83
+ const ai = info.get(a.id)!
84
+ ai.descendants++
85
+ if (span.error) ai.descendantError = true
86
+ }
87
+ stack.push({ id: span.spanId, depth })
88
+ }
89
+ return info
90
+ }
91
+
92
+ export interface SpanWaterfallProps {
93
+ spans: TraceSpan[]
94
+ /** Shown in the header line, next to the span count and total duration. */
95
+ traceId?: string
96
+ /** Wider name column and one more attribute chip — for a fullscreen sheet. */
97
+ fullWidth?: boolean
98
+ /** Controlled selection. Omit both to let the component keep it. */
99
+ selectedSpanId?: string | null
100
+ onSelectedSpanChange?: (spanId: string | null) => void
101
+ /** Override the colour rule (default `spanColor`). */
102
+ colorOf?: (span: TraceSpan) => string
103
+ /**
104
+ * Folded spans (their subtree is hidden). Controlled with `onCollapsedSpanIdsChange`; omit both
105
+ * to let the component keep it.
106
+ */
107
+ collapsedSpanIds?: string[]
108
+ onCollapsedSpanIdsChange?: (spanIds: string[]) => void
109
+ /**
110
+ * Uncontrolled only: spans at this depth or deeper start folded (0 = only the roots show).
111
+ * For a trace of hundreds of spans, where the first screen should be the shape, not the leaves.
112
+ */
113
+ defaultCollapsedDepth?: number
114
+ /** The colour legend under the waterfall. Hide it when `colorOf` means something else. */
115
+ legend?: boolean
116
+ /**
117
+ * The log lines stamped with this trace id. Given (even empty), a Logs section is drawn under
118
+ * the waterfall: times relative to the trace start, a line tied to a span selects it, and the
119
+ * selected span's lines are highlighted.
120
+ */
121
+ logs?: TelemetryLogLine[]
122
+ /** While the app fetches `logs`, or when it couldn't. */
123
+ logsStatus?: "loading" | "error"
124
+ className?: string
125
+ }
126
+
127
+ export function SpanWaterfall({
128
+ spans,
129
+ traceId,
130
+ fullWidth = false,
131
+ selectedSpanId,
132
+ onSelectedSpanChange,
133
+ colorOf = spanColor,
134
+ legend = true,
135
+ collapsedSpanIds,
136
+ onCollapsedSpanIdsChange,
137
+ defaultCollapsedDepth,
138
+ logs,
139
+ logsStatus,
140
+ className,
141
+ }: SpanWaterfallProps) {
142
+ const [ownSelected, setOwnSelected] = useState<string | null>(null)
143
+ const selected = selectedSpanId !== undefined ? selectedSpanId : ownSelected
144
+ const setSelected = (id: string | null) => {
145
+ if (selectedSpanId === undefined) setOwnSelected(id)
146
+ onSelectedSpanChange?.(id)
147
+ }
148
+
149
+ const ordered = useMemo(() => orderSpans(spans), [spans])
150
+ const tree = useMemo(() => spanTree(ordered), [ordered])
151
+
152
+ // Folding. Uncontrolled state is seeded once from `defaultCollapsedDepth`; a span id that isn't
153
+ // (or no longer is) a parent in this trace is simply ignored.
154
+ const [ownCollapsed, setOwnCollapsed] = useState<string[]>(() =>
155
+ defaultCollapsedDepth === undefined
156
+ ? []
157
+ : ordered.filter((o) => o.depth >= defaultCollapsedDepth && (tree.get(o.span.spanId)?.descendants ?? 0) > 0).map((o) => o.span.spanId)
158
+ )
159
+ const collapsedList = collapsedSpanIds ?? ownCollapsed
160
+ const collapsed = useMemo(() => new Set(collapsedList), [collapsedList])
161
+ const setCollapsed = (next: Set<string>) => {
162
+ const arr = [...next]
163
+ if (collapsedSpanIds === undefined) setOwnCollapsed(arr)
164
+ onCollapsedSpanIdsChange?.(arr)
165
+ }
166
+ const toggle = (id: string, fold?: boolean) => {
167
+ const next = new Set(collapsed)
168
+ if (fold ?? !next.has(id)) next.add(id)
169
+ else next.delete(id)
170
+ setCollapsed(next)
171
+ }
172
+ const parents = ordered.filter((o) => (tree.get(o.span.spanId)?.descendants ?? 0) > 0).map((o) => o.span.spanId)
173
+ const allCollapsed = parents.length > 0 && parents.every((id) => collapsed.has(id))
174
+ const anyCollapsed = parents.some((id) => collapsed.has(id))
175
+
176
+ // A span under a folded ancestor isn't drawn: skip the rest of its subtree.
177
+ const visible = useMemo(() => {
178
+ const out: typeof ordered = []
179
+ let hideBelow: number | null = null
180
+ for (const o of ordered) {
181
+ if (hideBelow !== null && o.depth > hideBelow) continue
182
+ hideBelow = collapsed.has(o.span.spanId) && (tree.get(o.span.spanId)?.descendants ?? 0) > 0 ? o.depth : null
183
+ out.push(o)
184
+ }
185
+ return out
186
+ }, [ordered, collapsed, tree])
187
+
188
+ // Selecting a span from outside the tree (a log line) unfolds its ancestors, so the row it
189
+ // highlights is on screen.
190
+ const selectAndReveal = (id: string | null) => {
191
+ if (id) {
192
+ const next = new Set(collapsed)
193
+ let changed = false
194
+ for (let p = tree.get(id)?.parentId ?? null; p; p = tree.get(p)?.parentId ?? null) {
195
+ if (next.delete(p)) changed = true
196
+ }
197
+ if (changed) setCollapsed(next)
198
+ }
199
+ setSelected(id)
200
+ }
201
+ const { t0, total } = useMemo(() => {
202
+ if (!spans.length) return { t0: 0, total: 0.001 }
203
+ let lo = Infinity
204
+ let hi = -Infinity
205
+ for (const s of spans) {
206
+ lo = Math.min(lo, s.startTimeMs)
207
+ hi = Math.max(hi, s.startTimeMs + s.durationMs)
208
+ }
209
+ return { t0: lo, total: Math.max(0.001, hi - lo) }
210
+ }, [spans])
211
+ const selectedSpan = ordered.find((o) => o.span.spanId === selected)?.span ?? null
212
+ const spanIds = useMemo(() => new Set(spans.map((s) => s.spanId)), [spans])
213
+
214
+ const nameCol = fullWidth ? "minmax(16rem,26rem)" : "minmax(11rem,18rem)"
215
+ const gridCols = `${nameCol} 1fr 5rem`
216
+ const ticks = [0, 0.25, 0.5, 0.75, 1]
217
+ // Continuous vertical gridlines behind every bar row (quarters of the timeline).
218
+ const gridBg =
219
+ "repeating-linear-gradient(to right, color-mix(in oklch, var(--border) 60%, transparent) 0 1px, transparent 1px 25%)"
220
+
221
+ return (
222
+ <div className={cn("space-y-3", className)}>
223
+ <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
224
+ {traceId && (
225
+ <>
226
+ <span className="font-mono text-[11px]">{traceId}</span>
227
+ <span>·</span>
228
+ </>
229
+ )}
230
+ <span>
231
+ {spans.length} span{spans.length !== 1 ? "s" : ""}
232
+ </span>
233
+ <span>·</span>
234
+ <span className="font-mono tabular-nums text-foreground">{formatLatency(spans.length ? total : 0)}</span>
235
+ {parents.length > 0 && (
236
+ <span className="ml-auto flex items-center gap-1">
237
+ <button
238
+ type="button"
239
+ disabled={allCollapsed}
240
+ onClick={() => setCollapsed(new Set(parents))}
241
+ className="rounded-md px-1.5 py-0.5 transition-colors hover:bg-muted hover:text-foreground disabled:pointer-events-none disabled:opacity-40"
242
+ >
243
+ Collapse all
244
+ </button>
245
+ <span aria-hidden>·</span>
246
+ <button
247
+ type="button"
248
+ disabled={!anyCollapsed}
249
+ onClick={() => setCollapsed(new Set())}
250
+ className="rounded-md px-1.5 py-0.5 transition-colors hover:bg-muted hover:text-foreground disabled:pointer-events-none disabled:opacity-40"
251
+ >
252
+ Expand all
253
+ </button>
254
+ </span>
255
+ )}
256
+ </div>
257
+
258
+ <div className="overflow-hidden rounded-xl border border-border bg-card">
259
+ {/* Time-axis ruler */}
260
+ <div
261
+ className="sticky top-0 z-10 grid items-center border-b border-border bg-muted/40 backdrop-blur"
262
+ style={{ gridTemplateColumns: gridCols }}
263
+ >
264
+ <div className="px-3 py-1.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">Span</div>
265
+ <div className="relative h-6">
266
+ {ticks.map((f) => (
267
+ <span
268
+ key={f}
269
+ className={cn(
270
+ "absolute top-1/2 -translate-y-1/2 font-mono text-[9.5px] tabular-nums text-muted-foreground/80",
271
+ f === 0 ? "left-1" : f === 1 ? "right-1" : "-translate-x-1/2"
272
+ )}
273
+ style={f !== 0 && f !== 1 ? { left: `${f * 100}%` } : undefined}
274
+ >
275
+ {formatLatency(f * total)}
276
+ </span>
277
+ ))}
278
+ </div>
279
+ <div className="px-3 py-1.5 text-right text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
280
+ Duration
281
+ </div>
282
+ </div>
283
+
284
+ {/* Span rows */}
285
+ <div className="max-h-[calc(100vh-16rem)] overflow-y-auto">
286
+ {ordered.length === 0 && (
287
+ <p className="px-3 py-6 text-center text-xs text-muted-foreground">No spans in this trace.</p>
288
+ )}
289
+ {visible.map(({ span: s, depth }) => {
290
+ const left = ((s.startTimeMs - t0) / total) * 100
291
+ const width = Math.max(0.5, (s.durationMs / total) * 100)
292
+ const isSel = selected === s.spanId
293
+ const color = colorOf(s)
294
+ const info = tree.get(s.spanId)
295
+ const foldable = (info?.descendants ?? 0) > 0
296
+ const isFolded = foldable && collapsed.has(s.spanId)
297
+ // The row is a div, not a button: it holds the fold chevron, and a button can't hold a
298
+ // button. Keyboard parity by hand — Enter/Space select, ←/→ fold/unfold.
299
+ const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
300
+ if (e.target !== e.currentTarget) return
301
+ if (e.key === "Enter" || e.key === " ") {
302
+ e.preventDefault()
303
+ setSelected(isSel ? null : s.spanId)
304
+ } else if (foldable && e.key === "ArrowLeft" && !isFolded) {
305
+ e.preventDefault()
306
+ toggle(s.spanId, true)
307
+ } else if (foldable && e.key === "ArrowRight" && isFolded) {
308
+ e.preventDefault()
309
+ toggle(s.spanId, false)
310
+ }
311
+ }
312
+ return (
313
+ <div
314
+ key={s.spanId}
315
+ role="button"
316
+ tabIndex={0}
317
+ onClick={() => setSelected(isSel ? null : s.spanId)}
318
+ onKeyDown={onKeyDown}
319
+ aria-pressed={isSel}
320
+ aria-expanded={foldable ? !isFolded : undefined}
321
+ className={cn(
322
+ "grid w-full cursor-pointer items-center border-b border-border/50 text-left outline-none transition-colors last:border-0 hover:bg-muted/40 focus-visible:bg-muted/60",
323
+ isSel && "bg-run/5 hover:bg-run/10"
324
+ )}
325
+ style={{ gridTemplateColumns: gridCols }}
326
+ >
327
+ {/* Fold chevron + depth guides + name */}
328
+ <span className="flex min-w-0 items-center py-2 pl-1.5 pr-2">
329
+ <span className="flex shrink-0 items-center" aria-hidden>
330
+ {Array.from({ length: depth }).map((_, i) => (
331
+ <span key={i} className="ml-1 mr-1 h-4 w-px bg-border/70" />
332
+ ))}
333
+ </span>
334
+ {foldable ? (
335
+ <button
336
+ type="button"
337
+ tabIndex={-1}
338
+ onClick={(e) => {
339
+ e.stopPropagation()
340
+ toggle(s.spanId)
341
+ }}
342
+ aria-label={isFolded ? `Expand ${s.name}` : `Collapse ${s.name}`}
343
+ className="mr-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
344
+ >
345
+ <ChevronRightIcon className={cn("h-3.5 w-3.5 transition-transform", !isFolded && "rotate-90")} />
346
+ </button>
347
+ ) : (
348
+ <span className="mr-0.5 w-5 shrink-0" aria-hidden />
349
+ )}
350
+ {s.error && <span className="mr-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-destructive" />}
351
+ <span className="mr-2 h-2.5 w-1 shrink-0 rounded-sm" style={{ background: color }} aria-hidden />
352
+ <span className={cn("truncate font-mono text-[12px]", isSel && "text-foreground")}>{s.name}</span>
353
+ {isFolded && (
354
+ <span
355
+ className={cn(
356
+ "ml-1.5 shrink-0 rounded-full border px-1.5 font-mono text-[10px] tabular-nums",
357
+ info!.descendantError
358
+ ? "border-destructive/30 bg-destructive/10 text-destructive"
359
+ : "border-border bg-muted text-muted-foreground"
360
+ )}
361
+ title={`${info!.descendants} span${info!.descendants !== 1 ? "s" : ""} hidden${info!.descendantError ? ", including an error" : ""}`}
362
+ >
363
+ +{info!.descendants}
364
+ </span>
365
+ )}
366
+ </span>
367
+
368
+ {/* Timeline bar */}
369
+ <span className="relative mx-2 hidden h-6 items-center sm:flex" style={{ backgroundImage: gridBg }}>
370
+ <span
371
+ className="absolute h-2.5 min-w-[2px] rounded-[3px]"
372
+ style={{ left: `${left}%`, width: `${width}%`, background: color }}
373
+ title={`${formatLatency(s.durationMs)} @ +${formatLatency(s.startTimeMs - t0)}`}
374
+ />
375
+ </span>
376
+
377
+ {/* Duration */}
378
+ <span className="px-3 py-2 text-right font-mono text-[11px] tabular-nums text-muted-foreground">
379
+ {formatLatency(s.durationMs)}
380
+ </span>
381
+ </div>
382
+ )
383
+ })}
384
+ </div>
385
+ </div>
386
+
387
+ {/* Selected span detail */}
388
+ {selectedSpan && (
389
+ <SpanDetail span={selectedSpan} offsetMs={selectedSpan.startTimeMs - t0} color={colorOf(selectedSpan)} />
390
+ )}
391
+
392
+ {legend && (
393
+ <p className="text-center text-xs text-muted-foreground/70">
394
+ {LEGEND.map(([label, color], i) => (
395
+ <span key={label}>
396
+ {i > 0 && " · "}
397
+ <span className="inline-flex items-center gap-1">
398
+ <span className="h-2 w-2 rounded-sm" style={{ background: color }} /> {label}
399
+ </span>
400
+ </span>
401
+ ))}
402
+ {parents.length > 0 ? " · click a span for details, the chevron to fold it" : " · click a span for details"}
403
+ </p>
404
+ )}
405
+
406
+ {(logs !== undefined || logsStatus) && (
407
+ <div className="space-y-2">
408
+ <div className="flex items-baseline justify-between gap-2">
409
+ <h3 className="text-sm font-medium">Logs</h3>
410
+ <span className="text-xs text-muted-foreground">
411
+ {logsStatus === "loading"
412
+ ? "loading…"
413
+ : logsStatus === "error"
414
+ ? "unavailable"
415
+ : `${logs?.length ?? 0} line${logs?.length === 1 ? "" : "s"} with this trace id`}
416
+ </span>
417
+ </div>
418
+ {!logsStatus && logs?.length === 0 ? (
419
+ <p className="rounded-lg border border-dashed px-3 py-4 text-center text-xs text-muted-foreground">
420
+ No log line carries this trace id — only lines written inside a span (with the trace context active) are
421
+ linked.
422
+ </p>
423
+ ) : (
424
+ logs &&
425
+ logs.length > 0 && (
426
+ <LogLines
427
+ lines={logs}
428
+ origin={t0}
429
+ spanIds={spanIds}
430
+ selectedSpanId={selected}
431
+ onSelectSpan={selectAndReveal}
432
+ className="max-h-80"
433
+ />
434
+ )
435
+ )}
436
+ </div>
437
+ )}
438
+ </div>
439
+ )
440
+ }
441
+
442
+ function SpanDetail({ span, offsetMs, color }: { span: TraceSpan; offsetMs: number; color: string }) {
443
+ const attrs = Object.entries(span.attributes ?? {})
444
+ return (
445
+ <div className="rounded-xl border border-border bg-card p-4">
446
+ <div className="mb-3 flex flex-wrap items-center gap-2">
447
+ <span className="h-3 w-1.5 rounded-sm" style={{ background: color }} aria-hidden />
448
+ <span className="font-mono text-sm">{span.name}</span>
449
+ {span.error && (
450
+ <span className="rounded-full border border-destructive/30 bg-destructive/10 px-2 py-0.5 text-[10px] font-medium text-destructive">
451
+ error
452
+ </span>
453
+ )}
454
+ <span className="ml-auto font-mono text-xs tabular-nums text-muted-foreground">
455
+ {formatLatency(span.durationMs)} · +{formatLatency(offsetMs)}
456
+ </span>
457
+ </div>
458
+ <div className="mb-3 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
459
+ <span>
460
+ service <span className="font-mono text-foreground">{span.service || "—"}</span>
461
+ </span>
462
+ {span.kind && (
463
+ <span>
464
+ kind <span className="font-mono text-foreground">{span.kind}</span>
465
+ </span>
466
+ )}
467
+ </div>
468
+ {attrs.length > 0 ? (
469
+ <div className="grid gap-x-4 gap-y-1" style={{ gridTemplateColumns: "minmax(8rem,auto) 1fr" }}>
470
+ {attrs.map(([k, v]) => (
471
+ <div key={k} className="contents">
472
+ <span className="truncate font-mono text-[11px] text-muted-foreground/80">{k}</span>
473
+ <span className="break-all font-mono text-[11px] text-foreground">{v}</span>
474
+ </div>
475
+ ))}
476
+ </div>
477
+ ) : (
478
+ <p className="text-xs text-muted-foreground">No attributes.</p>
479
+ )}
480
+ </div>
481
+ )
482
+ }
483
+
484
+ /** Placeholder rows with the waterfall's geometry, while the app fetches the spans. */
485
+ export function SpanWaterfallSkeleton({ rows = 10, className }: { rows?: number; className?: string }) {
486
+ return (
487
+ <div className={cn("space-y-2", className)}>
488
+ {Array.from({ length: rows }).map((_, i) => (
489
+ <div key={i} className="h-8 animate-pulse rounded-md border border-border bg-muted/40" />
490
+ ))}
491
+ </div>
492
+ )
493
+ }