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:
@@ -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 (
|
||||
<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 ───────────────────────────────────────────────────────────────
|
||||
|
||||
const TABS = [
|
||||
@@ -389,7 +448,7 @@ export default function DeviceDetail() {
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<SignalIndicator rssi={mqttStatus?.last_heartbeat?.rssi} size="md" />
|
||||
<SignalIndicator rssi={mqttStatus?.last_heartbeat?.rssi} isOnline={isOnline} size="md" />
|
||||
</span>
|
||||
<span style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<EditableText
|
||||
@@ -399,7 +458,7 @@ export default function DeviceDetail() {
|
||||
editable={canEdit}
|
||||
inputAriaLabel="Edit console name"
|
||||
/>
|
||||
<span className="page-header-subtitle" style={{ margin: 0 }}>{sn}</span>
|
||||
<SerialWithCopy serial={sn} />
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
|
||||
@@ -551,7 +551,7 @@ export default function DeviceList() {
|
||||
case 'name':
|
||||
return (
|
||||
<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)' }}>
|
||||
{deviceDisplayName(device)}
|
||||
</span>
|
||||
|
||||
@@ -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 (
|
||||
<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 ────────────────────────────────────────────────────── */}
|
||||
<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>
|
||||
</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>
|
||||
|
||||
{/* ── Confirm dialogs ───────────────────────────────────────────────── */}
|
||||
@@ -452,6 +653,39 @@ export default function ControlTab({ device, attr, canEdit, isOnline, sendMqttCo
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<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
|
||||
════════════════════════════════════════════════════════════════════ */}
|
||||
|
||||
@@ -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={{ color: 'var(--color-text-muted)', fontStyle: 'italic', fontWeight: 'var(--font-weight-normal)' }}>Unavailable</span>}
|
||||
</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>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-1)', minWidth: 0 }}>
|
||||
|
||||
Reference in New Issue
Block a user