@@ -661,6 +667,10 @@ async function loadDevice(deviceId, activeTab = null) {
// Render uptime timeline
renderUptimeTimeline(device.uptimeData || [], device.statusLog || []);
+ // Render the Recent incidents panel (merges typed device_events with
+ // offline→online transitions derived from the status log).
+ renderIncidents(device.deviceEvents || [], device.statusLog || []);
+
setupTabs();
setupActions(device);
setupRemote(device);
@@ -1659,6 +1669,9 @@ function renderUptimeTimeline(uptimeData, statusLog = []) {
// Build slot status: 'online', 'offline', or 'unknown'
const slotStatus = new Array(slots).fill('unknown');
+ // Parallel array: for offline slots, the {reason, detail} of the covering offline event
+ // (why the device was offline) — surfaced in the slot's hover title.
+ const slotReason = new Array(slots).fill(null);
// First pass: mark slots that have heartbeat telemetry as online
for (const ts of uptimeData) {
@@ -1677,8 +1690,12 @@ function renderUptimeTimeline(uptimeData, statusLog = []) {
: (event.status === 'online' ? slots - 1 : startSlot);
const isOnline = event.status === 'online';
+ const reason = isOnline ? null : { reason: event.reason || null, detail: event.detail || null };
for (let s = startSlot; s <= endSlot && s < slots; s++) {
- if (s >= 0) slotStatus[s] = isOnline ? 'online' : 'offline';
+ if (s >= 0) {
+ slotStatus[s] = isOnline ? 'online' : 'offline';
+ slotReason[s] = reason;
+ }
}
}
@@ -1709,7 +1726,127 @@ function renderUptimeTimeline(uptimeData, statusLog = []) {
const time = new Date((dayAgo + i * slotDuration) * 1000);
const label = time.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
const statusLabel = status === 'unknown' ? t('device.timeline.no_data') : status === 'online' ? t('device.timeline.online') : t('device.timeline.offline');
- return `
`;
+ let title = `${label} - ${statusLabel}`;
+ if (status === 'offline' && slotReason[i]) {
+ const r = slotReason[i];
+ title = `${label} – ${statusLabel} · ${eventLabel(r.reason)}${r.detail ? ` (${r.detail})` : ''}`;
+ }
+ return `
`;
+ }).join('');
+}
+
+// Map an event/reason token to a friendly label via i18n, falling back to the raw
+// token if no translation exists. Null → "Unknown cause".
+function eventLabel(key) {
+ if (!key) return t('device.event.silent');
+ const full = t('device.event.' + key);
+ return full === ('device.event.' + key) ? key : full;
+}
+
+// Dot color by incident type. Amber (#f59e0b) matches the warning accent used
+// elsewhere in this view; the rest use the shared CSS vars.
+function incidentColor(type) {
+ if (type === 'online' || type === 'display_on') return 'var(--success)';
+ if (type === 'display_off') return 'var(--text-muted)';
+ if (type === 'reboot') return '#f59e0b';
+ return 'var(--danger)'; // offline, network, crash, app_error
+}
+
+// Compact duration ("4m", "1h 5m", "2d 3h") for an offline period.
+function formatDur(seconds) {
+ seconds = Math.max(0, Math.floor(seconds));
+ if (seconds < 60) return seconds + 's';
+ const m = Math.floor(seconds / 60);
+ if (m < 60) return m + 'm';
+ const h = Math.floor(m / 60);
+ const rm = m % 60;
+ if (h < 24) return rm ? `${h}h ${rm}m` : `${h}h`;
+ const d = Math.floor(h / 24);
+ return `${d}d ${h % 24}h`;
+}
+
+// Compact relative time ("2h ago").
+function relTime(tsSec, nowSec = Math.floor(Date.now() / 1000)) {
+ const diff = Math.max(0, nowSec - tsSec);
+ if (diff < 60) return diff + 's ago';
+ const m = Math.floor(diff / 60);
+ if (m < 60) return m + 'm ago';
+ const h = Math.floor(m / 60);
+ if (h < 24) return h + 'h ago';
+ const d = Math.floor(h / 24);
+ return d + 'd ago';
+}
+
+// "Recent incidents" panel: a newest-first, time-sorted merge of typed device_events
+// (display sleep, crash, reboot, network, app_error) with offline→online periods
+// derived from the status log (so a device with only server-side offline data still
+// shows incidents, and downtime carries a duration).
+function renderIncidents(deviceEvents = [], statusLog = []) {
+ const panel = document.getElementById('incidentsPanel');
+ if (!panel) return;
+
+ const nowSec = Math.floor(Date.now() / 1000);
+ const incidents = [];
+
+ // Offline periods from the status log (server-side ground truth). Start a period
+ // on each offline transition and close it at the next 'online' row.
+ const log = (statusLog || []).slice().sort((a, b) => a.timestamp - b.timestamp);
+ for (let i = 0; i < log.length; i++) {
+ const ev = log[i];
+ if (ev.status === 'online') continue;
+ // Collapse a repeated offline row (e.g. offline followed by offline_timeout).
+ if (i > 0 && log[i - 1].status !== 'online') continue;
+ let end = null;
+ for (let j = i + 1; j < log.length; j++) {
+ if (log[j].status === 'online') { end = log[j].timestamp; break; }
+ }
+ incidents.push({
+ type: 'offline',
+ reason: ev.reason || null,
+ detail: ev.detail || null,
+ timestamp: ev.timestamp,
+ durationSec: (end != null ? end : nowSec) - ev.timestamp,
+ ongoing: end == null,
+ });
+ }
+
+ // Typed incidents from device_events. offline/online are already represented as
+ // periods above, so skip them here to avoid double-listing the same event.
+ for (const ev of (deviceEvents || [])) {
+ if (!ev || ev.type === 'offline' || ev.type === 'online') continue;
+ incidents.push({
+ type: ev.type,
+ reason: ev.reason || null,
+ detail: ev.detail || null,
+ timestamp: ev.timestamp,
+ });
+ }
+
+ if (!incidents.length) {
+ panel.innerHTML = `
${t('device.incidents.none')}
`;
+ return;
+ }
+
+ incidents.sort((a, b) => b.timestamp - a.timestamp);
+
+ panel.innerHTML = incidents.slice(0, 15).map(inc => {
+ const label = eventLabel(inc.reason || inc.type);
+ const detail = inc.detail
+ ? `
${esc(inc.detail)}`
+ : '';
+ const dur = (inc.durationSec != null)
+ ? `
${esc(t('device.incidents.down_for', { dur: formatDur(inc.durationSec) }) + (inc.ongoing ? '…' : ''))}`
+ : '';
+ return `
+
+
+ ${esc(label)}
+ ${detail}
+
+ ${dur}
+ ${esc(relTime(inc.timestamp, nowSec))}
+
+
`;
}).join('');
}
diff --git a/server/db/database.js b/server/db/database.js
index a84c8a3..10825c2 100644
--- a/server/db/database.js
+++ b/server/db/database.js
@@ -101,6 +101,25 @@ const migrations = [
'ALTER TABLE devices ADD COLUMN offline_reason TEXT',
'ALTER TABLE devices ADD COLUMN offline_reason_at INTEGER',
'ALTER TABLE devices ADD COLUMN offline_detail TEXT',
+ // Offline-cause log: annotate each historical offline transition with WHY. `reason` = category
+ // (transport_close / ping_timeout / heartbeat_timeout / network / crashed / clean_exit / silent);
+ // `detail` = human specifics (e.g. "Wi-Fi link lost — SSID Office, -78dBm" or "LAN up, server
+ // unreachable (router/upstream)"). NULL on online rows / pre-migration.
+ 'ALTER TABLE device_status_log ADD COLUMN reason TEXT',
+ 'ALTER TABLE device_status_log ADD COLUMN detail TEXT',
+ // Unified device-incident log (offline-cause + display/sleep + crash + reboot). Complements
+ // device_status_log (which drives the uptime timeline): this is the human-facing "what happened
+ // and why" feed. type: offline|online|display_off|display_on|crash|reboot|network. reason =
+ // category token; detail = human specifics (Wi-Fi/router/SSID/RSSI/IP, crash msg, sleep source).
+ `CREATE TABLE IF NOT EXISTS device_events (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ device_id TEXT NOT NULL,
+ type TEXT NOT NULL,
+ reason TEXT,
+ detail TEXT,
+ timestamp INTEGER NOT NULL DEFAULT (strftime('%s','now'))
+ )`,
+ 'CREATE INDEX IF NOT EXISTS idx_device_events_device_time ON device_events(device_id, timestamp)',
// Email settings on users
"ALTER TABLE users ADD COLUMN email_alerts INTEGER DEFAULT 1",
// Content folders
diff --git a/server/lib/incident-classify.js b/server/lib/incident-classify.js
new file mode 100644
index 0000000..b3dda4a
--- /dev/null
+++ b/server/lib/incident-classify.js
@@ -0,0 +1,77 @@
+'use strict';
+
+// Offline-cause / incident classification — pure helpers shared by deviceSocket.js
+// (the live path) and the unit tests. No DB, no socket, no side effects: given a
+// device-reported connectivity snapshot (or a raw socket.io disconnect reason),
+// return the canonical {reason, detail, type} the offline-cause log records.
+//
+// Keeping this here (a) makes the classification rules testable without spinning a
+// socket server, and (b) guarantees the live handler and the tests agree on the exact
+// strings (glyphs included) by construction.
+
+// device_events.type allowed set (the unified incident feed). Anything outside this is
+// dropped by the device:event handler so a forged/typo'd type can't pollute the feed.
+const ALLOWED_EVENT_TYPES = new Set([
+ 'offline', 'online', 'display_off', 'display_on', 'crash', 'reboot', 'network', 'app_error',
+]);
+
+function isAllowedEventType(type) {
+ return typeof type === 'string' && ALLOWED_EVENT_TYPES.has(type);
+}
+
+// Normalize a socket.io disconnect reason (transport close / ping timeout / transport
+// error / etc.) into a category token, falling back to 'silent' when none was supplied.
+// Mirrors the contract: String(reason||'').trim().replace(/\s+/g,'_').toLowerCase().
+function normalizeDisconnectReason(reason) {
+ const norm = String(reason == null ? '' : reason).trim().replace(/\s+/g, '_').toLowerCase();
+ return norm || 'silent';
+}
+
+// Compose reason + detail (and the device_events type) from a device connectivity report
+// sent on reconnect after an in-process disconnect. The app SURVIVED the gap, so absent a
+// cold_start it was NOT a reboot. Rules are the offline-cause contract's:
+// cold_start === true -> reboot, "Device restarted (power/reboot)"
+// else link_lost === true -> network, "Wi‑Fi/Ethernet link lost"
+// else link up — split by the device's internet probe (8.8.8.8/1.1.1.1) during the gap, which
+// pinpoints blame between the customer's internet and OUR server:
+// internet_ok === true -> server_down, "Internet reachable — our server was unreachable"
+// internet_ok === false -> no_internet, "No internet — router/ISP down"
+// internet_ok absent (no probe)-> network, "Local network up but server unreachable (router/upstream)"
+// then append, when present: SSID, weak-signal (rssi < -75), IP-changed detail fragments.
+function classifyConnectivity(report) {
+ const r = report || {};
+ let reason;
+ let detail;
+ if (r.cold_start === true) {
+ reason = 'reboot';
+ detail = 'Device restarted (power/reboot)';
+ } else if (r.link_lost === true) {
+ reason = 'network';
+ detail = 'Wi‑Fi/Ethernet link lost';
+ } else if (r.internet_ok === true) {
+ // Link up AND the wider internet was reachable, but WE weren't -> our server/hosting, not the site.
+ reason = 'server_down';
+ detail = 'Internet reachable but the ScreenTinker server was unreachable (server/hosting issue)';
+ } else if (r.internet_ok === false) {
+ reason = 'no_internet';
+ detail = 'No internet — router/ISP down (device link up, public hosts unreachable)';
+ } else {
+ reason = 'network';
+ detail = 'Local network up but server unreachable (router/internet/upstream)';
+ }
+
+ if (r.ssid) detail += ` · SSID "${String(r.ssid)}"`;
+ if (typeof r.rssi === 'number' && r.rssi < -75) detail += ` · weak signal (${r.rssi} dBm)`;
+ if (r.ip_changed) detail += ' · IP changed (DHCP/router)';
+
+ // device_events.type for this incident: a reboot is its own type, everything else is 'network'.
+ const type = reason === 'reboot' ? 'reboot' : 'network';
+ return { reason, detail, type };
+}
+
+module.exports = {
+ ALLOWED_EVENT_TYPES,
+ isAllowedEventType,
+ normalizeDisconnectReason,
+ classifyConnectivity,
+};
diff --git a/server/lib/status-log-writer.js b/server/lib/status-log-writer.js
index 8580bff..29281de 100644
--- a/server/lib/status-log-writer.js
+++ b/server/lib/status-log-writer.js
@@ -23,16 +23,17 @@ const pending = new Map(); // deviceId -> latest desired status (net state)
const lastWritten = new Map(); // deviceId -> last status actually inserted
let timer = null;
-const insertStmt = () => db.prepare('INSERT INTO device_status_log (device_id, status) VALUES (?, ?)');
+const insertStmt = () => db.prepare('INSERT INTO device_status_log (device_id, status, reason, detail) VALUES (?, ?, ?, ?)');
// Per-device age prune — the #146 fix for the old hardcoded 7-day window in
// deviceSocket.js (now a single source of truth: config.statusLogRetentionDays).
const pruneDeviceStmt = () =>
db.prepare("DELETE FROM device_status_log WHERE device_id = ? AND timestamp < strftime('%s','now') - ?");
// Record a transition. Cheap and allocation-light: just remembers the latest state.
-function record(deviceId, status) {
+// reason/detail (optional) annotate WHY an offline transition happened (offline-cause log).
+function record(deviceId, status, reason, detail) {
if (!deviceId || !status) return;
- pending.set(deviceId, status);
+ pending.set(deviceId, { status: status, reason: reason || null, detail: detail || null });
}
// Write all buffered transitions whose net state differs from what's on disk.
@@ -40,8 +41,8 @@ function record(deviceId, status) {
function flush() {
if (pending.size === 0) return 0;
const batch = [];
- for (const [deviceId, status] of pending) {
- if (lastWritten.get(deviceId) !== status) batch.push([deviceId, status]);
+ for (const [deviceId, val] of pending) {
+ if (lastWritten.get(deviceId) !== val.status) batch.push([deviceId, val.status, val.reason, val.detail]);
}
pending.clear();
if (batch.length === 0) return 0;
@@ -51,8 +52,8 @@ function flush() {
const prune = pruneDeviceStmt();
const ageSec = Math.round(config.statusLogRetentionDays * 86400);
const writeAll = db.transaction((rows) => {
- for (const [deviceId, status] of rows) {
- ins.run(deviceId, status);
+ for (const [deviceId, status, reason, detail] of rows) {
+ ins.run(deviceId, status, reason || null, detail || null);
lastWritten.set(deviceId, status);
prune.run(deviceId, ageSec);
}
diff --git a/server/player/index.html b/server/player/index.html
index 5ac3abb..8f77fa1 100644
--- a/server/player/index.html
+++ b/server/player/index.html
@@ -343,6 +343,22 @@
// ==================== State ====================
let socket = null;
let config = getConfig();
+ // feat/offline-cause-log: connectivity-report state — track in-session disconnects so the next
+ // reconnect can report WHY it was gone (local link lost vs server/upstream unreachable). A browser
+ // can't see SSID/RSSI, so we send only offline_ms + link_lost + cold_start:false.
+ let disconnectedAt = 0; // Date.now() at the first disconnect of the current gap (0 = not in a gap)
+ let linkLostDuringGap = false; // navigator went offline at any point during the gap
+ // feat/offline-cause-log: typed incident feed (device:event) — server inserts a device_events row.
+ // Best-effort + auth-guarded (the reconnected socket is authenticated by the time we emit).
+ function emitDeviceEvent(type, reason, detail) {
+ try {
+ if (!socket?.connected || !config.deviceId) return;
+ const m = { device_id: config.deviceId, type };
+ if (reason) m.reason = reason;
+ if (detail) m.detail = detail;
+ socket.emit('device:event', m);
+ } catch (e) {}
+ }
let playlist = [];
let currentIndex = -1;
// #157: deferred rotation-out. When a playlist update removes the item currently on screen
@@ -934,6 +950,11 @@
console.log('Disconnected');
stopHeartbeat();
stopWatchdog(); // socket.io owns the reconnect once it KNOWS it's down; watchdog is for half-open only
+ // feat/offline-cause-log: open an offline gap so the next reconnect can report cause.
+ if (!disconnectedAt) {
+ disconnectedAt = Date.now();
+ linkLostDuringGap = (typeof navigator !== 'undefined' && navigator.onLine === false);
+ }
});
socket.on('connect_error', (err) => {
@@ -948,6 +969,21 @@
saveConfig(config);
console.log('Registered:', data.device_id);
+ // feat/offline-cause-log: reconnected after an in-session disconnect -> report the gap length
+ // + whether the local link dropped. cold_start:false because the page SURVIVED the gap (a
+ // reboot/reload would have reset disconnectedAt). Browser has no SSID/RSSI to add.
+ if (disconnectedAt && config.deviceId) {
+ try {
+ socket.emit('device:connectivity-report', {
+ device_id: config.deviceId,
+ offline_ms: Math.max(0, Date.now() - disconnectedAt),
+ link_lost: linkLostDuringGap,
+ cold_start: false,
+ });
+ } catch (e) {}
+ disconnectedAt = 0; linkLostDuringGap = false;
+ }
+
if (!config.paired) {
// Show pairing code
document.getElementById('urlForm').style.display = 'none';
@@ -2564,6 +2600,14 @@
window.addEventListener('pageshow', verifyLivenessSoon); // sleep/resume via bfcache restore
window.addEventListener('online', verifyLivenessSoon); // network switch (wifi<->cellular)
+ // feat/offline-cause-log: display sleep / backgrounding proxy — screen off/on on a TV.
+ document.addEventListener('visibilitychange', () => {
+ emitDeviceEvent(document.hidden ? 'display_off' : 'display_on');
+ });
+ // feat/offline-cause-log: browser-side offline detection feeds link_lost on the next reconnect —
+ // if navigator goes offline during a disconnect gap, the drop was the local link (not upstream).
+ window.addEventListener('offline', () => { if (disconnectedAt) linkLostDuringGap = true; });
+
// Register service worker for offline content caching
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/player/sw.js').then(reg => {
diff --git a/server/routes/devices.js b/server/routes/devices.js
index a6fbf20..01f4294 100644
--- a/server/routes/devices.js
+++ b/server/routes/devices.js
@@ -153,16 +153,25 @@ router.get('/:id', (req, res) => {
let statusLog = [];
try {
statusLog = db.prepare(
- 'SELECT status, timestamp FROM device_status_log WHERE device_id = ? AND timestamp > ? ORDER BY timestamp ASC'
+ 'SELECT status, reason, detail, timestamp FROM device_status_log WHERE device_id = ? AND timestamp > ? ORDER BY timestamp ASC'
).all(req.params.id, dayAgo);
} catch (_) {}
+ // Offline-cause log: the unified incident feed (offline-cause + display/sleep + crash +
+ // reboot), most-recent first. Best-effort — an old DB without the table just yields [].
+ let deviceEvents = [];
+ try {
+ deviceEvents = db.prepare(
+ 'SELECT id, type, reason, detail, timestamp FROM device_events WHERE device_id = ? ORDER BY timestamp DESC, id DESC LIMIT 50'
+ ).all(req.params.id);
+ } catch (_) {}
+
// Also get telemetry timestamps as heartbeat proof (fills gaps between status events)
const uptimeData = db.prepare(
'SELECT reported_at FROM device_telemetry WHERE device_id = ? AND reported_at > ? ORDER BY reported_at ASC'
).all(req.params.id, dayAgo).map(r => r.reported_at);
- res.json({ ...stripDeviceSecrets(device), telemetry, screenshot, assignments, active_layout_zones, playlist_status, playlist_has_published, uptimeData, statusLog });
+ res.json({ ...stripDeviceSecrets(device), telemetry, screenshot, assignments, active_layout_zones, playlist_status, playlist_has_published, uptimeData, statusLog, deviceEvents });
});
// Helper: check device write access via the workspace the device belongs to.
diff --git a/server/services/heartbeat.js b/server/services/heartbeat.js
index 107d9a8..863bad2 100644
--- a/server/services/heartbeat.js
+++ b/server/services/heartbeat.js
@@ -106,7 +106,13 @@ function startHeartbeatChecker(io) {
console.log(`Device ${device.id} marked offline (heartbeat timeout)`);
// #146: batch through the coalescing writer (was an immediate INSERT here).
- statusLogWriter.record(device.id, 'offline_timeout');
+ // Offline-cause log: this liveness-timeout path is the "stopped reporting" case —
+ // annotate reason/detail and record it in the unified incident feed too.
+ statusLogWriter.record(device.id, 'offline_timeout', 'heartbeat_timeout', 'Stopped sending heartbeats');
+ try {
+ db.prepare("INSERT INTO device_events (device_id, type, reason, detail) VALUES (?, 'offline', 'heartbeat_timeout', 'Stopped sending heartbeats')")
+ .run(device.id);
+ } catch (_) { /* incident feed is best-effort; never perturb the heartbeat loop */ }
}
}
@@ -126,6 +132,35 @@ async function prunePlayLogs() {
return (await chunkedDelete((lim) => _delPlayLogs.run(cutoff, lim).changes, { batch: config.statusLogPruneBatch })).deleted;
}
+// Offline-cause log: retention sweep for the unified incident feed, mirroring the
+// device_status_log age prune (same retention window + chunked so a backlog trims across
+// many bounded DELETEs, never one blocking statement). Rides idx_device_events_device_time
+// only loosely (timestamp filter); bounded batches keep it off the loop regardless.
+const _delDeviceEvents = db.prepare('DELETE FROM device_events WHERE rowid IN (SELECT rowid FROM device_events WHERE timestamp < ? LIMIT ?)');
+async function pruneDeviceEvents() {
+ const cutoff = Math.floor(Date.now() / 1000) - Math.round(config.statusLogRetentionDays * 86400);
+ return (await chunkedDelete((lim) => _delDeviceEvents.run(cutoff, lim).changes, { batch: config.statusLogPruneBatch })).deleted;
+}
+
+// Per-device row cap: even within the retention window a chatty device (display on/off
+// flapping, reconnect churn) shouldn't accumulate unbounded incident rows. Trim any
+// device over the cap down to its most-recent DEVICE_EVENTS_PER_DEVICE_CAP rows. Only
+// touches devices actually over the cap (cheap HAVING scan on the index), yielding between.
+const DEVICE_EVENTS_PER_DEVICE_CAP = 500;
+const _capDeviceEvents = db.prepare(`
+ DELETE FROM device_events WHERE device_id = ? AND id NOT IN (
+ SELECT id FROM device_events WHERE device_id = ? ORDER BY timestamp DESC, id DESC LIMIT ?
+ )`);
+async function capDeviceEvents() {
+ const over = db.prepare('SELECT device_id FROM device_events GROUP BY device_id HAVING COUNT(*) > ?').all(DEVICE_EVENTS_PER_DEVICE_CAP);
+ let trimmed = 0;
+ for (const row of over) {
+ trimmed += _capDeviceEvents.run(row.device_id, row.device_id, DEVICE_EVENTS_PER_DEVICE_CAP).changes;
+ await yieldTick();
+ }
+ return trimmed;
+}
+
// #146 interval maintenance — band-gated (skip while loaded; runs next tick) and
// re-entrancy-guarded (a long run never stacks with the next interval). Never throws
// into the interval. NOT for startup (see the un-gated startup prune above).
@@ -138,6 +173,8 @@ async function runMaintenance() {
await pruneProvisioningDevices();
await prunePlayLogs();
await pruneStatusLog({ bandGate: true }); // per-device chunked; own re-entrancy
+ await pruneDeviceEvents(); // offline-cause log: incident-feed age retention (chunked)
+ await capDeviceEvents(); // offline-cause log: per-device incident row cap
await pruneUsageDaily(); // #146 BILLING rollup retention (chunked)
// Expiry sweeps on small tables — single cheap statements, bounded by table size.
db.prepare("DELETE FROM team_invites WHERE expires_at < strftime('%s','now')").run();
@@ -243,6 +280,8 @@ module.exports = {
recentReconnects, // FIX 2
livenessFor, // FIX 2
pruneProvisioningDevices,
+ pruneDeviceEvents, // offline-cause log: incident-feed retention
+ capDeviceEvents, // offline-cause log: per-device incident cap
accrueUsage,
pruneUsageDaily,
__resetAccrual: () => { _lastAccrue = 0; }, // #146 test hook: reset the accrual baseline
diff --git a/server/test/device-events.test.js b/server/test/device-events.test.js
new file mode 100644
index 0000000..5c0fa71
--- /dev/null
+++ b/server/test/device-events.test.js
@@ -0,0 +1,154 @@
+'use strict';
+
+// Offline-cause / incident-log unit tests. Two layers, no socket server needed:
+// 1. The pure classifier (lib/incident-classify) — the actual rules the live
+// device:connectivity-report + disconnect handlers apply. Testing the extracted
+// helper guarantees the handler and these assertions agree on the exact strings.
+// 2. A tiny in-memory better-sqlite3 exercising the same INSERT/UPDATE the handlers
+// run, driven by the classifier's output, so the persistence shape is proven too
+// (a device:event row lands; a connectivity-report upgrades the recent offline row).
+
+const { test } = require('node:test');
+const assert = require('node:assert/strict');
+const Database = require('better-sqlite3');
+
+const {
+ ALLOWED_EVENT_TYPES,
+ isAllowedEventType,
+ normalizeDisconnectReason,
+ classifyConnectivity,
+} = require('../lib/incident-classify');
+
+// ---- 1. classifyConnectivity: reason/detail composition ----
+
+test('connectivity: cold_start wins -> reason reboot', () => {
+ const c = classifyConnectivity({ cold_start: true, link_lost: true });
+ assert.equal(c.reason, 'reboot');
+ assert.equal(c.type, 'reboot');
+ assert.equal(c.detail, 'Device restarted (power/reboot)');
+});
+
+test('connectivity: link_lost true -> reason network, link-lost detail', () => {
+ const c = classifyConnectivity({ link_lost: true });
+ assert.equal(c.reason, 'network');
+ assert.equal(c.type, 'network');
+ assert.match(c.detail, /link lost/);
+});
+
+test('connectivity: link_lost false, no probe -> reason network, router/upstream detail', () => {
+ const c = classifyConnectivity({ link_lost: false });
+ assert.equal(c.reason, 'network');
+ assert.equal(c.type, 'network');
+ assert.match(c.detail, /server unreachable \(router\/internet\/upstream\)/);
+});
+
+test('connectivity: link up + internet_ok true -> server_down (OUR server, not the site)', () => {
+ const c = classifyConnectivity({ link_lost: false, internet_ok: true });
+ assert.equal(c.reason, 'server_down');
+ assert.equal(c.type, 'network');
+ assert.match(c.detail, /Internet reachable but the ScreenTinker server was unreachable/);
+});
+
+test('connectivity: link up + internet_ok false -> no_internet (router/ISP down)', () => {
+ const c = classifyConnectivity({ link_lost: false, internet_ok: false });
+ assert.equal(c.reason, 'no_internet');
+ assert.match(c.detail, /No internet — router\/ISP down/);
+});
+
+test('connectivity: link_lost true wins over internet_ok (device link is the root cause)', () => {
+ const c = classifyConnectivity({ link_lost: true, internet_ok: false });
+ assert.match(c.detail, /Wi‑Fi\/Ethernet link lost/);
+});
+
+test('connectivity: ssid / weak-rssi / ip_changed fragments append to detail', () => {
+ const c = classifyConnectivity({ link_lost: true, ssid: 'Office', rssi: -82, ip_changed: true });
+ assert.match(c.detail, /SSID "Office"/);
+ assert.match(c.detail, /weak signal \(-82 dBm\)/);
+ assert.match(c.detail, /IP changed \(DHCP\/router\)/);
+ // strong signal is NOT flagged
+ assert.ok(!/weak signal/.test(classifyConnectivity({ link_lost: true, rssi: -50 }).detail));
+});
+
+// ---- 2. normalizeDisconnectReason: socket.io reason -> category token ----
+
+test('disconnect reason normalizes (whitespace->_, lowercase) and defaults to silent', () => {
+ assert.equal(normalizeDisconnectReason('transport close'), 'transport_close');
+ assert.equal(normalizeDisconnectReason('ping timeout'), 'ping_timeout');
+ assert.equal(normalizeDisconnectReason('Transport Error'), 'transport_error');
+ assert.equal(normalizeDisconnectReason(''), 'silent');
+ assert.equal(normalizeDisconnectReason(undefined), 'silent');
+ assert.equal(normalizeDisconnectReason(null), 'silent');
+});
+
+// ---- 3. allowed event types ----
+
+test('event types: allowed set gates device:event', () => {
+ for (const t of ['offline', 'display_off', 'display_on', 'crash', 'reboot', 'network', 'app_error']) {
+ assert.ok(isAllowedEventType(t), `${t} allowed`);
+ assert.ok(ALLOWED_EVENT_TYPES.has(t));
+ }
+ assert.ok(!isAllowedEventType('bogus'));
+ assert.ok(!isAllowedEventType(''));
+ assert.ok(!isAllowedEventType(undefined));
+});
+
+// ---- 4. persistence: the SQL the handlers run, driven by the classifier ----
+
+function freshDb() {
+ const db = new Database(':memory:');
+ db.exec(`
+ CREATE TABLE device_events (
+ id INTEGER PRIMARY KEY AUTOINCREMENT, device_id TEXT NOT NULL, type TEXT NOT NULL,
+ reason TEXT, detail TEXT, timestamp INTEGER NOT NULL DEFAULT (strftime('%s','now')));
+ CREATE TABLE device_status_log (
+ id INTEGER PRIMARY KEY AUTOINCREMENT, device_id TEXT, status TEXT, reason TEXT, detail TEXT,
+ timestamp INTEGER NOT NULL DEFAULT (strftime('%s','now')));
+ `);
+ return db;
+}
+
+test('device:event inserts a device_events row (allowed type)', () => {
+ const db = freshDb();
+ // mirrors the handler body after the isAllowedEventType gate
+ db.prepare("INSERT INTO device_events (device_id, type, reason, detail) VALUES (?, ?, ?, ?)")
+ .run('dev1', 'display_off', null, 'screen slept');
+ const rows = db.prepare('SELECT * FROM device_events WHERE device_id = ?').all('dev1');
+ assert.equal(rows.length, 1);
+ assert.equal(rows[0].type, 'display_off');
+ assert.equal(rows[0].detail, 'screen slept');
+});
+
+test('connectivity-report upgrades the recent offline status-log row + logs an event', () => {
+ const db = freshDb();
+ // A server-guessed offline row exists (the disconnect handler wrote 'transport_close').
+ db.prepare("INSERT INTO device_status_log (device_id, status, reason, detail) VALUES ('dev1','offline','transport_close',NULL)").run();
+
+ // Handler path: classify the device's report, then UPDATE the recent offline row + INSERT an event.
+ const { reason, detail, type } = classifyConnectivity({ link_lost: true, ssid: 'Shop', rssi: -80 });
+ db.prepare(`UPDATE device_status_log SET reason = ?, detail = ?
+ WHERE id = (SELECT id FROM device_status_log
+ WHERE device_id = ? AND status IN ('offline','offline_timeout')
+ AND timestamp > strftime('%s','now') - 900
+ ORDER BY timestamp DESC, id DESC LIMIT 1)`).run(reason, detail, 'dev1');
+ db.prepare("INSERT INTO device_events (device_id, type, reason, detail) VALUES (?, ?, ?, ?)")
+ .run('dev1', type, reason, detail);
+
+ const log = db.prepare("SELECT reason, detail FROM device_status_log WHERE device_id = 'dev1'").get();
+ assert.equal(log.reason, 'network', 'server guess upgraded to device ground truth');
+ assert.match(log.detail, /link lost/);
+ assert.match(log.detail, /SSID "Shop"/);
+
+ const ev = db.prepare("SELECT type, reason FROM device_events WHERE device_id = 'dev1'").get();
+ assert.equal(ev.type, 'network');
+ assert.equal(ev.reason, 'network');
+});
+
+test('connectivity-report with cold_start records a reboot event', () => {
+ const db = freshDb();
+ const { reason, type, detail } = classifyConnectivity({ cold_start: true });
+ db.prepare("INSERT INTO device_events (device_id, type, reason, detail) VALUES (?, ?, ?, ?)")
+ .run('dev2', type, reason, detail);
+ const ev = db.prepare("SELECT type, reason FROM device_events WHERE device_id = 'dev2'").get();
+ assert.equal(ev.type, 'reboot');
+ assert.equal(ev.reason, 'reboot');
+});
diff --git a/server/ws/deviceSocket.js b/server/ws/deviceSocket.js
index bf3db63..2fbba78 100644
--- a/server/ws/deviceSocket.js
+++ b/server/ws/deviceSocket.js
@@ -17,6 +17,7 @@ const { resolveIdentity } = require('../lib/device-identity');
const logCoalescer = require('../lib/log-coalescer');
const loopLag = require('../services/loop-lag');
const deviceSettings = require('../lib/device-settings'); // #150 delete+re-pair settings restore
+const incidentClassify = require('../lib/incident-classify'); // offline-cause log: disconnect-reason + connectivity classification
// Debounce window for marking a device offline on socket disconnect. Brief
// flap (Wi-Fi blip, Engine.IO ping miss, server-side eviction-then-reconnect)
@@ -73,6 +74,18 @@ let lastScreenshots = {};
// dashboard reflects it without a full re-register / playlist push). Older APKs omit newer fields.
function applyDeviceInfo(deviceId, di) {
const num = (v) => (typeof v === 'number' ? v : null);
+ // Upgrade incident: if the reported app_version differs from what we had stored, log it
+ // (old → new) in the incident feed. Server-side, so it covers every client (Android/Tizen/web)
+ // with no client change. Only when we HAD a prior version (a fresh pair isn't an "upgrade").
+ try {
+ if (di.app_version) {
+ const prev = db.prepare('SELECT app_version FROM devices WHERE id = ?').get(deviceId);
+ if (prev && prev.app_version && prev.app_version !== di.app_version) {
+ db.prepare("INSERT INTO device_events (device_id, type, reason, detail) VALUES (?, 'upgrade', 'upgrade', ?)")
+ .run(deviceId, `${prev.app_version} → ${di.app_version}`);
+ }
+ }
+ } catch (_) { /* incident feed is best-effort */ }
db.prepare(`UPDATE devices SET android_version = ?, app_version = ?, screen_width = ?, screen_height = ?, render_width = ?, render_height = ?,
ota_status = ?, ota_target_version = ?, ota_attempts = ?, tier = ?, foreign_device_owner = ?,
can_write_settings = ?, accessibility_enabled = ?, overlay_granted = ?,
@@ -114,8 +127,8 @@ function getClientIp(socket) {
// writer and uses config.statusLogRetentionDays (was a hardcoded 7 days here — one
// source of truth). devices.status is still updated immediately by callers; only
// this audit log is deferred to the next flush.
-function logDeviceStatus(deviceId, status) {
- statusLogWriter.record(deviceId, status);
+function logDeviceStatus(deviceId, status, reason, detail) {
+ statusLogWriter.record(deviceId, status, reason, detail);
}
@@ -956,6 +969,47 @@ module.exports = function setupDeviceSocket(io) {
.run(e.reason, e.detail, currentDeviceId);
});
+ // Offline-cause log: a typed incident from the player (display_off/display_on, crash,
+ // app_error, ...). Just records a device_events row. Guarded by requireDeviceAuth like
+ // every other device event; unknown/forged types are dropped (never inserted).
+ socket.on('device:event', (data) => {
+ if (!requireDeviceAuth()) return;
+ const { device_id, type, reason, detail } = data || {};
+ if (device_id && device_id !== currentDeviceId) return; // forged/mismatched -> no-op
+ if (!incidentClassify.isAllowedEventType(type)) return; // unknown type -> ignore
+ try {
+ db.prepare("INSERT INTO device_events (device_id, type, reason, detail) VALUES (?, ?, ?, ?)")
+ .run(currentDeviceId, type, reason ? String(reason).slice(0, 64) : null, detail ? String(detail).slice(0, 500) : null);
+ } catch (_) { /* incident feed is best-effort; never crash the socket */ }
+ });
+
+ // Offline-cause log: the device's ground-truth account of an in-process disconnect it
+ // just recovered from (app SURVIVED the gap -> not a reboot unless cold_start). Compose
+ // reason+detail per the contract, then UPGRADE the server's earlier guess: flush the
+ // status-log writer so the offline row exists, UPDATE that recent offline row's
+ // reason/detail, and add a device_events row (type network|reboot).
+ socket.on('device:connectivity-report', (data) => {
+ if (!requireDeviceAuth()) return;
+ const { device_id } = data || {};
+ if (device_id && device_id !== currentDeviceId) return; // forged/mismatched -> no-op
+ const deviceId = currentDeviceId;
+ try {
+ const { reason, detail, type } = incidentClassify.classifyConnectivity(data);
+ // Ensure any buffered offline transition for this device is on disk before we
+ // reach back to annotate it (the writer coalesces on a ~1s interval otherwise).
+ statusLogWriter.flushNow();
+ // Upgrade the most-recent offline row (server guess) to the device's ground truth.
+ db.prepare(`UPDATE device_status_log SET reason = ?, detail = ?
+ WHERE id = (
+ SELECT id FROM device_status_log
+ WHERE device_id = ? AND status IN ('offline','offline_timeout')
+ AND timestamp > strftime('%s','now') - 900
+ ORDER BY timestamp DESC, id DESC LIMIT 1)`).run(reason, detail, deviceId);
+ db.prepare("INSERT INTO device_events (device_id, type, reason, detail) VALUES (?, ?, ?, ?)")
+ .run(deviceId, type, reason, detail);
+ } catch (_) { /* offline-cause annotation is best-effort; never crash the socket */ }
+ });
+
// Play event logging (proof-of-play)
socket.on('device:play-event', (data) => {
if (!requireDeviceAuth()) return;
@@ -1075,7 +1129,13 @@ module.exports = function setupDeviceSocket(io) {
deviceNs.to(leaderId).emit('group:sync-request', { group_id: group.id, requested_by: currentDeviceId });
});
- socket.on('disconnect', () => {
+ socket.on('disconnect', (reason) => {
+ // Offline-cause log: capture socket.io's disconnect reason (transport_close /
+ // ping_timeout / transport_error / ...) and normalize it to a category token now,
+ // while it's in scope; the offline transition below (deferred by the debounce
+ // timer) uses it as the fallback offline reason when the device sent no explicit
+ // exit signal this session. Falls back to 'silent' when absent.
+ const socketOfflineReason = incidentClassify.normalizeDisconnectReason(reason);
// #146: this socket was force-evicted by a newer registration for the same
// device. The new socket owns the device now (or is mid-register), so this
// disconnect must NOT arm an offline timer — doing so was the self-reset race
@@ -1114,13 +1174,20 @@ module.exports = function setupDeviceSocket(io) {
const activeNow = heartbeat.getConnection(deviceId);
if (activeNow && activeNow.socketId !== closingSocketId) return;
- // Exit-signal contract: resolve manner-of-death. If the device announced a reason before dying
- // (offline_reason non-NULL, set by device:exit/beacon this session), keep it; else -> 'silent'
- // (no signal arrived). COALESCE makes this a pure annotation — offline detection is unchanged.
+ // Exit-signal contract (UNCHANGED): devices.offline_reason stays the app's self-reported
+ // manner-of-death — 'crashed'/'clean_exit' if it announced one this session, else 'silent'
+ // (a violent/abrupt death is 'silent', never a socket-inferred value — Bold-critical).
db.prepare("UPDATE devices SET status = 'offline', updated_at = strftime('%s','now'), offline_reason = COALESCE(offline_reason, 'silent'), offline_reason_at = COALESCE(offline_reason_at, strftime('%s','now')) WHERE id = ?").run(deviceId);
heartbeat.removeConnection(deviceId);
- logDeviceStatus(deviceId, 'offline');
const _off = db.prepare("SELECT offline_reason, offline_detail, client_type FROM devices WHERE id = ?").get(deviceId) || {};
+ // The offline-CAUSE log (device_status_log + device_events) gets the richer signal, which is
+ // a SEPARATE axis from the exit-signal field: the app's announced reason if it gave one, else
+ // the normalized socket transport reason (transport_close/ping_timeout/...). This never touches
+ // devices.offline_reason, so the exit-signal 'silent' semantics above are preserved.
+ const finalReason = (_off.offline_reason && _off.offline_reason !== 'silent') ? _off.offline_reason : socketOfflineReason;
+ logDeviceStatus(deviceId, 'offline', finalReason, null);
+ // Offline-cause log: also record the transition in the unified incident feed.
+ try { db.prepare("INSERT INTO device_events (device_id, type, reason, detail) VALUES (?, 'offline', ?, NULL)").run(deviceId, finalReason); } catch (_) { /* incident feed is best-effort */ }
emitToDeviceWorkspace(dashboardNs, deviceId, 'dashboard:device-status', { device_id: deviceId, status: 'offline', liveness: 'offline', offline_reason: _off.offline_reason || 'silent', offline_detail: _off.offline_detail || null, client_type: _off.client_type || null });
// If this device was leading a wall, reassign leadership to the next
diff --git a/tizen/js/app.js b/tizen/js/app.js
index 11d9d41..14e77d6 100644
--- a/tizen/js/app.js
+++ b/tizen/js/app.js
@@ -199,6 +199,11 @@
var beatCount = 0;
var authenticated = false; // #118: true only between device:registered and disconnect/auth-error
var streamTimer = null; // #120: dashboard preview streaming interval
+ // feat/offline-cause-log: connectivity-report state. Track in-session disconnects so a reconnect can
+ // tell the server WHY it was gone (local link lost vs server/upstream unreachable). A browser can't
+ // see SSID/RSSI, so we send only offline_ms + link_lost + cold_start:false.
+ var disconnectedAtMono = 0; // mono() at the first disconnect of the current gap (0 = not in a gap)
+ var linkLostDuringGap = false; // navigator went offline at any point during the gap
// #group-sync clock discipline. Server is the time authority (heartbeat-ack). Cache a smoothed
// offset so synced_now = Date.now() + clockOffsetMs keeps schedule sync aligned through an outage.
@@ -297,6 +302,11 @@
socket.on('disconnect', function () {
authenticated = false; // #118
stopHeartbeat(); // #118: no beats on a dead socket
+ // feat/offline-cause-log: open an offline gap so the next reconnect can report cause.
+ if (!disconnectedAtMono) {
+ disconnectedAtMono = mono();
+ linkLostDuringGap = (typeof navigator !== 'undefined' && navigator.onLine === false);
+ }
toast('Reconnecting…', true);
});
@@ -305,6 +315,20 @@
set(LS.id, deviceId); set(LS.token, deviceToken);
authenticated = true; // #118: this socket may now send post-register events
clearToast(); // #118: drop any stale "Not authenticated…" banner
+ // feat/offline-cause-log: reconnected after an in-session disconnect -> report the gap length +
+ // whether the local link dropped. cold_start:false because the app SURVIVED the gap (a reboot
+ // would have lost this in-process state). Browser has no SSID/RSSI to add.
+ if (disconnectedAtMono) {
+ try {
+ socket.emit('device:connectivity-report', {
+ device_id: deviceId,
+ offline_ms: Math.max(0, Math.round(mono() - disconnectedAtMono)),
+ link_lost: linkLostDuringGap,
+ cold_start: false
+ });
+ } catch (e) {}
+ disconnectedAtMono = 0; linkLostDuringGap = false;
+ }
startHeartbeat();
reportCapabilities(); // #125: surface the fleet-control backend to the dashboard
if (data.status === 'provisioning') showPairing();
@@ -461,6 +485,18 @@
} catch (e) {}
}
+ // feat/offline-cause-log: typed incident feed (device:event) — server inserts a device_events row.
+ // Best-effort + auth-guarded (requireDeviceAuth rejects events on a pre-register socket).
+ function emitDeviceEvent(type, reason, detail) {
+ try {
+ if (!socket || !socket.connected || !deviceId || !authenticated) return;
+ var m = { device_id: deviceId, type: type };
+ if (reason) m.reason = reason;
+ if (detail) m.detail = detail;
+ socket.emit('device:event', m);
+ } catch (e) {}
+ }
+
// #125: report a command outcome to the dashboard. device:log surfaces live as
// dashboard:device-log on the open device-detail screen; device:command-result is
// a structured echo (harmless if the server doesn't handle it).
@@ -717,6 +753,16 @@
document.addEventListener('visibilitychange', onVisibility); // FIX B: suspend/resume fast-path
startWatchdog(); // FIX B (hardened): server-silence liveness backstop
+ // feat/offline-cause-log: display sleep / backgrounding proxy — screen off/on on a TV.
+ document.addEventListener('visibilitychange', function () {
+ emitDeviceEvent(document.hidden ? 'display_off' : 'display_on');
+ });
+ // feat/offline-cause-log: browser-side offline detection feeds link_lost on the next reconnect — if
+ // navigator goes offline during a disconnect gap, the drop was the local link (Wi‑Fi/Ethernet).
+ if (typeof window !== 'undefined' && window.addEventListener) {
+ window.addEventListener('offline', function () { if (disconnectedAtMono) linkLostDuringGap = true; });
+ }
+
// @exit-signal-slice:start — v4-exit-signal-phase3.test.js evals the lines between these markers.
// Exit-signal contract v1 — best-effort last gasp. crashed: window.onerror / unhandledrejection.
// clean_exit: operator BACK-key exit (below) + pagehide(persisted=false, a real unload not a bfcache