effect-inspect 0.1.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.
Files changed (38) hide show
  1. package/README.md +122 -0
  2. package/app/dist/client/assets/index-smV05cfr.js +25 -0
  3. package/app/dist/client/assets/rolldown-runtime-CbXtAM7H.js +1 -0
  4. package/app/dist/client/assets/routes-TKgeFdSW.js +5 -0
  5. package/app/dist/client/assets/styles-B3rAZGvS.css +2 -0
  6. package/app/dist/server/assets/_tanstack-start-manifest_v-Co953HeC.js +20 -0
  7. package/app/dist/server/assets/empty-plugin-adapters-D9UWiqvJ.js +5 -0
  8. package/app/dist/server/assets/router-CN98Ramo.js +491 -0
  9. package/app/dist/server/assets/routes-eZ4XqxE9.js +3999 -0
  10. package/app/dist/server/assets/start-5Z2QO8AU.js +4 -0
  11. package/app/dist/server/server.js +1812 -0
  12. package/dist/cli.d.ts +3 -0
  13. package/dist/cli.js +29 -0
  14. package/dist/client/Client.d.ts +52 -0
  15. package/dist/client/Client.js +224 -0
  16. package/dist/client/Edge.d.ts +31 -0
  17. package/dist/client/Edge.js +108 -0
  18. package/dist/client/Inspect.d.ts +49 -0
  19. package/dist/client/Inspect.js +55 -0
  20. package/dist/client/Tracer.d.ts +31 -0
  21. package/dist/client/Tracer.js +119 -0
  22. package/dist/collector/Config.d.ts +8 -0
  23. package/dist/collector/Config.js +9 -0
  24. package/dist/collector/Server.d.ts +24 -0
  25. package/dist/collector/Server.js +172 -0
  26. package/dist/collector/Store.d.ts +86 -0
  27. package/dist/collector/Store.js +119 -0
  28. package/dist/collector/WebApp.d.ts +3 -0
  29. package/dist/collector/WebApp.js +36 -0
  30. package/dist/collector/main.d.ts +1 -0
  31. package/dist/collector/main.js +22 -0
  32. package/dist/index.d.ts +3 -0
  33. package/dist/index.js +3 -0
  34. package/dist/protocol/Codec.d.ts +575 -0
  35. package/dist/protocol/Codec.js +50 -0
  36. package/dist/protocol/Schema.d.ts +1237 -0
  37. package/dist/protocol/Schema.js +327 -0
  38. package/package.json +85 -0
