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 6a6df64..fff6cbf 100644 --- a/containers/libreportal/frontend/components/apps/core/css/apps-layout.css +++ b/containers/libreportal/frontend/components/apps/core/css/apps-layout.css @@ -147,3 +147,12 @@ color: rgba(var(--text-rgb), 0.55); 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; +} diff --git a/containers/libreportal/frontend/components/apps/core/css/apps.css b/containers/libreportal/frontend/components/apps/core/css/apps.css index b0f49f0..33f2625 100644 --- a/containers/libreportal/frontend/components/apps/core/css/apps.css +++ b/containers/libreportal/frontend/components/apps/core/css/apps.css @@ -846,14 +846,7 @@ .ov-pick-empty { visibility: hidden; } .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 - shift the whole list down under the pointer mid-click. */ -.ov-selbar { - display: flex; align-items: center; gap: 10px; - 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; } +/* Selection actions live in the tab header's action slot (#ov-updates-actions + inside .ov-tab-header-actions), morphing "Update all" ⇄ "Update Selected + (N)" — no separate bar between toolbar and list. */ +#ov-updates-actions { display: inline-flex; align-items: center; gap: 8px; } 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 41c4fd5..a917c88 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 @@ -372,6 +372,20 @@ class AppTabbedManager {
${chips.join('')}
+
+ + +
`; } @@ -396,6 +410,10 @@ class AppTabbedManager { ? shown.map(t => this.tasksManager.renderTask(t)).join('') : `

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

