diff --git a/containers/libreportal/frontend/html/backup-content.html b/containers/libreportal/frontend/html/backup-content.html index 1cb4d7b..16aa404 100644 --- a/containers/libreportal/frontend/html/backup-content.html +++ b/containers/libreportal/frontend/html/backup-content.html @@ -27,6 +27,14 @@ Locations +
+ + + + + + Migrate +
@@ -143,6 +151,28 @@
+
+
+
+

Restore an app from another LibrePortal

+ + Pulls a snapshot taken on another host out of a shared backup location and + lays it down here. The destination's existing copy of the app is snapshotted + first (rollback safety), then replaced. + +
+ +
+
+
+
@@ -180,6 +210,20 @@
+
+
+
+

Migrate from another LibrePortal

+ +
+
+ +
+
+
diff --git a/containers/libreportal/frontend/js/components/backup/backup-page.js b/containers/libreportal/frontend/js/components/backup/backup-page.js index bb9c13e..a901950 100644 --- a/containers/libreportal/frontend/js/components/backup/backup-page.js +++ b/containers/libreportal/frontend/js/components/backup/backup-page.js @@ -116,7 +116,7 @@ class BackupPage { and /backup?backup=dashboard (standard query string) so links from either source resolve correctly. */ parseTabFromUrl() { - const allowed = new Set(['dashboard', 'backups', 'locations', 'configuration']); + const allowed = new Set(['dashboard', 'backups', 'locations', 'migrate', 'configuration']); // Path-based: /backup/ (bare /backup → default tab). const seg = window.location.pathname.replace(/^\/backup\/?/, '').split('/')[0]; if (seg && allowed.has(seg)) return seg; @@ -253,6 +253,26 @@ class BackupPage { return; } + const migrateAppBtn = e.target.closest('[data-action="migrate-app"]'); + if (migrateAppBtn) { + this.openMigrateModal({ + mode: 'app', + locIdx: parseInt(migrateAppBtn.dataset.loc, 10), + host: migrateAppBtn.dataset.host, + app: migrateAppBtn.dataset.app + }); + return; + } + const migrateHostBtn = e.target.closest('[data-action="migrate-host"]'); + if (migrateHostBtn) { + this.openMigrateModal({ + mode: 'host', + locIdx: parseInt(migrateHostBtn.dataset.loc, 10), + host: migrateHostBtn.dataset.host + }); + return; + } + if (e.target.closest('#backup-migrate-confirm')) { this.confirmMigrate(); return; } if (e.target.closest('#backup-restore-confirm')) { this.confirmRestore(); return; } if (e.target.closest('#backup-delete-confirm')) { this.confirmDelete(); return; } if (e.target.closest('#backup-add-location-confirm')) { this.confirmAddLocation(); return; } @@ -311,15 +331,17 @@ class BackupPage { async refreshAll() { const ts = Date.now(); - const [dashboard, locations, , schema] = await Promise.all([ + const [dashboard, locations, , schema, migrate] = await Promise.all([ this.fetchJson(`/data/backup/generated/dashboard.json?t=${ts}`), this.fetchJson(`/data/backup/generated/locations.json?t=${ts}`), this.loadSystemConfigs(), - this.fetchJson(`/data/backup/generated/schema.json?t=${ts}`) + this.fetchJson(`/data/backup/generated/schema.json?t=${ts}`), + this.fetchJson(`/data/backup/generated/migrate.json?t=${ts}`) ]); this.dashboard = dashboard; this.locations = locations; this.locSchema = schema; + this.migrate = migrate; this.snapshotsByLoc = {}; if (!this.engines.length) await this.loadEngines(); @@ -415,6 +437,7 @@ class BackupPage { dashboard: 'Dashboard', backups: 'Backups', locations: 'Locations', + migrate: 'Migrate', configuration: 'Configuration' }[tab] || 'Backups'; } @@ -424,6 +447,7 @@ class BackupPage { dashboard: 'Per-app status and storage at a glance.', backups: 'Every snapshot across every enabled location.', locations: 'Where backups are stored. Add, edit, or remove destinations.', + migrate: 'Restore an app from another LibrePortal that shares one of your backup locations.', configuration: 'Schedule, retention, and engine settings.' }[tab] || ''; } @@ -445,6 +469,11 @@ class BackupPage { '' + '' + '', + migrate: + '' + + '' + + '' + + '', configuration: '' + '' + @@ -520,6 +549,7 @@ class BackupPage { this.renderDashboard(); this.renderLocations(); this.renderSnapshots(); + this.renderMigrate(); this.renderConfiguration(); } @@ -1753,6 +1783,185 @@ class BackupPage { await this.runTask(`libreportal restore system`, 'restore', null); } + /* ----- Migrate (Phase 1: shared-backup) ----- */ + + renderMigrate() { + const body = document.getElementById('backup-migrate-body'); + const empty = document.getElementById('backup-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; + + // Group: one card per source-host, with that host's apps listed underneath. + // We collapse across locations — if the same host appears in two locations, + // we still show it once with the union of apps (the per-app row carries + // which location it came from). Most setups have one shared location anyway. + 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 => ` +
+
+
+ ${this.escape(host.hostname)} + ${(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; + } + + formatRelativeTime(iso) { + if (!iso) return 'never'; + const t = Date.parse(iso); + if (!t) return iso; + const diff = Date.now() - t; + const minute = 60_000, 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); + } + + openMigrateModal({ mode, locIdx, host, app }) { + const modal = document.getElementById('backup-migrate-modal'); + const body = document.getElementById('backup-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); + + // App-mode: one specific app. Host-mode: every app from the host. + 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' + ? `

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

` + : `

Migrate 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('backup-migrate-modal'); + if (!modal) return; + const { mode, locIdx, host, app } = modal.dataset; + const preBackup = document.getElementById('migrate-opt-pre-backup')?.checked; + const rewrite = document.getElementById('migrate-opt-rewrite-urls')?.checked; + + // The bash CLI defaults are: pre-backup ON, URL rewrite ON. Opt-out flags + // only get appended when the user un-ticks; matches the kernel's defaults. + const opts = []; + if (preBackup === false) opts.push('--no-pre-backup'); + if (rewrite === false) opts.push('--keep-urls'); + const optStr = opts.length ? ' ' + opts.join(' ') : ''; + + this.closeAllModals(); + 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); + } + } + async runTask(command, type, app) { if (!this.taskManager) { this.notify('Task system unavailable', 'error'); diff --git a/scripts/source/files/arrays/files_webui.sh b/scripts/source/files/arrays/files_webui.sh index 8ec9064..96153d9 100755 --- a/scripts/source/files/arrays/files_webui.sh +++ b/scripts/source/files/arrays/files_webui.sh @@ -14,6 +14,7 @@ webui_scripts=( "webui/data/generators/backup/webui_backup_dashboard.sh" "webui/data/generators/backup/webui_backup_engines.sh" "webui/data/generators/backup/webui_backup_locations.sh" + "webui/data/generators/backup/webui_backup_migrate.sh" "webui/data/generators/backup/webui_backup_passwords.sh" "webui/data/generators/backup/webui_backup_schema.sh" "webui/data/generators/backup/webui_backup_snapshots.sh" diff --git a/scripts/webui/data/generators/backup/webui_backup_migrate.sh b/scripts/webui/data/generators/backup/webui_backup_migrate.sh new file mode 100644 index 0000000..86f3368 --- /dev/null +++ b/scripts/webui/data/generators/backup/webui_backup_migrate.sh @@ -0,0 +1,134 @@ +#!/bin/bash + +# Generate data/backup/generated/migrate.json — drives the WebUI's "Migrate" +# tab. One pass per enabled backup location: list every (source_host, app) pair +# with snapshot count + latest id/date, plus a single destination summary so +# the frontend can compute collisions and disk warnings without a per-row API +# round-trip. +# +# Triggered from webuiLibrePortalUpdate (the regen-webui pipeline). Safe to +# call ad-hoc — pure read-only restic snapshot queries; no live state changes. + +webuiGenerateBackupMigrate() +{ + local output_dir="$containers_dir/libreportal/frontend/data/backup/generated" + local output_file="$output_dir/migrate.json" + local temp_file="${output_file}.tmp.$$" + + runFileOp mkdir -p "$output_dir" + + local generated_at + generated_at=$(date -Iseconds) + + # --- Destination summary ------------------------------------------------- + local this_host="${CFG_INSTALL_NAME:-$(hostname)}" + + local disk_free_kb + disk_free_kb=$(df -Pk "$containers_dir" 2>/dev/null | awk 'NR==2 {print $4}') + [[ -z "$disk_free_kb" ]] && disk_free_kb=0 + + local installed_apps_json="[" installed_first=true + local running_apps_json="[]" + local running_raw="" + if command -v docker >/dev/null 2>&1; then + running_raw=$(dockerCommandRun "docker ps --format '{{.Names}}' 2>/dev/null" 2>/dev/null) + fi + local running_first=true + running_apps_json="[" + local app_dir app_slug + for app_dir in "$containers_dir"*/; do + [[ -d "$app_dir" ]] || continue + app_slug=$(basename "$app_dir") + [[ -f "${app_dir}docker-compose.yml" || -f "${app_dir}compose.yml" ]] || continue + $installed_first || installed_apps_json+="," + installed_first=false + installed_apps_json+="\"$app_slug\"" + if echo "$running_raw" | grep -qx "$app_slug"; then + $running_first || running_apps_json+="," + running_first=false + running_apps_json+="\"$app_slug\"" + fi + done + installed_apps_json+="]" + running_apps_json+="]" + + local destination_json + destination_json="{\"hostname\":\"$this_host\",\"disk_free_kb\":$disk_free_kb,\"installed_apps\":$installed_apps_json,\"running_apps\":$running_apps_json}" + + # --- Per-location host/app inventory ------------------------------------- + local locations_json="[" + local loc_first=true + local idx + while IFS= read -r idx; do + [[ -z "$idx" ]] && continue + + local loc_name loc_uri + loc_name=$(resticLocationName "$idx" 2>/dev/null) + loc_uri=$(resticLocationUri "$idx" 2>/dev/null) + local loc_name_esc loc_uri_esc + loc_name_esc=$(printf '%s' "$loc_name" | sed 's/\\/\\\\/g; s/"/\\"/g') + loc_uri_esc=$(printf '%s' "$loc_uri" | sed 's/\\/\\\\/g; s/"/\\"/g') + + # All snapshots in this location, one pass. Then group in shell. + local all_json + all_json=$(engineSnapshotsJson "$idx" 2>/dev/null) + + local hosts_json="[" + local host_first=true + local host + # Distinct hostnames present. + while IFS= read -r host; do + [[ -z "$host" ]] && continue + # Skip our own hostname — we don't migrate to ourselves. + [[ "$host" == "$this_host" ]] && continue + + $host_first || hosts_json+="," + host_first=false + + local apps_json="[" + local app_first=true + local app + while IFS= read -r app; do + [[ -z "$app" ]] && continue + local app_snaps_json + app_snaps_json=$(engineSnapshotsJson "$idx" "$app" "$host" 2>/dev/null) + local count + count=$(printf '%s' "$app_snaps_json" | grep -oc '"short_id":"' || echo 0) + (( count == 0 )) && continue + + local latest_id latest_date + latest_id=$(printf '%s' "$app_snaps_json" | grep -o '"short_id":"[^"]*"' | tail -1 | cut -d'"' -f4) + latest_date=$(printf '%s' "$app_snaps_json" | grep -o '"time":"[^"]*"' | tail -1 | cut -d'"' -f4) + + local opt_out + opt_out=$(migrateUrlRewriteEnabled "$app" 2>/dev/null) + local opt_out_bool="false" + [[ "$opt_out" == "false" ]] && opt_out_bool="true" + + $app_first || apps_json+="," + app_first=false + apps_json+="{\"slug\":\"$app\",\"snapshots\":$count,\"latest_id\":\"$latest_id\",\"latest_date\":\"$latest_date\",\"url_rewrite_opt_out\":$opt_out_bool}" + done < <(printf '%s' "$all_json" | grep -o '"app=[^"]*"' | sort -u | sed 's/"app=\(.*\)"/\1/') + apps_json+="]" + + hosts_json+="{\"hostname\":\"$host\",\"apps\":$apps_json}" + done < <(printf '%s' "$all_json" | grep -o '"hostname":"[^"]*"' | sort -u | cut -d'"' -f4) + hosts_json+="]" + + $loc_first || locations_json+="," + loc_first=false + locations_json+="{\"idx\":$idx,\"name\":\"$loc_name_esc\",\"uri\":\"$loc_uri_esc\",\"hosts\":$hosts_json}" + done < <(resticEnabledLocations) + locations_json+="]" + + # --- Write atomically ---------------------------------------------------- + cat > "$temp_file" </dev/null || true +} diff --git a/scripts/webui/webui_updater.sh b/scripts/webui/webui_updater.sh index 6bc62a9..27b8135 100755 --- a/scripts/webui/webui_updater.sh +++ b/scripts/webui/webui_updater.sh @@ -86,7 +86,7 @@ webuiLibrePortalUpdate() { done # Generate Backup locations / snapshots / engines / dashboards - local result=$(webuiGenerateBackupLocations && webuiGenerateBackupDashboard && webuiGenerateBackupSnapshots all && webuiGenerateBackupAppStatus && webuiGenerateBackupEngines && webuiGenerateBackupSchema && webuiGenerateBackupPasswords) + local result=$(webuiGenerateBackupLocations && webuiGenerateBackupDashboard && webuiGenerateBackupSnapshots all && webuiGenerateBackupAppStatus && webuiGenerateBackupEngines && webuiGenerateBackupSchema && webuiGenerateBackupPasswords && webuiGenerateBackupMigrate) checkSuccess "Refreshed backup dashboard data..." # SSH access snapshot (authorized keys + password-login state)