From 5f95a0744a2dd9a77ebeb70892820304566bc942 Mon Sep 17 00:00:00 2001 From: librelad Date: Thu, 20 Aug 2026 22:59:15 +0100 Subject: [PATCH] feat(webui): follow an update into its task, and come back when it lands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../components/updater/js/updater-page.js | 59 +++++++++++++++++-- 1 file changed, 54 insertions(+), 5 deletions(-) diff --git a/containers/libreportal/frontend/components/updater/js/updater-page.js b/containers/libreportal/frontend/components/updater/js/updater-page.js index 97194fd..b97d53f 100644 --- a/containers/libreportal/frontend/components/updater/js/updater-page.js +++ b/containers/libreportal/frontend/components/updater/js/updater-page.js @@ -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.

`; 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');