diff --git a/backend/manufacturing/models.py b/backend/manufacturing/models.py index d9698aa..ed48648 100644 --- a/backend/manufacturing/models.py +++ b/backend/manufacturing/models.py @@ -5,14 +5,22 @@ from enum import Enum class BoardType(str, Enum): vs = "vs" # Vesper - vp = "vp" # Vesper+ - vx = "vx" # VesperPro + vp = "vp" # Vesper Plus + vx = "vx" # Vesper Pro + cb = "cb" # Chronos + cp = "cp" # Chronos Pro + am = "am" # Agnus Mini + ab = "ab" # Agnus BOARD_TYPE_LABELS = { "vs": "Vesper", - "vp": "Vesper+", - "vx": "VesperPro", + "vp": "Vesper Plus", + "vx": "Vesper Pro", + "cb": "Chronos", + "cp": "Chronos Pro", + "am": "Agnus Mini", + "ab": "Agnus", } @@ -27,7 +35,11 @@ class MfgStatus(str, Enum): class BatchCreate(BaseModel): board_type: BoardType - board_version: str = Field(..., pattern=r"^\d{2}$", description="2-digit zero-padded version, e.g. '01'") + board_version: str = Field( + ..., + pattern=r"^\d+(\.\d+)*$", + description="SemVer-style version string, e.g. '1.0' or legacy '01'", + ) quantity: int = Field(..., ge=1, le=100) @@ -49,6 +61,7 @@ class DeviceInventoryItem(BaseModel): created_at: Optional[str] = None owner: Optional[str] = None assigned_to: Optional[str] = None + device_name: Optional[str] = None class DeviceInventoryListResponse(BaseModel): diff --git a/backend/manufacturing/router.py b/backend/manufacturing/router.py index 9777398..b028713 100644 --- a/backend/manufacturing/router.py +++ b/backend/manufacturing/router.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends, Query +from fastapi import APIRouter, Depends, Query, HTTPException from fastapi.responses import Response from fastapi.responses import RedirectResponse from typing import Optional @@ -13,6 +13,7 @@ from manufacturing.models import ( ) from manufacturing import service from manufacturing import audit +from shared.exceptions import NotFoundError router = APIRouter(prefix="/api/manufacturing", tags=["manufacturing"]) @@ -129,6 +130,41 @@ async def assign_device( return result +@router.delete("/devices/{sn}", status_code=204) +async def delete_device( + sn: str, + force: bool = Query(False, description="Required to delete sold/claimed devices"), + user: TokenPayload = Depends(require_permission("manufacturing", "delete")), +): + """Delete a device. Sold/claimed devices require force=true.""" + try: + service.delete_device(sn, force=force) + except NotFoundError: + raise HTTPException(status_code=404, detail="Device not found") + except PermissionError as e: + raise HTTPException(status_code=403, detail=str(e)) + await audit.log_action( + admin_user=user.email, + action="device_deleted", + serial_number=sn, + detail={"force": force}, + ) + + +@router.delete("/devices", status_code=200) +async def delete_unprovisioned( + user: TokenPayload = Depends(require_permission("manufacturing", "delete")), +): + """Delete all devices with status 'manufactured' (never provisioned).""" + deleted = service.delete_unprovisioned_devices() + await audit.log_action( + admin_user=user.email, + action="bulk_delete_unprovisioned", + detail={"count": len(deleted), "serial_numbers": deleted}, + ) + return {"deleted": deleted, "count": len(deleted)} + + @router.get("/devices/{sn}/firmware.bin") def redirect_firmware( sn: str, diff --git a/backend/manufacturing/service.py b/backend/manufacturing/service.py index 84bf68e..5cf29de 100644 --- a/backend/manufacturing/service.py +++ b/backend/manufacturing/service.py @@ -46,6 +46,7 @@ def _doc_to_inventory_item(doc) -> DeviceInventoryItem: created_at=created_str, owner=data.get("owner"), assigned_to=data.get("assigned_to"), + device_name=data.get("device_name") or None, ) @@ -217,6 +218,47 @@ def get_stats() -> ManufacturingStats: return ManufacturingStats(counts=counts, recent_activity=recent) +PROTECTED_STATUSES = {"sold", "claimed"} + + +def delete_device(sn: str, force: bool = False) -> None: + """Delete a device by serial number. + + Raises PermissionError if the device is sold/claimed and force is not set. + The frontend uses force=True only after the user confirms by typing the SN. + """ + db = get_db() + docs = list(db.collection(COLLECTION).where("serial_number", "==", sn).limit(1).stream()) + if not docs: + raise NotFoundError("Device") + + data = docs[0].to_dict() or {} + status = data.get("mfg_status", "manufactured") + + if status in PROTECTED_STATUSES and not force: + raise PermissionError( + f"Device {sn} has status '{status}' and cannot be deleted without explicit confirmation." + ) + + docs[0].reference.delete() + + +def delete_unprovisioned_devices() -> list[str]: + """Delete all devices with status 'manufactured' (never flashed/provisioned). + + Returns the list of deleted serial numbers. + """ + db = get_db() + docs = list(db.collection(COLLECTION).where("mfg_status", "==", "manufactured").stream()) + deleted = [] + for doc in docs: + data = doc.to_dict() or {} + sn = data.get("serial_number", "") + doc.reference.delete() + deleted.append(sn) + return deleted + + def get_firmware_url(sn: str) -> str: """Return the FastAPI download URL for the latest stable firmware for this device's hw_type.""" from firmware.service import get_latest diff --git a/frontend/src/devices/DeviceDetail.jsx b/frontend/src/devices/DeviceDetail.jsx index 5d1aff3..f09e119 100644 --- a/frontend/src/devices/DeviceDetail.jsx +++ b/frontend/src/devices/DeviceDetail.jsx @@ -17,6 +17,23 @@ L.Icon.Default.mergeOptions({ shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png", }); +// --- Helpers --- + +function formatSecondsAgo(seconds) { + if (seconds == null) return null; + if (seconds < 60) return `${seconds}s ago`; + if (seconds < 3600) { + const m = Math.round(seconds / 60); + return `${m}m ago`; + } + if (seconds < 86400) { + const h = Math.round(seconds / 3600); + return `${h}h ago`; + } + const d = Math.round(seconds / 86400); + return `${d}d ago`; +} + // --- Helper components --- function Field({ label, children }) { @@ -3023,7 +3040,7 @@ export default function DeviceDetail() { {isOnline ? "Online" : "Offline"} {mqttStatus && ( - {mqttStatus.seconds_since_heartbeat}s ago + {formatSecondsAgo(mqttStatus.seconds_since_heartbeat)} )} diff --git a/frontend/src/manufacturing/BatchCreator.jsx b/frontend/src/manufacturing/BatchCreator.jsx index 22b9549..3ae7119 100644 --- a/frontend/src/manufacturing/BatchCreator.jsx +++ b/frontend/src/manufacturing/BatchCreator.jsx @@ -3,30 +3,35 @@ import { useNavigate } from "react-router-dom"; import api from "../api/client"; const BOARD_TYPES = [ - { value: "vs", label: "Vesper (VS)" }, - { value: "vp", label: "Vesper+ (VP)" }, - { value: "vx", label: "VesperPro (VX)" }, + { value: "vs", name: "VESPER", codename: "vesper-basic" }, + { value: "vp", name: "VESPER PLUS", codename: "vesper-plus" }, + { value: "vx", name: "VESPER PRO", codename: "vesper-pro" }, + { value: "cb", name: "CHRONOS", codename: "chronos-basic" }, + { value: "cp", name: "CHRONOS PRO", codename: "chronos-pro" }, + { value: "am", name: "AGNUS MINI", codename: "agnus-mini" }, + { value: "ab", name: "AGNUS", codename: "agnus-basic" }, ]; export default function BatchCreator() { const navigate = useNavigate(); - const [boardType, setBoardType] = useState("vs"); - const [boardVersion, setBoardVersion] = useState("01"); - const [quantity, setQuantity] = useState(1); - const [saving, setSaving] = useState(false); - const [error, setError] = useState(""); - const [result, setResult] = useState(null); - const [copied, setCopied] = useState(false); + const [boardType, setBoardType] = useState(null); + const [boardVersion, setBoardVersion] = useState("1.0"); + const [quantity, setQuantity] = useState(1); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(""); + const [result, setResult] = useState(null); + const [copied, setCopied] = useState(false); const handleSubmit = async (e) => { e.preventDefault(); + if (!boardType) return; setError(""); setResult(null); setSaving(true); try { const data = await api.post("/manufacturing/batch", { board_type: boardType, - board_version: boardVersion, + board_version: boardVersion.trim(), quantity: Number(quantity), }); setResult(data); @@ -44,21 +49,17 @@ export default function BatchCreator() { setTimeout(() => setCopied(false), 2000); }; + const inputStyle = { + backgroundColor: "var(--bg-input)", + borderColor: "var(--border-input)", + color: "var(--text-primary)", + }; + return ( -
-
- - / -

