From 4a964c42a235c52d82cba0e2881ce4b9b92d8592 Mon Sep 17 00:00:00 2001 From: librelad Date: Mon, 1 Jun 2026 10:24:54 +0100 Subject: [PATCH] feat(webui): add Migrate fleet tab (Restore + Peers sub-tabs) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New 5th Overview tab 'Migrate' with a nested segmented sub-tab row reusing the per-app Config-tab .tabs-list/.tab-button design: - Restore: a standalone MigratePage (cross-host migrate moved out of BackupPage into its own controller + fragment + modal; own data fetch + task dispatch). - Peers: reuses the existing PeersPage (container-parameterized) + its template. Both lazy-loaded on first open and disposed on apps-feature unmount. Additive — migrate is still in the backup center and Peers still in Admin until the next commits remove the duplicates. Co-Authored-By: Claude Opus 4.8 --- .../apps/core/html/apps-unified-layout.html | 21 ++ .../frontend/components/apps/index.js | 5 + .../components/apps/overview/css/overview.css | 7 + .../apps/overview/js/overview-manager.js | 68 ++++- .../migrate/html/migrate-content.html | 44 +++ .../apps/overview/migrate/js/migrate-page.js | 280 ++++++++++++++++++ 6 files changed, 424 insertions(+), 1 deletion(-) create mode 100644 containers/libreportal/frontend/components/apps/overview/migrate/html/migrate-content.html create mode 100644 containers/libreportal/frontend/components/apps/overview/migrate/js/migrate-page.js diff --git a/containers/libreportal/frontend/components/apps/core/html/apps-unified-layout.html b/containers/libreportal/frontend/components/apps/core/html/apps-unified-layout.html index 91832f3..824647a 100755 --- a/containers/libreportal/frontend/components/apps/core/html/apps-unified-layout.html +++ b/containers/libreportal/frontend/components/apps/core/html/apps-unified-layout.html @@ -305,6 +305,10 @@ 💾 Backups +
@@ -312,6 +316,23 @@
+
+ +
+
+ + +
+
+
+
+
+
+
diff --git a/containers/libreportal/frontend/components/apps/index.js b/containers/libreportal/frontend/components/apps/index.js index 2dd20e6..0adb413 100644 --- a/containers/libreportal/frontend/components/apps/index.js +++ b/containers/libreportal/frontend/components/apps/index.js @@ -127,5 +127,10 @@ LP.features.register({ // task-refresh registration on the way out (the "stacks on revisit" bug). try { window.overviewBackupPage && window.overviewBackupPage.dispose && window.overviewBackupPage.dispose(); } catch (_) {} window.overviewBackupPage = null; + // Migrate tab sub-controllers (Restore + Peers) — release their listeners. + try { window.migratePage && window.migratePage.dispose && window.migratePage.dispose(); } catch (_) {} + window.migratePage = null; + try { window.peersPage && window.peersPage.dispose && window.peersPage.dispose(); } catch (_) {} + window.peersPage = null; }, }); diff --git a/containers/libreportal/frontend/components/apps/overview/css/overview.css b/containers/libreportal/frontend/components/apps/overview/css/overview.css index 64c5144..92318e1 100644 --- a/containers/libreportal/frontend/components/apps/overview/css/overview.css +++ b/containers/libreportal/frontend/components/apps/overview/css/overview.css @@ -183,6 +183,13 @@ min-width: 0; } +/* ---- Migrate tab: nested segmented sub-tabs (per-app Config-tab design) -- */ +/* .tabs-wrapper/.tabs-list/.tab-button come from the global base.css; only the + panel show/hide is scoped here. */ +#overview-view .ov-subtabs { margin-bottom: 16px; } +#overview-view .ov-subtabs-content .tab-panel { display: none; } +#overview-view .ov-subtabs-content .tab-panel.active { display: block; } + /* ---- per-app Updates tab header ----------------------------------------- */ .app-updater-section { padding: 4px 0; } .app-updater-head { diff --git a/containers/libreportal/frontend/components/apps/overview/js/overview-manager.js b/containers/libreportal/frontend/components/apps/overview/js/overview-manager.js index 4c49cf3..3c1d65b 100644 --- a/containers/libreportal/frontend/components/apps/overview/js/overview-manager.js +++ b/containers/libreportal/frontend/components/apps/overview/js/overview-manager.js @@ -90,7 +90,7 @@ class OverviewManager { } parseTabFromUrl() { - const allowed = new Set(['overview', 'updates', 'improvements', 'backups']); + const allowed = new Set(['overview', 'updates', 'improvements', 'backups', 'migrate']); const seg = window.location.pathname.replace(/^\/overview\/?/, '').split('/')[0]; return (seg && allowed.has(seg)) ? seg : null; } @@ -132,6 +132,7 @@ class OverviewManager { updates: ['Updates', 'Available versions per app — expand a row for CVEs, recovery, and history. Every update is snapshotted first.'], improvements: ['Improvements', 'Signed, individually-reversible hotfixes from the LibrePortal team — applied with a snapshot first.'], backups: ['Backups', 'Backup health across your apps. Open an app for snapshots and restore.'], + migrate: ['Migrate', 'Move apps in from another LibrePortal, and manage the peers you share backup locations with.'], }; const t = titles[id] || titles.overview; const titleEl = document.getElementById('overview-title'); @@ -158,6 +159,71 @@ class OverviewManager { } case 'improvements': pane.innerHTML = this.renderImprovements(); break; case 'backups': this.mountBackupCenter(pane); break; + case 'migrate': this.mountMigrate(); break; + } + } + + // ---- Migrate tab: Restore (standalone MigratePage) + Peers (PeersPage) ---- + + mountMigrate() { + this.switchMigrateSub(this._migrateSub || 'restore'); + } + + switchMigrateSub(sub) { + this._migrateSub = sub; + const pane = document.getElementById('ov-pane-migrate'); + if (!pane) return; + pane.querySelectorAll('.ov-subtabs .tab-button[data-ov-subtab]').forEach((b) => b.classList.toggle('active', b.dataset.ovSubtab === sub)); + pane.querySelectorAll('.ov-subtabs-content .tab-panel[data-ov-subtab]').forEach((p) => p.classList.toggle('active', p.dataset.ovSubtab === sub)); + if (sub === 'restore') this._mountRestore(); + else if (sub === 'peers') this._mountPeers(); + } + + async _mountRestore() { + const panel = document.getElementById('ov-migrate-restore'); + if (!panel) return; + if (panel.dataset.mounted && window.migratePage) { + try { window.migratePage.refreshAll().then(() => window.migratePage.render()).catch(() => {}); } catch (_) {} + return; + } + panel.innerHTML = '
Loading…
'; + try { + if (typeof MigratePage === 'undefined' && window.spaClean && window.spaClean.loadScript) { + await window.spaClean.loadScript('/components/apps/overview/migrate/js/migrate-page.js'); + } + const html = await fetch('/components/apps/overview/migrate/html/migrate-content.html', { cache: 'no-store' }).then((r) => r.text()); + panel.innerHTML = html; + if (typeof MigratePage === 'undefined') { panel.innerHTML = '
Restore unavailable.
'; return; } + try { if (window.migratePage) window.migratePage.dispose(); } catch (_) {} + window.migratePage = new MigratePage('ov-migrate-restore'); + await window.migratePage.init(); + panel.dataset.mounted = '1'; + } catch (_) { + panel.innerHTML = '
Failed to load restore.
'; + } + } + + async _mountPeers() { + const panel = document.getElementById('ov-migrate-peers'); + if (!panel) return; + if (panel.dataset.mounted && window.peersPage) { + try { window.peersPage.refreshAll().then(() => window.peersPage.render()).catch(() => {}); } catch (_) {} + return; + } + panel.innerHTML = '
Loading…
'; + try { + if (typeof PeersPage === 'undefined' && window.spaClean && window.spaClean.loadScript) { + await window.spaClean.loadScript('/components/admin/peers/js/peers-page.js'); + } + const html = await fetch('/components/admin/peers/html/peers-content.html', { cache: 'no-store' }).then((r) => r.text()); + panel.innerHTML = html; + if (typeof PeersPage === 'undefined') { panel.innerHTML = '
Peers unavailable.
'; return; } + try { if (window.peersPage && window.peersPage.dispose) window.peersPage.dispose(); } catch (_) {} + window.peersPage = new PeersPage('ov-migrate-peers'); + await window.peersPage.init(); + panel.dataset.mounted = '1'; + } catch (_) { + panel.innerHTML = '
Failed to load peers.
'; } } diff --git a/containers/libreportal/frontend/components/apps/overview/migrate/html/migrate-content.html b/containers/libreportal/frontend/components/apps/overview/migrate/html/migrate-content.html new file mode 100644 index 0000000..82affab --- /dev/null +++ b/containers/libreportal/frontend/components/apps/overview/migrate/html/migrate-content.html @@ -0,0 +1,44 @@ + +
+
+

Cross-host restore ℹ️

+ Restore an app or whole host from another LibrePortal that shares one of your backup locations. +
+ +
+
+ +
+
+
+

Restore from another LibrePortal

+ +
+
+ +
+
diff --git a/containers/libreportal/frontend/components/apps/overview/migrate/js/migrate-page.js b/containers/libreportal/frontend/components/apps/overview/migrate/js/migrate-page.js new file mode 100644 index 0000000..5d2804b --- /dev/null +++ b/containers/libreportal/frontend/components/apps/overview/migrate/js/migrate-page.js @@ -0,0 +1,280 @@ +// components/apps/overview/migrate/js/migrate-page.js — cross-host Restore. +// +// Moved out of BackupPage (was its "Migrate" tab) into a standalone controller +// so it can live as Overview › Migrate › Restore. Pulls a backup taken on another +// LibrePortal out of a shared backup location and lays it down here: the +// destination's existing copy is snapshotted first (rollback safety), then +// replaced. Self-contained — own data fetch, modal, and task dispatch +// (libreportal restore migrate …). Renders into the container id it's given. +class MigratePage { + constructor(rootId) { + this.rootId = rootId || 'ov-migrate-restore'; + this.migrate = null; + this.locations = null; + this.hostnameToPeerName = {}; + this.taskManager = (typeof TaskManager !== 'undefined') ? new TaskManager() : null; + this._ac = new AbortController(); + this._bound = false; + } + + root() { return document.getElementById(this.rootId); } + + async init() { + this.bindEvents(); + await this.refreshAll(); + this.render(); + } + + async refreshAll() { + const ts = Date.now(); + const get = (u) => fetch(u, { cache: 'no-store' }).then((r) => (r.ok ? r.json() : null)).catch(() => null); + const [migrate, locations, peersData] = await Promise.all([ + get(`/data/backup/generated/migrate.json?t=${ts}`), + get(`/data/backup/generated/locations.json?t=${ts}`), + get(`/data/peers/generated/peers.json?t=${ts}`), + ]); + this.migrate = migrate; + this.locations = locations; + this.hostnameToPeerName = {}; + for (const p of (peersData?.peers || [])) { + if (p?.config?.hostname) this.hostnameToPeerName[p.config.hostname] = p.name; + } + } + + bindEvents() { + if (this._bound) return; + this._bound = true; + // Repaint when a restore/migrate task finishes (no live feed for these). + if (window.taskRefresh?.register) { + window.taskRefresh.register({ + id: 'ov-migrate', + match: (d) => d.action === 'restore' || /^libreportal\s+restore\b/.test((d.task && d.task.command) || ''), + run: () => { if (window.migratePage === this && this.root()) return this.refreshAll().then(() => this.render()); }, + debounceMs: 600, + }); + } + // One AbortController-scoped document listener (the pane is replaced on full + // navigation; dispose() aborts it on teardown). + document.addEventListener('click', (e) => { + const appBtn = e.target.closest('[data-action="migrate-app"]'); + if (appBtn && this.root()?.contains(appBtn)) { + this.openMigrateModal({ mode: 'app', locIdx: parseInt(appBtn.dataset.loc, 10), host: appBtn.dataset.host, app: appBtn.dataset.app }); + return; + } + const hostBtn = e.target.closest('[data-action="migrate-host"]'); + if (hostBtn && this.root()?.contains(hostBtn)) { + this.openMigrateModal({ mode: 'host', locIdx: parseInt(hostBtn.dataset.loc, 10), host: hostBtn.dataset.host }); + return; + } + const locBtn = e.target.closest('[data-action="go-to-locations"]'); + if (locBtn && this.root()?.contains(locBtn)) { + if (window.navigateToRoute) window.navigateToRoute('/overview/backups'); + return; + } + if (e.target.closest('#ov-migrate-confirm')) { this.confirmMigrate(); return; } + if (e.target.closest('[data-ov-migrate-close]')) { this.closeModal(); return; } + }, { signal: this._ac.signal }); + } + + dispose() { + try { this._ac.abort(); } catch (_) {} + try { window.taskRefresh?.unregister('ov-migrate'); } catch (_) {} + } + + // ---- helpers (self-contained copies) ------------------------------------- + + escape(s) { + return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); + } + + formatRelativeTime(iso) { + if (!iso) return 'never'; + const t = Date.parse(iso); + if (!t) return iso; + const diff = Date.now() - t; + const minute = 60000, hour = 60 * minute, day = 24 * hour; + if (diff < hour) return `${Math.max(1, Math.round(diff / minute))} min ago`; + if (diff < day) return `${Math.round(diff / hour)} h ago`; + if (diff < 7 * day) return `${Math.round(diff / day)} d ago`; + return new Date(t).toISOString().slice(0, 10); + } + + locName(idx) { + const l = (this.locations?.locations || []).find((x) => String(x.idx) === String(idx)); + return l ? l.name : ('location ' + idx); + } + + closeModal() { document.getElementById('ov-migrate-modal')?.classList.remove('open'); } + + notify(msg, type) { + const n = (window.LP && window.LP.services && window.LP.services.notify) || window.notify; + if (n && n.show) n.show(msg, type || 'info'); + } + + async runTask(command, type, app) { + if (!this.taskManager) { this.notify('Task system unavailable', 'error'); return; } + try { + await this.taskManager.createTask(command, type, app); + setTimeout(() => this.refreshAll().then(() => this.render()), 1500); + } catch (err) { + this.notify(`Failed to queue task: ${err.message || err}`, 'error'); + } + } + + // ---- rendering ----------------------------------------------------------- + + render() { + const body = document.getElementById('ov-migrate-body'); + const empty = document.getElementById('ov-migrate-empty'); + if (!body || !empty) return; + + const data = this.migrate || {}; + const locations = (data.locations || []).filter((l) => (l.hosts || []).length > 0); + if (!locations.length) { + body.innerHTML = ''; + empty.hidden = false; + return; + } + empty.hidden = true; + + const installed = new Set(data.destination?.installed_apps || []); + const html = locations.map((loc) => ` +
+
+

${this.escape(loc.name || 'Location')}

+ ${(loc.hosts || []).length} other host${loc.hosts.length === 1 ? '' : 's'} backing up here +
+ ${loc.hosts.map((host) => { + const peerName = (this.hostnameToPeerName || {})[host.hostname]; + const headerLabel = peerName + ? `${this.escape(peerName)}host: ${this.escape(host.hostname)}` + : `${this.escape(host.hostname)}`; + return ` +
+
+
+ ${headerLabel} + ${(host.apps || []).length} app${host.apps.length === 1 ? '' : 's'} available +
+ +
+
+ ${(host.apps || []).map((app) => { + const collide = installed.has(app.slug); + return ` +
+
+ + ${this.escape(app.slug)} + ${collide ? `` : ''} + + + ${app.snapshots} snapshot${app.snapshots === 1 ? '' : 's'} · latest ${this.escape(this.formatRelativeTime(app.latest_date))} + +
+ +
`; + }).join('')} +
+
`; + }).join('')} +
`).join(''); + body.innerHTML = html; + } + + // ---- modal --------------------------------------------------------------- + + openMigrateModal({ mode, locIdx, host, app }) { + const modal = document.getElementById('ov-migrate-modal'); + const body = document.getElementById('ov-migrate-modal-body'); + if (!modal || !body) return; + + const dest = this.migrate?.destination || {}; + const installed = new Set(dest.installed_apps || []); + const running = new Set(dest.running_apps || []); + const locName = this.locName(locIdx); + + let targetApps = []; + if (mode === 'app') { + targetApps = [app]; + } else { + const loc = (this.migrate?.locations || []).find((l) => l.idx === locIdx); + const h = (loc?.hosts || []).find((x) => x.hostname === host); + targetApps = (h?.apps || []).map((a) => a.slug); + } + const collisions = targetApps.filter((a) => installed.has(a)); + const collisionsRunning = collisions.filter((a) => running.has(a)); + + const intro = mode === 'app' + ? `

Restore ${this.escape(app)} from ${this.escape(host)} via ${this.escape(locName)} onto this host.

` + : `

Restore every app (${targetApps.length}) from ${this.escape(host)} via ${this.escape(locName)} onto this host.

`; + + let collisionNote = ''; + if (collisions.length) { + collisionNote = ` +

+ ⚠ Already installed here: ${collisions.map((c) => `${this.escape(c)}`).join(', ')}. + These will be replaced. + ${collisionsRunning.length ? `Currently running: ${collisionsRunning.map((c) => `${this.escape(c)}`).join(', ')} — will be stopped first.` : ''} +

`; + } + + body.innerHTML = ` + ${intro} + ${collisionNote} +
+ + +
`; + modal.dataset.mode = mode; + modal.dataset.locIdx = String(locIdx); + modal.dataset.host = host; + modal.dataset.app = app || ''; + modal.classList.add('open'); + } + + async confirmMigrate() { + const modal = document.getElementById('ov-migrate-modal'); + if (!modal) return; + const { mode, locIdx, host, app } = modal.dataset; + const preBackup = document.getElementById('ov-migrate-opt-pre-backup')?.checked; + const rewrite = document.getElementById('ov-migrate-opt-rewrite-urls')?.checked; + + const opts = []; + if (preBackup === false) opts.push('--no-pre-backup'); + if (rewrite === false) opts.push('--keep-urls'); + const optStr = opts.length ? ' ' + opts.join(' ') : ''; + + this.closeModal(); + if (mode === 'app') { + await this.runTask(`libreportal restore migrate app ${app} ${host} ${locIdx}${optStr}`, 'restore', app); + } else { + await this.runTask(`libreportal restore migrate system ${host} ${locIdx}${optStr}`, 'restore', null); + } + } +} + +window.MigratePage = MigratePage;