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:
parent
156c7fcc08
commit
429e750266
@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
`;
|
||||
},
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user