From f025b2529d6110cbf31c3f91b60fecd1b30208d1 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sat, 22 Aug 2026 20:34:08 +0100 Subject: [PATCH 1/3] feat(web): SPA skeleton embedded in the binary Vite + TypeScript, no framework. One health board page proves the whole path: fetch /api/health, render per-upstream verdicts. Biome and tsc join the just ci gate; dev mode proxies /api to the daemon. arr-daemon embeds web/dist via include_dir: build.rs runs pnpm when available, accepts ARR_WEB_DIST prebuilt, and falls back to a placeholder only in debug so the node-less CI rust job keeps working. Closes #6 Co-Authored-By: Claude Fable 5 --- .gitignore | 1 + .impeccable/design.json | 192 ++++++ Cargo.lock | 37 + PRODUCT.md | 87 +++ biome.json | 25 + crates/arr-daemon/Cargo.toml | 2 + crates/arr-daemon/build.rs | 127 ++++ crates/arr-daemon/src/main.rs | 5 +- crates/arr-daemon/src/web.rs | 137 ++++ web/index.html | 114 ++++ web/package.json | 20 + web/pnpm-lock.yaml | 786 ++++++++++++++++++++++ web/pnpm-workspace.yaml | 4 + web/public/fonts/rajdhani-600-latin.woff2 | Bin 0 -> 15732 bytes web/public/fonts/rajdhani-700-latin.woff2 | Bin 0 -> 15688 bytes web/src/health.ts | 42 ++ web/src/main.ts | 159 +++++ web/src/style.css | 488 ++++++++++++++ web/tsconfig.json | 21 + web/vite.config.ts | 29 + 20 files changed, 2275 insertions(+), 1 deletion(-) create mode 100644 .impeccable/design.json create mode 100644 PRODUCT.md create mode 100644 biome.json create mode 100644 crates/arr-daemon/build.rs create mode 100644 crates/arr-daemon/src/web.rs create mode 100644 web/index.html create mode 100644 web/package.json create mode 100644 web/pnpm-lock.yaml create mode 100644 web/pnpm-workspace.yaml create mode 100644 web/public/fonts/rajdhani-600-latin.woff2 create mode 100644 web/public/fonts/rajdhani-700-latin.woff2 create mode 100644 web/src/health.ts create mode 100644 web/src/main.ts create mode 100644 web/src/style.css create mode 100644 web/tsconfig.json create mode 100644 web/vite.config.ts diff --git a/.gitignore b/.gitignore index 1884ec6..2da8cb7 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ result result-* /worktrees +*.tsbuildinfo diff --git a/.impeccable/design.json b/.impeccable/design.json new file mode 100644 index 0000000..9b7812e --- /dev/null +++ b/.impeccable/design.json @@ -0,0 +1,192 @@ +{ + "schemaVersion": 2, + "generatedAt": "2026-08-22T00:00:00.000Z", + "title": "Design System: arr — Master Control", + "extensions": { + "colorMeta": { + "ground": { + "role": "neutral", + "displayName": "Console Charcoal", + "canonical": "oklch(0.14 0.01 250)", + "tonalRamp": [ + "oklch(0.14 0.01 250)", + "oklch(0.18 0.012 250)", + "oklch(0.21 0.014 250)", + "oklch(0.3 0.015 250)", + "oklch(0.42 0.02 250)", + "oklch(0.58 0.015 250)", + "oklch(0.72 0.015 250)", + "oklch(0.93 0.008 250)" + ] + }, + "signal-ok": { + "role": "secondary", + "displayName": "Signal Green", + "canonical": "oklch(0.8 0.17 150)", + "tonalRamp": ["oklch(0.45 0.1 150)", "oklch(0.62 0.14 150)", "oklch(0.8 0.17 150)", "oklch(0.88 0.12 150)"] + }, + "signal-warn": { + "role": "tertiary", + "displayName": "Caution Amber", + "canonical": "oklch(0.82 0.14 80)", + "tonalRamp": ["oklch(0.5 0.1 80)", "oklch(0.66 0.13 80)", "oklch(0.82 0.14 80)", "oklch(0.9 0.1 80)"] + }, + "signal-fault": { + "role": "secondary", + "displayName": "Fault Red", + "canonical": "oklch(0.68 0.21 25)", + "tonalRamp": ["oklch(0.42 0.15 25)", "oklch(0.55 0.19 25)", "oklch(0.68 0.21 25)", "oklch(0.8 0.14 25)"] + }, + "accent": { + "role": "primary", + "displayName": "Phosphor Cyan", + "canonical": "oklch(0.8 0.12 220)", + "tonalRamp": ["oklch(0.5 0.1 220)", "oklch(0.65 0.11 220)", "oklch(0.8 0.12 220)", "oklch(0.9 0.08 220)"] + } + }, + "typographyMeta": { + "display": { + "displayName": "Display (Rajdhani)", + "purpose": "Rajdhani 700 — wordmark and module names only. Machined, condensed, uppercase with 0.1-0.12em tracking. Self-hosted woff2 in web/public/fonts (no CDN: the SPA is embedded in the binary and must work offline)." + }, + "label": { + "displayName": "Label (system sans)", + "purpose": "system-ui 600 uppercase, 0.6875rem, 0.12em tracking — engraved micro-labels (module roles). Never a kicker above a heading." + }, + "readout": { + "displayName": "Readout (mono)", + "purpose": "ui-monospace stack — every value the operator reads: status words, details, version, rtt. Mono is for measurement, never costume." + } + }, + "shadows": [ + { + "name": "panel-highlight", + "value": "inset 0 1px oklch(1 0 0 / 4%)", + "purpose": "The machined top edge of every panel and control. The only non-lamp shadow in the system." + }, + { + "name": "lamp-glow", + "value": "0 0 6px var(--lamp), 0 0 14px oklch(from var(--lamp) l c h / 35%)", + "purpose": "A lamp's own light. Reserved for .lamp; never decoration on panels or text." + } + ], + "motion": [ + { + "name": "ease-out", + "value": "cubic-bezier(0.25, 1, 0.5, 1)", + "purpose": "Default easing for state transitions." + }, + { + "name": "duration-state", + "value": "150-300ms", + "purpose": "Hover/focus 150ms, lamp and trace state changes 300ms." + }, + { + "name": "power-up strike", + "value": "lamp-strike 420ms, staggered 120ms upstream→downstream, once", + "purpose": "The one authored moment: the board lights in signal order on first verdict. Guarded by prefers-reduced-motion." + } + ], + "breakpoints": [{ "name": "stack", "value": "46rem — the chain turns vertical, one junctioned trace" }], + "themes": { + "strategy": "committed dark only — a control room is lit by its lamps; the operator uses this on a homelab desk at night. color-scheme: dark is declared in the shell.", + "dark": { + "bg": "oklch(0.14 0.01 250)", + "ink": "oklch(0.93 0.008 250)", + "primary": "oklch(0.8 0.12 220)" + } + } + }, + "components": [ + { + "name": "Lamp", + "kind": "custom", + "refersTo": "signal-ok", + "description": "Status lamp. Hue by state (ok/degraded/unconfigured/unreachable), glow is its own light. data-state=\"off\" is dead air: dark, no glow, no motion. Always paired with a status word — remove color and the state must survive.", + "html": "", + "css": ".lamp { --lamp: var(--signal-idle); width: 0.625rem; height: 0.625rem; border-radius: 50%; background: var(--lamp); box-shadow: 0 0 6px var(--lamp), 0 0 14px oklch(from var(--lamp) l c h / 35%); } .lamp[data-state=\"ok\"] { --lamp: var(--signal-ok); } .lamp[data-state=\"off\"] { --lamp: oklch(0.3 0.012 250); box-shadow: none; }" + }, + { + "name": "Module (channel strip)", + "kind": "card", + "refersTo": "ground", + "description": "One unit on the chain: lamp + Rajdhani name + mono status word, role label, mono detail. Panel with hairline border and 1px inset highlight; 4px machined radius, never a soft card.", + "html": "

