feat(webui): follow an update into its task, and come back when it lands

Pressing Update produced a toast and nothing else. The row did not
change, nothing on the page moved, and the work was real but invisible —
so the button read as though it had not done anything.

Update, Update all / Update selected, and the stepped Upgrade now carry
you to the task they started and return you to the Updates tab when it
reaches a terminal state.

Correlated by task ID, not by app name: routeAction resolves to the
created task, so a second update started elsewhere cannot bring this one
back early, and a completion for an unrelated app cannot either. Only
completed/failed/cancelled end the wait — taskUpdated also fires
mid-run, and treating that as done would bounce the user out of a task
still in progress.

Navigation is deliberately NOT locked, which is the one part of the
request I did not build. The task is a background job with its own
snapshot and rollback; it does not need watching, and a lock would
strand someone here if a task ever hung. Leaving is therefore treated as
a choice — the return only fires while the user is still on a tasks
page, so nobody is yanked back from somewhere they chose to be.

Both listeners are removed on the first terminal event, and a 30-minute
timeout removes them anyway, so a task that never reports a terminal
status cannot leak a pair of window listeners per click.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
librelad 2026-08-20 22:59:15 +01:00
parent dbe3153af9
commit 5f95a0744a

View File

@ -308,7 +308,7 @@ class UpdaterPage {
}
applyUpdate(app) {
if (!app) return;
this.dispatch('updater_apply', { app }, `Updating ${app} (a recovery snapshot is taken first)…`);
this.dispatch('updater_apply', { app }, `Updating ${app} (a recovery snapshot is taken first)…`, { follow: true });
}
applyAll() {
const list = this.apps.filter(a => a.update_available).map(a => a.name);
@ -325,7 +325,7 @@ class UpdaterPage {
const want = new Set(names || []);
const list = this.apps.filter(a => a.update_available && want.has(a.name)).map(a => a.name);
if (!list.length) { this.toast('Everything is up to date.', 'info'); return; }
this.dispatch('updater_apply_all', { apps: list.join(',') }, `Updating ${list.length} app(s) — each is snapshotted first…`);
this.dispatch('updater_apply_all', { apps: list.join(',') }, `Updating ${list.length} app(s) — each is snapshotted first…`, { follow: true });
}
rollback(app) {
if (!app) return;
@ -354,7 +354,7 @@ class UpdaterPage {
migration. Watch it in Tasks.</p>
</div>`;
const go = () => this.dispatch('updater_upgrade', { app, version: version || '' },
`Upgrading ${app} to ${to}, one release at a time…`);
`Upgrading ${app} to ${to}, one release at a time…`, { follow: true });
if (window.showConfirmation) {
window.showConfirmation(`Upgrade ${label} to ${to}?`, '', go, 'Start upgrade', 'Cancel', 'warning', false, '', body,
`/core/icons/apps/${app}.svg`);
@ -371,11 +371,12 @@ class UpdaterPage {
this.dispatch('artifact_revert', { id }, `Reverting hotfix ${id}`);
}
dispatch(action, params, note) {
dispatch(action, params, note, opts) {
const route = this.services.tasks && this.services.tasks.route;
if (route && typeof route.routeAction === 'function') {
route.routeAction(action, params || {});
const started = route.routeAction(action, params || {});
this.toast(note || 'Working…', 'info');
if (opts && opts.follow) this.followTask(started);
} else if (typeof route === 'function') {
route(action, params || {});
this.toast(note || 'Working…', 'info');
@ -384,6 +385,54 @@ class UpdaterPage {
}
}
// Take the user to the task they just started, and bring them back when it
// lands. Pressing Update used to produce only a toast: the row did not change,
// nothing on the page moved, and the work was real but invisible.
//
// Correlated by task id rather than by app name — routeAction resolves to the
// created task — so a second update started elsewhere cannot return this one
// early, and a completion for some unrelated app cannot either.
//
// Deliberately does NOT lock navigation. The task is a background job with its
// own snapshot and rollback; it does not need to be watched, and a lock would
// strand the user on this page if it ever hung. So if they navigate away, that
// is a choice: the return only fires while they are still on a tasks page.
followTask(started) {
if (!window.spaClean || typeof window.spaClean.navigate !== 'function') return;
const back = window.location.pathname.startsWith('/apps/overview')
? window.location.pathname
: '/apps/overview/updates';
Promise.resolve(started).then((task) => {
const id = task && (task.id || task.taskId);
if (!id) return; // no id, nothing to follow or match
window.spaClean.navigate(window.taskPath ? window.taskPath('all', id) : `/tasks/all/${id}`);
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();
// taskUpdated also fires mid-run; only a terminal state ends the wait.
if (st && !['completed', 'failed', 'cancelled'].includes(st)) return;
finish();
if (!window.location.pathname.startsWith('/tasks')) return; // they left; leave them
window.spaClean.navigate(back);
};
window.addEventListener('taskCompleted', onEvent);
window.addEventListener('taskUpdated', onEvent);
// A task that never reports terminal must not leak two listeners forever.
const timer = setTimeout(finish, 30 * 60 * 1000);
}).catch(() => { /* the dispatch itself already surfaced the failure */ });
}
toast(msg, type) {
const n = this.services.notify;
if (n && typeof n.show === 'function') n.show(msg, type || 'info');