Public: anyone can read this; the crew can change it.

The usage table is a boards source, and a usage page lists routes nobody used in 30 days #2186

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/1565-the-usage-table-is-a-boards-source-and-a → main 4 commits fork 8 threads · 8 unresolved

  • Merged. scout/1565-the-usage-table-is-a-boards-source-and-a was merged into main as 268a823.

Review

Sign in to review this change.
12 files changed +400 −1
README.md +2
@@ -1136,6 +1136,8 @@ While the default branch's own last check is red, the forge holds its pulls' che
11361136
11371137 A forge admin sees every declared flag at `/settings/flags` (Settings → Flags): why it exists, its default, the owner issue and its live value. Set it for the whole forge, for listed user ids or roles, or for a percentage of people; Clear returns it to the default. A change is validated, kept in the change record and live on the next request, with no restart. In the terminal the same admin opens the command palette (`ctrl+k`), picks Flags, and presses enter to set a flag from a line such as `forge=on users=u1,u2 roles=admin percent=30` or `x` to clear it; an agent is never an admin there either.
11381138
1139 A forge admin sees every route the router knows at `/settings/usage` (Settings → Usage): its requests over the last 30 days, by role and by surface, and the hour of its last use. Routes with no request in the window are listed first, never-used ones before the longest-unused, as candidates to drop; the rest follow by count. Counts are per pattern, not per method. `usage` is also a boards source, for an admin only (an agent is refused); its `hour` column is text like `2026-10-16T13`, so group by a column such as `pattern` or `role`, not by a time bucket. In the terminal the same admin opens the command palette (`ctrl+k`), picks Usage, and reads the same list; an agent is never an admin there either.
1140
11391141 ## Limits
11401142
11411143 Every limit the owner can hit is declared in `src/settings/limits.ts` with its default, bounds and reason, and changed live by a forge admin at `/admin/limits`, `PUT /api/v1/admin/limits/:key` or the `limits` MCP tool (never by an agent). Each change is kept with who made it.
src/boards/sources.ts +20
@@ -98,6 +98,26 @@ export const SOURCES: Readonly<Record<string, Source>> = {
9898 outcome: "text", at: "time",
9999 },
100100 },
101 // Requests counted by the hour (`core/usage_count.ts`): admin only. `hour`
102 // is `2026-10-16T13`, so `from` and `to` bound it by string and a bucket

Low: from and to are off by one hour for this source. runQuery turns them into new Date(v).toISOString(), for example 2026-10-16T12:00:00.000Z, and compares that with hour as a string. Because "2026-10-16T12" < "2026-10-16T12:00:00.000Z", from drops its own hour and to includes the hour it should stop before. Fix: store hour as a full ISO instant (…T13:00:00.000Z), or have the query trim the bound to 13 characters when src.time is an hour column. Add a test with a from on an hour boundary.

