diff --git a/frontend/css/main.css b/frontend/css/main.css index 432724f..7168493 100644 --- a/frontend/css/main.css +++ b/frontend/css/main.css @@ -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; diff --git a/frontend/js/i18n/en.js b/frontend/js/i18n/en.js index ad0be0a..f447dbb 100644 --- a/frontend/js/i18n/en.js +++ b/frontend/js/i18n/en.js @@ -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', diff --git a/frontend/js/utils.js b/frontend/js/utils.js index cee0fb5..2a00f25 100644 --- a/frontend/js/utils.js +++ b/frontend/js/utils.js @@ -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,'&').replace(//g,'>').replace(/"/g,'"').replace(/'/g,'''); } +// 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 diff --git a/frontend/js/views/dashboard.js b/frontend/js/views/dashboard.js index 4725611..4f88a75 100644 --- a/frontend/js/views/dashboard.js +++ b/frontend/js/views/dashboard.js @@ -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) { ` }
- - ${device.status === 'provisioning' ? t('dashboard.awaiting_pairing') : device.status} + ${(() => { const b = livenessBadge(device); return `${esc(b.label)}`; })()}
${device.status === 'provisioning' && device.pairing_code ? `
@@ -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 = `${data.status}`; + if (statusEl) statusEl.innerHTML = `${esc(b.label)}`; }); }; diff --git a/frontend/js/views/device-detail.js b/frontend/js/views/device-detail.js index 4d73522..cf8bb10 100644 --- a/frontend/js/views/device-detail.js +++ b/frontend/js/views/device-detail.js @@ -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) {

${device.name}

- ${device.status} + ${(() => { const b = livenessBadge(device); return `${esc(b.label)}`; })()} ${device.owner_name || device.owner_email ? `${t('device.owner_label', { owner: device.owner_name || device.owner_email })}` : ''}