From 7ba281a3908e94c37dcc4e5028b6f0b37b7bdcc9 Mon Sep 17 00:00:00 2001 From: librelad Date: Thu, 28 May 2026 18:38:28 +0100 Subject: [PATCH] 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 Signed-off-by: librelad --- .../libreportal/frontend/css/backup.css | 77 +++++++++++++++++++ .../js/components/backup/backup-app-card.js | 39 ++++++++-- .../js/components/backup/backup-page.js | 43 +++++++++-- 3 files changed, 144 insertions(+), 15 deletions(-) diff --git a/containers/libreportal/frontend/css/backup.css b/containers/libreportal/frontend/css/backup.css index ea0fc4e..aebc91f 100755 --- a/containers/libreportal/frontend/css/backup.css +++ b/containers/libreportal/frontend/css/backup.css @@ -1322,6 +1322,83 @@ font-size: 0.72rem; color: rgba(var(--text-rgb), 0.7); } +/* Snapshot detail panel. The shared .task-meta/.meta-item layout forces + one nowrap line per item and clips long values (the full date, repo + paths) mid-string, so the backup row gets its own label-over-value grid + plus full-width blocks for tags and paths that wrap cleanly. */ +.backup-snapshot-meta { + display: flex; + flex-direction: column; + gap: 14px; +} +.backup-snapshot-meta .bsm-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 14px 18px; +} +.backup-snapshot-meta .bsm-field { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} +.backup-snapshot-meta .bsm-label { + font-size: 0.68rem; + text-transform: uppercase; + letter-spacing: 0.05em; + font-weight: 600; + color: rgba(var(--text-rgb), 0.45); +} +.backup-snapshot-meta .bsm-value { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; + min-width: 0; + font-size: 0.85rem; + color: var(--text-primary); +} +.backup-snapshot-meta .bsm-value code { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.78rem; + background: rgba(var(--text-rgb), 0.06); + color: rgba(var(--text-rgb), 0.82); + padding: 1px 6px; + border-radius: 4px; + word-break: break-all; +} +.backup-snapshot-meta .bsm-block { + display: flex; + flex-direction: column; + gap: 8px; + padding-top: 13px; + border-top: 1px solid rgba(var(--text-rgb), 0.08); +} +.backup-snapshot-meta .bsm-tags { + display: flex; + flex-wrap: wrap; + gap: 5px; +} +.backup-snapshot-meta .bsm-paths { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 6px; +} +.backup-snapshot-meta .bsm-paths code { + display: inline-block; + max-width: 100%; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.78rem; + background: rgba(var(--text-rgb), 0.06); + color: rgba(var(--text-rgb), 0.82); + padding: 4px 9px; + border-radius: 5px; + word-break: break-all; +} + .backup-snapshot-overflow { margin-top: 10px; font-size: 0.78rem; 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 4cc0fc3..21cbd8b 100644 --- a/containers/libreportal/frontend/js/components/backup/backup-app-card.js +++ b/containers/libreportal/frontend/js/components/backup/backup-app-card.js @@ -91,6 +91,14 @@ class BackupAppCard { _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) => + `
${label}${valueHtml}
`; + return `
@@ -113,13 +121,24 @@ class BackupAppCard {
-
-
Backup 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('
')}
` : ''} +
+
+ ${s.hostname ? field('Host', this.escape(s.hostname)) : ''} + ${field('Location', `${this.escape(s.locName)}`)} + ${field('Backup ID', `${this.escape(sid)}`)} + ${field('When', `${this.escape(this._fmtNice(s.time))}`)} + ${engineName ? field('Engine', this.escape(engineName)) : ''} +
+ ${otherTags.length ? ` +
+ Tags +
${otherTags.map(t => `${this.escape(t)}`).join('')}
+
` : ''} + ${s.paths && s.paths.length ? ` +
+ Paths +
    ${s.paths.map(p => `
  • ${this.escape(p)}
  • `).join('')}
+
` : ''}
`; @@ -151,6 +170,12 @@ class BackupAppCard { 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(); diff --git a/containers/libreportal/frontend/js/components/backup/backup-page.js b/containers/libreportal/frontend/js/components/backup/backup-page.js index e426601..5b807a6 100644 --- a/containers/libreportal/frontend/js/components/backup/backup-page.js +++ b/containers/libreportal/frontend/js/components/backup/backup-page.js @@ -1154,6 +1154,16 @@ class BackupPage { ? `${this.escape(displayName)}` : `${this.escape(displayName)}`; const sid = String(r.id); + + // Restic stamps app=/host=/engine= into the snapshot tags; surface + // those as their own fields and keep any remaining tags as chips. + const tagMap = {}; + (r.tags || []).forEach(t => { const i = t.indexOf('='); if (i > 0) tagMap[t.slice(0, i)] = t.slice(i + 1); }); + const engineName = tagMap.engine ? this.engineDisplayName(tagMap.engine) : null; + const otherTags = (r.tags || []).filter(t => !/^(app|host|engine|paths?)=/.test(t)); + const field = (label, valueHtml) => + `
${label}${valueHtml}
`; + return `
@@ -1181,14 +1191,25 @@ class BackupPage {
-
-
App: ${this.escape(displayName)}${hasApp ? ` (${this.escape(r.app)})` : ''}
-
Backup ID: ${this.escape(sid)}
-
Location: ${this.escape(r.locName)}
-
When: ${this.escape(this._fmtFullTime(r.time))}
-
Host: ${this.escape(r.host)}
- ${r.tags && r.tags.length ? `
Tags: ${r.tags.map(t => `${this.escape(t)}`).join(' ')}
` : ''} - ${r.paths && r.paths.length ? `
Paths:
${r.paths.map(p => this.escape(p)).join('
')}
` : ''} +
+
+ ${field('App', `${this.escape(displayName)}${hasApp ? ` ${this.escape(r.app)}` : ''}`)} + ${field('Host', this.escape(r.host))} + ${field('Location', `${this.escape(r.locName)}`)} + ${field('Backup ID', `${this.escape(sid)}`)} + ${field('When', `${this.escape(this._fmtNiceTime(r.time))}`)} + ${engineName ? field('Engine', this.escape(engineName)) : ''} +
+ ${otherTags.length ? ` +
+ Tags +
${otherTags.map(t => `${this.escape(t)}`).join('')}
+
` : ''} + ${r.paths && r.paths.length ? ` +
+ Paths +
    ${r.paths.map(p => `
  • ${this.escape(p)}
  • `).join('')}
+
` : ''}
`; @@ -1206,6 +1227,12 @@ class BackupPage { if (isNaN(d.getTime())) return String(iso); return d.toString(); } + _fmtNiceTime(iso) { + if (!iso) return ''; + const d = new Date(iso); + if (isNaN(d.getTime())) return String(iso); + return d.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' }); + } renderConfiguration() { const body = document.getElementById('backup-configuration-body');