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.
|
// 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
|
||||||
|
|||||||
@@ -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 { 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();
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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: {} }),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
(`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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user