diff --git a/frontend/src/components/ui/SignalIndicator.jsx b/frontend/src/components/ui/SignalIndicator.jsx index 0d028c3..e31ae11 100644 --- a/frontend/src/components/ui/SignalIndicator.jsx +++ b/frontend/src/components/ui/SignalIndicator.jsx @@ -4,6 +4,9 @@ // // Props: // rssi — number | null | undefined — signal strength in dBm (e.g. -62). null/undefined = unknown. +// isOnline — boolean | undefined — when explicitly false, renders a dimmed amber +// glyph with an "X" overlay regardless of rssi, so a stale last-known +// reading from an offline device never reads as a live "full signal". // size — 'sm' | 'md' (default: 'md') // className — extra classes // @@ -36,14 +39,17 @@ const SIZES = { const DIM_COLOR = 'var(--color-bg-island)' -export default function SignalIndicator({ rssi, size = 'md', className = '' }) { - const known = rssi != null && Number.isFinite(rssi) +export default function SignalIndicator({ rssi, isOnline, size = 'md', className = '' }) { + const offline = isOnline === false + const known = !offline && rssi != null && Number.isFinite(rssi) const level = known ? rssiToLevel(rssi) : 0 - const color = rssiToColor(known ? rssi : null) + const color = offline ? 'var(--color-warning)' : rssiToColor(known ? rssi : null) const { box, strokeWidth } = SIZES[size] || SIZES.md - const label = known - ? `Signal strength: ${['weak', 'fair', 'good', 'excellent'][level - 1]} (${rssi} dBm)` - : 'Signal strength unknown' + const label = offline + ? 'Signal strength unavailable — device offline' + : known + ? `Signal strength: ${['weak', 'fair', 'good', 'excellent'][level - 1]} (${rssi} dBm)` + : 'Signal strength unknown' return ( {label} {/* Center dot — level 1 */} - = 1 ? color : DIM_COLOR} style={{ transition: 'fill 0.2s' }} /> + = 1 ? color : DIM_COLOR)} style={{ transition: 'fill 0.2s' }} /> {/* Inner arc — level 2 */} = 2 ? color : DIM_COLOR} + stroke={offline ? DIM_COLOR : (level >= 2 ? color : DIM_COLOR)} strokeWidth={strokeWidth} strokeLinecap="round" style={{ transition: 'stroke 0.2s' }} @@ -71,7 +77,7 @@ export default function SignalIndicator({ rssi, size = 'md', className = '' }) { = 3 ? color : DIM_COLOR} + stroke={offline ? DIM_COLOR : (level >= 3 ? color : DIM_COLOR)} strokeWidth={strokeWidth} strokeLinecap="round" style={{ transition: 'stroke 0.2s' }} @@ -80,11 +86,18 @@ export default function SignalIndicator({ rssi, size = 'md', className = '' }) { = 4 ? color : DIM_COLOR} + stroke={offline ? DIM_COLOR : (level >= 4 ? color : DIM_COLOR)} strokeWidth={strokeWidth} strokeLinecap="round" style={{ transition: 'stroke 0.2s' }} /> + {/* Offline overlay — amber X, replaces the arc/dot reading entirely */} + {offline && ( + + + + + )} ) } diff --git a/frontend/src/pages/bellcloud/devices/DeviceDetail.jsx b/frontend/src/pages/bellcloud/devices/DeviceDetail.jsx index 2e7ac6a..b74b1b9 100644 --- a/frontend/src/pages/bellcloud/devices/DeviceDetail.jsx +++ b/frontend/src/pages/bellcloud/devices/DeviceDetail.jsx @@ -11,6 +11,7 @@ import PageHeader from '@/components/ui/PageHeader' import Button from '@/components/ui/Button' import SignalIndicator from '@/components/ui/SignalIndicator' import EditableText from '@/components/ui/EditableText' +import Icon from '@/components/ui/Icon' import Tabs from '@/components/ui/Tabs' import Spinner from '@/components/ui/Spinner' import ConfirmDialog from '@/components/ui/ConfirmDialog' @@ -35,6 +36,64 @@ import ManageTab from '@/pages/bellcloud/devices/tabs/ManageTab' import ControlTab from '@/pages/bellcloud/devices/tabs/ControlTab' import HealthTab from '@/pages/bellcloud/devices/tabs/HealthTab' +// ─── Serial subtitle with hover-to-reveal copy button ──────────────────────── + +function SerialWithCopy({ serial }) { + const [copied, setCopied] = useState(false) + + const handleCopy = () => { + if (!serial) return + const done = () => { setCopied(true); setTimeout(() => setCopied(false), 1500) } + if (navigator.clipboard) { + navigator.clipboard.writeText(serial).then(done).catch(() => { + const ta = document.createElement('textarea') + ta.value = serial; ta.style.cssText = 'position:fixed;opacity:0' + document.body.appendChild(ta); ta.select() + try { document.execCommand('copy'); done() } catch { /* clipboard unavailable */ } + document.body.removeChild(ta) + }) + } + } + + return ( + + {serial} + {serial && ( + + )} + + + ) +} + // ─── Tab config ─────────────────────────────────────────────────────────────── const TABS = [ @@ -389,7 +448,7 @@ export default function DeviceDetail() { display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, }}> - + - {sn} + } diff --git a/frontend/src/pages/bellcloud/devices/DeviceList.jsx b/frontend/src/pages/bellcloud/devices/DeviceList.jsx index bcf95a1..91a79d7 100644 --- a/frontend/src/pages/bellcloud/devices/DeviceList.jsx +++ b/frontend/src/pages/bellcloud/devices/DeviceList.jsx @@ -551,7 +551,7 @@ export default function DeviceList() { case 'name': return ( - + {deviceDisplayName(device)} diff --git a/frontend/src/pages/bellcloud/devices/tabs/ControlTab.jsx b/frontend/src/pages/bellcloud/devices/tabs/ControlTab.jsx index 2db9d6c..f02a796 100644 --- a/frontend/src/pages/bellcloud/devices/tabs/ControlTab.jsx +++ b/frontend/src/pages/bellcloud/devices/tabs/ControlTab.jsx @@ -1,7 +1,7 @@ // frontend/src/pages/bellcloud/devices/tabs/ControlTab.jsx // Control tab — direct MQTT command execution: relay tests, clock control, system actions. -import { useState } from 'react' +import { useState, useEffect, useRef } from 'react' import Card from '@/components/ui/Card' import Button from '@/components/ui/Button' import StatusBadge from '@/components/ui/StatusBadge' @@ -11,6 +11,12 @@ import ConfirmDialog from '@/components/ui/ConfirmDialog' import { useToast } from '@/components/ui/Toast' import ResetStatsModal from '@/modals/bellcloud/devices/ResetStatsModal' +const OTA_CHANNELS = [ + { value: 'stable', label: 'Stable' }, + { value: 'beta', label: 'Beta' }, + { value: 'development', label: 'Development' }, +] + // ─── Glass surface constants (match OverviewTab / BellsTab aesthetic) ───────── const GLASS = 'rgba(28, 32, 38, 0.30)' @@ -109,7 +115,7 @@ function StackedActionRow({ title, description, children, last = false }) { // ─── ControlTab ──────────────────────────────────────────────────────────────── -export default function ControlTab({ device, attr, canEdit, isOnline, sendMqttCommand, sendingCmd }) { +export default function ControlTab({ device, attr, canEdit, isOnline, sendMqttCommand, sendMqttCommandSilent, sendingCmd }) { const { toast } = useToast() const totalBells = attr?.totalBells || 0 @@ -130,8 +136,35 @@ export default function ControlTab({ device, attr, canEdit, isOnline, sendMqttCo const [confirmFactoryReset, setConfirmFactoryReset] = useState(false) const [showResetStats, setShowResetStats] = useState(false) + // firmware + const [firmwareStatus, setFirmwareStatus] = useState(null) + const [otaChannel, setOtaChannel] = useState('stable') + const [customUrl, setCustomUrl] = useState('') + const [confirmOtaUpdate, setConfirmOtaUpdate] = useState(false) + const [confirmOtaCustom, setConfirmOtaCustom] = useState(false) + const [confirmRollback, setConfirmRollback] = useState(false) + const hasSyncedFwRef = useRef(false) + const disabled = !canEdit || !isOnline + const loadFirmwareStatus = () => { + if (!sendMqttCommandSilent) return + sendMqttCommandSilent('firmware.status').then((reply) => { + const data = reply?.data || null + setFirmwareStatus(data) + if (data?.ota_channel) setOtaChannel(data.ota_channel) + }).catch(() => { + // Device offline or didn't reply — card stays empty/dash. + }) + } + + useEffect(() => { + if (hasSyncedFwRef.current || !sendMqttCommandSilent || !device) return + hasSyncedFwRef.current = true + loadFirmwareStatus() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [device?.id, device?.device_id]) + const run = async (cmd, contents) => { await sendMqttCommand(cmd, contents) } @@ -181,8 +214,47 @@ export default function ControlTab({ device, attr, canEdit, isOnline, sendMqttCo run('system.restart', {}) } + // ── Firmware handlers ───────────────────────────────────────────────────── + + const handleChangeChannel = async (channel) => { + const previous = otaChannel + setOtaChannel(channel) + try { + await run('ota.set_channel', { channel }) + } catch { + setOtaChannel(previous) + } + } + + const handleOtaUpdate = async () => { + setConfirmOtaUpdate(false) + await run('ota.update', { channel: otaChannel }) + loadFirmwareStatus() + } + + const handleOtaCustom = async () => { + setConfirmOtaCustom(false) + const firmware_url = customUrl.trim() + if (!firmware_url) { + toast.danger('Missing URL', 'Enter a firmware .bin URL first.') + return + } + await run('ota.custom', { firmware_url }) + loadFirmwareStatus() + } + + const handleCommit = async () => { + await run('firmware.commit', {}) + loadFirmwareStatus() + } + + const handleRollback = async () => { + setConfirmRollback(false) + await run('firmware.rollback', {}) + } + return ( -
+
{/* ── Status bar ────────────────────────────────────────────────────── */} @@ -419,6 +491,135 @@ export default function ControlTab({ device, attr, canEdit, isOnline, sendMqttCo
+ {/* ── Firmware ────────────────────────────────────────────────────── */} + } + > +
+ + {/* Current status summary */} + +
+ {firmwareStatus && ( + <> + + {firmwareStatus.validation_state || '—'} + + + Boot count: {Number.isFinite(firmwareStatus.boot_count) ? firmwareStatus.boot_count : '—'} + + + )} + +
+
+ + {/* Commit / rollback */} + + + + + + + + {/* OTA channel + update */} + +
+
+ handleChangeChannel(e.target.value)} + disabled={disabled} + > + {OTA_CHANNELS.map(c => ( + + ))} + +
+ +
+
+ + {/* Custom OTA URL */} + +
+
+ setCustomUrl(e.target.value)} + placeholder="http://example.com/firmware.bin" + disabled={disabled} + /> +
+ +
+
+ +
+
+
{/* ── Confirm dialogs ───────────────────────────────────────────────── */} @@ -452,6 +653,39 @@ export default function ControlTab({ device, attr, canEdit, isOnline, sendMqttCo sendMqttCommand={sendMqttCommand} /> + setConfirmOtaUpdate(false)} + loading={sendingCmd === 'ota.update'} + /> + + setConfirmOtaCustom(false)} + loading={sendingCmd === 'ota.custom'} + /> + + setConfirmRollback(false)} + loading={sendingCmd === 'firmware.rollback'} + /> + ) } diff --git a/frontend/src/pages/bellcloud/devices/tabs/GeneralTab.jsx b/frontend/src/pages/bellcloud/devices/tabs/GeneralTab.jsx index 3cd4452..2517bb7 100644 --- a/frontend/src/pages/bellcloud/devices/tabs/GeneralTab.jsx +++ b/frontend/src/pages/bellcloud/devices/tabs/GeneralTab.jsx @@ -886,6 +886,7 @@ export default function GeneralTab({ // needed since nothing else in the console ever writes it. const hasSyncedRef = useRef(false) const [networkInfo, setNetworkInfo] = useState(null) + const [firmwareStatus, setFirmwareStatus] = useState(null) useEffect(() => { if (!sendMqttCommandSilent || !device) return if (hasSyncedRef.current) return @@ -926,6 +927,12 @@ export default function GeneralTab({ }).catch(() => { // Device offline or didn't reply — Network Info card stays empty/dash. }) + + sendMqttCommandSilent('firmware.status').then((reply) => { + setFirmwareStatus(reply?.data || null) + }).catch(() => { + // Device offline or didn't reply — Firmware card stays empty/dash. + }) // eslint-disable-next-line react-hooks/exhaustive-deps }, [device?.id, device?.device_id]) @@ -1015,6 +1022,91 @@ export default function GeneralTab({ return (
+ {/* ════════════════════════════════════════════════════════════════════ + ROW 0 — Firmware (full width, short) + ════════════════════════════════════════════════════════════════════ */} + +
+
+
+ +
+
+ Firmware +
+ + {firmwareStatus?.current_version ? `v${firmwareStatus.current_version}` : '—'} + + {firmwareStatus?.build_date && ( + + built {firmwareStatus.build_date} + + )} +
+
+
+ +
+
+ Channel + + {firmwareStatus?.ota_channel || '—'} + +
+ +
+ Validation + + {firmwareStatus?.validation_state || '—'} + +
+ +
+ Boot Count + + {Number.isFinite(firmwareStatus?.boot_count) ? firmwareStatus.boot_count : '—'} + +
+ +
+ Backup Slot + + {firmwareStatus?.backup_version ? `v${firmwareStatus.backup_version}` : '—'} + {firmwareStatus?.backup_version && ( + + {firmwareStatus?.backup_is_valid ? 'valid' : 'invalid'} + + )} + +
+ + {firmwareStatus?.is_testing && ( + Testing — awaiting commit + )} +
+
+
+ {/* ════════════════════════════════════════════════════════════════════ ROW 1 — Map (2/3) + Safety Settings (1/3) equal height ════════════════════════════════════════════════════════════════════ */} diff --git a/frontend/src/pages/bellcloud/devices/tabs/OverviewTab.jsx b/frontend/src/pages/bellcloud/devices/tabs/OverviewTab.jsx index ad19121..a9edd12 100644 --- a/frontend/src/pages/bellcloud/devices/tabs/OverviewTab.jsx +++ b/frontend/src/pages/bellcloud/devices/tabs/OverviewTab.jsx @@ -512,7 +512,7 @@ export default function OverviewTab({ ? {formatUptime(Math.floor(mqttStatus.last_heartbeat.uptime_ms / 1000))} : Unavailable} - {device.device_location || '—'} + {mqttStatus?.last_heartbeat?.firmware_version || '—'} {hwRevision || device.device_attributes?.firmwareVersion || '—'}