fa65b2df86
Replace the camera stub with HttpCamera: Hikvision ISAPI and Dahua CGI snapshots over client-side HTTP Digest (new drivers/http-digest.ts). healthCheck() now pulls a real frame instead of returning ready/stub. Snapshot carries bytes (driver fetches); storage/imageRef is the caller's job, keeping the adapter free of storage deps. Fix the cosmetic Backend-push-IP field: add pushesToBackend to DeviceDriver (only Dingtian sets it), expose as pushCapable in the catalog, and gate the wizard's backend-IP fetch + field on it so pull-only devices hide it. Verified on hardware (Hikvision 10.0.10.121): healthCheck ready, captureSnapshot returns a valid JPEG.
530 lines
18 KiB
TypeScript
530 lines
18 KiB
TypeScript
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<Catalog | null>(null);
|
|
const [assignments, setAssignments] = useState<Assignment[] | null>(null);
|
|
const [lane, setLane] = useState(1);
|
|
const [error, setError] = useState<string | null>(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 <p style={{ color: "crimson" }}>Failed to load setup: {error}</p>;
|
|
if (!catalog || !assignments) return <p>Loading device catalog…</p>;
|
|
|
|
return (
|
|
<section>
|
|
<h2>First-run setup</h2>
|
|
<div style={{ display: "flex", gap: "1rem", alignItems: "center" }}>
|
|
<label>
|
|
Lane{" "}
|
|
<input
|
|
type="number"
|
|
min={1}
|
|
value={lane}
|
|
onChange={(e) => setLane(Number(e.target.value))}
|
|
style={{ width: "4rem" }}
|
|
/>
|
|
</label>
|
|
<span style={{ color: "#666", fontSize: "0.85em" }}>
|
|
Devices are added per lane. Switch lanes to configure another.
|
|
</span>
|
|
</div>
|
|
|
|
{CATEGORIES.map(({ key, title, noun }) => (
|
|
<CategorySection
|
|
key={key}
|
|
lane={lane}
|
|
category={key}
|
|
title={title}
|
|
noun={noun}
|
|
entries={catalog[key]}
|
|
discoverableIds={catalog.discoverable}
|
|
pushCapableIds={catalog.pushCapable}
|
|
assignments={assignments.filter((a) => a.category === key && a.lane === lane)}
|
|
onChanged={reloadState}
|
|
/>
|
|
))}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
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> | 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<string[]>([]);
|
|
const showForm = adding || assignments.length === 0;
|
|
|
|
return (
|
|
<fieldset style={{ marginTop: "1rem" }}>
|
|
<legend>
|
|
{title} <span style={{ color: "#888", fontWeight: 400 }}>· lane {lane}</span>
|
|
</legend>
|
|
|
|
{warnings.length > 0 && (
|
|
<div
|
|
style={{
|
|
margin: "0 0 0.75rem",
|
|
padding: "0.5rem 0.75rem",
|
|
background: "#fef3c7",
|
|
border: "1px solid #f59e0b",
|
|
borderRadius: 6,
|
|
}}
|
|
>
|
|
<strong style={{ color: "#92400e" }}>⚠ Saved, but action needed:</strong>
|
|
<ul style={{ margin: "0.25rem 0 0", paddingLeft: "1.25rem", color: "#92400e" }}>
|
|
{warnings.map((w, i) => (
|
|
<li key={i}>{w}</li>
|
|
))}
|
|
</ul>
|
|
<button type="button" onClick={() => setWarnings([])} style={{ marginTop: "0.5rem" }}>
|
|
Dismiss
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{assignments.length > 0 && (
|
|
<ul style={{ listStyle: "none", padding: 0, margin: "0 0 0.75rem" }}>
|
|
{assignments.map((a) => (
|
|
<AssignmentRow key={a.id} assignment={a} onChanged={onChanged} />
|
|
))}
|
|
</ul>
|
|
)}
|
|
|
|
{showForm ? (
|
|
<DeviceForm
|
|
lane={lane}
|
|
category={category}
|
|
entries={entries}
|
|
discoverableIds={discoverableIds}
|
|
pushCapableIds={pushCapableIds}
|
|
onSaved={async (w) => {
|
|
setWarnings(w);
|
|
await onChanged();
|
|
setAdding(false);
|
|
}}
|
|
onCancel={assignments.length > 0 ? () => setAdding(false) : undefined}
|
|
/>
|
|
) : (
|
|
<button type="button" onClick={() => setAdding(true)}>
|
|
+ Add another {noun}
|
|
</button>
|
|
)}
|
|
</fieldset>
|
|
);
|
|
}
|
|
|
|
function AssignmentRow({
|
|
assignment,
|
|
onChanged,
|
|
}: {
|
|
assignment: Assignment;
|
|
onChanged: () => Promise<void> | void;
|
|
}) {
|
|
const [removing, setRemoving] = useState(false);
|
|
const [error, setError] = useState<string | null>(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 (
|
|
<li
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: "0.5rem",
|
|
padding: "0.4rem 0.5rem",
|
|
borderBottom: "1px solid #eee",
|
|
}}
|
|
>
|
|
<strong>{assignment.driverId}</strong>
|
|
{role && <span style={{ color: "#0369a1" }}>{role}</span>}
|
|
{host && <span style={{ color: "#666" }}>{host}</span>}
|
|
{!assignment.enabled && <span style={{ color: "#b45309" }}>(disabled)</span>}
|
|
<span style={{ flex: 1 }} />
|
|
{error && <span style={{ color: "crimson" }}>{error}</span>}
|
|
<button type="button" onClick={remove} disabled={removing}>
|
|
{removing ? "Removing…" : "Remove"}
|
|
</button>
|
|
</li>
|
|
);
|
|
}
|
|
|
|
function DeviceForm({
|
|
lane,
|
|
category,
|
|
entries,
|
|
discoverableIds,
|
|
pushCapableIds,
|
|
onSaved,
|
|
onCancel,
|
|
}: {
|
|
lane: number;
|
|
category: DeviceCategory;
|
|
entries: CatalogEntry[];
|
|
discoverableIds: string[];
|
|
pushCapableIds: string[];
|
|
onSaved: (warnings: string[]) => Promise<void> | void;
|
|
onCancel?: () => void;
|
|
}) {
|
|
const [selectedId, setSelectedId] = useState<string>("");
|
|
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<Record<string, string | number>>({});
|
|
const [tested, setTested] = useState<TestResult | null>(null);
|
|
const [testing, setTesting] = useState(false);
|
|
const [testError, setTestError] = useState<string | null>(null);
|
|
const [saving, setSaving] = useState(false);
|
|
const [saveError, setSaveError] = useState<string | null>(null);
|
|
const [found, setFound] = useState<DiscoveredDevice[] | null>(null);
|
|
const [scanning, setScanning] = useState(false);
|
|
const [scanError, setScanError] = useState<string | null>(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<BackendIpCandidate[] | null>(null);
|
|
const [backendIp, setBackendIp] = useState<string>("");
|
|
|
|
// (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<string, string | number>) }));
|
|
resetStatus();
|
|
}
|
|
|
|
// Config the user actually entered, merged over driver defaults.
|
|
function mergedConfig(): Record<string, string | number> {
|
|
const out: Record<string, string | number> = {};
|
|
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 (
|
|
<div style={{ padding: "0.5rem", background: "#fafafa", borderRadius: 6 }}>
|
|
{entries.length === 0 ? (
|
|
<em>No drivers registered.</em>
|
|
) : (
|
|
<select value={selectedId} onChange={(e) => selectDriver(e.target.value)}>
|
|
<option value="" disabled>
|
|
Choose a device…
|
|
</option>
|
|
{entries.map((e) => (
|
|
<option key={e.id} value={e.id}>
|
|
{e.label} ({e.transports.join(", ")})
|
|
</option>
|
|
))}
|
|
</select>
|
|
)}
|
|
|
|
{selected && (
|
|
<div style={{ marginTop: "0.5rem" }}>
|
|
<p style={{ margin: "0.25rem 0", color: "#555" }}>{selected.description}</p>
|
|
|
|
{canDiscover && (
|
|
<div style={{ margin: "0.5rem 0", padding: "0.5rem", background: "#f3f4f6", borderRadius: 6 }}>
|
|
<button type="button" onClick={scan} disabled={scanning}>
|
|
{scanning ? "Scanning…" : "Scan for controllers"}
|
|
</button>
|
|
{scanError && <span style={{ marginLeft: 8, color: "crimson" }}>{scanError}</span>}
|
|
{found && found.length === 0 && <p style={{ margin: "0.5rem 0 0" }}>No controllers found on the LAN.</p>}
|
|
{found && found.length > 0 && (
|
|
<ul style={{ margin: "0.5rem 0 0", paddingLeft: "1rem" }}>
|
|
{found.map((d) => (
|
|
<li key={d.id} style={{ margin: "0.25rem 0" }}>
|
|
<button type="button" onClick={() => applyDiscovered(d)}>
|
|
Use
|
|
</button>{" "}
|
|
<strong>{d.label}</strong>{" "}
|
|
<HealthBadge status={d.health.status} />
|
|
{d.info?.firmware && <span style={{ color: "#666" }}> · fw {d.info.firmware}</span>}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{selected.configFields.map((f) => (
|
|
<div key={f.key} style={{ margin: "0.25rem 0" }}>
|
|
<label>
|
|
{f.label}
|
|
{f.required ? " *" : ""}{" "}
|
|
{f.type === "select" ? (
|
|
<select
|
|
value={String(config[f.key] ?? (f.default as string | number | undefined) ?? "")}
|
|
onChange={(e) => {
|
|
const v = e.target.value;
|
|
setConfig((c) => ({ ...c, [f.key]: v }));
|
|
resetStatus();
|
|
}}
|
|
>
|
|
{f.options?.map((o) => (
|
|
<option key={o.value} value={o.value}>
|
|
{o.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
) : (
|
|
<input
|
|
type={f.type === "secret" ? "password" : f.type === "number" || f.type === "port" ? "number" : "text"}
|
|
value={config[f.key] ?? (f.default as string | number | undefined) ?? ""}
|
|
placeholder={f.help}
|
|
onChange={(e) => {
|
|
const v = e.target.value;
|
|
setConfig((c) => ({ ...c, [f.key]: v }));
|
|
resetStatus();
|
|
}}
|
|
/>
|
|
)}
|
|
</label>
|
|
</div>
|
|
))}
|
|
|
|
{/* Test (no save/no device change) then Save (configures + persists). */}
|
|
<div style={{ marginTop: "0.75rem", display: "flex", gap: "0.5rem", alignItems: "center" }}>
|
|
<button type="button" onClick={test} disabled={testing}>
|
|
{testing ? "Testing…" : "Test connection"}
|
|
</button>
|
|
<button type="button" onClick={save} disabled={saving}>
|
|
{saving ? "Saving…" : "Save & configure"}
|
|
</button>
|
|
{onCancel && (
|
|
<button type="button" onClick={onCancel} disabled={saving}>
|
|
Cancel
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{testError && <p style={{ color: "crimson", margin: "0.5rem 0 0" }}>Test failed: {testError}</p>}
|
|
{tested && (
|
|
<div style={{ margin: "0.5rem 0 0" }}>
|
|
<div>
|
|
Device: <HealthBadge status={tested.health.status} />
|
|
{tested.health.detail && <span style={{ color: "#666" }}> — {tested.health.detail}</span>}
|
|
</div>
|
|
{tested.preconditions.ok ? (
|
|
<div style={{ color: "#16a34a" }}>● preconditions OK</div>
|
|
) : (
|
|
tested.preconditions.issues.map((i) => (
|
|
<div key={i.key} style={{ color: "#d97706" }}>
|
|
⚠ {i.message}
|
|
{i.fixable && <span style={{ color: "#666" }}> (auto-fixed on save)</span>}
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* 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 && (
|
|
<div style={{ margin: "0.5rem 0 0" }}>
|
|
<label>
|
|
Backend push IP{" "}
|
|
<select value={backendIp} onChange={(e) => setBackendIp(e.target.value)}>
|
|
{!backendIps.some((c) => c.onDeviceSubnet) && (
|
|
<option value="" disabled>
|
|
Choose an address…
|
|
</option>
|
|
)}
|
|
{backendIps.map((c) => (
|
|
<option key={c.ip} value={c.ip}>
|
|
{c.ip} ({c.iface}){c.onDeviceSubnet ? " — on device subnet" : ""}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
{!backendIps.some((c) => c.onDeviceSubnet) && (
|
|
<span style={{ marginLeft: 8, color: "#d97706" }}>
|
|
⚠ no NIC on the device's subnet — the device may not reach the backend
|
|
</span>
|
|
)}
|
|
<p style={{ margin: "0.25rem 0 0", color: "#666", fontSize: "0.85em" }}>
|
|
The address this device will POST input events to.
|
|
</p>
|
|
</div>
|
|
)}
|
|
{saveError && <p style={{ color: "crimson", margin: "0.5rem 0 0" }}>Save failed: {saveError}</p>}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function HealthBadge({ status }: { status: string }) {
|
|
const color = status === "ready" ? "#16a34a" : status === "degraded" ? "#d97706" : "#dc2626";
|
|
return <span style={{ color, fontWeight: 600 }}>● {status}</span>;
|
|
}
|