ux(backup): global Backups tab matches the per-app card pattern

The /backup → Backups tab was the last surface still rendering snapshots
as a plain HTML table — every other backup-related list had moved to the
.task-item card pattern shared with Services. Cohesion-only refactor:
both surfaces now look identical, with the global view adding the
fields the per-app view doesn't need.

HTML: drops <table class="backup-snapshot-table"> + its <tbody>,
replaces with a single <div id="backup-snapshot-list"
class="backup-snapshot-rows"> that the same .backup-snapshot-flash
deep-link highlight already targets.

renderSnapshots() now emits .task-item cards via the new
_renderSnapshotRow() helper. Each card carries:

  app icon · "12h ago" title · app-name chip (linked) · location pill
  · timestamp chip · short-ID chip       Restore · Delete · Details

Extras vs the per-app card:
  - App-name chip — global list isn't scoped to one app, so each row
    needs to name the app it belongs to. The chip is the deep-link to
    /app/<name>/backups?snapshot=<id> (replaces the dashed-underline
    "link" treatment on the old App / ID table cells).
  - Delete button alongside Restore — destructive cleanup lives on the
    global view, not on the per-app card.
  - "System config" rows (snapshots without an app=<slug> tag) get the
    LibrePortal icon and no app-link (no per-app page to open).

Detail panel (expanded via header / Details button) shows App, Backup
ID, Location, full timestamp, Host, Tags, Paths — the same shape as
the per-app version, plus Host (relevant on the global multi-host view).

Click delegation:
  - [data-action="toggle-snapshot-row"] on the header + Details button
    toggles .task-details-open
  - Restore / Delete buttons now stopPropagation so clicking them
    doesn't also toggle the panel
  - Existing [data-deep-link] handler is reused by the app-name chip

Signed-off-by: librelad <librelad@digitalangels.vip>
This commit is contained in:
librelad 2026-05-28 02:00:05 +01:00
parent 4492f79f73
commit d8f585aada
2 changed files with 93 additions and 47 deletions

View File

@ -111,21 +111,10 @@
<option value="">All locations</option>
</select>
</div>
<div class="backup-snapshot-table-wrap">
<table class="backup-snapshot-table">
<thead>
<tr>
<th>App</th>
<th>Host</th>
<th>Location</th>
<th>When</th>
<th>ID</th>
<th class="backup-col-actions">Actions</th>
</tr>
</thead>
<tbody id="backup-snapshot-tbody"></tbody>
</table>
</div>
<!-- Each backup is rendered by renderSnapshots() as a .task-item
card, matching the per-app Backups tab so the two surfaces
share one visual language. -->
<div class="backup-snapshot-rows" id="backup-snapshot-list"></div>
</section>
<section class="backup-tabpanel" id="backup-panel-locations">

View File

