diff --git a/.github/workflows/collector.yml b/.github/workflows/collector.yml new file mode 100644 index 0000000..eeaf1cd --- /dev/null +++ b/.github/workflows/collector.yml @@ -0,0 +1,57 @@ +name: collector + +# Path-filtered: a change to a phone runner should not spin up a Node matrix. +# +# The trap this creates is worth knowing about before you add a required check +# in branch protection. A filtered workflow does not run on a PR that touches +# nothing it watches, and GitHub reports a required check that never ran as +# pending forever, so an iOS-only PR would block on the collector's suite. Keep +# these advisory, or add a merge-gate job that always runs. +on: + push: + branches: [main, develop] + paths: ["collector/**", ".github/workflows/collector.yml"] + pull_request: + paths: ["collector/**", ".github/workflows/collector.yml"] + +defaults: + run: + working-directory: collector + +jobs: + test: + runs-on: ubuntu-latest + strategy: + # Both versions report, so a failure on one does not hide the other. + fail-fast: false + matrix: + node: ["22", "24"] + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + cache: npm + cache-dependency-path: | + collector/package-lock.json + collector/dash/package-lock.json + + - run: npm ci + + # The dashboard has its own package.json, so `npm test` skips building it + # when its modules are absent rather than failing on a missing binary. + # This step is what makes that build actually happen here. + - name: Install dashboard dependencies + run: npm --prefix dash ci + + # Playwright is a dependency but its browsers are not downloaded: nothing + # in this suite drives a browser. The web-* workloads that do are run by + # an executor on real hardware, not here. + # + # CI=1 also makes the metric-name check fail on a stale schemas/metrics.json + # rather than quietly regenerating it. That file is what an agent working in + # a runner directory reads, and a mirror that lags its schema states a + # contract confidently and wrongly. + - run: npm test diff --git a/.github/workflows/runner-android.yml b/.github/workflows/runner-android.yml new file mode 100644 index 0000000..ed9e797 --- /dev/null +++ b/.github/workflows/runner-android.yml @@ -0,0 +1,39 @@ +name: runner-android + +on: + push: + branches: [main, develop] + paths: ["runner-android/**", ".github/workflows/runner-android.yml"] + pull_request: + paths: ["runner-android/**", ".github/workflows/runner-android.yml"] + +defaults: + run: + working-directory: runner-android + +jobs: + unit-tests: + runs-on: ubuntu-latest + + steps: + # Deliberately without submodules. -Pfleet.skipNative drops the native + # build, so llama.cpp is never compiled here and cloning it would cost + # more than the tests do. + - uses: actions/checkout@v4 + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "17" + + - uses: gradle/actions/setup-gradle@v4 + with: + build-root-directory: runner-android + + # The JVM tests cover the protocol, the synthetic backend's scaling, the + # cancellation registry and the thermal curve's bookkeeping -- none of + # which call into the native library. The arm64 llama.cpp build takes + # about fifteen minutes and needs a pinned NDK, so it is verified on a + # developer machine (and by the phones that actually run the benchmarks) + # rather than on every push. + - run: ./gradlew :app:testDebugUnitTest -Pfleet.skipNative diff --git a/.github/workflows/runner-ios.yml b/.github/workflows/runner-ios.yml new file mode 100644 index 0000000..85cb74d --- /dev/null +++ b/.github/workflows/runner-ios.yml @@ -0,0 +1,37 @@ +name: runner-ios + +on: + push: + branches: [main, develop] + paths: ["runner-ios/**", ".github/workflows/runner-ios.yml"] + pull_request: + paths: ["runner-ios/**", ".github/workflows/runner-ios.yml"] + +defaults: + run: + working-directory: runner-ios + +jobs: + build: + runs-on: macos-latest + + steps: + - uses: actions/checkout@v4 + + - run: brew install xcodegen + + # Picks the spec this checkout can build. There is no llama.xcframework + # here -- it is 850 MB and gitignored -- so this generates the project + # without the llama.cpp backend, which is exactly the state a stranger + # cloning the repo is in. If that ever stops building, this job is how + # we find out. + - run: ./generate.sh + + - name: Build for the simulator + run: | + xcodebuild build \ + -project FleetRunner.xcodeproj \ + -scheme FleetRunner \ + -destination 'generic/platform=iOS Simulator' \ + -derivedDataPath build \ + CODE_SIGNING_ALLOWED=NO diff --git a/.github/workflows/runner-machine.yml b/.github/workflows/runner-machine.yml new file mode 100644 index 0000000..68efcaa --- /dev/null +++ b/.github/workflows/runner-machine.yml @@ -0,0 +1,37 @@ +name: runner-machine + +on: + push: + branches: [main, develop] + paths: ["runner-machine/**", ".github/workflows/runner-machine.yml"] + pull_request: + paths: ["runner-machine/**", ".github/workflows/runner-machine.yml"] + +defaults: + run: + working-directory: runner-machine + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node: ["22", "24"] + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + cache: npm + cache-dependency-path: runner-machine/package-lock.json + + - run: npm ci + + # A GitHub runner has no GPU, no llama-bench and no mlx_lm, which is + # exactly the machine the capability probes exist for: the suite passes + # there by declaring `benchmark` and nothing else. If a probe ever starts + # guessing instead of answering, this is where it shows up. + - run: npm test diff --git a/README.md b/README.md index 8c5e023..8450b0c 100644 --- a/README.md +++ b/README.md @@ -17,13 +17,35 @@ hardware?** It turned out to be yes, and the fleet is how I know. ![The dashboard: llama.cpp benchmark numbers measured on a real phone](docs/img/results.png) -## The repositories +## What is in here + +Four projects in one repository. They ship independently and share no code — +only a JSON protocol — but they are versioned together, because the protocol is +the thing that breaks and a change to it touches three of them at once. | | What it is | |---|---| -| **[fleet-collector](https://github.com/addisdev/fleet-collector)** | The brain. Device registry, job queue with leases, artifact store, results database, scheduler, alert engine, and the dashboard above. Node + Fastify + SQLite, no broker, no cloud. | -| **[fleet-runner-android](https://github.com/addisdev/fleet-runner-android)** | The Android agent. A foreground service on anything back to Android 7, with llama.cpp (NDK/JNI) and LiteRT backends. | -| **[fleet-runner-ios](https://github.com/addisdev/fleet-runner-ios)** | The iOS agent. SwiftUI, with llama.cpp and Core ML backends, speaking the same JSON protocol without sharing a line of code. | +| **[collector/](collector)** | The brain. Device registry, job queue with leases, artifact store, results database, scheduler, alert engine, and the dashboard above. Node + Fastify + SQLite, no broker, no cloud. | +| **[runner-android/](runner-android)** | The Android agent. A foreground service on anything back to Android 7, with llama.cpp (NDK/JNI) and LiteRT backends. | +| **[runner-ios/](runner-ios)** | The iOS agent. SwiftUI, with llama.cpp and Core ML backends, speaking the same JSON protocol without sharing a line of code. | +| **[runner-machine/](runner-machine)** | The desktop agent. A Node process that makes a laptop or desktop a fleet device, so a phone's tok/s and a laptop's land in the same table. | + +Each directory has its own README, its own tests and its own CI job, filtered by +path so a change to a phone runner does not build the dashboard. + +### Why one repository + +These were four repositories until the protocol started changing. A metric name +lives in `collector/schemas/result.schema.json` and is mirrored by hand in three +runners; a capability list is declared by an agent and enforced by the queue. +Every one of those is a change that has to land in several places at once, and +across repositories it lands in several pull requests that can each merge alone. +The drift is not hypothetical — an eval's accuracy once rode in a field named +`decode_tok_s` because vision had no field of its own, and no query can +reproduce that report's numbers today. + +One repository makes such a change one reviewable diff, and lets `npm test` +in `collector/` fail when the schema and its mirror disagree. ## How it fits together @@ -33,6 +55,7 @@ flowchart LR A["Android runner"] I["iOS runner"] end + M["machine runner
laptop or desktop"] subgraph host["a Mac with devices plugged in"] X["host executor
adb · Maestro · simctl · Playwright"] end @@ -41,6 +64,7 @@ flowchart LR A -- "long-poll, claim, report" --> C I -- "long-poll, claim, report" --> C + M -- "long-poll, claim, report" --> C X -- "claims host jobs" --> C X -- "drives from outside" --> shelf C --- D @@ -51,10 +75,15 @@ claimed by an executor on a Mac and drive a device from outside, because installing an APK or tapping through a UI test is not something an app can do to itself. -The two runners share a protocol, not code — including a synthetic SHA-256 -benchmark that is identical on both platforms token for token. That is what -lets a 2019 Android phone and a current iPhone produce numbers you can put in -the same table, which is the difference between a fleet and a pile of phones. +The runners share a protocol, not code — including a synthetic SHA-256 +benchmark that is identical on every platform token for token. That is what +lets a 2019 Android phone, a current iPhone and a laptop produce numbers you can +put in the same table, which is the difference between a fleet and a pile of +phones. + +A runner also says what it can run. The queue routes on those declared +capabilities rather than on a label someone applied, so adding a workload is +something a runner can do without the collector shipping a release. ## What came out of it @@ -87,7 +116,7 @@ Three findings worth the whole build: trustworthy. Full write-up, including the quantization scripts and the licensing of every -model and dataset: **[the eval](https://github.com/addisdev/fleet-collector/blob/main/evals/greenfolio-plant-id.md)**. +model and dataset: **[the eval](collector/evals/greenfolio-plant-id.md)**. ## Things I learned the hard way diff --git a/collector/.gitignore b/collector/.gitignore new file mode 100644 index 0000000..f481e44 --- /dev/null +++ b/collector/.gitignore @@ -0,0 +1,11 @@ +node_modules/ +data/ +artifacts/store/ +*.log +.DS_Store +power.json +dash/dist/ + +# Playwright's per-run output. Traces and screenshots that matter are uploaded +# to the artifact store by the executor; what lands here is scratch. +test-results/ diff --git a/collector/.nvmrc b/collector/.nvmrc new file mode 100644 index 0000000..2bd5a0a --- /dev/null +++ b/collector/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/collector/LICENSE b/collector/LICENSE new file mode 100644 index 0000000..11b23b6 --- /dev/null +++ b/collector/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Taylor Addison + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/collector/README.md b/collector/README.md new file mode 100644 index 0000000..309e08d --- /dev/null +++ b/collector/README.md @@ -0,0 +1,131 @@ +# fleet-collector + +The brain of **Fleet Runner**: a personal device lab that turns a shelf of old +phones into something you can send work to. It holds the device registry, the +job queue, the artifact store, the results database, and the dashboard. + +One queued job can install a build on every attached device, run a Maestro or +XCUITest suite across them, benchmark llama.cpp on real silicon, classify a +few hundred images through Core ML and LiteRT, screenshot a website on two +real phones and diff it against a baseline, or drain a battery on purpose and +plot the curve. The runners are separate apps — +[iOS](../runner-ios) and +[Android](../runner-android) — that speak a +shared JSON protocol, not shared code. + +Node + Fastify + SQLite in WAL mode. No build step for the server, no broker, +no cloud. + +![The results screen: llama.cpp benchmark numbers from a real phone](docs/img/results.png) + +## How it fits together + +```mermaid +flowchart LR + subgraph shelf["the shelf"] + A["Android runner
foreground service"] + I["iOS runner
SwiftUI app"] + end + subgraph host["a Mac with the devices plugged in"] + X["host executor
adb · Maestro · simctl · Playwright"] + end + C["collector
queue · registry · leases
artifacts · results · scheduler"] + D["dashboard
Preact, same process"] + + A -- "long-poll, claim, report" --> C + I -- "long-poll, claim, report" --> C + X -- "claims host jobs" --> C + X -- "drives from outside" --> shelf + C --- D +``` + +Two kinds of job. **Device jobs** are claimed by the runner app on the phone +itself — benchmarks, batch inference, vision evals. **Host jobs** are claimed +by an executor on a Mac and drive a device from outside, because installing an +APK or tapping through a UI test is not something an app can do to itself. + +## Run it + +Needs Node 22 or newer. Nothing else. + +```bash +npm install +npm start # collector + dashboard on http://127.0.0.1:8788 +``` + +That is a working collector against an empty database. To see it do something, +build a runner from one of the app repos, point it at this address, and enqueue +a job: + +```bash +curl -X POST http://127.0.0.1:8788/jobs -H 'content-type: application/json' -d '{ + "schema": 1, "job_id": "bench-1", "workload": "benchmark", + "executor": "device", "backend": "synthetic", + "params": { "prompt_tokens": 256, "gen_tokens": 64, "measure_iters": 3 }, + "targets": { "pool": "ml-capable" } +}' +``` + +```bash +npm test # typecheck, build the dashboard, run the suite +npm run executor # host executor: claims host jobs, drives devices +npm run dash:dev # dashboard dev server, proxying /api +``` + +`npm test` starts its own collector on a spare port with a temporary database, +so it never touches a real fleet's history. + +## The workloads + +| Workload | Runs on | What it does | +|---|---|---| +| `benchmark` | device | Prefill/decode tok/s via llama.cpp, or a synthetic SHA-256 backend that is identical on both platforms so old hardware still produces comparable numbers | +| `batch` / `pipeline` | device | Real generation over a set of inputs, or staged work across several devices | +| `vision-eval` | device | Top-1/top-5 accuracy and per-image latency for an image classifier, via Core ML or LiteRT | +| `install` | host | One artifact onto every attached device — `adb install`, or `simctl`/`devicectl` | +| `ui-test` | host | Maestro flows or an XCUITest bundle per device, JUnit parsed back into results | +| `web-test` / `web-shots` | host | Playwright suites, and visual-regression captures diffed against an accepted baseline — including on real phone screens | +| `web-audit` / `web-unfurl` | host | Crawl-and-audit with a real browser; and the raw HTML that link-preview bots actually see, which is a different answer | +| `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 | +| `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: +**[docs/operations.md](docs/operations.md)**. + +## Status and threat model + +Built and running: every phase of the original plan, a dashboard with live +updates over SSE, an alert engine, and a scheduler. The first real payload was +an [on-device plant-ID evaluation](evals/greenfolio-plant-id.md) across both +platforms — 77% top-1 at 7 ms per image on a phone, which answered a product +question that had been guesswork. + +**There is no authentication, by design.** The collector is reachable on a home +LAN and is meant to stay that way; anyone who can reach it can enqueue a job. +`FLEET_DASH_TOKEN` guards the dashboard's mutations, but that is a speed bump +against a stray browser tab, not an access control — `POST /jobs` stays open so +`curl` and CI keep working. Do not put this on the internet. If you want to, +the honest starting point is that every endpoint would need to be re-thought, +not that a token would need to be added. + +That posture depends on every agent being on the same LAN, which stops being +true the moment one of them is a laptop that leaves the house. `FLEET_BIND` +is the knob for that case: a comma-separated list of addresses to answer on, +defaulting to every interface. Set it to loopback plus the host's own tailnet +address and the collector is reachable from your own devices anywhere and from +nothing else — no port forward, no hotel wifi, no guest network. + +```bash +FLEET_BIND=127.0.0.1,100.x.y.z npm start +``` + +The network is the access control either way. `FLEET_BIND` only decides which +network that is. + +## License + +MIT — see [LICENSE](LICENSE). Third-party components and the licensing of the +models and datasets used by the evals are in +[THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md). diff --git a/collector/THIRD_PARTY_NOTICES.md b/collector/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..b2e80f1 --- /dev/null +++ b/collector/THIRD_PARTY_NOTICES.md @@ -0,0 +1,29 @@ +# Third-party notices + +The collector's npm dependencies are all MIT, ISC, or Apache-2.0 and are +declared in `package.json` and `dash/package.json`; `npm ls` will show you +their terms. This file is for the things `npm` does not know about. + +## Models and datasets used by the plant-ID eval + +Nothing here is committed. Weights and images are fetched from their sources +and cached in the artifact store by content hash. The eval writeup is +[`evals/greenfolio-plant-id.md`](evals/greenfolio-plant-id.md). + +| Asset | Source | License | +|---|---|---| +| PlantNet-300K ResNet18 (LiteRT) | `litert-community/PlantNet-300K-ResNet18-LiteRT` on Hugging Face | Apache-2.0 | +| House-Plants classifier | `AlyModrik41/House-Plants-Classification-TFLite-Model` on Hugging Face | per its model card | +| PlantNet-300K images | `mikehemberger/plantnet300K` on Hugging Face (Pl@ntNet) | CC-BY-SA 4.0 — attribution required, share-alike | + +The int8 and Core ML variants are our own conversions of the Apache-2.0 +ResNet18 (`evals/plant-id-assets/`) and inherit its terms. The eval images +are CC-BY-SA, which is the reason they are distributed to devices as an +artifact inside the fleet rather than committed to this repository. + +## Tools the host executor drives + +[Maestro](https://maestro.mobile.dev), [Playwright](https://playwright.dev), +`adb`, and Xcode's `simctl`/`devicectl`/`xcodebuild` are invoked as external +programs and are installed separately; none of their code is redistributed +here. diff --git a/collector/ci/README.md b/collector/ci/README.md new file mode 100644 index 0000000..2685fcb --- /dev/null +++ b/collector/ci/README.md @@ -0,0 +1,44 @@ +# Wiring an app repo to the fleet + +Two independent paths. Most apps want both, but they answer different questions +and neither depends on the other. + +## 1. Publish builds — `publish-build.yml` + +On merge to `main`, build and publish to the fleet's artifact store tagged with +the app name. Nothing is tested; nothing waits for a device. + +This is what makes a nightly meaningful. A schedule that pins a literal +`sha256` tests that build forever — one of ours spent six days guarding an APK +older than the code it was meant to guard. A schedule that asks for +`"sha256": "latest"` tests whatever was published most recently, and never has +to be edited. + +The contract is one header: an artifact uploaded with `x-artifact-app` belongs +to that app, and `latest` resolves to the newest one. If no build has ever been +published for an app, its nightly is **skipped rather than failed** — a job +pinned to a build that does not exist would fail in a way indistinguishable +from the app being broken. + +## 2. Test a pull request — `example-workflow.yml` + +Enqueue a job against real hardware and block the PR on the verdict. +`ci-enqueue` uploads the build, enqueues, polls to a verdict, and exits 0/1, so +the check fails on a red suite whether or not commit statuses are armed. + +## Commit statuses are built and dark + +`report_to.github_status` records a status row for every job that asks for one, +and posts nothing unless **both** are set on the collector: + +``` +FLEET_GITHUB_STATUS=1 +FLEET_GITHUB_TOKEN= +``` + +Unarmed, rows accumulate with `posted=0` and are visible on the dashboard — you +can see exactly what would have been posted before anything reaches GitHub. + +Arming it makes a red nightly block a PR. That is the point, and it is also a +decision with consequences for everyone who pushes, so it stays off until +somebody chooses it deliberately. diff --git a/collector/ci/example-workflow.yml b/collector/ci/example-workflow.yml new file mode 100644 index 0000000..2ae7f8c --- /dev/null +++ b/collector/ci/example-workflow.yml @@ -0,0 +1,44 @@ +# Per-PR device test. Copy into an app repo as .github/workflows/. See README.md. +# +# When (and only when) the fleet gets connected to real CI, a workflow like +# this goes into an app repo (e.g. greenfolio-android). Until then this file +# documents the contract and nothing reads it. +# +# Requirements at connect time, none of which exist today by design: +# - the runner host joined to the tailnet, FLEET_COLLECTOR_URL secret set +# - FLEET_GITHUB_STATUS=1 + FLEET_GITHUB_TOKEN on the collector, so the +# final commit status comes from the collector (this step's exit code +# already fails the check without it) +name: fleet-device-smoke +on: pull_request + +jobs: + device-smoke: + # Runs on the self-hosted runner that can reach the collector over Tailscale. + runs-on: [self-hosted] + steps: + - uses: actions/checkout@v4 + + - name: Build debug APK + run: ./gradlew :app:assembleDebug + + - name: Real-device smoke via fleet + run: | + cat > fleet-job.json < + + + + + + Fleet Runner + + + +
+ + + diff --git a/collector/dash/package-lock.json b/collector/dash/package-lock.json new file mode 100644 index 0000000..67f9c76 --- /dev/null +++ b/collector/dash/package-lock.json @@ -0,0 +1,1225 @@ +{ + "name": "fleet-dash", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "fleet-dash", + "version": "0.1.0", + "dependencies": { + "preact": "^10.29.0", + "qrcode-generator": "^2.0.4" + }, + "devDependencies": { + "@types/node": "^24.2.0", + "typescript": "^5.9.0", + "vite": "^7.1.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz", + "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz", + "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz", + "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz", + "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz", + "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz", + "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz", + "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz", + "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz", + "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz", + "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz", + "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz", + "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz", + "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz", + "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz", + "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz", + "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz", + "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz", + "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz", + "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz", + "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz", + "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz", + "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz", + "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz", + "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz", + "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/preact": { + "version": "10.29.8", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.8.tgz", + "integrity": "sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + }, + "peerDependencies": { + "preact-render-to-string": ">=5" + }, + "peerDependenciesMeta": { + "preact-render-to-string": { + "optional": true + } + } + }, + "node_modules/qrcode-generator": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/qrcode-generator/-/qrcode-generator-2.0.4.tgz", + "integrity": "sha512-mZSiP6RnbHl4xL2Ap5HfkjLnmxfKcPWpWe/c+5XxCuetEenqmNFf1FH/ftXPCtFG5/TDobjsjz6sSNL0Sr8Z9g==", + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz", + "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.4", + "@rollup/rollup-android-arm64": "4.62.4", + "@rollup/rollup-darwin-arm64": "4.62.4", + "@rollup/rollup-darwin-x64": "4.62.4", + "@rollup/rollup-freebsd-arm64": "4.62.4", + "@rollup/rollup-freebsd-x64": "4.62.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", + "@rollup/rollup-linux-arm-musleabihf": "4.62.4", + "@rollup/rollup-linux-arm64-gnu": "4.62.4", + "@rollup/rollup-linux-arm64-musl": "4.62.4", + "@rollup/rollup-linux-loong64-gnu": "4.62.4", + "@rollup/rollup-linux-loong64-musl": "4.62.4", + "@rollup/rollup-linux-ppc64-gnu": "4.62.4", + "@rollup/rollup-linux-ppc64-musl": "4.62.4", + "@rollup/rollup-linux-riscv64-gnu": "4.62.4", + "@rollup/rollup-linux-riscv64-musl": "4.62.4", + "@rollup/rollup-linux-s390x-gnu": "4.62.4", + "@rollup/rollup-linux-x64-gnu": "4.62.4", + "@rollup/rollup-linux-x64-musl": "4.62.4", + "@rollup/rollup-openbsd-x64": "4.62.4", + "@rollup/rollup-openharmony-arm64": "4.62.4", + "@rollup/rollup-win32-arm64-msvc": "4.62.4", + "@rollup/rollup-win32-ia32-msvc": "4.62.4", + "@rollup/rollup-win32-x64-gnu": "4.62.4", + "@rollup/rollup-win32-x64-msvc": "4.62.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/collector/dash/package.json b/collector/dash/package.json new file mode 100644 index 0000000..cf0df2a --- /dev/null +++ b/collector/dash/package.json @@ -0,0 +1,24 @@ +{ + "name": "fleet-dash", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Fleet Runner dashboard SPA. Built to dash/dist and served by the collector at /dash.", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build", + "preview": "vite preview" + }, + "dependencies": { + "preact": "^10.29.0", + "qrcode-generator": "^2.0.4" + }, + "devDependencies": { + "@types/node": "^24.2.0", + "typescript": "^5.9.0", + "vite": "^7.1.0" + }, + "allowScripts": { + "esbuild@0.28.2": true + } +} diff --git a/collector/dash/public/favicon.svg b/collector/dash/public/favicon.svg new file mode 100644 index 0000000..34eba60 --- /dev/null +++ b/collector/dash/public/favicon.svg @@ -0,0 +1,2 @@ + + diff --git a/collector/dash/src/TokenBanner.tsx b/collector/dash/src/TokenBanner.tsx new file mode 100644 index 0000000..a559c1a --- /dev/null +++ b/collector/dash/src/TokenBanner.tsx @@ -0,0 +1,46 @@ +// Shown when the collector requires a dashboard token and this browser has not +// been given one. +// +// The alternative is what happened before it existed: you click something +// ordinary, like renaming a device, and get a 401 naming an HTTP header. The +// requirement is knowable up front — /api/health reports it — so say so up +// front, and take the token right here rather than sending someone to another +// screen to find the field. +import { useState } from "preact/hooks"; +import { useApi, type Health } from "./api.js"; +import { useToken } from "./mutate.js"; + +export function TokenBanner() { + const health = useApi("/api/health", [], 0); + const [token, setToken] = useToken(); + const [draft, setDraft] = useState(""); + + // Silent unless the collector actually wants a token and we lack one. + if (!health.data?.guard || token) return null; + + return ( + + ); +} diff --git a/collector/dash/src/api.ts b/collector/dash/src/api.ts new file mode 100644 index 0000000..8104497 --- /dev/null +++ b/collector/dash/src/api.ts @@ -0,0 +1,312 @@ +// Typed access to the collector's read API, plus the hooks the pages use. +import { useEffect, useRef, useState } from "preact/hooks"; +import { onLive } from "./live.js"; + +export class ApiError extends Error { + constructor( + readonly status: number, + message: string, + ) { + super(message); + } +} + +export async function api(path: string, init?: RequestInit): Promise { + const res = await fetch(path.startsWith("/") ? path : `/api/${path}`, { + ...init, + headers: { accept: "application/json", ...(init?.headers ?? {}) }, + }); + if (!res.ok) { + // The API answers unknown /api paths with JSON, so an HTML body here means + // something upstream (a proxy, a stale bundle) is answering instead. + const body = await res.json().catch(() => ({}) as { error?: string }); + throw new ApiError(res.status, body.error ?? `${res.status} ${res.statusText}`); + } + return (await res.json()) as T; +} + +export type Loadable = { data: T | null; error: Error | null; loading: boolean; reload: () => void }; + +/** + * Fetch on mount, again whenever a live event names one of `topics`, and on a + * slow timer if `refreshMs` is set. The stream carries a nudge, not a payload: + * the page refetches the endpoint it already trusts rather than trying to patch + * its own copy of the state. + * + * The timer is not a fallback for the stream — it exists because a screen full + * of "3m ago" and lease countdowns goes stale on its own, with no event to say so. + */ +export function useApi(path: string | null, topics: string[] = [], refreshMs = 0): Loadable { + const [data, setData] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(path !== null); + const [nonce, setNonce] = useState(0); + // Guards against a slow first response overwriting a fast second one. + const seq = useRef(0); + + useEffect(() => { + if (path === null) return; + const mine = ++seq.current; + setLoading(true); + api(path) + .then((d) => { + if (seq.current === mine) { + setData(d); + setError(null); + } + }) + .catch((e) => { + if (seq.current === mine) setError(e as Error); + }) + .finally(() => { + if (seq.current === mine) setLoading(false); + }); + }, [path, nonce]); + + const reload = () => setNonce((n) => n + 1); + + useEffect(() => { + if (topics.length === 0) return; + // Coalesce bursts: a fan-out enqueue publishes one event per child, and + // refetching once per child would hammer the collector. + let timer: number | undefined; + return onLive(topics, () => { + clearTimeout(timer); + timer = setTimeout(reload, 250) as unknown as number; + }); + }, [topics.join(",")]); + + useEffect(() => { + if (!refreshMs) return; + // A hidden tab left open for a week should not keep waking the collector. + const tick = () => document.visibilityState === "visible" && reload(); + const id = setInterval(tick, refreshMs); + return () => clearInterval(id); + }, [refreshMs]); + + return { data, error, loading, reload }; +} + +// --- response shapes (mirrors src/api/*.ts) --- + +export type DeviceStatus = "online" | "stale" | "offline"; + +export type Beacon = { + battery_pct: number | null; + charging: boolean | null; + thermal: string | null; + mem_mb: number | null; + mem_method: string | null; + process_alive: boolean | null; + job_id: string | null; +} | null; + +export type Device = { + device_id: string; + name: string | null; + notes: string | null; + descriptor: Record; + /** Effective pools: the override when set, otherwise what the runner reports. */ + pools: string[]; + /** null = an agent that predates capability routing; it is offered everything. */ + capabilities: string[] | null; + /** /24 the agent last registered from. Distinguishes "offline" from "elsewhere". */ + last_net: string | null; + pools_reported: string[]; + pools_override: string[] | null; + platform: "ios" | "android"; + simulator: boolean; + status: DeviceStatus; + age_s: number | null; + last_seen: string | null; + beacon: Beacon; + current_job: string | null; + lock: { job_id: string; acquired_at: string | null } | null; +}; + +export type Job = { + job_id: string; + workload: string; + executor: "device" | "host"; + status: "queued" | "claimed" | "done" | "failed" | "cancelled"; + claimed_by: string | null; + attempts: number; + max_attempts: number; + priority: number; + template_id: string | null; + lease_ttl_s: number; + lease_deadline: string | null; + lease_remaining_s: number | null; + duration_s: number | null; + last_error: string | null; + created_at: string | null; + claimed_at: string | null; + finished_at: string | null; + pool: string | null; + match: string | null; + device_id: string | null; + exclusive: boolean; + backend: string | null; + model: string | null; + app: string | null; + report_to: Record | null; +}; + +export type Overview = { + generated_at: string; + devices: { + total: number; + online: number; + stale: number; + offline: number; + busy: number; + idle: number; + charging: number; + low_battery: number; + low_battery_devices: string[]; + worst_thermal: string | null; + by_pool: Record; + }; + queue: { + queued: number; + claimed: number; + done_24h: number; + failed_24h: number; + last_attempt: number; + oldest_queued_age_s: number | null; + }; + running: { + job_id: string; + workload: string; + executor: string; + claimed_by: string | null; + claimed_at: string | null; + attempts: number; + max_attempts: number; + lease_deadline: string | null; + lease_remaining_s: number | null; + lease_fraction: number | null; + elapsed_s: number | null; + }[]; + recent_failures: { + job_id: string; + workload: string; + executor: string; + claimed_by: string | null; + last_error: string | null; + finished_at: string | null; + attempts: number; + max_attempts: number; + }[]; + schedules: { + total: number; + enabled: number; + missed: number; + next: { id: string; cron: string; next_run: string | null; next_run_in_s: number | null; missed: boolean }[]; + }; + health: Health; +}; + +export type DeviceList = { total: number; pools: string[]; devices: Device[] }; + +export type DeviceDetail = Device & { + counts: { results: number; beacons: number }; + jobs: { + job_id: string; + workload: string; + executor: string; + status: Job["status"]; + created_at: string | null; + finished_at: string | null; + attempts: number; + max_attempts: number; + last_error: string | null; + }[]; + benchmarks: { + job_id: string; + at: string | null; + model: string; + quant: string | null; + backend: string; + metrics: Record | null; + }[]; +}; + +export type BeaconSample = { + ts: string | null; + battery_pct: number | null; + charging: boolean | null; + thermal: string | null; + mem_mb: number | null; + mem_method: string | null; + process_alive: boolean | null; + job_id: string | null; +}; + +export type BeaconHistory = { device_id: string; hours: number; count: number; samples: BeaconSample[] }; + +export type JobList = { + page: number; + per_page: number; + total: number; + pages: number; + status_counts: Record; + workloads: string[]; + pools: string[]; + jobs: Job[]; +}; + +export type ResultRow = { + device_id: string; + iter: number; + created_at: string | null; + payload: Record; +}; + +export type JobDetail = Job & { + spec: Record; + parent: string | null; + children: Job[]; + siblings: Job[]; + results: ResultRow[]; + beacons: (BeaconSample & { device_id: string })[]; + artifacts: { + sha256: string; + name: string | null; + size: number | null; + created_at: string | null; + in_store: boolean; + role: "input" | "output"; + }[]; + locks: { device_id: string; acquired_at: string | null }[]; + status_report: { target: string; state: string; posted: boolean; detail: string | null; created_at: string | null } | null; + derived_timeline: { at: string | null; what: string }[]; +}; + +export type RecentResults = { + results: { + job_id: string; + device_id: string; + iter: number; + workload: string; + final: boolean; + ok: boolean; + created_at: string | null; + age_s: number; + summary: string; + }[]; +}; + +export type Locks = { locks: { device_id: string; job_id: string; acquired_at: string | null; held_s: number }[] }; + +export type Health = { + ok: boolean; + instance: string; + started_at: string; + uptime_s: number; + now: string; + node: string; + pid: number; + stream_clients: number; + guard: boolean; +}; diff --git a/collector/dash/src/app.tsx b/collector/dash/src/app.tsx new file mode 100644 index 0000000..3f6b244 --- /dev/null +++ b/collector/dash/src/app.tsx @@ -0,0 +1,160 @@ +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 { Link, Panel } from "./ui.js"; +import { Compose } from "./pages/Compose.js"; +import { DeviceDetail } from "./pages/DeviceDetail.js"; +import { Devices } from "./pages/Devices.js"; +import { Enroll } from "./pages/Enroll.js"; +import { JobDetail } from "./pages/JobDetail.js"; +import { Jobs } from "./pages/Jobs.js"; +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 { Events } from "./pages/Events.js"; +import { Results } from "./pages/Results.js"; +import { Visual } from "./pages/Visual.js"; + +import { System } from "./pages/System.js"; + +const NAV = [ + ["/", "Overview"], + ["/devices", "Devices"], + ["/jobs", "Jobs"], + ["/results", "Results"], + ["/visual", "Visual"], + ["/schedules", "Schedules"], + ["/artifacts", "Artifacts"], + ["/events", "Events"], + ["/alerts", "Alerts"], + ["/system", "System"], +] as const; + +const LIVE_LABEL: Record = { + live: "live", + connecting: "connecting", + down: "offline", +}; + +function LiveDot() { + const state = useLiveState(); + return ( + + + {LIVE_LABEL[state]} + + ); +} + +function NotFound({ route }: { route: string }) { + return ( + <> +

Not found

+ +

+ No dashboard route at {route}. Back to the overview. +

+
+ + ); +} + +function Router() { + const route = useRoute(); + + if (match("/", route)) return ; + if (match("/system", route)) return ; + if (match("/alerts", route)) return ; + if (match("/devices", route)) return ; + // Before /devices/:id, or "new" reads as a device id. + if (match("/devices/new", route)) return ; + if (match("/jobs", route)) return ; + // 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("/visual", route)) return ; + if (match("/artifacts", route)) return ; + if (match("/events", route)) return ; + if (match("/schedules", route)) return ; + + // Keyed on the id so switching between two detail pages remounts rather than + // showing the previous device's charts under the new device's name. + const device = match("/devices/:id", route); + if (device) return ; + const job = match("/jobs/:id", route); + if (job) return ; + + + return ; +} + +function Help({ onClose }: { onClose: () => void }) { + return ( +
+
e.stopPropagation()}> +

