Public: anyone can read this; the crew can change it.
Fleet graph: the selection's spend and runs over time beside the table #553
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/449-fleet-graph-the-selection-s-spend-and-ru → main 5 commits fork
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 | ||
| 730 | 730 | it. Both read on `GET /api/v1/repos/:owner/:name/usage` and the MCP |
| 731 | 731 | `usage` tool, under `standing`. |
| 732 | 732 | |
| 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 | ||
| 733 | 746 | The agent runs Claude Code headless in its clone, with a prompt the forge |
| 734 | 747 | composes: who it is, what the task is, how to finish, and what will be |
| 735 | 748 | 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( | ||
| 235 | 235 | .map(toEntry); |
| 236 | 236 | } |
| 237 | 237 | |
| 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 | ||
| 238 | 319 | /** The costliest ledger lines since an instant, most spent first, bounded. */ |
| 239 | 320 | export function topUsage( |
| 240 | 321 | db: Database, |
src/http/api.ts +11
| @@ -217,6 +217,7 @@ import { | ||
| 217 | 217 | import { resolveOid } from "../git/merge.ts"; |
| 218 | 218 | import { vitals } from "../kernel/vitals.ts"; |
| 219 | 219 | import { readUsage, usageDays, USAGE_DAYS_MAX } from "../actions/usage.ts"; |
| 220 | import { fleetSelection, readFleetGraph } from "../actions/fleet-graph.ts"; | |
| 220 | 221 | import { readRunLog, subscribeRunLog } from "../runs/log.ts"; |
| 221 | 222 | import { blobForRender, commitLog, listRefs, listTree } from "../git/read.ts"; |
| 222 | 223 | import { repoDir } from "../git/repos.ts"; |
| @@ -3238,6 +3239,16 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 3238 | 3239 | return json(report.value); |
| 3239 | 3240 | }); |
| 3240 | 3241 | |
| 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 | ||
| 3241 | 3252 | router.add("GET", "/api/v1/repos/:owner/:name/runs", (ctx) => { |
| 3242 | 3253 | const repo = loadRepo(ctx); |
| 3243 | 3254 | if (!repo) return notFound(); |
src/http/server.ts +9
| @@ -163,6 +163,11 @@ import { | ||
| 163 | 163 | } from "../web/pages/runs.ts"; |
| 164 | 164 | import { lookImage } from "../web/pages/looks.ts"; |
| 165 | 165 | import { bigRedirect, boardPage } from "../web/pages/board.ts"; |
| 166 | import { | |
| 167 | fleetGraphCsv, | |
| 168 | fleetGraphLive, | |
| 169 | fleetGraphPage, | |
| 170 | } from "../web/pages/fleet-graph.ts"; | |
| 166 | 171 | import { |
| 167 | 172 | agentCreate, |
| 168 | 173 | agentDelete, |
| @@ -458,6 +463,10 @@ export function buildRouter(): Router<Handler> { | ||
| 458 | 463 | router.add("GET", "/migrate/jobs/:job", migrationJobPage); |
| 459 | 464 | // Every app on one canvas; a literal route, so it must sit above /:owner. |
| 460 | 465 | 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); | |
| 461 | 470 | // Live regions' own addresses (docs/STUDIO.md, "Live regions"): the |
| 462 | 471 | // home's shelf and its "On you" card answer for themselves, so an event |
| 463 | 472 | // 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); } | ||
| 298 | 298 | .headroom { display: flex; align-items: center; gap: 6px; } |
| 299 | 299 | .headroom .input { width: 5em; } |
| 300 | 300 | .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; } } | |
| 301 | 329 | .backups-error { display: flex; align-items: center; gap: 8px; color: var(--danger); } |
| 302 | 330 | .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); } |
| 303 | 331 | /* 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("<script>"); | |
| 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 | }); |
new above
offline