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>
1577 lines
78 KiB
React
1577 lines
78 KiB
React
// 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 & 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>
|
||
)
|
||
}
|