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:
@@ -1,5 +1,5 @@
|
||||
import { create } from "zustand";
|
||||
import type { LedgerEvent, Occupancy } from "../api.js";
|
||||
import type { DeviceStatus, LedgerEvent, Occupancy } from "../api.js";
|
||||
|
||||
// CLIENT state for the live booth feed — deliberately small. Server data (the
|
||||
// authoritative event list, occupancy totals) is owned by TanStack Query; this
|
||||
@@ -20,16 +20,31 @@ interface LiveState {
|
||||
occupancy: Occupancy | null;
|
||||
/** Newest-first tail of recently pushed ledger events (for the live ticker). */
|
||||
feed: LedgerEvent[];
|
||||
/** Live device status keyed by device id (for the footer): set from the WS
|
||||
* hello snapshot, then upserted per device on each device-status push. */
|
||||
devices: Record<string, DeviceStatus>;
|
||||
setStatus: (s: WsStatus) => void;
|
||||
setOccupancy: (o: Occupancy) => void;
|
||||
pushEvent: (e: LedgerEvent) => void;
|
||||
/** Replace the whole device-status set (WS hello / reconnect snapshot). */
|
||||
setDevices: (list: DeviceStatus[]) => void;
|
||||
/** Upsert one device's status (a device-status push). */
|
||||
upsertDevice: (d: DeviceStatus) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
/** Index a device-status list by device id. */
|
||||
function byId(list: DeviceStatus[]): Record<string, DeviceStatus> {
|
||||
const m: Record<string, DeviceStatus> = {};
|
||||
for (const d of list) m[d.deviceId] = d;
|
||||
return m;
|
||||
}
|
||||
|
||||
export const useLiveStore = create<LiveState>((set) => ({
|
||||
status: "connecting",
|
||||
occupancy: null,
|
||||
feed: [],
|
||||
devices: {},
|
||||
setStatus: (status) => set({ status }),
|
||||
setOccupancy: (occupancy) => set({ occupancy }),
|
||||
pushEvent: (e) =>
|
||||
@@ -37,5 +52,7 @@ export const useLiveStore = create<LiveState>((set) => ({
|
||||
// Newest first; de-dupe by id (a reconnect can replay) and cap the length.
|
||||
feed: s.feed.some((x) => x.id === e.id) ? s.feed : [e, ...s.feed].slice(0, MAX_FEED),
|
||||
})),
|
||||
reset: () => set({ status: "connecting", occupancy: null, feed: [] }),
|
||||
setDevices: (list) => set({ devices: byId(list) }),
|
||||
upsertDevice: (d) => set((s) => ({ devices: { ...s.devices, [d.deviceId]: d } })),
|
||||
reset: () => set({ status: "connecting", occupancy: null, feed: [], devices: {} }),
|
||||
}));
|
||||
|
||||
@@ -26,4 +26,5 @@ export const qk = {
|
||||
activeSessions: ["active-sessions"] as const,
|
||||
siteConfig: ["site-config"] as const,
|
||||
shift: ["shift"] as const,
|
||||
deviceStatus: ["device-status"] as const,
|
||||
} as const;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user