librelad 25027da86e style(backup): add icons to location buttons; move nebula CSS into theme folder
Buttons: the per-location Save changes / Delete location buttons had no icons,
unlike the apps-config action buttons. Add a save (floppy) icon and a trash
icon so they match the reference; colour comes from the nebula button groups
they already belong to.

Theme refactor: move the theme-specific [data-theme="nebula"] button/topbar/CTA
rules out of the shared css/themes.css and into themes/nebula/theme.css, where
the README says theme overrides belong. css/themes.css keeps only the generic,
non-theme-scoped defaults (solid status/accent buttons, danger-zone,
warning-banner) shared by dark-blue/light. No behaviour change: the nebula file
loads after css/themes.css so the moved rules still win.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-23 01:01:01 +01:00

1658 lines
78 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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', description: 'Automatic puts the repo at /docker/backups/<id>. 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.' }
};
const BACKUP_LOC_FIELDS_BY_TYPE = {
local: ['NAME', 'ENGINE', 'TYPE', 'PATH_MODE', 'PATH', 'APPEND_ONLY'],
sftp: ['NAME', 'ENGINE', 'TYPE', 'SSH_USER', 'SSH_HOST', 'SSH_PORT', 'SSH_PATH', 'SSH_AUTH', 'SSH_PASS', 'URI', 'APPEND_ONLY'],
rest: ['NAME', 'ENGINE', 'TYPE', 'URI', 'APPEND_ONLY'],
s3: ['NAME', 'ENGINE', 'TYPE', 'URI', 'S3_ACCESS_KEY', 'S3_SECRET_KEY', 'APPEND_ONLY'],
b2: ['NAME', 'ENGINE', 'TYPE', 'URI', 'B2_ACCOUNT_ID', 'B2_ACCOUNT_KEY', 'APPEND_ONLY'],
gs: ['NAME', 'ENGINE', 'TYPE', 'URI', 'APPEND_ONLY'],
azure: ['NAME', 'ENGINE', 'TYPE', 'URI', 'APPEND_ONLY'],
rclone: ['NAME', 'ENGINE', 'TYPE', 'URI', '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', 'configuration']);
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;
}
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;
}
if (e.target.closest('[data-close-modal]') || e.target.matches('.backup-modal')) {
this.closeAllModals();
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] = await Promise.all([
this.fetchJson(`/data/backup/generated/dashboard.json?t=${ts}`),
this.fetchJson(`/data/backup/generated/locations.json?t=${ts}`)
]);
this.dashboard = dashboard;
this.locations = locations;
this.snapshotsByLoc = {};
if (!this.engines.length) await this.loadEngines();
if (locations?.locations?.length) {
const enabled = locations.locations.filter(l => l.enabled);
await Promise.all(enabled.map(async (l) => {
const s = await this.fetchJson(`/data/backup/generated/snapshots_${l.idx}.json?t=${ts}`);
if (s) this.snapshotsByLoc[l.idx] = s;
}));
}
}
async fetchJson(url) {
try { const r = await fetch(url); if (!r.ok) return null; return await r.json(); }
catch { return null; }
}
async 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',
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.',
configuration: 'Schedule, retention, and engine settings.'
}[tab] || '';
}
iconFor(tab) {
const icons = {
dashboard:
'<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">' +
'<rect x="3" y="3" width="7" height="9"></rect>' +
'<rect x="14" y="3" width="7" height="5"></rect>' +
'<rect x="14" y="12" width="7" height="9"></rect>' +
'<rect x="3" y="16" width="7" height="5"></rect></svg>',
backups:
'<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">' +
'<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>' +
'<polyline points="17 8 12 3 7 8"></polyline>' +
'<line x1="12" y1="3" x2="12" y2="15"></line></svg>',
locations:
'<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">' +
'<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"></path>' +
'<circle cx="12" cy="10" r="3"></circle></svg>',
configuration:
'<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">' +
'<circle cx="12" cy="12" r="3"></circle>' +
'<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>'
};
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 = `
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
Add location
`;
btn.dataset.intent = 'add-location';
btn.removeAttribute('aria-haspopup');
} else if (this.currentTab === 'configuration') {
btn.innerHTML = `
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
</svg>
Export
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
`;
btn.dataset.intent = 'export-menu';
btn.setAttribute('aria-haspopup', 'menu');
} else {
btn.innerHTML = `
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="17 8 12 3 7 8"></polyline>
<line x1="12" y1="3" x2="12" y2="15"></line>
</svg>
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.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 = `<div class="backup-empty-state">No apps installed yet.</div>`;
} else {
appGrid.innerHTML = apps.map(app => this.renderAppTile(app)).join('');
}
if (!locs.length) {
locSummary.innerHTML = `<div class="backup-empty-state">No locations enabled.</div>`;
} else {
locSummary.innerHTML = locs.map(r => `
<div class="backup-repo-row">
<div class="backup-repo-row-name">
<span class="backup-repo-type-pill">${this.escape(r.type)}</span>
${this.escape(r.name)}
</div>
<div class="backup-repo-row-meta">
${this.formatBytes(parseInt(r.total_size_bytes) || 0)}<br>
<span class="backup-card-hint">${r.total_files || 0} files</span>
</div>
</div>
`).join('');
}
}
tile(label, value, detail) {
return `
<div class="backup-summary-tile">
<div class="backup-summary-tile-label">${this.escape(label)}</div>
<div class="backup-summary-tile-value">${this.escape(value)}</div>
<div class="backup-summary-tile-detail">${this.escape(detail || '')}</div>
</div>
`;
}
/* 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 `
<div class="backup-app-tile" data-app="${this.escape(app.app)}">
<img class="backup-app-tile-icon" src="${this.escape(icon)}" alt="" onerror="this.src='/icons/apps/default.svg'">
<div class="backup-app-tile-text">
<div class="backup-app-tile-name">${this.escape(displayName)}</div>
<div class="backup-app-tile-meta">
<span class="backup-status-dot ${dot}"></span>
<span>${when}</span>
</div>
</div>
</div>
`;
}
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 = `
<div class="backup-empty-state">
No backup locations configured yet.<br>
Click <strong>Add location</strong> above to create one.
</div>
`;
} else {
list.innerHTML = locs.map(l => this.renderLocationRow(l)).join('');
}
if (repoSelect) {
const cur = repoSelect.value;
repoSelect.innerHTML = `<option value="">All locations</option>` +
locs.filter(l => l.enabled).map(l => `<option value="${l.idx}">${this.escape(l.name)}</option>`).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 `
<div class="task-item backup-location-row" data-loc="${l.idx}">
<div class="task-header backup-location-header" data-action="toggle-location" data-loc="${l.idx}" aria-expanded="${expanded ? 'true' : 'false'}">
<div class="task-info backup-location-row-info">
<span class="backup-location-row-type-icon" data-type="${this.escape(l.type)}">${this.typeIcon(l.type)}</span>
<span class="backup-location-row-name">${this.escape(l.name)}</span>
<span class="backup-repo-type-pill">${this.escape(l.type)}</span>
<span class="backup-engine-pill" data-engine="${this.escape(l.engine || 'restic')}">${this.escape(this.engineDisplayName(l.engine))}</span>
${l.append_only ? '<span class="backup-pill-mini">append-only</span>' : ''}
<span class="task-status backup-loc-status status-${statusKind}">${statusMeta.icon} ${statusMeta.label}</span>
<span class="backup-location-row-sep">·</span>
<span class="backup-location-row-stat">${snapCount} backup${snapCount === 1 ? '' : 's'}</span>
<span class="backup-location-row-sep">·</span>
<span class="backup-location-row-stat">${size}</span>
</div>
<span class="backup-toggle backup-loc-enable-toggle" data-action="toggle-location-enabled" data-loc="${l.idx}" title="${l.enabled ? 'Enabled — click to disable this location' : 'Disabled — click to enable this location'}">
<input type="checkbox" ${l.enabled ? 'checked' : ''} aria-label="Enable this location">
<span class="backup-toggle-slider"></span>
</span>
<svg class="backup-location-chevron" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
</div>
<div class="task-details backup-location-details ${expanded ? 'show' : ''}" data-loc="${l.idx}">
${expanded ? this.renderLocationDetailsBody(l) : ''}
</div>
</div>
`;
}
/* 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 = `<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<ellipse cx="12" cy="5" rx="9" ry="3"></ellipse>
<path d="M3 5v6c0 1.66 4.03 3 9 3s9-1.34 9-3V5"></path>
<path d="M3 11v6c0 1.66 4.03 3 9 3s9-1.34 9-3v-6"></path>
</svg>`;
const cloud = `<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 10h-1.26A8 8 0 1 0 9 20h9a5 5 0 0 0 0-10z"></path>
</svg>`;
return type === 'local' ? local : cloud;
}
renderLocationDetailsBody(l) {
const idx = l.idx;
const connectionFields = BACKUP_LOC_FIELDS_BY_TYPE[l.type] || BACKUP_LOC_FIELDS_BY_TYPE.local;
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 || '') : ''
};
return `
<div class="config-category backup-location-config" data-section="location-${idx}">
<div class="domains-wrapper">
<div class="domains-header">
<div>
<h3>Connection</h3>
<p class="category-description">How LibrePortal connects to this storage location.</p>
</div>
</div>
<div class="domains-divider"></div>
<div class="backup-location-connection-fields" id="backup-location-${idx}-connection">
${this.renderLocFields(idx, connectionFields, l)}
</div>
</div>
</div>
<div class="config-category backup-location-config">
<div class="domains-wrapper">
<div class="domains-header">
<div>
<h3>Retention</h3>
<p class="category-description">When to delete old backups from this location.</p>
</div>
</div>
<div class="domains-divider"></div>
<div id="backup-location-${idx}-retention">
${this.formRetention(`CFG_BACKUP_LOC_${idx}_`, retentionValues, true)}
</div>
</div>
</div>
<div class="backup-location-actions">
<button class="backup-primary-btn" data-action="save-location" data-loc="${idx}">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path>
<polyline points="17 21 17 13 7 13 7 21"></polyline>
<polyline points="7 3 7 8 15 8"></polyline>
</svg>
Save changes
</button>
<button class="backup-danger-btn" data-action="delete-location" data-loc="${idx}">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="m19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
<line x1="10" y1="11" x2="10" y2="17"></line>
<line x1="14" y1="11" x2="14" y2="17"></line>
</svg>
Delete location
</button>
</div>
`;
}
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 container = document.getElementById(`backup-location-${idx}-connection`);
if (!container) return;
const loc = (this.locations?.locations || []).find(l => l.idx === idx) || {};
const suffixes = BACKUP_LOC_FIELDS_BY_TYPE[type] || BACKUP_LOC_FIELDS_BY_TYPE.local;
container.innerHTML = this.renderLocFields(idx, suffixes, { ...loc, type });
this.tagFieldsForSave(container);
this.filterEngineSelect(container, type, loc.engine);
this.applySshAuthVisibility(container);
this.applyPathModeVisibility(container);
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 passInput = scope.querySelector('input[name$="_SSH_PASS"]');
const passGroup = passInput?.closest('.field-group') || passInput?.closest('.password-mode-wrapper')?.parentElement;
if (!passGroup) return;
passGroup.style.display = authSelect.value === 'password' ? '' : '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;
const want = compatible.find(e => e.id === preferred)?.id || compatible[0].id;
select.innerHTML = compatible
.map(e => `<option value="${this.escape(e.id)}" ${e.id === want ? 'selected' : ''}>${this.escape(e.name || e.id)}</option>`)
.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 = `<tr><td colspan="6" class="backup-empty-state">No backups yet.</td></tr>`;
return;
}
tbody.innerHTML = filtered.map(r => `
<tr>
<td>${this.escape(r.app)}</td>
<td>${this.escape(r.host)}</td>
<td>${this.escape(r.locName)}</td>
<td>${this.formatRelative(r.time)}</td>
<td class="backup-snapshot-id">${this.escape(r.id)}</td>
<td class="backup-col-actions">
<button class="backup-row-action-btn" data-action="restore-snapshot" data-app="${this.escape(r.app)}" data-loc="${r.locIdx}" data-snapshot="${this.escape(r.id)}">Restore</button>
<button class="backup-row-action-btn danger" data-action="delete-snapshot" data-app="${this.escape(r.app)}" data-loc="${r.locIdx}" data-snapshot="${this.escape(r.id)}">Delete</button>
</td>
</tr>
`).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 ? '' : `
<div class="backup-warning-banner">
<svg class="backup-warning-banner-icon" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path>
<line x1="12" y1="9" x2="12" y2="13"></line>
<line x1="12" y1="17" x2="12.01" y2="17"></line>
</svg>
<div class="backup-warning-banner-text">
<strong>Keep your LibrePortal config backed up offline.</strong>
<span>Repository passwords live inside the config directory. Without that backup, snapshots cannot be decrypted by anyone — including you.</span>
</div>
<button type="button" class="backup-warning-banner-close" data-action="dismiss-config-warning" title="Dismiss this warning" aria-label="Dismiss this warning">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<div class="config-divider"></div>
`;
body.innerHTML = `
${warningHTML}
<div id="config-section" class="backup-embedded-config"></div>
`;
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 = `<div class="backup-empty-state">Configuration system not loaded. Try refreshing the page.</div>`;
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 = `
<label class="backup-form-row">
<span class="backup-form-label">Backup style <span class="tooltip" data-retention-tooltip title="${this.escape(meta?.hint || '')}"></span></span>
<select class="form-control" data-backup-retention-preset>${presetOptions}</select>
</label>
`;
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 <option> list for a "Backup style" dropdown. The scope's
default preset is tagged "(default)" and floated to the top; the rest
follow in declared order. inherit-global only makes sense per-location
(there's nothing to inherit at the global level), so it's omitted when
includeInherit is false. */
retentionPresetOptions(selected, includeInherit = false) {
const defaultKey = includeInherit ? 'inherit-global' : 'self-hosting';
const keys = Object.keys(BACKUP_RETENTION_PRESET_META)
.filter(k => k !== 'inherit-global' || includeInherit);
const ordered = [defaultKey, ...keys.filter(k => k !== defaultKey)];
return ordered.map(k => {
const base = BACKUP_RETENTION_PRESET_META[k].label;
const label = k === defaultKey ? `${base} (default)` : base;
return `<option value="${k}" ${k === selected ? 'selected' : ''}>${this.escape(label)}</option>`;
}).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
? `<input type="hidden" name="${prefix}CUSTOM_RETENTION" value="${preset === 'inherit-global' ? 'false' : 'true'}" data-backup-field>`
: '';
return `
<div class="backup-form-grid backup-retention-block" data-retention-prefix="${this.escape(prefix)}" data-retention-allow-inherit="${includeInherit ? '1' : '0'}">
<label class="backup-form-row">
<span class="backup-form-label">Backup style <span class="tooltip" data-retention-tooltip title="${this.escape(meta?.hint || '')}"></span></span>
<select class="form-control" data-retention-preset>${presetOptions}</select>
</label>
${customRetentionHidden}
</div>
<div class="backup-retention-advanced" data-retention-advanced ${preset === 'custom' ? '' : 'hidden'}>
<div class="backup-form-grid">
${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')}
</div>
</div>
`;
}
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 = `<input type="${type}" name="${name}" value="${escVal}" placeholder="${escPh}" class="form-control" data-backup-field>`;
const wrapped = unit ? `<div class="input-group">${inputHTML}<span class="input-group-text">${this.escape(unit)}</span></div>` : inputHTML;
return `
<label class="backup-form-row">
<span class="backup-form-label">${escLabel}</span>
${wrapped}
</label>
`;
}
formSelect(name, label, value, options) {
const escLabel = this.escape(label);
const opts = options.map(([v, lbl]) => `<option value="${this.escape(v)}" ${v === value ? 'selected' : ''}>${this.escape(lbl)}</option>`).join('');
return `
<label class="backup-form-row">
<span class="backup-form-label">${escLabel}</span>
<select name="${name}" class="form-control" data-backup-field>${opts}</select>
</label>
`;
}
formToggle(name, label, checked) {
const escLabel = this.escape(label);
return `
<label class="backup-form-row backup-form-row-toggle">
<span class="backup-form-label">${escLabel}</span>
<span class="backup-toggle">
<input type="checkbox" name="${name}" ${checked ? 'checked' : ''} data-backup-field data-backup-bool>
<span class="backup-toggle-slider"></span>
</span>
</label>
`;
}
/* 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 = `
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="16" x2="12" y2="12"></line>
<line x1="12" y1="8" x2="12.01" y2="8"></line>
</svg>
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 = `<div class="backup-empty-state">Loading engine details…</div>`;
modal.classList.add('open');
const data = await this.fetchJson(`/data/backup/generated/engines/${encodeURIComponent(engineId)}.json?t=${Date.now()}`);
if (!data) {
body.innerHTML = `
<div class="backup-empty-state">
No details file for engine "<strong>${this.escape(engineId)}</strong>".<br>
Add <code>scripts/backup/engines/${this.escape(engineId)}.json</code> and run the WebUI regen.
</div>
`;
return;
}
if (title) title.textContent = `Backup engine: ${data.name || engineId}`;
const propsHTML = (data.properties || []).map(p =>
`<tr><th>${this.escape(p.label)}</th><td>${this.escape(p.value)}</td></tr>`
).join('');
const featsHTML = (data.features || []).map(f => `<li>${this.escape(f)}</li>`).join('');
const docsHTML = data.docs_url
? `<a href="${this.escape(data.docs_url)}" target="_blank" rel="noopener noreferrer" class="backup-engine-docs-link">${this.escape(data.docs_url)} ↗</a>`
: '';
const logoHTML = data.logo
? `<img class="backup-engine-logo" src="${this.escape(data.logo)}" alt="" onerror="this.style.display='none'">`
: '';
body.innerHTML = `
<div class="backup-engine-modal-head">
${logoHTML}
<div>
<h4>${this.escape(data.name || engineId)}</h4>
<p class="backup-card-hint">${this.escape(data.tagline || '')}</p>
</div>
</div>
${propsHTML ? `<table class="backup-engine-props">${propsHTML}</table>` : ''}
${featsHTML ? `<h5>Highlights</h5><ul class="backup-engine-features">${featsHTML}</ul>` : ''}
${docsHTML ? `<h5>Documentation</h5><p>${docsHTML}</p>` : ''}
`;
}
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 `
<label class="backup-form-row">
<span class="backup-form-label">${this.escape(label)}</span>
${cronHtml}
</label>
`;
}
formReadOnly(label, value) {
return `
<div class="backup-form-row">
<span class="backup-form-label">${this.escape(label)}</span>
<span class="backup-form-readonly">${this.escape(value)}</span>
</div>
`;
}
/* ----- 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 = `
<div class="config-category backup-location-config" data-section="location-${idx}">
<div id="backup-location-connection"></div>
</div>
<div class="config-category backup-location-config">
<h3>Retention</h3>
<p class="category-description">When to delete old backups from this location.</p>
<div id="backup-location-retention"></div>
</div>
`;
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 = BACKUP_LOC_FIELDS_BY_TYPE[type] || BACKUP_LOC_FIELDS_BY_TYPE.local;
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}
<div class="backup-card-hint" style="margin-top:8px">Inherits the <strong>global retention policy</strong> from the Configuration tab.</div>
`;
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 `<div class="backup-empty-state">Configuration system not loaded.</div>`;
}
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
};
// Single .config-fields grid, exactly like /config's renderer — the grid
// (repeat(3, 1fr)) handles the row layout itself, and hidden fields
// (PATH_MODE/SSH/etc.) drop out cleanly without leaving column gaps.
let html = '<div class="config-fields">';
for (const suffix of suffixes) {
const def = BACKUP_LOC_FIELD_DEFS[suffix];
if (!def) continue;
const key = `CFG_BACKUP_LOC_${idx}_${suffix}`;
const value = (locValueLookup[suffix] ?? '').toString();
const fieldId = `config-${key}`;
html += ConfigShared.generateField(fieldId, key, value, def.title, def.description, {}, {});
}
html += '</div>';
return html;
}
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 = `
<div class="backup-form-grid">
${this.formInput('__add_name', 'Friendly name', '', 'text', 'e.g. Office NAS')}
${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']
])}
</div>
<p class="backup-card-hint" style="margin-top:12px">The location starts disabled — fill in its connection details on the next screen, then toggle Enabled.</p>
`;
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 = `
<p>Restore <strong>${this.escape(app)}</strong> from backup <code>${this.escape(snapshot)}</code> at <strong>${this.escape(locName)}</strong>?</p>
<p class="backup-card-hint">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.</p>
`;
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 = `
<p>Delete backup <code>${this.escape(snapshot)}</code> for <strong>${this.escape(app)}</strong> from <strong>${this.escape(locName)}</strong>?</p>
<p class="backup-card-hint">This cannot be undone. Append-only locations will reject the operation.</p>
`;
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 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() {
try {
const r = await fetch(`/data/config/generated/configs.json?t=${Date.now()}`);
if (r.ok) window.systemConfigs = await r.json();
} catch {}
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 => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
})[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;