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:
parent
dd68c04fec
commit
facf764c4b
@ -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;
|
||||
}
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -372,6 +372,20 @@ class AppTabbedManager {
|
||||
<div class="task-filter-chips">${chips.join('')}</div>
|
||||
<input type="search" id="app-task-search" class="task-filter-search"
|
||||
placeholder="Search commands…" autocomplete="off" spellcheck="false">
|
||||
<div 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>`;
|
||||
}
|
||||
|
||||
@ -396,6 +410,10 @@ class AppTabbedManager {
|
||||
? shown.map(t => this.tasksManager.renderTask(t)).join('')
|
||||
: `<p class="task-filter-empty">No tasks match${q ? ` “${escapeAttr(q)}”` : ''}${status === 'all' ? '' : ` in ${escapeAttr(status)}`}.</p>`;
|
||||
this.tasksManager.setupGlobalFunctions();
|
||||
// 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) +
|
||||
`<div id="app-tasks-list">${appTasks.map(t => this.tasksManager.renderTask(t)).join('')}</div>`;
|
||||
this._wireTaskFilterBar();
|
||||
if (typeof this.tasksManager._updateSelectionUI === 'function') this.tasksManager._updateSelectionUI();
|
||||
|
||||
// Setup app-specific task interactions (separate from main tasks system)
|
||||
this.setupAppTaskFunctions();
|
||||
|
||||
@ -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 ? `<button class="updater-btn updater-btn-primary" data-updater-action="update-all">Update all</button>` : '';
|
||||
// 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 ? `<span id="ov-updates-actions">${this._updatesHeaderActions()}</span>` : '';
|
||||
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 `<button class="updater-btn updater-btn-primary" data-overview-action="update-selected">Update Selected (${n})</button>
|
||||
<button class="updater-btn" data-overview-action="select-clear" title="Clear the selection">Clear</button>`;
|
||||
}
|
||||
return `<button class="updater-btn updater-btn-primary" data-updater-action="update-all">Update all</button>`;
|
||||
}
|
||||
|
||||
renderSelectionBar() {
|
||||
const n = this._liveSelection().length;
|
||||
return `<div class="ov-selbar${n > 0 ? ' is-on' : ''}">
|
||||
<span class="ov-selbar-count">${n === 1 ? '1 app selected' : `${n} apps selected`}</span>
|
||||
<button class="updater-btn updater-btn-primary" data-overview-action="update-selected"${n === 0 ? ' disabled' : ''}>Update selected</button>
|
||||
<button class="updater-btn" data-overview-action="select-clear">Clear</button>
|
||||
</div>`;
|
||||
// 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 {
|
||||
<span>Select all</span>
|
||||
</label>` : ''}
|
||||
</div>
|
||||
${nUpd ? this.renderSelectionBar() : ''}
|
||||
<div class="updater-list ov-updates-list">${rows}</div>`;
|
||||
}
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 <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` },
|
||||
|
||||
// -- Backup: system / locations ----------------------------------------
|
||||
|
||||
@ -213,7 +213,7 @@ Object.assign(TasksManager.prototype, {
|
||||
<span class="task-btn-label">Delete</span>
|
||||
</button>
|
||||
<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)">
|
||||
<span class="task-select-box" aria-hidden="true"></span>
|
||||
</label>
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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 = `
|
||||
<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">
|
||||
${allSnaps.slice(0, 50).map(s => this._renderRow(s, iconUrl)).join('')}
|
||||
</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>
|
||||
<span class="task-btn-label">Details</span>
|
||||
</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 class="task-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 <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) {
|
||||
return String(s ?? '').replace(/[&<>"']/g, c => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
||||
|
||||
@ -32,13 +32,50 @@ cliHandleBackupCommands()
|
||||
delete)
|
||||
[[ -z "$name" ]] && { isNotice "No app name provided."; cliShowBackupHelp; return; }
|
||||
[[ -z "$extra" ]] && { isNotice "No <location_idx>:<snapshot_id> provided (e.g. 1:abc123)."; cliShowBackupHelp; return; }
|
||||
local idx="${extra%%:*}"
|
||||
local snap="${extra##*:}"
|
||||
backupAppDeleteSnapshot "$idx" "$snap"
|
||||
# One or many: <idx>:<snap>[,<idx>:<snap>…]. 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; }
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user