librelad 7ba281a390 ux(backup): redesign the snapshot details panel
The expanded snapshot detail reused the shared .task-meta/.meta-item
layout, which forces each field onto one nowrap line and clips long
values (the full date string, repo paths) mid-string. Give the backup
snapshot its own scoped label-over-value grid plus full-width Tags/Paths
blocks that wrap, surface app=/host=/engine= tags as their own fields,
and show a readable date (full timestamp on hover). Applied to both the
global Snapshots tab and the per-app Backups card so they match.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-28 18:38:28 +01:00

270 lines
12 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 backups yet`;
snapsEl.innerHTML = `<div class="backup-empty-state">No backups 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} backups. 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 || '');
const tagMap = {};
(s.tags || []).forEach(t => { const i = t.indexOf('='); if (i > 0) tagMap[t.slice(0, i)] = t.slice(i + 1); });
const engineName = tagMap.engine ? tagMap.engine.charAt(0).toUpperCase() + tagMap.engine.slice(1) : null;
const otherTags = (s.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(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="Backup 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="backup-snapshot-meta">
<div class="bsm-grid">
${s.hostname ? field('Host', this.escape(s.hostname)) : ''}
${field('Location', `<span class="backup-snapshot-loc-pill">${this.escape(s.locName)}</span>`)}
${field('Backup ID', `<code>${this.escape(sid)}</code>`)}
${field('When', `<span title="${this.escape(this._fmtFull(s.time))}">${this.escape(this._fmtNice(s.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>` : ''}
${s.paths && s.paths.length ? `
<div class="bsm-block">
<span class="bsm-label">Paths</span>
<ul class="bsm-paths">${s.paths.map(p => `<li><code>${this.escape(p)}</code></li>`).join('')}</ul>
</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();
}
_fmtNice(iso) {
if (!iso) return '';
const d = new Date(iso);
if (isNaN(d.getTime())) return iso;
return d.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' });
}
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 => ({
'&': '&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;