From 726a7fedc749b3d1bdbefbabf6bc04dd8628a563 Mon Sep 17 00:00:00 2001 From: Mark Rapson Date: Sun, 25 Oct 2026 02:06:06 +0000 Subject: [PATCH] Update connection status indicator --- CHANGELOG.md | 1 + src/web/bootstrap.js | 1 + src/web/lib/dashboard-state.js | 2 + src/web/public/css/theme-custom.css | 37 +---- src/web/public/js/shared/system-status.js | 99 +++++++++--- src/web/views/shared/layout.hbs | 2 +- test/dashboard-state.test.js | 2 + test/system-status.test.js | 174 +++++++++++++++++++++- 8 files changed, 254 insertions(+), 64 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dbebbd1..08f9b05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ All notable changes to this project will be documented in this file. ### Changed +- The shared connection indicator names the connected path (Bridge or Player), shows the connected player count, uses blue when the service is online with no players, and uses one topology-neutral message when no services are connected. Status dots stay static and the last status is preserved across page navigation. - Thumbnail previews use the shared region renderers so time/date and data placeholders match the editor preview. - Preview framing follows the application theme and preserves the modal's rounded corners. diff --git a/src/web/bootstrap.js b/src/web/bootstrap.js index 06ee5d3..1d93c52 100644 --- a/src/web/bootstrap.js +++ b/src/web/bootstrap.js @@ -110,6 +110,7 @@ function createWebBootstrap(options) { pool: pool, common: common, thinClientBaseUrl: configuredBridgeInternalUrl, + playerServiceName: configuredBridgeInternalUrl ? 'Bridge' : 'Player', playerSnapshotCache: playerSnapshotCache, playerSnapshotSockets: playerSnapshotSockets, ensurePlayerSnapshotSubscription: ensurePlayerSnapshotSubscription, diff --git a/src/web/lib/dashboard-state.js b/src/web/lib/dashboard-state.js index b3ebb5e..162062c 100644 --- a/src/web/lib/dashboard-state.js +++ b/src/web/lib/dashboard-state.js @@ -105,6 +105,7 @@ function createDashboardStateService(options) { const pool = options && options.pool; const common = options && options.common; const connectedPlayerCountStaleSeconds = options && options.connectedPlayerCountStaleSeconds; + const playerServiceName = String(options && options.playerServiceName || 'Player'); const playerSnapshotCache = options && options.playerSnapshotCache; const playerSnapshotSockets = options && options.playerSnapshotSockets; const ensurePlayerSnapshotSubscription = options && options.ensurePlayerSnapshotSubscription; @@ -189,6 +190,7 @@ function createDashboardStateService(options) { clients: clients, kioskPlayers: kioskPlayers, slides: data.slides || [], + playerServiceName: playerServiceName, playerServiceConnected: playerServiceConnected, connectedPlayersCount: connectedPlayersCount !== null ? connectedPlayersCount : 0, connectedClientsCount: screens.reduce(function (total, screen) { diff --git a/src/web/public/css/theme-custom.css b/src/web/public/css/theme-custom.css index e8d7fac..afadc69 100644 --- a/src/web/public/css/theme-custom.css +++ b/src/web/public/css/theme-custom.css @@ -285,23 +285,14 @@ flex: 0 0 auto; } -.status-dot::after { - content: ''; - position: absolute; - inset: -0.28rem; - border-radius: inherit; - opacity: 0; - transform: scale(0.7); -} - .status-dot--online { background: #28a745; box-shadow: 0 0 0 0.2rem rgba(40, 167, 69, 0.2); } -.status-dot--online::after { - background: rgba(40, 167, 69, 0.32); - animation: status-dot-pulse 1.8s ease-out infinite; +.status-dot--idle { + background: #0d6efd; + box-shadow: 0 0 0 0.2rem rgba(13, 110, 253, 0.2); } .status-dot--offline { @@ -314,28 +305,6 @@ box-shadow: 0 0 0 0.2rem rgba(173, 181, 189, 0.18); } -.status-dot--unknown::after { - background: rgba(173, 181, 189, 0.26); - animation: status-dot-pulse 1.8s ease-out infinite; -} - -@keyframes status-dot-pulse { - 0% { - opacity: 0.55; - transform: scale(0.7); - } - - 70% { - opacity: 0; - transform: scale(1.75); - } - - 100% { - opacity: 0; - transform: scale(1.75); - } -} - /* Account menu */ .user-menu-avatar { diff --git a/src/web/public/js/shared/system-status.js b/src/web/public/js/shared/system-status.js index 1330a12..d8a9b8d 100644 --- a/src/web/public/js/shared/system-status.js +++ b/src/web/public/js/shared/system-status.js @@ -1,14 +1,24 @@ (function () { - function buildLivePlayerFeedLabel(connectedPlayersCount) { - var count = Number(connectedPlayersCount || 0); - if (count <= 0) { - return 'Player feed online - no players connected'; - } + var playerServiceName = 'Player'; - return 'Live player feed - ' + count + ' player' + (count === 1 ? '' : 's') + ' connected'; + function buildConnectingLabel() { + return playerServiceName === 'Bridge' ? 'Connecting to bridge' : 'Connecting to player service'; } - function updateSidebarStatus(status, label) { + function buildPlayerServiceLabel(connectedPlayersCount, serviceConnected) { + if (!serviceConnected) { + return 'No services connected'; + } + + var count = Number(connectedPlayersCount || 0); + if (count <= 0) { + return playerServiceName + ' online - no players connected'; + } + + return playerServiceName + ' online - ' + count + ' player' + (count === 1 ? '' : 's') + ' connected'; + } + + function updateSidebarStatus(status, label, persist) { var dot = document.getElementById('sidebar-status-dot'); var text = document.getElementById('sidebar-status-text'); var pill = document.getElementById('sidebar-status-pill'); @@ -16,18 +26,25 @@ return; } - var normalizedStatus = status === 'online' || status === 'offline' || status === 'unknown' + var normalizedStatus = status === 'online' || status === 'idle' || status === 'offline' || status === 'unknown' ? status : (status ? 'online' : 'offline'); - var statusLabel = normalizedStatus === 'online' - ? (label || 'Connected to player feed') - : normalizedStatus === 'offline' - ? 'Disconnected from player feed' - : (label || 'Connecting to player feed'); - var pillLabel = normalizedStatus === 'online' ? 'Connected' : normalizedStatus === 'offline' ? 'Disconnected' : 'Connecting'; + var statusLabel = label; + if (!statusLabel) { + if (normalizedStatus === 'online') { + statusLabel = playerServiceName + ' online'; + } else if (normalizedStatus === 'idle') { + statusLabel = playerServiceName + ' online - no players connected'; + } else if (normalizedStatus === 'offline') { + statusLabel = 'No services connected'; + } else { + statusLabel = buildConnectingLabel(); + } + } + var pillLabel = normalizedStatus === 'online' ? 'Connected' : normalizedStatus === 'idle' ? 'No players' : normalizedStatus === 'offline' ? 'Disconnected' : 'Connecting'; - dot.classList.remove('status-dot--unknown', 'status-dot--online', 'status-dot--offline'); - dot.classList.add(normalizedStatus === 'online' ? 'status-dot--online' : normalizedStatus === 'offline' ? 'status-dot--offline' : 'status-dot--unknown'); + dot.classList.remove('status-dot--unknown', 'status-dot--online', 'status-dot--idle', 'status-dot--offline'); + dot.classList.add(normalizedStatus === 'online' ? 'status-dot--online' : normalizedStatus === 'idle' ? 'status-dot--idle' : normalizedStatus === 'offline' ? 'status-dot--offline' : 'status-dot--unknown'); dot.setAttribute('aria-label', statusLabel); dot.setAttribute('title', statusLabel); @@ -36,10 +53,41 @@ } if (pill) { - pill.classList.remove('status-pill--unknown', 'status-pill--online', 'status-pill--offline'); - pill.classList.add(normalizedStatus === 'online' ? 'status-pill--online' : normalizedStatus === 'offline' ? 'status-pill--offline' : 'status-pill--unknown'); + pill.classList.remove('status-pill--unknown', 'status-pill--online', 'status-pill--idle', 'status-pill--offline'); + pill.classList.add(normalizedStatus === 'online' ? 'status-pill--online' : normalizedStatus === 'idle' ? 'status-pill--idle' : normalizedStatus === 'offline' ? 'status-pill--offline' : 'status-pill--unknown'); pill.textContent = pillLabel; } + + if (persist && window.sessionStorage) { + try { + window.sessionStorage.setItem('pulse-player-service-status-v2', JSON.stringify({ status: normalizedStatus, label: statusLabel, serviceName: playerServiceName })); + } catch (_error) { + // Storage may be unavailable in restricted browser contexts. + } + } + if (persist) { + hasCachedStatus = true; + } + } + + var hasCachedStatus = false; + if (window.sessionStorage) { + try { + var cachedStatus = JSON.parse(window.sessionStorage.getItem('pulse-player-service-status-v2') || 'null'); + if (cachedStatus && ['online', 'idle', 'offline', 'unknown'].indexOf(cachedStatus.status) !== -1 && typeof cachedStatus.label === 'string') { + if (cachedStatus.serviceName === 'Player service') { + cachedStatus.serviceName = 'Player'; + cachedStatus.label = cachedStatus.label.replace(/^Player service online(?= -|$)/, 'Player online'); + } + if (cachedStatus.serviceName === 'Bridge' || cachedStatus.serviceName === 'Player') { + playerServiceName = cachedStatus.serviceName; + } + updateSidebarStatus(cachedStatus.status, cachedStatus.status === 'offline' ? 'No services connected' : cachedStatus.label); + hasCachedStatus = true; + } + } catch (_error) { + // Ignore unavailable or malformed cached status. + } } function connectDashboardSocket() { @@ -68,7 +116,9 @@ function connect() { var protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; socket = new WebSocket(protocol + '//' + window.location.host + '/ws/dashboard'); - updateSidebarStatus('unknown', 'Connecting to player feed'); + if (!hasCachedStatus) { + updateSidebarStatus('unknown', buildConnectingLabel()); + } socket.onmessage = function (event) { try { @@ -77,11 +127,14 @@ if (typeof window.webHandleDashboardState === 'function') { window.webHandleDashboardState(payload.state); } + playerServiceName = payload.state && payload.state.playerServiceName === 'Bridge' ? 'Bridge' : 'Player'; var screenCount = Array.isArray(payload.state && payload.state.screens) ? payload.state.screens.length : 0; if (!screenCount) { - updateSidebarStatus('unknown', 'No screens configured'); + updateSidebarStatus('unknown', 'No screens configured', true); } else { - updateSidebarStatus(Boolean(payload.state && payload.state.playerServiceConnected), buildLivePlayerFeedLabel(payload.state && payload.state.connectedPlayersCount)); + var playersConnected = Number(payload.state && payload.state.connectedPlayersCount || 0); + var serviceConnected = Boolean(payload.state && payload.state.playerServiceConnected); + updateSidebarStatus(serviceConnected ? (playersConnected > 0 ? 'online' : 'idle') : 'offline', buildPlayerServiceLabel(playersConnected, serviceConnected), true); } } } catch (_error) { @@ -104,7 +157,9 @@ }; socket.onopen = function () { - updateSidebarStatus('unknown', 'Connecting to player feed'); + if (!hasCachedStatus) { + updateSidebarStatus('unknown', buildConnectingLabel()); + } }; } diff --git a/src/web/views/shared/layout.hbs b/src/web/views/shared/layout.hbs index bca761e..dec51c0 100644 --- a/src/web/views/shared/layout.hbs +++ b/src/web/views/shared/layout.hbs @@ -9,7 +9,7 @@ - + {{#if stylesheets.length}} {{#each stylesheets}} diff --git a/test/dashboard-state.test.js b/test/dashboard-state.test.js index 6fed5b8..a6f6a66 100644 --- a/test/dashboard-state.test.js +++ b/test/dashboard-state.test.js @@ -7,6 +7,7 @@ const { createDashboardStateService } = require('../src/web/lib/dashboard-state' test('dashboard state counts active players from the registry heartbeat', async () => { const service = createDashboardStateService({ + playerServiceName: 'Bridge', pool: { async query(sql) { if (String(sql || '').includes('COUNT(*) AS connected_count')) { @@ -64,6 +65,7 @@ test('dashboard state counts active players from the registry heartbeat', async const state = await service.buildDashboardState(); assert.equal(state.connectedPlayersCount, 6); + assert.equal(state.playerServiceName, 'Bridge'); assert.equal(state.playerServiceConnected, true); assert.equal(state.connectedClientsCount, 1); assert.equal(state.clients[0].player_identifier, 'player-alpha'); diff --git a/test/system-status.test.js b/test/system-status.test.js index 641f015..eef3351 100644 --- a/test/system-status.test.js +++ b/test/system-status.test.js @@ -3,13 +3,18 @@ const assert = require('node:assert/strict'); const fs = require('node:fs'); const vm = require('node:vm'); -test('system status shows connected player count in the live feed label', () => { +test('system status shows connected player count in the player label', () => { const script = fs.readFileSync(require.resolve('../src/web/public/js/shared/system-status.js'), 'utf8'); + const dotClasses = new Set(); const elements = { 'sidebar-status-dot': { classList: { - remove() {}, - add() {} + remove(...names) { + names.forEach((name) => dotClasses.delete(name)); + }, + add(name) { + dotClasses.add(name); + } }, setAttribute() {} }, @@ -64,22 +69,29 @@ test('system status shows connected player count in the live feed label', () => type: 'dashboard-state', state: { screens: [{ slug: 'alpha' }], + playerServiceName: 'Bridge', playerServiceConnected: true, connectedPlayersCount: 4 } }) }); - assert.equal(elements['sidebar-status-text'].textContent, 'Live player feed - 4 players connected'); + assert.equal(elements['sidebar-status-text'].textContent, 'Bridge online - 4 players connected'); + assert.equal(dotClasses.has('status-dot--online'), true); }); test('system status shows an explicit idle label when no players are connected', () => { const script = fs.readFileSync(require.resolve('../src/web/public/js/shared/system-status.js'), 'utf8'); + const dotClasses = new Set(); const elements = { 'sidebar-status-dot': { classList: { - remove() {}, - add() {} + remove(...names) { + names.forEach((name) => dotClasses.delete(name)); + }, + add(name) { + dotClasses.add(name); + } }, setAttribute() {} }, @@ -134,11 +146,159 @@ test('system status shows an explicit idle label when no players are connected', type: 'dashboard-state', state: { screens: [{ slug: 'alpha' }], + playerServiceName: 'Bridge', playerServiceConnected: true, connectedPlayersCount: 0 } }) }); - assert.equal(elements['sidebar-status-text'].textContent, 'Player feed online - no players connected'); + assert.equal(elements['sidebar-status-text'].textContent, 'Bridge online - no players connected'); + assert.equal(dotClasses.has('status-dot--idle'), true); + + sockets[0].onmessage({ + data: JSON.stringify({ + type: 'dashboard-state', + state: { + screens: [{ slug: 'alpha' }], + playerServiceName: 'Bridge', + playerServiceConnected: false, + connectedPlayersCount: 1 + } + }) + }); + + assert.equal(elements['sidebar-status-text'].textContent, 'No services connected'); + assert.equal(dotClasses.has('status-dot--offline'), true); +}); + +test('system status keeps the last service label while a new page reconnects', () => { + const script = fs.readFileSync(require.resolve('../src/web/public/js/shared/system-status.js'), 'utf8'); + const elements = { + 'sidebar-status-dot': { + classList: { + remove() {}, + add() {} + }, + setAttribute() {} + }, + 'sidebar-status-text': { + textContent: 'Player service' + } + }; + const storedValues = { + 'pulse-player-service-status-v2': JSON.stringify({ status: 'online', label: 'Player service online - 1 player connected', serviceName: 'Player service' }) + }; + const sockets = []; + + const context = { + document: { + getElementById(id) { + return elements[id] || null; + } + }, + window: { + WebSocket: true, + location: { + protocol: 'http:', + host: 'example.test' + }, + sessionStorage: { + getItem(key) { + return storedValues[key] || null; + }, + setItem(key, value) { + storedValues[key] = value; + } + }, + setTimeout() { + return 1; + }, + clearTimeout() {}, + webHandleDashboardState() {} + }, + WebSocket: function MockWebSocket(url) { + this.url = url; + sockets.push(this); + this.close = function () {}; + }, + JSON: JSON, + Number: Number, + String: String, + Boolean: Boolean, + Array: Array, + Object: Object, + Math: Math, + setTimeout() {}, + clearTimeout() {}, + console: console + }; + context.window.document = context.document; + context.window.WebSocket = context.WebSocket; + + vm.runInNewContext(script, context); + + assert.equal(elements['sidebar-status-text'].textContent, 'Player online - 1 player connected'); + sockets[0].onopen(); + assert.equal(elements['sidebar-status-text'].textContent, 'Player online - 1 player connected'); +}); + +test('system status uses the same offline label after restoring a cached bridge status', () => { + const script = fs.readFileSync(require.resolve('../src/web/public/js/shared/system-status.js'), 'utf8'); + const elements = { + 'sidebar-status-dot': { + classList: { + remove() {}, + add() {} + }, + setAttribute() {} + }, + 'sidebar-status-text': { + textContent: '' + } + }; + const context = { + document: { + getElementById(id) { + return elements[id] || null; + } + }, + window: { + WebSocket: true, + location: { + protocol: 'http:', + host: 'example.test' + }, + sessionStorage: { + getItem() { + return JSON.stringify({ status: 'offline', label: 'Bridge offline', serviceName: 'Bridge' }); + }, + setItem() {} + }, + setTimeout() { + return 1; + }, + clearTimeout() {}, + webHandleDashboardState() {} + }, + WebSocket: function MockWebSocket() { + this.close = function () {}; + }, + JSON: JSON, + Number: Number, + String: String, + Boolean: Boolean, + Array: Array, + Object: Object, + Math: Math, + setTimeout() {}, + clearTimeout() {}, + console: console + }; + context.window.document = context.document; + context.window.WebSocket = context.WebSocket; + + vm.runInNewContext(script, context); + + assert.equal(elements['sidebar-status-text'].textContent, 'No services connected'); }); \ No newline at end of file