Setup wizard: Test connection + Save & configure

Two-step device setup so the admin verifies before committing — and never touches
the device's own web UI.

- POST /api/setup/test (admin-only): healthCheck + checkPreconditions, no save and
  no device change. Returns device health + precondition issues.
- assign (Save) now also runs fixPreconditions (e.g. disables input_link_relay so
  a button press doesn't auto-fire its relay) before configuring the input push.
  Closes a gap where an assigned device could still auto-open. Fails the save with
  no DB row if device configuration fails (no orphan/half-configured rows).
- SetupWizard: wires config fields -> Test connection (health badge + precondition
  warnings) -> Save & configure; editing config resets prior test/save status.

Verified in-browser against the real device: Test -> ● ready + preconditions OK;
Save -> row persisted AND the device's Input Link URL written (push path matches
the saved device id). wiki/first-run-setup updated.
This commit is contained in:
2026-06-14 16:59:36 +02:00
parent 3294f188dd
commit 0375227a16
5 changed files with 212 additions and 36 deletions
+56 -14
View File
@@ -2,6 +2,7 @@ import { randomBytes, randomUUID } from "node:crypto";
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import { eq, laneDevices, setupState, type Db } from "@parking/db"; import { eq, laneDevices, setupState, type Db } from "@parking/db";
import { import {
hasPreconditions,
hasPushConfig, hasPushConfig,
isDiscoverable, isDiscoverable,
registerBuiltinDrivers, registerBuiltinDrivers,
@@ -22,6 +23,11 @@ interface AssignBody {
config: Record<string, string | number | boolean>; config: Record<string, string | number | boolean>;
} }
interface TestBody {
driverId: string;
config: Record<string, string | number | boolean>;
}
export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> { export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> {
registerBuiltinDrivers(); registerBuiltinDrivers();
setDeviceLogSink((line) => app.log.info(line)); setDeviceLogSink((line) => app.log.info(line));
@@ -80,12 +86,36 @@ export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> {
}, },
); );
// Assign a device to a lane. Validates the chosen driver + config against the // Test a device config WITHOUT saving or changing the device: validate the
// registry before persisting; rejects unknown drivers / missing config. // config, probe reachability (healthCheck), and report preconditions
// For push-capable devices (e.g. Dingtian), the backend generates a secret // (e.g. input_link_relay state). Lets the admin verify before committing.
// token, configures the device to HTTP-push input events to us (no manual URL app.post<{ Body: TestBody }>(
// entry by the admin), and stores the token so the push endpoint can verify "/api/setup/test",
// it. See wiki/concepts/device-input-flow.md. { preHandler: adminGuard },
async (req, reply) => {
const { driverId, config } = req.body;
const driver = registry.get(driverId);
if (!driver) return reply.code(400).send({ error: `unknown driver: ${driverId}` });
let device;
try {
device = registry.create(driverId, config);
} catch (err) {
return reply.code(400).send({ error: (err as Error).message });
}
const health = await device.healthCheck();
const preconditions = hasPreconditions(device)
? await device.checkPreconditions()
: { ok: true, issues: [] };
return { health, preconditions };
},
);
// Assign a device to a lane. Validates the chosen driver + config, configures
// the device (fix preconditions + set up Digest-authenticated input push — no
// manual device-web-UI step by the admin), then persists. Fails the save if
// the device can't be configured. See wiki/concepts/device-input-flow.md.
app.post<{ Body: AssignBody }>( app.post<{ Body: AssignBody }>(
"/api/setup/assign", "/api/setup/assign",
{ preHandler: adminGuard }, { preHandler: adminGuard },
@@ -106,9 +136,22 @@ export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> {
return reply.code(400).send({ error: (err as Error).message }); return reply.code(400).send({ error: (err as Error).message });
} }
// If the device supports input push, set it up now: generate Digest creds, // Configure the device on save (before persisting, so we don't store a row
// configure the device to push to us, store the creds. Done before // for a device we couldn't configure):
// persisting so we don't store half-configured rows. // 1. fix preconditions (e.g. disable input_link_relay so a button press
// doesn't auto-fire its relay — host must decide first), and
// 2. set up input push (Digest creds + push URLs).
try {
if (hasPreconditions(device)) {
const fixed = await device.fixPreconditions();
if (!fixed.ok) {
const unfixable = fixed.issues.find((i) => !i.fixable);
return reply.code(502).send({
error: `device precondition not satisfied: ${unfixable?.message ?? fixed.issues[0]?.message}`,
});
}
}
if (hasPushConfig(device)) { if (hasPushConfig(device)) {
const host = String(config.host ?? ""); const host = String(config.host ?? "");
const backendIp = backendIpForDevice(host); const backendIp = backendIpForDevice(host);
@@ -121,20 +164,19 @@ export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> {
// 24 hex chars = 96 bits. The Dingtian `pass` field caps at 31 chars // 24 hex chars = 96 bits. The Dingtian `pass` field caps at 31 chars
// (longer is silently truncated → auth mismatch), so keep it short. // (longer is silently truncated → auth mismatch), so keep it short.
const pushPassword = randomBytes(12).toString("hex"); const pushPassword = randomBytes(12).toString("hex");
try {
await device.configureInputPush({ await device.configureInputPush({
host: backendIp, host: backendIp,
port: backendPort(), port: backendPort(),
pathBase: `/api/devices/${driverId}/${id}/input`, pathBase: `/api/devices/${driverId}/${id}/input`,
auth: { user: pushUser, password: pushPassword }, auth: { user: pushUser, password: pushPassword },
}); });
fullConfig.pushUser = pushUser;
fullConfig.pushPassword = pushPassword;
}
} catch (err) { } catch (err) {
return reply return reply
.code(502) .code(502)
.send({ error: `device push config failed: ${(err as Error).message}` }); .send({ error: `device configuration failed: ${(err as Error).message}` });
}
fullConfig.pushUser = pushUser;
fullConfig.pushPassword = pushPassword;
} }
const row = { const row = {
+100 -2
View File
@@ -1,11 +1,14 @@
import { useEffect, useState } from "react"; import { useState, useEffect } from "react";
import { import {
assignDevice,
discoverDevices, discoverDevices,
fetchCatalog, fetchCatalog,
testDevice,
type Catalog, type Catalog,
type CatalogEntry, type CatalogEntry,
type DeviceCategory, type DeviceCategory,
type DiscoveredDevice, type DiscoveredDevice,
type TestResult,
} from "./api.js"; } from "./api.js";
// First-run setup wizard (scaffold). The admin picks a device per category for a // First-run setup wizard (scaffold). The admin picks a device per category for a
@@ -54,6 +57,8 @@ export function SetupWizard() {
{CATEGORIES.map(({ key, title }) => ( {CATEGORIES.map(({ key, title }) => (
<CategoryPicker <CategoryPicker
key={key} key={key}
lane={lane}
category={key}
title={title} title={title}
entries={catalog[key]} entries={catalog[key]}
discoverableIds={catalog.discoverable} discoverableIds={catalog.discoverable}
@@ -66,12 +71,16 @@ export function SetupWizard() {
} }
function CategoryPicker({ function CategoryPicker({
lane,
category,
title, title,
entries, entries,
discoverableIds, discoverableIds,
selectedId, selectedId,
onSelect, onSelect,
}: { }: {
lane: number;
category: DeviceCategory;
title: string; title: string;
entries: CatalogEntry[]; entries: CatalogEntry[];
discoverableIds: string[]; discoverableIds: string[];
@@ -83,6 +92,12 @@ function CategoryPicker({
// Config values (auto-filled by discovery, editable by hand). // Config values (auto-filled by discovery, editable by hand).
const [config, setConfig] = useState<Record<string, string | number>>({}); 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 [saved, setSaved] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [found, setFound] = useState<DiscoveredDevice[] | null>(null); const [found, setFound] = useState<DiscoveredDevice[] | null>(null);
const [scanning, setScanning] = useState(false); const [scanning, setScanning] = useState(false);
const [scanError, setScanError] = useState<string | null>(null); const [scanError, setScanError] = useState<string | null>(null);
@@ -102,6 +117,53 @@ function CategoryPicker({
function applyDiscovered(d: DiscoveredDevice) { function applyDiscovered(d: DiscoveredDevice) {
setConfig((c) => ({ ...c, ...(d.config as Record<string, string | number>) })); 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/save.
function resetStatus() {
setTested(null);
setTestError(null);
setSaved(false);
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 {
await assignDevice({ lane, category, driverId: selected.id, config: mergedConfig() });
setSaved(true);
} catch (e) {
setSaveError((e as Error).message);
} finally {
setSaving(false);
}
} }
return ( return (
@@ -159,11 +221,47 @@ function CategoryPicker({
type={f.type === "secret" ? "password" : f.type === "number" || f.type === "port" ? "number" : "text"} type={f.type === "secret" ? "password" : f.type === "number" || f.type === "port" ? "number" : "text"}
value={config[f.key] ?? (f.default as string | number | undefined) ?? ""} value={config[f.key] ?? (f.default as string | number | undefined) ?? ""}
placeholder={f.help} placeholder={f.help}
onChange={(e) => setConfig((c) => ({ ...c, [f.key]: e.target.value }))} onChange={(e) => {
const v = e.target.value;
setConfig((c) => ({ ...c, [f.key]: v }));
resetStatus();
}}
/> />
</label> </label>
</div> </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 || saved}>
{saving ? "Saving…" : saved ? "Saved ✓" : "Save & configure"}
</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>
)}
{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> </div>
)} )}
</fieldset> </fieldset>
+21 -2
View File
@@ -118,13 +118,32 @@ export async function discoverDevices(driverId: string): Promise<DiscoveredDevic
return body.devices; return body.devices;
} }
export type DeviceConfig = Record<string, string | number | boolean>;
export interface TestResult {
health: { status: string; detail?: string };
preconditions: {
ok: boolean;
issues: { key: string; message: string; fixable: boolean }[];
};
}
/** Test a device config (reachability + preconditions) without saving. */
export function testDevice(driverId: string, config: DeviceConfig): Promise<TestResult> {
return apiFetch<TestResult>("/api/setup/test", {
method: "POST",
body: JSON.stringify({ driverId, config }),
});
}
export interface AssignBody { export interface AssignBody {
lane: number; lane: number;
category: DeviceCategory; category: DeviceCategory;
driverId: string; driverId: string;
config: Record<string, string | number | boolean>; config: DeviceConfig;
} }
export function assignDevice(body: AssignBody): Promise<unknown> { /** Save + configure the device (preconditions, push setup), then persist. */
export function assignDevice(body: AssignBody): Promise<{ id: string }> {
return apiFetch("/api/setup/assign", { method: "POST", body: JSON.stringify(body) }); return apiFetch("/api/setup/assign", { method: "POST", body: JSON.stringify(body) });
} }
+10 -5
View File
@@ -18,11 +18,16 @@ each device's connection config.
1. **Read the catalog** — `GET /api/setup/catalog` returns supported drivers per category (no 1. **Read the catalog** — `GET /api/setup/catalog` returns supported drivers per category (no
secrets, just schema) plus a `discoverable` list. The web `SetupWizard` renders a picker + the secrets, just schema) plus a `discoverable` list. The web `SetupWizard` renders a picker + the
driver's config fields, and a **Scan** button for discoverable drivers ([[device-discovery]]). driver's config fields, and a **Scan** button for discoverable drivers ([[device-discovery]]).
2. **Assign per lane** — `POST /api/setup/assign` (admin-only, role-guarded; see 2. **Test** (optional, no save) — `POST /api/setup/test` (admin-only). Validates the config,
[[local-jwt-auth]]). The server validates the chosen driver + config against the registry probes reachability (`healthCheck`), and reports preconditions (e.g. `input_link_relay`) —
before persisting to the `lane_devices` table; unknown drivers / missing required fields are **without** saving or changing the device. The wizard's **Test connection** button shows a
rejected. health badge + any precondition warnings.
3. **Complete** — `POST /api/setup/complete` marks the single-row `setup_state`. 3. **Save & configure** — `POST /api/setup/assign` (admin-only). Validates, then **configures the
device**: fixes preconditions (e.g. disables `input_link_relay`) and sets up the Digest-
authenticated input push ([[device-input-flow]]) — the admin never touches the device's own web
UI. **Fails the save** (no DB row) if the device can't be configured, so there are no
orphan/half-configured rows. On success persists to `lane_devices`.
4. **Complete** — `POST /api/setup/complete` marks the single-row `setup_state`.
## Config granularity ## Config granularity
+12
View File
@@ -162,3 +162,15 @@ as "writes don't apply" all session; (2) the `pass` field caps at 31 chars →
use a 24-char password. Driver #writeConfig now polls-until-verified (device use a 24-char password. Driver #writeConfig now polls-until-verified (device
reboots on apply). VERIFIED on hardware: assign auto-configures the device, then reboots on apply). VERIFIED on hardware: assign auto-configures the device, then
all 4 inputs push with Digest auth, zero failures. Recorded in [[device-input-flow]]. all 4 inputs push with Digest auth, zero failures. Recorded in [[device-input-flow]].
## [2026-06-15] feature | Setup wizard: Test connection + Save & configure
Two-step device setup UX. New admin-only POST /api/setup/test (healthCheck +
checkPreconditions, no save / no device change). The assign (Save) step now also
fixes preconditions (disables input_link_relay) before configuring push — closing
a gap where assigned devices could still auto-fire relays; fails the save with no
DB row if device config fails (no orphan rows). SetupWizard wires the config
fields → Test button (health badge + precondition warnings) → Save & configure
button. Verified in-browser against the real device: Test shows ● ready +
preconditions OK; Save persists the row AND writes the device's Input Link URL
(push path matches the saved device id). Admin never logs into the device web UI.
Updated [[first-run-setup]].