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:
@@ -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,35 +136,47 @@ 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
|
||||||
if (hasPushConfig(device)) {
|
// doesn't auto-fire its relay — host must decide first), and
|
||||||
const host = String(config.host ?? "");
|
// 2. set up input push (Digest creds + push URLs).
|
||||||
const backendIp = backendIpForDevice(host);
|
try {
|
||||||
if (!backendIp) {
|
if (hasPreconditions(device)) {
|
||||||
return reply.code(400).send({
|
const fixed = await device.fixPreconditions();
|
||||||
error: `cannot determine the backend IP on the device's subnet (${host}). Set BACKEND_HOST_IP.`,
|
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}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const pushUser = "dingtian";
|
|
||||||
// 24 hex chars = 96 bits. The Dingtian `pass` field caps at 31 chars
|
if (hasPushConfig(device)) {
|
||||||
// (longer is silently truncated → auth mismatch), so keep it short.
|
const host = String(config.host ?? "");
|
||||||
const pushPassword = randomBytes(12).toString("hex");
|
const backendIp = backendIpForDevice(host);
|
||||||
try {
|
if (!backendIp) {
|
||||||
|
return reply.code(400).send({
|
||||||
|
error: `cannot determine the backend IP on the device's subnet (${host}). Set BACKEND_HOST_IP.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const pushUser = "dingtian";
|
||||||
|
// 24 hex chars = 96 bits. The Dingtian `pass` field caps at 31 chars
|
||||||
|
// (longer is silently truncated → auth mismatch), so keep it short.
|
||||||
|
const pushPassword = randomBytes(12).toString("hex");
|
||||||
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 },
|
||||||
});
|
});
|
||||||
} catch (err) {
|
fullConfig.pushUser = pushUser;
|
||||||
return reply
|
fullConfig.pushPassword = pushPassword;
|
||||||
.code(502)
|
|
||||||
.send({ error: `device push config failed: ${(err as Error).message}` });
|
|
||||||
}
|
}
|
||||||
fullConfig.pushUser = pushUser;
|
} catch (err) {
|
||||||
fullConfig.pushPassword = pushPassword;
|
return reply
|
||||||
|
.code(502)
|
||||||
|
.send({ error: `device configuration failed: ${(err as Error).message}` });
|
||||||
}
|
}
|
||||||
|
|
||||||
const row = {
|
const row = {
|
||||||
|
|||||||
@@ -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
@@ -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) });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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]].
|
||||||
|
|||||||
Reference in New Issue
Block a user