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)