diff --git a/frontend/src/pages/bellcloud/devices/DeviceListMapView.jsx b/frontend/src/pages/bellcloud/devices/DeviceListMapView.jsx
index 7e6e47c..787ba57 100644
--- a/frontend/src/pages/bellcloud/devices/DeviceListMapView.jsx
+++ b/frontend/src/pages/bellcloud/devices/DeviceListMapView.jsx
@@ -589,7 +589,7 @@ export default function DeviceListMapView({ devices, loading, mqttStatusMap, onV
onClick={(e) => { e.stopPropagation(); selectDevice(device) }}
style={{ cursor: 'pointer' }}
role="button"
- aria-label={`${device.device_name || 'Device'} — ${color.label}`}
+ aria-label={`${device.console_name || device.device_name || 'Device'} — ${color.label}`}
>
{/* Breathing ring */}
- {selected.device_name || 'Unnamed Device'}
+ {selected.console_name || selected.device_name || 'Unnamed Device'}
+ {children}
+
+ )
+}
+
+// ─── Hero KPI tile — uses Card's real title/subtitle so padding matches
+// every other card on the page exactly, per point #1/#8. Content below the
+// title is vertically centered in the remaining body height (#2).
+
+function KpiTile({ label, value, unit, sublabel, badge, variant }) {
+ const color =
+ variant === 'danger' ? 'var(--color-danger)' :
+ variant === 'warning' ? 'var(--color-warning)' :
+ 'var(--color-text-primary)'
+ return (
+
+
+ {badge ?? (
+
+ {value}
+ {unit && {unit} }
+
+ )}
+ {sublabel && (
+
+ {sublabel}
+
+ )}
+
+
+ )
+}
+
+// ─── Secondary metric tile (more compact, same Card-title convention) ──────
+
+function MetricTile({ label, value, unit, sublabel, children }) {
+ return (
+
+
+ {children ?? (
+
+ {value}
+ {unit && {unit} }
+
+ )}
+ {sublabel && (
+
+ {sublabel}
+
+ )}
+
+
+ )
+}
+
+// ─── Restart timeline — full-width horizontal track, one dot per boot ─────
+// Range-aware per #7: the track always spans [rangeStart, rangeEnd], with
+// dots placed at their real position in that span — a device with only 1
+// day of history inside a 30-day view shows its dots bunched near one edge,
+// not stretched to fill the whole track.
+
+function RestartTimeline({ events, rangeStart, rangeEnd }) {
+ const tMin = rangeStart
+ const tMax = rangeEnd
+ const span = Math.max(1, tMax - tMin)
+
+ return (
+
+
+ {events.length === 0 && (
+
+ No boot events in this range
+
+ )}
+ {events.map((e, i) => {
+ const t = new Date(e.occurred_at).getTime()
+ const pct = Math.max(0, Math.min(100, ((t - tMin) / span) * 100))
+ const color = e.is_fault ? 'var(--color-danger)' : 'var(--color-success)'
+ return (
+
+ )
+ })}
+
+
+
+ {fmtDateTimeMedium(new Date(tMin).toISOString())}
+
+
+ {fmtDateTimeMedium(new Date(tMax).toISOString())}
+
+
+
+ )
+}
+
+// ─── Boot event row (expandable for crash detail) ──────────────────────────
+
+function BootEventRow({ event, expanded, onToggle }) {
+ const hasCrash = event.crash_task != null
+ return (
+
+
+
+ {fmtDateTimeMedium(event.occurred_at)}
+
+
+ {reasonLabel(event.reset_reason)}
+
+
+ boot #{event.boot_count ?? '—'}
+
+
+ {event.free_heap != null && (
+
+ {Math.round(event.free_heap / 1024)} KB free
+
+ )}
+ {hasCrash && (
+
+ )}
+
+ {expanded && hasCrash && (
+
+ {[
+ ['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]) => (
+
+
+ {label}
+
+
+ {val}
+
+
+ ))}
+
+ )}
+
+ )
+}
+
+// ─── Health status icon — computed client-side from Settings thresholds ───
+
+function statusFromThresholds({ mqttStatus, latestDiag, restartsInRange, settings }) {
+ const w = settings.warning || {}
+ const c = settings.critical || {}
+ const rssi = mqttStatus?.last_heartbeat?.rssi
+ const heapKb = mqttStatus?.last_heartbeat?.free_heap != null ? mqttStatus.last_heartbeat.free_heap / 1024 : null
+ const temp = latestDiag?.cpu_temp_avg
+
+ const checks = [
+ [c.restarts_per_week_threshold, restartsInRange],
+ [c.rssi_floor_dbm, rssi, true],
+ [c.free_heap_floor_kb, heapKb, true],
+ [c.cpu_temp_ceiling_c, temp],
+ ]
+ const warnChecks = [
+ [w.restarts_per_week_threshold, restartsInRange],
+ [w.rssi_floor_dbm, rssi, true],
+ [w.free_heap_floor_kb, heapKb, true],
+ [w.cpu_temp_ceiling_c, temp],
+ ]
+
+ const crossed = (threshold, value, isFloor = false) => {
+ if (threshold == null || value == null) return false
+ return isFloor ? value < threshold : value > threshold
+ }
+
+ if (checks.some(([t, v, floor]) => crossed(t, v, floor))) return 'critical'
+ if (warnChecks.some(([t, v, floor]) => crossed(t, v, floor))) return 'warning'
+ return 'ok'
+}
+
+function StatusIcon({ status }) {
+ const meta = {
+ ok: { name: 'check', color: 'var(--color-success)', label: 'All thresholds within range' },
+ warning: { name: 'warning', color: 'var(--color-warning)', label: 'A warning threshold has been crossed' },
+ critical: { name: 'warning', color: 'var(--color-danger)', label: 'A critical threshold has been crossed' },
+ }[status] || { name: 'info', color: 'var(--color-text-muted)', label: 'Status unknown' }
+
+ return (
+
+
+
+ )
+}
+
+// ─── Health overview sub-tab (stats + charts + boot history) ───────────────
+
+function HealthOverview({ sn, mqttStatus, range, offlineTimeoutSec, healthSettings }) {
+ const [bootEvents, setBootEvents] = useState([])
+ const [heartbeats, setHeartbeats] = useState([])
+ const [pingSamples, setPingSamples] = useState([])
+ const [diagReports, setDiagReports] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState('')
+ const [expandedId, setExpandedId] = useState(null)
+
+ const rangeParams = useMemo(() => {
+ const p = new URLSearchParams()
+ if (range.since) p.set('since', range.since.toISOString())
+ if (range.until) p.set('until', range.until.toISOString())
+ return p.toString()
+ }, [range])
+
+ const load = useCallback(async () => {
+ if (!sn) return
+ setLoading(true)
+ setError('')
+ try {
+ const suffix = rangeParams ? `&${rangeParams}` : ''
+ const [bootRes, hbRes, pingRes, diagRes] = await Promise.all([
+ api.get(`/mqtt/boot-events/${sn}?limit=2000${suffix}`),
+ api.get(`/mqtt/heartbeats/${sn}?limit=5000${suffix}`),
+ api.get(`/mqtt/ping-samples/${sn}?limit=5000${suffix}`),
+ api.get(`/mqtt/diagnostics-reports/${sn}?limit=5000${suffix}`),
+ ])
+ setBootEvents(bootRes.events || [])
+ setHeartbeats(hbRes.heartbeats || [])
+ setPingSamples(pingRes.samples || [])
+ setDiagReports(diagRes.reports || [])
+ } catch (err) {
+ setError(err.message || 'Failed to load device health data.')
+ } finally {
+ setLoading(false)
+ }
+ }, [sn, rangeParams])
+
+ useEffect(() => { load() }, [load])
+
+ if (loading) {
+ return (
+
+
+
+ )
+ }
+
+ if (error) {
+ return (
+
+ {error}
+
+ )
+ }
+
+ const latestBoot = bootEvents[0] || null
+ const latestDiag = diagReports[0] || null
+ const restartsInRange = bootEvents.length
+ const rangeLabelShort = range.key === 'all' ? 'all-time' : (
+ range.key === 'custom' ? 'custom range' :
+ { '10m': '10 min', '1h': '1 hour', '3h': '3 hours', '24h': '24 hours', '3d': '3 days', '7d': '7 days', '30d': '30 days', '3mo': '3 months', '6mo': '6 months' }[range.key] || range.key
+ )
+
+ // Resolved axis bounds for every chart + the restart timeline — the FULL
+ // requested range, not just whatever data happens to exist (#7). "all"
+ // range has no lower bound, so fall back to the earliest sample actually
+ // present (or now-24h if there's nothing at all) purely so the axis has
+ // something sensible to show.
+ const allTimestamps = [
+ ...heartbeats.map(h => new Date(h.received_at).getTime()),
+ ...pingSamples.map(p => new Date(p.sampled_at).getTime()),
+ ...diagReports.map(d => new Date(d.received_at).getTime()),
+ ...bootEvents.map(e => new Date(e.occurred_at).getTime()),
+ ]
+ const rangeXMax = range.until ? range.until.getTime() : Date.now()
+ const rangeXMin = range.since ? range.since.getTime() : (allTimestamps.length ? Math.min(...allTimestamps) : rangeXMax - 86400000)
+
+ // ── Offline windows — shared across all four trend charts ────────────────
+ // Computed once from heartbeats (the highest-frequency, always-expected
+ // signal — every 30s from any online device) and applied identically to
+ // RSSI, Heap, Ping, and CPU Temp, rather than each chart inferring gaps
+ // from its own series. This matters because ping (every 60s) and
+ // diagnostics (every 5min) are naturally sparser than heartbeats, so a gap
+ // big enough to mean "offline" on the heartbeat timeline doesn't reliably
+ // show up as an anomalous gap in those slower series' own timestamps — and
+ // conversely, an outage that also took down the console itself (e.g. a
+ // power cut) still shows up as a heartbeat gap even though, from any single
+ // series' own perspective, it just looks like "no data for a while," which
+ // is indistinguishable from that series' normal sample spacing.
+ //
+ // Threshold is relative to the visible range (2% of its width) rather than
+ // a fixed duration — a 3-minute silence is obviously "offline" when viewing
+ // the last hour, but invisible noise when viewing the last 3 months. Floored
+ // at the device's own offline-timeout setting so a busy short-range view
+ // never flags normal heartbeat jitter as an outage.
+ const visibleSpanMs = rangeXMax - rangeXMin
+ const offlineWindowThresholdMs = Math.max(offlineTimeoutSec * 1000, visibleSpanMs * 0.02)
+
+ const offlineWindows = []
+ {
+ const hbTimes = heartbeats.map(h => new Date(h.received_at).getTime()).sort((a, b) => a - b)
+ let cursor = rangeXMin
+ for (const t of hbTimes) {
+ if (t - cursor > offlineWindowThresholdMs) offlineWindows.push({ start: cursor, end: t })
+ cursor = Math.max(cursor, t)
+ }
+ // Trailing edge: an outage still in progress (no "next" heartbeat yet)
+ // has no closing point in the array at all, so it must be checked
+ // against the end of the visible range explicitly.
+ if (rangeXMax - cursor > offlineWindowThresholdMs) offlineWindows.push({ start: cursor, end: rangeXMax })
+ }
+
+ const rssiSeries = [...heartbeats].reverse().map(h => ({ x: new Date(h.received_at).getTime(), y: h.rssi ?? null }))
+ const heapSeries = [...heartbeats].reverse().map(h => ({ x: new Date(h.received_at).getTime(), y: h.free_heap ?? null }))
+ const pingSeries = [...pingSamples].reverse().map(p => ({ x: new Date(p.sampled_at).getTime(), y: p.rtt_ms }))
+ // cpu_temp_avg is null on reports where no samples were taken yet that
+ // window (e.g. the very first report shortly after boot) — rendered as a
+ // gap by LineChart, not a false zero.
+ const tempSeries = [...diagReports].reverse().map(d => ({ x: new Date(d.received_at).getTime(), y: d.cpu_temp_avg ?? null }))
+
+ let stackHighWater = {}
+ try {
+ stackHighWater = latestDiag?.stack_high_water ? JSON.parse(latestDiag.stack_high_water) : {}
+ } catch {
+ stackHighWater = {}
+ }
+ const lowestStackEntry = Object.entries(stackHighWater).sort((a, b) => a[1] - b[1])[0]
+ const stackWarning = lowestStackEntry && lowestStackEntry[1] < 1024
+
+ // formatX must be date-aware once the range spans more than a day, or two
+ // axis endpoints 24h+ apart can render identical "HH:MM" labels.
+ const spanMs = rangeXMax - rangeXMin
+ const formatTime = x => spanMs > 36 * 3600000
+ ? new Date(x).toLocaleString([], { month: 'short', day: 'numeric' })
+ : new Date(x).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
+
+ // CPU temperature color stops — 3 user-configurable bands from Settings
+ // (cool/warn/ceiling), interpolated linearly by LineChart's gradient, not
+ // hard-cut.
+ const tempCeiling = healthSettings?.warning?.cpu_temp_ceiling_c ?? 65
+ const tempCritical = healthSettings?.critical?.cpu_temp_ceiling_c ?? 80
+ const tempColorStops = [
+ { value: tempCeiling - 20, color: 'var(--color-info)' },
+ { value: tempCeiling, color: 'var(--color-warning)' },
+ { value: tempCritical, color: 'var(--color-danger)' },
+ ]
+
+ return (
+
+
+ {/* ── Hero KPI row (compact) ───────────────────────────────────────── */}
+
+
+ 5 ? 'danger' : restartsInRange > 0 ? 'warning' : undefined}
+ sublabel={healthSettings?.warning?.restarts_per_week_threshold != null ? `alert threshold: ${healthSettings.warning.restarts_per_week_threshold} / week` : null}
+ />
+
+
+ {reasonLabel(latestBoot.reset_reason)}
+
+ ) : (
+
+ No boot data yet
+
+ )}
+ />
+
+
+ {/* ── Secondary metrics row (compact) ──────────────────────────────── */}
+
+
+
+
+
+ {latestDiag?.ota_current_version ? `v${latestDiag.ota_current_version}` : '—'}
+
+ {latestDiag && (
+
+
+ {latestDiag.ota_update_available ? 'update available' : 'up to date'}
+
+
+ )}
+
+
+
+ {lowestStackEntry ? (
+ <>
+
+ {lowestStackEntry[1]}B free
+
+
+ {lowestStackEntry[0]} (lowest)
+
+ >
+ ) : (
+ —
+ )}
+
+
+
+ {/* ── Trend graphs ─────────────────────────────────────────────────── */}
+
+
+ Math.round(v)}
+ formatX={formatTime}
+ xMin={rangeXMin}
+ xMax={rangeXMax}
+ offlineWindows={offlineWindows}
+ gapStyle="baseline"
+ emptyLabel="No heartbeat data in this range."
+ />
+
+
+
+ Math.round(v)}
+ formatX={formatTime}
+ xMin={rangeXMin}
+ xMax={rangeXMax}
+ offlineWindows={offlineWindows}
+ gapStyle="baseline"
+ emptyLabel="No ping samples in this range — not supported on non-RTC hardware variants."
+ />
+
+
+
+ Math.round(v / 1024)}
+ formatX={formatTime}
+ xMin={rangeXMin}
+ xMax={rangeXMax}
+ offlineWindows={offlineWindows}
+ gapStyle="baseline"
+ emptyLabel="No heartbeat data in this range."
+ />
+
+
+
+ v.toFixed(1)}
+ formatX={formatTime}
+ xMin={rangeXMin}
+ xMax={rangeXMax}
+ offlineWindows={offlineWindows}
+ gapStyle="baseline"
+ emptyLabel="No diagnostics reports in this range."
+ />
+
+
+
+ {/* ── Restart timeline — full page width ───────────────────────────── */}
+
+
+
+
+ {/* ── Full boot event history ──────────────────────────────────────── */}
+
+ {bootEvents.length === 0 ? (
+
+ No boot events recorded in this range.
+
+ ) : (
+
+ {bootEvents.map(event => (
+ setExpandedId(prev => prev === event.id ? null : event.id)}
+ />
+ ))}
+
+ )}
+
+
+ )
+}
+
+function formatUptimeShort(ms) {
+ const seconds = Math.floor(ms / 1000)
+ const d = Math.floor(seconds / 86400)
+ const h = Math.floor((seconds % 86400) / 3600)
+ const m = Math.floor((seconds % 3600) / 60)
+ return [d && `${d}d`, h && `${h}h`, m && `${m}m`].filter(Boolean).join(' ') || '< 1m'
+}
+
+// ─── Toggle switch (matches PublicFeaturesSettings' pattern) ──────────────
+
+function ToggleSwitch({ enabled, onChange, disabled, id }) {
+ return (
+
onChange(!enabled)}
+ style={{
+ flexShrink: 0,
+ position: 'relative',
+ display: 'inline-flex',
+ width: '44px',
+ height: '24px',
+ borderRadius: 'var(--radius-full)',
+ border: 'none',
+ cursor: disabled ? 'not-allowed' : 'pointer',
+ padding: 0,
+ transition: 'background 0.2s',
+ background: enabled ? 'var(--color-success)' : 'var(--color-bg-island)',
+ boxShadow: enabled
+ ? 'inset 0 1px 3px rgba(0,0,0,0.2), 0 0 0 1px var(--color-success)'
+ : 'inset 0 1px 3px rgba(0,0,0,0.2), 0 0 0 1px var(--color-border-strong)',
+ opacity: disabled ? 0.5 : 1,
+ }}
+ >
+
+
+ )
+}
+
+// ─── Settings sub-tab — per-device alert thresholds ────────────────────────
+// Each metric gets ONE row with two number inputs — Warning and Critical —
+// rather than the old single-threshold-per-metric layout, so both severity
+// levels driving the header status icon are visible and editable together.
+
+const DEFAULT_THRESHOLDS = {
+ warning: { restarts_per_week_threshold: 6, rssi_floor_dbm: -80, free_heap_floor_kb: 30, cpu_temp_ceiling_c: 65 },
+ critical: { restarts_per_week_threshold: 12, rssi_floor_dbm: -90, free_heap_floor_kb: 15, cpu_temp_ceiling_c: 80 },
+}
+
+const THRESHOLD_ROWS = [
+ { key: 'restarts_per_week_threshold', label: 'Restarts per week', unit: '', hint: 'alert if restarts in the selected range exceed this' },
+ { key: 'rssi_floor_dbm', label: 'Signal strength floor', unit: 'dBm', hint: 'alert if RSSI drops below' },
+ { key: 'free_heap_floor_kb', label: 'Free heap floor', unit: 'KB', hint: 'alert if free heap drops below' },
+ { key: 'cpu_temp_ceiling_c', label: 'CPU temperature ceiling', unit: '°C', hint: 'alert if temperature exceeds' },
+]
+
+function ThresholdRow({ row, warning, critical, onChange }) {
+ return (
+
+
+
{row.label}
+
{row.hint}{row.unit ? ` (${row.unit})` : ''}
+
+
onChange('warning', row.key, e.target.value)}
+ />
+ onChange('critical', row.key, e.target.value)}
+ />
+
+ )
+}
+
+function HealthSettings({ deviceId, settings, onSaved }) {
+ const { toast } = useToast()
+ const [form, setForm] = useState({
+ warning: { ...DEFAULT_THRESHOLDS.warning, ...settings.warning },
+ critical: { ...DEFAULT_THRESHOLDS.critical, ...settings.critical },
+ offline_timeout_sec: settings.offline_timeout_sec ?? DEFAULT_OFFLINE_TIMEOUT_SEC,
+ email_on_threshold: settings.email_on_threshold ?? false,
+ push_on_crash_boot: settings.push_on_crash_boot ?? false,
+ })
+ const [saving, setSaving] = useState(false)
+
+ useEffect(() => {
+ setForm({
+ warning: { ...DEFAULT_THRESHOLDS.warning, ...settings.warning },
+ critical: { ...DEFAULT_THRESHOLDS.critical, ...settings.critical },
+ offline_timeout_sec: settings.offline_timeout_sec ?? DEFAULT_OFFLINE_TIMEOUT_SEC,
+ email_on_threshold: settings.email_on_threshold ?? false,
+ push_on_crash_boot: settings.push_on_crash_boot ?? false,
+ })
+ }, [settings])
+
+ const setThreshold = (severity, key, value) => setForm(prev => ({
+ ...prev, [severity]: { ...prev[severity], [key]: value },
+ }))
+ const setField = (key, value) => setForm(prev => ({ ...prev, [key]: value }))
+
+ const save = async () => {
+ setSaving(true)
+ try {
+ const payload = {
+ warning: Object.fromEntries(THRESHOLD_ROWS.map(r => [r.key, Number(form.warning[r.key])])),
+ critical: Object.fromEntries(THRESHOLD_ROWS.map(r => [r.key, Number(form.critical[r.key])])),
+ offline_timeout_sec: Number(form.offline_timeout_sec),
+ email_on_threshold: !!form.email_on_threshold,
+ push_on_crash_boot: !!form.push_on_crash_boot,
+ }
+ const res = await api.put(`/devices/${deviceId}`, { device_health_settings: payload })
+ onSaved(res.device_health_settings || payload)
+ toast.success('Saved', 'Alert thresholds updated.')
+ } catch (err) {
+ toast.danger('Error', err.message || 'Failed to save thresholds.')
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ return (
+
+
+
+
+ Warning
+ Critical
+
+ {THRESHOLD_ROWS.map(row => (
+
+ ))}
+
+
+ setField('offline_timeout_sec', e.target.value)}
+ hint="seconds — no message of any kind for this long ⇒ device considered offline (also controls chart gap detection)"
+ />
+
+
+
+
+
+
Email me when a threshold is crossed
+
Not yet active — no email infrastructure wired up.
+
+
setField('email_on_threshold', v)} />
+
+
+
+
Push notification on crash boot
+
Not yet active — no push infrastructure wired up.
+
+
setField('push_on_crash_boot', v)} />
+
+
+
+
+ Save Thresholds
+
+
+
+ )
+}
+
+// ─── HealthTab ──────────────────────────────────────────────────────────────
+
+function SubTabPill({ active, onClick, children }) {
+ return (
+
+ {children}
+
+ )
+}
+
+// Reads the header status icon's inputs even while a sub-tab other than
+// Overview is active, so the icon doesn't disappear/reset when you switch
+// to Logs or Settings — it needs its own small, independent fetch of just
+// the latest heartbeat/diagnostics/boot-count, not the full ranged dataset
+// HealthOverview loads.
+function useHeaderStatus(sn, healthSettings, mqttStatus) {
+ const [latestDiag, setLatestDiag] = useState(null)
+ const [restarts7d, setRestarts7d] = useState(0)
+
+ useEffect(() => {
+ if (!sn) return
+ let cancelled = false
+ const sevenDaysAgo = new Date(Date.now() - 7 * 86400000).toISOString()
+ Promise.all([
+ api.get(`/mqtt/diagnostics-reports/${sn}?limit=1`).catch(() => null),
+ api.get(`/mqtt/boot-events/${sn}?limit=200&since=${encodeURIComponent(sevenDaysAgo)}`).catch(() => null),
+ ]).then(([diagRes, bootRes]) => {
+ if (cancelled) return
+ setLatestDiag(diagRes?.reports?.[0] || null)
+ setRestarts7d(bootRes?.total ?? bootRes?.events?.length ?? 0)
+ })
+ return () => { cancelled = true }
+ }, [sn])
+
+ return statusFromThresholds({
+ mqttStatus,
+ latestDiag,
+ restartsInRange: restarts7d,
+ settings: healthSettings,
+ })
+}
+
+export default function HealthTab({ device, sn, mqttStatus }) {
+ const [subTab, setSubTab] = useState('overview')
+ const [range, setRange] = useState(() => resolveRange('7d'))
+ // Local override after a successful save, so the Settings form and offline-
+ // timeout derivation reflect the new values immediately without waiting on
+ // the parent's device prop to re-fetch. Falls back to whatever's on the
+ // device doc otherwise — no effect-based sync needed since device updates
+ // flow down as a fresh prop value on every render already.
+ const [savedOverride, setSavedOverride] = useState(null)
+ const healthSettings = savedOverride || device?.device_health_settings || {}
+
+ const offlineTimeoutSec = healthSettings.offline_timeout_sec || DEFAULT_OFFLINE_TIMEOUT_SEC
+ const headerStatus = useHeaderStatus(sn, healthSettings, mqttStatus)
+
+ return (
+
+
+
+ setSubTab('overview')}>
+
+ Overview
+
+ setSubTab('logs')}>Logs
+ setSubTab('settings')}>
+
+ Settings
+
+
+ {/* Always rendered (Overview + Logs both use it) so the toolbar never
+ changes height when switching sub-tabs — only Settings hides it,
+ in which case an invisible spacer of the same size holds the row
+ height steady. */}
+
+
+
+
+
+ {subTab === 'overview' && (
+
+ )}
+ {subTab === 'logs' &&
}
+ {subTab === 'settings' && (
+
+ )}
+
+ )
+}
diff --git a/frontend/src/pages/bellcloud/devices/tabs/LogsTab.jsx b/frontend/src/pages/bellcloud/devices/tabs/LogsTab.jsx
new file mode 100644
index 0000000..f682155
--- /dev/null
+++ b/frontend/src/pages/bellcloud/devices/tabs/LogsTab.jsx
@@ -0,0 +1,968 @@
+// frontend/src/pages/bellcloud/devices/tabs/LogsTab.jsx
+// Logs tab — full-page device log explorer across every MQTT channel:
+// Unified (Logs+Info+Alerts merged), Device Logs, Info, Alerts, Heartbeats, Commands.
+
+import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
+import api from '@/lib/api'
+import Spinner from '@/components/ui/Spinner'
+import StatusBadge from '@/components/ui/StatusBadge'
+import Pagination from '@/components/ui/Pagination'
+import { fmtDateTimeMedium, fmtRelative, fmtLogTimestamp } from '@/lib/formatters'
+import { useMqttWebSocket } from '@/hooks/useMqttWebSocket'
+
+// ─── Glass surface tokens (match Overview / Control tab aesthetic) ────────────
+
+const GLASS = 'rgba(28, 32, 38, 0.30)'
+const GLASS_INNER = 'rgba(28, 32, 38, 0.40)'
+const BLUR = 'blur(12px)'
+
+function GlassCard({ children, style = {} }) {
+ return (
+
+ {children}
+
+ )
+}
+
+function SectionLabel({ children }) {
+ return (
+
+ {children}
+
+ )
+}
+
+// ─── Level / source colour maps ───────────────────────────────────────────────
+
+// INFO stays neutral/white — only WARN and ERROR get colour, so they read as
+// "something's wrong" at a glance instead of every line looking alarming.
+const LOG_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)' },
+}
+const MESSAGE_TEXT_COLOR = {
+ ERROR: 'var(--color-danger)',
+ WARN: 'var(--color-warning)',
+ INFO: 'var(--color-text-primary)',
+}
+
+const ALERT_STATE_COLORS = {
+ CRITICAL: { text: 'var(--color-danger)', bg: 'var(--color-danger-bg)' },
+ FAILED: { text: 'var(--color-danger)', bg: 'var(--color-danger-bg)' },
+ WARNING: { text: 'var(--color-warning)', bg: 'var(--color-warning-bg)' },
+}
+
+// Stepped severity: picking WARN also shows WARN + ERROR, picking INFO shows all.
+const 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 LEVEL_RANK = { INFO: 0, WARN: 1, ERROR: 2 }
+
+// Channel chips — color-coded per channel, chip style matches LEVEL_STEPS.
+const CHANNELS = [
+ { key: 'unified', label: 'Unified', color: 'var(--color-primary)' },
+ { key: 'log', label: 'Device Logs', color: 'var(--color-info)' },
+ { key: 'info', label: 'Info', color: 'var(--color-text-secondary)' },
+ { key: 'alerts', label: 'Alerts', color: 'var(--color-warning)' },
+ { key: 'heartbeats', label: 'Heartbeats', color: 'var(--color-success)' },
+ { key: 'commands', label: 'Commands', color: 'var(--color-danger)' },
+]
+
+const SOURCE_LABELS = { log: 'Device Log', info: 'Info', alert: 'Alert' }
+const SOURCE_COLORS = {
+ log: 'var(--color-info)',
+ info: 'var(--color-text-secondary)',
+ alert: 'var(--color-warning)',
+}
+
+const PAGE_SIZE = 50
+
+// ─── Channel chip switcher ─────────────────────────────────────────────────────
+
+function ChannelChips({ active, onChange }) {
+ return (
+
+ {CHANNELS.map(ch => (
+ onChange(ch.key)}
+ style={{
+ height: 24,
+ paddingInline: 'var(--space-2)',
+ borderRadius: 'var(--radius-sm)',
+ border: `1px solid ${active === ch.key ? ch.color : 'var(--color-border)'}`,
+ background: active === ch.key ? `${ch.color}22` : GLASS_INNER,
+ color: active === ch.key ? ch.color : 'var(--color-text-muted)',
+ fontSize: 'var(--font-size-xs)',
+ fontWeight: 'var(--font-weight-semibold)',
+ letterSpacing: 'var(--tracking-wide)',
+ cursor: 'pointer',
+ transition: 'all 0.15s',
+ fontFamily: 'var(--font-family-base)',
+ whiteSpace: 'nowrap',
+ }}
+ >
+ {ch.label}
+
+ ))}
+
+ )
+}
+
+// ─── Toolbar ──────────────────────────────────────────────────────────────────
+
+function LogToolbar({
+ channel, onChannelChange,
+ showLevelFilter, levelStep, setLevelStep,
+ searchText, setSearchText,
+ autoScroll, setAutoScroll,
+ autoRefresh, setAutoRefresh,
+ onRefresh,
+ liveCount,
+}) {
+ return (
+ <>
+
+
+ {/* Channel chips — left side, same row as the rest of the toolbar */}
+
+
+ {/* Live pulse indicator */}
+ {liveCount > 0 && (
+
+
+
+ LIVE
+
+
+ )}
+
+
+
+ {/* Level colour slider */}
+ {showLevelFilter && (
+
+
Level
+
+ {LEVEL_STEPS.map((step, idx) => (
+ setLevelStep(idx)}
+ title={step.label}
+ style={{
+ height: 24,
+ paddingInline: 'var(--space-2)',
+ borderRadius: 'var(--radius-sm)',
+ border: `1px solid ${levelStep === idx ? step.color : 'var(--color-border)'}`,
+ background: levelStep === idx ? `${step.color}22` : GLASS_INNER,
+ 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',
+ transition: 'all 0.15s',
+ fontFamily: 'var(--font-family-base)',
+ }}
+ >
+ {step.label}
+
+ ))}
+
+
+ )}
+
+ {/* Search */}
+
setSearchText(e.target.value)}
+ placeholder="Filter messages…"
+ style={{
+ height: 28,
+ padding: '0 var(--space-3)',
+ borderRadius: 'var(--radius-md)',
+ border: '1px solid var(--color-border-strong)',
+ background: GLASS_INNER,
+ backdropFilter: BLUR,
+ WebkitBackdropFilter: BLUR,
+ color: 'var(--color-text-primary)',
+ fontSize: 'var(--font-size-xs)',
+ fontFamily: 'var(--font-family-base)',
+ width: 180,
+ outline: 'none',
+ flexShrink: 0,
+ }}
+ />
+
+ {/* Toggle: AUTO-SCROLL */}
+
setAutoScroll(p => !p)}
+ style={{
+ height: 28,
+ paddingInline: 'var(--space-3)',
+ borderRadius: 'var(--radius-md)',
+ border: `1px solid ${autoScroll ? 'var(--color-primary)' : 'var(--color-border-strong)'}`,
+ background: autoScroll ? 'var(--color-primary-subtle)' : GLASS_INNER,
+ color: autoScroll ? 'var(--color-primary)' : 'var(--color-text-muted)',
+ fontSize: 'var(--font-size-xs)',
+ fontWeight: 'var(--font-weight-semibold)',
+ cursor: 'pointer',
+ letterSpacing: 'var(--tracking-wide)',
+ fontFamily: 'var(--font-family-base)',
+ flexShrink: 0,
+ }}
+ >
+ AUTO-SCROLL
+
+
+ {/* Toggle: AUTO-SYNC */}
+
setAutoRefresh(p => !p)}
+ style={{
+ height: 28,
+ paddingInline: 'var(--space-3)',
+ borderRadius: 'var(--radius-md)',
+ border: `1px solid ${autoRefresh ? 'var(--color-success)' : 'var(--color-border-strong)'}`,
+ background: autoRefresh ? 'rgba(74,222,128,0.10)' : GLASS_INNER,
+ color: autoRefresh ? 'var(--color-success)' : 'var(--color-text-muted)',
+ fontSize: 'var(--font-size-xs)',
+ fontWeight: 'var(--font-weight-semibold)',
+ cursor: 'pointer',
+ letterSpacing: 'var(--tracking-wide)',
+ fontFamily: 'var(--font-family-base)',
+ flexShrink: 0,
+ }}
+ >
+ AUTO-SYNC
+
+
+ {/* Refresh — no spinner/dim-out here: a background sync must stay
+ seamless (see fetchRows' `background` flag). Only a genuinely
+ stuck request would ever need a visual cue, which isn't worth
+ the flicker cost for a 5s poll. */}
+
+ REFRESH
+
+
+ >
+ )
+}
+
+// ─── Empty / loading table states ────────────────────────────────────────────
+
+function TableState({ loading, empty, colSpan, emptyText }) {
+ if (loading) {
+ return (
+
+
+
+ )
+ }
+ if (empty) {
+ return (
+
+ {emptyText}
+
+ )
+ }
+ return null
+}
+
+// ─── Device Logs / Info panel (shared shape — text lines with level) ─────────
+
+function TextLogPanel({ sn, source, liveTypes, channel, onChannelChange, range }) {
+ const [rows, setRows] = useState([])
+ const [total, setTotal] = useState(0)
+ const [initialLoading, setInitialLoading] = useState(true)
+ const [page, setPage] = useState(1)
+ const [levelStep, setLevelStep] = useState(0)
+ const [searchText, setSearchText] = useState('')
+ const [autoScroll, setAutoScroll] = useState(true)
+ const [autoRefresh, setAutoRefresh] = useState(true)
+ const [liveRows, setLiveRows] = useState([])
+ const containerRef = useRef(null)
+ const hasLoadedOnce = useRef(false)
+
+ const levelFilter = LEVEL_STEPS[levelStep].value
+
+ // `background` fetches (polling / silent revalidation) never toggle a loading
+ // flag — only the very first load for this panel shows a spinner. This is
+ // what keeps auto-sync and manual refresh from flickering the toolbar/table.
+ const fetchRows = useCallback(async (background = false) => {
+ if (!sn) return
+ if (!background && !hasLoadedOnce.current) setInitialLoading(true)
+ try {
+ const params = new URLSearchParams({
+ limit: PAGE_SIZE, offset: String((page - 1) * PAGE_SIZE),
+ source,
+ })
+ if (levelFilter) { params.set('level', levelFilter); params.set('min_level', 'true') }
+ if (searchText) params.set('search', searchText)
+ if (range?.since) params.set('since', range.since.toISOString())
+ if (range?.until) params.set('until', range.until.toISOString())
+ const data = await api.get(`/mqtt/logs/${sn}?${params}`)
+ setRows(data.logs || [])
+ setTotal(data.total || 0)
+ } catch {
+ // silent — table shows previous data until next successful fetch
+ } finally {
+ hasLoadedOnce.current = true
+ setInitialLoading(false)
+ }
+ }, [sn, source, levelFilter, searchText, page, range])
+
+ useEffect(() => { fetchRows(false) }, [fetchRows])
+
+ useEffect(() => {
+ if (!autoRefresh || !sn) return
+ const t = setInterval(() => fetchRows(true), 5000)
+ return () => clearInterval(t)
+ }, [autoRefresh, sn, fetchRows])
+
+ const handleWsMessage = useCallback((data) => {
+ if (liveTypes.includes(data.type) && data.device_serial === sn) {
+ const entry = {
+ id: `live-${Date.now()}-${Math.random()}`,
+ level: data.payload?.level?.includes('EROR') ? 'ERROR' :
+ data.payload?.level?.includes('WARN') ? 'WARN' : 'INFO',
+ message: data.payload?.message || data.payload?.type || '',
+ received_at: new Date().toISOString(),
+ _live: true,
+ }
+ setLiveRows(prev => [entry, ...prev].slice(0, 50))
+ }
+ }, [sn, liveTypes])
+
+ useMqttWebSocket({ enabled: page === 1, onMessage: handleWsMessage })
+
+ const meetsLevelFloor = lvl => !levelFilter || (LEVEL_RANK[lvl] ?? 0) >= LEVEL_RANK[levelFilter]
+
+ const liveFiltered = page === 1 ? liveRows.filter(l => {
+ if (!meetsLevelFloor(l.level)) return false
+ if (searchText && !l.message.toLowerCase().includes(searchText.toLowerCase())) return false
+ return true
+ }) : []
+
+ const allRows = [...liveFiltered, ...rows]
+
+ const liveCount = liveFiltered.length
+ const prevLiveCount = useRef(liveCount)
+ useEffect(() => {
+ if (liveCount > prevLiveCount.current && autoScroll && containerRef.current) {
+ containerRef.current.scrollTop = 0
+ }
+ prevLiveCount.current = liveCount
+ }, [liveCount, autoScroll])
+
+ return (
+ <>
+
{ setLevelStep(idx); setPage(1) }}
+ searchText={searchText} setSearchText={v => { setSearchText(v); setPage(1) }}
+ autoScroll={autoScroll} setAutoScroll={setAutoScroll}
+ autoRefresh={autoRefresh} setAutoRefresh={setAutoRefresh}
+ onRefresh={() => fetchRows(true)}
+ liveCount={liveCount}
+ />
+
+
+
+
+ {['Time', 'Level', 'Message'].map((h, i) => (
+
+ {h}
+
+ ))}
+
+
+
+
+ {allRows.map((log, idx) => {
+ const s = LOG_COLORS[log.level] || LOG_COLORS.INFO
+ return (
+
+
+ {fmtLogTimestamp(log.received_at)}
+ {log._live && LIVE }
+
+
+
+ {log.level}
+
+
+
+ {log.message}
+
+
+ )
+ })}
+
+
+
+ {total > PAGE_SIZE && (
+
+ )}
+ >
+ )
+}
+
+// ─── Unified feed (Device Logs + Info + Alerts merged, chronological) ────────
+
+function UnifiedPanel({ sn, channel, onChannelChange, range }) {
+ const [logRows, setLogRows] = useState([])
+ const [alertRows, setAlertRows] = useState([])
+ const [initialLoading, setInitialLoading] = useState(true)
+ const [levelStep, setLevelStep] = useState(0)
+ const [searchText, setSearchText] = useState('')
+ const [autoScroll, setAutoScroll] = useState(true)
+ const [autoRefresh, setAutoRefresh] = useState(true)
+ const [liveRows, setLiveRows] = useState([])
+ const containerRef = useRef(null)
+ const hasLoadedOnce = useRef(false)
+
+ const levelFilter = LEVEL_STEPS[levelStep].value
+
+ const fetchAll = useCallback(async (background = false) => {
+ if (!sn) return
+ if (!background && !hasLoadedOnce.current) setInitialLoading(true)
+ try {
+ const params = new URLSearchParams({ limit: '100', offset: '0' })
+ params.append('source', 'log')
+ params.append('source', 'info')
+ if (levelFilter) { params.set('level', levelFilter); params.set('min_level', 'true') }
+ if (searchText) params.set('search', searchText)
+ if (range?.since) params.set('since', range.since.toISOString())
+ if (range?.until) params.set('until', range.until.toISOString())
+ const [logsData, alertsData] = await Promise.all([
+ api.get(`/mqtt/logs/${sn}?${params}`),
+ api.get(`/mqtt/alert-events/${sn}?limit=50&offset=0`),
+ ])
+ setLogRows(logsData.logs || [])
+ setAlertRows(alertsData.events || [])
+ } catch {
+ // silent
+ } finally {
+ hasLoadedOnce.current = true
+ setInitialLoading(false)
+ }
+ }, [sn, levelFilter, searchText, range])
+
+ useEffect(() => { fetchAll(false) }, [fetchAll])
+
+ useEffect(() => {
+ if (!autoRefresh || !sn) return
+ const t = setInterval(() => fetchAll(true), 5000)
+ return () => clearInterval(t)
+ }, [autoRefresh, sn, fetchAll])
+
+ const handleWsMessage = useCallback((data) => {
+ if (data.device_serial !== sn) return
+ if (data.type === 'system/logs' || data.type === 'system/info') {
+ const entry = {
+ _kind: 'log',
+ source: data.type === 'system/logs' ? 'log' : 'info',
+ id: `live-${Date.now()}-${Math.random()}`,
+ level: data.payload?.level?.includes('EROR') ? 'ERROR' :
+ data.payload?.level?.includes('WARN') ? 'WARN' : 'INFO',
+ message: data.payload?.message || data.payload?.type || '',
+ received_at: new Date().toISOString(),
+ _live: true,
+ }
+ setLiveRows(prev => [entry, ...prev].slice(0, 50))
+ } else if (data.type === 'system/alerts' && data.payload?.state !== 'CLEARED') {
+ const entry = {
+ _kind: 'alert',
+ source: 'alert',
+ id: `live-${Date.now()}-${Math.random()}`,
+ state: data.payload?.state,
+ subsystem: data.payload?.subsystem,
+ message: data.payload?.msg || '',
+ occurred_at: new Date().toISOString(),
+ _live: true,
+ }
+ setLiveRows(prev => [entry, ...prev].slice(0, 50))
+ }
+ }, [sn])
+
+ useMqttWebSocket({ enabled: true, onMessage: handleWsMessage })
+
+ const meetsLevelFloor = lvl => !levelFilter || (LEVEL_RANK[lvl] ?? 0) >= LEVEL_RANK[levelFilter]
+
+ const merged = useMemo(() => {
+ const logEntries = logRows.map(l => ({ _kind: 'log', source: l.source || 'log', ...l }))
+ const alertEntries = alertRows.map(a => ({ _kind: 'alert', source: 'alert', ...a, received_at: a.occurred_at }))
+ const combined = [...logEntries, ...alertEntries]
+ combined.sort((a, b) => new Date(b.received_at) - new Date(a.received_at))
+ return combined
+ }, [logRows, alertRows])
+
+ const liveFiltered = liveRows.filter(l => {
+ if (l._kind === 'alert') return true
+ if (!meetsLevelFloor(l.level)) return false
+ if (searchText && !l.message.toLowerCase().includes(searchText.toLowerCase())) return false
+ return true
+ })
+
+ const allRows = [...liveFiltered, ...merged]
+
+ const liveCount = liveFiltered.length
+ const prevLiveCount = useRef(liveCount)
+ useEffect(() => {
+ if (liveCount > prevLiveCount.current && autoScroll && containerRef.current) {
+ containerRef.current.scrollTop = 0
+ }
+ prevLiveCount.current = liveCount
+ }, [liveCount, autoScroll])
+
+ return (
+ <>
+ fetchAll(true)}
+ liveCount={liveCount}
+ />
+
+
+
+
+ {['Time', 'Channel', 'Level', 'Message'].map((h, i) => (
+
+ {h}
+
+ ))}
+
+
+
+
+ {allRows.map((row, idx) => {
+ const channelColor = SOURCE_COLORS[row.source] || SOURCE_COLORS.log
+ const channelCell = (
+
+
+ {SOURCE_LABELS[row.source] || row.source}
+
+
+ )
+ if (row._kind === 'alert') {
+ const s = ALERT_STATE_COLORS[row.state] || ALERT_STATE_COLORS.WARNING
+ return (
+
+
+ {fmtLogTimestamp(row.received_at)}
+ {row._live && LIVE }
+
+ {channelCell}
+
+
+ {row.state}
+
+
+
+ [{row.subsystem}]
+ {row.message || '—'}
+
+
+ )
+ }
+ const s = LOG_COLORS[row.level] || LOG_COLORS.INFO
+ return (
+
+
+ {fmtLogTimestamp(row.received_at)}
+ {row._live && LIVE }
+
+ {channelCell}
+
+
+ {row.level}
+
+
+
+ {row.message}
+
+
+ )
+ })}
+
+
+
+ >
+ )
+}
+
+// ─── Alerts history panel ─────────────────────────────────────────────────────
+
+function AlertsPanel({ sn, channel, onChannelChange }) {
+ const [rows, setRows] = useState([])
+ const [total, setTotal] = useState(0)
+ const [initialLoading, setInitialLoading] = useState(true)
+ const [page, setPage] = useState(1)
+ const hasLoadedOnce = useRef(false)
+
+ const fetchRows = useCallback(async (background = false) => {
+ if (!sn) return
+ if (!background && !hasLoadedOnce.current) setInitialLoading(true)
+ try {
+ const data = await api.get(`/mqtt/alert-events/${sn}?limit=${PAGE_SIZE}&offset=${(page - 1) * PAGE_SIZE}`)
+ setRows(data.events || [])
+ setTotal(data.total || 0)
+ } catch {
+ // silent
+ } finally {
+ hasLoadedOnce.current = true
+ setInitialLoading(false)
+ }
+ }, [sn, page])
+
+ useEffect(() => { fetchRows(false) }, [fetchRows])
+
+ return (
+ <>
+
+
+
+
fetchRows(true)} style={{
+ height: 28, paddingInline: 'var(--space-3)', borderRadius: 'var(--radius-md)',
+ border: '1px solid var(--color-border-strong)', background: GLASS_INNER,
+ color: 'var(--color-text-secondary)', fontSize: 'var(--font-size-xs)',
+ fontWeight: 'var(--font-weight-semibold)', cursor: 'pointer',
+ letterSpacing: 'var(--tracking-wide)', fontFamily: 'var(--font-family-base)',
+ }}>
+ REFRESH
+
+
+
+
+
+
+ {['Time', 'State', 'Subsystem', 'Message'].map(h => (
+
+ {h}
+
+ ))}
+
+
+
+
+ {rows.map((row, idx) => {
+ const s = ALERT_STATE_COLORS[row.state] || ALERT_STATE_COLORS.WARNING
+ return (
+
+
+ {fmtDateTimeMedium(row.occurred_at)}
+
+
+
+ {row.state}
+
+
+ {row.subsystem}
+ {row.message || '—'}
+
+ )
+ })}
+
+
+
+ {total > PAGE_SIZE && (
+
+ )}
+ >
+ )
+}
+
+// ─── Heartbeats panel ─────────────────────────────────────────────────────────
+
+function HeartbeatsPanel({ sn, channel, onChannelChange }) {
+ const [rows, setRows] = useState([])
+ const [total, setTotal] = useState(0)
+ const [initialLoading, setInitialLoading] = useState(true)
+ const [page, setPage] = useState(1)
+ const hasLoadedOnce = useRef(false)
+
+ const fetchRows = useCallback(async (background = false) => {
+ if (!sn) return
+ if (!background && !hasLoadedOnce.current) setInitialLoading(true)
+ try {
+ const data = await api.get(`/mqtt/heartbeats/${sn}?limit=${PAGE_SIZE}&offset=${(page - 1) * PAGE_SIZE}`)
+ setRows(data.heartbeats || [])
+ setTotal(data.total || 0)
+ } catch {
+ // silent
+ } finally {
+ hasLoadedOnce.current = true
+ setInitialLoading(false)
+ }
+ }, [sn, page])
+
+ useEffect(() => { fetchRows(false) }, [fetchRows])
+
+ return (
+ <>
+
+
+
+
fetchRows(true)} style={{
+ height: 28, paddingInline: 'var(--space-3)', borderRadius: 'var(--radius-md)',
+ border: '1px solid var(--color-border-strong)', background: GLASS_INNER,
+ color: 'var(--color-text-secondary)', fontSize: 'var(--font-size-xs)',
+ fontWeight: 'var(--font-weight-semibold)', cursor: 'pointer',
+ letterSpacing: 'var(--tracking-wide)', fontFamily: 'var(--font-family-base)',
+ }}>
+ REFRESH
+
+
+
+
+
+
+ {['Time', 'Uptime', 'Firmware', 'IP Address', 'RSSI'].map(h => (
+
+ {h}
+
+ ))}
+
+
+
+
+ {rows.map((row, idx) => (
+
+
+ {fmtDateTimeMedium(row.received_at)}
+
+
+ {row.uptime_display || '—'}
+
+
+ {row.firmware_version || '—'}
+
+
+ {row.ip_address || '—'}
+
+
+ {row.rssi != null ? `${row.rssi} dBm` : '—'}
+
+
+ ))}
+
+
+
+ {total > PAGE_SIZE && (
+
+ )}
+ >
+ )
+}
+
+// ─── Commands panel ───────────────────────────────────────────────────────────
+
+const COMMAND_STATUS_VARIANT = {
+ pending: 'warning',
+ success: 'success',
+ error: 'danger',
+ timeout: 'danger',
+}
+
+function CommandsPanel({ sn, channel, onChannelChange }) {
+ const [rows, setRows] = useState([])
+ const [total, setTotal] = useState(0)
+ const [initialLoading, setInitialLoading] = useState(true)
+ const [page, setPage] = useState(1)
+ const hasLoadedOnce = useRef(false)
+
+ const fetchRows = useCallback(async (background = false) => {
+ if (!sn) return
+ if (!background && !hasLoadedOnce.current) setInitialLoading(true)
+ try {
+ const data = await api.get(`/mqtt/commands/${sn}?limit=${PAGE_SIZE}&offset=${(page - 1) * PAGE_SIZE}`)
+ setRows(data.commands || [])
+ setTotal(data.total || 0)
+ } catch {
+ // silent
+ } finally {
+ hasLoadedOnce.current = true
+ setInitialLoading(false)
+ }
+ }, [sn, page])
+
+ useEffect(() => { fetchRows(false) }, [fetchRows])
+
+ return (
+ <>
+
+
+
+
fetchRows(true)} style={{
+ height: 28, paddingInline: 'var(--space-3)', borderRadius: 'var(--radius-md)',
+ border: '1px solid var(--color-border-strong)', background: GLASS_INNER,
+ color: 'var(--color-text-secondary)', fontSize: 'var(--font-size-xs)',
+ fontWeight: 'var(--font-weight-semibold)', cursor: 'pointer',
+ letterSpacing: 'var(--tracking-wide)', fontFamily: 'var(--font-family-base)',
+ }}>
+ REFRESH
+
+
+
+
+
+
+ {['Sent', 'Command', 'Status', 'Responded'].map(h => (
+
+ {h}
+
+ ))}
+
+
+
+
+ {rows.map((row, idx) => (
+
+
+ {fmtDateTimeMedium(row.sent_at)}
+
+
+ {row.command_name}
+
+
+ {row.status}
+
+
+ {row.responded_at ? fmtDateTimeMedium(row.responded_at) : fmtRelative(row.sent_at)}
+
+
+ ))}
+
+
+
+ {total > PAGE_SIZE && (
+
+ )}
+ >
+ )
+}
+
+// ─── LogsTab ──────────────────────────────────────────────────────────────────
+
+export default function LogsTab({ sn, range }) {
+ const [channel, setChannel] = useState('unified')
+
+ return (
+
+
+ {!sn ? (
+
+ No serial number available for this device.
+
+ ) : channel === 'unified' ? (
+
+ ) : channel === 'log' ? (
+
+ ) : channel === 'info' ? (
+
+ ) : channel === 'alerts' ? (
+
+ ) : channel === 'heartbeats' ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ )
+}
diff --git a/frontend/src/pages/bellcloud/devices/tabs/OverviewTab.jsx b/frontend/src/pages/bellcloud/devices/tabs/OverviewTab.jsx
index c920d58..ad19121 100644
--- a/frontend/src/pages/bellcloud/devices/tabs/OverviewTab.jsx
+++ b/frontend/src/pages/bellcloud/devices/tabs/OverviewTab.jsx
@@ -10,7 +10,8 @@ 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 { fmtDateMedium, fmtRelative } from '@/lib/formatters'
+import Modal from '@/components/ui/Modal'
+import { fmtDateMedium, fmtDateTimeMedium, fmtRelative, fmtLogTimestamp } from '@/lib/formatters'
import { useMqttWebSocket } from '@/hooks/useMqttWebSocket'
import {
parseFirestoreDate,
@@ -52,6 +53,23 @@ function subscrDurationLabel(days) {
return `${days}d`
}
+// Latest Device Issue color: severity sets the base hue, age controls intensity.
+// today = full opacity, fading out toward ~1 week, capped at a dim floor beyond that.
+const ALERT_SEVERITY_HUE = {
+ CRITICAL: 'var(--color-danger)',
+ FAILED: 'var(--color-danger)',
+ WARNING: 'var(--color-warning)',
+}
+
+function alertEventStyle(occurredAtIso) {
+ const hue = ALERT_SEVERITY_HUE[occurredAtIso.state] || 'var(--color-warning)'
+ const ageMs = Date.now() - new Date(occurredAtIso.occurred_at).getTime()
+ const ageDays = ageMs / 86400000
+ // 1.0 opacity at "today", fading linearly to a 0.35 floor by 7 days out.
+ const opacity = Math.max(0.35, 1 - (ageDays / 7) * 0.65)
+ return { color: hue, opacity }
+}
+
// ─── Sub-components ───────────────────────────────────────────────────────────
function GlassCard({ children, style = {}, className = '' }) {
@@ -146,6 +164,14 @@ const LOG_COLORS = {
INFO: { text: 'var(--color-info)', bg: 'var(--color-info-bg)' },
}
+// Message text colour — INFO stays neutral/white, only WARN/ERROR get coloured
+// so problem lines are the ones that visually stand out.
+const MESSAGE_TEXT_COLOR = {
+ ERROR: 'var(--color-danger)',
+ WARN: 'var(--color-warning)',
+ INFO: 'var(--color-text-primary)',
+}
+
// Level slider steps: 0=All, 1=INFO, 2=WARN, 3=ERROR
const LEVEL_STEPS = [
{ value: '', label: 'ALL', color: 'var(--color-text-muted)' },
@@ -265,6 +291,24 @@ export default function OverviewTab({
const [issueModal, setIssueModal] = useState({ open: false, entry: null })
const [viewNote, setViewNote] = useState(null)
+ // ── Latest device issue detail modal ─────────────────────────────────────
+ const [alertModalOpen, setAlertModalOpen] = useState(false)
+ const [alertLogs, setAlertLogs] = useState([])
+ const [alertLogsLoading, setAlertLogsLoading] = useState(false)
+
+ const openAlertModal = useCallback(() => {
+ setAlertModalOpen(true)
+ if (!sn) return
+ setAlertLogsLoading(true)
+ api.get(`/mqtt/logs/${sn}?limit=20&offset=0`)
+ .then(data => {
+ const rows = (data.logs || []).filter(l => l.level === 'ERROR' || l.level === 'WARN')
+ setAlertLogs(rows)
+ })
+ .catch(() => setAlertLogs([]))
+ .finally(() => setAlertLogsLoading(false))
+ }, [sn])
+
const handleNoteSaved = () => {
setNoteModal({ open: false })
loadNotes()
@@ -277,43 +321,52 @@ export default function OverviewTab({
// ── Live logs ─────────────────────────────────────────────────────────────
const [logs, setLogs] = useState([])
- const [logsLoading, setLogsLoading] = useState(false)
+ const [logsInitialLoading, setLogsInitialLoading] = useState(true)
const [levelStep, setLevelStep] = useState(0) // index into LEVEL_STEPS
const [searchText, setSearchText] = useState('')
const [autoScroll, setAutoScroll] = useState(true)
- const [autoRefresh, setAutoRefresh] = useState(false)
+ const [autoRefresh, setAutoRefresh] = useState(true)
const [liveLogs, setLiveLogs] = useState([])
const logsContainerRef = useRef(null)
+ const logsHasLoadedOnce = useRef(false)
const LIMIT = 50
const levelFilter = LEVEL_STEPS[levelStep].value
+ // Stepped severity order: a level also matches everything more severe than it
+ // (ALL < INFO < WARN < ERROR), so picking WARN also shows WARN and ERROR.
+ const LEVEL_RANK = { INFO: 0, WARN: 1, ERROR: 2 }
+ const meetsLevelFloor = lvl => !levelFilter || (LEVEL_RANK[lvl] ?? 0) >= LEVEL_RANK[levelFilter]
- const fetchLogs = useCallback(async () => {
+ // `background` fetches (polling / manual refresh) never toggle a loading
+ // flag — only the very first load shows a spinner, so auto-sync and the
+ // REFRESH button stay seamless instead of flickering every few seconds.
+ const fetchLogs = useCallback(async (background = false) => {
if (!sn) return
- setLogsLoading(true)
+ if (!background && !logsHasLoadedOnce.current) setLogsInitialLoading(true)
try {
const params = new URLSearchParams({ limit: LIMIT, offset: 0 })
- if (levelFilter) params.set('level', levelFilter)
+ if (levelFilter) { params.set('level', levelFilter); params.set('min_level', 'true') }
if (searchText) params.set('search', searchText)
const data = await api.get(`/mqtt/logs/${sn}?${params}`)
setLogs(data.logs || [])
} catch {
// silent
} finally {
- setLogsLoading(false)
+ logsHasLoadedOnce.current = true
+ setLogsInitialLoading(false)
}
}, [sn, levelFilter, searchText])
- useEffect(() => { if (sn) fetchLogs() }, [sn, fetchLogs])
+ useEffect(() => { if (sn) fetchLogs(false) }, [sn, fetchLogs])
useEffect(() => {
if (!autoRefresh || !sn) return
- const t = setInterval(fetchLogs, 5000)
+ const t = setInterval(() => fetchLogs(true), 5000)
return () => clearInterval(t)
}, [autoRefresh, sn, fetchLogs])
const handleWsMessage = useCallback((data) => {
- if (data.type === 'logs' && data.device_serial === sn) {
+ if (data.type === 'system/logs' && data.device_serial === sn) {
const entry = {
id: Date.now(),
level: data.payload?.level?.includes('EROR') ? 'ERROR' :
@@ -330,18 +383,24 @@ export default function OverviewTab({
const allLogs = [
...liveLogs.filter(l => {
- if (levelFilter && l.level !== levelFilter) return false
+ if (!meetsLevelFloor(l.level)) return false
if (searchText && !l.message.toLowerCase().includes(searchText.toLowerCase())) return false
return true
}),
...logs,
]
+ // Only auto-scroll to top when a genuinely NEW live log arrives — not when
+ // the level filter / search changes the visible set (that should update
+ // the list in place, without yanking the scroll position).
+ const liveLogCount = liveLogs.length
+ const prevLiveLogCount = useRef(liveLogCount)
useEffect(() => {
- if (autoScroll && logsContainerRef.current) {
+ if (liveLogCount > prevLiveLogCount.current && autoScroll && logsContainerRef.current) {
logsContainerRef.current.scrollTop = 0
}
- }, [allLogs.length, autoScroll])
+ prevLiveLogCount.current = liveLogCount
+ }, [liveLogCount, autoScroll])
// ── Service lifecycle ─────────────────────────────────────────────────────
const subscrStart = parseFirestoreDate(sub?.subscrStart)
@@ -429,68 +488,93 @@ export default function OverviewTab({
- {/* ── Section B: Hardware info ── */}
+ {/* ── Section B+D: Device info grid (Serial/HW/Uptime/Location/Rev/Issue) ── */}