Device-agnostic driver registry + first-run setup
Make the device-adapter pattern selectable so the admin chooses hardware at install — per lane, from a catalog of supported drivers. Adding a device = registering one more driver; no business-logic change. packages/devices: - interfaces.ts: AccessControlDevice / ReaderDevice / CameraDevice / PrinterDevice (adds CameraDevice for entry/exit snapshot-on-event; access relay stays intent-only per "a barrier is not a door"). - registry.ts: driver catalog with per-driver config fields + factory, config validation, and a catalog payload for the setup UI. - drivers/: stub adapters — access (zkteco, esp32-relay), reader (wiegand, tcp-ip), camera (hikvision, dahua). Real vendor protocols TBD. packages/db: - lane_devices + setup_state tables (migration 0001); re-export query helpers. apps/server: - routes/setup.ts: GET /api/setup/catalog (public schema), and admin-only /assign, /state, /complete with registry validation before persisting. - extract auth.ts (requireJwtSecret, requireRole, JWT type aug). apps/web: - SetupWizard scaffold + api client: pick a driver per category for a lane, render its config fields. wiki: device-registry + first-run-setup concept pages; cross-link from device-adapter-pattern; index + log updated. Verified: full turbo build (5/5); catalog lists all drivers; admin assign persists; missing-config and no-token requests are rejected.
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
fetchCatalog,
|
||||
type Catalog,
|
||||
type CatalogEntry,
|
||||
type DeviceCategory,
|
||||
} 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. Persisting
|
||||
// goes through POST /api/setup/assign (admin-only). The actual auth/token flow
|
||||
// and a multi-lane stepper come later — this proves the device-agnostic
|
||||
// selection end to end. See wiki/concepts/first-run-setup.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" },
|
||||
];
|
||||
|
||||
export function SetupWizard() {
|
||||
const [catalog, setCatalog] = useState<Catalog | null>(null);
|
||||
const [lane, setLane] = useState(1);
|
||||
const [picked, setPicked] = useState<Partial<Record<DeviceCategory, string>>>({});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCatalog().then(setCatalog).catch((e: Error) => setError(e.message));
|
||||
}, []);
|
||||
|
||||
if (error) return <p style={{ color: "crimson" }}>Failed to load catalog: {error}</p>;
|
||||
if (!catalog) return <p>Loading device catalog…</p>;
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h2>First-run setup</h2>
|
||||
<label>
|
||||
Lane{" "}
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={lane}
|
||||
onChange={(e) => setLane(Number(e.target.value))}
|
||||
style={{ width: "4rem" }}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{CATEGORIES.map(({ key, title }) => (
|
||||
<CategoryPicker
|
||||
key={key}
|
||||
title={title}
|
||||
entries={catalog[key]}
|
||||
selectedId={picked[key]}
|
||||
onSelect={(id) => setPicked((p) => ({ ...p, [key]: id }))}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function CategoryPicker({
|
||||
title,
|
||||
entries,
|
||||
selectedId,
|
||||
onSelect,
|
||||
}: {
|
||||
title: string;
|
||||
entries: CatalogEntry[];
|
||||
selectedId: string | undefined;
|
||||
onSelect: (id: string) => void;
|
||||
}) {
|
||||
const selected = entries.find((e) => e.id === selectedId);
|
||||
return (
|
||||
<fieldset style={{ marginTop: "1rem" }}>
|
||||
<legend>{title}</legend>
|
||||
{entries.length === 0 ? (
|
||||
<em>No drivers registered.</em>
|
||||
) : (
|
||||
<select value={selectedId ?? ""} onChange={(e) => onSelect(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>
|
||||
{selected.configFields.map((f) => (
|
||||
<div key={f.key} style={{ margin: "0.25rem 0" }}>
|
||||
<label>
|
||||
{f.label}
|
||||
{f.required ? " *" : ""}{" "}
|
||||
<input
|
||||
type={f.type === "secret" ? "password" : f.type === "number" || f.type === "port" ? "number" : "text"}
|
||||
defaultValue={f.default as string | number | undefined}
|
||||
placeholder={f.help}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user