Keyboard

+ + {SHORTCUTS.map((s) => ( + + + + + ))} +
+ {s.keys} + {s.does}
+ +
+
+ ); +} + +export function App() { + const { help, setHelp } = useKeyboard(); + + return ( +
+
+ + + + Fleet Runner collector + + + + +
+ + +
+ +
+ + {help && setHelp(false)} />} +
+ ); +} diff --git a/collector/dash/src/chart.tsx b/collector/dash/src/chart.tsx new file mode 100644 index 0000000..7d61860 --- /dev/null +++ b/collector/dash/src/chart.tsx @@ -0,0 +1,291 @@ +// A minimal SVG time-series chart. +// +// No charting library: this draws one line, one shaded band set, and an axis. +// uPlot earns its ~40 KB in D3 where thousands of benchmark points need +// panning; a day of 60 s beacons is ~1.4 k points and renders fine as a path. +// Colors come from CSS classes so the chart follows the theme. +import type { ComponentChildren } from "preact"; + +export type Point = { t: number; v: number | null }; + +/** A battery series is a continuous signal, so a stretch with no samples must + * break the line rather than draw a slope nobody measured. Shared so the device + * chart and the drain chart cannot drift apart on what counts as a gap. */ +export const BATTERY_GAP_MS = 10 * 60 * 1000; + +const W = 800; +const H = 170; +const PAD = { top: 10, right: 8, bottom: 20, left: 30 }; + +const hhmm = (ms: number) => + new Date(ms).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }); + +/** Build an SVG path, breaking the line at gaps rather than drawing a straight + * line across hours when a device was off — a fabricated segment would read as + * a measurement. */ +function linePath(points: Point[], x: (t: number) => number, y: (v: number) => number, gapMs: number): string { + let d = ""; + let pen = false; + for (let i = 0; i < points.length; i++) { + const p = points[i]; + if (p.v == null) { + pen = false; + continue; + } + const gap = i > 0 && p.t - points[i - 1].t > gapMs; + if (!pen || gap) { + d += `M${x(p.t).toFixed(1)},${y(p.v).toFixed(1)}`; + pen = true; + } else { + d += `L${x(p.t).toFixed(1)},${y(p.v).toFixed(1)}`; + } + } + return d; +} + +/** Contiguous [start, end] runs where `flag` holds — used to shade the periods + * a device was on the charger, which is what explains a rising battery curve. */ +export function runs(items: T[], t: (x: T) => number, flag: (x: T) => boolean): [number, number][] { + const out: [number, number][] = []; + let start: number | null = null; + for (let i = 0; i < items.length; i++) { + const on = flag(items[i]); + if (on && start === null) start = t(items[i]); + if (!on && start !== null) { + out.push([start, t(items[i])]); + start = null; + } + } + if (start !== null && items.length) out.push([start, t(items[items.length - 1])]); + return out; +} + +export function TimeSeries({ + points, + bands = [], + domain, + yMin = 0, + yMax = 100, + unit = "", + gapMs = BATTERY_GAP_MS, + children, +}: { + points: Point[]; + bands?: [number, number][]; + domain?: [number, number]; + yMin?: number; + yMax?: number; + unit?: string; + gapMs?: number; + children?: ComponentChildren; +}) { + const real = points.filter((p) => p.v != null); + if (real.length === 0) return

