// 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). // Module-level schema/retention data moved to backup-schema.js (loaded first). 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; this._taskRefreshTimer = null; } 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; // Backups/restores/deletes complete asynchronously with no live feed, so // repaint when one finishes (snapshot lists, last-backup times, sizes). // Registered with the task-refresh coordinator (single source of truth); // debounced because picking several apps queues a task each, finishing // in a burst. The Refresh button stays as a manual pull. window.taskRefresh?.register({ id: 'backups', match: (d) => ['backup', 'restore', 'delete', 'delete_all'].includes(d.action) || /^libreportal\s+(backup|restore)\b/.test((d.task && d.task.command) || ''), run: () => { if (window.backupPage === this && document.getElementById('backup-page')) { return this.refreshAll().then(() => this.render()); } }, debounceMs: 600, }); // 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; } // Deep-link from the Snapshots table → /app//backups?snapshot=. // Routed via the SPA so the app page mounts in-place rather than a // full reload. const deepLink = e.target.closest('[data-deep-link]'); if (deepLink) { e.preventDefault(); if (window.navigateToRoute) window.navigateToRoute(deepLink.dataset.deepLink); else window.location.href = deepLink.dataset.deepLink; return; } // Tile actions, in priority order: // 1. "Backup now" pill on the tile → opens the pick modal // preticked with that tile (explicit affordance, replaces // the old implicit whole-tile click). // 2. Whole-tile click → navigates to the per-app Backups tab // (or the system page for the System tile). This is the // cohesion fix: each tile leads to the page that owns // that subject's full detail, not a modal asking "do // you want to back up?". const backupNowBtn = e.target.closest('[data-action="backup-now"]'); if (backupNowBtn) { e.stopPropagation(); if (backupNowBtn.dataset.system) { this.openBackupPickModal({ preTickSystem: true }); } else if (backupNowBtn.dataset.app) { this.openBackupPickModal({ preTickApps: [backupNowBtn.dataset.app] }); } return; } const tile = e.target.closest('.backup-app-tile'); if (tile) { if (tile.dataset.system) { // System has no dedicated page yet — keep the pick modal. this.openBackupPickModal({ preTickSystem: true }); } else if (tile.dataset.app && window.navigateToRoute) { window.navigateToRoute(`/app/${encodeURIComponent(tile.dataset.app)}/backups`); } return; } if (e.target.closest('#backup-pick-confirm')) { this.confirmBackupPick(); return; } const restoreBtn = e.target.closest('[data-action="restore-snapshot"]'); if (restoreBtn) { e.stopPropagation(); // don't also toggle the row's details panel this.openRestoreModal(restoreBtn.dataset.app, restoreBtn.dataset.loc, restoreBtn.dataset.snapshot); return; } const deleteBtn = e.target.closest('[data-action="delete-snapshot"]'); if (deleteBtn) { e.stopPropagation(); this.openDeleteModal(deleteBtn.dataset.app, deleteBtn.dataset.loc, deleteBtn.dataset.snapshot); return; } // Row header / Details button → toggle the .task-details panel. // Matches the per-app Backups tab interaction. const snapToggle = e.target.closest('[data-action="toggle-snapshot-row"]'); if (snapToggle) { const item = snapToggle.closest('.backup-snapshot-item'); const details = item && item.querySelector('.task-details'); if (details) details.classList.toggle('task-details-open'); 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-delete-location-confirm')) { this.confirmDeleteLocation(); 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 goToLocations = e.target.closest('[data-action="go-to-locations"]'); if (goToLocations) { this.switchTab('locations'); 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); } }); } /* 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. */ 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: "Check what's protected — and when it last ran.", backups: 'Every backup 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.style.display = ''; btn.innerHTML = ` Add location `; btn.dataset.intent = 'add-location'; btn.removeAttribute('aria-haspopup'); } else if (this.currentTab === 'configuration') { btn.style.display = ''; btn.innerHTML = ` Export `; btn.dataset.intent = 'export-menu'; btn.setAttribute('aria-haspopup', 'menu'); } else if (this.currentTab === 'backups') { btn.style.display = ''; btn.innerHTML = ` Backup all apps `; btn.dataset.intent = 'backup-all'; btn.removeAttribute('aria-haspopup'); } else { // Dashboard and Migrate have no header primary action. btn.style.display = 'none'; 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(); } /* 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 || '/core/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 }; } /* 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." */ /* Hide the SSH password field when SSH auth = key, show it when = password. Applied at expand time and whenever the SSH_AUTH select changes. */ /* Hide the custom PATH input when PATH_MODE=auto, show when =custom. */ /* 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. */ /* 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. */ /* Build the