@atolljs/devtools 0.1.7 → 0.1.8
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 +408 -71
- package/app/api.js +68 -0
- package/app/index.html +161 -2
- package/app/main.js +877 -127
- package/app/panels/actions.js +324 -0
- package/app/panels/audit-rules.js +728 -0
- package/app/panels/audits.js +438 -0
- package/app/panels/inspect-island.js +351 -0
- package/app/panels/memory-values.js +287 -0
- package/app/panels/performance.js +304 -0
- package/app/panels/reactivity.js +271 -0
- package/app/panels/recorder.js +344 -0
- package/app/panels/recording-format.js +166 -0
- package/app/panels/route.d.ts +44 -0
- package/app/panels/route.js +264 -0
- package/app/panels/shell.js +789 -0
- package/app/panels/toast.js +38 -0
- package/dist/bin.js +704 -5
- package/dist/chunk-57NGHQUL.js +344 -0
- package/dist/{chunk-4N3NBFL3.js → chunk-AGT6VNZI.js} +179 -217
- package/dist/chunk-WIL2P7HG.js +661 -0
- package/dist/client.d.ts +7 -0
- package/dist/control.d.ts +4 -0
- package/dist/index.js +2 -1
- package/dist/node.js +2 -1
- package/dist/nodeWs.d.ts +1 -0
- package/dist/otel.d.ts +35 -0
- package/dist/otel.js +87 -0
- package/dist/otelMap.d.ts +232 -0
- package/dist/otelTransport.d.ts +69 -0
- package/dist/overlay.d.ts +16 -2
- package/dist/probes.d.ts +7 -0
- package/dist/protocol.d.ts +46 -4
- package/dist/server.d.ts +19 -0
- package/dist/server.js +61 -3
- package/package.json +6 -2
- package/src/bin.ts +27 -2
- package/src/broadcast.ts +6 -0
- package/src/client.ts +26 -2
- package/src/control.ts +51 -0
- package/src/nodeWs.ts +7 -1
- package/src/otel.ts +140 -0
- package/src/otelMap.ts +661 -0
- package/src/otelTransport.ts +286 -0
- package/src/overlay.ts +153 -27
- package/src/probes.ts +155 -0
- package/src/protocol.ts +52 -4
- package/src/server.ts +83 -3
package/app/main.js
CHANGED
|
@@ -5,6 +5,10 @@
|
|
|
5
5
|
// Time basis: each event's `at` is performance.now() on its emitting
|
|
6
6
|
// thread, so intra-session timelines (the waterfall) use `at` directly.
|
|
7
7
|
// Cross-session ordering is only meaningful within one session.
|
|
8
|
+
//
|
|
9
|
+
// Feature panels live in ./panels/*.js and plug in through ./api.js (see
|
|
10
|
+
// PANELS at the bottom): this file owns the reducer, core views, transport.
|
|
11
|
+
import { api } from './api.js';
|
|
8
12
|
|
|
9
13
|
const $ = (id) => document.getElementById(id);
|
|
10
14
|
const esc = (s) => String(s ?? '').replace(/[&<>]/g, (c) => ({ '&': '&', '<': '<', '>': '>' }[c]));
|
|
@@ -56,8 +60,10 @@ const MAX_ALERTS = 60;
|
|
|
56
60
|
|
|
57
61
|
/* ── event reducer ───────────────────────────────────────────────────────── */
|
|
58
62
|
|
|
59
|
-
|
|
60
|
-
|
|
63
|
+
/** Per-second buckets on the dashboard clock: event `at` epochs differ per thread. */
|
|
64
|
+
const dashSec = () => Math.floor(performance.now() / 1000);
|
|
65
|
+
const bump = (sid, metric, _at, n = 1) => {
|
|
66
|
+
const k = `${sid}|${metric}|${dashSec()}`;
|
|
61
67
|
state.series.set(k, (state.series.get(k) ?? 0) + n);
|
|
62
68
|
};
|
|
63
69
|
|
|
@@ -69,7 +75,7 @@ function applyEvent(sess, e) {
|
|
|
69
75
|
state.pools.set(key(sid, e.poolId), {
|
|
70
76
|
poolId: e.poolId, label: e.label ?? e.poolId, size: e.poolSize,
|
|
71
77
|
memoryBytes: e.memoryBytes, workers: new Set(), tasks: 0, failed: 0,
|
|
72
|
-
dead: sess.closed === true,
|
|
78
|
+
dedicated: e.dedicated === true, dead: sess.closed === true,
|
|
73
79
|
});
|
|
74
80
|
break;
|
|
75
81
|
case 'pool:terminate': {
|
|
@@ -141,7 +147,9 @@ function applyEvent(sess, e) {
|
|
|
141
147
|
}
|
|
142
148
|
}
|
|
143
149
|
}
|
|
144
|
-
|
|
150
|
+
// Dashboard clock, like heapSeries: emit threads (and sessions) have
|
|
151
|
+
// unrelated performance.now() epochs, so `e.at` can't share an axis.
|
|
152
|
+
const sec = Math.floor(performance.now() / 1000);
|
|
145
153
|
const bk = key(sid, sec);
|
|
146
154
|
const b = state.tput.get(bk) ?? { n: 0, err: 0 };
|
|
147
155
|
b.n++; if (e.outcome !== 'ok') b.err++;
|
|
@@ -160,7 +168,7 @@ function applyEvent(sess, e) {
|
|
|
160
168
|
m.writes++; m.version = e.version; m.thread = e.thread; m.lastAt = e.at;
|
|
161
169
|
// worker:* forwarded events carry slot attribution — pin the writer.
|
|
162
170
|
if (e.worker) m.writer = `${e.worker.poolId}#${e.worker.slot}`;
|
|
163
|
-
const sec =
|
|
171
|
+
const sec = dashSec();
|
|
164
172
|
m.buckets.set(sec, (m.buckets.get(sec) ?? 0) + 1);
|
|
165
173
|
if (m.buckets.size > 120) m.buckets.delete([...m.buckets.keys()][0]);
|
|
166
174
|
state.mem.set(k, m);
|
|
@@ -235,7 +243,7 @@ function applyEvent(sess, e) {
|
|
|
235
243
|
// filter, or worker-side performance.memory) stamped to the slot.
|
|
236
244
|
const w = getWorker(sid, e.worker.poolId, e.worker.slot, e.at);
|
|
237
245
|
w.heap = e.heapBytes; w.heapLimit = e.heapLimitBytes; w.lastAt = e.at;
|
|
238
|
-
const sec =
|
|
246
|
+
const sec = dashSec();
|
|
239
247
|
(w.heapSeries ??= new Map()).set(sec, e.heapBytes / 1048576); // MB for the chart
|
|
240
248
|
if (w.heapSeries.size > 120) w.heapSeries.delete([...w.heapSeries.keys()][0]);
|
|
241
249
|
}
|
|
@@ -306,6 +314,7 @@ function reconcileSessions(live) {
|
|
|
306
314
|
if (state.sel && !ids.has(state.sel)) state.sel = null;
|
|
307
315
|
if (state.inspect && !state.islands.has(state.inspect)) state.inspect = null;
|
|
308
316
|
if (state.inspectWorker && !state.workers.has(state.inspectWorker)) state.inspectWorker = null;
|
|
317
|
+
for (const h of api.hooks.reconcile) h(ids);
|
|
309
318
|
}
|
|
310
319
|
|
|
311
320
|
/**
|
|
@@ -651,30 +660,78 @@ function drawThroughput() {
|
|
|
651
660
|
const canvas = $('tput');
|
|
652
661
|
const ctx = canvas.getContext('2d');
|
|
653
662
|
const w = (canvas.width = canvas.clientWidth || 560);
|
|
654
|
-
const h = (canvas.height =
|
|
663
|
+
const h = (canvas.height = 110);
|
|
655
664
|
ctx.clearRect(0, 0, w, h);
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
const
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
const max = Math.max(...buckets.map(([, b]) => b.n), 1);
|
|
664
|
-
for (const [k, b] of buckets) {
|
|
665
|
+
const statsEl = $('tput-stats');
|
|
666
|
+
if (state.t0 === null) { statsEl.textContent = ''; return; }
|
|
667
|
+
// Buckets are on the dashboard clock: sum sessions per second, window the
|
|
668
|
+
// last 60s ending now (an idle stretch shows as empty seconds).
|
|
669
|
+
const bySec = new Map();
|
|
670
|
+
for (const [k, b] of state.tput) {
|
|
671
|
+
if (!inSel(k)) continue;
|
|
665
672
|
const sec = Number(k.split('|')[1]);
|
|
666
|
-
const
|
|
667
|
-
|
|
673
|
+
const s = bySec.get(sec) ?? { n: 0, err: 0 };
|
|
674
|
+
s.n += b.n; s.err += b.err;
|
|
675
|
+
bySec.set(sec, s);
|
|
676
|
+
}
|
|
677
|
+
if (!bySec.size) { statsEl.textContent = 'no settled tasks yet'; return; }
|
|
678
|
+
const hi = Math.floor(performance.now() / 1000);
|
|
679
|
+
const lo = hi - 59;
|
|
680
|
+
const win = [...bySec].filter(([s]) => s >= lo);
|
|
681
|
+
const peak = Math.max(...win.map(([, b]) => b.n), 0);
|
|
682
|
+
const total = win.reduce((a, [, b]) => a + b.n, 0);
|
|
683
|
+
const errs = win.reduce((a, [, b]) => a + b.err, 0);
|
|
684
|
+
const first = win.length ? Math.min(...win.map(([s]) => s)) : hi;
|
|
685
|
+
const latest = bySec.get(hi - 1)?.n ?? 0;
|
|
686
|
+
statsEl.textContent =
|
|
687
|
+
`latest ${fmtN(latest)}/s · peak ${fmtN(peak)}/s · avg ${fmtN(total / (hi - first + 1))}/s · ` +
|
|
688
|
+
`${fmtN(total)} settled in 60s` + (errs ? ` · ${fmtN(errs)} failed` : '');
|
|
689
|
+
|
|
690
|
+
const scale = niceCeil(Math.max(peak, 1));
|
|
691
|
+
const L = 40, B = 16, T = 6;
|
|
692
|
+
const pw = w - L - 4, ph = h - B - T;
|
|
693
|
+
const bw = pw / 60;
|
|
694
|
+
ctx.font = '10px ui-monospace, monospace';
|
|
695
|
+
ctx.textBaseline = 'middle';
|
|
696
|
+
ctx.textAlign = 'right';
|
|
697
|
+
for (const f of [0, 0.5, 1]) {
|
|
698
|
+
const y = T + ph - f * ph;
|
|
699
|
+
ctx.strokeStyle = f === 0 ? '#30363d' : '#21262d';
|
|
700
|
+
ctx.lineWidth = 1;
|
|
701
|
+
ctx.beginPath(); ctx.moveTo(L, Math.round(y) + 0.5); ctx.lineTo(w - 4, Math.round(y) + 0.5); ctx.stroke();
|
|
702
|
+
ctx.fillStyle = '#8b949e';
|
|
703
|
+
ctx.fillText(fmtN(scale * f), L - 6, y);
|
|
704
|
+
}
|
|
705
|
+
ctx.textBaseline = 'top';
|
|
706
|
+
for (const [ago, align] of [[60, 'left'], [30, 'center'], [0, 'right']]) {
|
|
707
|
+
ctx.textAlign = align;
|
|
708
|
+
ctx.fillText(ago ? `-${ago}s` : 'now', L + pw * (1 - ago / 60), T + ph + 4);
|
|
709
|
+
}
|
|
710
|
+
for (const [sec, b] of win) {
|
|
711
|
+
const x = L + (sec - lo) * bw;
|
|
712
|
+
const bh = (b.n / scale) * ph;
|
|
668
713
|
ctx.fillStyle = '#58a6ff';
|
|
669
|
-
ctx.fillRect(x,
|
|
714
|
+
ctx.fillRect(x, T + ph - bh, Math.max(1, bw - 1), bh);
|
|
670
715
|
if (b.err) {
|
|
671
|
-
const eh = (b.err / max) * (h - 4);
|
|
672
716
|
ctx.fillStyle = '#f85149';
|
|
673
|
-
ctx.fillRect(x,
|
|
717
|
+
ctx.fillRect(x, T + ph - bh, Math.max(1, bw - 1), (b.err / scale) * ph);
|
|
674
718
|
}
|
|
675
719
|
}
|
|
676
720
|
}
|
|
677
721
|
|
|
722
|
+
/** Round up to 1, 2 or 5 × 10^k, so axis ticks land on readable numbers. */
|
|
723
|
+
function niceCeil(v) {
|
|
724
|
+
const p = 10 ** Math.floor(Math.log10(v));
|
|
725
|
+
return [1, 2, 5, 10].find((m) => m * p >= v) * p;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/** Compact count: 950, 1.2k, 3.4M. */
|
|
729
|
+
function fmtN(v) {
|
|
730
|
+
if (v >= 1e6) return `${+(v / 1e6).toFixed(1)}M`;
|
|
731
|
+
if (v >= 1e3) return `${+(v / 1e3).toFixed(1)}k`;
|
|
732
|
+
return v >= 10 || Number.isInteger(v) ? String(Math.round(v)) : v.toFixed(1);
|
|
733
|
+
}
|
|
734
|
+
|
|
678
735
|
/* ── tasks: waterfall + aggregates + table ───────────────────────────────── */
|
|
679
736
|
|
|
680
737
|
function visibleCalls() {
|
|
@@ -987,6 +1044,8 @@ function renderInspector() {
|
|
|
987
1044
|
<td class="${t.error ? 'error' : 'ok'}">${t.error ? 'error' : 'ok'}</td>
|
|
988
1045
|
<td class="muted">${esc(t.error ?? '')}</td></tr>`)
|
|
989
1046
|
.join('') || '<tr><td colspan="6" class="muted">no calls yet</td></tr>';
|
|
1047
|
+
|
|
1048
|
+
for (const h of api.hooks.inspectIsland) h(state.inspect, i, { extra: $('inspExtra'), actions: $('inspActions') });
|
|
990
1049
|
}
|
|
991
1050
|
|
|
992
1051
|
function drawOpTraffic(i) {
|
|
@@ -1013,20 +1072,225 @@ function drawOpTraffic(i) {
|
|
|
1013
1072
|
|
|
1014
1073
|
const logEl = () => $('log');
|
|
1015
1074
|
const pad = (n, w = 2) => String(n).padStart(w, '0');
|
|
1016
|
-
|
|
1075
|
+
|
|
1076
|
+
/*
|
|
1077
|
+
* Event log: one DOM line per event, appended incrementally (never a full
|
|
1078
|
+
* re-render). Filters flip `hidden` on existing lines. Lines are grouped
|
|
1079
|
+
* for the chips by event-type prefix ('task', 'island', …), and `log`
|
|
1080
|
+
* events by level ('log.warn'). reset() empties #log; the reset hook
|
|
1081
|
+
* below drops the matching records.
|
|
1082
|
+
*/
|
|
1083
|
+
const LOG_CAP = 2000;
|
|
1084
|
+
const LOG_LEVELS = ['trace', 'debug', 'info', 'warn', 'error'];
|
|
1085
|
+
const LOG_LEVEL_COLOR = { trace: '#484f58', debug: '#8b949e', info: '#58a6ff', warn: '#d29922', error: '#f85149' };
|
|
1086
|
+
const logView = {
|
|
1087
|
+
recs: [], // [{ sid, group, hay, el, vis, sess, e }] oldest first, all in #log
|
|
1088
|
+
held: [], // records ingested while paused, appended on resume
|
|
1089
|
+
groups: new Map(), // group → on (bool)
|
|
1090
|
+
sessions: new Set(),
|
|
1091
|
+
q: '',
|
|
1092
|
+
sess: '',
|
|
1093
|
+
paused: false,
|
|
1094
|
+
hidden: 0,
|
|
1095
|
+
};
|
|
1096
|
+
const logGroupOf = (e) => (e.type === 'log' ? `log.${e.level}` : e.type.split(':')[0]);
|
|
1097
|
+
const logMatch = (r) =>
|
|
1098
|
+
logView.groups.get(r.group) !== false &&
|
|
1099
|
+
(!logView.sess || r.sid === logView.sess) &&
|
|
1100
|
+
(!logView.q || r.hay.includes(logView.q));
|
|
1101
|
+
|
|
1102
|
+
function logStatus() {
|
|
1103
|
+
const s = $('logstat');
|
|
1104
|
+
if (!s) return;
|
|
1105
|
+
const parts = [`${logView.recs.length} lines`];
|
|
1106
|
+
if (logView.hidden) parts.push(`${logView.hidden} hidden by filters`);
|
|
1107
|
+
if (logView.paused) parts.push(`${logView.held.length} held while paused`);
|
|
1108
|
+
s.textContent = parts.join(' · ');
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
function logRefilter() {
|
|
1112
|
+
logView.hidden = 0;
|
|
1113
|
+
for (const r of logView.recs) {
|
|
1114
|
+
r.vis = logMatch(r);
|
|
1115
|
+
r.el.hidden = !r.vis;
|
|
1116
|
+
if (!r.vis) logView.hidden++;
|
|
1117
|
+
}
|
|
1118
|
+
logStatus();
|
|
1119
|
+
const el = logEl();
|
|
1120
|
+
el.scrollTop = el.scrollHeight;
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
function logChip(group) {
|
|
1124
|
+
if (logView.groups.has(group)) return;
|
|
1125
|
+
logView.groups.set(group, true);
|
|
1126
|
+
const bar = $('logchips');
|
|
1127
|
+
if (!bar) return;
|
|
1128
|
+
const b = document.createElement('button');
|
|
1129
|
+
b.className = 'on';
|
|
1130
|
+
b.dataset.g = group;
|
|
1131
|
+
const lvl = group.startsWith('log.') ? group.slice(4) : null;
|
|
1132
|
+
b.innerHTML = lvl ? `<i style="background:${LOG_LEVEL_COLOR[lvl]}"></i>${esc(lvl)}` : esc(group);
|
|
1133
|
+
b.title = lvl ? `log entries at ${lvl}` : `${group}:* events`;
|
|
1134
|
+
// levels first (in severity order), then event groups alphabetically
|
|
1135
|
+
const rank = (g) => (g.startsWith('log.') ? LOG_LEVELS.indexOf(g.slice(4)) : 10);
|
|
1136
|
+
const after = [...bar.querySelectorAll('button[data-g]')].find(
|
|
1137
|
+
(x) => rank(x.dataset.g) > rank(group) || (rank(x.dataset.g) === rank(group) && x.dataset.g > group),
|
|
1138
|
+
);
|
|
1139
|
+
bar.insertBefore(b, after ?? null);
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
function logSessOption(sess) {
|
|
1143
|
+
if (logView.sessions.has(sess.id)) return;
|
|
1144
|
+
logView.sessions.add(sess.id);
|
|
1145
|
+
const sel = $('logsess');
|
|
1146
|
+
if (!sel) return;
|
|
1147
|
+
const o = document.createElement('option');
|
|
1148
|
+
o.value = sess.id;
|
|
1149
|
+
o.textContent = sess.name ?? sess.id;
|
|
1150
|
+
sel.appendChild(o);
|
|
1151
|
+
sel.style.display = logView.sessions.size > 1 ? '' : 'none';
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
function logRender(sess, e) {
|
|
1017
1155
|
const div = document.createElement('div');
|
|
1018
1156
|
const { type, at, thread, ...rest } = e;
|
|
1019
1157
|
// Wall-clock is dashboard receive time — emit-side `at` is a per-thread
|
|
1020
1158
|
// performance.now() epoch, so it's shown as elapsed instead.
|
|
1021
1159
|
const now = new Date();
|
|
1022
1160
|
const ts = `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}.${pad(now.getMilliseconds(), 3)}`;
|
|
1023
|
-
|
|
1161
|
+
const origin = e.worker ? ` ${e.worker.poolId}#${e.worker.slot}` : thread === 'worker' ? ' ⧉' : '';
|
|
1162
|
+
const head = `<span class="logts">${ts}</span> <span class="muted">${esc(sess.name ?? sess.id)}${esc(origin)}</span> `;
|
|
1163
|
+
let body, hay;
|
|
1164
|
+
if (type === 'log') {
|
|
1165
|
+
div.className = `ll-${e.level}`;
|
|
1166
|
+
body = `<b>[${esc(e.level)}]</b> ${esc(e.scope)}: ${esc(e.message)}${e.data !== undefined ? ' <span class="logx">▸ data</span>' : ''}`;
|
|
1167
|
+
hay = `log ${e.level} ${e.scope} ${e.message} ${e.data ?? ''}`;
|
|
1168
|
+
} else {
|
|
1169
|
+
const json = JSON.stringify(rest);
|
|
1170
|
+
const bad = type === 'worker:error' || (type === 'task:settle' && e.outcome !== 'ok');
|
|
1171
|
+
if (bad) div.className = 'll-error';
|
|
1172
|
+
body = `${esc(type)} <span class="muted">${esc(json.length > 400 ? `${json.slice(0, 400)}…` : json)}</span>`;
|
|
1173
|
+
hay = `${type} ${json}`;
|
|
1174
|
+
}
|
|
1175
|
+
div.innerHTML = `${head}${body} <span class="logts">+${fmt(at / 1000, 1)}s</span>`;
|
|
1176
|
+
return { sid: sess.id, group: logGroupOf(e), hay: `${sess.name ?? sess.id} ${hay}`.toLowerCase(), el: div, vis: true, sess, e };
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
function logAppend(batch) {
|
|
1024
1180
|
const el = logEl();
|
|
1025
|
-
el.
|
|
1026
|
-
|
|
1027
|
-
|
|
1181
|
+
const stick = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
|
|
1182
|
+
const frag = document.createDocumentFragment();
|
|
1183
|
+
for (const r of batch) {
|
|
1184
|
+
r.vis = logMatch(r);
|
|
1185
|
+
r.el.hidden = !r.vis;
|
|
1186
|
+
if (!r.vis) logView.hidden++;
|
|
1187
|
+
frag.appendChild(r.el);
|
|
1188
|
+
logView.recs.push(r);
|
|
1189
|
+
}
|
|
1190
|
+
el.appendChild(frag);
|
|
1191
|
+
const over = logView.recs.length - LOG_CAP;
|
|
1192
|
+
if (over > 0) {
|
|
1193
|
+
for (const r of logView.recs.splice(0, over)) {
|
|
1194
|
+
if (!r.vis) logView.hidden--;
|
|
1195
|
+
r.el.remove();
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
if (stick) el.scrollTop = el.scrollHeight;
|
|
1199
|
+
logStatus();
|
|
1028
1200
|
}
|
|
1029
1201
|
|
|
1202
|
+
function logLine(sess, e) {
|
|
1203
|
+
logSessOption(sess);
|
|
1204
|
+
logChip(logGroupOf(e));
|
|
1205
|
+
const r = logRender(sess, e);
|
|
1206
|
+
if (logView.paused) {
|
|
1207
|
+
logView.held.push(r);
|
|
1208
|
+
if (logView.held.length > LOG_CAP) logView.held.shift();
|
|
1209
|
+
logStatus();
|
|
1210
|
+
return;
|
|
1211
|
+
}
|
|
1212
|
+
logAppend([r]);
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
function logClear() {
|
|
1216
|
+
logView.recs.length = 0;
|
|
1217
|
+
logView.held.length = 0;
|
|
1218
|
+
logView.hidden = 0;
|
|
1219
|
+
logEl().innerHTML = '';
|
|
1220
|
+
logStatus();
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
api.onReset(() => {
|
|
1224
|
+
logClear();
|
|
1225
|
+
logView.sessions.clear();
|
|
1226
|
+
const sel = $('logsess');
|
|
1227
|
+
if (sel) { sel.length = 1; sel.value = ''; sel.style.display = 'none'; }
|
|
1228
|
+
logView.sess = '';
|
|
1229
|
+
});
|
|
1230
|
+
|
|
1231
|
+
(function logControls() {
|
|
1232
|
+
const chips = $('logchips');
|
|
1233
|
+
if (!chips) return;
|
|
1234
|
+
chips.addEventListener('click', (ev) => {
|
|
1235
|
+
const b = ev.target.closest('button');
|
|
1236
|
+
if (!b) return;
|
|
1237
|
+
if (b.dataset.all !== undefined) {
|
|
1238
|
+
const on = b.dataset.all === '1';
|
|
1239
|
+
for (const g of logView.groups.keys()) logView.groups.set(g, on);
|
|
1240
|
+
for (const x of chips.querySelectorAll('button[data-g]')) x.classList.toggle('on', on);
|
|
1241
|
+
} else if (b.dataset.g) {
|
|
1242
|
+
const g = b.dataset.g;
|
|
1243
|
+
// alt/shift-click: solo this group
|
|
1244
|
+
if (ev.altKey || ev.shiftKey) {
|
|
1245
|
+
for (const k of logView.groups.keys()) logView.groups.set(k, k === g);
|
|
1246
|
+
for (const x of chips.querySelectorAll('button[data-g]')) x.classList.toggle('on', x.dataset.g === g);
|
|
1247
|
+
} else {
|
|
1248
|
+
const on = !(logView.groups.get(g) ?? true);
|
|
1249
|
+
logView.groups.set(g, on);
|
|
1250
|
+
b.classList.toggle('on', on);
|
|
1251
|
+
}
|
|
1252
|
+
} else return;
|
|
1253
|
+
logRefilter();
|
|
1254
|
+
});
|
|
1255
|
+
let qTimer = 0;
|
|
1256
|
+
$('logq').addEventListener('input', (ev) => {
|
|
1257
|
+
clearTimeout(qTimer);
|
|
1258
|
+
qTimer = setTimeout(() => { logView.q = ev.target.value.trim().toLowerCase(); logRefilter(); }, 150);
|
|
1259
|
+
});
|
|
1260
|
+
$('logsess').addEventListener('change', (ev) => { logView.sess = ev.target.value; logRefilter(); });
|
|
1261
|
+
$('logpause').addEventListener('click', (ev) => {
|
|
1262
|
+
logView.paused = !logView.paused;
|
|
1263
|
+
ev.target.textContent = logView.paused ? 'resume' : 'pause';
|
|
1264
|
+
ev.target.classList.toggle('on', logView.paused);
|
|
1265
|
+
if (!logView.paused && logView.held.length) logAppend(logView.held.splice(0));
|
|
1266
|
+
logStatus();
|
|
1267
|
+
});
|
|
1268
|
+
$('logclear').addEventListener('click', logClear);
|
|
1269
|
+
// Click a line to expand its full payload (log data, or the event JSON).
|
|
1270
|
+
logEl().addEventListener('click', (ev) => {
|
|
1271
|
+
const line = ev.target.closest('#log > div');
|
|
1272
|
+
if (!line) return;
|
|
1273
|
+
const open = line.querySelector(':scope > pre');
|
|
1274
|
+
if (open) { open.remove(); line.classList.remove('open'); return; }
|
|
1275
|
+
const r = logView.recs.find((x) => x.el === line);
|
|
1276
|
+
if (!r) return;
|
|
1277
|
+
const { e } = r;
|
|
1278
|
+
let text;
|
|
1279
|
+
if (e.type === 'log') {
|
|
1280
|
+
try { text = e.data !== undefined ? JSON.stringify(JSON.parse(e.data), null, 2) : '(no data)'; }
|
|
1281
|
+
catch { text = e.data; }
|
|
1282
|
+
} else {
|
|
1283
|
+
const { type, ...rest } = e;
|
|
1284
|
+
text = `${type}\n${JSON.stringify(rest, null, 2)}`;
|
|
1285
|
+
}
|
|
1286
|
+
const pre = document.createElement('pre');
|
|
1287
|
+
pre.className = 'body';
|
|
1288
|
+
pre.textContent = text;
|
|
1289
|
+
line.appendChild(pre);
|
|
1290
|
+
line.classList.add('open');
|
|
1291
|
+
});
|
|
1292
|
+
})();
|
|
1293
|
+
|
|
1030
1294
|
/* ── app map — one world-space canvas; zoom/pan + zoom-tier clustering ───── */
|
|
1031
1295
|
|
|
1032
1296
|
const APP_COLORS = ['#3fb950', '#f0883e', '#a371f7', '#79c0ff', '#d29922', '#f85149', '#56d4dd', '#e275ad'];
|
|
@@ -1046,6 +1310,21 @@ function renderTopology() {
|
|
|
1046
1310
|
window.__topo = topo; // exposed for tests/debugging
|
|
1047
1311
|
}
|
|
1048
1312
|
|
|
1313
|
+
/**
|
|
1314
|
+
* The worker that spawned a qualified pool: 'h1#s1~h2#s2~local' is a pool
|
|
1315
|
+
* minted inside worker h2#s2, whose own (qualified) pool is 'h1#s1~h2'.
|
|
1316
|
+
* Returns the `${sess}|${poolId}|${slot}` key or null for top-level ids.
|
|
1317
|
+
*/
|
|
1318
|
+
function parentWorkerKey(sid, qpid) {
|
|
1319
|
+
const segs = qpid.split('~');
|
|
1320
|
+
if (segs.length < 2) return null;
|
|
1321
|
+
const host = segs[segs.length - 2];
|
|
1322
|
+
const hi = host.lastIndexOf('#');
|
|
1323
|
+
if (hi < 0) return null;
|
|
1324
|
+
const parentPool = [...segs.slice(0, -2), host.slice(0, hi)].join('~');
|
|
1325
|
+
return `${sid}|${parentPool}|${host.slice(hi + 1)}`;
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1049
1328
|
/** World-space layout: each session is one atoll cluster in a loose grid. */
|
|
1050
1329
|
function layoutWorld(sids) {
|
|
1051
1330
|
const clusters = [];
|
|
@@ -1064,23 +1343,39 @@ function layoutWorld(sids) {
|
|
|
1064
1343
|
} else orphans.push({ ik, i });
|
|
1065
1344
|
}
|
|
1066
1345
|
// rim positions belong to pools, not workers — a size-N pool is one
|
|
1067
|
-
// socket containing N worker dots
|
|
1346
|
+
// socket containing N worker dots. Nested pools (poolIds qualified as
|
|
1347
|
+
// 'hostPool#slot~localPool' by sub-worker forwarding) don't take rim
|
|
1348
|
+
// sockets — they hang off their host worker instead.
|
|
1068
1349
|
const pools = [];
|
|
1350
|
+
const nested = new Map(); // parent worker key → [pool group]
|
|
1069
1351
|
const byPool = new Map();
|
|
1070
1352
|
for (const w of workers) {
|
|
1071
1353
|
const pk = w[0].slice(0, w[0].lastIndexOf('|'));
|
|
1072
1354
|
let g = byPool.get(pk);
|
|
1073
1355
|
if (!g) {
|
|
1074
|
-
g = {
|
|
1356
|
+
g = {
|
|
1357
|
+
pk, poolId: pk.split('|')[1], workers: [], nested: pk.split('|')[1].includes('~'),
|
|
1358
|
+
dedicated: state.pools.get(pk)?.dedicated === true,
|
|
1359
|
+
};
|
|
1075
1360
|
byPool.set(pk, g);
|
|
1076
1361
|
pools.push(g);
|
|
1077
1362
|
}
|
|
1078
1363
|
g.workers.push(w);
|
|
1079
1364
|
}
|
|
1365
|
+
for (const g of [...pools]) {
|
|
1366
|
+
if (!g.nested) continue;
|
|
1367
|
+
pools.splice(pools.indexOf(g), 1);
|
|
1368
|
+
const pkey = parentWorkerKey(sid, g.poolId);
|
|
1369
|
+
const l = nested.get(pkey) ?? [];
|
|
1370
|
+
if (!l.length) nested.set(pkey, l);
|
|
1371
|
+
l.push(g);
|
|
1372
|
+
}
|
|
1080
1373
|
const rimN = Math.max(pools.length + orphans.length, 1);
|
|
1081
1374
|
const rimR = Math.max(110, rimN * 15);
|
|
1082
|
-
|
|
1083
|
-
|
|
1375
|
+
// nested worker clusters hang ~120 world units outside the rim
|
|
1376
|
+
// (worker ring + sub-worker orbit + labels)
|
|
1377
|
+
maxR = Math.max(maxR, rimR + (nested.size ? 120 : 0));
|
|
1378
|
+
clusters.push({ sid, workers, pools, islands, hosted, orphans, nested, rimN, rimR });
|
|
1084
1379
|
}
|
|
1085
1380
|
const cell = maxR * 2 + 280;
|
|
1086
1381
|
const cols = Math.min(Math.max(clusters.length, 1), 4);
|
|
@@ -1110,8 +1405,30 @@ const FW_BADGES = {
|
|
|
1110
1405
|
leaflet: ['#19993b', 'L'],
|
|
1111
1406
|
};
|
|
1112
1407
|
|
|
1408
|
+
/** Structural (non-island) nodes — a lettered disc, so pools and plain
|
|
1409
|
+
* workers never read as a framework island. */
|
|
1410
|
+
const ROLE_MARKS = {
|
|
1411
|
+
pool: { letter: 'P', fill: '#21262d', ring: '#8b949e88', text: '#8b949e' },
|
|
1412
|
+
worker: { letter: 'W', fill: '#30363d', ring: '#8b949e', text: '#c9d1d9' },
|
|
1413
|
+
subworker: { letter: 'W', fill: '#a371f733', ring: '#a371f7', text: '#d2a8ff' },
|
|
1414
|
+
};
|
|
1415
|
+
|
|
1416
|
+
function drawRoleMark(ctx, m, x, y, s, dead) {
|
|
1417
|
+
ctx.save();
|
|
1418
|
+
if (dead) ctx.globalAlpha = 0.35;
|
|
1419
|
+
ctx.beginPath(); ctx.arc(x, y, s, 0, 7);
|
|
1420
|
+
ctx.fillStyle = m.fill; ctx.fill();
|
|
1421
|
+
ctx.strokeStyle = m.ring; ctx.lineWidth = s * 0.14; ctx.stroke();
|
|
1422
|
+
ctx.font = `bold ${s * 1.05}px ui-monospace, monospace`;
|
|
1423
|
+
ctx.fillStyle = m.text; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
|
1424
|
+
ctx.fillText(m.letter, x, y + s * 0.06);
|
|
1425
|
+
ctx.restore();
|
|
1426
|
+
ctx.textBaseline = 'top';
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1113
1429
|
/** Real framework marks, drawn at icon size (s ≈ node radius). */
|
|
1114
1430
|
function drawFwMark(ctx, fw, x, y, s, dead) {
|
|
1431
|
+
if (ROLE_MARKS[fw]) return drawRoleMark(ctx, ROLE_MARKS[fw], x, y, s, dead);
|
|
1115
1432
|
ctx.save();
|
|
1116
1433
|
if (dead) ctx.globalAlpha = 0.35;
|
|
1117
1434
|
switch (fw) {
|
|
@@ -1204,18 +1521,27 @@ function drawFwMark(ctx, fw, x, y, s, dead) {
|
|
|
1204
1521
|
ctx.textBaseline = 'top';
|
|
1205
1522
|
}
|
|
1206
1523
|
|
|
1207
|
-
/** The island node — the framework mark IS the dot.
|
|
1208
|
-
*
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1524
|
+
/** The island node — the framework mark IS the dot. An untagged island
|
|
1525
|
+
* isn't a framework island, so it reads as its worker: a W (`role`
|
|
1526
|
+
* picks the nested variant). Ended islands dim. Returns the hit radius. */
|
|
1527
|
+
function drawIslandDot(ctx, i, x, y, inv, role = 'worker') {
|
|
1528
|
+
drawFwMark(ctx, i.fw?.toLowerCase() || role, x, y, 12 * inv, i.ended);
|
|
1529
|
+
return 12 * inv;
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
/** The session hub (main thread): the shell's framework mark in a violet
|
|
1533
|
+
* ring when the session reports one (`initDevtools({ session: { framework } })`),
|
|
1534
|
+
* else the plain violet disc. */
|
|
1535
|
+
function drawHub(ctx, sess, x, y, r, inv) {
|
|
1536
|
+
if (sess?.framework) {
|
|
1537
|
+
ctx.beginPath(); ctx.arc(x, y, r, 0, 7);
|
|
1538
|
+
ctx.fillStyle = '#0d1117'; ctx.fill();
|
|
1539
|
+
ctx.strokeStyle = '#a371f7'; ctx.lineWidth = 2 * inv; ctx.stroke();
|
|
1540
|
+
drawFwMark(ctx, sess.framework.toLowerCase(), x, y, r * 0.72, sess.closed === true);
|
|
1541
|
+
} else {
|
|
1542
|
+
ctx.beginPath(); ctx.arc(x, y, r, 0, 7);
|
|
1543
|
+
ctx.fillStyle = '#a371f7'; ctx.fill();
|
|
1214
1544
|
}
|
|
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
1545
|
}
|
|
1220
1546
|
|
|
1221
1547
|
function drawAppMap(canvas, sids) {
|
|
@@ -1253,8 +1579,7 @@ function drawAppMap(canvas, sids) {
|
|
|
1253
1579
|
const a = (j / Math.max(cl.islands.length, 1)) * 2 * Math.PI - Math.PI / 2;
|
|
1254
1580
|
drawIslandDot(ctx, i, cx + r * Math.cos(a), cy + r * Math.sin(a), mi);
|
|
1255
1581
|
});
|
|
1256
|
-
ctx
|
|
1257
|
-
ctx.fillStyle = '#a371f7'; ctx.fill();
|
|
1582
|
+
drawHub(ctx, sess, cx, cy, 8 * inv, inv);
|
|
1258
1583
|
ctx.font = `${11 * inv}px ui-monospace, monospace`;
|
|
1259
1584
|
ctx.fillStyle = '#c9d1d9';
|
|
1260
1585
|
ctx.fillText(sess?.name ?? cl.sid, cx, cy + 14 * inv);
|
|
@@ -1264,11 +1589,11 @@ function drawAppMap(canvas, sids) {
|
|
|
1264
1589
|
continue;
|
|
1265
1590
|
}
|
|
1266
1591
|
|
|
1267
|
-
|
|
1268
|
-
const
|
|
1269
|
-
// orbit lines — one per level, so pool/worker
|
|
1592
|
+
// outer ring — workers; an island-hosting worker IS its island(s)
|
|
1593
|
+
const workerR = cl.rimR + 38;
|
|
1594
|
+
// orbit lines — one per level, so pool/worker nodes sit ON them
|
|
1270
1595
|
ctx.strokeStyle = '#21262d'; ctx.lineWidth = 1 * inv;
|
|
1271
|
-
for (const r of [cl.rimR, workerR
|
|
1596
|
+
for (const r of [cl.rimR, workerR]) {
|
|
1272
1597
|
ctx.beginPath(); ctx.arc(cx, cy, r, 0, 7); ctx.stroke();
|
|
1273
1598
|
}
|
|
1274
1599
|
|
|
@@ -1279,83 +1604,248 @@ function drawAppMap(canvas, sids) {
|
|
|
1279
1604
|
ctx.strokeStyle = '#21262d';
|
|
1280
1605
|
ctx.lineWidth = 1 * inv;
|
|
1281
1606
|
for (const p of ppos) {
|
|
1607
|
+
if (p.dedicated) continue; // its spoke runs to the worker, drawn with it
|
|
1282
1608
|
ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(p.x, p.y); ctx.stroke();
|
|
1283
1609
|
}
|
|
1284
1610
|
|
|
1285
1611
|
const font = (px) => `${px * inv}px ui-monospace, monospace`;
|
|
1612
|
+
|
|
1613
|
+
const islandName = (ik) => ik.split('|').slice(1).join('|');
|
|
1614
|
+
|
|
1615
|
+
const anchors = new Map(); // island key → its node { x, y, r }: spawn links start here
|
|
1616
|
+
const badges = new Map(); // worker key → the W badge on a shared worker's ring
|
|
1617
|
+
|
|
1618
|
+
/** Untagged instance inside a shared worker — hollow disc + the app's initial. */
|
|
1619
|
+
const instanceMark = (ik) => ({
|
|
1620
|
+
letter: islandName(ik).split('~').pop()[0] ?? '?',
|
|
1621
|
+
fill: '#3fb9501f', ring: '#3fb950', text: '#3fb950',
|
|
1622
|
+
});
|
|
1623
|
+
|
|
1624
|
+
/** A label pushed outward from (x, y) along angle th, aligned to its side. */
|
|
1625
|
+
const labelOut = (text, x, y, th, dist, color) => {
|
|
1626
|
+
const c = Math.cos(th), s = Math.sin(th);
|
|
1627
|
+
ctx.save();
|
|
1628
|
+
ctx.font = font(10); ctx.fillStyle = color;
|
|
1629
|
+
ctx.textAlign = c > 0.35 ? 'left' : c < -0.35 ? 'right' : 'center';
|
|
1630
|
+
ctx.textBaseline = s > 0.35 ? 'top' : s < -0.35 ? 'bottom' : 'middle';
|
|
1631
|
+
ctx.fillText(text, x + dist * c, y + dist * s);
|
|
1632
|
+
ctx.restore();
|
|
1633
|
+
};
|
|
1634
|
+
|
|
1635
|
+
/**
|
|
1636
|
+
* A worker hosting ONE island is that island: its mark (or a W when
|
|
1637
|
+
* untagged) sits on the worker's spot. A worker hosting several
|
|
1638
|
+
* (one shared client) is a container: a ring with a W badge for the
|
|
1639
|
+
* worker itself, one node per instance inside (framework mark, or a
|
|
1640
|
+
* hollow initial when untagged), each labeled outward. Every instance
|
|
1641
|
+
* registers in `anchors` so a sub-island's spawn link starts at the
|
|
1642
|
+
* instance that spawned it. `ring` colors the sub-worker variant.
|
|
1643
|
+
* Returns the drawn radius.
|
|
1644
|
+
*/
|
|
1645
|
+
const drawIslandWorker = (k, isl, x, y, ring, wid) => {
|
|
1646
|
+
const w = state.workers.get(k);
|
|
1647
|
+
const n = isl.length;
|
|
1648
|
+
const m = 12 * inv;
|
|
1649
|
+
const lc = ring ? '#a371f7' : '#8b949e';
|
|
1650
|
+
if (n === 1) {
|
|
1651
|
+
const { ik, i } = isl[0];
|
|
1652
|
+
drawIslandDot(ctx, i, x, y, inv, ring ? 'subworker' : 'worker');
|
|
1653
|
+
nodes.push({ x, y, r: m + 4 * inv, kind: 'island', key: ik, wk: k });
|
|
1654
|
+
anchors.set(ik, { x, y, r: m });
|
|
1655
|
+
let R = m;
|
|
1656
|
+
if (ring) {
|
|
1657
|
+
R += 3 * inv;
|
|
1658
|
+
ctx.beginPath(); ctx.arc(x, y, R, 0, 7);
|
|
1659
|
+
ctx.strokeStyle = ring; ctx.lineWidth = 1.2 * inv; ctx.stroke();
|
|
1660
|
+
}
|
|
1661
|
+
if (full) labelWorker(isl, wid, x, y, R, lc);
|
|
1662
|
+
return R;
|
|
1663
|
+
}
|
|
1664
|
+
const cr = (m + 4 * inv) / Math.sin(Math.PI / n);
|
|
1665
|
+
const R = cr + m + 5 * inv;
|
|
1666
|
+
ctx.beginPath(); ctx.arc(x, y, R, 0, 7);
|
|
1667
|
+
ctx.fillStyle = '#0d111799'; ctx.fill();
|
|
1668
|
+
ctx.strokeStyle = ring ?? '#8b949e88'; ctx.lineWidth = 1.2 * inv; ctx.stroke();
|
|
1669
|
+
isl.forEach(({ ik, i }, j) => {
|
|
1670
|
+
const th = (j / n) * 2 * Math.PI - Math.PI / 2;
|
|
1671
|
+
const ix = x + cr * Math.cos(th), iy = y + cr * Math.sin(th);
|
|
1672
|
+
if (i.fw) drawIslandDot(ctx, i, ix, iy, inv);
|
|
1673
|
+
else drawRoleMark(ctx, instanceMark(ik), ix, iy, m, i.ended);
|
|
1674
|
+
nodes.push({ x: ix, y: iy, r: m + inv, kind: 'island', key: ik, wk: k });
|
|
1675
|
+
anchors.set(ik, { x: ix, y: iy, r: m, cx: x, cy: y });
|
|
1676
|
+
if (full) labelOut(islandName(ik), x, y, th, R + 5 * inv, '#c9d1d9');
|
|
1677
|
+
});
|
|
1678
|
+
// the worker itself — a W badge on the ring, in the first gap between instances
|
|
1679
|
+
const tb = -Math.PI / 2 + Math.PI / n;
|
|
1680
|
+
const bx = x + R * Math.cos(tb), by = y + R * Math.sin(tb), br = 7 * inv;
|
|
1681
|
+
drawFwMark(ctx, ring ? 'subworker' : 'worker', bx, by, br, w?.dead);
|
|
1682
|
+
nodes.push({ x: bx, y: by, r: br + 3 * inv, kind: 'worker', key: k });
|
|
1683
|
+
badges.set(k, { x: bx, y: by, r: br });
|
|
1684
|
+
if (full) labelOut(wid, bx, by, tb, br + 4 * inv, lc);
|
|
1685
|
+
return R;
|
|
1686
|
+
};
|
|
1687
|
+
|
|
1688
|
+
/** Under-node label for a 1:1 or plain worker: the island name (if any), then the worker id. */
|
|
1689
|
+
const labelWorker = (isl, wid, x, y, r, color = '#8b949e') => {
|
|
1690
|
+
let ly = y + r + 2 * inv;
|
|
1691
|
+
ctx.font = font(10);
|
|
1692
|
+
if (isl.length) {
|
|
1693
|
+
ctx.fillStyle = '#c9d1d9';
|
|
1694
|
+
// the spawn link already names a sub-island's parent: label its own segment
|
|
1695
|
+
ctx.fillText(islandName(isl[0].ik).split('~').pop(), x, ly);
|
|
1696
|
+
ly += 12 * inv;
|
|
1697
|
+
}
|
|
1698
|
+
ctx.fillStyle = color;
|
|
1699
|
+
ctx.fillText(wid, x, ly);
|
|
1700
|
+
};
|
|
1701
|
+
|
|
1702
|
+
/**
|
|
1703
|
+
* Nested workers: a pool minted inside a worker hangs off whatever
|
|
1704
|
+
* spawned it. A sub-island's key (`parent~app@N`) names the spawning
|
|
1705
|
+
* INSTANCE, so its sub-worker links from that instance's node; a
|
|
1706
|
+
* worker-level spawn (`connectSubWorker`, no island) links from the
|
|
1707
|
+
* host's W badge, or the host node itself. Sub-workers fan outward
|
|
1708
|
+
* from the anchor, nudged off the anchor's own outward label. Each is
|
|
1709
|
+
* a violet W, or its island(s) in a violet ring. Deeper pools recurse.
|
|
1710
|
+
*/
|
|
1711
|
+
const drawBranch = (pwKey, px, py, a, pr) => {
|
|
1712
|
+
const ws = (cl.nested.get(pwKey) ?? [])
|
|
1713
|
+
.flatMap((g) => g.workers.map(([k, w]) => ({ k, w, g })));
|
|
1714
|
+
if (!ws.length) return;
|
|
1715
|
+
const groups = new Map(); // anchor key → sub-workers it spawned
|
|
1716
|
+
for (const s of ws) {
|
|
1717
|
+
let anc = null;
|
|
1718
|
+
for (const { ik } of cl.hosted.get(s.k) ?? []) {
|
|
1719
|
+
const t = ik.lastIndexOf('~');
|
|
1720
|
+
if (t > 0) { anc = ik.slice(0, t); break; }
|
|
1721
|
+
}
|
|
1722
|
+
// an anchor sitting on the host itself (a 1:1 island worker) joins
|
|
1723
|
+
// the host's own fan, so siblings spread together instead of overlapping
|
|
1724
|
+
const at = anc ? anchors.get(anc) : undefined;
|
|
1725
|
+
const gk = at && Math.hypot(at.x - px, at.y - py) > 1 ? anc : '';
|
|
1726
|
+
const l = groups.get(gk) ?? [];
|
|
1727
|
+
if (!l.length) groups.set(gk, l);
|
|
1728
|
+
l.push(s);
|
|
1729
|
+
}
|
|
1730
|
+
for (const [gk, raw] of groups) {
|
|
1731
|
+
// widest sibling (an island container) in the middle of the fan,
|
|
1732
|
+
// smaller ones alternating outward, so the bulk points straight out
|
|
1733
|
+
const size = (s) => (cl.hosted.get(s.k) ?? []).length;
|
|
1734
|
+
const bySize = [...raw].sort((x, y) => size(y) - size(x));
|
|
1735
|
+
const list = new Array(bySize.length);
|
|
1736
|
+
const mid = (bySize.length - 1) >> 1;
|
|
1737
|
+
bySize.forEach((s, i) => { list[mid + (i % 2 ? (i + 1) >> 1 : -(i >> 1))] = s; });
|
|
1738
|
+
const an = gk ? anchors.get(gk) : (badges.get(pwKey) ?? { x: px, y: py, r: pr });
|
|
1739
|
+
const off = Math.hypot(an.x - px, an.y - py) > 1;
|
|
1740
|
+
const dir = off ? Math.atan2(an.y - py, an.x - px) : a;
|
|
1741
|
+
// Each sibling takes the angle its own footprint needs at its
|
|
1742
|
+
// distance (a container is wide and sits further out; a W plus its
|
|
1743
|
+
// label is narrow), packed around one centre line. One or two
|
|
1744
|
+
// siblings rotate off the anchor's outward label toward the upper
|
|
1745
|
+
// side (labels hang below); a bigger fan points straight out.
|
|
1746
|
+
const geo = list.map(({ k }) => {
|
|
1747
|
+
const isl = cl.hosted.get(k) ?? [];
|
|
1748
|
+
// a container is wider than one mark: push its centre out by its radius
|
|
1749
|
+
const ext = isl.length > 1 ? (16 * inv) / Math.sin(Math.PI / isl.length) + 5 * inv : 0;
|
|
1750
|
+
const dist = an.r + 56 + ext;
|
|
1751
|
+
const foot = (isl.length > 1 ? ext + 12 * inv : 7 * inv) + 14 * inv;
|
|
1752
|
+
return { isl, dist, hw: Math.asin(Math.min(0.95, foot / dist)) };
|
|
1753
|
+
});
|
|
1754
|
+
const total = geo.reduce((s, x) => s + 2 * x.hw, 0);
|
|
1755
|
+
const turn = list.length > 2 ? 0 : Math.max(0, Math.min(off ? 1 : 1.25, 1.4 - total / 2));
|
|
1756
|
+
const nudge = Math.sin(dir + turn) < Math.sin(dir - turn) ? turn : -turn;
|
|
1757
|
+
let cursor = dir + nudge - total / 2;
|
|
1758
|
+
list.forEach(({ k, w, g }, j) => {
|
|
1759
|
+
const { isl, dist, hw } = geo[j];
|
|
1760
|
+
const th = cursor + hw;
|
|
1761
|
+
cursor += 2 * hw;
|
|
1762
|
+
const nx = an.x + dist * Math.cos(th), ny = an.y + dist * Math.sin(th);
|
|
1763
|
+
const local = g.poolId.split('~').pop();
|
|
1764
|
+
const wid = g.dedicated ? local : `${local}#${k.split('|').pop()}`;
|
|
1765
|
+
let r;
|
|
1766
|
+
if (isl.length) r = drawIslandWorker(k, isl, nx, ny, '#a371f7aa', wid);
|
|
1767
|
+
else {
|
|
1768
|
+
r = 7 * inv;
|
|
1769
|
+
drawFwMark(ctx, 'subworker', nx, ny, r, w.dead);
|
|
1770
|
+
nodes.push({ x: nx, y: ny, r: r + 4 * inv, kind: 'worker', key: k });
|
|
1771
|
+
if (full) labelWorker([], wid, nx, ny, r, '#a371f7');
|
|
1772
|
+
}
|
|
1773
|
+
// spawn link — violet, anchor edge → sub-worker edge
|
|
1774
|
+
ctx.beginPath();
|
|
1775
|
+
ctx.moveTo(an.x + an.r * Math.cos(th), an.y + an.r * Math.sin(th));
|
|
1776
|
+
ctx.lineTo(nx - r * Math.cos(th), ny - r * Math.sin(th));
|
|
1777
|
+
ctx.strokeStyle = '#a371f799'; ctx.lineWidth = 1.4 * inv; ctx.stroke();
|
|
1778
|
+
drawBranch(k, nx, ny, th, r);
|
|
1779
|
+
});
|
|
1780
|
+
}
|
|
1781
|
+
};
|
|
1782
|
+
|
|
1286
1783
|
for (const p of ppos) {
|
|
1287
1784
|
const mark = Math.max(inv, 1);
|
|
1288
|
-
// pool — a
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1785
|
+
// pool — a P socket on the rim, the container for its workers. A
|
|
1786
|
+
// dedicated runner (workers: 1, no pool) has no socket: its worker
|
|
1787
|
+
// keeps the worker-orbit spot and the hub spoke runs straight to it.
|
|
1788
|
+
const poolR = p.dedicated ? 0 : 10 * mark;
|
|
1789
|
+
if (!p.dedicated) {
|
|
1790
|
+
drawFwMark(ctx, 'pool', p.x, p.y, poolR);
|
|
1791
|
+
if (full) {
|
|
1792
|
+
ctx.font = font(10); ctx.fillStyle = '#8b949e';
|
|
1793
|
+
ctx.fillText(p.poolId, p.x, p.y + poolR + 2 * inv);
|
|
1794
|
+
}
|
|
1296
1795
|
}
|
|
1297
1796
|
const n = p.workers.length;
|
|
1298
1797
|
p.workers.forEach(([k, w], j) => {
|
|
1299
1798
|
// workers spread around the pool's angle on the mid ring
|
|
1300
1799
|
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
1800
|
const isl = cl.hosted.get(k) ?? [];
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1801
|
+
// a shared-worker container is wider than one mark — push it clear of its pool
|
|
1802
|
+
const span = isl.length > 1 ? (16 * inv) / Math.sin(Math.PI / isl.length) + 17 * inv : 0;
|
|
1803
|
+
const wd = cl.rimR + Math.max(38, span + (p.dedicated ? 10 * mark : poolR) + 8 * inv);
|
|
1804
|
+
const wx = cx + wd * Math.cos(a2), wy = cy + wd * Math.sin(a2);
|
|
1805
|
+
const wid = p.dedicated ? p.poolId : `${p.poolId}#${k.split('|').pop()}`;
|
|
1806
|
+
let r;
|
|
1807
|
+
if (isl.length) r = drawIslandWorker(k, isl, wx, wy, undefined, wid);
|
|
1808
|
+
else {
|
|
1809
|
+
// plain worker — W, sized by heap when the probe reports it
|
|
1810
|
+
r = Math.max(8, 7 + (w.heap !== undefined ? Math.min(6, (w.heap / (80 << 20)) * 6) : 0)) * mark;
|
|
1811
|
+
drawFwMark(ctx, 'worker', wx, wy, r, w.dead);
|
|
1812
|
+
nodes.push({ x: wx, y: wy, r: r + 4 * inv, kind: 'worker', key: k });
|
|
1315
1813
|
}
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1814
|
+
// pool → worker link, edge to edge so it never crosses a cluster
|
|
1815
|
+
const d = Math.hypot(wx - p.x, wy - p.y) || 1;
|
|
1816
|
+
const ux = (wx - p.x) / d, uy = (wy - p.y) / d;
|
|
1817
|
+
if (p.dedicated) {
|
|
1818
|
+
ctx.beginPath();
|
|
1819
|
+
ctx.moveTo(cx, cy); ctx.lineTo(wx - Math.cos(a2) * r, wy - Math.sin(a2) * r);
|
|
1820
|
+
ctx.strokeStyle = '#21262d'; ctx.lineWidth = 1 * inv; ctx.stroke();
|
|
1821
|
+
} else if (d > poolR + r) {
|
|
1822
|
+
ctx.beginPath();
|
|
1823
|
+
ctx.moveTo(p.x + ux * poolR, p.y + uy * poolR); ctx.lineTo(wx - ux * r, wy - uy * r);
|
|
1824
|
+
ctx.strokeStyle = '#8b949e44'; ctx.lineWidth = 1.2 * inv; ctx.stroke();
|
|
1319
1825
|
}
|
|
1320
|
-
|
|
1321
|
-
//
|
|
1322
|
-
|
|
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
|
-
});
|
|
1826
|
+
if (full && !isl.length) labelWorker([], wid, wx, wy, r);
|
|
1827
|
+
// nested pools branch off this worker — sub-workers / sub-islands
|
|
1828
|
+
drawBranch(k, wx, wy, a2, r);
|
|
1339
1829
|
});
|
|
1340
1830
|
}
|
|
1831
|
+
// islands whose worker never reported take a worker slot of their own
|
|
1341
1832
|
cl.orphans.forEach(({ ik, i }, j) => {
|
|
1342
1833
|
const a = ((cl.pools.length + j) / cl.rimN) * 2 * Math.PI - Math.PI / 2;
|
|
1343
|
-
const ix = cx +
|
|
1834
|
+
const ix = cx + workerR * Math.cos(a), iy = cy + workerR * Math.sin(a);
|
|
1344
1835
|
const r = drawIslandDot(ctx, i, ix, iy, inv);
|
|
1345
1836
|
if (full) {
|
|
1346
|
-
ctx.fillStyle = '#
|
|
1347
|
-
ctx.fillText(ik
|
|
1837
|
+
ctx.font = font(10); ctx.fillStyle = '#c9d1d9';
|
|
1838
|
+
ctx.fillText(islandName(ik), ix, iy + r + 2 * inv);
|
|
1348
1839
|
}
|
|
1349
1840
|
nodes.push({ x: ix, y: iy, r: r + 4 * inv, kind: 'island', key: ik });
|
|
1350
1841
|
});
|
|
1351
1842
|
|
|
1352
|
-
ctx
|
|
1353
|
-
ctx.fillStyle = '#a371f7'; ctx.fill();
|
|
1843
|
+
drawHub(ctx, sess, cx, cy, 16 * inv, inv);
|
|
1354
1844
|
ctx.font = font(11);
|
|
1355
1845
|
ctx.fillStyle = '#c9d1d9';
|
|
1356
1846
|
ctx.fillText(sess?.name ?? cl.sid, cx, cy + 19 * inv);
|
|
1357
1847
|
ctx.fillStyle = '#8b949e';
|
|
1358
|
-
ctx.fillText('main thread', cx, cy + 32 * inv);
|
|
1848
|
+
ctx.fillText(sess?.framework ? `main thread · ${sess.framework}` : 'main thread', cx, cy + 32 * inv);
|
|
1359
1849
|
if (!cl.pools.length && !cl.orphans.length) ctx.fillText('no workers or islands yet', cx, cy + 46 * inv);
|
|
1360
1850
|
// the session hub is clickable too — same drill-in as the far-zoom disc
|
|
1361
1851
|
nodes.push({ x: cx, y: cy, r: 16 * inv, kind: 'session', key: cl.sid });
|
|
@@ -1364,7 +1854,7 @@ function drawAppMap(canvas, sids) {
|
|
|
1364
1854
|
// selection highlight — ring whatever is under inspection / selected
|
|
1365
1855
|
for (const n of nodes) {
|
|
1366
1856
|
const sel = (n.kind === 'worker' && n.key === state.inspectWorker)
|
|
1367
|
-
|| (n.kind === 'island' && n.key === state.inspect)
|
|
1857
|
+
|| (n.kind === 'island' && (n.key === state.inspect || (n.wk && n.wk === state.inspectWorker)))
|
|
1368
1858
|
|| (n.kind === 'session' && n.key === state.sel);
|
|
1369
1859
|
if (!sel) continue;
|
|
1370
1860
|
const rr = n.r + 2.5 * inv;
|
|
@@ -1409,7 +1899,7 @@ function renderWorkers() {
|
|
|
1409
1899
|
<td class="${w.heap !== undefined ? '' : 'muted'}">${w.heap !== undefined ? fmtBytes(w.heap) : '—'}</td>
|
|
1410
1900
|
<td>${hosted.length
|
|
1411
1901
|
? `<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('
|
|
1902
|
+
: `<span class="muted" title="no hosted islands — plain worker">${fwIconImg('worker')}</span>`}</td>
|
|
1413
1903
|
<td class="muted">${esc(w.lastTaskId || '—')}</td>
|
|
1414
1904
|
<td class="${w.dead ? 'error' : 'ok'}">${w.dead ? 'down' : 'live'}</td></tr>`;
|
|
1415
1905
|
}) || '<tr><td colspan="10" class="muted">no workers yet</td></tr>';
|
|
@@ -1492,6 +1982,8 @@ function renderWorkerInsp() {
|
|
|
1492
1982
|
$('wkErrors').innerHTML = w.errors.length
|
|
1493
1983
|
? 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
1984
|
: '<div class="muted">none</div>';
|
|
1985
|
+
|
|
1986
|
+
for (const h of api.hooks.inspectWorker) h(k, w, { actions: $('wkActions') });
|
|
1495
1987
|
}
|
|
1496
1988
|
|
|
1497
1989
|
/* ── render dispatch ─────────────────────────────────────────────────────── */
|
|
@@ -1541,6 +2033,19 @@ function render() {
|
|
|
1541
2033
|
render();
|
|
1542
2034
|
};
|
|
1543
2035
|
}
|
|
2036
|
+
for (const h of api.hooks.render) h();
|
|
2037
|
+
updateBadges();
|
|
2038
|
+
}
|
|
2039
|
+
|
|
2040
|
+
/** Nav badges for registered views (`addView({ badge })`): counts, alerts. */
|
|
2041
|
+
function updateBadges() {
|
|
2042
|
+
for (const v of registeredViews) {
|
|
2043
|
+
if (!v.badge) continue;
|
|
2044
|
+
const el = document.querySelector(`.panels > nav button[data-view="${v.id}"] .nbadge`);
|
|
2045
|
+
if (!el) continue;
|
|
2046
|
+
const b = v.badge();
|
|
2047
|
+
el.textContent = b === null || b === undefined || b === 0 ? '' : String(b);
|
|
2048
|
+
}
|
|
1544
2049
|
}
|
|
1545
2050
|
|
|
1546
2051
|
let raf = 0;
|
|
@@ -1557,16 +2062,89 @@ setInterval(() => {
|
|
|
1557
2062
|
if ($('view-memory').classList.contains('on')) renderMemory();
|
|
1558
2063
|
if ($('view-dashboard').classList.contains('on')) renderTopology();
|
|
1559
2064
|
if (BC) sweepClosed();
|
|
2065
|
+
for (const h of api.hooks.tick) h();
|
|
1560
2066
|
}, 1000);
|
|
1561
2067
|
|
|
1562
2068
|
/* ── tabs + waterfall interaction ────────────────────────────────────────── */
|
|
1563
2069
|
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
};
|
|
2070
|
+
/** Switch the main view; panels and the palette route through this. */
|
|
2071
|
+
function openView(id) {
|
|
2072
|
+
const btn = document.querySelector(`.panels > nav button[data-view="${id}"]`);
|
|
2073
|
+
if (!btn) return;
|
|
2074
|
+
document.querySelectorAll('.panels > nav button').forEach((x) => x.classList.toggle('on', x === btn));
|
|
2075
|
+
document.querySelectorAll('.view').forEach((v) => v.classList.toggle('on', v.id === `view-${id}`));
|
|
2076
|
+
render();
|
|
2077
|
+
navigated();
|
|
2078
|
+
}
|
|
2079
|
+
|
|
2080
|
+
/** Fire api.onNavigate hooks (routing, nav layout) after a view/subview switch. */
|
|
2081
|
+
function navigated() {
|
|
2082
|
+
for (const h of api.hooks.navigate ?? []) h();
|
|
2083
|
+
}
|
|
2084
|
+
|
|
2085
|
+
// Delegated, so views/subviews panels add later get the same handlers.
|
|
2086
|
+
document.querySelector('.panels > nav').addEventListener('click', (e) => {
|
|
2087
|
+
const b = e.target.closest('button[data-view]');
|
|
2088
|
+
if (b) openView(b.dataset.view);
|
|
2089
|
+
});
|
|
2090
|
+
|
|
2091
|
+
/** Registered panel views, in nav order. */
|
|
2092
|
+
const registeredViews = [];
|
|
2093
|
+
const CORE_VIEW_ORDER = { dashboard: 10, tasks: 20, network: 30, memory: 40, log: 90 };
|
|
2094
|
+
const viewOrder = (b) => Number(b.dataset.order ?? CORE_VIEW_ORDER[b.dataset.view] ?? 50);
|
|
2095
|
+
|
|
2096
|
+
/**
|
|
2097
|
+
* Add a top-level view: a nav button (ordered by `order`; core views are
|
|
2098
|
+
* 10 dashboard, 20 tasks, 30 network, 40 memory, 90 log) and a section.
|
|
2099
|
+
* `badge()` feeds a small count on the nav button each render.
|
|
2100
|
+
*/
|
|
2101
|
+
function addView({ id, label, order = 50, html = '', badge, title }) {
|
|
2102
|
+
const nav = document.querySelector('.panels > nav');
|
|
2103
|
+
const btn = document.createElement('button');
|
|
2104
|
+
btn.dataset.view = id;
|
|
2105
|
+
btn.dataset.order = String(order);
|
|
2106
|
+
btn.innerHTML = `${esc(label)}<span class="nbadge"></span>`;
|
|
2107
|
+
if (title) btn.title = title;
|
|
2108
|
+
const after = [...nav.querySelectorAll('button[data-view]')].find((b) => viewOrder(b) > order);
|
|
2109
|
+
nav.insertBefore(btn, after ?? null);
|
|
2110
|
+
const section = document.createElement('section');
|
|
2111
|
+
section.className = 'view';
|
|
2112
|
+
section.id = `view-${id}`;
|
|
2113
|
+
section.innerHTML = html;
|
|
2114
|
+
document.querySelector('.panels').appendChild(section);
|
|
2115
|
+
registeredViews.push({ id, label, order, badge });
|
|
2116
|
+
return section;
|
|
2117
|
+
}
|
|
2118
|
+
|
|
2119
|
+
/**
|
|
2120
|
+
* Add a subview (sub-tab) under a view. A view without a subnav gets one,
|
|
2121
|
+
* and its existing content becomes the first subview (`<view>-main`).
|
|
2122
|
+
*/
|
|
2123
|
+
function addSubview(viewId, { id, label, html = '', order = 50 }) {
|
|
2124
|
+
const view = $(`view-${viewId}`);
|
|
2125
|
+
let subnav = view.querySelector(':scope > .subnav');
|
|
2126
|
+
if (!subnav) {
|
|
2127
|
+
const main = document.createElement('div');
|
|
2128
|
+
main.className = 'subview on';
|
|
2129
|
+
main.id = `${viewId}-main`;
|
|
2130
|
+
main.append(...view.childNodes);
|
|
2131
|
+
subnav = document.createElement('nav');
|
|
2132
|
+
subnav.className = 'subnav';
|
|
2133
|
+
subnav.innerHTML = `<button class="on" data-sub="${viewId}-main" data-order="0">${esc(registeredViews.find((v) => v.id === viewId)?.label ?? 'Main')}</button>`;
|
|
2134
|
+
view.append(subnav, main);
|
|
2135
|
+
}
|
|
2136
|
+
const btn = document.createElement('button');
|
|
2137
|
+
btn.dataset.sub = id;
|
|
2138
|
+
btn.dataset.order = String(order);
|
|
2139
|
+
btn.textContent = label;
|
|
2140
|
+
const after = [...subnav.querySelectorAll('button[data-sub]')].find((b) => Number(b.dataset.order ?? 50) > order);
|
|
2141
|
+
subnav.insertBefore(btn, after ?? null);
|
|
2142
|
+
const div = document.createElement('div');
|
|
2143
|
+
div.className = 'subview';
|
|
2144
|
+
div.id = id;
|
|
2145
|
+
div.innerHTML = html;
|
|
2146
|
+
view.appendChild(div);
|
|
2147
|
+
return div;
|
|
1570
2148
|
}
|
|
1571
2149
|
|
|
1572
2150
|
/* ── app map interaction — wheel zoom, drag pan, click/double-click ─────── */
|
|
@@ -1649,18 +2227,20 @@ if (new URLSearchParams(location.search).has('mini')) {
|
|
|
1649
2227
|
document.getElementById('tv-dash').classList.remove('on');
|
|
1650
2228
|
}
|
|
1651
2229
|
|
|
1652
|
-
|
|
1653
|
-
b
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
2230
|
+
document.addEventListener('click', (e) => {
|
|
2231
|
+
const b = e.target.closest?.('.subnav button[data-sub]');
|
|
2232
|
+
if (!b) return;
|
|
2233
|
+
const nav = b.closest('.subnav');
|
|
2234
|
+
nav.querySelectorAll('button').forEach((x) => x.classList.toggle('on', x === b));
|
|
2235
|
+
nav.parentElement.querySelectorAll(':scope > .subview')
|
|
2236
|
+
.forEach((v) => v.classList.toggle('on', v.id === b.dataset.sub));
|
|
2237
|
+
render();
|
|
2238
|
+
navigated();
|
|
2239
|
+
});
|
|
1661
2240
|
|
|
1662
2241
|
/** Open a subtab under a main view — switches main tabs first if needed. */
|
|
1663
2242
|
const openSub = (view, sub) => {
|
|
2243
|
+
if (!document.getElementById(`view-${view}`)) return;
|
|
1664
2244
|
if (!document.getElementById(`view-${view}`).classList.contains('on')) {
|
|
1665
2245
|
document.querySelector(`.panels > nav button[data-view="${view}"]`).click();
|
|
1666
2246
|
}
|
|
@@ -1714,10 +2294,17 @@ let viewer = null;
|
|
|
1714
2294
|
* BroadcastChannel the app publishes on. There is no server-side session
|
|
1715
2295
|
* registry — this page builds it from hello/batch/bye frames and owns
|
|
1716
2296
|
* retention (pins + TTL) itself.
|
|
2297
|
+
*
|
|
2298
|
+
* Extension mode (`__ATOLL_TRANSPORT === 'extension'`): the Chrome DevTools
|
|
2299
|
+
* panel (packages/devtools-extension) can't join the inspected page's
|
|
2300
|
+
* channel, so its bootstrap installs `__ATOLL_BRIDGE`, a BroadcastChannel-
|
|
2301
|
+
* shaped relay (postMessage / onmessage, plus onreset when the inspected
|
|
2302
|
+
* page navigates). Everything below treats it exactly like the channel.
|
|
1717
2303
|
*/
|
|
1718
|
-
const
|
|
2304
|
+
const BRIDGE = window.__ATOLL_TRANSPORT === 'extension' ? window.__ATOLL_BRIDGE ?? null : null;
|
|
2305
|
+
const BC = BRIDGE ?? (window.__ATOLL_TRANSPORT === 'broadcast' && typeof BroadcastChannel !== 'undefined'
|
|
1719
2306
|
? new BroadcastChannel('atoll-devtools')
|
|
1720
|
-
: null;
|
|
2307
|
+
: null);
|
|
1721
2308
|
// ?mini=1 — the overlay flyout: compact single-instance layout (CSS does
|
|
1722
2309
|
// the hiding; the session sidebar is meaningless for one origin's app).
|
|
1723
2310
|
if (new URLSearchParams(location.search).has('mini')) document.body.classList.add('mini');
|
|
@@ -1765,12 +2352,105 @@ const pinSession = (id, pinned) => {
|
|
|
1765
2352
|
}
|
|
1766
2353
|
};
|
|
1767
2354
|
|
|
2355
|
+
/**
|
|
2356
|
+
* The one ingest path — live transport frames and replayed recordings both
|
|
2357
|
+
* come through here, so panels see a single stream via api.hooks.
|
|
2358
|
+
*/
|
|
2359
|
+
function ingest(session, events) {
|
|
2360
|
+
state.sessions.set(session.id, session);
|
|
2361
|
+
for (const e of events) {
|
|
2362
|
+
applyEvent(session, e);
|
|
2363
|
+
logLine(session, e);
|
|
2364
|
+
for (const h of api.hooks.event) h(session, e);
|
|
2365
|
+
// New runners / islands / contracts register new app-side commands.
|
|
2366
|
+
if (e.type === 'pool:init' || e.type === 'island:mount' || e.type === 'memory:bind') {
|
|
2367
|
+
refreshCommands(session.id);
|
|
2368
|
+
}
|
|
2369
|
+
}
|
|
2370
|
+
for (const h of api.hooks.batch) h(session, events);
|
|
2371
|
+
scheduleRender();
|
|
2372
|
+
}
|
|
2373
|
+
|
|
2374
|
+
/* ── control channel: dashboard → app commands ───────────────────────────── */
|
|
2375
|
+
|
|
2376
|
+
const pendingCtl = new Map(); // request id → { resolve, reject, timer }
|
|
2377
|
+
|
|
2378
|
+
/** Invoke a registered app-side command (`registerDevtoolsCommand`) on a live session. */
|
|
2379
|
+
function control(sessionId, cmd, args = {}, timeoutMs = 5000) {
|
|
2380
|
+
const id = `c${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
|
|
2381
|
+
return new Promise((resolve, reject) => {
|
|
2382
|
+
const msg = { type: 'control', sessionId, id, cmd, args };
|
|
2383
|
+
if (BC) BC.postMessage(msg);
|
|
2384
|
+
else if (viewer?.readyState === WebSocket.OPEN) viewer.send(JSON.stringify(msg));
|
|
2385
|
+
else return reject(new Error('dashboard is not connected'));
|
|
2386
|
+
const timer = setTimeout(() => {
|
|
2387
|
+
pendingCtl.delete(id);
|
|
2388
|
+
reject(new Error(`${cmd}: no reply in ${timeoutMs}ms (app may predate the control channel)`));
|
|
2389
|
+
}, timeoutMs);
|
|
2390
|
+
pendingCtl.set(id, { resolve, reject, timer });
|
|
2391
|
+
});
|
|
2392
|
+
}
|
|
2393
|
+
|
|
2394
|
+
function settleControl(data) {
|
|
2395
|
+
const p = pendingCtl.get(data.id);
|
|
2396
|
+
if (!p) return; // another dashboard's request
|
|
2397
|
+
pendingCtl.delete(data.id);
|
|
2398
|
+
clearTimeout(p.timer);
|
|
2399
|
+
if (data.ok) p.resolve(data.result); else p.reject(new Error(data.error ?? 'command failed'));
|
|
2400
|
+
}
|
|
2401
|
+
|
|
2402
|
+
/** sid → Set of command names, from 'devtools.commands' (refreshed as runners appear). */
|
|
2403
|
+
const cmdCache = new Map();
|
|
2404
|
+
const cmdDirty = new Set();
|
|
2405
|
+
let cmdTimer = 0;
|
|
2406
|
+
function refreshCommands(sid) {
|
|
2407
|
+
if (state.sessions.get(sid)?.closed || api.livePaused) return;
|
|
2408
|
+
cmdDirty.add(sid);
|
|
2409
|
+
clearTimeout(cmdTimer);
|
|
2410
|
+
cmdTimer = setTimeout(() => {
|
|
2411
|
+
for (const s of cmdDirty) {
|
|
2412
|
+
control(s, 'devtools.commands', {}, 3000)
|
|
2413
|
+
.then((list) => { cmdCache.set(s, new Set(list)); scheduleRender(); })
|
|
2414
|
+
.catch(() => { /* app without the control channel: commands stay off */ });
|
|
2415
|
+
}
|
|
2416
|
+
cmdDirty.clear();
|
|
2417
|
+
}, 400);
|
|
2418
|
+
}
|
|
2419
|
+
const hasCommand = (sid, cmd) => cmdCache.get(sid)?.has(cmd) === true;
|
|
2420
|
+
|
|
2421
|
+
const STATE_INIT = { sel: null, drillTask: null, inspect: null, inspectWorker: null, inspectFetch: null, seq: 0, t0: null };
|
|
2422
|
+
|
|
2423
|
+
/** Clear every dashboard aggregate (the recorder's import/replay starts here). */
|
|
2424
|
+
function reset() {
|
|
2425
|
+
for (const v of Object.values(state)) {
|
|
2426
|
+
if (v instanceof Map) v.clear();
|
|
2427
|
+
else if (Array.isArray(v)) v.length = 0;
|
|
2428
|
+
}
|
|
2429
|
+
Object.assign(state, STATE_INIT);
|
|
2430
|
+
bcSessions.clear();
|
|
2431
|
+
cmdCache.clear();
|
|
2432
|
+
logEl().innerHTML = '';
|
|
2433
|
+
for (const h of api.hooks.reset) h();
|
|
2434
|
+
topo.fit = true;
|
|
2435
|
+
render();
|
|
2436
|
+
}
|
|
2437
|
+
|
|
2438
|
+
/** Ask live apps to re-identify and replay their tail (after a reset/unpause). */
|
|
2439
|
+
function reannounce() {
|
|
2440
|
+
if (BC) BC.postMessage({ type: 'view' });
|
|
2441
|
+
else viewer?.close(); // reconnect → the server replays its tail
|
|
2442
|
+
}
|
|
2443
|
+
|
|
1768
2444
|
function connect() {
|
|
1769
2445
|
if (BC) {
|
|
1770
|
-
|
|
1771
|
-
|
|
2446
|
+
if (!BRIDGE) { // the extension bridge owns #conn (page reachable, atoll seen, …)
|
|
2447
|
+
$('conn').textContent = 'local';
|
|
2448
|
+
$('conn').className = 'on';
|
|
2449
|
+
}
|
|
1772
2450
|
BC.onmessage = (msg) => {
|
|
1773
2451
|
const data = msg.data;
|
|
2452
|
+
if (data.type === 'control-result') return settleControl(data);
|
|
2453
|
+
if (api.livePaused) return;
|
|
1774
2454
|
if (data.type === 'hello') {
|
|
1775
2455
|
// a fresh hello means a new app instance — same id can't revive
|
|
1776
2456
|
const prev = bcSessions.get(data.session.id);
|
|
@@ -1778,6 +2458,7 @@ function connect() {
|
|
|
1778
2458
|
...data.session, closed: false, closedAt: undefined, pinned: prev?.pinned,
|
|
1779
2459
|
});
|
|
1780
2460
|
bcSync();
|
|
2461
|
+
refreshCommands(data.session.id);
|
|
1781
2462
|
} else if (data.type === 'bye') {
|
|
1782
2463
|
const s = bcSessions.get(data.sessionId);
|
|
1783
2464
|
if (s && !s.closed) { s.closed = true; s.closedAt = Date.now(); bcSync(); }
|
|
@@ -1786,14 +2467,12 @@ function connect() {
|
|
|
1786
2467
|
bcSessions.set(data.session.id, data.session);
|
|
1787
2468
|
bcSync();
|
|
1788
2469
|
}
|
|
1789
|
-
|
|
1790
|
-
for (const e of data.events) {
|
|
1791
|
-
applyEvent(data.session, e);
|
|
1792
|
-
logLine(data.session, e);
|
|
1793
|
-
}
|
|
2470
|
+
ingest(data.session, data.events);
|
|
1794
2471
|
}
|
|
1795
2472
|
scheduleRender();
|
|
1796
2473
|
};
|
|
2474
|
+
// inspected page navigated: its sessions are gone, start over and re-announce
|
|
2475
|
+
if (BRIDGE) BRIDGE.onreset = () => { reset(); reannounce(); };
|
|
1797
2476
|
// announce — apps re-hello and replay their batch tail
|
|
1798
2477
|
BC.postMessage({ type: 'view' });
|
|
1799
2478
|
return;
|
|
@@ -1808,16 +2487,87 @@ function connect() {
|
|
|
1808
2487
|
};
|
|
1809
2488
|
ws.onmessage = (msg) => {
|
|
1810
2489
|
const data = JSON.parse(msg.data);
|
|
2490
|
+
if (data.type === 'control-result') return settleControl(data);
|
|
2491
|
+
if (api.livePaused) return;
|
|
1811
2492
|
if (data.type === 'sessions') {
|
|
1812
2493
|
reconcileSessions(data.sessions);
|
|
2494
|
+
for (const s of data.sessions) if (!s.closed && !cmdCache.has(s.id)) refreshCommands(s.id);
|
|
1813
2495
|
} else if (data.type === 'batch') {
|
|
1814
|
-
|
|
1815
|
-
for (const e of data.events) {
|
|
1816
|
-
applyEvent(data.session, e);
|
|
1817
|
-
logLine(data.session, e);
|
|
1818
|
-
}
|
|
2496
|
+
ingest(data.session, data.events);
|
|
1819
2497
|
}
|
|
1820
2498
|
scheduleRender();
|
|
1821
2499
|
};
|
|
1822
2500
|
}
|
|
2501
|
+
|
|
2502
|
+
/* ── panels ──────────────────────────────────────────────────────────────── */
|
|
2503
|
+
|
|
2504
|
+
const kpi = (v, l, cls = '') => `<div class="kpi ${cls}"><div class="v">${v}</div><div class="l">${l}</div></div>`;
|
|
2505
|
+
|
|
2506
|
+
Object.assign(api, {
|
|
2507
|
+
state, $, esc, fmt, fmtBytes, fmtInt, key, sessOf, inSel, withGroups, kpi,
|
|
2508
|
+
sparkline, drawLine, drawMultiLine, fwIconImg,
|
|
2509
|
+
render, scheduleRender, addView, addSubview, openView, openSub,
|
|
2510
|
+
control, hasCommand, ingest, reset, reannounce,
|
|
2511
|
+
liveSessions: () => [...state.sessions.values()].filter((s) => !s.closed),
|
|
2512
|
+
/** The selected session, else the only live one, else null (ambiguous). */
|
|
2513
|
+
selectedOrOnlyLive: () => {
|
|
2514
|
+
if (state.sel) return state.sel;
|
|
2515
|
+
const live = [...state.sessions.values()].filter((s) => !s.closed);
|
|
2516
|
+
return live.length === 1 ? live[0].id : null;
|
|
2517
|
+
},
|
|
2518
|
+
isMini: new URLSearchParams(location.search).has('mini'),
|
|
2519
|
+
transport: BRIDGE ? 'extension' : BC ? 'broadcast' : 'ws',
|
|
2520
|
+
livePaused: false,
|
|
2521
|
+
/** () after every view/subview switch (openView, subnav clicks, openSub). */
|
|
2522
|
+
onNavigate(fn) { (api.hooks.navigate ??= []).push(fn); },
|
|
2523
|
+
/** viewId → intro html; the shell panel renders these under each view heading. */
|
|
2524
|
+
viewIntros: new Map(),
|
|
2525
|
+
/** One-line, dismissible "what this view shows" intro (callable before the shell loads). */
|
|
2526
|
+
setViewIntro(viewId, html) {
|
|
2527
|
+
api.viewIntros.set(viewId, html);
|
|
2528
|
+
for (const h of api.hooks.intro ?? []) h(viewId, html);
|
|
2529
|
+
},
|
|
2530
|
+
/** Transient message: kind 'ok' | 'error' | 'info' (panels/toast.js). */
|
|
2531
|
+
toast: (msg, kind, ms) => import('./panels/toast.js').then((m) => m.toast(msg, kind, ms)),
|
|
2532
|
+
});
|
|
2533
|
+
|
|
2534
|
+
/**
|
|
2535
|
+
* Header toolbar button (global actions: record, export, palette). Returns
|
|
2536
|
+
* the button; `order` sorts left→right.
|
|
2537
|
+
*/
|
|
2538
|
+
api.addToolbarButton = ({ id, label, title, order = 50, onClick }) => {
|
|
2539
|
+
const bar = $('toolbar');
|
|
2540
|
+
const b = document.createElement('button');
|
|
2541
|
+
b.className = 'act';
|
|
2542
|
+
b.id = id;
|
|
2543
|
+
b.dataset.order = String(order);
|
|
2544
|
+
b.innerHTML = label;
|
|
2545
|
+
if (title) b.title = title;
|
|
2546
|
+
b.onclick = onClick;
|
|
2547
|
+
const after = [...bar.children].find((c) => Number(c.dataset.order ?? 50) > order);
|
|
2548
|
+
bar.insertBefore(b, after ?? null);
|
|
2549
|
+
return b;
|
|
2550
|
+
};
|
|
2551
|
+
|
|
2552
|
+
/** Feature panels: each module exports setup(api). Order = setup order. */
|
|
2553
|
+
const PANELS = [
|
|
2554
|
+
'./panels/performance.js', // jank, frames, User Timing, message cost
|
|
2555
|
+
'./panels/inspect-island.js',// island Elements / Props & events tabs
|
|
2556
|
+
'./panels/memory-values.js', // shared-memory values, diff, watchpoints
|
|
2557
|
+
'./panels/reactivity.js', // cross-thread reactivity graph
|
|
2558
|
+
'./panels/actions.js', // live controls: kill, chaos, props, mode
|
|
2559
|
+
'./panels/recorder.js', // record / export / import / replay
|
|
2560
|
+
'./panels/audits.js', // recommendations
|
|
2561
|
+
'./panels/shell.js', // palette, routing, shortcuts, help (load last)
|
|
2562
|
+
];
|
|
2563
|
+
|
|
2564
|
+
for (const p of PANELS) {
|
|
2565
|
+
try {
|
|
2566
|
+
const mod = await import(p);
|
|
2567
|
+
mod.setup(api);
|
|
2568
|
+
} catch (err) {
|
|
2569
|
+
console.error(`[atoll devtools] panel ${p} failed to load`, err);
|
|
2570
|
+
}
|
|
2571
|
+
}
|
|
2572
|
+
|
|
1823
2573
|
connect();
|