screentinker/server/lib/incident-classify.js
screentinker 9c70fcc790
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>
2026-07-13 11:26:04 -05:00

78 lines
3.7 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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,
};