From 82989069e2e28cdae283e3ca766ae0d224f7dd26 Mon Sep 17 00:00:00 2001 From: librelad Date: Sat, 30 May 2026 14:02:45 +0100 Subject: [PATCH] refactor(backup): decompose backup-page god-file into 13 responsibility files Faithful prototype-augment split of backup-page.js (2353->753 line base) into fetch-client, dashboard, snapshots, locations, location-fields, ssh-key, retention-presets, configuration, engine-details, location-modal, snapshot-actions, migrate (+ the earlier cron-schedule). Methods relocated verbatim (mechanical sed/awk extraction, no logic change); all augment BackupPage.prototype and load after the base via the ordered kernel loader. Verified: all 99 original methods present exactly once across base+clusters, no duplicates, all 14 files node --check clean. Co-Authored-By: Claude Opus 4.8 Signed-off-by: librelad --- .../frontend/components/backup/index.js | 15 +- .../backup/js/backup-configuration.js | 120 ++ .../components/backup/js/backup-dashboard.js | 126 ++ .../backup/js/backup-engine-details.js | 84 + .../backup/js/backup-fetch-client.js | 79 + .../backup/js/backup-location-fields.js | 162 ++ .../backup/js/backup-location-modal.js | 125 ++ .../components/backup/js/backup-locations.js | 283 +++ .../components/backup/js/backup-migrate.js | 182 ++ .../components/backup/js/backup-page.js | 1600 ----------------- .../backup/js/backup-retention-presets.js | 135 ++ .../backup/js/backup-snapshot-actions.js | 147 ++ .../components/backup/js/backup-snapshots.js | 144 ++ .../components/backup/js/backup-ssh-key.js | 49 + 14 files changed, 1650 insertions(+), 1601 deletions(-) create mode 100644 containers/libreportal/frontend/components/backup/js/backup-configuration.js create mode 100644 containers/libreportal/frontend/components/backup/js/backup-dashboard.js create mode 100644 containers/libreportal/frontend/components/backup/js/backup-engine-details.js create mode 100644 containers/libreportal/frontend/components/backup/js/backup-fetch-client.js create mode 100644 containers/libreportal/frontend/components/backup/js/backup-location-fields.js create mode 100644 containers/libreportal/frontend/components/backup/js/backup-location-modal.js create mode 100644 containers/libreportal/frontend/components/backup/js/backup-locations.js create mode 100644 containers/libreportal/frontend/components/backup/js/backup-migrate.js create mode 100644 containers/libreportal/frontend/components/backup/js/backup-retention-presets.js create mode 100644 containers/libreportal/frontend/components/backup/js/backup-snapshot-actions.js create mode 100644 containers/libreportal/frontend/components/backup/js/backup-snapshots.js create mode 100644 containers/libreportal/frontend/components/backup/js/backup-ssh-key.js 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)} +
+
+ +
+ `; + }, + 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} +
+
+ +
+ `; + }, +}); 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 ? `${propsHTML}
    ` : ''} + ${featsHTML ? `
    Highlights
      ${featsHTML}
    ` : ''} + ${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 ` +
    + + ${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 ` + + `; + }, + formSelect(name, label, value, options) { + const escLabel = this.escape(label); + const opts = options.map(([v, lbl]) => ``).join(''); + return ` + + `; + }, + formToggle(name, label, checked) { + const escLabel = this.escape(label); + return ` + + `; + }, + 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 ` + + `; + }, + 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 = `` + + locs.filter(l => l.enabled).map(l => ``).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 ` +
    +
    +
    + ${this.typeIcon(l.type)} + ${this.escape(l.name)} + ${this.escape(l.type)} + ${this.escape(this.engineDisplayName(l.engine))} + ${l.append_only ? 'append-only' : ''} + ${statusMeta.icon} ${statusMeta.label} + · + ${snapCount} backup${snapCount === 1 ? '' : 's'} + · + ${size} +
    + + + + + + + +
    +
    + ${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) => ` + `; + + 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)} +
    +
    +
    +
    +
    +
    + + +
    + `; + }, + 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 ``; + }) + .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 => ` +
    +
    +

    ${this.escape(loc.name || 'Location')}

    + ${(loc.hosts || []).length} other host${loc.hosts.length === 1 ? '' : 's'} backing up here +
    + ${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 +
    + +
    +
    + ${(host.apps || []).map(app => { + const collide = installed.has(app.slug); + return ` +
    +
    + + ${this.escape(app.slug)} + ${collide ? `` : ''} + + + ${app.snapshots} snapshot${app.snapshots === 1 ? '' : 's'} · latest ${this.escape(this.formatRelativeTime(app.latest_date))} + +
    + +
    + `; + }).join('')} +
    +
    + `; + }).join('')} +
    + `).join(''); + + body.innerHTML = html; + }, + formatRelativeTime(iso) { + if (!iso) return 'never'; + const t = Date.parse(iso); + if (!t) return iso; + const diff = Date.now() - t; + const minute = 60_000, hour = 60 * minute, day = 24 * hour; + if (diff < hour) return `${Math.max(1, Math.round(diff / minute))} min ago`; + if (diff < day) return `${Math.round(diff / hour)} h ago`; + if (diff < 7 * day) return `${Math.round(diff / day)} d ago`; + return new Date(t).toISOString().slice(0, 10); + }, + openMigrateModal({ mode, locIdx, host, app }) { + const modal = document.getElementById('backup-migrate-modal'); + const body = document.getElementById('backup-migrate-modal-body'); + if (!modal || !body) return; + + const dest = this.migrate?.destination || {}; + const installed = new Set(dest.installed_apps || []); + const running = new Set(dest.running_apps || []); + const locName = this.locName(locIdx); + + // App-mode: one specific app. Host-mode: every app from the host. + let targetApps = []; + if (mode === 'app') { + targetApps = [app]; + } else { + const loc = (this.migrate?.locations || []).find(l => l.idx === locIdx); + const h = (loc?.hosts || []).find(x => x.hostname === host); + targetApps = (h?.apps || []).map(a => a.slug); + } + const collisions = targetApps.filter(a => installed.has(a)); + const collisionsRunning = collisions.filter(a => running.has(a)); + + const intro = mode === 'app' + ? `

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

    ` + : `

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

    `; + + let collisionNote = ''; + if (collisions.length) { + collisionNote = ` +

    + ⚠ Already installed here: ${collisions.map(c => `${this.escape(c)}`).join(', ')}. + These will be replaced. + ${collisionsRunning.length ? `Currently running: ${collisionsRunning.map(c => `${this.escape(c)}`).join(', ')} — will be stopped first.` : ''} +

    + `; + } + + body.innerHTML = ` + ${intro} + ${collisionNote} +
    + + +
    + `; + modal.dataset.mode = mode; + modal.dataset.locIdx = String(locIdx); + modal.dataset.host = host; + modal.dataset.app = app || ''; + modal.classList.add('open'); + }, + async confirmMigrate() { + const modal = document.getElementById('backup-migrate-modal'); + if (!modal) return; + const { mode, locIdx, host, app } = modal.dataset; + const preBackup = document.getElementById('migrate-opt-pre-backup')?.checked; + const rewrite = document.getElementById('migrate-opt-rewrite-urls')?.checked; + + // The bash CLI defaults are: pre-backup ON, URL rewrite ON. Opt-out flags + // only get appended when the user un-ticks; matches the kernel's defaults. + const opts = []; + if (preBackup === false) opts.push('--no-pre-backup'); + if (rewrite === false) opts.push('--keep-urls'); + const optStr = opts.length ? ' ' + opts.join(' ') : ''; + + this.closeAllModals(); + if (mode === 'app') { + await this.runTask( + `libreportal restore migrate app ${app} ${host} ${locIdx}${optStr}`, + 'restore', app); + } else { + await this.runTask( + `libreportal restore migrate system ${host} ${locIdx}${optStr}`, + 'restore', null); + } + }, +}); 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)} -
    -
    - -
    - `; - } - - 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} -
    -
    - -
    - `; - } - 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 = `` + - locs.filter(l => l.enabled).map(l => ``).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 ` -
    -
    -
    - ${this.typeIcon(l.type)} - ${this.escape(l.name)} - ${this.escape(l.type)} - ${this.escape(this.engineDisplayName(l.engine))} - ${l.append_only ? 'append-only' : ''} - ${statusMeta.icon} ${statusMeta.label} - · - ${snapCount} backup${snapCount === 1 ? '' : 's'} - · - ${size} -
    - - - - - - - -
    -
    - ${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) => ` - `; - 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)} -
    -
    -
    -
    -
    -
    - - -
    - `; - } - - 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 ``; - }) - .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 ` -
    -
    -
    - - ${this.escape(this.formatRelative(r.time))} - ${appChip} - ${this.escape(r.locName)} - ${this.escape(this._fmtShortTime(r.time))} - ${this.escape(sid)} -
    -
    - - - -
    -
    -
    -
    -
    - ${field('App', `${this.escape(displayName)}${hasApp ? ` ${this.escape(r.app)}` : ''}`)} - ${field('Host', this.escape(r.host))} - ${field('Location', `${this.escape(r.locName)}`)} - ${field('Backup ID', `${this.escape(sid)}`)} - ${field('When', `${this.escape(this._fmtNiceTime(r.time))}`)} - ${engineName ? field('Engine', this.escape(engineName)) : ''} -
    - ${otherTags.length ? ` -
    - Tags -
    ${otherTags.map(t => `${this.escape(t)}`).join('')}
    -
    ` : ''} - ${r.paths && r.paths.length ? ` -
    - Paths -
      ${r.paths.map(p => `
    • ${this.escape(p)}
    • `).join('')}
    -
    ` : ''} -
    -
    -
    `; - } - - _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 = ` - - `; - - 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 `; - }).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 ` -
    - - ${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 ` - - `; - } - - formSelect(name, label, value, options) { - const escLabel = this.escape(label); - const opts = options.map(([v, lbl]) => ``).join(''); - return ` - - `; - } - - formToggle(name, label, checked) { - const escLabel = this.escape(label); - return ` - - `; - } /* 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 ? `${propsHTML}
    ` : ''} - ${featsHTML ? `
    Highlights
      ${featsHTML}
    ` : ''} - ${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 ` - - `; - } - - 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:

    - -
    - - -
    ` : ` -

    Paste an existing private key, or generate one and we'll show the public key to add on the remote.

    - -
    - - -
    `; - 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) => ` - - `; - - 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 => ` -
    -
    -

    ${this.escape(loc.name || 'Location')}

    - ${(loc.hosts || []).length} other host${loc.hosts.length === 1 ? '' : 's'} backing up here -
    - ${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 -
    - -
    -
    - ${(host.apps || []).map(app => { - const collide = installed.has(app.slug); - return ` -
    -
    - - ${this.escape(app.slug)} - ${collide ? `` : ''} - - - ${app.snapshots} snapshot${app.snapshots === 1 ? '' : 's'} · latest ${this.escape(this.formatRelativeTime(app.latest_date))} - -
    - -
    - `; - }).join('')} -
    -
    - `; - }).join('')} -
    - `).join(''); - - body.innerHTML = html; - } - - formatRelativeTime(iso) { - if (!iso) return 'never'; - const t = Date.parse(iso); - if (!t) return iso; - const diff = Date.now() - t; - const minute = 60_000, hour = 60 * minute, day = 24 * hour; - if (diff < hour) return `${Math.max(1, Math.round(diff / minute))} min ago`; - if (diff < day) return `${Math.round(diff / hour)} h ago`; - if (diff < 7 * day) return `${Math.round(diff / day)} d ago`; - return new Date(t).toISOString().slice(0, 10); - } - - openMigrateModal({ mode, locIdx, host, app }) { - const modal = document.getElementById('backup-migrate-modal'); - const body = document.getElementById('backup-migrate-modal-body'); - if (!modal || !body) return; - - const dest = this.migrate?.destination || {}; - const installed = new Set(dest.installed_apps || []); - const running = new Set(dest.running_apps || []); - const locName = this.locName(locIdx); - - // App-mode: one specific app. Host-mode: every app from the host. - let targetApps = []; - if (mode === 'app') { - targetApps = [app]; - } else { - const loc = (this.migrate?.locations || []).find(l => l.idx === locIdx); - const h = (loc?.hosts || []).find(x => x.hostname === host); - targetApps = (h?.apps || []).map(a => a.slug); - } - const collisions = targetApps.filter(a => installed.has(a)); - const collisionsRunning = collisions.filter(a => running.has(a)); - - const intro = mode === 'app' - ? `

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

    ` - : `

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

    `; - - let collisionNote = ''; - if (collisions.length) { - collisionNote = ` -

    - ⚠ Already installed here: ${collisions.map(c => `${this.escape(c)}`).join(', ')}. - These will be replaced. - ${collisionsRunning.length ? `Currently running: ${collisionsRunning.map(c => `${this.escape(c)}`).join(', ')} — will be stopped first.` : ''} -

    - `; - } - - body.innerHTML = ` - ${intro} - ${collisionNote} -
    - - -
    - `; - modal.dataset.mode = mode; - modal.dataset.locIdx = String(locIdx); - modal.dataset.host = host; - modal.dataset.app = app || ''; - modal.classList.add('open'); - } - - async confirmMigrate() { - const modal = document.getElementById('backup-migrate-modal'); - if (!modal) return; - const { mode, locIdx, host, app } = modal.dataset; - const preBackup = document.getElementById('migrate-opt-pre-backup')?.checked; - const rewrite = document.getElementById('migrate-opt-rewrite-urls')?.checked; - - // The bash CLI defaults are: pre-backup ON, URL rewrite ON. Opt-out flags - // only get appended when the user un-ticks; matches the kernel's defaults. - const opts = []; - if (preBackup === false) opts.push('--no-pre-backup'); - if (rewrite === false) opts.push('--keep-urls'); - const optStr = opts.length ? ' ' + opts.join(' ') : ''; - - this.closeAllModals(); - if (mode === 'app') { - await this.runTask( - `libreportal restore migrate app ${app} ${host} ${locIdx}${optStr}`, - 'restore', app); - } else { - await this.runTask( - `libreportal restore migrate system ${host} ${locIdx}${optStr}`, - 'restore', null); - } - } async runTask(command, type, app) { if (!this.taskManager) { @@ -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 = ` + + `; + + 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 ``; + }).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) => ` + + `; + + 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 ` +
    +
    +
    + + ${this.escape(this.formatRelative(r.time))} + ${appChip} + ${this.escape(r.locName)} + ${this.escape(this._fmtShortTime(r.time))} + ${this.escape(sid)} +
    +
    + + + +
    +
    +
    +
    +
    + ${field('App', `${this.escape(displayName)}${hasApp ? ` ${this.escape(r.app)}` : ''}`)} + ${field('Host', this.escape(r.host))} + ${field('Location', `${this.escape(r.locName)}`)} + ${field('Backup ID', `${this.escape(sid)}`)} + ${field('When', `${this.escape(this._fmtNiceTime(r.time))}`)} + ${engineName ? field('Engine', this.escape(engineName)) : ''} +
    + ${otherTags.length ? ` +
    + Tags +
    ${otherTags.map(t => `${this.escape(t)}`).join('')}
    +
    ` : ''} + ${r.paths && r.paths.length ? ` +
    + Paths +
      ${r.paths.map(p => `
    • ${this.escape(p)}
    • `).join('')}
    +
    ` : ''} +
    +
    +
    `; + }, + _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:

    + +
    + + +
    ` : ` +

    Paste an existing private key, or generate one and we'll show the public key to add on the remote.

    + +
    + + +
    `; + 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'); } + }, +});