@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,438 @@
|
|
|
1
|
+
// Audits: live recommendations over the event stream. Keeps its own bounded
|
|
2
|
+
// aggregates (dashboard clock: performance.now() at ingest, since event `at`
|
|
3
|
+
// epochs differ per thread), re-runs the pure rules in ./audit-rules.js at
|
|
4
|
+
// most once a second, and renders findings as cards with entity chips that
|
|
5
|
+
// open the matching inspector.
|
|
6
|
+
import { runAudits, ruleCoverage, RULES, WINDOW_MS } from './audit-rules.js';
|
|
7
|
+
|
|
8
|
+
/** Series older than this are trimmed on each tick (rules look back ≤ WINDOW_MS). */
|
|
9
|
+
const KEEP_MS = Math.max(WINDOW_MS, 60_000) * 2;
|
|
10
|
+
/** Per-series sample cap, independent of age. */
|
|
11
|
+
const CAP = 2000;
|
|
12
|
+
/** Pending (enqueued, not yet dispatched/settled) call ids tracked per pool. */
|
|
13
|
+
const PENDING_CAP = 5000;
|
|
14
|
+
const MUTE_KEY = 'atoll.devtools.audits.muted';
|
|
15
|
+
const SEV_LABEL = { error: 'Errors', warn: 'Warnings', info: 'Info' };
|
|
16
|
+
|
|
17
|
+
const push = (arr, x, cap = CAP) => {
|
|
18
|
+
arr.push(x);
|
|
19
|
+
if (arr.length > cap * 1.25) arr.splice(0, arr.length - cap);
|
|
20
|
+
};
|
|
21
|
+
const trim = (arr, cutoff) => {
|
|
22
|
+
let i = 0;
|
|
23
|
+
while (i < arr.length && (arr[i].t ?? arr[i]) < cutoff) i++;
|
|
24
|
+
if (i) arr.splice(0, i);
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export function setup(api) {
|
|
28
|
+
const { esc } = api;
|
|
29
|
+
const now = () => performance.now();
|
|
30
|
+
|
|
31
|
+
/* ── aggregates ─────────────────────────────────────────────────────── */
|
|
32
|
+
|
|
33
|
+
const agg = {
|
|
34
|
+
sessions: new Map(), // sid → { longframes, frames, heap?, heapLimit? }
|
|
35
|
+
pools: new Map(), // `${sid}|${poolId}`
|
|
36
|
+
tasks: new Map(), // `${sid}|${poolId}|${taskId}`
|
|
37
|
+
workers: new Map(), // `${sid}|${poolId}|${slot}`
|
|
38
|
+
islands: new Map(), // `${sid}|${instance}`
|
|
39
|
+
mem: new Map(), // `${sid}|${path}`
|
|
40
|
+
fetches: [],
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const sessAgg = (sid) => {
|
|
44
|
+
let s = agg.sessions.get(sid);
|
|
45
|
+
if (!s) agg.sessions.set(sid, (s = { longframes: [], frames: [] }));
|
|
46
|
+
return s;
|
|
47
|
+
};
|
|
48
|
+
const poolAgg = (sid, poolId) => {
|
|
49
|
+
const key = `${sid}|${poolId}`;
|
|
50
|
+
let p = agg.pools.get(key);
|
|
51
|
+
if (!p) {
|
|
52
|
+
p = {
|
|
53
|
+
key, sid, poolId, initSeen: false, firstAt: now(), taskEvents: 0,
|
|
54
|
+
dispatches: [], runs: [], pending: new Set(), backlogSeries: [],
|
|
55
|
+
};
|
|
56
|
+
agg.pools.set(key, p);
|
|
57
|
+
}
|
|
58
|
+
return p;
|
|
59
|
+
};
|
|
60
|
+
const taskAgg = (sid, poolId, taskId) => {
|
|
61
|
+
const key = `${sid}|${poolId}|${taskId}`;
|
|
62
|
+
let t = agg.tasks.get(key);
|
|
63
|
+
if (!t) agg.tasks.set(key, (t = { key, sid, poolId, taskId, settles: [], argBytes: [], resultBytes: [] }));
|
|
64
|
+
return t;
|
|
65
|
+
};
|
|
66
|
+
const workerAgg = (sid, poolId, slot) => {
|
|
67
|
+
const key = `${sid}|${poolId}|${slot}`;
|
|
68
|
+
let w = agg.workers.get(key);
|
|
69
|
+
if (!w) agg.workers.set(key, (w = { key, sid, poolId, slot, respawns: [] }));
|
|
70
|
+
return w;
|
|
71
|
+
};
|
|
72
|
+
const islandAgg = (sid, instance) => {
|
|
73
|
+
const key = `${sid}|${instance}`;
|
|
74
|
+
let i = agg.islands.get(key);
|
|
75
|
+
if (!i) agg.islands.set(key, (i = { key, sid, instance, mounted: false, ended: false, batches: [] }));
|
|
76
|
+
return i;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
api.onEvent((session, e) => {
|
|
80
|
+
const sid = session.id;
|
|
81
|
+
const t = now();
|
|
82
|
+
switch (e.type) {
|
|
83
|
+
case 'pool:init': {
|
|
84
|
+
const p = poolAgg(sid, e.poolId);
|
|
85
|
+
Object.assign(p, {
|
|
86
|
+
initSeen: true, label: e.label, poolSize: e.poolSize, concurrency: e.concurrency,
|
|
87
|
+
dedicated: e.dedicated === true, dead: false, firstAt: t,
|
|
88
|
+
});
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
case 'pool:terminate': {
|
|
92
|
+
const p = agg.pools.get(`${sid}|${e.poolId}`);
|
|
93
|
+
if (p) { p.dead = true; p.pending.clear(); }
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
case 'worker:respawn':
|
|
97
|
+
push(workerAgg(sid, e.poolId, e.slot).respawns, t, 200);
|
|
98
|
+
break;
|
|
99
|
+
case 'worker:error':
|
|
100
|
+
workerAgg(sid, e.poolId, e.slot).lastError = e.message;
|
|
101
|
+
break;
|
|
102
|
+
case 'task:enqueue': {
|
|
103
|
+
const p = poolAgg(sid, e.poolId);
|
|
104
|
+
p.taskEvents++;
|
|
105
|
+
p.pending.add(e.callId);
|
|
106
|
+
if (p.pending.size > PENDING_CAP) p.pending.delete(p.pending.values().next().value);
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
case 'task:dispatch': {
|
|
110
|
+
const p = poolAgg(sid, e.poolId);
|
|
111
|
+
p.taskEvents++;
|
|
112
|
+
p.pending.delete(e.callId);
|
|
113
|
+
push(p.dispatches, { t, waitMs: e.waitMs });
|
|
114
|
+
if (e.argBytes !== undefined) push(taskAgg(sid, e.poolId, e.taskId).argBytes, { t, v: e.argBytes });
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
case 'task:settle': {
|
|
118
|
+
const p = poolAgg(sid, e.poolId);
|
|
119
|
+
p.taskEvents++;
|
|
120
|
+
p.pending.delete(e.callId);
|
|
121
|
+
if (e.runMs !== undefined) push(p.runs, { t, runMs: e.runMs });
|
|
122
|
+
const tk = taskAgg(sid, e.poolId, e.taskId);
|
|
123
|
+
push(tk.settles, { t, outcome: e.outcome, error: e.error });
|
|
124
|
+
if (e.resultBytes !== undefined) push(tk.resultBytes, { t, v: e.resultBytes });
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
case 'island:mount': {
|
|
128
|
+
const i = islandAgg(sid, e.instance);
|
|
129
|
+
Object.assign(i, { app: e.app, framework: e.framework, mounted: true, ended: false });
|
|
130
|
+
if (e.poolId) i.poolId = e.poolId;
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
case 'island:unmount':
|
|
134
|
+
islandAgg(sid, e.instance).ended = true;
|
|
135
|
+
break;
|
|
136
|
+
case 'island:ops':
|
|
137
|
+
push(islandAgg(sid, e.instance).batches, { t, count: e.count, bytes: e.bytes, replayMs: e.replayMs }, 500);
|
|
138
|
+
break;
|
|
139
|
+
case 'memory:write': {
|
|
140
|
+
const k = `${sid}|${e.path}`;
|
|
141
|
+
let m = agg.mem.get(k);
|
|
142
|
+
if (!m) agg.mem.set(k, (m = { key: k, sid, path: e.path, perSec: [] }));
|
|
143
|
+
const sec = Math.floor(t / 1000) * 1000;
|
|
144
|
+
const last = m.perSec[m.perSec.length - 1];
|
|
145
|
+
if (last && last.t === sec) last.v++;
|
|
146
|
+
else push(m.perSec, { t: sec, v: 1 }, 120);
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
case 'runtime:memory': {
|
|
150
|
+
if (e.worker) {
|
|
151
|
+
const w = workerAgg(sid, e.worker.poolId, e.worker.slot);
|
|
152
|
+
w.heap = e.heapBytes; w.heapLimit = e.heapLimitBytes;
|
|
153
|
+
} else {
|
|
154
|
+
const s = sessAgg(sid);
|
|
155
|
+
// measureUserAgentSpecificMemory's heapBytes is the whole agent
|
|
156
|
+
// cluster; compare only the window contexts against the limit.
|
|
157
|
+
const win = e.contexts?.filter((c) => c.scope === 'Window');
|
|
158
|
+
s.heap = e.contexts ? (win.length ? win.reduce((a, c) => a + c.bytes, 0) : undefined) : e.heapBytes;
|
|
159
|
+
s.heapLimit = e.heapLimitBytes;
|
|
160
|
+
}
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
case 'runtime:longframe':
|
|
164
|
+
push(sessAgg(sid).longframes, { t, ms: e.ms, blockingMs: e.blockingMs, scripts: e.scripts, hidden: !!e.hidden }, 500);
|
|
165
|
+
break;
|
|
166
|
+
case 'runtime:frames':
|
|
167
|
+
push(sessAgg(sid).frames, { t, fps: e.fps, dropped: e.dropped }, 300);
|
|
168
|
+
break;
|
|
169
|
+
case 'net:fetch':
|
|
170
|
+
push(agg.fetches, { sid, t, url: e.url, method: e.method, status: e.status, ms: e.ms, error: e.error }, 300);
|
|
171
|
+
break;
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
const clearAll = () => {
|
|
176
|
+
for (const v of Object.values(agg)) {
|
|
177
|
+
if (v instanceof Map) v.clear(); else v.length = 0;
|
|
178
|
+
}
|
|
179
|
+
findings = [];
|
|
180
|
+
lastRun = 0;
|
|
181
|
+
};
|
|
182
|
+
api.onReset(clearAll);
|
|
183
|
+
api.onReconcile((ids) => {
|
|
184
|
+
for (const map of [agg.pools, agg.tasks, agg.workers, agg.islands, agg.mem]) {
|
|
185
|
+
for (const [k, v] of map) if (!ids.has(v.sid)) map.delete(k);
|
|
186
|
+
}
|
|
187
|
+
for (const sid of [...agg.sessions.keys()]) if (!ids.has(sid)) agg.sessions.delete(sid);
|
|
188
|
+
agg.fetches = agg.fetches.filter((f) => ids.has(f.sid));
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
/** Age out old samples and record each pool's backlog depth (1Hz). */
|
|
192
|
+
const maintain = (t) => {
|
|
193
|
+
const cutoff = t - KEEP_MS;
|
|
194
|
+
for (const p of agg.pools.values()) {
|
|
195
|
+
trim(p.dispatches, cutoff); trim(p.runs, cutoff); trim(p.backlogSeries, cutoff);
|
|
196
|
+
push(p.backlogSeries, { t, v: p.pending.size }, 120);
|
|
197
|
+
}
|
|
198
|
+
for (const tk of agg.tasks.values()) { trim(tk.settles, cutoff); trim(tk.argBytes, cutoff); trim(tk.resultBytes, cutoff); }
|
|
199
|
+
for (const w of agg.workers.values()) trim(w.respawns, cutoff);
|
|
200
|
+
for (const i of agg.islands.values()) trim(i.batches, cutoff);
|
|
201
|
+
for (const m of agg.mem.values()) trim(m.perSec, cutoff);
|
|
202
|
+
for (const s of agg.sessions.values()) { trim(s.longframes, cutoff); trim(s.frames, cutoff); }
|
|
203
|
+
trim(agg.fetches, cutoff);
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
/** The rules' input, scoped to the selected session(s). */
|
|
207
|
+
const buildSnapshot = (t) => {
|
|
208
|
+
const sel = (sid) => api.inSel(sid);
|
|
209
|
+
const sessions = [...api.state.sessions.values()].filter((s) => sel(s.id)).map((s) => {
|
|
210
|
+
const a = agg.sessions.get(s.id);
|
|
211
|
+
return {
|
|
212
|
+
id: s.id, name: s.name, runtime: s.runtime, closed: s.closed, env: s.env,
|
|
213
|
+
heap: a?.heap, heapLimit: a?.heapLimit, longframes: a?.longframes ?? [], frames: a?.frames ?? [],
|
|
214
|
+
};
|
|
215
|
+
});
|
|
216
|
+
return {
|
|
217
|
+
now: t,
|
|
218
|
+
sessions,
|
|
219
|
+
pools: [...agg.pools.values()].filter((p) => sel(p.sid)).map((p) => ({ ...p, backlog: p.pending.size })),
|
|
220
|
+
tasks: [...agg.tasks.values()].filter((x) => sel(x.sid)),
|
|
221
|
+
workers: [...agg.workers.values()].filter((x) => sel(x.sid)),
|
|
222
|
+
islands: [...agg.islands.values()].filter((x) => sel(x.sid)),
|
|
223
|
+
memFields: [...agg.mem.values()].filter((x) => sel(x.sid)),
|
|
224
|
+
fetches: agg.fetches.filter((f) => sel(f.sid)),
|
|
225
|
+
};
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
/* ── mute state (localStorage) ──────────────────────────────────────── */
|
|
229
|
+
|
|
230
|
+
const loadMuted = () => {
|
|
231
|
+
try { return new Set(JSON.parse(localStorage.getItem(MUTE_KEY) ?? '[]')); } catch { return new Set(); }
|
|
232
|
+
};
|
|
233
|
+
const muted = loadMuted();
|
|
234
|
+
const saveMuted = () => {
|
|
235
|
+
try { localStorage.setItem(MUTE_KEY, JSON.stringify([...muted])); } catch { /* storage unavailable */ }
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
/* ── run loop ───────────────────────────────────────────────────────── */
|
|
239
|
+
|
|
240
|
+
let findings = [];
|
|
241
|
+
let coverage = {};
|
|
242
|
+
let lastRun = 0;
|
|
243
|
+
let lastSel;
|
|
244
|
+
let dirty = true;
|
|
245
|
+
let filter = 'all';
|
|
246
|
+
|
|
247
|
+
const run = () => {
|
|
248
|
+
const t = now();
|
|
249
|
+
const snap = buildSnapshot(t);
|
|
250
|
+
findings = runAudits(snap);
|
|
251
|
+
coverage = ruleCoverage(snap);
|
|
252
|
+
lastRun = t;
|
|
253
|
+
lastSel = api.state.sel;
|
|
254
|
+
dirty = true;
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
api.onTick(() => {
|
|
258
|
+
maintain(now());
|
|
259
|
+
run();
|
|
260
|
+
if (section.classList.contains('on')) paint();
|
|
261
|
+
});
|
|
262
|
+
api.onRender(() => {
|
|
263
|
+
// Selection changes re-run early, still capped at once a second.
|
|
264
|
+
if (api.state.sel !== lastSel && now() - lastRun >= 1000) run();
|
|
265
|
+
if (dirty && section.classList.contains('on')) paint();
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
/* ── view ───────────────────────────────────────────────────────────── */
|
|
269
|
+
|
|
270
|
+
const active = () => findings.filter((f) => !muted.has(f.rule));
|
|
271
|
+
const section = api.addView({
|
|
272
|
+
id: 'audits',
|
|
273
|
+
label: 'Audits',
|
|
274
|
+
order: 80,
|
|
275
|
+
title: 'Live recommendations: pool sizing, message cost, jank, errors, isolation',
|
|
276
|
+
badge: () => active().filter((f) => f.severity !== 'info').length || null,
|
|
277
|
+
html: `
|
|
278
|
+
<style>
|
|
279
|
+
#view-audits .au-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin: 10px 0; }
|
|
280
|
+
#view-audits .au-bar button.on { border-color: #58a6ff; color: #c9d1d9; }
|
|
281
|
+
#view-audits .au-card { border: 1px solid #21262d; border-left: 3px solid #8b949e; border-radius: 6px; padding: 8px 10px; margin-bottom: 8px; }
|
|
282
|
+
#view-audits .au-card.error { border-left-color: #f85149; }
|
|
283
|
+
#view-audits .au-card.warn { border-left-color: #d29922; }
|
|
284
|
+
#view-audits .au-card.info { border-left-color: #58a6ff; }
|
|
285
|
+
#view-audits .au-top { display: flex; align-items: baseline; gap: 8px; }
|
|
286
|
+
#view-audits .au-title { font-weight: 600; flex: 1; }
|
|
287
|
+
#view-audits .au-sev { font-size: 10px; text-transform: uppercase; letter-spacing: .05em; }
|
|
288
|
+
#view-audits .au-sev.error { color: #f85149; } #view-audits .au-sev.warn { color: #d29922; } #view-audits .au-sev.info { color: #58a6ff; }
|
|
289
|
+
#view-audits .au-detail { margin: 4px 0; }
|
|
290
|
+
#view-audits .au-fix { margin: 4px 0; color: #c9d1d9; }
|
|
291
|
+
#view-audits .au-fix b { color: #3fb950; font-weight: 600; }
|
|
292
|
+
#view-audits code { background: #161b22; border: 1px solid #21262d; border-radius: 4px; padding: 0 4px; font-size: 11px; }
|
|
293
|
+
#view-audits .au-chips { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 6px; }
|
|
294
|
+
#view-audits .au-chip { border: 1px solid #30363d; border-radius: 10px; padding: 0 8px; font-size: 11px; background: none; color: #58a6ff; cursor: pointer; }
|
|
295
|
+
#view-audits .au-chip:hover { border-color: #58a6ff; }
|
|
296
|
+
#view-audits .au-chip i { color: #8b949e; font-style: normal; margin-right: 4px; }
|
|
297
|
+
#view-audits .au-mute { background: none; border: 1px solid #30363d; border-radius: 4px; color: #8b949e; cursor: pointer; font-size: 10px; padding: 1px 6px; }
|
|
298
|
+
#view-audits .au-mute:hover { color: #c9d1d9; border-color: #8b949e; }
|
|
299
|
+
#view-audits .au-empty { color: #8b949e; padding: 24px 0; text-align: center; }
|
|
300
|
+
#view-audits .au-muted { margin-top: 14px; }
|
|
301
|
+
#view-audits .au-muted li { margin: 2px 0; }
|
|
302
|
+
#view-audits details { margin-top: 14px; }
|
|
303
|
+
#view-audits .au-cov { font-size: 10px; }
|
|
304
|
+
</style>
|
|
305
|
+
<div class="kpis" id="auKpis"></div>
|
|
306
|
+
<div class="au-bar" id="auFilter">
|
|
307
|
+
<button class="act on" data-f="all">All</button>
|
|
308
|
+
<button class="act" data-f="error">Errors</button>
|
|
309
|
+
<button class="act" data-f="warn">Warnings</button>
|
|
310
|
+
<button class="act" data-f="info">Info</button>
|
|
311
|
+
<span class="muted" id="auScope"></span>
|
|
312
|
+
</div>
|
|
313
|
+
<div id="auList"></div>
|
|
314
|
+
<div class="au-muted" id="auMuted"></div>
|
|
315
|
+
<details id="auRules">
|
|
316
|
+
<summary class="muted">What each rule needs</summary>
|
|
317
|
+
<table class="tkv"><tbody id="auRulesBody"></tbody></table>
|
|
318
|
+
</details>`,
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
const $ = (id) => section.querySelector(`#${id}`);
|
|
322
|
+
const code = (s) => esc(s).replace(/`([^`]+)`/g, '<code>$1</code>');
|
|
323
|
+
const ruleTitle = (id) => RULES.find((r) => r.id === id)?.title ?? id;
|
|
324
|
+
|
|
325
|
+
const paint = () => {
|
|
326
|
+
dirty = false;
|
|
327
|
+
const act = active();
|
|
328
|
+
const counts = { error: 0, warn: 0, info: 0 };
|
|
329
|
+
for (const f of act) counts[f.severity]++;
|
|
330
|
+
$('auKpis').innerHTML =
|
|
331
|
+
api.kpi(counts.error, 'errors', counts.error ? 'bad' : 'good') +
|
|
332
|
+
api.kpi(counts.warn, 'warnings', counts.warn ? 'warn' : 'good') +
|
|
333
|
+
api.kpi(counts.info, 'info') +
|
|
334
|
+
api.kpi(muted.size, 'muted rules');
|
|
335
|
+
const sel = api.state.sel ? api.state.sessions.get(api.state.sel) : null;
|
|
336
|
+
$('auScope').textContent = sel
|
|
337
|
+
? `session: ${sel.name ?? sel.id}`
|
|
338
|
+
: 'all live sessions';
|
|
339
|
+
for (const b of $('auFilter').querySelectorAll('button[data-f]')) {
|
|
340
|
+
b.classList.toggle('on', b.dataset.f === filter);
|
|
341
|
+
const n = b.dataset.f === 'all' ? act.length : counts[b.dataset.f];
|
|
342
|
+
b.textContent = `${b.dataset.f === 'all' ? 'All' : SEV_LABEL[b.dataset.f]} (${n})`;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const shown = filter === 'all' ? act : act.filter((f) => f.severity === filter);
|
|
346
|
+
$('auList').innerHTML = shown.length
|
|
347
|
+
? shown.map((f) => `
|
|
348
|
+
<div class="au-card ${f.severity}">
|
|
349
|
+
<div class="au-top">
|
|
350
|
+
<span class="au-sev ${f.severity}">${f.severity}</span>
|
|
351
|
+
<span class="au-title">${esc(f.title)}</span>
|
|
352
|
+
<button class="au-mute" data-mute="${esc(f.rule)}" title="Mute every '${esc(ruleTitle(f.rule))}' finding">mute rule</button>
|
|
353
|
+
</div>
|
|
354
|
+
<div class="au-detail muted">${code(f.detail)}</div>
|
|
355
|
+
<div class="au-fix"><b>Fix:</b> ${code(f.fix)}</div>
|
|
356
|
+
${f.docs ? `<a class="wlink" href="${esc(f.docs.href)}" target="_blank" rel="noopener">${esc(f.docs.label)} ↗</a>` : ''}
|
|
357
|
+
${f.entities?.length ? `<div class="au-chips">${f.entities.map((en) =>
|
|
358
|
+
`<button class="au-chip" data-kind="${en.kind}" data-key="${esc(en.key)}" title="Open ${en.kind}"><i>${en.kind}</i>${esc(en.label)}</button>`).join('')}</div>` : ''}
|
|
359
|
+
<div class="muted" style="font-size:10px;margin-top:4px">rule: ${esc(f.rule)}</div>
|
|
360
|
+
</div>`).join('')
|
|
361
|
+
: `<div class="au-empty">${act.length ? 'No findings at this severity.' : 'No issues found — audits re-run every second over live data'}</div>`;
|
|
362
|
+
|
|
363
|
+
const mutedCounts = new Map();
|
|
364
|
+
for (const f of findings) if (muted.has(f.rule)) mutedCounts.set(f.rule, (mutedCounts.get(f.rule) ?? 0) + 1);
|
|
365
|
+
$('auMuted').innerHTML = muted.size
|
|
366
|
+
? `<div class="muted">Muted rules</div><ul>${[...muted].map((r) =>
|
|
367
|
+
`<li>${esc(ruleTitle(r))} <span class="muted">(${esc(r)}${mutedCounts.get(r) ? `, ${mutedCounts.get(r)} hidden` : ''})</span>
|
|
368
|
+
<button class="au-mute" data-unmute="${esc(r)}">unmute</button></li>`).join('')}</ul>`
|
|
369
|
+
: '';
|
|
370
|
+
|
|
371
|
+
$('auRulesBody').innerHTML = RULES.map((r) => `<tr>
|
|
372
|
+
<td>${esc(r.title)}<div class="muted au-cov">${esc(r.id)}</div></td>
|
|
373
|
+
<td>${esc(r.needs)} <span class="au-cov ${coverage[r.id] ? 'ok' : 'muted'}">${coverage[r.id] ? '● data seen' : '○ waiting for data'}</span>${muted.has(r.id) ? ' <span class="pill">muted</span>' : ''}</td>
|
|
374
|
+
</tr>`).join('');
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
const openEntity = (kind, key) => {
|
|
378
|
+
const st = api.state;
|
|
379
|
+
if (kind === 'worker') {
|
|
380
|
+
st.inspectWorker = key;
|
|
381
|
+
api.openSub('dashboard', 'tv-worker');
|
|
382
|
+
} else if (kind === 'island') {
|
|
383
|
+
st.inspect = key;
|
|
384
|
+
api.openSub('dashboard', 'tv-island');
|
|
385
|
+
} else if (kind === 'pool') {
|
|
386
|
+
const wk = [...st.workers.keys()].filter((k) => k.startsWith(`${key}|`));
|
|
387
|
+
const live = wk.find((k) => !st.workers.get(k)?.dead) ?? wk[0];
|
|
388
|
+
if (live) {
|
|
389
|
+
st.inspectWorker = live;
|
|
390
|
+
api.openSub('dashboard', 'tv-worker');
|
|
391
|
+
} else {
|
|
392
|
+
api.openView('dashboard');
|
|
393
|
+
}
|
|
394
|
+
} else if (kind === 'session') {
|
|
395
|
+
st.sel = key;
|
|
396
|
+
api.render();
|
|
397
|
+
}
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
section.addEventListener('click', (e) => {
|
|
401
|
+
const t = e.target.closest?.('button');
|
|
402
|
+
if (!t || !section.contains(t)) return;
|
|
403
|
+
if (t.dataset.f) {
|
|
404
|
+
filter = t.dataset.f;
|
|
405
|
+
paint();
|
|
406
|
+
} else if (t.dataset.mute) {
|
|
407
|
+
muted.add(t.dataset.mute); saveMuted(); paint(); api.render();
|
|
408
|
+
} else if (t.dataset.unmute) {
|
|
409
|
+
muted.delete(t.dataset.unmute); saveMuted(); paint(); api.render();
|
|
410
|
+
} else if (t.dataset.kind) {
|
|
411
|
+
openEntity(t.dataset.kind, t.dataset.key);
|
|
412
|
+
}
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
/* ── palette ────────────────────────────────────────────────────────── */
|
|
416
|
+
|
|
417
|
+
api.addPaletteItem({
|
|
418
|
+
id: 'audits.show',
|
|
419
|
+
title: 'Audits: show issues',
|
|
420
|
+
group: 'Audits',
|
|
421
|
+
hint: 'live recommendations',
|
|
422
|
+
run: () => { filter = 'all'; api.openView('audits'); },
|
|
423
|
+
});
|
|
424
|
+
api.addPaletteItem({
|
|
425
|
+
id: 'audits.errors',
|
|
426
|
+
title: 'Audits: show errors only',
|
|
427
|
+
group: 'Audits',
|
|
428
|
+
run: () => { filter = 'error'; api.openView('audits'); },
|
|
429
|
+
});
|
|
430
|
+
api.addPaletteItem({
|
|
431
|
+
id: 'audits.unmute',
|
|
432
|
+
title: 'Audits: unmute all rules',
|
|
433
|
+
group: 'Audits',
|
|
434
|
+
run: () => { muted.clear(); saveMuted(); api.openView('audits'); },
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
run();
|
|
438
|
+
}
|