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

Migrated from github.com/Trevato/SO-ARM100

workbenchthis machine

Data view: Pi temperature and under-voltage over time #525

Parked opened this
Closed without merging

quill/arm:quill/520-data-view-pi-temperature-and-under-volta → main 3 commits fork

  • Closed without merging. Nothing was applied to main.

Review

Sign in to review this change.
20 files changed +2302 −3
Software/so101/README.md +15
@@ -48,3 +48,18 @@ 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, the Pi's hottest minute and the minutes it was short of power). 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; and, while the real arm is live, the Pi's own minute as `pi:temp` (°C) and `pi:undervolt` (0 or 1), at the time the Pi logged it.
60
61 The Pi appends a line every minute to `/var/log/so101-health.log` (another file: `SO101_HEALTH_LOG` for `so101 serve`). The arm server serves its last lines parsed at `GET /health/pi?n=1-1440` (`{"lines": [{at, tempC, undervolt, throttled}], "skipped"}`, 404 without the log), and the relay asks each minute for as many lines as minutes have passed since the last one it kept, so a relay that was down fills the gap (up to a day) and nothing is kept twice. A line is a time (ISO 8601, Unix seconds or syslog's `Mon DD HH:MM:SS`) then fields: `temp=51.6'C` (or `temp_c=`, `temperature=`), and `undervolt=0|1` or vcgencmd's `throttled=0x50005`, whose bit 0 is "under-voltage now". `src/pi-health.ts` has the details and `src/pi-health.test.ts` the formats it reads.
62
63 `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`.
64
65 Not yet shown: 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 +199
@@ -0,0 +1,199 @@
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 <section class="panel" aria-labelledby="h-pi"><h2 id="h-pi">Health · the Pi: temperature and under-voltage</h2><p class="note" id="pi-note"></p>${plot("c-pitemp", "plot small")}${plot("c-piuv", "plot small")}</section>
176 </div>
177 </main>
178 <dialog id="turn" aria-labelledby="turn-title"><button type="button" class="close" id="turn-close">Close</button><div id="turn-body"></div></dialog>
179 <script>${DATA_SCRIPT}</script>
180 </body>
181 </html>
182 `;
183 }
184
185 /** GET /admin/data: the page for the owner and moderators; to anyone else, the ordinary not-found. */
186 export function serveDataPage(lobby: Lobby, req: IncomingMessage, res: ServerResponse): void {
187 if (!lobby.who(req) || !lobby.moderates(req)) {
188 res.writeHead(404, { "content-type": "application/json", "cache-control": "no-store" });
189 res.end(JSON.stringify({ error: "no such route" }));
190 return;
191 }
192 if (req.method !== "GET" && req.method !== "HEAD") {
193 res.writeHead(405, { "content-type": "application/json", "cache-control": "no-store", allow: "GET, HEAD" });
194 res.end(JSON.stringify({ error: "GET" }));
195 return;
196 }
197 res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", "content-security-policy": dataCsp() });
198 res.end(req.method === "HEAD" ? undefined : renderDataPage());
199 }
Software/so101/src/data-series.test.ts added +172
@@ -0,0 +1,172 @@
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("the Pi: each step's hottest minute, and how many minutes it was short of power", () => {
126 const samples = [
127 { at: T0, metric: "pi:temp", value: 51.24 },
128 { at: T0 + 60_000, metric: "pi:temp", value: 63.18 },
129 { at: T0, metric: "pi:undervolt", value: 1 },
130 { at: T0 + 60_000, metric: "pi:undervolt", value: 1 },
131 { at: T0 + 120_000, metric: "pi:undervolt", value: 0 },
132 { at: T0 + H, metric: "pi:temp", value: 48 },
133 { at: T0 + H, metric: "pi:undervolt", value: 0 },
134 ];
135 const a = aggregate(input({ samples }));
136 expect(a.series.piTempC).toEqual([63.2, 48, null, null]);
137 expect(a.series.piUndervolt).toEqual([2, 0, null, null]);
138 });
139
140 test("version markers: one per change of commit, app and Pi apart; starts by cause", () => {
141 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")];
142 const a = aggregate(input({ events: events.sort((x, y) => x.at - y.at) }));
143 expect(a.versions).toEqual([
144 { at: T0, where: "app", commit: "aaa1111" },
145 { at: T0 + 5, where: "pi", commit: "ccc3333" },
146 { at: T0 + H, where: "app", commit: "bbb2222" },
147 ]);
148 expect(a.starts).toEqual({ deploy: 2, restart: 1 });
149 expect(a.series.restarts).toEqual([2, 1, 0, 0]);
150 });
151
152 test("the worst sessions are the measured ones, worst tracking first, at most ten", () => {
153 const turns = Array.from({ length: 14 }, (_, i) => turn({ id: i + 1, start: T0 + i, releases: 4, snapBacks: i % 2, trackingP95Deg: i }));
154 const a = aggregate(input({ turns: [...turns, turn({ id: 99, trackingP95Deg: null })] }));
155 expect(a.worst.map((w) => w.id)).toEqual([14, 13, 12, 11, 10, 9, 8, 7, 6, 5]);
156 });
157 });
158
159 describe("parseRange", () => {
160 const now = T0 + 24 * H;
161 const p = (q: string) => parseRange(new URLSearchParams(q), now);
162 test("defaults to the last day at its auto step", () => {
163 expect(p("")).toEqual({ from: now - 24 * H, to: now, step: 900 });
164 });
165 test("refuses what is not a range", () => {
166 expect(p("from=abc")).toHaveProperty("error");
167 expect(p(`from=${now}&to=${now - 1}`)).toHaveProperty("error");
168 expect(p("step=7")).toHaveProperty("error");
169 expect(p(`from=${now - (MAX_POINTS + 1) * 60_000}&to=${now}&step=60`)).toHaveProperty("error");
170 expect(p(`from=${now - H}&to=${now}&step=300`)).toEqual({ from: now - H, to: now, step: 300 });
171 });
172 });
Software/so101/src/data-series.ts added +366
@@ -0,0 +1,366 @@
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 * - Pi temperature: the hottest minute the Pi logged in a step (°C); under-voltage: the minutes it logged short of power.
13 * - uptime: the minutes the arm link was sampled live over the minutes since the range (or the record) began.
14 */
15
16 import type { EventRow, SampleRow, TurnRow } from "./facts.ts";
17
18 /** The steps a request may name, in seconds, smallest first. */
19 export const STEPS = [60, 300, 900, 1800, 3600, 3 * 3600, 6 * 3600, 86_400] as const;
20 /** The most steps one answer carries. */
21 export const MAX_POINTS = 400;
22 const TARGET_POINTS = 96;
23
24 /** The step that cuts a range into about TARGET_POINTS, never more than MAX_POINTS. */
25 export function autoStep(ms: number): number {
26 for (const s of STEPS) if (ms / (s * 1000) <= TARGET_POINTS) return s;
27 return STEPS[STEPS.length - 1]!;
28 }
29
30 /** Nearest-rank percentile; null for no values. */
31 export function percentile(values: readonly number[], p: number): number | null {
32 if (values.length === 0) return null;
33 const sorted = [...values].sort((a, b) => a - b);
34 const rank = Math.max(1, Math.ceil((p / 100) * sorted.length));
35 return sorted[rank - 1]!;
36 }
37
38 const round = (v: number | null, digits = 1): number | null => (v === null || !Number.isFinite(v) ? null : Number(v.toFixed(digits)));
39 const nums = (values: ReadonlyArray<number | null | undefined>): number[] => values.filter((v): v is number => typeof v === "number" && Number.isFinite(v));
40 const mean = (values: readonly number[]): number | null => (values.length ? values.reduce((a, b) => a + b, 0) / values.length : null);
41
42 export const TURN_BINS = [10, 30, 60, 120, 300] as const;
43 export const LATENCY_BINS = [25, 50, 100, 200, 400] as const;
44 /** Counts per bin: below the first edge, between each pair, and at or past the last. */
45 export function histogram(values: readonly number[], edges: readonly number[]): number[] {
46 const out = new Array<number>(edges.length + 1).fill(0);
47 for (const v of values) {
48 let i = 0;
49 while (i < edges.length && v >= edges[i]!) i++;
50 out[i]!++;
51 }
52 return out;
53 }
54
55 export interface Overview {
56 visitors: number;
57 sessions: number;
58 drivers: number;
59 minutes: number;
60 snapBacksPer100: number | null;
61 arriveP95Ms: number | null;
62 latencyP95Ms: number | null;
63 jams: number;
64 /** 0–1; null with no samples. */
65 uptime: number | null;
66 }
67
68 export interface Series {
69 visitorsAnon: number[];
70 visitorsNamed: number[];
71 sessions: number[];
72 drivers: number[];
73 minutes: number[];
74 releases: number[];
75 snapBacks: number[];
76 snapBacksPer100: Array<number | null>;
77 byCause: Record<string, number[]>;
78 arriveP95Ms: Array<number | null>;
79 latencyP95Ms: Array<number | null>;
80 hops: Record<string, { p50: Array<number | null>; p95: Array<number | null> }>;
81 joints: Record<string, { errorP95Deg: Array<number | null>; arriveP95Ms: Array<number | null> }>;
82 watchers: Array<number | null>;
83 driving: Array<number | null>;
84 queue: Array<number | null>;
85 waitP50Ms: Array<number | null>;
86 waitP95Ms: Array<number | null>;
87 turnP50S: Array<number | null>;
88 chat: number[];
89 reconnects: number[];
90 restarts: number[];
91 stops: number[];
92 jams: number[];
93 uptime: Array<number | null>;
94 fps: Record<string, Array<number | null>>;
95 piTempC: Array<number | null>;
96 /** Minutes the Pi logged under-voltage; null for a step with no reading. */
97 piUndervolt: Array<number | null>;
98 }
99
100 export interface Marker {
101 at: number;
102 where: "app" | "pi";
103 commit: string;
104 }
105
106 export interface Worst {
107 id: number;
108 start: number;
109 ms: number;
110 who: string;
111 name: string | null;
112 trackingP95Deg: number | null;
113 arriveP95Ms: number | null;
114 snapBacks: number | null;
115 releases: number | null;
116 latencyP95Ms: number | null;
117 commit: string;
118 }
119
120 export interface Aggregate {
121 from: number;
122 to: number;
123 step: number;
124 /** Each step's start, ms. */
125 t: number[];
126 overview: Overview;
127 series: Series;
128 /** Who got how far: opened the page, set a name, joined the line, drove, drove under 10 s (each distinct people). */
129 funnel: { visitors: number; named: number; queued: number; drove: number; under10s: number };
130 turnLengths: { edgesS: readonly number[]; counts: number[] };
131 latency: { edgesMs: readonly number[]; counts: number[] };
132 reasons: Record<string, number>;
133 /** App starts by cause: "deploy" (a new commit) or "restart" (the same one: a crash, or the host). */
134 starts: Record<string, number>;
135 versions: Marker[];
136 worst: Worst[];
137 }
138
139 export interface Input {
140 from: number;
141 to: number;
142 /** Seconds. */
143 step: number;
144 /** Now: no step past it is uptime's fault. */
145 now: number;
146 /** The first moment anything was recorded; uptime counts from there. */
147 oldest: number | null;
148 turns: readonly TurnRow[];
149 events: readonly EventRow[];
150 samples: readonly SampleRow[];
151 }
152
153 const MINUTE = 60_000;
154
155 export function aggregate(input: Input): Aggregate {
156 const { from, to, now } = input;
157 const stepMs = input.step * 1000;
158 const n = Math.max(1, Math.ceil((to - from) / stepMs));
159 const t = Array.from({ length: n }, (_, i) => from + i * stepMs);
160 const at = (ms: number) => Math.min(n - 1, Math.floor((ms - from) / stepMs));
161 const zeros = () => new Array<number>(n).fill(0);
162 const lists = () => Array.from({ length: n }, () => [] as number[]);
163 const p95s = (l: number[][]) => l.map((v) => round(percentile(v, 95)));
164 const p50s = (l: number[][]) => l.map((v) => round(percentile(v, 50)));
165
166 // ---- the room's events
167 const visitorSets = { anon: Array.from({ length: n }, () => new Set<string>()), named: Array.from({ length: n }, () => new Set<string>()) };
168 const people = { visitors: new Set<string>(), named: new Set<string>(), queued: new Set<string>() };
169 const chat = zeros(), reconnects = zeros(), restarts = zeros(), stops = zeros(), jamEvents = zeros();
170 const versions: Marker[] = [];
171 let appCommit: string | null = null;
172 let piCommit: string | null = null;
173 const starts: Record<string, number> = {};
174 for (const e of input.events) {
175 const i = at(e.at);
176 switch (e.kind) {
177 case "visit":
178 if (!e.who) break;
179 people.visitors.add(e.who);
180 if (e.who.startsWith("u:")) {
181 visitorSets.named[i]!.add(e.who);
182 people.named.add(e.who);
183 } else visitorSets.anon[i]!.add(e.who);
184 break;
185 case "named":
186 if (e.who) people.named.add(e.who);
187 break;
188 case "queued":
189 if (e.who) people.queued.add(e.who);
190 break;
191 case "chat":
192 chat[i]!++;
193 break;
194 case "reconnect":
195 reconnects[i]!++;
196 break;
197 case "stop":
198 stops[i]!++;
199 break;
200 case "jam":
201 jamEvents[i]!++;
202 break;
203 case "restart":
204 restarts[i]!++;
205 starts[e.detail ?? "restart"] = (starts[e.detail ?? "restart"] ?? 0) + 1;
206 if (e.commit !== appCommit) versions.push({ at: e.at, where: "app", commit: e.commit });
207 appCommit = e.commit;
208 break;
209 case "pi-version":
210 if (e.detail && e.detail !== piCommit) versions.push({ at: e.at, where: "pi", commit: e.detail });
211 piCommit = e.detail;
212 break;
213 }
214 }
215
216 // ---- turns
217 const sessions = zeros(), minutes = zeros(), releases = zeros(), snapBacks = zeros(), jams = zeros();
218 const driverSets = Array.from({ length: n }, () => new Set<string>());
219 const byCause: Record<string, number[]> = {};
220 const arrive = lists(), latency = lists(), waits = lists(), lengths = lists();
221 const hops: Record<string, { p50: number[][]; p95: number[][] }> = {};
222 const joints: Record<string, { error: number[][]; arrive: number[][] }> = {};
223 const drove = new Map<string, number>();
224 const reasons: Record<string, number> = {};
225 let measured = false;
226 for (const turn of input.turns) {
227 const i = at(turn.start);
228 sessions[i]!++;
229 driverSets[i]!.add(turn.who);
230 minutes[i]! += turn.ms / MINUTE;
231 lengths[i]!.push(turn.ms / 1000);
232 drove.set(turn.who, Math.max(drove.get(turn.who) ?? 0, turn.ms));
233 reasons[turn.reason] = (reasons[turn.reason] ?? 0) + 1;
234 if (turn.waitMs !== null) waits[i]!.push(turn.waitMs);
235 if (turn.releases === null) continue;
236 measured = true;
237 releases[i]! += turn.releases;
238 snapBacks[i]! += turn.snapBacks ?? 0;
239 jams[i]! += turn.jams ?? 0;
240 for (const [cause, count] of Object.entries(turn.byCause ?? {})) {
241 if (!count) continue;
242 (byCause[cause] ??= zeros())[i]! += count;
243 }
244 if (turn.arriveP95Ms !== null) arrive[i]!.push(turn.arriveP95Ms);
245 if (turn.latencyP95Ms !== null) latency[i]!.push(turn.latencyP95Ms);
246 for (const [name, h] of Object.entries(turn.hops ?? {})) {
247 const into = (hops[name] ??= { p50: lists(), p95: lists() });
248 if (h.p50 !== null) into.p50[i]!.push(h.p50);
249 if (h.p95 !== null) into.p95[i]!.push(h.p95);
250 }
251 for (const [name, j] of Object.entries(turn.joints ?? {})) {
252 const into = (joints[name] ??= { error: lists(), arrive: lists() });
253 if (j.errorP95Deg !== null) into.error[i]!.push(j.errorP95Deg);
254 if (j.arriveP95Ms !== null) into.arrive[i]!.push(j.arriveP95Ms);
255 }
256 }
257
258 // ---- the minute samples
259 const sampled: Record<string, number[][]> = {};
260 for (const s of input.samples) (sampled[s.metric] ??= lists())[at(s.at)]!.push(s.value);
261 const peak = (metric: string) => (sampled[metric] ?? lists()).map((v) => (v.length ? Math.max(...v) : null));
262 const fps: Record<string, Array<number | null>> = {};
263 for (const [metric, l] of Object.entries(sampled)) if (metric.startsWith("fps:")) fps[metric.slice(4)] = l.map((v) => round(mean(v)));
264 // uptime: live minutes over the minutes that have passed in each step since the record began
265 const since = Math.max(from, input.oldest ?? from);
266 const linkLive = (sampled.link ?? lists()).map((v) => v.filter((x) => x > 0).length);
267 const expected = t.map((start) => Math.max(0, (Math.min(start + stepMs, now, to) - Math.max(start, since)) / MINUTE));
268 const uptime = expected.map((e, i) => (e >= 1 ? round(Math.min(1, linkLive[i]! / Math.floor(e)), 3) : null));
269 const allExpected = Math.floor(expected.reduce((a, b) => a + b, 0));
270 const allLive = linkLive.reduce((a, b) => a + b, 0);
271
272 const allReleases = releases.reduce((a, b) => a + b, 0);
273 const allSnaps = snapBacks.reduce((a, b) => a + b, 0);
274 const turnsMeasured = input.turns.filter((x) => x.releases !== null);
275 const latencies = nums(turnsMeasured.map((x) => x.latencyP95Ms));
276 const allJams = jams.reduce((a, b) => a + b, 0);
277 const overview: Overview = {
278 visitors: people.visitors.size,
279 sessions: input.turns.length,
280 drivers: drove.size,
281 minutes: round(minutes.reduce((a, b) => a + b, 0)) ?? 0,
282 snapBacksPer100: allReleases > 0 ? round((100 * allSnaps) / allReleases) : null,
283 arriveP95Ms: round(percentile(nums(turnsMeasured.map((x) => x.arriveP95Ms)), 95), 0),
284 latencyP95Ms: round(percentile(latencies, 95), 0),
285 jams: measured ? allJams : jamEvents.reduce((a, b) => a + b, 0),
286 uptime: allExpected >= 1 && sampled.link ? round(Math.min(1, allLive / allExpected), 3) : null,
287 };
288
289 const per100 = releases.map((r, i) => (r > 0 ? round((100 * snapBacks[i]!) / r) : null));
290 const worst: Worst[] = turnsMeasured
291 .filter((x) => x.trackingP95Deg !== null || (x.snapBacks ?? 0) > 0)
292 .sort((a, b) => (b.trackingP95Deg ?? 0) - (a.trackingP95Deg ?? 0) || (b.snapBacks ?? 0) - (a.snapBacks ?? 0) || b.start - a.start)
293 .slice(0, 10)
294 .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 }));
295
296 return {
297 from,
298 to,
299 step: input.step,
300 t,
301 overview,
302 series: {
303 visitorsAnon: visitorSets.anon.map((s) => s.size),
304 visitorsNamed: visitorSets.named.map((s) => s.size),
305 sessions,
306 drivers: driverSets.map((s) => s.size),
307 minutes: minutes.map((m) => round(m) ?? 0),
308 releases,
309 snapBacks,
310 snapBacksPer100: per100,
311 byCause,
312 arriveP95Ms: p95s(arrive),
313 latencyP95Ms: p95s(latency),
314 hops: Object.fromEntries(Object.entries(hops).map(([k, h]) => [k, { p50: p50s(h.p50), p95: p95s(h.p95) }])),
315 joints: Object.fromEntries(Object.entries(joints).map(([k, j]) => [k, { errorP95Deg: j.error.map((v) => round(percentile(v, 95), 2)), arriveP95Ms: p95s(j.arrive) }])),
316 watchers: peak("watchers"),
317 driving: peak("drivers"),
318 queue: peak("queue"),
319 waitP50Ms: p50s(waits),
320 waitP95Ms: p95s(waits),
321 turnP50S: p50s(lengths),
322 chat,
323 reconnects,
324 restarts,
325 stops,
326 jams: measured ? jams : jamEvents,
327 uptime,
328 fps,
329 piTempC: peak("pi:temp").map((v) => round(v)),
330 piUndervolt: (sampled["pi:undervolt"] ?? lists()).map((v) => (v.length ? v.filter((x) => x > 0).length : null)),
331 },
332 funnel: {
333 visitors: people.visitors.size,
334 named: people.named.size,
335 queued: people.queued.size,
336 drove: drove.size,
337 under10s: [...drove.values()].filter((ms) => ms < 10_000).length,
338 },
339 turnLengths: { edgesS: TURN_BINS, counts: histogram(input.turns.map((x) => x.ms / 1000), TURN_BINS) },
340 latency: { edgesMs: LATENCY_BINS, counts: histogram(latencies, LATENCY_BINS) },
341 reasons,
342 starts,
343 versions,
344 worst,
345 };
346 }
347
348 /** A request's `from`, `to` and `step`, checked: ms since the epoch, from before to, a listed step; else why not. */
349 export function parseRange(params: URLSearchParams, now: number): { from: number; to: number; step: number } | { error: string } {
350 const read = (name: string) => {
351 const v = params.get(name);
352 if (v === null || v === "") return undefined;
353 return /^\d{1,15}$/.test(v) ? Number(v) : NaN;
354 };
355 const to = read("to") ?? now;
356 const from = read("from") ?? to - 86_400_000;
357 if (Number.isNaN(to) || Number.isNaN(from)) return { error: "from and to are ms since the epoch" };
358 if (from >= to) return { error: "from must be before to" };
359 const span = to - from;
360 const named = read("step");
361 if (Number.isNaN(named)) return { error: `step is one of ${STEPS.join(", ")} seconds` };
362 const step = named ?? autoStep(span);
363 if (!(STEPS as readonly number[]).includes(step)) return { error: `step is one of ${STEPS.join(", ")} seconds` };
364 if (span / (step * 1000) > MAX_POINTS) return { error: `at most ${MAX_POINTS} steps: choose a longer step` };
365 return { from, to, step };
366 }
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 +251
@@ -0,0 +1,251 @@
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 /**
20 * 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>";
21 * and the Pi's own minute from its health log (src/pi-health.ts): its temperature in °C and under-voltage (0 or 1).
22 */
23 export type Metric = "watchers" | "drivers" | "queue" | "link" | "pi:temp" | "pi:undervolt" | `fps:${string}`;
24
25 export interface TurnRow {
26 id: number;
27 start: number;
28 ms: number;
29 who: string;
30 /** The name they drove under: a driver has joined with one (a guest only watches). */
31 name: string | null;
32 reason: string;
33 waitMs: number | null;
34 commit: string;
35 /** The arm's numbers for the turn; null until (unless) the arm had them. Jams are the card's "jam" snap-backs. */
36 releases: number | null;
37 snapBacks: number | null;
38 byCause: Record<string, number> | null;
39 jams: number | null;
40 arriveP95Ms: number | null;
41 trackingP95Deg: number | null;
42 latencyP95Ms: number | null;
43 /** p50 and p95 per hop, in ms. */
44 hops: Record<string, { p50: number | null; p95: number | null }> | null;
45 /** Per joint: p95 settle error in degrees and p95 time to arrive in ms. */
46 joints: Record<string, { errorP95Deg: number | null; arriveP95Ms: number | null }> | null;
47 }
48
49 export interface EventRow {
50 at: number;
51 kind: EventKind;
52 who: string | null;
53 detail: string | null;
54 commit: string;
55 }
56
57 export interface SampleRow {
58 at: number;
59 metric: string;
60 value: number;
61 }
62
63 /** A user's id, or a short hash of an anonymous browser's: enough to count distinct people, nothing to find them by. */
64 export const whoKey = (person: { id: string } | null, browser: string | null): string | null =>
65 person ? `u:${person.id}` : browser ? `g:${createHash("sha256").update(browser).digest("hex").slice(0, 16)}` : null;
66
67 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 };
68
69 type ArmNumbers = Pick<TurnRow, "releases" | "snapBacks" | "byCause" | "jams" | "arriveP95Ms" | "trackingP95Deg" | "latencyP95Ms" | "hops" | "joints">;
70 const num = (v: unknown): number | null => (typeof v === "number" && Number.isFinite(v) ? v : null);
71 const obj = (v: unknown): Record<string, unknown> | null => (v && typeof v === "object" && !Array.isArray(v) ? (v as Record<string, unknown>) : null);
72 const NAME = /^[\w>→ .-]{1,40}$/u;
73
74 /**
75 * The parts of an arm's card (src/precision.ts) the view needs, read field by field: the card comes over the network,
76 * so anything not the expected shape is left out, and a card without its counts is null.
77 */
78 export function fromCard(input: unknown): ArmNumbers | null {
79 const card = obj(input);
80 const snaps = obj(card?.snapBacks);
81 const releases = num(card?.releases);
82 const total = num(snaps?.total);
83 if (!card || !snaps || releases === null || total === null) return null;
84 const byCause: Record<string, number> = {};
85 for (const [cause, n] of Object.entries(obj(snaps.byCause) ?? {})) if (NAME.test(cause) && num(n) !== null) byCause[cause] = n as number;
86 const hops: NonNullable<TurnRow["hops"]> = {};
87 for (const [name, s] of Object.entries(obj(card.hops) ?? {})) {
88 const spread = obj(s);
89 if (NAME.test(name) && spread && (num(spread.n) ?? 0) > 0) hops[name] = { p50: num(spread.p50), p95: num(spread.p95) };
90 }
91 const joints: NonNullable<TurnRow["joints"]> = {};
92 for (const j of Array.isArray(card.joints) ? card.joints : []) {
93 const joint = obj(j);
94 const name = joint?.joint;
95 const error = obj(joint?.errorDeg);
96 const arrive = obj(joint?.arriveMs);
97 if (typeof name !== "string" || !NAME.test(name) || ((num(error?.n) ?? 0) === 0 && (num(arrive?.n) ?? 0) === 0)) continue;
98 joints[name] = { errorP95Deg: num(error?.p95), arriveP95Ms: num(arrive?.p95) };
99 }
100 return {
101 releases,
102 snapBacks: total,
103 byCause,
104 jams: byCause.jam ?? 0,
105 arriveP95Ms: num(card.arriveP95Ms),
106 trackingP95Deg: num(card.trackingP95Deg),
107 latencyP95Ms: num(card.latencyP95Ms),
108 hops,
109 joints,
110 };
111 }
112
113 /** The arm's GET /precision `last` (the drive session that ended last), or null when it has none or it is not that shape. */
114 export function readLast(body: unknown): { endedAt: number; numbers: ArmNumbers } | null {
115 const last = obj(obj(body)?.last);
116 const endedAt = num(last?.endedAt);
117 const numbers = fromCard(last?.card);
118 return endedAt !== null && numbers ? { endedAt, numbers } : null;
119 }
120
121 const NO_ARM = { releases: null, snapBacks: null, byCause: null, jams: null, arriveP95Ms: null, trackingP95Deg: null, latencyP95Ms: null, hops: null, joints: null };
122
123 export class Facts {
124 readonly commit: string;
125 readonly #db: Db;
126 readonly #now: () => number;
127 /** Who asked for a turn and when: their wait, when the turn comes. */
128 readonly #queued = new Map<string, number>();
129 /** The turn being driven: who, since when, and how long they waited. */
130 #open: { who: string; name: string | null; start: number; waitMs: number | null } | null = null;
131 #trimmed = 0;
132 /** The turn that ended last: where the arm's numbers for it go. */
133 lastTurn: { id: number; start: number } | null = null;
134
135 constructor(options: { file?: string; commit: string; now?: () => number }) {
136 this.commit = options.commit;
137 this.#now = options.now ?? Date.now;
138 this.#db = openDb(options.file ?? ":memory:");
139 this.#db.run("PRAGMA journal_mode = WAL");
140 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,
141 reason TEXT NOT NULL, wait_ms INTEGER, commit_id TEXT NOT NULL, arm TEXT)`);
142 this.#db.run("CREATE INDEX IF NOT EXISTS turns_start ON turns (start)");
143 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)");
144 this.#db.run("CREATE INDEX IF NOT EXISTS events_at ON events (at)");
145 this.#db.run("CREATE TABLE IF NOT EXISTS samples (at INTEGER NOT NULL, metric TEXT NOT NULL, value REAL NOT NULL)");
146 this.#db.run("CREATE INDEX IF NOT EXISTS samples_at ON samples (at)");
147 this.trim();
148 }
149
150 event(kind: EventKind, who: string | null = null, detail: string | null = null): void {
151 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);
152 }
153
154 /** The app started: "deploy" when its commit differs from the last start's, else "restart" (a crash or a host restart). */
155 restarted(): "deploy" | "restart" {
156 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;
157 const cause = last && last.commit_id === this.commit ? "restart" : "deploy";
158 this.event("restart", null, cause);
159 return cause;
160 }
161
162 /** The room every minute; a value that is not a finite number is not kept. */
163 sample(values: Partial<Record<Metric, number>>): void {
164 this.sampleAt(this.#now(), values);
165 }
166
167 /** Values measured at `at` (the Pi's health lines carry their own time); a value that is not a finite number is not kept. */
168 sampleAt(at: number, values: Partial<Record<Metric, number>>): void {
169 const insert = this.#db.query("INSERT INTO samples (at, metric, value) VALUES (?, ?, ?)");
170 for (const [metric, value] of Object.entries(values)) if (typeof value === "number" && Number.isFinite(value)) insert.run(at, metric.slice(0, 80), value);
171 if (this.#now() - this.#trimmed > DAY) this.trim();
172 }
173
174 /** When a metric was last kept; null when never. */
175 lastSampleAt(metric: Metric): number | null {
176 return (this.#db.query("SELECT max(at) AS at FROM samples WHERE metric = ?").get(metric) as { at: number | null }).at;
177 }
178
179 /** Someone joined the line (not straight to a turn): their wait starts. */
180 queued(who: string): void {
181 if (!this.#queued.has(who)) this.#queued.set(who, this.#now());
182 this.event("queued", who);
183 }
184
185 /** Someone left the line without a turn. */
186 dequeued(who: string): void {
187 this.#queued.delete(who);
188 }
189
190 turnStarted(who: string, name: string | null): void {
191 const start = this.#now();
192 const since = this.#queued.get(who);
193 this.#queued.delete(who);
194 this.#open = { who, name: name === null ? null : name.slice(0, 64), start, waitMs: since === undefined ? null : start - since };
195 }
196
197 /** The turn ends: its row, whose id the arm's numbers are added under. Null when no turn was open for them. */
198 turnEnded(who: string, reason: string): number | null {
199 const open = this.#open;
200 if (!open || open.who !== who) return null;
201 this.#open = null;
202 const ms = this.#now() - open.start;
203 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 };
204 this.lastTurn = { id: row.id, start: open.start };
205 return row.id;
206 }
207
208 /** The arm's numbers for a turn, once it has them. */
209 armNumbers(id: number, numbers: ArmNumbers): void {
210 this.#db.query("UPDATE turns SET arm = ? WHERE id = ?").run(JSON.stringify(numbers), id);
211 }
212
213 turns(from: number, to: number): TurnRow[] {
214 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[];
215 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) }));
216 }
217
218 turn(id: number): TurnRow | null {
219 const r = this.#db.query("SELECT start FROM turns WHERE id = ?").get(id) as { start: number } | null;
220 return r ? (this.turns(r.start, r.start + 1).find((t) => t.id === id) ?? null) : null;
221 }
222
223 events(from: number, to: number): EventRow[] {
224 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 }>;
225 return rows.map((r) => ({ at: r.at, kind: r.kind, who: r.who, detail: r.detail, commit: r.commit_id }));
226 }
227
228 samples(from: number, to: number): SampleRow[] {
229 return this.#db.query("SELECT at, metric, value FROM samples WHERE at >= ? AND at < ? ORDER BY at").all(from, to) as SampleRow[];
230 }
231
232 /** The oldest moment kept: where "all" starts. */
233 oldest(): number | null {
234 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 };
235 return r.at;
236 }
237
238 /** Drop what is older than KEEP_DAYS. */
239 trim(): void {
240 const now = this.#now();
241 const cut = now - KEEP_DAYS * DAY;
242 this.#db.query("DELETE FROM turns WHERE start < ?").run(cut);
243 this.#db.query("DELETE FROM events WHERE at < ?").run(cut);
244 this.#db.query("DELETE FROM samples WHERE at < ?").run(cut);
245 this.#trimmed = now;
246 }
247
248 close(): void {
249 this.#db.close();
250 }
251 }
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/pi-health.test.ts added +148
@@ -0,0 +1,148 @@
1 import { afterAll, describe, expect, test } from "bun:test";
2 import { appendFileSync, mkdtempSync, writeFileSync } from "node:fs";
3 import { tmpdir } from "node:os";
4 import { join } from "node:path";
5 import { FeetechBus } from "./bus.ts";
6 import { FakeBus } from "./fake-bus.ts";
7 import { Facts } from "./facts.ts";
8 import { Lobby } from "./lobby.ts";
9 import { MAX_LINES, parseHealthLine, parseHealthLog, readHealthAnswer, readLogTail, samplePiHealth } from "./pi-health.ts";
10 import { parseRemote, startRelay, type RunningRelay } from "./relay.ts";
11 import { startServer, type RunningServer } from "./server.ts";
12
13 const T = Date.UTC(2026, 9, 22, 12, 3, 0);
14 const iso = (ms: number) => new Date(ms).toISOString().replace(/\.\d{3}Z$/, "Z");
15
16 describe("parseHealthLine", () => {
17 test("an ISO time with vcgencmd's own output: temperature, and under-voltage from throttled's bit 0", () => {
18 expect(parseHealthLine("2026-10-22T12:03:00Z temp=51.6'C throttled=0x50005")).toEqual({ at: T, tempC: 51.6, undervolt: 1, throttled: 0x50005 });
19 // bit 16 alone says it happened since boot, not that it is short of power now
20 expect(parseHealthLine("2026-10-22T12:03:00Z temp=48.2'C throttled=0x50000")).toMatchObject({ undervolt: 0, throttled: 0x50000 });
21 });
22
23 test("a zone offset is honoured; a time without one is the Pi's local time", () => {
24 expect(parseHealthLine("2026-10-22T14:03:00+02:00 temp=50")?.at).toBe(T);
25 expect(parseHealthLine("2026-10-22T07:33:00-0430 temp=50")?.at).toBe(T);
26 expect(parseHealthLine("2026-10-22 12:03:00 temp_c=50")?.at).toBe(new Date(2026, 9, 22, 12, 3, 0).getTime());
27 });
28
29 test("Unix seconds and syslog's time, placed in the year it was logged", () => {
30 expect(parseHealthLine(`${T / 1000} temp=50.1 undervolt=0`)).toEqual({ at: T, tempC: 50.1, undervolt: 0, throttled: null });
31 const jan = new Date(2027, 0, 2).getTime();
32 expect(parseHealthLine("Dec 31 23:59:00 pi so101-health: temperature=49.0°C under_voltage=yes", jan)).toMatchObject({ at: new Date(2026, 11, 31, 23, 59).getTime(), tempC: 49, undervolt: 1 });
33 });
34
35 test("an explicit undervolt= wins over throttled=", () => {
36 expect(parseHealthLine("2026-10-22T12:03:00Z throttled=0x1 undervolt=no")).toMatchObject({ tempC: null, undervolt: 0 });
37 });
38
39 test("a line without a time, without a reading, or out of range is not a reading", () => {
40 for (const line of ["", "temp=50", "booting", "2026-10-22T12:03:00Z hello", "2026-13-22T12:03:00Z temp=50", "2026-10-22T12:03:00Z temp=900", "2026-10-22T12:03:00Z temp=hot throttled=0xZZ", "Foo 22 12:03:00 temp=50"]) {
41 expect(parseHealthLine(line)).toBeNull();
42 }
43 expect(parseHealthLine("2026-10-22T12:03:00Z temp=900 undervolt=1")).toMatchObject({ tempC: null, undervolt: 1 });
44 });
45 });
46
47 describe("parseHealthLog", () => {
48 test("the last n readings, oldest first, with the unreadable lines counted", () => {
49 const text = [0, 1, 2, 3].map((m) => `${iso(T + m * 60_000)} temp=${50 + m}`).join("\n") + "\nnot a line\n\n";
50 const all = parseHealthLog(text);
51 expect(all.lines.map((l) => l.tempC)).toEqual([50, 51, 52, 53]);
52 expect(all.skipped).toBe(1);
53 expect(parseHealthLog(text, 2).lines.map((l) => l.tempC)).toEqual([52, 53]);
54 });
55
56 test("readLogTail drops the cut first line of a long file and is null without one", () => {
57 const dir = mkdtempSync(join(tmpdir(), "so101-health-"));
58 const file = join(dir, "health.log");
59 writeFileSync(file, Array.from({ length: 50 }, (_, m) => `${iso(T + m * 60_000)} temp=${40 + m / 10}`).join("\n") + "\n");
60 const tail = readLogTail(file, 200)!;
61 expect(tail.length).toBeLessThanOrEqual(200);
62 const { lines, skipped } = parseHealthLog(tail);
63 expect(skipped).toBe(0);
64 expect(lines.at(-1)?.tempC).toBe(44.9);
65 expect(readLogTail(join(dir, "missing.log"))).toBeNull();
66 });
67 });
68
69 describe("readHealthAnswer", () => {
70 test("keeps only the lines that are the shape, and is null for a body that is no answer", () => {
71 expect(readHealthAnswer(null)).toBeNull();
72 expect(readHealthAnswer({ error: "no" })).toBeNull();
73 expect(readHealthAnswer([{ at: T, tempC: 50 }])).toBeNull();
74 expect(
75 readHealthAnswer({
76 lines: [{ at: T + 60_000, tempC: 51, undervolt: 1, throttled: 1 }, { at: T, tempC: 50, undervolt: 0 }, { at: "x", tempC: 50 }, { at: T, tempC: 999, undervolt: 7 }, { at: T, tempC: "<b>" }, null],
77 }),
78 ).toEqual([
79 { at: T, tempC: 50, undervolt: 0, throttled: null },
80 { at: T + 60_000, tempC: 51, undervolt: 1, throttled: 1 },
81 ]);
82 });
83 });
84
85 describe("samplePiHealth", () => {
86 const line = (m: number, tempC: number | null, undervolt: 0 | 1 | null) => ({ at: T + m * 60_000, tempC, undervolt, throttled: null });
87
88 test("each line becomes pi:temp and pi:undervolt at its own time; asking again keeps nothing twice", () => {
89 const facts = new Facts({ commit: "abc1234", now: () => T + 10 * 60_000 });
90 expect(samplePiHealth(facts, [line(0, 50, 0), line(1, 51.5, 1), line(2, null, 0)], T + 10 * 60_000)).toBe(3);
91 expect(samplePiHealth(facts, [line(1, 51.5, 1), line(2, null, 0), line(3, 52, 0)], T + 10 * 60_000)).toBe(1);
92 const rows = facts.samples(T, T + 3600_000);
93 expect(rows.filter((r) => r.metric === "pi:temp").map((r) => [r.at - T, r.value])).toEqual([[0, 50], [60_000, 51.5], [180_000, 52]]);
94 expect(rows.filter((r) => r.metric === "pi:undervolt").map((r) => r.value)).toEqual([0, 1, 0, 0]);
95 expect(facts.lastSampleAt("pi:temp")).toBe(T + 180_000);
96 });
97
98 test("a line from a Pi clock far ahead is not kept", () => {
99 const facts = new Facts({ commit: "abc1234", now: () => T });
100 expect(samplePiHealth(facts, [line(0, 50, 0), line(60, 51, 0)], T)).toBe(1);
101 expect(facts.lastSampleAt("pi:temp")).toBe(T);
102 });
103 });
104
105 describe("GET /health/pi and the relay's minute", () => {
106 const dir = mkdtempSync(join(tmpdir(), "so101-health-serve-"));
107 const log = join(dir, "so101-health.log");
108 let arm: RunningServer | null = null;
109 let relay: RunningRelay | null = null;
110 // as in relay.test.ts: a server with a live link may take a while to close, and nothing waits on it
111 const shut = (s: { close(): Promise<void> } | null) => (s ? Promise.race([s.close(), Bun.sleep(500)]) : undefined);
112 afterAll(async () => {
113 await shut(relay);
114 await shut(arm);
115 });
116
117 test("the arm server serves the log's last lines parsed; 400 for a bad n, 404 without a log", async () => {
118 const now = Date.now();
119 writeFileSync(log, [3, 2, 1].map((ago) => `${iso(now - ago * 60_000)} temp=${60 - ago}'C throttled=0x${ago === 1 ? "50005" : "0"}`).join("\n") + "\ngarbage\n");
120 arm = await startServer(new FeetechBus(new FakeBus([1, 2, 6])), { host: "127.0.0.1", port: 0, ids: [1, 2, 6], posesFile: join(dir, "poses.json"), healthLog: log });
121 const base = `http://127.0.0.1:${arm.port}`;
122 const body = (await (await fetch(`${base}/health/pi?n=2`)).json()) as { lines: Array<{ tempC: number; undervolt: number }>; skipped: number };
123 expect(body.lines.map((l) => [l.tempC, l.undervolt])).toEqual([[58, 0], [59, 1]]);
124 expect(body.skipped).toBe(1);
125 for (const n of ["0", `${MAX_LINES + 1}`, "x", "1e3"]) expect((await fetch(`${base}/health/pi?n=${n}`)).status).toBe(400);
126 const none = await startServer(new FeetechBus(new FakeBus([1])), { host: "127.0.0.1", port: 0, ids: [1], posesFile: join(mkdtempSync(join(tmpdir(), "so101-health-none-")), "poses.json"), healthLog: join(dir, "missing.log") });
127 expect((await fetch(`http://127.0.0.1:${none.port}/health/pi`)).status).toBe(404);
128 await none.close();
129 });
130
131 test("the relay keeps the Pi's lines as samples, then each new one", async () => {
132 const facts = new Facts({ file: join(dir, "facts.sqlite"), commit: "abc1234" });
133 const lobby = new Lobby({ identityKey: "k".repeat(32), adminFile: join(dir, "admin.json"), countsFile: join(dir, "visitors.json"), facts });
134 const base = `http://127.0.0.1:${arm!.port}`;
135 relay = await startRelay({ port: 0, simulation: base, remote: parseRemote({ ARM_SERVER: `ws://127.0.0.1:${arm!.port}` })!, lobby, sampleEveryMs: 40 });
136 const until = async (ok: () => boolean) => {
137 for (let i = 0; i < 100 && !ok(); i++) await Bun.sleep(20);
138 };
139 const temps = () => facts.samples(0, Date.now() + 3600_000).filter((r) => r.metric === "pi:temp");
140 await until(() => temps().length >= 3);
141 expect(temps().map((r) => r.value)).toEqual([57, 58, 59]);
142 expect(facts.samples(0, Date.now() + 3600_000).filter((r) => r.metric === "pi:undervolt").map((r) => r.value)).toEqual([0, 0, 1]);
143 appendFileSync(log, `${iso(Date.now())} temp=61.2'C throttled=0x0\n`);
144 await until(() => temps().length >= 4);
145 await Bun.sleep(120); // a few more minutes go by: nothing is kept twice
146 expect(temps().map((r) => r.value)).toEqual([57, 58, 59, 61.2]);
147 });
148 });
Software/so101/src/pi-health.ts added +172
@@ -0,0 +1,172 @@
1 /**
2 * The Pi's own health, for the Data view: its SoC temperature and whether it is short of power (under-voltage).
3 * The Pi appends a line every minute to /var/log/so101-health.log; the arm server serves the recent lines parsed
4 * (GET /health/pi), and the relay copies each new one into facts.sqlite as the samples pi:temp and pi:undervolt.
5 *
6 * A line is a time, then fields, in any order:
7 * 2026-10-22T14:03:00+02:00 temp=51.6'C throttled=0x50005
8 * 2026-10-22 14:03:00 temp_c=51.6 undervolt=1
9 * 1792670580 temp=51.6 throttled=0x0
10 * Oct 22 14:03:00 pi so101-health: temp=51.6'C throttled=0x50000
11 * The time is ISO 8601 (a time without a zone is the Pi's local time), Unix seconds, or syslog's "Mon DD HH:MM:SS".
12 * The temperature is temp=, temp_c= or temperature= in °C ('C or C after it is allowed). Under-voltage is
13 * undervolt= / under_voltage= (0/1, yes/no, true/false), or else bit 0 of vcgencmd's throttled= ("under-voltage now";
14 * bit 16, "has happened since boot", is kept in `throttled` but is not a flag for this minute).
15 * A line without a time, or with neither a temperature nor an under-voltage reading, is skipped and counted.
16 * Plain data in, plain data out; the only I/O is reading the log's tail.
17 */
18
19 import { closeSync, fstatSync, openSync, readSync } from "node:fs";
20 import type { Facts } from "./facts.ts";
21
22 export const HEALTH_LOG = "/var/log/so101-health.log";
23 /** The most lines one answer carries: a day of minutes. */
24 export const MAX_LINES = 1440;
25 /** How much of the log's end is read: a day of lines at well over 100 bytes each. */
26 const TAIL_BYTES = 256 * 1024;
27 const MIN_C = -40;
28 const MAX_C = 125;
29
30 export interface HealthLine {
31 /** Unix ms. */
32 at: number;
33 tempC: number | null;
34 /** 1 while the Pi is short of power, 0 when it is not, null when the line does not say. */
35 undervolt: 0 | 1 | null;
36 /** vcgencmd get_throttled's bits, when the line carries them. */
37 throttled: number | null;
38 }
39
40 const MONTHS = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"];
41 const ISO = /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2})(?:[.,]\d+)?)?(Z|[+-]\d{2}:?\d{2})?(?=\s|$)/;
42 const EPOCH = /^(\d{9,10})(?:\.\d+)?(?=\s|$)/;
43 const SYSLOG = /^([A-Za-z]{3}) +(\d{1,2}) (\d{2}):(\d{2}):(\d{2})(?=\s|$)/;
44 const FIELD = /(?:^|[\s,;])([a-z_]+)[=:]\s*([^\s,;]+)/gi;
45
46 function time(line: string, now: number): { at: number; rest: string } | null {
47 let m = ISO.exec(line);
48 if (m) {
49 const [, y, mo, d, h, mi, s, zone] = m;
50 const parts = [Number(y), Number(mo) - 1, Number(d), Number(h), Number(mi), Number(s ?? 0)] as const;
51 if (parts[1] > 11 || parts[2] < 1 || parts[2] > 31 || parts[3] > 23 || parts[4] > 59 || parts[5] > 60) return null;
52 let at: number;
53 if (!zone) at = new Date(...parts).getTime();
54 else {
55 at = Date.UTC(...parts);
56 if (zone !== "Z") {
57 const z = zone.replace(":", "");
58 const offset = (Number(z.slice(1, 3)) * 60 + Number(z.slice(3, 5))) * 60_000;
59 at -= z[0] === "+" ? offset : -offset;
60 }
61 }
62 return Number.isFinite(at) ? { at, rest: line.slice(m[0].length) } : null;
63 }
64 m = EPOCH.exec(line);
65 if (m) return { at: Number(m[1]) * 1000, rest: line.slice(m[0].length) };
66 m = SYSLOG.exec(line);
67 if (m) {
68 const month = MONTHS.indexOf(m[1]!.toLowerCase());
69 if (month < 0) return null;
70 const year = new Date(now).getFullYear();
71 let at = new Date(year, month, Number(m[2]), Number(m[3]), Number(m[4]), Number(m[5])).getTime();
72 if (at > now + 86_400_000) at = new Date(year - 1, month, Number(m[2]), Number(m[3]), Number(m[4]), Number(m[5])).getTime(); // December's lines read in January
73 return Number.isFinite(at) ? { at, rest: line.slice(m[0].length) } : null;
74 }
75 return null;
76 }
77
78 const flag = (v: string): 0 | 1 | null => (/^(1|yes|true|on)$/i.test(v) ? 1 : /^(0|no|false|off)$/i.test(v) ? 0 : null);
79
80 /** One line of the health log, or null when it carries no time or no reading. `now` places a syslog time in its year. */
81 export function parseHealthLine(line: string, now = Date.now()): HealthLine | null {
82 const stamped = time(line.trim(), now);
83 if (!stamped) return null;
84 let tempC: number | null = null;
85 let undervolt: 0 | 1 | null = null;
86 let throttled: number | null = null;
87 for (const [, rawKey, value] of stamped.rest.matchAll(FIELD)) {
88 const key = rawKey!.toLowerCase();
89 if (key === "temp" || key === "temp_c" || key === "temperature") {
90 const m = /^(-?\d{1,3}(?:\.\d+)?)(?:'?C|°C)?$/i.exec(value!);
91 const c = m ? Number(m[1]) : NaN;
92 if (Number.isFinite(c) && c >= MIN_C && c <= MAX_C) tempC = c;
93 } else if (key === "undervolt" || key === "under_voltage" || key === "undervoltage") {
94 undervolt = flag(value!) ?? undervolt;
95 } else if (key === "throttled") {
96 if (/^0x[0-9a-f]{1,8}$/i.test(value!) || /^\d{1,10}$/.test(value!)) throttled = Number(value) >>> 0;
97 }
98 }
99 if (undervolt === null && throttled !== null) undervolt = (throttled & 1) as 0 | 1;
100 if (tempC === null && undervolt === null) return null;
101 return { at: stamped.at, tempC, undervolt, throttled };
102 }
103
104 /** The last `n` readable lines of a log's text, oldest first, and how many lines were skipped as unreadable. */
105 export function parseHealthLog(text: string, n = MAX_LINES, now = Date.now()): { lines: HealthLine[]; skipped: number } {
106 const lines: HealthLine[] = [];
107 let skipped = 0;
108 for (const raw of text.split("\n")) {
109 if (!raw.trim()) continue;
110 const line = parseHealthLine(raw, now);
111 if (line) lines.push(line);
112 else skipped++;
113 }
114 lines.sort((a, b) => a.at - b.at);
115 return { lines: lines.slice(-Math.max(1, Math.min(MAX_LINES, n))), skipped };
116 }
117
118 /** The end of the log file (the first, likely cut, line dropped when the file is longer); null when there is none. */
119 export function readLogTail(file: string, bytes = TAIL_BYTES): string | null {
120 let fd: number;
121 try {
122 fd = openSync(file, "r");
123 } catch {
124 return null;
125 }
126 try {
127 const size = fstatSync(fd).size;
128 const start = Math.max(0, size - bytes);
129 const buf = Buffer.alloc(size - start);
130 const read = readSync(fd, buf, 0, buf.length, start);
131 const text = buf.subarray(0, read).toString("utf8");
132 return start > 0 ? text.slice(text.indexOf("\n") + 1) : text;
133 } finally {
134 closeSync(fd);
135 }
136 }
137
138 /**
139 * The arm server's GET /health/pi answer, read field by field on the relay: it comes over the network, so a line
140 * that is not the shape (or is out of range) is left out. Null when the body is not an answer at all.
141 */
142 export function readHealthAnswer(body: unknown): HealthLine[] | null {
143 const lines = body && typeof body === "object" && !Array.isArray(body) ? (body as { lines?: unknown }).lines : undefined;
144 if (!Array.isArray(lines)) return null;
145 const out: HealthLine[] = [];
146 for (const l of lines.slice(-MAX_LINES)) {
147 if (!l || typeof l !== "object") continue;
148 const { at, tempC, undervolt, throttled } = l as Record<string, unknown>;
149 if (typeof at !== "number" || !Number.isFinite(at) || at <= 0) continue;
150 const t = typeof tempC === "number" && Number.isFinite(tempC) && tempC >= MIN_C && tempC <= MAX_C ? tempC : null;
151 const u = undervolt === 0 || undervolt === 1 ? undervolt : null;
152 if (t === null && u === null) continue;
153 out.push({ at, tempC: t, undervolt: u, throttled: typeof throttled === "number" && Number.isInteger(throttled) && throttled >= 0 ? throttled : null });
154 }
155 return out.sort((a, b) => a.at - b.at);
156 }
157
158 /**
159 * Keep every line newer than the last one kept, as the samples pi:temp and pi:undervolt at the line's own time: a
160 * relay that was down for a while fills the gap from the Pi's log, and asking twice keeps nothing twice. Lines
161 * from more than `future` ms ahead of now (a Pi clock gone wrong) are not kept. Returns how many lines were kept.
162 */
163 export function samplePiHealth(facts: Facts, lines: readonly HealthLine[], now = Date.now(), future = 5 * 60_000): number {
164 const last = Math.max(facts.lastSampleAt("pi:temp") ?? 0, facts.lastSampleAt("pi:undervolt") ?? 0);
165 let kept = 0;
166 for (const line of lines) {
167 if (line.at <= last || line.at > now + future) continue;
168 facts.sampleAt(line.at, { ...(line.tempC !== null ? { "pi:temp": line.tempC } : {}), ...(line.undervolt !== null ? { "pi:undervolt": line.undervolt } : {}) });
169 kept++;
170 }
171 return kept;
172 }
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 +59
@@ -89,6 +89,11 @@ 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, type Facts } from "./facts.ts";
94 import { MAX_LINES, readHealthAnswer, samplePiHealth } from "./pi-health.ts";
95 import { serveData } from "./data-api.ts";
96 import { serveDataPage } from "./data-page.ts";
9297 import { capBody, FrameCap } from "./speed-cap.ts";
9398 import { Clock, CommandGate, HELD_CLOSE, isRelease, parseCommand } from "./command-gate.ts";
9499 import { FLOOD_CLOSE, FrameRate, frameBytes, MAX_FRAME_BYTES, TOO_BIG_CLOSE } from "./frame-limit.ts";
@@ -117,6 +122,8 @@ export interface RelayOptions {
117122 /** Keeps the Pi at the app's commit (src/pi-deploy.ts); checked every `piEveryMs`. */
118123 piSync?: PiSync;
119124 piEveryMs?: number;
125 /** How often the room is sampled for the Data view and the Pi's health log is read (60 s); tests shorten it. */
126 sampleEveryMs?: number;
120127 host?: string;
121128 port?: number;
122129 /** The simulated arm's HTTP base, e.g. http://127.0.0.1:41234. */
@@ -288,6 +295,7 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> {
288295 recording = false;
289296 }
290297 };
298 const edges = new ArmEdges();
291299 const connect = () => {
292300 armLink?.close();
293301 armLink = null;
@@ -301,6 +309,7 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> {
301309 onStatus: (status) => {
302310 if (!onArm()) return;
303311 machine.status(status);
312 for (const edge of edges.see(status)) lobby?.facts?.event(edge.kind, null, edge.kind === "jam" ? edge.joints.join(",") : null);
304313 void push(); // on to the pages as it comes, at the arm's 25 Hz: no wait for the next tick
305314 },
306315 onMachine: (entries, run) => {
@@ -316,6 +325,7 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> {
316325 if (!baseline.recorded()) void recordBaseline();
317326 }
318327 else console.error(`arm server ${host}: ${state === "offline" ? "offline; simulating" : "reconnecting"} (${armLink?.problem})`);
328 if (previous === "live" && state !== "live") lobby?.facts?.event("reconnect", null, state);
319329 if (previous === "live") for (const c of inFlight) c.abort(new Error(armLink?.problem ?? "the link dropped"));
320330 },
321331 });
@@ -702,6 +712,7 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> {
702712 try {
703713 const { person, token } = lobby.accounts.create((body as { name?: unknown } | null)?.name, visitor(req));
704714 res.setHeader("set-cookie", sessionCookie(token));
715 lobby.facts?.event("named", whoKey(person, null));
705716 return json(res, 200, { you: person });
706717 } catch (err) {
707718 if (err instanceof AccountError) return json(res, err.status, { error: err.message });
@@ -738,6 +749,8 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> {
738749 const cameraAdmin = ADMIN_CAMERA.exec(url.pathname);
739750 if (cameraAdmin) return await administer(req, res, cameraAdmin[1] === undefined ? null : Number(cameraAdmin[1]));
740751 if (lobby && url.pathname === "/admin/lift") return await handleLift(lobby, req, res, readBody);
752 if (url.pathname === "/data") return serveData(lobby ?? undefined, req, res, url);
753 if (lobby && url.pathname === "/admin/data") return serveDataPage(lobby, req, res);
741754 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);
742755 if (url.pathname === "/admin/versions") return versionsRead(req, res);
743756 if (url.pathname === "/precision") return await precisionRead(req, res, url);
@@ -803,6 +816,7 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> {
803816 if (req.method === "GET" && PAGE.test(url.pathname)) return await page(req, res, await ask(simulation + path, req, undefined, null, false));
804817 if (req.method === "GET" && STATIC.test(url.pathname) && acceptsGzip(req.headers)) return await packed(res, await ask(simulation + path, req, undefined, null, false), path);
805818 if (route === "POST /stop") {
819 lobby?.facts?.event("stop");
806820 // Shut before anything is asked of an arm: a frame already on its way finds the gate closed.
807821 gate.hold(by);
808822 epoch = Math.max(epoch + 1, Date.now());
@@ -1084,6 +1098,20 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> {
10841098 if (remote && onArm()) for (const one of ms) armLink?.report(PAGE_HOP, one);
10851099 });
10861100 const turnEnded = lobby?.onTurnEnd((who) => {
1101 const ended = lobby.facts?.lastTurn;
1102 // The arm writes its card when the stream closes (below): ask a moment later. A card that ended more than a minute
1103 // from now (on the Pi's clock) belongs to some other turn, and is not kept.
1104 if (ended && lobby.facts) {
1105 const facts = lobby.facts;
1106 setTimeout(async () => {
1107 try {
1108 const last = readLast(await (await armPrecision(null)).json());
1109 if (last && Math.abs(last.endedAt - Date.now()) < 60_000) facts.armNumbers(ended.id, last.numbers);
1110 } catch {
1111 /* the arm did not answer: the turn keeps its own numbers */
1112 }
1113 }, 1500).unref?.();
1114 }
10871115 for (const [browser, id] of streams) if (id === who.id) browser.close(1000, "your turn ended");
10881116 const base = remote && onArm() ? remote.http : simulation;
10891117 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 +1131,36 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> {
11031131 if (options.piSync) setTimeout(() => void options.piSync!.check(), 3000).unref?.();
11041132 const pusher = lobby ? setInterval(() => void push(), statusEveryMs) : undefined;
11051133 pusher?.unref?.();
1134 // The room every minute for the Data view, the commit the Pi runs when it changes, and the Pi's health log's new lines
1135 // (src/pi-health.ts): as many as the minutes since the last one kept, so a relay that was down fills the gap (up to a day).
1136 const samplePi = async (facts: Facts) => {
1137 if (!remote || mode() !== "live") return;
1138 const last = Math.max(facts.lastSampleAt("pi:temp") ?? 0, facts.lastSampleAt("pi:undervolt") ?? 0);
1139 const n = Math.max(5, Math.min(MAX_LINES, Math.ceil((Date.now() - last) / 60_000) + 1));
1140 try {
1141 const answer = await fetch(`${remote.http}/health/pi?n=${n}`, { cache: "no-store", signal: AbortSignal.timeout(Math.min(readTimeoutMs, 2000)) });
1142 const lines = answer.ok ? readHealthAnswer(await answer.json()) : (await answer.body?.cancel(), null);
1143 if (lines) samplePiHealth(facts, lines);
1144 } catch {
1145 /* the Pi did not answer: the minute has no reading, and the next ask fills it from the log */
1146 }
1147 };
1148 let piSeen: string | null = null;
1149 const sampler = lobby?.facts
1150 ? setInterval(() => {
1151 const facts = lobby.facts!;
1152 const s = lobby.turns.snapshot();
1153 const fps: Record<string, number> = {};
1154 for (const card of videoCards()) if (card.fpsP50 !== null) fps[`fps:${card.camera}`] = card.fpsP50;
1155 facts.sample({ watchers: lobby.visitors.toJSON().here, drivers: s.driver ? 1 : 0, queue: s.queue.length, ...(remote ? { link: mode() === "live" ? 1 : 0 } : {}), ...fps });
1156 const pi = options.piSync?.versions().pi ?? null;
1157 if (pi && pi !== piSeen) facts.event("pi-version", null, pi);
1158 if (pi) piSeen = pi;
1159 void samplePi(facts);
1160 }, options.sampleEveryMs ?? 60_000)
1161 : undefined;
1162 sampler?.unref?.();
1163 lobby?.facts?.restarted();
11061164 const summary = options.summaryEveryMs ? setInterval(() => console.log(metrics.summaryLine()), options.summaryEveryMs) : undefined;
11071165 summary?.unref?.();
11081166 await new Promise<void>((resolve) => http.listen(options.port ?? 8102, options.host ?? "127.0.0.1", resolve));
@@ -1117,6 +1175,7 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> {
11171175 clearInterval(pusher);
11181176 clearInterval(piTimer);
11191177 clearInterval(summary);
1178 clearInterval(sampler);
11201179 settingsChanged?.();
11211180 turnEnded?.();
11221181 readBacks?.();
Software/so101/src/server.ts +13
@@ -42,6 +42,8 @@
4242 * ?hours=1-720 hourly rows (48 by default)
4343 * POST /tuning/p {"joint": id, "p": 1-64} run the P experiment (src/tuning-run.ts) on that joint while nobody drives: sweep, write, sweep, keep or put back; 202 at once,
4444 * 409 while a run is under way or anything drives, 423 on E-stop or hold; the result joins GET /precision's tunings
45 * GET /health/pi the Pi's temperature and under-voltage, minute by minute, from its health log (src/pi-health.ts):
46 * {"lines": [{at, tempC, undervolt, throttled}], "skipped"}, oldest first; ?n=1-1440 lines (60 by default); 404 without the log
4547 * GET /tuning the run's phase while one is under way, and the last outcome
4648 * POST /precision/snap-back {"cause": one of CAUSES, "sizeDeg"}: a snap-back only the page saw, counted beside the server's own (400 for any other cause)
4749 * GET /model.json the arm model: joints, the model's floor (floorZ) and the enforced table (tableZ), where the URDF is served
@@ -87,6 +89,7 @@ import { Metrics } from "./metrics.ts";
8789 import { wallMs } from "./clock.ts";
8890 import { ARRIVE_TICKS, LINK_HOPS, parseSnapBack, READ_BACK_HOP, RELAY_HOP, type Cause, type Tracker } from "./precision.ts";
8991 import { deployedCommit, PrecisionHistory } from "./precision-history.ts";
92 import { HEALTH_LOG, MAX_LINES, parseHealthLog, readLogTail } from "./pi-health.ts";
9093 import { Tuning, TuningRefused } from "./tuning.ts";
9194 import { sweepJoint } from "./tuning-sweep.ts";
9295 import type { SweepResult, TuningDeps } from "./tuning-run.ts";
@@ -127,6 +130,8 @@ export interface ServerOptions {
127130 watchEveryMs?: number;
128131 /** The clock the table touches expire by, ms; Date.now unless a test sets it. */
129132 now?: () => number;
133 /** The Pi's health log (src/pi-health.ts); SO101_HEALTH_LOG or /var/log/so101-health.log when not given. */
134 healthLog?: string;
130135 /** Longest /watch goes without sending an unchanged status, ms (200): keep it inside the relay link's staleMs. */
131136 watchQuietMs?: number;
132137 }
@@ -202,6 +207,7 @@ export async function startServer(bus: FeetechBus, options: ServerOptions = {}):
202207 const calibrationDir = join(dataDir, "calibration");
203208 const settingsFile = join(dataDir, "settings.json");
204209 const factoryFile = join(dataDir, "servo-factory.json");
210 const healthLog = options.healthLog ?? process.env.SO101_HEALTH_LOG ?? HEALTH_LOG;
205211 const streamTorque = options.streamTorque ?? 70;
206212 const jamSampleMs = options.jamSampleMs ?? 100;
207213 const heartbeatMs = options.heartbeatMs ?? 1000;
@@ -885,6 +891,13 @@ export async function startServer(bus: FeetechBus, options: ServerOptions = {}):
885891 return json(res, 200, loadCalibration());
886892 case "GET /metrics":
887893 return json(res, 200, metrics.summary());
894 case "GET /health/pi": {
895 const n = url.searchParams.get("n") ?? "60";
896 if (!/^\d{1,4}$/.test(n) || Number(n) < 1 || Number(n) > MAX_LINES) throw new HttpError(400, `n must be 1-${MAX_LINES}`);
897 const text = readLogTail(healthLog);
898 if (text === null) throw new HttpError(404, "no health log on this Pi");
899 return json(res, 200, parseHealthLog(text, Number(n)));
900 }
888901 case "GET /precision": {
889902 const hours = url.searchParams.get("hours") ?? "48";
890903 if (!/^\d{1,3}$/.test(hours) || Number(hours) < 1 || Number(hours) > 720) throw new HttpError(400, "hours must be 1-720");
Software/so101/src/ui/data-view.js added +467
@@ -0,0 +1,467 @@
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 read = s.piTempC.some(finite) || s.piUndervolt.some(finite);
340 timeChart($("#c-pitemp"), { h: 136, label: "Pi temperature, hottest minute", series: [{ name: "hottest", values: s.piTempC, color: 3, kind: "line", unit: "°C" }, ...prevOf("piTempC", "hottest", 3)], empty: "No temperature in the Pi's health log" });
341 timeChart($("#c-piuv"), { h: 136, label: "Minutes the Pi was short of power", series: [{ name: "under-voltage", values: s.piUndervolt, color: 4, kind: "bar", unit: "min" }], empty: read ? "No under-voltage" : "No reading from the Pi's health log" });
342 const hottest = Math.max(...s.piTempC.filter(finite));
343 const short = sum(s.piUndervolt);
344 $("#pi-note").textContent = read ? `${Number.isFinite(hottest) ? `Hottest ${fmt(hottest, "°C")}` : "No temperature"} · ${short ? `short of power for ${short} min` : "never short of power"} in this range.` : "The Pi logged nothing in this range: the app is simulating, or the arm server has no health log.";
345 const starts = Object.entries(d.starts).map(([k, n]) => `${n} ${k}${n === 1 ? "" : "s"}`).join(", ");
346 $("#starts").textContent = starts ? `App starts in this range: ${starts}.` : "The app did not restart in this range.";
347 }
348
349 function render() {
350 const d = state.data;
351 const range = $("#range");
352 range.textContent = `${new Date(d.from).toLocaleString()} – ${new Date(d.to).toLocaleString()} · per ${duration(d.step * 1000)}`;
353 $("#reset").hidden = !state.custom;
354 overview(d);
355 precision(d);
356 latency(d);
357 people(d);
358 health(d);
359 }
360
361 async function load() {
362 const seq = ++state.seq;
363 const now = Date.now();
364 const span = state.custom ? null : RANGES[state.range];
365 const to = state.custom ? state.custom.to : now;
366 const from = state.custom ? state.custom.from : now - span;
367 document.body.classList.add("loading");
368 $("#status").textContent = "";
369 try {
370 const r = await fetch(`/data?from=${Math.floor(from)}&to=${Math.ceil(to)}${state.compare ? "&compare=1" : ""}`, { credentials: "same-origin", cache: "no-store" });
371 const body = await r.json();
372 if (seq !== state.seq) return;
373 if (!r.ok) throw new Error(body && body.error ? body.error : `the app answered ${r.status}`);
374 state.data = body;
375 render();
376 } catch (err) {
377 if (seq === state.seq) $("#status").textContent = `Could not load: ${err.message}`;
378 } finally {
379 if (seq === state.seq) document.body.classList.remove("loading");
380 }
381 }
382
383 async function turn(id) {
384 const dialog = $("#turn");
385 const body = $("#turn-body");
386 body.replaceChildren(html("p", "empty", "Loading…"));
387 dialog.showModal();
388 try {
389 const r = await fetch(`/data?turn=${encodeURIComponent(id)}`, { credentials: "same-origin", cache: "no-store" });
390 const d = await r.json();
391 if (!r.ok) throw new Error(d.error || r.status);
392 const t = d.turn;
393 body.replaceChildren();
394 html("h2", null, `${t.name || "someone"} · ${new Date(t.start).toLocaleString()}`, body).id = "turn-title";
395 const facts = html("dl", "facts", null, body);
396 const pair = (k, v) => {
397 html("dt", null, k, facts);
398 html("dd", null, v, facts);
399 };
400 pair("Length", duration(t.ms));
401 pair("Ended", t.reason);
402 pair("Waited", finite(t.waitMs) ? duration(t.waitMs) : "no wait");
403 pair("Releases", fmt(t.releases));
404 pair("Snap-backs", fmt(t.snapBacks));
405 pair("p95 settle error", fmt(t.trackingP95Deg, "°"));
406 pair("p95 time to arrive", fmt(t.arriveP95Ms, "ms"));
407 pair("p95 command → motion", fmt(t.latencyP95Ms, "ms"));
408 pair("Commit", t.commit.slice(0, 12));
409 if (t.byCause) pair("By cause", Object.entries(t.byCause).filter(([, n]) => n > 0).map(([c, n]) => `${c} ${n}`).join(", ") || "none");
410 // the events on the turn's timeline
411 html("h3", null, "Timeline", body);
412 const span = t.ms + 120_000, t0 = t.start - 60_000;
413 const W = width(body);
414 const root = svg("svg", { viewBox: `0 0 ${W} 60`, width: W, height: 60, class: "timeline", role: "img", "aria-label": "Events during the turn" }, body);
415 const x = (ms) => L + ((ms - t0) / span) * (W - L - R);
416 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);
417 for (const e of d.events) {
418 const m = svg("line", { x1: x(e.at), x2: x(e.at), y1: 8, y2: 44, class: `mark ${e.kind}` }, root);
419 svg("title", {}, m).textContent = `${e.kind}${e.detail ? ` ${e.detail}` : ""} at ${new Date(e.at).toLocaleTimeString()}`;
420 }
421 svg("text", { x: L, y: 56, class: "tick" }, root).textContent = new Date(t0).toLocaleTimeString();
422 svg("text", { x: W - R, y: 56, class: "tick", "text-anchor": "end" }, root).textContent = new Date(t0 + span).toLocaleTimeString();
423 const list = html("ul", "events", null, body);
424 if (!d.events.length) html("li", "empty", "No STOP, jam, block or reconnect.", list);
425 for (const e of d.events) html("li", null, `${new Date(e.at).toLocaleTimeString()} · ${e.kind}${e.detail ? ` (${e.detail})` : ""}`, list);
426 if (t.hops && Object.keys(t.hops).length) {
427 html("h3", null, "Latency by hop", body);
428 const hl = html("ul", "events", null, body);
429 for (const [h, v] of Object.entries(t.hops)) html("li", null, `${h}: p50 ${fmt(v.p50, "ms")}, p95 ${fmt(v.p95, "ms")}`, hl);
430 }
431 if (t.joints && Object.keys(t.joints).length) {
432 html("h3", null, "Joints", body);
433 const jl = html("ul", "events", null, body);
434 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);
435 }
436 } catch (err) {
437 body.replaceChildren(html("p", "empty", `Could not load that turn: ${err.message}`));
438 }
439 }
440
441 for (const b of document.querySelectorAll("[data-range]")) {
442 b.addEventListener("click", () => {
443 state.range = b.dataset.range;
444 state.custom = null;
445 for (const o of document.querySelectorAll("[data-range]")) o.setAttribute("aria-pressed", String(o === b));
446 load();
447 });
448 }
449 $("#compare").addEventListener("change", (ev) => {
450 state.compare = ev.target.checked;
451 load();
452 });
453 $("#reset").addEventListener("click", () => {
454 state.custom = null;
455 load();
456 });
457 $("#turn-close").addEventListener("click", () => $("#turn").close());
458 // a new width (a phone turned, a window resized) draws again at it
459 let wide = document.documentElement.clientWidth, redraw = 0;
460 addEventListener("resize", () => {
461 if (document.documentElement.clientWidth === wide) return;
462 wide = document.documentElement.clientWidth;
463 clearTimeout(redraw);
464 redraw = setTimeout(() => state.data && render(), 150);
465 });
466 load();
467 })();
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