feat(dashboard): 3-state liveness badge (consume the patch4 server signal)

The patch4 server derives 3-state liveness (healthy / degraded-reconnecting / offline) and emits it as
data.liveness on dashboard:device-status, but the frontend only consumed binary online/offline — the
signal was thrown away. Add a shared livenessBadge() helper (utils.js) consumed by both the dashboard
device list and the device-detail view (initial render + live statusHandler). Degrades to the binary
status when liveness is absent (old payload / plain reconnect+disconnect emits / DB device object) so
nothing renders blank; unknown/no-data -> offline default. CSS: healthy=green, degraded=amber+pulse
(reads as reconnecting), offline=red — reusing the existing --success/--warning/--danger tokens. Labels
in en.js (all locales fall back to en). Frontend-only; server derivation unchanged. 13/13 unit tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
ScreenTinker 2026-07-08 13:22:15 -05:00
parent a0b47000f3
commit a458c8f96a
5 changed files with 47 additions and 8 deletions

View file

@ -263,6 +263,9 @@ body {
.status-dot.online { background: var(--success); box-shadow: 0 0 6px var(--success); }
.status-dot.offline { background: var(--danger); }
.status-dot.provisioning { background: var(--warning); animation: pulse 2s infinite; }
/* v4 liveness (server-derived): healthy=green, degraded=amber+pulse (reconnecting), offline=red (above) */
.status-dot.healthy { background: var(--success); box-shadow: 0 0 6px var(--success); }
.status-dot.degraded { background: var(--warning); box-shadow: 0 0 6px var(--warning); animation: pulse 2s infinite; }
@keyframes pulse {
0%, 100% { opacity: 1; }
@ -803,6 +806,9 @@ body {
.device-status-badge.online { background: var(--success-dim); color: var(--success); }
.device-status-badge.offline { background: var(--danger-dim); color: #fca5a5; }
.device-status-badge.provisioning { background: var(--warning-dim); color: var(--warning); }
/* v4 liveness (server-derived): healthy=green, degraded=amber (reconnecting), offline reuses .offline above */
.device-status-badge.healthy { background: var(--success-dim); color: var(--success); }
.device-status-badge.degraded { background: var(--warning-dim); color: var(--warning); }
.tabs {
display: flex;

View file

@ -264,6 +264,10 @@ export default {
'device.playlist.empty_desc': "Add content from your library to this display's playlist.",
'device.playlist_picker.with_count': '{name} — {n} items',
'device.playlist_picker.with_auto': '{name} (auto) — {n} items',
// v4 liveness badge (server-derived 3-state)
'device.liveness.healthy': 'Healthy',
'device.liveness.degraded': 'Reconnecting',
'device.liveness.offline': 'Offline',
// Info cards
'device.info.status': 'Status',
'device.info.ip_address': 'IP Address',

View file

@ -1,9 +1,37 @@
import { t } from './i18n.js';
// HTML escape helper — prevents XSS when inserting user data into innerHTML
export function esc(str) {
if (str == null) return '';
return String(str).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
}
// v4 liveness badge. The patch4 server derives a 3-state liveness — 'healthy' / 'degraded'
// (temporarily reconnecting) / 'offline' — and emits it as `data.liveness` on dashboard:device-status.
// It is present on SOME emits only (the plain reconnect + disconnect emits, and any device object read
// from the DB, carry just the binary `status`), so we DEGRADE to the binary status when liveness is
// absent — nothing ever renders blank. 'provisioning' is a lifecycle state (never-paired), kept
// distinct from liveness. livenessState() is pure (unit-testable); livenessBadge() adds the i18n label.
const LIVENESS_LABEL_KEY = {
healthy: 'device.liveness.healthy',
degraded: 'device.liveness.degraded',
offline: 'device.liveness.offline',
provisioning: 'dashboard.awaiting_pairing',
};
export function livenessState(data) {
const lv = data && data.liveness;
if (lv === 'healthy' || lv === 'degraded' || lv === 'offline') return lv; // 3-state signal present
const st = data && data.status; // backward-compat: derive from binary status
if (st === 'provisioning') return 'provisioning';
if (st === 'online') return 'healthy';
if (st === 'offline') return 'offline';
return 'offline'; // unknown / no data yet -> safe default, never blank
}
export function livenessBadge(data) {
const state = livenessState(data);
return { state, label: t(LIVENESS_LABEL_KEY[state]) };
}
// Phase 2.1: the Phase 1 schema migration renamed the legacy 'superadmin'
// role to 'platform_admin'. Existing frontend checks still match the old
// string; this helper accepts both so we don't have to splatter the array

View file

@ -1,7 +1,7 @@
import { api } from '../api.js';
import { on, off, requestScreenshot } from '../socket.js';
import { showToast } from '../components/toast.js';
import { esc } from '../utils.js';
import { esc, livenessBadge } from '../utils.js';
import { t, tn } from '../i18n.js';
const DESTRUCTIVE_COMMANDS = ['reboot', 'shutdown'];
@ -101,8 +101,7 @@ function renderDeviceCard(device) {
</div>`
}
<div class="device-card-status">
<span class="status-dot ${device.status}"></span>
<span>${device.status === 'provisioning' ? t('dashboard.awaiting_pairing') : device.status}</span>
${(() => { const b = livenessBadge(device); return `<span class="status-dot ${b.state}"></span><span>${esc(b.label)}</span>`; })()}
</div>
${device.status === 'provisioning' && device.pairing_code ? `
<div style="position:absolute;bottom:8px;left:50%;transform:translateX(-50%);background:rgba(0,0,0,0.85);color:#f59e0b;padding:4px 12px;border-radius:6px;font-size:13px;font-weight:600;letter-spacing:2px;font-family:monospace">
@ -359,10 +358,11 @@ export function render(container) {
// Real-time updates
statusHandler = (data) => {
const b = livenessBadge(data); // v4: prefer data.liveness (3-state), fall back to binary status
const cards = document.querySelectorAll(`[data-device-id="${data.device_id}"]`);
cards.forEach(card => {
const statusEl = card.querySelector('.device-card-status');
if (statusEl) statusEl.innerHTML = `<span class="status-dot ${data.status}"></span><span>${data.status}</span>`;
if (statusEl) statusEl.innerHTML = `<span class="status-dot ${b.state}"></span><span>${esc(b.label)}</span>`;
});
};

View file

@ -1,7 +1,7 @@
import { api } from '../api.js';
import { on, off, requestScreenshot, startRemote, stopRemote, sendTouch, sendKey, sendCommand } from '../socket.js';
import { showToast } from '../components/toast.js';
import { esc } from '../utils.js';
import { esc, livenessBadge } from '../utils.js';
import { t, tn } from '../i18n.js';
let currentDevice = null;
@ -68,8 +68,9 @@ export function render(container, deviceId) {
if (data.device_id !== deviceId) return;
const badge = document.querySelector('.device-status-badge');
if (badge) {
badge.className = `device-status-badge ${data.status}`;
badge.textContent = data.status;
const b = livenessBadge(data); // v4: 3-state liveness when present, else binary status
badge.className = `device-status-badge ${b.state}`;
badge.textContent = b.label;
}
if (data.telemetry) updateTelemetryDisplay(data.telemetry);
};
@ -149,7 +150,7 @@ async function loadDevice(deviceId, activeTab = null) {
<div class="device-header">
<div class="device-header-left">
<h1 id="deviceName">${device.name}</h1>
<span class="device-status-badge ${device.status}">${device.status}</span>
${(() => { const b = livenessBadge(device); return `<span class="device-status-badge ${b.state}">${esc(b.label)}</span>`; })()}
${device.owner_name || device.owner_email ? `<span style="font-size:12px;color:var(--text-muted)">${t('device.owner_label', { owner: device.owner_name || device.owner_email })}</span>` : ''}
</div>
<div style="display:flex;gap:8px">