@zerotal/devtools 1.7.0 → 1.7.3

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 CHANGED
@@ -6,6 +6,103 @@ follows the Zerotal monorepo's unified versioning.
6
6
 
7
7
  **Maturity: `stable`**
8
8
 
9
+ ## [Unreleased]
10
+
11
+ ## [1.7.1] — 2026-08-16
12
+
13
+ ### Changed
14
+
15
+ - **A request is a place, not twelve tabs.** The Requests strip carried fourteen tabs, twelve
16
+ of which described a single request — Queries, Timeline, Logs, Request, Exception, Mail,
17
+ Cache, Jobs, and every channel. They were empty for most requests, they answered a question
18
+ you can only ask about a request you have already picked, and under about 1100px they
19
+ scrolled off the right edge with nothing to say they had.
20
+
21
+ Every view now declares a `scope`. A request-scoped one is drawn inside whichever request
22
+ you are reading, as that request's own small strip of tabs; a session-scoped one keeps its
23
+ place in the panel's strip, because it keeps reading as you move between requests. Nothing
24
+ is reimplemented — a `TabView` was already "draw this trace into this element", which is
25
+ exactly what a section is.
26
+
27
+ Only the views with something to say appear, so the strip doubles as the summary: a request
28
+ showing Queries, Logs and Exception has told you what happened before you click anything. A
29
+ view that counts nothing is skipped before it renders, and anything that comes back as its
30
+ own empty-state line is dropped after.
31
+
32
+ Requests is now **Live**, **All** and whatever plugins are installed. App is untouched —
33
+ Routes, Config, Container, Providers, Events and Commands describe the app rather than a
34
+ request, which is exactly why they are tabs.
35
+
36
+ - **A Live view, first and default.** The newest request, already open. Opening the panel now
37
+ shows the page you are looking at instead of a heading you navigate away from, and it
38
+ follows: click something and it is showing what just happened. It ignores pinning on
39
+ purpose — pinning is how you hold still and read something older.
40
+
41
+ - **A request states its identity once.** The Request view opened with the method, path and
42
+ status — right for a tab that had to say which request it was describing, pure repetition
43
+ for a section sitting directly under the row that just said it, where it read as a second
44
+ heading competing with the real one.
45
+
46
+ - **Clear sits with the list it clears.** A labelled button beside the filter, rather than
47
+ only the icon at the far end of the status bar.
48
+
49
+ ### Fixed
50
+
51
+ - **Durations printed their whole float.** `fmt()` interpolated the number raw, so anything
52
+ measured with `performance.now()` — every Flow action, in the always-visible status bar —
53
+ read `3.6370999999926426ms`, while anything a caller had already rounded read `0ms` for a
54
+ query that plainly took time. Precision now follows magnitude: `0.42ms`, `3.6ms`, `143ms`,
55
+ `1.4s`. Numeric cells also use tabular figures, which is what made the request list's
56
+ right-aligned column ragged.
57
+
58
+ - **Timeline legend swatches floated away from their labels.** The legend reuses `.tmark` for
59
+ its colour, and `.tmark` is absolutely positioned for the waterfall — so in the legend,
60
+ whose rows are not positioned, all seven squares escaped to the nearest positioned ancestor
61
+ and stacked above their own text.
62
+
63
+ - **The panel covered the bottom of the page.** It is fixed to the viewport and reserved no
64
+ space, so the last strip of any page — 32px collapsed, the panel's full height open — could
65
+ not be scrolled to. The host page now gets matching bottom padding, updated on toggle and
66
+ resize, and `--zt-dt-height` is published on `<html>` for an app that would rather move
67
+ something of its own.
68
+
69
+ - **Muted text failed WCAG AA, and focus was invisible.** `--muted` sat at 2.35:1 on the tab
70
+ strip — below AA for text of any size, and this is 10–11px text. It now measures 4.69:1 on
71
+ the strip and 5.50:1 on the status bar in dark, 5.38:1 in light. `:focus-visible` was
72
+ suppressed panel-wide, leaving keyboard navigation with no indicator at all; focusable
73
+ controls now draw a ring.
74
+
75
+ ### Added
76
+
77
+ - **A contributed panel can read the selected trace.** `DevtoolsPanelPlugin.render` now
78
+ receives a second `context` argument carrying the trace selected in the request list. A
79
+ plugin exists because it owns live browser state, but the same events usually have a
80
+ server half recorded against a trace, and a plugin that could not reach it had to either
81
+ measure again client-side or show half the story in a tab of its own. `@zerotal/flow`'s
82
+ time-travel frames now print what each action cost on the server. The argument is
83
+ optional, so a plugin written against the one-argument form is untouched.
84
+
85
+ - **`hidden` on a channel descriptor.** Records the entries on the trace but gives them no
86
+ tab, for a package that renders the data somewhere better than a generic row list. Flow
87
+ declares its actions this way now that its own panel prints them on the frame they belong
88
+ to.
89
+
90
+ - **`TraceSink.finalise` — a trace for work that never was an HTTP request.** The sink let
91
+ a package buffer against a context but gave it no way to say that context was finished,
92
+ and a trace was only ever built from core's `RequestHandled` / `RequestFailed`. Anything
93
+ running against its own context outside the HTTP lifecycle therefore buffered its
94
+ evidence and dropped it: a Flow action over the WebSocket, and by the same mechanism a
95
+ queue job or a scheduled task.
96
+
97
+ `finalise(ctx, { startMs, durationMs, method })` builds the trace and pushes it. `method`
98
+ labels a synthetic request in the list — `@zerotal/flow` passes `FLOW`, which gets its own
99
+ colour so an action does not read as a second `GET` of the page it ran on. Finalising is
100
+ once per context, whichever claims it first, so a context that finalises itself can never
101
+ push a duplicate carrying none of the evidence.
102
+
103
+ Found by wiring DevTools into this repo's own `apps/docs`: the Flow tab could only ever
104
+ report "No flow activity during this request".
105
+
9
106
  ## [1.7.0] — 2026-08-16
10
107
 
11
108
  ### Added
package/api-surface.md CHANGED
@@ -101,7 +101,7 @@ interface DevtoolsInjectionOptions = {}
101
101
  interface DevtoolsPanelPlugin = {
102
102
  badge?: () => number | string | undefined
103
103
  id: string
104
- render: (el: HTMLElement) => void
104
+ render: (el: HTMLElement, context?: DevtoolsPanelContext) => void
105
105
  title: string
106
106
  }
107
107
 
