feat(tasks): sort the app task list explicitly, and add a filter + search
The app-scoped Tasks tab never sorted. It rendered straight from tasksManager.tasks and relied on loadTasks() having ordered it, so any path that appends after the load — a task arriving from the event bus, a retry, a queue merge — put that task wherever it happened to land rather than at the top. Sort where the list is rendered instead of trusting it from three callers away. Honest note on the reported symptom: a list_users task appearing mid-list could not be reproduced from the stored records — replaying the sort over all 96 task files puts the newest tool tasks first. What is demonstrably wrong is the missing sort above, and a second latent fault it would mask: 8 of those 96 records carry a null createdAt (cron-created backups), and `new Date(null)` is the epoch, so they sort as if from 1970 rather than as unknown. Adds window.taskSortTime for that: createdAt when it parses, otherwise the timestamp already embedded in the task id — the WebUI mints task_<epoch_ms>_<rand> and the backend task_<epoch_s>_<hex>, distinguishable by digit count. All three sorts now use it, so the global list, the app list and the loader agree. The filter bar is client-side over the already-loaded per-app array, so it is instant and needs no reload: status chips (built from the statuses actually present, with counts, so a chip can never return zero) plus a search over the command and the task id — the id being what a deep link and a log URL both carry, so pasting one finds it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
3ed912b5ed
commit
10d79cc297
@ -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;
|
||||
}
|
||||
|
||||
@ -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_<epoch_ms>_<rand>` and the backend `task_<epoch_s>_<hex>`, 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 = [`<button type="button" class="task-filter-chip active" data-status="all">All <span>${tasks.length}</span></button>`]
|
||||
.concat(present.map(s =>
|
||||
`<button type="button" class="task-filter-chip" data-status="${escapeAttr(s)}">${escapeAttr(s)} <span>${counts[s]}</span></button>`));
|
||||
|
||||
return `
|
||||
<div class="task-filter-bar">
|
||||
<div class="task-filter-chips">${chips.join('')}</div>
|
||||
<input type="search" id="app-task-search" class="task-filter-search"
|
||||
placeholder="Search commands…" autocomplete="off" spellcheck="false">
|
||||
</div>`;
|
||||
}
|
||||
|
||||
_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('')
|
||||
: `<p class="task-filter-empty">No tasks match${q ? ` “${escapeAttr(q)}”` : ''}${status === 'all' ? '' : ` in ${escapeAttr(status)}`}.</p>`;
|
||||
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) +
|
||||
`<div id="app-tasks-list">${appTasks.map(t => this.tasksManager.renderTask(t)).join('')}</div>`;
|
||||
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, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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('');
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user