import { useState, useEffect, useCallback } from "react"; import { assignDevice, discoverDevices, fetchBackendIps, fetchCatalog, fetchState, testDevice, unassignDevice, type Assignment, type BackendIpCandidate, type Catalog, type CatalogEntry, type DeviceCategory, type DiscoveredDevice, type TestResult, } from "./api.js"; // First-run setup wizard (scaffold). The admin assigns devices per lane from the // driver catalog. The data model is multi-instance — one lane_devices row per // instance — so EVERY category supports more than one device: each section lists // the already-assigned instances (with Remove) and an "Add" form. Drivers that // support LAN discovery get a "Scan" button. Auth is via the admin's session // cookie. See wiki/concepts/first-run-setup.md and device-discovery.md. const CATEGORIES: { key: DeviceCategory; title: string; noun: string }[] = [ { key: "access", title: "Access controllers", noun: "access controller" }, { key: "reader", title: "Readers", noun: "reader" }, { key: "camera", title: "Cameras (entry/exit snapshot)", noun: "camera" }, { key: "printer", title: "Printers", noun: "printer" }, ]; export function SetupWizard() { const [catalog, setCatalog] = useState(null); const [assignments, setAssignments] = useState(null); const [lane, setLane] = useState(1); const [error, setError] = useState(null); const reloadState = useCallback(() => { return fetchState() .then((s) => setAssignments(s.assignments)) .catch((e: Error) => setError(e.message)); }, []); useEffect(() => { fetchCatalog().then(setCatalog).catch((e: Error) => setError(e.message)); reloadState(); }, [reloadState]); if (error) return

Failed to load setup: {error}

; if (!catalog || !assignments) return

Loading device catalog…

