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:
@@ -17,7 +17,7 @@ export interface DeviceInputEvent {
|
||||
}
|
||||
|
||||
// A credential read: a ticket scanned at exit, a plate from LPR, a card at a reader.
|
||||
// Drives identity-based flows (exit validation, permits, pay-station lookup). `kind`
|
||||
// Drives identity-based flows (exit validation, subscriptions, pay-station lookup). `kind`
|
||||
// mirrors IdentitySource. See parking-session.md.
|
||||
export interface DeviceReadEvent {
|
||||
readonly driverId: string;
|
||||
@@ -35,7 +35,7 @@ export interface DeviceReadEvent {
|
||||
export interface ReadOutcome {
|
||||
/** Was the vehicle admitted/exited (barrier opened)? Drives the reader's beep. */
|
||||
readonly accepted: boolean;
|
||||
/** Which way it went, when known (permit/exit infer this). */
|
||||
/** Which way it went, when known (subscription/exit infer this). */
|
||||
readonly direction?: "entry" | "exit";
|
||||
/** Human-readable reason (for logs / the reader UI), esp. on reject. */
|
||||
readonly reason?: string;
|
||||
@@ -49,6 +49,33 @@ export interface PrinterStatusEvent {
|
||||
readonly status: PrinterStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* The unified live status of ANY configured device — what the booth footer shows.
|
||||
* Every enabled device is polled: printers via their rich `readStatus()`
|
||||
* (paper/cover/cutter), all other categories via the generic `healthCheck()`
|
||||
* reachability probe. `state` is the common traffic-light; `detail` carries the
|
||||
* human summary (e.g. "paper out", or an unreachable error). See device-monitor.ts
|
||||
* and wiki/concepts/device-status-monitoring.md.
|
||||
*/
|
||||
export interface DeviceStatusEvent {
|
||||
readonly deviceId: string; // devices id
|
||||
readonly driverId: string;
|
||||
readonly category: "access" | "reader" | "camera" | "printer";
|
||||
/**
|
||||
* The device's ROLE descriptor for the footer label — NOT the vendor. A
|
||||
* direction-style token the client localises and pairs with the category, so the
|
||||
* chip reads e.g. "Lexuesi hyrje" / "Kamera dalje" / "Printer kabina":
|
||||
* - reader/camera: "entry" | "exit" | "both" (inherited from its bound relay)
|
||||
* - access: "entry" | "exit" | "both" | "mixed" (from its relays[])
|
||||
* - printer: "lane" (entry-dispenser) | "booth" (booth-receipt)
|
||||
* - undetermined: null (chip shows the category alone)
|
||||
*/
|
||||
readonly roleKind: "entry" | "exit" | "both" | "mixed" | "lane" | "booth" | null;
|
||||
readonly state: "ready" | "degraded" | "offline";
|
||||
readonly detail?: string;
|
||||
readonly checkedAt: string; // ISO-8601
|
||||
}
|
||||
|
||||
class DeviceEventBus extends EventEmitter {
|
||||
emitInput(event: DeviceInputEvent): void {
|
||||
this.emit("input", event);
|
||||
@@ -76,6 +103,17 @@ class DeviceEventBus extends EventEmitter {
|
||||
return () => this.off("printer-status", cb);
|
||||
}
|
||||
|
||||
/** Emitted by the device monitor whenever ANY device's unified status CHANGES
|
||||
* (all categories — relays, readers, cameras, printers). Drives the booth
|
||||
* device-status footer over the WS. */
|
||||
emitDeviceStatus(event: DeviceStatusEvent): void {
|
||||
this.emit("device-status", event);
|
||||
}
|
||||
onDeviceStatus(cb: (event: DeviceStatusEvent) => void): () => void {
|
||||
this.on("device-status", cb);
|
||||
return () => this.off("device-status", cb);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emitted AFTER a signed business event is appended to the ledger (entry, exit,
|
||||
* payment, void, …). The payload is the persisted row — business facts only, no
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
import { devices, type Db, type DeviceRow } from "@parking/db";
|
||||
import { isMonitorable, registry } from "@parking/devices";
|
||||
import { deviceEvents, type DeviceStatusEvent } from "./device-events.js";
|
||||
import { directionOf, relaysOf } from "./device-resolve.js";
|
||||
|
||||
// Unified live DEVICE monitor — the source for the booth's device-status footer.
|
||||
// Every enabled, configured device is probed on an interval, regardless of
|
||||
// category: a printer via its rich readStatus() (paper/cover/cutter — reusing the
|
||||
// same capability the PrinterMonitor uses), and a relay/reader/camera via the
|
||||
// generic healthCheck() reachability probe every Device implements. The result is
|
||||
// flattened to a common traffic-light (ready | degraded | offline) + a detail
|
||||
// string, cached per device id, and emitted on the bus ONLY when it changes.
|
||||
//
|
||||
// This is device-agnostic (talks to the adapter interfaces, never a driver SDK)
|
||||
// and read-only — polling a device never drives a relay or mutates the ledger.
|
||||
// See wiki/concepts/device-status-monitoring.md, printer-status-monitoring.md.
|
||||
|
||||
const POLL_MS = Number(process.env.DEVICE_POLL_MS ?? 8000);
|
||||
|
||||
/**
|
||||
* The device's ROLE descriptor for the footer (never the vendor). Direction-style
|
||||
* tokens the client localises next to the category:
|
||||
* - reader/camera → the direction inherited from its bound relay (entry/exit/both)
|
||||
* - access → entry/exit/both from its relays[]; "mixed" if it spans more
|
||||
* than one direction; null if it declares none yet
|
||||
* - printer → "lane" (entry-dispenser) | "booth" (booth-receipt)
|
||||
*/
|
||||
function roleKindOf(db: Db, row: DeviceRow): DeviceStatusEvent["roleKind"] {
|
||||
switch (row.category) {
|
||||
case "reader":
|
||||
case "camera": {
|
||||
const d = directionOf(db, row); // entry | exit | both
|
||||
return d;
|
||||
}
|
||||
case "access": {
|
||||
const dirs = new Set(relaysOf(row).map((r) => r.direction));
|
||||
if (dirs.size === 0) return null;
|
||||
if (dirs.size > 1) return "mixed";
|
||||
const only = [...dirs][0]; // entry | exit | both
|
||||
return only ?? null;
|
||||
}
|
||||
case "printer": {
|
||||
const role = (row.config as { role?: string }).role;
|
||||
if (role === "booth-receipt") return "booth";
|
||||
if (role === "entry-dispenser") return "lane";
|
||||
return null;
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export class DeviceMonitor {
|
||||
readonly #db: Db;
|
||||
readonly #log: FastifyBaseLogger;
|
||||
readonly #pollMs: number;
|
||||
/** Latest unified status per device id. */
|
||||
readonly #latest = new Map<string, DeviceStatusEvent>();
|
||||
#timer: ReturnType<typeof setInterval> | null = null;
|
||||
#ticking = false;
|
||||
|
||||
constructor(db: Db, log: FastifyBaseLogger, pollMs = POLL_MS) {
|
||||
this.#db = db;
|
||||
this.#log = log;
|
||||
this.#pollMs = pollMs;
|
||||
}
|
||||
|
||||
/** Begin polling. Idempotent. */
|
||||
start(): void {
|
||||
if (this.#timer) return;
|
||||
void this.#tick(); // immediate first pass so the footer fills without a wait
|
||||
this.#timer = setInterval(() => void this.#tick(), this.#pollMs);
|
||||
this.#timer.unref?.();
|
||||
this.#log.info(`device-monitor: polling every ${this.#pollMs}ms`);
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.#timer) {
|
||||
clearInterval(this.#timer);
|
||||
this.#timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Current snapshot for the API / a freshly-connected WS client. */
|
||||
snapshot(): DeviceStatusEvent[] {
|
||||
return [...this.#latest.values()];
|
||||
}
|
||||
|
||||
async #tick(): Promise<void> {
|
||||
if (this.#ticking) return; // never overlap polls
|
||||
this.#ticking = true;
|
||||
try {
|
||||
// Re-read the device set each tick so a newly-assigned/removed device is
|
||||
// picked up without a restart.
|
||||
const rows = await this.#db.select().from(devices).all();
|
||||
const enabled = rows.filter((r) => r.enabled);
|
||||
const present = new Set(enabled.map((r) => r.id));
|
||||
|
||||
// Drop devices that are gone/disabled (so the footer doesn't show stale ones).
|
||||
for (const id of [...this.#latest.keys()]) {
|
||||
if (!present.has(id)) this.#latest.delete(id);
|
||||
}
|
||||
|
||||
await Promise.all(enabled.map((r) => this.#poll(r)));
|
||||
} catch (err) {
|
||||
this.#log.warn(`device-monitor tick failed: ${(err as Error).message}`);
|
||||
} finally {
|
||||
this.#ticking = false;
|
||||
}
|
||||
}
|
||||
|
||||
async #poll(row: DeviceRow): Promise<void> {
|
||||
const cfg = (row.config ?? {}) as Record<string, unknown>;
|
||||
const base = {
|
||||
deviceId: row.id,
|
||||
driverId: row.driverId,
|
||||
category: row.category,
|
||||
roleKind: roleKindOf(this.#db, row),
|
||||
};
|
||||
|
||||
let next: DeviceStatusEvent;
|
||||
const driver = registry.get(row.driverId);
|
||||
if (!driver) {
|
||||
// Configured against a driver that's no longer registered — surface it,
|
||||
// don't silently hide it.
|
||||
next = { ...base, state: "offline", detail: "driver not registered", checkedAt: new Date().toISOString() };
|
||||
} else {
|
||||
try {
|
||||
const device = driver.create(cfg as never);
|
||||
// Printers expose richer paper/cover/cutter status; everything else uses
|
||||
// the generic reachability probe. Both flatten to the same traffic-light.
|
||||
if (isMonitorable(device)) {
|
||||
const s = await device.readStatus();
|
||||
next = { ...base, state: s.status, detail: s.detail, checkedAt: s.checkedAt };
|
||||
} else {
|
||||
const h = await device.healthCheck();
|
||||
next = { ...base, state: h.status, detail: h.detail, checkedAt: new Date().toISOString() };
|
||||
}
|
||||
} catch (err) {
|
||||
// A probe that throws (build error, timeout) reads as offline — never crash
|
||||
// the tick, and fail toward "there's a problem" rather than false-healthy.
|
||||
next = { ...base, state: "offline", detail: (err as Error).message, checkedAt: new Date().toISOString() };
|
||||
}
|
||||
}
|
||||
|
||||
const prev = this.#latest.get(row.id);
|
||||
this.#latest.set(row.id, next);
|
||||
if (!prev || prev.state !== next.state || prev.detail !== next.detail) {
|
||||
this.#log.info(
|
||||
`device-monitor: ${next.category}/${next.roleKind ?? "—"} ${row.id} -> ${next.state}${next.detail ? ` (${next.detail})` : ""}`,
|
||||
);
|
||||
deviceEvents.emitDeviceStatus(next);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { requireRole } from "../auth.js";
|
||||
import type { DeviceMonitor } from "../device-monitor.js";
|
||||
|
||||
// Unified device-status snapshot for the booth footer. The DeviceMonitor polls all
|
||||
// configured devices (relays/readers/cameras via healthCheck, printers via their
|
||||
// rich readStatus) in the background; this exposes its cache. Live updates ride the
|
||||
// booth WebSocket (kind:"device-status") — this REST route is the initial load /
|
||||
// fallback. Any authenticated role may read (operational, not a setup action).
|
||||
// See wiki/concepts/device-status-monitoring.md, booth-console.md.
|
||||
|
||||
export async function deviceStatusRoutes(
|
||||
app: FastifyInstance,
|
||||
monitor: DeviceMonitor,
|
||||
): Promise<void> {
|
||||
const guard = requireRole("admin", "operator", "cashier", "readonly");
|
||||
|
||||
app.get("/api/devices/status", { preHandler: guard }, async () => ({
|
||||
devices: monitor.snapshot(),
|
||||
}));
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type { FastifyInstance } from "fastify";
|
||||
import type { Db } from "@parking/db";
|
||||
import type { Role } from "@parking/shared";
|
||||
import { deviceEvents } from "../device-events.js";
|
||||
import type { DeviceMonitor } from "../device-monitor.js";
|
||||
import { getOccupancy } from "../occupancy.js";
|
||||
|
||||
// Live booth feed over a WebSocket. The booth UI opens ONE socket and receives
|
||||
@@ -49,11 +50,12 @@ function isAllowedOrigin(origin: string | undefined, host: string | undefined):
|
||||
}
|
||||
|
||||
type OutMsg =
|
||||
| { kind: "hello"; occupancy: ReturnType<typeof getOccupancy> }
|
||||
| { kind: "hello"; occupancy: ReturnType<typeof getOccupancy>; devices: unknown }
|
||||
| { kind: "ledger"; event: unknown; occupancy: ReturnType<typeof getOccupancy> }
|
||||
| { kind: "printer-status"; event: unknown };
|
||||
| { kind: "printer-status"; event: unknown }
|
||||
| { kind: "device-status"; event: unknown };
|
||||
|
||||
export async function wsRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
export async function wsRoutes(app: FastifyInstance, db: Db, deviceMonitor: DeviceMonitor): Promise<void> {
|
||||
app.get(
|
||||
"/api/ws",
|
||||
{
|
||||
@@ -83,8 +85,9 @@ export async function wsRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
}
|
||||
};
|
||||
|
||||
// Initial snapshot so the client renders immediately, before any event.
|
||||
send({ kind: "hello", occupancy: getOccupancy(db) });
|
||||
// Initial snapshot so the client renders immediately, before any event:
|
||||
// occupancy AND the current device-status set (for the footer).
|
||||
send({ kind: "hello", occupancy: getOccupancy(db), devices: deviceMonitor.snapshot() });
|
||||
|
||||
// Subscribe to the live buses. Each handler recomputes occupancy from the
|
||||
// ledger (cheap fold) so the pushed count is always authoritative.
|
||||
@@ -94,10 +97,16 @@ export async function wsRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
const offPrinter = deviceEvents.onPrinterStatus((event) => {
|
||||
send({ kind: "printer-status", event });
|
||||
});
|
||||
// Unified device status (all categories) for the booth footer — pushed on
|
||||
// change; the initial set rode the hello above.
|
||||
const offDevice = deviceEvents.onDeviceStatus((event) => {
|
||||
send({ kind: "device-status", event });
|
||||
});
|
||||
|
||||
socket.on("close", () => {
|
||||
offLedger();
|
||||
offPrinter();
|
||||
offDevice();
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchDeviceStatus, type DeviceStatus } from "../api.js";
|
||||
import { qk } from "../lib/query.js";
|
||||
import { useLiveStore } from "../lib/live-store.js";
|
||||
|
||||
// Fixed device-status footer for the booth chrome. One compact chip per configured
|
||||
// device — relays, readers, cameras, printers — labelled by ROLE, never vendor
|
||||
// (e.g. "Lexuesi hyrje", "Printer kabina", "Kamera dalje"), with a traffic-light
|
||||
// dot. Fault detail does NOT pollute the footer: clicking opens a small panel that
|
||||
// lists the degraded/offline devices and their issues. Status is fed by the
|
||||
// DeviceMonitor over the WS (snapshot on connect + per-device pushes, held in the
|
||||
// live store); a REST snapshot seeds it / fills in if the WS is briefly down.
|
||||
// See wiki/concepts/device-status-monitoring.md, booth-console.md.
|
||||
|
||||
const DOT: Record<DeviceStatus["state"], string> = {
|
||||
ready: "bg-term-green",
|
||||
degraded: "bg-term-amber",
|
||||
offline: "bg-term-red",
|
||||
};
|
||||
|
||||
const TEXT: Record<DeviceStatus["state"], string> = {
|
||||
ready: "text-term-text",
|
||||
degraded: "text-term-amber",
|
||||
offline: "text-term-red",
|
||||
};
|
||||
|
||||
/** i18n key for a device category. */
|
||||
const CATEGORY_KEY: Record<DeviceStatus["category"], string> = {
|
||||
access: "devices.catAccess",
|
||||
reader: "devices.catReader",
|
||||
camera: "devices.catCamera",
|
||||
printer: "devices.catPrinter",
|
||||
};
|
||||
|
||||
/** i18n key for the role/direction token (null = no suffix). */
|
||||
function roleKey(roleKind: DeviceStatus["roleKind"]): string | null {
|
||||
return roleKind ? `devices.role.${roleKind}` : null;
|
||||
}
|
||||
|
||||
/** Stable display order: access (barrier) first, then readers, cameras, printers. */
|
||||
const ORDER: Record<DeviceStatus["category"], number> = {
|
||||
access: 0,
|
||||
reader: 1,
|
||||
camera: 2,
|
||||
printer: 3,
|
||||
};
|
||||
|
||||
/** "Lexuesi hyrje" — category word + localised role/direction (when known). */
|
||||
function useLabel() {
|
||||
const { t } = useTranslation();
|
||||
return (d: DeviceStatus) => {
|
||||
const cat = t(CATEGORY_KEY[d.category]);
|
||||
const rk = roleKey(d.roleKind);
|
||||
return rk ? `${cat} ${t(rk)}` : cat;
|
||||
};
|
||||
}
|
||||
|
||||
function sortDevices(list: DeviceStatus[]): DeviceStatus[] {
|
||||
return [...list].sort(
|
||||
(a, b) => ORDER[a.category] - ORDER[b.category] || (a.roleKind ?? "").localeCompare(b.roleKind ?? ""),
|
||||
);
|
||||
}
|
||||
|
||||
export function DeviceFooter() {
|
||||
const { t } = useTranslation();
|
||||
const label = useLabel();
|
||||
// Seed/fallback from REST; the WS keeps the live store authoritative thereafter.
|
||||
const seed = useQuery({ queryKey: qk.deviceStatus, queryFn: fetchDeviceStatus });
|
||||
const live = useLiveStore((s) => s.devices);
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const rootRef = useRef<HTMLElement>(null);
|
||||
|
||||
// Close the issues panel on an outside click or Escape.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
|
||||
document.addEventListener("mousedown", onDown);
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onDown);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
// Prefer the live store (WS); fall back to the REST snapshot before the first push.
|
||||
const fromLive = Object.values(live);
|
||||
const devices = sortDevices(fromLive.length > 0 ? fromLive : seed.data?.devices ?? []);
|
||||
const problems = devices.filter((d) => d.state !== "ready");
|
||||
|
||||
return (
|
||||
<footer
|
||||
ref={rootRef}
|
||||
className="relative flex shrink-0 items-center gap-2 overflow-visible border-t border-term-border bg-term-panel px-3 py-1.5 text-[11px]"
|
||||
>
|
||||
<span className="shrink-0 font-semibold uppercase tracking-wider text-term-muted">
|
||||
{t("devices.footerTitle")}
|
||||
</span>
|
||||
|
||||
<div className="flex items-center gap-1.5 overflow-x-auto">
|
||||
{devices.length === 0 ? (
|
||||
<span className="text-term-muted">{t("devices.none")}</span>
|
||||
) : (
|
||||
devices.map((d) => {
|
||||
const isProblem = d.state !== "ready";
|
||||
return (
|
||||
<button
|
||||
key={d.deviceId}
|
||||
type="button"
|
||||
// Only a problem chip is interactive (opens the issues panel).
|
||||
onClick={isProblem ? () => setOpen((v) => !v) : undefined}
|
||||
aria-disabled={!isProblem}
|
||||
title={isProblem ? t("devices.clickForIssues") : undefined}
|
||||
className={`flex shrink-0 items-center gap-1.5 whitespace-nowrap rounded-term border border-term-border bg-term-panel-2 px-2 py-0.5 ${
|
||||
isProblem ? "cursor-pointer hover:border-term-amber" : "cursor-default"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-2 w-2 shrink-0 rounded-full ${DOT[d.state]} ${
|
||||
d.state === "offline" ? "animate-pulse" : ""
|
||||
}`}
|
||||
/>
|
||||
<span className={TEXT[d.state]}>{label(d)}</span>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right-aligned roll-up; clicking opens the issues panel when any exist. */}
|
||||
<button
|
||||
type="button"
|
||||
disabled={problems.length === 0}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="ml-auto shrink-0 tabular-nums disabled:cursor-default"
|
||||
>
|
||||
{problems.length === 0 ? (
|
||||
devices.length > 0 ? (
|
||||
<span className="text-term-green">{t("devices.allOk")}</span>
|
||||
) : null
|
||||
) : (
|
||||
<span className="text-term-amber hover:underline">
|
||||
{t("devices.issuesCount", { count: problems.length })}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Issues panel — anchored above the footer, lists only problem devices. */}
|
||||
{open && problems.length > 0 && (
|
||||
<div className="absolute bottom-full right-2 z-50 mb-1 w-[360px] max-w-[95vw] rounded-term border border-term-border bg-term-panel shadow-2xl">
|
||||
<div className="flex items-center justify-between border-b border-term-border bg-term-panel-2 px-3 py-1.5">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-term-amber">
|
||||
{t("devices.issuesTitle")}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
className="text-term-muted hover:text-term-text"
|
||||
aria-label={t("common.close")}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<ul className="max-h-[40vh] overflow-y-auto p-1.5">
|
||||
{problems.map((d) => (
|
||||
<li
|
||||
key={d.deviceId}
|
||||
className="flex items-start gap-2 border-b border-term-border/40 px-1.5 py-1.5 last:border-b-0"
|
||||
>
|
||||
<span className={`mt-1 inline-block h-2 w-2 shrink-0 rounded-full ${DOT[d.state]}`} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className={`text-[12px] font-semibold ${TEXT[d.state]}`}>{label(d)}</span>
|
||||
<span className="shrink-0 text-[10px] uppercase tracking-wider text-term-muted">
|
||||
{t(`devices.state.${d.state}`)}
|
||||
</span>
|
||||
</div>
|
||||
{d.detail && <div className="mt-0.5 break-words text-[11px] text-term-muted">{d.detail}</div>}
|
||||
<div className="mt-0.5 text-[10px] tabular-nums text-term-muted/70">
|
||||
{t("devices.checkedAt", { time: new Date(d.checkedAt).toLocaleTimeString() })}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -83,6 +83,15 @@ the Active-Sessions "Open barrier" is disabled the same way. The server enforces
|
||||
(`requireShift` 409 `no_shift`) — the UI just front-runs the rejection. The live feed is **scoped to
|
||||
the open shift's window** (empty when no shift is open). See [[shift]] for the rule and the routes.
|
||||
|
||||
## The device-status footer
|
||||
|
||||
A **fixed footer** in the app shell shows the live status of every configured device — relays,
|
||||
readers, cameras, printers — one chip each (coloured dot + name + fault detail), with an "all ready
|
||||
/ N offline" roll-up. Fed by the unified [[device-status-monitoring|DeviceMonitor]] over the same
|
||||
`/api/ws` socket (`hello` carries the initial set; a `device-status` frame per change), held in the
|
||||
live store keyed by device id, with `GET /api/devices/status` as the seed/fallback. Visible on every
|
||||
screen, so the operator always sees the barrier relay's reachability and the printer's paper state.
|
||||
|
||||
## Dev notes
|
||||
- Vite proxies `/api/ws` (`ws: true`) to the backend; the backend's Origin allowlist must include the
|
||||
dev SPA origin (`WS_ALLOWED_ORIGINS=http://localhost:5173`). In production Fastify serves the SPA
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
---
|
||||
type: concept
|
||||
tags: [parking, device, monitoring, reliability, ui]
|
||||
sources: []
|
||||
updated: 2026-06-18
|
||||
status: open
|
||||
---
|
||||
|
||||
# Device status monitoring (the booth footer)
|
||||
|
||||
The booth shows a **fixed footer** with the live status of every configured device — relays,
|
||||
readers, cameras, printers — so an operator sees at a glance that the barrier relay is reachable,
|
||||
the exit scanner is up, and the ticket printer has paper. This generalises the printer-only
|
||||
[[printer-status-monitoring]] to **all four [[device-adapter-pattern|device categories]]**. A
|
||||
reliability control, not a threat-model one. (Built 2026-06-18.)
|
||||
|
||||
## What gets polled, and how
|
||||
|
||||
Every **enabled** row in `devices` is polled on an interval, regardless of category — the monitor
|
||||
talks only to the adapter interfaces ([[device-adapter-pattern]]), never a driver SDK:
|
||||
|
||||
- **Printers** → their rich `MonitorableDevice.readStatus()` (paper end / near-end, cover open,
|
||||
cutter error, off-line) — the same capability the existing [[printer-status-monitoring|PrinterMonitor]]
|
||||
uses. The footer surfaces the fault detail.
|
||||
- **Relays / readers / cameras** → the generic `Device.healthCheck()` **reachability** probe every
|
||||
adapter implements (`ready | degraded | offline`). This is presence/up-ness, not a deep fault
|
||||
model — a relay either answers or it doesn't.
|
||||
|
||||
Both collapse to one **traffic-light**: `ready | degraded | offline`, plus a `detail` string. Fail
|
||||
**toward "there's a problem"**, never false-healthy: a probe that throws or times out reads
|
||||
`offline` (consistent with [[printer-status-monitoring]]'s fail-safe mapping); a driver that's no
|
||||
longer registered reads `offline` ("driver not registered") rather than vanishing.
|
||||
|
||||
## The monitor (server)
|
||||
|
||||
`DeviceMonitor` (`apps/server/src/device-monitor.ts`), modelled on the PrinterMonitor:
|
||||
|
||||
- re-reads the device set each tick (a newly-assigned/removed device appears/disappears without a
|
||||
restart); drops cached status for devices that are gone or disabled;
|
||||
- polls every `DEVICE_POLL_MS` (default **8000ms**), never overlapping ticks;
|
||||
- caches the latest unified status per device id;
|
||||
- emits a `device-status` bus event **only when a device's state or detail changes** (deduped).
|
||||
|
||||
> **Relationship to the PrinterMonitor.** Both run. The PrinterMonitor stays the authority for the
|
||||
> printer-specific live detail + its SSE stream (`/api/printers/status*`) that the entry flow may
|
||||
> later depend on for [[printer-roles-failover]]. The DeviceMonitor is the **unified footer feed**
|
||||
> across all categories. They poll independently (printers get probed by both — cheap HTTP reads);
|
||||
> the small duplication is deliberate, to avoid coupling the footer to printer internals. Could be
|
||||
> consolidated later if the overlap ever matters.
|
||||
|
||||
## API / live UI
|
||||
|
||||
- `GET /api/devices/status` — cached snapshot of all devices (no device round-trip). Any
|
||||
authenticated role (operational, not a setup action).
|
||||
- Live updates ride the **one booth [[booth-console|WebSocket]]** (`/api/ws`): the `hello` frame
|
||||
carries the initial device-status set; a `device-status` frame is pushed per change. The web
|
||||
[[booth-console|live store]] holds the set keyed by device id; the REST snapshot seeds it / fills
|
||||
in if the socket is briefly down.
|
||||
- **`DeviceFooter`** (`apps/web/src/ui/DeviceFooter.tsx`) renders one **compact** chip per device —
|
||||
a coloured dot + a **role label, never the vendor** — ordered access → reader → camera → printer,
|
||||
with a right-aligned roll-up ("N with issues" / "all ready"). Mounted in the app shell so it's
|
||||
visible on every screen.
|
||||
|
||||
### Label = role, not vendor (refinement 2026-06-18)
|
||||
|
||||
The chip shows **what the device does, not who made it**: the localised category + a role/direction
|
||||
suffix → `Lexuesi hyrje`, `Printer kabina`, `Kamera dalje`. The server sends a structured
|
||||
**`roleKind`** token (not a composed string), the client localises it:
|
||||
- **reader / camera** → the direction inherited from its bound relay (`directionOf()` in
|
||||
[[entry-exit-points|device-resolve]]): `entry | exit | both`.
|
||||
- **access controller** → `entry | exit | both` from its `relays[]`, or **`mixed`** when it spans
|
||||
more than one direction; `null` if it declares none yet.
|
||||
- **printer** → `lane` (entry-dispenser) | `booth` (booth-receipt) — the [[printer-roles-failover]] role.
|
||||
- `null` → the chip shows the category alone.
|
||||
|
||||
### Detail does NOT pollute the footer (refinement 2026-06-18)
|
||||
|
||||
Chips stay short — **no inline fault text**. A device that is `degraded`/`offline` is clickable (so
|
||||
is the roll-up); clicking opens a small **issues panel** anchored above the footer that lists only
|
||||
the problem devices with their role label, state, the `detail` string, and the last-checked time.
|
||||
`ready` chips are non-interactive. The panel closes on outside-click / Escape (a lightweight
|
||||
popover — no extra dependency; only Radix Dialog is installed).
|
||||
|
||||
## Verified (2026-06-18)
|
||||
|
||||
On a fresh DB seeded with a stub relay, a TCP reader, and two printers (one reachable, one not):
|
||||
relay + reader → `ready` via `healthCheck`; the unreachable printer → `offline` (with a detail
|
||||
string, never threw); the bus emitted once per device on first observation, and a second unchanged
|
||||
tick was silent (change-only emit). Server + web build clean.
|
||||
|
||||
## Open / not yet done
|
||||
|
||||
- **Reachability ≠ correctness.** `healthCheck()` says a relay/reader answers, not that it's wired
|
||||
to the right barrier or reading cards — that's a setup/precondition concern ([[first-run-setup]],
|
||||
the Dingtian [[access-controller-button-flow|precondition checks]]).
|
||||
- **No per-device history / alerting.** The footer is point-in-time; a flapping device isn't
|
||||
tracked over time. Reconciliation-style alerting is out of scope here.
|
||||
- **Cameras** only expose `healthCheck` reachability today; a "last snapshot age" health signal
|
||||
could be richer ([[lpr-camera]], [[opencv-anpr-service]]).
|
||||
- Possible later **consolidation** of PrinterMonitor + DeviceMonitor (see the note above).
|
||||
@@ -7,6 +7,11 @@ updated: 2026-06-14
|
||||
|
||||
# Printer status monitoring
|
||||
|
||||
> **Generalised 2026-06-18:** the booth's all-device status **footer** is a separate, unified
|
||||
> monitor across every category (relays/readers/cameras/printers) — see
|
||||
> [[device-status-monitoring]]. This page remains the authority for the *printer-specific* rich
|
||||
> status (paper/cover/cutter) + its SSE stream; both monitors run.
|
||||
|
||||
The booth must know a printer is in trouble **before** a driver presses the entry button and no
|
||||
ticket comes out. So the system polls each printer's live status (paper out, cover open, cutter
|
||||
jam, off-line) and pushes changes to the operator UI. A reliability control, like
|
||||
|
||||
Reference in New Issue
Block a user