From 7db2a707b283f8bcbe5e9a47db29edf8aca21803 Mon Sep 17 00:00:00 2001 From: librelad Date: Fri, 12 Jun 2026 22:33:23 +0100 Subject: [PATCH] refactor(overview): turn the fleet Overview tab into an action board MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the stat-tile grid with a needs-attention board: a health hero on top (one big status circle β€” green all-clear / amber something-to-do / neutral pre-scan β€” matching the admin dashboard's dot language) over one status row per area (updates, security, improvements, backups). Rows that want a decision are tinted with their area hue and carry their action buttons inline (Review / Update all / Open Backups); healthy areas collapse to a quiet neutral one-liner. An Everything / Needs action chip pair filters the board down to just the actionable rows. Board rows deep-link with intent: Security lands on the Updates tab pre-filtered to the affected apps via a new data-filter hop on the goto action. backupSummary() now splits never-backed-up from week-stale apps so the backup row can say which it is. Signed-off-by: librelad --- .../components/apps/overview/css/overview.css | 68 ++++++ .../apps/overview/js/overview-manager.js | 204 ++++++++++++++---- 2 files changed, 235 insertions(+), 37 deletions(-) diff --git a/containers/libreportal/frontend/components/apps/overview/css/overview.css b/containers/libreportal/frontend/components/apps/overview/css/overview.css index 5da0ab0..bcfc362 100644 --- a/containers/libreportal/frontend/components/apps/overview/css/overview.css +++ b/containers/libreportal/frontend/components/apps/overview/css/overview.css @@ -69,6 +69,74 @@ border-radius: 8px; } +/* ---- Overview: health hero + action board -------------------------------- */ +/* The hero is the tab's "status circle": one glance answers "anything to do?". + Status colours match the admin dashboard's .admin-status-dot palette + (ok/warn/none) so the two boards speak the same language. */ +.ov-health { + display: flex; + align-items: center; + gap: 16px; + flex-wrap: wrap; + padding: 18px 20px; + margin-bottom: 14px; + border-radius: 12px; + border: 1px solid rgba(var(--text-rgb), .12); + background: rgba(var(--text-rgb), .04); +} +.ov-health.ok { background: rgba(54, 211, 153, .07); border-color: rgba(54, 211, 153, .25); } +.ov-health.warn { background: rgba(251, 189, 35, .07); border-color: rgba(251, 189, 35, .3); } +.ov-health-dot { width: 18px; height: 18px; border-radius: 50%; flex: 0 0 auto; } +.ov-health-dot.ok { background: #36d399; box-shadow: 0 0 12px rgba(54, 211, 153, .55); } +.ov-health-dot.warn { background: #fbbd23; box-shadow: 0 0 12px rgba(251, 189, 35, .55); } +.ov-health-dot.none { background: rgba(var(--text-rgb), .25); } +.ov-health-main { flex: 1; min-width: 220px; } +.ov-health-title { font-weight: 600; font-size: 1.02rem; color: var(--text-primary, #fff); } +.ov-health-sub { margin-top: 3px; font-size: .82rem; color: rgba(var(--text-rgb), .55); } +.ov-health .ov-chips { flex-shrink: 0; } + +/* Board rows: actionable ones are tinted with their area's identity hue and + carry buttons; ok/none rows stay neutral and quiet so they don't compete. */ +.ov-board { display: flex; flex-direction: column; gap: 10px; } +.ov-board-row { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + padding: 13px 16px; + border-radius: 11px; + background: rgba(var(--page-rgb, var(--accent-rgb)), .06); + border: 1px solid rgba(var(--page-rgb, var(--accent-rgb)), .16); +} +.ov-board-row.ok, +.ov-board-row.none { + background: rgba(var(--text-rgb), .03); + border-color: rgba(var(--text-rgb), .08); +} +.ov-dot { width: 9px; height: 9px; border-radius: 50%; flex: 0 0 auto; } +.ov-dot.ok { background: #36d399; } +.ov-dot.warn { background: #fbbd23; } +.ov-dot.none { background: rgba(var(--text-rgb), .25); } +.ov-board-icon { flex: 0 0 auto; font-size: 1.05rem; } +.ov-board-main { flex: 1; min-width: 220px; } +.ov-board-title { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + font-weight: 600; + font-size: .92rem; + color: var(--text-primary, #fff); +} +.ov-board-row.ok .ov-board-title, +.ov-board-row.none .ov-board-title { font-weight: 500; color: rgba(var(--text-rgb), .7); } +.ov-board-sub { margin-top: 3px; font-size: .8rem; color: rgba(var(--text-rgb), .5); } +.ov-board-actions { display: flex; gap: 8px; flex-shrink: 0; align-items: center; } + +/* "Needs action" view with nothing to do β€” keep it calm, the hero already + carries the good news. */ +.ov-allclear { padding: 28px 20px; text-align: center; font-size: .9rem; color: rgba(var(--text-rgb), .55); } + /* ---- Updates: filter chips + toolbar ------------------------------------ */ .ov-toolbar { display: flex; diff --git a/containers/libreportal/frontend/components/apps/overview/js/overview-manager.js b/containers/libreportal/frontend/components/apps/overview/js/overview-manager.js index 7089935..0738dec 100644 --- a/containers/libreportal/frontend/components/apps/overview/js/overview-manager.js +++ b/containers/libreportal/frontend/components/apps/overview/js/overview-manager.js @@ -23,7 +23,7 @@ // full BackupPage, which supplies its own header β€” so it has no entry to render // but is listed for completeness.) const OV_TAB_META = { - overview: { icon: 'πŸ›°οΈ', title: 'Overview', desc: 'Updates, improvements, and backups across all your apps.' }, + overview: { icon: 'πŸ›°οΈ', title: 'Overview', desc: 'What needs your attention across all your apps β€” updates, security, improvements, and backups.' }, updates: { icon: '⬆️', title: 'Updates', desc: 'Available versions per app β€” expand a row for CVEs, recovery, and history. Every update is snapshotted first.' }, improvements: { icon: '✨', title: 'Improvements', desc: 'Signed, individually-reversible hotfixes from the LibrePortal team β€” applied with a snapshot first.' }, backups: { icon: 'πŸ’Ύ', title: 'Backups', desc: 'Backup health across your apps. Open an app for snapshots and restore.' }, @@ -37,6 +37,7 @@ class OverviewManager { this.backup = null; // /data/backup/generated/dashboard.json this.tabs = null; // TabController (show/hide only) this.filter = 'all'; // Updates-tab chip: all | updates | security + this.board = 'all'; // Overview-tab chip: all | action this.current = null; } @@ -305,61 +306,190 @@ class OverviewManager { const oa = e.target.closest('[data-overview-action]'); if (oa) { switch (oa.dataset.overviewAction) { - case 'goto': this.switchTab(oa.dataset.tab); break; + case 'goto': + // Board rows can land on Updates pre-filtered (e.g. Security β†’ the + // affected apps only), so honor an optional data-filter on the way. + if (oa.dataset.filter) this.filter = oa.dataset.filter; + this.switchTab(oa.dataset.tab); + break; case 'filter': this.filter = oa.dataset.filter || 'all'; this.renderTab('updates'); break; + case 'board': this.board = oa.dataset.board || 'all'; this.renderTab('overview'); break; case 'toggle': this.toggleAppDetails(oa.dataset.app); break; } } } - // ---- Overview tab -------------------------------------------------------- + // ---- Overview tab (action board) ------------------------------------------ + // An action board, not a stat wall: a health hero on top (one big circle β€” + // green all-clear / amber something-to-do / neutral no-data-yet, the admin + // dashboard's dot language at fleet scale), then one row per area. Rows that + // want a decision carry their action buttons; healthy areas collapse to a + // quiet one-liner, and the "Needs action" chip hides them entirely. The raw + // numbers all live inside the rows and the hero's stat line. renderOverview() { - const u = this.updater - ? this.updater.counts() - : { updatesAvailable: 0, totalCves: 0, cveTotals: {}, improvements: 0, apps: 0, lastChecked: null }; + const rows = this.areaStatuses(); + const warn = rows.filter((r) => r.kind === 'warn'); + const scanned = !!(this.updater && this.updater.updates); + const u = this.updater ? this.updater.counts() : { apps: 0, lastChecked: null }; const b = this.backupSummary(); - const sev = u.cveTotals || {}; - const checked = (this.updater && u.lastChecked) ? this.updater.fmtRel(u.lastChecked) : 'never'; - const card = (hue, big, label, sub, btn) => ` -
-
${big}
-
${label}
- ${sub ? `
${sub}
` : ''} - ${btn || ''} -
`; + const kind = warn.length ? 'warn' : (scanned ? 'ok' : 'none'); + const headline = warn.length + ? `${warn.length} thing${warn.length === 1 ? '' : 's'} need${warn.length === 1 ? 's' : ''} your attention` + : (scanned ? 'All clear β€” nothing needs your attention' : 'Waiting for the first scan'); + let sub; + if (scanned) { + const checked = u.lastChecked ? this.updater.fmtRel(u.lastChecked) : 'never'; + const bits = [`${u.apps} app${u.apps === 1 ? '' : 's'} tracked`]; + if (this.backup) bits.push(`${b.protectedLabel} backed up`); + bits.push(`last scan ${checked}`); + sub = bits.join(' Β· '); + } else { + sub = 'The first automatic scan runs within a couple of minutes β€” or hit Check now.'; + } + const chip = (id, label, n) => + ``; + const shown = this.board === 'action' ? warn : rows; + const list = shown.length + ? `
${shown.map((r) => this.boardRow(r)).join('')}
` + : `
Nothing needs action right now.
`; return ` -
- ${card('updates', u.updatesAvailable, 'Updates available', u.updatesAvailable ? 'across your apps' : "you're current", - ``)} - ${card('verify', u.totalCves, 'Known CVEs', - (sev.critical || sev.high) ? `${sev.critical || 0} critical Β· ${sev.high || 0} high` : 'no high-severity issues', - ``)} - ${card('updater', u.improvements, 'Improvements', u.improvements ? 'signed hotfixes to apply' : 'nothing pending', - ``)} - ${card('backups', b.protectedLabel, 'Backups protected', b.sub, - ``)} - ${card('system', u.apps, 'Apps tracked', `last scan: ${checked}`)} +
+ +
+
${headline}
+
${sub}
+
+
${chip('all', 'Everything')}${chip('action', 'Needs action', warn.length)}
- ${(this.updater && this.updater.updates) ? '' : `
No scan data yet β€” the first automatic scan runs within a couple of minutes, or hit Check now.
`}`; + ${list}`; + } + + // One status per area. kind 'warn' = actionable (gets buttons), 'ok' = quiet + // one-liner, 'none' = no data yet. Amber is the ceiling β€” per the + // not-alarmist convention nothing on this board is *broken*, it just wants a + // decision; severity detail (e.g. critical CVEs) lives in the row's badge. + areaStatuses() { + const esc = (s) => this.escape(s); + const up = this.updater; + const u = up ? up.counts() : { updatesAvailable: 0, totalCves: 0, cveTotals: {}, improvements: 0, apps: 0 }; + const scanned = !!(up && up.updates); + const goto = (tab, label, filter, primary) => + ``; + const rows = []; + + // Updates + const pending = up ? up.apps.filter((a) => a.update_available) : []; + if (!scanned) { + rows.push({ hue: 'updates', icon: '⬆️', kind: 'none', text: 'Updates β€” not scanned yet' }); + } else if (pending.length) { + const names = pending.map((a) => a.displayName || a.name); + const list = names.slice(0, 3).join(', ') + (names.length > 3 ? ` +${names.length - 3} more` : ''); + rows.push({ + hue: 'updates', icon: '⬆️', kind: 'warn', + text: `${pending.length} update${pending.length === 1 ? '' : 's'} available`, + sub: `${esc(list)} β€” a recovery snapshot is taken before each update`, + actions: goto('updates', 'Review', 'updates') + + ``, + }); + } else { + rows.push({ + hue: 'updates', icon: '⬆️', kind: 'ok', + text: u.apps ? `Updates β€” all ${u.apps} app${u.apps === 1 ? '' : 's'} current` : 'Updates β€” no installed apps to track yet', + }); + } + + // Security (needs scan data β€” without it the updates row already says so) + if (scanned) { + const sev = u.cveTotals || {}; + if (u.totalCves) { + const hit = up.apps.filter((a) => (a.cves || []).length).length; + const order = ['critical', 'high', 'medium', 'low']; + const worst = order.find((s) => sev[s]); + rows.push({ + hue: 'verify', icon: 'πŸ›‘οΈ', kind: 'warn', + text: `${u.totalCves} known CVE${u.totalCves === 1 ? '' : 's'} across ${hit} app${hit === 1 ? '' : 's'}`, + badge: worst ? ` ${worst}` : '', + sub: order.filter((s) => sev[s]).map((s) => `${sev[s]} ${s}`).join(' Β· '), + actions: goto('updates', 'Review', 'security'), + }); + } else { + rows.push({ hue: 'verify', icon: 'πŸ›‘οΈ', kind: 'ok', text: 'Security β€” no known CVEs' }); + } + } + + // Improvements (signed hotfixes β€” independent of the version scan) + if (u.improvements) { + rows.push({ + hue: 'updater', icon: '✨', kind: 'warn', + text: `${u.improvements} signed improvement${u.improvements === 1 ? '' : 's'} ready to apply`, + sub: 'individually reversible β€” a snapshot is taken first', + actions: goto('improvements', 'View'), + }); + } else { + rows.push({ hue: 'updater', icon: '✨', kind: 'ok', text: 'Improvements β€” nothing pending' }); + } + + // Backups + const b = this.backupSummary(); + if (!this.backup) { + rows.push({ hue: 'backups', icon: 'πŸ’Ύ', kind: 'none', text: 'Backups β€” no backup data yet' }); + } else if (b.apps && !b.locations) { + rows.push({ + hue: 'backups', icon: 'πŸ’Ύ', kind: 'warn', + text: 'No backup location configured', + sub: 'apps can’t be protected until there’s somewhere to back them up to', + actions: goto('backups', 'Open Backups'), + }); + } else if (b.never + b.old) { + const n = b.never + b.old; + rows.push({ + hue: 'backups', icon: 'πŸ’Ύ', kind: 'warn', + text: `${n} app${n === 1 ? '' : 's'} need${n === 1 ? 's' : ''} a fresh backup`, + sub: [b.never && `${b.never} never backed up`, b.old && `${b.old} older than a week`].filter(Boolean).join(' Β· '), + actions: goto('backups', 'Open Backups'), + }); + } else { + rows.push({ + hue: 'backups', icon: 'πŸ’Ύ', kind: 'ok', + text: b.apps ? `Backups β€” all ${b.apps} app${b.apps === 1 ? '' : 's'} protected Β· last ${b.latest}` : 'Backups β€” no apps to protect yet', + }); + } + return rows; + } + + boardRow(r) { + return `
+ + +
+
${r.text}${r.badge || ''}
+ ${r.sub ? `
${r.sub}
` : ''} +
+ ${r.actions ? `
${r.actions}
` : ''} +
`; } backupSummary() { const d = this.backup; - if (!d) return { protectedLabel: 'β€”', sub: 'no backup data', stale: 0 }; + if (!d) return { apps: 0, protectedLabel: 'β€”', never: 0, old: 0, locations: 0, latest: 'never' }; const apps = Array.isArray(d.apps) ? d.apps : []; - const protectedCount = apps.filter((a) => a.latest_snapshot).length; const now = Date.now(); - const stale = apps.filter((a) => { - if (!a.latest_time) return true; + let never = 0, old = 0, newest = 0; + for (const a of apps) { + if (!a.latest_snapshot) { never++; continue; } const t = Date.parse(a.latest_time); - return isNaN(t) ? true : (now - t) > 7 * 86400 * 1000; - }).length; - const latest = (d.system && d.system.latest_time && this.updater) ? this.updater.fmtRel(d.system.latest_time) : 'never'; + if (isNaN(t) || (now - t) > 7 * 86400 * 1000) old++; + if (!isNaN(t) && t > newest) newest = t; + } + const sysT = d.system ? Date.parse(d.system.latest_time) : NaN; + if (!isNaN(sysT) && sysT > newest) newest = sysT; return { - protectedLabel: `${protectedCount}/${apps.length || 0}`, - sub: stale ? `${stale} need attention Β· last ${latest}` : `all current Β· last ${latest}`, - stale, + apps: apps.length, + protectedLabel: `${apps.length - never}/${apps.length}`, + never, old, + locations: Array.isArray(d.locations) ? d.locations.length : 0, + latest: (newest && this.updater) ? this.updater.fmtRel(new Date(newest).toISOString()) : 'never', }; }