103 // cannot read it; group by a column instead.
104 usage: {
105 table: "usage_count",
106 owner: "admin",
107 time: "hour",
108 columns: {
109 hour: "time", pattern: "text", method: "text", status: "text",
110 role: "text", surface: "text", n: "number",
111 },
112 docs: {
113 hour: "the hour the requests were answered in, UTC, like 2026-10-16T13",
114 pattern: "the route pattern that matched, or (unmatched) for every path that matched none",
115 status: "the status class: 2xx, 3xx, 4xx or 5xx",
116 role: "anon, user, admin or agent",
117 surface: "api, mcp or web",
118 n: "requests in that hour with that pattern, method, status class, role and surface",
119 },
120 },
101121 // What the machine used (`boards/sampler.ts`, kept by `boards/ring.ts`):
102122 // 15-s points for the last day, 5-min points before that, never both
103123 // for one moment.
src/core/usage_report.ts added +56
@@ -0,0 +1,56 @@
1 /**
2 * What the router knows against what was used: every route pattern with its
3 * requests over the last 30 days by role and by surface, and the hour of its
4 * last use ever. A pattern with no request in the window is listed first, as
5 * a candidate to drop; among those, the longest unused (or never used) leads.
6 * Plain data in, plain data out: the page and its test both read it.
7 */
8
9 import type { Database } from "bun:sqlite";
10 import { hourOf } from "./usage_count.ts";
11
12 export const WINDOW_DAYS = 30;
13
14 export interface RouteUsage {
15 readonly pattern: string;
16 readonly total: number;
17 readonly roles: Readonly<Record<string, number>>;
18 readonly surfaces: Readonly<Record<string, number>>;
19 /** Hour of the last request ever (`2026-10-16T13`), or null for never. */
20 readonly last: string | null;
21 }
22
23 /** Every known pattern, unused first (never, then oldest last use), then busiest. */
24 export function routeUsage(db: Database, patterns: readonly string[], now: Date): RouteUsage[] {
25 const since = hourOf(new Date(now.getTime() - WINDOW_DAYS * 86_400_000));
26 const rows = db
27 .query(
28 `SELECT pattern, role, surface, SUM(n) AS n FROM usage_count
29 WHERE hour >= ?1 GROUP BY pattern, role, surface`,
30 )
31 .all(since) as { pattern: string; role: string; surface: string; n: number }[];
32 const lasts = new Map(
33 (db.query(`SELECT pattern, MAX(hour) AS last FROM usage_count GROUP BY pattern`).all() as {

Note: MAX(hour) GROUP BY pattern reads all of usage_count, because the primary key starts with hour and nothing prunes the table. That is fine for an admin page today. If the page slows down, add retention or an index on (pattern, hour).

Medium, performance. MAX(hour) … GROUP BY pattern has no WHERE, so every load of /settings/usage (and every open of the terminal screen) scans all of usage_count. Nothing prunes that table, and it gains rows every hour for every combination of pattern, method, status, role and surface. That is small today, but after a year it is far past the forge's millisecond page budget. Fix: give usage_count a retention (for example 90 days, deleted by whatever already flushes the counter), so 'never' means 'not within the retention'. Or compute last use only for the patterns that are unused in the window, against an index on (pattern, hour).

34 pattern: string;
35 last: string;
36 }[]).map((r) => [r.pattern, r.last]),
37 );
38 const by = new Map<string, { total: number; roles: Record<string, number>; surfaces: Record<string, number> }>();
39 for (const r of rows) {
40 const e = by.get(r.pattern) ?? { total: 0, roles: {}, surfaces: {} };
41 e.total += r.n;
42 e.roles[r.role] = (e.roles[r.role] ?? 0) + r.n;
43 e.surfaces[r.surface] = (e.surfaces[r.surface] ?? 0) + r.n;
44 by.set(r.pattern, e);
45 }
46 return [...new Set(patterns)]
47 .map((pattern): RouteUsage => {
48 const e = by.get(pattern);
49 return { pattern, total: e?.total ?? 0, roles: e?.roles ?? {}, surfaces: e?.surfaces ?? {}, last: lasts.get(pattern) ?? null };
50 })
51 .sort((a, b) => {
52 if ((a.total === 0) !== (b.total === 0)) return a.total === 0 ? -1 : 1;
53 if (a.total === 0) return (a.last ?? "").localeCompare(b.last ?? "") || a.pattern.localeCompare(b.pattern);
54 return b.total - a.total || a.pattern.localeCompare(b.pattern);
55 });
56 }
src/http/router.ts +5
@@ -68,6 +68,11 @@ export class Router<H> {
6868 return [...heads].sort();
6969 }
7070
71 /** Every pattern given to `add`, once each, in the order added. */
72 patterns(): string[] {
73 return [...new Set(this.routes.map((r) => r.pattern))];
74 }
75
7176 add(method: string, pattern: string, handler: H): this {
7277 this.routes.push({ method, pattern, segments: compile(pattern), handler });
7378 return this;
src/http/server.ts +8
@@ -15,6 +15,7 @@ import { systemPage } from "../web/pages/system.ts";
1515 import { telemetryPage, telemetrySet } from "../web/pages/telemetry.ts";
1616 import { contributePage } from "../web/pages/contribute.ts";
1717 import { flagsPage, flagsSet } from "../web/pages/flags.ts";
18 import { usagePage } from "../web/pages/usage.ts";
1819 import { domainsPage } from "../web/pages/domains.ts";
1920 import { resiliencePage } from "../web/pages/resilience.ts";
2021 import { limitsPage, limitsSet, quotasSet, signupSet } from "../web/pages/admin-limits.ts";
@@ -763,6 +764,7 @@ export function buildRouter(): Router<Handler> {
763764 router.add("POST", "/boards/:slug/:op", boardPost);
764765 router.add("GET", "/settings/flags", flagsPage);
765766 router.add("POST", "/settings/flags", flagsSet);
767 router.add("GET", "/settings/usage", (ctx) => usagePage(ctx, knownPatterns));
766768 router.add("GET", "/admin/limits", limitsPage);
767769 router.add("POST", "/admin/limits", limitsSet);
outdated

Only the web door gets this. The terminal needs the same report (a tui/screens/usage.ts fed by the same pattern list), gated like Flags.

768770 router.add("POST", "/admin/quotas", quotasSet);
@@ -1288,6 +1290,12 @@ export function buildApiRouter(): Router<Handler> {
12881290 return router;
12891291 }
12901292
1293 let known: string[] | undefined;
1294 /** Every pattern the studio's and the API's routers know, read once on first use. */
1295 export function knownPatterns(): string[] {
1296 return (known ??= [...buildRouter().patterns(), ...buildApiRouter().patterns()]);
1297 }
1298
12911299 async function withGitAccess(
12921300 ctx: Ctx,
12931301 service: GitService,
src/tui/forge.ts +9
@@ -42,6 +42,7 @@ import { dashboardScreen } from "./screens/dashboard.ts";
4242 import { mayManageFlags } from "../core/flags.ts";
4343 import { accessScreen } from "./screens/access.ts";
4444 import { flagsScreen } from "./screens/flags.ts";
45 import { usageScreen } from "./screens/usage.ts";
4546 import { quotasScreen } from "./screens/quotas.ts";
4647 import { domainsScreen } from "./screens/domains.ts";
4748 import { resilienceLines, resilienceScreen } from "./screens/resilience.ts";
@@ -345,6 +346,14 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] {
345346 hint: "declared flags and their live values",
346347 run: () => ctx.push(flagsScreen(session, ctx)),
347348 });
349 // Settings → Usage, as the page has it: the same gate as Flags (#1565).
350 if (mayManageFlags(session.user))
351 commands.push({
352 id: "usage",
353 title: "Usage",
354 hint: "every route, unused ones first",
355 run: () => ctx.push(usageScreen(session, ctx)),
356 });
348357 // Account quotas, as /admin/limits has them: an admin who is not an agent (#2191).
349358 if (session.user?.isAdmin === true && session.user.kind !== "agent")
350359 commands.push({
src/tui/screens/usage.ts added +52
@@ -0,0 +1,52 @@
1 /**
2 * Usage in the terminal, a forge admin's (#1565): every route pattern with
3 * its requests over the last 30 days by role and by surface, unused ones
4 * first with their last use or `never`, as Settings → Usage lists them
5 * (`routeUsage`). The gate is the page's: an admin who is not an agent;
6 * anyone else sees nothing.
7 */
8
9 import { mayManageFlags } from "../../core/flags.ts";
10 import { ROLES, SURFACES } from "../../core/usage_count.ts";
11 import { routeUsage, WINDOW_DAYS, type RouteUsage } from "../../core/usage_report.ts";
12 import { knownPatterns } from "../../http/server.ts";

Low, structure. This is the first src/tui module to import http/server.ts. It loads the whole HTTP server and builds both routers just to list their patterns. A small http/known_patterns.ts that the page, the terminal and the test all import would keep the terminal from depending on the server module.

13 import type { Ctx, Screen } from "../app/app.ts";
14 import { binding } from "../app/keys.ts";
15 import { style } from "../term/style.ts";
16 import { truncate } from "../term/width.ts";
17 import type { Session } from "../session.ts";
18 import { fill, len, split } from "../ui/layout.ts";
19 import { ListState, drawList, type ListRow } from "./common.ts";
20
21 const part = (counts: Readonly<Record<string, number>>, keys: readonly string[]) =>
22 keys.map((k) => `${k} ${counts[k] ?? 0}`).join(" ");
23
24 export function usageRows(r: readonly RouteUsage[]): ListRow[] {
25 return r.map((u) => ({
26 title: u.pattern,
27 secondary: `${u.total}`,
28 meta: `${part(u.roles, ROLES)} ${part(u.surfaces, SURFACES)} last ${u.last === null ? "never" : `${u.last.replace("T", " ")}:00`}`,
29 }));
30 }
31
32 export function usageScreen(f: Session, ctx: Ctx): Screen {
33 const state = new ListState();
34 const allowed = mayManageFlags(f.user);
35 // Read once when the screen opens: the report scans the whole table, so a frame or a key never re-runs it.
outdated

This runs routeUsage (two GROUP BY passes over all of usage_count, one with no time bound) on every render and every key. Compute it once when the screen opens and keep the array.

36 const rows: RouteUsage[] = allowed ? routeUsage(f.db, knownPatterns(), new Date()) : [];
37 return {
38 title: "Usage",
39 keys: [binding(["j", "k"], "move cursor")],
40 render(area, s) {
41 const c = ctx.theme.c;
42 const [head, list] = split(area, "v", [len(1), fill(1)]);
43 const all = rows;
44 const text = !allowed
45 ? "Usage is read by a forge admin."
46 : `${all.filter((r) => r.total === 0).length} of ${all.length} routes had no request in ${WINDOW_DAYS} days; unused first.`;
47 s.text(head!.x, head!.y, truncate(text, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
48 drawList(s, list!, c, state, usageRows(all));
49 },
outdated

rows().length re-runs the whole report to move the cursor one row. Use the array computed when the screen opened.

50 key: (e) => state.key(e, rows.length),
51 };
52 }
src/web/pages/origins.ts +7
@@ -75,6 +75,7 @@ export type AccountSection =
7575 | "keys"
7676 | "passkeys"
7777 | "origins"
78 | "usage"
7879 | "crew"
7980 | "accounts"
8081 | "subscriptions"
@@ -167,6 +168,12 @@ export function accountSections(
167168 icon: "activity" as const,
168169 current: current === "flags",
169170 },
171 {
172 label: "Usage",
173 href: "/settings/usage",
174 icon: "activity" as const,
175 current: current === "usage",
176 },
170177 {
171178 label: "Backups",
172179 href: "/settings/backups",
src/web/pages/usage.ts added +64
@@ -0,0 +1,64 @@
1 /**
2 * Usage, for a forge admin: every route pattern the router knows with its
3 * requests over the last 30 days by role and by surface. The unused ones
4 * come first, each with its last use or "never": candidates to drop.
5 * Anyone else is told 403, and an agent is never an admin here.
6 */
7
8 import { mayManageFlags } from "../../core/flags.ts";
9 import { ROLES, SURFACES } from "../../core/usage_count.ts";
10 import { routeUsage, WINDOW_DAYS } from "../../core/usage_report.ts";
11 import type { Ctx } from "../../http/context.ts";
12 import { settingsLayout, signInFirst } from "../chrome.ts";
13 import { html } from "../html.ts";
14 import { page } from "../layout.ts";
15 import { btn, card, empty, icon } from "../ui.ts";
16 import { accountSections } from "./origins.ts";
17
18 const part = (counts: Readonly<Record<string, number>>, keys: readonly string[]) =>
19 keys.map((k) => `${k} ${counts[k] ?? 0}`).join(" · ");
20
21 // GET /settings/usage
22 export function usagePage(ctx: Ctx, patterns: () => readonly string[]): Response {

Add a test that an agent account with isAdmin gets 403 here. mayManageFlags handles it, but nothing pins it.

23 const user = ctx.auth.user;
24 if (!user) return signInFirst(ctx);
25 if (!mayManageFlags(user))
26 return page(
27 {
28 ctx,
29 title: "Forbidden",
30 body: html`<div class="page-head"><h1>403</h1></div>
31 ${empty({
32 icon: "lock",
33 title: "Admins only",
34 note: "Usage is read by a forge admin.",
35 action: btn("Go home", { href: "/", variant: "primary" }),
36 })}`,
37 },
38 403,
39 );
40 const rows = routeUsage(ctx.app.db, patterns(), new Date());
41 const unused = rows.filter((r) => r.total === 0).length;
42 const body = card(
43 html`<p class="muted small card-body">${String(unused)} of ${String(rows.length)} routes had no request in
44 ${String(WINDOW_DAYS)} days; they are listed first. Counted by <code>usage_count</code>; one pattern
45 covers every method.</p>
46 <table class="table" aria-label="Route usage">
47 <thead><tr><th scope="col">Pattern</th><th scope="col" class="num">${String(WINDOW_DAYS)} days</th>
48 <th scope="col">By role</th><th scope="col">By surface</th><th scope="col">Last use (UTC)</th></tr></thead>
49 <tbody>${rows.map(
50 (r) => html`<tr data-unused="${r.total === 0 ? "1" : "0"}">
51 <td class="mono">${r.pattern}</td><td class="num mono">${String(r.total)}</td>
52 <td class="mono small">${part(r.roles, ROLES)}</td><td class="mono small">${part(r.surfaces, SURFACES)}</td>
53 <td class="mono">${r.last === null ? "never" : r.last.replace("T", " ") + ":00"}</td></tr>`,
54 )}</tbody>
55 </table>`,
56 { head: html`${icon("activity")}<h2>Usage</h2>`, pad: false },
57 );
58 return page({
59 ctx,
60 title: "Usage",
61 body: html`<div class="page-head"><h1>Usage</h1></div>
62 ${settingsLayout(accountSections("usage", { admin: true }), body, "Account settings")}`,
63 });
64 }
test/tui-tell.test.ts +1 −1
@@ -74,7 +74,7 @@ afterAll(async () => {
7474
7575 describe("tell the crew in the terminal", () => {
7676 test("the owner's palette has the row", async () => {
77 expect(await palette(alice, [":"])).toContain("alice/lobby — Tell the crew");
77 expect(await palette(alice, [":", "tell"])).toContain("alice/lobby — Tell the crew");
7878 });
7979
8080 test("a reader who may not direct the crew sees no row", async () => {
test/tui-usage.test.ts added +81
@@ -0,0 +1,81 @@
1 /** Usage in the terminal (#1565): an admin who is not an agent sees unused routes on top; nobody else has the command. */
2 import { afterAll, beforeAll, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { createApp, type App as Forge } from "../src/app.ts";
7 import { hourOf } from "../src/core/usage_count.ts";
8 import { createUser, getUserByName } from "../src/core/users.ts";
9 import { createForgeApp } from "../src/tui/forge.ts";
10 import { headlessTerminal } from "../src/tui/term/terminal.ts";
11 import { quietLogs } from "./helpers/globals.ts";
12 import { settleApp } from "./helpers/tui.ts";
13
14 quietLogs();
15
16 let dataDir: string;
17 let forge: Forge;
18
19 function boot(username: string) {
20 const user = getUserByName(forge.db, username)!;
21 const { app } = createForgeApp(
22 forge,
23 { sshUsername: "t", remoteAddress: "127.0.0.1", user },
24 { terminal: headlessTerminal(140, 32), exitHooks: false },
25 );
26 app.start();
27 const key = (k: string, ctrl = false) => app.dispatch({ type: "key", key: k, ctrl, alt: false, shift: false });
28 return {
29 text: () => app.screen.toText(),
30 palette: async (word: string) => {
31 key("k", true);
32 for (const ch of word) key(ch);
33 await settleApp(app);
34 },
35 enter: async () => {
36 key("enter");
37 await settleApp(app);
38 },
39 stop: () => app.stop(),
40 };
41 }
42
43 beforeAll(async () => {
44 dataDir = await mkdtemp(join(tmpdir(), "forge-tui-usage-"));
45 forge = createApp({ dataDir, port: 0 });
46 for (const [username, adminIfFirst] of [["alice", true], ["bob", false], ["carol", false]] as const) {
47 const made = await createUser(forge.db, {
48 trusted: true, username, email: `${username}@forge.local`, password: "a-good-passphrase", adminIfFirst,
49 });
50 if (!made.ok) throw new Error(made.error);
51 }
52 forge.db.query("UPDATE user SET is_admin = 1, is_agent = 1 WHERE username = 'carol'").run();
53 forge.db.run(
54 "INSERT INTO usage_count (hour, pattern, method, status, role, surface, n) VALUES (?, '/system', 'GET', '2xx', 'admin', 'web', 5)",
55 [hourOf(new Date())],
56 );
57 });
58 afterAll(async () => {
59 forge.close();
60 await rm(dataDir, { recursive: true, force: true });
61 });
62
63 test("an admin's terminal lists unused routes first, with never, and the count line", async () => {
64 const h = boot("alice");
65 await h.palette("Usage");
66 await h.enter();
67 const text = h.text();
68 expect(text).toMatch(/\d+ of \d+ routes had no request in 30 days; unused first/);
69 expect(text).toContain("last never");
70 expect(text).not.toContain("admin 5"); // the one used route sorts below every unused one
71 h.stop();
72 });
73
74 test("a non-admin and an admin agent have no command", async () => {
75 for (const who of ["bob", "carol"]) {
76 const h = boot(who);
77 await h.palette("Usage");
78 expect(h.text()).not.toContain("every route, unused ones first");
79 h.stop();
80 }
81 });
test/usage-report.test.ts added +95
@@ -0,0 +1,95 @@
1 /** The usage page lists every known route, unused ones first; `usage` is an admin-only boards source (#1565). */
2
3 import { afterAll, beforeAll, expect, test } from "bun:test";
4 import { mkdtemp, rm } from "node:fs/promises";
5 import { tmpdir } from "node:os";
6 import { join } from "node:path";
7 import { createApp, type App } from "../src/app.ts";
8 import { createToken } from "../src/core/tokens.ts";
9 import { createUser } from "../src/core/users.ts";
10 import { hourOf } from "../src/core/usage_count.ts";
11 import { routeUsage } from "../src/core/usage_report.ts";
12 import { buildApiRouter, buildRouter, serve, type Servers } from "../src/http/server.ts";
13
14 let dir: string;
15 let app: App;
16 let servers: Servers;
17 const tok: Record<string, string> = {};
18 const now = new Date("2026-10-16T12:30:00Z");
19 const ago = (days: number) => hourOf(new Date(now.getTime() - days * 86_400_000));
20
21 function seed(hour: string, pattern: string, role: string, surface: string, n: number) {
22 app.db.run(
23 "INSERT INTO usage_count (hour, pattern, method, status, role, surface, n) VALUES (?, ?, 'GET', '2xx', ?, ?, ?)",
24 [hour, pattern, role, surface, n],
25 );
26 }
27
28 beforeAll(async () => {
29 dir = await mkdtemp(join(tmpdir(), "forge-usage-report-"));
30 app = createApp({ dataDir: dir, port: 0 });
31 servers = await serve(app);
32 const mk = async (username: string, adminIfFirst?: boolean) => {
33 const r = await createUser(app.db, {
34 trusted: true, username, email: `${username}@forge.local`,
35 password: "a-good-passphrase", ...(adminIfFirst ? { adminIfFirst } : {}),
36 });
37 if (!r.ok) throw new Error(r.error);
38 return r.value;
39 };
40 tok.admin = createToken(app.db, (await mk("alice", true)).id, "t", "admin").token;
41 const carol = await mk("carol");
42 app.db.query("UPDATE user SET is_admin = 1, is_agent = 1 WHERE id = ?").run(carol.id);
43 tok.carol = createToken(app.db, carol.id, "t", "admin").token;
44 tok.bob = createToken(app.db, (await mk("bob")).id, "t", "admin").token;
45 });
46
47 afterAll(async () => {
48 servers.stop();
49 app.close();
50 await rm(dir, { recursive: true, force: true });
51 });
52
53 test("an unused route is first, with its last use or never; used ones follow by count", () => {
54 seed(ago(1), "/busy", "user", "web", 5);
55 seed(ago(1), "/busy", "agent", "api", 2);
56 seed(ago(2), "/quiet", "admin", "web", 1);
57 seed(ago(45), "/stale", "user", "web", 9); // outside the 30 days
58 const rows = routeUsage(app.db, ["/busy", "/quiet", "/stale", "/never"], now);
59 expect(rows.map((r) => r.pattern)).toEqual(["/never", "/stale", "/busy", "/quiet"]);
60 expect(rows[0]).toMatchObject({ total: 0, last: null });
61 expect(rows[1]).toMatchObject({ total: 0, last: ago(45) });
62 expect(rows[2]).toMatchObject({ total: 7, roles: { user: 5, agent: 2 }, surfaces: { web: 5, api: 2 } });
63 });
64
65 test("the page lists every pattern both routers know, unused first, and only to an admin", async () => {
66 const known = [...buildRouter().patterns(), ...buildApiRouter().patterns()];
67 seed(hourOf(new Date()), "/system", "admin", "web", 3);
68 const get = (who: string | null) =>
69 fetch(`${servers.url}/settings/usage`, { headers: who ? { authorization: `Bearer ${tok[who]}` } : {} });
70 const res = await get("admin");
71 expect(res.status).toBe(200);
72 const text = await res.text();
73 for (const p of known) expect(text).toContain(p.replace(/&/g, "&amp;"));
74 expect(text.indexOf('data-unused="1"')).toBeGreaterThan(-1);
75 expect(text.indexOf('data-unused="1"')).toBeLessThan(text.indexOf('data-unused="0"'));
76 expect(text).toContain("never");
77 expect((await get("bob")).status).toBe(403);
78 expect((await get("carol")).status).toBe(403); // an agent account is never an admin here
79 expect(await (await get(null)).text()).not.toContain("Route usage");
80 });
81
82 test("usage is a boards source for an admin and for nobody else", async () => {
83 const q = (who: string) =>
84 fetch(`${servers.url}/api/v1/boards/query`, {
85 method: "POST",
86 headers: { authorization: `Bearer ${tok[who]}`, "content-type": "application/json" },
87 body: JSON.stringify({ source: "usage", aggregate: "sum", column: "n", groupBy: "pattern" }),
88 });
89 const ok = await q("admin");
90 expect(ok.status).toBe(200);
91 const rows = ((await ok.json()) as { rows: { group: string; value: number }[] }).rows;
92 expect(rows.find((r) => r.group === "/busy")?.value).toBe(7);
93 expect((await q("bob")).status).toBe(403);
94 expect((await q("carol")).status).toBe(403); // an admin agent is refused too
95 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs