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

Merged opened this

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

Merged

scout/forge:scout/3161-the-fleet-graph-redraws-when-the-event-s → main 2 commits fork

  • Merged. scout/3161-the-fleet-graph-redraws-when-the-event-s was merged into main as af33bc4.

Review

Sign in to review this change.
3 files changed +111 −12
src/web/fleet-graph.ts +35 −10
@@ -14,9 +14,10 @@
1414 * viewBox units, beside the geometry it has to fit, not in the stylesheet.
1515 *
1616 * `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.
2021 */
2122
2223 import type {
@@ -36,8 +37,20 @@ const AXIS = 18;
3637 const SVG_H = TOP + SPEND_H + GAP + RUNS_H + AXIS;
3738 const PLOT_W = SVG_W - LEFT - RIGHT;
3839
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;
4154
4255 /** The smallest 1, 2, 2.5 or 5 × 10ⁿ at or above `v`; `floor` when v is 0. */
4356 export function niceCeiling(v: number, floor: number): number {
@@ -254,7 +267,7 @@ export function fleetGraphSection(graph: FleetGraph): Html {
254267 <time datetime="${graph.at}">${graph.at.slice(11, 16)} UTC</time>. A
255268 run counts in the UTC hour or day it ended; spend is the provider's
256269 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.
258271 </p>
259272 </section>`;
260273 }
@@ -281,12 +294,24 @@ export const fleetGraphClientJs = /* js */ `
281294 .catch(function () {})
282295 .then(function () { busy = false; });
283296 }
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 });
287313 document.addEventListener('visibilitychange', function () {
288314 if (document.visibilityState === 'visible') redraw();
289315 });
290 document.addEventListener('forge:fleet-op', redraw);
291316 })();
292317 `;
test/fleet-graph.test.ts +76 −1
@@ -23,7 +23,12 @@ import { createToken } from "../src/core/tokens.ts";
2323 import { recordUsage, usageBuckets, type UsageOutcome } from "../src/core/usage.ts";
2424 import { createUser, type User } from "../src/core/users.ts";
2525 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";
2732 import { fleetGraphCsvText } from "../src/web/pages/fleet-graph.ts";
2833 import { promote } from "./helpers/members.ts";
2934
@@ -363,3 +368,73 @@ describe("the graph", () => {
363368 });
364369 });
365370 });
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 }> = {
1414 "src/web/pages/migrate.ts": { allow: ["refresh: {", 'http-equiv="refresh"'], why: "migrate progress runs in a process that emits no events" },
1515 "src/web/pages/browse.ts": { allow: ["refresh: {"], why: "mirror progress runs in a process that emits no events" },
1616 "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" },
1817 };
1918
2019 const PATTERNS: [string, RegExp][] = [

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs