@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.
Files changed (45) hide show
  1. package/CHANGELOG.md +329 -0
  2. package/api-surface.md +298 -0
  3. package/package.json +5 -4
  4. package/src/DevtoolsInjectionMiddleware.ts +41 -4
  5. package/src/RequestTrace.ts +106 -1
  6. package/src/TraceStore.ts +12 -0
  7. package/src/activity.ts +116 -0
  8. package/src/callsite.ts +146 -0
  9. package/src/client/filter.ts +108 -0
  10. package/src/client/index.ts +127 -0
  11. package/src/client/metrics.ts +98 -0
  12. package/src/client/registry.ts +87 -0
  13. package/src/client/state.ts +350 -0
  14. package/src/client/tabs/all.ts +323 -0
  15. package/src/client/tabs/app.ts +293 -0
  16. package/src/client/tabs/cache.ts +50 -0
  17. package/src/client/tabs/channel.ts +264 -0
  18. package/src/client/tabs/exceptions.ts +69 -0
  19. package/src/client/tabs/jobs.ts +51 -0
  20. package/src/client/tabs/live.ts +66 -0
  21. package/src/client/tabs/logs.ts +45 -0
  22. package/src/client/tabs/mail.ts +60 -0
  23. package/src/client/tabs/queries.ts +125 -0
  24. package/src/client/tabs/request.ts +75 -0
  25. package/src/client/tabs/sections.ts +115 -0
  26. package/src/client/tabs/timeline.ts +133 -0
  27. package/src/client/tabs/types.ts +68 -0
  28. package/src/client/transport.ts +81 -0
  29. package/src/client/tree.ts +138 -0
  30. package/src/client/ui/format.ts +137 -0
  31. package/src/client/ui/render.ts +87 -0
  32. package/src/client/ui/shell.ts +560 -0
  33. package/src/client/ui/theme.ts +445 -0
  34. package/src/client-auto.ts +1 -1
  35. package/src/config.ts +77 -2
  36. package/src/dashboard-auto.ts +1 -1
  37. package/src/editor.ts +107 -0
  38. package/src/enabled.ts +59 -0
  39. package/src/index.ts +19 -3
  40. package/src/map.ts +213 -0
  41. package/src/provider/DevtoolsProvider.ts +32 -7
  42. package/src/redaction.ts +161 -20
  43. package/src/tracing.ts +261 -29
  44. package/src/client.ts +0 -1048
  45. package/src/panel-app.js +0 -519
@@ -1,8 +1,11 @@
1
1
  import { fileURLToPath } from "node:url";
2
2
  import type { NextFn, HttpContext } from "@zerotal/core";
3
- import { BaseMiddleware } from "@zerotal/core";
3
+ import { BaseMiddleware, tryCurrentApp } from "@zerotal/core";
4
4
  import { traceStore } from "./TraceStore.ts";
5
5
  import { traceChannels } from "./tracing.ts";
6
+ import { devtoolsAuthorized, devtoolsSettings } from "./enabled.ts";
7
+ import { buildFrameworkMap } from "./map.ts";
8
+ import { activityFeed } from "./activity.ts";
6
9
 
7
10
  // ── Injected browser client bundle ────────────────────────────────────────────
8
11
  // The in-page devtools panel is bundled for the browser on first request and
@@ -44,6 +47,9 @@ export interface DevtoolsInjectionOptions {
44
47
  // reserved for future use
45
48
  }
46
49
 
50
+ /** Everything the inspector serves lives under here, and is gated as one thing. */
51
+ const DEVTOOLS_PREFIX = "/__zerotal/devtools";
52
+
47
53
  // ── SSE subscribers ───────────────────────────────────────────────────────────
48
54
 
49
55
  const _sseClients = new Set<ReadableStreamDefaultController<Uint8Array>>();
