diff --git a/apps/server/src/routes/setup.ts b/apps/server/src/routes/setup.ts index d3af12a..e26ecdf 100644 --- a/apps/server/src/routes/setup.ts +++ b/apps/server/src/routes/setup.ts @@ -2,6 +2,7 @@ import { randomBytes, randomUUID } from "node:crypto"; import type { FastifyInstance } from "fastify"; import { eq, laneDevices, setupState, type Db } from "@parking/db"; import { + hasPreconditions, hasPushConfig, isDiscoverable, registerBuiltinDrivers, @@ -22,6 +23,11 @@ interface AssignBody { config: Record; } +interface TestBody { + driverId: string; + config: Record; +} + export async function setupRoutes(app: FastifyInstance, db: Db): Promise { registerBuiltinDrivers(); setDeviceLogSink((line) => app.log.info(line)); @@ -80,12 +86,36 @@ export async function setupRoutes(app: FastifyInstance, db: Db): Promise { }, ); - // Assign a device to a lane. Validates the chosen driver + config against the - // registry before persisting; rejects unknown drivers / missing config. - // For push-capable devices (e.g. Dingtian), the backend generates a secret - // token, configures the device to HTTP-push input events to us (no manual URL - // entry by the admin), and stores the token so the push endpoint can verify - // it. See wiki/concepts/device-input-flow.md. + // Test a device config WITHOUT saving or changing the device: validate the + // config, probe reachability (healthCheck), and report preconditions + // (e.g. input_link_relay state). Lets the admin verify before committing. + app.post<{ Body: TestBody }>( + "/api/setup/test", + { 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 }>( "/api/setup/assign", { preHandler: adminGuard }, @@ -106,35 +136,47 @@ export async function setupRoutes(app: FastifyInstance, db: Db): Promise { 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 to push to us, store the creds. Done before - // persisting so we don't store half-configured rows. - if (hasPushConfig(device)) { - const host = String(config.host ?? ""); - const backendIp = backendIpForDevice(host); - if (!backendIp) { - return reply.code(400).send({ - error: `cannot determine the backend IP on the device's subnet (${host}). Set BACKEND_HOST_IP.`, - }); + // Configure the device on save (before persisting, so we don't store a row + // for a device we couldn't configure): + // 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}`, + }); + } } - 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"); - try { + + if (hasPushConfig(device)) { + const host = String(config.host ?? ""); + const backendIp = backendIpForDevice(host); + 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({ host: backendIp, port: backendPort(), pathBase: `/api/devices/${driverId}/${id}/input`, auth: { user: pushUser, password: pushPassword }, }); - } catch (err) { - return reply - .code(502) - .send({ error: `device push config failed: ${(err as Error).message}` }); + fullConfig.pushUser = pushUser; + fullConfig.pushPassword = pushPassword; } - fullConfig.pushUser = pushUser; - fullConfig.pushPassword = pushPassword; + } catch (err) { + return reply + .code(502) + .send({ error: `device configuration failed: ${(err as Error).message}` }); } const row = { diff --git a/apps/web/src/SetupWizard.tsx b/apps/web/src/SetupWizard.tsx index d098b04..5dba15b 100644 --- a/apps/web/src/SetupWizard.tsx +++ b/apps/web/src/SetupWizard.tsx @@ -1,11 +1,14 @@ -import { useEffect, useState } from "react"; +import { useState, useEffect } from "react"; import { + assignDevice, discoverDevices, fetchCatalog, + testDevice, type Catalog, type CatalogEntry, type DeviceCategory, type DiscoveredDevice, + type TestResult, } from "./api.js"; // 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 }) => ( >({}); + const [tested, setTested] = useState(null); + const [testing, setTesting] = useState(false); + const [testError, setTestError] = useState(null); + const [saving, setSaving] = useState(false); + const [saved, setSaved] = useState(false); + const [saveError, setSaveError] = useState(null); const [found, setFound] = useState(null); const [scanning, setScanning] = useState(false); const [scanError, setScanError] = useState(null); @@ -102,6 +117,53 @@ function CategoryPicker({ function applyDiscovered(d: DiscoveredDevice) { setConfig((c) => ({ ...c, ...(d.config as Record) })); + resetStatus(); + } + + // Config the user actually entered, merged over driver defaults. + function mergedConfig(): Record { + const out: Record = {}; + 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 ( @@ -159,11 +221,47 @@ function CategoryPicker({ 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) => setConfig((c) => ({ ...c, [f.key]: e.target.value }))} + onChange={(e) => { + const v = e.target.value; + setConfig((c) => ({ ...c, [f.key]: v })); + resetStatus(); + }} /> ))} + + {/* Test (no save/no device change) then Save (configures + persists). */} +
+ + +
+ + {testError &&

Test failed: {testError}

} + {tested && ( +
+
+ Device: + {tested.health.detail && — {tested.health.detail}} +
+ {tested.preconditions.ok ? ( +
● preconditions OK
+ ) : ( + tested.preconditions.issues.map((i) => ( +
+ ⚠ {i.message} + {i.fixable && (auto-fixed on save)} +
+ )) + )} +
+ )} + {saveError &&

Save failed: {saveError}

} + {saved &&

Saved and configured ✓

} )} diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index afce44f..10f31f5 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -118,13 +118,32 @@ export async function discoverDevices(driverId: string): Promise; + +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 { + return apiFetch("/api/setup/test", { + method: "POST", + body: JSON.stringify({ driverId, config }), + }); +} + export interface AssignBody { lane: number; category: DeviceCategory; driverId: string; - config: Record; + config: DeviceConfig; } -export function assignDevice(body: AssignBody): Promise { +/** 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) }); } diff --git a/wiki/concepts/first-run-setup.md b/wiki/concepts/first-run-setup.md index a013179..97050ac 100644 --- a/wiki/concepts/first-run-setup.md +++ b/wiki/concepts/first-run-setup.md @@ -18,11 +18,16 @@ each device's connection config. 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 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 - [[local-jwt-auth]]). The server validates the chosen driver + config against the registry - before persisting to the `lane_devices` table; unknown drivers / missing required fields are - rejected. -3. **Complete** — `POST /api/setup/complete` marks the single-row `setup_state`. +2. **Test** (optional, no save) — `POST /api/setup/test` (admin-only). Validates the config, + probes reachability (`healthCheck`), and reports preconditions (e.g. `input_link_relay`) — + **without** saving or changing the device. The wizard's **Test connection** button shows a + health badge + any precondition warnings. +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 diff --git a/wiki/log.md b/wiki/log.md index 9362ca8..355ce20 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -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 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]]. + +## [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]].