ux(backup): per-app snapshot list in Services-tab style + drill-down nav

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>
This commit is contained in:
librelad 2026-05-28 00:15:17 +01:00
parent a0ca9a5e9f
commit e86a65042a
3 changed files with 282 additions and 47 deletions

View File

@ -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=<id> 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;
}

View File

@ -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/<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) {
@ -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 {
<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 = `
<table class="backup-snapshot-table" style="margin-top:14px">
<thead>
<tr>
<th>Location</th>
<th>When</th>
<th>ID</th>
<th class="backup-col-actions"></th>
</tr>
</thead>
<tbody>
${allSnaps.slice(0, 15).map(s => `
<tr>
<td>${this.escape(s.locName)}</td>
<td>${this.formatRelative(s.time)}</td>
<td class="backup-snapshot-id">${this.escape(s.id)}</td>
<td class="backup-col-actions">
<button class="backup-row-action-btn" data-action="restore-app-snapshot" data-loc="${s.locIdx}" data-snapshot="${this.escape(s.id)}">Restore</button>
</td>
</tr>
`).join('')}
</tbody>
</table>
<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() {
@ -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 : [],
});
});
});

View File

@ -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="<slug>". Both share .backup-app-tile.
// Deep-link from the Snapshots table → /app/<name>/backups?snapshot=<id>.
// 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 `
<div class="backup-app-tile backup-app-tile--system" data-system="1">
<div class="backup-app-tile backup-app-tile--system" data-system="1" title="Back up system config">
<img class="backup-app-tile-icon" src="/icons/apps/libreportal.svg" alt="" onerror="this.style.display='none'">
<div class="backup-app-tile-text">
<div class="backup-app-tile-name">System config</div>
@ -652,6 +680,9 @@ class BackupPage {
<span>${this.escape(when)}</span>
</div>
</div>
<button type="button" class="backup-app-tile-action" data-action="backup-now" data-system="1" title="Back up now">
Back up
</button>
</div>
`;
}
@ -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 `
<div class="backup-app-tile" data-app="${this.escape(app.app)}">
<div class="backup-app-tile" data-app="${this.escape(app.app)}" title="Open ${this.escape(displayName)} backup history">
<img class="backup-app-tile-icon" src="${this.escape(icon)}" alt="" onerror="this.src='/icons/apps/default.svg'">
<div class="backup-app-tile-text">
<div class="backup-app-tile-name">${this.escape(displayName)}</div>
@ -698,6 +729,9 @@ class BackupPage {
<span>${when}</span>
</div>
</div>
<button type="button" class="backup-app-tile-action" data-action="backup-now" data-app="${this.escape(app.app)}" title="Back up now">
Back up
</button>
</div>
`;
}
@ -1031,19 +1065,35 @@ class BackupPage {
return;
}
tbody.innerHTML = filtered.map(r => `
<tr>
<td>${this.escape(r.app)}</td>
<td>${this.escape(r.host)}</td>
<td>${this.escape(r.locName)}</td>
<td>${this.formatRelative(r.time)}</td>
<td class="backup-snapshot-id">${this.escape(r.id)}</td>
<td class="backup-col-actions">
<button class="backup-row-action-btn" data-action="restore-snapshot" data-app="${this.escape(r.app)}" data-loc="${r.locIdx}" data-snapshot="${this.escape(r.id)}">Restore</button>
<button class="backup-row-action-btn danger" data-action="delete-snapshot" data-app="${this.escape(r.app)}" data-loc="${r.locIdx}" data-snapshot="${this.escape(r.id)}">Delete</button>
</td>
</tr>
`).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=<slug> 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
? `<a class="backup-snapshot-link" href="${this.escape(deepLink)}" data-deep-link="${this.escape(deepLink)}">${this.escape(r.app)}</a>`
: this.escape(r.app);
const idCell = hasApp
? `<a class="backup-snapshot-link backup-snapshot-id" href="${this.escape(deepLink)}" data-deep-link="${this.escape(deepLink)}">${this.escape(r.id)}</a>`
: `<span class="backup-snapshot-id">${this.escape(r.id)}</span>`;
return `
<tr>
<td>${appCell}</td>
<td>${this.escape(r.host)}</td>
<td>${this.escape(r.locName)}</td>
<td>${this.formatRelative(r.time)}</td>
<td>${idCell}</td>
<td class="backup-col-actions">
<button class="backup-row-action-btn" data-action="restore-snapshot" data-app="${this.escape(r.app)}" data-loc="${r.locIdx}" data-snapshot="${this.escape(r.id)}">Restore</button>
<button class="backup-row-action-btn danger" data-action="delete-snapshot" data-app="${this.escape(r.app)}" data-loc="${r.locIdx}" data-snapshot="${this.escape(r.id)}">Delete</button>
</td>
</tr>
`;
}).join('');
}
renderConfiguration() {