feat(web): SPA skeleton embedded in the binary (#60)
This commit was merged in pull request #60.
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user