feat(manufacturing): extract shared useSerialConnection hook for provisioning wizard

WebSerial port ownership was previously held inside StepFlash, which
made it awkward for other steps (StepVerify) to read from the same
port without fighting over exclusive access. useSerialConnection
centralizes port open/close/read/write so the wizard's steps share one
connection lifecycle instead of each managing their own.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-21 18:35:31 +03:00
co-authored by Claude Sonnet 5
parent d87925516a
commit f036000769
2 changed files with 505 additions and 227 deletions
+113
View File
@@ -0,0 +1,113 @@
// frontend/src/hooks/useSerialConnection.js
// Owns one WebSerial port connection across the Provisioning Wizard's Flash
// and Verify steps. Lifted out of StepFlash so the same live serial session
// (and its accumulated log) survives the step transition — a fresh device
// reboot after flashing is exactly the window the Verify step needs to watch.
import { useState, useRef, useCallback } from 'react'
export function useSerialConnection() {
const [portConnected, setPortConnected] = useState(false)
const [portName, setPortName] = useState('')
const [monitoring, setMonitoring] = useState(false)
const [lines, setLines] = useState([])
const portRef = useRef(null)
const readerRef = useRef(null)
const monitorActiveRef = useRef(false)
const appendLine = useCallback((msg) => {
setLines((prev) => [...prev, String(msg)])
}, [])
const clearLines = useCallback(() => setLines([]), [])
const requestPort = useCallback(async () => {
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)
return port
}, [])
// Starts (or restarts) the 115200-baud monitor on the currently held port.
// Safe to call after esptool has just disconnected its own transport —
// the small delay lets the OS release the port before we reopen it.
const startMonitor = useCallback(async (port, { delayMs = 1000 } = {}) => {
const target = port || portRef.current
if (!target) return
monitorActiveRef.current = true
setMonitoring(true)
if (delayMs) await new Promise((r) => setTimeout(r, delayMs))
try {
await target.open({ baudRate: 115200 })
} catch (openErr) {
appendLine(`[Error opening port: ${openErr.message}]`)
setMonitoring(false)
return
}
let reader
try {
reader = target.readable.getReader()
} catch (readerErr) {
appendLine(`[Error getting reader: ${readerErr.message}]`)
try { await target.close() } catch (_) {}
setMonitoring(false)
return
}
readerRef.current = reader
const dec = new TextDecoder()
let buf = ''
try {
while (monitorActiveRef.current) {
const { value, done } = await reader.read()
if (done) break
buf += dec.decode(value, { stream: true })
const parts = buf.split(/\r?\n/)
buf = parts.pop()
for (const line of parts) { if (line.trim()) appendLine(line) }
}
} catch (_) {
// reader cancelled/port lost — fall through to cleanup
} finally {
try { reader.releaseLock() } catch (_) {}
setMonitoring(false)
}
}, [appendLine])
const stopMonitor = useCallback(async () => {
monitorActiveRef.current = false
try { await readerRef.current?.cancel() } catch (_) {}
try { readerRef.current?.releaseLock() } catch (_) {}
setMonitoring(false)
}, [])
const disconnect = useCallback(async () => {
await stopMonitor()
try { await portRef.current?.close() } catch (_) {}
portRef.current = null
setPortConnected(false)
setPortName('')
appendLine('[Port disconnected]')
}, [stopMonitor, appendLine])
return {
portRef,
portConnected,
portName,
monitoring,
lines,
appendLine,
clearLines,
requestPort,
startMonitor,
stopMonitor,
disconnect,
}
}
export default useSerialConnection
@@ -2,7 +2,7 @@
// 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 { 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'
@@ -13,6 +13,7 @@ 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 ────────────────────────────────────────────────────────────────
@@ -82,6 +83,21 @@ function formatHwVersion(v) {
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']
@@ -660,74 +676,48 @@ function StepSelectDevice({ mode, preloadSn, onSelected, onCreatedSn }) {
}
// ─── 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 }) {
const [autoScroll, setAutoScroll] = useState(true)
const endRef = useRef(null)
useEffect(() => {
if (autoScroll && open) endRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [logs, autoScroll, open])
function SerialLogModal({ open, onClose, logs, monitoring }) {
if (!open) return null
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>
<>
{/* .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 }) {
const [portConnected, setPortConnected] = useState(false)
const [portName, setPortName] = useState('')
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)
@@ -736,22 +726,43 @@ function StepFlash({ device, bespokeOverride, onFlashed }) {
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 infoPanelRef = useRef(null)
const appendLog = (msg) => setLog((prev) => [...prev, String(msg)])
const appendSerial = (msg) => setSerial((prev) => [...prev, String(msg)])
// 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 scrollLog = () => logEndRef.current?.scrollIntoView({ behavior: 'smooth' })
const scrollSerial = () => serialEndRef.current?.scrollIntoView({ behavior: 'smooth' })
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')
@@ -770,56 +781,10 @@ function StepFlash({ device, bespokeOverride, onFlashed }) {
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)
await requestPort()
} catch (err) {
setError(err.message || 'Port selection cancelled.')
} finally {
@@ -829,7 +794,7 @@ function StepFlash({ device, bespokeOverride, onFlashed }) {
const handleStartFlash = async () => {
if (!portRef.current) return
setError(''); setLog([]); setSerial([])
setError(''); setLog([]); clearSerialLines()
setBlProgress(0); setPartProgress(0); setNvsProgress(0); setFwProgress(0)
setDone(false)
@@ -866,8 +831,8 @@ function StepFlash({ device, bespokeOverride, onFlashed }) {
transport, baudrate: FLASH_BAUD,
terminal: {
clean() {},
writeLine: (line) => { appendLog(line); scrollLog() },
write: (msg) => { appendLog(msg); scrollLog() },
writeLine: (line) => appendLog(line),
write: (msg) => appendLog(msg),
},
})
await loaderRef.current.main()
@@ -913,7 +878,7 @@ function StepFlash({ device, bespokeOverride, onFlashed }) {
setFlashing(false)
setDone(true)
appendSerial('── Serial monitor started (115200 baud) ──')
startSerialMonitor(port)
startMonitor(port)
} catch (err) {
setError(err.message || String(err))
setFlashing(false)
@@ -926,10 +891,11 @@ function StepFlash({ device, bespokeOverride, onFlashed }) {
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 style={{
<div ref={infoPanelRef} style={{
borderRadius: 'var(--radius-lg)',
border: `1px solid ${familyPal.idleBorder}`,
padding: 'var(--space-5)',
@@ -1029,13 +995,21 @@ function StepFlash({ device, bespokeOverride, onFlashed }) {
</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>
)}
{/* 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 && <div style={{ marginBottom: 'var(--space-3)' }}><ErrorBox msg={error} /></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)' }}>
@@ -1052,64 +1026,71 @@ function StepFlash({ device, bespokeOverride, onFlashed }) {
{/* 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 */}
{/* 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 }}>
{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>
)}
<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 */}
{!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)' }}>
{/* 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" />
</div>
)}
</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: 320,
alignSelf: 'start',
position: 'sticky', top: 0,
height: infoPanelHeight || undefined,
minHeight: 0,
}}>
<div style={{
padding: '8px 12px',
@@ -1143,8 +1124,10 @@ function StepFlash({ device, bespokeOverride, onFlashed }) {
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' }}>
{/* 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>
@@ -1152,9 +1135,148 @@ function StepFlash({ device, bespokeOverride, onFlashed }) {
)
}
// ─── 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 }) {
function StepVerify({ device, onVerified, serialConn }) {
const [polling, setPolling] = useState(false)
const [timedOut, setTimedOut] = useState(false)
const [verified, setVerified] = useState(false)
@@ -1162,6 +1284,20 @@ function StepVerify({ device, onVerified }) {
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
@@ -1174,8 +1310,13 @@ function StepVerify({ device, onVerified }) {
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
// 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)
@@ -1211,66 +1352,73 @@ function StepVerify({ device, onVerified }) {
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>
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>
)}
{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 || '—'} />
{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>
)}
{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 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>
)}
{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)' }}>
{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 }) {
function StepDone({ device, onProvisionNext, serialConn }) {
const navigate = useNavigate()
const [showSerialLogs, setShowSerialLogs] = useState(false)
@@ -1314,11 +1462,21 @@ function StepDone({ device, onProvisionNext }) {
</div>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 'var(--space-3)' }}>
<Button variant="primary" onClick={onProvisionNext}>
<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="secondary" onClick={() => navigate(`/manufacturing/devices/${device?.serial_number}`)}>
<Button variant="ghost" onClick={() => navigate(`/manufacturing/devices/${device?.serial_number}`)}>
View in Inventory
</Button>
<Button variant="ghost" onClick={() => setShowSerialLogs(true)}>
@@ -1326,7 +1484,7 @@ function StepDone({ device, onProvisionNext }) {
</Button>
</div>
<SerialLogModal open={showSerialLogs} onClose={() => setShowSerialLogs(false)} logs={[]} />
<SerialLogModal open={showSerialLogs} onClose={() => setShowSerialLogs(false)} logs={serialConn.lines} monitoring={serialConn.monitoring} />
</div>
)
}
@@ -1345,6 +1503,12 @@ export default function ProvisioningWizard() {
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) => {
@@ -1364,7 +1528,7 @@ export default function ProvisioningWizard() {
}
return (
<div className="page-wrapper page-wrapper--centered" style={{ '--page-content-max-width': step === 2 ? '1200px' : 'var(--content-max-width-md)' }}>
<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} />
@@ -1395,15 +1559,16 @@ export default function ProvisioningWizard() {
device={device}
bespokeOverride={bespokeOverride}
onFlashed={handleFlashed}
serialConn={serialConn}
/>
)}
{step === 3 && device && (
<StepVerify device={device} onVerified={handleVerified} />
<StepVerify device={device} onVerified={handleVerified} serialConn={serialConn} />
)}
{step === 4 && device && (
<StepDone device={device} onProvisionNext={handleProvisionNext} />
<StepDone device={device} onProvisionNext={handleProvisionNext} serialConn={serialConn} />
)}
</div>
</div>