// Backup page controller — restic-engine UI. // Reads JSON snapshots written by scripts/webui/data/generators/backup/* and // dispatches actions back into the task system (which calls bash CLI). // Retention presets — pick the persona that matches you. Each maps to the // five underlying restic --keep-* values. "Custom" reveals the raw fields. const BACKUP_RETENTION_PRESETS = { 'inherit-global': { last: '', daily: '', weekly: '', monthly: '', yearly: '' }, 'self-hosting': { last: '', daily: '30', weekly: '', monthly: '', yearly: '' }, 'personal': { last: '', daily: '30', weekly: '', monthly: '6', yearly: '' }, 'enterprise': { last: '', daily: '30', weekly: '', monthly: '12', yearly: '5' } }; const BACKUP_RETENTION_PRESET_META = { 'inherit-global': { label: 'Inherit global retention', hint: 'Use whatever the Configuration tab specifies. Pick something else here only when this location needs a different policy.' }, 'self-hosting': { label: 'Self-hosting', hint: '30 days of daily backups. Plenty for a homelab — covers accidental deletes and app screw-ups.' }, 'personal': { label: 'Personal', hint: '30 days of daily backups plus 6 monthly snapshots. Good for personal data where "what did this look like last summer" matters.' }, 'enterprise': { label: 'Enterprise', hint: '30 daily + 12 monthly + 5 yearly. Compliance-style retention with multi-year history.' }, 'custom': { label: 'Custom…', hint: 'Define each retention tier yourself.' } }; // Per-location field metadata. Configs.json doesn't carry titles for // CFG_BACKUP_LOC_N_* (locations are dynamic), so we provide them inline. // ConfigShared.generateField uses TITLE + key-based widget heuristics; the // regexes in config-options.js / config-shared.js already cover _TYPE, // _KEEP_*, _SECRET_KEY, _ACCOUNT_KEY for the right widgets. const BACKUP_LOC_FIELD_DEFS = { NAME: { title: 'Friendly name', description: 'Shown in lists and on the dashboard.' }, ENABLED: { title: 'Enabled', description: 'Push backups to this location.' }, ENGINE: { title: 'Engine', description: 'Backup engine used at this location.' }, TYPE: { title: 'Type', description: 'Backend the engine uses to talk to this location.' }, PATH_MODE: { title: 'Path Mode', description: 'Automatic uses the Default Backup Location from the Backup Engine config (one subfolder per location). Pick Custom to use a specific path (e.g. an attached drive or a NAS mount).' }, PATH: { title: 'Custom path', description: 'Filesystem path on this server. Used only when Path is set to Custom.' }, URI: { title: 'Repository URI (override)', description: 'Custom restic URI — leave blank to build from the fields below.' }, SSH_USER: { title: 'SSH user', description: '' }, SSH_HOST: { title: 'SSH host', description: '' }, SSH_PORT: { title: 'SSH port', description: '' }, SSH_PATH: { title: 'SSH remote path', description: 'Path on the remote host where the repo lives.' }, SSH_AUTH: { title: 'SSH authentication', description: 'Key auth uses ~/.ssh/id_rsa on this host. Password mode pipes via sshpass — restic + borg only; kopia requires keys.' }, SSH_PASS: { title: 'SSH password', description: 'Used only when SSH authentication is set to Password.' }, S3_ACCESS_KEY: { title: 'S3 access key', description: '' }, S3_SECRET_KEY: { title: 'S3 secret', description: '' }, B2_ACCOUNT_ID: { title: 'B2 account ID', description: '' }, B2_ACCOUNT_KEY: { title: 'B2 account key', description: '' }, APPEND_ONLY: { title: 'Append-only', description: 'Ransomware-safe — refuse forget/prune for this location even if LibrePortal itself is compromised. Trades off automatic retention cleanup.' }, CUSTOM_RETENTION: { title: 'Use custom retention', description: 'Otherwise this location inherits the global retention.' }, KEEP_LAST: { title: 'Keep last', description: 'Snapshots to always retain.' }, KEEP_DAILY: { title: 'Keep daily', description: 'One snapshot per day for this many days.' }, KEEP_WEEKLY: { title: 'Keep weekly', description: 'One snapshot per week for this many weeks.' }, KEEP_MONTHLY: { title: 'Keep monthly', description: 'One snapshot per month for this many months.' }, KEEP_YEARLY: { title: 'Keep yearly', description: 'One snapshot per year for this many years.' } }; // Fallback for the per-type field schema. The live source is the generator- // emitted data/backup/generated/schema.json (loaded into this.locSchema and // read via locFieldsForType); this map is only used if that fetch fails. // Type leads each list (it shapes the rest of the form); ENGINE stays in the // list but locFieldGroups folds it into the Advanced tab. const BACKUP_LOC_FIELDS_BY_TYPE = { local: ['TYPE', 'NAME', 'ENGINE', 'PATH_MODE', 'PATH', 'APPEND_ONLY'], sftp: ['TYPE', 'NAME', 'ENGINE', 'SSH_USER', 'SSH_HOST', 'SSH_PORT', 'SSH_PATH', 'SSH_AUTH', 'SSH_PASS', 'URI', 'APPEND_ONLY'], rest: ['TYPE', 'NAME', 'ENGINE', 'URI', 'APPEND_ONLY'], s3: ['TYPE', 'NAME', 'ENGINE', 'URI', 'S3_ACCESS_KEY', 'S3_SECRET_KEY', 'APPEND_ONLY'], b2: ['TYPE', 'NAME', 'ENGINE', 'URI', 'B2_ACCOUNT_ID', 'B2_ACCOUNT_KEY', 'APPEND_ONLY'], gs: ['TYPE', 'NAME', 'ENGINE', 'URI', 'APPEND_ONLY'], azure: ['TYPE', 'NAME', 'ENGINE', 'URI', 'APPEND_ONLY'], rclone: ['TYPE', 'NAME', 'ENGINE', 'URI', 'APPEND_ONLY'] }; // Suffixes that live in the editor's "Advanced" tab. configs.json can flag // more via a **ADVANCED** comment marker; this set keeps the known overrides // advanced even on legacy location.configs that predate the marker. Engine is // here too — the system picks a sensible default, so most users never touch it. const LOC_ADVANCED_SUFFIXES = new Set(['ENGINE', 'URI', 'SSH_PORT', 'APPEND_ONLY']); function backupRetentionDetectPreset(values, includeInherit = false) { const norm = (v) => (v == null ? '' : String(v).trim()); for (const [key, p] of Object.entries(BACKUP_RETENTION_PRESETS)) { if (key === 'inherit-global' && !includeInherit) continue; if (norm(values.last) === norm(p.last) && norm(values.daily) === norm(p.daily) && norm(values.weekly) === norm(p.weekly) && norm(values.monthly) === norm(p.monthly) && norm(values.yearly) === norm(p.yearly)) { return key; } } return 'custom'; } class BackupPage { constructor() { this.currentTab = 'dashboard'; this.dashboard = null; this.locations = null; this.snapshotsByLoc = {}; this.expandedLocs = new Set(); this.engines = []; // [{id,name,supported_types}, ...] — fetched once this.taskManager = (typeof TaskManager !== 'undefined') ? new TaskManager() : null; this.eventBound = false; } async init() { this.currentTab = this.parseTabFromUrl() || this.currentTab; this.applyActiveTabUi(this.currentTab); this.bindEvents(); await this.refreshAll(); await window.Dismissible?.load(); this.render(); this.updatePageHeader(); this.updatePrimaryAction(); } /* Read the active tab slug from window.location, supporting both /backup?=dashboard (the legacy libreportal ?= form used on /config) and /backup?backup=dashboard (standard query string) so links from either source resolve correctly. */ parseTabFromUrl() { const allowed = new Set(['dashboard', 'backups', 'locations', 'migrate', 'configuration']); // Path-based: /backup/ (bare /backup → default tab). const seg = window.location.pathname.replace(/^\/backup\/?/, '').split('/')[0]; if (seg && allowed.has(seg)) return seg; // Legacy ?= / ?backup= / ?tab= for old links. const search = window.location.search || ''; const legacy = search.match(/\?=([^&]+)/); if (legacy && allowed.has(legacy[1])) return legacy[1]; const params = new URLSearchParams(search); const q = params.get('backup') || params.get('tab'); if (q && allowed.has(q)) return q; return null; } /* Toggle the sidebar .active class + panel visibility without going through switchTab's URL-update path (used on initial render and browser back/forward). */ applyActiveTabUi(tab) { document.querySelectorAll('.backup-layout .sidebar .category[data-backup-tab]').forEach(b => { b.classList.toggle('active', b.dataset.backupTab === tab); }); document.querySelectorAll('.backup-tabpanel').forEach(p => { p.classList.toggle('active', p.id === `backup-panel-${tab}`); }); } bindEvents() { if (this.eventBound) return; this.eventBound = true; // Browser back/forward is handled by the SPA's popstate listener — // pushTabToUrl includes a `route` field in state so the SPA's // handler picks it up and re-runs handleBackup, which re-parses // the URL via parseTabFromUrl() at init time. document.addEventListener('click', (e) => { // Clicking outside the export dropdown (and not on its trigger) closes it. const exportMenu = document.getElementById('backup-export-menu'); if (exportMenu && !exportMenu.hidden && !e.target.closest('#backup-export-menu') && !e.target.closest('#backup-primary-action')) { this.toggleExportMenu(false); } const tabBtn = e.target.closest('.backup-layout .sidebar .category[data-backup-tab]'); if (tabBtn) { this.switchTab(tabBtn.dataset.backupTab); return; } if (e.target.closest('#backup-refresh-btn')) { this.refreshAll().then(() => this.render()); return; } if (e.target.closest('#backup-primary-action')) { this.handlePrimaryAction(); return; } if (e.target.closest('[data-action="backup-system"]')) { this.runBackupSystem(); return; } if (e.target.closest('[data-action="restore-system"]')) { this.confirmRestoreSystem(); return; } const restoreBtn = e.target.closest('[data-action="restore-snapshot"]'); if (restoreBtn) { this.openRestoreModal(restoreBtn.dataset.app, restoreBtn.dataset.loc, restoreBtn.dataset.snapshot); return; } const deleteBtn = e.target.closest('[data-action="delete-snapshot"]'); if (deleteBtn) { this.openDeleteModal(deleteBtn.dataset.app, deleteBtn.dataset.loc, deleteBtn.dataset.snapshot); return; } const locEnable = e.target.closest('[data-action="toggle-location-enabled"]'); if (locEnable) { const cb = locEnable.querySelector('input[type="checkbox"]'); this.setLocationEnabled(parseInt(locEnable.dataset.loc, 10), cb ? cb.checked : true); return; } const locHeader = e.target.closest('[data-action="toggle-location"]'); if (locHeader) { this.toggleLocationExpand(parseInt(locHeader.dataset.loc, 10)); return; } const locSave = e.target.closest('[data-action="save-location"]'); if (locSave) { this.saveInlineLocation(parseInt(locSave.dataset.loc, 10)); return; } const locDelete = e.target.closest('[data-action="delete-location"]'); if (locDelete) { this.deleteInlineLocation(parseInt(locDelete.dataset.loc, 10)); return; } const sshSave = e.target.closest('[data-action="ssh-key-save"]'); if (sshSave) { this.saveBackupSshKey(parseInt(sshSave.dataset.loc, 10)); return; } const sshGen = e.target.closest('[data-action="ssh-key-generate"]'); if (sshGen) { this.generateBackupSshKey(parseInt(sshGen.dataset.loc, 10)); return; } const sshDel = e.target.closest('[data-action="ssh-key-delete"]'); if (sshDel) { this.deleteBackupSshKey(parseInt(sshDel.dataset.loc, 10)); return; } const sshCopy = e.target.closest('[data-action="ssh-key-copy"]'); if (sshCopy) { this.copyBackupSshKey(parseInt(sshCopy.dataset.loc, 10)); return; } const locTab = e.target.closest('[data-action="loc-tab"]'); if (locTab) { const tabIdx = locTab.dataset.loc; const tabName = locTab.dataset.tab; const root = locTab.closest('.backup-location-config') || document; root.querySelectorAll(`[data-action="loc-tab"][data-loc="${tabIdx}"]`).forEach(b => { const on = b === locTab; b.classList.toggle('active', on); b.setAttribute('aria-selected', on ? 'true' : 'false'); }); root.querySelectorAll(`[data-tab-panel][data-loc="${tabIdx}"]`).forEach(p => { p.classList.toggle('active', p.dataset.tabPanel === tabName); }); return; } if (e.target.closest('[data-close-modal]') || e.target.matches('.backup-modal')) { this.closeAllModals(); return; } const migrateAppBtn = e.target.closest('[data-action="migrate-app"]'); if (migrateAppBtn) { this.openMigrateModal({ mode: 'app', locIdx: parseInt(migrateAppBtn.dataset.loc, 10), host: migrateAppBtn.dataset.host, app: migrateAppBtn.dataset.app }); return; } const migrateHostBtn = e.target.closest('[data-action="migrate-host"]'); if (migrateHostBtn) { this.openMigrateModal({ mode: 'host', locIdx: parseInt(migrateHostBtn.dataset.loc, 10), host: migrateHostBtn.dataset.host }); return; } if (e.target.closest('#backup-migrate-confirm')) { this.confirmMigrate(); return; } if (e.target.closest('#backup-restore-confirm')) { this.confirmRestore(); return; } if (e.target.closest('#backup-delete-confirm')) { this.confirmDelete(); return; } if (e.target.closest('#backup-add-location-confirm')) { this.confirmAddLocation(); return; } const engineBtn = e.target.closest('[data-action="open-engine-details"]'); if (engineBtn) { this.openEngineDetailsModal(engineBtn); return; } const exportBtn = e.target.closest('[data-action="export-passwords"]'); if (exportBtn) { this.toggleExportMenu(false); this.exportRepositoryPasswords(exportBtn); return; } const dismissWarn = e.target.closest('[data-action="dismiss-config-warning"]'); if (dismissWarn) { window.Dismissible?.dismiss('backup-config-warning'); const banner = dismissWarn.closest('.backup-warning-banner'); const divider = banner?.nextElementSibling; if (divider && divider.classList.contains('config-divider')) divider.remove(); banner?.remove(); return; } const saveBtn = e.target.closest('[data-backup-save]'); if (saveBtn) { this.saveSection(saveBtn.dataset.backupSave); return; } }); document.addEventListener('input', (e) => { if (e.target.id === 'backup-snapshot-filter' || e.target.id === 'backup-snapshot-repo') { this.renderSnapshots(); } }); // Type select changes refresh the visible connection fields inline. // Retention preset changes are handled by applyRetentionPreset, which // already updates CUSTOM_RETENTION too — no extra toggle wiring needed. document.addEventListener('change', (e) => { const detailsScope = e.target.closest('.backup-location-row .task-details'); if (detailsScope) { const locIdx = parseInt(detailsScope.dataset.loc, 10); if (e.target.matches('[name$="_TYPE"]')) { this.refreshInlineTypeFields(locIdx, e.target.value); } if (e.target.matches('[name$="_SSH_AUTH"]')) { this.applySshAuthVisibility(detailsScope); } if (e.target.matches('[name$="_PATH_MODE"]')) { this.applyPathModeVisibility(detailsScope); } } const presetSel = e.target.closest('[data-retention-preset]'); if (presetSel) { this.applyRetentionPreset(presetSel); } }); } 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; this.currentTab = tab; this.applyActiveTabUi(tab); this.updatePageHeader(); this.updatePrimaryAction(); if (!opts.fromPopstate) this.pushTabToUrl(tab); } pushTabToUrl(tab) { const url = `/backup/${tab}`; // Use replaceState for the *first* push (initial tab inferred from // URL); otherwise pushState so back/forward navigates between tabs. if (!this._pushedAnyTab) { window.history.replaceState({ backupTab: tab, route: url }, '', url); this._pushedAnyTab = true; } else { window.history.pushState({ backupTab: tab, route: url }, '', url); } } updatePageHeader() { const titleEl = document.getElementById('backup-section-title'); const subEl = document.getElementById('backup-section-subtitle'); const iconEl = document.getElementById('backup-page-header-icon'); if (titleEl) titleEl.textContent = this.titleFor(this.currentTab); if (subEl) subEl.textContent = this.subtitleFor(this.currentTab); if (iconEl) iconEl.innerHTML = this.iconFor(this.currentTab); } titleFor(tab) { return { dashboard: 'Dashboard', backups: 'Backups', locations: 'Locations', migrate: 'Migrate', configuration: 'Configuration' }[tab] || 'Backups'; } subtitleFor(tab) { return { dashboard: 'Per-app status and storage at a glance.', backups: 'Every snapshot across every enabled location.', locations: 'Where backups are stored. Add, edit, or remove destinations.', migrate: 'Restore an app from another LibrePortal that shares one of your backup locations.', configuration: 'Schedule, retention, and engine settings.' }[tab] || ''; } iconFor(tab) { const icons = { dashboard: '' + '' + '' + '' + '', backups: '' + '' + '' + '', locations: '' + '' + '', migrate: '' + '' + '' + '', configuration: '' + '' + '' }; return icons[tab] || icons.backups; } updatePrimaryAction() { const btn = document.getElementById('backup-primary-action'); if (!btn) return; // Switching tabs always closes the export dropdown. this.toggleExportMenu(false); if (this.currentTab === 'locations') { btn.innerHTML = ` Add location `; btn.dataset.intent = 'add-location'; btn.removeAttribute('aria-haspopup'); } else if (this.currentTab === 'configuration') { btn.innerHTML = ` Export `; btn.dataset.intent = 'export-menu'; btn.setAttribute('aria-haspopup', 'menu'); } else { btn.innerHTML = ` Backup all apps `; btn.dataset.intent = 'backup-all'; btn.removeAttribute('aria-haspopup'); } } handlePrimaryAction() { const intent = document.getElementById('backup-primary-action')?.dataset.intent; if (intent === 'add-location') { this.openAddLocationModal(); } else if (intent === 'export-menu') { this.toggleExportMenu(); } else { this.runBackupAllApps(); } } toggleExportMenu(force) { const menu = document.getElementById('backup-export-menu'); const btn = document.getElementById('backup-primary-action'); if (!menu) return; const show = typeof force === 'boolean' ? force : menu.hidden; menu.hidden = !show; if (btn) btn.setAttribute('aria-expanded', show ? 'true' : 'false'); } render() { this.renderDashboard(); this.renderLocations(); this.renderSnapshots(); this.renderMigrate(); 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')} `; if (!apps.length) { appGrid.innerHTML = `
No apps installed yet.
`; } else { appGrid.innerHTML = apps.map(app => this.renderAppTile(app)).join(''); } const sysStatus = document.getElementById('backup-system-status'); if (sysStatus) { const sys = d.system || {}; const hasSys = !!sys.latest_snapshot; sysStatus.innerHTML = ` ${hasSys ? 'Last backed up ' + this.formatRelative(sys.latest_time) : 'No backup yet'} `; } 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(''); } } 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 a capitalised slug if the app isn't in the cached list. */ appMeta(slug) { const apps = window.apps || []; const match = apps.find(a => { const command = a.command || ''; return command.endsWith(` ${slug}`) || a.name?.toLowerCase() === slug.toLowerCase(); }); let icon = match?.icon || '/icons/apps/default.svg'; if (!icon.startsWith('/')) icon = '/' + icon; const displayName = (typeof window.getAppDisplayName === 'function') ? window.getAppDisplayName(slug) : (slug.charAt(0).toUpperCase() + slug.slice(1)); 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}`); } async deleteInlineLocation(idx) { 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 — only LibrePortal's reference to it. The password file on disk also stays in place.`)) return; this.expandedLocs.delete(idx); await this.runTask(`libreportal backup location remove ${idx}`, 'backup', null); setTimeout(() => this.reloadAfterSave(), 2000); } renderSnapshots() { const tbody = document.getElementById('backup-snapshot-tbody'); if (!tbody) 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), }); }); }); 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) { tbody.innerHTML = `No backups yet.`; return; } tbody.innerHTML = filtered.map(r => ` ${this.escape(r.app)} ${this.escape(r.host)} ${this.escape(r.locName)} ${this.formatRelative(r.time)} ${this.escape(r.id)} `).join(''); } 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, snapshots 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_'. When `includeInherit` is true (per-location scope), an "Inherit global" option is added at the top and an extra hidden CUSTOM_RETENTION field is 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', '', 'snapshots')} ${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 snapshot 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)); return l?.name || `Location ${idx}`; } closeAllModals() { 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); } /* ----- 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) { this.notify('Task system unavailable', 'error'); return; } try { await this.taskManager.createTask(command, type, app); setTimeout(() => this.refreshAll().then(() => this.render()), 1500); } catch (err) { this.notify(`Failed to queue task: ${err.message || err}`, 'error'); } } /* ----- 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; if (sectionId.startsWith('location-')) { const idx = sectionId.slice('location-'.length); scope = document.querySelector(`.backup-location-row[data-loc="${idx}"] .task-details`); } else { scope = document.querySelector(`#backup-panel-${sectionId}`); } if (!scope) return; const cfg = window.systemConfigs || {}; const changes = []; scope.querySelectorAll('[data-backup-field]').forEach(el => { const name = el.name; if (!name || name.startsWith('__')) return; let value; if (el.hasAttribute('data-backup-bool')) { value = el.checked ? 'true' : 'false'; } else { value = (el.value ?? '').toString(); } const original = (cfg[name] ?? '').toString(); if (value === original) return; changes.push(`${name}=${value.replace(/\|/g, '%7C')}`); }); if (!changes.length) { this.notify('No changes to save.', 'info'); return; } const encoded = changes.join('|'); 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(`Saving ${changes.length} change${changes.length === 1 ? '' : 's'}…`, 'success'); setTimeout(() => this.reloadAfterSave(), 2500); } catch (err) { this.notify(`Save failed: ${err.message || err}`, 'error'); } } async reloadAfterSave() { await this.refreshAll(); this.render(); } notify(message, type) { if (window.notificationSystem) { window.notificationSystem.show(message, type || 'info'); } else { console.log(`[backup ${type || 'info'}] ${message}`); } } escape(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]); } formatBytes(b) { if (!b || b < 0) return '0 B'; const units = ['B', 'KB', 'MB', 'GB', 'TB']; let i = 0; let v = b; while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; } return `${v.toFixed(v < 10 ? 2 : 1)} ${units[i]}`; } formatRelative(iso) { if (!iso) return '—'; const t = new Date(iso).getTime(); if (!t) return iso; const diff = Math.max(0, Date.now() - t); const s = Math.floor(diff / 1000); if (s < 60) return 'just now'; const m = Math.floor(s / 60); if (m < 60) return `${m}m ago`; const h = Math.floor(m / 60); if (h < 48) return `${h}h ago`; const d = Math.floor(h / 24); if (d < 30) return `${d}d ago`; return new Date(iso).toLocaleDateString(); } } window.BackupPage = BackupPage;