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:
@@ -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,12 +39,15 @@ 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 unavailable — device offline'
|
||||||
|
: known
|
||||||
? `Signal strength: ${['weak', 'fair', 'good', 'excellent'][level - 1]} (${rssi} dBm)`
|
? `Signal strength: ${['weak', 'fair', 'good', 'excellent'][level - 1]} (${rssi} dBm)`
|
||||||
: 'Signal strength unknown'
|
: 'Signal strength unknown'
|
||||||
|
|
||||||
@@ -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 }}>
|
||||||
|
|||||||
Reference in New Issue
Block a user