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 @@
+
+
+
+
+
+
+
+ No backups from other hosts visible in any enabled location.
+ Add a shared backup location on both hosts to enable cross-host restore.
+
+
+
+
+
+
+
+
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) => `
+
+
+ ${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;