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>
155 lines
7.1 KiB
JavaScript
155 lines
7.1 KiB
JavaScript
'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');
|
||
});
|