@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.
Files changed (50) hide show
  1. package/README.md +408 -71
  2. package/app/api.js +68 -0
  3. package/app/fw-logos.js +14 -0
  4. package/app/index.html +161 -2
  5. package/app/main.js +897 -222
  6. package/app/panels/actions.js +324 -0
  7. package/app/panels/audit-rules.js +728 -0
  8. package/app/panels/audits.js +438 -0
  9. package/app/panels/inspect-island.js +351 -0
  10. package/app/panels/memory-values.js +287 -0
  11. package/app/panels/performance.js +304 -0
  12. package/app/panels/reactivity.js +271 -0
  13. package/app/panels/recorder.js +344 -0
  14. package/app/panels/recording-format.js +166 -0
  15. package/app/panels/route.d.ts +44 -0
  16. package/app/panels/route.js +264 -0
  17. package/app/panels/shell.js +789 -0
  18. package/app/panels/toast.js +38 -0
  19. package/dist/bin.js +704 -5
  20. package/dist/chunk-57NGHQUL.js +344 -0
  21. package/dist/chunk-6E54FXKS.js +495 -0
  22. package/dist/chunk-WIL2P7HG.js +661 -0
  23. package/dist/client.d.ts +7 -0
  24. package/dist/control.d.ts +4 -0
  25. package/dist/index.js +2 -1
  26. package/dist/node.js +2 -1
  27. package/dist/nodeWs.d.ts +1 -0
  28. package/dist/otel.d.ts +35 -0
  29. package/dist/otel.js +87 -0
  30. package/dist/otelMap.d.ts +232 -0
  31. package/dist/otelTransport.d.ts +69 -0
  32. package/dist/overlay.d.ts +16 -2
  33. package/dist/probes.d.ts +7 -0
  34. package/dist/protocol.d.ts +46 -4
  35. package/dist/server.d.ts +19 -0
  36. package/dist/server.js +61 -3
  37. package/package.json +6 -2
  38. package/src/bin.ts +27 -2
  39. package/src/broadcast.ts +6 -0
  40. package/src/client.ts +26 -2
  41. package/src/control.ts +51 -0
  42. package/src/nodeWs.ts +7 -1
  43. package/src/otel.ts +140 -0
  44. package/src/otelMap.ts +661 -0
  45. package/src/otelTransport.ts +286 -0
  46. package/src/overlay.ts +239 -47
  47. package/src/probes.ts +155 -0
  48. package/src/protocol.ts +52 -4
  49. package/src/server.ts +83 -3
  50. package/dist/chunk-4N3NBFL3.js +0 -474
