// Background tasks settings page renderer. const { renderView } = require('#src/web/view'); const { hasAnyPermission } = require('#src/rbac'); const { buildPagination, buildQueryString } = require('#src/web/lib/pagination'); const { parsePageNumber, normalizeSortDirection, sortRows, createSearchMatcher } = require('#src/web/lib/list-query'); const TASKS_PER_PAGE = 25; const QUEUE_PAGE_URL = '/settings/tasks-background'; const SCHEDULED_PAGE_URL = '/settings/tasks-scheduled'; const DATE_FILTER_FIELDS = { createdAt: 'queued', startedAt: 'started', finishedAt: 'finished' }; function formatIntervalLabel(intervalMs) { const value = Math.max(1, Number(intervalMs) || 0); if (value % 60000 === 0) { const minutes = Math.max(1, value / 60000); return minutes === 1 ? 'Every minute' : `Every ${minutes} minutes`; } if (value % 1000 === 0) { const seconds = Math.max(1, value / 1000); return seconds === 1 ? 'Every second' : `Every ${seconds} seconds`; } return `${value} ms`; } function normalizeSourceFilter(sourceType, sourceId) { const normalizedSourceType = String(sourceType || '').trim(); const normalizedSourceId = Math.floor(Number(sourceId) || 0); if (!normalizedSourceType || !Number.isFinite(normalizedSourceId) || normalizedSourceId <= 0) { return { sourceType: '', sourceId: '' }; } return { sourceType: normalizedSourceType, sourceId: normalizedSourceId }; } function normalizeDateInput(value) { const normalizedValue = String(value || '').trim(); return /^\d{4}-\d{2}-\d{2}$/.test(normalizedValue) ? normalizedValue : ''; } function normalizeDateField(value) { const normalizedField = String(value || '').trim(); return DATE_FILTER_FIELDS[normalizedField] ? normalizedField : 'createdAt'; } function getIsoDateString(value) { const date = new Date(value); if (Number.isNaN(date.getTime())) { return ''; } return date.toISOString().slice(0, 10); } function getTaskDateValue(task, dateField) { if (!task || !dateField) { return ''; } return task[dateField] || ''; } function taskMatchesDateFilter(task, dateFilter) { if (!dateFilter || (!dateFilter.from && !dateFilter.to)) { return true; } const taskDate = getIsoDateString(getTaskDateValue(task, dateFilter.field)); if (!taskDate) { return false; } if (dateFilter.from && taskDate < dateFilter.from) { return false; } if (dateFilter.to && taskDate > dateFilter.to) { return false; } return true; } function taskMatchesFilters(task, sourceFilter) { if (!sourceFilter || !sourceFilter.sourceType || !sourceFilter.sourceId) { return true; } const metadata = task.metadata || {}; return String(metadata.sourceType || '').trim() === sourceFilter.sourceType && Number(metadata.sourceId || 0) === sourceFilter.sourceId; } const taskMatchesSearch = createSearchMatcher; function formatStatusLabel(status) { const normalized = String(status || '').trim().toLowerCase(); if (!normalized) { return ''; } if (normalized === 'queued') { return 'Queued'; } if (normalized === 'running') { return 'Running'; } if (normalized === 'completed') { return 'Completed'; } if (normalized === 'failed') { return 'Failed'; } if (normalized === 'canceled') { return 'Canceled'; } return normalized.charAt(0).toUpperCase() + normalized.slice(1); } function buildTaskSourceFilterUrl(queryState, task, sourceFilter) { const metadata = task.metadata || {}; const sourceType = String(metadata.sourceType || '').trim(); const sourceId = Math.floor(Number(metadata.sourceId) || 0); if (!sourceType || !Number.isFinite(sourceId) || sourceId <= 0) { return ''; } const isActive = sourceFilter && sourceFilter.sourceType === sourceType && sourceFilter.sourceId === sourceId; const nextQuery = Object.assign({}, queryState, { page: 1, sourceType: isActive ? '' : sourceType, sourceId: isActive ? '' : sourceId }); return buildQueryString(nextQuery); } function buildTaskPlayerLabel(metadata) { const playerLabel = String(metadata && metadata.playerLabel || '').trim(); const playerIdentifier = String(metadata && metadata.playerIdentifier || '').trim(); const playerPublicBaseUrl = String(metadata && metadata.playerPublicBaseUrl || '').trim(); if (playerLabel) { return playerLabel; } if (playerIdentifier && playerPublicBaseUrl) { return `${playerIdentifier} (${playerPublicBaseUrl})`; } if (playerIdentifier) { return playerIdentifier; } if (playerPublicBaseUrl) { return playerPublicBaseUrl; } return ''; } 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() || 'queued'; const direction = normalizeSortDirection(data && data.direction || 'desc'); const queryState = { page: parsePageNumber(data && data.page), sourceType: '', sourceId: '', search: String(data && data.search || '').trim(), sort: sort, direction: direction }; const sourceFilter = normalizeSourceFilter(data && data.sourceType, data && data.sourceId); queryState.sourceType = sourceFilter.sourceType; queryState.sourceId = sourceFilter.sourceId; const filteredTasks = tasks.filter(function (task) { return taskMatchesFilters(task, sourceFilter) && taskMatchesDateFilter(task, { field: 'createdAt', from: '', to: '' }) && taskMatchesSearch(queryState.search, [ 'title', 'category', 'status', 'errorMessage', 'createdAt', 'startedAt', 'finishedAt', function (item) { return item && item.metadata && item.metadata.sourceName; }, function (item) { return item && item.metadata && item.metadata.sourceType; }, function (item) { return item && item.metadata && item.metadata.sourceId; }, function (item) { return item && item.metadata && item.metadata.playerLabel; }, function (item) { return item && item.metadata && item.metadata.playerIdentifier; }, function (item) { return item && item.metadata && item.metadata.playerPublicBaseUrl; } ])(task); }); const sortedTasks = sortRows(filteredTasks, function (task) { if (sort === 'task') { return task && task.title; } if (sort === 'category') { return task && task.category; } if (sort === 'status') { return task && task.status; } if (sort === 'queued') { return task && task.createdAt; } if (sort === 'started') { return task && (task.startedAt || '0001-01-01T00:00:00.000Z'); } if (sort === 'finished') { return task && task.finishedAt; } if (sort === 'source') { return [task && task.metadata && task.metadata.sourceName, task && task.metadata && task.metadata.sourceType, task && task.metadata && task.metadata.sourceId, task && task.metadata && task.metadata.playerLabel, task && task.metadata && task.metadata.playerIdentifier, task && task.metadata && task.metadata.playerPublicBaseUrl].map(function (value) { return String(value || '').trim(); }).join(' '); } return task && task.createdAt; }, direction); const pagination = buildPagination(sortedTasks.length, queryState.page, 'page', queryState, TASKS_PER_PAGE, 'tasks', 'Background task pages'); const visibleTasks = sortedTasks.slice((pagination.currentPage - 1) * TASKS_PER_PAGE, pagination.currentPage * TASKS_PER_PAGE); const statusCards = [ { status: 'queued', label: 'Queued', count: summary.counts.queued, variant: 'primary' }, { status: 'running', label: 'Running', count: summary.counts.running, variant: 'info' }, { status: 'failed', label: 'Failed', count: summary.counts.failed, variant: 'danger' }, { status: 'completed', label: 'Finished', count: summary.counts.completed, variant: 'success' } ].map(function (card) { return Object.assign({}, card, { active: false }); }); const visibleTasksWithSources = visibleTasks.map(function (task) { const playerLabel = buildTaskPlayerLabel(task.metadata); const taskDescription = [playerLabel, String(task && task.key || '').trim()].filter(Boolean).join(':'); return Object.assign({}, task, { sourceUrl: buildTaskSourceFilterUrl(queryState, task, sourceFilter), sourceLabel: task.metadata && task.metadata.sourceName ? String(task.metadata.sourceName).trim() : '', sourceTypeLabel: task.metadata && task.metadata.sourceType ? String(task.metadata.sourceType).trim() : '', sourceIdLabel: task.metadata && task.metadata.sourceId ? Number(task.metadata.sourceId) : '', playerLabel: playerLabel, taskDescription: taskDescription }); }); const hasActiveFilters = Boolean(queryState.sourceType || queryState.sourceId); return renderView('settings/tasks-background/index', { title: 'Background tasks', active: 'background-tasks', bodyClass: 'background-tasks-page', message: message, currentUser: currentUser || null, scripts: ['js/settings/background-tasks.js'], backgroundTasksMenuOpen: true, tasks: visibleTasksWithSources, summary: summary, statusCards: statusCards, activeSourceLabel: queryState.sourceType && queryState.sourceId ? `${queryState.sourceType} #${queryState.sourceId}` : '', search: queryState.search, sort: sort, direction: direction, sourceType: queryState.sourceType, sourceId: queryState.sourceId, clearFiltersUrl: QUEUE_PAGE_URL, hasActiveFilters: hasActiveFilters, pagination: pagination, canManage: canManage, canClearFinished: tasks.some(function (task) { return task.status !== 'queued' && task.status !== 'running'; }) }); } function buildScheduledPageViewModel(data, message, currentUser) { const recurringTasks = (data && data.recurringTasks) || []; const summary = (data && data.summary) || { counts: {}, total: 0, activeCount: 0, scheduledCount: 0 }; const canManage = hasAnyPermission(currentUser, ['scheduled-tasks.allow']); const search = String(data && data.search || '').trim(); const sort = String(data && data.sort || '').trim() || 'task'; const direction = normalizeSortDirection(data && data.direction); const filteredRecurringTasks = recurringTasks.filter(function (task) { return taskMatchesSearch(search, [ 'title', 'intervalLabel', 'nextRunAt', 'lastRunAt', 'lastStatus', 'lastError', function (item) { return item && item.metadata && item.metadata.sourceName; }, function (item) { return item && item.metadata && item.metadata.sourceType; }, function (item) { return item && item.metadata && item.metadata.sourceId; }, function (item) { return item && item.metadata && item.metadata.playerLabel; }, function (item) { return item && item.metadata && item.metadata.playerIdentifier; }, function (item) { return item && item.metadata && item.metadata.playerPublicBaseUrl; } ])(task); }); const sortedRecurringTasks = sortRows(filteredRecurringTasks, function (task) { if (sort === 'task') { return task && task.title; } if (sort === 'interval') { return task && task.intervalMs; } if (sort === 'next_run') { return task && task.nextRunAt; } if (sort === 'last_run') { return task && task.lastRunAt; } if (sort === 'last_result') { return [task && task.lastStatus, task && task.lastError].map(function (value) { return String(value || '').trim(); }).join(' '); } if (sort === 'source') { return [task && task.metadata && task.metadata.sourceName, task && task.metadata && task.metadata.sourceType, task && task.metadata && task.metadata.sourceId, task && task.metadata && task.metadata.playerLabel, task && task.metadata && task.metadata.playerIdentifier, task && task.metadata && task.metadata.playerPublicBaseUrl].map(function (value) { return String(value || '').trim(); }).join(' '); } return task && task.title; }, direction); return renderView('settings/tasks-scheduled/index', { title: 'Scheduled tasks', active: 'background-tasks-scheduled', message: message, currentUser: currentUser || null, scripts: ['js/settings/background-tasks.js'], backgroundTasksMenuOpen: true, recurringTasks: sortedRecurringTasks.map(function (task) { return Object.assign({}, task, { intervalLabel: formatIntervalLabel(task.intervalMs), playerLabel: buildTaskPlayerLabel(task.metadata) }); }), summary: summary, canManage: canManage, scheduledCount: Number(summary.scheduledCount || filteredRecurringTasks.length || 0), recurringPagination: buildPagination(sortedRecurringTasks.length, parsePageNumber(data && data.recurringPage), 'recurringPage', { recurringPage: parsePageNumber(data && data.recurringPage), search: search, sort: sort, direction: direction }) }); } module.exports = { renderBackgroundTasksPage: function (data, message, currentUser) { return buildQueuePageViewModel(data, message, currentUser); }, renderBackgroundTasksScheduledPage: function (data, message, currentUser) { return buildScheduledPageViewModel(data, message, currentUser); } };