- New Batch -

-
+
+

+ New Batch +

{!result ? (
{error && ( -
+
{error}
)} -
+ + {/* Board Type tiles */}
-
+ {/* Board Revision */}
- setBoardVersion(e.target.value)} - placeholder="01" - maxLength={2} - pattern="\d{2}" - required - className="w-full px-3 py-2 rounded-md text-sm border" - style={{ - backgroundColor: "var(--bg-input)", - borderColor: "var(--border-input)", - color: "var(--text-primary)", - }} - /> +
+ Rev + setBoardVersion(e.target.value)} + placeholder="1.0" + required + className="px-3 py-2 rounded-md text-sm border w-32" + style={inputStyle} + /> +

- 2-digit zero-padded version number (e.g. 01, 02) + Use semantic versioning: 1.0, 1.1, 2.0, etc.

+ {/* Quantity */}
@@ -201,9 +202,7 @@ export default function BatchCreator() { }} > {result.serial_numbers.map((sn) => ( -
- {sn} -
+
{sn}
))}
@@ -223,7 +222,7 @@ export default function BatchCreator() { View Inventory + + {open && ( +
+

+ Drag to reorder +

+ {orderedIds.map((id) => { + const col = ALL_COLUMNS.find((c) => c.id === id); + if (!col) return null; + return ( +
handleDragStart(id)} + onDragOver={(e) => handleDragOver(e, id)} + onDragEnd={handleDragEnd} + className="flex items-center gap-2 px-2 py-1.5 rounded cursor-grab select-none" + style={{ + backgroundColor: dragging === id ? "var(--bg-card-hover)" : "transparent", + opacity: dragging === id ? 0.5 : 1, + }} + > + + +
+ ); + })} +
+ )} +
+ ); +} + +// ─── Add Device Modal ───────────────────────────────────────────────────────── + +function AddDeviceModal({ onClose, onCreated }) { + 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) return; + setError(""); + setCreating(true); + try { + const batch = await api.post("/manufacturing/batch", { + board_type: boardType, + board_version: boardVersion.trim(), + quantity: 1, + }); + onCreated(batch.serial_numbers[0]); + onClose(); + } catch (err) { + setError(err.message); + } finally { + setCreating(false); + } + }; + + return ( +
+
+

Add Single Device

+ +

Board Type

+
+ {BOARD_TYPES.map((bt) => { + const isSel = boardType === bt.value; + return ( + + ); + })} +
+ + +
+ Rev + setBoardVersion(e.target.value)} + placeholder="1.0" + className="px-3 py-2 rounded-md text-sm border w-28" + style={{ backgroundColor: "var(--bg-input)", borderColor: "var(--border-input)", color: "var(--text-primary)" }} + /> +
+ + {error && ( +
+ {error} +
+ )} + +
+ + +
+
+
+ ); +} + +// ─── Delete Not Provisioned Modal ───────────────────────────────────────────── + +function DeleteUnprovisionedModal({ onClose, onDeleted }) { + const [scanning, setScanning] = useState(true); + const [candidates, setCandidates] = useState([]); + const [deleting, setDeleting] = useState(false); + const [error, setError] = useState(""); + + useEffect(() => { + (async () => { + try { + const data = await api.get("/manufacturing/devices?status=manufactured&limit=500"); + setCandidates(data.devices || []); + } catch (err) { + setError(err.message); + } finally { + setScanning(false); + } + })(); + }, []); + + const handleDelete = async () => { + setDeleting(true); + setError(""); + try { + const result = await api.request("/manufacturing/devices", { method: "DELETE" }); + onDeleted(result.count || 0); + onClose(); + } catch (err) { + setError(err.message); + } finally { + setDeleting(false); + } + }; + + return ( +
+
+

+ Delete Not Provisioned Devices +

