Public: anyone can read this; the crew can change it.
Migrated from github.com/Trevato/SO-ARM100
workbenchthis machine
A Data view to explore the arm: people, precision, latency and health, over time, on any screen #519
quill/arm:quill/508-a-data-view-to-explore-the-arm-people-pr → main 2 commits fork
17 files
changed +1910
−3
Software/so101/README.md +13
| @@ -48,3 +48,16 @@ The owner can test a joint's P (the position gain, 1–64) on the real arm, with | ||
| 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). Choose Hour, Day, Week or All, drag across any chart to zoom (every chart follows), tick "Compare to the period before" for a faint line of the previous span, and hover, tap or arrow-key a chart for exact values. Dashed upright lines mark a deployed commit (app grey, Pi violet). It is a page of its own, so nothing of it loads with the camera, and its only script is inline, pinned by hash in its Content-Security-Policy. The charts are SVG drawn by hand (`src/ui/data-view.js`): no dependency was added. | |
| 55 | ||
| 56 | What is kept, in `facts.sqlite` under `FORGE_APP_DATA` (`src/facts.ts`), 90 days and then dropped: | |
| 57 | - **turns**: one row per turn: who (a user id, or a 16-hex hash of an anonymous browser's id; never an address), start, length, why it ended, the wait in line, the app's commit, and, when the arm answers `/precision` just after the turn, its releases, snap-backs by cause, jams, p95 arrive and tracking, and per-hop and per-joint p50/p95. | |
| 58 | - **events**: visits, names set, joining the line, chat lines (counted, not their text), STOP, jams and zone blocks as they begin, link reconnects, app starts (`deploy` when the commit changed, else `restart`) and the Pi's commit when it changes. | |
| 59 | - **samples**, every minute: watchers, drivers, queue length, the arm link live or not, each camera's fps. | |
| 60 | ||
| 61 | `GET /data?from=<ms>&to=<ms>&step=<s>[&compare=1]` is the view's one read (anyone else: 403). `step` is one of 60, 300, 900, 1800, 3600, 10800, 21600, 86400 seconds (at most 400 steps; left out, about 96). `GET /data?turn=<id>` is one turn with the arm's events around it. How each number is made (sums before ratios; a step's p95 is the p95 of its turns' p95s, so one bad turn shows) is written at the top of `src/data-series.ts`. | |
| 62 | ||
| 63 | Not yet shown: Pi temperature and under-voltage (the Pi logs them in `/var/log/so101-health.log`, but the arm server does not serve them yet), and per-turn joint traces of intent against actual (the arm keeps only each turn's summary). |
Software/so101/bench/fake-serve.ts +4
| @@ -11,6 +11,7 @@ | ||
| 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 +198
| @@ -0,0 +1,198 @@ | ||
| 1 | /** | |
| 2 | * The Data view, /admin/data: the owner's and the moderators' place to explore how the arm is doing. A page of its own, | |
| 3 | * so nothing of it loads with the camera and it never covers it. The markup is fixed (no person's text in it); the | |
| 4 | * charts are src/ui/data-view.js, drawn from GET /data. Style and script are inline, pinned by their hashes in the | |
| 5 | * page's Content-Security-Policy, which allows nothing else. To anyone else there is no such page (404, as /admin). | |
| 6 | */ | |
| 7 | ||
| 8 | import { createHash } from "node:crypto"; | |
| 9 | import { readFileSync } from "node:fs"; | |
| 10 | import type { IncomingMessage, ServerResponse } from "node:http"; | |
| 11 | import type { Lobby } from "./lobby.ts"; | |
| 12 | ||
| 13 | export const DATA_SCRIPT = readFileSync(new URL("./ui/data-view.js", import.meta.url), "utf8"); | |
| 14 | ||
| 15 | export const DATA_STYLE = ` | |
| 16 | :root { color-scheme: dark light; --bg: #0b0d10; --panel: #11161d; --fg: #e8eaed; --dim: #9aa4ae; --line: #2a313a; --accent: #58a6ff; --good: #3fb950; --bad: #f85149; | |
| 17 | --c0: #58a6ff; --c1: #3fb950; --c2: #d2a8ff; --c3: #f0883e; --c4: #f85149; --c5: #e3b341; --c6: #8b949e; --c7: #39c5cf; } | |
| 18 | @media (prefers-color-scheme: light) { | |
| 19 | :root { --bg: #f6f7f9; --panel: #fff; --fg: #1f2328; --dim: #59636e; --line: #d8dee4; --accent: #0969da; --good: #1a7f37; --bad: #cf222e; | |
| 20 | --c0: #0969da; --c1: #1a7f37; --c2: #8250df; --c3: #bc4c00; --c4: #cf222e; --c5: #9a6700; --c6: #6e7781; --c7: #1b7c83; } | |
| 21 | } | |
| 22 | * { box-sizing: border-box; } | |
| 23 | body { margin: 0; background: var(--bg); color: var(--fg); font: 15px/1.45 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; font-variant-numeric: tabular-nums; } | |
| 24 | main { max-width: 110rem; margin: 0 auto; padding: max(1rem, env(safe-area-inset-top)) max(.75rem, env(safe-area-inset-right)) 3rem max(.75rem, env(safe-area-inset-left)); display: grid; gap: 1rem; min-width: 0; } | |
| 25 | header { display: grid; gap: .6rem; } | |
| 26 | .title { display: flex; flex-wrap: wrap; align-items: baseline; gap: .25rem 1rem; } | |
| 27 | h1 { font-size: clamp(1.5rem, 4vw, 2rem); line-height: 1.1; margin: 0; letter-spacing: -.01em; } | |
| 28 | .title nav { display: flex; gap: 1rem; font-size: .9rem; } | |
| 29 | a { color: var(--accent); } | |
| 30 | .controls { display: flex; flex-wrap: wrap; align-items: center; gap: .5rem .9rem; } | |
| 31 | .seg { display: inline-flex; border: 1px solid var(--line); border-radius: 8px; overflow: hidden; } | |
| 32 | .seg button { border: 0; border-radius: 0; border-right: 1px solid var(--line); } | |
| 33 | .seg button:last-child { border-right: 0; } | |
| 34 | button { font: inherit; font-weight: 600; min-height: 40px; padding: .35rem .85rem; border-radius: 8px; border: 1px solid var(--line); background: var(--panel); color: var(--fg); cursor: pointer; } | |
| 35 | button[aria-pressed="true"] { background: var(--accent); color: #fff; } | |
| 36 | button:focus-visible, input:focus-visible, figure:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } | |
| 37 | button.link { border: 0; background: none; color: var(--accent); padding: 0; min-height: 32px; font-weight: 500; text-align: left; } | |
| 38 | label.toggle { display: inline-flex; align-items: center; gap: .4rem; min-height: 40px; } | |
| 39 | label.toggle input { width: 1.1rem; height: 1.1rem; accent-color: var(--accent); } | |
| 40 | .range, .status, .live, .note { color: var(--dim); font-size: .85rem; margin: 0; } | |
| 41 | .status:empty { display: none; } | |
| 42 | .status { color: var(--bad); } | |
| 43 | body.loading .panels, body.loading .strip { opacity: .6; } | |
| 44 | .strip { display: grid; grid-auto-flow: column; grid-auto-columns: minmax(11rem, 72%); gap: .6rem; overflow-x: auto; scroll-snap-type: x mandatory; padding-bottom: .25rem; overscroll-behavior-x: contain; } | |
| 45 | .stat { scroll-snap-align: start; background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: .7rem .8rem .5rem; display: grid; gap: .1rem; min-width: 0; min-height: 8.5rem; } | |
| 46 | .stat h3 { margin: 0; font-size: .75rem; font-weight: 600; letter-spacing: .04em; text-transform: uppercase; color: var(--dim); } | |
| 47 | .stat .big { margin: 0; font-size: 1.7rem; font-weight: 650; line-height: 1.15; } | |
| 48 | .stat .delta { margin: 0; font-size: .8rem; color: var(--dim); min-height: 1.2em; } | |
| 49 | .delta.good { color: var(--good); } | |
| 50 | .delta.bad { color: var(--bad); } | |
| 51 | .spark { width: 100%; height: 24px; display: block; } | |
| 52 | .panels { display: grid; grid-template-columns: minmax(0, 1fr); gap: 1rem; grid-auto-flow: row dense; } | |
| 53 | .panel { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: .85rem; min-width: 0; display: grid; gap: .6rem; align-content: start; } | |
| 54 | .panel h2 { margin: 0; font-size: .8rem; font-weight: 650; letter-spacing: .08em; text-transform: uppercase; color: var(--dim); } | |
| 55 | .panel h3 { margin: .2rem 0 0; font-size: .95rem; font-weight: 600; } | |
| 56 | .multiples { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 15rem), 1fr)); gap: .75rem; } | |
| 57 | .joint h4 { margin: 0 0 .2rem; font-size: .85rem; font-weight: 600; text-transform: capitalize; } | |
| 58 | .plot { min-height: 196px; min-width: 0; } | |
| 59 | .plot.small { min-height: 136px; } | |
| 60 | .chart { margin: 0; position: relative; min-width: 0; } | |
| 61 | .chart svg { display: block; max-width: 100%; height: auto; overflow: visible; touch-action: pan-y; } | |
| 62 | .legend { display: flex; flex-wrap: wrap; gap: .1rem .8rem; font-size: .75rem; color: var(--dim); min-height: 1.1rem; } | |
| 63 | .key, .row { display: inline-flex; align-items: center; gap: .3rem; } | |
| 64 | .key i, .row i { width: .6rem; height: .6rem; border-radius: 2px; background: var(--k); display: inline-block; } | |
| 65 | .grid { stroke: var(--line); stroke-width: 1; } | |
| 66 | .tick, .vlabel { fill: var(--dim); font-size: 11px; } | |
| 67 | .vlabel { font-size: 10px; } | |
| 68 | .val { fill: var(--fg); font-size: 11px; } | |
| 69 | .none { fill: var(--dim); font-size: 13px; } | |
| 70 | .line { fill: none; stroke: var(--k); stroke-width: 2; stroke-linejoin: round; stroke-linecap: round; } | |
| 71 | .line.dashed { stroke-dasharray: 5 4; } | |
| 72 | .line.prev { stroke-width: 1.5; stroke-dasharray: 2 3; opacity: .55; } | |
| 73 | .spark .line { stroke-width: 1.6; vector-effect: non-scaling-stroke; } | |
| 74 | .fill { fill: var(--k); } | |
| 75 | .band { fill: var(--k); opacity: .18; } | |
| 76 | .dot { fill: var(--k); } | |
| 77 | .version { stroke: var(--dim); stroke-dasharray: 3 3; stroke-width: 1; } | |
| 78 | .version.pi { stroke: var(--c2); } | |
| 79 | .cursor { stroke: var(--fg); stroke-width: 1; opacity: .5; } | |
| 80 | .brush { fill: var(--accent); opacity: .15; } | |
| 81 | .c0 { --k: var(--c0); } .c1 { --k: var(--c1); } .c2 { --k: var(--c2); } .c3 { --k: var(--c3); } | |
| 82 | .c4 { --k: var(--c4); } .c5 { --k: var(--c5); } .c6 { --k: var(--c6); } .c7 { --k: var(--c7); } | |
| 83 | .tip { position: absolute; top: 1.4rem; right: .25rem; z-index: 1; display: grid; gap: .1rem; padding: .45rem .6rem; border-radius: 8px; background: var(--bg); border: 1px solid var(--line); font-size: .78rem; pointer-events: none; box-shadow: 0 4px 16px rgb(0 0 0 / .25); max-width: min(18rem, 90%); } | |
| 84 | .tip.left { right: auto; left: 2.6rem; } | |
| 85 | .tip .prev { opacity: .7; } | |
| 86 | .tip[hidden] { display: none; } | |
| 87 | table { width: 100%; border-collapse: collapse; font-size: .85rem; } | |
| 88 | th, td { text-align: right; padding: .35rem .4rem; border-bottom: 1px solid var(--line); white-space: nowrap; } | |
| 89 | th:first-child, td:first-child { text-align: left; white-space: normal; } | |
| 90 | th { color: var(--dim); font-weight: 600; font-size: .75rem; } | |
| 91 | .scroll { overflow-x: auto; } | |
| 92 | .mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .8rem; } | |
| 93 | .empty { color: var(--dim); font-size: .85rem; margin: 0; } | |
| 94 | dialog { width: min(44rem, calc(100vw - 1.5rem)); max-height: calc(100dvh - 1.5rem); border: 1px solid var(--line); border-radius: 12px; background: var(--panel); color: var(--fg); padding: 1rem; } | |
| 95 | dialog::backdrop { background: rgb(0 0 0 / .5); } | |
| 96 | dialog h2 { margin: 0 0 .6rem; font-size: 1.1rem; } | |
| 97 | dialog .close { float: right; } | |
| 98 | .facts { display: grid; grid-template-columns: max-content 1fr; gap: .2rem 1rem; margin: 0; font-size: .9rem; } | |
| 99 | .facts dt { color: var(--dim); } | |
| 100 | .facts dd { margin: 0; } | |
| 101 | .timeline { display: block; max-width: 100%; height: auto; } | |
| 102 | .turnbar { fill: var(--accent); opacity: .3; } | |
| 103 | .mark { stroke: var(--dim); stroke-width: 2; } | |
| 104 | .mark.stop { stroke: var(--c4); } .mark.jam { stroke: var(--c3); } .mark.block { stroke: var(--c5); } .mark.reconnect { stroke: var(--c2); } | |
| 105 | .events { margin: 0; padding-left: 1.1rem; font-size: .85rem; } | |
| 106 | @media (orientation: landscape) and (min-width: 560px) { | |
| 107 | .strip { grid-auto-flow: row; grid-template-columns: repeat(auto-fit, minmax(9.5rem, 1fr)); grid-auto-columns: auto; overflow: visible; } | |
| 108 | .panels { grid-template-columns: repeat(2, minmax(0, 1fr)); } | |
| 109 | .wide { grid-column: 1 / -1; } | |
| 110 | } | |
| 111 | @media (min-width: 760px) { | |
| 112 | main { padding-left: 1.5rem; padding-right: 1.5rem; } | |
| 113 | .strip { grid-auto-flow: row; grid-template-columns: repeat(auto-fit, minmax(9.5rem, 1fr)); grid-auto-columns: auto; overflow: visible; } | |
| 114 | .panels { grid-template-columns: repeat(2, minmax(0, 1fr)); } | |
| 115 | .wide { grid-column: 1 / -1; } | |
| 116 | } | |
| 117 | @media (min-width: 1280px) { | |
| 118 | .panels { grid-template-columns: repeat(3, minmax(0, 1fr)); } | |
| 119 | .wide { grid-column: span 2; } | |
| 120 | } | |
| 121 | @media (prefers-reduced-motion: no-preference) { | |
| 122 | .panels, .strip { transition: opacity .15s ease; } | |
| 123 | .strip { scroll-behavior: smooth; } | |
| 124 | } | |
| 125 | `; | |
| 126 | ||
| 127 | const sha = (text: string) => createHash("sha256").update(text).digest("base64"); | |
| 128 | export const dataCsp = (): string => `default-src 'none'; style-src 'sha256-${sha(DATA_STYLE)}'; script-src 'sha256-${sha(DATA_SCRIPT)}'; connect-src 'self'; img-src 'none'; form-action 'none'; frame-ancestors 'none'; base-uri 'none'`; | |
| 129 | ||
| 130 | const plot = (id: string, cls = "plot") => `<div class="${cls}" id="${id}"></div>`; | |
| 131 | ||
| 132 | export function renderDataPage(): string { | |
| 133 | return `<!doctype html> | |
| 134 | <html lang="en"> | |
| 135 | <head> | |
| 136 | <meta charset="utf-8"> | |
| 137 | <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> | |
| 138 | <meta name="referrer" content="same-origin"> | |
| 139 | <title>Data · the arm</title> | |
| 140 | <style>${DATA_STYLE}</style> | |
| 141 | </head> | |
| 142 | <body> | |
| 143 | <main> | |
| 144 | <header> | |
| 145 | <div class="title"><h1>Data</h1><nav><a href="/">The arm</a><a href="/admin">Admin</a></nav></div> | |
| 146 | <div class="controls"> | |
| 147 | <div class="seg" role="group" aria-label="Time range"> | |
| 148 | <button type="button" data-range="hour" aria-pressed="false">Hour</button><button type="button" data-range="day" aria-pressed="true">Day</button><button type="button" data-range="week" aria-pressed="false">Week</button><button type="button" data-range="all" aria-pressed="false">All</button> | |
| 149 | </div> | |
| 150 | <label class="toggle"><input type="checkbox" id="compare"> Compare to the period before</label> | |
| 151 | <button type="button" id="reset" hidden>Reset zoom</button> | |
| 152 | </div> | |
| 153 | <p class="range" id="range">Loading…</p> | |
| 154 | <p class="status" id="status" role="status" aria-live="polite"></p> | |
| 155 | </header> | |
| 156 | <section class="strip" id="strip" aria-label="Overview"></section> | |
| 157 | <p class="note">Drag across any chart to zoom into that stretch; hover, tap or use the arrow keys on a chart for exact values. Dashed upright lines mark a deployed commit.</p> | |
| 158 | <div class="panels"> | |
| 159 | <section class="panel" aria-labelledby="h-precision"><h2 id="h-precision">Precision · snap-backs by cause</h2>${plot("c-causes")}</section> | |
| 160 | <section class="panel wide" aria-labelledby="h-joints"><h2 id="h-joints">Precision · per joint: p95 settle error and time to arrive</h2><div class="multiples" id="joints"></div></section> | |
| 161 | <section class="panel wide" aria-labelledby="h-worst"><h2 id="h-worst">Precision · worst sessions</h2> | |
| 162 | <div class="scroll"><table id="worst"><thead><tr><th>Session</th><th>Length</th><th>p95 error</th><th>Snap-backs / releases</th><th>p95 arrive</th><th>Commit</th></tr></thead><tbody></tbody></table></div></section> | |
| 163 | <section class="panel" aria-labelledby="h-total"><h2 id="h-total">Latency · command to confirmed motion</h2>${plot("c-total")}</section> | |
| 164 | <section class="panel" aria-labelledby="h-lathist"><h2 id="h-lathist">Latency · turns by p95</h2>${plot("c-lathist")}</section> | |
| 165 | <section class="panel wide" aria-labelledby="h-hops"><h2 id="h-hops">Latency · per hop, p50 to p95</h2><div class="multiples" id="hops"></div></section> | |
| 166 | <section class="panel" aria-labelledby="h-room"><h2 id="h-room">People · in the room</h2><p class="live" id="live"></p>${plot("c-room")}</section> | |
| 167 | <section class="panel" aria-labelledby="h-visitors"><h2 id="h-visitors">People · visitors</h2>${plot("c-visitors")}</section> | |
| 168 | <section class="panel" aria-labelledby="h-funnel"><h2 id="h-funnel">People · where they drop off</h2>${plot("c-funnel")}</section> | |
| 169 | <section class="panel" aria-labelledby="h-wait"><h2 id="h-wait">People · wait in line</h2>${plot("c-wait")}</section> | |
| 170 | <section class="panel" aria-labelledby="h-turns"><h2 id="h-turns">People · turn length</h2>${plot("c-turns")}</section> | |
| 171 | <section class="panel" aria-labelledby="h-chat"><h2 id="h-chat">People · chat volume</h2>${plot("c-chat")}</section> | |
| 172 | <section class="panel" aria-labelledby="h-uptime"><h2 id="h-uptime">Health · arm link uptime</h2>${plot("c-uptime")}</section> | |
| 173 | <section class="panel" aria-labelledby="h-events"><h2 id="h-events">Health · reconnects, restarts, STOPs</h2><p class="note" id="starts"></p>${plot("c-events")}</section> | |
| 174 | <section class="panel" aria-labelledby="h-fps"><h2 id="h-fps">Health · camera fps</h2>${plot("c-fps")}</section> | |
| 175 | </div> | |
| 176 | </main> | |
| 177 | <dialog id="turn" aria-labelledby="turn-title"><button type="button" class="close" id="turn-close">Close</button><div id="turn-body"></div></dialog> | |
| 178 | <script>${DATA_SCRIPT}</script> | |
| 179 | </body> | |
| 180 | </html> | |
| 181 | `; | |
| 182 | } | |
| 183 | ||
| 184 | /** GET /admin/data: the page for the owner and moderators; to anyone else, the ordinary not-found. */ | |
| 185 | export function serveDataPage(lobby: Lobby, req: IncomingMessage, res: ServerResponse): void { | |
| 186 | if (!lobby.who(req) || !lobby.moderates(req)) { | |
| 187 | res.writeHead(404, { "content-type": "application/json", "cache-control": "no-store" }); | |
| 188 | res.end(JSON.stringify({ error: "no such route" })); | |
| 189 | return; | |
| 190 | } | |
| 191 | if (req.method !== "GET" && req.method !== "HEAD") { | |
| 192 | res.writeHead(405, { "content-type": "application/json", "cache-control": "no-store", allow: "GET, HEAD" }); | |
| 193 | res.end(JSON.stringify({ error: "GET" })); | |
| 194 | return; | |
| 195 | } | |
| 196 | res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", "content-security-policy": dataCsp() }); | |
| 197 | res.end(req.method === "HEAD" ? undefined : renderDataPage()); | |
| 198 | } |
Software/so101/src/data-series.test.ts added +157
| @@ -0,0 +1,157 @@ | ||
| 1 | import { describe, expect, test } from "bun:test"; | |
| 2 | import { aggregate, autoStep, histogram, MAX_POINTS, parseRange, percentile, type Input } from "./data-series.ts"; | |
| 3 | import type { EventRow, TurnRow } from "./facts.ts"; | |
| 4 | ||
| 5 | const H = 3_600_000; | |
| 6 | const T0 = 1_700_000_000_000 - (1_700_000_000_000 % H); | |
| 7 | ||
| 8 | const turn = (over: Partial<TurnRow>): TurnRow => ({ | |
| 9 | id: 1, | |
| 10 | start: T0, | |
| 11 | ms: 60_000, | |
| 12 | who: "u:a", | |
| 13 | name: "a", | |
| 14 | reason: "time", | |
| 15 | waitMs: null, | |
| 16 | commit: "abc1234", | |
| 17 | releases: null, | |
| 18 | snapBacks: null, | |
| 19 | byCause: null, | |
| 20 | jams: null, | |
| 21 | arriveP95Ms: null, | |
| 22 | trackingP95Deg: null, | |
| 23 | latencyP95Ms: null, | |
| 24 | hops: null, | |
| 25 | joints: null, | |
| 26 | ...over, | |
| 27 | }); | |
| 28 | const ev = (at: number, kind: EventRow["kind"], who: string | null = null, detail: string | null = null, commit = "abc1234"): EventRow => ({ at, kind, who, detail, commit }); | |
| 29 | const input = (over: Partial<Input>): Input => ({ from: T0, to: T0 + 4 * H, step: 3600, now: T0 + 4 * H, oldest: T0, turns: [], events: [], samples: [], ...over }); | |
| 30 | ||
| 31 | describe("percentile and histogram", () => { | |
| 32 | test("nearest rank: p95 of 1..20 is 19, p50 of 1..4 is 2, of nothing null", () => { | |
| 33 | expect(percentile(Array.from({ length: 20 }, (_, i) => i + 1), 95)).toBe(19); | |
| 34 | expect(percentile([4, 1, 3, 2], 50)).toBe(2); | |
| 35 | expect(percentile([7], 95)).toBe(7); | |
| 36 | expect(percentile([], 95)).toBeNull(); | |
| 37 | }); | |
| 38 | ||
| 39 | test("a histogram counts below the first edge, between, and at or past the last", () => { | |
| 40 | expect(histogram([0, 9.9, 10, 29, 30, 500], [10, 30])).toEqual([2, 2, 2]); | |
| 41 | }); | |
| 42 | ||
| 43 | test("autoStep keeps about 96 points: an hour by the minute, a day by 15 min, a week by 3 h", () => { | |
| 44 | expect(autoStep(H)).toBe(60); | |
| 45 | expect(autoStep(24 * H)).toBe(900); | |
| 46 | expect(autoStep(7 * 24 * H)).toBe(3 * 3600); | |
| 47 | expect(autoStep(90 * 24 * H)).toBe(86_400); | |
| 48 | }); | |
| 49 | }); | |
| 50 | ||
| 51 | describe("aggregate", () => { | |
| 52 | test("snap-backs per 100 releases are summed then divided, never an average of ratios", () => { | |
| 53 | const a = aggregate(input({ turns: [turn({ id: 1, releases: 10, snapBacks: 5, byCause: { jam: 5 }, jams: 5 }), turn({ id: 2, start: T0 + 60_000, releases: 90, snapBacks: 0, byCause: {}, jams: 0 })] })); | |
| 54 | // the mean of 50 and 0 would be 25; the truth is 5 in 100 | |
| 55 | expect(a.overview.snapBacksPer100).toBe(5); | |
| 56 | expect(a.series.snapBacksPer100[0]).toBe(5); | |
| 57 | expect(a.series.snapBacksPer100[1]).toBeNull(); | |
| 58 | expect(a.series.byCause.jam).toEqual([5, 0, 0, 0]); | |
| 59 | expect(a.overview.jams).toBe(5); | |
| 60 | }); | |
| 61 | ||
| 62 | test("steps: each turn lands in its own step; sessions, drivers and minutes add up", () => { | |
| 63 | const a = aggregate(input({ turns: [turn({ id: 1, who: "u:a", ms: 30_000 }), turn({ id: 2, who: "u:a", start: T0 + 2 * H, ms: 90_000 }), turn({ id: 3, who: "g:b", start: T0 + 2 * H + 1, ms: 5_000 })] })); | |
| 64 | expect(a.t).toEqual([T0, T0 + H, T0 + 2 * H, T0 + 3 * H]); | |
| 65 | expect(a.series.sessions).toEqual([1, 0, 2, 0]); | |
| 66 | expect(a.series.drivers).toEqual([1, 0, 2, 0]); | |
| 67 | expect(a.overview).toMatchObject({ sessions: 3, drivers: 2, minutes: 2.1 }); | |
| 68 | expect(a.turnLengths.counts).toEqual([1, 0, 1, 1, 0, 0]); | |
| 69 | }); | |
| 70 | ||
| 71 | test("a step's p95 time to arrive is the p95 of its turns' p95s, so one bad turn shows", () => { | |
| 72 | const turns = Array.from({ length: 20 }, (_, i) => turn({ id: i, start: T0 + i, releases: 1, snapBacks: 0, arriveP95Ms: i === 19 ? 2000 : 100, latencyP95Ms: 50 + i })); | |
| 73 | const a = aggregate(input({ turns })); | |
| 74 | expect(a.series.arriveP95Ms[0]).toBe(100); | |
| 75 | expect(a.overview.arriveP95Ms).toBe(100); | |
| 76 | const worse = aggregate(input({ turns: turns.map((t, i) => (i === 18 ? { ...t, arriveP95Ms: 1500 } : t)) })); | |
| 77 | expect(worse.overview.arriveP95Ms).toBe(1500); | |
| 78 | expect(a.overview.latencyP95Ms).toBe(68); | |
| 79 | }); | |
| 80 | ||
| 81 | test("hops and joints keep their p50 and p95 per step", () => { | |
| 82 | const a = aggregate(input({ | |
| 83 | turns: [ | |
| 84 | turn({ id: 1, releases: 1, snapBacks: 0, hops: { "relay→pi": { p50: 10, p95: 40 } }, joints: { elbow: { errorP95Deg: 1.234, arriveP95Ms: 300 } } }), | |
| 85 | turn({ id: 2, start: T0 + 1, releases: 1, snapBacks: 0, hops: { "relay→pi": { p50: 20, p95: 80 } }, joints: { elbow: { errorP95Deg: 2.5, arriveP95Ms: 500 } } }), | |
| 86 | ], | |
| 87 | })); | |
| 88 | expect(a.series.hops["relay→pi"]!.p50[0]).toBe(10); | |
| 89 | expect(a.series.hops["relay→pi"]!.p95[0]).toBe(80); | |
| 90 | expect(a.series.joints.elbow!.errorP95Deg[0]).toBe(2.5); | |
| 91 | expect(a.series.joints.elbow!.arriveP95Ms.slice(1)).toEqual([null, null, null]); | |
| 92 | }); | |
| 93 | ||
| 94 | test("visitors are distinct people per step, anonymous against named; the funnel counts how far each got", () => { | |
| 95 | const events = [ev(T0, "visit", "g:1"), ev(T0 + 1, "visit", "g:1"), ev(T0 + 2, "visit", "u:a"), ev(T0 + 3, "visit", "u:b"), ev(T0 + H, "visit", "g:1"), ev(T0 + 5, "named", "u:a"), ev(T0 + 6, "queued", "u:a"), ev(T0 + 7, "chat", "u:a"), ev(T0 + 8, "chat", "u:b")]; | |
| 96 | const a = aggregate(input({ events, turns: [turn({ who: "u:a", ms: 5_000 }), turn({ id: 2, who: "u:b", ms: 40_000 })] })); | |
| 97 | expect(a.series.visitorsAnon).toEqual([1, 1, 0, 0]); | |
| 98 | expect(a.series.visitorsNamed).toEqual([2, 0, 0, 0]); | |
| 99 | expect(a.overview.visitors).toBe(3); | |
| 100 | expect(a.funnel).toEqual({ visitors: 3, named: 2, queued: 1, drove: 2, under10s: 1 }); | |
| 101 | expect(a.series.chat).toEqual([2, 0, 0, 0]); | |
| 102 | }); | |
| 103 | ||
| 104 | test("uptime is live minutes over minutes passed since the record began; no samples is null", () => { | |
| 105 | const samples = Array.from({ length: 60 }, (_, i) => ({ at: T0 + i * 60_000, metric: "link", value: i < 45 ? 1 : 0 })); | |
| 106 | const a = aggregate(input({ samples, now: T0 + H, to: T0 + 4 * H })); | |
| 107 | expect(a.series.uptime[0]).toBe(0.75); | |
| 108 | expect(a.series.uptime[1]).toBeNull(); // in the future | |
| 109 | expect(a.overview.uptime).toBe(0.75); | |
| 110 | expect(aggregate(input({})).overview.uptime).toBeNull(); | |
| 111 | }); | |
| 112 | ||
| 113 | test("room samples show each step's peak; camera fps their mean", () => { | |
| 114 | const samples = [ | |
| 115 | { at: T0, metric: "watchers", value: 3 }, | |
| 116 | { at: T0 + 60_000, metric: "watchers", value: 7 }, | |
| 117 | { at: T0, metric: "fps:front", value: 20 }, | |
| 118 | { at: T0 + 60_000, metric: "fps:front", value: 30 }, | |
| 119 | ]; | |
| 120 | const a = aggregate(input({ samples })); | |
| 121 | expect(a.series.watchers).toEqual([7, null, null, null]); | |
| 122 | expect(a.series.fps.front).toEqual([25, null, null, null]); | |
| 123 | }); | |
| 124 | ||
| 125 | test("version markers: one per change of commit, app and Pi apart; starts by cause", () => { | |
| 126 | const events = [ev(T0, "restart", null, "deploy", "aaa1111"), ev(T0 + 10, "restart", null, "restart", "aaa1111"), ev(T0 + H, "restart", null, "deploy", "bbb2222"), ev(T0 + 5, "pi-version", null, "ccc3333"), ev(T0 + 6, "pi-version", null, "ccc3333")]; | |
| 127 | const a = aggregate(input({ events: events.sort((x, y) => x.at - y.at) })); | |
| 128 | expect(a.versions).toEqual([ | |
| 129 | { at: T0, where: "app", commit: "aaa1111" }, | |
| 130 | { at: T0 + 5, where: "pi", commit: "ccc3333" }, | |
| 131 | { at: T0 + H, where: "app", commit: "bbb2222" }, | |
| 132 | ]); | |
| 133 | expect(a.starts).toEqual({ deploy: 2, restart: 1 }); | |
| 134 | expect(a.series.restarts).toEqual([2, 1, 0, 0]); | |
| 135 | }); | |
| 136 | ||
| 137 | test("the worst sessions are the measured ones, worst tracking first, at most ten", () => { | |
| 138 | const turns = Array.from({ length: 14 }, (_, i) => turn({ id: i + 1, start: T0 + i, releases: 4, snapBacks: i % 2, trackingP95Deg: i })); | |
| 139 | const a = aggregate(input({ turns: [...turns, turn({ id: 99, trackingP95Deg: null })] })); | |
| 140 | expect(a.worst.map((w) => w.id)).toEqual([14, 13, 12, 11, 10, 9, 8, 7, 6, 5]); | |
| 141 | }); | |
| 142 | }); | |
| 143 | ||
| 144 | describe("parseRange", () => { | |
| 145 | const now = T0 + 24 * H; | |
| 146 | const p = (q: string) => parseRange(new URLSearchParams(q), now); | |
| 147 | test("defaults to the last day at its auto step", () => { | |
| 148 | expect(p("")).toEqual({ from: now - 24 * H, to: now, step: 900 }); | |
| 149 | }); | |
| 150 | test("refuses what is not a range", () => { | |
| 151 | expect(p("from=abc")).toHaveProperty("error"); | |
| 152 | expect(p(`from=${now}&to=${now - 1}`)).toHaveProperty("error"); | |
| 153 | expect(p("step=7")).toHaveProperty("error"); | |
| 154 | expect(p(`from=${now - (MAX_POINTS + 1) * 60_000}&to=${now}&step=60`)).toHaveProperty("error"); | |
| 155 | expect(p(`from=${now - H}&to=${now}&step=300`)).toEqual({ from: now - H, to: now, step: 300 }); | |
| 156 | }); | |
| 157 | }); |
Software/so101/src/data-series.ts added +360
| @@ -0,0 +1,360 @@ | ||
| 1 | /** | |
| 2 | * The Data view's numbers: src/facts.ts's rows over a time range, cut into steps. Plain data in, plain data out. | |
| 3 | * | |
| 4 | * What each number is, so a chart never claims more than was measured: | |
| 5 | * - visitors: distinct people (user ids, hashed anonymous browsers) who opened the page; named = had joined with a name. | |
| 6 | * - a session is a turn; drivers are distinct people who had one; minutes are the turns' lengths. | |
| 7 | * - snap-backs per 100 releases: summed over the turns the arm measured, then divided (never an average of ratios). | |
| 8 | * - p95 time to arrive, command → confirmed motion, per hop and per joint: each turn keeps its own p95 (the arm's | |
| 9 | * src/precision.ts); a step's number is the p95 of its turns' p95s (nearest rank), so one bad turn shows. A hop's p50 | |
| 10 | * is the median of its turns' p50s. | |
| 11 | * - watchers, drivers now, queue: the minute samples; a step shows the most seen. Camera fps: their mean. | |
| 12 | * - uptime: the minutes the arm link was sampled live over the minutes since the range (or the record) began. | |
| 13 | */ | |
| 14 | ||
| 15 | import type { EventRow, SampleRow, TurnRow } from "./facts.ts"; | |
| 16 | ||
| 17 | /** The steps a request may name, in seconds, smallest first. */ | |
| 18 | export const STEPS = [60, 300, 900, 1800, 3600, 3 * 3600, 6 * 3600, 86_400] as const; | |
| 19 | /** The most steps one answer carries. */ | |
| 20 | export const MAX_POINTS = 400; | |
| 21 | const TARGET_POINTS = 96; | |
| 22 | ||
| 23 | /** The step that cuts a range into about TARGET_POINTS, never more than MAX_POINTS. */ | |
| 24 | export function autoStep(ms: number): number { | |
| 25 | for (const s of STEPS) if (ms / (s * 1000) <= TARGET_POINTS) return s; | |
| 26 | return STEPS[STEPS.length - 1]!; | |
| 27 | } | |
| 28 | ||
| 29 | /** Nearest-rank percentile; null for no values. */ | |
| 30 | export function percentile(values: readonly number[], p: number): number | null { | |
| 31 | if (values.length === 0) return null; | |
| 32 | const sorted = [...values].sort((a, b) => a - b); | |
| 33 | const rank = Math.max(1, Math.ceil((p / 100) * sorted.length)); | |
| 34 | return sorted[rank - 1]!; | |
| 35 | } | |
| 36 | ||
| 37 | const round = (v: number | null, digits = 1): number | null => (v === null || !Number.isFinite(v) ? null : Number(v.toFixed(digits))); | |
| 38 | const nums = (values: ReadonlyArray<number | null | undefined>): number[] => values.filter((v): v is number => typeof v === "number" && Number.isFinite(v)); | |
| 39 | const mean = (values: readonly number[]): number | null => (values.length ? values.reduce((a, b) => a + b, 0) / values.length : null); | |
| 40 | ||
| 41 | export const TURN_BINS = [10, 30, 60, 120, 300] as const; | |
| 42 | export const LATENCY_BINS = [25, 50, 100, 200, 400] as const; | |
| 43 | /** Counts per bin: below the first edge, between each pair, and at or past the last. */ | |
| 44 | export function histogram(values: readonly number[], edges: readonly number[]): number[] { | |
| 45 | const out = new Array<number>(edges.length + 1).fill(0); | |
| 46 | for (const v of values) { | |
| 47 | let i = 0; | |
| 48 | while (i < edges.length && v >= edges[i]!) i++; | |
| 49 | out[i]!++; | |
| 50 | } | |
| 51 | return out; | |
| 52 | } | |
| 53 | ||
| 54 | export interface Overview { | |
| 55 | visitors: number; | |
| 56 | sessions: number; | |
| 57 | drivers: number; | |
| 58 | minutes: number; | |
| 59 | snapBacksPer100: number | null; | |
| 60 | arriveP95Ms: number | null; | |
| 61 | latencyP95Ms: number | null; | |
| 62 | jams: number; | |
| 63 | /** 0–1; null with no samples. */ | |
| 64 | uptime: number | null; | |
| 65 | } | |
| 66 | ||
| 67 | export interface Series { | |
| 68 | visitorsAnon: number[]; | |
| 69 | visitorsNamed: number[]; | |
| 70 | sessions: number[]; | |
| 71 | drivers: number[]; | |
| 72 | minutes: number[]; | |
| 73 | releases: number[]; | |
| 74 | snapBacks: number[]; | |
| 75 | snapBacksPer100: Array<number | null>; | |
| 76 | byCause: Record<string, number[]>; | |
| 77 | arriveP95Ms: Array<number | null>; | |
| 78 | latencyP95Ms: Array<number | null>; | |
| 79 | hops: Record<string, { p50: Array<number | null>; p95: Array<number | null> }>; | |
| 80 | joints: Record<string, { errorP95Deg: Array<number | null>; arriveP95Ms: Array<number | null> }>; | |
| 81 | watchers: Array<number | null>; | |
| 82 | driving: Array<number | null>; | |
| 83 | queue: Array<number | null>; | |
| 84 | waitP50Ms: Array<number | null>; | |
| 85 | waitP95Ms: Array<number | null>; | |
| 86 | turnP50S: Array<number | null>; | |
| 87 | chat: number[]; | |
| 88 | reconnects: number[]; | |
| 89 | restarts: number[]; | |
| 90 | stops: number[]; | |
| 91 | jams: number[]; | |
| 92 | uptime: Array<number | null>; | |
| 93 | fps: Record<string, Array<number | null>>; | |
| 94 | } | |
| 95 | ||
| 96 | export interface Marker { | |
| 97 | at: number; | |
| 98 | where: "app" | "pi"; | |
| 99 | commit: string; | |
| 100 | } | |
| 101 | ||
| 102 | export interface Worst { | |
| 103 | id: number; | |
| 104 | start: number; | |
| 105 | ms: number; | |
| 106 | who: string; | |
| 107 | name: string | null; | |
| 108 | trackingP95Deg: number | null; | |
| 109 | arriveP95Ms: number | null; | |
| 110 | snapBacks: number | null; | |
| 111 | releases: number | null; | |
| 112 | latencyP95Ms: number | null; | |
| 113 | commit: string; | |
| 114 | } | |
| 115 | ||
| 116 | export interface Aggregate { | |
| 117 | from: number; | |
| 118 | to: number; | |
| 119 | step: number; | |
| 120 | /** Each step's start, ms. */ | |
| 121 | t: number[]; | |
| 122 | overview: Overview; | |
| 123 | series: Series; | |
| 124 | /** Who got how far: opened the page, set a name, joined the line, drove, drove under 10 s (each distinct people). */ | |
| 125 | funnel: { visitors: number; named: number; queued: number; drove: number; under10s: number }; | |
| 126 | turnLengths: { edgesS: readonly number[]; counts: number[] }; | |
| 127 | latency: { edgesMs: readonly number[]; counts: number[] }; | |
| 128 | reasons: Record<string, number>; | |
| 129 | /** App starts by cause: "deploy" (a new commit) or "restart" (the same one: a crash, or the host). */ | |
| 130 | starts: Record<string, number>; | |
| 131 | versions: Marker[]; | |
| 132 | worst: Worst[]; | |
| 133 | } | |
| 134 | ||
| 135 | export interface Input { | |
| 136 | from: number; | |
| 137 | to: number; | |
| 138 | /** Seconds. */ | |
| 139 | step: number; | |
| 140 | /** Now: no step past it is uptime's fault. */ | |
| 141 | now: number; | |
| 142 | /** The first moment anything was recorded; uptime counts from there. */ | |
| 143 | oldest: number | null; | |
| 144 | turns: readonly TurnRow[]; | |
| 145 | events: readonly EventRow[]; | |
| 146 | samples: readonly SampleRow[]; | |
| 147 | } | |
| 148 | ||
| 149 | const MINUTE = 60_000; | |
| 150 | ||
| 151 | export function aggregate(input: Input): Aggregate { | |
| 152 | const { from, to, now } = input; | |
| 153 | const stepMs = input.step * 1000; | |
| 154 | const n = Math.max(1, Math.ceil((to - from) / stepMs)); | |
| 155 | const t = Array.from({ length: n }, (_, i) => from + i * stepMs); | |
| 156 | const at = (ms: number) => Math.min(n - 1, Math.floor((ms - from) / stepMs)); | |
| 157 | const zeros = () => new Array<number>(n).fill(0); | |
| 158 | const lists = () => Array.from({ length: n }, () => [] as number[]); | |
| 159 | const p95s = (l: number[][]) => l.map((v) => round(percentile(v, 95))); | |
| 160 | const p50s = (l: number[][]) => l.map((v) => round(percentile(v, 50))); | |
| 161 | ||
| 162 | // ---- the room's events | |
| 163 | const visitorSets = { anon: Array.from({ length: n }, () => new Set<string>()), named: Array.from({ length: n }, () => new Set<string>()) }; | |
| 164 | const people = { visitors: new Set<string>(), named: new Set<string>(), queued: new Set<string>() }; | |
| 165 | const chat = zeros(), reconnects = zeros(), restarts = zeros(), stops = zeros(), jamEvents = zeros(); | |
| 166 | const versions: Marker[] = []; | |
| 167 | let appCommit: string | null = null; | |
| 168 | let piCommit: string | null = null; | |
| 169 | const starts: Record<string, number> = {}; | |
| 170 | for (const e of input.events) { | |
| 171 | const i = at(e.at); | |
| 172 | switch (e.kind) { | |
| 173 | case "visit": | |
| 174 | if (!e.who) break; | |
| 175 | people.visitors.add(e.who); | |
| 176 | if (e.who.startsWith("u:")) { | |
| 177 | visitorSets.named[i]!.add(e.who); | |
| 178 | people.named.add(e.who); | |
| 179 | } else visitorSets.anon[i]!.add(e.who); | |
| 180 | break; | |
| 181 | case "named": | |
| 182 | if (e.who) people.named.add(e.who); | |
| 183 | break; | |
| 184 | case "queued": | |
| 185 | if (e.who) people.queued.add(e.who); | |
| 186 | break; | |
| 187 | case "chat": | |
| 188 | chat[i]!++; | |
| 189 | break; | |
| 190 | case "reconnect": | |
| 191 | reconnects[i]!++; | |
| 192 | break; | |
| 193 | case "stop": | |
| 194 | stops[i]!++; | |
| 195 | break; | |
| 196 | case "jam": | |
| 197 | jamEvents[i]!++; | |
| 198 | break; | |
| 199 | case "restart": | |
| 200 | restarts[i]!++; | |
| 201 | starts[e.detail ?? "restart"] = (starts[e.detail ?? "restart"] ?? 0) + 1; | |
| 202 | if (e.commit !== appCommit) versions.push({ at: e.at, where: "app", commit: e.commit }); | |
| 203 | appCommit = e.commit; | |
| 204 | break; | |
| 205 | case "pi-version": | |
| 206 | if (e.detail && e.detail !== piCommit) versions.push({ at: e.at, where: "pi", commit: e.detail }); | |
| 207 | piCommit = e.detail; | |
| 208 | break; | |
| 209 | } | |
| 210 | } | |
| 211 | ||
| 212 | // ---- turns | |
| 213 | const sessions = zeros(), minutes = zeros(), releases = zeros(), snapBacks = zeros(), jams = zeros(); | |
| 214 | const driverSets = Array.from({ length: n }, () => new Set<string>()); | |
| 215 | const byCause: Record<string, number[]> = {}; | |
| 216 | const arrive = lists(), latency = lists(), waits = lists(), lengths = lists(); | |
| 217 | const hops: Record<string, { p50: number[][]; p95: number[][] }> = {}; | |
| 218 | const joints: Record<string, { error: number[][]; arrive: number[][] }> = {}; | |
| 219 | const drove = new Map<string, number>(); | |
| 220 | const reasons: Record<string, number> = {}; | |
| 221 | let measured = false; | |
| 222 | for (const turn of input.turns) { | |
| 223 | const i = at(turn.start); | |
| 224 | sessions[i]!++; | |
| 225 | driverSets[i]!.add(turn.who); | |
| 226 | minutes[i]! += turn.ms / MINUTE; | |
| 227 | lengths[i]!.push(turn.ms / 1000); | |
| 228 | drove.set(turn.who, Math.max(drove.get(turn.who) ?? 0, turn.ms)); | |
| 229 | reasons[turn.reason] = (reasons[turn.reason] ?? 0) + 1; | |
| 230 | if (turn.waitMs !== null) waits[i]!.push(turn.waitMs); | |
| 231 | if (turn.releases === null) continue; | |
| 232 | measured = true; | |
| 233 | releases[i]! += turn.releases; | |
| 234 | snapBacks[i]! += turn.snapBacks ?? 0; | |
| 235 | jams[i]! += turn.jams ?? 0; | |
| 236 | for (const [cause, count] of Object.entries(turn.byCause ?? {})) { | |
| 237 | if (!count) continue; | |
| 238 | (byCause[cause] ??= zeros())[i]! += count; | |
| 239 | } | |
| 240 | if (turn.arriveP95Ms !== null) arrive[i]!.push(turn.arriveP95Ms); | |
| 241 | if (turn.latencyP95Ms !== null) latency[i]!.push(turn.latencyP95Ms); | |
| 242 | for (const [name, h] of Object.entries(turn.hops ?? {})) { | |
| 243 | const into = (hops[name] ??= { p50: lists(), p95: lists() }); | |
| 244 | if (h.p50 !== null) into.p50[i]!.push(h.p50); | |
| 245 | if (h.p95 !== null) into.p95[i]!.push(h.p95); | |
| 246 | } | |
| 247 | for (const [name, j] of Object.entries(turn.joints ?? {})) { | |
| 248 | const into = (joints[name] ??= { error: lists(), arrive: lists() }); | |
| 249 | if (j.errorP95Deg !== null) into.error[i]!.push(j.errorP95Deg); | |
| 250 | if (j.arriveP95Ms !== null) into.arrive[i]!.push(j.arriveP95Ms); | |
| 251 | } | |
| 252 | } | |
| 253 | ||
| 254 | // ---- the minute samples | |
| 255 | const sampled: Record<string, number[][]> = {}; | |
| 256 | for (const s of input.samples) (sampled[s.metric] ??= lists())[at(s.at)]!.push(s.value); | |
| 257 | const peak = (metric: string) => (sampled[metric] ?? lists()).map((v) => (v.length ? Math.max(...v) : null)); | |
| 258 | const fps: Record<string, Array<number | null>> = {}; | |
| 259 | for (const [metric, l] of Object.entries(sampled)) if (metric.startsWith("fps:")) fps[metric.slice(4)] = l.map((v) => round(mean(v))); | |
| 260 | // uptime: live minutes over the minutes that have passed in each step since the record began | |
| 261 | const since = Math.max(from, input.oldest ?? from); | |
| 262 | const linkLive = (sampled.link ?? lists()).map((v) => v.filter((x) => x > 0).length); | |
| 263 | const expected = t.map((start) => Math.max(0, (Math.min(start + stepMs, now, to) - Math.max(start, since)) / MINUTE)); | |
| 264 | const uptime = expected.map((e, i) => (e >= 1 ? round(Math.min(1, linkLive[i]! / Math.floor(e)), 3) : null)); | |
| 265 | const allExpected = Math.floor(expected.reduce((a, b) => a + b, 0)); | |
| 266 | const allLive = linkLive.reduce((a, b) => a + b, 0); | |
| 267 | ||
| 268 | const allReleases = releases.reduce((a, b) => a + b, 0); | |
| 269 | const allSnaps = snapBacks.reduce((a, b) => a + b, 0); | |
| 270 | const turnsMeasured = input.turns.filter((x) => x.releases !== null); | |
| 271 | const latencies = nums(turnsMeasured.map((x) => x.latencyP95Ms)); | |
| 272 | const allJams = jams.reduce((a, b) => a + b, 0); | |
| 273 | const overview: Overview = { | |
| 274 | visitors: people.visitors.size, | |
| 275 | sessions: input.turns.length, | |
| 276 | drivers: drove.size, | |
| 277 | minutes: round(minutes.reduce((a, b) => a + b, 0)) ?? 0, | |
| 278 | snapBacksPer100: allReleases > 0 ? round((100 * allSnaps) / allReleases) : null, | |
| 279 | arriveP95Ms: round(percentile(nums(turnsMeasured.map((x) => x.arriveP95Ms)), 95), 0), | |
| 280 | latencyP95Ms: round(percentile(latencies, 95), 0), | |
| 281 | jams: measured ? allJams : jamEvents.reduce((a, b) => a + b, 0), | |
| 282 | uptime: allExpected >= 1 && sampled.link ? round(Math.min(1, allLive / allExpected), 3) : null, | |
| 283 | }; | |
| 284 | ||
| 285 | const per100 = releases.map((r, i) => (r > 0 ? round((100 * snapBacks[i]!) / r) : null)); | |
| 286 | const worst: Worst[] = turnsMeasured | |
| 287 | .filter((x) => x.trackingP95Deg !== null || (x.snapBacks ?? 0) > 0) | |
| 288 | .sort((a, b) => (b.trackingP95Deg ?? 0) - (a.trackingP95Deg ?? 0) || (b.snapBacks ?? 0) - (a.snapBacks ?? 0) || b.start - a.start) | |
| 289 | .slice(0, 10) | |
| 290 | .map((x) => ({ id: x.id, start: x.start, ms: x.ms, who: x.who, name: x.name, trackingP95Deg: x.trackingP95Deg, arriveP95Ms: x.arriveP95Ms, snapBacks: x.snapBacks, releases: x.releases, latencyP95Ms: x.latencyP95Ms, commit: x.commit })); | |
| 291 | ||
| 292 | return { | |
| 293 | from, | |
| 294 | to, | |
| 295 | step: input.step, | |
| 296 | t, | |
| 297 | overview, | |
| 298 | series: { | |
| 299 | visitorsAnon: visitorSets.anon.map((s) => s.size), | |
| 300 | visitorsNamed: visitorSets.named.map((s) => s.size), | |
| 301 | sessions, | |
| 302 | drivers: driverSets.map((s) => s.size), | |
| 303 | minutes: minutes.map((m) => round(m) ?? 0), | |
| 304 | releases, | |
| 305 | snapBacks, | |
| 306 | snapBacksPer100: per100, | |
| 307 | byCause, | |
| 308 | arriveP95Ms: p95s(arrive), | |
| 309 | latencyP95Ms: p95s(latency), | |
| 310 | hops: Object.fromEntries(Object.entries(hops).map(([k, h]) => [k, { p50: p50s(h.p50), p95: p95s(h.p95) }])), | |
| 311 | joints: Object.fromEntries(Object.entries(joints).map(([k, j]) => [k, { errorP95Deg: j.error.map((v) => round(percentile(v, 95), 2)), arriveP95Ms: p95s(j.arrive) }])), | |
| 312 | watchers: peak("watchers"), | |
| 313 | driving: peak("drivers"), | |
| 314 | queue: peak("queue"), | |
| 315 | waitP50Ms: p50s(waits), | |
| 316 | waitP95Ms: p95s(waits), | |
| 317 | turnP50S: p50s(lengths), | |
| 318 | chat, | |
| 319 | reconnects, | |
| 320 | restarts, | |
| 321 | stops, | |
| 322 | jams: measured ? jams : jamEvents, | |
| 323 | uptime, | |
| 324 | fps, | |
| 325 | }, | |
| 326 | funnel: { | |
| 327 | visitors: people.visitors.size, | |
| 328 | named: people.named.size, | |
| 329 | queued: people.queued.size, | |
| 330 | drove: drove.size, | |
| 331 | under10s: [...drove.values()].filter((ms) => ms < 10_000).length, | |
| 332 | }, | |
| 333 | turnLengths: { edgesS: TURN_BINS, counts: histogram(input.turns.map((x) => x.ms / 1000), TURN_BINS) }, | |
| 334 | latency: { edgesMs: LATENCY_BINS, counts: histogram(latencies, LATENCY_BINS) }, | |
| 335 | reasons, | |
| 336 | starts, | |
| 337 | versions, | |
| 338 | worst, | |
| 339 | }; | |
| 340 | } | |
| 341 | ||
| 342 | /** A request's `from`, `to` and `step`, checked: ms since the epoch, from before to, a listed step; else why not. */ | |
| 343 | export function parseRange(params: URLSearchParams, now: number): { from: number; to: number; step: number } | { error: string } { | |
| 344 | const read = (name: string) => { | |
| 345 | const v = params.get(name); | |
| 346 | if (v === null || v === "") return undefined; | |
| 347 | return /^\d{1,15}$/.test(v) ? Number(v) : NaN; | |
| 348 | }; | |
| 349 | const to = read("to") ?? now; | |
| 350 | const from = read("from") ?? to - 86_400_000; | |
| 351 | if (Number.isNaN(to) || Number.isNaN(from)) return { error: "from and to are ms since the epoch" }; | |
| 352 | if (from >= to) return { error: "from must be before to" }; | |
| 353 | const span = to - from; | |
| 354 | const named = read("step"); | |
| 355 | if (Number.isNaN(named)) return { error: `step is one of ${STEPS.join(", ")} seconds` }; | |
| 356 | const step = named ?? autoStep(span); | |
| 357 | if (!(STEPS as readonly number[]).includes(step)) return { error: `step is one of ${STEPS.join(", ")} seconds` }; | |
| 358 | if (span / (step * 1000) > MAX_POINTS) return { error: `at most ${MAX_POINTS} steps: choose a longer step` }; | |
| 359 | return { from, to, step }; | |
| 360 | } |
Software/so101/src/facts.test.ts added +120
| @@ -0,0 +1,120 @@ | ||
| 1 | import { describe, expect, test } from "bun:test"; | |
| 2 | import { mkdtempSync, rmSync } from "node:fs"; | |
| 3 | import { tmpdir } from "node:os"; | |
| 4 | import { join } from "node:path"; | |
| 5 | import { ArmEdges } from "./arm-edges.ts"; | |
| 6 | import { Facts, fromCard, KEEP_DAYS, readLast, whoKey } from "./facts.ts"; | |
| 7 | ||
| 8 | const DAY = 86_400_000; | |
| 9 | ||
| 10 | const card = { | |
| 11 | releases: 20, | |
| 12 | snapBacks: { total: 3, byCause: { jam: 1, "zone>block": 2, "<script>": 9 } }, | |
| 13 | arriveP95Ms: 420, | |
| 14 | trackingP95Deg: 1.8, | |
| 15 | latencyP95Ms: 95, | |
| 16 | hops: { "page→relay": { n: 10, p50: 12, p95: 30 }, "relay→pi": { n: 0, p50: null, p95: null } }, | |
| 17 | joints: [{ joint: "elbow", errorDeg: { n: 5, p95: 1.1 }, arriveMs: { n: 5, p95: 380 } }, { joint: "wrist", errorDeg: { n: 0 }, arriveMs: { n: 0 } }], | |
| 18 | }; | |
| 19 | ||
| 20 | describe("Facts", () => { | |
| 21 | test("a turn keeps who, length, why it ended, its wait in line and the commit", () => { | |
| 22 | let now = 1_000_000; | |
| 23 | const facts = new Facts({ commit: "abc1234", now: () => now }); | |
| 24 | facts.queued("u:a"); | |
| 25 | now += 30_000; | |
| 26 | facts.turnStarted("u:a", "Ada"); | |
| 27 | now += 45_000; | |
| 28 | expect(facts.turnEnded("u:b", "time")).toBeNull(); | |
| 29 | const id = facts.turnEnded("u:a", "time")!; | |
| 30 | expect(facts.turn(id)).toMatchObject({ who: "u:a", name: "Ada", start: 1_030_000, ms: 45_000, reason: "time", waitMs: 30_000, commit: "abc1234", releases: null }); | |
| 31 | expect(facts.lastTurn).toEqual({ id, start: 1_030_000 }); | |
| 32 | }); | |
| 33 | ||
| 34 | test("the arm's numbers join their turn", () => { | |
| 35 | const facts = new Facts({ commit: "abc1234" }); | |
| 36 | facts.turnStarted("g:x", null); | |
| 37 | const id = facts.turnEnded("g:x", "left")!; | |
| 38 | facts.armNumbers(id, fromCard(card)!); | |
| 39 | expect(facts.turn(id)).toMatchObject({ releases: 20, snapBacks: 3, jams: 1, byCause: { jam: 1, "zone>block": 2 }, hops: { "page→relay": { p50: 12, p95: 30 } }, joints: { elbow: { errorP95Deg: 1.1, arriveP95Ms: 380 } } }); | |
| 40 | }); | |
| 41 | ||
| 42 | test("a start is a deploy when the commit changed, else a restart; it survives reopening", () => { | |
| 43 | const dir = mkdtempSync(join(tmpdir(), "so101-facts-")); | |
| 44 | try { | |
| 45 | const file = join(dir, "facts.sqlite"); | |
| 46 | const a = new Facts({ file, commit: "aaa1111" }); | |
| 47 | expect(a.restarted()).toBe("deploy"); | |
| 48 | a.close(); | |
| 49 | const b = new Facts({ file, commit: "aaa1111" }); | |
| 50 | expect(b.restarted()).toBe("restart"); | |
| 51 | b.close(); | |
| 52 | const c = new Facts({ file, commit: "bbb2222" }); | |
| 53 | expect(c.restarted()).toBe("deploy"); | |
| 54 | expect(c.events(0, Date.now() + 1).map((e) => e.detail)).toEqual(["deploy", "restart", "deploy"]); | |
| 55 | c.close(); | |
| 56 | } finally { | |
| 57 | rmSync(dir, { recursive: true, force: true }); | |
| 58 | } | |
| 59 | }); | |
| 60 | ||
| 61 | test(`rows older than ${KEEP_DAYS} days are dropped`, () => { | |
| 62 | let now = 0; | |
| 63 | const facts = new Facts({ commit: "abc1234", now: () => now }); | |
| 64 | facts.event("stop"); | |
| 65 | facts.sample({ watchers: 2 }); | |
| 66 | now = 10 * DAY; | |
| 67 | facts.event("jam"); | |
| 68 | now = (KEEP_DAYS + 5) * DAY; | |
| 69 | facts.trim(); | |
| 70 | expect(facts.events(0, now).map((e) => e.kind)).toEqual(["jam"]); | |
| 71 | expect(facts.samples(0, now)).toEqual([]); | |
| 72 | expect(facts.oldest()).toBe(10 * DAY); | |
| 73 | }); | |
| 74 | ||
| 75 | test("a sample that is not a finite number is not kept", () => { | |
| 76 | const facts = new Facts({ commit: "abc1234", now: () => 5 }); | |
| 77 | facts.sample({ watchers: 3, queue: Number.NaN, "fps:front": 24.5 }); | |
| 78 | expect(facts.samples(0, 10)).toEqual([ | |
| 79 | { at: 5, metric: "watchers", value: 3 }, | |
| 80 | { at: 5, metric: "fps:front", value: 24.5 }, | |
| 81 | ]); | |
| 82 | }); | |
| 83 | }); | |
| 84 | ||
| 85 | describe("reading the arm's card", () => { | |
| 86 | test("only the expected shape is kept: odd causes, empty hops and unmeasured joints are left out", () => { | |
| 87 | const n = fromCard(card)!; | |
| 88 | expect(Object.keys(n.byCause!)).toEqual(["jam", "zone>block"]); | |
| 89 | expect(Object.keys(n.hops!)).toEqual(["page→relay"]); | |
| 90 | expect(Object.keys(n.joints!)).toEqual(["elbow"]); | |
| 91 | expect(fromCard({ releases: "20" })).toBeNull(); | |
| 92 | expect(fromCard(null)).toBeNull(); | |
| 93 | }); | |
| 94 | ||
| 95 | test("readLast takes /precision's last session with when it ended", () => { | |
| 96 | expect(readLast({ last: { endedAt: 123, card } })?.endedAt).toBe(123); | |
| 97 | expect(readLast({ last: null })).toBeNull(); | |
| 98 | expect(readLast("nonsense")).toBeNull(); | |
| 99 | }); | |
| 100 | ||
| 101 | test("whoKey: a user's id, or a hash that is not the browser's id", () => { | |
| 102 | expect(whoKey({ id: "42" }, "browser-1")).toBe("u:42"); | |
| 103 | const g = whoKey(null, "browser-1")!; | |
| 104 | expect(g).toMatch(/^g:[0-9a-f]{16}$/); | |
| 105 | expect(g).not.toContain("browser-1"); | |
| 106 | expect(whoKey(null, null)).toBeNull(); | |
| 107 | }); | |
| 108 | }); | |
| 109 | ||
| 110 | describe("ArmEdges", () => { | |
| 111 | test("a jam or a block counts when it begins, not while it lasts", () => { | |
| 112 | const edges = new ArmEdges(); | |
| 113 | expect(edges.see({ jammed: [], blocked: false })).toEqual([]); | |
| 114 | expect(edges.see({ jammed: [2], blocked: true })).toEqual([{ kind: "jam", joints: [2] }, { kind: "block" }]); | |
| 115 | expect(edges.see({ jammed: [2], blocked: true })).toEqual([]); | |
| 116 | expect(edges.see({ jammed: [2, 4], blocked: false })).toEqual([{ kind: "jam", joints: [4] }]); | |
| 117 | expect(edges.see("garbage")).toEqual([]); | |
| 118 | expect(edges.see({ jammed: [2] })).toEqual([{ kind: "jam", joints: [2] }]); | |
| 119 | }); | |
| 120 | }); |
Software/so101/src/facts.ts added +239
| @@ -0,0 +1,239 @@ | ||
| 1 | /** | |
| 2 | * What happened, kept for the Data view (/admin/data): one row per turn, the room's and the link's events, and a | |
| 3 | * sample of the room every minute, each tagged with the commit the app ran, in facts.sqlite beside the chat. Rows | |
| 4 | * older than 90 days are dropped. Who is a user id ("u:…") or a hash of an anonymous browser's id ("g:…"): never | |
| 5 | * a name, never an address. The arm's own numbers for a turn (src/precision.ts's card) are added when the arm has them. | |
| 6 | * Reads are by time range; the sums and percentiles per step are src/data-series.ts's. | |
| 7 | */ | |
| 8 | ||
| 9 | import { createHash } from "node:crypto"; | |
| 10 | import { openDb, type Db } from "./sqlite.ts"; | |
| 11 | ||
| 12 | export const KEEP_DAYS = 90; | |
| 13 | const DAY = 86_400_000; | |
| 14 | ||
| 15 | /** The events the view marks on a time axis or counts. */ | |
| 16 | export const EVENT_KINDS = ["visit", "named", "queued", "chat", "stop", "jam", "block", "park", "reconnect", "restart", "pi-version"] as const; | |
| 17 | export type EventKind = (typeof EVENT_KINDS)[number]; | |
| 18 | ||
| 19 | /** The room every minute: watchers, drivers (0 or 1), queue length, the arm link live (0 or 1), and a camera's fps under "fps:<camera>". */ | |
| 20 | export type Metric = "watchers" | "drivers" | "queue" | "link" | `fps:${string}`; | |
| 21 | ||
| 22 | export interface TurnRow { | |
| 23 | id: number; | |
| 24 | start: number; | |
| 25 | ms: number; | |
| 26 | who: string; | |
| 27 | /** The name they drove under: a driver has joined with one (a guest only watches). */ | |
| 28 | name: string | null; | |
| 29 | reason: string; | |
| 30 | waitMs: number | null; | |
| 31 | commit: string; | |
| 32 | /** The arm's numbers for the turn; null until (unless) the arm had them. Jams are the card's "jam" snap-backs. */ | |
| 33 | releases: number | null; | |
| 34 | snapBacks: number | null; | |
| 35 | byCause: Record<string, number> | null; | |
| 36 | jams: number | null; | |
| 37 | arriveP95Ms: number | null; | |
| 38 | trackingP95Deg: number | null; | |
| 39 | latencyP95Ms: number | null; | |
| 40 | /** p50 and p95 per hop, in ms. */ | |
| 41 | hops: Record<string, { p50: number | null; p95: number | null }> | null; | |
| 42 | /** Per joint: p95 settle error in degrees and p95 time to arrive in ms. */ | |
| 43 | joints: Record<string, { errorP95Deg: number | null; arriveP95Ms: number | null }> | null; | |
| 44 | } | |
| 45 | ||
| 46 | export interface EventRow { | |
| 47 | at: number; | |
| 48 | kind: EventKind; | |
| 49 | who: string | null; | |
| 50 | detail: string | null; | |
| 51 | commit: string; | |
| 52 | } | |
| 53 | ||
| 54 | export interface SampleRow { | |
| 55 | at: number; | |
| 56 | metric: string; | |
| 57 | value: number; | |
| 58 | } | |
| 59 | ||
| 60 | /** A user's id, or a short hash of an anonymous browser's: enough to count distinct people, nothing to find them by. */ | |
| 61 | export const whoKey = (person: { id: string } | null, browser: string | null): string | null => | |
| 62 | person ? `u:${person.id}` : browser ? `g:${createHash("sha256").update(browser).digest("hex").slice(0, 16)}` : null; | |
| 63 | ||
| 64 | type StoredTurn = { id: number; start: number; ms: number; who: string; name: string | null; reason: string; wait_ms: number | null; commit_id: string; arm: string | null }; | |
| 65 | ||
| 66 | type ArmNumbers = Pick<TurnRow, "releases" | "snapBacks" | "byCause" | "jams" | "arriveP95Ms" | "trackingP95Deg" | "latencyP95Ms" | "hops" | "joints">; | |
| 67 | const num = (v: unknown): number | null => (typeof v === "number" && Number.isFinite(v) ? v : null); | |
| 68 | const obj = (v: unknown): Record<string, unknown> | null => (v && typeof v === "object" && !Array.isArray(v) ? (v as Record<string, unknown>) : null); | |
| 69 | const NAME = /^[\w>→ .-]{1,40}$/u; | |
| 70 | ||
| 71 | /** | |
| 72 | * The parts of an arm's card (src/precision.ts) the view needs, read field by field: the card comes over the network, | |
| 73 | * so anything not the expected shape is left out, and a card without its counts is null. | |
| 74 | */ | |
| 75 | export function fromCard(input: unknown): ArmNumbers | null { | |
| 76 | const card = obj(input); | |
| 77 | const snaps = obj(card?.snapBacks); | |
| 78 | const releases = num(card?.releases); | |
| 79 | const total = num(snaps?.total); | |
| 80 | if (!card || !snaps || releases === null || total === null) return null; | |
| 81 | const byCause: Record<string, number> = {}; | |
| 82 | for (const [cause, n] of Object.entries(obj(snaps.byCause) ?? {})) if (NAME.test(cause) && num(n) !== null) byCause[cause] = n as number; | |
| 83 | const hops: NonNullable<TurnRow["hops"]> = {}; | |
| 84 | for (const [name, s] of Object.entries(obj(card.hops) ?? {})) { | |
| 85 | const spread = obj(s); | |
| 86 | if (NAME.test(name) && spread && (num(spread.n) ?? 0) > 0) hops[name] = { p50: num(spread.p50), p95: num(spread.p95) }; | |
| 87 | } | |
| 88 | const joints: NonNullable<TurnRow["joints"]> = {}; | |
| 89 | for (const j of Array.isArray(card.joints) ? card.joints : []) { | |
| 90 | const joint = obj(j); | |
| 91 | const name = joint?.joint; | |
| 92 | const error = obj(joint?.errorDeg); | |
| 93 | const arrive = obj(joint?.arriveMs); | |
| 94 | if (typeof name !== "string" || !NAME.test(name) || ((num(error?.n) ?? 0) === 0 && (num(arrive?.n) ?? 0) === 0)) continue; | |
| 95 | joints[name] = { errorP95Deg: num(error?.p95), arriveP95Ms: num(arrive?.p95) }; | |
| 96 | } | |
| 97 | return { | |
| 98 | releases, | |
| 99 | snapBacks: total, | |
| 100 | byCause, | |
| 101 | jams: byCause.jam ?? 0, | |
| 102 | arriveP95Ms: num(card.arriveP95Ms), | |
| 103 | trackingP95Deg: num(card.trackingP95Deg), | |
| 104 | latencyP95Ms: num(card.latencyP95Ms), | |
| 105 | hops, | |
| 106 | joints, | |
| 107 | }; | |
| 108 | } | |
| 109 | ||
| 110 | /** The arm's GET /precision `last` (the drive session that ended last), or null when it has none or it is not that shape. */ | |
| 111 | export function readLast(body: unknown): { endedAt: number; numbers: ArmNumbers } | null { | |
| 112 | const last = obj(obj(body)?.last); | |
| 113 | const endedAt = num(last?.endedAt); | |
| 114 | const numbers = fromCard(last?.card); | |
| 115 | return endedAt !== null && numbers ? { endedAt, numbers } : null; | |
| 116 | } | |
| 117 | ||
| 118 | const NO_ARM = { releases: null, snapBacks: null, byCause: null, jams: null, arriveP95Ms: null, trackingP95Deg: null, latencyP95Ms: null, hops: null, joints: null }; | |
| 119 | ||
| 120 | export class Facts { | |
| 121 | readonly commit: string; | |
| 122 | readonly : Db; | |
| 123 | readonly : () => number; | |
| 124 | /** Who asked for a turn and when: their wait, when the turn comes. */ | |
| 125 | readonly = new Map<string, number>(); | |
| 126 | /** The turn being driven: who, since when, and how long they waited. */ | |
| 127 | : { who: string; name: string | null; start: number; waitMs: number | null } | null = null; | |
| 128 | = 0; | |
| 129 | /** The turn that ended last: where the arm's numbers for it go. */ | |
| 130 | lastTurn: { id: number; start: number } | null = null; | |
| 131 | ||
| 132 | constructor(options: { file?: string; commit: string; now?: () => number }) { | |
| 133 | this.commit = options.commit; | |
| 134 | this. = options.now ?? Date.now; | |
| 135 | this. = openDb(options.file ?? ":memory:"); | |
| 136 | this..run("PRAGMA journal_mode = WAL"); | |
| 137 | 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, | |
| 138 | reason TEXT NOT NULL, wait_ms INTEGER, commit_id TEXT NOT NULL, arm TEXT)`); | |
| 139 | this..run("CREATE INDEX IF NOT EXISTS turns_start ON turns (start)"); | |
| 140 | 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)"); | |
| 141 | this..run("CREATE INDEX IF NOT EXISTS events_at ON events (at)"); | |
| 142 | this..run("CREATE TABLE IF NOT EXISTS samples (at INTEGER NOT NULL, metric TEXT NOT NULL, value REAL NOT NULL)"); | |
| 143 | this..run("CREATE INDEX IF NOT EXISTS samples_at ON samples (at)"); | |
| 144 | this.trim(); | |
| 145 | } | |
| 146 | ||
| 147 | event(kind: EventKind, who: string | null = null, detail: string | null = null): void { | |
| 148 | this..query("INSERT INTO events (at, kind, who, detail, commit_id) VALUES (?, ?, ?, ?, ?)").run(this.(), kind, who, detail === null ? null : detail.slice(0, 200), this.commit); | |
| 149 | } | |
| 150 | ||
| 151 | /** The app started: "deploy" when its commit differs from the last start's, else "restart" (a crash or a host restart). */ | |
| 152 | restarted(): "deploy" | "restart" { | |
| 153 | const last = this..query("SELECT commit_id FROM events WHERE kind = 'restart' ORDER BY at DESC LIMIT 1").get() as { commit_id: string } | null; | |
| 154 | const cause = last && last.commit_id === this.commit ? "restart" : "deploy"; | |
| 155 | this.event("restart", null, cause); | |
| 156 | return cause; | |
| 157 | } | |
| 158 | ||
| 159 | /** The room every minute; a value that is not a finite number is not kept. */ | |
| 160 | sample(values: Partial<Record<Metric, number>>): void { | |
| 161 | const at = this.(); | |
| 162 | const insert = this..query("INSERT INTO samples (at, metric, value) VALUES (?, ?, ?)"); | |
| 163 | for (const [metric, value] of Object.entries(values)) if (typeof value === "number" && Number.isFinite(value)) insert.run(at, metric.slice(0, 80), value); | |
| 164 | if (at - this. > DAY) this.trim(); | |
| 165 | } | |
| 166 | ||
| 167 | /** Someone joined the line (not straight to a turn): their wait starts. */ | |
| 168 | queued(who: string): void { | |
| 169 | if (!this..has(who)) this..set(who, this.()); | |
| 170 | this.event("queued", who); | |
| 171 | } | |
| 172 | ||
| 173 | /** Someone left the line without a turn. */ | |
| 174 | dequeued(who: string): void { | |
| 175 | this..delete(who); | |
| 176 | } | |
| 177 | ||
| 178 | turnStarted(who: string, name: string | null): void { | |
| 179 | const start = this.(); | |
| 180 | const since = this..get(who); | |
| 181 | this..delete(who); | |
| 182 | this. = { who, name: name === null ? null : name.slice(0, 64), start, waitMs: since === undefined ? null : start - since }; | |
| 183 | } | |
| 184 | ||
| 185 | /** The turn ends: its row, whose id the arm's numbers are added under. Null when no turn was open for them. */ | |
| 186 | turnEnded(who: string, reason: string): number | null { | |
| 187 | const open = this.; | |
| 188 | if (!open || open.who !== who) return null; | |
| 189 | this. = null; | |
| 190 | const ms = this.() - open.start; | |
| 191 | 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 }; | |
| 192 | this.lastTurn = { id: row.id, start: open.start }; | |
| 193 | return row.id; | |
| 194 | } | |
| 195 | ||
| 196 | /** The arm's numbers for a turn, once it has them. */ | |
| 197 | armNumbers(id: number, numbers: ArmNumbers): void { | |
| 198 | this..query("UPDATE turns SET arm = ? WHERE id = ?").run(JSON.stringify(numbers), id); | |
| 199 | } | |
| 200 | ||
| 201 | turns(from: number, to: number): TurnRow[] { | |
| 202 | const rows = this..query("SELECT id, start, ms, who, name, reason, wait_ms, commit_id, arm FROM turns WHERE start >= ? AND start < ? ORDER BY start").all(from, to) as StoredTurn[]; | |
| 203 | return rows.map((r) => ({ id: r.id, start: r.start, ms: r.ms, who: r.who, name: r.name, reason: r.reason, waitMs: r.wait_ms, commit: r.commit_id, ...(r.arm ? (JSON.parse(r.arm) as typeof NO_ARM) : NO_ARM) })); | |
| 204 | } | |
| 205 | ||
| 206 | turn(id: number): TurnRow | null { | |
| 207 | const r = this..query("SELECT start FROM turns WHERE id = ?").get(id) as { start: number } | null; | |
| 208 | return r ? (this.turns(r.start, r.start + 1).find((t) => t.id === id) ?? null) : null; | |
| 209 | } | |
| 210 | ||
| 211 | events(from: number, to: number): EventRow[] { | |
| 212 | const rows = this..query("SELECT at, kind, who, detail, commit_id FROM events WHERE at >= ? AND at < ? ORDER BY at").all(from, to) as Array<{ at: number; kind: EventKind; who: string | null; detail: string | null; commit_id: string }>; | |
| 213 | return rows.map((r) => ({ at: r.at, kind: r.kind, who: r.who, detail: r.detail, commit: r.commit_id })); | |
| 214 | } | |
| 215 | ||
| 216 | samples(from: number, to: number): SampleRow[] { | |
| 217 | return this..query("SELECT at, metric, value FROM samples WHERE at >= ? AND at < ? ORDER BY at").all(from, to) as SampleRow[]; | |
| 218 | } | |
| 219 | ||
| 220 | /** The oldest moment kept: where "all" starts. */ | |
| 221 | oldest(): number | null { | |
| 222 | const r = this..query("SELECT min(at) AS at FROM (SELECT min(start) AS at FROM turns UNION ALL SELECT min(at) FROM events UNION ALL SELECT min(at) FROM samples)").get() as { at: number | null }; | |
| 223 | return r.at; | |
| 224 | } | |
| 225 | ||
| 226 | /** Drop what is older than KEEP_DAYS. */ | |
| 227 | trim(): void { | |
| 228 | const now = this.(); | |
| 229 | const cut = now - KEEP_DAYS * DAY; | |
| 230 | this..query("DELETE FROM turns WHERE start < ?").run(cut); | |
| 231 | this..query("DELETE FROM events WHERE at < ?").run(cut); | |
| 232 | this..query("DELETE FROM samples WHERE at < ?").run(cut); | |
| 233 | this. = now; | |
| 234 | } | |
| 235 | ||
| 236 | close(): void { | |
| 237 | this..close(); | |
| 238 | } | |
| 239 | } |
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/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 +42
| @@ -89,6 +89,10 @@ 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 } from "./facts.ts"; | |
| 94 | import { serveData } from "./data-api.ts"; | |
| 95 | import { serveDataPage } from "./data-page.ts"; | |
| 92 | 96 | import { capBody, FrameCap } from "./speed-cap.ts"; |
| 93 | 97 | import { Clock, CommandGate, HELD_CLOSE, isRelease, parseCommand } from "./command-gate.ts"; |
| 94 | 98 | import { FLOOD_CLOSE, FrameRate, frameBytes, MAX_FRAME_BYTES, TOO_BIG_CLOSE } from "./frame-limit.ts"; |
| @@ -288,6 +292,7 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> { | ||
| 288 | 292 | recording = false; |
| 289 | 293 | } |
| 290 | 294 | }; |
| 295 | const edges = new ArmEdges(); | |
| 291 | 296 | const connect = () => { |
| 292 | 297 | armLink?.close(); |
| 293 | 298 | armLink = null; |
| @@ -301,6 +306,7 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> { | ||
| 301 | 306 | onStatus: (status) => { |
| 302 | 307 | if (!onArm()) return; |
| 303 | 308 | machine.status(status); |
| 309 | for (const edge of edges.see(status)) lobby?.facts?.event(edge.kind, null, edge.kind === "jam" ? edge.joints.join(",") : null); | |
| 304 | 310 | void push(); // on to the pages as it comes, at the arm's 25 Hz: no wait for the next tick |
| 305 | 311 | }, |
| 306 | 312 | onMachine: (entries, run) => { |
| @@ -316,6 +322,7 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> { | ||
| 316 | 322 | if (!baseline.recorded()) void recordBaseline(); |
| 317 | 323 | } |
| 318 | 324 | else console.error(`arm server ${host}: ${state === "offline" ? "offline; simulating" : "reconnecting"} (${armLink?.problem})`); |
| 325 | if (previous === "live" && state !== "live") lobby?.facts?.event("reconnect", null, state); | |
| 319 | 326 | if (previous === "live") for (const c of inFlight) c.abort(new Error(armLink?.problem ?? "the link dropped")); |
| 320 | 327 | }, |
| 321 | 328 | }); |
| @@ -702,6 +709,7 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> { | ||
| 702 | 709 | try { |
| 703 | 710 | const { person, token } = lobby.accounts.create((body as { name?: unknown } | null)?.name, visitor(req)); |
| 704 | 711 | res.setHeader("set-cookie", sessionCookie(token)); |
| 712 | lobby.facts?.event("named", whoKey(person, null)); | |
| 705 | 713 | return json(res, 200, { you: person }); |
| 706 | 714 | } catch (err) { |
| 707 | 715 | if (err instanceof AccountError) return json(res, err.status, { error: err.message }); |
| @@ -738,6 +746,8 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> { | ||
| 738 | 746 | const cameraAdmin = ADMIN_CAMERA.exec(url.pathname); |
| 739 | 747 | if (cameraAdmin) return await administer(req, res, cameraAdmin[1] === undefined ? null : Number(cameraAdmin[1])); |
| 740 | 748 | if (lobby && url.pathname === "/admin/lift") return await handleLift(lobby, req, res, readBody); |
| 749 | if (url.pathname === "/data") return serveData(lobby ?? undefined, req, res, url); | |
| 750 | if (lobby && url.pathname === "/admin/data") return serveDataPage(lobby, req, res); | |
| 741 | 751 | 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 | 752 | if (url.pathname === "/admin/versions") return versionsRead(req, res); |
| 743 | 753 | if (url.pathname === "/precision") return await precisionRead(req, res, url); |
| @@ -803,6 +813,7 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> { | ||
| 803 | 813 | if (req.method === "GET" && PAGE.test(url.pathname)) return await page(req, res, await ask(simulation + path, req, undefined, null, false)); |
| 804 | 814 | 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 | 815 | if (route === "POST /stop") { |
| 816 | lobby?.facts?.event("stop"); | |
| 806 | 817 | // Shut before anything is asked of an arm: a frame already on its way finds the gate closed. |
| 807 | 818 | gate.hold(by); |
| 808 | 819 | epoch = Math.max(epoch + 1, Date.now()); |
| @@ -1084,6 +1095,20 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> { | ||
| 1084 | 1095 | if (remote && onArm()) for (const one of ms) armLink?.report(PAGE_HOP, one); |
| 1085 | 1096 | }); |
| 1086 | 1097 | const turnEnded = lobby?.onTurnEnd((who) => { |
| 1098 | const ended = lobby.facts?.lastTurn; | |
| 1099 | // The arm writes its card when the stream closes (below): ask a moment later. A card that ended more than a minute | |
| 1100 | // from now (on the Pi's clock) belongs to some other turn, and is not kept. | |
| 1101 | if (ended && lobby.facts) { | |
| 1102 | const facts = lobby.facts; | |
| 1103 | setTimeout(async () => { | |
| 1104 | try { | |
| 1105 | const last = readLast(await (await armPrecision(null)).json()); | |
| 1106 | if (last && Math.abs(last.endedAt - Date.now()) < 60_000) facts.armNumbers(ended.id, last.numbers); | |
| 1107 | } catch { | |
| 1108 | /* the arm did not answer: the turn keeps its own numbers */ | |
| 1109 | } | |
| 1110 | }, 1500).unref?.(); | |
| 1111 | } | |
| 1087 | 1112 | for (const [browser, id] of streams) if (id === who.id) browser.close(1000, "your turn ended"); |
| 1088 | 1113 | const base = remote && onArm() ? remote.http : simulation; |
| 1089 | 1114 | fetch(`${base}/stop`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ by: who.name, hold: false }), signal: AbortSignal.timeout(readTimeoutMs) }).then( |
| @@ -1103,6 +1128,22 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> { | ||
| 1103 | 1128 | if (options.piSync) setTimeout(() => void options.piSync!.check(), 3000).unref?.(); |
| 1104 | 1129 | const pusher = lobby ? setInterval(() => void push(), statusEveryMs) : undefined; |
| 1105 | 1130 | pusher?.unref?.(); |
| 1131 | // The room every minute for the Data view, and the commit the Pi runs when it changes. | |
| 1132 | let piSeen: string | null = null; | |
| 1133 | const sampler = lobby?.facts | |
| 1134 | ? setInterval(() => { | |
| 1135 | const facts = lobby.facts!; | |
| 1136 | const s = lobby.turns.snapshot(); | |
| 1137 | const fps: Record<string, number> = {}; | |
| 1138 | for (const card of videoCards()) if (card.fpsP50 !== null) fps[`fps:${card.camera}`] = card.fpsP50; | |
| 1139 | facts.sample({ watchers: lobby.visitors.toJSON().here, drivers: s.driver ? 1 : 0, queue: s.queue.length, ...(remote ? { link: mode() === "live" ? 1 : 0 } : {}), ...fps }); | |
| 1140 | const pi = options.piSync?.versions().pi ?? null; | |
| 1141 | if (pi && pi !== piSeen) facts.event("pi-version", null, pi); | |
| 1142 | if (pi) piSeen = pi; | |
| 1143 | }, 60_000) | |
| 1144 | : undefined; | |
| 1145 | sampler?.unref?.(); | |
| 1146 | lobby?.facts?.restarted(); | |
| 1106 | 1147 | const summary = options.summaryEveryMs ? setInterval(() => console.log(metrics.summaryLine()), options.summaryEveryMs) : undefined; |
| 1107 | 1148 | summary?.unref?.(); |
| 1108 | 1149 | await new Promise<void>((resolve) => http.listen(options.port ?? 8102, options.host ?? "127.0.0.1", resolve)); |
| @@ -1117,6 +1158,7 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> { | ||
| 1117 | 1158 | clearInterval(pusher); |
| 1118 | 1159 | clearInterval(piTimer); |
| 1119 | 1160 | clearInterval(summary); |
| 1161 | clearInterval(sampler); | |
| 1120 | 1162 | settingsChanged?.(); |
| 1121 | 1163 | turnEnded?.(); |
| 1122 | 1164 | readBacks?.(); |
Software/so101/src/ui/data-view.js added +461
| @@ -0,0 +1,461 @@ | ||
| 1 | // @ts-nocheck: a classic script inlined in the page, typed by its use (no DOM lib in this tsconfig). | |
| 2 | // The Data view's charts (/admin/data): reads GET /data and draws every chart by hand in SVG. Inlined in the page | |
| 3 | // and pinned by its hash (src/data-page.ts). Nothing from the answer is ever parsed as HTML: text goes in as text. | |
| 4 | (() => { | |
| 5 | "use strict"; | |
| 6 | const NS = "http://www.w3.org/2000/svg"; | |
| 7 | const HOUR = 3_600_000; | |
| 8 | const RANGES = { hour: HOUR, day: 24 * HOUR, week: 7 * 24 * HOUR, all: 90 * 24 * HOUR }; | |
| 9 | const state = { range: "day", custom: null, compare: false, data: null, seq: 0 }; | |
| 10 | const $ = (sel) => document.querySelector(sel); | |
| 11 | const svg = (tag, attrs, parent) => { | |
| 12 | const e = document.createElementNS(NS, tag); | |
| 13 | for (const k in attrs) e.setAttribute(k, String(attrs[k])); | |
| 14 | if (parent) parent.appendChild(e); | |
| 15 | return e; | |
| 16 | }; | |
| 17 | const html = (tag, cls, text, parent) => { | |
| 18 | const e = document.createElement(tag); | |
| 19 | if (cls) e.className = cls; | |
| 20 | if (text !== undefined && text !== null) e.textContent = String(text); | |
| 21 | if (parent) parent.appendChild(e); | |
| 22 | return e; | |
| 23 | }; | |
| 24 | const finite = (v) => typeof v === "number" && Number.isFinite(v); | |
| 25 | ||
| 26 | // ---------------------------------------------------------------- numbers and times | |
| 27 | const fmt = (v, unit) => { | |
| 28 | if (!finite(v)) return "—"; | |
| 29 | const a = Math.abs(v); | |
| 30 | const s = a >= 10_000 ? `${(v / 1000).toFixed(a >= 100_000 ? 0 : 1)}k` : a >= 100 || Number.isInteger(v) ? Math.round(v).toLocaleString() : v.toFixed(a >= 10 ? 1 : 2); | |
| 31 | return unit ? `${s} ${unit}` : s; | |
| 32 | }; | |
| 33 | const pct = (v) => (finite(v) ? `${(v * 100).toFixed(v >= 0.999 ? 0 : 1)}%` : "—"); | |
| 34 | const when = (ms, step) => { | |
| 35 | const d = new Date(ms); | |
| 36 | const day = d.toLocaleDateString(undefined, { month: "short", day: "numeric" }); | |
| 37 | const time = d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }); | |
| 38 | return step >= 86_400 ? day : step >= 3 * 3600 ? `${day} ${time}` : time; | |
| 39 | }; | |
| 40 | const duration = (ms) => (ms < 60_000 ? `${Math.round(ms / 1000)} s` : ms < HOUR ? `${Math.floor(ms / 60_000)} min ${Math.round((ms % 60_000) / 1000)} s` : `${(ms / HOUR).toFixed(1)} h`); | |
| 41 | const niceMax = (v) => { | |
| 42 | if (!(v > 0)) return 1; | |
| 43 | const p = 10 ** Math.floor(Math.log10(v)); | |
| 44 | for (const m of [1, 2, 2.5, 5, 10]) if (v <= m * p) return m * p; | |
| 45 | return 10 * p; | |
| 46 | }; | |
| 47 | const sum = (a) => a.reduce((x, y) => x + (finite(y) ? y : 0), 0); | |
| 48 | ||
| 49 | // ---------------------------------------------------------------- one time chart | |
| 50 | const CH = 170, L = 40, R = 10, T = 14, B = 22; | |
| 51 | /** Drawn at the box's own width, one unit a pixel, so text is never stretched. */ | |
| 52 | const width = (box) => Math.max(240, Math.round(box.getBoundingClientRect().width || 600)); | |
| 53 | /** | |
| 54 | * series: [{name, values, color (a palette index), kind: "line"|"bar", unit, dashed?}], stacked bars when stack is true; | |
| 55 | * band: [lowName, highName] fills between two lines. Previous period: series with prev: true are drawn faint. | |
| 56 | */ | |
| 57 | function timeChart(box, spec) { | |
| 58 | const d = state.data; | |
| 59 | const t = d.t, n = t.length, step = d.step; | |
| 60 | box.replaceChildren(); | |
| 61 | const W = width(box), H = spec.h || CH; | |
| 62 | const fig = html("figure", "chart", null, box); | |
| 63 | const root = svg("svg", { viewBox: `0 0 ${W} ${H}`, width: W, height: H, role: "img", "aria-label": spec.label }, fig); | |
| 64 | const legend = html("figcaption", "legend", null, fig); | |
| 65 | for (const s of spec.series.filter((s) => !s.prev)) { | |
| 66 | const k = html("span", `key c${s.color}`, null, legend); | |
| 67 | html("i", null, null, k); | |
| 68 | k.append(s.name); | |
| 69 | } | |
| 70 | const bw = (W - L - R) / n; | |
| 71 | const x = (i) => L + i * bw + bw / 2; | |
| 72 | const stackTotals = t.map((_, i) => sum(spec.series.filter((s) => s.kind === "bar" && !s.prev).map((s) => s.values[i]))); | |
| 73 | const all = spec.series.flatMap((s) => (s.kind === "bar" && spec.stack ? [] : s.values)).filter(finite); | |
| 74 | const top = niceMax(Math.max(spec.min ?? 0, ...all, ...(spec.stack ? stackTotals : []), ...spec.series.filter((s) => s.kind === "bar" && !spec.stack).flatMap((s) => s.values.filter(finite)))); | |
| 75 | const y = (v) => T + (H - T - B) * (1 - v / top); | |
| 76 | for (const f of [0, 0.5, 1]) { | |
| 77 | svg("line", { x1: L, x2: W - R, y1: y(top * f), y2: y(top * f), class: "grid" }, root); | |
| 78 | svg("text", { x: L - 6, y: y(top * f) + 4, class: "tick", "text-anchor": "end" }, root).textContent = spec.percent ? pct(top * f) : fmt(top * f); | |
| 79 | } | |
| 80 | for (const i of [0, Math.floor(n / 2), n - 1]) { | |
| 81 | if (i < 0 || (i > 0 && i === Math.floor(n / 2) && n < 3)) continue; | |
| 82 | svg("text", { x: x(i), y: H - 6, class: "tick", "text-anchor": i === 0 ? "start" : i === n - 1 ? "end" : "middle" }, root).textContent = when(t[i], step); | |
| 83 | } | |
| 84 | // bars, stacked or side by side | |
| 85 | const base = new Array(n).fill(0); | |
| 86 | const bars = spec.series.filter((s) => s.kind === "bar" && !s.prev); | |
| 87 | bars.forEach((s, k) => { | |
| 88 | for (let i = 0; i < n; i++) { | |
| 89 | const v = s.values[i]; | |
| 90 | if (!finite(v) || v <= 0) continue; | |
| 91 | const w = spec.stack ? bw * 0.78 : (bw * 0.78) / bars.length; | |
| 92 | const x0 = L + i * bw + bw * 0.11 + (spec.stack ? 0 : k * w); | |
| 93 | const y1 = spec.stack ? y(base[i] + v) : y(v); | |
| 94 | const y0 = spec.stack ? y(base[i]) : y(0); | |
| 95 | svg("rect", { x: x0, y: y1, width: Math.max(1, w), height: Math.max(0.5, y0 - y1), class: `fill c${s.color}` }, root); | |
| 96 | if (spec.stack) base[i] += v; | |
| 97 | } | |
| 98 | }); | |
| 99 | // a band between two lines | |
| 100 | if (spec.band) { | |
| 101 | const [lo, hi] = spec.band.map((name) => spec.series.find((s) => s.name === name)); | |
| 102 | let seg = []; | |
| 103 | const flush = () => { | |
| 104 | if (seg.length > 1) svg("path", { d: `M${seg.map((i) => `${x(i)},${y(hi.values[i])}`).join("L")}L${[...seg].reverse().map((i) => `${x(i)},${y(lo.values[i])}`).join("L")}Z`, class: `band c${hi.color}` }, root); | |
| 105 | seg = []; | |
| 106 | }; | |
| 107 | for (let i = 0; i < n; i++) (finite(lo.values[i]) && finite(hi.values[i]) ? seg.push(i) : flush()); | |
| 108 | flush(); | |
| 109 | } | |
| 110 | // lines, with gaps where nothing was measured | |
| 111 | for (const s of spec.series.filter((s) => s.kind === "line" || (s.prev && s.kind === "bar"))) { | |
| 112 | let d = ""; | |
| 113 | let pen = false; | |
| 114 | for (let i = 0; i < n; i++) { | |
| 115 | const v = s.values[i]; | |
| 116 | if (!finite(v)) { | |
| 117 | pen = false; | |
| 118 | continue; | |
| 119 | } | |
| 120 | d += `${pen ? "L" : "M"}${x(i).toFixed(1)},${y(v).toFixed(1)}`; | |
| 121 | pen = true; | |
| 122 | } | |
| 123 | if (d) svg("path", { d, class: `line c${s.color}${s.prev ? " prev" : ""}${s.dashed ? " dashed" : ""}` }, root); | |
| 124 | const lone = s.values.map((v, i) => (finite(v) && !finite(s.values[i - 1]) && !finite(s.values[i + 1]) ? i : -1)).filter((i) => i >= 0); | |
| 125 | if (!s.prev) for (const i of lone) svg("circle", { cx: x(i), cy: y(s.values[i]), r: 2.5, class: `dot c${s.color}` }, root); | |
| 126 | } | |
| 127 | // the deployed commits | |
| 128 | for (const v of d.versions) { | |
| 129 | if (v.at < d.from || v.at >= d.to) continue; | |
| 130 | const vx = L + ((v.at - d.from) / (d.to - d.from)) * (W - L - R); | |
| 131 | svg("line", { x1: vx, x2: vx, y1: T - 4, y2: H - B, class: `version ${v.where}` }, root); | |
| 132 | svg("text", { x: vx + 3, y: T - 4, class: "vlabel" }, root).textContent = `${v.where} ${v.commit.slice(0, 7)}`; | |
| 133 | } | |
| 134 | if (!spec.series.some((s) => !s.prev && s.values.some((v) => finite(v) && v !== 0))) svg("text", { x: W / 2, y: H / 2, class: "none", "text-anchor": "middle" }, root).textContent = spec.empty || "Nothing in this range"; | |
| 135 | // hover, tap, arrow keys: exact values; drag: zoom | |
| 136 | const cursor = svg("line", { y1: T, y2: H - B, class: "cursor", visibility: "hidden" }, root); | |
| 137 | const brush = svg("rect", { y: T, height: H - T - B, class: "brush", visibility: "hidden" }, root); | |
| 138 | const tip = html("div", "tip", null, fig); | |
| 139 | tip.hidden = true; | |
| 140 | fig.tabIndex = 0; | |
| 141 | let at = -1; | |
| 142 | const show = (i) => { | |
| 143 | at = Math.max(0, Math.min(n - 1, i)); | |
| 144 | cursor.setAttribute("x1", x(at)); | |
| 145 | cursor.setAttribute("x2", x(at)); | |
| 146 | cursor.setAttribute("visibility", "visible"); | |
| 147 | tip.replaceChildren(); | |
| 148 | html("b", null, `${when(t[at], step)} – ${when(t[at] + step * 1000, step)}`, tip); | |
| 149 | for (const s of spec.series) { | |
| 150 | const row = html("span", `row c${s.color}${s.prev ? " prev" : ""}`, null, tip); | |
| 151 | html("i", null, null, row); | |
| 152 | row.append(`${s.prev ? "before: " : ""}${s.name} `); | |
| 153 | html("b", null, spec.percent ? pct(s.values[at]) : fmt(s.values[at], s.unit), row); | |
| 154 | } | |
| 155 | tip.hidden = false; | |
| 156 | tip.classList.toggle("left", at > n / 2); | |
| 157 | }; | |
| 158 | const hide = () => { | |
| 159 | cursor.setAttribute("visibility", "hidden"); | |
| 160 | tip.hidden = true; | |
| 161 | }; | |
| 162 | const index = (ev) => { | |
| 163 | const r = root.getBoundingClientRect(); | |
| 164 | return Math.floor((((ev.clientX - r.left) / r.width) * W - L) / bw); | |
| 165 | }; | |
| 166 | let from = null; | |
| 167 | root.addEventListener("pointerdown", (ev) => { | |
| 168 | from = index(ev); | |
| 169 | show(from); | |
| 170 | }); | |
| 171 | root.addEventListener("pointermove", (ev) => { | |
| 172 | const i = index(ev); | |
| 173 | show(i); | |
| 174 | if (from !== null && ev.buttons && Math.abs(i - from) >= 1) { | |
| 175 | const [a, b] = [Math.max(0, Math.min(from, i)), Math.min(n - 1, Math.max(from, i))]; | |
| 176 | brush.setAttribute("x", L + a * bw); | |
| 177 | brush.setAttribute("width", (b - a + 1) * bw); | |
| 178 | brush.setAttribute("visibility", "visible"); | |
| 179 | } | |
| 180 | }); | |
| 181 | root.addEventListener("pointerup", (ev) => { | |
| 182 | const i = index(ev); | |
| 183 | brush.setAttribute("visibility", "hidden"); | |
| 184 | if (from !== null && Math.abs(i - from) >= 2) { | |
| 185 | const [a, b] = [Math.max(0, Math.min(from, i)), Math.min(n - 1, Math.max(from, i))]; | |
| 186 | state.custom = { from: t[a], to: Math.min(d.to, t[b] + step * 1000) }; | |
| 187 | load(); | |
| 188 | } | |
| 189 | from = null; | |
| 190 | }); | |
| 191 | root.addEventListener("pointerleave", () => { | |
| 192 | from = null; | |
| 193 | brush.setAttribute("visibility", "hidden"); | |
| 194 | hide(); | |
| 195 | }); | |
| 196 | fig.addEventListener("keydown", (ev) => { | |
| 197 | if (ev.key === "ArrowLeft" || ev.key === "ArrowRight") { | |
| 198 | ev.preventDefault(); | |
| 199 | show(at < 0 ? n - 1 : at + (ev.key === "ArrowLeft" ? -1 : 1)); | |
| 200 | } else if (ev.key === "Escape") hide(); | |
| 201 | }); | |
| 202 | fig.addEventListener("blur", hide); | |
| 203 | } | |
| 204 | ||
| 205 | /** Bars over named bins, for the histograms and the funnel. */ | |
| 206 | function binChart(box, label, names, counts, color) { | |
| 207 | box.replaceChildren(); | |
| 208 | const W = width(box), H = CH; | |
| 209 | const fig = html("figure", "chart", null, box); | |
| 210 | const root = svg("svg", { viewBox: `0 0 ${W} ${H}`, width: W, height: H, role: "img", "aria-label": label }, fig); | |
| 211 | const top = niceMax(Math.max(1, ...counts)); | |
| 212 | const bw = (W - L - R) / counts.length; | |
| 213 | const y = (v) => T + (H - T - B) * (1 - v / top); | |
| 214 | svg("line", { x1: L, x2: W - R, y1: y(0), y2: y(0), class: "grid" }, root); | |
| 215 | svg("text", { x: L - 6, y: y(top) + 4, class: "tick", "text-anchor": "end" }, root).textContent = fmt(top); | |
| 216 | counts.forEach((c, i) => { | |
| 217 | const r = svg("rect", { x: L + i * bw + bw * 0.12, y: y(c), width: bw * 0.76, height: Math.max(0.5, y(0) - y(c)), class: `fill c${color}` }, root); | |
| 218 | svg("title", {}, r).textContent = `${names[i]}: ${c}`; | |
| 219 | svg("text", { x: L + i * bw + bw / 2, y: H - 6, class: "tick", "text-anchor": "middle" }, root).textContent = names[i]; | |
| 220 | if (c > 0) svg("text", { x: L + i * bw + bw / 2, y: y(c) - 4, class: "val", "text-anchor": "middle" }, root).textContent = String(c); | |
| 221 | }); | |
| 222 | } | |
| 223 | const binNames = (edges, unit) => [`<${edges[0]}`, ...edges.slice(1).map((e, i) => `${edges[i]}–${e}`), `${edges[edges.length - 1]}+`].map((s, i) => (i === edges.length ? `${s} ${unit}` : s)); | |
| 224 | ||
| 225 | // ---------------------------------------------------------------- the page | |
| 226 | function spark(parent, values, color) { | |
| 227 | const v = values.map((x) => (finite(x) ? x : null)); | |
| 228 | const top = niceMax(Math.max(0, ...v.filter(finite))); | |
| 229 | const root = svg("svg", { viewBox: "0 0 100 24", class: "spark", "aria-hidden": "true", preserveAspectRatio: "none" }, parent); | |
| 230 | let d = ""; | |
| 231 | let pen = false; | |
| 232 | v.forEach((x, i) => { | |
| 233 | if (x === null) return void (pen = false); | |
| 234 | d += `${pen ? "L" : "M"}${((i + 0.5) / v.length) * 100},${22 - (x / top) * 20}`; | |
| 235 | pen = true; | |
| 236 | }); | |
| 237 | if (d) svg("path", { d, class: `line c${color}` }, root); | |
| 238 | } | |
| 239 | ||
| 240 | function overview(d) { | |
| 241 | const o = d.overview, s = d.series, p = d.previous && d.previous.overview; | |
| 242 | const plus = (a, b) => a.map((x, i) => x + b[i]); | |
| 243 | const cards = [ | |
| 244 | ["Visitors", o.visitors, p && p.visitors, plus(s.visitorsAnon, s.visitorsNamed), 0, fmt, true], | |
| 245 | ["Driving sessions", o.sessions, p && p.sessions, s.sessions, 1, fmt, true], | |
| 246 | ["Drivers", o.drivers, p && p.drivers, s.drivers, 1, fmt, true], | |
| 247 | ["Minutes driven", o.minutes, p && p.minutes, s.minutes, 2, fmt, true], | |
| 248 | ["Snap-backs / 100 releases", o.snapBacksPer100, p && p.snapBacksPer100, s.snapBacksPer100, 3, fmt, false], | |
| 249 | ["p95 time to arrive", o.arriveP95Ms, p && p.arriveP95Ms, s.arriveP95Ms, 4, (v) => fmt(v, "ms"), false], | |
| 250 | ["p95 command → motion", o.latencyP95Ms, p && p.latencyP95Ms, s.latencyP95Ms, 5, (v) => fmt(v, "ms"), false], | |
| 251 | ["Jams", o.jams, p && p.jams, s.jams, 3, fmt, false], | |
| 252 | ["Uptime", o.uptime, p && p.uptime, s.uptime, 1, pct, true], | |
| 253 | ]; | |
| 254 | const strip = $("#strip"); | |
| 255 | strip.replaceChildren(); | |
| 256 | for (const [label, v, before, series, color, f, upIsGood] of cards) { | |
| 257 | const card = html("article", "stat", null, strip); | |
| 258 | html("h3", null, label, card); | |
| 259 | html("p", "big", f(v), card); | |
| 260 | if (state.compare && finite(before) && finite(v)) { | |
| 261 | const delta = v - before; | |
| 262 | const good = delta === 0 ? "same" : delta > 0 === upIsGood ? "good" : "bad"; | |
| 263 | html("p", `delta ${good}`, `${delta > 0 ? "▲" : delta < 0 ? "▼" : "="} ${f(Math.abs(delta))} vs before`, card); | |
| 264 | } else html("p", "delta", state.compare ? "nothing before" : "", card); | |
| 265 | spark(card, series, color); | |
| 266 | } | |
| 267 | } | |
| 268 | ||
| 269 | function precision(d) { | |
| 270 | const s = d.series, causes = Object.keys(s.byCause); | |
| 271 | timeChart($("#c-causes"), { label: "Snap-backs by cause", stack: true, series: causes.map((c, i) => ({ name: c, values: s.byCause[c], color: i % 8, kind: "bar" })), empty: "No snap-backs in this range" }); | |
| 272 | const joints = $("#joints"); | |
| 273 | joints.replaceChildren(); | |
| 274 | const names = Object.keys(s.joints); | |
| 275 | if (!names.length) html("p", "empty", "No joint was measured in this range.", joints); | |
| 276 | for (const name of names) { | |
| 277 | const cell = html("div", "joint", null, joints); | |
| 278 | html("h4", null, name.replace(/_/g, " "), cell); | |
| 279 | const a = html("div", "plot small", null, cell), b = html("div", "plot small", null, cell); | |
| 280 | timeChart(a, { h: 110, label: `${name} settle error`, series: [{ name: "p95 error", values: s.joints[name].errorP95Deg, color: 4, kind: "line", unit: "°" }] }); | |
| 281 | timeChart(b, { h: 110, label: `${name} time to arrive`, series: [{ name: "p95 arrive", values: s.joints[name].arriveP95Ms, color: 5, kind: "line", unit: "ms" }] }); | |
| 282 | } | |
| 283 | const body = $("#worst tbody"); | |
| 284 | body.replaceChildren(); | |
| 285 | if (!d.worst.length) { | |
| 286 | const tr = html("tr", null, null, body); | |
| 287 | html("td", "empty", "No measured turns in this range.", tr).colSpan = 6; | |
| 288 | } | |
| 289 | for (const w of d.worst) { | |
| 290 | const tr = html("tr", null, null, body); | |
| 291 | const td = html("td", null, null, tr); | |
| 292 | const btn = html("button", "link", `${when(w.start, 3600)} · ${w.name || "someone"}`, td); | |
| 293 | btn.type = "button"; | |
| 294 | btn.addEventListener("click", () => turn(w.id)); | |
| 295 | html("td", null, duration(w.ms), tr); | |
| 296 | html("td", null, fmt(w.trackingP95Deg, "°"), tr); | |
| 297 | html("td", null, `${fmt(w.snapBacks)} / ${fmt(w.releases)}`, tr); | |
| 298 | html("td", null, fmt(w.arriveP95Ms, "ms"), tr); | |
| 299 | html("td", "mono", w.commit.slice(0, 7), tr); | |
| 300 | } | |
| 301 | } | |
| 302 | ||
| 303 | const prevOf = (key, name, color) => (state.compare && state.data.previous ? [{ name, values: state.data.previous.series[key], color, kind: "line", prev: true }] : []); | |
| 304 | ||
| 305 | function latency(d) { | |
| 306 | const box = $("#hops"); | |
| 307 | box.replaceChildren(); | |
| 308 | const hops = Object.keys(d.series.hops); | |
| 309 | if (!hops.length) html("p", "empty", "No hop was timed in this range: the hops are timed against the real arm, while someone drives.", box); | |
| 310 | hops.forEach((h, i) => { | |
| 311 | const cell = html("div", "joint", null, box); | |
| 312 | html("h4", null, h, cell); | |
| 313 | const c = html("div", "plot small", null, cell); | |
| 314 | const v = d.series.hops[h]; | |
| 315 | timeChart(c, { h: 110, label: `${h} latency`, band: ["p50", "p95"], series: [{ name: "p50", values: v.p50, color: i % 8, kind: "line", unit: "ms" }, { name: "p95", values: v.p95, color: i % 8, kind: "line", unit: "ms", dashed: true }] }); | |
| 316 | }); | |
| 317 | timeChart($("#c-total"), { label: "Command to confirmed motion, p95", series: [{ name: "p95 total", values: d.series.latencyP95Ms, color: 5, kind: "line", unit: "ms" }, ...prevOf("latencyP95Ms", "p95 total", 5)] }); | |
| 318 | binChart($("#c-lathist"), "Turns by p95 latency", binNames(d.latency.edgesMs, "ms"), d.latency.counts, 5); | |
| 319 | } | |
| 320 | ||
| 321 | function people(d) { | |
| 322 | const s = d.series; | |
| 323 | $("#live").textContent = `Now: ${d.live.watchers} watching · ${d.live.driving ? "someone driving" : "nobody driving"} · ${d.live.queue} in line`; | |
| 324 | timeChart($("#c-visitors"), { label: "Visitors", stack: true, series: [{ name: "named", values: s.visitorsNamed, color: 0, kind: "bar" }, { name: "anonymous", values: s.visitorsAnon, color: 6, kind: "bar" }] }); | |
| 325 | timeChart($("#c-room"), { label: "Watchers, drivers and the line", series: [{ name: "watchers", values: s.watchers, color: 0, kind: "line" }, { name: "driving", values: s.driving, color: 1, kind: "line" }, { name: "in line", values: s.queue, color: 3, kind: "line" }, ...prevOf("watchers", "watchers", 0)] }); | |
| 326 | timeChart($("#c-wait"), { label: "Wait in line", series: [{ name: "p50 wait", values: s.waitP50Ms.map((v) => (finite(v) ? v / 1000 : v)), color: 2, kind: "line", unit: "s" }, { name: "p95 wait", values: s.waitP95Ms.map((v) => (finite(v) ? v / 1000 : v)), color: 3, kind: "line", unit: "s", dashed: true }] }); | |
| 327 | binChart($("#c-turns"), "Turn length", binNames(d.turnLengths.edgesS, "s"), d.turnLengths.counts, 1); | |
| 328 | timeChart($("#c-chat"), { label: "Chat lines", series: [{ name: "lines", values: s.chat, color: 7, kind: "bar" }, ...prevOf("chat", "lines", 7)] }); | |
| 329 | const f = d.funnel; | |
| 330 | binChart($("#c-funnel"), "Where people drop off", ["opened", "named", "in line", "drove", "drove <10 s"], [f.visitors, f.named, f.queued, f.drove, f.under10s], 0); | |
| 331 | } | |
| 332 | ||
| 333 | function health(d) { | |
| 334 | const s = d.series; | |
| 335 | timeChart($("#c-uptime"), { label: "Arm link uptime", percent: true, min: 1, series: [{ name: "live", values: s.uptime, color: 1, kind: "line" }], empty: "No link samples: the app is simulating, or just started" }); | |
| 336 | timeChart($("#c-events"), { label: "Reconnects, restarts and STOPs", series: [{ name: "reconnects", values: s.reconnects, color: 3, kind: "bar" }, { name: "restarts", values: s.restarts, color: 4, kind: "bar" }, { name: "STOP", values: s.stops, color: 6, kind: "bar" }] }); | |
| 337 | const cams = Object.keys(s.fps); | |
| 338 | timeChart($("#c-fps"), { label: "Camera fps", series: cams.map((c, i) => ({ name: `camera ${c}`, values: s.fps[c], color: i % 8, kind: "line", unit: "fps" })), empty: "No H.264 camera reported fps" }); | |
| 339 | const starts = Object.entries(d.starts).map(([k, n]) => `${n} ${k}${n === 1 ? "" : "s"}`).join(", "); | |
| 340 | $("#starts").textContent = starts ? `App starts in this range: ${starts}.` : "The app did not restart in this range."; | |
| 341 | } | |
| 342 | ||
| 343 | function render() { | |
| 344 | const d = state.data; | |
| 345 | const range = $("#range"); | |
| 346 | range.textContent = `${new Date(d.from).toLocaleString()} – ${new Date(d.to).toLocaleString()} · per ${duration(d.step * 1000)}`; | |
| 347 | $("#reset").hidden = !state.custom; | |
| 348 | overview(d); | |
| 349 | precision(d); | |
| 350 | latency(d); | |
| 351 | people(d); | |
| 352 | health(d); | |
| 353 | } | |
| 354 | ||
| 355 | async function load() { | |
| 356 | const seq = ++state.seq; | |
| 357 | const now = Date.now(); | |
| 358 | const span = state.custom ? null : RANGES[state.range]; | |
| 359 | const to = state.custom ? state.custom.to : now; | |
| 360 | const from = state.custom ? state.custom.from : now - span; | |
| 361 | document.body.classList.add("loading"); | |
| 362 | $("#status").textContent = ""; | |
| 363 | try { | |
| 364 | const r = await fetch(`/data?from=${Math.floor(from)}&to=${Math.ceil(to)}${state.compare ? "&compare=1" : ""}`, { credentials: "same-origin", cache: "no-store" }); | |
| 365 | const body = await r.json(); | |
| 366 | if (seq !== state.seq) return; | |
| 367 | if (!r.ok) throw new Error(body && body.error ? body.error : `the app answered ${r.status}`); | |
| 368 | state.data = body; | |
| 369 | render(); | |
| 370 | } catch (err) { | |
| 371 | if (seq === state.seq) $("#status").textContent = `Could not load: ${err.message}`; | |
| 372 | } finally { | |
| 373 | if (seq === state.seq) document.body.classList.remove("loading"); | |
| 374 | } | |
| 375 | } | |
| 376 | ||
| 377 | async function turn(id) { | |
| 378 | const dialog = $("#turn"); | |
| 379 | const body = $("#turn-body"); | |
| 380 | body.replaceChildren(html("p", "empty", "Loading…")); | |
| 381 | dialog.showModal(); | |
| 382 | try { | |
| 383 | const r = await fetch(`/data?turn=${encodeURIComponent(id)}`, { credentials: "same-origin", cache: "no-store" }); | |
| 384 | const d = await r.json(); | |
| 385 | if (!r.ok) throw new Error(d.error || r.status); | |
| 386 | const t = d.turn; | |
| 387 | body.replaceChildren(); | |
| 388 | html("h2", null, `${t.name || "someone"} · ${new Date(t.start).toLocaleString()}`, body).id = "turn-title"; | |
| 389 | const facts = html("dl", "facts", null, body); | |
| 390 | const pair = (k, v) => { | |
| 391 | html("dt", null, k, facts); | |
| 392 | html("dd", null, v, facts); | |
| 393 | }; | |
| 394 | pair("Length", duration(t.ms)); | |
| 395 | pair("Ended", t.reason); | |
| 396 | pair("Waited", finite(t.waitMs) ? duration(t.waitMs) : "no wait"); | |
| 397 | pair("Releases", fmt(t.releases)); | |
| 398 | pair("Snap-backs", fmt(t.snapBacks)); | |
| 399 | pair("p95 settle error", fmt(t.trackingP95Deg, "°")); | |
| 400 | pair("p95 time to arrive", fmt(t.arriveP95Ms, "ms")); | |
| 401 | pair("p95 command → motion", fmt(t.latencyP95Ms, "ms")); | |
| 402 | pair("Commit", t.commit.slice(0, 12)); | |
| 403 | if (t.byCause) pair("By cause", Object.entries(t.byCause).filter(([, n]) => n > 0).map(([c, n]) => `${c} ${n}`).join(", ") || "none"); | |
| 404 | // the events on the turn's timeline | |
| 405 | html("h3", null, "Timeline", body); | |
| 406 | const span = t.ms + 120_000, t0 = t.start - 60_000; | |
| 407 | const W = width(body); | |
| 408 | const root = svg("svg", { viewBox: `0 0 ${W} 60`, width: W, height: 60, class: "timeline", role: "img", "aria-label": "Events during the turn" }, body); | |
| 409 | const x = (ms) => L + ((ms - t0) / span) * (W - L - R); | |
| 410 | svg("rect", { x: x(t.start), y: 18, width: Math.max(2, x(t.start + t.ms) - x(t.start)), height: 16, class: "turnbar" }, root); | |
| 411 | for (const e of d.events) { | |
| 412 | const m = svg("line", { x1: x(e.at), x2: x(e.at), y1: 8, y2: 44, class: `mark ${e.kind}` }, root); | |
| 413 | svg("title", {}, m).textContent = `${e.kind}${e.detail ? ` ${e.detail}` : ""} at ${new Date(e.at).toLocaleTimeString()}`; | |
| 414 | } | |
| 415 | svg("text", { x: L, y: 56, class: "tick" }, root).textContent = new Date(t0).toLocaleTimeString(); | |
| 416 | svg("text", { x: W - R, y: 56, class: "tick", "text-anchor": "end" }, root).textContent = new Date(t0 + span).toLocaleTimeString(); | |
| 417 | const list = html("ul", "events", null, body); | |
| 418 | if (!d.events.length) html("li", "empty", "No STOP, jam, block or reconnect.", list); | |
| 419 | for (const e of d.events) html("li", null, `${new Date(e.at).toLocaleTimeString()} · ${e.kind}${e.detail ? ` (${e.detail})` : ""}`, list); | |
| 420 | if (t.hops && Object.keys(t.hops).length) { | |
| 421 | html("h3", null, "Latency by hop", body); | |
| 422 | const hl = html("ul", "events", null, body); | |
| 423 | for (const [h, v] of Object.entries(t.hops)) html("li", null, `${h}: p50 ${fmt(v.p50, "ms")}, p95 ${fmt(v.p95, "ms")}`, hl); | |
| 424 | } | |
| 425 | if (t.joints && Object.keys(t.joints).length) { | |
| 426 | html("h3", null, "Joints", body); | |
| 427 | const jl = html("ul", "events", null, body); | |
| 428 | for (const [j, v] of Object.entries(t.joints)) html("li", null, `${j.replace(/_/g, " ")}: p95 error ${fmt(v.errorP95Deg, "°")}, p95 arrive ${fmt(v.arriveP95Ms, "ms")}`, jl); | |
| 429 | } | |
| 430 | } catch (err) { | |
| 431 | body.replaceChildren(html("p", "empty", `Could not load that turn: ${err.message}`)); | |
| 432 | } | |
| 433 | } | |
| 434 | ||
| 435 | for (const b of document.querySelectorAll("[data-range]")) { | |
| 436 | b.addEventListener("click", () => { | |
| 437 | state.range = b.dataset.range; | |
| 438 | state.custom = null; | |
| 439 | for (const o of document.querySelectorAll("[data-range]")) o.setAttribute("aria-pressed", String(o === b)); | |
| 440 | load(); | |
| 441 | }); | |
| 442 | } | |
| 443 | $("#compare").addEventListener("change", (ev) => { | |
| 444 | state.compare = ev.target.checked; | |
| 445 | load(); | |
| 446 | }); | |
| 447 | $("#reset").addEventListener("click", () => { | |
| 448 | state.custom = null; | |
| 449 | load(); | |
| 450 | }); | |
| 451 | $("#turn-close").addEventListener("click", () => $("#turn").close()); | |
| 452 | // a new width (a phone turned, a window resized) draws again at it | |
| 453 | let wide = document.documentElement.clientWidth, redraw = 0; | |
| 454 | addEventListener("resize", () => { | |
| 455 | if (document.documentElement.clientWidth === wide) return; | |
| 456 | wide = document.documentElement.clientWidth; | |
| 457 | clearTimeout(redraw); | |
| 458 | redraw = setTimeout(() => state.data && render(), 150); | |
| 459 | }); | |
| 460 | load(); | |
| 461 | })(); |
Software/so101/src/viewer.html +1
| @@ -356,6 +356,7 @@ | ||
| 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