diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..14224e1 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,10 @@ +# At the repository root because that is the only place git reads it from. +# +# The migration to a mono repo moved the Android runner into a subdirectory and +# its .gitmodules moved with it, where git never looks — so the gitlink at +# runner-android/third_party/llama.cpp survived with no URL registered against +# it, and `git submodule update --init` in a fresh clone silently found nothing +# to do. The path below is the only line that had to change. +[submodule "runner-android/third_party/llama.cpp"] + path = runner-android/third_party/llama.cpp + url = https://github.com/ggml-org/llama.cpp diff --git a/collector/README.md b/collector/README.md index 309e08d..665cd0e 100644 --- a/collector/README.md +++ b/collector/README.md @@ -89,6 +89,12 @@ so it never touches a real fleet's history. | `thermal` | device | The same benchmark run back to back for a quarter hour, so the answer is a curve: does the cold number survive the device getting warm | | `cold-start` | host | Launch the installed build from cold, warm and hot; p50 and p95 per state, per device | | `drain` / `soak` | host | Battery curve under a replayed GPX track; and whether a runner is still alive hours later | +| `build` | machine | Check out a ref, build it, publish the product as an artifact the next `install` can name | +| `speech-eval` / `embed-eval` | device | Word error rate and real-time factor for on-device transcription; recall-at-k and throughput for on-device embeddings | +| `vantage` | device, machine | DNS, connect, TLS and TTFB to your own sites from wherever each agent is, so "the site is slow" and "my wifi is slow" stop being the same answer | +| `locale-shots` | host | A screenshot flow under every locale, including RTL, bundled as a store-ready contact sheet | +| `app-soak` / `a11y-audit` | host | Memory, jank and crashes over hours; and the accessibility tree diffed against a baseline at the largest dynamic type | +| `self-check` | host, machine | The fleet inspects its own hosts: disk, tool versions, clock drift, agents actually loaded | | `archive` / `digest` | host | Pull store reviews and Search Console data; then have the shelf summarize its own reviews using its own models | Full reference, including every parameter and the failure modes worth knowing: diff --git a/collector/dash/src/api.ts b/collector/dash/src/api.ts index 8104497..e6fb67c 100644 --- a/collector/dash/src/api.ts +++ b/collector/dash/src/api.ts @@ -128,7 +128,11 @@ export type Job = { job_id: string; workload: string; executor: "device" | "host"; - status: "queued" | "claimed" | "done" | "failed" | "cancelled"; + status: "waiting" | "queued" | "claimed" | "done" | "failed" | "cancelled"; + /** Job ids this one waits for; it stays `waiting` until they are all done. */ + depends_on?: string[] | null; + /** Whether a higher-priority job may ask this one to checkpoint and step aside. */ + preemptible?: boolean; claimed_by: string | null; attempts: number; max_attempts: number; diff --git a/collector/dash/src/app.tsx b/collector/dash/src/app.tsx index 3f6b244..f434d3a 100644 --- a/collector/dash/src/app.tsx +++ b/collector/dash/src/app.tsx @@ -2,7 +2,7 @@ import { useKeyboard, SHORTCUTS } from "./keys.js"; import { TokenBanner } from "./TokenBanner.js"; import { useLiveState } from "./live.js"; import { match, useRoute } from "./router.js"; -import { Glyph } from "./icons.js"; +import { Glyph, Icon, NAV_ICON } from "./icons.js"; import { Link, Panel } from "./ui.js"; import { Compose } from "./pages/Compose.js"; import { DeviceDetail } from "./pages/DeviceDetail.js"; @@ -14,6 +14,7 @@ import { Overview } from "./pages/Overview.js"; import { Schedules } from "./pages/Schedules.js"; import { AlertBanner, Alerts } from "./pages/Alerts.js"; import { Artifacts } from "./pages/Artifacts.js"; +import { Evals, EvalSetPage } from "./pages/Evals.js"; import { Events } from "./pages/Events.js"; import { Results } from "./pages/Results.js"; import { Visual } from "./pages/Visual.js"; @@ -25,6 +26,7 @@ const NAV = [ ["/devices", "Devices"], ["/jobs", "Jobs"], ["/results", "Results"], + ["/evals", "Evals"], ["/visual", "Visual"], ["/schedules", "Schedules"], ["/artifacts", "Artifacts"], @@ -82,6 +84,7 @@ function Router() { // Before /jobs/:id, or "new" would be read as a job id. if (match("/jobs/new", route)) return ; if (match("/results", route)) return ; + if (match("/evals", route)) return ; if (match("/visual", route)) return ; if (match("/artifacts", route)) return ; if (match("/events", route)) return ; @@ -93,6 +96,10 @@ function Router() { if (device) return ; const job = match("/jobs/:id", route); if (job) return ; + // After the literal /evals above, for the same reason /devices/new is before + // /devices/:id — a literal segment must never be read as an id. + const evalSet = match("/evals/:sha", route); + if (evalSet) return ; return ; @@ -134,11 +141,15 @@ export function App() { diff --git a/collector/dash/src/art.tsx b/collector/dash/src/art.tsx new file mode 100644 index 0000000..295696d --- /dev/null +++ b/collector/dash/src/art.tsx @@ -0,0 +1,127 @@ +// Empty-state drawings. +// +// A panel with nothing in it is a state worth drawing, not a gap to apologise +// for — and the four that matter are different states, not one "no data": +// the fleet being idle is good news, no results yet is a beginning, and no +// device matching a filter is a filter problem. Each gets its own picture and +// keeps the sentence the dashboard already printed underneath it. +// +// Same drawing language as icons.tsx: stroke on a flat ground, theme colours +// through CSS classes (never a hard-coded fill except the brand amber, which +// is a brand colour rather than a theme one), and no illustration bigger than +// the paragraph it introduces. +import type { ComponentChildren } from "preact"; + +function Art({ children, caption }: { children: ComponentChildren; caption: ComponentChildren }) { + return ( +
+ {children} +

{caption}

+
+ ); +} + +/** + * Nothing claimed — the shelf is powered and waiting. + * + * The middle phone's pulse draws every few seconds. Idle is not the same as + * dead, and a completely still drawing would say the wrong one. + */ +export function ArtIdle({ caption }: { caption: ComponentChildren }) { + return ( + + + + + + + + + + + + + + + + ); +} + +/** + * No device here — either the shelf is empty or the filters exclude everything. + * The enrolment QR pointing at an outline is the actual fix in both cases. + */ +export function ArtNoDevices({ caption }: { caption: ComponentChildren }) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +/** + * Nothing failed. A flat trace running into a tick — the same pulse the mark + * uses, ending well. + */ +export function ArtAllClear({ caption }: { caption: ComponentChildren }) { + return ( + + + + + + + + ); +} + +/** + * No results yet. Dotted bars in the accent: the chart that will be here, not + * a chart of nothing — a solid empty chart would read as a measurement of zero. + */ +export function ArtNoResults({ caption }: { caption: ComponentChildren }) { + return ( + + + + + + + + + + + ); +} diff --git a/collector/dash/src/icons.tsx b/collector/dash/src/icons.tsx index b24e5ac..210ae65 100644 --- a/collector/dash/src/icons.tsx +++ b/collector/dash/src/icons.tsx @@ -53,6 +53,40 @@ const PATHS = { ), + hammer: ( + <> + + + ), + waveform: ( + <> + + + ), + vector: ( + <> + + + + + ), + globe: ( + <> + + + + ), + translate: ( + <> + + + ), + accessibility: ( + <> + + + + ), stopwatch: ( <> @@ -125,6 +159,130 @@ const PATHS = { ), + + // --- one per navigation tab --- + // + // The nav used to be ten words that all looked alike at a glance, which on a + // phone (where the row scrolls and two labels are visible) made getting + // somewhere a reading exercise. Each of these is the screen's subject drawn + // literally: four tiles for the overview, a phone and a tablet for devices, + // a queue for jobs, bars for results. + overview: ( + <> + + + + + + ), + devices: ( + <> + + + + + ), + // Solid ticks on the left, dashed queue stretching right: work waiting. + jobs: ( + <> + + + + ), + results: ( + <> + + + + + + ), + // A target: an eval is a score against a known answer. + evals: ( + <> + + + + + ), + schedules: ( + <> + + + + + ), + events: ( + <> + + + + + + + ), + alerts: ( + <> + + + + ), + system: ( + <> + + + + + ), + + // --- job states --- + // + // The same circle at the same size for every state, so a column of them + // reads down cleanly: only what is inside the circle changes. Never used + // alone — the pill still says the word. + queued: , + claimed: ( + <> + + + + ), + done: ( + <> + + + + ), + failed: ( + <> + + + + ), + cancelled: ( + <> + + + + ), + retry: ( + <> + + + + ), + locked: ( + <> + + + + ), + download: ( + <> + + + + ), } satisfies Record; export type IconName = keyof typeof PATHS; @@ -195,6 +353,13 @@ const WORKLOAD_ICON: Record = { thermal: "thermal", "cold-start": "stopwatch", "self-check": "heartbeat", + build: "hammer", + "speech-eval": "waveform", + "embed-eval": "vector", + vantage: "globe", + "locale-shots": "translate", + "app-soak": "soak", + "a11y-audit": "accessibility", }; /** The workload's name with its icon, for job tables. */ @@ -208,3 +373,70 @@ export function Workload({ name, children }: { name: string; children?: Componen ); } + +/** + * Which icon a nav tab gets. Keyed by route, so a tab and its icon are + * declared in one place and a new route without an icon simply shows its word + * rather than the wrong picture. + */ +export const NAV_ICON: Record = { + "/": "overview", + "/devices": "devices", + "/jobs": "jobs", + "/results": "results", + "/evals": "evals", + "/visual": "visual", + "/schedules": "schedules", + "/artifacts": "archive", + "/events": "events", + "/alerts": "alerts", + "/system": "system", +}; + +/** + * A device, drawn as the object it is. + * + * The shelf view shows machines, not rows, so each one gets a silhouette: a + * phone, or a laptop for a desktop runner, dashed when it is a simulator. The + * screen carries two live facts at once — its fill is the device's status + * colour, and the pulse is drawn on it only while the device is actually + * online — so a shelf reads before any word on it does. + * + * `kind` comes from the reported OS rather than the API's `platform` field: + * platform is a two-value ios/android split (anything not iOS is called + * android), which would draw a MacBook running the machine runner as a phone. + */ +export function DeviceGlyph({ + kind, + status, + busy, + simulator, + size = 44, +}: { + kind: "phone" | "laptop"; + status: "online" | "stale" | "offline"; + busy?: boolean; + simulator?: boolean; + size?: number; +}) { + const cls = ["dev-glyph", status, busy ? "busy" : "", simulator ? "simulator" : ""].filter(Boolean).join(" "); + + if (kind === "laptop") + return ( + + ); + + return ( + + ); +} diff --git a/collector/dash/src/pages/Devices.tsx b/collector/dash/src/pages/Devices.tsx index 57ed975..f8d1bea 100644 --- a/collector/dash/src/pages/Devices.tsx +++ b/collector/dash/src/pages/Devices.tsx @@ -1,8 +1,9 @@ // The shelf. What is online, what it is doing, and how to get to it. import { useState } from "preact/hooks"; import { useApi, type Device, type DeviceList } from "../api.js"; -import { Icon } from "../icons.js"; -import { mutate, useMutation } from "../mutate.js"; +import { ArtNoDevices } from "../art.js"; +import { DeviceGlyph, Icon } from "../icons.js"; +import { mutate } from "../mutate.js"; import { refreshNames } from "../names.js"; import { useQuery } from "../router.js"; import { Filters, Link, Loaded, Panel, Pill, Search, Select, Stat, agoFrom } from "../ui.js"; @@ -101,6 +102,204 @@ export function Thermal({ state }: { state: string | null }) { ); } +/** Battery as a bar as well as a number, for the shelf cards. */ +function BatteryMeter({ pct, charging }: { pct: number | null; charging: boolean | null }) { + if (pct == null || pct < 0) return ; + const tone = pct < 15 && !charging ? "critical" : pct < 30 && !charging ? "low" : ""; + return ( + + + + + ); +} + +/** + * Which silhouette a device gets. + * + * Read from the reported OS, not from the API's `platform` field: platform is + * an ios/android split where anything not iOS is called android, so a MacBook + * running the machine runner would be drawn as a phone. + */ +function glyphKind(device: Device): "phone" | "laptop" { + const os = String(device.descriptor.os ?? "").toLowerCase(); + return /^(macos|darwin|linux|windows|win32)/.test(os) ? "laptop" : "phone"; +} + +function hardware(device: Device): string { + const d = device.descriptor; + const bits = [ + String(d.os ?? "?"), + d.soc ? String(d.soc) : null, + d.ram_mb ? `${Math.round(Number(d.ram_mb) / 1024)} GB` : null, + ].filter(Boolean); + return bits.join(" · "); +} + +/** One device, as an object on a shelf. */ +function DeviceCard({ device, onDone }: { device: Device; onDone: () => void }) { + return ( +
+ +
+
+ + +
+ {device.simulator && ( +
+ + simulator +
+ )} +
+ {String(device.descriptor.model ?? "?")} · {hardware(device)} +
+
+ + + {device.current_job ? ( + + {device.current_job} + + ) : ( + idle + )} + {device.lock && locked} +
+
+ + open + + {device.pools.join(", ") || "no pool"} + {agoFrom(device.last_seen)} +
+
+
+ ); +} + +/** + * Hardware first, simulators under it. + * + * The two are not the same kind of thing: a wedged physical phone needs + * someone to walk over to the shelf, a wedged simulator needs a `simctl`. They + * used to interleave in one id-sorted table, so the question "is any real + * device in trouble" meant reading every row. + */ +function Shelf({ devices, onDone }: { devices: Device[]; onDone: () => void }) { + const real = devices.filter((d) => !d.simulator); + const sims = devices.filter((d) => d.simulator); + return ( + <> + {real.length > 0 && ( +
+ {sims.length > 0 &&

Hardware

} +
+ {real.map((d) => ( + + ))} +
+
+ )} + {sims.length > 0 && ( +
+ {real.length > 0 &&

Simulators and emulators

} +
+ {sims.map((d) => ( + + ))} +
+
+ )} + + ); +} + +/** The original table, unchanged — still the right view for comparing columns. */ +function Table({ devices, onDone }: { devices: Device[]; onDone: () => void }) { + return ( +
+ + + + + + + + + + + + {devices.map((dev) => ( + + + + + + + + + + + ))} +
DeviceHardwarePoolsRunsBatteryThermalDoingLast seen
+ +
+ + open + {" "} + {" "} + {dev.simulator && ( + + + simulator + + )} +
+
+ {String(dev.descriptor.model ?? "?")} +
+ {String(dev.descriptor.os ?? "?")} + {dev.descriptor.soc ? ` · ${dev.descriptor.soc}` : ""} + {dev.descriptor.ram_mb ? ` · ${Math.round(Number(dev.descriptor.ram_mb) / 1024)} GB` : ""} +
+
{dev.pools.join(", ") || "—"} + {dev.capabilities === null ? ( + + all + + ) : ( + dev.capabilities.join(", ") || "—" + )} + + + + + + {dev.current_job ? ( + + {dev.current_job} + + ) : ( + idle + )} + {dev.lock &&
locked
} +
+ {agoFrom(dev.last_seen)} + {dev.last_net &&
{dev.last_net}
} +
+
+ ); +} + export function Devices() { const [q, setQuery] = useQuery(); const status = q.get("status") ?? ""; @@ -108,6 +307,10 @@ export function Devices() { const platform = q.get("platform") ?? ""; const search = q.get("q") ?? ""; const hideSims = q.get("simulator") === "false"; + // The shelf leads because the fleet is objects on a rack and that is the + // question this screen usually answers. The table is one click away and, + // because the choice lives in the URL, a link to either stays that view. + const view = q.get("view") === "table" ? "table" : "shelf"; const params = new URLSearchParams(); for (const [k, v] of [ @@ -134,11 +337,14 @@ export function Devices() { + add a device - + {(d) => ( <> - setQuery({ status: null, pool: null, platform: null, q: null, simulator: null })}> + setQuery({ status: null, pool: null, platform: null, q: null, simulator: null })} + > setQuery({ pool: v })} /> setQuery({ simulator: (e.target as HTMLInputElement).checked ? "false" : null })} /> hardware only + +
+ + +
{d.devices.length === 0 ? ( -

No device matches these filters.

+ + ) : view === "shelf" ? ( + ) : ( -
- - - - - - - - - - - - {d.devices.map((dev) => ( - - - - - - - - - - - ))} -
DeviceHardwarePoolsRunsBatteryThermalDoingLast seen
- -
- - open - {" "} - {" "} - {dev.simulator && ( - - - simulator - - )} -
-
- {String(dev.descriptor.model ?? "?")} -
- {String(dev.descriptor.os ?? "?")} - {dev.descriptor.soc ? ` · ${dev.descriptor.soc}` : ""} - {dev.descriptor.ram_mb ? ` · ${Math.round(Number(dev.descriptor.ram_mb) / 1024)} GB` : ""} -
-
{dev.pools.join(", ") || "—"} - {dev.capabilities === null ? ( - - all - - ) : ( - dev.capabilities.join(", ") || "—" - )} - - - - - - {dev.current_job ? ( - - {dev.current_job} - - ) : ( - idle - )} - {dev.lock &&
locked
} -
- {agoFrom(dev.last_seen)} - {dev.last_net &&
{dev.last_net}
} -
-
+ )} diff --git a/collector/dash/src/pages/Evals.tsx b/collector/dash/src/pages/Evals.tsx new file mode 100644 index 0000000..7a6cc8d --- /dev/null +++ b/collector/dash/src/pages/Evals.tsx @@ -0,0 +1,593 @@ +// Evals as a page, so the next one needs no hand-written report. +// +// The plant-ID writeup in evals/ was typed by hand because the rows behind it +// could not be queried: top-1 accuracy lived in `decode_tok_s`, p50 in +// `ttft_ms`, and top-5 and p95 lived nowhere at all. Those rows are the reason +// this page exists, so this page must not do to them what the old dashboard +// did — hide them. Every row that cannot be pivoted is COUNTED, ATTRIBUTED and +// LISTED, above the table it is missing from. +// +// House style is Results.tsx: useApi + Loaded + Panel, a Csv button on the +// panel that holds the rows it exports. The Markdown button is the new one, and +// is the point of the page: it emits the table a writeup would have opened with, +// caveats included, so nobody starts the next eval report from a blank file. +import { useApi } from "../api.js"; +import { Bars } from "../chart.js"; +import { Link, Loaded, Panel, Pill, agoFrom, clock, num, short } from "../ui.js"; + +// --- shapes (mirrors src/api/evals.ts) --- + +type Family = "vision" | "llm" | "speech" | "embed"; +type PerJoule = { value: number; unit: string; basis: string }; +type EnergyView = { + wh: number; + method: "plug" | "plug-shared" | "os"; + includes_charging: boolean | null; + source: "result" | "integrated"; + baseline_source: string | null; + note: string | null; +}; +type Cell = { + job_id: string; + device_id: string; + device_model: string | null; + simulator: boolean; + at: string | null; + metrics: Record; + energy: EnergyView | null; + per_joule: PerJoule | null; +}; +type ModelRow = { + key: string; + model: string | null; + quant: string | null; + backend: string | null; + accel: string | null; + cells: Record; +}; +type Excluded = { + job_id: string; + device_id: string; + at: string | null; + workload: string; + backend: string | null; + input_sha256: string | null; + reason: string; + present: string[]; +}; +type EvalSet = { + input_sha256: string; + family: Family; + headline: { key: string; label: string; unit: string; higherIsBetter: boolean }; + named_metrics: string[]; + workloads: string[]; + devices: { device_id: string; device_model: string | null; simulator: boolean }[]; + models: ModelRow[]; + runs: number; + latest_at: string | null; + excluded: number; + has_energy: boolean; +}; +type EvalsIndex = { + sets: { + input_sha256: string; family: Family; workloads: string[]; models: number; devices: number; + runs: number; latest_at: string | null; excluded: number; has_energy: boolean; + }[]; + excluded: { count: number; by_reason: { reason: string; count: number }[]; rows: Excluded[] }; + scanned: number; + energy_configured: boolean; +}; +type EvalSetResponse = { set: EvalSet | null; excluded: Excluded[]; energy_configured: boolean }; + +// --- exports --- + +function save(name: string, text: string, mime: string) { + const url = URL.createObjectURL(new Blob([text], { type: mime })); + const a = document.createElement("a"); + a.href = url; + a.download = name; + a.click(); + URL.revokeObjectURL(url); +} + +/** Same CSV writer Results.tsx uses, over the rows the page is showing. */ +function downloadCsv(name: string, rows: Record[]) { + if (rows.length === 0) return; + const cols = [...new Set(rows.flatMap((r) => Object.keys(r)))]; + const esc = (v: unknown) => { + const s = v == null ? "" : typeof v === "object" ? JSON.stringify(v) : String(v); + return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; + }; + save(`${name}.csv`, [cols.join(","), ...rows.map((r) => cols.map((c) => esc(r[c])).join(","))].join("\n"), "text/csv"); +} + +const Csv = ({ name, rows }: { name: string; rows: Record[] }) => ( + +); + +/** Flat rows for CSV: one line per (model, device) cell. */ +function flatten(set: EvalSet): Record[] { + const out: Record[] = []; + for (const m of set.models) + for (const d of set.devices) { + const c = m.cells[d.device_id]; + if (!c) continue; + out.push({ + input_sha256: set.input_sha256, + model: m.model, quant: m.quant, backend: m.backend, accel: m.accel, + device_id: c.device_id, device_model: c.device_model, simulator: c.simulator, + job_id: c.job_id, at: c.at, + ...c.metrics, + energy_wh: c.energy?.wh ?? null, + energy_method: c.energy?.method ?? null, + energy_source: c.energy?.source ?? null, + includes_charging: c.energy?.includes_charging ?? null, + per_joule: c.per_joule?.value ?? null, + per_joule_unit: c.per_joule?.unit ?? null, + }); + } + return out; +} + +/** + * The Markdown a writeup would have opened with. + * + * It carries the caveats, not just the numbers. An export that emitted a clean + * table and left the excluded rows behind would let the next report repeat the + * original mistake with less typing, which is worse than no export at all. + */ +function markdown(set: EvalSet, excluded: Excluded[], energyConfigured: boolean): string { + const L: string[] = []; + const cols = set.devices; + L.push(`# Eval set \`${set.input_sha256.slice(0, 12)}…\``); + L.push(""); + L.push(`- **Input artifact:** \`${set.input_sha256}\` — every device below scored these exact bytes.`); + L.push(`- **Workload:** ${set.workloads.join(", ")} (${set.family})`); + L.push(`- **Runs:** ${set.runs} across ${set.models.length} model(s) and ${cols.length} device(s)`); + L.push(`- **Latest run:** ${set.latest_at ?? "—"}`); + L.push(`- **Generated from** \`/api/evals/${set.input_sha256}\` — this table is a query, not a transcription.`); + L.push(""); + + const anyPerJoule = set.models.some((m) => Object.values(m.cells).some((c) => c.per_joule)); + for (const key of set.named_metrics) { + const present = set.models.some((m) => Object.values(m.cells).some((c) => c.metrics[key] != null)); + if (!present) continue; + L.push(`## ${key}`); + L.push(""); + L.push(`| Model | ${cols.map((d) => d.device_model ?? d.device_id).join(" | ")} |`); + L.push(`|---|${cols.map(() => "---:").join("|")}|`); + for (const m of set.models) { + const name = `${m.model ?? "?"}${m.quant ? ` ${m.quant}` : ""} (${m.backend ?? "?"}${m.accel ? `/${m.accel}` : ""})`; + L.push(`| ${name} | ${cols.map((d) => fmt(m.cells[d.device_id]?.metrics[key])).join(" | ")} |`); + } + L.push(""); + } + + if (anyPerJoule) { + L.push("## Energy"); + L.push(""); + L.push(`| Model | Device | Wh | Method | Per joule | Includes charging |`); + L.push("|---|---|---:|---|---:|---|"); + for (const m of set.models) + for (const d of cols) { + const c = m.cells[d.device_id]; + if (!c?.energy) continue; + L.push( + `| ${m.model ?? "?"} | ${c.device_model ?? c.device_id} | ${c.energy.wh.toFixed(3)} | ${c.energy.method} | ${ + c.per_joule ? `${c.per_joule.value.toPrecision(3)} ${c.per_joule.unit}` : "—" + } | ${c.energy.includes_charging === null ? "unknown" : c.energy.includes_charging ? "yes" : "no"} |`, + ); + } + L.push(""); + L.push( + "> Watt-hours are measured at the wall, above the pool's idle baseline, over the job's claim window. " + + "`plug-shared` figures are the whole pool's and must not be divided per device. " + + "Where *includes charging* is yes, the figure includes energy that went into the battery rather than into the work — " + + "an idle baseline removes the charger's standing draw, not its charging current.", + ); + L.push(""); + } else if (!energyConfigured) { + L.push("## Energy"); + L.push(""); + L.push("_No pool in `power.json` declares `read_url` + `watts_path` + `energy_method`, so no run here has energy data._"); + L.push(""); + } + + L.push("## Rows this table excludes"); + L.push(""); + if (excluded.length === 0) { + L.push("None — every result in scope carried named metrics."); + } else { + L.push( + `**${excluded.length} result row(s) are not in the tables above.** They are listed rather than dropped: ` + + "rows that predate the named metric fields are exactly why this page exists, and hiding them would repeat " + + "the mistake that made the last eval a hand-written document.", + ); + L.push(""); + L.push("| Job | Device | Workload | Metrics it does carry | Why |"); + L.push("|---|---|---|---|---|"); + for (const e of excluded) + L.push(`| \`${e.job_id}\` | ${e.device_id} | ${e.workload} | ${e.present.join(", ") || "none"} | ${e.reason} |`); + } + L.push(""); + return L.join("\n"); +} + +const fmt = (v: number | null | undefined) => (typeof v === "number" ? num(v) : "—"); + +const Markdown = ({ set, excluded, energyConfigured }: { set: EvalSet; excluded: Excluded[]; energyConfigured: boolean }) => ( + +); + +// --- excluded rows --- + +function ExcludedPanel({ rows, scope }: { rows: Excluded[]; scope: string }) { + if (rows.length === 0) + return ( + +

None — every {scope} carried named metrics and named an eval set.

+
+ ); + return ( + []} />}> +

+ These rows are real results that this page cannot pivot, and they are listed rather than filtered out.{" "} + Rows that predate the named metric fields are the reason this page exists — the plant-ID eval + had to be written by hand precisely because its numbers sat in fields that mean something else. Dropping them + quietly would make the same mistake with a nicer table. +

+
+
+ + + + + + + + + {rows.map((e) => ( + + + + + + + + + ))} +
JobDeviceWorkloadCarriesWhy it is outWhen
+ + {short(e.job_id)} + + + + {short(e.device_id, 22)} + + + {e.workload} + {e.backend ? ` · ${e.backend}` : ""} + {e.present.length ? e.present.map((p) => {p} ) : nothing}{e.reason}{clock(e.at)}
+ +
+ ); +} + +// --- energy caveat, stated once per page that shows a watt-hour --- + +function EnergyNote({ set }: { set: EvalSet }) { + const cells = set.models.flatMap((m) => Object.values(m.cells)).filter((c) => c.energy); + if (cells.length === 0) return null; + const shared = cells.some((c) => c.energy!.method === "plug-shared"); + const charging = cells.filter((c) => c.energy!.includes_charging === true).length; + const unknown = cells.filter((c) => c.energy!.includes_charging === null).length; + const estimated = cells.filter((c) => c.energy!.baseline_source === "estimated-p10").length; + + return ( + +