@@ -0,0 +1,3999 @@
1
+ import { a as CollapseButton, c as PanelHeader, i as themeAtom, l as usePanel, n as DEFAULT_THEME, o as CollapsedRail, r as resolvedThemeAtom, s as Empty$1, u as Button } from "./router-CN98Ramo.js";
2
+ import { useCallback, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
3
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
4
+ import { FilterX, Monitor, Moon, MousePointerClick, PlugZap, Radio, SearchX, Sun, X } from "lucide-react";
5
+ import { RegistryContext, useAtom, useAtomMount, useAtomValue } from "@effect/atom-react";
6
+ import { Atom } from "effect/unstable/reactivity";
7
+ import { Data, Result, Schema } from "effect";
8
+ //#region app/src/chart/Layout.ts
9
+ var EMPTY$1 = {
10
+ version: -1,
11
+ rows: [],
12
+ rowOf: /* @__PURE__ */ new Map(),
13
+ duration: 0,
14
+ ordered: []
15
+ };
16
+ /** Effective end of a span: open spans run to `now` (the trace's right edge). */
17
+ var spanEnd = (span, now) => span.end ?? now;
18
+ /**
19
+ * Returns a layout for the store's current version, reusing `previous` when
20
+ * nothing has changed.
21
+ *
22
+ * **A span's row is final once assigned.** `previous.rowOf` is carried over
23
+ * verbatim and only spans the previous layout never saw are packed. A late
24
+ * arrival that fits nowhere takes a new row rather than repacking what the
25
+ * user is already looking at — under a live trace, rows never reshuffle.
26
+ *
27
+ * The cost is deliberate and one-directional: a trace watched live can end up
28
+ * taller than the same trace loaded from scratch, because a gap that opens up
29
+ * later is never reclaimed. Visual stability beats vertical compactness.
30
+ */
31
+ var layout = (store, previous = EMPTY$1) => {
32
+ if (previous.version === store.version) return previous;
33
+ const duration = store.stats().duration;
34
+ const ordered = [];
35
+ for (const ids of store.rows) for (const id of ids) {
36
+ const span = store.spans.get(id);
37
+ if (span !== void 0) ordered.push(span);
38
+ }
39
+ ordered.sort((a, b) => a.start - b.start || a.depth - b.depth);
40
+ const spansByRow = [];
41
+ const freeFrom = [];
42
+ const pinned = [];
43
+ const rowOf = /* @__PURE__ */ new Map();
44
+ const claim = (row, span, isPinned) => {
45
+ while (spansByRow.length <= row) {
46
+ spansByRow.push([]);
47
+ freeFrom.push(Number.NEGATIVE_INFINITY);
48
+ pinned.push([]);
49
+ }
50
+ const to = spanEnd(span, duration);
51
+ spansByRow[row].push(span);
52
+ if (isPinned) pinned[row].push({
53
+ from: span.start,
54
+ to
55
+ });
56
+ else if (to > freeFrom[row]) freeFrom[row] = to;
57
+ rowOf.set(span.spanId, row);
58
+ };
59
+ /** True when a span spanning `[from, to)` can be drawn on `row` untouched. */
60
+ const fits = (row, from, to) => {
61
+ if (row >= spansByRow.length) return true;
62
+ if (freeFrom[row] > from) return false;
63
+ return !pinned[row].some((i) => i.from < to && from < i.to);
64
+ };
65
+ if (ordered.length >= previous.ordered.length) for (const span of ordered) {
66
+ const pinnedRow = previous.rowOf.get(span.spanId);
67
+ if (pinnedRow !== void 0) claim(pinnedRow, span, true);
68
+ }
69
+ const rowFor = (span) => {
70
+ const parentRow = span.parentId === void 0 ? void 0 : rowOf.get(span.parentId);
71
+ const from = span.start;
72
+ const to = spanEnd(span, duration);
73
+ let row = parentRow === void 0 ? 0 : parentRow + 1;
74
+ while (!fits(row, from, to)) row++;
75
+ return row;
76
+ };
77
+ for (const span of ordered) {
78
+ if (rowOf.has(span.spanId)) continue;
79
+ claim(rowFor(span), span, false);
80
+ }
81
+ const rows = spansByRow.map((spans) => {
82
+ spans.sort((a, b) => a.start - b.start);
83
+ const maxEnd = new Float64Array(spans.length);
84
+ let running = Number.NEGATIVE_INFINITY;
85
+ for (let i = 0; i < spans.length; i++) {
86
+ const end = spanEnd(spans[i], duration);
87
+ if (end > running) running = end;
88
+ maxEnd[i] = running;
89
+ }
90
+ return {
91
+ spans,
92
+ maxEnd
93
+ };
94
+ });
95
+ return {
96
+ version: store.version,
97
+ rows,
98
+ rowOf,
99
+ duration,
100
+ ordered
101
+ };
102
+ };
103
+ var emptyLayout = () => EMPTY$1;
104
+ /**
105
+ * Index of the first span in `row` that can intersect `[from, to]`.
106
+ *
107
+ * Binary searches the prefix-maximum of end times: everything before the
108
+ * result ends strictly before `from`, so it is safely skipped. Returns
109
+ * `row.spans.length` when nothing intersects.
110
+ */
111
+ var firstVisible = (row, from) => {
112
+ let lo = 0;
113
+ let hi = row.spans.length;
114
+ while (lo < hi) {
115
+ const mid = lo + hi >>> 1;
116
+ if (row.maxEnd[mid] < from) lo = mid + 1;
117
+ else hi = mid;
118
+ }
119
+ return lo;
120
+ };
121
+ /**
122
+ * Walks the spans of `row` intersecting `[from, to]`, in start order.
123
+ *
124
+ * Stops as soon as a span starts after `to` — the row is start-sorted, so
125
+ * everything after it starts later still.
126
+ */
127
+ var forEachVisible = (row, from, to, now, visit) => {
128
+ for (let i = firstVisible(row, from); i < row.spans.length; i++) {
129
+ const span = row.spans[i];
130
+ if (span.start > to) return;
131
+ if (spanEnd(span, now) >= from) visit(span);
132
+ }
133
+ };
134
+ //#endregion
135
+ //#region app/src/chart/metrics.ts
136
+ /** Span timing derived the same way for the chart, the tooltip, the log and the drawer. */
137
+ /**
138
+ * Time covered by the union of `span`'s direct children, clipped to `[from, to]`.
139
+ *
140
+ * A **union**, not a sum: two children running concurrently for 10ms each
141
+ * occupy 10ms of their parent, not 20ms. Summing would make a heavily
142
+ * concurrent span's self time read as zero (or negative, and clamp to zero),
143
+ * which is exactly the number the aggregation tabs are built to show.
144
+ *
145
+ * Children are sorted by start and swept once, so this is O(k log k) in the
146
+ * number of direct children — not in the subtree, and never in the trace.
147
+ */
148
+ var childUnion = (store, span, now, from, to) => {
149
+ const intervals = [];
150
+ for (const id of span.children) {
151
+ const child = store.spans.get(id);
152
+ if (child === void 0) continue;
153
+ const lo = Math.max(child.start, from);
154
+ const hi = Math.min(spanEnd(child, now), to);
155
+ if (hi > lo) intervals.push([lo, hi]);
156
+ }
157
+ if (intervals.length === 0) return 0;
158
+ intervals.sort((a, b) => a[0] - b[0]);
159
+ let covered = 0;
160
+ let [runStart, runEnd] = intervals[0];
161
+ for (let i = 1; i < intervals.length; i++) {
162
+ const [lo, hi] = intervals[i];
163
+ if (lo > runEnd) {
164
+ covered += runEnd - runStart;
165
+ runStart = lo;
166
+ runEnd = hi;
167
+ } else if (hi > runEnd) runEnd = hi;
168
+ }
169
+ return covered + (runEnd - runStart);
170
+ };
171
+ /**
172
+ * Total and self time for a span, in millis.
173
+ *
174
+ * Self time is the span's own duration minus the time covered by its direct
175
+ * children. `from`/`to` clip both to a time window, which is how the
176
+ * aggregation tabs follow the viewport the way Chrome does: a span half inside
177
+ * the window contributes only its visible half, and only the child time that
178
+ * overlaps that half is subtracted.
179
+ */
180
+ var timings = (store, span, now, from = -Infinity, to = Infinity) => {
181
+ const lo = Math.max(span.start, from);
182
+ const hi = Math.min(spanEnd(span, now), to);
183
+ if (hi <= lo) return {
184
+ total: 0,
185
+ self: 0
186
+ };
187
+ return {
188
+ total: hi - lo,
189
+ self: Math.max(hi - lo - childUnion(store, span, now, lo, hi), 0)
190
+ };
191
+ };
192
+ //#endregion
193
+ //#region app/src/chart/selection.ts
194
+ /**
195
+ * The one selection model, shared by the flame chart and the event log.
196
+ *
197
+ * Both views read and write **these** atoms; neither holds a selection of its
198
+ * own that it then syncs. That is the difference between "selection-synced"
199
+ * and "two views of one value" — the latter cannot drift.
200
+ *
201
+ * These hold span **ids**, not spans: a span object is mutable and can be
202
+ * re-depthed by a late-arriving parent, so holding a reference would pin a
203
+ * stale depth. Both views resolve the id against `traceStore` at read time.
204
+ */
205
+ /** The clicked span, or `undefined`. Drives the detail panel and both views' highlight. */
206
+ var selectedSpanIdAtom = Atom.make(void 0);
207
+ /** The span under the cursor, or `undefined`. Written by whichever view is hovered. */
208
+ var hoveredSpanIdAtom = Atom.make(void 0);
209
+ /** Free-text span-name filter. Non-matching spans are dimmed in both views. */
210
+ var filterAtom = Atom.make("");
211
+ /** Whether the filter hides non-matching spans outright rather than dimming them. */
212
+ var filterHidesAtom = Atom.make(false);
213
+ /**
214
+ * Case-insensitive substring match, with an empty filter matching everything.
215
+ *
216
+ * Substring rather than fuzzy or regex: Chrome's own filter is a substring
217
+ * match, and a regex in a per-frame draw loop is a performance trap.
218
+ */
219
+ var matches = (name, filter) => filter === "" || name.toLowerCase().includes(filter);
220
+ /**
221
+ * Whether the memory track is collapsed away.
222
+ *
223
+ * Lives here rather than in the renderer because the toggle is DOM chrome and
224
+ * the track is canvas; an atom is the seam they already share. Collapsed means
225
+ * the track takes zero height, which is the same thing a session with no
226
+ * samples gets — so there is only one "no track" code path.
227
+ */
228
+ var memoryCollapsedAtom = Atom.make(false);
229
+ //#endregion
230
+ //#region app/src/chart/aggregate.ts
231
+ /**
232
+ * The drawer's aggregation trio: Summary, Bottom-up and Call tree.
233
+ *
234
+ * All three are presentations of one computation — **self time**, a span's
235
+ * duration minus the union of its children's (see `metrics.ts`) — so they are
236
+ * built here together from a single pass rather than three times over.
237
+ *
238
+ * Two rules they all share, both Chrome's:
239
+ * - **Aggregate the visible range, not the whole trace.** A span is clipped to
240
+ * the viewport, so a span half on screen contributes half its time.
241
+ * - **Group by name.** Two `db.query` spans are one row; individual spans are
242
+ * the flame chart's job.
243
+ *
244
+ * Rebuilt when the viewport, the filter or `store.version` changes — never per
245
+ * frame. The drawer is not in the chart's draw path at all.
246
+ */
247
+ var EMPTY = {
248
+ summary: [],
249
+ callTree: [],
250
+ bottomUp: [],
251
+ timings: []
252
+ };
253
+ var bucket = (id, name, spanId) => ({
254
+ id,
255
+ name,
256
+ count: 0,
257
+ total: 0,
258
+ self: 0,
259
+ failed: false,
260
+ spanId,
261
+ children: /* @__PURE__ */ new Map()
262
+ });
263
+ var add = (into, timing, self) => {
264
+ into.count += 1;
265
+ into.total += timing.total;
266
+ into.self += self;
267
+ if (timing.span.outcome?._tag === "Failure") into.failed = true;
268
+ };
269
+ /**
270
+ * Lookup-or-create a child, keyed by name so recursive calls merge into one node.
271
+ *
272
+ * Paths join segments with NUL: it cannot occur in a span name, so it cannot collide with one.
273
+ */
274
+ var descend = (children, path, name, spanId) => {
275
+ const existing = children.get(name);
276
+ if (existing !== void 0) return existing;
277
+ const made = bucket(path === "" ? name : `${path}\0${name}`, name, spanId);
278
+ children.set(name, made);
279
+ return made;
280
+ };
281
+ /** Heaviest first, by whichever measure the tree is ordered on. */
282
+ var freeze = (source, by) => [...source].sort((a, b) => b[by] - a[by] || a.name.localeCompare(b.name)).map((node) => ({
283
+ id: node.id,
284
+ name: node.name,
285
+ count: node.count,
286
+ total: node.total,
287
+ self: node.self,
288
+ failed: node.failed,
289
+ spanId: node.spanId,
290
+ children: freeze(node.children.values(), by)
291
+ }));
292
+ /**
293
+ * Builds all three views for the spans visible in `[from, to]`.
294
+ *
295
+ * `hides` mirrors the chart's "hide non-matching" toggle: set, a non-matching
296
+ * span is excluded from the aggregation entirely; clear, the filter only marks
297
+ * rows and every span still counts, so the numbers keep adding up to the trace.
298
+ */
299
+ var aggregate = (store, from, to, filter, hides) => {
300
+ const now = store.stats().duration;
301
+ const needle = filter.toLowerCase();
302
+ const visible = [];
303
+ for (const span of store.spans.values()) {
304
+ if (hides && !matches(span.name, needle)) continue;
305
+ const { total, self } = timings(store, span, now, from, to);
306
+ if (total <= 0) continue;
307
+ visible.push({
308
+ span,
309
+ total,
310
+ self
311
+ });
312
+ }
313
+ if (visible.length === 0) return EMPTY;
314
+ visible.sort((a, b) => a.span.start - b.span.start);
315
+ const byName = /* @__PURE__ */ new Map();
316
+ for (const timing of visible) add(descend(byName, "", timing.span.name, timing.span.spanId), timing, timing.self);
317
+ const summary = [...byName.values()].sort((a, b) => b.self - a.self || a.name.localeCompare(b.name)).map((row) => ({
318
+ name: row.name,
319
+ count: row.count,
320
+ total: row.total,
321
+ self: row.self,
322
+ average: row.total / row.count,
323
+ failed: row.failed,
324
+ spanId: row.spanId
325
+ }));
326
+ const included = new Set(visible.map((timing) => timing.span.spanId));
327
+ const chains = /* @__PURE__ */ new Map();
328
+ const chain = (span) => {
329
+ const cached = chains.get(span.spanId);
330
+ if (cached !== void 0) return cached;
331
+ const parentId = span.parentId;
332
+ const parent = parentId === void 0 ? void 0 : store.spans.get(parentId);
333
+ const built = parent === void 0 || !included.has(parent.spanId) ? [span] : [...chain(parent), span];
334
+ chains.set(span.spanId, built);
335
+ return built;
336
+ };
337
+ const callRoots = /* @__PURE__ */ new Map();
338
+ const bottomRoots = /* @__PURE__ */ new Map();
339
+ for (const timing of visible) {
340
+ const ancestry = chain(timing.span);
341
+ let node = descend(callRoots, "", ancestry[0].name, ancestry[0].spanId);
342
+ for (let i = 1; i < ancestry.length; i++) node = descend(node.children, node.id, ancestry[i].name, ancestry[i].spanId);
343
+ add(node, timing, timing.self);
344
+ let caller = descend(bottomRoots, "", timing.span.name, timing.span.spanId);
345
+ add(caller, timing, timing.self);
346
+ for (let i = ancestry.length - 2; i >= 0; i--) {
347
+ caller = descend(caller.children, caller.id, ancestry[i].name, ancestry[i].spanId);
348
+ add(caller, timing, timing.self);
349
+ }
350
+ }
351
+ return {
352
+ summary,
353
+ callTree: freeze(callRoots.values(), "total"),
354
+ bottomUp: freeze(bottomRoots.values(), "self"),
355
+ timings: visible
356
+ };
357
+ };
358
+ //#endregion
359
+ //#region src/protocol/Schema.ts
360
+ /**
361
+ * The effect-inspect wire protocol.
362
+ *
363
+ * Three unions: {@link ClientMessage} (instrumented program → collector),
364
+ * {@link CollectorMessage} (collector → instrumented program) and
365
+ * {@link WebappMessage} (collector → webapp).
366
+ *
367
+ * Informed by `effect/devtools/DevToolsSchema` but deliberately different:
368
+ * spans are delta-encoded as `SpanStart` / `SpanEnd` rather than a full span
369
+ * snapshot re-sent on end, every message carries a `sessionId`, and attribute
370
+ * values are a bounded {@link Json} union instead of `Schema.Any`.
371
+ */
372
+ /**
373
+ * Monotonic nanosecond timestamp, matching `Tracer.Span#startTime`.
374
+ *
375
+ * Carried as a decimal string on the wire because JSON numbers cannot hold
376
+ * nanos without precision loss.
377
+ */
378
+ var Timestamp = Schema.BigIntFromString;
379
+ var Json = Schema.Union([
380
+ Schema.String,
381
+ Schema.Finite,
382
+ Schema.Boolean,
383
+ Schema.Null,
384
+ Schema.Array(Schema.suspend(() => Json)),
385
+ Schema.Record(Schema.String, Schema.suspend(() => Json))
386
+ ]);
387
+ /** Span attributes, log annotations, and metric/event attributes. */
388
+ var Attributes = Schema.Record(Schema.String, Json);
389
+ /** Identifies one run of an instrumented program. */
390
+ var SessionId = Schema.String;
391
+ var sessionId = { sessionId: SessionId };
392
+ /** Mirrors `Tracer.SpanKind`. */
393
+ var SpanKind = Schema.Literals([
394
+ "internal",
395
+ "server",
396
+ "client",
397
+ "producer",
398
+ "consumer"
399
+ ]);
400
+ /** Mirrors `LogLevel.LogLevel`. */
401
+ var LogLevel = Schema.Literals([
402
+ "All",
403
+ "Fatal",
404
+ "Error",
405
+ "Warn",
406
+ "Info",
407
+ "Debug",
408
+ "Trace",
409
+ "None"
410
+ ]);
411
+ /**
412
+ * Wall-clock anchor for a session's monotonic clock.
413
+ *
414
+ * `startTime` is nanos from the same monotonic source as span times;
415
+ * `wallClockEpochMillis` is `Date.now()` sampled at the same instant. Together
416
+ * they let the webapp render absolute times and align sessions with each other.
417
+ */
418
+ var Clock = Schema.Struct({
419
+ startTime: Timestamp,
420
+ wallClockEpochMillis: Schema.Natural
421
+ });
422
+ /** First message on a client connection; opens the session. */
423
+ var Hello = Schema.Struct({
424
+ _tag: Schema.tag("Hello"),
425
+ ...sessionId,
426
+ program: Schema.String,
427
+ pid: Schema.Natural,
428
+ runtime: Schema.String,
429
+ protocolVersion: Schema.Natural,
430
+ clock: Clock
431
+ });
432
+ /**
433
+ * A span parent that lives outside this session's span tree — propagated from
434
+ * another tracing system, so the collector has ids but no span body.
435
+ */
436
+ var ExternalParent = Schema.Struct({
437
+ _tag: Schema.tag("ExternalParent"),
438
+ spanId: Schema.String,
439
+ traceId: Schema.String,
440
+ sampled: Schema.Boolean
441
+ });
442
+ /** A span parent within this session, referenced by id only. */
443
+ var LocalParent = Schema.Struct({
444
+ _tag: Schema.tag("LocalParent"),
445
+ spanId: Schema.String
446
+ });
447
+ var SpanParent = Schema.Union([LocalParent, ExternalParent]);
448
+ /**
449
+ * A span opened. Sent once, when the span starts — never re-sent.
450
+ *
451
+ * `parent` is absent for a root span.
452
+ */
453
+ var SpanStart = Schema.Struct({
454
+ _tag: Schema.tag("SpanStart"),
455
+ ...sessionId,
456
+ spanId: Schema.String,
457
+ traceId: Schema.String,
458
+ parent: Schema.optional(SpanParent),
459
+ name: Schema.String,
460
+ kind: SpanKind,
461
+ startTime: Timestamp,
462
+ attributes: Attributes,
463
+ sampled: Schema.Boolean,
464
+ fiberId: Schema.optional(Schema.Natural)
465
+ });
466
+ /**
467
+ * How a span finished.
468
+ *
469
+ * The full `Cause` is not carried: the webapp only needs to colour the span and
470
+ * show a message, so the producer flattens failures to `error` (rendered
471
+ * message) plus optional `stack`. `_tag: "Failure"` covers expected errors,
472
+ * defects and interrupts alike, distinguished by `kind`.
473
+ */
474
+ var SpanOutcomeSuccess = Schema.Struct({ _tag: Schema.tag("Success") });
475
+ var SpanOutcomeFailure = Schema.Struct({
476
+ _tag: Schema.tag("Failure"),
477
+ kind: Schema.Literals([
478
+ "Fail",
479
+ "Die",
480
+ "Interrupt"
481
+ ]),
482
+ error: Schema.String,
483
+ stack: Schema.optional(Schema.String)
484
+ });
485
+ var SpanOutcome = Schema.Union([SpanOutcomeSuccess, SpanOutcomeFailure]);
486
+ /**
487
+ * A span closed.
488
+ *
489
+ * `attributes` carries only attributes added after `SpanStart` was sent; the
490
+ * collector merges them over the ones it already has.
491
+ */
492
+ var SpanEnd = Schema.Struct({
493
+ _tag: Schema.tag("SpanEnd"),
494
+ ...sessionId,
495
+ spanId: Schema.String,
496
+ endTime: Timestamp,
497
+ outcome: SpanOutcome,
498
+ attributes: Attributes
499
+ });
500
+ /** A point-in-time event recorded against a span. */
501
+ var SpanEvent = Schema.Struct({
502
+ _tag: Schema.tag("SpanEvent"),
503
+ ...sessionId,
504
+ spanId: Schema.String,
505
+ name: Schema.String,
506
+ time: Timestamp,
507
+ attributes: Attributes
508
+ });
509
+ /** A log record, in the same ordered stream as spans. */
510
+ var Log = Schema.Struct({
511
+ _tag: Schema.tag("Log"),
512
+ ...sessionId,
513
+ time: Timestamp,
514
+ level: LogLevel,
515
+ message: Json,
516
+ spanId: Schema.optional(Schema.String),
517
+ fiberId: Schema.optional(Schema.Natural),
518
+ annotations: Attributes
519
+ });
520
+ var metric = (type, state) => Schema.Struct({
521
+ type: Schema.tag(type),
522
+ name: Schema.String,
523
+ description: Schema.optional(Schema.String),
524
+ attributes: Schema.Record(Schema.String, Schema.String),
525
+ state
526
+ });
527
+ var Counter = metric("Counter", Schema.Struct({
528
+ count: Schema.Natural,
529
+ incremental: Schema.Boolean
530
+ }));
531
+ var Gauge = metric("Gauge", Schema.Struct({ value: Schema.Finite }));
532
+ var Histogram = metric("Histogram", Schema.Struct({
533
+ buckets: Schema.Array(Schema.Tuple([Schema.Finite, Schema.Natural])),
534
+ count: Schema.Natural,
535
+ min: Schema.Finite,
536
+ max: Schema.Finite,
537
+ sum: Schema.Finite
538
+ }));
539
+ var Frequency = metric("Frequency", Schema.Struct({ occurrences: Schema.Record(Schema.String, Schema.Natural) }));
540
+ var Summary$1 = metric("Summary", Schema.Struct({
541
+ quantiles: Schema.Array(Schema.Tuple([Schema.Finite.check(Schema.isBetween({
542
+ minimum: 0,
543
+ maximum: 1
544
+ })), Schema.NullOr(Schema.Finite)])),
545
+ count: Schema.Natural,
546
+ min: Schema.Finite,
547
+ max: Schema.Finite,
548
+ sum: Schema.Finite
549
+ }));
550
+ var Metric = Schema.Union([
551
+ Counter,
552
+ Gauge,
553
+ Histogram,
554
+ Frequency,
555
+ Summary$1
556
+ ]);
557
+ /** A snapshot of every metric, taken at `time`. */
558
+ var Metrics = Schema.Struct({
559
+ _tag: Schema.tag("Metrics"),
560
+ ...sessionId,
561
+ time: Timestamp,
562
+ metrics: Schema.Array(Metric)
563
+ });
564
+ /** A fiber lifecycle transition. */
565
+ var FiberEvent = Schema.Struct({
566
+ _tag: Schema.tag("FiberEvent"),
567
+ ...sessionId,
568
+ fiberId: Schema.Natural,
569
+ event: Schema.Literals([
570
+ "Start",
571
+ "End",
572
+ "Suspend",
573
+ "Resume"
574
+ ]),
575
+ parentFiberId: Schema.optional(Schema.Natural),
576
+ time: Timestamp
577
+ });
578
+ /**
579
+ * A `process.memoryUsage()` reading, sampled on an interval by the client.
580
+ *
581
+ * Node and Bun only: a runtime without `process.memoryUsage` emits none of
582
+ * these and a session simply has no memory track. Figures are bytes, as the
583
+ * runtime reports them; `time` shares the monotonic base of span times, so the
584
+ * webapp can draw the curve against the same x-axis as the flame chart.
585
+ */
586
+ var MemorySample = Schema.Struct({
587
+ _tag: Schema.tag("MemorySample"),
588
+ ...sessionId,
589
+ time: Timestamp,
590
+ heapUsed: Schema.Natural,
591
+ heapTotal: Schema.Natural,
592
+ rss: Schema.Natural,
593
+ external: Schema.Natural
594
+ });
595
+ var Ping = Schema.Struct({
596
+ _tag: Schema.tag("Ping"),
597
+ ...sessionId
598
+ });
599
+ var Pong = Schema.Struct({
600
+ _tag: Schema.tag("Pong"),
601
+ ...sessionId
602
+ });
603
+ /** Asks the client for a {@link Metrics} snapshot. */
604
+ var MetricsRequest = Schema.Struct({ _tag: Schema.tag("MetricsRequest") });
605
+ /** Telemetry the instrumented program sends to the collector. */
606
+ var ClientMessage = Schema.Union([
607
+ Hello,
608
+ SpanStart,
609
+ SpanEnd,
610
+ SpanEvent,
611
+ Log,
612
+ Metrics,
613
+ FiberEvent,
614
+ MemorySample,
615
+ Ping
616
+ ]);
617
+ /** What the collector sends back to the instrumented program. */
618
+ var CollectorMessage = Schema.Union([Pong, MetricsRequest]);
619
+ /** A session the collector knows about, as listed to the webapp. */
620
+ var Session = Schema.Struct({
621
+ sessionId: SessionId,
622
+ program: Schema.String,
623
+ pid: Schema.Natural,
624
+ runtime: Schema.String,
625
+ clock: Clock,
626
+ active: Schema.Boolean,
627
+ endedAtEpochMillis: Schema.optional(Schema.Natural)
628
+ });
629
+ /** Every session the collector holds. Sent on connect and on change. */
630
+ var SessionList = Schema.Struct({
631
+ _tag: Schema.tag("SessionList"),
632
+ sessions: Schema.Array(Session)
633
+ });
634
+ /**
635
+ * Everything already recorded for a session, replayed in arrival order.
636
+ *
637
+ * Sent once when the webapp subscribes, before any live telemetry for that
638
+ * session. `complete` is false when the backlog is split across several
639
+ * messages, so the webapp knows more is coming.
640
+ */
641
+ var Backlog = Schema.Struct({
642
+ _tag: Schema.tag("Backlog"),
643
+ ...sessionId,
644
+ messages: Schema.Array(ClientMessage),
645
+ complete: Schema.Boolean
646
+ });
647
+ /** A live client message forwarded to the webapp as it arrives. */
648
+ var Live = Schema.Struct({
649
+ _tag: Schema.tag("Live"),
650
+ message: ClientMessage
651
+ });
652
+ /**
653
+ * A session ended — its program exited or its connection dropped.
654
+ *
655
+ * **Nothing emits this.** The collector marks the session `active: false` and
656
+ * re-sends the whole {@link SessionList} on every change, which already tells
657
+ * the webapp everything this message would. The variant is kept because the
658
+ * protocol is extended additively and removing it would break the union for
659
+ * anyone decoding an older stream; the webapp handler for it was deleted
660
+ * rather than left looking implemented.
661
+ */
662
+ var SessionEnded = Schema.Struct({
663
+ _tag: Schema.tag("SessionEnded"),
664
+ ...sessionId,
665
+ endedAtEpochMillis: Schema.Natural
666
+ });
667
+ /** What the collector sends to the webapp. */
668
+ var WebappMessage = Schema.Union([
669
+ SessionList,
670
+ Backlog,
671
+ Live,
672
+ SessionEnded
673
+ ]);
674
+ /** Asks the collector to stream one session: its backlog, then live messages. */
675
+ var Subscribe = Schema.Struct({
676
+ _tag: Schema.tag("Subscribe"),
677
+ ...sessionId
678
+ });
679
+ /** Stops the stream started by {@link Subscribe}. */
680
+ var Unsubscribe = Schema.Struct({
681
+ _tag: Schema.tag("Unsubscribe"),
682
+ ...sessionId
683
+ });
684
+ /** What the webapp sends to the collector. */
685
+ var WebappRequest = Schema.Union([Subscribe, Unsubscribe]);
686
+ /**
687
+ * Line 1 of a saved trace file.
688
+ *
689
+ * The rest of the file is {@link ClientMessage} NDJSON, byte-identical to what
690
+ * the wire carries — a trace file is the protocol message stream with a header
691
+ * on top, not a second representation of a trace. So any new `ClientMessage`
692
+ * variant is carried by a saved trace for free, and {@link traceFileFormatVersion}
693
+ * only moves if *this* struct or the line layout changes.
694
+ *
695
+ * The full {@link Session} is embedded because a loaded trace has no
696
+ * `SessionList` to get its `clock` from, and without the clock the webapp
697
+ * cannot show absolute times.
698
+ */
699
+ var TraceFileHeader = Schema.Struct({
700
+ _tag: Schema.tag("TraceFileHeader"),
701
+ formatVersion: Schema.Natural,
702
+ /** {@link protocolVersion} at save time. Recorded for diagnosis, not enforced. */
703
+ protocolVersion: Schema.Natural,
704
+ session: Session,
705
+ savedAtEpochMillis: Schema.Natural
706
+ });
707
+ //#endregion
708
+ //#region src/protocol/Codec.ts
709
+ /**
710
+ * NDJSON codec for the protocol unions.
711
+ *
712
+ * The whole encoding lives behind {@link Codec}: one line of JSON per message,
713
+ * `\n`-terminated. Swapping in a compact binary format later means another
714
+ * `make`-shaped factory, not touching callers.
715
+ */
716
+ /** Raised when a message cannot be encoded — an out-of-domain field value. */
717
+ var EncodeError = class extends Data.TaggedError("EncodeError") {
718
+ get message() {
719
+ return `Unencodable protocol message: ${this.reason}`;
720
+ }
721
+ };
722
+ /** Raised when a line is not valid JSON, or not a valid message. */
723
+ var DecodeError = class extends Data.TaggedError("DecodeError") {
724
+ get message() {
725
+ return `Invalid protocol message: ${this.reason}`;
726
+ }
727
+ };
728
+ var make = (schema) => {
729
+ const json = Schema.fromJsonString(schema);
730
+ const encodeJson = Schema.encodeResult(json);
731
+ const decodeJson = Schema.decodeUnknownResult(json);
732
+ const encodeResult = (message) => Result.match(encodeJson(message), {
733
+ onSuccess: (line) => Result.succeed(`${line}\n`),
734
+ onFailure: (error) => Result.fail(new EncodeError({ reason: error.message }))
735
+ });
736
+ const decode = (line) => Result.mapError(decodeJson(line.trim()), (error) => new DecodeError({
737
+ line,
738
+ reason: error.message
739
+ }));
740
+ return {
741
+ encode: (message) => Result.getOrThrow(encodeResult(message)),
742
+ encodeResult,
743
+ decode,
744
+ decodeAll: (chunk) => Result.all(chunk.split("\n").filter((line) => line.trim() !== "").map(decode))
745
+ };
746
+ };
747
+ /** Instrumented program → collector. */
748
+ var clientCodec = make(ClientMessage);
749
+ make(CollectorMessage);
750
+ /** Collector → webapp. */
751
+ var webappCodec = make(WebappMessage);
752
+ /** Webapp → collector. */
753
+ var webappRequestCodec = make(WebappRequest);
754
+ /** Line 1 of a saved trace file; the rest of the file is {@link clientCodec} lines. */
755
+ var traceFileHeaderCodec = make(TraceFileHeader);
756
+ //#endregion
757
+ //#region app/src/trace/TraceFile.ts
758
+ /**
759
+ * Saving a trace to a file and loading it back.
760
+ *
761
+ * The file **is** the protocol message stream: line 1 is a
762
+ * {@link Protocol.TraceFileHeader}, every line after it is a `ClientMessage`
763
+ * exactly as `clientCodec` writes it on the wire. So a new protocol message
764
+ * variant is carried by a saved trace for free — the format version only moves
765
+ * if the header or the line layout changes, never because the protocol grew.
766
+ *
767
+ * Parsing is deliberately lenient about the *tail* and strict about the
768
+ * *head*: a header that will not decode means this is not a trace file and
769
+ * there is nothing to show, while a truncated last line means the writer died
770
+ * mid-save and everything before it is still a real trace worth rendering.
771
+ */
772
+ /** File extension for a saved trace. */
773
+ var traceFileExtension = ".eitrace";
774
+ var fail = (message) => Result.fail({
775
+ _tag: "TraceFileError",
776
+ message
777
+ });
778
+ /**
779
+ * Prefix on a loaded session's id.
780
+ *
781
+ * Without it, loading a trace exported from the collector you are currently
782
+ * connected to would collide with the live session of the same id and the two
783
+ * would fight over the selection.
784
+ */
785
+ var loadedSessionPrefix = "loaded:";
786
+ /** True for a session id produced by {@link parseTraceFile}. */
787
+ var isLoadedSession = (sessionId) => sessionId.startsWith(loadedSessionPrefix);
788
+ /**
789
+ * Serializes a session and its messages to trace-file text.
790
+ *
791
+ * `messages` is the raw protocol stream in arrival order — not a re-derivation
792
+ * from the rendered trace model, which would silently drop every message the
793
+ * model does not draw.
794
+ */
795
+ var serializeTraceFile = (session, messages, savedAtEpochMillis) => {
796
+ const header = traceFileHeaderCodec.encode({
797
+ _tag: "TraceFileHeader",
798
+ formatVersion: 1,
799
+ protocolVersion: 1,
800
+ session,
801
+ savedAtEpochMillis
802
+ });
803
+ const body = [];
804
+ for (const message of messages) body.push(clientCodec.encode(message));
805
+ return header + body.join("");
806
+ };
807
+ /** A filename safe on every platform, carrying the program and save time. */
808
+ var traceFileName = (session, savedAtEpochMillis) => {
809
+ return `${(session.program.split(/[/\\]/).pop() ?? "trace").replace(/[^\w.-]+/g, "-").replace(/^-+|-+$/g, "") || "trace"}-${new Date(savedAtEpochMillis).toISOString().replace(/[:.]/g, "-").slice(0, 19)}${traceFileExtension}`;
810
+ };
811
+ /**
812
+ * Parses trace-file text.
813
+ *
814
+ * Fails only when the file is not a trace file at all — an unreadable header,
815
+ * a format version from the future, or a body whose *interior* is corrupt. A
816
+ * bad line that is not the last one means the file was edited or mangled, not
817
+ * merely cut short, and rendering a trace with a hole in the middle would be a
818
+ * lie; a bad final line is reported as {@link LoadedTrace.truncatedLines}.
819
+ */
820
+ var parseTraceFile = (text) => {
821
+ const lines = text.split("\n");
822
+ const headerLine = lines[0];
823
+ if (headerLine === void 0 || headerLine.trim() === "") return fail("The file is empty.");
824
+ const headerResult = traceFileHeaderCodec.decode(headerLine);
825
+ if (Result.isFailure(headerResult)) return fail("This is not an effect-inspect trace file: its header could not be read.");
826
+ const header = headerResult.success;
827
+ if (header.formatVersion > 1) return fail(`This trace file is format version ${header.formatVersion}; this build understands up to 1.`);
828
+ const messages = [];
829
+ let truncatedLines = 0;
830
+ for (let index = 1; index < lines.length; index++) {
831
+ const line = lines[index];
832
+ if (line.trim() === "") continue;
833
+ const decoded = clientCodec.decode(line);
834
+ if (Result.isSuccess(decoded)) {
835
+ messages.push(decoded.success);
836
+ continue;
837
+ }
838
+ if (!lines.slice(index + 1).every((rest) => rest.trim() === "")) return fail(`This trace file is corrupt: line ${index + 1} could not be read.`);
839
+ truncatedLines = 1;
840
+ }
841
+ return Result.succeed({
842
+ header,
843
+ messages,
844
+ truncatedLines
845
+ });
846
+ };
847
+ /** The `Session` a loaded trace lists as: never active, id namespaced so it cannot collide. */
848
+ var loadedSession = (header) => ({
849
+ ...header.session,
850
+ sessionId: isLoadedSession(header.session.sessionId) ? header.session.sessionId : `${loadedSessionPrefix}${header.session.sessionId}`,
851
+ active: false,
852
+ endedAtEpochMillis: header.session.endedAtEpochMillis ?? header.savedAtEpochMillis
853
+ });
854
+ //#endregion
855
+ //#region app/src/trace/TraceStore.ts
856
+ var NANOS_PER_MILLI = 1000000n;
857
+ /**
858
+ * Converts protocol nanos to milliseconds relative to `origin`, as a `number`.
859
+ *
860
+ * The subtraction happens in `bigint` so it stays exact, and only the
861
+ * (small, relative) result is narrowed to `number` — narrowing the absolute
862
+ * nanos first would lose precision well before it got here.
863
+ */
864
+ var toRelativeMillis = (time, origin) => Number((time - origin) / NANOS_PER_MILLI) + Number((time - origin) % NANOS_PER_MILLI) / 1e6;
865
+ /**
866
+ * Mutable span index for one session.
867
+ *
868
+ * Ingest is `apply`, one protocol message at a time, in arrival order. Reads
869
+ * are direct field access — `spans`, `roots` and `rows` are live structures,
870
+ * not copies, so the renderer must treat them as read-only and must re-read
871
+ * them (not cache them) whenever {@link version} changes.
872
+ */
873
+ var TraceStore = class {
874
+ /** Every span seen, by span id. Includes spans that are still open. */
875
+ spans = /* @__PURE__ */ new Map();
876
+ /** Root span ids in arrival order — the renderer's entry points. */
877
+ roots = [];
878
+ /** Span ids that have no `SpanEnd` yet, so the renderer can draw them open-ended. */
879
+ openSpans = /* @__PURE__ */ new Set();
880
+ /** Logs in arrival order. */
881
+ logs = [];
882
+ /**
883
+ * Memory samples in arrival order, which is also time order.
884
+ *
885
+ * A plain array rather than anything indexed: the track draws the whole
886
+ * series each frame by walking it once, and at the client's 100ms interval a
887
+ * ten-minute trace is 6,000 entries — a scan the renderer does not notice.
888
+ * ponytail: linear scan, swap for a binary search into the viewport if a
889
+ * trace ever runs long enough for it to show up in a frame budget.
890
+ */
891
+ memory = [];
892
+ /**
893
+ * Largest `heapUsed` seen — the memory track's y-axis top, kept here so the
894
+ * renderer never re-scans the series to scale a frame.
895
+ */
896
+ memoryPeak = 0;
897
+ /** Smallest `heapUsed` seen — the memory track's y-axis floor. */
898
+ memoryTrough = Number.POSITIVE_INFINITY;
899
+ /** Largest `rss` seen; the secondary line has its own scale. */
900
+ memoryRssPeak = 0;
901
+ /**
902
+ * Every message ingested, in arrival order — the source for saving to a file.
903
+ *
904
+ * The rendered model above is lossy on purpose (relative millis, merged
905
+ * attributes, `Metrics`/`FiberEvent` dropped), so a file written from it
906
+ * would quietly lose whatever the chart does not draw. Keeping the decoded
907
+ * messages costs one array slot each — they are already allocated — and
908
+ * makes save a copy rather than a re-derivation.
909
+ */
910
+ raw = [];
911
+ /**
912
+ * Span ids bucketed by depth: `rows[2]` is every span nested two levels deep.
913
+ *
914
+ * This is the flame chart's row layout. It is maintained incrementally on
915
+ * ingest so the renderer never has to traverse the tree to find a row, and
916
+ * so drawing a viewport means scanning only the rows it covers.
917
+ */
918
+ rows = [];
919
+ /**
920
+ * Spans waiting on a parent that has not arrived, keyed by the missing
921
+ * parent id.
922
+ *
923
+ * A child can legitimately precede its parent: the backlog preserves arrival
924
+ * order, and a parent's `SpanStart` is emitted when it opens, which a
925
+ * concurrent fiber's child can beat to the wire. Rather than drop such a
926
+ * span, it is parked here and re-linked when the parent shows up.
927
+ */
928
+ pendingChildren = /* @__PURE__ */ new Map();
929
+ /** Monotonic nanos of the first event seen; the zero point for `start`/`end`. */
930
+ origin;
931
+ /** Wall-clock millis matching {@link origin}, from the session's `Hello`. */
932
+ epochOrigin;
933
+ /**
934
+ * Bumped on every mutation.
935
+ *
936
+ * This is the *only* value React is allowed to observe. The renderer polls
937
+ * it per frame to decide whether to redraw; the UI mirrors it into an atom
938
+ * on a timer so counters update without a render per span.
939
+ */
940
+ version = 0;
941
+ spanCount = 0;
942
+ errorCount = 0;
943
+ eventCount = 0;
944
+ maxTime = 0;
945
+ /** Snapshot of the counters — allocates, so call it per repaint, not per span. */
946
+ stats() {
947
+ return {
948
+ spans: this.spanCount,
949
+ openSpans: this.openSpans.size,
950
+ errors: this.errorCount,
951
+ logs: this.logs.length,
952
+ events: this.eventCount,
953
+ duration: this.maxTime
954
+ };
955
+ }
956
+ /** Drops everything — used when switching sessions. */
957
+ clear() {
958
+ this.spans.clear();
959
+ this.roots.length = 0;
960
+ this.openSpans.clear();
961
+ this.logs.length = 0;
962
+ this.memory.length = 0;
963
+ this.memoryPeak = 0;
964
+ this.memoryTrough = Number.POSITIVE_INFINITY;
965
+ this.memoryRssPeak = 0;
966
+ this.raw.length = 0;
967
+ this.rows.length = 0;
968
+ this.pendingChildren.clear();
969
+ this.origin = void 0;
970
+ this.epochOrigin = void 0;
971
+ this.spanCount = 0;
972
+ this.errorCount = 0;
973
+ this.eventCount = 0;
974
+ this.maxTime = 0;
975
+ this.version++;
976
+ }
977
+ /** Ingests one client message. Unknown/undrawn variants are ignored, not errors. */
978
+ apply(message) {
979
+ this.raw.push(message);
980
+ switch (message._tag) {
981
+ case "Hello":
982
+ this.anchor(message.clock.startTime);
983
+ this.epochOrigin = message.clock.wallClockEpochMillis;
984
+ break;
985
+ case "SpanStart":
986
+ this.applySpanStart(message);
987
+ break;
988
+ case "SpanEnd":
989
+ this.applySpanEnd(message);
990
+ break;
991
+ case "SpanEvent":
992
+ this.applySpanEvent(message);
993
+ break;
994
+ case "Log":
995
+ this.applyLog(message);
996
+ break;
997
+ case "MemorySample":
998
+ this.applyMemorySample(message);
999
+ break;
1000
+ default: return;
1001
+ }
1002
+ this.version++;
1003
+ }
1004
+ /** Ingests a batch, bumping `version` once rather than per message. */
1005
+ applyAll(messages) {
1006
+ const before = this.version;
1007
+ for (const message of messages) this.apply(message);
1008
+ this.version = before + 1;
1009
+ }
1010
+ anchor(time) {
1011
+ this.origin ??= time;
1012
+ }
1013
+ relative(time) {
1014
+ this.anchor(time);
1015
+ const value = toRelativeMillis(time, this.origin);
1016
+ if (value > this.maxTime) this.maxTime = value;
1017
+ return value;
1018
+ }
1019
+ applySpanStart(message) {
1020
+ if (this.spans.has(message.spanId)) return;
1021
+ const parentId = message.parent?._tag === "LocalParent" ? message.parent.spanId : void 0;
1022
+ const parent = parentId === void 0 ? void 0 : this.spans.get(parentId);
1023
+ const span = {
1024
+ spanId: message.spanId,
1025
+ traceId: message.traceId,
1026
+ name: message.name,
1027
+ kind: message.kind,
1028
+ parentId,
1029
+ start: this.relative(message.startTime),
1030
+ end: void 0,
1031
+ depth: parent === void 0 ? 0 : parent.depth + 1,
1032
+ outcome: void 0,
1033
+ attributes: { ...message.attributes },
1034
+ children: [],
1035
+ events: [],
1036
+ fiberId: message.fiberId,
1037
+ orphaned: parentId !== void 0 && parent === void 0
1038
+ };
1039
+ this.spans.set(span.spanId, span);
1040
+ this.openSpans.add(span.spanId);
1041
+ this.spanCount++;
1042
+ if (parent !== void 0) parent.children.push(span.spanId);
1043
+ else if (parentId === void 0) this.roots.push(span.spanId);
1044
+ else {
1045
+ const pending = this.pendingChildren.get(parentId);
1046
+ if (pending === void 0) this.pendingChildren.set(parentId, [span.spanId]);
1047
+ else pending.push(span.spanId);
1048
+ }
1049
+ this.addToRow(span);
1050
+ this.adoptPending(span);
1051
+ }
1052
+ /** Re-links children that arrived before this span did. */
1053
+ adoptPending(parent) {
1054
+ const pending = this.pendingChildren.get(parent.spanId);
1055
+ if (pending === void 0) return;
1056
+ this.pendingChildren.delete(parent.spanId);
1057
+ for (const childId of pending) {
1058
+ const child = this.spans.get(childId);
1059
+ if (child === void 0) continue;
1060
+ parent.children.push(childId);
1061
+ child.orphaned = false;
1062
+ this.redepth(child, parent.depth + 1);
1063
+ }
1064
+ }
1065
+ /**
1066
+ * Moves a subtree to a new depth after a late parent arrives.
1067
+ *
1068
+ * Iterative rather than recursive: a deeply-nested Effect program can stack
1069
+ * hundreds of spans and a blown call stack during ingest would take the
1070
+ * whole webapp down.
1071
+ */
1072
+ redepth(root, depth) {
1073
+ const stack = [[root, depth]];
1074
+ while (stack.length > 0) {
1075
+ const next = stack.pop();
1076
+ if (next === void 0) break;
1077
+ const [span, spanDepth] = next;
1078
+ if (span.depth !== spanDepth) {
1079
+ this.removeFromRow(span);
1080
+ span.depth = spanDepth;
1081
+ this.addToRow(span);
1082
+ }
1083
+ for (const childId of span.children) {
1084
+ const child = this.spans.get(childId);
1085
+ if (child !== void 0) stack.push([child, spanDepth + 1]);
1086
+ }
1087
+ }
1088
+ }
1089
+ addToRow(span) {
1090
+ while (this.rows.length <= span.depth) this.rows.push([]);
1091
+ this.rows[span.depth].push(span.spanId);
1092
+ }
1093
+ removeFromRow(span) {
1094
+ const row = this.rows[span.depth];
1095
+ if (row === void 0) return;
1096
+ const index = row.indexOf(span.spanId);
1097
+ if (index !== -1) row.splice(index, 1);
1098
+ }
1099
+ applySpanEnd(message) {
1100
+ const span = this.spans.get(message.spanId);
1101
+ if (span === void 0) return;
1102
+ span.end = this.relative(message.endTime);
1103
+ span.outcome = message.outcome;
1104
+ if (Object.keys(message.attributes).length > 0) span.attributes = {
1105
+ ...span.attributes,
1106
+ ...message.attributes
1107
+ };
1108
+ if (this.openSpans.delete(message.spanId) && message.outcome._tag === "Failure") this.errorCount++;
1109
+ }
1110
+ applySpanEvent(message) {
1111
+ const span = this.spans.get(message.spanId);
1112
+ if (span === void 0) return;
1113
+ span.events.push({
1114
+ name: message.name,
1115
+ time: this.relative(message.time),
1116
+ attributes: message.attributes
1117
+ });
1118
+ this.eventCount++;
1119
+ }
1120
+ applyMemorySample(message) {
1121
+ this.memory.push({
1122
+ time: this.relative(message.time),
1123
+ heapUsed: message.heapUsed,
1124
+ heapTotal: message.heapTotal,
1125
+ rss: message.rss,
1126
+ external: message.external
1127
+ });
1128
+ if (message.heapUsed > this.memoryPeak) this.memoryPeak = message.heapUsed;
1129
+ if (message.heapUsed < this.memoryTrough) this.memoryTrough = message.heapUsed;
1130
+ if (message.rss > this.memoryRssPeak) this.memoryRssPeak = message.rss;
1131
+ }
1132
+ applyLog(message) {
1133
+ this.logs.push({
1134
+ time: this.relative(message.time),
1135
+ level: message.level,
1136
+ message: message.message,
1137
+ spanId: message.spanId,
1138
+ fiberId: message.fiberId,
1139
+ annotations: message.annotations
1140
+ });
1141
+ }
1142
+ };
1143
+ //#endregion
1144
+ //#region app/src/state/atoms.ts
1145
+ /**
1146
+ * Application state.
1147
+ *
1148
+ * The split here is the whole design: **atoms hold what React renders**
1149
+ * (connection status, the session list, which session is selected, a repaint
1150
+ * tick) and the {@link TraceStore} holds **what the canvas renders** (every
1151
+ * span). Nothing per-span is ever put in an atom — a 10k-span trace would mean
1152
+ * 10k subscriptions and a React render per arriving span, which is exactly the
1153
+ * stutter the spec forbids.
1154
+ *
1155
+ * The bridge between the two is {@link traceVersionAtom}: the socket writes
1156
+ * spans straight into the store and the store's version is sampled on an
1157
+ * animation frame, so the header counters stay live while the span data path
1158
+ * never touches React.
1159
+ */
1160
+ /**
1161
+ * Where the collector listens.
1162
+ *
1163
+ * The `/webapp` path matters: the collector routes both roles on one port by
1164
+ * request path, and anything that is not `/webapp` is treated as an
1165
+ * instrumented program — which would get no `SessionList` at all. See
1166
+ * `webappPath` in `src/collector/Server.ts`.
1167
+ *
1168
+ * `VITE_COLLECTOR_URL` overrides it, so a second collector on a non-default
1169
+ * `EFFECT_INSPECT_PORT` can be inspected without editing source.
1170
+ */
1171
+ var COLLECTOR_URL = typeof window === "undefined" ? "ws://localhost:34437/webapp" : `${window.location.protocol === "https:" ? "wss:" : "ws:"}//${window.location.host}/webapp`;
1172
+ /**
1173
+ * The span store for the currently selected session.
1174
+ *
1175
+ * A single long-lived instance rather than one per session: switching sessions
1176
+ * calls `clear()`, so the renderer can hold one stable reference for the life
1177
+ * of the page instead of re-acquiring it whenever selection changes.
1178
+ */
1179
+ var traceStore = new TraceStore();
1180
+ /** Current connection status. Written by {@link connectionAtom}. */
1181
+ var connectionStatusAtom = Atom.make({ _tag: "Connecting" });
1182
+ /** Every session the collector knows about, newest first. */
1183
+ var liveSessionsAtom = Atom.make([]);
1184
+ /**
1185
+ * Traces loaded from files this page load, newest first.
1186
+ *
1187
+ * Held in an atom rather than a module-level array because the session list
1188
+ * renders from it; the *messages* are not per-span React state — they are
1189
+ * replayed into {@link traceStore} in one `applyAll` on selection and never
1190
+ * read by React again.
1191
+ */
1192
+ var loadedSessionsAtom = Atom.make([]);
1193
+ /**
1194
+ * Live and loaded sessions in one list, loaded first.
1195
+ *
1196
+ * Loaded traces sort above live ones so a file you just opened is where you
1197
+ * are looking, rather than buried under whatever the collector is holding.
1198
+ */
1199
+ var sessionsAtom = Atom.readable((get) => [...get(loadedSessionsAtom).map((loaded) => loaded.session), ...get(liveSessionsAtom)]);
1200
+ /** The selected session id, or `undefined` when nothing is selected. */
1201
+ var selectedSessionIdAtom = Atom.make(void 0);
1202
+ /**
1203
+ * Sampled copy of `traceStore.version`, so components can re-render on trace
1204
+ * change without subscribing to the trace itself.
1205
+ *
1206
+ * Updated once per animation frame while data is arriving (see
1207
+ * {@link connectionAtom}), which caps the UI at one render per frame no matter
1208
+ * how many spans land in between.
1209
+ */
1210
+ var traceVersionAtom = Atom.make(0);
1211
+ /** Count of protocol lines the webapp could not decode; surfaced in the header. */
1212
+ var decodeErrorsAtom = Atom.make(0);
1213
+ /** The selected session's metadata, derived from the list and the selection. */
1214
+ var selectedSessionAtom = Atom.readable((get) => {
1215
+ const id = get(selectedSessionIdAtom);
1216
+ if (id === void 0) return void 0;
1217
+ return get(sessionsAtom).find((session) => session.sessionId === id);
1218
+ });
1219
+ /**
1220
+ * Wall-clock epoch millis corresponding to `traceStore.origin`.
1221
+ *
1222
+ * `TraceStore.epochOrigin` is permanently `undefined` in practice: the
1223
+ * collector does not append `Hello` to the session ring, so the store's `Hello`
1224
+ * case never fires. The anchor is plumbed from the `Session` record instead,
1225
+ * which carries the same `clock` and is already in hand — appending `Hello`
1226
+ * would change the collector's retention semantics for one timestamp.
1227
+ *
1228
+ * The store's origin is the first *observed* event, not the session start, so
1229
+ * the session clock has to be shifted by the gap between them.
1230
+ */
1231
+ var wallClockOriginAtom = Atom.readable((get) => {
1232
+ get(traceVersionAtom);
1233
+ const session = get(selectedSessionAtom);
1234
+ if (session === void 0 || traceStore.origin === void 0) return void 0;
1235
+ const offsetNanos = traceStore.origin - session.clock.startTime;
1236
+ return session.clock.wallClockEpochMillis + Number(offsetNanos / 1000000n);
1237
+ });
1238
+ /** Live counters for the header, recomputed only when the sampled version changes. */
1239
+ var traceStatsAtom = Atom.readable((get) => {
1240
+ get(traceVersionAtom);
1241
+ return traceStore.stats();
1242
+ });
1243
+ /** Backoff for reconnect attempts, capped so a long-down collector still retries. */
1244
+ var retryDelay = (attempt) => Math.min(1e3 * 2 ** attempt, 1e4);
1245
+ /**
1246
+ * Owns the collector WebSocket for as long as it is mounted.
1247
+ *
1248
+ * Written as a `keepAlive` atom rather than a `useEffect` so the connection
1249
+ * survives component remounts and React strict-mode double-invocation, and so
1250
+ * the socket's writes go through the registry — the same path a component
1251
+ * write takes — instead of a side channel.
1252
+ *
1253
+ * Reads as `void`: the value of this atom is its side effect. Components mount
1254
+ * it with `useAtomMount` and read status from {@link connectionStatusAtom}.
1255
+ */
1256
+ var connectionAtom = Atom.keepAlive(Atom.readable((ctx) => {
1257
+ let socket;
1258
+ let retry;
1259
+ let frame;
1260
+ let attempt = 0;
1261
+ let closed = false;
1262
+ /** The session this socket is subscribed to, so we can unsubscribe on switch. */
1263
+ let subscribed;
1264
+ /**
1265
+ * Mirrors the store's version into an atom on the next frame.
1266
+ *
1267
+ * Coalesced: many messages within one frame schedule a single write, so a
1268
+ * burst of a thousand spans costs one React render, not a thousand.
1269
+ */
1270
+ const scheduleRepaint = () => {
1271
+ if (frame !== void 0) return;
1272
+ frame = requestAnimationFrame(() => {
1273
+ frame = void 0;
1274
+ ctx.set(traceVersionAtom, traceStore.version);
1275
+ });
1276
+ };
1277
+ const send = (request) => {
1278
+ if (socket?.readyState === WebSocket.OPEN) socket.send(webappRequestCodec.encode(request));
1279
+ };
1280
+ /**
1281
+ * Points the store at whichever session is selected.
1282
+ *
1283
+ * A live session means subscribing to the collector; a loaded one means
1284
+ * replaying its file's messages straight into the store. Both paths clear
1285
+ * the store first, because it is shared across sessions.
1286
+ */
1287
+ const syncSubscription = () => {
1288
+ const next = ctx.get(selectedSessionIdAtom);
1289
+ if (next === subscribed) return;
1290
+ if (subscribed !== void 0 && !isLoadedSession(subscribed)) send({
1291
+ _tag: "Unsubscribe",
1292
+ sessionId: subscribed
1293
+ });
1294
+ subscribed = next;
1295
+ traceStore.clear();
1296
+ scheduleRepaint();
1297
+ if (next === void 0) return;
1298
+ if (isLoadedSession(next)) {
1299
+ const loaded = ctx.get(loadedSessionsAtom).find((entry) => entry.session.sessionId === next);
1300
+ if (loaded !== void 0) traceStore.applyAll(loaded.messages);
1301
+ scheduleRepaint();
1302
+ return;
1303
+ }
1304
+ send({
1305
+ _tag: "Subscribe",
1306
+ sessionId: next
1307
+ });
1308
+ };
1309
+ const handle = (message) => {
1310
+ switch (message._tag) {
1311
+ case "SessionList": {
1312
+ const sessions = [...message.sessions].sort((a, b) => b.clock.wallClockEpochMillis - a.clock.wallClockEpochMillis);
1313
+ ctx.set(liveSessionsAtom, sessions);
1314
+ if (ctx.get(selectedSessionIdAtom) === void 0 && sessions.length > 0) ctx.set(selectedSessionIdAtom, sessions[0].sessionId);
1315
+ break;
1316
+ }
1317
+ case "Backlog":
1318
+ if (message.sessionId !== subscribed) return;
1319
+ traceStore.applyAll(message.messages);
1320
+ scheduleRepaint();
1321
+ break;
1322
+ case "Live":
1323
+ if (message.message.sessionId !== subscribed) return;
1324
+ traceStore.apply(message.message);
1325
+ scheduleRepaint();
1326
+ }
1327
+ };
1328
+ const connect = () => {
1329
+ if (closed) return;
1330
+ ctx.set(connectionStatusAtom, { _tag: "Connecting" });
1331
+ const ws = new WebSocket(COLLECTOR_URL);
1332
+ socket = ws;
1333
+ ws.onopen = () => {
1334
+ attempt = 0;
1335
+ ctx.set(connectionStatusAtom, { _tag: "Connected" });
1336
+ if (subscribed !== void 0 && isLoadedSession(subscribed)) return;
1337
+ subscribed = void 0;
1338
+ syncSubscription();
1339
+ };
1340
+ ws.onmessage = (event) => {
1341
+ if (typeof event.data !== "string") return;
1342
+ const messages = decodeFrame(event.data);
1343
+ if (messages === void 0) {
1344
+ ctx.set(decodeErrorsAtom, ctx.get(decodeErrorsAtom) + 1);
1345
+ return;
1346
+ }
1347
+ for (const message of messages) handle(message);
1348
+ };
1349
+ ws.onerror = () => {};
1350
+ ws.onclose = () => {
1351
+ if (closed) return;
1352
+ socket = void 0;
1353
+ if (subscribed === void 0 || !isLoadedSession(subscribed)) subscribed = void 0;
1354
+ const delay = retryDelay(attempt);
1355
+ ctx.set(connectionStatusAtom, {
1356
+ _tag: "Disconnected",
1357
+ reason: `Cannot reach the collector at ${COLLECTOR_URL}`,
1358
+ attempt
1359
+ });
1360
+ attempt++;
1361
+ retry = setTimeout(connect, delay);
1362
+ };
1363
+ };
1364
+ ctx.subscribe(selectedSessionIdAtom, () => syncSubscription());
1365
+ ctx.addFinalizer(() => {
1366
+ closed = true;
1367
+ if (retry !== void 0) clearTimeout(retry);
1368
+ if (frame !== void 0) cancelAnimationFrame(frame);
1369
+ socket?.close();
1370
+ socket = void 0;
1371
+ });
1372
+ connect();
1373
+ }));
1374
+ /**
1375
+ * Decodes one WebSocket frame, which may hold several NDJSON lines.
1376
+ *
1377
+ * Returns `undefined` rather than throwing on a bad frame: `decodeAll` is
1378
+ * strict by design (a bad line means the stream is out of sync), but the
1379
+ * webapp's job is to stay up and show the error count, not to die.
1380
+ */
1381
+ var decodeFrame = (data) => Result.getOrUndefined(webappCodec.decodeAll(data));
1382
+ /**
1383
+ * Adds a parsed trace file to the session list and selects it.
1384
+ *
1385
+ * Re-loading the same file replaces the existing entry rather than stacking a
1386
+ * duplicate — the session id is derived from the file's, so a second copy
1387
+ * would be indistinguishable in the list.
1388
+ */
1389
+ var addLoadedTrace = (registry, text) => {
1390
+ const parsed = parseTraceFile(text);
1391
+ if (Result.isFailure(parsed)) return Result.fail(parsed.failure.message);
1392
+ const { header, messages, truncatedLines } = parsed.success;
1393
+ const session = loadedSession(header);
1394
+ const entry = {
1395
+ session,
1396
+ messages,
1397
+ truncatedLines
1398
+ };
1399
+ registry.set(loadedSessionsAtom, [entry, ...registry.get(loadedSessionsAtom).filter((existing) => existing.session.sessionId !== session.sessionId)]);
1400
+ registry.set(selectedSessionIdAtom, session.sessionId);
1401
+ return Result.succeed(session);
1402
+ };
1403
+ /**
1404
+ * The messages to write when saving the selected session.
1405
+ *
1406
+ * A loaded session is written back from the file's own messages rather than
1407
+ * from the store, so re-exporting a file is lossless even for message types
1408
+ * the chart does not draw.
1409
+ */
1410
+ var saveableMessages = (registry, sessionId) => {
1411
+ if (!isLoadedSession(sessionId)) return traceStore.raw;
1412
+ return registry.get(loadedSessionsAtom).find((entry) => entry.session.sessionId === sessionId)?.messages ?? traceStore.raw;
1413
+ };
1414
+ //#endregion
1415
+ //#region app/src/chart/palette.ts
1416
+ /**
1417
+ * Resolves one custom property against `<html>`.
1418
+ *
1419
+ * `document.documentElement` rather than the canvas: the canvas sits inside the
1420
+ * shell, which paints its own background, and reading from it would resolve
1421
+ * inherited values correctly but pointlessly — every token in play is declared
1422
+ * on `:root`/`.dark`, which is the same element.
1423
+ */
1424
+ var read = (style, name, fallback) => {
1425
+ const value = style.getPropertyValue(name).trim();
1426
+ return value === "" ? fallback : value;
1427
+ };
1428
+ /**
1429
+ * Mixes `color` with `base` at `amount` percent, as a string the canvas can use.
1430
+ *
1431
+ * `color-mix()` in a canvas `fillStyle` is supported wherever `oklch()` is, and
1432
+ * every browser that ships `@property`-less Tailwind v4 has both — so this is a
1433
+ * plain string rather than a manual colour-space conversion. It exists because
1434
+ * a few chart surfaces need a token at partial strength (the overview scrim,
1435
+ * the area fill under the memory curve) and foundation ships no `-tint` for
1436
+ * the neutrals.
1437
+ */
1438
+ var mix = (color, base, amount) => `color-mix(in oklch, ${color} ${amount}%, ${base})`;
1439
+ /**
1440
+ * Snapshot of the current theme's chart colours.
1441
+ *
1442
+ * Fallbacks are the dark values, which is what the server renders and what a
1443
+ * headless canvas test with no stylesheet attached would otherwise get as an
1444
+ * empty string — an empty `fillStyle` assignment is ignored and leaves the
1445
+ * previous colour, so a missing token would paint garbage rather than
1446
+ * something plain.
1447
+ */
1448
+ var readPalette = () => {
1449
+ const style = getComputedStyle(document.documentElement);
1450
+ const bg = read(style, "--canvas", "#1c1d20");
1451
+ const ink = read(style, "--ink", "#f5f6f7");
1452
+ const ink2 = read(style, "--ink-2", "#a8adb8");
1453
+ const ink3 = read(style, "--ink-3", "#6f7480");
1454
+ const red = read(style, "--red", "#e05252");
1455
+ return {
1456
+ bg,
1457
+ panel: read(style, "--surface", "#26272c"),
1458
+ grid: read(style, "--grid-line", "#3a3c42"),
1459
+ rulerText: ink3,
1460
+ label: ink,
1461
+ labelDim: ink2,
1462
+ error: mix(red, bg, 72),
1463
+ errorHot: red,
1464
+ selected: ink,
1465
+ depth: [
1466
+ read(style, "--depth-1", "#3f3f46"),
1467
+ read(style, "--depth-2", "#52525b"),
1468
+ read(style, "--depth-3", "#34343a"),
1469
+ read(style, "--depth-4", "#45454d"),
1470
+ read(style, "--depth-5", "#2e2e33")
1471
+ ],
1472
+ overviewScrim: mix(bg, "transparent", 72),
1473
+ overviewWindow: read(style, "--line-strong", "#4a4d55"),
1474
+ memoryCurve: ink2,
1475
+ memoryFill: mix(ink2, "transparent", 16),
1476
+ memoryRss: ink3,
1477
+ memoryLabel: ink3,
1478
+ memoryBase: read(style, "--line", "#33353b"),
1479
+ readoutBg: read(style, "--tooltip-bg", "#1a1b1e"),
1480
+ readoutFg: read(style, "--tooltip-fg", "#f5f6f7")
1481
+ };
1482
+ };
1483
+ //#endregion
1484
+ //#region app/src/chart/Viewport.ts
1485
+ /** Never zoom in past this window width; below it float maths gets noisy. */
1486
+ var MIN_SPAN = .001;
1487
+ /**
1488
+ * Clamps a window to `[0, total]` while preserving its width where possible.
1489
+ *
1490
+ * Preserving width matters for panning: dragging past the left edge should
1491
+ * stop at the edge, not squash the window.
1492
+ */
1493
+ var clamp = (view, total) => {
1494
+ const limit = Math.max(total, MIN_SPAN);
1495
+ const width = Math.min(Math.max(view.to - view.from, MIN_SPAN), limit);
1496
+ let from = view.from;
1497
+ if (from < 0) from = 0;
1498
+ if (from + width > limit) from = limit - width;
1499
+ return {
1500
+ from,
1501
+ to: from + width
1502
+ };
1503
+ };
1504
+ /** Zooms by `factor` (>1 zooms out) keeping the time under `anchor` fixed. */
1505
+ var zoom = (view, anchor, factor, total) => {
1506
+ const width = (view.to - view.from) * factor;
1507
+ const ratio = (anchor - view.from) / (view.to - view.from);
1508
+ return clamp({
1509
+ from: anchor - width * ratio,
1510
+ to: anchor + width * (1 - ratio)
1511
+ }, total);
1512
+ };
1513
+ /** Slides the window by `delta` millis. */
1514
+ var pan = (view, delta, total) => clamp({
1515
+ from: view.from + delta,
1516
+ to: view.to + delta
1517
+ }, total);
1518
+ /** True when the window covers the whole trace, i.e. live mode should follow. */
1519
+ var isFull = (view, total) => view.from <= 0 && view.to >= Math.max(total, MIN_SPAN) - MIN_SPAN;
1520
+ /** The height a track takes: full, collapsed to its handle, or nothing at all. */
1521
+ var trackHeight = (samples, collapsed) => {
1522
+ if (samples.length === 0) return 0;
1523
+ return collapsed ? 11 : 40;
1524
+ };
1525
+ /** Bytes as the shortest readable unit — the track's only text. */
1526
+ var formatBytes = (bytes) => {
1527
+ if (bytes < 1024) return `${bytes} B`;
1528
+ if (bytes < 1048576) return `${(bytes / 1024).toFixed(0)} KB`;
1529
+ if (bytes < 1073741824) return `${(bytes / 1048576).toFixed(1)} MB`;
1530
+ return `${(bytes / 1073741824).toFixed(2)} GB`;
1531
+ };
1532
+ /**
1533
+ * Index of the sample at or just before `time`, or `-1` when `time` predates
1534
+ * the series.
1535
+ *
1536
+ * Binary search rather than a scan: this runs per pointer move, and a long
1537
+ * trace's series is thousands of entries.
1538
+ */
1539
+ var sampleAt = (samples, time) => {
1540
+ let lo = 0;
1541
+ let hi = samples.length - 1;
1542
+ let found = -1;
1543
+ while (lo <= hi) {
1544
+ const mid = lo + hi >>> 1;
1545
+ if (samples[mid].time <= time) {
1546
+ found = mid;
1547
+ lo = mid + 1;
1548
+ } else hi = mid - 1;
1549
+ }
1550
+ return samples[found === -1 ? 0 : found];
1551
+ };
1552
+ /**
1553
+ * Draws the heap curve across the full track width.
1554
+ *
1555
+ * The y-scale is the **whole trace's** peak rather than the visible window's,
1556
+ * so zooming in does not silently re-scale the curve under the user and make a
1557
+ * flat stretch look like a spike. Chrome does the same.
1558
+ *
1559
+ * Samples are drawn as a step-then-line polyline over the visible range only,
1560
+ * with one sample of overshoot on each side so the curve enters and leaves the
1561
+ * viewport rather than starting at its edge.
1562
+ */
1563
+ var drawMemoryTrack = (draw) => {
1564
+ const { ctx, samples, collapsed, top, height, width, timeToX, peak, trough, rssPeak, traceEnd, palette } = draw;
1565
+ if (samples.length === 0 || height <= 0) return;
1566
+ if (collapsed) {
1567
+ ctx.save();
1568
+ ctx.fillStyle = palette.bg;
1569
+ ctx.fillRect(0, top, width, height);
1570
+ ctx.fillStyle = palette.memoryLabel;
1571
+ ctx.textAlign = "left";
1572
+ ctx.fillText(`+ memory · heap peak ${formatBytes(peak)}`, 4, top + height / 2);
1573
+ ctx.restore();
1574
+ return;
1575
+ }
1576
+ const bottom = top + height;
1577
+ const scale = height - 12;
1578
+ const floor = trough;
1579
+ const span = peak - floor;
1580
+ const yOf = (bytes) => span > 0 ? bottom - (bytes - floor) / span * scale - 2 : bottom - scale / 2;
1581
+ ctx.save();
1582
+ ctx.beginPath();
1583
+ ctx.rect(0, top, width, height);
1584
+ ctx.clip();
1585
+ const xOf = (time) => {
1586
+ const x = timeToX(time);
1587
+ if (x < -width) return -width;
1588
+ return x > width * 2 ? width * 2 : x;
1589
+ };
1590
+ const first = samples[0];
1591
+ const last = samples[samples.length - 1];
1592
+ const leftX = xOf(0);
1593
+ const rightX = xOf(traceEnd);
1594
+ ctx.beginPath();
1595
+ ctx.moveTo(leftX, bottom);
1596
+ ctx.lineTo(leftX, yOf(first.heapUsed));
1597
+ for (const sample of samples) ctx.lineTo(xOf(sample.time), yOf(sample.heapUsed));
1598
+ ctx.lineTo(rightX, yOf(last.heapUsed));
1599
+ ctx.lineTo(rightX, bottom);
1600
+ ctx.closePath();
1601
+ ctx.fillStyle = palette.memoryFill;
1602
+ ctx.fill();
1603
+ ctx.strokeStyle = palette.memoryCurve;
1604
+ ctx.lineWidth = 1;
1605
+ ctx.stroke();
1606
+ if (rssPeak > 0) {
1607
+ const yRss = (bytes) => bottom - bytes / rssPeak * scale - 2;
1608
+ ctx.beginPath();
1609
+ ctx.moveTo(leftX, yRss(first.rss));
1610
+ for (const sample of samples) ctx.lineTo(xOf(sample.time), yRss(sample.rss));
1611
+ ctx.lineTo(rightX, yRss(last.rss));
1612
+ ctx.strokeStyle = palette.memoryRss;
1613
+ ctx.setLineDash([2, 2]);
1614
+ ctx.stroke();
1615
+ ctx.setLineDash([]);
1616
+ }
1617
+ ctx.strokeStyle = palette.memoryBase;
1618
+ ctx.beginPath();
1619
+ ctx.moveTo(0, bottom - .5);
1620
+ ctx.lineTo(width, bottom - .5);
1621
+ ctx.stroke();
1622
+ const label = `− memory · heap ${formatBytes(floor)}–${formatBytes(peak)} · rss ${formatBytes(rssPeak)}`;
1623
+ ctx.textAlign = "left";
1624
+ ctx.fillStyle = palette.bg;
1625
+ ctx.fillRect(0, top, ctx.measureText(label).width + 8, 13);
1626
+ ctx.fillStyle = palette.memoryLabel;
1627
+ ctx.fillText(label, 4, top + 7);
1628
+ ctx.restore();
1629
+ };
1630
+ //#endregion
1631
+ //#region app/src/chart/Renderer.ts
1632
+ /** Height of the whole-trace overview strip, in CSS pixels. */
1633
+ var OVERVIEW_HEIGHT = 34;
1634
+ /** Height of the time ruler below the overview. */
1635
+ var RULER_HEIGHT = 18;
1636
+ /** Height of one depth row. Tight, per the visual direction. */
1637
+ var ROW_HEIGHT$1 = 16;
1638
+ /** Bars narrower than this are drawn but never labelled — the text would not fit. */
1639
+ var MIN_LABEL_WIDTH = 26;
1640
+ var FlameRenderer = class {
1641
+ canvas;
1642
+ registry;
1643
+ ctx;
1644
+ layout = emptyLayout();
1645
+ view = {
1646
+ from: 0,
1647
+ to: 1
1648
+ };
1649
+ /** True while the viewport still covers the whole trace, so live data follows. */
1650
+ following = true;
1651
+ scrollY = 0;
1652
+ width = 0;
1653
+ height = 0;
1654
+ dpr = 1;
1655
+ frame;
1656
+ dirty = true;
1657
+ lastVersion = -1;
1658
+ /**
1659
+ * The current theme's colours.
1660
+ *
1661
+ * Refreshed on a theme change, not per frame: resolving a dozen custom
1662
+ * properties through `getComputedStyle` forces a style recalculation, which
1663
+ * at 60fps would cost more than drawing the bars.
1664
+ */
1665
+ palette = readPalette();
1666
+ hover;
1667
+ /** Last pointer x while the cursor is over the canvas — the keyboard zoom anchor. */
1668
+ cursorX;
1669
+ drag;
1670
+ overviewDrag = false;
1671
+ overviewGrab = 0;
1672
+ unsubscribes = [];
1673
+ observer;
1674
+ constructor(canvas, registry) {
1675
+ this.canvas = canvas;
1676
+ this.registry = registry;
1677
+ const ctx = canvas.getContext("2d", { alpha: false });
1678
+ if (ctx === null) throw new Error("2d canvas context unavailable");
1679
+ this.ctx = ctx;
1680
+ this.observer = new ResizeObserver(() => this.resize());
1681
+ this.observer.observe(canvas);
1682
+ this.resize();
1683
+ canvas.addEventListener("pointerdown", this.onPointerDown);
1684
+ canvas.addEventListener("pointermove", this.onPointerMove);
1685
+ canvas.addEventListener("pointerup", this.onPointerUp);
1686
+ canvas.addEventListener("pointerleave", this.onPointerLeave);
1687
+ canvas.addEventListener("wheel", this.onWheel, { passive: false });
1688
+ const repaint = () => this.invalidate();
1689
+ this.unsubscribes.push(registry.subscribe(selectedSpanIdAtom, repaint), registry.subscribe(hoveredSpanIdAtom, repaint), registry.subscribe(filterAtom, repaint), registry.subscribe(filterHidesAtom, repaint), registry.subscribe(memoryCollapsedAtom, repaint), registry.subscribe(resolvedThemeAtom, () => {
1690
+ this.palette = readPalette();
1691
+ this.invalidate();
1692
+ }));
1693
+ this.loop();
1694
+ }
1695
+ dispose() {
1696
+ if (this.frame !== void 0) cancelAnimationFrame(this.frame);
1697
+ this.observer.disconnect();
1698
+ for (const off of this.unsubscribes) off();
1699
+ this.canvas.removeEventListener("pointerdown", this.onPointerDown);
1700
+ this.canvas.removeEventListener("pointermove", this.onPointerMove);
1701
+ this.canvas.removeEventListener("pointerup", this.onPointerUp);
1702
+ this.canvas.removeEventListener("pointerleave", this.onPointerLeave);
1703
+ this.canvas.removeEventListener("wheel", this.onWheel);
1704
+ }
1705
+ /** Frames the whole trace and resumes following live data. */
1706
+ resetView() {
1707
+ this.following = true;
1708
+ this.invalidate();
1709
+ }
1710
+ /** Scrolls the given span into view and centres the viewport on it. */
1711
+ revealSpan(spanId) {
1712
+ const span = traceStore.spans.get(spanId);
1713
+ if (span === void 0) return;
1714
+ const total = this.total();
1715
+ const end = spanEnd(span, total);
1716
+ const width = Math.max(this.view.to - this.view.from, (end - span.start) * 1.4, .01);
1717
+ const centre = (span.start + end) / 2;
1718
+ this.following = false;
1719
+ this.setView(clamp({
1720
+ from: centre - width / 2,
1721
+ to: centre + width / 2
1722
+ }, total));
1723
+ const top = (this.layout.rowOf.get(span.spanId) ?? span.depth) * ROW_HEIGHT$1;
1724
+ const viewTop = this.chartTop();
1725
+ const visible = this.height - viewTop;
1726
+ if (top < this.scrollY) this.scrollY = top;
1727
+ else if (top + ROW_HEIGHT$1 > this.scrollY + visible) this.scrollY = top + ROW_HEIGHT$1 - visible;
1728
+ this.invalidate();
1729
+ }
1730
+ /** The current time window — the overview strip and the ruler both read it. */
1731
+ viewport() {
1732
+ return this.view;
1733
+ }
1734
+ /**
1735
+ * Zooms by `factor` (>1 out) around the keyboard anchor — W/S.
1736
+ *
1737
+ * The anchor is the cursor while it is over the chart, else the selected
1738
+ * span's midpoint, else the window centre. That is Chrome's rule, and it is
1739
+ * what makes W/S usable without a mouse at all: with a span selected, zooming
1740
+ * keeps that span under the eye rather than drifting off screen.
1741
+ */
1742
+ zoomBy(factor) {
1743
+ this.setView(zoom(this.view, this.keyboardAnchor(), factor, this.total()));
1744
+ }
1745
+ /** Pans by a fraction of the window width — A/D. */
1746
+ panBy(fraction) {
1747
+ this.setView(pan(this.view, (this.view.to - this.view.from) * fraction, this.total()));
1748
+ }
1749
+ /** Scrolls the rows vertically by `delta` CSS pixels, clamped to the content. */
1750
+ scrollRows(delta) {
1751
+ const rows = this.layout.rows.length * ROW_HEIGHT$1;
1752
+ const visible = this.height - this.chartTop();
1753
+ this.scrollY = Math.max(0, Math.min(this.scrollY + delta, Math.max(rows - visible, 0)));
1754
+ this.invalidate();
1755
+ }
1756
+ /**
1757
+ * Where a keyboard zoom pivots. The pointer position is remembered on every
1758
+ * move and dropped on leave, so "cursor is over the chart" is a real test
1759
+ * rather than a guess.
1760
+ */
1761
+ keyboardAnchor() {
1762
+ if (this.cursorX !== void 0) return this.xToTime(this.cursorX);
1763
+ const selected = this.registry.get(selectedSpanIdAtom);
1764
+ const span = selected === void 0 ? void 0 : traceStore.spans.get(selected);
1765
+ if (span !== void 0) return (span.start + spanEnd(span, this.total())) / 2;
1766
+ return (this.view.from + this.view.to) / 2;
1767
+ }
1768
+ /** The span the cursor is over, with its screen rect, for the tooltip. */
1769
+ hovered() {
1770
+ return this.hover;
1771
+ }
1772
+ invalidate() {
1773
+ this.dirty = true;
1774
+ }
1775
+ total() {
1776
+ return Math.max(this.layout.duration, .001);
1777
+ }
1778
+ /**
1779
+ * Height the memory track occupies right now.
1780
+ *
1781
+ * Zero when the session has no samples, so a trace recorded in a runtime
1782
+ * without `process.memoryUsage` gets no empty band — and zero when collapsed.
1783
+ */
1784
+ memoryHeight() {
1785
+ return trackHeight(traceStore.memory, this.registry.get(memoryCollapsedAtom));
1786
+ }
1787
+ /**
1788
+ * Top of the bar area — below the overview, the ruler and the memory track.
1789
+ *
1790
+ * Everything vertical in this class routes through here: hit testing, row
1791
+ * scrolling, gridlines and `revealSpan`. So the memory track pushing the bars
1792
+ * down is one number, and nothing else has to know it moved.
1793
+ */
1794
+ chartTop() {
1795
+ return 52 + this.memoryHeight();
1796
+ }
1797
+ setView(next) {
1798
+ this.view = next;
1799
+ this.following = isFull(next, this.total());
1800
+ this.invalidate();
1801
+ }
1802
+ resize() {
1803
+ const rect = this.canvas.getBoundingClientRect();
1804
+ if (rect.width === 0 || rect.height === 0) return;
1805
+ this.dpr = Math.min(globalThis.devicePixelRatio || 1, 2);
1806
+ this.width = rect.width;
1807
+ this.height = rect.height;
1808
+ this.canvas.width = Math.round(rect.width * this.dpr);
1809
+ this.canvas.height = Math.round(rect.height * this.dpr);
1810
+ this.invalidate();
1811
+ }
1812
+ loop = () => {
1813
+ this.frame = requestAnimationFrame(this.loop);
1814
+ if (traceStore.version !== this.lastVersion) {
1815
+ this.lastVersion = traceStore.version;
1816
+ this.layout = layout(traceStore, this.layout);
1817
+ this.dirty = true;
1818
+ }
1819
+ if (this.following) {
1820
+ const total = this.total();
1821
+ if (this.view.from !== 0 || this.view.to !== total) {
1822
+ this.view = {
1823
+ from: 0,
1824
+ to: total
1825
+ };
1826
+ this.dirty = true;
1827
+ }
1828
+ }
1829
+ if (!this.dirty) return;
1830
+ this.dirty = false;
1831
+ this.draw();
1832
+ };
1833
+ timeToX(time) {
1834
+ return (time - this.view.from) / (this.view.to - this.view.from) * this.width;
1835
+ }
1836
+ xToTime(x) {
1837
+ return this.view.from + x / this.width * (this.view.to - this.view.from);
1838
+ }
1839
+ pointer(event) {
1840
+ const rect = this.canvas.getBoundingClientRect();
1841
+ return {
1842
+ x: event.clientX - rect.left,
1843
+ y: event.clientY - rect.top
1844
+ };
1845
+ }
1846
+ /**
1847
+ * Finds the span under a point.
1848
+ *
1849
+ * Sub-pixel spans are the reason this searches by time rather than by drawn
1850
+ * rect: at a wide zoom a span can be a fraction of a pixel, and testing
1851
+ * against its *rendered* width would make it unhittable. Instead the cursor
1852
+ * x is converted to a time and the row is searched for a span containing it,
1853
+ * with a small pixel-width tolerance so a zero-width span still has a
1854
+ * grabbable target.
1855
+ */
1856
+ hitTest(x, y) {
1857
+ const top = this.chartTop();
1858
+ if (y < top) return void 0;
1859
+ const rowIndex = Math.floor((y - top + this.scrollY) / ROW_HEIGHT$1);
1860
+ const row = this.layout.rows[rowIndex];
1861
+ if (row === void 0) return void 0;
1862
+ const total = this.total();
1863
+ const tolerance = (this.view.to - this.view.from) / this.width * 2;
1864
+ const time = this.xToTime(x);
1865
+ let found;
1866
+ forEachVisible(row, time - tolerance, time + tolerance, total, (span) => {
1867
+ if (span.start - tolerance <= time && spanEnd(span, total) + tolerance >= time) found = span;
1868
+ });
1869
+ if (found === void 0) return void 0;
1870
+ const x0 = this.timeToX(found.start);
1871
+ const x1 = this.timeToX(spanEnd(found, total));
1872
+ return {
1873
+ span: found,
1874
+ x: x0,
1875
+ y: top + rowIndex * ROW_HEIGHT$1 - this.scrollY,
1876
+ width: Math.max(x1 - x0, 1)
1877
+ };
1878
+ }
1879
+ onPointerDown = (event) => {
1880
+ const { x, y } = this.pointer(event);
1881
+ this.canvas.setPointerCapture(event.pointerId);
1882
+ if (y < OVERVIEW_HEIGHT) {
1883
+ const total = this.total();
1884
+ const left = this.view.from / total * this.width;
1885
+ const right = this.view.to / total * this.width;
1886
+ if (x >= left && x <= right) {
1887
+ this.overviewDrag = true;
1888
+ this.overviewGrab = x - left;
1889
+ } else {
1890
+ const width = this.view.to - this.view.from;
1891
+ const centre = x / this.width * total;
1892
+ this.overviewDrag = true;
1893
+ this.overviewGrab = width / total * this.width / 2;
1894
+ this.setView(clamp({
1895
+ from: centre - width / 2,
1896
+ to: centre + width / 2
1897
+ }, total));
1898
+ }
1899
+ return;
1900
+ }
1901
+ const memoryHeight = this.memoryHeight();
1902
+ const memoryTop = 52;
1903
+ if (memoryHeight > 0 && y >= memoryTop && y < memoryTop + memoryHeight && x < 260) {
1904
+ this.registry.set(memoryCollapsedAtom, !this.registry.get(memoryCollapsedAtom));
1905
+ this.invalidate();
1906
+ return;
1907
+ }
1908
+ const hit = this.hitTest(x, y);
1909
+ this.registry.set(selectedSpanIdAtom, hit?.span.spanId);
1910
+ this.drag = {
1911
+ x,
1912
+ view: this.view
1913
+ };
1914
+ };
1915
+ onPointerMove = (event) => {
1916
+ const { x, y } = this.pointer(event);
1917
+ this.cursorX = x;
1918
+ if (this.overviewDrag) {
1919
+ const total = this.total();
1920
+ const width = this.view.to - this.view.from;
1921
+ const from = (x - this.overviewGrab) / this.width * total;
1922
+ this.setView(clamp({
1923
+ from,
1924
+ to: from + width
1925
+ }, total));
1926
+ return;
1927
+ }
1928
+ if (this.drag !== void 0) {
1929
+ const perPixel = (this.drag.view.to - this.drag.view.from) / this.width;
1930
+ this.setView(pan(this.drag.view, (this.drag.x - x) * perPixel, this.total()));
1931
+ return;
1932
+ }
1933
+ const hit = this.hitTest(x, y);
1934
+ if (hit?.span.spanId !== this.hover?.span.spanId) this.registry.set(hoveredSpanIdAtom, hit?.span.spanId);
1935
+ this.hover = hit;
1936
+ this.canvas.style.cursor = hit === void 0 ? "default" : "pointer";
1937
+ this.invalidate();
1938
+ };
1939
+ onPointerUp = (event) => {
1940
+ this.canvas.releasePointerCapture(event.pointerId);
1941
+ this.drag = void 0;
1942
+ this.overviewDrag = false;
1943
+ };
1944
+ onPointerLeave = () => {
1945
+ this.hover = void 0;
1946
+ this.cursorX = void 0;
1947
+ this.registry.set(hoveredSpanIdAtom, void 0);
1948
+ this.invalidate();
1949
+ };
1950
+ onWheel = (event) => {
1951
+ event.preventDefault();
1952
+ const { x } = this.pointer(event);
1953
+ const total = this.total();
1954
+ if (event.altKey) {
1955
+ this.scrollRows(event.deltaY);
1956
+ return;
1957
+ }
1958
+ if (event.shiftKey) {
1959
+ const perPixel = (this.view.to - this.view.from) / this.width;
1960
+ this.setView(pan(this.view, event.deltaY * perPixel, total));
1961
+ return;
1962
+ }
1963
+ if (Math.abs(event.deltaX) > Math.abs(event.deltaY)) {
1964
+ const perPixel = (this.view.to - this.view.from) / this.width;
1965
+ this.setView(pan(this.view, event.deltaX * perPixel, total));
1966
+ return;
1967
+ }
1968
+ const factor = Math.exp(event.deltaY * .002);
1969
+ this.setView(zoom(this.view, this.xToTime(x), factor, total));
1970
+ };
1971
+ /** The bar fill for a nesting depth, cycling through the theme's ramp. */
1972
+ depthFill(depth) {
1973
+ const ramp = this.palette.depth;
1974
+ return ramp[depth % ramp.length] ?? this.palette.labelDim;
1975
+ }
1976
+ draw() {
1977
+ const ctx = this.ctx;
1978
+ ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
1979
+ ctx.fillStyle = this.palette.bg;
1980
+ ctx.fillRect(0, 0, this.width, this.height);
1981
+ ctx.font = "10px ui-monospace, SFMono-Regular, Menlo, monospace";
1982
+ ctx.textBaseline = "middle";
1983
+ const filter = this.registry.get(filterAtom).toLowerCase();
1984
+ const hides = this.registry.get(filterHidesAtom);
1985
+ const selected = this.registry.get(selectedSpanIdAtom);
1986
+ this.drawOverview(filter);
1987
+ const ticks = this.drawRuler();
1988
+ this.drawMemory();
1989
+ this.drawBars(filter, hides, selected, ticks);
1990
+ }
1991
+ /** The whole-trace strip, with the viewport drawn as a window over it. */
1992
+ drawOverview(filter) {
1993
+ const ctx = this.ctx;
1994
+ const total = this.total();
1995
+ ctx.fillStyle = this.palette.panel;
1996
+ ctx.fillRect(0, 0, this.width, OVERVIEW_HEIGHT);
1997
+ const rows = this.layout.rows.length;
1998
+ if (rows > 0) {
1999
+ const rowHeight = Math.max(30 / rows, .5);
2000
+ for (let rowIndex = 0; rowIndex < rows; rowIndex++) {
2001
+ const row = this.layout.rows[rowIndex];
2002
+ const y = 2 + rowIndex * rowHeight;
2003
+ for (const span of row.spans) {
2004
+ const x0 = span.start / total * this.width;
2005
+ const x1 = spanEnd(span, total) / total * this.width;
2006
+ if (filter !== "" && !matches(span.name, filter)) continue;
2007
+ ctx.fillStyle = this.depthFill(span.depth);
2008
+ ctx.fillRect(x0, y, Math.max(x1 - x0, .5), Math.max(rowHeight - .5, .5));
2009
+ }
2010
+ }
2011
+ }
2012
+ const left = this.view.from / total * this.width;
2013
+ const right = this.view.to / total * this.width;
2014
+ ctx.fillStyle = this.palette.overviewScrim;
2015
+ ctx.fillRect(0, 0, left, OVERVIEW_HEIGHT);
2016
+ ctx.fillRect(right, 0, this.width - right, OVERVIEW_HEIGHT);
2017
+ ctx.strokeStyle = this.palette.overviewWindow;
2018
+ ctx.lineWidth = 1;
2019
+ ctx.strokeRect(left + .5, .5, Math.max(right - left - 1, 1), 33);
2020
+ }
2021
+ /** The time ruler; returns the tick times so the gridlines can reuse them. */
2022
+ drawRuler() {
2023
+ const ctx = this.ctx;
2024
+ const y = OVERVIEW_HEIGHT;
2025
+ ctx.fillStyle = this.palette.bg;
2026
+ ctx.fillRect(0, y, this.width, RULER_HEIGHT);
2027
+ const ticks = tickTimes(this.view.from, this.view.to, this.width);
2028
+ ctx.fillStyle = this.palette.rulerText;
2029
+ ctx.textAlign = "left";
2030
+ for (const time of ticks) {
2031
+ const x = this.timeToX(time);
2032
+ ctx.fillText(formatTick(time, ticks), x + 3, 43);
2033
+ }
2034
+ ctx.strokeStyle = this.palette.grid;
2035
+ ctx.beginPath();
2036
+ ctx.moveTo(0, 51.5);
2037
+ ctx.lineTo(this.width, 51.5);
2038
+ ctx.stroke();
2039
+ return ticks;
2040
+ }
2041
+ /**
2042
+ * The memory track, drawn with this renderer's own `timeToX`.
2043
+ *
2044
+ * Sharing the coordinate function rather than the numbers is what makes the
2045
+ * track x-aligned with the bars at every zoom level: there is no second
2046
+ * viewport to keep in sync, so there is nothing to drift.
2047
+ */
2048
+ drawMemory() {
2049
+ const height = this.memoryHeight();
2050
+ if (height === 0) return;
2051
+ const top = 52;
2052
+ const cursorTime = this.cursorX === void 0 ? void 0 : this.xToTime(this.cursorX);
2053
+ const collapsed = this.registry.get(memoryCollapsedAtom);
2054
+ drawMemoryTrack({
2055
+ ctx: this.ctx,
2056
+ samples: traceStore.memory,
2057
+ collapsed,
2058
+ top,
2059
+ height,
2060
+ width: this.width,
2061
+ timeToX: (time) => this.timeToX(time),
2062
+ peak: traceStore.memoryPeak,
2063
+ trough: traceStore.memoryTrough,
2064
+ rssPeak: traceStore.memoryRssPeak,
2065
+ traceEnd: this.total(),
2066
+ cursorTime,
2067
+ palette: this.palette
2068
+ });
2069
+ if (collapsed || cursorTime === void 0) return;
2070
+ const sample = sampleAt(traceStore.memory, cursorTime);
2071
+ if (sample === void 0) return;
2072
+ const ctx = this.ctx;
2073
+ ctx.save();
2074
+ const x = Math.round(this.timeToX(sample.time)) + .5;
2075
+ ctx.strokeStyle = this.palette.memoryLabel;
2076
+ ctx.beginPath();
2077
+ ctx.moveTo(x, top);
2078
+ ctx.lineTo(x, top + height);
2079
+ ctx.stroke();
2080
+ const label = `heap ${formatBytes(sample.heapUsed)} · rss ${formatBytes(sample.rss)}`;
2081
+ ctx.textAlign = "left";
2082
+ const textWidth = ctx.measureText(label).width;
2083
+ const labelX = x + 6 + textWidth > this.width ? x - 6 - textWidth : x + 6;
2084
+ ctx.fillStyle = this.palette.readoutBg;
2085
+ ctx.fillRect(labelX - 3, top + height - 16, textWidth + 6, 12);
2086
+ ctx.fillStyle = this.palette.readoutFg;
2087
+ ctx.fillText(label, labelX, top + height - 10);
2088
+ ctx.restore();
2089
+ }
2090
+ drawBars(filter, hides, selected, ticks) {
2091
+ const ctx = this.ctx;
2092
+ const top = this.chartTop();
2093
+ const total = this.total();
2094
+ const hovered = this.hover?.span.spanId;
2095
+ ctx.save();
2096
+ ctx.beginPath();
2097
+ ctx.rect(0, top, this.width, this.height - top);
2098
+ ctx.clip();
2099
+ ctx.strokeStyle = this.palette.grid;
2100
+ ctx.beginPath();
2101
+ for (const time of ticks) {
2102
+ const x = Math.round(this.timeToX(time)) + .5;
2103
+ ctx.moveTo(x, top);
2104
+ ctx.lineTo(x, this.height);
2105
+ }
2106
+ ctx.stroke();
2107
+ const firstRow = Math.max(Math.floor(this.scrollY / ROW_HEIGHT$1), 0);
2108
+ const lastRow = Math.min(Math.ceil((this.scrollY + this.height - top) / ROW_HEIGHT$1), this.layout.rows.length - 1);
2109
+ ctx.textAlign = "left";
2110
+ for (let rowIndex = firstRow; rowIndex <= lastRow; rowIndex++) {
2111
+ const row = this.layout.rows[rowIndex];
2112
+ if (row === void 0) continue;
2113
+ const y = top + rowIndex * ROW_HEIGHT$1 - this.scrollY;
2114
+ forEachVisible(row, this.view.from, this.view.to, total, (span) => {
2115
+ const matched = matches(span.name, filter);
2116
+ if (hides && !matched) return;
2117
+ const x0 = this.timeToX(span.start);
2118
+ const width = Math.max(this.timeToX(spanEnd(span, total)) - x0, 1);
2119
+ const failed = span.outcome?._tag === "Failure";
2120
+ const hot = span.spanId === hovered;
2121
+ ctx.globalAlpha = matched ? 1 : .22;
2122
+ if (failed) ctx.fillStyle = hot ? this.palette.errorHot : this.palette.error;
2123
+ else ctx.fillStyle = hot ? this.palette.labelDim : this.depthFill(span.depth);
2124
+ ctx.fillRect(x0, y, width, 15);
2125
+ if (span.spanId === selected) {
2126
+ ctx.strokeStyle = this.palette.selected;
2127
+ ctx.lineWidth = 1;
2128
+ ctx.strokeRect(x0 + .5, y + .5, Math.max(width - 1, 1), 14);
2129
+ }
2130
+ if (width >= MIN_LABEL_WIDTH) {
2131
+ ctx.save();
2132
+ ctx.beginPath();
2133
+ ctx.rect(x0, y, width - 3, 15);
2134
+ ctx.clip();
2135
+ ctx.fillStyle = matched ? this.palette.label : this.palette.labelDim;
2136
+ ctx.fillText(span.name, x0 + 3, y + 15 / 2);
2137
+ ctx.restore();
2138
+ }
2139
+ });
2140
+ ctx.globalAlpha = 1;
2141
+ }
2142
+ ctx.restore();
2143
+ }
2144
+ };
2145
+ /**
2146
+ * Chooses ruler tick times for a window, at a 1/2/5 step.
2147
+ *
2148
+ * Exported for testing — it is the only non-obvious arithmetic in the chart
2149
+ * and a wrong step makes the ruler lie at some zoom levels.
2150
+ */
2151
+ /** Rounds a normalized (1..10) step up to the nearest 1/2/5/10. */
2152
+ var niceStep = (normalized) => {
2153
+ if (normalized > 5) return 10;
2154
+ if (normalized > 2) return 5;
2155
+ if (normalized > 1) return 2;
2156
+ return 1;
2157
+ };
2158
+ /** Decimal places that keep two ticks `step` apart visually distinct. */
2159
+ var tickDecimals = (step) => {
2160
+ if (step >= 10) return 0;
2161
+ if (step >= 1) return 1;
2162
+ if (step >= .1) return 2;
2163
+ return 3;
2164
+ };
2165
+ var tickTimes = (from, to, width) => {
2166
+ const target = Math.max(Math.floor(width / 90), 1);
2167
+ const rough = (to - from) / target;
2168
+ if (!Number.isFinite(rough) || rough <= 0) return [from];
2169
+ const magnitude = 10 ** Math.floor(Math.log10(rough));
2170
+ const step = niceStep(rough / magnitude) * magnitude;
2171
+ const ticks = [];
2172
+ for (let t = Math.ceil(from / step) * step; t <= to; t += step) ticks.push(t);
2173
+ return ticks.length === 0 ? [from] : ticks;
2174
+ };
2175
+ /** Formats a tick, with enough decimals to keep adjacent ticks distinct. */
2176
+ var formatTick = (time, ticks) => {
2177
+ const step = ticks.length > 1 ? Math.abs(ticks[1] - ticks[0]) : Math.abs(time) || 1;
2178
+ if (step >= 1e3) return `${(time / 1e3).toFixed(time % 1e3 === 0 ? 0 : 1)}s`;
2179
+ return `${time.toFixed(tickDecimals(step))}ms`;
2180
+ };
2181
+ //#endregion
2182
+ //#region app/src/components/Shortcuts.tsx
2183
+ /**
2184
+ * The keyboard-shortcut overlay, opened with `?`.
2185
+ *
2186
+ * Undiscoverable shortcuts may as well not exist, so this is paired with a
2187
+ * permanent `? keys` hint in the chart toolbar — the overlay is the detail, the
2188
+ * hint is how anyone finds out the overlay is there.
2189
+ *
2190
+ * Hand-rolled rather than shadcn's `dialog`. Radix would be a new dependency,
2191
+ * it portals to `body` (this overlay is positioned inside the chart container,
2192
+ * not over the whole app), and its own Escape handling would race
2193
+ * `useKeyboard`, which already dismisses innermost-first. What it would give us
2194
+ * that matters — the focus trap and focus restore — is the effect below.
2195
+ */
2196
+ var KEYS = [
2197
+ ["W / S", "zoom in / out around the cursor"],
2198
+ ["A / D", "pan left / right"],
2199
+ ["Q / E", "scroll rows up / down"],
2200
+ ["0", "reset zoom, follow live data"],
2201
+ ["← / →", "previous / next sibling span"],
2202
+ ["↑ / ↓", "parent / first child span"],
2203
+ ["Enter", "reveal the selected span"],
2204
+ ["Esc", "clear the selection"],
2205
+ ["?", "this list"]
2206
+ ];
2207
+ var FOCUSABLE = "a[href], button:not([disabled]), input, select, textarea, [tabindex]";
2208
+ var Shortcuts = ({ onClose }) => {
2209
+ const dialog = useRef(null);
2210
+ /**
2211
+ * Move focus in, keep it in, and put it back on close.
2212
+ *
2213
+ * Escape is deliberately *not* handled here: `useKeyboard` owns it and closes
2214
+ * the overlay before clearing the selection, so a second handler would only
2215
+ * make the ordering ambiguous.
2216
+ */
2217
+ useEffect(() => {
2218
+ const restoreTo = document.activeElement;
2219
+ dialog.current?.querySelector(FOCUSABLE)?.focus();
2220
+ const onKeyDown = (event) => {
2221
+ if (event.key !== "Tab") return;
2222
+ const element = dialog.current;
2223
+ if (element === null) return;
2224
+ const focusable = [...element.querySelectorAll(FOCUSABLE)];
2225
+ const first = focusable[0];
2226
+ const last = focusable[focusable.length - 1];
2227
+ if (first === void 0 || last === void 0) return;
2228
+ const leaving = event.shiftKey ? first : last;
2229
+ if (document.activeElement === leaving || !element.contains(document.activeElement)) {
2230
+ event.preventDefault();
2231
+ (event.shiftKey ? last : first).focus();
2232
+ }
2233
+ };
2234
+ globalThis.addEventListener("keydown", onKeyDown, true);
2235
+ return () => {
2236
+ globalThis.removeEventListener("keydown", onKeyDown, true);
2237
+ if (restoreTo instanceof HTMLElement) restoreTo.focus();
2238
+ };
2239
+ }, []);
2240
+ return /* @__PURE__ */ jsx("div", {
2241
+ onClick: onClose,
2242
+ className: "absolute inset-0 z-20 flex items-center justify-center bg-page/80",
2243
+ children: /* @__PURE__ */ jsxs("div", {
2244
+ ref: dialog,
2245
+ role: "dialog",
2246
+ "aria-modal": "true",
2247
+ "aria-label": "Keyboard shortcuts",
2248
+ onClick: (event) => event.stopPropagation(),
2249
+ className: "w-80 rounded-card bg-surface p-4 text-[11px] shadow-overlay",
2250
+ children: [/* @__PURE__ */ jsxs("div", {
2251
+ className: "flex items-baseline justify-between",
2252
+ children: [/* @__PURE__ */ jsx("h2", {
2253
+ className: "text-xs text-ink",
2254
+ children: "Keyboard"
2255
+ }), /* @__PURE__ */ jsx(Button, {
2256
+ type: "button",
2257
+ variant: "quiet",
2258
+ size: "xs",
2259
+ onClick: onClose,
2260
+ className: "text-ink-2",
2261
+ children: "close"
2262
+ })]
2263
+ }), /* @__PURE__ */ jsx("dl", {
2264
+ className: "mt-3 grid grid-cols-[5rem_1fr] gap-x-3 gap-y-1.5",
2265
+ children: KEYS.map(([key, description]) => /* @__PURE__ */ jsxs("div", {
2266
+ className: "contents",
2267
+ children: [/* @__PURE__ */ jsx("dt", {
2268
+ className: "tabular-nums text-ink",
2269
+ children: key
2270
+ }), /* @__PURE__ */ jsx("dd", {
2271
+ className: "text-ink-2",
2272
+ children: description
2273
+ })]
2274
+ }, key))
2275
+ })]
2276
+ })
2277
+ });
2278
+ };
2279
+ //#endregion
2280
+ //#region app/src/chart/navigate.ts
2281
+ var byStart = (store, ids) => {
2282
+ const spans = [];
2283
+ for (const id of ids) {
2284
+ const span = store.spans.get(id);
2285
+ if (span !== void 0) spans.push(span);
2286
+ }
2287
+ spans.sort((a, b) => a.start - b.start || (a.spanId < b.spanId ? -1 : 1));
2288
+ return spans;
2289
+ };
2290
+ /**
2291
+ * The span's siblings in visual order, including itself.
2292
+ *
2293
+ * A root's siblings are the other roots: at depth 0 there is no parent to ask,
2294
+ * but left/right must still walk the top row.
2295
+ *
2296
+ * An **orphan** (parent id seen, parent span not yet arrived) is the case worth
2297
+ * knowing about: `TraceStore` parks it in `pendingChildren` rather than pushing
2298
+ * it to `roots`, so it is drawn at depth 0 but is in nobody's child list. Left
2299
+ * and right would dead-end on it — the span is visible and unreachable. So it
2300
+ * is grouped with the roots it is drawn beside, which is what the eye expects.
2301
+ */
2302
+ var siblingsOf = (store, span) => {
2303
+ const parent = span.parentId === void 0 ? void 0 : store.spans.get(span.parentId);
2304
+ if (parent !== void 0) return byStart(store, parent.children);
2305
+ return byStart(store, span.orphaned ? [...store.roots, span.spanId] : store.roots);
2306
+ };
2307
+ /**
2308
+ * The next span in `direction` from `spanId`, or `undefined` at the edge.
2309
+ *
2310
+ * Returning `undefined` rather than wrapping or clamping is deliberate: the
2311
+ * caller leaves the selection where it is, so holding an arrow key at the end
2312
+ * of a row does nothing instead of teleporting to the other end.
2313
+ */
2314
+ var step = (store, spanId, direction) => {
2315
+ const span = store.spans.get(spanId);
2316
+ if (span === void 0) return void 0;
2317
+ if (direction === "parent") return span.parentId === void 0 ? void 0 : store.spans.get(span.parentId)?.spanId;
2318
+ if (direction === "child") return byStart(store, span.children)[0]?.spanId;
2319
+ const siblings = siblingsOf(store, span);
2320
+ const index = siblings.findIndex((candidate) => candidate.spanId === spanId);
2321
+ if (index === -1) return void 0;
2322
+ return siblings[direction === "previous" ? index - 1 : index + 1]?.spanId;
2323
+ };
2324
+ /**
2325
+ * A sensible span to select when nothing is selected yet and an arrow is
2326
+ * pressed: the earliest root, so the first keystroke lands somewhere visible
2327
+ * rather than doing nothing.
2328
+ */
2329
+ var firstSpan = (store) => byStart(store, store.roots)[0]?.spanId;
2330
+ /**
2331
+ * True when a keystroke's target is somewhere it means text, not a command.
2332
+ *
2333
+ * This is the rule that actually bites: typing `w` in the filter box must type
2334
+ * a `w`, not zoom the chart. Checked by element kind rather than by a flag the
2335
+ * filter box sets, so every input the app grows — the aggregation tabs' own
2336
+ * filters included — is covered without touching the key handler.
2337
+ *
2338
+ * `isContentEditable` covers rich-text hosts; `closest` catches a keystroke
2339
+ * that lands on a child of one. Lives here rather than beside the handler so a
2340
+ * plain `bun test` can exercise it without mounting React.
2341
+ */
2342
+ var isTypingTarget = (target) => {
2343
+ if (!(target instanceof HTMLElement)) return false;
2344
+ const tag = target.tagName;
2345
+ if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true;
2346
+ return target.isContentEditable || target.closest("[contenteditable=\"true\"]") !== null;
2347
+ };
2348
+ //#endregion
2349
+ //#region app/src/components/useKeyboard.ts
2350
+ /**
2351
+ * The chart's keyboard bindings — Chrome DevTools' performance-panel keys.
2352
+ *
2353
+ * One listener on `window` rather than a focusable canvas: the bindings have to
2354
+ * work after clicking an event-log row or a session in the sidebar, and
2355
+ * requiring the user to click back onto the chart first would make W/A/S/D feel
2356
+ * broken. The cost of that choice is that this handler is responsible for
2357
+ * staying out of the way of text entry — see {@link isTypingTarget}.
2358
+ */
2359
+ /** Zoom step per W/S press. Matches the wheel's feel over a few presses. */
2360
+ var ZOOM_STEP = 1.25;
2361
+ /** Pan step per A/D press, as a fraction of the visible window. */
2362
+ var PAN_STEP = .12;
2363
+ /** Rows scrolled per Q/E press, in CSS pixels (two rows). */
2364
+ var ROW_STEP = 32;
2365
+ var ARROWS = {
2366
+ ArrowUp: "parent",
2367
+ ArrowDown: "child",
2368
+ ArrowLeft: "previous",
2369
+ ArrowRight: "next"
2370
+ };
2371
+ /**
2372
+ * Binds the chart's keys for as long as the component is mounted.
2373
+ *
2374
+ * Returns nothing: every effect is a write to the shared selection model or a
2375
+ * call onto the mounted renderer, so there is no state for React to hold.
2376
+ */
2377
+ var useKeyboard = (onShowHelp, onCloseHelp, helpOpen) => {
2378
+ const registry = useContext(RegistryContext);
2379
+ useEffect(() => {
2380
+ const onKeyDown = (event) => {
2381
+ if (isTypingTarget(event.target)) return;
2382
+ if (event.ctrlKey || event.metaKey || event.altKey) return;
2383
+ const chart = activeChart();
2384
+ const selected = registry.get(selectedSpanIdAtom);
2385
+ const direction = ARROWS[event.key];
2386
+ if (direction !== void 0) {
2387
+ const next = selected === void 0 ? firstSpan(traceStore) : step(traceStore, selected, direction);
2388
+ if (next !== void 0) {
2389
+ registry.set(selectedSpanIdAtom, next);
2390
+ revealSpan(next);
2391
+ }
2392
+ event.preventDefault();
2393
+ return;
2394
+ }
2395
+ switch (event.key) {
2396
+ case "w":
2397
+ case "W":
2398
+ chart?.zoomBy(1 / ZOOM_STEP);
2399
+ break;
2400
+ case "s":
2401
+ case "S":
2402
+ chart?.zoomBy(ZOOM_STEP);
2403
+ break;
2404
+ case "a":
2405
+ case "A":
2406
+ chart?.panBy(-.12);
2407
+ break;
2408
+ case "d":
2409
+ case "D":
2410
+ chart?.panBy(PAN_STEP);
2411
+ break;
2412
+ case "q":
2413
+ case "Q":
2414
+ chart?.scrollRows(-32);
2415
+ break;
2416
+ case "e":
2417
+ case "E":
2418
+ chart?.scrollRows(ROW_STEP);
2419
+ break;
2420
+ case "Enter":
2421
+ if (selected === void 0) {
2422
+ const first = firstSpan(traceStore);
2423
+ if (first !== void 0) {
2424
+ registry.set(selectedSpanIdAtom, first);
2425
+ revealSpan(first);
2426
+ }
2427
+ } else revealSpan(selected);
2428
+ break;
2429
+ case "Escape":
2430
+ if (helpOpen) onCloseHelp();
2431
+ else registry.set(selectedSpanIdAtom, void 0);
2432
+ break;
2433
+ case "0":
2434
+ chart?.resetView();
2435
+ break;
2436
+ case "?":
2437
+ onShowHelp();
2438
+ break;
2439
+ default: return;
2440
+ }
2441
+ event.preventDefault();
2442
+ };
2443
+ globalThis.addEventListener("keydown", onKeyDown);
2444
+ return () => globalThis.removeEventListener("keydown", onKeyDown);
2445
+ }, [
2446
+ registry,
2447
+ onShowHelp,
2448
+ onCloseHelp,
2449
+ helpOpen
2450
+ ]);
2451
+ };
2452
+ //#endregion
2453
+ //#region app/src/components/format.ts
2454
+ /** Shared number formatting for the chart chrome and the event log. */
2455
+ /** Millis, with the unit and precision Chrome's performance panel uses. */
2456
+ var formatDuration$1 = (millis) => {
2457
+ if (!Number.isFinite(millis)) return "—";
2458
+ if (millis >= 1e3) return `${(millis / 1e3).toFixed(2)}s`;
2459
+ if (millis >= 1) return `${millis.toFixed(2)}ms`;
2460
+ return `${(millis * 1e3).toFixed(0)}µs`;
2461
+ };
2462
+ /** An attribute value as one line of text; objects/arrays are JSON, not `[object Object]`. */
2463
+ var formatValue = (value) => typeof value === "string" ? value : JSON.stringify(value) ?? String(value);
2464
+ //#endregion
2465
+ //#region app/src/components/FlameChart.tsx
2466
+ /**
2467
+ * The canvas flame chart, plus the DOM chrome that floats over it.
2468
+ *
2469
+ * The canvas itself is owned entirely by {@link FlameRenderer}; this component
2470
+ * mounts it, hands it the atom registry, and otherwise does not re-render when
2471
+ * the chart repaints. The only DOM here is the hover tooltip and the toolbar,
2472
+ * both of which are cheap and genuinely better as DOM than as canvas text.
2473
+ */
2474
+ /**
2475
+ * Floating hover tooltip.
2476
+ *
2477
+ * Reads the hovered span **id** from the shared selection model and resolves
2478
+ * it against the store, so it cannot show a stale span; position comes from
2479
+ * the renderer, which is the only thing that knows where the bar landed.
2480
+ */
2481
+ var Tooltip = ({ renderer, containerWidth }) => {
2482
+ const hoveredId = useAtomValue(hoveredSpanIdAtom);
2483
+ if (renderer === void 0 || hoveredId === void 0) return null;
2484
+ const hit = renderer.hovered();
2485
+ if (hit === void 0 || hit.span.spanId !== hoveredId) return null;
2486
+ const span = hit.span;
2487
+ const { total, self } = timings(traceStore, span, traceStore.stats().duration);
2488
+ const attributes = Object.entries(span.attributes).slice(0, 6);
2489
+ const left = Math.min(Math.max(hit.x, 4), Math.max(containerWidth - 264, 4));
2490
+ return /* @__PURE__ */ jsxs("div", {
2491
+ className: "pointer-events-none absolute z-10 w-64 rounded-card border border-[var(--tooltip-border)] bg-[var(--tooltip-bg)] p-2 text-[11px] text-[var(--tooltip-fg)] shadow-overlay",
2492
+ style: {
2493
+ left,
2494
+ top: hit.y + 20
2495
+ },
2496
+ children: [
2497
+ /* @__PURE__ */ jsx("div", {
2498
+ className: "truncate",
2499
+ children: span.name
2500
+ }),
2501
+ /* @__PURE__ */ jsxs("dl", {
2502
+ className: "mt-1.5 grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5 text-[var(--tooltip-muted)]",
2503
+ children: [
2504
+ /* @__PURE__ */ jsx("dt", { children: "total" }),
2505
+ /* @__PURE__ */ jsx("dd", {
2506
+ className: "text-right tabular-nums text-[var(--tooltip-fg)]",
2507
+ children: formatDuration$1(total)
2508
+ }),
2509
+ /* @__PURE__ */ jsx("dt", { children: "self" }),
2510
+ /* @__PURE__ */ jsx("dd", {
2511
+ className: "text-right tabular-nums text-[var(--tooltip-fg)]",
2512
+ children: formatDuration$1(self)
2513
+ }),
2514
+ /* @__PURE__ */ jsx("dt", { children: "start" }),
2515
+ /* @__PURE__ */ jsx("dd", {
2516
+ className: "text-right tabular-nums text-[var(--tooltip-fg)]",
2517
+ children: formatDuration$1(span.start)
2518
+ })
2519
+ ]
2520
+ }),
2521
+ span.outcome?._tag === "Failure" && /* @__PURE__ */ jsx("p", {
2522
+ className: "mt-1.5 line-clamp-3 border-t border-[var(--tooltip-border)] pt-1.5 text-red",
2523
+ children: span.outcome.error
2524
+ }),
2525
+ attributes.length > 0 && /* @__PURE__ */ jsx("dl", {
2526
+ className: "mt-1.5 grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5 border-t border-[var(--tooltip-border)] pt-1.5 text-[var(--tooltip-muted)]",
2527
+ children: attributes.map(([key, value]) => /* @__PURE__ */ jsxs("div", {
2528
+ className: "contents",
2529
+ children: [/* @__PURE__ */ jsx("dt", {
2530
+ className: "truncate",
2531
+ children: key
2532
+ }), /* @__PURE__ */ jsx("dd", {
2533
+ className: "truncate text-right text-[var(--tooltip-fg)]",
2534
+ children: formatValue(value)
2535
+ })]
2536
+ }, key))
2537
+ })
2538
+ ]
2539
+ });
2540
+ };
2541
+ /** Filter box and view controls; writes the shared filter atoms. */
2542
+ var Toolbar = ({ onReset, onShowHelp }) => {
2543
+ const [filter, setFilter] = useAtom(filterAtom);
2544
+ const [hides, setHides] = useAtom(filterHidesAtom);
2545
+ return /* @__PURE__ */ jsxs("div", {
2546
+ className: "flex h-8 shrink-0 items-center gap-2 border-b border-line bg-surface px-2",
2547
+ children: [
2548
+ /* @__PURE__ */ jsx("input", {
2549
+ value: filter,
2550
+ onChange: (event) => setFilter(event.target.value),
2551
+ placeholder: "Filter spans…",
2552
+ "aria-label": "Filter spans",
2553
+ className: "h-6 w-56 rounded-control bg-field px-2 text-xs text-ink shadow-hairline placeholder:text-ink-3"
2554
+ }),
2555
+ /* @__PURE__ */ jsxs("label", {
2556
+ className: "flex items-center gap-1.5 text-[11px] text-ink-2",
2557
+ children: [/* @__PURE__ */ jsx("input", {
2558
+ type: "checkbox",
2559
+ checked: hides,
2560
+ onChange: (event) => setHides(event.target.checked),
2561
+ className: "accent-accent"
2562
+ }), "hide non-matching"]
2563
+ }),
2564
+ /* @__PURE__ */ jsx(Button, {
2565
+ variant: "quiet",
2566
+ size: "xs",
2567
+ onClick: onReset,
2568
+ className: "ml-auto text-ink-2",
2569
+ children: "reset zoom"
2570
+ }),
2571
+ /* @__PURE__ */ jsx(Button, {
2572
+ variant: "quiet",
2573
+ size: "xs",
2574
+ onClick: onShowHelp,
2575
+ "aria-label": "Keyboard shortcuts",
2576
+ className: "text-ink-2",
2577
+ children: "? keys"
2578
+ }),
2579
+ /* @__PURE__ */ jsx("span", {
2580
+ className: "text-[11px] text-ink-3",
2581
+ children: "drag to pan · wheel to zoom · W/A/S/D"
2582
+ })
2583
+ ]
2584
+ });
2585
+ };
2586
+ var FlameChart = () => {
2587
+ const registry = useContext(RegistryContext);
2588
+ const canvasRef = useRef(null);
2589
+ const containerRef = useRef(null);
2590
+ const [renderer, setRenderer] = useState();
2591
+ const [width, setWidth] = useState(0);
2592
+ const [helpOpen, setHelpOpen] = useState(false);
2593
+ useEffect(() => {
2594
+ const canvas = canvasRef.current;
2595
+ if (canvas === null) return;
2596
+ const instance = new FlameRenderer(canvas, registry);
2597
+ setRenderer(instance);
2598
+ const observer = new ResizeObserver(([entry]) => setWidth(entry?.contentRect.width ?? 0));
2599
+ if (containerRef.current !== null) observer.observe(containerRef.current);
2600
+ return () => {
2601
+ observer.disconnect();
2602
+ instance.dispose();
2603
+ setRenderer(void 0);
2604
+ };
2605
+ }, [registry]);
2606
+ useEffect(() => {
2607
+ activeRenderer = renderer;
2608
+ return () => {
2609
+ if (activeRenderer === renderer) activeRenderer = void 0;
2610
+ };
2611
+ }, [renderer]);
2612
+ const showHelp = useCallback(() => setHelpOpen(true), []);
2613
+ const closeHelp = useCallback(() => setHelpOpen(false), []);
2614
+ useKeyboard(showHelp, closeHelp, helpOpen);
2615
+ return /* @__PURE__ */ jsxs("div", {
2616
+ className: "flex min-h-0 flex-1 flex-col",
2617
+ children: [/* @__PURE__ */ jsx(Toolbar, {
2618
+ onReset: () => renderer?.resetView(),
2619
+ onShowHelp: showHelp
2620
+ }), /* @__PURE__ */ jsxs("div", {
2621
+ ref: containerRef,
2622
+ className: "relative min-h-0 flex-1",
2623
+ children: [
2624
+ /* @__PURE__ */ jsx("canvas", {
2625
+ ref: canvasRef,
2626
+ className: "absolute inset-0 size-full touch-none"
2627
+ }),
2628
+ /* @__PURE__ */ jsx(Tooltip, {
2629
+ renderer,
2630
+ containerWidth: width
2631
+ }),
2632
+ helpOpen && /* @__PURE__ */ jsx(Shortcuts, { onClose: closeHelp })
2633
+ ]
2634
+ })]
2635
+ });
2636
+ };
2637
+ /** The mounted chart, if any — see the comment at its assignment. */
2638
+ var activeRenderer;
2639
+ /** Centres the chart on a span. No-op when the chart is not mounted. */
2640
+ var revealSpan = (spanId) => activeRenderer?.revealSpan(spanId);
2641
+ /**
2642
+ * The mounted renderer, for the keyboard bindings.
2643
+ *
2644
+ * Same module-level handle `revealSpan` already uses, exposed because the key
2645
+ * handler needs several of the renderer's transitions rather than one, and
2646
+ * wrapping each in its own free function would be five of these.
2647
+ */
2648
+ var activeChart = () => activeRenderer;
2649
+ /**
2650
+ * The chart's current time window, or `undefined` when no chart is mounted.
2651
+ *
2652
+ * Read by the drawer's aggregation tabs, which follow the visible range the
2653
+ * way Chrome does. They **poll** this on a settle timer rather than being
2654
+ * pushed every viewport change: the viewport moves once per pan frame and
2655
+ * aggregating 13k spans at that rate would stutter, so the drawer samples a
2656
+ * settled window instead of subscribing to a moving one.
2657
+ */
2658
+ var chartViewport = () => activeRenderer?.viewport();
2659
+ //#endregion
2660
+ //#region app/src/components/EventLog.tsx
2661
+ /**
2662
+ * The bottom drawer's Event log tab: every span as a sortable, filterable row.
2663
+ *
2664
+ * Selection is **not** synced with the flame chart — it is the same value.
2665
+ * Both read and write `selectedSpanIdAtom`, so a click here moves the chart
2666
+ * and a click there scrolls this table, with no effect wiring in between and
2667
+ * nothing that can drift.
2668
+ *
2669
+ * Rows are windowed rather than all mounted: a 10k-span trace is 10k table
2670
+ * rows, which is exactly the DOM-node-per-span cost the chart exists to avoid.
2671
+ * Only the ~40 rows in the scroll window are real elements; the rest is two
2672
+ * spacer heights.
2673
+ */
2674
+ var ROW_HEIGHT = 22;
2675
+ /** Rows rendered beyond the scroll window, so fast scrolling does not flash. */
2676
+ var OVERSCAN = 8;
2677
+ /**
2678
+ * The shared table chrome, lifted from the registry's `records-table` and
2679
+ * `filter-table`: a sticky header on the panel surface, a strong rule under
2680
+ * it, hover and selection as background changes on the row.
2681
+ *
2682
+ * `primitive-table-cell` is deliberately *not* used — it is 10px/12px padding
2683
+ * for a roomy demo grid, and these rows are a fixed 22px so the virtualizer
2684
+ * can multiply by them. The tokens are the part worth sharing, not the metric.
2685
+ */
2686
+ var TABLE_HEAD = "flex shrink-0 border-b border-line-strong bg-surface px-2 text-ink-3";
2687
+ /** Header cell: quiet until hovered, full ink once it is the sort key. */
2688
+ var headCellClass = (active) => `py-1 text-left transition-colors hover:text-ink ${active ? "text-ink" : ""}`;
2689
+ /** Selected row wash — `records-table`'s accent mix over the panel surface. */
2690
+ var SELECTED = "color-mix(in srgb, var(--accent) 10%, var(--surface))";
2691
+ /**
2692
+ * Row background, in precedence order: selected, hovered, then the zebra
2693
+ * stripe. The stripe is foundation's `--stripe` over `--stripe-bg`, which is
2694
+ * what makes a dense numeric table scannable across its columns.
2695
+ */
2696
+ var rowBackground = (selected, hovered, even) => {
2697
+ if (selected) return SELECTED;
2698
+ if (hovered) return "var(--hover)";
2699
+ return even ? void 0 : "var(--stripe)";
2700
+ };
2701
+ var COLUMNS = [
2702
+ {
2703
+ key: "start",
2704
+ label: "Start"
2705
+ },
2706
+ {
2707
+ key: "self",
2708
+ label: "Self"
2709
+ },
2710
+ {
2711
+ key: "total",
2712
+ label: "Total"
2713
+ },
2714
+ {
2715
+ key: "name",
2716
+ label: "Name"
2717
+ }
2718
+ ];
2719
+ var compare = (key, a, b) => {
2720
+ if (key === "name") return a.span.name.localeCompare(b.span.name);
2721
+ if (key === "start") return a.span.start - b.span.start;
2722
+ return a[key] - b[key];
2723
+ };
2724
+ var EventLog = () => {
2725
+ const version = useAtomValue(traceVersionAtom);
2726
+ const filter = useAtomValue(filterAtom);
2727
+ const hides = useAtomValue(filterHidesAtom);
2728
+ const [selectedId, setSelectedId] = useAtom(selectedSpanIdAtom);
2729
+ const [hoveredId, setHoveredId] = useAtom(hoveredSpanIdAtom);
2730
+ const [sort, setSort] = useState({
2731
+ key: "start",
2732
+ desc: false
2733
+ });
2734
+ const [scrollTop, setScrollTop] = useState(0);
2735
+ const [viewHeight, setViewHeight] = useState(0);
2736
+ const scrollRef = useRef(null);
2737
+ const rows = useMemo(() => {
2738
+ const now = traceStore.stats().duration;
2739
+ const needle = filter.toLowerCase();
2740
+ const list = [];
2741
+ for (const span of traceStore.spans.values()) {
2742
+ const matched = matches(span.name, needle);
2743
+ if (hides && !matched) continue;
2744
+ list.push({
2745
+ span,
2746
+ matched,
2747
+ ...timings(traceStore, span, now)
2748
+ });
2749
+ }
2750
+ list.sort((a, b) => (sort.desc ? -1 : 1) * compare(sort.key, a, b));
2751
+ return list;
2752
+ }, [
2753
+ version,
2754
+ filter,
2755
+ hides,
2756
+ sort
2757
+ ]);
2758
+ useEffect(() => {
2759
+ const element = scrollRef.current;
2760
+ if (element === null) return;
2761
+ const observer = new ResizeObserver(([entry]) => setViewHeight(entry?.contentRect.height ?? 0));
2762
+ observer.observe(element);
2763
+ return () => observer.disconnect();
2764
+ }, []);
2765
+ useEffect(() => {
2766
+ const element = scrollRef.current;
2767
+ if (element === null || selectedId === void 0) return;
2768
+ const index = rows.findIndex((row) => row.span.spanId === selectedId);
2769
+ if (index === -1) return;
2770
+ const top = index * ROW_HEIGHT;
2771
+ if (top < element.scrollTop || top + ROW_HEIGHT > element.scrollTop + element.clientHeight) element.scrollTop = top - element.clientHeight / 2;
2772
+ }, [selectedId, rows]);
2773
+ const first = Math.max(Math.floor(scrollTop / ROW_HEIGHT) - OVERSCAN, 0);
2774
+ const last = Math.min(Math.ceil((scrollTop + viewHeight) / ROW_HEIGHT) + OVERSCAN, rows.length);
2775
+ const visible = rows.slice(first, last);
2776
+ return /* @__PURE__ */ jsxs("div", {
2777
+ className: "flex min-h-0 flex-1 flex-col text-[11px]",
2778
+ children: [/* @__PURE__ */ jsx("div", {
2779
+ className: TABLE_HEAD,
2780
+ children: COLUMNS.map((column) => /* @__PURE__ */ jsxs("button", {
2781
+ type: "button",
2782
+ onClick: () => setSort((current) => current.key === column.key ? {
2783
+ key: column.key,
2784
+ desc: !current.desc
2785
+ } : {
2786
+ key: column.key,
2787
+ desc: column.key !== "name" && column.key !== "start"
2788
+ }),
2789
+ className: `${headCellClass(sort.key === column.key)} ${column.key === "name" ? "flex-1 pl-3" : "w-24 pr-3 text-right"}`,
2790
+ children: [column.label, sort.key === column.key && (sort.desc ? " ↓" : " ↑")]
2791
+ }, column.key))
2792
+ }), /* @__PURE__ */ jsx("div", {
2793
+ ref: scrollRef,
2794
+ onScroll: (event) => setScrollTop(event.currentTarget.scrollTop),
2795
+ className: "min-h-0 flex-1 overflow-y-auto",
2796
+ children: rows.length === 0 ? /* @__PURE__ */ jsx("p", {
2797
+ className: "px-3 py-2 text-ink-3",
2798
+ children: "No spans match."
2799
+ }) : /* @__PURE__ */ jsx("div", {
2800
+ style: {
2801
+ height: rows.length * ROW_HEIGHT,
2802
+ position: "relative"
2803
+ },
2804
+ children: /* @__PURE__ */ jsx("div", {
2805
+ style: { transform: `translateY(${first * ROW_HEIGHT}px)` },
2806
+ children: visible.map((row, index) => {
2807
+ const id = row.span.spanId;
2808
+ const failed = row.span.outcome?._tag === "Failure";
2809
+ const selected = id === selectedId;
2810
+ return /* @__PURE__ */ jsxs("button", {
2811
+ type: "button",
2812
+ onClick: () => {
2813
+ setSelectedId(id);
2814
+ revealSpan(id);
2815
+ },
2816
+ onMouseEnter: () => setHoveredId(id),
2817
+ onMouseLeave: () => setHoveredId(void 0),
2818
+ style: {
2819
+ height: ROW_HEIGHT,
2820
+ background: rowBackground(selected, id === hoveredId, (first + index) % 2 === 0)
2821
+ },
2822
+ className: `flex w-full items-center px-2 text-left tabular-nums transition-colors ${selected ? "text-ink" : ""} ${row.matched ? "text-ink-2" : "text-ink-3"}`,
2823
+ children: [
2824
+ /* @__PURE__ */ jsx("span", {
2825
+ className: "w-24 pr-3 text-right",
2826
+ children: formatDuration$1(row.span.start)
2827
+ }),
2828
+ /* @__PURE__ */ jsx("span", {
2829
+ className: "w-24 pr-3 text-right",
2830
+ children: formatDuration$1(row.self)
2831
+ }),
2832
+ /* @__PURE__ */ jsx("span", {
2833
+ className: "w-24 pr-3 text-right",
2834
+ children: formatDuration$1(row.total)
2835
+ }),
2836
+ /* @__PURE__ */ jsx("span", {
2837
+ className: `flex-1 truncate pl-3 ${failed ? "text-red" : "text-ink"}`,
2838
+ children: row.span.name
2839
+ })
2840
+ ]
2841
+ }, id);
2842
+ })
2843
+ })
2844
+ })
2845
+ })]
2846
+ });
2847
+ };
2848
+ //#endregion
2849
+ //#region app/src/components/Aggregation.tsx
2850
+ /**
2851
+ * The drawer's Summary, Bottom-up and Call tree tabs.
2852
+ *
2853
+ * All three read one {@link Aggregation}, built by `aggregate.ts`, so they
2854
+ * cannot disagree about a number. They are in one file because they are one
2855
+ * feature: a shared hook, a shared row chrome, and three ~30-line bodies.
2856
+ *
2857
+ * Selection is the *existing* model — `selectedSpanIdAtom` plus `revealSpan`,
2858
+ * exactly what the Event log uses. A row selects a representative span of its
2859
+ * group, which is what Chrome does when you click an aggregated row.
2860
+ */
2861
+ /** How often the drawer samples the chart's viewport. Not a frame — a gesture. */
2862
+ var VIEWPORT_POLL_MS = 150;
2863
+ /**
2864
+ * The chart's time window, sampled rather than subscribed.
2865
+ *
2866
+ * The viewport moves once per pan frame; aggregating 13k spans at that rate
2867
+ * would put the drawer in the chart's frame budget, which the task forbids. So
2868
+ * this polls on a coarse timer and only re-renders when the window actually
2869
+ * moved — a drag produces a handful of rebuilds, not one per frame.
2870
+ */
2871
+ var useViewport = () => {
2872
+ const [view, setView] = useState(() => chartViewport() ?? {
2873
+ from: -Infinity,
2874
+ to: Infinity
2875
+ });
2876
+ useEffect(() => {
2877
+ const id = setInterval(() => {
2878
+ const next = chartViewport();
2879
+ if (next === void 0) return;
2880
+ setView((current) => current.from === next.from && current.to === next.to ? current : next);
2881
+ }, VIEWPORT_POLL_MS);
2882
+ return () => clearInterval(id);
2883
+ }, []);
2884
+ return view;
2885
+ };
2886
+ /**
2887
+ * The aggregation for the current viewport, filter and trace version.
2888
+ *
2889
+ * Memoised on exactly those four inputs, so a repaint, a hover or a selection
2890
+ * change costs nothing and only real data or real navigation rebuilds it.
2891
+ */
2892
+ var useAggregation = () => {
2893
+ const version = useAtomValue(traceVersionAtom);
2894
+ const filter = useAtomValue(filterAtom);
2895
+ const hides = useAtomValue(filterHidesAtom);
2896
+ const view = useViewport();
2897
+ return useMemo(() => aggregate(traceStore, view.from, view.to, filter, hides), [
2898
+ version,
2899
+ view.from,
2900
+ view.to,
2901
+ filter,
2902
+ hides
2903
+ ]);
2904
+ };
2905
+ /**
2906
+ * Nothing in the aggregation window.
2907
+ *
2908
+ * Unlike the other empty states this one is almost always *reachable* — it
2909
+ * means the current filter or zoom excluded everything, not that the trace is
2910
+ * empty — so the hint names the two controls that caused it.
2911
+ */
2912
+ var Empty = () => /* @__PURE__ */ jsx(Empty$1, {
2913
+ icon: FilterX,
2914
+ title: "No spans in view",
2915
+ hint: "These tabs aggregate the chart's visible range. Reset the zoom, or clear the filter, to widen it."
2916
+ });
2917
+ /** Right-aligned numeric cell, the same width in all three tabs. */
2918
+ var Cell = ({ children }) => /* @__PURE__ */ jsx("span", {
2919
+ className: "w-20 shrink-0 pr-3 text-right tabular-nums",
2920
+ children
2921
+ });
2922
+ /** The Event log's header, plus the row padding the trees want. */
2923
+ var HEAD = `${TABLE_HEAD} py-1 text-[11px]`;
2924
+ var SCROLL = "min-h-0 flex-1 overflow-y-auto text-[11px]";
2925
+ /**
2926
+ * Row chrome, shared with the Event log so the four tabs read as one table.
2927
+ *
2928
+ * These rows are not virtualized — the tree is small and already collapsed —
2929
+ * so unlike the Event log the zebra can key off the DOM with `even:`, and
2930
+ * hovering is a CSS state rather than the atom the chart also listens to.
2931
+ */
2932
+ var rowClass = (selected, dimmed) => `flex w-full items-center px-2 text-left tabular-nums transition-colors odd:bg-[var(--stripe)] hover:bg-[var(--hover)] ${selected ? "text-ink" : ""} ${dimmed ? "text-ink-3" : "text-ink-2"}`;
2933
+ /** Selected rows take the Event log's wash, which must beat the zebra. */
2934
+ var rowStyle = (selected) => selected ? { background: rowBackground(true, false, false) } : {};
2935
+ var Summary = () => {
2936
+ const { summary } = useAggregation();
2937
+ const filter = useAtomValue(filterAtom);
2938
+ const [selectedId, setSelectedId] = useAtom(selectedSpanIdAtom);
2939
+ const [sort, setSort] = useState({
2940
+ key: "self",
2941
+ desc: true
2942
+ });
2943
+ const rows = useMemo(() => {
2944
+ const compare = (a, b) => sort.key === "name" ? a.name.localeCompare(b.name) : a[sort.key] - b[sort.key];
2945
+ return [...summary].sort((a, b) => (sort.desc ? -1 : 1) * compare(a, b));
2946
+ }, [summary, sort]);
2947
+ if (rows.length === 0) return /* @__PURE__ */ jsx(Empty, {});
2948
+ return /* @__PURE__ */ jsxs("div", {
2949
+ className: "flex min-h-0 flex-1 flex-col",
2950
+ children: [/* @__PURE__ */ jsx("div", {
2951
+ className: HEAD,
2952
+ children: [
2953
+ {
2954
+ key: "count",
2955
+ label: "Count"
2956
+ },
2957
+ {
2958
+ key: "total",
2959
+ label: "Total"
2960
+ },
2961
+ {
2962
+ key: "self",
2963
+ label: "Self"
2964
+ },
2965
+ {
2966
+ key: "average",
2967
+ label: "Avg"
2968
+ },
2969
+ {
2970
+ key: "name",
2971
+ label: "Name"
2972
+ }
2973
+ ].map((column) => /* @__PURE__ */ jsxs("button", {
2974
+ type: "button",
2975
+ onClick: () => setSort((current) => current.key === column.key ? {
2976
+ key: column.key,
2977
+ desc: !current.desc
2978
+ } : {
2979
+ key: column.key,
2980
+ desc: column.key !== "name"
2981
+ }),
2982
+ className: `${headCellClass(sort.key === column.key)} py-0 ${column.key === "name" ? "flex-1 pl-3" : "w-20 pr-3 text-right"}`,
2983
+ children: [column.label, sort.key === column.key && (sort.desc ? " ↓" : " ↑")]
2984
+ }, column.key))
2985
+ }), /* @__PURE__ */ jsx("div", {
2986
+ className: SCROLL,
2987
+ children: rows.map((row) => /* @__PURE__ */ jsxs("button", {
2988
+ type: "button",
2989
+ onClick: () => setSelectedId(row.spanId),
2990
+ className: rowClass(row.spanId === selectedId, !matches(row.name, filter.toLowerCase())),
2991
+ style: {
2992
+ height: 22,
2993
+ ...rowStyle(row.spanId === selectedId)
2994
+ },
2995
+ children: [
2996
+ /* @__PURE__ */ jsx(Cell, { children: row.count }),
2997
+ /* @__PURE__ */ jsx(Cell, { children: formatDuration$1(row.total) }),
2998
+ /* @__PURE__ */ jsx(Cell, { children: formatDuration$1(row.self) }),
2999
+ /* @__PURE__ */ jsx(Cell, { children: formatDuration$1(row.average) }),
3000
+ /* @__PURE__ */ jsx("span", {
3001
+ className: `flex-1 truncate pl-3 ${row.failed ? "text-red" : ""}`,
3002
+ children: row.name
3003
+ })
3004
+ ]
3005
+ }, row.name))
3006
+ })]
3007
+ });
3008
+ };
3009
+ /**
3010
+ * One node of either tree, plus its expanded descendants.
3011
+ *
3012
+ * Expansion state is a set of node ids held by the tree, not per-node state:
3013
+ * the tree is rebuilt whenever the viewport or the trace moves, so a node
3014
+ * component cannot hold anything across a rebuild. Node ids are name paths,
3015
+ * which survive a rebuild as long as the path still exists.
3016
+ */
3017
+ /** Disclosure marker: nothing for a leaf, a caret for a node. */
3018
+ var marker = (hasChildren, open) => {
3019
+ if (!hasChildren) return "";
3020
+ return open ? "▾" : "▸";
3021
+ };
3022
+ var Row = ({ node, depth, expanded, onToggle }) => {
3023
+ const [selectedId, setSelectedId] = useAtom(selectedSpanIdAtom);
3024
+ const filter = useAtomValue(filterAtom);
3025
+ const open = expanded.has(node.id);
3026
+ const hasChildren = node.children.length > 0;
3027
+ return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsxs("button", {
3028
+ type: "button",
3029
+ onClick: () => {
3030
+ setSelectedId(node.spanId);
3031
+ if (hasChildren) onToggle(node.id);
3032
+ },
3033
+ className: rowClass(node.spanId === selectedId, !matches(node.name, filter.toLowerCase())),
3034
+ style: {
3035
+ height: 22,
3036
+ ...rowStyle(node.spanId === selectedId)
3037
+ },
3038
+ children: [
3039
+ /* @__PURE__ */ jsx(Cell, { children: node.count }),
3040
+ /* @__PURE__ */ jsx(Cell, { children: formatDuration$1(node.total) }),
3041
+ /* @__PURE__ */ jsx(Cell, { children: formatDuration$1(node.self) }),
3042
+ /* @__PURE__ */ jsxs("span", {
3043
+ className: `flex-1 truncate pl-3 ${node.failed ? "text-red" : ""}`,
3044
+ style: { paddingLeft: depth * 12 + 12 },
3045
+ children: [/* @__PURE__ */ jsx("span", {
3046
+ className: "inline-block w-3 text-ink-3",
3047
+ children: marker(hasChildren, open)
3048
+ }), node.name]
3049
+ })
3050
+ ]
3051
+ }), open && node.children.map((child) => /* @__PURE__ */ jsx(Row, {
3052
+ node: child,
3053
+ depth: depth + 1,
3054
+ expanded,
3055
+ onToggle
3056
+ }, child.id))] });
3057
+ };
3058
+ /** Shared body for the two trees; only the roots and the header differ. */
3059
+ var Tree = ({ roots, selfLabel }) => {
3060
+ const [expanded, setExpanded] = useState(/* @__PURE__ */ new Set());
3061
+ const toggle = (id) => setExpanded((current) => {
3062
+ const next = new Set(current);
3063
+ if (!next.delete(id)) next.add(id);
3064
+ return next;
3065
+ });
3066
+ if (roots.length === 0) return /* @__PURE__ */ jsx(Empty, {});
3067
+ return /* @__PURE__ */ jsxs("div", {
3068
+ className: "flex min-h-0 flex-1 flex-col",
3069
+ children: [/* @__PURE__ */ jsxs("div", {
3070
+ className: HEAD,
3071
+ children: [
3072
+ /* @__PURE__ */ jsx("span", {
3073
+ className: "w-20 pr-3 text-right",
3074
+ children: "Count"
3075
+ }),
3076
+ /* @__PURE__ */ jsx("span", {
3077
+ className: "w-20 pr-3 text-right",
3078
+ children: "Total"
3079
+ }),
3080
+ /* @__PURE__ */ jsx("span", {
3081
+ className: "w-20 pr-3 text-right",
3082
+ children: selfLabel
3083
+ }),
3084
+ /* @__PURE__ */ jsx("span", {
3085
+ className: "flex-1 pl-3",
3086
+ children: "Name"
3087
+ })
3088
+ ]
3089
+ }), /* @__PURE__ */ jsx("div", {
3090
+ className: SCROLL,
3091
+ children: roots.map((node) => /* @__PURE__ */ jsx(Row, {
3092
+ node,
3093
+ depth: 0,
3094
+ expanded,
3095
+ onToggle: toggle
3096
+ }, node.id))
3097
+ })]
3098
+ });
3099
+ };
3100
+ /** Root-first: expand a node to see what it called. */
3101
+ var CallTree = () => /* @__PURE__ */ jsx(Tree, {
3102
+ roots: useAggregation().callTree,
3103
+ selfLabel: "Self"
3104
+ });
3105
+ /** Leaf-first: expand a name to see who called it. */
3106
+ var BottomUp = () => /* @__PURE__ */ jsx(Tree, {
3107
+ roots: useAggregation().bottomUp,
3108
+ selfLabel: "Self"
3109
+ });
3110
+ //#endregion
3111
+ //#region app/src/components/Drawer.tsx
3112
+ /**
3113
+ * The bottom tabbed drawer.
3114
+ *
3115
+ * Four tabs over one trace: the Event log is per span, the other three are
3116
+ * aggregations of the visible range. Only the active tab is mounted, so a
3117
+ * closed Summary costs nothing — the aggregation is built by the tab, not by
3118
+ * the drawer.
3119
+ *
3120
+ * The tab bar is hand-rolled rather than `@radix-ui/react-tabs`. Radix would
3121
+ * be a new dependency to render four buttons, and its `TabsContent` keeps a
3122
+ * panel per tab — the opposite of the mount-only-the-active-tab decision above.
3123
+ * The roles and the roving tabindex below are the whole of what it would buy.
3124
+ */
3125
+ var TABS = [
3126
+ {
3127
+ id: "log",
3128
+ label: "Event log",
3129
+ render: () => /* @__PURE__ */ jsx(EventLog, {})
3130
+ },
3131
+ {
3132
+ id: "summary",
3133
+ label: "Summary",
3134
+ render: () => /* @__PURE__ */ jsx(Summary, {})
3135
+ },
3136
+ {
3137
+ id: "bottom-up",
3138
+ label: "Bottom-up",
3139
+ render: () => /* @__PURE__ */ jsx(BottomUp, {})
3140
+ },
3141
+ {
3142
+ id: "call-tree",
3143
+ label: "Call tree",
3144
+ render: () => /* @__PURE__ */ jsx(CallTree, {})
3145
+ }
3146
+ ];
3147
+ /** Arrow-key step within the tab list, wrapping at both ends. */
3148
+ var STEP = {
3149
+ ArrowLeft: -1,
3150
+ ArrowRight: 1
3151
+ };
3152
+ var Drawer = () => {
3153
+ const [height, setHeight] = useState(220);
3154
+ const [collapsed, setCollapsed] = useState(false);
3155
+ const [active, setActive] = useState("log");
3156
+ const tabList = useRef(null);
3157
+ /**
3158
+ * Drag the top edge to resize. Pointer capture so it survives leaving the bar.
3159
+ *
3160
+ * Capturing redirects the `pointerup` to the bar, so no `click` ever fires on
3161
+ * a control inside it. The guard therefore lives **here**, on the bar, rather
3162
+ * than as a `stopPropagation` on each child: a drag only starts when the
3163
+ * pointer landed on the bar itself, so every button in the bar — present and
3164
+ * future — stays clickable without having to remember to opt out.
3165
+ */
3166
+ const onPointerDown = (event) => {
3167
+ if (event.target !== event.currentTarget) return;
3168
+ event.currentTarget.setPointerCapture(event.pointerId);
3169
+ const startY = event.clientY;
3170
+ const startHeight = height;
3171
+ const move = (moveEvent) => {
3172
+ setHeight(Math.min(Math.max(startHeight - (moveEvent.clientY - startY), 80), 600));
3173
+ };
3174
+ const up = () => {
3175
+ globalThis.removeEventListener("pointermove", move);
3176
+ globalThis.removeEventListener("pointerup", up);
3177
+ };
3178
+ globalThis.addEventListener("pointermove", move);
3179
+ globalThis.addEventListener("pointerup", up);
3180
+ };
3181
+ /**
3182
+ * Arrow keys move the selection, as the tabs pattern requires.
3183
+ *
3184
+ * Only the active tab is reachable with Tab (the roving tabindex below), so
3185
+ * without this the other three would be unreachable from the keyboard.
3186
+ * Focus has to move with the selection or the next arrow press is read by
3187
+ * whatever still holds focus.
3188
+ */
3189
+ const onKeyDown = (event) => {
3190
+ const step = STEP[event.key];
3191
+ if (step === void 0) return;
3192
+ event.preventDefault();
3193
+ const next = TABS[(TABS.findIndex((tab) => tab.id === active) + step + TABS.length) % TABS.length];
3194
+ setActive(next.id);
3195
+ tabList.current?.querySelector(`[data-tab="${next.id}"]`)?.focus();
3196
+ };
3197
+ return /* @__PURE__ */ jsxs("div", {
3198
+ className: "flex shrink-0 flex-col border-t border-line bg-surface",
3199
+ style: { height: collapsed ? "auto" : height },
3200
+ children: [/* @__PURE__ */ jsxs("div", {
3201
+ onPointerDown: collapsed ? void 0 : onPointerDown,
3202
+ className: `flex h-8 shrink-0 items-center gap-1 px-2 ${collapsed ? "" : "cursor-row-resize"}`,
3203
+ children: [/* @__PURE__ */ jsx("div", {
3204
+ ref: tabList,
3205
+ role: "tablist",
3206
+ "aria-label": "Trace views",
3207
+ onKeyDown,
3208
+ className: "flex items-center gap-1",
3209
+ children: TABS.map((tab) => {
3210
+ const selected = tab.id === active;
3211
+ return /* @__PURE__ */ jsx("button", {
3212
+ type: "button",
3213
+ role: "tab",
3214
+ id: `drawer-tab-${tab.id}`,
3215
+ "data-tab": tab.id,
3216
+ "aria-selected": selected,
3217
+ "aria-controls": "drawer-panel",
3218
+ tabIndex: selected ? 0 : -1,
3219
+ onClick: () => setActive(tab.id),
3220
+ className: `rounded-chip px-2 py-1 text-[11px] transition-colors ${selected ? "bg-hover text-ink" : "text-ink-3 hover:bg-hover hover:text-ink-2"}`,
3221
+ children: tab.label
3222
+ }, tab.id);
3223
+ })
3224
+ }), /* @__PURE__ */ jsx(Button, {
3225
+ type: "button",
3226
+ variant: "quiet",
3227
+ size: "xs",
3228
+ onClick: () => setCollapsed((value) => !value),
3229
+ "aria-expanded": !collapsed,
3230
+ "aria-controls": "drawer-panel",
3231
+ className: "ml-auto text-ink-3 hover:text-ink",
3232
+ children: collapsed ? "expand" : "collapse"
3233
+ })]
3234
+ }), !collapsed && /* @__PURE__ */ jsx("div", {
3235
+ id: "drawer-panel",
3236
+ role: "tabpanel",
3237
+ "aria-labelledby": `drawer-tab-${active}`,
3238
+ className: "flex min-h-0 flex-1 flex-col",
3239
+ children: TABS.find((tab) => tab.id === active).render()
3240
+ })]
3241
+ });
3242
+ };
3243
+ //#endregion
3244
+ //#region app/src/components/SpanDetail.tsx
3245
+ /**
3246
+ * Detail panel for the selected span — the full record the tooltip truncates.
3247
+ *
3248
+ * Reads the shared `selectedSpanIdAtom` and resolves the id against the store
3249
+ * on each render, so a re-depthed or newly-ended span shows its current state
3250
+ * rather than a snapshot taken at click time.
3251
+ */
3252
+ var Field = ({ label, value }) => /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("dt", {
3253
+ className: "truncate text-ink-3",
3254
+ children: label
3255
+ }), /* @__PURE__ */ jsx("dd", {
3256
+ className: "truncate text-right tabular-nums text-ink",
3257
+ children: value
3258
+ })] });
3259
+ /** The panel's own chrome, matching the sidebar and drawer it sits beside. */
3260
+ var PANEL = "flex w-72 shrink-0 flex-col border-l border-line bg-surface text-[11px]";
3261
+ /** Section rule, so every block in the panel divides the same way. */
3262
+ var SECTION = "border-b border-line px-2.5 py-2";
3263
+ /** Small uppercase section label — the panel's only non-data type. */
3264
+ var LABEL = "mb-1 text-[10px] uppercase tracking-wider text-ink-3";
3265
+ /**
3266
+ * The failure block's red wash, as a flat `background-image` over the panel's
3267
+ * opaque `bg-surface`.
3268
+ *
3269
+ * Foundation's `-tint` tokens are translucent in dark (`… / 0.14`), so using
3270
+ * one as a `background-color` here would let the chart behind the panel read
3271
+ * through in dark only. Same fix, same reason, as `tintWash` in `TraceFile`.
3272
+ */
3273
+ var redWash = { backgroundImage: "linear-gradient(var(--red-tint), var(--red-tint))" };
3274
+ /**
3275
+ * The panel's frame: one header row carrying every panel-level control, then
3276
+ * whatever fills it.
3277
+ *
3278
+ * Clear-selection lives *here* rather than beside the span's name. With the
3279
+ * panel header added, a `✕` in the first content block sat directly under the
3280
+ * collapse chevron — two dismissal-shaped controls, one above the other, doing
3281
+ * different things. Both belong to the panel, so both belong in its header,
3282
+ * ordered clear-then-collapse: the narrower action first.
3283
+ */
3284
+ var Frame = ({ onToggle, onClear, title, children }) => /* @__PURE__ */ jsxs("aside", {
3285
+ id: "detail-panel",
3286
+ className: PANEL,
3287
+ children: [/* @__PURE__ */ jsxs(PanelHeader, {
3288
+ title,
3289
+ children: [onClear !== void 0 && /* @__PURE__ */ jsx(Button, {
3290
+ type: "button",
3291
+ variant: "quiet",
3292
+ size: "xs",
3293
+ onClick: onClear,
3294
+ "aria-label": "Clear selection",
3295
+ title: "Clear selection",
3296
+ className: "size-6 shrink-0 px-0 text-ink-3 hover:text-ink",
3297
+ children: /* @__PURE__ */ jsx(X, { className: "size-3.5" })
3298
+ }), /* @__PURE__ */ jsx(CollapseButton, {
3299
+ edge: "right",
3300
+ collapsed: false,
3301
+ onToggle,
3302
+ label: "Hide span detail",
3303
+ controls: "detail-panel"
3304
+ })]
3305
+ }), /* @__PURE__ */ jsx("div", {
3306
+ className: "min-h-0 flex-1 overflow-y-auto",
3307
+ children
3308
+ })]
3309
+ });
3310
+ var SpanDetail = () => {
3311
+ const [selectedId, setSelectedId] = useAtom(selectedSpanIdAtom);
3312
+ const [collapsed, toggle] = usePanel("detail");
3313
+ useAtomValue(traceVersionAtom);
3314
+ if (collapsed) return /* @__PURE__ */ jsx(CollapsedRail, {
3315
+ edge: "right",
3316
+ title: "Span",
3317
+ onToggle: toggle,
3318
+ id: "detail-panel"
3319
+ });
3320
+ if (selectedId === void 0) return /* @__PURE__ */ jsx(Frame, {
3321
+ onToggle: toggle,
3322
+ title: "Span",
3323
+ children: /* @__PURE__ */ jsx(Empty$1, {
3324
+ icon: MousePointerClick,
3325
+ title: "No span selected",
3326
+ hint: "Click a bar in the flame chart, or a row in the event log, to see its timings, attributes and logs."
3327
+ })
3328
+ });
3329
+ const span = traceStore.spans.get(selectedId);
3330
+ if (span === void 0) return /* @__PURE__ */ jsx(Frame, {
3331
+ onToggle: toggle,
3332
+ title: "Span",
3333
+ children: /* @__PURE__ */ jsx(Empty$1, {
3334
+ icon: SearchX,
3335
+ title: "Span no longer in the trace",
3336
+ hint: "The collector's backlog rolled past it. Select another span, or reload to start a fresh trace."
3337
+ })
3338
+ });
3339
+ const now = traceStore.stats().duration;
3340
+ const { total, self } = timings(traceStore, span, now);
3341
+ const attributes = Object.entries(span.attributes);
3342
+ const logs = traceStore.logs.filter((log) => log.spanId === span.spanId);
3343
+ return /* @__PURE__ */ jsxs(Frame, {
3344
+ onToggle: toggle,
3345
+ onClear: () => setSelectedId(void 0),
3346
+ title: "Span",
3347
+ children: [
3348
+ /* @__PURE__ */ jsxs("div", {
3349
+ className: SECTION,
3350
+ children: [/* @__PURE__ */ jsx("p", {
3351
+ className: "break-words text-ink",
3352
+ children: span.name
3353
+ }), /* @__PURE__ */ jsx("p", {
3354
+ className: "mt-0.5 text-ink-3",
3355
+ children: span.kind
3356
+ })]
3357
+ }),
3358
+ /* @__PURE__ */ jsxs("dl", {
3359
+ className: `grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 ${SECTION}`,
3360
+ children: [
3361
+ /* @__PURE__ */ jsx(Field, {
3362
+ label: "start",
3363
+ value: formatDuration$1(span.start)
3364
+ }),
3365
+ /* @__PURE__ */ jsx(Field, {
3366
+ label: "total",
3367
+ value: formatDuration$1(total)
3368
+ }),
3369
+ /* @__PURE__ */ jsx(Field, {
3370
+ label: "self",
3371
+ value: formatDuration$1(self)
3372
+ }),
3373
+ /* @__PURE__ */ jsx(Field, {
3374
+ label: "depth",
3375
+ value: String(span.depth)
3376
+ }),
3377
+ /* @__PURE__ */ jsx(Field, {
3378
+ label: "children",
3379
+ value: String(span.children.length)
3380
+ }),
3381
+ span.fiberId !== void 0 && /* @__PURE__ */ jsx(Field, {
3382
+ label: "fiber",
3383
+ value: String(span.fiberId)
3384
+ }),
3385
+ /* @__PURE__ */ jsx(Field, {
3386
+ label: "state",
3387
+ value: span.end === void 0 ? "running" : "ended"
3388
+ })
3389
+ ]
3390
+ }),
3391
+ span.outcome?._tag === "Failure" && /* @__PURE__ */ jsxs("div", {
3392
+ className: SECTION,
3393
+ style: redWash,
3394
+ children: [
3395
+ /* @__PURE__ */ jsx("p", {
3396
+ className: "text-ink-2",
3397
+ children: span.outcome.kind
3398
+ }),
3399
+ /* @__PURE__ */ jsx("p", {
3400
+ className: "mt-1 break-words text-red",
3401
+ children: span.outcome.error
3402
+ }),
3403
+ span.outcome.stack !== void 0 && /* @__PURE__ */ jsx("pre", {
3404
+ className: "mt-1.5 overflow-x-auto rounded-chip bg-inset p-2 text-[10px] leading-[1.6] whitespace-pre-wrap text-ink-2",
3405
+ children: span.outcome.stack
3406
+ })
3407
+ ]
3408
+ }),
3409
+ attributes.length > 0 && /* @__PURE__ */ jsxs("div", {
3410
+ className: SECTION,
3411
+ children: [/* @__PURE__ */ jsx("p", {
3412
+ className: LABEL,
3413
+ children: "Attributes"
3414
+ }), /* @__PURE__ */ jsx("dl", {
3415
+ className: "grid grid-cols-[auto_1fr] gap-x-3 gap-y-1",
3416
+ children: attributes.map(([key, value]) => /* @__PURE__ */ jsxs("div", {
3417
+ className: "contents",
3418
+ children: [/* @__PURE__ */ jsx("dt", {
3419
+ className: "truncate text-ink-3",
3420
+ children: key
3421
+ }), /* @__PURE__ */ jsx("dd", {
3422
+ className: "break-words text-right text-ink-2",
3423
+ children: formatValue(value)
3424
+ })]
3425
+ }, key))
3426
+ })]
3427
+ }),
3428
+ span.events.length > 0 && /* @__PURE__ */ jsxs("div", {
3429
+ className: SECTION,
3430
+ children: [/* @__PURE__ */ jsx("p", {
3431
+ className: LABEL,
3432
+ children: "Events"
3433
+ }), span.events.map((event, index) => /* @__PURE__ */ jsxs("div", {
3434
+ className: "flex justify-between gap-2 py-0.5",
3435
+ children: [/* @__PURE__ */ jsx("span", {
3436
+ className: "truncate text-ink-2",
3437
+ children: event.name
3438
+ }), /* @__PURE__ */ jsx("span", {
3439
+ className: "shrink-0 tabular-nums text-ink-3",
3440
+ children: formatDuration$1(event.time - span.start)
3441
+ })]
3442
+ }, `${event.name}-${index}`))]
3443
+ }),
3444
+ logs.length > 0 && /* @__PURE__ */ jsxs("div", {
3445
+ className: "px-2.5 py-2",
3446
+ children: [/* @__PURE__ */ jsx("p", {
3447
+ className: LABEL,
3448
+ children: "Logs"
3449
+ }), logs.map((log, index) => /* @__PURE__ */ jsxs("div", {
3450
+ className: "py-0.5",
3451
+ children: [
3452
+ /* @__PURE__ */ jsx("span", {
3453
+ className: log.level === "Error" ? "text-red" : "text-ink-3",
3454
+ children: log.level
3455
+ }),
3456
+ " ",
3457
+ /* @__PURE__ */ jsx("span", {
3458
+ className: "text-ink-2",
3459
+ children: formatValue(log.message)
3460
+ })
3461
+ ]
3462
+ }, index))]
3463
+ })
3464
+ ]
3465
+ });
3466
+ };
3467
+ //#endregion
3468
+ //#region app/src/components/ThemeToggle.tsx
3469
+ /**
3470
+ * The theme control: a three-state segmented switch for `light | dark | system`.
3471
+ *
3472
+ * A segmented group rather than a single cycling button, because the
3473
+ * preference has three values and `system` is not a state you can discover by
3474
+ * clicking through — with three radios the current choice and the available
3475
+ * choices are both visible at once.
3476
+ *
3477
+ * It is a `radiogroup`, so a screen reader announces "3 of 3, system,
3478
+ * selected", and arrow keys move between options the way a native radio group
3479
+ * does. Only the checked option is in the tab order (`tabIndex`), which is the
3480
+ * standard roving-tabindex pattern — Tab reaches the group, arrows move inside it.
3481
+ */
3482
+ var OPTIONS = [
3483
+ {
3484
+ value: "light",
3485
+ label: "Light",
3486
+ Icon: Sun
3487
+ },
3488
+ {
3489
+ value: "dark",
3490
+ label: "Dark",
3491
+ Icon: Moon
3492
+ },
3493
+ {
3494
+ value: "system",
3495
+ label: "System",
3496
+ Icon: Monitor
3497
+ }
3498
+ ];
3499
+ /** No-op subscribe: {@link useHydrated} never changes after the first commit. */
3500
+ var noSubscribe = () => () => {};
3501
+ /**
3502
+ * `false` during server render and during hydration, `true` afterwards.
3503
+ *
3504
+ * `useSyncExternalStore` is the sanctioned way to ask this: React calls the
3505
+ * third argument on the server and while hydrating, and the second one after,
3506
+ * which is precisely the distinction needed — and unlike a `useEffect` + state
3507
+ * pair, React guarantees the switch happens once hydration has committed
3508
+ * rather than racing it.
3509
+ */
3510
+ var useHydrated = () => useSyncExternalStore(noSubscribe, () => true, () => false);
3511
+ var ThemeToggle = () => {
3512
+ const [stored, setTheme] = useAtom(themeAtom);
3513
+ const theme = useHydrated() ? stored : DEFAULT_THEME;
3514
+ /** Arrow keys cycle, as a native radio group does. */
3515
+ const onKeyDown = (event) => {
3516
+ const step = {
3517
+ ArrowRight: 1,
3518
+ ArrowDown: 1,
3519
+ ArrowLeft: -1,
3520
+ ArrowUp: -1
3521
+ }[event.key];
3522
+ if (step === void 0) return;
3523
+ event.preventDefault();
3524
+ const next = OPTIONS[(OPTIONS.findIndex((option) => option.value === theme) + step + OPTIONS.length) % OPTIONS.length];
3525
+ setTheme(next.value);
3526
+ event.currentTarget.querySelector(`[data-theme="${next.value}"]`)?.focus();
3527
+ };
3528
+ return /* @__PURE__ */ jsx("div", {
3529
+ role: "radiogroup",
3530
+ "aria-label": "Colour theme",
3531
+ onKeyDown,
3532
+ className: "flex items-center gap-0.5 rounded-control bg-inset p-0.5 shadow-hairline",
3533
+ children: OPTIONS.map(({ value, label, Icon }) => {
3534
+ const checked = theme === value;
3535
+ return /* @__PURE__ */ jsx("button", {
3536
+ type: "button",
3537
+ role: "radio",
3538
+ "aria-checked": checked,
3539
+ "aria-label": label,
3540
+ title: label,
3541
+ "data-theme": value,
3542
+ tabIndex: checked ? 0 : -1,
3543
+ onClick: () => setTheme(value),
3544
+ className: `flex size-6 items-center justify-center rounded-chip transition-colors ${checked ? "bg-surface text-ink shadow-btn" : "text-ink-3 hover:text-ink-2"}`,
3545
+ children: /* @__PURE__ */ jsx(Icon, { className: "size-3.5" })
3546
+ }, value);
3547
+ })
3548
+ });
3549
+ };
3550
+ //#endregion
3551
+ //#region app/src/components/atoms/ValuePill.tsx
3552
+ var TONES = {
3553
+ neutral: {
3554
+ cls: "bg-field text-ink-2",
3555
+ ring: "var(--shadow-hairline)"
3556
+ },
3557
+ green: {
3558
+ cls: "bg-green-tint text-green",
3559
+ ring: "0 0 0 1px color-mix(in oklch, var(--green) 28%, transparent)"
3560
+ },
3561
+ orange: {
3562
+ cls: "bg-orange-tint text-orange",
3563
+ ring: "0 0 0 1px color-mix(in oklch, var(--orange) 28%, transparent)"
3564
+ },
3565
+ red: {
3566
+ cls: "bg-red-tint text-red",
3567
+ ring: "0 0 0 1px color-mix(in oklch, var(--red) 28%, transparent)"
3568
+ },
3569
+ accent: {
3570
+ cls: "bg-accent-tint text-accent-ink",
3571
+ ring: "0 0 0 1px color-mix(in oklch, var(--accent) 28%, transparent)"
3572
+ }
3573
+ };
3574
+ /** Inline value badge — a plain value (a date, a name, a count) set off in
3575
+ * prose. Softer than a StatusPill (no dot) and not a mono token (see Chip). */
3576
+ var ValuePill = ({ children, tone = "neutral", className = "" }) => {
3577
+ const t = TONES[tone];
3578
+ return /* @__PURE__ */ jsx("span", {
3579
+ className: `mx-0.5 inline-flex items-center rounded-full px-1.5 py-0
3580
+ align-middle text-[12px] font-medium ${t.cls} ${className}`,
3581
+ style: { boxShadow: t.ring },
3582
+ children
3583
+ });
3584
+ };
3585
+ //#endregion
3586
+ //#region app/src/components/TraceFile.tsx
3587
+ /**
3588
+ * Saving the selected trace to a file, and loading one back.
3589
+ *
3590
+ * Drop target is the whole window rather than a zone in the layout: when the
3591
+ * collector is down there is no chart to drop onto, and "drop a trace anywhere
3592
+ * on the page" is the behaviour every profiler has.
3593
+ */
3594
+ /**
3595
+ * The shared surface for both header notices.
3596
+ *
3597
+ * The error and the truncation warning sit in the same place and say the same
3598
+ * kind of thing — "the file you opened is not what you expected" — so they get
3599
+ * one treatment and differ only by tone, the same red/orange split the stats
3600
+ * row and connection badge use.
3601
+ */
3602
+ var NOTICE = "absolute inset-x-0 top-11 z-20 mx-auto flex w-fit max-w-xl items-baseline gap-3 rounded-card bg-surface px-3 py-2 text-xs shadow-overlay";
3603
+ /**
3604
+ * A notice's tone wash, as a flat `background-image` over {@link NOTICE}'s
3605
+ * opaque `bg-surface`.
3606
+ *
3607
+ * Foundation's `-tint` tokens are *translucent* in dark (`… / 0.14`), so
3608
+ * setting one as the notice's `background-color` lets the toolbar behind it
3609
+ * read straight through. Painting it as a one-stop gradient layers the tone on
3610
+ * top of an opaque surface instead, which is what a floating panel needs.
3611
+ */
3612
+ var tintWash = (tone) => ({ backgroundImage: `linear-gradient(var(--${tone}-tint), var(--${tone}-tint))` });
3613
+ /** Hands the text to the browser as a download. */
3614
+ var download = (name, text) => {
3615
+ const url = URL.createObjectURL(new Blob([text], { type: "application/x-ndjson" }));
3616
+ const anchor = document.createElement("a");
3617
+ anchor.href = url;
3618
+ anchor.download = name;
3619
+ anchor.click();
3620
+ URL.revokeObjectURL(url);
3621
+ };
3622
+ var TraceFileControls = () => {
3623
+ const registry = useContext(RegistryContext);
3624
+ const session = useAtomValue(selectedSessionAtom);
3625
+ const stats = useAtomValue(traceStatsAtom);
3626
+ const loaded = useAtomValue(loadedSessionsAtom);
3627
+ const [error, setError] = useState(void 0);
3628
+ const [dragging, setDragging] = useState(false);
3629
+ const input = useRef(null);
3630
+ const open = (file) => {
3631
+ setError(void 0);
3632
+ file.text().then((text) => {
3633
+ const result = addLoadedTrace(registry, text);
3634
+ if (Result.isFailure(result)) setError(result.failure);
3635
+ }).catch((cause) => setError(`Could not read ${file.name}: ${String(cause)}`));
3636
+ };
3637
+ useEffect(() => {
3638
+ const over = (event) => {
3639
+ if (event.dataTransfer?.types.includes("Files") !== true) return;
3640
+ event.preventDefault();
3641
+ setDragging(true);
3642
+ };
3643
+ const leave = (event) => {
3644
+ if (event.relatedTarget === null) setDragging(false);
3645
+ };
3646
+ const drop = (event) => {
3647
+ const file = event.dataTransfer?.files[0];
3648
+ if (file === void 0) return;
3649
+ event.preventDefault();
3650
+ setDragging(false);
3651
+ open(file);
3652
+ };
3653
+ globalThis.addEventListener("dragover", over);
3654
+ globalThis.addEventListener("dragleave", leave);
3655
+ globalThis.addEventListener("drop", drop);
3656
+ return () => {
3657
+ globalThis.removeEventListener("dragover", over);
3658
+ globalThis.removeEventListener("dragleave", leave);
3659
+ globalThis.removeEventListener("drop", drop);
3660
+ };
3661
+ });
3662
+ const save = () => {
3663
+ if (session === void 0) return;
3664
+ const savedAt = Date.now();
3665
+ const text = serializeTraceFile(session, saveableMessages(registry, session.sessionId), savedAt);
3666
+ download(traceFileName(session, savedAt), text);
3667
+ };
3668
+ const truncated = loaded.find((entry) => entry.session.sessionId === session?.sessionId && entry.truncatedLines > 0);
3669
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
3670
+ /* @__PURE__ */ jsxs("div", {
3671
+ className: "flex items-center gap-1.5",
3672
+ children: [
3673
+ /* @__PURE__ */ jsx(Button, {
3674
+ type: "button",
3675
+ size: "xs",
3676
+ onClick: save,
3677
+ disabled: session === void 0 || stats.spans === 0,
3678
+ "data-testid": "save-trace",
3679
+ children: "save"
3680
+ }),
3681
+ /* @__PURE__ */ jsx(Button, {
3682
+ type: "button",
3683
+ size: "xs",
3684
+ onClick: () => input.current?.click(),
3685
+ "data-testid": "open-trace",
3686
+ children: "open"
3687
+ }),
3688
+ /* @__PURE__ */ jsx("input", {
3689
+ ref: input,
3690
+ type: "file",
3691
+ accept: traceFileExtension,
3692
+ "data-testid": "trace-file-input",
3693
+ className: "hidden",
3694
+ onChange: (event) => {
3695
+ const file = event.target.files?.[0];
3696
+ if (file !== void 0) open(file);
3697
+ event.target.value = "";
3698
+ }
3699
+ })
3700
+ ]
3701
+ }),
3702
+ error !== void 0 && /* @__PURE__ */ jsxs("div", {
3703
+ "data-testid": "trace-file-error",
3704
+ className: `${NOTICE} text-red`,
3705
+ style: tintWash("red"),
3706
+ children: [error, /* @__PURE__ */ jsx("button", {
3707
+ type: "button",
3708
+ onClick: () => setError(void 0),
3709
+ className: "shrink-0 text-ink-2 transition-colors hover:text-ink",
3710
+ children: "dismiss"
3711
+ })]
3712
+ }),
3713
+ truncated !== void 0 && error === void 0 && /* @__PURE__ */ jsx("div", {
3714
+ "data-testid": "trace-file-truncated",
3715
+ className: `${NOTICE} text-orange`,
3716
+ style: tintWash("orange"),
3717
+ children: "This trace file was cut short mid-write; everything before the cut is shown."
3718
+ }),
3719
+ dragging && /* @__PURE__ */ jsx("div", {
3720
+ className: "pointer-events-none fixed inset-0 z-30 flex items-center justify-center bg-page/80",
3721
+ children: /* @__PURE__ */ jsxs("p", {
3722
+ className: "rounded-card border border-dashed border-line-strong bg-surface px-6 py-4 text-xs text-ink-2 shadow-overlay",
3723
+ children: [
3724
+ "Drop a ",
3725
+ ".eitrace",
3726
+ " file to load it"
3727
+ ]
3728
+ })
3729
+ })
3730
+ ] });
3731
+ };
3732
+ //#endregion
3733
+ //#region app/src/components/Shell.tsx
3734
+ /**
3735
+ * The app shell: header, session sidebar, and the pane the flame chart will
3736
+ * fill.
3737
+ *
3738
+ * Every component here reads atoms. None of them touch `traceStore` except via
3739
+ * {@link traceStatsAtom}, which is derived from the sampled version — so a span
3740
+ * arriving never renders this tree directly.
3741
+ */
3742
+ var formatTime = (epochMillis) => new Date(epochMillis).toLocaleTimeString([], { hour12: false });
3743
+ var formatDuration = (millis) => {
3744
+ if (millis < 1e3) return `${millis.toFixed(1)}ms`;
3745
+ return `${(millis / 1e3).toFixed(2)}s`;
3746
+ };
3747
+ /**
3748
+ * Connection states, as a pill tone and a dot colour.
3749
+ *
3750
+ * A dropped collector is an error, so it takes the red tone. A healthy socket
3751
+ * is deliberately the *neutral* pill with a green dot rather than a green
3752
+ * pill: the steady state is the one you see all day, and it should not shout.
3753
+ */
3754
+ var CONNECTION = {
3755
+ Connected: {
3756
+ label: "connected",
3757
+ tone: "neutral",
3758
+ dot: "bg-green"
3759
+ },
3760
+ Connecting: {
3761
+ label: "connecting",
3762
+ tone: "neutral",
3763
+ dot: "bg-ink-3 animate-pulse"
3764
+ },
3765
+ Disconnected: {
3766
+ label: "disconnected",
3767
+ tone: "red",
3768
+ dot: "bg-red"
3769
+ }
3770
+ };
3771
+ var ConnectionBadge = () => {
3772
+ const status = useAtomValue(connectionStatusAtom);
3773
+ const errors = useAtomValue(decodeErrorsAtom);
3774
+ const { label, tone, dot } = CONNECTION[status._tag];
3775
+ return /* @__PURE__ */ jsxs("div", {
3776
+ className: "flex items-center gap-1.5",
3777
+ children: [/* @__PURE__ */ jsxs(ValuePill, {
3778
+ tone,
3779
+ className: "gap-1.5",
3780
+ children: [/* @__PURE__ */ jsx("span", { className: `size-1.5 rounded-full ${dot}` }), label]
3781
+ }), errors > 0 && /* @__PURE__ */ jsxs(ValuePill, {
3782
+ tone: "red",
3783
+ children: [errors, " undecodable"]
3784
+ })]
3785
+ });
3786
+ };
3787
+ var Stat = ({ label, value }) => /* @__PURE__ */ jsxs("div", {
3788
+ className: "flex items-baseline gap-1.5",
3789
+ children: [/* @__PURE__ */ jsx("span", {
3790
+ className: "text-ink-3",
3791
+ children: label
3792
+ }), /* @__PURE__ */ jsx("span", {
3793
+ className: "tabular-nums text-ink",
3794
+ children: value
3795
+ })]
3796
+ });
3797
+ var Stats = () => {
3798
+ const stats = useAtomValue(traceStatsAtom);
3799
+ const wallClock = useAtomValue(wallClockOriginAtom);
3800
+ return /* @__PURE__ */ jsxs("div", {
3801
+ className: "flex items-center gap-4 text-xs",
3802
+ children: [
3803
+ wallClock !== void 0 && /* @__PURE__ */ jsx(Stat, {
3804
+ label: "t0",
3805
+ value: formatTime(wallClock)
3806
+ }),
3807
+ /* @__PURE__ */ jsx(Stat, {
3808
+ label: "spans",
3809
+ value: String(stats.spans)
3810
+ }),
3811
+ /* @__PURE__ */ jsx(Stat, {
3812
+ label: "open",
3813
+ value: String(stats.openSpans)
3814
+ }),
3815
+ /* @__PURE__ */ jsx(Stat, {
3816
+ label: "logs",
3817
+ value: String(stats.logs)
3818
+ }),
3819
+ /* @__PURE__ */ jsx(Stat, {
3820
+ label: "events",
3821
+ value: String(stats.events)
3822
+ }),
3823
+ /* @__PURE__ */ jsx(Stat, {
3824
+ label: "dur",
3825
+ value: formatDuration(stats.duration)
3826
+ }),
3827
+ stats.errors > 0 && /* @__PURE__ */ jsxs("div", {
3828
+ className: "flex items-baseline gap-1.5",
3829
+ children: [/* @__PURE__ */ jsx("span", {
3830
+ className: "text-ink-3",
3831
+ children: "errors"
3832
+ }), /* @__PURE__ */ jsx(ValuePill, {
3833
+ tone: "red",
3834
+ className: "tabular-nums",
3835
+ children: stats.errors
3836
+ })]
3837
+ })
3838
+ ]
3839
+ });
3840
+ };
3841
+ var SessionRow = ({ session, selected, onSelect }) => /* @__PURE__ */ jsxs("button", {
3842
+ type: "button",
3843
+ onClick: onSelect,
3844
+ className: `w-full border-l-2 px-2 py-1.5 text-left transition-colors ${selected ? "border-accent bg-hover text-ink" : "border-transparent text-ink-2 hover:bg-hover hover:text-ink"}`,
3845
+ children: [/* @__PURE__ */ jsxs("div", {
3846
+ className: "flex items-center gap-2",
3847
+ children: [/* @__PURE__ */ jsx("span", { className: `size-1.5 shrink-0 rounded-full ${session.active ? "bg-green" : "bg-line-strong"}` }), /* @__PURE__ */ jsx("span", {
3848
+ className: "truncate text-xs",
3849
+ children: programLabel(session.program)
3850
+ })]
3851
+ }), /* @__PURE__ */ jsxs("div", {
3852
+ className: "mt-0.5 flex justify-between pl-3.5 text-[10px] text-ink-3",
3853
+ children: [/* @__PURE__ */ jsx("span", { children: isLoadedSession(session.sessionId) ? "file" : `pid ${session.pid}` }), /* @__PURE__ */ jsx("span", {
3854
+ className: "tabular-nums",
3855
+ children: formatTime(session.clock.wallClockEpochMillis)
3856
+ })]
3857
+ })]
3858
+ });
3859
+ /** Empty sessions list: waiting on the collector, or waiting on a program. */
3860
+ var NoSessions = ({ connected }) => connected ? /* @__PURE__ */ jsx(Empty$1, {
3861
+ icon: Radio,
3862
+ title: "No sessions yet",
3863
+ hint: /* @__PURE__ */ jsxs(Fragment, { children: [
3864
+ "Run a program with the inspect layer attached, or drop a saved",
3865
+ " ",
3866
+ /* @__PURE__ */ jsx("code", {
3867
+ className: "text-ink-2",
3868
+ children: ".eitrace"
3869
+ }),
3870
+ " file anywhere on this page."
3871
+ ] })
3872
+ }) : /* @__PURE__ */ jsx(Empty$1, {
3873
+ icon: PlugZap,
3874
+ title: "Waiting for the collector",
3875
+ hint: /* @__PURE__ */ jsxs(Fragment, { children: [
3876
+ "Start it with ",
3877
+ /* @__PURE__ */ jsx("code", {
3878
+ className: "text-ink-2",
3879
+ children: "bun run collector"
3880
+ }),
3881
+ ". This page reconnects on its own."
3882
+ ] })
3883
+ });
3884
+ var Sessions = () => {
3885
+ const sessions = useAtomValue(sessionsAtom);
3886
+ const [selectedId, setSelectedId] = useAtom(selectedSessionIdAtom);
3887
+ const status = useAtomValue(connectionStatusAtom);
3888
+ const [collapsed, toggle] = usePanel("sessions");
3889
+ if (collapsed) return /* @__PURE__ */ jsx(CollapsedRail, {
3890
+ edge: "left",
3891
+ title: "Sessions",
3892
+ onToggle: toggle,
3893
+ id: "sessions-panel"
3894
+ });
3895
+ return /* @__PURE__ */ jsxs("aside", {
3896
+ id: "sessions-panel",
3897
+ className: "flex w-56 shrink-0 flex-col border-r border-line bg-surface",
3898
+ children: [/* @__PURE__ */ jsx(PanelHeader, {
3899
+ title: "Sessions",
3900
+ children: /* @__PURE__ */ jsx(CollapseButton, {
3901
+ edge: "left",
3902
+ collapsed: false,
3903
+ onToggle: toggle,
3904
+ label: "Hide sessions",
3905
+ controls: "sessions-panel"
3906
+ })
3907
+ }), /* @__PURE__ */ jsx("div", {
3908
+ className: "min-h-0 flex-1 overflow-y-auto",
3909
+ children: sessions.length === 0 ? /* @__PURE__ */ jsx(NoSessions, { connected: status._tag === "Connected" }) : sessions.map((session) => /* @__PURE__ */ jsx(SessionRow, {
3910
+ session,
3911
+ selected: session.sessionId === selectedId,
3912
+ onSelect: () => setSelectedId(session.sessionId)
3913
+ }, session.sessionId))
3914
+ })]
3915
+ });
3916
+ };
3917
+ /** Shown in place of the chart when the collector cannot be reached. */
3918
+ var Offline = () => /* @__PURE__ */ jsx(Empty$1, {
3919
+ className: "flex-1",
3920
+ icon: PlugZap,
3921
+ title: "Collector unreachable",
3922
+ hint: /* @__PURE__ */ jsxs(Fragment, { children: [
3923
+ "Nothing is listening on ",
3924
+ /* @__PURE__ */ jsx("code", {
3925
+ className: "text-ink-2",
3926
+ children: COLLECTOR_URL
3927
+ }),
3928
+ ". Start it with",
3929
+ " ",
3930
+ /* @__PURE__ */ jsx("code", {
3931
+ className: "text-ink-2",
3932
+ children: "bun run collector"
3933
+ }),
3934
+ " — this page reconnects on its own. You can still open a saved trace: drop one anywhere on this page."
3935
+ ] })
3936
+ });
3937
+ /** A session's program, without the absolute path a default `programName` carries. */
3938
+ var programLabel = (program) => program.split(/[/\\]/).pop() || program;
3939
+ /** The chart, drawer and detail panel, once a session is selected. */
3940
+ var Workspace = () => {
3941
+ if (useAtomValue(selectedSessionAtom) === void 0) return /* @__PURE__ */ jsx(Empty$1, {
3942
+ className: "flex-1",
3943
+ icon: MousePointerClick,
3944
+ title: "No session selected",
3945
+ hint: "Pick a program from the sessions list to see its flame chart, event log and span detail."
3946
+ });
3947
+ return /* @__PURE__ */ jsxs("div", {
3948
+ className: "flex min-h-0 flex-1",
3949
+ children: [/* @__PURE__ */ jsx(SpanDetail, {}), /* @__PURE__ */ jsxs("div", {
3950
+ className: "order-first flex min-w-0 flex-1 flex-col",
3951
+ children: [/* @__PURE__ */ jsx(FlameChart, {}), /* @__PURE__ */ jsx(Drawer, {})]
3952
+ })]
3953
+ });
3954
+ };
3955
+ var Shell = () => {
3956
+ useAtomMount(connectionAtom);
3957
+ const status = useAtomValue(connectionStatusAtom);
3958
+ const session = useAtomValue(selectedSessionAtom);
3959
+ const loadedSelected = session !== void 0 && isLoadedSession(session.sessionId);
3960
+ return /* @__PURE__ */ jsxs("div", {
3961
+ className: "relative flex h-screen flex-col bg-page font-mono text-ink antialiased",
3962
+ children: [/* @__PURE__ */ jsxs("header", {
3963
+ className: "flex h-11 shrink-0 items-center justify-between gap-4 border-b border-line bg-surface px-3",
3964
+ children: [/* @__PURE__ */ jsxs("div", {
3965
+ className: "flex min-w-0 items-baseline gap-3",
3966
+ children: [/* @__PURE__ */ jsx("h1", {
3967
+ className: "text-sm text-ink",
3968
+ children: "effect-inspect"
3969
+ }), session !== void 0 && /* @__PURE__ */ jsxs("span", {
3970
+ className: "truncate text-xs text-ink-3",
3971
+ children: [
3972
+ programLabel(session.program),
3973
+ " · ",
3974
+ session.runtime
3975
+ ]
3976
+ })]
3977
+ }), /* @__PURE__ */ jsxs("div", {
3978
+ className: "flex shrink-0 items-center gap-4",
3979
+ children: [
3980
+ /* @__PURE__ */ jsx(Stats, {}),
3981
+ /* @__PURE__ */ jsx(TraceFileControls, {}),
3982
+ /* @__PURE__ */ jsx(ConnectionBadge, {}),
3983
+ /* @__PURE__ */ jsx(ThemeToggle, {})
3984
+ ]
3985
+ })]
3986
+ }), /* @__PURE__ */ jsxs("div", {
3987
+ className: "flex min-h-0 flex-1",
3988
+ children: [/* @__PURE__ */ jsx(Sessions, {}), /* @__PURE__ */ jsx("main", {
3989
+ className: "flex min-w-0 flex-1 flex-col",
3990
+ children: status._tag === "Disconnected" && !loadedSelected ? /* @__PURE__ */ jsx(Offline, {}) : /* @__PURE__ */ jsx(Workspace, {})
3991
+ })]
3992
+ })]
3993
+ });
3994
+ };
3995
+ //#endregion
3996
+ //#region app/src/routes/index.tsx?tsr-split=component
3997
+ var SplitComponent = Shell;
3998
+ //#endregion
3999
+ export { SplitComponent as component };