+

+ The following devices have status manufactured and have never been flashed or provisioned. + They will be permanently deleted. +

+ + {scanning ? ( +

Scanning…

+ ) : candidates.length === 0 ? ( +

+ No unprovisioned devices found. +

+ ) : ( +
+ + + + + + + + + + {candidates.map((d) => ( + + + + + + ))} + +
SerialTypeCreated
{d.serial_number}{BOARD_TYPE_LABELS[d.hw_type] || d.hw_type}{formatDate(d.created_at)}
+
+ )} + + {error && ( +
+ {error} +
+ )} + +
+ + +
+
+
+ ); +} + +// ─── Multi-select Delete Modal ──────────────────────────────────────────────── + +function MultiDeleteModal({ selected, devices, onClose, onDeleted }) { + const [deleting, setDeleting] = useState(false); + const [error, setError] = useState(""); + + const selectedDevices = devices.filter((d) => selected.has(d.id)); + const hasProtected = selectedDevices.some((d) => PROTECTED_STATUSES.includes(d.mfg_status)); + + const handleDelete = async () => { + setDeleting(true); + setError(""); + try { + // Only delete non-protected devices from this modal + const toDelete = selectedDevices.filter((d) => !PROTECTED_STATUSES.includes(d.mfg_status)); + await Promise.all( + toDelete.map((d) => + api.request(`/manufacturing/devices/${d.serial_number}`, { method: "DELETE" }) + ) + ); + onDeleted(toDelete.map((d) => d.id)); + onClose(); + } catch (err) { + setError(err.message); + } finally { + setDeleting(false); + } + }; + + const safeCount = selectedDevices.filter((d) => !PROTECTED_STATUSES.includes(d.mfg_status)).length; + const protectedCount = selectedDevices.length - safeCount; + + return ( +
+
+

+ Delete {selectedDevices.length} Device{selectedDevices.length !== 1 ? "s" : ""} +

+ + {hasProtected && ( +
+ ⚠ {protectedCount} selected device{protectedCount !== 1 ? "s are" : " is"} sold/claimed and will be skipped. + To delete those, open each device individually. +
+ )} + +

+ {safeCount > 0 + ? `${safeCount} device${safeCount !== 1 ? "s" : ""} will be permanently deleted.` + : "No deletable devices in your selection (all are sold/claimed)."} +

+ + {error && ( +
+ {error} +
+ )} + +
+ + +
+
+
+ ); +} + +// ─── Main Component ─────────────────────────────────────────────────────────── + export default function DeviceInventory() { const navigate = useNavigate(); const { hasPermission } = useAuth(); - const canAdd = hasPermission("manufacturing", "add"); + const canAdd = hasPermission("manufacturing", "add"); + const canDelete = hasPermission("manufacturing", "delete"); const [devices, setDevices] = useState([]); const [loading, setLoading] = useState(true); - const [error, setError] = useState(""); + const [error, setError] = useState(""); - const [search, setSearch] = useState(""); + const [search, setSearch] = useState(""); const [statusFilter, setStatusFilter] = useState(""); const [hwTypeFilter, setHwTypeFilter] = useState(""); + // Column preferences + const [colPrefs, setColPrefs] = useState(loadColumnPrefs); + + // Selection + const [selected, setSelected] = useState(new Set()); + const allIds = devices.map((d) => d.id); + const allSelected = allIds.length > 0 && allIds.every((id) => selected.has(id)); + + // Modals + const [showAddDevice, setShowAddDevice] = useState(false); + const [showDeleteUnprovisioned, setShowDeleteUnprovisioned] = useState(false); + const [showMultiDelete, setShowMultiDelete] = useState(false); + const fetchDevices = async () => { setLoading(true); setError(""); try { const params = new URLSearchParams(); - if (search) params.set("search", search); + if (search) params.set("search", search); if (statusFilter) params.set("status", statusFilter); if (hwTypeFilter) params.set("hw_type", hwTypeFilter); params.set("limit", "200"); const qs = params.toString(); const data = await api.get(`/manufacturing/devices${qs ? `?${qs}` : ""}`); setDevices(data.devices); + // Clear selection on refresh + setSelected(new Set()); } catch (err) { setError(err.message); } finally { @@ -58,67 +517,108 @@ export default function DeviceInventory() { } }; - useEffect(() => { - fetchDevices(); - }, [search, statusFilter, hwTypeFilter]); + useEffect(() => { fetchDevices(); }, [search, statusFilter, hwTypeFilter]); - const formatDate = (iso) => { - if (!iso) return "—"; - try { - return new Date(iso).toLocaleDateString("en-US", { - year: "numeric", month: "short", day: "numeric", - }); - } catch { - return iso; + const updateColVisible = (id, visible) => { + const next = { ...colPrefs.visible, [id]: visible }; + setColPrefs((p) => ({ ...p, visible: next })); + saveColumnPrefs(next, colPrefs.orderedIds); + }; + + const updateColOrder = (orderedIds) => { + setColPrefs((p) => ({ ...p, orderedIds })); + saveColumnPrefs(colPrefs.visible, orderedIds); + }; + + const toggleRow = (id, e) => { + e.stopPropagation(); + setSelected((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); else next.add(id); + return next; + }); + }; + + const toggleAll = () => { + if (allSelected) setSelected(new Set()); + else setSelected(new Set(allIds)); + }; + + const visibleCols = colPrefs.orderedIds + .map((id) => ALL_COLUMNS.find((c) => c.id === id)) + .filter((c) => c && colPrefs.visible[c.id]); + + const renderCell = (col, device) => { + switch (col.id) { + case "serial": return {device.serial_number}; + case "type": return {BOARD_TYPE_LABELS[device.hw_type] || device.hw_type}; + case "version": return {formatHwVersion(device.hw_version)}; + case "status": return ; + case "batch": return {device.mfg_batch_id || "—"}; + case "created": return {formatDate(device.created_at)}; + case "owner": return {device.owner || "—"}; + case "name": return {device.device_name || "—"}; + default: return null; } }; return (
+ {/* Header */}
-

- Device Inventory -

+

Device Inventory

{devices.length} device{devices.length !== 1 ? "s" : ""} {statusFilter || hwTypeFilter || search ? " (filtered)" : ""}

