feat(webui): add 'Migrate' tab — restore an app from another LibrePortal
Phase 1 of the migration-system refresh. Surfaces Phase 0's kernel
(libreportal restore migrate ...) as a WebUI flow so users don't have
to drop to the CLI to pull an app from a peer's backups.
backend / data generator:
scripts/webui/data/generators/backup/webui_backup_migrate.sh
Walks every enabled backup location, lists every (other_host, app)
pair with snapshot count + latest id/date, and emits a single
destination summary block (installed apps, running apps, disk free)
so the frontend can compute collisions and warnings without per-row
API round-trips. Filters out our own hostname — we don't migrate to
ourselves. Output: data/backup/generated/migrate.json.
Hooked into the standard webuiLibrePortalUpdate refresh pipeline,
so 'libreportal regen webui' (and the periodic task-processor poll)
keep it fresh on their own.
frontend:
- New 'Migrate' sidebar tab on /backup, sits between Locations and
Configuration. Path-based URL: /backup/migrate.
- Per-source-host cards listing every available app, with snapshot
count + relative-time hint, collision dot when the app is already
installed here, and per-app + per-host migrate buttons.
- Confirm modal with two checkboxes matching the kernel's defaults:
[✓] Back up the destination's existing copy first (pre-migrate
backup; auto-disabled when there's nothing to back up)
[✓] Rewrite host-bound URLs to this host (URL rewrite
— uncheck only to keep source hostnames)
On confirm, runs 'libreportal restore migrate app/system …' via the
task system; opt-out checkboxes append --no-pre-backup / --keep-urls
only when the user un-ticks, matching the kernel's default-on flags.
- Empty state when no other hosts have visible backups, explaining
the shared-backup-location prerequisite.
The CLI dispatcher hooks (Phase 0) wire restore migrate app/system to
migrateApplyApp/migrateApplySystem, so the WebUI gets pre-backup safety,
URL rewrite, and structured progress (when --json-progress is set; not
needed here yet — the task system's log tail is enough for v1).
Signed-off-by: librelad <librelad@digitalangels.vip>
This commit is contained in:
parent
83d2535c4b
commit
52e4280a67
@ -27,6 +27,14 @@
|
||||
</svg>
|
||||
Locations
|
||||
</div>
|
||||
<div class="category" data-backup-tab="migrate">
|
||||
<svg class="category-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 12h18"></path>
|
||||
<polyline points="12 5 19 12 12 19"></polyline>
|
||||
<path d="M3 5v14"></path>
|
||||
</svg>
|
||||
Migrate
|
||||
</div>
|
||||
<div class="category" data-backup-tab="configuration">
|
||||
<svg class="category-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
@ -143,6 +151,28 @@
|
||||
<div class="backup-location-list" id="backup-location-list"></div>
|
||||
</section>
|
||||
|
||||
<section class="backup-tabpanel" id="backup-panel-migrate">
|
||||
<div class="backup-card backup-migrate-card">
|
||||
<div class="backup-card-header">
|
||||
<h2>Restore an app from another LibrePortal</h2>
|
||||
<span class="backup-card-hint">
|
||||
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.
|
||||
</span>
|
||||
</div>
|
||||
<div class="backup-migrate-empty" id="backup-migrate-empty" hidden>
|
||||
<p>No backups from other hosts found in any enabled location.</p>
|
||||
<p class="backup-card-hint">
|
||||
Either no other LibrePortal has backed up to a location this host can see,
|
||||
or this is the only host using its locations. Add a shared backup location
|
||||
on both hosts to enable cross-host migrate.
|
||||
</p>
|
||||
</div>
|
||||
<div class="backup-migrate-body" id="backup-migrate-body"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="backup-tabpanel" id="backup-panel-configuration">
|
||||
<div id="backup-configuration-body"></div>
|
||||
</section>
|
||||
@ -180,6 +210,20 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="backup-modal" id="backup-migrate-modal">
|
||||
<div class="backup-modal-inner">
|
||||
<div class="backup-modal-header">
|
||||
<h3>Migrate from another LibrePortal</h3>
|
||||
<button class="backup-modal-close" data-close-modal>×</button>
|
||||
</div>
|
||||
<div class="backup-modal-body" id="backup-migrate-modal-body"></div>
|
||||
<div class="backup-modal-footer">
|
||||
<button class="backup-secondary-btn" data-close-modal>Cancel</button>
|
||||
<button class="backup-primary-btn" id="backup-migrate-confirm">Start migrate</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="backup-modal" id="backup-engine-modal">
|
||||
<div class="backup-modal-inner backup-modal-wide">
|
||||
<div class="backup-modal-header">
|
||||
|
||||
@ -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/<tab> (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 {
|
||||
'<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">' +
|
||||
'<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>',
|
||||
migrate:
|
||||
'<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' +
|
||||
'<path d="M3 12h18"></path>' +
|
||||
'<polyline points="12 5 19 12 12 19"></polyline>' +
|
||||
'<path d="M3 5v14"></path></svg>',
|
||||
configuration:
|
||||
'<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">' +
|
||||
'<circle cx="12" cy="12" r="3"></circle>' +
|
||||
@ -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 => `
|
||||
<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 => `
|
||||
<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>
|
||||
<strong style="font-size:1.05em">${this.escape(host.hostname)}</strong>
|
||||
<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)}">
|
||||
Migrate 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)}">
|
||||
Migrate
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
`).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'
|
||||
? `<p>Migrate <strong>${this.escape(app)}</strong> from <strong>${this.escape(host)}</strong> via <strong>${this.escape(locName)}</strong> onto this host.</p>`
|
||||
: `<p>Migrate <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="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="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('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');
|
||||
|
||||
@ -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"
|
||||
|
||||
134
scripts/webui/data/generators/backup/webui_backup_migrate.sh
Normal file
134
scripts/webui/data/generators/backup/webui_backup_migrate.sh
Normal file
@ -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" <<EOF
|
||||
{
|
||||
"generated_at": "$generated_at",
|
||||
"destination": $destination_json,
|
||||
"locations": $locations_json
|
||||
}
|
||||
EOF
|
||||
runFileOp mv "$temp_file" "$output_file"
|
||||
runFileOp chmod 644 "$output_file" 2>/dev/null || true
|
||||
}
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user