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

Fleet graph: the selection's spend and runs over time beside the table #553

Merged opened this in answer to

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

Merged

torque/forge:torque/449-fleet-graph-the-selection-s-spend-and-ru → main 5 commits fork

  • Merged. torque/449-fleet-graph-the-selection-s-spend-and-ru was merged into main as c5f0673.

Review

Sign in to review this change.
9 files changed +1239 −0
docs/APPS.md +13
@@ -730,6 +730,19 @@ a floor). An operator keeps a share of every window for people
730730 it. Both read on `GET /api/v1/repos/:owner/:name/usage` and the MCP
731731 `usage` tool, under `standing`.
732732
733 Across repositories, the _fleet graph_ (`GET /fleet/graph`) draws a
734 selection's spend and runs from the same ledger: the last 24 hours by UTC
735 hour and the last 14 days by UTC day, stacked per repository, with the
736 numbers as a table beside it. The selection is `?repo=owner/name`,
737 repeated; absent, it is every repository you can write that spent in the
738 last 14 days. A run counts in the bucket it ended in. Past eight
739 repositories the smallest spenders share one grey series, "N others".
740 The page redraws every 30 seconds while it is open, and at once on a
741 `forge:fleet-op` event, so an operation's effect shows within a minute.
742 The same numbers are `GET /fleet/graph.csv` and `GET /api/v1/fleet/graph`;
743 spend is the owner's fact, so a repository you cannot write is dropped
744 and counted with the names that do not exist, never told apart.
745
733746 The agent runs Claude Code headless in its clone, with a prompt the forge
734747 composes: who it is, what the task is, how to finish, and what will be
735748 refused. Permissions are the forge's, passed as settings: file tools
src/actions/fleet-graph.ts added +301
@@ -0,0 +1,301 @@
1 /**
2 * The fleet graph: a selection of repositories' spend and runs over time,
3 * as plain data — the last 24 hours by UTC hour and the last 14 days by UTC
4 * day. The page (`web/pages/fleet-graph.ts`), its CSV and the API route all
5 * answer from here, so they never disagree.
6 *
7 * Read from the usage ledger (`core/usage.ts`): a run is counted in the
8 * bucket it *ended* in, at the provider's reported cost in US dollars. The
9 * newest bucket is the current hour or day, still filling.
10 *
11 * Spend is the owner's own fact (`canSeeOwnerFacts` is write access), so a
12 * selection is narrowed to the repositories the viewer may write. A name
13 * that is missing and a name that is not theirs are dropped alike and only
14 * counted together (`hidden`), so the count never says which.
15 */
16
17 import type { App } from "../app.ts";
18 import {
19 getRepo,
20 getReposByIds,
21 writableRepoIds,
22 type Repo,
23 } from "../core/repos.ts";
24 import {
25 reposWithUsage,
26 usageBuckets,
27 type UsageUnit,
28 } from "../core/usage.ts";
29 import type { User } from "../core/users.ts";
30 import { err, ok, type Result } from "../kernel/result.ts";
31
32 export type FleetWindowName = "24h" | "14d";
33
34 /** Each window: its bucket width and how many buckets, the newest last. */
35 export const FLEET_WINDOWS: Readonly<
36 Record<FleetWindowName, { unit: UsageUnit; count: number; ms: number }>
37 > = {
38 "24h": { unit: "hour", count: 24, ms: 3_600_000 },
39 "14d": { unit: "day", count: 14, ms: 86_400_000 },
40 };
41
42 /** At most this many repositories in one selection's request. */
43 export const FLEET_GRAPH_MAX_REPOS = 100;
44 /**
45 * At most this many lines in the legend: a ninth colour is one nobody can
46 * tell from the other eight, so past eight the smallest spenders share one
47 * series, "N others", and the table still lists every repository.
48 */
49 export const FLEET_GRAPH_MAX_SERIES = 8;
50
51 export interface FleetSeries {
52 /** `owner/name`, or `N others` for the folded tail. */
53 readonly label: string;
54 /** Every repository summed into this series, as `owner/name`. */
55 readonly repos: readonly string[];
56 }
57
58 /** Figures indexed like `FleetGraph.series`. */
59 export interface FleetFigures {
60 readonly runs: readonly number[];
61 readonly failed: readonly number[];
62 /** US dollars, the provider's reported cost. */
63 readonly costUsd: readonly number[];
64 }
65
66 export interface FleetBucket extends FleetFigures {
67 /** The bucket's first instant (ISO 8601, UTC). */
68 readonly start: string;
69 }
70
71 export interface FleetWindow {
72 readonly name: FleetWindowName;
73 readonly unit: UsageUnit;
74 /** The first bucket's start; the window covers [since, until). */
75 readonly since: string;
76 /** The end of the newest bucket, which is still filling. */
77 readonly until: string;
78 readonly buckets: readonly FleetBucket[];
79 /** Per series, over the whole window. */
80 readonly totals: FleetFigures;
81 }
82
83 /** One repository's own totals, for the table beside the chart. */
84 export interface FleetRepoRow {
85 readonly repo: string;
86 /** The index into `series` this repository is drawn in. */
87 readonly series: number;
88 readonly day: { readonly runs: number; readonly failed: number; readonly costUsd: number };
89 readonly fortnight: { readonly runs: number; readonly failed: number; readonly costUsd: number };
90 }
91
92 export interface FleetGraph {
93 /** When this was read (ISO 8601, UTC). */
94 readonly at: string;
95 /** The selection as asked: the names given, or `null` for the default. */
96 readonly asked: readonly string[] | null;
97 /** Names asked for that are not shown: missing or not the viewer's to see. */
98 readonly hidden: number;
99 readonly series: readonly FleetSeries[];
100 /** The 24-hour window, then the 14-day one. */
101 readonly windows: readonly [FleetWindow, FleetWindow];
102 /** Every repository shown, most spent over 14 days first. */
103 readonly repos: readonly FleetRepoRow[];
104 }
105
106 /** `owner/name`: each part starts with a letter, digit or underscore. */
107 const NAME = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,99}\/[A-Za-z0-9_][A-Za-z0-9_.-]{0,99}$/;
108
109 /**
110 * The selection from a request: each `repo` value is `owner/name`; space-
111 * or comma-separated lists are accepted too. Absent is `null` — the
112 * default selection. Anything malformed or past the cap is a refusal, not
113 * a silent trim, because a graph of less than was asked looks like an answer.
114 */
115 export function fleetSelection(
116 values: readonly string[],
117 ): Result<string[] | null, string> {
118 const names = values
119 .flatMap((v) => v.split(/[\s,]+/))
120 .filter((v) => v !== "");
121 if (names.length === 0) return ok(null);
122 if (names.length > FLEET_GRAPH_MAX_REPOS)
123 return err(`at most ${FLEET_GRAPH_MAX_REPOS} repositories in one graph`);
124 for (const n of names)
125 if (!NAME.test(n)) return err(`"${n.slice(0, 40)}" is not owner/name`);
126 return ok([...new Set(names.map((n) => n.toLowerCase()))]);
127 }
128
129 /** The start of the bucket holding `now`, then the window's first bucket. */
130 export function windowBounds(
131 name: FleetWindowName,
132 now: Date,
133 ): { since: Date; until: Date; starts: Date[] } {
134 const { ms, count } = FLEET_WINDOWS[name];
135 // Hours and UTC days are both whole multiples of the epoch's ms, so
136 // flooring the epoch time is flooring to the hour or the UTC midnight.
137 const current = Math.floor(now.getTime() / ms) * ms;
138 const first = current - (count - 1) * ms;
139 const starts = Array.from({ length: count }, (_, i) => new Date(first + i * ms));
140 return { since: new Date(first), until: new Date(current + ms), starts };
141 }
142
143 const fullName = (r: Repo): string => `${r.ownerName}/${r.name}`;
144
145 /**
146 * The graph for a selection of names (`null` is the default: every
147 * repository the viewer may write that spent anything in the last 14 days).
148 */
149 export function readFleetGraph(
150 app: App,
151 user: User,
152 asked: readonly string[] | null,
153 now: Date = new Date(),
154 ): FleetGraph {
155 const db = app.db;
156 const fortnight = windowBounds("14d", now);
157 let candidates: Repo[];
158 if (asked === null) {
159 candidates = [
160 ...getReposByIds(db, reposWithUsage(db, fortnight.since.toISOString())).values(),
161 ];
162 } else {
163 candidates = [];
164 for (const n of asked) {
165 const [owner, name] = n.split("/") as [string, string];
166 const repo = getRepo(db, owner, name);
167 if (repo !== undefined) candidates.push(repo);
168 }
169 }
170 const writable = writableRepoIds(
171 db,
172 user,
173 candidates.map((r) => r.id),
174 );
175 const repos = candidates.filter((r) => writable.has(r.id));
176 return fleetGraphFor(app, repos, asked, asked === null ? 0 : asked.length - repos.length, now);
177 }
178
179 /**
180 * The graph for repositories already resolved and already allowed — the
181 * seam the fleet table hands its selection through. Callers must have
182 * narrowed `repos` to what the viewer may see spend for.
183 */
184 export function fleetGraphFor(
185 app: App,
186 repos: readonly Repo[],
187 asked: readonly string[] | null,
188 hidden: number,
189 now: Date = new Date(),
190 ): FleetGraph {
191 const ids = repos.map((r) => r.id);
192 const read = (name: FleetWindowName) => {
193 const bounds = windowBounds(name, now);
194 const rows = usageBuckets(
195 app.db,
196 ids,
197 bounds.since.toISOString(),
198 FLEET_WINDOWS[name].unit,
199 );
200 return { bounds, rows };
201 };
202 const day = read("24h");
203 const fortnight = read("14d");
204
205 // Each repository's own totals, then the order: most spent over 14 days,
206 // then most runs, then by name, so the order is stable for equal rows.
207 type Sum = { runs: number; failed: number; costUsd: number };
208 const zero = (): Sum => ({ runs: 0, failed: 0, costUsd: 0 });
209 const own = new Map<string, { day: Sum; fortnight: Sum }>(
210 ids.map((id) => [id, { day: zero(), fortnight: zero() }]),
211 );
212 for (const [rows, key] of [
213 [day.rows, "day"],
214 [fortnight.rows, "fortnight"],
215 ] as const)
216 for (const r of rows) {
217 const s = own.get(r.repoId)?.[key];
218 if (s === undefined) continue;
219 s.runs += r.runs;
220 s.failed += r.failed;
221 s.costUsd += r.costUsd;
222 }
223 const ranked = [...repos].sort((a, b) => {
224 const x = own.get(a.id)!.fortnight;
225 const y = own.get(b.id)!.fortnight;
226 return (
227 y.costUsd - x.costUsd ||
228 y.runs - x.runs ||
229 fullName(a).localeCompare(fullName(b))
230 );
231 });
232
233 // Series: one per repository up to the cap; past it, the last one is
234 // the rest folded together.
235 const folds = ranked.length > FLEET_GRAPH_MAX_SERIES;
236 const named = folds ? FLEET_GRAPH_MAX_SERIES - 1 : ranked.length;
237 const seriesOf = new Map<string, number>();
238 ranked.forEach((r, i) => seriesOf.set(r.id, Math.min(i, named)));
239 const series: FleetSeries[] = ranked
240 .slice(0, named)
241 .map((r) => ({ label: fullName(r), repos: [fullName(r)] }));
242 if (folds) {
243 const rest = ranked.slice(named).map(fullName);
244 series.push({ label: `${rest.length} others`, repos: rest });
245 }
246
247 const window = (
248 name: FleetWindowName,
249 w: typeof day,
250 ): FleetWindow => {
251 const n = series.length;
252 const index = new Map(
253 w.bounds.starts.map((s, i) => [
254 s.toISOString().slice(0, name === "24h" ? 13 : 10),
255 i,
256 ]),
257 );
258 const buckets = w.bounds.starts.map((s) => ({
259 start: s.toISOString(),
260 runs: new Array<number>(n).fill(0),
261 failed: new Array<number>(n).fill(0),
262 costUsd: new Array<number>(n).fill(0),
263 }));
264 for (const r of w.rows) {
265 const b = buckets[index.get(r.bucket) ?? -1];
266 const s = seriesOf.get(r.repoId);
267 if (b === undefined || s === undefined) continue;
268 b.runs[s]! += r.runs;
269 b.failed[s]! += r.failed;
270 b.costUsd[s]! += r.costUsd;
271 }
272 const total = (k: "runs" | "failed" | "costUsd") =>
273 series.map((_, s) => buckets.reduce((sum, b) => sum + b[k][s]!, 0));
274 return {
275 name,
276 unit: FLEET_WINDOWS[name].unit,
277 since: w.bounds.since.toISOString(),
278 until: w.bounds.until.toISOString(),
279 buckets,
280 totals: {
281 runs: total("runs"),
282 failed: total("failed"),
283 costUsd: total("costUsd"),
284 },
285 };
286 };
287
288 return {
289 at: now.toISOString(),
290 asked,
291 hidden: Math.max(hidden, 0),
292 series,
293 windows: [window("24h", day), window("14d", fortnight)],
294 repos: ranked.map((r) => ({
295 repo: fullName(r),
296 series: seriesOf.get(r.id)!,
297 day: own.get(r.id)!.day,
298 fortnight: own.get(r.id)!.fortnight,
299 })),
300 };
301 }
src/core/usage.ts +81
@@ -235,6 +235,87 @@ export function recentUsage(
235235 .map(toEntry);
236236 }
237237
238 /** The width of one bucket in `usageBuckets`: a UTC hour or a UTC day. */
239 export type UsageUnit = "hour" | "day";
240
241 /**
242 * How many leading characters of an ISO 8601 UTC instant name its bucket:
243 * `2026-10-02T14` is the hour 14:00–14:59 UTC, `2026-10-02` the day. Every
244 * `at` is written by `toISOString()`, so the prefix is exact — no float
245 * arithmetic on julian days deciding which side of a boundary a run lands.
246 */
247 const PREFIX: Readonly<Record<UsageUnit, number>> = { hour: 13, day: 10 };
248
249 export interface UsageBucket {
250 readonly repoId: string;
251 /** The bucket's ISO prefix: `YYYY-MM-DDTHH` for an hour, `YYYY-MM-DD` for a day. */
252 readonly bucket: string;
253 readonly runs: number;
254 readonly failed: number;
255 readonly costUsd: number;
256 }
257
258 /**
259 * The ledger summed per repository per UTC hour or day since an instant, for
260 * these repositories only — the fleet graph's one read (`actions/fleet-graph.ts`).
261 * Runs are counted when they end, because that is when a run's line is
262 * written. An empty list of repositories reads nothing.
263 */
264 export function usageBuckets(
265 db: Database,
266 repoIds: readonly string[],
267 since: string,
268 unit: UsageUnit,
269 ): UsageBucket[] {
270 const unique = [...new Set(repoIds)];
271 const out: UsageBucket[] = [];
272 const CHUNK = 400;
273 for (let i = 0; i < unique.length; i += CHUNK) {
274 const batch = unique.slice(i, i + CHUNK);
275 const rows = db
276 .query<
277 {
278 repo_id: string;
279 bucket: string;
280 runs: number;
281 failed: number;
282 cost_usd: number;
283 },
284 [string, number, ...string[]]
285 >(
286 `SELECT repo_id, substr(at, 1, ?2) AS bucket,
287 COUNT(*) AS runs,
288 SUM(outcome = 'failed') AS failed,
289 SUM(cost_usd) AS cost_usd
290 FROM usage
291 WHERE at >= ?1
292 AND repo_id IN (${batch.map((_, n) => `?${n + 3}`).join(",")})
293 GROUP BY repo_id, bucket
294 ORDER BY bucket, repo_id`,
295 )
296 .all(since, PREFIX[unit], ...batch);
297 for (const r of rows)
298 out.push({
299 repoId: r.repo_id,
300 bucket: r.bucket,
301 runs: r.runs,
302 failed: r.failed,
303 costUsd: r.cost_usd,
304 });
305 }
306 return out;
307 }
308
309 /** Every repository with a line in the ledger since an instant, by id. */
310 export function reposWithUsage(db: Database, since: string): string[] {
311 return db
312 .query<{ repo_id: string }, [string]>(
313 "SELECT DISTINCT repo_id FROM usage WHERE at >= ?1",
314 )
315 .all(since)
316 .map((r) => r.repo_id);
317 }
318
238319 /** The costliest ledger lines since an instant, most spent first, bounded. */
239320 export function topUsage(
240321 db: Database,
src/http/api.ts +11
@@ -217,6 +217,7 @@ import {
217217 import { resolveOid } from "../git/merge.ts";
218218 import { vitals } from "../kernel/vitals.ts";
219219 import { readUsage, usageDays, USAGE_DAYS_MAX } from "../actions/usage.ts";
220 import { fleetSelection, readFleetGraph } from "../actions/fleet-graph.ts";
220221 import { readRunLog, subscribeRunLog } from "../runs/log.ts";
221222 import { blobForRender, commitLog, listRefs, listTree } from "../git/read.ts";
222223 import { repoDir } from "../git/repos.ts";
@@ -3238,6 +3239,16 @@ export function apiRoutes(router: Router<Handler>): void {
32383239 return json(report.value);
32393240 });
32403241
3242 // The fleet graph's numbers (`actions/fleet-graph.ts`): spend and runs
3243 // per repository by UTC hour (24 h) and day (14 d), for `?repo=owner/name`
3244 // repeated, or every repository the caller may write that spent lately.
3245 router.add("GET", "/api/v1/fleet/graph", (ctx) => {
3246 if (!ctx.auth.user) return unauthorized();
3247 const asked = fleetSelection(ctx.url.searchParams.getAll("repo"));
3248 if (!asked.ok) return apiError("invalid_selection", 422, asked.error);
3249 return json(readFleetGraph(ctx.app, ctx.auth.user, asked.value));
3250 });
3251
32413252 router.add("GET", "/api/v1/repos/:owner/:name/runs", (ctx) => {
32423253 const repo = loadRepo(ctx);
32433254 if (!repo) return notFound();
src/http/server.ts +9
@@ -163,6 +163,11 @@ import {
163163 } from "../web/pages/runs.ts";
164164 import { lookImage } from "../web/pages/looks.ts";
165165 import { bigRedirect, boardPage } from "../web/pages/board.ts";
166 import {
167 fleetGraphCsv,
168 fleetGraphLive,
169 fleetGraphPage,
170 } from "../web/pages/fleet-graph.ts";
166171 import {
167172 agentCreate,
168173 agentDelete,
@@ -458,6 +463,10 @@ export function buildRouter(): Router<Handler> {
458463 router.add("GET", "/migrate/jobs/:job", migrationJobPage);
459464 // Every app on one canvas; a literal route, so it must sit above /:owner.
460465 router.add("GET", "/map", globalMapPage);
466 // A selection's spend and runs over time; literal, above /:owner.
467 router.add("GET", "/fleet/graph", fleetGraphPage);
468 router.add("GET", "/fleet/graph/live", fleetGraphLive);
469 router.add("GET", "/fleet/graph.csv", fleetGraphCsv);
461470 // Live regions' own addresses (docs/STUDIO.md, "Live regions"): the
462471 // home's shelf and its "On you" card answer for themselves, so an event
463472 // fetches a region and not the page. Literal, above /:owner.
src/web/fleet-graph.ts added +289
@@ -0,0 +1,289 @@
1 /**
2 * The fleet graph drawn: for each window, spend stacked per series above
3 * runs stacked per series, on one time axis — SVG written here on the
4 * server, no chart library, no client drawing. Beside it, the same numbers
5 * as a table, which is also what a screen reader is pointed at.
6 *
7 * Scales, stated once: a bar's height is its value over the panel's top
8 * tick times the panel's height (`SPEND_H`, `RUNS_H` px), and the top tick
9 * is the smallest 1, 2, 2.5 or 5 × 10ⁿ at or above the tallest stack
10 * (`niceCeiling`). Colours are the Okabe–Ito palette, chosen to be told
11 * apart with the common colour-vision deficiencies; the folded "others"
12 * series is grey. They are classes (`fg-s0`…), set in the stylesheet.
13 *
14 * `fleetGraphSection` carries `data-src`: the client script below fetches
15 * it every 30 s while the tab is visible, and at once on a
16 * `forge:fleet-op` event, so an operation's effect is drawn within a minute
17 * without a reload. The fragment route returns this same section.
18 */
19
20 import type {
21 FleetGraph,
22 FleetWindow,
23 } from "../actions/fleet-graph.ts";
24 import { html, type Html } from "./html.ts";
25
26 export const SVG_W = 600;
27 const LEFT = 48;
28 const RIGHT = 8;
29 const TOP = 10;
30 export const SPEND_H = 120;
31 const GAP = 22;
32 export const RUNS_H = 60;
33 const AXIS = 18;
34 const SVG_H = TOP + SPEND_H + GAP + RUNS_H + AXIS;
35 const PLOT_W = SVG_W - LEFT - RIGHT;
36
37 /** How often the open page redraws on its own, in ms. */
38 export const FLEET_GRAPH_REFRESH_MS = 30_000;
39
40 /** The smallest 1, 2, 2.5 or 5 × 10ⁿ at or above `v`; `floor` when v is 0. */
41 export function niceCeiling(v: number, floor: number): number {
42 if (!(v > 0)) return floor;
43 const step = 10 ** Math.floor(Math.log10(v));
44 for (const m of [1, 2, 2.5, 5, 10]) if (m * step >= v - 1e-12) return m * step;
45 return 10 * step;
46 }
47
48 export const money = (n: number): string => `$${n.toFixed(2)}`;
49
50 /** A tick's words: dollars to as many places as the step needs. */
51 const moneyTick = (n: number): string =>
52 n >= 10 || Number.isInteger(n) ? `$${Math.round(n)}` : `$${n.toFixed(n < 0.1 ? 3 : n < 1 ? 2 : 1)}`;
53
54 /** `14:00` for an hour, `10-02` for a day — UTC either way. */
55 const bucketWords = (w: FleetWindow, start: string): string =>
56 w.unit === "hour" ? `${start.slice(11, 13)}:00` : start.slice(5, 10);
57
58 const bucketTitle = (w: FleetWindow, start: string): string =>
59 w.unit === "hour"
60 ? `${start.slice(0, 10)} ${start.slice(11, 13)}:00 UTC`
61 : `${start.slice(0, 10)} UTC`;
62
63 const r2 = (n: number): string => String(Math.round(n * 100) / 100);
64
65 /** One window as an SVG: spend panel, runs panel, a shared axis. */
66 export function windowSvg(graph: FleetGraph, w: FleetWindow): Html {
67 const n = w.buckets.length;
68 const slot = PLOT_W / n;
69 const bar = Math.max(slot * 0.72, 1);
70 const stack = (k: "costUsd" | "runs") =>
71 Math.max(0, ...w.buckets.map((b) => b[k].reduce((s, v) => s + v, 0)));
72 const spendTop = niceCeiling(stack("costUsd"), 1);
73 const runsTop = niceCeiling(stack("runs"), 1);
74 const runsTopWhole = Math.max(Math.ceil(runsTop), 1);
75 const spendY0 = TOP + SPEND_H;
76 const runsY0 = TOP + SPEND_H + GAP + RUNS_H;
77 const every = w.unit === "hour" ? 6 : 3;
78 const totalSpend = w.totals.costUsd.reduce((s, v) => s + v, 0);
79 const totalRuns = w.totals.runs.reduce((s, v) => s + v, 0);
80 const label = `${w.name === "24h" ? "Last 24 hours by hour" : "Last 14 days by day"}: ${money(totalSpend)} over ${totalRuns} ${totalRuns === 1 ? "run" : "runs"}`;
81
82 const segments = (
83 k: "costUsd" | "runs",
84 top: number,
85 height: number,
86 y0: number,
87 ): Html[] =>
88 w.buckets.flatMap((b, i) => {
89 const x = LEFT + i * slot + (slot - bar) / 2;
90 let y = y0;
91 return graph.series.flatMap((s, si) => {
92 const v = b[k][si] ?? 0;
93 if (v <= 0) return [];
94 const h = (v / top) * height;
95 y -= h;
96 const words =
97 k === "costUsd"
98 ? `${s.label} · ${bucketTitle(w, b.start)} · ${money(v)}`
99 : `${s.label} · ${bucketTitle(w, b.start)} · ${v} ${v === 1 ? "run" : "runs"}${b.failed[si] ? `, ${b.failed[si]} failed` : ""}`;
100 return [
101 html`<rect class="fg-s${si}" data-series="${si}" data-bucket="${b.start}" data-${k === "costUsd" ? "spend" : "runs"}="${r2(v)}" x="${r2(x)}" y="${r2(y)}" width="${r2(bar)}" height="${r2(h)}"><title>${words}</title></rect>`,
102 ];
103 });
104 });
105
106 return html`<svg
107 class="fleet-svg"
108 viewBox="0 0 ${SVG_W} ${SVG_H}"
109 preserveAspectRatio="xMinYMin meet"
110 role="img"
111 aria-label="${label}"
112 data-window="${w.name}"
113 data-spend-top="${spendTop}"
114 data-runs-top="${runsTopWhole}"
115 >
116 <g class="fleet-grid">
117 <line x1="${LEFT}" y1="${TOP}" x2="${SVG_W - RIGHT}" y2="${TOP}" />
118 <line x1="${LEFT}" y1="${TOP + SPEND_H / 2}" x2="${SVG_W - RIGHT}" y2="${TOP + SPEND_H / 2}" />
119 <line x1="${LEFT}" y1="${spendY0}" x2="${SVG_W - RIGHT}" y2="${spendY0}" />
120 <line x1="${LEFT}" y1="${runsY0 - RUNS_H}" x2="${SVG_W - RIGHT}" y2="${runsY0 - RUNS_H}" />
121 <line x1="${LEFT}" y1="${runsY0}" x2="${SVG_W - RIGHT}" y2="${runsY0}" />
122 </g>
123 <g class="fleet-ticks">
124 <text x="${LEFT - 6}" y="${TOP + 4}">${moneyTick(spendTop)}</text>
125 <text x="${LEFT - 6}" y="${TOP + SPEND_H / 2 + 4}">${moneyTick(spendTop / 2)}</text>
126 <text x="${LEFT - 6}" y="${spendY0 + 4}">$0</text>
127 <text x="${LEFT - 6}" y="${runsY0 - RUNS_H + 4}">${runsTopWhole}</text>
128 <text x="${LEFT - 6}" y="${runsY0 + 4}">0</text>
129 </g>
130 <g class="fleet-panel-name">
131 <text x="${LEFT + 4}" y="${TOP + 12}">spend, USD</text>
132 <text x="${LEFT + 4}" y="${runsY0 - RUNS_H + 12}">runs ended</text>
133 </g>
134 <g class="fleet-bars">
135 ${segments("costUsd", spendTop, SPEND_H, spendY0)}
136 ${segments("runs", runsTopWhole, RUNS_H, runsY0)}
137 </g>
138 <g class="fleet-axis">
139 ${w.buckets.map((b, i) =>
140 (n - 1 - i) % every === 0
141 ? html`<text x="${r2(LEFT + i * slot + slot / 2)}" y="${runsY0 + 14}">${bucketWords(w, b.start)}</text>`
142 : null,
143 )}
144 </g>
145 </svg>`;
146 }
147
148 /** The legend: one swatch per series, with its 14-day spend. */
149 function legend(graph: FleetGraph): Html {
150 const fortnight = graph.windows[1];
151 return html`<ul class="fleet-legend">
152 ${graph.series.map(
153 (s, i) =>
154 html`<li title="${s.repos.join(", ")}">
155 <span class="fleet-swatch fg-s${i}" aria-hidden="true"></span>
156 ${s.repos.length === 1
157 ? html`<a href="/${s.label}">${s.label}</a>`
158 : s.label}
159 <span class="muted">${money(fortnight.totals.costUsd[i] ?? 0)}</span>
160 </li>`,
161 )}
162 </ul>`;
163 }
164
165 /** Every repository's own figures, the chart's numbers as text. */
166 function table(graph: FleetGraph): Html {
167 return html`<div class="table-scroll">
168 <table class="table fleet-table" data-dense>
169 <caption class="sr-only">
170 Spend and runs per repository, last 24 hours and last 14 days
171 </caption>
172 <thead>
173 <tr>
174 <th scope="col">repository</th>
175 <th scope="col" class="num">24 h spend</th>
176 <th scope="col" class="num">24 h runs</th>
177 <th scope="col" class="num">14 d spend</th>
178 <th scope="col" class="num">14 d runs</th>
179 <th scope="col" class="num">14 d failed</th>
180 </tr>
181 </thead>
182 <tbody>
183 ${graph.repos.map(
184 (r) => html`<tr data-repo="${r.repo}">
185 <th scope="row">
186 <span class="fleet-swatch fg-s${r.series}" aria-hidden="true"></span>
187 <a href="/${r.repo}">${r.repo}</a>
188 </th>
189 <td class="num">${money(r.day.costUsd)}</td>
190 <td class="num">${r.day.runs}</td>
191 <td class="num">${money(r.fortnight.costUsd)}</td>
192 <td class="num">${r.fortnight.runs}</td>
193 <td class="num">${r.fortnight.failed}</td>
194 </tr>`,
195 )}
196 </tbody>
197 </table>
198 </div>`;
199 }
200
201 /** Built as text, so no markup whitespace lands inside the sentence. */
202 const hiddenWords = (n: number): string =>
203 `${n} of the names asked for ${n === 1 ? "is" : "are"} not shown: missing, or not yours to see spend for.`;
204
205 /** The query string that reproduces this selection. */
206 export function selectionQuery(graph: FleetGraph): string {
207 return graph.asked === null
208 ? ""
209 : graph.asked.map((n) => `repo=${encodeURIComponent(n)}`).join("&");
210 }
211
212 /**
213 * The whole graph: legend, both windows, the table and what it assumes. The
214 * same markup the live fragment returns, so a redraw replaces it whole.
215 */
216 export function fleetGraphSection(graph: FleetGraph): Html {
217 const q = selectionQuery(graph);
218 return html`<section
219 class="fleet-graph"
220 id="fleet-graph"
221 data-src="/fleet/graph/live${q === "" ? "" : `?${q}`}"
222 data-at="${graph.at}"
223 >
224 ${graph.repos.length === 0
225 ? html`<p class="muted">
226 ${graph.asked === null
227 ? "No repository you can write spent anything in the last 14 days."
228 : "None of these repositories is yours to see spend for."}
229 </p>`
230 : html`${legend(graph)}
231 <div class="fleet-windows">
232 ${graph.windows.map(
233 (w) => html`<figure class="fleet-window">
234 <figcaption>
235 ${w.name === "24h" ? "Last 24 hours" : "Last 14 days"}
236 <span class="muted"
237 >${money(w.totals.costUsd.reduce((s, v) => s + v, 0))} ·
238 ${w.totals.runs.reduce((s, v) => s + v, 0)} runs</span
239 >
240 </figcaption>
241 ${windowSvg(graph, w)}
242 </figure>`,
243 )}
244 </div>
245 ${table(graph)}`}
246 ${graph.hidden > 0
247 ? html`<p class="muted small">${hiddenWords(graph.hidden)}</p>`
248 : null}
249 <p class="muted small fleet-assume">
250 Read from the usage ledger at
251 <time datetime="${graph.at}">${graph.at.slice(11, 16)} UTC</time>. A
252 run counts in the UTC hour or day it ended; spend is the provider's
253 reported cost in US dollars. The newest bar is still filling. Redrawn
254 every ${FLEET_GRAPH_REFRESH_MS / 1000} s while open.
255 </p>
256 </section>`;
257 }
258
259 /** The redraw: fetch the fragment, swap the section; pure enhancement. */
260 export const fleetGraphClientJs = /* js */ `
261 (function () {
262 var busy = false;
263 function redraw() {
264 var el = document.getElementById('fleet-graph');
265 if (!el || busy) return;
266 var src = el.getAttribute('data-src');
267 if (!src) return;
268 busy = true;
269 fetch(src, { credentials: 'same-origin', headers: { accept: 'text/html' } })
270 .then(function (r) { return r.ok ? r.text() : null; })
271 .then(function (text) {
272 if (text === null) return;
273 var doc = new DOMParser().parseFromString(text, 'text/html');
274 var next = doc.getElementById('fleet-graph');
275 var now = document.getElementById('fleet-graph');
276 if (next && now) now.replaceWith(document.importNode(next, true));
277 })
278 .catch(function () {})
279 .then(function () { busy = false; });
280 }
281 setInterval(function () {
282 if (document.visibilityState === 'visible') redraw();
283 }, ${FLEET_GRAPH_REFRESH_MS});
284 document.addEventListener('visibilitychange', function () {
285 if (document.visibilityState === 'visible') redraw();
286 });
287 document.addEventListener('forge:fleet-op', redraw);
288 })();
289 `;
src/web/pages/fleet-graph.ts added +146
@@ -0,0 +1,146 @@
1 /**
2 * The fleet graph's pages: `GET /fleet/graph` (the chart, the table beside
3 * it and the selection that drew them, all in the URL), `GET
4 * /fleet/graph/live` (the same section alone, which the page refetches to
5 * redraw) and `GET /fleet/graph.csv` (every bucket as a table file).
6 *
7 * The selection is `?repo=owner/name`, repeated; absent, it is every
8 * repository the viewer may write that spent anything in the last 14 days.
9 * Signed in only: spend is an owner's fact (`actions/fleet-graph.ts`).
10 */
11
12 import {
13 fleetSelection,
14 readFleetGraph,
15 type FleetGraph,
16 } from "../../actions/fleet-graph.ts";
17 import type { Ctx } from "../../http/context.ts";
18 import { signInFirst } from "../chrome.ts";
19 import {
20 fleetGraphClientJs,
21 fleetGraphSection,
22 selectionQuery,
23 } from "../fleet-graph.ts";
24 import { html } from "../html.ts";
25 import { fragment, page } from "../layout.ts";
26 import { btn } from "../ui.ts";
27
28 /** The selection from the URL, or the refusal's words. */
29 function selection(ctx: Ctx) {
30 return fleetSelection(ctx.url.searchParams.getAll("repo"));
31 }
32
33 /** GET /fleet/graph */
34 export function fleetGraphPage(ctx: Ctx): Response {
35 const user = ctx.auth.user;
36 if (!user) return signInFirst(ctx);
37 const asked = selection(ctx);
38 const graph = asked.ok
39 ? readFleetGraph(ctx.app, user, asked.value)
40 : undefined;
41 const q = graph === undefined ? "" : selectionQuery(graph);
42 const names = ctx.url.searchParams.getAll("repo").join(" ");
43 return page(
44 {
45 ctx,
46 title: "Fleet graph",
47 description:
48 "Spend and runs over time for a selection of repositories, by hour and by day.",
49 wide: true,
50 body: html`<div class="page-head">
51 <h1>Fleet graph</h1>
52 <span class="spacer"></span>
53 ${btn("Table file", {
54 href: `/fleet/graph.csv${q === "" ? "" : `?${q}`}`,
55 size: "sm",
56 icon: "download",
57 })}
58 ${btn("JSON", {
59 href: `/api/v1/fleet/graph${q === "" ? "" : `?${q}`}`,
60 size: "sm",
61 variant: "secondary",
62 })}
63 <p class="muted">
64 What a selection of repositories spent and how many runs ended,
65 hour by hour and day by day.
66 </p>
67 </div>
68 <form method="get" action="/fleet/graph" class="fleet-pick">
69 <label for="fleet-repos">Repositories</label>
70 <input
71 id="fleet-repos"
72 name="repo"
73 value="${names}"
74 placeholder="owner/name owner/name — empty is everything you can write that spent"
75 autocomplete="off"
76 spellcheck="false"
77 />
78 ${btn("Draw", { type: "submit", variant: "secondary" })}
79 </form>
80 ${graph === undefined
81 ? html`<p class="error-note" role="alert">
82 ${asked.ok ? "" : asked.error}
83 </p>`
84 : fleetGraphSection(graph)}`,
85 scriptJs: fleetGraphClientJs,
86 },
87 graph === undefined ? 422 : 200,
88 );
89 }
90
91 /** GET /fleet/graph/live — the section alone, for the redraw. */
92 export function fleetGraphLive(ctx: Ctx): Response {
93 const user = ctx.auth.user;
94 if (!user) return fragment(html``, { status: 404 });
95 const asked = selection(ctx);
96 if (!asked.ok) return fragment(html``, { status: 422 });
97 return fragment(fleetGraphSection(readFleetGraph(ctx.app, user, asked.value)), {
98 request: ctx.request,
99 });
100 }
101
102 /**
103 * Every bucket of both windows as rows: window, bucket start, repository
104 * (or the folded series' label), runs, failed, spend in USD to the cent's
105 * hundredth — what the bars were drawn from, for a spreadsheet.
106 */
107 export function fleetGraphCsvText(graph: FleetGraph): string {
108 const cell = (v: string): string =>
109 /[",\r\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v;
110 const rows: string[][] = [
111 ["window", "bucket_start_utc", "series", "runs", "failed", "cost_usd"],
112 ];
113 for (const w of graph.windows)
114 for (const b of w.buckets)
115 graph.series.forEach((s, i) =>
116 rows.push([
117 w.name,
118 b.start,
119 s.label,
120 String(b.runs[i] ?? 0),
121 String(b.failed[i] ?? 0),
122 (b.costUsd[i] ?? 0).toFixed(4),
123 ]),
124 );
125 return rows.map((r) => r.map(cell).join(",")).join("\r\n") + "\r\n";
126 }
127
128 /** GET /fleet/graph.csv */
129 export function fleetGraphCsv(ctx: Ctx): Response {
130 const user = ctx.auth.user;
131 if (!user) return signInFirst(ctx);
132 const asked = selection(ctx);
133 if (!asked.ok)
134 return new Response(`${asked.error}\n`, {
135 status: 422,
136 headers: { "content-type": "text/plain; charset=utf-8" },
137 });
138 const graph = readFleetGraph(ctx.app, user, asked.value);
139 return new Response(fleetGraphCsvText(graph), {
140 headers: {
141 "content-type": "text/csv; charset=utf-8",
142 "content-disposition": 'attachment; filename="fleet-graph.csv"',
143 "cache-control": "private, no-store",
144 },
145 });
146 }
src/web/style/content.ts +28
@@ -298,6 +298,34 @@ a.origin-chip:hover { border-color: var(--border-strong); color: var(--fg); }
298298 .headroom { display: flex; align-items: center; gap: 6px; }
299299 .headroom .input { width: 5em; }
300300 .backups-now { margin-top: 12px; }
301 /* The fleet graph: a selection's spend and runs, hour by hour and day by day.
302 Series colours are Okabe–Ito (told apart with common colour-vision
303 deficiencies); the eighth, the folded "others", is grey. */
304 .fleet-pick { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin: 0 0 16px; }
305 .fleet-pick input { flex: 1 1 24em; min-width: 0; font-family: var(--mono); font-size: 13px; padding: 6px 8px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--fg); }
306 .fleet-legend { display: flex; flex-wrap: wrap; gap: 6px 16px; margin: 0 0 12px; padding: 0; list-style: none; font-size: 13px; }
307 .fleet-legend li { display: inline-flex; align-items: center; gap: 6px; }
308 .fleet-swatch { display: inline-block; width: 10px; height: 10px; border-radius: 2px; background: currentColor; vertical-align: middle; flex: none; }
309 .fleet-windows { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 380px), 1fr)); gap: 16px; margin: 0 0 16px; }
310 .fleet-window { margin: 0; }
311 .fleet-window figcaption { font-size: 13px; font-weight: 600; margin: 0 0 4px; display: flex; gap: 8px; align-items: baseline; }
312 .fleet-svg { display: block; width: 100%; height: auto; aspect-ratio: 600 / 230; }
313 .fleet-grid line { stroke: var(--border); stroke-width: 1; }
314 .fleet-ticks text { fill: var(--muted-fg); font-size: 10px; text-anchor: end; font-family: var(--mono); }
315 .fleet-axis text { fill: var(--muted-fg); font-size: 10px; text-anchor: middle; font-family: var(--mono); }
316 .fleet-panel-name text { fill: var(--faint); font-size: 10px; }
317 .fleet-table .num { text-align: right; font-variant-numeric: tabular-nums; }
318 .fleet-table th[scope="row"] { font-weight: 500; white-space: nowrap; text-align: left; }
319 .fleet-assume { margin-top: 8px; }
320 .fg-s0 { fill: #0072b2; color: #0072b2; }
321 .fg-s1 { fill: #e69f00; color: #e69f00; }
322 .fg-s2 { fill: #009e73; color: #009e73; }
323 .fg-s3 { fill: #cc79a7; color: #cc79a7; }
324 .fg-s4 { fill: #56b4e9; color: #56b4e9; }
325 .fg-s5 { fill: #d55e00; color: #d55e00; }
326 .fg-s6 { fill: #c9b800; color: #c9b800; }
327 .fg-s7 { fill: #8a8a8a; color: #8a8a8a; }
328 @media print { .fleet-pick { display: none; } }
301329 .backups-error { display: flex; align-items: center; gap: 8px; color: var(--danger); }
302330 .backups-restore { margin: 8px 0 0; padding: 8px 10px; font-size: 12px; white-space: pre-wrap; word-break: break-all; background: var(--muted); border-radius: var(--radius); }
303331 /* The records a domain waits on: type, name, value — as a zone file reads. */
test/fleet-graph.test.ts added +361
@@ -0,0 +1,361 @@
1 /**
2 * The fleet graph: a selection's spend and runs per UTC hour (24 h) and day
3 * (14 d). The buckets are checked against a hand count, the bars against
4 * the scale stated in `web/fleet-graph.ts`, and the selection against
5 * write access — a reader never sees another owner's spend, and a name they
6 * cannot see is counted with the missing ones, never told apart.
7 */
8
9 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
10 import { mkdtemp, rm } from "node:fs/promises";
11 import { tmpdir } from "node:os";
12 import { join } from "node:path";
13 import {
14 FLEET_GRAPH_MAX_REPOS,
15 fleetSelection,
16 readFleetGraph,
17 windowBounds,
18 } from "../src/actions/fleet-graph.ts";
19 import { createRepo } from "../src/actions/repos.ts";
20 import { createApp, type App } from "../src/app.ts";
21 import { createRun } from "../src/core/runs.ts";
22 import { createToken } from "../src/core/tokens.ts";
23 import { recordUsage, usageBuckets, type UsageOutcome } from "../src/core/usage.ts";
24 import { createUser, type User } from "../src/core/users.ts";
25 import { serve, type Servers } from "../src/http/server.ts";
26 import { niceCeiling, windowSvg } from "../src/web/fleet-graph.ts";
27 import { fleetGraphCsvText } from "../src/web/pages/fleet-graph.ts";
28
29 describe("the windows", () => {
30 const now = new Date("2026-10-02T14:37:12.345Z");
31
32 test("24 h is 24 whole UTC hours, the current one last and still filling", () => {
33 const w = windowBounds("24h", now);
34 expect(w.since.toISOString()).toBe("2026-10-01T15:00:00.000Z");
35 expect(w.until.toISOString()).toBe("2026-10-02T15:00:00.000Z");
36 expect(w.starts).toHaveLength(24);
37 expect(w.starts[23]!.toISOString()).toBe("2026-10-02T14:00:00.000Z");
38 });
39
40 test("14 d is 14 whole UTC days, today last", () => {
41 const w = windowBounds("14d", now);
42 // 2026-10-02 minus 13 days is 2026-09-19.
43 expect(w.since.toISOString()).toBe("2026-09-19T00:00:00.000Z");
44 expect(w.until.toISOString()).toBe("2026-10-03T00:00:00.000Z");
45 expect(w.starts).toHaveLength(14);
46 });
47 });
48
49 describe("the scale", () => {
50 test("the top tick is the smallest 1, 2, 2.5 or 5 × 10ⁿ at or above", () => {
51 expect(niceCeiling(3, 1)).toBe(5);
52 expect(niceCeiling(2.1, 1)).toBe(2.5);
53 expect(niceCeiling(0.42, 1)).toBeCloseTo(0.5, 12);
54 expect(niceCeiling(10, 1)).toBe(10);
55 expect(niceCeiling(11, 1)).toBe(20);
56 expect(niceCeiling(0, 1)).toBe(1);
57 });
58 });
59
60 describe("the selection", () => {
61 test("absent is the default; lists split on spaces and commas; names dedupe", () => {
62 expect(fleetSelection([])).toEqual({ ok: true, value: null });
63 expect(fleetSelection([""])).toEqual({ ok: true, value: null });
64 expect(fleetSelection(["alice/one, alice/two", "Alice/One"])).toEqual({
65 ok: true,
66 value: ["alice/one", "alice/two"],
67 });
68 });
69
70 test("anything not owner/name, or too many, is refused rather than trimmed", () => {
71 expect(fleetSelection(["../etc"]).ok).toBe(false);
72 expect(fleetSelection(["alice/<b>"]).ok).toBe(false);
73 expect(fleetSelection(["alice"]).ok).toBe(false);
74 const many = Array.from(
75 { length: FLEET_GRAPH_MAX_REPOS + 1 },
76 (_, i) => `a/r${i}`,
77 );
78 expect(fleetSelection(many).ok).toBe(false);
79 });
80 });
81
82 describe("the graph", () => {
83 let dataDir: string;
84 let app: App;
85 let alice: User;
86 let bob: User;
87 let servers: Servers;
88 const ids: Record<string, string> = {};
89 const now = new Date("2026-10-02T14:37:12.345Z");
90
91 const spend = (
92 repo: string,
93 at: string,
94 usd: number,
95 outcome: UsageOutcome = "ok",
96 ) =>
97 recordUsage(app.db, {
98 runId: createRun(app.db, { repoId: ids[repo]!, kind: "agent" }).id,
99 repoId: ids[repo]!,
100 issueId: null,
101 agentId: null,
102 subscriptionId: null,
103 tokensIn: 1000,
104 tokensOut: 100,
105 costUsd: usd,
106 wallMs: 60_000,
107 outcome,
108 at,
109 });
110
111 const signIn = async (username: string, password: string) =>
112 (
113 (
114 await fetch(`${servers.url}/signin`, {
115 method: "POST",
116 body: new URLSearchParams({ username, password }),
117 redirect: "manual",
118 })
119 ).headers.get("set-cookie") ?? ""
120 ).split(";")[0]!;
121
122 beforeAll(async () => {
123 dataDir = await mkdtemp(join(tmpdir(), "forge-fleet-graph-"));
124 app = createApp({ dataDir, port: 0 });
125 servers = await serve(app);
126 // The first account is the site admin, who may write everything; the
127 // owners below are ordinary accounts, so their spend is theirs alone.
128 const root = await createUser(app.db, {
129 username: "root",
130 email: "[email protected]",
131 password: "a-root-passphrase",
132 adminIfFirst: true,
133 });
134 if (!root.ok) throw new Error("root");
135 const a = await createUser(app.db, {
136 username: "alice",
137 email: "[email protected]",
138 password: "a-good-passphrase",
139 });
140 const b = await createUser(app.db, {
141 username: "bob",
142 email: "[email protected]",
143 password: "another-good-passphrase",
144 });
145 if (!a.ok || !b.ok) throw new Error("users");
146 alice = a.value;
147 bob = b.value;
148 for (const name of ["one", "two"]) {
149 const r = await createRepo(app, alice, { name, visibility: "private" });
150 if (!r.ok) throw new Error("repo");
151 ids[name] = r.value.id;
152 }
153 // Bob's repository is public: anyone may read it, only Bob may see its spend.
154 const three = await createRepo(app, bob, { name: "three", visibility: "public" });
155 if (!three.ok) throw new Error("repo");
156 ids["three"] = three.value.id;
157
158 spend("one", "2026-10-02T14:05:00.000Z", 1.25);
159 spend("one", "2026-10-02T14:50:00.000Z", 0.75, "failed"); // after `now`'s minute, same hour
160 spend("two", "2026-10-02T13:10:00.000Z", 2);
161 spend("one", "2026-09-25T10:00:00.000Z", 4); // in 14 d, not in 24 h
162 spend("one", "2026-09-18T23:59:59.999Z", 100); // a millisecond before the 14 d window
163 spend("three", "2026-10-02T14:10:00.000Z", 9); // Bob's
164 });
165
166 afterAll(async () => {
167 servers.stop();
168 await rm(dataDir, { recursive: true, force: true });
169 });
170
171 test("the ledger buckets by the exact UTC hour and day prefix", () => {
172 const rows = usageBuckets(
173 app.db,
174 [ids["one"]!],
175 "2026-10-02T00:00:00.000Z",
176 "hour",
177 );
178 expect(rows).toEqual([
179 {
180 repoId: ids["one"]!,
181 bucket: "2026-10-02T14",
182 runs: 2,
183 failed: 1,
184 costUsd: 2,
185 },
186 ]);
187 });
188
189 test("by default: what the viewer may write that spent, summed by hand", () => {
190 const g = readFleetGraph(app, alice, null, now);
191 expect(g.series.map((s) => s.label)).toEqual(["alice/one", "alice/two"]);
192 expect(g.hidden).toBe(0);
193 const [day, fortnight] = g.windows;
194 // 14:00 holds one's two runs: $1.25 + $0.75 = $2.00, one failed.
195 expect(day.buckets[23]!.start).toBe("2026-10-02T14:00:00.000Z");
196 expect(day.buckets[23]!.costUsd).toEqual([2, 0]);
197 expect(day.buckets[23]!.runs).toEqual([2, 0]);
198 expect(day.buckets[23]!.failed).toEqual([1, 0]);
199 expect(day.buckets[22]!.costUsd).toEqual([0, 2]);
200 expect(day.totals.costUsd).toEqual([2, 2]);
201 // 14 d: $2.00 today + $4.00 on 09-25 = $6.00; the $100 a millisecond
202 // before the window is not in it.
203 expect(fortnight.totals.costUsd).toEqual([6, 2]);
204 expect(fortnight.buckets[6]!.start).toBe("2026-09-25T00:00:00.000Z");
205 expect(fortnight.buckets[6]!.costUsd).toEqual([4, 0]);
206 expect(fortnight.buckets[13]!.runs).toEqual([2, 1]);
207 expect(g.repos.map((r) => [r.repo, r.fortnight.costUsd, r.day.runs])).toEqual([
208 ["alice/one", 6, 2],
209 ["alice/two", 2, 1],
210 ]);
211 });
212
213 test("another owner's spend is never drawn, and a hidden name is not told from a missing one", () => {
214 const g = readFleetGraph(
215 app,
216 alice,
217 ["alice/one", "bob/three", "nobody/nothing"],
218 now,
219 );
220 expect(g.repos.map((r) => r.repo)).toEqual(["alice/one"]);
221 expect(g.hidden).toBe(2);
222 // `asked` echoes the caller's own words; nothing drawn is Bob's.
223 expect(JSON.stringify({ ...g, asked: [] })).not.toContain("three");
224 const bobs = readFleetGraph(app, bob, null, now);
225 expect(bobs.repos.map((r) => r.repo)).toEqual(["bob/three"]);
226 });
227
228 test("past eight repositories the smallest share one series", async () => {
229 const names: string[] = [];
230 for (let i = 0; i < 9; i++) {
231 const r = await createRepo(app, alice, { name: `many${i}`, visibility: "private" });
232 if (!r.ok) throw new Error("repo");
233 ids[`many${i}`] = r.value.id;
234 names.push(`alice/many${i}`);
235 // many0 spends $9, many1 $8 … many8 $1.
236 spend(`many${i}`, "2026-10-02T10:00:00.000Z", 9 - i);
237 }
238 const g = readFleetGraph(app, alice, names, now);
239 expect(g.series).toHaveLength(8);
240 expect(g.series[7]!.label).toBe("2 others");
241 expect(g.series[7]!.repos).toEqual(["alice/many7", "alice/many8"]);
242 // $2 + $1 folded together.
243 expect(g.windows[0].totals.costUsd[7]).toBe(3);
244 expect(g.repos).toHaveLength(9);
245 expect(g.repos[8]!.series).toBe(7);
246 });
247
248 test("a bar's height is its value over the top tick times the panel height", () => {
249 const g = readFleetGraph(app, alice, ["alice/one", "alice/two"], now);
250 const svg = windowSvg(g, g.windows[0]).text;
251 // Tallest spend stack is $2.00 → top tick 2; tallest runs stack is 2 → 2.
252 expect(svg).toContain('data-spend-top="2"');
253 expect(svg).toContain('data-runs-top="2"');
254 const rect = (attr: string, bucket: string, series: number) =>
255 svg.match(
256 new RegExp(
257 `<rect class="fg-s${series}" data-series="${series}" data-bucket="${bucket}" data-${attr}="[^"]*"[^>]*height="([^"]+)"`,
258 ),
259 )?.[1];
260 // $2.00 / 2 × 120 px = 120 px; one run of two-tick 60 px panel = 30 px.
261 expect(rect("spend", "2026-10-02T14:00:00.000Z", 0)).toBe("120");
262 expect(rect("runs", "2026-10-02T14:00:00.000Z", 0)).toBe("60");
263 expect(rect("runs", "2026-10-02T13:00:00.000Z", 1)).toBe("30");
264 expect(svg).toContain("<title>alice/one · 2026-10-02 14:00 UTC · 2 runs, 1 failed</title>");
265 });
266
267 test("the table file has a row per window, bucket and series", () => {
268 const g = readFleetGraph(app, alice, ["alice/one", "alice/two"], now);
269 const lines = fleetGraphCsvText(g).trimEnd().split("\r\n");
270 // Header + (24 + 14) buckets × 2 series.
271 expect(lines).toHaveLength(1 + (24 + 14) * 2);
272 expect(lines[0]).toBe("window,bucket_start_utc,series,runs,failed,cost_usd");
273 expect(lines).toContain("24h,2026-10-02T14:00:00.000Z,alice/one,2,1,2.0000");
274 expect(lines).toContain("14d,2026-09-25T00:00:00.000Z,alice/one,1,0,4.0000");
275 });
276
277 describe("over HTTP", () => {
278 test("the page draws the selection and says what it assumes", async () => {
279 // A run that ended a minute ago, so the real clock's windows hold it.
280 spend("two", new Date(Date.now() - 60_000).toISOString(), 0.5);
281 const cookie = await signIn("alice", "a-good-passphrase");
282 const res = await fetch(`${servers.url}/fleet/graph?repo=alice/two`, {
283 headers: { cookie },
284 });
285 expect(res.status).toBe(200);
286 expect(res.headers.get("content-security-policy")).toContain("default-src 'none'");
287 expect(res.headers.get("x-content-type-options")).toBe("nosniff");
288 const body = await res.text();
289 expect(body).toContain('id="fleet-graph"');
290 expect(body).toContain('data-src="/fleet/graph/live?repo=alice%2Ftwo"');
291 expect(body).toContain('data-repo="alice/two"');
292 expect(body).toContain("Last 24 hours");
293 expect(body).toContain("in the UTC hour or day it ended");
294 expect(body).toContain('href="/fleet/graph.csv?repo=alice%2Ftwo"');
295
296 const live = await fetch(`${servers.url}/fleet/graph/live?repo=alice/two`, {
297 headers: { cookie },
298 });
299 expect(live.status).toBe(200);
300 const fragment = await live.text();
301 expect(fragment.trimStart().startsWith("<section")).toBe(true);
302 expect(fragment).toContain("$0.50");
303 });
304
305 test("another owner's selection shows nothing of theirs", async () => {
306 const cookie = await signIn("bob", "another-good-passphrase");
307 const body = await (
308 await fetch(`${servers.url}/fleet/graph?repo=alice/one`, { headers: { cookie } })
309 ).text();
310 expect(body).toContain("None of these repositories is yours");
311 expect(body).not.toContain('data-repo="alice/one"');
312 expect(body).toContain("1 of the names asked for is not shown");
313 });
314
315 test("signed out is sent to sign in; the fragment and the API refuse", async () => {
316 const page = await fetch(`${servers.url}/fleet/graph`, { redirect: "manual" });
317 expect([302, 303]).toContain(page.status);
318 expect(page.headers.get("location")).toContain("/signin");
319 expect((await fetch(`${servers.url}/fleet/graph/live`)).status).toBe(404);
320 expect((await fetch(`${servers.url}/api/v1/fleet/graph`)).status).toBe(401);
321 });
322
323 test("a malformed selection is refused, and its words are escaped", async () => {
324 const cookie = await signIn("alice", "a-good-passphrase");
325 const res = await fetch(
326 `${servers.url}/fleet/graph?repo=${encodeURIComponent('"><script>x</script>')}`,
327 { headers: { cookie } },
328 );
329 expect(res.status).toBe(422);
330 const body = await res.text();
331 expect(body).not.toContain("<script>x</script>");
332 expect(body).toContain("&lt;script&gt;");
333 });
334
335 test("the API answers the same numbers, and refuses a bad name", async () => {
336 const token = createToken(app.db, alice.id, "ro", "ro").token;
337 const auth = { authorization: `Bearer ${token}` };
338 const bad = await fetch(`${servers.url}/api/v1/fleet/graph?repo=nope`, {
339 headers: auth,
340 });
341 expect(bad.status).toBe(422);
342 expect(((await bad.json()) as { error: string }).error).toBe("invalid_selection");
343 const res = await fetch(
344 `${servers.url}/api/v1/fleet/graph?repo=alice/one&repo=bob/three`,
345 { headers: auth },
346 );
347 expect(res.status).toBe(200);
348 const g = (await res.json()) as {
349 series: { label: string }[];
350 hidden: number;
351 windows: { name: string; buckets: unknown[] }[];
352 };
353 expect(g.series.map((s) => s.label)).toEqual(["alice/one"]);
354 expect(g.hidden).toBe(1);
355 expect(g.windows.map((w) => [w.name, w.buckets.length])).toEqual([
356 ["24h", 24],
357 ["14d", 14],
358 ]);
359 });
360 });
361 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs