From 4ee7ab8da76a53401bb69d0d65440d9adf1e7204 Mon Sep 17 00:00:00 2001 From: Taylor Addison Date: Fri, 4 Sep 2026 18:51:02 -0400 Subject: [PATCH] Jobs that wait for each other, and measurements that admit what they are MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wave 2. Chains, builds, energy, and five new workloads — plus a migration defect this wave surfaced. depends_on makes a chain one enqueue. A job names ids it waits for, is inserted 'waiting', and is promoted when the last of them closes; a failed or cancelled dependency fails its waiters down the whole chain rather than parking them forever. A promoted spec can carry ${jobs..artifact} and ${jobs..metrics.}, substituted whole so a metric stays a number. That is build → install → ui-test without a script watching the queue. preemptible lets a twelve-hour drain step aside for a two-minute install. The beacon answers preempt:true only when a strictly higher-priority queued job would go to the same claimant; the runner checkpoints, uploads, and posts preempted:true, which requeues with params.resume_from and hands the attempt back. Stepping aside never counts against max_attempts, because it is not a failure. Equal priority does not preempt. build on the machine agent closes the loop the artifact store was built for: check out a ref, build it, publish it with the headers that stamp publish_seq, and a later job asking for "latest" resolves to it. It posts pending at claim under its own status context rather than sharing the collector's, because two writers on one GitHub context means the loser's verdict silently disappears. Energy is measured honestly or not at all. energy_method is declared in config, never inferred; a shared plug reports the pool's draw and refuses to divide it; the integration skips gaps rather than bridging them and returns null rather than zero when it has fewer than two samples. What it reports is watt-hours at the wall above a measured idle baseline, including charging — stated everywhere it appears, because a wall measurement cannot separate the two. The Evals page counts what it excludes. The plant-ID write-up's accuracy rode in decode_tok_s because vision had no field of its own, and those rows cannot be queried today; a page that quietly dropped them would repeat the mistake it exists to prevent. Five workloads arrive with their metric names, their views and their refusals: speech-eval and embed-eval on the phones, vantage from wherever an agent sits, locale-shots, app-soak and a11y-audit on the host, self-check on the hosts themselves. Every one of them refuses rather than approximates where a platform cannot answer: no PSS on an iOS simulator, no locale on a physical iPhone, no bold text below Android 12, no in-device shaping on an unrooted phone. Three bugs found by doing the work. Swift's convertToSnakeCase splits on capitals and a digit is not one, so recallAt1 encoded as recall_at1 — one underscore short of the declared name, which means a metric that silently never arrives. Explicit CodingKeys now. The old runSoak ran simctl against a physical iPhone's UDID, so a real-device soak reported the process DEAD at every check. And the mono repo migration left .gitmodules inside runner-android/, where git never looks. The gitlink survived with no URL registered against it, so `git submodule update --init` in a fresh clone silently found nothing to do. Moved to the root with its path rewritten; verified it registers. Verified: the collector's suite passes with 552 assertions, the machine agent's 110 tests pass, the Android unit tests and debug build succeed, and the iOS app builds from a clean xcodegen run off project.yml with zero xcframework references in the committed project. Co-Authored-By: Claude Opus 5 --- .gitmodules | 10 + collector/README.md | 6 + collector/dash/src/api.ts | 6 +- collector/dash/src/app.tsx | 23 +- collector/dash/src/art.tsx | 127 ++ collector/dash/src/icons.tsx | 232 ++++ collector/dash/src/pages/Devices.tsx | 301 +++-- collector/dash/src/pages/Evals.tsx | 593 ++++++++++ collector/dash/src/pages/JobDetail.tsx | 68 +- collector/dash/src/pages/Jobs.tsx | 5 +- collector/dash/src/pages/Overview.tsx | 88 +- collector/dash/src/pages/System.tsx | 20 +- collector/dash/src/style.css | 238 ++++ collector/dash/src/ui.tsx | 142 ++- collector/docs/operations.md | 172 ++- collector/power.example.json | 26 +- collector/schemas/job.schema.json | 14 +- collector/schemas/metrics.json | 24 +- collector/schemas/result.schema.json | 32 + collector/scripts/smoke.ts | 266 +++++ collector/src/a11y-tree.ts | 447 +++++++ collector/src/api/evals.test.ts | 167 +++ collector/src/api/evals.ts | 427 +++++++ collector/src/api/index.ts | 2 + collector/src/api/jobs.ts | 18 +- collector/src/api/mutations.ts | Bin 24127 -> 35037 bytes collector/src/api/overview.ts | 5 + collector/src/api/shared.ts | 4 + collector/src/contact-sheet.ts | 119 ++ collector/src/db.ts | 37 +- collector/src/device-parsers.test.ts | 485 ++++++++ collector/src/device-state.ts | 518 ++++++++ collector/src/display-settings.ts | 161 +++ collector/src/executor.ts | 1038 ++++++++++++++++- collector/src/locale.ts | 124 ++ collector/src/power.test.ts | 144 +++ collector/src/power.ts | 355 ++++++ collector/src/server.ts | 374 ++++-- collector/src/soak-samples.ts | 282 +++++ collector/src/watts.ts | 221 ++++ runner-android/.gitmodules | 3 - .../app/src/main/AndroidManifest.xml | 5 + .../app/src/main/cpp/fleet_llama.cpp | 97 +- .../com/taylab/fleetrunner/RunnerService.kt | 146 ++- .../fleetrunner/backend/LlamaCppBackend.kt | 34 +- .../taylab/fleetrunner/backend/LlamaNative.kt | 16 +- .../taylab/fleetrunner/protocol/Protocol.kt | 39 + .../taylab/fleetrunner/telemetry/Telemetry.kt | 32 + .../fleetrunner/workload/EmbedEvalEngine.kt | 399 +++++++ .../taylab/fleetrunner/workload/Percentile.kt | 28 + .../fleetrunner/workload/VantageEngine.kt | 371 ++++++ .../main/res/drawable/bg_button_primary.xml | 12 + .../main/res/drawable/bg_button_secondary.xml | 11 + .../app/src/main/res/drawable/bg_card.xml | 8 + .../app/src/main/res/drawable/bg_field.xml | 31 + .../src/main/res/drawable/bg_meter_fill.xml | 5 + .../src/main/res/drawable/bg_meter_track.xml | 5 + .../app/src/main/res/drawable/bg_pill.xml | 6 + .../app/src/main/res/drawable/bg_tile.xml | 6 + .../app/src/main/res/drawable/divider.xml | 4 + .../res/drawable/ic_launcher_background.xml | 8 + .../res/drawable/ic_launcher_foreground.xml | 23 + .../res/drawable/ic_launcher_monochrome.xml | 19 + .../app/src/main/res/drawable/ic_stat.xml | 29 +- .../res/mipmap-anydpi-v26/ic_launcher.xml | 6 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 6 + .../app/src/main/res/values-night/colors.xml | 20 + .../app/src/main/res/values/colors.xml | 37 + .../app/src/main/res/values/themes.xml | 21 + .../taylab/fleetrunner/CancellationTest.kt | 12 +- .../com/taylab/fleetrunner/EmbedEvalTest.kt | 300 +++++ .../taylab/fleetrunner/VantageTimingTest.kt | 227 ++++ .../FleetRunner.xcodeproj/project.pbxproj | 16 + .../FleetRunner/EmbedEvalWorkload.swift | 349 ++++++ runner-ios/FleetRunner/FleetAgent.swift | 172 ++- runner-ios/FleetRunner/FleetRunnerApp.swift | 476 +++++++- runner-ios/FleetRunner/LlamaCppBackend.swift | 73 +- runner-ios/FleetRunner/Percentile.swift | 22 + runner-ios/FleetRunner/Protocol.swift | 65 +- runner-ios/FleetRunner/Telemetry.swift | 42 + runner-ios/FleetRunner/Theme.swift | 194 +++ runner-ios/FleetRunner/VantageWorkload.swift | 299 +++++ runner-machine/README.md | 51 +- runner-machine/src/agent.ts | 13 +- runner-machine/src/buildkinds.ts | 157 +++ runner-machine/src/capabilities.ts | 71 +- runner-machine/src/clock.ts | 117 ++ runner-machine/src/collector.ts | 50 +- runner-machine/src/git.ts | 113 ++ runner-machine/src/github.ts | 85 ++ runner-machine/src/logtail.ts | 147 +++ runner-machine/src/protocol.ts | 47 + runner-machine/src/versions.ts | 96 ++ runner-machine/src/workloads/build.ts | 396 +++++++ runner-machine/src/workloads/selfcheck.ts | 180 +++ runner-machine/test/build.test.ts | 128 ++ runner-machine/test/capabilities.test.ts | 60 +- runner-machine/test/logtail.test.ts | 128 ++ runner-machine/test/selfcheck.test.ts | 188 +++ 99 files changed, 12667 insertions(+), 355 deletions(-) create mode 100644 .gitmodules create mode 100644 collector/dash/src/art.tsx create mode 100644 collector/dash/src/pages/Evals.tsx create mode 100644 collector/src/a11y-tree.ts create mode 100644 collector/src/api/evals.test.ts create mode 100644 collector/src/api/evals.ts create mode 100644 collector/src/contact-sheet.ts create mode 100644 collector/src/device-parsers.test.ts create mode 100644 collector/src/device-state.ts create mode 100644 collector/src/display-settings.ts create mode 100644 collector/src/locale.ts create mode 100644 collector/src/power.test.ts create mode 100644 collector/src/power.ts create mode 100644 collector/src/soak-samples.ts create mode 100644 collector/src/watts.ts delete mode 100644 runner-android/.gitmodules create mode 100644 runner-android/app/src/main/java/com/taylab/fleetrunner/workload/EmbedEvalEngine.kt create mode 100644 runner-android/app/src/main/java/com/taylab/fleetrunner/workload/Percentile.kt create mode 100644 runner-android/app/src/main/java/com/taylab/fleetrunner/workload/VantageEngine.kt create mode 100644 runner-android/app/src/main/res/drawable/bg_button_primary.xml create mode 100644 runner-android/app/src/main/res/drawable/bg_button_secondary.xml create mode 100644 runner-android/app/src/main/res/drawable/bg_card.xml create mode 100644 runner-android/app/src/main/res/drawable/bg_field.xml create mode 100644 runner-android/app/src/main/res/drawable/bg_meter_fill.xml create mode 100644 runner-android/app/src/main/res/drawable/bg_meter_track.xml create mode 100644 runner-android/app/src/main/res/drawable/bg_pill.xml create mode 100644 runner-android/app/src/main/res/drawable/bg_tile.xml create mode 100644 runner-android/app/src/main/res/drawable/divider.xml create mode 100644 runner-android/app/src/main/res/drawable/ic_launcher_background.xml create mode 100644 runner-android/app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 runner-android/app/src/main/res/drawable/ic_launcher_monochrome.xml create mode 100644 runner-android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 runner-android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 runner-android/app/src/main/res/values-night/colors.xml create mode 100644 runner-android/app/src/main/res/values/colors.xml create mode 100644 runner-android/app/src/main/res/values/themes.xml create mode 100644 runner-android/app/src/test/java/com/taylab/fleetrunner/EmbedEvalTest.kt create mode 100644 runner-android/app/src/test/java/com/taylab/fleetrunner/VantageTimingTest.kt create mode 100644 runner-ios/FleetRunner/EmbedEvalWorkload.swift create mode 100644 runner-ios/FleetRunner/Percentile.swift create mode 100644 runner-ios/FleetRunner/Theme.swift create mode 100644 runner-ios/FleetRunner/VantageWorkload.swift create mode 100644 runner-machine/src/buildkinds.ts create mode 100644 runner-machine/src/clock.ts create mode 100644 runner-machine/src/git.ts create mode 100644 runner-machine/src/github.ts create mode 100644 runner-machine/src/logtail.ts create mode 100644 runner-machine/src/versions.ts create mode 100644 runner-machine/src/workloads/build.ts create mode 100644 runner-machine/src/workloads/selfcheck.ts create mode 100644 runner-machine/test/build.test.ts create mode 100644 runner-machine/test/logtail.test.ts create mode 100644 runner-machine/test/selfcheck.test.ts 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" ? ( +