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

The fleet graph is reachable: the board, the spend page and each app's page link to /fleet/graph with their selection #10982

Merged opened this

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

Merged

scout/forge:scout/578-the-fleet-graph-is-reachable-the-board-t → main 6 commits fork 3 threads · 3 unresolved

  • Merged. scout/578-the-fleet-graph-is-reachable-the-board-t was merged into main as 33131c3.

Review

Sign in to review this change.
14 files changed +153 −21
docs/features/fleet-graph-doors.md added +11
@@ -0,0 +1,11 @@
1 # Ways to the fleet graph
2
3 The fleet graph (`/fleet/graph`) shows runs and spend by the hour and by the day. Wherever spend is shown, you can reach the graph in one step, already narrowed to what you were looking at. The graph checks the names again (`fleetSelection`) and shows only what you may see.
4
5 **On the web** (each link is a plain `<a>`, and each name goes in as its own URL-encoded `repo=`, built by `fleetGraphHref` in `src/web/fleet-graph.ts`):
6
7 - **Board** (`/board`): "Fleet graph" sits beside the Tokens line. It is a 44px target, and on a phone it wraps under the line. With no `?apps=` it opens your whole graph. With `?apps=` it opens exactly the apps the board lists whose spend you may see (`narrowedGraphHref`). When that list is empty there is no link, so it never shows the whole fleet in place of the apps you asked for.
8 - **Spend** (`/settings/spend`): "Fleet graph" above the tables opens your whole graph. Each `owner/name` in "By app" opens the graph on that app.
9 - **An app's page**: under "On its own", Graph opens the graph on that app. Only the owner sees it, as with the rest of that block.
10
11 **In the terminal:** on Usage, `f` opens the fleet graph. On a repository's Usage (the palette's "Usage of a repository"), `f` opens the graph on that repository alone, titled `Fleet graph · owner/name` (`src/tui/screens/usage.ts`, `src/tui/screens/fleetgraph.ts`). `w` switches between 24 h and 14 d.
docs/features/forge-usage-terminal.md +1 −1
@@ -8,4 +8,4 @@ Press `:` (or Ctrl-K), type `Usage` and press enter, with no repository named. A
88
99 The spend is only of the apps you write: a member sees their own, an admin sees the forge's. A signed-out visitor is not offered the entry.
1010
11 Below the spend, an admin who is not an agent also sees every route, unused ones first; anyone else is told that list is an admin's. `[` and `]` move the Returns window, `f` opens the fleet graph. A repository's Usage (`Usage of a repository`) is unchanged and has no such lines.
11 Below the spend, an admin who is not an agent also sees every route, unused ones first; anyone else is told that list is an admin's. `[` and `]` move the Returns window, `f` opens the fleet graph. A repository's Usage (`Usage of a repository`) has no such lines; its `f` opens the fleet graph on that repository alone.
src/tui/screens/fleetgraph.ts +6 −4
@@ -3,7 +3,9 @@
33 * sparkline row per series (`FLEET_GRAPH_MAX_SERIES` at most, the rest folded
44 * as "N others"), over the last 24 hours by hour or 14 days by day, `w`
55 * 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.
6 * so who may see spend is that action's rule, never this screen's. Opened
7 * from a repository's Usage it holds that repository alone, as the web's
8 * Graph button on an app's page does (`/fleet/graph?repo=`, #578).
79 */
810
911 import { FLEET_GRAPH_MAX_SERIES, readFleetGraph, type FleetWindow, type FleetWindowName } from "../../actions/fleet-graph.ts";
@@ -52,13 +54,13 @@ export function fleetLines(w: FleetWindow, labels: readonly string[], width: num
5254 return { runs: rows("runs", (n) => `${n} ${n === 1 ? "run" : "runs"}`), spend: rows("costUsd", usd) };
5355 }
5456
55 export function fleetGraphScreen(f: Session, ctx: Ctx): Screen {
57 export function fleetGraphScreen(f: Session, ctx: Ctx, asked: readonly string[] | null = null): Screen {
5658 let which: FleetWindowName = "24h";
5759 // 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);
60 const graph = f.user === undefined ? undefined : readFleetGraph(f.app, f.user, asked);
5961 const labels = (graph?.series ?? []).slice(0, FLEET_GRAPH_MAX_SERIES).map((s) => s.label);
6062 return {
61 title: "Fleet graph",
63 title: asked === null ? "Fleet graph" : `Fleet graph · ${asked.join(", ")}`,
6264 keys: [binding(["w"], "24 h / 14 d")],
6365 render(area, s) {
6466 const c = ctx.theme.c;
src/tui/screens/usage.ts +4 −3
@@ -89,7 +89,7 @@ export function usageScreen(f: Session, ctx: Ctx, repo?: Repo): Screen {
8989 keys: [
9090 binding(["j", "k"], "move cursor", { hidden: rows.length === 0 }),
9191 binding(["[", "]"], "returns window", { hidden: returns === undefined }),
92 binding(["f"], "fleet graph", { hidden: repo !== undefined }),
92 binding(["f"], "fleet graph"),
9393 ],
9494 render(area, s) {
9595 const c = ctx.theme.c;
@@ -109,8 +109,9 @@ export function usageScreen(f: Session, ctx: Ctx, repo?: Repo): Screen {
109109 if (repo === undefined) drawList(s, list!, c, state, usageRows(all));
110110 },
111111 key: (e) => {
112 if (repo === undefined && !e.ctrl && !e.alt && e.key === "f") {
113 ctx.push(fleetGraphScreen(f, ctx));
112 if (!e.ctrl && !e.alt && e.key === "f") {
113 // A repository's Usage opens the graph on that repository alone.
114 ctx.push(fleetGraphScreen(f, ctx, scope.repo === undefined ? null : [scope.repo]));
114115 return true;
115116 }
116117 if (returns !== undefined && !e.ctrl && !e.alt && (e.key === "[" || e.key === "]")) {
src/web/fleet-graph.ts +32 −3
@@ -20,9 +20,10 @@
2020 * because nothing moved. The fragment route returns this same section.
2121 */
2222
23 import type {
24 FleetGraph,
25 FleetWindow,
23 import {
24 FLEET_GRAPH_MAX_REPOS,
25 type FleetGraph,
26 type FleetWindow,
2627 } from "../actions/fleet-graph.ts";
2728 import { html, type Html } from "./html.ts";
2829 import { seriesDot } from "./ui.ts";
@@ -228,6 +229,34 @@ export function selectionQuery(graph: FleetGraph): string {
228229 : graph.asked.map((n) => `repo=${encodeURIComponent(n)}`).join("&");
229230 }
230231
232 /**
233 * The way to the graph from another page, already holding what that page
234 * shows: one `repo=` per owner/name, each URL-encoded. None is every
235 * repository the viewer may write, the graph's own default. The graph
236 * re-validates the names (`fleetSelection`); this only spells them.
237 */
238 export function fleetGraphHref(names: readonly string[]): string {
239 const q = [...new Set(names)]
240 .map((n) => `repo=${encodeURIComponent(n)}`)
241 .join("&");
242 return q === "" ? "/fleet/graph" : `/fleet/graph?${q}`;
243 }
244
245 /**
246 * The graph for a page that may be narrowed: not narrowed is the graph's
247 * default, the viewer's own; narrowed is exactly those names, and none (or
248 * more than one graph holds) is no link, so a link never widens or quietly
249 * cuts what the page showed.
250 */
251 export function narrowedGraphHref(
252 narrowed: boolean,
253 names: readonly string[],
254 ): string | null {
255 if (!narrowed) return fleetGraphHref([]);
256 if (names.length === 0 || names.length > FLEET_GRAPH_MAX_REPOS) return null;
257 return fleetGraphHref(names);
258 }
259
231260 /**
232261 * The whole graph: legend, both windows, the table and what it assumes. The
233262 * same markup the live fragment returns, so a redraw replaces it whole.
src/web/pages/board.ts +12 −4
@@ -50,6 +50,7 @@ import {
5050 } from "../../core/subscriptions.ts";
5151 import { readSpend } from "../../actions/spend.ts";
5252 import { spendView } from "./spend.ts";
53 import { narrowedGraphHref } from "../fleet-graph.ts";
5354 import type { HandContext } from "../../crew/handover.ts";
5455 import { forgeHealth, rolloutLine } from "../../kernel/guard.ts";
5556 import { coveredByVerdict, forgeVerdict, verdictLine } from "../../kernel/guard_verdict.ts";
@@ -506,9 +507,9 @@ function returnsStrip(ctx: Ctx, scope: BoardScope): Html | null {
506507 // The crew spends on every repository, app or not: the whole of the
507508 // viewer's own unless `?apps=` narrows the board.
508509 const apps = ctx.url.searchParams.get("apps");
509 const ids = apps
510 ? scope.repos.filter((r) => scope.ownerFacts.has(r.id)).map((r) => r.id)
511 : returnsRepos(ctx.app, user).map((r) => r.id);
510 // One list for the money and the graph's link, so the two never disagree.
511 const owned = apps ? scope.repos.filter((r) => scope.ownerFacts.has(r.id)) : [];
512 const ids = apps ? owned.map((r) => r.id) : returnsRepos(ctx.app, user).map((r) => r.id);
512513 if (ids.length === 0) return null;
513514 const now = Date.now();
514515 const r = tokenReturns(ctx.app.db, ids, {
@@ -518,7 +519,13 @@ function returnsStrip(ctx: Ctx, scope: BoardScope): Html | null {
518519 if (r.spent.runs === 0) return null;
519520 const ask = readThroughput(ctx.app.db, [], new Date(now)).medianAskToLiveMs;
520521 const href = `/board/returns${apps ? `?apps=${encodeURIComponent(apps)}` : ""}`;
521 return html`<a class="muted small" id="returns-line" href="${href}"
522 // The graph of the same money, narrowed as the board is (#578).
523 const graph = narrowedGraphHref(
524 apps !== null && apps !== "",
525 owned.map((r) => `${r.ownerName}/${r.name}`),
526 );
527 return html`<span id="returns"
528 ><a class="muted small" id="returns-line" href="${href}"
outdated

Empty selection after the ownerFacts filter becomes the unfiltered graph: widens what the board showed. Test the ?apps= case too.

522529 >Tokens, ${ASKS_WINDOW_DAYS} days: <b>${usd(r.spent.usd)}</b> → <b>${r.landed}</b> landed
523530 ${r.onLanded.perChange.changes === 0
524531 ? null
@@ -527,6 +534,7 @@ function returnsStrip(ctx: Ctx, scope: BoardScope): Html | null {
527534 · <b>${usd(r.landedNothing.open.usd)}</b> still open${
528535 ask === null ? null : html` · median ask→live <b>${hoursSpan(ask)}</b> (24 h)`
529536 }</a
537 >${graph === null ? null : html`<a id="returns-graph" href="${graph}">Fleet graph</a>`}</span
530538 >`;
531539 }
532540
src/web/pages/board_style.ts +3
@@ -14,6 +14,9 @@ export const boardCss = /* css */ `
1414 #notes ul { list-style: none; margin: 4px 0 0; padding: 0; max-height: 30dvh; overflow: auto; }
1515 #notes li > * { display: flex; align-items: center; flex-wrap: wrap; gap: 4px 8px; min-height: 44px; overflow-wrap: anywhere; }
1616 #notes li > a { display: block; padding-block: 12px; min-height: 0; }
17 /* The spend line's way to the fleet graph: a 44px target beside it, wrapping under it on a phone. */
18 #returns { display: flex; flex-wrap: wrap; align-items: center; column-gap: 12px; }
19 #returns-graph { display: inline-flex; align-items: center; min-height: 44px; padding-inline: 8px; }
1720 .board #board-live { display: inline-flex; align-items: center; }
1821 .board #board-clock { min-width: 5ch; text-align: right; }
1922 .board [data-started] { min-width: 7ch; text-align: right; }
src/web/pages/spend.ts +5 −1
@@ -7,6 +7,7 @@
77 */
88
99 import type { Spend, SpendLine, SpanName } from "../../actions/spend.ts";
10 import { fleetGraphHref } from "../fleet-graph.ts";
1011 import { html, type Html } from "../html.ts";
1112
1213 const usd = (n: number): string => `$${n.toFixed(2)}`;
@@ -71,7 +72,9 @@ function table(spend: Spend, dim: Dimension, title: string): Html {
7172 const of = at.get(n)!;
7273 const w = of.get("week");
7374 return html`<tr>
74 <th scope="row">${n}</th>
75 <th scope="row">${dim === "byApp" && n.includes("/")
76 ? html`<a href="${fleetGraphHref([n])}" title="This app on the fleet graph">${n}</a>`
77 : n}</th>
7578 ${spend.spans.map(
7679 (s) =>
7780 html`<td class="num">${usd(of.get(s.span)?.costUsd ?? 0)}</td>`,
@@ -100,6 +103,7 @@ export function spendView(spend: Spend): Html {
100103 >`,
101104 )}
102105 </p>
106 <p><a href="${fleetGraphHref([])}">Fleet graph</a></p>
outdated

Principle 1 and 8: this sentence explains what the per-app links already show, in two clauses. One short line, or none.

103107 ${DIMENSIONS.map(([dim, title]) => table(spend, dim, title))}
104108 <p class="muted" id="spend-top">The 10 costliest runs, 7 days</p>
105109 <div class="table-scroll">
src/web/studio/app.ts +6
@@ -10,6 +10,7 @@
1010 */
1111
1212 import { appCardPath } from "../og/path.ts";
13 import { fleetGraphHref } from "../fleet-graph.ts";
1314 import { sealedScope } from "../../core/sealed.ts";
1415 import { egressSetting } from "../../core/capacity.ts";
1516 import { egressNote } from "./egress.ts";
@@ -444,6 +445,11 @@ export async function studioApp(
444445 head: html`${icon("play")}
445446 <h2>On its own</h2>
446447 <span class="spacer"></span>
448 ${btn("Graph", {
449 href: fleetGraphHref([`${repo.ownerName}/${repo.name}`]),
450 size: "sm",
451 variant: "secondary",
452 })}
447453 ${btn("Change", { href: `${base}/settings`, size: "sm" })}`,
448454 extra: "about",
449455 },
test/fleet-graph-href.test.ts added +45
@@ -0,0 +1,45 @@
1 /**
2 * The way to the fleet graph from the board, the spend page and an app's
3 * page (#578): one `repo=` per name, URL-encoded, so whatever a page holds
4 * arrives as the graph's selection and nothing in it can break the query
5 * or the attribute it is printed in.
6 */
7 import { expect, test } from "bun:test";
8 import { fleetSelection } from "../src/actions/fleet-graph.ts";
9 import { FLEET_GRAPH_MAX_REPOS } from "../src/actions/fleet-graph.ts";
10 import { fleetGraphHref, narrowedGraphHref } from "../src/web/fleet-graph.ts";
11 import { html } from "../src/web/html.ts";
12
13 test("no names is the graph's own default", () => {
14 expect(fleetGraphHref([])).toBe("/fleet/graph");
15 });
16
17 test("each name is one encoded repo=, once, and the graph reads them back", () => {
18 const href = fleetGraphHref(["alice/mill", "bob/ledger-app", "alice/mill"]);
19 expect(href).toBe("/fleet/graph?repo=alice%2Fmill&repo=bob%2Fledger-app");
20 const back = new URL(href, "http://x").searchParams.getAll("repo");
21 expect(fleetSelection(back)).toEqual({ ok: true, value: ["alice/mill", "bob/ledger-app"] });
22 });
23
24 test("a hostile name cannot add a parameter or leave the attribute", () => {
25 const href = fleetGraphHref([`a/b&repo=evil"><script>`]);
26 expect(new URL(href, "http://x").searchParams.getAll("repo")).toEqual([
27 `a/b&repo=evil"><script>`,
28 ]);
29 const out = String(html`<a href="${href}">graph</a>`);
30 expect(out).not.toContain("<script>");
31 expect(out).not.toContain('"><');
32 });
33
34 test("a board not narrowed links the viewer's whole graph", () => {
35 expect(narrowedGraphHref(false, [])).toBe("/fleet/graph");
36 expect(narrowedGraphHref(false, ["alice/mill"])).toBe("/fleet/graph");
37 });
38
39 test("a narrowed board links exactly its apps, and nothing rather than wider or cut", () => {
40 expect(narrowedGraphHref(true, ["alice/mill"])).toBe("/fleet/graph?repo=alice%2Fmill");
41 // `?apps=` that names nothing the viewer owns: no link, not the whole fleet.
42 expect(narrowedGraphHref(true, [])).toBeNull();
43 const many = Array.from({ length: FLEET_GRAPH_MAX_REPOS + 1 }, (_, i) => `a/r${i}`);
44 expect(narrowedGraphHref(true, many)).toBeNull();
45 });
test/returns.test.ts +14
@@ -10,6 +10,7 @@ import { mkdtemp, rm } from "node:fs/promises";
1010 import { tmpdir } from "node:os";
1111 import { join } from "node:path";
1212 import { createApp, type App } from "../src/app.ts";
13 import { enableApp } from "../src/actions/apps.ts";
1314 import { addToCrew, createAgent } from "../src/actions/crew.ts";
1415 import { openIssue, setIssueState } from "../src/actions/issues.ts";
1516 import { openPull } from "../src/actions/pulls.ts";
@@ -308,6 +309,19 @@ describe("what the tokens bought", () => {
308309 expect(line).toContain("<b>$2.40</b> closed with nothing");
309310 expect(line).toContain("<b>$0.10</b> still open");
310311 expect(line).not.toContain("landed nothing");
312 // The spend line's way to the fleet graph: the viewer's whole selection,
313 // the graph's own default, when nothing narrows the board (#578).
314 expect(board).toContain('<a id="returns-graph" href="/fleet/graph">Fleet graph</a>');
315 // Narrowed to something holding none of her money (mill is no app, so
316 // not on the board): no line and no graph link, never the whole fleet.
317 const narrowed = await (await get("/board?apps=alice/mill", { cookie: aliceCookie })).text();
318 expect(narrowed).not.toContain('id="returns-line"');
319 expect(narrowed).not.toContain('id="returns-graph"');
320 // Once mill is an app the narrowed board carries it, and the graph only it.
321 const enabled = enableApp(app, alice, repo, { slug: "mill" });
322 if (!enabled.ok) throw new Error(String(enabled.error));
323 const asked = await (await get("/board?apps=alice/mill", { cookie: aliceCookie })).text();
324 expect(asked).toContain('<a id="returns-graph" href="/fleet/graph?repo=alice%2Fmill">Fleet graph</a>');
311325 const page = (
312326 await (await get("/board/returns", { cookie: aliceCookie })).text()
313327 ).replace(/\s+/g, " ");
test/spend-tab.test.ts +3
@@ -111,5 +111,8 @@ test("a forge admin sees the seeded run's dollars by credential and by app", asy
111111 expect(body).toContain("work-plan");
112112 expect(body).toContain("bob/ledger-app");
113113 expect(body).toContain("$12.34");
114 // The graph is one click away, whole or already narrowed to an app (#578).
115 expect(body).toContain('<p><a href="/fleet/graph">Fleet graph</a></p>');
116 expect(body).toContain('<a href="/fleet/graph?repo=bob%2Fledger-app" title="This app on the fleet graph">bob/ledger-app</a>');
114117 expect(res.headers.get("server-timing") ?? "").toMatch(/(^|, )spend;dur=\d+\.\d/);
115118 });
test/studio-shipping.test.ts +3
@@ -219,6 +219,7 @@ describe("a stranger sees the app, not the owner's books", () => {
219219 expect(page).not.toContain("keeper");
220220 expect(page).not.toContain(">Decided<");
221221 expect(page).not.toContain(">On its own<");
222 expect(page).not.toContain("/fleet/graph");
222223 expect(page).not.toContain(">Slots<");
223224 expect(page).not.toContain("Nobody yet");
224225 // The app itself is still there: what it is, that it shipped, a way in.
@@ -235,6 +236,8 @@ describe("a stranger sees the app, not the owner's books", () => {
235236 expect(owner).toContain("$42.50");
236237 expect(owner).toContain("keeper");
237238 expect(owner).toContain(">On its own<");
239 // One click from the app's spend to the fleet graph, scoped to this app (#578).
240 expect(owner).toContain('href="/fleet/graph?repo=alice%2Fbeacon"');
238241 expect(owner).toContain(">Decided<");
239242 // Three served deploys this week — 2.0s, 4.0s and 6.0s — median 4.0s;
240243 // the fourth, never served, does not drag it toward zero.
test/tui-fleetgraph.test.ts +8 −5
@@ -1,5 +1,6 @@
11 /**
2 * The terminal's fleet graph (#8048): `f` on Usage opens it; with usage seeded in two
2 * The terminal's fleet graph (#8048): `f` on Usage opens it (on a repository's
3 * Usage, that repository alone); with usage seeded in two
34 * repositories the screen names both and their totals are `readFleetGraph`'s; `w` moves
45 * between 24 h and 14 d; an empty ledger, or a viewer who may not see the spend, reads
56 * "Nothing spent in this window."
@@ -124,14 +125,16 @@ test("someone who may not see the spend gets nothing: the action's rule", () =>
124125 expect(graph.windows[0].totals.costUsd).toEqual([]);
125126 });
126127
127 test("f does nothing on a repository's Usage", async () => {
128 test("f on a repository's Usage opens the graph on that repository alone (#578)", async () => {
128129 const h = boot("alice");
129130 await h.openRepoUsage("alice/mill");
130131 expect(h.text()).toContain("Usage · alice/mill");
131 expect(h.text()).not.toContain("fleet graph");
132 expect(h.text()).toContain("fleet graph");
132133 await h.key("f");
133 expect(h.text()).toContain("Usage · alice/mill");
134 expect(h.text()).not.toContain("over 24 h");
134 const one = h.text();
135 expect(one).toContain("Fleet graph · alice/mill");
136 expect(one).toContain("$3.50");
137 expect(one).not.toContain("alice/kiln");
135138 h.stop();
136139 });
137140

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs