@zerotal/devtools 1.6.2 → 1.7.0

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 (43) hide show
  1. package/CHANGELOG.md +233 -1
  2. package/api-surface.md +296 -0
  3. package/package.json +5 -4
  4. package/src/DevtoolsInjectionMiddleware.ts +41 -4
  5. package/src/RequestTrace.ts +96 -1
  6. package/src/TraceStore.ts +12 -0
  7. package/src/activity.ts +116 -0
  8. package/src/callsite.ts +146 -0
  9. package/src/client/filter.ts +108 -0
  10. package/src/client/index.ts +122 -0
  11. package/src/client/metrics.ts +98 -0
  12. package/src/client/registry.ts +65 -0
  13. package/src/client/state.ts +311 -0
  14. package/src/client/tabs/all.ts +276 -0
  15. package/src/client/tabs/app.ts +292 -0
  16. package/src/client/tabs/cache.ts +49 -0
  17. package/src/client/tabs/channel.ts +263 -0
  18. package/src/client/tabs/exceptions.ts +68 -0
  19. package/src/client/tabs/jobs.ts +50 -0
  20. package/src/client/tabs/logs.ts +44 -0
  21. package/src/client/tabs/mail.ts +59 -0
  22. package/src/client/tabs/queries.ts +124 -0
  23. package/src/client/tabs/request.ts +76 -0
  24. package/src/client/tabs/timeline.ts +132 -0
  25. package/src/client/tabs/types.ts +51 -0
  26. package/src/client/transport.ts +81 -0
  27. package/src/client/tree.ts +138 -0
  28. package/src/client/ui/format.ts +118 -0
  29. package/src/client/ui/render.ts +87 -0
  30. package/src/client/ui/shell.ts +511 -0
  31. package/src/client/ui/theme.ts +389 -0
  32. package/src/client-auto.ts +1 -1
  33. package/src/config.ts +77 -2
  34. package/src/dashboard-auto.ts +1 -1
  35. package/src/editor.ts +107 -0
  36. package/src/enabled.ts +59 -0
  37. package/src/index.ts +19 -3
  38. package/src/map.ts +213 -0
  39. package/src/provider/DevtoolsProvider.ts +32 -7
  40. package/src/redaction.ts +161 -20
  41. package/src/tracing.ts +213 -24
  42. package/src/client.ts +0 -1048
  43. package/src/panel-app.js +0 -519