No samples in this window.

; + + const [t0, t1] = domain ?? [points[0].t, points[points.length - 1].t]; + const span = Math.max(1, t1 - t0); + const x = (t: number) => PAD.left + ((t - t0) / span) * (W - PAD.left - PAD.right); + const y = (v: number) => + PAD.top + (1 - (Math.max(yMin, Math.min(yMax, v)) - yMin) / (yMax - yMin)) * (H - PAD.top - PAD.bottom); + + const ticks = [0, 0.25, 0.5, 0.75, 1].map((f) => t0 + f * span); + const yTicks = [yMin, (yMin + yMax) / 2, yMax]; + + return ( +
+ + {bands.map(([a, b]) => ( + + ))} + {yTicks.map((v) => ( + + + + {v} + + + ))} + {ticks.map((t, i) => ( + + {hhmm(t)} + + ))} + + +
+ {children} + + {real.length} samples{unit ? ` · ${unit}` : ""} + +
+
+ ); +} + +/** Horizontal bars for comparing one measurement across devices. Horizontal + * because device ids are long: vertical bars would need rotated labels. */ +export function Bars({ + items, + unit = "", + digits = 1, +}: { + items: { label: string; value: number | null; note?: string; muted?: boolean }[]; + unit?: string; + digits?: number; +}) { + const real = items.filter((i) => typeof i.value === "number") as { label: string; value: number }[]; + if (real.length === 0) return

Nothing to compare yet.

; + const max = Math.max(...real.map((i) => i.value), 0) || 1; + + return ( +
+ {items.map((i) => ( +
+ + {i.label} + + + + + + {typeof i.value === "number" ? i.value.toFixed(digits) : "—"} + {unit && typeof i.value === "number" ? ` ${unit}` : ""} + {i.note && {i.note}} + +
+ ))} +
+ ); +} + +const SERIES_CLASSES = ["s0", "s1", "s2", "s3", "s4", "s5"]; + +/** Several lines on shared axes — benchmark trends over time, or two drain runs + * overlaid. Each series breaks on gaps for the same reason a single one does. */ +export function MultiSeries({ + series, + yMax, + yMin = 0, + unit = "", + gapMs = Number.POSITIVE_INFINITY, +}: { + series: { name: string; points: Point[]; muted?: boolean }[]; + yMax?: number; + yMin?: number; + unit?: string; + gapMs?: number; +}) { + const all = series.flatMap((s) => s.points).filter((p) => p.v != null) as { t: number; v: number }[]; + if (all.length === 0) return

No data in this view.

; + + const t0 = Math.min(...all.map((p) => p.t)); + const t1 = Math.max(...all.map((p) => p.t)); + // Headroom above the tallest point so a peak is not clipped by the axis. + const top = yMax ?? (Math.max(...all.map((p) => p.v)) * 1.1 || 1); + const span = Math.max(1, t1 - t0); + const x = (t: number) => PAD.left + ((t - t0) / span) * (W - PAD.left - PAD.right); + const y = (v: number) => PAD.top + (1 - (v - yMin) / (top - yMin)) * (H - PAD.top - PAD.bottom); + const yTicks = [yMin, (yMin + top) / 2, top]; + + return ( +
+ + {yTicks.map((v) => ( + + + + {v.toFixed(top < 10 ? 1 : 0)} + + + ))} + {[t0, t0 + span / 2, t1].map((t, i) => ( + + {new Date(t).toLocaleDateString(undefined, { month: "short", day: "numeric" })} + + ))} + {series.map((s, i) => ( + + + {/* A single sample has no line to draw, so mark the point itself — + otherwise a device that ran once looks like it never ran. */} + {s.points.filter((p) => p.v != null).length === 1 && + s.points + .filter((p) => p.v != null) + .map((p) => )} + + ))} + +
+ {series.map((s, i) => ( + + + {s.name} + + ))} + {unit && {unit}} +
+
+ ); +} + +// The shared thermal enum, worst last. The runners map every platform's native +// state onto these four so a strip like this is comparable across the fleet. +const THERMAL = ["nominal", "fair", "serious", "critical"]; + +/** A horizontal strip of thermal state over time — a curve, not a single + * state, which is the whole point of sampling it. */ +export function ThermalStrip({ + samples, + domain, + gapMs = 10 * 60 * 1000, +}: { + samples: { t: number; thermal: string | null }[]; + domain: [number, number]; + gapMs?: number; +}) { + const known = samples.filter((s) => s.thermal && THERMAL.includes(s.thermal)); + if (known.length === 0) return