- {canAdd && ( - - )} +
+ {canDelete && ( + + )} + {canAdd && ( + + )} + {canAdd && ( + + )} +
- {/* Filters */} + {/* Filters + column toggle */}
setSearch(e.target.value)} className="px-3 py-2 rounded-md text-sm border flex-1 min-w-48" - style={{ - backgroundColor: "var(--bg-input)", - borderColor: "var(--border-input)", - color: "var(--text-primary)", - }} + style={{ backgroundColor: "var(--bg-input)", borderColor: "var(--border-input)", color: "var(--text-primary)" }} /> +
- {error && ( + {/* Multi-select action bar */} + {selected.size > 0 && canDelete && (
+ + {selected.size} device{selected.size !== 1 ? "s" : ""} selected + +
+ + +
+
+ )} + + {error && ( +
{error}
)} -
+ {/* Table */} +
- - - - - - - + {/* Checkbox column */} + + {visibleCols.map((col) => ( + + ))} {loading ? ( - ) : devices.length === 0 ? ( - ) : ( - devices.map((device) => ( - navigate(`/manufacturing/devices/${device.serial_number}`)} - 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 = "")} - > - - - - - - - - - )) + devices.map((device) => { + const isSelected = selected.has(device.id); + return ( + { if (!isSelected) e.currentTarget.style.backgroundColor = "var(--bg-card-hover)"; }} + onMouseLeave={(e) => { if (!isSelected) e.currentTarget.style.backgroundColor = ""; }} + > + {/* Checkbox */} + + {/* Data cells */} + {visibleCols.map((col) => ( + + ))} + + ); + }) )}
Serial NumberTypeVersionStatusBatchCreatedOwner + + + {col.label} +
+ Loading…
+ No devices found. - {canAdd && ( - - {" "} - {" "} - to get started. - - )}
- {device.serial_number} - - {BOARD_TYPE_LABELS[device.hw_type] || device.hw_type} - - v{device.hw_version} - - - - {device.mfg_batch_id || "—"} - - {formatDate(device.created_at)} - - {device.owner || "—"} -
toggleRow(device.id, e)}> + {}} + className="cursor-pointer" + /> + navigate(`/manufacturing/devices/${device.serial_number}`)} + > + {renderCell(col, device)} +
+ + {/* Modals */} + {showAddDevice && ( + setShowAddDevice(false)} + onCreated={() => fetchDevices()} + /> + )} + + {showDeleteUnprovisioned && ( + setShowDeleteUnprovisioned(false)} + onDeleted={(count) => { + fetchDevices(); + }} + /> + )} + + {showMultiDelete && ( + setShowMultiDelete(false)} + onDeleted={(deletedIds) => { + setDevices((prev) => prev.filter((d) => !deletedIds.includes(d.id))); + setSelected(new Set()); + }} + /> + )}
); } diff --git a/frontend/src/manufacturing/DeviceInventoryDetail.jsx b/frontend/src/manufacturing/DeviceInventoryDetail.jsx index 702d56f..3c26eb1 100644 --- a/frontend/src/manufacturing/DeviceInventoryDetail.jsx +++ b/frontend/src/manufacturing/DeviceInventoryDetail.jsx @@ -3,7 +3,10 @@ import { useParams, useNavigate } from "react-router-dom"; import { useAuth } from "../auth/AuthContext"; import api from "../api/client"; -const BOARD_TYPE_LABELS = { vs: "Vesper", vp: "Vesper+", vx: "VesperPro" }; +const BOARD_TYPE_LABELS = { + vs: "Vesper", vp: "Vesper Plus", vx: "Vesper Pro", + cb: "Chronos", cp: "Chronos Pro", am: "Agnus Mini", ab: "Agnus", +}; const STATUS_STYLES = { manufactured: { bg: "var(--bg-card-hover)", color: "var(--text-muted)" }, @@ -18,6 +21,17 @@ const STATUS_OPTIONS = [ "manufactured", "flashed", "provisioned", "sold", "claimed", "decommissioned", ]; +// Statuses that require serial confirmation before delete +const PROTECTED_STATUSES = ["sold", "claimed"]; + +function formatHwVersion(v) { + if (!v) return "—"; + if (/^\d+\.\d+/.test(v)) return `Rev ${v}`; + const n = parseInt(v, 10); + if (!isNaN(n)) return `Rev ${n}.0`; + return `Rev ${v}`; +} + function StatusBadge({ status }) { const style = STATUS_STYLES[status] || STATUS_STYLES.manufactured; return ( @@ -36,21 +50,97 @@ function Field({ label, value, mono = false }) {

{label}

-

+

{value || "—"}

); } +// ─── Delete Confirmation Modal ──────────────────────────────────────────────── + +function DeleteModal({ device, onConfirm, onCancel, deleting }) { + const isProtected = PROTECTED_STATUSES.includes(device?.mfg_status); + const [typed, setTyped] = useState(""); + const confirmed = !isProtected || typed === device?.serial_number; + + return ( +
+
+

+ {isProtected ? "⚠ Delete Protected Device" : "Delete Device"} +

