@zerotal/devtools 1.0.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.
package/src/config.ts ADDED
@@ -0,0 +1,60 @@
1
+ import { deepMerge } from "@zerotal/core";
2
+ import type { RedactionOptions } from "./redaction.ts";
3
+
4
+ export interface DevtoolsConfigShape {
5
+ /**
6
+ * How many request traces to keep in memory and reload on start.
7
+ * Default: `100`.
8
+ */
9
+ capacity: number;
10
+ /**
11
+ * SQLite file backing the trace history, so it survives a restart. Set to
12
+ * `null` to keep traces in memory only — nothing is written to disk.
13
+ * Default: `.zerotal/devtools.sqlite`, or `ZT_DEVTOOLS_DB`.
14
+ */
15
+ dbPath: string | null;
16
+ /**
17
+ * How long a persisted trace survives, in hours.
18
+ * Default: `24`, or `ZT_DEVTOOLS_PRUNE_HOURS`.
19
+ */
20
+ pruneHours: number;
21
+ /**
22
+ * Whether query bindings are masked before a trace leaves the process.
23
+ *
24
+ * On by default: a trace is streamed to the browser *and* written to disk for
25
+ * a day, and bindings are the request's real values — the password on a
26
+ * registration, a reset token, every customer email a listing selects by.
27
+ * Turn it off only when you are debugging the values themselves.
28
+ */
29
+ redact: RedactionOptions;
30
+ }
31
+
32
+ const defaults: DevtoolsConfigShape = {
33
+ capacity: 100,
34
+ dbPath: Bun.env["ZT_DEVTOOLS_DB"] ?? ".zerotal/devtools.sqlite",
35
+ pruneHours: Number(Bun.env["ZT_DEVTOOLS_PRUNE_HOURS"] ?? 24),
36
+ redact: { enabled: true, allow: [], deny: [] },
37
+ };
38
+
39
+ /**
40
+ * Create a typed devtools configuration object with defaults.
41
+ *
42
+ * @example
43
+ * // config/devtools.ts
44
+ * import { DevtoolsConfig } from '@zerotal/devtools';
45
+ *
46
+ * export default DevtoolsConfig({
47
+ * capacity: 250,
48
+ * redact: { allow: ['email', 'slug'] },
49
+ * });
50
+ */
51
+ export function DevtoolsConfig(options: Partial<DevtoolsConfigShape> = {}): DevtoolsConfigShape {
52
+ return deepMerge(defaults, options);
53
+ }
54
+
55
+ // Register this package's config namespace for typed config() dot-paths.
56
+ declare module "@zerotal/core" {
57
+ interface ConfigRegistry {
58
+ devtools: DevtoolsConfigShape;
59
+ }
60
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Auto-start entry for the standalone inspector dashboard, bundled on demand and
3
+ * served at `GET /__zerotal/devtools/dashboard.js`.
4
+ *
5
+ * The dashboard is the same panel as the injected one, mounted full-window. It
6
+ * used to be a second implementation living in `panel.html` — a thousand lines
7
+ * of duplicated markup, CSS, and formatters that drifted from the panel it
8
+ * shadowed and never gained the plugin tabs the panel had. Sharing the renderer
9
+ * means a tab added anywhere shows up in both.
10
+ */
11
+ import { DevTools } from "./client.ts";
12
+
13
+ DevTools.start({ mode: "standalone" });
package/src/index.ts ADDED
@@ -0,0 +1,27 @@
1
+ // @zerotal/devtools — public API barrel
2
+
3
+ export { DevtoolsProvider } from "./provider/DevtoolsProvider.ts";
4
+ export type { DevtoolsPanelPlugin } from "./client.ts";
5
+ export { DevtoolsInjectionMiddleware, startDevtoolsStream } from "./DevtoolsInjectionMiddleware.ts";
6
+ export type { DevtoolsInjectionOptions } from "./DevtoolsInjectionMiddleware.ts";
7
+ export { TraceStore, traceStore, _setTraceStore } from "./TraceStore.ts";
8
+ export type { TraceStoreOptions } from "./TraceStore.ts";
9
+ export { DevtoolsConfig } from "./config.ts";
10
+ export type { DevtoolsConfigShape } from "./config.ts";
11
+ export { redactBindings, attributeBindings } from "./redaction.ts";
12
+ export type { RedactionOptions } from "./redaction.ts";
13
+ export { traceSink, traceChannels } from "./tracing.ts";
14
+ export type { TraceSink } from "./tracing.ts";
15
+ export type {
16
+ RequestTrace,
17
+ QuerySpan,
18
+ NPlusOneWarning,
19
+ MailEntry,
20
+ CacheEntry,
21
+ JobEntry,
22
+ LogEntry,
23
+ RouteInfo,
24
+ AuthInfo,
25
+ TraceChannelDescriptor,
26
+ TraceChannelEntry,
27
+ } from "./RequestTrace.ts";
@@ -0,0 +1,519 @@
1
+ /* @zerotal/devtools — browser panel injected by DevtoolsInjectionMiddleware */
2
+ (function () {
3
+ "use strict";
4
+
5
+ const T = window.__ZT_DT__;
6
+ if (!T) return;
7
+
8
+ // ── Styles (shadow DOM — fully isolated from host page) ──────────────────
9
+ const STYLES = `<style>
10
+ :host {
11
+ --bg: #1a1b26;
12
+ --surf: #24283b;
13
+ --card: #2f3452;
14
+ --border: #3b4261;
15
+ --text: #c0caf5;
16
+ --muted: #565f89;
17
+ --purple: #7aa2f7;
18
+ --green: #9ece6a;
19
+ --yellow: #e0af68;
20
+ --red: #f7768e;
21
+ --orange: #ff9e64;
22
+ --cyan: #7dcfff;
23
+ }
24
+ * { box-sizing: border-box; margin: 0; padding: 0; }
25
+ #wrap {
26
+ display: flex; flex-direction: column;
27
+ font: 12px/1.5 'JetBrains Mono','Fira Code','SF Mono',ui-monospace,monospace;
28
+ color: var(--text);
29
+ }
30
+ #bar {
31
+ display: flex; align-items: center; gap: 8px;
32
+ height: 32px; padding: 0 12px;
33
+ background: var(--bg); border-top: 1px solid var(--border);
34
+ cursor: pointer; user-select: none; flex-shrink: 0;
35
+ }
36
+ #bar:hover { background: var(--surf); }
37
+ .logo { color: var(--purple); font-weight: 700; }
38
+ .divider { color: var(--border); }
39
+ .flex1 { flex: 1; }
40
+ .dim { color: var(--muted); }
41
+ .path { color: var(--text); max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
42
+ .icon-btn {
43
+ background: none; border: none; color: var(--muted);
44
+ cursor: pointer; font: inherit; font-size: 14px; padding: 0 4px; line-height: 1;
45
+ }
46
+ .icon-btn:hover { color: var(--text); }
47
+ .tog { font-size: 10px; }
48
+ .meth {
49
+ font-size: 10px; font-weight: 700; padding: 2px 6px;
50
+ border-radius: 3px; letter-spacing: 0.3px;
51
+ }
52
+ .get { background: #1d2d50; color: var(--purple); }
53
+ .post { background: #1e3328; color: var(--green); }
54
+ .put, .patch { background: #362b18; color: var(--yellow); }
55
+ .delete { background: #361e22; color: var(--red); }
56
+ .head, .options { background: var(--card); color: var(--muted); }
57
+ .sc { font-weight: 700; font-size: 12px; }
58
+ .green { color: var(--green); }
59
+ .yellow { color: var(--yellow); }
60
+ .red { color: var(--red); }
61
+ .orange { color: var(--orange); }
62
+ .cyan { color: var(--cyan); }
63
+ .chip-n1 {
64
+ background: #362314; color: var(--orange);
65
+ font-size: 10px; font-weight: 700;
66
+ padding: 1px 6px; border-radius: 10px;
67
+ }
68
+ .chip-user {
69
+ background: var(--card); color: var(--cyan);
70
+ font-size: 10px; padding: 1px 7px; border-radius: 10px;
71
+ max-width: 140px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
72
+ }
73
+ #panel {
74
+ flex-direction: column; height: 380px;
75
+ background: var(--bg); border-top: 1px solid var(--border);
76
+ }
77
+ #tabs {
78
+ display: flex; flex-shrink: 0;
79
+ background: var(--surf); border-bottom: 1px solid var(--border);
80
+ }
81
+ .tab {
82
+ background: none; border: none; border-bottom: 2px solid transparent;
83
+ color: var(--muted); cursor: pointer; font: inherit; font-size: 12px;
84
+ padding: 7px 14px;
85
+ }
86
+ .tab:hover { color: var(--text); }
87
+ .tab.active { color: var(--text); border-bottom-color: var(--purple); }
88
+ .tab-badge {
89
+ display: inline-block; background: var(--card); color: var(--muted);
90
+ font-size: 10px; padding: 0 5px; border-radius: 8px; margin-left: 4px;
91
+ }
92
+ .tab-badge.warn { background: #362314; color: var(--orange); }
93
+ #content {
94
+ flex: 1; overflow-y: auto; padding: 12px 16px;
95
+ display: flex; flex-direction: column; gap: 12px;
96
+ }
97
+ .stats { display: flex; flex-wrap: wrap; gap: 16px; }
98
+ .stat { display: flex; flex-direction: column; gap: 2px; }
99
+ .slbl { font-size: 10px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.6px; }
100
+ .sval { font-size: 15px; font-weight: 700; }
101
+ .section { display: flex; flex-direction: column; gap: 6px; }
102
+ .sec-title { font-size: 10px; font-weight: 700; color: var(--muted); text-transform: uppercase; letter-spacing: 0.8px; }
103
+ .route-card {
104
+ background: var(--surf); border: 1px solid var(--border);
105
+ border-radius: 5px; padding: 8px 10px;
106
+ display: flex; flex-direction: column; gap: 3px;
107
+ }
108
+ .route-pattern { color: var(--cyan); font-size: 12px; }
109
+ .route-action { color: var(--muted); font-size: 11px; }
110
+ .qrow {
111
+ background: var(--surf); border: 1px solid var(--border);
112
+ border-radius: 5px; padding: 8px 10px;
113
+ }
114
+ .qmeta { display: flex; align-items: center; gap: 8px; margin-bottom: 5px; }
115
+ .qdur { font-size: 11px; font-weight: 700; min-width: 44px; }
116
+ .qbar { flex: 1; height: 3px; background: var(--border); border-radius: 2px; overflow: hidden; }
117
+ .qfill { height: 100%; background: var(--purple); border-radius: 2px; }
118
+ .qrc { font-size: 11px; color: var(--muted); flex-shrink: 0; }
119
+ .qsql { font-size: 11px; color: var(--text); word-break: break-all; line-height: 1.6; }
120
+ .qbind { font-size: 11px; margin-top: 4px; }
121
+ .bind { color: var(--orange); }
122
+ .warn-row {
123
+ background: #261b10; border: 1px solid var(--orange);
124
+ border-radius: 5px; padding: 8px 10px;
125
+ }
126
+ .warn-head { font-size: 11px; font-weight: 700; color: var(--orange); margin-bottom: 5px; }
127
+ .warn-fix { font-size: 11px; color: var(--muted); margin-top: 5px; }
128
+ .warn-fix code { color: var(--cyan); }
129
+ .log-row {
130
+ display: flex; gap: 8px; padding: 4px 0;
131
+ border-bottom: 1px solid var(--border); align-items: flex-start;
132
+ }
133
+ .log-row:last-child { border-bottom: none; }
134
+ .log-time { color: var(--muted); font-size: 11px; flex-shrink: 0; min-width: 42px; }
135
+ .log-lvl { font-size: 10px; font-weight: 700; flex-shrink: 0; min-width: 36px; text-transform: uppercase; }
136
+ .log-msg { font-size: 11px; word-break: break-all; flex: 1; line-height: 1.5; }
137
+ .lvl-log { color: var(--muted); }
138
+ .lvl-debug { color: var(--muted); }
139
+ .lvl-info { color: var(--cyan); }
140
+ .lvl-warn { color: var(--yellow); }
141
+ .lvl-error { color: var(--red); }
142
+ .kv-table { width: 100%; border-collapse: collapse; }
143
+ .kv-table tr:nth-child(odd) td { background: var(--surf); }
144
+ .kv-table td { padding: 3px 8px; vertical-align: top; word-break: break-all; }
145
+ .kv-key { color: var(--purple); white-space: nowrap; padding-right: 12px; min-width: 140px; }
146
+ .kv-val { color: var(--text); }
147
+ .hrow {
148
+ display: flex; align-items: center; gap: 8px;
149
+ padding: 5px 8px; border-radius: 4px;
150
+ }
151
+ .hrow:nth-child(odd) { background: var(--surf); }
152
+ .hrow.cur { background: #1d2d50; }
153
+ .hpath { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
154
+ .empty { color: var(--muted); font-size: 12px; padding: 16px 0; }
155
+ ::-webkit-scrollbar { width: 5px; }
156
+ ::-webkit-scrollbar-track { background: transparent; }
157
+ ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
158
+ ::-webkit-scrollbar-thumb:hover { background: var(--muted); }
159
+ </style>`;
160
+
161
+ // ── Mount ────────────────────────────────────────────────────────────────
162
+ const host = document.createElement("div");
163
+ host.id = "__zerotal_dt__";
164
+ host.style.cssText =
165
+ "position:fixed;bottom:0;left:0;right:0;z-index:2147483647;pointer-events:none";
166
+ document.body.appendChild(host);
167
+
168
+ const shadow = host.attachShadow({ mode: "open" });
169
+ shadow.innerHTML =
170
+ STYLES +
171
+ `
172
+ <div id="wrap" style="pointer-events:auto">
173
+ <div id="panel" style="display:none">
174
+ <div id="tabs">
175
+ <button id="tab-cur" class="tab active">Queries<span id="badge-q" class="tab-badge"></span></button>
176
+ <button id="tab-logs" class="tab">Logs<span id="badge-l" class="tab-badge"></span></button>
177
+ <button id="tab-req" class="tab">Request</button>
178
+ <button id="tab-hist" class="tab">History</button>
179
+ </div>
180
+ <div id="content"></div>
181
+ </div>
182
+ <div id="bar"></div>
183
+ </div>`;
184
+
185
+ // ── State ────────────────────────────────────────────────────────────────
186
+ let open = false;
187
+ let tab = "cur";
188
+ let hist = null;
189
+ let histErr = false;
190
+
191
+ // Populate tab badges once
192
+ q("#badge-q").textContent = T.queries.length;
193
+ if (T.warnings.length) q("#badge-q").classList.add("warn");
194
+ if ((T.logs || []).length) {
195
+ q("#badge-l").textContent = T.logs.length;
196
+ const hasErr = T.logs.some(function (l) {
197
+ return l.level === "error" || l.level === "warn";
198
+ });
199
+ if (hasErr) q("#badge-l").classList.add("warn");
200
+ }
201
+
202
+ // ── Wire events ──────────────────────────────────────────────────────────
203
+ q("#bar").addEventListener("click", function (e) {
204
+ if (e.target.closest && e.target.closest("#btn-close")) return;
205
+ toggle();
206
+ });
207
+ q("#tab-cur").addEventListener("click", function () {
208
+ setTab("cur");
209
+ });
210
+ q("#tab-logs").addEventListener("click", function () {
211
+ setTab("logs");
212
+ });
213
+ q("#tab-req").addEventListener("click", function () {
214
+ setTab("req");
215
+ });
216
+ q("#tab-hist").addEventListener("click", function () {
217
+ setTab("hist");
218
+ });
219
+ document.addEventListener("keydown", function (e) {
220
+ if ((e.altKey || e.metaKey) && e.key === "d") {
221
+ e.preventDefault();
222
+ toggle();
223
+ }
224
+ });
225
+
226
+ // ── Boot ─────────────────────────────────────────────────────────────────
227
+ renderBar();
228
+
229
+ // ── Core ─────────────────────────────────────────────────────────────────
230
+ function toggle() {
231
+ open = !open;
232
+ q("#panel").style.display = open ? "flex" : "none";
233
+ if (open) renderPanel();
234
+ renderBar();
235
+ }
236
+
237
+ function setTab(t) {
238
+ tab = t;
239
+ q("#tab-cur").classList.toggle("active", t === "cur");
240
+ q("#tab-logs").classList.toggle("active", t === "logs");
241
+ q("#tab-req").classList.toggle("active", t === "req");
242
+ q("#tab-hist").classList.toggle("active", t === "hist");
243
+ if (t === "hist" && hist === null && !histErr) fetchHistory();
244
+ renderPanel();
245
+ }
246
+
247
+ // ── Toolbar ───────────────────────────────────────────────────────────────
248
+ function renderBar() {
249
+ const sc = scls(T.statusCode);
250
+ const dc = dcls(T.durationMs);
251
+ const n1 = T.warnings.length ? `<span class="chip-n1">⚠ N+1 ×${T.warnings.length}</span>` : "";
252
+ const mem = T.memory
253
+ ? `<span class="divider">·</span><span class="dim">${fmtMem(T.memory)}</span>`
254
+ : "";
255
+ const user = T.auth
256
+ ? `<span class="chip-user">👤 ${esc(String(T.auth.name || T.auth.email || T.auth.id))}</span>`
257
+ : `<span class="dim" style="font-size:11px">guest</span>`;
258
+ const arr = open ? "▼" : "▲";
259
+ q("#bar").innerHTML = `<span class="logo">⬡ <b>Zerotal</b></span>
260
+ <span class="divider">|</span>
261
+ <span class="meth ${T.method.toLowerCase()}">${esc(T.method)}</span>
262
+ <span class="path">${esc(T.path)}</span>
263
+ <span class="divider">|</span>
264
+ <span class="sc ${sc}">${T.statusCode || "—"}</span>
265
+ <span class="divider">·</span>
266
+ <span class="${dc}">${fmt(T.durationMs)}</span>
267
+ <span class="divider">·</span>
268
+ <span class="dim">${T.queries.length}&thinsp;quer${T.queries.length === 1 ? "y" : "ies"}</span>
269
+ ${mem}
270
+ <span class="divider">·</span>
271
+ ${user}
272
+ ${n1}
273
+ <span class="flex1"></span>
274
+ <button id="btn-close" class="icon-btn" title="Hide (Alt+D)">×</button>
275
+ <button class="icon-btn tog" title="Toggle panel">${arr}</button>`;
276
+
277
+ q("#btn-close").addEventListener("click", function (e) {
278
+ e.stopPropagation();
279
+ host.remove();
280
+ });
281
+ }
282
+
283
+ // ── Panel ─────────────────────────────────────────────────────────────────
284
+ function renderPanel() {
285
+ if (tab === "cur") renderQueries();
286
+ else if (tab === "logs") renderLogs();
287
+ else if (tab === "req") renderRequest();
288
+ else renderHistory();
289
+ }
290
+
291
+ function renderQueries() {
292
+ const total = T.queries.reduce(function (s, q) {
293
+ return s + q.durationMs;
294
+ }, 0);
295
+ const peak = T.queries.reduce(function (m, q) {
296
+ return Math.max(m, q.durationMs);
297
+ }, 1);
298
+
299
+ const routeCard = T.route
300
+ ? `<div class="route-card">
301
+ <div class="route-pattern">${esc(T.method)} <strong>${esc(T.route.pattern)}</strong></div>
302
+ <div class="route-action dim">${esc(T.route.controller)}@${esc(T.route.action)}</div>
303
+ </div>`
304
+ : "";
305
+
306
+ const authCard = T.auth
307
+ ? `<div class="stat">
308
+ <div class="slbl">User</div>
309
+ <div class="sval cyan" style="font-size:13px">${esc(String(T.auth.name || T.auth.email || T.auth.id))}</div>
310
+ ${T.auth.email ? `<div class="dim" style="font-size:10px">${esc(String(T.auth.email))}</div>` : ""}
311
+ </div>`
312
+ : `<div class="stat"><div class="slbl">User</div><div class="sval dim" style="font-size:13px">Guest</div></div>`;
313
+
314
+ const qRows =
315
+ T.queries.length === 0
316
+ ? '<p class="empty">No queries recorded</p>'
317
+ : T.queries
318
+ .map(function (qr) {
319
+ const pct = Math.round((qr.durationMs / peak) * 100);
320
+ const dc = qr.durationMs < 10 ? "green" : qr.durationMs < 100 ? "yellow" : "red";
321
+ const bindings =
322
+ qr.bindings && qr.bindings.length
323
+ ? `<div class="qbind"><span class="dim">bindings:</span> ${qr.bindings
324
+ .map(function (v) {
325
+ return v === null || v === undefined
326
+ ? '<span class="bind">null</span>'
327
+ : typeof v === "string"
328
+ ? `<span class="bind">'${esc(v)}'</span>`
329
+ : `<span class="bind">${esc(String(v))}</span>`;
330
+ })
331
+ .join('<span class="dim">, </span>')}</div>`
332
+ : "";
333
+ return `<div class="qrow">
334
+ <div class="qmeta">
335
+ <span class="qdur ${dc}">${fmt(qr.durationMs)}</span>
336
+ <div class="qbar"><div class="qfill" style="width:${pct}%"></div></div>
337
+ <span class="qrc">${qr.rowCount}&thinsp;row${qr.rowCount !== 1 ? "s" : ""}</span>
338
+ </div>
339
+ <div class="qsql">${esc(qr.sql)}</div>
340
+ ${bindings}
341
+ </div>`;
342
+ })
343
+ .join("");
344
+
345
+ const wRows = T.warnings
346
+ .map(function (w) {
347
+ return `<div class="warn-row">
348
+ <div class="warn-head">⚠ Executed <strong>${w.count}×</strong> in this request — N+1 detected</div>
349
+ <div class="qsql">${esc(w.sql)}</div>
350
+ <div class="warn-fix">Fix: use <code>.with('relation')</code>
351
+ &nbsp;·&nbsp; suppress: <code>DB.allowNPlusOne('${esc(tableFrom(w.sql))}')</code></div>
352
+ </div>`;
353
+ })
354
+ .join("");
355
+
356
+ q("#content").innerHTML = `${routeCard ? `<div class="section">${routeCard}</div>` : ""}
357
+ <div class="stats">
358
+ <div class="stat"><div class="slbl">Duration</div>
359
+ <div class="sval ${dcls(T.durationMs)}">${fmt(T.durationMs)}</div></div>
360
+ <div class="stat"><div class="slbl">Queries</div>
361
+ <div class="sval">${T.queries.length}</div></div>
362
+ <div class="stat"><div class="slbl">Query time</div>
363
+ <div class="sval">${fmt(total)}</div></div>
364
+ ${
365
+ T.memory
366
+ ? `<div class="stat"><div class="slbl">Memory</div>
367
+ <div class="sval">${fmtMem(T.memory)}</div></div>`
368
+ : ""
369
+ }
370
+ ${
371
+ T.warnings.length
372
+ ? `<div class="stat"><div class="slbl">N+1</div>
373
+ <div class="sval orange">${T.warnings.length}</div></div>`
374
+ : ""
375
+ }
376
+ ${authCard}
377
+ </div>
378
+ <div class="section">
379
+ <div class="sec-title">Queries (${T.queries.length})</div>${qRows}
380
+ </div>
381
+ ${
382
+ T.warnings.length
383
+ ? `<div class="section"><div class="sec-title">⚠ N+1 Warnings</div>${wRows}</div>`
384
+ : ""
385
+ }`;
386
+ }
387
+
388
+ function renderLogs() {
389
+ const logs = T.logs || [];
390
+ if (logs.length === 0) {
391
+ q("#content").innerHTML = '<p class="empty">No console output captured for this request</p>';
392
+ return;
393
+ }
394
+ q("#content").innerHTML = `<div class="section">
395
+ <div class="sec-title">Console (${logs.length})</div>
396
+ <div>${logs
397
+ .map(function (l) {
398
+ return `<div class="log-row">
399
+ <span class="log-time dim">+${l.offsetMs}ms</span>
400
+ <span class="log-lvl lvl-${esc(l.level)}">${esc(l.level)}</span>
401
+ <span class="log-msg">${l.args
402
+ .map(function (a) {
403
+ return esc(a);
404
+ })
405
+ .join(" ")}</span>
406
+ </div>`;
407
+ })
408
+ .join("")}</div>
409
+ </div>`;
410
+ }
411
+
412
+ function renderRequest() {
413
+ const qp = T.queryParams || {};
414
+ const hdrs = T.headers || {};
415
+ const qpKeys = Object.keys(qp);
416
+ const hdrKeys = Object.keys(hdrs);
417
+
418
+ const qpRows =
419
+ qpKeys.length === 0
420
+ ? '<tr><td colspan="2" class="dim" style="padding:6px 8px">No query parameters</td></tr>'
421
+ : qpKeys
422
+ .map(function (k) {
423
+ return `<tr><td class="kv-key">${esc(k)}</td><td class="kv-val">${esc(qp[k])}</td></tr>`;
424
+ })
425
+ .join("");
426
+
427
+ const hdrRows =
428
+ hdrKeys.length === 0
429
+ ? '<tr><td colspan="2" class="dim" style="padding:6px 8px">—</td></tr>'
430
+ : hdrKeys
431
+ .map(function (k) {
432
+ return `<tr><td class="kv-key">${esc(k)}</td><td class="kv-val">${esc(hdrs[k])}</td></tr>`;
433
+ })
434
+ .join("");
435
+
436
+ q("#content").innerHTML = `<div class="section">
437
+ <div class="sec-title">Query Parameters (${qpKeys.length})</div>
438
+ <table class="kv-table">${qpRows}</table>
439
+ </div>
440
+ <div class="section">
441
+ <div class="sec-title">Request Headers</div>
442
+ <table class="kv-table">${hdrRows}</table>
443
+ </div>`;
444
+ }
445
+
446
+ function renderHistory() {
447
+ if (hist === null) {
448
+ q("#content").innerHTML = '<p class="empty">Loading…</p>';
449
+ return;
450
+ }
451
+ const rows =
452
+ histErr || !hist.length
453
+ ? `<p class="empty">${histErr ? "Failed to load history" : "No requests recorded yet"}</p>`
454
+ : hist
455
+ .map(function (t) {
456
+ const cur = t.id === T.id ? " cur" : "";
457
+ return `<div class="hrow${cur}">
458
+ <span class="meth ${t.method.toLowerCase()}">${esc(t.method)}</span>
459
+ <span class="hpath">${esc(t.path)}</span>
460
+ <span class="sc ${scls(t.statusCode)}">${t.statusCode || "—"}</span>
461
+ <span class="${dcls(t.durationMs)}">${fmt(t.durationMs)}</span>
462
+ <span class="dim">${t.queries.length}q</span>
463
+ ${t.logs && t.logs.length ? `<span class="dim">${t.logs.length}log</span>` : ""}
464
+ ${t.warnings.length ? '<span class="chip-n1">N+1</span>' : ""}
465
+ </div>`;
466
+ })
467
+ .join("");
468
+ q("#content").innerHTML = rows;
469
+ }
470
+
471
+ function fetchHistory() {
472
+ fetch("/__zerotal/devtools/api/traces")
473
+ .then(function (r) {
474
+ return r.json();
475
+ })
476
+ .then(function (data) {
477
+ hist = data;
478
+ if (tab === "hist") renderPanel();
479
+ })
480
+ .catch(function () {
481
+ histErr = true;
482
+ if (tab === "hist") renderPanel();
483
+ });
484
+ }
485
+
486
+ // ── Helpers ───────────────────────────────────────────────────────────────
487
+ function q(sel) {
488
+ return shadow.querySelector(sel);
489
+ }
490
+ function esc(s) {
491
+ return String(s).replace(/[&<>"']/g, function (c) {
492
+ return {
493
+ "&": "&amp;",
494
+ "<": "&lt;",
495
+ ">": "&gt;",
496
+ '"': "&quot;",
497
+ "'": "&#39;",
498
+ }[c];
499
+ });
500
+ }
501
+ function fmt(ms) {
502
+ return ms >= 1000 ? (ms / 1000).toFixed(1) + "s" : ms + "ms";
503
+ }
504
+ function fmtMem(bytes) {
505
+ return bytes >= 1048576
506
+ ? (bytes / 1048576).toFixed(1) + " MB"
507
+ : (bytes / 1024).toFixed(0) + " KB";
508
+ }
509
+ function scls(s) {
510
+ return s >= 500 ? "red" : s >= 400 ? "yellow" : s >= 300 ? "cyan" : s ? "green" : "dim";
511
+ }
512
+ function dcls(ms) {
513
+ return ms > 1000 ? "red" : ms > 300 ? "yellow" : "";
514
+ }
515
+ function tableFrom(sql) {
516
+ const m = /from\s+[`'"]?(\w+)[`'"]?/i.exec(sql);
517
+ return m ? m[1] : "table_name";
518
+ }
519
+ })();
package/src/panel.html ADDED
@@ -0,0 +1,26 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <meta name="robots" content="noindex" />
7
+ <title>Zerotal Inspector</title>
8
+ <style>
9
+ html,
10
+ body {
11
+ margin: 0;
12
+ padding: 0;
13
+ height: 100%;
14
+ background: #1a1b26;
15
+ }
16
+ </style>
17
+ </head>
18
+ <body>
19
+ <!--
20
+ A shell, not a second panel. The dashboard is the injected panel mounted
21
+ full-window, so both surfaces share one set of tabs, renderers, and styles
22
+ and a package that contributes a tab gets it in both.
23
+ -->
24
+ <script type="module" src="/__zerotal/devtools/dashboard.js"></script>
25
+ </body>
26
+ </html>