// Per-app backup view — Services-tab-style list inside the app detail // "Backups" tab. Each snapshot is a collapsible row (status dot, location // pill, "when" chip, ID chip, action buttons + an expandable detail block // matching the same .task-item visual pattern the Services tab uses). // // Deep-link contract: /app//backups?snapshot= auto-expands and // scrolls to that row, so the global Snapshots table on /backup can jump // straight to a specific backup on its app's page. 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) { e.stopPropagation(); // don't also collapse/expand the row card.restoreSnapshot(restoreBtn.dataset.loc, restoreBtn.dataset.snapshot); return; } // Header click → toggle the detail panel. Mirrors the // .task-header click target Services uses. const header = e.target.closest('.backup-snapshot-item .task-header'); if (header) { const item = header.closest('.backup-snapshot-item'); if (item) { const details = item.querySelector('.task-details'); if (details) details.classList.toggle('task-details-open'); } } }); } 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 backups yet`; snapsEl.innerHTML = `
No backups 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'} `; const iconUrl = `/icons/apps/${encodeURIComponent(this.appName)}.svg`; snapsEl.innerHTML = `
${allSnaps.slice(0, 50).map(s => this._renderRow(s, iconUrl)).join('')}
${allSnaps.length > 50 ? `
Showing the most recent 50 of ${allSnaps.length} backups. Use the backup center for the full list.
` : ''} `; // Deep-link: /app//backups?snapshot= auto-expands that row // and scrolls it into view, briefly flashing the highlight class so // the user's eye lands on the right thing. this._honorSnapshotDeepLink(); } _renderRow(s, iconUrl) { const sid = String(s.id || ''); return `
${this.escape(this.appName)} ${this.escape(this.formatRelative(s.time))} ${this.escape(s.locName)} ${this.escape(this._fmtShort(s.time))} ${this.escape(sid)}
Backup ID: ${this.escape(sid)}
Location: ${this.escape(s.locName)}
When: ${this.escape(this._fmtFull(s.time))}
${s.hostname ? `
Host: ${this.escape(s.hostname)}
` : ''} ${s.tags && s.tags.length ? `
Tags: ${s.tags.map(t => `${this.escape(t)}`).join(' ')}
` : ''} ${s.paths && s.paths.length ? `
Paths:
${s.paths.map(p => this.escape(p)).join('
')}
` : ''}
`; } _honorSnapshotDeepLink() { const want = new URLSearchParams(window.location.search).get('snapshot'); if (!want) return; const row = document.querySelector(`.backup-snapshot-item[data-snapshot="${CSS.escape(want)}"]`); if (!row) return; const details = row.querySelector('.task-details'); if (details && !details.classList.contains('task-details-open')) details.classList.add('task-details-open'); setTimeout(() => { row.scrollIntoView({ behavior: 'smooth', block: 'center' }); row.classList.add('backup-snapshot-flash'); setTimeout(() => row.classList.remove('backup-snapshot-flash'), 2200); }, 200); } _fmtShort(iso) { if (!iso) return ''; const d = new Date(iso); if (isNaN(d.getTime())) return iso; return d.toLocaleString(); } _fmtFull(iso) { if (!iso) return ''; const d = new Date(iso); if (isNaN(d.getTime())) return iso; return d.toString(); } 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), hostname: s.hostname || '', tags, paths: Array.isArray(s.paths) ? s.paths : [], }); }); }); 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}?\n\nThe 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;