+ + {isProtected ? ( + <> +

+ This device has status {device.mfg_status} and is linked to a customer. + Deleting it is irreversible and may break the customer's access. +

+

+ To confirm, type the serial number exactly: +

+

+ {device.serial_number} +

+ setTyped(e.target.value)} + onPaste={(e) => e.preventDefault()} + placeholder="Type serial number here…" + autoComplete="off" + className="w-full px-3 py-2 rounded-md text-sm border mb-4 font-mono" + style={{ + backgroundColor: "var(--bg-input)", + borderColor: typed === device.serial_number ? "var(--accent)" : "var(--border-input)", + color: "var(--text-primary)", + }} + /> + + ) : ( +

+ Are you sure you want to delete {device?.serial_number}? + This cannot be undone. +

+ )} + +
+ + +
+
+
+ ); +} + +// ─── Main Component ─────────────────────────────────────────────────────────── + export default function DeviceInventoryDetail() { const { sn } = useParams(); const navigate = useNavigate(); const { hasPermission } = useAuth(); - const canEdit = hasPermission("manufacturing", "edit"); + const canEdit = hasPermission("manufacturing", "edit"); + const canDelete = hasPermission("manufacturing", "delete"); const [device, setDevice] = useState(null); const [loading, setLoading] = useState(true); @@ -70,6 +160,9 @@ export default function DeviceInventoryDetail() { const [assignError, setAssignError] = useState(""); const [assignSuccess, setAssignSuccess] = useState(false); + const [showDeleteModal, setShowDeleteModal] = useState(false); + const [deleting, setDeleting] = useState(false); + const loadDevice = async () => { setLoading(true); setError(""); @@ -83,9 +176,7 @@ export default function DeviceInventoryDetail() { } }; - useEffect(() => { - loadDevice(); - }, [sn]); + useEffect(() => { loadDevice(); }, [sn]); const handleStatusSave = async () => { setStatusError(""); @@ -111,10 +202,7 @@ export default function DeviceInventoryDetail() { try { const updated = await api.request(`/manufacturing/devices/${sn}/assign`, { method: "POST", - body: JSON.stringify({ - customer_email: assignEmail, - customer_name: assignName || null, - }), + body: JSON.stringify({ customer_email: assignEmail, customer_name: assignName || null }), }); setDevice(updated); setAssignSuccess(true); @@ -152,6 +240,21 @@ export default function DeviceInventoryDetail() { } }; + const handleDelete = async () => { + setDeleting(true); + try { + const isProtected = PROTECTED_STATUSES.includes(device?.mfg_status); + const url = `/manufacturing/devices/${sn}${isProtected ? "?force=true" : ""}`; + await api.request(url, { method: "DELETE" }); + navigate("/manufacturing"); + } catch (err) { + setError(err.message); + setShowDeleteModal(false); + } finally { + setDeleting(false); + } + }; + const formatDate = (iso) => { if (!iso) return "—"; try { @@ -159,9 +262,7 @@ export default function DeviceInventoryDetail() { year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", }); - } catch { - return iso; - } + } catch { return iso; } }; if (loading) { @@ -175,20 +276,9 @@ export default function DeviceInventoryDetail() { if (error && !device) { return (
-
{error}
@@ -197,67 +287,67 @@ export default function DeviceInventoryDetail() { } return ( -
-
- - / -

- {device?.serial_number} -

+
+ {/* Title row */} +
+
+

+ {device?.serial_number} +

+ {device?.device_name && ( +

{device.device_name}

+ )} +
+ {canDelete && ( + + )}
{error && ( -
+
{error}
)} {/* Identity card */} -
+

Device Identity

- + + {device?.device_name && ( + + )}
{/* Status card */} -
+

Status

{canEdit && !editingStatus && (
); } diff --git a/frontend/src/manufacturing/ProvisioningWizard.jsx b/frontend/src/manufacturing/ProvisioningWizard.jsx index a067492..840c330 100644 --- a/frontend/src/manufacturing/ProvisioningWizard.jsx +++ b/frontend/src/manufacturing/ProvisioningWizard.jsx @@ -1,4 +1,4 @@ -import { useState, useRef, useCallback } from "react"; +import { useState, useRef, useCallback, useEffect } from "react"; import { useNavigate } from "react-router-dom"; import { ESPLoader, Transport } from "esptool-js"; import api from "../api/client"; @@ -6,12 +6,27 @@ import api from "../api/client"; // ─── constants ─────────────────────────────────────────────────────────────── const BOARD_TYPES = [ - { value: "vs", label: "Vesper (VS)" }, - { value: "vp", label: "Vesper+ (VP)" }, - { value: "vx", label: "VesperPro (VX)" }, + { value: "vs", name: "VESPER", codename: "vesper-basic", desc: "Standard bell controller" }, + { value: "vp", name: "VESPER PLUS", codename: "vesper-plus", desc: "Extended output controller" }, + { value: "vx", name: "VESPER PRO", codename: "vesper-pro", desc: "Full-featured pro controller" }, + { value: "cb", name: "CHRONOS", codename: "chronos-basic", desc: "Basic clock controller" }, + { value: "cp", name: "CHRONOS PRO", codename: "chronos-pro", desc: "Pro clock controller" }, + { value: "am", name: "AGNUS MINI", codename: "agnus-mini", desc: "Compact carillon module" }, + { value: "ab", name: "AGNUS", codename: "agnus-basic", desc: "Standard carillon module" }, ]; -const BOARD_TYPE_LABELS = { vs: "Vesper", vp: "Vesper+", vx: "VesperPro" }; +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)" }, @@ -22,10 +37,13 @@ const STATUS_STYLES = { decommissioned: { bg: "var(--danger-bg)", color: "var(--danger-text)" }, }; -const FLASH_BAUD = 460800; -const NVS_ADDRESS = 0x9000; -const FW_ADDRESS = 0x10000; -const VERIFY_POLL_MS = 5000; +// 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 ──────────────────────────────────────────────────────────── @@ -42,56 +60,6 @@ function StatusBadge({ status }) { ); } -function StepIndicator({ current }) { - const steps = ["Select Device", "Flash", "Verify", "Done"]; - return ( -
- {steps.map((label, i) => { - const idx = i + 1; - const done = idx < current; - const active = idx === current; - const pending = idx > current; - return ( -
-
-
- {done ? ( - - - - ) : idx} -
- - {label} - -
- {i < steps.length - 1 && ( -
- )} -
- ); - })} -
- ); -} - function ProgressBar({ label, percent }) { return (
@@ -99,10 +67,7 @@ function ProgressBar({ label, percent }) { {label} {Math.round(percent)}%
-
+
{msg}
); } -function inputCls() { - return "w-full px-3 py-2 rounded-md text-sm border"; -} - function inputStyle() { - return { - backgroundColor: "var(--bg-input)", - borderColor: "var(--border-input)", - color: "var(--text-primary)", - }; + return { backgroundColor: "var(--bg-input)", borderColor: "var(--border-input)", color: "var(--text-primary)" }; } -// ─── Step 1 — Select or create device ───────────────────────────────────────── +// ─── 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) -function StepSelectDevice({ onSelected }) { - const [mode, setMode] = useState("search"); // "search" | "create" - const [searchSn, setSearchSn] = useState(""); - const [searching, setSearching] = useState(false); - const [searchError, setSearchError] = useState(""); - const [found, setFound] = useState(null); +const STEP_LABELS = ["Select Device", "Flash", "Verify", "Done"]; - // Create-device fields - const [boardType, setBoardType] = useState("vs"); - const [boardVersion, setBoardVersion] = useState("01"); - const [creating, setCreating] = useState(false); - const [createError, setCreateError] = useState(""); +function CheckeredFlagIcon() { + return ( + + + + ); +} - const handleSearch = async (e) => { - e.preventDefault(); - setSearchError(""); - setFound(null); - setSearching(true); - try { - const data = await api.get(`/manufacturing/devices/${searchSn.trim().toUpperCase()}`); - if (data.mfg_status === "flashed" || data.mfg_status === "provisioned") { - setSearchError( - `Device is already ${data.mfg_status}. Only unprovisioned devices can be re-flashed here.` - ); - } else { - setFound(data); - } - } catch (err) { - setSearchError(err.message); - } finally { - setSearching(false); - } - }; +function StepIndicator({ current }) { + // current: 1..4 (step numbers matching STEP_LABELS index + 1) + return ( +
+ {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; - const handleCreate = async (e) => { - e.preventDefault(); - setCreateError(""); - setCreating(true); - try { - const batch = await api.post("/manufacturing/batch", { - board_type: boardType, - board_version: boardVersion, - quantity: 1, - }); - const sn = batch.serial_numbers[0]; - const device = await api.get(`/manufacturing/devices/${sn}`); - setFound(device); - } catch (err) { - setCreateError(err.message); - } finally { - setCreating(false); - } - }; + // Color tokens + const dotBg = done ? "#1a5c4a" + : active ? "#22c55e" + : "#2a2a2a"; + const dotColor = done ? "#4dd6c8" + : active ? "#fff" + : "#555"; + const labelColor = active ? "#22c55e" : done ? "#4dd6c8" : "#555"; + const labelGlow = active ? "0 0 8px rgba(34,197,94,0.45)" : "none"; + const lineColor = done ? "#4dd6c8" : "var(--border-primary)"; - if (found) { - return ( -
-
-

- Device Selected -

-
-
-

Serial Number

-

{found.serial_number}

-
-
-

Board Type

-

{BOARD_TYPE_LABELS[found.hw_type] || found.hw_type}

-
-
-

HW Version

-

v{found.hw_version}

-
-
-

Status

- + return ( +
+ {/* Node */} +
+
+ {done ? ( + + + + ) : isLast ? ( + + ) : idx} +
+ + {label} +
+ + {/* Connector line */} + {i < STEP_LABELS.length - 1 && ( +
+ )}
-
+ ); + })} +
+ ); +} + +// ─── Wizard top bar (title + indicator + Back/Abort) ───────────────────────── + +function WizardTopBar({ step, onBack, onAbort, showBack, showAbort }) { + return ( +
+
+ {/* Left: Back button (or spacer) */} +
+ {showBack && ( - + )} +
+ + {/* Center: Step indicator */} +
+ {step >= 1 && } +
+ + {/* Right: Abort button (or spacer) */} +
+ {showAbort && ( + + )} +
+
+
+ ); +} + +// ─── Step 0 — Mode picker ───────────────────────────────────────────────────── + +function StepModePicker({ onPick }) { + return ( +
+

