feat(devices): crash detail view and fleet Crash Report (F-070)

- CrashDetail (components/shared): headline is abort_msg when present,
  else the Xtensa exception name. For aborts, pc/exception/fault address are
  shown muted with a note that they describe abort() and that the first
  2-4 frames are abort internals. Shows task, uptime at crash, heap stats with
  a "likely heap exhaustion / fragmentation" badge (largest_block < 12000 or
  free_heap < 25000, F-055), PC + backtrace as 0x%08X in ESP serial format,
  a copyable addr2line command (PC, backtrace, then the PC parsed from
  "abort() was called at PC ..."), elf_sha256, and a backtrace-corrupted
  flag. When the server has the build's ELF and addr2line, a Decode button
  shows function + file:line per frame.
- Health tab boot history uses it, previews the headline in the row, and
  gets a "Sync from device" button (telemetry.get_boot_history, merged
  server-side).
- New /devices/crashes page (linked from the Fleet header): crashes grouped
  across devices with per-device counts and the latest occurrence.
- Firmware release modal: optional firmware.elf attachment.
- API reference: boot_report payload documents the F-070 fields.
- lib/crashReport.js holds the parsing/formatting rules; lib/clipboard.js
  a copy helper with a non-secure-context fallback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-30 16:28:41 +03:00
