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 })
|
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. */
|
/** Relative time: "just now", "5 minutes ago", "3 days ago", etc. */
|
||||||
export function fmtRelative(iso) {
|
export function fmtRelative(iso) {
|
||||||
const d = safeDate(iso)
|
const d = safeDate(iso)
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ import { useState, useEffect, useCallback } from 'react'
|
|||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import api from '@/lib/api'
|
import api from '@/lib/api'
|
||||||
import { useAuth } from '@/hooks/useAuth'
|
import { useAuth } from '@/hooks/useAuth'
|
||||||
|
import { useMqttWebSocket } from '@/hooks/useMqttWebSocket'
|
||||||
import PageHeader from '@/components/ui/PageHeader'
|
import PageHeader from '@/components/ui/PageHeader'
|
||||||
import Button from '@/components/ui/Button'
|
import Button from '@/components/ui/Button'
|
||||||
import DataTable from '@/components/ui/DataTable'
|
import DataTable from '@/components/ui/DataTable'
|
||||||
import StatusBadge from '@/components/ui/StatusBadge'
|
import StatusBadge from '@/components/ui/StatusBadge'
|
||||||
|
import SignalIndicator from '@/components/ui/SignalIndicator'
|
||||||
import Pagination from '@/components/ui/Pagination'
|
import Pagination from '@/components/ui/Pagination'
|
||||||
import SearchBar from '@/components/ui/SearchBar'
|
import SearchBar from '@/components/ui/SearchBar'
|
||||||
import RowActions from '@/components/ui/RowActions'
|
import RowActions from '@/components/ui/RowActions'
|
||||||
@@ -25,6 +27,7 @@ import DeviceListMapView from './DeviceListMapView'
|
|||||||
const ALL_COLUMNS = [
|
const ALL_COLUMNS = [
|
||||||
{ key: 'status', label: '', pickerLabel: 'Status', defaultOn: true, width: '44px' },
|
{ key: 'status', label: '', pickerLabel: 'Status', defaultOn: true, width: '44px' },
|
||||||
{ key: 'name', label: 'Name', defaultOn: true, alwaysOn: true, sortable: true },
|
{ 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: 'serialNumber', label: 'Serial Number', defaultOn: true },
|
||||||
{ key: 'location', label: 'Location', defaultOn: true, sortable: true },
|
{ key: 'location', label: 'Location', defaultOn: true, sortable: true },
|
||||||
{ key: 'subscrTier', label: 'Tier', defaultOn: true },
|
{ key: 'subscrTier', label: 'Tier', defaultOn: true },
|
||||||
@@ -48,8 +51,97 @@ const ALL_COLUMNS = [
|
|||||||
{ key: 'tags', label: 'Tags', defaultOn: false },
|
{ key: 'tags', label: 'Tags', defaultOn: false },
|
||||||
{ key: 'hw_family', label: 'HW Family', defaultOn: false },
|
{ key: 'hw_family', label: 'HW Family', defaultOn: false },
|
||||||
{ key: 'hw_revision', label: 'HW Revision', 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)
|
const ALL_KEYS = ALL_COLUMNS.map((c) => c.key)
|
||||||
|
|
||||||
function loadColumnPrefs() {
|
function loadColumnPrefs() {
|
||||||
@@ -75,6 +167,13 @@ function saveColumnPrefs(order, visible) {
|
|||||||
|
|
||||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
// ─── 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) {
|
function parseFirestoreDate(str) {
|
||||||
if (!str) return null
|
if (!str) return null
|
||||||
const cleaned = str.replace(' at ', ' ').replace('UTC+0000', 'UTC').replace(/UTC\+\d{4}/, 'UTC')
|
const cleaned = str.replace(' at ', ' ').replace('UTC+0000', 'UTC').replace(/UTC\+\d{4}/, 'UTC')
|
||||||
@@ -157,12 +256,17 @@ function Muted({ children }) {
|
|||||||
|
|
||||||
// ─── Sorting helper ───────────────────────────────────────────────────────────
|
// ─── Sorting helper ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function getSortValue(device, key) {
|
function getSortValue(device, key, metrics) {
|
||||||
const attr = device.device_attributes || {}
|
const attr = device.device_attributes || {}
|
||||||
const sub = device.device_subscription || {}
|
const sub = device.device_subscription || {}
|
||||||
const stats = device.device_stats || {}
|
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) {
|
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 'location': return (device.device_location || '').toLowerCase()
|
||||||
case 'totalBells': return attr.totalBells ?? -1
|
case 'totalBells': return attr.totalBells ?? -1
|
||||||
case 'assignedUsers': return Array.isArray(device.user_list) ? device.user_list.length : 0
|
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 'totalHammerStrikes': return stats.totalHammerStrikes ?? -1
|
||||||
case 'totalWarnings': return stats.totalWarningsGiven ?? -1
|
case 'totalWarnings': return stats.totalWarningsGiven ?? -1
|
||||||
case 'totalMelodies': return device.device_melodies_all?.length ?? -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
|
default: return 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function sortDevices(devices, key, dir) {
|
function sortDevices(devices, key, dir, metrics) {
|
||||||
if (!key) return devices
|
if (!key) return devices
|
||||||
return [...devices].sort((a, b) => {
|
return [...devices].sort((a, b) => {
|
||||||
const va = getSortValue(a, key)
|
const va = getSortValue(a, key, metrics)
|
||||||
const vb = getSortValue(b, key)
|
const vb = getSortValue(b, key, metrics)
|
||||||
if (va < vb) return dir === 'asc' ? -1 : 1
|
if (va < vb) return dir === 'asc' ? -1 : 1
|
||||||
if (va > vb) return dir === 'asc' ? 1 : -1
|
if (va > vb) return dir === 'asc' ? 1 : -1
|
||||||
return 0
|
return 0
|
||||||
@@ -199,6 +307,8 @@ export default function DeviceList() {
|
|||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [mqttStatusMap, setMqttStatusMap] = useState({})
|
const [mqttStatusMap, setMqttStatusMap] = useState({})
|
||||||
const [mqttLoaded, setMqttLoaded] = useState(false)
|
const [mqttLoaded, setMqttLoaded] = useState(false)
|
||||||
|
const [latestDiagMap, setLatestDiagMap] = useState({})
|
||||||
|
const [latestPingMap, setLatestPingMap] = useState({})
|
||||||
|
|
||||||
// Server-side filters
|
// Server-side filters
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
@@ -291,6 +401,21 @@ export default function DeviceList() {
|
|||||||
}
|
}
|
||||||
setMqttLoaded(true)
|
setMqttLoaded(true)
|
||||||
}).catch(() => { 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) {
|
} catch (err) {
|
||||||
setError(err.message || 'Failed to load devices.')
|
setError(err.message || 'Failed to load devices.')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -300,6 +425,46 @@ export default function DeviceList() {
|
|||||||
|
|
||||||
useEffect(() => { fetchDevices() }, [fetchDevices])
|
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(() => {
|
useEffect(() => {
|
||||||
setPage(1)
|
setPage(1)
|
||||||
}, [search, onlineFilter, tierFilter, subscrStatusFilter, warrantyStatusFilter, hasClockFilter, hasBellsFilter])
|
}, [search, onlineFilter, tierFilter, subscrStatusFilter, warrantyStatusFilter, hasClockFilter, hasBellsFilter])
|
||||||
@@ -340,7 +505,7 @@ export default function DeviceList() {
|
|||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
const sortedDevices = sortDevices(filteredDevices, sortKey, sortDir)
|
const sortedDevices = sortDevices(filteredDevices, sortKey, sortDir, { mqttStatusMap, latestDiagMap, latestPingMap })
|
||||||
const total = sortedDevices.length
|
const total = sortedDevices.length
|
||||||
const pagedDevices = sortedDevices.slice((page - 1) * pageSize, page * pageSize)
|
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 sn = device.serial_number || device.device_id
|
||||||
const mqtt = mqttStatusMap[sn]
|
const mqtt = mqttStatusMap[sn]
|
||||||
const isOnline = mqtt ? mqtt.online : device.is_Online
|
const isOnline = mqtt ? mqtt.online : device.is_Online
|
||||||
|
const hb = mqtt?.last_heartbeat
|
||||||
|
const diag = latestDiagMap[sn]
|
||||||
|
const ping = latestPingMap[sn]
|
||||||
|
|
||||||
switch (c.key) {
|
switch (c.key) {
|
||||||
case 'status':
|
case 'status':
|
||||||
return <OnlineDot isOnline={isOnline} />
|
return <OnlineDot isOnline={isOnline} />
|
||||||
case 'name':
|
case 'name':
|
||||||
return (
|
return (
|
||||||
<span style={{ fontWeight: 'var(--font-weight-medium)', color: 'var(--color-text-primary)' }}>
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 'var(--space-2)' }}>
|
||||||
{device.device_name || 'Unnamed Device'}
|
<SignalIndicator rssi={mqtt?.last_heartbeat?.rssi} size="sm" />
|
||||||
|
<span style={{ fontWeight: 'var(--font-weight-medium)', color: 'var(--color-text-primary)' }}>
|
||||||
|
{deviceDisplayName(device)}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
|
case 'appName':
|
||||||
|
return device.device_name || <Muted>—</Muted>
|
||||||
case 'serialNumber':
|
case 'serialNumber':
|
||||||
return (
|
return (
|
||||||
<span style={{ fontFamily: 'var(--font-family-mono)', color: 'var(--color-text-muted)', fontSize: 'var(--font-size-sm)' }}>
|
<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>
|
return device.hw_family || <Muted>—</Muted>
|
||||||
case 'hw_revision':
|
case 'hw_revision':
|
||||||
return device.hw_revision || <Muted>—</Muted>
|
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:
|
default:
|
||||||
return <Muted>—</Muted>
|
return <Muted>—</Muted>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import Icon from '@/components/ui/Icon'
|
import Icon from '@/components/ui/Icon'
|
||||||
|
import SignalIndicator from '@/components/ui/SignalIndicator'
|
||||||
|
|
||||||
// ─── Tier config ──────────────────────────────────────────────────────────────
|
// ─── Tier config ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -194,13 +195,17 @@ function MapBackground({ coords }) {
|
|||||||
|
|
||||||
// ─── Online status block (rectangular, spans both identity rows) ──────────────
|
// ─── Online status block (rectangular, spans both identity rows) ──────────────
|
||||||
|
|
||||||
function OnlineBlock({ online }) {
|
function OnlineBlock({ online, rssi }) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`dcard__online-block ${online ? 'dcard__online-block--on' : 'dcard__online-block--off'}`}
|
className={`dcard__online-block ${online ? 'dcard__online-block--on' : 'dcard__online-block--off'}`}
|
||||||
aria-label={online ? 'Online' : 'Offline'}
|
aria-label={online ? 'Online' : 'Offline'}
|
||||||
>
|
>
|
||||||
<span className="dcard__online-block-dot" aria-hidden="true" />
|
{online ? (
|
||||||
|
<SignalIndicator rssi={rssi} size="sm" />
|
||||||
|
) : (
|
||||||
|
<span className="dcard__online-block-dot" aria-hidden="true" />
|
||||||
|
)}
|
||||||
<span className="dcard__online-block-label">{online ? 'Online' : 'Offline'}</span>
|
<span className="dcard__online-block-label">{online ? 'Online' : 'Offline'}</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -241,7 +246,7 @@ function DeviceCard({ device, mqttStatusMap, onView, onMiddleClick }) {
|
|||||||
const mqtt = mqttStatusMap[sn]
|
const mqtt = mqttStatusMap[sn]
|
||||||
const isOnline = mqtt ? mqtt.online : device.is_Online
|
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 location = device.device_location
|
||||||
const coords = useMemo(() => parseCoords(device.device_location_coordinates), [device.device_location_coordinates])
|
const coords = useMemo(() => parseCoords(device.device_location_coordinates), [device.device_location_coordinates])
|
||||||
const tierLabel = getTierLabel(sub.subscrTier)
|
const tierLabel = getTierLabel(sub.subscrTier)
|
||||||
@@ -266,7 +271,7 @@ function DeviceCard({ device, mqttStatusMap, onView, onMiddleClick }) {
|
|||||||
|
|
||||||
{/* ── Header: status block + identity ── */}
|
{/* ── Header: status block + identity ── */}
|
||||||
<header className="dcard__header">
|
<header className="dcard__header">
|
||||||
<OnlineBlock online={isOnline} />
|
<OnlineBlock online={isOnline} rssi={mqtt?.last_heartbeat?.rssi} />
|
||||||
|
|
||||||
<div className="dcard__identity">
|
<div className="dcard__identity">
|
||||||
<div className="dcard__name-row">
|
<div className="dcard__name-row">
|
||||||
|
|||||||
@@ -589,7 +589,7 @@ export default function DeviceListMapView({ devices, loading, mqttStatusMap, onV
|
|||||||
onClick={(e) => { e.stopPropagation(); selectDevice(device) }}
|
onClick={(e) => { e.stopPropagation(); selectDevice(device) }}
|
||||||
style={{ cursor: 'pointer' }}
|
style={{ cursor: 'pointer' }}
|
||||||
role="button"
|
role="button"
|
||||||
aria-label={`${device.device_name || 'Device'} — ${color.label}`}
|
aria-label={`${device.console_name || device.device_name || 'Device'} — ${color.label}`}
|
||||||
>
|
>
|
||||||
{/* Breathing ring */}
|
{/* Breathing ring */}
|
||||||
<circle
|
<circle
|
||||||
@@ -767,7 +767,7 @@ export default function DeviceListMapView({ devices, loading, mqttStatusMap, onV
|
|||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
<h2 className="dmv-drawer__name">
|
<h2 className="dmv-drawer__name">
|
||||||
{selected.device_name || 'Unnamed Device'}
|
{selected.console_name || selected.device_name || 'Unnamed Device'}
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
<button
|
||||||
className="dmv-drawer__close"
|
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 StatusBadge from '@/components/ui/StatusBadge'
|
||||||
import Spinner from '@/components/ui/Spinner'
|
import Spinner from '@/components/ui/Spinner'
|
||||||
import Icon from '@/components/ui/Icon'
|
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 { useMqttWebSocket } from '@/hooks/useMqttWebSocket'
|
||||||
import {
|
import {
|
||||||
parseFirestoreDate,
|
parseFirestoreDate,
|
||||||
@@ -52,6 +53,23 @@ function subscrDurationLabel(days) {
|
|||||||
return `${days}d`
|
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 ───────────────────────────────────────────────────────────
|
// ─── Sub-components ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function GlassCard({ children, style = {}, className = '' }) {
|
function GlassCard({ children, style = {}, className = '' }) {
|
||||||
@@ -146,6 +164,14 @@ const LOG_COLORS = {
|
|||||||
INFO: { text: 'var(--color-info)', bg: 'var(--color-info-bg)' },
|
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
|
// Level slider steps: 0=All, 1=INFO, 2=WARN, 3=ERROR
|
||||||
const LEVEL_STEPS = [
|
const LEVEL_STEPS = [
|
||||||
{ value: '', label: 'ALL', color: 'var(--color-text-muted)' },
|
{ 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 [issueModal, setIssueModal] = useState({ open: false, entry: null })
|
||||||
const [viewNote, setViewNote] = useState(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 = () => {
|
const handleNoteSaved = () => {
|
||||||
setNoteModal({ open: false })
|
setNoteModal({ open: false })
|
||||||
loadNotes()
|
loadNotes()
|
||||||
@@ -277,43 +321,52 @@ export default function OverviewTab({
|
|||||||
|
|
||||||
// ── Live logs ─────────────────────────────────────────────────────────────
|
// ── Live logs ─────────────────────────────────────────────────────────────
|
||||||
const [logs, setLogs] = useState([])
|
const [logs, setLogs] = useState([])
|
||||||
const [logsLoading, setLogsLoading] = useState(false)
|
const [logsInitialLoading, setLogsInitialLoading] = useState(true)
|
||||||
const [levelStep, setLevelStep] = useState(0) // index into LEVEL_STEPS
|
const [levelStep, setLevelStep] = useState(0) // index into LEVEL_STEPS
|
||||||
const [searchText, setSearchText] = useState('')
|
const [searchText, setSearchText] = useState('')
|
||||||
const [autoScroll, setAutoScroll] = useState(true)
|
const [autoScroll, setAutoScroll] = useState(true)
|
||||||
const [autoRefresh, setAutoRefresh] = useState(false)
|
const [autoRefresh, setAutoRefresh] = useState(true)
|
||||||
const [liveLogs, setLiveLogs] = useState([])
|
const [liveLogs, setLiveLogs] = useState([])
|
||||||
const logsContainerRef = useRef(null)
|
const logsContainerRef = useRef(null)
|
||||||
|
const logsHasLoadedOnce = useRef(false)
|
||||||
const LIMIT = 50
|
const LIMIT = 50
|
||||||
|
|
||||||
const levelFilter = LEVEL_STEPS[levelStep].value
|
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
|
if (!sn) return
|
||||||
setLogsLoading(true)
|
if (!background && !logsHasLoadedOnce.current) setLogsInitialLoading(true)
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({ limit: LIMIT, offset: 0 })
|
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)
|
if (searchText) params.set('search', searchText)
|
||||||
const data = await api.get(`/mqtt/logs/${sn}?${params}`)
|
const data = await api.get(`/mqtt/logs/${sn}?${params}`)
|
||||||
setLogs(data.logs || [])
|
setLogs(data.logs || [])
|
||||||
} catch {
|
} catch {
|
||||||
// silent
|
// silent
|
||||||
} finally {
|
} finally {
|
||||||
setLogsLoading(false)
|
logsHasLoadedOnce.current = true
|
||||||
|
setLogsInitialLoading(false)
|
||||||
}
|
}
|
||||||
}, [sn, levelFilter, searchText])
|
}, [sn, levelFilter, searchText])
|
||||||
|
|
||||||
useEffect(() => { if (sn) fetchLogs() }, [sn, fetchLogs])
|
useEffect(() => { if (sn) fetchLogs(false) }, [sn, fetchLogs])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!autoRefresh || !sn) return
|
if (!autoRefresh || !sn) return
|
||||||
const t = setInterval(fetchLogs, 5000)
|
const t = setInterval(() => fetchLogs(true), 5000)
|
||||||
return () => clearInterval(t)
|
return () => clearInterval(t)
|
||||||
}, [autoRefresh, sn, fetchLogs])
|
}, [autoRefresh, sn, fetchLogs])
|
||||||
|
|
||||||
const handleWsMessage = useCallback((data) => {
|
const handleWsMessage = useCallback((data) => {
|
||||||
if (data.type === 'logs' && data.device_serial === sn) {
|
if (data.type === 'system/logs' && data.device_serial === sn) {
|
||||||
const entry = {
|
const entry = {
|
||||||
id: Date.now(),
|
id: Date.now(),
|
||||||
level: data.payload?.level?.includes('EROR') ? 'ERROR' :
|
level: data.payload?.level?.includes('EROR') ? 'ERROR' :
|
||||||
@@ -330,18 +383,24 @@ export default function OverviewTab({
|
|||||||
|
|
||||||
const allLogs = [
|
const allLogs = [
|
||||||
...liveLogs.filter(l => {
|
...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
|
if (searchText && !l.message.toLowerCase().includes(searchText.toLowerCase())) return false
|
||||||
return true
|
return true
|
||||||
}),
|
}),
|
||||||
...logs,
|
...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(() => {
|
useEffect(() => {
|
||||||
if (autoScroll && logsContainerRef.current) {
|
if (liveLogCount > prevLiveLogCount.current && autoScroll && logsContainerRef.current) {
|
||||||
logsContainerRef.current.scrollTop = 0
|
logsContainerRef.current.scrollTop = 0
|
||||||
}
|
}
|
||||||
}, [allLogs.length, autoScroll])
|
prevLiveLogCount.current = liveLogCount
|
||||||
|
}, [liveLogCount, autoScroll])
|
||||||
|
|
||||||
// ── Service lifecycle ─────────────────────────────────────────────────────
|
// ── Service lifecycle ─────────────────────────────────────────────────────
|
||||||
const subscrStart = parseFirestoreDate(sub?.subscrStart)
|
const subscrStart = parseFirestoreDate(sub?.subscrStart)
|
||||||
@@ -429,68 +488,93 @@ export default function OverviewTab({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
{/* ── Section B: Hardware info ── */}
|
{/* ── Section B+D: Device info grid (Serial/HW/Uptime/Location/Rev/Issue) ── */}
|
||||||
<div style={{
|
<div style={{
|
||||||
flex: 1,
|
flex: 2,
|
||||||
padding: 'var(--space-6)',
|
padding: 'var(--space-6)',
|
||||||
|
marginLeft: 'var(--space-4)',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
|
||||||
gap: 'var(--space-4)',
|
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
}}>
|
}} className="overview-hero-grid-wrap">
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-5) var(--space-8)', maxWidth: 480 }}>
|
<div className="overview-hero-grid" style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(3, minmax(0, 1fr))',
|
||||||
|
gridAutoRows: 'auto',
|
||||||
|
columnGap: 'var(--space-8)',
|
||||||
|
rowGap: 'var(--space-5)',
|
||||||
|
width: '100%',
|
||||||
|
}}>
|
||||||
<InfoField label="Serial Number" mono>{sn || '—'}</InfoField>
|
<InfoField label="Serial Number" mono>{sn || '—'}</InfoField>
|
||||||
<InfoField label="Hardware Family">{hwVariant}</InfoField>
|
<InfoField label="Hardware Family">{hwVariant}</InfoField>
|
||||||
|
<InfoField label="Device Uptime">
|
||||||
|
{mqttStatus?.last_heartbeat?.uptime_ms != null
|
||||||
|
? <span style={{ fontFamily: 'var(--font-family-mono)', fontSize: 'var(--font-size-base)' }}>{formatUptime(Math.floor(mqttStatus.last_heartbeat.uptime_ms / 1000))}</span>
|
||||||
|
: <span style={{ color: 'var(--color-text-muted)', fontStyle: 'italic', fontWeight: 'var(--font-weight-normal)' }}>Unavailable</span>}
|
||||||
|
</InfoField>
|
||||||
<InfoField label="Location">{device.device_location || '—'}</InfoField>
|
<InfoField label="Location">{device.device_location || '—'}</InfoField>
|
||||||
<InfoField label="Hardware Revision">{hwRevision || device.device_attributes?.firmwareVersion || '—'}</InfoField>
|
<InfoField label="Hardware Revision">{hwRevision || device.device_attributes?.firmwareVersion || '—'}</InfoField>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-1)', minWidth: 0 }}>
|
||||||
{/* ── Section D: Uptime + Latest Issue ── */}
|
<span style={{
|
||||||
<div style={{
|
fontSize: 'var(--font-size-xs)',
|
||||||
width: 220,
|
fontWeight: 'var(--font-weight-semibold)',
|
||||||
flexShrink: 0,
|
color: 'var(--color-text-more-muted)',
|
||||||
padding: 'var(--space-6)',
|
letterSpacing: 'var(--tracking-wide)',
|
||||||
display: 'flex',
|
textTransform: 'uppercase',
|
||||||
flexDirection: 'column',
|
}}>
|
||||||
justifyContent: 'center',
|
Latest Device Issue
|
||||||
gap: 'var(--space-5)',
|
|
||||||
minWidth: 0,
|
|
||||||
}}>
|
|
||||||
<InfoField label="Device Uptime">
|
|
||||||
{mqttStatus?.uptime_seconds != null
|
|
||||||
? <span style={{ fontFamily: 'var(--font-family-mono)', fontSize: 'var(--font-size-base)' }}>{formatUptime(mqttStatus.uptime_seconds)}</span>
|
|
||||||
: <span style={{ color: 'var(--color-text-muted)', fontStyle: 'italic', fontWeight: 'var(--font-weight-normal)' }}>Unavailable</span>}
|
|
||||||
</InfoField>
|
|
||||||
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-3)' }}>
|
|
||||||
<span style={{ fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-semibold)', color: 'var(--color-text-more-muted)', letterSpacing: 'var(--tracking-wide)', textTransform: 'uppercase' }}>Latest Device Issue</span>
|
|
||||||
{mqttStatus?.last_warn_message ? (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-1)' }}>
|
|
||||||
<span style={{
|
|
||||||
fontSize: 'var(--font-size-xs)',
|
|
||||||
color: 'var(--color-warning)',
|
|
||||||
fontWeight: 'var(--font-weight-semibold)',
|
|
||||||
letterSpacing: 'var(--tracking-wide)',
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
}}>
|
|
||||||
MQTT · WARN · Health Monitor
|
|
||||||
</span>
|
|
||||||
<span style={{
|
|
||||||
fontSize: 'var(--font-size-sm)',
|
|
||||||
color: 'var(--color-text-secondary)',
|
|
||||||
lineHeight: 1.5,
|
|
||||||
}}>
|
|
||||||
{mqttStatus.last_warn_message}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<span style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)', fontStyle: 'italic' }}>
|
|
||||||
No recent issues
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
{mqttStatus?.last_alert_event ? (
|
||||||
|
<div
|
||||||
|
onClick={openAlertModal}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openAlertModal() } }}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 2,
|
||||||
|
cursor: 'pointer',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
marginInline: -4,
|
||||||
|
paddingInline: 4,
|
||||||
|
transition: 'background 0.15s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.background = GLASS_INNER}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
|
||||||
|
>
|
||||||
|
<span style={{
|
||||||
|
fontSize: 'var(--font-size-xs)',
|
||||||
|
fontWeight: 'var(--font-weight-semibold)',
|
||||||
|
letterSpacing: 'var(--tracking-wide)',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
...alertEventStyle(mqttStatus.last_alert_event),
|
||||||
|
}}>
|
||||||
|
{mqttStatus.last_alert_event.state} · {mqttStatus.last_alert_event.subsystem} · {fmtRelative(mqttStatus.last_alert_event.occurred_at)}
|
||||||
|
</span>
|
||||||
|
<span style={{
|
||||||
|
fontSize: 'var(--font-size-sm)',
|
||||||
|
color: 'var(--color-text-secondary)',
|
||||||
|
lineHeight: 1.4,
|
||||||
|
overflow: 'hidden',
|
||||||
|
display: '-webkit-box',
|
||||||
|
WebkitLineClamp: 1,
|
||||||
|
WebkitBoxOrient: 'vertical',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
}}>
|
||||||
|
{mqttStatus.last_alert_event.message || 'No details provided.'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)', fontStyle: 'italic' }}>
|
||||||
|
No recent issues
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -930,7 +1014,7 @@ export default function OverviewTab({
|
|||||||
{LEVEL_STEPS.map((step, idx) => (
|
{LEVEL_STEPS.map((step, idx) => (
|
||||||
<button
|
<button
|
||||||
key={step.value}
|
key={step.value}
|
||||||
onClick={() => { setLevelStep(idx); setLogs([]) }}
|
onClick={() => setLevelStep(idx)}
|
||||||
title={step.label}
|
title={step.label}
|
||||||
style={{
|
style={{
|
||||||
height: 24,
|
height: 24,
|
||||||
@@ -1020,10 +1104,10 @@ export default function OverviewTab({
|
|||||||
AUTO-SYNC
|
AUTO-SYNC
|
||||||
</button>
|
</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
|
<button
|
||||||
onClick={fetchLogs}
|
onClick={() => fetchLogs(true)}
|
||||||
disabled={logsLoading}
|
|
||||||
style={{
|
style={{
|
||||||
height: 28,
|
height: 28,
|
||||||
paddingInline: 'var(--space-3)',
|
paddingInline: 'var(--space-3)',
|
||||||
@@ -1033,24 +1117,19 @@ export default function OverviewTab({
|
|||||||
color: 'var(--color-text-secondary)',
|
color: 'var(--color-text-secondary)',
|
||||||
fontSize: 'var(--font-size-xs)',
|
fontSize: 'var(--font-size-xs)',
|
||||||
fontWeight: 'var(--font-weight-semibold)',
|
fontWeight: 'var(--font-weight-semibold)',
|
||||||
cursor: logsLoading ? 'not-allowed' : 'pointer',
|
cursor: 'pointer',
|
||||||
letterSpacing: 'var(--tracking-wide)',
|
letterSpacing: 'var(--tracking-wide)',
|
||||||
fontFamily: 'var(--font-family-base)',
|
fontFamily: 'var(--font-family-base)',
|
||||||
opacity: logsLoading ? 0.5 : 1,
|
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 'var(--space-1)',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{logsLoading ? <Spinner size="sm" /> : null}
|
|
||||||
REFRESH
|
REFRESH
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Log rows */}
|
{/* Log rows */}
|
||||||
<div ref={logsContainerRef} style={{ maxHeight: 320, overflowY: 'auto', fontFamily: 'var(--font-family-mono)', fontSize: 'var(--font-size-sm)' }}>
|
<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)' }}>
|
<div style={{ display: 'flex', justifyContent: 'center', padding: 'var(--space-8)' }}>
|
||||||
<Spinner size="sm" />
|
<Spinner size="sm" />
|
||||||
</div>
|
</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' }}>
|
<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 && (
|
{log._live && (
|
||||||
<span style={{ marginLeft: 'var(--space-2)', fontSize: 'var(--font-size-xs)', color: 'var(--color-success)', fontWeight: 'var(--font-weight-semibold)' }}>LIVE</span>
|
<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}
|
{log.level}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</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}
|
{log.message}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -1134,6 +1213,12 @@ export default function OverviewTab({
|
|||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.overview-row2 { grid-template-columns: 1fr !important; }
|
.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 {
|
@keyframes ov-pulse {
|
||||||
0%, 100% { opacity: 1; }
|
0%, 100% { opacity: 1; }
|
||||||
50% { opacity: 0.35; }
|
50% { opacity: 0.35; }
|
||||||
@@ -1160,8 +1245,8 @@ export default function OverviewTab({
|
|||||||
open={issueModal.open}
|
open={issueModal.open}
|
||||||
entry={issueModal.entry}
|
entry={issueModal.entry}
|
||||||
defaultType="issue"
|
defaultType="issue"
|
||||||
prefilledLinks={issueModal.entry ? undefined : [{ entity_type: 'device', entity_id: id, display_name: device?.device_name || sn, locked: true }]}
|
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?.device_name || sn } : undefined}
|
knownEntities={id ? { [id]: device?.console_name || device?.device_name || sn } : undefined}
|
||||||
onClose={() => setIssueModal({ open: false, entry: null })}
|
onClose={() => setIssueModal({ open: false, entry: null })}
|
||||||
onSaved={handleIssueSaved}
|
onSaved={handleIssueSaved}
|
||||||
/>
|
/>
|
||||||
@@ -1224,6 +1309,92 @@ export default function OverviewTab({
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user