@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
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
// Performance view: main-thread responsiveness (runtime:longframe,
|
|
2
|
+
// runtime:frames from the jank probe) and message cost (argBytes /
|
|
3
|
+
// resultBytes on task events, island:ops.bytes).
|
|
4
|
+
//
|
|
5
|
+
// Time basis: event `at` is the emitting thread's clock, so every series
|
|
6
|
+
// here buckets on the dashboard clock (performance.now() at ingest), the
|
|
7
|
+
// same rule heapSeries/tput follow in main.js.
|
|
8
|
+
|
|
9
|
+
const WINDOW_MS = 60_000;
|
|
10
|
+
const MAX_FRAMES = 1000; // long-frame records kept (all sessions)
|
|
11
|
+
const MAX_FPS = 240; // fps samples kept per session (~4 min)
|
|
12
|
+
const WORST_ROWS = 15;
|
|
13
|
+
const COST_ROWS = 30;
|
|
14
|
+
const FPS_COLORS = ['#3fb950', '#79c0ff', '#a371f7', '#56d4dd', '#e275ad', '#f0883e'];
|
|
15
|
+
|
|
16
|
+
const HTML = `
|
|
17
|
+
<h2>Main thread <span class="muted">— long frames and frame rate, last 60s</span></h2>
|
|
18
|
+
<div class="kpis" id="pf-kpis"></div>
|
|
19
|
+
<div id="pf-empty" class="muted" style="margin:8px 0"></div>
|
|
20
|
+
<canvas id="pf-timeline" style="width:100%;height:150px;display:block;margin-top:10px;border:1px solid #21262d;border-radius:8px;background:#010409"></canvas>
|
|
21
|
+
<div class="legend" style="margin-top:4px">
|
|
22
|
+
<span><i style="background:#d29922"></i>long frame (height = duration)</span>
|
|
23
|
+
<span><i style="background:#f85149"></i>blocking ≥ 100ms</span>
|
|
24
|
+
<span><i style="background:#3fb950"></i>fps</span>
|
|
25
|
+
<span><i style="background:#6e7681"></i>background (page hidden, ≥ 100ms script)</span>
|
|
26
|
+
<span class="muted" id="pf-legend-extra"></span>
|
|
27
|
+
</div>
|
|
28
|
+
<h2 style="margin-top:14px">Worst long frames <span class="muted">— retained frames, longest first; scripts from Long Animation Frame attribution</span></h2>
|
|
29
|
+
<table id="pf-worst"><thead><tr>
|
|
30
|
+
<th>session</th><th>when</th><th>duration</th><th>blocking</th><th>top scripts</th>
|
|
31
|
+
</tr></thead><tbody></tbody></table>
|
|
32
|
+
<h2 style="margin-top:14px">Message cost <span class="muted">— estimated structured-clone size per message</span></h2>
|
|
33
|
+
<div class="grid2">
|
|
34
|
+
<div>
|
|
35
|
+
<h2>Tasks <span class="muted">— args posted / result returned</span></h2>
|
|
36
|
+
<table id="pf-tasks"><thead><tr>
|
|
37
|
+
<th>task</th><th>calls</th><th>args avg</th><th>args max</th><th>result avg</th><th>result max</th>
|
|
38
|
+
</tr></thead><tbody></tbody></table>
|
|
39
|
+
</div>
|
|
40
|
+
<div>
|
|
41
|
+
<h2>Islands <span class="muted">— op batches worker → main</span></h2>
|
|
42
|
+
<table id="pf-islands"><thead><tr>
|
|
43
|
+
<th>instance</th><th>batches</th><th>avg</th><th>max</th><th>total</th>
|
|
44
|
+
</tr></thead><tbody></tbody></table>
|
|
45
|
+
</div>
|
|
46
|
+
</div>
|
|
47
|
+
`;
|
|
48
|
+
|
|
49
|
+
export function setup(api) {
|
|
50
|
+
const { state, $, esc, fmt, fmtBytes, inSel, key, sessOf } = api;
|
|
51
|
+
|
|
52
|
+
/** [{ dash, sid, ms, blockingMs, scripts }] — dashboard-clock stamped. */
|
|
53
|
+
let frames = [];
|
|
54
|
+
/** sid → [{ dash, fps, dropped }] */
|
|
55
|
+
const fps = new Map();
|
|
56
|
+
/** `${sid}|${taskId}` → cost aggregate */
|
|
57
|
+
const taskCost = new Map();
|
|
58
|
+
/** `${sid}|${instance}` → { n, sum, max } */
|
|
59
|
+
const islandCost = new Map();
|
|
60
|
+
let dirty = true;
|
|
61
|
+
|
|
62
|
+
const agg = () => ({ argN: 0, argSum: 0, argMax: 0, resN: 0, resSum: 0, resMax: 0 });
|
|
63
|
+
|
|
64
|
+
api.onEvent((sess, e) => {
|
|
65
|
+
const sid = sess.id;
|
|
66
|
+
switch (e.type) {
|
|
67
|
+
case 'runtime:longframe':
|
|
68
|
+
if (e.worker) return;
|
|
69
|
+
frames.push({ dash: performance.now(), sid, ms: e.ms, blockingMs: e.blockingMs, scripts: e.scripts, hidden: !!e.hidden });
|
|
70
|
+
if (frames.length > MAX_FRAMES) frames.splice(0, frames.length - MAX_FRAMES);
|
|
71
|
+
break;
|
|
72
|
+
case 'runtime:frames': {
|
|
73
|
+
if (e.worker) return;
|
|
74
|
+
const list = fps.get(sid) ?? [];
|
|
75
|
+
list.push({ dash: performance.now(), fps: e.fps, dropped: e.dropped });
|
|
76
|
+
if (list.length > MAX_FPS) list.shift();
|
|
77
|
+
fps.set(sid, list);
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
case 'task:dispatch': {
|
|
81
|
+
if (e.argBytes === undefined) return;
|
|
82
|
+
const k = key(sid, e.taskId);
|
|
83
|
+
const a = taskCost.get(k) ?? agg();
|
|
84
|
+
a.argN++; a.argSum += e.argBytes; if (e.argBytes > a.argMax) a.argMax = e.argBytes;
|
|
85
|
+
taskCost.set(k, a);
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
case 'task:settle': {
|
|
89
|
+
if (e.resultBytes === undefined) return;
|
|
90
|
+
const k = key(sid, e.taskId);
|
|
91
|
+
const a = taskCost.get(k) ?? agg();
|
|
92
|
+
a.resN++; a.resSum += e.resultBytes; if (e.resultBytes > a.resMax) a.resMax = e.resultBytes;
|
|
93
|
+
taskCost.set(k, a);
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
case 'island:ops': {
|
|
97
|
+
if (e.bytes === undefined) return;
|
|
98
|
+
const k = key(sid, e.instance);
|
|
99
|
+
const c = islandCost.get(k) ?? { n: 0, sum: 0, max: 0 };
|
|
100
|
+
c.n++; c.sum += e.bytes; if (e.bytes > c.max) c.max = e.bytes;
|
|
101
|
+
islandCost.set(k, c);
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
default:
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
dirty = true;
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
api.onReset(() => {
|
|
111
|
+
frames = [];
|
|
112
|
+
fps.clear(); taskCost.clear(); islandCost.clear();
|
|
113
|
+
dirty = true;
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
api.onReconcile((ids) => {
|
|
117
|
+
frames = frames.filter((f) => ids.has(f.sid));
|
|
118
|
+
for (const sid of [...fps.keys()]) if (!ids.has(sid)) fps.delete(sid);
|
|
119
|
+
for (const m of [taskCost, islandCost]) for (const k of [...m.keys()]) if (!ids.has(sessOf(k))) m.delete(k);
|
|
120
|
+
dirty = true;
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
/** Long frames in the selected sessions within the last `ms` (dashboard clock). */
|
|
124
|
+
const recentFrames = (ms) => {
|
|
125
|
+
const lo = performance.now() - ms;
|
|
126
|
+
const out = [];
|
|
127
|
+
for (let i = frames.length - 1; i >= 0 && frames[i].dash >= lo; i--) {
|
|
128
|
+
if (inSel(frames[i].sid)) out.push(frames[i]);
|
|
129
|
+
}
|
|
130
|
+
return out;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const section = api.addView({
|
|
134
|
+
id: 'performance',
|
|
135
|
+
label: 'Performance',
|
|
136
|
+
order: 25,
|
|
137
|
+
html: HTML,
|
|
138
|
+
title: 'Main-thread jank, frame rate, and message cost',
|
|
139
|
+
badge: () => recentFrames(10_000).filter((f) => !f.hidden).length || null,
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const visible = () => section.classList.contains('on');
|
|
143
|
+
|
|
144
|
+
function renderKpis(all) {
|
|
145
|
+
const win = all.filter((f) => !f.hidden);
|
|
146
|
+
const bg = all.filter((f) => f.hidden);
|
|
147
|
+
const bgScript = bg.reduce((a, f) => a + (f.scripts ? f.scripts.reduce((n, s) => n + s.ms, 0) : f.ms), 0);
|
|
148
|
+
const lastSamples = [...fps.entries()].filter(([sid]) => inSel(sid)).map(([, l]) => l[l.length - 1]).filter(Boolean);
|
|
149
|
+
const lo = performance.now() - WINDOW_MS;
|
|
150
|
+
const winSamples = [...fps.entries()].filter(([sid]) => inSel(sid)).flatMap(([, l]) => l.filter((s) => s.dash >= lo));
|
|
151
|
+
const cur = lastSamples.length ? Math.min(...lastSamples.map((s) => s.fps)) : undefined;
|
|
152
|
+
const min = winSamples.length ? Math.min(...winSamples.map((s) => s.fps)) : undefined;
|
|
153
|
+
const dropped = winSamples.reduce((a, s) => a + s.dropped, 0);
|
|
154
|
+
const blocking = win.reduce((a, f) => a + f.blockingMs, 0);
|
|
155
|
+
const worst = win.length ? Math.max(...win.map((f) => f.ms)) : undefined;
|
|
156
|
+
const fpsCls = (v) => (v === undefined ? '' : v < 30 ? 'bad' : v < 50 ? 'warn' : 'good');
|
|
157
|
+
$('pf-kpis').innerHTML =
|
|
158
|
+
api.kpi(String(win.length), 'long frames · 60s', win.length > 10 ? 'bad' : win.length ? 'warn' : 'good') +
|
|
159
|
+
api.kpi(`${Math.round(blocking)}<span class="muted" style="font-size:11px">ms</span>`, 'total blocking · 60s', blocking > 500 ? 'bad' : blocking > 0 ? 'warn' : '') +
|
|
160
|
+
api.kpi(cur === undefined ? '—' : String(cur), 'fps now', fpsCls(cur)) +
|
|
161
|
+
api.kpi(min === undefined ? '—' : String(min), 'min fps · 60s', fpsCls(min)) +
|
|
162
|
+
api.kpi(String(dropped), 'dropped frames · 60s', dropped > 30 ? 'warn' : '') +
|
|
163
|
+
api.kpi(worst === undefined ? '—' : `${worst}<span class="muted" style="font-size:11px">ms</span>`, 'worst frame · 60s', worst >= 200 ? 'bad' : worst ? 'warn' : '') +
|
|
164
|
+
(bg.length
|
|
165
|
+
? api.kpi(`${bg.length}<span class="muted" style="font-size:11px"> · ${Math.round(bgScript)}ms</span>`, 'background work · 60s', '')
|
|
166
|
+
: '');
|
|
167
|
+
const anyFps = [...fps.keys()].some((sid) => inSel(sid));
|
|
168
|
+
const anyFrames = frames.some((f) => inSel(f.sid));
|
|
169
|
+
$('pf-empty').textContent = anyFps || anyFrames
|
|
170
|
+
? ''
|
|
171
|
+
: 'No main-thread samples yet. The jank probe (connectDevtools `jank` option, on by default) reports long frames ' +
|
|
172
|
+
'(Long Animation Frames, or longtask where unsupported) and a once-a-second fps sample from browser apps; ' +
|
|
173
|
+
'Node apps and workers have no frames to report.';
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function drawTimeline(win) {
|
|
177
|
+
const canvas = $('pf-timeline');
|
|
178
|
+
const ctx = canvas.getContext('2d');
|
|
179
|
+
const W = (canvas.width = canvas.clientWidth || 560);
|
|
180
|
+
const H = (canvas.height = canvas.clientHeight || 150);
|
|
181
|
+
ctx.clearRect(0, 0, W, H);
|
|
182
|
+
const L = 40, R = 34, T = 8, B = 16;
|
|
183
|
+
const pw = W - L - R, ph = H - T - B;
|
|
184
|
+
const now = performance.now();
|
|
185
|
+
const x = (dash) => L + pw * (1 - (now - dash) / WINDOW_MS);
|
|
186
|
+
const msMax = Math.max(100, ...win.map((f) => f.ms));
|
|
187
|
+
const sids = [...fps.keys()].filter((sid) => inSel(sid));
|
|
188
|
+
const fpsMax = Math.max(60, ...sids.flatMap((sid) => fps.get(sid).map((s) => s.fps)));
|
|
189
|
+
|
|
190
|
+
ctx.font = '10px ui-monospace, monospace';
|
|
191
|
+
ctx.textBaseline = 'middle';
|
|
192
|
+
for (const f of [0, 0.5, 1]) {
|
|
193
|
+
const y = T + ph - f * ph;
|
|
194
|
+
ctx.strokeStyle = f === 0 ? '#30363d' : '#21262d';
|
|
195
|
+
ctx.beginPath(); ctx.moveTo(L, Math.round(y) + 0.5); ctx.lineTo(W - R, Math.round(y) + 0.5); ctx.stroke();
|
|
196
|
+
ctx.fillStyle = '#d29922'; ctx.textAlign = 'right';
|
|
197
|
+
ctx.fillText(`${Math.round(msMax * f)}ms`, L - 4, y);
|
|
198
|
+
ctx.fillStyle = '#3fb950'; ctx.textAlign = 'left';
|
|
199
|
+
ctx.fillText(String(Math.round(fpsMax * f)), W - R + 4, y);
|
|
200
|
+
}
|
|
201
|
+
ctx.fillStyle = '#8b949e';
|
|
202
|
+
ctx.textBaseline = 'top';
|
|
203
|
+
for (const [ago, align] of [[60, 'left'], [30, 'center'], [0, 'right']]) {
|
|
204
|
+
ctx.textAlign = align;
|
|
205
|
+
ctx.fillText(ago ? `-${ago}s` : 'now', L + pw * (1 - ago / 60), T + ph + 3);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
for (const f of win) {
|
|
209
|
+
const bh = Math.max(2, (f.ms / msMax) * ph);
|
|
210
|
+
ctx.fillStyle = f.hidden ? '#6e7681' : f.blockingMs >= 100 ? '#f85149' : '#d29922';
|
|
211
|
+
ctx.fillRect(Math.round(x(f.dash)) - 1, T + ph - bh, 3, bh);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
sids.forEach((sid, i) => {
|
|
215
|
+
const pts = fps.get(sid).filter((s) => s.dash >= now - WINDOW_MS);
|
|
216
|
+
if (!pts.length) return;
|
|
217
|
+
ctx.strokeStyle = FPS_COLORS[i % FPS_COLORS.length];
|
|
218
|
+
ctx.lineWidth = 1.5;
|
|
219
|
+
ctx.beginPath();
|
|
220
|
+
pts.forEach((s, j) => {
|
|
221
|
+
const px = x(s.dash), py = T + ph - (s.fps / fpsMax) * ph;
|
|
222
|
+
j ? ctx.lineTo(px, py) : ctx.moveTo(px, py);
|
|
223
|
+
});
|
|
224
|
+
ctx.stroke();
|
|
225
|
+
ctx.lineWidth = 1;
|
|
226
|
+
});
|
|
227
|
+
$('pf-legend-extra').textContent = sids.length > 1 ? `${sids.length} sessions: one fps line each` : '';
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function renderWorst() {
|
|
231
|
+
const now = performance.now();
|
|
232
|
+
const rows = frames.filter((f) => inSel(f.sid)).sort((a, b) => b.ms - a.ms).slice(0, WORST_ROWS);
|
|
233
|
+
$('pf-worst').querySelector('tbody').innerHTML = rows.length
|
|
234
|
+
? rows.map((f) => {
|
|
235
|
+
const s = state.sessions.get(f.sid);
|
|
236
|
+
const scripts = f.scripts?.length
|
|
237
|
+
? f.scripts.map((sc) => `<div>${sc.ms}ms <b>${esc(sc.fn || '(anonymous)')}</b> <span class="muted">${esc(shortSrc(sc.src))}</span></div>`).join('')
|
|
238
|
+
: '<span class="muted">no attribution (longtask fallback or no script)</span>';
|
|
239
|
+
return `<tr${f.hidden ? ' style="opacity:.6" title="page was hidden: frame length is throttling, script time is real"' : ''}>` +
|
|
240
|
+
`<td class="muted">${esc(s?.name ?? f.sid)}${f.hidden ? ' · background' : ''}</td><td>${fmt((now - f.dash) / 1000, 0)}s ago</td>` +
|
|
241
|
+
`<td>${f.ms}ms</td><td class="${f.blockingMs >= 100 ? 'error' : ''}">${f.blockingMs}ms</td><td>${scripts}</td></tr>`;
|
|
242
|
+
}).join('')
|
|
243
|
+
: '<tr><td colspan="5" class="muted">no long frames: frames over 50ms show up here with their slowest scripts</td></tr>';
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function renderCost() {
|
|
247
|
+
const byTask = new Map();
|
|
248
|
+
for (const [k, a] of taskCost) {
|
|
249
|
+
if (!inSel(k)) continue;
|
|
250
|
+
const taskId = k.slice(k.indexOf('|') + 1);
|
|
251
|
+
const t = byTask.get(taskId) ?? { argN: 0, argSum: 0, argMax: 0, resN: 0, resSum: 0, resMax: 0 };
|
|
252
|
+
t.argN += a.argN; t.argSum += a.argSum; t.argMax = Math.max(t.argMax, a.argMax);
|
|
253
|
+
t.resN += a.resN; t.resSum += a.resSum; t.resMax = Math.max(t.resMax, a.resMax);
|
|
254
|
+
byTask.set(taskId, t);
|
|
255
|
+
}
|
|
256
|
+
const tasks = [...byTask].sort((a, b) => (b[1].argMax + b[1].resMax) - (a[1].argMax + a[1].resMax)).slice(0, COST_ROWS);
|
|
257
|
+
const avg = (sum, n) => (n ? fmtBytes(Math.round(sum / n)) : '—');
|
|
258
|
+
$('pf-tasks').querySelector('tbody').innerHTML = tasks.length
|
|
259
|
+
? tasks.map(([id, t]) => `<tr><td>${esc(id)}</td><td>${Math.max(t.argN, t.resN)}</td>` +
|
|
260
|
+
`<td>${avg(t.argSum, t.argN)}</td><td>${t.argN ? fmtBytes(t.argMax) : '—'}</td>` +
|
|
261
|
+
`<td>${avg(t.resSum, t.resN)}</td><td>${t.resN ? fmtBytes(t.resMax) : '—'}</td></tr>`).join('')
|
|
262
|
+
: '<tr><td colspan="6" class="muted">no sized task messages yet: task:dispatch / task:settle carry argBytes / resultBytes while devtools is on</td></tr>';
|
|
263
|
+
|
|
264
|
+
const isl = [...islandCost].filter(([k]) => inSel(k)).sort((a, b) => b[1].max - a[1].max).slice(0, COST_ROWS);
|
|
265
|
+
$('pf-islands').querySelector('tbody').innerHTML = isl.length
|
|
266
|
+
? isl.map(([k, c]) => `<tr><td>${esc(k.slice(k.indexOf('|') + 1))}</td><td>${c.n}</td>` +
|
|
267
|
+
`<td>${fmtBytes(Math.round(c.sum / c.n))}</td><td>${fmtBytes(c.max)}</td><td>${fmtBytes(c.sum)}</td></tr>`).join('')
|
|
268
|
+
: '<tr><td colspan="5" class="muted">no sized op batches yet: island:ops events carry `bytes` per batch</td></tr>';
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
let wasVisible = false;
|
|
272
|
+
function draw() {
|
|
273
|
+
const vis = visible();
|
|
274
|
+
if (vis && !wasVisible) dirty = true;
|
|
275
|
+
wasVisible = vis;
|
|
276
|
+
if (!vis) return;
|
|
277
|
+
const win = recentFrames(WINDOW_MS);
|
|
278
|
+
drawTimeline(win);
|
|
279
|
+
if (!dirty) return;
|
|
280
|
+
dirty = false;
|
|
281
|
+
renderKpis(win);
|
|
282
|
+
renderWorst();
|
|
283
|
+
renderCost();
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
let lastSel;
|
|
287
|
+
api.onRender(() => {
|
|
288
|
+
if (state.sel !== lastSel) { lastSel = state.sel; dirty = true; }
|
|
289
|
+
draw();
|
|
290
|
+
});
|
|
291
|
+
// Rolling window: "ago" labels, KPIs, and the chart move with time.
|
|
292
|
+
api.onTick(() => { dirty = true; draw(); });
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Last path segments of a script URL, for compact attribution cells. */
|
|
296
|
+
function shortSrc(src) {
|
|
297
|
+
if (!src) return '';
|
|
298
|
+
try {
|
|
299
|
+
const u = new URL(src);
|
|
300
|
+
return u.pathname.split('/').slice(-2).join('/') + (u.search ? '?…' : '');
|
|
301
|
+
} catch {
|
|
302
|
+
return src.slice(-60);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
// Reactivity view: the cross-thread dependency graph from reactive:node
|
|
2
|
+
// events. Lanes are owners: 'shared memory' (field sources, ids `mem:<path>`
|
|
3
|
+
// shared by every thread), the main thread, and each worker slot (from the
|
|
4
|
+
// pool's forwarding stamp). Edges run dep → dependent; disposed nodes fade.
|
|
5
|
+
//
|
|
6
|
+
// Non-source ids are per-thread counters, so they're qualified here by
|
|
7
|
+
// lane: `main/e3`, `pool-1#0/b2`. Sources stay global so a worker-side
|
|
8
|
+
// writer's field and a main-thread watcher meet at one node.
|
|
9
|
+
|
|
10
|
+
const CSS = `
|
|
11
|
+
.rx-bar { display:flex; gap:10px; align-items:center; flex-wrap:wrap; font-size:11px; margin-bottom:8px; }
|
|
12
|
+
.rx-wrap { display:grid; grid-template-columns: minmax(0,1fr) 280px; gap:10px; }
|
|
13
|
+
.rx-graph { border:1px solid #21262d; border-radius:8px; background:#010409; overflow:auto; max-height:560px; min-height:200px; }
|
|
14
|
+
.rx-graph svg { display:block; }
|
|
15
|
+
.rx-graph .n { cursor:pointer; }
|
|
16
|
+
.rx-graph .n rect { stroke-width:1; }
|
|
17
|
+
.rx-graph .n.sel rect { stroke:#f0f6fc; stroke-width:2; }
|
|
18
|
+
.rx-graph .n.off { opacity:.3; }
|
|
19
|
+
.rx-graph text { font:11px ui-monospace,monospace; fill:#c9d1d9; pointer-events:none; }
|
|
20
|
+
.rx-graph .lane { font-size:10px; fill:#8b949e; text-transform:uppercase; letter-spacing:.06em; }
|
|
21
|
+
.rx-graph path.e { fill:none; stroke:#30363d; stroke-width:1.2; }
|
|
22
|
+
.rx-graph path.e.hot { stroke:#58a6ff; stroke-width:2; }
|
|
23
|
+
.rx-graph path.e.off { opacity:.25; }
|
|
24
|
+
.rx-detail { border:1px solid #21262d; border-radius:8px; padding:8px 10px; font-size:11px; max-height:560px; overflow:auto; }
|
|
25
|
+
.rx-empty { padding:16px; color:#8b949e; font-size:12px; line-height:1.6; }
|
|
26
|
+
.rx-legend i { display:inline-block; width:10px; height:10px; border-radius:2px; margin:0 4px 0 10px; vertical-align:middle; }
|
|
27
|
+
`;
|
|
28
|
+
|
|
29
|
+
const KIND = {
|
|
30
|
+
source: { color: '#d29922', order: 0 },
|
|
31
|
+
bridge: { color: '#a371f7', order: 1 },
|
|
32
|
+
derived: { color: '#58a6ff', order: 2 },
|
|
33
|
+
effect: { color: '#3fb950', order: 3 },
|
|
34
|
+
};
|
|
35
|
+
const SHARED = 'shared memory';
|
|
36
|
+
const MAX_DISPOSED = 300;
|
|
37
|
+
const COL_W = 230, BOX_W = 196, BOX_H = 22, ROW_H = 30, TOP = 30, LEFT = 14;
|
|
38
|
+
|
|
39
|
+
const injectCss = () => {
|
|
40
|
+
if (document.getElementById('rx-css')) return;
|
|
41
|
+
const s = document.createElement('style');
|
|
42
|
+
s.id = 'rx-css';
|
|
43
|
+
s.textContent = CSS;
|
|
44
|
+
document.head.appendChild(s);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export function setup(api) {
|
|
48
|
+
injectCss();
|
|
49
|
+
const { esc } = api;
|
|
50
|
+
|
|
51
|
+
const section = api.addView({
|
|
52
|
+
id: 'reactivity',
|
|
53
|
+
label: 'Reactivity',
|
|
54
|
+
order: 45,
|
|
55
|
+
html: `
|
|
56
|
+
<h2>Reactivity graph <span class="muted">— shared-memory sources → version bridges → slices → effects, laned by thread</span></h2>
|
|
57
|
+
<div class="rx-bar">
|
|
58
|
+
<label><input type="checkbox" id="rxDisposed" checked> show disposed</label>
|
|
59
|
+
<button class="act" id="rxSnap" title="Fetch the main thread's live nodes (reactive.nodes): for dashboards opened after the creation events scrolled out of the replay tail">Load snapshot</button>
|
|
60
|
+
<span class="muted" id="rxStats"></span>
|
|
61
|
+
<span class="rx-legend">${Object.entries(KIND).map(([k, v]) => `<i style="background:${v.color}"></i>${k}`).join('')}</span>
|
|
62
|
+
</div>
|
|
63
|
+
<div class="rx-wrap">
|
|
64
|
+
<div class="rx-graph" id="rxGraph"></div>
|
|
65
|
+
<div class="rx-detail" id="rxDetail"><span class="muted">click a node for details</span></div>
|
|
66
|
+
</div>`,
|
|
67
|
+
});
|
|
68
|
+
const $ = (id) => section.querySelector(`#${id}`);
|
|
69
|
+
|
|
70
|
+
/** `${sid}|${qid}` → node record */
|
|
71
|
+
const nodes = new Map();
|
|
72
|
+
let version = 0;
|
|
73
|
+
let drawn = -1;
|
|
74
|
+
let selected = null;
|
|
75
|
+
|
|
76
|
+
const laneOf = (e) => (e.worker ? `${e.worker.poolId}#${e.worker.slot}` : e.thread === 'main' ? 'main' : 'worker');
|
|
77
|
+
const qualify = (lane, id) => (id.startsWith('mem:') ? id : `${lane}/${id}`);
|
|
78
|
+
|
|
79
|
+
const upsert = (sid, raw, lane, at) => {
|
|
80
|
+
const qid = qualify(lane, raw.id);
|
|
81
|
+
const k = api.key(sid, qid);
|
|
82
|
+
const prev = nodes.get(k);
|
|
83
|
+
const n = prev ?? { key: k, sid, qid, id: raw.id, createdAt: at, seq: version };
|
|
84
|
+
n.kind = raw.kind;
|
|
85
|
+
if (raw.label !== undefined) n.label = raw.label;
|
|
86
|
+
if (raw.deps !== undefined) n.deps = raw.deps.map((d) => qualify(lane, d));
|
|
87
|
+
n.deps ??= [];
|
|
88
|
+
n.lane = raw.kind === 'source' ? SHARED : lane;
|
|
89
|
+
n.owner = raw.owner;
|
|
90
|
+
n.disposed = raw.disposed === true;
|
|
91
|
+
if (n.disposed) n.disposedAt = at;
|
|
92
|
+
nodes.set(k, n);
|
|
93
|
+
version++;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const trimDisposed = (sid) => {
|
|
97
|
+
const dead = [...nodes.values()].filter((n) => n.sid === sid && n.disposed);
|
|
98
|
+
if (dead.length <= MAX_DISPOSED) return;
|
|
99
|
+
dead.sort((a, b) => a.disposedAt - b.disposedAt);
|
|
100
|
+
for (const n of dead.slice(0, dead.length - MAX_DISPOSED)) nodes.delete(n.key);
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
api.onEvent((s, e) => {
|
|
104
|
+
if (e.type !== 'reactive:node') return;
|
|
105
|
+
upsert(s.id, e, laneOf(e), Date.now());
|
|
106
|
+
if (e.disposed) trimDisposed(s.id);
|
|
107
|
+
});
|
|
108
|
+
api.onReset(() => { nodes.clear(); version++; selected = null; });
|
|
109
|
+
api.onReconcile((ids) => {
|
|
110
|
+
for (const [k, n] of nodes) if (!ids.has(n.sid)) nodes.delete(k);
|
|
111
|
+
version++;
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
$('rxDisposed').onchange = () => { drawn = -1; draw(); };
|
|
115
|
+
$('rxSnap').onclick = () => {
|
|
116
|
+
const sid = api.selectedOrOnlyLive();
|
|
117
|
+
if (!sid || !api.hasCommand(sid, 'reactive.nodes')) {
|
|
118
|
+
$('rxStats').textContent = sid ? "this app doesn't expose 'reactive.nodes' (no reactive node created with devtools on yet)" : 'select one live session first';
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
api.control(sid, 'reactive.nodes', {}, 3000)
|
|
122
|
+
.then((list) => {
|
|
123
|
+
for (const raw of list) upsert(sid, raw, 'main', Date.now());
|
|
124
|
+
api.scheduleRender();
|
|
125
|
+
})
|
|
126
|
+
.catch((err) => { $('rxStats').textContent = `snapshot failed: ${err.message}`; });
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const visibleNodes = () => {
|
|
130
|
+
const showDead = $('rxDisposed').checked;
|
|
131
|
+
return [...nodes.values()].filter((n) => api.inSel(n.key) && (showDead || !n.disposed));
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const laneSort = (a, b) => {
|
|
135
|
+
const rank = (l) => (l === SHARED ? 0 : l === 'main' ? 1 : 2);
|
|
136
|
+
return rank(a) - rank(b) || a.localeCompare(b);
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
function draw() {
|
|
140
|
+
const list = visibleNodes();
|
|
141
|
+
const sessions = new Set(list.map((n) => n.sid));
|
|
142
|
+
const multi = sessions.size > 1;
|
|
143
|
+
const laneKey = (n) => (multi ? `${api.state.sessions.get(n.sid)?.name ?? n.sid} · ${n.lane}` : n.lane);
|
|
144
|
+
const lanes = [...new Set(list.map(laneKey))].sort(laneSort);
|
|
145
|
+
const live = list.filter((n) => !n.disposed).length;
|
|
146
|
+
$('rxStats').textContent = list.length ? `${live} live · ${list.length - live} disposed · ${lanes.length} lane(s)` : '';
|
|
147
|
+
|
|
148
|
+
const graph = $('rxGraph');
|
|
149
|
+
if (!list.length) {
|
|
150
|
+
graph.innerHTML = `<div class="rx-empty">
|
|
151
|
+
No reactive nodes yet.<br>
|
|
152
|
+
Nodes are emitted while devtools is enabled by <code>watch()</code>, <code>observe()</code> (framework
|
|
153
|
+
bindings subscribe through it) and <code>reactive().observeRemote()</code>; island push-mode doorbells
|
|
154
|
+
appear as <code>observe(opsVersion)</code>. Call <code>initDevtools()</code> before those subscriptions are
|
|
155
|
+
created. Opened the dashboard late? <b>Load snapshot</b> fetches the main thread's live nodes.<br>
|
|
156
|
+
Worker-side nodes arrive through the event stream (the worker's devtools forwarding must be on).
|
|
157
|
+
</div>`;
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const pos = new Map();
|
|
162
|
+
const byLane = new Map(lanes.map((l) => [l, []]));
|
|
163
|
+
for (const n of list) byLane.get(laneKey(n)).push(n);
|
|
164
|
+
let maxRows = 0;
|
|
165
|
+
lanes.forEach((l, col) => {
|
|
166
|
+
const ns = byLane.get(l).sort((a, b) => KIND[a.kind].order - KIND[b.kind].order || a.seq - b.seq);
|
|
167
|
+
maxRows = Math.max(maxRows, ns.length);
|
|
168
|
+
ns.forEach((n, row) => pos.set(n.qid + '|' + n.sid, { x: LEFT + col * COL_W, y: TOP + row * ROW_H }));
|
|
169
|
+
});
|
|
170
|
+
const W = LEFT * 2 + lanes.length * COL_W;
|
|
171
|
+
const H = TOP + maxRows * ROW_H + 10;
|
|
172
|
+
const at = (sid, qid) => pos.get(qid + '|' + sid);
|
|
173
|
+
const selNode = selected ? nodes.get(selected) : null;
|
|
174
|
+
|
|
175
|
+
const edges = [];
|
|
176
|
+
for (const n of list) {
|
|
177
|
+
const to = at(n.sid, n.qid);
|
|
178
|
+
for (const d of n.deps) {
|
|
179
|
+
const from = at(n.sid, d);
|
|
180
|
+
if (!from) continue;
|
|
181
|
+
const dep = nodes.get(api.key(n.sid, d));
|
|
182
|
+
const hot = selNode && (selNode.key === n.key || selNode.key === dep?.key);
|
|
183
|
+
const off = n.disposed || dep?.disposed;
|
|
184
|
+
let path;
|
|
185
|
+
if (from.x === to.x) {
|
|
186
|
+
// same lane: loop out on the right side
|
|
187
|
+
const x = from.x + BOX_W, bend = x + 18 + Math.min(40, Math.abs(to.y - from.y) / 4);
|
|
188
|
+
path = `M${x},${from.y + BOX_H / 2} C${bend},${from.y + BOX_H / 2} ${bend},${to.y + BOX_H / 2} ${x},${to.y + BOX_H / 2}`;
|
|
189
|
+
} else {
|
|
190
|
+
const [a, b] = from.x < to.x ? [from.x + BOX_W, to.x] : [from.x, to.x + BOX_W];
|
|
191
|
+
const mid = (a + b) / 2;
|
|
192
|
+
path = `M${a},${from.y + BOX_H / 2} C${mid},${from.y + BOX_H / 2} ${mid},${to.y + BOX_H / 2} ${b},${to.y + BOX_H / 2}`;
|
|
193
|
+
}
|
|
194
|
+
edges.push(`<path class="e${hot ? ' hot' : ''}${off ? ' off' : ''}" d="${path}" marker-end="url(#rxArrow)"/>`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const boxes = list.map((n) => {
|
|
199
|
+
const p = at(n.sid, n.qid);
|
|
200
|
+
const label = n.label ?? n.id;
|
|
201
|
+
const text = label.length > 28 ? `${label.slice(0, 27)}…` : label;
|
|
202
|
+
return `<g class="n${n.disposed ? ' off' : ''}${selected === n.key ? ' sel' : ''}" data-k="${esc(n.key)}" transform="translate(${p.x},${p.y})">
|
|
203
|
+
<title>${esc(`${n.kind} ${n.qid}${n.label ? `\n${n.label}` : ''}`)}</title>
|
|
204
|
+
<rect width="${BOX_W}" height="${BOX_H}" rx="4" fill="#0d1117" stroke="${KIND[n.kind].color}"/>
|
|
205
|
+
<rect width="4" height="${BOX_H}" rx="2" fill="${KIND[n.kind].color}"/>
|
|
206
|
+
<text x="10" y="15">${esc(text)}</text></g>`;
|
|
207
|
+
}).join('');
|
|
208
|
+
|
|
209
|
+
const laneHeads = lanes.map((l, col) => `<text class="lane" x="${LEFT + col * COL_W}" y="16">${esc(l)}</text>`).join('');
|
|
210
|
+
const scroll = [graph.scrollLeft, graph.scrollTop];
|
|
211
|
+
graph.innerHTML = `<svg width="${W}" height="${H}" xmlns="http://www.w3.org/2000/svg">
|
|
212
|
+
<defs><marker id="rxArrow" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="6" markerHeight="6" orient="auto">
|
|
213
|
+
<path d="M0,0 L8,4 L0,8 z" fill="#484f58"/></marker></defs>
|
|
214
|
+
${laneHeads}${edges.join('')}${boxes}</svg>`;
|
|
215
|
+
[graph.scrollLeft, graph.scrollTop] = scroll;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const showDetail = () => {
|
|
219
|
+
const n = selected ? nodes.get(selected) : null;
|
|
220
|
+
const box = $('rxDetail');
|
|
221
|
+
if (!n) { box.innerHTML = '<span class="muted">click a node for details</span>'; return; }
|
|
222
|
+
const dependents = [...nodes.values()].filter((x) => x.sid === n.sid && x.deps.includes(n.qid));
|
|
223
|
+
const ref = (qid) => {
|
|
224
|
+
const x = nodes.get(api.key(n.sid, qid));
|
|
225
|
+
return `<a href="#" class="wlink" data-k="${esc(api.key(n.sid, qid))}">${esc(x?.label ?? qid)}</a>${x?.disposed ? ' <span class="muted">(disposed)</span>' : ''}`;
|
|
226
|
+
};
|
|
227
|
+
box.innerHTML = `
|
|
228
|
+
<div><b style="color:${KIND[n.kind].color}">${esc(n.kind)}</b> ${n.disposed ? '<span class="pill">disposed</span>' : '<span class="pill">live</span>'}</div>
|
|
229
|
+
<table class="tkv" style="margin-top:6px"><tbody>
|
|
230
|
+
<tr><td>label</td><td>${esc(n.label ?? '—')}</td></tr>
|
|
231
|
+
<tr><td>id</td><td>${esc(n.qid)}</td></tr>
|
|
232
|
+
<tr><td>lane</td><td>${esc(n.lane)}</td></tr>
|
|
233
|
+
<tr><td>owner</td><td>${esc(n.owner ?? '—')}</td></tr>
|
|
234
|
+
<tr><td>session</td><td>${esc(api.state.sessions.get(n.sid)?.name ?? n.sid)}</td></tr>
|
|
235
|
+
</tbody></table>
|
|
236
|
+
<h2 style="margin-top:10px">Depends on</h2>
|
|
237
|
+
<div>${n.deps.length ? n.deps.map(ref).join('<br>') : '<span class="muted">nothing</span>'}</div>
|
|
238
|
+
<h2 style="margin-top:10px">Dependents</h2>
|
|
239
|
+
<div>${dependents.length ? dependents.map((x) => ref(x.qid)).join('<br>') : '<span class="muted">none</span>'}</div>`;
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
const select = (k) => {
|
|
243
|
+
selected = selected === k ? null : k;
|
|
244
|
+
drawn = -1;
|
|
245
|
+
draw();
|
|
246
|
+
showDetail();
|
|
247
|
+
};
|
|
248
|
+
$('rxGraph').addEventListener('click', (ev) => {
|
|
249
|
+
const g = ev.target.closest('g.n');
|
|
250
|
+
if (g) select(g.dataset.k);
|
|
251
|
+
});
|
|
252
|
+
$('rxDetail').addEventListener('click', (ev) => {
|
|
253
|
+
const a = ev.target.closest('a[data-k]');
|
|
254
|
+
if (!a) return;
|
|
255
|
+
ev.preventDefault();
|
|
256
|
+
if (nodes.has(a.dataset.k)) select(a.dataset.k);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
let lastSel = null;
|
|
260
|
+
api.onRender(() => {
|
|
261
|
+
if (!section.classList.contains('on')) return;
|
|
262
|
+
// selection scope (session picker) changes re-filter the lanes too
|
|
263
|
+
const scope = api.state.sel;
|
|
264
|
+
if (drawn === version && lastSel === scope) return;
|
|
265
|
+
drawn = version;
|
|
266
|
+
lastSel = scope;
|
|
267
|
+
draw();
|
|
268
|
+
if (selected && !nodes.has(selected)) selected = null;
|
|
269
|
+
showDetail();
|
|
270
|
+
});
|
|
271
|
+
}
|