Merge claude/1

This commit is contained in:
librelad 2026-06-01 10:24:54 +01:00
commit 0c52abde78
6 changed files with 424 additions and 1 deletions

View File

@ -305,6 +305,10 @@
<span class="tab-emoji">💾</span>
<span class="tab-name">Backups</span>
</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 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="improvements" id="ov-pane-improvements"></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>

View File

@ -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;
},
});

View File

@ -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 {

View File

@ -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 = '<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>';
}
}

View File

@ -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>&times;</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>

View File

@ -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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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;