@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/CHANGELOG.md +21 -0
- package/LICENSE +21 -0
- package/README.md +111 -0
- package/package.json +53 -0
- package/src/DevtoolsInjectionMiddleware.ts +167 -0
- package/src/RequestTrace.ts +148 -0
- package/src/TraceStore.ts +272 -0
- package/src/client-auto.ts +9 -0
- package/src/client.ts +1048 -0
- package/src/config.ts +60 -0
- package/src/dashboard-auto.ts +13 -0
- package/src/index.ts +27 -0
- package/src/panel-app.js +519 -0
- package/src/panel.html +26 -0
- package/src/provider/DevtoolsProvider.ts +153 -0
- package/src/redaction.ts +208 -0
- package/src/tracing.ts +347 -0
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";
|
package/src/panel-app.js
ADDED
|
@@ -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} 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} 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
|
+
· 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
|
+
"&": "&",
|
|
494
|
+
"<": "<",
|
|
495
|
+
">": ">",
|
|
496
|
+
'"': """,
|
|
497
|
+
"'": "'",
|
|
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>
|