feat(webui): add Migrate fleet tab (Restore + Peers sub-tabs)
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 <noreply@anthropic.com>
This commit is contained in:
parent
a27df62bf3
commit
4a964c42a2
@ -305,6 +305,10 @@
|
|||||||
<span class="tab-emoji">💾</span>
|
<span class="tab-emoji">💾</span>
|
||||||
<span class="tab-name">Backups</span>
|
<span class="tab-name">Backups</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="main-tab-button" data-tab="migrate" onclick="if(window.overviewManager) window.overviewManager.switchTab('migrate')">
|
||||||
|
<span class="tab-emoji">🔀</span>
|
||||||
|
<span class="tab-name">Migrate</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tab-content">
|
<div class="tab-content">
|
||||||
@ -312,6 +316,23 @@
|
|||||||
<div class="tab-pane" data-tab="updates" id="ov-pane-updates"></div>
|
<div class="tab-pane" data-tab="updates" id="ov-pane-updates"></div>
|
||||||
<div class="tab-pane" data-tab="improvements" id="ov-pane-improvements"></div>
|
<div class="tab-pane" data-tab="improvements" id="ov-pane-improvements"></div>
|
||||||
<div class="tab-pane" data-tab="backups" id="ov-pane-backups"></div>
|
<div class="tab-pane" data-tab="backups" id="ov-pane-backups"></div>
|
||||||
|
<div class="tab-pane" data-tab="migrate" id="ov-pane-migrate">
|
||||||
|
<!-- Cross-host area: nested segmented sub-tabs (Config-tab design). -->
|
||||||
|
<div class="tabs-wrapper ov-subtabs">
|
||||||
|
<div class="tabs-list">
|
||||||
|
<button class="tab-button active" data-ov-subtab="restore" onclick="if(window.overviewManager) window.overviewManager.switchMigrateSub('restore')">
|
||||||
|
<span class="tab-emoji">♻️</span><span class="tab-name">Restore</span>
|
||||||
|
</button>
|
||||||
|
<button class="tab-button" data-ov-subtab="peers" onclick="if(window.overviewManager) window.overviewManager.switchMigrateSub('peers')">
|
||||||
|
<span class="tab-emoji">🛰️</span><span class="tab-name">Peers</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="tabs-content ov-subtabs-content">
|
||||||
|
<div class="tab-panel active" data-ov-subtab="restore" id="ov-migrate-restore"></div>
|
||||||
|
<div class="tab-panel" data-ov-subtab="peers" id="ov-migrate-peers"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -127,5 +127,10 @@ LP.features.register({
|
|||||||
// task-refresh registration on the way out (the "stacks on revisit" bug).
|
// task-refresh registration on the way out (the "stacks on revisit" bug).
|
||||||
try { window.overviewBackupPage && window.overviewBackupPage.dispose && window.overviewBackupPage.dispose(); } catch (_) {}
|
try { window.overviewBackupPage && window.overviewBackupPage.dispose && window.overviewBackupPage.dispose(); } catch (_) {}
|
||||||
window.overviewBackupPage = null;
|
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;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@ -183,6 +183,13 @@
|
|||||||
min-width: 0;
|
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 ----------------------------------------- */
|
/* ---- per-app Updates tab header ----------------------------------------- */
|
||||||
.app-updater-section { padding: 4px 0; }
|
.app-updater-section { padding: 4px 0; }
|
||||||
.app-updater-head {
|
.app-updater-head {
|
||||||
|
|||||||
@ -90,7 +90,7 @@ class OverviewManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
parseTabFromUrl() {
|
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];
|
const seg = window.location.pathname.replace(/^\/overview\/?/, '').split('/')[0];
|
||||||
return (seg && allowed.has(seg)) ? seg : null;
|
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.'],
|
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.'],
|
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.'],
|
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 t = titles[id] || titles.overview;
|
||||||
const titleEl = document.getElementById('overview-title');
|
const titleEl = document.getElementById('overview-title');
|
||||||
@ -158,6 +159,71 @@ class OverviewManager {
|
|||||||
}
|
}
|
||||||
case 'improvements': pane.innerHTML = this.renderImprovements(); break;
|
case 'improvements': pane.innerHTML = this.renderImprovements(); break;
|
||||||
case 'backups': this.mountBackupCenter(pane); 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 = '<div class="updater-empty">Loading…</div>';
|
||||||
|
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 = '<div class="updater-empty">Restore unavailable.</div>'; 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 = '<div class="updater-empty">Failed to load restore.</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = '<div class="updater-empty">Loading…</div>';
|
||||||
|
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 = '<div class="updater-empty">Peers unavailable.</div>'; 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 = '<div class="updater-empty">Failed to load peers.</div>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,44 @@
|
|||||||
|
<!-- Overview › Migrate › Restore — cross-host restore (moved out of the backup
|
||||||
|
center). Rendered by MigratePage into #ov-migrate-body. -->
|
||||||
|
<div class="backup-card backup-migrate-card">
|
||||||
|
<div class="backup-card-header">
|
||||||
|
<h2>Cross-host restore <span class="tooltip" title="Pulls a backup taken on another host out of a shared backup location and lays it down here. The destination's existing copy of the app is backed up first (rollback safety), then replaced." style="font-size:.75em;opacity:.7;cursor:help">ℹ️</span></h2>
|
||||||
|
<span class="backup-card-hint">Restore an app or whole host from another LibrePortal that shares one of your backup locations.</span>
|
||||||
|
</div>
|
||||||
|
<div class="backup-migrate-empty" id="ov-migrate-empty" hidden>
|
||||||
|
<div class="backup-empty-state" style="border: 1px solid var(--border-color, #2a2a2a); background: var(--surface-2, rgba(255,255,255,0.02)); border-radius: 10px; padding: 28px 24px; margin: 12px 0; text-align: center;">
|
||||||
|
<div style="margin-bottom: 6px; opacity: .7; display: flex; justify-content: center;">
|
||||||
|
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"></path>
|
||||||
|
<circle cx="12" cy="10" r="3"></circle>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div style="margin-bottom: 14px;">
|
||||||
|
No backups from other hosts visible in any enabled location.<br>
|
||||||
|
Add a <strong>shared backup location</strong> on both hosts to enable cross-host restore.
|
||||||
|
</div>
|
||||||
|
<button type="button" class="backup-primary-btn" data-action="go-to-locations">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"></path>
|
||||||
|
<circle cx="12" cy="10" r="3"></circle>
|
||||||
|
</svg>
|
||||||
|
Open Locations
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="backup-migrate-body" id="ov-migrate-body"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="backup-modal" id="ov-migrate-modal">
|
||||||
|
<div class="backup-modal-inner">
|
||||||
|
<div class="backup-modal-header">
|
||||||
|
<h3>Restore from another LibrePortal</h3>
|
||||||
|
<button class="backup-modal-close" data-ov-migrate-close>×</button>
|
||||||
|
</div>
|
||||||
|
<div class="backup-modal-body" id="ov-migrate-modal-body"></div>
|
||||||
|
<div class="backup-modal-footer">
|
||||||
|
<button class="backup-secondary-btn" data-ov-migrate-close>Cancel</button>
|
||||||
|
<button class="backup-primary-btn" id="ov-migrate-confirm">Start restore</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@ -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) => `
|
||||||
|
<div class="backup-migrate-location">
|
||||||
|
<div class="backup-card-header" style="margin-bottom:8px">
|
||||||
|
<h3 style="margin:0">${this.escape(loc.name || 'Location')}</h3>
|
||||||
|
<span class="backup-card-hint">${(loc.hosts || []).length} other host${loc.hosts.length === 1 ? '' : 's'} backing up here</span>
|
||||||
|
</div>
|
||||||
|
${loc.hosts.map((host) => {
|
||||||
|
const peerName = (this.hostnameToPeerName || {})[host.hostname];
|
||||||
|
const headerLabel = peerName
|
||||||
|
? `<strong style="font-size:1.05em">${this.escape(peerName)}</strong><span class="backup-card-hint" style="margin-left:6px; font-size:.85em">host: <code>${this.escape(host.hostname)}</code></span>`
|
||||||
|
: `<strong style="font-size:1.05em">${this.escape(host.hostname)}</strong>`;
|
||||||
|
return `
|
||||||
|
<div class="backup-migrate-host" style="border:1px solid var(--border-color, #2a2a2a); border-radius:8px; padding:14px; margin-bottom:12px">
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:center; gap:12px; margin-bottom:10px">
|
||||||
|
<div>
|
||||||
|
${headerLabel}
|
||||||
|
<span class="backup-card-hint" style="margin-left:10px">${(host.apps || []).length} app${host.apps.length === 1 ? '' : 's'} available</span>
|
||||||
|
</div>
|
||||||
|
<button class="backup-primary-btn" data-action="migrate-host" data-loc="${loc.idx}" data-host="${this.escape(host.hostname)}">
|
||||||
|
Restore every app from this host
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="backup-migrate-apps" style="display:grid; grid-template-columns:repeat(auto-fill, minmax(280px, 1fr)); gap:8px">
|
||||||
|
${(host.apps || []).map((app) => {
|
||||||
|
const collide = installed.has(app.slug);
|
||||||
|
return `
|
||||||
|
<div class="backup-migrate-app" style="display:flex; justify-content:space-between; align-items:center; padding:8px 12px; background:var(--surface-2, #1a1a1a); border-radius:6px">
|
||||||
|
<div style="display:flex; flex-direction:column; min-width:0">
|
||||||
|
<span style="display:flex; align-items:center; gap:8px">
|
||||||
|
<strong>${this.escape(app.slug)}</strong>
|
||||||
|
${collide ? `<span class="backup-status-dot warn" title="Already installed here"></span>` : ''}
|
||||||
|
</span>
|
||||||
|
<span class="backup-card-hint" style="font-size:.82em">
|
||||||
|
${app.snapshots} snapshot${app.snapshots === 1 ? '' : 's'} · latest ${this.escape(this.formatRelativeTime(app.latest_date))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button class="backup-secondary-btn" data-action="migrate-app" data-loc="${loc.idx}" data-host="${this.escape(host.hostname)}" data-app="${this.escape(app.slug)}">
|
||||||
|
Restore
|
||||||
|
</button>
|
||||||
|
</div>`;
|
||||||
|
}).join('')}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('')}
|
||||||
|
</div>`).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'
|
||||||
|
? `<p>Restore <strong>${this.escape(app)}</strong> from <strong>${this.escape(host)}</strong> via <strong>${this.escape(locName)}</strong> onto this host.</p>`
|
||||||
|
: `<p>Restore <strong>every app</strong> (${targetApps.length}) from <strong>${this.escape(host)}</strong> via <strong>${this.escape(locName)}</strong> onto this host.</p>`;
|
||||||
|
|
||||||
|
let collisionNote = '';
|
||||||
|
if (collisions.length) {
|
||||||
|
collisionNote = `
|
||||||
|
<p class="backup-card-hint" style="color:var(--warning, #d97706); margin-top:8px">
|
||||||
|
⚠ Already installed here: ${collisions.map((c) => `<code>${this.escape(c)}</code>`).join(', ')}.
|
||||||
|
These will be <strong>replaced</strong>.
|
||||||
|
${collisionsRunning.length ? `Currently running: ${collisionsRunning.map((c) => `<code>${this.escape(c)}</code>`).join(', ')} — will be stopped first.` : ''}
|
||||||
|
</p>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.innerHTML = `
|
||||||
|
${intro}
|
||||||
|
${collisionNote}
|
||||||
|
<div style="margin-top:14px; display:flex; flex-direction:column; gap:8px">
|
||||||
|
<label style="display:flex; align-items:flex-start; gap:8px; cursor:pointer">
|
||||||
|
<input type="checkbox" id="ov-migrate-opt-pre-backup" ${collisions.length ? 'checked' : 'disabled'}>
|
||||||
|
<span>
|
||||||
|
Back up the destination's existing copy first
|
||||||
|
<span class="backup-card-hint" style="display:block; font-size:.85em">
|
||||||
|
Safety net: snapshot the current ${mode === 'app' ? this.escape(app) : 'app'} into your first
|
||||||
|
enabled backup location (tagged <code>pre-migrate</code>) before wipe.
|
||||||
|
${collisions.length ? '' : 'No collision — nothing to back up.'}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label style="display:flex; align-items:flex-start; gap:8px; cursor:pointer">
|
||||||
|
<input type="checkbox" id="ov-migrate-opt-rewrite-urls" checked>
|
||||||
|
<span>
|
||||||
|
Rewrite host-bound URLs to this host
|
||||||
|
<span class="backup-card-hint" style="display:block; font-size:.85em">
|
||||||
|
Replaces <code>CFG_*_URL</code>, <code>*_DOMAIN</code>, <code>*_HOSTNAME</code> with this
|
||||||
|
host's values. Uncheck only if you want the moved app to keep claiming the source's hostname.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>`;
|
||||||
|
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;
|
||||||
Loading…
x
Reference in New Issue
Block a user