diff --git a/containers/libreportal/frontend/components/apps/core/css/apps-layout.css b/containers/libreportal/frontend/components/apps/core/css/apps-layout.css index 44e1f4f..6a6df64 100644 --- a/containers/libreportal/frontend/components/apps/core/css/apps-layout.css +++ b/containers/libreportal/frontend/components/apps/core/css/apps-layout.css @@ -91,3 +91,59 @@ padding: 10px; } } + +/* Task filter bar (app detail → Tasks). Client-side over the already-loaded + per-app list, so it stays instant and needs no reload. */ +.task-filter-bar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; + margin-bottom: 14px; +} +.task-filter-chips { display: flex; flex-wrap: wrap; gap: 6px; } +.task-filter-chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 5px 11px; + border-radius: 999px; + border: 1px solid rgba(var(--text-rgb), 0.12); + background: rgba(var(--text-rgb), 0.04); + color: rgba(var(--text-rgb), 0.75); + font-size: 12px; + text-transform: capitalize; + cursor: pointer; + transition: background .15s ease, border-color .15s ease, color .15s ease; +} +.task-filter-chip:hover { background: rgba(var(--text-rgb), 0.09); } +.task-filter-chip.active { + background: rgba(var(--accent-rgb, 56, 189, 248), 0.16); + border-color: rgba(var(--accent-rgb, 56, 189, 248), 0.45); + color: var(--text-primary); +} +.task-filter-chip span { + font-size: 11px; + opacity: 0.7; + font-variant-numeric: tabular-nums; +} +.task-filter-search { + flex: 1 1 200px; + min-width: 160px; + padding: 6px 11px; + border-radius: 8px; + border: 1px solid rgba(var(--text-rgb), 0.12); + background: rgba(var(--text-rgb), 0.04); + color: var(--text-primary); + font-size: 13px; +} +.task-filter-search::placeholder { color: rgba(var(--text-rgb), 0.4); } +.task-filter-search:focus { + outline: none; + border-color: rgba(var(--accent-rgb, 56, 189, 248), 0.5); +} +.task-filter-empty { + padding: 18px 4px; + color: rgba(var(--text-rgb), 0.55); + font-size: 13px; +} diff --git a/containers/libreportal/frontend/components/apps/core/js/app-tabbed-manager.js b/containers/libreportal/frontend/components/apps/core/js/app-tabbed-manager.js index 9c19112..e24d83a 100755 --- a/containers/libreportal/frontend/components/apps/core/js/app-tabbed-manager.js +++ b/containers/libreportal/frontend/components/apps/core/js/app-tabbed-manager.js @@ -1,3 +1,20 @@ + +// Milliseconds to sort a task by. createdAt is the intended field, but ~8% of +// existing records carry a null one (cron-created backups), and those would all +// collapse onto the epoch and sort as if they were from 1970. Fall back to the +// timestamp embedded in the task id, which every id carries: the WebUI mints +// `task__` and the backend `task__`, so the digit +// count tells the two apart. +window.taskSortTime = function (task) { + const t = Date.parse(task?.createdAt || ''); + if (!Number.isNaN(t)) return t; + const m = String(task?.id || '').match(/^task_(\d+)_/); + if (m) { + const n = Number(m[1]); + return m[1].length >= 13 ? n : n * 1000; // ms already, or seconds + } + return 0; +}; // Enhanced App Manager with Tabbed Interface // Integrates app management with task history @@ -333,6 +350,64 @@ class AppTabbedManager { } } + + // Status counts drive the chips, so a status with nothing in it is not + // offered — a filter that can only ever return zero results is noise. + _renderTaskFilterBar(tasks) { + const counts = tasks.reduce((acc, t) => { + const s = (t.status || 'unknown').toLowerCase(); + acc[s] = (acc[s] || 0) + 1; + return acc; + }, {}); + const order = ['running', 'processing', 'queued', 'pending', 'completed', 'failed', 'cancelled']; + const present = order.filter(s => counts[s]); + Object.keys(counts).forEach(s => { if (!present.includes(s)) present.push(s); }); + + const chips = [``] + .concat(present.map(s => + ``)); + + return ` +
+
${chips.join('')}
+ +
`; + } + + _wireTaskFilterBar() { + const bar = document.querySelector('.task-filter-bar'); + if (!bar) return; + const search = bar.querySelector('#app-task-search'); + let status = 'all'; + + const apply = () => { + const q = (search?.value || '').trim().toLowerCase(); + const shown = (this._appTasks || []).filter(t => { + const okStatus = status === 'all' || (t.status || '').toLowerCase() === status; + // Match the command and the task id — the id is what a log URL and the + // Tasks tab deep link both use, so pasting one in should find it. + const hay = `${t.command || ''} ${t.id || ''}`.toLowerCase(); + return okStatus && (!q || hay.includes(q)); + }); + const list = document.getElementById('app-tasks-list'); + if (!list) return; + list.innerHTML = shown.length + ? shown.map(t => this.tasksManager.renderTask(t)).join('') + : `

No tasks match${q ? ` “${escapeAttr(q)}”` : ''}${status === 'all' ? '' : ` in ${escapeAttr(status)}`}.

