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:
@@ -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={{ 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)' }}>
|
||||
{device.device_name || 'Unnamed Device'}
|
||||
{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'}
|
||||
>
|
||||
{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,61 +488,85 @@ 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,
|
||||
}} 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%',
|
||||
}}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-5) var(--space-8)', maxWidth: 480 }}>
|
||||
<InfoField label="Serial Number" mono>{sn || '—'}</InfoField>
|
||||
<InfoField label="Hardware Family">{hwVariant}</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>
|
||||
{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 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)' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-1)', minWidth: 0 }}>
|
||||
<span style={{
|
||||
fontSize: 'var(--font-size-xs)',
|
||||
color: 'var(--color-warning)',
|
||||
fontWeight: 'var(--font-weight-semibold)',
|
||||
color: 'var(--color-text-more-muted)',
|
||||
letterSpacing: 'var(--tracking-wide)',
|
||||
textTransform: 'uppercase',
|
||||
}}>
|
||||
MQTT · WARN · Health Monitor
|
||||
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.5,
|
||||
lineHeight: 1.4,
|
||||
overflow: 'hidden',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 1,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
textOverflow: 'ellipsis',
|
||||
}}>
|
||||
{mqttStatus.last_warn_message}
|
||||
{mqttStatus.last_alert_event.message || 'No details provided.'}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
@@ -493,6 +576,7 @@ export default function OverviewTab({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* ── Section C: Subscription + Warranty ── */}
|
||||
@@ -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 & 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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user