@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/README.md +156 -0
- package/app/index.html +388 -0
- package/app/main.js +1823 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +313 -0
- package/dist/broadcast.d.ts +3 -0
- package/dist/chunk-4N3NBFL3.js +474 -0
- package/dist/client.d.ts +34 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +14 -0
- package/dist/init.d.ts +28 -0
- package/dist/node.d.ts +18 -0
- package/dist/node.js +12 -0
- package/dist/nodeWs.d.ts +9 -0
- package/dist/overlay.d.ts +31 -0
- package/dist/protocol.d.ts +83 -0
- package/dist/server.d.ts +23 -0
- package/dist/server.js +296 -0
- package/dist/ws.d.ts +43 -0
- package/package.json +49 -0
- package/src/bin.ts +23 -0
- package/src/broadcast.ts +97 -0
- package/src/client.ts +178 -0
- package/src/index.ts +11 -0
- package/src/init.ts +50 -0
- package/src/node.ts +18 -0
- package/src/nodeWs.ts +118 -0
- package/src/overlay.ts +165 -0
- package/src/protocol.ts +69 -0
- package/src/server.ts +218 -0
- package/src/ws.ts +206 -0
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) => ({ '&': '&', '<': '<', '>': '>' }[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();
|