diff --git a/frontend/src/lib/formatters.js b/frontend/src/lib/formatters.js index 895911f..7dbb7b9 100644 --- a/frontend/src/lib/formatters.js +++ b/frontend/src/lib/formatters.js @@ -110,6 +110,14 @@ export function fmtTime24(iso) { return d.toLocaleTimeString(LOCALE_LONG, { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }) } +/** "2026-07-14 19:03:21" — dense log-row timestamp (date + time incl. seconds, 24h) */ +export function fmtLogTimestamp(iso) { + const d = safeDate(iso) + if (!d) return '—' + const pad = n => String(n).padStart(2, '0') + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}` +} + /** Relative time: "just now", "5 minutes ago", "3 days ago", etc. */ export function fmtRelative(iso) { const d = safeDate(iso) diff --git a/frontend/src/pages/bellcloud/devices/DeviceList.jsx b/frontend/src/pages/bellcloud/devices/DeviceList.jsx index 6297c48..bcf95a1 100644 --- a/frontend/src/pages/bellcloud/devices/DeviceList.jsx +++ b/frontend/src/pages/bellcloud/devices/DeviceList.jsx @@ -4,10 +4,12 @@ import { useState, useEffect, useCallback } from 'react' import { useNavigate } from 'react-router-dom' import api from '@/lib/api' import { useAuth } from '@/hooks/useAuth' +import { useMqttWebSocket } from '@/hooks/useMqttWebSocket' import PageHeader from '@/components/ui/PageHeader' import Button from '@/components/ui/Button' import DataTable from '@/components/ui/DataTable' import StatusBadge from '@/components/ui/StatusBadge' +import SignalIndicator from '@/components/ui/SignalIndicator' import Pagination from '@/components/ui/Pagination' import SearchBar from '@/components/ui/SearchBar' import RowActions from '@/components/ui/RowActions' @@ -25,6 +27,7 @@ import DeviceListMapView from './DeviceListMapView' const ALL_COLUMNS = [ { key: 'status', label: '', pickerLabel: 'Status', defaultOn: true, width: '44px' }, { key: 'name', label: 'Name', defaultOn: true, alwaysOn: true, sortable: true }, + { key: 'appName', label: 'App Name', pickerLabel: 'App Name (user-given)', defaultOn: false, sortable: true }, { key: 'serialNumber', label: 'Serial Number', defaultOn: true }, { key: 'location', label: 'Location', defaultOn: true, sortable: true }, { key: 'subscrTier', label: 'Tier', defaultOn: true }, @@ -48,8 +51,97 @@ const ALL_COLUMNS = [ { key: 'tags', label: 'Tags', defaultOn: false }, { key: 'hw_family', label: 'HW Family', defaultOn: false }, { key: 'hw_revision', label: 'HW Revision', defaultOn: false }, + { key: 'uptime', label: 'Uptime', defaultOn: true, sortable: true }, + { key: 'firmwareVersion', label: 'Firmware', defaultOn: true }, + { key: 'signalStrength', label: 'Signal', defaultOn: false, sortable: true }, + { key: 'cpuTemp', label: 'CPU Temp', defaultOn: false, sortable: true }, + { key: 'pingLatency', label: 'Ping', defaultOn: false, sortable: true }, ] +// Values older than this are shown greyed-out instead of full-strength text — +// "stale" meaning the last poll happened long enough ago that the reading may +// no longer reflect reality. Mirrors the offline/diagnostics cadences used on +// the Health tab (HealthTab.jsx heartbeatGapMs/diagGapMs) rather than inventing +// new numbers here. +const HEARTBEAT_STALE_MS = 90 * 1000 // uptime, firmware, signal — from 30s heartbeat +const DIAG_STALE_MS = 15 * 60 * 1000 // cpu temp — diagnostics report every 5 min +const PING_STALE_MS = 15 * 60 * 1000 // ping — sampled every 60s, but not all devices have RTC + +// Approximates to the single largest non-zero unit only — "13d 1h 55m" +// becomes "13 days", "14h 27m" becomes "14 hours" — a precise breakdown is +// too dense for a table column; the Health tab's KPI tile still shows the +// full d/h/m form. +function formatUptimeApprox(ms) { + if (ms == null) return null + const seconds = Math.floor(ms / 1000) + const days = Math.floor(seconds / 86400) + if (days >= 1) return `${days} day${days !== 1 ? 's' : ''}` + const hours = Math.floor(seconds / 3600) + if (hours >= 1) return `${hours} hour${hours !== 1 ? 's' : ''}` + const minutes = Math.floor(seconds / 60) + return `${minutes} minute${minutes !== 1 ? 's' : ''}` +} + +// Freshly-booted devices are more likely to be mid-recovery from a crash/OTA/ +// power event — color signals "how settled is this device," not severity in +// the danger/warning sense, so it deliberately doesn't reuse rssiToColor's +// palette. --mail-filter-sales-text is the only "orange" token in the design +// system, reused here for the 10-30min middle band. +function uptimeToColor(ms) { + if (ms == null) return null + const minutes = ms / 60000 + if (minutes < 10) return 'var(--color-warning)' + if (minutes < 30) return 'var(--mail-filter-sales-text)' + if (minutes < 60) return 'var(--color-success)' + return 'var(--color-info)' +} + +function rssiToColor(rssi) { + if (rssi == null) return null + if (rssi > -50) return 'var(--color-success)' + if (rssi >= -60) return 'var(--color-info)' + if (rssi >= -70) return 'var(--color-warning)' + return 'var(--color-danger)' +} + +// Mirrors HealthTab's CPU-temp gradient stops (HealthTab.jsx tempColorStops) +// — reads the same per-device device_health_settings thresholds (already +// present on the device doc from GET /devices, no extra request needed) +// rather than a hardcoded fleet-wide number, so a device with a custom +// ceiling shows the same color here as on its own Health tab. +function cpuTempToColor(temp, healthSettings) { + if (temp == null) return null + const ceiling = healthSettings?.warning?.cpu_temp_ceiling_c ?? 65 + const critical = healthSettings?.critical?.cpu_temp_ceiling_c ?? 80 + if (temp >= critical) return 'var(--color-danger)' + if (temp >= ceiling) return 'var(--color-warning)' + return 'var(--color-info)' +} + +function pingToColor(rttMs) { + if (rttMs == null) return null + if (rttMs <= 150) return 'var(--color-success)' + if (rttMs <= 300) return 'var(--color-warning)' + return 'var(--color-danger)' +} + +// Whether a timestamp is old enough to render its value greyed-out — "grey +// means stale" convention used for status dots elsewhere. Plain helper (not +// a component) so the Date.now() read happens at row-render time in the +// column's render() callback, not inside a component body. +function isStaleAt(timestamp, maxAgeMs) { + return !timestamp || (Date.now() - new Date(timestamp).getTime()) > maxAgeMs +} + +function StaleableValue({ text, isStale, color }) { + if (text == null) return — + return ( + + {text} + + ) +} + const ALL_KEYS = ALL_COLUMNS.map((c) => c.key) function loadColumnPrefs() { @@ -75,6 +167,13 @@ function saveColumnPrefs(order, visible) { // ─── Helpers ────────────────────────────────────────────────────────────────── +// The console-only friendly name always wins when set — the user-given +// device_name is unreliable (random/joke names some end users pick) and is +// still available separately via the "App Name" column. +function deviceDisplayName(device) { + return device.console_name || device.device_name || 'Unnamed Device' +} + function parseFirestoreDate(str) { if (!str) return null const cleaned = str.replace(' at ', ' ').replace('UTC+0000', 'UTC').replace(/UTC\+\d{4}/, 'UTC') @@ -157,12 +256,17 @@ function Muted({ children }) { // ─── Sorting helper ─────────────────────────────────────────────────────────── -function getSortValue(device, key) { +function getSortValue(device, key, metrics) { const attr = device.device_attributes || {} const sub = device.device_subscription || {} const stats = device.device_stats || {} + const sn = device.serial_number || device.device_id + const hb = metrics?.mqttStatusMap[sn]?.last_heartbeat + const diag = metrics?.latestDiagMap[sn] + const ping = metrics?.latestPingMap[sn] switch (key) { - case 'name': return (device.device_name || '').toLowerCase() + case 'name': return deviceDisplayName(device).toLowerCase() + case 'appName': return (device.device_name || '').toLowerCase() case 'location': return (device.device_location || '').toLowerCase() case 'totalBells': return attr.totalBells ?? -1 case 'assignedUsers': return Array.isArray(device.user_list) ? device.user_list.length : 0 @@ -171,15 +275,19 @@ function getSortValue(device, key) { case 'totalHammerStrikes': return stats.totalHammerStrikes ?? -1 case 'totalWarnings': return stats.totalWarningsGiven ?? -1 case 'totalMelodies': return device.device_melodies_all?.length ?? -1 + case 'uptime': return hb?.uptime_ms ?? -1 + case 'signalStrength': return hb?.rssi ?? -999 + case 'cpuTemp': return diag?.cpu_temp_avg ?? -999 + case 'pingLatency': return ping?.rtt_ms ?? -1 default: return 0 } } -function sortDevices(devices, key, dir) { +function sortDevices(devices, key, dir, metrics) { if (!key) return devices return [...devices].sort((a, b) => { - const va = getSortValue(a, key) - const vb = getSortValue(b, key) + const va = getSortValue(a, key, metrics) + const vb = getSortValue(b, key, metrics) if (va < vb) return dir === 'asc' ? -1 : 1 if (va > vb) return dir === 'asc' ? 1 : -1 return 0 @@ -199,6 +307,8 @@ export default function DeviceList() { const [error, setError] = useState('') const [mqttStatusMap, setMqttStatusMap] = useState({}) const [mqttLoaded, setMqttLoaded] = useState(false) + const [latestDiagMap, setLatestDiagMap] = useState({}) + const [latestPingMap, setLatestPingMap] = useState({}) // Server-side filters const [search, setSearch] = useState('') @@ -291,6 +401,21 @@ export default function DeviceList() { } setMqttLoaded(true) }).catch(() => { setMqttLoaded(true) }) + // Last-known CPU temp + ping RTT — one extra request, fleet-wide, + // reading whatever the background MQTT ingestion already stored. + // Never triggers a live poll of any device. + api.get('/mqtt/latest-metrics').then((metrics) => { + if (metrics?.diagnostics) { + const dMap = {} + for (const d of metrics.diagnostics) dMap[d.device_serial] = d + setLatestDiagMap(dMap) + } + if (metrics?.pings) { + const pMap = {} + for (const p of metrics.pings) pMap[p.device_serial] = p + setLatestPingMap(pMap) + } + }).catch(() => { /* optional columns — non-fatal if unavailable */ }) } catch (err) { setError(err.message || 'Failed to load devices.') } finally { @@ -300,6 +425,46 @@ export default function DeviceList() { useEffect(() => { fetchDevices() }, [fetchDevices]) + // ── Live status via WebSocket ──────────────────────────────────────────── + // Devices publish a heartbeat every ~30s; reflect it in the row the moment + // it arrives instead of waiting for the next fetchDevices() round trip. + // First paint still comes from the one-shot /mqtt/status fetch above — this + // just keeps it fresh without polling. + useMqttWebSocket({ + enabled: true, + onMessage: (msg) => { + if (msg?.type !== 'status/heartbeat') return + // v2 heartbeat payload is FLAT (no nested .payload wrapper) — see + // vesper_mqtt_topic_spec_v2.md. field names: fw_version, uptime_human. + const hb = msg.payload || {} + const serial = msg.device_serial + if (!serial) return + setMqttStatusMap(prev => ({ + ...prev, + [serial]: { + ...(prev[serial] || {}), + device_serial: serial, + online: true, + seconds_since_heartbeat: 0, + last_heartbeat: { + ...(prev[serial]?.last_heartbeat || {}), + device_id: hb.device_id ?? prev[serial]?.last_heartbeat?.device_id, + firmware_version: hb.fw_version ?? prev[serial]?.last_heartbeat?.firmware_version, + ip_address: hb.ip_address ?? prev[serial]?.last_heartbeat?.ip_address, + gateway: hb.gateway ?? prev[serial]?.last_heartbeat?.gateway, + uptime_ms: hb.uptime_ms ?? prev[serial]?.last_heartbeat?.uptime_ms, + uptime_display: hb.uptime_human ?? prev[serial]?.last_heartbeat?.uptime_display, + rssi: hb.rssi ?? prev[serial]?.last_heartbeat?.rssi, + free_heap: hb.free_heap ?? prev[serial]?.last_heartbeat?.free_heap, + state: hb.state ?? prev[serial]?.last_heartbeat?.state, + ok: hb.ok ?? prev[serial]?.last_heartbeat?.ok, + received_at: new Date().toISOString(), + }, + }, + })) + }, + }) + useEffect(() => { setPage(1) }, [search, onlineFilter, tierFilter, subscrStatusFilter, warrantyStatusFilter, hasClockFilter, hasBellsFilter]) @@ -340,7 +505,7 @@ export default function DeviceList() { return true }) - const sortedDevices = sortDevices(filteredDevices, sortKey, sortDir) + const sortedDevices = sortDevices(filteredDevices, sortKey, sortDir, { mqttStatusMap, latestDiagMap, latestPingMap }) const total = sortedDevices.length const pagedDevices = sortedDevices.slice((page - 1) * pageSize, page * pageSize) @@ -376,16 +541,24 @@ export default function DeviceList() { const sn = device.serial_number || device.device_id const mqtt = mqttStatusMap[sn] const isOnline = mqtt ? mqtt.online : device.is_Online + const hb = mqtt?.last_heartbeat + const diag = latestDiagMap[sn] + const ping = latestPingMap[sn] switch (c.key) { case 'status': return case 'name': return ( - - {device.device_name || 'Unnamed Device'} + + + + {deviceDisplayName(device)} + ) + case 'appName': + return device.device_name || — case 'serialNumber': return ( @@ -436,6 +609,45 @@ export default function DeviceList() { return device.hw_family || — case 'hw_revision': return device.hw_revision || — + case 'uptime': + return ( + + ) + case 'firmwareVersion': + return ( + + ) + case 'signalStrength': + return ( + + ) + case 'cpuTemp': + return ( + + ) + case 'pingLatency': + return ( + + ) default: return — } diff --git a/frontend/src/pages/bellcloud/devices/DeviceListCardView.jsx b/frontend/src/pages/bellcloud/devices/DeviceListCardView.jsx index 01ffb61..d14cd14 100644 --- a/frontend/src/pages/bellcloud/devices/DeviceListCardView.jsx +++ b/frontend/src/pages/bellcloud/devices/DeviceListCardView.jsx @@ -7,6 +7,7 @@ import { useMemo } from 'react' import Icon from '@/components/ui/Icon' +import SignalIndicator from '@/components/ui/SignalIndicator' // ─── Tier config ────────────────────────────────────────────────────────────── @@ -194,13 +195,17 @@ function MapBackground({ coords }) { // ─── Online status block (rectangular, spans both identity rows) ────────────── -function OnlineBlock({ online }) { +function OnlineBlock({ online, rssi }) { return (
-
) @@ -241,7 +246,7 @@ function DeviceCard({ device, mqttStatusMap, onView, onMiddleClick }) { const mqtt = mqttStatusMap[sn] const isOnline = mqtt ? mqtt.online : device.is_Online - const name = device.device_name || 'Unnamed Device' + const name = device.console_name || device.device_name || 'Unnamed Device' const location = device.device_location const coords = useMemo(() => parseCoords(device.device_location_coordinates), [device.device_location_coordinates]) const tierLabel = getTierLabel(sub.subscrTier) @@ -266,7 +271,7 @@ function DeviceCard({ device, mqttStatusMap, onView, onMiddleClick }) { {/* ── Header: status block + identity ── */}
- +
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 */}
+ ) +} + +// ─── 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 ( + + ) +} + +// ─── 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)} /> +
+
+ +
+ +
+
+
+ ) +} + +// ─── HealthTab ────────────────────────────────────────────────────────────── + +function SubTabPill({ active, onClick, children }) { + return ( + + ) +} + +// 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 => ( + + ))} +
+ ) +} + +// ─── 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) => ( + + ))} +
+
+ )} + + {/* 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 */} + + + {/* Toggle: 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. */} + +
+ + ) +} + +// ─── 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) => ( + + ))} + + + + + {allRows.map((log, idx) => { + const s = LOG_COLORS[log.level] || LOG_COLORS.INFO + return ( + + + + + + ) + })} + +
+ {h} +
+ {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) => ( + + ))} + + + + + {allRows.map((row, idx) => { + const channelColor = SOURCE_COLORS[row.source] || SOURCE_COLORS.log + const channelCell = ( + + ) + if (row._kind === 'alert') { + const s = ALERT_STATE_COLORS[row.state] || ALERT_STATE_COLORS.WARNING + return ( + + + {channelCell} + + + + ) + } + const s = LOG_COLORS[row.level] || LOG_COLORS.INFO + return ( + + + {channelCell} + + + + ) + })} + +
+ {h} +
+ + {SOURCE_LABELS[row.source] || row.source} + +
+ {fmtLogTimestamp(row.received_at)} + {row._live && LIVE} + + + {row.state} + + + [{row.subsystem}] + {row.message || '—'} +
+ {fmtLogTimestamp(row.received_at)} + {row._live && LIVE} + + + {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 ( + <> +
+ +
+ +
+
+ + + + {['Time', 'State', 'Subsystem', 'Message'].map(h => ( + + ))} + + + + + {rows.map((row, idx) => { + const s = ALERT_STATE_COLORS[row.state] || ALERT_STATE_COLORS.WARNING + return ( + + + + + + + ) + })} + +
+ {h} +
+ {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 ( + <> +
+ +
+ +
+
+ + + + {['Time', 'Uptime', 'Firmware', 'IP Address', 'RSSI'].map(h => ( + + ))} + + + + + {rows.map((row, idx) => ( + + + + + + + + ))} + +
+ {h} +
+ {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 ( + <> +
+ +
+ +
+
+ + + + {['Sent', 'Command', 'Status', 'Responded'].map(h => ( + + ))} + + + + + {rows.map((row, idx) => ( + + + + + + + ))} + +
+ {h} +
+ {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) ── */}
-
+ }} className="overview-hero-grid-wrap"> +
{sn || '—'} {hwVariant} + + {mqttStatus?.last_heartbeat?.uptime_ms != null + ? {formatUptime(Math.floor(mqttStatus.last_heartbeat.uptime_ms / 1000))} + : Unavailable} + {device.device_location || '—'} {hwRevision || device.device_attributes?.firmwareVersion || '—'} -
-
- - {/* ── Section D: Uptime + Latest Issue ── */} -
- - {mqttStatus?.uptime_seconds != null - ? {formatUptime(mqttStatus.uptime_seconds)} - : Unavailable} - - -
- Latest Device Issue - {mqttStatus?.last_warn_message ? ( -
- - MQTT · WARN · Health Monitor - - - {mqttStatus.last_warn_message} - -
- ) : ( - - No recent issues +
+ + Latest Device Issue - )} + {mqttStatus?.last_alert_event ? ( +
{ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openAlertModal() } }} + style={{ + display: 'flex', + flexDirection: 'column', + gap: 2, + cursor: 'pointer', + borderRadius: 'var(--radius-sm)', + marginInline: -4, + paddingInline: 4, + transition: 'background 0.15s', + }} + onMouseEnter={e => e.currentTarget.style.background = GLASS_INNER} + onMouseLeave={e => e.currentTarget.style.background = 'transparent'} + > + + {mqttStatus.last_alert_event.state} · {mqttStatus.last_alert_event.subsystem} · {fmtRelative(mqttStatus.last_alert_event.occurred_at)} + + + {mqttStatus.last_alert_event.message || 'No details provided.'} + +
+ ) : ( + + No recent issues + + )} +
@@ -930,7 +1014,7 @@ export default function OverviewTab({ {LEVEL_STEPS.map((step, idx) => ( - {/* Refresh — same visual style as the toggle buttons */} + {/* Refresh — same visual style as the toggle buttons. No spinner/dim-out: + a background sync must stay seamless, not flicker every few seconds. */}
{/* Log rows */}
- {logsLoading && allLogs.length === 0 ? ( + {logsInitialLoading && allLogs.length === 0 ? (
@@ -1095,7 +1174,7 @@ export default function OverviewTab({ }} > - {log.received_at?.replace('T', ' ').substring(0, 19)} + {fmtLogTimestamp(log.received_at)} {log._live && ( LIVE )} @@ -1114,7 +1193,7 @@ export default function OverviewTab({ {log.level} - + {log.message} @@ -1134,6 +1213,12 @@ export default function OverviewTab({ @media (max-width: 900px) { .overview-row2 { grid-template-columns: 1fr !important; } } + @media (max-width: 1100px) { + .overview-hero-grid { grid-template-columns: repeat(2, minmax(0, 1fr)) !important; } + } + @media (max-width: 640px) { + .overview-hero-grid { grid-template-columns: 1fr !important; } + } @keyframes ov-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.35; } @@ -1160,8 +1245,8 @@ export default function OverviewTab({ open={issueModal.open} entry={issueModal.entry} defaultType="issue" - prefilledLinks={issueModal.entry ? undefined : [{ entity_type: 'device', entity_id: id, display_name: device?.device_name || sn, locked: true }]} - knownEntities={id ? { [id]: device?.device_name || sn } : undefined} + prefilledLinks={issueModal.entry ? undefined : [{ entity_type: 'device', entity_id: id, display_name: device?.console_name || device?.device_name || sn, locked: true }]} + knownEntities={id ? { [id]: device?.console_name || device?.device_name || sn } : undefined} onClose={() => setIssueModal({ open: false, entry: null })} onSaved={handleIssueSaved} /> @@ -1224,6 +1309,92 @@ export default function OverviewTab({
)} + + {/* ── Latest Device Issue detail modal ──────────────────────────────── */} + setAlertModalOpen(false)} + title="Latest Device Issue" + size="md" + > + {mqttStatus?.last_alert_event && ( +
+
+
+ + {mqttStatus.last_alert_event.state} · {mqttStatus.last_alert_event.subsystem} + + + {fmtDateTimeMedium(mqttStatus.last_alert_event.occurred_at)} + +
+

+ {mqttStatus.last_alert_event.message || 'No details provided.'} +

+
+ +
+ Recent Error & Warning Logs + {alertLogsLoading ? ( +
+ +
+ ) : alertLogs.length === 0 ? ( +

+ No recent error or warning logs. +

+ ) : ( +
+ {alertLogs.map((log, i) => { + const s = LOG_COLORS[log.level] || LOG_COLORS.INFO + return ( +
+ + {log.level} + + + {log.message} + + + {fmtLogTimestamp(log.received_at)} + +
+ ) + })} +
+ )} +
+
+ )} +
) }