package/src/client.ts DELETED
@@ -1,1048 +0,0 @@
1
- /**
2
- * @zerotal/devtools — browser client
3
- *
4
- * Usage (in your app.js / frontend entry):
5
- * import { DevTools } from '@zerotal/devtools/client';
6
- * DevTools.start();
7
- *
8
- * Connects to the SSE stream served by DevtoolsInjectionMiddleware and renders
9
- * a live floating panel. No script injection by the server is required.
10
- */
11
-
12
- import type {
13
- RequestTrace,
14
- QuerySpan,
15
- LogEntry,
16
- MailEntry,
17
- CacheEntry,
18
- JobEntry,
19
- TraceChannelDescriptor,
20
- TraceChannelEntry,
21
- } from "./RequestTrace.ts";
22
-
23
- export interface DevtoolsClientOptions {
24
- /** Base URL path for the devtools API. Default: '/__zerotal/devtools' */
25
- endpoint?: string;
26
- /**
27
- * How the panel is mounted.
28
- *
29
- * `'floating'` (default) pins a collapsible bar to the bottom of the page.
30
- * `'standalone'` fills the window and drops the collapse/close controls — the
31
- * inspector dashboard. Both run the same renderers, so a tab added for one
32
- * exists in the other.
33
- */
34
- mode?: "floating" | "standalone";
35
- /** Element to mount into. Defaults to `document.body`. */
36
- mount?: HTMLElement;
37
- }
38
-
39
- // ── Styles (injected into Shadow DOM) ─────────────────────────────────────────
40
-
41
- const CSS = `<style>
42
- :host { all: initial; }
43
- * { box-sizing: border-box; margin: 0; padding: 0; }
44
- #wrap {
45
- --bg: #1a1b26; --surf: #24283b; --card: #2f3452; --bdr: #3b4261;
46
- --text: #c0caf5; --muted: #565f89; --purple: #7aa2f7;
47
- --green: #9ece6a; --yellow: #e0af68; --red: #f7768e;
48
- --cyan: #7dcfff; --orange: #ff9e64;
49
- font: 12px/1.5 'JetBrains Mono','Fira Code','SF Mono',ui-monospace,monospace;
50
- color: var(--text);
51
- }
52
- /* ── utility colours ──────────────────────────────────────────────────────── */
53
- .green { color: var(--green); }
54
- .yellow { color: var(--yellow); }
55
- .red { color: var(--red); }
56
- .cyan { color: var(--cyan); }
57
- .dim { color: var(--muted); }
58
- /* ── bar ──────────────────────────────────────────────────────────────────── */
59
- #bar {
60
- height: 32px; background: var(--bg); border-top: 1px solid var(--bdr);
61
- display: flex; align-items: center; gap: 5px; padding: 0 8px;
62
- cursor: pointer; user-select: none; overflow: hidden; flex-shrink: 0;
63
- }
64
- .logo { color: var(--purple); font-weight: 700; font-size: 13px; flex-shrink: 0; }
65
- .dot { font-size: 8px; }
66
- .dot.ok { color: var(--green); }
67
- .dot.err { color: var(--red); animation: blink 1s step-start infinite; }
68
- @keyframes blink { 50% { opacity: 0.25; } }
69
- .bdiv { color: var(--bdr); flex-shrink: 0; }
70
- .sp { flex: 1; }
71
- .meth { font-weight: 700; font-size: 11px; flex-shrink: 0; }
72
- .meth.get { color: var(--green); }
73
- .meth.post { color: var(--cyan); }
74
- .meth.put, .meth.patch { color: var(--yellow); }
75
- .meth.delete { color: var(--red); }
76
- .bpath { max-width: 280px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; }
77
- .sc { font-weight: 700; font-size: 11px; flex-shrink: 0; }
78
- .sc.ok { color: var(--green); }
79
- .sc.redir { color: var(--cyan); }
80
- .sc.cli { color: var(--yellow); }
81
- .sc.srv { color: var(--red); }
82
- .chip {
83
- font-size: 10px; padding: 1px 5px; border-radius: 999px;
84
- background: var(--card); border: 1px solid var(--bdr); white-space: nowrap; flex-shrink: 0;
85
- }
86
- .chip.warn { border-color: var(--yellow); color: var(--yellow); }
87
- .chip.ok { border-color: var(--green); color: var(--green); }
88
- .ibtn {
89
- height: 22px; padding: 0 6px;
90
- background: var(--card); border: 1px solid var(--bdr); color: var(--text);
91
- cursor: pointer; border-radius: 4px; font-size: 11px; font-family: inherit;
92
- display: flex; align-items: center; gap: 3px; flex-shrink: 0;
93
- }
94
- .ibtn:hover { background: var(--surf); border-color: var(--purple); }
95
- .ibtn.live-on { border-color: var(--green); color: var(--green); }
96
- /* ── panel ────────────────────────────────────────────────────────────────── */
97
- #panel {
98
- height: 380px; background: var(--bg); border-top: 1px solid var(--bdr);
99
- display: flex; flex-direction: column;
100
- }
101
- #tabs {
102
- display: flex; gap: 2px; padding: 4px 8px 0;
103
- background: var(--surf); border-bottom: 1px solid var(--bdr);
104
- flex-shrink: 0; overflow-x: auto;
105
- }
106
- .tab {
107
- height: 28px; padding: 0 10px; background: transparent; border: none;
108
- border-bottom: 2px solid transparent; color: var(--muted); cursor: pointer;
109
- font: inherit; font-size: 11px; white-space: nowrap;
110
- display: flex; align-items: center; gap: 4px;
111
- }
112
- .tab:hover { color: var(--text); }
113
- .tab.active { color: var(--text); border-bottom-color: var(--purple); }
114
- .tbdg {
115
- font-size: 10px; padding: 0 4px; border-radius: 999px;
116
- background: var(--card); min-width: 16px; text-align: center;
117
- }
118
- .tbdg.warn { background: transparent; color: var(--yellow); }
119
- .ldot { font-size: 8px; color: var(--green); animation: blink 1.5s step-start infinite; }
120
- #content { flex: 1; overflow-y: auto; overflow-x: hidden; }
121
- /* ── content shared ───────────────────────────────────────────────────────── */
122
- .empty { color: var(--muted); text-align: center; padding: 40px 20px; }
123
- .sec { padding: 10px 12px; border-bottom: 1px solid var(--bdr); }
124
- .sec:last-child { border-bottom: none; }
125
- .stitle { font-size: 10px; text-transform: uppercase; letter-spacing: .6px; color: var(--muted); margin-bottom: 8px; }
126
- .stats { display: flex; flex-wrap: wrap; gap: 1px; background: var(--bdr); border-bottom: 1px solid var(--bdr); }
127
- .stat { flex: 1; min-width: 90px; padding: 8px 12px; background: var(--bg); }
128
- .slbl { font-size: 10px; color: var(--muted); margin-bottom: 2px; }
129
- .sval { font-weight: 700; font-size: 13px; }
130
- .rcard { padding: 8px 12px; border-bottom: 1px solid var(--bdr); background: var(--surf); font-size: 12px; }
131
- /* ── queries ──────────────────────────────────────────────────────────────── */
132
- .qrow { padding: 8px 12px; border-bottom: 1px solid var(--bdr); }
133
- .qrow:last-child { border-bottom: none; }
134
- .qmeta { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
135
- .qdur { font-weight: 700; font-size: 11px; min-width: 44px; }
136
- .qbar { flex: 1; height: 4px; background: var(--card); border-radius: 2px; max-width: 120px; }
137
- .qfill { height: 100%; background: var(--purple); border-radius: 2px; }
138
- .qsql { font-size: 11px; white-space: pre-wrap; word-break: break-all; }
139
- .qbind { font-size: 10px; color: var(--muted); margin-top: 3px; }
140
- .bind { color: var(--orange); }
141
- .wrow { padding: 8px 12px; border-bottom: 1px solid var(--bdr); border-left: 3px solid var(--yellow); }
142
- .whead { color: var(--yellow); margin-bottom: 4px; font-size: 12px; }
143
- /* ── logs ─────────────────────────────────────────────────────────────────── */
144
- .lrow { display: flex; gap: 6px; padding: 4px 12px; border-bottom: 1px solid var(--bdr); }
145
- .ltime { color: var(--muted); min-width: 54px; font-size: 10px; }
146
- .llvl { min-width: 38px; font-weight: 700; font-size: 11px; }
147
- .llvl.log, .llvl.debug, .llvl.info { color: var(--cyan); }
148
- .llvl.warn { color: var(--yellow); }
149
- .llvl.error { color: var(--red); }
150
- .lmsg { font-size: 11px; white-space: pre-wrap; word-break: break-all; }
151
- /* ── request kv table ─────────────────────────────────────────────────────── */
152
- .kv { width: 100%; border-collapse: collapse; font-size: 11px; }
153
- .kv td { padding: 4px 12px; border-bottom: 1px solid var(--bdr); vertical-align: top; }
154
- .kv td:first-child { color: var(--muted); min-width: 160px; font-size: 10px; }
155
- /* ── cards (mail / jobs) ──────────────────────────────────────────────────── */
156
- .card { padding: 9px 12px; border-bottom: 1px solid var(--bdr); }
157
- /* ── all-requests list ────────────────────────────────────────────────────── */
158
- .hrow {
159
- display: flex; align-items: center; gap: 6px;
160
- padding: 5px 12px; border-bottom: 1px solid var(--bdr);
161
- cursor: pointer; font-size: 11px;
162
- }
163
- .hrow:hover { background: var(--surf); }
164
- .hrow.cur { background: var(--card); }
165
- .hpath { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
166
- /* ── filter bar ───────────────────────────────────────────────────────────── */
167
- .fbar { display: flex; gap: 6px; align-items: center; padding: 6px 12px; border-bottom: 1px solid var(--bdr); background: var(--surf); position: sticky; top: 0; z-index: 1; }
168
- .finput {
169
- flex: 1; height: 24px; padding: 0 8px; font: inherit; font-size: 11px;
170
- background: var(--bg); border: 1px solid var(--bdr); border-radius: 4px; color: var(--text);
171
- }
172
- .finput:focus { outline: none; border-color: var(--purple); }
173
- .finput::placeholder { color: var(--muted); }
174
- /* ── timeline waterfall ───────────────────────────────────────────────────── */
175
- .trow { display: flex; align-items: center; gap: 8px; padding: 3px 12px; font-size: 11px; }
176
- .trow:hover { background: var(--surf); }
177
- .tlbl { min-width: 78px; font-size: 10px; text-align: right; flex-shrink: 0; }
178
- .ttrack { flex: 1; height: 12px; position: relative; background: var(--card); border-radius: 2px; }
179
- .tmark {
180
- position: absolute; top: 0; height: 12px; min-width: 3px; border-radius: 2px;
181
- background: var(--purple);
182
- }
183
- .tmark.query { background: var(--purple); }
184
- .tmark.cache { background: var(--cyan); }
185
- .tmark.mail { background: var(--green); }
186
- .tmark.job { background: var(--orange); }
187
- .tmark.log { background: var(--muted); }
188
- .tmark.warn { background: var(--red); }
189
- .tmark.chan { background: var(--yellow); }
190
- .ttxt { flex: 2; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 10px; }
191
- .tkey { display: flex; gap: 10px; flex-wrap: wrap; padding: 6px 12px; font-size: 10px; color: var(--muted); }
192
- .tkey i { display: inline-block; width: 8px; height: 8px; border-radius: 2px; margin-right: 3px; vertical-align: middle; font-style: normal; }
193
- /* ── generic channel rows ─────────────────────────────────────────────────── */
194
- .crow { padding: 7px 12px; border-bottom: 1px solid var(--bdr); }
195
- .crow.warn { border-left: 3px solid var(--yellow); }
196
- .chead { display: flex; align-items: center; gap: 8px; margin-bottom: 3px; }
197
- .cttl { font-size: 11px; word-break: break-word; }
198
- .cmeta { font-size: 10px; color: var(--muted); display: flex; gap: 10px; flex-wrap: wrap; }
199
- /* ── standalone dashboard ─────────────────────────────────────────────────── */
200
- #wrap.standalone { display: flex; flex-direction: column; height: 100vh; background: var(--bg); }
201
- #wrap.standalone #panel { flex: 1; height: auto; border-top: none; }
202
- #wrap.standalone #bar { border-top: none; border-bottom: 1px solid var(--bdr); cursor: default; order: -1; }
203
- </style>`;
204
-
205
- // ── Extension registry ───────────────────────────────────────────────────────────
206
- //
207
- // Other packages add their own tab to the injected panel — a unified dev tool across the
208
- // framework. They call `window.__zerotalDevtools?.register(panel)` from their own browser code
209
- // (e.g. `@zerotal/flow`'s time-travel timeline); the injected panel renders the extra tab and
210
- // calls `panel.render(el)` when it's shown. `refresh(id)` lets an extension push a live update
211
- // (a new badge count, re-render the open tab). The registry is created lazily by whichever runs
212
- // first (this panel or an extension), so registration is order-independent.
213
-
214
- /** A panel another package contributes as a tab in the Zerotal devtools. */
215
- export interface DevtoolsPanelPlugin {
216
- /** Unique id — the tab is addressed internally as `plugin:<id>`. */
217
- id: string;
218
- /** Tab label. */
219
- title: string;
220
- /** Optional badge value (e.g. a count); a falsy return hides the badge. */
221
- badge?: () => number | string | undefined;
222
- /** Render the panel's content into `el` (the shared, persistent content area). */
223
- render: (el: HTMLElement) => void;
224
- }
225
-
226
- interface DevtoolsRegistry {
227
- panels: DevtoolsPanelPlugin[];
228
- /** @internal set by the host panel — called when a panel registers. */
229
- _emit: ((p: DevtoolsPanelPlugin) => void) | null;
230
- /** @internal set by the host panel — called on refresh(id). */
231
- _refresh: ((id?: string) => void) | null;
232
- register(panel: DevtoolsPanelPlugin): DevtoolsPanelPlugin;
233
- refresh(id?: string): void;
234
- }
235
-
236
- /**
237
- * Match a trace against the All tab's filter box.
238
- *
239
- * Every space-separated term has to match, so `posts 500` narrows twice rather
240
- * than widening — a filter that ORs its terms gets less useful the more you type.
241
- * The haystack covers what you would search a request list by: method, path,
242
- * status, and the route it matched.
243
- *
244
- * Exported because it is the only part of the panel that is logic rather than
245
- * markup, and it is worth testing without a DOM.
246
- */
247
- export function matchesFilter(trace: RequestTrace, query: string): boolean {
248
- const terms = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
249
- if (!terms.length) return true;
250
- const haystack = [
251
- trace.method,
252
- trace.path,
253
- String(trace.statusCode),
254
- trace.route?.pattern ?? "",
255
- trace.route?.controller ?? "",
256
- trace.route?.action ?? "",
257
- ]
258
- .join(" ")
259
- .toLowerCase();
260
- return terms.every((term) => haystack.includes(term));
261
- }
262
-
263
- /** Get-or-create the global devtools extension registry. */
264
- function _ensureRegistry(): DevtoolsRegistry {
265
- const w = window as unknown as { __zerotalDevtools?: DevtoolsRegistry };
266
- if (!w.__zerotalDevtools) {
267
- w.__zerotalDevtools = {
268
- panels: [],
269
- _emit: null,
270
- _refresh: null,
271
- register(panel: DevtoolsPanelPlugin) {
272
- this.panels.push(panel);
273
- this._emit?.(panel);
274
- return panel;
275
- },
276
- refresh(id?: string) {
277
- this._refresh?.(id);
278
- },
279
- };
280
- }
281
- return w.__zerotalDevtools;
282
- }
283
-
284
- // ── Public API ─────────────────────────────────────────────────────────────────
285
-
286
- export const DevTools = {
287
- start(opts: DevtoolsClientOptions = {}): void {
288
- if (typeof document === "undefined") return;
289
- if (document.getElementById("__zerotal_dt__")) return;
290
-
291
- const BASE = (opts.endpoint ?? "/__zerotal/devtools").replace(/\/$/, "");
292
- const standalone = opts.mode === "standalone";
293
- const registry = _ensureRegistry();
294
-
295
- // ── State ─────────────────────────────────────────────────────────────────
296
- let traces: RequestTrace[] = [];
297
- let channels: TraceChannelDescriptor[] = [];
298
- let selected: RequestTrace | null = null;
299
- let tab = "queries";
300
- // The dashboard has nothing to collapse into — it is always open.
301
- let open = standalone;
302
- let live = true;
303
- let connected = false;
304
- let filter = "";
305
-
306
- // ── DOM ───────────────────────────────────────────────────────────────────
307
- const host = document.createElement("div");
308
- host.id = "__zerotal_dt__";
309
- host.style.cssText = standalone
310
- ? "position:fixed;inset:0;z-index:2147483647"
311
- : "position:fixed;bottom:0;left:0;right:0;z-index:2147483647;pointer-events:none";
312
- (opts.mount ?? document.body).appendChild(host);
313
-
314
- const shadow = host.attachShadow({ mode: "open" });
315
- shadow.innerHTML =
316
- `${CSS}<div id="wrap" class="${standalone ? "standalone" : ""}" style="pointer-events:auto">` +
317
- `<div id="panel" style="display:${standalone ? "flex" : "none"}">` +
318
- `<div id="tabs"></div><div id="content"></div></div>` +
319
- `<div id="bar"></div></div>`;
320
-
321
- function $<T extends HTMLElement = HTMLElement>(sel: string): T {
322
- return shadow.querySelector<T>(sel)!;
323
- }
324
-
325
- // ── Permanent event delegation ────────────────────────────────────────────
326
- // Listeners on #bar, #tabs, #content survive innerHTML replacement of
327
- // their children because they are on the persistent container elements.
328
-
329
- $<HTMLElement>("#bar").addEventListener("click", (e: MouseEvent) => {
330
- const btn = (e.target as HTMLElement).closest("[data-action]") as HTMLElement | null;
331
- if (!btn) {
332
- togglePanel();
333
- return;
334
- }
335
- switch (btn.dataset["action"]) {
336
- case "toggle":
337
- togglePanel();
338
- break;
339
- case "live":
340
- enableLive();
341
- break;
342
- case "pin":
343
- live = false;
344
- renderBar();
345
- break;
346
- case "clear":
347
- clearTraces();
348
- break;
349
- case "dash":
350
- window.open(BASE, "_blank");
351
- break;
352
- case "close":
353
- sse.close();
354
- host.remove();
355
- break;
356
- }
357
- });
358
-
359
- $<HTMLElement>("#tabs").addEventListener("click", (e: MouseEvent) => {
360
- const btn = (e.target as HTMLElement).closest("[data-tab]") as HTMLElement | null;
361
- if (btn) {
362
- tab = btn.dataset["tab"]!;
363
- renderContent();
364
- }
365
- });
366
-
367
- $<HTMLElement>("#content").addEventListener("click", (e: MouseEvent) => {
368
- const row = (e.target as HTMLElement).closest("[data-idx]") as HTMLElement | null;
369
- if (row) selectTrace(traces[Number(row.dataset["idx"])]!);
370
- });
371
-
372
- // The filter input is re-rendered with the list it filters, so the listener
373
- // lives on the persistent content element and the caret is restored after.
374
- $<HTMLElement>("#content").addEventListener("input", (e: Event) => {
375
- const input = e.target as HTMLInputElement;
376
- if (input.id !== "filter") return;
377
- filter = input.value;
378
- renderAll();
379
- const next = shadow.querySelector<HTMLInputElement>("#filter");
380
- if (next) {
381
- next.focus();
382
- next.setSelectionRange(next.value.length, next.value.length);
383
- }
384
- });
385
-
386
- // ── SSE ───────────────────────────────────────────────────────────────────
387
- const sse = new EventSource(`${BASE}/sse`);
388
-
389
- sse.onopen = () => {
390
- connected = true;
391
- renderBar();
392
- };
393
- sse.onerror = () => {
394
- connected = false;
395
- renderBar();
396
- };
397
-
398
- sse.onmessage = (e: MessageEvent<string>) => {
399
- type Msg =
400
- | { type: "history"; data: RequestTrace[]; channels?: TraceChannelDescriptor[] }
401
- | { type: "trace"; data: RequestTrace }
402
- | { type: "clear" };
403
-
404
- const msg = JSON.parse(e.data) as Msg;
405
-
406
- if (msg.type === "history") {
407
- traces = msg.data;
408
- // Descriptors arrive with the history so a package's tab is on screen at
409
- // first paint rather than after that package's next entry.
410
- channels = msg.channels ?? [];
411
- if (live || !selected) selected = traces[0] ?? null;
412
- renderBar();
413
- if (open) renderContent();
414
- } else if (msg.type === "trace") {
415
- traces.unshift(msg.data);
416
- if (traces.length > 100) traces.length = 100;
417
- if (live) {
418
- selected = msg.data;
419
- renderBar();
420
- if (open) renderContent();
421
- } else {
422
- renderBar();
423
- if (open && tab === "all") renderAll();
424
- }
425
- } else {
426
- traces = [];
427
- selected = null;
428
- renderBar();
429
- if (open) renderContent();
430
- }
431
- };
432
-
433
- // ── Keyboard shortcut ─────────────────────────────────────────────────────
434
- document.addEventListener("keydown", (e: KeyboardEvent) => {
435
- if ((e.altKey || e.metaKey) && e.key === "d") {
436
- e.preventDefault();
437
- togglePanel();
438
- }
439
- });
440
-
441
- // ── Extension panels ───────────────────────────────────────────────────────
442
- // A registered plugin adds a tab; a late registration (e.g. flow enabling its timeline
443
- // once its WebSocket is ready) refreshes the tab strip if the panel is open.
444
- registry._emit = () => {
445
- if (open) renderTabs();
446
- };
447
- registry._refresh = (id) => {
448
- if (!open) return;
449
- renderTabs(); // update the plugin's badge
450
- if (tab.indexOf("plugin:") === 0 && (!id || tab === "plugin:" + id)) renderPlugin();
451
- };
452
-
453
- // ── Initial render ────────────────────────────────────────────────────────
454
- renderBar();
455
- if (open) renderContent();
456
-
457
- // ─────────────────────────────────────────────────────────────────────────
458
- // Function declarations (hoisted — can reference each other freely)
459
- // ─────────────────────────────────────────────────────────────────────────
460
-
461
- function togglePanel() {
462
- if (standalone) return; // nothing to collapse into
463
- open = !open;
464
- $<HTMLElement>("#panel").style.display = open ? "flex" : "none";
465
- renderBar();
466
- if (open) renderContent();
467
- }
468
-
469
- function selectTrace(t: RequestTrace) {
470
- selected = t;
471
- live = false;
472
- tab = "queries";
473
- renderBar();
474
- if (open) renderContent();
475
- }
476
-
477
- function enableLive() {
478
- live = true;
479
- selected = traces[0] ?? null;
480
- renderBar();
481
- if (open) renderContent();
482
- }
483
-
484
- function clearTraces() {
485
- fetch(`${BASE}/api/clear`, { method: "POST" });
486
- }
487
-
488
- // ── Bar ───────────────────────────────────────────────────────────────────
489
-
490
- function renderBar() {
491
- const T = selected;
492
- const dot = `<span class="dot ${connected ? "ok" : "err"}" title="${connected ? "Connected" : "Disconnected"}">●</span>`;
493
- const liveBtn = live
494
- ? `<button class="ibtn live-on" data-action="pin" title="Live — click to pin">⏵ Live</button>`
495
- : `<button class="ibtn" data-action="live" title="Pinned — click to follow newest">⏸ Pinned</button>`;
496
- const arr = open ? "▼" : "▲";
497
- // The dashboard has no page to collapse back to and no host page to be
498
- // removed from, so it drops the toggle, popout, and close controls.
499
- const chrome = standalone
500
- ? `<button class="ibtn" data-action="clear" title="Clear traces">🗑</button>`
501
- : `<button class="ibtn" data-action="clear" title="Clear traces">🗑</button>` +
502
- `<button class="ibtn" data-action="dash" title="Open inspector">⤢</button>` +
503
- `<button class="ibtn" data-action="close" title="Remove devtools">✕</button>` +
504
- `<button class="ibtn" data-action="toggle" title="Toggle panel (Alt+D)">${arr}</button>`;
505
-
506
- if (!T) {
507
- $<HTMLElement>("#bar").innerHTML =
508
- `<span class="logo">⬡ <b>Zerotal</b></span>${dot}<span class="sp"></span>` +
509
- `${liveBtn}${standalone ? "" : `<button class="ibtn" data-action="toggle" title="Toggle (Alt+D)">${arr}</button>`}`;
510
- return;
511
- }
512
-
513
- const n1 = T.warnings.length ? `<span class="chip warn">⚠${T.warnings.length}</span>` : "";
514
- const user = T.auth
515
- ? `<span class="chip" title="${esc(String(T.auth.email ?? T.auth.id ?? ""))}">👤</span>`
516
- : "";
517
- const mail = T.mail?.length ? `<span class="chip">📧${T.mail.length}</span>` : "";
518
- const jobs = T.jobs?.length ? `<span class="chip">⚙${T.jobs.length}</span>` : "";
519
- const ch = T.cache?.length
520
- ? `<span class="chip">${T.cache.filter((c) => c.op === "hit").length}/${T.cache.length}c</span>`
521
- : "";
522
-
523
- $<HTMLElement>("#bar").innerHTML =
524
- `<span class="logo">⬡ <b>Zerotal</b></span>${dot}<span class="bdiv">│</span>` +
525
- `<span class="meth ${T.method.toLowerCase()}">${esc(T.method)}</span>` +
526
- `<span class="bpath">${esc(T.path)}</span><span class="bdiv">│</span>` +
527
- `<span class="sc ${scCls(T.statusCode)}">${T.statusCode || "—"}</span>` +
528
- `<span class="dim">·</span><span class="${dCls(T.durationMs) || "dim"}">${fmt(T.durationMs)}</span>` +
529
- `<span class="dim">·</span><span class="dim">${T.queries.length}q</span>` +
530
- `${n1}${user}${mail}${jobs}${ch}<span class="sp"></span>` +
531
- `${liveBtn}${chrome}`;
532
- }
533
-
534
- // ── Content router ────────────────────────────────────────────────────────
535
-
536
- function renderContent() {
537
- renderTabs();
538
- // Extension tabs own their content (their own data source) — route before the
539
- // request-trace "waiting for traffic" guard.
540
- if (tab.indexOf("plugin:") === 0) {
541
- renderPlugin();
542
- return;
543
- }
544
- if (!selected && tab !== "all") {
545
- $<HTMLElement>("#content").innerHTML = '<p class="empty">Waiting for traffic…</p>';
546
- return;
547
- }
548
- // Channel tabs are declared by other packages and rendered from their
549
- // descriptor, so devtools ships no code per contributing package.
550
- if (tab.indexOf("channel:") === 0) {
551
- const ch = channels.find((c) => "channel:" + c.id === tab);
552
- if (ch) renderChannel(selected!, ch);
553
- else $<HTMLElement>("#content").innerHTML = '<p class="empty">Channel unavailable</p>';
554
- return;
555
- }
556
- switch (tab) {
557
- case "queries":
558
- renderQueries(selected!);
559
- break;
560
- case "timeline":
561
- renderTimeline(selected!);
562
- break;
563
- case "logs":
564
- renderLogs(selected!);
565
- break;
566
- case "request":
567
- renderRequest(selected!);
568
- break;
569
- case "mail":
570
- renderMail(selected!);
571
- break;
572
- case "cache":
573
- renderCache(selected!);
574
- break;
575
- case "jobs":
576
- renderJobs(selected!);
577
- break;
578
- case "all":
579
- renderAll();
580
- break;
581
- }
582
- }
583
-
584
- function renderTabs() {
585
- const T = selected;
586
- const TABS = [
587
- { id: "queries", lbl: "Queries", n: T?.queries.length ?? 0, warn: !!T?.warnings.length },
588
- { id: "timeline", lbl: "Timeline", n: 0 },
589
- {
590
- id: "logs",
591
- lbl: "Logs",
592
- n: T?.logs?.length ?? 0,
593
- warn: T?.logs?.some((l) => l.level === "error" || l.level === "warn") ?? false,
594
- },
595
- { id: "request", lbl: "Request", n: 0 },
596
- { id: "mail", lbl: "Mail", n: T?.mail?.length ?? 0 },
597
- { id: "cache", lbl: "Cache", n: T?.cache?.length ?? 0 },
598
- { id: "jobs", lbl: "Jobs", n: T?.jobs?.length ?? 0 },
599
- { id: "all", lbl: "All", n: traces.length, live: true },
600
- ];
601
- const builtin = TABS.map((t) => tabHtml(t.id, t.lbl, t.n, t.warn, t.id === "all")).join("");
602
- // Channel tabs declared server-side by other packages.
603
- const chans = channels
604
- .map((c) => {
605
- const rows = T?.channels?.[c.id] ?? [];
606
- const warn = !!c.warn && rows.some((r) => !!r[c.warn!]);
607
- return tabHtml("channel:" + c.id, c.label, rows.length, warn);
608
- })
609
- .join("");
610
- // Extension tabs contributed by other packages via window.__zerotalDevtools.register().
611
- const plugins = registry.panels
612
- .map((p) => tabHtml("plugin:" + p.id, p.title, p.badge?.() ?? 0, false))
613
- .join("");
614
- $<HTMLElement>("#tabs").innerHTML = builtin + chans + plugins;
615
- }
616
-
617
- function tabHtml(
618
- id: string,
619
- label: string,
620
- count: number | string,
621
- warn?: boolean,
622
- isLive?: boolean,
623
- ): string {
624
- const badge = count
625
- ? `<span class="tbdg${warn ? " warn" : ""}">${esc(String(count))}</span>`
626
- : "";
627
- const ldot = isLive && connected ? '<span class="ldot">●</span>' : "";
628
- return `<button class="tab${tab === id ? " active" : ""}" data-tab="${esc(id)}">${esc(label)}${badge}${ldot}</button>`;
629
- }
630
-
631
- // ── Channel tab (generic) ─────────────────────────────────────────────────
632
-
633
- function renderChannel(T: RequestTrace, c: TraceChannelDescriptor) {
634
- const rows = T.channels?.[c.id] ?? [];
635
- if (!rows.length) {
636
- $<HTMLElement>("#content").innerHTML =
637
- `<p class="empty">No ${esc(c.label.toLowerCase())} activity during this request</p>`;
638
- return;
639
- }
640
- $<HTMLElement>("#content").innerHTML =
641
- `<div>` +
642
- rows
643
- .map((r: TraceChannelEntry) => {
644
- const isWarn = !!c.warn && !!r[c.warn];
645
- const badge = c.badge && r[c.badge] != null ? String(r[c.badge]) : "";
646
- const titleKey = c.title ?? c.badge;
647
- const title = titleKey && r[titleKey] != null ? String(r[titleKey]) : "";
648
- const meta = (c.meta ?? [])
649
- .filter((k) => r[k] != null && r[k] !== "")
650
- .map((k) => `<span>${esc(k)}: ${esc(String(r[k]))}</span>`)
651
- .join("");
652
- return (
653
- `<div class="crow${isWarn ? " warn" : ""}">` +
654
- `<div class="chead">` +
655
- (badge ? `<span class="chip${isWarn ? " warn" : ""}">${esc(badge)}</span>` : "") +
656
- `<span class="dim" style="font-size:10px">+${r.offsetMs}ms</span>` +
657
- `</div>` +
658
- (title && title !== badge ? `<div class="cttl">${esc(title)}</div>` : "") +
659
- (meta ? `<div class="cmeta">${meta}</div>` : "") +
660
- `</div>`
661
- );
662
- })
663
- .join("") +
664
- `</div>`;
665
- }
666
-
667
- // ── Timeline tab (waterfall) ──────────────────────────────────────────────
668
-
669
- function renderTimeline(T: RequestTrace) {
670
- // Every entry already carries an offset from the request start; the
671
- // waterfall is what makes "when did this happen relative to everything
672
- // else" legible instead of a column of numbers.
673
- type Span = { at: number; dur: number; kind: string; text: string };
674
- const spans: Span[] = [];
675
-
676
- for (const q of T.queries) {
677
- spans.push({
678
- at: Math.max(0, q.startMs - T.startMs),
679
- dur: q.durationMs,
680
- kind: "query",
681
- text: q.sql,
682
- });
683
- }
684
- for (const c of T.cache ?? []) {
685
- spans.push({ at: c.offsetMs, dur: c.durationMs, kind: "cache", text: `${c.op} ${c.key}` });
686
- }
687
- for (const m of T.mail ?? []) {
688
- spans.push({ at: m.offsetMs, dur: m.durationMs, kind: "mail", text: m.subject });
689
- }
690
- for (const j of T.jobs ?? []) {
691
- spans.push({ at: j.offsetMs, dur: j.durationMs, kind: "job", text: j.className });
692
- }
693
- for (const l of T.logs ?? []) {
694
- spans.push({
695
- at: l.offsetMs,
696
- dur: 0,
697
- kind: l.level === "error" || l.level === "warn" ? "warn" : "log",
698
- text: l.args.join(" "),
699
- });
700
- }
701
- for (const [id, rows] of Object.entries(T.channels ?? {})) {
702
- const desc = channels.find((c) => c.id === id);
703
- for (const r of rows) {
704
- const titleKey = desc?.title ?? desc?.badge;
705
- spans.push({
706
- at: r.offsetMs,
707
- dur: typeof r["durationMs"] === "number" ? (r["durationMs"] as number) : 0,
708
- kind: "chan",
709
- text: `${desc?.label ?? id}: ${titleKey ? String(r[titleKey] ?? "") : ""}`,
710
- });
711
- }
712
- }
713
-
714
- if (!spans.length) {
715
- $<HTMLElement>("#content").innerHTML =
716
- '<p class="empty">Nothing recorded on the timeline for this request</p>';
717
- return;
718
- }
719
-
720
- spans.sort((a, b) => a.at - b.at);
721
- const total = Math.max(1, T.durationMs);
722
- const KEY = [
723
- ["query", "Queries"],
724
- ["cache", "Cache"],
725
- ["mail", "Mail"],
726
- ["job", "Jobs"],
727
- ["chan", "Channels"],
728
- ["log", "Logs"],
729
- ["warn", "Warnings"],
730
- ];
731
-
732
- $<HTMLElement>("#content").innerHTML =
733
- `<div class="tkey">` +
734
- KEY.map(([k, lbl]) => `<span><i class="tmark ${k}"></i>${lbl}</span>`).join("") +
735
- `</div><div>` +
736
- spans
737
- .map((s) => {
738
- const left = Math.min(99, (s.at / total) * 100);
739
- const width = Math.max(0.6, Math.min(100 - left, (s.dur / total) * 100));
740
- return (
741
- `<div class="trow">` +
742
- `<span class="tlbl dim">+${s.at}ms</span>` +
743
- `<span class="ttrack"><span class="tmark ${s.kind}" style="left:${left}%;width:${width}%"></span></span>` +
744
- `<span class="ttxt dim" title="${esc(s.text)}">${esc(s.text)}</span>` +
745
- `</div>`
746
- );
747
- })
748
- .join("") +
749
- `</div>`;
750
- }
751
-
752
- function renderPlugin() {
753
- const p = registry.panels.find((x) => "plugin:" + x.id === tab);
754
- if (!p) {
755
- $<HTMLElement>("#content").innerHTML = '<p class="empty">Panel unavailable</p>';
756
- return;
757
- }
758
- try {
759
- p.render($<HTMLElement>("#content"));
760
- } catch {
761
- $<HTMLElement>("#content").innerHTML = '<p class="empty">Panel error</p>';
762
- }
763
- }
764
-
765
- // ── Queries tab ───────────────────────────────────────────────────────────
766
-
767
- function renderQueries(T: RequestTrace) {
768
- const dbMs = T.queries.reduce((s, q) => s + q.durationMs, 0);
769
- const peak = Math.max(1, ...T.queries.map((q) => q.durationMs));
770
-
771
- const route = T.route
772
- ? `<div class="rcard">` +
773
- `<span class="meth ${T.method.toLowerCase()}">${esc(T.method)}</span> ` +
774
- `<b>${esc(T.route.pattern)}</b>` +
775
- `<span class="dim" style="font-size:10px;margin-left:8px">${esc(T.route.controller)}@${esc(T.route.action)}</span>` +
776
- `</div>`
777
- : "";
778
-
779
- const mem = T.memory
780
- ? `<div class="stat"><div class="slbl">Memory</div><div class="sval">${fmtMem(T.memory)}</div></div>`
781
- : "";
782
- const auth = T.auth
783
- ? `<div class="stat"><div class="slbl">User</div><div class="sval cyan">${esc(String(T.auth.name ?? T.auth.email ?? T.auth.id ?? "?"))}</div></div>`
784
- : `<div class="stat"><div class="slbl">User</div><div class="sval dim">Guest</div></div>`;
785
-
786
- const warns = T.warnings
787
- .map(
788
- (w) =>
789
- `<div class="wrow"><div class="whead">⚠ N+1 · executed <b>${w.count}×</b></div>` +
790
- `<div class="qsql dim">${esc(w.sql)}</div></div>`,
791
- )
792
- .join("");
793
-
794
- const qs = T.queries.length
795
- ? T.queries.map((q) => qRow(q, peak)).join("")
796
- : '<p class="empty">No queries for this request</p>';
797
-
798
- $<HTMLElement>("#content").innerHTML =
799
- `${route}<div class="stats">` +
800
- `<div class="stat"><div class="slbl">Duration</div><div class="sval ${dCls(T.durationMs) || ""}">${fmt(T.durationMs)}</div></div>` +
801
- `<div class="stat"><div class="slbl">Queries</div><div class="sval">${T.queries.length}</div></div>` +
802
- `<div class="stat"><div class="slbl">DB time</div><div class="sval">${fmt(dbMs)}</div></div>` +
803
- `${mem}${auth}</div>` +
804
- (T.warnings.length
805
- ? `<div class="sec"><div class="stitle">N+1 Warnings</div>${warns}</div>`
806
- : "") +
807
- `<div class="sec"><div class="stitle">Queries (${T.queries.length})</div>${qs}</div>`;
808
- }
809
-
810
- function qRow(qr: QuerySpan, peak: number): string {
811
- const pct = Math.round((qr.durationMs / peak) * 100);
812
- const dc = qr.durationMs < 10 ? "green" : qr.durationMs < 100 ? "yellow" : "red";
813
- const binds = (qr.bindings as unknown[])?.length
814
- ? `<div class="qbind"><span class="dim">bindings: </span>` +
815
- (qr.bindings as unknown[])
816
- .map((v) =>
817
- v == null
818
- ? '<span class="bind">null</span>'
819
- : typeof v === "string"
820
- ? `<span class="bind">'${esc(v)}'</span>`
821
- : `<span class="bind">${esc(String(v))}</span>`,
822
- )
823
- .join('<span class="dim">, </span>') +
824
- `</div>`
825
- : "";
826
- return (
827
- `<div class="qrow">` +
828
- `<div class="qmeta">` +
829
- `<span class="qdur ${dc}">${fmt(qr.durationMs)}</span>` +
830
- `<div class="qbar"><div class="qfill" style="width:${pct}%"></div></div>` +
831
- `<span class="dim" style="font-size:10px">${qr.rowCount}&thinsp;row${qr.rowCount !== 1 ? "s" : ""}</span>` +
832
- `</div><div class="qsql">${esc(qr.sql)}</div>${binds}</div>`
833
- );
834
- }
835
-
836
- // ── Logs tab ──────────────────────────────────────────────────────────────
837
-
838
- function renderLogs(T: RequestTrace) {
839
- const logs = T.logs ?? [];
840
- if (!logs.length) {
841
- $<HTMLElement>("#content").innerHTML = '<p class="empty">No console output captured</p>';
842
- return;
843
- }
844
- $<HTMLElement>("#content").innerHTML =
845
- `<div>` +
846
- logs
847
- .map(
848
- (l: LogEntry) =>
849
- `<div class="lrow">` +
850
- `<span class="ltime dim">+${l.offsetMs}ms</span>` +
851
- `<span class="llvl ${l.level}">${l.level.toUpperCase()}</span>` +
852
- `<span class="lmsg">${l.args.map(esc).join(" ")}</span>` +
853
- `</div>`,
854
- )
855
- .join("") +
856
- `</div>`;
857
- }
858
-
859
- // ── Request tab ───────────────────────────────────────────────────────────
860
-
861
- function renderRequest(T: RequestTrace) {
862
- const qpRows =
863
- Object.entries(T.queryParams ?? {})
864
- .map(([k, v]) => `<tr><td>${esc(k)}</td><td>${esc(v)}</td></tr>`)
865
- .join("") || `<tr><td colspan="2" class="dim" style="padding:6px 12px">None</td></tr>`;
866
- const hdRows =
867
- Object.entries(T.headers ?? {})
868
- .map(([k, v]) => `<tr><td>${esc(k)}</td><td>${esc(v)}</td></tr>`)
869
- .join("") || `<tr><td colspan="2" class="dim" style="padding:6px 12px">None</td></tr>`;
870
-
871
- $<HTMLElement>("#content").innerHTML =
872
- `<div class="sec"><div class="stitle">Query Params</div><table class="kv">${qpRows}</table></div>` +
873
- `<div class="sec"><div class="stitle">Request Headers</div><table class="kv">${hdRows}</table></div>`;
874
- }
875
-
876
- // ── Mail tab ──────────────────────────────────────────────────────────────
877
-
878
- function renderMail(T: RequestTrace) {
879
- const mail = T.mail ?? [];
880
- if (!mail.length) {
881
- $<HTMLElement>("#content").innerHTML =
882
- '<p class="empty">No emails sent during this request</p>';
883
- return;
884
- }
885
- $<HTMLElement>("#content").innerHTML =
886
- `<div>` +
887
- mail
888
- .map(
889
- (m: MailEntry) =>
890
- `<div class="card">` +
891
- `<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px">` +
892
- `<span class="chip${m.queued ? "" : " ok"}">${m.queued ? "⏳ Queued" : "✓ Sent"}</span>` +
893
- `<span class="dim" style="font-size:10px">${esc(m.className)} · ${fmt(m.durationMs)}</span>` +
894
- `</div>` +
895
- `<div style="font-weight:700;margin-bottom:2px">${esc(m.subject)}</div>` +
896
- `<div class="dim" style="font-size:11px">To: ${esc(m.to.join(", "))}</div>` +
897
- `</div>`,
898
- )
899
- .join("") +
900
- `</div>`;
901
- }
902
-
903
- // ── Cache tab ─────────────────────────────────────────────────────────────
904
-
905
- function renderCache(T: RequestTrace) {
906
- const cache = T.cache ?? [];
907
- if (!cache.length) {
908
- $<HTMLElement>("#content").innerHTML =
909
- '<p class="empty">No cache operations during this request</p>';
910
- return;
911
- }
912
- const hits = cache.filter((c: CacheEntry) => c.op === "hit").length;
913
- const misses = cache.filter((c: CacheEntry) => c.op === "miss").length;
914
-
915
- $<HTMLElement>("#content").innerHTML =
916
- `<div class="stats">` +
917
- `<div class="stat"><div class="slbl">Operations</div><div class="sval">${cache.length}</div></div>` +
918
- `<div class="stat"><div class="slbl">Hits</div><div class="sval green">${hits}</div></div>` +
919
- `<div class="stat"><div class="slbl">Misses</div><div class="sval yellow">${misses}</div></div>` +
920
- `</div><div>` +
921
- cache
922
- .map((c: CacheEntry) => {
923
- const oc =
924
- c.op === "hit"
925
- ? "green"
926
- : c.op === "miss"
927
- ? "yellow"
928
- : c.op === "write"
929
- ? "cyan"
930
- : "dim";
931
- return (
932
- `<div class="qrow">` +
933
- `<div class="qmeta">` +
934
- `<span class="qdur ${oc}">${c.op.toUpperCase()}</span>` +
935
- `<span class="dim" style="flex:1;font-size:11px">${esc(c.key)}</span>` +
936
- `<span class="dim" style="font-size:10px">${fmt(c.durationMs)}</span>` +
937
- `</div>` +
938
- `<div class="dim" style="font-size:10px">${c.ttl != null ? `TTL: ${c.ttl}s · ` : ""}+${c.offsetMs}ms</div>` +
939
- `</div>`
940
- );
941
- })
942
- .join("") +
943
- `</div>`;
944
- }
945
-
946
- // ── Jobs tab ──────────────────────────────────────────────────────────────
947
-
948
- function renderJobs(T: RequestTrace) {
949
- const jobs = T.jobs ?? [];
950
- if (!jobs.length) {
951
- $<HTMLElement>("#content").innerHTML =
952
- '<p class="empty">No jobs dispatched during this request</p>';
953
- return;
954
- }
955
- $<HTMLElement>("#content").innerHTML =
956
- `<div>` +
957
- jobs
958
- .map((j: JobEntry) => {
959
- const jc = j.status === "completed" ? "ok" : j.status === "failed" ? "warn" : "";
960
- const ico = j.status === "dispatched" ? "⚙" : j.status === "completed" ? "✓" : "✗";
961
- return (
962
- `<div class="card">` +
963
- `<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px">` +
964
- `<span class="chip ${jc}">${ico} ${j.status}</span>` +
965
- `<span class="dim" style="font-size:10px">+${j.offsetMs}ms · ${fmt(j.durationMs)}</span>` +
966
- `</div>` +
967
- `<div style="font-weight:600;margin-bottom:2px">${esc(j.className)}</div>` +
968
- `<div class="dim" style="font-size:11px">Queue: ${esc(j.queue)}</div>` +
969
- (j.error
970
- ? `<div style="color:var(--red);font-size:11px;margin-top:4px">${esc(j.error)}</div>`
971
- : "") +
972
- `</div>`
973
- );
974
- })
975
- .join("") +
976
- `</div>`;
977
- }
978
-
979
- // ── All Requests tab ──────────────────────────────────────────────────────
980
-
981
- function renderAll() {
982
- if (!traces.length) {
983
- $<HTMLElement>("#content").innerHTML =
984
- '<p class="empty">No requests recorded yet — make a request to see it here</p>';
985
- return;
986
- }
987
-
988
- // `data-idx` indexes into `traces`, not into the filtered view, so clicking
989
- // a row still selects the right trace once a filter is applied.
990
- const matches = traces.map((t, i) => ({ t, i })).filter(({ t }) => matchesFilter(t, filter));
991
-
992
- const bar =
993
- `<div class="fbar">` +
994
- `<input id="filter" class="finput" type="search" placeholder="Filter by path, method, status, or controller…" value="${esc(filter)}">` +
995
- `<span class="dim">${matches.length}/${traces.length}</span>` +
996
- `</div>`;
997
-
998
- const rows = matches.length
999
- ? matches
1000
- .map(
1001
- ({ t, i }) =>
1002
- `<div class="hrow${t.id === selected?.id ? " cur" : ""}" data-idx="${i}">` +
1003
- `<span class="meth ${t.method.toLowerCase()}">${esc(t.method)}</span>` +
1004
- `<span class="hpath">${esc(t.path)}</span>` +
1005
- `<span class="sc ${scCls(t.statusCode)}">${t.statusCode || "—"}</span>` +
1006
- `<span class="${dCls(t.durationMs) || "dim"}">${fmt(t.durationMs)}</span>` +
1007
- `<span class="dim">${t.queries.length}q</span>` +
1008
- (t.warnings.length ? '<span class="chip warn">N+1</span>' : "") +
1009
- `</div>`,
1010
- )
1011
- .join("")
1012
- : '<p class="empty">No requests match this filter</p>';
1013
-
1014
- $<HTMLElement>("#content").innerHTML = bar + `<div>${rows}</div>`;
1015
- }
1016
-
1017
- // ── Pure helpers ──────────────────────────────────────────────────────────
1018
-
1019
- function esc(s: unknown): string {
1020
- return String(s ?? "").replace(
1021
- /[&<>"']/g,
1022
- (c) =>
1023
- (
1024
- ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }) as Record<
1025
- string,
1026
- string
1027
- >
1028
- )[c]!,
1029
- );
1030
- }
1031
-
1032
- function fmt(ms: number): string {
1033
- return ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${ms}ms`;
1034
- }
1035
-
1036
- function fmtMem(b: number): string {
1037
- return b >= 1_048_576 ? `${(b / 1_048_576).toFixed(1)} MB` : `${(b / 1024).toFixed(0)} KB`;
1038
- }
1039
-
1040
- function dCls(ms: number): string {
1041
- return ms > 1000 ? "red" : ms > 300 ? "yellow" : "";
1042
- }
1043
-
1044
- function scCls(s: number): string {
1045
- return s >= 500 ? "srv" : s >= 400 ? "cli" : s >= 300 ? "redir" : "ok";
1046
- }
1047
- },
1048
- };