@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.
- package/README.md +32 -1
- package/package.json +5 -1
- package/src/components/observability/analytics.ts +153 -0
- package/src/components/observability/index.ts +39 -0
- package/src/components/observability/latency.ts +47 -0
- package/src/components/observability/log-lines.tsx +117 -0
- package/src/components/observability/observability.test.tsx +244 -0
- package/src/components/observability/span-waterfall.tsx +493 -0
- package/src/components/observability/trace-filters.tsx +213 -0
- package/src/components/observability/trace-list.tsx +193 -0
- package/src/components/observability/traces-overview.tsx +284 -0
- package/src/components/observability/types.ts +29 -0
- package/src/components/tasks/event-stream.tsx +9 -2
- package/src/components/tasks/types.ts +1 -1
|
@@ -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
|
+
}
|