mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 22:03:13 -06:00
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:
parent
a0b47000f3
commit
a458c8f96a
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,'"').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
|
||||
|
|
|
|||
|
|
@ -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>`;
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Reference in a new issue