prowlarr

ok

indexers in

torznab search and rss

", + "css": ".module { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: inset 0 1px var(--highlight); padding: var(--space-4) var(--space-4) var(--space-3); }" + }, + { + "name": "Bus trace", + "kind": "custom", + "refersTo": "ground", + "description": "Signal path between modules: hairline + arrowhead SVG, tethered to its source module's row. Lit with the source's signal hue via data-tone; inert gray when nothing flows.", + "html": "", + "css": ".bus-trace { --trace: var(--line-strong); } .bus-trace line { stroke: var(--trace); } .bus-arrow { fill: var(--trace); } .bus-trace[data-tone=\"ok\"] { --trace: var(--signal-ok); }" + }, + { + "name": "Control", + "kind": "button", + "refersTo": "accent", + "description": "The interactive element: mono uppercase, phosphor-cyan text, panel ground, ≥44px hit target. Cyan belongs to interaction only — never to status.", + "html": "", + "css": ".control { min-height: 2.75rem; padding: 0 var(--space-4); font-family: var(--font-readout); text-transform: uppercase; letter-spacing: 0.08em; color: var(--accent); background: var(--panel-raised); border: 1px solid var(--line-strong); border-radius: var(--radius); }" + }, + { + "name": "Rail", + "kind": "nav", + "refersTo": "ground", + "description": "Full-width panel strip framing the board top (identity + verdict + controls) and bottom (readouts). Hairline-ruled, panel ground, inset highlight.", + "html": "

arr

all signals nominal

", + "css": ".rail { display: flex; align-items: center; gap: var(--space-6); padding: var(--space-3) var(--space-6); border-bottom: 1px solid var(--line); background: var(--panel); box-shadow: inset 0 1px var(--highlight); }" + } + ], + "narrative": { + "northStar": "Master Control", + "overview": "arr's UI is a broadcast master-control panel: the daemon is a signal chain (TMDB and Prowlarr feed in, Transmission is driven out) and every surface reads as lamps, readouts and traces on that chain. Near-black charcoal ground, machined panels with hairline borders and a 1px inset highlight, Rajdhani display names, monospace readouts, three semantic lamp hues plus one phosphor-cyan interactive hue. Committed dark. Built for the operator's one-glance question: can arr do its job, and if not, which upstream is down.", + "keyCharacteristics": [ + "Signal chain, not dashboard: topology mirrors DESIGN.md §3; no sidebar, no stat cards, no hero metric.", + "Lamps carry state, words carry proof — every hue is paired with a mono status word.", + "Rajdhani 700 uppercase for names, system sans for micro-labels, mono for every value.", + "Panels are machined: hairline border, inset highlight, 4px radius; never soft shadows or glass.", + "One authored motion: the power-up strike, upstream to downstream, once." + ], + "rules": [ + { + "name": "The Lamp-Hue Rule", + "body": "Green/amber/red belong to state and to nothing else. Interaction is phosphor-cyan. A saturated color that is neither a state nor an interactive affordance is a bug.", + "section": "colors" + }, + { + "name": "The Word-Beside-The-Lamp Rule", + "body": "Every lamp and lit trace is paired with a monospace status word. Remove all color and the board must still read.", + "section": "colors" + }, + { + "name": "The Readout Rule", + "body": "Monospace is for values the operator reads — statuses, sizes, latencies, versions. Prose and labels are never mono; names are never sans.", + "section": "typography" + }, + { + "name": "The Machined-Panel Rule", + "body": "Depth is one inset 1px highlight on a hairline-bordered panel. No drop shadows, no glass, no gradients. The only glow on the board is a lamp's own light.", + "section": "elevation" + }, + { + "name": "The Dead-Air Rule", + "body": "When nothing is flowing, nothing animates. Pulse means probing; data-state=\"off\" is dark and still. Motion that misreports activity is a truth bug.", + "section": "motion" + } + ], + "dos": [ + "Do lay future surfaces (manual search buckets, queues) out as panels and readouts consuming these tokens; chips in §9.3 are small panels with mono values.", + "Do keep every control ≥44px, focus rings cyan and visible, and reduced-motion honored.", + "Do keep muted text at or above 4.5:1 on panel ground (--ink-muted floor).", + "Do self-host every asset; the SPA is embedded in the binary and must work with no network." + ], + "donts": [ + "Don't introduce a second interactive hue or use cyan for a status.", + "Don't box content in soft-shadowed rounded cards or add glass/blur.", + "Don't put a kicker/eyebrow label above a heading.", + "Don't animate idle or dead elements; the strike runs once, on real data." + ] + } +} diff --git a/Cargo.lock b/Cargo.lock index 8904f19..3847a64 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -55,6 +55,8 @@ version = "0.1.0" dependencies = [ "arr-api", "axum", + "include_dir", + "mime_guess", "reqwest", "serde", "tempfile", @@ -966,6 +968,25 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "include_dir" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd" +dependencies = [ + "include_dir_macros", +] + +[[package]] +name = "include_dir_macros" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75" +dependencies = [ + "proc-macro2", + "quote", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -1115,6 +1136,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "mio" version = "1.2.2" @@ -2354,6 +2385,12 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-bidi" version = "0.3.18" diff --git a/PRODUCT.md b/PRODUCT.md new file mode 100644 index 0000000..27ebeef --- /dev/null +++ b/PRODUCT.md @@ -0,0 +1,87 @@ +# Product + + + +All facts below are drawn from DESIGN.md and the issue tracker; none were +confirmed in an interview (the driving session mandated autonomous operation). +Items marked *(inferred)* are best-effort readings, not user-approved. + +## Platform + +web + +## Stack + +Vite + TypeScript SPA, no framework *(fixed by DESIGN.md §11: `web/` is a +Vite + TypeScript SPA embedded in the Rust binary via `include_dir`; the +reference project `~/tea/maestro` is framework-free vanilla TS)*. + +## Users + +A single household's operator (the person who runs the media stack) and, via +filtered views, a small set of family members. Used on a self-hosted LAN/VPN, +mostly desktop, sometimes phone. The operator's job: decide what media is +wanted, watch it get sourced, and resolve the queue items that need a human +call (no-PT-source queue, waivers, manual grabs). + +## Product Purpose + +One service replacing Radarr, Sonarr and later Bazarr: decide what you want, +find it, fetch it, put it somewhere Jellyfin can read. Success is a 20-40 MB +single binary doing what five .NET services do at 600-900 MB — with a UI whose +manual-search view is actually usable. + +## Positioning + +Policy engine classifies every release candidate before the human sees it. +The UI shows decisions (eligible / waived / rejected, with the killing rule +named), not raw release-name dumps. Radarr cannot truthfully claim this; its +manual search buries the decision under the release name column. + +## Operating Context + +- Self-hosted behind VPN + Authelia; the app itself has no auth layer. +- API-first: the SPA is one client of the OpenAPI-described HTTP API, no + privileged path. +- Coexists with Prowlarr (indexers), Transmission (downloads), Jellyfin + (playback), Jellyseerr (requests), ntfy (notifications). + +## Capabilities and Constraints + +- Phase 1 (now): skeleton only — health endpoint, embedded empty SPA shell + with one page rendering `GET /api/health`. +- Phase 5 brings the real UI: unified search (§9.2), manual search buckets + (§9.3), library views, queues. +- The manual-search view is the reason the UI exists; §9.3 fixes chip-based + columns, three buckets, secondary release names. Future tokens/components + must serve dense, scannable, chip-heavy tabular data. +- CI gate: `biome ci web/` and `tsc -b --noEmit`; target under 5 min total. +- Bundle is embedded at compile time; no CDN, no external assets at runtime + *(inferred from the include_dir + single-binary requirement)*. + +## Brand Commitments + +Name: `arr`, lowercase. No logo, no committed palette or typography yet +*(inferred: nothing visual exists in the repo)*. Voice of DESIGN.md is terse, +technical, anti-noise — the UI should read the same way. + +## Evidence on Hand + +- DESIGN.md — the contract, including §9.2/§9.3 UI specs. +- `~/tea/maestro` — reference for repo/web mechanics, not visual identity. +- No screenshots, no user testimonials, no existing UI. Nothing to fabricate. + +## Product Principles + +- Decisions over data: surface the classification, keep the raw evidence one + expand away. +- Quiet by default: default views show only what needs attention; everything + satisfied collapses (mirrors §4.2 and §9.5's notification restraint). +- Density with fixed structure: chips and fixed-width columns, never + horizontal scroll. +- One surface of truth: the API; the UI never has a privileged path. + +## Accessibility & Inclusion + +No product-specific requirement established. Household includes a pre-reader +child, but the child consumes Jellyfin, not this UI *(inferred)*. diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..145abb6 --- /dev/null +++ b/biome.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.5.8/schema.json", + "files": { + "includes": ["web/**", "!web/src/api/**", "!web/dist/**", "!web/node_modules/**"] + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 100 + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true + } + }, + "assist": { + "actions": { + "source": { + "organizeImports": "on" + } + } + } +} diff --git a/crates/arr-daemon/Cargo.toml b/crates/arr-daemon/Cargo.toml index 8ecd7b1..3ecb567 100644 --- a/crates/arr-daemon/Cargo.toml +++ b/crates/arr-daemon/Cargo.toml @@ -13,6 +13,8 @@ path = "src/main.rs" [dependencies] arr-api = { workspace = true } axum = { workspace = true } +include_dir = { workspace = true } +mime_guess = { workspace = true } reqwest = { workspace = true } serde = { workspace = true } thiserror = { workspace = true } diff --git a/crates/arr-daemon/build.rs b/crates/arr-daemon/build.rs new file mode 100644 index 0000000..21bdd32 --- /dev/null +++ b/crates/arr-daemon/build.rs @@ -0,0 +1,127 @@ +//! Resolve the SPA bundle embedded by `web.rs` via `include_dir!`. +//! +//! Three modes, in order: +//! +//! * `ARR_WEB_DIST` set — a prebuilt bundle (Nix, CI, release pipeline) is +//! embedded as-is. Missing or empty means a broken pipeline: fail. +//! * pnpm available — the default developer path: build `web/` and embed +//! `web/dist`. Failures in the build itself fail loudly. +//! * pnpm missing, debug profile — embed a generated placeholder page and +//! warn. This keeps `cargo clippy`/`cargo test` working on runners with no +//! node toolchain (the CI rust job, DESIGN.md §12). +//! +//! A release build never gets the placeholder: shipping a binary whose UI is +//! an apology defeats the single-binary acceptance test, so that combination +//! panics instead. + +use std::env; +use std::path::PathBuf; +use std::process::Command; + +fn main() { + println!("cargo:rerun-if-env-changed=ARR_WEB_DIST"); + + let dist = match env::var_os("ARR_WEB_DIST") { + Some(prebuilt) => PathBuf::from(prebuilt), + None => build_or_placeholder(), + }; + + let index = dist.join("index.html"); + let bundled = std::fs::metadata(&index).is_ok_and(|m| m.is_file() && m.len() > 0); + assert!( + bundled, + "no SPA bundle at {} (missing or empty index.html) — check the vite \ + outDir or the ARR_WEB_DIST override", + index.display() + ); + + // `include_dir!` expands `$VAR` against the env rustc is invoked with, + // which is what `cargo:rustc-env` sets. + println!("cargo:rustc-env=ARR_WEB_BUNDLE_DIR={}", dist.display()); +} + +fn build_or_placeholder() -> PathBuf { + let manifest_dir = + PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("cargo sets CARGO_MANIFEST_DIR")); + let web_dir = manifest_dir.join("../../web"); + + // Rebuild only when a frontend input changes, not on unrelated Rust + // recompiles. The dist watch matters too: the inputs decide whether this + // script reruns, but `include_dir!` reads dist, and without this watch an + // incremental build can regenerate dist yet keep the old bytes embedded. + for rel in [ + "src", + "index.html", + "package.json", + "pnpm-lock.yaml", + "pnpm-workspace.yaml", + "vite.config.ts", + "tsconfig.json", + "dist", + ] { + println!("cargo:rerun-if-changed={}", web_dir.join(rel).display()); + } + + if !tool_works("pnpm") { + let profile = env::var("PROFILE").unwrap_or_default(); + assert!( + profile != "release", + "arr embeds the web SPA at build time and needs pnpm + node for a \ + release build. Install them, or point ARR_WEB_DIST at a prebuilt \ + bundle." + ); + println!( + "cargo:warning=pnpm not found; embedding a placeholder page instead of \ + the web SPA (debug builds only)" + ); + return write_placeholder(); + } + + run( + Command::new("pnpm") + .args(["install", "--frozen-lockfile"]) + .current_dir(&web_dir), + "pnpm install --frozen-lockfile", + ); + run( + Command::new("pnpm").arg("build").current_dir(&web_dir), + "pnpm build", + ); + + web_dir.join("dist") +} + +fn write_placeholder() -> PathBuf { + let out = PathBuf::from(env::var("OUT_DIR").expect("cargo sets OUT_DIR")).join("placeholder"); + std::fs::create_dir_all(&out).expect("create placeholder dir"); + std::fs::write(out.join("index.html"), PLACEHOLDER_HTML).expect("write placeholder"); + out +} + +fn tool_works(tool: &str) -> bool { + Command::new(tool) + .arg("--version") + .output() + .is_ok_and(|o| o.status.success()) +} + +fn run(cmd: &mut Command, label: &str) { + let status = cmd + .status() + .unwrap_or_else(|e| panic!("failed to spawn `{label}`: {e}")); + assert!(status.success(), "`{label}` failed with {status}"); +} + +const PLACEHOLDER_HTML: &str = r#" + +arr — web bundle not built + +

