Files
bellsystems-cp/frontend/src/pages/engineering/manufacturing/ProvisioningWizard.jsx
T
bonaminandClaude Sonnet 5 f036000769 feat(manufacturing): extract shared useSerialConnection hook for provisioning wizard
WebSerial port ownership was previously held inside StepFlash, which
made it awkward for other steps (StepVerify) to read from the same
port without fighting over exclusive access. useSerialConnection
centralizes port open/close/read/write so the wizard's steps share one
connection lifecycle instead of each managing their own.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 18:35:31 +03:00

1577 lines
78 KiB
React
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// frontend/src/pages/manufacturing/ProvisioningWizard.jsx
// Provisions an ESP32 board via WebSerial + esptool-js.
// Steps: 0=Mode 1=Select/Create device 2=Flash 3=Verify 4=Done
import { useState, useRef, useCallback, useEffect, useMemo } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { ESPLoader, Transport } from 'esptool-js'
import api from '@/lib/api'
import PageHeader from '@/components/ui/PageHeader'
import Button from '@/components/ui/Button'
import Modal from '@/components/ui/Modal'
import StatusBadge from '@/components/ui/StatusBadge'
import SearchBar from '@/components/ui/SearchBar'
import Spinner from '@/components/ui/Spinner'
import Icon from '@/components/ui/Icon'
import { useSerialConnection } from '@/hooks/useSerialConnection'
// ─── Constants ────────────────────────────────────────────────────────────────
const BOARD_TYPES = [
{ value: 'vesper_pro', name: 'VESPER PRO', uiName: 'Vesper Pro', codename: 'vesper-pro', desc: 'Full-featured pro controller', family: 'vesper' },
{ value: 'vesper_plus', name: 'VESPER PLUS', uiName: 'Vesper Plus', codename: 'vesper-plus', desc: 'Extended output controller', family: 'vesper' },
{ value: 'vesper', name: 'VESPER', uiName: 'Vesper', codename: 'vesper-basic', desc: 'Standard bell controller', family: 'vesper' },
{ value: 'agnus', name: 'AGNUS', uiName: 'Agnus', codename: 'agnus-basic', desc: 'Standard carillon module', family: 'agnus' },
{ value: 'agnus_mini', name: 'AGNUS MINI', uiName: 'Agnus Mini', codename: 'agnus-mini', desc: 'Compact carillon module', family: 'agnus' },
{ value: 'chronos_pro', name: 'CHRONOS PRO', uiName: 'Chronos Pro', codename: 'chronos-pro', desc: 'Pro clock controller', family: 'chronos' },
{ value: 'chronos', name: 'CHRONOS', uiName: 'Chronos', codename: 'chronos-basic', desc: 'Basic clock controller', family: 'chronos' },
]
const BOARD_TYPE_MAP = Object.fromEntries(BOARD_TYPES.map((b) => [b.value, b]))
const BOARD_TYPE_LABELS = Object.fromEntries(BOARD_TYPES.map((b) => [b.value, b.name]))
// 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 STATUS_VARIANT = {
manufactured: 'neutral', flashed: 'info', provisioned: 'warning',
sold: 'success', claimed: 'success', decommissioned: 'danger',
}
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
// ─── Helpers ──────────────────────────────────────────────────────────────────
function formatHwVersion(v) {
if (!v) return '—'
if (/^\d+\.\d+/.test(v)) return `Rev ${v}`
const n = parseInt(v, 10)
return isNaN(n) ? `Rev ${v}` : `Rev ${n}.0`
}
// Parses one line of serial output from Logging.cpp's fixed format:
// 🔴 [ERROR][TagName] message | 🟡 [WARN][TagName] message | etc.
// The leading icon may or may not survive the terminal's encoding, so the
// match is anchored on the bracketed level/tag text instead. Anything that
// doesn't match (ROM/bootloader output, other libraries) is untagged system
// output — still shown, just without a level or tag chip.
const SERIAL_LEVEL_MAP = { ERROR: 'ERROR', WARN: 'WARN', INFO: 'INFO', DEBG: 'DEBUG', VERB: 'VERBOSE' }
const SERIAL_LINE_RE = /^(?:\S+\s+)?\[(ERROR|WARN|INFO|DEBG|VERB)\]\[([^\]]+)\]\s*(.*)$/
function parseSerialLine(raw) {
const m = SERIAL_LINE_RE.exec(raw)
if (!m) return { level: null, tag: null, message: raw }
return { level: SERIAL_LEVEL_MAP[m[1]] || null, tag: m[2], message: m[3] }
}
// ─── Step Indicator ───────────────────────────────────────────────────────────
const STEP_LABELS = ['Mode', 'Device', 'Flash', 'Verify', 'Done']
function CheckeredFlagIcon() {
return (
<svg width="12" height="12" 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 }) {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 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
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(--color-border)'
const dotSize = active ? 38 : 30
return (
<div key={label} style={{ display: 'flex', alignItems: 'center' }}>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', 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 width="12" height="12" 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 style={{
fontSize: 'var(--font-size-xs)', marginTop: 4,
fontWeight: active ? 'var(--font-weight-semibold)' : 'var(--font-weight-normal)',
color: labelColor, textShadow: labelGlow,
opacity: pending ? 0.5 : 1, maxWidth: 72, textAlign: 'center', lineHeight: 1.2,
whiteSpace: 'nowrap',
}}>{label}</span>
</div>
{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>
)
}
// ─── Board Tile ───────────────────────────────────────────────────────────────
function BoardTile({ bt, isSelected, onClick }) {
const [hovered, setHovered] = useState(false)
const pal = BOARD_FAMILY_COLORS[bt.family]
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
type="button"
onClick={onClick}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
aria-pressed={isSelected}
style={{
backgroundColor: isSelected ? pal.selectedBg : 'var(--color-bg-elevated)',
border: `1px solid ${borderColor}`,
borderRadius: 'var(--radius-md)',
padding: 'var(--space-3)',
textAlign: 'left', cursor: 'pointer',
transition: 'border-color 0.15s, box-shadow 0.15s, background-color 0.15s',
boxShadow,
width: '100%',
}}
>
<p style={{ fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-bold)', fontFamily: 'var(--font-family-display)', letterSpacing: 'var(--tracking-wide)', color: isSelected ? pal.selectedText : hovered ? pal.idleText : 'var(--color-text-primary)', marginBottom: 2 }}>{bt.name}</p>
<p style={{ fontSize: 'var(--font-size-xs)', fontFamily: 'var(--font-family-mono)', color: 'var(--color-text-muted)', opacity: 0.7, marginBottom: 3 }}>{bt.codename}</p>
<p style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', opacity: isSelected ? 0.9 : 0.6, lineHeight: 1.4 }}>{bt.desc}</p>
</button>
)
}
// ─── Progress Bar ─────────────────────────────────────────────────────────────
function ProgressBar({ label, percent, flex = false }) {
return (
<div style={{ flex: flex ? 1 : undefined }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 'var(--space-1)' }}>
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-secondary)' }}>{label}</span>
<span style={{ fontSize: 'var(--font-size-xs)', fontFamily: 'var(--font-family-mono)', color: 'var(--color-text-muted)' }}>{Math.round(percent)}%</span>
</div>
<div style={{ height: 6, borderRadius: 'var(--radius-full)', backgroundColor: 'var(--color-bg-elevated)', overflow: 'hidden' }}>
<div style={{
height: '100%', width: `${percent}%`, borderRadius: 'var(--radius-full)',
background: 'var(--gradient-primary)', transition: 'width 0.2s ease',
boxShadow: percent > 0 ? 'var(--shadow-primary-glow)' : 'none',
}} />
</div>
</div>
)
}
// ─── Info Cell ────────────────────────────────────────────────────────────────
function InfoCell({ label, value, mono = false }) {
return (
<div>
<p style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)', fontWeight: 'var(--font-weight-semibold)', marginBottom: 'var(--space-1)' }}>{label}</p>
{typeof value === 'string'
? <p style={{ fontSize: 'var(--font-size-sm)', fontFamily: mono ? 'var(--font-family-mono)' : undefined, color: 'var(--color-text-primary)' }}>{value || '—'}</p>
: value}
</div>
)
}
// ─── Error Box ────────────────────────────────────────────────────────────────
function ErrorBox({ msg }) {
if (!msg) return null
return (
<div style={{ padding: 'var(--space-3)', borderRadius: 'var(--radius-md)', backgroundColor: 'var(--color-danger-bg)', border: '1px solid var(--color-danger)', color: 'var(--color-danger)', fontSize: 'var(--font-size-sm)' }}>
{msg}
</div>
)
}
// ─── Bespoke Picker Modal ─────────────────────────────────────────────────────
function BespokePickerModal({ open, onConfirm, onClose }) {
const [firmwares, setFirmwares] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [selected, setSelected] = useState(null)
const [hwFamily, setHwFamily] = useState('')
useEffect(() => {
if (!open) return
setLoading(true)
api.get('/firmware?hw_type=bespoke')
.then((data) => setFirmwares(data.firmware || []))
.catch((err) => setError(err.message))
.finally(() => setLoading(false))
}, [open])
const handleConfirm = () => {
if (!selected) return
onConfirm({ firmware: selected, hwFamily })
}
return (
<Modal
open={open}
onClose={onClose}
title="Select Bespoke Firmware"
size="md"
footer={
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%' }}>
<p style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>
hw_revision will be set to <span style={{ fontFamily: 'var(--font-family-mono)' }}>1.0</span> for bespoke devices.
</p>
<div style={{ display: 'flex', gap: 'var(--space-2)' }}>
<Button variant="ghost" onClick={onClose}>Cancel</Button>
<Button variant="primary" onClick={handleConfirm} disabled={!selected || !hwFamily.trim()}>
Continue
</Button>
</div>
</div>
}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' }}>
<p style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)' }}>
Choose a bespoke firmware and the hardware family to register in NVS.
</p>
{loading && <div style={{ display: 'flex', justifyContent: 'center', padding: 'var(--space-6)' }}><Spinner /></div>}
{error && <ErrorBox msg={error} />}
{!loading && !error && firmwares.length === 0 && (
<p style={{ textAlign: 'center', padding: 'var(--space-6)', fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)' }}>
No bespoke firmwares uploaded yet. Upload one from the Firmware Manager.
</p>
)}
{firmwares.length > 0 && (
<div style={{ border: '1px solid var(--color-border)', borderRadius: 'var(--radius-md)', overflow: 'hidden' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ backgroundColor: 'var(--color-bg-elevated)', borderBottom: '1px solid var(--color-border)' }}>
{['UID', 'Version', 'Channel', 'Size'].map((h) => (
<th key={h} style={{ padding: 'var(--space-2) var(--space-3)', textAlign: 'left', fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-semibold)', color: 'var(--color-text-muted)', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)' }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{firmwares.map((fw) => {
const isSel = selected?.id === fw.id
return (
<tr
key={fw.id}
onClick={() => setSelected(fw)}
style={{ borderBottom: '1px solid var(--color-border)', cursor: 'pointer', transition: 'background-color 0.1s', backgroundColor: isSel ? 'var(--color-info-bg)' : '' }}
onMouseEnter={(e) => { if (!isSel) e.currentTarget.style.backgroundColor = 'var(--color-bg-elevated)' }}
onMouseLeave={(e) => { if (!isSel) e.currentTarget.style.backgroundColor = '' }}
>
<td style={{ padding: 'var(--space-2) var(--space-3)', fontFamily: 'var(--font-family-mono)', fontSize: 'var(--font-size-xs)', color: 'var(--color-text-primary)' }}>{fw.bespoke_uid || '—'}</td>
<td style={{ padding: 'var(--space-2) var(--space-3)', fontFamily: 'var(--font-family-mono)', fontSize: 'var(--font-size-xs)', color: 'var(--color-text-secondary)' }}>{fw.version}</td>
<td style={{ padding: 'var(--space-2) var(--space-3)', fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>{fw.channel}</td>
<td style={{ padding: 'var(--space-2) var(--space-3)', fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>{fw.size_bytes ? `${(fw.size_bytes / 1024).toFixed(1)} KB` : '—'}</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
<div>
<label style={{ display: 'block', fontSize: 'var(--font-size-sm)', fontWeight: 'var(--font-weight-medium)', color: 'var(--color-text-secondary)', marginBottom: 'var(--space-1)' }}>
Hardware Family for NVS
</label>
<p style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', marginBottom: 'var(--space-2)' }}>
Written to NVS as <span style={{ fontFamily: 'var(--font-family-mono)' }}>hw_type</span>.
</p>
<input
type="text"
value={hwFamily}
onChange={(e) => setHwFamily(e.target.value)}
placeholder="e.g. vesper_plus"
style={{ width: '100%', padding: 'var(--space-2) var(--space-3)', borderRadius: 'var(--radius-md)', border: '1px solid var(--color-border-strong)', backgroundColor: 'var(--color-bg-input)', color: 'var(--color-text-primary)', fontSize: 'var(--font-size-sm)' }}
/>
</div>
</div>
</Modal>
)
}
// ─── Step 0: Mode Picker ──────────────────────────────────────────────────────
function StepMode({ onPick }) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-5)' }}>
<div>
<h2 style={{ fontSize: 'var(--font-size-lg)', fontFamily: 'var(--font-family-display)', fontWeight: 'var(--font-weight-semibold)', color: 'var(--color-text-primary)', marginBottom: 'var(--space-1)' }}>
What would you like to do?
</h2>
<p style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)' }}>
Choose how to start the provisioning process.
</p>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-4)' }}>
{/* Flash Existing */}
<button
type="button"
onClick={() => onPick('existing')}
style={{
backgroundColor: 'var(--color-bg-elevated)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-lg)',
padding: 'var(--space-5)',
textAlign: 'left', cursor: 'pointer',
transition: 'border-color 0.15s, box-shadow 0.15s',
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = 'var(--color-primary)'
e.currentTarget.style.boxShadow = '0 0 16px rgba(34,197,94,0.18)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'var(--color-border)'
e.currentTarget.style.boxShadow = 'none'
}}
>
<div style={{ width: 40, height: 40, borderRadius: 'var(--radius-lg)', backgroundColor: 'var(--color-primary-subtle)', border: '1px solid var(--color-primary)', display: 'flex', alignItems: 'center', justifyContent: 'center', marginBottom: 'var(--space-3)' }}>
<svg width="18" height="18" fill="none" stroke="var(--color-primary)" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<p style={{ fontSize: 'var(--font-size-md)', fontWeight: 'var(--font-weight-semibold)', color: 'var(--color-text-primary)', marginBottom: 'var(--space-1)' }}>Flash Existing</p>
<p style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)', lineHeight: 1.5 }}>
Re-flash a device already in inventory — manufactured, flashed, or provisioned.
</p>
</button>
{/* Deploy New Device */}
<button
type="button"
onClick={() => onPick('new')}
style={{
backgroundColor: 'var(--color-bg-elevated)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-lg)',
padding: 'var(--space-5)',
textAlign: 'left', cursor: 'pointer',
transition: 'border-color 0.15s, box-shadow 0.15s',
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = 'var(--color-success)'
e.currentTarget.style.boxShadow = '0 0 16px rgba(34,197,94,0.18)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'var(--color-border)'
e.currentTarget.style.boxShadow = 'none'
}}
>
<div style={{ width: 40, height: 40, borderRadius: 'var(--radius-lg)', backgroundColor: 'var(--color-success-bg)', border: '1px solid var(--color-success)', display: 'flex', alignItems: 'center', justifyContent: 'center', marginBottom: 'var(--space-3)' }}>
<svg width="18" height="18" fill="none" stroke="var(--color-success)" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
</div>
<p style={{ fontSize: 'var(--font-size-md)', fontWeight: 'var(--font-weight-semibold)', color: 'var(--color-text-primary)', marginBottom: 'var(--space-1)' }}>Deploy New Device</p>
<p style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)', lineHeight: 1.5 }}>
Generate a new serial number, select board type and revision, then flash and provision.
</p>
</button>
</div>
</div>
)
}
// ─── Step 1: Select / Create Device ──────────────────────────────────────────
function StepSelectDevice({ mode, preloadSn, onSelected, onCreatedSn }) {
const [search, setSearch] = useState(preloadSn || '')
const [results, setResults] = useState([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [picked, setPicked] = useState(null)
// New device form
const [boardType, setBoardType] = useState(null)
const [boardVersion, setBoardVersion] = useState('1.0')
const [creating, setCreating] = useState(false)
const [showBespoke, setShowBespoke] = useState(false)
const doSearch = useCallback(async (q) => {
setLoading(true); setError('')
try {
const params = new URLSearchParams({ limit: '50' })
if (q) params.set('search', q)
const data = await api.get(`/manufacturing/devices?${params}`)
const all = data.devices || []
setResults(all.filter((d) => FLASHABLE_STATUSES.includes(d.mfg_status)))
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}, [])
useEffect(() => { if (mode === 'existing') doSearch(preloadSn || '') }, [mode, doSearch, preloadSn])
const handleCreate = async () => {
if (!boardType) return
setCreating(true); setError('')
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)
const device = await api.get(`/manufacturing/devices/${sn}`)
onSelected(device)
} catch (err) {
setError(err.message)
} finally {
setCreating(false)
}
}
const handleBespokeConfirm = async ({ firmware, hwFamily }) => {
setShowBespoke(false)
setCreating(true); setError('')
try {
const batch = await api.post('/manufacturing/batch', { board_type: 'vesper', board_version: '1.0', quantity: 1 })
const sn = batch.serial_numbers[0]
onCreatedSn(sn)
const device = await api.get(`/manufacturing/devices/${sn}`)
onSelected(device, { firmware, hwFamily })
} catch (err) {
setError(err.message)
} finally {
setCreating(false)
}
}
// ── Selected confirmation panel ──
if (picked) {
const boardInfo = BOARD_TYPE_MAP[picked.hw_type]
const pal = BOARD_FAMILY_COLORS[boardInfo?.family || 'vesper']
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' }}>
<div style={{ padding: 'var(--space-4)', backgroundColor: 'var(--color-bg-elevated)', border: `1px solid ${pal.selectedBorder}`, borderRadius: 'var(--radius-lg)' }}>
<p style={{ fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-semibold)', color: pal.selectedText, textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)', marginBottom: 'var(--space-3)' }}>Device Selected</p>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-3)' }}>
<InfoCell label="Serial Number" value={picked.serial_number} mono />
<InfoCell label="Board Type" value={BOARD_TYPE_LABELS[picked.hw_type] || picked.hw_type} />
<InfoCell label="HW Revision" value={formatHwVersion(picked.hw_version)} />
<InfoCell label="Status" value={<StatusBadge variant={STATUS_VARIANT[picked.mfg_status] || 'neutral'}>{picked.mfg_status}</StatusBadge>} />
</div>
</div>
<div style={{ display: 'flex', gap: 'var(--space-3)' }}>
<Button variant="primary" onClick={() => onSelected(picked)}>
Continue to Flash <Icon name="arrow-right" size={14} />
</Button>
<Button variant="ghost" onClick={() => setPicked(null)}>Change Device</Button>
</div>
</div>
)
}
// ── Deploy New form ──
if (mode === 'new') {
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')
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-5)' }}>
{/* Board type */}
<div>
<p style={{ fontSize: 'var(--font-size-sm)', fontWeight: 'var(--font-weight-semibold)', color: 'var(--color-text-secondary)', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)', marginBottom: 'var(--space-3)' }}>Board Type</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-2)' }}>
{/* Vesper row — 3 equal columns */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 'var(--space-2)' }}>
{vesperBoards.map((bt) => (
<BoardTile key={bt.value} bt={bt} isSelected={boardType === bt.value} onClick={() => setBoardType(bt.value)} />
))}
</div>
{/* Agnus row — 2 boards centered inside the same 3-col grid */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 'var(--space-2)' }}>
<div style={{ gridColumn: '1 / 3', display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-2)' }}>
{agnusBoards.map((bt) => (
<BoardTile key={bt.value} bt={bt} isSelected={boardType === bt.value} onClick={() => setBoardType(bt.value)} />
))}
</div>
</div>
{/* Chronos row — 2 boards centered inside the same 3-col grid */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 'var(--space-2)' }}>
<div style={{ gridColumn: '1 / 3', display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-2)' }}>
{chronosBoards.map((bt) => (
<BoardTile key={bt.value} bt={bt} isSelected={boardType === bt.value} onClick={() => setBoardType(bt.value)} />
))}
</div>
</div>
</div>
</div>
{/* Board revision — narrow */}
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 'var(--space-4)' }}>
<div>
<label style={{ display: 'block', fontSize: 'var(--font-size-sm)', fontWeight: 'var(--font-weight-medium)', color: 'var(--color-text-secondary)', marginBottom: 'var(--space-1)' }}>
Board Revision
</label>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)' }}>
<span style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)' }}>Rev</span>
<input
type="text"
value={boardVersion}
onChange={(e) => setBoardVersion(e.target.value)}
placeholder="1.0"
style={{
width: 72, padding: 'var(--space-2) var(--space-2)',
borderRadius: 'var(--radius-md)', border: '1px solid var(--color-border-strong)',
backgroundColor: 'var(--color-bg-input)', color: 'var(--color-text-primary)',
fontSize: 'var(--font-size-sm)', textAlign: 'center',
}}
/>
</div>
<p style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', marginTop: 'var(--space-1)' }}>e.g. 1.0, 1.20</p>
</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 'var(--space-2)', marginLeft: 'auto' }}>
<ErrorBox msg={error} />
<Button variant="primary" onClick={handleCreate} loading={creating} disabled={!boardType}>
Generate Serial &amp; Continue <Icon name="arrow-right" size={14} />
</Button>
</div>
</div>
{/* Bespoke divider */}
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-3)' }}>
<div style={{ flex: 1, height: 1, backgroundColor: 'var(--color-border)' }} />
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>or</span>
<div style={{ flex: 1, height: 1, backgroundColor: 'var(--color-border)' }} />
</div>
{/* Bespoke option */}
<div style={{ padding: 'var(--space-4)', backgroundColor: 'var(--color-bg-elevated)', border: '1px solid var(--color-border)', borderRadius: 'var(--radius-lg)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--space-4)' }}>
<div>
<p style={{ fontSize: 'var(--font-size-sm)', fontWeight: 'var(--font-weight-semibold)', color: 'var(--color-text-primary)', marginBottom: 'var(--space-1)' }}>Select Bespoke Firmware</p>
<p style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>
Flash a one-off bespoke firmware with a custom hardware family written to NVS.
</p>
</div>
<Button variant="secondary" onClick={() => setShowBespoke(true)} style={{ flexShrink: 0 }}>
Select Bespoke →
</Button>
</div>
<BespokePickerModal open={showBespoke} onConfirm={handleBespokeConfirm} onClose={() => setShowBespoke(false)} />
</div>
)
}
// ── Existing device search ──
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' }}>
<SearchBar
value={search}
onChange={(v) => { setSearch(v); doSearch(v) }}
placeholder="Search serial, batch, type…"
/>
{error && <ErrorBox msg={error} />}
{loading ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: 'var(--space-8)' }}><Spinner /></div>
) : results.length === 0 ? (
<p style={{ textAlign: 'center', padding: 'var(--space-8)', fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)' }}>No flashable devices found.</p>
) : (
<div style={{ border: '1px solid var(--color-border)', borderRadius: 'var(--radius-lg)', overflow: 'hidden' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ backgroundColor: 'var(--color-bg-elevated)', borderBottom: '1px solid var(--color-border)' }}>
{['Serial', 'Type', 'Revision', 'Status'].map((h) => (
<th key={h} style={{ padding: 'var(--space-2) var(--space-3)', textAlign: 'left', fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-semibold)', color: 'var(--color-text-muted)', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)' }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{results.map((d, i) => (
<tr
key={d.id}
onClick={() => setPicked(d)}
style={{ borderBottom: i < results.length - 1 ? '1px solid var(--color-border)' : 'none', cursor: 'pointer', transition: 'background-color 0.1s' }}
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = 'var(--color-bg-elevated)' }}
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = '' }}
>
<td style={{ padding: 'var(--space-3)', fontFamily: 'var(--font-family-mono)', fontSize: 'var(--font-size-sm)', color: 'var(--color-text-primary)' }}>{d.serial_number}</td>
<td style={{ padding: 'var(--space-3)', fontSize: 'var(--font-size-sm)', color: 'var(--color-text-secondary)' }}>{BOARD_TYPE_LABELS[d.hw_type] || d.hw_type}</td>
<td style={{ padding: 'var(--space-3)', fontFamily: 'var(--font-family-mono)', fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)' }}>{formatHwVersion(d.hw_version)}</td>
<td style={{ padding: 'var(--space-3)' }}>
<StatusBadge variant={STATUS_VARIANT[d.mfg_status] || 'neutral'}>{d.mfg_status}</StatusBadge>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)
}
// ─── Serial Log Modal ──────────────────────────────────────────────────────────
// Reuses SerialLogPanel (level filters, search, live indicator — same as the
// Verify step's serial card) inside a near-fullscreen modal, since this is
// the "full history so far" view rather than a small at-a-glance card.
function SerialLogModal({ open, onClose, logs, monitoring }) {
if (!open) return null
return (
<>
{/* .modal-body is a plain padded/scrolling block by default (the shape
every other modal's content wants). This instance needs it to be a
non-scrolling flex column instead, so SerialLogPanel's own internal
scroll area is the only thing that scrolls and can stretch to fill
the near-fullscreen modal. */}
<style>{`
.serial-log-modal .modal-body {
display: flex; flex-direction: column;
overflow: hidden; min-height: 0;
}
`}</style>
<Modal
open={open}
onClose={onClose}
title="Serial Output Logs"
size="xxl"
className="serial-log-modal"
footer={
<div style={{ display: 'flex', justifyContent: 'flex-end', width: '100%' }}>
<Button variant="secondary" onClick={onClose}>Close</Button>
</div>
}
>
<SerialLogPanel lines={logs} monitoring={monitoring} />
</Modal>
</>
)
}
// ─── Step 2: Flash ────────────────────────────────────────────────────────────
function StepFlash({ device, bespokeOverride, onFlashed, serialConn }) {
const { portRef, portConnected, portName, requestPort, disconnect: disconnectPort, startMonitor, appendLine: appendSerial, clearLines: clearSerialLines } = serialConn
const [connecting, setConnecting] = useState(false)
const [flashing, setFlashing] = useState(false)
const [done, setDone] = useState(false)
const [blProgress, setBlProgress] = useState(0)
const [partProgress, setPartProgress] = useState(0)
const [nvsProgress, setNvsProgress] = useState(0)
const [fwProgress, setFwProgress] = useState(0)
const [log, setLog] = useState([])
const [nvsProfile, setNvsProfile] = useState('current')
const [error, setError] = useState('')
const loaderRef = useRef(null)
const logEndRef = useRef(null)
const infoPanelRef = useRef(null)
// Flash Output must follow the left (info) panel's height one-way — it
// should never influence the left panel's own height in return. CSS Grid's
// `align-items: stretch` computes the row height from BOTH cells' intrinsic
// sizes, so a tall flash log still grows the row (and the left panel with
// it). Measuring the left panel directly and applying that as an explicit
// pixel height on the right panel is the only way to make sizing one-way.
//
// Read getBoundingClientRect().height (the true border-box/visual height),
// NOT ResizeObserver's own contentRect — contentRect always excludes
// border+padding regardless of box-sizing, so applying it as a plain
// `height` on the (border-box) right panel came out 2px short (one
// border-width per side) and made the two cards visibly mismatched.
const [infoPanelHeight, setInfoPanelHeight] = useState(null)
useEffect(() => {
const el = infoPanelRef.current
if (!el) return
const observer = new ResizeObserver(() => {
setInfoPanelHeight(el.getBoundingClientRect().height)
})
observer.observe(el)
return () => observer.disconnect()
}, [])
const appendLog = (msg) => setLog((prev) => [...prev, String(msg)])
// Scrolling on the *next* render (after the DOM has the new line) rather
// than synchronously in the append call — scrolling immediately after
// setLog targets the sentinel's position from BEFORE the new line was
// committed, so the view lands one line short of the actual end.
useEffect(() => { logEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' }) }, [log])
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 handleConnectPort = async () => {
setError(''); setConnecting(true)
try {
await requestPort()
} catch (err) {
setError(err.message || 'Port selection cancelled.')
} finally {
setConnecting(false)
}
}
const handleStartFlash = async () => {
if (!portRef.current) return
setError(''); setLog([]); clearSerialLines()
setBlProgress(0); setPartProgress(0); setNvsProgress(0); setFwProgress(0)
setDone(false)
const port = portRef.current
const sn = device.serial_number
try {
const blUrl = bespokeOverride
? `/api/manufacturing/devices/${sn}/bootloader.bin?hw_type_override=${bespokeOverride.hwFamily}`
: `/api/manufacturing/devices/${sn}/bootloader.bin`
const partUrl = bespokeOverride
? `/api/manufacturing/devices/${sn}/partitions.bin?hw_type_override=${bespokeOverride.hwFamily}`
: `/api/manufacturing/devices/${sn}/partitions.bin`
const nvsUrl = bespokeOverride
? `/api/manufacturing/devices/${sn}/nvs.bin?hw_type_override=${bespokeOverride.hwFamily}&hw_revision_override=1.0&nvs_schema=${nvsProfile}`
: `/api/manufacturing/devices/${sn}/nvs.bin?nvs_schema=${nvsProfile}`
const fwUrl = bespokeOverride
? `/api/firmware/bespoke/${bespokeOverride.firmware.channel}/${bespokeOverride.firmware.version}/firmware.bin`
: `/api/manufacturing/devices/${sn}/firmware.bin`
appendLog('Fetching bootloader binary…')
const blBuffer = await fetchBinary(blUrl); appendLog(`Bootloader: ${blBuffer.byteLength} bytes`)
appendLog('Fetching partition table binary…')
const partBuffer = await fetchBinary(partUrl); appendLog(`Partition table: ${partBuffer.byteLength} bytes`)
appendLog('Fetching NVS binary…')
const nvsBuffer = await fetchBinary(nvsUrl); appendLog(`NVS: ${nvsBuffer.byteLength} bytes`)
appendLog('Fetching firmware binary…')
const fwBuffer = await fetchBinary(fwUrl); appendLog(`Firmware: ${fwBuffer.byteLength} bytes`)
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),
write: (msg) => appendLog(msg),
},
})
await loaderRef.current.main()
appendLog('ESP32 connected.')
await loaderRef.current.writeFlash({
fileArray: [
{ data: arrayBufferToString(blBuffer), address: 0x1000 },
{ data: arrayBufferToString(partBuffer), address: 0x8000 },
{ data: arrayBufferToString(nvsBuffer), address: NVS_ADDRESS },
{ data: arrayBufferToString(fwBuffer), address: FW_ADDRESS },
],
flashSize: 'keep', flashMode: 'keep', flashFreq: 'keep',
eraseAll: false, compress: true,
reportProgress(fileIndex, written, total) {
const pct = (written / total) * 100
if (fileIndex === 0) { setBlProgress(pct) }
else if (fileIndex === 1) { setBlProgress(100); setPartProgress(pct) }
else if (fileIndex === 2) { setPartProgress(100); setNvsProgress(pct) }
else { setNvsProgress(100); setFwProgress(pct) }
},
calculateMD5Hash: () => '',
})
setBlProgress(100); setPartProgress(100); setNvsProgress(100); setFwProgress(100)
appendLog('Flash complete. Resetting device…')
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…')
try { await loaderRef.current.transport.disconnect() } catch (_) {}
appendLog('esptool disconnected. Opening serial monitor…')
await api.request(`/manufacturing/devices/${sn}/status`, {
method: 'PATCH',
body: JSON.stringify({ status: 'flashed', note: 'Flashed via browser provisioning wizard' }),
})
setFlashing(false)
setDone(true)
appendSerial('── Serial monitor started (115200 baud) ──')
startMonitor(port)
} catch (err) {
setError(err.message || String(err))
setFlashing(false)
try { await loaderRef.current?.transport?.disconnect() } catch (_) {}
}
}
const webSerialAvailable = typeof navigator !== 'undefined' && 'serial' in navigator
const busy = connecting || flashing
const boardInfo = BOARD_TYPE_MAP[device.hw_type]
const familyPal = BOARD_FAMILY_COLORS[boardInfo?.family || 'vesper']
const readyToFlash = portConnected && !flashing && !done && log.length === 0
// ── Left panel: device info + controls ────────────────────────────────────
const InfoPanel = (
<div ref={infoPanelRef} style={{
borderRadius: 'var(--radius-lg)',
border: `1px solid ${familyPal.idleBorder}`,
padding: 'var(--space-5)',
backgroundColor: 'var(--color-bg-elevated)',
display: 'flex', flexDirection: 'column',
}}>
{/* Header: title + COM status button */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 'var(--space-4)' }}>
<p style={{ fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-semibold)', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)', color: familyPal.idleText }}>
Device to Flash
</p>
<button
onClick={portConnected ? disconnectPort : undefined}
title={portConnected ? `${portName} — Click to disconnect` : 'No port connected'}
style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '4px 10px', borderRadius: 'var(--radius-md)',
fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-medium)',
cursor: portConnected ? 'pointer' : 'default',
backgroundColor: portConnected ? '#0a2e1a' : 'var(--color-bg-base)',
color: portConnected ? '#4dd6c8' : 'var(--color-text-muted)',
border: `1px solid ${portConnected ? '#4dd6c8' : 'var(--color-border)'}`,
transition: 'opacity 0.15s',
}}
onMouseEnter={(e) => { if (portConnected) e.currentTarget.style.opacity = '0.75' }}
onMouseLeave={(e) => { e.currentTarget.style.opacity = '1' }}
>
<span style={{ width: 8, height: 8, borderRadius: '50%', backgroundColor: portConnected ? '#22c55e' : '#444', display: 'inline-block', flexShrink: 0 }} />
{portConnected ? portName || 'Connected' : 'No Port'}
{portConnected && <span style={{ marginLeft: 2, opacity: 0.6 }}>✕</span>}
</button>
</div>
{/* Device info */}
<div style={{ marginBottom: 'var(--space-4)' }}>
{bespokeOverride ? (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-3)' }}>
<InfoCell label="Serial Number" value={device.serial_number} mono />
<InfoCell label="NVS hw_type" value={bespokeOverride.hwFamily} />
<InfoCell label="NVS hw_revision" value="1.0" />
<div>
<p style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)', fontWeight: 'var(--font-weight-semibold)', marginBottom: 'var(--space-1)' }}>Firmware</p>
<p style={{ fontSize: 'var(--font-size-xs)', fontFamily: 'var(--font-family-mono)', color: '#fb923c' }}>BESPOKE · {bespokeOverride.firmware.bespoke_uid}</p>
<p style={{ fontSize: 'var(--font-size-xs)', fontFamily: 'var(--font-family-mono)', color: 'var(--color-text-muted)', marginTop: 2 }}>v{bespokeOverride.firmware.version} / {bespokeOverride.firmware.channel}</p>
</div>
</div>
) : (
<>
{/* Row 1: serial number + status badge */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 'var(--space-2)' }}>
<p style={{ fontFamily: 'var(--font-family-mono)', fontSize: 'var(--font-size-md)', fontWeight: 'var(--font-weight-semibold)', color: 'var(--color-text-primary)' }}>
{device.serial_number}
</p>
<StatusBadge variant={STATUS_VARIANT[device.mfg_status] || 'neutral'}>{device.mfg_status}</StatusBadge>
</div>
{/* Row 2: board type + codename */}
<p style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)', marginBottom: 'var(--space-1)' }}>
Board Type:
<span style={{ display: 'inline-block', width: 'var(--space-3)' }} />
<span style={{ fontFamily: 'var(--font-family-mono)', fontSize: 'var(--font-size-md)', fontWeight: 'var(--font-weight-semibold)', color: 'var(--color-primary)' }}>
{boardInfo?.uiName || boardInfo?.name || device.hw_type}
</span>
{boardInfo?.codename && (
<span style={{ fontFamily: 'var(--font-family-mono)', color: 'var(--color-text-muted)', fontSize: 'var(--font-size-xs)', marginLeft: 'var(--space-2)' }}>| {boardInfo.codename}</span>
)}
</p>
{/* Row 3: revision */}
<p style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)' }}>
Revision:
<span style={{ display: 'inline-block', width: 'var(--space-3)' }} />
<span style={{ fontFamily: 'var(--font-family-mono)', fontSize: 'var(--font-size-md)', fontWeight: 'var(--font-weight-semibold)', color: 'var(--color-primary)' }}>
{formatHwVersion(device.hw_version)}
</span>
</p>
</>
)}
</div>
{/* NVS profile toggle */}
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)', marginBottom: 'var(--space-3)' }}>
<div style={{ display: 'flex', borderRadius: 'var(--radius-md)', border: '1px solid var(--color-border)', overflow: 'hidden' }}>
{[['current', 'Current Gen NVS'], ['legacy', 'Legacy NVS']].map(([val, lbl]) => (
<button
key={val}
type="button"
onClick={() => setNvsProfile(val)}
style={{
padding: '2px 10px',
fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-medium)',
cursor: 'pointer', border: 'none',
backgroundColor: nvsProfile === val ? 'var(--color-primary)' : 'var(--color-bg-elevated)',
color: nvsProfile === val ? 'var(--color-bg-base)' : 'var(--color-text-muted)',
transition: 'background-color 0.15s, color 0.15s',
}}
>{lbl}</button>
))}
</div>
</div>
{/* Web Serial warning — space always reserved so its appearance never
shifts panel height; hidden via visibility, not removed from flow. */}
<div style={{
padding: 'var(--space-3)', borderRadius: 'var(--radius-md)', marginBottom: 'var(--space-3)',
backgroundColor: 'var(--color-warning-bg)', border: '1px solid var(--color-warning)',
color: 'var(--color-warning)', fontSize: 'var(--font-size-sm)',
visibility: webSerialAvailable ? 'hidden' : 'visible',
}} aria-hidden={webSerialAvailable}>
Web Serial API not available. Use Chrome or Edge on a desktop system.
</div>
{/* Error box — same reserved-space treatment. */}
<div style={{ marginBottom: 'var(--space-3)', visibility: error ? 'visible' : 'hidden' }} aria-hidden={!error}>
<ErrorBox msg={error || ' '} />
</div>
{/* Progress bars — always visible, idle at 0% */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-3)', marginBottom: 'var(--space-3)' }}>
<div style={{ display: 'flex', gap: 'var(--space-4)' }}>
<ProgressBar flex label="Bootloader (0x1000)" percent={blProgress} />
<ProgressBar flex label="Partition Table (0x8000)" percent={partProgress} />
</div>
<ProgressBar label="NVS (0x9000)" percent={nvsProgress} />
<ProgressBar label="Firmware (0x10000)" percent={fwProgress} />
</div>
{/* Spacer */}
<div style={{ flex: 1 }} />
{/* Bottom bar */}
<div style={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', gap: 'var(--space-3)', paddingTop: 'var(--space-3)', marginTop: 'var(--space-2)' }}>
{/* Left: status hint — the "ready/flashing" line always occupies its
slot (visibility toggle, not conditional render) so this column's
height never changes as flash state advances. */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<div
style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)', fontSize: 'var(--font-size-xs)', color: flashing ? 'var(--color-text-muted)' : '#4dd6c8' }}
aria-hidden={!(readyToFlash || flashing)}
>
<span style={{
width: 7, height: 7, borderRadius: '50%', display: 'inline-block',
backgroundColor: '#22c55e',
visibility: readyToFlash ? 'visible' : 'hidden',
}} />
<span style={{ visibility: readyToFlash || flashing ? 'visible' : 'hidden' }}>
{flashing ? 'Flashing — do not disconnect…' : 'Ready to flash.'}
</span>
</div>
<p style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', opacity: 0.5 }}>
NVS 0x9000 · FW 0x10000 · {FLASH_BAUD} baud
</p>
</div>
{/* Right: action buttons — every possible button always renders (in a
fixed order/slot) so the row's height and the buttons' own sizes
never shift; only the applicable ones are visible at a time. */}
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)', minHeight: 36 }}>
<span style={{ display: busy ? 'inline-flex' : 'none', alignItems: 'center' }}>
<Spinner size="sm" />
</span>
<span style={{ display: !busy && !portConnected ? 'inline-flex' : 'none' }}>
<Button variant="secondary" onClick={handleConnectPort} disabled={!webSerialAvailable}>
Select COM Port
</Button>
</span>
<span style={{ display: !busy && portConnected && done ? 'inline-flex' : 'none' }}>
<Button variant="ghost" onClick={handleStartFlash}>Flash Again</Button>
</span>
<span style={{ display: !busy && portConnected && !done ? 'inline-flex' : 'none' }}>
<Button variant="primary" onClick={handleStartFlash}>
Start Flashing
</Button>
</span>
<span style={{ display: !busy && done ? 'inline-flex' : 'none' }}>
<Button variant="primary" onClick={onFlashed}>
Proceed to Verify <Icon name="arrow-right" size={14} />
</Button>
</span>
</div>
</div>
</div>
)
// ── Right panel: flash output log ─────────────────────────────────────────
// Height is pinned to the measured InfoPanel height (one-way: left drives,
// right follows) — see infoPanelHeight/ResizeObserver above. minHeight: 0
// lets the log body's flex: 1 + overflowY: auto scroll internally within
// that fixed height instead of growing the panel (and the row) with it.
const FlashOutputPanel = (
<div style={{
borderRadius: 'var(--radius-lg)',
border: '1px solid var(--color-border)',
overflow: 'hidden',
display: 'flex', flexDirection: 'column',
height: infoPanelHeight || undefined,
minHeight: 0,
}}>
<div style={{
padding: '8px 12px',
fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-semibold)',
textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)',
color: 'var(--color-text-muted)',
backgroundColor: 'var(--color-bg-elevated)',
borderBottom: '1px solid var(--color-border)',
flexShrink: 0,
}}>
Flash Output
</div>
<div style={{
flex: 1, minHeight: 0,
padding: 'var(--space-3)',
overflowY: 'auto',
fontFamily: 'var(--font-family-mono)',
fontSize: 'var(--font-size-xs)',
backgroundColor: 'var(--color-bg-base)',
color: 'var(--color-text-secondary)',
lineHeight: 1.6,
}}>
{log.length === 0
? <span style={{ color: 'var(--color-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: 'var(--space-3)' }}>
{/* Info panel (left) | Flash output (right) — align-items: start so the
row's height is never driven by the (explicitly-sized) right panel,
only ever by the left panel's natural content height. */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-3)', alignItems: 'start' }}>
{InfoPanel}
{FlashOutputPanel}
</div>
</div>
)
}
// ─── Serial Log Panel (Verify step's right panel) ─────────────────────────────
// Same stepped-severity filter pattern as LogsTab (frontend/src/pages/bellcloud/
// devices/tabs/LogsTab.jsx), applied to raw serial lines instead of MQTT rows.
const SERIAL_LEVEL_STEPS = [
{ value: '', label: 'ALL', color: 'var(--color-text-muted)' },
{ value: 'INFO', label: 'INFO', color: 'var(--color-info)' },
{ value: 'WARN', label: 'WARN', color: 'var(--color-warning)' },
{ value: 'ERROR', label: 'ERROR', color: 'var(--color-danger)' },
]
const SERIAL_LEVEL_RANK = { VERBOSE: -1, DEBUG: 0, INFO: 1, WARN: 2, ERROR: 3 }
const SERIAL_LEVEL_COLORS = {
ERROR: { text: 'var(--color-danger)', bg: 'var(--color-danger-bg)' },
WARN: { text: 'var(--color-warning)', bg: 'var(--color-warning-bg)' },
INFO: { text: 'var(--color-info)', bg: 'var(--color-info-bg)' },
DEBUG: { text: 'var(--color-text-muted)', bg: 'var(--color-bg-base)' },
VERBOSE: { text: 'var(--color-text-muted)', bg: 'var(--color-bg-base)' },
}
function SerialLogPanel({ lines, monitoring, height }) {
const [levelStep, setLevelStep] = useState(0)
const [searchText, setSearchText] = useState('')
const [autoScroll, setAutoScroll] = useState(true)
const containerRef = useRef(null)
const levelFilter = SERIAL_LEVEL_STEPS[levelStep].value
const parsed = useMemo(() => lines.map((raw) => ({ raw, ...parseSerialLine(raw) })), [lines])
const filtered = useMemo(() => parsed.filter((l) => {
if (levelFilter) {
const rank = l.level ? SERIAL_LEVEL_RANK[l.level] : -1
if (rank < SERIAL_LEVEL_RANK[levelFilter]) return false
}
if (searchText && !l.raw.toLowerCase().includes(searchText.toLowerCase())) return false
return true
}), [parsed, levelFilter, searchText])
const endRef = useRef(null)
useEffect(() => {
if (autoScroll) endRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' })
}, [filtered, autoScroll])
return (
<div style={{
borderRadius: 'var(--radius-lg)', border: '1px solid var(--color-border)',
overflow: 'hidden', display: 'flex', flexDirection: 'column',
height: height || undefined,
flex: height ? undefined : 1,
minHeight: 0,
}}>
<div style={{
display: 'flex', alignItems: 'center', gap: 'var(--space-3)', flexWrap: 'wrap',
padding: '8px 12px', backgroundColor: 'var(--color-bg-elevated)',
borderBottom: '1px solid var(--color-border)', flexShrink: 0,
}}>
<span style={{
fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-semibold)',
textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)', color: 'var(--color-text-muted)',
}}>
Serial Output
</span>
{monitoring && (
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<span style={{ width: 6, height: 6, borderRadius: '50%', backgroundColor: 'var(--color-success)', boxShadow: '0 0 6px var(--color-success)', display: 'inline-block' }} />
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-success)', fontWeight: 'var(--font-weight-semibold)' }}>LIVE</span>
</span>
)}
<div style={{ flex: 1 }} />
<div style={{ display: 'flex', gap: 'var(--space-1)' }}>
{SERIAL_LEVEL_STEPS.map((step, idx) => (
<button
key={step.value}
onClick={() => setLevelStep(idx)}
title={step.label}
style={{
height: 22, paddingInline: 'var(--space-2)', borderRadius: 'var(--radius-sm)',
border: `1px solid ${levelStep === idx ? step.color : 'var(--color-border)'}`,
background: levelStep === idx ? `${step.color}22` : 'transparent',
color: levelStep === idx ? step.color : 'var(--color-text-muted)',
fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-semibold)',
letterSpacing: 'var(--tracking-wide)', cursor: 'pointer',
}}
>{step.label}</button>
))}
</div>
<input
type="text"
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
placeholder="Filter…"
style={{
height: 22, padding: '0 var(--space-2)', borderRadius: 'var(--radius-sm)',
border: '1px solid var(--color-border-strong)', backgroundColor: 'var(--color-bg-base)',
color: 'var(--color-text-primary)', fontSize: 'var(--font-size-xs)', width: 120, outline: 'none',
}}
/>
<button
onClick={() => setAutoScroll((v) => !v)}
style={{
height: 22, paddingInline: 'var(--space-2)', borderRadius: 'var(--radius-sm)',
border: `1px solid ${autoScroll ? 'var(--color-primary)' : 'var(--color-border-strong)'}`,
background: autoScroll ? 'var(--color-primary-subtle)' : 'transparent',
color: autoScroll ? 'var(--color-primary)' : 'var(--color-text-muted)',
fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-semibold)', cursor: 'pointer',
}}
>AUTO-SCROLL</button>
</div>
<div ref={containerRef} style={{
flex: 1, minHeight: 0, padding: 'var(--space-3)', overflowY: 'auto',
fontFamily: 'var(--font-family-mono)', fontSize: 'var(--font-size-xs)',
backgroundColor: 'var(--color-bg-base)', color: 'var(--color-text-secondary)', lineHeight: 1.6,
}}>
{filtered.length === 0 ? (
<span style={{ color: 'var(--color-text-muted)', opacity: 0.5 }}>
{lines.length === 0 ? 'No serial output yet.' : 'No lines match this filter.'}
</span>
) : filtered.map((l, i) => {
const s = l.level ? SERIAL_LEVEL_COLORS[l.level] : null
return (
<div key={i} style={{ display: 'flex', gap: 6, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
{l.level && (
<span style={{
flexShrink: 0, display: 'inline-block', padding: '0 4px', borderRadius: 'var(--radius-sm)',
fontSize: '10px', fontWeight: 'var(--font-weight-semibold)', background: s.bg, color: s.text,
letterSpacing: 'var(--tracking-wide)', height: 15, lineHeight: '15px', marginTop: 2,
}}>{l.level}</span>
)}
{l.tag && <span style={{ flexShrink: 0, color: 'var(--color-text-muted)' }}>[{l.tag}]</span>}
<span style={{ color: s ? s.text : 'var(--color-text-secondary)' }}>{l.message}</span>
</div>
)
})}
<div ref={endRef} />
</div>
</div>
)
}
// ─── Step 3: Verify ───────────────────────────────────────────────────────────
function StepVerify({ device, onVerified, serialConn }) {
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)
const statusPanelRef = useRef(null)
const [statusPanelHeight, setStatusPanelHeight] = useState(null)
// getBoundingClientRect().height (border-box/visual), not contentRect —
// see the matching note on StepFlash's infoPanelHeight above.
useEffect(() => {
const el = statusPanelRef.current
if (!el) return
const observer = new ResizeObserver(() => {
setStatusPanelHeight(el.getBoundingClientRect().height)
})
observer.observe(el)
return () => observer.disconnect()
}, [])
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]
// received_at already comes back as a full ISO-8601 string with an
// explicit UTC offset (e.g. "2026-07-14T16:03:21+00:00") — see
// backend/database/pg_mqtt.py::_row_to_dict. Parsing it directly is
// correct; appending a literal "Z" (the old code) turns it into an
// invalid string like "...+00:00Z", so Date.parse always returned
// NaN and a live device could never be detected as verified.
const receivedMs = latest.received_at ? Date.parse(latest.received_at) : 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' })
}
}
} 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])
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)
}
const StatusPanel = (
<div ref={statusPanelRef} style={{ padding: 'var(--space-5)', backgroundColor: 'var(--color-bg-elevated)', border: '1px solid var(--color-border)', borderRadius: 'var(--radius-lg)', display: 'flex', flexDirection: 'column' }}>
<p style={{ fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-semibold)', color: 'var(--color-text-muted)', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)', marginBottom: 'var(--space-4)' }}>
Waiting for Device
</p>
{polling && !verified && (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', padding: 'var(--space-6) 0', gap: 'var(--space-4)' }}>
<Spinner size="lg" />
<p style={{ fontSize: 'var(--font-size-sm)', textAlign: 'center', color: 'var(--color-text-secondary)' }}>
Waiting for device to connect…<br />
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>
Power cycle the device and ensure it can reach the MQTT broker.
</span>
</p>
<Button variant="ghost" onClick={stopPolling}>Stop</Button>
</div>
)}
{verified && heartbeatData && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-3)' }}>
<div style={{ width: 32, height: 32, borderRadius: '50%', backgroundColor: '#0a2e2a', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Icon name="check" size={16} color="#4dd6c8" />
</div>
<p style={{ fontSize: 'var(--font-size-sm)', fontWeight: 'var(--font-weight-semibold)', color: '#4dd6c8' }}>Device is live!</p>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-3)', padding: 'var(--space-3)', backgroundColor: 'var(--color-bg-base)', borderRadius: 'var(--radius-md)' }}>
<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>
)}
{timedOut && !verified && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)', paddingTop: 'var(--space-4)' }}>
<div style={{ padding: 'var(--space-3)', borderRadius: 'var(--radius-md)', backgroundColor: 'var(--color-warning-bg)', border: '1px solid var(--color-warning)', color: 'var(--color-warning)', fontSize: 'var(--font-size-sm)', textAlign: 'center' }}>
Timed out after {VERIFY_TIMEOUT_MS / 1000}s. Check WiFi credentials and MQTT broker connectivity.
</div>
<Button variant="primary" onClick={startPolling}>Retry Verification</Button>
</div>
)}
{error && !timedOut && !verified && (
<div style={{ marginTop: 'var(--space-3)' }}><ErrorBox msg={`Poll error (will retry): ${error}`} /></div>
)}
<div style={{ flex: 1 }} />
<p style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', paddingTop: 'var(--space-3)' }}>
Polling every {VERIFY_POLL_MS / 1000}s · timeout {VERIFY_TIMEOUT_MS / 1000}s
</p>
</div>
)
return (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-3)', alignItems: 'start' }}>
{StatusPanel}
<SerialLogPanel lines={serialConn.lines} monitoring={serialConn.monitoring} height={statusPanelHeight} />
</div>
)
}
// ─── Step 4: Done ─────────────────────────────────────────────────────────────
function StepDone({ device, onProvisionNext, serialConn }) {
const navigate = useNavigate()
const [showSerialLogs, setShowSerialLogs] = useState(false)
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' }}>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', padding: 'var(--space-6) var(--space-4)', gap: 'var(--space-4)' }}>
<div style={{ width: 64, height: 64, borderRadius: '50%', backgroundColor: '#0a2e2a', border: '2px solid #4dd6c8', display: 'flex', alignItems: 'center', justifyContent: 'center', boxShadow: '0 0 24px rgba(77,214,200,0.3)' }}>
<Icon name="check" size={28} color="#4dd6c8" />
</div>
<div style={{ textAlign: 'center' }}>
<h3 style={{ fontSize: 'var(--font-size-lg)', fontFamily: 'var(--font-family-display)', fontWeight: 'var(--font-weight-bold)', color: 'var(--color-text-primary)', marginBottom: 'var(--space-1)' }}>
Device Provisioned
</h3>
<p style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)' }}>
{device?.serial_number} is live.
</p>
</div>
</div>
{/* Device summary grid */}
<div style={{ borderRadius: 'var(--radius-lg)', overflow: 'hidden', border: '1px solid var(--color-border)', backgroundColor: 'var(--color-bg-elevated)' }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', borderBottom: '1px solid var(--color-border)' }}>
<div style={{ padding: 'var(--space-4)', borderRight: '1px solid var(--color-border)' }}>
<p style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)', marginBottom: 'var(--space-1)' }}>Serial Number</p>
<p style={{ fontSize: 'var(--font-size-sm)', fontFamily: 'var(--font-family-mono)', color: 'var(--color-text-primary)' }}>{device?.serial_number}</p>
</div>
<div style={{ padding: 'var(--space-4)', display: 'flex', flexDirection: 'column', alignItems: 'flex-end' }}>
<p style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)', marginBottom: 'var(--space-1)' }}>Status</p>
<StatusBadge variant={STATUS_VARIANT[device?.mfg_status] || 'neutral'}>{device?.mfg_status}</StatusBadge>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr' }}>
<div style={{ padding: 'var(--space-4)', borderRight: '1px solid var(--color-border)' }}>
<p style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)', marginBottom: 'var(--space-1)' }}>Board Type</p>
<p style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-primary)' }}>{BOARD_TYPE_LABELS[device?.hw_type] || device?.hw_type}</p>
</div>
<div style={{ padding: 'var(--space-4)', display: 'flex', flexDirection: 'column', alignItems: 'flex-end' }}>
<p style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)', marginBottom: 'var(--space-1)' }}>HW Version</p>
<p style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-primary)' }}>{formatHwVersion(device?.hw_version)}</p>
</div>
</div>
</div>
<p style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-secondary)', textAlign: 'center' }}>
Onboard this device now, or stop here and provision another.
</p>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 'var(--space-3)', justifyContent: 'center' }}>
<Button
variant="primary"
onClick={() => navigate(`/devices/onboard?sn=${encodeURIComponent(device?.serial_number || '')}`)}
>
Onboard This Device
</Button>
<Button variant="secondary" onClick={onProvisionNext}>
Provision Next Device
</Button>
<Button variant="ghost" onClick={() => navigate(`/manufacturing/devices/${device?.serial_number}`)}>
View in Inventory
</Button>
<Button variant="ghost" onClick={() => setShowSerialLogs(true)}>
View Serial Logs
</Button>
</div>
<SerialLogModal open={showSerialLogs} onClose={() => setShowSerialLogs(false)} logs={serialConn.lines} monitoring={serialConn.monitoring} />
</div>
)
}
// ─── Main Wizard ──────────────────────────────────────────────────────────────
// Steps: 0=Mode 1=Select/Create device 2=Flash 3=Verify 4=Done
export default function ProvisioningWizard() {
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const preloadSn = searchParams.get('sn') || ''
const [step, setStep] = useState(preloadSn ? 1 : 0)
const [mode, setMode] = useState(preloadSn ? 'existing' : null)
const [device, setDevice] = useState(null)
const [bespokeOverride, setBespokeOverride] = useState(null)
const createdSnRef = useRef(null)
// Owned here (not inside StepFlash) so the same live WebSerial session —
// and the log lines it has accumulated — survives the Flash → Verify step
// transition instead of being torn down when StepFlash unmounts.
const serialConn = useSerialConnection()
useEffect(() => () => { serialConn.disconnect() }, []) // eslint-disable-line react-hooks/exhaustive-deps
const handleModePick = (m) => { setMode(m); setStep(1) }
const handleDeviceSelected = (d, bespoke = null) => {
setBespokeOverride(bespoke)
setDevice(d)
setStep(2)
}
const handleCreatedSn = (sn) => { createdSnRef.current = sn }
const handleFlashed = () => { setStep(3) }
const handleVerified = (updatedDevice) => { setDevice(updatedDevice); setStep(4) }
const handleProvisionNext = () => {
setStep(0); setMode(null); setDevice(null); setBespokeOverride(null); createdSnRef.current = null
}
return (
<div className="page-wrapper page-wrapper--centered" style={{ '--page-content-max-width': (step === 2 || step === 3) ? '1200px' : 'var(--content-max-width-md)' }}>
<PageHeader title="Provisioning Wizard" subtitle="Flash firmware to a Bell Systems board via WebSerial">
<Button variant="ghost" onClick={() => navigate('/manufacturing')}>
<Icon name="arrow-left" size={14} />
Back to Inventory
</Button>
</PageHeader>
{/* Step indicator */}
<div style={{ display: 'flex', justifyContent: 'center' }}>
<StepIndicator current={step + 1} />
</div>
{/* Step content — floating on page background, no card wrapper */}
<div style={{ paddingTop: 'var(--space-2)' }}>
{step === 0 && <StepMode onPick={handleModePick} />}
{step === 1 && (
<StepSelectDevice
mode={mode}
preloadSn={preloadSn}
onSelected={handleDeviceSelected}
onCreatedSn={handleCreatedSn}
/>
)}
{step === 2 && device && (
<StepFlash
device={device}
bespokeOverride={bespokeOverride}
onFlashed={handleFlashed}
serialConn={serialConn}
/>
)}
{step === 3 && device && (
<StepVerify device={device} onVerified={handleVerified} serialConn={serialConn} />
)}
{step === 4 && device && (
<StepDone device={device} onProvisionNext={handleProvisionNext} serialConn={serialConn} />
)}
</div>
</div>
)
}