+ What would you like to do? +

+

+ Choose how to start the provisioning process. +

+
+ {/* Flash Existing */} + + + {/* Deploy New */} + +
+
+ ); +} + +// ─── 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 ( +
+

+ Device Selected +

+
+ + + +
+

Status

+ +
+
+
+ +
); @@ -247,110 +380,188 @@ function StepSelectDevice({ onSelected }) { return (
- {/* Mode toggle */} -
- {[["search", "Search Existing"], ["create", "Quick Create"]].map(([val, lbl]) => ( +
+

+ Select a Device to Flash +

+ + setSearch(e.target.value)} + className="flex-1 px-3 py-2 rounded-md text-sm border" + style={inputStyle()} + /> - ))} + + + + {results.length === 0 && !loading && ( +

+ No flashable devices found. +

+ )} + + {results.length > 0 && ( +
+ + + + + + + + + + + {results.map((d) => ( + 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 = "")} + > + + + + + + ))} + +
SerialTypeVerStatus
{d.serial_number} + {BOARD_TYPE_LABELS[d.hw_type] || d.hw_type} + + {formatHwVersion(d.hw_version)} + + +
+
+ )} +
+
+ ); +} + +// ─── 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); + } + }; + + return ( +
+ {/* Board type tiles */} +
+

Board Type

+
+ {BOARD_TYPES.map((bt) => { + const isSelected = boardType === bt.value; + return ( + + ); + })} +
- {/* Search */} - {mode === "search" && ( -
-

- Find Unprovisioned Device -

- - {searchError &&
} -
- setSearchSn(e.target.value)} - required - className={inputCls()} - style={inputStyle()} - /> - -
+ {/* Board revision */} +
+ +
+ Rev + setBoardVersion(e.target.value)} + placeholder="1.0" + className="px-3 py-2 rounded-md text-sm border w-32" + style={inputStyle()} + />
- )} +

+ Use semantic versioning: 1.0, 1.1, 2.0, etc. +

+
- {/* Create */} - {mode === "create" && ( -
-

- Create Single Device -

- - {createError &&
} -
-
- - -
-
- - setBoardVersion(e.target.value)} - placeholder="01" - maxLength={2} - pattern="\d{2}" - required - className={inputCls()} - style={inputStyle()} - /> -
- -
-
- )} + + + +
+ ); +} + +// ─── Shared info cell ───────────────────────────────────────────────────────── + +function InfoCell({ label, value, mono = false }) { + return ( +
+

{label}

+

