@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 +97 -0
- package/api-surface.md +4 -2
- package/package.json +3 -3
- package/src/RequestTrace.ts +10 -0
- package/src/client/index.ts +13 -8
- package/src/client/registry.ts +24 -2
- package/src/client/state.ts +42 -3
- package/src/client/tabs/all.ts +61 -14
- package/src/client/tabs/app.ts +1 -0
- package/src/client/tabs/cache.ts +1 -0
- package/src/client/tabs/channel.ts +5 -4
- package/src/client/tabs/exceptions.ts +1 -0
- package/src/client/tabs/jobs.ts +1 -0
- package/src/client/tabs/live.ts +66 -0
- package/src/client/tabs/logs.ts +1 -0
- package/src/client/tabs/mail.ts +1 -0
- package/src/client/tabs/queries.ts +1 -0
- package/src/client/tabs/request.ts +7 -8
- package/src/client/tabs/sections.ts +115 -0
- package/src/client/tabs/timeline.ts +1 -0
- package/src/client/tabs/types.ts +17 -0
- package/src/client/ui/format.ts +21 -2
- package/src/client/ui/shell.ts +59 -10
- package/src/client/ui/theme.ts +60 -4
- package/src/tracing.ts +49 -6
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.
|
|
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.
|
|
34
|
+
"@zerotal/core": "1.7.3"
|
|
35
35
|
},
|
|
36
36
|
"devDependencies": {
|
|
37
37
|
"typescript": "^5.8.0",
|
|
38
|
-
"@zerotal/orm": "1.7.
|
|
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": [
|
package/src/RequestTrace.ts
CHANGED
|
@@ -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
|
//
|
package/src/client/index.ts
CHANGED
|
@@ -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
|
|
51
|
+
* The built-in views, in strip order.
|
|
51
52
|
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
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
|
-
|
|
58
|
-
|
|
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
|
-
|
|
70
|
+
timelineTab,
|
|
66
71
|
];
|
|
67
72
|
|
|
68
73
|
export const DevTools = {
|
package/src/client/registry.ts
CHANGED
|
@@ -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
|
-
/**
|
|
24
|
-
|
|
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 {
|
package/src/client/state.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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;
|
package/src/client/tabs/all.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
(
|
|
212
|
-
|
|
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,
|
|
224
|
-
|
|
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(
|
|
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,
|
package/src/client/tabs/app.ts
CHANGED
package/src/client/tabs/cache.ts
CHANGED
|
@@ -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}
|
|
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}
|
|
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}
|
|
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] ?? [];
|
package/src/client/tabs/jobs.ts
CHANGED
|
@@ -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
|
+
};
|
package/src/client/tabs/logs.ts
CHANGED
package/src/client/tabs/mail.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
+
}
|
package/src/client/tabs/types.ts
CHANGED
|
@@ -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.
|
package/src/client/ui/format.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
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. */
|
package/src/client/ui/shell.ts
CHANGED
|
@@ -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
|
-
*
|
|
73
|
-
*
|
|
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
|
-
|
|
77
|
-
|
|
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)
|
|
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
|
|
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
|
|
532
|
+
function select(trace: RequestTrace | null): void {
|
|
484
533
|
if (!trace) return;
|
|
485
|
-
store.select(trace
|
|
534
|
+
store.select(trace);
|
|
486
535
|
}
|
|
487
536
|
|
|
488
537
|
// ── Extension panels ────────────────────────────────────────────────────────
|
package/src/client/ui/theme.ts
CHANGED
|
@@ -13,14 +13,17 @@
|
|
|
13
13
|
const TOKENS = `
|
|
14
14
|
#wrap {
|
|
15
15
|
--bg: #1a1b26; --surf: #24283b; --card: #2f3452; --bdr: #3b4261;
|
|
16
|
-
|
|
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: #
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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>)]
|
|
100
|
-
(
|
|
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
|
}
|