`; 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 => { @@ -484,11 +502,20 @@ class AppTabbedManager { // 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. + // + // 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; + // 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 = this._renderTaskFilterBar(appTasks) + `
${appTasks.map(t => this.tasksManager.renderTask(t)).join('')}
`; this._wireTaskFilterBar(); + if (typeof this.tasksManager._updateSelectionUI === 'function') this.tasksManager._updateSelectionUI(); // Setup app-specific task interactions (separate from main tasks system) this.setupAppTaskFunctions(); diff --git a/containers/libreportal/frontend/components/apps/overview/js/overview-manager.js b/containers/libreportal/frontend/components/apps/overview/js/overview-manager.js index 2cd460d..29d0169 100644 --- a/containers/libreportal/frontend/components/apps/overview/js/overview-manager.js +++ b/containers/libreportal/frontend/components/apps/overview/js/overview-manager.js @@ -193,9 +193,12 @@ class OverviewManager { .map((d) => d.id.replace(/^ov-detail-/, '')); 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 - // body carries the (secondary) Check-now nudge. Only the real "Update all" - // action lives up here, and only when there's actually something to apply. - const actions = anyUpdate ? `` : ''; + // body carries the (secondary) Check-now nudge. Only the real update + // action lives up here, and only when there's actually something to + // 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 ? `${this._updatesHeaderActions()}` : ''; pane.innerHTML = this.renderHeader(id, actions) + body(this.renderUpdates()); open.forEach((app) => this._openDetail(app)); this._honorAppDeepLink(); @@ -349,6 +352,11 @@ class OverviewManager { case 'update-selected': if (this.updater) this.updater.applySelected([...this.selected]); 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; case 'goto': // 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)); } - // Update the bar in place. Re-rendering the whole tab on every tick would - // rebuild the checkboxes underneath the pointer and lose focus mid-selection. - _syncSelectionBar() { - const bar = document.querySelector('.ov-selbar'); - if (!bar) return; + // The tab header's action slot, driven by the selection: no ticks → + // "Update all"; any ticks → "Update Selected (N)" + Clear. One dynamic + // button atop the list, matching the /tasks page's Clear All ⇄ Delete + // Selected morph, instead of a second bar between toolbar and rows. + _updatesHeaderActions() { const n = this._liveSelection().length; - bar.classList.toggle('is-on', n > 0); - const label = bar.querySelector('.ov-selbar-count'); - if (label) label.textContent = n === 1 ? '1 app selected' : `${n} apps selected`; - const btn = bar.querySelector('[data-overview-action="update-selected"]'); - if (btn) btn.disabled = n === 0; + if (n > 0) { + return ` + `; + } + return ``; } - renderSelectionBar() { - const n = this._liveSelection().length; - return `
- ${n === 1 ? '1 app selected' : `${n} apps selected`} - - -
`; + // Update the header button in place. Re-rendering the whole tab on every + // tick would rebuild the checkboxes underneath the pointer and lose focus + // mid-selection. + _syncSelectionBar() { + const mount = document.getElementById('ov-updates-actions'); + if (mount) mount.innerHTML = this._updatesHeaderActions(); } renderUpdates() { @@ -684,7 +691,6 @@ class OverviewManager { Select all ` : ''} - ${nUpd ? this.renderSelectionBar() : ''}
${rows}
`; } diff --git a/containers/libreportal/frontend/components/backup/core/css/backup.css b/containers/libreportal/frontend/components/backup/core/css/backup.css index 7dffcdb..295094c 100755 --- a/containers/libreportal/frontend/components/backup/core/css/backup.css +++ b/containers/libreportal/frontend/components/backup/core/css/backup.css @@ -1332,6 +1332,17 @@ 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 list's own top margin to keep the box's internal rhythm even. */ .backup-snapshot-panel .backup-snapshot-rows { diff --git a/containers/libreportal/frontend/components/tasks/js/tasks-actions.js b/containers/libreportal/frontend/components/tasks/js/tasks-actions.js index eae82bd..5ceda3f 100644 --- a/containers/libreportal/frontend/components/tasks/js/tasks-actions.js +++ b/containers/libreportal/frontend/components/tasks/js/tasks-actions.js @@ -118,17 +118,27 @@ Object.assign(TasksManager.prototype, { // 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. - 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 - ? this.tasks.filter(t => this.selectedTaskIds.has(t.id)) - : this.tasks; + ? 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, then refresh - // the button label + master-checkbox state. - this.selectedTaskIds = new Set([...this.selectedTaskIds].filter(id => this.tasks.find(t => t.id === id))); + // 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; @@ -184,6 +194,11 @@ Object.assign(TasksManager.prototype, { 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(); @@ -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) { - if (checked) this.selectedTaskIds.add(taskId); - else this.selectedTaskIds.delete(taskId); + 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 = document.querySelectorAll('#tasks-list [data-task-select]'); + const boxes = list ? list.querySelectorAll('[data-task-select]') : []; boxes.forEach((cb) => { - this.selectedTaskIds.add(cb.dataset.taskSelect); + sel.add(cb.dataset.taskSelect); cb.checked = true; }); } else { - this.selectedTaskIds.clear(); - document.querySelectorAll('#tasks-list [data-task-select]').forEach((cb) => { cb.checked = false; }); + sel.clear(); + if (list) list.querySelectorAll('[data-task-select]').forEach((cb) => { cb.checked = false; }); } this._updateSelectionUI(); }, @@ -240,16 +282,17 @@ Object.assign(TasksManager.prototype, { // 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.selectedTaskIds.size; - const btn = document.getElementById('tasks-clear-btn'); + 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'); - const visible = document.querySelectorAll('#tasks-list [data-task-select]'); + 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; diff --git a/containers/libreportal/frontend/components/tasks/js/tasks-format.js b/containers/libreportal/frontend/components/tasks/js/tasks-format.js index 643289c..cbc7c88 100644 --- a/containers/libreportal/frontend/components/tasks/js/tasks-format.js +++ b/containers/libreportal/frontend/components/tasks/js/tasks-format.js @@ -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 list (\w+)/, title: (m) => `${displayName(m[1])} - List Backups` }, { match: /^libreportal backup app delete_all (\w+)/, title: (m) => `${displayName(m[1])} - Delete All Backups` }, + // Bulk form first (comma-separated : 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` }, // -- Backup: system / locations ---------------------------------------- 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 e8cabb1..02e7dc8 100644 --- a/containers/libreportal/frontend/components/tasks/js/tasks-list-render.js +++ b/containers/libreportal/frontend/components/tasks/js/tasks-list-render.js @@ -213,7 +213,7 @@ Object.assign(TasksManager.prototype, { Delete diff --git a/containers/libreportal/frontend/components/tasks/js/tasks-manager.js b/containers/libreportal/frontend/components/tasks/js/tasks-manager.js index a8d1b90..f06a8db 100755 --- a/containers/libreportal/frontend/components/tasks/js/tasks-manager.js +++ b/containers/libreportal/frontend/components/tasks/js/tasks-manager.js @@ -187,6 +187,13 @@ class TasksManager { // state — a constructor-only read goes stale after the first visit. 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 await this.loadTasks(); diff --git a/containers/libreportal/frontend/core/backup-card/js/backup-app-card.js b/containers/libreportal/frontend/core/backup-card/js/backup-app-card.js index 2b5f3b6..0503ab1 100644 --- a/containers/libreportal/frontend/core/backup-card/js/backup-app-card.js +++ b/containers/libreportal/frontend/core/backup-card/js/backup-app-card.js @@ -13,6 +13,8 @@ class BackupAppCard { this.snapshotsByLoc = {}; this.locationsByIdx = {}; 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.bindDelegated(); } @@ -37,6 +39,18 @@ class BackupAppCard { 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 // .task-header click target Services uses. 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() { @@ -76,7 +119,24 @@ class BackupAppCard { `; 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 = ` +
+ + +
${allSnaps.slice(0, 50).map(s => this._renderRow(s, iconUrl)).join('')}
@@ -118,6 +178,17 @@ class BackupAppCard { Details + +
@@ -246,6 +317,77 @@ class BackupAppCard { 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 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) { return String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' diff --git a/scripts/cli/commands/backup/cli_backup_commands.sh b/scripts/cli/commands/backup/cli_backup_commands.sh index 3c5afba..072c027 100755 --- a/scripts/cli/commands/backup/cli_backup_commands.sh +++ b/scripts/cli/commands/backup/cli_backup_commands.sh @@ -32,13 +32,50 @@ cliHandleBackupCommands() delete) [[ -z "$name" ]] && { isNotice "No app name provided."; cliShowBackupHelp; return; } [[ -z "$extra" ]] && { isNotice "No : provided (e.g. 1:abc123)."; cliShowBackupHelp; return; } - local idx="${extra%%:*}" - local snap="${extra##*:}" - backupAppDeleteSnapshot "$idx" "$snap" + # One or many: :[,:…]. The WebUI's + # bulk delete sends the whole selection as ONE task — the + # 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) [[ -z "$name" ]] && { isNotice "No app name provided."; cliShowBackupHelp; return; } 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) [[ -z "$name" ]] && { isNotice "No app name provided."; cliShowBackupHelp; return; }