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.
- package/README.md +122 -0
- package/app/dist/client/assets/index-smV05cfr.js +25 -0
- package/app/dist/client/assets/rolldown-runtime-CbXtAM7H.js +1 -0
- package/app/dist/client/assets/routes-TKgeFdSW.js +5 -0
- package/app/dist/client/assets/styles-B3rAZGvS.css +2 -0
- package/app/dist/server/assets/_tanstack-start-manifest_v-Co953HeC.js +20 -0
- package/app/dist/server/assets/empty-plugin-adapters-D9UWiqvJ.js +5 -0
- package/app/dist/server/assets/router-CN98Ramo.js +491 -0
- package/app/dist/server/assets/routes-eZ4XqxE9.js +3999 -0
- package/app/dist/server/assets/start-5Z2QO8AU.js +4 -0
- package/app/dist/server/server.js +1812 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +29 -0
- package/dist/client/Client.d.ts +52 -0
- package/dist/client/Client.js +224 -0
- package/dist/client/Edge.d.ts +31 -0
- package/dist/client/Edge.js +108 -0
- package/dist/client/Inspect.d.ts +49 -0
- package/dist/client/Inspect.js +55 -0
- package/dist/client/Tracer.d.ts +31 -0
- package/dist/client/Tracer.js +119 -0
- package/dist/collector/Config.d.ts +8 -0
- package/dist/collector/Config.js +9 -0
- package/dist/collector/Server.d.ts +24 -0
- package/dist/collector/Server.js +172 -0
- package/dist/collector/Store.d.ts +86 -0
- package/dist/collector/Store.js +119 -0
- package/dist/collector/WebApp.d.ts +3 -0
- package/dist/collector/WebApp.js +36 -0
- package/dist/collector/main.d.ts +1 -0
- package/dist/collector/main.js +22 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/protocol/Codec.d.ts +575 -0
- package/dist/protocol/Codec.js +50 -0
- package/dist/protocol/Schema.d.ts +1237 -0
- package/dist/protocol/Schema.js +327 -0
- 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 };
|