From 429e7502660bda3d97056515511ca17bd2550f52 Mon Sep 17 00:00:00 2001 From: librelad Date: Fri, 21 Aug 2026 00:14:11 +0100 Subject: [PATCH] feat(webui): backup, restore and delete buttons show their task running MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same treatment the updater buttons got. Confirming a backup produced a toast and a blind 1.5-second refresh; the tile's "Back up" pill stayed armed and nothing said work was happening. Restore and Delete on a snapshot row behaved the same. runTask now derives the SUBJECTS a command holds from the command itself: an app backup busies that app's tile, `backup system` the system tile, and `backup all` busies every tile at once — one task, honest feedback everywhere it acts. Restore and delete also parse the exact snapshot out of the command, so only the clicked row's button spins while the app's other snapshot buttons merely disable: the spinner marks the action running, not the ones waiting on it. Starting a second task on a busy subject is refused with a notice — a second restore of the same app queued behind the first is a footgun, not a feature. The face survives re-renders: the refresh coordinator repaints this page on every task event, so the tile and row renderers consult the busy map rather than relying on the instant DOM patch alone. Cleared on the task's terminal state, before the coordinator's debounced repaint lands, so that repaint shows the finished state. The leak backstop is two hours — a big app's backup is legitimately slow. Verified end to end against the live install with two real backups: the pill flips to "Backing up…" (disabled, spinner) in the same tick as the confirm click, holds through the run, and restores within a second of the task completing, with the busy map empty. Subject derivation unit-tested across all five command shapes, including `backup all` fanning out to every tile and snapshot-level targeting for restore and delete. Reuses the updater's .btn-spin — both stylesheets are global, so busy looks the same everywhere in the app. Co-Authored-By: Claude Opus 5 --- .../components/backup/core/js/backup-page.js | 100 +++++++++++++++++- .../backup/dashboard/js/backup-dashboard.js | 23 ++-- .../backup/snapshots/js/backup-snapshots.js | 31 ++++-- 3 files changed, 139 insertions(+), 15 deletions(-) diff --git a/containers/libreportal/frontend/components/backup/core/js/backup-page.js b/containers/libreportal/frontend/components/backup/core/js/backup-page.js index 444248f..b47fee5 100644 --- a/containers/libreportal/frontend/components/backup/core/js/backup-page.js +++ b/containers/libreportal/frontend/components/backup/core/js/backup-page.js @@ -18,6 +18,10 @@ class BackupPage { this.onTabChange = typeof opts.onTabChange === 'function' ? opts.onTabChange : null; this.currentTab = 'dashboard'; this.dashboard = null; + // Subjects with a task in flight: app slug or '__system__' -> + // { verb, taskId, snap } — consulted by the tile/row renderers and the + // instant DOM paint, cleared when the task reaches a terminal state. + this.busy = new Map(); this.locations = null; this.snapshotsByLoc = {}; this.expandedLocs = new Set(); @@ -613,15 +617,109 @@ class BackupPage { + // Which tiles and rows hold their breath for this command. `backup all` + // covers every app AND system, so everything busies — one task, honest + // feedback everywhere it acts. Restore/delete also record the exact + // snapshot (parsed from the command), so only the clicked row's button + // spins while the app's other snapshot buttons merely disable. + _busySubjects(command, type, app) { + const verb = /\bdelete\b/.test(command) ? 'delete' : (type === 'restore' ? 'restore' : 'backup'); + let keys = []; + if (/backup all\b/.test(command)) { + keys = [...(this.dashboard?.apps || []).map(a => a.app), '__system__']; + } else if (/\b(backup|restore) system\b/.test(command)) { + keys = ['__system__']; + } else if (app) { + keys = [app]; + } + let snap = null; + let m = command.match(/ delete \S+ (\d+):(\S+)$/); + if (m) snap = `${m[1]}:${m[2]}`; + m = command.match(/ start \S+ (\S+) (\d+)$/); + if (m) snap = `${m[2]}:${m[1]}`; + return { verb, keys, snap }; + } + + _busyLabel(verb) { + return { backup: 'Backing up…', restore: 'Restoring…', delete: 'Deleting…' }[verb] || 'Working…'; + } + + // Instant DOM patch, same pattern as the updater's buttons: flip the moment + // the click lands, stash the original face for restore, and let the + // renderers (which also consult this.busy) keep the state across the + // re-renders the refresh coordinator triggers mid-task. + _paintBusy() { + const sel = '[data-action="backup-now"], [data-action="backup-system"], [data-action="restore-snapshot"], [data-action="delete-snapshot"]'; + document.querySelectorAll(sel).forEach(btn => { + const key = btn.dataset.system ? '__system__' + : (btn.dataset.action === 'backup-system' ? '__system__' : btn.dataset.app); + const f = key ? this.busy.get(key) : null; + const isSnapBtn = btn.dataset.action === 'restore-snapshot' || btn.dataset.action === 'delete-snapshot'; + const verbOf = { 'backup-now': 'backup', 'backup-system': 'backup', 'restore-snapshot': 'restore', 'delete-snapshot': 'delete' }[btn.dataset.action]; + const snapId = isSnapBtn ? `${btn.dataset.loc}:${btn.dataset.snapshot}` : null; + const spins = !!(f && f.verb === verbOf && (!isSnapBtn || !f.snap || f.snap === snapId)); + const held = !!(f && !spins); + if (spins && !btn.dataset.busyOrig) { + btn.dataset.busyOrig = btn.innerHTML; + btn.innerHTML = `${this._busyLabel(f.verb)}`; + btn.disabled = true; + } else if (!f && btn.dataset.busyOrig) { + btn.innerHTML = btn.dataset.busyOrig; + delete btn.dataset.busyOrig; + btn.disabled = false; + } + if (held && !btn.dataset.busyOrig) { btn.disabled = true; btn.dataset.held = '1'; } + else if (!held && btn.dataset.held) { if (!btn.dataset.busyOrig) btn.disabled = false; delete btn.dataset.held; } + }); + } + async runTask(command, type, app) { if (!this.taskManager) { this.notify('Task system unavailable', 'error'); return; } + const { verb, keys, snap } = this._busySubjects(command, type, app); + // One task per subject at a time — a second restore of the same app + // queued behind the first is a footgun, not a feature. + if (keys.some(k => this.busy.has(k))) { + this.notify('That one already has a task running — let it finish first.', 'info'); + return; + } + keys.forEach(k => this.busy.set(k, { verb, taskId: null, snap })); + this._paintBusy(); + const clear = () => { keys.forEach(k => this.busy.delete(k)); this._paintBusy(); }; try { - await this.taskManager.createTask(command, type, app); + const task = await this.taskManager.createTask(command, type, app); + const id = task && task.id; + if (!id) { clear(); return; } + keys.forEach(k => this.busy.set(k, { verb, taskId: String(id), snap })); + // Clear on the task's terminal state. The refresh coordinator's own + // 'backups' entry re-renders shortly after the same event, and the + // renderers consult this.busy — so clearing first means that repaint + // shows the finished state, not a stuck spinner. + let done = false; + const finish = () => { + if (done) return; + done = true; + window.removeEventListener('taskCompleted', onEvent); + window.removeEventListener('taskUpdated', onEvent); + clearTimeout(timer); + }; + const onEvent = (e) => { + const d = e && e.detail; + if (!d || String(d.taskId) !== String(id)) return; + const st = (d.status || (d.task && d.task.status) || '').toLowerCase(); + if (st && !['completed', 'failed', 'cancelled'].includes(st)) return; + finish(); clear(); + }; + window.addEventListener('taskCompleted', onEvent); + window.addEventListener('taskUpdated', onEvent); + // A backup of a big app is legitimately slow; two hours is the + // leak backstop, not an expectation. + const timer = setTimeout(() => { finish(); clear(); }, 2 * 60 * 60 * 1000); setTimeout(() => this.refreshAll().then(() => this.render()), 1500); } catch (err) { + clear(); this.notify(`Failed to queue task: ${err.message || err}`, 'error'); } } diff --git a/containers/libreportal/frontend/components/backup/dashboard/js/backup-dashboard.js b/containers/libreportal/frontend/components/backup/dashboard/js/backup-dashboard.js index ce99fbe..bf5612b 100644 --- a/containers/libreportal/frontend/components/backup/dashboard/js/backup-dashboard.js +++ b/containers/libreportal/frontend/components/backup/dashboard/js/backup-dashboard.js @@ -87,9 +87,16 @@ Object.assign(BackupPage.prototype, { ${this.escape(when)} - + ${(() => { + // Busy-aware, so a repaint mid-task (the refresh coordinator + // re-renders on every task event) reconstructs the spinner + // instead of silently re-arming the button. + const b = this.busy && this.busy.get('__system__'); + const spin = b && b.verb === 'backup'; + return ``; + })()} `; }, @@ -117,9 +124,13 @@ Object.assign(BackupPage.prototype, { ${when} - + ${(() => { + const b = this.busy && this.busy.get(app.app); + const spin = b && b.verb === 'backup'; + return ``; + })()} `; }, diff --git a/containers/libreportal/frontend/components/backup/snapshots/js/backup-snapshots.js b/containers/libreportal/frontend/components/backup/snapshots/js/backup-snapshots.js index 6036b18..05a7afb 100644 --- a/containers/libreportal/frontend/components/backup/snapshots/js/backup-snapshots.js +++ b/containers/libreportal/frontend/components/backup/snapshots/js/backup-snapshots.js @@ -85,14 +85,29 @@ Object.assign(BackupPage.prototype, { ${this.escape(sid)}
- - + ${(() => { + // Busy-aware: while ANY task runs on this app its + // snapshot buttons are held; only the exact snapshot + // being restored/deleted shows the spinner — the + // spinner marks the action running, not the ones + // waiting on it. + const b = this.busy && this.busy.get(r.app); + const snapId = `${r.locIdx}:${sid}`; + const spin = b && b.verb === 'restore' && (!b.snap || b.snap === snapId); + return ``; + })()} + ${(() => { + const b = this.busy && this.busy.get(r.app); + const snapId = `${r.locIdx}:${sid}`; + const spin = b && b.verb === 'delete' && (!b.snap || b.snap === snapId); + return ``; + })()}