arr is running

+

but this binary was built without the web bundle (no pnpm on the build +machine). Rebuild with pnpm + node installed, or set ARR_WEB_DIST +to a prebuilt bundle.

+"#; diff --git a/crates/arr-daemon/src/main.rs b/crates/arr-daemon/src/main.rs index 4774b65..6a2c3e4 100644 --- a/crates/arr-daemon/src/main.rs +++ b/crates/arr-daemon/src/main.rs @@ -1,6 +1,7 @@ //! arr — reconcile loop and process entry point. See DESIGN.md §8. mod config; +mod web; use std::process::ExitCode; @@ -72,7 +73,9 @@ async fn run() -> Result<(), Error> { .with_tmdb_api_key(config.tmdb_api_key), )?; - let app = arr_api::router(state).layer(TraceLayer::new_for_http()); + let app = arr_api::router(state) + .fallback(web::serve) + .layer(TraceLayer::new_for_http()); let listener = tokio::net::TcpListener::bind(config.bind_addr) .await diff --git a/crates/arr-daemon/src/web.rs b/crates/arr-daemon/src/web.rs new file mode 100644 index 0000000..2ca285c --- /dev/null +++ b/crates/arr-daemon/src/web.rs @@ -0,0 +1,137 @@ +//! The embedded SPA. See DESIGN.md §11: `web/` is built by Vite and compiled +//! into this binary via `include_dir`, so `cargo run` serves the whole UI +//! with no external files. `build.rs` resolves which directory gets embedded. + +use axum::http::{header, StatusCode, Uri}; +use axum::response::{IntoResponse, Response}; +use include_dir::{include_dir, Dir}; + +static BUNDLE: Dir<'_> = include_dir!("$ARR_WEB_BUNDLE_DIR"); + +/// Fallback handler for everything the API router did not claim. +pub async fn serve(uri: Uri) -> Response { + serve_path(uri.path()) +} + +/// Serve a path from the bundle. +/// +/// Content-hashed files under `assets/` are immutable, so they get a year of +/// `immutable` cache. The shell and everything else is `no-cache`: the shell +/// references the current hashed asset names and a stale cached copy would +/// point at files that no longer exist after a rebuild. +/// +/// A miss falls back to the shell only for extensionless paths (client-side +/// routes). A miss that names a file — a stale `assets/index-OLD.js` — is a +/// real 404: serving HTML where the browser expects JS blanks the page. And +/// `/api/*` never falls through to HTML; an unknown API path is a 404. +fn serve_path(path: &str) -> Response { + let trimmed = path.trim_start_matches('/'); + if trimmed == "api" || trimmed.starts_with("api/") { + return not_found(); + } + if trimmed.is_empty() { + return serve_index(); + } + match BUNDLE.get_file(trimmed) { + Some(file) => { + let mime = mime_guess::from_path(trimmed).first_or_octet_stream(); + let cache = if trimmed.starts_with("assets/") { + "public, max-age=31536000, immutable" + } else { + "no-cache" + }; + ( + [ + (header::CONTENT_TYPE, mime.as_ref()), + (header::CACHE_CONTROL, cache), + ], + file.contents(), + ) + .into_response() + } + None if names_a_file(trimmed) => not_found(), + None => serve_index(), + } +} + +fn serve_index() -> Response { + match BUNDLE.get_file("index.html") { + Some(file) => ( + [ + (header::CONTENT_TYPE, "text/html; charset=utf-8"), + (header::CACHE_CONTROL, "no-cache"), + ], + file.contents(), + ) + .into_response(), + // build.rs guarantees index.html exists in whatever it embedded. + None => not_found(), + } +} + +/// Whether the last segment carries an extension, i.e. names a concrete file +/// rather than a client-side route. +fn names_a_file(path: &str) -> bool { + path.rsplit('/').next().is_some_and(|seg| seg.contains('.')) +} + +fn not_found() -> Response { + ( + StatusCode::NOT_FOUND, + [(header::CONTENT_TYPE, "text/plain; charset=utf-8")], + "not found", + ) + .into_response() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn content_type(response: &Response) -> String { + response + .headers() + .get(header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or_default() + .to_owned() + } + + #[test] + fn the_shell_is_embedded_html() { + let response = serve_path("/"); + assert_eq!(response.status(), StatusCode::OK); + assert!(content_type(&response).starts_with("text/html")); + } + + #[test] + fn an_extensionless_route_falls_back_to_the_shell() { + let response = serve_path("/movies/42"); + assert_eq!(response.status(), StatusCode::OK); + assert!(content_type(&response).starts_with("text/html")); + } + + #[test] + fn a_missing_hashed_asset_is_a_404_never_html() { + let response = serve_path("/assets/index-deadbeef.js"); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + assert!(!content_type(&response).starts_with("text/html")); + } + + #[test] + fn unknown_api_paths_never_fall_through_to_html() { + let response = serve_path("/api/nope"); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + assert!(!content_type(&response).starts_with("text/html")); + } + + #[test] + fn the_shell_is_never_cached() { + let cache = serve_path("/") + .headers() + .get(header::CACHE_CONTROL) + .and_then(|v| v.to_str().ok()) + .map(str::to_owned); + assert_eq!(cache.as_deref(), Some("no-cache")); + } +} diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..ac7c7f0 --- /dev/null +++ b/web/index.html @@ -0,0 +1,114 @@ + + + + + + + arr + + + + +
+
+ +

