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>
754 lines
31 KiB
JavaScript
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 => ({
|
|
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
|
})[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;
|