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>
345 lines
15 KiB
JavaScript
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);
|
|
});
|
|
},
|
|
});
|