The expanded location row was one long form. Split it into tabs so it opens showing only the Connection fields. Retention moves from a stacked section into its own tab, and the advanced overrides (URI/SSH port/append-only) get their own tab instead of the inline disclosure from the previous pass. Field grouping is metadata-driven: locFieldGroups partitions a type's fields into Connection vs Advanced via the configs.json "advanced" flag (with LOC_ADVANCED_SUFFIXES as the legacy fallback). Type changes rebuild both the Connection and Advanced panels since advanced fields are type-dependent too. Save still reads every field across all panels (hidden tabs stay in the DOM). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> Signed-off-by: librelad <librelad@digitalangels.vip>
1742 lines
83 KiB
JavaScript
1742 lines
83 KiB
JavaScript
// 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']
|
||
};
|
||
|
||
// 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.
|
||
const LOC_ADVANCED_SUFFIXES = new Set(['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', '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;
|
||
}
|
||
|
||
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.toggleAttribute('hidden', p.dataset.tabPanel !== tabName);
|
||
});
|
||
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.loadSystemConfigs()
|
||
]);
|
||
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; }
|
||
}
|
||
|
||
/* 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',
|
||
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 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 || '') : ''
|
||
};
|
||
|
||
const tab = (id, label) => `
|
||
<button type="button" class="backup-loc-tab${id === 'connection' ? ' active' : ''}" data-action="loc-tab" data-loc="${idx}" data-tab="${id}" role="tab" aria-selected="${id === 'connection'}">${label}</button>`;
|
||
const advancedBody = groups.advanced.length
|
||
? this.renderLocFields(idx, groups.advanced, l)
|
||
: `<div class="backup-empty-state">No advanced options for this location type.</div>`;
|
||
|
||
return `
|
||
<div class="config-category backup-location-config" data-section="location-${idx}">
|
||
<div class="backup-loc-tabs" role="tablist">
|
||
${tab('connection', 'Connection')}
|
||
${tab('retention', 'Retention')}
|
||
${tab('advanced', 'Advanced')}
|
||
</div>
|
||
<div class="backup-loc-tab-panel" data-tab-panel="connection" data-loc="${idx}">
|
||
<p class="category-description">How LibrePortal connects to this storage location.</p>
|
||
<div class="backup-location-connection-fields" id="backup-location-${idx}-connection">
|
||
${this.renderLocFields(idx, groups.connection, l)}
|
||
</div>
|
||
</div>
|
||
<div class="backup-loc-tab-panel" data-tab-panel="retention" data-loc="${idx}" hidden>
|
||
<p class="category-description">When to delete old backups from this location.</p>
|
||
<div id="backup-location-${idx}-retention">
|
||
${this.formRetention(`CFG_BACKUP_LOC_${idx}_`, retentionValues, true)}
|
||
</div>
|
||
</div>
|
||
<div class="backup-loc-tab-panel" data-tab-panel="advanced" data-loc="${idx}" hidden>
|
||
<p class="category-description">Overrides most locations don't need.</p>
|
||
<div id="backup-location-${idx}-advanced">
|
||
${advancedBody}
|
||
</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.renderLocFields(idx, groups.connection, { ...loc, type });
|
||
this.tagFieldsForSave(conn);
|
||
this.filterEngineSelect(conn, type, loc.engine);
|
||
this.applySshAuthVisibility(conn);
|
||
this.applyPathModeVisibility(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 = groups.advanced.length
|
||
? this.renderLocFields(idx, groups.advanced, { ...loc, type })
|
||
: `<div class="backup-empty-state">No advanced options for this location type.</div>`;
|
||
this.tagFieldsForSave(adv);
|
||
}
|
||
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;
|
||
|
||
// 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 = 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
|
||
};
|
||
|
||
// 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
|
||
};
|
||
}
|
||
|
||
/* Split a type's fields into the Connection tab vs the Advanced tab. */
|
||
locFieldGroups(idx, type) {
|
||
const suffixes = BACKUP_LOC_FIELDS_BY_TYPE[type] || BACKUP_LOC_FIELDS_BY_TYPE.local;
|
||
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 };
|
||
}
|
||
|
||
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() {
|
||
await this.refreshAll();
|
||
this.render();
|
||
}
|
||
|
||
notify(message, type) {
|
||
if (window.notificationSystem) {
|
||
window.notificationSystem.show(message, type || 'info');
|
||
} else {
|
||
console.log(`[backup ${type || 'info'}] ${message}`);
|
||
}
|
||
}
|
||
|
||
escape(s) {
|
||
return String(s ?? '').replace(/[&<>"']/g, c => ({
|
||
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
||
})[c]);
|
||
}
|
||
|
||
formatBytes(b) {
|
||
if (!b || b < 0) return '0 B';
|
||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||
let i = 0;
|
||
let v = b;
|
||
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
|
||
return `${v.toFixed(v < 10 ? 2 : 1)} ${units[i]}`;
|
||
}
|
||
|
||
formatRelative(iso) {
|
||
if (!iso) return '—';
|
||
const t = new Date(iso).getTime();
|
||
if (!t) return iso;
|
||
const diff = Math.max(0, Date.now() - t);
|
||
const s = Math.floor(diff / 1000);
|
||
if (s < 60) return 'just now';
|
||
const m = Math.floor(s / 60);
|
||
if (m < 60) return `${m}m ago`;
|
||
const h = Math.floor(m / 60);
|
||
if (h < 48) return `${h}h ago`;
|
||
const d = Math.floor(h / 24);
|
||
if (d < 30) return `${d}d ago`;
|
||
return new Date(iso).toLocaleDateString();
|
||
}
|
||
}
|
||
|
||
window.BackupPage = BackupPage;
|