feat(web): SPA skeleton embedded in the binary (#60)
ci / web (push) Successful in 33s
ci / rust (push) Successful in 57s
e2e / e2e (push) Successful in 1m7s

This commit was merged in pull request #60.
This commit is contained in:
2026-08-22 21:07:40 +01:00
parent 94c334ffe9
commit 514628f089
22 changed files with 2277 additions and 2 deletions
+42
View File
@@ -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
View File
@@ -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();
+488
View File
@@ -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;
}
}