feat(socket): Phase 2.3 workspace-scoped dashboard socket rooms + per-command permission gates. Dashboard namespace was previously a flat broadcast - every connected dashboard received every device's status/screenshot/playback events platform-wide (foreign device names + IPs included). Inbound socket commands gated by a legacy admin/superadmin role check that was dead code post-Phase-1 rename.

Fix: at connect, enumerate the user's accessible workspace_ids (direct workspace_members + org_owner/admin paths + platform_admin 'all') via new accessibleWorkspaceIds() helper in lib/tenancy.js; socket.join one room per workspace. All 12 dashboardNs.emit sites across deviceSocket / heartbeat / server.js / devices route / video-walls route now route via dashboardNs.to(workspaceRoom(...)).emit() with the workspace looked up from the relevant device or wall. New lib/socket-rooms.js holds the helpers and breaks a circular dependency (dashboardSocket already requires heartbeat, so heartbeat can't require dashboardSocket).

Inbound 6 commands rewired to canActOnDevice(socket, deviceId, tier): request-screenshot is read tier (workspace_viewer+); remote-touch/key/start/stop and device-command are write tier (workspace_editor+). Platform_admin and org_owner/admin always pass via actingAs. Legacy admin/superadmin branch dropped.

Lifecycle note: workspace-switch already calls window.location.reload (Phase 3 switcher), which forces a fresh socket with updated memberships - no per-emit re-evaluation needed.

Smoke tested with 3 simultaneous socket.io-client connections (switcher-test, swninja, dw5304 platform_admin) + direct canActOnDevice invocation for 6 user/device/tier combinations. All 9 outbound isolation cells and all 6 permission gates pass. Fixture mutation: switcher-test's Field Crew membership flipped from workspace_editor to workspace_viewer to exercise the read/write tier split in one login.
This commit is contained in:
ScreenTinker 2026-05-12 11:34:24 -05:00
parent 56da64d0cd
commit fc29843035
8 changed files with 146 additions and 49 deletions

View file

@ -0,0 +1,34 @@
// Phase 2.3: helpers for resolving socket.io room names per workspace /
// device / wall. Extracted from ws/dashboardSocket.js to break a circular
// dependency: dashboardSocket already requires services/heartbeat, so
// heartbeat can't require dashboardSocket. Everything goes through this
// neutral module instead.
const { db } = require('../db/database');
const ROOM_PREFIX = 'workspace:';
function workspaceRoom(workspaceId) {
return workspaceId ? ROOM_PREFIX + workspaceId : null;
}
function deviceRoom(deviceId) {
if (!deviceId) return null;
const d = db.prepare('SELECT workspace_id FROM devices WHERE id = ?').get(deviceId);
return d?.workspace_id ? workspaceRoom(d.workspace_id) : null;
}
function wallRoom(wallId) {
if (!wallId) return null;
const w = db.prepare('SELECT workspace_id FROM video_walls WHERE id = ?').get(wallId);
return w?.workspace_id ? workspaceRoom(w.workspace_id) : null;
}
// Emit to a workspace room with no-op on missing room. Centralized so callers
// don't have to remember the "skip if null room" guard - silent drop is safer
// than the pre-2.3 platform-wide broadcast.
function emitToWorkspace(ns, room, event, payload) {
if (!room) return;
ns.to(room).emit(event, payload);
}
module.exports = { workspaceRoom, deviceRoom, wallRoom, emitToWorkspace };

View file

