727c62da90
- site_config gains optional park identity (park_name, operator_name, nius, address, phone, email); additive Drizzle migration 0001. GET/PUT /api/site-config read/write the full config (PUT partial patch, admin only); SiteSettings + SetupWizard expose the fields. - renderTicket() prints an Albanian header sourced from site_config, the all-numeric 13-digit ticket id (12 random + Luhn) as Code128, large digits, and a lost-ticket footer. CP852 codepage so ë/ç render. - Widen the Code128 module width 2->3 and height 80->100 dots so the short-range "Simple" QR/barcode reader decodes reliably (was barely reading at module width 2 on the 80mm head). See wiki/concepts/site-metadata.md and ticket-encoding.md.
830 lines
29 KiB
TypeScript
830 lines
29 KiB
TypeScript
import { useState, useEffect, useCallback } from "react";
|
|
import {
|
|
assignDevice,
|
|
editDevice,
|
|
discoverDevices,
|
|
fetchBackendIps,
|
|
fetchCatalog,
|
|
fetchState,
|
|
testDevice,
|
|
unassignDevice,
|
|
type Assignment,
|
|
type BackendIpCandidate,
|
|
type Catalog,
|
|
type CatalogEntry,
|
|
type DeviceCategory,
|
|
type DeviceConfig,
|
|
type Direction,
|
|
type DiscoveredDevice,
|
|
type RelaySpec,
|
|
type TestResult,
|
|
} from "./api.js";
|
|
|
|
// First-run setup wizard. The pool-of-spaces model: a parking lot is one pool with
|
|
// a flexible set of entry/exit points — NO lane. The admin adds CONTROLLERS (each
|
|
// declares its relays = entry/exit/both + which input terminal the entry button is
|
|
// on), then binds READERS / CAMERAS to a controller relay (the barrier they sit at).
|
|
// Direction is a property of the relay, inherited by bound devices. The data model
|
|
// is multi-instance — one `devices` row per instance. See entry-exit-points.md.
|
|
|
|
const CONTROLLER: { key: DeviceCategory; title: string; noun: string } = {
|
|
key: "access",
|
|
title: "Controllers (barriers + entry button)",
|
|
noun: "controller",
|
|
};
|
|
// Categories that BIND to a controller relay (direction inherited from the relay).
|
|
const BOUND: { key: DeviceCategory; title: string; noun: string }[] = [
|
|
{ key: "reader", title: "Readers (QR / RFID)", noun: "reader" },
|
|
{ key: "camera", title: "Cameras (snapshot + plate)", noun: "camera" },
|
|
{ key: "printer", title: "Printers (tickets / vouchers)", noun: "printer" },
|
|
];
|
|
|
|
const DIRECTION_LABELS: Record<Direction, string> = {
|
|
entry: "Entry",
|
|
exit: "Exit",
|
|
both: "Both (entry + exit)",
|
|
};
|
|
|
|
export function SetupWizard() {
|
|
const [catalog, setCatalog] = useState<Catalog | null>(null);
|
|
const [assignments, setAssignments] = useState<Assignment[] | null>(null);
|
|
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>;
|
|
|
|
// Controllers are needed before binding readers/cameras (they pick a controller relay).
|
|
const controllers = assignments.filter((a) => a.category === "access");
|
|
|
|
return (
|
|
<section>
|
|
<h2>First-run setup</h2>
|
|
<p style={{ color: "#666", fontSize: "0.9em" }}>
|
|
Add your barrier controllers first — set which relay is entry/exit and which
|
|
terminal the entry button is wired to. Then add readers, cameras and printers
|
|
and point each at the barrier it serves.
|
|
</p>
|
|
|
|
<CategorySection
|
|
category={CONTROLLER.key}
|
|
title={CONTROLLER.title}
|
|
noun={CONTROLLER.noun}
|
|
entries={catalog[CONTROLLER.key]}
|
|
discoverableIds={catalog.discoverable}
|
|
pushCapableIds={catalog.pushCapable}
|
|
controllers={controllers}
|
|
assignments={controllers}
|
|
onChanged={reloadState}
|
|
/>
|
|
|
|
{BOUND.map(({ key, title, noun }) => (
|
|
<CategorySection
|
|
key={key}
|
|
category={key}
|
|
title={title}
|
|
noun={noun}
|
|
entries={catalog[key]}
|
|
discoverableIds={catalog.discoverable}
|
|
pushCapableIds={catalog.pushCapable}
|
|
controllers={controllers}
|
|
assignments={assignments.filter((a) => a.category === key)}
|
|
onChanged={reloadState}
|
|
/>
|
|
))}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function CategorySection({
|
|
category,
|
|
title,
|
|
noun,
|
|
entries,
|
|
discoverableIds,
|
|
pushCapableIds,
|
|
controllers,
|
|
assignments,
|
|
onChanged,
|
|
}: {
|
|
category: DeviceCategory;
|
|
title: string;
|
|
noun: string;
|
|
entries: CatalogEntry[];
|
|
discoverableIds: string[];
|
|
pushCapableIds: string[];
|
|
controllers: Assignment[];
|
|
assignments: Assignment[];
|
|
onChanged: () => Promise<void> | void;
|
|
}) {
|
|
const [adding, setAdding] = useState(false);
|
|
const [editingId, setEditingId] = useState<string | null>(null);
|
|
const [warnings, setWarnings] = useState<string[]>([]);
|
|
const editing = editingId ? assignments.find((a) => a.id === editingId) : undefined;
|
|
// Show the add form for an empty category or an explicit "+ Add", but not while
|
|
// editing an existing row (that row renders its own inline form).
|
|
const showForm = !editing && (adding || assignments.length === 0);
|
|
|
|
// Binding categories need a controller to point at first.
|
|
const isBound = category !== "access";
|
|
const blockedNoController = isBound && controllers.length === 0;
|
|
|
|
return (
|
|
<fieldset style={{ marginTop: "1rem" }}>
|
|
<legend>{title}</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) =>
|
|
editingId === a.id ? (
|
|
<li key={a.id} style={{ listStyle: "none", padding: 0 }}>
|
|
<DeviceForm
|
|
category={category}
|
|
entries={entries}
|
|
discoverableIds={discoverableIds}
|
|
pushCapableIds={pushCapableIds}
|
|
controllers={controllers}
|
|
editing={a}
|
|
onSaved={async (w) => {
|
|
setWarnings(w);
|
|
await onChanged();
|
|
setEditingId(null);
|
|
}}
|
|
onCancel={() => setEditingId(null)}
|
|
/>
|
|
</li>
|
|
) : (
|
|
<AssignmentRow
|
|
key={a.id}
|
|
assignment={a}
|
|
controllers={controllers}
|
|
onChanged={onChanged}
|
|
onEdit={() => {
|
|
setAdding(false);
|
|
setEditingId(a.id);
|
|
}}
|
|
/>
|
|
),
|
|
)}
|
|
</ul>
|
|
)}
|
|
|
|
{blockedNoController ? (
|
|
<p style={{ color: "#b45309", margin: 0 }}>Add a controller first — a {noun} points at one of its relays.</p>
|
|
) : editing ? null : showForm ? (
|
|
<DeviceForm
|
|
category={category}
|
|
entries={entries}
|
|
discoverableIds={discoverableIds}
|
|
pushCapableIds={pushCapableIds}
|
|
controllers={controllers}
|
|
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,
|
|
controllers,
|
|
onChanged,
|
|
onEdit,
|
|
}: {
|
|
assignment: Assignment;
|
|
controllers: Assignment[];
|
|
onChanged: () => Promise<void> | void;
|
|
onEdit: () => void;
|
|
}) {
|
|
const [removing, setRemoving] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const cfg = assignment.config as Record<string, unknown>;
|
|
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>
|
|
{host && <span style={{ color: "#666" }}>{host}</span>}
|
|
<DeviceSummary assignment={assignment} controllers={controllers} />
|
|
{!assignment.enabled && <span style={{ color: "#b45309" }}>(disabled)</span>}
|
|
<span style={{ flex: 1 }} />
|
|
{error && <span style={{ color: "crimson" }}>{error}</span>}
|
|
<button type="button" onClick={onEdit} disabled={removing}>
|
|
Edit
|
|
</button>
|
|
<button type="button" onClick={remove} disabled={removing}>
|
|
{removing ? "Removing…" : "Remove"}
|
|
</button>
|
|
</li>
|
|
);
|
|
}
|
|
|
|
/** Inline summary of an assignment's direction/binding for the list. */
|
|
function DeviceSummary({ assignment, controllers }: { assignment: Assignment; controllers: Assignment[] }) {
|
|
const cfg = assignment.config as Record<string, unknown>;
|
|
if (assignment.category === "access") {
|
|
const relays = Array.isArray(cfg.relays) ? (cfg.relays as RelaySpec[]) : [];
|
|
if (relays.length === 0) return <em style={{ color: "#b45309" }}>no relays set</em>;
|
|
return (
|
|
<span style={{ display: "flex", gap: "0.35rem" }}>
|
|
{relays.map((r) => (
|
|
<DirectionBadge key={r.relay} direction={r.direction} label={`R${r.relay}${r.button ? `·btn${r.button}` : ""}`} />
|
|
))}
|
|
</span>
|
|
);
|
|
}
|
|
// Bound device: show controller + relay it points at, with inherited direction.
|
|
const controllerId = typeof cfg.controllerId === "string" ? cfg.controllerId : null;
|
|
const relay = typeof cfg.relay === "number" ? cfg.relay : null;
|
|
if (!controllerId || relay == null) return <em style={{ color: "#b45309" }}>unbound</em>;
|
|
const controller = controllers.find((c) => c.id === controllerId);
|
|
const spec = controller
|
|
? (((controller.config as Record<string, unknown>).relays as RelaySpec[]) ?? []).find((r) => r.relay === relay)
|
|
: undefined;
|
|
return (
|
|
<DirectionBadge
|
|
direction={spec?.direction ?? "both"}
|
|
label={`${controller ? controller.driverId : "?"} · R${relay}`}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function DeviceForm({
|
|
category,
|
|
entries,
|
|
discoverableIds,
|
|
pushCapableIds,
|
|
controllers,
|
|
editing,
|
|
onSaved,
|
|
onCancel,
|
|
}: {
|
|
category: DeviceCategory;
|
|
entries: CatalogEntry[];
|
|
discoverableIds: string[];
|
|
pushCapableIds: string[];
|
|
controllers: Assignment[];
|
|
/** When set, the form edits this assignment in place (driver locked, config
|
|
* pre-filled) instead of adding a new device. */
|
|
editing?: Assignment;
|
|
onSaved: (warnings: string[]) => Promise<void> | void;
|
|
onCancel?: () => void;
|
|
}) {
|
|
// On edit the driver is fixed (you can't change what KIND of device a slot is —
|
|
// that's a remove + re-add); pre-select it and lock the picker.
|
|
const editCfg = editing?.config as Record<string, unknown> | undefined;
|
|
const [selectedId, setSelectedId] = useState<string>(editing?.driverId ?? "");
|
|
const selected = entries.find((e) => e.id === selectedId);
|
|
const canDiscover = !editing && selected != null && discoverableIds.includes(selected.id);
|
|
const pushesToBackend = selected != null && pushCapableIds.includes(selected.id);
|
|
const isController = category === "access";
|
|
|
|
// Pre-fill scalar config fields from the existing assignment when editing.
|
|
// (relays/controllerId/relay are model fields handled by their own state below.)
|
|
const [config, setConfig] = useState<Record<string, string | number>>(() => {
|
|
if (!editCfg) return {};
|
|
const out: Record<string, string | number> = {};
|
|
for (const [k, v] of Object.entries(editCfg)) {
|
|
if (typeof v === "string" || typeof v === "number") out[k] = v;
|
|
}
|
|
return out;
|
|
});
|
|
// Controllers: the relay map (which relay = entry/exit/both, + entry button terminal).
|
|
const [relays, setRelays] = useState<RelaySpec[]>(() =>
|
|
Array.isArray(editCfg?.relays) ? (editCfg!.relays as RelaySpec[]) : [{ relay: 1, direction: "both" }],
|
|
);
|
|
// Bound devices: which controller + relay this device sits at.
|
|
const [controllerId, setControllerId] = useState<string>(
|
|
typeof editCfg?.controllerId === "string" ? editCfg.controllerId : "",
|
|
);
|
|
const [boundRelay, setBoundRelay] = useState<number | "">(
|
|
typeof editCfg?.relay === "number" ? editCfg.relay : "",
|
|
);
|
|
|
|
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);
|
|
|
|
const [backendIps, setBackendIps] = useState<BackendIpCandidate[] | null>(null);
|
|
const [backendIp, setBackendIp] = useState<string>("");
|
|
|
|
const testedHost = tested ? String(mergedScalarConfig().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();
|
|
}
|
|
|
|
/** Scalar config the user entered, merged over driver defaults (for test/push-IP). */
|
|
function mergedScalarConfig(): 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;
|
|
}
|
|
|
|
/** Full config to persist: scalars + the model's direction/binding fields. */
|
|
function mergedConfig(): DeviceConfig {
|
|
const out: DeviceConfig = { ...mergedScalarConfig() };
|
|
if (isController) {
|
|
out.relays = relays.map((r) => ({
|
|
relay: r.relay,
|
|
direction: r.direction,
|
|
...(r.button ? { button: r.button } : {}),
|
|
}));
|
|
} else if (controllerId && boundRelay !== "") {
|
|
out.controllerId = controllerId;
|
|
out.relay = boundRelay;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
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, mergedScalarConfig()));
|
|
} catch (e) {
|
|
setTestError((e as Error).message);
|
|
} finally {
|
|
setTesting(false);
|
|
}
|
|
}
|
|
|
|
async function save() {
|
|
if (!selected) return;
|
|
// Bound devices must point at a controller relay (binding is optional in the
|
|
// model with a fallback, but the wizard guides the admin to bind explicitly).
|
|
if (!isController && (!controllerId || boundRelay === "")) {
|
|
setSaveError("Pick the controller and relay this device sits at.");
|
|
return;
|
|
}
|
|
setSaving(true);
|
|
setSaveError(null);
|
|
try {
|
|
const result = editing
|
|
? await editDevice(editing.id, {
|
|
config: mergedConfig(),
|
|
...(backendIp ? { backendIp } : {}),
|
|
})
|
|
: await assignDevice({
|
|
category,
|
|
driverId: selected.id,
|
|
config: mergedConfig(),
|
|
...(backendIp ? { backendIp } : {}),
|
|
});
|
|
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>
|
|
) : (
|
|
// Driver is locked when editing — changing the kind of device is a
|
|
// remove + re-add, not an in-place edit.
|
|
<select value={selectedId} onChange={(e) => selectDriver(e.target.value)} disabled={!!editing}>
|
|
<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>
|
|
))}
|
|
|
|
{/* CONTROLLER: the relay map — which relay opens which direction + entry button. */}
|
|
{isController && <RelayEditor relays={relays} onChange={setRelays} />}
|
|
|
|
{/* BOUND device: which controller + relay it sits at. */}
|
|
{!isController && (
|
|
<BindingPicker
|
|
controllers={controllers}
|
|
controllerId={controllerId}
|
|
relay={boundRelay}
|
|
onControllerChange={(id) => {
|
|
setControllerId(id);
|
|
setBoundRelay("");
|
|
}}
|
|
onRelayChange={setBoundRelay}
|
|
/>
|
|
)}
|
|
|
|
{/* 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…" : editing ? "Save changes" : "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>
|
|
)}
|
|
|
|
{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>
|
|
);
|
|
}
|
|
|
|
/** Controller relay map editor: each row = a relay + its direction + (optional)
|
|
* the input terminal its entry button is wired to. */
|
|
function RelayEditor({ relays, onChange }: { relays: RelaySpec[]; onChange: (r: RelaySpec[]) => void }) {
|
|
function update(i: number, patch: Partial<RelaySpec>) {
|
|
onChange(relays.map((r, idx) => (idx === i ? { ...r, ...patch } : r)));
|
|
}
|
|
function add() {
|
|
const nextRelay = (relays.reduce((m, r) => Math.max(m, r.relay), 0) || 0) + 1;
|
|
onChange([...relays, { relay: nextRelay, direction: "both" }]);
|
|
}
|
|
function remove(i: number) {
|
|
onChange(relays.filter((_, idx) => idx !== i));
|
|
}
|
|
|
|
return (
|
|
<div style={{ margin: "0.5rem 0", padding: "0.5rem", background: "#f3f4f6", borderRadius: 6 }}>
|
|
<strong style={{ fontSize: "0.9em" }}>Relays on this controller</strong>
|
|
<p style={{ margin: "0.15rem 0 0.5rem", color: "#666", fontSize: "0.8em" }}>
|
|
Each relay opens one barrier. Set its direction; for transient entry, set which input
|
|
terminal the entry button is wired to.
|
|
</p>
|
|
{relays.map((r, i) => (
|
|
<div key={i} style={{ display: "flex", gap: "0.5rem", alignItems: "center", margin: "0.25rem 0" }}>
|
|
<label>
|
|
Relay{" "}
|
|
<input
|
|
type="number"
|
|
min={1}
|
|
value={r.relay}
|
|
style={{ width: "3.5rem" }}
|
|
onChange={(e) => update(i, { relay: Number(e.target.value) })}
|
|
/>
|
|
</label>
|
|
<select value={r.direction} onChange={(e) => update(i, { direction: e.target.value as Direction })}>
|
|
{(["entry", "exit", "both"] as Direction[]).map((d) => (
|
|
<option key={d} value={d}>
|
|
{DIRECTION_LABELS[d]}
|
|
</option>
|
|
))}
|
|
</select>
|
|
{(r.direction === "entry" || r.direction === "both") && (
|
|
<label>
|
|
Entry button on terminal{" "}
|
|
<input
|
|
type="number"
|
|
min={1}
|
|
value={r.button ?? ""}
|
|
placeholder="—"
|
|
style={{ width: "3.5rem" }}
|
|
onChange={(e) => update(i, { button: e.target.value === "" ? undefined : Number(e.target.value) })}
|
|
/>
|
|
</label>
|
|
)}
|
|
{relays.length > 1 && (
|
|
<button type="button" onClick={() => remove(i)}>
|
|
✕
|
|
</button>
|
|
)}
|
|
</div>
|
|
))}
|
|
<button type="button" onClick={add} style={{ marginTop: "0.25rem" }}>
|
|
+ Add relay
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Binding picker for readers/cameras/printers: choose the controller + relay this
|
|
* device sits at. Direction is inherited from the chosen relay (shown). */
|
|
function BindingPicker({
|
|
controllers,
|
|
controllerId,
|
|
relay,
|
|
onControllerChange,
|
|
onRelayChange,
|
|
}: {
|
|
controllers: Assignment[];
|
|
controllerId: string;
|
|
relay: number | "";
|
|
onControllerChange: (id: string) => void;
|
|
onRelayChange: (relay: number) => void;
|
|
}) {
|
|
const controller = controllers.find((c) => c.id === controllerId);
|
|
const relays: RelaySpec[] = controller
|
|
? (((controller.config as Record<string, unknown>).relays as RelaySpec[]) ?? [])
|
|
: [];
|
|
const chosen = relays.find((r) => r.relay === relay);
|
|
|
|
return (
|
|
<div style={{ margin: "0.5rem 0", padding: "0.5rem", background: "#f3f4f6", borderRadius: 6 }}>
|
|
<strong style={{ fontSize: "0.9em" }}>Which barrier does this device serve?</strong>
|
|
<div style={{ display: "flex", gap: "0.5rem", alignItems: "center", marginTop: "0.35rem", flexWrap: "wrap" }}>
|
|
<label>
|
|
Controller{" "}
|
|
<select value={controllerId} onChange={(e) => onControllerChange(e.target.value)}>
|
|
<option value="" disabled>
|
|
Choose…
|
|
</option>
|
|
{controllers.map((c) => {
|
|
const host = (c.config as Record<string, unknown>).host;
|
|
return (
|
|
<option key={c.id} value={c.id}>
|
|
{c.driverId}
|
|
{typeof host === "string" ? ` (${host})` : ""}
|
|
</option>
|
|
);
|
|
})}
|
|
</select>
|
|
</label>
|
|
<label>
|
|
Relay{" "}
|
|
<select
|
|
value={relay === "" ? "" : String(relay)}
|
|
disabled={!controller}
|
|
onChange={(e) => onRelayChange(Number(e.target.value))}
|
|
>
|
|
<option value="" disabled>
|
|
Choose…
|
|
</option>
|
|
{relays.map((r) => (
|
|
<option key={r.relay} value={r.relay}>
|
|
Relay {r.relay} ({DIRECTION_LABELS[r.direction]})
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
{chosen && <DirectionBadge direction={chosen.direction} label={`inherits ${chosen.direction}`} />}
|
|
</div>
|
|
{controller && relays.length === 0 && (
|
|
<p style={{ margin: "0.35rem 0 0", color: "#b45309", fontSize: "0.85em" }}>
|
|
This controller has no relays configured.
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function DirectionBadge({ direction, label }: { direction: Direction; label?: string }) {
|
|
const color = direction === "entry" ? "#15803d" : direction === "exit" ? "#b45309" : "#6b7280";
|
|
return (
|
|
<span
|
|
style={{
|
|
color,
|
|
border: `1px solid ${color}`,
|
|
borderRadius: 4,
|
|
padding: "0 0.35rem",
|
|
fontSize: "0.75em",
|
|
fontWeight: 600,
|
|
}}
|
|
>
|
|
{label ?? direction}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function HealthBadge({ status }: { status: string }) {
|
|
const color = status === "ready" ? "#16a34a" : status === "degraded" ? "#d97706" : "#dc2626";
|
|
return <span style={{ color, fontWeight: 600 }}>● {status}</span>;
|
|
}
|