librelad 875a60f90f LibrePortal v0.1.0 — initial release
A free, open, self-hosted app platform (GNU AGPLv3): one-click app deploys,
Traefik reverse proxy with automatic SSL, rootless Docker support, gluetun
VPN routing, and a web dashboard to manage it all.

Free & open forever to self-host; optional paid hosted services fund it.
See PROMISE.md.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-21 20:37:54 +01:00

174 lines
6.7 KiB
JavaScript

// Per-app backup card — used inside the app detail "Backups" tab.
// Lightweight view that lists the app's snapshots across all enabled repos and
// offers Backup Now + per-snapshot restore. For full management (delete,
// migrate, schedule overrides) the user follows the link to /backup.
class BackupAppCard {
constructor(appName) {
this.appName = appName;
this.snapshotsByLoc = {};
this.locationsByIdx = {};
this.appStatus = null;
this.taskManager = (typeof TaskManager !== 'undefined') ? new TaskManager() : null;
this.bindDelegated();
}
bindDelegated() {
if (window.__backupAppCardBound) return;
window.__backupAppCardBound = true;
document.addEventListener('click', (e) => {
const card = window.backupAppCard;
if (!card) return;
if (e.target.closest('#backup-app-card-backup-btn')) {
card.backupNow();
return;
}
const restoreBtn = e.target.closest('[data-action="restore-app-snapshot"]');
if (restoreBtn) {
card.restoreSnapshot(restoreBtn.dataset.loc, restoreBtn.dataset.snapshot);
}
});
}
async render() {
const statusEl = document.getElementById('backup-app-card-status');
const snapsEl = document.getElementById('backup-app-card-snapshots');
if (!statusEl || !snapsEl) return;
statusEl.textContent = 'Loading…';
snapsEl.innerHTML = '';
await this.loadData();
const allSnaps = this.flattenSnapshots();
if (!allSnaps.length) {
statusEl.innerHTML = `<span class="backup-status-dot none"></span> No snapshots yet`;
snapsEl.innerHTML = `<div class="backup-empty-state">No snapshots found for <strong>${this.escape(this.appName)}</strong>. Click "Backup now" to create the first one.</div>`;
return;
}
const latest = allSnaps[0];
const locCount = Object.keys(this.snapshotsByLoc).length;
statusEl.innerHTML = `
<span class="backup-status-dot ok"></span>
Latest backup ${this.formatRelative(latest.time)}
<span class="backup-card-hint">${allSnaps.length} total across ${locCount} location${locCount === 1 ? '' : 's'}</span>
`;
snapsEl.innerHTML = `
<table class="backup-snapshot-table" style="margin-top:14px">
<thead>
<tr>
<th>Location</th>
<th>When</th>
<th>ID</th>
<th class="backup-col-actions"></th>
</tr>
</thead>
<tbody>
${allSnaps.slice(0, 15).map(s => `
<tr>
<td>${this.escape(s.locName)}</td>
<td>${this.formatRelative(s.time)}</td>
<td class="backup-snapshot-id">${this.escape(s.id)}</td>
<td class="backup-col-actions">
<button class="backup-row-action-btn" data-action="restore-app-snapshot" data-loc="${s.locIdx}" data-snapshot="${this.escape(s.id)}">Restore</button>
</td>
</tr>
`).join('')}
</tbody>
</table>
`;
}
async loadData() {
const ts = Date.now();
const statusUrl = `/data/backup/generated/apps/${encodeURIComponent(this.appName)}.json?t=${ts}`;
const locationsUrl = `/data/backup/generated/locations.json?t=${ts}`;
const [appStatus, locationsJson] = await Promise.all([
this.fetchJson(statusUrl),
this.fetchJson(locationsUrl)
]);
this.appStatus = appStatus;
this.snapshotsByLoc = {};
this.locationsByIdx = {};
if (locationsJson?.locations?.length) {
locationsJson.locations.forEach(l => { this.locationsByIdx[l.idx] = l; });
const enabled = locationsJson.locations.filter(l => l.enabled);
await Promise.all(enabled.map(async (l) => {
const data = await this.fetchJson(`/data/backup/generated/snapshots_${l.idx}.json?t=${ts}`);
if (data?.snapshots) this.snapshotsByLoc[l.idx] = data.snapshots;
}));
}
}
flattenSnapshots() {
const out = [];
Object.entries(this.snapshotsByLoc).forEach(([locIdx, snaps]) => {
(snaps || []).forEach(s => {
const tags = s.tags || [];
const isApp = tags.includes(`app=${this.appName}`);
if (!isApp) return;
out.push({
locIdx,
locName: this.locationsByIdx[locIdx]?.name || `Location ${locIdx}`,
time: s.time,
id: s.short_id || (s.id || '').slice(0, 8)
});
});
});
out.sort((a, b) => String(b.time).localeCompare(String(a.time)));
return out;
}
async fetchJson(url) {
try {
const r = await fetch(url);
if (!r.ok) return null;
return await r.json();
} catch { return null; }
}
async backupNow() {
if (!this.taskManager) return;
await this.taskManager.createTask(`libreportal backup app create ${this.appName}`, 'backup', this.appName);
setTimeout(() => this.render(), 1500);
}
async restoreSnapshot(locIdx, snapshot) {
const locName = this.locationsByIdx[locIdx]?.name || `Location ${locIdx}`;
if (!confirm(`Restore ${this.appName} from backup ${snapshot} at ${locName}? The app will be stopped, its folder wiped, the backup restored in place, then the app started again.`)) return;
if (!this.taskManager) return;
await this.taskManager.createTask(`libreportal restore app start ${this.appName} ${snapshot} ${locIdx}`, 'restore', this.appName);
}
escape(s) {
return String(s ?? '').replace(/[&<>"']/g, c => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
})[c]);
}
formatRelative(iso) {
if (!iso) return '—';
const t = new Date(iso).getTime();
if (!t) return iso;
const diff = Math.max(0, Date.now() - t);
const s = Math.floor(diff / 1000);
if (s < 60) return 'just now';
const m = Math.floor(s / 60);
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 48) return `${h}h ago`;
const d = Math.floor(h / 24);
if (d < 30) return `${d}d ago`;
return new Date(iso).toLocaleDateString();
}
}
window.BackupAppCard = BackupAppCard;