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>
This commit is contained in:
librelad 2026-08-21 00:59:19 +01:00
parent dd68c04fec
commit facf764c4b
11 changed files with 332 additions and 53 deletions

View File

@ -147,3 +147,12 @@
color: rgba(var(--text-rgb), 0.55); color: rgba(var(--text-rgb), 0.55);
font-size: 13px; font-size: 13px;
} }
/* Bulk-delete controls on the app Tasks tab same Clear All / Delete
Selected button + master tick the /tasks status bar carries (their
styles come from tasks.css, which loads globally). */
.task-filter-actions {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}

View File

@ -846,14 +846,7 @@
.ov-pick-empty { visibility: hidden; } .ov-pick-empty { visibility: hidden; }
.ov-pick-box { cursor: pointer; width: 14px; height: 14px; accent-color: var(--accent, #29b6f6); } .ov-pick-box { cursor: pointer; width: 14px; height: 14px; accent-color: var(--accent, #29b6f6); }
/* The bar holds its space even at zero selected: appearing on first tick would /* Selection actions live in the tab header's action slot (#ov-updates-actions
shift the whole list down under the pointer mid-click. */ inside .ov-tab-header-actions), morphing "Update all" "Update Selected
.ov-selbar { (N)" no separate bar between toolbar and list. */
display: flex; align-items: center; gap: 10px; #ov-updates-actions { display: inline-flex; align-items: center; gap: 8px; }
padding: 8px 12px; margin: 0 0 10px;
border: 1px dashed rgba(var(--text-rgb), 0.18); border-radius: 10px;
opacity: 0.55; transition: opacity 120ms ease, border-color 120ms ease;
}
.ov-selbar.is-on { opacity: 1; border-style: solid; border-color: rgba(var(--text-rgb), 0.3); }
.ov-selbar-count { font-size: 13px; color: var(--text-secondary); }
.ov-selbar .updater-btn[disabled] { opacity: 0.45; cursor: not-allowed; }

View File

@ -372,6 +372,20 @@ class AppTabbedManager {
<div class="task-filter-chips">${chips.join('')}</div> <div class="task-filter-chips">${chips.join('')}</div>
<input type="search" id="app-task-search" class="task-filter-search" <input type="search" id="app-task-search" class="task-filter-search"
placeholder="Search commands…" autocomplete="off" spellcheck="false"> placeholder="Search commands…" autocomplete="off" spellcheck="false">
<div class="task-filter-actions">
<button class="clear-btn" id="app-tasks-clear-btn" onclick="clearAllTasks()" title="Clear All Tasks">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 6h18"></path>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
<span class="clear-btn-label">Clear All</span>
</button>
<label class="task-select-all" title="Select all visible tasks">
<input type="checkbox" id="app-tasks-select-all" onchange="window.tasksManager && tasksManager.toggleSelectAll(this.checked)">
<span class="task-select-box" aria-hidden="true"></span>
<span class="task-select-all-label">Select all</span>
</label>
</div>
</div>`; </div>`;
} }
@ -396,6 +410,10 @@ class AppTabbedManager {
? shown.map(t => this.tasksManager.renderTask(t)).join('') ? 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>`; : `<p class="task-filter-empty">No tasks match${q ? `${escapeAttr(q)}` : ''}${status === 'all' ? '' : ` in ${escapeAttr(status)}`}.</p>`;
this.tasksManager.setupGlobalFunctions(); this.tasksManager.setupGlobalFunctions();
// The filter can hide rows that stay ticked in the set (renderTask
// re-checks them when they come back) — keep the master checkbox and
// Delete Selected count honest for what's on screen.
if (typeof this.tasksManager._updateSelectionUI === 'function') this.tasksManager._updateSelectionUI();
}; };
bar.querySelectorAll('.task-filter-chip').forEach(chip => { bar.querySelectorAll('.task-filter-chip').forEach(chip => {
@ -484,11 +502,20 @@ class AppTabbedManager {
// Render app-specific tasks, with a filter bar above them. Kept // Render app-specific tasks, with a filter bar above them. Kept
// client-side over the already-loaded array: the list is per-app and // client-side over the already-loaded array: the list is per-app and
// small, so filtering is instant and needs no reload. // small, so filtering is instant and needs no reload.
//
// Drop any selection carried over from /tasks (or a previous app):
// ticks made elsewhere would silently widen this page's Delete
// Selected beyond what the user can see here.
this._appTasks = appTasks; this._appTasks = appTasks;
// Clear the page-wide set (window.tasksManager's — the one the row
// checkboxes write through), not just this instance's copy.
if (window.tasksManager) window.tasksManager.selectedTaskIds.clear();
this.tasksManager.selectedTaskIds.clear();
tasksContainer.innerHTML = tasksContainer.innerHTML =
this._renderTaskFilterBar(appTasks) + this._renderTaskFilterBar(appTasks) +
`<div id="app-tasks-list">${appTasks.map(t => this.tasksManager.renderTask(t)).join('')}</div>`; `<div id="app-tasks-list">${appTasks.map(t => this.tasksManager.renderTask(t)).join('')}</div>`;
this._wireTaskFilterBar(); this._wireTaskFilterBar();
if (typeof this.tasksManager._updateSelectionUI === 'function') this.tasksManager._updateSelectionUI();
// Setup app-specific task interactions (separate from main tasks system) // Setup app-specific task interactions (separate from main tasks system)
this.setupAppTaskFunctions(); this.setupAppTaskFunctions();

View File

@ -193,9 +193,12 @@ class OverviewManager {
.map((d) => d.id.replace(/^ov-detail-/, '')); .map((d) => d.id.replace(/^ov-detail-/, ''));
const anyUpdate = !!(this.updater && this.updater.apps.some((a) => a.update_available)); const anyUpdate = !!(this.updater && this.updater.apps.some((a) => a.update_available));
// No manual "Check" in the header — the auto-check line at the top of the // No manual "Check" in the header — the auto-check line at the top of the
// body carries the (secondary) Check-now nudge. Only the real "Update all" // body carries the (secondary) Check-now nudge. Only the real update
// action lives up here, and only when there's actually something to apply. // action lives up here, and only when there's actually something to
const actions = anyUpdate ? `<button class="updater-btn updater-btn-primary" data-updater-action="update-all">Update all</button>` : ''; // apply. The span is a stable mount so _syncSelectionBar can morph
// "Update all" ⇄ "Update Selected (N)" in place as rows get ticked —
// same dynamic-button layout as the /tasks Clear All / Delete Selected.
const actions = anyUpdate ? `<span id="ov-updates-actions">${this._updatesHeaderActions()}</span>` : '';
pane.innerHTML = this.renderHeader(id, actions) + body(this.renderUpdates()); pane.innerHTML = this.renderHeader(id, actions) + body(this.renderUpdates());
open.forEach((app) => this._openDetail(app)); open.forEach((app) => this._openDetail(app));
this._honorAppDeepLink(); this._honorAppDeepLink();
@ -349,6 +352,11 @@ class OverviewManager {
case 'update-selected': case 'update-selected':
if (this.updater) this.updater.applySelected([...this.selected]); if (this.updater) this.updater.applySelected([...this.selected]);
this.selected.clear(); this.selected.clear();
// Untick in place and flip the header back to "Update all" — the
// rows themselves repaint on the updater's own task events.
document.querySelectorAll('#overview-view .ov-pick-box').forEach((b) => { b.checked = false; });
document.querySelectorAll('#overview-view [data-overview-action="select-all"]').forEach((b) => { b.checked = false; });
this._syncSelectionBar();
return; return;
case 'goto': case 'goto':
// Whole-row navigation is a convenience over the row's own button — // Whole-row navigation is a convenience over the row's own button —
@ -630,26 +638,25 @@ class OverviewManager {
return avail.length > 0 && avail.every((n) => this.selected.has(n)); return avail.length > 0 && avail.every((n) => this.selected.has(n));
} }
// Update the bar in place. Re-rendering the whole tab on every tick would // The tab header's action slot, driven by the selection: no ticks →
// rebuild the checkboxes underneath the pointer and lose focus mid-selection. // "Update all"; any ticks → "Update Selected (N)" + Clear. One dynamic
_syncSelectionBar() { // button atop the list, matching the /tasks page's Clear All ⇄ Delete
const bar = document.querySelector('.ov-selbar'); // Selected morph, instead of a second bar between toolbar and rows.
if (!bar) return; _updatesHeaderActions() {
const n = this._liveSelection().length; const n = this._liveSelection().length;
bar.classList.toggle('is-on', n > 0); if (n > 0) {
const label = bar.querySelector('.ov-selbar-count'); return `<button class="updater-btn updater-btn-primary" data-overview-action="update-selected">Update Selected (${n})</button>
if (label) label.textContent = n === 1 ? '1 app selected' : `${n} apps selected`; <button class="updater-btn" data-overview-action="select-clear" title="Clear the selection">Clear</button>`;
const btn = bar.querySelector('[data-overview-action="update-selected"]'); }
if (btn) btn.disabled = n === 0; return `<button class="updater-btn updater-btn-primary" data-updater-action="update-all">Update all</button>`;
} }
renderSelectionBar() { // Update the header button in place. Re-rendering the whole tab on every
const n = this._liveSelection().length; // tick would rebuild the checkboxes underneath the pointer and lose focus
return `<div class="ov-selbar${n > 0 ? ' is-on' : ''}"> // mid-selection.
<span class="ov-selbar-count">${n === 1 ? '1 app selected' : `${n} apps selected`}</span> _syncSelectionBar() {
<button class="updater-btn updater-btn-primary" data-overview-action="update-selected"${n === 0 ? ' disabled' : ''}>Update selected</button> const mount = document.getElementById('ov-updates-actions');
<button class="updater-btn" data-overview-action="select-clear">Clear</button> if (mount) mount.innerHTML = this._updatesHeaderActions();
</div>`;
} }
renderUpdates() { renderUpdates() {
@ -684,7 +691,6 @@ class OverviewManager {
<span>Select all</span> <span>Select all</span>
</label>` : ''} </label>` : ''}
</div> </div>
${nUpd ? this.renderSelectionBar() : ''}
<div class="updater-list ov-updates-list">${rows}</div>`; <div class="updater-list ov-updates-list">${rows}</div>`;
} }

View File

@ -1332,6 +1332,17 @@
margin-top: 14px; margin-top: 14px;
} }
/* Bulk-delete controls atop the app card's snapshot list the Delete All /
Delete Selected morphing button + master tick, right-aligned like the
/tasks status bar (their styles come from tasks.css, loaded globally). */
.backup-snapshot-toolbar {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 4px;
margin-top: 12px;
}
/* Inside the panel the top inset comes from the panel padding, so drop the /* Inside the panel the top inset comes from the panel padding, so drop the
list's own top margin to keep the box's internal rhythm even. */ list's own top margin to keep the box's internal rhythm even. */
.backup-snapshot-panel .backup-snapshot-rows { .backup-snapshot-panel .backup-snapshot-rows {

View File

@ -118,17 +118,27 @@ Object.assign(TasksManager.prototype, {
// Routes to one of two modes depending on whether any rows are ticked. // Routes to one of two modes depending on whether any rows are ticked.
// Both paths share _showClearAllModal — same UX, same modal, different // Both paths share _showClearAllModal — same UX, same modal, different
// input list + title. // input list + title.
const hasSelection = this.selectedTaskIds.size > 0; //
// 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 const targetTasks = hasSelection
? this.tasks.filter(t => this.selectedTaskIds.has(t.id)) ? universe.filter(t => sel.has(t.id))
: this.tasks; : universe;
const result = await this._showClearAllModal(targetTasks, hasSelection ? 'selected' : 'all'); const result = await this._showClearAllModal(targetTasks, hasSelection ? 'selected' : 'all');
if (!result || !result.confirmed) return false; if (!result || !result.confirmed) return false;
await this.performClearAll({ cancelRunning: result.cancelRunning, targets: targetTasks }); await this.performClearAll({ cancelRunning: result.cancelRunning, targets: targetTasks });
if (hasSelection) { if (hasSelection) {
// Drop any ids we just deleted from the selection set, then refresh // Drop any ids we just deleted from the selection set (mutating in
// the button label + master-checkbox state. // place — the set is shared across instances), then refresh the
this.selectedTaskIds = new Set([...this.selectedTaskIds].filter(id => this.tasks.find(t => t.id === id))); // 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(); this._updateSelectionUI();
} }
return true; return true;
@ -184,6 +194,11 @@ Object.assign(TasksManager.prototype, {
this.updateStats(); this.updateStats();
this.updateSidebarCounts(); this.updateSidebarCounts();
this.generateAppCategories(); 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) { if (progressNotification && progressNotification.remove) {
progressNotification.remove(); progressNotification.remove();
@ -214,25 +229,52 @@ Object.assign(TasksManager.prototype, {
} }
} }
}, },
// Selection helpers wired by the row + master checkboxes. // 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) { toggleTaskSelection(taskId, checked) {
if (checked) this.selectedTaskIds.add(taskId); const sel = this._selectionSet();
else this.selectedTaskIds.delete(taskId); if (checked) sel.add(taskId);
else sel.delete(taskId);
this._updateSelectionUI(); this._updateSelectionUI();
}, },
toggleSelectAll(checked) { toggleSelectAll(checked) {
const list = this._selectionListEl();
const sel = this._selectionSet();
if (checked) { if (checked) {
// Tick every CURRENTLY VISIBLE row. We use the rendered checkboxes // Tick every CURRENTLY VISIBLE row. We use the rendered checkboxes
// rather than this.tasks so category-filtered views only select // rather than this.tasks so category-filtered views only select
// what the user can see. // what the user can see.
const boxes = document.querySelectorAll('#tasks-list [data-task-select]'); const boxes = list ? list.querySelectorAll('[data-task-select]') : [];
boxes.forEach((cb) => { boxes.forEach((cb) => {
this.selectedTaskIds.add(cb.dataset.taskSelect); sel.add(cb.dataset.taskSelect);
cb.checked = true; cb.checked = true;
}); });
} else { } else {
this.selectedTaskIds.clear(); sel.clear();
document.querySelectorAll('#tasks-list [data-task-select]').forEach((cb) => { cb.checked = false; }); if (list) list.querySelectorAll('[data-task-select]').forEach((cb) => { cb.checked = false; });
} }
this._updateSelectionUI(); this._updateSelectionUI();
}, },
@ -240,16 +282,17 @@ Object.assign(TasksManager.prototype, {
// state to reflect the current selection. Cheap — only touches a few // state to reflect the current selection. Cheap — only touches a few
// DOM nodes, safe to call from any selection-change path. // DOM nodes, safe to call from any selection-change path.
_updateSelectionUI() { _updateSelectionUI() {
const n = this.selectedTaskIds.size; const n = this._selectionSet().size;
const btn = document.getElementById('tasks-clear-btn'); const btn = document.getElementById('tasks-clear-btn') || document.getElementById('app-tasks-clear-btn');
const btnLabel = btn && btn.querySelector('.clear-btn-label'); const btnLabel = btn && btn.querySelector('.clear-btn-label');
if (btnLabel) btnLabel.textContent = n > 0 ? `Delete Selected (${n})` : 'Clear All'; 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'; 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 // Master checkbox: checked when ALL visible are picked, indeterminate
// when SOME are, unchecked when none. // when SOME are, unchecked when none.
const master = document.getElementById('tasks-select-all'); const master = document.getElementById('tasks-select-all') || document.getElementById('app-tasks-select-all');
const visible = document.querySelectorAll('#tasks-list [data-task-select]'); const list = this._selectionListEl();
const visible = list ? list.querySelectorAll('[data-task-select]') : [];
if (master) { if (master) {
if (n === 0 || visible.length === 0) { if (n === 0 || visible.length === 0) {
master.checked = false; master.checked = false;

View File

@ -96,6 +96,10 @@ Object.assign(TasksManager.prototype, {
{ match: /^libreportal backup app schedule (\w+)/, title: (m) => `${displayName(m[1])} - Scheduled Backup` }, { match: /^libreportal backup app schedule (\w+)/, title: (m) => `${displayName(m[1])} - Scheduled Backup` },
{ match: /^libreportal backup app list (\w+)/, title: (m) => `${displayName(m[1])} - List Backups` }, { match: /^libreportal backup app list (\w+)/, title: (m) => `${displayName(m[1])} - List Backups` },
{ match: /^libreportal backup app delete_all (\w+)/, title: (m) => `${displayName(m[1])} - Delete All Backups` }, { match: /^libreportal backup app delete_all (\w+)/, title: (m) => `${displayName(m[1])} - Delete All Backups` },
// Bulk form first (comma-separated <idx>:<snap> pairs from the app
// card's Delete Selected) — the single-delete row below would match
// the same command and mislabel it.
{ match: /^libreportal backup app delete (\w+) (\S*,\S*)/, title: (m) => `${displayName(m[1])} - Delete ${m[2].split(',').filter(Boolean).length} Backups` },
{ match: /^libreportal backup app delete (\w+)/, title: (m) => `${displayName(m[1])} - Delete Backup` }, { match: /^libreportal backup app delete (\w+)/, title: (m) => `${displayName(m[1])} - Delete Backup` },
// -- Backup: system / locations ---------------------------------------- // -- Backup: system / locations ----------------------------------------

View File

@ -213,7 +213,7 @@ Object.assign(TasksManager.prototype, {
<span class="task-btn-label">Delete</span> <span class="task-btn-label">Delete</span>
</button> </button>
<label class="task-select" onclick="event.stopPropagation();" title="Select for bulk delete"> <label class="task-select" onclick="event.stopPropagation();" title="Select for bulk delete">
<input type="checkbox" data-task-select="${task.id}" ${this.selectedTaskIds.has(task.id) ? 'checked' : ''} <input type="checkbox" data-task-select="${task.id}" ${(typeof this._selectionSet === 'function' ? this._selectionSet() : this.selectedTaskIds).has(task.id) ? 'checked' : ''}
onchange="event.stopPropagation(); window.tasksManager && tasksManager.toggleTaskSelection('${task.id}', this.checked)"> onchange="event.stopPropagation(); window.tasksManager && tasksManager.toggleTaskSelection('${task.id}', this.checked)">
<span class="task-select-box" aria-hidden="true"></span> <span class="task-select-box" aria-hidden="true"></span>
</label> </label>

View File

@ -187,6 +187,13 @@ class TasksManager {
// state — a constructor-only read goes stale after the first visit. // state — a constructor-only read goes stale after the first visit.
this.initializeFromURL(); this.initializeFromURL();
// Fresh visit, fresh selection. Ticks survive category switches within
// the page on purpose, but ones carried over from an app page's Tasks
// tab would widen a bulk delete beyond what this page showed being
// ticked. Clear the shared page-wide set too (see _selectionSet).
if (window.tasksManager && window.tasksManager !== this) window.tasksManager.selectedTaskIds.clear();
this.selectedTaskIds.clear();
// Load initial tasks and refresh sidebar counts // Load initial tasks and refresh sidebar counts
await this.loadTasks(); await this.loadTasks();

View File

@ -13,6 +13,8 @@ class BackupAppCard {
this.snapshotsByLoc = {}; this.snapshotsByLoc = {};
this.locationsByIdx = {}; this.locationsByIdx = {};
this.appStatus = null; this.appStatus = null;
// Bulk-delete selection: "locIdx:snapshotId" keys of ticked rows.
this.selectedSnaps = new Set();
this.taskManager = (typeof TaskManager !== 'undefined') ? new TaskManager() : null; this.taskManager = (typeof TaskManager !== 'undefined') ? new TaskManager() : null;
this.bindDelegated(); this.bindDelegated();
} }
@ -37,6 +39,18 @@ class BackupAppCard {
return; return;
} }
const deleteBtn = e.target.closest('[data-action="delete-app-snapshot"]');
if (deleteBtn) {
e.stopPropagation();
card.deleteSnapshot(deleteBtn.dataset.loc, deleteBtn.dataset.snapshot);
return;
}
if (e.target.closest('#backup-snaps-delete-btn')) {
card.deleteSelected();
return;
}
// Header click → toggle the detail panel. Mirrors the // Header click → toggle the detail panel. Mirrors the
// .task-header click target Services uses. // .task-header click target Services uses.
const header = e.target.closest('.backup-snapshot-item .task-header'); const header = e.target.closest('.backup-snapshot-item .task-header');
@ -48,6 +62,35 @@ class BackupAppCard {
} }
} }
}); });
// Selection checkboxes (per-row + master). Change, not click — the
// visible box is a styled sibling of a hidden input, so click targets
// vary; change always fires on the input itself.
document.addEventListener('change', (e) => {
const card = window.backupAppCard;
if (!card) return;
const row = e.target.closest && e.target.closest('[data-snap-select]');
if (row || (e.target.matches && e.target.matches('[data-snap-select]'))) {
const cb = row || e.target;
card.toggleSnapSelection(cb.dataset.snapSelect, cb.checked);
return;
}
if (e.target.id === 'backup-snaps-select-all') {
card.toggleSelectAllSnaps(e.target.checked);
}
});
// A finished backup/delete/restore task for this app means the
// snapshot list on screen is stale — repaint from the regenerated
// JSON. render() no-ops when the Backups tab isn't mounted.
window.addEventListener('taskCompleted', (e) => {
const card = window.backupAppCard;
if (!card) return;
const t = e.detail && e.detail.task;
if (!t || t.app !== card.appName) return;
if (!/^libreportal (backup app (create|delete|delete_all)|restore app start)\b/.test(t.command || '')) return;
setTimeout(() => card.render(), 800);
});
} }
async render() { async render() {
@ -76,7 +119,24 @@ class BackupAppCard {
`; `;
const iconUrl = `/core/icons/apps/${encodeURIComponent(this.appName)}.svg`; const iconUrl = `/core/icons/apps/${encodeURIComponent(this.appName)}.svg`;
// Fresh data, fresh selection — ticks made against the previous list
// could name snapshots that no longer exist.
this.selectedSnaps.clear();
snapsEl.innerHTML = ` snapsEl.innerHTML = `
<div class="backup-snapshot-toolbar">
<button class="clear-btn" id="backup-snaps-delete-btn" title="Delete All Backups">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 6h18"></path>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
<span class="clear-btn-label">Delete All</span>
</button>
<label class="task-select-all" title="Select all backups">
<input type="checkbox" id="backup-snaps-select-all">
<span class="task-select-box" aria-hidden="true"></span>
<span class="task-select-all-label">Select all</span>
</label>
</div>
<div class="backup-snapshot-rows"> <div class="backup-snapshot-rows">
${allSnaps.slice(0, 50).map(s => this._renderRow(s, iconUrl)).join('')} ${allSnaps.slice(0, 50).map(s => this._renderRow(s, iconUrl)).join('')}
</div> </div>
@ -118,6 +178,17 @@ class BackupAppCard {
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6,9 12,15 18,9"></polyline></svg> <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6,9 12,15 18,9"></polyline></svg>
<span class="task-btn-label">Details</span> <span class="task-btn-label">Details</span>
</button> </button>
<button class="task-btn delete" data-action="delete-app-snapshot" data-loc="${this.escape(String(s.locIdx))}" data-snapshot="${this.escape(sid)}" title="Delete this backup">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
<span class="task-btn-label">Delete</span>
</button>
<label class="task-select" onclick="event.stopPropagation();" title="Select for bulk delete">
<input type="checkbox" data-snap-select="${this.escape(String(s.locIdx))}:${this.escape(sid)}"${this.selectedSnaps.has(`${s.locIdx}:${sid}`) ? ' checked' : ''}>
<span class="task-select-box" aria-hidden="true"></span>
</label>
</div> </div>
</div> </div>
<div class="task-details"> <div class="task-details">
@ -246,6 +317,77 @@ class BackupAppCard {
await this.taskManager.createTask(`libreportal restore app start ${this.appName} ${snapshot} ${locIdx}`, 'restore', this.appName); await this.taskManager.createTask(`libreportal restore app start ${this.appName} ${snapshot} ${locIdx}`, 'restore', this.appName);
} }
// ---- Deletion (single row, ticked selection, or everything) ----------
toggleSnapSelection(key, checked) {
if (!key) return;
if (checked) this.selectedSnaps.add(key);
else this.selectedSnaps.delete(key);
this._updateSnapSelectionUI();
}
toggleSelectAllSnaps(checked) {
const boxes = document.querySelectorAll('#backup-app-card-snapshots [data-snap-select]');
if (checked) {
boxes.forEach(cb => { this.selectedSnaps.add(cb.dataset.snapSelect); cb.checked = true; });
} else {
this.selectedSnaps.clear();
boxes.forEach(cb => { cb.checked = false; });
}
this._updateSnapSelectionUI();
}
// Same morph as the /tasks action bar: "Delete All" with nothing ticked,
// "Delete Selected (N)" once rows are.
_updateSnapSelectionUI() {
const n = this.selectedSnaps.size;
const btn = document.getElementById('backup-snaps-delete-btn');
const label = btn && btn.querySelector('.clear-btn-label');
if (label) label.textContent = n > 0 ? `Delete Selected (${n})` : 'Delete All';
if (btn) btn.title = n > 0 ? `Delete ${n} selected backup${n === 1 ? '' : 's'}` : 'Delete All Backups';
const master = document.getElementById('backup-snaps-select-all');
const visible = document.querySelectorAll('#backup-app-card-snapshots [data-snap-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; }
}
}
async deleteSnapshot(locIdx, snapshot) {
const locName = this.locationsByIdx[locIdx]?.name || `Location ${locIdx}`;
if (!confirm(`Delete backup ${snapshot} for ${this.appName} from ${locName}?\n\nThis cannot be undone. Append-only locations will reject the operation.`)) return;
await this._queueDelete([`${locIdx}:${snapshot}`]);
}
async deleteSelected() {
if (this.selectedSnaps.size === 0) {
await this.deleteAllBackups();
return;
}
const pairs = [...this.selectedSnaps];
if (!confirm(`Delete ${pairs.length} selected backup${pairs.length === 1 ? '' : 's'} for ${this.appName}?\n\nThis cannot be undone. Append-only locations will reject the operation.`)) return;
await this._queueDelete(pairs);
}
async deleteAllBackups() {
if (!confirm(`Delete ALL backups for ${this.appName}, on every location?\n\nEvery snapshot of this app will be gone for good. Append-only locations are skipped.`)) return;
if (!this.taskManager) return;
await this.taskManager.createTask(`libreportal backup app delete_all ${this.appName}`, 'backup', this.appName);
if (window.notificationSystem) window.notificationSystem.show(`Deleting all backups for ${this.appName}`, 'info');
}
// The whole selection rides in ONE task ("delete <app> 1:a,2:b,…") — the
// backup surfaces hold one task per subject at a time, so N queued
// single-snapshot tasks would be refused after the first anyway.
async _queueDelete(pairs) {
if (!this.taskManager || !pairs.length) return;
await this.taskManager.createTask(`libreportal backup app delete ${this.appName} ${pairs.join(',')}`, 'backup', this.appName);
this.selectedSnaps.clear();
this._updateSnapSelectionUI();
if (window.notificationSystem) window.notificationSystem.show(`Deleting ${pairs.length} backup${pairs.length === 1 ? '' : 's'} for ${this.appName}`, 'info');
}
escape(s) { escape(s) {
return String(s ?? '').replace(/[&<>"']/g, c => ({ return String(s ?? '').replace(/[&<>"']/g, c => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'

View File

@ -32,13 +32,50 @@ cliHandleBackupCommands()
delete) delete)
[[ -z "$name" ]] && { isNotice "No app name provided."; cliShowBackupHelp; return; } [[ -z "$name" ]] && { isNotice "No app name provided."; cliShowBackupHelp; return; }
[[ -z "$extra" ]] && { isNotice "No <location_idx>:<snapshot_id> provided (e.g. 1:abc123)."; cliShowBackupHelp; return; } [[ -z "$extra" ]] && { isNotice "No <location_idx>:<snapshot_id> provided (e.g. 1:abc123)."; cliShowBackupHelp; return; }
local idx="${extra%%:*}" # One or many: <idx>:<snap>[,<idx>:<snap>…]. The WebUI's
local snap="${extra##*:}" # bulk delete sends the whole selection as ONE task — the
backupAppDeleteSnapshot "$idx" "$snap" # backup surfaces allow one task per subject at a time, so
# N queued single-snapshot tasks would be refused after
# the first.
local _del_pair _del_idx _del_snap _del_ok=0 _del_failed=0
local _del_pairs
IFS=',' read -ra _del_pairs <<< "$extra"
for _del_pair in "${_del_pairs[@]}"; do
[[ -z "$_del_pair" ]] && continue
_del_idx="${_del_pair%%:*}"
_del_snap="${_del_pair##*:}"
if backupAppDeleteSnapshot "$_del_idx" "$_del_snap"; then
_del_ok=$((_del_ok + 1))
else
_del_failed=$((_del_failed + 1))
fi
done
# The generated JSON is what the WebUI renders — without a
# regen the deleted snapshots keep showing until the next
# backup rewrites it.
if [[ "$CFG_REQUIREMENT_WEBUI" == "true" && $_del_ok -gt 0 ]]; then
webuiGenerateBackupDashboard
webuiGenerateBackupSnapshots all
webuiGenerateBackupAppStatus "$name"
fi
if [[ $_del_failed -gt 0 ]]; then
isError "Deleted $_del_ok snapshot(s); $_del_failed failed"
false
elif [[ $_del_ok -gt 1 ]]; then
isSuccessful "Deleted $_del_ok snapshots for $name"
fi
;; ;;
delete_all) delete_all)
[[ -z "$name" ]] && { isNotice "No app name provided."; cliShowBackupHelp; return; } [[ -z "$name" ]] && { isNotice "No app name provided."; cliShowBackupHelp; return; }
backupAppDeleteAll "$name" backupAppDeleteAll "$name"
# Same staleness rule as `delete`: the WebUI renders the
# generated JSON, so regenerate it now that the snapshots
# are gone.
if [[ "$CFG_REQUIREMENT_WEBUI" == "true" ]]; then
webuiGenerateBackupDashboard
webuiGenerateBackupSnapshots all
webuiGenerateBackupAppStatus "$name"
fi
;; ;;
list) list)
[[ -z "$name" ]] && { isNotice "No app name provided."; cliShowBackupHelp; return; } [[ -z "$name" ]] && { isNotice "No app name provided."; cliShowBackupHelp; return; }