@zerotal/devtools 1.6.3 → 1.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +329 -0
- package/api-surface.md +298 -0
- package/package.json +5 -4
- package/src/DevtoolsInjectionMiddleware.ts +41 -4
- package/src/RequestTrace.ts +106 -1
- package/src/TraceStore.ts +12 -0
- package/src/activity.ts +116 -0
- package/src/callsite.ts +146 -0
- package/src/client/filter.ts +108 -0
- package/src/client/index.ts +127 -0
- package/src/client/metrics.ts +98 -0
- package/src/client/registry.ts +87 -0
- package/src/client/state.ts +350 -0
- package/src/client/tabs/all.ts +323 -0
- package/src/client/tabs/app.ts +293 -0
- package/src/client/tabs/cache.ts +50 -0
- package/src/client/tabs/channel.ts +264 -0
- package/src/client/tabs/exceptions.ts +69 -0
- package/src/client/tabs/jobs.ts +51 -0
- package/src/client/tabs/live.ts +66 -0
- package/src/client/tabs/logs.ts +45 -0
- package/src/client/tabs/mail.ts +60 -0
- package/src/client/tabs/queries.ts +125 -0
- package/src/client/tabs/request.ts +75 -0
- package/src/client/tabs/sections.ts +115 -0
- package/src/client/tabs/timeline.ts +133 -0
- package/src/client/tabs/types.ts +68 -0
- package/src/client/transport.ts +81 -0
- package/src/client/tree.ts +138 -0
- package/src/client/ui/format.ts +137 -0
- package/src/client/ui/render.ts +87 -0
- package/src/client/ui/shell.ts +560 -0
- package/src/client/ui/theme.ts +445 -0
- package/src/client-auto.ts +1 -1
- package/src/config.ts +77 -2
- package/src/dashboard-auto.ts +1 -1
- package/src/editor.ts +107 -0
- package/src/enabled.ts +59 -0
- package/src/index.ts +19 -3
- package/src/map.ts +213 -0
- package/src/provider/DevtoolsProvider.ts +32 -7
- package/src/redaction.ts +161 -20
- package/src/tracing.ts +261 -29
- package/src/client.ts +0 -1048
- package/src/panel-app.js +0 -519
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @zerotal/devtools — browser client
|
|
3
|
+
*
|
|
4
|
+
* Usage (in your app.js / frontend entry):
|
|
5
|
+
* import { DevTools } from '@zerotal/devtools/client';
|
|
6
|
+
* DevTools.start();
|
|
7
|
+
*
|
|
8
|
+
* Connects to the SSE stream served by DevtoolsInjectionMiddleware and renders a
|
|
9
|
+
* live floating panel. No script injection by the server is required.
|
|
10
|
+
*
|
|
11
|
+
* This file is only the wiring. The panel was one 1,400-line closure holding its
|
|
12
|
+
* state, its transport, its styles, eight renderers, and every helper — which
|
|
13
|
+
* made adding a tab an edit to the middle of it and made none of its logic
|
|
14
|
+
* testable. It is now a directory: {@link Store} holds the state,
|
|
15
|
+
* `transport.ts` owns the wire, `ui/shell.ts` owns the frame, and each tab is a
|
|
16
|
+
* file that exports a {@link TabView}. Both mount modes still run one set of
|
|
17
|
+
* renderers, and both extension doors are unchanged.
|
|
18
|
+
*/
|
|
19
|
+
import { Store } from "./state.ts";
|
|
20
|
+
import { collectClientMetrics, onceLoaded } from "./metrics.ts";
|
|
21
|
+
import { connect } from "./transport.ts";
|
|
22
|
+
import { mountShell } from "./ui/shell.ts";
|
|
23
|
+
import { allTab } from "./tabs/all.ts";
|
|
24
|
+
import { liveTab } from "./tabs/live.ts";
|
|
25
|
+
import { cacheTab } from "./tabs/cache.ts";
|
|
26
|
+
import { exceptionsTab } from "./tabs/exceptions.ts";
|
|
27
|
+
import { jobsTab } from "./tabs/jobs.ts";
|
|
28
|
+
import { logsTab } from "./tabs/logs.ts";
|
|
29
|
+
import { mailTab } from "./tabs/mail.ts";
|
|
30
|
+
import { queriesTab } from "./tabs/queries.ts";
|
|
31
|
+
import { requestTab } from "./tabs/request.ts";
|
|
32
|
+
import { timelineTab } from "./tabs/timeline.ts";
|
|
33
|
+
|
|
34
|
+
export interface DevtoolsClientOptions {
|
|
35
|
+
/** Base URL path for the devtools API. Default: '/__zerotal/devtools' */
|
|
36
|
+
endpoint?: string;
|
|
37
|
+
/**
|
|
38
|
+
* How the panel is mounted.
|
|
39
|
+
*
|
|
40
|
+
* `'floating'` (default) pins a collapsible bar to the bottom of the page.
|
|
41
|
+
* `'standalone'` fills the window and drops the collapse/close controls — the
|
|
42
|
+
* inspector dashboard. Both run the same renderers, so a tab added for one
|
|
43
|
+
* exists in the other.
|
|
44
|
+
*/
|
|
45
|
+
mode?: "floating" | "standalone";
|
|
46
|
+
/** Element to mount into. Defaults to `document.body`. */
|
|
47
|
+
mount?: HTMLElement;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The built-in views, in strip order.
|
|
52
|
+
*
|
|
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.
|
|
59
|
+
*/
|
|
60
|
+
const BUILT_IN = [
|
|
61
|
+
liveTab,
|
|
62
|
+
allTab,
|
|
63
|
+
requestTab,
|
|
64
|
+
exceptionsTab,
|
|
65
|
+
queriesTab,
|
|
66
|
+
logsTab,
|
|
67
|
+
mailTab,
|
|
68
|
+
cacheTab,
|
|
69
|
+
jobsTab,
|
|
70
|
+
timelineTab,
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
export const DevTools = {
|
|
74
|
+
start(opts: DevtoolsClientOptions = {}): void {
|
|
75
|
+
if (typeof document === "undefined") return;
|
|
76
|
+
if (document.getElementById("__zerotal_dt__")) return;
|
|
77
|
+
|
|
78
|
+
const base = (opts.endpoint ?? "/__zerotal/devtools").replace(/\/$/, "");
|
|
79
|
+
const standalone = opts.mode === "standalone";
|
|
80
|
+
|
|
81
|
+
const store = new Store(standalone, base);
|
|
82
|
+
const transport = connect(base, store);
|
|
83
|
+
|
|
84
|
+
// What the browser measured for this page load, read once after it settles.
|
|
85
|
+
// The panel reports server duration as though it were the user's experience;
|
|
86
|
+
// it is not, and this is the only place that knows the difference.
|
|
87
|
+
onceLoaded(() => {
|
|
88
|
+
store.clientMetrics = collectClientMetrics();
|
|
89
|
+
store.changed();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
mountShell({
|
|
93
|
+
base,
|
|
94
|
+
standalone,
|
|
95
|
+
mount: opts.mount ?? document.body,
|
|
96
|
+
store,
|
|
97
|
+
transport,
|
|
98
|
+
tabs: BUILT_IN,
|
|
99
|
+
});
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
// ── Public surface ────────────────────────────────────────────────────────────
|
|
104
|
+
//
|
|
105
|
+
// The panel is markup, and markup is awkward to assert on. What is exported here
|
|
106
|
+
// is the part of it that is *logic*: a package contributing a channel can check
|
|
107
|
+
// how its rows will filter, fold, and nest without a browser.
|
|
108
|
+
//
|
|
109
|
+
// Deliberately not everything the directory exports. `TabView`, the theme choice,
|
|
110
|
+
// and the All tab's own mechanics are internal contracts this package reserves
|
|
111
|
+
// the right to change — the tests that cover them import them by path, which is
|
|
112
|
+
// what a same-package test should do rather than widening the API to be reachable.
|
|
113
|
+
|
|
114
|
+
export type { DevtoolsPanelPlugin } from "./registry.ts";
|
|
115
|
+
export type { Facets } from "./filter.ts";
|
|
116
|
+
export type { PathTreeNode, TraceRow } from "./tree.ts";
|
|
117
|
+
|
|
118
|
+
export {
|
|
119
|
+
matchesFilter,
|
|
120
|
+
matchesFacets,
|
|
121
|
+
traceMatches,
|
|
122
|
+
methodsPresent,
|
|
123
|
+
noFacets,
|
|
124
|
+
facetsActive,
|
|
125
|
+
SLOW_MS,
|
|
126
|
+
} from "./filter.ts";
|
|
127
|
+
export { buildPathTree, traceGroupKey, foldTraceRows } from "./tree.ts";
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the *browser* measured, alongside what the server did.
|
|
3
|
+
*
|
|
4
|
+
* The panel reports server duration as though it were the user's experience. It
|
|
5
|
+
* is not: a 12ms response that the browser spends 900ms parsing, laying out, and
|
|
6
|
+
* painting is a slow page, and nothing in the trace said so. The panel already
|
|
7
|
+
* runs JavaScript on the page and had never asked the one API that knows.
|
|
8
|
+
*
|
|
9
|
+
* Read once, after the load event, from the Performance timeline — no polling,
|
|
10
|
+
* no observer left running, nothing sampled per frame. It is a report on the
|
|
11
|
+
* document, so there is exactly one of it per page load.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** One browser-side measurement, in the shape the panel's stat grid draws. */
|
|
15
|
+
export interface ClientMetric {
|
|
16
|
+
label: string;
|
|
17
|
+
/** Milliseconds. Rounded — sub-millisecond precision here is noise. */
|
|
18
|
+
value: number;
|
|
19
|
+
/** Longer text for the row's tooltip. */
|
|
20
|
+
detail: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The phases of a page load, as the Navigation Timing entry names them.
|
|
25
|
+
*
|
|
26
|
+
* Chosen for what a developer can act on: time to the first byte is the server
|
|
27
|
+
* plus the network, DOM interactive is parsing, and load is everything the page
|
|
28
|
+
* asked for. The rest of the entry is either derived from these or is about DNS
|
|
29
|
+
* and TLS, which is not what a request inspector is for.
|
|
30
|
+
*/
|
|
31
|
+
function navigationMetrics(nav: PerformanceNavigationTiming): ClientMetric[] {
|
|
32
|
+
const out: ClientMetric[] = [];
|
|
33
|
+
const add = (label: string, value: number, detail: string): void => {
|
|
34
|
+
if (Number.isFinite(value) && value > 0) out.push({ label, value: Math.round(value), detail });
|
|
35
|
+
};
|
|
36
|
+
add("TTFB", nav.responseStart - nav.requestStart, "Request sent → first byte back");
|
|
37
|
+
add("Response", nav.responseEnd - nav.responseStart, "First byte → last byte");
|
|
38
|
+
add("DOM interactive", nav.domInteractive - nav.responseEnd, "Parsing the document");
|
|
39
|
+
add("DOM complete", nav.domComplete - nav.domInteractive, "Subresources and deferred scripts");
|
|
40
|
+
add("Load", nav.loadEventEnd - nav.startTime, "Navigation start → load event");
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Paint timings, when the browser recorded them.
|
|
46
|
+
*
|
|
47
|
+
* First Contentful Paint is the one number that most closely tracks "did that
|
|
48
|
+
* feel fast", and it is the only Web Vital available without a library and
|
|
49
|
+
* without leaving an observer running for the life of the page.
|
|
50
|
+
*/
|
|
51
|
+
function paintMetrics(): ClientMetric[] {
|
|
52
|
+
try {
|
|
53
|
+
return performance
|
|
54
|
+
.getEntriesByType("paint")
|
|
55
|
+
.filter((e) => e.name === "first-contentful-paint")
|
|
56
|
+
.map((e) => ({
|
|
57
|
+
label: "First paint",
|
|
58
|
+
value: Math.round(e.startTime),
|
|
59
|
+
detail: "Navigation start → first content on screen",
|
|
60
|
+
}));
|
|
61
|
+
} catch {
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Everything the browser can tell us about this page load, or an empty list.
|
|
68
|
+
*
|
|
69
|
+
* Empty rather than throwing on every browser that does not implement the API,
|
|
70
|
+
* and empty before the load event, when the numbers are not final.
|
|
71
|
+
*/
|
|
72
|
+
export function collectClientMetrics(): ClientMetric[] {
|
|
73
|
+
try {
|
|
74
|
+
const [nav] = performance.getEntriesByType("navigation") as PerformanceNavigationTiming[];
|
|
75
|
+
// `loadEventEnd` is 0 until the load event has actually fired; reading before
|
|
76
|
+
// then produces negative durations rather than an error.
|
|
77
|
+
if (!nav || nav.loadEventEnd <= 0) return paintMetrics();
|
|
78
|
+
return [...navigationMetrics(nav), ...paintMetrics()];
|
|
79
|
+
} catch {
|
|
80
|
+
return [];
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Run `fn` once the page has finished loading.
|
|
86
|
+
*
|
|
87
|
+
* A panel injected into a page that has already loaded — which is what happens
|
|
88
|
+
* on a hot reload — would otherwise wait for an event that has been and gone.
|
|
89
|
+
*/
|
|
90
|
+
export function onceLoaded(fn: () => void): void {
|
|
91
|
+
if (document.readyState === "complete") {
|
|
92
|
+
// Not synchronously: `loadEventEnd` is stamped *after* the handlers run, so
|
|
93
|
+
// reading it in the same turn as a just-fired load event reads a zero.
|
|
94
|
+
setTimeout(fn, 0);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
window.addEventListener("load", () => setTimeout(fn, 0), { once: true });
|
|
98
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The extension door other packages come through.
|
|
3
|
+
*
|
|
4
|
+
* A package adds its own tab to the injected panel — a unified dev tool across
|
|
5
|
+
* the framework — by calling `window.__zerotalDevtools?.register(panel)` from its
|
|
6
|
+
* own browser code (`@zerotal/flow`'s time-travel timeline is the live consumer).
|
|
7
|
+
* The panel renders the extra tab and calls `panel.render(el)` when it is shown;
|
|
8
|
+
* `refresh(id)` lets the extension push a live update.
|
|
9
|
+
*
|
|
10
|
+
* The registry is created lazily by whichever runs first — this panel or an
|
|
11
|
+
* extension — so registration is order-independent. That shape is public API and
|
|
12
|
+
* has a shipped consumer, so it survived the client rewrite unchanged.
|
|
13
|
+
*/
|
|
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
|
+
|
|
32
|
+
/** A panel another package contributes as a tab in the Zerotal devtools. */
|
|
33
|
+
export interface DevtoolsPanelPlugin {
|
|
34
|
+
/** Unique id — the tab is addressed internally as `plugin:<id>`. */
|
|
35
|
+
id: string;
|
|
36
|
+
/** Tab label. */
|
|
37
|
+
title: string;
|
|
38
|
+
/** Optional badge value (e.g. a count); a falsy return hides the badge. */
|
|
39
|
+
badge?: () => number | string | undefined;
|
|
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;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface DevtoolsRegistry {
|
|
50
|
+
panels: DevtoolsPanelPlugin[];
|
|
51
|
+
/** @internal set by the host panel — called when a panel registers. */
|
|
52
|
+
_emit: ((p: DevtoolsPanelPlugin) => void) | null;
|
|
53
|
+
/** @internal set by the host panel — called on refresh(id). */
|
|
54
|
+
_refresh: ((id?: string) => void) | null;
|
|
55
|
+
register(panel: DevtoolsPanelPlugin): DevtoolsPanelPlugin;
|
|
56
|
+
refresh(id?: string): void;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// `window.__zerotalDevtools` is the documented extension point above, so it
|
|
60
|
+
// belongs on `Window` rather than behind a cast at each use. Declared, not
|
|
61
|
+
// asserted: an extension reading it from its own code gets the same type.
|
|
62
|
+
declare global {
|
|
63
|
+
interface Window {
|
|
64
|
+
__zerotalDevtools?: DevtoolsRegistry;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Get-or-create the global devtools extension registry. */
|
|
69
|
+
export function ensureRegistry(): DevtoolsRegistry {
|
|
70
|
+
const w = window;
|
|
71
|
+
if (!w.__zerotalDevtools) {
|
|
72
|
+
w.__zerotalDevtools = {
|
|
73
|
+
panels: [],
|
|
74
|
+
_emit: null,
|
|
75
|
+
_refresh: null,
|
|
76
|
+
register(panel: DevtoolsPanelPlugin) {
|
|
77
|
+
this.panels.push(panel);
|
|
78
|
+
this._emit?.(panel);
|
|
79
|
+
return panel;
|
|
80
|
+
},
|
|
81
|
+
refresh(id?: string) {
|
|
82
|
+
this._refresh?.(id);
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
return w.__zerotalDevtools;
|
|
87
|
+
}
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Everything the panel knows, in one place, with a change signal.
|
|
3
|
+
*
|
|
4
|
+
* It used to be sixteen `let`s inside `DevTools.start()` and a scattering of
|
|
5
|
+
* `renderBar(); if (open) renderContent();` after each assignment — which meant
|
|
6
|
+
* adding a piece of state meant finding every place that had to redraw because of
|
|
7
|
+
* it, and forgetting one was a panel that showed something stale. Now a mutation
|
|
8
|
+
* calls {@link Store.changed} and the shell decides what to redraw.
|
|
9
|
+
*/
|
|
10
|
+
import type { RequestTrace, TraceChannelDescriptor } from "../RequestTrace.ts";
|
|
11
|
+
import type { EditorName } from "../editor.ts";
|
|
12
|
+
import type { ClientMetric } from "./metrics.ts";
|
|
13
|
+
import { noFacets, traceMatches, type Facets } from "./filter.ts";
|
|
14
|
+
import type { ThemeChoice } from "./ui/theme.ts";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* How the panel turns a captured location into a link.
|
|
18
|
+
*
|
|
19
|
+
* Sent by the server rather than configured in the browser: the paths come from
|
|
20
|
+
* the process that recorded them, so the process that recorded them is what
|
|
21
|
+
* knows how to rewrite them.
|
|
22
|
+
*/
|
|
23
|
+
export interface EditorSettings {
|
|
24
|
+
editor: EditorName | null;
|
|
25
|
+
editorPathMap: Record<string, string>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The slice of state that outlives a reload.
|
|
30
|
+
*
|
|
31
|
+
* Which tab you were on, what you had filtered to, how tall you dragged the
|
|
32
|
+
* panel, and whether it was open are answers to "where was I", and every reload
|
|
33
|
+
* used to throw them away — which on a page you are reloading *because* you are
|
|
34
|
+
* debugging it is the wrong moment to lose them.
|
|
35
|
+
*/
|
|
36
|
+
export interface PersistedUi {
|
|
37
|
+
open: boolean;
|
|
38
|
+
section: Section;
|
|
39
|
+
tab: string;
|
|
40
|
+
appTab: string;
|
|
41
|
+
/** The view showing inside an open request. */
|
|
42
|
+
sectionTab: string;
|
|
43
|
+
filter: string;
|
|
44
|
+
facets: Facets;
|
|
45
|
+
height: number;
|
|
46
|
+
theme: ThemeChoice;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Which half of the panel is showing.
|
|
51
|
+
*
|
|
52
|
+
* `requests` is the trace stream — what the app just did. `app` is the framework
|
|
53
|
+
* map — what the app *is*. Two sections rather than fifteen tabs in one strip:
|
|
54
|
+
* they answer different questions, and a strip you have to scroll to reach the
|
|
55
|
+
* routes list is one you stop reaching for.
|
|
56
|
+
*/
|
|
57
|
+
export type Section = "requests" | "app";
|
|
58
|
+
|
|
59
|
+
const UI_KEY = "__zerotal_devtools_ui";
|
|
60
|
+
|
|
61
|
+
/** Panel heights outside this range are a panel you cannot use. */
|
|
62
|
+
export const MIN_HEIGHT = 120;
|
|
63
|
+
export const DEFAULT_HEIGHT = 380;
|
|
64
|
+
|
|
65
|
+
function loadUi(): Partial<PersistedUi> {
|
|
66
|
+
try {
|
|
67
|
+
const raw = localStorage.getItem(UI_KEY);
|
|
68
|
+
const parsed: unknown = raw ? JSON.parse(raw) : null;
|
|
69
|
+
return parsed && typeof parsed === "object" ? (parsed as Partial<PersistedUi>) : {};
|
|
70
|
+
} catch {
|
|
71
|
+
// Private mode, a disabled store, a half-written value — a dev panel that
|
|
72
|
+
// cannot remember its tab is fine; one that throws on boot is not.
|
|
73
|
+
return {};
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function saveUi(state: PersistedUi): void {
|
|
78
|
+
try {
|
|
79
|
+
localStorage.setItem(UI_KEY, JSON.stringify(state));
|
|
80
|
+
} catch {
|
|
81
|
+
/* see above */
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export class Store {
|
|
86
|
+
// ── Server state ────────────────────────────────────────────────────────────
|
|
87
|
+
traces: RequestTrace[] = [];
|
|
88
|
+
channels: TraceChannelDescriptor[] = [];
|
|
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 = "";
|
|
101
|
+
connected = false;
|
|
102
|
+
/**
|
|
103
|
+
* How many traces to keep. Replaced by the server's real capacity when the
|
|
104
|
+
* history frame lands; until then this matches the store's own default rather
|
|
105
|
+
* than being a number of the client's own that a configured capacity could not
|
|
106
|
+
* move.
|
|
107
|
+
*/
|
|
108
|
+
capacity = 100;
|
|
109
|
+
/** Replaced by the app's real settings when the history frame lands. */
|
|
110
|
+
editor: EditorSettings = { editor: null, editorPathMap: {} };
|
|
111
|
+
|
|
112
|
+
// ── Session state ───────────────────────────────────────────────────────────
|
|
113
|
+
/** Following the newest request, rather than pinned to one you picked. */
|
|
114
|
+
live = true;
|
|
115
|
+
/** Traces that arrived while pinned — offered, never jumped to. */
|
|
116
|
+
pending = 0;
|
|
117
|
+
/** Correlated-request groups opened on the All tab. */
|
|
118
|
+
readonly expanded = new Set<string>();
|
|
119
|
+
/**
|
|
120
|
+
* What the browser measured for this page load.
|
|
121
|
+
*
|
|
122
|
+
* Per page, not per request — which is why they sit above the waterfall
|
|
123
|
+
* labelled as the browser's rather than being merged into it. A 12ms response
|
|
124
|
+
* the browser spends 900ms painting is a slow page, and the server trace
|
|
125
|
+
* cannot say so.
|
|
126
|
+
*/
|
|
127
|
+
clientMetrics: ClientMetric[] = [];
|
|
128
|
+
|
|
129
|
+
// ── Persisted UI ────────────────────────────────────────────────────────────
|
|
130
|
+
open: boolean;
|
|
131
|
+
section: Section;
|
|
132
|
+
tab: string;
|
|
133
|
+
/** The App section's tab, kept apart so switching sections restores each one. */
|
|
134
|
+
appTab: string;
|
|
135
|
+
filter: string;
|
|
136
|
+
facets: Facets;
|
|
137
|
+
height: number;
|
|
138
|
+
theme: ThemeChoice;
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Bumped on every change. A tab that must redraw whenever anything moved —
|
|
142
|
+
* rather than only when the selected trace changed — reads this into its cache
|
|
143
|
+
* key, which is how the All tab stays live while the others stay still.
|
|
144
|
+
*/
|
|
145
|
+
revision = 0;
|
|
146
|
+
|
|
147
|
+
private readonly listeners = new Set<() => void>();
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* @param standalone - The dashboard has nothing to collapse into; it starts open.
|
|
151
|
+
* @param base - The devtools endpoint root, for the surfaces that fetch rather
|
|
152
|
+
* than listen. The App section reads the framework map over it.
|
|
153
|
+
*/
|
|
154
|
+
constructor(
|
|
155
|
+
standalone: boolean,
|
|
156
|
+
readonly base = "/__zerotal/devtools",
|
|
157
|
+
) {
|
|
158
|
+
const saved = loadUi();
|
|
159
|
+
this.open = standalone || saved.open === true;
|
|
160
|
+
this.section = saved.section === "app" ? "app" : "requests";
|
|
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");
|
|
166
|
+
this.appTab = saved.appTab ?? "app:routes";
|
|
167
|
+
this.sectionTab = saved.sectionTab ?? "";
|
|
168
|
+
this.filter = saved.filter ?? "";
|
|
169
|
+
this.facets = { ...noFacets(), ...(saved.facets ?? {}) };
|
|
170
|
+
this.height = Math.max(MIN_HEIGHT, saved.height ?? DEFAULT_HEIGHT);
|
|
171
|
+
this.theme = saved.theme ?? "auto";
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
subscribe(fn: () => void): () => void {
|
|
175
|
+
this.listeners.add(fn);
|
|
176
|
+
return () => this.listeners.delete(fn);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Announce a change. Every mutation ends here; nothing redraws without it. */
|
|
180
|
+
changed(): void {
|
|
181
|
+
this.revision++;
|
|
182
|
+
for (const fn of this.listeners) fn();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Write the durable slice out. Called from the mutations that touch it. */
|
|
186
|
+
persist(): void {
|
|
187
|
+
saveUi({
|
|
188
|
+
open: this.open,
|
|
189
|
+
section: this.section,
|
|
190
|
+
appTab: this.appTab,
|
|
191
|
+
tab: this.tab,
|
|
192
|
+
sectionTab: this.sectionTab,
|
|
193
|
+
filter: this.filter,
|
|
194
|
+
facets: this.facets,
|
|
195
|
+
height: this.height,
|
|
196
|
+
theme: this.theme,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ── Derived ─────────────────────────────────────────────────────────────────
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* The traces the All tab shows, with their index in the full list.
|
|
204
|
+
*
|
|
205
|
+
* The index rides along because a click has to select the right trace after
|
|
206
|
+
* filtering, and because keyboard navigation steps through *this* list rather
|
|
207
|
+
* than through everything recorded.
|
|
208
|
+
*/
|
|
209
|
+
visible(): Array<{ trace: RequestTrace; index: number }> {
|
|
210
|
+
const out: Array<{ trace: RequestTrace; index: number }> = [];
|
|
211
|
+
this.traces.forEach((trace, index) => {
|
|
212
|
+
if (traceMatches(trace, this.filter, this.facets)) out.push({ trace, index });
|
|
213
|
+
});
|
|
214
|
+
return out;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ── Mutations ───────────────────────────────────────────────────────────────
|
|
218
|
+
|
|
219
|
+
/** The tab showing in the current section. */
|
|
220
|
+
get activeTab(): string {
|
|
221
|
+
return this.section === "app" ? this.appTab : this.tab;
|
|
222
|
+
}
|
|
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
|
+
|
|
232
|
+
setTab(tab: string): void {
|
|
233
|
+
const key = this.section === "app" ? "appTab" : "tab";
|
|
234
|
+
if (this[key] === tab) return;
|
|
235
|
+
this[key] = tab;
|
|
236
|
+
this.persist();
|
|
237
|
+
this.changed();
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
setSection(section: Section): void {
|
|
241
|
+
if (this.section === section) return;
|
|
242
|
+
this.section = section;
|
|
243
|
+
this.persist();
|
|
244
|
+
this.changed();
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
setFilter(filter: string): void {
|
|
248
|
+
this.filter = filter;
|
|
249
|
+
this.persist();
|
|
250
|
+
this.changed();
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
setFacets(facets: Facets): void {
|
|
254
|
+
this.facets = facets;
|
|
255
|
+
this.persist();
|
|
256
|
+
this.changed();
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
setHeight(height: number): void {
|
|
260
|
+
this.height = Math.max(MIN_HEIGHT, Math.round(height));
|
|
261
|
+
this.persist();
|
|
262
|
+
this.changed();
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
setTheme(theme: ThemeChoice): void {
|
|
266
|
+
this.theme = theme;
|
|
267
|
+
this.persist();
|
|
268
|
+
this.changed();
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
setOpen(open: boolean): void {
|
|
272
|
+
this.open = open;
|
|
273
|
+
this.persist();
|
|
274
|
+
this.changed();
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Pin a trace and stop following the newest. */
|
|
278
|
+
select(trace: RequestTrace | null): void {
|
|
279
|
+
this.selected = trace;
|
|
280
|
+
this.live = false;
|
|
281
|
+
this.pending = 0;
|
|
282
|
+
this.persist();
|
|
283
|
+
this.changed();
|
|
284
|
+
}
|
|
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
|
+
|
|
299
|
+
/** Follow the newest request again, clearing the backlog offer. */
|
|
300
|
+
follow(): void {
|
|
301
|
+
this.live = true;
|
|
302
|
+
this.pending = 0;
|
|
303
|
+
this.selected = this.traces[0] ?? null;
|
|
304
|
+
this.changed();
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
pin(): void {
|
|
308
|
+
this.live = false;
|
|
309
|
+
this.changed();
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
toggleGroup(key: string): void {
|
|
313
|
+
if (!this.expanded.delete(key)) this.expanded.add(key);
|
|
314
|
+
this.changed();
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Apply the stream's opening frame. */
|
|
318
|
+
loadHistory(
|
|
319
|
+
traces: RequestTrace[],
|
|
320
|
+
channels: TraceChannelDescriptor[],
|
|
321
|
+
capacity?: number,
|
|
322
|
+
editor?: Partial<EditorSettings>,
|
|
323
|
+
): void {
|
|
324
|
+
this.traces = traces;
|
|
325
|
+
this.channels = channels;
|
|
326
|
+
if (editor) this.editor = { ...this.editor, ...editor };
|
|
327
|
+
// An older server sends no capacity; keeping what we have then is better than
|
|
328
|
+
// trimming its history to a guess.
|
|
329
|
+
if (typeof capacity === "number" && capacity > 0) this.capacity = capacity;
|
|
330
|
+
if (this.live || !this.selected) this.selected = traces[0] ?? null;
|
|
331
|
+
this.changed();
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Take one new trace off the stream. */
|
|
335
|
+
addTrace(trace: RequestTrace): void {
|
|
336
|
+
this.traces.unshift(trace);
|
|
337
|
+
if (this.traces.length > this.capacity) this.traces.length = this.capacity;
|
|
338
|
+
if (this.live) this.selected = trace;
|
|
339
|
+
else this.pending++;
|
|
340
|
+
this.changed();
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
clear(): void {
|
|
344
|
+
this.traces = [];
|
|
345
|
+
this.selected = null;
|
|
346
|
+
this.pending = 0;
|
|
347
|
+
this.expanded.clear();
|
|
348
|
+
this.changed();
|
|
349
|
+
}
|
|
350
|
+
}
|