feat(diagnostics): device incident log — offline cause, network-vs-reboot, display-sleep (#175)

* feat(diagnostics): device incident log — why a screen went offline/black, with device-attested cause

Field request (Bold/s_t_r_o_b_e): "screens go offline randomly — let us see the cause." Answers it
across the fleet with a unified incident log, and — the key insight — lets the DEVICE disambiguate
the cause the server can't: if the app process survived the gap it was a NETWORK problem (not a
reboot), and it can even tell a dropped Wi-Fi/Ethernet link from a link-up-but-server-unreachable
(router/upstream) failure.

Schema:
- device_status_log gains reason + detail (WHY each offline transition happened).
- NEW device_events table (unified incident feed): type (offline/online/display_off/display_on/
  crash/reboot/network/app_error) + reason + detail, indexed, age-pruned + per-device capped.

Server:
- Capture the socket.io disconnect REASON (transport_close/ping_timeout/transport_error) instead of
  discarding it — recorded in the offline-cause log. devices.offline_reason stays on the EXIT-SIGNAL
  contract (crashed/clean_exit/silent) — a separate axis, preserved (violent death = 'silent').
- device:event handler (typed incidents) + device:connectivity-report handler (device-attested).
  lib/incident-classify.js (pure, unit-tested) composes reason+detail: cold_start->reboot;
  link_lost->network "Wi-Fi/Ethernet link lost"; else network "LAN up, server unreachable
  (router/upstream)"; appends SSID / weak-signal (rssi<-75) / IP-changed. On a report it upgrades the
  most-recent offline row from the server's guess to the device's ground truth.
- heartbeat timeout -> 'heartbeat_timeout'; retention/cap for device_events.
- Device-detail API returns statusLog.reason/detail + the last 50 device_events.

Device (Android WebSocketService): ConnectivityManager default-network callback (link-lost during a
gap) + Wi-Fi SSID/RSSI + IP snapshot -> device:connectivity-report on reconnect (app survived =>
network); ACTION_SCREEN_ON/OFF receiver -> device:event display_on/off ("screen went black"). All
guarded/feature-detected; no manifest change; compiles clean.

Web + Tizen players: reconnect connectivity-report (link_lost from navigator.onLine during the gap)
+ visibilitychange -> display_off/on. Best-effort (no wifi detail in a browser). Tizen exit-signal
marker slice untouched.

CMS (device-detail): the offline cause on the uptime-timeline hover + a new "Recent incidents" panel
(merged offline periods + typed events, friendly labels, detail, relative time + down-duration).

Built as a 4-way parallel agent fan-out over disjoint domains against a locked contract, then
integrated. Verified: full server suite 443/443 (incl. the seam fix keeping the exit-signal contract
intact), Android compileDebugKotlin clean, all players + CMS node -c clean.

Refs #170.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(diagnostics): internet-reachability probe — split "our server down" from "no internet" (#170)

Follow-up to the incident log: when a device's link is UP but it's offline, "router/upstream" was a
catch-all. The device now probes a public host (1.1.1.1 / 8.8.8.8 :443) DURING the gap, so the cause
pinpoints blame:
  - link_lost=true                     -> Wi‑Fi/Ethernet link lost (device's own link)
  - link up, internet_ok=true          -> server_down: internet reachable, OUR server was unreachable
  - link up, internet_ok=false         -> no_internet: router/ISP down
  - link up, no probe result           -> generic router/upstream (unchanged fallback)

- Android WebSocketService: fire a short daemon-thread TCP probe (443, either host) at disconnect;
  the result rides the connectivity-report as internet_ok (omitted if the gap ends before it finishes).
- lib/incident-classify.js: 3-way split on internet_ok; new reasons server_down / no_internet.
- Frontend i18n: device.event.server_down / .no_internet labels.
- Tests: +3 classify cases (server_down, no_internet, link_lost wins over internet_ok). 12/12.

Verified: classify 12/12, Android compileDebugKotlin clean, node -c clean. Refs #170.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(diagnostics): log an 'upgrade' incident (old → new app_version) — server-side (#170)

When a device reports an app_version different from the stored one, applyDeviceInfo logs an
'upgrade' device_events row (detail 'old → new'). Server-side, so it covers Android/Tizen/web with
no client change; a fresh pair (no prior version) isn't counted. Adds device.event.upgrade label.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
screentinker 2026-07-13 11:26:04 -05:00 committed by GitHub
parent 2dc1d1279a
commit 9c70fcc790
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 876 additions and 19 deletions

View file

@ -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 (WiFi/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}") }
}
// WiFi/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 (WiFi 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()

View file

@ -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°)',

View file

@ -393,6 +393,12 @@ async function loadDevice(deviceId, activeTab = null) {
</div>
</div>
<!-- Recent incidents (device diagnostics / offline-cause log) -->
<div style="margin-top:20px">
<h4 style="font-size:13px;margin-bottom:8px">${t('device.incidents.title')}</h4>
<div id="incidentsPanel"></div>
</div>
<div style="margin-top:20px">
<div style="display:flex;gap:12px;margin-bottom:12px">
<div class="form-group" style="flex:1;margin:0">
@ -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 `<div style="flex:1;background:${colors[status]};opacity:${opacities[status]}" title="${label} - ${statusLabel}"></div>`;
let title = `${label} - ${statusLabel}`;
if (status === 'offline' && slotReason[i]) {
const r = slotReason[i];
title = `${label} ${statusLabel} · ${eventLabel(r.reason)}${r.detail ? ` (${r.detail})` : ''}`;
}
return `<div style="flex:1;background:${colors[status]};opacity:${opacities[status]}" title="${esc(title)}"></div>`;
}).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 = `<div style="font-size:12px;color:var(--text-muted);padding:8px 0">${t('device.incidents.none')}</div>`;
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
? `<span style="color:var(--text-muted);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(inc.detail)}</span>`
: '';
const dur = (inc.durationSec != null)
? `<span style="color:var(--text-muted);font-size:11px;flex:none">${esc(t('device.incidents.down_for', { dur: formatDur(inc.durationSec) }) + (inc.ongoing ? '…' : ''))}</span>`
: '';
return `
<div style="display:flex;align-items:center;gap:8px;padding:6px 0;border-bottom:1px solid var(--border);font-size:12px">
<span title="${esc(eventLabel(inc.type))}" style="flex:none;width:9px;height:9px;border-radius:50%;background:${incidentColor(inc.type)}"></span>
<span style="font-weight:600;color:var(--text-primary);flex:none">${esc(label)}</span>
${detail}
<span style="margin-left:auto;display:flex;gap:8px;align-items:center;flex:none">
${dur}
<span style="color:var(--text-muted);font-size:11px">${esc(relTime(inc.timestamp, nowSec))}</span>
</span>
</div>`;
}).join('');
}

View file

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

View file

@ -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, "WiFi/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 = 'WiFi/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,
};

View file

@ -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);
}

View file

@ -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 => {

View file

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

View file

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

View file

@ -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, /WiFi\/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');
});

View file

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

View file

@ -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 (WiFi/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