@zerotal/devtools 1.6.3 → 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.
- package/CHANGELOG.md +233 -1
- package/api-surface.md +296 -0
- package/package.json +5 -4
- package/src/DevtoolsInjectionMiddleware.ts +41 -4
- package/src/RequestTrace.ts +96 -1
- package/src/TraceStore.ts +12 -0
- package/src/activity.ts +116 -0
- package/src/callsite.ts +146 -0
- package/src/client/filter.ts +108 -0
- package/src/client/index.ts +122 -0
- package/src/client/metrics.ts +98 -0
- package/src/client/registry.ts +65 -0
- package/src/client/state.ts +311 -0
- package/src/client/tabs/all.ts +276 -0
- package/src/client/tabs/app.ts +292 -0
- package/src/client/tabs/cache.ts +49 -0
- package/src/client/tabs/channel.ts +263 -0
- package/src/client/tabs/exceptions.ts +68 -0
- package/src/client/tabs/jobs.ts +50 -0
- package/src/client/tabs/logs.ts +44 -0
- package/src/client/tabs/mail.ts +59 -0
- package/src/client/tabs/queries.ts +124 -0
- package/src/client/tabs/request.ts +76 -0
- package/src/client/tabs/timeline.ts +132 -0
- package/src/client/tabs/types.ts +51 -0
- package/src/client/transport.ts +81 -0
- package/src/client/tree.ts +138 -0
- package/src/client/ui/format.ts +118 -0
- package/src/client/ui/render.ts +87 -0
- package/src/client/ui/shell.ts +511 -0
- package/src/client/ui/theme.ts +389 -0
- package/src/client-auto.ts +1 -1
- package/src/config.ts +77 -2
- package/src/dashboard-auto.ts +1 -1
- package/src/editor.ts +107 -0
- package/src/enabled.ts +59 -0
- package/src/index.ts +19 -3
- package/src/map.ts +213 -0
- package/src/provider/DevtoolsProvider.ts +32 -7
- package/src/redaction.ts +161 -20
- package/src/tracing.ts +213 -24
- package/src/client.ts +0 -1048
- package/src/panel-app.js +0 -519
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A channel tab, drawn from its descriptor alone.
|
|
3
|
+
*
|
|
4
|
+
* Five presentations rather than one, because a flat badge-title-meta list is
|
|
5
|
+
* right for an audit feed and wrong for a prop map or a route table — and a
|
|
6
|
+
* package that needs a tree should not have to ship a renderer into devtools to
|
|
7
|
+
* get one. Which presentation to use is declared as data like everything else on
|
|
8
|
+
* the descriptor, so this file stays the only channel-rendering code in the panel
|
|
9
|
+
* however many packages contribute.
|
|
10
|
+
*/
|
|
11
|
+
import type { TraceChannelDescriptor, TraceChannelEntry } from "../../RequestTrace.ts";
|
|
12
|
+
import { buildPathTree, type PathTreeNode } from "../tree.ts";
|
|
13
|
+
import { chipFor, copyBtn, esc, fmtCell } from "../ui/format.ts";
|
|
14
|
+
import type { TabView } from "./types.ts";
|
|
15
|
+
|
|
16
|
+
/** `key: value` spans for every field the descriptor named as meta. */
|
|
17
|
+
function metaLine(r: TraceChannelEntry, keys: string[]): string {
|
|
18
|
+
return keys
|
|
19
|
+
.filter((k) => r[k] != null && r[k] !== "")
|
|
20
|
+
.map((k) => `<span>${esc(k)}: ${esc(fmtCell(r[k]))}</span>`)
|
|
21
|
+
.join("");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Chips for the descriptor's `flags` that are truthy here.
|
|
26
|
+
*
|
|
27
|
+
* A flag is named by its *field*, so `{ shared: true }` reads as **shared**
|
|
28
|
+
* rather than `shared: true` — which is how a row ends up saying nothing at a
|
|
29
|
+
* glance.
|
|
30
|
+
*/
|
|
31
|
+
function flagChips(r: Record<string, unknown>, flags: string[] | undefined): string {
|
|
32
|
+
return (flags ?? [])
|
|
33
|
+
.filter((f) => !!r[f])
|
|
34
|
+
.map((f) => `<span class="chip flag">${esc(f)}</span>`)
|
|
35
|
+
.join("");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The default: one block per entry — badge, title, flags, meta. */
|
|
39
|
+
function asRows(rows: TraceChannelEntry[], c: TraceChannelDescriptor): string {
|
|
40
|
+
return (
|
|
41
|
+
`<div>` +
|
|
42
|
+
rows
|
|
43
|
+
.map((r) => {
|
|
44
|
+
const isWarn = !!c.warn && !!r[c.warn];
|
|
45
|
+
const badge = c.badge && r[c.badge] != null ? String(r[c.badge]) : "";
|
|
46
|
+
const titleKey = c.title ?? c.badge;
|
|
47
|
+
const title = titleKey && r[titleKey] != null ? String(r[titleKey]) : "";
|
|
48
|
+
const meta = metaLine(r, c.meta ?? []);
|
|
49
|
+
return (
|
|
50
|
+
`<div class="crow${isWarn ? " warn" : ""}">` +
|
|
51
|
+
`<div class="chead">` +
|
|
52
|
+
(badge ? chipFor(badge, isWarn) : "") +
|
|
53
|
+
flagChips(r, c.flags) +
|
|
54
|
+
`<span class="dim" style="font-size:10px">+${r.offsetMs}ms</span>` +
|
|
55
|
+
copyBtn(JSON.stringify(r, null, 2), "Copy entry") +
|
|
56
|
+
`</div>` +
|
|
57
|
+
(title && title !== badge ? `<div class="cttl">${esc(title)}</div>` : "") +
|
|
58
|
+
(meta ? `<div class="cmeta">${meta}</div>` : "") +
|
|
59
|
+
`</div>`
|
|
60
|
+
);
|
|
61
|
+
})
|
|
62
|
+
.join("") +
|
|
63
|
+
`</div>`
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** One row per entry, the descriptor's fields as columns. */
|
|
68
|
+
function asTable(rows: TraceChannelEntry[], c: TraceChannelDescriptor): string {
|
|
69
|
+
const titleKey = c.title ?? c.badge;
|
|
70
|
+
const cols = [
|
|
71
|
+
...(c.badge ? [c.badge] : []),
|
|
72
|
+
...(titleKey && titleKey !== c.badge ? [titleKey] : []),
|
|
73
|
+
...(c.meta ?? []),
|
|
74
|
+
];
|
|
75
|
+
if (!cols.length) return asRows(rows, c);
|
|
76
|
+
return (
|
|
77
|
+
`<table class="ctbl"><thead><tr>` +
|
|
78
|
+
cols.map((k) => `<th>${esc(k)}</th>`).join("") +
|
|
79
|
+
`<th>at</th></tr></thead><tbody>` +
|
|
80
|
+
rows
|
|
81
|
+
.map((r) => {
|
|
82
|
+
const isWarn = !!c.warn && !!r[c.warn];
|
|
83
|
+
return (
|
|
84
|
+
`<tr${isWarn ? ' class="warn"' : ""}>` +
|
|
85
|
+
cols
|
|
86
|
+
.map(
|
|
87
|
+
(k, i) =>
|
|
88
|
+
`<td>${i === 0 && c.badge === k && r[k] != null ? chipFor(String(r[k]), isWarn) : esc(fmtCell(r[k]))}` +
|
|
89
|
+
(i === 0 ? flagChips(r, c.flags) : "") +
|
|
90
|
+
`</td>`,
|
|
91
|
+
)
|
|
92
|
+
.join("") +
|
|
93
|
+
`<td class="dim">+${r.offsetMs}ms</td></tr>`
|
|
94
|
+
);
|
|
95
|
+
})
|
|
96
|
+
.join("") +
|
|
97
|
+
`</tbody></table>`
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Every field of every entry, as a key/value table per entry. */
|
|
102
|
+
function asKv(rows: TraceChannelEntry[], c: TraceChannelDescriptor): string {
|
|
103
|
+
const titleKey = c.title ?? c.badge;
|
|
104
|
+
return rows
|
|
105
|
+
.map((r) => {
|
|
106
|
+
const heading = titleKey && r[titleKey] != null ? String(r[titleKey]) : "";
|
|
107
|
+
const cells = Object.entries(r)
|
|
108
|
+
.filter(([k, v]) => k !== "offsetMs" && v != null && v !== "")
|
|
109
|
+
.map(([k, v]) => `<tr><td>${esc(k)}</td><td>${esc(fmtCell(v))}</td></tr>`)
|
|
110
|
+
.join("");
|
|
111
|
+
return (
|
|
112
|
+
`<div class="sec"><div class="stitle">` +
|
|
113
|
+
`${esc(heading || "Entry")} · +${r.offsetMs}ms` +
|
|
114
|
+
copyBtn(JSON.stringify(r, null, 2), "Copy entry") +
|
|
115
|
+
`</div><table class="kv">${cells}</table></div>`
|
|
116
|
+
);
|
|
117
|
+
})
|
|
118
|
+
.join("");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Entries collected under the value of the descriptor's `groupBy` field. */
|
|
122
|
+
function asGrouped(rows: TraceChannelEntry[], c: TraceChannelDescriptor): string {
|
|
123
|
+
const key = c.groupBy;
|
|
124
|
+
if (!key) return asRows(rows, c);
|
|
125
|
+
const groups = new Map<string, TraceChannelEntry[]>();
|
|
126
|
+
for (const r of rows) {
|
|
127
|
+
const g = r[key] == null || r[key] === "" ? "—" : String(r[key]);
|
|
128
|
+
let bucket = groups.get(g);
|
|
129
|
+
if (!bucket) groups.set(g, (bucket = []));
|
|
130
|
+
bucket.push(r);
|
|
131
|
+
}
|
|
132
|
+
return [...groups.entries()]
|
|
133
|
+
.map(
|
|
134
|
+
([g, entries]) =>
|
|
135
|
+
`<details class="cgrp" open><summary>` +
|
|
136
|
+
`<b>${esc(g)}</b><span class="chip">${entries.length}</span>` +
|
|
137
|
+
(c.warn && entries.some((r) => !!r[c.warn!]) ? '<span class="chip warn">⚠</span>' : "") +
|
|
138
|
+
`</summary>${asRows(entries, c)}</details>`,
|
|
139
|
+
)
|
|
140
|
+
.join("");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** A node's badge, its truthy flags, and whatever else it carries. */
|
|
144
|
+
function nodeAttrs(attrs: Record<string, unknown>, c: TraceChannelDescriptor): string {
|
|
145
|
+
const badge = c.treeBadge && attrs[c.treeBadge] != null ? String(attrs[c.treeBadge]) : "";
|
|
146
|
+
const flags = new Set(c.flags ?? []);
|
|
147
|
+
const rest = Object.entries(attrs)
|
|
148
|
+
.filter(([k, v]) => k !== c.treeBadge && !flags.has(k) && v != null && v !== "")
|
|
149
|
+
.map(([k, v]) => `<span class="tattr">${esc(k)}: ${esc(fmtCell(v))}</span>`)
|
|
150
|
+
.join("");
|
|
151
|
+
return (badge ? chipFor(badge, false) : "") + flagChips(attrs, c.flags) + rest;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function treeNodes(
|
|
155
|
+
level: Map<string, PathTreeNode>,
|
|
156
|
+
c: TraceChannelDescriptor,
|
|
157
|
+
prefix: string,
|
|
158
|
+
): string {
|
|
159
|
+
return [...level.entries()]
|
|
160
|
+
.map(([name, node]) => {
|
|
161
|
+
const path = prefix ? `${prefix}.${name}` : name;
|
|
162
|
+
if (node.children.size === 0) {
|
|
163
|
+
return (
|
|
164
|
+
`<div class="tnode tleaf">` +
|
|
165
|
+
`<span class="tname">${esc(name)}</span>` +
|
|
166
|
+
nodeAttrs(node.attrs ?? {}, c) +
|
|
167
|
+
copyBtn(path, "Copy path") +
|
|
168
|
+
`</div>`
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
return (
|
|
172
|
+
`<details class="tnode tbranch" open><summary>` +
|
|
173
|
+
`<span class="tname">${esc(name)}</span>` +
|
|
174
|
+
(node.attrs ? nodeAttrs(node.attrs, c) : "") +
|
|
175
|
+
`<span class="dim tattr">${node.children.size}</span>` +
|
|
176
|
+
copyBtn(path, "Copy path") +
|
|
177
|
+
`</summary><div class="tkids">${treeNodes(node.children, c, path)}</div></details>`
|
|
178
|
+
);
|
|
179
|
+
})
|
|
180
|
+
.join("");
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** The entry's own badge/title/meta, above its tree. */
|
|
184
|
+
function treeHead(r: TraceChannelEntry, c: TraceChannelDescriptor): string {
|
|
185
|
+
const isWarn = !!c.warn && !!r[c.warn];
|
|
186
|
+
const badge = c.badge && r[c.badge] != null ? String(r[c.badge]) : "";
|
|
187
|
+
const titleKey = c.title ?? c.badge;
|
|
188
|
+
const title = titleKey && r[titleKey] != null ? String(r[titleKey]) : "";
|
|
189
|
+
const meta = metaLine(r, c.meta ?? []);
|
|
190
|
+
if (!badge && !title && !meta) return "";
|
|
191
|
+
return (
|
|
192
|
+
`<div class="rcard">` +
|
|
193
|
+
`<div class="chead">` +
|
|
194
|
+
(badge ? chipFor(badge, isWarn) : "") +
|
|
195
|
+
flagChips(r, c.flags) +
|
|
196
|
+
(title ? `<b>${esc(title)}</b>` : "") +
|
|
197
|
+
`</div>` +
|
|
198
|
+
(meta ? `<div class="cmeta">${meta}</div>` : "") +
|
|
199
|
+
`</div>`
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* A map of dotted paths, drawn as the tree the dots already describe.
|
|
205
|
+
*
|
|
206
|
+
* Each node's own fields become its badge, its flags, and a dim attribute line,
|
|
207
|
+
* so a package describes what a node *is* without devtools knowing what any of it
|
|
208
|
+
* means.
|
|
209
|
+
*/
|
|
210
|
+
function asTree(rows: TraceChannelEntry[], c: TraceChannelDescriptor): string {
|
|
211
|
+
const field = c.treeField;
|
|
212
|
+
if (!field) return asRows(rows, c);
|
|
213
|
+
|
|
214
|
+
return rows
|
|
215
|
+
.map((r) => {
|
|
216
|
+
const raw = r[field];
|
|
217
|
+
const head = treeHead(r, c);
|
|
218
|
+
if (!raw || typeof raw !== "object") {
|
|
219
|
+
return head + `<p class="empty">Nothing recorded under ${esc(field)}</p>`;
|
|
220
|
+
}
|
|
221
|
+
const paths = Object.entries(raw as Record<string, unknown>);
|
|
222
|
+
if (!paths.length) return head + `<p class="empty">No ${esc(field)} on this entry</p>`;
|
|
223
|
+
return head + treeNodes(buildPathTree(paths), c, "");
|
|
224
|
+
})
|
|
225
|
+
.join("");
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Build the tab for one declared channel. */
|
|
229
|
+
export function channelTab(c: TraceChannelDescriptor): TabView {
|
|
230
|
+
return {
|
|
231
|
+
id: `channel:${c.id}`,
|
|
232
|
+
label: c.label,
|
|
233
|
+
|
|
234
|
+
badge: ({ trace }) => {
|
|
235
|
+
const rows = trace?.channels?.[c.id] ?? [];
|
|
236
|
+
return { count: rows.length, warn: !!c.warn && rows.some((r) => !!r[c.warn!]) };
|
|
237
|
+
},
|
|
238
|
+
|
|
239
|
+
render(host, { trace }) {
|
|
240
|
+
const rows = trace?.channels?.[c.id] ?? [];
|
|
241
|
+
if (!rows.length) {
|
|
242
|
+
host.innerHTML = `<p class="empty">No ${esc(c.label.toLowerCase())} activity during this request</p>`;
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
switch (c.render) {
|
|
246
|
+
case "tree":
|
|
247
|
+
host.innerHTML = asTree(rows, c);
|
|
248
|
+
break;
|
|
249
|
+
case "table":
|
|
250
|
+
host.innerHTML = asTable(rows, c);
|
|
251
|
+
break;
|
|
252
|
+
case "kv":
|
|
253
|
+
host.innerHTML = asKv(rows, c);
|
|
254
|
+
break;
|
|
255
|
+
case "grouped":
|
|
256
|
+
host.innerHTML = asGrouped(rows, c);
|
|
257
|
+
break;
|
|
258
|
+
default:
|
|
259
|
+
host.innerHTML = asRows(rows, c);
|
|
260
|
+
}
|
|
261
|
+
},
|
|
262
|
+
};
|
|
263
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The error that ended the request, and how it got there.
|
|
3
|
+
*
|
|
4
|
+
* The Queries tab leads with the message because on a failed request that is the
|
|
5
|
+
* answer. This is the rest of the answer: the class name, and the stack with
|
|
6
|
+
* every frame a link into your editor.
|
|
7
|
+
*
|
|
8
|
+
* Deliberately *not* filtered to application code, unlike a query's call site.
|
|
9
|
+
* You read a stack trace to find out how you got somewhere, and a trace with the
|
|
10
|
+
* middle removed does not tell you that — so the framework frames stay, marked
|
|
11
|
+
* dim so the ones you wrote stand out of them.
|
|
12
|
+
*/
|
|
13
|
+
import { copyBtn, esc, sourceLink } from "../ui/format.ts";
|
|
14
|
+
import type { TabView } from "./types.ts";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Frames from inside the framework, which are context rather than the answer.
|
|
18
|
+
*
|
|
19
|
+
* The same judgement the call-site capture makes, but used to *style* rather than
|
|
20
|
+
* to drop: your own code is what you can act on, and in a forty-frame trace it is
|
|
21
|
+
* six of them.
|
|
22
|
+
*/
|
|
23
|
+
function isVendorFrame(file: string): boolean {
|
|
24
|
+
const path = file.replace(/\\/g, "/");
|
|
25
|
+
return path.includes("node_modules/") || path.includes("/packages/") || path.startsWith("bun:");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const exceptionsTab: TabView = {
|
|
29
|
+
id: "exceptions",
|
|
30
|
+
label: "Exception",
|
|
31
|
+
|
|
32
|
+
badge: ({ trace }) => (trace?.exception ? { count: "!", warn: true } : undefined),
|
|
33
|
+
|
|
34
|
+
render(host, { trace, store }) {
|
|
35
|
+
const e = trace!.exception;
|
|
36
|
+
if (!e) {
|
|
37
|
+
host.innerHTML = '<p class="empty">This request completed without throwing</p>';
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const frames = e.frames ?? [];
|
|
42
|
+
const asText =
|
|
43
|
+
`${e.type ?? "Error"}: ${e.message}\n` +
|
|
44
|
+
frames.map((f) => ` at ${f.function ?? "<anonymous>"} (${f.file}:${f.line})`).join("\n");
|
|
45
|
+
|
|
46
|
+
const body = frames.length
|
|
47
|
+
? frames
|
|
48
|
+
.map(
|
|
49
|
+
(f) =>
|
|
50
|
+
`<div class="frame${isVendorFrame(f.file) ? " vendor" : ""}">` +
|
|
51
|
+
`<span class="fnname">${esc(f.function ?? "‹anonymous›")}</span>` +
|
|
52
|
+
sourceLink(f, store.editor) +
|
|
53
|
+
`</div>`,
|
|
54
|
+
)
|
|
55
|
+
.join("")
|
|
56
|
+
: // A failure with no stack is not a bug in the panel: a thrown string, or
|
|
57
|
+
// an error crossing a boundary that dropped it, both arrive this way.
|
|
58
|
+
'<p class="empty">No stack was captured for this failure</p>';
|
|
59
|
+
|
|
60
|
+
host.innerHTML =
|
|
61
|
+
`<div class="exc">` +
|
|
62
|
+
`<div class="exhead">✗ ${esc(e.type ?? "Error")} · ${e.status}${copyBtn(asText, "Copy the whole trace")}</div>` +
|
|
63
|
+
`<div class="exmsg">${esc(e.message)}</div>` +
|
|
64
|
+
`</div>` +
|
|
65
|
+
`<div class="sec"><div class="stitle">Stack (${frames.length})</div></div>` +
|
|
66
|
+
`<div>${body}</div>`;
|
|
67
|
+
},
|
|
68
|
+
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/** Jobs dispatched (or processed synchronously) during the request. */
|
|
2
|
+
import type { JobEntry } from "../../RequestTrace.ts";
|
|
3
|
+
import { esc, fmt } from "../ui/format.ts";
|
|
4
|
+
import type { TabView } from "./types.ts";
|
|
5
|
+
|
|
6
|
+
function icon(status: JobEntry["status"]): string {
|
|
7
|
+
return status === "dispatched" ? "⚙" : status === "completed" ? "✓" : "✗";
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const jobsTab: TabView = {
|
|
11
|
+
id: "jobs",
|
|
12
|
+
label: "Jobs",
|
|
13
|
+
|
|
14
|
+
badge: ({ trace }) =>
|
|
15
|
+
trace
|
|
16
|
+
? {
|
|
17
|
+
count: trace.jobs?.length ?? 0,
|
|
18
|
+
warn: trace.jobs?.some((j) => j.status === "failed") ?? false,
|
|
19
|
+
}
|
|
20
|
+
: undefined,
|
|
21
|
+
|
|
22
|
+
render(host, { trace }) {
|
|
23
|
+
const jobs = trace!.jobs ?? [];
|
|
24
|
+
if (!jobs.length) {
|
|
25
|
+
host.innerHTML = '<p class="empty">No jobs dispatched during this request</p>';
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
host.innerHTML =
|
|
29
|
+
`<div>` +
|
|
30
|
+
jobs
|
|
31
|
+
.map((j) => {
|
|
32
|
+
const chip = j.status === "completed" ? "ok" : j.status === "failed" ? "warn" : "";
|
|
33
|
+
return (
|
|
34
|
+
`<div class="card">` +
|
|
35
|
+
`<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px">` +
|
|
36
|
+
`<span class="chip ${chip}">${icon(j.status)} ${j.status}</span>` +
|
|
37
|
+
`<span class="dim" style="font-size:10px">+${j.offsetMs}ms · ${fmt(j.durationMs)}</span>` +
|
|
38
|
+
`</div>` +
|
|
39
|
+
`<div style="font-weight:600;margin-bottom:2px">${esc(j.className)}</div>` +
|
|
40
|
+
`<div class="dim" style="font-size:11px">Queue: ${esc(j.queue)}</div>` +
|
|
41
|
+
(j.error
|
|
42
|
+
? `<div style="color:var(--red);font-size:11px;margin-top:4px">${esc(j.error)}</div>`
|
|
43
|
+
: "") +
|
|
44
|
+
`</div>`
|
|
45
|
+
);
|
|
46
|
+
})
|
|
47
|
+
.join("") +
|
|
48
|
+
`</div>`;
|
|
49
|
+
},
|
|
50
|
+
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/** Console output captured during the request. */
|
|
2
|
+
import { copyBtn, esc, sourceLink } from "../ui/format.ts";
|
|
3
|
+
import type { TabView } from "./types.ts";
|
|
4
|
+
|
|
5
|
+
export const logsTab: TabView = {
|
|
6
|
+
id: "logs",
|
|
7
|
+
label: "Logs",
|
|
8
|
+
|
|
9
|
+
badge: ({ trace }) => {
|
|
10
|
+
const logs = trace?.logs ?? [];
|
|
11
|
+
if (!trace) return undefined;
|
|
12
|
+
return {
|
|
13
|
+
count: logs.length,
|
|
14
|
+
warn: logs.some((l) => l.level === "error" || l.level === "warn"),
|
|
15
|
+
};
|
|
16
|
+
},
|
|
17
|
+
|
|
18
|
+
render(host, { trace, store }) {
|
|
19
|
+
const logs = trace!.logs ?? [];
|
|
20
|
+
if (!logs.length) {
|
|
21
|
+
host.innerHTML = '<p class="empty">No console output captured</p>';
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
host.innerHTML =
|
|
25
|
+
`<div>` +
|
|
26
|
+
logs
|
|
27
|
+
.map((l) => {
|
|
28
|
+
const message = l.args.join(" ");
|
|
29
|
+
return (
|
|
30
|
+
`<div class="lrow">` +
|
|
31
|
+
`<span class="ltime dim">+${l.offsetMs}ms</span>` +
|
|
32
|
+
`<span class="llvl ${l.level}">${l.level.toUpperCase()}</span>` +
|
|
33
|
+
`<span class="lmsg">${esc(message)}</span>` +
|
|
34
|
+
// Where it was logged from. A stray `console.log` in a codebase you
|
|
35
|
+
// did not write all of is otherwise a search.
|
|
36
|
+
sourceLink(l.source, store.editor) +
|
|
37
|
+
copyBtn(message, "Copy line") +
|
|
38
|
+
`</div>`
|
|
39
|
+
);
|
|
40
|
+
})
|
|
41
|
+
.join("") +
|
|
42
|
+
`</div>`;
|
|
43
|
+
},
|
|
44
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/** Emails sent or queued during the request, each with a sandboxed preview. */
|
|
2
|
+
import type { MailEntry } from "../../RequestTrace.ts";
|
|
3
|
+
import { esc, fmt } from "../ui/format.ts";
|
|
4
|
+
import type { TabView } from "./types.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The rendered email, in a frame that can do nothing.
|
|
8
|
+
*
|
|
9
|
+
* The HTML has crossed the wire on every request since mail capture landed and
|
|
10
|
+
* had never been shown. It cannot simply be inserted: the panel lives in a shadow
|
|
11
|
+
* root on the app's own origin, so injecting a template's markup inline is a
|
|
12
|
+
* self-XSS on every dev machine that renders a mail containing user input. An
|
|
13
|
+
* empty `sandbox` allows nothing — no scripts, no same-origin, no forms, no
|
|
14
|
+
* navigation — which is the whole of what a preview needs.
|
|
15
|
+
*
|
|
16
|
+
* Collapsed by default: the point of the tab is which mails were sent, and three
|
|
17
|
+
* open previews push that off the screen. Keyed rendering means an open preview
|
|
18
|
+
* now survives the next request arriving, rather than reloading its iframe.
|
|
19
|
+
*/
|
|
20
|
+
function preview(m: MailEntry): string {
|
|
21
|
+
if (!m.html) return "";
|
|
22
|
+
return (
|
|
23
|
+
`<details class="mprev"><summary>Preview</summary>` +
|
|
24
|
+
`<iframe class="mframe" sandbox="" srcdoc="${esc(m.html)}" title="${esc(m.subject)}"></iframe>` +
|
|
25
|
+
`</details>`
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const mailTab: TabView = {
|
|
30
|
+
id: "mail",
|
|
31
|
+
label: "Mail",
|
|
32
|
+
|
|
33
|
+
badge: ({ trace }) => (trace ? { count: trace.mail?.length ?? 0 } : undefined),
|
|
34
|
+
|
|
35
|
+
render(host, { trace }) {
|
|
36
|
+
const mail = trace!.mail ?? [];
|
|
37
|
+
if (!mail.length) {
|
|
38
|
+
host.innerHTML = '<p class="empty">No emails sent during this request</p>';
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
host.innerHTML =
|
|
42
|
+
`<div>` +
|
|
43
|
+
mail
|
|
44
|
+
.map(
|
|
45
|
+
(m) =>
|
|
46
|
+
`<div class="card">` +
|
|
47
|
+
`<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px">` +
|
|
48
|
+
`<span class="chip${m.queued ? "" : " ok"}">${m.queued ? "⏳ Queued" : "✓ Sent"}</span>` +
|
|
49
|
+
`<span class="dim" style="font-size:10px">${esc(m.className)} · ${fmt(m.durationMs)}</span>` +
|
|
50
|
+
`</div>` +
|
|
51
|
+
`<div style="font-weight:700;margin-bottom:2px">${esc(m.subject)}</div>` +
|
|
52
|
+
`<div class="dim" style="font-size:11px">To: ${esc(m.to.join(", "))}</div>` +
|
|
53
|
+
preview(m) +
|
|
54
|
+
`</div>`,
|
|
55
|
+
)
|
|
56
|
+
.join("") +
|
|
57
|
+
`</div>`;
|
|
58
|
+
},
|
|
59
|
+
};
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The default tab: what the request cost, what went wrong, and every statement
|
|
3
|
+
* it ran.
|
|
4
|
+
*/
|
|
5
|
+
import type { QuerySpan, RequestTrace } from "../../RequestTrace.ts";
|
|
6
|
+
import type { EditorSettings } from "../state.ts";
|
|
7
|
+
import { copyBtn, dCls, esc, fmt, fmtMem, sourceLink, tableFrom } from "../ui/format.ts";
|
|
8
|
+
import type { TabView } from "./types.ts";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The error that ended the request, above everything else about it.
|
|
12
|
+
*
|
|
13
|
+
* `RequestFailed` has always carried the message; the trace used to drop it, so a
|
|
14
|
+
* failed request showed as a red status code with nothing to read. It heads this
|
|
15
|
+
* tab rather than getting a tab of its own because on a request that threw, it is
|
|
16
|
+
* the answer — and a tab you have to find first is not.
|
|
17
|
+
*/
|
|
18
|
+
function exceptionBanner(t: RequestTrace): string {
|
|
19
|
+
if (!t.exception) return "";
|
|
20
|
+
return (
|
|
21
|
+
`<div class="exc">` +
|
|
22
|
+
`<div class="exhead">✗ Request failed · ${t.exception.status}${copyBtn(t.exception.message, "Copy message")}</div>` +
|
|
23
|
+
`<div class="exmsg">${esc(t.exception.message)}</div>` +
|
|
24
|
+
`</div>`
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function queryRow(q: QuerySpan, peak: number, editor: EditorSettings): string {
|
|
29
|
+
const pct = Math.round((q.durationMs / peak) * 100);
|
|
30
|
+
const dc = q.durationMs < 10 ? "green" : q.durationMs < 100 ? "yellow" : "red";
|
|
31
|
+
const bindings = q.bindings as unknown[] | undefined;
|
|
32
|
+
const binds = bindings?.length
|
|
33
|
+
? `<div class="qbind"><span class="dim">bindings: </span>` +
|
|
34
|
+
bindings
|
|
35
|
+
.map((v) =>
|
|
36
|
+
v == null
|
|
37
|
+
? '<span class="bind">null</span>'
|
|
38
|
+
: typeof v === "string"
|
|
39
|
+
? `<span class="bind">'${esc(v)}'</span>`
|
|
40
|
+
: `<span class="bind">${esc(String(v))}</span>`,
|
|
41
|
+
)
|
|
42
|
+
.join('<span class="dim">, </span>') +
|
|
43
|
+
`</div>`
|
|
44
|
+
: "";
|
|
45
|
+
return (
|
|
46
|
+
`<div class="qrow">` +
|
|
47
|
+
`<div class="qmeta">` +
|
|
48
|
+
`<span class="qdur ${dc}">${fmt(q.durationMs)}</span>` +
|
|
49
|
+
`<div class="qbar"><div class="qfill" style="width:${pct}%"></div></div>` +
|
|
50
|
+
`<span class="dim" style="font-size:10px">${q.rowCount} row${q.rowCount !== 1 ? "s" : ""}</span>` +
|
|
51
|
+
// The line that ran it. "Which of my forty queries is slow" was answerable;
|
|
52
|
+
// this is the other half — where to go and change it.
|
|
53
|
+
sourceLink(q.source, editor) +
|
|
54
|
+
copyBtn(q.sql, "Copy SQL") +
|
|
55
|
+
`</div><div class="qsql">${esc(q.sql)}</div>${binds}</div>`
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const queriesTab: TabView = {
|
|
60
|
+
id: "queries",
|
|
61
|
+
label: "Queries",
|
|
62
|
+
|
|
63
|
+
badge: ({ trace }) =>
|
|
64
|
+
trace
|
|
65
|
+
? {
|
|
66
|
+
count: trace.queries.length,
|
|
67
|
+
warn: !!trace.warnings.length || !!trace.exception,
|
|
68
|
+
}
|
|
69
|
+
: undefined,
|
|
70
|
+
|
|
71
|
+
render(host, { trace, store }) {
|
|
72
|
+
const t = trace!;
|
|
73
|
+
const dbMs = t.queries.reduce((s, q) => s + q.durationMs, 0);
|
|
74
|
+
const peak = Math.max(1, ...t.queries.map((q) => q.durationMs));
|
|
75
|
+
|
|
76
|
+
const route = t.route
|
|
77
|
+
? `<div class="rcard">` +
|
|
78
|
+
`<span class="meth ${t.method.toLowerCase()}">${esc(t.method)}</span> ` +
|
|
79
|
+
`<b>${esc(t.route.pattern)}</b>` +
|
|
80
|
+
`<span class="dim" style="font-size:10px;margin-left:8px">${esc(t.route.controller)}@${esc(t.route.action)}</span>` +
|
|
81
|
+
`</div>`
|
|
82
|
+
: "";
|
|
83
|
+
|
|
84
|
+
const mem = t.memory
|
|
85
|
+
? `<div class="stat"><div class="slbl">Memory</div><div class="sval">${fmtMem(t.memory)}</div></div>`
|
|
86
|
+
: "";
|
|
87
|
+
const auth = t.auth
|
|
88
|
+
? `<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>`
|
|
89
|
+
: `<div class="stat"><div class="slbl">User</div><div class="sval dim">Guest</div></div>`;
|
|
90
|
+
|
|
91
|
+
// A warning that does not say what to do about it is half a feature, so each
|
|
92
|
+
// one carries the eager-load that removes it and the call that suppresses it
|
|
93
|
+
// when the repetition is intended.
|
|
94
|
+
const warns = t.warnings
|
|
95
|
+
.map(
|
|
96
|
+
(w) =>
|
|
97
|
+
`<div class="wrow"><div class="whead">⚠ N+1 · executed <b>${w.count}×</b></div>` +
|
|
98
|
+
`<div class="qsql dim">${esc(w.sql)}</div>` +
|
|
99
|
+
`<div class="wfix">Fix: eager-load with <code>.with('relation')</code>` +
|
|
100
|
+
` · suppress: <code>DB.allowNPlusOne('${esc(tableFrom(w.sql))}')</code></div>` +
|
|
101
|
+
`</div>`,
|
|
102
|
+
)
|
|
103
|
+
.join("");
|
|
104
|
+
|
|
105
|
+
const queries = t.queries.length
|
|
106
|
+
? t.queries.map((q) => queryRow(q, peak, store.editor)).join("")
|
|
107
|
+
: '<p class="empty">No queries for this request</p>';
|
|
108
|
+
|
|
109
|
+
const allSql = t.queries.map((q) => q.sql).join(";\n\n");
|
|
110
|
+
|
|
111
|
+
host.innerHTML =
|
|
112
|
+
`${route}${exceptionBanner(t)}<div class="stats">` +
|
|
113
|
+
`<div class="stat"><div class="slbl">Duration</div><div class="sval ${dCls(t.durationMs)}">${fmt(t.durationMs)}</div></div>` +
|
|
114
|
+
`<div class="stat"><div class="slbl">Queries</div><div class="sval">${t.queries.length}</div></div>` +
|
|
115
|
+
`<div class="stat"><div class="slbl">DB time</div><div class="sval">${fmt(dbMs)}</div></div>` +
|
|
116
|
+
`${mem}${auth}</div>` +
|
|
117
|
+
(t.warnings.length
|
|
118
|
+
? `<div class="sec"><div class="stitle">N+1 Warnings</div>${warns}</div>`
|
|
119
|
+
: "") +
|
|
120
|
+
`<div class="sec"><div class="stitle">Queries (${t.queries.length})` +
|
|
121
|
+
copyBtn(allSql, "Copy every statement") +
|
|
122
|
+
`</div>${queries}</div>`;
|
|
123
|
+
},
|
|
124
|
+
};
|