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