@atolljs/devtools 0.1.7 → 0.1.9
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/fw-logos.js +14 -0
- package/app/index.html +161 -2
- package/app/main.js +897 -222
- 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-6E54FXKS.js +495 -0
- 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 +239 -47
- package/src/probes.ts +155 -0
- package/src/protocol.ts +52 -4
- package/src/server.ts +83 -3
- package/dist/chunk-4N3NBFL3.js +0 -474
package/app/main.js
CHANGED
|
@@ -5,6 +5,11 @@
|
|
|
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';
|
|
12
|
+
import { FW_LOGOS } from './fw-logos.js';
|
|
8
13
|
|
|
9
14
|
const $ = (id) => document.getElementById(id);
|
|
10
15
|
const esc = (s) => String(s ?? '').replace(/[&<>]/g, (c) => ({ '&': '&', '<': '<', '>': '>' }[c]));
|
|
@@ -56,8 +61,10 @@ const MAX_ALERTS = 60;
|
|
|
56
61
|
|
|
57
62
|
/* ── event reducer ───────────────────────────────────────────────────────── */
|
|
58
63
|
|
|
59
|
-
|
|
60
|
-
|
|
64
|
+
/** Per-second buckets on the dashboard clock: event `at` epochs differ per thread. */
|
|
65
|
+
const dashSec = () => Math.floor(performance.now() / 1000);
|
|
66
|
+
const bump = (sid, metric, _at, n = 1) => {
|
|
67
|
+
const k = `${sid}|${metric}|${dashSec()}`;
|
|
61
68
|
state.series.set(k, (state.series.get(k) ?? 0) + n);
|
|
62
69
|
};
|
|
63
70
|
|
|
@@ -69,7 +76,7 @@ function applyEvent(sess, e) {
|
|
|
69
76
|
state.pools.set(key(sid, e.poolId), {
|
|
70
77
|
poolId: e.poolId, label: e.label ?? e.poolId, size: e.poolSize,
|
|
71
78
|
memoryBytes: e.memoryBytes, workers: new Set(), tasks: 0, failed: 0,
|
|
72
|
-
dead: sess.closed === true,
|
|
79
|
+
dedicated: e.dedicated === true, dead: sess.closed === true,
|
|
73
80
|
});
|
|
74
81
|
break;
|
|
75
82
|
case 'pool:terminate': {
|
|
@@ -141,7 +148,9 @@ function applyEvent(sess, e) {
|
|
|
141
148
|
}
|
|
142
149
|
}
|
|
143
150
|
}
|
|
144
|
-
|
|
151
|
+
// Dashboard clock, like heapSeries: emit threads (and sessions) have
|
|
152
|
+
// unrelated performance.now() epochs, so `e.at` can't share an axis.
|
|
153
|
+
const sec = Math.floor(performance.now() / 1000);
|
|
145
154
|
const bk = key(sid, sec);
|
|
146
155
|
const b = state.tput.get(bk) ?? { n: 0, err: 0 };
|
|
147
156
|
b.n++; if (e.outcome !== 'ok') b.err++;
|
|
@@ -160,7 +169,7 @@ function applyEvent(sess, e) {
|
|
|
160
169
|
m.writes++; m.version = e.version; m.thread = e.thread; m.lastAt = e.at;
|
|
161
170
|
// worker:* forwarded events carry slot attribution — pin the writer.
|
|
162
171
|
if (e.worker) m.writer = `${e.worker.poolId}#${e.worker.slot}`;
|
|
163
|
-
const sec =
|
|
172
|
+
const sec = dashSec();
|
|
164
173
|
m.buckets.set(sec, (m.buckets.get(sec) ?? 0) + 1);
|
|
165
174
|
if (m.buckets.size > 120) m.buckets.delete([...m.buckets.keys()][0]);
|
|
166
175
|
state.mem.set(k, m);
|
|
@@ -235,7 +244,7 @@ function applyEvent(sess, e) {
|
|
|
235
244
|
// filter, or worker-side performance.memory) stamped to the slot.
|
|
236
245
|
const w = getWorker(sid, e.worker.poolId, e.worker.slot, e.at);
|
|
237
246
|
w.heap = e.heapBytes; w.heapLimit = e.heapLimitBytes; w.lastAt = e.at;
|
|
238
|
-
const sec =
|
|
247
|
+
const sec = dashSec();
|
|
239
248
|
(w.heapSeries ??= new Map()).set(sec, e.heapBytes / 1048576); // MB for the chart
|
|
240
249
|
if (w.heapSeries.size > 120) w.heapSeries.delete([...w.heapSeries.keys()][0]);
|
|
241
250
|
}
|
|
@@ -306,6 +315,7 @@ function reconcileSessions(live) {
|
|
|
306
315
|
if (state.sel && !ids.has(state.sel)) state.sel = null;
|
|
307
316
|
if (state.inspect && !state.islands.has(state.inspect)) state.inspect = null;
|
|
308
317
|
if (state.inspectWorker && !state.workers.has(state.inspectWorker)) state.inspectWorker = null;
|
|
318
|
+
for (const h of api.hooks.reconcile) h(ids);
|
|
309
319
|
}
|
|
310
320
|
|
|
311
321
|
/**
|
|
@@ -651,30 +661,78 @@ function drawThroughput() {
|
|
|
651
661
|
const canvas = $('tput');
|
|
652
662
|
const ctx = canvas.getContext('2d');
|
|
653
663
|
const w = (canvas.width = canvas.clientWidth || 560);
|
|
654
|
-
const h = (canvas.height =
|
|
664
|
+
const h = (canvas.height = 110);
|
|
655
665
|
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) {
|
|
666
|
+
const statsEl = $('tput-stats');
|
|
667
|
+
if (state.t0 === null) { statsEl.textContent = ''; return; }
|
|
668
|
+
// Buckets are on the dashboard clock: sum sessions per second, window the
|
|
669
|
+
// last 60s ending now (an idle stretch shows as empty seconds).
|
|
670
|
+
const bySec = new Map();
|
|
671
|
+
for (const [k, b] of state.tput) {
|
|
672
|
+
if (!inSel(k)) continue;
|
|
665
673
|
const sec = Number(k.split('|')[1]);
|
|
666
|
-
const
|
|
667
|
-
|
|
674
|
+
const s = bySec.get(sec) ?? { n: 0, err: 0 };
|
|
675
|
+
s.n += b.n; s.err += b.err;
|
|
676
|
+
bySec.set(sec, s);
|
|
677
|
+
}
|
|
678
|
+
if (!bySec.size) { statsEl.textContent = 'no settled tasks yet'; return; }
|
|
679
|
+
const hi = Math.floor(performance.now() / 1000);
|
|
680
|
+
const lo = hi - 59;
|
|
681
|
+
const win = [...bySec].filter(([s]) => s >= lo);
|
|
682
|
+
const peak = Math.max(...win.map(([, b]) => b.n), 0);
|
|
683
|
+
const total = win.reduce((a, [, b]) => a + b.n, 0);
|
|
684
|
+
const errs = win.reduce((a, [, b]) => a + b.err, 0);
|
|
685
|
+
const first = win.length ? Math.min(...win.map(([s]) => s)) : hi;
|
|
686
|
+
const latest = bySec.get(hi - 1)?.n ?? 0;
|
|
687
|
+
statsEl.textContent =
|
|
688
|
+
`latest ${fmtN(latest)}/s · peak ${fmtN(peak)}/s · avg ${fmtN(total / (hi - first + 1))}/s · ` +
|
|
689
|
+
`${fmtN(total)} settled in 60s` + (errs ? ` · ${fmtN(errs)} failed` : '');
|
|
690
|
+
|
|
691
|
+
const scale = niceCeil(Math.max(peak, 1));
|
|
692
|
+
const L = 40, B = 16, T = 6;
|
|
693
|
+
const pw = w - L - 4, ph = h - B - T;
|
|
694
|
+
const bw = pw / 60;
|
|
695
|
+
ctx.font = '10px ui-monospace, monospace';
|
|
696
|
+
ctx.textBaseline = 'middle';
|
|
697
|
+
ctx.textAlign = 'right';
|
|
698
|
+
for (const f of [0, 0.5, 1]) {
|
|
699
|
+
const y = T + ph - f * ph;
|
|
700
|
+
ctx.strokeStyle = f === 0 ? '#30363d' : '#21262d';
|
|
701
|
+
ctx.lineWidth = 1;
|
|
702
|
+
ctx.beginPath(); ctx.moveTo(L, Math.round(y) + 0.5); ctx.lineTo(w - 4, Math.round(y) + 0.5); ctx.stroke();
|
|
703
|
+
ctx.fillStyle = '#8b949e';
|
|
704
|
+
ctx.fillText(fmtN(scale * f), L - 6, y);
|
|
705
|
+
}
|
|
706
|
+
ctx.textBaseline = 'top';
|
|
707
|
+
for (const [ago, align] of [[60, 'left'], [30, 'center'], [0, 'right']]) {
|
|
708
|
+
ctx.textAlign = align;
|
|
709
|
+
ctx.fillText(ago ? `-${ago}s` : 'now', L + pw * (1 - ago / 60), T + ph + 4);
|
|
710
|
+
}
|
|
711
|
+
for (const [sec, b] of win) {
|
|
712
|
+
const x = L + (sec - lo) * bw;
|
|
713
|
+
const bh = (b.n / scale) * ph;
|
|
668
714
|
ctx.fillStyle = '#58a6ff';
|
|
669
|
-
ctx.fillRect(x,
|
|
715
|
+
ctx.fillRect(x, T + ph - bh, Math.max(1, bw - 1), bh);
|
|
670
716
|
if (b.err) {
|
|
671
|
-
const eh = (b.err / max) * (h - 4);
|
|
672
717
|
ctx.fillStyle = '#f85149';
|
|
673
|
-
ctx.fillRect(x,
|
|
718
|
+
ctx.fillRect(x, T + ph - bh, Math.max(1, bw - 1), (b.err / scale) * ph);
|
|
674
719
|
}
|
|
675
720
|
}
|
|
676
721
|
}
|
|
677
722
|
|
|
723
|
+
/** Round up to 1, 2 or 5 × 10^k, so axis ticks land on readable numbers. */
|
|
724
|
+
function niceCeil(v) {
|
|
725
|
+
const p = 10 ** Math.floor(Math.log10(v));
|
|
726
|
+
return [1, 2, 5, 10].find((m) => m * p >= v) * p;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/** Compact count: 950, 1.2k, 3.4M. */
|
|
730
|
+
function fmtN(v) {
|
|
731
|
+
if (v >= 1e6) return `${+(v / 1e6).toFixed(1)}M`;
|
|
732
|
+
if (v >= 1e3) return `${+(v / 1e3).toFixed(1)}k`;
|
|
733
|
+
return v >= 10 || Number.isInteger(v) ? String(Math.round(v)) : v.toFixed(1);
|
|
734
|
+
}
|
|
735
|
+
|
|
678
736
|
/* ── tasks: waterfall + aggregates + table ───────────────────────────────── */
|
|
679
737
|
|
|
680
738
|
function visibleCalls() {
|
|
@@ -987,6 +1045,8 @@ function renderInspector() {
|
|
|
987
1045
|
<td class="${t.error ? 'error' : 'ok'}">${t.error ? 'error' : 'ok'}</td>
|
|
988
1046
|
<td class="muted">${esc(t.error ?? '')}</td></tr>`)
|
|
989
1047
|
.join('') || '<tr><td colspan="6" class="muted">no calls yet</td></tr>';
|
|
1048
|
+
|
|
1049
|
+
for (const h of api.hooks.inspectIsland) h(state.inspect, i, { extra: $('inspExtra'), actions: $('inspActions') });
|
|
990
1050
|
}
|
|
991
1051
|
|
|
992
1052
|
function drawOpTraffic(i) {
|
|
@@ -1013,20 +1073,225 @@ function drawOpTraffic(i) {
|
|
|
1013
1073
|
|
|
1014
1074
|
const logEl = () => $('log');
|
|
1015
1075
|
const pad = (n, w = 2) => String(n).padStart(w, '0');
|
|
1016
|
-
|
|
1076
|
+
|
|
1077
|
+
/*
|
|
1078
|
+
* Event log: one DOM line per event, appended incrementally (never a full
|
|
1079
|
+
* re-render). Filters flip `hidden` on existing lines. Lines are grouped
|
|
1080
|
+
* for the chips by event-type prefix ('task', 'island', …), and `log`
|
|
1081
|
+
* events by level ('log.warn'). reset() empties #log; the reset hook
|
|
1082
|
+
* below drops the matching records.
|
|
1083
|
+
*/
|
|
1084
|
+
const LOG_CAP = 2000;
|
|
1085
|
+
const LOG_LEVELS = ['trace', 'debug', 'info', 'warn', 'error'];
|
|
1086
|
+
const LOG_LEVEL_COLOR = { trace: '#484f58', debug: '#8b949e', info: '#58a6ff', warn: '#d29922', error: '#f85149' };
|
|
1087
|
+
const logView = {
|
|
1088
|
+
recs: [], // [{ sid, group, hay, el, vis, sess, e }] oldest first, all in #log
|
|
1089
|
+
held: [], // records ingested while paused, appended on resume
|
|
1090
|
+
groups: new Map(), // group → on (bool)
|
|
1091
|
+
sessions: new Set(),
|
|
1092
|
+
q: '',
|
|
1093
|
+
sess: '',
|
|
1094
|
+
paused: false,
|
|
1095
|
+
hidden: 0,
|
|
1096
|
+
};
|
|
1097
|
+
const logGroupOf = (e) => (e.type === 'log' ? `log.${e.level}` : e.type.split(':')[0]);
|
|
1098
|
+
const logMatch = (r) =>
|
|
1099
|
+
logView.groups.get(r.group) !== false &&
|
|
1100
|
+
(!logView.sess || r.sid === logView.sess) &&
|
|
1101
|
+
(!logView.q || r.hay.includes(logView.q));
|
|
1102
|
+
|
|
1103
|
+
function logStatus() {
|
|
1104
|
+
const s = $('logstat');
|
|
1105
|
+
if (!s) return;
|
|
1106
|
+
const parts = [`${logView.recs.length} lines`];
|
|
1107
|
+
if (logView.hidden) parts.push(`${logView.hidden} hidden by filters`);
|
|
1108
|
+
if (logView.paused) parts.push(`${logView.held.length} held while paused`);
|
|
1109
|
+
s.textContent = parts.join(' · ');
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
function logRefilter() {
|
|
1113
|
+
logView.hidden = 0;
|
|
1114
|
+
for (const r of logView.recs) {
|
|
1115
|
+
r.vis = logMatch(r);
|
|
1116
|
+
r.el.hidden = !r.vis;
|
|
1117
|
+
if (!r.vis) logView.hidden++;
|
|
1118
|
+
}
|
|
1119
|
+
logStatus();
|
|
1120
|
+
const el = logEl();
|
|
1121
|
+
el.scrollTop = el.scrollHeight;
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
function logChip(group) {
|
|
1125
|
+
if (logView.groups.has(group)) return;
|
|
1126
|
+
logView.groups.set(group, true);
|
|
1127
|
+
const bar = $('logchips');
|
|
1128
|
+
if (!bar) return;
|
|
1129
|
+
const b = document.createElement('button');
|
|
1130
|
+
b.className = 'on';
|
|
1131
|
+
b.dataset.g = group;
|
|
1132
|
+
const lvl = group.startsWith('log.') ? group.slice(4) : null;
|
|
1133
|
+
b.innerHTML = lvl ? `<i style="background:${LOG_LEVEL_COLOR[lvl]}"></i>${esc(lvl)}` : esc(group);
|
|
1134
|
+
b.title = lvl ? `log entries at ${lvl}` : `${group}:* events`;
|
|
1135
|
+
// levels first (in severity order), then event groups alphabetically
|
|
1136
|
+
const rank = (g) => (g.startsWith('log.') ? LOG_LEVELS.indexOf(g.slice(4)) : 10);
|
|
1137
|
+
const after = [...bar.querySelectorAll('button[data-g]')].find(
|
|
1138
|
+
(x) => rank(x.dataset.g) > rank(group) || (rank(x.dataset.g) === rank(group) && x.dataset.g > group),
|
|
1139
|
+
);
|
|
1140
|
+
bar.insertBefore(b, after ?? null);
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
function logSessOption(sess) {
|
|
1144
|
+
if (logView.sessions.has(sess.id)) return;
|
|
1145
|
+
logView.sessions.add(sess.id);
|
|
1146
|
+
const sel = $('logsess');
|
|
1147
|
+
if (!sel) return;
|
|
1148
|
+
const o = document.createElement('option');
|
|
1149
|
+
o.value = sess.id;
|
|
1150
|
+
o.textContent = sess.name ?? sess.id;
|
|
1151
|
+
sel.appendChild(o);
|
|
1152
|
+
sel.style.display = logView.sessions.size > 1 ? '' : 'none';
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
function logRender(sess, e) {
|
|
1017
1156
|
const div = document.createElement('div');
|
|
1018
1157
|
const { type, at, thread, ...rest } = e;
|
|
1019
1158
|
// Wall-clock is dashboard receive time — emit-side `at` is a per-thread
|
|
1020
1159
|
// performance.now() epoch, so it's shown as elapsed instead.
|
|
1021
1160
|
const now = new Date();
|
|
1022
1161
|
const ts = `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}.${pad(now.getMilliseconds(), 3)}`;
|
|
1023
|
-
|
|
1162
|
+
const origin = e.worker ? ` ${e.worker.poolId}#${e.worker.slot}` : thread === 'worker' ? ' ⧉' : '';
|
|
1163
|
+
const head = `<span class="logts">${ts}</span> <span class="muted">${esc(sess.name ?? sess.id)}${esc(origin)}</span> `;
|
|
1164
|
+
let body, hay;
|
|
1165
|
+
if (type === 'log') {
|
|
1166
|
+
div.className = `ll-${e.level}`;
|
|
1167
|
+
body = `<b>[${esc(e.level)}]</b> ${esc(e.scope)}: ${esc(e.message)}${e.data !== undefined ? ' <span class="logx">▸ data</span>' : ''}`;
|
|
1168
|
+
hay = `log ${e.level} ${e.scope} ${e.message} ${e.data ?? ''}`;
|
|
1169
|
+
} else {
|
|
1170
|
+
const json = JSON.stringify(rest);
|
|
1171
|
+
const bad = type === 'worker:error' || (type === 'task:settle' && e.outcome !== 'ok');
|
|
1172
|
+
if (bad) div.className = 'll-error';
|
|
1173
|
+
body = `${esc(type)} <span class="muted">${esc(json.length > 400 ? `${json.slice(0, 400)}…` : json)}</span>`;
|
|
1174
|
+
hay = `${type} ${json}`;
|
|
1175
|
+
}
|
|
1176
|
+
div.innerHTML = `${head}${body} <span class="logts">+${fmt(at / 1000, 1)}s</span>`;
|
|
1177
|
+
return { sid: sess.id, group: logGroupOf(e), hay: `${sess.name ?? sess.id} ${hay}`.toLowerCase(), el: div, vis: true, sess, e };
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
function logAppend(batch) {
|
|
1024
1181
|
const el = logEl();
|
|
1025
|
-
el.
|
|
1026
|
-
|
|
1027
|
-
|
|
1182
|
+
const stick = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
|
|
1183
|
+
const frag = document.createDocumentFragment();
|
|
1184
|
+
for (const r of batch) {
|
|
1185
|
+
r.vis = logMatch(r);
|
|
1186
|
+
r.el.hidden = !r.vis;
|
|
1187
|
+
if (!r.vis) logView.hidden++;
|
|
1188
|
+
frag.appendChild(r.el);
|
|
1189
|
+
logView.recs.push(r);
|
|
1190
|
+
}
|
|
1191
|
+
el.appendChild(frag);
|
|
1192
|
+
const over = logView.recs.length - LOG_CAP;
|
|
1193
|
+
if (over > 0) {
|
|
1194
|
+
for (const r of logView.recs.splice(0, over)) {
|
|
1195
|
+
if (!r.vis) logView.hidden--;
|
|
1196
|
+
r.el.remove();
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
if (stick) el.scrollTop = el.scrollHeight;
|
|
1200
|
+
logStatus();
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
function logLine(sess, e) {
|
|
1204
|
+
logSessOption(sess);
|
|
1205
|
+
logChip(logGroupOf(e));
|
|
1206
|
+
const r = logRender(sess, e);
|
|
1207
|
+
if (logView.paused) {
|
|
1208
|
+
logView.held.push(r);
|
|
1209
|
+
if (logView.held.length > LOG_CAP) logView.held.shift();
|
|
1210
|
+
logStatus();
|
|
1211
|
+
return;
|
|
1212
|
+
}
|
|
1213
|
+
logAppend([r]);
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
function logClear() {
|
|
1217
|
+
logView.recs.length = 0;
|
|
1218
|
+
logView.held.length = 0;
|
|
1219
|
+
logView.hidden = 0;
|
|
1220
|
+
logEl().innerHTML = '';
|
|
1221
|
+
logStatus();
|
|
1028
1222
|
}
|
|
1029
1223
|
|
|
1224
|
+
api.onReset(() => {
|
|
1225
|
+
logClear();
|
|
1226
|
+
logView.sessions.clear();
|
|
1227
|
+
const sel = $('logsess');
|
|
1228
|
+
if (sel) { sel.length = 1; sel.value = ''; sel.style.display = 'none'; }
|
|
1229
|
+
logView.sess = '';
|
|
1230
|
+
});
|
|
1231
|
+
|
|
1232
|
+
(function logControls() {
|
|
1233
|
+
const chips = $('logchips');
|
|
1234
|
+
if (!chips) return;
|
|
1235
|
+
chips.addEventListener('click', (ev) => {
|
|
1236
|
+
const b = ev.target.closest('button');
|
|
1237
|
+
if (!b) return;
|
|
1238
|
+
if (b.dataset.all !== undefined) {
|
|
1239
|
+
const on = b.dataset.all === '1';
|
|
1240
|
+
for (const g of logView.groups.keys()) logView.groups.set(g, on);
|
|
1241
|
+
for (const x of chips.querySelectorAll('button[data-g]')) x.classList.toggle('on', on);
|
|
1242
|
+
} else if (b.dataset.g) {
|
|
1243
|
+
const g = b.dataset.g;
|
|
1244
|
+
// alt/shift-click: solo this group
|
|
1245
|
+
if (ev.altKey || ev.shiftKey) {
|
|
1246
|
+
for (const k of logView.groups.keys()) logView.groups.set(k, k === g);
|
|
1247
|
+
for (const x of chips.querySelectorAll('button[data-g]')) x.classList.toggle('on', x.dataset.g === g);
|
|
1248
|
+
} else {
|
|
1249
|
+
const on = !(logView.groups.get(g) ?? true);
|
|
1250
|
+
logView.groups.set(g, on);
|
|
1251
|
+
b.classList.toggle('on', on);
|
|
1252
|
+
}
|
|
1253
|
+
} else return;
|
|
1254
|
+
logRefilter();
|
|
1255
|
+
});
|
|
1256
|
+
let qTimer = 0;
|
|
1257
|
+
$('logq').addEventListener('input', (ev) => {
|
|
1258
|
+
clearTimeout(qTimer);
|
|
1259
|
+
qTimer = setTimeout(() => { logView.q = ev.target.value.trim().toLowerCase(); logRefilter(); }, 150);
|
|
1260
|
+
});
|
|
1261
|
+
$('logsess').addEventListener('change', (ev) => { logView.sess = ev.target.value; logRefilter(); });
|
|
1262
|
+
$('logpause').addEventListener('click', (ev) => {
|
|
1263
|
+
logView.paused = !logView.paused;
|
|
1264
|
+
ev.target.textContent = logView.paused ? 'resume' : 'pause';
|
|
1265
|
+
ev.target.classList.toggle('on', logView.paused);
|
|
1266
|
+
if (!logView.paused && logView.held.length) logAppend(logView.held.splice(0));
|
|
1267
|
+
logStatus();
|
|
1268
|
+
});
|
|
1269
|
+
$('logclear').addEventListener('click', logClear);
|
|
1270
|
+
// Click a line to expand its full payload (log data, or the event JSON).
|
|
1271
|
+
logEl().addEventListener('click', (ev) => {
|
|
1272
|
+
const line = ev.target.closest('#log > div');
|
|
1273
|
+
if (!line) return;
|
|
1274
|
+
const open = line.querySelector(':scope > pre');
|
|
1275
|
+
if (open) { open.remove(); line.classList.remove('open'); return; }
|
|
1276
|
+
const r = logView.recs.find((x) => x.el === line);
|
|
1277
|
+
if (!r) return;
|
|
1278
|
+
const { e } = r;
|
|
1279
|
+
let text;
|
|
1280
|
+
if (e.type === 'log') {
|
|
1281
|
+
try { text = e.data !== undefined ? JSON.stringify(JSON.parse(e.data), null, 2) : '(no data)'; }
|
|
1282
|
+
catch { text = e.data; }
|
|
1283
|
+
} else {
|
|
1284
|
+
const { type, ...rest } = e;
|
|
1285
|
+
text = `${type}\n${JSON.stringify(rest, null, 2)}`;
|
|
1286
|
+
}
|
|
1287
|
+
const pre = document.createElement('pre');
|
|
1288
|
+
pre.className = 'body';
|
|
1289
|
+
pre.textContent = text;
|
|
1290
|
+
line.appendChild(pre);
|
|
1291
|
+
line.classList.add('open');
|
|
1292
|
+
});
|
|
1293
|
+
})();
|
|
1294
|
+
|
|
1030
1295
|
/* ── app map — one world-space canvas; zoom/pan + zoom-tier clustering ───── */
|
|
1031
1296
|
|
|
1032
1297
|
const APP_COLORS = ['#3fb950', '#f0883e', '#a371f7', '#79c0ff', '#d29922', '#f85149', '#56d4dd', '#e275ad'];
|
|
@@ -1046,6 +1311,21 @@ function renderTopology() {
|
|
|
1046
1311
|
window.__topo = topo; // exposed for tests/debugging
|
|
1047
1312
|
}
|
|
1048
1313
|
|
|
1314
|
+
/**
|
|
1315
|
+
* The worker that spawned a qualified pool: 'h1#s1~h2#s2~local' is a pool
|
|
1316
|
+
* minted inside worker h2#s2, whose own (qualified) pool is 'h1#s1~h2'.
|
|
1317
|
+
* Returns the `${sess}|${poolId}|${slot}` key or null for top-level ids.
|
|
1318
|
+
*/
|
|
1319
|
+
function parentWorkerKey(sid, qpid) {
|
|
1320
|
+
const segs = qpid.split('~');
|
|
1321
|
+
if (segs.length < 2) return null;
|
|
1322
|
+
const host = segs[segs.length - 2];
|
|
1323
|
+
const hi = host.lastIndexOf('#');
|
|
1324
|
+
if (hi < 0) return null;
|
|
1325
|
+
const parentPool = [...segs.slice(0, -2), host.slice(0, hi)].join('~');
|
|
1326
|
+
return `${sid}|${parentPool}|${host.slice(hi + 1)}`;
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1049
1329
|
/** World-space layout: each session is one atoll cluster in a loose grid. */
|
|
1050
1330
|
function layoutWorld(sids) {
|
|
1051
1331
|
const clusters = [];
|
|
@@ -1064,23 +1344,39 @@ function layoutWorld(sids) {
|
|
|
1064
1344
|
} else orphans.push({ ik, i });
|
|
1065
1345
|
}
|
|
1066
1346
|
// rim positions belong to pools, not workers — a size-N pool is one
|
|
1067
|
-
// socket containing N worker dots
|
|
1347
|
+
// socket containing N worker dots. Nested pools (poolIds qualified as
|
|
1348
|
+
// 'hostPool#slot~localPool' by sub-worker forwarding) don't take rim
|
|
1349
|
+
// sockets — they hang off their host worker instead.
|
|
1068
1350
|
const pools = [];
|
|
1351
|
+
const nested = new Map(); // parent worker key → [pool group]
|
|
1069
1352
|
const byPool = new Map();
|
|
1070
1353
|
for (const w of workers) {
|
|
1071
1354
|
const pk = w[0].slice(0, w[0].lastIndexOf('|'));
|
|
1072
1355
|
let g = byPool.get(pk);
|
|
1073
1356
|
if (!g) {
|
|
1074
|
-
g = {
|
|
1357
|
+
g = {
|
|
1358
|
+
pk, poolId: pk.split('|')[1], workers: [], nested: pk.split('|')[1].includes('~'),
|
|
1359
|
+
dedicated: state.pools.get(pk)?.dedicated === true,
|
|
1360
|
+
};
|
|
1075
1361
|
byPool.set(pk, g);
|
|
1076
1362
|
pools.push(g);
|
|
1077
1363
|
}
|
|
1078
1364
|
g.workers.push(w);
|
|
1079
1365
|
}
|
|
1366
|
+
for (const g of [...pools]) {
|
|
1367
|
+
if (!g.nested) continue;
|
|
1368
|
+
pools.splice(pools.indexOf(g), 1);
|
|
1369
|
+
const pkey = parentWorkerKey(sid, g.poolId);
|
|
1370
|
+
const l = nested.get(pkey) ?? [];
|
|
1371
|
+
if (!l.length) nested.set(pkey, l);
|
|
1372
|
+
l.push(g);
|
|
1373
|
+
}
|
|
1080
1374
|
const rimN = Math.max(pools.length + orphans.length, 1);
|
|
1081
1375
|
const rimR = Math.max(110, rimN * 15);
|
|
1082
|
-
|
|
1083
|
-
|
|
1376
|
+
// nested worker clusters hang ~120 world units outside the rim
|
|
1377
|
+
// (worker ring + sub-worker orbit + labels)
|
|
1378
|
+
maxR = Math.max(maxR, rimR + (nested.size ? 120 : 0));
|
|
1379
|
+
clusters.push({ sid, workers, pools, islands, hosted, orphans, nested, rimN, rimR });
|
|
1084
1380
|
}
|
|
1085
1381
|
const cell = maxR * 2 + 280;
|
|
1086
1382
|
const cols = Math.min(Math.max(clusters.length, 1), 4);
|
|
@@ -1099,123 +1395,78 @@ function fitTopo(canvas, world) {
|
|
|
1099
1395
|
topo.oy = (H - world.height * topo.scale) / 2;
|
|
1100
1396
|
}
|
|
1101
1397
|
|
|
1102
|
-
/**
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
solid: ['#4f88c6', 'So'],
|
|
1109
|
-
vanilla: ['#f0db4f', 'JS'],
|
|
1110
|
-
leaflet: ['#19993b', 'L'],
|
|
1398
|
+
/** Structural (non-island) nodes — a lettered disc, so pools and plain
|
|
1399
|
+
* workers never read as a framework island. */
|
|
1400
|
+
const ROLE_MARKS = {
|
|
1401
|
+
pool: { letter: 'P', fill: '#21262d', ring: '#8b949e88', text: '#8b949e' },
|
|
1402
|
+
worker: { letter: 'W', fill: '#30363d', ring: '#8b949e', text: '#c9d1d9' },
|
|
1403
|
+
subworker: { letter: 'W', fill: '#a371f733', ring: '#a371f7', text: '#d2a8ff' },
|
|
1111
1404
|
};
|
|
1112
1405
|
|
|
1113
|
-
|
|
1406
|
+
function drawRoleMark(ctx, m, x, y, s, dead) {
|
|
1407
|
+
ctx.save();
|
|
1408
|
+
if (dead) ctx.globalAlpha = 0.35;
|
|
1409
|
+
ctx.beginPath(); ctx.arc(x, y, s, 0, 7);
|
|
1410
|
+
ctx.fillStyle = m.fill; ctx.fill();
|
|
1411
|
+
ctx.strokeStyle = m.ring; ctx.lineWidth = s * 0.14; ctx.stroke();
|
|
1412
|
+
ctx.font = `bold ${s * 1.05}px ui-monospace, monospace`;
|
|
1413
|
+
ctx.fillStyle = m.text; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
|
1414
|
+
ctx.fillText(m.letter, x, y + s * 0.06);
|
|
1415
|
+
ctx.restore();
|
|
1416
|
+
ctx.textBaseline = 'top';
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
/** Framework logos (FW_LOGOS paths in their brand colours) on a disc, drawn
|
|
1420
|
+
* at icon size (s ≈ node radius). Logos too dark for the dashboard sit on
|
|
1421
|
+
* a light disc. Unknown tags fall back to a lettered disc. */
|
|
1422
|
+
const fwPaths = {};
|
|
1114
1423
|
function drawFwMark(ctx, fw, x, y, s, dead) {
|
|
1424
|
+
if (ROLE_MARKS[fw]) return drawRoleMark(ctx, ROLE_MARKS[fw], x, y, s, dead);
|
|
1425
|
+
const logo = FW_LOGOS[fw];
|
|
1115
1426
|
ctx.save();
|
|
1116
1427
|
if (dead) ctx.globalAlpha = 0.35;
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
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
|
-
}
|
|
1428
|
+
if (logo && typeof Path2D === 'function') {
|
|
1429
|
+
const n = parseInt(logo.hex.slice(1), 16);
|
|
1430
|
+
const lum = (0.299 * (n >> 16) + 0.587 * ((n >> 8) & 255) + 0.114 * (n & 255)) / 255;
|
|
1431
|
+
ctx.beginPath(); ctx.arc(x, y, s, 0, 7);
|
|
1432
|
+
ctx.fillStyle = lum < 0.35 ? '#f0f3f6' : '#22272e'; ctx.fill();
|
|
1433
|
+
const k = (s * 1.3) / 24;
|
|
1434
|
+
ctx.translate(x - 12 * k, y - 12 * k);
|
|
1435
|
+
ctx.scale(k, k);
|
|
1436
|
+
ctx.fillStyle = logo.hex;
|
|
1437
|
+
ctx.fill(fwPaths[fw] ??= new Path2D(logo.d));
|
|
1438
|
+
} else {
|
|
1439
|
+
ctx.beginPath(); ctx.arc(x, y, s * 0.85, 0, 7);
|
|
1440
|
+
ctx.fillStyle = dead ? '#484f58' : '#3fb950'; ctx.fill();
|
|
1441
|
+
ctx.font = `bold ${s * 0.9}px ui-monospace, monospace`;
|
|
1442
|
+
ctx.fillStyle = '#0d1117'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
|
1443
|
+
ctx.fillText(fw[0].toUpperCase(), x, y);
|
|
1202
1444
|
}
|
|
1203
1445
|
ctx.restore();
|
|
1204
1446
|
ctx.textBaseline = 'top';
|
|
1205
1447
|
}
|
|
1206
1448
|
|
|
1207
|
-
/** The island node — the framework mark IS the dot.
|
|
1208
|
-
*
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1449
|
+
/** The island node — the framework mark IS the dot. An untagged island
|
|
1450
|
+
* isn't a framework island, so it reads as its worker: a W (`role`
|
|
1451
|
+
* picks the nested variant). Ended islands dim. Returns the hit radius. */
|
|
1452
|
+
function drawIslandDot(ctx, i, x, y, inv, role = 'worker') {
|
|
1453
|
+
drawFwMark(ctx, i.fw?.toLowerCase() || role, x, y, 12 * inv, i.ended);
|
|
1454
|
+
return 12 * inv;
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
/** The session hub (main thread): the shell's framework mark in a violet
|
|
1458
|
+
* ring when the session reports one (`initDevtools({ session: { framework } })`),
|
|
1459
|
+
* else the plain violet disc. */
|
|
1460
|
+
function drawHub(ctx, sess, x, y, r, inv) {
|
|
1461
|
+
if (sess?.framework) {
|
|
1462
|
+
ctx.beginPath(); ctx.arc(x, y, r, 0, 7);
|
|
1463
|
+
ctx.fillStyle = '#0d1117'; ctx.fill();
|
|
1464
|
+
ctx.strokeStyle = '#a371f7'; ctx.lineWidth = 2 * inv; ctx.stroke();
|
|
1465
|
+
drawFwMark(ctx, sess.framework.toLowerCase(), x, y, r * 0.72, sess.closed === true);
|
|
1466
|
+
} else {
|
|
1467
|
+
ctx.beginPath(); ctx.arc(x, y, r, 0, 7);
|
|
1468
|
+
ctx.fillStyle = '#a371f7'; ctx.fill();
|
|
1214
1469
|
}
|
|
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
1470
|
}
|
|
1220
1471
|
|
|
1221
1472
|
function drawAppMap(canvas, sids) {
|
|
@@ -1253,8 +1504,7 @@ function drawAppMap(canvas, sids) {
|
|
|
1253
1504
|
const a = (j / Math.max(cl.islands.length, 1)) * 2 * Math.PI - Math.PI / 2;
|
|
1254
1505
|
drawIslandDot(ctx, i, cx + r * Math.cos(a), cy + r * Math.sin(a), mi);
|
|
1255
1506
|
});
|
|
1256
|
-
ctx
|
|
1257
|
-
ctx.fillStyle = '#a371f7'; ctx.fill();
|
|
1507
|
+
drawHub(ctx, sess, cx, cy, 8 * inv, inv);
|
|
1258
1508
|
ctx.font = `${11 * inv}px ui-monospace, monospace`;
|
|
1259
1509
|
ctx.fillStyle = '#c9d1d9';
|
|
1260
1510
|
ctx.fillText(sess?.name ?? cl.sid, cx, cy + 14 * inv);
|
|
@@ -1264,11 +1514,11 @@ function drawAppMap(canvas, sids) {
|
|
|
1264
1514
|
continue;
|
|
1265
1515
|
}
|
|
1266
1516
|
|
|
1267
|
-
|
|
1268
|
-
const
|
|
1269
|
-
// orbit lines — one per level, so pool/worker
|
|
1517
|
+
// outer ring — workers; an island-hosting worker IS its island(s)
|
|
1518
|
+
const workerR = cl.rimR + 38;
|
|
1519
|
+
// orbit lines — one per level, so pool/worker nodes sit ON them
|
|
1270
1520
|
ctx.strokeStyle = '#21262d'; ctx.lineWidth = 1 * inv;
|
|
1271
|
-
for (const r of [cl.rimR, workerR
|
|
1521
|
+
for (const r of [cl.rimR, workerR]) {
|
|
1272
1522
|
ctx.beginPath(); ctx.arc(cx, cy, r, 0, 7); ctx.stroke();
|
|
1273
1523
|
}
|
|
1274
1524
|
|
|
@@ -1279,83 +1529,248 @@ function drawAppMap(canvas, sids) {
|
|
|
1279
1529
|
ctx.strokeStyle = '#21262d';
|
|
1280
1530
|
ctx.lineWidth = 1 * inv;
|
|
1281
1531
|
for (const p of ppos) {
|
|
1532
|
+
if (p.dedicated) continue; // its spoke runs to the worker, drawn with it
|
|
1282
1533
|
ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(p.x, p.y); ctx.stroke();
|
|
1283
1534
|
}
|
|
1284
1535
|
|
|
1285
1536
|
const font = (px) => `${px * inv}px ui-monospace, monospace`;
|
|
1537
|
+
|
|
1538
|
+
const islandName = (ik) => ik.split('|').slice(1).join('|');
|
|
1539
|
+
|
|
1540
|
+
const anchors = new Map(); // island key → its node { x, y, r }: spawn links start here
|
|
1541
|
+
const badges = new Map(); // worker key → the W badge on a shared worker's ring
|
|
1542
|
+
|
|
1543
|
+
/** Untagged instance inside a shared worker — hollow disc + the app's initial. */
|
|
1544
|
+
const instanceMark = (ik) => ({
|
|
1545
|
+
letter: islandName(ik).split('~').pop()[0] ?? '?',
|
|
1546
|
+
fill: '#3fb9501f', ring: '#3fb950', text: '#3fb950',
|
|
1547
|
+
});
|
|
1548
|
+
|
|
1549
|
+
/** A label pushed outward from (x, y) along angle th, aligned to its side. */
|
|
1550
|
+
const labelOut = (text, x, y, th, dist, color) => {
|
|
1551
|
+
const c = Math.cos(th), s = Math.sin(th);
|
|
1552
|
+
ctx.save();
|
|
1553
|
+
ctx.font = font(10); ctx.fillStyle = color;
|
|
1554
|
+
ctx.textAlign = c > 0.35 ? 'left' : c < -0.35 ? 'right' : 'center';
|
|
1555
|
+
ctx.textBaseline = s > 0.35 ? 'top' : s < -0.35 ? 'bottom' : 'middle';
|
|
1556
|
+
ctx.fillText(text, x + dist * c, y + dist * s);
|
|
1557
|
+
ctx.restore();
|
|
1558
|
+
};
|
|
1559
|
+
|
|
1560
|
+
/**
|
|
1561
|
+
* A worker hosting ONE island is that island: its mark (or a W when
|
|
1562
|
+
* untagged) sits on the worker's spot. A worker hosting several
|
|
1563
|
+
* (one shared client) is a container: a ring with a W badge for the
|
|
1564
|
+
* worker itself, one node per instance inside (framework mark, or a
|
|
1565
|
+
* hollow initial when untagged), each labeled outward. Every instance
|
|
1566
|
+
* registers in `anchors` so a sub-island's spawn link starts at the
|
|
1567
|
+
* instance that spawned it. `ring` colors the sub-worker variant.
|
|
1568
|
+
* Returns the drawn radius.
|
|
1569
|
+
*/
|
|
1570
|
+
const drawIslandWorker = (k, isl, x, y, ring, wid) => {
|
|
1571
|
+
const w = state.workers.get(k);
|
|
1572
|
+
const n = isl.length;
|
|
1573
|
+
const m = 12 * inv;
|
|
1574
|
+
const lc = ring ? '#a371f7' : '#8b949e';
|
|
1575
|
+
if (n === 1) {
|
|
1576
|
+
const { ik, i } = isl[0];
|
|
1577
|
+
drawIslandDot(ctx, i, x, y, inv, ring ? 'subworker' : 'worker');
|
|
1578
|
+
nodes.push({ x, y, r: m + 4 * inv, kind: 'island', key: ik, wk: k });
|
|
1579
|
+
anchors.set(ik, { x, y, r: m });
|
|
1580
|
+
let R = m;
|
|
1581
|
+
if (ring) {
|
|
1582
|
+
R += 3 * inv;
|
|
1583
|
+
ctx.beginPath(); ctx.arc(x, y, R, 0, 7);
|
|
1584
|
+
ctx.strokeStyle = ring; ctx.lineWidth = 1.2 * inv; ctx.stroke();
|
|
1585
|
+
}
|
|
1586
|
+
if (full) labelWorker(isl, wid, x, y, R, lc);
|
|
1587
|
+
return R;
|
|
1588
|
+
}
|
|
1589
|
+
const cr = (m + 4 * inv) / Math.sin(Math.PI / n);
|
|
1590
|
+
const R = cr + m + 5 * inv;
|
|
1591
|
+
ctx.beginPath(); ctx.arc(x, y, R, 0, 7);
|
|
1592
|
+
ctx.fillStyle = '#0d111799'; ctx.fill();
|
|
1593
|
+
ctx.strokeStyle = ring ?? '#8b949e88'; ctx.lineWidth = 1.2 * inv; ctx.stroke();
|
|
1594
|
+
isl.forEach(({ ik, i }, j) => {
|
|
1595
|
+
const th = (j / n) * 2 * Math.PI - Math.PI / 2;
|
|
1596
|
+
const ix = x + cr * Math.cos(th), iy = y + cr * Math.sin(th);
|
|
1597
|
+
if (i.fw) drawIslandDot(ctx, i, ix, iy, inv);
|
|
1598
|
+
else drawRoleMark(ctx, instanceMark(ik), ix, iy, m, i.ended);
|
|
1599
|
+
nodes.push({ x: ix, y: iy, r: m + inv, kind: 'island', key: ik, wk: k });
|
|
1600
|
+
anchors.set(ik, { x: ix, y: iy, r: m, cx: x, cy: y });
|
|
1601
|
+
if (full) labelOut(islandName(ik), x, y, th, R + 5 * inv, '#c9d1d9');
|
|
1602
|
+
});
|
|
1603
|
+
// the worker itself — a W badge on the ring, in the first gap between instances
|
|
1604
|
+
const tb = -Math.PI / 2 + Math.PI / n;
|
|
1605
|
+
const bx = x + R * Math.cos(tb), by = y + R * Math.sin(tb), br = 7 * inv;
|
|
1606
|
+
drawFwMark(ctx, ring ? 'subworker' : 'worker', bx, by, br, w?.dead);
|
|
1607
|
+
nodes.push({ x: bx, y: by, r: br + 3 * inv, kind: 'worker', key: k });
|
|
1608
|
+
badges.set(k, { x: bx, y: by, r: br });
|
|
1609
|
+
if (full) labelOut(wid, bx, by, tb, br + 4 * inv, lc);
|
|
1610
|
+
return R;
|
|
1611
|
+
};
|
|
1612
|
+
|
|
1613
|
+
/** Under-node label for a 1:1 or plain worker: the island name (if any), then the worker id. */
|
|
1614
|
+
const labelWorker = (isl, wid, x, y, r, color = '#8b949e') => {
|
|
1615
|
+
let ly = y + r + 2 * inv;
|
|
1616
|
+
ctx.font = font(10);
|
|
1617
|
+
if (isl.length) {
|
|
1618
|
+
ctx.fillStyle = '#c9d1d9';
|
|
1619
|
+
// the spawn link already names a sub-island's parent: label its own segment
|
|
1620
|
+
ctx.fillText(islandName(isl[0].ik).split('~').pop(), x, ly);
|
|
1621
|
+
ly += 12 * inv;
|
|
1622
|
+
}
|
|
1623
|
+
ctx.fillStyle = color;
|
|
1624
|
+
ctx.fillText(wid, x, ly);
|
|
1625
|
+
};
|
|
1626
|
+
|
|
1627
|
+
/**
|
|
1628
|
+
* Nested workers: a pool minted inside a worker hangs off whatever
|
|
1629
|
+
* spawned it. A sub-island's key (`parent~app@N`) names the spawning
|
|
1630
|
+
* INSTANCE, so its sub-worker links from that instance's node; a
|
|
1631
|
+
* worker-level spawn (`connectSubWorker`, no island) links from the
|
|
1632
|
+
* host's W badge, or the host node itself. Sub-workers fan outward
|
|
1633
|
+
* from the anchor, nudged off the anchor's own outward label. Each is
|
|
1634
|
+
* a violet W, or its island(s) in a violet ring. Deeper pools recurse.
|
|
1635
|
+
*/
|
|
1636
|
+
const drawBranch = (pwKey, px, py, a, pr) => {
|
|
1637
|
+
const ws = (cl.nested.get(pwKey) ?? [])
|
|
1638
|
+
.flatMap((g) => g.workers.map(([k, w]) => ({ k, w, g })));
|
|
1639
|
+
if (!ws.length) return;
|
|
1640
|
+
const groups = new Map(); // anchor key → sub-workers it spawned
|
|
1641
|
+
for (const s of ws) {
|
|
1642
|
+
let anc = null;
|
|
1643
|
+
for (const { ik } of cl.hosted.get(s.k) ?? []) {
|
|
1644
|
+
const t = ik.lastIndexOf('~');
|
|
1645
|
+
if (t > 0) { anc = ik.slice(0, t); break; }
|
|
1646
|
+
}
|
|
1647
|
+
// an anchor sitting on the host itself (a 1:1 island worker) joins
|
|
1648
|
+
// the host's own fan, so siblings spread together instead of overlapping
|
|
1649
|
+
const at = anc ? anchors.get(anc) : undefined;
|
|
1650
|
+
const gk = at && Math.hypot(at.x - px, at.y - py) > 1 ? anc : '';
|
|
1651
|
+
const l = groups.get(gk) ?? [];
|
|
1652
|
+
if (!l.length) groups.set(gk, l);
|
|
1653
|
+
l.push(s);
|
|
1654
|
+
}
|
|
1655
|
+
for (const [gk, raw] of groups) {
|
|
1656
|
+
// widest sibling (an island container) in the middle of the fan,
|
|
1657
|
+
// smaller ones alternating outward, so the bulk points straight out
|
|
1658
|
+
const size = (s) => (cl.hosted.get(s.k) ?? []).length;
|
|
1659
|
+
const bySize = [...raw].sort((x, y) => size(y) - size(x));
|
|
1660
|
+
const list = new Array(bySize.length);
|
|
1661
|
+
const mid = (bySize.length - 1) >> 1;
|
|
1662
|
+
bySize.forEach((s, i) => { list[mid + (i % 2 ? (i + 1) >> 1 : -(i >> 1))] = s; });
|
|
1663
|
+
const an = gk ? anchors.get(gk) : (badges.get(pwKey) ?? { x: px, y: py, r: pr });
|
|
1664
|
+
const off = Math.hypot(an.x - px, an.y - py) > 1;
|
|
1665
|
+
const dir = off ? Math.atan2(an.y - py, an.x - px) : a;
|
|
1666
|
+
// Each sibling takes the angle its own footprint needs at its
|
|
1667
|
+
// distance (a container is wide and sits further out; a W plus its
|
|
1668
|
+
// label is narrow), packed around one centre line. One or two
|
|
1669
|
+
// siblings rotate off the anchor's outward label toward the upper
|
|
1670
|
+
// side (labels hang below); a bigger fan points straight out.
|
|
1671
|
+
const geo = list.map(({ k }) => {
|
|
1672
|
+
const isl = cl.hosted.get(k) ?? [];
|
|
1673
|
+
// a container is wider than one mark: push its centre out by its radius
|
|
1674
|
+
const ext = isl.length > 1 ? (16 * inv) / Math.sin(Math.PI / isl.length) + 5 * inv : 0;
|
|
1675
|
+
const dist = an.r + 56 + ext;
|
|
1676
|
+
const foot = (isl.length > 1 ? ext + 12 * inv : 7 * inv) + 14 * inv;
|
|
1677
|
+
return { isl, dist, hw: Math.asin(Math.min(0.95, foot / dist)) };
|
|
1678
|
+
});
|
|
1679
|
+
const total = geo.reduce((s, x) => s + 2 * x.hw, 0);
|
|
1680
|
+
const turn = list.length > 2 ? 0 : Math.max(0, Math.min(off ? 1 : 1.25, 1.4 - total / 2));
|
|
1681
|
+
const nudge = Math.sin(dir + turn) < Math.sin(dir - turn) ? turn : -turn;
|
|
1682
|
+
let cursor = dir + nudge - total / 2;
|
|
1683
|
+
list.forEach(({ k, w, g }, j) => {
|
|
1684
|
+
const { isl, dist, hw } = geo[j];
|
|
1685
|
+
const th = cursor + hw;
|
|
1686
|
+
cursor += 2 * hw;
|
|
1687
|
+
const nx = an.x + dist * Math.cos(th), ny = an.y + dist * Math.sin(th);
|
|
1688
|
+
const local = g.poolId.split('~').pop();
|
|
1689
|
+
const wid = g.dedicated ? local : `${local}#${k.split('|').pop()}`;
|
|
1690
|
+
let r;
|
|
1691
|
+
if (isl.length) r = drawIslandWorker(k, isl, nx, ny, '#a371f7aa', wid);
|
|
1692
|
+
else {
|
|
1693
|
+
r = 7 * inv;
|
|
1694
|
+
drawFwMark(ctx, 'subworker', nx, ny, r, w.dead);
|
|
1695
|
+
nodes.push({ x: nx, y: ny, r: r + 4 * inv, kind: 'worker', key: k });
|
|
1696
|
+
if (full) labelWorker([], wid, nx, ny, r, '#a371f7');
|
|
1697
|
+
}
|
|
1698
|
+
// spawn link — violet, anchor edge → sub-worker edge
|
|
1699
|
+
ctx.beginPath();
|
|
1700
|
+
ctx.moveTo(an.x + an.r * Math.cos(th), an.y + an.r * Math.sin(th));
|
|
1701
|
+
ctx.lineTo(nx - r * Math.cos(th), ny - r * Math.sin(th));
|
|
1702
|
+
ctx.strokeStyle = '#a371f799'; ctx.lineWidth = 1.4 * inv; ctx.stroke();
|
|
1703
|
+
drawBranch(k, nx, ny, th, r);
|
|
1704
|
+
});
|
|
1705
|
+
}
|
|
1706
|
+
};
|
|
1707
|
+
|
|
1286
1708
|
for (const p of ppos) {
|
|
1287
1709
|
const mark = Math.max(inv, 1);
|
|
1288
|
-
// pool — a
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1710
|
+
// pool — a P socket on the rim, the container for its workers. A
|
|
1711
|
+
// dedicated runner (workers: 1, no pool) has no socket: its worker
|
|
1712
|
+
// keeps the worker-orbit spot and the hub spoke runs straight to it.
|
|
1713
|
+
const poolR = p.dedicated ? 0 : 10 * mark;
|
|
1714
|
+
if (!p.dedicated) {
|
|
1715
|
+
drawFwMark(ctx, 'pool', p.x, p.y, poolR);
|
|
1716
|
+
if (full) {
|
|
1717
|
+
ctx.font = font(10); ctx.fillStyle = '#8b949e';
|
|
1718
|
+
ctx.fillText(p.poolId, p.x, p.y + poolR + 2 * inv);
|
|
1719
|
+
}
|
|
1296
1720
|
}
|
|
1297
1721
|
const n = p.workers.length;
|
|
1298
1722
|
p.workers.forEach(([k, w], j) => {
|
|
1299
1723
|
// workers spread around the pool's angle on the mid ring
|
|
1300
1724
|
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
1725
|
const isl = cl.hosted.get(k) ?? [];
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1726
|
+
// a shared-worker container is wider than one mark — push it clear of its pool
|
|
1727
|
+
const span = isl.length > 1 ? (16 * inv) / Math.sin(Math.PI / isl.length) + 17 * inv : 0;
|
|
1728
|
+
const wd = cl.rimR + Math.max(38, span + (p.dedicated ? 10 * mark : poolR) + 8 * inv);
|
|
1729
|
+
const wx = cx + wd * Math.cos(a2), wy = cy + wd * Math.sin(a2);
|
|
1730
|
+
const wid = p.dedicated ? p.poolId : `${p.poolId}#${k.split('|').pop()}`;
|
|
1731
|
+
let r;
|
|
1732
|
+
if (isl.length) r = drawIslandWorker(k, isl, wx, wy, undefined, wid);
|
|
1733
|
+
else {
|
|
1734
|
+
// plain worker — W, sized by heap when the probe reports it
|
|
1735
|
+
r = Math.max(8, 7 + (w.heap !== undefined ? Math.min(6, (w.heap / (80 << 20)) * 6) : 0)) * mark;
|
|
1736
|
+
drawFwMark(ctx, 'worker', wx, wy, r, w.dead);
|
|
1737
|
+
nodes.push({ x: wx, y: wy, r: r + 4 * inv, kind: 'worker', key: k });
|
|
1315
1738
|
}
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1739
|
+
// pool → worker link, edge to edge so it never crosses a cluster
|
|
1740
|
+
const d = Math.hypot(wx - p.x, wy - p.y) || 1;
|
|
1741
|
+
const ux = (wx - p.x) / d, uy = (wy - p.y) / d;
|
|
1742
|
+
if (p.dedicated) {
|
|
1743
|
+
ctx.beginPath();
|
|
1744
|
+
ctx.moveTo(cx, cy); ctx.lineTo(wx - Math.cos(a2) * r, wy - Math.sin(a2) * r);
|
|
1745
|
+
ctx.strokeStyle = '#21262d'; ctx.lineWidth = 1 * inv; ctx.stroke();
|
|
1746
|
+
} else if (d > poolR + r) {
|
|
1747
|
+
ctx.beginPath();
|
|
1748
|
+
ctx.moveTo(p.x + ux * poolR, p.y + uy * poolR); ctx.lineTo(wx - ux * r, wy - uy * r);
|
|
1749
|
+
ctx.strokeStyle = '#8b949e44'; ctx.lineWidth = 1.2 * inv; ctx.stroke();
|
|
1319
1750
|
}
|
|
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
|
-
});
|
|
1751
|
+
if (full && !isl.length) labelWorker([], wid, wx, wy, r);
|
|
1752
|
+
// nested pools branch off this worker — sub-workers / sub-islands
|
|
1753
|
+
drawBranch(k, wx, wy, a2, r);
|
|
1339
1754
|
});
|
|
1340
1755
|
}
|
|
1756
|
+
// islands whose worker never reported take a worker slot of their own
|
|
1341
1757
|
cl.orphans.forEach(({ ik, i }, j) => {
|
|
1342
1758
|
const a = ((cl.pools.length + j) / cl.rimN) * 2 * Math.PI - Math.PI / 2;
|
|
1343
|
-
const ix = cx +
|
|
1759
|
+
const ix = cx + workerR * Math.cos(a), iy = cy + workerR * Math.sin(a);
|
|
1344
1760
|
const r = drawIslandDot(ctx, i, ix, iy, inv);
|
|
1345
1761
|
if (full) {
|
|
1346
|
-
ctx.fillStyle = '#
|
|
1347
|
-
ctx.fillText(ik
|
|
1762
|
+
ctx.font = font(10); ctx.fillStyle = '#c9d1d9';
|
|
1763
|
+
ctx.fillText(islandName(ik), ix, iy + r + 2 * inv);
|
|
1348
1764
|
}
|
|
1349
1765
|
nodes.push({ x: ix, y: iy, r: r + 4 * inv, kind: 'island', key: ik });
|
|
1350
1766
|
});
|
|
1351
1767
|
|
|
1352
|
-
ctx
|
|
1353
|
-
ctx.fillStyle = '#a371f7'; ctx.fill();
|
|
1768
|
+
drawHub(ctx, sess, cx, cy, 16 * inv, inv);
|
|
1354
1769
|
ctx.font = font(11);
|
|
1355
1770
|
ctx.fillStyle = '#c9d1d9';
|
|
1356
1771
|
ctx.fillText(sess?.name ?? cl.sid, cx, cy + 19 * inv);
|
|
1357
1772
|
ctx.fillStyle = '#8b949e';
|
|
1358
|
-
ctx.fillText('main thread', cx, cy + 32 * inv);
|
|
1773
|
+
ctx.fillText(sess?.framework ? `main thread · ${sess.framework}` : 'main thread', cx, cy + 32 * inv);
|
|
1359
1774
|
if (!cl.pools.length && !cl.orphans.length) ctx.fillText('no workers or islands yet', cx, cy + 46 * inv);
|
|
1360
1775
|
// the session hub is clickable too — same drill-in as the far-zoom disc
|
|
1361
1776
|
nodes.push({ x: cx, y: cy, r: 16 * inv, kind: 'session', key: cl.sid });
|
|
@@ -1364,7 +1779,7 @@ function drawAppMap(canvas, sids) {
|
|
|
1364
1779
|
// selection highlight — ring whatever is under inspection / selected
|
|
1365
1780
|
for (const n of nodes) {
|
|
1366
1781
|
const sel = (n.kind === 'worker' && n.key === state.inspectWorker)
|
|
1367
|
-
|| (n.kind === 'island' && n.key === state.inspect)
|
|
1782
|
+
|| (n.kind === 'island' && (n.key === state.inspect || (n.wk && n.wk === state.inspectWorker)))
|
|
1368
1783
|
|| (n.kind === 'session' && n.key === state.sel);
|
|
1369
1784
|
if (!sel) continue;
|
|
1370
1785
|
const rr = n.r + 2.5 * inv;
|
|
@@ -1409,7 +1824,7 @@ function renderWorkers() {
|
|
|
1409
1824
|
<td class="${w.heap !== undefined ? '' : 'muted'}">${w.heap !== undefined ? fmtBytes(w.heap) : '—'}</td>
|
|
1410
1825
|
<td>${hosted.length
|
|
1411
1826
|
? `<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('
|
|
1827
|
+
: `<span class="muted" title="no hosted islands — plain worker">${fwIconImg('worker')}</span>`}</td>
|
|
1413
1828
|
<td class="muted">${esc(w.lastTaskId || '—')}</td>
|
|
1414
1829
|
<td class="${w.dead ? 'error' : 'ok'}">${w.dead ? 'down' : 'live'}</td></tr>`;
|
|
1415
1830
|
}) || '<tr><td colspan="10" class="muted">no workers yet</td></tr>';
|
|
@@ -1492,6 +1907,8 @@ function renderWorkerInsp() {
|
|
|
1492
1907
|
$('wkErrors').innerHTML = w.errors.length
|
|
1493
1908
|
? 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
1909
|
: '<div class="muted">none</div>';
|
|
1910
|
+
|
|
1911
|
+
for (const h of api.hooks.inspectWorker) h(k, w, { actions: $('wkActions') });
|
|
1495
1912
|
}
|
|
1496
1913
|
|
|
1497
1914
|
/* ── render dispatch ─────────────────────────────────────────────────────── */
|
|
@@ -1541,6 +1958,19 @@ function render() {
|
|
|
1541
1958
|
render();
|
|
1542
1959
|
};
|
|
1543
1960
|
}
|
|
1961
|
+
for (const h of api.hooks.render) h();
|
|
1962
|
+
updateBadges();
|
|
1963
|
+
}
|
|
1964
|
+
|
|
1965
|
+
/** Nav badges for registered views (`addView({ badge })`): counts, alerts. */
|
|
1966
|
+
function updateBadges() {
|
|
1967
|
+
for (const v of registeredViews) {
|
|
1968
|
+
if (!v.badge) continue;
|
|
1969
|
+
const el = document.querySelector(`.panels > nav button[data-view="${v.id}"] .nbadge`);
|
|
1970
|
+
if (!el) continue;
|
|
1971
|
+
const b = v.badge();
|
|
1972
|
+
el.textContent = b === null || b === undefined || b === 0 ? '' : String(b);
|
|
1973
|
+
}
|
|
1544
1974
|
}
|
|
1545
1975
|
|
|
1546
1976
|
let raf = 0;
|
|
@@ -1557,16 +1987,89 @@ setInterval(() => {
|
|
|
1557
1987
|
if ($('view-memory').classList.contains('on')) renderMemory();
|
|
1558
1988
|
if ($('view-dashboard').classList.contains('on')) renderTopology();
|
|
1559
1989
|
if (BC) sweepClosed();
|
|
1990
|
+
for (const h of api.hooks.tick) h();
|
|
1560
1991
|
}, 1000);
|
|
1561
1992
|
|
|
1562
1993
|
/* ── tabs + waterfall interaction ────────────────────────────────────────── */
|
|
1563
1994
|
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
};
|
|
1995
|
+
/** Switch the main view; panels and the palette route through this. */
|
|
1996
|
+
function openView(id) {
|
|
1997
|
+
const btn = document.querySelector(`.panels > nav button[data-view="${id}"]`);
|
|
1998
|
+
if (!btn) return;
|
|
1999
|
+
document.querySelectorAll('.panels > nav button').forEach((x) => x.classList.toggle('on', x === btn));
|
|
2000
|
+
document.querySelectorAll('.view').forEach((v) => v.classList.toggle('on', v.id === `view-${id}`));
|
|
2001
|
+
render();
|
|
2002
|
+
navigated();
|
|
2003
|
+
}
|
|
2004
|
+
|
|
2005
|
+
/** Fire api.onNavigate hooks (routing, nav layout) after a view/subview switch. */
|
|
2006
|
+
function navigated() {
|
|
2007
|
+
for (const h of api.hooks.navigate ?? []) h();
|
|
2008
|
+
}
|
|
2009
|
+
|
|
2010
|
+
// Delegated, so views/subviews panels add later get the same handlers.
|
|
2011
|
+
document.querySelector('.panels > nav').addEventListener('click', (e) => {
|
|
2012
|
+
const b = e.target.closest('button[data-view]');
|
|
2013
|
+
if (b) openView(b.dataset.view);
|
|
2014
|
+
});
|
|
2015
|
+
|
|
2016
|
+
/** Registered panel views, in nav order. */
|
|
2017
|
+
const registeredViews = [];
|
|
2018
|
+
const CORE_VIEW_ORDER = { dashboard: 10, tasks: 20, network: 30, memory: 40, log: 90 };
|
|
2019
|
+
const viewOrder = (b) => Number(b.dataset.order ?? CORE_VIEW_ORDER[b.dataset.view] ?? 50);
|
|
2020
|
+
|
|
2021
|
+
/**
|
|
2022
|
+
* Add a top-level view: a nav button (ordered by `order`; core views are
|
|
2023
|
+
* 10 dashboard, 20 tasks, 30 network, 40 memory, 90 log) and a section.
|
|
2024
|
+
* `badge()` feeds a small count on the nav button each render.
|
|
2025
|
+
*/
|
|
2026
|
+
function addView({ id, label, order = 50, html = '', badge, title }) {
|
|
2027
|
+
const nav = document.querySelector('.panels > nav');
|
|
2028
|
+
const btn = document.createElement('button');
|
|
2029
|
+
btn.dataset.view = id;
|
|
2030
|
+
btn.dataset.order = String(order);
|
|
2031
|
+
btn.innerHTML = `${esc(label)}<span class="nbadge"></span>`;
|
|
2032
|
+
if (title) btn.title = title;
|
|
2033
|
+
const after = [...nav.querySelectorAll('button[data-view]')].find((b) => viewOrder(b) > order);
|
|
2034
|
+
nav.insertBefore(btn, after ?? null);
|
|
2035
|
+
const section = document.createElement('section');
|
|
2036
|
+
section.className = 'view';
|
|
2037
|
+
section.id = `view-${id}`;
|
|
2038
|
+
section.innerHTML = html;
|
|
2039
|
+
document.querySelector('.panels').appendChild(section);
|
|
2040
|
+
registeredViews.push({ id, label, order, badge });
|
|
2041
|
+
return section;
|
|
2042
|
+
}
|
|
2043
|
+
|
|
2044
|
+
/**
|
|
2045
|
+
* Add a subview (sub-tab) under a view. A view without a subnav gets one,
|
|
2046
|
+
* and its existing content becomes the first subview (`<view>-main`).
|
|
2047
|
+
*/
|
|
2048
|
+
function addSubview(viewId, { id, label, html = '', order = 50 }) {
|
|
2049
|
+
const view = $(`view-${viewId}`);
|
|
2050
|
+
let subnav = view.querySelector(':scope > .subnav');
|
|
2051
|
+
if (!subnav) {
|
|
2052
|
+
const main = document.createElement('div');
|
|
2053
|
+
main.className = 'subview on';
|
|
2054
|
+
main.id = `${viewId}-main`;
|
|
2055
|
+
main.append(...view.childNodes);
|
|
2056
|
+
subnav = document.createElement('nav');
|
|
2057
|
+
subnav.className = 'subnav';
|
|
2058
|
+
subnav.innerHTML = `<button class="on" data-sub="${viewId}-main" data-order="0">${esc(registeredViews.find((v) => v.id === viewId)?.label ?? 'Main')}</button>`;
|
|
2059
|
+
view.append(subnav, main);
|
|
2060
|
+
}
|
|
2061
|
+
const btn = document.createElement('button');
|
|
2062
|
+
btn.dataset.sub = id;
|
|
2063
|
+
btn.dataset.order = String(order);
|
|
2064
|
+
btn.textContent = label;
|
|
2065
|
+
const after = [...subnav.querySelectorAll('button[data-sub]')].find((b) => Number(b.dataset.order ?? 50) > order);
|
|
2066
|
+
subnav.insertBefore(btn, after ?? null);
|
|
2067
|
+
const div = document.createElement('div');
|
|
2068
|
+
div.className = 'subview';
|
|
2069
|
+
div.id = id;
|
|
2070
|
+
div.innerHTML = html;
|
|
2071
|
+
view.appendChild(div);
|
|
2072
|
+
return div;
|
|
1570
2073
|
}
|
|
1571
2074
|
|
|
1572
2075
|
/* ── app map interaction — wheel zoom, drag pan, click/double-click ─────── */
|
|
@@ -1649,18 +2152,20 @@ if (new URLSearchParams(location.search).has('mini')) {
|
|
|
1649
2152
|
document.getElementById('tv-dash').classList.remove('on');
|
|
1650
2153
|
}
|
|
1651
2154
|
|
|
1652
|
-
|
|
1653
|
-
b
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
2155
|
+
document.addEventListener('click', (e) => {
|
|
2156
|
+
const b = e.target.closest?.('.subnav button[data-sub]');
|
|
2157
|
+
if (!b) return;
|
|
2158
|
+
const nav = b.closest('.subnav');
|
|
2159
|
+
nav.querySelectorAll('button').forEach((x) => x.classList.toggle('on', x === b));
|
|
2160
|
+
nav.parentElement.querySelectorAll(':scope > .subview')
|
|
2161
|
+
.forEach((v) => v.classList.toggle('on', v.id === b.dataset.sub));
|
|
2162
|
+
render();
|
|
2163
|
+
navigated();
|
|
2164
|
+
});
|
|
1661
2165
|
|
|
1662
2166
|
/** Open a subtab under a main view — switches main tabs first if needed. */
|
|
1663
2167
|
const openSub = (view, sub) => {
|
|
2168
|
+
if (!document.getElementById(`view-${view}`)) return;
|
|
1664
2169
|
if (!document.getElementById(`view-${view}`).classList.contains('on')) {
|
|
1665
2170
|
document.querySelector(`.panels > nav button[data-view="${view}"]`).click();
|
|
1666
2171
|
}
|
|
@@ -1714,10 +2219,17 @@ let viewer = null;
|
|
|
1714
2219
|
* BroadcastChannel the app publishes on. There is no server-side session
|
|
1715
2220
|
* registry — this page builds it from hello/batch/bye frames and owns
|
|
1716
2221
|
* retention (pins + TTL) itself.
|
|
2222
|
+
*
|
|
2223
|
+
* Extension mode (`__ATOLL_TRANSPORT === 'extension'`): the Chrome DevTools
|
|
2224
|
+
* panel (packages/devtools-extension) can't join the inspected page's
|
|
2225
|
+
* channel, so its bootstrap installs `__ATOLL_BRIDGE`, a BroadcastChannel-
|
|
2226
|
+
* shaped relay (postMessage / onmessage, plus onreset when the inspected
|
|
2227
|
+
* page navigates). Everything below treats it exactly like the channel.
|
|
1717
2228
|
*/
|
|
1718
|
-
const
|
|
2229
|
+
const BRIDGE = window.__ATOLL_TRANSPORT === 'extension' ? window.__ATOLL_BRIDGE ?? null : null;
|
|
2230
|
+
const BC = BRIDGE ?? (window.__ATOLL_TRANSPORT === 'broadcast' && typeof BroadcastChannel !== 'undefined'
|
|
1719
2231
|
? new BroadcastChannel('atoll-devtools')
|
|
1720
|
-
: null;
|
|
2232
|
+
: null);
|
|
1721
2233
|
// ?mini=1 — the overlay flyout: compact single-instance layout (CSS does
|
|
1722
2234
|
// the hiding; the session sidebar is meaningless for one origin's app).
|
|
1723
2235
|
if (new URLSearchParams(location.search).has('mini')) document.body.classList.add('mini');
|
|
@@ -1765,12 +2277,105 @@ const pinSession = (id, pinned) => {
|
|
|
1765
2277
|
}
|
|
1766
2278
|
};
|
|
1767
2279
|
|
|
2280
|
+
/**
|
|
2281
|
+
* The one ingest path — live transport frames and replayed recordings both
|
|
2282
|
+
* come through here, so panels see a single stream via api.hooks.
|
|
2283
|
+
*/
|
|
2284
|
+
function ingest(session, events) {
|
|
2285
|
+
state.sessions.set(session.id, session);
|
|
2286
|
+
for (const e of events) {
|
|
2287
|
+
applyEvent(session, e);
|
|
2288
|
+
logLine(session, e);
|
|
2289
|
+
for (const h of api.hooks.event) h(session, e);
|
|
2290
|
+
// New runners / islands / contracts register new app-side commands.
|
|
2291
|
+
if (e.type === 'pool:init' || e.type === 'island:mount' || e.type === 'memory:bind') {
|
|
2292
|
+
refreshCommands(session.id);
|
|
2293
|
+
}
|
|
2294
|
+
}
|
|
2295
|
+
for (const h of api.hooks.batch) h(session, events);
|
|
2296
|
+
scheduleRender();
|
|
2297
|
+
}
|
|
2298
|
+
|
|
2299
|
+
/* ── control channel: dashboard → app commands ───────────────────────────── */
|
|
2300
|
+
|
|
2301
|
+
const pendingCtl = new Map(); // request id → { resolve, reject, timer }
|
|
2302
|
+
|
|
2303
|
+
/** Invoke a registered app-side command (`registerDevtoolsCommand`) on a live session. */
|
|
2304
|
+
function control(sessionId, cmd, args = {}, timeoutMs = 5000) {
|
|
2305
|
+
const id = `c${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
|
|
2306
|
+
return new Promise((resolve, reject) => {
|
|
2307
|
+
const msg = { type: 'control', sessionId, id, cmd, args };
|
|
2308
|
+
if (BC) BC.postMessage(msg);
|
|
2309
|
+
else if (viewer?.readyState === WebSocket.OPEN) viewer.send(JSON.stringify(msg));
|
|
2310
|
+
else return reject(new Error('dashboard is not connected'));
|
|
2311
|
+
const timer = setTimeout(() => {
|
|
2312
|
+
pendingCtl.delete(id);
|
|
2313
|
+
reject(new Error(`${cmd}: no reply in ${timeoutMs}ms (app may predate the control channel)`));
|
|
2314
|
+
}, timeoutMs);
|
|
2315
|
+
pendingCtl.set(id, { resolve, reject, timer });
|
|
2316
|
+
});
|
|
2317
|
+
}
|
|
2318
|
+
|
|
2319
|
+
function settleControl(data) {
|
|
2320
|
+
const p = pendingCtl.get(data.id);
|
|
2321
|
+
if (!p) return; // another dashboard's request
|
|
2322
|
+
pendingCtl.delete(data.id);
|
|
2323
|
+
clearTimeout(p.timer);
|
|
2324
|
+
if (data.ok) p.resolve(data.result); else p.reject(new Error(data.error ?? 'command failed'));
|
|
2325
|
+
}
|
|
2326
|
+
|
|
2327
|
+
/** sid → Set of command names, from 'devtools.commands' (refreshed as runners appear). */
|
|
2328
|
+
const cmdCache = new Map();
|
|
2329
|
+
const cmdDirty = new Set();
|
|
2330
|
+
let cmdTimer = 0;
|
|
2331
|
+
function refreshCommands(sid) {
|
|
2332
|
+
if (state.sessions.get(sid)?.closed || api.livePaused) return;
|
|
2333
|
+
cmdDirty.add(sid);
|
|
2334
|
+
clearTimeout(cmdTimer);
|
|
2335
|
+
cmdTimer = setTimeout(() => {
|
|
2336
|
+
for (const s of cmdDirty) {
|
|
2337
|
+
control(s, 'devtools.commands', {}, 3000)
|
|
2338
|
+
.then((list) => { cmdCache.set(s, new Set(list)); scheduleRender(); })
|
|
2339
|
+
.catch(() => { /* app without the control channel: commands stay off */ });
|
|
2340
|
+
}
|
|
2341
|
+
cmdDirty.clear();
|
|
2342
|
+
}, 400);
|
|
2343
|
+
}
|
|
2344
|
+
const hasCommand = (sid, cmd) => cmdCache.get(sid)?.has(cmd) === true;
|
|
2345
|
+
|
|
2346
|
+
const STATE_INIT = { sel: null, drillTask: null, inspect: null, inspectWorker: null, inspectFetch: null, seq: 0, t0: null };
|
|
2347
|
+
|
|
2348
|
+
/** Clear every dashboard aggregate (the recorder's import/replay starts here). */
|
|
2349
|
+
function reset() {
|
|
2350
|
+
for (const v of Object.values(state)) {
|
|
2351
|
+
if (v instanceof Map) v.clear();
|
|
2352
|
+
else if (Array.isArray(v)) v.length = 0;
|
|
2353
|
+
}
|
|
2354
|
+
Object.assign(state, STATE_INIT);
|
|
2355
|
+
bcSessions.clear();
|
|
2356
|
+
cmdCache.clear();
|
|
2357
|
+
logEl().innerHTML = '';
|
|
2358
|
+
for (const h of api.hooks.reset) h();
|
|
2359
|
+
topo.fit = true;
|
|
2360
|
+
render();
|
|
2361
|
+
}
|
|
2362
|
+
|
|
2363
|
+
/** Ask live apps to re-identify and replay their tail (after a reset/unpause). */
|
|
2364
|
+
function reannounce() {
|
|
2365
|
+
if (BC) BC.postMessage({ type: 'view' });
|
|
2366
|
+
else viewer?.close(); // reconnect → the server replays its tail
|
|
2367
|
+
}
|
|
2368
|
+
|
|
1768
2369
|
function connect() {
|
|
1769
2370
|
if (BC) {
|
|
1770
|
-
|
|
1771
|
-
|
|
2371
|
+
if (!BRIDGE) { // the extension bridge owns #conn (page reachable, atoll seen, …)
|
|
2372
|
+
$('conn').textContent = 'local';
|
|
2373
|
+
$('conn').className = 'on';
|
|
2374
|
+
}
|
|
1772
2375
|
BC.onmessage = (msg) => {
|
|
1773
2376
|
const data = msg.data;
|
|
2377
|
+
if (data.type === 'control-result') return settleControl(data);
|
|
2378
|
+
if (api.livePaused) return;
|
|
1774
2379
|
if (data.type === 'hello') {
|
|
1775
2380
|
// a fresh hello means a new app instance — same id can't revive
|
|
1776
2381
|
const prev = bcSessions.get(data.session.id);
|
|
@@ -1778,6 +2383,7 @@ function connect() {
|
|
|
1778
2383
|
...data.session, closed: false, closedAt: undefined, pinned: prev?.pinned,
|
|
1779
2384
|
});
|
|
1780
2385
|
bcSync();
|
|
2386
|
+
refreshCommands(data.session.id);
|
|
1781
2387
|
} else if (data.type === 'bye') {
|
|
1782
2388
|
const s = bcSessions.get(data.sessionId);
|
|
1783
2389
|
if (s && !s.closed) { s.closed = true; s.closedAt = Date.now(); bcSync(); }
|
|
@@ -1786,14 +2392,12 @@ function connect() {
|
|
|
1786
2392
|
bcSessions.set(data.session.id, data.session);
|
|
1787
2393
|
bcSync();
|
|
1788
2394
|
}
|
|
1789
|
-
|
|
1790
|
-
for (const e of data.events) {
|
|
1791
|
-
applyEvent(data.session, e);
|
|
1792
|
-
logLine(data.session, e);
|
|
1793
|
-
}
|
|
2395
|
+
ingest(data.session, data.events);
|
|
1794
2396
|
}
|
|
1795
2397
|
scheduleRender();
|
|
1796
2398
|
};
|
|
2399
|
+
// inspected page navigated: its sessions are gone, start over and re-announce
|
|
2400
|
+
if (BRIDGE) BRIDGE.onreset = () => { reset(); reannounce(); };
|
|
1797
2401
|
// announce — apps re-hello and replay their batch tail
|
|
1798
2402
|
BC.postMessage({ type: 'view' });
|
|
1799
2403
|
return;
|
|
@@ -1808,16 +2412,87 @@ function connect() {
|
|
|
1808
2412
|
};
|
|
1809
2413
|
ws.onmessage = (msg) => {
|
|
1810
2414
|
const data = JSON.parse(msg.data);
|
|
2415
|
+
if (data.type === 'control-result') return settleControl(data);
|
|
2416
|
+
if (api.livePaused) return;
|
|
1811
2417
|
if (data.type === 'sessions') {
|
|
1812
2418
|
reconcileSessions(data.sessions);
|
|
2419
|
+
for (const s of data.sessions) if (!s.closed && !cmdCache.has(s.id)) refreshCommands(s.id);
|
|
1813
2420
|
} else if (data.type === 'batch') {
|
|
1814
|
-
|
|
1815
|
-
for (const e of data.events) {
|
|
1816
|
-
applyEvent(data.session, e);
|
|
1817
|
-
logLine(data.session, e);
|
|
1818
|
-
}
|
|
2421
|
+
ingest(data.session, data.events);
|
|
1819
2422
|
}
|
|
1820
2423
|
scheduleRender();
|
|
1821
2424
|
};
|
|
1822
2425
|
}
|
|
2426
|
+
|
|
2427
|
+
/* ── panels ──────────────────────────────────────────────────────────────── */
|
|
2428
|
+
|
|
2429
|
+
const kpi = (v, l, cls = '') => `<div class="kpi ${cls}"><div class="v">${v}</div><div class="l">${l}</div></div>`;
|
|
2430
|
+
|
|
2431
|
+
Object.assign(api, {
|
|
2432
|
+
state, $, esc, fmt, fmtBytes, fmtInt, key, sessOf, inSel, withGroups, kpi,
|
|
2433
|
+
sparkline, drawLine, drawMultiLine, fwIconImg,
|
|
2434
|
+
render, scheduleRender, addView, addSubview, openView, openSub,
|
|
2435
|
+
control, hasCommand, ingest, reset, reannounce,
|
|
2436
|
+
liveSessions: () => [...state.sessions.values()].filter((s) => !s.closed),
|
|
2437
|
+
/** The selected session, else the only live one, else null (ambiguous). */
|
|
2438
|
+
selectedOrOnlyLive: () => {
|
|
2439
|
+
if (state.sel) return state.sel;
|
|
2440
|
+
const live = [...state.sessions.values()].filter((s) => !s.closed);
|
|
2441
|
+
return live.length === 1 ? live[0].id : null;
|
|
2442
|
+
},
|
|
2443
|
+
isMini: new URLSearchParams(location.search).has('mini'),
|
|
2444
|
+
transport: BRIDGE ? 'extension' : BC ? 'broadcast' : 'ws',
|
|
2445
|
+
livePaused: false,
|
|
2446
|
+
/** () after every view/subview switch (openView, subnav clicks, openSub). */
|
|
2447
|
+
onNavigate(fn) { (api.hooks.navigate ??= []).push(fn); },
|
|
2448
|
+
/** viewId → intro html; the shell panel renders these under each view heading. */
|
|
2449
|
+
viewIntros: new Map(),
|
|
2450
|
+
/** One-line, dismissible "what this view shows" intro (callable before the shell loads). */
|
|
2451
|
+
setViewIntro(viewId, html) {
|
|
2452
|
+
api.viewIntros.set(viewId, html);
|
|
2453
|
+
for (const h of api.hooks.intro ?? []) h(viewId, html);
|
|
2454
|
+
},
|
|
2455
|
+
/** Transient message: kind 'ok' | 'error' | 'info' (panels/toast.js). */
|
|
2456
|
+
toast: (msg, kind, ms) => import('./panels/toast.js').then((m) => m.toast(msg, kind, ms)),
|
|
2457
|
+
});
|
|
2458
|
+
|
|
2459
|
+
/**
|
|
2460
|
+
* Header toolbar button (global actions: record, export, palette). Returns
|
|
2461
|
+
* the button; `order` sorts left→right.
|
|
2462
|
+
*/
|
|
2463
|
+
api.addToolbarButton = ({ id, label, title, order = 50, onClick }) => {
|
|
2464
|
+
const bar = $('toolbar');
|
|
2465
|
+
const b = document.createElement('button');
|
|
2466
|
+
b.className = 'act';
|
|
2467
|
+
b.id = id;
|
|
2468
|
+
b.dataset.order = String(order);
|
|
2469
|
+
b.innerHTML = label;
|
|
2470
|
+
if (title) b.title = title;
|
|
2471
|
+
b.onclick = onClick;
|
|
2472
|
+
const after = [...bar.children].find((c) => Number(c.dataset.order ?? 50) > order);
|
|
2473
|
+
bar.insertBefore(b, after ?? null);
|
|
2474
|
+
return b;
|
|
2475
|
+
};
|
|
2476
|
+
|
|
2477
|
+
/** Feature panels: each module exports setup(api). Order = setup order. */
|
|
2478
|
+
const PANELS = [
|
|
2479
|
+
'./panels/performance.js', // jank, frames, User Timing, message cost
|
|
2480
|
+
'./panels/inspect-island.js',// island Elements / Props & events tabs
|
|
2481
|
+
'./panels/memory-values.js', // shared-memory values, diff, watchpoints
|
|
2482
|
+
'./panels/reactivity.js', // cross-thread reactivity graph
|
|
2483
|
+
'./panels/actions.js', // live controls: kill, chaos, props, mode
|
|
2484
|
+
'./panels/recorder.js', // record / export / import / replay
|
|
2485
|
+
'./panels/audits.js', // recommendations
|
|
2486
|
+
'./panels/shell.js', // palette, routing, shortcuts, help (load last)
|
|
2487
|
+
];
|
|
2488
|
+
|
|
2489
|
+
for (const p of PANELS) {
|
|
2490
|
+
try {
|
|
2491
|
+
const mod = await import(p);
|
|
2492
|
+
mod.setup(api);
|
|
2493
|
+
} catch (err) {
|
|
2494
|
+
console.error(`[atoll devtools] panel ${p} failed to load`, err);
|
|
2495
|
+
}
|
|
2496
|
+
}
|
|
2497
|
+
|
|
1823
2498
|
connect();
|