@zerotal/devtools 1.6.3 → 1.7.2
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 +329 -0
- package/api-surface.md +298 -0
- package/package.json +5 -4
- package/src/DevtoolsInjectionMiddleware.ts +41 -4
- package/src/RequestTrace.ts +106 -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 +127 -0
- package/src/client/metrics.ts +98 -0
- package/src/client/registry.ts +87 -0
- package/src/client/state.ts +350 -0
- package/src/client/tabs/all.ts +323 -0
- package/src/client/tabs/app.ts +293 -0
- package/src/client/tabs/cache.ts +50 -0
- package/src/client/tabs/channel.ts +264 -0
- package/src/client/tabs/exceptions.ts +69 -0
- package/src/client/tabs/jobs.ts +51 -0
- package/src/client/tabs/live.ts +66 -0
- package/src/client/tabs/logs.ts +45 -0
- package/src/client/tabs/mail.ts +60 -0
- package/src/client/tabs/queries.ts +125 -0
- package/src/client/tabs/request.ts +75 -0
- package/src/client/tabs/sections.ts +115 -0
- package/src/client/tabs/timeline.ts +133 -0
- package/src/client/tabs/types.ts +68 -0
- package/src/client/transport.ts +81 -0
- package/src/client/tree.ts +138 -0
- package/src/client/ui/format.ts +137 -0
- package/src/client/ui/render.ts +87 -0
- package/src/client/ui/shell.ts +560 -0
- package/src/client/ui/theme.ts +445 -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 +261 -29
- package/src/client.ts +0 -1048
- package/src/panel-app.js +0 -519
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The request history: filter, facets, correlated groups, and a windowed list.
|
|
3
|
+
*
|
|
4
|
+
* The tab that redraws most, because every request changes it — which is why it
|
|
5
|
+
* is the one that reconciles rather than rebuilding. Three things depend on that:
|
|
6
|
+
* the caret in the filter box, the scroll position, and the open state of every
|
|
7
|
+
* expanded group all used to be thrown away by each arriving request.
|
|
8
|
+
*/
|
|
9
|
+
import { facetsActive, methodsPresent, noFacets, type Facets } from "../filter.ts";
|
|
10
|
+
import { foldTraceRows, type TraceRow } from "../tree.ts";
|
|
11
|
+
import { dCls, esc, fmt, scCls } from "../ui/format.ts";
|
|
12
|
+
import { el, reconcile } from "../ui/render.ts";
|
|
13
|
+
import type { TabContext, TabView } from "./types.ts";
|
|
14
|
+
import type { RequestTrace } from "../../RequestTrace.ts";
|
|
15
|
+
import { renderSections } from "./sections.ts";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Row height in pixels, which the stylesheet pins.
|
|
19
|
+
*
|
|
20
|
+
* The windowing below positions rows arithmetically rather than measuring them,
|
|
21
|
+
* so a row that could grow would put every offset out. `.hrow` is fixed at this
|
|
22
|
+
* height for exactly that reason.
|
|
23
|
+
*/
|
|
24
|
+
const ROW_H = 26;
|
|
25
|
+
|
|
26
|
+
/** Above this many rows the list is windowed; below it, drawn whole. */
|
|
27
|
+
const VIRTUAL_THRESHOLD = 200;
|
|
28
|
+
|
|
29
|
+
/** Rows drawn beyond each edge of the viewport, so a fast scroll finds them there. */
|
|
30
|
+
const OVERSCAN = 8;
|
|
31
|
+
|
|
32
|
+
const STATUS_CLASSES: Array<[string, string]> = [
|
|
33
|
+
["2", "2xx"],
|
|
34
|
+
["3", "3xx"],
|
|
35
|
+
["4", "4xx"],
|
|
36
|
+
["5", "5xx"],
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
// ── Skeleton ──────────────────────────────────────────────────────────────────
|
|
40
|
+
//
|
|
41
|
+
// Built once per visit to the tab and then only updated. The filter input in
|
|
42
|
+
// particular must never be re-created: an input rebuilt under a typing user is an
|
|
43
|
+
// input that loses the caret on every keystroke, which the old panel worked
|
|
44
|
+
// around by re-focusing and re-selecting after each render.
|
|
45
|
+
|
|
46
|
+
function skeleton(): string {
|
|
47
|
+
return (
|
|
48
|
+
`<div class="fbar">` +
|
|
49
|
+
`<input id="filter" class="finput" type="search" ` +
|
|
50
|
+
`placeholder="Filter by path, method, status, or controller…">` +
|
|
51
|
+
`<span class="dim" id="fcount"></span>` +
|
|
52
|
+
// Labelled rather than the bar's icon: this is where you are when you decide
|
|
53
|
+
// the history is in your way, and the icon at the far end of the status bar
|
|
54
|
+
// is not where you look for it.
|
|
55
|
+
`<button class="tbtn" data-action="clear" title="Discard every recorded request">Clear</button>` +
|
|
56
|
+
`</div>` +
|
|
57
|
+
`<div class="facets" id="facets"></div>` +
|
|
58
|
+
`<div id="rowswrap">` +
|
|
59
|
+
`<div class="vpad" id="padtop"></div>` +
|
|
60
|
+
`<div id="rows"></div>` +
|
|
61
|
+
`<div class="vpad" id="padbot"></div>` +
|
|
62
|
+
`</div>`
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The facet chips.
|
|
68
|
+
*
|
|
69
|
+
* Method chips come from the traces actually recorded — listing every HTTP verb
|
|
70
|
+
* would put five dead chips on screen for an app that only ever GETs.
|
|
71
|
+
*/
|
|
72
|
+
function facetChips(store: TabContext["store"]): string {
|
|
73
|
+
const f = store.facets;
|
|
74
|
+
const chip = (kind: string, value: string, label: string, on: boolean, warn = false): string =>
|
|
75
|
+
`<button class="fchip${on ? " on" : ""}${warn ? " warn" : ""}" ` +
|
|
76
|
+
`data-facet="${esc(kind)}" data-value="${esc(value)}">${esc(label)}</button>`;
|
|
77
|
+
|
|
78
|
+
const methods = methodsPresent(store.traces)
|
|
79
|
+
.map((m) => chip("method", m, m, f.methods.includes(m)))
|
|
80
|
+
.join("");
|
|
81
|
+
const statuses = STATUS_CLASSES.map(([digit, label]) =>
|
|
82
|
+
chip("status", digit, label, f.statusClasses.includes(digit)),
|
|
83
|
+
).join("");
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
methods +
|
|
87
|
+
(methods ? `<span class="fsep"></span>` : "") +
|
|
88
|
+
statuses +
|
|
89
|
+
`<span class="fsep"></span>` +
|
|
90
|
+
chip("errors", "", "errors", f.errors, true) +
|
|
91
|
+
chip("slow", "", "slow", f.slow, true) +
|
|
92
|
+
chip("nplus", "", "n+1", f.nPlusOne, true) +
|
|
93
|
+
(facetsActive(f) ? `<span class="fsep"></span>` + chip("clear", "", "✕ clear", false) : "")
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Apply one chip click to the facet set, returning a new one. */
|
|
98
|
+
export function toggleFacet(f: Facets, kind: string, value: string): Facets {
|
|
99
|
+
const drop = <T>(list: T[], item: T): T[] =>
|
|
100
|
+
list.includes(item) ? list.filter((x) => x !== item) : [...list, item];
|
|
101
|
+
switch (kind) {
|
|
102
|
+
case "method":
|
|
103
|
+
return { ...f, methods: drop(f.methods, value) };
|
|
104
|
+
case "status":
|
|
105
|
+
return { ...f, statusClasses: drop(f.statusClasses, value) };
|
|
106
|
+
case "errors":
|
|
107
|
+
return { ...f, errors: !f.errors };
|
|
108
|
+
case "slow":
|
|
109
|
+
return { ...f, slow: !f.slow };
|
|
110
|
+
case "nplus":
|
|
111
|
+
return { ...f, nPlusOne: !f.nPlusOne };
|
|
112
|
+
case "clear":
|
|
113
|
+
return noFacets();
|
|
114
|
+
default:
|
|
115
|
+
return f;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ── Rows ──────────────────────────────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
/** A row's identity across redraws — the trace, plus where it sits in its group. */
|
|
122
|
+
function rowKey(r: TraceRow): string {
|
|
123
|
+
return `${r.trace.id}:${r.child ? "c" : "h"}`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** What the list draws: request rows, and the detail of whichever one is open. */
|
|
127
|
+
type DrawItem = { kind: "row"; row: TraceRow } | { kind: "detail"; trace: RequestTrace };
|
|
128
|
+
|
|
129
|
+
function itemKey(item: DrawItem): string {
|
|
130
|
+
return item.kind === "detail" ? `${item.trace.id}:d` : rowKey(item.row);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function rowHtml(r: TraceRow, selectedId: string | undefined, openId: string | null): string {
|
|
134
|
+
const t = r.trace;
|
|
135
|
+
const toggle = r.groupKey
|
|
136
|
+
? `<button class="gtog" data-group="${esc(r.groupKey)}" title="Requests in this batch">` +
|
|
137
|
+
`+${r.groupSize}</button>`
|
|
138
|
+
: "";
|
|
139
|
+
return (
|
|
140
|
+
`<div class="hrow${t.id === selectedId ? " cur" : ""}${t.exception ? " err" : ""}` +
|
|
141
|
+
`${r.child ? " child" : ""}" data-idx="${r.index}">` +
|
|
142
|
+
`<span class="hchev">${t.id === openId ? "▾" : "▸"}</span>` +
|
|
143
|
+
`<span class="meth ${t.method.toLowerCase()}">${esc(t.method)}</span>` +
|
|
144
|
+
`<span class="hpath">${esc(t.path)}</span>` +
|
|
145
|
+
// The message, not just the status: scanning a list for the request that
|
|
146
|
+
// broke is the reason to open this tab.
|
|
147
|
+
(t.exception
|
|
148
|
+
? `<span class="hexc" title="${esc(t.exception.message)}">${esc(t.exception.message)}</span>`
|
|
149
|
+
: "") +
|
|
150
|
+
toggle +
|
|
151
|
+
`<span class="sc ${scCls(t.statusCode)}">${t.statusCode || "—"}</span>` +
|
|
152
|
+
`<span class="${dCls(t.durationMs) || "dim"}">${fmt(t.durationMs)}</span>` +
|
|
153
|
+
`<span class="dim">${t.queries.length}q</span>` +
|
|
154
|
+
(t.warnings.length ? '<span class="chip warn">N+1</span>' : "") +
|
|
155
|
+
`</div>`
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Which slice of the list to actually draw.
|
|
161
|
+
*
|
|
162
|
+
* Below the threshold, all of it — windowing a short list costs more than it
|
|
163
|
+
* saves and makes `Ctrl+F` in the browser useless for no reason. Above it, only
|
|
164
|
+
* what the viewport can show plus an overscan, with two spacers standing in for
|
|
165
|
+
* the height of everything else.
|
|
166
|
+
*/
|
|
167
|
+
export function windowRange(
|
|
168
|
+
total: number,
|
|
169
|
+
scrollTop: number,
|
|
170
|
+
viewportH: number,
|
|
171
|
+
headerH: number,
|
|
172
|
+
): { first: number; count: number } {
|
|
173
|
+
if (total <= VIRTUAL_THRESHOLD) return { first: 0, count: total };
|
|
174
|
+
const above = Math.max(0, scrollTop - headerH);
|
|
175
|
+
const visible = Math.ceil(viewportH / ROW_H) + OVERSCAN * 2;
|
|
176
|
+
// Clamped at both ends. Without the upper clamp a scroll offset past the list —
|
|
177
|
+
// which happens for one frame whenever the list shrinks under a scrolled
|
|
178
|
+
// viewport, as it does on every clear — asks for a window starting beyond the
|
|
179
|
+
// end, and the count comes back negative.
|
|
180
|
+
const first = Math.max(0, Math.min(Math.floor(above / ROW_H) - OVERSCAN, total - visible));
|
|
181
|
+
return { first, count: Math.min(visible, total - first) };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function draw(host: HTMLElement, ctx: TabContext): void {
|
|
185
|
+
const { store } = ctx;
|
|
186
|
+
const rowsHost = host.querySelector<HTMLElement>("#rows");
|
|
187
|
+
const wrap = host.querySelector<HTMLElement>("#rowswrap");
|
|
188
|
+
const padTop = host.querySelector<HTMLElement>("#padtop");
|
|
189
|
+
const padBot = host.querySelector<HTMLElement>("#padbot");
|
|
190
|
+
if (!rowsHost || !wrap || !padTop || !padBot) return;
|
|
191
|
+
|
|
192
|
+
const matches = store.visible();
|
|
193
|
+
const rows = foldTraceRows(matches, store.channels, store.expanded);
|
|
194
|
+
|
|
195
|
+
const count = host.querySelector<HTMLElement>("#fcount");
|
|
196
|
+
if (count) count.textContent = `${matches.length}/${store.traces.length}`;
|
|
197
|
+
|
|
198
|
+
if (!rows.length) {
|
|
199
|
+
padTop.style.height = "0px";
|
|
200
|
+
padBot.style.height = "0px";
|
|
201
|
+
rowsHost.innerHTML = `<p class="empty">No requests match this filter</p>`;
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// `host` *is* the scroller — the tab renders straight into the content pane,
|
|
206
|
+
// which is what scrolls. The rows start below the sticky filter bar and the
|
|
207
|
+
// facet strip, so the window is offset by the wrapper's position rather than
|
|
208
|
+
// measured from zero.
|
|
209
|
+
//
|
|
210
|
+
// Windowing is suspended while a request is open, because an open detail is a
|
|
211
|
+
// row of unknown height and every offset here is arithmetic on a fixed one.
|
|
212
|
+
// The store keeps 100 traces and the threshold is 200, so in practice this
|
|
213
|
+
// draws the same whole list it would have drawn anyway; the guard is for the
|
|
214
|
+
// correlated-group case that can fold more rows than traces.
|
|
215
|
+
const openId = store.openTraceId;
|
|
216
|
+
const { first, count: take } = openId
|
|
217
|
+
? { first: 0, count: rows.length }
|
|
218
|
+
: windowRange(rows.length, host.scrollTop, host.clientHeight || 0, wrap.offsetTop);
|
|
219
|
+
const slice = rows.slice(first, first + take);
|
|
220
|
+
|
|
221
|
+
padTop.style.height = `${first * ROW_H}px`;
|
|
222
|
+
padBot.style.height = `${Math.max(0, rows.length - first - take) * ROW_H}px`;
|
|
223
|
+
|
|
224
|
+
// The open request's detail rides in the list as its own item, so the
|
|
225
|
+
// reconciler keeps it across redraws — rebuilding it on every arriving request
|
|
226
|
+
// would collapse whatever the reader had scrolled to inside it.
|
|
227
|
+
const items: DrawItem[] = [];
|
|
228
|
+
for (const row of slice) {
|
|
229
|
+
items.push({ kind: "row", row });
|
|
230
|
+
if (openId && row.trace.id === openId && !row.child) {
|
|
231
|
+
items.push({ kind: "detail", trace: row.trace });
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const selectedId = store.selected?.id;
|
|
236
|
+
reconcile(
|
|
237
|
+
rowsHost,
|
|
238
|
+
items,
|
|
239
|
+
itemKey,
|
|
240
|
+
(item) => {
|
|
241
|
+
if (item.kind === "detail") {
|
|
242
|
+
const node = document.createElement("div");
|
|
243
|
+
node.className = "hdetail";
|
|
244
|
+
renderSections(node, item.trace, ctx);
|
|
245
|
+
node.setAttribute("data-detail-rev", String(store.revision));
|
|
246
|
+
return node;
|
|
247
|
+
}
|
|
248
|
+
const html = rowHtml(item.row, selectedId, openId);
|
|
249
|
+
const node = el(html);
|
|
250
|
+
// Stamped on creation as well, so the very next update compares equal and
|
|
251
|
+
// a row that has not changed is never rewritten.
|
|
252
|
+
node.setAttribute("data-html", html);
|
|
253
|
+
return node;
|
|
254
|
+
},
|
|
255
|
+
// Refilled in place rather than patched field by field: a row is six spans,
|
|
256
|
+
// and the reconciler's job — keeping the *node* so scroll position and text
|
|
257
|
+
// selection survive — is already done by the time this runs. The markup is
|
|
258
|
+
// compared first, so a steady list generates no DOM writes at all.
|
|
259
|
+
(node, item) => {
|
|
260
|
+
if (item.kind === "detail") {
|
|
261
|
+
// Redrawn only when the store actually moved. The sections below are
|
|
262
|
+
// whole tab renderers; running them on every keystroke in the filter box
|
|
263
|
+
// would be the most expensive thing in the panel.
|
|
264
|
+
const rev = String(store.revision);
|
|
265
|
+
if (node.getAttribute("data-detail-rev") === rev) return;
|
|
266
|
+
renderSections(node, item.trace, ctx);
|
|
267
|
+
node.setAttribute("data-detail-rev", rev);
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
const next = rowHtml(item.row, selectedId, openId);
|
|
271
|
+
if (node.getAttribute("data-html") === next) return;
|
|
272
|
+
const fresh = el(next);
|
|
273
|
+
node.className = fresh.className;
|
|
274
|
+
node.replaceChildren(...Array.from(fresh.childNodes));
|
|
275
|
+
node.setAttribute("data-idx", String(item.row.index));
|
|
276
|
+
node.setAttribute("data-html", next);
|
|
277
|
+
},
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export const allTab: TabView = {
|
|
282
|
+
id: "all",
|
|
283
|
+
label: "All",
|
|
284
|
+
scope: "session",
|
|
285
|
+
live: true,
|
|
286
|
+
volatile: true,
|
|
287
|
+
standsAlone: true,
|
|
288
|
+
|
|
289
|
+
badge: ({ store }) => ({ count: store.traces.length }),
|
|
290
|
+
|
|
291
|
+
render(host, ctx) {
|
|
292
|
+
if (!ctx.store.traces.length) {
|
|
293
|
+
host.innerHTML =
|
|
294
|
+
'<p class="empty">No requests recorded yet — make a request to see it here</p>';
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
// Rebuild the frame only when it is not already there — switching tabs away
|
|
298
|
+
// and back replaces the content host's children, arriving traces do not.
|
|
299
|
+
if (!host.querySelector("#rowswrap")) {
|
|
300
|
+
host.innerHTML = skeleton();
|
|
301
|
+
const input = host.querySelector<HTMLInputElement>("#filter");
|
|
302
|
+
if (input) input.value = ctx.store.filter;
|
|
303
|
+
}
|
|
304
|
+
// Rewritten only when it actually differs. Otherwise every arriving request
|
|
305
|
+
// replaces the strip, which throws away keyboard focus on a chip the user is
|
|
306
|
+
// tabbing through for a set of buttons that did not change.
|
|
307
|
+
const facets = host.querySelector<HTMLElement>("#facets");
|
|
308
|
+
if (facets) {
|
|
309
|
+
const next = facetChips(ctx.store);
|
|
310
|
+
if (facets.dataset["sig"] !== next) {
|
|
311
|
+
facets.innerHTML = next;
|
|
312
|
+
facets.dataset["sig"] = next;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
draw(host, ctx);
|
|
316
|
+
},
|
|
317
|
+
|
|
318
|
+
// Windowing means the visible slice is a function of the scroll offset, so the
|
|
319
|
+
// list has to be redrawn as it moves — but only the list.
|
|
320
|
+
onScroll(host, ctx) {
|
|
321
|
+
if (host.querySelector("#rowswrap")) draw(host, ctx);
|
|
322
|
+
},
|
|
323
|
+
};
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The App section: the framework as it *is*, rather than as it just behaved.
|
|
3
|
+
*
|
|
4
|
+
* Every other tab reads the trace stream — what one request did. These six read
|
|
5
|
+
* the framework's own registries, which existed all along and were CLI-only or
|
|
6
|
+
* invisible. All six share one fetch, because they are one read of one map and
|
|
7
|
+
* six requests for it would be six answers that can disagree.
|
|
8
|
+
*/
|
|
9
|
+
import { esc, fmt } from "../ui/format.ts";
|
|
10
|
+
import type { Store } from "../state.ts";
|
|
11
|
+
import type { TabContext, TabView } from "./types.ts";
|
|
12
|
+
|
|
13
|
+
/** One route, as the map serves it. */
|
|
14
|
+
interface RouteRow {
|
|
15
|
+
method: string;
|
|
16
|
+
path: string;
|
|
17
|
+
name: string;
|
|
18
|
+
handler: string;
|
|
19
|
+
middleware: string;
|
|
20
|
+
}
|
|
21
|
+
interface BindingRow {
|
|
22
|
+
token: string;
|
|
23
|
+
kind: string;
|
|
24
|
+
provider: string;
|
|
25
|
+
}
|
|
26
|
+
interface ProviderRow {
|
|
27
|
+
name: string;
|
|
28
|
+
durationMs: number;
|
|
29
|
+
bindings: number;
|
|
30
|
+
}
|
|
31
|
+
interface EventRow {
|
|
32
|
+
event: string;
|
|
33
|
+
listeners: string;
|
|
34
|
+
source: string;
|
|
35
|
+
}
|
|
36
|
+
interface ActivityRow {
|
|
37
|
+
kind: string;
|
|
38
|
+
name: string;
|
|
39
|
+
outcome: string;
|
|
40
|
+
durationMs: number;
|
|
41
|
+
at: number;
|
|
42
|
+
failed: boolean;
|
|
43
|
+
detail?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface FrameworkMap {
|
|
47
|
+
routes: RouteRow[];
|
|
48
|
+
config: Record<string, unknown>;
|
|
49
|
+
bindings: BindingRow[];
|
|
50
|
+
providers: ProviderRow[];
|
|
51
|
+
events: EventRow[];
|
|
52
|
+
activity: ActivityRow[];
|
|
53
|
+
bootMs: number | null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ── Shared fetch ──────────────────────────────────────────────────────────────
|
|
57
|
+
//
|
|
58
|
+
// One read, shared by six tabs. Kept out of the store because it is not part of
|
|
59
|
+
// what the panel *is* — it is a thing the App section goes and gets, and a store
|
|
60
|
+
// field for it would make every request-tab render depend on it.
|
|
61
|
+
|
|
62
|
+
let _map: FrameworkMap | null = null;
|
|
63
|
+
let _loading = false;
|
|
64
|
+
let _error: string | null = null;
|
|
65
|
+
|
|
66
|
+
/** Drop the cached map so the next render re-reads it. */
|
|
67
|
+
export function invalidateMap(): void {
|
|
68
|
+
_map = null;
|
|
69
|
+
_error = null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Fetch the map once, then serve it from memory.
|
|
74
|
+
*
|
|
75
|
+
* Cached because the registries are static for the life of the process and six
|
|
76
|
+
* tabs share one answer; refreshed on demand by the button each tab carries,
|
|
77
|
+
* for the case where a provider registered a route late.
|
|
78
|
+
*/
|
|
79
|
+
function loadMap(store: Store, redraw: () => void): void {
|
|
80
|
+
if (_map || _loading) return;
|
|
81
|
+
_loading = true;
|
|
82
|
+
void fetch(`${store.base}/api/map`)
|
|
83
|
+
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))))
|
|
84
|
+
.then((data: FrameworkMap) => {
|
|
85
|
+
_map = data;
|
|
86
|
+
_error = null;
|
|
87
|
+
})
|
|
88
|
+
.catch((e: unknown) => {
|
|
89
|
+
_error = e instanceof Error ? e.message : String(e);
|
|
90
|
+
})
|
|
91
|
+
.finally(() => {
|
|
92
|
+
_loading = false;
|
|
93
|
+
redraw();
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ── Rendering ─────────────────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
function table(headers: string[], rows: string[][]): string {
|
|
100
|
+
if (!rows.length) return '<p class="empty">Nothing registered</p>';
|
|
101
|
+
return (
|
|
102
|
+
`<table class="ctbl"><thead><tr>` +
|
|
103
|
+
headers.map((h) => `<th>${esc(h)}</th>`).join("") +
|
|
104
|
+
`</tr></thead><tbody>` +
|
|
105
|
+
rows.map((r) => `<tr>${r.map((c) => `<td>${c}</td>`).join("")}</tr>`).join("") +
|
|
106
|
+
`</tbody></table>`
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The refresh affordance every App tab carries, since the map is cached. */
|
|
111
|
+
function header(title: string, count: number, extra = ""): string {
|
|
112
|
+
return (
|
|
113
|
+
`<div class="fbar">` +
|
|
114
|
+
`<b>${esc(title)}</b><span class="dim">${count}</span>` +
|
|
115
|
+
(extra ? `<span class="dim">${extra}</span>` : "") +
|
|
116
|
+
`<span class="sp" style="flex:1"></span>` +
|
|
117
|
+
`<button class="ibtn" data-map-refresh="1" title="Re-read the registries">↻</button>` +
|
|
118
|
+
`</div>`
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Build one App tab.
|
|
124
|
+
*
|
|
125
|
+
* All six share the fetch, the loading state, and the refresh button; only the
|
|
126
|
+
* body differs. Written as a factory rather than six near-identical files
|
|
127
|
+
* because the difference between them really is one function.
|
|
128
|
+
*/
|
|
129
|
+
function appTab(
|
|
130
|
+
id: string,
|
|
131
|
+
label: string,
|
|
132
|
+
body: (map: FrameworkMap, ctx: TabContext) => string,
|
|
133
|
+
count: (map: FrameworkMap) => number,
|
|
134
|
+
): TabView {
|
|
135
|
+
return {
|
|
136
|
+
id: `app:${id}`,
|
|
137
|
+
scope: "session",
|
|
138
|
+
label,
|
|
139
|
+
standsAlone: true,
|
|
140
|
+
volatile: true,
|
|
141
|
+
render(host, ctx) {
|
|
142
|
+
if (_error) {
|
|
143
|
+
host.innerHTML =
|
|
144
|
+
header(label, 0) + `<p class="empty">Could not read the map — ${esc(_error)}</p>`;
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (!_map) {
|
|
148
|
+
loadMap(ctx.store, () => ctx.store.changed());
|
|
149
|
+
host.innerHTML = '<p class="empty">Reading the application…</p>';
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
host.innerHTML = header(label, count(_map)) + body(_map, ctx);
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Flatten the config tree to dotted paths, which is how you look a value up. */
|
|
158
|
+
function flattenConfig(value: unknown, prefix = "", out: Array<[string, unknown]> = []) {
|
|
159
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
160
|
+
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
|
161
|
+
flattenConfig(v, prefix ? `${prefix}.${k}` : k, out);
|
|
162
|
+
}
|
|
163
|
+
return out;
|
|
164
|
+
}
|
|
165
|
+
out.push([prefix, value]);
|
|
166
|
+
return out;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export const routesTab = appTab(
|
|
170
|
+
"routes",
|
|
171
|
+
"Routes",
|
|
172
|
+
(map) =>
|
|
173
|
+
table(
|
|
174
|
+
["", "path", "name", "handler", "middleware"],
|
|
175
|
+
map.routes.map((r) => [
|
|
176
|
+
`<span class="meth ${r.method.toLowerCase()}">${esc(r.method)}</span>`,
|
|
177
|
+
// Click to navigate: a route list you cannot go to is a list you copy
|
|
178
|
+
// out of. Only GETs — nothing here should POST on a click.
|
|
179
|
+
r.method === "GET"
|
|
180
|
+
? `<a class="link" href="${esc(r.path)}" target="_blank">${esc(r.path)}</a>`
|
|
181
|
+
: esc(r.path),
|
|
182
|
+
r.name ? `<span class="chip">${esc(r.name)}</span>` : '<span class="dim">—</span>',
|
|
183
|
+
esc(r.handler),
|
|
184
|
+
`<span class="dim">${esc(r.middleware || "—")}</span>`,
|
|
185
|
+
]),
|
|
186
|
+
),
|
|
187
|
+
(map) => map.routes.length,
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
export const configTab = appTab(
|
|
191
|
+
"config",
|
|
192
|
+
"Config",
|
|
193
|
+
(map) =>
|
|
194
|
+
table(
|
|
195
|
+
["path", "value"],
|
|
196
|
+
flattenConfig(map.config).map(([path, value]) => [
|
|
197
|
+
`<b>${esc(path)}</b>`,
|
|
198
|
+
`<span class="dim">${esc(
|
|
199
|
+
value === null || value === undefined
|
|
200
|
+
? String(value)
|
|
201
|
+
: typeof value === "object"
|
|
202
|
+
? JSON.stringify(value)
|
|
203
|
+
: String(value),
|
|
204
|
+
)}</span>`,
|
|
205
|
+
]),
|
|
206
|
+
) +
|
|
207
|
+
`<p class="dim" style="font-size:10px;padding:6px 12px">` +
|
|
208
|
+
`Anything whose key reads as a secret is masked by the same rule the Queries tab uses.</p>`,
|
|
209
|
+
(map) => flattenConfig(map.config).length,
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
export const containerTab = appTab(
|
|
213
|
+
"container",
|
|
214
|
+
"Container",
|
|
215
|
+
(map) =>
|
|
216
|
+
table(
|
|
217
|
+
["token", "kind", "bound by"],
|
|
218
|
+
map.bindings.map((b) => [
|
|
219
|
+
`<b>${esc(b.token)}</b>`,
|
|
220
|
+
`<span class="chip">${esc(b.kind)}</span>`,
|
|
221
|
+
`<span class="dim">${esc(b.provider)}</span>`,
|
|
222
|
+
]),
|
|
223
|
+
),
|
|
224
|
+
(map) => map.bindings.length,
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
export const providersTab = appTab(
|
|
228
|
+
"providers",
|
|
229
|
+
"Providers",
|
|
230
|
+
(map) =>
|
|
231
|
+
table(
|
|
232
|
+
["#", "provider", "boot", "bindings"],
|
|
233
|
+
map.providers.map((p, i) => [
|
|
234
|
+
`<span class="dim">${i + 1}</span>`,
|
|
235
|
+
`<b>${esc(p.name)}</b>`,
|
|
236
|
+
`<span class="${p.durationMs > 50 ? "yellow" : "dim"}">${fmt(p.durationMs)}</span>`,
|
|
237
|
+
`<span class="dim">${p.bindings || "—"}</span>`,
|
|
238
|
+
]),
|
|
239
|
+
) +
|
|
240
|
+
`<p class="dim" style="font-size:10px;padding:6px 12px">` +
|
|
241
|
+
`In boot order — which decides who wins a contested binding. ` +
|
|
242
|
+
(map.bootMs === null ? "" : `Total boot ${fmt(Math.round(map.bootMs))}. `) +
|
|
243
|
+
`The <code>onBooted</code> phase runs concurrently, so these overlap rather than summing.</p>`,
|
|
244
|
+
(map) => map.providers.length,
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
export const eventsTab = appTab(
|
|
248
|
+
"events",
|
|
249
|
+
"Events",
|
|
250
|
+
(map) =>
|
|
251
|
+
table(
|
|
252
|
+
["event", "reacts", "bus"],
|
|
253
|
+
map.events.map((e) => [
|
|
254
|
+
`<b>${esc(e.event)}</b>`,
|
|
255
|
+
esc(e.listeners),
|
|
256
|
+
`<span class="dim">${esc(e.source)}</span>`,
|
|
257
|
+
]),
|
|
258
|
+
),
|
|
259
|
+
(map) => map.events.length,
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
export const activityTab = appTab(
|
|
263
|
+
"activity",
|
|
264
|
+
"Commands",
|
|
265
|
+
(map) =>
|
|
266
|
+
map.activity.length
|
|
267
|
+
? table(
|
|
268
|
+
["", "name", "outcome", "took", "when"],
|
|
269
|
+
map.activity.map((a) => [
|
|
270
|
+
`<span class="chip">${esc(a.kind)}</span>`,
|
|
271
|
+
`<b>${esc(a.name)}</b>` +
|
|
272
|
+
(a.detail ? `<div class="dim" style="font-size:10px">${esc(a.detail)}</div>` : ""),
|
|
273
|
+
`<span class="${a.failed ? "red" : "dim"}">${esc(a.outcome)}</span>`,
|
|
274
|
+
`<span class="dim">${a.durationMs ? fmt(a.durationMs) : "—"}</span>`,
|
|
275
|
+
`<span class="dim">${esc(new Date(a.at).toLocaleTimeString())}</span>`,
|
|
276
|
+
]),
|
|
277
|
+
)
|
|
278
|
+
: // A scheduled task that fails at 03:00 used to leave no trace anywhere;
|
|
279
|
+
// an empty feed here means nothing has run yet, not that nothing is
|
|
280
|
+
// recorded.
|
|
281
|
+
'<p class="empty">No commands or scheduled tasks have run in this process yet</p>',
|
|
282
|
+
(map) => map.activity.length,
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
/** The App section's tabs, in strip order. */
|
|
286
|
+
export const APP_TABS: TabView[] = [
|
|
287
|
+
routesTab,
|
|
288
|
+
configTab,
|
|
289
|
+
containerTab,
|
|
290
|
+
providersTab,
|
|
291
|
+
eventsTab,
|
|
292
|
+
activityTab,
|
|
293
|
+
];
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/** Cache operations performed during the request. */
|
|
2
|
+
import type { CacheEntry } from "../../RequestTrace.ts";
|
|
3
|
+
import { esc, fmt } from "../ui/format.ts";
|
|
4
|
+
import type { TabView } from "./types.ts";
|
|
5
|
+
|
|
6
|
+
function opClass(op: CacheEntry["op"]): string {
|
|
7
|
+
if (op === "hit") return "green";
|
|
8
|
+
if (op === "miss") return "yellow";
|
|
9
|
+
if (op === "write") return "cyan";
|
|
10
|
+
return "dim";
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const cacheTab: TabView = {
|
|
14
|
+
id: "cache",
|
|
15
|
+
label: "Cache",
|
|
16
|
+
scope: "request",
|
|
17
|
+
|
|
18
|
+
badge: ({ trace }) => (trace ? { count: trace.cache?.length ?? 0 } : undefined),
|
|
19
|
+
|
|
20
|
+
render(host, { trace }) {
|
|
21
|
+
const cache = trace!.cache ?? [];
|
|
22
|
+
if (!cache.length) {
|
|
23
|
+
host.innerHTML = '<p class="empty">No cache operations during this request</p>';
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
const hits = cache.filter((c) => c.op === "hit").length;
|
|
27
|
+
const misses = cache.filter((c) => c.op === "miss").length;
|
|
28
|
+
|
|
29
|
+
host.innerHTML =
|
|
30
|
+
`<div class="stats">` +
|
|
31
|
+
`<div class="stat"><div class="slbl">Operations</div><div class="sval">${cache.length}</div></div>` +
|
|
32
|
+
`<div class="stat"><div class="slbl">Hits</div><div class="sval green">${hits}</div></div>` +
|
|
33
|
+
`<div class="stat"><div class="slbl">Misses</div><div class="sval yellow">${misses}</div></div>` +
|
|
34
|
+
`</div><div>` +
|
|
35
|
+
cache
|
|
36
|
+
.map(
|
|
37
|
+
(c) =>
|
|
38
|
+
`<div class="qrow">` +
|
|
39
|
+
`<div class="qmeta">` +
|
|
40
|
+
`<span class="qdur ${opClass(c.op)}">${c.op.toUpperCase()}</span>` +
|
|
41
|
+
`<span class="dim" style="flex:1;font-size:11px">${esc(c.key)}</span>` +
|
|
42
|
+
`<span class="dim" style="font-size:10px">${fmt(c.durationMs)}</span>` +
|
|
43
|
+
`</div>` +
|
|
44
|
+
`<div class="dim" style="font-size:10px">${c.ttl != null ? `TTL: ${c.ttl}s · ` : ""}+${c.offsetMs}ms</div>` +
|
|
45
|
+
`</div>`,
|
|
46
|
+
)
|
|
47
|
+
.join("") +
|
|
48
|
+
`</div>`;
|
|
49
|
+
},
|
|
50
|
+
};
|