// 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 = ` No snapshots yet`; snapsEl.innerHTML = `
No snapshots found for ${this.escape(this.appName)}. Click "Backup now" to create the first one.
`; return; } const latest = allSnaps[0]; const locCount = Object.keys(this.snapshotsByLoc).length; statusEl.innerHTML = ` Latest backup ${this.formatRelative(latest.time)} ${allSnaps.length} total across ${locCount} location${locCount === 1 ? '' : 's'} `; snapsEl.innerHTML = ` ${allSnaps.slice(0, 15).map(s => ` `).join('')}
Location When ID
${this.escape(s.locName)} ${this.formatRelative(s.time)} ${this.escape(s.id)}
`; } 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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;