screentinker/frontend/js/utils.js
ScreenTinker f1fe5d97bd 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>
2026-07-08 15:32:55 -05:00

73 lines
4.3 KiB
JavaScript

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
}
// 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);
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'
// 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
// at every call site. Use everywhere the UI gates on platform-level access.
export function isPlatformAdmin(user) {
return !!(user && (user.role === 'superadmin' || user.role === 'platform_admin'));
}