feat(devices): add Health tab, log explorer, and fleet health indicators

Surfaces the new device-health telemetry (boot events, ping RTT,
diagnostics reports, alert events) across the console:

- HealthTab: boot/diagnostics timeline, CPU temp and RSSI charts
  (LineChart), current alert status, and a Settings sub-tab for
  DeviceHealthSettings thresholds
- LogsTab: dedicated log explorer embedded in the Health tab, with
  level/source filtering and fmtLogTimestamp for dense timestamp rows
- OverviewTab: a "Latest Device Issue" card showing the most recent
  alert event, colour-coded by severity and fading with age, plus a
  modal to inspect the surrounding log lines
- DeviceList/DeviceListCardView/DeviceListMapView: fleet list gains a
  SignalIndicator-based RSSI display in place of the plain online dot

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-21 18:32:06 +03:00
co-authored by Claude Sonnet 5
parent 0d79a9f72c
commit acd1896788
7 changed files with 2395 additions and 94 deletions
+8
View File
@@ -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)
@@ -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 <Muted>—</Muted>
return (
<span style={{ color: isStale ? 'var(--color-text-muted)' : (color || 'var(--color-text-primary)'), opacity: isStale ? 0.5 : 1 }}>
{text}
</span>
)
}
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 <OnlineDot isOnline={isOnline} />
case 'name':
return (
<span style={{ fontWeight: 'var(--font-weight-medium)', color: 'var(--color-text-primary)' }}>
{device.device_name || 'Unnamed Device'}
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 'var(--space-2)' }}>
<SignalIndicator rssi={mqtt?.last_heartbeat?.rssi} size="sm" />
<span style={{ fontWeight: 'var(--font-weight-medium)', color: 'var(--color-text-primary)' }}>
{deviceDisplayName(device)}
</span>
</span>
)
case 'appName':
return device.device_name || <Muted>—</Muted>
case 'serialNumber':
return (
<span style={{ fontFamily: 'var(--font-family-mono)', color: 'var(--color-text-muted)', fontSize: 'var(--font-size-sm)' }}>
@@ -436,6 +609,45 @@ export default function DeviceList() {
return device.hw_family || <Muted>—</Muted>
case 'hw_revision':
return device.hw_revision || <Muted>—</Muted>
case 'uptime':
return (
<StaleableValue
text={formatUptimeApprox(hb?.uptime_ms)}
isStale={isStaleAt(hb?.received_at, HEARTBEAT_STALE_MS)}
color={uptimeToColor(hb?.uptime_ms)}
/>
)
case 'firmwareVersion':
return (
<StaleableValue
text={hb?.firmware_version || null}
isStale={isStaleAt(hb?.received_at, HEARTBEAT_STALE_MS)}
/>
)
case 'signalStrength':
return (
<StaleableValue
text={hb?.rssi != null ? `${hb.rssi} dBm` : null}
isStale={isStaleAt(hb?.received_at, HEARTBEAT_STALE_MS)}
color={rssiToColor(hb?.rssi)}
/>
)
case 'cpuTemp':
return (
<StaleableValue
text={diag?.cpu_temp_avg != null ? `${diag.cpu_temp_avg.toFixed(1)}°C` : null}
isStale={isStaleAt(diag?.received_at, DIAG_STALE_MS)}
color={cpuTempToColor(diag?.cpu_temp_avg, device.device_health_settings)}
/>
)
case 'pingLatency':
return (
<StaleableValue
text={ping?.rtt_ms != null ? `${ping.rtt_ms} ms` : null}
color={pingToColor(ping?.rtt_ms)}
isStale={isStaleAt(ping?.sampled_at, PING_STALE_MS)}
/>
)
default:
return <Muted>—</Muted>
}
@@ -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 (
<div
className={`dcard__online-block ${online ? 'dcard__online-block--on' : 'dcard__online-block--off'}`}
aria-label={online ? 'Online' : 'Offline'}
>
<span className="dcard__online-block-dot" aria-hidden="true" />
{online ? (
<SignalIndicator rssi={rssi} size="sm" />
) : (
<span className="dcard__online-block-dot" aria-hidden="true" />
)}
<span className="dcard__online-block-label">{online ? 'Online' : 'Offline'}</span>
</div>
)
@@ -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 ── */}
<header className="dcard__header">
<OnlineBlock online={isOnline} />
<OnlineBlock online={isOnline} rssi={mqtt?.last_heartbeat?.rssi} />
<div className="dcard__identity">
<div className="dcard__name-row">
@@ -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 */}
<circle
@@ -767,7 +767,7 @@ export default function DeviceListMapView({ devices, loading, mqttStatusMap, onV
aria-hidden="true"
/>
<h2 className="dmv-drawer__name">
{selected.device_name || 'Unnamed Device'}
{selected.console_name || selected.device_name || 'Unnamed Device'}
</h2>
<button
className="dmv-drawer__close"
@@ -0,0 +1,937 @@
// frontend/src/pages/bellcloud/devices/tabs/HealthTab.jsx
// Health tab — stability/diagnostics data derived from firmware boot tracking
// (F-052/F-053/F-056) and MQTT heartbeat/ping telemetry: lifetime boot count,
// reset reasons, coredump crash detail, RSSI/ping/heap/temp trends, restart
// timeline. Sub-tabs: Overview, Logs (full MQTT log explorer), Settings
// (per-device alert thresholds). A single time-range control in the toolbar
// scopes every data-fetching section on the page (Overview fully; Logs'
// Unified/Device Logs/Info channels — Alerts/Heartbeats/Commands panels don't
// have range support on their endpoints yet).
import { useState, useEffect, useCallback, useMemo } from 'react'
import api from '@/lib/api'
import Card from '@/components/ui/Card'
import StatusBadge from '@/components/ui/StatusBadge'
import Spinner from '@/components/ui/Spinner'
import Icon from '@/components/ui/Icon'
import FormField from '@/components/ui/FormField'
import Button from '@/components/ui/Button'
import TimeRangeSelect from '@/components/ui/TimeRangeSelect'
import LineChart from '@/components/ui/LineChart'
import LogsTab from '@/pages/bellcloud/devices/tabs/LogsTab'
import { fmtDateTimeMedium, fmtRelative } from '@/lib/formatters'
import { resolveRange } from '@/lib/timeRange'
import { useToast } from '@/components/ui/Toast'
// ─── Reset reason → badge variant ──────────────────────────────────────────
const FAULT_REASONS = new Set([
'PANIC', 'TASK_WATCHDOG', 'INTERRUPT_WATCHDOG', 'OTHER_WATCHDOG', 'BROWNOUT',
])
function reasonVariant(reason, isFault) {
if (isFault ?? FAULT_REASONS.has(reason)) return 'danger'
if (reason === 'UNKNOWN') return 'warning'
return 'success'
}
function reasonLabel(reason) {
if (!reason) return 'Unknown'
return reason.replace(/_/g, ' ')
}
const DEFAULT_OFFLINE_TIMEOUT_SEC = 90 // matches backend's online = seconds_since_heartbeat < 90
// Standard section gap used everywhere on this tab — matches the gap other
// device-detail tabs (e.g. General) and DESIGN.md's masonry-grid use, so the
// Health tab doesn't visually stand apart with its own tighter/looser rhythm.
const SECTION_GAP = 'var(--space-6)'
// RSSI color stops — fixed semantic thresholds (not user-configurable, unlike
// CPU temp): weak/fair/good signal quality. Matches SignalIndicator's own
// dBm bands so the line chart and the header signal icon never disagree.
const RSSI_COLOR_STOPS = [
{ value: -90, color: 'var(--color-danger)' },
{ value: -70, color: 'var(--color-warning)' },
{ value: -50, color: 'var(--color-success)' },
]
// ─── Equal-height card grid ─────────────────────────────────────────────────
// DESIGN.md §11: masonry-grid is for variable-height sections that should
// auto-flow into balanced columns — it does NOT equalize row heights. Stat
// tiles and same-shape chart cards must align horizontally, so this uses a
// real CSS grid instead (masonry would render them at uneven heights).
function EqualGrid({ columns, children }) {
return (
<div style={{
display: 'grid',
gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
gap: SECTION_GAP,
alignItems: 'stretch',
}}>
{children}
</div>
)
}
// ─── 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 (
<Card title={label}>
<div style={{ display: 'flex', flexDirection: 'column', justifyContent: 'center', gap: 'var(--space-1)', minHeight: 64 }}>
{badge ?? (
<span style={{ fontFamily: 'var(--font-family-mono)', fontSize: '1.75rem', fontWeight: 'var(--font-weight-bold)', color, lineHeight: 1.15, letterSpacing: '-0.01em' }}>
{value}
{unit && <span style={{ fontSize: '1rem', color: 'var(--color-text-muted)', fontWeight: 'var(--font-weight-medium)', marginLeft: 4 }}>{unit}</span>}
</span>
)}
{sublabel && (
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', fontFamily: 'var(--font-family-mono)' }}>
{sublabel}
</span>
)}
</div>
</Card>
)
}
// ─── Secondary metric tile (more compact, same Card-title convention) ──────
function MetricTile({ label, value, unit, sublabel, children }) {
return (
<Card title={label}>
<div style={{ display: 'flex', flexDirection: 'column', justifyContent: 'center', gap: 2, minHeight: 44 }}>
{children ?? (
<span style={{ fontFamily: 'var(--font-family-mono)', fontSize: '1.15rem', fontWeight: 'var(--font-weight-semibold)', color: 'var(--color-text-primary)' }}>
{value}
{unit && <span style={{ fontSize: '0.8rem', color: 'var(--color-text-muted)', marginLeft: 3 }}>{unit}</span>}
</span>
)}
{sublabel && (
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>
{sublabel}
</span>
)}
</div>
</Card>
)
}
// ─── 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 (
<div style={{ position: 'relative', padding: 'var(--space-4) var(--space-2) var(--space-2)' }}>
<div style={{
position: 'relative', height: 8, margin: '32px 4px 8px',
background: 'var(--color-bg-abyss)', border: '1px solid var(--color-border)',
borderRadius: 4,
}}>
{events.length === 0 && (
<span style={{
position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)',
fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', fontStyle: 'italic',
whiteSpace: 'nowrap', background: 'var(--color-bg-surface)', padding: '0 var(--space-2)',
}}>
No boot events in this range
</span>
)}
{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 (
<div
key={e.id ?? i}
title={`Boot #${e.boot_count ?? '?'} · ${reasonLabel(e.reset_reason)} · ${fmtDateTimeMedium(e.occurred_at)}`}
style={{
position: 'absolute', top: '50%', left: `${pct}%`,
transform: 'translate(-50%, -50%)',
width: e.is_fault ? 12 : 10, height: e.is_fault ? 12 : 10,
borderRadius: '50%',
background: color,
boxShadow: `0 0 8px ${color}`,
border: '2px solid var(--color-bg-surface)',
cursor: 'default',
}}
/>
)
})}
</div>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', fontFamily: 'var(--font-family-mono)' }}>
{fmtDateTimeMedium(new Date(tMin).toISOString())}
</span>
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', fontFamily: 'var(--font-family-mono)' }}>
{fmtDateTimeMedium(new Date(tMax).toISOString())}
</span>
</div>
</div>
)
}
// ─── Boot event row (expandable for crash detail) ──────────────────────────
function BootEventRow({ event, expanded, onToggle }) {
const hasCrash = event.crash_task != null
return (
<div style={{ borderBottom: '1px solid var(--color-border)' }}>
<div
onClick={hasCrash ? onToggle : undefined}
style={{
display: 'flex', alignItems: 'center', gap: 'var(--space-4)',
padding: 'var(--space-3) var(--space-4)',
cursor: hasCrash ? 'pointer' : 'default',
}}
>
<span style={{ width: 160, flexShrink: 0, fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)' }}>
{fmtDateTimeMedium(event.occurred_at)}
</span>
<StatusBadge variant={reasonVariant(event.reset_reason, event.is_fault)} size="sm">
{reasonLabel(event.reset_reason)}
</StatusBadge>
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', fontFamily: 'var(--font-family-mono)' }}>
boot #{event.boot_count ?? '—'}
</span>
<span style={{ flex: 1 }} />
{event.free_heap != null && (
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', fontFamily: 'var(--font-family-mono)' }}>
{Math.round(event.free_heap / 1024)} KB free
</span>
)}
{hasCrash && (
<Icon
name="chevron_down"
size={14}
color="var(--color-text-muted)"
style={{ transform: expanded ? 'rotate(180deg)' : 'none', transition: 'transform 0.15s' }}
/>
)}
</div>
{expanded && hasCrash && (
<div style={{
padding: 'var(--space-3) var(--space-4) var(--space-4) calc(160px + var(--space-4) + var(--space-4))',
display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', gap: 'var(--space-3)',
}}>
{[
['Faulting task', event.crash_task],
['Program counter', event.crash_pc != null ? `0x${Number(event.crash_pc).toString(16)}` : '—'],
['Exception cause', event.crash_exc_cause ?? '—'],
['Faulting address', event.crash_exc_vaddr != null ? `0x${Number(event.crash_exc_vaddr).toString(16)}` : '—'],
].map(([label, val]) => (
<div key={label} style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)' }}>
{label}
</span>
<span style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-danger)', fontFamily: 'var(--font-family-mono)' }}>
{val}
</span>
</div>
))}
</div>
)}
</div>
)
}
// ─── 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 (
<span title={meta.label} style={{ display: 'inline-flex', alignItems: 'center' }}>
<Icon name={meta.name} size={16} color={meta.color} />
</span>
)
}
// ─── 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 (
<div style={{ display: 'flex', justifyContent: 'center', padding: 'var(--space-16)' }}>
<Spinner size="lg" />
</div>
)
}
if (error) {
return (
<div role="alert" style={{ backgroundColor: 'var(--color-danger-bg)', border: '1px solid var(--color-danger)', borderRadius: 'var(--radius-md)', padding: 'var(--space-4)', color: 'var(--color-danger)' }}>
{error}
</div>
)
}
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 (
<div style={{ display: 'flex', flexDirection: 'column', gap: SECTION_GAP }}>
{/* ── Hero KPI row (compact) ───────────────────────────────────────── */}
<EqualGrid columns={4}>
<KpiTile
label="Lifetime Boot Count"
value={latestBoot?.boot_count ?? '—'}
/>
<KpiTile
label={`Restarts (${rangeLabelShort})`}
value={restartsInRange}
variant={restartsInRange > 5 ? 'danger' : restartsInRange > 0 ? 'warning' : undefined}
sublabel={healthSettings?.warning?.restarts_per_week_threshold != null ? `alert threshold: ${healthSettings.warning.restarts_per_week_threshold} / week` : null}
/>
<KpiTile
label="Device Uptime"
value={mqttStatus?.last_heartbeat?.uptime_ms != null
? formatUptimeShort(mqttStatus.last_heartbeat.uptime_ms)
: '—'}
sublabel={latestBoot?.boot_count != null ? `since boot #${latestBoot.boot_count}` : null}
/>
<KpiTile
label="Last Boot Reason"
sublabel={latestBoot ? fmtRelative(latestBoot.occurred_at) : null}
badge={latestBoot ? (
<StatusBadge variant={reasonVariant(latestBoot.reset_reason, latestBoot.is_fault)}>
{reasonLabel(latestBoot.reset_reason)}
</StatusBadge>
) : (
<span style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)', fontStyle: 'italic' }}>
No boot data yet
</span>
)}
/>
</EqualGrid>
{/* ── Secondary metrics row (compact) ──────────────────────────────── */}
<EqualGrid columns={5}>
<MetricTile
label="CPU Temperature"
value={latestDiag?.cpu_temp_avg != null ? latestDiag.cpu_temp_avg.toFixed(1) : '—'}
unit="°C"
sublabel={latestDiag?.cpu_temp_max != null ? `peak ${latestDiag.cpu_temp_max.toFixed(1)}°C` : null}
/>
<MetricTile
label="WiFi Reconnects"
value={latestDiag?.wifi_reconnect_count ?? '—'}
sublabel={latestDiag?.wifi_last_disconnect_reason ? `last: ${latestDiag.wifi_last_disconnect_reason}` : null}
/>
<MetricTile label="Firmware / OTA">
<span style={{ fontFamily: 'var(--font-family-mono)', fontSize: '1rem', fontWeight: 'var(--font-weight-semibold)', color: 'var(--color-text-primary)' }}>
{latestDiag?.ota_current_version ? `v${latestDiag.ota_current_version}` : '—'}
</span>
{latestDiag && (
<div style={{ marginTop: 2 }}>
<StatusBadge variant={latestDiag.ota_update_available ? 'warning' : 'success'} size="sm">
{latestDiag.ota_update_available ? 'update available' : 'up to date'}
</StatusBadge>
</div>
)}
</MetricTile>
<MetricTile
label="Signal Strength"
value={mqttStatus?.last_heartbeat?.rssi ?? '—'}
unit={mqttStatus?.last_heartbeat?.rssi != null ? 'dBm' : ''}
sublabel="most recent heartbeat"
/>
<MetricTile label="Stack High-Water">
{lowestStackEntry ? (
<>
<span style={{
fontFamily: 'var(--font-family-mono)', fontSize: '1.15rem', fontWeight: 'var(--font-weight-semibold)',
color: stackWarning ? 'var(--color-danger)' : 'var(--color-text-primary)',
}}>
{lowestStackEntry[1]}<span style={{ fontSize: '0.8rem', color: 'var(--color-text-muted)', marginLeft: 3 }}>B free</span>
</span>
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>
{lowestStackEntry[0]} (lowest)
</span>
</>
) : (
<span style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)', fontStyle: 'italic' }}>—</span>
)}
</MetricTile>
</EqualGrid>
{/* ── Trend graphs ─────────────────────────────────────────────────── */}
<EqualGrid columns={2}>
<Card title="Signal Strength (RSSI)" subtitle="From 30s heartbeat — colored by signal quality">
<LineChart
data={rssiSeries}
colorStops={RSSI_COLOR_STOPS}
unit=" dBm"
formatValue={v => Math.round(v)}
formatX={formatTime}
xMin={rangeXMin}
xMax={rangeXMax}
offlineWindows={offlineWindows}
gapStyle="baseline"
emptyLabel="No heartbeat data in this range."
/>
</Card>
<Card
title="Ping Latency"
subtitle={pingSeries.length === 0 ? 'RTC-equipped devices only' : 'Round-trip time, sampled every 60s'}
>
<LineChart
data={pingSeries}
color="var(--color-primary)"
unit=" ms"
formatValue={v => 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."
/>
</Card>
<Card title="Free Heap" subtitle="From 30s heartbeat">
<LineChart
data={heapSeries}
color="var(--color-success)"
unit=" KB"
formatValue={v => Math.round(v / 1024)}
formatX={formatTime}
xMin={rangeXMin}
xMax={rangeXMax}
offlineWindows={offlineWindows}
gapStyle="baseline"
emptyLabel="No heartbeat data in this range."
/>
</Card>
<Card title="CPU Temperature" subtitle="Min/max/avg sampled every 15s, reported every 5 min">
<LineChart
data={tempSeries}
colorStops={tempColorStops}
unit="°C"
formatValue={v => v.toFixed(1)}
formatX={formatTime}
xMin={rangeXMin}
xMax={rangeXMax}
offlineWindows={offlineWindows}
gapStyle="baseline"
emptyLabel="No diagnostics reports in this range."
/>
</Card>
</EqualGrid>
{/* ── Restart timeline — full page width ───────────────────────────── */}
<Card title="Restart Timeline" subtitle="Each dot is one boot — red = fault reset" padding={false}>
<RestartTimeline events={[...bootEvents].reverse()} rangeStart={rangeXMin} rangeEnd={rangeXMax} />
</Card>
{/* ── Full boot event history ──────────────────────────────────────── */}
<Card title="Boot History" subtitle="Reverse-chronological — click a fault row with crash detail to expand" padding={false}>
{bootEvents.length === 0 ? (
<div style={{ padding: 'var(--space-8)', textAlign: 'center', color: 'var(--color-text-muted)', fontSize: 'var(--font-size-sm)', fontStyle: 'italic' }}>
No boot events recorded in this range.
</div>
) : (
<div>
{bootEvents.map(event => (
<BootEventRow
key={event.id}
event={event}
expanded={expandedId === event.id}
onToggle={() => setExpandedId(prev => prev === event.id ? null : event.id)}
/>
))}
</div>
)}
</Card>
</div>
)
}
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 (
<button
type="button"
id={id}
role="switch"
aria-checked={enabled}
disabled={disabled}
onClick={() => 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,
}}
>
<span
style={{
position: 'absolute',
top: '3px',
left: enabled ? '23px' : '3px',
width: '18px',
height: '18px',
borderRadius: 'var(--radius-full)',
background: 'white',
transition: 'left 0.2s',
boxShadow: '0 1px 4px rgba(0,0,0,0.3)',
}}
aria-hidden="true"
/>
</button>
)
}
// ─── 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 (
<div style={{
display: 'grid', gridTemplateColumns: '1fr 140px 140px', gap: 'var(--space-4)', alignItems: 'center',
padding: 'var(--space-3) 0', borderBottom: '1px solid var(--color-border)',
}}>
<div>
<p style={{ margin: 0, fontSize: 'var(--font-size-sm)', fontWeight: 'var(--font-weight-medium)', color: 'var(--color-text-primary)' }}>{row.label}</p>
<p style={{ margin: 0, fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>{row.hint}{row.unit ? ` (${row.unit})` : ''}</p>
</div>
<FormField
label="Warning"
name={`warning_${row.key}`}
type="number"
value={warning}
onChange={e => onChange('warning', row.key, e.target.value)}
/>
<FormField
label="Critical"
name={`critical_${row.key}`}
type="number"
value={critical}
onChange={e => onChange('critical', row.key, e.target.value)}
/>
</div>
)
}
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 (
<div style={{ maxWidth: 'var(--content-max-width-lg)' }}>
<Card title="Alert Thresholds" subtitle="Warning shows an orange status icon; Critical shows red. Both drive the icon next to the time-range control above.">
<div style={{
display: 'grid', gridTemplateColumns: '1fr 140px 140px', gap: 'var(--space-4)',
paddingBottom: 'var(--space-2)', borderBottom: '1px solid var(--color-border-strong)',
}}>
<span />
<span style={{ fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-semibold)', color: 'var(--color-warning)', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)' }}>Warning</span>
<span style={{ fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-semibold)', color: 'var(--color-danger)', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)' }}>Critical</span>
</div>
{THRESHOLD_ROWS.map(row => (
<ThresholdRow
key={row.key}
row={row}
warning={form.warning[row.key]}
critical={form.critical[row.key]}
onChange={setThreshold}
/>
))}
<div style={{ marginTop: 'var(--space-6)' }}>
<FormField
label="Offline timeout"
name="offline_timeout_sec"
type="number"
value={form.offline_timeout_sec}
onChange={e => 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)"
/>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', gap: 'var(--space-4)', marginTop: 'var(--space-6)' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--space-3)', padding: 'var(--space-3) var(--space-4)', borderRadius: 'var(--radius-md)', background: 'var(--color-bg-elevated)', border: '1px solid var(--color-border)' }}>
<div>
<span style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-primary)' }}>Email me when a threshold is crossed</span>
<p style={{ margin: 0, fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>Not yet active — no email infrastructure wired up.</p>
</div>
<ToggleSwitch id="email-on-threshold" enabled={!!form.email_on_threshold} onChange={v => setField('email_on_threshold', v)} />
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--space-3)', padding: 'var(--space-3) var(--space-4)', borderRadius: 'var(--radius-md)', background: 'var(--color-bg-elevated)', border: '1px solid var(--color-border)' }}>
<div>
<span style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-primary)' }}>Push notification on crash boot</span>
<p style={{ margin: 0, fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>Not yet active — no push infrastructure wired up.</p>
</div>
<ToggleSwitch id="push-on-crash" enabled={!!form.push_on_crash_boot} onChange={v => setField('push_on_crash_boot', v)} />
</div>
</div>
<div style={{ marginTop: 'var(--space-6)' }}>
<Button variant="primary" loading={saving} onClick={save}>Save Thresholds</Button>
</div>
</Card>
</div>
)
}
// ─── HealthTab ──────────────────────────────────────────────────────────────
function SubTabPill({ active, onClick, children }) {
return (
<button
type="button"
onClick={onClick}
style={{
display: 'inline-flex', alignItems: 'center', gap: 'var(--space-2)',
padding: 'var(--space-2) var(--space-5)',
borderRadius: 'var(--radius-full)',
border: `1px solid ${active ? 'var(--color-border-focus)' : 'var(--color-border)'}`,
background: active ? 'var(--color-primary-subtle)' : 'var(--color-bg-surface)',
color: active ? 'var(--color-text-accent)' : 'var(--color-text-muted)',
fontSize: 'var(--font-size-sm)', fontWeight: 'var(--font-weight-semibold)',
fontFamily: 'var(--font-family-base)',
cursor: 'pointer', whiteSpace: 'nowrap',
transition: 'background 0.15s, border-color 0.15s, color 0.15s',
}}
>
{children}
</button>
)
}
// 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 (
<div style={{ display: 'flex', flexDirection: 'column', gap: SECTION_GAP }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--space-4)', flexWrap: 'wrap', minHeight: 38 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-3)' }}>
<SubTabPill active={subTab === 'overview'} onClick={() => setSubTab('overview')}>
<StatusIcon status={headerStatus} />
Overview
</SubTabPill>
<SubTabPill active={subTab === 'logs'} onClick={() => setSubTab('logs')}>Logs</SubTabPill>
<SubTabPill active={subTab === 'settings'} onClick={() => setSubTab('settings')}>
<Icon name="settings" size={13} />
Settings
</SubTabPill>
</div>
{/* 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. */}
<div style={{ width: 220, visibility: subTab === 'settings' ? 'hidden' : 'visible' }}>
<TimeRangeSelect value={range} onChange={setRange} />
</div>
</div>
{subTab === 'overview' && (
<HealthOverview sn={sn} mqttStatus={mqttStatus} range={range} offlineTimeoutSec={offlineTimeoutSec} healthSettings={healthSettings} />
)}
{subTab === 'logs' && <LogsTab sn={sn} range={range} />}
{subTab === 'settings' && (
<HealthSettings deviceId={device?.id} settings={healthSettings} onSaved={setSavedOverride} />
)}
</div>
)
}
@@ -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 (
<div style={{
background: GLASS,
backdropFilter: BLUR,
WebkitBackdropFilter: BLUR,
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-xl)',
boxShadow: 'var(--shadow-card), var(--shadow-md)',
...style,
}}>
{children}
</div>
)
}
function SectionLabel({ children }) {
return (
<span style={{
fontSize: 'var(--font-size-xs)',
fontWeight: 'var(--font-weight-semibold)',
color: 'var(--color-text-muted)',
letterSpacing: 'var(--tracking-wide)',
textTransform: 'uppercase',
}}>
{children}
</span>
)
}
// ─── 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 (
<div style={{ display: 'flex', gap: 'var(--space-1)', alignItems: 'center', flexWrap: 'wrap' }}>
{CHANNELS.map(ch => (
<button
key={ch.key}
onClick={() => 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}
</button>
))}
</div>
)
}
// ─── Toolbar ──────────────────────────────────────────────────────────────────
function LogToolbar({
channel, onChannelChange,
showLevelFilter, levelStep, setLevelStep,
searchText, setSearchText,
autoScroll, setAutoScroll,
autoRefresh, setAutoRefresh,
onRefresh,
liveCount,
}) {
return (
<>
<style>{`
.logstab-toolbar .btn {
padding-top: var(--space-3) !important;
padding-bottom: var(--space-3) !important;
line-height: var(--line-height-base) !important;
}
`}</style>
<div className="logstab-toolbar" style={{
display: 'flex',
alignItems: 'center',
gap: 'var(--space-4)',
padding: 'var(--space-3) var(--space-5)',
borderBottom: '1px solid var(--color-border)',
flexWrap: 'wrap',
}}>
{/* Channel chips — left side, same row as the rest of the toolbar */}
<ChannelChips active={channel} onChange={onChannelChange} />
{/* Live pulse indicator */}
{liveCount > 0 && (
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-1)', flexShrink: 0 }}>
<span style={{
width: 6, height: 6, borderRadius: '50%',
background: 'var(--color-success)',
boxShadow: '0 0 6px var(--color-success)',
animation: 'logstab-pulse 1.5s ease-in-out infinite',
display: 'block',
}} />
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-success)', fontWeight: 'var(--font-weight-semibold)', letterSpacing: 'var(--tracking-wide)' }}>
LIVE
</span>
</div>
)}
<div style={{ flex: 1 }} />
{/* Level colour slider */}
{showLevelFilter && (
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-3)', flexShrink: 0 }}>
<SectionLabel>Level</SectionLabel>
<div style={{ display: 'flex', gap: 'var(--space-1)', alignItems: 'center' }}>
{LEVEL_STEPS.map((step, idx) => (
<button
key={step.value}
onClick={() => 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}
</button>
))}
</div>
</div>
)}
{/* Search */}
<input
type="text"
value={searchText}
onChange={e => 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 */}
<button
onClick={() => 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
</button>
{/* Toggle: AUTO-SYNC */}
<button
onClick={() => 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
</button>
{/* 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. */}
<button
onClick={onRefresh}
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)',
flexShrink: 0,
}}
>
REFRESH
</button>
</div>
</>
)
}
// ─── Empty / loading table states ────────────────────────────────────────────
function TableState({ loading, empty, colSpan, emptyText }) {
if (loading) {
return (
<tr><td colSpan={colSpan} style={{ padding: 'var(--space-8)', textAlign: 'center' }}>
<Spinner size="sm" />
</td></tr>
)
}
if (empty) {
return (
<tr><td colSpan={colSpan} style={{ padding: 'var(--space-8)', textAlign: 'center', color: 'var(--color-text-muted)', fontSize: 'var(--font-size-sm)', fontFamily: 'var(--font-family-base)', fontStyle: 'italic' }}>
{emptyText}
</td></tr>
)
}
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 (
<>
<LogToolbar
channel={channel} onChannelChange={onChannelChange}
showLevelFilter
levelStep={levelStep} setLevelStep={idx => { 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}
/>
<div ref={containerRef} style={{ maxHeight: 520, overflowY: 'auto', fontFamily: 'var(--font-family-mono)', fontSize: 'var(--font-size-sm)' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ position: 'sticky', top: 0, background: GLASS_INNER, backdropFilter: BLUR, WebkitBackdropFilter: BLUR, borderBottom: '1px solid var(--color-border)', zIndex: 1 }}>
{['Time', 'Level', 'Message'].map((h, i) => (
<th key={h} style={{
padding: 'var(--space-2) var(--space-4)', textAlign: 'left',
width: i === 0 ? 160 : i === 1 ? 72 : undefined,
fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)',
fontWeight: 'var(--font-weight-semibold)', letterSpacing: 'var(--tracking-wide)',
textTransform: 'uppercase', fontFamily: 'var(--font-family-base)',
}}>
{h}
</th>
))}
</tr>
</thead>
<tbody>
<TableState loading={initialLoading && allRows.length === 0} empty={!initialLoading && allRows.length === 0} colSpan={3} emptyText="No logs found." />
{allRows.map((log, idx) => {
const s = LOG_COLORS[log.level] || LOG_COLORS.INFO
return (
<tr key={log._live ? log.id : (log.id || idx)} style={{
borderBottom: idx < allRows.length - 1 ? '1px solid var(--color-border)' : 'none',
background: log._live ? 'rgba(74,222,128,0.04)' : 'transparent',
}}>
<td style={{ padding: 'var(--space-2) var(--space-4)', color: 'var(--color-text-muted)', whiteSpace: 'nowrap' }}>
{fmtLogTimestamp(log.received_at)}
{log._live && <span style={{ marginLeft: 'var(--space-2)', fontSize: 'var(--font-size-xs)', color: 'var(--color-success)', fontWeight: 'var(--font-weight-semibold)' }}>LIVE</span>}
</td>
<td style={{ padding: 'var(--space-2) var(--space-4)' }}>
<span style={{ display: 'inline-block', padding: '1px var(--space-2)', borderRadius: 'var(--radius-sm)', fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-semibold)', background: s.bg, color: s.text, letterSpacing: 'var(--tracking-wide)' }}>
{log.level}
</span>
</td>
<td style={{ padding: 'var(--space-2) var(--space-4)', color: MESSAGE_TEXT_COLOR[log.level] || 'var(--color-text-primary)' }}>
{log.message}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
{total > PAGE_SIZE && (
<div style={{ padding: 'var(--space-4) var(--space-5)', borderTop: '1px solid var(--color-border)' }}>
<Pagination page={page} pageSize={PAGE_SIZE} total={total} onPageChange={setPage} />
</div>
)}
</>
)
}
// ─── 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 (
<>
<LogToolbar
channel={channel} onChannelChange={onChannelChange}
showLevelFilter
levelStep={levelStep} setLevelStep={setLevelStep}
searchText={searchText} setSearchText={setSearchText}
autoScroll={autoScroll} setAutoScroll={setAutoScroll}
autoRefresh={autoRefresh} setAutoRefresh={setAutoRefresh}
onRefresh={() => fetchAll(true)}
liveCount={liveCount}
/>
<div ref={containerRef} style={{ maxHeight: 520, overflowY: 'auto', fontFamily: 'var(--font-family-mono)', fontSize: 'var(--font-size-sm)' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ position: 'sticky', top: 0, background: GLASS_INNER, backdropFilter: BLUR, WebkitBackdropFilter: BLUR, borderBottom: '1px solid var(--color-border)', zIndex: 1 }}>
{['Time', 'Channel', 'Level', 'Message'].map((h, i) => (
<th key={h} style={{
padding: 'var(--space-2) var(--space-4)', textAlign: 'left',
width: i === 0 ? 160 : i === 1 ? 100 : i === 2 ? 100 : undefined,
fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)',
fontWeight: 'var(--font-weight-semibold)', letterSpacing: 'var(--tracking-wide)',
textTransform: 'uppercase', fontFamily: 'var(--font-family-base)',
}}>
{h}
</th>
))}
</tr>
</thead>
<tbody>
<TableState loading={initialLoading && allRows.length === 0} empty={!initialLoading && allRows.length === 0} colSpan={4} emptyText="No events found." />
{allRows.map((row, idx) => {
const channelColor = SOURCE_COLORS[row.source] || SOURCE_COLORS.log
const channelCell = (
<td style={{ padding: 'var(--space-2) var(--space-4)' }}>
<span style={{ fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-semibold)', color: channelColor, letterSpacing: 'var(--tracking-wide)' }}>
{SOURCE_LABELS[row.source] || row.source}
</span>
</td>
)
if (row._kind === 'alert') {
const s = ALERT_STATE_COLORS[row.state] || ALERT_STATE_COLORS.WARNING
return (
<tr key={row.id || idx} style={{
borderBottom: idx < allRows.length - 1 ? '1px solid var(--color-border)' : 'none',
background: row._live ? 'rgba(255,92,92,0.05)' : 'transparent',
}}>
<td style={{ padding: 'var(--space-2) var(--space-4)', color: 'var(--color-text-muted)', whiteSpace: 'nowrap' }}>
{fmtLogTimestamp(row.received_at)}
{row._live && <span style={{ marginLeft: 'var(--space-2)', fontSize: 'var(--font-size-xs)', color: 'var(--color-success)', fontWeight: 'var(--font-weight-semibold)' }}>LIVE</span>}
</td>
{channelCell}
<td style={{ padding: 'var(--space-2) var(--space-4)' }}>
<span style={{ display: 'inline-block', padding: '1px var(--space-2)', borderRadius: 'var(--radius-sm)', fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-semibold)', background: s.bg, color: s.text, letterSpacing: 'var(--tracking-wide)' }}>
{row.state}
</span>
</td>
<td style={{ padding: 'var(--space-2) var(--space-4)', color: s.text }}>
<span style={{ color: 'var(--color-text-muted)', marginRight: 'var(--space-2)' }}>[{row.subsystem}]</span>
{row.message || '—'}
</td>
</tr>
)
}
const s = LOG_COLORS[row.level] || LOG_COLORS.INFO
return (
<tr key={row.id || idx} style={{
borderBottom: idx < allRows.length - 1 ? '1px solid var(--color-border)' : 'none',
background: row._live ? 'rgba(74,222,128,0.04)' : 'transparent',
}}>
<td style={{ padding: 'var(--space-2) var(--space-4)', color: 'var(--color-text-muted)', whiteSpace: 'nowrap' }}>
{fmtLogTimestamp(row.received_at)}
{row._live && <span style={{ marginLeft: 'var(--space-2)', fontSize: 'var(--font-size-xs)', color: 'var(--color-success)', fontWeight: 'var(--font-weight-semibold)' }}>LIVE</span>}
</td>
{channelCell}
<td style={{ padding: 'var(--space-2) var(--space-4)' }}>
<span style={{ display: 'inline-block', padding: '1px var(--space-2)', borderRadius: 'var(--radius-sm)', fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-semibold)', background: s.bg, color: s.text, letterSpacing: 'var(--tracking-wide)' }}>
{row.level}
</span>
</td>
<td style={{ padding: 'var(--space-2) var(--space-4)', color: MESSAGE_TEXT_COLOR[row.level] || 'var(--color-text-primary)' }}>
{row.message}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</>
)
}
// ─── 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 (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-4)', padding: 'var(--space-3) var(--space-5)', borderBottom: '1px solid var(--color-border)', flexWrap: 'wrap' }}>
<ChannelChips active={channel} onChange={onChannelChange} />
<div style={{ flex: 1 }} />
<button onClick={() => 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
</button>
</div>
<div style={{ maxHeight: 520, overflowY: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 'var(--font-size-sm)' }}>
<thead>
<tr style={{ position: 'sticky', top: 0, background: GLASS_INNER, backdropFilter: BLUR, WebkitBackdropFilter: BLUR, borderBottom: '1px solid var(--color-border)', zIndex: 1 }}>
{['Time', 'State', 'Subsystem', 'Message'].map(h => (
<th key={h} style={{ padding: 'var(--space-2) var(--space-4)', textAlign: 'left', fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', fontWeight: 'var(--font-weight-semibold)', letterSpacing: 'var(--tracking-wide)', textTransform: 'uppercase' }}>
{h}
</th>
))}
</tr>
</thead>
<tbody>
<TableState loading={initialLoading && rows.length === 0} empty={!initialLoading && rows.length === 0} colSpan={4} emptyText="No alert history for this device." />
{rows.map((row, idx) => {
const s = ALERT_STATE_COLORS[row.state] || ALERT_STATE_COLORS.WARNING
return (
<tr key={row.id || idx} style={{ borderBottom: idx < rows.length - 1 ? '1px solid var(--color-border)' : 'none' }}>
<td style={{ padding: 'var(--space-2) var(--space-4)', color: 'var(--color-text-muted)', fontFamily: 'var(--font-family-mono)', whiteSpace: 'nowrap' }}>
{fmtDateTimeMedium(row.occurred_at)}
</td>
<td style={{ padding: 'var(--space-2) var(--space-4)' }}>
<span style={{ display: 'inline-block', padding: '1px var(--space-2)', borderRadius: 'var(--radius-sm)', fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-semibold)', background: s.bg, color: s.text, letterSpacing: 'var(--tracking-wide)' }}>
{row.state}
</span>
</td>
<td style={{ padding: 'var(--space-2) var(--space-4)', color: 'var(--color-text-secondary)' }}>{row.subsystem}</td>
<td style={{ padding: 'var(--space-2) var(--space-4)', color: s.text }}>{row.message || '—'}</td>
</tr>
)
})}
</tbody>
</table>
</div>
{total > PAGE_SIZE && (
<div style={{ padding: 'var(--space-4) var(--space-5)', borderTop: '1px solid var(--color-border)' }}>
<Pagination page={page} pageSize={PAGE_SIZE} total={total} onPageChange={setPage} />
</div>
)}
</>
)
}
// ─── 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 (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-4)', padding: 'var(--space-3) var(--space-5)', borderBottom: '1px solid var(--color-border)', flexWrap: 'wrap' }}>
<ChannelChips active={channel} onChange={onChannelChange} />
<div style={{ flex: 1 }} />
<button onClick={() => 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
</button>
</div>
<div style={{ maxHeight: 520, overflowY: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 'var(--font-size-sm)' }}>
<thead>
<tr style={{ position: 'sticky', top: 0, background: GLASS_INNER, backdropFilter: BLUR, WebkitBackdropFilter: BLUR, borderBottom: '1px solid var(--color-border)', zIndex: 1 }}>
{['Time', 'Uptime', 'Firmware', 'IP Address', 'RSSI'].map(h => (
<th key={h} style={{ padding: 'var(--space-2) var(--space-4)', textAlign: 'left', fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', fontWeight: 'var(--font-weight-semibold)', letterSpacing: 'var(--tracking-wide)', textTransform: 'uppercase' }}>
{h}
</th>
))}
</tr>
</thead>
<tbody>
<TableState loading={initialLoading && rows.length === 0} empty={!initialLoading && rows.length === 0} colSpan={5} emptyText="No heartbeats recorded for this device." />
{rows.map((row, idx) => (
<tr key={row.id || idx} style={{ borderBottom: idx < rows.length - 1 ? '1px solid var(--color-border)' : 'none' }}>
<td style={{ padding: 'var(--space-2) var(--space-4)', color: 'var(--color-text-muted)', fontFamily: 'var(--font-family-mono)', whiteSpace: 'nowrap' }}>
{fmtDateTimeMedium(row.received_at)}
</td>
<td style={{ padding: 'var(--space-2) var(--space-4)', color: 'var(--color-text-primary)', fontFamily: 'var(--font-family-mono)' }}>
{row.uptime_display || '—'}
</td>
<td style={{ padding: 'var(--space-2) var(--space-4)', color: 'var(--color-text-secondary)', fontFamily: 'var(--font-family-mono)' }}>
{row.firmware_version || '—'}
</td>
<td style={{ padding: 'var(--space-2) var(--space-4)', color: 'var(--color-text-secondary)', fontFamily: 'var(--font-family-mono)' }}>
{row.ip_address || '—'}
</td>
<td style={{ padding: 'var(--space-2) var(--space-4)', color: 'var(--color-text-secondary)', fontFamily: 'var(--font-family-mono)' }}>
{row.rssi != null ? `${row.rssi} dBm` : '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
{total > PAGE_SIZE && (
<div style={{ padding: 'var(--space-4) var(--space-5)', borderTop: '1px solid var(--color-border)' }}>
<Pagination page={page} pageSize={PAGE_SIZE} total={total} onPageChange={setPage} />
</div>
)}
</>
)
}
// ─── 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 (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-4)', padding: 'var(--space-3) var(--space-5)', borderBottom: '1px solid var(--color-border)', flexWrap: 'wrap' }}>
<ChannelChips active={channel} onChange={onChannelChange} />
<div style={{ flex: 1 }} />
<button onClick={() => 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
</button>
</div>
<div style={{ maxHeight: 520, overflowY: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 'var(--font-size-sm)' }}>
<thead>
<tr style={{ position: 'sticky', top: 0, background: GLASS_INNER, backdropFilter: BLUR, WebkitBackdropFilter: BLUR, borderBottom: '1px solid var(--color-border)', zIndex: 1 }}>
{['Sent', 'Command', 'Status', 'Responded'].map(h => (
<th key={h} style={{ padding: 'var(--space-2) var(--space-4)', textAlign: 'left', fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', fontWeight: 'var(--font-weight-semibold)', letterSpacing: 'var(--tracking-wide)', textTransform: 'uppercase' }}>
{h}
</th>
))}
</tr>
</thead>
<tbody>
<TableState loading={initialLoading && rows.length === 0} empty={!initialLoading && rows.length === 0} colSpan={4} emptyText="No commands sent to this device yet." />
{rows.map((row, idx) => (
<tr key={row.id || idx} style={{ borderBottom: idx < rows.length - 1 ? '1px solid var(--color-border)' : 'none' }}>
<td style={{ padding: 'var(--space-2) var(--space-4)', color: 'var(--color-text-muted)', fontFamily: 'var(--font-family-mono)', whiteSpace: 'nowrap' }}>
{fmtDateTimeMedium(row.sent_at)}
</td>
<td style={{ padding: 'var(--space-2) var(--space-4)', color: 'var(--color-text-primary)', fontFamily: 'var(--font-family-mono)' }}>
{row.command_name}
</td>
<td style={{ padding: 'var(--space-2) var(--space-4)' }}>
<StatusBadge variant={COMMAND_STATUS_VARIANT[row.status] || 'neutral'}>{row.status}</StatusBadge>
</td>
<td style={{ padding: 'var(--space-2) var(--space-4)', color: 'var(--color-text-muted)', fontFamily: 'var(--font-family-mono)', whiteSpace: 'nowrap' }}>
{row.responded_at ? fmtDateTimeMedium(row.responded_at) : fmtRelative(row.sent_at)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{total > PAGE_SIZE && (
<div style={{ padding: 'var(--space-4) var(--space-5)', borderTop: '1px solid var(--color-border)' }}>
<Pagination page={page} pageSize={PAGE_SIZE} total={total} onPageChange={setPage} />
</div>
)}
</>
)
}
// ─── LogsTab ──────────────────────────────────────────────────────────────────
export default function LogsTab({ sn, range }) {
const [channel, setChannel] = useState('unified')
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-6)', width: '100%', maxWidth: 2000, margin: '0 auto' }}>
<GlassCard style={{ overflow: 'hidden' }}>
{!sn ? (
<div style={{ padding: 'var(--space-8)', textAlign: 'center', color: 'var(--color-text-muted)', fontSize: 'var(--font-size-sm)', fontStyle: 'italic' }}>
No serial number available for this device.
</div>
) : channel === 'unified' ? (
<UnifiedPanel sn={sn} channel={channel} onChannelChange={setChannel} range={range} />
) : channel === 'log' ? (
<TextLogPanel sn={sn} source="log" liveTypes={['system/logs']} channel={channel} onChannelChange={setChannel} range={range} />
) : channel === 'info' ? (
<TextLogPanel sn={sn} source="info" liveTypes={['system/info']} channel={channel} onChannelChange={setChannel} range={range} />
) : channel === 'alerts' ? (
<AlertsPanel sn={sn} channel={channel} onChannelChange={setChannel} />
) : channel === 'heartbeats' ? (
<HeartbeatsPanel sn={sn} channel={channel} onChannelChange={setChannel} />
) : (
<CommandsPanel sn={sn} channel={channel} onChannelChange={setChannel} />
)}
</GlassCard>
<style>{`
@keyframes logstab-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.35; }
}
`}</style>
</div>
)
}
@@ -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({
</div>
{/* ── Section B: Hardware info ── */}
{/* ── Section B+D: Device info grid (Serial/HW/Uptime/Location/Rev/Issue) ── */}
<div style={{
flex: 1,
flex: 2,
padding: 'var(--space-6)',
marginLeft: 'var(--space-4)',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
gap: 'var(--space-4)',
alignItems: 'center',
minWidth: 0,
}}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-5) var(--space-8)', maxWidth: 480 }}>
}} className="overview-hero-grid-wrap">
<div className="overview-hero-grid" style={{
display: 'grid',
gridTemplateColumns: 'repeat(3, minmax(0, 1fr))',
gridAutoRows: 'auto',
columnGap: 'var(--space-8)',
rowGap: 'var(--space-5)',
width: '100%',
}}>
<InfoField label="Serial Number" mono>{sn || '—'}</InfoField>
<InfoField label="Hardware Family">{hwVariant}</InfoField>
<InfoField label="Device Uptime">
{mqttStatus?.last_heartbeat?.uptime_ms != null
? <span style={{ fontFamily: 'var(--font-family-mono)', fontSize: 'var(--font-size-base)' }}>{formatUptime(Math.floor(mqttStatus.last_heartbeat.uptime_ms / 1000))}</span>
: <span style={{ color: 'var(--color-text-muted)', fontStyle: 'italic', fontWeight: 'var(--font-weight-normal)' }}>Unavailable</span>}
</InfoField>
<InfoField label="Location">{device.device_location || '—'}</InfoField>
<InfoField label="Hardware Revision">{hwRevision || device.device_attributes?.firmwareVersion || '—'}</InfoField>
</div>
</div>
{/* ── Section D: Uptime + Latest Issue ── */}
<div style={{
width: 220,
flexShrink: 0,
padding: 'var(--space-6)',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
gap: 'var(--space-5)',
minWidth: 0,
}}>
<InfoField label="Device Uptime">
{mqttStatus?.uptime_seconds != null
? <span style={{ fontFamily: 'var(--font-family-mono)', fontSize: 'var(--font-size-base)' }}>{formatUptime(mqttStatus.uptime_seconds)}</span>
: <span style={{ color: 'var(--color-text-muted)', fontStyle: 'italic', fontWeight: 'var(--font-weight-normal)' }}>Unavailable</span>}
</InfoField>
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-3)' }}>
<span style={{ fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-semibold)', color: 'var(--color-text-more-muted)', letterSpacing: 'var(--tracking-wide)', textTransform: 'uppercase' }}>Latest Device Issue</span>
{mqttStatus?.last_warn_message ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-1)' }}>
<span style={{
fontSize: 'var(--font-size-xs)',
color: 'var(--color-warning)',
fontWeight: 'var(--font-weight-semibold)',
letterSpacing: 'var(--tracking-wide)',
textTransform: 'uppercase',
}}>
MQTT · WARN · Health Monitor
</span>
<span style={{
fontSize: 'var(--font-size-sm)',
color: 'var(--color-text-secondary)',
lineHeight: 1.5,
}}>
{mqttStatus.last_warn_message}
</span>
</div>
) : (
<span style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)', fontStyle: 'italic' }}>
No recent issues
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-1)', minWidth: 0 }}>
<span style={{
fontSize: 'var(--font-size-xs)',
fontWeight: 'var(--font-weight-semibold)',
color: 'var(--color-text-more-muted)',
letterSpacing: 'var(--tracking-wide)',
textTransform: 'uppercase',
}}>
Latest Device Issue
</span>
)}
{mqttStatus?.last_alert_event ? (
<div
onClick={openAlertModal}
role="button"
tabIndex={0}
onKeyDown={e => { 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'}
>
<span style={{
fontSize: 'var(--font-size-xs)',
fontWeight: 'var(--font-weight-semibold)',
letterSpacing: 'var(--tracking-wide)',
textTransform: 'uppercase',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
...alertEventStyle(mqttStatus.last_alert_event),
}}>
{mqttStatus.last_alert_event.state} · {mqttStatus.last_alert_event.subsystem} · {fmtRelative(mqttStatus.last_alert_event.occurred_at)}
</span>
<span style={{
fontSize: 'var(--font-size-sm)',
color: 'var(--color-text-secondary)',
lineHeight: 1.4,
overflow: 'hidden',
display: '-webkit-box',
WebkitLineClamp: 1,
WebkitBoxOrient: 'vertical',
textOverflow: 'ellipsis',
}}>
{mqttStatus.last_alert_event.message || 'No details provided.'}
</span>
</div>
) : (
<span style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)', fontStyle: 'italic' }}>
No recent issues
</span>
)}
</div>
</div>
</div>
@@ -930,7 +1014,7 @@ export default function OverviewTab({
{LEVEL_STEPS.map((step, idx) => (
<button
key={step.value}
onClick={() => { setLevelStep(idx); setLogs([]) }}
onClick={() => setLevelStep(idx)}
title={step.label}
style={{
height: 24,
@@ -1020,10 +1104,10 @@ export default function OverviewTab({
AUTO-SYNC
</button>
{/* 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. */}
<button
onClick={fetchLogs}
disabled={logsLoading}
onClick={() => fetchLogs(true)}
style={{
height: 28,
paddingInline: 'var(--space-3)',
@@ -1033,24 +1117,19 @@ export default function OverviewTab({
color: 'var(--color-text-secondary)',
fontSize: 'var(--font-size-xs)',
fontWeight: 'var(--font-weight-semibold)',
cursor: logsLoading ? 'not-allowed' : 'pointer',
cursor: 'pointer',
letterSpacing: 'var(--tracking-wide)',
fontFamily: 'var(--font-family-base)',
opacity: logsLoading ? 0.5 : 1,
flexShrink: 0,
display: 'flex',
alignItems: 'center',
gap: 'var(--space-1)',
}}
>
{logsLoading ? <Spinner size="sm" /> : null}
REFRESH
</button>
</div>
{/* Log rows */}
<div ref={logsContainerRef} style={{ maxHeight: 320, overflowY: 'auto', fontFamily: 'var(--font-family-mono)', fontSize: 'var(--font-size-sm)' }}>
{logsLoading && allLogs.length === 0 ? (
{logsInitialLoading && allLogs.length === 0 ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: 'var(--space-8)' }}>
<Spinner size="sm" />
</div>
@@ -1095,7 +1174,7 @@ export default function OverviewTab({
}}
>
<td style={{ padding: 'var(--space-2) var(--space-4)', color: 'var(--color-text-muted)', whiteSpace: 'nowrap' }}>
{log.received_at?.replace('T', ' ').substring(0, 19)}
{fmtLogTimestamp(log.received_at)}
{log._live && (
<span style={{ marginLeft: 'var(--space-2)', fontSize: 'var(--font-size-xs)', color: 'var(--color-success)', fontWeight: 'var(--font-weight-semibold)' }}>LIVE</span>
)}
@@ -1114,7 +1193,7 @@ export default function OverviewTab({
{log.level}
</span>
</td>
<td style={{ padding: 'var(--space-2) var(--space-4)', color: 'var(--color-text-primary)' }}>
<td style={{ padding: 'var(--space-2) var(--space-4)', color: MESSAGE_TEXT_COLOR[log.level] || 'var(--color-text-primary)' }}>
{log.message}
</td>
</tr>
@@ -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({
</div>
</div>
)}
{/* ── Latest Device Issue detail modal ──────────────────────────────── */}
<Modal
open={alertModalOpen}
onClose={() => setAlertModalOpen(false)}
title="Latest Device Issue"
size="md"
>
{mqttStatus?.last_alert_event && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-5)' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-2)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)' }}>
<span style={{
fontSize: 'var(--font-size-xs)',
fontWeight: 'var(--font-weight-semibold)',
letterSpacing: 'var(--tracking-wide)',
textTransform: 'uppercase',
...alertEventStyle(mqttStatus.last_alert_event),
opacity: 1,
}}>
{mqttStatus.last_alert_event.state} · {mqttStatus.last_alert_event.subsystem}
</span>
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>
{fmtDateTimeMedium(mqttStatus.last_alert_event.occurred_at)}
</span>
</div>
<p style={{ margin: 0, fontSize: 'var(--font-size-sm)', color: 'var(--color-text-primary)', lineHeight: 1.7, whiteSpace: 'pre-wrap' }}>
{mqttStatus.last_alert_event.message || 'No details provided.'}
</p>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-2)', borderTop: '1px solid var(--color-border)', paddingTop: 'var(--space-4)' }}>
<SectionLabel>Recent Error &amp; Warning Logs</SectionLabel>
{alertLogsLoading ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: 'var(--space-4)' }}>
<Spinner size="sm" />
</div>
) : alertLogs.length === 0 ? (
<p style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)', fontStyle: 'italic' }}>
No recent error or warning logs.
</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-2)', maxHeight: 280, overflowY: 'auto' }}>
{alertLogs.map((log, i) => {
const s = LOG_COLORS[log.level] || LOG_COLORS.INFO
return (
<div
key={log.id || i}
style={{
display: 'flex',
alignItems: 'flex-start',
gap: 'var(--space-3)',
padding: 'var(--space-2) var(--space-3)',
borderRadius: 'var(--radius-md)',
background: GLASS_INNER,
border: '1px solid var(--color-border)',
}}
>
<span style={{
display: 'inline-block',
padding: '1px var(--space-2)',
borderRadius: 'var(--radius-sm)',
fontSize: 'var(--font-size-xs)',
fontWeight: 'var(--font-weight-semibold)',
background: s.bg,
color: s.text,
letterSpacing: 'var(--tracking-wide)',
flexShrink: 0,
}}>
{log.level}
</span>
<span style={{ flex: 1, fontSize: 'var(--font-size-sm)', color: MESSAGE_TEXT_COLOR[log.level] || 'var(--color-text-primary)', fontFamily: 'var(--font-family-mono)' }}>
{log.message}
</span>
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', whiteSpace: 'nowrap', flexShrink: 0 }}>
{fmtLogTimestamp(log.received_at)}
</span>
</div>
)
})}
</div>
)}
</div>
</div>
)}
</Modal>
</div>
)
}