diff --git a/android/app/src/main/java/com/remotedisplay/player/service/WebSocketService.kt b/android/app/src/main/java/com/remotedisplay/player/service/WebSocketService.kt index 6b4a051..c47e119 100644 --- a/android/app/src/main/java/com/remotedisplay/player/service/WebSocketService.kt +++ b/android/app/src/main/java/com/remotedisplay/player/service/WebSocketService.kt @@ -3,7 +3,13 @@ package com.remotedisplay.player.service import android.app.Notification import android.app.PendingIntent import android.app.Service +import android.content.BroadcastReceiver +import android.content.Context import android.content.Intent +import android.content.IntentFilter +import android.net.ConnectivityManager +import android.net.Network +import android.net.wifi.WifiManager import android.os.Binder import android.os.Handler import android.os.IBinder @@ -12,6 +18,7 @@ import android.os.SystemClock import android.util.Log import kotlin.random.Random import androidx.core.app.NotificationCompat +import androidx.core.content.ContextCompat import com.remotedisplay.player.MainActivity import com.remotedisplay.player.RemoteDisplayApp import com.remotedisplay.player.data.ServerConfig @@ -52,7 +59,42 @@ class WebSocketService : Service() { private fun markAlive() { lastServerMessageAt = SystemClock.elapsedRealtime() } + // feat/offline-cause-log: device-diagnostics / incident-log. All ADDITIVE and fully guarded — a + // panel whose ROM lacks any of these APIs must degrade to silence, never crash. + // - disconnectedAtMs: elapsedRealtime of the FIRST socket 'disconnect' of the current offline + // gap (0 = currently connected / no in-process gap). On 'connect' after a gap we emit a + // device:connectivity-report so the server can tell "app survived the gap" (network/router) + // from a reboot (the app can't report its own reboot — cold_start best-effort covers that). + // - linkLostDuringGap: set by the default-network callback's onLost — "the physical link went + // away at some point during the gap" (Wi-Fi/Ethernet down) vs "link up but server unreachable". + // - lastIpSnapshot: last observed local IPv4, to compute ip_changed (DHCP/router change). + // - sawFirstConnect: gates the one-shot cold-start report to the process's very first connect. + @Volatile private var disconnectedAtMs = 0L + @Volatile private var linkLostDuringGap = false + // - internetOkDuringGap: a public-host reachability probe (1.1.1.1 / 8.8.8.8 :443) fired at + // disconnect. When the link is UP but we're offline, this splits "OUR server is down" + // (wider internet reachable) from "no internet — router/ISP down". null = probe didn't + // finish / not run, in which case the server falls back to the generic router/upstream detail. + @Volatile private var internetOkDuringGap: Boolean? = null + private var lastIpSnapshot: String? = null + @Volatile private var sawFirstConnect = false + // A connectivity-report needs device auth on the (re)connected socket, so it is ARMED at + // 'connect' but FLUSHED from device:registered (once the server knows who we are). + @Volatile private var pendingReport = false + private var pendingOfflineMs = 0L + private var pendingLinkLost = false + private var pendingColdStart = false + private var pendingInternetOk: Boolean? = null + // Registered-once diagnostics plumbing; unregistered in onDestroy. Nullable so a register + // failure (locked-down ROM) just leaves the feature dark. + private var netCallback: ConnectivityManager.NetworkCallback? = null + private var screenReceiver: BroadcastReceiver? = null + companion object { + // feat/offline-cause-log: if the process's FIRST connect happens within this long of boot + // (elapsedRealtime, which is wall-time since boot), treat it as a cold start (power/reboot) + // rather than a network gap. Generous so a slow panel's boot→launch→network still counts. + private const val COLD_START_WINDOW_MS = 120_000L // #148: backoff before re-opening the single socket after a disconnect that Socket.IO // does NOT auto-reconnect (io server/client disconnect) — never a blind immediate re-open. private const val RECONNECT_AFTER_EVICT_MS = 3000L @@ -116,6 +158,58 @@ class WebSocketService : Service() { wakeLock?.acquire() startReconnectWatchdog() + + // feat/offline-cause-log: best-effort diagnostics plumbing (both guarded, both cleaned up in + // onDestroy). Failure to register either just leaves that signal dark — never fatal. + registerNetworkCallback() + registerScreenReceiver() + } + + /** + * feat/offline-cause-log: watch the DEFAULT network so a connectivity-report can distinguish a + * lost physical link (Wi‑Fi/Ethernet down) from "link up but the server is unreachable". onLost + * of the default network during an offline gap flips linkLostDuringGap; it is reset after the + * next report. registerDefaultNetworkCallback is API 24 (== minSdk), so no version gate needed, + * but everything is still wrapped so a locked-down ROM can't crash the service. + */ + private fun registerNetworkCallback() { + try { + val cm = getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return + val cb = object : ConnectivityManager.NetworkCallback() { + override fun onLost(network: Network) { + // Only meaningful mid-gap; if we're still connected this is a transient handoff. + if (disconnectedAtMs != 0L) linkLostDuringGap = true + } + } + cm.registerDefaultNetworkCallback(cb) + netCallback = cb + } catch (e: Throwable) { Log.w("WebSocketService", "registerNetworkCallback: ${e.message}") } + } + + /** + * feat/offline-cause-log: ACTION_SCREEN_ON/OFF can ONLY be delivered to a context-registered + * receiver (the framework refuses them from the manifest), so we register here and drop a + * device:event display_on/display_off — "the screen went black" as an incident, not an outage. + */ + private fun registerScreenReceiver() { + try { + val r = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + when (intent?.action) { + Intent.ACTION_SCREEN_OFF -> emitEvent("display_off", detail = "Screen off / sleep") + Intent.ACTION_SCREEN_ON -> emitEvent("display_on", detail = "Screen on") + } + } + } + val filter = IntentFilter().apply { + addAction(Intent.ACTION_SCREEN_ON) + addAction(Intent.ACTION_SCREEN_OFF) + } + // Protected system broadcasts (SCREEN_ON/OFF) are exempt from the API 34 export-flag + // rule, but pass RECEIVER_NOT_EXPORTED explicitly so no OEM ROM can reject the register. + ContextCompat.registerReceiver(this, r, filter, ContextCompat.RECEIVER_NOT_EXPORTED) + screenReceiver = r + } catch (e: Throwable) { Log.w("WebSocketService", "registerScreenReceiver: ${e.message}") } } /** @@ -222,12 +316,23 @@ class WebSocketService : Service() { safeOn(Socket.EVENT_CONNECT) { Log.i("WebSocketService", "Connected to server") consecutiveFailures = 0 + armConnectivityReport() // feat/offline-cause-log: capture the gap, flush post-auth register() } safeOn(Socket.EVENT_DISCONNECT) { args -> val reason = args.firstOrNull()?.toString() ?: "unknown" Log.w("WebSocketService", "Disconnected from server: $reason") + // feat/offline-cause-log: mark the START of an in-process offline gap. Keep the + // EARLIEST timestamp if several disconnects fire before we reconnect, so offline_ms + // spans the whole gap. elapsedRealtime = monotonic (immune to wall-clock jumps). + if (disconnectedAtMs == 0L) { + disconnectedAtMs = SystemClock.elapsedRealtime() + // Probe the wider internet DURING the gap so a link-up outage can be split into + // "our server down" (internet reachable) vs "no internet" (router/ISP down). + internetOkDuringGap = null + probeInternetAsync() + } // Stop heartbeat while disconnected; player keeps showing cached content. stopHeartbeat() // #148 reconnect discipline: Socket.IO auto-reconnects the SAME socket on a @@ -269,6 +374,9 @@ class WebSocketService : Service() { } handler.post { try { onRegistered?.invoke(newDeviceId) } catch (e: Throwable) { Log.e("WebSocketService", "onRegistered cb: ${e.message}") } } startHeartbeat() + // feat/offline-cause-log: now authenticated on this socket — safe to flush the + // connectivity-report armed at 'connect' (requireDeviceAuth gates it server-side). + flushConnectivityReport() } // v4 degrade-safe ARM: the watchdog arms ONLY after the first heartbeat-ack, so a @@ -1019,6 +1127,135 @@ class WebSocketService : Service() { } catch (e: Throwable) { Log.w("WebSocketService", "emitGroupSyncRequest: ${e.message}") } } + // ── feat/offline-cause-log: connectivity-report + device:event emitters ────────────────────── + // All guarded, all no-ops when unpaired/disconnected. See the field block near markAlive() for + // the state model. The server (deviceSocket.js) turns a report into a human offline reason. + + /** + * Called from EVENT_CONNECT. Decides whether this connect warrants a connectivity-report and, if + * so, snapshots the gap into the pending* fields for flushConnectivityReport() to emit once we're + * authenticated. Two cases: + * - a prior in-process disconnect (disconnectedAtMs != 0) → the app survived the gap, so this is + * NOT a reboot: report offline_ms + link_lost (network vs router/upstream). + * - the process's very first connect with NO prior disconnect, on a freshly-booted device + * (elapsedRealtime within the cold-start window) → one-shot cold_start report (power/reboot). + * Never emits directly (auth isn't established yet); only arms the pending report. + */ + private fun armConnectivityReport() { + try { + val now = SystemClock.elapsedRealtime() + if (disconnectedAtMs != 0L) { + pendingOfflineMs = now - disconnectedAtMs + pendingLinkLost = linkLostDuringGap + pendingInternetOk = internetOkDuringGap // may be null if the probe didn't finish + pendingColdStart = false + pendingReport = true + // Reset the gap trackers now that it's been captured for report. + disconnectedAtMs = 0L + linkLostDuringGap = false + internetOkDuringGap = null + } else if (!sawFirstConnect && now < COLD_START_WINDOW_MS) { + pendingOfflineMs = now // best-effort "time since boot" as the offline span + pendingLinkLost = false + pendingInternetOk = null + pendingColdStart = true + pendingReport = true + } + sawFirstConnect = true + } catch (e: Throwable) { Log.w("WebSocketService", "armConnectivityReport: ${e.message}") } + } + + /** Emit the armed connectivity-report (from device:registered, i.e. post-auth). One-shot. */ + private fun flushConnectivityReport() { + if (!pendingReport) return + pendingReport = false + emitConnectivityReport(pendingOfflineMs, pendingLinkLost, pendingColdStart, pendingInternetOk) + } + + // Fire a short public-host reachability check on a background thread (never on the socket thread). + // Success on EITHER 1.1.1.1 or 8.8.8.8 :443 = the wider internet is reachable. Result lands in + // internetOkDuringGap; if the gap ends before it finishes, the report simply omits internet_ok. + private fun probeInternetAsync() { + Thread { + val ok = probeHost("1.1.1.1") || probeHost("8.8.8.8") + // Only record if we're still in the SAME gap (not reset by a reconnect meanwhile). + if (disconnectedAtMs != 0L) internetOkDuringGap = ok + }.apply { isDaemon = true }.start() + } + private fun probeHost(host: String): Boolean = try { + java.net.Socket().use { s -> s.connect(java.net.InetSocketAddress(host, 443), 3000); true } + } catch (_: Throwable) { false } + + private fun emitConnectivityReport(offlineMs: Long, linkLost: Boolean, coldStart: Boolean, internetOk: Boolean?) { + try { + val id = config.deviceId + if (id.isEmpty() || socket?.connected() != true) return + val ssid = readWifiSsid() + val rssi = readWifiRssi() + val ip = readCurrentIp() + val ipChanged = lastIpSnapshot != null && ip != null && ip != lastIpSnapshot + if (ip != null) lastIpSnapshot = ip + val data = JSONObject().apply { + put("device_id", id) + put("offline_ms", offlineMs) + put("link_lost", linkLost) + if (!ssid.isNullOrEmpty() && ssid != "Unknown") put("ssid", ssid) + if (rssi != 0) put("rssi", rssi) + put("ip_changed", ipChanged) + put("cold_start", coldStart) + if (internetOk != null) put("internet_ok", internetOk) // omitted when the probe didn't finish + } + socket?.emit("device:connectivity-report", data) + Log.i("WebSocketService", "connectivity-report offline_ms=$offlineMs link_lost=$linkLost internet_ok=$internetOk cold_start=$coldStart ip_changed=$ipChanged") + } catch (e: Throwable) { Log.w("WebSocketService", "emitConnectivityReport: ${e.message}") } + } + + /** Emit a typed incident (device_events). Guarded + no-op when unpaired/disconnected. */ + private fun emitEvent(type: String, reason: String? = null, detail: String? = null) { + try { + val id = config.deviceId + if (id.isEmpty() || socket?.connected() != true) return + socket?.emit("device:event", JSONObject().apply { + put("device_id", id) + put("type", type) + if (reason != null) put("reason", reason) + if (detail != null) put("detail", detail) + }) + Log.i("WebSocketService", "device:event $type${reason?.let { " ($it)" } ?: ""}") + } catch (e: Throwable) { Log.w("WebSocketService", "emitEvent: ${e.message}") } + } + + // Wi‑Fi/IP snapshot helpers — mirror telemetry's DeviceInfo.getWifiSSID/RSSI (those are private + // there). @Suppress DEPRECATION: WifiManager.connectionInfo is deprecated on API 31+ but is the + // only path that works down to minSdk 24 and still returns for a foreground/system app. + @Suppress("DEPRECATION") + private fun readWifiSsid(): String? = try { + val wm = applicationContext.getSystemService(Context.WIFI_SERVICE) as? WifiManager + wm?.connectionInfo?.ssid?.replace("\"", "") + } catch (e: Throwable) { null } + + @Suppress("DEPRECATION") + private fun readWifiRssi(): Int = try { + val wm = applicationContext.getSystemService(Context.WIFI_SERVICE) as? WifiManager + wm?.connectionInfo?.rssi ?: 0 + } catch (e: Throwable) { 0 } + + /** First non-loopback IPv4 on any up interface (Wi‑Fi or Ethernet) — no extra permission needed. */ + private fun readCurrentIp(): String? = try { + var found: String? = null + val ifaces = java.net.NetworkInterface.getNetworkInterfaces() + while (ifaces != null && ifaces.hasMoreElements() && found == null) { + val iface = ifaces.nextElement() + if (!iface.isUp || iface.isLoopback) continue + val addrs = iface.inetAddresses + while (addrs.hasMoreElements()) { + val addr = addrs.nextElement() + if (!addr.isLoopbackAddress && addr is java.net.Inet4Address) { found = addr.hostAddress; break } + } + } + found + } catch (e: Throwable) { null } + fun disconnect() { stopHeartbeat() cancelReopen() @@ -1065,6 +1302,12 @@ class WebSocketService : Service() { val ctx = applicationContext Thread { ExitSignal.send(ctx, "clean_exit", "onDestroy") }.apply { start(); try { join(1500) } catch (e: InterruptedException) { /* proceed with teardown */ } } reconnectWatchdog?.let { handler.removeCallbacks(it) }; reconnectWatchdog = null + // feat/offline-cause-log: tear down the diagnostics plumbing (guarded — a never-registered + // receiver/callback would otherwise throw IllegalArgumentException here). + try { netCallback?.let { (getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager)?.unregisterNetworkCallback(it) } } catch (e: Throwable) { Log.w("WebSocketService", "unregister netCallback: ${e.message}") } + netCallback = null + try { screenReceiver?.let { unregisterReceiver(it) } } catch (e: Throwable) { Log.w("WebSocketService", "unregister screenReceiver: ${e.message}") } + screenReceiver = null wakeLock?.let { if (it.isHeld) it.release() } disconnect() super.onDestroy() diff --git a/frontend/js/i18n/en.js b/frontend/js/i18n/en.js index a9b1f64..1f5c799 100644 --- a/frontend/js/i18n/en.js +++ b/frontend/js/i18n/en.js @@ -361,6 +361,27 @@ export default { 'device.timeline.no_data': 'No data', 'device.timeline.uptime_pct_tracked': '{pct}% uptime ({n}min tracked)', 'device.timeline.uptime_pct_no_data': '{pct}% uptime (no data)', + // Recent incidents / device diagnostics (offline-cause log) + 'device.incidents.title': 'Recent incidents', + 'device.incidents.none': 'No incidents recorded', + 'device.incidents.down_for': 'down {dur}', + // Friendly labels for device event / offline-reason tokens + 'device.event.network': 'Network problem', + 'device.event.server_down': 'Our server unreachable', + 'device.event.no_internet': 'No internet (router/ISP)', + 'device.event.transport_close': 'Connection lost', + 'device.event.ping_timeout': 'No response (network stall)', + 'device.event.transport_error': 'Connection error', + 'device.event.heartbeat_timeout': 'Stopped reporting', + 'device.event.reboot': 'Device restarted', + 'device.event.crash': 'App crashed', + 'device.event.app_error': 'App error', + 'device.event.display_off': 'Screen off / sleep', + 'device.event.display_on': 'Screen on', + 'device.event.upgrade': 'Upgraded', + 'device.event.silent': 'Unknown cause', + 'device.event.offline': 'Went offline', + 'device.event.online': 'Came online', // Form 'device.form.orientation_label': 'Orientation / Rotation', 'device.form.orientation.landscape': 'Landscape (0°)', diff --git a/frontend/js/views/device-detail.js b/frontend/js/views/device-detail.js index 81d52c9..ac0f7d3 100644 --- a/frontend/js/views/device-detail.js +++ b/frontend/js/views/device-detail.js @@ -393,6 +393,12 @@ async function loadDevice(deviceId, activeTab = null) { + +
+

${t('device.incidents.title')}

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