; return (

First-run setup

Devices are added per lane. Switch lanes to configure another.
{CATEGORIES.map(({ key, title, noun }) => ( a.category === key && a.lane === lane)} onChanged={reloadState} /> ))}
); } function CategorySection({ lane, category, title, noun, entries, discoverableIds, pushCapableIds, assignments, onChanged, }: { lane: number; category: DeviceCategory; title: string; noun: string; entries: CatalogEntry[]; discoverableIds: string[]; pushCapableIds: string[]; assignments: Assignment[]; onChanged: () => Promise | void; }) { // Show the add-form automatically when nothing is assigned yet; otherwise it's // collapsed behind "Add another" so the list stays the focus. const [adding, setAdding] = useState(false); // Warnings from the most recent save (e.g. "string protocol could not be // disabled — finish in the device web UI"). Persist after the form closes. const [warnings, setWarnings] = useState([]); const showForm = adding || assignments.length === 0; return (
{title} · lane {lane} {warnings.length > 0 && (
⚠ Saved, but action needed:
    {warnings.map((w, i) => (
  • {w}
  • ))}
)} {assignments.length > 0 && (
    {assignments.map((a) => ( ))}
)} {showForm ? ( { setWarnings(w); await onChanged(); setAdding(false); }} onCancel={assignments.length > 0 ? () => setAdding(false) : undefined} /> ) : ( )}
); } function AssignmentRow({ assignment, onChanged, }: { assignment: Assignment; onChanged: () => Promise | void; }) { const [removing, setRemoving] = useState(false); const [error, setError] = useState(null); // A short, human summary of the instance: role (if any) + host. const cfg = assignment.config; const role = typeof cfg.role === "string" ? cfg.role : null; const host = typeof cfg.host === "string" ? cfg.host : null; async function remove() { if (!confirm(`Remove this ${assignment.driverId} device?`)) return; setRemoving(true); setError(null); try { await unassignDevice(assignment.id); await onChanged(); } catch (e) { setError((e as Error).message); setRemoving(false); } } return (
  • {assignment.driverId} {role && {role}} {host && {host}} {!assignment.enabled && (disabled)} {error && {error}}
  • ); } function DeviceForm({ lane, category, entries, discoverableIds, pushCapableIds, onSaved, onCancel, }: { lane: number; category: DeviceCategory; entries: CatalogEntry[]; discoverableIds: string[]; pushCapableIds: string[]; onSaved: (warnings: string[]) => Promise | void; onCancel?: () => void; }) { const [selectedId, setSelectedId] = useState(""); const selected = entries.find((e) => e.id === selectedId); const canDiscover = selected != null && discoverableIds.includes(selected.id); // Only push-capable drivers (e.g. the Dingtian relay) call back to the // backend and need a backend IP. Pull-only devices (cameras, commanded relays) // must NOT show the field. See wiki/concepts/device-input-flow.md. const pushesToBackend = selected != null && pushCapableIds.includes(selected.id); // Config values (auto-filled by discovery, editable by hand). const [config, setConfig] = useState>({}); const [tested, setTested] = useState(null); const [testing, setTesting] = useState(false); const [testError, setTestError] = useState(null); const [saving, setSaving] = useState(false); const [saveError, setSaveError] = useState(null); const [found, setFound] = useState(null); const [scanning, setScanning] = useState(false); const [scanError, setScanError] = useState(null); // Backend push IP: which of OUR addresses the device should call back on. We // auto-pick the NIC on the device's subnet, but surface it editable here so a // multi-NIC host can be corrected (the chosen IP is baked into the device on // save). Only relevant for drivers that push back to us (pushesToBackend). const [backendIps, setBackendIps] = useState(null); const [backendIp, setBackendIp] = useState(""); // (Re)load backend-IP candidates whenever the device host changes after a // successful test (the test confirms the host is real + reachable) — but only // for push-capable drivers; a pull-only device never calls back. const testedHost = tested ? String(mergedConfig().host ?? "") : ""; useEffect(() => { if (!testedHost || !pushesToBackend) { setBackendIps(null); return; } let live = true; fetchBackendIps(testedHost) .then(({ candidates }) => { if (!live) return; setBackendIps(candidates); setBackendIp((cur) => cur || candidates.find((c) => c.onDeviceSubnet)?.ip || ""); }) .catch(() => { if (live) setBackendIps(null); }); return () => { live = false; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [testedHost, pushesToBackend]); function selectDriver(id: string) { setSelectedId(id); setConfig({}); setFound(null); resetStatus(); } async function scan() { if (!selected) return; setScanning(true); setScanError(null); try { setFound(await discoverDevices(selected.id)); } catch (e) { setScanError((e as Error).message); } finally { setScanning(false); } } function applyDiscovered(d: DiscoveredDevice) { setConfig((c) => ({ ...c, ...(d.config as Record) })); resetStatus(); } // Config the user actually entered, merged over driver defaults. function mergedConfig(): Record { const out: Record = {}; for (const f of selected?.configFields ?? []) { const v = config[f.key] ?? (f.default as string | number | undefined); if (v !== undefined && v !== "") out[f.key] = v; } return out; } // Editing config invalidates a prior test. function resetStatus() { setTested(null); setTestError(null); setSaveError(null); } async function test() { if (!selected) return; setTesting(true); setTestError(null); setTested(null); try { setTested(await testDevice(selected.id, mergedConfig())); } catch (e) { setTestError((e as Error).message); } finally { setTesting(false); } } async function save() { if (!selected) return; setSaving(true); setSaveError(null); try { const result = await assignDevice({ lane, category, driverId: selected.id, config: mergedConfig(), ...(backendIp ? { backendIp } : {}), }); // Hand warnings to the parent so they persist after this form unmounts. await onSaved(result.warnings ?? []); } catch (e) { setSaveError((e as Error).message); } finally { setSaving(false); } } return (
    {entries.length === 0 ? ( No drivers registered. ) : ( )} {selected && (

    {selected.description}

    {canDiscover && (
    {scanError && {scanError}} {found && found.length === 0 &&

    No controllers found on the LAN.

    } {found && found.length > 0 && (
      {found.map((d) => (
    • {" "} {d.label}{" "} {d.info?.firmware && · fw {d.info.firmware}}
    • ))}
    )}
    )} {selected.configFields.map((f) => (
    ))} {/* Test (no save/no device change) then Save (configures + persists). */}
    {onCancel && ( )}
    {testError &&

    Test failed: {testError}

    } {tested && (
    Device: {tested.health.detail && — {tested.health.detail}}
    {tested.preconditions.ok ? (
    ● preconditions OK
    ) : ( tested.preconditions.issues.map((i) => (
    ⚠ {i.message} {i.fixable && (auto-fixed on save)}
    )) )}
    )} {/* Backend push IP — only for push-capable devices (candidates present). Pre-filled with the auto-pick; editable for multi-NIC hosts. */} {backendIps && backendIps.length > 0 && (
    {!backendIps.some((c) => c.onDeviceSubnet) && ( ⚠ no NIC on the device's subnet — the device may not reach the backend )}

    The address this device will POST input events to.

    )} {saveError &&

    Save failed: {saveError}

    }
    )}
    ); } function HealthBadge({ status }: { status: string }) { const color = status === "ready" ? "#16a34a" : status === "degraded" ? "#d97706" : "#dc2626"; return ● {status}; }