mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 22:03:13 -06:00
Put Members in the nav, reveal titles on touch, and stop a stale heartbeat killing a socket
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
This commit is contained in:
parent
618af0811a
commit
7747d7e051
|
|
@ -120,6 +120,16 @@
|
|||
</svg>
|
||||
<span>Teams</span>
|
||||
</a></li>
|
||||
<!-- Members: inviting a colleague is a core action, and the only route to it used to be
|
||||
an unlabelled icon beside the workspace name. #/members resolves to the ACTIVE
|
||||
workspace so this link can stay static. -->
|
||||
<li><a href="#/members" class="nav-link" data-view="members">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/>
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>
|
||||
</svg>
|
||||
<span>Members</span>
|
||||
</a></li>
|
||||
<li><a href="#/help" class="nav-link" data-view="help">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
112
server/test/heartbeat-deleted-device.test.js
Normal file
112
server/test/heartbeat-deleted-device.test.js
Normal file
|
|
@ -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();
|
||||
});
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue