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}`)}
+
+
+
+
${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.icon}
+
+
${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',
};
}