feat(devices): live device-status footer across all categories

Generalise printer-only monitoring to every configured device. New
DeviceMonitor polls all enabled devices each tick (default 8s): printers
via rich readStatus(), relays/readers/cameras via the generic healthCheck()
reachability probe, flattened to one traffic-light (ready/degraded/offline)
+ detail, deduped (emit on change only), fail-toward-offline.

- device-status bus event + GET /api/devices/status snapshot.
- Pushed over the existing /api/ws (hello carries the initial set;
  device-status frame per change).
- Web: live-store devices map, WS handler, DeviceFooter chip-per-device
  (role label not vendor; click a degraded/offline chip for an issues panel).

Verified roleKind resolution + change-only emit on a fresh DB.

Note: the footer's UI surface (api type, router mount, i18n devices) rides
in the subsequent subscription commit due to shared-file overlap.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-06-18 13:14:36 +02:00
parent 4e2e4feedb
commit f87e4c0d6b
11 changed files with 569 additions and 13 deletions
+9 -4
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef } from "react";
import { useQueryClient } from "@tanstack/react-query";
import type { LedgerEvent, Occupancy } from "../api.js";
import type { DeviceStatus, LedgerEvent, Occupancy } from "../api.js";
import { qk } from "./query.js";
import { useLiveStore } from "./live-store.js";
@@ -13,9 +13,10 @@ import { useLiveStore } from "./live-store.js";
/** Server → client message shapes (mirror routes/ws.ts OutMsg). */
type WsMessage =
| { kind: "hello"; occupancy: Occupancy }
| { kind: "hello"; occupancy: Occupancy; devices: DeviceStatus[] }
| { kind: "ledger"; event: LedgerEvent; occupancy: Occupancy }
| { kind: "printer-status"; event: unknown };
| { kind: "printer-status"; event: unknown }
| { kind: "device-status"; event: DeviceStatus };
/** Build the ws:// or wss:// URL for the same origin the SPA is served from. */
function wsUrl(): string {
@@ -25,7 +26,7 @@ function wsUrl(): string {
export function useLiveFeed(): void {
const qc = useQueryClient();
const { setStatus, setOccupancy, pushEvent } = useLiveStore();
const { setStatus, setOccupancy, pushEvent, setDevices, upsertDevice } = useLiveStore();
// Hold the socket + reconnect timer across renders; guard against StrictMode
// double-invoke and unmount.
const sockRef = useRef<WebSocket | null>(null);
@@ -55,6 +56,10 @@ export function useLiveFeed(): void {
}
if (msg.kind === "hello") {
setOccupancy(msg.occupancy);
// Initial device-status snapshot for the footer.
if (Array.isArray(msg.devices)) setDevices(msg.devices);
} else if (msg.kind === "device-status") {
upsertDevice(msg.event);
} else if (msg.kind === "ledger") {
setOccupancy(msg.occupancy);
pushEvent(msg.event);