mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 13:53:12 -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>
89 lines
4 KiB
JavaScript
89 lines
4 KiB
JavaScript
// #146 — batched, coalescing writer for device_status_log.
|
|
//
|
|
// Before: every device status transition (online/offline/offline_timeout) did its
|
|
// own synchronous INSERT on the hot path (deviceSocket.logDeviceStatus + the
|
|
// heartbeat checker). Under a reconnect storm that is one row PER flap, which both
|
|
// (a) bloats the table — it reached 1.1M rows in prod — and (b) makes each write
|
|
// slower as the table grows, lagging status processing further. A textbook feedback
|
|
// loop, the connection-layer twin of the OTA loop #144 contained.
|
|
//
|
|
// After: transitions are buffered in memory and flushed on an interval. The buffer
|
|
// keeps only the LATEST (net) status per device, so a device that flaps
|
|
// online->offline->online within a flush window collapses to at most one row — and
|
|
// if it ends where it started, zero rows. devices.status (the dashboard's source of
|
|
// truth) is still updated immediately by the callers; only the AUDIT log is batched,
|
|
// so coalescing storm noise loses nothing the uptime view needs.
|
|
//
|
|
// State is in-memory and resets on restart (like the throttle / breaker buckets).
|
|
|
|
const { db } = require('../db/database');
|
|
const config = require('../config');
|
|
|
|
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, 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.
|
|
// 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: status, reason: reason || null, detail: detail || null });
|
|
}
|
|
|
|
// Write all buffered transitions whose net state differs from what's on disk.
|
|
// Returns the number of rows actually inserted (for tests/observability).
|
|
function flush() {
|
|
if (pending.size === 0) return 0;
|
|
const batch = [];
|
|
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;
|
|
|
|
try {
|
|
const ins = insertStmt();
|
|
const prune = pruneDeviceStmt();
|
|
const ageSec = Math.round(config.statusLogRetentionDays * 86400);
|
|
const writeAll = db.transaction((rows) => {
|
|
for (const [deviceId, status, reason, detail] of rows) {
|
|
ins.run(deviceId, status, reason || null, detail || null);
|
|
lastWritten.set(deviceId, status);
|
|
prune.run(deviceId, ageSec);
|
|
}
|
|
});
|
|
writeAll(batch);
|
|
// #146 Item E: bound lastWritten so it can't grow unbounded over churned device_ids.
|
|
// It only suppresses a redundant consecutive same-status row, so evicting the oldest
|
|
// entries is safe (worst case: one extra row later). Keep it to the newest ~5k ids.
|
|
if (lastWritten.size > 5000) {
|
|
const excess = lastWritten.size - 5000;
|
|
let i = 0;
|
|
for (const k of lastWritten.keys()) { if (i++ >= excess) break; lastWritten.delete(k); }
|
|
}
|
|
return batch.length;
|
|
} catch (_) {
|
|
// table might not exist yet (early boot) — drop silently, same as the old path
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
function start() {
|
|
if (timer) return timer;
|
|
timer = setInterval(flush, config.statusLogFlushMs);
|
|
if (timer.unref) timer.unref(); // don't keep the process alive on the flush timer
|
|
return timer;
|
|
}
|
|
|
|
// Test-only: force a synchronous flush and clear coalescing memory.
|
|
function flushNow() { return flush(); }
|
|
function __reset() { pending.clear(); lastWritten.clear(); }
|
|
|
|
module.exports = { record, flush, flushNow, start, __reset };
|