Public: anyone can read this; the crew can change it.
The fleet graph redraws when the event stream says spend or runs moved, never on a 30 s timer #3171
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/3161-the-fleet-graph-redraws-when-the-event-s → main 2 commits fork
3 files
changed +111
−12
src/web/fleet-graph.ts +35 −10
| @@ -14,9 +14,10 @@ | ||
| 14 | 14 | * viewBox units, beside the geometry it has to fit, not in the stylesheet. |
| 15 | 15 | * |
| 16 | 16 | * `fleetGraphSection` carries `data-src`: the client script below fetches |
| 17 | * it every 30 s while the tab is visible, and at once on a | |
| 18 | * `forge:fleet-op` event, so an operation's effect is drawn within a minute | |
| 19 | * without a reload. The fragment route returns this same section. | |
| 17 | * it when the page's one `/events` stream (`forge:event`) carries a kind in | |
| 18 | * `FLEET_GRAPH_EVENTS`, at most once a second, and when the tab becomes | |
| 19 | * visible. There is no timer: a quiet forge never redraws, which is correct | |
| 20 | * because nothing moved. The fragment route returns this same section. | |
| 20 | 21 | */ |
| 21 | 22 | |
| 22 | 23 | import type { |
| @@ -36,8 +37,20 @@ const AXIS = 18; | ||
| 36 | 37 | const SVG_H = TOP + SPEND_H + GAP + RUNS_H + AXIS; |
| 37 | 38 | const PLOT_W = SVG_W - LEFT - RIGHT; |
| 38 | 39 | |
| 39 | /** How often the open page redraws on its own, in ms. */ | |
| 40 | export const FLEET_GRAPH_REFRESH_MS = 30_000; | |
| 40 | /** | |
| 41 | * The event kinds that move spend or runs: a run starting, a spend tick | |
| 42 | * mid-run (the runner records a `running` usage row and appends | |
| 43 | * `usage.delta`), and a run ending. The one-second coalescing below is what | |
| 44 | * keeps the tick rate cheap. | |
| 45 | */ | |
| 46 | export const FLEET_GRAPH_EVENTS: readonly string[] = [ | |
| 47 | "run.started", | |
| 48 | "usage.delta", | |
| 49 | "run.finished", | |
| 50 | ]; | |
| 51 | ||
| 52 | /** Redraws are coalesced to at most one in this many ms. */ | |
| 53 | export const FLEET_GRAPH_MIN_GAP_MS = 1000; | |
| 41 | 54 | |
| 42 | 55 | /** The smallest 1, 2, 2.5 or 5 × 10ⁿ at or above `v`; `floor` when v is 0. */ |
| 43 | 56 | export function niceCeiling(v: number, floor: number): number { |
| @@ -254,7 +267,7 @@ export function fleetGraphSection(graph: FleetGraph): Html { | ||
| 254 | 267 | <time datetime="${graph.at}">${graph.at.slice(11, 16)} UTC</time>. A |
| 255 | 268 | run counts in the UTC hour or day it ended; spend is the provider's |
| 256 | 269 | reported cost in US dollars. The newest bar is still filling. Redrawn |
| 257 | every ${FLEET_GRAPH_REFRESH_MS / 1000} s while open. | |
| 270 | when spend or runs move while open. | |
| 258 | 271 | </p> |
| 259 | 272 | </section>`; |
| 260 | 273 | } |
| @@ -281,12 +294,24 @@ export const fleetGraphClientJs = /* js */ ` | ||
| 281 | 294 | .catch(function () {}) |
| 282 | 295 | .then(function () { busy = false; }); |
| 283 | 296 | } |
| 284 | setInterval(function () { | |
| 285 | if (document.visibilityState === 'visible') redraw(); | |
| 286 | }, ${FLEET_GRAPH_REFRESH_MS}); | |
| 297 | var kinds = ${JSON.stringify(FLEET_GRAPH_EVENTS)}; | |
| 298 | var last = 0; | |
| 299 | var timer = null; | |
| 300 | // At most one redraw a second: a burst of events draws once, trailing. | |
| 301 | function soon() { | |
| 302 | if (timer !== null) return; | |
| 303 | timer = setTimeout(function () { | |
| 304 | timer = null; | |
| 305 | last = Date.now(); | |
| 306 | redraw(); | |
| 307 | }, Math.max(0, last + ${FLEET_GRAPH_MIN_GAP_MS} - Date.now())); | |
| 308 | } | |
| 309 | document.addEventListener('forge:event', function (e) { | |
| 310 | var kind = e.detail && e.detail.kind; | |
| 311 | if (kinds.indexOf(kind) !== -1) soon(); | |
| 312 | }); | |
| 287 | 313 | document.addEventListener('visibilitychange', function () { |
| 288 | 314 | if (document.visibilityState === 'visible') redraw(); |
| 289 | 315 | }); |
| 290 | document.addEventListener('forge:fleet-op', redraw); | |
| 291 | 316 | })(); |
| 292 | 317 | `; |
test/fleet-graph.test.ts +76 −1
| @@ -23,7 +23,12 @@ import { createToken } from "../src/core/tokens.ts"; | ||
| 23 | 23 | import { recordUsage, usageBuckets, type UsageOutcome } from "../src/core/usage.ts"; |
| 24 | 24 | import { createUser, type User } from "../src/core/users.ts"; |
| 25 | 25 | import { serve, type Servers } from "../src/http/server.ts"; |
| 26 | import { niceCeiling, windowSvg } from "../src/web/fleet-graph.ts"; | |
| 26 | import { | |
| 27 | FLEET_GRAPH_EVENTS, | |
| 28 | fleetGraphClientJs, | |
| 29 | niceCeiling, | |
| 30 | windowSvg, | |
| 31 | } from "../src/web/fleet-graph.ts"; | |
| 27 | 32 | import { fleetGraphCsvText } from "../src/web/pages/fleet-graph.ts"; |
| 28 | 33 | import { promote } from "./helpers/members.ts"; |
| 29 | 34 | |
| @@ -363,3 +368,73 @@ describe("the graph", () => { | ||
| 363 | 368 | }); |
| 364 | 369 | }); |
| 365 | 370 | }); |
| 371 | ||
| 372 | describe("the redraw", () => { | |
| 373 | test("the client script has no timer loop and names its events in one list", () => { | |
| 374 | expect(fleetGraphClientJs).not.toContain("setInterval"); | |
| 375 | expect(FLEET_GRAPH_EVENTS).toEqual(["run.started", "usage.delta", "run.finished"]); | |
| 376 | expect(fleetGraphClientJs).toContain(JSON.stringify(FLEET_GRAPH_EVENTS)); | |
| 377 | expect(fleetGraphClientJs).toContain("forge:event"); | |
| 378 | expect(fleetGraphClientJs).toContain("visibilitychange"); | |
| 379 | }); | |
| 380 | ||
| 381 | test("the source file carries no setInterval or refresh constant", async () => { | |
| 382 | const src = await Bun.file("src/web/fleet-graph.ts").text(); | |
| 383 | expect(src).not.toMatch(/setInterval\s*\(/); | |
| 384 | expect(src).not.toContain("FLEET_GRAPH_REFRESH_MS"); | |
| 385 | }); | |
| 386 | }); | |
| 387 | ||
| 388 | describe("the redraw, run against a stub page", () => { | |
| 389 | function page() { | |
| 390 | const listeners: Record<string, ((e: unknown) => void)[]> = {}; | |
| 391 | const fetched: string[] = []; | |
| 392 | const timers: { fn: () => void; ms: number }[] = []; | |
| 393 | let now = 10_000; | |
| 394 | const section = { getAttribute: () => "/fleet/graph/live", replaceWith() {} }; | |
| 395 | const document = { | |
| 396 | visibilityState: "visible", | |
| 397 | getElementById: () => section, | |
| 398 | addEventListener: (n: string, f: (e: unknown) => void) => (listeners[n] ??= []).push(f), | |
| 399 | importNode: (x: unknown) => x, | |
| 400 | }; | |
| 401 | const fetch = (src: string) => { | |
| 402 | fetched.push(src); | |
| 403 | return Promise.resolve({ ok: false }); | |
| 404 | }; | |
| 405 | new Function("document", "fetch", "DOMParser", "setTimeout", "Date", fleetGraphClientJs)( | |
| 406 | document, fetch, class {}, (fn: () => void, ms: number) => timers.push({ fn, ms }), | |
| 407 | { now: () => now }, | |
| 408 | ); | |
| 409 | const emit = (kind: string) => listeners["forge:event"]?.forEach((f) => f({ detail: { kind } })); | |
| 410 | const flush = async () => { await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); }; | |
| 411 | return { emit, fetched, timers, flush, advance: (ms: number) => { now += ms; } }; | |
| 412 | } | |
| 413 | ||
| 414 | test("a matching kind draws once, a non-matching kind never", async () => { | |
| 415 | const p = page(); | |
| 416 | p.emit("issue.opened"); | |
| 417 | expect(p.timers).toHaveLength(0); | |
| 418 | for (const kind of FLEET_GRAPH_EVENTS) { | |
| 419 | const q = page(); | |
| 420 | q.emit(kind); | |
| 421 | expect(q.timers).toHaveLength(1); | |
| 422 | q.timers[0]!.fn(); | |
| 423 | expect(q.fetched).toEqual(["/fleet/graph/live"]); | |
| 424 | } | |
| 425 | }); | |
| 426 | ||
| 427 | test("a burst inside the gap coalesces to one trailing redraw", async () => { | |
| 428 | const p = page(); | |
| 429 | p.emit("usage.delta"); | |
| 430 | p.timers[0]!.fn(); | |
| 431 | await p.flush(); | |
| 432 | p.advance(200); | |
| 433 | p.emit("usage.delta"); | |
| 434 | p.emit("run.finished"); | |
| 435 | expect(p.timers).toHaveLength(2); | |
| 436 | expect(p.timers[1]!.ms).toBe(800); | |
| 437 | p.timers[1]!.fn(); | |
| 438 | expect(p.fetched).toHaveLength(2); | |
| 439 | }); | |
| 440 | }); |
test/no-polling.test.ts −1
| @@ -14,7 +14,6 @@ const ALLOWED: Record<string, { allow: string[]; why: string }> = { | ||
| 14 | 14 | "src/web/pages/migrate.ts": { allow: ["refresh: {", 'http-equiv="refresh"'], why: "migrate progress runs in a process that emits no events" }, |
| 15 | 15 | "src/web/pages/browse.ts": { allow: ["refresh: {"], why: "mirror progress runs in a process that emits no events" }, |
| 16 | 16 | "src/web/client.ts": { allow: ["data-live-every", "setInterval"], why: "the data-live-every mechanism itself, and clocks that only redraw text" }, |
| 17 | "src/web/fleet-graph.ts": { allow: ["setInterval"], why: "no fleet event yet; remove once it has one" }, | |
| 18 | 17 | }; |
| 19 | 18 | |
| 20 | 19 | const PATTERNS: [string, RegExp][] = [ |
new above
offline