No thermal samples in this window.

; + + const [t0, t1] = domain; + const span = Math.max(1, t1 - t0); + const pct = (t: number) => ((t - t0) / span) * 100; + + // Collapse consecutive equal states into one segment — but never across a + // reporting gap. Painting straight through hours the device was off would + // claim a thermal state nobody measured, which is exactly what the battery + // line above refuses to do when it breaks on the same gap. + const segs: { from: number; to: number; state: string }[] = []; + for (const s of known) { + const last = segs[segs.length - 1]; + if (last && last.state === s.thermal && s.t - last.to <= gapMs) last.to = s.t; + else segs.push({ from: s.t, to: s.t, state: s.thermal! }); + } + + return ( +
+
+ {segs.map((s, i) => ( + + ))} +
+
+ {THERMAL.map((t) => ( + + {t} + + ))} +
+
+ ); +} diff --git a/collector/dash/src/icons.tsx b/collector/dash/src/icons.tsx new file mode 100644 index 0000000..b24e5ac --- /dev/null +++ b/collector/dash/src/icons.tsx @@ -0,0 +1,210 @@ +// Inline stroke icons on a 16 grid, from the dashboard's asset sheet. +// +// Every icon is currentColor, so it takes the colour of the text it sits +// beside and needs no theme rule of its own — and every icon sits next to its +// word, never instead of it: the pills, battery and workload cells still read +// as text, the icon only makes them faster to scan. +import type { ComponentChildren } from "preact"; + +const PATHS = { + phone: ( + <> + + + + ), + // Dashed outline: a phone that is not really there. + simulator: ( + <> + + + + ), + host: ( + <> + + + + ), + online: ( + <> + + + + ), + stale: ( + <> + + + + ), + offline: ( + <> + + + + + ), + busy: , + charging: , + thermal: ( + <> + + + + ), + stopwatch: ( + <> + + + + ), + heartbeat: ( + <> + + + ), + install: ( + <> + + + + ), + uitest: , + benchmark: ( + <> + + + + + ), + batch: ( + <> + + + + ), + visual: ( + <> + + + + ), + soak: ( + <> + + + + ), + audit: ( + <> + + + + ), + // Three stages, left to right, joined: a pipeline is stages in an order. + pipeline: ( + <> + + + + + + ), + archive: ( + <> + + + + + ), + digest: ( + <> + + + + + ), +} satisfies Record; + +export type IconName = keyof typeof PATHS; + +/** A 16-grid stroke icon. Decorative unless given a title, in which case it + * is announced — use a title only when the icon carries meaning the text + * beside it does not (the charging bolt). */ +export function Icon({ name, size = 14, title }: { name: IconName; size?: number; title?: string }) { + return ( + + {title && {title}} + {PATHS[name]} + + ); +} + +/** The Fleet Runner glyph: the pulse from the launcher mark, on its ink + * square. This is the under-24px form of the mark, the same drawing as + * public/favicon.svg — the tile carries its ink as an attribute, so it is + * right with no stylesheet at all, and style.css lifts it on the dark shell + * where ink on near-ink would leave the pulse floating with no square. + * Decorative: the wordmark beside it says the name. */ +export function Glyph({ size = 20 }: { size?: number }) { + return ( + + ); +} + +/** Which icon a workload gets. Covers every workload the collector accepts + * (WORKLOADS in src/server.ts); anything else shows its name alone, because a + * wrong icon would be worse than none. */ +const WORKLOAD_ICON: Record = { + install: "install", + "ui-test": "uitest", + "web-test": "uitest", + benchmark: "benchmark", + batch: "batch", + soak: "soak", + drain: "soak", + "web-shots": "visual", + "web-audit": "audit", + "web-unfurl": "audit", + pipeline: "pipeline", + archive: "archive", + digest: "digest", + thermal: "thermal", + "cold-start": "stopwatch", + "self-check": "heartbeat", +}; + +/** The workload's name with its icon, for job tables. */ +export function Workload({ name, children }: { name: string; children?: ComponentChildren }) { + const icon = WORKLOAD_ICON[name]; + return ( + + {icon && } + {name} + {children} + + ); +} diff --git a/collector/dash/src/keys.ts b/collector/dash/src/keys.ts new file mode 100644 index 0000000..42367d0 --- /dev/null +++ b/collector/dash/src/keys.ts @@ -0,0 +1,94 @@ +// Keyboard shortcuts, in the "g then j" style that gmail and github trained +// everyone on. The dashboard is a thing you leave open and glance at, so +// getting between screens should not need the mouse. +import { useEffect, useState } from "preact/hooks"; +import { navigate } from "./router.js"; + +const GOTO: Record = { + o: ["/", "Overview"], + d: ["/devices", "Devices"], + j: ["/jobs", "Jobs"], + n: ["/jobs/new", "New job"], + r: ["/results", "Results"], + s: ["/schedules", "Schedules"], + a: ["/artifacts", "Artifacts"], + e: ["/events", "Events"], + x: ["/alerts", "Alerts"], + y: ["/system", "System"], +}; + +export const SHORTCUTS = [ + ...Object.entries(GOTO).map(([k, [, label]]) => ({ keys: `g ${k}`, does: label })), + { keys: "/", does: "Focus the search box on this screen" }, + { keys: "?", does: "Show this help" }, + { keys: "Esc", does: "Close help, or leave the search box" }, +]; + +/** True while a keystroke would go into a form field rather than the page. */ +function typing(t: EventTarget | null): boolean { + const el = t as HTMLElement | null; + if (!el) return false; + return el.isContentEditable || ["INPUT", "TEXTAREA", "SELECT"].includes(el.tagName); +} + +export function useKeyboard(): { help: boolean; setHelp: (v: boolean) => void } { + const [help, setHelp] = useState(false); + + useEffect(() => { + // The 'g' prefix expires, so a stray g does not silently arm a jump + // minutes later. + let pending = false; + let timer: number | undefined; + + const onKey = (e: KeyboardEvent) => { + if (e.metaKey || e.ctrlKey || e.altKey) return; + + if (e.key === "Escape") { + if (typing(e.target)) (e.target as HTMLElement).blur(); + setHelp(false); + return; + } + // Never steal a keystroke that was meant for a field. + if (typing(e.target)) return; + + if (e.key === "?") { + e.preventDefault(); + setHelp((h) => !h); + return; + } + if (e.key === "/") { + const box = document.querySelector('input[type="search"]'); + if (box) { + e.preventDefault(); + box.focus(); + box.select(); + } + return; + } + if (pending) { + pending = false; + clearTimeout(timer); + const target = GOTO[e.key.toLowerCase()]; + if (target) { + e.preventDefault(); + setHelp(false); + navigate(target[0]); + } + return; + } + if (e.key === "g") { + pending = true; + clearTimeout(timer); + timer = setTimeout(() => (pending = false), 1500) as unknown as number; + } + }; + + addEventListener("keydown", onKey); + return () => { + removeEventListener("keydown", onKey); + clearTimeout(timer); + }; + }, []); + + return { help, setHelp }; +} diff --git a/collector/dash/src/live.ts b/collector/dash/src/live.ts new file mode 100644 index 0000000..5c5dc42 --- /dev/null +++ b/collector/dash/src/live.ts @@ -0,0 +1,88 @@ +// The SSE client: one EventSource for the whole page, shared by every hook. +// +// Pages subscribe to event *types*, not payloads. An event means "this part of +// the fleet changed" and the subscriber refetches — so a dropped or duplicated +// event costs a redundant GET, never a wrong screen. +import { useEffect, useState } from "preact/hooks"; + +export type LiveState = "connecting" | "live" | "down"; + +type Handler = (event: Record) => void; + +const TYPES = ["hello", "job", "device", "beacon", "result", "lock", "schedule", "artifact", "pipeline-event"] as const; + +const handlers = new Map>(); +const stateWatchers = new Set<(s: LiveState) => void>(); + +let source: EventSource | null = null; +let state: LiveState = "connecting"; +let instance: string | null = null; + +function setState(next: LiveState) { + if (state === next) return; + state = next; + for (const w of stateWatchers) w(next); +} + +function dispatch(type: string, raw: MessageEvent) { + let data: Record = {}; + try { + data = JSON.parse(raw.data) as Record; + } catch { + return; + } + if (type === "hello") { + // A different instance id means the collector restarted under us. Anything + // the page inferred from earlier events may be stale, so everyone refetches. + const seen = data.instance as string; + const restarted = instance !== null && instance !== seen; + instance = seen; + setState("live"); + if (restarted) for (const t of TYPES) for (const h of handlers.get(t) ?? []) h({ type: t, reason: "restart" }); + return; + } + for (const h of handlers.get(type) ?? []) h(data); +} + +function connect() { + if (source) return; + setState(state === "live" ? "live" : "connecting"); + const es = new EventSource("/api/stream"); + source = es; + for (const type of TYPES) es.addEventListener(type, (e) => dispatch(type, e as MessageEvent)); + es.addEventListener("open", () => setState("live")); + es.addEventListener("error", () => { + // EventSource reconnects on its own; the state flag is only so the UI can + // stop claiming to be live while it does. + setState(es.readyState === EventSource.CLOSED ? "down" : "connecting"); + if (es.readyState === EventSource.CLOSED) { + source = null; + // The collector caps concurrent streams, so back off rather than spin. + setTimeout(connect, 3000); + } + }); +} + +/** Subscribe to live event types. Returns the unsubscribe function. */ +export function onLive(types: string[], handler: Handler): () => void { + connect(); + for (const t of types) { + const set = handlers.get(t) ?? new Set(); + set.add(handler); + handlers.set(t, set); + } + return () => { + for (const t of types) handlers.get(t)?.delete(handler); + }; +} + +export function useLiveState(): LiveState { + const [s, setS] = useState(state); + useEffect(() => { + connect(); + stateWatchers.add(setS); + setS(state); + return () => void stateWatchers.delete(setS); + }, []); + return s; +} diff --git a/collector/dash/src/main.tsx b/collector/dash/src/main.tsx new file mode 100644 index 0000000..6fd4596 --- /dev/null +++ b/collector/dash/src/main.tsx @@ -0,0 +1,5 @@ +import { render } from "preact"; +import { App } from "./app.js"; +import "./style.css"; + +render(, document.getElementById("app")!); diff --git a/collector/dash/src/mutate.ts b/collector/dash/src/mutate.ts new file mode 100644 index 0000000..de64d5b --- /dev/null +++ b/collector/dash/src/mutate.ts @@ -0,0 +1,76 @@ +// Mutating calls, and the token they may need to carry. +// +// The token lives in localStorage rather than a cookie: it is a shared secret +// for a single-operator LAN tool, and a cookie would ride along on every +// request including the ones that do not need it. +import { useEffect, useState } from "preact/hooks"; +import { ApiError } from "./api.js"; + +const KEY = "fleet.dash.token"; + +export const getToken = () => localStorage.getItem(KEY) ?? ""; + +export function setToken(token: string) { + if (token) localStorage.setItem(KEY, token); + else localStorage.removeItem(KEY); + dispatchEvent(new Event("fleet:token")); +} + +export function useToken(): [string, (t: string) => void] { + const [token, set] = useState(getToken()); + useEffect(() => { + const on = () => set(getToken()); + addEventListener("fleet:token", on); + return () => removeEventListener("fleet:token", on); + }, []); + return [token, setToken]; +} + +export async function mutate(method: "POST" | "PATCH" | "DELETE", path: string, body?: unknown): Promise { + const token = getToken(); + const res = await fetch(path, { + method, + headers: { + accept: "application/json", + ...(body !== undefined ? { "content-type": "application/json" } : {}), + ...(token ? { "x-fleet-token": token } : {}), + }, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + const json = (await res.json().catch(() => ({}))) as { error?: string }; + if (res.status === 401) { + // Naming the missing header is true and useless. Say what to do. + throw new ApiError(401, token + ? "That dashboard token was rejected. Check it on the System screen." + : "This collector needs a dashboard token — paste it in the banner above, or on the System screen."); + } + if (!res.ok) throw new ApiError(res.status, json.error ?? `${res.status} ${res.statusText}`); + return json as T; +} + +/** + * Wraps one mutation with the state a button needs: in-flight, error, and the + * result note the server sent back. Errors are surfaced, never swallowed — a + * cancel that silently failed is worse than one that visibly did. + */ +export function useMutation(run: () => Promise) { + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [result, setResult] = useState(null); + + const go = async () => { + setBusy(true); + setError(null); + try { + setResult(await run()); + return true; + } catch (e) { + setError(e as Error); + return false; + } finally { + setBusy(false); + } + }; + + return { busy, error, result, go, reset: () => { setError(null); setResult(null); } }; +} diff --git a/collector/dash/src/names.ts b/collector/dash/src/names.ts new file mode 100644 index 0000000..77523ac --- /dev/null +++ b/collector/dash/src/names.ts @@ -0,0 +1,49 @@ +// One shared device-name lookup for the whole dashboard. +// +// A device id is a machine's answer to "who are you" — `sm-x930-0d41`, +// `sdk-gphone64-arm64-b386`. Fine for a URL, useless for knowing which slab of +// glass on the shelf just went thermally critical at 3am. Nicknames existed +// since D2 but only appeared on the device's own page, so you could set a name +// and then never see it again. +// +// Every screen that prints a device id goes through here instead. One fetch, +// shared, refreshed when a device changes — not one request per table row. +import { useEffect, useState } from "preact/hooks"; +import { onLive } from "./live.js"; + +type Names = Record; + +let cache: Names = {}; +let inflight: Promise | null = null; +const watchers = new Set<(n: Names) => void>(); + +async function load(): Promise { + const res = await fetch("/api/devices"); + const body = (await res.json()) as { devices: { device_id: string; name: string | null }[] }; + cache = Object.fromEntries( + body.devices.filter((d) => d.name).map((d) => [d.device_id, d.name as string]), + ); + for (const w of watchers) w(cache); + return cache; +} + +/** Refetch now — call after renaming so every table updates at once. */ +export function refreshNames() { + inflight = load().finally(() => (inflight = null)); + return inflight; +} + +export function useDeviceNames(): Names { + const [names, setNames] = useState(cache); + useEffect(() => { + watchers.add(setNames); + if (!inflight) refreshNames(); + // A rename arrives as a device event, same as a registration. + const off = onLive(["device"], () => void refreshNames()); + return () => { + watchers.delete(setNames); + off(); + }; + }, []); + return names; +} diff --git a/collector/dash/src/pages/Alerts.tsx b/collector/dash/src/pages/Alerts.tsx new file mode 100644 index 0000000..20c73dc --- /dev/null +++ b/collector/dash/src/pages/Alerts.tsx @@ -0,0 +1,160 @@ +// Alerts: what the collector thinks is wrong right now, and what it thought +// was wrong earlier. +import { useApi } from "../api.js"; +import { mutate, useMutation } from "../mutate.js"; +import { useQuery } from "../router.js"; +import { Actions, Button, ErrorBox, Link, Loaded, Panel, Pill, Stat, agoFrom, clock, duration } from "../ui.js"; + +export type Alert = { + id: number; + rule: string; + subject: string; + severity: "warning" | "critical"; + message: string; + state: "open" | "acked" | "snoozed" | "resolved"; + first_seen: string | null; + last_seen: string | null; + resolved_at: string | null; + snooze_until: string | null; + seen_count: number; + age_s: number; +}; +export type AlertList = { + alerts: Alert[]; + counts: Record; + webhook: boolean; + thresholds: Record; +}; + +/** Where an alert's subject actually lives, so a name is a link. */ +function subjectLink(a: Alert) { + if (a.rule.startsWith("device-") || a.rule === "thermal-critical" || a.rule === "low-battery") + return {a.subject}; + if (a.rule.startsWith("job-")) return {a.subject}; + if (a.rule === "schedule-missed") return {a.subject}; + return {a.subject}; +} + +function AlertActions({ a, onDone }: { a: Alert; onDone: () => void }) { + const ack = useMutation(async () => { + const r = await mutate("POST", `/api/alerts/${a.id}/ack`, {}); + onDone(); + return r; + }); + const snooze = useMutation(async () => { + const r = await mutate("POST", `/api/alerts/${a.id}/snooze`, { minutes: 60 }); + onDone(); + return r; + }); + + if (a.state === "resolved") return null; + return ( + <> + + {a.state !== "acked" && ( + + )} + {a.state !== "snoozed" && ( + + )} + + {ack.error && } + {snooze.error && } + + ); +} + +export function Alerts() { + const [q, setQuery] = useQuery(); + const showResolved = q.get("resolved") === "true"; + const state = useApi( + `/api/alerts?state=${showResolved ? "open,acked,snoozed,resolved" : "open,acked,snoozed"}`, + ["alert", "job", "device"], + 30_000, + ); + + return ( + <> +

