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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/578-the-fleet-graph-is-reachable-the-board-t → main 6 commits fork 3 threads · 3 unresolved
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 | ||
| 8 | 8 | |
| 9 | 9 | 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. |
| 10 | 10 | |
| 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 @@ | ||
| 3 | 3 | * sparkline row per series (`FLEET_GRAPH_MAX_SERIES` at most, the rest folded |
| 4 | 4 | * as "N others"), over the last 24 hours by hour or 14 days by day, `w` |
| 5 | 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. | |
| 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). | |
| 7 | 9 | */ |
| 8 | 10 | |
| 9 | 11 | 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 | ||
| 52 | 54 | return { runs: rows("runs", (n) => `${n} ${n === 1 ? "run" : "runs"}`), spend: rows("costUsd", usd) }; |
| 53 | 55 | } |
| 54 | 56 | |
| 55 | export function fleetGraphScreen(f: Session, ctx: Ctx): Screen { | |
| 57 | export function fleetGraphScreen(f: Session, ctx: Ctx, asked: readonly string[] | null = null): Screen { | |
| 56 | 58 | let which: FleetWindowName = "24h"; |
| 57 | 59 | // 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); | |
| 59 | 61 | const labels = (graph?.series ?? []).slice(0, FLEET_GRAPH_MAX_SERIES).map((s) => s.label); |
| 60 | 62 | return { |
| 61 | title: "Fleet graph", | |
| 63 | title: asked === null ? "Fleet graph" : `Fleet graph · ${asked.join(", ")}`, | |
| 62 | 64 | keys: [binding(["w"], "24 h / 14 d")], |
| 63 | 65 | render(area, s) { |
| 64 | 66 | 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 { | ||
| 89 | 89 | keys: [ |
| 90 | 90 | binding(["j", "k"], "move cursor", { hidden: rows.length === 0 }), |
| 91 | 91 | binding(["[", "]"], "returns window", { hidden: returns === undefined }), |
| 92 | binding(["f"], "fleet graph", { hidden: repo !== undefined }), | |
| 92 | binding(["f"], "fleet graph"), | |
| 93 | 93 | ], |
| 94 | 94 | render(area, s) { |
| 95 | 95 | const c = ctx.theme.c; |
| @@ -109,8 +109,9 @@ export function usageScreen(f: Session, ctx: Ctx, repo?: Repo): Screen { | ||
| 109 | 109 | if (repo === undefined) drawList(s, list!, c, state, usageRows(all)); |
| 110 | 110 | }, |
| 111 | 111 | 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])); | |
| 114 | 115 | return true; |
| 115 | 116 | } |
| 116 | 117 | if (returns !== undefined && !e.ctrl && !e.alt && (e.key === "[" || e.key === "]")) { |
src/web/fleet-graph.ts +32 −3
| @@ -20,9 +20,10 @@ | ||
| 20 | 20 | * because nothing moved. The fragment route returns this same section. |
| 21 | 21 | */ |
| 22 | 22 | |
| 23 | import type { | |
| 24 | FleetGraph, | |
| 25 | FleetWindow, | |
| 23 | import { | |
| 24 | FLEET_GRAPH_MAX_REPOS, | |
| 25 | type FleetGraph, | |
| 26 | type FleetWindow, | |
| 26 | 27 | } from "../actions/fleet-graph.ts"; |
| 27 | 28 | import { html, type Html } from "./html.ts"; |
| 28 | 29 | import { seriesDot } from "./ui.ts"; |
| @@ -228,6 +229,34 @@ export function selectionQuery(graph: FleetGraph): string { | ||
| 228 | 229 | : graph.asked.map((n) => `repo=${encodeURIComponent(n)}`).join("&"); |
| 229 | 230 | } |
| 230 | 231 | |
| 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 | ||
| 231 | 260 | /** |
| 232 | 261 | * The whole graph: legend, both windows, the table and what it assumes. The |
| 233 | 262 | * same markup the live fragment returns, so a redraw replaces it whole. |
src/web/pages/board.ts +12 −4
| @@ -50,6 +50,7 @@ import { | ||
| 50 | 50 | } from "../../core/subscriptions.ts"; |
| 51 | 51 | import { readSpend } from "../../actions/spend.ts"; |
| 52 | 52 | import { spendView } from "./spend.ts"; |
| 53 | import { narrowedGraphHref } from "../fleet-graph.ts"; | |
| 53 | 54 | import type { HandContext } from "../../crew/handover.ts"; |
| 54 | 55 | import { forgeHealth, rolloutLine } from "../../kernel/guard.ts"; |
| 55 | 56 | import { coveredByVerdict, forgeVerdict, verdictLine } from "../../kernel/guard_verdict.ts"; |
| @@ -506,9 +507,9 @@ function returnsStrip(ctx: Ctx, scope: BoardScope): Html | null { | ||
| 506 | 507 | // The crew spends on every repository, app or not: the whole of the |
| 507 | 508 | // viewer's own unless `?apps=` narrows the board. |
| 508 | 509 | 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); | |
| 512 | 513 | if (ids.length === 0) return null; |
| 513 | 514 | const now = Date.now(); |
| 514 | 515 | const r = tokenReturns(ctx.app.db, ids, { |
| @@ -518,7 +519,13 @@ function returnsStrip(ctx: Ctx, scope: BoardScope): Html | null { | ||
| 518 | 519 | if (r.spent.runs === 0) return null; |
| 519 | 520 | const ask = readThroughput(ctx.app.db, [], new Date(now)).medianAskToLiveMs; |
| 520 | 521 | 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}" | |
| 522 | 529 | >Tokens, ${ASKS_WINDOW_DAYS} days: <b>${usd(r.spent.usd)}</b> → <b>${r.landed}</b> landed |
| 523 | 530 | ${r.onLanded.perChange.changes === 0 |
| 524 | 531 | ? null |
| @@ -527,6 +534,7 @@ function returnsStrip(ctx: Ctx, scope: BoardScope): Html | null { | ||
| 527 | 534 | · <b>${usd(r.landedNothing.open.usd)}</b> still open${ |
| 528 | 535 | ask === null ? null : html` · median ask→live <b>${hoursSpan(ask)}</b> (24 h)` |
| 529 | 536 | }</a |
| 537 | >${graph === null ? null : html`<a id="returns-graph" href="${graph}">Fleet graph</a>`}</span | |
| 530 | 538 | >`; |
| 531 | 539 | } |
| 532 | 540 |
src/web/pages/board_style.ts +3
| @@ -14,6 +14,9 @@ export const boardCss = /* css */ ` | ||
| 14 | 14 | #notes ul { list-style: none; margin: 4px 0 0; padding: 0; max-height: 30dvh; overflow: auto; } |
| 15 | 15 | #notes li > * { display: flex; align-items: center; flex-wrap: wrap; gap: 4px 8px; min-height: 44px; overflow-wrap: anywhere; } |
| 16 | 16 | #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; } | |
| 17 | 20 | .board #board-live { display: inline-flex; align-items: center; } |
| 18 | 21 | .board #board-clock { min-width: 5ch; text-align: right; } |
| 19 | 22 | .board [data-started] { min-width: 7ch; text-align: right; } |
src/web/pages/spend.ts +5 −1
| @@ -7,6 +7,7 @@ | ||
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | 9 | import type { Spend, SpendLine, SpanName } from "../../actions/spend.ts"; |
| 10 | import { fleetGraphHref } from "../fleet-graph.ts"; | |
| 10 | 11 | import { html, type Html } from "../html.ts"; |
| 11 | 12 | |
| 12 | 13 | const usd = (n: number): string => `$${n.toFixed(2)}`; |
| @@ -71,7 +72,9 @@ function table(spend: Spend, dim: Dimension, title: string): Html { | ||
| 71 | 72 | const of = at.get(n)!; |
| 72 | 73 | const w = of.get("week"); |
| 73 | 74 | 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> | |
| 75 | 78 | ${spend.spans.map( |
| 76 | 79 | (s) => |
| 77 | 80 | html`<td class="num">${usd(of.get(s.span)?.costUsd ?? 0)}</td>`, |
| @@ -100,6 +103,7 @@ export function spendView(spend: Spend): Html { | ||
| 100 | 103 | >`, |
| 101 | 104 | )} |
| 102 | 105 | </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. |
||
| 103 | 107 | ${DIMENSIONS.map(([dim, title]) => table(spend, dim, title))} |
| 104 | 108 | <p class="muted" id="spend-top">The 10 costliest runs, 7 days</p> |
| 105 | 109 | <div class="table-scroll"> |
src/web/studio/app.ts +6
| @@ -10,6 +10,7 @@ | ||
| 10 | 10 | */ |
| 11 | 11 | |
| 12 | 12 | import { appCardPath } from "../og/path.ts"; |
| 13 | import { fleetGraphHref } from "../fleet-graph.ts"; | |
| 13 | 14 | import { sealedScope } from "../../core/sealed.ts"; |
| 14 | 15 | import { egressSetting } from "../../core/capacity.ts"; |
| 15 | 16 | import { egressNote } from "./egress.ts"; |
| @@ -444,6 +445,11 @@ export async function studioApp( | ||
| 444 | 445 | head: html`${icon("play")} |
| 445 | 446 | <h2>On its own</h2> |
| 446 | 447 | <span class="spacer"></span> |
| 448 | ${btn("Graph", { | |
| 449 | href: fleetGraphHref([`${repo.ownerName}/${repo.name}`]), | |
| 450 | size: "sm", | |
| 451 | variant: "secondary", | |
| 452 | })} | |
| 447 | 453 | ${btn("Change", { href: `${base}/settings`, size: "sm" })}`, |
| 448 | 454 | extra: "about", |
| 449 | 455 | }, |
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"; | ||
| 10 | 10 | import { tmpdir } from "node:os"; |
| 11 | 11 | import { join } from "node:path"; |
| 12 | 12 | import { createApp, type App } from "../src/app.ts"; |
| 13 | import { enableApp } from "../src/actions/apps.ts"; | |
| 13 | 14 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; |
| 14 | 15 | import { openIssue, setIssueState } from "../src/actions/issues.ts"; |
| 15 | 16 | import { openPull } from "../src/actions/pulls.ts"; |
| @@ -308,6 +309,19 @@ describe("what the tokens bought", () => { | ||
| 308 | 309 | expect(line).toContain("<b>$2.40</b> closed with nothing"); |
| 309 | 310 | expect(line).toContain("<b>$0.10</b> still open"); |
| 310 | 311 | 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>'); | |
| 311 | 325 | const page = ( |
| 312 | 326 | await (await get("/board/returns", { cookie: aliceCookie })).text() |
| 313 | 327 | ).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 | ||
| 111 | 111 | expect(body).toContain("work-plan"); |
| 112 | 112 | expect(body).toContain("bob/ledger-app"); |
| 113 | 113 | 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>'); | |
| 114 | 117 | expect(res.headers.get("server-timing") ?? "").toMatch(/(^|, )spend;dur=\d+\.\d/); |
| 115 | 118 | }); |
test/studio-shipping.test.ts +3
| @@ -219,6 +219,7 @@ describe("a stranger sees the app, not the owner's books", () => { | ||
| 219 | 219 | expect(page).not.toContain("keeper"); |
| 220 | 220 | expect(page).not.toContain(">Decided<"); |
| 221 | 221 | expect(page).not.toContain(">On its own<"); |
| 222 | expect(page).not.toContain("/fleet/graph"); | |
| 222 | 223 | expect(page).not.toContain(">Slots<"); |
| 223 | 224 | expect(page).not.toContain("Nobody yet"); |
| 224 | 225 | // 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", () => { | ||
| 235 | 236 | expect(owner).toContain("$42.50"); |
| 236 | 237 | expect(owner).toContain("keeper"); |
| 237 | 238 | 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"'); | |
| 238 | 241 | expect(owner).toContain(">Decided<"); |
| 239 | 242 | // Three served deploys this week — 2.0s, 4.0s and 6.0s — median 4.0s; |
| 240 | 243 | // the fourth, never served, does not drag it toward zero. |
test/tui-fleetgraph.test.ts +8 −5
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | /** |
| 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 | |
| 3 | 4 | * repositories the screen names both and their totals are `readFleetGraph`'s; `w` moves |
| 4 | 5 | * between 24 h and 14 d; an empty ledger, or a viewer who may not see the spend, reads |
| 5 | 6 | * "Nothing spent in this window." |
| @@ -124,14 +125,16 @@ test("someone who may not see the spend gets nothing: the action's rule", () => | ||
| 124 | 125 | expect(graph.windows[0].totals.costUsd).toEqual([]); |
| 125 | 126 | }); |
| 126 | 127 | |
| 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 () => { | |
| 128 | 129 | const h = boot("alice"); |
| 129 | 130 | await h.openRepoUsage("alice/mill"); |
| 130 | 131 | expect(h.text()).toContain("Usage · alice/mill"); |
| 131 | expect(h.text()).not.toContain("fleet graph"); | |
| 132 | expect(h.text()).toContain("fleet graph"); | |
| 132 | 133 | 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"); | |
| 135 | 138 | h.stop(); |
| 136 | 139 | }); |
| 137 | 140 |
Empty selection after the ownerFacts filter becomes the unfiltered graph: widens what the board showed. Test the ?apps= case too.