From e86a65042a8b5de003cf774b41024c3dfce1260c Mon Sep 17 00:00:00 2001 From: librelad Date: Thu, 28 May 2026 00:15:17 +0100 Subject: [PATCH] ux(backup): per-app snapshot list in Services-tab style + drill-down nav MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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//backups └─ click any snapshot row expands to detail in place └─ click App / ID cell → /app//backups?snapshot= (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//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//backups?snapshot=. Snapshots without an app= 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= 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 --- .../libreportal/frontend/css/backup.css | 114 ++++++++++++++++ .../js/components/backup/backup-app-card.js | 125 ++++++++++++++---- .../js/components/backup/backup-page.js | 90 ++++++++++--- 3 files changed, 282 insertions(+), 47 deletions(-) diff --git a/containers/libreportal/frontend/css/backup.css b/containers/libreportal/frontend/css/backup.css index be9b66e..124a51b 100755 --- a/containers/libreportal/frontend/css/backup.css +++ b/containers/libreportal/frontend/css/backup.css @@ -1211,3 +1211,117 @@ font-size: 0.78rem; color: var(--text-secondary, rgba(var(--text-rgb), 0.65)); } + +/* ============================================================ + Per-app Backups tab — Services-style snapshot rows. + Each row is a .task-item + .task-header + .task-details so it + inherits the global task-list visual (shared with services). The + only backup-specific styling is the location pill colour, the + ID chip, the deep-link highlight flash, and the inline tags. + ============================================================ */ +.backup-snapshot-rows { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 14px; +} +.backup-snapshot-loc-pill { + background: rgba(var(--accent-rgb), 0.15); + color: var(--accent); + border-radius: 999px; + padding: 2px 9px; + font-size: 0.72rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; +} +.backup-snapshot-id-chip { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.72rem; + color: rgba(var(--text-rgb), 0.55); + padding: 1px 6px; + background: rgba(var(--text-rgb), 0.05); + border-radius: 4px; + letter-spacing: 0.02em; +} +.backup-snapshot-tag { + display: inline-block; + margin: 0 4px 2px 0; + padding: 1px 6px; + background: rgba(var(--text-rgb), 0.06); + border-radius: 4px; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.72rem; + color: rgba(var(--text-rgb), 0.7); +} +.backup-snapshot-overflow { + margin-top: 10px; + font-size: 0.78rem; + color: rgba(var(--text-rgb), 0.5); + text-align: center; +} +.backup-snapshot-overflow a { color: var(--accent); } + +/* Deep-link arrival: ?snapshot= flashes the row briefly so the + user's eye lands on the right thing after the SPA jump. */ +.backup-snapshot-flash { + animation: backup-snapshot-flash 2.2s ease-out; +} +@keyframes backup-snapshot-flash { + 0% { box-shadow: 0 0 0 0 rgba(var(--accent-rgb), 0.55); } + 20% { box-shadow: 0 0 0 4px rgba(var(--accent-rgb), 0.55); } + 100% { box-shadow: 0 0 0 0 rgba(var(--accent-rgb), 0.0); } +} + +/* ============================================================ + Global Backup dashboard — app tile + "Back up" action pill. + The whole tile navigates to the per-app Backups tab; the pill + is the explicit affordance for the old "open the pick modal" + behaviour. Hidden by default; visible on hover/focus so the + tile stays calm at rest. + ============================================================ */ +.backup-app-tile { + position: relative; +} +.backup-app-tile-action { + position: absolute; + top: 10px; + right: 10px; + padding: 4px 10px; + font-size: 0.72rem; + font-weight: 700; + color: var(--accent); + background: rgba(var(--accent-rgb), 0.14); + border: 1px solid rgba(var(--accent-rgb), 0.4); + border-radius: 999px; + cursor: pointer; + opacity: 0; + transform: translateY(-2px); + transition: opacity .15s ease, transform .15s ease, background .15s ease; +} +.backup-app-tile:hover .backup-app-tile-action, +.backup-app-tile:focus-within .backup-app-tile-action { + opacity: 1; + transform: translateY(0); +} +.backup-app-tile-action:hover { + background: rgba(var(--accent-rgb), 0.28); +} + +/* ============================================================ + Global Snapshots table — App + ID cells link to the per-app + page deep-linked to that snapshot. + ============================================================ */ +.backup-snapshot-link { + color: var(--text-primary); + text-decoration: none; + border-bottom: 1px dashed rgba(var(--accent-rgb), 0.4); + transition: color .15s ease, border-color .15s ease; +} +.backup-snapshot-link:hover { + color: var(--accent); + border-bottom-color: var(--accent); +} +a.backup-snapshot-link.backup-snapshot-id { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; +} diff --git a/containers/libreportal/frontend/js/components/backup/backup-app-card.js b/containers/libreportal/frontend/js/components/backup/backup-app-card.js index 25dad6a..48724a4 100644 --- a/containers/libreportal/frontend/js/components/backup/backup-app-card.js +++ b/containers/libreportal/frontend/js/components/backup/backup-app-card.js @@ -1,7 +1,11 @@ -// 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. +// 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) { @@ -28,7 +32,20 @@ class BackupAppCard { 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'); + } } }); } @@ -58,30 +75,81 @@ class BackupAppCard { ${allSnaps.length} total across ${locCount} location${locCount === 1 ? '' : 's'} `; + const iconUrl = `/icons/apps/${encodeURIComponent(this.appName)}.svg`; snapsEl.innerHTML = ` - - - - - - - - - - - ${allSnaps.slice(0, 15).map(s => ` - - - - - - - `).join('')} - -
LocationWhenID
${this.escape(s.locName)}${this.formatRelative(s.time)}${this.escape(s.id)} - -
+
+ ${allSnaps.slice(0, 50).map(s => this._renderRow(s, iconUrl)).join('')} +
+ ${allSnaps.length > 50 ? `
Showing the most recent 50 of ${allSnaps.length} snapshots. 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)} +
+
+ + +
+
+
+
+
Snapshot 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() { @@ -118,7 +186,10 @@ class BackupAppCard { locIdx, locName: this.locationsByIdx[locIdx]?.name || `Location ${locIdx}`, time: s.time, - id: s.short_id || (s.id || '').slice(0, 8) + id: s.short_id || (s.id || '').slice(0, 8), + hostname: s.hostname || '', + tags, + paths: Array.isArray(s.paths) ? s.paths : [], }); }); }); diff --git a/containers/libreportal/frontend/js/components/backup/backup-page.js b/containers/libreportal/frontend/js/components/backup/backup-page.js index e77dd56..706be4f 100644 --- a/containers/libreportal/frontend/js/components/backup/backup-page.js +++ b/containers/libreportal/frontend/js/components/backup/backup-page.js @@ -186,15 +186,43 @@ class BackupPage { return; } - // Click any tile on the dashboard → open the Back-up checklist - // modal with that tile pre-ticked. System tile is data-system="1"; - // app tiles carry data-app="". Both share .backup-app-tile. + // Deep-link from the Snapshots table → /app//backups?snapshot=. + // Routed via the SPA so the app page mounts in-place rather than a + // full reload. + const deepLink = e.target.closest('[data-deep-link]'); + if (deepLink) { + e.preventDefault(); + if (window.navigateToRoute) window.navigateToRoute(deepLink.dataset.deepLink); + else window.location.href = deepLink.dataset.deepLink; + return; + } + + // Tile actions, in priority order: + // 1. "Backup now" pill on the tile → opens the pick modal + // preticked with that tile (explicit affordance, replaces + // the old implicit whole-tile click). + // 2. Whole-tile click → navigates to the per-app Backups tab + // (or the system page for the System tile). This is the + // cohesion fix: each tile leads to the page that owns + // that subject's full detail, not a modal asking "do + // you want to back up?". + const backupNowBtn = e.target.closest('[data-action="backup-now"]'); + if (backupNowBtn) { + e.stopPropagation(); + if (backupNowBtn.dataset.system) { + this.openBackupPickModal({ preTickSystem: true }); + } else if (backupNowBtn.dataset.app) { + this.openBackupPickModal({ preTickApps: [backupNowBtn.dataset.app] }); + } + return; + } const tile = e.target.closest('.backup-app-tile'); if (tile) { if (tile.dataset.system) { + // System has no dedicated page yet — keep the pick modal. this.openBackupPickModal({ preTickSystem: true }); - } else if (tile.dataset.app) { - this.openBackupPickModal({ preTickApps: [tile.dataset.app] }); + } else if (tile.dataset.app && window.navigateToRoute) { + window.navigateToRoute(`/app/${encodeURIComponent(tile.dataset.app)}/backups`); } return; } @@ -643,7 +671,7 @@ class BackupPage { const dot = has ? 'ok' : 'none'; const when = has ? 'Last backed up ' + this.formatRelative(sys.latest_time) : 'No backup yet'; return ` -
+
System config
@@ -652,6 +680,9 @@ class BackupPage { ${this.escape(when)}
+
`; } @@ -689,7 +720,7 @@ class BackupPage { const when = has ? this.formatRelative(app.latest_time) : 'No backup yet'; const { icon, displayName } = this.appMeta(app.app); return ` -
+
${this.escape(displayName)}
@@ -698,6 +729,9 @@ class BackupPage { ${when}
+
`; } @@ -1031,19 +1065,35 @@ class BackupPage { return; } - tbody.innerHTML = filtered.map(r => ` - - ${this.escape(r.app)} - ${this.escape(r.host)} - ${this.escape(r.locName)} - ${this.formatRelative(r.time)} - ${this.escape(r.id)} - - - - - - `).join(''); + tbody.innerHTML = filtered.map(r => { + // Link the App and ID cells to the per-app Backups tab with the + // snapshot pre-expanded. "—" rows (snapshots without an + // app= tag, e.g. system config backups) stay plain text + // since there's no app page to open. + const hasApp = r.app && r.app !== '—'; + const deepLink = hasApp + ? `/app/${encodeURIComponent(r.app)}/backups?snapshot=${encodeURIComponent(r.id)}` + : null; + const appCell = hasApp + ? `${this.escape(r.app)}` + : this.escape(r.app); + const idCell = hasApp + ? `${this.escape(r.id)}` + : `${this.escape(r.id)}`; + return ` + + ${appCell} + ${this.escape(r.host)} + ${this.escape(r.locName)} + ${this.formatRelative(r.time)} + ${idCell} + + + + + + `; + }).join(''); } renderConfiguration() {