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

1412 lines
69 KiB
JavaScript

// 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 } 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'
// ─── 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`
}
// ─── 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 ──────────────────────────────────────────────────────────
function SerialLogModal({ open, onClose, logs }) {
const [autoScroll, setAutoScroll] = useState(true)
const endRef = useRef(null)
useEffect(() => {
if (autoScroll && open) endRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [logs, autoScroll, open])
return (
<Modal
open={open}
onClose={onClose}
title="Serial Output Logs"
size="lg"
footer={
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)', cursor: 'pointer', userSelect: 'none' }}>
<span style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)' }}>Auto-scroll</span>
<span
onClick={() => setAutoScroll((v) => !v)}
style={{
position: 'relative', display: 'inline-flex', alignItems: 'center',
width: 32, height: 18,
backgroundColor: autoScroll ? 'var(--color-primary)' : 'var(--color-bg-elevated)',
borderRadius: 9, border: '1px solid var(--color-border)',
cursor: 'pointer', transition: 'background-color 0.2s', flexShrink: 0,
}}
>
<span style={{
position: 'absolute', left: autoScroll ? 14 : 2,
width: 14, height: 14, borderRadius: '50%',
backgroundColor: '#fff', transition: 'left 0.15s',
}} />
</span>
</label>
<Button variant="secondary" onClick={onClose}>Close</Button>
</div>
}
>
<div style={{
backgroundColor: 'var(--color-bg-abyss)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-md)',
padding: 'var(--space-3)',
height: 400,
overflowY: 'auto',
fontFamily: 'var(--font-family-mono)',
fontSize: 'var(--font-size-xs)',
lineHeight: 1.6,
color: '#a3e635',
}}>
{logs.length === 0 ? (
<span style={{ color: 'var(--color-text-muted)', opacity: 0.5 }}>No serial output yet.</span>
) : (
logs.map((line, i) => <div key={i}>{line}</div>)
)}
<div ref={endRef} />
</div>
</Modal>
)
}
// ─── Step 2: Flash ────────────────────────────────────────────────────────────
function StepFlash({ device, bespokeOverride, onFlashed }) {
const [portConnected, setPortConnected] = useState(false)
const [portName, setPortName] = useState('')
const [connecting, setConnecting] = useState(false)
const [flashing, setFlashing] = useState(false)
const [done, setDone] = useState(false)
const [blProgress, setBlProgress] = useState(0)
const [partProgress, setPartProgress] = useState(0)
const [nvsProgress, setNvsProgress] = useState(0)
const [fwProgress, setFwProgress] = useState(0)
const [log, setLog] = useState([])
const [serial, setSerial] = useState([])
const [nvsProfile, setNvsProfile] = useState('current')
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)])
const scrollLog = () => logEndRef.current?.scrollIntoView({ behavior: 'smooth' })
const scrollSerial = () => serialEndRef.current?.scrollIntoView({ behavior: 'smooth' })
const fetchBinary = async (url) => {
const token = localStorage.getItem('access_token')
const resp = await fetch(url, { headers: { Authorization: `Bearer ${token}` } })
if (!resp.ok) {
const err = await resp.json().catch(() => ({}))
throw new Error(err.detail || `Failed to fetch ${url}: ${resp.status}`)
}
return resp.arrayBuffer()
}
const arrayBufferToString = (buf) => {
const bytes = new Uint8Array(buf)
let str = ''
for (let i = 0; i < bytes.length; i++) str += String.fromCharCode(bytes[i])
return str
}
const startSerialMonitor = async (port) => {
serialActiveRef.current = true
await new Promise((r) => setTimeout(r, 1000))
try { await port.open({ baudRate: 115200 }) } catch (openErr) {
appendSerial(`[Error opening port: ${openErr.message}]`); scrollSerial(); return
}
let reader
try { reader = port.readable.getReader() } catch (readerErr) {
appendSerial(`[Error getting reader: ${readerErr.message}]`); scrollSerial()
try { await port.close() } catch (_) {}
return
}
serialReaderRef.current = reader
const dec = new TextDecoder()
let buf = ''
try {
while (serialActiveRef.current) {
const { value, done: streamDone } = await reader.read()
if (streamDone) break
buf += dec.decode(value, { stream: true })
const lines = buf.split(/\r?\n/)
buf = lines.pop()
for (const line of lines) { if (line.trim()) { appendSerial(line); scrollSerial() } }
}
} catch (_) {}
finally { try { reader.releaseLock() } catch (_) {} }
}
const disconnectPort = async () => {
serialActiveRef.current = false
try { await serialReaderRef.current?.cancel() } catch (_) {}
try { serialReaderRef.current?.releaseLock() } catch (_) {}
try { await portRef.current?.close() } catch (_) {}
portRef.current = null
setPortConnected(false)
setPortName('')
appendSerial('[Port disconnected]')
}
const handleConnectPort = async () => {
setError(''); setConnecting(true)
try {
const port = await navigator.serial.requestPort()
portRef.current = port
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([])
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); scrollLog() },
write: (msg) => { appendLog(msg); scrollLog() },
},
})
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) ──')
startSerialMonitor(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']
// ── Left panel: device info + controls ────────────────────────────────────
const InfoPanel = (
<div 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>
{!webSerialAvailable && (
<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)' }}>
Web Serial API not available. Use Chrome or Edge on a desktop system.
</div>
)}
{error && <div style={{ marginBottom: 'var(--space-3)' }}><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 */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{portConnected && !flashing && !done && log.length === 0 && (
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)', fontSize: 'var(--font-size-xs)', color: '#4dd6c8' }}>
<span style={{ width: 7, height: 7, borderRadius: '50%', backgroundColor: '#22c55e', display: 'inline-block' }} />
Ready to flash.
</div>
)}
{flashing && (
<p style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>Flashing do not disconnect</p>
)}
<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 */}
{!busy && (
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)' }}>
{!portConnected && (
<Button variant="secondary" onClick={handleConnectPort} disabled={!webSerialAvailable}>
Select COM Port
</Button>
)}
{portConnected && done && (
<Button variant="ghost" onClick={handleStartFlash}>Flash Again</Button>
)}
{done && (
<Button variant="primary" onClick={onFlashed}>
Proceed to Verify <Icon name="arrow-right" size={14} />
</Button>
)}
{portConnected && !done && (
<Button variant="primary" onClick={handleStartFlash}>
Start Flashing
</Button>
)}
</div>
)}
{busy && (
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)' }}>
<Spinner size="sm" />
</div>
)}
</div>
</div>
)
// ── Right panel: flash output log ─────────────────────────────────────────
const FlashOutputPanel = (
<div style={{
borderRadius: 'var(--radius-lg)',
border: '1px solid var(--color-border)',
overflow: 'hidden',
display: 'flex', flexDirection: 'column',
height: 320,
alignSelf: 'start',
position: 'sticky', top: 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) */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-3)', alignItems: 'stretch' }}>
{InfoPanel}
{FlashOutputPanel}
</div>
</div>
)
}
// ─── Step 3: Verify ───────────────────────────────────────────────────────────
function StepVerify({ device, onVerified }) {
const [polling, setPolling] = useState(false)
const [timedOut, setTimedOut] = useState(false)
const [verified, setVerified] = useState(false)
const [heartbeatData, setHeartbeatData] = useState(null)
const [error, setError] = useState('')
const intervalRef = useRef(null)
const timeoutRef = useRef(null)
const startPolling = useCallback(() => {
if (polling) return
setPolling(true); setTimedOut(false); setError('')
const startTime = Date.now()
intervalRef.current = setInterval(async () => {
try {
const hbData = await api.get(`/mqtt/heartbeats/${device.serial_number}?limit=1&offset=0`)
if (hbData.heartbeats?.length > 0) {
const latest = hbData.heartbeats[0]
const receivedMs = latest.received_at
? Date.parse(latest.received_at.replace(' ', 'T') + 'Z') : NaN
if (!isNaN(receivedMs) && receivedMs > startTime) {
clearInterval(intervalRef.current)
clearTimeout(timeoutRef.current)
try {
await api.request(`/manufacturing/devices/${device.serial_number}/status`, {
method: 'PATCH',
body: JSON.stringify({ status: 'provisioned', note: 'Auto-verified via wizard' }),
})
} catch (_) {}
const deviceData = await api.get(`/manufacturing/devices/${device.serial_number}`)
setHeartbeatData(latest)
setPolling(false)
setVerified(true)
onVerified({ ...deviceData, mfg_status: 'provisioned' })
}
}
} 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)
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' }}>
<div style={{ padding: 'var(--space-5)', backgroundColor: 'var(--color-bg-elevated)', border: '1px solid var(--color-border)', borderRadius: 'var(--radius-lg)', minHeight: 280 }}>
<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>
<p style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>
Polling every {VERIFY_POLL_MS / 1000}s · timeout {VERIFY_TIMEOUT_MS / 1000}s
</p>
</div>
)
}
// ─── Step 4: Done ─────────────────────────────────────────────────────────────
function StepDone({ device, onProvisionNext }) {
const navigate = useNavigate()
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>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 'var(--space-3)' }}>
<Button variant="primary" onClick={onProvisionNext}>
Provision Next Device
</Button>
<Button variant="secondary" 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={[]} />
</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)
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 ? '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}
/>
)}
{step === 3 && device && (
<StepVerify device={device} onVerified={handleVerified} />
)}
{step === 4 && device && (
<StepDone device={device} onProvisionNext={handleProvisionNext} />
)}
</div>
</div>
)
}