From 0c0dd9d0e9bd3113c50583f7eca4600724c925c0 Mon Sep 17 00:00:00 2001 From: bonamin Date: Tue, 22 Sep 2026 12:45:01 +0300 Subject: [PATCH] fix(devices): signal glyph polish, fix invisible copy button, even firmware row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up on 529e866 after review: - SignalIndicator: offline now renders a diagonal slash (standard "no signal" convention) instead of an X. Added a distinct amber "?" state for devices that are online but whose firmware never reports rssi at all (legacy v1 heartbeats predate the rssi/state/ok fields — see API Reference's v2 migration notes) — previously this looked identical to "not loaded yet". DeviceListCardView's online block now opts into this by passing isOnline explicitly. - Copy-to-clipboard for the serial number was invisible: the icon button had opacity:0 as an inline style, which beats a CSS hover rule at equal specificity, so the reveal-on-hover code paths never fired. Replaced with a shared CopyableSerial component (components/shared/) where the whole serial text is clickable, not just a trailing icon, and the icon sits at partial opacity at rest instead of fully hidden. Wired into both DeviceDetail's header and OverviewTab's hero Serial Number field. - GeneralTab firmware hero: all stat columns are now equal-width via a grid instead of ad-hoc flex gaps, and backup_version's "Unknown" sentinel (the device's own placeholder when no second OTA slot has ever been flashed) is no longer rendered as the literal string "vUnknown". Co-Authored-By: Claude Sonnet 5 --- .../src/components/shared/CopyableSerial.jsx | 71 +++++++ .../src/components/ui/SignalIndicator.jsx | 57 ++++-- .../pages/bellcloud/devices/DeviceDetail.jsx | 65 +------ .../bellcloud/devices/DeviceListCardView.jsx | 2 +- .../bellcloud/devices/tabs/GeneralTab.jsx | 177 ++++++++++-------- .../bellcloud/devices/tabs/OverviewTab.jsx | 8 +- 6 files changed, 228 insertions(+), 152 deletions(-) create mode 100644 frontend/src/components/shared/CopyableSerial.jsx diff --git a/frontend/src/components/shared/CopyableSerial.jsx b/frontend/src/components/shared/CopyableSerial.jsx new file mode 100644 index 0000000..d6a4d84 --- /dev/null +++ b/frontend/src/components/shared/CopyableSerial.jsx @@ -0,0 +1,71 @@ +// frontend/src/components/shared/CopyableSerial.jsx +// Serial number display with click-to-copy — the whole text is clickable, +// not just the trailing icon, and the icon stays dimly visible at rest so +// its affordance isn't hover-only-discoverable (it was previously opacity:0 +// at rest via an inline style, which beat the hover CSS rule and made the +// button invisible at all times). + +import { useState } from 'react' +import Icon from '@/components/ui/Icon' + +function copyText(text, onDone) { + if (navigator.clipboard) { + navigator.clipboard.writeText(text).then(onDone).catch(() => { + const ta = document.createElement('textarea') + ta.value = text; ta.style.cssText = 'position:fixed;opacity:0' + document.body.appendChild(ta); ta.select() + try { document.execCommand('copy'); onDone() } catch { /* clipboard unavailable */ } + document.body.removeChild(ta) + }) + } +} + +export default function CopyableSerial({ serial, className = '', style = {}, textStyle = {}, iconSize = 12 }) { + const [copied, setCopied] = useState(false) + + if (!serial) return — + + const handleCopy = () => { + copyText(serial, () => { setCopied(true); setTimeout(() => setCopied(false), 1500) }) + } + + return ( + + ) +} diff --git a/frontend/src/components/ui/SignalIndicator.jsx b/frontend/src/components/ui/SignalIndicator.jsx index e31ae11..4227046 100644 --- a/frontend/src/components/ui/SignalIndicator.jsx +++ b/frontend/src/components/ui/SignalIndicator.jsx @@ -5,8 +5,13 @@ // 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". +// glyph with a diagonal slash overlay regardless of rssi, so a stale +// last-known reading from an offline device never reads as a live +// "full signal". When true (or undefined) but rssi is null/undefined, +// renders an amber "?" instead — legacy v1-firmware devices report a +// heartbeat with no rssi field at all (see API Reference: legacy +// heartbeats predate rssi/state/ok), so "online with unknown signal" +// is a distinct, expected state from "we haven't loaded it yet". // size — 'sm' | 'md' (default: 'md') // className — extra classes // @@ -42,14 +47,19 @@ const DIM_COLOR = 'var(--color-bg-island)' export default function SignalIndicator({ rssi, isOnline, size = 'md', className = '' }) { const offline = isOnline === false const known = !offline && rssi != null && Number.isFinite(rssi) + // Online, but the device firmware never reports rssi at all (legacy v1 API). + const unsupported = !offline && !known && isOnline === true + const muted = offline || unsupported const level = known ? rssiToLevel(rssi) : 0 - const color = offline ? 'var(--color-warning)' : rssiToColor(known ? rssi : null) + const color = muted ? 'var(--color-warning)' : rssiToColor(known ? rssi : null) const { box, strokeWidth } = SIZES[size] || SIZES.md const label = offline ? 'Signal strength unavailable — device offline' - : known - ? `Signal strength: ${['weak', 'fair', 'good', 'excellent'][level - 1]} (${rssi} dBm)` - : 'Signal strength unknown' + : unsupported + ? 'Signal strength not reported by this device\'s firmware' + : 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={muted ? DIM_COLOR : (level >= 2 ? color : DIM_COLOR)} strokeWidth={strokeWidth} strokeLinecap="round" style={{ transition: 'stroke 0.2s' }} @@ -77,7 +87,7 @@ export default function SignalIndicator({ rssi, isOnline, size = 'md', className = 3 ? color : DIM_COLOR)} + stroke={muted ? DIM_COLOR : (level >= 3 ? color : DIM_COLOR)} strokeWidth={strokeWidth} strokeLinecap="round" style={{ transition: 'stroke 0.2s' }} @@ -86,17 +96,34 @@ export default function SignalIndicator({ rssi, isOnline, size = 'md', className = 4 ? color : DIM_COLOR)} + stroke={muted ? 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 overlay — amber diagonal slash, standard "no signal" convention */} {offline && ( - - - - + + )} + {/* Unsupported overlay — amber "?", device is online but its firmware never reports rssi (v1 API) */} + {unsupported && ( + + ? + )} ) diff --git a/frontend/src/pages/bellcloud/devices/DeviceDetail.jsx b/frontend/src/pages/bellcloud/devices/DeviceDetail.jsx index b74b1b9..920361f 100644 --- a/frontend/src/pages/bellcloud/devices/DeviceDetail.jsx +++ b/frontend/src/pages/bellcloud/devices/DeviceDetail.jsx @@ -12,6 +12,7 @@ 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 CopyableSerial from '@/components/shared/CopyableSerial' import Tabs from '@/components/ui/Tabs' import Spinner from '@/components/ui/Spinner' import ConfirmDialog from '@/components/ui/ConfirmDialog' @@ -36,64 +37,6 @@ 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 = [ @@ -458,7 +401,11 @@ export default function DeviceDetail() { editable={canEdit} inputAriaLabel="Edit console name" /> - + } diff --git a/frontend/src/pages/bellcloud/devices/DeviceListCardView.jsx b/frontend/src/pages/bellcloud/devices/DeviceListCardView.jsx index d14cd14..dd4e78c 100644 --- a/frontend/src/pages/bellcloud/devices/DeviceListCardView.jsx +++ b/frontend/src/pages/bellcloud/devices/DeviceListCardView.jsx @@ -202,7 +202,7 @@ function OnlineBlock({ online, rssi }) { aria-label={online ? 'Online' : 'Offline'} > {online ? ( - + ) : (