co-authored by Claude Opus 5.5
parent 82cf95a9af
commit f1a7c5a7ca
9 changed files with 632 additions and 28 deletions
@@ -22,6 +22,8 @@ import LogsTab from '@/pages/bellcloud/devices/tabs/LogsTab'
import { fmtDateTimeMedium, fmtRelative } from '@/lib/formatters'
import { resolveRange } from '@/lib/timeRange'
import { useToast } from '@/components/ui/Toast'
import CrashDetail from '@/components/shared/CrashDetail'
import { hasCrashDetail, crashHeadline, isHeapExhaustion } from '@/lib/crashReport'
// ─── Reset reason → badge variant ──────────────────────────────────────────
@@ -189,13 +191,15 @@ function RestartTimeline({ events, rangeStart, rangeEnd }) {
// ─── Boot event row (expandable for crash detail) ──────────────────────────
function BootEventRow({ event, expanded, onToggle }) {
const hasCrash = event.crash_task != null
const hasCrash = hasCrashDetail(event)
const headline = hasCrash ? crashHeadline(event) : null
const heapLow = isHeapExhaustion(event.pre_crash)
return (
<div style={{ borderBottom: '1px solid var(--color-border)' }}>
<div
onClick={hasCrash ? onToggle : undefined}
style={{
display: 'flex', alignItems: 'center', gap: 'var(--space-4)',
display: 'flex', alignItems: 'center', gap: 'var(--space-4)', flexWrap: 'wrap',
padding: 'var(--space-3) var(--space-4)',
cursor: hasCrash ? 'pointer' : 'default',
}}
@@ -209,10 +213,19 @@ function BootEventRow({ event, expanded, onToggle }) {
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', fontFamily: 'var(--font-family-mono)' }}>
boot #{event.boot_count ?? '—'}
</span>
<span style={{ flex: 1 }} />
{headline && (
<span style={{
flex: 1, minWidth: 0, fontSize: 'var(--font-size-xs)', color: 'var(--color-danger)',
fontFamily: 'var(--font-family-mono)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>
{headline}
</span>
)}
{!headline && <span style={{ flex: 1 }} />}
{heapLow && <StatusBadge variant="warning" size="sm">Low heap</StatusBadge>}
{event.free_heap != null && (
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', fontFamily: 'var(--font-family-mono)' }}>
{Math.round(event.free_heap / 1024)} KB free
{Math.round(event.free_heap / 1024)} KB free at boot
</span>
)}
{hasCrash && (
@@ -225,25 +238,8 @@ function BootEventRow({ event, expanded, onToggle }) {
)}
</div>
{expanded && hasCrash && (
<div style={{
padding: 'var(--space-3) var(--space-4) var(--space-4) calc(160px + var(--space-4) + var(--space-4))',
display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', gap: 'var(--space-3)',
}}>
{[
['Faulting task', event.crash_task],
['Program counter', event.crash_pc != null ? `0x${Number(event.crash_pc).toString(16)}` : '—'],
['Exception cause', event.crash_exc_cause ?? '—'],
['Faulting address', event.crash_exc_vaddr != null ? `0x${Number(event.crash_exc_vaddr).toString(16)}` : '—'],
].map(([label, val]) => (
<div key={label} style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)' }}>
{label}
</span>
<span style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-danger)', fontFamily: 'var(--font-family-mono)' }}>
{val}
</span>
</div>
))}
<div style={{ padding: 'var(--space-2) var(--space-4) var(--space-5)' }}>
<CrashDetail event={event} />
</div>
)}
</div>
@@ -298,7 +294,7 @@ function StatusIcon({ status }) {
// ─── Health overview sub-tab (stats + charts + boot history) ───────────────
function HealthOverview({ sn, mqttStatus, range, offlineTimeoutSec, healthSettings }) {
function HealthOverview({ sn, mqttStatus, range, offlineTimeoutSec, healthSettings, isOnline, sendMqttCommand }) {
const [bootEvents, setBootEvents] = useState([])
const [heartbeats, setHeartbeats] = useState([])
const [pingSamples, setPingSamples] = useState([])
@@ -306,6 +302,7 @@ function HealthOverview({ sn, mqttStatus, range, offlineTimeoutSec, healthSettin
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [expandedId, setExpandedId] = useState(null)
const [syncing, setSyncing] = useState(false)
const rangeParams = useMemo(() => {
const p = new URLSearchParams()
@@ -339,6 +336,23 @@ function HealthOverview({ sn, mqttStatus, range, offlineTimeoutSec, healthSettin
useEffect(() => { load() }, [load])
// Asks the device for its own SD boot log. The backend merges the reply
// into boot history as it arrives (fills in crash detail for boots we
// already have, adds boots the console never saw live), before the ack
// reaches us — so a reload right after the reply shows the merged result.
const syncFromDevice = async () => {
if (!sendMqttCommand) return
setSyncing(true)
try {
await sendMqttCommand('telemetry.get_boot_history')
await load()
} catch {
// the command toast already reports the failure
} finally {
setSyncing(false)
}
}
if (loading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', padding: 'var(--space-16)' }}>
@@ -606,7 +620,16 @@ function HealthOverview({ sn, mqttStatus, range, offlineTimeoutSec, healthSettin
</Card>
{/* ── Full boot event history ──────────────────────────────────────── */}
<Card title="Boot History" subtitle="Reverse-chronological — click a fault row with crash detail to expand" padding={false}>
<Card
title="Boot History"
subtitle="Reverse-chronological — click a fault row with crash detail to expand"
padding={false}
action={sendMqttCommand && (
<Button variant="secondary" size="sm" icon={<Icon name="refresh" size={14} />} onClick={syncFromDevice} loading={syncing} disabled={!isOnline} title={isOnline ? "Fetch the device's own boot log and merge it into this history" : 'Device is offline'}>
Sync from device
</Button>
)}
>
{bootEvents.length === 0 ? (
<div style={{ padding: 'var(--space-8)', textAlign: 'center', color: 'var(--color-text-muted)', fontSize: 'var(--font-size-sm)', fontStyle: 'italic' }}>
No boot events recorded in this range.
@@ -888,7 +911,7 @@ function useHeaderStatus(sn, healthSettings, mqttStatus) {
})
}
export default function HealthTab({ device, sn, mqttStatus }) {
export default function HealthTab({ device, sn, mqttStatus, isOnline, sendMqttCommand }) {
const [subTab, setSubTab] = useState('overview')
const [range, setRange] = useState(() => resolveRange('7d'))
// Local override after a successful save, so the Settings form and offline-
@@ -926,7 +949,7 @@ export default function HealthTab({ device, sn, mqttStatus }) {
</div>
{subTab === 'overview' && (
<HealthOverview sn={sn} mqttStatus={mqttStatus} range={range} offlineTimeoutSec={offlineTimeoutSec} healthSettings={healthSettings} />
<HealthOverview sn={sn} mqttStatus={mqttStatus} range={range} offlineTimeoutSec={offlineTimeoutSec} healthSettings={healthSettings} isOnline={isOnline} sendMqttCommand={sendMqttCommand} />
)}
{subTab === 'logs' && <LogsTab sn={sn} range={range} />}
{subTab === 'settings' && (