From f1fe5d97bdfeae0b0c2f5c97308c2b596e1ec45c Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Wed, 8 Jul 2026 15:32:55 -0500 Subject: [PATCH] =?UTF-8?q?feat(dashboard):=20exit-reason=20display=20?= =?UTF-8?q?=E2=80=94=20Offline=20annotation=20+=20tooltip=20+=20filter=20d?= =?UTF-8?q?rill-in=20+=20list=20label?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface the server's manner-of-death (crashed / clean_exit / silent) as a subordinate qualifier ON the Offline badge (not a 4th liveness state), on both the device list and device-detail. Rides livenessBadge. - Reliability-aware label (contract §10): clean_exit reads plainly on /player (reliable), "(best-effort)" on APK/.wgt. silent = "silent (no signal)". - Honest hover tooltip on every reason (both views), incl. silent = "external/violent: power loss, network, force-stop, or MDM/kill". Never fabricates a reason (no-reason -> plain Offline); state-gated (reason only on Offline); clears on re-online (matches the server). - Filter drill-in: "Offline by reason" -> Offline · silent / crashed / clean exit, matched via a data-offline-reason attribute (Offline·silent = the MDM-killed set — the Bold use case). Existing three-state filter (All/Healthy/Reconnecting/Offline) unchanged. - List label shortened to fit the pill (full text stays on detail; tooltip carries the full honesty both). Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/js/i18n/en.js | 16 ++++++++++++++ frontend/js/utils.js | 35 ++++++++++++++++++++++++++++-- frontend/js/views/dashboard.js | 24 ++++++++++++++------ frontend/js/views/device-detail.js | 3 ++- 4 files changed, 68 insertions(+), 10 deletions(-) diff --git a/frontend/js/i18n/en.js b/frontend/js/i18n/en.js index f447dbb..cce3843 100644 --- a/frontend/js/i18n/en.js +++ b/frontend/js/i18n/en.js @@ -268,6 +268,22 @@ export default { 'device.liveness.healthy': 'Healthy', 'device.liveness.degraded': 'Reconnecting', 'device.liveness.offline': 'Offline', + // exit-signal contract — manner-of-death annotation on Offline (reliability-aware, §10) + 'device.exit.crashed': 'crashed', + 'device.exit.clean': 'clean exit', + 'device.exit.clean_besteffort': 'clean exit (best-effort)', + 'device.exit.silent': 'silent (no signal)', + 'device.exit.silent_short': 'silent', + // filter drill-in options (sub-dimension of Offline) + 'dashboard.filter.offline_silent': 'Offline · silent', + 'dashboard.filter.offline_crashed': 'Offline · crashed', + 'dashboard.filter.offline_clean': 'Offline · clean exit', + 'dashboard.filter.offline_by_reason': 'Offline by reason', + // honest hover explanations (carry the contract's reliability) + 'device.exit.crashed.tip': 'The app’s own error handler fired — it crashed before dying (our fault).', + 'device.exit.clean.tip': 'An orderly shutdown signal fired — something closed the app cleanly.', + 'device.exit.clean_besteffort.tip': 'Reported an orderly shutdown, but this platform’s signal is best-effort — likely, not certain.', + 'device.exit.silent.tip': 'No exit signal arrived — external or violent termination: power loss, network drop, force-stop, or MDM/kill. The honest catch-all.', // 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 2a00f25..29dbdf1 100644 --- a/frontend/js/utils.js +++ b/frontend/js/utils.js @@ -27,9 +27,40 @@ export function livenessState(data) { if (st === 'offline') return 'offline'; return 'offline'; // unknown / no data yet -> safe default, never blank } -export function livenessBadge(data) { +// Exit-signal contract §8/§10 — honest, reliability-aware manner-of-death sub-label for an Offline +// device. clean_exit is RELIABLE only on the browser /player (pagehide+sendBeacon); best-effort on +// APK/.wgt, so we qualify it there rather than overstate certainty. crashed/silent are labeled plainly. +// Returns null when no reason is known (old data / never went offline) -> plain "Offline". +// short=true -> concise LIST label (drops the parenthetical qualifiers, which the tooltip still carries); +// full (default) -> DETAIL label with the reliability qualifier. Honesty is preserved either way: the +// full meaning lives in the tooltip (both views) and in the detail label. +function offlineReasonLabel(reason, clientType, short) { + if (reason === 'crashed') return t('device.exit.crashed'); + if (reason === 'clean_exit') { + if (short) return t('device.exit.clean'); // list: "clean exit" (tooltip carries best-effort) + return clientType === 'player' ? t('device.exit.clean') : t('device.exit.clean_besteffort'); + } + if (reason === 'silent') return short ? t('device.exit.silent_short') : t('device.exit.silent'); + return null; +} +// Honest hover explanation of the manner of death — carries the contract's reliability (esp. 'silent' +// = external/violent, and best-effort clean_exit) so an operator isn't misled by a terse badge label. +function offlineReasonTip(reason, clientType) { + if (reason === 'crashed') return t('device.exit.crashed.tip'); + if (reason === 'clean_exit') return clientType === 'player' ? t('device.exit.clean.tip') : t('device.exit.clean_besteffort.tip'); + if (reason === 'silent') return t('device.exit.silent.tip'); + return ''; +} +export function livenessBadge(data, opts = {}) { const state = livenessState(data); - return { state, label: t(LIVENESS_LABEL_KEY[state]) }; + let label = t(LIVENESS_LABEL_KEY[state]); + let title = '', reason = ''; + if (state === 'offline') { // annotate Offline with the manner of death, if known + const r = data && data.offline_reason, ct = data && data.client_type; + const sub = offlineReasonLabel(r, ct, opts.short); + if (sub) { label += ' · ' + sub; title = offlineReasonTip(r, ct); reason = r || ''; } + } + return { state, label, title, reason }; // reason -> data-offline-reason (filter drill-in); '' unless offline+known } // Phase 2.1: the Phase 1 schema migration renamed the legacy 'superadmin' diff --git a/frontend/js/views/dashboard.js b/frontend/js/views/dashboard.js index 8f811eb..596c3bc 100644 --- a/frontend/js/views/dashboard.js +++ b/frontend/js/views/dashboard.js @@ -101,7 +101,7 @@ function renderDeviceCard(device) { ` }
- ${(() => { const b = livenessBadge(device); return `${esc(b.label)}`; })()} + ${(() => { const b = livenessBadge(device, { short: true }); return `${esc(b.label)}`; })()}
${device.status === 'provisioning' && device.pairing_code ? `
@@ -268,11 +268,16 @@ export function render(container) {
- + + + + +
@@ -295,12 +300,17 @@ export function render(container) { // Compare against the liveness STATE ('healthy'|'degraded'|'offline'), NOT the display label: // the badge text is now "Healthy"/"Reconnecting"/"Offline", so the old text-vs-'online' compare // matched nothing and emptied the list. data-liveness carries the state for a robust match. - const state = document.getElementById('deviceFilter').value; + const filter = document.getElementById('deviceFilter').value; // '' | healthy | degraded | offline | offline: + const reasonDrill = filter.startsWith('offline:') ? filter.slice(8) : null; // drill into a manner-of-death document.querySelectorAll('.device-card').forEach(card => { const name = card.querySelector('.device-card-name')?.textContent.toLowerCase() || ''; - const cardState = card.querySelector('.device-card-status [data-liveness]')?.dataset.liveness || ''; + const el = card.querySelector('.device-card-status [data-liveness]'); + const cardState = el?.dataset.liveness || ''; + const cardReason = el?.dataset.offlineReason || ''; const matchSearch = !search || name.includes(search); - const matchState = !state || cardState === state; + const matchState = reasonDrill + ? (cardState === 'offline' && cardReason === reasonDrill) // Offline drill-in: liveness AND reason (e.g. silent = MDM-killed set) + : (!filter || cardState === filter); // existing three-state filter — unchanged card.style.display = (matchSearch && matchState) ? '' : 'none'; }); } @@ -362,11 +372,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 b = livenessBadge(data, { short: true }); // list = concise label; tooltip carries the full text const cards = document.querySelectorAll(`[data-device-id="${data.device_id}"]`); cards.forEach(card => { const statusEl = card.querySelector('.device-card-status'); - if (statusEl) statusEl.innerHTML = `${esc(b.label)}`; + if (statusEl) statusEl.innerHTML = `${esc(b.label)}`; }); }; diff --git a/frontend/js/views/device-detail.js b/frontend/js/views/device-detail.js index cf8bb10..a505c4a 100644 --- a/frontend/js/views/device-detail.js +++ b/frontend/js/views/device-detail.js @@ -71,6 +71,7 @@ export function render(container, deviceId) { const b = livenessBadge(data); // v4: 3-state liveness when present, else binary status badge.className = `device-status-badge ${b.state}`; badge.textContent = b.label; + badge.title = b.title || ''; // exit-reason hover (empty for non-offline / no-reason) } if (data.telemetry) updateTelemetryDisplay(data.telemetry); }; @@ -150,7 +151,7 @@ async function loadDevice(deviceId, activeTab = null) {

${device.name}

- ${(() => { const b = livenessBadge(device); return `${esc(b.label)}`; })()} + ${(() => { 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 })}` : ''}