Release 1.5.16
Publish Docker Image / build-and-push (push) Successful in 1m18s

This commit is contained in:
2026-07-26 22:03:36 +01:00
parent 42279d95aa
commit de1469c29d
20 changed files with 679 additions and 242 deletions
+16
View File
@@ -6,6 +6,22 @@ All notable changes to this project will be documented in this file.
- No unreleased changes recorded yet.
## 1.5.16 - 2026-07-26
### Added
- Background task handling was split into dedicated handler and scheduling modules, with startup data-source refreshes and unused-upload cleanup wired through the shared setup flow.
### Changed
- The admin shell, shared theme, and toast presentation were refreshed to match the newer layout and notification styling.
- Playlist scheduling now handles video-duration toggles and already-assigned slides more clearly in the picker and editor UI.
- RBAC and settings views were updated to align with the revised admin layout and shared table behavior.
### Fixed
- Template and slide thumbnail refreshes now run through the background task queue so template-wide thumbnail regeneration stays consistent.
## 1.5.15 - 2026-07-26
### Changed
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "pulse-signage",
"version": "1.5.15",
"version": "1.5.16",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pulse-signage",
"version": "1.5.15",
"version": "1.5.16",
"dependencies": {
"@sparticuz/chromium": "^137.0.0",
"bootstrap-icons": "1.11.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "pulse-signage",
"version": "1.5.15",
"version": "1.5.16",
"private": false,
"description": "Pulse Signage application with MySQL and media storage",
"repository": {
+72 -1
View File
@@ -130,6 +130,75 @@ function findTopLevelOrderByIndex(sql) {
return lastOrderByIndex;
}
function findTopLevelWhereIndex(sql) {
const text = String(sql || '');
let depth = 0;
let inSingleQuote = false;
let inDoubleQuote = false;
let inBacktick = false;
let lastWhereIndex = -1;
for (let index = 0; index < text.length; index += 1) {
const character = text[index];
const previousCharacter = index > 0 ? text[index - 1] : '';
if (inSingleQuote) {
if (character === '\'' && previousCharacter !== '\\') {
inSingleQuote = false;
}
continue;
}
if (inDoubleQuote) {
if (character === '"' && previousCharacter !== '\\') {
inDoubleQuote = false;
}
continue;
}
if (inBacktick) {
if (character === '`') {
inBacktick = false;
}
continue;
}
if (character === '\'') {
inSingleQuote = true;
continue;
}
if (character === '"') {
inDoubleQuote = true;
continue;
}
if (character === '`') {
inBacktick = true;
continue;
}
if (character === '(') {
depth += 1;
continue;
}
if (character === ')' && depth > 0) {
depth -= 1;
continue;
}
if (depth === 0 && /[wW]/.test(character)) {
const remaining = text.slice(index);
if (/^where\b/i.test(remaining)) {
lastWhereIndex = index;
}
}
}
return lastWhereIndex;
}
function buildSearchFilter(searchColumns, searchTerm) {
const columns = Array.isArray(searchColumns) ? searchColumns.map(function (column) {
return String(column || '').trim();
@@ -175,7 +244,9 @@ async function fetchPagedRows(pool, options) {
orderBySql = selectSql.slice(orderByIndex).trim();
}
const filteredSelectSql = `${baseSelectSql}${searchFilter.clause}`;
const hasTopLevelWhere = findTopLevelWhereIndex(baseSelectSql) >= 0;
const searchClause = searchFilter.clause ? (hasTopLevelWhere ? searchFilter.clause.replace(/^\s*WHERE\s+/i, ' AND ') : searchFilter.clause) : '';
const filteredSelectSql = `${baseSelectSql}${searchClause}`;
const countQuery = searchFilter.clause
? `SELECT COUNT(*) AS count FROM (${filteredSelectSql}) AS filtered_rows`
: countSql;
+18 -167
View File
@@ -16,7 +16,8 @@ const registerAdminScreenCommandRoutes = require('./web/routes/admin/client-comm
const registerAdminContentRoutes = require('./web/routes/admin/content');
const registerAdminDataSourceRoutes = require('./web/routes/data-sources');
const registerAdminSettingsRoutes = require('./web/routes/settings/background-tasks');
const { createBackgroundTaskQueue, normalizeIntervalMs } = require('./web/lib/background-task-queue');
const { createBackgroundTaskQueue } = require('./web/lib/background-task-queue');
const { createBackgroundTaskSetup } = require('./web/lib/background-task-setup');
const { captureSlideThumbnail } = require('./web/lib/slide-thumbnails');
const { refreshApiSource, refreshRssFeed } = require('./web/lib/data-source-refresh');
const { createWebBootstrap } = require('./web/bootstrap');
@@ -105,6 +106,21 @@ async function start() {
const syncPlaylistUploadsOnChange = webBootstrap.syncPlaylistUploadsOnChange;
const syncExistingUploadsToPlayer = webBootstrap.syncExistingUploadsToPlayer;
const runMediaSyncTask = webBootstrap.runMediaSyncTask;
const backgroundTaskSetup = createBackgroundTaskSetup({
pool: pool,
common: common,
backgroundTaskQueue: backgroundTaskQueue,
collectUploadPathsFromDirectory: collectUploadPathsFromDirectory,
removeUnusedUploadFiles: removeUnusedUploadFiles,
captureSlideThumbnail: captureSlideThumbnail,
refreshApiSource: refreshApiSource,
refreshRssFeed: refreshRssFeed,
runMediaSyncTask: runMediaSyncTask,
mediaDir: MEDIA_DIR,
uploadsDir: UPLOADS_DIR,
playerInternalBaseUrl: PLAYER_INTERNAL_BASE_URL,
dataSourceStartupRefreshStaggerMs: DATA_SOURCE_STARTUP_REFRESH_STAGGER_MS
});
const broadcastDashboardState = webBootstrap.broadcastDashboardState;
const sessionService = createSessionService({
sessionCookieName: SESSION_COOKIE_NAME,
@@ -119,51 +135,6 @@ async function start() {
const createUserSession = sessionService.createUserSession;
const requireAuth = sessionService.requireAuth;
backgroundTaskQueue.setTaskHandler('media-sync', function (task) {
return runMediaSyncTask(task && task.payload ? task.payload : task);
});
backgroundTaskQueue.setTaskHandler('data-source-refresh', async function (task) {
const payload = task && task.payload ? task.payload : {};
const sourceType = String(payload.sourceType || '').trim();
const sourceId = Number(payload.sourceId || 0);
if (sourceType === 'api-source') {
const apiSource = await common.fetchApiSourceById(pool, sourceId);
if (!apiSource) {
throw new Error('API source not found.');
}
return refreshApiSource(pool, common, apiSource.id, apiSource.api_url, Number(payload.actorId) || null);
}
if (sourceType === 'rss-feed') {
const rssFeed = await common.fetchRssFeedById(pool, sourceId);
if (!rssFeed) {
throw new Error('RSS feed not found.');
}
return refreshRssFeed(pool, common, rssFeed.id, rssFeed.feed_url, rssFeed.item_limit, Number(payload.actorId) || null);
}
throw new Error('Unsupported data source refresh task.');
});
backgroundTaskQueue.setTaskHandler('slide-thumbnail-refresh', async function (task) {
const payload = task && task.payload ? task.payload : {};
const slideId = Number(payload.slideId || 0);
if (!Number.isFinite(slideId) || slideId <= 0) {
throw new Error('Slide id is required.');
}
return captureSlideThumbnail({
pool: pool,
common: common,
mediaDir: MEDIA_DIR,
baseUrl: PLAYER_INTERNAL_BASE_URL,
slideId: slideId,
previousThumbnailPath: payload.previousThumbnailPath || null
});
});
app.use(express.urlencoded({ extended: true }));
app.use(express.json());
app.use('/assets', express.static(ASSET_DIR));
@@ -374,127 +345,7 @@ async function start() {
// Ensure schema and mirror media before the web service starts handling traffic.
await common.ensureSchema(pool, { mediaDir: MEDIA_DIR });
await backgroundTaskQueue.initialize();
async function syncRecurringRefreshes() {
const apiSourcesData = await common.fetchApiSourcesData(pool);
(apiSourcesData.apiSources || []).forEach(function (apiSource) {
backgroundTaskQueue.registerRecurringTask({
key: 'api-source-refresh:' + Number(apiSource.id),
title: 'API source refresh',
category: 'data-source',
intervalMs: normalizeIntervalMs(apiSource.update_interval_value, apiSource.update_interval_unit),
metadata: {
sourceType: 'api-source',
sourceId: Number(apiSource.id),
sourceName: apiSource.name
},
run: function () {
return refreshApiSource(pool, common, apiSource.id, apiSource.api_url, null);
}
});
});
const rssFeedsData = await common.fetchRssFeedsData(pool);
(rssFeedsData.rssFeeds || []).forEach(function (rssFeed) {
backgroundTaskQueue.registerRecurringTask({
key: 'rss-feed-refresh:' + Number(rssFeed.id),
title: 'RSS feed refresh',
category: 'data-source',
intervalMs: normalizeIntervalMs(rssFeed.update_interval_value, rssFeed.update_interval_unit),
metadata: {
sourceType: 'rss-feed',
sourceId: Number(rssFeed.id),
sourceName: rssFeed.name
},
run: function () {
return refreshRssFeed(pool, common, rssFeed.id, rssFeed.feed_url, rssFeed.item_limit, null);
}
});
});
}
async function registerRecurringMaintenanceTasks() {
async function runUnusedUploadSweep() {
const uploadPaths = await collectUploadPathsFromDirectory(UPLOADS_DIR);
if (!uploadPaths.length) {
return;
}
await removeUnusedUploadFiles(pool, UPLOADS_DIR, uploadPaths);
}
backgroundTaskQueue.registerRecurringTask({
key: 'unused-upload-sweep',
title: 'Unused upload sweep',
category: 'media-sync',
intervalMs: 24 * 60 * 60 * 1000,
metadata: {
uploadDir: UPLOADS_DIR
},
run: runUnusedUploadSweep
});
}
async function scheduleInitialDataSourceRefreshes() {
const apiSourcesData = await common.fetchApiSourcesData(pool);
const rssFeedsData = await common.fetchRssFeedsData(pool);
const startupSources = [];
(apiSourcesData.apiSources || []).forEach(function (apiSource) {
startupSources.push({
type: 'api-source',
id: Number(apiSource.id),
name: apiSource.name,
run: function () {
return refreshApiSource(pool, common, apiSource.id, apiSource.api_url, null);
}
});
});
(rssFeedsData.rssFeeds || []).forEach(function (rssFeed) {
startupSources.push({
type: 'rss-feed',
id: Number(rssFeed.id),
name: rssFeed.name,
run: function () {
return refreshRssFeed(pool, common, rssFeed.id, rssFeed.feed_url, rssFeed.item_limit, null);
}
});
});
startupSources.forEach(function (source, index) {
const startupDelayMs = index * DATA_SOURCE_STARTUP_REFRESH_STAGGER_MS;
setTimeout(function () {
backgroundTaskQueue.enqueueTask({
key: 'startup-data-source-refresh:' + source.type + ':' + source.id + ':' + Date.now(),
title: source.type === 'rss-feed' ? 'RSS feed refresh' : 'API source refresh',
category: 'data-source',
taskType: 'data-source-refresh',
metadata: {
sourceType: source.type,
sourceId: source.id,
sourceName: source.name,
startupRefresh: true
},
payload: {
sourceType: source.type,
sourceId: source.id,
sourceName: source.name,
startupRefresh: true
}
}).catch(function (error) {
console.warn('Unable to queue startup refresh for ' + source.type + ' ' + source.id + ':', error);
});
}, startupDelayMs);
});
}
await syncRecurringRefreshes();
await registerRecurringMaintenanceTasks();
scheduleInitialDataSourceRefreshes().catch(function (error) {
console.warn('Unable to schedule startup data source refreshes:', error);
});
await backgroundTaskSetup.initialize();
fs.mkdirSync(MEDIA_DIR, { recursive: true });
await syncExistingUploadsToPlayer(pool, MEDIA_DIR).catch(function (error) {
+93
View File
@@ -0,0 +1,93 @@
function registerBackgroundTaskHandlers(options) {
const pool = options && options.pool;
const common = options && options.common;
const backgroundTaskQueue = options && options.backgroundTaskQueue;
const captureSlideThumbnail = options && options.captureSlideThumbnail;
const refreshApiSource = options && options.refreshApiSource;
const refreshRssFeed = options && options.refreshRssFeed;
const runMediaSyncTask = options && options.runMediaSyncTask;
const mediaDir = String(options && options.mediaDir || '').trim();
const playerInternalBaseUrl = String(options && options.playerInternalBaseUrl || '').trim();
if (!pool || !common || !backgroundTaskQueue || !captureSlideThumbnail || !refreshApiSource || !refreshRssFeed || !runMediaSyncTask || !mediaDir || !playerInternalBaseUrl) {
throw new Error('registerBackgroundTaskHandlers requires the background task dependencies.');
}
backgroundTaskQueue.setTaskHandler('media-sync', function (task) {
return runMediaSyncTask(task && task.payload ? task.payload : task);
});
backgroundTaskQueue.setTaskHandler('data-source-refresh', async function (task) {
const payload = task && task.payload ? task.payload : {};
const sourceType = String(payload.sourceType || '').trim();
const sourceId = Number(payload.sourceId || 0);
if (sourceType === 'api-source') {
const apiSource = await common.fetchApiSourceById(pool, sourceId);
if (!apiSource) {
throw new Error('API source not found.');
}
return refreshApiSource(pool, common, apiSource.id, apiSource.api_url, Number(payload.actorId) || null);
}
if (sourceType === 'rss-feed') {
const rssFeed = await common.fetchRssFeedById(pool, sourceId);
if (!rssFeed) {
throw new Error('RSS feed not found.');
}
return refreshRssFeed(pool, common, rssFeed.id, rssFeed.feed_url, rssFeed.item_limit, Number(payload.actorId) || null);
}
throw new Error('Unsupported data source refresh task.');
});
backgroundTaskQueue.setTaskHandler('slide-thumbnail-refresh', async function (task) {
const payload = task && task.payload ? task.payload : {};
const slideId = Number(payload.slideId || 0);
if (!Number.isFinite(slideId) || slideId <= 0) {
throw new Error('Slide id is required.');
}
return captureSlideThumbnail({
pool: pool,
common: common,
mediaDir: mediaDir,
baseUrl: playerInternalBaseUrl,
slideId: slideId,
previousThumbnailPath: payload.previousThumbnailPath || null
});
});
backgroundTaskQueue.setTaskHandler('template-slide-thumbnail-refresh', async function (task) {
const payload = task && task.payload ? task.payload : {};
const templateId = Number(payload.templateId || 0);
if (!Number.isFinite(templateId) || templateId <= 0) {
throw new Error('Template id is required.');
}
const [slides] = await pool.query(
'SELECT id, thumbnail_path FROM slides WHERE template_id = ? ORDER BY id ASC',
[templateId]
);
for (const slide of slides || []) {
const slideId = Number(slide && slide.id || 0);
if (!Number.isFinite(slideId) || slideId <= 0) {
continue;
}
await captureSlideThumbnail({
pool: pool,
common: common,
mediaDir: mediaDir,
baseUrl: playerInternalBaseUrl,
slideId: slideId,
previousThumbnailPath: slide && slide.thumbnail_path ? slide.thumbnail_path : null
});
}
return { templateId: templateId, slideCount: Array.isArray(slides) ? slides.length : 0 };
});
}
module.exports = { registerBackgroundTaskHandlers };
+16 -6
View File
@@ -628,14 +628,24 @@ function createBackgroundTaskQueue(options) {
return Array.from(tasksById.values())
.slice()
.sort(function (left, right) {
const leftQueuedTime = left && left.createdAt ? Date.parse(left.createdAt) : NaN;
const rightQueuedTime = right && right.createdAt ? Date.parse(right.createdAt) : NaN;
if (Number.isFinite(leftQueuedTime) && Number.isFinite(rightQueuedTime) && leftQueuedTime !== rightQueuedTime) {
return rightQueuedTime - leftQueuedTime;
const leftStartedTime = left && left.startedAt ? Date.parse(left.startedAt) : NaN;
const rightStartedTime = right && right.startedAt ? Date.parse(right.startedAt) : NaN;
if (Number.isFinite(leftStartedTime) && Number.isFinite(rightStartedTime) && leftStartedTime !== rightStartedTime) {
return rightStartedTime - leftStartedTime;
}
if (Number.isFinite(leftQueuedTime) !== Number.isFinite(rightQueuedTime)) {
return Number.isFinite(leftQueuedTime) ? -1 : 1;
if (Number.isFinite(leftStartedTime) !== Number.isFinite(rightStartedTime)) {
return Number.isFinite(leftStartedTime) ? -1 : 1;
}
const leftCreatedTime = left && left.createdAt ? Date.parse(left.createdAt) : NaN;
const rightCreatedTime = right && right.createdAt ? Date.parse(right.createdAt) : NaN;
if (Number.isFinite(leftCreatedTime) && Number.isFinite(rightCreatedTime) && leftCreatedTime !== rightCreatedTime) {
return rightCreatedTime - leftCreatedTime;
}
if (Number.isFinite(leftCreatedTime) !== Number.isFinite(rightCreatedTime)) {
return Number.isFinite(leftCreatedTime) ? -1 : 1;
}
return right.id - left.id;
+146
View File
@@ -0,0 +1,146 @@
const { normalizeIntervalMs } = require('./background-task-queue');
function registerBackgroundTaskScheduling(options) {
const pool = options && options.pool;
const common = options && options.common;
const backgroundTaskQueue = options && options.backgroundTaskQueue;
const collectUploadPathsFromDirectory = options && options.collectUploadPathsFromDirectory;
const removeUnusedUploadFiles = options && options.removeUnusedUploadFiles;
const refreshApiSource = options && options.refreshApiSource;
const refreshRssFeed = options && options.refreshRssFeed;
const uploadsDir = String(options && options.uploadsDir || '').trim();
const dataSourceStartupRefreshStaggerMs = Math.max(100, Number(options && options.dataSourceStartupRefreshStaggerMs || 250));
if (!pool || !common || !backgroundTaskQueue || !collectUploadPathsFromDirectory || !removeUnusedUploadFiles || !refreshApiSource || !refreshRssFeed || !uploadsDir) {
throw new Error('registerBackgroundTaskScheduling requires the background task dependencies.');
}
async function syncRecurringRefreshes() {
const apiSourcesData = await common.fetchApiSourcesData(pool);
(apiSourcesData.apiSources || []).forEach(function (apiSource) {
backgroundTaskQueue.registerRecurringTask({
key: 'api-source-refresh:' + Number(apiSource.id),
title: 'API source refresh',
category: 'data-source',
intervalMs: normalizeIntervalMs(apiSource.update_interval_value, apiSource.update_interval_unit),
metadata: {
sourceType: 'api-source',
sourceId: Number(apiSource.id),
sourceName: apiSource.name
},
run: function () {
return refreshApiSource(pool, common, apiSource.id, apiSource.api_url, null);
}
});
});
const rssFeedsData = await common.fetchRssFeedsData(pool);
(rssFeedsData.rssFeeds || []).forEach(function (rssFeed) {
backgroundTaskQueue.registerRecurringTask({
key: 'rss-feed-refresh:' + Number(rssFeed.id),
title: 'RSS feed refresh',
category: 'data-source',
intervalMs: normalizeIntervalMs(rssFeed.update_interval_value, rssFeed.update_interval_unit),
metadata: {
sourceType: 'rss-feed',
sourceId: Number(rssFeed.id),
sourceName: rssFeed.name
},
run: function () {
return refreshRssFeed(pool, common, rssFeed.id, rssFeed.feed_url, rssFeed.item_limit, null);
}
});
});
}
async function registerRecurringMaintenanceTasks() {
async function runUnusedUploadSweep() {
const uploadPaths = await collectUploadPathsFromDirectory(uploadsDir);
if (!uploadPaths.length) {
return;
}
await removeUnusedUploadFiles(pool, uploadsDir, uploadPaths);
}
backgroundTaskQueue.registerRecurringTask({
key: 'unused-upload-sweep',
title: 'Unused upload sweep',
category: 'media-sync',
intervalMs: 24 * 60 * 60 * 1000,
metadata: {
uploadDir: uploadsDir
},
run: runUnusedUploadSweep
});
}
async function scheduleInitialDataSourceRefreshes() {
const apiSourcesData = await common.fetchApiSourcesData(pool);
const rssFeedsData = await common.fetchRssFeedsData(pool);
const startupSources = [];
(apiSourcesData.apiSources || []).forEach(function (apiSource) {
startupSources.push({
type: 'api-source',
id: Number(apiSource.id),
name: apiSource.name,
run: function () {
return refreshApiSource(pool, common, apiSource.id, apiSource.api_url, null);
}
});
});
(rssFeedsData.rssFeeds || []).forEach(function (rssFeed) {
startupSources.push({
type: 'rss-feed',
id: Number(rssFeed.id),
name: rssFeed.name,
run: function () {
return refreshRssFeed(pool, common, rssFeed.id, rssFeed.feed_url, rssFeed.item_limit, null);
}
});
});
startupSources.forEach(function (source, index) {
const startupDelayMs = index * dataSourceStartupRefreshStaggerMs;
setTimeout(function () {
backgroundTaskQueue.enqueueTask({
key: 'startup-data-source-refresh:' + source.type + ':' + source.id + ':' + Date.now(),
title: source.type === 'rss-feed' ? 'RSS feed refresh' : 'API source refresh',
category: 'data-source',
taskType: 'data-source-refresh',
metadata: {
sourceType: source.type,
sourceId: source.id,
sourceName: source.name,
startupRefresh: true
},
payload: {
sourceType: source.type,
sourceId: source.id,
sourceName: source.name,
startupRefresh: true
}
}).catch(function (error) {
console.warn('Unable to queue startup refresh for ' + source.type + ' ' + source.id + ':', error);
});
}, startupDelayMs);
});
}
async function initialize() {
await syncRecurringRefreshes();
await registerRecurringMaintenanceTasks();
scheduleInitialDataSourceRefreshes().catch(function (error) {
console.warn('Unable to schedule startup data source refreshes:', error);
});
}
return {
initialize: initialize
};
}
module.exports = { registerBackgroundTaskScheduling };
+32
View File
@@ -0,0 +1,32 @@
const { registerBackgroundTaskHandlers } = require('./background-task-handlers');
const { registerBackgroundTaskScheduling } = require('./background-task-scheduling');
function createBackgroundTaskSetup(options) {
const pool = options && options.pool;
const common = options && options.common;
const backgroundTaskQueue = options && options.backgroundTaskQueue;
const collectUploadPathsFromDirectory = options && options.collectUploadPathsFromDirectory;
const removeUnusedUploadFiles = options && options.removeUnusedUploadFiles;
const refreshApiSource = options && options.refreshApiSource;
const refreshRssFeed = options && options.refreshRssFeed;
const uploadsDir = String(options && options.uploadsDir || '').trim();
const dataSourceStartupRefreshStaggerMs = Math.max(100, Number(options && options.dataSourceStartupRefreshStaggerMs || 250));
if (!pool || !common || !backgroundTaskQueue || !collectUploadPathsFromDirectory || !removeUnusedUploadFiles || !refreshApiSource || !refreshRssFeed || !uploadsDir) {
throw new Error('createBackgroundTaskSetup requires the background task dependencies.');
}
async function initialize() {
registerBackgroundTaskHandlers(options);
const backgroundTaskScheduling = registerBackgroundTaskScheduling(options);
if (backgroundTaskScheduling && typeof backgroundTaskScheduling.initialize === 'function') {
await backgroundTaskScheduling.initialize();
}
}
return {
initialize: initialize
};
}
module.exports = { createBackgroundTaskSetup };
+8 -2
View File
@@ -134,7 +134,11 @@ async function fetchUsersWithRoles(pool) {
});
}
async function fetchUsersWithRolesPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
async function fetchUsersWithRolesPage(pool, page, pageSize, searchTerm, sortKey, sortDirection, options) {
const excludedUserId = Number(options && options.excludeUserId);
const hasExcludedUserId = Number.isInteger(excludedUserId) && excludedUserId > 0;
const whereSql = hasExcludedUserId ? 'WHERE u.id <> ?' : '';
const queryArgs = hasExcludedUserId ? [excludedUserId] : [];
const paged = await fetchPagedRows(pool, {
selectSql: `SELECT u.id, u.name, u.username, u.created_at, u.modified_at,
COALESCE(role_data.role_names, '') AS role_names,
@@ -148,8 +152,10 @@ async function fetchUsersWithRolesPage(pool, page, pageSize, searchTerm, sortKey
JOIN roles r ON r.id = ur.role_id
GROUP BY ur.user_id
) role_data ON role_data.user_id = u.id
${whereSql}
ORDER BY u.id ASC`,
countSql: 'SELECT COUNT(*) AS count FROM users',
countSql: `SELECT COUNT(*) AS count FROM users u ${whereSql}`,
params: queryArgs,
searchColumns: ['u.name', 'u.username', 'role_data.role_names'],
searchTerm: searchTerm,
sortColumns: {
+6 -3
View File
@@ -1552,6 +1552,10 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
z-index: 1085;
}
.app-toast-container {
top: calc(40px + 1rem);
}
.is-hidden {
display: none !important;
}
@@ -1747,9 +1751,8 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
background: transparent;
max-width: none;
overflow: hidden;
width: min(calc(100vw - 1rem), 42rem);
max-height: calc(100vh - 1rem);
height: auto;
width: min(calc(100vw - 2rem), 56rem);
max-height: calc(100vh - 0.25rem);
margin: 0;
}
@@ -353,6 +353,25 @@
var rowKeyInput = form.querySelector('[name="row_key"]');
var dayCheckboxes = form.querySelectorAll('[name="schedule_days"]');
function getDayButton(checkbox) {
return checkbox && checkbox.id ? form.querySelector('label[for="' + checkbox.id + '"]') : null;
}
function syncDayButtonState(checkbox) {
var button = getDayButton(checkbox);
if (!button) {
return;
}
button.classList.toggle('active', Boolean(checkbox.checked));
button.setAttribute('aria-pressed', checkbox.checked ? 'true' : 'false');
}
function syncAllDayButtonStates() {
Array.prototype.forEach.call(dayCheckboxes, syncDayButtonState);
}
function clearScheduleValidity() {
[startDateInput, endDateInput, startTimeInput, endTimeInput].forEach(function (input) {
if (input) {
@@ -412,7 +431,20 @@
}
});
Array.prototype.forEach.call(dayCheckboxes, function (checkbox) {
checkbox.addEventListener('change', clearScheduleValidity);
checkbox.addEventListener('change', function () {
syncDayButtonState(checkbox);
clearScheduleValidity();
});
var button = getDayButton(checkbox);
if (button) {
button.addEventListener('click', function (event) {
event.preventDefault();
checkbox.checked = !checkbox.checked;
checkbox.dispatchEvent(new Event('change', { bubbles: true }));
checkbox.focus();
});
}
});
select.addEventListener('change', clearScheduleValidity);
@@ -436,6 +468,7 @@
select.addEventListener('change', updateVisibility);
updateVisibility();
syncAllDayButtonStates();
form.addEventListener('submit', function (event) {
event.preventDefault();
+113 -9
View File
@@ -1,4 +1,110 @@
(function () {
function getToastTitle(variant) {
var textVariant = String(variant || '').trim().toLowerCase();
if (textVariant === 'danger') {
return 'Error';
}
if (textVariant === 'warning') {
return 'Warning';
}
if (textVariant === 'success') {
return 'Success';
}
return 'Pulse';
}
function getToastTimeLabel(createdAt) {
var timestamp = Number(createdAt);
if (!timestamp || Number.isNaN(timestamp)) {
return 'just now';
}
var elapsed = Date.now() - timestamp;
if (elapsed < 60 * 1000) {
return 'just now';
}
var minutes = Math.floor(elapsed / (60 * 1000));
if (minutes < 60) {
return minutes + ' min' + (minutes === 1 ? '' : 's') + ' ago';
}
var hours = Math.floor(minutes / 60);
if (hours < 24) {
return hours + ' hour' + (hours === 1 ? '' : 's') + ' ago';
}
var days = Math.floor(hours / 24);
if (days < 7) {
return days + ' day' + (days === 1 ? '' : 's') + ' ago';
}
return new Date(timestamp).toLocaleString();
}
function getToastButtonClass(variant) {
var textVariant = String(variant || '').trim().toLowerCase();
return textVariant === 'light' || textVariant === 'warning' ? 'btn-close' : 'btn-close-white';
}
function getToastIconClass(variant) {
var textVariant = String(variant || '').trim().toLowerCase();
if (textVariant === 'success') {
return 'bi-check-circle-fill';
}
if (textVariant === 'warning') {
return 'bi-exclamation-triangle-fill';
}
if (textVariant === 'danger') {
return 'bi-x-circle-fill';
}
if (textVariant === 'primary') {
return 'bi-bell-fill';
}
return 'bi-info-circle-fill';
}
function buildToastMarkup(title, timeLabel, buttonClass, iconClass) {
return '<div class="toast-header border-0">' +
'<i class="bi ' + iconClass + ' me-2"></i>' +
'<strong class="me-auto">' + title + '</strong>' +
'<small class="toast-time text-nowrap">' + timeLabel + '</small>' +
'<button type="button" class="btn-close ' + buttonClass + ' ms-2" data-bs-dismiss="toast" aria-label="Dismiss notification"></button>' +
'</div>' +
'<div class="toast-body"></div>';
}
function setToastHeader(toast, variant, createdAt) {
if (!toast) {
return;
}
var header = toast.querySelector('.toast-header');
var icon = toast.querySelector('.toast-header i');
var title = toast.querySelector('.toast-header .me-auto');
var time = toast.querySelector('.toast-time');
var closeButton = toast.querySelector('.toast-header .btn-close');
var nextVariant = getMessageVariant('', variant);
if (!header) {
return;
}
header.className = 'toast-header border-0 text-bg-' + nextVariant;
if (icon) {
icon.className = 'bi ' + getToastIconClass(nextVariant) + ' me-2';
}
if (title) {
title.textContent = getToastTitle(nextVariant);
}
if (time) {
time.textContent = getToastTimeLabel(createdAt);
}
if (closeButton) {
closeButton.className = 'btn-close ' + getToastButtonClass(nextVariant) + ' ms-2';
}
}
function getBootstrapToast(toast) {
if (!toast || !window.bootstrap || !window.bootstrap.Toast) {
return null;
@@ -30,12 +136,7 @@
return;
}
var nextVariant = String(variant || 'info').trim().toLowerCase();
var variants = ['primary', 'secondary', 'success', 'danger', 'warning', 'info', 'light', 'dark'];
variants.forEach(function (value) {
toast.classList.remove('text-bg-' + value);
});
toast.classList.add('text-bg-' + (variants.indexOf(nextVariant) === -1 ? 'info' : nextVariant));
setToastHeader(toast, variant, toast.getAttribute('data-toast-created-at') || Date.now());
}
function getMessageVariant(message, fallbackVariant) {
@@ -68,7 +169,9 @@
}
var nextVariant = getMessageVariant(text, variant);
existingToast.setAttribute('data-toast-variant', nextVariant);
existingToast.setAttribute('data-toast-created-at', String(Date.now()));
setToastVariant(existingToast, nextVariant);
setToastHeader(existingToast, nextVariant, Date.now());
var existingInstance = getBootstrapToast(existingToast);
if (existingInstance) {
existingInstance.show();
@@ -77,18 +180,19 @@
}
var toast = document.createElement('div');
toast.className = 'toast align-items-center border-0';
toast.className = 'toast shadow-lg border-0';
toast.id = 'app-toast';
toast.setAttribute('role', 'status');
toast.setAttribute('aria-live', 'polite');
toast.setAttribute('aria-atomic', 'true');
toast.setAttribute('data-bs-autohide', 'true');
toast.setAttribute('data-bs-delay', '4000');
toast.innerHTML = '<div class="d-flex"><div class="toast-body"></div><button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Dismiss notification"></button></div>';
toast.setAttribute('data-toast-created-at', String(Date.now()));
var toastVariant = getMessageVariant(text, variant);
toast.setAttribute('data-toast-variant', toastVariant);
setToastVariant(toast, toastVariant);
toast.innerHTML = buildToastMarkup(getToastTitle(toastVariant), getToastTimeLabel(Date.now()), getToastButtonClass(toastVariant), getToastIconClass(toastVariant));
toast.querySelector('.toast-body').textContent = text;
setToastVariant(toast, toastVariant);
toast.addEventListener('hidden.bs.toast', function () {
removeToast(toast);
+28
View File
@@ -135,6 +135,11 @@ module.exports = function registerAdminContentRoutes(app, deps) {
return Array.from(usage);
}
async function fetchSlidesByTemplateId(templateId) {
const [slides] = await pool.query('SELECT id, thumbnail_path FROM slides WHERE template_id = ? ORDER BY id ASC', [templateId]);
return slides;
}
function getRemovedTemplateRegionKeys(existingRegions, nextRegions) {
const nextKeys = new Set((Array.isArray(nextRegions) ? nextRegions : []).map((region) => String(region && region.region_key || '').trim()).filter(Boolean));
return (Array.isArray(existingRegions) ? existingRegions : [])
@@ -171,6 +176,23 @@ module.exports = function registerAdminContentRoutes(app, deps) {
});
}
function queueTemplateSlideThumbnailRefresh(templateId) {
if (!backgroundTaskQueue || typeof backgroundTaskQueue.enqueueTask !== 'function') {
return Promise.resolve(null);
}
return backgroundTaskQueue.enqueueTask({
key: 'template-slide-thumbnail:' + Number(templateId),
title: 'Refresh slide thumbnails',
category: 'slides',
taskType: 'template-slide-thumbnail-refresh',
payload: {
templateId: Number(templateId)
},
persist: true
});
}
async function canvasSizeExists(width, height, ignoreId) {
const params = [width, height];
let query = 'SELECT COUNT(*) AS count FROM canvas_sizes WHERE width = ? AND height = ?';
@@ -524,6 +546,12 @@ module.exports = function registerAdminContentRoutes(app, deps) {
previousUploadRefs: existingUploadRefs,
nextUploadRefs: nextUploadRefs
});
const templateSlides = await fetchSlidesByTemplateId(template.id);
if (templateSlides.length > 0) {
queueTemplateSlideThumbnailRefresh(template.id).catch(function (error) {
console.warn('Unable to queue template slide thumbnail refresh:', error);
});
}
await notifyPlayerScreens(affectedScreens, 'refresh');
redirectAfterSave(req, res, '/templates/' + template.id + '/edit', {
closeUrl: '/templates',
+10 -8
View File
@@ -147,7 +147,6 @@ module.exports = function registerAdminRbacRoutes(app, deps) {
const permissionRows = await rbacData.fetchPermissions(pool);
const selectedPermissionKeys = await rbacData.fetchRolePermissionKeys(pool, role.id);
const selectedUserIds = await rbacData.fetchRoleUserIds(pool, role.id);
const users = await rbacData.fetchUsersWithRoles(pool);
return {
role: Object.assign({}, role, {
permissionKeys: selectedPermissionKeys,
@@ -155,7 +154,7 @@ module.exports = function registerAdminRbacRoutes(app, deps) {
userCount: Number(role.user_count) || 0
}),
permissionGroups: buildPermissionGroups(mapPermissionsForView(permissionRows, selectedPermissionKeys)),
users: mapUsersForView(users, selectedUserIds)
selectedUserIds: selectedUserIds
};
}
@@ -251,14 +250,17 @@ module.exports = function registerAdminRbacRoutes(app, deps) {
return res.status(404).send('Role not found.');
}
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
const search = common.getSearchQuery(req);
const sort = common.getSortQuery(req);
const direction = common.getSortDirectionQuery(req);
const currentUserId = req.currentUser ? Number(req.currentUser.id) : null;
const users = Array.isArray(viewModel.users)
? viewModel.users.filter(function (user) {
return Number(user && user.id) !== currentUserId;
})
: [];
const data = await rbacData.fetchUsersWithRolesPage(pool, page, LIST_PAGE_SIZE, search, sort, direction, {
excludeUserId: currentUserId
});
const users = mapUsersForView(data.users, viewModel.selectedUserIds);
res.send(pages.renderRbacEditPage(viewModel.role, req.query.message ? String(req.query.message) : '', req.currentUser, viewModel.permissionGroups, users));
res.send(pages.renderRbacEditPage(viewModel.role, req.query.message ? String(req.query.message) : '', req.currentUser, viewModel.permissionGroups, users, buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'users', 'User pages')));
} catch (error) {
next(error);
}
@@ -91,8 +91,8 @@ function buildQueuePageViewModel(data, message, currentUser) {
const tasks = (data && data.tasks) || [];
const summary = (data && data.summary) || { counts: {}, total: 0, activeCount: 0 };
const canManage = hasAnyPermission(currentUser, ['background-tasks.allow']);
const sort = String(data && data.sort || '').trim();
const direction = normalizeSortDirection(data && data.direction);
const sort = String(data && data.sort || '').trim() || 'started';
const direction = normalizeSortDirection(data && data.direction || 'desc');
const queryState = {
page: parsePageNumber(data && data.page),
sourceType: '',
@@ -130,7 +130,7 @@ function buildQueuePageViewModel(data, message, currentUser) {
return task && task.createdAt;
}
if (sort === 'started') {
return task && task.startedAt;
return task && (task.startedAt || '0001-01-01T00:00:00.000Z');
}
if (sort === 'finished') {
return task && task.finishedAt;
+3 -1
View File
@@ -1,6 +1,6 @@
const { renderView } = require('../../../view');
module.exports = function renderRbacEditPage(role, message, currentUser, permissionGroups, users) {
module.exports = function renderRbacEditPage(role, message, currentUser, permissionGroups, users, pagination) {
return renderView('settings/rbac/edit', {
title: 'Edit role',
active: 'rbac',
@@ -9,6 +9,8 @@ module.exports = function renderRbacEditPage(role, message, currentUser, permiss
role: role,
permissionGroups: permissionGroups || [],
users: users || [],
pagination: pagination || null,
roleEditBasePath: '/rbac/' + Number(role && role.id) + '/edit',
scripts: ['js/rbac-permissions.js']
});
};
+22
View File
@@ -36,6 +36,27 @@ function formatDays(daysJson) {
return days.map((day) => dayNames[Number(day)] || '').filter(Boolean).join(', ');
}
function normalizeScheduleDaysJson(daysJson) {
if (Array.isArray(daysJson)) {
return JSON.stringify(daysJson.map((day) => Number(day)).filter((day) => Number.isInteger(day) && day >= 0 && day <= 6));
}
if (!daysJson) {
return '[]';
}
try {
const parsed = JSON.parse(daysJson);
if (Array.isArray(parsed)) {
return JSON.stringify(parsed.map((day) => Number(day)).filter((day) => Number.isInteger(day) && day >= 0 && day <= 6));
}
} catch (_error) {
return JSON.stringify(String(daysJson).split(',').map((day) => Number(day)).filter((day) => Number.isInteger(day) && day >= 0 && day <= 6));
}
return '[]';
}
function scheduleSummary(item) {
const mode = String(item.schedule_mode || 'always');
if (mode === 'dates') {
@@ -160,6 +181,7 @@ module.exports = function renderPlaylistEditPage(playlist, data, message, curren
videoDurationSeconds: getVideoDurationSeconds(item),
useVideoDuration: Boolean(item.use_video_duration),
durationSeconds: getVideoDurationValue(item),
schedule_days_json: normalizeScheduleDaysJson(item.schedule_days_json),
scheduleSummary: scheduleSummary(item)
}));
const assignedSlideIds = new Set(playlistSlides.map((item) => item.slide_id));
+38 -33
View File
@@ -25,7 +25,7 @@
<textarea id="role-description-edit" name="description" class="form-control" rows="5">{{role.description}}</textarea>
</div>
</div>
<div class="card-footer d-flex justify-content-end gap-2 flex-wrap">
<div class="card-footer d-flex justify-content-end flex-wrap">
<div class="btn-group" role="group" aria-label="Role actions">
{{{saveActionButtons formId="role-edit-form" saveLabel="Save" saveAndCloseLabel="Save and Close" saveAndCloseValue="close" showSaveAndNew=false}}}
</div>
@@ -35,45 +35,50 @@
</div>
</div>
<div class="card card-outline card-secondary admin-form-card">
<div class="card-header">
<div class="card card-outline card-secondary admin-form-card" data-table-search-container>
<div class="card-header d-flex flex-wrap align-items-center justify-content-between">
<h3 class="card-title">Users</h3>
<div class="input-group input-group-sm flex-shrink-1 ms-auto" style="width: min(14rem, 100%);">
<span class="input-group-text" aria-hidden="true"><i class="bi bi-search"></i></span>
<input type="search" class="form-control" placeholder="Search users" aria-label="Search users" data-table-search />
</div>
</div>
<div class="card-body p-0">
<div class="card-body table-responsive p-0">
<div class="px-3 pt-3">
<p class="text-body-secondary mb-3">Choose the users who should belong to this role.</p>
</div>
{{#if users.length}}
<div class="table-responsive">
<table class="table table-sm align-middle mb-0 w-100">
<thead>
<tr>
<th style="width: 1%" scope="col">Select</th>
<th scope="col">Name</th>
<th scope="col">Username</th>
<th scope="col">Roles</th>
<table class="table table-striped w-100 mb-0 align-middle" data-table-searchable>
<thead>
<tr>
<th style="width: 1%" scope="col" data-sortable="false">Select</th>
<th scope="col" data-table-sort-key="user">Username</th>
<th scope="col" data-table-sort-key="name">Name</th>
<th scope="col" data-table-sort-key="roles">Roles</th>
</tr>
</thead>
<tbody>
{{#if users.length}}
{{#each users}}
<tr data-table-search-row>
<td data-label="Select">
<input class="form-check-input" type="checkbox" name="user_ids[]" value="{{id}}" {{#if isSelected}}checked{{/if}} />
</td>
<td data-label="Username">
<div class="user-cell">
<div>{{username}}</div>
</div>
</td>
<td data-label="Name">{{#if name}}{{name}}{{else}}-{{/if}}</td>
<td data-label="Roles" class="text-body-secondary">{{#if roleNames}}{{roleNames}}{{else}}No roles assigned{{/if}}</td>
</tr>
</thead>
<tbody>
{{#each users}}
<tr>
<td>
<input class="form-check-input" type="checkbox" name="user_ids[]" value="{{id}}" {{#if isSelected}}checked{{/if}} />
</td>
<td>{{name}}</td>
<td class="text-body-secondary">{{username}}</td>
<td class="text-body-secondary">{{#if roleNames}}{{roleNames}}{{else}}No roles assigned{{/if}}</td>
</tr>
{{/each}}
</tbody>
</table>
</div>
{{else}}
<div class="px-3 pb-3">
<div class="alert alert-warning mb-0">Create at least one user before assigning this role.</div>
</div>
{{/if}}
{{/each}}
{{else}}
<tr data-table-search-empty-default><td colspan="4" class="empty">No users found.</td></tr>
{{/if}}
</tbody>
</table>
</div>
{{> table-pagination pagination=pagination basePath=roleEditBasePath}}
</div>
</div>
+18 -5
View File
@@ -33,13 +33,26 @@
<script src="/assets/js/admin/admin-page.js"></script>
<script src="/assets/js/system-status.js"></script>
{{else}}
<div id="app-toast-container" class="toast-container position-fixed top-0 end-0 p-3">
<div id="app-toast-container" class="toast-container app-toast-container position-fixed end-0 p-3">
{{#if message}}
<div class="toast align-items-center text-bg-{{#if messageVariant}}{{messageVariant}}{{else}}info{{/if}} border-0" id="app-toast" role="status" aria-live="polite" aria-atomic="true" data-bs-autohide="true" data-bs-delay="4000" data-toast-variant="{{#if messageVariant}}{{messageVariant}}{{else}}info{{/if}}">
<div class="d-flex">
<div class="toast-body">{{message}}</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Dismiss notification"></button>
<div class="toast shadow-lg border-0" id="app-toast" role="status" aria-live="polite" aria-atomic="true" data-bs-autohide="true" data-bs-delay="4000" data-toast-variant="{{#if messageVariant}}{{messageVariant}}{{else}}info{{/if}}" data-toast-created-at="">
<div class="toast-header border-0 text-bg-{{#if messageVariant}}{{messageVariant}}{{else}}info{{/if}}">
{{#if (eq messageVariant 'success')}}
<i class="bi bi-check-circle-fill me-2"></i>
{{else if (eq messageVariant 'warning')}}
<i class="bi bi-exclamation-triangle-fill me-2"></i>
{{else if (eq messageVariant 'danger')}}
<i class="bi bi-x-circle-fill me-2"></i>
{{else if (eq messageVariant 'primary')}}
<i class="bi bi-bell-fill me-2"></i>
{{else}}
<i class="bi bi-info-circle-fill me-2"></i>
{{/if}}
<strong class="me-auto">Pulse</strong>
<small class="toast-time text-nowrap">just now</small>
<button type="button" class="btn-close btn-close-white ms-2" data-bs-dismiss="toast" aria-label="Dismiss notification"></button>
</div>
<div class="toast-body">{{message}}</div>
</div>
{{/if}}
</div>