Public: anyone can read this; the crew can change it.
The terminal's Usage screen opens the fleet graph with `f`: your repositories' spend and runs per hour over 24 h and per day over 14 d #8059
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/8048-the-terminal-s-usage-screen-opens-the-fl → main 3 commits fork 4 threads · 4 unresolved
4 files
changed +295
−1
README.md +1 −1
| @@ -501,7 +501,7 @@ This page is the front door. Everything else is a file of its own under | ||
| 501 | 501 | - [](docs/DESIGN.md) — the design system's spine; its sections are under `docs/design/sections/`. |
| 502 | 502 | - [](docs/SPIKE-CLOUDFLARE.md), [](docs/AUDIT-MANY-HANDS.md), [](docs/BACKLOG.md) and [](docs/research/) — a spike, an audit, a backlog and research notes, kept as written. |
| 503 | 503 | |
| 504 | What the crew's tokens bought is on the board (`/board/returns`), in MCP `returns`, and in the terminal: the palette's Usage carries a Returns line (dollars, runs, changes landed, cost per landed change; `[`/`]` move between 1, 7, 30 and 90 days), and Usage of a repository (`owner/name`) narrows it to one app. Owner facts only (`src/actions/returns.ts`); see [](docs/OPERATING.md). | |
| 504 | What the crew's tokens bought is on the board (`/board/returns`), in MCP `returns`, and in the terminal: the palette's Usage carries a Returns line (dollars, runs, changes landed, cost per landed change; `[`/`]` move between 1, 7, 30 and 90 days), and Usage of a repository (`owner/name`) narrows it to one app. On the forge-wide Usage, `f` opens the fleet graph (what `/fleet/graph` shows): one sparkline row per repository for runs and for spend, each with its total at the right, over 24 h by hour or 14 d by day (`w` toggles); past eight series the smallest share "N others", and a short terminal ends with a "+N more rows" line. Who may see spend is the action's rule (`src/actions/fleet-graph.ts`), not the screen's. Owner facts only (`src/actions/returns.ts`); see [](docs/OPERATING.md). | |
| 505 | 505 | |
| 506 | 506 | The terminal's palette has `Signed in` for anyone signed in: your browsers, tokens and this terminal's key, with when each was last seen; `d` signs the selected browser out after a confirm, as `/settings` and MCP `sessions` do, and on a token revokes it after a confirm (Keep is the default) through the same `deleteToken` action as Settings and the API, so it stops working at once (`docs/CREW.md`, "Where you are signed in"). |
| 507 | 507 |
src/tui/screens/fleetgraph.ts added +87
| @@ -0,0 +1,87 @@ | ||
| 1 | /** | |
| 2 | * The fleet graph in the terminal: your repositories' runs and spend, one | |
| 3 | * sparkline row per series (`FLEET_GRAPH_MAX_SERIES` at most, the rest folded | |
| 4 | * as "N others"), over the last 24 hours by hour or 14 days by day, `w` | |
| 5 | * toggling. It reads `readFleetGraph`, the page's, CSV's and API's own data, | |
| 6 | * so who may see spend is that action's rule, never this screen's. | |
| 7 | */ | |
| 8 | ||
| 9 | import { FLEET_GRAPH_MAX_SERIES, readFleetGraph, type FleetWindow, type FleetWindowName } from "../../actions/fleet-graph.ts"; | |
| 10 | import { usd } from "../../core/money.ts"; | |
| 11 | import { spark } from "../../system/format.ts"; | |
| 12 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 13 | import { binding } from "../app/keys.ts"; | |
| 14 | import type { Session } from "../session.ts"; | |
| 15 | import { style } from "../term/style.ts"; | |
| 16 | import { pad, truncate } from "../term/width.ts"; | |
| 17 | import { fill, len, split } from "../ui/layout.ts"; | |
| 18 | ||
| 19 | /** The graph is never narrower than this many columns. */ | |
| 20 | const MIN_GRAPH = 10; | |
| 21 | /** A name is never padded past this, so the rows stay together on a wide terminal. */ | |
| 22 | const NAME_MAX = 32; | |
| 23 | ||
| 24 | /** At most `n` readings, each the sum of its stretch, so a narrow terminal still shows the whole window. */ | |
| 25 | function shrink(xs: readonly number[], n: number): number[] { | |
| 26 | if (xs.length <= n) return [...xs]; | |
| 27 | return Array.from({ length: n }, (_, i) => | |
| 28 | xs.slice(Math.floor((i * xs.length) / n), Math.floor(((i + 1) * xs.length) / n)).reduce((a, b) => a + b, 0), | |
| 29 | ); | |
| 30 | } | |
| 31 | ||
| 32 | /** One row: name, sparkline scaled to its own peak, total at the right; the graph keeps `MIN_GRAPH` columns, the name gives way. */ | |
| 33 | export function graphRow(label: string, xs: readonly number[], total: string, width: number): string { | |
| 34 | const totalW = total.length; | |
| 35 | const graphW = Math.max(MIN_GRAPH, Math.min(xs.length, width - totalW - 2 - 8)); | |
| 36 | const nameW = Math.max(0, Math.min(NAME_MAX, width - graphW - totalW - 2)); | |
| 37 | const cells = shrink(xs, graphW); | |
| 38 | const peak = Math.max(0, ...cells); | |
| 39 | const line = spark(cells.map((x) => (peak > 0 ? (x / peak) * 100 : 0))); | |
| 40 | return `${pad(truncate(label, nameW), nameW)} ${pad(line, graphW)} ${total}`; | |
| 41 | } | |
| 42 | ||
| 43 | /** The screen's lines for one window: runs, then spend; or the empty sentence. */ | |
| 44 | export function fleetLines(w: FleetWindow, labels: readonly string[], width: number): { runs: string[]; spend: string[] } | undefined { | |
| 45 | const any = w.totals.runs.some((n) => n > 0) || w.totals.costUsd.some((n) => n > 0); | |
| 46 | if (!any) return undefined; | |
| 47 | const rows = (pick: "runs" | "costUsd", say: (n: number) => string): string[] => { | |
| 48 | const totals = labels.map((_, i) => say(w.totals[pick][i]!)); | |
| 49 | const wide = Math.max(0, ...totals.map((t) => t.length)); | |
| 50 | return labels.map((label, i) => graphRow(label, w.buckets.map((b) => b[pick][i]!), pad(totals[i]!, wide, "right"), width)); | |
| 51 | }; | |
| 52 | return { runs: rows("runs", (n) => `${n} ${n === 1 ? "run" : "runs"}`), spend: rows("costUsd", usd) }; | |
| 53 | } | |
| 54 | ||
| 55 | export function fleetGraphScreen(f: Session, ctx: Ctx): Screen { | |
| 56 | let which: FleetWindowName = "24h"; | |
| 57 | // Read once when the screen opens; a frame or a key never re-runs the ledger scan. | |
| 58 | const graph = f.user === undefined ? undefined : readFleetGraph(f.app, f.user, null); | |
| 59 | const labels = (graph?.series ?? []).slice(0, FLEET_GRAPH_MAX_SERIES).map((s) => s.label); | |
| 60 | return { | |
| 61 | title: "Fleet graph", | |
| 62 | keys: [binding(["w"], "24 h / 14 d")], | |
| 63 | render(area, s) { | |
| 64 | const c = ctx.theme.c; | |
| 65 | const w = graph?.windows[which === "24h" ? 0 : 1]; | |
| 66 | const [head, body] = split(area, "v", [len(1), fill(1)]); | |
| 67 | const unit = which === "24h" ? "per hour over 24 h" : "per day over 14 d"; | |
| 68 | s.text(head!.x, head!.y, truncate(`Runs and spend ${unit} (w: 24 h / 14 d), UTC`, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w); | |
| 69 | const lines = w === undefined ? undefined : fleetLines(w, labels, body!.w); | |
| 70 | const out: [string, boolean][] = | |
| 71 | lines === undefined | |
| 72 | ? [["Nothing spent in this window.", false]] | |
| 73 | : [["Runs", true], ...lines.runs.map((l): [string, boolean] => [l, false]), ["", false], ["Spend", true], ...lines.spend.map((l): [string, boolean] => [l, false])]; | |
| 74 | // A short terminal says what it cut rather than dropping rows silently. | |
| 75 | const shown: [string, boolean][] = | |
| 76 | out.length <= body!.h ? out : [...out.slice(0, Math.max(0, body!.h - 1)), [`+${out.length - Math.max(0, body!.h - 1)} more rows (make the terminal taller)`, false]]; | |
| 77 | shown.forEach(([text, heading], i) => { | |
| 78 | s.text(body!.x, body!.y + i, truncate(text, body!.w), style(heading ? c.fg : c.mutedFg, c.bg), body!.x + body!.w); | |
| 79 | }); | |
| 80 | }, | |
| 81 | key(e) { | |
| 82 | if (e.ctrl || e.alt || e.key !== "w") return false; | |
| 83 | which = which === "24h" ? "14d" : "24h"; | |
| 84 | return true; | |
| 85 | }, | |
| 86 | }; | |
| 87 | } |
src/tui/screens/usage.ts +6
| @@ -26,6 +26,7 @@ import { style } from "../term/style.ts"; | ||
| 26 | 26 | import { truncate } from "../term/width.ts"; |
| 27 | 27 | import type { Session } from "../session.ts"; |
| 28 | 28 | import { fill, len, split } from "../ui/layout.ts"; |
| 29 | import { fleetGraphScreen } from "./fleetgraph.ts"; | |
| 29 | 30 | import { ListState, drawList, type ListRow } from "./common.ts"; |
| 30 | 31 | |
| 31 | 32 | const part = (counts: Readonly<Record<string, number>>, keys: readonly string[]) => |
| @@ -73,6 +74,7 @@ export function usageScreen(f: Session, ctx: Ctx, repo?: Repo): Screen { | ||
| 73 | 74 | keys: [ |
| 74 | 75 | binding(["j", "k"], "move cursor", { hidden: rows.length === 0 }), |
| 75 | 76 | binding(["[", "]"], "returns window", { hidden: returns === undefined }), |
| 77 | binding(["f"], "fleet graph", { hidden: repo !== undefined }), | |
Hidden on a repository's Usage, but no test proves |
||
| 76 | 78 | ], |
| 77 | 79 | render(area, s) { |
| 78 | 80 | const c = ctx.theme.c; |
| @@ -91,6 +93,10 @@ export function usageScreen(f: Session, ctx: Ctx, repo?: Repo): Screen { | ||
| 91 | 93 | if (repo === undefined) drawList(s, list!, c, state, usageRows(all)); |
| 92 | 94 | }, |
| 93 | 95 | key: (e) => { |
| 96 | if (repo === undefined && !e.ctrl && !e.alt && e.key === "f") { | |
| 97 | ctx.push(fleetGraphScreen(f, ctx)); | |
| 98 | return true; | |
| 99 | } | |
| 94 | 100 | if (returns !== undefined && !e.ctrl && !e.alt && (e.key === "[" || e.key === "]")) { |
| 95 | 101 | const next = Math.max(0, Math.min(RETURNS_SPANS.length - 1, span + (e.key === "]" ? 1 : -1))); |
| 96 | 102 | if (next === span) return true; |
test/tui-fleetgraph.test.ts added +201
| @@ -0,0 +1,201 @@ | ||
| 1 | /** | |
| 2 | * The terminal's fleet graph (#8048): `f` on Usage opens it; with usage seeded in two | |
| 3 | * repositories the screen names both and their totals are `readFleetGraph`'s; `w` moves | |
| 4 | * between 24 h and 14 d; an empty ledger, or a viewer who may not see the spend, reads | |
| 5 | * "Nothing spent in this window." | |
| 6 | */ | |
| 7 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 8 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { readFleetGraph } from "../src/actions/fleet-graph.ts"; | |
| 12 | import { createRepo } from "../src/actions/repos.ts"; | |
| 13 | import { createApp, type App as Forge } from "../src/app.ts"; | |
| 14 | import { usd } from "../src/core/money.ts"; | |
| 15 | import { createRun } from "../src/core/runs.ts"; | |
| 16 | import { recordUsage } from "../src/core/usage.ts"; | |
| 17 | import { createUser, getUserByName } from "../src/core/users.ts"; | |
| 18 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 19 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 20 | import { quietLogs } from "./helpers/globals.ts"; | |
| 21 | import { settleApp } from "./helpers/tui.ts"; | |
| 22 | ||
| 23 | quietLogs(); | |
| 24 | ||
| 25 | let dataDir: string; | |
| 26 | let forge: Forge; | |
| 27 | const ids: Record<string, string> = {}; | |
| 28 | ||
| 29 | function boot(username: string, cols = 120, rows = 32, on: Forge = forge) { | |
| 30 | const user = getUserByName(on.db, username)!; | |
| 31 | const { app } = createForgeApp( | |
| 32 | on, | |
| 33 | { sshUsername: "t", remoteAddress: "127.0.0.1", user }, | |
| 34 | { terminal: headlessTerminal(cols, rows), exitHooks: false }, | |
| 35 | ); | |
| 36 | app.start(); | |
| 37 | const key = async (k: string, ctrl = false) => { | |
| 38 | app.dispatch({ type: "key", key: k, ctrl, alt: false, shift: false }); | |
| 39 | await settleApp(app); | |
| 40 | }; | |
| 41 | return { | |
| 42 | text: () => app.screen.toText(), | |
| 43 | key, | |
| 44 | open: async () => { | |
| 45 | await key("k", true); | |
| 46 | for (const ch of "Usage") await key(ch); | |
| 47 | await key("enter"); | |
| 48 | await key("f"); | |
| 49 | }, | |
| 50 | /** The palette's "Usage of a repository" for `spec`. */ | |
| 51 | openRepoUsage: async (spec: string) => { | |
| 52 | await key("k", true); | |
| 53 | for (const ch of "Usage of a repository") await key(ch); | |
| 54 | await key("enter"); | |
| 55 | for (const ch of spec) await key(ch); | |
| 56 | await key("enter"); | |
| 57 | }, | |
| 58 | stop: () => app.stop(), | |
| 59 | }; | |
| 60 | } | |
| 61 | ||
| 62 | function spend(repo: string, usdSpent: number, msAgo: number): void { | |
| 63 | recordUsage(forge.db, { | |
| 64 | runId: createRun(forge.db, { repoId: ids[repo]!, kind: "agent" }).id, | |
| 65 | repoId: ids[repo]!, issueId: null, agentId: null, subscriptionId: null, | |
| 66 | tokensIn: 1, tokensOut: 1, costUsd: usdSpent, wallMs: 1, outcome: "ok", | |
| 67 | at: new Date(Date.now() - msAgo).toISOString(), | |
| 68 | }); | |
| 69 | } | |
| 70 | ||
| 71 | beforeAll(async () => { | |
| 72 | dataDir = await mkdtemp(join(tmpdir(), "forge-tui-fleetgraph-")); | |
| 73 | forge = createApp({ dataDir, port: 0 }); | |
| 74 | for (const [username, adminIfFirst] of [["alice", true], ["bob", false]] as const) { | |
| 75 | const made = await createUser(forge.db, { | |
| 76 | origin: "seed", trusted: true, username, email: `${username}@forge.local`, password: "a-good-passphrase", adminIfFirst, | |
| 77 | }); | |
| 78 | if (!made.ok) throw new Error(made.error); | |
| 79 | } | |
| 80 | const alice = getUserByName(forge.db, "alice")!; | |
| 81 | for (const name of ["mill", "kiln"]) { | |
| 82 | const made = await createRepo(forge, alice, { name, visibility: "public" }); | |
| 83 | if (!made.ok) throw new Error(String(made.error)); | |
| 84 | ids[name] = made.value.id; | |
| 85 | } | |
| 86 | spend("mill", 2.5, 60_000); | |
| 87 | spend("mill", 1, 60_000); | |
| 88 | spend("kiln", 4, 60_000); | |
| 89 | spend("kiln", 0.25, 3 * 86_400_000); // only in the 14-day window | |
| 90 | }, 30_000); | |
| 91 | afterAll(async () => { | |
| 92 | forge.close(); | |
| 93 | await rm(dataDir, { recursive: true, force: true }); | |
| 94 | }); | |
| 95 | ||
| 96 | test("f opens the graph: both repositories, totals as readFleetGraph answers, w moves to 14 d", async () => { | |
| 97 | const graph = readFleetGraph(forge, getUserByName(forge.db, "alice")!, null); | |
| 98 | const h = boot("alice"); | |
| 99 | await h.open(); | |
| 100 | const day = h.text(); | |
| 101 | expect(day).toContain("alice/mill"); | |
| 102 | expect(day).toContain("alice/kiln"); | |
| 103 | graph.series.forEach((s, i) => { | |
| 104 | expect(day).toContain(usd(graph.windows[0].totals.costUsd[i]!)); | |
| 105 | expect(day).toContain(`${graph.windows[0].totals.runs[i]} ${graph.windows[0].totals.runs[i] === 1 ? "run" : "runs"}`); | |
| 106 | expect(s.label).toBeTruthy(); | |
| 107 | }); | |
| 108 | expect(day).toContain("$4.00"); | |
| 109 | expect(day).toContain("$3.50"); | |
outdated
Vacuous: bob cannot reach Usage, so these assertions hold with or without the feature. Test a path that reaches the empty-state sentence, or assert bob never gets the screen. |
||
| 110 | expect(day).toContain("over 24 h"); | |
| 111 | await h.key("w"); | |
| 112 | const fortnight = h.text(); | |
| 113 | expect(fortnight).toContain("over 14 d"); | |
| 114 | expect(fortnight).toContain("$4.25"); | |
| 115 | expect(fortnight).toContain("2 runs"); | |
| 116 | await h.key("w"); | |
| 117 | expect(h.text()).toContain("over 24 h"); | |
| 118 | h.stop(); | |
| 119 | }); | |
| 120 | ||
| 121 | test("someone who may not see the spend gets nothing: the action's rule", () => { | |
| 122 | const graph = readFleetGraph(forge, getUserByName(forge.db, "bob")!, null); | |
| 123 | expect(graph.repos).toEqual([]); | |
outdated
Only checks one glyph; assert every line is <= 44 columns and the name is truncated. |
||
| 124 | expect(graph.windows[0].totals.costUsd).toEqual([]); | |
| 125 | }); | |
| 126 | ||
| 127 | test("f does nothing on a repository's Usage", async () => { | |
| 128 | const h = boot("alice"); | |
| 129 | await h.openRepoUsage("alice/mill"); | |
| 130 | expect(h.text()).toContain("Usage · alice/mill"); | |
| 131 | expect(h.text()).not.toContain("fleet graph"); | |
| 132 | await h.key("f"); | |
| 133 | expect(h.text()).toContain("Usage · alice/mill"); | |
| 134 | expect(h.text()).not.toContain("over 24 h"); | |
| 135 | h.stop(); | |
| 136 | }); | |
| 137 | ||
| 138 | /** A fresh forge with an admin `alice` and `n` repositories, each having spent a little. */ | |
| 139 | async function fresh(n: number, stem = "app"): Promise<{ on: Forge; done: () => Promise<void> }> { | |
| 140 | const dir = await mkdtemp(join(tmpdir(), "forge-tui-fleetgraph-x-")); | |
| 141 | const on = createApp({ dataDir: dir, port: 0 }); | |
| 142 | const made = await createUser(on.db, { | |
| 143 | origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true, | |
| 144 | }); | |
| 145 | if (!made.ok) throw new Error(made.error); | |
| 146 | const alice = getUserByName(on.db, "alice")!; | |
| 147 | for (let i = 0; i < n; i++) { | |
| 148 | const repo = await createRepo(on, alice, { name: `${stem}${i}`, visibility: "public" }); | |
| 149 | if (!repo.ok) throw new Error(String(repo.error)); | |
| 150 | recordUsage(on.db, { | |
| 151 | runId: createRun(on.db, { repoId: repo.value.id, kind: "agent" }).id, | |
| 152 | repoId: repo.value.id, issueId: null, agentId: null, subscriptionId: null, | |
| 153 | tokensIn: 1, tokensOut: 1, costUsd: 1 + i, wallMs: 1, outcome: "ok", | |
| 154 | at: new Date(Date.now() - 60_000).toISOString(), | |
| 155 | }); | |
| 156 | } | |
| 157 | return { on, done: async () => { on.close(); await rm(dir, { recursive: true, force: true }); } }; | |
| 158 | } | |
| 159 | ||
| 160 | test("an empty ledger reads Nothing spent in this window.", async () => { | |
| 161 | const { on, done } = await fresh(0); | |
| 162 | const h = boot("alice", 120, 32, on); | |
| 163 | await h.open(); | |
| 164 | expect(h.text()).toContain("Nothing spent in this window."); | |
| 165 | h.stop(); | |
| 166 | await done(); | |
| 167 | }); | |
| 168 | ||
| 169 | test("past eight series the smallest share \"N others\"", async () => { | |
| 170 | const { on, done } = await fresh(10); | |
| 171 | const h = boot("alice", 120, 40, on); | |
| 172 | await h.open(); | |
| 173 | expect(h.text()).toContain("3 others"); | |
| 174 | expect(h.text()).toContain("alice/app9"); | |
| 175 | expect(h.text()).not.toContain("alice/app0"); | |
| 176 | h.stop(); | |
| 177 | await done(); | |
| 178 | }); | |
| 179 | ||
| 180 | test("a short terminal says how many rows it cut", async () => { | |
| 181 | const { on, done } = await fresh(10); | |
| 182 | const h = boot("alice", 120, 14, on); | |
| 183 | await h.open(); | |
| 184 | expect(h.text()).toMatch(/\+\d+ more rows/); | |
| 185 | h.stop(); | |
| 186 | await done(); | |
| 187 | }); | |
| 188 | ||
| 189 | test("at 44 columns every line fits and the long name is cut", async () => { | |
| 190 | const { on, done } = await fresh(2, "a-rather-long-repository-name-"); | |
| 191 | const h = boot("alice", 44, 32, on); | |
| 192 | await h.open(); | |
| 193 | const lines = h.text().split("\n"); | |
| 194 | for (const l of lines) expect([...l].length).toBeLessThanOrEqual(44); | |
| 195 | const rows = lines.filter((l) => l.includes("▁") || l.includes("█")); | |
| 196 | expect(rows.length).toBeGreaterThan(0); | |
| 197 | expect(h.text()).toContain("…"); | |
| 198 | expect(h.text()).not.toContain("a-rather-long-repository-name-1"); | |
| 199 | h.stop(); | |
| 200 | await done(); | |
| 201 | }); |
new above
offline
Rows beyond body.h are dropped silently on a short terminal; add an overflow line or scrolling.