librelad facf764c4b feat(webui): bulk selection on app tasks, updates overview, and app backups
The /tasks page's right-side tick + dynamic Select all / Clear All ⇄
Delete Selected layout now covers the other three management surfaces:

- App detail → Tasks tab: filter bar gains the Clear All button and
  master tick; Clear All there scopes to that app's tasks only. The
  selection set is resolved through window.tasksManager everywhere —
  TasksManager is constructed in several places, and ticks previously
  landed on one instance while Delete Selected read another's empty set.
- Apps overview → Updates: the header's Update all button now morphs to
  Update Selected (N) + Clear in place as rows are ticked, replacing the
  separate selection bar between toolbar and list.
- App detail → Backups: each snapshot row gains Delete + a right-side
  tick; a toolbar atop the list morphs Delete All ⇄ Delete Selected (N).
  The whole selection rides in ONE task (delete <app> 1:a,2:b,…) since
  the backup surfaces hold one task per subject at a time.
- CLI: backup app delete accepts comma-separated <idx>:<snap> pairs, and
  both delete and delete_all now regenerate the WebUI backup JSON so
  deleted snapshots leave the screen instead of lingering until the next
  backup.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 00:59:19 +01:00

345 lines
15 KiB
JavaScript

// Auto-extracted from tasks-manager.js (verbatim) — augments TasksManager.prototype. Loaded after the base.
Object.assign(TasksManager.prototype, {
async retryTask(taskId) {
if (!confirm('Are you sure you want to retry this task?')) return;
try {
const task = this.tasks.find(t => t.id === taskId);
if (!task) {
throw new Error('Task not found');
}
// Create a new task with the same command, linked to the failed one via
// `retryOf`. The server keeps the failed task (its log is the failure
// record) and stamps it `retriedBy` so the UI can mark it "Retried".
const newTask = await this.taskManager.createTask(
task.command,
task.type,
task.app,
task.config,
{ retryOf: task.id }
);
// Refresh tasks to show both the new run and the now-"Retried" original,
// then jump to the new run and open its live log — the user follows the
// retry instead of hunting for it.
await this.loadTasks();
if (typeof this.selectTask === 'function') {
// Row exists after the re-render; select on the next frame to be safe.
requestAnimationFrame(() => this.selectTask(newTask.id));
}
if (window.notificationSystem) {
// Use the source task's type icon — retrying a backup shows 💾 etc.
const typeIcon = this.getTaskTypeIcon ? this.getTaskTypeIcon(task)?.icon : '';
const customIcon = typeIcon ? `<span style="font-size:18px;line-height:1;">${typeIcon}</span>` : null;
window.notificationSystem.show('Task retried — following the new run.', 'success', null, null, null, customIcon);
}
} catch (error) {
console.error('Error retrying task:', error);
if (window.notificationSystem) {
window.notificationSystem.error(`Failed to retry task: ${error.message}`);
}
}
},
async deleteTask(taskId) {
// Get the latest known status before deciding what to do. The server
// refuses to delete tasks that are still running or queued (HTTP 409),
// and we used to just propagate that as a red banner — but the user is
// almost always trying to clean up a row that looked finished, or a
// genuinely active task they want gone. Either way the right answer is
// "cancel first, then delete", which is what we do here.
const cached = (window.taskEventBus && window.taskEventBus.getTask)
? window.taskEventBus.getTask(taskId)
: null;
let task = cached || (this.tasks && this.tasks.find(t => t.id === taskId));
if (!task && this.taskManager && this.taskManager.getTask) {
try { task = await this.taskManager.getTask(taskId); } catch {}
}
const isActive = task && (task.status === 'running' || task.status === 'queued' || task.status === 'pending');
const confirmed = await this._showDeleteTaskModal(task, isActive);
if (!confirmed) return;
try {
if (isActive) {
// Ask the server to cancel. POST /cancel either flips status straight
// to `cancelled` (queued -> cancelled is synchronous) or drops a
// `<id>.cancel` marker the bash processor picks up within ~1s
// (running -> cancelled). We then wait for the terminal status via
// SSE before issuing the delete.
try { await this.taskManager.cancelTask(taskId); } catch {
// If cancel itself failed (e.g. already terminal), fall through
// and try the delete anyway.
}
await this._waitForTaskTerminal(taskId, 15_000);
}
// Try the polite delete first. If the task is still flagged as
// running/queued server-side (e.g. the bash processor is dead and
// never picked up the cancel marker), fall back to the force-delete
// override so the user isn't stuck with a permanently-undeletable row.
try {
await this.taskManager.deleteTask(taskId);
} catch (err) {
const looks409 = /\bHTTP 409\b/.test(err && err.message ? err.message : '');
if (!looks409) throw err;
await this.taskManager.deleteTask(taskId, { force: true });
}
// Remove from local array and re-render
this.tasks = this.tasks.filter(t => t.id !== taskId);
this.renderTasks();
this.updateStats();
this.updateSidebarCounts();
this.generateAppCategories();
if (window.notificationSystem) {
// Match the "<App> task <verb>" two-line shape used by started/
// completed/failed/cancelled so deletion reads as part of the same
// family. Type emoji falls back to 🗑️ since deletion is a 🗑️ action.
const { appName, actionTitle, displayName, icon, typeIcon } = this._taskNotificationDescriptor(task);
const emoji = typeIcon || '🗑️';
const customIcon = `<span style="font-size:18px;line-height:1;">${emoji}</span>`;
const body = displayName
? `<strong>${displayName}</strong><br>${actionTitle} task deleted.`
: `${actionTitle || 'Task'} deleted.`;
window.notificationSystem.show(body, 'info', appName, null, icon, customIcon);
}
} catch (error) {
console.error('Error deleting task:', error);
if (window.notificationSystem) {
window.notificationSystem.error(`Failed to delete task: ${error.message}`);
}
}
},
async clearAllTasks() {
// Routes to one of two modes depending on whether any rows are ticked.
// Both paths share _showClearAllModal — same UX, same modal, different
// input list + title.
//
// On an app detail page (#app-tasks-list present, no #tasks-list) the
// universe is that app's tasks only — "Clear All" there must never
// reach across every app the way it does on /tasks.
const appScope = this._selectionAppScope();
const universe = appScope ? this.tasks.filter(t => t.app === appScope) : this.tasks;
const sel = this._selectionSet();
const hasSelection = sel.size > 0;
const targetTasks = hasSelection
? universe.filter(t => sel.has(t.id))
: universe;
const result = await this._showClearAllModal(targetTasks, hasSelection ? 'selected' : 'all');
if (!result || !result.confirmed) return false;
await this.performClearAll({ cancelRunning: result.cancelRunning, targets: targetTasks });
if (hasSelection) {
// Drop any ids we just deleted from the selection set (mutating in
// place — the set is shared across instances), then refresh the
// button label + master-checkbox state.
const alive = [...sel].filter(id => this.tasks.find(t => t.id === id));
sel.clear();
alive.forEach(id => sel.add(id));
this._updateSelectionUI();
}
return true;
},
async performClearAll(opts) {
opts = opts || {};
const cancelRunning = !!opts.cancelRunning;
// Caller (clearAllTasks) passes the subset to operate on. Falls back
// to this.tasks for backward compatibility — old direct callers
// continue to mean "everything".
const universe = Array.isArray(opts.targets) ? opts.targets : (this.tasks || []);
// Show progress notification with the trash type icon in the left slot
// (this is conceptually a delete-all action, so 🗑️ matches the row icon).
const customIcon = '<span style="font-size:18px;line-height:1;">🗑️</span>';
const progressNotification = window.notificationSystem.show(
'Clearing tasks...',
'info',
null,
null,
null,
customIcon
);
const isActive = (t) => t && (t.status === 'running' || t.status === 'queued' || t.status === 'pending');
// Partition: which tasks we attempt now, which we skip silently.
const targets = universe.filter(t => cancelRunning || !isActive(t));
const skipped = universe.filter(t => !targets.includes(t));
try {
// For active tasks (only reached when the toggle is on): cancel first,
// wait for terminal status, then delete. Mirrors the single-row
// deleteTask flow so the bash processor has a chance to honour the
// cancel marker before the DELETE arrives (otherwise we'd 409).
await Promise.all(targets.map(async (task) => {
if (cancelRunning && isActive(task)) {
try { await this.taskManager.cancelTask(task.id); } catch {}
await this._waitForTaskTerminal(task.id, 15_000);
}
try {
await this.taskManager.deleteTask(task.id);
} catch (err) {
const looks409 = /\bHTTP 409\b/.test(err && err.message ? err.message : '');
if (!looks409) throw err;
await this.taskManager.deleteTask(task.id, { force: true });
}
}));
// Re-sync local state: keep anything we intentionally skipped.
const deletedIds = new Set(targets.map(t => t.id));
this.tasks = this.tasks.filter(t => !deletedIds.has(t.id));
this.renderTasks();
this.updateStats();
this.updateSidebarCounts();
this.generateAppCategories();
// renderTasks no-ops on an app page (#tasks-list absent) — rebuild the
// per-app list there so the deleted rows actually leave the screen.
if (this._selectionAppScope() && window.appTabbedManager && typeof window.appTabbedManager.loadAppTasks === 'function') {
try { await window.appTabbedManager.loadAppTasks(); } catch {}
}
if (progressNotification && progressNotification.remove) {
progressNotification.remove();
}
if (window.notificationSystem) {
const msg = skipped.length > 0
? `Deleted ${targets.length} tasks (skipped ${skipped.length} still running)`
: (targets.length === 1 ? 'Task deleted' : `Deleted ${targets.length} tasks`);
window.notificationSystem.show(msg, 'success', null, null, null, customIcon);
}
} catch (error) {
console.error('Error clearing tasks:', error);
if (progressNotification && progressNotification.remove) {
progressNotification.remove();
}
if (window.notificationSystem) {
window.notificationSystem.show(
`Failed to clear tasks: ${error.message}`,
'error',
null,
null,
null,
customIcon
);
}
}
},
// Selection helpers wired by the row + master checkboxes. The same
// machinery serves two hosts: the global /tasks page (#tasks-list,
// #tasks-clear-btn, #tasks-select-all) and an app detail page's Tasks tab
// (#app-tasks-list, #app-tasks-clear-btn, #app-tasks-select-all) — the
// resolvers below pick whichever host is actually in the DOM.
//
// Selection state must be ONE set page-wide. TasksManager is constructed
// in several places (system-loader, app-tabbed-manager, …) while the row
// checkboxes always write through window.tasksManager — so every reader
// and writer resolves to that instance's set. Without this, ticks landed
// on one instance and the Delete Selected click read another's empty set,
// silently widening the delete to the whole (scoped) list.
_selectionSet() {
return (window.tasksManager && window.tasksManager.selectedTaskIds) || this.selectedTaskIds;
},
_selectionListEl() {
return document.getElementById('tasks-list') || document.getElementById('app-tasks-list');
},
// Truthy (the app slug) only when the ACTIVE selection host is an app
// page's task list — used to scope Clear All to that app's tasks.
_selectionAppScope() {
if (document.getElementById('tasks-list')) return null;
if (!document.getElementById('app-tasks-list')) return null;
return (window.appTabbedManager && window.appTabbedManager.currentApp) || null;
},
toggleTaskSelection(taskId, checked) {
const sel = this._selectionSet();
if (checked) sel.add(taskId);
else sel.delete(taskId);
this._updateSelectionUI();
},
toggleSelectAll(checked) {
const list = this._selectionListEl();
const sel = this._selectionSet();
if (checked) {
// Tick every CURRENTLY VISIBLE row. We use the rendered checkboxes
// rather than this.tasks so category-filtered views only select
// what the user can see.
const boxes = list ? list.querySelectorAll('[data-task-select]') : [];
boxes.forEach((cb) => {
sel.add(cb.dataset.taskSelect);
cb.checked = true;
});
} else {
sel.clear();
if (list) list.querySelectorAll('[data-task-select]').forEach((cb) => { cb.checked = false; });
}
this._updateSelectionUI();
},
// Rewrites the Clear All button label + the master-checkbox indeterminate
// state to reflect the current selection. Cheap — only touches a few
// DOM nodes, safe to call from any selection-change path.
_updateSelectionUI() {
const n = this._selectionSet().size;
const btn = document.getElementById('tasks-clear-btn') || document.getElementById('app-tasks-clear-btn');
const btnLabel = btn && btn.querySelector('.clear-btn-label');
if (btnLabel) btnLabel.textContent = n > 0 ? `Delete Selected (${n})` : 'Clear All';
if (btn) btn.title = n > 0 ? `Delete ${n} selected task${n === 1 ? '' : 's'}` : 'Clear All Tasks';
// Master checkbox: checked when ALL visible are picked, indeterminate
// when SOME are, unchecked when none.
const master = document.getElementById('tasks-select-all') || document.getElementById('app-tasks-select-all');
const list = this._selectionListEl();
const visible = list ? list.querySelectorAll('[data-task-select]') : [];
if (master) {
if (n === 0 || visible.length === 0) {
master.checked = false;
master.indeterminate = false;
} else if (n >= visible.length) {
master.checked = true;
master.indeterminate = false;
} else {
master.checked = false;
master.indeterminate = true;
}
}
},
// Resolves once the task reaches completed/failed/cancelled, or after the
// timeout. Used by deleteTask so a cancel request has time to take effect
// before we issue the DELETE that would otherwise 409.
_waitForTaskTerminal(taskId, timeoutMs = 15_000) {
return new Promise((resolve) => {
// Already terminal in the bus cache? No need to wait.
const cached = (window.taskEventBus && window.taskEventBus.getTask) ? window.taskEventBus.getTask(taskId) : null;
if (cached && (cached.status === 'completed' || cached.status === 'failed' || cached.status === 'cancelled')) {
return resolve(cached);
}
let timer;
const cleanup = () => {
window.removeEventListener('taskCompleted', onComplete);
window.removeEventListener('taskUpdated', onUpdate);
if (timer) clearTimeout(timer);
};
const isTerminal = (s) => s === 'completed' || s === 'failed' || s === 'cancelled';
const onComplete = (e) => {
const t = e.detail && (e.detail.task || (e.detail.taskId === taskId ? { id: taskId, status: e.detail.status } : null));
const id = (t && t.id) || (e.detail && e.detail.taskId);
if (id !== taskId) return;
cleanup();
resolve(t);
};
const onUpdate = (e) => {
const t = e.detail && e.detail.task;
if (!t || t.id !== taskId) return;
if (isTerminal(t.status)) { cleanup(); resolve(t); }
};
window.addEventListener('taskCompleted', onComplete);
window.addEventListener('taskUpdated', onUpdate);
timer = setTimeout(() => { cleanup(); resolve(null); }, timeoutMs);
});
},
});