feat(web): SPA skeleton embedded in the binary (#60)
This commit was merged in pull request #60.
This commit is contained in:
@@ -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'
|
||||
|
||||
@@ -10,3 +10,4 @@
|
||||
result
|
||||
result-*
|
||||
/worktrees
|
||||
*.tsbuildinfo
|
||||
|
||||
@@ -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": "<span class=\"lamp\" data-state=\"ok\" aria-hidden=\"true\"></span>",
|
||||
"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": "<article class=\"module\"><header class=\"module-head\"><span class=\"lamp\" data-state=\"ok\" aria-hidden=\"true\"></span><h2 class=\"module-name\">prowlarr</h2><span class=\"module-status readout\" data-tone=\"ok\">ok</span></header><p class=\"module-role\">indexers in</p><p class=\"module-detail readout\">torznab search and rss</p></article>",
|
||||
"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": "<svg viewBox=\"0 0 40 8\" preserveAspectRatio=\"none\" class=\"bus-trace\" data-tone=\"ok\" aria-hidden=\"true\"><line x1=\"0\" y1=\"4\" x2=\"32\" y2=\"4\" /><path d=\"M32 0.5 39 4 32 7.5 Z\" class=\"bus-arrow\" /></svg>",
|
||||
"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": "<button type=\"button\" class=\"control\">probe</button>",
|
||||
"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": "<header class=\"rail\"><div class=\"rail-id\"><span class=\"lamp\" data-state=\"ok\" aria-hidden=\"true\"></span><h1 class=\"wordmark\">arr</h1></div><p class=\"rail-verdict readout\" data-tone=\"ok\">all signals nominal</p></header>",
|
||||
"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."
|
||||
]
|
||||
}
|
||||
}
|
||||
Generated
+37
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
# Product
|
||||
|
||||
<!-- impeccable:product-schema 1 -->
|
||||
|
||||
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)*.
|
||||
+25
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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#"<!doctype html>
|
||||
<meta charset="utf-8">
|
||||
<title>arr — web bundle not built</title>
|
||||
<style>
|
||||
body { font-family: ui-sans-serif, system-ui, sans-serif; max-width: 40em;
|
||||
margin: 4em auto; padding: 0 1em; line-height: 1.5; }
|
||||
code { background: #eee; padding: 0.1em 0.3em; border-radius: 3px; }
|
||||
</style>
|
||||
<h1>arr is running</h1>
|
||||
<p>but this binary was built without the web bundle (no pnpm on the build
|
||||
machine). Rebuild with pnpm + node installed, or set <code>ARR_WEB_DIST</code>
|
||||
to a prebuilt bundle.</p>
|
||||
"#;
|
||||
@@ -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)
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<title>arr</title>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</head>
|
||||
<body>
|
||||
<!--
|
||||
THESIS: arr is a signal chain — TMDB and Prowlarr feed in, Transmission is
|
||||
driven out — and health is lamps on that chain, read in one glance. Refuses
|
||||
the admin-dashboard rut: no sidebar, no stat cards, no hero metric.
|
||||
OWN-WORLD: broadcast master-control panel. Near-black charcoal ground,
|
||||
machined panel modules with inset hairline borders, engraved uppercase
|
||||
micro-labels, monospace readouts, status lamps (signal green / amber /
|
||||
fault red) with a soft glow, phosphor-cyan for the one interactive control.
|
||||
STORY: the operator opens the board, reads the master lamp, and knows in a
|
||||
second whether arr can do its job — and which upstream is down, by name.
|
||||
FIRST VIEWPORT: a top rail (wordmark + master lamp + verdict + version +
|
||||
PROBE control), then the chain: TMDB and PROWLARR modules on the left
|
||||
feeding the central arr module, TRANSMISSION driven on the right, joined by
|
||||
bus hairlines with direction arrows. Footer rail carries the round-trip
|
||||
readout. On a phone the chain stacks vertically.
|
||||
FORM: broadcast master-control panel, #2 of 7 on the ordered list; external
|
||||
roll (shell RANDOM → 2, witness 57910; concept-seed.mjs degraded to empty
|
||||
output in this environment).
|
||||
FINISH: unreviewed and undocumented is unfinished; this build ends with the
|
||||
finish review, the verdict, and DESIGN.md.
|
||||
-->
|
||||
<header class="rail">
|
||||
<div class="rail-id">
|
||||
<span class="lamp" id="master-lamp" data-state="probing" aria-hidden="true"></span>
|
||||
<h1 class="wordmark">arr</h1>
|
||||
</div>
|
||||
<p class="rail-verdict readout" id="master-verdict" role="status">probing chain…</p>
|
||||
<div class="rail-meta">
|
||||
<span class="readout dim" id="version" data-testid="version">v —</span>
|
||||
<button type="button" class="control" id="probe">probe</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="board">
|
||||
<section class="chain" aria-label="signal chain">
|
||||
<article class="module area-tmdb" data-check="tmdb">
|
||||
<header class="module-head">
|
||||
<span class="lamp" data-state="probing" aria-hidden="true"></span>
|
||||
<h2 class="module-name">tmdb</h2>
|
||||
<span class="module-status readout" data-role="status">probing</span>
|
||||
</header>
|
||||
<p class="module-role">metadata in</p>
|
||||
<p class="module-detail readout" data-role="detail">titles, languages, release dates</p>
|
||||
</article>
|
||||
|
||||
<div class="bus area-t1" aria-hidden="true">
|
||||
<svg viewBox="0 0 40 8" preserveAspectRatio="none" class="bus-trace" data-trace="tmdb" aria-hidden="true">
|
||||
<line x1="0" y1="4" x2="32" y2="4" />
|
||||
<path d="M32 0.5 39 4 32 7.5 Z" class="bus-arrow" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<article class="module area-prowlarr" data-check="prowlarr">
|
||||
<header class="module-head">
|
||||
<span class="lamp" data-state="probing" aria-hidden="true"></span>
|
||||
<h2 class="module-name">prowlarr</h2>
|
||||
<span class="module-status readout" data-role="status">probing</span>
|
||||
</header>
|
||||
<p class="module-role">indexers in</p>
|
||||
<p class="module-detail readout" data-role="detail">torznab search and rss</p>
|
||||
</article>
|
||||
|
||||
<div class="bus area-t2" aria-hidden="true">
|
||||
<svg viewBox="0 0 40 8" preserveAspectRatio="none" class="bus-trace" data-trace="prowlarr" aria-hidden="true">
|
||||
<line x1="0" y1="4" x2="32" y2="4" />
|
||||
<path d="M32 0.5 39 4 32 7.5 Z" class="bus-arrow" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<article class="module module-master area-master" data-check="master">
|
||||
<header class="module-head">
|
||||
<span class="lamp lamp-big" data-state="probing" aria-hidden="true"></span>
|
||||
<h2 class="module-name">arr</h2>
|
||||
<span class="module-status readout" data-role="status">probing</span>
|
||||
</header>
|
||||
<p class="module-role">reconcile core</p>
|
||||
<p class="module-detail readout" data-role="detail">decide · find · fetch · import</p>
|
||||
</article>
|
||||
|
||||
<div class="bus area-out" aria-hidden="true">
|
||||
<svg viewBox="0 0 40 8" preserveAspectRatio="none" class="bus-trace" data-trace="master" aria-hidden="true">
|
||||
<line x1="0" y1="4" x2="32" y2="4" />
|
||||
<path d="M32 0.5 39 4 32 7.5 Z" class="bus-arrow" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<article class="module area-transmission" data-check="transmission">
|
||||
<header class="module-head">
|
||||
<span class="lamp" data-state="probing" aria-hidden="true"></span>
|
||||
<h2 class="module-name">transmission</h2>
|
||||
<span class="module-status readout" data-role="status">probing</span>
|
||||
</header>
|
||||
<p class="module-role">torrents out</p>
|
||||
<p class="module-detail readout" data-role="detail">rpc · grab and seed</p>
|
||||
</article>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="rail rail-foot">
|
||||
<span class="readout dim" id="roundtrip">rtt —</span>
|
||||
<span class="readout dim" id="poll-note">repolls every 15 s</span>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Generated
+786
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
# esbuild's postinstall validates its platform binary; everything else stays
|
||||
# script-free.
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
Binary file not shown.
Binary file not shown.
@@ -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<Probe> {
|
||||
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" };
|
||||
}
|
||||
}
|
||||
+159
@@ -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<CheckStatus, Tone> = {
|
||||
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<HTMLElement>(".lamp");
|
||||
const status = root?.querySelector<HTMLElement>('[data-role="status"]');
|
||||
const detail = root?.querySelector<HTMLElement>('[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<T extends Element>(selector: string): T {
|
||||
const element = document.querySelector<T>(selector);
|
||||
if (!element) {
|
||||
throw new Error(`board markup is missing ${selector}`);
|
||||
}
|
||||
return element;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const board = must<HTMLElement>(".board");
|
||||
const masterLamp = must<HTMLElement>("#master-lamp");
|
||||
const verdict = must<HTMLElement>("#master-verdict");
|
||||
const version = must<HTMLElement>("#version");
|
||||
const roundtrip = must<HTMLElement>("#roundtrip");
|
||||
const probeButton = must<HTMLButtonElement>("#probe");
|
||||
|
||||
const master = moduleRefs("master");
|
||||
const checks = {
|
||||
tmdb: moduleRefs("tmdb"),
|
||||
prowlarr: moduleRefs("prowlarr"),
|
||||
transmission: moduleRefs("transmission"),
|
||||
} as const;
|
||||
|
||||
const traces = {
|
||||
tmdb: document.querySelector<SVGElement>('[data-trace="tmdb"]'),
|
||||
prowlarr: document.querySelector<SVGElement>('[data-trace="prowlarr"]'),
|
||||
master: document.querySelector<SVGElement>('[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();
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user