Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7973ee0ea4 | ||
|
|
6416dbfd99 | ||
|
|
6fb413cb6d | ||
|
|
8393923c5a |
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "pulse-signage",
|
"name": "pulse-signage",
|
||||||
"version": "1.2.0",
|
"version": "1.3.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "pulse-signage",
|
"name": "pulse-signage",
|
||||||
"version": "1.2.0",
|
"version": "1.3.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bootstrap-icons": "1.11.3",
|
"bootstrap-icons": "1.11.3",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pulse-signage",
|
"name": "pulse-signage",
|
||||||
"version": "1.4.0",
|
"version": "1.3.4",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "Pulse Signage application with MySQL and media uploads",
|
"description": "Pulse Signage application with MySQL and media uploads",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
function normalizeClientName(value) {
|
||||||
|
return String(value || '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDeviceId(value) {
|
||||||
|
return String(value || '').trim().replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 128);
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectLiveConnections(liveConnections) {
|
||||||
|
return Array.isArray(liveConnections) ? liveConnections : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isClientNameAvailable(pool, clientName, excludeDeviceId, liveConnections) {
|
||||||
|
const normalizedName = normalizeClientName(clientName);
|
||||||
|
if (!normalizedName) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedDeviceId = normalizeDeviceId(excludeDeviceId);
|
||||||
|
const live = collectLiveConnections(liveConnections);
|
||||||
|
const lowerName = normalizedName.toLowerCase();
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (pool) {
|
||||||
|
const [deviceRows] = await pool.query(
|
||||||
|
`SELECT device_id
|
||||||
|
FROM player_onboarding_devices
|
||||||
|
WHERE client_name IS NOT NULL
|
||||||
|
AND TRIM(client_name) <> ''
|
||||||
|
AND LOWER(TRIM(client_name)) = LOWER(TRIM(?))
|
||||||
|
AND device_id <> ?
|
||||||
|
LIMIT 1`,
|
||||||
|
[normalizedName, normalizedDeviceId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (deviceRows.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const connection of live) {
|
||||||
|
const existingName = normalizeClientName(connection && connection.clientName ? connection.clientName : '');
|
||||||
|
if (!existingName || existingName.toLowerCase() !== lowerName) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const existingDeviceId = normalizeDeviceId(connection && (connection.deviceId || connection.clientId) ? (connection.deviceId || connection.clientId) : '');
|
||||||
|
if (normalizedDeviceId && existingDeviceId && existingDeviceId === normalizedDeviceId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (_error) {
|
||||||
|
for (const connection of live) {
|
||||||
|
const existingName = normalizeClientName(connection && connection.clientName ? connection.clientName : '');
|
||||||
|
if (!existingName || existingName.toLowerCase() !== lowerName) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const existingDeviceId = normalizeDeviceId(connection && (connection.deviceId || connection.clientId) ? (connection.deviceId || connection.clientId) : '');
|
||||||
|
if (normalizedDeviceId && existingDeviceId && existingDeviceId === normalizedDeviceId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildClientNameLockName(clientName) {
|
||||||
|
return `ps_client_name_${crypto.createHash('sha1').update(String(clientName || '').trim().toLowerCase()).digest('hex')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withClientNameReservation(pool, clientName, handler) {
|
||||||
|
if (!pool || typeof pool.getConnection !== 'function') {
|
||||||
|
return handler();
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedName = normalizeClientName(clientName);
|
||||||
|
if (!normalizedName) {
|
||||||
|
return handler();
|
||||||
|
}
|
||||||
|
|
||||||
|
const connection = await pool.getConnection();
|
||||||
|
const lockName = buildClientNameLockName(normalizedName);
|
||||||
|
let lockAcquired = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [lockRows] = await connection.query('SELECT GET_LOCK(?, 5) AS lock_result', [lockName]);
|
||||||
|
const lockResult = lockRows && lockRows[0] ? Number(lockRows[0].lock_result) : 0;
|
||||||
|
if (lockResult !== 1) {
|
||||||
|
const error = new Error('Client name is busy. Please try again.');
|
||||||
|
error.statusCode = 409;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
lockAcquired = true;
|
||||||
|
return await handler();
|
||||||
|
} finally {
|
||||||
|
if (lockAcquired) {
|
||||||
|
try {
|
||||||
|
await connection.query('SELECT RELEASE_LOCK(?)', [lockName]);
|
||||||
|
} catch (_error) {}
|
||||||
|
}
|
||||||
|
connection.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
normalizeClientName: normalizeClientName,
|
||||||
|
normalizeDeviceId: normalizeDeviceId,
|
||||||
|
collectLiveConnections: collectLiveConnections,
|
||||||
|
isClientNameAvailable: isClientNameAvailable,
|
||||||
|
withClientNameReservation: withClientNameReservation
|
||||||
|
};
|
||||||
@@ -31,6 +31,13 @@ async function addColumnIfMissing(pool, tableName, columnName, columnDefinition)
|
|||||||
await pool.query(`ALTER TABLE \`${tableName}\` ADD COLUMN \`${columnName}\` ${columnDefinition}`);
|
await pool.query(`ALTER TABLE \`${tableName}\` ADD COLUMN \`${columnName}\` ${columnDefinition}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function pruneStaleOnboardingDevices(pool) {
|
||||||
|
await pool.query(
|
||||||
|
`DELETE FROM player_onboarding_devices
|
||||||
|
WHERE modified_at < (CURRENT_TIMESTAMP - INTERVAL 1 MINUTE)`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function ensureSchema(pool) {
|
async function ensureSchema(pool) {
|
||||||
await pool.query(`
|
await pool.query(`
|
||||||
CREATE TABLE IF NOT EXISTS canvas_sizes (
|
CREATE TABLE IF NOT EXISTS canvas_sizes (
|
||||||
@@ -262,5 +269,6 @@ async function ensureSchema(pool) {
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
createPool,
|
createPool,
|
||||||
ensureSchema
|
ensureSchema,
|
||||||
|
pruneStaleOnboardingDevices
|
||||||
};
|
};
|
||||||
|
|||||||
+37
-2
@@ -5,8 +5,10 @@ const path = require('path');
|
|||||||
const common = require('./common');
|
const common = require('./common');
|
||||||
const { createPlayerRuntime } = require('./player/runtime');
|
const { createPlayerRuntime } = require('./player/runtime');
|
||||||
const { createPlayerPlaylistService } = require('./player/playlist');
|
const { createPlayerPlaylistService } = require('./player/playlist');
|
||||||
const { normalizeDeviceId, registerPlayerOnboardingRoutes } = require('./player/onboarding');
|
const { normalizeDeviceId, registerPlayerOnboardingRoutes, commitDeviceBinding } = require('./player/onboarding');
|
||||||
|
const { createOnboardingStore } = require('./player/onboarding-store');
|
||||||
const { registerPlayerRoutes } = require('./player/routes');
|
const { registerPlayerRoutes } = require('./player/routes');
|
||||||
|
const { pruneStaleOnboardingDevices } = require('./db');
|
||||||
|
|
||||||
|
|
||||||
// Player runtime, upload API, and websocket wiring.
|
// Player runtime, upload API, and websocket wiring.
|
||||||
@@ -16,6 +18,9 @@ async function start() {
|
|||||||
const PORT = Number(process.env.PLAYER_PORT || 3001);
|
const PORT = Number(process.env.PLAYER_PORT || 3001);
|
||||||
const ASSET_DIR = path.join(__dirname, 'player', 'public');
|
const ASSET_DIR = path.join(__dirname, 'player', 'public');
|
||||||
const UPLOAD_DIR = path.join(__dirname, '..', 'uploads');
|
const UPLOAD_DIR = path.join(__dirname, '..', 'uploads');
|
||||||
|
const ONBOARDING_QUEUE_FILE = path.join(UPLOAD_DIR, 'player-onboarding-queue.json');
|
||||||
|
const DB_SYNC_INTERVAL_MS = Number(process.env.PLAYER_DB_SYNC_INTERVAL_MS || 15000);
|
||||||
|
const onboardingStore = createOnboardingStore(ONBOARDING_QUEUE_FILE);
|
||||||
const playerRuntime = createPlayerRuntime({
|
const playerRuntime = createPlayerRuntime({
|
||||||
pool: pool,
|
pool: pool,
|
||||||
normalizeDeviceId: normalizeDeviceId
|
normalizeDeviceId: normalizeDeviceId
|
||||||
@@ -31,6 +36,7 @@ async function start() {
|
|||||||
pool: pool,
|
pool: pool,
|
||||||
common: common,
|
common: common,
|
||||||
playerRuntime: playerRuntime,
|
playerRuntime: playerRuntime,
|
||||||
|
onboardingStore: onboardingStore,
|
||||||
QRCode: require('qrcode')
|
QRCode: require('qrcode')
|
||||||
});
|
});
|
||||||
registerPlayerRoutes(app, {
|
registerPlayerRoutes(app, {
|
||||||
@@ -47,12 +53,41 @@ async function start() {
|
|||||||
res.status(error.statusCode || 500).send(error.statusCode ? error.message : 'Internal server error');
|
res.status(error.statusCode || 500).send(error.statusCode ? error.message : 'Internal server error');
|
||||||
});
|
});
|
||||||
|
|
||||||
await common.ensureSchema(pool);
|
|
||||||
fs.mkdirSync(UPLOAD_DIR, { recursive: true });
|
fs.mkdirSync(UPLOAD_DIR, { recursive: true });
|
||||||
|
|
||||||
server.listen(PORT, function () {
|
server.listen(PORT, function () {
|
||||||
console.log(`Pulse Signage app listening on port ${PORT}`);
|
console.log(`Pulse Signage app listening on port ${PORT}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function syncDatabaseState() {
|
||||||
|
try {
|
||||||
|
await common.ensureSchema(pool);
|
||||||
|
|
||||||
|
if (playerRuntime.snapshotAllConnections().length > 0) {
|
||||||
|
await pruneStaleOnboardingDevices(pool);
|
||||||
|
}
|
||||||
|
|
||||||
|
await onboardingStore.flushBindings(function (entry) {
|
||||||
|
return commitDeviceBinding(
|
||||||
|
pool,
|
||||||
|
entry.deviceId,
|
||||||
|
entry.clientName,
|
||||||
|
entry.screenSlug,
|
||||||
|
playerRuntime.isClientNameAvailableOnScreen,
|
||||||
|
playerRuntime.snapshotAllConnections()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await syncDatabaseState();
|
||||||
|
setInterval(function () {
|
||||||
|
syncDatabaseState().catch(function (error) {
|
||||||
|
console.error(error);
|
||||||
|
});
|
||||||
|
}, DB_SYNC_INTERVAL_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { start };
|
module.exports = { start };
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
function isTransientDbError(error) {
|
||||||
|
const code = String(error && error.code ? error.code : '').trim();
|
||||||
|
return [
|
||||||
|
'ECONNREFUSED',
|
||||||
|
'ECONNRESET',
|
||||||
|
'ETIMEDOUT',
|
||||||
|
'EPIPE',
|
||||||
|
'ENOTFOUND',
|
||||||
|
'PROTOCOL_CONNECTION_LOST',
|
||||||
|
'POOL_CLOSED',
|
||||||
|
'ERR_POOL_CLOSED'
|
||||||
|
].indexOf(code) !== -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createOnboardingStore(filePath) {
|
||||||
|
const normalizedFilePath = String(filePath || '').trim();
|
||||||
|
|
||||||
|
async function readEntries() {
|
||||||
|
try {
|
||||||
|
const raw = await fs.promises.readFile(normalizedFilePath, 'utf8');
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
return Array.isArray(parsed) ? parsed : [];
|
||||||
|
} catch (error) {
|
||||||
|
if (error && error.code === 'ENOENT') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeEntries(entries) {
|
||||||
|
await fs.promises.mkdir(path.dirname(normalizedFilePath), { recursive: true });
|
||||||
|
const tempPath = `${normalizedFilePath}.tmp`;
|
||||||
|
await fs.promises.writeFile(tempPath, JSON.stringify(Array.isArray(entries) ? entries : [], null, 2), 'utf8');
|
||||||
|
await fs.promises.rename(tempPath, normalizedFilePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enqueueBinding(entry) {
|
||||||
|
const normalizedEntry = {
|
||||||
|
deviceId: String(entry && entry.deviceId ? entry.deviceId : '').trim(),
|
||||||
|
clientName: String(entry && entry.clientName ? entry.clientName : '').trim(),
|
||||||
|
screenSlug: String(entry && entry.screenSlug ? entry.screenSlug : '').trim(),
|
||||||
|
queuedAt: String(entry && entry.queuedAt ? entry.queuedAt : new Date().toISOString())
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!normalizedEntry.deviceId || !normalizedEntry.clientName || !normalizedEntry.screenSlug) {
|
||||||
|
return readEntries();
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = await readEntries();
|
||||||
|
const nextEntries = entries.filter(function (queuedEntry) {
|
||||||
|
return String(queuedEntry && queuedEntry.deviceId ? queuedEntry.deviceId : '').trim() !== normalizedEntry.deviceId;
|
||||||
|
});
|
||||||
|
nextEntries.push(normalizedEntry);
|
||||||
|
await writeEntries(nextEntries);
|
||||||
|
return nextEntries;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flushBindings(applyBinding) {
|
||||||
|
const entries = await readEntries();
|
||||||
|
if (!entries.length) {
|
||||||
|
return { flushed: 0, remaining: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const remaining = [];
|
||||||
|
let flushed = 0;
|
||||||
|
for (let index = 0; index < entries.length; index += 1) {
|
||||||
|
const entry = entries[index];
|
||||||
|
try {
|
||||||
|
await applyBinding(entry);
|
||||||
|
flushed += 1;
|
||||||
|
} catch (error) {
|
||||||
|
if (isTransientDbError(error)) {
|
||||||
|
remaining.push.apply(remaining, entries.slice(index));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
remaining.push.apply(remaining, entries.slice(index + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeEntries(remaining);
|
||||||
|
return { flushed: flushed, remaining: remaining.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
enqueueBinding: enqueueBinding,
|
||||||
|
flushBindings: flushBindings,
|
||||||
|
readEntries: readEntries
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
createOnboardingStore: createOnboardingStore,
|
||||||
|
isTransientDbError: isTransientDbError
|
||||||
|
};
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
const { isClientNameAvailable, withClientNameReservation } = require('../client-name-check');
|
||||||
|
const { isTransientDbError } = require('./onboarding-store');
|
||||||
function normalizeDeviceId(value) {
|
function normalizeDeviceId(value) {
|
||||||
return String(value || '').trim().replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 128);
|
return String(value || '').trim().replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 128);
|
||||||
}
|
}
|
||||||
@@ -31,7 +33,7 @@ async function getOnboardingStatus(pool, deviceId) {
|
|||||||
return rows[0] || null;
|
return rows[0] || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function bindDeviceToScreen(pool, deviceId, clientName, screenSlug, isNameAvailableOnScreen) {
|
async function commitDeviceBinding(pool, deviceId, clientName, screenSlug, isNameAvailableOnScreen, liveConnections) {
|
||||||
const normalizedDeviceId = normalizeDeviceId(deviceId);
|
const normalizedDeviceId = normalizeDeviceId(deviceId);
|
||||||
const normalizedClientName = String(clientName || '').trim();
|
const normalizedClientName = String(clientName || '').trim();
|
||||||
const normalizedScreenSlug = String(screenSlug || '').trim();
|
const normalizedScreenSlug = String(screenSlug || '').trim();
|
||||||
@@ -46,15 +48,14 @@ async function bindDeviceToScreen(pool, deviceId, clientName, screenSlug, isName
|
|||||||
throw new Error('Screen is required.');
|
throw new Error('Screen is required.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return withClientNameReservation(pool, normalizedClientName, async function () {
|
||||||
const [screenRows] = await pool.query('SELECT id, name, slug FROM screens WHERE slug = ?', [normalizedScreenSlug]);
|
const [screenRows] = await pool.query('SELECT id, name, slug FROM screens WHERE slug = ?', [normalizedScreenSlug]);
|
||||||
if (!screenRows.length) {
|
if (!screenRows.length) {
|
||||||
throw new Error('Screen not found.');
|
throw new Error('Screen not found.');
|
||||||
}
|
}
|
||||||
const screen = screenRows[0];
|
const screen = screenRows[0];
|
||||||
|
|
||||||
const available = typeof isNameAvailableOnScreen === 'function'
|
const available = await isClientNameAvailable(pool, normalizedClientName, normalizedDeviceId, liveConnections);
|
||||||
? await isNameAvailableOnScreen(pool, normalizedClientName, normalizedDeviceId)
|
|
||||||
: true;
|
|
||||||
if (!available) {
|
if (!available) {
|
||||||
const error = new Error('Client name already exists.');
|
const error = new Error('Client name already exists.');
|
||||||
error.statusCode = 400;
|
error.statusCode = 400;
|
||||||
@@ -67,6 +68,37 @@ async function bindDeviceToScreen(pool, deviceId, clientName, screenSlug, isName
|
|||||||
);
|
);
|
||||||
|
|
||||||
return getOnboardingStatus(pool, normalizedDeviceId);
|
return getOnboardingStatus(pool, normalizedDeviceId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bindDeviceToScreen(pool, deviceId, clientName, screenSlug, isNameAvailableOnScreen, playerRuntime, onboardingStore) {
|
||||||
|
const liveConnections = playerRuntime && typeof playerRuntime.snapshotAllConnections === 'function'
|
||||||
|
? playerRuntime.snapshotAllConnections()
|
||||||
|
: [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await commitDeviceBinding(pool, deviceId, clientName, screenSlug, isNameAvailableOnScreen, liveConnections);
|
||||||
|
} catch (error) {
|
||||||
|
if (!isTransientDbError(error)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onboardingStore && typeof onboardingStore.enqueueBinding === 'function') {
|
||||||
|
await onboardingStore.enqueueBinding({
|
||||||
|
deviceId: deviceId,
|
||||||
|
clientName: clientName,
|
||||||
|
screenSlug: screenSlug,
|
||||||
|
queuedAt: new Date().toISOString()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
device_id: normalizeDeviceId(deviceId),
|
||||||
|
client_name: String(clientName || '').trim(),
|
||||||
|
screen_slug: String(screenSlug || '').trim(),
|
||||||
|
queued: true
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function registerPlayerOnboardingRoutes(app, options) {
|
function registerPlayerOnboardingRoutes(app, options) {
|
||||||
@@ -74,6 +106,7 @@ function registerPlayerOnboardingRoutes(app, options) {
|
|||||||
const common = options && options.common ? options.common : null;
|
const common = options && options.common ? options.common : null;
|
||||||
const playerRuntime = options && options.playerRuntime ? options.playerRuntime : null;
|
const playerRuntime = options && options.playerRuntime ? options.playerRuntime : null;
|
||||||
const QRCode = options && options.QRCode ? options.QRCode : null;
|
const QRCode = options && options.QRCode ? options.QRCode : null;
|
||||||
|
const onboardingStore = options && options.onboardingStore ? options.onboardingStore : null;
|
||||||
|
|
||||||
if (!app || !pool || !common || !playerRuntime || !QRCode) {
|
if (!app || !pool || !common || !playerRuntime || !QRCode) {
|
||||||
throw new Error('registerPlayerOnboardingRoutes requires app, pool, common, playerRuntime, and QRCode.');
|
throw new Error('registerPlayerOnboardingRoutes requires app, pool, common, playerRuntime, and QRCode.');
|
||||||
@@ -146,14 +179,15 @@ function registerPlayerOnboardingRoutes(app, options) {
|
|||||||
return res.status(400).json({ error: 'Screen is required' });
|
return res.status(400).json({ error: 'Screen is required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const status = await bindDeviceToScreen(pool, deviceId, clientName, screenSlug, playerRuntime.isClientNameAvailableOnScreen);
|
const status = await bindDeviceToScreen(pool, deviceId, clientName, screenSlug, playerRuntime.isClientNameAvailableOnScreen, playerRuntime, onboardingStore);
|
||||||
res.json({
|
res.json({
|
||||||
deviceId: deviceId,
|
deviceId: deviceId,
|
||||||
clientName: status ? status.client_name : clientName,
|
clientName: status ? status.client_name : clientName,
|
||||||
screenId: status ? status.screen_id : null,
|
screenId: status && status.screen_id ? status.screen_id : null,
|
||||||
screenSlug: status ? status.screen_slug : null,
|
screenSlug: status ? status.screen_slug : screenSlug,
|
||||||
screenName: status ? status.screen_name : null,
|
screenName: status && status.screen_name ? status.screen_name : null,
|
||||||
playerUrl: status && status.screen_slug ? `${getPublicBaseUrl(req)}/screen/${encodeURIComponent(status.screen_slug)}` : null
|
playerUrl: status && status.screen_slug ? `${getPublicBaseUrl(req)}/screen/${encodeURIComponent(status.screen_slug)}` : `${getPublicBaseUrl(req)}/screen/${encodeURIComponent(screenSlug)}`,
|
||||||
|
queued: Boolean(status && status.queued)
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
@@ -165,6 +199,7 @@ module.exports = {
|
|||||||
normalizeDeviceId: normalizeDeviceId,
|
normalizeDeviceId: normalizeDeviceId,
|
||||||
getPublicBaseUrl: getPublicBaseUrl,
|
getPublicBaseUrl: getPublicBaseUrl,
|
||||||
getOnboardingStatus: getOnboardingStatus,
|
getOnboardingStatus: getOnboardingStatus,
|
||||||
|
commitDeviceBinding: commitDeviceBinding,
|
||||||
bindDeviceToScreen: bindDeviceToScreen,
|
bindDeviceToScreen: bindDeviceToScreen,
|
||||||
registerPlayerOnboardingRoutes: registerPlayerOnboardingRoutes
|
registerPlayerOnboardingRoutes: registerPlayerOnboardingRoutes
|
||||||
};
|
};
|
||||||
@@ -138,8 +138,8 @@ function registerPlayerRoutes(app, options) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sent = connectionId
|
const sent = connectionId
|
||||||
? playerRuntime.sendCommandToConnection(req.params.slug, connectionId, commandPayload)
|
? await playerRuntime.sendCommandToConnection(req.params.slug, connectionId, commandPayload)
|
||||||
: playerRuntime.broadcastCommand(req.params.slug, commandPayload);
|
: await playerRuntime.broadcastCommand(req.params.slug, commandPayload);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
screen: screenRows[0] || null,
|
screen: screenRows[0] || null,
|
||||||
|
|||||||
+15
-63
@@ -1,5 +1,6 @@
|
|||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const { WebSocketServer, WebSocket } = require('ws');
|
const { WebSocketServer, WebSocket } = require('ws');
|
||||||
|
const { isClientNameAvailable } = require('../client-name-check');
|
||||||
|
|
||||||
function createPlayerRuntime(options) {
|
function createPlayerRuntime(options) {
|
||||||
const pool = options && options.pool ? options.pool : null;
|
const pool = options && options.pool ? options.pool : null;
|
||||||
@@ -134,76 +135,25 @@ function createPlayerRuntime(options) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function isClientNameAvailableOnScreen(poolArg, clientName, excludeDeviceId) {
|
function snapshotAllConnections() {
|
||||||
const normalizedName = String(clientName || '').trim();
|
const allConnections = [];
|
||||||
if (!normalizedName) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizedDeviceId = normalizeDeviceId(excludeDeviceId);
|
|
||||||
const lowerName = normalizedName.toLowerCase();
|
|
||||||
const liveDeviceIds = new Set();
|
|
||||||
const liveClientIds = new Set();
|
|
||||||
|
|
||||||
for (const bucket of connectionsBySlug.values()) {
|
for (const bucket of connectionsBySlug.values()) {
|
||||||
if (!bucket || typeof bucket.values !== 'function') {
|
if (!bucket || typeof bucket.values !== 'function') {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
for (const connection of bucket.values()) {
|
for (const connection of bucket.values()) {
|
||||||
const existingDeviceId = normalizeDeviceId(connection && connection.deviceId ? connection.deviceId : '');
|
allConnections.push({
|
||||||
const existingClientId = normalizeDeviceId(connection && connection.clientId ? connection.clientId : '');
|
clientId: connection.clientId || null,
|
||||||
if (existingDeviceId) {
|
clientName: connection.clientName || null,
|
||||||
liveDeviceIds.add(existingDeviceId);
|
deviceId: connection.deviceId || null
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (existingClientId) {
|
|
||||||
liveClientIds.add(existingClientId);
|
|
||||||
}
|
|
||||||
const existingName = String(connection && connection.clientName ? connection.clientName : '').trim();
|
|
||||||
if (!existingName) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (existingName.toLowerCase() !== lowerName) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (normalizedDeviceId && existingDeviceId && existingDeviceId === normalizedDeviceId) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
return allConnections;
|
||||||
}
|
}
|
||||||
|
|
||||||
const activePool = poolArg || pool;
|
async function isClientNameAvailableOnScreen(poolArg, clientName, excludeDeviceId) {
|
||||||
if (!activePool || (!liveDeviceIds.size && !liveClientIds.size)) {
|
return isClientNameAvailable(poolArg || pool, clientName, excludeDeviceId, snapshotAllConnections());
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const [deviceRows] = await activePool.query(
|
|
||||||
`SELECT device_id
|
|
||||||
FROM player_onboarding_devices
|
|
||||||
WHERE client_name IS NOT NULL
|
|
||||||
AND TRIM(client_name) <> ''
|
|
||||||
AND LOWER(TRIM(client_name)) = LOWER(TRIM(?))`,
|
|
||||||
[normalizedName]
|
|
||||||
);
|
|
||||||
|
|
||||||
for (let i = 0; i < deviceRows.length; i += 1) {
|
|
||||||
const deviceId = normalizeDeviceId(deviceRows[i] && deviceRows[i].device_id ? deviceRows[i].device_id : '');
|
|
||||||
if (!deviceId) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (normalizedDeviceId && deviceId === normalizedDeviceId) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (liveDeviceIds.has(deviceId)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (_error) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function broadcastConnectionSnapshot(slug) {
|
function broadcastConnectionSnapshot(slug) {
|
||||||
@@ -227,7 +177,7 @@ function createPlayerRuntime(options) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function sendCommandToConnection(slug, connectionId, commandOrPayload) {
|
async function sendCommandToConnection(slug, connectionId, commandOrPayload) {
|
||||||
const bucket = connectionsBySlug.get(String(slug || '').trim());
|
const bucket = connectionsBySlug.get(String(slug || '').trim());
|
||||||
if (!bucket || !bucket.size) {
|
if (!bucket || !bucket.size) {
|
||||||
return 0;
|
return 0;
|
||||||
@@ -249,7 +199,7 @@ function createPlayerRuntime(options) {
|
|||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
function broadcastCommand(slug, commandOrPayload) {
|
async function broadcastCommand(slug, commandOrPayload) {
|
||||||
const bucket = connectionsBySlug.get(String(slug || '').trim());
|
const bucket = connectionsBySlug.get(String(slug || '').trim());
|
||||||
if (!bucket || !bucket.size) {
|
if (!bucket || !bucket.size) {
|
||||||
return 0;
|
return 0;
|
||||||
@@ -269,6 +219,7 @@ function createPlayerRuntime(options) {
|
|||||||
connection.socket.send(JSON.stringify(payload));
|
connection.socket.send(JSON.stringify(payload));
|
||||||
sent += 1;
|
sent += 1;
|
||||||
});
|
});
|
||||||
|
|
||||||
return sent;
|
return sent;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -405,6 +356,7 @@ function createPlayerRuntime(options) {
|
|||||||
return {
|
return {
|
||||||
installWebsocket: installWebsocket,
|
installWebsocket: installWebsocket,
|
||||||
snapshotConnections: snapshotConnections,
|
snapshotConnections: snapshotConnections,
|
||||||
|
snapshotAllConnections: snapshotAllConnections,
|
||||||
isClientNameAvailableOnScreen: isClientNameAvailableOnScreen,
|
isClientNameAvailableOnScreen: isClientNameAvailableOnScreen,
|
||||||
sendCommandToConnection: sendCommandToConnection,
|
sendCommandToConnection: sendCommandToConnection,
|
||||||
broadcastCommand: broadcastCommand
|
broadcastCommand: broadcastCommand
|
||||||
|
|||||||
+6
-1
@@ -16,6 +16,7 @@ const registerAdminScreenCommandRoutes = require('./web/routes/admin-screen-comm
|
|||||||
const registerAdminContentRoutes = require('./web/routes/admin-content');
|
const registerAdminContentRoutes = require('./web/routes/admin-content');
|
||||||
const { createWebBootstrap } = require('./web/bootstrap');
|
const { createWebBootstrap } = require('./web/bootstrap');
|
||||||
const { createPlayerActionService } = require('./web/player-actions');
|
const { createPlayerActionService } = require('./web/player-actions');
|
||||||
|
const { isClientNameAvailable, withClientNameReservation } = require('./client-name-check');
|
||||||
const { createSessionService } = require('./web/session');
|
const { createSessionService } = require('./web/session');
|
||||||
const {
|
const {
|
||||||
formatDashboardDate,
|
formatDashboardDate,
|
||||||
@@ -168,6 +169,7 @@ async function start() {
|
|||||||
notifyPlayerScreens: notifyPlayerScreens,
|
notifyPlayerScreens: notifyPlayerScreens,
|
||||||
broadcastDashboardState: broadcastDashboardState,
|
broadcastDashboardState: broadcastDashboardState,
|
||||||
getScreenDeleteBlockMessage: playerActionService.getScreenDeleteBlockMessage,
|
getScreenDeleteBlockMessage: playerActionService.getScreenDeleteBlockMessage,
|
||||||
|
getScreenConnections: playerActionService.getScreenConnections,
|
||||||
getPlaylistDeleteBlockMessage: playerActionService.getPlaylistDeleteBlockMessage,
|
getPlaylistDeleteBlockMessage: playerActionService.getPlaylistDeleteBlockMessage,
|
||||||
forwardPlayerCommand: playerActionService.forwardPlayerCommand,
|
forwardPlayerCommand: playerActionService.forwardPlayerCommand,
|
||||||
playerPublicBaseUrl: PLAYER_PUBLIC_BASE_URL
|
playerPublicBaseUrl: PLAYER_PUBLIC_BASE_URL
|
||||||
@@ -175,7 +177,10 @@ async function start() {
|
|||||||
|
|
||||||
registerAdminScreenCommandRoutes(app, {
|
registerAdminScreenCommandRoutes(app, {
|
||||||
pool: pool,
|
pool: pool,
|
||||||
forwardPlayerCommand: playerActionService.forwardPlayerCommand
|
forwardPlayerCommand: playerActionService.forwardPlayerCommand,
|
||||||
|
getScreenConnections: playerActionService.getScreenConnections,
|
||||||
|
isClientNameAvailable: isClientNameAvailable,
|
||||||
|
withClientNameReservation: withClientNameReservation
|
||||||
});
|
});
|
||||||
|
|
||||||
registerAdminContentRoutes(app, {
|
registerAdminContentRoutes(app, {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ function enrichScreensWithConnections(screens, connectionsBySlug, onboardingName
|
|||||||
return (screens || []).map(function (screen) {
|
return (screens || []).map(function (screen) {
|
||||||
const connectionState = connectionsBySlug[screen.slug] || { count: 0, connections: [] };
|
const connectionState = connectionsBySlug[screen.slug] || { count: 0, connections: [] };
|
||||||
return Object.assign({}, screen, {
|
return Object.assign({}, screen, {
|
||||||
client_name: onboardingNameBySlug[screen.slug] || screen.client_name || null,
|
client_name: onboardingNameBySlug[screen.slug] || null,
|
||||||
player_connection_count: connectionState.count || 0,
|
player_connection_count: connectionState.count || 0,
|
||||||
player_connections: Array.isArray(connectionState.connections) ? connectionState.connections : []
|
player_connections: Array.isArray(connectionState.connections) ? connectionState.connections : []
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -34,9 +34,45 @@ function createPlayerActionService(options) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getScreenDeleteBlockMessage(pool, screen) {
|
async function getScreenConnections(slug) {
|
||||||
|
const response = await fetch(`${playerInternalBaseUrl}/api/screens/${encodeURIComponent(slug)}/connections`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text().catch(function () { return ''; });
|
||||||
|
const error = new Error(errorText || `Unable to load screen connections for ${slug}.`);
|
||||||
|
error.statusCode = response.status;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json().catch(function () {
|
||||||
|
return { connections: [] };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getScreenDeleteBlockMessage(pool, screen, getScreenConnections) {
|
||||||
const [rows] = await pool.query('SELECT COUNT(*) AS ref_count FROM player_onboarding_devices WHERE screen_id = ?', [screen.id]);
|
const [rows] = await pool.query('SELECT COUNT(*) AS ref_count FROM player_onboarding_devices WHERE screen_id = ?', [screen.id]);
|
||||||
return Number(rows[0] && rows[0].ref_count) > 0 ? 'This screen is still linked to onboarding devices.' : '';
|
if (Number(rows[0] && rows[0].ref_count) > 0) {
|
||||||
|
return 'This screen is still linked to onboarding devices.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof getScreenConnections === 'function' && String(screen && screen.slug ? screen.slug : '').trim()) {
|
||||||
|
try {
|
||||||
|
const response = await getScreenConnections(screen.slug);
|
||||||
|
const liveConnections = Array.isArray(response && response.connections) ? response.connections : [];
|
||||||
|
if (liveConnections.length > 0) {
|
||||||
|
return 'This screen is still in use by connected players.';
|
||||||
|
}
|
||||||
|
} catch (_error) {
|
||||||
|
// Keep the delete guard based on onboarding references if live connection lookup fails.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getSlideDeleteBlockMessage(pool, slide) {
|
async function getSlideDeleteBlockMessage(pool, slide) {
|
||||||
@@ -61,6 +97,7 @@ function createPlayerActionService(options) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
forwardPlayerCommand: forwardPlayerCommand,
|
forwardPlayerCommand: forwardPlayerCommand,
|
||||||
|
getScreenConnections: getScreenConnections,
|
||||||
getScreenDeleteBlockMessage: getScreenDeleteBlockMessage,
|
getScreenDeleteBlockMessage: getScreenDeleteBlockMessage,
|
||||||
getSlideDeleteBlockMessage: getSlideDeleteBlockMessage,
|
getSlideDeleteBlockMessage: getSlideDeleteBlockMessage,
|
||||||
getTemplateDeleteBlockMessage: getTemplateDeleteBlockMessage,
|
getTemplateDeleteBlockMessage: getTemplateDeleteBlockMessage,
|
||||||
|
|||||||
@@ -123,10 +123,37 @@
|
|||||||
--bs-navbar-nav-link-padding-x: 10px;
|
--bs-navbar-nav-link-padding-x: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-sidebar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.min-h-0 {
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.sidebar-brand .brand-text.fw-light {
|
.sidebar-brand .brand-text.fw-light {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar-version {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-version__inner {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,14 +82,16 @@
|
|||||||
function renderClientActionCell(client) {
|
function renderClientActionCell(client) {
|
||||||
var paused = Boolean(client.paused);
|
var paused = Boolean(client.paused);
|
||||||
var pauseButtonClass = 'btn btn-sm btn-info';
|
var pauseButtonClass = 'btn btn-sm btn-info';
|
||||||
|
var pauseButtonIcon = paused ? 'bi-play-fill' : 'bi-pause-fill';
|
||||||
var pauseButtonLabel = paused ? 'Resume' : 'Pause';
|
var pauseButtonLabel = paused ? 'Resume' : 'Pause';
|
||||||
var blackout = Boolean(client.blackout);
|
var blackout = Boolean(client.blackout);
|
||||||
var blackoutButtonClass = 'btn btn-sm ' + (blackout ? 'btn-success' : 'btn-secondary');
|
var blackoutButtonClass = 'btn btn-sm ' + (blackout ? 'btn-success' : 'btn-secondary');
|
||||||
|
var blackoutButtonIcon = blackout ? 'bi-eye' : 'bi-eye-slash';
|
||||||
var blackoutButtonLabel = blackout ? 'Restore' : 'Blackout';
|
var blackoutButtonLabel = blackout ? 'Restore' : 'Blackout';
|
||||||
var reloadConfirmMessage = 'Reloading will restart the player page. Continue?';
|
var reloadConfirmMessage = 'Reloading will restart the player page. Continue?';
|
||||||
var blackoutCommandValue = blackout ? 'false' : 'true';
|
var blackoutCommandValue = blackout ? 'false' : 'true';
|
||||||
|
|
||||||
return '<div class="d-flex flex-wrap gap-1"><form method="post" action="/admin/screens/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-confirm-message="' + escapeHtml(reloadConfirmMessage) + '" data-async-command><input type="hidden" name="command" value="reload" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="btn btn-sm btn-danger" data-action="reload"><i class="bi bi-arrow-repeat me-1" aria-hidden="true"></i>Reload</button></form><form method="post" action="/admin/screens/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="previous" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="btn btn-sm btn-outline-secondary" data-action="previous" aria-label="Previous slide"><i class="bi bi-skip-backward-fill" aria-hidden="true"></i></button></form><form method="post" action="/admin/screens/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="next" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="btn btn-sm btn-outline-secondary" data-action="next" aria-label="Next slide"><i class="bi bi-skip-forward-fill" aria-hidden="true"></i></button></form><form method="post" action="/admin/screens/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="pause" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="' + pauseButtonClass + '" data-action="pause"><i class="bi bi-pause-fill me-1" aria-hidden="true"></i>' + pauseButtonLabel + '</button></form><form method="post" action="/admin/screens/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="blackout" /><input type="hidden" name="blackout" value="' + blackoutCommandValue + '" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="' + blackoutButtonClass + '" data-action="blackout"><i class="bi bi-eye-slash me-1" aria-hidden="true"></i>' + blackoutButtonLabel + '</button></form></div>';
|
return '<div class="d-flex flex-wrap gap-1"><form method="post" action="/admin/screens/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-confirm-message="' + escapeHtml(reloadConfirmMessage) + '" data-async-command><input type="hidden" name="command" value="reload" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="btn btn-sm btn-danger" data-action="reload"><i class="bi bi-arrow-repeat me-1" aria-hidden="true"></i>Reload</button></form><form method="post" action="/admin/screens/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="previous" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="btn btn-sm btn-outline-secondary" data-action="previous" aria-label="Previous slide"><i class="bi bi-skip-backward-fill" aria-hidden="true"></i></button></form><form method="post" action="/admin/screens/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="next" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="btn btn-sm btn-outline-secondary" data-action="next" aria-label="Next slide"><i class="bi bi-skip-forward-fill" aria-hidden="true"></i></button></form><form method="post" action="/admin/screens/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="pause" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="' + pauseButtonClass + '" data-action="pause"><i class="bi bi-' + pauseButtonIcon + ' me-1" aria-hidden="true"></i>' + pauseButtonLabel + '</button></form><form method="post" action="/admin/screens/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="blackout" /><input type="hidden" name="blackout" value="' + blackoutCommandValue + '" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="' + blackoutButtonClass + '" data-action="blackout"><i class="bi bi-' + blackoutButtonIcon + ' me-1" aria-hidden="true"></i>' + blackoutButtonLabel + '</button></form></div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateClientActionCell(cell, client) {
|
function updateClientActionCell(cell, client) {
|
||||||
@@ -105,7 +107,7 @@
|
|||||||
|
|
||||||
var paused = Boolean(client.paused);
|
var paused = Boolean(client.paused);
|
||||||
setButtonVariant(pauseButton, ['btn-secondary', 'btn-outline-primary'], 'btn-info');
|
setButtonVariant(pauseButton, ['btn-secondary', 'btn-outline-primary'], 'btn-info');
|
||||||
pauseButton.innerHTML = '<i class="bi bi-pause-fill me-1" aria-hidden="true"></i>' + (paused ? 'Resume' : 'Pause');
|
pauseButton.innerHTML = '<i class="bi bi-' + (paused ? 'play-fill' : 'pause-fill') + ' me-1" aria-hidden="true"></i>' + (paused ? 'Resume' : 'Pause');
|
||||||
|
|
||||||
var pauseForm = pauseButton.form;
|
var pauseForm = pauseButton.form;
|
||||||
if (pauseForm) {
|
if (pauseForm) {
|
||||||
@@ -142,8 +144,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
var blackout = Boolean(client.blackout);
|
var blackout = Boolean(client.blackout);
|
||||||
|
var blackoutButtonIcon = blackout ? 'bi-eye' : 'bi-eye-slash';
|
||||||
setButtonVariant(blackoutButton, ['btn-success', 'btn-secondary'], blackout ? 'btn-success' : 'btn-secondary');
|
setButtonVariant(blackoutButton, ['btn-success', 'btn-secondary'], blackout ? 'btn-success' : 'btn-secondary');
|
||||||
blackoutButton.innerHTML = '<i class="bi bi-eye-slash me-1" aria-hidden="true"></i>' + (blackout ? 'Restore' : 'Blackout');
|
blackoutButton.innerHTML = '<i class="bi ' + blackoutButtonIcon + ' me-1" aria-hidden="true"></i>' + (blackout ? 'Restore' : 'Blackout');
|
||||||
|
|
||||||
var blackoutForm = blackoutButton.form;
|
var blackoutForm = blackoutButton.form;
|
||||||
if (blackoutForm) {
|
if (blackoutForm) {
|
||||||
@@ -357,8 +360,9 @@
|
|||||||
var label = allBlackout ? 'Restore all clients' : 'Blackout all clients';
|
var label = allBlackout ? 'Restore all clients' : 'Blackout all clients';
|
||||||
var blackoutForm = blackoutButton.form;
|
var blackoutForm = blackoutButton.form;
|
||||||
var blackoutInput = blackoutForm ? blackoutForm.querySelector('input[name="blackout"]') : null;
|
var blackoutInput = blackoutForm ? blackoutForm.querySelector('input[name="blackout"]') : null;
|
||||||
|
var blackoutButtonIcon = allBlackout ? 'bi-eye' : 'bi-eye-slash';
|
||||||
|
|
||||||
blackoutButton.innerHTML = (allBlackout ? '<i class="bi bi-eye me-1" aria-hidden="true"></i>' : '<i class="bi bi-eye-slash me-1" aria-hidden="true"></i>') + escapeHtml(label);
|
blackoutButton.innerHTML = '<i class="bi ' + blackoutButtonIcon + ' me-1" aria-hidden="true"></i>' + escapeHtml(label);
|
||||||
setButtonVariant(blackoutButton, ['btn-success', 'btn-danger', 'btn-secondary', 'btn-outline-secondary', 'btn-outline-dark'], 'btn-secondary');
|
setButtonVariant(blackoutButton, ['btn-success', 'btn-danger', 'btn-secondary', 'btn-outline-secondary', 'btn-outline-dark'], 'btn-secondary');
|
||||||
if (blackoutInput) {
|
if (blackoutInput) {
|
||||||
blackoutInput.value = allBlackout ? 'false' : 'true';
|
blackoutInput.value = allBlackout ? 'false' : 'true';
|
||||||
@@ -399,7 +403,14 @@
|
|||||||
}).then(function (response) {
|
}).then(function (response) {
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
return response.text().then(function (text) {
|
return response.text().then(function (text) {
|
||||||
throw new Error(text || 'Unable to rename client.');
|
var message = text || 'Unable to rename client.';
|
||||||
|
try {
|
||||||
|
var payload = JSON.parse(text);
|
||||||
|
message = payload && (payload.error || payload.message) ? String(payload.error || payload.message) : message;
|
||||||
|
} catch (_error) {
|
||||||
|
// fall back to the raw text body
|
||||||
|
}
|
||||||
|
throw new Error(message);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return response.json().catch(function () {
|
return response.json().catch(function () {
|
||||||
|
|||||||
@@ -40,7 +40,7 @@
|
|||||||
|
|
||||||
function getMessageVariant(message, fallbackVariant) {
|
function getMessageVariant(message, fallbackVariant) {
|
||||||
var text = String(message || '').trim();
|
var text = String(message || '').trim();
|
||||||
if (/^(unable to delete|cannot delete|can't delete)/i.test(text)) {
|
if (/^(unable to delete|cannot delete|can't delete)|\bstill (?:in use|linked|assigned|used)\b/i.test(text)) {
|
||||||
return 'danger';
|
return 'danger';
|
||||||
}
|
}
|
||||||
return String(fallbackVariant || 'success').trim().toLowerCase() || 'success';
|
return String(fallbackVariant || 'success').trim().toLowerCase() || 'success';
|
||||||
|
|||||||
@@ -15,10 +15,62 @@ module.exports = function registerAdminManageRoutes(app, deps) {
|
|||||||
const notifyPlayerScreens = deps.notifyPlayerScreens;
|
const notifyPlayerScreens = deps.notifyPlayerScreens;
|
||||||
const broadcastDashboardState = deps.broadcastDashboardState;
|
const broadcastDashboardState = deps.broadcastDashboardState;
|
||||||
const getScreenDeleteBlockMessage = deps.getScreenDeleteBlockMessage;
|
const getScreenDeleteBlockMessage = deps.getScreenDeleteBlockMessage;
|
||||||
|
const getScreenConnections = deps.getScreenConnections;
|
||||||
const getPlaylistDeleteBlockMessage = deps.getPlaylistDeleteBlockMessage;
|
const getPlaylistDeleteBlockMessage = deps.getPlaylistDeleteBlockMessage;
|
||||||
const forwardPlayerCommand = deps.forwardPlayerCommand;
|
const forwardPlayerCommand = deps.forwardPlayerCommand;
|
||||||
const PLAYER_PUBLIC_BASE_URL = deps.playerPublicBaseUrl;
|
const PLAYER_PUBLIC_BASE_URL = deps.playerPublicBaseUrl;
|
||||||
|
|
||||||
|
async function fetchAllScreenSlugs() {
|
||||||
|
const [rows] = await pool.query('SELECT slug FROM screens ORDER BY slug ASC');
|
||||||
|
return (rows || [])
|
||||||
|
.map(function (row) {
|
||||||
|
return String(row && row.slug ? row.slug : '').trim();
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
app.post('/admin/commands', async function (req, res, next) {
|
||||||
|
try {
|
||||||
|
const command = String((req.body && req.body.command) || req.query.command || '').trim().toLowerCase();
|
||||||
|
const blackoutValue = req.body && Object.prototype.hasOwnProperty.call(req.body, 'blackout')
|
||||||
|
? req.body.blackout
|
||||||
|
: req.query.blackout;
|
||||||
|
|
||||||
|
if (!command) {
|
||||||
|
return res.status(400).json({ error: 'Command is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command !== 'reload' && command !== 'blackout') {
|
||||||
|
return res.status(400).json({ error: 'Unsupported command' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const slugs = await fetchAllScreenSlugs();
|
||||||
|
if (!slugs.length) {
|
||||||
|
await broadcastDashboardState();
|
||||||
|
return res.json({ ok: true, command: command, sent: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = command === 'blackout'
|
||||||
|
? {
|
||||||
|
command: 'blackout',
|
||||||
|
blackout: blackoutValue === true || blackoutValue === 'true' || blackoutValue === '1' ? true : false
|
||||||
|
}
|
||||||
|
: 'reload';
|
||||||
|
|
||||||
|
const sentCount = await notifyPlayerScreens(slugs, payload);
|
||||||
|
await broadcastDashboardState();
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
ok: true,
|
||||||
|
command: command,
|
||||||
|
sent: sentCount,
|
||||||
|
blackout: command === 'blackout' ? Boolean(payload.blackout) : undefined
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.post('/admin/playlists', async function (req, res, next) {
|
app.post('/admin/playlists', async function (req, res, next) {
|
||||||
try {
|
try {
|
||||||
const name = String(req.body.name || '').trim();
|
const name = String(req.body.name || '').trim();
|
||||||
@@ -529,7 +581,7 @@ module.exports = function registerAdminManageRoutes(app, deps) {
|
|||||||
if (!screen) {
|
if (!screen) {
|
||||||
return res.status(404).send('Screen not found');
|
return res.status(404).send('Screen not found');
|
||||||
}
|
}
|
||||||
const blockMessage = await getScreenDeleteBlockMessage(pool, screen);
|
const blockMessage = await getScreenDeleteBlockMessage(pool, screen, getScreenConnections);
|
||||||
if (blockMessage) {
|
if (blockMessage) {
|
||||||
return res.redirect('/admin/screens?message=' + encodeURIComponent(blockMessage));
|
return res.redirect('/admin/screens?message=' + encodeURIComponent(blockMessage));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
module.exports = function registerAdminScreenCommandRoutes(app, deps) {
|
module.exports = function registerAdminScreenCommandRoutes(app, deps) {
|
||||||
const pool = deps.pool;
|
const pool = deps.pool;
|
||||||
const forwardPlayerCommand = deps.forwardPlayerCommand;
|
const forwardPlayerCommand = deps.forwardPlayerCommand;
|
||||||
|
const getScreenConnections = deps.getScreenConnections;
|
||||||
|
const isClientNameAvailable = deps.isClientNameAvailable;
|
||||||
|
const withClientNameReservation = deps.withClientNameReservation;
|
||||||
|
|
||||||
app.post('/admin/screens/:slug/commands', async function (req, res, next) {
|
app.post('/admin/screens/:slug/commands', async function (req, res, next) {
|
||||||
try {
|
try {
|
||||||
@@ -23,6 +26,110 @@ module.exports = function registerAdminScreenCommandRoutes(app, deps) {
|
|||||||
return res.status(404).json({ error: 'Screen not found' });
|
return res.status(404).json({ error: 'Screen not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (command === 'setclientname') {
|
||||||
|
const deviceId = String((req.body && (req.body.deviceId || req.body.clientId)) || req.query.deviceId || req.query.clientId || '').trim();
|
||||||
|
const clientName = String((req.body && req.body.clientName) || req.query.clientName || '').trim();
|
||||||
|
if (!deviceId) {
|
||||||
|
return res.status(400).json({ error: 'Device ID is required' });
|
||||||
|
}
|
||||||
|
if (!clientName) {
|
||||||
|
return res.status(400).json({ error: 'Client name is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const [currentRows] = await pool.query(
|
||||||
|
`SELECT client_name
|
||||||
|
FROM player_onboarding_devices
|
||||||
|
WHERE device_id = ?
|
||||||
|
LIMIT 1`,
|
||||||
|
[deviceId]
|
||||||
|
);
|
||||||
|
const onboardingRow = currentRows[0] || null;
|
||||||
|
const currentName = String(onboardingRow && onboardingRow.client_name ? onboardingRow.client_name : '').trim();
|
||||||
|
if (currentName && currentName.toLowerCase() === clientName.toLowerCase()) {
|
||||||
|
return res.json({
|
||||||
|
screen: screenRows[0],
|
||||||
|
screenSlug: slug,
|
||||||
|
command: command,
|
||||||
|
connectionId: connectionId || null,
|
||||||
|
deviceId: deviceId,
|
||||||
|
clientName: currentName,
|
||||||
|
ok: true,
|
||||||
|
unchanged: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (typeof withClientNameReservation !== 'function') {
|
||||||
|
return res.status(500).json({ error: 'Client name reservation is unavailable.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return withClientNameReservation(pool, clientName, async function () {
|
||||||
|
let liveConnections = [];
|
||||||
|
try {
|
||||||
|
const [screenSlugs] = await pool.query('SELECT slug FROM screens ORDER BY slug ASC');
|
||||||
|
const liveResults = await Promise.all((screenSlugs || []).map(async function (row) {
|
||||||
|
const screenSlug = String(row && row.slug ? row.slug : '').trim();
|
||||||
|
if (!screenSlug || typeof getScreenConnections !== 'function') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const liveResponse = await getScreenConnections(screenSlug);
|
||||||
|
return Array.isArray(liveResponse && liveResponse.connections) ? liveResponse.connections : [];
|
||||||
|
} catch (_error) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
liveConnections = liveResults.flat();
|
||||||
|
} catch (_error) {
|
||||||
|
liveConnections = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const available = await isClientNameAvailable(pool, clientName, deviceId, liveConnections);
|
||||||
|
if (!available) {
|
||||||
|
return res.status(409).json({ error: 'Client name already exists.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!onboardingRow) {
|
||||||
|
await forwardPlayerCommand(slug, {
|
||||||
|
command: command,
|
||||||
|
clientName: clientName,
|
||||||
|
clientId: connectionId || deviceId || null,
|
||||||
|
deviceId: deviceId || null
|
||||||
|
}, connectionId || deviceId || undefined);
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
screen: screenRows[0],
|
||||||
|
screenSlug: slug,
|
||||||
|
command: command,
|
||||||
|
connectionId: connectionId || null,
|
||||||
|
deviceId: deviceId,
|
||||||
|
clientName: clientName,
|
||||||
|
ok: true,
|
||||||
|
liveOnly: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const [updateResult] = await pool.query(
|
||||||
|
`UPDATE player_onboarding_devices pod
|
||||||
|
JOIN screens s ON s.id = pod.screen_id
|
||||||
|
SET pod.client_name = ?, pod.modified_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE s.slug = ? AND pod.device_id = ?`,
|
||||||
|
[clientName, slug, deviceId]
|
||||||
|
);
|
||||||
|
if (!updateResult.affectedRows) {
|
||||||
|
return res.status(404).json({ error: 'Client not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
screen: screenRows[0],
|
||||||
|
screenSlug: slug,
|
||||||
|
command: command,
|
||||||
|
connectionId: connectionId || null,
|
||||||
|
deviceId: deviceId,
|
||||||
|
clientName: clientName,
|
||||||
|
ok: true
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const commandPayload = req.body && typeof req.body === 'object' && !Array.isArray(req.body)
|
const commandPayload = req.body && typeof req.body === 'object' && !Array.isArray(req.body)
|
||||||
? Object.assign({}, req.body, { command: command })
|
? Object.assign({}, req.body, { command: command })
|
||||||
: { command: command };
|
: { command: command };
|
||||||
|
|||||||
@@ -67,12 +67,16 @@
|
|||||||
<form method="post" action="/admin/screens/{{screen_slug}}/commands" class="inline-form" data-async-command>
|
<form method="post" action="/admin/screens/{{screen_slug}}/commands" class="inline-form" data-async-command>
|
||||||
<input type="hidden" name="command" value="pause" />
|
<input type="hidden" name="command" value="pause" />
|
||||||
<input type="hidden" name="connectionId" value="{{id}}" />
|
<input type="hidden" name="connectionId" value="{{id}}" />
|
||||||
<button type="submit" class="btn btn-sm btn-info">{{#if paused}}Resume{{else}}Pause{{/if}}</button>
|
<button type="submit" class="btn btn-sm btn-info">
|
||||||
|
<i class="bi bi-pause-fill me-1" aria-hidden="false"></i>Pause
|
||||||
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<form method="post" action="/admin/screens/{{screen_slug}}/commands" class="inline-form" data-async-command>
|
<form method="post" action="/admin/screens/{{screen_slug}}/commands" class="inline-form" data-async-command>
|
||||||
<input type="hidden" name="command" value="blackout" />
|
<input type="hidden" name="command" value="blackout" />
|
||||||
<input type="hidden" name="connectionId" value="{{id}}" />
|
<input type="hidden" name="connectionId" value="{{id}}" />
|
||||||
<button type="submit" class="btn btn-sm {{#if blackout}}btn-success{{else}}btn-secondary{{/if}}">{{#if blackout}}Restore{{else}}Blackout{{/if}}</button>
|
<button type="submit" class="btn btn-sm {{#if blackout}}btn-success{{else}}btn-secondary{{/if}}">
|
||||||
|
<i class="bi bi-eye-slash me-1" aria-hidden="false"></i>Blackout
|
||||||
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -68,7 +68,7 @@
|
|||||||
type="submit"
|
type="submit"
|
||||||
class="btn btn-secondary dashboard-action-button"
|
class="btn btn-secondary dashboard-action-button"
|
||||||
id="dashboard-blackout-all-button"
|
id="dashboard-blackout-all-button"
|
||||||
><i class="bi bi-eye-slash me-1" aria-hidden="true"></i>Blackout all clients</button>
|
><i class="bi bi-eye-slash me-1" aria-hidden="false"></i>Blackout all clients</button>
|
||||||
<span class="dashboard-action-help">Blank active players immediately.</span>
|
<span class="dashboard-action-help">Blank active players immediately.</span>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
<title>{{title}} - Pulse</title>
|
<title>{{title}} - Pulse</title>
|
||||||
<link rel="icon" type="image/png" href="/assets/favicon.png" />
|
<link rel="icon" type="image/png" href="/assets/favicon.png" />
|
||||||
<script src="/assets/js/theme-init.js"></script>
|
<script src="/assets/js/theme-init.js"></script>
|
||||||
|
<link rel="preload" href="/assets/adminlte/bootstrap-icons/fonts/bootstrap-icons.woff2" as="font" type="font/woff2" crossorigin="anonymous" />
|
||||||
|
<link rel="preload" href="/assets/adminlte/bootstrap-icons/fonts/bootstrap-icons.woff" as="font" type="font/woff" crossorigin="anonymous" />
|
||||||
<link rel="stylesheet" href="/assets/adminlte/bootstrap-icons/css/bootstrap-icons.min.css" />
|
<link rel="stylesheet" href="/assets/adminlte/bootstrap-icons/css/bootstrap-icons.min.css" />
|
||||||
<link rel="stylesheet" href="/assets/adminlte/css/adminlte.min.css" />
|
<link rel="stylesheet" href="/assets/adminlte/css/adminlte.min.css" />
|
||||||
<link rel="stylesheet" href="/assets/css/theme-custom.css" />
|
<link rel="stylesheet" href="/assets/css/theme-custom.css" />
|
||||||
@@ -126,14 +128,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<aside class="app-sidebar bg-body-secondary shadow" data-bs-theme="dark">
|
<aside class="app-sidebar bg-body-secondary shadow d-flex flex-column" data-bs-theme="dark">
|
||||||
<div class="sidebar-brand">
|
<div class="sidebar-brand">
|
||||||
<a href="/admin" class="brand-link">
|
<a href="/admin" class="brand-link">
|
||||||
<span class="brand-text fw-light">Pulse Signage</span>
|
<span class="brand-text fw-light">Pulse Signage</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="sidebar-wrapper">
|
<div class="sidebar-wrapper d-flex flex-column flex-grow-1 min-h-0">
|
||||||
<nav class="mt-2" aria-label="Main navigation">
|
<nav class="mt-2 flex-grow-1" aria-label="Main navigation">
|
||||||
<ul class="nav sidebar-menu flex-column" data-lte-toggle="treeview" data-accordion="false" role="menu" id="navigation">
|
<ul class="nav sidebar-menu flex-column" data-lte-toggle="treeview" data-accordion="false" role="menu" id="navigation">
|
||||||
<li class="nav-header">MAIN NAVIGATION</li>
|
<li class="nav-header">MAIN NAVIGATION</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
@@ -187,6 +189,11 @@
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
|
<div class="sidebar-version mt-auto pt-2 text-secondary small">
|
||||||
|
<div class="sidebar-version__inner border-top pt-2">
|
||||||
|
v{{appVersion}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user