@ -234,16 +234,28 @@ class BackupPage {
const restoreBtn = e.target.closest('[data-action="restore-snapshot"]');
if (restoreBtn) {
e.stopPropagation(); // don't also toggle the row's details panel
this.openRestoreModal(restoreBtn.dataset.app, restoreBtn.dataset.loc, restoreBtn.dataset.snapshot);
return;
}
const deleteBtn = e.target.closest('[data-action="delete-snapshot"]');
if (deleteBtn) {
e.stopPropagation();
this.openDeleteModal(deleteBtn.dataset.app, deleteBtn.dataset.loc, deleteBtn.dataset.snapshot);
return;
}
// Row header / Details button → toggle the .task-details panel.
// Matches the per-app Backups tab interaction.
const snapToggle = e.target.closest('[data-action="toggle-snapshot-row"]');
if (snapToggle) {
const item = snapToggle.closest('.backup-snapshot-item');
const details = item && item.querySelector('.task-details');
if (details) details.classList.toggle('task-details-open');
return;
}
const locEnable = e.target.closest('[data-action="toggle-location-enabled"]');
if (locEnable) {
const cb = locEnable.querySelector('input[type="checkbox"]');
@ -1045,8 +1057,8 @@ class BackupPage {
}
renderSnapshots() {
const tbody = document.getElementById('backup-snapshot-tbody');
if (!tbody) return;
const list = document.getElementById('backup-snapshot-list');
if (!list) return;
const filter = (document.getElementById('backup-snapshot-filter')?.value || '').toLowerCase();
const locFilter = document.getElementById('backup-snapshot-repo')?.value || '';
@ -1067,6 +1079,8 @@ class BackupPage {
locName: locNameByIdx[locIdx] || `Loc ${locIdx}`,
time: s.time,
id: s.short_id || (s.id || '').slice(0, 8),
tags: Array.isArray(s.tags) ? s.tags : [],
paths: Array.isArray(s.paths) ? s.paths : [],
});
});
});
@ -1081,39 +1095,82 @@ class BackupPage {
) : rows;
if (!filtered.length) {
tbody.innerHTML = `<tr><td colspan="6" class="backup-empty-state">No backups yet.</td></tr>`;
list.innerHTML = `<div class="backup-empty-state">No backups yet.</div>`;
return;
}
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('');
list.innerHTML = filtered.map(r => this._renderSnapshotRow(r)).join('');
}
// Render one global-Snapshots-tab backup as the same .task-item card
// the per-app Backups tab uses, so the two surfaces look identical.
// Extras vs the per-app card:
// - An app-name chip (because the global list isn't scoped to one app)
// that doubles as a deep-link to /app/<name>/backups?snapshot=<id>
// - A Delete action alongside Restore (per-app card only offers
// Restore — delete lives in the global view)
_renderSnapshotRow(r) {
const hasApp = r.app && r.app !== '—';
const deepLink = hasApp
? `/app/${encodeURIComponent(r.app)}/backups?snapshot=${encodeURIComponent(r.id)}`
: null;
const iconUrl = hasApp ? `/icons/apps/${encodeURIComponent(r.app)}.svg` : '/icons/apps/libreportal.svg';
const displayName = hasApp ? this.appMeta(r.app).displayName : 'System config';
const appChip = hasApp
? `<a class="backup-snapshot-link backup-snapshot-app-chip" href="${this.escape(deepLink)}" data-deep-link="${this.escape(deepLink)}" title="Open ${this.escape(displayName)} backups">${this.escape(displayName)}</a>`
: `<span class="backup-snapshot-app-chip">${this.escape(displayName)}</span>`;
const sid = String(r.id);
return `
<div class="task-item backup-snapshot-item" data-snapshot="${this.escape(sid)}" data-loc="${this.escape(String(r.locIdx))}">
<div class="task-header" data-action="toggle-snapshot-row">
<div class="task-info">
<img src="${this.escape(iconUrl)}" alt="" class="task-app-icon" onerror="this.style.display='none'">
<span class="task-title">${this.escape(this.formatRelative(r.time))}</span>
${appChip}
<span class="task-status backup-snapshot-loc-pill">${this.escape(r.locName)}</span>
<span class="task-time" title="${this.escape(this._fmtFullTime(r.time))}">${this.escape(this._fmtShortTime(r.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-snapshot" data-app="${this.escape(r.app)}" data-loc="${this.escape(String(r.locIdx))}" data-snapshot="${this.escape(sid)}" title="Restore from this backup">
<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 delete" data-action="delete-snapshot" data-app="${this.escape(r.app)}" data-loc="${this.escape(String(r.locIdx))}" data-snapshot="${this.escape(sid)}" title="Delete this backup">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"></path><path d="M10 11v6M14 11v6"></path></svg>
<span class="task-btn-label">Delete</span>
</button>
<button class="task-btn toggle-details" data-action="toggle-snapshot-row" 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>App:</strong> ${this.escape(displayName)}${hasApp ? ` (<code>${this.escape(r.app)}</code>)` : ''}</div>
<div class="meta-item"><strong>Backup ID:</strong> <code>${this.escape(sid)}</code></div>
<div class="meta-item"><strong>Location:</strong> ${this.escape(r.locName)}</div>
<div class="meta-item"><strong>When:</strong> ${this.escape(this._fmtFullTime(r.time))}</div>
<div class="meta-item"><strong>Host:</strong> ${this.escape(r.host)}</div>
${r.tags && r.tags.length ? `<div class="meta-item"><strong>Tags:</strong> ${r.tags.map(t => `<span class="backup-snapshot-tag">${this.escape(t)}</span>`).join(' ')}</div>` : ''}
${r.paths && r.paths.length ? `<div class="meta-item"><strong>Paths:</strong><br><code>${r.paths.map(p => this.escape(p)).join('<br>')}</code></div>` : ''}
</div>
</div>
</div>`;
}
_fmtShortTime(iso) {
if (!iso) return '';
const d = new Date(iso);
if (isNaN(d.getTime())) return String(iso);
return d.toLocaleString();
}
_fmtFullTime(iso) {
if (!iso) return '';
const d = new Date(iso);
if (isNaN(d.getTime())) return String(iso);
return d.toString();
}
renderConfiguration() {