{value || "—"}

); } @@ -358,27 +569,35 @@ function StepSelectDevice({ onSelected }) { // ─── Step 2 — Flash ──────────────────────────────────────────────────────────── function StepFlash({ device, onFlashed }) { - 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 [error, setError] = useState(""); - const loaderRef = useRef(null); - const portRef = useRef(null); - const serialReaderRef = useRef(null); - const serialActiveRef = useRef(false); - const logEndRef = useRef(null); - const serialEndRef = useRef(null); + 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 logEndRef = useRef(null); + const serialEndRef = useRef(null); const appendLog = (msg) => setLog((prev) => [...prev, String(msg)]); const appendSerial = (msg) => setSerial((prev) => [...prev, String(msg)]); - // Auto-scroll both panels to bottom const scrollLog = () => logEndRef.current?.scrollIntoView({ behavior: "smooth" }); - const scrollSerial = () => serialEndRef.current?.scrollIntoView({ behavior: "smooth" }); + const scrollSerial = () => { if (serialAutoScroll) 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"); @@ -390,7 +609,6 @@ function StepFlash({ device, onFlashed }) { return resp.arrayBuffer(); }; - // esptool-js wants binary data as a plain string of char codes const arrayBufferToString = (buf) => { const bytes = new Uint8Array(buf); let str = ""; @@ -398,13 +616,9 @@ function StepFlash({ device, onFlashed }) { return str; }; - // Start reading raw UART output from the device after flash+reset const startSerialMonitor = async (port) => { serialActiveRef.current = true; - - // Wait for the OS/browser to fully release the port after esptool closed it await new Promise((r) => setTimeout(r, 1000)); - try { await port.open({ baudRate: 115200 }); } catch (openErr) { @@ -412,8 +626,6 @@ function StepFlash({ device, onFlashed }) { scrollSerial(); return; } - - // Use getReader() directly — avoids locking issues from pipeTo() let reader; try { reader = port.readable.getReader(); @@ -424,7 +636,6 @@ function StepFlash({ device, onFlashed }) { return; } serialReaderRef.current = reader; - const textDecoder = new TextDecoder(); let lineBuffer = ""; try { @@ -433,7 +644,7 @@ function StepFlash({ device, onFlashed }) { if (streamDone) break; lineBuffer += textDecoder.decode(value, { stream: true }); const lines = lineBuffer.split(/\r?\n/); - lineBuffer = lines.pop(); // keep incomplete last fragment + lineBuffer = lines.pop(); for (const line of lines) { if (line.trim()) { appendSerial(line); @@ -448,14 +659,39 @@ function StepFlash({ device, onFlashed }) { } }; - const stopSerialMonitor = async () => { + 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 handleFlash = async () => { + 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([]); @@ -463,21 +699,10 @@ function StepFlash({ device, onFlashed }) { setFwProgress(0); setDone(false); - // 1. Open Web Serial port - let port; - try { - setConnecting(true); - appendLog("Opening port picker…"); - port = await navigator.serial.requestPort(); - portRef.current = port; - } catch (err) { - setError(err.message || "Port selection cancelled."); - setConnecting(false); - return; - } + const port = portRef.current; try { - // 2. Fetch binaries from backend + // 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`); @@ -486,8 +711,7 @@ function StepFlash({ device, onFlashed }) { const fwBuffer = await fetchBinary(`/api/manufacturing/devices/${device.serial_number}/firmware.bin`); appendLog(`Firmware binary: ${fwBuffer.byteLength} bytes`); - // 3. Connect ESPLoader - setConnecting(false); + // 2. Connect ESPLoader setFlashing(true); appendLog("Connecting to ESP32…"); @@ -505,7 +729,7 @@ function StepFlash({ device, onFlashed }) { await loaderRef.current.main(); appendLog("ESP32 connected."); - // 4. Flash NVS + firmware + // 3. Flash NVS + firmware const nvsData = arrayBufferToString(nvsBuffer); const fwData = arrayBufferToString(fwBuffer); @@ -514,18 +738,11 @@ function StepFlash({ device, onFlashed }) { { data: nvsData, address: NVS_ADDRESS }, { data: fwData, address: FW_ADDRESS }, ], - flashSize: "keep", - flashMode: "keep", - flashFreq: "keep", - eraseAll: false, - compress: true, + 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); - } + if (fileIndex === 0) setNvsProgress((written / total) * 100); + else { setNvsProgress(100); setFwProgress((written / total) * 100); } }, calculateMD5Hash: () => "", }); @@ -534,27 +751,23 @@ function StepFlash({ device, onFlashed }) { setFwProgress(100); appendLog("Flash complete. Resetting device…"); - // 5. Manual hard-reset via RTS pulse (ESP32: RTS → EN pin) - // esptool-js HardReset only calls setRTS(false) which is incomplete. - // Full pulse: RTS high (EN=LOW → chip in reset) → wait → RTS low (EN=HIGH → boot) + // 4. Hard-reset via RTS pulse try { - const transport = loaderRef.current.transport; - await transport.setRTS(true); // pull EN low → enter reset + const t = loaderRef.current.transport; + await t.setRTS(true); await new Promise((r) => setTimeout(r, 100)); - await transport.setRTS(false); // release EN → boot into user code + 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…"); - // 6. Disconnect the esptool transport so it releases the port lock - try { - await loaderRef.current.transport.disconnect(); - } catch (_) {} + // 5. Disconnect esptool transport + try { await loaderRef.current.transport.disconnect(); } catch (_) {} appendLog("esptool disconnected. Opening serial monitor…"); - // 7. Update device status → flashed + // 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" }), @@ -563,15 +776,12 @@ function StepFlash({ device, onFlashed }) { setFlashing(false); setDone(true); - // 8. Re-open the port at 115200 for live UART monitoring + // 7. Re-open for serial monitor appendSerial("── Serial monitor started (115200 baud) ──"); startSerialMonitor(port); - - // Do NOT auto-advance — user clicks "Proceed to Verify" when ready } catch (err) { setError(err.message || String(err)); setFlashing(false); - setConnecting(false); try { await loaderRef.current?.transport?.disconnect(); } catch (_) {} } }; @@ -581,32 +791,44 @@ function StepFlash({ device, onFlashed }) { return (
- {/* Device info header */} + {/* Main card */}
-

- Device to Flash -

+ {/* Header row: title + COM status icon */} +
+

+ Device to Flash +

+ {/* COM port status icon — click to disconnect */} + +
+
-
-

Serial Number

-

{device.serial_number}

-
-
-

Board

-

- {BOARD_TYPE_LABELS[device.hw_type] || device.hw_type} v{device.hw_version} -

-
+ +
{!webSerialAvailable && ( -
+
Web Serial API not available. Use Chrome or Edge on a desktop system.
)} @@ -621,19 +843,50 @@ function StepFlash({ device, onFlashed }) {
)} + {/* Action buttons */} {!busy && ( -
- +
+ {/* Connect port button — only if not yet connected */} + {!portConnected && ( + + )} + + {/* Start Flashing button — only after port connected */} + {portConnected && !done && ( + + )} + + {/* Re-flash button after done */} + {portConnected && done && ( + + )} + + {/* Proceed to Verify — available after flash completes */} {done && (
- {/* Two-column output: Flash log + Serial output */} - {(log.length > 0 || serial.length > 0) && ( -
- {/* Left: flash / esptool output */} -
-
- Flash Output -
-
- {log.map((line, i) => ( -
{line}
- ))} -
-
-
+ {/* Output panels — always shown once there's any content */} + {(log.length > 0 || serial.length > 0 || portConnected) && ( +
+ {/* Top row: Flash Output + main section (side by side) */} + {log.length > 0 && ( +
+ {/* Flash Output */} +
+
+ Flash Output +
+
+ {log.map((line, i) =>
{line}
)} +
+
+
- {/* Right: live device serial output */} + {/* Placeholder / status on the right during flash */} +
+ {flashing ? ( +
+ + + + +

Flashing…

+
+ ) : ( +

+ {done ? "Flash complete." : "Waiting to start."} +

+ )} +
+
+ )} + + {/* Serial Output — full width below */}
- Serial Output - {serialActiveRef.current && ( - +
+ Serial Output + {serialActiveRef.current && ( + + + Live + + )} +
+ {/* Auto-scroll toggle */} +
{serial.length === 0 ? ( {done ? "Waiting for device boot…" : "Available after flash completes."} ) : ( - serial.map((line, i) => ( -
{line}
- )) + serial.map((line, i) =>
{line}
) )}
@@ -737,12 +1028,15 @@ function StepFlash({ device, onFlashed }) { // ─── Step 3 — Verify ────────────────────────────────────────────────────────── function StepVerify({ device, onVerified }) { - const [polling, setPolling] = useState(false); - const [timedOut, setTimedOut] = useState(false); - const [error, setError] = useState(""); - const intervalRef = useRef(null); - const timeoutRef = useRef(null); + 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); @@ -753,23 +1047,17 @@ function StepVerify({ device, onVerified }) { intervalRef.current = setInterval(async () => { try { - // Poll the heartbeat endpoint — device is verified when it sends a heartbeat - // after we started polling (i.e. after the flash completed) const hbData = await api.get( `/mqtt/heartbeats/${device.serial_number}?limit=1&offset=0` ); - if (hbData.heartbeats && hbData.heartbeats.length > 0) { + if (hbData.heartbeats?.length > 0) { const latest = hbData.heartbeats[0]; - const receivedAt = latest.received_at; - // SQLite stores datetime as "YYYY-MM-DD HH:MM:SS" (UTC, no T/Z suffix). - // Replace the space with T and append Z so Date.parse() treats it as UTC. - const receivedMs = receivedAt - ? Date.parse(receivedAt.replace(" ", "T") + "Z") + 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); - // Promote device status to provisioned try { await api.request(`/manufacturing/devices/${device.serial_number}/status`, { method: "PATCH", @@ -777,12 +1065,14 @@ function StepVerify({ device, onVerified }) { }); } 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) { - // Non-fatal; keep polling setError(err.message); } }, VERIFY_POLL_MS); @@ -794,6 +1084,16 @@ function StepVerify({ device, onVerified }) { }, 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); @@ -802,76 +1102,89 @@ function StepVerify({ device, onVerified }) { return (
+ {/* Fixed-height card so layout doesn't jump when data arrives */}

Waiting for Device

-
- {polling && ( - <> - - - - -

- Waiting for device to connect… -
- - Power cycle the device and ensure it can reach the MQTT broker. - -

- - )} - - {!polling && !timedOut && ( -

- Power cycle the device, then click Start Verification. + {/* Loading state */} + {polling && !verified && ( +

+ + + + +

+ Waiting for device to connect… +
+ + Power cycle the device and ensure it can reach the MQTT broker. +

- )} + +
+ )} - {timedOut && ( + {/* Verified state */} + {verified && heartbeatData && ( +
+
+
+ + + +
+

Device is live!

+
+
+ + + + +
+
+ )} + + {/* Timed out state */} + {timedOut && !verified && ( +
Timed out after {VERIFY_TIMEOUT_MS / 1000}s. Check WiFi credentials and MQTT broker connectivity.
- )} -
- - {error && !timedOut && ( - +
+ +
+
)} -
- {!polling && ( - - )} - {polling && ( - - )} -
+ {error && !timedOut && !verified && ( +
+ +
+ )}

@@ -884,8 +1197,8 @@ function StepVerify({ device, onVerified }) { // ─── Step 4 — Done ──────────────────────────────────────────────────────────── function StepDone({ device, startedAt, onProvisionNext }) { - const navigate = useNavigate(); - const elapsed = startedAt ? Math.round((Date.now() - startedAt) / 1000) : null; + const navigate = useNavigate(); + const elapsed = startedAt ? Math.round((Date.now() - startedAt) / 1000) : null; const formatElapsed = (sec) => { if (sec < 60) return `${sec}s`; @@ -899,10 +1212,7 @@ function StepDone({ device, startedAt, onProvisionNext }) { style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }} >

-
+
@@ -915,29 +1225,16 @@ function StepDone({ device, startedAt, onProvisionNext }) {
-
-
-

Serial Number

-

{device.serial_number}

-
-
-

Board Type

-

- {BOARD_TYPE_LABELS[device.hw_type] || device.hw_type} v{device.hw_version} -

-
+
+ + +

Status

{elapsed !== null && ( -
-

Time Taken

-

{formatElapsed(elapsed)}

-
+ )}
@@ -963,12 +1260,24 @@ function StepDone({ device, startedAt, onProvisionNext }) { } // ─── 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 navigate = useNavigate(); - const [step, setStep] = useState(1); - const [device, setDevice] = useState(null); + const [step, setStep] = useState(0); + const [mode, setMode] = useState(null); // "existing" | "new" + const [device, setDevice] = useState(null); const [startedAt, setStartedAt] = 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); @@ -976,40 +1285,80 @@ export default function ProvisioningWizard() { setStep(2); }; - const handleFlashed = () => { - setStep(3); - }; + 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); + setStartedAt(null); + }; + const handleProvisionNext = () => { setDevice(null); setStartedAt(null); - setStep(1); + setCreatedSn(null); + setStep(0); + setMode(null); }; + // Determine Back/Abort visibility + const showBack = step >= 1 && step <= 3; + const showAbort = step >= 1 && step <= 3; + return ( -
-
- - / -

- Provisioning Wizard -

-
+
+

+ Provisioning Wizard +

- + + + {step === 0 && } + + {step === 1 && mode === "existing" && ( + + )} + {step === 1 && mode === "new" && ( + setCreatedSn(sn)} + /> + )} - {step === 1 && } {step === 2 && device && ( )} @@ -1017,12 +1366,19 @@ export default function ProvisioningWizard() { )} {step === 4 && device && ( - + )}
); } + +// ─── 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 + } +}