feat(updater): Upgrade button for cross-version moves

Puts the stepped engine behind the "34 available" chip so it is not
CLI-only. Wired end to end: updater_upgrade task type -> task-router ->
updaterUpgrade action -> `libreportal updater upgrade <app> [version]`,
with a label and icon in the tasks list.

The button always confirms, and the dialog states the plan and the
guarantee rather than asking "are you sure?" — which app, from which
version to which, that every step snapshots first and waits for the app
to confirm it is serving with no migration outstanding, that a failure
stops the ladder on the last version that verified, and that it can take
a long time because each release runs its own migration.

Both delegated dispatchers (per-app Updates tab and the fleet Overview
rows) learn the action, so the button works wherever the chip appears.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
librelad 2026-08-13 00:08:16 +01:00
parent 0679fd65b2
commit 046d69bad7
6 changed files with 47 additions and 1 deletions

View File

@ -306,6 +306,7 @@ class OverviewManager {
case 'update': this.updater.applyUpdate(app); break;
case 'update-all': this.updater.applyAll(); break;
case 'rollback': this.updater.rollback(app); break;
case 'upgrade': this.updater.upgrade(app, ua.dataset.version); break;
case 'apply-artifact': this.updater.applyArtifact(ua.dataset.id); break;
case 'revert-artifact': this.updater.revertArtifact(ua.dataset.id); break;
}

View File

@ -145,6 +145,7 @@ Object.assign(TasksManager.prototype, {
'system_health_heal': 'Fix System Issues', 'system_network_heal': 'Heal Network',
'updater_check': 'Check for Updates', 'updater_apply': 'Update',
'updater_apply_all': 'Update All', 'updater_rollback': 'Roll Back',
'updater_upgrade': 'Upgrade Version',
'artifact_apply': 'Apply Hotfix', 'artifact_revert': 'Revert Hotfix',
'app_add': 'Add App',
'setup-config': 'Apply Configuration',

View File

@ -328,6 +328,7 @@ Object.assign(TasksManager.prototype, {
'updater_apply': { icon: '⬆️', class: 'update' },
'updater_apply_all': { icon: '⬆️', class: 'update' },
'updater_rollback': { icon: '↩️', class: 'restore' },
'updater_upgrade': { icon: '⏫', class: 'update' },
'artifact_apply': { icon: '⚡', class: 'update' },
'artifact_revert': { icon: '↩️', class: 'restore' },
'custom': { icon: '⚙️', class: 'custom' }

View File

@ -109,6 +109,7 @@ class UpdaterPage {
case 'update': this.applyUpdate(app); break;
case 'update-all': this.applyAll(); break;
case 'rollback': this.rollback(app); break;
case 'upgrade': this.upgrade(app, action.dataset.version); break;
case 'apply-artifact': this.applyArtifact(action.dataset.id); break;
case 'revert-artifact': this.revertArtifact(action.dataset.id); break;
case 'goto': this.switchTab(action.dataset.tab); break;
@ -301,6 +302,33 @@ class UpdaterPage {
if (!app) return;
this.dispatch('updater_rollback', { app }, `Rolling ${app} back to its pre-update snapshot…`);
}
// 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;
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';
const body = `
<div class="updater-detail-section">
<p><strong>${this.escape(app)}</strong> will move from <strong>${this.escape(from)}</strong>
to <strong>${this.escape(to)}</strong>, one release at a time.</p>
<p class="updater-detail-meta">Every step takes its own recovery snapshot first, then waits for
${this.escape(app)} to confirm it is serving that version with no migration outstanding.
If any step fails it is rolled back and the upgrade stops there, leaving the app on the last
version that verified.</p>
<p class="updater-detail-meta">This can take a long time each release runs its own database
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…`);
if (window.showConfirmation) {
window.showConfirmation(`Upgrade ${app} to ${to}?`, '', go, 'Start upgrade', 'Cancel', 'warning', false, '', body);
} else if (window.confirm(`Upgrade ${app} from ${from} to ${to}, one release at a time?`)) {
go();
}
}
applyArtifact(id) {
if (!id) return;
this.dispatch('artifact_apply', { id }, `Applying hotfix ${id} (a snapshot is taken first)…`);
@ -555,7 +583,8 @@ class UpdaterPage {
// current on the version you track — while still saying a newer one
// 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; to move, set <strong>Version</strong> on this app's Advanced config tab after checking the release notes.</span></div>`
? `<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>`
: '';
versionSection = `<div class="updater-detail-section"><h4>Version</h4>
<div class="updater-detail-row">${badge} <span class="updater-row-ver">${cur}${avail ? ` <span class="updater-arrow">→</span> <strong>${avail}</strong>` : ''}</span></div>

View File

@ -328,6 +328,18 @@ async configUpdate(changes) {
return await this.executeTask('updater_apply', app, `libreportal updater apply ${app}`, `Update ${app}`);
} catch (error) { throw new Error(`Failed to update ${app}: ${error.message}`); }
}
// Stepped, cross-version upgrade. Separate from updaterApply because the CLI
// verb is separate: apply moves within a release line, upgrade walks between
// lines one version at a time, snapshotting and verifying every step. Passing
// no version means "climb to the newest".
async updaterUpgrade(app, version) {
if (!app) throw new Error('No app specified');
try {
const v = (version || '').toString().trim();
const cmd = v ? `libreportal updater upgrade ${app} ${v}` : `libreportal updater upgrade ${app}`;
return await this.executeTask('updater_upgrade', app, cmd, `Upgrade ${app}${v ? ` to ${v}` : ''}`);
} catch (error) { throw new Error(`Failed to upgrade ${app}: ${error.message}`); }
}
async updaterApplyAll(apps) {
try {
const list = (apps || '').toString();

View File

@ -87,6 +87,8 @@ class TaskRouter {
return await this.actions.updaterApplyAll(params.apps);
case 'updater_rollback':
return await this.actions.updaterRollback(params.app);
case 'updater_upgrade':
return await this.actions.updaterUpgrade(params.app, params.version);
// Hotfix channel (components/updater "Improvements") — apply/revert one
// signed hotfix via the locked-down `libreportal artifact` CLI as a task.