librelad 82989069e2 refactor(backup): decompose backup-page god-file into 13 responsibility files
Faithful prototype-augment split of backup-page.js (2353->753 line base) into
fetch-client, dashboard, snapshots, locations, location-fields, ssh-key,
retention-presets, configuration, engine-details, location-modal,
snapshot-actions, migrate (+ the earlier cron-schedule). Methods relocated
verbatim (mechanical sed/awk extraction, no logic change); all augment
BackupPage.prototype and load after the base via the ordered kernel loader.
Verified: all 99 original methods present exactly once across base+clusters,
no duplicates, all 14 files node --check clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-30 14:02:45 +01:00

754 lines
31 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).
// Module-level schema/retention data moved to backup-schema.js (loaded first).
class BackupPage {
constructor() {
this.currentTab = 'dashboard';
this.dashboard = null;
this.locations = null;
this.snapshotsByLoc = {};
this.expandedLocs = new Set();
this.engines = []; // [{id,name,supported_types}, ...] — fetched once
this.taskManager = (typeof TaskManager !== 'undefined') ? new TaskManager() : null;
this.eventBound = false;
this._taskRefreshTimer = null;
}
async init() {
this.currentTab = this.parseTabFromUrl() || this.currentTab;
this.applyActiveTabUi(this.currentTab);
this.bindEvents();
await this.refreshAll();
await window.Dismissible?.load();
this.render();
this.updatePageHeader();
this.updatePrimaryAction();
}
/* Read the active tab slug from window.location, supporting both
/backup?=dashboard (the legacy libreportal ?= form used on /config)
and /backup?backup=dashboard (standard query string) so links from
either source resolve correctly. */
parseTabFromUrl() {
const allowed = new Set(['dashboard', 'backups', 'locations', 'migrate', 'configuration']);
// Path-based: /backup/<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;
// Backups/restores/deletes complete asynchronously with no live feed, so
// repaint when one finishes (snapshot lists, last-backup times, sizes).
// Registered with the task-refresh coordinator (single source of truth);
// debounced because picking several apps queues a task each, finishing
// in a burst. The Refresh button stays as a manual pull.
window.taskRefresh?.register({
id: 'backups',
match: (d) => ['backup', 'restore', 'delete', 'delete_all'].includes(d.action)
|| /^libreportal\s+(backup|restore)\b/.test((d.task && d.task.command) || ''),
run: () => {
if (window.backupPage === this && document.getElementById('backup-page')) {
return this.refreshAll().then(() => this.render());
}
},
debounceMs: 600,
});
// Browser back/forward is handled by the SPA's popstate listener —
// pushTabToUrl includes a `route` field in state so the SPA's
// handler picks it up and re-runs handleBackup, which re-parses
// the URL via parseTabFromUrl() at init time.
document.addEventListener('click', (e) => {
// Clicking outside the export dropdown (and not on its trigger) closes it.
const exportMenu = document.getElementById('backup-export-menu');
if (exportMenu && !exportMenu.hidden
&& !e.target.closest('#backup-export-menu')
&& !e.target.closest('#backup-primary-action')) {
this.toggleExportMenu(false);
}
const tabBtn = e.target.closest('.backup-layout .sidebar .category[data-backup-tab]');
if (tabBtn) {
this.switchTab(tabBtn.dataset.backupTab);
return;
}
if (e.target.closest('#backup-refresh-btn')) {
this.refreshAll().then(() => this.render());
return;
}
if (e.target.closest('#backup-primary-action')) {
this.handlePrimaryAction();
return;
}
if (e.target.closest('[data-action="backup-system"]')) {
this.runBackupSystem();
return;
}
if (e.target.closest('[data-action="restore-system"]')) {
this.confirmRestoreSystem();
return;
}
// Deep-link from the Snapshots table → /app/<name>/backups?snapshot=<id>.
// Routed via the SPA so the app page mounts in-place rather than a
// full reload.
const deepLink = e.target.closest('[data-deep-link]');
if (deepLink) {
e.preventDefault();
if (window.navigateToRoute) window.navigateToRoute(deepLink.dataset.deepLink);
else window.location.href = deepLink.dataset.deepLink;
return;
}
// Tile actions, in priority order:
// 1. "Backup now" pill on the tile → opens the pick modal
// preticked with that tile (explicit affordance, replaces
// the old implicit whole-tile click).
// 2. Whole-tile click → navigates to the per-app Backups tab
// (or the system page for the System tile). This is the
// cohesion fix: each tile leads to the page that owns
// that subject's full detail, not a modal asking "do
// you want to back up?".
const backupNowBtn = e.target.closest('[data-action="backup-now"]');
if (backupNowBtn) {
e.stopPropagation();
if (backupNowBtn.dataset.system) {
this.openBackupPickModal({ preTickSystem: true });
} else if (backupNowBtn.dataset.app) {
this.openBackupPickModal({ preTickApps: [backupNowBtn.dataset.app] });
}
return;
}
const tile = e.target.closest('.backup-app-tile');
if (tile) {
if (tile.dataset.system) {
// System has no dedicated page yet — keep the pick modal.
this.openBackupPickModal({ preTickSystem: true });
} else if (tile.dataset.app && window.navigateToRoute) {
window.navigateToRoute(`/app/${encodeURIComponent(tile.dataset.app)}/backups`);
}
return;
}
if (e.target.closest('#backup-pick-confirm')) {
this.confirmBackupPick();
return;
}
const restoreBtn = e.target.closest('[data-action="restore-snapshot"]');
if (restoreBtn) {
e.stopPropagation(); // don't also toggle the row's details panel
this.openRestoreModal(restoreBtn.dataset.app, restoreBtn.dataset.loc, restoreBtn.dataset.snapshot);
return;
}
const deleteBtn = e.target.closest('[data-action="delete-snapshot"]');
if (deleteBtn) {
e.stopPropagation();
this.openDeleteModal(deleteBtn.dataset.app, deleteBtn.dataset.loc, deleteBtn.dataset.snapshot);
return;
}
// Row header / Details button → toggle the .task-details panel.
// Matches the per-app Backups tab interaction.
const snapToggle = e.target.closest('[data-action="toggle-snapshot-row"]');
if (snapToggle) {
const item = snapToggle.closest('.backup-snapshot-item');
const details = item && item.querySelector('.task-details');
if (details) details.classList.toggle('task-details-open');
return;
}
const locEnable = e.target.closest('[data-action="toggle-location-enabled"]');
if (locEnable) {
const cb = locEnable.querySelector('input[type="checkbox"]');
this.setLocationEnabled(parseInt(locEnable.dataset.loc, 10), cb ? cb.checked : true);
return;
}
const locHeader = e.target.closest('[data-action="toggle-location"]');
if (locHeader) {
this.toggleLocationExpand(parseInt(locHeader.dataset.loc, 10));
return;
}
const locSave = e.target.closest('[data-action="save-location"]');
if (locSave) {
this.saveInlineLocation(parseInt(locSave.dataset.loc, 10));
return;
}
const locDelete = e.target.closest('[data-action="delete-location"]');
if (locDelete) {
this.deleteInlineLocation(parseInt(locDelete.dataset.loc, 10));
return;
}
const sshSave = e.target.closest('[data-action="ssh-key-save"]');
if (sshSave) { this.saveBackupSshKey(parseInt(sshSave.dataset.loc, 10)); return; }
const sshGen = e.target.closest('[data-action="ssh-key-generate"]');
if (sshGen) { this.generateBackupSshKey(parseInt(sshGen.dataset.loc, 10)); return; }
const sshDel = e.target.closest('[data-action="ssh-key-delete"]');
if (sshDel) { this.deleteBackupSshKey(parseInt(sshDel.dataset.loc, 10)); return; }
const sshCopy = e.target.closest('[data-action="ssh-key-copy"]');
if (sshCopy) { this.copyBackupSshKey(parseInt(sshCopy.dataset.loc, 10)); return; }
const locTab = e.target.closest('[data-action="loc-tab"]');
if (locTab) {
const tabIdx = locTab.dataset.loc;
const tabName = locTab.dataset.tab;
const root = locTab.closest('.backup-location-config') || document;
root.querySelectorAll(`[data-action="loc-tab"][data-loc="${tabIdx}"]`).forEach(b => {
const on = b === locTab;
b.classList.toggle('active', on);
b.setAttribute('aria-selected', on ? 'true' : 'false');
});
root.querySelectorAll(`[data-tab-panel][data-loc="${tabIdx}"]`).forEach(p => {
p.classList.toggle('active', p.dataset.tabPanel === tabName);
});
return;
}
if (e.target.closest('[data-close-modal]') || e.target.matches('.backup-modal')) {
this.closeAllModals();
return;
}
const migrateAppBtn = e.target.closest('[data-action="migrate-app"]');
if (migrateAppBtn) {
this.openMigrateModal({
mode: 'app',
locIdx: parseInt(migrateAppBtn.dataset.loc, 10),
host: migrateAppBtn.dataset.host,
app: migrateAppBtn.dataset.app
});
return;
}
const migrateHostBtn = e.target.closest('[data-action="migrate-host"]');
if (migrateHostBtn) {
this.openMigrateModal({
mode: 'host',
locIdx: parseInt(migrateHostBtn.dataset.loc, 10),
host: migrateHostBtn.dataset.host
});
return;
}
if (e.target.closest('#backup-migrate-confirm')) { this.confirmMigrate(); return; }
if (e.target.closest('#backup-restore-confirm')) { this.confirmRestore(); return; }
if (e.target.closest('#backup-delete-confirm')) { this.confirmDelete(); return; }
if (e.target.closest('#backup-delete-location-confirm')) { this.confirmDeleteLocation(); return; }
if (e.target.closest('#backup-add-location-confirm')) { this.confirmAddLocation(); return; }
const engineBtn = e.target.closest('[data-action="open-engine-details"]');
if (engineBtn) { this.openEngineDetailsModal(engineBtn); return; }
const exportBtn = e.target.closest('[data-action="export-passwords"]');
if (exportBtn) { this.toggleExportMenu(false); this.exportRepositoryPasswords(exportBtn); return; }
const goToLocations = e.target.closest('[data-action="go-to-locations"]');
if (goToLocations) { this.switchTab('locations'); return; }
const dismissWarn = e.target.closest('[data-action="dismiss-config-warning"]');
if (dismissWarn) {
window.Dismissible?.dismiss('backup-config-warning');
const banner = dismissWarn.closest('.backup-warning-banner');
const divider = banner?.nextElementSibling;
if (divider && divider.classList.contains('config-divider')) divider.remove();
banner?.remove();
return;
}
const saveBtn = e.target.closest('[data-backup-save]');
if (saveBtn) {
this.saveSection(saveBtn.dataset.backupSave);
return;
}
});
document.addEventListener('input', (e) => {
if (e.target.id === 'backup-snapshot-filter' || e.target.id === 'backup-snapshot-repo') {
this.renderSnapshots();
}
});
// Type select changes refresh the visible connection fields inline.
// Retention preset changes are handled by applyRetentionPreset, which
// already updates CUSTOM_RETENTION too — no extra toggle wiring needed.
document.addEventListener('change', (e) => {
const detailsScope = e.target.closest('.backup-location-row .task-details');
if (detailsScope) {
const locIdx = parseInt(detailsScope.dataset.loc, 10);
if (e.target.matches('[name$="_TYPE"]')) {
this.refreshInlineTypeFields(locIdx, e.target.value);
}
if (e.target.matches('[name$="_SSH_AUTH"]')) {
this.applySshAuthVisibility(detailsScope);
}
if (e.target.matches('[name$="_PATH_MODE"]')) {
this.applyPathModeVisibility(detailsScope);
}
}
const presetSel = e.target.closest('[data-retention-preset]');
if (presetSel) {
this.applyRetentionPreset(presetSel);
}
});
}
/* Load the unified config file once for the Locations editor: configData
carries field metadata (titles/descriptions/options/advanced) the editor
renders from; systemConfigs is the flat key->value map used for default
lookups (e.g. CFG_BACKUP_ENGINE) and save-time change detection. */
switchTab(tab, opts = {}) {
if (!tab || tab === this.currentTab) return;
this.currentTab = tab;
this.applyActiveTabUi(tab);
this.updatePageHeader();
this.updatePrimaryAction();
if (!opts.fromPopstate) this.pushTabToUrl(tab);
}
pushTabToUrl(tab) {
const url = `/backup/${tab}`;
// Use replaceState for the *first* push (initial tab inferred from
// URL); otherwise pushState so back/forward navigates between tabs.
if (!this._pushedAnyTab) {
window.history.replaceState({ backupTab: tab, route: url }, '', url);
this._pushedAnyTab = true;
} else {
window.history.pushState({ backupTab: tab, route: url }, '', url);
}
}
updatePageHeader() {
const titleEl = document.getElementById('backup-section-title');
const subEl = document.getElementById('backup-section-subtitle');
const iconEl = document.getElementById('backup-page-header-icon');
if (titleEl) titleEl.textContent = this.titleFor(this.currentTab);
if (subEl) subEl.textContent = this.subtitleFor(this.currentTab);
if (iconEl) iconEl.innerHTML = this.iconFor(this.currentTab);
}
titleFor(tab) {
return {
dashboard: 'Dashboard',
backups: 'Backups',
locations: 'Locations',
migrate: 'Migrate',
configuration: 'Configuration'
}[tab] || 'Backups';
}
subtitleFor(tab) {
return {
dashboard: "Check what's protected — and when it last ran.",
backups: 'Every backup across every enabled location.',
locations: 'Where backups are stored. Add, edit, or remove destinations.',
migrate: 'Restore an app from another LibrePortal that shares one of your backup locations.',
configuration: 'Schedule, retention, and engine settings.'
}[tab] || '';
}
iconFor(tab) {
const icons = {
dashboard:
'<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.style.display = '';
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.style.display = '';
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 if (this.currentTab === 'backups') {
btn.style.display = '';
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');
} else {
// Dashboard and Migrate have no header primary action.
btn.style.display = 'none';
btn.removeAttribute('aria-haspopup');
}
}
handlePrimaryAction() {
const intent = document.getElementById('backup-primary-action')?.dataset.intent;
if (intent === 'add-location') {
this.openAddLocationModal();
} else if (intent === 'export-menu') {
this.toggleExportMenu();
} else {
this.runBackupAllApps();
}
}
toggleExportMenu(force) {
const menu = document.getElementById('backup-export-menu');
const btn = document.getElementById('backup-primary-action');
if (!menu) return;
const show = typeof force === 'boolean' ? force : menu.hidden;
menu.hidden = !show;
if (btn) btn.setAttribute('aria-expanded', show ? 'true' : 'false');
}
render() {
this.renderDashboard();
this.renderLocations();
this.renderSnapshots();
this.renderMigrate();
this.renderConfiguration();
}
/* Look up the icon + display name from window.apps the same way the
dashboard and tasks page do. Falls back to the default app icon and
a capitalised slug if the app isn't in the cached list. */
appMeta(slug) {
const apps = window.apps || [];
const match = apps.find(a => {
const command = a.command || '';
return command.endsWith(` ${slug}`) || a.name?.toLowerCase() === slug.toLowerCase();
});
let icon = match?.icon || '/core/icons/apps/default.svg';
if (!icon.startsWith('/')) icon = '/' + icon;
const displayName = (typeof window.getAppDisplayName === 'function')
? window.getAppDisplayName(slug)
: (slug.charAt(0).toUpperCase() + slug.slice(1));
return { icon, displayName };
}
/* Inline-SVG icon for a location's backend type. Local gets the disk
(stack of platters) glyph; everything else gets a cloud — that's
the visual line between "lives on this box" and "lives somewhere else." */
/* Hide the SSH password field when SSH auth = key, show it when = password.
Applied at expand time and whenever the SSH_AUTH select changes. */
/* Hide the custom PATH input when PATH_MODE=auto, show when =custom. */
/* Trim the per-location ENGINE select to only engines whose
supported_types include the location's current TYPE. If the currently
saved engine isn't compatible, fall back to the first compatible one. */
/* Post-render polish on the dynamic /config render: wrap the five raw
retention number fields in a persona-preset dropdown. The five inputs
stay in the DOM (so /config's save flow captures them unchanged) but
are hidden under "Custom…" by default. */
/* Build the <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. */
/* 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. */
/* 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. */
/* ----- Location modal (edit / add) ----- */
/* 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. */
/* 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. */
/* 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. */
/* Split a type's fields into the Connection tab vs the Advanced tab. */
/* Connection-tab body: the generic fields plus, for sftp, the SSH key card.
Used both on first render and when the Type select changes. */
/* 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. */
/* ----- Add location modal ----- */
/* ----- Snapshot restore/delete modals ----- */
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'));
}
/* ----- 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. */
/* ----- Migrate (Phase 1: shared-backup) ----- */
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 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');
}
}
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;