Restores the per-app snapshot list (regressed during the backup-system
revamp) and rebuilds it on the same .task-item visual the Services tab
uses, so the two app-page tabs read as a matched pair. Wires the three-
level navigation the user asked for end-to-end:
/backup global dashboard + snapshot table
└─ click app tile → /app/<name>/backups
└─ click any snapshot row expands to detail in place
└─ click App / ID cell → /app/<name>/backups?snapshot=<id>
(auto-expands + scrolls + flashes)
Per-app Backups tab (BackupAppCard):
- Snapshots render as task-item rows: app icon, "12h ago" title,
location pill, full timestamp chip, short-ID monospace chip,
Restore + Details actions.
- Click the row header (or "Details") to toggle a .task-details panel
showing snapshot ID, location, full timestamp, host, tags, and the
paths the snapshot covers.
- Shows up to the 50 most recent; >50 surfaces a hint to the global
backup center for the full list.
- flattenSnapshots() now carries hostname/tags/paths through so the
detail panel has real content.
Cross-page navigation:
- Dashboard app-tile click navigates to /app/<name>/backups instead of
opening the pick-now modal. The pick-now action is preserved as an
explicit "Back up" pill that appears top-right on hover/focus.
System tile keeps the old modal click (no dedicated page yet).
- Global Snapshots table — the App and ID cells are now SPA-routed
links to /app/<name>/backups?snapshot=<id>. Snapshots without an
app=<slug> tag (system backups) stay plain text. Routed via
navigateToRoute so the SPA mounts in place instead of a full reload.
Deep-link mechanism:
- BackupAppCard._honorSnapshotDeepLink reads ?snapshot=<id> on render,
finds the matching .backup-snapshot-item, opens its details, scrolls
it into view, and applies a brief .backup-snapshot-flash (animated
box-shadow pulse) so the user's eye lands on it after the SPA jump.
CSS:
- backup.css gains .backup-snapshot-rows, the location pill, the
monospace ID chip, the tag chips, the deep-link flash keyframes,
the tile "Back up" pill (.backup-app-tile-action — only visible on
hover/focus to keep the dashboard calm at rest), and the dashed
underline link style for the snapshot-table deep-link cells.
Signed-off-by: librelad <librelad@digitalangels.vip>
245 lines
11 KiB
JavaScript
245 lines
11 KiB
JavaScript
// 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/<name>/backups?snapshot=<id> 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 = `<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>
|
|
`;
|
|
|
|
const iconUrl = `/icons/apps/${encodeURIComponent(this.appName)}.svg`;
|
|
snapsEl.innerHTML = `
|
|
<div class="backup-snapshot-rows">
|
|
${allSnaps.slice(0, 50).map(s => this._renderRow(s, iconUrl)).join('')}
|
|
</div>
|
|
${allSnaps.length > 50 ? `<div class="backup-snapshot-overflow">Showing the most recent 50 of ${allSnaps.length} snapshots. Use the <a href="/backup">backup center</a> for the full list.</div>` : ''}
|
|
`;
|
|
|
|
// Deep-link: /app/<name>/backups?snapshot=<id> 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 `
|
|
<div class="task-item backup-snapshot-item" data-snapshot="${this.escape(sid)}" data-loc="${this.escape(String(s.locIdx))}">
|
|
<div class="task-header">
|
|
<div class="task-info">
|
|
<img src="${iconUrl}" alt="${this.escape(this.appName)}" class="task-app-icon" onerror="this.style.display='none'">
|
|
<span class="task-title">${this.escape(this.formatRelative(s.time))}</span>
|
|
<span class="task-status backup-snapshot-loc-pill">${this.escape(s.locName)}</span>
|
|
<span class="task-time" title="${this.escape(this._fmtFull(s.time))}">${this.escape(this._fmtShort(s.time))}</span>
|
|
<span class="backup-snapshot-id-chip" title="Snapshot ID">${this.escape(sid)}</span>
|
|
</div>
|
|
<div class="task-actions">
|
|
<button class="task-btn" data-action="restore-app-snapshot" data-loc="${this.escape(String(s.locIdx))}" data-snapshot="${this.escape(sid)}" title="Restore from this snapshot">
|
|
<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 toggle-details" data-action="toggle-snapshot" 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="task-meta">
|
|
<div class="meta-item"><strong>Snapshot ID:</strong> <code>${this.escape(sid)}</code></div>
|
|
<div class="meta-item"><strong>Location:</strong> ${this.escape(s.locName)}</div>
|
|
<div class="meta-item"><strong>When:</strong> ${this.escape(this._fmtFull(s.time))}</div>
|
|
${s.hostname ? `<div class="meta-item"><strong>Host:</strong> ${this.escape(s.hostname)}</div>` : ''}
|
|
${s.tags && s.tags.length ? `<div class="meta-item"><strong>Tags:</strong> ${s.tags.map(t => `<span class="backup-snapshot-tag">${this.escape(t)}</span>`).join(' ')}</div>` : ''}
|
|
${s.paths && s.paths.length ? `<div class="meta-item"><strong>Paths:</strong><br><code>${s.paths.map(p => this.escape(p)).join('<br>')}</code></div>` : ''}
|
|
</div>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
_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}? 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;
|