mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 22:03:13 -06:00
* 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>
78 lines
3.7 KiB
JavaScript
78 lines
3.7 KiB
JavaScript
'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,
|
||
};
|