@@ -200,6 +200,7 @@ interface TraceChannelDescriptor = {
200
200
  badge?: string
201
201
  flags?: string[]
202
202
  groupBy?: string
203
+ hidden?: boolean
203
204
  id: string
204
205
  label: string
205
206
  meta?: string[]
@@ -224,6 +225,7 @@ interface TraceSink = {
224
225
  bufferQuery: (ctx: object, q: QuerySpan) => void
225
226
  bufferWarning: (ctx: object, w: NPlusOneWarning) => void
226
227
  channel: (descriptor: TraceChannelDescriptor) => void
228
+ finalise: (ctx: object, meta: { startMs: number; durationMs: number; method?: string;}) => void
227
229
  record: (ctx: object, channel: string, entry: Record<string, unknown>) => void
228
230
  }
229
231
 
@@ -270,7 +272,7 @@ interface DevtoolsClientOptions = {
270
272
  interface DevtoolsPanelPlugin = {
271
273
  badge?: () => number | string | undefined
272
274
  id: string
273
- render: (el: HTMLElement) => void
275
+ render: (el: HTMLElement, context?: DevtoolsPanelContext) => void
274
276
  title: string
275
277
  }
276
278
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zerotal/devtools",
3
- "version": "1.7.0",
3
+ "version": "1.7.3",
4
4
  "license": "MIT",
5
5
  "maturity": "stable",
6
6
  "private": false,
@@ -31,11 +31,11 @@
31
31
  "typecheck": "tsc --noEmit"
32
32
  },
33
33
  "dependencies": {
34
- "@zerotal/core": "1.7.0"
34
+ "@zerotal/core": "1.7.3"
35
35
  },
36
36
  "devDependencies": {
37
37
  "typescript": "^5.8.0",
38
- "@zerotal/orm": "1.7.0"
38
+ "@zerotal/orm": "1.7.3"
39
39
  },
40
40
  "description": "In-browser developer tools for Zerotal — request traces, an inspector panel, and an extensible tab registry.",
41
41
  "keywords": [
@@ -141,6 +141,16 @@ export interface TraceChannelDescriptor {
141
141
  warn?: string;
142
142
  /** Sort order among channel tabs. Lower sorts first. Defaults to 100. */
143
143
  order?: number;
144
+ /**
145
+ * Record the entries on the trace but give them no tab of their own.
146
+ *
147
+ * For a package that renders this data somewhere better than a generic row
148
+ * list — its own plugin panel, say — while still wanting it on the persisted
149
+ * trace. Flow declares its actions this way: the numbers belong on the trace,
150
+ * but they read as one line on a time-travel frame rather than as a tab
151
+ * repeating what the trace header already says.
152
+ */
153
+ hidden?: boolean;
144
154
 
145
155
  // ── Presentation hints ──────────────────────────────────────────────────────
146
156
  //
@@ -21,6 +21,7 @@ import { collectClientMetrics, onceLoaded } from "./metrics.ts";
21
21
  import { connect } from "./transport.ts";
22
22
  import { mountShell } from "./ui/shell.ts";
23
23
  import { allTab } from "./tabs/all.ts";
24
+ import { liveTab } from "./tabs/live.ts";
24
25
  import { cacheTab } from "./tabs/cache.ts";
25
26
  import { exceptionsTab } from "./tabs/exceptions.ts";
26
27
  import { jobsTab } from "./tabs/jobs.ts";
@@ -47,22 +48,26 @@ export interface DevtoolsClientOptions {
47
48
  }
48
49
 
49
50
  /**
50
- * The built-in tabs, in strip order.
51
+ * The built-in views, in strip order.
51
52
  *
52
- * The order is also the `1`–`9` keyboard order, so it is worth being deliberate
53
- * about: queries first because it is where a request explains itself, all last
54
- * because it is where you go to leave the request you are on.
53
+ * Only two of these are tabs. The rest are `scope: "request"` sections of
54
+ * whichever request you are reading, in this order, rather than headings in a
55
+ * strip that are empty until you have picked something. Request leads because it
56
+ * says what the thing *was*; the exception comes next because if there is one it
57
+ * is why you opened the panel; then the work it did, and the waterfall last,
58
+ * being the summary of everything above it.
55
59
  */
56
60
  const BUILT_IN = [
57
- queriesTab,
58
- timelineTab,
59
- logsTab,
61
+ liveTab,
62
+ allTab,
60
63
  requestTab,
61
64
  exceptionsTab,
65
+ queriesTab,
66
+ logsTab,
62
67
  mailTab,
63
68
  cacheTab,
64
69
  jobsTab,
65
- allTab,
70
+ timelineTab,
66
71
  ];
67
72
 
68
73
  export const DevTools = {
@@ -12,6 +12,23 @@
12
12
  * has a shipped consumer, so it survived the client rewrite unchanged.
13
13
  */
14
14
 
15
+ import type { RequestTrace } from "../RequestTrace.ts";
16
+
17
+ /**
18
+ * What the panel knows when it asks a plugin to draw.
19
+ *
20
+ * A plugin owns live browser state, which is why it renders itself rather than
21
+ * declaring a channel. But the same events usually have a server half recorded
22
+ * against a trace, and a plugin that cannot reach it has to either duplicate the
23
+ * measurement client-side or show half the story in a tab of its own. Flow's
24
+ * time-travel frames and its server actions are the case in point: the same
25
+ * clicks, once from each end.
26
+ */
27
+ export interface DevtoolsPanelContext {
28
+ /** The trace selected in the request list, or `null` when none is. */
29
+ trace: RequestTrace | null;
30
+ }
31
+
15
32
  /** A panel another package contributes as a tab in the Zerotal devtools. */
16
33
  export interface DevtoolsPanelPlugin {
17
34
  /** Unique id — the tab is addressed internally as `plugin:<id>`. */
@@ -20,8 +37,13 @@ export interface DevtoolsPanelPlugin {
20
37
  title: string;
21
38
  /** Optional badge value (e.g. a count); a falsy return hides the badge. */
22
39
  badge?: () => number | string | undefined;
23
- /** Render the panel's content into `el` (the shared, persistent content area). */
24
- render: (el: HTMLElement) => void;
40
+ /**
41
+ * Render the panel's content into `el` (the shared, persistent content area).
42
+ *
43
+ * `context` is optional so a plugin written against the one-argument form keeps
44
+ * working untouched — it simply ignores an argument it never declared.
45
+ */
46
+ render: (el: HTMLElement, context?: DevtoolsPanelContext) => void;
25
47
  }
26
48
 
27
49
  export interface DevtoolsRegistry {
@@ -38,6 +38,8 @@ export interface PersistedUi {
38
38
  section: Section;
39
39
  tab: string;
40
40
  appTab: string;
41
+ /** The view showing inside an open request. */
42
+ sectionTab: string;
41
43
  filter: string;
42
44
  facets: Facets;
43
45
  height: number;
@@ -85,6 +87,17 @@ export class Store {
85
87
  traces: RequestTrace[] = [];
86
88
  channels: TraceChannelDescriptor[] = [];
87
89
  selected: RequestTrace | null = null;
90
+ /** The request whose detail is open in the list, by trace id. */
91
+ openTraceId: string | null = null;
92
+ /**
93
+ * Which view is showing inside the request you are reading.
94
+ *
95
+ * Kept across requests on purpose: someone comparing the queries of one
96
+ * request against the next wants the queries again, not to be returned to the
97
+ * top every time. Falls back to the first available when a request has nothing
98
+ * to show under it.
99
+ */
100
+ sectionTab = "";
88
101
  connected = false;
89
102
  /**
90
103
  * How many traces to keep. Replaced by the server's real capacity when the
@@ -145,8 +158,13 @@ export class Store {
145
158
  const saved = loadUi();
146
159
  this.open = standalone || saved.open === true;
147
160
  this.section = saved.section === "app" ? "app" : "requests";
148
- this.tab = saved.tab ?? "queries";
161
+ // Live by default: the panel opens on the request you are looking at rather
162
+ // than on a heading you then have to navigate away from. `queries` is no
163
+ // longer a tab at all — a persisted one from before this change falls back to
164
+ // the first, which is Live.
165
+ this.tab = saved.tab === "queries" ? "live" : (saved.tab ?? "live");
149
166
  this.appTab = saved.appTab ?? "app:routes";
167
+ this.sectionTab = saved.sectionTab ?? "";
150
168
  this.filter = saved.filter ?? "";
151
169
  this.facets = { ...noFacets(), ...(saved.facets ?? {}) };
152
170
  this.height = Math.max(MIN_HEIGHT, saved.height ?? DEFAULT_HEIGHT);
@@ -171,6 +189,7 @@ export class Store {
171
189
  section: this.section,
172
190
  appTab: this.appTab,
173
191
  tab: this.tab,
192
+ sectionTab: this.sectionTab,
174
193
  filter: this.filter,
175
194
  facets: this.facets,
176
195
  height: this.height,
@@ -202,6 +221,14 @@ export class Store {
202
221
  return this.section === "app" ? this.appTab : this.tab;
203
222
  }
204
223
 
224
+ /** Pick the view showing inside the open request. */
225
+ setSectionTab(id: string): void {
226
+ if (this.sectionTab === id) return;
227
+ this.sectionTab = id;
228
+ this.persist();
229
+ this.changed();
230
+ }
231
+
205
232
  setTab(tab: string): void {
206
233
  const key = this.section === "app" ? "appTab" : "tab";
207
234
  if (this[key] === tab) return;
@@ -248,15 +275,27 @@ export class Store {
248
275
  }
249
276
 
250
277
  /** Pin a trace and stop following the newest. */
251
- select(trace: RequestTrace | null, { switchTab = false } = {}): void {
278
+ select(trace: RequestTrace | null): void {
252
279
  this.selected = trace;
253
280
  this.live = false;
254
281
  this.pending = 0;
255
- if (switchTab && this.tab === "all") this.tab = "queries";
256
282
  this.persist();
257
283
  this.changed();
258
284
  }
259
285
 
286
+ /**
287
+ * Open a request's detail in the list, or close it if it is already open.
288
+ *
289
+ * Opening pins as well, because the detail and the status bar have to agree
290
+ * about which request you are reading. One at a time: the detail is tall, and
291
+ * two open at once is a list you cannot scan.
292
+ */
293
+ toggleOpen(trace: RequestTrace | null): void {
294
+ if (!trace) return;
295
+ this.openTraceId = this.openTraceId === trace.id ? null : trace.id;
296
+ this.select(trace);
297
+ }
298
+
260
299
  /** Follow the newest request again, clearing the backlog offer. */
261
300
  follow(): void {
262
301
  this.live = true;
@@ -11,6 +11,8 @@ import { foldTraceRows, type TraceRow } from "../tree.ts";
11
11
  import { dCls, esc, fmt, scCls } from "../ui/format.ts";
12
12
  import { el, reconcile } from "../ui/render.ts";
13
13
  import type { TabContext, TabView } from "./types.ts";
14
+ import type { RequestTrace } from "../../RequestTrace.ts";
15
+ import { renderSections } from "./sections.ts";
14
16
 
15
17
  /**
16
18
  * Row height in pixels, which the stylesheet pins.
@@ -47,6 +49,10 @@ function skeleton(): string {
47
49
  `<input id="filter" class="finput" type="search" ` +
48
50
  `placeholder="Filter by path, method, status, or controller…">` +
49
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>` +
50
56
  `</div>` +
51
57
  `<div class="facets" id="facets"></div>` +
52
58
  `<div id="rowswrap">` +
@@ -117,7 +123,14 @@ function rowKey(r: TraceRow): string {
117
123
  return `${r.trace.id}:${r.child ? "c" : "h"}`;
118
124
  }
119
125
 
120
- function rowHtml(r: TraceRow, selectedId: string | undefined): string {
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 {
121
134
  const t = r.trace;
122
135
  const toggle = r.groupKey
123
136
  ? `<button class="gtog" data-group="${esc(r.groupKey)}" title="Requests in this batch">` +
@@ -126,6 +139,7 @@ function rowHtml(r: TraceRow, selectedId: string | undefined): string {
126
139
  return (
127
140
  `<div class="hrow${t.id === selectedId ? " cur" : ""}${t.exception ? " err" : ""}` +
128
141
  `${r.child ? " child" : ""}" data-idx="${r.index}">` +
142
+ `<span class="hchev">${t.id === openId ? "▾" : "▸"}</span>` +
129
143
  `<span class="meth ${t.method.toLowerCase()}">${esc(t.method)}</span>` +
130
144
  `<span class="hpath">${esc(t.path)}</span>` +
131
145
  // The message, not just the status: scanning a list for the request that
@@ -192,24 +206,46 @@ function draw(host: HTMLElement, ctx: TabContext): void {
192
206
  // which is what scrolls. The rows start below the sticky filter bar and the
193
207
  // facet strip, so the window is offset by the wrapper's position rather than
194
208
  // measured from zero.
195
- const { first, count: take } = windowRange(
196
- rows.length,
197
- host.scrollTop,
198
- host.clientHeight || 0,
199
- wrap.offsetTop,
200
- );
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);
201
219
  const slice = rows.slice(first, first + take);
202
220
 
203
221
  padTop.style.height = `${first * ROW_H}px`;
204
222
  padBot.style.height = `${Math.max(0, rows.length - first - take) * ROW_H}px`;
205
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
+
206
235
  const selectedId = store.selected?.id;
207
236
  reconcile(
208
237
  rowsHost,
209
- slice,
210
- rowKey,
211
- (r) => {
212
- const html = rowHtml(r, selectedId);
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);
213
249
  const node = el(html);
214
250
  // Stamped on creation as well, so the very next update compares equal and
215
251
  // a row that has not changed is never rewritten.
@@ -220,13 +256,23 @@ function draw(host: HTMLElement, ctx: TabContext): void {
220
256
  // and the reconciler's job — keeping the *node* so scroll position and text
221
257
  // selection survive — is already done by the time this runs. The markup is
222
258
  // compared first, so a steady list generates no DOM writes at all.
223
- (node, r) => {
224
- const next = rowHtml(r, selectedId);
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);
225
271
  if (node.getAttribute("data-html") === next) return;
226
272
  const fresh = el(next);
227
273
  node.className = fresh.className;
228
274
  node.replaceChildren(...Array.from(fresh.childNodes));
229
- node.setAttribute("data-idx", String(r.index));
275
+ node.setAttribute("data-idx", String(item.row.index));
230
276
  node.setAttribute("data-html", next);
231
277
  },
232
278
  );
@@ -235,6 +281,7 @@ function draw(host: HTMLElement, ctx: TabContext): void {
235
281
  export const allTab: TabView = {
236
282
  id: "all",
237
283
  label: "All",
284
+ scope: "session",
238
285
  live: true,
239
286
  volatile: true,
240
287
  standsAlone: true,
@@ -134,6 +134,7 @@ function appTab(
134
134
  ): TabView {
135
135
  return {
136
136
  id: `app:${id}`,
137
+ scope: "session",
137
138
  label,
138
139
  standsAlone: true,
139
140
  volatile: true,
@@ -13,6 +13,7 @@ function opClass(op: CacheEntry["op"]): string {
13
13
  export const cacheTab: TabView = {
14
14
  id: "cache",
15
15
  label: "Cache",
16
+ scope: "request",
16
17
 
17
18
  badge: ({ trace }) => (trace ? { count: trace.cache?.length ?? 0 } : undefined),
18
19
 
@@ -10,7 +10,7 @@
10
10
  */
11
11
  import type { TraceChannelDescriptor, TraceChannelEntry } from "../../RequestTrace.ts";
12
12
  import { buildPathTree, type PathTreeNode } from "../tree.ts";
13
- import { chipFor, copyBtn, esc, fmtCell } from "../ui/format.ts";
13
+ import { chipFor, copyBtn, esc, fmt, fmtCell } from "../ui/format.ts";
14
14
  import type { TabView } from "./types.ts";
15
15
 
16
16
  /** `key: value` spans for every field the descriptor named as meta. */
@@ -51,7 +51,7 @@ function asRows(rows: TraceChannelEntry[], c: TraceChannelDescriptor): string {
51
51
  `<div class="chead">` +
52
52
  (badge ? chipFor(badge, isWarn) : "") +
53
53
  flagChips(r, c.flags) +
54
- `<span class="dim" style="font-size:10px">+${r.offsetMs}ms</span>` +
54
+ `<span class="dim" style="font-size:10px">+${fmt(r.offsetMs)}</span>` +
55
55
  copyBtn(JSON.stringify(r, null, 2), "Copy entry") +
56
56
  `</div>` +
57
57
  (title && title !== badge ? `<div class="cttl">${esc(title)}</div>` : "") +
@@ -90,7 +90,7 @@ function asTable(rows: TraceChannelEntry[], c: TraceChannelDescriptor): string {
90
90
  `</td>`,
91
91
  )
92
92
  .join("") +
93
- `<td class="dim">+${r.offsetMs}ms</td></tr>`
93
+ `<td class="dim">+${fmt(r.offsetMs)}</td></tr>`
94
94
  );
95
95
  })
96
96
  .join("") +
@@ -110,7 +110,7 @@ function asKv(rows: TraceChannelEntry[], c: TraceChannelDescriptor): string {
110
110
  .join("");
111
111
  return (
112
112
  `<div class="sec"><div class="stitle">` +
113
- `${esc(heading || "Entry")} · +${r.offsetMs}ms` +
113
+ `${esc(heading || "Entry")} · +${fmt(r.offsetMs)}` +
114
114
  copyBtn(JSON.stringify(r, null, 2), "Copy entry") +
115
115
  `</div><table class="kv">${cells}</table></div>`
116
116
  );
@@ -230,6 +230,7 @@ export function channelTab(c: TraceChannelDescriptor): TabView {
230
230
  return {
231
231
  id: `channel:${c.id}`,
232
232
  label: c.label,
233
+ scope: "request",
233
234
 
234
235
  badge: ({ trace }) => {
235
236
  const rows = trace?.channels?.[c.id] ?? [];
@@ -28,6 +28,7 @@ function isVendorFrame(file: string): boolean {
28
28
  export const exceptionsTab: TabView = {
29
29
  id: "exceptions",
30
30
  label: "Exception",
31
+ scope: "request",
31
32
 
32
33
  badge: ({ trace }) => (trace?.exception ? { count: "!", warn: true } : undefined),
33
34
 
@@ -10,6 +10,7 @@ function icon(status: JobEntry["status"]): string {
10
10
  export const jobsTab: TabView = {
11
11
  id: "jobs",
12
12
  label: "Jobs",
13
+ scope: "request",
13
14
 
14
15
  badge: ({ trace }) =>
15
16
  trace
@@ -0,0 +1,66 @@
1
+ /**
2
+ * The request happening now, opened for you.
3
+ *
4
+ * What the panel is for, most of the time, is the page you are looking at — and
5
+ * every other view made you go and find it first: open the panel, find the list,
6
+ * pick the top row. This is that request already open, and it follows: load a
7
+ * page, click something, and this is showing what just happened without a click
8
+ * of its own.
9
+ *
10
+ * It renders the same sections a request opens into in the list, so there is one
11
+ * description of what a request looks like rather than two that drift.
12
+ */
13
+ import { dCls, esc, fmt, scCls } from "../ui/format.ts";
14
+ import { renderSections } from "./sections.ts";
15
+ import type { TabView } from "./types.ts";
16
+
17
+ /**
18
+ * The newest request, rather than the pinned one.
19
+ *
20
+ * Pinning is how you hold still and read something older; this view is the
21
+ * opposite gesture, so it deliberately ignores it. The two disagreeing is the
22
+ * point — the status bar tells you what you pinned, this tells you what the page
23
+ * just did.
24
+ */
25
+ export const liveTab: TabView = {
26
+ id: "live",
27
+ label: "Live",
28
+ scope: "session",
29
+ live: true,
30
+ volatile: true,
31
+ standsAlone: true,
32
+
33
+ badge({ store }) {
34
+ const t = store.traces[0];
35
+ if (!t) return undefined;
36
+ return t.exception ? { count: "!", warn: true } : undefined;
37
+ },
38
+
39
+ render(host, ctx) {
40
+ const trace = ctx.store.traces[0];
41
+ if (!trace) {
42
+ host.innerHTML =
43
+ '<p class="empty">Nothing yet — load a page or click something to see it here</p>';
44
+ return;
45
+ }
46
+
47
+ host.replaceChildren();
48
+
49
+ // Which request this is, since the sections below describe it but none of
50
+ // them names it.
51
+ const head = document.createElement("div");
52
+ head.className = "lvhead";
53
+ head.innerHTML =
54
+ `<span class="meth ${esc(trace.method.toLowerCase())}">${esc(trace.method)}</span>` +
55
+ `<span class="hpath">${esc(trace.path)}</span>` +
56
+ `<span class="sc ${scCls(trace.statusCode)}">${trace.statusCode || "—"}</span>` +
57
+ `<span class="${dCls(trace.durationMs) || "dim"}">${fmt(trace.durationMs)}</span>` +
58
+ `<span class="dim">${trace.queries.length}q</span>` +
59
+ (trace.warnings.length ? '<span class="chip warn">N+1</span>' : "");
60
+ host.appendChild(head);
61
+
62
+ const body = document.createElement("div");
63
+ renderSections(body, trace, ctx);
64
+ host.appendChild(body);
65
+ },
66
+ };
@@ -5,6 +5,7 @@ import type { TabView } from "./types.ts";
5
5
  export const logsTab: TabView = {
6
6
  id: "logs",
7
7
  label: "Logs",
8
+ scope: "request",
8
9
 
9
10
  badge: ({ trace }) => {
10
11
  const logs = trace?.logs ?? [];
@@ -29,6 +29,7 @@ function preview(m: MailEntry): string {
29
29
  export const mailTab: TabView = {
30
30
  id: "mail",
31
31
  label: "Mail",
32
+ scope: "request",
32
33
 
33
34
  badge: ({ trace }) => (trace ? { count: trace.mail?.length ?? 0 } : undefined),
34
35
 
@@ -59,6 +59,7 @@ function queryRow(q: QuerySpan, peak: number, editor: EditorSettings): string {
59
59
  export const queriesTab: TabView = {
60
60
  id: "queries",
61
61
  label: "Queries",
62
+ scope: "request",
62
63
 
63
64
  badge: ({ trace }) =>
64
65
  trace
@@ -11,7 +11,7 @@
11
11
  * survive the redirect, is the user id set" are all answered by the keys, and the
12
12
  * values are the request's real state.
13
13
  */
14
- import { copyBtn, esc, scCls } from "../ui/format.ts";
14
+ import { copyBtn, esc } from "../ui/format.ts";
15
15
  import type { TabView } from "./types.ts";
16
16
 
17
17
  function kvTable(pairs: Record<string, string>): string {
@@ -39,6 +39,7 @@ function section(title: string, pairs: Record<string, string>): string {
39
39
  export const requestTab: TabView = {
40
40
  id: "request",
41
41
  label: "Request",
42
+ scope: "request",
42
43
 
43
44
  render(host, { trace }) {
44
45
  const t = trace!;
@@ -47,12 +48,11 @@ export const requestTab: TabView = {
47
48
  const responseHeaders = t.responseHeaders ?? {};
48
49
  const session = t.session ?? [];
49
50
 
50
- const statusLine =
51
- `<div class="rcard">` +
52
- `<span class="meth ${t.method.toLowerCase()}">${esc(t.method)}</span> ` +
53
- `<b>${esc(t.path)}</b> ` +
54
- `<span class="sc ${scCls(t.statusCode)}">${t.statusCode || "—"}</span>` +
55
- `</div>`;
51
+ // No status line here. This view was once a tab of its own and had to say
52
+ // which request it was describing; it is now a section of that request, and
53
+ // whatever opened it — the row in the list, the header in Live — has already
54
+ // said the method, the path and the status directly above. Repeating them
55
+ // read as a second heading competing with the real one.
56
56
 
57
57
  const sessionKeys = session.length
58
58
  ? `<div class="chips">` +
@@ -62,7 +62,6 @@ export const requestTab: TabView = {
62
62
  `No session on this request — or no session middleware installed</p>`;
63
63
 
64
64
  host.innerHTML =
65
- statusLine +
66
65
  section("Query Params", params) +
67
66
  section("Request Headers", headers) +
68
67
  section("Response Headers", responseHeaders) +
@@ -0,0 +1,115 @@
1
+ /**
2
+ * A request, drawn as its own small set of tabs.
3
+ *
4
+ * Twelve request-scoped tabs in the panel's main strip is twelve headings that
5
+ * are empty for most requests and answer a question you can only ask about a
6
+ * request you have already picked. They belong to the request, so they are drawn
7
+ * inside it — by calling the views themselves, since a `TabView` is already
8
+ * "draw this trace into this element", which is exactly what a section is.
9
+ * Nothing here reimplements a view.
10
+ *
11
+ * Tabs rather than a stack, because the sections are alternatives: you are
12
+ * reading the queries *or* the headers *or* the waterfall, and stacking them
13
+ * makes you scroll past two to reach the third. Only the ones with something to
14
+ * say appear, so the strip is also the summary — a request with a Queries, a
15
+ * Logs and an Exception tab has already told you what happened before you click
16
+ * anything.
17
+ *
18
+ * Shared by the request list, where a row opens into its own detail, and by the
19
+ * Live view, which shows the newest request without your having to open anything.
20
+ */
21
+ import type { RequestTrace } from "../../RequestTrace.ts";
22
+ import { esc } from "../ui/format.ts";
23
+ import type { TabContext, TabView } from "./types.ts";
24
+
25
+ /** Whether a view drew anything beyond its own "nothing here" line. */
26
+ export function isEmptyRender(body: HTMLElement): boolean {
27
+ if (!body.textContent?.trim()) return true;
28
+ const kids = Array.from(body.children);
29
+ return kids.length > 0 && kids.every((k) => k.classList.contains("empty"));
30
+ }
31
+
32
+ interface Drawn {
33
+ view: TabView;
34
+ badge: ReturnType<NonNullable<TabView["badge"]>>;
35
+ body: HTMLElement;
36
+ }
37
+
38
+ /**
39
+ * Render every request-scoped view that has something to say about `trace`.
40
+ *
41
+ * A view is asked for its count first and skipped when it counts nothing — the
42
+ * queries view renders a stats strip even for a request that ran none, and a
43
+ * "Queries 0" heading over it is exactly the empty furniture this replaces. What
44
+ * survives that is rendered and then dropped anyway if what came back is only the
45
+ * view's own empty-state line, so a request that sent no mail has no Mail tab
46
+ * rather than a tab holding the word "none".
47
+ *
48
+ * Every surviving body is kept in the DOM and hidden rather than re-rendered on
49
+ * each switch: they are whole tab renderers, and the flick between two of them
50
+ * should cost nothing.
51
+ */
52
+ export function renderSections(hostEl: HTMLElement, trace: RequestTrace, ctx: TabContext): void {
53
+ hostEl.replaceChildren();
54
+
55
+ const drawn: Drawn[] = [];
56
+ for (const view of ctx.sections ?? []) {
57
+ const badge = view.badge?.({ trace, store: ctx.store });
58
+ if (badge && Number(badge.count) === 0) continue;
59
+
60
+ const body = document.createElement("div");
61
+ body.className = "dsec-body";
62
+ try {
63
+ view.render(body, { trace, store: ctx.store });
64
+ } catch {
65
+ // A view that throws must not take the request it belongs to with it.
66
+ continue;
67
+ }
68
+ if (isEmptyRender(body)) continue;
69
+ drawn.push({ view, badge, body });
70
+ }
71
+
72
+ if (!drawn.length) {
73
+ hostEl.innerHTML = `<p class="empty">Nothing else was recorded for this request</p>`;
74
+ return;
75
+ }
76
+
77
+ const activeId = activeSection(
78
+ ctx.store.sectionTab,
79
+ drawn.map((d) => d.view.id),
80
+ );
81
+
82
+ const strip = document.createElement("div");
83
+ strip.className = "dsecs";
84
+ strip.innerHTML = drawn
85
+ .map(({ view, badge }) => {
86
+ const count = badge
87
+ ? `<span class="dsec-n${badge.warn ? " warn" : ""}">${esc(String(badge.count))}</span>`
88
+ : "";
89
+ return (
90
+ `<button class="dsect${view.id === activeId ? " on" : ""}" ` +
91
+ `data-sec="${esc(view.id)}">${esc(view.label)}${count}</button>`
92
+ );
93
+ })
94
+ .join("");
95
+ hostEl.appendChild(strip);
96
+
97
+ for (const { view, body } of drawn) {
98
+ const pane = document.createElement("div");
99
+ pane.className = "dsec-pane";
100
+ if (view.id !== activeId) pane.style.display = "none";
101
+ pane.appendChild(body);
102
+ hostEl.appendChild(pane);
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Which section to show: the one you were reading, or the first this request has.
108
+ *
109
+ * Falling back rather than clearing the preference — move from a request with
110
+ * queries to one without and you get its first section, but the one after that
111
+ * with queries again puts you back where you were.
112
+ */
113
+ export function activeSection(preferred: string, available: string[]): string {
114
+ return available.includes(preferred) ? preferred : (available[0] ?? "");
115
+ }
@@ -55,6 +55,7 @@ const KEY: Array<[string, string]> = [
55
55
  export const timelineTab: TabView = {
56
56
  id: "timeline",
57
57
  label: "Timeline",
58
+ scope: "request",
58
59
 
59
60
  render(host, { trace, store }) {
60
61
  const t = trace!;
@@ -12,6 +12,11 @@ export interface TabContext {
12
12
  /** The pinned or live trace, or null before any traffic. */
13
13
  trace: RequestTrace | null;
14
14
  store: Store;
15
+ /**
16
+ * The request-scoped views, for the list to render inside whichever request is
17
+ * open. Absent in a test that renders one tab on its own.
18
+ */
19
+ sections?: TabView[];
15
20
  }
16
21
 
17
22
  /** The count beside a tab's label, and whether it should read as a warning. */
@@ -23,6 +28,18 @@ export interface TabBadge {
23
28
  export interface TabView {
24
29
  id: string;
25
30
  label: string;
31
+ /**
32
+ * Whether this describes one request or the session.
33
+ *
34
+ * `"request"` is not a tab at all: it is a section of the request you opened
35
+ * in the list. Twelve of these in the strip is twelve tabs that are empty for
36
+ * most requests and answer a question you can only ask about a request you
37
+ * have already picked — so they are rendered inside its row instead, and only
38
+ * when they have something to say. `"session"` earns a tab, because it keeps
39
+ * reading while you move between requests: the list itself, and a plugin that
40
+ * owns live browser state.
41
+ */
42
+ scope: "request" | "session";
26
43
  /**
27
44
  * Show the live dot while connected. For the tab whose contents change on
28
45
  * their own rather than only when you pick a different request.
@@ -27,9 +27,28 @@ export function esc(s: unknown): string {
27
27
  return String(s ?? "").replace(/[&<>"']/g, (c) => ESCAPES[c]!);
28
28
  }
29
29
 
30
- /** A duration, in the largest unit that stays readable. */
30
+ /** Drop the trailing zeros a fixed-precision number leaves behind: `3.0` → `3`. */
31
+ function _trim(text: string): string {
32
+ return text.includes(".") ? text.replace(/0+$/, "").replace(/\.$/, "") : text;
33
+ }
34
+
35
+ /**
36
+ * A duration, at a precision worth reading.
37
+ *
38
+ * Precision scales with magnitude, because the interesting digits move: at 400ms
39
+ * nobody cares about the decimal, and at 0.4ms the decimal is the whole number.
40
+ * Both ends were wrong before. This interpolated the value raw, so anything
41
+ * measured with `performance.now()` printed its full float — the status bar read
42
+ * `3.6370999999926426ms` — while anything a caller had already rounded printed
43
+ * `0ms` for a query that plainly took time.
44
+ */
31
45
  export function fmt(ms: number): string {
32
- return ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${ms}ms`;
46
+ if (!Number.isFinite(ms) || ms < 0) return "—";
47
+ if (ms === 0) return "0ms";
48
+ if (ms >= 1000) return `${_trim((ms / 1000).toFixed(2))}s`;
49
+ if (ms >= 100) return `${Math.round(ms)}ms`;
50
+ if (ms >= 1) return `${_trim(ms.toFixed(1))}ms`;
51
+ return `${_trim(ms.toFixed(2))}ms`;
33
52
  }
34
53
 
35
54
  /** A byte count as KB or MB. */
@@ -66,21 +66,35 @@ export function mountShell(opts: ShellOptions): void {
66
66
  // Rebuilt per render because channels arrive over the wire and plugins register
67
67
  // whenever their own package is ready — the set is not known at start.
68
68
 
69
+ /** Every view the section showing can offer, before scope decides where it goes. */
70
+ function everyTab(): TabView[] {
71
+ if (store.section === "app") return APP_TABS;
72
+ return [...tabs, ...store.channels.map(channelTab), ...registry.panels.map(pluginTab)];
73
+ }
74
+
69
75
  /**
70
76
  * The tabs for the section showing.
71
77
  *
72
- * Channels and plugins belong to the request stream they are per-request
73
- * data so they only appear alongside it. The App section is a fixed six.
78
+ * Only the session-scoped ones. Everything that describes a single request
79
+ * its queries, its logs, its channels is a section of that request in the
80
+ * list rather than a tab of its own, so the strip is what you can look at
81
+ * rather than a dozen headings that are empty until you have picked something.
74
82
  */
75
83
  function allTabs(): TabView[] {
76
- if (store.section === "app") return APP_TABS;
77
- return [...tabs, ...store.channels.map(channelTab), ...registry.panels.map(pluginTab)];
84
+ return everyTab().filter((t) => t.scope === "session");
85
+ }
86
+
87
+ /** The views rendered inside whichever request is open. */
88
+ function requestTabs(): TabView[] {
89
+ return everyTab().filter((t) => t.scope === "request");
78
90
  }
79
91
 
80
92
  function pluginTab(p: DevtoolsRegistry["panels"][number]): TabView {
81
93
  return {
82
94
  id: `plugin:${p.id}`,
83
95
  label: p.title,
96
+ // Live browser state, which keeps reading as you move between requests.
97
+ scope: "session",
84
98
  // A plugin owns its data and its DOM; the panel cannot know when either
85
99
  // moved, so it redraws whenever anything else did and on explicit refresh.
86
100
  volatile: true,
@@ -91,7 +105,7 @@ export function mountShell(opts: ShellOptions): void {
91
105
  },
92
106
  render(el) {
93
107
  try {
94
- p.render(el);
108
+ p.render(el, { trace: store.selected });
95
109
  } catch {
96
110
  // A broken extension tab must not take the panel with it.
97
111
  el.innerHTML = '<p class="empty">Panel error</p>';
@@ -106,7 +120,7 @@ export function mountShell(opts: ShellOptions): void {
106
120
  }
107
121
 
108
122
  function ctx(): TabContext {
109
- return { trace: store.selected, store };
123
+ return { trace: store.selected, store, sections: requestTabs() };
110
124
  }
111
125
 
112
126
  // ── Render ──────────────────────────────────────────────────────────────────
@@ -132,6 +146,23 @@ export function mountShell(opts: ShellOptions): void {
132
146
  function applyTheme(): void {
133
147
  wrap.classList.toggle("light", isLightTheme(store.theme));
134
148
  if (!standalone) panel.style.height = `${store.height}px`;
149
+ reserveSpace();
150
+ }
151
+
152
+ /**
153
+ * Give the host page back the strip the panel covers.
154
+ *
155
+ * The panel is fixed to the bottom of the viewport, so without this the last
156
+ * 32px of a page — or the panel's full height when open — is behind it and
157
+ * cannot be scrolled to. The value is written as a custom property as well as
158
+ * the padding, so an app that would rather move something else of its own can
159
+ * read `--zt-dt-height` instead.
160
+ */
161
+ function reserveSpace(): void {
162
+ if (standalone) return;
163
+ const height = wrap.getBoundingClientRect().height;
164
+ document.documentElement.style.setProperty("--zt-dt-height", `${height}px`);
165
+ document.body.style.paddingBottom = `${height}px`;
135
166
  }
136
167
 
137
168
  function renderBar(): void {
@@ -339,8 +370,24 @@ export function mountShell(opts: ShellOptions): void {
339
370
  return;
340
371
  }
341
372
 
373
+ // A view inside the request being read. Checked before the row, since the
374
+ // strip sits inside the detail the row opened.
375
+ const sec = target.closest("[data-sec]") as HTMLElement | null;
376
+ if (sec?.dataset["sec"]) {
377
+ store.setSectionTab(sec.dataset["sec"]);
378
+ return;
379
+ }
380
+
381
+ // The list carries its own Clear, beside the filter it belongs with.
382
+ if (target.closest('[data-action="clear"]')) {
383
+ transport.clear();
384
+ return;
385
+ }
386
+
387
+ // Opening a request is the whole navigation now: its queries, logs, timeline
388
+ // and channels are sections of it rather than tabs you go and find.
342
389
  const row = target.closest("[data-idx]") as HTMLElement | null;
343
- if (row) select(store.traces[Number(row.dataset["idx"])] ?? null);
390
+ if (row) store.toggleOpen(store.traces[Number(row.dataset["idx"])] ?? null);
344
391
  });
345
392
 
346
393
  content.addEventListener("input", (e: Event) => {
@@ -391,6 +438,7 @@ export function mountShell(opts: ShellOptions): void {
391
438
  // Leave the host page a strip of itself: a panel dragged to full height is
392
439
  // one you cannot get out of by dragging.
393
440
  panel.style.height = `${Math.max(MIN_HEIGHT, Math.min(next, window.innerHeight - 60))}px`;
441
+ reserveSpace();
394
442
  };
395
443
  const up = (): void => {
396
444
  grip.classList.remove("dragging");
@@ -457,7 +505,7 @@ export function mountShell(opts: ShellOptions): void {
457
505
  if (!rows.length) return;
458
506
  const at = rows.findIndex((r) => r.trace.id === store.selected?.id);
459
507
  const next = at === -1 ? 0 : Math.min(rows.length - 1, Math.max(0, at + delta));
460
- select(rows[next]!.trace, false);
508
+ select(rows[next]!.trace);
461
509
  }
462
510
 
463
511
  // Alt+D stays global — it is how you reach a panel that does not have focus.
@@ -474,15 +522,16 @@ export function mountShell(opts: ShellOptions): void {
474
522
  if (standalone) return; // nothing to collapse into
475
523
  store.setOpen(!store.open);
476
524
  panel.style.display = store.open ? "flex" : "none";
525
+ reserveSpace();
477
526
  if (store.open) {
478
527
  wrap.focus({ preventScroll: true });
479
528
  renderContent(true);
480
529
  }
481
530
  }
482
531
 
483
- function select(trace: RequestTrace | null, switchTab = true): void {
532
+ function select(trace: RequestTrace | null): void {
484
533
  if (!trace) return;
485
- store.select(trace, { switchTab });
534
+ store.select(trace);
486
535
  }
487
536
 
488
537
  // ── Extension panels ────────────────────────────────────────────────────────
@@ -13,14 +13,17 @@
13
13
  const TOKENS = `
14
14
  #wrap {
15
15
  --bg: #1a1b26; --surf: #24283b; --card: #2f3452; --bdr: #3b4261;
16
- --text: #c0caf5; --muted: #565f89; --purple: #7aa2f7;
16
+ /* --muted carries labels, hints and every inactive tab. At the palette's own
17
+ #565f89 that is 2.35:1 on --surf — below AA for text of any size, and this
18
+ is 10–11px text. Lifted until it clears 4.5:1 while staying recessive. */
19
+ --text: #c0caf5; --muted: #8790bd; --purple: #7aa2f7;
17
20
  --green: #9ece6a; --yellow: #e0af68; --red: #f7768e;
18
21
  --cyan: #7dcfff; --orange: #ff9e64;
19
22
  --childbg: rgba(0,0,0,.15);
20
23
  }
21
24
  #wrap.light {
22
25
  --bg: #f4f4f8; --surf: #e8e9f0; --card: #dcdee8; --bdr: #c0c4d4;
23
- --text: #343b58; --muted: #6b7192; --purple: #34548a;
26
+ --text: #343b58; --muted: #565c7d; --purple: #34548a;
24
27
  --green: #33635c; --yellow: #8f5e15; --red: #c64343;
25
28
  --cyan: #0f4b6e; --orange: #965027;
26
29
  --childbg: rgba(0,0,0,.05);
@@ -35,7 +38,18 @@ ${TOKENS}
35
38
  color: var(--text);
36
39
  }
37
40
  #wrap:focus { outline: none; }
38
- #wrap:focus-visible { outline: none; }
41
+ /* The container itself takes focus on open and should not draw a ring for it.
42
+ Anything a person actually tabs to must — the panel used to suppress focus
43
+ everywhere, which left keyboard navigation invisible. */
44
+ #wrap :focus-visible {
45
+ outline: 2px solid var(--purple);
46
+ outline-offset: -2px;
47
+ border-radius: 2px;
48
+ }
49
+ /* Numbers line up only if the digits are the same width. Durations sit in a
50
+ right-aligned column, and proportional digits are what made its left edge
51
+ ragged from row to row. */
52
+ .num, .dur, .meth, .stat .sval, .tlbl { font-variant-numeric: tabular-nums; }
39
53
  /* ── utility colours ──────────────────────────────────────────────────────── */
40
54
  .green { color: var(--green); }
41
55
  .yellow { color: var(--yellow); }
@@ -60,6 +74,10 @@ ${TOKENS}
60
74
  .meth.post { color: var(--cyan); }
61
75
  .meth.put, .meth.patch { color: var(--yellow); }
62
76
  .meth.delete { color: var(--red); }
77
+ /* Not an HTTP method: a Flow action, which arrives over the socket against a
78
+ synthetic GET of its own page. Its own colour so the list does not read as two
79
+ loads of that page. */
80
+ .meth.flow { color: var(--purple); }
63
81
  .bpath { max-width: 280px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; }
64
82
  .sc { font-weight: 700; font-size: 11px; flex-shrink: 0; }
65
83
  .sc.ok { color: var(--green); }
@@ -257,7 +275,45 @@ ${TOKENS}
257
275
  .tmark.chan { background: var(--yellow); }
258
276
  .ttxt { flex: 2; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 10px; }
259
277
  .tkey { display: flex; gap: 10px; flex-wrap: wrap; padding: 6px 12px; font-size: 10px; color: var(--muted); }
260
- .tkey i { display: inline-block; width: 8px; height: 8px; border-radius: 2px; margin-right: 3px; vertical-align: middle; font-style: normal; }
278
+ /* position:static undoes .tmark, which the legend swatch reuses for its colour.
279
+ .tmark is absolutely positioned for the waterfall, and in the legend — whose
280
+ rows are not positioned — that sent every swatch to the nearest positioned
281
+ ancestor, leaving seven squares stacked above their own labels. */
282
+ .tkey i { position: static; display: inline-block; width: 8px; height: 8px; border-radius: 2px; margin-right: 3px; vertical-align: middle; font-style: normal; }
283
+ /* ── an open request ──────────────────────────────────────────────────────── */
284
+ .lvhead {
285
+ display: flex; align-items: baseline; gap: 8px; padding: 7px 12px;
286
+ border-bottom: 1px solid var(--bdr); background: var(--surf);
287
+ position: sticky; top: 0; z-index: 1;
288
+ }
289
+ .hchev { width: 12px; flex-shrink: 0; color: var(--muted); text-align: center; }
290
+ .hdetail {
291
+ background: var(--childbg); border-bottom: 1px solid var(--bdr);
292
+ border-left: 2px solid var(--purple);
293
+ }
294
+ /* The request's own tab strip — alternatives, not a stack you scroll past. */
295
+ .dsecs {
296
+ display: flex; gap: 2px; align-items: center; flex-wrap: wrap;
297
+ padding: 4px 10px; border-bottom: 1px solid var(--bdr); background: var(--surf);
298
+ }
299
+ .dsect {
300
+ display: inline-flex; align-items: center; gap: 4px;
301
+ background: none; border: 0; border-radius: 3px; cursor: pointer;
302
+ padding: 3px 7px; font: inherit; font-size: 11px; color: var(--muted);
303
+ }
304
+ .dsect:hover { color: var(--text); background: var(--card); }
305
+ .dsect.on { color: var(--text); background: var(--card); font-weight: 700; }
306
+ .tbtn {
307
+ background: none; border: 1px solid var(--bdr); border-radius: 3px;
308
+ color: var(--muted); cursor: pointer; padding: 2px 8px; font: inherit; font-size: 11px;
309
+ flex-shrink: 0;
310
+ }
311
+ .tbtn:hover { color: var(--text); border-color: var(--muted); }
312
+ .dsec-n {
313
+ background: var(--card); color: var(--text); border-radius: 6px;
314
+ padding: 0 5px; font-size: 10px; font-variant-numeric: tabular-nums;
315
+ }
316
+ .dsec-n.warn { background: var(--red); color: var(--bg); }
261
317
  /* ── generic channel rows ─────────────────────────────────────────────────── */
262
318
  .crow { padding: 7px 12px; border-bottom: 1px solid var(--bdr); }
263
319
  .crow.warn { border-left: 3px solid var(--yellow); }
package/src/tracing.ts CHANGED
@@ -94,11 +94,16 @@ export function _bufPush<T>(map: WeakMap<object, T[]>, ctx: object, item: T): vo
94
94
 
95
95
  const _channels = new Map<string, TraceChannelDescriptor>();
96
96
 
97
- /** Every channel declared so far, in display order. */
97
+ /**
98
+ * Every channel that wants a tab, in display order.
99
+ *
100
+ * A `hidden` channel is left out: its entries are still recorded and still reach
101
+ * the panel on the trace, but whatever renders them is not this generic row list.
102
+ */
98
103
  export function traceChannels(): TraceChannelDescriptor[] {
99
- return [...(_channels.values() as Iterable<TraceChannelDescriptor>)].sort(
100
- (a, b) => (a.order ?? 100) - (b.order ?? 100) || a.label.localeCompare(b.label),
101
- );
104
+ return [...(_channels.values() as Iterable<TraceChannelDescriptor>)]
105
+ .filter((c) => !c.hidden)
106
+ .sort((a, b) => (a.order ?? 100) - (b.order ?? 100) || a.label.localeCompare(b.label));
102
107
  }
103
108
 
104
109
  /** @internal — drop every declared channel (provider teardown, tests). */
@@ -164,6 +169,25 @@ export interface TraceSink {
164
169
  * picks up the entries already buffered for the request in flight.
165
170
  */
166
171
  record(ctx: object, channel: string, entry: Record<string, unknown>): void;
172
+ /**
173
+ * Turn a context into a trace, for work that never was an HTTP request.
174
+ *
175
+ * Traces are normally finalised from core's `RequestHandled` / `RequestFailed`,
176
+ * which covers everything the HTTP kernel serves and nothing else. A Flow action
177
+ * arrives over a WebSocket and runs against its own `HttpContext` — as do queue
178
+ * jobs and scheduled tasks — so no HTTP lifecycle event ever fires for it, and
179
+ * without this call everything buffered against that context (its channel rows,
180
+ * but equally its queries, its logs, its N+1 warnings) accumulated and was
181
+ * dropped unread. The Flow tab could then only ever report "no flow activity",
182
+ * which is exactly what it did.
183
+ *
184
+ * `method` labels the trace in the request list, because the underlying request
185
+ * is synthetic: Flow passes `FLOW` so an action is not read as a second `GET` of
186
+ * the page it acted on.
187
+ *
188
+ * Finalising happens once per context; a second call for the same one is ignored.
189
+ */
190
+ finalise(ctx: object, meta: { startMs: number; durationMs: number; method?: string }): void;
167
191
  bufferQuery(ctx: object, q: QuerySpan): void;
168
192
  bufferWarning(ctx: object, w: NPlusOneWarning): void;
169
193
  bufferMail(ctx: object, m: Omit<MailEntry, "offsetMs">): void;
@@ -186,6 +210,9 @@ export const traceSink: TraceSink = {
186
210
  absMs: Date.now(),
187
211
  });
188
212
  },
213
+ finalise(ctx: object, meta: { startMs: number; durationMs: number; method?: string }): void {
214
+ _finaliseTrace(ctx as HttpContext, meta.startMs, meta.durationMs, null, meta.method);
215
+ },
189
216
  bufferQuery(ctx: object, q: QuerySpan): void {
190
217
  // The call site is captured here rather than at the emit site because here
191
218
  // is the only place that knows whether anyone is recording. `skip` is 0: the
@@ -303,6 +330,7 @@ function _buildTrace(
303
330
  startMs: number,
304
331
  durationMs: number,
305
332
  exception: ExceptionInfo | null,
333
+ method?: string,
306
334
  ): RequestTrace {
307
335
  const queryParams: Record<string, string> = {};
308
336
  ctx.url.searchParams.forEach((v, k) => {
@@ -335,7 +363,7 @@ function _buildTrace(
335
363
  return {
336
364
  id: crypto.randomUUID().slice(0, 12),
337
365
  requestId: ctx.requestId,
338
- method: ctx.request.method.toUpperCase(),
366
+ method: (method ?? ctx.request.method).toUpperCase(),
339
367
  path: ctx.url.pathname,
340
368
  statusCode: ctx.response?.status ?? 0,
341
369
  startMs,
@@ -437,20 +465,35 @@ export function startDevtoolsTracing(): void {
437
465
  });
438
466
  }
439
467
 
468
+ /**
469
+ * Contexts already turned into a trace.
470
+ *
471
+ * One trace per context, enforced here rather than trusted: the HTTP lifecycle
472
+ * finalises exactly once, but a context that finalises *itself* — a Flow action,
473
+ * a queue job — could also be claimed by something else, and the second call
474
+ * would push a duplicate carrying none of the evidence, since the first cleaned
475
+ * the buffers out. Weak so it holds no context alive.
476
+ */
477
+ const _finalised = new WeakSet<object>();
478
+
440
479
  /** Merge buffered events into a trace and push it to the store (once per request). */
441
480
  function _finaliseTrace(
442
481
  ctx: HttpContext,
443
482
  startMs: number,
444
483
  durationMs: number,
445
484
  exception: ExceptionInfo | null,
485
+ method?: string,
446
486
  ): void {
487
+ if (_finalised.has(ctx)) return;
488
+ _finalised.add(ctx);
489
+
447
490
  // Internal framework paths are noise — skip them
448
491
  if (_isInternal(ctx.url.pathname)) {
449
492
  _cleanupBuffers(ctx);
450
493
  return;
451
494
  }
452
495
 
453
- const trace = _buildTrace(ctx, startMs, durationMs, exception);
496
+ const trace = _buildTrace(ctx, startMs, durationMs, exception, method);
454
497
  _cleanupBuffers(ctx);
455
498
  traceStore().push(trace);
456
499
  }