feat(webui): backup, restore and delete buttons show their task running

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 <noreply@anthropic.com>
This commit is contained in:
librelad 2026-08-21 00:14:11 +01:00
parent 156c7fcc08
commit 429e750266
3 changed files with 139 additions and 15 deletions

View File

@ -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 = `<span class="btn-spin" aria-hidden="true"></span>${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');
}
}

View File

@ -87,9 +87,16 @@ Object.assign(BackupPage.prototype, {
<span>${this.escape(when)}</span>
</div>
</div>
<button type="button" class="backup-app-tile-action" data-action="backup-now" data-system="1" title="Back up now">
Back up
</button>
${(() => {
// 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 `<button type="button" class="backup-app-tile-action" data-action="backup-now" data-system="1" title="Back up now"${b ? ' disabled' : ''}>
${spin ? '<span class="btn-spin" aria-hidden="true"></span>' + this._busyLabel('backup') : 'Back up'}
</button>`;
})()}
</div>
`;
},
@ -117,9 +124,13 @@ Object.assign(BackupPage.prototype, {
<span>${when}</span>
</div>
</div>
<button type="button" class="backup-app-tile-action" data-action="backup-now" data-app="${this.escape(app.app)}" title="Back up now">
Back up
</button>
${(() => {
const b = this.busy && this.busy.get(app.app);
const spin = b && b.verb === 'backup';
return `<button type="button" class="backup-app-tile-action" data-action="backup-now" data-app="${this.escape(app.app)}" title="Back up now"${b ? ' disabled' : ''}>
${spin ? '<span class="btn-spin" aria-hidden="true"></span>' + this._busyLabel('backup') : 'Back up'}
</button>`;
})()}
</div>
`;
},

View File

@ -85,14 +85,29 @@ Object.assign(BackupPage.prototype, {
<span class="backup-snapshot-id-chip" title="Backup ID">${this.escape(sid)}</span>
</div>
<div class="task-actions">
<button class="task-btn" data-action="restore-snapshot" data-app="${this.escape(r.app)}" data-loc="${this.escape(String(r.locIdx))}" data-snapshot="${this.escape(sid)}" title="Restore from this backup">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 12a9 9 0 1 0 3-6.7"/><path d="M3 4v6h6"/></svg>
<span class="task-btn-label">Restore</span>
</button>
<button class="task-btn delete" data-action="delete-snapshot" data-app="${this.escape(r.app)}" data-loc="${this.escape(String(r.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"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"></path><path d="M10 11v6M14 11v6"></path></svg>
<span class="task-btn-label">Delete</span>
</button>
${(() => {
// 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 `<button class="task-btn" data-action="restore-snapshot" data-app="${this.escape(r.app)}" data-loc="${this.escape(String(r.locIdx))}" data-snapshot="${this.escape(sid)}" title="Restore from this backup"${b ? ' disabled' : ''}>
${spin ? '<span class="btn-spin" aria-hidden="true"></span><span class="task-btn-label">' + this._busyLabel('restore') + '</span>'
: '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 12a9 9 0 1 0 3-6.7"/><path d="M3 4v6h6"/></svg><span class="task-btn-label">Restore</span>'}
</button>`;
})()}
${(() => {
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 `<button class="task-btn delete" data-action="delete-snapshot" data-app="${this.escape(r.app)}" data-loc="${this.escape(String(r.locIdx))}" data-snapshot="${this.escape(sid)}" title="Delete this backup"${b ? ' disabled' : ''}>
${spin ? '<span class="btn-spin" aria-hidden="true"></span><span class="task-btn-label">' + this._busyLabel('delete') + '</span>'
: '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"></path><path d="M10 11v6M14 11v6"></path></svg><span class="task-btn-label">Delete</span>'}
</button>`;
})()}
<button class="task-btn toggle-details" data-action="toggle-snapshot-row" title="Toggle details">
<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>