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:
@@ -0,0 +1,191 @@
|
||||
// frontend/src/pages/bellcloud/devices/CrashReport.jsx
|
||||
// Fleet-wide crash report (firmware F-070): every fault boot with crash
|
||||
// detail, grouped so recurring crash types stand out across devices.
|
||||
// Grouping (backend mqtt/crash_groups.py): abort_msg with hex addresses
|
||||
// stripped when present, else faulting task + exception cause, else reset
|
||||
// reason for fault resets with no coredump.
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import api from '@/lib/api'
|
||||
import PageHeader from '@/components/ui/PageHeader'
|
||||
import Card from '@/components/ui/Card'
|
||||
import Button from '@/components/ui/Button'
|
||||
import StatusBadge from '@/components/ui/StatusBadge'
|
||||
import Spinner from '@/components/ui/Spinner'
|
||||
import Icon from '@/components/ui/Icon'
|
||||
import TimeRangeSelect from '@/components/ui/TimeRangeSelect'
|
||||
import { resolveRange } from '@/lib/timeRange'
|
||||
import CrashDetail from '@/components/shared/CrashDetail'
|
||||
import { fmtDateTimeMedium, fmtRelative } from '@/lib/formatters'
|
||||
import { isHeapExhaustion } from '@/lib/crashReport'
|
||||
|
||||
const KIND_LABEL = { abort: 'Abort', exception: 'Exception', reason: 'No coredump' }
|
||||
const KIND_VARIANT = { abort: 'danger', exception: 'danger', reason: 'warning' }
|
||||
|
||||
function deviceLabel(device) {
|
||||
return device?.console_name || device?.device_name || null
|
||||
}
|
||||
|
||||
function CrashGroupCard({ group, devicesBySerial }) {
|
||||
const [showDetail, setShowDetail] = useState(false)
|
||||
const heapLow = isHeapExhaustion(group.latest?.pre_crash)
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={group.title}
|
||||
subtitle={`${group.count} ${group.count === 1 ? 'crash' : 'crashes'} on ${group.device_count} ${group.device_count === 1 ? 'device' : 'devices'} · last ${fmtRelative(group.last_at)}`}
|
||||
action={<StatusBadge variant={KIND_VARIANT[group.kind] || 'neutral'} size="sm">{KIND_LABEL[group.kind] || group.kind}</StatusBadge>}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' }}>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 'var(--space-2) var(--space-4)', fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>
|
||||
<span>First seen {fmtDateTimeMedium(group.first_at)}</span>
|
||||
<span>Last seen {fmtDateTimeMedium(group.last_at)}</span>
|
||||
{heapLow && <StatusBadge variant="warning" size="sm">Latest: likely heap exhaustion</StatusBadge>}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
{group.devices.map(d => {
|
||||
const device = devicesBySerial[d.device_serial]
|
||||
const name = deviceLabel(device)
|
||||
return (
|
||||
<div
|
||||
key={d.device_serial}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 'var(--space-3)', flexWrap: 'wrap',
|
||||
padding: 'var(--space-2) 0', borderBottom: '1px solid var(--color-border)',
|
||||
}}
|
||||
>
|
||||
<span style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column' }}>
|
||||
{device ? (
|
||||
<Link to={`/devices/${device.id}?tab=health`} style={{ color: 'var(--color-text-accent)', fontSize: 'var(--font-size-sm)' }}>
|
||||
{name || d.device_serial}
|
||||
</Link>
|
||||
) : (
|
||||
<span style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-primary)' }}>{d.device_serial}</span>
|
||||
)}
|
||||
{name && (
|
||||
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', fontFamily: 'var(--font-family-mono)' }}>
|
||||
{d.device_serial}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', fontFamily: 'var(--font-family-mono)' }}>
|
||||
{d.count}×
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
iconRight={<Icon name="chevron_down" size={14} style={{ transform: showDetail ? 'rotate(180deg)' : 'none', transition: 'transform 0.15s' }} />}
|
||||
onClick={() => setShowDetail(v => !v)}
|
||||
>
|
||||
{showDetail ? 'Hide latest occurrence' : 'Show latest occurrence'}
|
||||
</Button>
|
||||
</div>
|
||||
{showDetail && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-3)' }}>
|
||||
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>
|
||||
{group.latest.device_serial} · boot #{group.latest.boot_count ?? '—'} · {fmtDateTimeMedium(group.latest.occurred_at)}
|
||||
</span>
|
||||
<CrashDetail event={group.latest} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export default function CrashReport() {
|
||||
const [range, setRange] = useState(() => resolveRange('30d'))
|
||||
const [groups, setGroups] = useState([])
|
||||
const [totalCrashes, setTotalCrashes] = useState(0)
|
||||
const [devices, setDevices] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const p = new URLSearchParams()
|
||||
if (range.since) p.set('since', range.since.toISOString())
|
||||
if (range.until) p.set('until', range.until.toISOString())
|
||||
const qs = p.toString()
|
||||
const [crashRes, deviceRes] = await Promise.all([
|
||||
api.get(`/mqtt/crash-groups${qs ? `?${qs}` : ''}`),
|
||||
// Only for names/links — the report itself doesn't depend on it.
|
||||
api.get('/devices').catch(() => ({ devices: [] })),
|
||||
])
|
||||
setGroups(crashRes.groups || [])
|
||||
setTotalCrashes(crashRes.total_crashes || 0)
|
||||
setDevices(deviceRes.devices || [])
|
||||
} catch (err) {
|
||||
setError(err.message || 'Failed to load crash report.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [range])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const devicesBySerial = useMemo(() => {
|
||||
const map = {}
|
||||
for (const d of devices) {
|
||||
const sn = d.serial_number || d.device_id
|
||||
if (sn) map[sn] = d
|
||||
}
|
||||
return map
|
||||
}, [devices])
|
||||
|
||||
const affectedDevices = useMemo(
|
||||
() => new Set(groups.flatMap(g => g.devices.map(d => d.device_serial))).size,
|
||||
[groups],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="page-wrapper">
|
||||
<PageHeader
|
||||
title="Crash Report"
|
||||
subtitle="Fault resets across the fleet, grouped by crash signature"
|
||||
breadcrumbs={[{ label: 'Device Fleet', href: '/devices' }, { label: 'Crash Report' }]}
|
||||
>
|
||||
<div style={{ width: 'min(220px, 100%)' }}>
|
||||
<TimeRangeSelect value={range} onChange={setRange} />
|
||||
</div>
|
||||
</PageHeader>
|
||||
|
||||
{loading ? (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: 'var(--space-16)' }}>
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div role="alert" style={{ backgroundColor: 'var(--color-danger-bg)', border: '1px solid var(--color-danger)', borderRadius: 'var(--radius-md)', padding: 'var(--space-4)', color: 'var(--color-danger)' }}>
|
||||
{error}
|
||||
</div>
|
||||
) : groups.length === 0 ? (
|
||||
<Card>
|
||||
<div style={{ padding: 'var(--space-8)', textAlign: 'center', color: 'var(--color-text-muted)', fontSize: 'var(--font-size-sm)' }}>
|
||||
No crashes recorded in this range.
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
<span style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)' }}>
|
||||
{totalCrashes} {totalCrashes === 1 ? 'crash' : 'crashes'} in {groups.length} {groups.length === 1 ? 'group' : 'groups'} across {affectedDevices} {affectedDevices === 1 ? 'device' : 'devices'}
|
||||
</span>
|
||||
<div className="masonry-grid masonry-grid--2">
|
||||
{groups.map(g => (
|
||||
<CrashGroupCard key={g.key} group={g} devicesBySerial={devicesBySerial} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -713,6 +713,9 @@ export default function DeviceList() {
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Button variant="secondary" icon={<Icon name="warning" size={14} />} onClick={() => navigate('/devices/crashes')}>
|
||||
Crash Report
|
||||
</Button>
|
||||
{canEdit && (
|
||||
<Button variant="primary" onClick={() => navigate('/devices/new')}>
|
||||
Add Device
|
||||
|
||||
@@ -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' && (
|
||||
|
||||
Reference in New Issue
Block a user