Merge claude/1
This commit is contained in:
commit
e0737b65ef
@ -15,7 +15,20 @@ LP.features.register({
|
|||||||
// Controllers the feature needs; lazy-loaded on first mount (idempotent).
|
// Controllers the feature needs; lazy-loaded on first mount (idempotent).
|
||||||
scripts: [
|
scripts: [
|
||||||
'/components/backup/js/backup-schema.js',
|
'/components/backup/js/backup-schema.js',
|
||||||
'/components/backup/js/backup-page.js',
|
'/components/backup/js/backup-page.js', // base: class + constructor + init/switchTab/render
|
||||||
|
// prototype-augment clusters (load after the base, order among them is free):
|
||||||
|
'/components/backup/js/backup-fetch-client.js',
|
||||||
|
'/components/backup/js/backup-dashboard.js',
|
||||||
|
'/components/backup/js/backup-snapshots.js',
|
||||||
|
'/components/backup/js/backup-locations.js',
|
||||||
|
'/components/backup/js/backup-location-fields.js',
|
||||||
|
'/components/backup/js/backup-ssh-key.js',
|
||||||
|
'/components/backup/js/backup-retention-presets.js',
|
||||||
|
'/components/backup/js/backup-configuration.js',
|
||||||
|
'/components/backup/js/backup-engine-details.js',
|
||||||
|
'/components/backup/js/backup-location-modal.js',
|
||||||
|
'/components/backup/js/backup-snapshot-actions.js',
|
||||||
|
'/components/backup/js/backup-migrate.js',
|
||||||
'/components/backup/js/backup-cron-schedule.js',
|
'/components/backup/js/backup-cron-schedule.js',
|
||||||
'/core/lib/backup-app-card.js',
|
'/core/lib/backup-app-card.js',
|
||||||
],
|
],
|
||||||
|
|||||||
@ -0,0 +1,120 @@
|
|||||||
|
// Auto-extracted from backup-page.js (verbatim) — augments BackupPage.prototype. Loaded after the base.
|
||||||
|
Object.assign(BackupPage.prototype, {
|
||||||
|
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, the others 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 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);
|
||||||
|
},
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -0,0 +1,126 @@
|
|||||||
|
// Auto-extracted from backup-page.js (verbatim) — augments BackupPage.prototype. Loaded after the base.
|
||||||
|
Object.assign(BackupPage.prototype, {
|
||||||
|
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')}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Next-run hint in the "Backup status" card header — derived from
|
||||||
|
// CFG_BACKUP_CRONTAB_APP (the cron expression the app-backup
|
||||||
|
// scheduler uses). Pure client-side computation; no backend
|
||||||
|
// surface needed.
|
||||||
|
const nextRunEl = document.getElementById('backup-next-run');
|
||||||
|
if (nextRunEl) {
|
||||||
|
const cron = (window.systemConfigs?.CFG_BACKUP_CRONTAB_APP || '').trim();
|
||||||
|
const next = cron ? this.nextCronFireTime(cron) : null;
|
||||||
|
if (next) {
|
||||||
|
nextRunEl.textContent = `Next backup ${this.formatRelativeFuture(next)} · ${this.formatScheduleClock(next)}`;
|
||||||
|
nextRunEl.title = `Next scheduled backup: ${next.toLocaleString()}\nSchedule: ${cron}`;
|
||||||
|
} else if (cron) {
|
||||||
|
nextRunEl.textContent = `Schedule: ${cron}`;
|
||||||
|
nextRunEl.title = `Couldn't parse the schedule "${cron}" to compute the next run.`;
|
||||||
|
} else {
|
||||||
|
nextRunEl.textContent = 'No schedule set';
|
||||||
|
nextRunEl.title = 'CFG_BACKUP_CRONTAB_APP is empty — backups only run when triggered manually.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// System config tile is rendered FIRST so the bare-metal-restore
|
||||||
|
// prerequisite is always at eye-level — without it, the user's
|
||||||
|
// backups exist but the credentials needed to reach them don't.
|
||||||
|
const systemTileHtml = this.renderSystemTile(d.system || {});
|
||||||
|
if (!apps.length) {
|
||||||
|
appGrid.innerHTML = systemTileHtml + `<div class="backup-empty-state">No apps installed yet.</div>`;
|
||||||
|
} else {
|
||||||
|
appGrid.innerHTML = systemTileHtml + 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('');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// System config tile — same shape as an app tile but with the LibrePortal
|
||||||
|
// app icon. Clicking any tile (system or app) opens the Back-up checklist
|
||||||
|
// modal with that tile pre-ticked; there are no inline action buttons
|
||||||
|
// anymore. Rendered first in the Backup status grid so the bare-metal
|
||||||
|
// prerequisite is always visible up top.
|
||||||
|
renderSystemTile(sys) {
|
||||||
|
const has = !!sys.latest_snapshot;
|
||||||
|
const dot = has ? 'ok' : 'none';
|
||||||
|
const when = has ? 'Last backed up ' + this.formatRelative(sys.latest_time) : 'No backup yet';
|
||||||
|
return `
|
||||||
|
<div class="backup-app-tile backup-app-tile--system" data-system="1" title="Back up system config">
|
||||||
|
<img class="backup-app-tile-icon" src="/core/icons/apps/libreportal.svg" alt="" onerror="this.style.display='none'">
|
||||||
|
<div class="backup-app-tile-text">
|
||||||
|
<div class="backup-app-tile-name">Configs</div>
|
||||||
|
<div class="backup-app-tile-meta">
|
||||||
|
<span class="backup-status-dot ${dot}"></span>
|
||||||
|
<span>${this.escape(when)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="backup-app-tile-action" data-action="backup-now" data-system="1" title="Back up now">
|
||||||
|
Back up
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
},
|
||||||
|
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>
|
||||||
|
`;
|
||||||
|
},
|
||||||
|
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)}" title="Open ${this.escape(displayName)} backup history">
|
||||||
|
<img class="backup-app-tile-icon" src="${this.escape(icon)}" alt="" onerror="this.src='/core/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>
|
||||||
|
<button type="button" class="backup-app-tile-action" data-action="backup-now" data-app="${this.escape(app.app)}" title="Back up now">
|
||||||
|
Back up
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -0,0 +1,84 @@
|
|||||||
|
// Auto-extracted from backup-page.js (verbatim) — augments BackupPage.prototype. Loaded after the base.
|
||||||
|
Object.assign(BackupPage.prototype, {
|
||||||
|
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>` : ''}
|
||||||
|
`;
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -0,0 +1,79 @@
|
|||||||
|
// Auto-extracted from backup-page.js (verbatim) — augments BackupPage.prototype. Loaded after the base.
|
||||||
|
Object.assign(BackupPage.prototype, {
|
||||||
|
async refreshAll() {
|
||||||
|
const ts = Date.now();
|
||||||
|
const [dashboard, locations, , schema, migrate, peersData] = await Promise.all([
|
||||||
|
this.fetchJson(`/data/backup/generated/dashboard.json?t=${ts}`),
|
||||||
|
this.fetchJson(`/data/backup/generated/locations.json?t=${ts}`),
|
||||||
|
this.loadSystemConfigs(),
|
||||||
|
this.fetchJson(`/data/backup/generated/schema.json?t=${ts}`),
|
||||||
|
this.fetchJson(`/data/backup/generated/migrate.json?t=${ts}`),
|
||||||
|
this.fetchJson(`/data/peers/generated/peers.json?t=${ts}`)
|
||||||
|
]);
|
||||||
|
this.dashboard = dashboard;
|
||||||
|
this.locations = locations;
|
||||||
|
this.locSchema = schema;
|
||||||
|
this.migrate = migrate;
|
||||||
|
// Build hostname → friendly-name lookup once so renderMigrate can show
|
||||||
|
// "homelab (host: homelab.lan)" instead of bare hostnames.
|
||||||
|
this.hostnameToPeerName = {};
|
||||||
|
for (const p of (peersData?.peers || [])) {
|
||||||
|
if (p.kind === 'backup-channel' && p.config?.hostname) {
|
||||||
|
this.hostnameToPeerName[p.config.hostname] = p.name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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; }
|
||||||
|
},
|
||||||
|
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)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
async reloadAfterSave() {
|
||||||
|
await this.refreshAll();
|
||||||
|
this.render();
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -0,0 +1,162 @@
|
|||||||
|
// Auto-extracted from backup-page.js (verbatim) — augments BackupPage.prototype. Loaded after the base.
|
||||||
|
Object.assign(BackupPage.prototype, {
|
||||||
|
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;
|
||||||
|
},
|
||||||
|
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
|
||||||
|
};
|
||||||
|
},
|
||||||
|
locFieldsForType(type) {
|
||||||
|
return this.locSchema?.types?.[type]
|
||||||
|
|| BACKUP_LOC_FIELDS_BY_TYPE[type]
|
||||||
|
|| BACKUP_LOC_FIELDS_BY_TYPE.local;
|
||||||
|
},
|
||||||
|
locFieldGroups(idx, type) {
|
||||||
|
const suffixes = this.locFieldsForType(type);
|
||||||
|
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 };
|
||||||
|
},
|
||||||
|
renderConnectionInner(idx, type, loc, connectionSuffixes) {
|
||||||
|
let html = this.renderLocFields(idx, connectionSuffixes, loc);
|
||||||
|
if (type === 'sftp') html += this.renderBackupSshKeyCard(loc);
|
||||||
|
return html;
|
||||||
|
},
|
||||||
|
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', '', 'backups')}
|
||||||
|
${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>
|
||||||
|
`;
|
||||||
|
},
|
||||||
|
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>
|
||||||
|
`;
|
||||||
|
},
|
||||||
|
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>
|
||||||
|
`;
|
||||||
|
},
|
||||||
|
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', '');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -0,0 +1,125 @@
|
|||||||
|
// Auto-extracted from backup-page.js (verbatim) — augments BackupPage.prototype. Loaded after the base.
|
||||||
|
Object.assign(BackupPage.prototype, {
|
||||||
|
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 = this.locFieldsForType(type);
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
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.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']
|
||||||
|
])}
|
||||||
|
${this.formInput('__add_name', 'Friendly name', '', 'text', 'e.g. Office NAS')}
|
||||||
|
</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);
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -0,0 +1,283 @@
|
|||||||
|
// 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');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -0,0 +1,182 @@
|
|||||||
|
// Auto-extracted from backup-page.js (verbatim) — augments BackupPage.prototype. Loaded after the base.
|
||||||
|
Object.assign(BackupPage.prototype, {
|
||||||
|
renderMigrate() {
|
||||||
|
const body = document.getElementById('backup-migrate-body');
|
||||||
|
const empty = document.getElementById('backup-migrate-empty');
|
||||||
|
if (!body || !empty) return;
|
||||||
|
|
||||||
|
const data = this.migrate || {};
|
||||||
|
const locations = (data.locations || []).filter(l => (l.hosts || []).length > 0);
|
||||||
|
|
||||||
|
if (!locations.length) {
|
||||||
|
body.innerHTML = '';
|
||||||
|
empty.hidden = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
empty.hidden = true;
|
||||||
|
|
||||||
|
// Group: one card per source-host, with that host's apps listed underneath.
|
||||||
|
// We collapse across locations — if the same host appears in two locations,
|
||||||
|
// we still show it once with the union of apps (the per-app row carries
|
||||||
|
// which location it came from). Most setups have one shared location anyway.
|
||||||
|
const installed = new Set(data.destination?.installed_apps || []);
|
||||||
|
const html = locations.map(loc => `
|
||||||
|
<div class="backup-migrate-location">
|
||||||
|
<div class="backup-card-header" style="margin-bottom:8px">
|
||||||
|
<h3 style="margin:0">${this.escape(loc.name || 'Location')}</h3>
|
||||||
|
<span class="backup-card-hint">${(loc.hosts || []).length} other host${loc.hosts.length === 1 ? '' : 's'} backing up here</span>
|
||||||
|
</div>
|
||||||
|
${loc.hosts.map(host => {
|
||||||
|
const peerName = (this.hostnameToPeerName || {})[host.hostname];
|
||||||
|
const headerLabel = peerName
|
||||||
|
? `<strong style="font-size:1.05em">${this.escape(peerName)}</strong><span class="backup-card-hint" style="margin-left:6px; font-size:.85em">host: <code>${this.escape(host.hostname)}</code></span>`
|
||||||
|
: `<strong style="font-size:1.05em">${this.escape(host.hostname)}</strong>`;
|
||||||
|
return `
|
||||||
|
<div class="backup-migrate-host" style="border:1px solid var(--border-color, #2a2a2a); border-radius:8px; padding:14px; margin-bottom:12px">
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:center; gap:12px; margin-bottom:10px">
|
||||||
|
<div>
|
||||||
|
${headerLabel}
|
||||||
|
<span class="backup-card-hint" style="margin-left:10px">${(host.apps || []).length} app${host.apps.length === 1 ? '' : 's'} available</span>
|
||||||
|
</div>
|
||||||
|
<button class="backup-primary-btn" data-action="migrate-host"
|
||||||
|
data-loc="${loc.idx}" data-host="${this.escape(host.hostname)}">
|
||||||
|
Migrate every app from this host
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="backup-migrate-apps" style="display:grid; grid-template-columns:repeat(auto-fill, minmax(280px, 1fr)); gap:8px">
|
||||||
|
${(host.apps || []).map(app => {
|
||||||
|
const collide = installed.has(app.slug);
|
||||||
|
return `
|
||||||
|
<div class="backup-migrate-app" style="display:flex; justify-content:space-between; align-items:center; padding:8px 12px; background:var(--surface-2, #1a1a1a); border-radius:6px">
|
||||||
|
<div style="display:flex; flex-direction:column; min-width:0">
|
||||||
|
<span style="display:flex; align-items:center; gap:8px">
|
||||||
|
<strong>${this.escape(app.slug)}</strong>
|
||||||
|
${collide ? `<span class="backup-status-dot warn" title="Already installed here"></span>` : ''}
|
||||||
|
</span>
|
||||||
|
<span class="backup-card-hint" style="font-size:.82em">
|
||||||
|
${app.snapshots} snapshot${app.snapshots === 1 ? '' : 's'} · latest ${this.escape(this.formatRelativeTime(app.latest_date))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button class="backup-secondary-btn" data-action="migrate-app"
|
||||||
|
data-loc="${loc.idx}" data-host="${this.escape(host.hostname)}" data-app="${this.escape(app.slug)}">
|
||||||
|
Migrate
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('')}
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
body.innerHTML = html;
|
||||||
|
},
|
||||||
|
formatRelativeTime(iso) {
|
||||||
|
if (!iso) return 'never';
|
||||||
|
const t = Date.parse(iso);
|
||||||
|
if (!t) return iso;
|
||||||
|
const diff = Date.now() - t;
|
||||||
|
const minute = 60_000, hour = 60 * minute, day = 24 * hour;
|
||||||
|
if (diff < hour) return `${Math.max(1, Math.round(diff / minute))} min ago`;
|
||||||
|
if (diff < day) return `${Math.round(diff / hour)} h ago`;
|
||||||
|
if (diff < 7 * day) return `${Math.round(diff / day)} d ago`;
|
||||||
|
return new Date(t).toISOString().slice(0, 10);
|
||||||
|
},
|
||||||
|
openMigrateModal({ mode, locIdx, host, app }) {
|
||||||
|
const modal = document.getElementById('backup-migrate-modal');
|
||||||
|
const body = document.getElementById('backup-migrate-modal-body');
|
||||||
|
if (!modal || !body) return;
|
||||||
|
|
||||||
|
const dest = this.migrate?.destination || {};
|
||||||
|
const installed = new Set(dest.installed_apps || []);
|
||||||
|
const running = new Set(dest.running_apps || []);
|
||||||
|
const locName = this.locName(locIdx);
|
||||||
|
|
||||||
|
// App-mode: one specific app. Host-mode: every app from the host.
|
||||||
|
let targetApps = [];
|
||||||
|
if (mode === 'app') {
|
||||||
|
targetApps = [app];
|
||||||
|
} else {
|
||||||
|
const loc = (this.migrate?.locations || []).find(l => l.idx === locIdx);
|
||||||
|
const h = (loc?.hosts || []).find(x => x.hostname === host);
|
||||||
|
targetApps = (h?.apps || []).map(a => a.slug);
|
||||||
|
}
|
||||||
|
const collisions = targetApps.filter(a => installed.has(a));
|
||||||
|
const collisionsRunning = collisions.filter(a => running.has(a));
|
||||||
|
|
||||||
|
const intro = mode === 'app'
|
||||||
|
? `<p>Migrate <strong>${this.escape(app)}</strong> from <strong>${this.escape(host)}</strong> via <strong>${this.escape(locName)}</strong> onto this host.</p>`
|
||||||
|
: `<p>Migrate <strong>every app</strong> (${targetApps.length}) from <strong>${this.escape(host)}</strong> via <strong>${this.escape(locName)}</strong> onto this host.</p>`;
|
||||||
|
|
||||||
|
let collisionNote = '';
|
||||||
|
if (collisions.length) {
|
||||||
|
collisionNote = `
|
||||||
|
<p class="backup-card-hint" style="color:var(--warning, #d97706); margin-top:8px">
|
||||||
|
⚠ Already installed here: ${collisions.map(c => `<code>${this.escape(c)}</code>`).join(', ')}.
|
||||||
|
These will be <strong>replaced</strong>.
|
||||||
|
${collisionsRunning.length ? `Currently running: ${collisionsRunning.map(c => `<code>${this.escape(c)}</code>`).join(', ')} — will be stopped first.` : ''}
|
||||||
|
</p>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.innerHTML = `
|
||||||
|
${intro}
|
||||||
|
${collisionNote}
|
||||||
|
<div style="margin-top:14px; display:flex; flex-direction:column; gap:8px">
|
||||||
|
<label style="display:flex; align-items:flex-start; gap:8px; cursor:pointer">
|
||||||
|
<input type="checkbox" id="migrate-opt-pre-backup" ${collisions.length ? 'checked' : 'disabled'}>
|
||||||
|
<span>
|
||||||
|
Back up the destination's existing copy first
|
||||||
|
<span class="backup-card-hint" style="display:block; font-size:.85em">
|
||||||
|
Safety net: snapshot the current ${mode === 'app' ? this.escape(app) : 'app'} into your first
|
||||||
|
enabled backup location (tagged <code>pre-migrate</code>) before wipe.
|
||||||
|
${collisions.length ? '' : 'No collision — nothing to back up.'}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label style="display:flex; align-items:flex-start; gap:8px; cursor:pointer">
|
||||||
|
<input type="checkbox" id="migrate-opt-rewrite-urls" checked>
|
||||||
|
<span>
|
||||||
|
Rewrite host-bound URLs to this host
|
||||||
|
<span class="backup-card-hint" style="display:block; font-size:.85em">
|
||||||
|
Replaces <code>CFG_*_URL</code>, <code>*_DOMAIN</code>, <code>*_HOSTNAME</code> with this
|
||||||
|
host's values. Uncheck only if you want the moved app to keep claiming the source's hostname.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
modal.dataset.mode = mode;
|
||||||
|
modal.dataset.locIdx = String(locIdx);
|
||||||
|
modal.dataset.host = host;
|
||||||
|
modal.dataset.app = app || '';
|
||||||
|
modal.classList.add('open');
|
||||||
|
},
|
||||||
|
async confirmMigrate() {
|
||||||
|
const modal = document.getElementById('backup-migrate-modal');
|
||||||
|
if (!modal) return;
|
||||||
|
const { mode, locIdx, host, app } = modal.dataset;
|
||||||
|
const preBackup = document.getElementById('migrate-opt-pre-backup')?.checked;
|
||||||
|
const rewrite = document.getElementById('migrate-opt-rewrite-urls')?.checked;
|
||||||
|
|
||||||
|
// The bash CLI defaults are: pre-backup ON, URL rewrite ON. Opt-out flags
|
||||||
|
// only get appended when the user un-ticks; matches the kernel's defaults.
|
||||||
|
const opts = [];
|
||||||
|
if (preBackup === false) opts.push('--no-pre-backup');
|
||||||
|
if (rewrite === false) opts.push('--keep-urls');
|
||||||
|
const optStr = opts.length ? ' ' + opts.join(' ') : '';
|
||||||
|
|
||||||
|
this.closeAllModals();
|
||||||
|
if (mode === 'app') {
|
||||||
|
await this.runTask(
|
||||||
|
`libreportal restore migrate app ${app} ${host} ${locIdx}${optStr}`,
|
||||||
|
'restore', app);
|
||||||
|
} else {
|
||||||
|
await this.runTask(
|
||||||
|
`libreportal restore migrate system ${host} ${locIdx}${optStr}`,
|
||||||
|
'restore', null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,135 @@
|
|||||||
|
// Auto-extracted from backup-page.js (verbatim) — augments BackupPage.prototype. Loaded after the base.
|
||||||
|
Object.assign(BackupPage.prototype, {
|
||||||
|
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 }));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
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('');
|
||||||
|
},
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -0,0 +1,147 @@
|
|||||||
|
// Auto-extracted from backup-page.js (verbatim) — augments BackupPage.prototype. Loaded after the base.
|
||||||
|
Object.assign(BackupPage.prototype, {
|
||||||
|
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 backup 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');
|
||||||
|
},
|
||||||
|
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 runBackupSystem() {
|
||||||
|
await this.runTask(`libreportal backup system`, 'backup', null);
|
||||||
|
},
|
||||||
|
async confirmRestoreSystem() {
|
||||||
|
if (!confirm('Restore the latest system-config snapshot?\n\nIt is restored into a staging folder (not applied) — review it, then copy what you need (e.g. backup-location credentials, login, settings) into the live config. Your running config is NOT overwritten.')) return;
|
||||||
|
await this.runTask(`libreportal restore system`, 'restore', null);
|
||||||
|
},
|
||||||
|
openBackupPickModal(opts = {}) {
|
||||||
|
const modal = document.getElementById('backup-pick-modal');
|
||||||
|
const body = document.getElementById('backup-pick-modal-body');
|
||||||
|
if (!modal || !body) return;
|
||||||
|
|
||||||
|
const apps = this.dashboard?.apps || [];
|
||||||
|
const sys = this.dashboard?.system || {};
|
||||||
|
const preTickSystem = !!opts.preTickSystem;
|
||||||
|
const preTickApps = new Set(opts.preTickApps || []);
|
||||||
|
|
||||||
|
// System row at the top — uses the LibrePortal icon to match the
|
||||||
|
// dashboard tile. Then every installed app, alphabetical.
|
||||||
|
const sortedApps = apps.slice().sort((a, b) => a.app.localeCompare(b.app));
|
||||||
|
|
||||||
|
const row = (key, iconSrc, label, sub, checked) => `
|
||||||
|
<label class="backup-pick-row" style="display:flex; align-items:center; gap:12px; padding:10px 12px; border:1px solid rgba(var(--text-rgb),0.08); border-radius:8px; margin-bottom:6px; cursor:pointer">
|
||||||
|
<input type="checkbox" class="backup-pick-cb" value="${this.escape(key)}" ${checked ? 'checked' : ''}>
|
||||||
|
<img src="${this.escape(iconSrc)}" alt="" style="width:28px; height:28px; flex-shrink:0; border-radius:6px" onerror="this.style.display='none'">
|
||||||
|
<div style="flex:1; min-width:0">
|
||||||
|
<div style="font-weight:600">${this.escape(label)}</div>
|
||||||
|
<div class="backup-card-hint" style="font-size:.82em">${this.escape(sub)}</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const sysSub = sys.latest_snapshot
|
||||||
|
? 'Last backed up ' + this.formatRelative(sys.latest_time)
|
||||||
|
: 'No backup yet';
|
||||||
|
|
||||||
|
const rows = [
|
||||||
|
row('__system__', '/core/icons/apps/libreportal.svg', 'Configs', sysSub, preTickSystem),
|
||||||
|
...sortedApps.map(app => {
|
||||||
|
const meta = this.appMeta(app.app);
|
||||||
|
const sub = app.latest_snapshot
|
||||||
|
? 'Last backed up ' + this.formatRelative(app.latest_time)
|
||||||
|
: 'No backup yet';
|
||||||
|
return row(app.app, meta.icon, meta.displayName, sub, preTickApps.has(app.app));
|
||||||
|
})
|
||||||
|
].join('');
|
||||||
|
|
||||||
|
body.innerHTML = `
|
||||||
|
<p class="backup-card-hint" style="margin:0 0 10px">
|
||||||
|
Pick what to snapshot. Each selection runs in the background and shows up on the Tasks page.
|
||||||
|
</p>
|
||||||
|
<div style="display:flex; gap:14px; margin-bottom:10px; font-size:.85em">
|
||||||
|
<a href="#" data-pick-action="select-all" style="color:var(--accent); text-decoration:none">Select all</a>
|
||||||
|
<a href="#" data-pick-action="select-none" style="color:var(--accent); text-decoration:none">Clear</a>
|
||||||
|
</div>
|
||||||
|
<div class="backup-pick-list" style="max-height:60vh; overflow-y:auto">${rows}</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// The select-all / clear links live inside the modal body so we wire
|
||||||
|
// them once here per open (they get rebuilt every open, no listener
|
||||||
|
// leak).
|
||||||
|
body.querySelectorAll('[data-pick-action]').forEach(a => {
|
||||||
|
a.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const all = a.dataset.pickAction === 'select-all';
|
||||||
|
body.querySelectorAll('.backup-pick-cb').forEach(cb => { cb.checked = all; });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
modal.classList.add('open');
|
||||||
|
},
|
||||||
|
async confirmBackupPick() {
|
||||||
|
const modal = document.getElementById('backup-pick-modal');
|
||||||
|
if (!modal) return;
|
||||||
|
const selected = Array.from(modal.querySelectorAll('.backup-pick-cb:checked'))
|
||||||
|
.map(cb => cb.value);
|
||||||
|
if (!selected.length) {
|
||||||
|
this.notify('Pick at least one thing to back up.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.closeAllModals();
|
||||||
|
|
||||||
|
const apps = this.dashboard?.apps || [];
|
||||||
|
const totalThings = apps.length + 1; // +1 for system
|
||||||
|
const wantsSystem = selected.includes('__system__');
|
||||||
|
const appSlugs = selected.filter(s => s !== '__system__');
|
||||||
|
|
||||||
|
// Whole-fleet shortcut — `backup all` queues a single task and also
|
||||||
|
// covers system, instead of N+1 separate tasks. Requires at least one
|
||||||
|
// app so a system-only pick never collapses into "backup all".
|
||||||
|
if (wantsSystem && apps.length > 0 && appSlugs.length === apps.length) {
|
||||||
|
await this.runTask('libreportal backup all', 'backup', null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (wantsSystem) {
|
||||||
|
await this.runTask('libreportal backup system', 'backup', null);
|
||||||
|
}
|
||||||
|
for (const slug of appSlugs) {
|
||||||
|
await this.runTask(`libreportal backup app create ${slug}`, 'backup', slug);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -0,0 +1,144 @@
|
|||||||
|
// Auto-extracted from backup-page.js (verbatim) — augments BackupPage.prototype. Loaded after the base.
|
||||||
|
Object.assign(BackupPage.prototype, {
|
||||||
|
renderSnapshots() {
|
||||||
|
const list = document.getElementById('backup-snapshot-list');
|
||||||
|
if (!list) 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),
|
||||||
|
tags: Array.isArray(s.tags) ? s.tags : [],
|
||||||
|
paths: Array.isArray(s.paths) ? s.paths : [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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) {
|
||||||
|
list.innerHTML = `<div class="backup-empty-state">No backups yet.</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
list.innerHTML = filtered.map(r => this._renderSnapshotRow(r)).join('');
|
||||||
|
},
|
||||||
|
// Render one global-Snapshots-tab backup as the same .task-item card
|
||||||
|
// the per-app Backups tab uses, so the two surfaces look identical.
|
||||||
|
// Extras vs the per-app card:
|
||||||
|
// - An app-name chip (because the global list isn't scoped to one app)
|
||||||
|
// that doubles as a deep-link to /app/<name>/backups?snapshot=<id>
|
||||||
|
// - A Delete action alongside Restore (per-app card only offers
|
||||||
|
// Restore — delete lives in the global view)
|
||||||
|
_renderSnapshotRow(r) {
|
||||||
|
const hasApp = r.app && r.app !== '—';
|
||||||
|
const deepLink = hasApp
|
||||||
|
? `/app/${encodeURIComponent(r.app)}/backups?snapshot=${encodeURIComponent(r.id)}`
|
||||||
|
: null;
|
||||||
|
const iconUrl = hasApp ? `/core/icons/apps/${encodeURIComponent(r.app)}.svg` : '/core/icons/apps/libreportal.svg';
|
||||||
|
const displayName = hasApp ? this.appMeta(r.app).displayName : 'Configs';
|
||||||
|
const appChip = hasApp
|
||||||
|
? `<a class="backup-snapshot-link backup-snapshot-app-chip" href="${this.escape(deepLink)}" data-deep-link="${this.escape(deepLink)}" title="Open ${this.escape(displayName)} backups">${this.escape(displayName)}</a>`
|
||||||
|
: `<span class="backup-snapshot-app-chip">${this.escape(displayName)}</span>`;
|
||||||
|
const sid = String(r.id);
|
||||||
|
|
||||||
|
// Restic stamps app=/host=/engine= into the snapshot tags; surface
|
||||||
|
// those as their own fields and keep any remaining tags as chips.
|
||||||
|
const tagMap = {};
|
||||||
|
(r.tags || []).forEach(t => { const i = t.indexOf('='); if (i > 0) tagMap[t.slice(0, i)] = t.slice(i + 1); });
|
||||||
|
const engineName = tagMap.engine ? this.engineDisplayName(tagMap.engine) : null;
|
||||||
|
const otherTags = (r.tags || []).filter(t => !/^(app|host|engine|paths?)=/.test(t));
|
||||||
|
const field = (label, valueHtml) =>
|
||||||
|
`<div class="bsm-field"><span class="bsm-label">${label}</span><span class="bsm-value">${valueHtml}</span></div>`;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="task-item backup-snapshot-item" data-snapshot="${this.escape(sid)}" data-loc="${this.escape(String(r.locIdx))}">
|
||||||
|
<div class="task-header" data-action="toggle-snapshot-row">
|
||||||
|
<div class="task-info">
|
||||||
|
<img src="${this.escape(iconUrl)}" alt="" class="task-app-icon" onerror="this.style.display='none'">
|
||||||
|
<span class="task-title">${this.escape(this.formatRelative(r.time))}</span>
|
||||||
|
${appChip}
|
||||||
|
<span class="task-status backup-snapshot-loc-pill">${this.escape(r.locName)}</span>
|
||||||
|
<span class="task-time" title="${this.escape(this._fmtFullTime(r.time))}">${this.escape(this._fmtShortTime(r.time))}</span>
|
||||||
|
<span class="backup-snapshot-id-chip" title="Backup ID">${this.escape(sid)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="task-actions">
|
||||||
|
<button class="task-btn" data-action="restore-snapshot" data-app="${this.escape(r.app)}" data-loc="${this.escape(String(r.locIdx))}" data-snapshot="${this.escape(sid)}" title="Restore from this backup">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 12a9 9 0 1 0 3-6.7"/><path d="M3 4v6h6"/></svg>
|
||||||
|
<span class="task-btn-label">Restore</span>
|
||||||
|
</button>
|
||||||
|
<button class="task-btn delete" data-action="delete-snapshot" data-app="${this.escape(r.app)}" data-loc="${this.escape(String(r.locIdx))}" data-snapshot="${this.escape(sid)}" title="Delete this backup">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"></path><path d="M10 11v6M14 11v6"></path></svg>
|
||||||
|
<span class="task-btn-label">Delete</span>
|
||||||
|
</button>
|
||||||
|
<button class="task-btn toggle-details" data-action="toggle-snapshot-row" title="Toggle details">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6,9 12,15 18,9"></polyline></svg>
|
||||||
|
<span class="task-btn-label">Details</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="task-details">
|
||||||
|
<div class="backup-snapshot-meta">
|
||||||
|
<div class="bsm-grid">
|
||||||
|
${field('App', `${this.escape(displayName)}${hasApp ? ` <code>${this.escape(r.app)}</code>` : ''}`)}
|
||||||
|
${field('Host', this.escape(r.host))}
|
||||||
|
${field('Location', `<span class="backup-snapshot-loc-pill">${this.escape(r.locName)}</span>`)}
|
||||||
|
${field('Backup ID', `<code>${this.escape(sid)}</code>`)}
|
||||||
|
${field('When', `<span title="${this.escape(this._fmtFullTime(r.time))}">${this.escape(this._fmtNiceTime(r.time))}</span>`)}
|
||||||
|
${engineName ? field('Engine', this.escape(engineName)) : ''}
|
||||||
|
</div>
|
||||||
|
${otherTags.length ? `
|
||||||
|
<div class="bsm-block">
|
||||||
|
<span class="bsm-label">Tags</span>
|
||||||
|
<div class="bsm-tags">${otherTags.map(t => `<span class="backup-snapshot-tag">${this.escape(t)}</span>`).join('')}</div>
|
||||||
|
</div>` : ''}
|
||||||
|
${r.paths && r.paths.length ? `
|
||||||
|
<div class="bsm-block">
|
||||||
|
<span class="bsm-label">Paths</span>
|
||||||
|
<ul class="bsm-paths">${r.paths.map(p => `<li><code>${this.escape(p)}</code></li>`).join('')}</ul>
|
||||||
|
</div>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
},
|
||||||
|
_fmtShortTime(iso) {
|
||||||
|
if (!iso) return '';
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (isNaN(d.getTime())) return String(iso);
|
||||||
|
return d.toLocaleString();
|
||||||
|
},
|
||||||
|
_fmtFullTime(iso) {
|
||||||
|
if (!iso) return '';
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (isNaN(d.getTime())) return String(iso);
|
||||||
|
return d.toString();
|
||||||
|
},
|
||||||
|
_fmtNiceTime(iso) {
|
||||||
|
if (!iso) return '';
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (isNaN(d.getTime())) return String(iso);
|
||||||
|
return d.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' });
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -0,0 +1,49 @@
|
|||||||
|
// Auto-extracted from backup-page.js (verbatim) — augments BackupPage.prototype. Loaded after the base.
|
||||||
|
Object.assign(BackupPage.prototype, {
|
||||||
|
renderBackupSshKeyCard(l) {
|
||||||
|
const idx = l.idx;
|
||||||
|
const hasKey = l.ssh_key_exists === true;
|
||||||
|
const pub = l.ssh_public_key || '';
|
||||||
|
const body = hasKey ? `
|
||||||
|
<p class="backup-card-hint">Add this public key to the remote server's <code>~/.ssh/authorized_keys</code>:</p>
|
||||||
|
<textarea class="backup-ssh-pubkey" readonly rows="2" spellcheck="false">${this.escape(pub)}</textarea>
|
||||||
|
<div class="backup-ssh-key-actions">
|
||||||
|
<button type="button" class="backup-secondary-btn" data-action="ssh-key-copy" data-loc="${idx}">Copy public key</button>
|
||||||
|
<button type="button" class="backup-danger-btn" data-action="ssh-key-delete" data-loc="${idx}">Delete key</button>
|
||||||
|
</div>` : `
|
||||||
|
<p class="backup-card-hint">Paste an existing private key, or generate one and we'll show the public key to add on the remote.</p>
|
||||||
|
<textarea class="backup-ssh-keyinput" rows="4" spellcheck="false" placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"></textarea>
|
||||||
|
<div class="backup-ssh-key-actions">
|
||||||
|
<button type="button" class="backup-primary-btn" data-action="ssh-key-save" data-loc="${idx}">Save key</button>
|
||||||
|
<button type="button" class="backup-secondary-btn" data-action="ssh-key-generate" data-loc="${idx}">Generate keypair</button>
|
||||||
|
</div>`;
|
||||||
|
return `
|
||||||
|
<div class="backup-ssh-key-card" data-loc="${idx}">
|
||||||
|
<div class="backup-ssh-key-head">
|
||||||
|
<span class="backup-ssh-key-title">SSH key</span>
|
||||||
|
<span class="backup-ssh-key-status ${hasKey ? 'ok' : 'none'}">${hasKey ? '✓ Key configured' : 'No key yet'}</span>
|
||||||
|
</div>
|
||||||
|
${body}
|
||||||
|
</div>`;
|
||||||
|
},
|
||||||
|
async saveBackupSshKey(idx) {
|
||||||
|
const card = document.querySelector(`.backup-ssh-key-card[data-loc="${idx}"]`);
|
||||||
|
const key = (card?.querySelector('.backup-ssh-keyinput')?.value || '').trim();
|
||||||
|
if (!key) { this.notify('Paste a private key first', 'error'); return; }
|
||||||
|
const b64 = btoa(unescape(encodeURIComponent(key + '\n')));
|
||||||
|
await this.runTask(`libreportal backup location ssh-key-set ${idx} ${b64}`, 'backup', null);
|
||||||
|
},
|
||||||
|
async generateBackupSshKey(idx) {
|
||||||
|
await this.runTask(`libreportal backup location ssh-key-generate ${idx}`, 'backup', null);
|
||||||
|
},
|
||||||
|
async deleteBackupSshKey(idx) {
|
||||||
|
if (!confirm("Delete this location's SSH key? Backups here will fail until a new key is set and added on the remote.")) return;
|
||||||
|
await this.runTask(`libreportal backup location ssh-key-delete ${idx}`, 'backup', null);
|
||||||
|
},
|
||||||
|
async copyBackupSshKey(idx) {
|
||||||
|
const loc = (this.locations?.locations || []).find(l => l.idx === idx);
|
||||||
|
const pub = loc?.ssh_public_key || '';
|
||||||
|
try { await navigator.clipboard.writeText(pub); this.notify('Public key copied', 'success'); }
|
||||||
|
catch { this.notify('Copy failed — select the text and copy manually', 'error'); }
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user