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' 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 a38c007..c1b9880 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -78,6 +78,8 @@ dependencies = [ "arr-db", "arr-meta", "axum", + "include_dir", + "mime_guess", "reqwest", "serde", "sqlx", @@ -1022,6 +1024,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" @@ -1171,6 +1192,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" @@ -2420,6 +2451,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/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. 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 7b0d9f5..edda77f 100644 --- a/crates/arr-daemon/Cargo.toml +++ b/crates/arr-daemon/Cargo.toml @@ -16,6 +16,8 @@ arr-compat = { workspace = true } arr-db = { workspace = true } arr-meta = { workspace = true } axum = { workspace = true } +include_dir = { workspace = true } +mime_guess = { workspace = true } reqwest = { workspace = true } serde = { workspace = true } sqlx = { 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 1438fd7..6d9b587 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; use std::sync::Arc; @@ -94,6 +95,7 @@ async fn run() -> Result<(), Error> { let app = arr_api::router(state) .merge(arr_compat::router(compat)) + .fallback(web::serve) .layer(TraceLayer::new_for_http()); let listener = tokio::net::TcpListener::bind(config.bind_addr) 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

+
+
+
+ + + + 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 0000000..bc9a946 Binary files /dev/null and b/web/public/fonts/rajdhani-600-latin.woff2 differ diff --git a/web/public/fonts/rajdhani-700-latin.woff2 b/web/public/fonts/rajdhani-700-latin.woff2 new file mode 100644 index 0000000..057c632 Binary files /dev/null and b/web/public/fonts/rajdhani-700-latin.woff2 differ diff --git a/web/src/health.ts b/web/src/health.ts new file mode 100644 index 0000000..f864a94 --- /dev/null +++ b/web/src/health.ts @@ -0,0 +1,42 @@ +// Hand-written mirror of arr-api's /api/health schema. `just gen-client` +// output (src/api/) is uncommitted, so CI's tsc cannot see it — this one +// endpoint stays hand-typed until the generated client is wired in. + +export type CheckStatus = "ok" | "unreachable" | "unconfigured"; + +export interface Check { + status: CheckStatus; + detail?: string; +} + +export interface HealthReport { + status: "ok" | "degraded"; + version: string; + prowlarr: Check; + transmission: Check; + tmdb: Check; +} + +export type Probe = + | { kind: "report"; report: HealthReport; roundtripMs: number } + | { kind: "unreachable"; detail: string }; + +/** One probe of the daemon. The endpoint always answers 200 with a verdict in + * the body; anything else means the daemon itself is the outage. */ +export async function probeHealth(): Promise { + 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, + }, + }, + }, + }; +});