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