Public: anyone can read this; the crew can change it.
Design: merge usage, spend, capacity and telemetry into one Usage page #2761
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2692-design-merge-usage-spend-capacity-and-te → main 6 commits fork 5 threads · 5 unresolved
README.md +1 −1
| @@ -1564,7 +1564,7 @@ A test file the shard gate clears because it passed alone is still a failure: wh | ||
| 1564 | 1564 | |
| 1565 | 1565 | 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. |
| 1566 | 1566 | |
| 1567 | 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. | |
| 1567 | Settings → Usage is one page for a forge admin, opening at `/settings/spend`, with four tabs: Spend, Routes (`/settings/usage`), Capacity (`/settings/capacity`) and Telemetry (`/settings/telemetry`); each tab keeps its own URL and permission. Spend is admin-only (anyone else gets 404) and holds what the crew's runs spent, by credential, app, agent, issue and run kind; it used to sit on Subscriptions, which now links to it. Routes lists every route the router knows: 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 Routes list only — the spend breakdown, capacity and telemetry tabs are web-only, and the terminal has never shown spend; an agent is never an admin there either. | |
| 1568 | 1568 | |
| 1569 | 1569 | ## Limits |
| 1570 | 1570 |
docs/design/audit.md +2 −1
| @@ -33,7 +33,7 @@ their own vocabulary instead of composing the named one. | ||
| 33 | 33 | |
| 34 | 34 | ## Finding 2 — the pages, and what each is for |
| 35 | 35 | |
| 36 | There are 59 top-level pages, one entry each: every static `GET` route in `src/http/server.ts` | |
| 36 | There are 60 top-level pages, one entry each: every static `GET` route in `src/http/server.ts` | |
| 37 | 37 | that returns a page (`test/design-audit.test.ts` holds this list to the router, |
| 38 | 38 | both ways). Each entry reads `route` — module · what it is for · what |
| 39 | 39 | duplicates it · what breaks `docs/DESIGN.md`. "Breaks" counts colour literals |
| @@ -91,6 +91,7 @@ Machine routes are not pages and have no entry: `/audit.csv`, | ||
| 91 | 91 | - `/settings/capacity` — `pages/capacity.ts` · how many runs and checks go at once · `/admin/limits`, `/settings/usage` · none seen. |
| 92 | 92 | - `/settings/usage` — `pages/usage.ts` · route patterns by use, unused first · `/settings/telemetry`, `/board/returns` · none seen. Not about cost, despite the name. |
| 93 | 93 | - `/settings/telemetry` — `pages/telemetry.ts` · which agents may read telemetry · `/settings/crew` (agent grants) · none seen. |
| 94 | - `/settings/spend` — `pages/spend-tab.ts` · what the crew's runs spent, by credential, app, agent, issue and run kind · `/settings/subscriptions`, `/board/returns` · none seen. First tab of Usage (#2692). | |
| 94 | 95 | - `/settings/resilience` — `pages/domains.ts` · failure domains and their last drill · `/resilience` · none seen. |
| 95 | 96 | - `/settings/backups` — `pages/backups.ts` (621 lines) · backups and restore · `/settings/resilience`, `/releases` · none seen. |
| 96 | 97 | - `/settings/flags` — `pages/flags.ts` · the forge's switches · none · none seen. |
src/http/server.ts +2
| @@ -20,6 +20,7 @@ import { telemetryPage, telemetrySet } from "../web/pages/telemetry.ts"; | ||
| 20 | 20 | import { contributePage } from "../web/pages/contribute.ts"; |
| 21 | 21 | import { flagsPage, flagsSet } from "../web/pages/flags.ts"; |
| 22 | 22 | import { usagePage } from "../web/pages/usage.ts"; |
| 23 | import { spendPage } from "../web/pages/spend-tab.ts"; | |
| 23 | 24 | import { resiliencePage } from "../web/pages/resilience.ts"; |
| 24 | 25 | import { limitsPage, limitsSet, quotasSet, signupSet, inviteMint, inviteRevoke } from "../web/pages/admin-limits.ts"; |
| 25 | 26 | import { boardsPage } from "../web/pages/boards.ts"; |
| @@ -824,6 +825,7 @@ export function buildRouter(): Router<Handler> { | ||
| 824 | 825 | router.add("GET", "/settings/flags", flagsPage); |
| 825 | 826 | router.add("POST", "/settings/flags", flagsSet); |
| 826 | 827 | router.add("GET", "/settings/usage", (ctx) => usagePage(ctx, knownPatterns)); |
| 828 | router.add("GET", "/settings/spend", spendPage); | |
| 827 | 829 | router.add("GET", "/admin/limits", limitsPage); |
| 828 | 830 | router.add("POST", "/admin/limits", limitsSet); |
| 829 | 831 | router.add("POST", "/admin/quotas", quotasSet); |
src/web/pages/capacity.ts +4 −13
| @@ -9,12 +9,12 @@ | ||
| 9 | 9 | import { changeCapacity, listCapacity } from "../../actions/capacity.ts"; |
| 10 | 10 | import { CAPACITY, type CapacityKey } from "../../core/capacity.ts"; |
| 11 | 11 | import type { Ctx } from "../../http/context.ts"; |
| 12 | import { notFoundPage, settingsLayout, signInFirst } from "../chrome.ts"; | |
| 12 | import { notFoundPage, signInFirst } from "../chrome.ts"; | |
| 13 | 13 | import { field, readForm } from "../form.ts"; |
| 14 | 14 | import { html, when } from "../html.ts"; |
| 15 | import { flashTo, page } from "../layout.ts"; | |
| 15 | import { flashTo } from "../layout.ts"; | |
| 16 | 16 | import { btn, card, icon, row, time } from "../ui.ts"; |
| 17 | import { accountSections } from "./origins.ts"; | |
| 17 | import { usageShell } from "./usage-tabs.ts"; | |
| 18 | 18 | |
| 19 | 19 | function requireAdmin(ctx: Ctx): Response | undefined { |
| 20 | 20 | if (!ctx.auth.user) return signInFirst(ctx); |
| @@ -169,16 +169,7 @@ export function capacityPage(ctx: Ctx): Response { | ||
| 169 | 169 | pad: false, |
| 170 | 170 | }, |
| 171 | 171 | )}`; |
| 172 | return page({ | |
| 173 | ctx, | |
| 174 | title: "Capacity", | |
| 175 | body: html`<div class="page-head"><h1>Capacity</h1></div> | |
| 176 | ${settingsLayout( | |
| 177 | accountSections("capacity", { admin: true }), | |
| 178 | body, | |
| 179 | "Account settings", | |
| 180 | )}`, | |
| 181 | }); | |
| 172 | return usageShell(ctx, "capacity", body); | |
| 182 | 173 | } |
| 183 | 174 | |
| 184 | 175 | const ERRORS: Record<string, string> = { |
src/web/pages/origins.ts +5 −14
| @@ -77,6 +77,7 @@ export type AccountSection = | ||
| 77 | 77 | | "passkeys" |
| 78 | 78 | | "origins" |
| 79 | 79 | | "usage" |
| 80 | | "spend" | |
| 80 | 81 | | "crew" |
| 81 | 82 | | "accounts" |
| 82 | 83 | | "people" |
| @@ -96,6 +97,8 @@ export type AccountSection = | ||
| 96 | 97 | * admin's section — an agent is an account that acts — so it is on the nav |
| 97 | 98 | * only for one, the way the repo header hides Settings from a non-admin. |
| 98 | 99 | */ |
| 100 | const USAGE: ReadonlySet<AccountSection> = new Set(["usage", "spend", "capacity", "telemetry"]); | |
| 101 | ||
| 99 | 102 | export function accountSections( |
| 100 | 103 | current: AccountSection, |
| 101 | 104 | o: { admin?: boolean } = {}, |
| @@ -139,18 +142,6 @@ export function accountSections( | ||
| 139 | 142 | icon: "key" as const, |
| 140 | 143 | current: current === "cloud", |
| 141 | 144 | }, |
| 142 | { | |
| 143 | label: "Capacity", | |
| 144 | href: "/settings/capacity", | |
| 145 | icon: "activity" as const, | |
| 146 | current: current === "capacity", | |
| 147 | }, | |
| 148 | { | |
| 149 | label: "Telemetry", | |
| 150 | href: "/settings/telemetry", | |
| 151 | icon: "activity" as const, | |
| 152 | current: current === "telemetry", | |
| 153 | }, | |
| 154 | 145 | { |
| 155 | 146 | label: "Access", |
| 156 | 147 | href: "/settings/access", |
| @@ -171,9 +162,9 @@ export function accountSections( | ||
| 171 | 162 | }, |
| 172 | 163 | { |
| 173 | 164 | label: "Usage", |
| 174 | href: "/settings/usage", | |
| 165 | href: "/settings/spend", | |
The sidebar's Usage entry now opens Spend, but the README still says Settings → Usage is |
||
| 175 | 166 | icon: "activity" as const, |
| 176 | current: current === "usage", | |
| 167 | current: USAGE.has(current), | |
| 177 | 168 | }, |
| 178 | 169 | { |
| 179 | 170 | label: "Backups", |
src/web/pages/spend-tab.ts added +24
| @@ -0,0 +1,24 @@ | ||
| 1 | /** | |
| 2 | * Usage → Spend, for a forge admin: what the crew's runs spent, by | |
| 3 | * credential, app, agent, issue and run kind (`pages/spend.ts`). It kept live | |
| 4 | * on the Subscriptions page before; the numbers are the same. | |
| 5 | */ | |
| 6 | ||
| 7 | import { readSpend } from "../../actions/spend.ts"; | |
| 8 | import type { Ctx } from "../../http/context.ts"; | |
| 9 | import { notFoundPage, signInFirst } from "../chrome.ts"; | |
| 10 | import { html } from "../html.ts"; | |
| 11 | import { card, icon } from "../ui.ts"; | |
| 12 | import { spendView } from "./spend.ts"; | |
| 13 | import { usageShell } from "./usage-tabs.ts"; | |
| 14 | ||
| 15 | // GET /settings/spend | |
| 16 | export function spendPage(ctx: Ctx): Response { | |
| 17 | if (!ctx.auth.user) return signInFirst(ctx); | |
| 18 | if (!ctx.auth.user.isAdmin) return notFoundPage(ctx); | |
| 19 | const body = html`<div id="spend" data-live="run.*,usage.*">${card( | |
| 20 | spendView(readSpend(ctx.app, new Date())), | |
| 21 | { head: html`${icon("clock")}<h2>Spend</h2>` }, | |
| 22 | )}</div>`; | |
| 23 | return usageShell(ctx, "spend", body); | |
| 24 | } |
src/web/pages/subscriptions.ts +1 −14
| @@ -54,9 +54,7 @@ import { | ||
| 54 | 54 | } from "../ui.ts"; |
| 55 | 55 | import { whenText } from "../../crew/limits.ts"; |
| 56 | 56 | import { accountSections } from "./origins.ts"; |
| 57 | import { readSpend } from "../../actions/spend.ts"; | |
| 58 | 57 | import { liveRegionsJs } from "../client.ts"; |
| 59 | import { spendView } from "./spend.ts"; | |
| 60 | 58 | import { markOf } from "../../core/marks.ts"; |
| 61 | 59 | import { limitValue } from "../../settings/limits.ts"; |
| 62 | 60 | |
| @@ -385,18 +383,7 @@ export function subscriptionsPage(ctx: Ctx): Response { | ||
| 385 | 383 | Claude's tokenizer. |
| 386 | 384 | </p>`, |
| 387 | 385 | )} |
| 388 | <div class="mt" id="spend" data-live="run.*,usage.*"> | |
| 389 | ${card( | |
| 390 | html`<details id="spend-detail" open> | |
| 391 | <summary>By credential, app, agent, issue and run kind</summary> | |
| 392 | ${spendView(readSpend(ctx.app, now))} | |
| 393 | </details>`, | |
| 394 | { | |
| 395 | head: html`${icon("clock")} | |
| 396 | <h2>Spend</h2>`, | |
| 397 | }, | |
| 398 | )} | |
| 399 | </div> | |
| 386 | <p class="mt muted small">What these accounts spent, by credential, app, agent, issue and run kind: <a href="/settings/spend">Usage → Spend</a>.</p> | |
| 400 | 387 | <div class="mt">${add}</div> |
| 401 | 388 | ${used === null ? null : html`<div class="mt">${used}</div>`}`, |
outdated
This link replaced the spend card, which leaves the |
||
| 402 | 389 | ); |
src/web/pages/telemetry.ts +4 −9
| @@ -5,12 +5,12 @@ | ||
| 5 | 5 | import { setTelemetryGrant } from "../../actions/telemetry.ts"; |
| 6 | 6 | import { grantable, recentReads } from "../../core/telemetry.ts"; |
| 7 | 7 | import type { Ctx } from "../../http/context.ts"; |
| 8 | import { notFoundPage, settingsLayout, signInFirst } from "../chrome.ts"; | |
| 8 | import { notFoundPage, signInFirst } from "../chrome.ts"; | |
| 9 | 9 | import { field, readForm } from "../form.ts"; |
| 10 | 10 | import { html } from "../html.ts"; |
| 11 | import { flashTo, page } from "../layout.ts"; | |
| 11 | import { flashTo } from "../layout.ts"; | |
| 12 | 12 | import { btn, card, icon, row, time } from "../ui.ts"; |
| 13 | import { accountSections } from "./origins.ts"; | |
| 13 | import { usageShell } from "./usage-tabs.ts"; | |
| 14 | 14 | |
| 15 | 15 | function owner(ctx: Ctx): Response | undefined { |
| 16 | 16 | if (!ctx.auth.user) return signInFirst(ctx); |
| @@ -60,12 +60,7 @@ export function telemetryPage(ctx: Ctx): Response { | ||
| 60 | 60 | )}</ul>`, |
| 61 | 61 | { head: html`${icon("history")}<h2>Reads</h2>`, pad: false }, |
| 62 | 62 | )}`; |
| 63 | return page({ | |
| 64 | ctx, | |
| 65 | title: "Telemetry", | |
| 66 | body: html`<div class="page-head"><h1>Telemetry</h1></div> | |
| 67 | ${settingsLayout(accountSections("telemetry", { admin: true }), body, "Account settings")}`, | |
| 68 | }); | |
| 63 | return usageShell(ctx, "telemetry", body); | |
| 69 | 64 | } |
| 70 | 65 | |
| 71 | 66 | // POST /settings/telemetry |
src/web/pages/usage-tabs.ts added +45
| @@ -0,0 +1,45 @@ | ||
| 1 | /** | |
| 2 | * The one Usage page of Settings: what is used (spend), what is asked | |
| 3 | * (routes), what is full (capacity) and who may read the telemetry. Each tab | |
| 4 | * is its own URL, so a link and a back button keep working; this renders the | |
| 5 | * frame they share. | |
| 6 | */ | |
| 7 | ||
| 8 | import type { Html } from "../html.ts"; | |
| 9 | import { settingsLayout } from "../chrome.ts"; | |
| 10 | import { html } from "../html.ts"; | |
| 11 | import { liveRegionsJs } from "../client.ts"; | |
| 12 | import { page } from "../layout.ts"; | |
| 13 | import { tabs } from "../ui.ts"; | |
| 14 | import type { Ctx } from "../../http/context.ts"; | |
| 15 | import { accountSections } from "./origins.ts"; | |
| 16 | ||
| 17 | export type UsageTab = "spend" | "routes" | "capacity" | "telemetry"; | |
| 18 | ||
| 19 | const TABS: readonly { id: UsageTab; label: string; href: string }[] = [ | |
| 20 | { id: "spend", label: "Spend", href: "/settings/spend" }, | |
| 21 | { id: "routes", label: "Routes", href: "/settings/usage" }, | |
| 22 | { id: "capacity", label: "Capacity", href: "/settings/capacity" }, | |
| 23 | { id: "telemetry", label: "Telemetry", href: "/settings/telemetry" }, | |
| 24 | ]; | |
| 25 | ||
| 26 | /** The Usage page around `body`, with `current` marked in the tabs. */ | |
| 27 | export function usageShell(ctx: Ctx, current: UsageTab, body: Html, status?: number): Response { | |
Nit: no caller passes |
||
| 28 | return page( | |
| 29 | { | |
| 30 | ctx, | |
| 31 | title: "Usage", | |
| 32 | scriptJs: liveRegionsJs, | |
| 33 | body: html`<div class="page-head"><h1>Usage</h1></div> | |
| 34 | ${settingsLayout( | |
| 35 | accountSections("usage", { admin: true }), | |
| 36 | html`${tabs( | |
| 37 | TABS.map((t) => ({ label: t.label, href: t.href, current: t.id === current })), | |
| 38 | "Usage", | |
| 39 | )}${body}`, | |
| 40 | "Account settings", | |
| 41 | )}`, | |
| 42 | }, | |
| 43 | status, | |
| 44 | ); | |
| 45 | } |
src/web/pages/usage.ts +4 −9
| @@ -9,11 +9,11 @@ import { mayManageFlags } from "../../core/flags.ts"; | ||
| 9 | 9 | import { ROLES, SURFACES } from "../../core/usage_count.ts"; |
| 10 | 10 | import { routeUsage, WINDOW_DAYS } from "../../core/usage_report.ts"; |
| 11 | 11 | import type { Ctx } from "../../http/context.ts"; |
| 12 | import { settingsLayout, signInFirst } from "../chrome.ts"; | |
| 12 | import { signInFirst } from "../chrome.ts"; | |
| 13 | 13 | import { html } from "../html.ts"; |
| 14 | 14 | import { page } from "../layout.ts"; |
| 15 | 15 | import { btn, card, empty, icon } from "../ui.ts"; |
| 16 | import { accountSections } from "./origins.ts"; | |
| 16 | import { usageShell } from "./usage-tabs.ts"; | |
| 17 | 17 | |
| 18 | 18 | const part = (counts: Readonly<Record<string, number>>, keys: readonly string[]) => |
| 19 | 19 | keys.map((k) => `${k} ${counts[k] ?? 0}`).join(" · "); |
| @@ -53,12 +53,7 @@ export function usagePage(ctx: Ctx, patterns: () => readonly string[]): Response | ||
| 53 | 53 | <td class="mono">${r.last === null ? "never" : r.last.replace("T", " ") + ":00"}</td></tr>`, |
| 54 | 54 | )}</tbody> |
| 55 | 55 | </table>`, |
| 56 | { head: html`${icon("activity")}<h2>Usage</h2>`, pad: false }, | |
| 56 | { head: html`${icon("activity")}<h2>Routes</h2>`, pad: false }, | |
outdated
Nit: this card is now the Routes tab inside a page titled Usage, so its |
||
| 57 | 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 | }); | |
| 58 | return usageShell(ctx, "routes", body); | |
| 64 | 59 | } |
test/credentials.test.ts +4 −1
| @@ -333,6 +333,9 @@ describe("the board", () => { | ||
| 333 | 333 | ).text(); |
| 334 | 334 | expect(subs).toContain("platform (env)"); |
| 335 | 335 | expect(subs).toContain("not offered to the crew"); |
| 336 | expect(subs).toContain('data-by="byCredential"'); | |
| 336 | expect(subs).toContain('href="/settings/spend"'); | |
| 337 | ||
| 338 | const spend = await (await fetch(`${servers.url}/settings/spend`, { headers: { cookie } })).text(); | |
| 339 | expect(spend).toContain('data-by="byCredential"'); | |
| 337 | 340 | }); |
| 338 | 341 | }); |
test/usage-report.test.ts +19
| @@ -94,3 +94,22 @@ test("usage is a boards source for an admin and for nobody else", async () => { | ||
| 94 | 94 | expect((await q("bob")).status).toBe(403); |
| 95 | 95 | expect((await q("carol")).status).toBe(403); // an admin agent is refused too |
| 96 | 96 | }); |
| 97 | ||
| 98 | test("Usage is one page: the four tabs on every tab, Spend admin-only, one sidebar entry", async () => { | |
| 99 | const get = (path: string, who: string) => | |
| 100 | fetch(`${servers.url}${path}`, { headers: { authorization: `Bearer ${tok[who]}` } }); | |
| 101 | for (const [path, current] of [ | |
| 102 | ["/settings/spend", "Spend"], | |
| 103 | ["/settings/usage", "Routes"], | |
| 104 | ["/settings/capacity", "Capacity"], | |
| 105 | ["/settings/telemetry", "Telemetry"], | |
| 106 | ] as const) { | |
| 107 | const text = await (await get(path, "admin")).text(); | |
| 108 | for (const label of ["Spend", "Routes", "Capacity", "Telemetry"]) | |
| 109 | expect(text).toMatch(new RegExp(`href="/settings/[a-z]+"[^>]*>\\s*${label}\\s*</a\\s*>`)); | |
| 110 | expect(text).toMatch(new RegExp(`href="${path}"\\s+aria-current="page"[^>]*>\\s*${current}\\s*</a\\s*>`)); | |
| 111 | expect(text.match(/data-nav/g) ?? []).not.toHaveLength(0); | |
| 112 | expect(text).not.toMatch(/data-nav[^>]*>[^<]*(<svg[\s\S]*?<\/svg>)?\s*(Capacity|Telemetry)\s*</); | |
| 113 | } | |
| 114 | expect((await get("/settings/spend", "bob")).status).toBe(404); | |
| 115 | }); |
Since
usageShelltook over, nothing here callspageany more, andsettingsLayouton line 12 is also unused. Drop both imports.telemetry.tshas the same two at lines 8 and 11.