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. // Provisions an ESP32 board via WebSerial + esptool-js.
// Steps: 0=Mode 1=Select/Create device 2=Flash 3=Verify 4=Done // 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 { useNavigate, useSearchParams } from 'react-router-dom'
import { ESPLoader, Transport } from 'esptool-js' import { ESPLoader, Transport } from 'esptool-js'
import api from '@/lib/api' import api from '@/lib/api'
@@ -13,6 +13,7 @@ import StatusBadge from '@/components/ui/StatusBadge'
import SearchBar from '@/components/ui/SearchBar' import SearchBar from '@/components/ui/SearchBar'
import Spinner from '@/components/ui/Spinner' import Spinner from '@/components/ui/Spinner'
import Icon from '@/components/ui/Icon' import Icon from '@/components/ui/Icon'
import { useSerialConnection } from '@/hooks/useSerialConnection'
// ─── Constants ──────────────────────────────────────────────────────────────── // ─── Constants ────────────────────────────────────────────────────────────────
@@ -82,6 +83,21 @@ function formatHwVersion(v) {
return isNaN(n) ? `Rev ${v}` : `Rev ${n}.0` 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 ─────────────────────────────────────────────────────────── // ─── Step Indicator ───────────────────────────────────────────────────────────
const STEP_LABELS = ['Mode', 'Device', 'Flash', 'Verify', 'Done'] const STEP_LABELS = ['Mode', 'Device', 'Flash', 'Verify', 'Done']
@@ -660,74 +676,48 @@ function StepSelectDevice({ mode, preloadSn, onSelected, onCreatedSn }) {
} }
// ─── Serial Log Modal ────────────────────────────────────────────────────────── // ─── 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 }) { function SerialLogModal({ open, onClose, logs, monitoring }) {
const [autoScroll, setAutoScroll] = useState(true) if (!open) return null
const endRef = useRef(null)
useEffect(() => {
if (autoScroll && open) endRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [logs, autoScroll, open])
return ( 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 <Modal
open={open} open={open}
onClose={onClose} onClose={onClose}
title="Serial Output Logs" title="Serial Output Logs"
size="lg" size="xxl"
className="serial-log-modal"
footer={ footer={
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%' }}> <div style={{ display: 'flex', justifyContent: 'flex-end', 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> <Button variant="secondary" onClick={onClose}>Close</Button>
</div> </div>
} }
> >
<div style={{ <SerialLogPanel lines={logs} monitoring={monitoring} />
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>
</>
) )
} }
// ─── Step 2: Flash ──────────────────────────────────────────────────────────── // ─── Step 2: Flash ────────────────────────────────────────────────────────────
function StepFlash({ device, bespokeOverride, onFlashed }) { function StepFlash({ device, bespokeOverride, onFlashed, serialConn }) {
const [portConnected, setPortConnected] = useState(false) const { portRef, portConnected, portName, requestPort, disconnect: disconnectPort, startMonitor, appendLine: appendSerial, clearLines: clearSerialLines } = serialConn
const [portName, setPortName] = useState('')
const [connecting, setConnecting] = useState(false) const [connecting, setConnecting] = useState(false)
const [flashing, setFlashing] = useState(false) const [flashing, setFlashing] = useState(false)
const [done, setDone] = useState(false) const [done, setDone] = useState(false)
@@ -736,22 +726,43 @@ function StepFlash({ device, bespokeOverride, onFlashed }) {
const [nvsProgress, setNvsProgress] = useState(0) const [nvsProgress, setNvsProgress] = useState(0)
const [fwProgress, setFwProgress] = useState(0) const [fwProgress, setFwProgress] = useState(0)
const [log, setLog] = useState([]) const [log, setLog] = useState([])
const [serial, setSerial] = useState([])
const [nvsProfile, setNvsProfile] = useState('current') const [nvsProfile, setNvsProfile] = useState('current')
const [error, setError] = useState('') const [error, setError] = useState('')
const loaderRef = useRef(null) const loaderRef = useRef(null)
const portRef = useRef(null)
const serialReaderRef = useRef(null)
const serialActiveRef = useRef(false)
const logEndRef = useRef(null) const logEndRef = useRef(null)
const serialEndRef = 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)]) const appendLog = (msg) => setLog((prev) => [...prev, String(msg)])
const appendSerial = (msg) => setSerial((prev) => [...prev, String(msg)])
const scrollLog = () => logEndRef.current?.scrollIntoView({ behavior: 'smooth' }) // Scrolling on the *next* render (after the DOM has the new line) rather
const scrollSerial = () => serialEndRef.current?.scrollIntoView({ behavior: 'smooth' }) // 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 fetchBinary = async (url) => {
const token = localStorage.getItem('access_token') const token = localStorage.getItem('access_token')
@@ -770,56 +781,10 @@ function StepFlash({ device, bespokeOverride, onFlashed }) {
return str 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 () => { const handleConnectPort = async () => {
setError(''); setConnecting(true) setError(''); setConnecting(true)
try { try {
const port = await navigator.serial.requestPort() await requestPort()
portRef.current = port
const info = port.getInfo?.() || {}
const label = info.usbVendorId
? `USB ${info.usbVendorId.toString(16).toUpperCase()}:${(info.usbProductId || 0).toString(16).toUpperCase()}`
: 'Serial Port'
setPortName(label)
setPortConnected(true)
} catch (err) { } catch (err) {
setError(err.message || 'Port selection cancelled.') setError(err.message || 'Port selection cancelled.')
} finally { } finally {
@@ -829,7 +794,7 @@ function StepFlash({ device, bespokeOverride, onFlashed }) {
const handleStartFlash = async () => { const handleStartFlash = async () => {
if (!portRef.current) return if (!portRef.current) return
setError(''); setLog([]); setSerial([]) setError(''); setLog([]); clearSerialLines()
setBlProgress(0); setPartProgress(0); setNvsProgress(0); setFwProgress(0) setBlProgress(0); setPartProgress(0); setNvsProgress(0); setFwProgress(0)
setDone(false) setDone(false)
@@ -866,8 +831,8 @@ function StepFlash({ device, bespokeOverride, onFlashed }) {
transport, baudrate: FLASH_BAUD, transport, baudrate: FLASH_BAUD,
terminal: { terminal: {
clean() {}, clean() {},
writeLine: (line) => { appendLog(line); scrollLog() }, writeLine: (line) => appendLog(line),
write: (msg) => { appendLog(msg); scrollLog() }, write: (msg) => appendLog(msg),
}, },
}) })
await loaderRef.current.main() await loaderRef.current.main()
@@ -913,7 +878,7 @@ function StepFlash({ device, bespokeOverride, onFlashed }) {
setFlashing(false) setFlashing(false)
setDone(true) setDone(true)
appendSerial('── Serial monitor started (115200 baud) ──') appendSerial('── Serial monitor started (115200 baud) ──')
startSerialMonitor(port) startMonitor(port)
} catch (err) { } catch (err) {
setError(err.message || String(err)) setError(err.message || String(err))
setFlashing(false) setFlashing(false)
@@ -926,10 +891,11 @@ function StepFlash({ device, bespokeOverride, onFlashed }) {
const boardInfo = BOARD_TYPE_MAP[device.hw_type] const boardInfo = BOARD_TYPE_MAP[device.hw_type]
const familyPal = BOARD_FAMILY_COLORS[boardInfo?.family || 'vesper'] const familyPal = BOARD_FAMILY_COLORS[boardInfo?.family || 'vesper']
const readyToFlash = portConnected && !flashing && !done && log.length === 0
// ── Left panel: device info + controls ──────────────────────────────────── // ── Left panel: device info + controls ────────────────────────────────────
const InfoPanel = ( const InfoPanel = (
<div style={{ <div ref={infoPanelRef} style={{
borderRadius: 'var(--radius-lg)', borderRadius: 'var(--radius-lg)',
border: `1px solid ${familyPal.idleBorder}`, border: `1px solid ${familyPal.idleBorder}`,
padding: 'var(--space-5)', padding: 'var(--space-5)',
@@ -1029,13 +995,21 @@ function StepFlash({ device, bespokeOverride, onFlashed }) {
</div> </div>
</div> </div>
{!webSerialAvailable && ( {/* Web Serial warning — space always reserved so its appearance never
<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)' }}> 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. Web Serial API not available. Use Chrome or Edge on a desktop system.
</div> </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% */} {/* 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', flexDirection: 'column', gap: 'var(--space-3)', marginBottom: 'var(--space-3)' }}>
@@ -1052,64 +1026,71 @@ function StepFlash({ device, bespokeOverride, onFlashed }) {
{/* Bottom bar */} {/* Bottom bar */}
<div style={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', gap: 'var(--space-3)', paddingTop: 'var(--space-3)', marginTop: 'var(--space-2)' }}> <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 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{portConnected && !flashing && !done && log.length === 0 && ( <div
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)', fontSize: 'var(--font-size-xs)', color: '#4dd6c8' }}> style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)', fontSize: 'var(--font-size-xs)', color: flashing ? 'var(--color-text-muted)' : '#4dd6c8' }}
<span style={{ width: 7, height: 7, borderRadius: '50%', backgroundColor: '#22c55e', display: 'inline-block' }} /> aria-hidden={!(readyToFlash || flashing)}
Ready to flash. >
<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> </div>
)}
{flashing && (
<p style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>Flashing — do not disconnect…</p>
)}
<p style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', opacity: 0.5 }}> <p style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', opacity: 0.5 }}>
NVS 0x9000 · FW 0x10000 · {FLASH_BAUD} baud NVS 0x9000 · FW 0x10000 · {FLASH_BAUD} baud
</p> </p>
</div> </div>
{/* Right: action buttons */} {/* Right: action buttons — every possible button always renders (in a
{!busy && ( fixed order/slot) so the row's height and the buttons' own sizes
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)' }}> never shift; only the applicable ones are visible at a time. */}
{!portConnected && ( <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}> <Button variant="secondary" onClick={handleConnectPort} disabled={!webSerialAvailable}>
Select COM Port Select COM Port
</Button> </Button>
)} </span>
{portConnected && done && ( <span style={{ display: !busy && portConnected && done ? 'inline-flex' : 'none' }}>
<Button variant="ghost" onClick={handleStartFlash}>Flash Again</Button> <Button variant="ghost" onClick={handleStartFlash}>Flash Again</Button>
)} </span>
{done && ( <span style={{ display: !busy && portConnected && !done ? 'inline-flex' : 'none' }}>
<Button variant="primary" onClick={onFlashed}>
Proceed to Verify <Icon name="arrow-right" size={14} />
</Button>
)}
{portConnected && !done && (
<Button variant="primary" onClick={handleStartFlash}> <Button variant="primary" onClick={handleStartFlash}>
Start Flashing Start Flashing
</Button> </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>
)}
{busy && (
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)' }}>
<Spinner size="sm" />
</div>
)}
</div> </div>
</div> </div>
) )
// ── Right panel: flash output log ───────────────────────────────────────── // ── 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 = ( const FlashOutputPanel = (
<div style={{ <div style={{
borderRadius: 'var(--radius-lg)', borderRadius: 'var(--radius-lg)',
border: '1px solid var(--color-border)', border: '1px solid var(--color-border)',
overflow: 'hidden', overflow: 'hidden',
display: 'flex', flexDirection: 'column', display: 'flex', flexDirection: 'column',
height: 320, height: infoPanelHeight || undefined,
alignSelf: 'start', minHeight: 0,
position: 'sticky', top: 0,
}}> }}>
<div style={{ <div style={{
padding: '8px 12px', padding: '8px 12px',
@@ -1143,8 +1124,10 @@ function StepFlash({ device, bespokeOverride, onFlashed }) {
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-3)' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-3)' }}>
{/* Info panel (left) | Flash output (right) */} {/* Info panel (left) | Flash output (right) — align-items: start so the
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-3)', alignItems: 'stretch' }}> 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} {InfoPanel}
{FlashOutputPanel} {FlashOutputPanel}
</div> </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 ─────────────────────────────────────────────────────────── // ─── Step 3: Verify ───────────────────────────────────────────────────────────
function StepVerify({ device, onVerified }) { function StepVerify({ device, onVerified, serialConn }) {
const [polling, setPolling] = useState(false) const [polling, setPolling] = useState(false)
const [timedOut, setTimedOut] = useState(false) const [timedOut, setTimedOut] = useState(false)
const [verified, setVerified] = useState(false) const [verified, setVerified] = useState(false)
@@ -1162,6 +1284,20 @@ function StepVerify({ device, onVerified }) {
const [error, setError] = useState('') const [error, setError] = useState('')
const intervalRef = useRef(null) const intervalRef = useRef(null)
const timeoutRef = 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(() => { const startPolling = useCallback(() => {
if (polling) return 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`) const hbData = await api.get(`/mqtt/heartbeats/${device.serial_number}?limit=1&offset=0`)
if (hbData.heartbeats?.length > 0) { if (hbData.heartbeats?.length > 0) {
const latest = hbData.heartbeats[0] const latest = hbData.heartbeats[0]
const receivedMs = latest.received_at // received_at already comes back as a full ISO-8601 string with an
? Date.parse(latest.received_at.replace(' ', 'T') + 'Z') : NaN // 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) { if (!isNaN(receivedMs) && receivedMs > startTime) {
clearInterval(intervalRef.current) clearInterval(intervalRef.current)
clearTimeout(timeoutRef.current) clearTimeout(timeoutRef.current)
@@ -1211,9 +1352,8 @@ function StepVerify({ device, onVerified }) {
clearInterval(intervalRef.current); clearTimeout(timeoutRef.current); setPolling(false) clearInterval(intervalRef.current); clearTimeout(timeoutRef.current); setPolling(false)
} }
return ( const StatusPanel = (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' }}> <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' }}>
<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)' }}> <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 Waiting for Device
</p> </p>
@@ -1260,17 +1400,25 @@ function StepVerify({ device, onVerified }) {
{error && !timedOut && !verified && ( {error && !timedOut && !verified && (
<div style={{ marginTop: 'var(--space-3)' }}><ErrorBox msg={`Poll error (will retry): ${error}`} /></div> <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)' }}> <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 Polling every {VERIFY_POLL_MS / 1000}s · timeout {VERIFY_TIMEOUT_MS / 1000}s
</p> </p>
</div> </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 ───────────────────────────────────────────────────────────── // ─── Step 4: Done ─────────────────────────────────────────────────────────────
function StepDone({ device, onProvisionNext }) { function StepDone({ device, onProvisionNext, serialConn }) {
const navigate = useNavigate() const navigate = useNavigate()
const [showSerialLogs, setShowSerialLogs] = useState(false) const [showSerialLogs, setShowSerialLogs] = useState(false)
@@ -1314,11 +1462,21 @@ function StepDone({ device, onProvisionNext }) {
</div> </div>
</div> </div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 'var(--space-3)' }}> <p style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-secondary)', textAlign: 'center' }}>
<Button variant="primary" onClick={onProvisionNext}> 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 Provision Next Device
</Button> </Button>
<Button variant="secondary" onClick={() => navigate(`/manufacturing/devices/${device?.serial_number}`)}> <Button variant="ghost" onClick={() => navigate(`/manufacturing/devices/${device?.serial_number}`)}>
View in Inventory View in Inventory
</Button> </Button>
<Button variant="ghost" onClick={() => setShowSerialLogs(true)}> <Button variant="ghost" onClick={() => setShowSerialLogs(true)}>
@@ -1326,7 +1484,7 @@ function StepDone({ device, onProvisionNext }) {
</Button> </Button>
</div> </div>
<SerialLogModal open={showSerialLogs} onClose={() => setShowSerialLogs(false)} logs={[]} /> <SerialLogModal open={showSerialLogs} onClose={() => setShowSerialLogs(false)} logs={serialConn.lines} monitoring={serialConn.monitoring} />
</div> </div>
) )
} }
@@ -1345,6 +1503,12 @@ export default function ProvisioningWizard() {
const [bespokeOverride, setBespokeOverride] = useState(null) const [bespokeOverride, setBespokeOverride] = useState(null)
const createdSnRef = useRef(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 handleModePick = (m) => { setMode(m); setStep(1) }
const handleDeviceSelected = (d, bespoke = null) => { const handleDeviceSelected = (d, bespoke = null) => {
@@ -1364,7 +1528,7 @@ export default function ProvisioningWizard() {
} }
return ( 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"> <PageHeader title="Provisioning Wizard" subtitle="Flash firmware to a Bell Systems board via WebSerial">
<Button variant="ghost" onClick={() => navigate('/manufacturing')}> <Button variant="ghost" onClick={() => navigate('/manufacturing')}>
<Icon name="arrow-left" size={14} /> <Icon name="arrow-left" size={14} />
@@ -1395,15 +1559,16 @@ export default function ProvisioningWizard() {
device={device} device={device}
bespokeOverride={bespokeOverride} bespokeOverride={bespokeOverride}
onFlashed={handleFlashed} onFlashed={handleFlashed}
serialConn={serialConn}
/> />
)} )}
{step === 3 && device && ( {step === 3 && device && (
<StepVerify device={device} onVerified={handleVerified} /> <StepVerify device={device} onVerified={handleVerified} serialConn={serialConn} />
)} )}
{step === 4 && device && ( {step === 4 && device && (
<StepDone device={device} onProvisionNext={handleProvisionNext} /> <StepDone device={device} onProvisionNext={handleProvisionNext} serialConn={serialConn} />
)} )}
</div> </div>
</div> </div>