From 7747d7e051019835fecbc0c7b2780e00d83e9736 Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Tue, 28 Jul 2026 20:00:35 -0500 Subject: [PATCH] Put Members in the nav, reveal titles on touch, and stop a stale heartbeat killing a socket MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three loose ends from the interface review. Inviting a colleague is a core action and had no entry in the navigation at all. The only route was an unlabelled icon beside the workspace name, or typing the URL. There is now a Members item, translated, which resolves to the active workspace so the static link needs no id. The Teams entry it sits near stays hidden, since that feature is still switched off. A native title= is hover-only, so the icon-only buttons — rename a wall, remove a device from one, manage members — explained themselves on a desktop and said nothing on a touchscreen. Long-pressing one now shows its label. The text was already there and already translated; it simply had no way to reach a finger. The last one is the bug that took a real screen dark. A device row can vanish while its socket is still heartbeating, and the telemetry insert then failed a foreign key. That throw was fatal in a way that is hard to guess: the safe-socket wrapper reads a throwing handler as a broken one and disconnects the socket server-side, and socket.io deliberately does not retry that kind of disconnect — so the player sat doing nothing until a person reloaded it. A heartbeat for a device that no longer exists is an ordinary race, not a fault worth ending a connection over; the write is skipped and the register path answers unpaired, which is the reply that actually helps the client recover. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL --- frontend/index.html | 10 ++ frontend/js/app.js | 28 +++++ frontend/js/i18n/de.js | 1 + frontend/js/i18n/en.js | 1 + frontend/js/i18n/es.js | 1 + frontend/js/i18n/fr.js | 1 + frontend/js/i18n/it.js | 1 + frontend/js/i18n/pt.js | 1 + server/test/heartbeat-deleted-device.test.js | 112 +++++++++++++++++++ server/ws/deviceSocket.js | 9 +- 10 files changed, 164 insertions(+), 1 deletion(-) create mode 100644 server/test/heartbeat-deleted-device.test.js diff --git a/frontend/index.html b/frontend/index.html index ec49af1..9b592bf 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -120,6 +120,16 @@ Teams + +
  • + + + + + Members +
  • diff --git a/frontend/js/app.js b/frontend/js/app.js index 8c1feb9..5cf312a 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -170,6 +170,7 @@ const NAV_LABEL_KEYS = { designer: 'nav.designer', activity: 'nav.activity', teams: 'nav.teams', + members: 'nav.members', help: 'nav.help', settings: 'nav.settings', billing: 'nav.subscription', @@ -266,6 +267,21 @@ function enableHelpTips() { }); if (tipsBound) return; tipsBound = true; + // A native title= is hover-only too, so icon-only buttons (rename a wall, remove a device + // from one, manage members) explain themselves on a desktop and say nothing at all on a + // touchscreen. Long-press one and show its label as a toast — the text already exists and is + // translated, it simply had no way to reach a finger. + let pressTimer = null; + const cancelPress = () => { clearTimeout(pressTimer); pressTimer = null; }; + document.addEventListener('touchstart', (e) => { + const el = e.target.closest('[title]'); + if (!el) return; + const label = el.getAttribute('title'); + if (!label) return; + pressTimer = setTimeout(() => showToast(label, 'info'), 500); + }, { passive: true }); + ['touchend', 'touchmove', 'touchcancel'].forEach(ev => + document.addEventListener(ev, cancelPress, { passive: true })); // Views render from ~20 call sites and modals appear later still, so watch the DOM rather // than trying to call this after each one — a tip added by a route nobody remembered to hook // would otherwise be keyboard-unreachable again. @@ -489,6 +505,18 @@ function route() { } else if (hash === '#/teams' || hash.startsWith('#/team/')) { currentView = teams; teams.render(app); + } else if (hash === '#/members') { + // The static nav link cannot know the workspace id, so resolve it here from the signed-in + // user. Falls back to the first accessible workspace, and to the dashboard when there is + // none at all — better than rendering a members page for nothing. + // /me is cached in localStorage by refreshCurrentUser(); there is no in-memory copy. + let me = null; + try { me = JSON.parse(localStorage.getItem('user') || 'null'); } catch (_) { me = null; } + const activeWs = me?.current_workspace_id + || (Array.isArray(me?.accessible_workspaces) && me.accessible_workspaces[0]?.id); + if (!activeWs) { window.location.hash = '#/'; return; } + currentView = workspaceMembers; + workspaceMembers.render(app, activeWs); } else if (hash.startsWith('#/workspace/') && hash.includes('/members')) { const wsId = hash.split('#/workspace/')[1].split('/')[0]; currentView = workspaceMembers; diff --git a/frontend/js/i18n/de.js b/frontend/js/i18n/de.js index d8c1a11..35c3f19 100644 --- a/frontend/js/i18n/de.js +++ b/frontend/js/i18n/de.js @@ -2,6 +2,7 @@ // standard for B2B software in DACH). Native review recommended before // publicizing as fully supported. export default { + 'nav.members': 'Mitglieder', 'common.close': 'Schließen', 'switcher.manage_members': 'Mitglieder verwalten', 'switcher.rename': 'Workspace umbenennen', diff --git a/frontend/js/i18n/en.js b/frontend/js/i18n/en.js index 6a9e462..cd4b087 100644 --- a/frontend/js/i18n/en.js +++ b/frontend/js/i18n/en.js @@ -1,6 +1,7 @@ // English translations. This file is the source of truth for keys — // every other locale should mirror its keys (or fall back to en). export default { + 'nav.members': 'Members', 'common.close': 'Close', 'switcher.manage_members': 'Manage members', 'switcher.rename': 'Rename workspace', diff --git a/frontend/js/i18n/es.js b/frontend/js/i18n/es.js index b1fde20..c933749 100644 --- a/frontend/js/i18n/es.js +++ b/frontend/js/i18n/es.js @@ -1,6 +1,7 @@ // Spanish translations. Reviewed for UI register (informal tú). // Native review still recommended before publicizing as fully supported. export default { + 'nav.members': 'Miembros', 'common.close': 'Cerrar', 'switcher.manage_members': 'Gestionar miembros', 'switcher.rename': 'Renombrar espacio de trabajo', diff --git a/frontend/js/i18n/fr.js b/frontend/js/i18n/fr.js index e4a8f8c..963b30a 100644 --- a/frontend/js/i18n/fr.js +++ b/frontend/js/i18n/fr.js @@ -2,6 +2,7 @@ // standard for software UIs in France; tu would feel underdressed for a B2B tool). // Native review recommended before publicizing as fully supported. export default { + 'nav.members': 'Membres', 'common.close': 'Fermer', 'switcher.manage_members': 'Gérer les membres', 'switcher.rename': 'Renommer l\'espace de travail', diff --git a/frontend/js/i18n/it.js b/frontend/js/i18n/it.js index 09dd9bf..32894d1 100644 --- a/frontend/js/i18n/it.js +++ b/frontend/js/i18n/it.js @@ -1,6 +1,7 @@ // Italian translations. This file is the source of truth for keys — // every other locale should mirror its keys (or fall back to en). export default { + 'nav.members': 'Membri', 'common.close': 'Chiudi', 'switcher.manage_members': 'Gestisci membri', 'switcher.rename': 'Rinomina spazio di lavoro', diff --git a/frontend/js/i18n/pt.js b/frontend/js/i18n/pt.js index 64bb1a6..9559f72 100644 --- a/frontend/js/i18n/pt.js +++ b/frontend/js/i18n/pt.js @@ -2,6 +2,7 @@ // Reviewed for UI register (informal você). Native review recommended before // publicizing as fully supported. export default { + 'nav.members': 'Membros', 'common.close': 'Fechar', 'switcher.manage_members': 'Gerir membros', 'switcher.rename': 'Mudar o nome da área de trabalho', diff --git a/server/test/heartbeat-deleted-device.test.js b/server/test/heartbeat-deleted-device.test.js new file mode 100644 index 0000000..08bd2ea --- /dev/null +++ b/server/test/heartbeat-deleted-device.test.js @@ -0,0 +1,112 @@ +'use strict'; + +// A device row can vanish while its socket is still open — an operator deletes it, or a re-pair +// replaces it. The next heartbeat carrying telemetry then failed the foreign key on +// device_telemetry, and that throw was fatal in a way nobody would guess: +// +// the safe-socket wrapper treats a throwing handler as a broken one and disconnects the socket +// SERVER-side -> socket.io does NOT auto-retry an 'io server disconnect' -> the player +// sits there doing nothing until a human reloads the page. +// +// That is not theoretical. Deleting a device row mid-session took a real screen dark, and it +// needed someone at the other end to press reload. A heartbeat for a device that no longer +// exists is an ordinary race, not a fault worth ending a connection over. + +const { test, before, after } = require('node:test'); +const assert = require('node:assert/strict'); +const { spawn } = require('node:child_process'); +const path = require('node:path'); +const os = require('node:os'); +const fs = require('node:fs'); +const crypto = require('node:crypto'); +const Database = require('better-sqlite3'); +const ioClient = require('../node_modules/socket.io-client'); + +const { freePort } = require('./helpers/free-port'); +let PORT, BASE, proc, db; +const DATA_DIR = path.join(os.tmpdir(), 'st-hbdel-' + crypto.randomBytes(4).toString('hex')); +const LOG = path.join(os.tmpdir(), 'st-hbdel-' + crypto.randomBytes(4).toString('hex') + '.log'); + +const TELEMETRY = { battery_level: 80, battery_charging: true, storage_free_mb: 100, storage_total_mb: 200, + ram_free_mb: 50, ram_total_mb: 100, cpu_usage: 5, wifi_ssid: 'x', wifi_rssi: -50, uptime_seconds: 60 }; + +before(async () => { + PORT = await freePort(); + BASE = `http://127.0.0.1:${PORT}`; + const logFd = fs.openSync(LOG, 'w'); + proc = spawn('node', ['server.js'], { + cwd: path.join(__dirname, '..'), + env: { ...process.env, DATA_DIR, SELF_HOSTED: 'true', PORT: String(PORT), NODE_ENV: 'test' }, + stdio: ['ignore', logFd, logFd], + }); + let up = false; + for (let i = 0; i < 80; i++) { + try { const r = await fetch(BASE + '/api/status'); if (r.ok) { up = true; break; } } catch { /* */ } + await new Promise(r => setTimeout(r, 250)); + } + if (!up) throw new Error('server did not boot:\n' + fs.readFileSync(LOG, 'utf8').slice(-2000)); + db = new Database(path.join(DATA_DIR, 'db', 'remote_display.db')); +}); +after(() => { try { db && db.close(); } catch { /* */ } try { proc.kill('SIGKILL'); } catch { /* */ } }); + +function makeDevice() { + const id = crypto.randomUUID(); + const token = crypto.randomBytes(32).toString('hex'); + db.prepare(`INSERT INTO devices (id, name, status, device_token, created_at) + VALUES (?, 'HB', 'online', ?, strftime('%s','now'))`).run(id, token); + return { id, token }; +} +function connect(dev) { + return new Promise((resolve, reject) => { + const s = ioClient(BASE + '/device', { transports: ['websocket'], reconnection: false }); + s.on('connect', () => s.emit('device:register', { device_id: dev.id, device_token: dev.token })); + s.on('device:registered', () => resolve(s)); + s.on('device:auth-error', (e) => reject(new Error(e && e.error))); + setTimeout(() => reject(new Error('register timeout')), 10000); + }); +} +const wait = (ms) => new Promise(r => setTimeout(r, ms)); + +test('a heartbeat for a device deleted mid-session does NOT drop the socket', async () => { + const dev = makeDevice(); + const s = await connect(dev); + s.emit('device:heartbeat', { device_id: dev.id, telemetry: TELEMETRY }); + await wait(400); + assert.equal(s.connected, true, 'sanity: healthy heartbeat keeps the socket'); + + // The row disappears underneath the live socket. + db.prepare('DELETE FROM devices WHERE id = ?').run(dev.id); + s.emit('device:heartbeat', { device_id: dev.id, telemetry: TELEMETRY }); + await wait(700); + + assert.equal(s.connected, true, + 'the socket survives — before this, the FK threw, the server disconnected it, and socket.io ' + + 'would not retry, so the screen stayed dark until someone reloaded it'); + s.close(); +}); + +test('and the server stays up rather than logging a foreign-key failure', async () => { + const log = fs.readFileSync(LOG, 'utf8'); + assert.doesNotMatch(log, /FOREIGN KEY constraint failed/, + 'no constraint error was raised at all'); + assert.doesNotMatch(log, /handler threw for/, 'and no handler was reported as broken'); +}); + +test('telemetry for a LIVE device is still recorded', async () => { + const dev = makeDevice(); + const s = await connect(dev); + s.emit('device:heartbeat', { device_id: dev.id, telemetry: TELEMETRY }); + await wait(600); + const n = db.prepare('SELECT COUNT(*) c FROM device_telemetry WHERE device_id = ?').get(dev.id).c; + assert.ok(n >= 1, 'the guard must not have turned telemetry off for everyone'); + s.close(); +}); + +test('a heartbeat with no telemetry is unaffected', async () => { + const dev = makeDevice(); + const s = await connect(dev); + s.emit('device:heartbeat', { device_id: dev.id }); + await wait(400); + assert.equal(s.connected, true); + s.close(); +}); diff --git a/server/ws/deviceSocket.js b/server/ws/deviceSocket.js index a87cb5a..5b367ba 100644 --- a/server/ws/deviceSocket.js +++ b/server/ws/deviceSocket.js @@ -975,7 +975,14 @@ module.exports = function setupDeviceSocket(io) { db.prepare("UPDATE devices SET status = 'online', last_heartbeat = strftime('%s','now'), updated_at = strftime('%s','now') WHERE id = ?") .run(device_id); - if (telemetry) { + // A device row can vanish mid-session — deleted by an operator, or replaced by a re-pair — + // while its socket is still heartbeating. The telemetry insert then fails the foreign key, + // the safe-socket wrapper reads that throw as a broken handler and disconnects the socket + // SERVER-side, and socket.io deliberately does not retry that kind of disconnect. The panel + // goes dark until a human reloads it; that happened to a live screen. A heartbeat for a + // device that no longer exists is not worth killing a connection over — skip the write and + // let the register path answer with unpaired, which is what actually helps it recover. + if (telemetry && deviceExists(device_id)) { db.prepare(` INSERT INTO device_telemetry (device_id, battery_level, battery_charging, storage_free_mb, storage_total_mb, ram_free_mb, ram_total_mb, cpu_usage, wifi_ssid, wifi_rssi, uptime_seconds)