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

Migrated from github.com/Trevato/SO-ARM100

workbenchthis machine

A Data view to explore the arm: people, precision, latency and health, over time, on any screen #519

Parked opened this in answer to
Closed without merging

quill/arm:quill/508-a-data-view-to-explore-the-arm-people-pr → main 2 commits fork

  • Closed without merging. Nothing was applied to main.

Review

Sign in to review this change.
17 files changed +1910 −3
Software/so101/README.md +13
@@ -48,3 +48,16 @@ The owner can test a joint's P (the position gain, 1–64) on the real arm, with
4848 3. keeps the new P only if p95 tracking error and overshoot are both no worse and one is better; otherwise, and on any failure or STOP, writes the old P back.
4949
5050 Nobody can drive while it runs, and it will not start while someone does (409; 423 on an E-stop or hold). Only one run at a time. Progress shows in the Servos section (reload the page) and with `/tune` alone, which also gives the last result (`GET /tuning`). Every result is stored with the deployed commit and listed newest first in the Precision panel (joint, P from→to, error and overshoot before→after, kept or put back) and as `tunings` in `GET /precision`. All of it is the owner's: anyone else gets 404.
51
52 ## The Data view
53
54 `/admin/data` (owner and moderators; linked from `/admin` and from the Room card in the app's Admin tab) explores how the arm is doing over time: an overview strip (visitors, sessions, drivers, minutes driven, snap-backs per 100 releases, p95 time to arrive, p95 command → confirmed motion, jams, uptime), then precision (snap-backs by cause, per-joint p95 settle error and arrival, the worst sessions with a session detail), latency (command → motion over time and per hop as p50–p95 bands, and a histogram of turns by p95), people (in the room now, visitors anonymous against named, where people drop off, wait in line, turn length, chat) and health (arm-link uptime, reconnects, app restarts by cause, STOPs, camera fps). Choose Hour, Day, Week or All, drag across any chart to zoom (every chart follows), tick "Compare to the period before" for a faint line of the previous span, and hover, tap or arrow-key a chart for exact values. Dashed upright lines mark a deployed commit (app grey, Pi violet). It is a page of its own, so nothing of it loads with the camera, and its only script is inline, pinned by hash in its Content-Security-Policy. The charts are SVG drawn by hand (`src/ui/data-view.js`): no dependency was added.
55
56 What is kept, in `facts.sqlite` under `FORGE_APP_DATA` (`src/facts.ts`), 90 days and then dropped:
57 - **turns**: one row per turn: who (a user id, or a 16-hex hash of an anonymous browser's id; never an address), start, length, why it ended, the wait in line, the app's commit, and, when the arm answers `/precision` just after the turn, its releases, snap-backs by cause, jams, p95 arrive and tracking, and per-hop and per-joint p50/p95.
58 - **events**: visits, names set, joining the line, chat lines (counted, not their text), STOP, jams and zone blocks as they begin, link reconnects, app starts (`deploy` when the commit changed, else `restart`) and the Pi's commit when it changes.
59 - **samples**, every minute: watchers, drivers, queue length, the arm link live or not, each camera's fps.
60
61 `GET /data?from=<ms>&to=<ms>&step=<s>[&compare=1]` is the view's one read (anyone else: 403). `step` is one of 60, 300, 900, 1800, 3600, 10800, 21600, 86400 seconds (at most 400 steps; left out, about 96). `GET /data?turn=<id>` is one turn with the arm's events around it. How each number is made (sums before ratios; a step's p95 is the p95 of its turns' p95s, so one bad turn shows) is written at the top of `src/data-series.ts`.
62
63 Not yet shown: Pi temperature and under-voltage (the Pi logs them in `/var/log/so101-health.log`, but the arm server does not serve them yet), and per-turn joint traces of intent against actual (the arm keeps only each turn's summary).
Software/so101/bench/fake-serve.ts +4
@@ -11,6 +11,7 @@
1111 * (src/accounts.ts, kept in accounts.sqlite); the forge signs who its own signed-in people are with FORGE_APP_IDENTITY_KEY.
1212 * Run on one's own machine, away from the forge and with no key, every visitor is the owner.
1313 * The owner's settings (pause, turn length, speed cap, announcement) and their log live beside the chat (src/admin.ts).
14 * What happens (turns, visits, the link, the room each minute) is kept for the Data view in facts.sqlite (src/facts.ts).
1415 */
1516 import { mkdirSync, mkdtempSync } from "node:fs";
1617 import { tmpdir } from "node:os";
@@ -21,6 +22,8 @@ import { FakeBus } from "../src/fake-bus.ts";
2122 import { CameraHub } from "../src/cameras.ts";
2223 import { LeaderCalibrations } from "../src/leader-calibrations.ts";
2324 import { Lobby } from "../src/lobby.ts";
25 import { Facts } from "../src/facts.ts";
26 import { deployedCommit } from "../src/precision-history.ts";
2427 import { Moderator, modelFromEnv } from "../src/moderator.ts";
2528 import { parseRemote, parseVideo, startRelay, type Remote } from "../src/relay.ts";
2629 import { piSyncFromEnv } from "../src/pi-deploy.ts";
@@ -59,6 +62,7 @@ const lobby = new Lobby({
5962 reportsFile: join(dir, "reports.json"),
6063 accounts: new Accounts({ file: join(dir, "accounts.sqlite") }),
6164 moderator: new Moderator({ file: join(dir, "crew.json"), model: modelFromEnv(process.env) }),
65 facts: new Facts({ file: join(dir, "facts.sqlite"), commit: deployedCommit() }),
6266 });
6367 const cameras = new CameraHub({ file: join(dir, "cameras.json"), seed: remote ? [{ name: "Camera 1", source: remote.cameras[0]! }] : [] });
6468
Software/so101/src/admin-page.ts +1 −1
@@ -320,7 +320,7 @@ export function renderAdmin(p: AdminPage): string {
320320 "Admin · SO-101",
321321 `<header>
322322 <h1>Admin</h1>
323 <div class="state"><span class="pill${s.paused ? " paused" : ""}">${s.paused ? "Paused" : "Open"}</span><span>${d ? `<b>${esc(d.name)}</b> has control` : "Nobody has control"}</span><span><b>${p.snapshot.queue.length}</b> waiting</span><a href="/admin/camera-list">Cameras</a><a href="/">Back to the arm</a></div>
323 <div class="state"><span class="pill${s.paused ? " paused" : ""}">${s.paused ? "Paused" : "Open"}</span><span>${d ? `<b>${esc(d.name)}</b> has control` : "Nobody has control"}</span><span><b>${p.snapshot.queue.length}</b> waiting</span><a href="/admin/data">Data</a><a href="/admin/camera-list">Cameras</a><a href="/">Back to the arm</a></div>
324324 </header>
325325 ${flash}
326326 <section aria-labelledby="now"><h2 id="now">Right now</h2>
Software/so101/src/arm-edges.ts added +25
@@ -0,0 +1,25 @@
1 /**
2 * The arm's status, 25 times a second, turned into the moments the Data view marks: a joint that starts to jam, a
3 * move the zone starts to block. Only the edge counts; a jam held for a minute is one jam.
4 */
5
6 export type Edge = { kind: "jam"; joints: number[] } | { kind: "block" };
7
8 export class ArmEdges {
9 #jammed = new Set<number>();
10 #blocked = false;
11
12 /** What began with this status. Anything not the expected shape reads as nothing. */
13 see(status: unknown): Edge[] {
14 const s = status && typeof status === "object" ? (status as { jammed?: unknown; blocked?: unknown }) : {};
15 const jammed = new Set(Array.isArray(s.jammed) ? s.jammed.filter((j): j is number => Number.isSafeInteger(j)) : []);
16 const blocked = Boolean(s.blocked);
17 const out: Edge[] = [];
18 const fresh = [...jammed].filter((j) => !this.#jammed.has(j));
19 if (fresh.length) out.push({ kind: "jam", joints: fresh });
20 if (blocked && !this.#blocked) out.push({ kind: "block" });
21 this.#jammed = jammed;
22 this.#blocked = blocked;
23 return out;
24 }
25 }
Software/so101/src/data-api.test.ts added +93
@@ -0,0 +1,93 @@
1 import { afterEach, expect, test } from "bun:test";
2 import { mkdtempSync, rmSync } from "node:fs";
3 import { tmpdir } from "node:os";
4 import { join } from "node:path";
5 import { Facts } from "./facts.ts";
6 import { sign } from "./identity.ts";
7 import { Lobby } from "./lobby.ts";
8 import { startRelay, type RunningRelay } from "./relay.ts";
9
10 const KEY = "k".repeat(64);
11 const later = Date.now() + 600_000;
12 const as = (name: string, role: "user" | "admin") => {
13 const identity = `id-${name}|${name}|${role}|${later}`;
14 return { "x-forge-identity": identity, "x-forge-signature": sign(identity, KEY) };
15 };
16 const owner = as("trevato", "admin");
17
18 let dir = "";
19 let relay: RunningRelay | undefined;
20 let lobby: Lobby | undefined;
21 afterEach(async () => {
22 await relay?.close();
23 lobby?.close();
24 if (dir) rmSync(dir, { recursive: true, force: true });
25 dir = "";
26 relay = lobby = undefined;
27 });
28
29 async function boot() {
30 dir = mkdtempSync(join(tmpdir(), "so101-data-api-"));
31 const facts = new Facts({ file: join(dir, "facts.sqlite"), commit: "abc1234def56" });
32 lobby = new Lobby({ identityKey: KEY, adminFile: join(dir, "admin.json"), countsFile: join(dir, "visitors.json"), facts });
33 relay = await startRelay({ port: 0, simulation: "http://127.0.0.1:1", lobby });
34 return { base: `http://127.0.0.1:${relay.port}`, facts };
35 }
36
37 test("GET /data: anonymous and plain users get 403; the owner and a moderator get the series", async () => {
38 const { base, facts } = await boot();
39 expect((await fetch(`${base}/data`)).status).toBe(403);
40 expect((await fetch(`${base}/data`, { headers: as("eve", "user") })).status).toBe(403);
41 await fetch(`${base}/admin`, { method: "POST", headers: { ...owner, "content-type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ action: "mod-add", name: "bob" }), redirect: "manual" });
42
43 facts.turnStarted("u:id-ada", "ada");
44 const id = facts.turnEnded("u:id-ada", "time")!;
45 facts.event("stop");
46 for (const who of [owner, as("bob", "user")]) {
47 const res = await fetch(`${base}/data?compare=1`, { headers: who });
48 expect(res.status).toBe(200);
49 expect(res.headers.get("cache-control")).toBe("no-store");
50 expect(res.headers.get("x-content-type-options")).toBe("nosniff");
51 const body = (await res.json()) as Record<string, any>;
52 expect(body.step).toBe(900);
53 expect(body.t).toHaveLength(96);
54 expect(body.overview.sessions).toBe(1);
55 expect(body.commit).toBe("abc1234def56");
56 expect(body.versions[0]).toMatchObject({ where: "app", commit: "abc1234def56" });
57 expect(body.previous.overview.sessions).toBe(0);
58 expect(body.live).toEqual({ watchers: 0, driving: 0, queue: 0 });
59 }
60
61 const one = (await (await fetch(`${base}/data?turn=${id}`, { headers: owner })).json()) as Record<string, any>;
62 expect(one.turn).toMatchObject({ id, who: "u:id-ada", reason: "time" });
63 expect(one.events.map((e: { kind: string }) => e.kind)).toContain("stop");
64 expect(one.events.every((e: Record<string, unknown>) => !("who" in e))).toBe(true);
65 expect((await fetch(`${base}/data?turn=${id}`)).status).toBe(403);
66 });
67
68 test("GET /data refuses a bad range or turn with a 400 that says what to send; POST is 405", async () => {
69 const { base } = await boot();
70 for (const q of ["from=yesterday", "from=10&to=5", "step=7", "compare=yes", "turn=-1"]) {
71 const res = await fetch(`${base}/data?${q}`, { headers: owner });
72 expect(res.status).toBe(400);
73 expect(((await res.json()) as { error: string }).error).toBeTruthy();
74 }
75 expect((await fetch(`${base}/data?turn=999`, { headers: owner })).status).toBe(404);
76 expect((await fetch(`${base}/data`, { method: "POST", headers: owner })).status).not.toBe(200);
77 });
78
79 test("/admin/data: not found to anyone but the owner and moderators; the page pins its script by hash", async () => {
80 const { base } = await boot();
81 expect((await fetch(`${base}/admin/data`)).status).toBe(404);
82 expect((await fetch(`${base}/admin/data`, { headers: as("eve", "user") })).status).toBe(404);
83 const res = await fetch(`${base}/admin/data`, { headers: owner });
84 expect(res.status).toBe(200);
85 const csp = res.headers.get("content-security-policy")!;
86 expect(csp).toContain("default-src 'none'");
87 expect(csp).toMatch(/script-src 'sha256-[\w+/=]+'/);
88 expect(csp).not.toContain("unsafe");
89 expect(res.headers.get("referrer-policy")).toBe("same-origin");
90 const html = await res.text();
91 expect(html).toContain("<h1>Data</h1>");
92 expect(await (await fetch(`${base}/admin`, { headers: owner })).text()).toContain('href="/admin/data"');
93 });
Software/so101/src/data-api.ts added +51
@@ -0,0 +1,51 @@
1 /**
2 * GET /data: the Data view's one read, for the owner and moderators (anyone else: 403).
3 * ?from=<ms>&to=<ms>&step=<s>[&compare=1] the series over the range, per step (src/data-series.ts); compare=1 adds
4 * `previous`, the same length of time just before, on the same steps
5 * ?turn=<id> one turn: its row, and the events and samples from a minute before to a minute after
6 * Every parameter is checked here; a bad one is a 400 that says what to send.
7 */
8
9 import type { IncomingMessage, ServerResponse } from "node:http";
10 import type { Lobby } from "./lobby.ts";
11 import { aggregate, parseRange } from "./data-series.ts";
12
13 const MINUTE = 60_000;
14 /** Shown on a turn's timeline: what happened to the arm, not who opened the page or spoke. */
15 const ARM_EVENTS = new Set(["stop", "jam", "block", "park", "reconnect", "restart", "pi-version"]);
16
17 function answer(res: ServerResponse, status: number, body: unknown, head = false): void {
18 res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store" });
19 res.end(head ? undefined : JSON.stringify(body));
20 }
21
22 export function serveData(lobby: Lobby | undefined, req: IncomingMessage, res: ServerResponse, url: URL, now = Date.now()): void {
23 const facts = lobby?.facts;
24 if (!lobby || !facts) return answer(res, 404, { error: "no data is kept here" });
25 if (!lobby.who(req) || !lobby.moderates(req)) return answer(res, 403, { error: "the data is the owner's and the moderators'" });
26 if (req.method !== "GET" && req.method !== "HEAD") {
27 res.setHeader("allow", "GET, HEAD");
28 return answer(res, 405, { error: "GET" });
29 }
30 const head = req.method === "HEAD";
31 const turn = url.searchParams.get("turn");
32 if (turn !== null) {
33 if (!/^\d{1,12}$/.test(turn)) return answer(res, 400, { error: "turn is a turn's id" });
34 const row = facts.turn(Number(turn));
35 if (!row) return answer(res, 404, { error: "no such turn" });
36 const [from, to] = [row.start - MINUTE, row.start + row.ms + MINUTE];
37 return answer(res, 200, { turn: row, events: facts.events(from, to).filter((e) => ARM_EVENTS.has(e.kind)).map(({ who: _who, ...e }) => e), samples: facts.samples(from, to) }, head);
38 }
39 const range = parseRange(url.searchParams, now);
40 if ("error" in range) return answer(res, 400, range);
41 const compare = url.searchParams.get("compare");
42 if (compare !== null && compare !== "1" && compare !== "0") return answer(res, 400, { error: "compare is 1 or 0" });
43 const oldest = facts.oldest();
44 const over = (from: number, to: number) => aggregate({ from, to, step: range.step, now, oldest, turns: facts.turns(from, to), events: facts.events(from, to), samples: facts.samples(from, to) });
45 const body = over(range.from, range.to);
46 const span = range.to - range.from;
47 const previous = compare === "1" ? over(range.from - span, range.from) : null;
48 const snapshot = lobby.turns.snapshot();
49 const live = { watchers: lobby.visitors.toJSON().here, driving: snapshot.driver ? 1 : 0, queue: snapshot.queue.length };
50 return answer(res, 200, { ...body, live, oldest, commit: facts.commit, previous: previous && { overview: previous.overview, series: previous.series, funnel: previous.funnel } }, head);
51 }
Software/so101/src/data-layout.test.ts added +122
@@ -0,0 +1,122 @@
1 import { describe, expect, test } from "bun:test";
2 import { readFileSync } from "node:fs";
3 import { DATA_SCRIPT, DATA_STYLE, renderDataPage } from "./data-page.ts";
4
5 // Read, not rendered: the Data view's CSS resolved at each screen the issue names, the way a browser picks its @media blocks.
6
7 type Rule = { selector: string; body: string };
8 const css = DATA_STYLE.replace(/\/\*[\s\S]*?\*\//g, "");
9
10 /** Does a media query hold at width × height (orientation, min-/max-width, prefers-* taken as their defaults)? */
11 function holds(query: string, w: number, h: number): boolean {
12 return query.split(/\s+and\s+/).every((part) => {
13 const p = part.trim().replace(/^\(|\)$/g, "");
14 const [name, value] = p.split(":").map((s) => s.trim());
15 if (name === "orientation") return value === (w > h ? "landscape" : "portrait");
16 if (name === "min-width") return w >= parseFloat(value!);
17 if (name === "max-width") return w <= parseFloat(value!);
18 if (name === "prefers-reduced-motion") return value === "no-preference";
19 if (name === "prefers-color-scheme") return value === "dark";
20 throw new Error(`unread media feature ${p}`);
21 });
22 }
23
24 /** The rules in force at width × height, in source order. */
25 function rulesAt(w: number, h: number): Rule[] {
26 const out: Rule[] = [];
27 const walk = (src: string) => {
28 let i = 0;
29 while (i < src.length) {
30 const open = src.indexOf("{", i);
31 if (open < 0) break;
32 let depth = 1;
33 let j = open + 1;
34 for (; j < src.length && depth; j++) depth += src[j] === "{" ? 1 : src[j] === "}" ? -1 : 0;
35 const head = src.slice(i, open).trim();
36 const body = src.slice(open + 1, j - 1);
37 if (head.startsWith("@media")) {
38 if (holds(head.slice(6).trim(), w, h)) walk(body);
39 } else for (const s of head.split(",")) out.push({ selector: s.trim(), body });
40 i = j;
41 }
42 };
43 walk(css);
44 return out;
45 }
46
47 /** The last value a selector's rules give a property at that screen. */
48 function prop(rules: Rule[], selector: string, name: string): string | undefined {
49 let found: string | undefined;
50 for (const r of rules) {
51 if (r.selector !== selector) continue;
52 for (const decl of r.body.split(";")) {
53 const at = decl.indexOf(":");
54 if (at > 0 && decl.slice(0, at).trim() === name) found = decl.slice(at + 1).trim();
55 }
56 }
57 return found;
58 }
59
60 const columns = (template: string | undefined) => {
61 if (!template) return 1;
62 const repeat = /^repeat\((\d+),/.exec(template);
63 return repeat ? Number(repeat[1]) : template.split(/\s+(?![^(]*\))/).length;
64 };
65
66 const SCREENS = [
67 { name: "phone portrait", w: 390, h: 844, cols: 1, swipe: true },
68 { name: "phone landscape", w: 844, h: 390, cols: 2, swipe: false },
69 { name: "tablet portrait", w: 820, h: 1180, cols: 2, swipe: false },
70 { name: "desktop", w: 1440, h: 900, cols: 3, swipe: false },
71 ] as const;
72
73 describe("Data view layout", () => {
74 for (const s of SCREENS) {
75 test(`${s.name} ${s.w}×${s.h}: ${s.cols} column${s.cols > 1 ? "s" : ""}, the overview ${s.swipe ? "as swipeable cards" : "as a grid"}`, () => {
76 const rules = rulesAt(s.w, s.h);
77 expect(columns(prop(rules, ".panels", "grid-template-columns"))).toBe(s.cols);
78 // every column may shrink to the screen: a minmax(0, …) track, never a fixed width
79 expect(prop(rules, ".panels", "grid-template-columns")).toContain("minmax(0, 1fr)");
80 if (s.swipe) {
81 expect(prop(rules, ".strip", "grid-auto-flow")).toBe("column");
82 expect(prop(rules, ".strip", "overflow-x")).toBe("auto");
83 expect(prop(rules, ".strip", "scroll-snap-type")).toBe("x mandatory");
84 } else {
85 expect(prop(rules, ".strip", "grid-auto-flow")).toBe("row");
86 expect(prop(rules, ".strip", "overflow")).toBe("visible");
87 }
88 });
89
90 test(`${s.name} ${s.w}×${s.h}: nothing overflows and nothing is 0 px`, () => {
91 const rules = rulesAt(s.w, s.h);
92 for (const sel of ["main", ".panel", ".stat", ".chart", ".plot"]) expect(prop(rules, sel, "min-width")).toBe("0");
93 expect(prop(rules, ".chart svg", "max-width")).toBe("100%");
94 expect(prop(rules, ".scroll", "overflow-x")).toBe("auto");
95 // a chart is drawn at its box's width, never under 240 px; the narrowest box must hold that
96 const pad = 12 * 2 /* main */ + 14 * 2 /* panel */ + 2;
97 expect(s.w - pad).toBeGreaterThanOrEqual(240);
98 for (const sel of [".plot", ".plot.small", ".stat"]) expect(parseFloat(prop(rules, sel, "min-height") ?? "0")).toBeGreaterThan(0);
99 // no width in px wider than the narrowest screen
100 for (const r of rules) for (const m of r.body.matchAll(/(?:^|[;\s])(?:min-)?width\s*:\s*(\d+)px/g)) expect(Number(m[1])).toBeLessThanOrEqual(390);
101 });
102 }
103
104 test("the camera is never covered: a page of its own, nothing fixed over another page, loaded only when opened", () => {
105 expect(css).not.toMatch(/position\s*:\s*fixed/);
106 const viewer = readFileSync(new URL("./viewer.html", import.meta.url), "utf8");
107 expect(viewer).not.toContain("data-view.js");
108 expect(viewer).toContain('href="/admin/data"');
109 expect(renderDataPage()).not.toMatch(/<script[^>]+src=|<link[^>]+stylesheet/);
110 });
111
112 test("both colour schemes and reduced motion", () => {
113 expect(css).toMatch(/color-scheme:\s*dark light/);
114 expect(css).toContain("@media (prefers-color-scheme: light)");
115 // motion only for those who did not ask for less, and the script draws without animating
116 const motion = rulesAt(390, 844).filter((r) => /(^|[;\s])(transition|animation|scroll-behavior)\s*:/.test(r.body));
117 const still = css.split("@media (prefers-reduced-motion: no-preference)")[0]!;
118 expect(still).not.toMatch(/(^|[;\s])(transition|animation|scroll-behavior)\s*:/);
119 expect(motion.length).toBeGreaterThan(0);
120 expect(DATA_SCRIPT).not.toMatch(/\.animate\(|requestAnimationFrame\(\s*function\s+tick/);
121 });
122 });
Software/so101/src/data-page.ts added +198
@@ -0,0 +1,198 @@
1 /**
2 * The Data view, /admin/data: the owner's and the moderators' place to explore how the arm is doing. A page of its own,
3 * so nothing of it loads with the camera and it never covers it. The markup is fixed (no person's text in it); the
4 * charts are src/ui/data-view.js, drawn from GET /data. Style and script are inline, pinned by their hashes in the
5 * page's Content-Security-Policy, which allows nothing else. To anyone else there is no such page (404, as /admin).
6 */
7
8 import { createHash } from "node:crypto";
9 import { readFileSync } from "node:fs";
10 import type { IncomingMessage, ServerResponse } from "node:http";
11 import type { Lobby } from "./lobby.ts";
12
13 export const DATA_SCRIPT = readFileSync(new URL("./ui/data-view.js", import.meta.url), "utf8");
14
15 export const DATA_STYLE = `
16 :root { color-scheme: dark light; --bg: #0b0d10; --panel: #11161d; --fg: #e8eaed; --dim: #9aa4ae; --line: #2a313a; --accent: #58a6ff; --good: #3fb950; --bad: #f85149;
17 --c0: #58a6ff; --c1: #3fb950; --c2: #d2a8ff; --c3: #f0883e; --c4: #f85149; --c5: #e3b341; --c6: #8b949e; --c7: #39c5cf; }
18 @media (prefers-color-scheme: light) {
19 :root { --bg: #f6f7f9; --panel: #fff; --fg: #1f2328; --dim: #59636e; --line: #d8dee4; --accent: #0969da; --good: #1a7f37; --bad: #cf222e;
20 --c0: #0969da; --c1: #1a7f37; --c2: #8250df; --c3: #bc4c00; --c4: #cf222e; --c5: #9a6700; --c6: #6e7781; --c7: #1b7c83; }
21 }
22 * { box-sizing: border-box; }
23 body { margin: 0; background: var(--bg); color: var(--fg); font: 15px/1.45 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; font-variant-numeric: tabular-nums; }
24 main { max-width: 110rem; margin: 0 auto; padding: max(1rem, env(safe-area-inset-top)) max(.75rem, env(safe-area-inset-right)) 3rem max(.75rem, env(safe-area-inset-left)); display: grid; gap: 1rem; min-width: 0; }
25 header { display: grid; gap: .6rem; }
26 .title { display: flex; flex-wrap: wrap; align-items: baseline; gap: .25rem 1rem; }
27 h1 { font-size: clamp(1.5rem, 4vw, 2rem); line-height: 1.1; margin: 0; letter-spacing: -.01em; }
28 .title nav { display: flex; gap: 1rem; font-size: .9rem; }
29 a { color: var(--accent); }
30 .controls { display: flex; flex-wrap: wrap; align-items: center; gap: .5rem .9rem; }
31 .seg { display: inline-flex; border: 1px solid var(--line); border-radius: 8px; overflow: hidden; }
32 .seg button { border: 0; border-radius: 0; border-right: 1px solid var(--line); }
33 .seg button:last-child { border-right: 0; }
34 button { font: inherit; font-weight: 600; min-height: 40px; padding: .35rem .85rem; border-radius: 8px; border: 1px solid var(--line); background: var(--panel); color: var(--fg); cursor: pointer; }
35 button[aria-pressed="true"] { background: var(--accent); color: #fff; }
36 button:focus-visible, input:focus-visible, figure:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
37 button.link { border: 0; background: none; color: var(--accent); padding: 0; min-height: 32px; font-weight: 500; text-align: left; }
38 label.toggle { display: inline-flex; align-items: center; gap: .4rem; min-height: 40px; }
39 label.toggle input { width: 1.1rem; height: 1.1rem; accent-color: var(--accent); }
40 .range, .status, .live, .note { color: var(--dim); font-size: .85rem; margin: 0; }
41 .status:empty { display: none; }
42 .status { color: var(--bad); }
43 body.loading .panels, body.loading .strip { opacity: .6; }
44 .strip { display: grid; grid-auto-flow: column; grid-auto-columns: minmax(11rem, 72%); gap: .6rem; overflow-x: auto; scroll-snap-type: x mandatory; padding-bottom: .25rem; overscroll-behavior-x: contain; }
45 .stat { scroll-snap-align: start; background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: .7rem .8rem .5rem; display: grid; gap: .1rem; min-width: 0; min-height: 8.5rem; }
46 .stat h3 { margin: 0; font-size: .75rem; font-weight: 600; letter-spacing: .04em; text-transform: uppercase; color: var(--dim); }
47 .stat .big { margin: 0; font-size: 1.7rem; font-weight: 650; line-height: 1.15; }
48 .stat .delta { margin: 0; font-size: .8rem; color: var(--dim); min-height: 1.2em; }
49 .delta.good { color: var(--good); }
50 .delta.bad { color: var(--bad); }
51 .spark { width: 100%; height: 24px; display: block; }
52 .panels { display: grid; grid-template-columns: minmax(0, 1fr); gap: 1rem; grid-auto-flow: row dense; }
53 .panel { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: .85rem; min-width: 0; display: grid; gap: .6rem; align-content: start; }
54 .panel h2 { margin: 0; font-size: .8rem; font-weight: 650; letter-spacing: .08em; text-transform: uppercase; color: var(--dim); }
55 .panel h3 { margin: .2rem 0 0; font-size: .95rem; font-weight: 600; }
56 .multiples { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 15rem), 1fr)); gap: .75rem; }
57 .joint h4 { margin: 0 0 .2rem; font-size: .85rem; font-weight: 600; text-transform: capitalize; }
58 .plot { min-height: 196px; min-width: 0; }
59 .plot.small { min-height: 136px; }
60 .chart { margin: 0; position: relative; min-width: 0; }
61 .chart svg { display: block; max-width: 100%; height: auto; overflow: visible; touch-action: pan-y; }
62 .legend { display: flex; flex-wrap: wrap; gap: .1rem .8rem; font-size: .75rem; color: var(--dim); min-height: 1.1rem; }
63 .key, .row { display: inline-flex; align-items: center; gap: .3rem; }
64 .key i, .row i { width: .6rem; height: .6rem; border-radius: 2px; background: var(--k); display: inline-block; }
65 .grid { stroke: var(--line); stroke-width: 1; }
66 .tick, .vlabel { fill: var(--dim); font-size: 11px; }
67 .vlabel { font-size: 10px; }
68 .val { fill: var(--fg); font-size: 11px; }
69 .none { fill: var(--dim); font-size: 13px; }
70 .line { fill: none; stroke: var(--k); stroke-width: 2; stroke-linejoin: round; stroke-linecap: round; }
71 .line.dashed { stroke-dasharray: 5 4; }
72 .line.prev { stroke-width: 1.5; stroke-dasharray: 2 3; opacity: .55; }
73 .spark .line { stroke-width: 1.6; vector-effect: non-scaling-stroke; }
74 .fill { fill: var(--k); }
75 .band { fill: var(--k); opacity: .18; }
76 .dot { fill: var(--k); }
77 .version { stroke: var(--dim); stroke-dasharray: 3 3; stroke-width: 1; }
78 .version.pi { stroke: var(--c2); }
79 .cursor { stroke: var(--fg); stroke-width: 1; opacity: .5; }
80 .brush { fill: var(--accent); opacity: .15; }
81 .c0 { --k: var(--c0); } .c1 { --k: var(--c1); } .c2 { --k: var(--c2); } .c3 { --k: var(--c3); }
82 .c4 { --k: var(--c4); } .c5 { --k: var(--c5); } .c6 { --k: var(--c6); } .c7 { --k: var(--c7); }
83 .tip { position: absolute; top: 1.4rem; right: .25rem; z-index: 1; display: grid; gap: .1rem; padding: .45rem .6rem; border-radius: 8px; background: var(--bg); border: 1px solid var(--line); font-size: .78rem; pointer-events: none; box-shadow: 0 4px 16px rgb(0 0 0 / .25); max-width: min(18rem, 90%); }
84 .tip.left { right: auto; left: 2.6rem; }
85 .tip .prev { opacity: .7; }
86 .tip[hidden] { display: none; }
87 table { width: 100%; border-collapse: collapse; font-size: .85rem; }
88 th, td { text-align: right; padding: .35rem .4rem; border-bottom: 1px solid var(--line); white-space: nowrap; }
89 th:first-child, td:first-child { text-align: left; white-space: normal; }
90 th { color: var(--dim); font-weight: 600; font-size: .75rem; }
91 .scroll { overflow-x: auto; }
92 .mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .8rem; }
93 .empty { color: var(--dim); font-size: .85rem; margin: 0; }
94 dialog { width: min(44rem, calc(100vw - 1.5rem)); max-height: calc(100dvh - 1.5rem); border: 1px solid var(--line); border-radius: 12px; background: var(--panel); color: var(--fg); padding: 1rem; }
95 dialog::backdrop { background: rgb(0 0 0 / .5); }
96 dialog h2 { margin: 0 0 .6rem; font-size: 1.1rem; }
97 dialog .close { float: right; }
98 .facts { display: grid; grid-template-columns: max-content 1fr; gap: .2rem 1rem; margin: 0; font-size: .9rem; }
99 .facts dt { color: var(--dim); }
100 .facts dd { margin: 0; }
101 .timeline { display: block; max-width: 100%; height: auto; }
102 .turnbar { fill: var(--accent); opacity: .3; }
103 .mark { stroke: var(--dim); stroke-width: 2; }
104 .mark.stop { stroke: var(--c4); } .mark.jam { stroke: var(--c3); } .mark.block { stroke: var(--c5); } .mark.reconnect { stroke: var(--c2); }
105 .events { margin: 0; padding-left: 1.1rem; font-size: .85rem; }
106 @media (orientation: landscape) and (min-width: 560px) {
107 .strip { grid-auto-flow: row; grid-template-columns: repeat(auto-fit, minmax(9.5rem, 1fr)); grid-auto-columns: auto; overflow: visible; }
108 .panels { grid-template-columns: repeat(2, minmax(0, 1fr)); }
109 .wide { grid-column: 1 / -1; }
110 }
111 @media (min-width: 760px) {
112 main { padding-left: 1.5rem; padding-right: 1.5rem; }
113 .strip { grid-auto-flow: row; grid-template-columns: repeat(auto-fit, minmax(9.5rem, 1fr)); grid-auto-columns: auto; overflow: visible; }
114 .panels { grid-template-columns: repeat(2, minmax(0, 1fr)); }
115 .wide { grid-column: 1 / -1; }
116 }
117 @media (min-width: 1280px) {
118 .panels { grid-template-columns: repeat(3, minmax(0, 1fr)); }
119 .wide { grid-column: span 2; }
120 }
121 @media (prefers-reduced-motion: no-preference) {
122 .panels, .strip { transition: opacity .15s ease; }
123 .strip { scroll-behavior: smooth; }
124 }
125 `;
126
127 const sha = (text: string) => createHash("sha256").update(text).digest("base64");
128 export const dataCsp = (): string => `default-src 'none'; style-src 'sha256-${sha(DATA_STYLE)}'; script-src 'sha256-${sha(DATA_SCRIPT)}'; connect-src 'self'; img-src 'none'; form-action 'none'; frame-ancestors 'none'; base-uri 'none'`;
129
130 const plot = (id: string, cls = "plot") => `<div class="${cls}" id="${id}"></div>`;
131
132 export function renderDataPage(): string {
133 return `<!doctype html>
134 <html lang="en">
135 <head>
136 <meta charset="utf-8">
137 <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
138 <meta name="referrer" content="same-origin">
139 <title>Data · the arm</title>
140 <style>${DATA_STYLE}</style>
141 </head>
142 <body>
143 <main>
144 <header>
145 <div class="title"><h1>Data</h1><nav><a href="/">The arm</a><a href="/admin">Admin</a></nav></div>
146 <div class="controls">
147 <div class="seg" role="group" aria-label="Time range">
148 <button type="button" data-range="hour" aria-pressed="false">Hour</button><button type="button" data-range="day" aria-pressed="true">Day</button><button type="button" data-range="week" aria-pressed="false">Week</button><button type="button" data-range="all" aria-pressed="false">All</button>
149 </div>
150 <label class="toggle"><input type="checkbox" id="compare"> Compare to the period before</label>
151 <button type="button" id="reset" hidden>Reset zoom</button>
152 </div>
153 <p class="range" id="range">Loading…</p>
154 <p class="status" id="status" role="status" aria-live="polite"></p>
155 </header>
156 <section class="strip" id="strip" aria-label="Overview"></section>
157 <p class="note">Drag across any chart to zoom into that stretch; hover, tap or use the arrow keys on a chart for exact values. Dashed upright lines mark a deployed commit.</p>
158 <div class="panels">
159 <section class="panel" aria-labelledby="h-precision"><h2 id="h-precision">Precision · snap-backs by cause</h2>${plot("c-causes")}</section>
160 <section class="panel wide" aria-labelledby="h-joints"><h2 id="h-joints">Precision · per joint: p95 settle error and time to arrive</h2><div class="multiples" id="joints"></div></section>
161 <section class="panel wide" aria-labelledby="h-worst"><h2 id="h-worst">Precision · worst sessions</h2>
162 <div class="scroll"><table id="worst"><thead><tr><th>Session</th><th>Length</th><th>p95 error</th><th>Snap-backs / releases</th><th>p95 arrive</th><th>Commit</th></tr></thead><tbody></tbody></table></div></section>
163 <section class="panel" aria-labelledby="h-total"><h2 id="h-total">Latency · command to confirmed motion</h2>${plot("c-total")}</section>
164 <section class="panel" aria-labelledby="h-lathist"><h2 id="h-lathist">Latency · turns by p95</h2>${plot("c-lathist")}</section>
165 <section class="panel wide" aria-labelledby="h-hops"><h2 id="h-hops">Latency · per hop, p50 to p95</h2><div class="multiples" id="hops"></div></section>
166 <section class="panel" aria-labelledby="h-room"><h2 id="h-room">People · in the room</h2><p class="live" id="live"></p>${plot("c-room")}</section>
167 <section class="panel" aria-labelledby="h-visitors"><h2 id="h-visitors">People · visitors</h2>${plot("c-visitors")}</section>
168 <section class="panel" aria-labelledby="h-funnel"><h2 id="h-funnel">People · where they drop off</h2>${plot("c-funnel")}</section>
169 <section class="panel" aria-labelledby="h-wait"><h2 id="h-wait">People · wait in line</h2>${plot("c-wait")}</section>
170 <section class="panel" aria-labelledby="h-turns"><h2 id="h-turns">People · turn length</h2>${plot("c-turns")}</section>
171 <section class="panel" aria-labelledby="h-chat"><h2 id="h-chat">People · chat volume</h2>${plot("c-chat")}</section>
172 <section class="panel" aria-labelledby="h-uptime"><h2 id="h-uptime">Health · arm link uptime</h2>${plot("c-uptime")}</section>
173 <section class="panel" aria-labelledby="h-events"><h2 id="h-events">Health · reconnects, restarts, STOPs</h2><p class="note" id="starts"></p>${plot("c-events")}</section>
174 <section class="panel" aria-labelledby="h-fps"><h2 id="h-fps">Health · camera fps</h2>${plot("c-fps")}</section>
175 </div>
176 </main>
177 <dialog id="turn" aria-labelledby="turn-title"><button type="button" class="close" id="turn-close">Close</button><div id="turn-body"></div></dialog>
178 <script>${DATA_SCRIPT}</script>
179 </body>
180 </html>
181 `;
182 }
183
184 /** GET /admin/data: the page for the owner and moderators; to anyone else, the ordinary not-found. */
185 export function serveDataPage(lobby: Lobby, req: IncomingMessage, res: ServerResponse): void {
186 if (!lobby.who(req) || !lobby.moderates(req)) {
187 res.writeHead(404, { "content-type": "application/json", "cache-control": "no-store" });
188 res.end(JSON.stringify({ error: "no such route" }));
189 return;
190 }
191 if (req.method !== "GET" && req.method !== "HEAD") {
192 res.writeHead(405, { "content-type": "application/json", "cache-control": "no-store", allow: "GET, HEAD" });
193 res.end(JSON.stringify({ error: "GET" }));
194 return;
195 }
196 res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", "content-security-policy": dataCsp() });
197 res.end(req.method === "HEAD" ? undefined : renderDataPage());
198 }
Software/so101/src/data-series.test.ts added +157
@@ -0,0 +1,157 @@
1 import { describe, expect, test } from "bun:test";
2 import { aggregate, autoStep, histogram, MAX_POINTS, parseRange, percentile, type Input } from "./data-series.ts";
3 import type { EventRow, TurnRow } from "./facts.ts";
4
5 const H = 3_600_000;
6 const T0 = 1_700_000_000_000 - (1_700_000_000_000 % H);
7
8 const turn = (over: Partial<TurnRow>): TurnRow => ({
9 id: 1,
10 start: T0,
11 ms: 60_000,
12 who: "u:a",
13 name: "a",
14 reason: "time",
15 waitMs: null,
16 commit: "abc1234",
17 releases: null,
18 snapBacks: null,
19 byCause: null,
20 jams: null,
21 arriveP95Ms: null,
22 trackingP95Deg: null,
23 latencyP95Ms: null,
24 hops: null,
25 joints: null,
26 ...over,
27 });
28 const ev = (at: number, kind: EventRow["kind"], who: string | null = null, detail: string | null = null, commit = "abc1234"): EventRow => ({ at, kind, who, detail, commit });
29 const input = (over: Partial<Input>): Input => ({ from: T0, to: T0 + 4 * H, step: 3600, now: T0 + 4 * H, oldest: T0, turns: [], events: [], samples: [], ...over });
30
31 describe("percentile and histogram", () => {
32 test("nearest rank: p95 of 1..20 is 19, p50 of 1..4 is 2, of nothing null", () => {
33 expect(percentile(Array.from({ length: 20 }, (_, i) => i + 1), 95)).toBe(19);
34 expect(percentile([4, 1, 3, 2], 50)).toBe(2);
35 expect(percentile([7], 95)).toBe(7);
36 expect(percentile([], 95)).toBeNull();
37 });
38
39 test("a histogram counts below the first edge, between, and at or past the last", () => {
40 expect(histogram([0, 9.9, 10, 29, 30, 500], [10, 30])).toEqual([2, 2, 2]);
41 });
42
43 test("autoStep keeps about 96 points: an hour by the minute, a day by 15 min, a week by 3 h", () => {
44 expect(autoStep(H)).toBe(60);
45 expect(autoStep(24 * H)).toBe(900);
46 expect(autoStep(7 * 24 * H)).toBe(3 * 3600);
47 expect(autoStep(90 * 24 * H)).toBe(86_400);
48 });
49 });
50
51 describe("aggregate", () => {
52 test("snap-backs per 100 releases are summed then divided, never an average of ratios", () => {
53 const a = aggregate(input({ turns: [turn({ id: 1, releases: 10, snapBacks: 5, byCause: { jam: 5 }, jams: 5 }), turn({ id: 2, start: T0 + 60_000, releases: 90, snapBacks: 0, byCause: {}, jams: 0 })] }));
54 // the mean of 50 and 0 would be 25; the truth is 5 in 100
55 expect(a.overview.snapBacksPer100).toBe(5);
56 expect(a.series.snapBacksPer100[0]).toBe(5);
57 expect(a.series.snapBacksPer100[1]).toBeNull();
58 expect(a.series.byCause.jam).toEqual([5, 0, 0, 0]);
59 expect(a.overview.jams).toBe(5);
60 });
61
62 test("steps: each turn lands in its own step; sessions, drivers and minutes add up", () => {
63 const a = aggregate(input({ turns: [turn({ id: 1, who: "u:a", ms: 30_000 }), turn({ id: 2, who: "u:a", start: T0 + 2 * H, ms: 90_000 }), turn({ id: 3, who: "g:b", start: T0 + 2 * H + 1, ms: 5_000 })] }));
64 expect(a.t).toEqual([T0, T0 + H, T0 + 2 * H, T0 + 3 * H]);
65 expect(a.series.sessions).toEqual([1, 0, 2, 0]);
66 expect(a.series.drivers).toEqual([1, 0, 2, 0]);
67 expect(a.overview).toMatchObject({ sessions: 3, drivers: 2, minutes: 2.1 });
68 expect(a.turnLengths.counts).toEqual([1, 0, 1, 1, 0, 0]);
69 });
70
71 test("a step's p95 time to arrive is the p95 of its turns' p95s, so one bad turn shows", () => {
72 const turns = Array.from({ length: 20 }, (_, i) => turn({ id: i, start: T0 + i, releases: 1, snapBacks: 0, arriveP95Ms: i === 19 ? 2000 : 100, latencyP95Ms: 50 + i }));
73 const a = aggregate(input({ turns }));
74 expect(a.series.arriveP95Ms[0]).toBe(100);
75 expect(a.overview.arriveP95Ms).toBe(100);
76 const worse = aggregate(input({ turns: turns.map((t, i) => (i === 18 ? { ...t, arriveP95Ms: 1500 } : t)) }));
77 expect(worse.overview.arriveP95Ms).toBe(1500);
78 expect(a.overview.latencyP95Ms).toBe(68);
79 });
80
81 test("hops and joints keep their p50 and p95 per step", () => {
82 const a = aggregate(input({
83 turns: [
84 turn({ id: 1, releases: 1, snapBacks: 0, hops: { "relay→pi": { p50: 10, p95: 40 } }, joints: { elbow: { errorP95Deg: 1.234, arriveP95Ms: 300 } } }),
85 turn({ id: 2, start: T0 + 1, releases: 1, snapBacks: 0, hops: { "relay→pi": { p50: 20, p95: 80 } }, joints: { elbow: { errorP95Deg: 2.5, arriveP95Ms: 500 } } }),
86 ],
87 }));
88 expect(a.series.hops["relay→pi"]!.p50[0]).toBe(10);
89 expect(a.series.hops["relay→pi"]!.p95[0]).toBe(80);
90 expect(a.series.joints.elbow!.errorP95Deg[0]).toBe(2.5);
91 expect(a.series.joints.elbow!.arriveP95Ms.slice(1)).toEqual([null, null, null]);
92 });
93
94 test("visitors are distinct people per step, anonymous against named; the funnel counts how far each got", () => {
95 const events = [ev(T0, "visit", "g:1"), ev(T0 + 1, "visit", "g:1"), ev(T0 + 2, "visit", "u:a"), ev(T0 + 3, "visit", "u:b"), ev(T0 + H, "visit", "g:1"), ev(T0 + 5, "named", "u:a"), ev(T0 + 6, "queued", "u:a"), ev(T0 + 7, "chat", "u:a"), ev(T0 + 8, "chat", "u:b")];
96 const a = aggregate(input({ events, turns: [turn({ who: "u:a", ms: 5_000 }), turn({ id: 2, who: "u:b", ms: 40_000 })] }));
97 expect(a.series.visitorsAnon).toEqual([1, 1, 0, 0]);
98 expect(a.series.visitorsNamed).toEqual([2, 0, 0, 0]);
99 expect(a.overview.visitors).toBe(3);
100 expect(a.funnel).toEqual({ visitors: 3, named: 2, queued: 1, drove: 2, under10s: 1 });
101 expect(a.series.chat).toEqual([2, 0, 0, 0]);
102 });
103
104 test("uptime is live minutes over minutes passed since the record began; no samples is null", () => {
105 const samples = Array.from({ length: 60 }, (_, i) => ({ at: T0 + i * 60_000, metric: "link", value: i < 45 ? 1 : 0 }));
106 const a = aggregate(input({ samples, now: T0 + H, to: T0 + 4 * H }));
107 expect(a.series.uptime[0]).toBe(0.75);
108 expect(a.series.uptime[1]).toBeNull(); // in the future
109 expect(a.overview.uptime).toBe(0.75);
110 expect(aggregate(input({})).overview.uptime).toBeNull();
111 });
112
113 test("room samples show each step's peak; camera fps their mean", () => {
114 const samples = [
115 { at: T0, metric: "watchers", value: 3 },
116 { at: T0 + 60_000, metric: "watchers", value: 7 },
117 { at: T0, metric: "fps:front", value: 20 },
118 { at: T0 + 60_000, metric: "fps:front", value: 30 },
119 ];
120 const a = aggregate(input({ samples }));
121 expect(a.series.watchers).toEqual([7, null, null, null]);
122 expect(a.series.fps.front).toEqual([25, null, null, null]);
123 });
124
125 test("version markers: one per change of commit, app and Pi apart; starts by cause", () => {
126 const events = [ev(T0, "restart", null, "deploy", "aaa1111"), ev(T0 + 10, "restart", null, "restart", "aaa1111"), ev(T0 + H, "restart", null, "deploy", "bbb2222"), ev(T0 + 5, "pi-version", null, "ccc3333"), ev(T0 + 6, "pi-version", null, "ccc3333")];
127 const a = aggregate(input({ events: events.sort((x, y) => x.at - y.at) }));
128 expect(a.versions).toEqual([
129 { at: T0, where: "app", commit: "aaa1111" },
130 { at: T0 + 5, where: "pi", commit: "ccc3333" },
131 { at: T0 + H, where: "app", commit: "bbb2222" },
132 ]);
133 expect(a.starts).toEqual({ deploy: 2, restart: 1 });
134 expect(a.series.restarts).toEqual([2, 1, 0, 0]);
135 });
136
137 test("the worst sessions are the measured ones, worst tracking first, at most ten", () => {
138 const turns = Array.from({ length: 14 }, (_, i) => turn({ id: i + 1, start: T0 + i, releases: 4, snapBacks: i % 2, trackingP95Deg: i }));
139 const a = aggregate(input({ turns: [...turns, turn({ id: 99, trackingP95Deg: null })] }));
140 expect(a.worst.map((w) => w.id)).toEqual([14, 13, 12, 11, 10, 9, 8, 7, 6, 5]);
141 });
142 });
143
144 describe("parseRange", () => {
145 const now = T0 + 24 * H;
146 const p = (q: string) => parseRange(new URLSearchParams(q), now);
147 test("defaults to the last day at its auto step", () => {
148 expect(p("")).toEqual({ from: now - 24 * H, to: now, step: 900 });
149 });
150 test("refuses what is not a range", () => {
151 expect(p("from=abc")).toHaveProperty("error");
152 expect(p(`from=${now}&to=${now - 1}`)).toHaveProperty("error");
153 expect(p("step=7")).toHaveProperty("error");
154 expect(p(`from=${now - (MAX_POINTS + 1) * 60_000}&to=${now}&step=60`)).toHaveProperty("error");
155 expect(p(`from=${now - H}&to=${now}&step=300`)).toEqual({ from: now - H, to: now, step: 300 });
156 });
157 });
Software/so101/src/data-series.ts added +360
@@ -0,0 +1,360 @@
1 /**
2 * The Data view's numbers: src/facts.ts's rows over a time range, cut into steps. Plain data in, plain data out.
3 *
4 * What each number is, so a chart never claims more than was measured:
5 * - visitors: distinct people (user ids, hashed anonymous browsers) who opened the page; named = had joined with a name.
6 * - a session is a turn; drivers are distinct people who had one; minutes are the turns' lengths.
7 * - snap-backs per 100 releases: summed over the turns the arm measured, then divided (never an average of ratios).
8 * - p95 time to arrive, command → confirmed motion, per hop and per joint: each turn keeps its own p95 (the arm's
9 * src/precision.ts); a step's number is the p95 of its turns' p95s (nearest rank), so one bad turn shows. A hop's p50
10 * is the median of its turns' p50s.
11 * - watchers, drivers now, queue: the minute samples; a step shows the most seen. Camera fps: their mean.
12 * - uptime: the minutes the arm link was sampled live over the minutes since the range (or the record) began.
13 */
14
15 import type { EventRow, SampleRow, TurnRow } from "./facts.ts";
16
17 /** The steps a request may name, in seconds, smallest first. */
18 export const STEPS = [60, 300, 900, 1800, 3600, 3 * 3600, 6 * 3600, 86_400] as const;
19 /** The most steps one answer carries. */
20 export const MAX_POINTS = 400;
21 const TARGET_POINTS = 96;
22
23 /** The step that cuts a range into about TARGET_POINTS, never more than MAX_POINTS. */
24 export function autoStep(ms: number): number {
25 for (const s of STEPS) if (ms / (s * 1000) <= TARGET_POINTS) return s;
26 return STEPS[STEPS.length - 1]!;
27 }
28
29 /** Nearest-rank percentile; null for no values. */
30 export function percentile(values: readonly number[], p: number): number | null {
31 if (values.length === 0) return null;
32 const sorted = [...values].sort((a, b) => a - b);
33 const rank = Math.max(1, Math.ceil((p / 100) * sorted.length));
34 return sorted[rank - 1]!;
35 }
36
37 const round = (v: number | null, digits = 1): number | null => (v === null || !Number.isFinite(v) ? null : Number(v.toFixed(digits)));
38 const nums = (values: ReadonlyArray<number | null | undefined>): number[] => values.filter((v): v is number => typeof v === "number" && Number.isFinite(v));
39 const mean = (values: readonly number[]): number | null => (values.length ? values.reduce((a, b) => a + b, 0) / values.length : null);
40
41 export const TURN_BINS = [10, 30, 60, 120, 300] as const;
42 export const LATENCY_BINS = [25, 50, 100, 200, 400] as const;
43 /** Counts per bin: below the first edge, between each pair, and at or past the last. */
44 export function histogram(values: readonly number[], edges: readonly number[]): number[] {
45 const out = new Array<number>(edges.length + 1).fill(0);
46 for (const v of values) {
47 let i = 0;
48 while (i < edges.length && v >= edges[i]!) i++;
49 out[i]!++;
50 }
51 return out;
52 }
53
54 export interface Overview {
55 visitors: number;
56 sessions: number;
57 drivers: number;
58 minutes: number;
59 snapBacksPer100: number | null;
60 arriveP95Ms: number | null;
61 latencyP95Ms: number | null;
62 jams: number;
63 /** 0–1; null with no samples. */
64 uptime: number | null;
65 }
66
67 export interface Series {
68 visitorsAnon: number[];
69 visitorsNamed: number[];
70 sessions: number[];
71 drivers: number[];
72 minutes: number[];
73 releases: number[];
74 snapBacks: number[];
75 snapBacksPer100: Array<number | null>;
76 byCause: Record<string, number[]>;
77 arriveP95Ms: Array<number | null>;
78 latencyP95Ms: Array<number | null>;
79 hops: Record<string, { p50: Array<number | null>; p95: Array<number | null> }>;
80 joints: Record<string, { errorP95Deg: Array<number | null>; arriveP95Ms: Array<number | null> }>;
81 watchers: Array<number | null>;
82 driving: Array<number | null>;
83 queue: Array<number | null>;
84 waitP50Ms: Array<number | null>;
85 waitP95Ms: Array<number | null>;
86 turnP50S: Array<number | null>;
87 chat: number[];
88 reconnects: number[];
89 restarts: number[];
90 stops: number[];
91 jams: number[];
92 uptime: Array<number | null>;
93 fps: Record<string, Array<number | null>>;
94 }
95
96 export interface Marker {
97 at: number;
98 where: "app" | "pi";
99 commit: string;
100 }
101
102 export interface Worst {
103 id: number;
104 start: number;
105 ms: number;
106 who: string;
107 name: string | null;
108 trackingP95Deg: number | null;
109 arriveP95Ms: number | null;
110 snapBacks: number | null;
111 releases: number | null;
112 latencyP95Ms: number | null;
113 commit: string;
114 }
115
116 export interface Aggregate {
117 from: number;
118 to: number;
119 step: number;
120 /** Each step's start, ms. */
121 t: number[];
122 overview: Overview;
123 series: Series;
124 /** Who got how far: opened the page, set a name, joined the line, drove, drove under 10 s (each distinct people). */
125 funnel: { visitors: number; named: number; queued: number; drove: number; under10s: number };
126 turnLengths: { edgesS: readonly number[]; counts: number[] };
127 latency: { edgesMs: readonly number[]; counts: number[] };
128 reasons: Record<string, number>;
129 /** App starts by cause: "deploy" (a new commit) or "restart" (the same one: a crash, or the host). */
130 starts: Record<string, number>;
131 versions: Marker[];
132 worst: Worst[];
133 }
134
135 export interface Input {
136 from: number;
137 to: number;
138 /** Seconds. */
139 step: number;
140 /** Now: no step past it is uptime's fault. */
141 now: number;
142 /** The first moment anything was recorded; uptime counts from there. */
143 oldest: number | null;
144 turns: readonly TurnRow[];
145 events: readonly EventRow[];
146 samples: readonly SampleRow[];
147 }
148
149 const MINUTE = 60_000;
150
151 export function aggregate(input: Input): Aggregate {
152 const { from, to, now } = input;
153 const stepMs = input.step * 1000;
154 const n = Math.max(1, Math.ceil((to - from) / stepMs));
155 const t = Array.from({ length: n }, (_, i) => from + i * stepMs);
156 const at = (ms: number) => Math.min(n - 1, Math.floor((ms - from) / stepMs));
157 const zeros = () => new Array<number>(n).fill(0);
158 const lists = () => Array.from({ length: n }, () => [] as number[]);
159 const p95s = (l: number[][]) => l.map((v) => round(percentile(v, 95)));
160 const p50s = (l: number[][]) => l.map((v) => round(percentile(v, 50)));
161
162 // ---- the room's events
163 const visitorSets = { anon: Array.from({ length: n }, () => new Set<string>()), named: Array.from({ length: n }, () => new Set<string>()) };
164 const people = { visitors: new Set<string>(), named: new Set<string>(), queued: new Set<string>() };
165 const chat = zeros(), reconnects = zeros(), restarts = zeros(), stops = zeros(), jamEvents = zeros();
166 const versions: Marker[] = [];
167 let appCommit: string | null = null;
168 let piCommit: string | null = null;
169 const starts: Record<string, number> = {};
170 for (const e of input.events) {
171 const i = at(e.at);
172 switch (e.kind) {
173 case "visit":
174 if (!e.who) break;
175 people.visitors.add(e.who);
176 if (e.who.startsWith("u:")) {
177 visitorSets.named[i]!.add(e.who);
178 people.named.add(e.who);
179 } else visitorSets.anon[i]!.add(e.who);
180 break;
181 case "named":
182 if (e.who) people.named.add(e.who);
183 break;
184 case "queued":
185 if (e.who) people.queued.add(e.who);
186 break;
187 case "chat":
188 chat[i]!++;
189 break;
190 case "reconnect":
191 reconnects[i]!++;
192 break;
193 case "stop":
194 stops[i]!++;
195 break;
196 case "jam":
197 jamEvents[i]!++;
198 break;
199 case "restart":
200 restarts[i]!++;
201 starts[e.detail ?? "restart"] = (starts[e.detail ?? "restart"] ?? 0) + 1;
202 if (e.commit !== appCommit) versions.push({ at: e.at, where: "app", commit: e.commit });
203 appCommit = e.commit;
204 break;
205 case "pi-version":
206 if (e.detail && e.detail !== piCommit) versions.push({ at: e.at, where: "pi", commit: e.detail });
207 piCommit = e.detail;
208 break;
209 }
210 }
211
212 // ---- turns
213 const sessions = zeros(), minutes = zeros(), releases = zeros(), snapBacks = zeros(), jams = zeros();
214 const driverSets = Array.from({ length: n }, () => new Set<string>());
215 const byCause: Record<string, number[]> = {};
216 const arrive = lists(), latency = lists(), waits = lists(), lengths = lists();
217 const hops: Record<string, { p50: number[][]; p95: number[][] }> = {};
218 const joints: Record<string, { error: number[][]; arrive: number[][] }> = {};
219 const drove = new Map<string, number>();
220 const reasons: Record<string, number> = {};
221 let measured = false;
222 for (const turn of input.turns) {
223 const i = at(turn.start);
224 sessions[i]!++;
225 driverSets[i]!.add(turn.who);
226 minutes[i]! += turn.ms / MINUTE;
227 lengths[i]!.push(turn.ms / 1000);
228 drove.set(turn.who, Math.max(drove.get(turn.who) ?? 0, turn.ms));
229 reasons[turn.reason] = (reasons[turn.reason] ?? 0) + 1;
230 if (turn.waitMs !== null) waits[i]!.push(turn.waitMs);
231 if (turn.releases === null) continue;
232 measured = true;
233 releases[i]! += turn.releases;
234 snapBacks[i]! += turn.snapBacks ?? 0;
235 jams[i]! += turn.jams ?? 0;
236 for (const [cause, count] of Object.entries(turn.byCause ?? {})) {
237 if (!count) continue;
238 (byCause[cause] ??= zeros())[i]! += count;
239 }
240 if (turn.arriveP95Ms !== null) arrive[i]!.push(turn.arriveP95Ms);
241 if (turn.latencyP95Ms !== null) latency[i]!.push(turn.latencyP95Ms);
242 for (const [name, h] of Object.entries(turn.hops ?? {})) {
243 const into = (hops[name] ??= { p50: lists(), p95: lists() });
244 if (h.p50 !== null) into.p50[i]!.push(h.p50);
245 if (h.p95 !== null) into.p95[i]!.push(h.p95);
246 }
247 for (const [name, j] of Object.entries(turn.joints ?? {})) {
248 const into = (joints[name] ??= { error: lists(), arrive: lists() });
249 if (j.errorP95Deg !== null) into.error[i]!.push(j.errorP95Deg);
250 if (j.arriveP95Ms !== null) into.arrive[i]!.push(j.arriveP95Ms);
251 }
252 }
253
254 // ---- the minute samples
255 const sampled: Record<string, number[][]> = {};
256 for (const s of input.samples) (sampled[s.metric] ??= lists())[at(s.at)]!.push(s.value);
257 const peak = (metric: string) => (sampled[metric] ?? lists()).map((v) => (v.length ? Math.max(...v) : null));
258 const fps: Record<string, Array<number | null>> = {};
259 for (const [metric, l] of Object.entries(sampled)) if (metric.startsWith("fps:")) fps[metric.slice(4)] = l.map((v) => round(mean(v)));
260 // uptime: live minutes over the minutes that have passed in each step since the record began
261 const since = Math.max(from, input.oldest ?? from);
262 const linkLive = (sampled.link ?? lists()).map((v) => v.filter((x) => x > 0).length);
263 const expected = t.map((start) => Math.max(0, (Math.min(start + stepMs, now, to) - Math.max(start, since)) / MINUTE));
264 const uptime = expected.map((e, i) => (e >= 1 ? round(Math.min(1, linkLive[i]! / Math.floor(e)), 3) : null));
265 const allExpected = Math.floor(expected.reduce((a, b) => a + b, 0));
266 const allLive = linkLive.reduce((a, b) => a + b, 0);
267
268 const allReleases = releases.reduce((a, b) => a + b, 0);
269 const allSnaps = snapBacks.reduce((a, b) => a + b, 0);
270 const turnsMeasured = input.turns.filter((x) => x.releases !== null);
271 const latencies = nums(turnsMeasured.map((x) => x.latencyP95Ms));
272 const allJams = jams.reduce((a, b) => a + b, 0);
273 const overview: Overview = {
274 visitors: people.visitors.size,
275 sessions: input.turns.length,
276 drivers: drove.size,
277 minutes: round(minutes.reduce((a, b) => a + b, 0)) ?? 0,
278 snapBacksPer100: allReleases > 0 ? round((100 * allSnaps) / allReleases) : null,
279 arriveP95Ms: round(percentile(nums(turnsMeasured.map((x) => x.arriveP95Ms)), 95), 0),
280 latencyP95Ms: round(percentile(latencies, 95), 0),
281 jams: measured ? allJams : jamEvents.reduce((a, b) => a + b, 0),
282 uptime: allExpected >= 1 && sampled.link ? round(Math.min(1, allLive / allExpected), 3) : null,
283 };
284
285 const per100 = releases.map((r, i) => (r > 0 ? round((100 * snapBacks[i]!) / r) : null));
286 const worst: Worst[] = turnsMeasured
287 .filter((x) => x.trackingP95Deg !== null || (x.snapBacks ?? 0) > 0)
288 .sort((a, b) => (b.trackingP95Deg ?? 0) - (a.trackingP95Deg ?? 0) || (b.snapBacks ?? 0) - (a.snapBacks ?? 0) || b.start - a.start)
289 .slice(0, 10)
290 .map((x) => ({ id: x.id, start: x.start, ms: x.ms, who: x.who, name: x.name, trackingP95Deg: x.trackingP95Deg, arriveP95Ms: x.arriveP95Ms, snapBacks: x.snapBacks, releases: x.releases, latencyP95Ms: x.latencyP95Ms, commit: x.commit }));
291
292 return {
293 from,
294 to,
295 step: input.step,
296 t,
297 overview,
298 series: {
299 visitorsAnon: visitorSets.anon.map((s) => s.size),
300 visitorsNamed: visitorSets.named.map((s) => s.size),
301 sessions,
302 drivers: driverSets.map((s) => s.size),
303 minutes: minutes.map((m) => round(m) ?? 0),
304 releases,
305 snapBacks,
306 snapBacksPer100: per100,
307 byCause,
308 arriveP95Ms: p95s(arrive),
309 latencyP95Ms: p95s(latency),
310 hops: Object.fromEntries(Object.entries(hops).map(([k, h]) => [k, { p50: p50s(h.p50), p95: p95s(h.p95) }])),
311 joints: Object.fromEntries(Object.entries(joints).map(([k, j]) => [k, { errorP95Deg: j.error.map((v) => round(percentile(v, 95), 2)), arriveP95Ms: p95s(j.arrive) }])),
312 watchers: peak("watchers"),
313 driving: peak("drivers"),
314 queue: peak("queue"),
315 waitP50Ms: p50s(waits),
316 waitP95Ms: p95s(waits),
317 turnP50S: p50s(lengths),
318 chat,
319 reconnects,
320 restarts,
321 stops,
322 jams: measured ? jams : jamEvents,
323 uptime,
324 fps,
325 },
326 funnel: {
327 visitors: people.visitors.size,
328 named: people.named.size,
329 queued: people.queued.size,
330 drove: drove.size,
331 under10s: [...drove.values()].filter((ms) => ms < 10_000).length,
332 },
333 turnLengths: { edgesS: TURN_BINS, counts: histogram(input.turns.map((x) => x.ms / 1000), TURN_BINS) },
334 latency: { edgesMs: LATENCY_BINS, counts: histogram(latencies, LATENCY_BINS) },
335 reasons,
336 starts,
337 versions,
338 worst,
339 };
340 }
341
342 /** A request's `from`, `to` and `step`, checked: ms since the epoch, from before to, a listed step; else why not. */
343 export function parseRange(params: URLSearchParams, now: number): { from: number; to: number; step: number } | { error: string } {
344 const read = (name: string) => {
345 const v = params.get(name);
346 if (v === null || v === "") return undefined;
347 return /^\d{1,15}$/.test(v) ? Number(v) : NaN;
348 };
349 const to = read("to") ?? now;
350 const from = read("from") ?? to - 86_400_000;
351 if (Number.isNaN(to) || Number.isNaN(from)) return { error: "from and to are ms since the epoch" };
352 if (from >= to) return { error: "from must be before to" };
353 const span = to - from;
354 const named = read("step");
355 if (Number.isNaN(named)) return { error: `step is one of ${STEPS.join(", ")} seconds` };
356 const step = named ?? autoStep(span);
357 if (!(STEPS as readonly number[]).includes(step)) return { error: `step is one of ${STEPS.join(", ")} seconds` };
358 if (span / (step * 1000) > MAX_POINTS) return { error: `at most ${MAX_POINTS} steps: choose a longer step` };
359 return { from, to, step };
360 }
Software/so101/src/facts.test.ts added +120
@@ -0,0 +1,120 @@
1 import { describe, expect, test } from "bun:test";
2 import { mkdtempSync, rmSync } from "node:fs";
3 import { tmpdir } from "node:os";
4 import { join } from "node:path";
5 import { ArmEdges } from "./arm-edges.ts";
6 import { Facts, fromCard, KEEP_DAYS, readLast, whoKey } from "./facts.ts";
7
8 const DAY = 86_400_000;
9
10 const card = {
11 releases: 20,
12 snapBacks: { total: 3, byCause: { jam: 1, "zone>block": 2, "<script>": 9 } },
13 arriveP95Ms: 420,
14 trackingP95Deg: 1.8,
15 latencyP95Ms: 95,
16 hops: { "page→relay": { n: 10, p50: 12, p95: 30 }, "relay→pi": { n: 0, p50: null, p95: null } },
17 joints: [{ joint: "elbow", errorDeg: { n: 5, p95: 1.1 }, arriveMs: { n: 5, p95: 380 } }, { joint: "wrist", errorDeg: { n: 0 }, arriveMs: { n: 0 } }],
18 };
19
20 describe("Facts", () => {
21 test("a turn keeps who, length, why it ended, its wait in line and the commit", () => {
22 let now = 1_000_000;
23 const facts = new Facts({ commit: "abc1234", now: () => now });
24 facts.queued("u:a");
25 now += 30_000;
26 facts.turnStarted("u:a", "Ada");
27 now += 45_000;
28 expect(facts.turnEnded("u:b", "time")).toBeNull();
29 const id = facts.turnEnded("u:a", "time")!;
30 expect(facts.turn(id)).toMatchObject({ who: "u:a", name: "Ada", start: 1_030_000, ms: 45_000, reason: "time", waitMs: 30_000, commit: "abc1234", releases: null });
31 expect(facts.lastTurn).toEqual({ id, start: 1_030_000 });
32 });
33
34 test("the arm's numbers join their turn", () => {
35 const facts = new Facts({ commit: "abc1234" });
36 facts.turnStarted("g:x", null);
37 const id = facts.turnEnded("g:x", "left")!;
38 facts.armNumbers(id, fromCard(card)!);
39 expect(facts.turn(id)).toMatchObject({ releases: 20, snapBacks: 3, jams: 1, byCause: { jam: 1, "zone>block": 2 }, hops: { "page→relay": { p50: 12, p95: 30 } }, joints: { elbow: { errorP95Deg: 1.1, arriveP95Ms: 380 } } });
40 });
41
42 test("a start is a deploy when the commit changed, else a restart; it survives reopening", () => {
43 const dir = mkdtempSync(join(tmpdir(), "so101-facts-"));
44 try {
45 const file = join(dir, "facts.sqlite");
46 const a = new Facts({ file, commit: "aaa1111" });
47 expect(a.restarted()).toBe("deploy");
48 a.close();
49 const b = new Facts({ file, commit: "aaa1111" });
50 expect(b.restarted()).toBe("restart");
51 b.close();
52 const c = new Facts({ file, commit: "bbb2222" });
53 expect(c.restarted()).toBe("deploy");
54 expect(c.events(0, Date.now() + 1).map((e) => e.detail)).toEqual(["deploy", "restart", "deploy"]);
55 c.close();
56 } finally {
57 rmSync(dir, { recursive: true, force: true });
58 }
59 });
60
61 test(`rows older than ${KEEP_DAYS} days are dropped`, () => {
62 let now = 0;
63 const facts = new Facts({ commit: "abc1234", now: () => now });
64 facts.event("stop");
65 facts.sample({ watchers: 2 });
66 now = 10 * DAY;
67 facts.event("jam");
68 now = (KEEP_DAYS + 5) * DAY;
69 facts.trim();
70 expect(facts.events(0, now).map((e) => e.kind)).toEqual(["jam"]);
71 expect(facts.samples(0, now)).toEqual([]);
72 expect(facts.oldest()).toBe(10 * DAY);
73 });
74
75 test("a sample that is not a finite number is not kept", () => {
76 const facts = new Facts({ commit: "abc1234", now: () => 5 });
77 facts.sample({ watchers: 3, queue: Number.NaN, "fps:front": 24.5 });
78 expect(facts.samples(0, 10)).toEqual([
79 { at: 5, metric: "watchers", value: 3 },
80 { at: 5, metric: "fps:front", value: 24.5 },
81 ]);
82 });
83 });
84
85 describe("reading the arm's card", () => {
86 test("only the expected shape is kept: odd causes, empty hops and unmeasured joints are left out", () => {
87 const n = fromCard(card)!;
88 expect(Object.keys(n.byCause!)).toEqual(["jam", "zone>block"]);
89 expect(Object.keys(n.hops!)).toEqual(["page→relay"]);
90 expect(Object.keys(n.joints!)).toEqual(["elbow"]);
91 expect(fromCard({ releases: "20" })).toBeNull();
92 expect(fromCard(null)).toBeNull();
93 });
94
95 test("readLast takes /precision's last session with when it ended", () => {
96 expect(readLast({ last: { endedAt: 123, card } })?.endedAt).toBe(123);
97 expect(readLast({ last: null })).toBeNull();
98 expect(readLast("nonsense")).toBeNull();
99 });
100
101 test("whoKey: a user's id, or a hash that is not the browser's id", () => {
102 expect(whoKey({ id: "42" }, "browser-1")).toBe("u:42");
103 const g = whoKey(null, "browser-1")!;
104 expect(g).toMatch(/^g:[0-9a-f]{16}$/);
105 expect(g).not.toContain("browser-1");
106 expect(whoKey(null, null)).toBeNull();
107 });
108 });
109
110 describe("ArmEdges", () => {
111 test("a jam or a block counts when it begins, not while it lasts", () => {
112 const edges = new ArmEdges();
113 expect(edges.see({ jammed: [], blocked: false })).toEqual([]);
114 expect(edges.see({ jammed: [2], blocked: true })).toEqual([{ kind: "jam", joints: [2] }, { kind: "block" }]);
115 expect(edges.see({ jammed: [2], blocked: true })).toEqual([]);
116 expect(edges.see({ jammed: [2, 4], blocked: false })).toEqual([{ kind: "jam", joints: [4] }]);
117 expect(edges.see("garbage")).toEqual([]);
118 expect(edges.see({ jammed: [2] })).toEqual([{ kind: "jam", joints: [2] }]);
119 });
120 });
Software/so101/src/facts.ts added +239
@@ -0,0 +1,239 @@
1 /**
2 * What happened, kept for the Data view (/admin/data): one row per turn, the room's and the link's events, and a
3 * sample of the room every minute, each tagged with the commit the app ran, in facts.sqlite beside the chat. Rows
4 * older than 90 days are dropped. Who is a user id ("u:…") or a hash of an anonymous browser's id ("g:…"): never
5 * a name, never an address. The arm's own numbers for a turn (src/precision.ts's card) are added when the arm has them.
6 * Reads are by time range; the sums and percentiles per step are src/data-series.ts's.
7 */
8
9 import { createHash } from "node:crypto";
10 import { openDb, type Db } from "./sqlite.ts";
11
12 export const KEEP_DAYS = 90;
13 const DAY = 86_400_000;
14
15 /** The events the view marks on a time axis or counts. */
16 export const EVENT_KINDS = ["visit", "named", "queued", "chat", "stop", "jam", "block", "park", "reconnect", "restart", "pi-version"] as const;
17 export type EventKind = (typeof EVENT_KINDS)[number];
18
19 /** The room every minute: watchers, drivers (0 or 1), queue length, the arm link live (0 or 1), and a camera's fps under "fps:<camera>". */
20 export type Metric = "watchers" | "drivers" | "queue" | "link" | `fps:${string}`;
21
22 export interface TurnRow {
23 id: number;
24 start: number;
25 ms: number;
26 who: string;
27 /** The name they drove under: a driver has joined with one (a guest only watches). */
28 name: string | null;
29 reason: string;
30 waitMs: number | null;
31 commit: string;
32 /** The arm's numbers for the turn; null until (unless) the arm had them. Jams are the card's "jam" snap-backs. */
33 releases: number | null;
34 snapBacks: number | null;
35 byCause: Record<string, number> | null;
36 jams: number | null;
37 arriveP95Ms: number | null;
38 trackingP95Deg: number | null;
39 latencyP95Ms: number | null;
40 /** p50 and p95 per hop, in ms. */
41 hops: Record<string, { p50: number | null; p95: number | null }> | null;
42 /** Per joint: p95 settle error in degrees and p95 time to arrive in ms. */
43 joints: Record<string, { errorP95Deg: number | null; arriveP95Ms: number | null }> | null;
44 }
45
46 export interface EventRow {
47 at: number;
48 kind: EventKind;
49 who: string | null;
50 detail: string | null;
51 commit: string;
52 }
53
54 export interface SampleRow {
55 at: number;
56 metric: string;
57 value: number;
58 }
59
60 /** A user's id, or a short hash of an anonymous browser's: enough to count distinct people, nothing to find them by. */
61 export const whoKey = (person: { id: string } | null, browser: string | null): string | null =>
62 person ? `u:${person.id}` : browser ? `g:${createHash("sha256").update(browser).digest("hex").slice(0, 16)}` : null;
63
64 type StoredTurn = { id: number; start: number; ms: number; who: string; name: string | null; reason: string; wait_ms: number | null; commit_id: string; arm: string | null };
65
66 type ArmNumbers = Pick<TurnRow, "releases" | "snapBacks" | "byCause" | "jams" | "arriveP95Ms" | "trackingP95Deg" | "latencyP95Ms" | "hops" | "joints">;
67 const num = (v: unknown): number | null => (typeof v === "number" && Number.isFinite(v) ? v : null);
68 const obj = (v: unknown): Record<string, unknown> | null => (v && typeof v === "object" && !Array.isArray(v) ? (v as Record<string, unknown>) : null);
69 const NAME = /^[\w>→ .-]{1,40}$/u;
70
71 /**
72 * The parts of an arm's card (src/precision.ts) the view needs, read field by field: the card comes over the network,
73 * so anything not the expected shape is left out, and a card without its counts is null.
74 */
75 export function fromCard(input: unknown): ArmNumbers | null {
76 const card = obj(input);
77 const snaps = obj(card?.snapBacks);
78 const releases = num(card?.releases);
79 const total = num(snaps?.total);
80 if (!card || !snaps || releases === null || total === null) return null;
81 const byCause: Record<string, number> = {};
82 for (const [cause, n] of Object.entries(obj(snaps.byCause) ?? {})) if (NAME.test(cause) && num(n) !== null) byCause[cause] = n as number;
83 const hops: NonNullable<TurnRow["hops"]> = {};
84 for (const [name, s] of Object.entries(obj(card.hops) ?? {})) {
85 const spread = obj(s);
86 if (NAME.test(name) && spread && (num(spread.n) ?? 0) > 0) hops[name] = { p50: num(spread.p50), p95: num(spread.p95) };
87 }
88 const joints: NonNullable<TurnRow["joints"]> = {};
89 for (const j of Array.isArray(card.joints) ? card.joints : []) {
90 const joint = obj(j);
91 const name = joint?.joint;
92 const error = obj(joint?.errorDeg);
93 const arrive = obj(joint?.arriveMs);
94 if (typeof name !== "string" || !NAME.test(name) || ((num(error?.n) ?? 0) === 0 && (num(arrive?.n) ?? 0) === 0)) continue;
95 joints[name] = { errorP95Deg: num(error?.p95), arriveP95Ms: num(arrive?.p95) };
96 }
97 return {
98 releases,
99 snapBacks: total,
100 byCause,
101 jams: byCause.jam ?? 0,
102 arriveP95Ms: num(card.arriveP95Ms),
103 trackingP95Deg: num(card.trackingP95Deg),
104 latencyP95Ms: num(card.latencyP95Ms),
105 hops,
106 joints,
107 };
108 }
109
110 /** The arm's GET /precision `last` (the drive session that ended last), or null when it has none or it is not that shape. */
111 export function readLast(body: unknown): { endedAt: number; numbers: ArmNumbers } | null {
112 const last = obj(obj(body)?.last);
113 const endedAt = num(last?.endedAt);
114 const numbers = fromCard(last?.card);
115 return endedAt !== null && numbers ? { endedAt, numbers } : null;
116 }
117
118 const NO_ARM = { releases: null, snapBacks: null, byCause: null, jams: null, arriveP95Ms: null, trackingP95Deg: null, latencyP95Ms: null, hops: null, joints: null };
119
120 export class Facts {
121 readonly commit: string;
122 readonly #db: Db;
123 readonly #now: () => number;
124 /** Who asked for a turn and when: their wait, when the turn comes. */
125 readonly #queued = new Map<string, number>();
126 /** The turn being driven: who, since when, and how long they waited. */
127 #open: { who: string; name: string | null; start: number; waitMs: number | null } | null = null;
128 #trimmed = 0;
129 /** The turn that ended last: where the arm's numbers for it go. */
130 lastTurn: { id: number; start: number } | null = null;
131
132 constructor(options: { file?: string; commit: string; now?: () => number }) {
133 this.commit = options.commit;
134 this.#now = options.now ?? Date.now;
135 this.#db = openDb(options.file ?? ":memory:");
136 this.#db.run("PRAGMA journal_mode = WAL");
137 this.#db.run(`CREATE TABLE IF NOT EXISTS turns (id INTEGER PRIMARY KEY, start INTEGER NOT NULL, ms INTEGER NOT NULL, who TEXT NOT NULL, name TEXT,
138 reason TEXT NOT NULL, wait_ms INTEGER, commit_id TEXT NOT NULL, arm TEXT)`);
139 this.#db.run("CREATE INDEX IF NOT EXISTS turns_start ON turns (start)");
140 this.#db.run("CREATE TABLE IF NOT EXISTS events (at INTEGER NOT NULL, kind TEXT NOT NULL, who TEXT, detail TEXT, commit_id TEXT NOT NULL)");
141 this.#db.run("CREATE INDEX IF NOT EXISTS events_at ON events (at)");
142 this.#db.run("CREATE TABLE IF NOT EXISTS samples (at INTEGER NOT NULL, metric TEXT NOT NULL, value REAL NOT NULL)");
143 this.#db.run("CREATE INDEX IF NOT EXISTS samples_at ON samples (at)");
144 this.trim();
145 }
146
147 event(kind: EventKind, who: string | null = null, detail: string | null = null): void {
148 this.#db.query("INSERT INTO events (at, kind, who, detail, commit_id) VALUES (?, ?, ?, ?, ?)").run(this.#now(), kind, who, detail === null ? null : detail.slice(0, 200), this.commit);
149 }
150
151 /** The app started: "deploy" when its commit differs from the last start's, else "restart" (a crash or a host restart). */
152 restarted(): "deploy" | "restart" {
153 const last = this.#db.query("SELECT commit_id FROM events WHERE kind = 'restart' ORDER BY at DESC LIMIT 1").get() as { commit_id: string } | null;
154 const cause = last && last.commit_id === this.commit ? "restart" : "deploy";
155 this.event("restart", null, cause);
156 return cause;
157 }
158
159 /** The room every minute; a value that is not a finite number is not kept. */
160 sample(values: Partial<Record<Metric, number>>): void {
161 const at = this.#now();
162 const insert = this.#db.query("INSERT INTO samples (at, metric, value) VALUES (?, ?, ?)");
163 for (const [metric, value] of Object.entries(values)) if (typeof value === "number" && Number.isFinite(value)) insert.run(at, metric.slice(0, 80), value);
164 if (at - this.#trimmed > DAY) this.trim();
165 }
166
167 /** Someone joined the line (not straight to a turn): their wait starts. */
168 queued(who: string): void {
169 if (!this.#queued.has(who)) this.#queued.set(who, this.#now());
170 this.event("queued", who);
171 }
172
173 /** Someone left the line without a turn. */
174 dequeued(who: string): void {
175 this.#queued.delete(who);
176 }
177
178 turnStarted(who: string, name: string | null): void {
179 const start = this.#now();
180 const since = this.#queued.get(who);
181 this.#queued.delete(who);
182 this.#open = { who, name: name === null ? null : name.slice(0, 64), start, waitMs: since === undefined ? null : start - since };
183 }
184
185 /** The turn ends: its row, whose id the arm's numbers are added under. Null when no turn was open for them. */
186 turnEnded(who: string, reason: string): number | null {
187 const open = this.#open;
188 if (!open || open.who !== who) return null;
189 this.#open = null;
190 const ms = this.#now() - open.start;
191 const row = this.#db.query("INSERT INTO turns (start, ms, who, name, reason, wait_ms, commit_id) VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING id").get(open.start, ms, who, open.name, reason.slice(0, 80), open.waitMs, this.commit) as { id: number };
192 this.lastTurn = { id: row.id, start: open.start };
193 return row.id;
194 }
195
196 /** The arm's numbers for a turn, once it has them. */
197 armNumbers(id: number, numbers: ArmNumbers): void {
198 this.#db.query("UPDATE turns SET arm = ? WHERE id = ?").run(JSON.stringify(numbers), id);
199 }
200
201 turns(from: number, to: number): TurnRow[] {
202 const rows = this.#db.query("SELECT id, start, ms, who, name, reason, wait_ms, commit_id, arm FROM turns WHERE start >= ? AND start < ? ORDER BY start").all(from, to) as StoredTurn[];
203 return rows.map((r) => ({ id: r.id, start: r.start, ms: r.ms, who: r.who, name: r.name, reason: r.reason, waitMs: r.wait_ms, commit: r.commit_id, ...(r.arm ? (JSON.parse(r.arm) as typeof NO_ARM) : NO_ARM) }));
204 }
205
206 turn(id: number): TurnRow | null {
207 const r = this.#db.query("SELECT start FROM turns WHERE id = ?").get(id) as { start: number } | null;
208 return r ? (this.turns(r.start, r.start + 1).find((t) => t.id === id) ?? null) : null;
209 }
210
211 events(from: number, to: number): EventRow[] {
212 const rows = this.#db.query("SELECT at, kind, who, detail, commit_id FROM events WHERE at >= ? AND at < ? ORDER BY at").all(from, to) as Array<{ at: number; kind: EventKind; who: string | null; detail: string | null; commit_id: string }>;
213 return rows.map((r) => ({ at: r.at, kind: r.kind, who: r.who, detail: r.detail, commit: r.commit_id }));
214 }
215
216 samples(from: number, to: number): SampleRow[] {
217 return this.#db.query("SELECT at, metric, value FROM samples WHERE at >= ? AND at < ? ORDER BY at").all(from, to) as SampleRow[];
218 }
219
220 /** The oldest moment kept: where "all" starts. */
221 oldest(): number | null {
222 const r = this.#db.query("SELECT min(at) AS at FROM (SELECT min(start) AS at FROM turns UNION ALL SELECT min(at) FROM events UNION ALL SELECT min(at) FROM samples)").get() as { at: number | null };
223 return r.at;
224 }
225
226 /** Drop what is older than KEEP_DAYS. */
227 trim(): void {
228 const now = this.#now();
229 const cut = now - KEEP_DAYS * DAY;
230 this.#db.query("DELETE FROM turns WHERE start < ?").run(cut);
231 this.#db.query("DELETE FROM events WHERE at < ?").run(cut);
232 this.#db.query("DELETE FROM samples WHERE at < ?").run(cut);
233 this.#trimmed = now;
234 }
235
236 close(): void {
237 this.#db.close();
238 }
239 }
Software/so101/src/lobby.ts +18 −2
@@ -57,6 +57,7 @@ import { Reports } from "./reports.ts";
5757 import { WordFilter } from "./word-filter.ts";
5858 import { identify, LOCAL, type Person, type Who } from "./identity.ts";
5959 import { VisitorCounts } from "./visitor-counts.ts";
60 import { whoKey, type Facts } from "./facts.ts";
6061 import { Turns, type TurnEvent } from "./turns.ts";
6162 import { parseReport, ViewerReports } from "./viewer-reports.ts";
6263 import { wallMs } from "./clock.ts";
@@ -94,6 +95,8 @@ export interface LobbyOptions {
9495 admin?: Admin;
9596 /** Checks each posted line in the background; none leaves the chat unchecked. */
9697 moderator?: Moderator;
98 /** Where visits, turns, the line and chat volume are kept for the Data view; none keeps nothing. */
99 facts?: Facts;
97100 /** How often each socket is pinged, and how long it may stay silent before it counts as dropped. */
98101 pingMs?: number;
99102 silentMs?: number;
@@ -143,6 +146,7 @@ export class Lobby {
143146 private countsSaved = 0;
144147 private countsTimer: ReturnType<typeof setTimeout> | undefined;
145148 readonly moderator: Moderator | undefined;
149 readonly facts: Facts | undefined;
146150 private readonly key: string;
147151 private readonly wss = new WebSocketServer({ noServer: true, maxPayload: MAX_FRAME });
148152 private readonly seats = new Set<Seat>();
@@ -186,6 +190,7 @@ export class Lobby {
186190 this.lineReports = new Reports(options.reportsFile);
187191 this.silentMs = options.silentMs ?? 15_000;
188192 this.moderator = options.moderator;
193 this.facts = options.facts;
189194 if (this.moderator) {
190195 this.moderator.attach({
191196 onHide: (message) => {
@@ -357,6 +362,7 @@ export class Lobby {
357362 if (!who) seat.guest = this.claim(id);
358363 this.seats.add(seat);
359364 this.visitors.joined();
365 this.facts?.event("visit", whoKey(who, seat.guest?.id ?? null));
360366 this.countsChanged();
361367 this.presenceChanged();
362368 if (who) this.turns.connect(who.id);
@@ -468,12 +474,19 @@ export class Lobby {
468474 return this.send(seat, { type: "history", ...this.page(m.before as number, HISTORY_PAGE) });
469475 }
470476 if (!who) return this.send(seat, { type: "error", error: "join to take part" });
471 if (m.type === "leave") return void this.turns.leave(who.id);
477 if (m.type === "leave") {
478 this.facts?.dequeued(whoKey(who, null)!);
479 return void this.turns.leave(who.id);
480 }
472481 if (this.barred(who, seat.from)) return this.send(seat, { type: "error", error: "you are banned: you can still watch" });
473482 switch (m.type) {
474483 case "join":
475 if (this.takeControl(who) === "full") this.send(seat, { type: "error", error: `the line is full (${this.turns.maxQueue}); try again in a little while` });
484 {
485 const joined = this.takeControl(who);
486 if (joined === "full") this.send(seat, { type: "error", error: `the line is full (${this.turns.maxQueue}); try again in a little while` });
487 if (joined === "queued") this.facts?.queued(whoKey(who, null)!);
476488 return;
489 }
477490 case "ready":
478491 this.turns.ready(who.id);
479492 return;
@@ -488,6 +501,7 @@ export class Lobby {
488501 if (held > 0) throw new ChatError(`slow mode: wait ${Math.ceil(held / 1000)}s`);
489502 const said = this.chat.post(who, m.text);
490503 this.lastPosted.set(who.id, this.now());
504 this.facts?.event("chat", whoKey(who, null));
491505 this.broadcast({ type: "said", message: this.marked(said) });
492506 void this.moderator?.check(said);
493507 return;
@@ -704,6 +718,8 @@ export class Lobby {
704718 private turnChanged(event: TurnEvent): void {
705719 if (event.kind === "turn") {
706720 const { ended, started } = event;
721 if (ended) this.facts?.turnEnded(whoKey(ended.who, null)!, ended.reason);
722 if (started) this.facts?.turnStarted(whoKey(started, null)!, started.name);
707723 if (ended) for (const listener of this.endListeners) listener(ended.who);
708724 const parts = [ended ? `${ended.who.name}'s turn ended (${ended.reason})` : null, started ? `${started.name} has control` : ended ? "nobody has control" : null].filter(Boolean);
709725 if (parts.length) this.broadcast({ type: "said", message: this.chat.system(parts.join("; ")) });
Software/so101/src/precision-history.ts +5
@@ -61,6 +61,8 @@ export interface Scorecard {
6161 session: Card | null;
6262 /** The last ten minutes, sessions pooled. */
6363 window: Card;
64 /** The drive session that ended last, whole, and when (ms): the app keeps it with that turn (src/facts.ts). */
65 last: { endedAt: number; card: Card } | null;
6466 /** The last stored sessions and hours, oldest first: the trend across releases. */
6567 sessions: Row[];
6668 hours: Row[];
@@ -77,6 +79,7 @@ export class PrecisionHistory {
7779 #current: Tracker | null = null;
7880 /** The card of the drive session that ended last: how a sweep reads its own numbers. */
7981 last: Card | null = null;
82 #lastAt: number | null = null;
8083
8184 constructor(options: { file?: string; commit?: string; now?: () => number } = {}) {
8285 this.commit = options.commit ?? deployedCommit();
@@ -119,6 +122,7 @@ export class PrecisionHistory {
119122 if (this.#current === tracker) this.#current = null;
120123 const card = summarize(tracker.records);
121124 this.last = card;
125 this.#lastAt = at;
122126 const measured = card.joints.some((j) => j.errorTicks.n > 0) || card.snapBacks.total > 0;
123127 if (!measured) return card;
124128 const seconds = Number(((at - tracker.startedAt) / 1000).toFixed(1));
@@ -171,6 +175,7 @@ export class PrecisionHistory {
171175 commit: this.commit,
172176 tunings: this.tunings(),
173177 session: this.#current ? summarize(this.#current.records) : null,
178 last: this.last && this.#lastAt !== null ? { endedAt: this.#lastAt, card: this.last } : null,
174179 window: summarize(this.#window, now - WINDOW_MS),
175180 sessions: this.rows("sessions"),
176181 hours: this.rows("hours", hours),
Software/so101/src/relay.ts +42
@@ -89,6 +89,10 @@ import { LeaderCalibrations } from "./leader-calibrations.ts";
8989 import { proxyFor, whyNot } from "./egress.ts";
9090 import { CalibrationError } from "./leader-map.js";
9191 import type { Lobby } from "./lobby.ts";
92 import { ArmEdges } from "./arm-edges.ts";
93 import { readLast, whoKey } from "./facts.ts";
94 import { serveData } from "./data-api.ts";
95 import { serveDataPage } from "./data-page.ts";
9296 import { capBody, FrameCap } from "./speed-cap.ts";
9397 import { Clock, CommandGate, HELD_CLOSE, isRelease, parseCommand } from "./command-gate.ts";
9498 import { FLOOD_CLOSE, FrameRate, frameBytes, MAX_FRAME_BYTES, TOO_BIG_CLOSE } from "./frame-limit.ts";
@@ -288,6 +292,7 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> {
288292 recording = false;
289293 }
290294 };
295 const edges = new ArmEdges();
291296 const connect = () => {
292297 armLink?.close();
293298 armLink = null;
@@ -301,6 +306,7 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> {
301306 onStatus: (status) => {
302307 if (!onArm()) return;
303308 machine.status(status);
309 for (const edge of edges.see(status)) lobby?.facts?.event(edge.kind, null, edge.kind === "jam" ? edge.joints.join(",") : null);
304310 void push(); // on to the pages as it comes, at the arm's 25 Hz: no wait for the next tick
305311 },
306312 onMachine: (entries, run) => {
@@ -316,6 +322,7 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> {
316322 if (!baseline.recorded()) void recordBaseline();
317323 }
318324 else console.error(`arm server ${host}: ${state === "offline" ? "offline; simulating" : "reconnecting"} (${armLink?.problem})`);
325 if (previous === "live" && state !== "live") lobby?.facts?.event("reconnect", null, state);
319326 if (previous === "live") for (const c of inFlight) c.abort(new Error(armLink?.problem ?? "the link dropped"));
320327 },
321328 });
@@ -702,6 +709,7 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> {
702709 try {
703710 const { person, token } = lobby.accounts.create((body as { name?: unknown } | null)?.name, visitor(req));
704711 res.setHeader("set-cookie", sessionCookie(token));
712 lobby.facts?.event("named", whoKey(person, null));
705713 return json(res, 200, { you: person });
706714 } catch (err) {
707715 if (err instanceof AccountError) return json(res, err.status, { error: err.message });
@@ -738,6 +746,8 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> {
738746 const cameraAdmin = ADMIN_CAMERA.exec(url.pathname);
739747 if (cameraAdmin) return await administer(req, res, cameraAdmin[1] === undefined ? null : Number(cameraAdmin[1]));
740748 if (lobby && url.pathname === "/admin/lift") return await handleLift(lobby, req, res, readBody);
749 if (url.pathname === "/data") return serveData(lobby ?? undefined, req, res, url);
750 if (lobby && url.pathname === "/admin/data") return serveDataPage(lobby, req, res);
741751 if (lobby && (url.pathname === "/admin" || url.pathname === "/admin/health")) return await handleAdmin(lobby, req, res, url, readBody, askPrecision, askServos, videoCards, options.piSync ? () => options.piSync!.versions() : undefined, tuneP);
742752 if (url.pathname === "/admin/versions") return versionsRead(req, res);
743753 if (url.pathname === "/precision") return await precisionRead(req, res, url);
@@ -803,6 +813,7 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> {
803813 if (req.method === "GET" && PAGE.test(url.pathname)) return await page(req, res, await ask(simulation + path, req, undefined, null, false));
804814 if (req.method === "GET" && STATIC.test(url.pathname) && acceptsGzip(req.headers)) return await packed(res, await ask(simulation + path, req, undefined, null, false), path);
805815 if (route === "POST /stop") {
816 lobby?.facts?.event("stop");
806817 // Shut before anything is asked of an arm: a frame already on its way finds the gate closed.
807818 gate.hold(by);
808819 epoch = Math.max(epoch + 1, Date.now());
@@ -1084,6 +1095,20 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> {
10841095 if (remote && onArm()) for (const one of ms) armLink?.report(PAGE_HOP, one);
10851096 });
10861097 const turnEnded = lobby?.onTurnEnd((who) => {
1098 const ended = lobby.facts?.lastTurn;
1099 // The arm writes its card when the stream closes (below): ask a moment later. A card that ended more than a minute
1100 // from now (on the Pi's clock) belongs to some other turn, and is not kept.
1101 if (ended && lobby.facts) {
1102 const facts = lobby.facts;
1103 setTimeout(async () => {
1104 try {
1105 const last = readLast(await (await armPrecision(null)).json());
1106 if (last && Math.abs(last.endedAt - Date.now()) < 60_000) facts.armNumbers(ended.id, last.numbers);
1107 } catch {
1108 /* the arm did not answer: the turn keeps its own numbers */
1109 }
1110 }, 1500).unref?.();
1111 }
10871112 for (const [browser, id] of streams) if (id === who.id) browser.close(1000, "your turn ended");
10881113 const base = remote && onArm() ? remote.http : simulation;
10891114 fetch(`${base}/stop`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ by: who.name, hold: false }), signal: AbortSignal.timeout(readTimeoutMs) }).then(
@@ -1103,6 +1128,22 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> {
11031128 if (options.piSync) setTimeout(() => void options.piSync!.check(), 3000).unref?.();
11041129 const pusher = lobby ? setInterval(() => void push(), statusEveryMs) : undefined;
11051130 pusher?.unref?.();
1131 // The room every minute for the Data view, and the commit the Pi runs when it changes.
1132 let piSeen: string | null = null;
1133 const sampler = lobby?.facts
1134 ? setInterval(() => {
1135 const facts = lobby.facts!;
1136 const s = lobby.turns.snapshot();
1137 const fps: Record<string, number> = {};
1138 for (const card of videoCards()) if (card.fpsP50 !== null) fps[`fps:${card.camera}`] = card.fpsP50;
1139 facts.sample({ watchers: lobby.visitors.toJSON().here, drivers: s.driver ? 1 : 0, queue: s.queue.length, ...(remote ? { link: mode() === "live" ? 1 : 0 } : {}), ...fps });
1140 const pi = options.piSync?.versions().pi ?? null;
1141 if (pi && pi !== piSeen) facts.event("pi-version", null, pi);
1142 if (pi) piSeen = pi;
1143 }, 60_000)
1144 : undefined;
1145 sampler?.unref?.();
1146 lobby?.facts?.restarted();
11061147 const summary = options.summaryEveryMs ? setInterval(() => console.log(metrics.summaryLine()), options.summaryEveryMs) : undefined;
11071148 summary?.unref?.();
11081149 await new Promise<void>((resolve) => http.listen(options.port ?? 8102, options.host ?? "127.0.0.1", resolve));
@@ -1117,6 +1158,7 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> {
11171158 clearInterval(pusher);
11181159 clearInterval(piTimer);
11191160 clearInterval(summary);
1161 clearInterval(sampler);
11201162 settingsChanged?.();
11211163 turnEnded?.();
11221164 readBacks?.();
Software/so101/src/ui/data-view.js added +461
@@ -0,0 +1,461 @@
1 // @ts-nocheck: a classic script inlined in the page, typed by its use (no DOM lib in this tsconfig).
2 // The Data view's charts (/admin/data): reads GET /data and draws every chart by hand in SVG. Inlined in the page
3 // and pinned by its hash (src/data-page.ts). Nothing from the answer is ever parsed as HTML: text goes in as text.
4 (() => {
5 "use strict";
6 const NS = "http://www.w3.org/2000/svg";
7 const HOUR = 3_600_000;
8 const RANGES = { hour: HOUR, day: 24 * HOUR, week: 7 * 24 * HOUR, all: 90 * 24 * HOUR };
9 const state = { range: "day", custom: null, compare: false, data: null, seq: 0 };
10 const $ = (sel) => document.querySelector(sel);
11 const svg = (tag, attrs, parent) => {
12 const e = document.createElementNS(NS, tag);
13 for (const k in attrs) e.setAttribute(k, String(attrs[k]));
14 if (parent) parent.appendChild(e);
15 return e;
16 };
17 const html = (tag, cls, text, parent) => {
18 const e = document.createElement(tag);
19 if (cls) e.className = cls;
20 if (text !== undefined && text !== null) e.textContent = String(text);
21 if (parent) parent.appendChild(e);
22 return e;
23 };
24 const finite = (v) => typeof v === "number" && Number.isFinite(v);
25
26 // ---------------------------------------------------------------- numbers and times
27 const fmt = (v, unit) => {
28 if (!finite(v)) return "—";
29 const a = Math.abs(v);
30 const s = a >= 10_000 ? `${(v / 1000).toFixed(a >= 100_000 ? 0 : 1)}k` : a >= 100 || Number.isInteger(v) ? Math.round(v).toLocaleString() : v.toFixed(a >= 10 ? 1 : 2);
31 return unit ? `${s} ${unit}` : s;
32 };
33 const pct = (v) => (finite(v) ? `${(v * 100).toFixed(v >= 0.999 ? 0 : 1)}%` : "—");
34 const when = (ms, step) => {
35 const d = new Date(ms);
36 const day = d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
37 const time = d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
38 return step >= 86_400 ? day : step >= 3 * 3600 ? `${day} ${time}` : time;
39 };
40 const duration = (ms) => (ms < 60_000 ? `${Math.round(ms / 1000)} s` : ms < HOUR ? `${Math.floor(ms / 60_000)} min ${Math.round((ms % 60_000) / 1000)} s` : `${(ms / HOUR).toFixed(1)} h`);
41 const niceMax = (v) => {
42 if (!(v > 0)) return 1;
43 const p = 10 ** Math.floor(Math.log10(v));
44 for (const m of [1, 2, 2.5, 5, 10]) if (v <= m * p) return m * p;
45 return 10 * p;
46 };
47 const sum = (a) => a.reduce((x, y) => x + (finite(y) ? y : 0), 0);
48
49 // ---------------------------------------------------------------- one time chart
50 const CH = 170, L = 40, R = 10, T = 14, B = 22;
51 /** Drawn at the box's own width, one unit a pixel, so text is never stretched. */
52 const width = (box) => Math.max(240, Math.round(box.getBoundingClientRect().width || 600));
53 /**
54 * series: [{name, values, color (a palette index), kind: "line"|"bar", unit, dashed?}], stacked bars when stack is true;
55 * band: [lowName, highName] fills between two lines. Previous period: series with prev: true are drawn faint.
56 */
57 function timeChart(box, spec) {
58 const d = state.data;
59 const t = d.t, n = t.length, step = d.step;
60 box.replaceChildren();
61 const W = width(box), H = spec.h || CH;
62 const fig = html("figure", "chart", null, box);
63 const root = svg("svg", { viewBox: `0 0 ${W} ${H}`, width: W, height: H, role: "img", "aria-label": spec.label }, fig);
64 const legend = html("figcaption", "legend", null, fig);
65 for (const s of spec.series.filter((s) => !s.prev)) {
66 const k = html("span", `key c${s.color}`, null, legend);
67 html("i", null, null, k);
68 k.append(s.name);
69 }
70 const bw = (W - L - R) / n;
71 const x = (i) => L + i * bw + bw / 2;
72 const stackTotals = t.map((_, i) => sum(spec.series.filter((s) => s.kind === "bar" && !s.prev).map((s) => s.values[i])));
73 const all = spec.series.flatMap((s) => (s.kind === "bar" && spec.stack ? [] : s.values)).filter(finite);
74 const top = niceMax(Math.max(spec.min ?? 0, ...all, ...(spec.stack ? stackTotals : []), ...spec.series.filter((s) => s.kind === "bar" && !spec.stack).flatMap((s) => s.values.filter(finite))));
75 const y = (v) => T + (H - T - B) * (1 - v / top);
76 for (const f of [0, 0.5, 1]) {
77 svg("line", { x1: L, x2: W - R, y1: y(top * f), y2: y(top * f), class: "grid" }, root);
78 svg("text", { x: L - 6, y: y(top * f) + 4, class: "tick", "text-anchor": "end" }, root).textContent = spec.percent ? pct(top * f) : fmt(top * f);
79 }
80 for (const i of [0, Math.floor(n / 2), n - 1]) {
81 if (i < 0 || (i > 0 && i === Math.floor(n / 2) && n < 3)) continue;
82 svg("text", { x: x(i), y: H - 6, class: "tick", "text-anchor": i === 0 ? "start" : i === n - 1 ? "end" : "middle" }, root).textContent = when(t[i], step);
83 }
84 // bars, stacked or side by side
85 const base = new Array(n).fill(0);
86 const bars = spec.series.filter((s) => s.kind === "bar" && !s.prev);
87 bars.forEach((s, k) => {
88 for (let i = 0; i < n; i++) {
89 const v = s.values[i];
90 if (!finite(v) || v <= 0) continue;
91 const w = spec.stack ? bw * 0.78 : (bw * 0.78) / bars.length;
92 const x0 = L + i * bw + bw * 0.11 + (spec.stack ? 0 : k * w);
93 const y1 = spec.stack ? y(base[i] + v) : y(v);
94 const y0 = spec.stack ? y(base[i]) : y(0);
95 svg("rect", { x: x0, y: y1, width: Math.max(1, w), height: Math.max(0.5, y0 - y1), class: `fill c${s.color}` }, root);
96 if (spec.stack) base[i] += v;
97 }
98 });
99 // a band between two lines
100 if (spec.band) {
101 const [lo, hi] = spec.band.map((name) => spec.series.find((s) => s.name === name));
102 let seg = [];
103 const flush = () => {
104 if (seg.length > 1) svg("path", { d: `M${seg.map((i) => `${x(i)},${y(hi.values[i])}`).join("L")}L${[...seg].reverse().map((i) => `${x(i)},${y(lo.values[i])}`).join("L")}Z`, class: `band c${hi.color}` }, root);
105 seg = [];
106 };
107 for (let i = 0; i < n; i++) (finite(lo.values[i]) && finite(hi.values[i]) ? seg.push(i) : flush());
108 flush();
109 }
110 // lines, with gaps where nothing was measured
111 for (const s of spec.series.filter((s) => s.kind === "line" || (s.prev && s.kind === "bar"))) {
112 let d = "";
113 let pen = false;
114 for (let i = 0; i < n; i++) {
115 const v = s.values[i];
116 if (!finite(v)) {
117 pen = false;
118 continue;
119 }
120 d += `${pen ? "L" : "M"}${x(i).toFixed(1)},${y(v).toFixed(1)}`;
121 pen = true;
122 }
123 if (d) svg("path", { d, class: `line c${s.color}${s.prev ? " prev" : ""}${s.dashed ? " dashed" : ""}` }, root);
124 const lone = s.values.map((v, i) => (finite(v) && !finite(s.values[i - 1]) && !finite(s.values[i + 1]) ? i : -1)).filter((i) => i >= 0);
125 if (!s.prev) for (const i of lone) svg("circle", { cx: x(i), cy: y(s.values[i]), r: 2.5, class: `dot c${s.color}` }, root);
126 }
127 // the deployed commits
128 for (const v of d.versions) {
129 if (v.at < d.from || v.at >= d.to) continue;
130 const vx = L + ((v.at - d.from) / (d.to - d.from)) * (W - L - R);
131 svg("line", { x1: vx, x2: vx, y1: T - 4, y2: H - B, class: `version ${v.where}` }, root);
132 svg("text", { x: vx + 3, y: T - 4, class: "vlabel" }, root).textContent = `${v.where} ${v.commit.slice(0, 7)}`;
133 }
134 if (!spec.series.some((s) => !s.prev && s.values.some((v) => finite(v) && v !== 0))) svg("text", { x: W / 2, y: H / 2, class: "none", "text-anchor": "middle" }, root).textContent = spec.empty || "Nothing in this range";
135 // hover, tap, arrow keys: exact values; drag: zoom
136 const cursor = svg("line", { y1: T, y2: H - B, class: "cursor", visibility: "hidden" }, root);
137 const brush = svg("rect", { y: T, height: H - T - B, class: "brush", visibility: "hidden" }, root);
138 const tip = html("div", "tip", null, fig);
139 tip.hidden = true;
140 fig.tabIndex = 0;
141 let at = -1;
142 const show = (i) => {
143 at = Math.max(0, Math.min(n - 1, i));
144 cursor.setAttribute("x1", x(at));
145 cursor.setAttribute("x2", x(at));
146 cursor.setAttribute("visibility", "visible");
147 tip.replaceChildren();
148 html("b", null, `${when(t[at], step)} – ${when(t[at] + step * 1000, step)}`, tip);
149 for (const s of spec.series) {
150 const row = html("span", `row c${s.color}${s.prev ? " prev" : ""}`, null, tip);
151 html("i", null, null, row);
152 row.append(`${s.prev ? "before: " : ""}${s.name} `);
153 html("b", null, spec.percent ? pct(s.values[at]) : fmt(s.values[at], s.unit), row);
154 }
155 tip.hidden = false;
156 tip.classList.toggle("left", at > n / 2);
157 };
158 const hide = () => {
159 cursor.setAttribute("visibility", "hidden");
160 tip.hidden = true;
161 };
162 const index = (ev) => {
163 const r = root.getBoundingClientRect();
164 return Math.floor((((ev.clientX - r.left) / r.width) * W - L) / bw);
165 };
166 let from = null;
167 root.addEventListener("pointerdown", (ev) => {
168 from = index(ev);
169 show(from);
170 });
171 root.addEventListener("pointermove", (ev) => {
172 const i = index(ev);
173 show(i);
174 if (from !== null && ev.buttons && Math.abs(i - from) >= 1) {
175 const [a, b] = [Math.max(0, Math.min(from, i)), Math.min(n - 1, Math.max(from, i))];
176 brush.setAttribute("x", L + a * bw);
177 brush.setAttribute("width", (b - a + 1) * bw);
178 brush.setAttribute("visibility", "visible");
179 }
180 });
181 root.addEventListener("pointerup", (ev) => {
182 const i = index(ev);
183 brush.setAttribute("visibility", "hidden");
184 if (from !== null && Math.abs(i - from) >= 2) {
185 const [a, b] = [Math.max(0, Math.min(from, i)), Math.min(n - 1, Math.max(from, i))];
186 state.custom = { from: t[a], to: Math.min(d.to, t[b] + step * 1000) };
187 load();
188 }
189 from = null;
190 });
191 root.addEventListener("pointerleave", () => {
192 from = null;
193 brush.setAttribute("visibility", "hidden");
194 hide();
195 });
196 fig.addEventListener("keydown", (ev) => {
197 if (ev.key === "ArrowLeft" || ev.key === "ArrowRight") {
198 ev.preventDefault();
199 show(at < 0 ? n - 1 : at + (ev.key === "ArrowLeft" ? -1 : 1));
200 } else if (ev.key === "Escape") hide();
201 });
202 fig.addEventListener("blur", hide);
203 }
204
205 /** Bars over named bins, for the histograms and the funnel. */
206 function binChart(box, label, names, counts, color) {
207 box.replaceChildren();
208 const W = width(box), H = CH;
209 const fig = html("figure", "chart", null, box);
210 const root = svg("svg", { viewBox: `0 0 ${W} ${H}`, width: W, height: H, role: "img", "aria-label": label }, fig);
211 const top = niceMax(Math.max(1, ...counts));
212 const bw = (W - L - R) / counts.length;
213 const y = (v) => T + (H - T - B) * (1 - v / top);
214 svg("line", { x1: L, x2: W - R, y1: y(0), y2: y(0), class: "grid" }, root);
215 svg("text", { x: L - 6, y: y(top) + 4, class: "tick", "text-anchor": "end" }, root).textContent = fmt(top);
216 counts.forEach((c, i) => {
217 const r = svg("rect", { x: L + i * bw + bw * 0.12, y: y(c), width: bw * 0.76, height: Math.max(0.5, y(0) - y(c)), class: `fill c${color}` }, root);
218 svg("title", {}, r).textContent = `${names[i]}: ${c}`;
219 svg("text", { x: L + i * bw + bw / 2, y: H - 6, class: "tick", "text-anchor": "middle" }, root).textContent = names[i];
220 if (c > 0) svg("text", { x: L + i * bw + bw / 2, y: y(c) - 4, class: "val", "text-anchor": "middle" }, root).textContent = String(c);
221 });
222 }
223 const binNames = (edges, unit) => [`<${edges[0]}`, ...edges.slice(1).map((e, i) => `${edges[i]}–${e}`), `${edges[edges.length - 1]}+`].map((s, i) => (i === edges.length ? `${s} ${unit}` : s));
224
225 // ---------------------------------------------------------------- the page
226 function spark(parent, values, color) {
227 const v = values.map((x) => (finite(x) ? x : null));
228 const top = niceMax(Math.max(0, ...v.filter(finite)));
229 const root = svg("svg", { viewBox: "0 0 100 24", class: "spark", "aria-hidden": "true", preserveAspectRatio: "none" }, parent);
230 let d = "";
231 let pen = false;
232 v.forEach((x, i) => {
233 if (x === null) return void (pen = false);
234 d += `${pen ? "L" : "M"}${((i + 0.5) / v.length) * 100},${22 - (x / top) * 20}`;
235 pen = true;
236 });
237 if (d) svg("path", { d, class: `line c${color}` }, root);
238 }
239
240 function overview(d) {
241 const o = d.overview, s = d.series, p = d.previous && d.previous.overview;
242 const plus = (a, b) => a.map((x, i) => x + b[i]);
243 const cards = [
244 ["Visitors", o.visitors, p && p.visitors, plus(s.visitorsAnon, s.visitorsNamed), 0, fmt, true],
245 ["Driving sessions", o.sessions, p && p.sessions, s.sessions, 1, fmt, true],
246 ["Drivers", o.drivers, p && p.drivers, s.drivers, 1, fmt, true],
247 ["Minutes driven", o.minutes, p && p.minutes, s.minutes, 2, fmt, true],
248 ["Snap-backs / 100 releases", o.snapBacksPer100, p && p.snapBacksPer100, s.snapBacksPer100, 3, fmt, false],
249 ["p95 time to arrive", o.arriveP95Ms, p && p.arriveP95Ms, s.arriveP95Ms, 4, (v) => fmt(v, "ms"), false],
250 ["p95 command → motion", o.latencyP95Ms, p && p.latencyP95Ms, s.latencyP95Ms, 5, (v) => fmt(v, "ms"), false],
251 ["Jams", o.jams, p && p.jams, s.jams, 3, fmt, false],
252 ["Uptime", o.uptime, p && p.uptime, s.uptime, 1, pct, true],
253 ];
254 const strip = $("#strip");
255 strip.replaceChildren();
256 for (const [label, v, before, series, color, f, upIsGood] of cards) {
257 const card = html("article", "stat", null, strip);
258 html("h3", null, label, card);
259 html("p", "big", f(v), card);
260 if (state.compare && finite(before) && finite(v)) {
261 const delta = v - before;
262 const good = delta === 0 ? "same" : delta > 0 === upIsGood ? "good" : "bad";
263 html("p", `delta ${good}`, `${delta > 0 ? "▲" : delta < 0 ? "▼" : "="} ${f(Math.abs(delta))} vs before`, card);
264 } else html("p", "delta", state.compare ? "nothing before" : "", card);
265 spark(card, series, color);
266 }
267 }
268
269 function precision(d) {
270 const s = d.series, causes = Object.keys(s.byCause);
271 timeChart($("#c-causes"), { label: "Snap-backs by cause", stack: true, series: causes.map((c, i) => ({ name: c, values: s.byCause[c], color: i % 8, kind: "bar" })), empty: "No snap-backs in this range" });
272 const joints = $("#joints");
273 joints.replaceChildren();
274 const names = Object.keys(s.joints);
275 if (!names.length) html("p", "empty", "No joint was measured in this range.", joints);
276 for (const name of names) {
277 const cell = html("div", "joint", null, joints);
278 html("h4", null, name.replace(/_/g, " "), cell);
279 const a = html("div", "plot small", null, cell), b = html("div", "plot small", null, cell);
280 timeChart(a, { h: 110, label: `${name} settle error`, series: [{ name: "p95 error", values: s.joints[name].errorP95Deg, color: 4, kind: "line", unit: "°" }] });
281 timeChart(b, { h: 110, label: `${name} time to arrive`, series: [{ name: "p95 arrive", values: s.joints[name].arriveP95Ms, color: 5, kind: "line", unit: "ms" }] });
282 }
283 const body = $("#worst tbody");
284 body.replaceChildren();
285 if (!d.worst.length) {
286 const tr = html("tr", null, null, body);
287 html("td", "empty", "No measured turns in this range.", tr).colSpan = 6;
288 }
289 for (const w of d.worst) {
290 const tr = html("tr", null, null, body);
291 const td = html("td", null, null, tr);
292 const btn = html("button", "link", `${when(w.start, 3600)} · ${w.name || "someone"}`, td);
293 btn.type = "button";
294 btn.addEventListener("click", () => turn(w.id));
295 html("td", null, duration(w.ms), tr);
296 html("td", null, fmt(w.trackingP95Deg, "°"), tr);
297 html("td", null, `${fmt(w.snapBacks)} / ${fmt(w.releases)}`, tr);
298 html("td", null, fmt(w.arriveP95Ms, "ms"), tr);
299 html("td", "mono", w.commit.slice(0, 7), tr);
300 }
301 }
302
303 const prevOf = (key, name, color) => (state.compare && state.data.previous ? [{ name, values: state.data.previous.series[key], color, kind: "line", prev: true }] : []);
304
305 function latency(d) {
306 const box = $("#hops");
307 box.replaceChildren();
308 const hops = Object.keys(d.series.hops);
309 if (!hops.length) html("p", "empty", "No hop was timed in this range: the hops are timed against the real arm, while someone drives.", box);
310 hops.forEach((h, i) => {
311 const cell = html("div", "joint", null, box);
312 html("h4", null, h, cell);
313 const c = html("div", "plot small", null, cell);
314 const v = d.series.hops[h];
315 timeChart(c, { h: 110, label: `${h} latency`, band: ["p50", "p95"], series: [{ name: "p50", values: v.p50, color: i % 8, kind: "line", unit: "ms" }, { name: "p95", values: v.p95, color: i % 8, kind: "line", unit: "ms", dashed: true }] });
316 });
317 timeChart($("#c-total"), { label: "Command to confirmed motion, p95", series: [{ name: "p95 total", values: d.series.latencyP95Ms, color: 5, kind: "line", unit: "ms" }, ...prevOf("latencyP95Ms", "p95 total", 5)] });
318 binChart($("#c-lathist"), "Turns by p95 latency", binNames(d.latency.edgesMs, "ms"), d.latency.counts, 5);
319 }
320
321 function people(d) {
322 const s = d.series;
323 $("#live").textContent = `Now: ${d.live.watchers} watching · ${d.live.driving ? "someone driving" : "nobody driving"} · ${d.live.queue} in line`;
324 timeChart($("#c-visitors"), { label: "Visitors", stack: true, series: [{ name: "named", values: s.visitorsNamed, color: 0, kind: "bar" }, { name: "anonymous", values: s.visitorsAnon, color: 6, kind: "bar" }] });
325 timeChart($("#c-room"), { label: "Watchers, drivers and the line", series: [{ name: "watchers", values: s.watchers, color: 0, kind: "line" }, { name: "driving", values: s.driving, color: 1, kind: "line" }, { name: "in line", values: s.queue, color: 3, kind: "line" }, ...prevOf("watchers", "watchers", 0)] });
326 timeChart($("#c-wait"), { label: "Wait in line", series: [{ name: "p50 wait", values: s.waitP50Ms.map((v) => (finite(v) ? v / 1000 : v)), color: 2, kind: "line", unit: "s" }, { name: "p95 wait", values: s.waitP95Ms.map((v) => (finite(v) ? v / 1000 : v)), color: 3, kind: "line", unit: "s", dashed: true }] });
327 binChart($("#c-turns"), "Turn length", binNames(d.turnLengths.edgesS, "s"), d.turnLengths.counts, 1);
328 timeChart($("#c-chat"), { label: "Chat lines", series: [{ name: "lines", values: s.chat, color: 7, kind: "bar" }, ...prevOf("chat", "lines", 7)] });
329 const f = d.funnel;
330 binChart($("#c-funnel"), "Where people drop off", ["opened", "named", "in line", "drove", "drove <10 s"], [f.visitors, f.named, f.queued, f.drove, f.under10s], 0);
331 }
332
333 function health(d) {
334 const s = d.series;
335 timeChart($("#c-uptime"), { label: "Arm link uptime", percent: true, min: 1, series: [{ name: "live", values: s.uptime, color: 1, kind: "line" }], empty: "No link samples: the app is simulating, or just started" });
336 timeChart($("#c-events"), { label: "Reconnects, restarts and STOPs", series: [{ name: "reconnects", values: s.reconnects, color: 3, kind: "bar" }, { name: "restarts", values: s.restarts, color: 4, kind: "bar" }, { name: "STOP", values: s.stops, color: 6, kind: "bar" }] });
337 const cams = Object.keys(s.fps);
338 timeChart($("#c-fps"), { label: "Camera fps", series: cams.map((c, i) => ({ name: `camera ${c}`, values: s.fps[c], color: i % 8, kind: "line", unit: "fps" })), empty: "No H.264 camera reported fps" });
339 const starts = Object.entries(d.starts).map(([k, n]) => `${n} ${k}${n === 1 ? "" : "s"}`).join(", ");
340 $("#starts").textContent = starts ? `App starts in this range: ${starts}.` : "The app did not restart in this range.";
341 }
342
343 function render() {
344 const d = state.data;
345 const range = $("#range");
346 range.textContent = `${new Date(d.from).toLocaleString()} – ${new Date(d.to).toLocaleString()} · per ${duration(d.step * 1000)}`;
347 $("#reset").hidden = !state.custom;
348 overview(d);
349 precision(d);
350 latency(d);
351 people(d);
352 health(d);
353 }
354
355 async function load() {
356 const seq = ++state.seq;
357 const now = Date.now();
358 const span = state.custom ? null : RANGES[state.range];
359 const to = state.custom ? state.custom.to : now;
360 const from = state.custom ? state.custom.from : now - span;
361 document.body.classList.add("loading");
362 $("#status").textContent = "";
363 try {
364 const r = await fetch(`/data?from=${Math.floor(from)}&to=${Math.ceil(to)}${state.compare ? "&compare=1" : ""}`, { credentials: "same-origin", cache: "no-store" });
365 const body = await r.json();
366 if (seq !== state.seq) return;
367 if (!r.ok) throw new Error(body && body.error ? body.error : `the app answered ${r.status}`);
368 state.data = body;
369 render();
370 } catch (err) {
371 if (seq === state.seq) $("#status").textContent = `Could not load: ${err.message}`;
372 } finally {
373 if (seq === state.seq) document.body.classList.remove("loading");
374 }
375 }
376
377 async function turn(id) {
378 const dialog = $("#turn");
379 const body = $("#turn-body");
380 body.replaceChildren(html("p", "empty", "Loading…"));
381 dialog.showModal();
382 try {
383 const r = await fetch(`/data?turn=${encodeURIComponent(id)}`, { credentials: "same-origin", cache: "no-store" });
384 const d = await r.json();
385 if (!r.ok) throw new Error(d.error || r.status);
386 const t = d.turn;
387 body.replaceChildren();
388 html("h2", null, `${t.name || "someone"} · ${new Date(t.start).toLocaleString()}`, body).id = "turn-title";
389 const facts = html("dl", "facts", null, body);
390 const pair = (k, v) => {
391 html("dt", null, k, facts);
392 html("dd", null, v, facts);
393 };
394 pair("Length", duration(t.ms));
395 pair("Ended", t.reason);
396 pair("Waited", finite(t.waitMs) ? duration(t.waitMs) : "no wait");
397 pair("Releases", fmt(t.releases));
398 pair("Snap-backs", fmt(t.snapBacks));
399 pair("p95 settle error", fmt(t.trackingP95Deg, "°"));
400 pair("p95 time to arrive", fmt(t.arriveP95Ms, "ms"));
401 pair("p95 command → motion", fmt(t.latencyP95Ms, "ms"));
402 pair("Commit", t.commit.slice(0, 12));
403 if (t.byCause) pair("By cause", Object.entries(t.byCause).filter(([, n]) => n > 0).map(([c, n]) => `${c} ${n}`).join(", ") || "none");
404 // the events on the turn's timeline
405 html("h3", null, "Timeline", body);
406 const span = t.ms + 120_000, t0 = t.start - 60_000;
407 const W = width(body);
408 const root = svg("svg", { viewBox: `0 0 ${W} 60`, width: W, height: 60, class: "timeline", role: "img", "aria-label": "Events during the turn" }, body);
409 const x = (ms) => L + ((ms - t0) / span) * (W - L - R);
410 svg("rect", { x: x(t.start), y: 18, width: Math.max(2, x(t.start + t.ms) - x(t.start)), height: 16, class: "turnbar" }, root);
411 for (const e of d.events) {
412 const m = svg("line", { x1: x(e.at), x2: x(e.at), y1: 8, y2: 44, class: `mark ${e.kind}` }, root);
413 svg("title", {}, m).textContent = `${e.kind}${e.detail ? ` ${e.detail}` : ""} at ${new Date(e.at).toLocaleTimeString()}`;
414 }
415 svg("text", { x: L, y: 56, class: "tick" }, root).textContent = new Date(t0).toLocaleTimeString();
416 svg("text", { x: W - R, y: 56, class: "tick", "text-anchor": "end" }, root).textContent = new Date(t0 + span).toLocaleTimeString();
417 const list = html("ul", "events", null, body);
418 if (!d.events.length) html("li", "empty", "No STOP, jam, block or reconnect.", list);
419 for (const e of d.events) html("li", null, `${new Date(e.at).toLocaleTimeString()} · ${e.kind}${e.detail ? ` (${e.detail})` : ""}`, list);
420 if (t.hops && Object.keys(t.hops).length) {
421 html("h3", null, "Latency by hop", body);
422 const hl = html("ul", "events", null, body);
423 for (const [h, v] of Object.entries(t.hops)) html("li", null, `${h}: p50 ${fmt(v.p50, "ms")}, p95 ${fmt(v.p95, "ms")}`, hl);
424 }
425 if (t.joints && Object.keys(t.joints).length) {
426 html("h3", null, "Joints", body);
427 const jl = html("ul", "events", null, body);
428 for (const [j, v] of Object.entries(t.joints)) html("li", null, `${j.replace(/_/g, " ")}: p95 error ${fmt(v.errorP95Deg, "°")}, p95 arrive ${fmt(v.arriveP95Ms, "ms")}`, jl);
429 }
430 } catch (err) {
431 body.replaceChildren(html("p", "empty", `Could not load that turn: ${err.message}`));
432 }
433 }
434
435 for (const b of document.querySelectorAll("[data-range]")) {
436 b.addEventListener("click", () => {
437 state.range = b.dataset.range;
438 state.custom = null;
439 for (const o of document.querySelectorAll("[data-range]")) o.setAttribute("aria-pressed", String(o === b));
440 load();
441 });
442 }
443 $("#compare").addEventListener("change", (ev) => {
444 state.compare = ev.target.checked;
445 load();
446 });
447 $("#reset").addEventListener("click", () => {
448 state.custom = null;
449 load();
450 });
451 $("#turn-close").addEventListener("click", () => $("#turn").close());
452 // a new width (a phone turned, a window resized) draws again at it
453 let wide = document.documentElement.clientWidth, redraw = 0;
454 addEventListener("resize", () => {
455 if (document.documentElement.clientWidth === wide) return;
456 wide = document.documentElement.clientWidth;
457 clearTimeout(redraw);
458 redraw = setTimeout(() => state.data && render(), 150);
459 });
460 load();
461 })();
Software/so101/src/viewer.html +1
@@ -356,6 +356,7 @@
356356 <div class="card" id="modCard">
357357 <h3>Room</h3>
358358 <div class="row" id="modTurn"></div>
359 <p><a href="/admin/data">Data</a>: people, precision, latency and health over time.</p>
359360 <p>Long-press a chat message (or right-click it) to delete it, or mute or ban its author. In the Queue tab, × takes someone out of the line.</p>
360361 <div id="mutedLine"></div>
361362 <div id="bannedLine"></div>

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs