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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/1565-the-usage-table-is-a-boards-source-and-a → main 4 commits fork 8 threads · 8 unresolved
README.md +2
| @@ -1136,6 +1136,8 @@ While the default branch's own last check is red, the forge holds its pulls' che | ||
| 1136 | 1136 | |
| 1137 | 1137 | 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. |
| 1138 | 1138 | |
| 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 | ||
| 1139 | 1141 | ## Limits |
| 1140 | 1142 | |
| 1141 | 1143 | 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>> = { | ||
| 98 | 98 | outcome: "text", at: "time", |
| 99 | 99 | }, |
| 100 | 100 | }, |
| 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 | |
| 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 | }, | |
| 101 | 121 | // What the machine used (`boards/sampler.ts`, kept by `boards/ring.ts`): |
| 102 | 122 | // 15-s points for the last day, 5-min points before that, never both |
| 103 | 123 | // 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: Medium, performance. |
||
| 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> { | ||
| 68 | 68 | return [...heads].sort(); |
| 69 | 69 | } |
| 70 | 70 | |
| 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 | ||
| 71 | 76 | add(method: string, pattern: string, handler: H): this { |
| 72 | 77 | this.routes.push({ method, pattern, segments: compile(pattern), handler }); |
| 73 | 78 | return this; |
src/http/server.ts +8
| @@ -15,6 +15,7 @@ import { systemPage } from "../web/pages/system.ts"; | ||
| 15 | 15 | import { telemetryPage, telemetrySet } from "../web/pages/telemetry.ts"; |
| 16 | 16 | import { contributePage } from "../web/pages/contribute.ts"; |
| 17 | 17 | import { flagsPage, flagsSet } from "../web/pages/flags.ts"; |
| 18 | import { usagePage } from "../web/pages/usage.ts"; | |
| 18 | 19 | import { domainsPage } from "../web/pages/domains.ts"; |
| 19 | 20 | import { resiliencePage } from "../web/pages/resilience.ts"; |
| 20 | 21 | import { limitsPage, limitsSet, quotasSet, signupSet } from "../web/pages/admin-limits.ts"; |
| @@ -763,6 +764,7 @@ export function buildRouter(): Router<Handler> { | ||
| 763 | 764 | router.add("POST", "/boards/:slug/:op", boardPost); |
| 764 | 765 | router.add("GET", "/settings/flags", flagsPage); |
| 765 | 766 | router.add("POST", "/settings/flags", flagsSet); |
| 767 | router.add("GET", "/settings/usage", (ctx) => usagePage(ctx, knownPatterns)); | |
| 766 | 768 | router.add("GET", "/admin/limits", limitsPage); |
| 767 | 769 | router.add("POST", "/admin/limits", limitsSet); |
outdated
Only the web door gets this. The terminal needs the same report (a |
||
| 768 | 770 | router.add("POST", "/admin/quotas", quotasSet); |
| @@ -1288,6 +1290,12 @@ export function buildApiRouter(): Router<Handler> { | ||
| 1288 | 1290 | return router; |
| 1289 | 1291 | } |
| 1290 | 1292 | |
| 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 | ||
| 1291 | 1299 | async function withGitAccess( |
| 1292 | 1300 | ctx: Ctx, |
| 1293 | 1301 | service: GitService, |
src/tui/forge.ts +9
| @@ -42,6 +42,7 @@ import { dashboardScreen } from "./screens/dashboard.ts"; | ||
| 42 | 42 | import { mayManageFlags } from "../core/flags.ts"; |
| 43 | 43 | import { accessScreen } from "./screens/access.ts"; |
| 44 | 44 | import { flagsScreen } from "./screens/flags.ts"; |
| 45 | import { usageScreen } from "./screens/usage.ts"; | |
| 45 | 46 | import { quotasScreen } from "./screens/quotas.ts"; |
| 46 | 47 | import { domainsScreen } from "./screens/domains.ts"; |
| 47 | 48 | import { resilienceLines, resilienceScreen } from "./screens/resilience.ts"; |
| @@ -345,6 +346,14 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] { | ||
| 345 | 346 | hint: "declared flags and their live values", |
| 346 | 347 | run: () => ctx.push(flagsScreen(session, ctx)), |
| 347 | 348 | }); |
| 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 | }); | |
| 348 | 357 | // Account quotas, as /admin/limits has them: an admin who is not an agent (#2191). |
| 349 | 358 | if (session.user?.isAdmin === true && session.user.kind !== "agent") |
| 350 | 359 | 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 |
||
| 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 |
||
| 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
|
||
| 50 | key: (e) => state.key(e, rows.length), | |
| 51 | }; | |
| 52 | } |
src/web/pages/origins.ts +7
| @@ -75,6 +75,7 @@ export type AccountSection = | ||
| 75 | 75 | | "keys" |
| 76 | 76 | | "passkeys" |
| 77 | 77 | | "origins" |
| 78 | | "usage" | |
| 78 | 79 | | "crew" |
| 79 | 80 | | "accounts" |
| 80 | 81 | | "subscriptions" |
| @@ -167,6 +168,12 @@ export function accountSections( | ||
| 167 | 168 | icon: "activity" as const, |
| 168 | 169 | current: current === "flags", |
| 169 | 170 | }, |
| 171 | { | |
| 172 | label: "Usage", | |
| 173 | href: "/settings/usage", | |
| 174 | icon: "activity" as const, | |
| 175 | current: current === "usage", | |
| 176 | }, | |
| 170 | 177 | { |
| 171 | 178 | label: "Backups", |
| 172 | 179 | 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 |
||
| 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 () => { | ||
| 74 | 74 | |
| 75 | 75 | describe("tell the crew in the terminal", () => { |
| 76 | 76 | 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"); | |
| 78 | 78 | }); |
| 79 | 79 | |
| 80 | 80 | 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, "&")); | |
| 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 | }); |
Low:
fromandtoare off by one hour for this source.runQueryturns them intonew Date(v).toISOString(), for example2026-10-16T12:00:00.000Z, and compares that withhouras a string. Because"2026-10-16T12" < "2026-10-16T12:00:00.000Z",fromdrops its own hour andtoincludes the hour it should stop before. Fix: storehouras a full ISO instant (…T13:00:00.000Z), or have the query trim the bound to 13 characters whensrc.timeis an hour column. Add a test with afromon an hour boundary.