feat(dashboard): exit-reason display — Offline annotation + tooltip + filter drill-in + list label

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: <optgroup> "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) <noreply@anthropic.com>
This commit is contained in:
ScreenTinker 2026-07-08 15:32:55 -05:00
parent 8ad2258e7c
commit f1fe5d97bd
4 changed files with 68 additions and 10 deletions

View file

@ -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 apps 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 platforms 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',

View file

@ -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'

View file

@ -101,7 +101,7 @@ function renderDeviceCard(device) {
</div>`
}
<div class="device-card-status is-liveness">
${(() => { const b = livenessBadge(device); return `<span class="device-status-badge ${b.state}" data-liveness="${b.state}">${esc(b.label)}</span>`; })()}
${(() => { const b = livenessBadge(device, { short: true }); return `<span class="device-status-badge ${b.state}" data-liveness="${b.state}" data-offline-reason="${esc(b.reason)}"${b.title ? ` title="${esc(b.title)}"` : ''}>${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">
@ -268,11 +268,16 @@ export function render(container) {
<div id="dashStats" class="dash-stats-row" style="display:flex;gap:12px;margin-bottom:16px"></div>
<div style="display:flex;gap:12px;margin-bottom:16px;align-items:center">
<input type="text" id="deviceSearch" class="input" placeholder="${t('dashboard.search')}" style="max-width:300px">
<select id="deviceFilter" class="input" style="width:160px;background:var(--bg-input)">
<select id="deviceFilter" class="input" style="width:180px;background:var(--bg-input)">
<option value="">${t('dashboard.all_status')}</option>
<option value="healthy">${t('device.liveness.healthy')}</option>
<option value="degraded">${t('device.liveness.degraded')}</option>
<option value="offline">${t('device.liveness.offline')}</option>
<optgroup label="${t('dashboard.filter.offline_by_reason')}">
<option value="offline:silent">${t('dashboard.filter.offline_silent')}</option>
<option value="offline:crashed">${t('dashboard.filter.offline_crashed')}</option>
<option value="offline:clean_exit">${t('dashboard.filter.offline_clean')}</option>
</optgroup>
</select>
</div>
<div id="groupedDevices"></div>
@ -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:<reason>
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 = `<span class="device-status-badge ${b.state}" data-liveness="${b.state}">${esc(b.label)}</span>`;
if (statusEl) statusEl.innerHTML = `<span class="device-status-badge ${b.state}" data-liveness="${b.state}" data-offline-reason="${esc(b.reason)}"${b.title ? ` title="${esc(b.title)}"` : ''}>${esc(b.label)}</span>`;
});
};

View file

@ -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) {
<div class="device-header">
<div class="device-header-left">
<h1 id="deviceName">${device.name}</h1>
${(() => { const b = livenessBadge(device); return `<span class="device-status-badge ${b.state}">${esc(b.label)}</span>`; })()}
${(() => { const b = livenessBadge(device); return `<span class="device-status-badge ${b.state}"${b.title ? ` title="${esc(b.title)}"` : ''}>${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">