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
+40 -2
View File
@@ -17,7 +17,7 @@ export interface DeviceInputEvent {
} }
// A credential read: a ticket scanned at exit, a plate from LPR, a card at a reader. // 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. // mirrors IdentitySource. See parking-session.md.
export interface DeviceReadEvent { export interface DeviceReadEvent {
readonly driverId: string; readonly driverId: string;
@@ -35,7 +35,7 @@ export interface DeviceReadEvent {
export interface ReadOutcome { export interface ReadOutcome {
/** Was the vehicle admitted/exited (barrier opened)? Drives the reader's beep. */ /** Was the vehicle admitted/exited (barrier opened)? Drives the reader's beep. */
readonly accepted: boolean; 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"; readonly direction?: "entry" | "exit";
/** Human-readable reason (for logs / the reader UI), esp. on reject. */ /** Human-readable reason (for logs / the reader UI), esp. on reject. */
readonly reason?: string; readonly reason?: string;
@@ -49,6 +49,33 @@ export interface PrinterStatusEvent {
readonly status: PrinterStatus; 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 { class DeviceEventBus extends EventEmitter {
emitInput(event: DeviceInputEvent): void { emitInput(event: DeviceInputEvent): void {
this.emit("input", event); this.emit("input", event);
@@ -76,6 +103,17 @@ class DeviceEventBus extends EventEmitter {
return () => this.off("printer-status", cb); 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, * 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 * payment, void, …). The payload is the persisted row — business facts only, no
+156
View File
@@ -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);
}
}
}
+21
View File
@@ -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(),
}));
}
+14 -5
View File
@@ -2,6 +2,7 @@ import type { FastifyInstance } from "fastify";
import type { Db } from "@parking/db"; import type { Db } from "@parking/db";
import type { Role } from "@parking/shared"; import type { Role } from "@parking/shared";
import { deviceEvents } from "../device-events.js"; import { deviceEvents } from "../device-events.js";
import type { DeviceMonitor } from "../device-monitor.js";
import { getOccupancy } from "../occupancy.js"; import { getOccupancy } from "../occupancy.js";
// Live booth feed over a WebSocket. The booth UI opens ONE socket and receives // 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 = 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: "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( app.get(
"/api/ws", "/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. // Initial snapshot so the client renders immediately, before any event:
send({ kind: "hello", occupancy: getOccupancy(db) }); // 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 // Subscribe to the live buses. Each handler recomputes occupancy from the
// ledger (cheap fold) so the pushed count is always authoritative. // 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) => { const offPrinter = deviceEvents.onPrinterStatus((event) => {
send({ kind: "printer-status", 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", () => { socket.on("close", () => {
offLedger(); offLedger();
offPrinter(); offPrinter();
offDevice();
}); });
}, },
); );
+19 -2
View File
@@ -1,5 +1,5 @@
import { create } from "zustand"; 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 // CLIENT state for the live booth feed — deliberately small. Server data (the
// authoritative event list, occupancy totals) is owned by TanStack Query; this // authoritative event list, occupancy totals) is owned by TanStack Query; this
@@ -20,16 +20,31 @@ interface LiveState {
occupancy: Occupancy | null; occupancy: Occupancy | null;
/** Newest-first tail of recently pushed ledger events (for the live ticker). */ /** Newest-first tail of recently pushed ledger events (for the live ticker). */
feed: LedgerEvent[]; 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; setStatus: (s: WsStatus) => void;
setOccupancy: (o: Occupancy) => void; setOccupancy: (o: Occupancy) => void;
pushEvent: (e: LedgerEvent) => 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; 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) => ({ export const useLiveStore = create<LiveState>((set) => ({
status: "connecting", status: "connecting",
occupancy: null, occupancy: null,
feed: [], feed: [],
devices: {},
setStatus: (status) => set({ status }), setStatus: (status) => set({ status }),
setOccupancy: (occupancy) => set({ occupancy }), setOccupancy: (occupancy) => set({ occupancy }),
pushEvent: (e) => 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. // 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), 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: {} }),
})); }));
+1
View File
@@ -26,4 +26,5 @@ export const qk = {
activeSessions: ["active-sessions"] as const, activeSessions: ["active-sessions"] as const,
siteConfig: ["site-config"] as const, siteConfig: ["site-config"] as const,
shift: ["shift"] as const, shift: ["shift"] as const,
deviceStatus: ["device-status"] as const,
} as const; } as const;
+9 -4
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { useQueryClient } from "@tanstack/react-query"; 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 { qk } from "./query.js";
import { useLiveStore } from "./live-store.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). */ /** Server → client message shapes (mirror routes/ws.ts OutMsg). */
type WsMessage = type WsMessage =
| { kind: "hello"; occupancy: Occupancy } | { kind: "hello"; occupancy: Occupancy; devices: DeviceStatus[] }
| { kind: "ledger"; event: LedgerEvent; occupancy: Occupancy } | { 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. */ /** Build the ws:// or wss:// URL for the same origin the SPA is served from. */
function wsUrl(): string { function wsUrl(): string {
@@ -25,7 +26,7 @@ function wsUrl(): string {
export function useLiveFeed(): void { export function useLiveFeed(): void {
const qc = useQueryClient(); 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 // Hold the socket + reconnect timer across renders; guard against StrictMode
// double-invoke and unmount. // double-invoke and unmount.
const sockRef = useRef<WebSocket | null>(null); const sockRef = useRef<WebSocket | null>(null);
@@ -55,6 +56,10 @@ export function useLiveFeed(): void {
} }
if (msg.kind === "hello") { if (msg.kind === "hello") {
setOccupancy(msg.occupancy); 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") { } else if (msg.kind === "ledger") {
setOccupancy(msg.occupancy); setOccupancy(msg.occupancy);
pushEvent(msg.event); pushEvent(msg.event);
+195
View File
@@ -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>
);
}
+9
View File
@@ -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 (`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 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 ## Dev notes
- Vite proxies `/api/ws` (`ws: true`) to the backend; the backend's Origin allowlist must include the - 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 dev SPA origin (`WS_ALLOWED_ORIGINS=http://localhost:5173`). In production Fastify serves the SPA
+100
View File
@@ -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 # 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 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 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 jam, off-line) and pushes changes to the operator UI. A reliability control, like