+ Energy is measured at the wall, above the pool's idle baseline, across the job's claim window. + It is not the energy the model used. +

+ {shared && ( +

+ Some of these pools are plug-shared: several devices sit behind one plug, so the figure is the{" "} + whole pool's. Do not divide it by the number of devices — the devices are not identical, the + idle ones are already inside the baseline, and a per-device number reached by division would be indistinguishable + from a measured one. +

+ )} + {charging > 0 && ( +

+ {charging} of {cells.length} runs were charging while they ran. A phone on a plug is also + filling its battery, and subtracting an idle baseline removes the charger's standing draw but not its charging + current. Those watt-hours include battery replenishment as well as the work. +

+ )} + {unknown > 0 && ( +

+ {unknown} run(s) had no beacon covering the window, so whether the device was also charging is unknown. +

+ )} + {estimated > 0 && ( +

+ {estimated} run(s) used an estimated idle baseline (the 10th percentile of the pool's own samples) + because power.json declares no idle_watts for that pool. Measure it with the shelf + quiet and the figures get better. +

+ )} +
+ ); +} + +// --- one eval set --- + +export function EvalSetPage({ sha }: { sha: string }) { + const state = useApi(`/api/evals/${encodeURIComponent(sha)}`, ["result"], 60_000); + + return ( + <> +

