Files
bellsystems-cp/frontend/src/manufacturing/ProvisioningWizard.jsx

1514 lines
59 KiB
JavaScript

import { useState, useRef, useCallback, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { ESPLoader, Transport } from "esptool-js";
import api from "../api/client";
// ─── constants ───────────────────────────────────────────────────────────────
// Row layout: Vesper Pro / Vesper Plus / Vesper | Agnus / Agnus Mini | Chronos Pro / Chronos
const BOARD_TYPES = [
{ value: "vx", name: "VESPER PRO", codename: "vesper-pro", desc: "Full-featured pro controller", family: "vesper" },
{ value: "vp", name: "VESPER PLUS", codename: "vesper-plus", desc: "Extended output controller", family: "vesper" },
{ value: "vs", name: "VESPER", codename: "vesper-basic", desc: "Standard bell controller", family: "vesper" },
{ value: "ab", name: "AGNUS", codename: "agnus-basic", desc: "Standard carillon module", family: "agnus" },
{ value: "am", name: "AGNUS MINI", codename: "agnus-mini", desc: "Compact carillon module", family: "agnus" },
{ value: "cp", name: "CHRONOS PRO", codename: "chronos-pro", desc: "Pro clock controller", family: "chronos"},
{ value: "cb", name: "CHRONOS", codename: "chronos-basic", desc: "Basic clock controller", family: "chronos"},
];
// Color palette per board family (idle → selected → hover glow)
const BOARD_FAMILY_COLORS = {
vesper: {
selectedBg: "#0a1929",
selectedBorder:"#3b82f6",
selectedText: "#60a5fa",
hoverBorder: "#3b82f6",
glowColor: "rgba(59,130,246,0.35)",
idleBorder: "#1d3a5c",
idleText: "#7ca8d4",
},
agnus: {
selectedBg: "#1a1400",
selectedBorder:"#f59e0b",
selectedText: "#fbbf24",
hoverBorder: "#f59e0b",
glowColor: "rgba(245,158,11,0.35)",
idleBorder: "#4a3800",
idleText: "#c79d3a",
},
chronos: {
selectedBg: "#1a0808",
selectedBorder:"#ef4444",
selectedText: "#f87171",
hoverBorder: "#ef4444",
glowColor: "rgba(239,68,68,0.35)",
idleBorder: "#5c1a1a",
idleText: "#d47a7a",
},
};
const BOARD_TYPE_LABELS = Object.fromEntries(BOARD_TYPES.map((b) => [b.value, b.name]));
// Display board version stored as semver string ("1.0", "2.1") as "Rev 1.0"
function formatHwVersion(v) {
if (!v) return "—";
// Already in semver form ("1.0") or legacy 2-digit ("01" → "Rev 1.0")
if (/^\d+\.\d+/.test(v)) return `Rev ${v}`;
// Legacy: "01" → "1.0", "02" → "2.0"
const n = parseInt(v, 10);
if (!isNaN(n)) return `Rev ${n}.0`;
return `Rev ${v}`;
}
const STATUS_STYLES = {
manufactured: { bg: "var(--bg-card-hover)", color: "var(--text-muted)" },
flashed: { bg: "var(--badge-blue-bg)", color: "var(--badge-blue-text)" },
provisioned: { bg: "#0a2e2a", color: "#4dd6c8" },
sold: { bg: "#1e1036", color: "#c084fc" },
claimed: { bg: "#2e1a00", color: "#fb923c" },
decommissioned: { bg: "var(--danger-bg)", color: "var(--danger-text)" },
};
// Statuses that can be re-flashed via the wizard
const FLASHABLE_STATUSES = ["manufactured", "flashed", "provisioned"];
const FLASH_BAUD = 460800;
const NVS_ADDRESS = 0x9000;
const FW_ADDRESS = 0x10000;
const VERIFY_POLL_MS = 5000;
const VERIFY_TIMEOUT_MS = 120_000;
// ─── small helpers ────────────────────────────────────────────────────────────
function StatusBadge({ status }) {
const style = STATUS_STYLES[status] || STATUS_STYLES.manufactured;
return (
<span
className="px-2.5 py-1 text-sm rounded-full capitalize font-medium"
style={{ backgroundColor: style.bg, color: style.color }}
>
{status}
</span>
);
}
function ProgressBar({ label, percent }) {
return (
<div className="space-y-1.5">
<div className="flex justify-between text-xs" style={{ color: "var(--text-secondary)" }}>
<span>{label}</span>
<span>{Math.round(percent)}%</span>
</div>
<div className="h-2 rounded-full overflow-hidden" style={{ backgroundColor: "var(--bg-card-hover)" }}>
<div
className="h-full rounded-full transition-all duration-200"
style={{ width: `${percent}%`, backgroundColor: "var(--accent)" }}
/>
</div>
</div>
);
}
function ErrorBox({ msg }) {
if (!msg) return null;
return (
<div
className="text-sm rounded-md p-3 border"
style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}
>
{msg}
</div>
);
}
function inputStyle() {
return { backgroundColor: "var(--bg-input)", borderColor: "var(--border-input)", color: "var(--text-primary)" };
}
// ─── Step Indicator ───────────────────────────────────────────────────────────
// Steps: 0=Mode, 1=Select, 2=Flash, 3=Verify, 4=Done
// current is 1-based to match step state (step 0 = mode picker, shown differently)
const STEP_LABELS = ["Begin", "Device", "Flash", "Verify", "Done"];
function CheckeredFlagIcon() {
return (
<svg className="w-3 h-3" viewBox="0 0 24 24" fill="currentColor">
<path d="M4 2v20h2V13h14V3H4zm2 2h3v3H6V4zm0 5h3v3H6V9zm5-5h3v3h-3V4zm0 5h3v3h-3V9zm5-5h3v3h-3V4zm0 5h3v3h-3V9z" />
</svg>
);
}
function StepIndicator({ current }) {
// current: 1..4 (step numbers matching STEP_LABELS index + 1)
return (
<div className="flex items-center" style={{ minWidth: 0 }}>
{STEP_LABELS.map((label, i) => {
const idx = i + 1;
const done = idx < current;
const active = idx === current;
const pending = idx > current;
const isLast = i === STEP_LABELS.length - 1;
// Color tokens
const dotBg = done ? "#53b15786"
: active ? "#22c55e"
: "#251a1a";
const dotColor = done ? "#cbedb9"
: active ? "#ffffff"
: "#555";
const labelColor = active ? "#22c55e" : done ? "#53b15786" : "#555";
const labelGlow = active ? "0 0 8px rgba(34,197,94,0.45)" : "none";
const lineColor = done ? "#22c55e" : "var(--border-primary)";
// Active dot is 38px, others are 30px
const dotSize = active ? 38 : 30;
return (
<div key={label} className="flex items-center" style={{ minWidth: 0 }}>
{/* Node */}
<div className="flex flex-col items-center" style={{ flexShrink: 0 }}>
<div
style={{
width: dotSize, height: dotSize,
borderRadius: "60%",
display: "flex", alignItems: "center", justifyContent: "center",
backgroundColor: dotBg,
color: dotColor,
fontSize: active ? "1.0rem" : "0.85rem",
fontWeight: 500,
border: active ? "2px solid #22c55e" : done ? "2px solid #6d9b78c0" : "2px solid #333",
boxShadow: active ? "0 0 18px 8px rgba(34,197,94,0.4)" : "none",
transition: "all 0.2s",
}}
>
{done ? (
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
</svg>
) : isLast ? (
<CheckeredFlagIcon />
) : idx}
</div>
<span
className="text-xs mt-1 whitespace-nowrap font-medium"
style={{
color: labelColor,
textShadow: labelGlow,
opacity: pending ? 0.5 : 1,
maxWidth: 72,
textAlign: "center",
lineHeight: 1.2,
}}
>
{label}
</span>
</div>
{/* Connector line */}
{i < STEP_LABELS.length - 1 && (
<div
style={{
height: 2,
flex: "1 1 16px",
minWidth: 28,
maxWidth: 40,
backgroundColor: lineColor,
marginBottom: 18,
marginLeft: 4,
marginRight: 4,
transition: "background-color 0.2s",
flexShrink: 1,
}}
/>
)}
</div>
);
})}
</div>
);
}
// ─── Step 0 — Mode picker ─────────────────────────────────────────────────────
function StepModePicker({ onPick }) {
return (
<div>
<h2 className="text-base font-semibold mb-2" style={{ color: "var(--text-heading)" }}>
What would you like to do?
</h2>
<p className="text-sm mb-6" style={{ color: "var(--text-muted)" }}>
Choose how to start the provisioning process.
</p>
<div className="grid grid-cols-2 gap-4">
{/* Flash Existing */}
<button
onClick={() => onPick("existing")}
className="rounded-xl border p-6 text-left cursor-pointer"
style={{
backgroundColor: "var(--bg-card)",
borderColor: "var(--border-primary)",
transition: "border-color 0.15s, box-shadow 0.15s",
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = "var(--accent)";
e.currentTarget.style.boxShadow = "0 0 14px 4px rgba(34,197,94,0.25)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = "var(--border-primary)";
e.currentTarget.style.boxShadow = "none";
}}
>
<div
className="w-10 h-10 rounded-lg flex items-center justify-center mb-4"
style={{ backgroundColor: "var(--bg-card-hover)" }}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" style={{ color: "var(--accent)" }}>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<p className="font-semibold text-sm mb-1" style={{ color: "var(--text-heading)" }}>Flash Existing</p>
<p className="text-xs leading-relaxed" style={{ color: "var(--text-muted)" }}>
Re-flash a device already in inventory manufactured, flashed, or provisioned.
</p>
</button>
{/* Deploy New — same style as Flash Existing */}
<button
onClick={() => onPick("new")}
className="rounded-xl border p-6 text-left cursor-pointer"
style={{
backgroundColor: "var(--bg-card)",
borderColor: "var(--border-primary)",
transition: "border-color 0.15s, box-shadow 0.15s",
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = "var(--accent)";
e.currentTarget.style.boxShadow = "0 0 14px 4px rgba(34,197,94,0.25)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = "var(--border-primary)";
e.currentTarget.style.boxShadow = "none";
}}
>
<div
className="w-10 h-10 rounded-lg flex items-center justify-center mb-4"
style={{ backgroundColor: "var(--bg-card-hover)" }}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" style={{ color: "var(--accent)" }}>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
</div>
<p className="font-semibold text-sm mb-1" style={{ color: "var(--text-heading)" }}>Deploy New</p>
<p className="text-xs leading-relaxed" style={{ color: "var(--text-muted)" }}>
Generate a new serial number, select board type and revision, then flash and provision.
</p>
</button>
</div>
</div>
);
}
// ─── Step 1a — Flash Existing: pick from inventory ────────────────────────────
function StepSelectExisting({ onSelected }) {
const [search, setSearch] = useState("");
const [results, setResults] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [selected, setSelected] = useState(null);
const doSearch = async (q) => {
setError("");
setLoading(true);
try {
const params = new URLSearchParams({ limit: "50" });
if (q) params.set("search", q);
// Only show flashable statuses
const all = await Promise.all(
FLASHABLE_STATUSES.map((s) =>
api.get(`/manufacturing/devices?${params}&status=${s}`).then((d) => d.devices)
)
);
const merged = all.flat().sort((a, b) =>
(b.created_at || "").localeCompare(a.created_at || "")
);
setResults(merged);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
useEffect(() => { doSearch(""); }, []);
const handleSearchSubmit = (e) => {
e.preventDefault();
doSearch(search);
};
if (selected) {
return (
<div
className="rounded-lg border p-5"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<h3 className="text-sm font-semibold uppercase tracking-wide mb-4" style={{ color: "var(--text-muted)" }}>
Device Selected
</h3>
<div className="grid grid-cols-2 gap-4 mb-4">
<InfoCell label="Serial Number" value={selected.serial_number} mono />
<InfoCell label="Board Type" value={BOARD_TYPE_LABELS[selected.hw_type] || selected.hw_type} />
<InfoCell label="HW Version" value={formatHwVersion(selected.hw_version)} />
<div>
<p className="text-xs uppercase tracking-wide mb-0.5" style={{ color: "var(--text-muted)" }}>Status</p>
<StatusBadge status={selected.mfg_status} />
</div>
</div>
<div className="flex gap-3">
<button
onClick={() => onSelected(selected)}
className="px-5 py-2 text-sm rounded-md font-medium hover:opacity-90 transition-opacity cursor-pointer"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
>
Continue
</button>
<button
onClick={() => setSelected(null)}
className="px-4 py-2 text-sm rounded-md hover:opacity-80 transition-opacity cursor-pointer"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)" }}
>
Change Device
</button>
</div>
</div>
);
}
return (
<div className="space-y-4">
<div
className="rounded-lg border p-5"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<h3 className="text-sm font-semibold mb-3" style={{ color: "var(--text-heading)" }}>
Select a Device to Flash
</h3>
<form onSubmit={handleSearchSubmit} className="flex gap-2 mb-4">
<input
type="text"
placeholder="Search serial, batch, type…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="flex-1 px-3 py-2 rounded-md text-sm border"
style={inputStyle()}
/>
<button
type="submit"
className="px-4 py-2 text-sm rounded-md font-medium cursor-pointer hover:opacity-90"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
>
{loading ? "…" : "Search"}
</button>
</form>
<ErrorBox msg={error} />
{results.length === 0 && !loading && (
<p className="text-sm text-center py-4" style={{ color: "var(--text-muted)" }}>
No flashable devices found.
</p>
)}
{results.length > 0 && (
<div className="rounded-md border overflow-hidden" style={{ borderColor: "var(--border-primary)" }}>
<table className="w-full text-sm">
<thead>
<tr style={{ backgroundColor: "var(--bg-secondary)", borderBottom: "1px solid var(--border-primary)" }}>
<th className="px-3 py-2 text-left text-xs font-medium" style={{ color: "var(--text-muted)" }}>Serial</th>
<th className="px-3 py-2 text-left text-xs font-medium" style={{ color: "var(--text-muted)" }}>Type</th>
<th className="px-3 py-2 text-left text-xs font-medium" style={{ color: "var(--text-muted)" }}>Ver</th>
<th className="px-3 py-2 text-left text-xs font-medium" style={{ color: "var(--text-muted)" }}>Status</th>
</tr>
</thead>
<tbody>
{results.map((d) => (
<tr
key={d.id}
onClick={() => setSelected(d)}
className="cursor-pointer transition-colors"
style={{ borderBottom: "1px solid var(--border-secondary)" }}
onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = "var(--bg-card-hover)")}
onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = "")}
>
<td className="px-3 py-2 font-mono text-xs" style={{ color: "var(--text-primary)" }}>{d.serial_number}</td>
<td className="px-3 py-2 text-xs" style={{ color: "var(--text-secondary)" }}>
{BOARD_TYPE_LABELS[d.hw_type] || d.hw_type}
</td>
<td className="px-3 py-2 text-xs" style={{ color: "var(--text-muted)" }}>
{formatHwVersion(d.hw_version)}
</td>
<td className="px-3 py-2">
<StatusBadge status={d.mfg_status} />
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}
// ─── Board Type Tile (shared between StepDeployNew and AddDeviceModal) ────────
function BoardTypeTile({ bt, isSelected, pal, onClick }) {
const [hovered, setHovered] = useState(false);
const borderColor = isSelected
? pal.selectedBorder
: hovered
? pal.hoverBorder
: pal.idleBorder;
const boxShadow = isSelected
? `0 0 0 1px ${pal.selectedBorder}, 0 0 14px 4px ${pal.glowColor}`
: hovered
? `0 0 12px 3px ${pal.glowColor}`
: "none";
return (
<button
onClick={onClick}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
className="rounded-lg border p-3 text-left transition-all cursor-pointer"
style={{
backgroundColor: isSelected ? pal.selectedBg : "var(--bg-card)",
borderColor,
boxShadow,
transition: "border-color 0.15s, box-shadow 0.15s, background-color 0.15s",
}}
>
<p
className="font-bold text-xs tracking-wide"
style={{ color: isSelected ? pal.selectedText : hovered ? pal.idleText : "var(--text-heading)" }}
>
{bt.name}
</p>
<p className="text-xs mt-0.5 font-mono opacity-60" style={{ color: "var(--text-muted)" }}>
{bt.codename}
</p>
<p className="text-xs mt-1 leading-snug" style={{ color: "var(--text-muted)", opacity: 0.75 }}>
{bt.desc}
</p>
</button>
);
}
// ─── Step 1b — Deploy New: pick board type + revision ─────────────────────────
function StepDeployNew({ onSelected, onCreatedSn }) {
const [boardType, setBoardType] = useState(null);
const [boardVersion, setBoardVersion] = useState("1.0");
const [creating, setCreating] = useState(false);
const [error, setError] = useState("");
const handleCreate = async () => {
if (!boardType || !boardVersion.trim()) return;
setError("");
setCreating(true);
try {
const batch = await api.post("/manufacturing/batch", {
board_type: boardType,
board_version: boardVersion.trim(),
quantity: 1,
});
const sn = batch.serial_numbers[0];
onCreatedSn(sn); // register for abort-cleanup
const device = await api.get(`/manufacturing/devices/${sn}`);
onSelected(device);
} catch (err) {
setError(err.message);
} finally {
setCreating(false);
}
};
// Group boards by family for row layout
const vesperBoards = BOARD_TYPES.filter((b) => b.family === "vesper");
const agnusBoards = BOARD_TYPES.filter((b) => b.family === "agnus");
const chronosBoards = BOARD_TYPES.filter((b) => b.family === "chronos");
const renderTileRow = (boards, cols) => (
<div style={{ display: "grid", gridTemplateColumns: `repeat(${cols}, 1fr)`, gap: 10, justifyItems: "stretch" }}>
{boards.map((bt) => (
<BoardTypeTile
key={bt.value}
bt={bt}
isSelected={boardType === bt.value}
pal={BOARD_FAMILY_COLORS[bt.family]}
onClick={() => setBoardType(bt.value)}
/>
))}
</div>
);
return (
<div className="space-y-5">
{/* Board type tiles — Vesper: 3 col, Agnus: 2 col centered, Chronos: 2 col centered */}
<div>
<p className="text-sm font-medium mb-3" style={{ color: "var(--text-secondary)" }}>Board Type</p>
<div className="space-y-2">
{renderTileRow(vesperBoards, 3)}
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 10 }}>
<div style={{ gridColumn: "1 / 3", display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10 }}>
{agnusBoards.map((bt) => (
<BoardTypeTile
key={bt.value}
bt={bt}
isSelected={boardType === bt.value}
pal={BOARD_FAMILY_COLORS[bt.family]}
onClick={() => setBoardType(bt.value)}
/>
))}
</div>
</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 10 }}>
<div style={{ gridColumn: "1 / 3", display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10 }}>
{chronosBoards.map((bt) => (
<BoardTypeTile
key={bt.value}
bt={bt}
isSelected={boardType === bt.value}
pal={BOARD_FAMILY_COLORS[bt.family]}
onClick={() => setBoardType(bt.value)}
/>
))}
</div>
</div>
</div>
</div>
{/* Board revision (left) + Generate button (right) */}
<div className="flex items-end justify-between gap-4">
<div>
<label className="block text-sm font-medium mb-1.5" style={{ color: "var(--text-secondary)" }}>
Board Revision
</label>
<div className="flex items-center gap-2">
<span className="text-sm font-medium" style={{ color: "var(--text-muted)" }}>Rev</span>
<input
type="text"
value={boardVersion}
onChange={(e) => setBoardVersion(e.target.value)}
placeholder="1.0"
className="px-3 py-2 rounded-md text-sm border w-32"
style={inputStyle()}
/>
</div>
<p className="text-xs mt-1" style={{ color: "var(--text-muted)" }}>
Semantic versioning: 1.0, 1.1, 2.0
</p>
</div>
<div className="flex flex-col items-end gap-2">
<ErrorBox msg={error} />
<button
onClick={handleCreate}
disabled={!boardType || creating}
className="px-5 py-2 text-sm rounded-md font-medium hover:opacity-90 transition-opacity cursor-pointer disabled:opacity-40"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
>
{creating ? "Creating…" : "Generate Serial & Continue →"}
</button>
</div>
</div>
</div>
);
}
// ─── Shared info cell ─────────────────────────────────────────────────────────
function InfoCell({ label, value, mono = false }) {
return (
<div>
<p className="text-xs uppercase tracking-wide mb-0.5" style={{ color: "var(--text-muted)" }}>{label}</p>
<p className={`text-sm ${mono ? "font-mono" : ""}`} style={{ color: "var(--text-primary)" }}>{value || "—"}</p>
</div>
);
}
// ─── Step 2 — Flash ────────────────────────────────────────────────────────────
function StepFlash({ device, onFlashed }) {
const [portConnected, setPortConnected] = useState(false);
const [portName, setPortName] = useState("");
const [connecting, setConnecting] = useState(false);
const [flashing, setFlashing] = useState(false);
const [done, setDone] = useState(false);
const [nvsProgress, setNvsProgress] = useState(0);
const [fwProgress, setFwProgress] = useState(0);
const [log, setLog] = useState([]);
const [serial, setSerial] = useState([]);
const [serialAutoScroll, setSerialAutoScroll] = useState(true);
const [error, setError] = useState("");
const loaderRef = useRef(null);
const portRef = useRef(null);
const serialReaderRef = useRef(null);
const serialActiveRef = useRef(false);
const serialAutoScrollRef = useRef(true); // mirrors state — readable inside async loops
const logEndRef = useRef(null);
const serialEndRef = useRef(null);
// Keep the ref in sync with state so async loops always see the current value
const handleSetSerialAutoScroll = (val) => {
serialAutoScrollRef.current = val;
setSerialAutoScroll(val);
};
const appendLog = (msg) => setLog((prev) => [...prev, String(msg)]);
const appendSerial = (msg) => setSerial((prev) => [...prev, String(msg)]);
const scrollLog = () => logEndRef.current?.scrollIntoView({ behavior: "smooth" });
const scrollSerial = () => { if (serialAutoScrollRef.current) serialEndRef.current?.scrollIntoView({ behavior: "smooth" }); };
// When auto-scroll is re-enabled, jump to bottom immediately
useEffect(() => {
if (serialAutoScroll) serialEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [serialAutoScroll]);
const fetchBinary = async (url) => {
const token = localStorage.getItem("access_token");
const resp = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || `Failed to fetch ${url}: ${resp.status}`);
}
return resp.arrayBuffer();
};
const arrayBufferToString = (buf) => {
const bytes = new Uint8Array(buf);
let str = "";
for (let i = 0; i < bytes.length; i++) str += String.fromCharCode(bytes[i]);
return str;
};
const startSerialMonitor = async (port) => {
serialActiveRef.current = true;
await new Promise((r) => setTimeout(r, 1000));
try {
await port.open({ baudRate: 115200 });
} catch (openErr) {
appendSerial(`[Error opening port: ${openErr.message}]`);
scrollSerial();
return;
}
let reader;
try {
reader = port.readable.getReader();
} catch (readerErr) {
appendSerial(`[Error getting reader: ${readerErr.message}]`);
scrollSerial();
try { await port.close(); } catch (_) {}
return;
}
serialReaderRef.current = reader;
const textDecoder = new TextDecoder();
let lineBuffer = "";
try {
while (serialActiveRef.current) {
const { value, done: streamDone } = await reader.read();
if (streamDone) break;
lineBuffer += textDecoder.decode(value, { stream: true });
const lines = lineBuffer.split(/\r?\n/);
lineBuffer = lines.pop();
for (const line of lines) {
if (line.trim()) {
appendSerial(line);
scrollSerial();
}
}
}
} catch (_) {
// Reader cancelled on cleanup — expected
} finally {
try { reader.releaseLock(); } catch (_) {}
}
};
const disconnectPort = async () => {
serialActiveRef.current = false;
try { await serialReaderRef.current?.cancel(); } catch (_) {}
try { serialReaderRef.current?.releaseLock(); } catch (_) {}
try { await portRef.current?.close(); } catch (_) {}
portRef.current = null;
setPortConnected(false);
setPortName("");
appendSerial("[Port disconnected]");
};
const handleConnectPort = async () => {
setError("");
setConnecting(true);
try {
const port = await navigator.serial.requestPort();
portRef.current = port;
// Try to get a display name from port info
const info = port.getInfo?.() || {};
const label = info.usbVendorId
? `USB ${info.usbVendorId.toString(16).toUpperCase()}:${(info.usbProductId || 0).toString(16).toUpperCase()}`
: "Serial Port";
setPortName(label);
setPortConnected(true);
} catch (err) {
setError(err.message || "Port selection cancelled.");
} finally {
setConnecting(false);
}
};
const handleStartFlash = async () => {
if (!portRef.current) return;
setError("");
setLog([]);
setSerial([]);
setNvsProgress(0);
setFwProgress(0);
setDone(false);
const port = portRef.current;
try {
// 1. Fetch binaries
appendLog("Fetching NVS binary…");
const nvsBuffer = await fetchBinary(`/api/manufacturing/devices/${device.serial_number}/nvs.bin`);
appendLog(`NVS binary: ${nvsBuffer.byteLength} bytes`);
appendLog("Fetching firmware binary…");
const fwBuffer = await fetchBinary(`/api/manufacturing/devices/${device.serial_number}/firmware.bin`);
appendLog(`Firmware binary: ${fwBuffer.byteLength} bytes`);
// 2. Connect ESPLoader
setFlashing(true);
appendLog("Connecting to ESP32…");
const transport = new Transport(port, true);
loaderRef.current = new ESPLoader({
transport,
baudrate: FLASH_BAUD,
terminal: {
clean() {},
writeLine: (line) => { appendLog(line); scrollLog(); },
write: (msg) => { appendLog(msg); scrollLog(); },
},
});
await loaderRef.current.main();
appendLog("ESP32 connected.");
// 3. Flash NVS + firmware
const nvsData = arrayBufferToString(nvsBuffer);
const fwData = arrayBufferToString(fwBuffer);
await loaderRef.current.writeFlash({
fileArray: [
{ data: nvsData, address: NVS_ADDRESS },
{ data: fwData, address: FW_ADDRESS },
],
flashSize: "keep", flashMode: "keep", flashFreq: "keep",
eraseAll: false, compress: true,
reportProgress(fileIndex, written, total) {
if (fileIndex === 0) setNvsProgress((written / total) * 100);
else { setNvsProgress(100); setFwProgress((written / total) * 100); }
},
calculateMD5Hash: () => "",
});
setNvsProgress(100);
setFwProgress(100);
appendLog("Flash complete. Resetting device…");
// 4. Hard-reset via RTS pulse
try {
const t = loaderRef.current.transport;
await t.setRTS(true);
await new Promise((r) => setTimeout(r, 100));
await t.setRTS(false);
await new Promise((r) => setTimeout(r, 100));
} catch (rstErr) {
appendLog(`[Reset warning: ${rstErr.message}]`);
}
appendLog("Hard reset sent. Device is booting…");
// 5. Disconnect esptool transport
try { await loaderRef.current.transport.disconnect(); } catch (_) {}
appendLog("esptool disconnected. Opening serial monitor…");
// 6. Update status → flashed
await api.request(`/manufacturing/devices/${device.serial_number}/status`, {
method: "PATCH",
body: JSON.stringify({ status: "flashed", note: "Flashed via browser provisioning wizard" }),
});
setFlashing(false);
setDone(true);
// 7. Re-open for serial monitor
appendSerial("── Serial monitor started (115200 baud) ──");
startSerialMonitor(port);
} catch (err) {
setError(err.message || String(err));
setFlashing(false);
try { await loaderRef.current?.transport?.disconnect(); } catch (_) {}
}
};
const webSerialAvailable = "serial" in navigator;
const busy = connecting || flashing;
// ── Row 1: Info+buttons (left) | Flash Output (right) ──────────────────────
const InfoPanel = (
<div
className="rounded-lg border p-5 flex flex-col"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
{/* Header: title + COM status */}
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>
Device to Flash
</h3>
<button
onClick={portConnected ? disconnectPort : undefined}
title={portConnected ? `${portName} — Click to disconnect` : "No port connected"}
className={`flex items-center gap-1.5 px-2.5 py-1 rounded-md text-xs font-medium transition-all ${portConnected ? "cursor-pointer hover:opacity-80" : "cursor-default"}`}
style={{
backgroundColor: portConnected ? "#0a2e1a" : "var(--bg-card-hover)",
color: portConnected ? "#4dd6c8" : "var(--text-muted)",
border: `1px solid ${portConnected ? "#4dd6c8" : "var(--border-primary)"}`,
}}
>
<span className="inline-block w-2 h-2 rounded-full" style={{ backgroundColor: portConnected ? "#22c55e" : "#444" }} />
{portConnected ? portName || "Connected" : "No Port"}
{portConnected && <span className="ml-1 opacity-60"></span>}
</button>
</div>
<div className="grid grid-cols-2 gap-3 mb-4">
<InfoCell label="Serial Number" value={device.serial_number} mono />
<InfoCell label="Board" value={`${BOARD_TYPE_LABELS[device.hw_type] || device.hw_type} ${formatHwVersion(device.hw_version)}`} />
</div>
{!webSerialAvailable && (
<div className="text-sm rounded-md p-3 mb-3 border"
style={{ backgroundColor: "#2e1a00", borderColor: "#fb923c", color: "#fb923c" }}>
Web Serial API not available. Use Chrome or Edge on a desktop system.
</div>
)}
<ErrorBox msg={error} />
{error && <div className="h-2" />}
{(flashing || nvsProgress > 0) && (
<div className="space-y-3 mb-4">
<ProgressBar label="NVS Partition (0x9000)" percent={nvsProgress} />
<ProgressBar label="Firmware (0x10000)" percent={fwProgress} />
</div>
)}
{/* Spacer — pushes bottom bar to the actual bottom of the card */}
<div style={{ flex: 1 }} />
{/* Bottom bar: info (left) | buttons (right) */}
<div
className="flex items-end justify-between gap-3 pt-3 mt-2"
style={{ borderTop: "1px solid var(--border-secondary)" }}
>
{/* Left: status hint + tech info */}
<div>
{portConnected && !flashing && !done && log.length === 0 && (
<div className="flex items-center gap-2 text-sm mb-1.5" style={{ color: "#4dd6c8" }}>
<span className="inline-block w-2 h-2 rounded-full" style={{ backgroundColor: "#22c55e" }} />
Ready to flash.
</div>
)}
{flashing && (
<p className="text-sm mb-1.5" style={{ color: "var(--text-muted)" }}>Flashing do not disconnect</p>
)}
<p className="text-xs" style={{ color: "var(--text-muted)", opacity: 0.6 }}>
NVS 0x9000 · FW 0x10000 · {FLASH_BAUD} baud
</p>
</div>
{/* Right: action buttons */}
{!busy && (
<div className="flex items-center gap-2 flex-wrap">
{!portConnected && (
<button
onClick={handleConnectPort}
disabled={!webSerialAvailable}
className="flex items-center gap-2 px-4 py-2 text-sm rounded-md font-medium hover:opacity-90 transition-opacity cursor-pointer disabled:opacity-50"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)", border: "1px solid var(--border-primary)" }}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
</svg>
Select COM Port
</button>
)}
{portConnected && done && (
<button
onClick={handleStartFlash}
className="flex items-center gap-2 px-4 py-2 text-sm rounded-md hover:opacity-80 transition-opacity cursor-pointer"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)" }}
>
Flash Again
</button>
)}
{done && (
<button
onClick={onFlashed}
className="flex items-center gap-2 px-4 py-2 text-sm rounded-md font-medium hover:opacity-90 transition-opacity cursor-pointer"
style={{ backgroundColor: "var(--accent)", color: "var(--bg-primary)" }}
>
Proceed to Verify
</button>
)}
{portConnected && !done && (
<button
onClick={handleStartFlash}
className="flex items-center gap-2 px-5 py-2 text-sm rounded-md font-medium hover:opacity-90 transition-opacity cursor-pointer"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
Start Flashing
</button>
)}
</div>
)}
</div>
</div>
);
const FlashOutputPanel = (
<div className="rounded-lg border overflow-hidden flex flex-col" style={{ borderColor: "var(--border-primary)", height: 320 }}>
<div
className="px-3 py-2 text-xs font-semibold uppercase tracking-wide border-b"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)", color: "var(--text-muted)" }}
>
Flash Output
</div>
<div
className="p-3 font-mono text-xs overflow-y-auto space-y-0.5"
style={{ backgroundColor: "var(--bg-primary)", color: "var(--text-secondary)", flex: 1, minHeight: 0 }}
>
{log.length === 0 ? (
<span style={{ color: "var(--text-muted)", opacity: 0.5 }}>
{flashing ? "Connecting…" : "Output will appear here once flashing starts."}
</span>
) : (
log.map((line, i) => <div key={i}>{line}</div>)
)}
<div ref={logEndRef} />
</div>
</div>
);
return (
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
{/* Row 1: Info | Flash Output */}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12, alignItems: "stretch" }}>
{InfoPanel}
{FlashOutputPanel}
</div>
{/* Row 2: Serial Output — full width, resizable by drag.
↓ EDIT THIS VALUE to adjust the serial monitor height ↓ */}
<div
className="rounded-lg border"
style={{
borderColor: "var(--border-primary)",
display: "flex",
flexDirection: "column",
minHeight: 180,
height: 320, /* ← change this number to adjust height */
resize: "vertical",
overflow: "auto",
}}
>
<div
className="px-3 py-2 text-xs font-semibold uppercase tracking-wide border-b flex items-center justify-between"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)", color: "var(--text-muted)", flexShrink: 0 }}
>
<div className="flex items-center gap-2">
<span>Serial Output</span>
{serialActiveRef.current && (
<span className="flex items-center gap-1.5" style={{ color: "#4dd6c8" }}>
<span className="inline-block w-1.5 h-1.5 rounded-full" style={{ backgroundColor: "#4dd6c8" }} />
Live
</span>
)}
</div>
<label className="flex items-center gap-1.5 cursor-pointer select-none">
<span className="text-xs" style={{ color: "var(--text-muted)" }}>Auto-scroll</span>
<span
onClick={() => handleSetSerialAutoScroll(!serialAutoScrollRef.current)}
className="relative inline-flex items-center"
style={{
width: 32, height: 18,
backgroundColor: serialAutoScroll ? "var(--accent)" : "var(--bg-card-hover)",
borderRadius: 9,
border: "1px solid var(--border-primary)",
cursor: "pointer",
transition: "background-color 0.2s",
flexShrink: 0,
}}
>
<span
style={{
position: "absolute",
left: serialAutoScroll ? 14 : 2,
width: 14, height: 14,
borderRadius: "50%",
backgroundColor: "#fff",
transition: "left 0.15s",
}}
/>
</span>
</label>
</div>
<div
className="p-3 font-mono text-xs space-y-0.5"
style={{ backgroundColor: "var(--bg-primary)", color: "#a3e635", flex: 1, overflowY: "auto", minHeight: 0 }}
>
{serial.length === 0 ? (
<span style={{ color: "var(--text-muted)" }}>
{done ? "Waiting for device boot…" : "Available after flash completes."}
</span>
) : (
serial.map((line, i) => <div key={i}>{line}</div>)
)}
<div ref={serialEndRef} />
</div>
</div>
</div>
);
}
// ─── Step 3 — Verify ──────────────────────────────────────────────────────────
function StepVerify({ device, onVerified }) {
const [polling, setPolling] = useState(false);
const [timedOut, setTimedOut] = useState(false);
const [verified, setVerified] = useState(false);
const [heartbeatData, setHeartbeatData] = useState(null);
const [error, setError] = useState("");
const intervalRef = useRef(null);
const timeoutRef = useRef(null);
// Auto-start polling on mount
const startPolling = useCallback(() => {
if (polling) return;
setPolling(true);
setTimedOut(false);
setError("");
const startTime = Date.now();
intervalRef.current = setInterval(async () => {
try {
const hbData = await api.get(
`/mqtt/heartbeats/${device.serial_number}?limit=1&offset=0`
);
if (hbData.heartbeats?.length > 0) {
const latest = hbData.heartbeats[0];
const receivedMs = latest.received_at
? Date.parse(latest.received_at.replace(" ", "T") + "Z")
: NaN;
if (!isNaN(receivedMs) && receivedMs > startTime) {
clearInterval(intervalRef.current);
clearTimeout(timeoutRef.current);
try {
await api.request(`/manufacturing/devices/${device.serial_number}/status`, {
method: "PATCH",
body: JSON.stringify({ status: "provisioned", note: "Auto-verified via wizard" }),
});
} catch (_) {}
const deviceData = await api.get(`/manufacturing/devices/${device.serial_number}`);
setHeartbeatData(latest);
setPolling(false);
setVerified(true);
onVerified({ ...deviceData, mfg_status: "provisioned" });
return;
}
}
} catch (err) {
setError(err.message);
}
}, VERIFY_POLL_MS);
timeoutRef.current = setTimeout(() => {
clearInterval(intervalRef.current);
setPolling(false);
setTimedOut(true);
}, VERIFY_TIMEOUT_MS);
}, [polling, device.serial_number, onVerified]);
// Auto-start on mount
useEffect(() => {
startPolling();
return () => {
clearInterval(intervalRef.current);
clearTimeout(timeoutRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const stopPolling = () => {
clearInterval(intervalRef.current);
clearTimeout(timeoutRef.current);
setPolling(false);
};
return (
<div className="space-y-4">
{/* Fixed-height card so layout doesn't jump when data arrives */}
<div
className="rounded-lg border p-5"
style={{
backgroundColor: "var(--bg-card)",
borderColor: "var(--border-primary)",
minHeight: 280,
}}
>
<h3 className="text-sm font-semibold uppercase tracking-wide mb-4" style={{ color: "var(--text-muted)" }}>
Waiting for Device
</h3>
{/* Loading state */}
{polling && !verified && (
<div className="flex flex-col items-center py-6 gap-4">
<svg className="w-12 h-12 animate-spin" style={{ color: "var(--accent)" }} fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
<p className="text-sm text-center" style={{ color: "var(--text-secondary)" }}>
Waiting for device to connect
<br />
<span className="text-xs" style={{ color: "var(--text-muted)" }}>
Power cycle the device and ensure it can reach the MQTT broker.
</span>
</p>
<button
onClick={stopPolling}
className="px-4 py-1.5 text-xs rounded-md hover:opacity-80 transition-opacity cursor-pointer"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)" }}
>
Stop
</button>
</div>
)}
{/* Verified state */}
{verified && heartbeatData && (
<div className="space-y-4">
<div className="flex items-center gap-3 mb-2">
<div className="w-8 h-8 rounded-full flex items-center justify-center" style={{ backgroundColor: "#0a2e2a" }}>
<svg className="w-4 h-4" style={{ color: "#4dd6c8" }} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
</svg>
</div>
<p className="text-sm font-semibold" style={{ color: "#4dd6c8" }}>Device is live!</p>
</div>
<div className="grid grid-cols-2 gap-3 rounded-md p-3" style={{ backgroundColor: "var(--bg-primary)" }}>
<InfoCell label="Firmware" value={heartbeatData.firmware_version || "—"} />
<InfoCell label="IP Address" value={heartbeatData.ip_address || "—"} />
<InfoCell label="Uptime" value={heartbeatData.uptime_display || "—"} />
<InfoCell label="Gateway" value={heartbeatData.gateway || "—"} />
</div>
</div>
)}
{/* Timed out state */}
{timedOut && !verified && (
<div className="py-4 space-y-4">
<div
className="text-sm rounded-md p-3 border text-center"
style={{ backgroundColor: "#2e1a00", borderColor: "#fb923c", color: "#fb923c" }}
>
Timed out after {VERIFY_TIMEOUT_MS / 1000}s. Check WiFi credentials and MQTT broker connectivity.
</div>
<div className="flex gap-3">
<button
onClick={startPolling}
className="px-5 py-2 text-sm rounded-md font-medium hover:opacity-90 transition-opacity cursor-pointer"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
>
Retry Verification
</button>
</div>
</div>
)}
{error && !timedOut && !verified && (
<div className="mt-3">
<ErrorBox msg={`Poll error (will retry): ${error}`} />
</div>
)}
</div>
<p className="text-xs" style={{ color: "var(--text-muted)" }}>
Polling every {VERIFY_POLL_MS / 1000}s · timeout {VERIFY_TIMEOUT_MS / 1000}s
</p>
</div>
);
}
// ─── Step 4 — Done ────────────────────────────────────────────────────────────
function StepDone({ device, onProvisionNext }) {
const navigate = useNavigate();
return (
<div className="space-y-4">
<div
className="rounded-lg border p-6"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<div className="flex flex-col items-center py-4 gap-4">
<div className="w-16 h-16 rounded-full flex items-center justify-center" style={{ backgroundColor: "#0a2e2a" }}>
<svg className="w-8 h-8" style={{ color: "#4dd6c8" }} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
</svg>
</div>
<div className="text-center">
<h3 className="text-lg font-bold" style={{ color: "var(--text-heading)" }}>Device Provisioned</h3>
<p className="text-sm mt-1" style={{ color: "var(--text-muted)" }}>
{device.serial_number} is live.
</p>
</div>
</div>
<div className="rounded-md mb-5" style={{ backgroundColor: "var(--bg-primary)" }}>
{/* Row 1: Serial Number (left) | Status (right) */}
<div className="grid grid-cols-2" style={{ borderBottom: "1px solid var(--border-secondary)" }}>
<div className="p-4" style={{ borderRight: "1px solid var(--border-secondary)" }}>
<p className="text-xs uppercase tracking-wide mb-1" style={{ color: "var(--text-muted)" }}>Serial Number</p>
<p className="text-sm font-mono" style={{ color: "var(--text-primary)" }}>{device.serial_number}</p>
</div>
<div className="p-4 flex flex-col items-end">
<p className="text-xs uppercase tracking-wide mb-1" style={{ color: "var(--text-muted)" }}>Status</p>
<StatusBadge status={device.mfg_status} />
</div>
</div>
{/* Row 2: Board Type (left) | HW Version (right) */}
<div className="grid grid-cols-2">
<div className="p-4" style={{ borderRight: "1px solid var(--border-secondary)" }}>
<p className="text-xs uppercase tracking-wide mb-1" style={{ color: "var(--text-muted)" }}>Board Type</p>
<p className="text-sm" style={{ color: "var(--text-primary)" }}>{BOARD_TYPE_LABELS[device.hw_type] || device.hw_type}</p>
</div>
<div className="p-4 flex flex-col items-end">
<p className="text-xs uppercase tracking-wide mb-1" style={{ color: "var(--text-muted)" }}>HW Version</p>
<p className="text-sm" style={{ color: "var(--text-primary)" }}>{formatHwVersion(device.hw_version)}</p>
</div>
</div>
</div>
<div className="flex flex-wrap gap-3">
<button
onClick={onProvisionNext}
className="px-5 py-2 text-sm rounded-md font-medium hover:opacity-90 transition-opacity cursor-pointer"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
>
Provision Next Device
</button>
<button
onClick={() => navigate(`/manufacturing/devices/${device.serial_number}`)}
className="px-4 py-2 text-sm rounded-md hover:opacity-80 transition-opacity cursor-pointer"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)" }}
>
View in Inventory
</button>
</div>
</div>
</div>
);
}
// ─── Main Wizard ──────────────────────────────────────────────────────────────
// Steps:
// 0 = Mode picker ("Flash Existing" vs "Deploy New")
// 1 = Select device (varies by mode)
// 2 = Flash
// 3 = Verify
// 4 = Done
export default function ProvisioningWizard() {
const [step, setStep] = useState(0);
const [mode, setMode] = useState(null); // "existing" | "new"
const [device, setDevice] = useState(null);
const [createdSn, setCreatedSn] = useState(null); // SN created in Deploy New, for abort cleanup
const handleModePicked = (m) => {
setMode(m);
setStep(1);
};
const handleDeviceSelected = (dev) => {
setDevice(dev);
setStep(2);
};
const handleFlashed = () => setStep(3);
const handleVerified = (updatedDevice) => {
setDevice(updatedDevice);
setStep(4);
};
const handleBack = async () => {
if (step === 1) {
// Going back to mode picker — if Deploy New created a serial, clean it up
if (mode === "new" && createdSn) {
await cleanupCreatedSn(createdSn);
setCreatedSn(null);
}
setStep(0);
setMode(null);
setDevice(null);
} else if (step === 2) {
setStep(1);
setDevice(null);
} else if (step === 3) {
setStep(2);
}
};
const handleAbort = async () => {
if (mode === "new" && createdSn) {
await cleanupCreatedSn(createdSn);
setCreatedSn(null);
}
setStep(0);
setMode(null);
setDevice(null);
};
const handleProvisionNext = () => {
setDevice(null);
setCreatedSn(null);
setStep(0);
setMode(null);
};
// Determine Back/Abort visibility
const showBack = step >= 1 && step <= 3;
const showAbort = step >= 1 && step <= 3;
// Flash step takes full available width; all others are centered at 720px
const isFlashStep = step === 2;
return (
<div style={{ display: "flex", flexDirection: "column", minHeight: "100%" }}>
{/* ── Sticky top bar ── */}
<div
style={{
position: "sticky",
top: 0,
zIndex: 50,
backgroundColor: "var(--bg-primary)",
borderBottom: "1px solid var(--border-primary)",
padding: "12px 24px",
}}
>
{/* Title + controls row */}
<div className="flex items-center justify-between gap-4">
{/* Left: Title */}
<h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)", minWidth: 0, flexShrink: 0 }}>
Provisioning Wizard
</h1>
{/* Center: StepIndicator — always visible, current=step+1 to account for Begin as step 1 */}
<div style={{ flex: 1, display: "flex", justifyContent: "center", paddingBottom: 8 }}>
<StepIndicator current={step + 1} />
</div>
{/* Right: Back + Abort grouped together */}
<div className="flex items-center gap-2" style={{ flexShrink: 0 }}>
{showBack && (
<button
onClick={handleBack}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md transition-opacity hover:opacity-80 cursor-pointer"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)" }}
>
Back
</button>
)}
{showAbort && (
<button
onClick={handleAbort}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md transition-opacity hover:opacity-80 cursor-pointer"
style={{ backgroundColor: "var(--danger-bg)", color: "var(--danger-text)", border: "1px solid var(--danger)" }}
>
Abort
</button>
)}
</div>
</div>
</div>
{/* ── Step content ── */}
<div
style={{
flex: 1,
padding: isFlashStep ? "24px" : "32px 24px",
...(isFlashStep
? {}
: { display: "flex", justifyContent: "center" }),
}}
>
<div style={isFlashStep ? {} : { width: "100%", maxWidth: 720 }}>
{step === 0 && <StepModePicker onPick={handleModePicked} />}
{step === 1 && mode === "existing" && (
<StepSelectExisting onSelected={handleDeviceSelected} />
)}
{step === 1 && mode === "new" && (
<StepDeployNew
onSelected={handleDeviceSelected}
onCreatedSn={(sn) => setCreatedSn(sn)}
/>
)}
{step === 2 && device && (
<StepFlash device={device} onFlashed={handleFlashed} />
)}
{step === 3 && device && (
<StepVerify device={device} onVerified={handleVerified} />
)}
{step === 4 && device && (
<StepDone device={device} onProvisionNext={handleProvisionNext} />
)}
</div>
</div>
</div>
);
}
// ─── Cleanup helper ───────────────────────────────────────────────────────────
// Deletes a newly-created (un-flashed) serial number when user aborts Deploy New
async function cleanupCreatedSn(sn) {
try {
await api.request(`/manufacturing/devices/${sn}`, { method: "DELETE" });
} catch (_) {
// Best-effort — if the endpoint doesn't exist yet, silently ignore
}
}