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>
284 lines
15 KiB
JavaScript
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');
|
|
}
|
|
},
|
|
});
|