Eval set

+ +

+ ← all eval sets · input artifact {sha} +

+
+ + {(d) => + !d.set ? ( + <> + +

+ No pivotable result carries this input_sha256. + {d.excluded.length > 0 && " Every row that names it is listed below."} +

+
+ + + ) : ( + <> + + {" "} + + + } + > +

+ {d.set.workloads.join(", ")} · {d.set.family} · {d.set.runs} runs · latest{" "} + {agoFrom(d.set.latest_at)} +

+

+ {d.set.headline.label} — {d.set.headline.higherIsBetter ? "higher is better" : "lower is better"} +

+ + d.set!.devices + .filter((dev) => m.cells[dev.device_id]) + .map((dev) => ({ + label: `${m.model ?? "?"}${m.quant ? ` ${m.quant}` : ""} · ${dev.device_model ?? dev.device_id}`, + value: m.cells[dev.device_id].metrics[d.set!.headline.key] ?? null, + muted: dev.simulator, + })), + )} + unit={d.set.headline.unit} + /> +
+ + {d.set.named_metrics + .filter((key) => d.set!.models.some((m) => Object.values(m.cells).some((c) => c.metrics[key] != null))) + .map((key) => ( + +
+ + + + {d.set!.devices.map((dev) => ( + + ))} + + {d.set!.models.map((m) => ( + + + {d.set!.devices.map((dev) => { + const c = m.cells[dev.device_id]; + return ( + + ); + })} + + ))} +
Model + {dev.device_model ?? dev.device_id} + {dev.simulator &&
simulator
} +
+ {m.model ?? "?"} + {m.quant ? ` ${m.quant}` : ""} +
+ {m.backend ?? "?"} + {m.accel ? ` · ${m.accel}` : ""} +
+
+ {c ? ( + {fmt(c.metrics[key])} + ) : ( + + not run + + )} +
+
+
+ ))} + + + + {d.set.has_energy ? ( + +
+ + + + + + + + + + {d.set.models.flatMap((m) => + d.set!.devices.map((dev) => { + const c = m.cells[dev.device_id]; + if (!c?.energy) return null; + return ( + + + + + + {/* Omitted cleanly where the run has no countable + work: a rate times a duration would look like a + measurement without being one. */} + + + + ); + }), + )} +
ModelDeviceWhMethodPer jouleCharging
+ {m.model ?? "?"} + {m.quant ? ` ${m.quant}` : ""} + + {c.device_model ?? c.device_id} + {c.energy.wh.toFixed(3)} + {c.energy.method} +
{c.energy.source === "result" ? "from the runner" : "integrated here"}
+
+ {c.per_joule ? ( + + {c.per_joule.value.toPrecision(3)} {c.per_joule.unit} + + ) : ( + + — + + )} + + {c.energy.includes_charging === null + ? "unknown" + : c.energy.includes_charging + ? "yes — includes battery" + : "no"} +
+
+
+ ) : ( + +

+ {d.energy_configured + ? "No run in this set has energy data: either the runner stored none and the plug had too few samples inside the job window, or the device's pool declares no energy_method." + : "No pool in power.json declares read_url, watts_path and energy_method, so nothing here has energy data. See power.example.json."} +

+
+ )} + + + + ) + } +
+ + ); +} + +// --- the index --- + +export function Evals() { + const state = useApi("/api/evals", ["result"], 60_000); + + return ( + <> +

Evals

+ +

+ An eval set is one params.input_sha256 — the exact bytes every device scored. + Results group by model and pivot across devices, which is the shape a writeup wants, so the next eval report + starts from the Markdown button rather than from a blank file. +

