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

Design: merge usage, spend, capacity and telemetry into one Usage page #2761

Merged opened this

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

Merged

scout/forge:scout/2692-design-merge-usage-spend-capacity-and-te → main 6 commits fork 5 threads · 5 unresolved

  • Merged. scout/2692-design-merge-usage-spend-capacity-and-te was merged into main as 305752e.

Review

Sign in to review this change.
12 files changed +115 −62
README.md +1 −1
@@ -1564,7 +1564,7 @@ A test file the shard gate clears because it passed alone is still a failure: wh
15641564
15651565 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.
15661566
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.
15681568
15691569 ## Limits
15701570
docs/design/audit.md +2 −1
@@ -33,7 +33,7 @@ their own vocabulary instead of composing the named one.
3333
3434 ## Finding 2 — the pages, and what each is for
3535
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`
3737 that returns a page (`test/design-audit.test.ts` holds this list to the router,
3838 both ways). Each entry reads `route` — module · what it is for · what
3939 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`,
9191 - `/settings/capacity` — `pages/capacity.ts` · how many runs and checks go at once · `/admin/limits`, `/settings/usage` · none seen.
9292 - `/settings/usage` — `pages/usage.ts` · route patterns by use, unused first · `/settings/telemetry`, `/board/returns` · none seen. Not about cost, despite the name.
9393 - `/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).
9495 - `/settings/resilience` — `pages/domains.ts` · failure domains and their last drill · `/resilience` · none seen.
9596 - `/settings/backups` — `pages/backups.ts` (621 lines) · backups and restore · `/settings/resilience`, `/releases` · none seen.
9697 - `/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";
2020 import { contributePage } from "../web/pages/contribute.ts";
2121 import { flagsPage, flagsSet } from "../web/pages/flags.ts";
2222 import { usagePage } from "../web/pages/usage.ts";
23 import { spendPage } from "../web/pages/spend-tab.ts";
2324 import { resiliencePage } from "../web/pages/resilience.ts";
2425 import { limitsPage, limitsSet, quotasSet, signupSet, inviteMint, inviteRevoke } from "../web/pages/admin-limits.ts";
2526 import { boardsPage } from "../web/pages/boards.ts";
@@ -824,6 +825,7 @@ export function buildRouter(): Router<Handler> {
824825 router.add("GET", "/settings/flags", flagsPage);
825826 router.add("POST", "/settings/flags", flagsSet);
826827 router.add("GET", "/settings/usage", (ctx) => usagePage(ctx, knownPatterns));
828 router.add("GET", "/settings/spend", spendPage);
827829 router.add("GET", "/admin/limits", limitsPage);
828830 router.add("POST", "/admin/limits", limitsSet);
829831 router.add("POST", "/admin/quotas", quotasSet);
src/web/pages/capacity.ts +4 −13
@@ -9,12 +9,12 @@
99 import { changeCapacity, listCapacity } from "../../actions/capacity.ts";
1010 import { CAPACITY, type CapacityKey } from "../../core/capacity.ts";
1111 import type { Ctx } from "../../http/context.ts";
12 import { notFoundPage, settingsLayout, signInFirst } from "../chrome.ts";
12 import { notFoundPage, signInFirst } from "../chrome.ts";
1313 import { field, readForm } from "../form.ts";
1414 import { html, when } from "../html.ts";
15 import { flashTo, page } from "../layout.ts";
15 import { flashTo } from "../layout.ts";
outdated

Since usageShell took over, nothing here calls page any more, and settingsLayout on line 12 is also unused. Drop both imports. telemetry.ts has the same two at lines 8 and 11.

1616 import { btn, card, icon, row, time } from "../ui.ts";
17 import { accountSections } from "./origins.ts";
17 import { usageShell } from "./usage-tabs.ts";
1818
1919 function requireAdmin(ctx: Ctx): Response | undefined {
2020 if (!ctx.auth.user) return signInFirst(ctx);
@@ -169,16 +169,7 @@ export function capacityPage(ctx: Ctx): Response {
169169 pad: false,
170170 },
171171 )}`;
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);
182173 }
183174
184175 const ERRORS: Record<string, string> = {
src/web/pages/origins.ts +5 −14
@@ -77,6 +77,7 @@ export type AccountSection =
7777 | "passkeys"
7878 | "origins"
7979 | "usage"
80 | "spend"
8081 | "crew"
8182 | "accounts"
8283 | "people"
@@ -96,6 +97,8 @@ export type AccountSection =
9697 * admin's section — an agent is an account that acts — so it is on the nav
9798 * only for one, the way the repo header hides Settings from a non-admin.
9899 */
100 const USAGE: ReadonlySet<AccountSection> = new Set(["usage", "spend", "capacity", "telemetry"]);
101
99102 export function accountSections(
100103 current: AccountSection,
101104 o: { admin?: boolean } = {},
@@ -139,18 +142,6 @@ export function accountSections(
139142 icon: "key" as const,
140143 current: current === "cloud",
141144 },
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 },
154145 {
155146 label: "Access",
156147 href: "/settings/access",
@@ -171,9 +162,9 @@ export function accountSections(
171162 },
172163 {
173164 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 /settings/usage. Update that paragraph along with this change (see the review body).

175166 icon: "activity" as const,
176 current: current === "usage",
167 current: USAGE.has(current),
177168 },
178169 {
179170 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 {
5454 } from "../ui.ts";
5555 import { whenText } from "../../crew/limits.ts";
5656 import { accountSections } from "./origins.ts";
57 import { readSpend } from "../../actions/spend.ts";
5857 import { liveRegionsJs } from "../client.ts";
59 import { spendView } from "./spend.ts";
6058 import { markOf } from "../../core/marks.ts";
6159 import { limitValue } from "../../settings/limits.ts";
6260
@@ -385,18 +383,7 @@ export function subscriptionsPage(ctx: Ctx): Response {
385383 Claude's tokenizer.
386384 </p>`,
387385 )}
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>
400387 <div class="mt">${add}</div>
401388 ${used === null ? null : html`<div class="mt">${used}</div>`}`,
outdated

This link replaced the spend card, which leaves the readSpend and spendView imports at the top of the file (lines 57 and 59) unused. Remove them.

402389 );
src/web/pages/telemetry.ts +4 −9
@@ -5,12 +5,12 @@
55 import { setTelemetryGrant } from "../../actions/telemetry.ts";
66 import { grantable, recentReads } from "../../core/telemetry.ts";
77 import type { Ctx } from "../../http/context.ts";
8 import { notFoundPage, settingsLayout, signInFirst } from "../chrome.ts";
8 import { notFoundPage, signInFirst } from "../chrome.ts";
99 import { field, readForm } from "../form.ts";
1010 import { html } from "../html.ts";
11 import { flashTo, page } from "../layout.ts";
11 import { flashTo } from "../layout.ts";
1212 import { btn, card, icon, row, time } from "../ui.ts";
13 import { accountSections } from "./origins.ts";
13 import { usageShell } from "./usage-tabs.ts";
1414
1515 function owner(ctx: Ctx): Response | undefined {
1616 if (!ctx.auth.user) return signInFirst(ctx);
@@ -60,12 +60,7 @@ export function telemetryPage(ctx: Ctx): Response {
6060 )}</ul>`,
6161 { head: html`${icon("history")}<h2>Reads</h2>`, pad: false },
6262 )}`;
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);
6964 }
7065
7166 // 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 status, so it is dead for now. Drop it, or keep it only if a tab will render an error page through the shell.

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";
99 import { ROLES, SURFACES } from "../../core/usage_count.ts";
1010 import { routeUsage, WINDOW_DAYS } from "../../core/usage_report.ts";
1111 import type { Ctx } from "../../http/context.ts";
12 import { settingsLayout, signInFirst } from "../chrome.ts";
12 import { signInFirst } from "../chrome.ts";
1313 import { html } from "../html.ts";
1414 import { page } from "../layout.ts";
1515 import { btn, card, empty, icon } from "../ui.ts";
16 import { accountSections } from "./origins.ts";
16 import { usageShell } from "./usage-tabs.ts";
1717
1818 const part = (counts: Readonly<Record<string, number>>, keys: readonly string[]) =>
1919 keys.map((k) => `${k} ${counts[k] ?? 0}`).join(" · ");
@@ -53,12 +53,7 @@ export function usagePage(ctx: Ctx, patterns: () => readonly string[]): Response
5353 <td class="mono">${r.last === null ? "never" : r.last.replace("T", " ") + ":00"}</td></tr>`,
5454 )}</tbody>
5555 </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 <h2>Usage</h2> repeats the page heading. Routes would match the tab.

5757 );
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);
6459 }
test/credentials.test.ts +4 −1
@@ -333,6 +333,9 @@ describe("the board", () => {
333333 ).text();
334334 expect(subs).toContain("platform (env)");
335335 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"');
337340 });
338341 });
test/usage-report.test.ts +19
@@ -94,3 +94,22 @@ test("usage is a boards source for an admin and for nobody else", async () => {
9494 expect((await q("bob")).status).toBe(403);
9595 expect((await q("carol")).status).toBe(403); // an admin agent is refused too
9696 });
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs