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 ``; + })()}