librelad 5984869c7a copy(backup): tighter dashboard subtitle, action-led
Old: "Backup status — system config + every app — at a glance."
New: "Check what's protected — and when it last ran."

The em-dash chain was filler and "at a glance" was redundant on the
dashboard tab (which is the at-a-glance view). New copy leads with
what the admin is here to do.

Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-27 19:13:41 +01:00

2240 lines
109 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 Mode', description: 'Automatic uses the Default Backup Location from the Backup Engine config (one subfolder per location). Pick Custom to use a specific path (e.g. an attached drive or a NAS mount).' },
PATH: { title: 'Custom path', description: 'Filesystem path on this server. Used only when Path is set to Custom.' },
URI: { title: 'Repository URI (override)', description: 'Custom restic URI — leave blank to build from the fields below.' },
SSH_USER: { title: 'SSH user', description: '' },
SSH_HOST: { title: 'SSH host', description: '' },
SSH_PORT: { title: 'SSH port', description: '' },
SSH_PATH: { title: 'SSH remote path', description: 'Path on the remote host where the repo lives.' },
SSH_AUTH: { title: 'SSH authentication', description: 'Key auth uses ~/.ssh/id_rsa on this host. Password mode pipes via sshpass — restic + borg only; kopia requires keys.' },
SSH_PASS: { title: 'SSH password', description: 'Used only when SSH authentication is set to Password.' },
S3_ACCESS_KEY: { title: 'S3 access key', description: '' },
S3_SECRET_KEY: { title: 'S3 secret', description: '' },
B2_ACCOUNT_ID: { title: 'B2 account ID', description: '' },
B2_ACCOUNT_KEY: { title: 'B2 account key', description: '' },
APPEND_ONLY: { title: 'Append-only', description: 'Ransomware-safe — refuse forget/prune for this location even if LibrePortal itself is compromised. Trades off automatic retention cleanup.' },
CUSTOM_RETENTION: { title: 'Use custom retention', description: 'Otherwise this location inherits the global retention.' },
KEEP_LAST: { title: 'Keep last', description: 'Snapshots to always retain.' },
KEEP_DAILY: { title: 'Keep daily', description: 'One snapshot per day for this many days.' },
KEEP_WEEKLY: { title: 'Keep weekly', description: 'One snapshot per week for this many weeks.' },
KEEP_MONTHLY: { title: 'Keep monthly', description: 'One snapshot per month for this many months.' },
KEEP_YEARLY: { title: 'Keep yearly', description: 'One snapshot per year for this many years.' }
};
// Fallback for the per-type field schema. The live source is the generator-
// emitted data/backup/generated/schema.json (loaded into this.locSchema and
// read via locFieldsForType); this map is only used if that fetch fails.
// Type leads each list (it shapes the rest of the form); ENGINE stays in the
// list but locFieldGroups folds it into the Advanced tab.
const BACKUP_LOC_FIELDS_BY_TYPE = {
local: ['TYPE', 'NAME', 'ENGINE', 'PATH_MODE', 'PATH', 'APPEND_ONLY'],
sftp: ['TYPE', 'NAME', 'ENGINE', 'SSH_USER', 'SSH_HOST', 'SSH_PORT', 'SSH_PATH', 'SSH_AUTH', 'SSH_PASS', 'URI', 'APPEND_ONLY'],
rest: ['TYPE', 'NAME', 'ENGINE', 'URI', 'APPEND_ONLY'],
s3: ['TYPE', 'NAME', 'ENGINE', 'URI', 'S3_ACCESS_KEY', 'S3_SECRET_KEY', 'APPEND_ONLY'],
b2: ['TYPE', 'NAME', 'ENGINE', 'URI', 'B2_ACCOUNT_ID', 'B2_ACCOUNT_KEY', 'APPEND_ONLY'],
gs: ['TYPE', 'NAME', 'ENGINE', 'URI', 'APPEND_ONLY'],
azure: ['TYPE', 'NAME', 'ENGINE', 'URI', 'APPEND_ONLY'],
rclone: ['TYPE', 'NAME', 'ENGINE', 'URI', 'APPEND_ONLY']
};
// Suffixes that live in the editor's "Advanced" tab. configs.json can flag
// more via a **ADVANCED** comment marker; this set keeps the known overrides
// advanced even on legacy location.configs that predate the marker. Engine is
// here too — the system picks a sensible default, so most users never touch it.
const LOC_ADVANCED_SUFFIXES = new Set(['ENGINE', 'URI', 'SSH_PORT', 'APPEND_ONLY']);
function backupRetentionDetectPreset(values, includeInherit = false) {
const norm = (v) => (v == null ? '' : String(v).trim());
for (const [key, p] of Object.entries(BACKUP_RETENTION_PRESETS)) {
if (key === 'inherit-global' && !includeInherit) continue;
if (norm(values.last) === norm(p.last) &&
norm(values.daily) === norm(p.daily) &&
norm(values.weekly) === norm(p.weekly) &&
norm(values.monthly) === norm(p.monthly) &&
norm(values.yearly) === norm(p.yearly)) {
return key;
}
}
return 'custom';
}
class BackupPage {
constructor() {
this.currentTab = 'dashboard';
this.dashboard = null;
this.locations = null;
this.snapshotsByLoc = {};
this.expandedLocs = new Set();
this.engines = []; // [{id,name,supported_types}, ...] — fetched once
this.taskManager = (typeof TaskManager !== 'undefined') ? new TaskManager() : null;
this.eventBound = false;
}
async init() {
this.currentTab = this.parseTabFromUrl() || this.currentTab;
this.applyActiveTabUi(this.currentTab);
this.bindEvents();
await this.refreshAll();
await window.Dismissible?.load();
this.render();
this.updatePageHeader();
this.updatePrimaryAction();
}
/* Read the active tab slug from window.location, supporting both
/backup?=dashboard (the legacy libreportal ?= form used on /config)
and /backup?backup=dashboard (standard query string) so links from
either source resolve correctly. */
parseTabFromUrl() {
const allowed = new Set(['dashboard', 'backups', 'locations', 'migrate', 'configuration']);
// Path-based: /backup/<tab> (bare /backup → default tab).
const seg = window.location.pathname.replace(/^\/backup\/?/, '').split('/')[0];
if (seg && allowed.has(seg)) return seg;
// Legacy ?=<tab> / ?backup=<tab> / ?tab=<tab> for old links.
const search = window.location.search || '';
const legacy = search.match(/\?=([^&]+)/);
if (legacy && allowed.has(legacy[1])) return legacy[1];
const params = new URLSearchParams(search);
const q = params.get('backup') || params.get('tab');
if (q && allowed.has(q)) return q;
return null;
}
/* Toggle the sidebar .active class + panel visibility without going
through switchTab's URL-update path (used on initial render and
browser back/forward). */
applyActiveTabUi(tab) {
document.querySelectorAll('.backup-layout .sidebar .category[data-backup-tab]').forEach(b => {
b.classList.toggle('active', b.dataset.backupTab === tab);
});
document.querySelectorAll('.backup-tabpanel').forEach(p => {
p.classList.toggle('active', p.id === `backup-panel-${tab}`);
});
}
bindEvents() {
if (this.eventBound) return;
this.eventBound = true;
// Browser back/forward is handled by the SPA's popstate listener —
// pushTabToUrl includes a `route` field in state so the SPA's
// handler picks it up and re-runs handleBackup, which re-parses
// the URL via parseTabFromUrl() at init time.
document.addEventListener('click', (e) => {
// Clicking outside the export dropdown (and not on its trigger) closes it.
const exportMenu = document.getElementById('backup-export-menu');
if (exportMenu && !exportMenu.hidden
&& !e.target.closest('#backup-export-menu')
&& !e.target.closest('#backup-primary-action')) {
this.toggleExportMenu(false);
}
const tabBtn = e.target.closest('.backup-layout .sidebar .category[data-backup-tab]');
if (tabBtn) {
this.switchTab(tabBtn.dataset.backupTab);
return;
}
if (e.target.closest('#backup-refresh-btn')) {
this.refreshAll().then(() => this.render());
return;
}
if (e.target.closest('#backup-primary-action')) {
this.handlePrimaryAction();
return;
}
if (e.target.closest('[data-action="backup-system"]')) {
this.runBackupSystem();
return;
}
if (e.target.closest('[data-action="restore-system"]')) {
this.confirmRestoreSystem();
return;
}
// Click any tile on the dashboard → open the Back-up checklist
// modal with that tile pre-ticked. System tile is data-system="1";
// app tiles carry data-app="<slug>". Both share .backup-app-tile.
const tile = e.target.closest('.backup-app-tile');
if (tile) {
if (tile.dataset.system) {
this.openBackupPickModal({ preTickSystem: true });
} else if (tile.dataset.app) {
this.openBackupPickModal({ preTickApps: [tile.dataset.app] });
}
return;
}
if (e.target.closest('#backup-pick-confirm')) {
this.confirmBackupPick();
return;
}
const restoreBtn = e.target.closest('[data-action="restore-snapshot"]');
if (restoreBtn) {
this.openRestoreModal(restoreBtn.dataset.app, restoreBtn.dataset.loc, restoreBtn.dataset.snapshot);
return;
}
const deleteBtn = e.target.closest('[data-action="delete-snapshot"]');
if (deleteBtn) {
this.openDeleteModal(deleteBtn.dataset.app, deleteBtn.dataset.loc, deleteBtn.dataset.snapshot);
return;
}
const locEnable = e.target.closest('[data-action="toggle-location-enabled"]');
if (locEnable) {
const cb = locEnable.querySelector('input[type="checkbox"]');
this.setLocationEnabled(parseInt(locEnable.dataset.loc, 10), cb ? cb.checked : true);
return;
}
const locHeader = e.target.closest('[data-action="toggle-location"]');
if (locHeader) {
this.toggleLocationExpand(parseInt(locHeader.dataset.loc, 10));
return;
}
const locSave = e.target.closest('[data-action="save-location"]');
if (locSave) {
this.saveInlineLocation(parseInt(locSave.dataset.loc, 10));
return;
}
const locDelete = e.target.closest('[data-action="delete-location"]');
if (locDelete) {
this.deleteInlineLocation(parseInt(locDelete.dataset.loc, 10));
return;
}
const sshSave = e.target.closest('[data-action="ssh-key-save"]');
if (sshSave) { this.saveBackupSshKey(parseInt(sshSave.dataset.loc, 10)); return; }
const sshGen = e.target.closest('[data-action="ssh-key-generate"]');
if (sshGen) { this.generateBackupSshKey(parseInt(sshGen.dataset.loc, 10)); return; }
const sshDel = e.target.closest('[data-action="ssh-key-delete"]');
if (sshDel) { this.deleteBackupSshKey(parseInt(sshDel.dataset.loc, 10)); return; }
const sshCopy = e.target.closest('[data-action="ssh-key-copy"]');
if (sshCopy) { this.copyBackupSshKey(parseInt(sshCopy.dataset.loc, 10)); return; }
const locTab = e.target.closest('[data-action="loc-tab"]');
if (locTab) {
const tabIdx = locTab.dataset.loc;
const tabName = locTab.dataset.tab;
const root = locTab.closest('.backup-location-config') || document;
root.querySelectorAll(`[data-action="loc-tab"][data-loc="${tabIdx}"]`).forEach(b => {
const on = b === locTab;
b.classList.toggle('active', on);
b.setAttribute('aria-selected', on ? 'true' : 'false');
});
root.querySelectorAll(`[data-tab-panel][data-loc="${tabIdx}"]`).forEach(p => {
p.classList.toggle('active', p.dataset.tabPanel === tabName);
});
return;
}
if (e.target.closest('[data-close-modal]') || e.target.matches('.backup-modal')) {
this.closeAllModals();
return;
}
const migrateAppBtn = e.target.closest('[data-action="migrate-app"]');
if (migrateAppBtn) {
this.openMigrateModal({
mode: 'app',
locIdx: parseInt(migrateAppBtn.dataset.loc, 10),
host: migrateAppBtn.dataset.host,
app: migrateAppBtn.dataset.app
});
return;
}
const migrateHostBtn = e.target.closest('[data-action="migrate-host"]');
if (migrateHostBtn) {
this.openMigrateModal({
mode: 'host',
locIdx: parseInt(migrateHostBtn.dataset.loc, 10),
host: migrateHostBtn.dataset.host
});
return;
}
if (e.target.closest('#backup-migrate-confirm')) { this.confirmMigrate(); return; }
if (e.target.closest('#backup-restore-confirm')) { this.confirmRestore(); return; }
if (e.target.closest('#backup-delete-confirm')) { this.confirmDelete(); return; }
if (e.target.closest('#backup-add-location-confirm')) { this.confirmAddLocation(); return; }
const engineBtn = e.target.closest('[data-action="open-engine-details"]');
if (engineBtn) { this.openEngineDetailsModal(engineBtn); return; }
const exportBtn = e.target.closest('[data-action="export-passwords"]');
if (exportBtn) { this.toggleExportMenu(false); this.exportRepositoryPasswords(exportBtn); return; }
const 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 snapshot across every enabled location.',
locations: 'Where backups are stored. Add, edit, or remove destinations.',
migrate: 'Restore an app from another LibrePortal that shares one of your backup locations.',
configuration: 'Schedule, retention, and engine settings.'
}[tab] || '';
}
iconFor(tab) {
const icons = {
dashboard:
'<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>',
migrate:
'<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' +
'<path d="M3 12h18"></path>' +
'<polyline points="12 5 19 12 12 19"></polyline>' +
'<path d="M3 5v14"></path></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.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')}
`;
// 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 + `<div class="backup-empty-state">No apps installed yet.</div>`;
} else {
appGrid.innerHTML = systemTileHtml + 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('');
}
}
// 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 `
<div class="backup-app-tile backup-app-tile--system" data-system="1">
<img class="backup-app-tile-icon" src="/icons/apps/libreportal.svg" alt="" onerror="this.style.display='none'">
<div class="backup-app-tile-text">
<div class="backup-app-tile-name">System config</div>
<div class="backup-app-tile-meta">
<span class="backup-status-dot ${dot}"></span>
<span>${this.escape(when)}</span>
</div>
</div>
</div>
`;
}
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 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) => `
<button type="button" class="tab-button${id === 'connection' ? ' active' : ''}" data-action="loc-tab" data-loc="${idx}" data-tab="${id}" role="tab" aria-selected="${id === 'connection'}">
<span class="tab-emoji">${emoji}</span>
<span class="tab-name">${label}</span>
</button>`;
return `
<div class="config-category backup-location-config" data-section="location-${idx}">
<div class="tabs-wrapper">
<div class="tabs-list" role="tablist">
${tab('connection', '🔗', 'Connection')}
${tab('retention', '♻️', 'Retention')}
${tab('advanced', '⚙️', 'Advanced')}
</div>
<div class="tabs-content">
<div class="tab-panel active" data-tab-panel="connection" data-loc="${idx}">
<div class="backup-location-connection-fields" id="backup-location-${idx}-connection">
${this.renderConnectionInner(idx, l.type, l, groups.connection)}
</div>
</div>
<div class="tab-panel" data-tab-panel="retention" data-loc="${idx}">
<div id="backup-location-${idx}-retention">
${this.formRetention(`CFG_BACKUP_LOC_${idx}_`, retentionValues, true)}
</div>
</div>
<div class="tab-panel" data-tab-panel="advanced" data-loc="${idx}">
<div id="backup-location-${idx}-advanced">
${this.renderLocFields(idx, groups.advanced, l)}
</div>
</div>
</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 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 `<option value="${this.escape(e.id)}" ${e.id === want ? 'selected' : ''}>${this.escape(label)}</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 = 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}
<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
};
// 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 = '<div class="config-fields">';
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 += '</div>';
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 ? `
<p class="backup-card-hint">Add this public key to the remote server's <code>~/.ssh/authorized_keys</code>:</p>
<textarea class="backup-ssh-pubkey" readonly rows="2" spellcheck="false">${this.escape(pub)}</textarea>
<div class="backup-ssh-key-actions">
<button type="button" class="backup-secondary-btn" data-action="ssh-key-copy" data-loc="${idx}">Copy public key</button>
<button type="button" class="backup-danger-btn" data-action="ssh-key-delete" data-loc="${idx}">Delete key</button>
</div>` : `
<p class="backup-card-hint">Paste an existing private key, or generate one and we'll show the public key to add on the remote.</p>
<textarea class="backup-ssh-keyinput" rows="4" spellcheck="false" placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"></textarea>
<div class="backup-ssh-key-actions">
<button type="button" class="backup-primary-btn" data-action="ssh-key-save" data-loc="${idx}">Save key</button>
<button type="button" class="backup-secondary-btn" data-action="ssh-key-generate" data-loc="${idx}">Generate keypair</button>
</div>`;
return `
<div class="backup-ssh-key-card" data-loc="${idx}">
<div class="backup-ssh-key-head">
<span class="backup-ssh-key-title">SSH key</span>
<span class="backup-ssh-key-status ${hasKey ? 'ok' : 'none'}">${hasKey ? '✓ Key configured' : 'No key yet'}</span>
</div>
${body}
</div>`;
}
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 = `
<div class="backup-form-grid">
${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')}
</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 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) => `
<label class="backup-pick-row" style="display:flex; align-items:center; gap:12px; padding:10px 12px; border:1px solid rgba(var(--text-rgb),0.08); border-radius:8px; margin-bottom:6px; cursor:pointer">
<input type="checkbox" class="backup-pick-cb" value="${this.escape(key)}" ${checked ? 'checked' : ''}>
<img src="${this.escape(iconSrc)}" alt="" style="width:28px; height:28px; flex-shrink:0; border-radius:6px" onerror="this.style.display='none'">
<div style="flex:1; min-width:0">
<div style="font-weight:600">${this.escape(label)}</div>
<div class="backup-card-hint" style="font-size:.82em">${this.escape(sub)}</div>
</div>
</label>
`;
const sysSub = sys.latest_snapshot
? 'Last backed up ' + this.formatRelative(sys.latest_time)
: 'No backup yet';
const rows = [
row('__system__', '/icons/apps/libreportal.svg', 'System config', 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 = `
<p class="backup-card-hint" style="margin:0 0 10px">
Pick what to snapshot. Each selection runs in the background and shows up on the Tasks page.
</p>
<div style="display:flex; gap:14px; margin-bottom:10px; font-size:.85em">
<a href="#" data-pick-action="select-all" style="color:var(--accent); text-decoration:none">Select all</a>
<a href="#" data-pick-action="select-none" style="color:var(--accent); text-decoration:none">Clear</a>
</div>
<div class="backup-pick-list" style="max-height:60vh; overflow-y:auto">${rows}</div>
`;
// 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.
if (wantsSystem && 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 => `
<div class="backup-migrate-location">
<div class="backup-card-header" style="margin-bottom:8px">
<h3 style="margin:0">${this.escape(loc.name || 'Location')}</h3>
<span class="backup-card-hint">${(loc.hosts || []).length} other host${loc.hosts.length === 1 ? '' : 's'} backing up here</span>
</div>
${loc.hosts.map(host => {
const peerName = (this.hostnameToPeerName || {})[host.hostname];
const headerLabel = peerName
? `<strong style="font-size:1.05em">${this.escape(peerName)}</strong><span class="backup-card-hint" style="margin-left:6px; font-size:.85em">host: <code>${this.escape(host.hostname)}</code></span>`
: `<strong style="font-size:1.05em">${this.escape(host.hostname)}</strong>`;
return `
<div class="backup-migrate-host" style="border:1px solid var(--border-color, #2a2a2a); border-radius:8px; padding:14px; margin-bottom:12px">
<div style="display:flex; justify-content:space-between; align-items:center; gap:12px; margin-bottom:10px">
<div>
${headerLabel}
<span class="backup-card-hint" style="margin-left:10px">${(host.apps || []).length} app${host.apps.length === 1 ? '' : 's'} available</span>
</div>
<button class="backup-primary-btn" data-action="migrate-host"
data-loc="${loc.idx}" data-host="${this.escape(host.hostname)}">
Migrate every app from this host
</button>
</div>
<div class="backup-migrate-apps" style="display:grid; grid-template-columns:repeat(auto-fill, minmax(280px, 1fr)); gap:8px">
${(host.apps || []).map(app => {
const collide = installed.has(app.slug);
return `
<div class="backup-migrate-app" style="display:flex; justify-content:space-between; align-items:center; padding:8px 12px; background:var(--surface-2, #1a1a1a); border-radius:6px">
<div style="display:flex; flex-direction:column; min-width:0">
<span style="display:flex; align-items:center; gap:8px">
<strong>${this.escape(app.slug)}</strong>
${collide ? `<span class="backup-status-dot warn" title="Already installed here"></span>` : ''}
</span>
<span class="backup-card-hint" style="font-size:.82em">
${app.snapshots} snapshot${app.snapshots === 1 ? '' : 's'} · latest ${this.escape(this.formatRelativeTime(app.latest_date))}
</span>
</div>
<button class="backup-secondary-btn" data-action="migrate-app"
data-loc="${loc.idx}" data-host="${this.escape(host.hostname)}" data-app="${this.escape(app.slug)}">
Migrate
</button>
</div>
`;
}).join('')}
</div>
</div>
`;
}).join('')}
</div>
`).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'
? `<p>Migrate <strong>${this.escape(app)}</strong> from <strong>${this.escape(host)}</strong> via <strong>${this.escape(locName)}</strong> onto this host.</p>`
: `<p>Migrate <strong>every app</strong> (${targetApps.length}) from <strong>${this.escape(host)}</strong> via <strong>${this.escape(locName)}</strong> onto this host.</p>`;
let collisionNote = '';
if (collisions.length) {
collisionNote = `
<p class="backup-card-hint" style="color:var(--warning, #d97706); margin-top:8px">
⚠ Already installed here: ${collisions.map(c => `<code>${this.escape(c)}</code>`).join(', ')}.
These will be <strong>replaced</strong>.
${collisionsRunning.length ? `Currently running: ${collisionsRunning.map(c => `<code>${this.escape(c)}</code>`).join(', ')} — will be stopped first.` : ''}
</p>
`;
}
body.innerHTML = `
${intro}
${collisionNote}
<div style="margin-top:14px; display:flex; flex-direction:column; gap:8px">
<label style="display:flex; align-items:flex-start; gap:8px; cursor:pointer">
<input type="checkbox" id="migrate-opt-pre-backup" ${collisions.length ? 'checked' : 'disabled'}>
<span>
Back up the destination's existing copy first
<span class="backup-card-hint" style="display:block; font-size:.85em">
Safety net: snapshot the current ${mode === 'app' ? this.escape(app) : 'app'} into your first
enabled backup location (tagged <code>pre-migrate</code>) before wipe.
${collisions.length ? '' : 'No collision — nothing to back up.'}
</span>
</span>
</label>
<label style="display:flex; align-items:flex-start; gap:8px; cursor:pointer">
<input type="checkbox" id="migrate-opt-rewrite-urls" checked>
<span>
Rewrite host-bound URLs to this host
<span class="backup-card-hint" style="display:block; font-size:.85em">
Replaces <code>CFG_*_URL</code>, <code>*_DOMAIN</code>, <code>*_HOSTNAME</code> with this
host's values. Uncheck only if you want the moved app to keep claiming the source's hostname.
</span>
</span>
</label>
</div>
`;
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 => ({
'&': '&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;