diff --git a/containers/libreportal/frontend/components/backup/index.js b/containers/libreportal/frontend/components/backup/index.js
index 4e2f88a..891b703 100644
--- a/containers/libreportal/frontend/components/backup/index.js
+++ b/containers/libreportal/frontend/components/backup/index.js
@@ -15,7 +15,20 @@ LP.features.register({
// Controllers the feature needs; lazy-loaded on first mount (idempotent).
scripts: [
'/components/backup/js/backup-schema.js',
- '/components/backup/js/backup-page.js',
+ '/components/backup/js/backup-page.js', // base: class + constructor + init/switchTab/render
+ // prototype-augment clusters (load after the base, order among them is free):
+ '/components/backup/js/backup-fetch-client.js',
+ '/components/backup/js/backup-dashboard.js',
+ '/components/backup/js/backup-snapshots.js',
+ '/components/backup/js/backup-locations.js',
+ '/components/backup/js/backup-location-fields.js',
+ '/components/backup/js/backup-ssh-key.js',
+ '/components/backup/js/backup-retention-presets.js',
+ '/components/backup/js/backup-configuration.js',
+ '/components/backup/js/backup-engine-details.js',
+ '/components/backup/js/backup-location-modal.js',
+ '/components/backup/js/backup-snapshot-actions.js',
+ '/components/backup/js/backup-migrate.js',
'/components/backup/js/backup-cron-schedule.js',
'/core/lib/backup-app-card.js',
],
diff --git a/containers/libreportal/frontend/components/backup/js/backup-configuration.js b/containers/libreportal/frontend/components/backup/js/backup-configuration.js
new file mode 100644
index 0000000..0d03f3d
--- /dev/null
+++ b/containers/libreportal/frontend/components/backup/js/backup-configuration.js
@@ -0,0 +1,120 @@
+// Auto-extracted from backup-page.js (verbatim) — augments BackupPage.prototype. Loaded after the base.
+Object.assign(BackupPage.prototype, {
+ renderConfiguration() {
+ const body = document.getElementById('backup-configuration-body');
+ if (!body) return;
+
+ // Dismissed state is persisted server-side via Dismissible
+ // (data/ui-state.json), so it follows the user across browsers/devices.
+ // Banner + its divider are omitted entirely once dismissed.
+ const warningDismissed = !!window.Dismissible?.isDismissed('backup-config-warning');
+ const warningHTML = warningDismissed ? '' : `
+
+
+
+
+
+
+
+ Keep your LibrePortal config backed up offline.
+ Repository passwords live inside the config directory. Without that backup, the others cannot be decrypted by anyone — including you.
+
+
+
+
+
+
+
+
+
+ `;
+
+ body.innerHTML = `
+ ${warningHTML}
+
+ `;
+
+ this.invokeConfigManager();
+ },
+ async invokeConfigManager(attempt = 0) {
+ if (window.configManager && typeof window.configManager.renderConfig === 'function') {
+ try {
+ await window.configManager.renderConfig('backup');
+ this.enhanceConfigurationWithPresets();
+ } catch (err) {
+ console.error('Backup configuration render failed:', err);
+ }
+ return;
+ }
+ if (attempt >= 20) {
+ const sec = document.getElementById('config-section');
+ if (sec) sec.innerHTML = `Configuration system not loaded. Try refreshing the page.
`;
+ return;
+ }
+ setTimeout(() => this.invokeConfigManager(attempt + 1), 150);
+ },
+ async exportRepositoryPasswords(triggerBtn) {
+ const restoreBtn = () => {
+ if (triggerBtn) {
+ triggerBtn.disabled = false;
+ triggerBtn.dataset.busy = '';
+ }
+ };
+ if (triggerBtn) {
+ triggerBtn.disabled = true;
+ triggerBtn.dataset.busy = '1';
+ }
+
+ try {
+ const task = await this.taskManager?.createTask(
+ 'libreportal webui generate backup',
+ 'webui',
+ null
+ );
+ if (task?.id) {
+ await this.waitForTask(task.id, 20000);
+ }
+ const res = await fetch(`/data/backup/generated/passwords.txt?t=${Date.now()}`, {
+ credentials: 'same-origin'
+ });
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ const text = await res.text();
+ if (!text || !text.includes('CFG_BACKUP_LOC_')) {
+ throw new Error('Password file is empty — no locations configured?');
+ }
+ const blob = new Blob([text], { type: 'text/plain;charset=utf-8' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ const host = (window.systemConfigs?.CFG_INSTALL_NAME || 'libreportal').replace(/[^a-z0-9_-]/gi, '_');
+ const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-');
+ a.href = url;
+ a.download = `libreportal-backup-passwords-${host}-${stamp}.txt`;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+ this.notify('Password export downloaded — store it offline.', 'success');
+ } catch (err) {
+ this.notify(`Export failed: ${err.message || err}`, 'error');
+ } finally {
+ restoreBtn();
+ }
+ },
+ waitForTask(taskId, timeoutMs = 15000) {
+ return new Promise((resolve) => {
+ let done = false;
+ const finish = () => {
+ if (done) return;
+ done = true;
+ window.removeEventListener('taskCompleted', onComplete);
+ clearTimeout(timer);
+ resolve();
+ };
+ const onComplete = (e) => {
+ if (e?.detail?.taskId === taskId) finish();
+ };
+ window.addEventListener('taskCompleted', onComplete);
+ const timer = setTimeout(finish, timeoutMs);
+ });
+ },
+});
diff --git a/containers/libreportal/frontend/components/backup/js/backup-dashboard.js b/containers/libreportal/frontend/components/backup/js/backup-dashboard.js
new file mode 100644
index 0000000..aeb18b6
--- /dev/null
+++ b/containers/libreportal/frontend/components/backup/js/backup-dashboard.js
@@ -0,0 +1,126 @@
+// Auto-extracted from backup-page.js (verbatim) — augments BackupPage.prototype. Loaded after the base.
+Object.assign(BackupPage.prototype, {
+ renderDashboard() {
+ const summary = document.getElementById('backup-summary-row');
+ const appGrid = document.getElementById('backup-app-grid');
+ const locSummary = document.getElementById('backup-repo-list-summary');
+ if (!summary || !appGrid || !locSummary) return;
+
+ const d = this.dashboard || {};
+ const locs = d.locations || [];
+ const apps = d.apps || [];
+ const totalSnapshots = Object.values(this.snapshotsByLoc).reduce((acc, r) => {
+ return acc + (Array.isArray(r?.snapshots) ? r.snapshots.length : 0);
+ }, 0);
+ const protectedApps = apps.filter(a => a.latest_snapshot).length;
+ const totalSize = locs.reduce((acc, r) => acc + (parseInt(r.total_size_bytes) || 0), 0);
+
+ summary.innerHTML = `
+ ${this.tile('Apps protected', `${protectedApps} / ${apps.length}`, 'with at least one backup')}
+ ${this.tile('Backups', `${totalSnapshots}`, `across ${locs.length} location${locs.length === 1 ? '' : 's'}`)}
+ ${this.tile('Total stored', this.formatBytes(totalSize), 'deduplicated, encrypted')}
+ `;
+
+ // Next-run hint in the "Backup status" card header — derived from
+ // CFG_BACKUP_CRONTAB_APP (the cron expression the app-backup
+ // scheduler uses). Pure client-side computation; no backend
+ // surface needed.
+ const nextRunEl = document.getElementById('backup-next-run');
+ if (nextRunEl) {
+ const cron = (window.systemConfigs?.CFG_BACKUP_CRONTAB_APP || '').trim();
+ const next = cron ? this.nextCronFireTime(cron) : null;
+ if (next) {
+ nextRunEl.textContent = `Next backup ${this.formatRelativeFuture(next)} · ${this.formatScheduleClock(next)}`;
+ nextRunEl.title = `Next scheduled backup: ${next.toLocaleString()}\nSchedule: ${cron}`;
+ } else if (cron) {
+ nextRunEl.textContent = `Schedule: ${cron}`;
+ nextRunEl.title = `Couldn't parse the schedule "${cron}" to compute the next run.`;
+ } else {
+ nextRunEl.textContent = 'No schedule set';
+ nextRunEl.title = 'CFG_BACKUP_CRONTAB_APP is empty — backups only run when triggered manually.';
+ }
+ }
+
+ // System config tile is rendered FIRST so the bare-metal-restore
+ // prerequisite is always at eye-level — without it, the user's
+ // backups exist but the credentials needed to reach them don't.
+ const systemTileHtml = this.renderSystemTile(d.system || {});
+ if (!apps.length) {
+ appGrid.innerHTML = systemTileHtml + `No apps installed yet.
`;
+ } else {
+ appGrid.innerHTML = systemTileHtml + apps.map(app => this.renderAppTile(app)).join('');
+ }
+
+ if (!locs.length) {
+ locSummary.innerHTML = `No locations enabled.
`;
+ } else {
+ locSummary.innerHTML = locs.map(r => `
+
+
+ ${this.escape(r.type)}
+ ${this.escape(r.name)}
+
+
+ ${this.formatBytes(parseInt(r.total_size_bytes) || 0)}
+ ${r.total_files || 0} files
+
+
+ `).join('');
+ }
+ },
+ // System config tile — same shape as an app tile but with the LibrePortal
+ // app icon. Clicking any tile (system or app) opens the Back-up checklist
+ // modal with that tile pre-ticked; there are no inline action buttons
+ // anymore. Rendered first in the Backup status grid so the bare-metal
+ // prerequisite is always visible up top.
+ renderSystemTile(sys) {
+ const has = !!sys.latest_snapshot;
+ const dot = has ? 'ok' : 'none';
+ const when = has ? 'Last backed up ' + this.formatRelative(sys.latest_time) : 'No backup yet';
+ return `
+
+
+
+
Configs
+
+
+ ${this.escape(when)}
+
+
+
+ Back up
+
+
+ `;
+ },
+ tile(label, value, detail) {
+ return `
+
+
${this.escape(label)}
+
${this.escape(value)}
+
${this.escape(detail || '')}
+
+ `;
+ },
+ renderAppTile(app) {
+ const has = !!app.latest_snapshot;
+ const dot = has ? 'ok' : 'none';
+ const when = has ? this.formatRelative(app.latest_time) : 'No backup yet';
+ const { icon, displayName } = this.appMeta(app.app);
+ return `
+
+
+
+
${this.escape(displayName)}
+
+
+ ${when}
+
+
+
+ Back up
+
+
+ `;
+ },
+});
diff --git a/containers/libreportal/frontend/components/backup/js/backup-engine-details.js b/containers/libreportal/frontend/components/backup/js/backup-engine-details.js
new file mode 100644
index 0000000..bc60b75
--- /dev/null
+++ b/containers/libreportal/frontend/components/backup/js/backup-engine-details.js
@@ -0,0 +1,84 @@
+// Auto-extracted from backup-page.js (verbatim) — augments BackupPage.prototype. Loaded after the base.
+Object.assign(BackupPage.prototype, {
+ enhanceEngineDetailsButton() {
+ const selector = '[name="CFG_BACKUP_ENGINE"], [name^="CFG_BACKUP_LOC_"][name$="_ENGINE"]';
+ document.querySelectorAll(`#config-section ${selector}, .backup-location-details ${selector}`).forEach((engineInput) => {
+ const customSelect = engineInput.closest('.custom-select');
+ const wrapTarget = customSelect || engineInput;
+ const group = wrapTarget.closest('.field-group') || wrapTarget.parentElement;
+ if (!group || group.dataset.engineDetailsBound === '1') return;
+ group.dataset.engineDetailsBound = '1';
+
+ const btn = document.createElement('button');
+ btn.type = 'button';
+ btn.className = 'backup-secondary-btn backup-engine-details-btn';
+ btn.dataset.action = 'open-engine-details';
+ btn.innerHTML = `
+
+
+
+
+
+ Details
+ `;
+
+ const wrap = document.createElement('div');
+ wrap.className = 'backup-engine-input-row';
+ wrapTarget.parentNode.insertBefore(wrap, wrapTarget);
+ wrap.appendChild(wrapTarget);
+ wrap.appendChild(btn);
+ });
+ },
+ async openEngineDetailsModal(triggerEl) {
+ const modal = document.getElementById('backup-engine-modal');
+ const body = document.getElementById('backup-engine-modal-body');
+ const title = document.getElementById('backup-engine-modal-title');
+ if (!modal || !body) return;
+
+ // Find the engine select adjacent to the Details button that fired
+ // this event so per-location Details work even when the user has
+ // changed the select but not saved yet.
+ let engineId = (window.systemConfigs?.CFG_BACKUP_ENGINE || 'restic').trim();
+ const row = triggerEl?.closest('.backup-engine-input-row');
+ const sel = row?.querySelector('select, input');
+ if (sel && sel.value) engineId = sel.value.trim();
+ body.innerHTML = `Loading engine details…
`;
+ modal.classList.add('open');
+
+ const data = await this.fetchJson(`/data/backup/generated/engines/${encodeURIComponent(engineId)}.json?t=${Date.now()}`);
+ if (!data) {
+ body.innerHTML = `
+
+ No details file for engine "${this.escape(engineId)} ".
+ Add scripts/backup/engines/${this.escape(engineId)}.json and run the WebUI regen.
+
+ `;
+ return;
+ }
+
+ if (title) title.textContent = `Backup engine: ${data.name || engineId}`;
+ const propsHTML = (data.properties || []).map(p =>
+ `${this.escape(p.label)} ${this.escape(p.value)} `
+ ).join('');
+ const featsHTML = (data.features || []).map(f => `${this.escape(f)} `).join('');
+ const docsHTML = data.docs_url
+ ? `${this.escape(data.docs_url)} ↗ `
+ : '';
+ const logoHTML = data.logo
+ ? ` `
+ : '';
+
+ body.innerHTML = `
+
+ ${logoHTML}
+
+
${this.escape(data.name || engineId)}
+
${this.escape(data.tagline || '')}
+
+
+ ${propsHTML ? `` : ''}
+ ${featsHTML ? `Highlights ` : ''}
+ ${docsHTML ? `Documentation ${docsHTML}
` : ''}
+ `;
+ },
+});
diff --git a/containers/libreportal/frontend/components/backup/js/backup-fetch-client.js b/containers/libreportal/frontend/components/backup/js/backup-fetch-client.js
new file mode 100644
index 0000000..f27c56b
--- /dev/null
+++ b/containers/libreportal/frontend/components/backup/js/backup-fetch-client.js
@@ -0,0 +1,79 @@
+// Auto-extracted from backup-page.js (verbatim) — augments BackupPage.prototype. Loaded after the base.
+Object.assign(BackupPage.prototype, {
+ async refreshAll() {
+ const ts = Date.now();
+ const [dashboard, locations, , schema, migrate, peersData] = 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/migrate.json?t=${ts}`),
+ this.fetchJson(`/data/peers/generated/peers.json?t=${ts}`)
+ ]);
+ this.dashboard = dashboard;
+ this.locations = locations;
+ this.locSchema = schema;
+ this.migrate = migrate;
+ // Build hostname → friendly-name lookup once so renderMigrate can show
+ // "homelab (host: homelab.lan)" instead of bare hostnames.
+ this.hostnameToPeerName = {};
+ for (const p of (peersData?.peers || [])) {
+ if (p.kind === 'backup-channel' && p.config?.hostname) {
+ this.hostnameToPeerName[p.config.hostname] = p.name;
+ }
+ }
+ this.snapshotsByLoc = {};
+
+ if (!this.engines.length) await this.loadEngines();
+
+ if (locations?.locations?.length) {
+ const enabled = locations.locations.filter(l => l.enabled);
+ await Promise.all(enabled.map(async (l) => {
+ const s = await this.fetchJson(`/data/backup/generated/snapshots_${l.idx}.json?t=${ts}`);
+ if (s) this.snapshotsByLoc[l.idx] = s;
+ }));
+ }
+ },
+ async fetchJson(url) {
+ try { const r = await fetch(url); if (!r.ok) return null; return await r.json(); }
+ catch { return null; }
+ },
+ async loadSystemConfigs() {
+ const data = await this.fetchJson(`/data/config/generated/configs.json?t=${Date.now()}`);
+ if (!data) return;
+ window.configData = data;
+ const flat = {};
+ for (const [k, v] of Object.entries(data.config || {})) flat[k] = v?.value ?? '';
+ window.systemConfigs = flat;
+ },
+ async loadEngines() {
+ const ts = Date.now();
+ const index = await this.fetchJson(`/data/backup/generated/engines/index.json?t=${ts}`);
+ const ids = index?.engines || [];
+ const metas = await Promise.all(ids.map(id =>
+ this.fetchJson(`/data/backup/generated/engines/${encodeURIComponent(id)}.json?t=${ts}`)
+ ));
+ this.engines = metas.filter(Boolean);
+ // Fallback so the dropdown never collapses to empty if the regen
+ // hasn't run yet — restic is always assumed available.
+ if (!this.engines.length) {
+ this.engines = [{ id: 'restic', name: 'Restic', supported_types: ['local','sftp','rest','s3','b2','gs','azure','rclone'] }];
+ }
+ },
+ engineDisplayName(id) {
+ if (!id) return 'Restic';
+ const match = (this.engines || []).find(e => e.id === id);
+ return match?.name || id;
+ },
+ enginesForType(type) {
+ if (!type) return this.engines;
+ return this.engines.filter(e =>
+ !Array.isArray(e.supported_types) ||
+ e.supported_types.includes(type)
+ );
+ },
+ async reloadAfterSave() {
+ await this.refreshAll();
+ this.render();
+ },
+});
diff --git a/containers/libreportal/frontend/components/backup/js/backup-location-fields.js b/containers/libreportal/frontend/components/backup/js/backup-location-fields.js
new file mode 100644
index 0000000..3b50549
--- /dev/null
+++ b/containers/libreportal/frontend/components/backup/js/backup-location-fields.js
@@ -0,0 +1,162 @@
+// Auto-extracted from backup-page.js (verbatim) — augments BackupPage.prototype. Loaded after the base.
+Object.assign(BackupPage.prototype, {
+ renderLocFields(idx, suffixes, loc) {
+ if (typeof ConfigShared === 'undefined' || !ConfigShared.generateField) {
+ return `Configuration system not loaded.
`;
+ }
+ const locValueLookup = {
+ NAME: loc.name, ENABLED: loc.enabled ? 'true' : 'false', TYPE: loc.type,
+ ENGINE: loc.engine || 'restic',
+ PATH_MODE: loc.path_mode || 'custom',
+ PATH: loc.path, URI: loc.uri, SSH_USER: loc.ssh_user, SSH_HOST: loc.ssh_host,
+ SSH_PORT: loc.ssh_port, SSH_PATH: loc.ssh_path,
+ SSH_AUTH: loc.ssh_auth || 'key', SSH_PASS: '',
+ S3_ACCESS_KEY: '', S3_SECRET_KEY: '',
+ B2_ACCOUNT_ID: '', B2_ACCOUNT_KEY: '',
+ APPEND_ONLY: loc.append_only ? 'true' : 'false',
+ CUSTOM_RETENTION: loc.custom_retention ? 'true' : 'false',
+ KEEP_LAST: loc.keep_last, KEEP_DAILY: loc.keep_daily,
+ KEEP_WEEKLY: loc.keep_weekly, KEEP_MONTHLY: loc.keep_monthly,
+ KEEP_YEARLY: loc.keep_yearly
+ };
+
+ // Field metadata comes from configs.json (window.configData) via
+ // locFieldMeta; the basic/advanced split is decided by the caller, which
+ // renders each group into its own tab (Connection vs Advanced).
+ let html = '';
+ for (const suffix of suffixes) {
+ const m = this.locFieldMeta(idx, suffix);
+ if (!m.exists) continue;
+ const value = (locValueLookup[suffix] ?? '').toString();
+ html += ConfigShared.generateField(`config-${m.key}`, m.key, value, m.title, m.description, {}, {});
+ }
+ html += '
';
+ return html;
+ },
+ locFieldMeta(idx, suffix) {
+ const key = `CFG_BACKUP_LOC_${idx}_${suffix}`;
+ const cfg = window.configData?.config?.[key] || {};
+ const def = BACKUP_LOC_FIELD_DEFS[suffix] || {};
+ return {
+ key,
+ exists: !!(cfg.title || cfg.description || BACKUP_LOC_FIELD_DEFS[suffix]),
+ title: cfg.title || def.title || suffix,
+ description: cfg.description ?? def.description ?? '',
+ advanced: LOC_ADVANCED_SUFFIXES.has(suffix) || cfg.advanced === true
+ };
+ },
+ locFieldsForType(type) {
+ return this.locSchema?.types?.[type]
+ || BACKUP_LOC_FIELDS_BY_TYPE[type]
+ || BACKUP_LOC_FIELDS_BY_TYPE.local;
+ },
+ locFieldGroups(idx, type) {
+ const suffixes = this.locFieldsForType(type);
+ const connection = [];
+ const advanced = [];
+ for (const suffix of suffixes) {
+ const m = this.locFieldMeta(idx, suffix);
+ if (!m.exists) continue;
+ (m.advanced ? advanced : connection).push(suffix);
+ }
+ return { connection, advanced };
+ },
+ renderConnectionInner(idx, type, loc, connectionSuffixes) {
+ let html = this.renderLocFields(idx, connectionSuffixes, loc);
+ if (type === 'sftp') html += this.renderBackupSshKeyCard(loc);
+ return html;
+ },
+ formRetention(prefix, values, includeInherit = false) {
+ const preset = backupRetentionDetectPreset(values, includeInherit);
+ const meta = BACKUP_RETENTION_PRESET_META[preset];
+ const presetOptions = this.retentionPresetOptions(preset, includeInherit);
+
+ const customRetentionHidden = includeInherit
+ ? ` `
+ : '';
+
+ return `
+
+
+ Backup style ℹ️
+ ${presetOptions}
+
+ ${customRetentionHidden}
+
+
+
+ ${this.formInput(`${prefix}KEEP_LAST`, 'Keep last', values.last, 'number', '', 'backups')}
+ ${this.formInput(`${prefix}KEEP_DAILY`, 'Keep daily', values.daily, 'number', '', 'days')}
+ ${this.formInput(`${prefix}KEEP_WEEKLY`, 'Keep weekly', values.weekly, 'number', '', 'weeks')}
+ ${this.formInput(`${prefix}KEEP_MONTHLY`, 'Keep monthly', values.monthly, 'number', '', 'months')}
+ ${this.formInput(`${prefix}KEEP_YEARLY`, 'Keep yearly', values.yearly, 'number', '', 'years')}
+
+
+ `;
+ },
+ formInput(name, label, value, type = 'text', placeholder = '', unit = '') {
+ const escVal = this.escape(value ?? '');
+ const escPh = this.escape(placeholder);
+ const escLabel = this.escape(label);
+ const inputHTML = ` `;
+ const wrapped = unit ? `${inputHTML}${this.escape(unit)}
` : inputHTML;
+ return `
+
+ ${escLabel}
+ ${wrapped}
+
+ `;
+ },
+ formSelect(name, label, value, options) {
+ const escLabel = this.escape(label);
+ const opts = options.map(([v, lbl]) => `${this.escape(lbl)} `).join('');
+ return `
+
+ ${escLabel}
+ ${opts}
+
+ `;
+ },
+ formToggle(name, label, checked) {
+ const escLabel = this.escape(label);
+ return `
+
+ ${escLabel}
+
+
+
+
+
+ `;
+ },
+ formCrontab(name, label, value) {
+ if (typeof ConfigShared === 'undefined' || !ConfigShared.createCrontabField) {
+ return this.formInput(name, label, value, 'text', 'minute hour day month weekday');
+ }
+ const fieldId = `config-${name}`;
+ let cronHtml = ConfigShared.createCrontabField(fieldId, name, value, label, '');
+ cronHtml = cronHtml.replace(`name="${name}"`, `name="${name}" data-backup-field`);
+ return `
+
+ ${this.escape(label)}
+ ${cronHtml}
+
+ `;
+ },
+ formReadOnly(label, value) {
+ return `
+
+ ${this.escape(label)}
+ ${this.escape(value)}
+
+ `;
+ },
+ tagFieldsForSave(container) {
+ container.querySelectorAll('input[name], select[name], textarea[name]').forEach(el => {
+ if (!el.hasAttribute('data-backup-field')) {
+ el.setAttribute('data-backup-field', '');
+ if (el.type === 'checkbox') el.setAttribute('data-backup-bool', '');
+ }
+ });
+ },
+});
diff --git a/containers/libreportal/frontend/components/backup/js/backup-location-modal.js b/containers/libreportal/frontend/components/backup/js/backup-location-modal.js
new file mode 100644
index 0000000..d46a724
--- /dev/null
+++ b/containers/libreportal/frontend/components/backup/js/backup-location-modal.js
@@ -0,0 +1,125 @@
+// Auto-extracted from backup-page.js (verbatim) — augments BackupPage.prototype. Loaded after the base.
+Object.assign(BackupPage.prototype, {
+ openLocationModal_unused(idx) {
+ const loc = (this.locations?.locations || []).find(l => l.idx === idx);
+ if (!loc) return;
+
+ const modal = document.getElementById('backup-location-modal');
+ const body = document.getElementById('backup-location-modal-body');
+ const title = document.getElementById('backup-location-modal-title');
+ if (!modal || !body) return;
+
+ modal.dataset.locIdx = idx;
+ title.textContent = `Edit location: ${loc.name}`;
+
+ body.innerHTML = `
+
+
+
Retention
+
When to delete old backups from this location.
+
+
+ `;
+
+ this.refreshLocationModalTypeFields(loc.type, loc);
+ this.refreshLocationModalRetention(loc.custom_retention);
+
+ modal.classList.add('open');
+ },
+ refreshLocationModalTypeFields(type, locOverride) {
+ const container = document.getElementById('backup-location-connection');
+ const modal = document.getElementById('backup-location-modal');
+ if (!container || !modal) return;
+ const idx = parseInt(modal.dataset.locIdx, 10);
+ const loc = locOverride || (this.locations?.locations || []).find(l => l.idx === idx) || {};
+
+ const suffixes = this.locFieldsForType(type);
+ container.innerHTML = this.renderLocFields(idx, suffixes, loc);
+ this.tagFieldsForSave(container);
+ },
+ refreshLocationModalRetention(enabled) {
+ const container = document.getElementById('backup-location-retention');
+ const modal = document.getElementById('backup-location-modal');
+ if (!container || !modal) return;
+ const idx = parseInt(modal.dataset.locIdx, 10);
+ const loc = (this.locations?.locations || []).find(l => l.idx === idx) || {};
+
+ // The "Use custom retention" toggle itself stays at the top regardless.
+ const toggleField = this.renderLocFields(idx, ['CUSTOM_RETENTION'], loc);
+
+ if (!enabled) {
+ container.innerHTML = `
+ ${toggleField}
+ Inherits the global retention policy from the Configuration tab.
+ `;
+ this.tagFieldsForSave(container);
+ return;
+ }
+
+ const values = {
+ last: loc.keep_last || '',
+ daily: loc.keep_daily || '',
+ weekly: loc.keep_weekly || '',
+ monthly: loc.keep_monthly || '',
+ yearly: loc.keep_yearly || ''
+ };
+ container.innerHTML = `
+ ${toggleField}
+ ${this.formRetention(`CFG_BACKUP_LOC_${idx}_`, values)}
+ `;
+ this.tagFieldsForSave(container);
+ },
+ async saveLocationModal() {
+ const modal = document.getElementById('backup-location-modal');
+ if (!modal) return;
+ const idx = parseInt(modal.dataset.locIdx, 10);
+ this.closeAllModals();
+ await this.saveSection(`location-${idx}`);
+ },
+ async deleteLocationModal() {
+ const modal = document.getElementById('backup-location-modal');
+ if (!modal) return;
+ const idx = parseInt(modal.dataset.locIdx, 10);
+ const loc = (this.locations?.locations || []).find(l => l.idx === idx);
+ const name = loc?.name || `Location ${idx}`;
+ if (!confirm(`Delete location "${name}"?\n\nBackup data already stored at this location is not deleted by this action — only LibrePortal's reference to it. The password file on disk also stays in place (rename it manually if you want to start fresh).`)) return;
+ this.closeAllModals();
+ await this.runTask(`libreportal backup location remove ${idx}`, 'backup', null);
+ setTimeout(() => this.reloadAfterSave(), 2000);
+ },
+ openAddLocationModal() {
+ const modal = document.getElementById('backup-add-location-modal');
+ const body = document.getElementById('backup-add-location-modal-body');
+ if (!modal || !body) return;
+ body.innerHTML = `
+
+ ${this.formSelect('__add_type', 'Type', 'local', [
+ ['local', 'Local / mounted path'],
+ ['sftp', 'SFTP'],
+ ['rest', 'REST server'],
+ ['s3', 'S3'],
+ ['b2', 'Backblaze B2'],
+ ['gs', 'Google Cloud Storage'],
+ ['azure', 'Azure'],
+ ['rclone', 'rclone']
+ ])}
+ ${this.formInput('__add_name', 'Friendly name', '', 'text', 'e.g. Office NAS')}
+
+ The location starts disabled — fill in its connection details on the next screen, then toggle Enabled.
+ `;
+ modal.classList.add('open');
+ },
+ async confirmAddLocation() {
+ const modal = document.getElementById('backup-add-location-modal');
+ if (!modal) return;
+ const name = modal.querySelector('[name="__add_name"]')?.value?.trim();
+ const type = modal.querySelector('[name="__add_type"]')?.value || 'local';
+ if (!name) { this.notify('Name is required.', 'error'); return; }
+ this.closeAllModals();
+ const safeName = name.replace(/'/g, "'\\''");
+ await this.runTask(`libreportal backup location add '${safeName}' ${type}`, 'backup', null);
+ setTimeout(() => this.reloadAfterSave(), 2000);
+ },
+});
diff --git a/containers/libreportal/frontend/components/backup/js/backup-locations.js b/containers/libreportal/frontend/components/backup/js/backup-locations.js
new file mode 100644
index 0000000..4284c7d
--- /dev/null
+++ b/containers/libreportal/frontend/components/backup/js/backup-locations.js
@@ -0,0 +1,283 @@
+// Auto-extracted from backup-page.js (verbatim) — augments BackupPage.prototype. Loaded after the base.
+Object.assign(BackupPage.prototype, {
+ renderLocations() {
+ const list = document.getElementById('backup-location-list');
+ const repoSelect = document.getElementById('backup-snapshot-repo');
+ if (!list) return;
+
+ const locs = this.locations?.locations || [];
+ if (!locs.length) {
+ list.innerHTML = `
+
+ No backup locations configured yet.
+ Click Add location above to create one.
+
+ `;
+ } else {
+ list.innerHTML = locs.map(l => this.renderLocationRow(l)).join('');
+ }
+
+ if (repoSelect) {
+ const cur = repoSelect.value;
+ repoSelect.innerHTML = `All locations ` +
+ locs.filter(l => l.enabled).map(l => `${this.escape(l.name)} `).join('');
+ if (cur) repoSelect.value = cur;
+ }
+ },
+ renderLocationRow(l) {
+ // Status pill mirrors task-status: ✅ Ready / ⏳ Initialising / ⏸ Disabled.
+ const statusKind = l.enabled && l.password_exists ? 'ready'
+ : l.enabled && !l.password_exists ? 'init'
+ : 'disabled';
+ const statusMeta = {
+ ready: { icon: '✅', label: 'Ready' },
+ init: { icon: '⏳', label: 'Initialising' },
+ disabled: { icon: '⏸', label: 'Disabled' }
+ }[statusKind];
+ const snapCount = this.snapshotsByLoc[l.idx]?.snapshots?.length ?? 0;
+ const expanded = this.expandedLocs.has(l.idx);
+ const size = this.formatBytes(parseInt(l.total_size_bytes) || 0);
+ return `
+
+
+
+ ${expanded ? this.renderLocationDetailsBody(l) : ''}
+
+
+ `;
+ },
+ typeIcon(type) {
+ const local = `
+
+
+
+ `;
+ const cloud = `
+
+ `;
+ return type === 'local' ? local : cloud;
+ },
+ renderLocationDetailsBody(l) {
+ const idx = l.idx;
+ const groups = this.locFieldGroups(idx, l.type);
+ const retentionValues = {
+ last: l.custom_retention ? (l.keep_last || '') : '',
+ daily: l.custom_retention ? (l.keep_daily || '') : '',
+ weekly: l.custom_retention ? (l.keep_weekly || '') : '',
+ monthly: l.custom_retention ? (l.keep_monthly || '') : '',
+ yearly: l.custom_retention ? (l.keep_yearly || '') : ''
+ };
+
+ // Reuse the app-detail tab design (.tabs-wrapper/.tab-button/.tab-panel
+ // from style.css) so the Locations editor matches the rest of the UI.
+ const tab = (id, emoji, label) => `
+
+ ${emoji}
+ ${label}
+ `;
+
+ return `
+
+
+
+ ${tab('connection', '🔗', 'Connection')}
+ ${tab('retention', '♻️', 'Retention')}
+ ${tab('advanced', '⚙️', 'Advanced')}
+
+
+
+
+ ${this.renderConnectionInner(idx, l.type, l, groups.connection)}
+
+
+
+
+ ${this.formRetention(`CFG_BACKUP_LOC_${idx}_`, retentionValues, true)}
+
+
+
+
+ ${this.renderLocFields(idx, groups.advanced, l)}
+
+
+
+
+
+
+
+
+
+
+
+
+ Save changes
+
+
+
+
+
+
+
+
+ Delete location
+
+
+ `;
+ },
+ toggleLocationExpand(idx) {
+ const row = document.querySelector(`.backup-location-row[data-loc="${idx}"]`);
+ if (!row) return;
+ const details = row.querySelector('.task-details');
+ const header = row.querySelector('.task-header');
+ if (!details) return;
+
+ const willOpen = !this.expandedLocs.has(idx);
+ if (willOpen) {
+ this.expandedLocs.add(idx);
+ const loc = (this.locations?.locations || []).find(l => l.idx === idx);
+ if (loc) {
+ details.innerHTML = this.renderLocationDetailsBody(loc);
+ this.tagFieldsForSave(details);
+ this.filterEngineSelect(details, loc.type, loc.engine);
+ this.applySshAuthVisibility(details);
+ this.applyPathModeVisibility(details);
+ }
+ this.enhanceEngineDetailsButton();
+ details.classList.add('show');
+ row.classList.add('expanded');
+ if (header) header.setAttribute('aria-expanded', 'true');
+ } else {
+ this.expandedLocs.delete(idx);
+ details.classList.remove('show');
+ row.classList.remove('expanded');
+ if (header) header.setAttribute('aria-expanded', 'false');
+ }
+ },
+ refreshInlineTypeFields(idx, type) {
+ const loc = (this.locations?.locations || []).find(l => l.idx === idx) || {};
+ const groups = this.locFieldGroups(idx, type);
+
+ const conn = document.getElementById(`backup-location-${idx}-connection`);
+ if (conn) {
+ conn.innerHTML = this.renderConnectionInner(idx, type, { ...loc, type }, groups.connection);
+ this.tagFieldsForSave(conn);
+ }
+
+ // The Advanced tab's fields are type-dependent too (URI override only
+ // applies to some types), so rebuild it alongside the Connection tab.
+ const adv = document.getElementById(`backup-location-${idx}-advanced`);
+ if (adv) {
+ adv.innerHTML = this.renderLocFields(idx, groups.advanced, { ...loc, type });
+ this.tagFieldsForSave(adv);
+ }
+
+ // Re-apply dynamic behaviors across the whole details scope: the engine
+ // select lives in the Advanced tab while SSH-auth / path-mode live in
+ // Connection, so target the shared parent rather than one panel.
+ const scope = (conn || adv)?.closest('.task-details');
+ if (scope) {
+ this.filterEngineSelect(scope, type, loc.engine);
+ this.applySshAuthVisibility(scope);
+ this.applyPathModeVisibility(scope);
+ }
+ this.enhanceEngineDetailsButton();
+ },
+ applySshAuthVisibility(scope) {
+ const authSelect = scope.querySelector('select[name$="_SSH_AUTH"]');
+ if (!authSelect) return;
+ const isPassword = authSelect.value === 'password';
+ const passInput = scope.querySelector('input[name$="_SSH_PASS"]');
+ const passGroup = passInput?.closest('.field-group') || passInput?.closest('.password-mode-wrapper')?.parentElement;
+ if (passGroup) passGroup.style.display = isPassword ? '' : 'none';
+ // SSH key card is the counterpart: shown for key auth, hidden for password.
+ const keyCard = scope.querySelector('.backup-ssh-key-card');
+ if (keyCard) keyCard.style.display = isPassword ? 'none' : '';
+ },
+ applyPathModeVisibility(scope) {
+ const modeSelect = scope.querySelector('select[name$="_PATH_MODE"]');
+ if (!modeSelect) return;
+ const pathInput = scope.querySelector('input[name$="_PATH"]:not([name$="_SSH_PATH"])');
+ const pathGroup = pathInput?.closest('.field-group') || pathInput?.parentElement;
+ if (!pathGroup) return;
+ pathGroup.style.display = modeSelect.value === 'custom' ? '' : 'none';
+ },
+ filterEngineSelect(scope, type, preferred) {
+ const select = scope.querySelector('select[name$="_ENGINE"]');
+ if (!select) return;
+ const compatible = this.enginesForType(type);
+ if (!compatible.length) return;
+
+ // Float the system-default engine (CFG_BACKUP_ENGINE) to the top and
+ // tag it "(default)" so it's the obvious pick for new locations.
+ const defaultId = (window.systemConfigs?.CFG_BACKUP_ENGINE || 'restic').trim();
+ const rank = e => (e.id === defaultId ? 0 : 1);
+ const ordered = [...compatible].sort((a, b) => rank(a) - rank(b));
+
+ const want = ordered.find(e => e.id === preferred)?.id || ordered[0].id;
+ select.innerHTML = ordered
+ .map(e => {
+ const label = (e.name || e.id) + (e.id === defaultId ? ' (default)' : '');
+ return `${this.escape(label)} `;
+ })
+ .join('');
+ select.value = want;
+ },
+ async saveInlineLocation(idx) {
+ await this.saveSection(`location-${idx}`);
+ },
+ deleteInlineLocation(idx) {
+ const loc = (this.locations?.locations || []).find(l => l.idx === idx);
+ const name = loc?.name || `Location ${idx}`;
+ const modal = document.getElementById('backup-delete-location-modal');
+ const body = document.getElementById('backup-delete-location-modal-body');
+ if (!modal || !body) return;
+ body.innerHTML = `
+ Delete location ${this.escape(name)} ?
+ Backup data already stored at this location is not deleted — only LibrePortal's reference to it. The password file on disk also stays in place.
+ `;
+ modal.dataset.locIdx = String(idx);
+ modal.classList.add('open');
+ },
+ async confirmDeleteLocation() {
+ const modal = document.getElementById('backup-delete-location-modal');
+ if (!modal) return;
+ const idx = parseInt(modal.dataset.locIdx, 10);
+ this.closeAllModals();
+ this.expandedLocs.delete(idx);
+ await this.runTask(`libreportal backup location remove ${idx}`, 'backup', null);
+ setTimeout(() => this.reloadAfterSave(), 2000);
+ },
+ async setLocationEnabled(idx, enabled) {
+ const encoded = `CFG_BACKUP_LOC_${idx}_ENABLED=${enabled ? 'true' : 'false'}`;
+ try {
+ if (!window.tasksManager?.router) throw new Error('Task system not available');
+ await window.tasksManager.router.routeAction('config_update', {
+ changes: `'${encoded.replace(/'/g, "'\\''")}'`
+ });
+ this.notify(`${enabled ? 'Enabling' : 'Disabling'} this location…`, 'success');
+ setTimeout(() => this.reloadAfterSave(), 2500);
+ } catch (err) {
+ this.notify(`Save failed: ${err.message || err}`, 'error');
+ }
+ },
+});
diff --git a/containers/libreportal/frontend/components/backup/js/backup-migrate.js b/containers/libreportal/frontend/components/backup/js/backup-migrate.js
new file mode 100644
index 0000000..34dc6d8
--- /dev/null
+++ b/containers/libreportal/frontend/components/backup/js/backup-migrate.js
@@ -0,0 +1,182 @@
+// Auto-extracted from backup-page.js (verbatim) — augments BackupPage.prototype. Loaded after the base.
+Object.assign(BackupPage.prototype, {
+ 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 => `
+
+
+ ${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
+
+
+ Migrate every app from this host
+
+
+
+ ${(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))}
+
+
+
+ Migrate
+
+
+ `;
+ }).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}
+
+
+
+
+ Back up the destination's existing copy first
+
+ Safety net: snapshot the current ${mode === 'app' ? this.escape(app) : 'app'} into your first
+ enabled backup location (tagged pre-migrate) before wipe.
+ ${collisions.length ? '' : 'No collision — nothing to back up.'}
+
+
+
+
+
+
+ Rewrite host-bound URLs to this host
+
+ Replaces CFG_*_URL, *_DOMAIN, *_HOSTNAME with this
+ host's values. Uncheck only if you want the moved app to keep claiming the source's hostname.
+
+
+
+
+ `;
+ 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);
+ }
+ },
+});
diff --git a/containers/libreportal/frontend/components/backup/js/backup-page.js b/containers/libreportal/frontend/components/backup/js/backup-page.js
index bc58a1e..c7d117f 100644
--- a/containers/libreportal/frontend/components/backup/js/backup-page.js
+++ b/containers/libreportal/frontend/components/backup/js/backup-page.js
@@ -326,87 +326,15 @@ class BackupPage {
});
}
- async refreshAll() {
- const ts = Date.now();
- const [dashboard, locations, , schema, migrate, peersData] = 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/migrate.json?t=${ts}`),
- this.fetchJson(`/data/peers/generated/peers.json?t=${ts}`)
- ]);
- this.dashboard = dashboard;
- this.locations = locations;
- this.locSchema = schema;
- this.migrate = migrate;
- // Build hostname → friendly-name lookup once so renderMigrate can show
- // "homelab (host: homelab.lan)" instead of bare hostnames.
- this.hostnameToPeerName = {};
- for (const p of (peersData?.peers || [])) {
- if (p.kind === 'backup-channel' && p.config?.hostname) {
- this.hostnameToPeerName[p.config.hostname] = p.name;
- }
- }
- this.snapshotsByLoc = {};
- if (!this.engines.length) await this.loadEngines();
-
- if (locations?.locations?.length) {
- const enabled = locations.locations.filter(l => l.enabled);
- await Promise.all(enabled.map(async (l) => {
- const s = await this.fetchJson(`/data/backup/generated/snapshots_${l.idx}.json?t=${ts}`);
- if (s) this.snapshotsByLoc[l.idx] = s;
- }));
- }
- }
-
- async fetchJson(url) {
- try { const r = await fetch(url); if (!r.ok) return null; return await r.json(); }
- catch { return null; }
- }
/* Load the unified config file once for the Locations editor: configData
carries field metadata (titles/descriptions/options/advanced) the editor
renders from; systemConfigs is the flat key->value map used for default
lookups (e.g. CFG_BACKUP_ENGINE) and save-time change detection. */
- async loadSystemConfigs() {
- const data = await this.fetchJson(`/data/config/generated/configs.json?t=${Date.now()}`);
- if (!data) return;
- window.configData = data;
- const flat = {};
- for (const [k, v] of Object.entries(data.config || {})) flat[k] = v?.value ?? '';
- window.systemConfigs = flat;
- }
- async loadEngines() {
- const ts = Date.now();
- const index = await this.fetchJson(`/data/backup/generated/engines/index.json?t=${ts}`);
- const ids = index?.engines || [];
- const metas = await Promise.all(ids.map(id =>
- this.fetchJson(`/data/backup/generated/engines/${encodeURIComponent(id)}.json?t=${ts}`)
- ));
- this.engines = metas.filter(Boolean);
- // Fallback so the dropdown never collapses to empty if the regen
- // hasn't run yet — restic is always assumed available.
- if (!this.engines.length) {
- this.engines = [{ id: 'restic', name: 'Restic', supported_types: ['local','sftp','rest','s3','b2','gs','azure','rclone'] }];
- }
- }
- engineDisplayName(id) {
- if (!id) return 'Restic';
- const match = (this.engines || []).find(e => e.id === id);
- return match?.name || id;
- }
- enginesForType(type) {
- if (!type) return this.engines;
- return this.engines.filter(e =>
- !Array.isArray(e.supported_types) ||
- e.supported_types.includes(type)
- );
- }
switchTab(tab, opts = {}) {
if (!tab || tab === this.currentTab) return;
@@ -566,110 +494,8 @@ class BackupPage {
this.renderConfiguration();
}
- renderDashboard() {
- const summary = document.getElementById('backup-summary-row');
- const appGrid = document.getElementById('backup-app-grid');
- const locSummary = document.getElementById('backup-repo-list-summary');
- if (!summary || !appGrid || !locSummary) return;
- const d = this.dashboard || {};
- const locs = d.locations || [];
- const apps = d.apps || [];
- const totalSnapshots = Object.values(this.snapshotsByLoc).reduce((acc, r) => {
- return acc + (Array.isArray(r?.snapshots) ? r.snapshots.length : 0);
- }, 0);
- const protectedApps = apps.filter(a => a.latest_snapshot).length;
- const totalSize = locs.reduce((acc, r) => acc + (parseInt(r.total_size_bytes) || 0), 0);
- summary.innerHTML = `
- ${this.tile('Apps protected', `${protectedApps} / ${apps.length}`, 'with at least one backup')}
- ${this.tile('Backups', `${totalSnapshots}`, `across ${locs.length} location${locs.length === 1 ? '' : 's'}`)}
- ${this.tile('Total stored', this.formatBytes(totalSize), 'deduplicated, encrypted')}
- `;
-
- // Next-run hint in the "Backup status" card header — derived from
- // CFG_BACKUP_CRONTAB_APP (the cron expression the app-backup
- // scheduler uses). Pure client-side computation; no backend
- // surface needed.
- const nextRunEl = document.getElementById('backup-next-run');
- if (nextRunEl) {
- const cron = (window.systemConfigs?.CFG_BACKUP_CRONTAB_APP || '').trim();
- const next = cron ? this.nextCronFireTime(cron) : null;
- if (next) {
- nextRunEl.textContent = `Next backup ${this.formatRelativeFuture(next)} · ${this.formatScheduleClock(next)}`;
- nextRunEl.title = `Next scheduled backup: ${next.toLocaleString()}\nSchedule: ${cron}`;
- } else if (cron) {
- nextRunEl.textContent = `Schedule: ${cron}`;
- nextRunEl.title = `Couldn't parse the schedule "${cron}" to compute the next run.`;
- } else {
- nextRunEl.textContent = 'No schedule set';
- nextRunEl.title = 'CFG_BACKUP_CRONTAB_APP is empty — backups only run when triggered manually.';
- }
- }
-
- // System config tile is rendered FIRST so the bare-metal-restore
- // prerequisite is always at eye-level — without it, the user's
- // backups exist but the credentials needed to reach them don't.
- const systemTileHtml = this.renderSystemTile(d.system || {});
- if (!apps.length) {
- appGrid.innerHTML = systemTileHtml + `No apps installed yet.
`;
- } else {
- appGrid.innerHTML = systemTileHtml + apps.map(app => this.renderAppTile(app)).join('');
- }
-
- if (!locs.length) {
- locSummary.innerHTML = `No locations enabled.
`;
- } else {
- locSummary.innerHTML = locs.map(r => `
-
-
- ${this.escape(r.type)}
- ${this.escape(r.name)}
-
-
- ${this.formatBytes(parseInt(r.total_size_bytes) || 0)}
- ${r.total_files || 0} files
-
-
- `).join('');
- }
- }
-
- // System config tile — same shape as an app tile but with the LibrePortal
- // app icon. Clicking any tile (system or app) opens the Back-up checklist
- // modal with that tile pre-ticked; there are no inline action buttons
- // anymore. Rendered first in the Backup status grid so the bare-metal
- // prerequisite is always visible up top.
- renderSystemTile(sys) {
- const has = !!sys.latest_snapshot;
- const dot = has ? 'ok' : 'none';
- const when = has ? 'Last backed up ' + this.formatRelative(sys.latest_time) : 'No backup yet';
- return `
-
-
-
-
Configs
-
-
- ${this.escape(when)}
-
-
-
- Back up
-
-
- `;
- }
-
- tile(label, value, detail) {
- return `
-
-
${this.escape(label)}
-
${this.escape(value)}
-
${this.escape(detail || '')}
-
- `;
- }
/* Look up the icon + display name from window.apps the same way the
dashboard and tasks page do. Falls back to the default app icon and
@@ -688,686 +514,45 @@ class BackupPage {
return { icon, displayName };
}
- renderAppTile(app) {
- const has = !!app.latest_snapshot;
- const dot = has ? 'ok' : 'none';
- const when = has ? this.formatRelative(app.latest_time) : 'No backup yet';
- const { icon, displayName } = this.appMeta(app.app);
- return `
-
-
-
-
${this.escape(displayName)}
-
-
- ${when}
-
-
-
- Back up
-
-
- `;
- }
- renderLocations() {
- const list = document.getElementById('backup-location-list');
- const repoSelect = document.getElementById('backup-snapshot-repo');
- if (!list) return;
- const locs = this.locations?.locations || [];
- if (!locs.length) {
- list.innerHTML = `
-
- No backup locations configured yet.
- Click Add location above to create one.
-
- `;
- } else {
- list.innerHTML = locs.map(l => this.renderLocationRow(l)).join('');
- }
-
- if (repoSelect) {
- const cur = repoSelect.value;
- repoSelect.innerHTML = `All locations ` +
- locs.filter(l => l.enabled).map(l => `${this.escape(l.name)} `).join('');
- if (cur) repoSelect.value = cur;
- }
- }
-
- renderLocationRow(l) {
- // Status pill mirrors task-status: ✅ Ready / ⏳ Initialising / ⏸ Disabled.
- const statusKind = l.enabled && l.password_exists ? 'ready'
- : l.enabled && !l.password_exists ? 'init'
- : 'disabled';
- const statusMeta = {
- ready: { icon: '✅', label: 'Ready' },
- init: { icon: '⏳', label: 'Initialising' },
- disabled: { icon: '⏸', label: 'Disabled' }
- }[statusKind];
- const snapCount = this.snapshotsByLoc[l.idx]?.snapshots?.length ?? 0;
- const expanded = this.expandedLocs.has(l.idx);
- const size = this.formatBytes(parseInt(l.total_size_bytes) || 0);
- return `
-
-
-
- ${expanded ? this.renderLocationDetailsBody(l) : ''}
-
-
- `;
- }
/* Inline-SVG icon for a location's backend type. Local gets the disk
(stack of platters) glyph; everything else gets a cloud — that's
the visual line between "lives on this box" and "lives somewhere else." */
- typeIcon(type) {
- const local = `
-
-
-
- `;
- const cloud = `
-
- `;
- return type === 'local' ? local : cloud;
- }
- renderLocationDetailsBody(l) {
- const idx = l.idx;
- const groups = this.locFieldGroups(idx, l.type);
- const retentionValues = {
- last: l.custom_retention ? (l.keep_last || '') : '',
- daily: l.custom_retention ? (l.keep_daily || '') : '',
- weekly: l.custom_retention ? (l.keep_weekly || '') : '',
- monthly: l.custom_retention ? (l.keep_monthly || '') : '',
- yearly: l.custom_retention ? (l.keep_yearly || '') : ''
- };
- // Reuse the app-detail tab design (.tabs-wrapper/.tab-button/.tab-panel
- // from style.css) so the Locations editor matches the rest of the UI.
- const tab = (id, emoji, label) => `
-
- ${emoji}
- ${label}
- `;
- return `
-
-
-
- ${tab('connection', '🔗', 'Connection')}
- ${tab('retention', '♻️', 'Retention')}
- ${tab('advanced', '⚙️', 'Advanced')}
-
-
-
-
- ${this.renderConnectionInner(idx, l.type, l, groups.connection)}
-
-
-
-
- ${this.formRetention(`CFG_BACKUP_LOC_${idx}_`, retentionValues, true)}
-
-
-
-
- ${this.renderLocFields(idx, groups.advanced, l)}
-
-
-
-
-
-
-
-
-
-
-
-
- Save changes
-
-
-
-
-
-
-
-
- Delete location
-
-
- `;
- }
-
- toggleLocationExpand(idx) {
- const row = document.querySelector(`.backup-location-row[data-loc="${idx}"]`);
- if (!row) return;
- const details = row.querySelector('.task-details');
- const header = row.querySelector('.task-header');
- if (!details) return;
-
- const willOpen = !this.expandedLocs.has(idx);
- if (willOpen) {
- this.expandedLocs.add(idx);
- const loc = (this.locations?.locations || []).find(l => l.idx === idx);
- if (loc) {
- details.innerHTML = this.renderLocationDetailsBody(loc);
- this.tagFieldsForSave(details);
- this.filterEngineSelect(details, loc.type, loc.engine);
- this.applySshAuthVisibility(details);
- this.applyPathModeVisibility(details);
- }
- this.enhanceEngineDetailsButton();
- details.classList.add('show');
- row.classList.add('expanded');
- if (header) header.setAttribute('aria-expanded', 'true');
- } else {
- this.expandedLocs.delete(idx);
- details.classList.remove('show');
- row.classList.remove('expanded');
- if (header) header.setAttribute('aria-expanded', 'false');
- }
- }
-
- refreshInlineTypeFields(idx, type) {
- const loc = (this.locations?.locations || []).find(l => l.idx === idx) || {};
- const groups = this.locFieldGroups(idx, type);
-
- const conn = document.getElementById(`backup-location-${idx}-connection`);
- if (conn) {
- conn.innerHTML = this.renderConnectionInner(idx, type, { ...loc, type }, groups.connection);
- this.tagFieldsForSave(conn);
- }
-
- // The Advanced tab's fields are type-dependent too (URI override only
- // applies to some types), so rebuild it alongside the Connection tab.
- const adv = document.getElementById(`backup-location-${idx}-advanced`);
- if (adv) {
- adv.innerHTML = this.renderLocFields(idx, groups.advanced, { ...loc, type });
- this.tagFieldsForSave(adv);
- }
-
- // Re-apply dynamic behaviors across the whole details scope: the engine
- // select lives in the Advanced tab while SSH-auth / path-mode live in
- // Connection, so target the shared parent rather than one panel.
- const scope = (conn || adv)?.closest('.task-details');
- if (scope) {
- this.filterEngineSelect(scope, type, loc.engine);
- this.applySshAuthVisibility(scope);
- this.applyPathModeVisibility(scope);
- }
- this.enhanceEngineDetailsButton();
- }
/* Hide the SSH password field when SSH auth = key, show it when = password.
Applied at expand time and whenever the SSH_AUTH select changes. */
- applySshAuthVisibility(scope) {
- const authSelect = scope.querySelector('select[name$="_SSH_AUTH"]');
- if (!authSelect) return;
- const isPassword = authSelect.value === 'password';
- const passInput = scope.querySelector('input[name$="_SSH_PASS"]');
- const passGroup = passInput?.closest('.field-group') || passInput?.closest('.password-mode-wrapper')?.parentElement;
- if (passGroup) passGroup.style.display = isPassword ? '' : 'none';
- // SSH key card is the counterpart: shown for key auth, hidden for password.
- const keyCard = scope.querySelector('.backup-ssh-key-card');
- if (keyCard) keyCard.style.display = isPassword ? 'none' : '';
- }
/* Hide the custom PATH input when PATH_MODE=auto, show when =custom. */
- applyPathModeVisibility(scope) {
- const modeSelect = scope.querySelector('select[name$="_PATH_MODE"]');
- if (!modeSelect) return;
- const pathInput = scope.querySelector('input[name$="_PATH"]:not([name$="_SSH_PATH"])');
- const pathGroup = pathInput?.closest('.field-group') || pathInput?.parentElement;
- if (!pathGroup) return;
- pathGroup.style.display = modeSelect.value === 'custom' ? '' : 'none';
- }
/* Trim the per-location ENGINE select to only engines whose
supported_types include the location's current TYPE. If the currently
saved engine isn't compatible, fall back to the first compatible one. */
- filterEngineSelect(scope, type, preferred) {
- const select = scope.querySelector('select[name$="_ENGINE"]');
- if (!select) return;
- const compatible = this.enginesForType(type);
- if (!compatible.length) return;
- // Float the system-default engine (CFG_BACKUP_ENGINE) to the top and
- // tag it "(default)" so it's the obvious pick for new locations.
- const defaultId = (window.systemConfigs?.CFG_BACKUP_ENGINE || 'restic').trim();
- const rank = e => (e.id === defaultId ? 0 : 1);
- const ordered = [...compatible].sort((a, b) => rank(a) - rank(b));
- const want = ordered.find(e => e.id === preferred)?.id || ordered[0].id;
- select.innerHTML = ordered
- .map(e => {
- const label = (e.name || e.id) + (e.id === defaultId ? ' (default)' : '');
- return `${this.escape(label)} `;
- })
- .join('');
- select.value = want;
- }
- async saveInlineLocation(idx) {
- await this.saveSection(`location-${idx}`);
- }
- deleteInlineLocation(idx) {
- const loc = (this.locations?.locations || []).find(l => l.idx === idx);
- const name = loc?.name || `Location ${idx}`;
- const modal = document.getElementById('backup-delete-location-modal');
- const body = document.getElementById('backup-delete-location-modal-body');
- if (!modal || !body) return;
- body.innerHTML = `
- Delete location ${this.escape(name)} ?
- Backup data already stored at this location is not deleted — only LibrePortal's reference to it. The password file on disk also stays in place.
- `;
- modal.dataset.locIdx = String(idx);
- modal.classList.add('open');
- }
- async confirmDeleteLocation() {
- const modal = document.getElementById('backup-delete-location-modal');
- if (!modal) return;
- const idx = parseInt(modal.dataset.locIdx, 10);
- this.closeAllModals();
- this.expandedLocs.delete(idx);
- await this.runTask(`libreportal backup location remove ${idx}`, 'backup', null);
- setTimeout(() => this.reloadAfterSave(), 2000);
- }
- renderSnapshots() {
- const list = document.getElementById('backup-snapshot-list');
- if (!list) return;
- const filter = (document.getElementById('backup-snapshot-filter')?.value || '').toLowerCase();
- const locFilter = document.getElementById('backup-snapshot-repo')?.value || '';
- const locNameByIdx = {};
- (this.locations?.locations || []).forEach(l => { locNameByIdx[l.idx] = l.name; });
- const rows = [];
- Object.entries(this.snapshotsByLoc).forEach(([locIdx, data]) => {
- if (locFilter && String(locFilter) !== String(locIdx)) return;
- const snaps = Array.isArray(data?.snapshots) ? data.snapshots : [];
- snaps.forEach(s => {
- const app = (s.tags || []).map(t => /^app=/.test(t) ? t.slice(4) : null).find(Boolean) || '—';
- rows.push({
- app,
- host: s.hostname || '—',
- locIdx,
- locName: locNameByIdx[locIdx] || `Loc ${locIdx}`,
- time: s.time,
- id: s.short_id || (s.id || '').slice(0, 8),
- tags: Array.isArray(s.tags) ? s.tags : [],
- paths: Array.isArray(s.paths) ? s.paths : [],
- });
- });
- });
- rows.sort((a, b) => String(b.time).localeCompare(String(a.time)));
-
- const filtered = filter ? rows.filter(r =>
- r.app.toLowerCase().includes(filter) ||
- r.host.toLowerCase().includes(filter) ||
- r.id.toLowerCase().includes(filter) ||
- r.locName.toLowerCase().includes(filter)
- ) : rows;
-
- if (!filtered.length) {
- list.innerHTML = `No backups yet.
`;
- return;
- }
-
- list.innerHTML = filtered.map(r => this._renderSnapshotRow(r)).join('');
- }
-
- // Render one global-Snapshots-tab backup as the same .task-item card
- // the per-app Backups tab uses, so the two surfaces look identical.
- // Extras vs the per-app card:
- // - An app-name chip (because the global list isn't scoped to one app)
- // that doubles as a deep-link to /app//backups?snapshot=
- // - A Delete action alongside Restore (per-app card only offers
- // Restore — delete lives in the global view)
- _renderSnapshotRow(r) {
- const hasApp = r.app && r.app !== '—';
- const deepLink = hasApp
- ? `/app/${encodeURIComponent(r.app)}/backups?snapshot=${encodeURIComponent(r.id)}`
- : null;
- const iconUrl = hasApp ? `/core/icons/apps/${encodeURIComponent(r.app)}.svg` : '/core/icons/apps/libreportal.svg';
- const displayName = hasApp ? this.appMeta(r.app).displayName : 'Configs';
- const appChip = hasApp
- ? `${this.escape(displayName)} `
- : `${this.escape(displayName)} `;
- const sid = String(r.id);
-
- // Restic stamps app=/host=/engine= into the snapshot tags; surface
- // those as their own fields and keep any remaining tags as chips.
- const tagMap = {};
- (r.tags || []).forEach(t => { const i = t.indexOf('='); if (i > 0) tagMap[t.slice(0, i)] = t.slice(i + 1); });
- const engineName = tagMap.engine ? this.engineDisplayName(tagMap.engine) : null;
- const otherTags = (r.tags || []).filter(t => !/^(app|host|engine|paths?)=/.test(t));
- const field = (label, valueHtml) =>
- `${label} ${valueHtml}
`;
-
- return `
- `;
- }
-
- _fmtShortTime(iso) {
- if (!iso) return '';
- const d = new Date(iso);
- if (isNaN(d.getTime())) return String(iso);
- return d.toLocaleString();
- }
- _fmtFullTime(iso) {
- if (!iso) return '';
- const d = new Date(iso);
- if (isNaN(d.getTime())) return String(iso);
- return d.toString();
- }
- _fmtNiceTime(iso) {
- if (!iso) return '';
- const d = new Date(iso);
- if (isNaN(d.getTime())) return String(iso);
- return d.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' });
- }
-
- renderConfiguration() {
- const body = document.getElementById('backup-configuration-body');
- if (!body) return;
-
- // Dismissed state is persisted server-side via Dismissible
- // (data/ui-state.json), so it follows the user across browsers/devices.
- // Banner + its divider are omitted entirely once dismissed.
- const warningDismissed = !!window.Dismissible?.isDismissed('backup-config-warning');
- const warningHTML = warningDismissed ? '' : `
-
-
-
-
-
-
-
- Keep your LibrePortal config backed up offline.
- Repository passwords live inside the config directory. Without that backup, the others cannot be decrypted by anyone — including you.
-
-
-
-
-
-
-
-
-
- `;
-
- body.innerHTML = `
- ${warningHTML}
-
- `;
-
- this.invokeConfigManager();
- }
-
- async exportRepositoryPasswords(triggerBtn) {
- const restoreBtn = () => {
- if (triggerBtn) {
- triggerBtn.disabled = false;
- triggerBtn.dataset.busy = '';
- }
- };
- if (triggerBtn) {
- triggerBtn.disabled = true;
- triggerBtn.dataset.busy = '1';
- }
-
- try {
- const task = await this.taskManager?.createTask(
- 'libreportal webui generate backup',
- 'webui',
- null
- );
- if (task?.id) {
- await this.waitForTask(task.id, 20000);
- }
- const res = await fetch(`/data/backup/generated/passwords.txt?t=${Date.now()}`, {
- credentials: 'same-origin'
- });
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
- const text = await res.text();
- if (!text || !text.includes('CFG_BACKUP_LOC_')) {
- throw new Error('Password file is empty — no locations configured?');
- }
- const blob = new Blob([text], { type: 'text/plain;charset=utf-8' });
- const url = URL.createObjectURL(blob);
- const a = document.createElement('a');
- const host = (window.systemConfigs?.CFG_INSTALL_NAME || 'libreportal').replace(/[^a-z0-9_-]/gi, '_');
- const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-');
- a.href = url;
- a.download = `libreportal-backup-passwords-${host}-${stamp}.txt`;
- document.body.appendChild(a);
- a.click();
- document.body.removeChild(a);
- URL.revokeObjectURL(url);
- this.notify('Password export downloaded — store it offline.', 'success');
- } catch (err) {
- this.notify(`Export failed: ${err.message || err}`, 'error');
- } finally {
- restoreBtn();
- }
- }
-
- waitForTask(taskId, timeoutMs = 15000) {
- return new Promise((resolve) => {
- let done = false;
- const finish = () => {
- if (done) return;
- done = true;
- window.removeEventListener('taskCompleted', onComplete);
- clearTimeout(timer);
- resolve();
- };
- const onComplete = (e) => {
- if (e?.detail?.taskId === taskId) finish();
- };
- window.addEventListener('taskCompleted', onComplete);
- const timer = setTimeout(finish, timeoutMs);
- });
- }
-
- async invokeConfigManager(attempt = 0) {
- if (window.configManager && typeof window.configManager.renderConfig === 'function') {
- try {
- await window.configManager.renderConfig('backup');
- this.enhanceConfigurationWithPresets();
- } catch (err) {
- console.error('Backup configuration render failed:', err);
- }
- return;
- }
- if (attempt >= 20) {
- const sec = document.getElementById('config-section');
- if (sec) sec.innerHTML = `Configuration system not loaded. Try refreshing the page.
`;
- return;
- }
- setTimeout(() => this.invokeConfigManager(attempt + 1), 150);
- }
/* Post-render polish on the dynamic /config render: wrap the five raw
retention number fields in a persona-preset dropdown. The five inputs
stay in the DOM (so /config's save flow captures them unchanged) but
are hidden under "Custom…" by default. */
- enhanceConfigurationWithPresets() {
- this.enhanceEngineDetailsButton();
- const lastInput = document.querySelector('#config-section [name="CFG_BACKUP_KEEP_LAST"]');
- if (!lastInput) return;
-
- const section = lastInput.closest('.config-category');
- if (!section || section.dataset.backupPresetEnhanced === '1') return;
- section.dataset.backupPresetEnhanced = '1';
-
- const fieldNames = [
- 'CFG_BACKUP_KEEP_LAST',
- 'CFG_BACKUP_KEEP_DAILY',
- 'CFG_BACKUP_KEEP_WEEKLY',
- 'CFG_BACKUP_KEEP_MONTHLY',
- 'CFG_BACKUP_KEEP_YEARLY'
- ];
- const inputs = fieldNames
- .map(n => section.querySelector(`[name="${n}"]`))
- .filter(Boolean);
- if (inputs.length < 5) return;
-
- const wrappers = inputs.map(input => {
- return input.closest('.config-field, .field-group, .form-group') || input.parentElement;
- });
-
- const extraCustomFields = ['CFG_BACKUP_PRUNE_AFTER_FORGET'];
- extraCustomFields.forEach(name => {
- const el = section.querySelector(`[name="${name}"]`);
- if (el) {
- const wrap = el.closest('.config-field, .field-group, .form-group') || el.parentElement;
- if (wrap) wrappers.push(wrap);
- }
- });
-
- const readVals = () => ({
- last: inputs[0].value || '',
- daily: inputs[1].value || '',
- weekly: inputs[2].value || '',
- monthly: inputs[3].value || '',
- yearly: inputs[4].value || ''
- });
-
- const preset = backupRetentionDetectPreset(readVals());
- const meta = BACKUP_RETENTION_PRESET_META[preset];
- const presetOptions = this.retentionPresetOptions(preset, false);
-
- const block = document.createElement('div');
- block.className = 'backup-retention-preset-block';
- block.innerHTML = `
-
- Backup style ℹ️
- ${presetOptions}
-
- `;
-
- const fieldsGrid = section.querySelector('.config-fields');
- if (fieldsGrid) {
- fieldsGrid.prepend(block);
- } else {
- section.prepend(block);
- }
-
- const applyVisibility = (presetKey) => {
- const isCustom = presetKey === 'custom';
- wrappers.forEach(w => { if (w) w.style.display = isCustom ? '' : 'none'; });
- };
- applyVisibility(preset);
-
- const select = block.querySelector('[data-backup-retention-preset]');
- const tooltipEl = block.querySelector('[data-retention-tooltip]');
- select.addEventListener('change', () => {
- const chosen = select.value;
- if (tooltipEl) tooltipEl.title = BACKUP_RETENTION_PRESET_META[chosen]?.hint || '';
- applyVisibility(chosen);
- if (chosen === 'custom') return;
- const p = BACKUP_RETENTION_PRESETS[chosen];
- const map = { last: 0, daily: 1, weekly: 2, monthly: 3, yearly: 4 };
- Object.entries(map).forEach(([k, i]) => {
- inputs[i].value = p[k];
- inputs[i].dispatchEvent(new Event('input', { bubbles: true }));
- inputs[i].dispatchEvent(new Event('change', { bubbles: true }));
- });
- });
- }
/* Build the list for a "Backup style" dropdown. The scope's
default preset is tagged "(default)" and floated to the top; the rest
follow in declared order. inherit-global only makes sense per-location
(there's nothing to inherit at the global level), so it's omitted when
includeInherit is false. */
- retentionPresetOptions(selected, includeInherit = false) {
- const defaultKey = includeInherit ? 'inherit-global' : 'self-hosting';
- const keys = Object.keys(BACKUP_RETENTION_PRESET_META)
- .filter(k => k !== 'inherit-global' || includeInherit);
- const ordered = [defaultKey, ...keys.filter(k => k !== defaultKey)];
- return ordered.map(k => {
- const base = BACKUP_RETENTION_PRESET_META[k].label;
- const label = k === defaultKey ? `${base} (default)` : base;
- return ` ${this.escape(label)} `;
- }).join('');
- }
/* Retention preset dropdown + hidden underlying fields.
`prefix` is the CFG name prefix, e.g. 'CFG_BACKUP_' or 'CFG_BACKUP_LOC_3_'.
@@ -1376,535 +561,62 @@ class BackupPage {
written: false when inherit, true otherwise. The five raw KEEP_* inputs
are always rendered (so the save flow captures them) but hidden until
"Custom…" is selected. */
- formRetention(prefix, values, includeInherit = false) {
- const preset = backupRetentionDetectPreset(values, includeInherit);
- const meta = BACKUP_RETENTION_PRESET_META[preset];
- const presetOptions = this.retentionPresetOptions(preset, includeInherit);
- const customRetentionHidden = includeInherit
- ? ` `
- : '';
- return `
-
-
- Backup style ℹ️
- ${presetOptions}
-
- ${customRetentionHidden}
-
-
-
- ${this.formInput(`${prefix}KEEP_LAST`, 'Keep last', values.last, 'number', '', 'backups')}
- ${this.formInput(`${prefix}KEEP_DAILY`, 'Keep daily', values.daily, 'number', '', 'days')}
- ${this.formInput(`${prefix}KEEP_WEEKLY`, 'Keep weekly', values.weekly, 'number', '', 'weeks')}
- ${this.formInput(`${prefix}KEEP_MONTHLY`, 'Keep monthly', values.monthly, 'number', '', 'months')}
- ${this.formInput(`${prefix}KEEP_YEARLY`, 'Keep yearly', values.yearly, 'number', '', 'years')}
-
-
- `;
- }
- applyRetentionPreset(selectEl) {
- const block = selectEl.closest('[data-retention-prefix]');
- const advanced = block?.nextElementSibling;
- if (!block) return;
- const prefix = block.dataset.retentionPrefix;
- const allowInherit = block.dataset.retentionAllowInherit === '1';
- const preset = selectEl.value;
- const tooltipEl = block.querySelector('[data-retention-tooltip]');
- if (tooltipEl) tooltipEl.title = BACKUP_RETENTION_PRESET_META[preset]?.hint || '';
- if (preset === 'custom') {
- if (advanced) advanced.hidden = false;
- } else {
- if (advanced) advanced.hidden = true;
- const p = BACKUP_RETENTION_PRESETS[preset];
- if (p) {
- const setField = (suffix, value) => {
- const el = document.querySelector(`[name="${prefix}${suffix}"]`);
- if (el) {
- el.value = value;
- el.dispatchEvent(new Event('input', { bubbles: true }));
- }
- };
- setField('KEEP_LAST', p.last);
- setField('KEEP_DAILY', p.daily);
- setField('KEEP_WEEKLY', p.weekly);
- setField('KEEP_MONTHLY', p.monthly);
- setField('KEEP_YEARLY', p.yearly);
- }
- }
-
- // Keep CUSTOM_RETENTION in sync with the preset (location scope only).
- if (allowInherit) {
- const cr = block.querySelector(`[name="${prefix}CUSTOM_RETENTION"]`);
- if (cr) cr.value = preset === 'inherit-global' ? 'false' : 'true';
- }
- }
-
- formInput(name, label, value, type = 'text', placeholder = '', unit = '') {
- const escVal = this.escape(value ?? '');
- const escPh = this.escape(placeholder);
- const escLabel = this.escape(label);
- const inputHTML = ` `;
- const wrapped = unit ? `${inputHTML}${this.escape(unit)}
` : inputHTML;
- return `
-
- ${escLabel}
- ${wrapped}
-
- `;
- }
-
- formSelect(name, label, value, options) {
- const escLabel = this.escape(label);
- const opts = options.map(([v, lbl]) => `${this.escape(lbl)} `).join('');
- return `
-
- ${escLabel}
- ${opts}
-
- `;
- }
-
- formToggle(name, label, checked) {
- const escLabel = this.escape(label);
- return `
-
- ${escLabel}
-
-
-
-
-
- `;
- }
/* Append a "Details" button next to every Engine field (global or
per-location). The button reads its engine id from a sibling input
at click time so per-location selects work even before save. */
- enhanceEngineDetailsButton() {
- const selector = '[name="CFG_BACKUP_ENGINE"], [name^="CFG_BACKUP_LOC_"][name$="_ENGINE"]';
- document.querySelectorAll(`#config-section ${selector}, .backup-location-details ${selector}`).forEach((engineInput) => {
- const customSelect = engineInput.closest('.custom-select');
- const wrapTarget = customSelect || engineInput;
- const group = wrapTarget.closest('.field-group') || wrapTarget.parentElement;
- if (!group || group.dataset.engineDetailsBound === '1') return;
- group.dataset.engineDetailsBound = '1';
- const btn = document.createElement('button');
- btn.type = 'button';
- btn.className = 'backup-secondary-btn backup-engine-details-btn';
- btn.dataset.action = 'open-engine-details';
- btn.innerHTML = `
-
-
-
-
-
- Details
- `;
- const wrap = document.createElement('div');
- wrap.className = 'backup-engine-input-row';
- wrapTarget.parentNode.insertBefore(wrap, wrapTarget);
- wrap.appendChild(wrapTarget);
- wrap.appendChild(btn);
- });
- }
- async openEngineDetailsModal(triggerEl) {
- const modal = document.getElementById('backup-engine-modal');
- const body = document.getElementById('backup-engine-modal-body');
- const title = document.getElementById('backup-engine-modal-title');
- if (!modal || !body) return;
-
- // Find the engine select adjacent to the Details button that fired
- // this event so per-location Details work even when the user has
- // changed the select but not saved yet.
- let engineId = (window.systemConfigs?.CFG_BACKUP_ENGINE || 'restic').trim();
- const row = triggerEl?.closest('.backup-engine-input-row');
- const sel = row?.querySelector('select, input');
- if (sel && sel.value) engineId = sel.value.trim();
- body.innerHTML = `Loading engine details…
`;
- modal.classList.add('open');
-
- const data = await this.fetchJson(`/data/backup/generated/engines/${encodeURIComponent(engineId)}.json?t=${Date.now()}`);
- if (!data) {
- body.innerHTML = `
-
- No details file for engine "${this.escape(engineId)} ".
- Add scripts/backup/engines/${this.escape(engineId)}.json and run the WebUI regen.
-
- `;
- return;
- }
-
- if (title) title.textContent = `Backup engine: ${data.name || engineId}`;
- const propsHTML = (data.properties || []).map(p =>
- `${this.escape(p.label)} ${this.escape(p.value)} `
- ).join('');
- const featsHTML = (data.features || []).map(f => `${this.escape(f)} `).join('');
- const docsHTML = data.docs_url
- ? `${this.escape(data.docs_url)} ↗ `
- : '';
- const logoHTML = data.logo
- ? ` `
- : '';
-
- body.innerHTML = `
-
- ${logoHTML}
-
-
${this.escape(data.name || engineId)}
-
${this.escape(data.tagline || '')}
-
-
- ${propsHTML ? `` : ''}
- ${featsHTML ? `Highlights ` : ''}
- ${docsHTML ? `Documentation ${docsHTML}
` : ''}
- `;
- }
-
- formCrontab(name, label, value) {
- if (typeof ConfigShared === 'undefined' || !ConfigShared.createCrontabField) {
- return this.formInput(name, label, value, 'text', 'minute hour day month weekday');
- }
- const fieldId = `config-${name}`;
- let cronHtml = ConfigShared.createCrontabField(fieldId, name, value, label, '');
- cronHtml = cronHtml.replace(`name="${name}"`, `name="${name}" data-backup-field`);
- return `
-
- ${this.escape(label)}
- ${cronHtml}
-
- `;
- }
-
- formReadOnly(label, value) {
- return `
-
- ${this.escape(label)}
- ${this.escape(value)}
-
- `;
- }
/* ----- Location modal (edit / add) ----- */
- openLocationModal_unused(idx) {
- const loc = (this.locations?.locations || []).find(l => l.idx === idx);
- if (!loc) return;
- const modal = document.getElementById('backup-location-modal');
- const body = document.getElementById('backup-location-modal-body');
- const title = document.getElementById('backup-location-modal-title');
- if (!modal || !body) return;
- modal.dataset.locIdx = idx;
- title.textContent = `Edit location: ${loc.name}`;
-
- body.innerHTML = `
-
-
-
Retention
-
When to delete old backups from this location.
-
-
- `;
-
- this.refreshLocationModalTypeFields(loc.type, loc);
- this.refreshLocationModalRetention(loc.custom_retention);
-
- modal.classList.add('open');
- }
-
- refreshLocationModalTypeFields(type, locOverride) {
- const container = document.getElementById('backup-location-connection');
- const modal = document.getElementById('backup-location-modal');
- if (!container || !modal) return;
- const idx = parseInt(modal.dataset.locIdx, 10);
- const loc = locOverride || (this.locations?.locations || []).find(l => l.idx === idx) || {};
-
- const suffixes = this.locFieldsForType(type);
- container.innerHTML = this.renderLocFields(idx, suffixes, loc);
- this.tagFieldsForSave(container);
- }
-
- refreshLocationModalRetention(enabled) {
- const container = document.getElementById('backup-location-retention');
- const modal = document.getElementById('backup-location-modal');
- if (!container || !modal) return;
- const idx = parseInt(modal.dataset.locIdx, 10);
- const loc = (this.locations?.locations || []).find(l => l.idx === idx) || {};
-
- // The "Use custom retention" toggle itself stays at the top regardless.
- const toggleField = this.renderLocFields(idx, ['CUSTOM_RETENTION'], loc);
-
- if (!enabled) {
- container.innerHTML = `
- ${toggleField}
- Inherits the global retention policy from the Configuration tab.
- `;
- this.tagFieldsForSave(container);
- return;
- }
-
- const values = {
- last: loc.keep_last || '',
- daily: loc.keep_daily || '',
- weekly: loc.keep_weekly || '',
- monthly: loc.keep_monthly || '',
- yearly: loc.keep_yearly || ''
- };
- container.innerHTML = `
- ${toggleField}
- ${this.formRetention(`CFG_BACKUP_LOC_${idx}_`, values)}
- `;
- this.tagFieldsForSave(container);
- }
/* Render a list of CFG_BACKUP_LOC_${idx}_${suffix} fields via the same
ConfigShared.generateField machinery /config uses, so widgets and
styling match pixel-for-pixel. Values are picked up from the location
object (locations.json) using the camelCase mirrors of each suffix. */
- renderLocFields(idx, suffixes, loc) {
- if (typeof ConfigShared === 'undefined' || !ConfigShared.generateField) {
- return `Configuration system not loaded.
`;
- }
- const locValueLookup = {
- NAME: loc.name, ENABLED: loc.enabled ? 'true' : 'false', TYPE: loc.type,
- ENGINE: loc.engine || 'restic',
- PATH_MODE: loc.path_mode || 'custom',
- PATH: loc.path, URI: loc.uri, SSH_USER: loc.ssh_user, SSH_HOST: loc.ssh_host,
- SSH_PORT: loc.ssh_port, SSH_PATH: loc.ssh_path,
- SSH_AUTH: loc.ssh_auth || 'key', SSH_PASS: '',
- S3_ACCESS_KEY: '', S3_SECRET_KEY: '',
- B2_ACCOUNT_ID: '', B2_ACCOUNT_KEY: '',
- APPEND_ONLY: loc.append_only ? 'true' : 'false',
- CUSTOM_RETENTION: loc.custom_retention ? 'true' : 'false',
- KEEP_LAST: loc.keep_last, KEEP_DAILY: loc.keep_daily,
- KEEP_WEEKLY: loc.keep_weekly, KEEP_MONTHLY: loc.keep_monthly,
- KEEP_YEARLY: loc.keep_yearly
- };
-
- // Field metadata comes from configs.json (window.configData) via
- // locFieldMeta; the basic/advanced split is decided by the caller, which
- // renders each group into its own tab (Connection vs Advanced).
- let html = '';
- for (const suffix of suffixes) {
- const m = this.locFieldMeta(idx, suffix);
- if (!m.exists) continue;
- const value = (locValueLookup[suffix] ?? '').toString();
- html += ConfigShared.generateField(`config-${m.key}`, m.key, value, m.title, m.description, {}, {});
- }
- html += '
';
- return html;
- }
/* Resolve a location field's metadata. Source of truth is configs.json
(window.configData) — titles/descriptions/options + a per-field "advanced"
flag; BACKUP_LOC_FIELD_DEFS is the fallback for sparse location.configs.
LOC_ADVANCED_SUFFIXES keeps the known overrides advanced even on legacy
locations whose config predates the **ADVANCED** marker. */
- locFieldMeta(idx, suffix) {
- const key = `CFG_BACKUP_LOC_${idx}_${suffix}`;
- const cfg = window.configData?.config?.[key] || {};
- const def = BACKUP_LOC_FIELD_DEFS[suffix] || {};
- return {
- key,
- exists: !!(cfg.title || cfg.description || BACKUP_LOC_FIELD_DEFS[suffix]),
- title: cfg.title || def.title || suffix,
- description: cfg.description ?? def.description ?? '',
- advanced: LOC_ADVANCED_SUFFIXES.has(suffix) || cfg.advanced === true
- };
- }
/* Ordered field list for a location type. Primary source is the generator-
emitted schema.json (this.locSchema); BACKUP_LOC_FIELDS_BY_TYPE is the
fallback if that file didn't load. */
- locFieldsForType(type) {
- return this.locSchema?.types?.[type]
- || BACKUP_LOC_FIELDS_BY_TYPE[type]
- || BACKUP_LOC_FIELDS_BY_TYPE.local;
- }
/* Split a type's fields into the Connection tab vs the Advanced tab. */
- locFieldGroups(idx, type) {
- const suffixes = this.locFieldsForType(type);
- const connection = [];
- const advanced = [];
- for (const suffix of suffixes) {
- const m = this.locFieldMeta(idx, suffix);
- if (!m.exists) continue;
- (m.advanced ? advanced : connection).push(suffix);
- }
- return { connection, advanced };
- }
/* Connection-tab body: the generic fields plus, for sftp, the SSH key card.
Used both on first render and when the Type select changes. */
- renderConnectionInner(idx, type, loc, connectionSuffixes) {
- let html = this.renderLocFields(idx, connectionSuffixes, loc);
- if (type === 'sftp') html += this.renderBackupSshKeyCard(loc);
- return html;
- }
/* SSH key card for an sftp location. LibrePortal holds the private key in
the location's ssh.key file; only the public key is shown — that's what
you paste into the remote server's authorized_keys. Hidden by
applySshAuthVisibility when SSH auth = password. */
- renderBackupSshKeyCard(l) {
- const idx = l.idx;
- const hasKey = l.ssh_key_exists === true;
- const pub = l.ssh_public_key || '';
- const body = hasKey ? `
- Add this public key to the remote server's ~/.ssh/authorized_keys:
-
-
- Copy public key
- Delete key
-
` : `
- Paste an existing private key, or generate one and we'll show the public key to add on the remote.
-
-
- Save key
- Generate keypair
-
`;
- return `
-
-
- SSH key
- ${hasKey ? '✓ Key configured' : 'No key yet'}
-
- ${body}
-
`;
- }
- async saveBackupSshKey(idx) {
- const card = document.querySelector(`.backup-ssh-key-card[data-loc="${idx}"]`);
- const key = (card?.querySelector('.backup-ssh-keyinput')?.value || '').trim();
- if (!key) { this.notify('Paste a private key first', 'error'); return; }
- const b64 = btoa(unescape(encodeURIComponent(key + '\n')));
- await this.runTask(`libreportal backup location ssh-key-set ${idx} ${b64}`, 'backup', null);
- }
- async generateBackupSshKey(idx) {
- await this.runTask(`libreportal backup location ssh-key-generate ${idx}`, 'backup', null);
- }
- async deleteBackupSshKey(idx) {
- if (!confirm("Delete this location's SSH key? Backups here will fail until a new key is set and added on the remote.")) return;
- await this.runTask(`libreportal backup location ssh-key-delete ${idx}`, 'backup', null);
- }
- async copyBackupSshKey(idx) {
- const loc = (this.locations?.locations || []).find(l => l.idx === idx);
- const pub = loc?.ssh_public_key || '';
- try { await navigator.clipboard.writeText(pub); this.notify('Public key copied', 'success'); }
- catch { this.notify('Copy failed — select the text and copy manually', 'error'); }
- }
- tagFieldsForSave(container) {
- container.querySelectorAll('input[name], select[name], textarea[name]').forEach(el => {
- if (!el.hasAttribute('data-backup-field')) {
- el.setAttribute('data-backup-field', '');
- if (el.type === 'checkbox') el.setAttribute('data-backup-bool', '');
- }
- });
- }
- async saveLocationModal() {
- const modal = document.getElementById('backup-location-modal');
- if (!modal) return;
- const idx = parseInt(modal.dataset.locIdx, 10);
- this.closeAllModals();
- await this.saveSection(`location-${idx}`);
- }
- async deleteLocationModal() {
- const modal = document.getElementById('backup-location-modal');
- if (!modal) return;
- const idx = parseInt(modal.dataset.locIdx, 10);
- const loc = (this.locations?.locations || []).find(l => l.idx === idx);
- const name = loc?.name || `Location ${idx}`;
- if (!confirm(`Delete location "${name}"?\n\nBackup data already stored at this location is not deleted by this action — only LibrePortal's reference to it. The password file on disk also stays in place (rename it manually if you want to start fresh).`)) return;
- this.closeAllModals();
- await this.runTask(`libreportal backup location remove ${idx}`, 'backup', null);
- setTimeout(() => this.reloadAfterSave(), 2000);
- }
/* ----- Add location modal ----- */
- openAddLocationModal() {
- const modal = document.getElementById('backup-add-location-modal');
- const body = document.getElementById('backup-add-location-modal-body');
- if (!modal || !body) return;
- body.innerHTML = `
-
- ${this.formSelect('__add_type', 'Type', 'local', [
- ['local', 'Local / mounted path'],
- ['sftp', 'SFTP'],
- ['rest', 'REST server'],
- ['s3', 'S3'],
- ['b2', 'Backblaze B2'],
- ['gs', 'Google Cloud Storage'],
- ['azure', 'Azure'],
- ['rclone', 'rclone']
- ])}
- ${this.formInput('__add_name', 'Friendly name', '', 'text', 'e.g. Office NAS')}
-
- The location starts disabled — fill in its connection details on the next screen, then toggle Enabled.
- `;
- modal.classList.add('open');
- }
- async confirmAddLocation() {
- const modal = document.getElementById('backup-add-location-modal');
- if (!modal) return;
- const name = modal.querySelector('[name="__add_name"]')?.value?.trim();
- const type = modal.querySelector('[name="__add_type"]')?.value || 'local';
- if (!name) { this.notify('Name is required.', 'error'); return; }
- this.closeAllModals();
- const safeName = name.replace(/'/g, "'\\''");
- await this.runTask(`libreportal backup location add '${safeName}' ${type}`, 'backup', null);
- setTimeout(() => this.reloadAfterSave(), 2000);
- }
/* ----- Snapshot restore/delete modals ----- */
- openRestoreModal(app, locIdx, snapshot) {
- const locName = this.locName(locIdx);
- const modal = document.getElementById('backup-restore-modal');
- const body = document.getElementById('backup-restore-modal-body');
- if (!modal || !body) return;
- body.innerHTML = `
- Restore ${this.escape(app)} from backup ${this.escape(snapshot)} at ${this.escape(locName)} ?
- The app will be stopped, its folder wiped, the backup restored in place, then the app started again. App-specific pre/post-restore hooks run if present.
- `;
- modal.dataset.app = app;
- modal.dataset.locIdx = locIdx;
- modal.dataset.snapshot = snapshot;
- modal.classList.add('open');
- }
- openDeleteModal(app, locIdx, snapshot) {
- const locName = this.locName(locIdx);
- const modal = document.getElementById('backup-delete-modal');
- const body = document.getElementById('backup-delete-modal-body');
- if (!modal || !body) return;
- body.innerHTML = `
- Delete backup ${this.escape(snapshot)} for ${this.escape(app)} from ${this.escape(locName)} ?
- This cannot be undone. Append-only locations will reject the operation.
- `;
- modal.dataset.app = app;
- modal.dataset.locIdx = locIdx;
- modal.dataset.snapshot = snapshot;
- modal.classList.add('open');
- }
locName(idx) {
const l = (this.locations?.locations || []).find(x => String(x.idx) === String(idx));
@@ -1915,32 +627,10 @@ class BackupPage {
document.querySelectorAll('.backup-modal.open').forEach(m => m.classList.remove('open'));
}
- async confirmRestore() {
- const modal = document.getElementById('backup-restore-modal');
- const { app, locIdx, snapshot } = modal.dataset;
- this.closeAllModals();
- await this.runTask(`libreportal restore app start ${app} ${snapshot} ${locIdx}`, 'restore', app);
- }
- async confirmDelete() {
- const modal = document.getElementById('backup-delete-modal');
- const { app, locIdx, snapshot } = modal.dataset;
- this.closeAllModals();
- await this.runTask(`libreportal backup app delete ${app} ${locIdx}:${snapshot}`, 'backup', app);
- }
- async runBackupAllApps() {
- await this.runTask(`libreportal backup all`, 'backup', null);
- }
- async runBackupSystem() {
- await this.runTask(`libreportal backup system`, 'backup', null);
- }
- async confirmRestoreSystem() {
- if (!confirm('Restore the latest system-config snapshot?\n\nIt is restored into a staging folder (not applied) — review it, then copy what you need (e.g. backup-location credentials, login, settings) into the live config. Your running config is NOT overwritten.')) return;
- await this.runTask(`libreportal restore system`, 'restore', null);
- }
/* ----- Back-up checklist modal -----
Triggered by clicking any tile in the Backup status grid. Lists System
@@ -1950,286 +640,13 @@ class BackupPage {
collapse to `libreportal backup all` (which also runs `backup system`
under the hood) so we only queue one task instead of N. */
- openBackupPickModal(opts = {}) {
- const modal = document.getElementById('backup-pick-modal');
- const body = document.getElementById('backup-pick-modal-body');
- if (!modal || !body) return;
- const apps = this.dashboard?.apps || [];
- const sys = this.dashboard?.system || {};
- const preTickSystem = !!opts.preTickSystem;
- const preTickApps = new Set(opts.preTickApps || []);
-
- // System row at the top — uses the LibrePortal icon to match the
- // dashboard tile. Then every installed app, alphabetical.
- const sortedApps = apps.slice().sort((a, b) => a.app.localeCompare(b.app));
-
- const row = (key, iconSrc, label, sub, checked) => `
-
-
-
-
-
${this.escape(label)}
-
${this.escape(sub)}
-
-
- `;
-
- const sysSub = sys.latest_snapshot
- ? 'Last backed up ' + this.formatRelative(sys.latest_time)
- : 'No backup yet';
-
- const rows = [
- row('__system__', '/core/icons/apps/libreportal.svg', 'Configs', sysSub, preTickSystem),
- ...sortedApps.map(app => {
- const meta = this.appMeta(app.app);
- const sub = app.latest_snapshot
- ? 'Last backed up ' + this.formatRelative(app.latest_time)
- : 'No backup yet';
- return row(app.app, meta.icon, meta.displayName, sub, preTickApps.has(app.app));
- })
- ].join('');
-
- body.innerHTML = `
-
- Pick what to snapshot. Each selection runs in the background and shows up on the Tasks page.
-
-
- ${rows}
- `;
-
- // The select-all / clear links live inside the modal body so we wire
- // them once here per open (they get rebuilt every open, no listener
- // leak).
- body.querySelectorAll('[data-pick-action]').forEach(a => {
- a.addEventListener('click', (e) => {
- e.preventDefault();
- const all = a.dataset.pickAction === 'select-all';
- body.querySelectorAll('.backup-pick-cb').forEach(cb => { cb.checked = all; });
- });
- });
-
- modal.classList.add('open');
- }
-
- async confirmBackupPick() {
- const modal = document.getElementById('backup-pick-modal');
- if (!modal) return;
- const selected = Array.from(modal.querySelectorAll('.backup-pick-cb:checked'))
- .map(cb => cb.value);
- if (!selected.length) {
- this.notify('Pick at least one thing to back up.', 'error');
- return;
- }
- this.closeAllModals();
-
- const apps = this.dashboard?.apps || [];
- const totalThings = apps.length + 1; // +1 for system
- const wantsSystem = selected.includes('__system__');
- const appSlugs = selected.filter(s => s !== '__system__');
-
- // Whole-fleet shortcut — `backup all` queues a single task and also
- // covers system, instead of N+1 separate tasks. Requires at least one
- // app so a system-only pick never collapses into "backup all".
- if (wantsSystem && apps.length > 0 && appSlugs.length === apps.length) {
- await this.runTask('libreportal backup all', 'backup', null);
- return;
- }
- if (wantsSystem) {
- await this.runTask('libreportal backup system', 'backup', null);
- }
- for (const slug of appSlugs) {
- await this.runTask(`libreportal backup app create ${slug}`, 'backup', slug);
- }
- }
/* ----- 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 => `
-
-
- ${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
-
-
- Migrate every app from this host
-
-
-
- ${(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))}
-
-
-
- Migrate
-
-
- `;
- }).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}
-
-
-
-
- Back up the destination's existing copy first
-
- Safety net: snapshot the current ${mode === 'app' ? this.escape(app) : 'app'} into your first
- enabled backup location (tagged pre-migrate) before wipe.
- ${collisions.length ? '' : 'No collision — nothing to back up.'}
-
-
-
-
-
-
- Rewrite host-bound URLs to this host
-
- Replaces CFG_*_URL, *_DOMAIN, *_HOSTNAME with this
- host's values. Uncheck only if you want the moved app to keep claiming the source's hostname.
-
-
-
-
- `;
- 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) {
@@ -2246,19 +663,6 @@ class BackupPage {
/* ----- Generic save handler ----- */
- async setLocationEnabled(idx, enabled) {
- const encoded = `CFG_BACKUP_LOC_${idx}_ENABLED=${enabled ? 'true' : 'false'}`;
- try {
- if (!window.tasksManager?.router) throw new Error('Task system not available');
- await window.tasksManager.router.routeAction('config_update', {
- changes: `'${encoded.replace(/'/g, "'\\''")}'`
- });
- this.notify(`${enabled ? 'Enabling' : 'Disabling'} this location…`, 'success');
- setTimeout(() => this.reloadAfterSave(), 2500);
- } catch (err) {
- this.notify(`Save failed: ${err.message || err}`, 'error');
- }
- }
async saveSection(sectionId) {
let scope;
@@ -2304,10 +708,6 @@ class BackupPage {
}
}
- async reloadAfterSave() {
- await this.refreshAll();
- this.render();
- }
notify(message, type) {
if (window.notificationSystem) {
diff --git a/containers/libreportal/frontend/components/backup/js/backup-retention-presets.js b/containers/libreportal/frontend/components/backup/js/backup-retention-presets.js
new file mode 100644
index 0000000..f23492d
--- /dev/null
+++ b/containers/libreportal/frontend/components/backup/js/backup-retention-presets.js
@@ -0,0 +1,135 @@
+// Auto-extracted from backup-page.js (verbatim) — augments BackupPage.prototype. Loaded after the base.
+Object.assign(BackupPage.prototype, {
+ enhanceConfigurationWithPresets() {
+ this.enhanceEngineDetailsButton();
+ const lastInput = document.querySelector('#config-section [name="CFG_BACKUP_KEEP_LAST"]');
+ if (!lastInput) return;
+
+ const section = lastInput.closest('.config-category');
+ if (!section || section.dataset.backupPresetEnhanced === '1') return;
+ section.dataset.backupPresetEnhanced = '1';
+
+ const fieldNames = [
+ 'CFG_BACKUP_KEEP_LAST',
+ 'CFG_BACKUP_KEEP_DAILY',
+ 'CFG_BACKUP_KEEP_WEEKLY',
+ 'CFG_BACKUP_KEEP_MONTHLY',
+ 'CFG_BACKUP_KEEP_YEARLY'
+ ];
+ const inputs = fieldNames
+ .map(n => section.querySelector(`[name="${n}"]`))
+ .filter(Boolean);
+ if (inputs.length < 5) return;
+
+ const wrappers = inputs.map(input => {
+ return input.closest('.config-field, .field-group, .form-group') || input.parentElement;
+ });
+
+ const extraCustomFields = ['CFG_BACKUP_PRUNE_AFTER_FORGET'];
+ extraCustomFields.forEach(name => {
+ const el = section.querySelector(`[name="${name}"]`);
+ if (el) {
+ const wrap = el.closest('.config-field, .field-group, .form-group') || el.parentElement;
+ if (wrap) wrappers.push(wrap);
+ }
+ });
+
+ const readVals = () => ({
+ last: inputs[0].value || '',
+ daily: inputs[1].value || '',
+ weekly: inputs[2].value || '',
+ monthly: inputs[3].value || '',
+ yearly: inputs[4].value || ''
+ });
+
+ const preset = backupRetentionDetectPreset(readVals());
+ const meta = BACKUP_RETENTION_PRESET_META[preset];
+ const presetOptions = this.retentionPresetOptions(preset, false);
+
+ const block = document.createElement('div');
+ block.className = 'backup-retention-preset-block';
+ block.innerHTML = `
+
+ Backup style ℹ️
+ ${presetOptions}
+
+ `;
+
+ const fieldsGrid = section.querySelector('.config-fields');
+ if (fieldsGrid) {
+ fieldsGrid.prepend(block);
+ } else {
+ section.prepend(block);
+ }
+
+ const applyVisibility = (presetKey) => {
+ const isCustom = presetKey === 'custom';
+ wrappers.forEach(w => { if (w) w.style.display = isCustom ? '' : 'none'; });
+ };
+ applyVisibility(preset);
+
+ const select = block.querySelector('[data-backup-retention-preset]');
+ const tooltipEl = block.querySelector('[data-retention-tooltip]');
+ select.addEventListener('change', () => {
+ const chosen = select.value;
+ if (tooltipEl) tooltipEl.title = BACKUP_RETENTION_PRESET_META[chosen]?.hint || '';
+ applyVisibility(chosen);
+ if (chosen === 'custom') return;
+ const p = BACKUP_RETENTION_PRESETS[chosen];
+ const map = { last: 0, daily: 1, weekly: 2, monthly: 3, yearly: 4 };
+ Object.entries(map).forEach(([k, i]) => {
+ inputs[i].value = p[k];
+ inputs[i].dispatchEvent(new Event('input', { bubbles: true }));
+ inputs[i].dispatchEvent(new Event('change', { bubbles: true }));
+ });
+ });
+ },
+ retentionPresetOptions(selected, includeInherit = false) {
+ const defaultKey = includeInherit ? 'inherit-global' : 'self-hosting';
+ const keys = Object.keys(BACKUP_RETENTION_PRESET_META)
+ .filter(k => k !== 'inherit-global' || includeInherit);
+ const ordered = [defaultKey, ...keys.filter(k => k !== defaultKey)];
+ return ordered.map(k => {
+ const base = BACKUP_RETENTION_PRESET_META[k].label;
+ const label = k === defaultKey ? `${base} (default)` : base;
+ return `${this.escape(label)} `;
+ }).join('');
+ },
+ applyRetentionPreset(selectEl) {
+ const block = selectEl.closest('[data-retention-prefix]');
+ const advanced = block?.nextElementSibling;
+ if (!block) return;
+ const prefix = block.dataset.retentionPrefix;
+ const allowInherit = block.dataset.retentionAllowInherit === '1';
+ const preset = selectEl.value;
+ const tooltipEl = block.querySelector('[data-retention-tooltip]');
+ if (tooltipEl) tooltipEl.title = BACKUP_RETENTION_PRESET_META[preset]?.hint || '';
+
+ if (preset === 'custom') {
+ if (advanced) advanced.hidden = false;
+ } else {
+ if (advanced) advanced.hidden = true;
+ const p = BACKUP_RETENTION_PRESETS[preset];
+ if (p) {
+ const setField = (suffix, value) => {
+ const el = document.querySelector(`[name="${prefix}${suffix}"]`);
+ if (el) {
+ el.value = value;
+ el.dispatchEvent(new Event('input', { bubbles: true }));
+ }
+ };
+ setField('KEEP_LAST', p.last);
+ setField('KEEP_DAILY', p.daily);
+ setField('KEEP_WEEKLY', p.weekly);
+ setField('KEEP_MONTHLY', p.monthly);
+ setField('KEEP_YEARLY', p.yearly);
+ }
+ }
+
+ // Keep CUSTOM_RETENTION in sync with the preset (location scope only).
+ if (allowInherit) {
+ const cr = block.querySelector(`[name="${prefix}CUSTOM_RETENTION"]`);
+ if (cr) cr.value = preset === 'inherit-global' ? 'false' : 'true';
+ }
+ },
+});
diff --git a/containers/libreportal/frontend/components/backup/js/backup-snapshot-actions.js b/containers/libreportal/frontend/components/backup/js/backup-snapshot-actions.js
new file mode 100644
index 0000000..3d5c605
--- /dev/null
+++ b/containers/libreportal/frontend/components/backup/js/backup-snapshot-actions.js
@@ -0,0 +1,147 @@
+// Auto-extracted from backup-page.js (verbatim) — augments BackupPage.prototype. Loaded after the base.
+Object.assign(BackupPage.prototype, {
+ openRestoreModal(app, locIdx, snapshot) {
+ const locName = this.locName(locIdx);
+ const modal = document.getElementById('backup-restore-modal');
+ const body = document.getElementById('backup-restore-modal-body');
+ if (!modal || !body) return;
+ body.innerHTML = `
+ Restore ${this.escape(app)} from backup ${this.escape(snapshot)} at ${this.escape(locName)} ?
+ The app will be stopped, its folder wiped, the backup restored in place, then the app started again. App-specific pre/post-restore hooks run if present.
+ `;
+ modal.dataset.app = app;
+ modal.dataset.locIdx = locIdx;
+ modal.dataset.snapshot = snapshot;
+ modal.classList.add('open');
+ },
+ openDeleteModal(app, locIdx, snapshot) {
+ const locName = this.locName(locIdx);
+ const modal = document.getElementById('backup-delete-modal');
+ const body = document.getElementById('backup-delete-modal-body');
+ if (!modal || !body) return;
+ body.innerHTML = `
+ Delete backup ${this.escape(snapshot)} for ${this.escape(app)} from ${this.escape(locName)} ?
+ This cannot be undone. Append-only locations will reject the operation.
+ `;
+ modal.dataset.app = app;
+ modal.dataset.locIdx = locIdx;
+ modal.dataset.snapshot = snapshot;
+ modal.classList.add('open');
+ },
+ async confirmRestore() {
+ const modal = document.getElementById('backup-restore-modal');
+ const { app, locIdx, snapshot } = modal.dataset;
+ this.closeAllModals();
+ await this.runTask(`libreportal restore app start ${app} ${snapshot} ${locIdx}`, 'restore', app);
+ },
+ async confirmDelete() {
+ const modal = document.getElementById('backup-delete-modal');
+ const { app, locIdx, snapshot } = modal.dataset;
+ this.closeAllModals();
+ await this.runTask(`libreportal backup app delete ${app} ${locIdx}:${snapshot}`, 'backup', app);
+ },
+ async runBackupAllApps() {
+ await this.runTask(`libreportal backup all`, 'backup', null);
+ },
+ async runBackupSystem() {
+ await this.runTask(`libreportal backup system`, 'backup', null);
+ },
+ async confirmRestoreSystem() {
+ if (!confirm('Restore the latest system-config snapshot?\n\nIt is restored into a staging folder (not applied) — review it, then copy what you need (e.g. backup-location credentials, login, settings) into the live config. Your running config is NOT overwritten.')) return;
+ await this.runTask(`libreportal restore system`, 'restore', null);
+ },
+ openBackupPickModal(opts = {}) {
+ const modal = document.getElementById('backup-pick-modal');
+ const body = document.getElementById('backup-pick-modal-body');
+ if (!modal || !body) return;
+
+ const apps = this.dashboard?.apps || [];
+ const sys = this.dashboard?.system || {};
+ const preTickSystem = !!opts.preTickSystem;
+ const preTickApps = new Set(opts.preTickApps || []);
+
+ // System row at the top — uses the LibrePortal icon to match the
+ // dashboard tile. Then every installed app, alphabetical.
+ const sortedApps = apps.slice().sort((a, b) => a.app.localeCompare(b.app));
+
+ const row = (key, iconSrc, label, sub, checked) => `
+
+
+
+
+
${this.escape(label)}
+
${this.escape(sub)}
+
+
+ `;
+
+ const sysSub = sys.latest_snapshot
+ ? 'Last backed up ' + this.formatRelative(sys.latest_time)
+ : 'No backup yet';
+
+ const rows = [
+ row('__system__', '/core/icons/apps/libreportal.svg', 'Configs', sysSub, preTickSystem),
+ ...sortedApps.map(app => {
+ const meta = this.appMeta(app.app);
+ const sub = app.latest_snapshot
+ ? 'Last backed up ' + this.formatRelative(app.latest_time)
+ : 'No backup yet';
+ return row(app.app, meta.icon, meta.displayName, sub, preTickApps.has(app.app));
+ })
+ ].join('');
+
+ body.innerHTML = `
+
+ Pick what to snapshot. Each selection runs in the background and shows up on the Tasks page.
+
+
+ ${rows}
+ `;
+
+ // The select-all / clear links live inside the modal body so we wire
+ // them once here per open (they get rebuilt every open, no listener
+ // leak).
+ body.querySelectorAll('[data-pick-action]').forEach(a => {
+ a.addEventListener('click', (e) => {
+ e.preventDefault();
+ const all = a.dataset.pickAction === 'select-all';
+ body.querySelectorAll('.backup-pick-cb').forEach(cb => { cb.checked = all; });
+ });
+ });
+
+ modal.classList.add('open');
+ },
+ async confirmBackupPick() {
+ const modal = document.getElementById('backup-pick-modal');
+ if (!modal) return;
+ const selected = Array.from(modal.querySelectorAll('.backup-pick-cb:checked'))
+ .map(cb => cb.value);
+ if (!selected.length) {
+ this.notify('Pick at least one thing to back up.', 'error');
+ return;
+ }
+ this.closeAllModals();
+
+ const apps = this.dashboard?.apps || [];
+ const totalThings = apps.length + 1; // +1 for system
+ const wantsSystem = selected.includes('__system__');
+ const appSlugs = selected.filter(s => s !== '__system__');
+
+ // Whole-fleet shortcut — `backup all` queues a single task and also
+ // covers system, instead of N+1 separate tasks. Requires at least one
+ // app so a system-only pick never collapses into "backup all".
+ if (wantsSystem && apps.length > 0 && appSlugs.length === apps.length) {
+ await this.runTask('libreportal backup all', 'backup', null);
+ return;
+ }
+ if (wantsSystem) {
+ await this.runTask('libreportal backup system', 'backup', null);
+ }
+ for (const slug of appSlugs) {
+ await this.runTask(`libreportal backup app create ${slug}`, 'backup', slug);
+ }
+ },
+});
diff --git a/containers/libreportal/frontend/components/backup/js/backup-snapshots.js b/containers/libreportal/frontend/components/backup/js/backup-snapshots.js
new file mode 100644
index 0000000..bf8067d
--- /dev/null
+++ b/containers/libreportal/frontend/components/backup/js/backup-snapshots.js
@@ -0,0 +1,144 @@
+// Auto-extracted from backup-page.js (verbatim) — augments BackupPage.prototype. Loaded after the base.
+Object.assign(BackupPage.prototype, {
+ renderSnapshots() {
+ const list = document.getElementById('backup-snapshot-list');
+ if (!list) return;
+
+ const filter = (document.getElementById('backup-snapshot-filter')?.value || '').toLowerCase();
+ const locFilter = document.getElementById('backup-snapshot-repo')?.value || '';
+
+ const locNameByIdx = {};
+ (this.locations?.locations || []).forEach(l => { locNameByIdx[l.idx] = l.name; });
+
+ const rows = [];
+ Object.entries(this.snapshotsByLoc).forEach(([locIdx, data]) => {
+ if (locFilter && String(locFilter) !== String(locIdx)) return;
+ const snaps = Array.isArray(data?.snapshots) ? data.snapshots : [];
+ snaps.forEach(s => {
+ const app = (s.tags || []).map(t => /^app=/.test(t) ? t.slice(4) : null).find(Boolean) || '—';
+ rows.push({
+ app,
+ host: s.hostname || '—',
+ locIdx,
+ locName: locNameByIdx[locIdx] || `Loc ${locIdx}`,
+ time: s.time,
+ id: s.short_id || (s.id || '').slice(0, 8),
+ tags: Array.isArray(s.tags) ? s.tags : [],
+ paths: Array.isArray(s.paths) ? s.paths : [],
+ });
+ });
+ });
+
+ rows.sort((a, b) => String(b.time).localeCompare(String(a.time)));
+
+ const filtered = filter ? rows.filter(r =>
+ r.app.toLowerCase().includes(filter) ||
+ r.host.toLowerCase().includes(filter) ||
+ r.id.toLowerCase().includes(filter) ||
+ r.locName.toLowerCase().includes(filter)
+ ) : rows;
+
+ if (!filtered.length) {
+ list.innerHTML = `No backups yet.
`;
+ return;
+ }
+
+ list.innerHTML = filtered.map(r => this._renderSnapshotRow(r)).join('');
+ },
+ // Render one global-Snapshots-tab backup as the same .task-item card
+ // the per-app Backups tab uses, so the two surfaces look identical.
+ // Extras vs the per-app card:
+ // - An app-name chip (because the global list isn't scoped to one app)
+ // that doubles as a deep-link to /app//backups?snapshot=
+ // - A Delete action alongside Restore (per-app card only offers
+ // Restore — delete lives in the global view)
+ _renderSnapshotRow(r) {
+ const hasApp = r.app && r.app !== '—';
+ const deepLink = hasApp
+ ? `/app/${encodeURIComponent(r.app)}/backups?snapshot=${encodeURIComponent(r.id)}`
+ : null;
+ const iconUrl = hasApp ? `/core/icons/apps/${encodeURIComponent(r.app)}.svg` : '/core/icons/apps/libreportal.svg';
+ const displayName = hasApp ? this.appMeta(r.app).displayName : 'Configs';
+ const appChip = hasApp
+ ? `${this.escape(displayName)} `
+ : `${this.escape(displayName)} `;
+ const sid = String(r.id);
+
+ // Restic stamps app=/host=/engine= into the snapshot tags; surface
+ // those as their own fields and keep any remaining tags as chips.
+ const tagMap = {};
+ (r.tags || []).forEach(t => { const i = t.indexOf('='); if (i > 0) tagMap[t.slice(0, i)] = t.slice(i + 1); });
+ const engineName = tagMap.engine ? this.engineDisplayName(tagMap.engine) : null;
+ const otherTags = (r.tags || []).filter(t => !/^(app|host|engine|paths?)=/.test(t));
+ const field = (label, valueHtml) =>
+ `${label} ${valueHtml}
`;
+
+ return `
+ `;
+ },
+ _fmtShortTime(iso) {
+ if (!iso) return '';
+ const d = new Date(iso);
+ if (isNaN(d.getTime())) return String(iso);
+ return d.toLocaleString();
+ },
+ _fmtFullTime(iso) {
+ if (!iso) return '';
+ const d = new Date(iso);
+ if (isNaN(d.getTime())) return String(iso);
+ return d.toString();
+ },
+ _fmtNiceTime(iso) {
+ if (!iso) return '';
+ const d = new Date(iso);
+ if (isNaN(d.getTime())) return String(iso);
+ return d.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' });
+ },
+});
diff --git a/containers/libreportal/frontend/components/backup/js/backup-ssh-key.js b/containers/libreportal/frontend/components/backup/js/backup-ssh-key.js
new file mode 100644
index 0000000..be6f2e7
--- /dev/null
+++ b/containers/libreportal/frontend/components/backup/js/backup-ssh-key.js
@@ -0,0 +1,49 @@
+// Auto-extracted from backup-page.js (verbatim) — augments BackupPage.prototype. Loaded after the base.
+Object.assign(BackupPage.prototype, {
+ renderBackupSshKeyCard(l) {
+ const idx = l.idx;
+ const hasKey = l.ssh_key_exists === true;
+ const pub = l.ssh_public_key || '';
+ const body = hasKey ? `
+ Add this public key to the remote server's ~/.ssh/authorized_keys:
+
+
+ Copy public key
+ Delete key
+
` : `
+ Paste an existing private key, or generate one and we'll show the public key to add on the remote.
+
+
+ Save key
+ Generate keypair
+
`;
+ return `
+
+
+ SSH key
+ ${hasKey ? '✓ Key configured' : 'No key yet'}
+
+ ${body}
+
`;
+ },
+ async saveBackupSshKey(idx) {
+ const card = document.querySelector(`.backup-ssh-key-card[data-loc="${idx}"]`);
+ const key = (card?.querySelector('.backup-ssh-keyinput')?.value || '').trim();
+ if (!key) { this.notify('Paste a private key first', 'error'); return; }
+ const b64 = btoa(unescape(encodeURIComponent(key + '\n')));
+ await this.runTask(`libreportal backup location ssh-key-set ${idx} ${b64}`, 'backup', null);
+ },
+ async generateBackupSshKey(idx) {
+ await this.runTask(`libreportal backup location ssh-key-generate ${idx}`, 'backup', null);
+ },
+ async deleteBackupSshKey(idx) {
+ if (!confirm("Delete this location's SSH key? Backups here will fail until a new key is set and added on the remote.")) return;
+ await this.runTask(`libreportal backup location ssh-key-delete ${idx}`, 'backup', null);
+ },
+ async copyBackupSshKey(idx) {
+ const loc = (this.locations?.locations || []).find(l => l.idx === idx);
+ const pub = loc?.ssh_public_key || '';
+ try { await navigator.clipboard.writeText(pub); this.notify('Public key copied', 'success'); }
+ catch { this.notify('Copy failed — select the text and copy manually', 'error'); }
+ },
+});