4af8b56dda
Make the vision service genuinely configurable (was env-only). - SetupWizard: an "ANPR" checkbox on the camera form (writes config.anpr; persisted only when on; sq+en) — opt-in is no longer raw JSON. - DeviceMonitor optionally takes the VisionClient and probes /health each tick, emitting a "vision" pseudo-device → a Vision chip (ready/degraded/offline + recognizer) in the booth footer when VISION_ENABLED, no chip when off. Widened the DeviceStatus category union (server + web) + footer maps + devices.catVision. Verified: ready/fast_alpr when up, 0 chips when disabled. - apps/vision/.env.example (Python service) + a VISION_* block in apps/server/.env.example (Node side) + a Configuration section in opencv-anpr-service.md covering all four layers and the caveats: the two processes share the VISION_ prefix but need SEPARATE .env files; bind /analyze to 127.0.0.1; cache model weights at deploy; an unbound anpr camera recognizes but every read is refused. Build + lint green. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
198 lines
7.8 KiB
TypeScript
198 lines
7.8 KiB
TypeScript
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",
|
|
vision: "devices.catVision",
|
|
};
|
|
|
|
/** 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,
|
|
vision: 4,
|
|
};
|
|
|
|
/** "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>
|
|
);
|
|
}
|