@@ -0,0 +1,324 @@
1
+ // Live controls on the inspectors: kill a worker / inject chaos on its pool
2
+ // (worker inspector), edit props / switch push-poll mode (island inspector).
3
+ // Every control goes through api.control and is gated on the app having
4
+ // registered the command (api.hasCommand) and the session being live.
5
+ //
6
+ // Inspector hooks fire on every render: the controls are built once per
7
+ // inspected key and only their disabled/title state is touched afterwards.
8
+
9
+ import { toast } from './toast.js';
10
+
11
+ /** sid → pool.list result ([{ poolId, label, size, dedicated, chaos }]). */
12
+ const poolLists = new Map();
13
+
14
+ const CSS = `
15
+ .act-row { display:flex; flex-wrap:wrap; gap:6px; align-items:center; }
16
+ .act-row .chaos { display:inline-flex; gap:4px; align-items:center; border:1px solid #30363d; border-radius:6px; padding:2px 6px; }
17
+ .act-row .chaos input { width:56px; background:#0d1117; color:#c9d1d9; border:1px solid #30363d; border-radius:4px; padding:1px 4px; font:inherit; }
18
+ .act-row .chaos .st { color:#8b949e; font-size:11px; }
19
+ .act-row .chaos .st.on { color:#d29922; }
20
+ .act-props { margin:0 0 10px; }
21
+ .act-props textarea { width:100%; min-height:140px; box-sizing:border-box; background:#0d1117; color:#c9d1d9; border:1px solid #30363d; border-radius:6px; padding:6px; font:12px/1.4 ui-monospace,monospace; }
22
+ .act-props .err { color:#ff7b72; font-size:12px; min-height:1em; }
23
+ .act-props .row { display:flex; gap:6px; margin-top:4px; }
24
+ `;
25
+
26
+ const injectCss = () => {
27
+ if (document.getElementById('act-css')) return;
28
+ const s = document.createElement('style');
29
+ s.id = 'act-css';
30
+ s.textContent = CSS;
31
+ document.head.appendChild(s);
32
+ };
33
+
34
+ const h = (tag, props = {}, ...kids) => {
35
+ const el = document.createElement(tag);
36
+ for (const [k, v] of Object.entries(props)) {
37
+ if (k === 'style') el.style.cssText = v;
38
+ else if (k.startsWith('on')) el.addEventListener(k.slice(2), v);
39
+ else if (k in el) el[k] = v;
40
+ else el.setAttribute(k, v);
41
+ }
42
+ for (const c of kids) el.append(c);
43
+ return el;
44
+ };
45
+
46
+ /** Set disabled + tooltip only when they change (render-rate safe). */
47
+ const gate = (el, reason, title) => {
48
+ const off = reason !== null;
49
+ if (el.disabled !== off) el.disabled = off;
50
+ const t = off ? reason : title;
51
+ if (el.title !== t) el.title = t;
52
+ };
53
+
54
+ /** Mount (once) a container owned by this panel inside an inspector's actions div. */
55
+ const ownBox = (actions, id) => {
56
+ let box = actions.querySelector(`:scope > [data-act="${id}"]`);
57
+ if (!box) {
58
+ box = h('div', { className: 'act-row', 'data-act': id, style: 'display:contents' });
59
+ actions.appendChild(box);
60
+ }
61
+ return box;
62
+ };
63
+
64
+ const chaosText = (c) => {
65
+ if (!c) return 'chaos: off';
66
+ const parts = [];
67
+ if (c.delayMs) parts.push(`+${c.delayMs}ms`);
68
+ if (c.failRate) parts.push(`${Math.round(c.failRate * 100)}% fail`);
69
+ if (c.timeoutRate) parts.push(`${Math.round(c.timeoutRate * 100)}% timeout`);
70
+ return `chaos: ${parts.join(' · ')}`;
71
+ };
72
+
73
+ export function setup(api) {
74
+ injectCss();
75
+
76
+ /** Why a control on session `sid` is unavailable, or null when usable. */
77
+ const blocked = (sid, cmd) => {
78
+ if (api.livePaused) return 'Viewing a recording: controls are off';
79
+ if (api.state.sessions.get(sid)?.closed) return 'Session has ended';
80
+ if (!api.hasCommand(sid, cmd)) return `The app does not expose '${cmd}' (devtools off for this runner, or an older app)`;
81
+ return null;
82
+ };
83
+
84
+ const refreshPools = (sid) =>
85
+ api.control(sid, 'pool.list', {}, 3000)
86
+ .then((list) => { poolLists.set(sid, list); api.scheduleRender(); return list; })
87
+ .catch(() => []);
88
+
89
+ const killWorker = async (sid, poolId, slot) => {
90
+ if (!confirm(`Kill ${poolId} slot ${slot}?\n\nRuns the crash path: in-flight calls reject with WorkerCrashedError, and the worker respawns if the runner allows it.`)) return;
91
+ try {
92
+ await api.control(sid, 'worker.kill', { poolId, slot });
93
+ toast(`Killed ${poolId}#${slot}`);
94
+ } catch (err) {
95
+ toast(err, 'error');
96
+ }
97
+ };
98
+
99
+ /* ── worker inspector ─────────────────────────────────────────────────── */
100
+
101
+ let wk = null; // { key, sid, poolId, slot, nested, els }
102
+ let wkTicks = 0;
103
+
104
+ const buildWorker = (actions, key) => {
105
+ const [sid, poolId, slotStr] = key.split('|');
106
+ const slot = Number(slotStr);
107
+ const box = ownBox(actions, 'worker');
108
+ box.replaceChildren();
109
+ const kill = h('button', { className: 'danger', textContent: 'Kill worker', onclick: () => killWorker(sid, poolId, slot) });
110
+ const delay = h('input', { type: 'number', min: '0', step: '50', placeholder: 'ms', 'data-tip': 'Delay before each call is posted (ms)' });
111
+ const fail = h('input', { type: 'number', min: '0', max: '100', step: '5', placeholder: '%', 'data-tip': 'Share of calls failed with "chaos: injected failure"' });
112
+ const tout = h('input', { type: 'number', min: '0', max: '100', step: '5', placeholder: '%', 'data-tip': 'Share of calls failed through the timeout path' });
113
+ const status = h('span', { className: 'st', textContent: 'chaos: …' });
114
+ const send = async (args) => {
115
+ try {
116
+ const active = await api.control(sid, 'pool.chaos', { poolId, ...args });
117
+ toast(active ? `${poolId}: ${chaosText(active)}` : `${poolId}: chaos cleared`);
118
+ refreshPools(sid);
119
+ } catch (err) {
120
+ toast(err, 'error');
121
+ }
122
+ };
123
+ const apply = h('button', {
124
+ textContent: 'Apply',
125
+ 'data-tip': `Inject faults into every call on ${poolId}`,
126
+ onclick: () => {
127
+ const num = (el) => (el.value === '' ? undefined : Number(el.value));
128
+ const d = num(delay); const f = num(fail); const t = num(tout);
129
+ send({ delayMs: d, failRate: f === undefined ? undefined : f / 100, timeoutRate: t === undefined ? undefined : t / 100 });
130
+ },
131
+ });
132
+ const clear = h('button', { textContent: 'Clear', 'data-tip': 'Remove chaos from this runner', onclick: () => send({}) });
133
+ const chaos = h('span', { className: 'chaos' },
134
+ h('span', { textContent: 'Chaos' }), 'delay', delay, 'fail', fail, 'timeout', tout, apply, clear, status);
135
+ box.append(kill, chaos);
136
+ wk = { key, sid, poolId, slot, nested: poolId.includes('~'), filled: false, els: { kill, delay, fail, tout, apply, clear, status } };
137
+ if (!blocked(sid, 'pool.list')) refreshPools(sid);
138
+ };
139
+
140
+ const updateWorker = (w) => {
141
+ const { sid, poolId, nested, els } = wk;
142
+ const nestedWhy = nested ? 'Runs inside a worker: nested pools are not reachable from the dashboard' : null;
143
+ gate(els.kill, nestedWhy ?? (w.dead ? 'Worker is down' : blocked(sid, 'worker.kill')),
144
+ 'Kill this worker as if it crashed (in-flight calls reject, respawn when enabled)');
145
+ const chaosWhy = nestedWhy ?? blocked(sid, 'pool.chaos');
146
+ for (const el of [els.delay, els.fail, els.tout, els.apply, els.clear]) gate(el, chaosWhy, el.dataset.tip);
147
+ const entry = poolLists.get(sid)?.find((p) => p.poolId === poolId);
148
+ const text = entry ? chaosText(entry.chaos) : chaosWhy ? '' : 'chaos: …';
149
+ if (els.status.textContent !== text) els.status.textContent = text;
150
+ els.status.classList.toggle('on', !!entry?.chaos);
151
+ // Prefill the form from the active config once, never while the user types.
152
+ if (entry && !wk.filled && !els.delay.matches(':focus') && !els.fail.matches(':focus') && !els.tout.matches(':focus')) {
153
+ wk.filled = true;
154
+ els.delay.value = entry.chaos?.delayMs ?? '';
155
+ els.fail.value = entry.chaos?.failRate ? Math.round(entry.chaos.failRate * 100) : '';
156
+ els.tout.value = entry.chaos?.timeoutRate ? Math.round(entry.chaos.timeoutRate * 100) : '';
157
+ }
158
+ };
159
+
160
+ api.onInspectWorker((key, w, { actions }) => {
161
+ if (!actions) return;
162
+ if (!wk || wk.key !== key || !actions.contains(wk.els.kill)) buildWorker(actions, key);
163
+ updateWorker(w);
164
+ });
165
+
166
+ // Chaos can change from another dashboard or the palette: poll while inspected.
167
+ api.onTick(() => {
168
+ if (!wk || api.state.inspectWorker !== wk.key || ++wkTicks % 5) return;
169
+ if (!blocked(wk.sid, 'pool.list')) refreshPools(wk.sid);
170
+ });
171
+
172
+ /* ── island inspector ─────────────────────────────────────────────────── */
173
+
174
+ let isl = null; // { key, sid, instance, els, open, mode }
175
+
176
+ const loadProps = async () => {
177
+ const { sid, instance, els } = isl;
178
+ els.err.textContent = '';
179
+ els.ta.value = 'loading…';
180
+ try {
181
+ const res = await api.control(sid, 'island.props', { instance });
182
+ if (isl?.instance !== instance) return;
183
+ els.ta.value = res?.props !== undefined ? JSON.stringify(res.props, null, 2) : (res?.preview ?? '{}');
184
+ } catch (err) {
185
+ els.ta.value = '';
186
+ els.err.textContent = err.message;
187
+ }
188
+ };
189
+
190
+ const applyProps = async () => {
191
+ const { sid, instance, els } = isl;
192
+ let props;
193
+ try {
194
+ props = JSON.parse(els.ta.value);
195
+ } catch (err) {
196
+ els.err.textContent = `Invalid JSON: ${err.message}`;
197
+ return;
198
+ }
199
+ if (props === null || typeof props !== 'object' || Array.isArray(props)) {
200
+ els.err.textContent = 'Props must be a JSON object';
201
+ return;
202
+ }
203
+ els.err.textContent = '';
204
+ try {
205
+ await api.control(sid, 'island.updateProps', { instance, props });
206
+ toast(`${instance}: props updated`);
207
+ } catch (err) {
208
+ els.err.textContent = err.message;
209
+ toast(err, 'error');
210
+ }
211
+ };
212
+
213
+ const setMode = async (mode) => {
214
+ const { sid, instance } = isl;
215
+ try {
216
+ const res = await api.control(sid, 'island.setMode', { instance, mode });
217
+ if (isl?.instance === instance) isl.mode = res?.mode ?? mode;
218
+ toast(`${instance}: ${res?.mode ?? mode} mode`);
219
+ api.scheduleRender();
220
+ } catch (err) {
221
+ toast(err, 'error');
222
+ }
223
+ };
224
+
225
+ const buildIsland = (actions, key) => {
226
+ const sid = key.split('|')[0];
227
+ const instance = key.split('|').slice(1).join('|');
228
+ const box = ownBox(actions, 'island');
229
+ box.replaceChildren();
230
+ const edit = h('button', { textContent: 'Edit props', onclick: () => toggleEditor() });
231
+ const push = h('button', { textContent: 'Push', onclick: () => setMode('push') });
232
+ const poll = h('button', { textContent: 'Poll', onclick: () => setMode('poll') });
233
+ const mode = h('span', { className: 'chaos' }, h('span', { textContent: 'Mode' }), push, poll);
234
+ box.append(edit, mode);
235
+
236
+ const ta = h('textarea', { spellcheck: false });
237
+ ta.addEventListener('keydown', (e) => {
238
+ if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); applyProps(); }
239
+ });
240
+ const err = h('div', { className: 'err' });
241
+ const apply = h('button', { textContent: 'Apply', title: 'Send island.updateProps (Ctrl+Enter)', onclick: () => applyProps() });
242
+ const reload = h('button', { textContent: 'Reload', title: 'Re-read the current props from the app', onclick: () => loadProps() });
243
+ const close = h('button', { textContent: 'Close', onclick: () => toggleEditor(false) });
244
+ const editor = h('div', { className: 'act-props', style: 'display:none;flex-basis:100%' },
245
+ ta, err, h('div', { className: 'row' }, apply, reload, close));
246
+ box.append(editor);
247
+ isl = { key, sid, instance, open: false, mode: null, els: { edit, push, poll, editor, ta, err, apply, reload } };
248
+ };
249
+
250
+ const toggleEditor = (open = !isl.open) => {
251
+ isl.open = open;
252
+ isl.els.editor.style.display = open ? 'block' : 'none';
253
+ isl.els.edit.textContent = open ? 'Hide props' : 'Edit props';
254
+ if (open) loadProps();
255
+ };
256
+
257
+ const updateIsland = (island) => {
258
+ const { sid, els } = isl;
259
+ const ended = island.ended ? 'Island is unmounted' : null;
260
+ const propsWhy = ended ?? blocked(sid, 'island.updateProps');
261
+ gate(els.edit, propsWhy && !isl.open ? propsWhy : null, 'Edit the island props as JSON (island.updateProps)');
262
+ gate(els.apply, propsWhy, 'Send island.updateProps (Ctrl+Enter)');
263
+ gate(els.reload, ended ?? blocked(sid, 'island.props'), 'Re-read the current props from the app');
264
+ const modeWhy = ended ?? blocked(sid, 'island.setMode');
265
+ gate(els.push, modeWhy, "Push mode: the SharedArrayBuffer doorbell triggers flushes (polls anyway on a doorbell-free client)");
266
+ gate(els.poll, modeWhy, 'Poll mode: the main thread drains ops every 50ms');
267
+ const fw = (b, on) => { if (b.classList.contains('on') !== on) b.classList.toggle('on', on); b.style.borderColor = on ? '#58a6ff' : ''; };
268
+ fw(els.push, isl.mode === 'push');
269
+ fw(els.poll, isl.mode === 'poll');
270
+ };
271
+
272
+ api.onInspectIsland((key, island, { actions }) => {
273
+ if (!actions || !key) return;
274
+ if (!isl || isl.key !== key || !actions.contains(isl.els.edit)) buildIsland(actions, key);
275
+ updateIsland(island);
276
+ });
277
+
278
+ api.onReset(() => {
279
+ poolLists.clear();
280
+ wk = null;
281
+ isl = null;
282
+ });
283
+
284
+ /* ── palette ──────────────────────────────────────────────────────────── */
285
+
286
+ api.addPaletteItem({
287
+ id: 'actions.chaos-clear-all',
288
+ title: 'Chaos: clear all',
289
+ group: 'Actions',
290
+ hint: 'Remove fault injection from every runner in every live session',
291
+ run: async () => {
292
+ let n = 0;
293
+ for (const s of api.liveSessions()) {
294
+ if (!api.hasCommand(s.id, 'pool.chaos')) continue;
295
+ const list = await refreshPools(s.id);
296
+ for (const p of list.filter((x) => x.chaos)) {
297
+ try {
298
+ await api.control(s.id, 'pool.chaos', { poolId: p.poolId });
299
+ n++;
300
+ } catch (err) {
301
+ toast(err, 'error');
302
+ }
303
+ }
304
+ refreshPools(s.id);
305
+ }
306
+ toast(n ? `Cleared chaos on ${n} runner(s)` : 'No runner had chaos set', n ? 'ok' : 'info');
307
+ },
308
+ });
309
+
310
+ api.addPaletteItem({
311
+ id: 'actions.kill-inspected',
312
+ title: 'Kill inspected worker',
313
+ group: 'Actions',
314
+ hint: 'Crash the worker open in the worker inspector',
315
+ run: () => {
316
+ const key = api.state.inspectWorker;
317
+ if (!key) return toast('No worker is being inspected', 'info');
318
+ const [sid, poolId, slot] = key.split('|');
319
+ const why = poolId.includes('~') ? 'Nested pools are not reachable from the dashboard' : blocked(sid, 'worker.kill');
320
+ if (why) return toast(why, 'error');
321
+ killWorker(sid, poolId, Number(slot));
322
+ },
323
+ });
324
+ }