Alerts

+ + {(d) => ( + <> + +
+ 0 ? "bad" : "ok"} /> + + + +
+

+ {d.webhook + ? "A webhook is configured, so newly opened alerts also go to your phone." + : "No FLEET_ALERT_WEBHOOK is set, so this dashboard is the only place alerts appear."}{" "} + A device is called offline after {duration(d.thresholds.deviceOfflineS)}; a schedule is called missed{" "} + {duration(d.thresholds.scheduleLateS)} after it should have fired. +

+ +
+ + {d.alerts.length === 0 ? ( + +

Nothing is wrong. The collector re-checks every minute.

+
+ ) : ( + d.alerts.map((a) => ( + + + {a.state === "resolved" ? "resolved" : a.severity} + {" "} + {a.state !== "open" && a.state !== "resolved" && {a.state}} + + } + > +

{a.message}

+

+ {subjectLink(a)} · first seen {clock(a.first_seen)} · last seen {agoFrom(a.last_seen)} ·{" "} + {/* seen_count is how many one-minute ticks found it still + true, which is the honest measure of how long it lasted. */} + seen {a.seen_count}× + {a.state === "snoozed" && a.snooze_until ? ` · wakes ${clock(a.snooze_until)}` : ""} + {a.resolved_at ? ` · resolved ${clock(a.resolved_at)}` : ""} +

+ +
+ )) + )} + + )} +
+ + ); +} + +/** The banner every screen carries. Silent unless something is actually open. */ +export function AlertBanner() { + const state = useApi("/api/alerts?state=open", ["alert", "job", "device"], 60_000); + const open = state.data?.alerts ?? []; + if (open.length === 0) return null; + const worst = open.some((a) => a.severity === "critical") ? "critical" : "warning"; + return ( + + {open.length} open alert{open.length === 1 ? "" : "s"} — {open[0].message} + {open.length > 1 ? ` (+${open.length - 1} more)` : ""} + + ); +} diff --git a/collector/dash/src/pages/Artifacts.tsx b/collector/dash/src/pages/Artifacts.tsx new file mode 100644 index 0000000..329b83c --- /dev/null +++ b/collector/dash/src/pages/Artifacts.tsx @@ -0,0 +1,249 @@ +// The content-addressed store: models, app builds, JUnit reports, screenshots +// and batch outputs, with the reference counts that make garbage collection +// safe to reason about. +import { useState } from "preact/hooks"; +import { useApi } from "../api.js"; +import { getToken, mutate, useMutation } from "../mutate.js"; +import { useQuery } from "../router.js"; +import { Actions, Button, ConfirmButton, ErrorBox, Loaded, Pager, Panel, Pill, Search, bytes, clock } from "../ui.js"; + +type Artifact = { + sha256: string; name: string | null; size: number; created_at: string | null; + on_disk: boolean; references: number; + /** Kept whatever the reference scan concludes. */ + pinned: boolean; pin_reason: string | null; + /** Referenced by a baselines row rather than by any spec text, so its + * reference count reads 0 while it is undeletable. */ + baseline: boolean; +}; +type ArtifactList = { page: number; per_page: number; total: number; pages: number; artifacts: Artifact[] }; +type GcList = { days: number; count: number; bytes: number; candidates: { sha256: string; name: string | null; size: number; created_at: string | null }[] }; + +function Upload({ onDone }: { onDone: () => void }) { + const [file, setFile] = useState(null); + const [progress, setProgress] = useState(null); + + const upload = useMutation(async () => { + if (!file) return null; + setProgress(`uploading ${file.name}…`); + const token = getToken(); + // Streamed straight from the File object: the collector hashes to disk as + // bytes arrive, so a multi-GB GGUF never has to fit in anyone's memory. + const res = await fetch("/artifacts", { + method: "POST", + headers: { + "content-type": "application/octet-stream", + "x-artifact-name": file.name, + ...(token ? { "x-fleet-token": token } : {}), + }, + body: file, + duplex: "half", + } as RequestInit & { duplex: string }); + const body = (await res.json().catch(() => ({}))) as { sha256?: string; error?: string }; + setProgress(null); + if (!res.ok) throw new Error(body.error ?? `upload failed (${res.status})`); + setFile(null); + onDone(); + return body; + }); + + return ( + <> +
+ + +
+ {progress &&

{progress}

} + {upload.error && } + {upload.result && ( +

+ Stored as {(upload.result as { sha256?: string }).sha256} +

+ )} + + ); +} + +function Gc({ onDone }: { onDone: () => void }) { + const [days, setDays] = useState(30); + const state = useApi(`/api/artifacts/gc-candidates?days=${days}`, ["artifact"], 0); + + const purge = useMutation(async () => { + const list = state.data?.candidates ?? []; + const results = await Promise.allSettled( + list.map((c) => mutate("DELETE", `/api/artifacts/${c.sha256}`)), + ); + state.reload(); + onDone(); + // Some may now be referenced by a job enqueued since the list was built; + // those come back 409 and are reported rather than retried. + return { + deleted: results.filter((r) => r.status === "fulfilled").length, + refused: results.filter((r) => r.status === "rejected").length, + }; + }); + + return ( + + {(d) => ( + <> +
+ +
+

+ {d.count} artifact{d.count === 1 ? "" : "s"} ({bytes(d.bytes)}) older than {d.days} days + are referenced by no job spec, result, schedule or template. +

+ {d.count > 0 && ( + <> +
+ + + + + + + + {d.candidates.slice(0, 25).map((c) => ( + + + + + + + ))} +
NameSizeCreatedsha256
{c.name ?? unnamed}{bytes(c.size)}{clock(c.created_at)}{c.sha256.slice(0, 12)}…
+
+ {d.candidates.length > 25 &&

…and {d.candidates.length - 25} more.

} + + void purge.go()} + > + Delete unreferenced + + + {purge.error && } + {purge.result && ( +

+ Deleted {(purge.result as { deleted: number }).deleted} + {(purge.result as { refused: number }).refused > 0 && + `, refused ${(purge.result as { refused: number }).refused} (referenced since the list was built)`} + . +

+ )} + + )} + + )} +
+ ); +} + +export function Artifacts() { + const [q, setQuery] = useQuery(); + const search = q.get("q") ?? ""; + const page = Number(q.get("page") ?? 1) || 1; + const params = new URLSearchParams(); + if (search) params.set("q", search); + if (page > 1) params.set("page", String(page)); + + const state = useApi(`/api/artifacts${params.toString() ? `?${params}` : ""}`, ["artifact"], 60_000); + + return ( + <> +

Artifacts

+ + + + + + + + + + +
+ setQuery({ q: v })} /> +
+
+ + + {(d) => ( + + {d.artifacts.length === 0 ? ( +

Nothing in the store yet.

+ ) : ( + <> +
+ + + + + + + + + + + {d.artifacts.map((a) => ( + + + + + + + + + + ))} +
NameSizeRefsKeptOn diskCreatedsha256
{a.name ?? unnamed}{bytes(a.size)}{a.references === 0 ? 0 : a.references} + {/* Why a row with no references is still safe. Without + this the only honest reading of "Refs 0" is "free to + delete", which for a baseline is exactly wrong. */} + {a.baseline ? ( + + baseline + + ) : ( + + )} + + {/* A row without its file is a download failure waiting + to happen for any job that references it. */} + {a.on_disk ? yes : missing} + {clock(a.created_at)} + + {a.sha256.slice(0, 12)}… + +
+
+ setQuery({ page: String(p) })} /> + + )} +
+ )} +
+ + ); +} diff --git a/collector/dash/src/pages/Compose.tsx b/collector/dash/src/pages/Compose.tsx new file mode 100644 index 0000000..c053258 --- /dev/null +++ b/collector/dash/src/pages/Compose.tsx @@ -0,0 +1,360 @@ +// The job composer: build a spec, see who it will land on, enqueue it. +// +// Deliberately not a generic JSON editor. Each workload pre-fills the params +// that workload actually takes, the target picker says how many devices match +// before anything is enqueued, and the equivalent curl is shown throughout — so +// the form teaches the API rather than hiding it. +import { useEffect, useState } from "preact/hooks"; +import { api, useApi, type DeviceList } from "../api.js"; +import { mutate, useMutation } from "../mutate.js"; +import { navigate, useQuery } from "../router.js"; +import { Actions, Button, ErrorBox, Field, Json, Loaded, Panel, Pill, Select } from "../ui.js"; + +type Template = { id: string; name: string | null; spec: Record }; + +// Defaults per workload, matching schemas/job.schema.json. These are starting +// points a person edits, not validation. +const WORKLOADS: Record Record }> = { + benchmark: { + executor: "device", + blurb: "Warmup then measured inference iterations on the device itself.", + spec: () => ({ + backend: "synthetic", + params: { prompt_tokens: 512, gen_tokens: 128, warmup_iters: 1, measure_iters: 3 }, + }), + }, + batch: { + executor: "device", + blurb: "Process every item of an input artifact and upload the outputs.", + spec: () => ({ backend: "llama.cpp", params: { input_sha256: "" } }), + }, + pipeline: { + executor: "device", + blurb: "Subscribe to a topic, process each event, publish to .out.", + spec: () => ({ backend: "llama.cpp", params: { topic: "" } }), + }, + install: { + executor: "host", + blurb: "Install a build on every attached device and verify it launches.", + spec: () => ({ app: { name: "", build: "", sha256: "" } }), + }, + "ui-test": { + executor: "host", + blurb: "Run a Maestro flow or XCUITest bundle against an installed build.", + spec: () => ({ + app: { name: "", build: "", sha256: "" }, + suite: { kind: "maestro", flows: "flows/smoke/*.yaml" }, + }), + }, + drain: { + executor: "host", + blurb: "Run unplugged from a fixed battery level and record the drain curve.", + spec: () => ({ app: { name: "", build: "", sha256: "" }, params: { scenario: "" } }), + }, + soak: { + executor: "host", + blurb: "Leave the app running overnight; the beacon reports process-alive.", + spec: () => ({ app: { name: "", build: "", sha256: "" } }), + }, + thermal: { + executor: "device", + blurb: "Benchmark back to back for a fixed duration; the answer is a curve, not a number.", + spec: () => ({ + backend: "synthetic", + // Fifteen minutes is long enough for a phone to reach the state a pocket + // puts it in, which is the whole question this workload answers. + params: { prompt_tokens: 512, gen_tokens: 128, duration_s: 900 }, + lease: { ttl_s: 1200 }, + // A device on battery throttles for a reason that has nothing to do with + // heat, which would make the curve unreadable. + constraints: { require_charging: true }, + }), + }, + "cold-start": { + executor: "host", + blurb: "Launch the installed build from cold, warm and hot; report p50 and p95 per state.", + spec: () => ({ + app: { name: "", build: "", sha256: "" }, + params: { app_id: "", launches: 10, states: ["cold", "warm", "hot"] }, + }), + }, + "self-check": { + executor: "host", + blurb: "The host inspects itself: disk, tool versions, clock drift, agents loaded.", + spec: () => ({ targets: { executor: "" } }), + }, +}; + +function stamp(workload: string) { + // Local time, no separators — readable in a job list and sortable. + const d = new Date(); + const p = (n: number) => String(n).padStart(2, "0"); + return `${workload}-${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`; +} + +export function Compose() { + const [q] = useQuery(); + const devices = useApi("/api/devices", ["device"]); + const templates = useApi<{ templates: Template[] }>("/api/templates", ["template"]); + + const [workload, setWorkload] = useState(q.get("workload") ?? "benchmark"); + const [jobId, setJobId] = useState(stamp(q.get("workload") ?? "benchmark")); + const [pool, setPool] = useState(q.get("pool") ?? ""); + const [matchExpr, setMatchExpr] = useState(""); + const [deviceId, setDeviceId] = useState(q.get("device") ?? ""); + const [fanout, setFanout] = useState(false); + const [exclusive, setExclusive] = useState(false); + const [priority, setPriority] = useState(0); + const [rest, setRest] = useState(JSON.stringify(WORKLOADS[workload].spec(), null, 2)); + const [restError, setRestError] = useState(null); + + const executor = WORKLOADS[workload]?.executor ?? "device"; + + const applyWorkload = (w: string) => { + setWorkload(w); + setJobId(stamp(w)); + setRest(JSON.stringify(WORKLOADS[w].spec(), null, 2)); + setRestError(null); + if (WORKLOADS[w].executor === "host") setFanout(false); + }; + + const loadTemplate = (t: Template) => { + const { workload: w, executor: _e, targets, fanout: f, priority: p, ...body } = t.spec; + if (w && WORKLOADS[w]) setWorkload(w); + setJobId(stamp(w ?? workload)); + setPool(targets?.pool ?? ""); + setMatchExpr(targets?.match ?? ""); + setDeviceId(targets?.device_id ?? ""); + setExclusive(!!targets?.exclusive); + setFanout(!!f); + setPriority(typeof p === "number" ? p : 0); + setRest(JSON.stringify(body, null, 2)); + setRestError(null); + }; + + // The spec as it stands, or null when the free-form half is not valid JSON. + let spec: Record | null = null; + try { + const body = JSON.parse(rest) as Record; + const targets: Record = {}; + if (pool) targets.pool = pool; + if (matchExpr) targets.match = matchExpr; + if (deviceId) targets.device_id = deviceId; + if (exclusive) targets.exclusive = true; + spec = { + schema: 1, + job_id: jobId, + workload, + executor, + ...body, + ...(Object.keys(targets).length ? { targets } : {}), + ...(fanout ? { fanout: true } : {}), + ...(priority ? { priority } : {}), + }; + } catch (e) { + spec = null; + } + useEffect(() => { + try { + JSON.parse(rest); + setRestError(null); + } catch (e) { + setRestError((e as Error).message); + } + }, [rest]); + + // Live target preview. Runs the same matcher fan-out will run. + const [preview, setPreview] = useState<{ count: number; devices: { device_id: string }[] } | null>(null); + const [previewError, setPreviewError] = useState(null); + useEffect(() => { + if (executor === "host") { + setPreview(null); + return; + } + let live = true; + const t = setTimeout(() => { + mutate<{ count: number; devices: { device_id: string }[] }>("POST", "/api/jobs/preview-targets", { + targets: { pool: pool || undefined, match: matchExpr || undefined, device_id: deviceId || undefined }, + // Sent so the count means "agents that can run this", not "agents this + // pool selects" — otherwise the preview promises devices the queue + // would then refuse to hand the job to. + workload, + }) + .then((r) => live && (setPreview(r), setPreviewError(null))) + .catch((e) => live && (setPreview(null), setPreviewError((e as Error).message))); + }, 300); + return () => { + live = false; + clearTimeout(t); + }; + }, [pool, matchExpr, deviceId, executor, workload]); + + const enqueue = useMutation(async () => { + const out = await mutate<{ ok: boolean; job_id?: string; fanout?: string[] }>("POST", "/api/jobs", spec); + // Fan-out returns children; a single job returns its id. Land on whichever + // page can actually show what just happened. + if (out.fanout?.length) navigate(`/jobs?q=${encodeURIComponent(jobId)}`); + else if (out.job_id) navigate(`/jobs/${encodeURIComponent(out.job_id)}`); + return out; + }); + + const saveTemplate = useMutation(async () => { + const id = prompt("Template id", `${workload}-template`); + if (!id) return null; + const { job_id: _drop, ...body } = spec ?? {}; + await mutate("POST", "/api/templates", { id, name: id, spec: body }); + templates.reload(); + return { id }; + }); + + const curl = spec + ? `curl -X POST ${location.origin}/jobs \\\n -H 'content-type: application/json' \\\n -d '${JSON.stringify(spec)}'` + : ""; + + return ( + <> +

New job

+ + +
+ setJobId((e.target as HTMLInputElement).value)} /> + + + setPriority(Number((e.target as HTMLInputElement).value) || 0)} /> + +
+

+ {WORKLOADS[workload]?.blurb} Runs on the {executor} executor. +

+
+ + + + {(d) => ( + <> +
+ dev.device_id)} + onChange={setDeviceId} + /> + + setMatchExpr((e.target as HTMLInputElement).value)} /> + +
+
+ + +
+ + {executor === "host" ? ( +

+ Host-executor jobs are claimed by the executor on the Mac, which fans across whatever devices are + attached to it — so there is nothing to preview here. +

+ ) : previewError ? ( +

{previewError}

+ ) : preview ? ( +

+ {preview.count} device{preview.count === 1 ? "" : "s"} match + {preview.count === 1 ? "es" : ""} this target + {fanout && preview.count > 0 ? ` — fan-out would enqueue ${preview.count} child jobs` : ""}. + {preview.count > 0 && ( + {preview.devices.map((x) => x.device_id).join(", ")} + )} + {preview.count === 0 && Nothing would ever claim this job.} +

+ ) : null} + + )} +
+
+ + + +