Setup: manage multiple device instances per category (add/remove)
The data model was already multi-instance (lane_devices = one row per instance; assign always inserts) -- the limitation was UI-only. Make the whole flow support more than one of every category: - Backend: add DELETE /api/setup/assign/:id (unassign by id). /state now redacts secrets (pushPassword/webPassword/relayPassword) via a shared redactSecrets() also used by /assign -- it was returning raw config rows. - Web: SetupWizard reworked from one fixed slot per category into a list of assigned instances (driver/role/host + Remove) plus an "Add another" form. select-type config fields (e.g. printer role) now render as dropdowns. - api.ts: add fetchState(), unassignDevice(), Assignment/SetupState types. Verified via Fastify inject: two printers assigned to one lane both list, no secret leak, delete -> 204, delete unknown -> 404, count drops to 1. Full repo typechecks. Wiki: first-run-setup documents multi-instance + delete + redaction.
This commit is contained in:
+195
-48
@@ -1,10 +1,13 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
assignDevice,
|
||||
discoverDevices,
|
||||
fetchBackendIps,
|
||||
fetchCatalog,
|
||||
fetchState,
|
||||
testDevice,
|
||||
unassignDevice,
|
||||
type Assignment,
|
||||
type BackendIpCandidate,
|
||||
type Catalog,
|
||||
type CatalogEntry,
|
||||
@@ -13,32 +16,39 @@ import {
|
||||
type TestResult,
|
||||
} from "./api.js";
|
||||
|
||||
// First-run setup wizard (scaffold). The admin picks a device per category for a
|
||||
// lane from the driver catalog and fills in its connection config. Drivers that
|
||||
// support LAN discovery (e.g. UHPPOTE) get a "Scan" button that lists found
|
||||
// devices; selecting one auto-fills the config. Auth is via the admin's session
|
||||
// cookie (the SPA only renders this for admins). See wiki/concepts/first-run-setup.md
|
||||
// and device-discovery.md.
|
||||
// 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 }[] = [
|
||||
{ key: "access", title: "Access controller" },
|
||||
{ key: "reader", title: "Reader" },
|
||||
{ key: "camera", title: "Camera (entry/exit snapshot)" },
|
||||
{ key: "printer", title: "Printer" },
|
||||
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 [picked, setPicked] = useState<Partial<Record<DeviceCategory, string>>>({});
|
||||
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 catalog: {error}</p>;
|
||||
if (!catalog) return <p>Loading device catalog…</p>;
|
||||
if (error) return <p style={{ color: "crimson" }}>Failed to load setup: {error}</p>;
|
||||
if (!catalog || !assignments) return <p>Loading device catalog…</p>;
|
||||
|
||||
return (
|
||||
<section>
|
||||
@@ -54,41 +64,154 @@ export function SetupWizard() {
|
||||
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 }) => (
|
||||
<CategoryPicker
|
||||
{CATEGORIES.map(({ key, title, noun }) => (
|
||||
<CategorySection
|
||||
key={key}
|
||||
lane={lane}
|
||||
category={key}
|
||||
title={title}
|
||||
noun={noun}
|
||||
entries={catalog[key]}
|
||||
discoverableIds={catalog.discoverable}
|
||||
selectedId={picked[key]}
|
||||
onSelect={(id) => setPicked((p) => ({ ...p, [key]: id }))}
|
||||
assignments={assignments.filter((a) => a.category === key && a.lane === lane)}
|
||||
onChanged={reloadState}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function CategoryPicker({
|
||||
function CategorySection({
|
||||
lane,
|
||||
category,
|
||||
title,
|
||||
noun,
|
||||
entries,
|
||||
discoverableIds,
|
||||
selectedId,
|
||||
onSelect,
|
||||
assignments,
|
||||
onChanged,
|
||||
}: {
|
||||
lane: number;
|
||||
category: DeviceCategory;
|
||||
title: string;
|
||||
noun: string;
|
||||
entries: CatalogEntry[];
|
||||
discoverableIds: string[];
|
||||
selectedId: string | undefined;
|
||||
onSelect: (id: string) => void;
|
||||
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);
|
||||
const showForm = adding || assignments.length === 0;
|
||||
|
||||
return (
|
||||
<fieldset style={{ marginTop: "1rem" }}>
|
||||
<legend>
|
||||
{title} <span style={{ color: "#888", fontWeight: 400 }}>· lane {lane}</span>
|
||||
</legend>
|
||||
|
||||
{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}
|
||||
onSaved={async () => {
|
||||
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,
|
||||
onSaved,
|
||||
onCancel,
|
||||
}: {
|
||||
lane: number;
|
||||
category: DeviceCategory;
|
||||
entries: CatalogEntry[];
|
||||
discoverableIds: string[];
|
||||
onSaved: () => 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);
|
||||
|
||||
@@ -98,7 +221,6 @@ function CategoryPicker({
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testError, setTestError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [found, setFound] = useState<DiscoveredDevice[] | null>(null);
|
||||
const [scanning, setScanning] = useState(false);
|
||||
@@ -124,8 +246,6 @@ function CategoryPicker({
|
||||
.then(({ candidates }) => {
|
||||
if (!live) return;
|
||||
setBackendIps(candidates);
|
||||
// Pre-fill with the on-subnet auto-pick (the first candidate, since the
|
||||
// server sorts on-subnet first), unless the admin already chose one.
|
||||
setBackendIp((cur) => cur || candidates.find((c) => c.onDeviceSubnet)?.ip || "");
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -137,6 +257,13 @@ function CategoryPicker({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [testedHost]);
|
||||
|
||||
function selectDriver(id: string) {
|
||||
setSelectedId(id);
|
||||
setConfig({});
|
||||
setFound(null);
|
||||
resetStatus();
|
||||
}
|
||||
|
||||
async function scan() {
|
||||
if (!selected) return;
|
||||
setScanning(true);
|
||||
@@ -165,11 +292,10 @@ function CategoryPicker({
|
||||
return out;
|
||||
}
|
||||
|
||||
// Editing config invalidates a prior test/save.
|
||||
// Editing config invalidates a prior test.
|
||||
function resetStatus() {
|
||||
setTested(null);
|
||||
setTestError(null);
|
||||
setSaved(false);
|
||||
setSaveError(null);
|
||||
}
|
||||
|
||||
@@ -199,7 +325,8 @@ function CategoryPicker({
|
||||
config: mergedConfig(),
|
||||
...(backendIp ? { backendIp } : {}),
|
||||
});
|
||||
setSaved(true);
|
||||
// Parent reloads the list; this form is unmounted or reset by it.
|
||||
await onSaved();
|
||||
} catch (e) {
|
||||
setSaveError((e as Error).message);
|
||||
} finally {
|
||||
@@ -208,12 +335,11 @@ function CategoryPicker({
|
||||
}
|
||||
|
||||
return (
|
||||
<fieldset style={{ marginTop: "1rem" }}>
|
||||
<legend>{title}</legend>
|
||||
<div style={{ padding: "0.5rem", background: "#fafafa", borderRadius: 6 }}>
|
||||
{entries.length === 0 ? (
|
||||
<em>No drivers registered.</em>
|
||||
) : (
|
||||
<select value={selectedId ?? ""} onChange={(e) => onSelect(e.target.value)}>
|
||||
<select value={selectedId} onChange={(e) => selectDriver(e.target.value)}>
|
||||
<option value="" disabled>
|
||||
Choose a device…
|
||||
</option>
|
||||
@@ -258,16 +384,33 @@ function CategoryPicker({
|
||||
<label>
|
||||
{f.label}
|
||||
{f.required ? " *" : ""}{" "}
|
||||
<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();
|
||||
}}
|
||||
/>
|
||||
{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>
|
||||
))}
|
||||
@@ -277,9 +420,14 @@ function CategoryPicker({
|
||||
<button type="button" onClick={test} disabled={testing}>
|
||||
{testing ? "Testing…" : "Test connection"}
|
||||
</button>
|
||||
<button type="button" onClick={save} disabled={saving || saved}>
|
||||
{saving ? "Saving…" : saved ? "Saved ✓" : "Save & configure"}
|
||||
<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>}
|
||||
@@ -332,10 +480,9 @@ function CategoryPicker({
|
||||
</div>
|
||||
)}
|
||||
{saveError && <p style={{ color: "crimson", margin: "0.5rem 0 0" }}>Save failed: {saveError}</p>}
|
||||
{saved && <p style={{ color: "#16a34a", margin: "0.5rem 0 0" }}>Saved and configured ✓</p>}
|
||||
</div>
|
||||
)}
|
||||
</fieldset>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user