@atolljs/devtools 0.1.7

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/app/main.js ADDED
@@ -0,0 +1,1823 @@
1
+ // atoll devtools — analytics dashboard over the /view WebSocket.
2
+ // State is rebuilt incrementally from the event stream; nothing here knows
3
+ // about the app except the event shapes from @atolljs/core's devtools union.
4
+ //
5
+ // Time basis: each event's `at` is performance.now() on its emitting
6
+ // thread, so intra-session timelines (the waterfall) use `at` directly.
7
+ // Cross-session ordering is only meaningful within one session.
8
+
9
+ const $ = (id) => document.getElementById(id);
10
+ const esc = (s) => String(s ?? '').replace(/[&<>]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c]));
11
+ const fmt = (n, d = 1) => (n === undefined || n === null ? '—' : Number(n).toFixed(d));
12
+ const fmtBytes = (b) => (b >= 1 << 20 ? `${(b / (1 << 20)).toFixed(1)}MB` : b >= 1024 ? `${(b / 1024).toFixed(1)}KB` : `${b}B`);
13
+ const fmtInt = (n) => n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${(n / 1e3).toFixed(1)}k` : String(n);
14
+ const key = (sess, ...rest) => [sess, ...rest].join('|');
15
+ const sessOf = (k) => k.split('|')[0];
16
+ /** Session heap in bytes — plain sample, else sum of measured contexts. */
17
+ const sessHeapOf = (sid) => {
18
+ const h = state.sessHeap.get(sid);
19
+ if (h) return h.heap;
20
+ const ctx = state.sessCtx.get(sid);
21
+ if (!ctx?.length) return undefined;
22
+ return ctx.reduce((a, c) => a + c.bytes, 0);
23
+ };
24
+
25
+ const OUTCOME_COLOR = {
26
+ ok: '#58a6ff', error: '#f85149', crashed: '#f85149',
27
+ aborted: '#d29922', timeout: '#d29922', 'queue-full': '#d29922',
28
+ };
29
+
30
+ const state = {
31
+ sessions: new Map(), // id → SessionInfo
32
+ sel: null, // session id or null = all
33
+ drillTask: null, // taskId drilldown filter or null
34
+ inspect: null, // `${sess}|${instance}` under inspection or null
35
+ inspectWorker: null, // `${sess}|${poolId}|${slot}` under inspection or null
36
+ inspectFetch: null, // fetches[].seq under inspection or null
37
+ workers: new Map(), // `${sess}|${poolId}|${slot}` → worker record
38
+ series: new Map(), // `${sess}|${metric}|${sec}` → number — per-second rollup for live charts
39
+ pools: new Map(), // `${sess}|${poolId}` → pool record
40
+ calls: new Map(), // `${sess}|${poolId}|${callId}` → call record
41
+ callOrder: [], // insertion order of call keys
42
+ mem: new Map(), // `${sess}|${path}` → { writes, version, thread, buckets: Map }
43
+ islands: new Map(), // `${sess}|${instance}` → island record
44
+ tput: new Map(), // `${sess}|${sec}` → { n, err } — settle events per second
45
+ fetches: [], // [{seq,id,src,at,sess,worker,url,method,status,ms,bytes,error,reqHeaders,reqBody,d?}] capped 300
46
+ sessHeap: new Map(), // sid → {heap, limit} — latest runtime:memory sample
47
+ sessCtx: new Map(), // sid → [{bytes,scope,url}] — measureUserAgentSpecificMemory contexts
48
+ heapSeries: new Map(), // sid → Map(dashSec → MB) — heap over time, keyed by dashboard receive clock
49
+ alerts: [], // [{at, sess, kind, text}]
50
+ seq: 0,
51
+ t0: null, // earliest event.at seen (per selected set — min)
52
+ };
53
+
54
+ const MAX_CALLS = 2000;
55
+ const MAX_ALERTS = 60;
56
+
57
+ /* ── event reducer ───────────────────────────────────────────────────────── */
58
+
59
+ const bump = (sid, metric, at, n = 1) => {
60
+ const k = `${sid}|${metric}|${Math.floor(at / 1000)}`;
61
+ state.series.set(k, (state.series.get(k) ?? 0) + n);
62
+ };
63
+
64
+ function applyEvent(sess, e) {
65
+ const sid = sess.id;
66
+ if (state.t0 === null || e.at < state.t0) state.t0 = e.at;
67
+ switch (e.type) {
68
+ case 'pool:init':
69
+ state.pools.set(key(sid, e.poolId), {
70
+ poolId: e.poolId, label: e.label ?? e.poolId, size: e.poolSize,
71
+ memoryBytes: e.memoryBytes, workers: new Set(), tasks: 0, failed: 0,
72
+ dead: sess.closed === true,
73
+ });
74
+ break;
75
+ case 'pool:terminate': {
76
+ const p = state.pools.get(key(sid, e.poolId));
77
+ if (p) p.dead = true;
78
+ for (const [k, w] of state.workers) {
79
+ if (k.startsWith(`${sid}|${e.poolId}|`)) w.dead = true;
80
+ }
81
+ break;
82
+ }
83
+ case 'worker:spawn': {
84
+ state.pools.get(key(sid, e.poolId))?.workers.add(e.slot);
85
+ const w = getWorker(sid, e.poolId, e.slot, e.at);
86
+ w.spawnedAt = e.at;
87
+ if (!sess.closed) w.dead = false;
88
+ break;
89
+ }
90
+ case 'worker:respawn': {
91
+ state.pools.get(key(sid, e.poolId))?.workers.add(e.slot);
92
+ const w = getWorker(sid, e.poolId, e.slot, e.at);
93
+ w.respawns++; w.spawnedAt = e.at;
94
+ if (!sess.closed) w.dead = false;
95
+ alert(sess, e, 'worker respawned', `pool ${e.poolId} slot ${e.slot}`);
96
+ break;
97
+ }
98
+ case 'worker:error': {
99
+ const w = getWorker(sid, e.poolId, e.slot, e.at);
100
+ w.errors.push({ at: e.at, message: e.message });
101
+ if (w.errors.length > 50) w.errors.shift();
102
+ w.dead = true;
103
+ alert(sess, e, 'worker error', `${e.poolId} slot ${e.slot}: ${e.message}`);
104
+ break;
105
+ }
106
+ case 'task:enqueue': {
107
+ bump(sid, 'enq', e.at);
108
+ const k = key(sid, e.poolId, e.callId);
109
+ state.calls.set(k, { taskId: e.taskId, poolId: e.poolId, sess: sid, enqAt: e.at, seq: ++state.seq });
110
+ state.callOrder.push(k);
111
+ if (state.callOrder.length > MAX_CALLS * 2) {
112
+ const drop = state.callOrder.splice(0, state.callOrder.length - MAX_CALLS);
113
+ for (const k of drop) state.calls.delete(k);
114
+ }
115
+ const p = state.pools.get(key(sid, e.poolId));
116
+ if (p) p.tasks++;
117
+ break;
118
+ }
119
+ case 'task:dispatch': {
120
+ bump(sid, 'disp', e.at);
121
+ const c = state.calls.get(key(sid, e.poolId, e.callId));
122
+ if (c) { c.waitMs = e.waitMs; c.dispAt = e.at; c.slot = e.slot; }
123
+ const w = getWorker(sid, e.poolId, e.slot, e.at);
124
+ w.tasks++; w.lastAt = e.at; w.lastTaskId = e.taskId;
125
+ break;
126
+ }
127
+ case 'task:settle': {
128
+ bump(sid, 'settle', e.at);
129
+ if (e.outcome !== 'ok') bump(sid, 'err', e.at);
130
+ if (e.runMs !== undefined) { bump(sid, 'runSum', e.at, e.runMs); bump(sid, 'runN', e.at); }
131
+ const c = state.calls.get(key(sid, e.poolId, e.callId));
132
+ if (c) { c.outcome = e.outcome; c.runMs = e.runMs; c.error = e.error; c.settleAt = e.at; }
133
+ if (c?.slot !== undefined) {
134
+ const w = getWorker(sid, e.poolId, c.slot, e.at);
135
+ w.lastAt = e.at; w.lastTaskId = e.taskId;
136
+ if (e.outcome !== 'ok') {
137
+ w.failed++;
138
+ if (e.error) {
139
+ w.errors.push({ at: e.at, message: `${e.taskId}: ${e.error}` });
140
+ if (w.errors.length > 50) w.errors.shift();
141
+ }
142
+ }
143
+ }
144
+ const sec = Math.floor(e.at / 1000);
145
+ const bk = key(sid, sec);
146
+ const b = state.tput.get(bk) ?? { n: 0, err: 0 };
147
+ b.n++; if (e.outcome !== 'ok') b.err++;
148
+ state.tput.set(bk, b);
149
+ if (e.outcome !== 'ok') {
150
+ const p = state.pools.get(key(sid, e.poolId));
151
+ if (p) p.failed++;
152
+ alert(sess, e, e.outcome, `${e.taskId} — ${e.outcome}${e.error ? `: ${e.error}` : ''}`);
153
+ }
154
+ break;
155
+ }
156
+ case 'memory:write': {
157
+ bump(sid, 'wr', e.at);
158
+ const k = key(sid, e.path);
159
+ const m = state.mem.get(k) ?? { writes: 0, buckets: new Map() };
160
+ m.writes++; m.version = e.version; m.thread = e.thread; m.lastAt = e.at;
161
+ // worker:* forwarded events carry slot attribution — pin the writer.
162
+ if (e.worker) m.writer = `${e.worker.poolId}#${e.worker.slot}`;
163
+ const sec = Math.floor(e.at / 1000);
164
+ m.buckets.set(sec, (m.buckets.get(sec) ?? 0) + 1);
165
+ if (m.buckets.size > 120) m.buckets.delete([...m.buckets.keys()][0]);
166
+ state.mem.set(k, m);
167
+ break;
168
+ }
169
+ case 'island:mount': {
170
+ // island:task/island:ops for the mount round-trip arrive BEFORE this
171
+ // event — merge into the record rather than overwriting it.
172
+ const i = getIsland(sid, e.instance, e.at);
173
+ i.app = e.app; i.pid = e.pid; i.mountedAt = e.at; i.fw = e.framework;
174
+ if (!sess.closed) i.ended = false;
175
+ if (e.poolId) i.poolId = e.poolId;
176
+ break;
177
+ }
178
+ case 'island:ops': {
179
+ bump(sid, 'iops', e.at, e.count);
180
+ const i = getIsland(sid, e.instance, e.at);
181
+ i.batches++; i.ops += e.count; i.replayMs += e.replayMs ?? 0;
182
+ i.timeline.push({ at: e.at, via: e.via, count: e.count, replayMs: e.replayMs ?? 0 });
183
+ if (i.timeline.length > 400) i.timeline.shift();
184
+ const v = i.byVia.get(e.via) ?? { batches: 0, ops: 0, replayMs: 0 };
185
+ v.batches++; v.ops += e.count; v.replayMs += e.replayMs ?? 0;
186
+ i.byVia.set(e.via, v);
187
+ break;
188
+ }
189
+ case 'island:task': {
190
+ bump(sid, 'irt', e.at);
191
+ const i = getIsland(sid, e.instance, e.at);
192
+ i.tasks.push({ at: e.at, method: e.method, ms: e.ms, ops: e.ops, error: e.error, seq: ++state.seq });
193
+ if (i.tasks.length > 500) i.tasks.shift();
194
+ break;
195
+ }
196
+ case 'island:event': {
197
+ bump(sid, 'iev', e.at);
198
+ const i = getIsland(sid, e.instance, e.at);
199
+ i.events++;
200
+ i.eventNames.set(e.name, (i.eventNames.get(e.name) ?? 0) + 1);
201
+ break;
202
+ }
203
+ case 'island:unmount': {
204
+ const i = state.islands.get(key(sid, e.instance));
205
+ if (i) i.ended = true;
206
+ break;
207
+ }
208
+ case 'net:fetch': {
209
+ bump(sid, 'fetch', e.at);
210
+ state.fetches.push({
211
+ seq: ++state.seq, at: e.at, sess: sid, worker: e.worker,
212
+ id: e.id, src: `${e.thread}:${e.worker ? `${e.worker.poolId}#${e.worker.slot}` : 'main'}`,
213
+ url: e.url, method: e.method, status: e.status, ms: e.ms, bytes: e.bytes, error: e.error,
214
+ reqHeaders: e.reqHeaders, reqBody: e.reqBody,
215
+ });
216
+ if (state.fetches.length > 300) state.fetches.splice(0, state.fetches.length - 300);
217
+ break;
218
+ }
219
+ case 'net:fetch-detail': {
220
+ // Merge into the matching base record (same context id + source).
221
+ const src = `${e.thread}:${e.worker ? `${e.worker.poolId}#${e.worker.slot}` : 'main'}`;
222
+ const f = [...state.fetches].reverse().find((x) => x.sess === sid && x.id === e.id && x.src === src);
223
+ if (f) {
224
+ f.d = {
225
+ resHeaders: e.resHeaders, resBody: e.resBody, timing: e.timing,
226
+ transferSize: e.transferSize, encodedSize: e.encodedSize, decodedSize: e.decodedSize,
227
+ };
228
+ if (e.reqBody !== undefined) f.reqBody = e.reqBody;
229
+ }
230
+ break;
231
+ }
232
+ case 'runtime:memory': {
233
+ if (e.worker) {
234
+ // Worker-reported heap — its own context (measureMemory own-URL
235
+ // filter, or worker-side performance.memory) stamped to the slot.
236
+ const w = getWorker(sid, e.worker.poolId, e.worker.slot, e.at);
237
+ w.heap = e.heapBytes; w.heapLimit = e.heapLimitBytes; w.lastAt = e.at;
238
+ const sec = Math.floor(e.at / 1000);
239
+ (w.heapSeries ??= new Map()).set(sec, e.heapBytes / 1048576); // MB for the chart
240
+ if (w.heapSeries.size > 120) w.heapSeries.delete([...w.heapSeries.keys()][0]);
241
+ }
242
+ if (!e.worker) {
243
+ state.sessHeap.set(sid, { heap: e.heapBytes, limit: e.heapLimitBytes });
244
+ if (e.contexts) state.sessCtx.set(sid, e.contexts);
245
+ // Series on the dashboard clock — emit threads have unrelated epochs.
246
+ const hs = state.heapSeries.get(sid) ?? new Map();
247
+ hs.set(Math.floor(performance.now() / 1000), e.heapBytes / 1048576);
248
+ if (hs.size > 240) hs.delete([...hs.keys()][0]); // ~10min at 2.5s sampling
249
+ state.heapSeries.set(sid, hs);
250
+ }
251
+ break;
252
+ }
253
+ }
254
+ }
255
+
256
+ function getWorker(sid, poolId, slot, at) {
257
+ const k = key(sid, poolId, slot);
258
+ let w = state.workers.get(k);
259
+ if (!w) {
260
+ w = { spawnedAt: at, respawns: 0, errors: [], tasks: 0, failed: 0, lastAt: at, lastTaskId: '', dead: state.sessions.get(sid)?.closed === true };
261
+ state.workers.set(k, w);
262
+ }
263
+ return w;
264
+ }
265
+
266
+ function getIsland(sid, instance, at) {
267
+ const k = key(sid, instance);
268
+ let i = state.islands.get(k);
269
+ if (!i) {
270
+ i = {
271
+ app: instance.split('@')[0], pid: '—', batches: 0, ops: 0, replayMs: 0, events: 0,
272
+ tasks: [], timeline: [], eventNames: new Map(), byVia: new Map(),
273
+ mountedAt: at, ended: state.sessions.get(sid)?.closed === true, seq: ++state.seq,
274
+ };
275
+ state.islands.set(k, i);
276
+ }
277
+ return i;
278
+ }
279
+
280
+ function alert(sess, e, kind, text) {
281
+ state.alerts.unshift({ at: e.at, sessId: sess.id, sess: sess.name ?? sess.id, kind, text });
282
+ if (state.alerts.length > MAX_ALERTS) state.alerts.pop();
283
+ }
284
+
285
+ /** Drop state for fully-evicted sessions; mark closed sessions' entities dead. */
286
+ function reconcileSessions(live) {
287
+ state.sessions = new Map(live.map((s) => [s.id, s]));
288
+ const ids = new Set(live.map((s) => s.id));
289
+ const closedIds = new Set(live.filter((s) => s.closed).map((s) => s.id));
290
+ for (const [k, p] of state.pools) if (closedIds.has(sessOf(k))) p.dead = true;
291
+ for (const [k, w] of state.workers) if (closedIds.has(sessOf(k))) w.dead = true;
292
+ for (const [k, i] of state.islands) if (closedIds.has(sessOf(k))) i.ended = true;
293
+ for (const map of [state.pools, state.mem, state.islands, state.workers, state.series]) {
294
+ for (const k of [...map.keys()]) if (!ids.has(sessOf(k))) map.delete(k);
295
+ }
296
+ for (const k of [...state.calls.keys()]) {
297
+ if (!ids.has(sessOf(k))) { state.calls.delete(k); }
298
+ }
299
+ state.callOrder = state.callOrder.filter((k) => state.calls.has(k));
300
+ for (const k of [...state.tput.keys()]) if (!ids.has(sessOf(k))) state.tput.delete(k);
301
+ state.fetches = state.fetches.filter((f) => ids.has(f.sess));
302
+ for (const sid of [...state.sessHeap.keys()]) if (!ids.has(sid)) state.sessHeap.delete(sid);
303
+ for (const sid of [...state.sessCtx.keys()]) if (!ids.has(sid)) state.sessCtx.delete(sid);
304
+ for (const sid of [...state.heapSeries.keys()]) if (!ids.has(sid)) state.heapSeries.delete(sid);
305
+ state.alerts = state.alerts.filter((a) => !a.sessId || ids.has(a.sessId));
306
+ if (state.sel && !ids.has(state.sel)) state.sel = null;
307
+ if (state.inspect && !state.islands.has(state.inspect)) state.inspect = null;
308
+ if (state.inspectWorker && !state.workers.has(state.inspectWorker)) state.inspectWorker = null;
309
+ }
310
+
311
+ /**
312
+ * Selected-session mode shows that session (live or ended — post-mortem).
313
+ * "All sessions" mode merges LIVE sessions only; ended sessions stay out
314
+ * of KPIs, charts, and tables until explicitly selected.
315
+ */
316
+ const inSel = (k) =>
317
+ state.sel !== null
318
+ ? sessOf(k) === state.sel
319
+ : state.sessions.get(sessOf(k))?.closed !== true;
320
+
321
+ /**
322
+ * "All sessions" rendering: sort items into session order (live first —
323
+ * the server lists live sessions before closed ones) and emit a group
324
+ * header row at each boundary. Selected-session mode stays flat.
325
+ * `rowFn` returns the row's <tr> html for one item.
326
+ */
327
+ const withGroups = (items, getSess, cols, rowFn) => {
328
+ if (state.sel !== null) return items.map(rowFn).join('') || null;
329
+ const order = [...state.sessions.keys()];
330
+ const sorted = [...items].sort(
331
+ (a, b) => order.indexOf(getSess(a)) - order.indexOf(getSess(b)),
332
+ );
333
+ let html = '', last = null;
334
+ for (const it of sorted) {
335
+ const s = getSess(it);
336
+ if (s !== last) {
337
+ last = s;
338
+ const sess = state.sessions.get(s);
339
+ html += `<tr class="grp"><td colspan="${cols}">⦿ ${esc(sess?.name ?? s)}${sess?.closed ? ' (ended)' : ''}</td></tr>`;
340
+ }
341
+ html += rowFn(it);
342
+ }
343
+ return html || null;
344
+ };
345
+
346
+ /* ── helpers ─────────────────────────────────────────────────────────────── */
347
+
348
+ const sortedRuns = (runs) => [...runs].sort((a, b) => a - b);
349
+ const pct = (sorted, p) => sorted.length ? sorted[Math.min(sorted.length - 1, Math.floor(p * sorted.length))] : undefined;
350
+
351
+ function sparkline(canvas, values, color = '#58a6ff') {
352
+ const ctx = canvas.getContext('2d');
353
+ const w = (canvas.width = canvas.clientWidth || 120);
354
+ const h = (canvas.height = canvas.clientHeight || 28);
355
+ ctx.clearRect(0, 0, w, h);
356
+ if (!values.length) return;
357
+ const max = Math.max(...values, 1);
358
+ const bw = w / values.length;
359
+ ctx.fillStyle = color;
360
+ values.forEach((v, i) => {
361
+ const bh = Math.max(1, (v / max) * (h - 2));
362
+ ctx.fillRect(i * bw + 1, h - bh, Math.max(1, bw - 2), bh);
363
+ });
364
+ }
365
+
366
+ /* ── live charts (Network + Memory tabs) ─────────────────────────────────── */
367
+
368
+ const CHART_SECS = 60;
369
+
370
+ /** sec→value points for one metric, summed across the selected sessions. */
371
+ function seriesFor(metric) {
372
+ const pts = new Map();
373
+ for (const [k, v] of state.series) {
374
+ const [sid, m, sec] = k.split('|');
375
+ if (m !== metric || !inSel(k)) continue;
376
+ const s = Number(sec);
377
+ pts.set(s, (pts.get(s) ?? 0) + v);
378
+ }
379
+ return [...pts.entries()].map(([sec, v]) => ({ sec, v })).sort((a, b) => a.sec - b.sec);
380
+ }
381
+
382
+ /** Merge two per-second maps into avg points (sum ÷ count), e.g. run ms. */
383
+ function avgSeries(sumPts, nPts) {
384
+ const n = new Map(nPts.map((p) => [p.sec, p.v]));
385
+ return sumPts.filter((p) => n.get(p.sec)).map((p) => ({ sec: p.sec, v: p.v / n.get(p.sec) }));
386
+ }
387
+
388
+ /** Cumulative (a-b) line — used for in-flight calls = dispatched − settled. */
389
+ function deltaSeries(aPts, bPts) {
390
+ const b = new Map(bPts.map((p) => [p.sec, p.v]));
391
+ const secs = new Set([...aPts.map((p) => p.sec), ...b.keys()]);
392
+ const a = new Map(aPts.map((p) => [p.sec, p.v]));
393
+ let acc = 0;
394
+ return [...secs].sort((x, y) => x - y).map((sec) => {
395
+ acc += (a.get(sec) ?? 0) - (b.get(sec) ?? 0);
396
+ return { sec, v: Math.max(0, acc) };
397
+ });
398
+ }
399
+
400
+ const fmtChart = (v) => (v >= 1e6 ? `${(v / 1e6).toFixed(1)}M` : v >= 1e3 ? `${(v / 1e3).toFixed(1)}k` : v % 1 ? v.toFixed(1) : String(v));
401
+
402
+ /** Scale + axis annotations shared by both chart renderers. */
403
+ function annotate(ctx, W, H, max, lastX, lastY, color) {
404
+ ctx.font = '9px ui-monospace, monospace';
405
+ ctx.fillStyle = '#8b949e';
406
+ ctx.textBaseline = 'top';
407
+ ctx.fillText(fmtChart(max), 4, 3); // top of scale
408
+ ctx.fillText('0', 4, H - 12); // bottom of scale
409
+ ctx.textAlign = 'right';
410
+ ctx.fillText('now', W - 4, H - 12);
411
+ ctx.fillText('-60s', W * 0.5 + 20, H - 12);
412
+ ctx.textAlign = 'left';
413
+ if (lastX !== undefined) {
414
+ ctx.fillStyle = color;
415
+ ctx.beginPath(); ctx.arc(lastX, lastY, 2.5, 0, 7); ctx.fill();
416
+ ctx.strokeStyle = `${color}66`;
417
+ ctx.setLineDash([3, 3]);
418
+ ctx.beginPath(); ctx.moveTo(lastX, 0); ctx.lineTo(lastX, H - 12); ctx.stroke();
419
+ ctx.setLineDash([]);
420
+ }
421
+ }
422
+
423
+ function drawLine(canvas, points, color = '#58a6ff') {
424
+ const ctx = canvas.getContext('2d');
425
+ const W = (canvas.width = canvas.clientWidth || 270);
426
+ const H = (canvas.height = canvas.clientHeight || 64);
427
+ ctx.clearRect(0, 0, W, H);
428
+ ctx.strokeStyle = '#21262d';
429
+ for (let g = 1; g <= 3; g++) {
430
+ const gy = H - (g / 3) * (H - 12);
431
+ ctx.beginPath(); ctx.moveTo(0, gy); ctx.lineTo(W, gy); ctx.stroke();
432
+ }
433
+ if (!points.length) {
434
+ ctx.fillStyle = '#484f58'; ctx.font = '9px ui-monospace, monospace';
435
+ ctx.fillText('no data', 4, 4);
436
+ return;
437
+ }
438
+ const hi = Math.max(...points.map((p) => p.sec));
439
+ const lo = hi - CHART_SECS + 1;
440
+ const vals = new Map(points.map((p) => [p.sec, p.v]));
441
+ const max = Math.max(...points.map((p) => p.v), 0.01);
442
+ const x = (s) => ((s - lo) / CHART_SECS) * W;
443
+ const y = (v) => (H - 3) - (v / max) * (H - 15);
444
+ ctx.beginPath();
445
+ for (let s = lo; s <= hi; s++) {
446
+ const py = y(vals.get(s) ?? 0);
447
+ s === lo ? ctx.moveTo(x(s), py) : ctx.lineTo(x(s), py);
448
+ }
449
+ ctx.strokeStyle = color; ctx.lineWidth = 1.5; ctx.stroke();
450
+ ctx.lineTo(x(hi), H); ctx.lineTo(x(lo), H); ctx.closePath();
451
+ ctx.fillStyle = `${color}22`; ctx.fill();
452
+ ctx.lineWidth = 1;
453
+ annotate(ctx, W, H, max, x(hi), y(vals.get(hi) ?? 0), color);
454
+ }
455
+
456
+ function drawMultiLine(canvas, lines) {
457
+ const ctx = canvas.getContext('2d');
458
+ const W = (canvas.width = canvas.clientWidth || 270);
459
+ const H = (canvas.height = canvas.clientHeight || 80);
460
+ ctx.clearRect(0, 0, W, H);
461
+ ctx.strokeStyle = '#21262d';
462
+ for (let g = 1; g <= 3; g++) {
463
+ const gy = H - (g / 3) * (H - 12);
464
+ ctx.beginPath(); ctx.moveTo(0, gy); ctx.lineTo(W, gy); ctx.stroke();
465
+ }
466
+ const allSecs = lines.flatMap((l) => l.points.map((p) => p.sec));
467
+ if (!allSecs.length) return;
468
+ const hi = Math.max(...allSecs);
469
+ const lo = hi - CHART_SECS + 1;
470
+ const max = Math.max(...lines.flatMap((l) => l.points.map((p) => p.v)), 0.01);
471
+ const x = (s) => ((s - lo) / CHART_SECS) * W;
472
+ const y = (v) => (H - 3) - (v / max) * (H - 15);
473
+ for (const { points, color } of lines) {
474
+ const vals = new Map(points.map((p) => [p.sec, p.v]));
475
+ ctx.beginPath();
476
+ for (let s = lo; s <= hi; s++) {
477
+ const py = y(vals.get(s) ?? 0);
478
+ s === lo ? ctx.moveTo(x(s), py) : ctx.lineTo(x(s), py);
479
+ }
480
+ ctx.strokeStyle = color; ctx.lineWidth = 1.5; ctx.stroke();
481
+ }
482
+ ctx.lineWidth = 1;
483
+ annotate(ctx, W, H, max);
484
+ }
485
+
486
+ const NET_CARDS = [
487
+ { id: 'ch-tasks', label: 'tasks /s', color: '#58a6ff', get: () => seriesFor('settle') },
488
+ { id: 'ch-lat', label: 'avg run ms', color: '#a371f7', get: () => avgSeries(seriesFor('runSum'), seriesFor('runN')) },
489
+ { id: 'ch-err', label: 'errors /s', color: '#f85149', get: () => seriesFor('err') },
490
+ { id: 'ch-inf', label: 'in-flight calls', color: '#d29922', get: () => deltaSeries(seriesFor('disp'), seriesFor('settle')) },
491
+ { id: 'ch-iops', label: 'island ops /s', color: '#3fb950', get: () => seriesFor('iops') },
492
+ { id: 'ch-irt', label: 'island round-trips /s', color: '#58a6ff', get: () => seriesFor('irt') },
493
+ { id: 'ch-iev', label: 'island events /s', color: '#79c0ff', get: () => seriesFor('iev') },
494
+ { id: 'ch-fetch', label: 'fetches /s', color: '#f0883e', get: () => seriesFor('fetch') },
495
+ ];
496
+
497
+ /** Display form of a URL — path+query when same-origin, full URL otherwise. */
498
+ const shortUrl = (u) => {
499
+ try {
500
+ const x = new URL(u);
501
+ return x.origin === location.origin ? x.pathname + x.search : u;
502
+ } catch {
503
+ return u;
504
+ }
505
+ };
506
+
507
+ function renderNetwork() {
508
+ const host = $('charts');
509
+ if (!host.children.length) {
510
+ host.innerHTML = NET_CARDS.map((c) =>
511
+ `<div class="chart-card"><div class="lbl"><span>${c.label}</span><span><b id="${c.id}-v" style="color:${c.color}">—</b> <span class="muted" id="${c.id}-p"></span></span></div><canvas id="${c.id}"></canvas></div>`,
512
+ ).join('');
513
+ }
514
+ for (const c of NET_CARDS) {
515
+ const pts = c.get();
516
+ drawLine($(c.id), pts, c.color);
517
+ const last = pts.at(-1);
518
+ const peak = pts.length ? Math.max(...pts.map((p) => p.v)) : 0;
519
+ $(`${c.id}-v`).textContent = last ? fmtChart(last.v) : '0';
520
+ $(`${c.id}-p`).textContent = pts.length ? `peak ${fmtChart(peak)}` : '';
521
+ }
522
+
523
+ const fl = state.fetches.filter((f) => inSel(f.sess)).slice(-150).reverse();
524
+ $('fetchlog').querySelector('tbody').innerHTML = withGroups(fl, (f) => f.sess, 7, (f) => {
525
+ const cls = f.error || f.status >= 400 ? 'error' : f.status >= 300 ? 'timeout' : 'ok';
526
+ return `<tr data-fetch="${f.seq}">
527
+ <td class="muted">${esc(state.sessions.get(f.sess)?.name ?? f.sess)}</td>
528
+ <td>${fmt(f.ms)}</td>
529
+ <td class="${cls}">${f.status !== undefined ? f.status : '✕'}</td>
530
+ <td>${esc(f.method)}</td>
531
+ <td class="url" title="${esc(f.url)}">${esc(shortUrl(f.url))}</td>
532
+ <td class="muted">${f.worker ? `${esc(f.worker.poolId)}#${f.worker.slot}` : 'main'}</td>
533
+ <td class="muted">${f.bytes !== undefined ? fmtBytes(f.bytes) : '—'}</td></tr>`;
534
+ }) || '<tr><td colspan="7" class="muted">no fetches — enable with connectDevtools() on the app</td></tr>';
535
+ for (const tr of $('fetchlog').querySelectorAll('tr[data-fetch]')) {
536
+ tr.onclick = () => {
537
+ state.inspectFetch = Number(tr.dataset.fetch);
538
+ openSub('network', 'nv-req');
539
+ };
540
+ }
541
+ }
542
+
543
+ /* ── request detail (network inspector) ─────────────────────────────────── */
544
+
545
+ const TIMING_STAGES = [
546
+ ['queue', 'stalled/queue', '#8b949e'],
547
+ ['dns', 'dns', '#d29922'],
548
+ ['tcp', 'tcp connect', '#58a6ff'],
549
+ ['tls', 'tls', '#a371f7'],
550
+ ['wait', 'request + waiting', '#f0883e'],
551
+ ['download', 'download', '#3fb950'],
552
+ ];
553
+
554
+ function renderFetchInsp() {
555
+ const btn = $('navNetReq');
556
+ const f = state.fetches.find((x) => x.seq === state.inspectFetch);
557
+ if (!f) {
558
+ btn.style.display = 'none';
559
+ if ($('nv-req').classList.contains('on')) {
560
+ document.querySelector('#view-network .subnav button[data-sub="nv-traffic"]').click();
561
+ }
562
+ return;
563
+ }
564
+ btn.style.display = '';
565
+ btn.textContent = `Request: ${f.method} ${shortUrl(f.url).slice(0, 24)}`;
566
+
567
+ const cls = f.error || f.status >= 400 ? 'error' : f.status >= 300 ? 'timeout' : 'ok';
568
+ $('reqTitle').innerHTML =
569
+ `Request — <b>${esc(f.method)} ${esc(f.url)}</b> · ` +
570
+ `<span class="${cls}">${f.error ? `failed: ${esc(f.error)}` : `status ${f.status}`}</span> · ` +
571
+ `${esc(state.sessions.get(f.sess)?.name ?? f.sess)} · from ${f.worker ? `${esc(f.worker.poolId)}#${f.worker.slot}` : 'main'}`;
572
+
573
+ const d = f.d;
574
+ const size = d?.transferSize ?? f.bytes;
575
+ const kpi = (v, l) => `<div class="kpi"><div class="v">${v}</div><div class="l">${l}</div></div>`;
576
+ $('reqKpis').innerHTML =
577
+ kpi(`${fmt(f.ms)}`, 'total ms (to headers)') +
578
+ kpi(d?.timing ? fmt(d.timing.wait) : '—', 'wait (ttfb)') +
579
+ kpi(d?.timing ? fmt(d.timing.download) : '—', 'download ms') +
580
+ kpi(size !== undefined ? fmtBytes(size) : '—', 'transferred') +
581
+ kpi(d?.decodedSize !== undefined ? fmtBytes(d.decodedSize) : '—', 'decoded');
582
+
583
+ if (d?.timing) {
584
+ const t = d.timing;
585
+ const total = TIMING_STAGES.reduce((a, [k]) => a + (t[k] || 0), 0);
586
+ $('reqTiming').innerHTML =
587
+ `<div class="tbar">${TIMING_STAGES.map(([k, , c]) =>
588
+ `<div style="flex:0 0 ${total ? (100 * (t[k] || 0)) / total : 0}%;background:${c}" title=""></div>`,
589
+ ).join('')}</div>` +
590
+ `<table class="tkv" style="margin-top:6px"><tbody>${TIMING_STAGES.map(([k, l, c]) =>
591
+ `<tr><td><span style="color:${c}">■</span> ${l}</td><td>${fmt(t[k] || 0)} ms${total ? ` · ${fmt((100 * (t[k] || 0)) / total, 0)}%` : ''}</td></tr>`,
592
+ ).join('')}<tr><td>total (resource timing)</td><td>${fmt(total)} ms</td></tr></tbody></table>`;
593
+ } else {
594
+ $('reqTiming').innerHTML = `<div class="muted">${d ? 'resource timing unavailable (cross-origin without Timing-Allow-Origin, or entry evicted)' : 'detail pending — waiting for response body / timing entry…'}</div>`;
595
+ }
596
+
597
+ const hdr = (h) =>
598
+ h?.length ? h.map(([k, v]) => `<tr><td>${esc(k)}</td><td>${esc(v)}</td></tr>`).join('') : '<tr><td colspan="2" class="muted">—</td></tr>';
599
+ $('reqReqH').innerHTML = hdr(f.reqHeaders);
600
+ $('reqResH').innerHTML = hdr(d?.resHeaders);
601
+ $('reqReqB').textContent = f.reqBody ?? (f.method === 'GET' || f.method === 'HEAD' ? '' : '(not captured)');
602
+ $('reqResB').textContent = d?.resBody ?? (d ? '(binary or no body)' : '');
603
+ }
604
+
605
+ /* ── overview ────────────────────────────────────────────────────────────── */
606
+
607
+ function renderOverview() {
608
+ const pools = [...state.pools.entries()].filter(([k]) => inSel(k));
609
+ const calls = state.callOrder.filter(inSel).map((k) => state.calls.get(k)).filter(Boolean);
610
+ const settled = calls.filter((c) => c.outcome);
611
+ const failed = settled.filter((c) => c.outcome !== 'ok');
612
+ const runs = sortedRuns(settled.map((c) => c.runMs).filter((v) => v !== undefined));
613
+ const writes = [...state.mem.entries()].filter(([k]) => inSel(k)).reduce((a, [, m]) => a + m.writes, 0);
614
+ const islands = [...state.islands.entries()].filter(([k, i]) => inSel(k) && !i.ended).length;
615
+ const isEnded = (k) => state.sessions.get(sessOf(k))?.closed === true;
616
+ const livePools = pools.filter(([k, p]) => !p.dead && !isEnded(k)).length;
617
+ const errRate = settled.length ? (100 * failed.length) / settled.length : 0;
618
+
619
+ const kpi = (v, l, cls = '') => `<div class="kpi ${cls}"><div class="v">${v}</div><div class="l">${l}</div></div>`;
620
+ $('kpis').innerHTML =
621
+ kpi(livePools, 'live pools', livePools ? 'good' : '') +
622
+ kpi(fmtInt(settled.length), 'tasks settled') +
623
+ kpi(`${fmt(errRate, 1)}%`, 'error rate', errRate > 5 ? 'bad' : errRate > 0 ? 'warn' : 'good') +
624
+ kpi(fmt(pct(runs, 0.95)), 'run p95 ms') +
625
+ kpi(fmt(Math.max(...runs, 0)), 'run max ms', runs.length && Math.max(...runs) > 10 * (pct(runs, 0.5) ?? 1) ? 'warn' : '') +
626
+ kpi(fmtInt(writes), 'field writes') +
627
+ kpi(islands, 'islands') +
628
+ kpi(pools.reduce((a, [, p]) => a + p.workers.size, 0), 'workers');
629
+
630
+ const alerts = state.sel === null
631
+ ? state.alerts.filter((a) => !a.sessId || state.sessions.get(a.sessId)?.closed !== true)
632
+ : state.alerts.filter((a) => a.sessId === state.sel);
633
+ $('alerts').innerHTML = alerts.length
634
+ ? alerts.map((a) => `<div><span class="${a.kind}">${esc(a.kind)}</span> <span class="muted">${esc(a.sess)}</span> ${esc(a.text)}</div>`).join('')
635
+ : '<div class="muted">nothing flagged</div>';
636
+
637
+ $('pools').querySelector('tbody').innerHTML = withGroups(pools, ([k]) => sessOf(k), 7, ([k, p]) => {
638
+ const sess = state.sessions.get(sessOf(k));
639
+ const ended = sess?.closed === true;
640
+ return `<tr class="${p.dead || ended ? 'muted' : ''}">
641
+ <td class="muted">${esc(sess?.name ?? sessOf(k))}${ended ? ' (ended)' : ''}</td>
642
+ <td>${esc(p.label)}${p.label !== p.poolId ? ` <span class="muted">${esc(p.poolId)}</span>` : ''}${p.dead ? '<span class="pill">terminated</span>' : ''}</td>
643
+ <td>${p.size}</td><td>${p.memoryBytes !== undefined ? fmtBytes(p.memoryBytes) : '—'}</td>
644
+ <td>${p.workers.size}</td><td>${p.tasks}</td><td class="${p.failed ? 'error' : ''}">${p.failed}</td></tr>`;
645
+ }) || '<tr><td colspan="7" class="muted">no pools</td></tr>';
646
+
647
+ drawThroughput();
648
+ }
649
+
650
+ function drawThroughput() {
651
+ const canvas = $('tput');
652
+ const ctx = canvas.getContext('2d');
653
+ const w = (canvas.width = canvas.clientWidth || 560);
654
+ const h = (canvas.height = 80);
655
+ ctx.clearRect(0, 0, w, h);
656
+ if (state.t0 === null) return;
657
+ const buckets = [...state.tput.entries()].filter(([k]) => inSel(k));
658
+ const secs = buckets.map(([k]) => Number(k.split('|')[1]));
659
+ if (!secs.length) return;
660
+ const lo = Math.min(...secs), hi = Math.max(...secs);
661
+ const span = Math.max(hi - lo + 1, 1);
662
+ const bw = w / Math.max(span, 60);
663
+ const max = Math.max(...buckets.map(([, b]) => b.n), 1);
664
+ for (const [k, b] of buckets) {
665
+ const sec = Number(k.split('|')[1]);
666
+ const x = (sec - lo) * (w / Math.max(span, 60));
667
+ const bh = (b.n / max) * (h - 4);
668
+ ctx.fillStyle = '#58a6ff';
669
+ ctx.fillRect(x, h - bh, Math.max(1, bw - 1), bh);
670
+ if (b.err) {
671
+ const eh = (b.err / max) * (h - 4);
672
+ ctx.fillStyle = '#f85149';
673
+ ctx.fillRect(x, h - bh, Math.max(1, bw - 1), eh);
674
+ }
675
+ }
676
+ }
677
+
678
+ /* ── tasks: waterfall + aggregates + table ───────────────────────────────── */
679
+
680
+ function visibleCalls() {
681
+ return state.callOrder.filter(inSel).map((k) => state.calls.get(k)).filter(Boolean);
682
+ }
683
+
684
+ /**
685
+ * Waterfall/flame render: one lane per (pool, slot), gray wait segment then
686
+ * outcome-colored run segment. Segments are stored on `canvas._segs` for
687
+ * the shared hover/click handlers — used by both the Tasks view and the
688
+ * worker inspector's single-lane view.
689
+ */
690
+ function drawWaterfall(canvas, calls, emptyMsg) {
691
+ const ctx = canvas.getContext('2d');
692
+ const W = (canvas.width = canvas.clientWidth || 800);
693
+ const H = (canvas.height = canvas.clientHeight || 240);
694
+ ctx.clearRect(0, 0, W, H);
695
+ canvas._segs = [];
696
+ calls = calls.filter((c) => c.dispAt !== undefined);
697
+ if (!calls.length) {
698
+ ctx.fillStyle = '#8b949e';
699
+ ctx.font = '11px ui-monospace, monospace';
700
+ ctx.fillText(emptyMsg, 12, 20);
701
+ return 0;
702
+ }
703
+ const last = calls.at(-1);
704
+ const t1 = Math.max(...calls.map((c) => c.settleAt ?? last.enqAt + 1));
705
+ const t0 = Math.min(...calls.map((c) => c.enqAt), t1 - 30000);
706
+ const span = Math.max(t1 - t0, 1);
707
+
708
+ const laneOf = (c) => `${c.poolId}#${c.slot ?? '?'}`;
709
+ const lanes = [...new Set(calls.map(laneOf))];
710
+ const laneH = Math.min(28, (H - 16) / lanes.length);
711
+ const x = (t) => 8 + ((t - t0) / span) * (W - 16);
712
+ const y = (lane) => 8 + lanes.indexOf(lane) * laneH;
713
+
714
+ ctx.font = '10px ui-monospace, monospace';
715
+ lanes.forEach((l) => {
716
+ const yy = y(l);
717
+ ctx.strokeStyle = '#21262d';
718
+ ctx.beginPath(); ctx.moveTo(0, yy + laneH); ctx.lineTo(W, yy + laneH); ctx.stroke();
719
+ ctx.fillStyle = '#8b949e';
720
+ ctx.fillText(l, 8, yy + Math.min(11, laneH - 2));
721
+ });
722
+
723
+ for (const c of calls) {
724
+ const yy = y(laneOf(c)) + 2;
725
+ const hh = laneH - 6;
726
+ if (hh <= 2) continue;
727
+ const wx0 = x(c.enqAt), wx1 = x(c.dispAt);
728
+ if (wx1 - wx0 > 0.5) {
729
+ ctx.fillStyle = '#3d444d';
730
+ ctx.fillRect(wx0, yy + hh / 3, wx1 - wx0, hh / 3);
731
+ canvas._segs.push({ x0: wx0, x1: wx1, y0: yy, y1: yy + hh, call: c, part: 'wait' });
732
+ }
733
+ const rx1 = x(c.settleAt ?? t1);
734
+ ctx.fillStyle = c.outcome ? (OUTCOME_COLOR[c.outcome] ?? '#58a6ff') : '#8b949e';
735
+ ctx.fillRect(wx1, yy, Math.max(1, rx1 - wx1), hh);
736
+ canvas._segs.push({ x0: wx1, x1: rx1, y0: yy, y1: yy + hh, call: c, part: 'run' });
737
+ }
738
+ return span;
739
+ }
740
+
741
+ function renderTasks() {
742
+ const calls = visibleCalls().filter((c) => !state.drillTask || c.taskId === state.drillTask);
743
+ const span = drawWaterfall(
744
+ $('waterfall'), calls,
745
+ state.drillTask ? `no dispatched calls for ${state.drillTask}` : 'no dispatched calls yet',
746
+ );
747
+ if (span) $('wfrange').textContent = `${fmt(span / 1000, 1)}s window`;
748
+ renderTaskTables();
749
+ }
750
+
751
+ function renderTaskTables() {
752
+ // per-task aggregates
753
+ const byTask = new Map();
754
+ for (const c of visibleCalls()) {
755
+ if (!c.outcome) continue;
756
+ const a = byTask.get(c.taskId) ?? { n: 0, fail: 0, waits: [], runs: [] };
757
+ a.n++;
758
+ if (c.outcome !== 'ok') a.fail++;
759
+ if (c.waitMs !== undefined) a.waits.push(c.waitMs);
760
+ if (c.runMs !== undefined) a.runs.push(c.runMs);
761
+ byTask.set(c.taskId, a);
762
+ }
763
+ const mean = (a) => (a.length ? a.reduce((x, y) => x + y, 0) / a.length : undefined);
764
+ $('taskAgg').querySelector('tbody').innerHTML = [...byTask.entries()]
765
+ .sort((a, b) => b[1].n - a[1].n)
766
+ .map(([t, a]) => {
767
+ const runs = sortedRuns(a.runs);
768
+ return `<tr data-drill="${esc(t)}" class="${state.drillTask === t ? 'sel' : ''}">
769
+ <td>${esc(t)}</td><td>${a.n}</td><td class="${a.fail ? 'error' : ''}">${a.fail}</td>
770
+ <td>${fmt(mean(a.waits))}</td><td>${fmt(mean(runs))}</td>
771
+ <td>${fmt(pct(runs, 0.95))}</td><td>${fmt(Math.max(...runs, 0))}</td></tr>`;
772
+ }).join('') || '<tr><td colspan="7" class="muted">no settled calls</td></tr>';
773
+ for (const tr of $('taskAgg').querySelectorAll('tr[data-drill]')) {
774
+ tr.onclick = () => {
775
+ state.drillTask = state.drillTask === tr.dataset.drill ? null : tr.dataset.drill;
776
+ renderTasks();
777
+ };
778
+ }
779
+
780
+ const calls = visibleCalls().filter((c) => !state.drillTask || c.taskId === state.drillTask);
781
+ $('drill').className = state.drillTask ? 'pill on' : 'pill';
782
+ $('drill').textContent = state.drillTask ? `drilldown: ${state.drillTask} — click again to clear` : '';
783
+ $('tasks').querySelector('tbody').innerHTML = withGroups(calls.slice(-200).reverse(), (c) => c.sess, 8,
784
+ (c) => `<tr><td class="muted">#${c.seq}</td><td>${esc(c.taskId)}</td>
785
+ <td class="muted">${esc(c.poolId)}</td><td class="muted">${c.slot ?? '—'}</td>
786
+ <td>${fmt(c.waitMs)}</td><td>${fmt(c.runMs)}</td>
787
+ <td class="${c.outcome ?? ''}">${c.outcome ?? '…'}</td><td class="muted">${esc(c.error ?? '')}</td></tr>`)
788
+ || '<tr><td colspan="8" class="muted">no tasks yet</td></tr>';
789
+ }
790
+
791
+ /* ── memory / islands / log ──────────────────────────────────────────────── */
792
+
793
+ function renderMemory() {
794
+ drawLine($('memrate'), seriesFor('wr'), '#58a6ff');
795
+ // Top-5 fields by total writes — one line each, same 60s window.
796
+ const FIELD_COLORS = ['#58a6ff', '#3fb950', '#d29922', '#a371f7', '#f85149'];
797
+ const top = [...state.mem.entries()].filter(([k]) => inSel(k))
798
+ .sort((a, b) => b[1].writes - a[1].writes).slice(0, 5);
799
+ drawMultiLine($('memtop'), top.map(([k, m], i) => ({
800
+ color: FIELD_COLORS[i],
801
+ points: [...m.buckets.entries()].map(([sec, v]) => ({ sec, v })),
802
+ })));
803
+ $('memtopLegend').innerHTML = top.map(([k], i) =>
804
+ `<span><i style="background:${FIELD_COLORS[i]}"></i>${esc(k.split('|').slice(1).join('|'))}</span>`).join('');
805
+
806
+ // JS heap live lines — one per session, on the dashboard clock.
807
+ const heapSids = [...state.sessions.keys()].filter(inSel).filter((sid) => state.heapSeries.has(sid));
808
+ drawMultiLine($('heapline'), heapSids.map((sid) => ({
809
+ color: appColor(state.sessions.get(sid)?.name ?? sid),
810
+ points: [...state.heapSeries.get(sid).entries()].map(([sec, v]) => ({ sec, v })),
811
+ })));
812
+ $('heaplineLegend').innerHTML = heapSids.map((sid) => {
813
+ const s = state.sessions.get(sid);
814
+ const cur = sessHeapOf(sid);
815
+ return `<span><i style="background:${appColor(s?.name ?? sid)}"></i>${esc(s?.name ?? sid)}${cur !== undefined ? ` ${fmtBytes(cur)}` : ''}</span>`;
816
+ }).join('') || '<span class="muted">no heap samples yet — needs the memory probe (Chrome)</span>';
817
+
818
+ // JS heap per execution context — measureUserAgentSpecificMemory
819
+ // attribution; worker contexts identified by script URL.
820
+ const SHORT_SCOPE = { DedicatedWorkerGlobalScope: 'worker', SharedWorkerGlobalScope: 'shared', Window: 'window', ServiceWorkerGlobalScope: 'sw' };
821
+ const basename = (u) => { try { const x = new URL(u); return x.pathname.split('/').pop() || x.href; } catch { return u; } };
822
+ const ctxRows = [];
823
+ for (const [sid, ctx] of state.sessCtx) {
824
+ if (state.sel !== null && sid !== state.sel) continue;
825
+ if (state.sel === null && state.sessions.get(sid)?.closed) continue;
826
+ for (const c of ctx) ctxRows.push({ sid, c });
827
+ }
828
+ ctxRows.sort((a, b) => b.c.bytes - a.c.bytes);
829
+ $('heapctx').querySelector('tbody').innerHTML = withGroups(ctxRows.slice(0, 40), (r) => r.sid, 3, ({ sid, c }) =>
830
+ `<tr><td class="muted">${esc(state.sessions.get(sid)?.name ?? sid)}</td>
831
+ <td title="${esc(c.url ?? '')}"><span class="muted">${esc(SHORT_SCOPE[c.scope] ?? c.scope ?? 'ctx')}</span> ${esc(c.url ? basename(c.url) : 'unattributed')}</td>
832
+ <td>${fmtBytes(c.bytes)}</td></tr>`,
833
+ ) || '<tr><td colspan="3" class="muted">no heap data — needs Chrome measureUserAgentSpecificMemory</td></tr>';
834
+
835
+ // Writes-desc within each session (withGroups preserves this order per
836
+ // group). The same row order drives the post-insert sparkline painting.
837
+ const sorted = [...state.mem.entries()].filter(([k]) => inSel(k))
838
+ .map(([k, m]) => {
839
+ const secs = [...m.buckets.keys()];
840
+ const span = secs.length ? Math.max(...secs) - Math.min(...secs) + 1 : 1;
841
+ const vals = [];
842
+ if (secs.length) for (let s = Math.min(...secs); s <= Math.max(...secs); s++) vals.push(m.buckets.get(s) ?? 0);
843
+ return { k, m, rate: m.writes / span, vals };
844
+ })
845
+ .sort((a, b) => b.m.writes - a.m.writes);
846
+ $('mem').querySelector('tbody').innerHTML = withGroups(sorted, ({ k }) => sessOf(k), 7,
847
+ ({ k, m, rate }) => {
848
+ const id = `sp-${Math.abs([...k].reduce((a, c) => a * 31 + c.charCodeAt(0) | 0, 7))}`;
849
+ return `<tr><td>${esc(k.split('|').slice(1).join('|'))}</td>
850
+ <td class="muted">${esc(state.sessions.get(sessOf(k))?.name ?? '')}</td>
851
+ <td>${m.writes}</td><td>${fmt(rate, 1)}</td><td>${m.version ?? '—'}</td>
852
+ <td class="muted">${esc(m.writer ?? m.thread ?? '')}</td>
853
+ <td><canvas class="spark" id="${id}" width="120" height="24" style="width:120px;height:24px"></canvas></td></tr>`;
854
+ }) || '<tr><td colspan="7" class="muted">no writes</td></tr>';
855
+ // paint sparklines post-insert, matching rendered row order
856
+ const rendered = state.sel !== null
857
+ ? sorted
858
+ : [...sorted].sort((a, b) => [...state.sessions.keys()].indexOf(sessOf(a.k)) - [...state.sessions.keys()].indexOf(sessOf(b.k)));
859
+ document.querySelectorAll('canvas.spark').forEach((c, i) => {
860
+ if (rendered[i]) sparkline(c, rendered[i].vals);
861
+ });
862
+ }
863
+
864
+ const VIA_COLOR = {
865
+ mount: '#58a6ff', dispatch: '#3fb950', flush: '#8b949e',
866
+ setSize: '#d29922', updateProps: '#a371f7', unmount: '#f85149',
867
+ };
868
+
869
+ /** The real framework marks, rendered once to data URLs for HTML surfaces —
870
+ * same drawFwMark the app map uses, so tables show the actual icon. */
871
+ const fwIcons = {};
872
+ function fwIconImg(fw) {
873
+ const f = fw?.toLowerCase();
874
+ if (!f) return '';
875
+ if (!fwIcons[f]) {
876
+ const c = document.createElement('canvas');
877
+ c.width = c.height = 32;
878
+ drawFwMark(c.getContext('2d'), f, 16, 16, 13);
879
+ fwIcons[f] = c.toDataURL();
880
+ }
881
+ return `<img class="fwicon" src="${fwIcons[f]}" title="${esc(fw)}" alt="${esc(fw)}">`;
882
+ }
883
+
884
+ /** Framework chip for HTML surfaces — real icon + name. */
885
+ const fwChip = (fw) =>
886
+ fw ? `${fwIconImg(fw)} <span class="muted">${esc(fw)}</span>` : '<span class="muted">—</span>';
887
+
888
+ function renderIslands() {
889
+ const rows = [...state.islands.entries()]
890
+ .filter(([k]) => inSel(k))
891
+ .sort((a, b) => a[1].seq - b[1].seq);
892
+ $('islands').querySelector('tbody').innerHTML = withGroups(rows, ([k]) => sessOf(k), 11,
893
+ ([k, i]) => {
894
+ const host = i.poolId ? `${i.poolId}#0` : null;
895
+ const wkey = i.poolId ? `${sessOf(k)}|${i.poolId}|0` : null;
896
+ return `<tr data-inspect="${esc(k)}" class="${i.ended ? 'muted' : ''}"><td>${esc(k.split('|').slice(1).join('|'))}</td><td>${esc(i.app)}</td>
897
+ <td>${fwChip(i.fw)}</td>
898
+ <td class="muted">${esc(i.pid)}</td>
899
+ <td>${host && state.workers.has(wkey) ? `<span class="wlink" data-wk="${esc(wkey)}">${esc(host)}</span>` : `<span class="muted">${host ?? '—'}</span>`}</td>
900
+ <td>${i.tasks.length}</td><td>${i.batches}</td><td>${i.ops}</td>
901
+ <td>${fmt(i.replayMs)}</td><td>${i.events}</td>
902
+ <td class="${i.ended ? '' : 'ok'}">${i.ended ? 'unmounted' : 'live'}</td></tr>`;
903
+ })
904
+ || '<tr><td colspan="11" class="muted">no islands</td></tr>';
905
+ for (const tr of $('islands').querySelectorAll('tr[data-inspect]')) {
906
+ tr.onclick = (ev) => {
907
+ // host cell cross-links to the worker inspector instead
908
+ const link = ev.target.closest?.('.wlink');
909
+ if (link && state.workers.has(link.dataset.wk)) {
910
+ ev.stopPropagation();
911
+ state.inspectWorker = link.dataset.wk;
912
+ openSub('dashboard', 'tv-worker');
913
+ return;
914
+ }
915
+ state.inspect = tr.dataset.inspect;
916
+ openSub('dashboard', 'tv-island');
917
+ };
918
+ }
919
+ }
920
+
921
+ /* ── island inspector ────────────────────────────────────────────────────── */
922
+
923
+ function renderInspector() {
924
+ const btn = $('navTopoI');
925
+ const i = state.inspect ? state.islands.get(state.inspect) : null;
926
+ if (!i) {
927
+ btn.style.display = 'none';
928
+ if (document.getElementById('tv-island').classList.contains('on')) {
929
+ document.querySelector('.subnav button[data-sub="tv-dash"]').click();
930
+ }
931
+ return;
932
+ }
933
+ btn.style.display = '';
934
+ btn.textContent = `Island: ${state.inspect.split('|').slice(1).join('|')}`;
935
+
936
+ $('inspTitle').innerHTML =
937
+ `Island inspector — <b>${esc(state.inspect.split('|').slice(1).join('|'))}</b> ` +
938
+ `${i.fw ? fwChip(i.fw) + ' ' : ''}` +
939
+ `<span class="muted">app ${esc(i.app)} · pid ${esc(i.pid)} · ${i.ended ? 'unmounted' : 'live'}` +
940
+ (i.poolId ? ` · <a href="#" id="inspPoolLink" class="wlink">pool ${esc(i.poolId)} → worker</a>` : '') +
941
+ `</span>`;
942
+ if (i.poolId) {
943
+ const link = $('inspPoolLink');
944
+ if (link) link.onclick = (ev) => {
945
+ ev.preventDefault();
946
+ const slotKey = `${state.inspect.split('|')[0]}|${i.poolId}|0`;
947
+ if (state.workers.has(slotKey)) {
948
+ state.inspectWorker = slotKey;
949
+ openSub('dashboard', 'tv-worker');
950
+ }
951
+ };
952
+ }
953
+
954
+ const rts = i.tasks;
955
+ const failed = rts.filter((t) => t.error).length;
956
+ const meanMs = rts.length ? rts.reduce((a, t) => a + t.ms, 0) / rts.length : 0;
957
+ const maxMs = rts.length ? Math.max(...rts.map((t) => t.ms)) : 0;
958
+ // The island runs on its client pool's worker (poolSize 1 → slot 0).
959
+ const host = i.poolId ? state.workers.get(`${state.inspect.split('|')[0]}|${i.poolId}|0`) : null;
960
+ const kpi = (v, l, cls = '') => `<div class="kpi ${cls}"><div class="v">${v}</div><div class="l">${l}</div></div>`;
961
+ $('inspKpis').innerHTML =
962
+ kpi(rts.length, 'round-trips') +
963
+ kpi(fmt(meanMs), 'avg round-trip ms') +
964
+ kpi(fmt(maxMs), 'max round-trip ms', maxMs > 100 ? 'warn' : '') +
965
+ kpi(i.ops, 'ops applied') +
966
+ kpi(fmt(i.replayMs), 'replay ms total') +
967
+ kpi(i.events, 'emits') +
968
+ kpi(host?.heap !== undefined ? fmtBytes(host.heap) : '—', 'host heap') +
969
+ kpi(failed, 'failed calls', failed ? 'bad' : 'good');
970
+
971
+ drawOpTraffic(i);
972
+
973
+ $('inspVia').querySelector('tbody').innerHTML = [...i.byVia.entries()]
974
+ .sort((a, b) => b[1].ops - a[1].ops)
975
+ .map(([via, v]) => `<tr><td><i style="display:inline-block;width:10px;height:10px;border-radius:2px;background:${VIA_COLOR[via] ?? '#8b949e'};margin-right:4px"></i>${esc(via)}</td>
976
+ <td>${v.batches}</td><td>${v.ops}</td><td>${fmt(v.replayMs)}</td></tr>`)
977
+ .join('') || '<tr><td colspan="4" class="muted">no ops</td></tr>';
978
+
979
+ $('inspEvents').querySelector('tbody').innerHTML = [...i.eventNames.entries()]
980
+ .sort((a, b) => b[1] - a[1])
981
+ .map(([name, n]) => `<tr><td>${esc(name)}</td><td>${n}</td></tr>`)
982
+ .join('') || '<tr><td colspan="2" class="muted">none</td></tr>';
983
+
984
+ $('inspTasks').querySelector('tbody').innerHTML = rts.slice(-150).reverse()
985
+ .map((t) => `<tr><td class="muted">#${t.seq}</td><td>${esc(t.method)}</td>
986
+ <td>${fmt(t.ms)}</td><td>${t.ops ?? '—'}</td>
987
+ <td class="${t.error ? 'error' : 'ok'}">${t.error ? 'error' : 'ok'}</td>
988
+ <td class="muted">${esc(t.error ?? '')}</td></tr>`)
989
+ .join('') || '<tr><td colspan="6" class="muted">no calls yet</td></tr>';
990
+ }
991
+
992
+ function drawOpTraffic(i) {
993
+ const canvas = $('optraffic');
994
+ const ctx = canvas.getContext('2d');
995
+ const W = (canvas.width = canvas.clientWidth || 560);
996
+ const H = (canvas.height = 140);
997
+ ctx.clearRect(0, 0, W, H);
998
+ const tl = i.timeline;
999
+ if (!tl.length) {
1000
+ ctx.fillStyle = '#8b949e';
1001
+ ctx.font = '11px ui-monospace, monospace';
1002
+ ctx.fillText('no op traffic yet', 12, 20);
1003
+ return;
1004
+ }
1005
+ const max = Math.max(...tl.map((b) => b.count), 1);
1006
+ const bw = Math.max(2, W / tl.length - 1);
1007
+ tl.forEach((b, idx) => {
1008
+ const h = Math.max(1, (b.count / max) * (H - 18));
1009
+ ctx.fillStyle = VIA_COLOR[b.via] ?? '#8b949e';
1010
+ ctx.fillRect(idx * (W / tl.length), H - h, bw, h);
1011
+ });
1012
+ }
1013
+
1014
+ const logEl = () => $('log');
1015
+ const pad = (n, w = 2) => String(n).padStart(w, '0');
1016
+ function logLine(sess, e) {
1017
+ const div = document.createElement('div');
1018
+ const { type, at, thread, ...rest } = e;
1019
+ // Wall-clock is dashboard receive time — emit-side `at` is a per-thread
1020
+ // performance.now() epoch, so it's shown as elapsed instead.
1021
+ const now = new Date();
1022
+ const ts = `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}.${pad(now.getMilliseconds(), 3)}`;
1023
+ div.innerHTML = `<span class="logts">${ts}</span> <span class="muted">${esc(sess.name ?? sess.id)}${thread === 'worker' ? ' ⧉' : ''}</span> ${esc(type)} <span class="muted">${esc(JSON.stringify(rest))}</span> <span class="logts">+${fmt(at / 1000, 1)}s</span>`;
1024
+ const el = logEl();
1025
+ el.appendChild(div);
1026
+ while (el.children.length > 500) el.firstChild.remove();
1027
+ el.scrollTop = el.scrollHeight;
1028
+ }
1029
+
1030
+ /* ── app map — one world-space canvas; zoom/pan + zoom-tier clustering ───── */
1031
+
1032
+ const APP_COLORS = ['#3fb950', '#f0883e', '#a371f7', '#79c0ff', '#d29922', '#f85149', '#56d4dd', '#e275ad'];
1033
+ const appColor = (name) => APP_COLORS[[...name].reduce((a, c) => (a * 31 + c.charCodeAt(0)) | 0, 7) >>> 0 % APP_COLORS.length];
1034
+
1035
+ const topo = { scale: 1, ox: 0, oy: 0, fit: true, sig: '' };
1036
+
1037
+ function renderTopology() {
1038
+ const canvas = $('appmap');
1039
+ if (!canvas) return;
1040
+ const sids = state.sel !== null && state.sessions.has(state.sel)
1041
+ ? [state.sel]
1042
+ : [...state.sessions.keys()].filter((id) => !state.sessions.get(id)?.closed);
1043
+ const sig = sids.join(',');
1044
+ if (sig !== topo.sig) { topo.sig = sig; topo.fit = true; }
1045
+ drawAppMap(canvas, sids);
1046
+ window.__topo = topo; // exposed for tests/debugging
1047
+ }
1048
+
1049
+ /** World-space layout: each session is one atoll cluster in a loose grid. */
1050
+ function layoutWorld(sids) {
1051
+ const clusters = [];
1052
+ let maxR = 110;
1053
+ for (const sid of sids) {
1054
+ const workers = [...state.workers.entries()].filter(([k]) => sessOf(k) === sid);
1055
+ const islands = [...state.islands.entries()].filter(([k]) => sessOf(k) === sid);
1056
+ const hosted = new Map();
1057
+ const orphans = [];
1058
+ for (const [ik, i] of islands) {
1059
+ const wk = i.poolId ? `${sid}|${i.poolId}|0` : null;
1060
+ if (wk && state.workers.has(wk)) {
1061
+ const l = hosted.get(wk) ?? [];
1062
+ if (!l.length) hosted.set(wk, l);
1063
+ l.push({ ik, i });
1064
+ } else orphans.push({ ik, i });
1065
+ }
1066
+ // rim positions belong to pools, not workers — a size-N pool is one
1067
+ // socket containing N worker dots
1068
+ const pools = [];
1069
+ const byPool = new Map();
1070
+ for (const w of workers) {
1071
+ const pk = w[0].slice(0, w[0].lastIndexOf('|'));
1072
+ let g = byPool.get(pk);
1073
+ if (!g) {
1074
+ g = { pk, poolId: pk.split('|')[1], workers: [] };
1075
+ byPool.set(pk, g);
1076
+ pools.push(g);
1077
+ }
1078
+ g.workers.push(w);
1079
+ }
1080
+ const rimN = Math.max(pools.length + orphans.length, 1);
1081
+ const rimR = Math.max(110, rimN * 15);
1082
+ maxR = Math.max(maxR, rimR);
1083
+ clusters.push({ sid, workers, pools, islands, hosted, orphans, rimN, rimR });
1084
+ }
1085
+ const cell = maxR * 2 + 280;
1086
+ const cols = Math.min(Math.max(clusters.length, 1), 4);
1087
+ clusters.forEach((c, i) => {
1088
+ c.cx = (i % cols) * cell + cell / 2;
1089
+ c.cy = Math.floor(i / cols) * cell + cell / 2;
1090
+ });
1091
+ return { clusters, width: cols * cell, height: Math.ceil(clusters.length / cols) * cell };
1092
+ }
1093
+
1094
+ function fitTopo(canvas, world) {
1095
+ const W = canvas.clientWidth || 900;
1096
+ const H = canvas.clientHeight || 420;
1097
+ topo.scale = Math.max(0.08, Math.min(W / world.width, H / world.height, 1.4));
1098
+ topo.ox = (W - world.width * topo.scale) / 2;
1099
+ topo.oy = (H - world.height * topo.scale) / 2;
1100
+ }
1101
+
1102
+ /** framework → [chip color, short tag] — HTML chips + fallback marks. */
1103
+ const FW_BADGES = {
1104
+ react: ['#61dafb', '⚛'],
1105
+ vue: ['#42b883', 'V'],
1106
+ svelte: ['#ff3e00', 'S'],
1107
+ angular: ['#dd0031', 'A'],
1108
+ solid: ['#4f88c6', 'So'],
1109
+ vanilla: ['#f0db4f', 'JS'],
1110
+ leaflet: ['#19993b', 'L'],
1111
+ };
1112
+
1113
+ /** Real framework marks, drawn at icon size (s ≈ node radius). */
1114
+ function drawFwMark(ctx, fw, x, y, s, dead) {
1115
+ ctx.save();
1116
+ if (dead) ctx.globalAlpha = 0.35;
1117
+ switch (fw) {
1118
+ case 'react': {
1119
+ // the atom — nucleus + three orbital ellipses
1120
+ ctx.beginPath(); ctx.arc(x, y, s, 0, 7);
1121
+ ctx.fillStyle = '#22272e'; ctx.fill();
1122
+ ctx.strokeStyle = '#61dafb'; ctx.lineWidth = s * 0.13;
1123
+ for (const deg of [0, 60, 120]) {
1124
+ ctx.beginPath();
1125
+ ctx.ellipse(x, y, s * 0.95, s * 0.38, (deg * Math.PI) / 180, 0, 7);
1126
+ ctx.stroke();
1127
+ }
1128
+ ctx.beginPath(); ctx.arc(x, y, s * 0.15, 0, 7);
1129
+ ctx.fillStyle = '#61dafb'; ctx.fill();
1130
+ break;
1131
+ }
1132
+ case 'vue': {
1133
+ // nested V — green outer chevron, dark inner chevron
1134
+ const h = s * 0.92;
1135
+ ctx.beginPath();
1136
+ ctx.moveTo(x - s, y - h); ctx.lineTo(x + s, y - h); ctx.lineTo(x, y + h);
1137
+ ctx.closePath(); ctx.fillStyle = '#42b883'; ctx.fill();
1138
+ ctx.beginPath();
1139
+ ctx.moveTo(x - s * 0.58, y - h); ctx.lineTo(x + s * 0.58, y - h); ctx.lineTo(x, y + h * 0.14);
1140
+ ctx.closePath(); ctx.fillStyle = '#35495e'; ctx.fill();
1141
+ break;
1142
+ }
1143
+ case 'svelte': {
1144
+ ctx.beginPath(); ctx.arc(x, y, s, 0, 7);
1145
+ ctx.fillStyle = '#ff3e00'; ctx.fill();
1146
+ ctx.font = `bold ${s * 1.3}px ui-monospace, monospace`;
1147
+ ctx.fillStyle = '#fff'; ctx.textBaseline = 'middle';
1148
+ ctx.fillText('S', x, y + s * 0.06);
1149
+ break;
1150
+ }
1151
+ case 'angular': {
1152
+ // shield + A
1153
+ ctx.beginPath();
1154
+ ctx.moveTo(x, y - s); ctx.lineTo(x + s * 0.95, y - s * 0.62);
1155
+ ctx.lineTo(x + s * 0.78, y + s * 0.72); ctx.lineTo(x, y + s);
1156
+ ctx.lineTo(x - s * 0.78, y + s * 0.72); ctx.lineTo(x - s * 0.95, y - s * 0.62);
1157
+ ctx.closePath(); ctx.fillStyle = '#dd0031'; ctx.fill();
1158
+ ctx.font = `bold ${s * 1.15}px ui-monospace, monospace`;
1159
+ ctx.fillStyle = '#fff'; ctx.textBaseline = 'middle';
1160
+ ctx.fillText('A', x, y + s * 0.1);
1161
+ break;
1162
+ }
1163
+ case 'solid': {
1164
+ ctx.beginPath(); ctx.arc(x, y, s, 0, 7);
1165
+ ctx.fillStyle = '#4f88c6'; ctx.fill();
1166
+ ctx.font = `bold ${s * 1.15}px ui-monospace, monospace`;
1167
+ ctx.fillStyle = '#fff'; ctx.textBaseline = 'middle';
1168
+ ctx.fillText('S', x, y + s * 0.06);
1169
+ break;
1170
+ }
1171
+ case 'vanilla': {
1172
+ // the JS badge — yellow square, black JS
1173
+ ctx.beginPath();
1174
+ ctx.roundRect(x - s, y - s, s * 2, s * 2, s * 0.14);
1175
+ ctx.fillStyle = '#f0db4f'; ctx.fill();
1176
+ ctx.font = `bold ${s * 0.85}px ui-monospace, monospace`;
1177
+ ctx.fillStyle = '#0d1117'; ctx.textBaseline = 'middle';
1178
+ ctx.fillText('JS', x, y + s * 0.18);
1179
+ break;
1180
+ }
1181
+ case 'leaflet': {
1182
+ // green rounded square + white leaf
1183
+ ctx.beginPath();
1184
+ ctx.roundRect(x - s, y - s, s * 2, s * 2, s * 0.25);
1185
+ ctx.fillStyle = '#19993b'; ctx.fill();
1186
+ ctx.beginPath();
1187
+ ctx.ellipse(x, y - s * 0.08, s * 0.3, s * 0.58, Math.PI / 4, 0, 7);
1188
+ ctx.fillStyle = '#fff'; ctx.fill();
1189
+ ctx.beginPath();
1190
+ ctx.moveTo(x - s * 0.45, y + s * 0.42); ctx.lineTo(x + s * 0.45, y - s * 0.48);
1191
+ ctx.strokeStyle = '#19993b'; ctx.lineWidth = s * 0.07; ctx.stroke();
1192
+ break;
1193
+ }
1194
+ default: {
1195
+ // tagged but unknown framework — green disc + first letter
1196
+ ctx.beginPath(); ctx.arc(x, y, s * 0.85, 0, 7);
1197
+ ctx.fillStyle = dead ? '#484f58' : '#3fb950'; ctx.fill();
1198
+ ctx.font = `bold ${s * 0.9}px ui-monospace, monospace`;
1199
+ ctx.fillStyle = '#0d1117'; ctx.textBaseline = 'middle';
1200
+ ctx.fillText(fw[0].toUpperCase(), x, y);
1201
+ }
1202
+ }
1203
+ ctx.restore();
1204
+ ctx.textBaseline = 'top';
1205
+ }
1206
+
1207
+ /** The island node — the framework mark IS the dot. Untagged islands
1208
+ * keep the plain green dot; ended islands dim. Returns the hit radius. */
1209
+ function drawIslandDot(ctx, i, x, y, inv) {
1210
+ const fw = i.fw?.toLowerCase();
1211
+ if (fw) {
1212
+ drawFwMark(ctx, fw, x, y, 12 * inv, i.ended);
1213
+ return 12 * inv;
1214
+ }
1215
+ ctx.beginPath(); ctx.arc(x, y, 5.5 * inv, 0, 7);
1216
+ ctx.fillStyle = i.ended ? '#484f58' : '#3fb950';
1217
+ ctx.fill();
1218
+ return 5.5 * inv;
1219
+ }
1220
+
1221
+ function drawAppMap(canvas, sids) {
1222
+ const ctx = canvas.getContext('2d');
1223
+ const W = (canvas.width = canvas.clientWidth || 900);
1224
+ const H = (canvas.height = canvas.clientHeight || 420);
1225
+ ctx.clearRect(0, 0, W, H);
1226
+ const world = layoutWorld(sids);
1227
+ if (topo.fit) { fitTopo(canvas, world); topo.fit = false; }
1228
+
1229
+ const t = topo.scale;
1230
+ const full = t >= 0.7, mid = t >= 0.25; // zoom tiers
1231
+ const inv = Math.min(1 / t, 3); // keeps marks/labels screen-sized
1232
+ const nodes = [];
1233
+
1234
+ ctx.save();
1235
+ ctx.translate(topo.ox, topo.oy);
1236
+ ctx.scale(t, t);
1237
+ ctx.textAlign = 'center';
1238
+ ctx.textBaseline = 'top';
1239
+
1240
+ for (const cl of world.clusters) {
1241
+ const { cx, cy } = cl;
1242
+ const sess = state.sessions.get(cl.sid);
1243
+
1244
+ if (!mid) {
1245
+ // far tier — session collapsed to one clickable disc
1246
+ const r = cl.rimR * 0.5;
1247
+ ctx.beginPath(); ctx.arc(cx, cy, r, 0, 7);
1248
+ ctx.fillStyle = '#a371f71a'; ctx.fill();
1249
+ ctx.strokeStyle = '#a371f7'; ctx.lineWidth = 2 * inv; ctx.stroke();
1250
+ // the atoll look — the session's islands as framework marks on the rim
1251
+ const mi = Math.min(inv, r / 26); // mark scale ~0.45× disc radius
1252
+ cl.islands.forEach(([, i], j) => {
1253
+ const a = (j / Math.max(cl.islands.length, 1)) * 2 * Math.PI - Math.PI / 2;
1254
+ drawIslandDot(ctx, i, cx + r * Math.cos(a), cy + r * Math.sin(a), mi);
1255
+ });
1256
+ ctx.beginPath(); ctx.arc(cx, cy, 8 * inv, 0, 7);
1257
+ ctx.fillStyle = '#a371f7'; ctx.fill();
1258
+ ctx.font = `${11 * inv}px ui-monospace, monospace`;
1259
+ ctx.fillStyle = '#c9d1d9';
1260
+ ctx.fillText(sess?.name ?? cl.sid, cx, cy + 14 * inv);
1261
+ ctx.fillStyle = '#8b949e';
1262
+ ctx.fillText(`${cl.pools.length} pools · ${cl.workers.length} workers · ${cl.islands.length} islands`, cx, cy + 27 * inv);
1263
+ nodes.push({ x: cx, y: cy, r, kind: 'session', key: cl.sid });
1264
+ continue;
1265
+ }
1266
+
1267
+ const workerR = cl.rimR + 38; // mid ring — workers
1268
+ const islandR = cl.rimR + 76; // outer ring — islands
1269
+ // orbit lines — one per level, so pool/worker/island nodes sit ON them
1270
+ ctx.strokeStyle = '#21262d'; ctx.lineWidth = 1 * inv;
1271
+ for (const r of [cl.rimR, workerR, islandR]) {
1272
+ ctx.beginPath(); ctx.arc(cx, cy, r, 0, 7); ctx.stroke();
1273
+ }
1274
+
1275
+ const ppos = cl.pools.map((p, idx) => {
1276
+ const a = (idx / cl.rimN) * 2 * Math.PI - Math.PI / 2;
1277
+ return { ...p, a, x: cx + cl.rimR * Math.cos(a), y: cy + cl.rimR * Math.sin(a) };
1278
+ });
1279
+ ctx.strokeStyle = '#21262d';
1280
+ ctx.lineWidth = 1 * inv;
1281
+ for (const p of ppos) {
1282
+ ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(p.x, p.y); ctx.stroke();
1283
+ }
1284
+
1285
+ const font = (px) => `${px * inv}px ui-monospace, monospace`;
1286
+ for (const p of ppos) {
1287
+ const mark = Math.max(inv, 1);
1288
+ // pool — a gray socket on the rim, the container for its workers
1289
+ const poolR = 10 * mark;
1290
+ ctx.beginPath(); ctx.arc(p.x, p.y, poolR, 0, 7);
1291
+ ctx.fillStyle = '#21262d'; ctx.fill();
1292
+ ctx.strokeStyle = '#8b949e55'; ctx.lineWidth = 1.2 * inv; ctx.stroke();
1293
+ if (full) {
1294
+ ctx.font = font(10); ctx.fillStyle = '#8b949e';
1295
+ ctx.fillText(p.poolId, p.x, p.y + poolR + 2 * inv);
1296
+ }
1297
+ const n = p.workers.length;
1298
+ p.workers.forEach(([k, w], j) => {
1299
+ // workers spread around the pool's angle on the mid ring
1300
+ const a2 = p.a + (j - (n - 1) / 2) * 0.22;
1301
+ const wx = cx + workerR * Math.cos(a2), wy = cy + workerR * Math.sin(a2);
1302
+ const r = (7 + (w.heap !== undefined ? Math.min(6, (w.heap / (80 << 20)) * 6) : 0)) * mark;
1303
+ // pool → worker link
1304
+ ctx.beginPath(); ctx.moveTo(p.x, p.y); ctx.lineTo(wx, wy);
1305
+ ctx.strokeStyle = '#8b949e44'; ctx.lineWidth = 1.2 * inv; ctx.stroke();
1306
+ const isl = cl.hosted.get(k) ?? [];
1307
+ if (isl.length === 0) {
1308
+ // a worker hosting no islands is itself a vanilla island —
1309
+ // same mark the islands column shows
1310
+ drawFwMark(ctx, 'vanilla', wx, wy, Math.max(r, 9 * mark), w.dead);
1311
+ } else {
1312
+ ctx.beginPath(); ctx.arc(wx, wy, r, 0, 7);
1313
+ ctx.fillStyle = w.dead ? '#484f58' : '#8b949e';
1314
+ ctx.fill();
1315
+ }
1316
+ if (full) {
1317
+ ctx.font = font(10); ctx.fillStyle = '#8b949e';
1318
+ ctx.fillText(`${p.poolId}#${k.split('|').pop()}`, wx, wy + r + 2 * inv);
1319
+ }
1320
+ nodes.push({ x: wx, y: wy, r: r + 4 * inv, kind: 'worker', key: k });
1321
+ // this worker's islands spread around its angle on the outer ring —
1322
+ // marks stay visible when zoomed out (the atoll look)
1323
+ isl.forEach(({ ik, i }, jj) => {
1324
+ const a3 = a2 + (jj - (isl.length - 1) / 2) * 0.18;
1325
+ const ix = cx + islandR * Math.cos(a3), iy = cy + islandR * Math.sin(a3);
1326
+ if (full) {
1327
+ // host link — worker → its island
1328
+ ctx.beginPath(); ctx.moveTo(wx, wy); ctx.lineTo(ix, iy);
1329
+ ctx.strokeStyle = `${i.ended ? '#484f58' : '#3fb950'}55`;
1330
+ ctx.lineWidth = 1.4 * inv; ctx.stroke();
1331
+ }
1332
+ const r = drawIslandDot(ctx, i, ix, iy, inv);
1333
+ if (full) {
1334
+ ctx.fillStyle = '#8b949e';
1335
+ ctx.fillText(ik.split('|').slice(1).join('|'), ix, iy + r + 2 * inv);
1336
+ }
1337
+ nodes.push({ x: ix, y: iy, r: r + 4 * inv, kind: 'island', key: ik });
1338
+ });
1339
+ });
1340
+ }
1341
+ cl.orphans.forEach(({ ik, i }, j) => {
1342
+ const a = ((cl.pools.length + j) / cl.rimN) * 2 * Math.PI - Math.PI / 2;
1343
+ const ix = cx + islandR * Math.cos(a), iy = cy + islandR * Math.sin(a);
1344
+ const r = drawIslandDot(ctx, i, ix, iy, inv);
1345
+ if (full) {
1346
+ ctx.fillStyle = '#8b949e';
1347
+ ctx.fillText(ik.split('|').slice(1).join('|'), ix, iy + r + 2 * inv);
1348
+ }
1349
+ nodes.push({ x: ix, y: iy, r: r + 4 * inv, kind: 'island', key: ik });
1350
+ });
1351
+
1352
+ ctx.beginPath(); ctx.arc(cx, cy, 16 * inv, 0, 7);
1353
+ ctx.fillStyle = '#a371f7'; ctx.fill();
1354
+ ctx.font = font(11);
1355
+ ctx.fillStyle = '#c9d1d9';
1356
+ ctx.fillText(sess?.name ?? cl.sid, cx, cy + 19 * inv);
1357
+ ctx.fillStyle = '#8b949e';
1358
+ ctx.fillText('main thread', cx, cy + 32 * inv);
1359
+ if (!cl.pools.length && !cl.orphans.length) ctx.fillText('no workers or islands yet', cx, cy + 46 * inv);
1360
+ // the session hub is clickable too — same drill-in as the far-zoom disc
1361
+ nodes.push({ x: cx, y: cy, r: 16 * inv, kind: 'session', key: cl.sid });
1362
+ }
1363
+
1364
+ // selection highlight — ring whatever is under inspection / selected
1365
+ for (const n of nodes) {
1366
+ const sel = (n.kind === 'worker' && n.key === state.inspectWorker)
1367
+ || (n.kind === 'island' && n.key === state.inspect)
1368
+ || (n.kind === 'session' && n.key === state.sel);
1369
+ if (!sel) continue;
1370
+ const rr = n.r + 2.5 * inv;
1371
+ ctx.beginPath(); ctx.arc(n.x, n.y, rr, 0, 7);
1372
+ ctx.strokeStyle = '#58a6ff'; ctx.lineWidth = 1.8 * inv; ctx.stroke();
1373
+ ctx.beginPath(); ctx.arc(n.x, n.y, rr + 3 * inv, 0, 7);
1374
+ ctx.strokeStyle = '#58a6ff44'; ctx.lineWidth = 1 * inv; ctx.stroke();
1375
+ }
1376
+ ctx.restore();
1377
+ canvas._nodes = nodes;
1378
+ }
1379
+
1380
+ /* ── workers + worker inspector ──────────────────────────────────────────── */
1381
+
1382
+ function renderWorkers() {
1383
+ // pool → hosted island names — the reverse of island.poolId
1384
+ const hostedBy = new Map();
1385
+ for (const [ik, i] of state.islands) {
1386
+ if (!i.poolId) continue;
1387
+ const pk = `${sessOf(ik)}|${i.poolId}`;
1388
+ const l = hostedBy.get(pk) ?? [];
1389
+ if (!l.length) hostedBy.set(pk, l);
1390
+ l.push({ name: ik.split('|').slice(1).join('|'), fw: i.fw });
1391
+ }
1392
+ const rows = [...state.workers.entries()]
1393
+ .filter(([k]) => inSel(k))
1394
+ .sort((a, b) => {
1395
+ // pool+slot ordering within a session (session order applied by withGroups)
1396
+ const pa = a[0].split('|').slice(1).join('|'), pb = b[0].split('|').slice(1).join('|');
1397
+ return pa.localeCompare(pb, undefined, { numeric: true });
1398
+ });
1399
+ $('workers').querySelector('tbody').innerHTML = withGroups(rows, ([k]) => sessOf(k), 10,
1400
+ ([k, w]) => {
1401
+ const [, poolId, slot] = k.split('|');
1402
+ const hosted = hostedBy.get(`${sessOf(k)}|${poolId}`) ?? [];
1403
+ return `<tr data-winspect="${esc(k)}" class="${w.dead ? 'muted' : ''}">
1404
+ <td class="muted">${esc(state.sessions.get(sessOf(k))?.name ?? sessOf(k))}${state.sessions.get(sessOf(k))?.closed ? ' (ended)' : ''}</td>
1405
+ <td>${esc(poolId)}#${esc(slot)}</td>
1406
+ <td>${w.tasks}</td><td class="${w.failed ? 'error' : ''}">${w.failed}</td>
1407
+ <td class="${w.respawns ? 'timeout' : ''}">${w.respawns}</td>
1408
+ <td class="${w.errors.length ? 'error' : ''}">${w.errors.length}</td>
1409
+ <td class="${w.heap !== undefined ? '' : 'muted'}">${w.heap !== undefined ? fmtBytes(w.heap) : '—'}</td>
1410
+ <td>${hosted.length
1411
+ ? `<span class="ok">${hosted.map((h) => `${fwIconImg(h.fw)} ${esc(h.name)}`).join(', ')}</span>`
1412
+ : `<span class="muted" title="no hosted islands — plain worker">${fwIconImg('vanilla')}</span>`}</td>
1413
+ <td class="muted">${esc(w.lastTaskId || '—')}</td>
1414
+ <td class="${w.dead ? 'error' : 'ok'}">${w.dead ? 'down' : 'live'}</td></tr>`;
1415
+ }) || '<tr><td colspan="10" class="muted">no workers yet</td></tr>';
1416
+ for (const tr of $('workers').querySelectorAll('tr[data-winspect]')) {
1417
+ tr.onclick = () => {
1418
+ state.inspectWorker = tr.dataset.winspect;
1419
+ openSub('dashboard', 'tv-worker');
1420
+ };
1421
+ }
1422
+ }
1423
+
1424
+ function renderWorkerInsp() {
1425
+ const btn = $('navTopoW');
1426
+ const k = state.inspectWorker;
1427
+ const w = k ? state.workers.get(k) : null;
1428
+ if (!w) {
1429
+ btn.style.display = 'none';
1430
+ if (document.getElementById('tv-worker').classList.contains('on')) {
1431
+ document.querySelector('.subnav button[data-sub="tv-dash"]').click();
1432
+ }
1433
+ return;
1434
+ }
1435
+ const [sess, poolId, slot] = k.split('|');
1436
+ btn.style.display = '';
1437
+ btn.textContent = `Worker: ${poolId}#${slot}`;
1438
+
1439
+ // Islands hosted by this pool (for the "what is this worker" breadcrumb).
1440
+ const hosted = [...state.islands.entries()]
1441
+ .filter(([ik, i]) => sessOf(ik) === sess && i.poolId === poolId)
1442
+ .map(([ik, i]) => `${fwIconImg(i.fw)} ${esc(ik.split('|').slice(1).join('|'))}`);
1443
+ $('wkTitle').innerHTML =
1444
+ `Worker inspector — <b>${esc(poolId)} slot ${esc(slot)}</b> ` +
1445
+ `<span class="muted">${esc(state.sessions.get(sess)?.name ?? sess)} · ${w.dead ? 'down' : 'live'}` +
1446
+ (hosted.length ? ` · hosting ${hosted.join(', ')}` : '') + `</span>`;
1447
+
1448
+ const uptime = (w.lastAt - w.spawnedAt) / 1000;
1449
+ const kpi = (v, l, cls = '') => `<div class="kpi ${cls}"><div class="v">${v}</div><div class="l">${l}</div></div>`;
1450
+ $('wkKpis').innerHTML =
1451
+ kpi(w.tasks, 'tasks run') +
1452
+ kpi(w.failed, 'failed', w.failed ? 'bad' : 'good') +
1453
+ kpi(w.respawns, 'respawns', w.respawns ? 'warn' : '') +
1454
+ kpi(w.errors.length, 'errors', w.errors.length ? 'bad' : '') +
1455
+ kpi(w.heap !== undefined ? fmtBytes(w.heap) : '—', 'heap', w.heapLimit && w.heap > w.heapLimit * 0.8 ? 'bad' : '') +
1456
+ kpi(fmt(uptime, 1) + 's', 'uptime (last activity)') +
1457
+ kpi(hosted.length, 'islands hosted');
1458
+
1459
+ const calls = visibleCalls().filter((c) => c.sess === sess && c.poolId === poolId && c.slot === Number(slot));
1460
+ const span = drawWaterfall($('wklane'), calls, 'no tasks dispatched to this worker yet');
1461
+ $('wkrange').textContent = span ? `${fmt(span / 1000, 1)}s window — click a segment to drill` : '';
1462
+
1463
+ $('wkTasks').querySelector('tbody').innerHTML = calls.slice(-150).reverse()
1464
+ .map((c) => `<tr><td class="muted">#${c.seq}</td><td>${esc(c.taskId)}</td>
1465
+ <td>${fmt(c.waitMs)}</td><td>${fmt(c.runMs)}</td>
1466
+ <td class="${c.outcome ?? ''}">${c.outcome ?? '…'}</td><td class="muted">${esc(c.error ?? '')}</td></tr>`)
1467
+ .join('') || '<tr><td colspan="6" class="muted">no tasks</td></tr>';
1468
+
1469
+ // JS heap (MB) over the last ~2min of samples — Chrome-only probe.
1470
+ drawLine(
1471
+ $('wkheap'),
1472
+ [...(w.heapSeries?.entries() ?? [])].map(([sec, v]) => ({ sec, v })).sort((a, b) => a.sec - b.sec),
1473
+ '#a371f7',
1474
+ );
1475
+
1476
+ const wf = state.fetches
1477
+ .filter((f) => f.sess === sess && f.worker && f.worker.poolId === poolId && f.worker.slot === Number(slot))
1478
+ .slice(-50).reverse();
1479
+ $('wkFetch').querySelector('tbody').innerHTML = wf.map((f) => {
1480
+ const cls = f.error || f.status >= 400 ? 'error' : f.status >= 300 ? 'timeout' : 'ok';
1481
+ return `<tr data-fetch="${f.seq}"><td>${fmt(f.ms)}</td><td class="${cls}">${f.status !== undefined ? f.status : '✕'}</td>
1482
+ <td>${esc(f.method)}</td><td class="url" title="${esc(f.url)}">${esc(shortUrl(f.url))}</td>
1483
+ <td class="muted">${f.bytes !== undefined ? fmtBytes(f.bytes) : '—'}</td></tr>`;
1484
+ }).join('') || '<tr><td colspan="5" class="muted">no fetches from this worker</td></tr>';
1485
+ for (const tr of $('wkFetch').querySelectorAll('tr[data-fetch]')) {
1486
+ tr.onclick = () => {
1487
+ state.inspectFetch = Number(tr.dataset.fetch);
1488
+ openSub('network', 'nv-req');
1489
+ };
1490
+ }
1491
+
1492
+ $('wkErrors').innerHTML = w.errors.length
1493
+ ? w.errors.slice().reverse().map((x) => `<div style="padding:2px 0;border-bottom:1px dashed #21262d"><span class="error">error</span> ${esc(x.message)}</div>`).join('')
1494
+ : '<div class="muted">none</div>';
1495
+ }
1496
+
1497
+ /* ── render dispatch ─────────────────────────────────────────────────────── */
1498
+
1499
+ function render() {
1500
+ renderOverview();
1501
+ renderTasks();
1502
+ renderMemory();
1503
+ renderIslands();
1504
+ renderInspector();
1505
+ renderTopology();
1506
+ renderWorkers();
1507
+ renderWorkerInsp();
1508
+ renderNetwork();
1509
+ renderFetchInsp();
1510
+ const selSess = state.sel ? state.sessions.get(state.sel) : null;
1511
+ $('scope').innerHTML = state.sel
1512
+ ? `<span class="muted">viewing</span> <span class="nm">${esc(selSess?.name ?? state.sel)}</span>` +
1513
+ `<span class="meta">${esc(selSess?.runtime ?? '')}${selSess?.closed ? ' · ended' : ''}</span>` +
1514
+ ` <a href="#" class="wlink" id="scopeAll">← all live sessions</a>`
1515
+ : `<span class="nm">all live sessions</span>` +
1516
+ `<span class="meta">${[...state.sessions.values()].filter((s) => !s.closed).length} connected — ended sessions stay in the sidebar</span>`;
1517
+ const scopeAll = $('scopeAll');
1518
+ if (scopeAll) scopeAll.onclick = (ev) => { ev.preventDefault(); state.sel = null; render(); };
1519
+ const apps = state.sel ? 1 : [...state.sessions.values()].filter((s) => !s.closed).length;
1520
+ const heapTotal = [...state.sessions.keys()].filter(inSel).reduce((a, sid) => a + (sessHeapOf(sid) ?? 0), 0);
1521
+ $('counts').textContent =
1522
+ `${apps} apps · ${[...state.islands.entries()].filter(([k, i]) => inSel(k) && !i.ended).length} islands · ${[...state.workers.entries()].filter(([k, w]) => inSel(k) && !w.dead).length} workers · ${heapTotal ? fmtBytes(heapTotal) : '—'} heap`;
1523
+ const sess = [...state.sessions.values()];
1524
+ $('sessions').innerHTML = sess.length
1525
+ ? `<div class="sess ${state.sel === null ? 'sel' : ''}" data-id=""><span class="nm">all live sessions</span></div>` +
1526
+ sess.map((s) =>
1527
+ `<div class="sess ${state.sel === s.id ? 'sel' : ''} ${s.closed ? 'muted' : ''}" data-id="${esc(s.id)}"><span class="nm">${esc(s.name ?? s.id)}</span>${s.closed ? ` <span class="pill">ended</span><span class="pin${s.pinned ? ' on' : ''}" title="${s.pinned ? 'pinned — kept after the 30min cleanup' : 'pin — keep after the 30min cleanup'}">◆</span><span class="dismiss" title="dismiss session">×</span>` : ''}<br><span class="rt">${esc(s.runtime)}${sessHeapOf(s.id) !== undefined ? ` · heap ${fmtBytes(sessHeapOf(s.id))}` : ''} · ${esc(s.hint ?? '')}</span></div>`,
1528
+ ).join('')
1529
+ : '<div class="muted">none</div>';
1530
+ for (const el of document.querySelectorAll('.sess')) {
1531
+ el.onclick = (ev) => {
1532
+ if (ev.target.classList.contains('dismiss')) {
1533
+ dismissSession(el.dataset.id);
1534
+ return;
1535
+ }
1536
+ if (ev.target.classList.contains('pin')) {
1537
+ pinSession(el.dataset.id, !state.sessions.get(el.dataset.id)?.pinned);
1538
+ return;
1539
+ }
1540
+ state.sel = el.dataset.id || null;
1541
+ render();
1542
+ };
1543
+ }
1544
+ }
1545
+
1546
+ let raf = 0;
1547
+ const scheduleRender = () => {
1548
+ if (raf) return;
1549
+ raf = requestAnimationFrame(() => { raf = 0; render(); });
1550
+ };
1551
+
1552
+ // Keep the live charts rolling while their tab is visible, even between
1553
+ // batches — cheap canvas redraws, ~1 fps. In broadcast mode this tick
1554
+ // also runs the closed-session retention sweep.
1555
+ setInterval(() => {
1556
+ if ($('view-network').classList.contains('on')) renderNetwork();
1557
+ if ($('view-memory').classList.contains('on')) renderMemory();
1558
+ if ($('view-dashboard').classList.contains('on')) renderTopology();
1559
+ if (BC) sweepClosed();
1560
+ }, 1000);
1561
+
1562
+ /* ── tabs + waterfall interaction ────────────────────────────────────────── */
1563
+
1564
+ for (const b of document.querySelectorAll('.panels > nav button')) {
1565
+ b.onclick = () => {
1566
+ document.querySelectorAll('.panels > nav button').forEach((x) => x.classList.toggle('on', x === b));
1567
+ document.querySelectorAll('.view').forEach((v) => v.classList.toggle('on', v.id === `view-${b.dataset.view}`));
1568
+ render();
1569
+ };
1570
+ }
1571
+
1572
+ /* ── app map interaction — wheel zoom, drag pan, click/double-click ─────── */
1573
+
1574
+ const amap = $('appmap');
1575
+ if (amap) {
1576
+ let drag = null;
1577
+ amap.addEventListener('wheel', (e) => {
1578
+ e.preventDefault();
1579
+ const r = amap.getBoundingClientRect();
1580
+ const mx = e.clientX - r.left, my = e.clientY - r.top;
1581
+ const k = e.deltaY < 0 ? 1.15 : 1 / 1.15;
1582
+ const ns = Math.min(4, Math.max(0.05, topo.scale * k));
1583
+ const f = ns / topo.scale;
1584
+ topo.ox = mx - (mx - topo.ox) * f;
1585
+ topo.oy = my - (my - topo.oy) * f;
1586
+ topo.scale = ns;
1587
+ topo.fit = false;
1588
+ renderTopology();
1589
+ }, { passive: false });
1590
+ amap.addEventListener('mousedown', (e) => {
1591
+ drag = { x: e.clientX, y: e.clientY, ox: topo.ox, oy: topo.oy, moved: false };
1592
+ });
1593
+ window.addEventListener('mousemove', (e) => {
1594
+ if (!drag) return;
1595
+ const dx = e.clientX - drag.x, dy = e.clientY - drag.y;
1596
+ if (!drag.moved && dx * dx + dy * dy < 16) return;
1597
+ drag.moved = true;
1598
+ topo.ox = drag.ox + dx;
1599
+ topo.oy = drag.oy + dy;
1600
+ topo.fit = false;
1601
+ renderTopology();
1602
+ });
1603
+ window.addEventListener('mouseup', (e) => {
1604
+ if (!drag) return;
1605
+ const wasClick = !drag.moved;
1606
+ drag = null;
1607
+ if (!wasClick) return;
1608
+ const r = amap.getBoundingClientRect();
1609
+ const mx = (e.clientX - r.left - topo.ox) / topo.scale;
1610
+ const my = (e.clientY - r.top - topo.oy) / topo.scale;
1611
+ const n = (amap._nodes ?? []).find((n) => (mx - n.x) ** 2 + (my - n.y) ** 2 <= n.r * n.r);
1612
+ if (!n) return;
1613
+ if (n.kind === 'session') {
1614
+ // drill into the cluster — selects the session and refits to just it
1615
+ state.sel = n.key;
1616
+ topo.fit = true;
1617
+ render();
1618
+ } else if (n.kind === 'worker') {
1619
+ state.inspectWorker = n.key;
1620
+ openSub('dashboard', 'tv-worker');
1621
+ } else {
1622
+ state.inspect = n.key;
1623
+ openSub('dashboard', 'tv-island');
1624
+ }
1625
+ });
1626
+ amap.addEventListener('dblclick', () => {
1627
+ state.sel = null; // back out to the whole fleet
1628
+ topo.fit = true;
1629
+ render();
1630
+ });
1631
+ }
1632
+
1633
+ // Mini mode — the flyout is small, so the app map becomes its own
1634
+ // default sub-tab and gets the whole view instead of a hero strip.
1635
+ if (new URLSearchParams(location.search).has('mini')) {
1636
+ const view = document.getElementById('view-dashboard');
1637
+ const subnav = view.querySelector('.subnav');
1638
+ const mapView = document.createElement('div');
1639
+ mapView.className = 'subview on';
1640
+ mapView.id = 'tv-map';
1641
+ mapView.append(view.querySelector('h2'), $('appmap'));
1642
+ view.appendChild(mapView);
1643
+ const mapBtn = document.createElement('button');
1644
+ mapBtn.dataset.sub = 'tv-map';
1645
+ mapBtn.textContent = 'Map';
1646
+ mapBtn.className = 'on';
1647
+ subnav.prepend(mapBtn);
1648
+ subnav.querySelector('[data-sub="tv-dash"]').classList.remove('on');
1649
+ document.getElementById('tv-dash').classList.remove('on');
1650
+ }
1651
+
1652
+ for (const b of document.querySelectorAll('.subnav button')) {
1653
+ b.onclick = () => {
1654
+ const nav = b.closest('.subnav');
1655
+ nav.querySelectorAll('button').forEach((x) => x.classList.toggle('on', x === b));
1656
+ nav.parentElement.querySelectorAll(':scope > .subview')
1657
+ .forEach((v) => v.classList.toggle('on', v.id === b.dataset.sub));
1658
+ render();
1659
+ };
1660
+ }
1661
+
1662
+ /** Open a subtab under a main view — switches main tabs first if needed. */
1663
+ const openSub = (view, sub) => {
1664
+ if (!document.getElementById(`view-${view}`).classList.contains('on')) {
1665
+ document.querySelector(`.panels > nav button[data-view="${view}"]`).click();
1666
+ }
1667
+ render(); // lets the inspector renders unhide their subtab buttons
1668
+ const btn = document.querySelector(`#view-${view} .subnav button[data-sub="${sub}"]`);
1669
+ if (btn && btn.style.display !== 'none') btn.click();
1670
+ };
1671
+
1672
+ const tip = $('tip');
1673
+ const attachWaterfallHandlers = (canvas) => {
1674
+ const segAt = (e) => {
1675
+ const r = canvas.getBoundingClientRect();
1676
+ const mx = e.clientX - r.left, my = e.clientY - r.top;
1677
+ return (canvas._segs ?? []).find((s) => mx >= s.x0 && mx <= s.x1 && my >= s.y0 && my <= s.y1);
1678
+ };
1679
+ canvas.addEventListener('mousemove', (e) => {
1680
+ const seg = segAt(e);
1681
+ if (!seg) { tip.style.display = 'none'; return; }
1682
+ const c = seg.call;
1683
+ tip.innerHTML = `<b>${esc(c.taskId)}</b> · ${esc(c.poolId)} slot ${c.slot ?? '?'}<br>
1684
+ wait ${fmt(c.waitMs)} ms · run ${fmt(c.runMs)} ms · <span class="${c.outcome ?? ''}">${c.outcome ?? 'in flight'}</span>
1685
+ ${c.error ? `<br><span class="muted">${esc(c.error)}</span>` : ''}`;
1686
+ tip.style.display = 'block';
1687
+ tip.style.left = `${e.clientX + 12}px`;
1688
+ tip.style.top = `${e.clientY + 12}px`;
1689
+ });
1690
+ canvas.addEventListener('mouseleave', () => { tip.style.display = 'none'; });
1691
+ canvas.addEventListener('click', (e) => {
1692
+ const seg = segAt(e);
1693
+ if (seg) {
1694
+ state.drillTask = state.drillTask === seg.call.taskId ? null : seg.call.taskId;
1695
+ renderTasks();
1696
+ // A drill set from the worker lane jumps to the tasks view where the
1697
+ // filter is visible.
1698
+ if (state.drillTask && canvas.id === 'wklane') {
1699
+ document.querySelector('nav button[data-view="tasks"]').click();
1700
+ }
1701
+ }
1702
+ });
1703
+ };
1704
+ attachWaterfallHandlers($('waterfall'));
1705
+ attachWaterfallHandlers($('wklane'));
1706
+
1707
+ /* ── connection ──────────────────────────────────────────────────────────── */
1708
+
1709
+ let viewer = null;
1710
+
1711
+ /**
1712
+ * Pure-client mode: the dashboard page is served on the app's own origin
1713
+ * (vite plugin mounts it at /__atoll/) and listens on the same
1714
+ * BroadcastChannel the app publishes on. There is no server-side session
1715
+ * registry — this page builds it from hello/batch/bye frames and owns
1716
+ * retention (pins + TTL) itself.
1717
+ */
1718
+ const BC = window.__ATOLL_TRANSPORT === 'broadcast' && typeof BroadcastChannel !== 'undefined'
1719
+ ? new BroadcastChannel('atoll-devtools')
1720
+ : null;
1721
+ // ?mini=1 — the overlay flyout: compact single-instance layout (CSS does
1722
+ // the hiding; the session sidebar is meaningless for one origin's app).
1723
+ if (new URLSearchParams(location.search).has('mini')) document.body.classList.add('mini');
1724
+ const bcSessions = new Map(); // broadcast mode: local session registry
1725
+ const CLOSED_TTL = 30 * 60 * 1000;
1726
+ // Pure-client mode is structurally single-app — sessions are just this
1727
+ // origin's instances, so the selector/scope title earn their space back.
1728
+ if (BC) document.body.classList.add('local');
1729
+
1730
+ const bcSync = () => reconcileSessions([...bcSessions.values()]);
1731
+
1732
+ /** Broadcast-mode retention: closed unpinned sessions expire after the TTL. */
1733
+ const sweepClosed = () => {
1734
+ const cutoff = Date.now() - CLOSED_TTL;
1735
+ let changed = false;
1736
+ for (const [id, s] of bcSessions) {
1737
+ if (s.closed && !s.pinned && s.closedAt !== undefined && s.closedAt < cutoff) {
1738
+ bcSessions.delete(id);
1739
+ changed = true;
1740
+ }
1741
+ }
1742
+ if (changed) { bcSync(); scheduleRender(); }
1743
+ };
1744
+
1745
+ const dismissSession = (id) => {
1746
+ if (BC) {
1747
+ bcSessions.delete(id);
1748
+ bcSync();
1749
+ scheduleRender();
1750
+ return;
1751
+ }
1752
+ if (viewer?.readyState === WebSocket.OPEN) {
1753
+ viewer.send(JSON.stringify({ type: 'dismiss', sessionId: id }));
1754
+ }
1755
+ };
1756
+
1757
+ const pinSession = (id, pinned) => {
1758
+ if (BC) {
1759
+ const s = bcSessions.get(id);
1760
+ if (s) { s.pinned = pinned; bcSync(); scheduleRender(); }
1761
+ return;
1762
+ }
1763
+ if (viewer?.readyState === WebSocket.OPEN) {
1764
+ viewer.send(JSON.stringify({ type: 'pin', sessionId: id, pinned }));
1765
+ }
1766
+ };
1767
+
1768
+ function connect() {
1769
+ if (BC) {
1770
+ $('conn').textContent = 'local';
1771
+ $('conn').className = 'on';
1772
+ BC.onmessage = (msg) => {
1773
+ const data = msg.data;
1774
+ if (data.type === 'hello') {
1775
+ // a fresh hello means a new app instance — same id can't revive
1776
+ const prev = bcSessions.get(data.session.id);
1777
+ bcSessions.set(data.session.id, {
1778
+ ...data.session, closed: false, closedAt: undefined, pinned: prev?.pinned,
1779
+ });
1780
+ bcSync();
1781
+ } else if (data.type === 'bye') {
1782
+ const s = bcSessions.get(data.sessionId);
1783
+ if (s && !s.closed) { s.closed = true; s.closedAt = Date.now(); bcSync(); }
1784
+ } else if (data.type === 'batch') {
1785
+ if (!bcSessions.has(data.session.id)) {
1786
+ bcSessions.set(data.session.id, data.session);
1787
+ bcSync();
1788
+ }
1789
+ state.sessions.set(data.session.id, data.session);
1790
+ for (const e of data.events) {
1791
+ applyEvent(data.session, e);
1792
+ logLine(data.session, e);
1793
+ }
1794
+ }
1795
+ scheduleRender();
1796
+ };
1797
+ // announce — apps re-hello and replay their batch tail
1798
+ BC.postMessage({ type: 'view' });
1799
+ return;
1800
+ }
1801
+ const ws = new WebSocket(`${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/view`);
1802
+ viewer = ws;
1803
+ ws.onopen = () => { $('conn').textContent = 'live'; $('conn').className = 'on'; };
1804
+ ws.onclose = () => {
1805
+ $('conn').textContent = 'disconnected — retrying';
1806
+ $('conn').className = 'off';
1807
+ setTimeout(connect, 1500);
1808
+ };
1809
+ ws.onmessage = (msg) => {
1810
+ const data = JSON.parse(msg.data);
1811
+ if (data.type === 'sessions') {
1812
+ reconcileSessions(data.sessions);
1813
+ } else if (data.type === 'batch') {
1814
+ state.sessions.set(data.session.id, data.session);
1815
+ for (const e of data.events) {
1816
+ applyEvent(data.session, e);
1817
+ logLine(data.session, e);
1818
+ }
1819
+ }
1820
+ scheduleRender();
1821
+ };
1822
+ }
1823
+ connect();