@ -138,6 +138,28 @@ function resolveTenancy(req, res, next) {
next(); next();
} }
// Enumerate every workspace_id the given user has any path into:
// - direct workspace_members rows
// - any workspace in an org where they are org_owner / org_admin
// - platform_admin / superadmin: every workspace in the system
// Used by socket.io rooms (Phase 2.3) to scope outbound broadcasts. Also a
// candidate to broaden /me's accessible_workspaces query - currently /me only
// returns direct workspace_members for non-admins, missing the org-admin
// path. Future cleanup tracked in the handoff doc.
function accessibleWorkspaceIds(userId, role) {
if (!userId) return [];
if (role === 'platform_admin' || role === 'superadmin') {
return db.prepare('SELECT id FROM workspaces').all().map(r => r.id);
}
return db.prepare(`
SELECT workspace_id AS id FROM workspace_members WHERE user_id = ?
UNION
SELECT w.id FROM workspaces w
JOIN organization_members om ON om.organization_id = w.organization_id
WHERE om.user_id = ? AND om.role IN ('org_owner', 'org_admin')
`).all(userId, userId).map(r => r.id);
}
module.exports = { module.exports = {
resolveTenancy, resolveTenancy,
// Exported for testing / direct use by routes that need ad-hoc checks. // Exported for testing / direct use by routes that need ad-hoc checks.
@ -145,4 +167,5 @@ module.exports = {
membershipOf, membershipOf,
orgMembershipOf, orgMembershipOf,
firstAccessibleWorkspace, firstAccessibleWorkspace,
accessibleWorkspaceIds,
}; };

View file

@ -175,10 +175,13 @@ router.delete('/:id', (req, res) => {
db.prepare('DELETE FROM video_wall_devices WHERE device_id = ?').run(req.params.id); db.prepare('DELETE FROM video_wall_devices WHERE device_id = ?').run(req.params.id);
db.prepare('DELETE FROM devices WHERE id = ?').run(req.params.id); db.prepare('DELETE FROM devices WHERE id = ?').run(req.params.id);
// Notify dashboard in real-time // Notify dashboard in real-time. Phase 2.3: scope to the device's
// (now-deleted but still-known) workspace room. `device.workspace_id`
// came from checkDeviceOwnership() above.
const io = req.app.get('io'); const io = req.app.get('io');
if (io) { if (io) {
io.of('/dashboard').emit('dashboard:device-removed', { device_id: req.params.id }); const { workspaceRoom, emitToWorkspace } = require('../lib/socket-rooms');
emitToWorkspace(io.of('/dashboard'), workspaceRoom(device.workspace_id), 'dashboard:device-removed', { device_id: req.params.id });
} }
res.json({ success: true }); res.json({ success: true });

View file

@ -53,13 +53,14 @@ router.get('/', (req, res) => {
res.json(walls); res.json(walls);
}); });
// Notify dashboard clients to re-fetch walls/devices. Re-fetches re-apply // Notify dashboard clients to re-fetch walls/devices. Phase 2.3: scoped to
// per-user visibility filtering, so a broadcast is safe. // the wall's workspace room so other tenants don't get a stray refresh ping.
function notifyDashboards(req) { function notifyDashboards(req, workspaceId) {
try { try {
const io = req.app.get('io'); const io = req.app.get('io');
if (!io) return; if (!io || !workspaceId) return;
io.of('/dashboard').emit('dashboard:wall-changed'); const { workspaceRoom, emitToWorkspace } = require('../lib/socket-rooms');
emitToWorkspace(io.of('/dashboard'), workspaceRoom(workspaceId), 'dashboard:wall-changed', null);
} catch (e) { /* silent */ } } catch (e) { /* silent */ }
} }
@ -125,7 +126,7 @@ router.post('/', (req, res) => {
bezel_h_mm || 0, bezel_v_mm || 0, playlist_id || null); bezel_h_mm || 0, bezel_v_mm || 0, playlist_id || null);
const wall = loadWallWithDevices(id); const wall = loadWallWithDevices(id);
notifyDashboards(req); notifyDashboards(req, req.workspaceId);
res.status(201).json(wall); res.status(201).json(wall);
}); });
@ -181,13 +182,14 @@ router.put('/:id', requireWallWrite, (req, res) => {
} }
pushToWallMembers(req, req.params.id); pushToWallMembers(req, req.params.id);
notifyDashboards(req); notifyDashboards(req, req.wall.workspace_id);
res.json(loadWallWithDevices(req.params.id)); res.json(loadWallWithDevices(req.params.id));
}); });
// Delete wall — clear playlists + wall_id on every former member (matches // Delete wall — clear playlists + wall_id on every former member (matches
// group-dissolve semantics: leaving the wall returns devices to ungrouped). // group-dissolve semantics: leaving the wall returns devices to ungrouped).
router.delete('/:id', requireWallWrite, (req, res) => { router.delete('/:id', requireWallWrite, (req, res) => {
const wallWorkspaceId = req.wall.workspace_id; // capture before the DELETE
const members = db.prepare('SELECT device_id FROM video_wall_devices WHERE wall_id = ?').all(req.params.id); const members = db.prepare('SELECT device_id FROM video_wall_devices WHERE wall_id = ?').all(req.params.id);
const tx = db.transaction(() => { const tx = db.transaction(() => {
db.prepare("UPDATE devices SET wall_id = NULL, playlist_id = NULL WHERE wall_id = ?").run(req.params.id); db.prepare("UPDATE devices SET wall_id = NULL, playlist_id = NULL WHERE wall_id = ?").run(req.params.id);
@ -198,7 +200,7 @@ router.delete('/:id', requireWallWrite, (req, res) => {
// Push fresh (now wall-less, playlist-less) payloads to ex-members so they // Push fresh (now wall-less, playlist-less) payloads to ex-members so they
// exit wall mode and clear content immediately. // exit wall mode and clear content immediately.
for (const m of members) pushWallPayloadToDevice(req, m.device_id); for (const m of members) pushWallPayloadToDevice(req, m.device_id);
notifyDashboards(req); notifyDashboards(req, wallWorkspaceId);
res.json({ success: true }); res.json({ success: true });
}); });
@ -276,7 +278,7 @@ router.put('/:id/devices', requireWallWrite, (req, res) => {
// ex-members so they exit wall mode. // ex-members so they exit wall mode.
for (const id of removedIds) pushWallPayloadToDevice(req, id); for (const id of removedIds) pushWallPayloadToDevice(req, id);
pushToWallMembers(req, req.params.id); pushToWallMembers(req, req.params.id);
notifyDashboards(req); notifyDashboards(req, req.wall.workspace_id);
res.json(loadWallWithDevices(req.params.id)); res.json(loadWallWithDevices(req.params.id));
}); });

View file

@ -467,7 +467,9 @@ app.post('/api/provision/pair', requireAuth, resolveTenancy, checkDeviceLimit, (
deviceNs.to(device.id).emit('device:paired', { device_id: device.id, name: deviceName }); deviceNs.to(device.id).emit('device:paired', { device_id: device.id, name: deviceName });
const updated = db.prepare('SELECT * FROM devices WHERE id = ?').get(device.id); const updated = db.prepare('SELECT * FROM devices WHERE id = ?').get(device.id);
dashboardNs.emit('dashboard:device-added', updated); // Phase 2.3: scope to the workspace the device was just claimed into.
const { workspaceRoom, emitToWorkspace } = require('./lib/socket-rooms');
emitToWorkspace(dashboardNs, workspaceRoom(updated.workspace_id), 'dashboard:device-added', updated);
res.json(updated); res.json(updated);
}); });

View file

@ -1,5 +1,6 @@
const { db } = require('../db/database'); const { db } = require('../db/database');
const config = require('../config'); const config = require('../config');
const { deviceRoom, emitToWorkspace } = require('../lib/socket-rooms');
// Track connected device sockets: deviceId -> { socketId, lastHeartbeat } // Track connected device sockets: deviceId -> { socketId, lastHeartbeat }
const deviceConnections = new Map(); const deviceConnections = new Map();
@ -21,8 +22,8 @@ function startHeartbeatChecker(io) {
.run(device.id); .run(device.id);
deviceConnections.delete(device.id); deviceConnections.delete(device.id);
// Notify dashboard // Notify dashboard (workspace-scoped via the device's room).
dashboardNs.emit('dashboard:device-status', { emitToWorkspace(dashboardNs, deviceRoom(device.id), 'dashboard:device-status', {
device_id: device.id, device_id: device.id,
status: 'offline', status: 'offline',
telemetry: null telemetry: null

View file

@ -1,11 +1,39 @@
const heartbeat = require('../services/heartbeat'); const heartbeat = require('../services/heartbeat');
const { verifyToken } = require('../middleware/auth'); const { verifyToken } = require('../middleware/auth');
const { db } = require('../db/database');
const { accessContext, accessibleWorkspaceIds } = require('../lib/tenancy');
const { workspaceRoom } = require('../lib/socket-rooms');
// Phase 2.3: workspace-scoped socket rooms + per-command permission gates.
// Replaces the previous flat dashboardNs.emit broadcast (which leaked every
// device's status/screenshot/playback events to every connected dashboard)
// and the legacy admin/superadmin role bypass (dead code post-Phase-1
// rename - admin -> user, superadmin -> platform_admin).
//
// On connect: enumerate the user's accessible workspace_ids and socket.join
// a room per workspace. Outbound broadcasts route via dashboardNs.to(room).
// Inbound commands check permission against the target device's workspace.
// Permission gate for inbound socket commands. Read tier = workspace_viewer+;
// write tier = workspace_editor+. Platform_admin and org_owner/admin always
// pass via actingAs.
function canActOnDevice(socket, deviceId, tier /* 'read' | 'write' */) {
const device = db.prepare('SELECT workspace_id FROM devices WHERE id = ?').get(deviceId);
if (!device || !device.workspace_id) return false;
const ws = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(device.workspace_id);
if (!ws) return false;
const ctx = accessContext(socket.userId, socket.userRole, ws);
if (!ctx) return false;
if (ctx.actingAs) return true; // platform_admin or org admin
if (tier === 'read') return !!ctx.workspaceRole; // viewer/editor/admin all OK
// write tier: workspace_editor or workspace_admin
return ctx.workspaceRole === 'workspace_editor' || ctx.workspaceRole === 'workspace_admin';
}
module.exports = function setupDashboardSocket(io) { module.exports = function setupDashboardSocket(io) {
const dashboardNs = io.of('/dashboard'); const dashboardNs = io.of('/dashboard');
const deviceNs = io.of('/device'); const deviceNs = io.of('/device');
// Authenticate dashboard WebSocket connections
dashboardNs.use((socket, next) => { dashboardNs.use((socket, next) => {
const token = socket.handshake.auth?.token; const token = socket.handshake.auth?.token;
if (!token) return next(new Error('Authentication required')); if (!token) return next(new Error('Authentication required'));
@ -19,65 +47,54 @@ module.exports = function setupDashboardSocket(io) {
} }
}); });
// Verify the user owns the device or is admin/superadmin
function checkDeviceOwnership(socket, device_id) {
if (['admin', 'superadmin'].includes(socket.userRole)) return true;
const { db } = require('../db/database');
const device = db.prepare('SELECT user_id FROM devices WHERE id = ?').get(device_id);
if (!device) return false;
return device.user_id === socket.userId;
}
dashboardNs.on('connection', (socket) => { dashboardNs.on('connection', (socket) => {
console.log(`Dashboard client connected: ${socket.id} (user: ${socket.userId})`); // Note on workspace-switch lifecycle: the switcher (Phase 3 MVP) calls
// window.location.reload() after switching, which forces a new socket
// connection with fresh JWT claims. So workspace memberships are
// re-evaluated at connect time and we don't need to re-evaluate per-emit.
const wsIds = accessibleWorkspaceIds(socket.userId, socket.userRole);
for (const wsId of wsIds) socket.join(workspaceRoom(wsId));
console.log(`Dashboard client connected: ${socket.id} (user: ${socket.userId}, rooms: ${wsIds.length})`);
// Request screenshot from a device
socket.on('dashboard:request-screenshot', (data) => { socket.on('dashboard:request-screenshot', (data) => {
const { device_id } = data; const { device_id } = data;
if (!checkDeviceOwnership(socket, device_id)) return; if (!canActOnDevice(socket, device_id, 'read')) return;
const conn = heartbeat.getConnection(device_id); const conn = heartbeat.getConnection(device_id);
if (conn) { if (conn) deviceNs.to(device_id).emit('device:screenshot-request', {});
deviceNs.to(device_id).emit('device:screenshot-request', {});
}
}); });
// Remote control: touch forwarding
socket.on('dashboard:remote-touch', (data) => { socket.on('dashboard:remote-touch', (data) => {
const { device_id, x, y, action } = data; const { device_id, x, y, action } = data;
if (!checkDeviceOwnership(socket, device_id)) return; if (!canActOnDevice(socket, device_id, 'write')) return;
deviceNs.to(device_id).emit('device:remote-touch', { x, y, action }); deviceNs.to(device_id).emit('device:remote-touch', { x, y, action });
}); });
// Remote control: key forwarding
socket.on('dashboard:remote-key', (data) => { socket.on('dashboard:remote-key', (data) => {
const { device_id, keycode } = data; const { device_id, keycode } = data;
if (!checkDeviceOwnership(socket, device_id)) return; if (!canActOnDevice(socket, device_id, 'write')) return;
console.log(`Remote key: ${keycode} -> ${device_id}`); console.log(`Remote key: ${keycode} -> ${device_id}`);
deviceNs.to(device_id).emit('device:remote-key', { keycode }); deviceNs.to(device_id).emit('device:remote-key', { keycode });
}); });
// Start remote screenshot streaming
socket.on('dashboard:remote-start', (data) => { socket.on('dashboard:remote-start', (data) => {
const { device_id } = data; const { device_id } = data;
if (!checkDeviceOwnership(socket, device_id)) return; if (!canActOnDevice(socket, device_id, 'write')) return;
const room = deviceNs.adapter.rooms.get(device_id); const room = deviceNs.adapter.rooms.get(device_id);
console.log(`Remote start for ${device_id}, room has ${room?.size || 0} socket(s)`); console.log(`Remote start for ${device_id}, room has ${room?.size || 0} socket(s)`);
deviceNs.to(device_id).emit('device:remote-start', {}); deviceNs.to(device_id).emit('device:remote-start', {});
console.log(`Remote session started for device ${device_id}`); console.log(`Remote session started for device ${device_id}`);
}); });
// Stop remote screenshot streaming
socket.on('dashboard:remote-stop', (data) => { socket.on('dashboard:remote-stop', (data) => {
const { device_id } = data; const { device_id } = data;
if (!checkDeviceOwnership(socket, device_id)) return; if (!canActOnDevice(socket, device_id, 'write')) return;
deviceNs.to(device_id).emit('device:remote-stop', {}); deviceNs.to(device_id).emit('device:remote-stop', {});
console.log(`Remote session stopped for device ${device_id}`); console.log(`Remote session stopped for device ${device_id}`);
}); });
// Send command to device (reboot, refresh, etc.)
socket.on('dashboard:device-command', (data) => { socket.on('dashboard:device-command', (data) => {
const { device_id, type, payload } = data; const { device_id, type, payload } = data;
if (!checkDeviceOwnership(socket, device_id)) return; if (!canActOnDevice(socket, device_id, 'write')) return;
deviceNs.to(device_id).emit('device:command', { type, payload }); deviceNs.to(device_id).emit('device:command', { type, payload });
console.log(`Command sent to device ${device_id}: ${type}`); console.log(`Command sent to device ${device_id}: ${type}`);
}); });
@ -89,3 +106,4 @@ module.exports = function setupDashboardSocket(io) {
return dashboardNs; return dashboardNs;
}; };

View file

@ -6,6 +6,13 @@ const { db, pruneTelemetry, pruneScreenshots } = require('../db/database');
const config = require('../config'); const config = require('../config');
const heartbeat = require('../services/heartbeat'); const heartbeat = require('../services/heartbeat');
const { getUserPlan, getUserDeviceCount } = require('../middleware/subscription'); const { getUserPlan, getUserDeviceCount } = require('../middleware/subscription');
// Phase 2.3: deviceRoom() resolves a device_id to its workspace room so
// dashboardNs.emit can be scoped instead of broadcast platform-wide.
const { deviceRoom, emitToWorkspace } = require('../lib/socket-rooms');
function emitToDeviceWorkspace(dashboardNs, deviceId, event, payload) {
emitToWorkspace(dashboardNs, deviceRoom(deviceId), event, payload);
}
// In-memory store for latest screenshot per device (avoids disk writes during streaming) // In-memory store for latest screenshot per device (avoids disk writes during streaming)
let lastScreenshots = {}; let lastScreenshots = {};
@ -247,7 +254,7 @@ module.exports = function setupDeviceSocket(io) {
heartbeat.registerConnection(existing.device_id, socket.id); heartbeat.registerConnection(existing.device_id, socket.id);
socket.join(existing.device_id); socket.join(existing.device_id);
logDeviceStatus(existing.device_id, 'online'); logDeviceStatus(existing.device_id, 'online');
dashboardNs.emit('dashboard:device-status', { device_id: existing.device_id, status: 'online' }); emitToDeviceWorkspace(dashboardNs, existing.device_id, 'dashboard:device-status', { device_id: existing.device_id, status: 'online' });
// Send playlist // Send playlist
const access = checkDeviceAccess(existing.device_id); const access = checkDeviceAccess(existing.device_id);
if (!access.allowed) { if (!access.allowed) {
@ -343,7 +350,7 @@ module.exports = function setupDeviceSocket(io) {
socket.emit('device:playlist-update', buildPlaylistPayload(device_id)); socket.emit('device:playlist-update', buildPlaylistPayload(device_id));
} }
dashboardNs.emit('dashboard:device-status', { device_id, status: 'online' }); emitToDeviceWorkspace(dashboardNs, device_id, 'dashboard:device-status', { device_id, status: 'online' });
console.log(`Device reconnected: ${device_id}`); console.log(`Device reconnected: ${device_id}`);
return; return;
} }
@ -376,7 +383,12 @@ module.exports = function setupDeviceSocket(io) {
socket.join(id); socket.join(id);
socket.emit('device:registered', { device_id: id, device_token: newToken, status: 'provisioning' }); socket.emit('device:registered', { device_id: id, device_token: newToken, status: 'provisioning' });
dashboardNs.emit('dashboard:device-added', db.prepare('SELECT * FROM devices WHERE id = ?').get(id)); // Newly-provisioned devices have no workspace_id yet (they'll get one
// on pair claim). emitToDeviceWorkspace silently drops when there's no
// workspace; that's safer than the previous platform-wide broadcast.
// Dashboards refresh /api/devices/unassigned on poll for the
// platform_admin pairing view.
emitToDeviceWorkspace(dashboardNs, id, 'dashboard:device-added', db.prepare('SELECT * FROM devices WHERE id = ?').get(id));
console.log(`New device registered: ${id} with pairing code: ${pairing_code}`); console.log(`New device registered: ${id} with pairing code: ${pairing_code}`);
} }
}); });
@ -422,7 +434,7 @@ module.exports = function setupDeviceSocket(io) {
); );
pruneTelemetry(device_id); pruneTelemetry(device_id);
dashboardNs.emit('dashboard:device-status', { emitToDeviceWorkspace(dashboardNs, device_id, 'dashboard:device-status', {
device_id, device_id,
status: 'online', status: 'online',
telemetry telemetry
@ -444,7 +456,7 @@ module.exports = function setupDeviceSocket(io) {
// Relay directly to dashboard - no disk write // Relay directly to dashboard - no disk write
try { try {
dashboardNs.emit('dashboard:screenshot-ready', { emitToDeviceWorkspace(dashboardNs, device_id, 'dashboard:screenshot-ready', {
device_id, device_id,
image_data: `data:image/jpeg;base64,${image_b64}`, image_data: `data:image/jpeg;base64,${image_b64}`,
timestamp: Date.now() timestamp: Date.now()
@ -460,13 +472,15 @@ module.exports = function setupDeviceSocket(io) {
const { device_id, content_id, status } = data; const { device_id, content_id, status } = data;
if (device_id !== currentDeviceId) return; if (device_id !== currentDeviceId) return;
console.log(`Device ${device_id} content ${content_id}: ${status}`); console.log(`Device ${device_id} content ${content_id}: ${status}`);
dashboardNs.emit('dashboard:content-ack', { device_id, content_id, status }); emitToDeviceWorkspace(dashboardNs, device_id, 'dashboard:content-ack', { device_id, content_id, status });
}); });
// Playback state update // Playback state update
socket.on('device:playback-state', (data) => { socket.on('device:playback-state', (data) => {
if (!requireDeviceAuth()) return; if (!requireDeviceAuth()) return;
dashboardNs.emit('dashboard:playback-state', data); // currentDeviceId is the authenticated device for this socket; use it
// for the workspace lookup since data may not carry device_id consistently.
emitToDeviceWorkspace(dashboardNs, currentDeviceId, 'dashboard:playback-state', data);
}); });
// Play event logging (proof-of-play) // Play event logging (proof-of-play)
@ -482,7 +496,7 @@ module.exports = function setupDeviceSocket(io) {
`).run(device_id, content_id || null, zone_id || null, content_name || 'Unknown'); `).run(device_id, content_id || null, zone_id || null, content_name || 'Unknown');
// Forward to dashboard so it can render a per-device progress bar. // Forward to dashboard so it can render a per-device progress bar.
// Server-side timestamp avoids clock-skew between player and dashboard. // Server-side timestamp avoids clock-skew between player and dashboard.
dashboardNs.emit('dashboard:playback-progress', { emitToDeviceWorkspace(dashboardNs, device_id, 'dashboard:playback-progress', {
device_id, device_id,
content_id: content_id || null, content_id: content_id || null,
content_name: content_name || null, content_name: content_name || null,
@ -561,7 +575,7 @@ module.exports = function setupDeviceSocket(io) {
.run(currentDeviceId); .run(currentDeviceId);
heartbeat.removeConnection(currentDeviceId); heartbeat.removeConnection(currentDeviceId);
logDeviceStatus(currentDeviceId, 'offline'); logDeviceStatus(currentDeviceId, 'offline');
dashboardNs.emit('dashboard:device-status', { device_id: currentDeviceId, status: 'offline' }); emitToDeviceWorkspace(dashboardNs, currentDeviceId, 'dashboard:device-status', { device_id: currentDeviceId, status: 'offline' });
// If this device was leading a wall, reassign leadership to the next // If this device was leading a wall, reassign leadership to the next
// online member so playback stays driven. Without this the wall freezes // online member so playback stays driven. Without this the wall freezes