fix(devices): offline signal glyph, plus firmware UI across Device Detail tabs

SignalIndicator now takes an isOnline prop — when a device is offline it
renders a dimmed amber glyph with an X instead of showing the last-known
(possibly full-bars) RSSI reading, which was misleading on the Device List
table view. Card view already special-cased offline devices and is
unaffected.

Also, while working the device details surface:
- DeviceDetail: hover-to-reveal copy button next to the serial number
  subtitle in the page header.
- OverviewTab hero: swapped the redundant "Location" field (already shown
  in detail on the General tab's map) for live Firmware Version.
- GeneralTab: new full-width, compact Firmware hero row (version, channel,
  validation state, boot count, backup slot) sourced from firmware.status.
- ControlTab: max width brought in line with every other tab (2000, was
  1400), and added a 4th "Firmware" section with channel selection,
  Update Now, custom-URL flash, and commit/rollback controls, wired to the
  firmware.status / ota.* / firmware.commit / firmware.rollback commands
  documented in the API Reference.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-22 11:47:10 +03:00
co-authored by Claude Sonnet 5
parent c8ac0b78c5
commit 529e866fff
6 changed files with 415 additions and 17 deletions
+23 -10
View File
@@ -4,6 +4,9 @@
// //
// Props: // Props:
// rssi — number | null | undefined — signal strength in dBm (e.g. -62). null/undefined = unknown. // 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') // size — 'sm' | 'md' (default: 'md')
// className — extra classes // className — extra classes
// //
@@ -36,14 +39,17 @@ const SIZES = {
const DIM_COLOR = 'var(--color-bg-island)' const DIM_COLOR = 'var(--color-bg-island)'
export default function SignalIndicator({ rssi, size = 'md', className = '' }) { export default function SignalIndicator({ rssi, isOnline, size = 'md', className = '' }) {
const known = rssi != null && Number.isFinite(rssi) const offline = isOnline === false
const known = !offline && rssi != null && Number.isFinite(rssi)
const level = known ? rssiToLevel(rssi) : 0 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 { box, strokeWidth } = SIZES[size] || SIZES.md
const label = known const label = offline
? `Signal strength: ${['weak', 'fair', 'good', 'excellent'][level - 1]} (${rssi} dBm)` ? 'Signal strength unavailable — device offline'
: 'Signal strength unknown' : known
? `Signal strength: ${['weak', 'fair', 'good', 'excellent'][level - 1]} (${rssi} dBm)`
: 'Signal strength unknown'
return ( return (
<svg <svg
@@ -57,12 +63,12 @@ export default function SignalIndicator({ rssi, size = 'md', className = '' }) {
> >
<title>{label}</title> <title>{label}</title>
{/* Center dot — level 1 */} {/* Center dot — level 1 */}
<circle cx="12" cy="18.5" r="1.6" fill={level >= 1 ? color : DIM_COLOR} style={{ transition: 'fill 0.2s' }} /> <circle cx="12" cy="18.5" r="1.6" fill={offline ? DIM_COLOR : (level >= 1 ? color : DIM_COLOR)} style={{ transition: 'fill 0.2s' }} />
{/* Inner arc — level 2 */} {/* Inner arc — level 2 */}
<path <path
d="M8.2 14.8a5.4 5.4 0 0 1 7.6 0" d="M8.2 14.8a5.4 5.4 0 0 1 7.6 0"
fill="none" fill="none"
stroke={level >= 2 ? color : DIM_COLOR} stroke={offline ? DIM_COLOR : (level >= 2 ? color : DIM_COLOR)}
strokeWidth={strokeWidth} strokeWidth={strokeWidth}
strokeLinecap="round" strokeLinecap="round"
style={{ transition: 'stroke 0.2s' }} style={{ transition: 'stroke 0.2s' }}
@@ -71,7 +77,7 @@ export default function SignalIndicator({ rssi, size = 'md', className = '' }) {
<path <path
d="M5 11.6a9.8 9.8 0 0 1 14 0" d="M5 11.6a9.8 9.8 0 0 1 14 0"
fill="none" fill="none"
stroke={level >= 3 ? color : DIM_COLOR} stroke={offline ? DIM_COLOR : (level >= 3 ? color : DIM_COLOR)}
strokeWidth={strokeWidth} strokeWidth={strokeWidth}
strokeLinecap="round" strokeLinecap="round"
style={{ transition: 'stroke 0.2s' }} style={{ transition: 'stroke 0.2s' }}
@@ -80,11 +86,18 @@ export default function SignalIndicator({ rssi, size = 'md', className = '' }) {
<path <path
d="M1.8 8.4a14.2 14.2 0 0 1 20.4 0" d="M1.8 8.4a14.2 14.2 0 0 1 20.4 0"
fill="none" fill="none"
stroke={level >= 4 ? color : DIM_COLOR} stroke={offline ? DIM_COLOR : (level >= 4 ? color : DIM_COLOR)}
strokeWidth={strokeWidth} strokeWidth={strokeWidth}
strokeLinecap="round" strokeLinecap="round"
style={{ transition: 'stroke 0.2s' }} style={{ transition: 'stroke 0.2s' }}
/> />
{/* Offline overlay — amber X, replaces the arc/dot reading entirely */}
{offline && (
<g stroke={color} strokeWidth={strokeWidth} strokeLinecap="round">
<path d="M8.5 5.5l7 7" />
<path d="M15.5 5.5l-7 7" />
</g>
)}
</svg> </svg>
) )
} }
@@ -11,6 +11,7 @@ import PageHeader from '@/components/ui/PageHeader'
import Button from '@/components/ui/Button' import Button from '@/components/ui/Button'
import SignalIndicator from '@/components/ui/SignalIndicator' import SignalIndicator from '@/components/ui/SignalIndicator'
import EditableText from '@/components/ui/EditableText' import EditableText from '@/components/ui/EditableText'
import Icon from '@/components/ui/Icon'
import Tabs from '@/components/ui/Tabs' import Tabs from '@/components/ui/Tabs'
import Spinner from '@/components/ui/Spinner' import Spinner from '@/components/ui/Spinner'
import ConfirmDialog from '@/components/ui/ConfirmDialog' 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 ControlTab from '@/pages/bellcloud/devices/tabs/ControlTab'
import HealthTab from '@/pages/bellcloud/devices/tabs/HealthTab' 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 (
<span className="device-serial-copy" style={{ display: 'inline-flex', alignItems: 'center', gap: 'var(--space-1)' }}>
<span className="page-header-subtitle" style={{ margin: 0 }}>{serial}</span>
{serial && (
<button
type="button"
onClick={handleCopy}
title={copied ? 'Copied!' : 'Copy serial number'}
aria-label="Copy serial number"
className="device-serial-copy__btn"
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: 18,
height: 18,
padding: 0,
border: 'none',
background: 'none',
borderRadius: 'var(--radius-sm)',
color: copied ? 'var(--color-success)' : 'var(--color-text-muted)',
cursor: 'pointer',
opacity: 0,
transition: 'opacity 0.15s, color 0.15s',
}}
>
<Icon name={copied ? 'check' : 'copy'} size={12} />
</button>
)}
<style>{`
.device-serial-copy:hover .device-serial-copy__btn,
.device-serial-copy__btn:focus-visible {
opacity: 1;
}
`}</style>
</span>
)
}
// ─── Tab config ─────────────────────────────────────────────────────────────── // ─── Tab config ───────────────────────────────────────────────────────────────
const TABS = [ const TABS = [
@@ -389,7 +448,7 @@ export default function DeviceDetail() {
display: 'flex', alignItems: 'center', justifyContent: 'center', display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0, flexShrink: 0,
}}> }}>
<SignalIndicator rssi={mqttStatus?.last_heartbeat?.rssi} size="md" /> <SignalIndicator rssi={mqttStatus?.last_heartbeat?.rssi} isOnline={isOnline} size="md" />
</span> </span>
<span style={{ display: 'flex', flexDirection: 'column' }}> <span style={{ display: 'flex', flexDirection: 'column' }}>
<EditableText <EditableText
@@ -399,7 +458,7 @@ export default function DeviceDetail() {
editable={canEdit} editable={canEdit}
inputAriaLabel="Edit console name" inputAriaLabel="Edit console name"
/> />
<span className="page-header-subtitle" style={{ margin: 0 }}>{sn}</span> <SerialWithCopy serial={sn} />
</span> </span>
</span> </span>
} }
@@ -551,7 +551,7 @@ export default function DeviceList() {
case 'name': case 'name':
return ( return (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 'var(--space-2)' }}> <span style={{ display: 'inline-flex', alignItems: 'center', gap: 'var(--space-2)' }}>
<SignalIndicator rssi={mqtt?.last_heartbeat?.rssi} size="sm" /> <SignalIndicator rssi={mqtt?.last_heartbeat?.rssi} isOnline={isOnline} size="sm" />
<span style={{ fontWeight: 'var(--font-weight-medium)', color: 'var(--color-text-primary)' }}> <span style={{ fontWeight: 'var(--font-weight-medium)', color: 'var(--color-text-primary)' }}>
{deviceDisplayName(device)} {deviceDisplayName(device)}
</span> </span>
@@ -1,7 +1,7 @@
// frontend/src/pages/bellcloud/devices/tabs/ControlTab.jsx // frontend/src/pages/bellcloud/devices/tabs/ControlTab.jsx
// Control tab — direct MQTT command execution: relay tests, clock control, system actions. // 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 Card from '@/components/ui/Card'
import Button from '@/components/ui/Button' import Button from '@/components/ui/Button'
import StatusBadge from '@/components/ui/StatusBadge' import StatusBadge from '@/components/ui/StatusBadge'
@@ -11,6 +11,12 @@ import ConfirmDialog from '@/components/ui/ConfirmDialog'
import { useToast } from '@/components/ui/Toast' import { useToast } from '@/components/ui/Toast'
import ResetStatsModal from '@/modals/bellcloud/devices/ResetStatsModal' 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) ───────── // ─── Glass surface constants (match OverviewTab / BellsTab aesthetic) ─────────
const GLASS = 'rgba(28, 32, 38, 0.30)' const GLASS = 'rgba(28, 32, 38, 0.30)'
@@ -109,7 +115,7 @@ function StackedActionRow({ title, description, children, last = false }) {
// ─── ControlTab ──────────────────────────────────────────────────────────────── // ─── 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 { toast } = useToast()
const totalBells = attr?.totalBells || 0 const totalBells = attr?.totalBells || 0
@@ -130,8 +136,35 @@ export default function ControlTab({ device, attr, canEdit, isOnline, sendMqttCo
const [confirmFactoryReset, setConfirmFactoryReset] = useState(false) const [confirmFactoryReset, setConfirmFactoryReset] = useState(false)
const [showResetStats, setShowResetStats] = 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 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) => { const run = async (cmd, contents) => {
await sendMqttCommand(cmd, contents) await sendMqttCommand(cmd, contents)
} }
@@ -181,8 +214,47 @@ export default function ControlTab({ device, attr, canEdit, isOnline, sendMqttCo
run('system.restart', {}) 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 ( return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-6)', width: '100%', maxWidth: 1400, margin: '0 auto' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-6)', width: '100%', maxWidth: 2000, margin: '0 auto' }}>
{/* ── Status bar ────────────────────────────────────────────────────── */} {/* ── Status bar ────────────────────────────────────────────────────── */}
<GlassCard style={{ padding: 'var(--space-4) var(--space-6)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--space-4)', flexWrap: 'wrap' }}> <GlassCard style={{ padding: 'var(--space-4) var(--space-6)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--space-4)', flexWrap: 'wrap' }}>
@@ -419,6 +491,135 @@ export default function ControlTab({ device, attr, canEdit, isOnline, sendMqttCo
</div> </div>
</Card> </Card>
{/* ── Firmware ────────────────────────────────────────────────────── */}
<Card
title="Firmware"
subtitle="OTA channel, update, and rollback-safety controls."
icon={<Icon name="firmware" size={15} />}
>
<div style={{ display: 'flex', flexDirection: 'column' }}>
{/* Current status summary */}
<StackedActionRow
title="Current Firmware"
description={
firmwareStatus
? `v${firmwareStatus.current_version || '—'} · ${firmwareStatus.validation_state || 'UNKNOWN'}${firmwareStatus.is_testing ? ' · testing window open' : ''}`
: 'Not yet loaded — requires the device to be online.'
}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-3)', flexWrap: 'wrap' }}>
{firmwareStatus && (
<>
<StatusBadge variant={firmwareStatus.validation_state === 'VALIDATED' ? 'success' : firmwareStatus.is_testing ? 'warning' : 'neutral'}>
{firmwareStatus.validation_state || '—'}
</StatusBadge>
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>
Boot count: {Number.isFinite(firmwareStatus.boot_count) ? firmwareStatus.boot_count : '—'}
</span>
</>
)}
<Button variant="ghost" size="sm" disabled={!isOnline} onClick={loadFirmwareStatus}>
Refresh
</Button>
</div>
</StackedActionRow>
{/* Commit / rollback */}
<ActionRow
title="Commit Firmware"
description="Marks the current OTA slot as permanently valid. Only available during a testing window."
>
<Button
variant="secondary"
size="md"
disabled={disabled || !firmwareStatus?.can_commit}
loading={sendingCmd === 'firmware.commit'}
onClick={handleCommit}
>
Commit
</Button>
</ActionRow>
<ActionRow
title="Roll Back Firmware"
description="Reverts to the previous OTA slot and reboots immediately."
>
<Button
variant="danger"
size="md"
disabled={disabled || !firmwareStatus?.can_rollback}
loading={sendingCmd === 'firmware.rollback'}
onClick={() => setConfirmRollback(true)}
>
Rollback
</Button>
</ActionRow>
{/* OTA channel + update */}
<StackedActionRow
title="Update Now"
description="Flash the latest build from the VPS update server for the selected channel. Device reboots after flashing."
>
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 'var(--space-2)', flexWrap: 'wrap' }}>
<div style={{ width: 160 }}>
<FormField
label="Channel"
name="ota-channel"
type="select"
value={otaChannel}
onChange={e => handleChangeChannel(e.target.value)}
disabled={disabled}
>
{OTA_CHANNELS.map(c => (
<option key={c.value} value={c.value}>{c.label}</option>
))}
</FormField>
</div>
<Button
variant="secondary"
size="md"
disabled={disabled}
loading={sendingCmd === 'ota.update'}
onClick={() => setConfirmOtaUpdate(true)}
>
Update Now
</Button>
</div>
</StackedActionRow>
{/* Custom OTA URL */}
<StackedActionRow
title="Flash From Custom URL"
description="Flash a staging or manual build from a direct HTTP .bin URL. HTTPS not supported."
last
>
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 'var(--space-2)', flexWrap: 'wrap', width: '100%' }}>
<div style={{ flex: 1, minWidth: 220 }}>
<FormField
label="Firmware URL"
name="ota-custom-url"
type="text"
value={customUrl}
onChange={e => setCustomUrl(e.target.value)}
placeholder="http://example.com/firmware.bin"
disabled={disabled}
/>
</div>
<Button
variant="secondary"
size="md"
disabled={disabled || !customUrl.trim()}
loading={sendingCmd === 'ota.custom'}
onClick={() => setConfirmOtaCustom(true)}
>
Flash
</Button>
</div>
</StackedActionRow>
</div>
</Card>
</div> </div>
{/* ── Confirm dialogs ───────────────────────────────────────────────── */} {/* ── Confirm dialogs ───────────────────────────────────────────────── */}
@@ -452,6 +653,39 @@ export default function ControlTab({ device, attr, canEdit, isOnline, sendMqttCo
sendMqttCommand={sendMqttCommand} sendMqttCommand={sendMqttCommand}
/> />
<ConfirmDialog
open={confirmOtaUpdate}
variant="primary"
title="Update Firmware Now"
message={`This will flash the latest "${otaChannel}" build from the update server and reboot the device once complete. Make sure playback is stopped first. Continue?`}
confirmLabel="Update Now"
onConfirm={handleOtaUpdate}
onCancel={() => setConfirmOtaUpdate(false)}
loading={sendingCmd === 'ota.update'}
/>
<ConfirmDialog
open={confirmOtaCustom}
variant="primary"
title="Flash Custom Firmware"
message={`This will flash the device from:\n${customUrl.trim()}\n\nThe device will reboot after flashing. Continue?`}
confirmLabel="Flash"
onConfirm={handleOtaCustom}
onCancel={() => setConfirmOtaCustom(false)}
loading={sendingCmd === 'ota.custom'}
/>
<ConfirmDialog
open={confirmRollback}
variant="danger"
title="Roll Back Firmware"
message="This will revert to the previous OTA slot and reboot the device immediately. Continue?"
confirmLabel="Rollback"
onConfirm={handleRollback}
onCancel={() => setConfirmRollback(false)}
loading={sendingCmd === 'firmware.rollback'}
/>
</div> </div>
) )
} }
@@ -886,6 +886,7 @@ export default function GeneralTab({
// needed since nothing else in the console ever writes it. // needed since nothing else in the console ever writes it.
const hasSyncedRef = useRef(false) const hasSyncedRef = useRef(false)
const [networkInfo, setNetworkInfo] = useState(null) const [networkInfo, setNetworkInfo] = useState(null)
const [firmwareStatus, setFirmwareStatus] = useState(null)
useEffect(() => { useEffect(() => {
if (!sendMqttCommandSilent || !device) return if (!sendMqttCommandSilent || !device) return
if (hasSyncedRef.current) return if (hasSyncedRef.current) return
@@ -926,6 +927,12 @@ export default function GeneralTab({
}).catch(() => { }).catch(() => {
// Device offline or didn't reply — Network Info card stays empty/dash. // 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 // eslint-disable-next-line react-hooks/exhaustive-deps
}, [device?.id, device?.device_id]) }, [device?.id, device?.device_id])
@@ -1015,6 +1022,91 @@ export default function GeneralTab({
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-6)', width: '100%', maxWidth: 2000, margin: '0 auto' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-6)', width: '100%', maxWidth: 2000, margin: '0 auto' }}>
{/* ════════════════════════════════════════════════════════════════════
ROW 0 — Firmware (full width, short)
════════════════════════════════════════════════════════════════════ */}
<GlassCard>
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 'var(--space-6)',
flexWrap: 'wrap',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-3)', minWidth: 0 }}>
<div style={{
width: 36, height: 36, flexShrink: 0,
borderRadius: 'var(--radius-md)',
background: 'var(--color-primary-subtle)',
border: '1px solid rgba(192,193,255,0.20)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<Icon name="firmware" size={17} color="var(--color-primary)" />
</div>
<div style={{ minWidth: 0 }}>
<SectionLabel>Firmware</SectionLabel>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 'var(--space-2)', marginTop: 2 }}>
<span style={{
fontSize: 'var(--font-size-lg)',
fontWeight: 'var(--font-weight-semibold)',
fontFamily: 'var(--font-family-mono)',
color: 'var(--color-text-primary)',
}}>
{firmwareStatus?.current_version ? `v${firmwareStatus.current_version}` : '—'}
</span>
{firmwareStatus?.build_date && (
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>
built {firmwareStatus.build_date}
</span>
)}
</div>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-6)', flexWrap: 'wrap' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<SectionLabel>Channel</SectionLabel>
<StatusBadge variant={firmwareStatus?.ota_channel === 'stable' ? 'success' : firmwareStatus?.ota_channel === 'beta' ? 'warning' : 'neutral'}>
{firmwareStatus?.ota_channel || '—'}
</StatusBadge>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<SectionLabel>Validation</SectionLabel>
<StatusBadge variant={
firmwareStatus?.validation_state === 'VALIDATED' ? 'success' :
firmwareStatus?.is_testing ? 'warning' : 'neutral'
}>
{firmwareStatus?.validation_state || '—'}
</StatusBadge>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<SectionLabel>Boot Count</SectionLabel>
<span style={{ fontSize: 'var(--font-size-base)', fontWeight: 'var(--font-weight-medium)', fontFamily: 'var(--font-family-mono)', color: 'var(--color-text-primary)' }}>
{Number.isFinite(firmwareStatus?.boot_count) ? firmwareStatus.boot_count : '—'}
</span>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<SectionLabel>Backup Slot</SectionLabel>
<span style={{ fontSize: 'var(--font-size-base)', fontWeight: 'var(--font-weight-medium)', fontFamily: 'var(--font-family-mono)', color: 'var(--color-text-primary)' }}>
{firmwareStatus?.backup_version ? `v${firmwareStatus.backup_version}` : '—'}
{firmwareStatus?.backup_version && (
<span style={{ marginLeft: 6, fontSize: 'var(--font-size-xs)', fontFamily: 'var(--font-family-base)', color: firmwareStatus?.backup_is_valid ? 'var(--color-success)' : 'var(--color-danger)' }}>
{firmwareStatus?.backup_is_valid ? 'valid' : 'invalid'}
</span>
)}
</span>
</div>
{firmwareStatus?.is_testing && (
<StatusBadge variant="warning">Testing — awaiting commit</StatusBadge>
)}
</div>
</div>
</GlassCard>
{/* ════════════════════════════════════════════════════════════════════ {/* ════════════════════════════════════════════════════════════════════
ROW 1 — Map (2/3) + Safety Settings (1/3) equal height ROW 1 — Map (2/3) + Safety Settings (1/3) equal height
════════════════════════════════════════════════════════════════════ */} ════════════════════════════════════════════════════════════════════ */}
@@ -512,7 +512,7 @@ export default function OverviewTab({
? <span style={{ fontFamily: 'var(--font-family-mono)', fontSize: 'var(--font-size-base)' }}>{formatUptime(Math.floor(mqttStatus.last_heartbeat.uptime_ms / 1000))}</span> ? <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>} : <span style={{ color: 'var(--color-text-muted)', fontStyle: 'italic', fontWeight: 'var(--font-weight-normal)' }}>Unavailable</span>}
</InfoField> </InfoField>
<InfoField label="Location">{device.device_location || '—'}</InfoField> <InfoField label="Firmware Version" mono>{mqttStatus?.last_heartbeat?.firmware_version || '—'}</InfoField>
<InfoField label="Hardware Revision">{hwRevision || device.device_attributes?.firmwareVersion || '—'}</InfoField> <InfoField label="Hardware Revision">{hwRevision || device.device_attributes?.firmwareVersion || '—'}</InfoField>
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-1)', minWidth: 0 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-1)', minWidth: 0 }}>