+
+ + {(d) => ( + <> + {d.sets.length === 0 ? ( + +

+ No eval set has a pivotable result yet. + {d.excluded.count > 0 && ` ${d.excluded.count} row(s) were found but could not be pivoted — see below.`} +

+
+ ) : ( + []} />}> +
+ + + + + + + + + + + + {d.sets.map((s) => ( + + + + + + + + + + + ))} +
Input artifactWorkloadModelsDevicesRunsExcludedEnergyLatest
+ + {s.input_sha256.slice(0, 16)}… + + + {s.workloads.join(", ")} +
{s.family}
+
{s.models}{s.devices}{s.runs}{s.excluded > 0 ? {s.excluded} : "0"}{s.has_energy ? "yes" : —}{clock(s.latest_at)}
+
+
+ )} + + {d.excluded.count > 0 && ( + +

+ Counted here rather than filtered away. The breakdown:{" "} + {d.excluded.by_reason.map((r) => `${r.count} × ${r.reason}`).join(", ")}. Open a set to see which rows, + and what each one does carry. +

+
+ )} + + + + )} +
+ + ); +} diff --git a/collector/dash/src/pages/JobDetail.tsx b/collector/dash/src/pages/JobDetail.tsx index 2f07daa..66aaf48 100644 --- a/collector/dash/src/pages/JobDetail.tsx +++ b/collector/dash/src/pages/JobDetail.tsx @@ -6,6 +6,12 @@ import { useDeviceNames } from "../names.js"; import { Actions, Button, ConfirmButton, CopyId, DeviceName, ErrorBox, Json, Link, Loaded, Panel, Pill, Stat, bytes, clock, duration, num } from "../ui.js"; import { JobRows, LeaseCell } from "./Jobs.js"; +// The collector gained a 'waiting' status with dependency chains. `Job` in +// api.ts still describes the older set, and that type is shared with every +// other page, so the status is read as a plain string here rather than +// pretending the union is complete. +const statusOf = (job: { status: string }) => job.status as string; + /** One line that says what a result row actually reported, per workload. */ function summarize(payload: Record): string { const m = payload.metrics; @@ -57,6 +63,51 @@ function Results({ results, names }: { results: ResultRow[]; names: Record + Waiting on{" "} + {deps.map((d, i) => ( + + {i > 0 && ", "} + + {d} + + + ))} + .{" "} + + {statusOf(job) === "waiting" + ? "It is queued the moment the last of them is done, with any ${jobs.…} references in its spec filled in from their final rows. If one fails or is cancelled, this job fails with it rather than waiting forever." + : "The chain has already resolved; this is what it hung off."} + +