@@ -130,10 +136,34 @@ export class DevtoolsInjectionMiddleware extends BaseMiddleware<DevtoolsInjectio
130
136
  async handle(http: HttpContext, next: NextFn): Promise<Response | void> {
131
137
  const { pathname } = http.url;
132
138
 
139
+ // Everything under the prefix is one secret. The stream, the trace JSON, the
140
+ // dashboard, and the panel bundle all expose the same request data, so they
141
+ // are gated together and before anything is read — checking per endpoint is
142
+ // how one of them ends up ungated.
143
+ if (pathname.startsWith(DEVTOOLS_PREFIX)) {
144
+ if (!(await devtoolsAuthorized(http.request))) {
145
+ // 404, not 403: outside development the honest answer to an
146
+ // unauthenticated stranger is that there is nothing here.
147
+ return new Response("Not Found", { status: 404 });
148
+ }
149
+ }
150
+
133
151
  if (pathname === "/__zerotal/devtools/api/traces") {
134
152
  return Response.json(traceStore().all());
135
153
  }
136
154
 
155
+ // The framework map: read fresh, never cached. The registries are small and
156
+ // static, and a cached map is one that disagrees with the app the moment a
157
+ // provider registers a route late.
158
+ if (pathname === "/__zerotal/devtools/api/map") {
159
+ const app = tryCurrentApp();
160
+ if (!app) return Response.json({ error: "No application in scope" }, { status: 503 });
161
+ return Response.json({
162
+ ...buildFrameworkMap(app, devtoolsSettings().redact),
163
+ activity: activityFeed(),
164
+ });
165
+ }
166
+
137
167
  if (pathname === "/__zerotal/devtools/api/channels") {
138
168
  return Response.json(traceChannels());
139
169
  }
@@ -149,15 +179,22 @@ export class DevtoolsInjectionMiddleware extends BaseMiddleware<DevtoolsInjectio
149
179
  start(c) {
150
180
  ctrl = c;
151
181
  _sseClients.add(ctrl);
152
- // The opening frame carries the channel descriptors alongside the
153
- // history, so a panel can render a package's tab on first paint
154
- // instead of waiting for that package's next entry.
182
+ // The opening frame carries everything the panel needs at first paint
183
+ // rather than making it ask three more times: the channel descriptors,
184
+ // so a package's tab is there before that package's next entry; the
185
+ // store's capacity, so the list trims to the depth the app asked for;
186
+ // and the editor settings, so a `file:line` is a link on the first
187
+ // trace rather than the second.
188
+ const settings = devtoolsSettings();
155
189
  ctrl.enqueue(
156
190
  _enc.encode(
157
191
  `data: ${JSON.stringify({
158
192
  type: "history",
159
193
  data: traceStore().all(),
160
194
  channels: traceChannels(),
195
+ capacity: traceStore().capacity,
196
+ editor: settings.editor,
197
+ editorPathMap: settings.editorPathMap,
161
198
  })}\n\n`,
162
199
  ),
163
200
  );
@@ -1,9 +1,19 @@
1
+ import type { SourceLocation } from "./editor.ts";
2
+
1
3
  export interface QuerySpan {
2
4
  sql: string;
3
5
  bindings: unknown[];
4
6
  startMs: number;
5
7
  durationMs: number;
6
8
  rowCount: number;
9
+ /**
10
+ * The application line that ran this query, when one could be found.
11
+ *
12
+ * Absent for a query with no application frame above it — a seeder, a
13
+ * framework-internal read — which is a truthful answer and better than
14
+ * pointing at a file nobody wrote.
15
+ */
16
+ source?: SourceLocation;
7
17
  }
8
18
 
9
19
  export interface NPlusOneWarning {
@@ -27,6 +37,32 @@ export interface LogEntry {
27
37
  level: "log" | "debug" | "info" | "warn" | "error";
28
38
  args: string[];
29
39
  offsetMs: number;
40
+ /** The application line that logged this, when one could be found. */
41
+ source?: SourceLocation;
42
+ }
43
+
44
+ /**
45
+ * The error that propagated out of the request pipeline, when one did.
46
+ *
47
+ * A failed request finalises like any other, so its status code was always on
48
+ * the trace — but the message that caused it was not, and a red `500` with no
49
+ * text next to it is the one thing a request inspector must not do.
50
+ */
51
+ export interface ExceptionInfo {
52
+ /** The error's message as it left the pipeline. */
53
+ message: string;
54
+ /** The status the rendered error response used. */
55
+ status: number;
56
+ /** The error's class name, when the failure was an `Error`. */
57
+ type?: string;
58
+ /**
59
+ * The stack, innermost first, with framework frames kept.
60
+ *
61
+ * Unlike a query's call site this is deliberately *not* filtered to
62
+ * application code: you read a stack trace to find out how you got somewhere,
63
+ * and a trace with the middle removed does not tell you that.
64
+ */
65
+ frames?: SourceLocation[];
30
66
  }
31
67
 
32
68
  export interface MailEntry {
@@ -105,6 +141,63 @@ export interface TraceChannelDescriptor {
105
141
  warn?: string;
106
142
  /** Sort order among channel tabs. Lower sorts first. Defaults to 100. */
107
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;
154
+
155
+ // ── Presentation hints ──────────────────────────────────────────────────────
156
+ //
157
+ // A flat list of badge-title-meta rows is the right shape for an audit feed and
158
+ // the wrong one for a prop map or a route table. These pick a different
159
+ // presentation without breaking the property that makes channels worth having:
160
+ // everything here is still *data*, so devtools ships no code per package.
161
+
162
+ /**
163
+ * How rows are presented. Defaults to `"rows"` — badge, title, and a meta line.
164
+ *
165
+ * - `"rows"` — one block per entry.
166
+ * - `"tree"` — {@link treeField} holds a map of dotted paths; shared prefixes
167
+ * become branches.
168
+ * - `"table"` — one row per entry, {@link meta} as columns. For many entries
169
+ * with the same shape.
170
+ * - `"kv"` — every field of every entry as a key/value table. For a handful of
171
+ * entries with many fields.
172
+ * - `"grouped"` — entries collected under the value of {@link groupBy}.
173
+ */
174
+ render?: "rows" | "tree" | "table" | "kv" | "grouped";
175
+ /**
176
+ * For `"tree"`: the entry field holding the tree, as a map of dotted path →
177
+ * a record of that node's attributes. Dotted keys become branches.
178
+ */
179
+ treeField?: string;
180
+ /** For `"tree"`: the node field rendered as each leaf's leading badge. */
181
+ treeBadge?: string;
182
+ /** For `"grouped"`: the entry field rows are grouped by. */
183
+ groupBy?: string;
184
+ /**
185
+ * Fields rendered as a bare chip when truthy — `shared`, `deferred`, `failed`.
186
+ * Applies to a row's own fields and, under `"tree"`, to each node's.
187
+ *
188
+ * A flag is named by its *field*, so a `true` reads as the word rather than as
189
+ * `deepMerge: true`, which is how a row ends up saying nothing at a glance.
190
+ */
191
+ flags?: string[];
192
+ /**
193
+ * The entry field whose value groups whole *traces* together on the All tab.
194
+ *
195
+ * One request can cause several — a visit and the deferred-prop loads it
196
+ * triggers — and listing them as unrelated siblings is how the thing you are
197
+ * debugging scrolls away. Traces sharing a value here collapse into one
198
+ * expandable entry. Read from the channel's first entry on each trace.
199
+ */
200
+ traceGroup?: string;
108
201
  }
109
202
 
110
203
  export interface RequestTrace {
@@ -121,14 +214,26 @@ export interface RequestTrace {
121
214
  memory: number;
122
215
  /** URL query string parameters */
123
216
  queryParams: Record<string, string>;
124
- /** Filtered request headers (no auth/cookie values) */
217
+ /** Filtered request headers (never auth/cookie values) */
125
218
  headers: Record<string, string>;
219
+ /** Filtered response headers — the other half of the exchange */
220
+ responseHeaders: Record<string, string>;
221
+ /**
222
+ * Session key names, never values.
223
+ *
224
+ * "Is the CSRF token there, did the flash survive the redirect, is the user id
225
+ * set" are all answered by the keys — and the values are the request's real
226
+ * state, on a trace that is written to disk for a day.
227
+ */
228
+ session: string[];
126
229
  /** Matched route pattern, controller, and action */
127
230
  route: RouteInfo | null;
128
231
  /** Authenticated user at the end of the request, or null for guests */
129
232
  auth: AuthInfo | null;
130
233
  /** Console log/debug/info/warn/error messages emitted during the request */
131
234
  logs: LogEntry[];
235
+ /** The error that ended the request, or null when it completed normally */
236
+ exception: ExceptionInfo | null;
132
237
  /** Emails sent (or queued) during this request */
133
238
  mail: MailEntry[];
134
239
  /** Cache operations performed during this request */
package/src/TraceStore.ts CHANGED
@@ -138,6 +138,18 @@ export class TraceStore {
138
138
  return this._db !== null;
139
139
  }
140
140
 
141
+ /**
142
+ * How many traces this store keeps.
143
+ *
144
+ * Read by the SSE stream so the panel can trim its own list to the same depth.
145
+ * The client used to cap at a hardcoded 100, so an app that configured a larger
146
+ * capacity got the full history in the opening frame and then silently lost
147
+ * everything past 100 as soon as the next request arrived.
148
+ */
149
+ get capacity(): number {
150
+ return this._capacity;
151
+ }
152
+
141
153
  // ── Persistence ───────────────────────────────────────────────────────
142
154
 
143
155
  /** Open the database and load history, once, on first use. */
@@ -0,0 +1,116 @@
1
+ /**
2
+ * What the application did when nobody was making a request.
3
+ *
4
+ * A scheduled task that fails at 03:00 leaves no trace in the tool whose job is
5
+ * to show you what your app did — because every surface in the panel until now
6
+ * hangs off an `HttpContext`, and a console command and a cron tick have none.
7
+ * `CommandRan`, `TaskRan`, `TaskFailed` and `TaskSkipped` were all on the bus and
8
+ * all went nowhere.
9
+ *
10
+ * A small ring of its own rather than a channel, for the reason channels exist:
11
+ * a channel entry belongs to a request. These belong to the process.
12
+ */
13
+ import { FrameworkEvents } from "@zerotal/core";
14
+ import type { CommandRan } from "@zerotal/core";
15
+
16
+ /** One thing the app did outside a request. */
17
+ export interface ActivityEntry {
18
+ kind: "command" | "task";
19
+ name: string;
20
+ /** `ok`, `failed`, or why a task was skipped. */
21
+ outcome: string;
22
+ durationMs: number;
23
+ /** Unix milliseconds, so the panel can show when rather than only what. */
24
+ at: number;
25
+ failed: boolean;
26
+ detail?: string;
27
+ }
28
+
29
+ /**
30
+ * How many entries to keep.
31
+ *
32
+ * A long-lived dev server running a per-minute schedule produces 1,440 of these
33
+ * a day; the useful window is the last few dozen. Unbounded here would be a
34
+ * memory leak with a friendly name.
35
+ */
36
+ const MAX_ENTRIES = 200;
37
+
38
+ let _entries: ActivityEntry[] = [];
39
+
40
+ /** Newest first, as the panel draws them. */
41
+ export function activityFeed(): ActivityEntry[] {
42
+ return [..._entries].reverse();
43
+ }
44
+
45
+ /** @internal — drop everything (provider teardown, tests). */
46
+ export function _resetActivity(): void {
47
+ _entries = [];
48
+ }
49
+
50
+ function push(entry: ActivityEntry): void {
51
+ _entries.push(entry);
52
+ if (_entries.length > MAX_ENTRIES) _entries.shift();
53
+ }
54
+
55
+ /**
56
+ * Subscribe to the non-HTTP lifecycle events.
57
+ *
58
+ * The scheduler's events are subscribed **by kind string** rather than by class:
59
+ * `@zerotal/scheduler` is an optional package, and importing its event classes
60
+ * to name them would make devtools depend on it. The bus supports either door
61
+ * and a string subscription costs nothing when nothing ever emits.
62
+ *
63
+ * @returns A disposer that removes every subscription.
64
+ */
65
+ export function startActivityCapture(): () => void {
66
+ const unsubs = [
67
+ FrameworkEvents.on<CommandRan>("CommandRan", (e) => {
68
+ push({
69
+ kind: "command",
70
+ name: e.name,
71
+ outcome: e.ok ? "ok" : `exit ${e.exitCode}`,
72
+ durationMs: e.durationMs,
73
+ at: Date.now(),
74
+ failed: !e.ok,
75
+ ...(e.error ? { detail: e.error } : {}),
76
+ });
77
+ }),
78
+ FrameworkEvents.on<{ name: string; durationMs: number; ok: boolean }>("TaskRan", (e) => {
79
+ push({
80
+ kind: "task",
81
+ name: e.name,
82
+ outcome: e.ok ? "ok" : "failed",
83
+ durationMs: e.durationMs,
84
+ at: Date.now(),
85
+ failed: !e.ok,
86
+ });
87
+ }),
88
+ FrameworkEvents.on<{ name: string; durationMs: number; error: string }>("TaskFailed", (e) => {
89
+ push({
90
+ kind: "task",
91
+ name: e.name,
92
+ outcome: "failed",
93
+ durationMs: e.durationMs,
94
+ at: Date.now(),
95
+ failed: true,
96
+ detail: e.error,
97
+ });
98
+ }),
99
+ FrameworkEvents.on<{ name: string; reason: string }>("TaskSkipped", (e) => {
100
+ push({
101
+ kind: "task",
102
+ name: e.name,
103
+ // Why it skipped is the whole content of the event: "skipped" alone
104
+ // sends you looking for a bug in a task that was told not to run.
105
+ outcome: `skipped · ${e.reason}`,
106
+ durationMs: 0,
107
+ at: Date.now(),
108
+ failed: false,
109
+ });
110
+ }),
111
+ ];
112
+
113
+ return () => {
114
+ for (const unsub of unsubs) unsub();
115
+ };
116
+ }
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Where in *your* code this happened.
3
+ *
4
+ * A `QuerySpan` was `{ sql, bindings, startMs, durationMs, rowCount }` and a
5
+ * `LogEntry` was `{ level, args, offsetMs }`. Neither knew which line produced
6
+ * it, so "which of my forty queries is the slow one" was answerable and "where do
7
+ * I go to fix it" was not.
8
+ *
9
+ * The whole trick is throwing away frames. A stack captured where devtools
10
+ * buffers an event begins inside devtools, passes through the emitting package,
11
+ * and only then reaches the application — so the first frame that is *not*
12
+ * framework code is the answer, and every frame above it is noise.
13
+ *
14
+ * **Cost.** Measured under Bun at roughly two microseconds per capture, flat
15
+ * across stack depths from 5 to 80 — the engine builds the trace lazily, so
16
+ * depth barely registers. A request running forty queries pays about 0.08ms.
17
+ * That is why {@link DevtoolsConfigShape.captureSource} defaults to on: it was
18
+ * expected to be the expensive part of this and it is not.
19
+ */
20
+ import type { SourceLocation } from "./editor.ts";
21
+
22
+ /**
23
+ * Path fragments that mean "not the application".
24
+ *
25
+ * Matched against the normalised path, so the separator is always `/`. The
26
+ * framework's own packages are here twice over — as a workspace checkout
27
+ * (`packages/orm/src`) and as an installed dependency (`node_modules`) — because
28
+ * a contributor debugging the framework and an app developer using it see
29
+ * different paths for the same file.
30
+ */
31
+ const FRAMEWORK_FRAGMENTS = [
32
+ "node_modules/",
33
+ "/packages/core/",
34
+ "/packages/orm/",
35
+ "/packages/devtools/",
36
+ "/packages/cache/",
37
+ "/packages/queue/",
38
+ "/packages/auth/",
39
+ "/packages/session/",
40
+ "/packages/notifications/",
41
+ "/packages/inertia/",
42
+ "/packages/flow/",
43
+ "bun:",
44
+ "node:",
45
+ ];
46
+
47
+ /** Frames the runtime adds that name no file at all. */
48
+ const NATIVE = ["[native code]", "<anonymous>", "unknown"];
49
+
50
+ /**
51
+ * One line of a stack, as the runtimes spell it.
52
+ *
53
+ * Two shapes: `at fn (file:line:col)` and a bare `at file:line:col`. The `async`
54
+ * prefix rides along on continuation frames and is stripped from the name rather
55
+ * than being allowed to become part of it.
56
+ */
57
+ const FRAME = /^\s*at\s+(?:(.+?)\s+\()?(.+?):(\d+):(\d+)\)?\s*$/;
58
+
59
+ /** How far down a stack to look before giving up. */
60
+ const MAX_FRAMES = 40;
61
+
62
+ function isFrameworkFrame(file: string): boolean {
63
+ const normalised = file.replace(/\\/g, "/");
64
+ if (NATIVE.some((n) => normalised.includes(n))) return true;
65
+ return FRAMEWORK_FRAGMENTS.some((f) => normalised.includes(f));
66
+ }
67
+
68
+ /** Parse one stack line into a location, or null when it is not one. */
69
+ export function parseFrame(line: string): SourceLocation | null {
70
+ const match = FRAME.exec(line);
71
+ if (!match) return null;
72
+ const [, rawName, file, lineNo, column] = match;
73
+ if (!file || !lineNo) return null;
74
+ const name = rawName?.replace(/^async\s+/, "").trim();
75
+ return {
76
+ file,
77
+ line: Number(lineNo),
78
+ column: Number(column ?? 1),
79
+ ...(name ? { function: name } : {}),
80
+ };
81
+ }
82
+
83
+ /**
84
+ * Every frame of a stack, framework noise included.
85
+ *
86
+ * Used for an exception, where the full trace is the point — you are reading it
87
+ * to find out how you got somewhere, and a trace with the framework removed does
88
+ * not tell you that.
89
+ *
90
+ * @param stack - An `Error.stack` string.
91
+ * @param limit - How many frames to keep.
92
+ */
93
+ export function parseStack(stack: string | undefined, limit = MAX_FRAMES): SourceLocation[] {
94
+ if (!stack) return [];
95
+ const out: SourceLocation[] = [];
96
+ for (const line of stack.split("\n")) {
97
+ const frame = parseFrame(line);
98
+ if (frame) out.push(frame);
99
+ if (out.length >= limit) break;
100
+ }
101
+ return out;
102
+ }
103
+
104
+ /**
105
+ * The first application frame in a stack.
106
+ *
107
+ * Null when every frame is framework — a query run from a seeder, a log line
108
+ * from inside a package — which is a truthful answer and better than pointing at
109
+ * a file the reader did not write.
110
+ *
111
+ * Pure, and separate from {@link captureCallSite}, because this is the part with
112
+ * a decision in it: which frames count as yours. Taking the stack as an argument
113
+ * is also the only way to test it from inside this package, whose own files the
114
+ * filter is supposed to reject.
115
+ *
116
+ * @param stack - An `Error.stack` string.
117
+ * @param skip - Frames to drop before looking, for a caller that knows its own
118
+ * wrappers are on the stack.
119
+ */
120
+ export function firstAppFrame(stack: string | undefined, skip = 0): SourceLocation | null {
121
+ if (!stack) return null;
122
+
123
+ // The first line is the "Error" header on V8 and absent on JSC; `parseFrame`
124
+ // returns null for it either way, so this does not need to know which runtime
125
+ // it is on.
126
+ let seen = 0;
127
+ const lines = stack.split("\n");
128
+ for (let i = 0; i < lines.length && i < MAX_FRAMES; i++) {
129
+ const frame = parseFrame(lines[i]!);
130
+ if (!frame) continue;
131
+ if (seen++ < skip) continue;
132
+ if (isFrameworkFrame(frame.file)) continue;
133
+ return frame;
134
+ }
135
+ return null;
136
+ }
137
+
138
+ /**
139
+ * Where in the application this was called from.
140
+ *
141
+ * @param skip - Frames to drop before looking. The console patch passes 1,
142
+ * because it stands between the caller and the stack.
143
+ */
144
+ export function captureCallSite(skip = 0): SourceLocation | null {
145
+ return firstAppFrame(new Error().stack, skip);
146
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Which traces the All tab shows.
3
+ *
4
+ * Two independent narrowings that compose with AND: free text, and facets. Text
5
+ * answers "the request I am thinking of"; facets answer "the kind of request I am
6
+ * hunting" — and a list you can only search by name is one you cannot ask "show
7
+ * me the failures" of.
8
+ *
9
+ * All of it is pure, and none of it touches the DOM: this is the part of the
10
+ * panel that is logic rather than markup, and it is worth testing without a
11
+ * browser.
12
+ */
13
+ import type { RequestTrace } from "../RequestTrace.ts";
14
+
15
+ /**
16
+ * A request slower than this reads as slow.
17
+ *
18
+ * The same boundary the duration colour already uses, so the `slow` facet
19
+ * selects exactly the rows that were already amber or red — a filter that
20
+ * disagreed with the colouring next to it would be worse than no filter.
21
+ */
22
+ export const SLOW_MS = 300;
23
+
24
+ /** The non-text narrowings, each empty or false meaning "do not narrow by this". */
25
+ export interface Facets {
26
+ /** Uppercase method names. Empty means every method. */
27
+ methods: string[];
28
+ /** Status classes as their leading digit — `"2"`, `"4"`, … Empty means every status. */
29
+ statusClasses: string[];
30
+ /** Only requests that threw. */
31
+ errors: boolean;
32
+ /** Only requests slower than {@link SLOW_MS}. */
33
+ slow: boolean;
34
+ /** Only requests with an N+1 warning. */
35
+ nPlusOne: boolean;
36
+ }
37
+
38
+ /** No narrowing at all — what a fresh panel starts with. */
39
+ export function noFacets(): Facets {
40
+ return { methods: [], statusClasses: [], errors: false, slow: false, nPlusOne: false };
41
+ }
42
+
43
+ /** Whether any facet is actually narrowing, for the "clear" affordance. */
44
+ export function facetsActive(f: Facets): boolean {
45
+ return f.methods.length > 0 || f.statusClasses.length > 0 || f.errors || f.slow || f.nPlusOne;
46
+ }
47
+
48
+ /**
49
+ * Match a trace against the All tab's filter box.
50
+ *
51
+ * Every space-separated term has to match, so `posts 500` narrows twice rather
52
+ * than widening — a filter that ORs its terms gets less useful the more you type.
53
+ * The haystack covers what you would search a request list by: method, path,
54
+ * status, and the route it matched.
55
+ */
56
+ export function matchesFilter(trace: RequestTrace, query: string): boolean {
57
+ const terms = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
58
+ if (!terms.length) return true;
59
+ const haystack = [
60
+ trace.method,
61
+ trace.path,
62
+ String(trace.statusCode),
63
+ trace.route?.pattern ?? "",
64
+ trace.route?.controller ?? "",
65
+ trace.route?.action ?? "",
66
+ ]
67
+ .join(" ")
68
+ .toLowerCase();
69
+ return terms.every((term) => haystack.includes(term));
70
+ }
71
+
72
+ /**
73
+ * Match a trace against the facet chips.
74
+ *
75
+ * Within one facet the values are alternatives — picking `GET` and `POST` shows
76
+ * both. Across facets they compound, the same way the text terms do: `POST` plus
77
+ * `5xx` means failing writes, not writes-or-failures.
78
+ */
79
+ export function matchesFacets(trace: RequestTrace, f: Facets): boolean {
80
+ if (f.methods.length && !f.methods.includes(trace.method.toUpperCase())) return false;
81
+ if (f.statusClasses.length) {
82
+ const cls = String(trace.statusCode || 0).charAt(0);
83
+ if (!f.statusClasses.includes(cls)) return false;
84
+ }
85
+ // A 4xx or 5xx counts as an error even when nothing threw: a rendered 404 is a
86
+ // failed request to anyone reading this list, and the trace only carries an
87
+ // `exception` when an error escaped the pipeline.
88
+ if (f.errors && !trace.exception && trace.statusCode < 400) return false;
89
+ if (f.slow && trace.durationMs <= SLOW_MS) return false;
90
+ if (f.nPlusOne && !trace.warnings.length) return false;
91
+ return true;
92
+ }
93
+
94
+ /** Both narrowings at once — what the All tab actually asks. */
95
+ export function traceMatches(trace: RequestTrace, query: string, f: Facets): boolean {
96
+ return matchesFacets(trace, f) && matchesFilter(trace, query);
97
+ }
98
+
99
+ /**
100
+ * The method chips worth offering, from the traces actually recorded.
101
+ *
102
+ * Listing every HTTP verb would put five dead chips on screen for an app that
103
+ * only ever GETs. Sorted for a stable strip — chips that reorder as traffic
104
+ * arrives are chips you have to re-find every time you look.
105
+ */
106
+ export function methodsPresent(traces: RequestTrace[]): string[] {
107
+ return [...new Set(traces.map((t) => t.method.toUpperCase()))].sort();
108
+ }