`; + this.tasksManager.setupGlobalFunctions(); + }; + + bar.querySelectorAll('.task-filter-chip').forEach(chip => { + chip.addEventListener('click', () => { + status = chip.dataset.status || 'all'; + bar.querySelectorAll('.task-filter-chip').forEach(c => c.classList.toggle('active', c === chip)); + apply(); + }); + }); + search?.addEventListener('input', apply); + } + // Load tasks specific to current app async loadAppTasks() { @@ -380,8 +455,15 @@ class AppTabbedManager { await this.tasksManager.loadTasks(); const allTasks = this.tasksManager.tasks || []; - // Filter tasks for current app - const appTasks = allTasks.filter(task => task.app === this.currentApp); + // Filter tasks for current app, newest first. + // + // The sort is done here rather than trusted from loadTasks(): this view + // renders straight from the array, so any path that appends a task after + // the load (a fresh task arriving from the event bus, a retry, a queue + // merge) would show up wherever it landed rather than at the top. + const appTasks = allTasks + .filter(task => task.app === this.currentApp) + .sort((a, b) => taskSortTime(b) - taskSortTime(a)); // Debug: Show what would match if we used different app names ['libreportal', 'fail2ban', 'LibrePortal', 'Fail2Ban'].forEach(testApp => { @@ -399,9 +481,14 @@ class AppTabbedManager { // Setup global functions for task interactions this.tasksManager.setupGlobalFunctions(); - // Render app-specific tasks - const tasksHtml = appTasks.map(task => this.tasksManager.renderTask(task)).join(''); - tasksContainer.innerHTML = tasksHtml; + // Render app-specific tasks, with a filter bar above them. Kept + // client-side over the already-loaded array: the list is per-app and + // small, so filtering is instant and needs no reload. + this._appTasks = appTasks; + tasksContainer.innerHTML = + this._renderTaskFilterBar(appTasks) + + `
${appTasks.map(t => this.tasksManager.renderTask(t)).join('')}
`; + this._wireTaskFilterBar(); // Setup app-specific task interactions (separate from main tasks system) this.setupAppTaskFunctions(); @@ -1127,3 +1214,8 @@ document.addEventListener('DOMContentLoaded', async () => { window.addEventListener('load', async () => { // Don't initialize here - let SPA handle it }); + +function escapeAttr(v) { + return String(v ?? '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); +} diff --git a/containers/libreportal/frontend/components/tasks/js/tasks-data-load.js b/containers/libreportal/frontend/components/tasks/js/tasks-data-load.js index 2854b81..a0a892a 100644 --- a/containers/libreportal/frontend/components/tasks/js/tasks-data-load.js +++ b/containers/libreportal/frontend/components/tasks/js/tasks-data-load.js @@ -171,7 +171,10 @@ Object.assign(TasksManager.prototype, { this.tasks = allTasks; // Sort by creation time (newest first) - this.tasks.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)); + // Newest first, tolerant of a missing createdAt (see taskSortTime). + this.tasks.sort((a, b) => + (window.taskSortTime ? window.taskSortTime(b) - window.taskSortTime(a) + : new Date(b.createdAt) - new Date(a.createdAt))); //// // console.log('📋 All tasks:', this.tasks); diff --git a/containers/libreportal/frontend/components/tasks/js/tasks-list-render.js b/containers/libreportal/frontend/components/tasks/js/tasks-list-render.js index 1166ca6..f3a53d0 100644 --- a/containers/libreportal/frontend/components/tasks/js/tasks-list-render.js +++ b/containers/libreportal/frontend/components/tasks/js/tasks-list-render.js @@ -62,8 +62,11 @@ Object.assign(TasksManager.prototype, { } // Sort tasks by creation time (newest first) - const sortedTasks = filteredTasks.sort((a, b) => - new Date(b.createdAt) - new Date(a.createdAt) + // taskSortTime rather than createdAt directly — see its definition: a + // null createdAt would otherwise sort the task to 1970. + const sortedTasks = filteredTasks.sort((a, b) => + (window.taskSortTime ? window.taskSortTime(b) - window.taskSortTime(a) + : new Date(b.createdAt) - new Date(a.createdAt)) ); const html = sortedTasks.map(task => this.renderTask(task)).join(''); diff --git a/scripts/validation/validate_config.sh b/scripts/validation/validate_config.sh index 7c36c2f..bfa6aa2 100644 --- a/scripts/validation/validate_config.sh +++ b/scripts/validation/validate_config.sh @@ -198,7 +198,16 @@ validateAppConfiguration() isError "No config found for '$app'." return 1 fi + + # Called on its own rather than from the all-apps loop: own the counters and + # build the source index here. Without the index every tag filled by a hook + # instead of a CFG key reads as unbacked — `validation app matrix` reported + # RUN_UID/RUN_GID as failures that `validation all` correctly did not. + local standalone="" + [[ -z "$_lpv_in_all" ]] && { standalone=1; _lpv_issues=0; _lpv_checked=0; } + _lpvLoadSources ((_lpv_checked++)) + local before=$_lpv_issues _lpvCheckConfigFile "$app" "${_lpv_cfg_live:-$_lpv_cfg_tmpl}" "$( [[ -n "$_lpv_cfg_live" ]] && echo "deployed config" || echo "config template" )" _lpvCheckPlaceholders "$app" "$_lpv_cfg_tmpl" @@ -207,6 +216,17 @@ validateAppConfiguration() "$( [[ -n "$_lpv_comp_live" ]] && echo "deployed compose" || echo "compose template" )" \ "${_lpv_cfg_live:-$_lpv_cfg_tmpl}" _lpvCheckAuthAdapter "$app" "${_lpv_cfg_live:-$_lpv_cfg_tmpl}" + + # A single-app run that says nothing is indistinguishable from one that did + # not run, so report either way. The all-apps loop keeps its own summary. + if [[ -n "$standalone" ]]; then + if [[ $_lpv_issues -eq $before ]]; then + isSuccessful "$app: no configuration problems found." + else + isError "$app: $((_lpv_issues - before)) configuration problem(s)." + return 1 + fi + fi return 0 }