feat(webui): update buttons show their task running, in place
Replaces the follow-the-task navigation from the previous commit — carrying the user to the tasks page and back was feedback by relocation. This is feedback where the click happened: the button becomes "Updating…" with a spinner and stays disabled until the task reaches a terminal state, then the data refetches and the row repaints with the result. Update, Update all / selected, the stepped Upgrade and Roll back all get it (verbs keep their own labels: Upgrading…, Rolling back…). Two paths keep the face honest. A DOM patch flips the buttons the moment the click lands — no waiting for a render pass — stashing the original face on the element so a failed dispatch can restore it. And every renderer now builds these buttons through one busy-aware helper, so a repaint landing MID-task (the auto-refresh poll, a filter change) reconstructs the spinner instead of silently re-enabling the button. Sibling actions are held while a task runs: Roll back on an app that is mid-update is disabled — without a spinner, which marks the action that is running, not the ones waiting on it. Entry points also guard on the inflight set, so a keyboard-triggered duplicate is inert. Correlation is by task id (routeAction resolves to the created task), so another task finishing cannot end this button's busy state early. Only completed/failed/cancelled end it; listeners are removed on the first terminal event, with a 30-minute timeout so a task that never reports terminal cannot pin a spinner forever. cursor is "progress", not "not-allowed" — the work is happening, the button is not refusing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
caead9e100
commit
c6e3997382
@ -791,9 +791,9 @@ class OverviewManager {
|
||||
// no way to act on them, which is exactly how it was reported. Secondary
|
||||
// styling keeps the two visibly different.
|
||||
const updBtn = a.update_available
|
||||
? `<button class="updater-btn updater-btn-primary" data-updater-action="update" data-app="${slug}">Update</button>`
|
||||
? this.updater.actionBtn(a.name, 'update', 'Update', { cls: 'updater-btn updater-btn-primary' })
|
||||
: (a.newer_version
|
||||
? `<button class="updater-btn updater-btn-success" data-updater-action="upgrade" data-app="${slug}" data-version="${esc(a.newer_version)}" title="Move to the ${esc(a.newer_version)} release line — you will be asked to confirm first">Upgrade</button>`
|
||||
? this.updater.actionBtn(a.name, 'upgrade', 'Upgrade', { cls: 'updater-btn updater-btn-success', extra: ` data-version="${esc(a.newer_version)}" title="Move to the ${esc(a.newer_version)} release line — you will be asked to confirm first"` })
|
||||
: '');
|
||||
// Only rows with something to apply are selectable. A checkbox on a row
|
||||
// that is already current would offer a choice with no outcome, and
|
||||
|
||||
@ -204,3 +204,24 @@
|
||||
@media (max-width: 600px) {
|
||||
.updater-row { grid-template-columns: 1fr; gap: 6px; }
|
||||
}
|
||||
|
||||
/* Busy face for action buttons (Update / Upgrade / Roll back) while their task
|
||||
runs. The spinner borrows the global spin keyframes; currentColor keeps it
|
||||
legible on primary, success and plain buttons alike without per-variant
|
||||
colours. cursor: progress rather than not-allowed — the work IS happening,
|
||||
the button is not refusing. */
|
||||
.updater-btn .btn-spin {
|
||||
display: inline-block;
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
margin-right: 6px;
|
||||
vertical-align: -1px;
|
||||
border: 2px solid rgba(var(--text-rgb), 0.25);
|
||||
border-top-color: currentColor;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
.updater-btn-busy {
|
||||
opacity: 0.75;
|
||||
cursor: progress;
|
||||
}
|
||||
|
||||
@ -19,6 +19,7 @@ class UpdaterPage {
|
||||
this._pushedAnyTab = false;
|
||||
this._eventBound = false;
|
||||
this._poll = null; // auto-refresh timer (startAutoRefresh/dispose)
|
||||
this.inflight = new Map(); // app name -> { verb, taskId } while its task runs
|
||||
}
|
||||
|
||||
// ---- lifecycle -----------------------------------------------------------
|
||||
@ -307,8 +308,9 @@ class UpdaterPage {
|
||||
this.dispatch('updater_check', {}, 'Checking apps for updates & vulnerabilities…');
|
||||
}
|
||||
applyUpdate(app) {
|
||||
if (!app) return;
|
||||
this.dispatch('updater_apply', { app }, `Updating ${app} (a recovery snapshot is taken first)…`, { follow: true });
|
||||
if (!app || this.inflight.has(app)) return; // one task per app at a time
|
||||
this.dispatch('updater_apply', { app }, `Updating ${app} (a recovery snapshot is taken first)…`,
|
||||
{ track: { apps: [app], verb: 'update' } });
|
||||
}
|
||||
applyAll() {
|
||||
const list = this.apps.filter(a => a.update_available).map(a => a.name);
|
||||
@ -325,17 +327,19 @@ 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…`, { follow: true });
|
||||
this.dispatch('updater_apply_all', { apps: list.join(',') }, `Updating ${list.length} app(s) — each is snapshotted first…`,
|
||||
{ track: { apps: list, verb: 'update' } });
|
||||
}
|
||||
rollback(app) {
|
||||
if (!app) return;
|
||||
this.dispatch('updater_rollback', { app }, `Rolling ${app} back to its pre-update snapshot…`);
|
||||
if (!app || this.inflight.has(app)) return;
|
||||
this.dispatch('updater_rollback', { app }, `Rolling ${app} back to its pre-update snapshot…`,
|
||||
{ track: { apps: [app], verb: 'rollback' } });
|
||||
}
|
||||
// Cross-version upgrade. Always confirmed, and never quietly: this walks
|
||||
// real migrations one release at a time and can take a long while, so the
|
||||
// dialog states the plan and the guarantee rather than asking "are you sure?".
|
||||
upgrade(app, version) {
|
||||
if (!app) return;
|
||||
if (!app || this.inflight.has(app)) return;
|
||||
const a = this.apps.find((x) => x.name === app) || {};
|
||||
const from = a.channel || a.current_version || 'the current version';
|
||||
const to = version || a.newer_version || 'the newest release';
|
||||
@ -354,7 +358,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…`, { follow: true });
|
||||
`Upgrading ${app} to ${to}, one release at a time…`, { track: { apps: [app], verb: 'upgrade' } });
|
||||
if (window.showConfirmation) {
|
||||
window.showConfirmation(`Upgrade ${label} to ${to}?`, '', go, 'Start upgrade', 'Cancel', 'warning', false, '', body,
|
||||
`/core/icons/apps/${app}.svg`);
|
||||
@ -376,7 +380,7 @@ class UpdaterPage {
|
||||
if (route && typeof route.routeAction === 'function') {
|
||||
const started = route.routeAction(action, params || {});
|
||||
this.toast(note || 'Working…', 'info');
|
||||
if (opts && opts.follow) this.followTask(started);
|
||||
if (opts && opts.track) this.trackTask(opts.track.apps, opts.track.verb, started);
|
||||
} else if (typeof route === 'function') {
|
||||
route(action, params || {});
|
||||
this.toast(note || 'Working…', 'info');
|
||||
@ -385,28 +389,75 @@ 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.
|
||||
// In-place feedback for a started task. 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 — the button read as though it had done nothing. Now the
|
||||
// button itself becomes "Updating…" with a spinner and stays disabled until
|
||||
// the task reaches a terminal state, at which point the data is refetched and
|
||||
// the row repaints showing the result. (An earlier iteration navigated to the
|
||||
// tasks page and back instead; superseded — watching the button beats being
|
||||
// moved around, and the tasks page is one click away for whoever wants logs.)
|
||||
//
|
||||
// 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';
|
||||
// created task — so a second task started elsewhere cannot end this one's
|
||||
// busy state early, and an unrelated app's completion cannot either.
|
||||
_busyLabel(verb) {
|
||||
return { update: 'Updating…', upgrade: 'Upgrading…', rollback: 'Rolling back…' }[verb] || 'Working…';
|
||||
}
|
||||
|
||||
// One action button, busy-aware. Every renderer builds these through here so
|
||||
// a repaint that lands MID-task (the auto-refresh poll, a filter change)
|
||||
// reconstructs the busy state instead of silently re-enabling the button.
|
||||
actionBtn(app, verb, label, opts) {
|
||||
const o = opts || {};
|
||||
const f = this.inflight.get(app);
|
||||
const busy = !!(f && f.verb === verb);
|
||||
// A SIBLING action is held while any task runs on the app — Roll back on an
|
||||
// app that is mid-update is not a thing to offer. Disabled but no spinner:
|
||||
// the spinner marks the action that is running, not the ones waiting on it.
|
||||
const held = !!(f && f.verb !== verb);
|
||||
return `<button class="${o.cls || 'updater-btn'}${busy ? ' updater-btn-busy' : ''}"`
|
||||
+ ` data-updater-action="${verb}" data-app="${this.escape(app)}"${o.extra || ''}${busy || held ? ' disabled' : ''}>`
|
||||
+ `${busy ? `<span class="btn-spin" aria-hidden="true"></span>${this._busyLabel(verb)}` : label}</button>`;
|
||||
}
|
||||
|
||||
// Patch buttons already in the DOM, both on this page and on the fleet
|
||||
// Overview (they share the data-updater-action markup). This is what makes
|
||||
// the click feel acknowledged INSTANTLY — no waiting for a re-render pass.
|
||||
// The original face is stashed on the element so clearing restores it even
|
||||
// when no repaint follows (e.g. the dispatch itself failed).
|
||||
_paintInflight() {
|
||||
document.querySelectorAll('button[data-updater-action][data-app]').forEach((btn) => {
|
||||
const f = this.inflight.get(btn.dataset.app);
|
||||
const busy = !!(f && f.verb === btn.dataset.updaterAction);
|
||||
const held = !!(f && f.verb !== btn.dataset.updaterAction);
|
||||
if (held && !btn.dataset.busyOrig) { btn.disabled = true; btn.dataset.held = '1'; }
|
||||
else if (!held && btn.dataset.held) { btn.disabled = false; delete btn.dataset.held; }
|
||||
if (busy && !btn.dataset.busyOrig) {
|
||||
btn.dataset.busyOrig = btn.innerHTML;
|
||||
btn.innerHTML = `<span class="btn-spin" aria-hidden="true"></span>${this._busyLabel(btn.dataset.updaterAction)}`;
|
||||
btn.classList.add('updater-btn-busy');
|
||||
btn.disabled = true;
|
||||
} else if (!busy && btn.dataset.busyOrig) {
|
||||
btn.innerHTML = btn.dataset.busyOrig;
|
||||
delete btn.dataset.busyOrig;
|
||||
btn.classList.remove('updater-btn-busy');
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
trackTask(apps, verb, started) {
|
||||
const names = (Array.isArray(apps) ? apps : [apps]).filter(Boolean);
|
||||
if (!names.length) return;
|
||||
names.forEach((n) => this.inflight.set(n, { verb, taskId: null }));
|
||||
this._paintInflight();
|
||||
const clear = () => names.forEach((n) => this.inflight.delete(n));
|
||||
|
||||
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}`);
|
||||
if (!id) { clear(); this._paintInflight(); return; } // nothing to correlate on
|
||||
names.forEach((n) => this.inflight.set(n, { verb, taskId: String(id) }));
|
||||
|
||||
let done = false;
|
||||
const finish = () => {
|
||||
@ -420,19 +471,23 @@ class UpdaterPage {
|
||||
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.
|
||||
// taskUpdated also fires mid-run; only a terminal state ends the busy face.
|
||||
if (st && !['completed', 'failed', 'cancelled'].includes(st)) return;
|
||||
finish();
|
||||
if (!window.location.pathname.startsWith('/tasks')) return; // they left; leave them
|
||||
window.spaClean.navigate(back);
|
||||
clear();
|
||||
// Refetch, then repaint: render() no-ops when this page is embedded in
|
||||
// the fleet Overview, whose own refresh coordinator repaints its rows.
|
||||
this.refreshAll().then(() => { this._paintInflight(); this.render(); });
|
||||
};
|
||||
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 */ });
|
||||
// A task that never reports terminal must not pin a spinner (or leak two
|
||||
// listeners) forever.
|
||||
const timer = setTimeout(() => { finish(); clear(); this._paintInflight(); }, 30 * 60 * 1000);
|
||||
}).catch(() => { clear(); this._paintInflight(); });
|
||||
}
|
||||
|
||||
|
||||
toast(msg, type) {
|
||||
const n = this.services.notify;
|
||||
if (n && typeof n.show === 'function') n.show(msg, type || 'info');
|
||||
@ -523,7 +578,7 @@ class UpdaterPage {
|
||||
: (a.scanned ? `<span class="updater-badge updater-badge-ok">up to date</span>` : `<span class="updater-badge updater-badge-unknown">unscanned</span>`);
|
||||
const sev = a.worstSeverity ? `<span class="updater-badge sev-${a.worstSeverity}">${a.worstSeverity}</span>` : '';
|
||||
const btn = a.update_available
|
||||
? `<button class="updater-btn updater-btn-primary" data-updater-action="update" data-app="${this.escape(a.name)}">Update</button>`
|
||||
? this.actionBtn(a.name, 'update', 'Update', { cls: 'updater-btn updater-btn-primary' })
|
||||
: '';
|
||||
return `<div class="updater-row">
|
||||
<div class="updater-row-main"><span class="updater-row-name">${this.escape(a.displayName)}</span> ${badge} ${sev}</div>
|
||||
@ -611,7 +666,7 @@ class UpdaterPage {
|
||||
<div class="updater-row-main"><span class="updater-row-name">${this.escape(a.displayName)}</span>
|
||||
<span class="updater-badge ${can ? 'updater-badge-ok' : 'updater-badge-unknown'}">${can ? 'recoverable' : 'protected'}</span></div>
|
||||
<div class="updater-row-ver">${snap}</div>
|
||||
<div class="updater-row-actions">${can ? `<button class="updater-btn" data-updater-action="rollback" data-app="${this.escape(a.name)}">Roll back</button>` : ''}</div>
|
||||
<div class="updater-row-actions">${can ? `${this.actionBtn(a.name, 'rollback', 'Roll back')}` : ''}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
return `
|
||||
@ -666,7 +721,7 @@ class UpdaterPage {
|
||||
// exists and what to do about it.
|
||||
const newerLine = a.newer_version
|
||||
? `<div class="updater-detail-row"><span class="updater-detail-meta">A newer release line is available: <strong>${this.escape(a.newer_version)}</strong> (you track ${this.escape(a.channel || '—')}). Automatic updates keep you current within your line. Upgrading walks the releases one at a time, snapshotting and verifying each — read the release notes first.</span>
|
||||
<button class="updater-btn" data-updater-action="upgrade" data-app="${this.escape(a.name)}" data-version="${this.escape(a.newer_version)}">Upgrade to ${this.escape(a.newer_version)}</button></div>`
|
||||
${this.actionBtn(a.name, 'upgrade', `Upgrade to ${this.escape(a.newer_version)}`, { extra: ` data-version="${this.escape(a.newer_version)}"` })}</div>`
|
||||
: '';
|
||||
// Maintenance, which "up to date" cannot express. Phrased as an
|
||||
// observation with a date, not an accusation — plenty of small tools are
|
||||
@ -719,7 +774,7 @@ class UpdaterPage {
|
||||
const recovery = `<div class="updater-detail-section"><h4>Recovery</h4>
|
||||
<div class="updater-detail-row"><span class="updater-badge ${can ? 'updater-badge-ok' : 'updater-badge-unknown'}">${can ? 'recoverable' : 'protected'}</span>
|
||||
<span class="updater-detail-meta">${snap}</span>
|
||||
${can ? `<button class="updater-btn" data-updater-action="rollback" data-app="${this.escape(a.name)}">Roll back</button>` : ''}</div></div>`;
|
||||
${can ? `${this.actionBtn(a.name, 'rollback', 'Roll back')}` : ''}</div></div>`;
|
||||
|
||||
const entries = ((this.history && this.history.entries) || []).filter((e) => e.app === a.name).slice(0, 8);
|
||||
const history = entries.length ? `<div class="updater-detail-section"><h4>History</h4>${entries.map((e) => `
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user