arr

+
+

probing chain…

+
+ v — + +
+
+ +
+
+
+
+ +

tmdb

+ probing +
+

metadata in

+

titles, languages, release dates

+
+ + + +
+
+ +

prowlarr

+ probing +
+

indexers in

+

torznab search and rss

+
+ + + +
+
+ +

arr

+ probing +
+

reconcile core

+

decide · find · fetch · import

+
+ + + +
+
+ +

transmission

+ probing +
+

torrents out

+

rpc · grab and seed

+
+
+
+ +
+ rtt — + repolls every 15 s +
+ + diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..e0a11e9 --- /dev/null +++ b/web/package.json @@ -0,0 +1,20 @@ +{ + "name": "arr-web", + "private": true, + "version": "0.1.0", + "type": "module", + "packageManager": "pnpm@11.21.0", + "scripts": { + "dev": "vite", + "build": "tsc -b --noEmit && vite build", + "preview": "vite preview", + "typecheck": "tsc -b --noEmit", + "lint": "biome ci ." + }, + "devDependencies": { + "@biomejs/biome": "^2.5.8", + "@types/node": "^24.0.0", + "typescript": "^5.9.3", + "vite": "^7.1.0" + } +} diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml new file mode 100644 index 0000000..bc48d7a --- /dev/null +++ b/web/pnpm-lock.yaml @@ -0,0 +1,786 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@biomejs/biome': + specifier: ^2.5.8 + version: 2.5.10 + '@types/node': + specifier: ^24.0.0 + version: 24.13.3 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vite: + specifier: ^7.1.0 + version: 7.3.6(@types/node@24.13.3) + +packages: + + '@biomejs/biome@2.5.10': + resolution: {integrity: sha512-WRKXARA3kTuiV5sxqTpobJ/I0MVd4vk3pOL6wnp5az4LntFIhWTj1RWZq3DI9PCEN3lXcqy7p5aqUHzvq8AXyQ==} + engines: {node: '>=14.21.3'} + hasBin: true + + '@biomejs/cli-darwin-arm64@2.5.10': + resolution: {integrity: sha512-ItCrxKK6SXVT6flYs0qIuBd4AA3TTTl4d66Re6YI2FuGZnN85NmuYNzkiTJUyYw8qBLv69L5zTUB6uyWd++h3Q==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [darwin] + + '@biomejs/cli-darwin-x64@2.5.10': + resolution: {integrity: sha512-yLsPU9pAmtChXDu8vhKAzErqe+LeeYuwuUB2FZMkRitsmdodxsYRa9KHrFispsUHzzOu+9HB3nP/TQxyia+Sjw==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [darwin] + + '@biomejs/cli-linux-arm64-musl@2.5.10': + resolution: {integrity: sha512-t1QAKZwQJRB4dvgJSgFiQ4BNfNPChg69BNonz854qLVxnjT3UvDzQg9mbkTJRu35ZqU0Rw10A73J8Urgbg2RPw==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@biomejs/cli-linux-arm64@2.5.10': + resolution: {integrity: sha512-VG8uQW/86a1roLaIFvtIbEigxIdzdJ190oGyg1tV7VYeQtOS+x10sflk7WbuXgw91EtZX5DlIIIej1YqkNLlcg==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@biomejs/cli-linux-x64-musl@2.5.10': + resolution: {integrity: sha512-pgDDqp9JybHm2I0KRgzN6i4+lt8xu4iqxUwLzglUMmOmyRTU1AYBGKzh9sNMOtIjah7xoWvKHlLVetvyifzoiQ==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@biomejs/cli-linux-x64@2.5.10': + resolution: {integrity: sha512-4O6T0eq2heoHZN0a9UX+rWQoxXEBaKf+lRi2hbsGlHneUz9BWXM76nEWMK7Eeq8gzMxR1khQB6BFpAASpeXqGg==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@biomejs/cli-win32-arm64@2.5.10': + resolution: {integrity: sha512-pxAbxduPO4xq/Cvgaa2lOrs9BB0hEXmmDqfMNP4ZOffGOkUrD1/QGw9UAMpFQpX2P8MqTIIRuQKcmetum4Oa6A==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [win32] + + '@biomejs/cli-win32-x64@2.5.10': + resolution: {integrity: sha512-M+2dgBsl3lXRiTfgPVc2p3anS4Tocojke4rzFLScZ2Y/wmF+36dRb1iHCLiyGqOzQGyTplZH1HnEYviiAqi3nA==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [win32] + + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-android-arm-eabi@4.62.5': + resolution: {integrity: sha512-jfkGfTwhQpsiSckPF8r9bU3pn3vyd72NlWaO+TgEO6WPSDnUhXzrNYCHBMOYj0ACaUgjm6eERLF+XV9a6RstoA==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.62.5': + resolution: {integrity: sha512-oGVqyQlxnrz9/ty89oHpU857VUHEl5/Xu4R2lS+aivCTrNnSsbiENzTnNaBsjxH0CNWGPhzHArOLFwo+oKXveA==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.62.5': + resolution: {integrity: sha512-bW7B8xMEq8n99Q3ieEcPRGuphurdZAaFzQc9Efyyw3FL6DZO6pMy9xhdN+kBoD7Sy05xNXSr4OyPPnpkYriS/A==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.62.5': + resolution: {integrity: sha512-YSwBS86QeHOGlrxJ1PSOIZSkzRL/JmKeunhc+lV6M1a6En8QuVCD/T/qIA0J4Gd2Y86RIOBYrLcOUtqGh9+/1w==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.62.5': + resolution: {integrity: sha512-2fST8lILgl7cKbme/1KDdPCmbXbG+gqoV3bHp19L0ypX/3akYMBVdOunPleRCwonoLnXOZ/0F+Mt/v8POFmfcQ==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.62.5': + resolution: {integrity: sha512-cpIxQCP9J+EVad0a6LO1kY3ZGODlk80VlI+2I96B8xMcdHZ4pLVhfQ49JFpYqjPF91FFkQWftf57YlDcTiw9yQ==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.62.5': + resolution: {integrity: sha512-r9fGh3eFs3e/udWh5ZjXQtxiYK/xoFxQaYR/cELxac/Udkl5Th+IsFm0CX3Kl9hmUH/we7EoMpjJgeQNnE0+IA==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.62.5': + resolution: {integrity: sha512-xdvFdp7OM6KLJviJT2g/YuRSUjnZgGHk4RNgwIbN7X6cPugOucV60DdHXWzsBVCUdrGb6qSXnJQrrAKMmQuj3Q==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.62.5': + resolution: {integrity: sha512-rRqILAndyzHzP7T9NFQrq+4HFWNhqkqkKur7eiBpfLmz01PO0JKx5Vchu3YllE4YXI/Ftgq/szrDWg5GJ0mI8g==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.62.5': + resolution: {integrity: sha512-Gf4X3qVMucayUvux6aXXPgXovocSFUC0rrffDuPI/S2nHhNMhjcZxsrAFYCOF350PRreW1XwzFj3CT/3bKsWCw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.62.5': + resolution: {integrity: sha512-+s5qA0TNM0qm8PK/a5gt/1Hpx+NV08uSuCncvhziIlQzT6AEV2fnUQo7eBtFTFO0nA9scauvoR2HusfXmQnO4w==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.62.5': + resolution: {integrity: sha512-ybb6QvWwWJCbBWqERpc8K3pYVGIrXlG8MEQ8IIuJY6Y9KdHQxoFoNyfkAOtKn1VHu3KuLidXvwrvGR1mEjeWCw==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.62.5': + resolution: {integrity: sha512-nZb1DtnOyhCmYvsC8A2CwOkopVg+IS1+fPUa7rMOAXtNw5+lLCLLPqd6XAiNrGtoQKsbvIBOwsHnBH/3wnb4HQ==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.62.5': + resolution: {integrity: sha512-yMbj63Sp89ryrXLWyz+sy+fYD2HpOnMCLGbe4Oa1smclFSUukdtD/BgdiHaAetJNb74URD8U4hM+qG5KVzMEkg==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.62.5': + resolution: {integrity: sha512-mhoan3OJw2kYV/e1jtIdmvUZgyBFeA6zGWsOswmR0Tg19TQbowZuR+JMLID6spbbBN7Zee2ejrgmy3+FxGrIdA==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.62.5': + resolution: {integrity: sha512-5ZTLmjWbb1VZdjuyhe83K/8QO0/h11midQCBP+X5OYn32ra7eOBoM0ZqtaY4nkgNsYgmdVhMYPoyVPTjUpHf3w==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.62.5': + resolution: {integrity: sha512-m53kG+br6PGxOTmgBEM2DHSDs9RVjsyEbUwjJPJGTFm1grWOG8EKJggDCTb60unD4Tjby8fi7/m9XfkEWasVWg==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.62.5': + resolution: {integrity: sha512-6RHPJR1g/uvdYU8uXBnfq3nlqyZCP82Fr6NHgfGoaIeSh0YEqnX/x6uA9MmJJbnSH7swqX4F+CkGdUF+6doiQA==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.62.5': + resolution: {integrity: sha512-xs+OXQtEXgpXT0DmA5+U3qnRZHdCST/5HRQxS8wSPZTUZN/EMWeHuSIod32LQklTBZBV9DyfncKBQ8n5V3eFdw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.62.5': + resolution: {integrity: sha512-e7hD+sl3s+mcLQDZ8pbudBVsdG6r5yN4w3LqG2TJ8sQHDpblWj5lrJs/3m01Cvlxbt4x13zu5thLjgypgtkYzw==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.62.5': + resolution: {integrity: sha512-GiyJaCf+WpMub/17aPcKk27QMl5W6f+KhdPTjlFOn5akH5Wa/DCM9Stdx5cDfmasyKB08MqpVQ1uJE2RkkpbXg==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.62.5': + resolution: {integrity: sha512-+OQ8U2DdoEfXl8T4Fb18AjmEwbXMerKDKCL8yCPAYhKCEEKoul7rkbeGCBFCbAlaGaa7pmtRTpkAJM2LE/i5FA==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.62.5': + resolution: {integrity: sha512-KanvAZrPKbDBFwrgiU9yEVpQoox9QPV1WZOXX7HudJQY+eSlu82CtWxDU8WtuRRvtN5EGkLczkd6Y6DTcvm9wA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.62.5': + resolution: {integrity: sha512-1aC3UEWTtRl3RK3VpDJ/Tqk1XI4SLTmXIthAq6wRWo8XiSXJNd+VprJM4/1P4+i6HIaFEFlVi9sTTziniD2tOQ==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.62.5': + resolution: {integrity: sha512-/gDJaRs4gl0NPIwqCz+6PkpmhhjRAD2j6P4rSNHBzUkO3naEx2mIU0pRle1vUNRQ7mE/+8OOeXLTv/J56FKiQg==} + cpu: [x64] + os: [win32] + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/node@24.13.3': + resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==} + + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + postcss@8.5.26: + resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} + engines: {node: ^10 || ^12 || >=14} + + rollup@4.62.5: + resolution: {integrity: sha512-/tqMfgP7GPA3PHhCmuiS4vIjrSVhHLgY++i+dhbG462euyAj7FpM4D9uq1X3BgjlqRdpcOrYhcQtfiQLNc8tqw==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.18.2: + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + + vite@7.3.6: + resolution: {integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + 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 + +snapshots: + + '@biomejs/biome@2.5.10': + optionalDependencies: + '@biomejs/cli-darwin-arm64': 2.5.10 + '@biomejs/cli-darwin-x64': 2.5.10 + '@biomejs/cli-linux-arm64': 2.5.10 + '@biomejs/cli-linux-arm64-musl': 2.5.10 + '@biomejs/cli-linux-x64': 2.5.10 + '@biomejs/cli-linux-x64-musl': 2.5.10 + '@biomejs/cli-win32-arm64': 2.5.10 + '@biomejs/cli-win32-x64': 2.5.10 + + '@biomejs/cli-darwin-arm64@2.5.10': + optional: true + + '@biomejs/cli-darwin-x64@2.5.10': + optional: true + + '@biomejs/cli-linux-arm64-musl@2.5.10': + optional: true + + '@biomejs/cli-linux-arm64@2.5.10': + optional: true + + '@biomejs/cli-linux-x64-musl@2.5.10': + optional: true + + '@biomejs/cli-linux-x64@2.5.10': + optional: true + + '@biomejs/cli-win32-arm64@2.5.10': + optional: true + + '@biomejs/cli-win32-x64@2.5.10': + optional: true + + '@esbuild/aix-ppc64@0.28.2': + optional: true + + '@esbuild/android-arm64@0.28.2': + optional: true + + '@esbuild/android-arm@0.28.2': + optional: true + + '@esbuild/android-x64@0.28.2': + optional: true + + '@esbuild/darwin-arm64@0.28.2': + optional: true + + '@esbuild/darwin-x64@0.28.2': + optional: true + + '@esbuild/freebsd-arm64@0.28.2': + optional: true + + '@esbuild/freebsd-x64@0.28.2': + optional: true + + '@esbuild/linux-arm64@0.28.2': + optional: true + + '@esbuild/linux-arm@0.28.2': + optional: true + + '@esbuild/linux-ia32@0.28.2': + optional: true + + '@esbuild/linux-loong64@0.28.2': + optional: true + + '@esbuild/linux-mips64el@0.28.2': + optional: true + + '@esbuild/linux-ppc64@0.28.2': + optional: true + + '@esbuild/linux-riscv64@0.28.2': + optional: true + + '@esbuild/linux-s390x@0.28.2': + optional: true + + '@esbuild/linux-x64@0.28.2': + optional: true + + '@esbuild/netbsd-arm64@0.28.2': + optional: true + + '@esbuild/netbsd-x64@0.28.2': + optional: true + + '@esbuild/openbsd-arm64@0.28.2': + optional: true + + '@esbuild/openbsd-x64@0.28.2': + optional: true + + '@esbuild/openharmony-arm64@0.28.2': + optional: true + + '@esbuild/sunos-x64@0.28.2': + optional: true + + '@esbuild/win32-arm64@0.28.2': + optional: true + + '@esbuild/win32-ia32@0.28.2': + optional: true + + '@esbuild/win32-x64@0.28.2': + optional: true + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + optional: true + + '@rollup/rollup-android-arm-eabi@4.62.5': + optional: true + + '@rollup/rollup-android-arm64@4.62.5': + optional: true + + '@rollup/rollup-darwin-arm64@4.62.5': + optional: true + + '@rollup/rollup-darwin-x64@4.62.5': + optional: true + + '@rollup/rollup-freebsd-arm64@4.62.5': + optional: true + + '@rollup/rollup-freebsd-x64@4.62.5': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.62.5': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.62.5': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.62.5': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.62.5': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.62.5': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.62.5': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.62.5': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.62.5': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.62.5': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.62.5': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.62.5': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.62.5': + optional: true + + '@rollup/rollup-linux-x64-musl@4.62.5': + optional: true + + '@rollup/rollup-openbsd-x64@4.62.5': + optional: true + + '@rollup/rollup-openharmony-arm64@4.62.5': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.62.5': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.62.5': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.62.5': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.62.5': + optional: true + + '@types/estree@1.0.9': {} + + '@types/node@24.13.3': + dependencies: + undici-types: 7.18.2 + + esbuild@0.28.2: + 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 + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + fsevents@2.3.3: + optional: true + + nanoid@3.3.18: {} + + picocolors@1.1.1: {} + + picomatch@4.0.5: {} + + postcss@8.5.26: + dependencies: + nanoid: 3.3.18 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + rollup@4.62.5: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@napi-rs/lzma-linux-x64-gnu': 1.5.1 + '@rollup/rollup-android-arm-eabi': 4.62.5 + '@rollup/rollup-android-arm64': 4.62.5 + '@rollup/rollup-darwin-arm64': 4.62.5 + '@rollup/rollup-darwin-x64': 4.62.5 + '@rollup/rollup-freebsd-arm64': 4.62.5 + '@rollup/rollup-freebsd-x64': 4.62.5 + '@rollup/rollup-linux-arm-gnueabihf': 4.62.5 + '@rollup/rollup-linux-arm-musleabihf': 4.62.5 + '@rollup/rollup-linux-arm64-gnu': 4.62.5 + '@rollup/rollup-linux-arm64-musl': 4.62.5 + '@rollup/rollup-linux-loong64-gnu': 4.62.5 + '@rollup/rollup-linux-loong64-musl': 4.62.5 + '@rollup/rollup-linux-ppc64-gnu': 4.62.5 + '@rollup/rollup-linux-ppc64-musl': 4.62.5 + '@rollup/rollup-linux-riscv64-gnu': 4.62.5 + '@rollup/rollup-linux-riscv64-musl': 4.62.5 + '@rollup/rollup-linux-s390x-gnu': 4.62.5 + '@rollup/rollup-linux-x64-gnu': 4.62.5 + '@rollup/rollup-linux-x64-musl': 4.62.5 + '@rollup/rollup-openbsd-x64': 4.62.5 + '@rollup/rollup-openharmony-arm64': 4.62.5 + '@rollup/rollup-win32-arm64-msvc': 4.62.5 + '@rollup/rollup-win32-ia32-msvc': 4.62.5 + '@rollup/rollup-win32-x64-gnu': 4.62.5 + '@rollup/rollup-win32-x64-msvc': 4.62.5 + fsevents: 2.3.3 + + source-map-js@1.2.1: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + typescript@5.9.3: {} + + undici-types@7.18.2: {} + + vite@7.3.6(@types/node@24.13.3): + dependencies: + esbuild: 0.28.2 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + postcss: 8.5.26 + rollup: 4.62.5 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 24.13.3 + fsevents: 2.3.3 diff --git a/web/pnpm-workspace.yaml b/web/pnpm-workspace.yaml new file mode 100644 index 0000000..75c06f6 --- /dev/null +++ b/web/pnpm-workspace.yaml @@ -0,0 +1,4 @@ +# esbuild's postinstall validates its platform binary; everything else stays +# script-free. +allowBuilds: + esbuild: true diff --git a/web/public/fonts/rajdhani-600-latin.woff2 b/web/public/fonts/rajdhani-600-latin.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..bc9a9464c8010fecc0781fad46e46b10d3e0b519 GIT binary patch literal 15732 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 literal 0 HcmV?d00001 diff --git a/web/public/fonts/rajdhani-700-latin.woff2 b/web/public/fonts/rajdhani-700-latin.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..057c632a6a0fd9ffea8d8be5b7166b7e0886e81f GIT binary patch literal 15688 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 { + const started = performance.now(); + try { + const response = await fetch("/api/health"); + if (!response.ok) { + return { kind: "unreachable", detail: `http ${response.status}` }; + } + const report = (await response.json()) as HealthReport; + return { + kind: "report", + report, + roundtripMs: Math.round(performance.now() - started), + }; + } catch { + return { kind: "unreachable", detail: "no response" }; + } +} diff --git a/web/src/main.ts b/web/src/main.ts new file mode 100644 index 0000000..7a664f5 --- /dev/null +++ b/web/src/main.ts @@ -0,0 +1,159 @@ +import { type CheckStatus, type Probe, probeHealth } from "./health"; +import "./style.css"; + +const POLL_MS = 15_000; + +type Tone = "ok" | "warn" | "fault" | "idle"; + +const TONE_BY_STATUS: Record = { + ok: "ok", + unconfigured: "warn", + unreachable: "fault", +}; + +interface ModuleRefs { + lamp: HTMLElement; + status: HTMLElement; + detail: HTMLElement; + defaultDetail: string; +} + +function moduleRefs(name: string): ModuleRefs { + const root = document.querySelector(`[data-check="${name}"]`); + const lamp = root?.querySelector(".lamp"); + const status = root?.querySelector('[data-role="status"]'); + const detail = root?.querySelector('[data-role="detail"]'); + if (!lamp || !status || !detail) { + throw new Error(`board is missing the ${name} module`); + } + return { lamp, status, detail, defaultDetail: detail.textContent ?? "" }; +} + +function setModule(refs: ModuleRefs, state: string, tone: Tone, word: string, detail?: string) { + refs.lamp.dataset.state = state; + refs.status.dataset.tone = tone; + refs.status.textContent = word; + refs.detail.textContent = detail ?? refs.defaultDetail; +} + +function must(selector: string): T { + const element = document.querySelector(selector); + if (!element) { + throw new Error(`board markup is missing ${selector}`); + } + return element; +} + +function main() { + const board = must(".board"); + const masterLamp = must("#master-lamp"); + const verdict = must("#master-verdict"); + const version = must("#version"); + const roundtrip = must("#roundtrip"); + const probeButton = must("#probe"); + + const master = moduleRefs("master"); + const checks = { + tmdb: moduleRefs("tmdb"), + prowlarr: moduleRefs("prowlarr"), + transmission: moduleRefs("transmission"), + } as const; + + const traces = { + tmdb: document.querySelector('[data-trace="tmdb"]'), + prowlarr: document.querySelector('[data-trace="prowlarr"]'), + master: document.querySelector('[data-trace="master"]'), + } as const; + + function setTrace(name: keyof typeof traces, tone: Tone | null) { + const trace = traces[name]; + if (!trace) { + return; + } + if (tone === null || tone === "idle") { + delete trace.dataset.tone; + } else { + trace.dataset.tone = tone; + } + } + + // stagger order for the one power-up animation, upstream to downstream + const strikeOrder = [checks.tmdb, checks.prowlarr, master, checks.transmission]; + strikeOrder.forEach((refs, index) => { + refs.lamp.style.setProperty("--strike", String(index)); + }); + masterLamp.style.setProperty("--strike", "0"); + + let powered = false; + + function render(probe: Probe) { + if (!powered) { + powered = true; + board.classList.add("powered"); + } + + if (probe.kind === "unreachable") { + masterLamp.dataset.state = "unreachable"; + verdict.dataset.tone = "fault"; + verdict.textContent = `daemon unreachable — ${probe.detail}`; + version.textContent = "v —"; + setModule(master, "unreachable", "fault", "down"); + for (const refs of Object.values(checks)) { + setModule(refs, "off", "idle", "no signal"); + } + for (const name of ["tmdb", "prowlarr", "master"] as const) { + setTrace(name, null); + } + roundtrip.textContent = "rtt —"; + return; + } + + const { report, roundtripMs } = probe; + const degraded = report.status === "degraded"; + + masterLamp.dataset.state = degraded ? "degraded" : "ok"; + verdict.dataset.tone = degraded ? "warn" : "ok"; + verdict.textContent = degraded ? "chain degraded" : "all signals nominal"; + version.textContent = `v${report.version}`; + roundtrip.textContent = `rtt ${roundtripMs} ms`; + + setModule(master, degraded ? "degraded" : "ok", degraded ? "warn" : "ok", report.status); + setTrace("master", degraded ? "warn" : "ok"); + for (const name of ["tmdb", "prowlarr", "transmission"] as const) { + const check = report[name]; + setModule( + checks[name], + check.status, + TONE_BY_STATUS[check.status], + check.status, + check.detail, + ); + if (name !== "transmission") { + setTrace(name, TONE_BY_STATUS[check.status]); + } + } + } + + let inFlight = false; + + async function probe() { + if (inFlight) { + return; + } + inFlight = true; + probeButton.disabled = true; + render(await probeHealth()); + probeButton.disabled = false; + inFlight = false; + } + + probeButton.addEventListener("click", () => { + void probe(); + }); + window.setInterval(() => { + void probe(); + }, POLL_MS); + void probe(); +} + +main(); diff --git a/web/src/style.css b/web/src/style.css new file mode 100644 index 0000000..6ce5847 --- /dev/null +++ b/web/src/style.css @@ -0,0 +1,488 @@ +/* arr — master-control board. + Tokens mirror .impeccable/design.json; components consume tokens, never + literals. Committed dark: a control room is lit by its lamps. */ + +@font-face { + font-family: "Rajdhani"; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url("/fonts/rajdhani-600-latin.woff2") format("woff2"); +} + +@font-face { + font-family: "Rajdhani"; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url("/fonts/rajdhani-700-latin.woff2") format("woff2"); +} + +:root { + /* ground and panels */ + --ground: oklch(0.14 0.01 250); + --panel: oklch(0.18 0.012 250); + --panel-raised: oklch(0.21 0.014 250); + --line: oklch(0.3 0.015 250); + --line-strong: oklch(0.42 0.02 250); + --highlight: oklch(1 0 0 / 4%); + + /* ink */ + --ink: oklch(0.93 0.008 250); + --ink-muted: oklch(0.72 0.015 250); + --ink-faint: oklch(0.58 0.015 250); + + /* signal lamps */ + --signal-ok: oklch(0.8 0.17 150); + --signal-warn: oklch(0.82 0.14 80); + --signal-fault: oklch(0.68 0.21 25); + --signal-idle: oklch(0.45 0.02 250); + + /* the one interactive hue */ + --accent: oklch(0.8 0.12 220); + + /* type */ + --font-label: system-ui, "Segoe UI", sans-serif; + --font-readout: + ui-monospace, "SF Mono", "Cascadia Mono", "JetBrains Mono", Menlo, Consolas, monospace; + --font-display: "Rajdhani", "Avenir Next Condensed", "Arial Narrow", sans-serif; + + /* rhythm */ + --space-1: 0.25rem; + --space-2: 0.5rem; + --space-3: 0.75rem; + --space-4: 1rem; + --space-6: 1.5rem; + --space-8: 2rem; + --space-12: 3rem; + + --radius: 4px; + --ease-out: cubic-bezier(0.25, 1, 0.5, 1); +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + padding: 0; +} + +body { + min-height: 100dvh; + display: flex; + flex-direction: column; + background: var(--ground); + color: var(--ink); + font-family: var(--font-label); + font-size: 1rem; + line-height: 1.5; +} + +.readout { + font-family: var(--font-readout); + font-size: 0.8125rem; +} + +.dim { + color: var(--ink-muted); +} + +/* ---- rails ---------------------------------------------------------- */ + +.rail { + display: flex; + align-items: center; + gap: var(--space-6); + padding: var(--space-3) var(--space-6); + border-bottom: 1px solid var(--line); + background: var(--panel); + box-shadow: inset 0 1px var(--highlight); +} + +.rail-foot { + margin-top: auto; + border-bottom: 0; + border-top: 1px solid var(--line); + justify-content: space-between; + font-size: 0.75rem; +} + +.rail-id { + display: flex; + align-items: center; + gap: var(--space-3); +} + +.wordmark { + margin: 0; + font-family: var(--font-display); + font-size: 1.625rem; + font-weight: 700; + letter-spacing: 0.1em; + line-height: 1; +} + +.rail-verdict { + margin: 0; + flex: 1; + color: var(--ink-muted); +} + +.rail-verdict[data-tone="ok"] { + color: var(--signal-ok); +} + +.rail-verdict[data-tone="warn"] { + color: var(--signal-warn); +} + +.rail-verdict[data-tone="fault"] { + color: var(--signal-fault); +} + +.rail-meta { + display: flex; + align-items: center; + gap: var(--space-4); +} + +/* ---- lamps ---------------------------------------------------------- */ + +.lamp { + --lamp: var(--signal-idle); + width: 0.625rem; + height: 0.625rem; + flex: none; + border-radius: 50%; + background: var(--lamp); + /* the panel's own device: a lit indicator, not a depth shadow */ + box-shadow: + 0 0 6px var(--lamp), + 0 0 14px oklch(from var(--lamp) l c h / 35%); + transition: background 300ms var(--ease-out); +} + +.lamp-big { + width: 0.875rem; + height: 0.875rem; +} + +.lamp[data-state="ok"] { + --lamp: var(--signal-ok); +} + +.lamp[data-state="unconfigured"] { + --lamp: var(--signal-warn); +} + +.lamp[data-state="degraded"] { + --lamp: var(--signal-warn); +} + +.lamp[data-state="unreachable"] { + --lamp: var(--signal-fault); +} + +.lamp[data-state="probing"] { + --lamp: var(--signal-idle); + animation: lamp-probe 1.2s var(--ease-out) infinite alternate; +} + +/* dead air: dark, no glow, no motion */ +.lamp[data-state="off"] { + --lamp: oklch(0.3 0.012 250); + box-shadow: none; +} + +@keyframes lamp-probe { + from { + opacity: 0.45; + } + to { + opacity: 1; + } +} + +/* power-up: lamps flicker on left to right, once, on first verdict */ +@keyframes lamp-strike { + 0% { + opacity: 0.2; + } + 40% { + opacity: 1; + } + 55% { + opacity: 0.35; + } + 100% { + opacity: 1; + } +} + +.board.powered .lamp { + animation: lamp-strike 420ms var(--ease-out) both; + animation-delay: calc(var(--strike, 0) * 120ms); +} + +.board.powered .lamp[data-state="off"] { + animation: none; +} + +@media (prefers-reduced-motion: reduce) { + .lamp[data-state="probing"], + .board.powered .lamp { + animation: none; + } +} + +/* ---- board and chain ------------------------------------------------- */ + +.board { + width: 100%; + max-width: 68rem; + margin: 0 auto; + padding: var(--space-12) var(--space-6); + flex: 1; + display: flex; + align-items: center; +} + +.chain { + width: 100%; +} + +.chain { + display: grid; + grid-template-columns: 1fr 2.5rem 1.2fr 2.5rem 1fr; + grid-template-areas: + "tmdb t1 master out sink" + "prow t2 master out sink"; + align-items: center; + row-gap: var(--space-6); +} + +.area-tmdb { + grid-area: tmdb; +} + +.area-prowlarr { + grid-area: prow; +} + +.area-t1 { + grid-area: t1; +} + +.area-t2 { + grid-area: t2; +} + +.area-master { + grid-area: master; +} + +.area-out { + grid-area: out; +} + +.area-transmission { + grid-area: sink; +} + +/* ---- modules --------------------------------------------------------- */ + +.module { + background: var(--panel); + border: 1px solid var(--line); + border-radius: var(--radius); + box-shadow: inset 0 1px var(--highlight); + padding: var(--space-4) var(--space-4) var(--space-3); +} + +.module-master { + background: var(--panel-raised); + border-color: var(--line-strong); + padding: var(--space-6) var(--space-4) var(--space-4); +} + +.module-head { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--space-1) var(--space-2); +} + +.module-name { + margin: 0; + font-family: var(--font-display); + font-size: 1.0625rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.12em; + line-height: 1; +} + +.module-master .module-name { + font-size: 1.3125rem; +} + +.module-status { + margin-left: auto; + padding-left: var(--space-3); + color: var(--ink-muted); +} + +.module-status[data-tone="ok"] { + color: var(--signal-ok); +} + +.module-status[data-tone="warn"] { + color: var(--signal-warn); +} + +.module-status[data-tone="fault"] { + color: var(--signal-fault); +} + +.module-role { + margin: var(--space-2) 0 0; + font-size: 0.6875rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.12em; + color: var(--ink-muted); +} + +.module-detail { + margin: var(--space-1) 0 0; + font-size: 0.75rem; + color: var(--ink-muted); + min-height: 1.2em; +} + +/* ---- bus traces ------------------------------------------------------ */ + +.bus { + display: grid; + align-content: center; + justify-items: center; +} + +.bus-trace { + --trace: var(--line-strong); + width: 100%; + height: 0.5rem; + display: block; + transition: filter 300ms var(--ease-out); +} + +.bus-trace line { + stroke: var(--trace); + stroke-width: 1; +} + +.bus-arrow { + fill: var(--trace); +} + +.bus-trace[data-tone="ok"] { + --trace: var(--signal-ok); +} + +.bus-trace[data-tone="warn"] { + --trace: var(--signal-warn); +} + +.bus-trace[data-tone="fault"] { + --trace: var(--signal-fault); +} + +/* ---- controls -------------------------------------------------------- */ + +.control { + min-height: 2.75rem; + padding: 0 var(--space-4); + font-family: var(--font-readout); + font-size: 0.8125rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--accent); + background: var(--panel-raised); + border: 1px solid var(--line-strong); + border-radius: var(--radius); + box-shadow: inset 0 1px var(--highlight); + cursor: pointer; + transition: + border-color 150ms var(--ease-out), + color 150ms var(--ease-out); +} + +.control:hover { + border-color: var(--accent); + color: oklch(from var(--accent) calc(l + 0.08) c h); +} + +.control:active { + box-shadow: inset 0 1px oklch(0 0 0 / 30%); +} + +.control:disabled { + color: var(--ink-faint); + border-color: var(--line); + cursor: default; +} + +:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +/* ---- small screens --------------------------------------------------- */ + +@media (max-width: 46rem) { + .rail { + flex-wrap: wrap; + gap: var(--space-3) var(--space-4); + } + + .rail-verdict { + flex-basis: 100%; + order: 3; + } + + .board { + padding: var(--space-6) var(--space-4); + } + + .chain { + grid-template-columns: 1fr; + grid-template-areas: + "tmdb" + "prow" + "t1" + "master" + "out" + "sink"; + row-gap: var(--space-4); + } + + /* one junctioned trace on the stacked chain; the second is redundant */ + .area-t2 { + display: none; + } + + .bus { + height: 2.25rem; + justify-content: center; + } + + .bus svg { + transform: rotate(90deg); + transform-origin: center; + width: 2.25rem; + height: 0.5rem; + } +} diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..e03756d --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noImplicitOverride": true, + "noUncheckedIndexedAccess": true, + "isolatedModules": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "useDefineForClassFields": true, + "noEmit": true + }, + "include": ["src"] +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..522e2e2 --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,29 @@ +import { defineConfig, loadEnv } from "vite"; + +// Dev-mode proxy target: the running daemon. `pnpm dev` gives hot reload while +// every `/api/*` request goes to a real arr. Override with: +// ARR_DAEMON_URL=http://other-host:7878 pnpm dev +const DEFAULT_DAEMON_URL = "http://127.0.0.1:7878"; + +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd(), ""); + const target = (env.ARR_DAEMON_URL ?? DEFAULT_DAEMON_URL).replace(/\/+$/, ""); + return { + build: { + target: "es2022", + outDir: "dist", + emptyOutDir: true, + sourcemap: false, + }, + server: { + host: true, + port: 5173, + proxy: { + "/api": { + target, + changeOrigin: true, + }, + }, + }, + }; +}); -- 2.47.3 From 98a040a9638784df47ab8fe1f1ff00ccce67f0be Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sat, 22 Aug 2026 20:39:45 +0100 Subject: [PATCH 2/3] fix(ci): install before running biome in web-check Co-Authored-By: Claude Fable 5 --- Justfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Justfile b/Justfile index f8b2d6b..cc041a1 100644 --- a/Justfile +++ b/Justfile @@ -38,8 +38,8 @@ web-check: #!/usr/bin/env bash set -euo pipefail if [ ! -f web/package.json ]; then echo "web/ not present yet, skipping"; exit 0; fi - biome ci web/ pnpm -C web install --frozen-lockfile + pnpm -C web exec biome ci . pnpm -C web exec tsc -b --noEmit # Build the SPA and the binary that embeds it. -- 2.47.3 From 1b64afc2a18fa510de00440271a9c1e36af790e1 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sat, 22 Aug 2026 20:57:38 +0100 Subject: [PATCH 3/3] fix(ci): prepare the pinned pnpm version via corepack Co-Authored-By: Claude Fable 5 --- .gitea/workflows/ci.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index ec5f3b6..3da4d44 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -91,7 +91,9 @@ jobs: curl -fsSL https://deb.nodesource.com/setup_22.x | bash - apt-get install -y --no-install-recommends nodejs corepack enable - corepack prepare pnpm@latest --activate + # Prepare the exact version package.json pins; corepack refuses to + # run a mismatched version under a packageManager pin. + corepack prepare "$(node -p "require('./web/package.json').packageManager")" --activate - name: Cache pnpm store if: steps.probe.outputs.present == 'true' -- 2.47.3