// 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); } }); } 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: "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(); } 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 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 }; } 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_'. 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', '', '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)); 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); } /* ----- Back-up checklist modal ----- Triggered by clicking any tile in the Backup status grid. Lists System config first (special row, key="__system__"), then every installed app. Pre-ticks the tile that was clicked. Confirm queues one backup task per ticked item — except when EVERYTHING is ticked, in which case we 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) { 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(); } // formatRelative's future-tense sibling — "in 6h", "tomorrow at 5am", etc. // Used by the backup-status header to summarise the next scheduled run. formatRelativeFuture(when) { if (!when) return ''; const t = when.getTime(); const diff = t - Date.now(); if (diff <= 0) return 'imminent'; const s = Math.floor(diff / 1000); if (s < 60) return 'in less than a minute'; const m = Math.floor(s / 60); if (m < 60) return `in ${m}m`; const h = Math.floor(m / 60); if (h < 24) return `in ${h}h`; const sameDay = (a, b) => a.toDateString() === b.toDateString(); const tomorrow = new Date(Date.now() + 86400000); if (sameDay(when, tomorrow)) return 'tomorrow'; const days = Math.floor(h / 24); return `in ${days}d`; } // "05:00" or "Mon 05:00" depending on whether it's later today or not. formatScheduleClock(when) { if (!when) return ''; const sameDay = (new Date()).toDateString() === when.toDateString(); const t = when.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); if (sameDay) return `at ${t}`; const day = when.toLocaleDateString([], { weekday: 'short' }); return `${day} ${t}`; } // Tiny cron-next utility — given a 5-field crontab expression // (minute hour dom month dow) returns a Date for the next fire after // now, or null if the expression is unparseable / never fires within // the lookahead window. Supports the common syntax: *, N, lists // (N,M,O), ranges (N-M), and steps (* /N or N-M/S). Doesn't try to // be a full cron implementation — just enough for the // CFG_BACKUP_CRONTAB_APP value the WebUI shows in the header. nextCronFireTime(expr) { const fields = String(expr || '').trim().split(/\s+/); if (fields.length !== 5) return null; const ranges = [[0, 59], [0, 23], [1, 31], [1, 12], [0, 6]]; let sets; try { sets = fields.map((f, i) => this._cronFieldSet(f, ranges[i][0], ranges[i][1])); } catch (_) { return null; } if (sets.some(s => !s.length)) return null; const [minSet, hourSet, domSet, monSet, dowSet] = sets; const minOk = new Set(minSet); const hourOk = new Set(hourSet); const domOk = new Set(domSet); const monOk = new Set(monSet); const dowOk = new Set(dowSet); const domStar = fields[2] === '*'; const dowStar = fields[4] === '*'; // Start one minute from now (rounded down to the minute) and walk // forward checking each candidate. Cap at ~366 days so a totally // unmatchable expression doesn't loop forever. const start = new Date(); start.setSeconds(0, 0); start.setMinutes(start.getMinutes() + 1); const limit = start.getTime() + 366 * 86400 * 1000; const cur = new Date(start); while (cur.getTime() < limit) { const m = cur.getMinutes(); const h = cur.getHours(); const dom = cur.getDate(); const mon = cur.getMonth() + 1; const dow = cur.getDay(); // 0 = Sun // POSIX cron: if both DOM and DOW are restricted, fire when // EITHER matches. If only one is restricted, that one must // match. If both are *, day passes. let dayMatch; if (domStar && dowStar) dayMatch = true; else if (domStar) dayMatch = dowOk.has(dow); else if (dowStar) dayMatch = domOk.has(dom); else dayMatch = domOk.has(dom) || dowOk.has(dow); if (dayMatch && monOk.has(mon) && hourOk.has(h) && minOk.has(m)) { return new Date(cur); } cur.setMinutes(cur.getMinutes() + 1); } return null; } // Expand one cron field into a sorted list of valid numeric values. // Throws on bad syntax so nextCronFireTime can drop back to null. _cronFieldSet(field, lo, hi) { const out = new Set(); for (const part of String(field).split(',')) { // step (every-Nth): "value/step" — value is "*", a single // number, or a range "a-b". let stepBase = part, step = 1; const slash = part.indexOf('/'); if (slash !== -1) { stepBase = part.slice(0, slash); step = parseInt(part.slice(slash + 1), 10); if (!Number.isFinite(step) || step < 1) throw new Error('bad step'); } let from = lo, to = hi; if (stepBase === '*' || stepBase === '') { // range stays lo..hi } else if (stepBase.includes('-')) { const [a, b] = stepBase.split('-').map(n => parseInt(n, 10)); if (!Number.isFinite(a) || !Number.isFinite(b)) throw new Error('bad range'); from = a; to = b; } else { const n = parseInt(stepBase, 10); if (!Number.isFinite(n)) throw new Error('bad value'); from = n; to = n; } if (from < lo || to > hi || from > to) throw new Error('out of range'); for (let v = from; v <= to; v += step) out.add(v); } return [...out].sort((a, b) => a - b); } } window.BackupPage = BackupPage;