+ ); +} + +/** Whether the job will stand aside for higher-priority work. */ +function Preemptible({ job }: { job: Detail }) { + if (job.spec?.preemptible !== true) return null; + return ( +

+ Preemptible.{" "} + + {statusOf(job) === "claimed" + ? "If a queued job with a strictly higher priority could take this device, the next beacon is answered with preempt:true. The runner checkpoints and posts a final row; the job goes back on the queue with its progress and keeps the attempt — raise something above it and watch." + : "When it runs, higher-priority work can ask it to stand aside; it is requeued with its progress rather than failed."} + +

+ ); +} + function JobActions({ job, onDone }: { job: Detail; onDone: () => void }) { const cancel = useMutation(async () => { const r = await mutate<{ note: string }>("POST", `/api/jobs/${encodeURIComponent(job.job_id)}/cancel`, {}); @@ -81,7 +132,12 @@ function JobActions({ job, onDone }: { job: Detail; onDone: () => void }) { return r; }); - const stoppable = job.status === "queued" || job.status === "claimed"; + const stoppable = ["queued", "claimed", "waiting"].includes(statusOf(job)); + // Priority is worth changing for as long as the job has not run. On a claimed + // job it is not cosmetic either: raising a *queued* job above a running + // preemptible one is what asks the runner to stand aside, so the controls + // stay live rather than disappearing the moment work starts. + const reorderable = stoppable; return ( <> @@ -98,9 +154,13 @@ function JobActions({ job, onDone }: { job: Detail; onDone: () => void }) { - {job.status === "queued" && ( + {reorderable && ( <> - {dryRun.result && ( void purge.go()} > diff --git a/collector/dash/src/style.css b/collector/dash/src/style.css index 69b2e21..f90f351 100644 --- a/collector/dash/src/style.css +++ b/collector/dash/src/style.css @@ -155,6 +155,11 @@ td.num { text-align: right; font-variant-numeric: tabular-nums; } font-size: 0.72rem; font-weight: 550; border: 1px solid transparent; white-space: nowrap; } .pill.queued { color: var(--ink-dim); border-color: var(--line); } +/* Waiting is not queued: the queue is not looking at it yet, and the reason is + another job rather than a busy fleet. Same palette as queued so it reads as + the same family, but dashed, so a parked chain is distinguishable at a glance + from work the fleet is failing to pick up. */ +.pill.waiting { color: var(--ink-dim); border-color: var(--line); border-style: dashed; } /* Cancelled is deliberately not styled like failed: a person stopped it, nothing went wrong. */ .pill.cancelled { color: var(--ink-faint); border-color: var(--line); text-decoration: line-through; } @@ -183,6 +188,13 @@ td.num { text-align: right; font-variant-numeric: tabular-nums; } .skeleton { color: var(--ink-faint); font-size: 0.85rem; } +/* Announced to a screen reader, never painted. Used by the skeletons, whose + blocks are decoration — the sentence they replace still has to be said. */ +.visually-hidden { + position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; + overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; +} + .stub { color: var(--ink-dim); font-size: 0.88rem; max-width: 42rem; } .stub code { background: var(--panel-2); padding: 0.1rem 0.3rem; border-radius: 4px; @@ -505,3 +517,229 @@ pre.cmd { .vr-compare figure { margin: 0; } .vr-compare figcaption { font-size: 0.72rem; color: var(--ink-dim); text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.3rem; } .vr-compare img { max-width: 100%; border: 1px solid var(--line); border-radius: 6px; background: var(--panel-2); } + +/* ========================================================================== + Motion + ========================================================================== + + Six movements, each with one job, and one rule: a loop runs only while + something is genuinely in progress. Everything else plays once, on mount, + and stops — a dashboard you leave open all day must not be animating at + 3pm because it animated at 9am. + + Durations are short enough to read as feedback rather than as choreography: + 240ms for a panel arriving, 180ms for a number changing. The one long + animation is the lease drain, whose duration is the lease itself. + + Every loop and transition below is switched off under + prefers-reduced-motion at the end of this section; each piece is authored + so its resting frame is the correct static rendering, not an empty one. */ + +/* --- panel enter --- */ + +/* Plays when a panel mounts: on first load and on navigation. It does NOT + replay on a live refresh, because keeps its children mounted while + refetching — a number updating in place must not re-animate the panel that + contains it. */ +@keyframes panel-in { + from { opacity: 0; transform: translateY(6px); } + to { opacity: 1; transform: none; } +} + +main .panel { + animation: panel-in 240ms cubic-bezier(0.2, 0.8, 0.2, 1) both; +} + +/* Staggered top to bottom, so the page assembles in reading order. Capped at + five: beyond that the last panel would arrive late enough to feel slow. */ +main .panel:nth-of-type(2) { animation-delay: 40ms; } +main .panel:nth-of-type(3) { animation-delay: 80ms; } +main .panel:nth-of-type(4) { animation-delay: 120ms; } +main .panel:nth-of-type(5) { animation-delay: 160ms; } +main .panel:nth-of-type(n + 6) { animation-delay: 160ms; } + +/* --- value swap --- */ + +/* A stat tile whose number changed on a live event. The old value leaves + upward and the new one arrives from below, so a change is visible without a + flash — and without moving the tile, which would shove the row around. */ +@keyframes value-in { + from { opacity: 0; transform: translateY(4px); } + to { opacity: 1; transform: none; } +} + +.stat.changed b { animation: value-in 180ms ease both; } + +/* --- skeleton --- */ + +/* Shape-true placeholders: a stat row gets tiles, a table gets its header and + three rows. The sweep is the only thing that says "still loading", so it is + the only thing that moves. */ +@keyframes skeleton-sweep { + from { background-position: 200% 0; } + to { background-position: -200% 0; } +} + +.sk { + border-radius: 4px; + background: linear-gradient( + 90deg, + var(--panel-2) 0%, + color-mix(in srgb, var(--panel-2) 82%, var(--ink)) 45%, + var(--panel-2) 60% + ); + background-size: 200% 100%; + animation: skeleton-sweep 1.6s linear infinite; +} + +.sk-stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(7.5rem, 1fr)); gap: 0.75rem; } +.sk-stat { height: 4.1rem; border-radius: 6px; } +.sk-rows { display: flex; flex-direction: column; gap: 0.55rem; } +.sk-row { height: 1.05rem; } +.sk-head { height: 0.7rem; width: 40%; margin-bottom: 0.7rem; } + +/* --- lease drain --- */ + +/* The bar is re-rendered every second from lease_deadline rather than every + 30s from the poll, so this transition only has to smooth the correction + when a renewal lands — not fake a countdown the page cannot see. */ +.lease-bar i { transition: width 900ms linear, background-color 300ms ease; } + +/* ========================================================================== + Device shelf + ========================================================================== */ + +/* The shelf is what the fleet actually is: objects on a rack, not rows in a + table. Hardware sits on the top shelf and simulators below it, because + "which physical phone is wedged" is the question the table answered slowly. + The table view is still one click away and keeps its place in the URL. */ + +.shelf { display: grid; grid-template-columns: repeat(auto-fill, minmax(19rem, 1fr)); gap: 0.9rem; } + +/* A real shelf edge under each row of devices. Thicker than a table rule and + in --line, so it reads as a plank rather than as a cell border. */ +.shelf-row { padding-bottom: 0.9rem; border-bottom: 3px solid var(--line); margin-bottom: 1.4rem; } +.shelf-row:last-child { margin-bottom: 0; } +.shelf-row > h3.sub { margin-top: 0; } + +.dev-card { + background: var(--panel-2); + border: 1px solid var(--line); + border-radius: 8px; + padding: 0.9rem 1rem; + display: flex; + gap: 1rem; + align-items: flex-start; + transition: border-color 160ms ease, transform 160ms ease; +} +.dev-card:hover { border-color: var(--ink-faint); transform: translateY(-1px); } +.dev-body { flex: 1; min-width: 0; } +.dev-top { display: flex; align-items: baseline; justify-content: space-between; gap: 0.5rem; } +.dev-hw { font-size: 0.8rem; color: var(--ink-dim); margin-top: 0.35rem; } +.dev-facts { display: flex; flex-wrap: wrap; align-items: center; gap: 0.35rem 0.9rem; margin-top: 0.55rem; font-size: 0.8rem; } +.dev-doing { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%; } + +/* The silhouette carries battery as a fill level, so a shelf of phones reads + as a shelf of phones — charge visible before any number is. */ +.dev-glyph { flex: none; display: block; } +.dev-glyph .body { fill: var(--panel); stroke: var(--ink-faint); stroke-width: 1.5; } +.dev-glyph.simulator .body { stroke-dasharray: 3 2.4; } +.dev-glyph .screen { fill: var(--line); } +.dev-glyph.online .screen { fill: var(--ok); opacity: 0.85; } +.dev-glyph.stale .screen { fill: var(--warn); opacity: 0.3; } +.dev-glyph .pulse { stroke: var(--bg); stroke-width: 1.5; fill: none; } +.dev-glyph .chin { stroke: var(--ink-faint); stroke-width: 1.5; } + +/* A device that is running a job breathes. This is the only loop on the + Devices screen, and it stops the moment the job does. */ +@keyframes screen-breathe { 0%, 100% { opacity: 0.55; } 50% { opacity: 1; } } +.dev-glyph.busy .screen { animation: screen-breathe 2.4s ease-in-out infinite; } + +/* Inline battery meter beside the percentage. */ +.batt-track { width: 34px; height: 4px; border-radius: 2px; background: var(--panel); overflow: hidden; display: inline-block; vertical-align: middle; } +.batt-track i { display: block; height: 100%; background: var(--ok); } +.batt-track.low i { background: var(--warn); } +.batt-track.critical i { background: var(--bad); } + +/* Shelf/table toggle: a segmented control, not two buttons — the two views + are one setting with two values. */ +.segmented { display: inline-flex; border: 1px solid var(--line); border-radius: 5px; overflow: hidden; font-size: 0.78rem; } +.segmented button { + font: inherit; font-size: 0.78rem; padding: 0.25rem 0.6rem; cursor: pointer; + background: none; border: 0; color: var(--ink-dim); +} +.segmented button[aria-pressed="true"] { background: var(--panel-2); color: var(--ink); font-weight: 550; } +.segmented button:hover:not([aria-pressed="true"]) { color: var(--ink); } + +/* ========================================================================== + Empty states + ========================================================================== */ + +/* An empty panel is a state worth drawing, not a gap to apologise for. The + copy is unchanged — the drawing sits above the same sentence the dashboard + printed before. */ +.empty-art { display: flex; flex-direction: column; align-items: center; gap: 0.75rem; padding: 1.4rem 0 1rem; text-align: center; } +.empty-art svg { display: block; max-width: 260px; width: 100%; height: auto; } +.empty-art p { margin: 0; color: var(--ink-dim); font-size: 0.86rem; } + +.art-shelf { stroke: var(--line); stroke-width: 3; stroke-linecap: round; } +.art-body { fill: var(--panel-2); stroke: var(--ink-faint); stroke-width: 1.5; } +.art-body.ghost { fill: none; stroke-dasharray: 4 3; } +.art-screen { fill: var(--bg); } +.art-flat { stroke: var(--line); stroke-width: 1.5; stroke-linecap: round; fill: none; } +.art-pulse { stroke: #e3a44a; stroke-width: 1.75; stroke-linecap: round; stroke-linejoin: round; fill: none; } +.art-ok { stroke: var(--ok); stroke-width: 2; fill: none; } +.art-bar { stroke: var(--accent); stroke-width: 1.5; stroke-dasharray: 3 3; fill: none; } + +/* One phone blips every few seconds: the fleet is idle, not dead. */ +@keyframes art-blip { 0%, 72% { stroke-dashoffset: 60; } 88%, 100% { stroke-dashoffset: 0; } } +.art-pulse.blip { stroke-dasharray: 60; animation: art-blip 6s cubic-bezier(0.4, 0, 0.2, 1) infinite; } + +/* The tick draws once per visit, then stays drawn. */ +@keyframes art-check { from { stroke-dashoffset: 40; } to { stroke-dashoffset: 0; } } +.art-ok.check { stroke-dasharray: 40; animation: art-check 700ms cubic-bezier(0.2, 0.8, 0.2, 1) 200ms both; } + +/* ========================================================================== + Nav icons + ========================================================================== */ + +/* The icons are for the narrow nav, and only for it. + Eleven tabs and their words already fill the 76rem shell to within a few + pixels, so an icon on every desktop tab buys scannability the desktop nav + did not need and pays for it by making the row scroll — a regression. Below + the phone breakpoint the row is full width, already scrolls, and shows three + or four tabs at a time: that is where a picture beside the word is worth its + space, so that is the only place it appears. */ +.nav a { display: inline-flex; align-items: center; } +.nav a .icon { display: none; color: var(--ink-faint); } +.nav a:hover .icon, .nav a[aria-current="page"] .icon { color: inherit; } + +@media (max-width: 720px) { + .nav a { gap: 0.3rem; padding: 0.3rem 0.5rem; } + .nav a .icon { display: block; } +} + +/* ========================================================================== + Reduced motion + ========================================================================== + + Everything above is decoration over a layout that is already correct. Under + this query each piece drops to its resting frame: panels are simply there, + skeletons are flat blocks, the lease bar jumps rather than glides, and the + pulse and tick are drawn rather than drawing. */ + +@media (prefers-reduced-motion: reduce) { + main .panel, + .stat.changed b, + .sk, + .dev-glyph.busy .screen, + .art-pulse.blip, + .art-ok.check { + animation: none; + } + .art-pulse.blip, .art-ok.check { stroke-dasharray: none; } + .lease-bar i, .dev-card { transition: none; } + .dev-card:hover { transform: none; } + .live.connecting i { animation: none; } + .spinner { animation: none; } +} diff --git a/collector/dash/src/ui.tsx b/collector/dash/src/ui.tsx index 8a49de3..3fb8c95 100644 --- a/collector/dash/src/ui.tsx +++ b/collector/dash/src/ui.tsx @@ -1,6 +1,6 @@ // Shared presentational bits and the formatters every page needs. import type { ComponentChildren, JSX } from "preact"; -import { useEffect, useState } from "preact/hooks"; +import { useEffect, useRef, useState } from "preact/hooks"; import { Icon, type IconName } from "./icons.js"; import { BASE, navigate, useRoute } from "./router.js"; @@ -39,18 +39,58 @@ export function Panel({ title, children, aside }: { title?: string; children: Co ); } +/** + * True for one beat after `value` changes — false on the first render. + * + * The dashboard updates numbers in place from the live stream, which is right + * (re-animating a whole panel because one count moved would be noise) but + * makes a change easy to miss entirely if you happened to look away. This is + * the smallest signal that says "that number just moved": no layout shift, no + * colour flash, gone in 180ms. + */ +function useChanged(value: unknown): boolean { + const previous = useRef(value); + const [changed, setChanged] = useState(false); + + useEffect(() => { + if (Object.is(previous.current, value)) return; + previous.current = value; + setChanged(true); + const t = setTimeout(() => setChanged(false), 220); + return () => clearTimeout(t); + }, [value]); + + return changed; +} + export function Stat({ label, value, tone }: { label: string; value: ComponentChildren; tone?: "ok" | "warn" | "bad" }) { + // Only primitives are worth watching: a tile rendering an element gets a new + // vnode every render and would flash forever. + const watchable = typeof value === "string" || typeof value === "number" ? value : null; + const changed = useChanged(watchable); return ( -
+
{value} {label}
); } -/* Device states get an icon; job states do not. Online/stale/offline are - read across a whole shelf at a glance, job status is read one row at a time. */ -const PILL_ICON: Partial> = { online: "online", stale: "stale", offline: "offline" }; +/* Every state gets an icon — device states and job states alike. + Device states are read across a whole shelf at a glance; job states are read + down a long Jobs table, where a column of identical circles differing only + inside is faster to scan than five words of similar length. The word stays + in every pill, so the icon is never the only thing carrying the meaning. */ +const PILL_ICON: Partial> = { + online: "online", + stale: "stale", + offline: "offline", + queued: "queued", + claimed: "claimed", + done: "done", + failed: "failed", + cancelled: "cancelled", +}; export const Pill = ({ kind, children }: { kind: string; children?: ComponentChildren }) => { const icon = PILL_ICON[kind]; @@ -62,8 +102,38 @@ export const Pill = ({ kind, children }: { kind: string; children?: ComponentChi ); }; -export function Loading({ what }: { what: string }) { - return

Loading {what}…

; +/** + * A shape-true placeholder for the panel that is about to arrive. + * + * "Loading devices…" told you the same thing every skeleton does, but the page + * then jumped from one line of text to a full table. A placeholder shaped like + * its panel means the layout is already correct when the data lands, so + * nothing below it moves. + * + * `what` is still announced, for anyone who is being read the page rather than + * looking at it — the blocks themselves are decoration and stay hidden. + */ +export function Loading({ what, shape = "rows" }: { what: string; shape?: "rows" | "stats" | "none" }) { + if (shape === "none") return

Loading {what}…

; + return ( +
+ Loading {what}… + {shape === "stats" ? ( +