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

284 lines
15 KiB
JavaScript

// Auto-extracted from backup-page.js (verbatim) — augments BackupPage.prototype. Loaded after the base.
Object.assign(BackupPage.prototype, {
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>
`;
},
typeIcon(type) {
const local = `<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<ellipse cx="12" cy="5" rx="9" ry="3"></ellipse>
<path d="M3 5v6c0 1.66 4.03 3 9 3s9-1.34 9-3V5"></path>
<path d="M3 11v6c0 1.66 4.03 3 9 3s9-1.34 9-3v-6"></path>
</svg>`;
const cloud = `<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 10h-1.26A8 8 0 1 0 9 20h9a5 5 0 0 0 0-10z"></path>
</svg>`;
return type === 'local' ? local : cloud;
},
renderLocationDetailsBody(l) {
const idx = l.idx;
const groups = this.locFieldGroups(idx, l.type);
const retentionValues = {
last: l.custom_retention ? (l.keep_last || '') : '',
daily: l.custom_retention ? (l.keep_daily || '') : '',
weekly: l.custom_retention ? (l.keep_weekly || '') : '',
monthly: l.custom_retention ? (l.keep_monthly || '') : '',
yearly: l.custom_retention ? (l.keep_yearly || '') : ''
};
// Reuse the app-detail tab design (.tabs-wrapper/.tab-button/.tab-panel
// from style.css) so the Locations editor matches the rest of the UI.
const tab = (id, emoji, label) => `
<button type="button" class="tab-button${id === 'connection' ? ' active' : ''}" data-action="loc-tab" data-loc="${idx}" data-tab="${id}" role="tab" aria-selected="${id === 'connection'}">
<span class="tab-emoji">${emoji}</span>
<span class="tab-name">${label}</span>
</button>`;
return `
<div class="config-category backup-location-config" data-section="location-${idx}">
<div class="tabs-wrapper">
<div class="tabs-list" role="tablist">
${tab('connection', '🔗', 'Connection')}
${tab('retention', '♻️', 'Retention')}
${tab('advanced', '⚙️', 'Advanced')}
</div>
<div class="tabs-content">
<div class="tab-panel active" data-tab-panel="connection" data-loc="${idx}">
<div class="backup-location-connection-fields" id="backup-location-${idx}-connection">
${this.renderConnectionInner(idx, l.type, l, groups.connection)}
</div>
</div>
<div class="tab-panel" data-tab-panel="retention" data-loc="${idx}">
<div id="backup-location-${idx}-retention">
${this.formRetention(`CFG_BACKUP_LOC_${idx}_`, retentionValues, true)}
</div>
</div>
<div class="tab-panel" data-tab-panel="advanced" data-loc="${idx}">
<div id="backup-location-${idx}-advanced">
${this.renderLocFields(idx, groups.advanced, l)}
</div>
</div>
</div>
</div>
</div>
<div class="backup-location-actions">
<button class="backup-primary-btn" data-action="save-location" data-loc="${idx}">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path>
<polyline points="17 21 17 13 7 13 7 21"></polyline>
<polyline points="7 3 7 8 15 8"></polyline>
</svg>
Save changes
</button>
<button class="backup-danger-btn" data-action="delete-location" data-loc="${idx}">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="m19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
<line x1="10" y1="11" x2="10" y2="17"></line>
<line x1="14" y1="11" x2="14" y2="17"></line>
</svg>
Delete location
</button>
</div>
`;
},
toggleLocationExpand(idx) {
const row = document.querySelector(`.backup-location-row[data-loc="${idx}"]`);
if (!row) return;
const details = row.querySelector('.task-details');
const header = row.querySelector('.task-header');
if (!details) return;
const willOpen = !this.expandedLocs.has(idx);
if (willOpen) {
this.expandedLocs.add(idx);
const loc = (this.locations?.locations || []).find(l => l.idx === idx);
if (loc) {
details.innerHTML = this.renderLocationDetailsBody(loc);
this.tagFieldsForSave(details);
this.filterEngineSelect(details, loc.type, loc.engine);
this.applySshAuthVisibility(details);
this.applyPathModeVisibility(details);
}
this.enhanceEngineDetailsButton();
details.classList.add('show');
row.classList.add('expanded');
if (header) header.setAttribute('aria-expanded', 'true');
} else {
this.expandedLocs.delete(idx);
details.classList.remove('show');
row.classList.remove('expanded');
if (header) header.setAttribute('aria-expanded', 'false');
}
},
refreshInlineTypeFields(idx, type) {
const loc = (this.locations?.locations || []).find(l => l.idx === idx) || {};
const groups = this.locFieldGroups(idx, type);
const conn = document.getElementById(`backup-location-${idx}-connection`);
if (conn) {
conn.innerHTML = this.renderConnectionInner(idx, type, { ...loc, type }, groups.connection);
this.tagFieldsForSave(conn);
}
// The Advanced tab's fields are type-dependent too (URI override only
// applies to some types), so rebuild it alongside the Connection tab.
const adv = document.getElementById(`backup-location-${idx}-advanced`);
if (adv) {
adv.innerHTML = this.renderLocFields(idx, groups.advanced, { ...loc, type });
this.tagFieldsForSave(adv);
}
// Re-apply dynamic behaviors across the whole details scope: the engine
// select lives in the Advanced tab while SSH-auth / path-mode live in
// Connection, so target the shared parent rather than one panel.
const scope = (conn || adv)?.closest('.task-details');
if (scope) {
this.filterEngineSelect(scope, type, loc.engine);
this.applySshAuthVisibility(scope);
this.applyPathModeVisibility(scope);
}
this.enhanceEngineDetailsButton();
},
applySshAuthVisibility(scope) {
const authSelect = scope.querySelector('select[name$="_SSH_AUTH"]');
if (!authSelect) return;
const isPassword = authSelect.value === 'password';
const passInput = scope.querySelector('input[name$="_SSH_PASS"]');
const passGroup = passInput?.closest('.field-group') || passInput?.closest('.password-mode-wrapper')?.parentElement;
if (passGroup) passGroup.style.display = isPassword ? '' : 'none';
// SSH key card is the counterpart: shown for key auth, hidden for password.
const keyCard = scope.querySelector('.backup-ssh-key-card');
if (keyCard) keyCard.style.display = isPassword ? 'none' : '';
},
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';
},
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}`);
},
deleteInlineLocation(idx) {
const loc = (this.locations?.locations || []).find(l => l.idx === idx);
const name = loc?.name || `Location ${idx}`;
const modal = document.getElementById('backup-delete-location-modal');
const body = document.getElementById('backup-delete-location-modal-body');
if (!modal || !body) return;
body.innerHTML = `
<p>Delete location <strong>${this.escape(name)}</strong>?</p>
<p class="backup-card-hint">Backup data already stored at this location is not deleted — only LibrePortal's reference to it. The password file on disk also stays in place.</p>
`;
modal.dataset.locIdx = String(idx);
modal.classList.add('open');
},
async confirmDeleteLocation() {
const modal = document.getElementById('backup-delete-location-modal');
if (!modal) return;
const idx = parseInt(modal.dataset.locIdx, 10);
this.closeAllModals();
this.expandedLocs.delete(idx);
await this.runTask(`libreportal backup location remove ${idx}`, 'backup', null);
setTimeout(() => this.reloadAfterSave(), 2000);
},
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');
}
},
});