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
quill/arm:quill/520-data-view-pi-temperature-and-under-volta → main 3 commits fork
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 | ||
| 48 | 48 | 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. |
| 49 | 49 | |
| 50 | 50 | 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 @@ | ||
| 11 | 11 | * (src/accounts.ts, kept in accounts.sqlite); the forge signs who its own signed-in people are with FORGE_APP_IDENTITY_KEY. |
| 12 | 12 | * Run on one's own machine, away from the forge and with no key, every visitor is the owner. |
| 13 | 13 | * 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). | |
| 14 | 15 | */ |
| 15 | 16 | import { mkdirSync, mkdtempSync } from "node:fs"; |
| 16 | 17 | import { tmpdir } from "node:os"; |
| @@ -21,6 +22,8 @@ import { FakeBus } from "../src/fake-bus.ts"; | ||
| 21 | 22 | import { CameraHub } from "../src/cameras.ts"; |
| 22 | 23 | import { LeaderCalibrations } from "../src/leader-calibrations.ts"; |
| 23 | 24 | import { Lobby } from "../src/lobby.ts"; |
| 25 | import { Facts } from "../src/facts.ts"; | |
| 26 | import { deployedCommit } from "../src/precision-history.ts"; | |
| 24 | 27 | import { Moderator, modelFromEnv } from "../src/moderator.ts"; |
| 25 | 28 | import { parseRemote, parseVideo, startRelay, type Remote } from "../src/relay.ts"; |
| 26 | 29 | import { piSyncFromEnv } from "../src/pi-deploy.ts"; |
| @@ -59,6 +62,7 @@ const lobby = new Lobby({ | ||
| 59 | 62 | reportsFile: join(dir, "reports.json"), |
| 60 | 63 | accounts: new Accounts({ file: join(dir, "accounts.sqlite") }), |
| 61 | 64 | moderator: new Moderator({ file: join(dir, "crew.json"), model: modelFromEnv(process.env) }), |
| 65 | facts: new Facts({ file: join(dir, "facts.sqlite"), commit: deployedCommit() }), | |
| 62 | 66 | }); |
| 63 | 67 | const cameras = new CameraHub({ file: join(dir, "cameras.json"), seed: remote ? [{ name: "Camera 1", source: remote.cameras[0]! }] : [] }); |
| 64 | 68 |
Software/so101/src/admin-page.ts +1 −1
| @@ -320,7 +320,7 @@ export function renderAdmin(p: AdminPage): string { | ||
| 320 | 320 | "Admin · SO-101", |
| 321 | 321 | `<header> |
| 322 | 322 | <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> | |
| 324 | 324 | </header> |
| 325 | 325 | ${flash} |
| 326 | 326 | <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 | = new Set<number>(); | |
| 10 | = 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..has(j)); | |
| 19 | if (fresh.length) out.push({ kind: "jam", joints: fresh }); | |
| 20 | if (blocked && !this.) out.push({ kind: "block" }); | |
| 21 | this. = jammed; | |
| 22 | this. = 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; | |
| 126 | readonly : () => number; | |
| 127 | /** Who asked for a turn and when: their wait, when the turn comes. */ | |
| 128 | readonly = new Map<string, number>(); | |
| 129 | /** The turn being driven: who, since when, and how long they waited. */ | |
| 130 | : { who: string; name: string | null; start: number; waitMs: number | null } | null = null; | |
| 131 | = 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. = options.now ?? Date.now; | |
| 138 | this. = openDb(options.file ?? ":memory:"); | |
| 139 | this..run("PRAGMA journal_mode = WAL"); | |
| 140 | this..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..run("CREATE INDEX IF NOT EXISTS turns_start ON turns (start)"); | |
| 143 | this..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..run("CREATE INDEX IF NOT EXISTS events_at ON events (at)"); | |
| 145 | this..run("CREATE TABLE IF NOT EXISTS samples (at INTEGER NOT NULL, metric TEXT NOT NULL, value REAL NOT NULL)"); | |
| 146 | this..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..query("INSERT INTO events (at, kind, who, detail, commit_id) VALUES (?, ?, ?, ?, ?)").run(this.(), 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..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.(), 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..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.() - this. > DAY) this.trim(); | |
| 172 | } | |
| 173 | ||
| 174 | /** When a metric was last kept; null when never. */ | |
| 175 | lastSampleAt(metric: Metric): number | null { | |
| 176 | return (this..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..has(who)) this..set(who, this.()); | |
| 182 | this.event("queued", who); | |
| 183 | } | |
| 184 | ||
| 185 | /** Someone left the line without a turn. */ | |
| 186 | dequeued(who: string): void { | |
| 187 | this..delete(who); | |
| 188 | } | |
| 189 | ||
| 190 | turnStarted(who: string, name: string | null): void { | |
| 191 | const start = this.(); | |
| 192 | const since = this..get(who); | |
| 193 | this..delete(who); | |
| 194 | this. = { 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.; | |
| 200 | if (!open || open.who !== who) return null; | |
| 201 | this. = null; | |
| 202 | const ms = this.() - open.start; | |
| 203 | const row = this..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..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..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..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..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..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..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.(); | |
| 241 | const cut = now - KEEP_DAYS * DAY; | |
| 242 | this..query("DELETE FROM turns WHERE start < ?").run(cut); | |
| 243 | this..query("DELETE FROM events WHERE at < ?").run(cut); | |
| 244 | this..query("DELETE FROM samples WHERE at < ?").run(cut); | |
| 245 | this. = now; | |
| 246 | } | |
| 247 | ||
| 248 | close(): void { | |
| 249 | this..close(); | |
| 250 | } | |
| 251 | } |
Software/so101/src/lobby.ts +18 −2
| @@ -57,6 +57,7 @@ import { Reports } from "./reports.ts"; | ||
| 57 | 57 | import { WordFilter } from "./word-filter.ts"; |
| 58 | 58 | import { identify, LOCAL, type Person, type Who } from "./identity.ts"; |
| 59 | 59 | import { VisitorCounts } from "./visitor-counts.ts"; |
| 60 | import { whoKey, type Facts } from "./facts.ts"; | |
| 60 | 61 | import { Turns, type TurnEvent } from "./turns.ts"; |
| 61 | 62 | import { parseReport, ViewerReports } from "./viewer-reports.ts"; |
| 62 | 63 | import { wallMs } from "./clock.ts"; |
| @@ -94,6 +95,8 @@ export interface LobbyOptions { | ||
| 94 | 95 | admin?: Admin; |
| 95 | 96 | /** Checks each posted line in the background; none leaves the chat unchecked. */ |
| 96 | 97 | moderator?: Moderator; |
| 98 | /** Where visits, turns, the line and chat volume are kept for the Data view; none keeps nothing. */ | |
| 99 | facts?: Facts; | |
| 97 | 100 | /** How often each socket is pinged, and how long it may stay silent before it counts as dropped. */ |
| 98 | 101 | pingMs?: number; |
| 99 | 102 | silentMs?: number; |
| @@ -143,6 +146,7 @@ export class Lobby { | ||
| 143 | 146 | private countsSaved = 0; |
| 144 | 147 | private countsTimer: ReturnType<typeof setTimeout> | undefined; |
| 145 | 148 | readonly moderator: Moderator | undefined; |
| 149 | readonly facts: Facts | undefined; | |
| 146 | 150 | private readonly key: string; |
| 147 | 151 | private readonly wss = new WebSocketServer({ noServer: true, maxPayload: MAX_FRAME }); |
| 148 | 152 | private readonly seats = new Set<Seat>(); |
| @@ -186,6 +190,7 @@ export class Lobby { | ||
| 186 | 190 | this.lineReports = new Reports(options.reportsFile); |
| 187 | 191 | this.silentMs = options.silentMs ?? 15_000; |
| 188 | 192 | this.moderator = options.moderator; |
| 193 | this.facts = options.facts; | |
| 189 | 194 | if (this.moderator) { |
| 190 | 195 | this.moderator.attach({ |
| 191 | 196 | onHide: (message) => { |
| @@ -357,6 +362,7 @@ export class Lobby { | ||
| 357 | 362 | if (!who) seat.guest = this.claim(id); |
| 358 | 363 | this.seats.add(seat); |
| 359 | 364 | this.visitors.joined(); |
| 365 | this.facts?.event("visit", whoKey(who, seat.guest?.id ?? null)); | |
| 360 | 366 | this.countsChanged(); |
| 361 | 367 | this.presenceChanged(); |
| 362 | 368 | if (who) this.turns.connect(who.id); |
| @@ -468,12 +474,19 @@ export class Lobby { | ||
| 468 | 474 | return this.send(seat, { type: "history", ...this.page(m.before as number, HISTORY_PAGE) }); |
| 469 | 475 | } |
| 470 | 476 | 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 | } | |
| 472 | 481 | if (this.barred(who, seat.from)) return this.send(seat, { type: "error", error: "you are banned: you can still watch" }); |
| 473 | 482 | switch (m.type) { |
| 474 | 483 | 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)!); | |
| 476 | 488 | return; |
| 489 | } | |
| 477 | 490 | case "ready": |
| 478 | 491 | this.turns.ready(who.id); |
| 479 | 492 | return; |
| @@ -488,6 +501,7 @@ export class Lobby { | ||
| 488 | 501 | if (held > 0) throw new ChatError(`slow mode: wait ${Math.ceil(held / 1000)}s`); |
| 489 | 502 | const said = this.chat.post(who, m.text); |
| 490 | 503 | this.lastPosted.set(who.id, this.now()); |
| 504 | this.facts?.event("chat", whoKey(who, null)); | |
| 491 | 505 | this.broadcast({ type: "said", message: this.marked(said) }); |
| 492 | 506 | void this.moderator?.check(said); |
| 493 | 507 | return; |
| @@ -704,6 +718,8 @@ export class Lobby { | ||
| 704 | 718 | private turnChanged(event: TurnEvent): void { |
| 705 | 719 | if (event.kind === "turn") { |
| 706 | 720 | 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); | |
| 707 | 723 | if (ended) for (const listener of this.endListeners) listener(ended.who); |
| 708 | 724 | const parts = [ended ? `${ended.who.name}'s turn ended (${ended.reason})` : null, started ? `${started.name} has control` : ended ? "nobody has control" : null].filter(Boolean); |
| 709 | 725 | 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 { | ||
| 61 | 61 | session: Card | null; |
| 62 | 62 | /** The last ten minutes, sessions pooled. */ |
| 63 | 63 | 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; | |
| 64 | 66 | /** The last stored sessions and hours, oldest first: the trend across releases. */ |
| 65 | 67 | sessions: Row[]; |
| 66 | 68 | hours: Row[]; |
| @@ -77,6 +79,7 @@ export class PrecisionHistory { | ||
| 77 | 79 | : Tracker | null = null; |
| 78 | 80 | /** The card of the drive session that ended last: how a sweep reads its own numbers. */ |
| 79 | 81 | last: Card | null = null; |
| 82 | : number | null = null; | |
| 80 | 83 | |
| 81 | 84 | constructor(options: { file?: string; commit?: string; now?: () => number } = {}) { |
| 82 | 85 | this.commit = options.commit ?? deployedCommit(); |
| @@ -119,6 +122,7 @@ export class PrecisionHistory { | ||
| 119 | 122 | if (this. === tracker) this. = null; |
| 120 | 123 | const card = summarize(tracker.records); |
| 121 | 124 | this.last = card; |
| 125 | this. = at; | |
| 122 | 126 | const measured = card.joints.some((j) => j.errorTicks.n > 0) || card.snapBacks.total > 0; |
| 123 | 127 | if (!measured) return card; |
| 124 | 128 | const seconds = Number(((at - tracker.startedAt) / 1000).toFixed(1)); |
| @@ -171,6 +175,7 @@ export class PrecisionHistory { | ||
| 171 | 175 | commit: this.commit, |
| 172 | 176 | tunings: this.tunings(), |
| 173 | 177 | session: this. ? summarize(this..records) : null, |
| 178 | last: this.last && this. !== null ? { endedAt: this., card: this.last } : null, | |
| 174 | 179 | window: summarize(this., now - WINDOW_MS), |
| 175 | 180 | sessions: this.rows("sessions"), |
| 176 | 181 | hours: this.rows("hours", hours), |
Software/so101/src/relay.ts +59
| @@ -89,6 +89,11 @@ import { LeaderCalibrations } from "./leader-calibrations.ts"; | ||
| 89 | 89 | import { proxyFor, whyNot } from "./egress.ts"; |
| 90 | 90 | import { CalibrationError } from "./leader-map.js"; |
| 91 | 91 | 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"; | |
| 92 | 97 | import { capBody, FrameCap } from "./speed-cap.ts"; |
| 93 | 98 | import { Clock, CommandGate, HELD_CLOSE, isRelease, parseCommand } from "./command-gate.ts"; |
| 94 | 99 | import { FLOOD_CLOSE, FrameRate, frameBytes, MAX_FRAME_BYTES, TOO_BIG_CLOSE } from "./frame-limit.ts"; |
| @@ -117,6 +122,8 @@ export interface RelayOptions { | ||
| 117 | 122 | /** Keeps the Pi at the app's commit (src/pi-deploy.ts); checked every `piEveryMs`. */ |
| 118 | 123 | piSync?: PiSync; |
| 119 | 124 | 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; | |
| 120 | 127 | host?: string; |
| 121 | 128 | port?: number; |
| 122 | 129 | /** 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> { | ||
| 288 | 295 | recording = false; |
| 289 | 296 | } |
| 290 | 297 | }; |
| 298 | const edges = new ArmEdges(); | |
| 291 | 299 | const connect = () => { |
| 292 | 300 | armLink?.close(); |
| 293 | 301 | armLink = null; |
| @@ -301,6 +309,7 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> { | ||
| 301 | 309 | onStatus: (status) => { |
| 302 | 310 | if (!onArm()) return; |
| 303 | 311 | machine.status(status); |
| 312 | for (const edge of edges.see(status)) lobby?.facts?.event(edge.kind, null, edge.kind === "jam" ? edge.joints.join(",") : null); | |
| 304 | 313 | void push(); // on to the pages as it comes, at the arm's 25 Hz: no wait for the next tick |
| 305 | 314 | }, |
| 306 | 315 | onMachine: (entries, run) => { |
| @@ -316,6 +325,7 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> { | ||
| 316 | 325 | if (!baseline.recorded()) void recordBaseline(); |
| 317 | 326 | } |
| 318 | 327 | 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); | |
| 319 | 329 | if (previous === "live") for (const c of inFlight) c.abort(new Error(armLink?.problem ?? "the link dropped")); |
| 320 | 330 | }, |
| 321 | 331 | }); |
| @@ -702,6 +712,7 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> { | ||
| 702 | 712 | try { |
| 703 | 713 | const { person, token } = lobby.accounts.create((body as { name?: unknown } | null)?.name, visitor(req)); |
| 704 | 714 | res.setHeader("set-cookie", sessionCookie(token)); |
| 715 | lobby.facts?.event("named", whoKey(person, null)); | |
| 705 | 716 | return json(res, 200, { you: person }); |
| 706 | 717 | } catch (err) { |
| 707 | 718 | if (err instanceof AccountError) return json(res, err.status, { error: err.message }); |
| @@ -738,6 +749,8 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> { | ||
| 738 | 749 | const cameraAdmin = ADMIN_CAMERA.exec(url.pathname); |
| 739 | 750 | if (cameraAdmin) return await administer(req, res, cameraAdmin[1] === undefined ? null : Number(cameraAdmin[1])); |
| 740 | 751 | 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); | |
| 741 | 754 | 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); |
| 742 | 755 | if (url.pathname === "/admin/versions") return versionsRead(req, res); |
| 743 | 756 | if (url.pathname === "/precision") return await precisionRead(req, res, url); |
| @@ -803,6 +816,7 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> { | ||
| 803 | 816 | if (req.method === "GET" && PAGE.test(url.pathname)) return await page(req, res, await ask(simulation + path, req, undefined, null, false)); |
| 804 | 817 | if (req.method === "GET" && STATIC.test(url.pathname) && acceptsGzip(req.headers)) return await packed(res, await ask(simulation + path, req, undefined, null, false), path); |
| 805 | 818 | if (route === "POST /stop") { |
| 819 | lobby?.facts?.event("stop"); | |
| 806 | 820 | // Shut before anything is asked of an arm: a frame already on its way finds the gate closed. |
| 807 | 821 | gate.hold(by); |
| 808 | 822 | epoch = Math.max(epoch + 1, Date.now()); |
| @@ -1084,6 +1098,20 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> { | ||
| 1084 | 1098 | if (remote && onArm()) for (const one of ms) armLink?.report(PAGE_HOP, one); |
| 1085 | 1099 | }); |
| 1086 | 1100 | 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 | } | |
| 1087 | 1115 | for (const [browser, id] of streams) if (id === who.id) browser.close(1000, "your turn ended"); |
| 1088 | 1116 | const base = remote && onArm() ? remote.http : simulation; |
| 1089 | 1117 | 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> { | ||
| 1103 | 1131 | if (options.piSync) setTimeout(() => void options.piSync!.check(), 3000).unref?.(); |
| 1104 | 1132 | const pusher = lobby ? setInterval(() => void push(), statusEveryMs) : undefined; |
| 1105 | 1133 | 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(); | |
| 1106 | 1164 | const summary = options.summaryEveryMs ? setInterval(() => console.log(metrics.summaryLine()), options.summaryEveryMs) : undefined; |
| 1107 | 1165 | summary?.unref?.(); |
| 1108 | 1166 | 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> { | ||
| 1117 | 1175 | clearInterval(pusher); |
| 1118 | 1176 | clearInterval(piTimer); |
| 1119 | 1177 | clearInterval(summary); |
| 1178 | clearInterval(sampler); | |
| 1120 | 1179 | settingsChanged?.(); |
| 1121 | 1180 | turnEnded?.(); |
| 1122 | 1181 | readBacks?.(); |
Software/so101/src/server.ts +13
| @@ -42,6 +42,8 @@ | ||
| 42 | 42 | * ?hours=1-720 hourly rows (48 by default) |
| 43 | 43 | * 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, |
| 44 | 44 | * 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 | |
| 45 | 47 | * GET /tuning the run's phase while one is under way, and the last outcome |
| 46 | 48 | * 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) |
| 47 | 49 | * 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"; | ||
| 87 | 89 | import { wallMs } from "./clock.ts"; |
| 88 | 90 | import { ARRIVE_TICKS, LINK_HOPS, parseSnapBack, READ_BACK_HOP, RELAY_HOP, type Cause, type Tracker } from "./precision.ts"; |
| 89 | 91 | import { deployedCommit, PrecisionHistory } from "./precision-history.ts"; |
| 92 | import { HEALTH_LOG, MAX_LINES, parseHealthLog, readLogTail } from "./pi-health.ts"; | |
| 90 | 93 | import { Tuning, TuningRefused } from "./tuning.ts"; |
| 91 | 94 | import { sweepJoint } from "./tuning-sweep.ts"; |
| 92 | 95 | import type { SweepResult, TuningDeps } from "./tuning-run.ts"; |
| @@ -127,6 +130,8 @@ export interface ServerOptions { | ||
| 127 | 130 | watchEveryMs?: number; |
| 128 | 131 | /** The clock the table touches expire by, ms; Date.now unless a test sets it. */ |
| 129 | 132 | 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; | |
| 130 | 135 | /** Longest /watch goes without sending an unchanged status, ms (200): keep it inside the relay link's staleMs. */ |
| 131 | 136 | watchQuietMs?: number; |
| 132 | 137 | } |
| @@ -202,6 +207,7 @@ export async function startServer(bus: FeetechBus, options: ServerOptions = {}): | ||
| 202 | 207 | const calibrationDir = join(dataDir, "calibration"); |
| 203 | 208 | const settingsFile = join(dataDir, "settings.json"); |
| 204 | 209 | const factoryFile = join(dataDir, "servo-factory.json"); |
| 210 | const healthLog = options.healthLog ?? process.env.SO101_HEALTH_LOG ?? HEALTH_LOG; | |
| 205 | 211 | const streamTorque = options.streamTorque ?? 70; |
| 206 | 212 | const jamSampleMs = options.jamSampleMs ?? 100; |
| 207 | 213 | const heartbeatMs = options.heartbeatMs ?? 1000; |
| @@ -885,6 +891,13 @@ export async function startServer(bus: FeetechBus, options: ServerOptions = {}): | ||
| 885 | 891 | return json(res, 200, loadCalibration()); |
| 886 | 892 | case "GET /metrics": |
| 887 | 893 | 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 | } | |
| 888 | 901 | case "GET /precision": { |
| 889 | 902 | const hours = url.searchParams.get("hours") ?? "48"; |
| 890 | 903 | 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 @@ | ||
| 356 | 356 | <div class="card" id="modCard"> |
| 357 | 357 | <h3>Room</h3> |
| 358 | 358 | <div class="row" id="modTurn"></div> |
| 359 | <p><a href="/admin/data">Data</a>: people, precision, latency and health over time.</p> | |
| 359 | 360 | <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> |
| 360 | 361 | <div id="mutedLine"></div> |
| 361 | 362 | <div id="bannedLine"></div> |
new above
offline