diff --git a/containers/libreportal/frontend/components/apps/core/html/apps-unified-layout.html b/containers/libreportal/frontend/components/apps/core/html/apps-unified-layout.html
index e1812df..c15f2fc 100755
--- a/containers/libreportal/frontend/components/apps/core/html/apps-unified-layout.html
+++ b/containers/libreportal/frontend/components/apps/core/html/apps-unified-layout.html
@@ -6,6 +6,18 @@
+
+
+
+
+
+
+
+
+ 🛰️
+ Overview
+
+
+ ⬆️
+ Updates
+
+
+ ✨
+ Improvements
+
+
+ 💾
+ Backups
+
+
+
+
+
+
diff --git a/containers/libreportal/frontend/components/apps/core/js/apps-manager.js b/containers/libreportal/frontend/components/apps/core/js/apps-manager.js
index f097ab8..eaa4d32 100755
--- a/containers/libreportal/frontend/components/apps/core/js/apps-manager.js
+++ b/containers/libreportal/frontend/components/apps/core/js/apps-manager.js
@@ -449,19 +449,17 @@ class AppsManager {
}
showView(viewType) {
- // Get both view containers
- const appsView = document.getElementById('apps-view');
- const appDetailView = document.getElementById('app-detail-view');
-
- if (viewType === 'apps') {
- // Show apps view, hide app detail view
- if (appsView) appsView.style.display = 'block';
- if (appDetailView) appDetailView.style.display = 'none';
- } else if (viewType === 'app-detail') {
- // Show app detail view, hide apps view
- if (appsView) appsView.style.display = 'none';
- if (appDetailView) appDetailView.style.display = 'block';
- }
+ // Three sibling content-views share the apps shell: the grid, the per-app
+ // detail, and the fleet Overview. Exactly one is visible at a time.
+ const views = {
+ 'apps': document.getElementById('apps-view'),
+ 'app-detail': document.getElementById('app-detail-view'),
+ 'overview': document.getElementById('overview-view'),
+ };
+ Object.keys(views).forEach((key) => {
+ const el = views[key];
+ if (el) el.style.display = (key === viewType) ? 'block' : 'none';
+ });
}
diff --git a/containers/libreportal/frontend/components/apps/feature.json b/containers/libreportal/frontend/components/apps/feature.json
index 7805010..cd3501b 100644
--- a/containers/libreportal/frontend/components/apps/feature.json
+++ b/containers/libreportal/frontend/components/apps/feature.json
@@ -4,7 +4,9 @@
"/apps",
"/apps*",
"/app",
- "/app*"
+ "/app*",
+ "/overview",
+ "/overview*"
],
"module": "/components/apps/index.js",
"handler": "handleApps",
diff --git a/containers/libreportal/frontend/components/apps/index.js b/containers/libreportal/frontend/components/apps/index.js
index fc7a3ac..caa4739 100644
--- a/containers/libreportal/frontend/components/apps/index.js
+++ b/containers/libreportal/frontend/components/apps/index.js
@@ -6,11 +6,14 @@
// sibling component; it's the same feature, so it lives here.)
LP.features.register({
id: 'apps',
- routes: ['/apps', '/apps*', '/app', '/app*'],
+ routes: ['/apps', '/apps*', '/app', '/app*', '/overview', '/overview*'],
async mount(ctx) {
- // /apps* -> grid; everything else (/app*) -> detail. Check '/apps' FIRST so
- // it wins over '/app' (since '/apps'.startsWith('/app')).
+ // /overview* -> fleet Overview; /apps* -> grid; everything else (/app*) ->
+ // detail. Check '/apps' before '/app' (since '/apps'.startsWith('/app')).
+ if (window.location.pathname.startsWith('/overview')) {
+ return this._mountOverview(ctx);
+ }
if (window.location.pathname.startsWith('/apps')) {
return this._mountGrid(ctx);
}
@@ -75,12 +78,50 @@ LP.features.register({
await window.appTabbedManager.initialize();
},
- async unmount() {
+ // ---- fleet Overview (/overview[/]) ----
+ async _mountOverview(ctx) {
+ // Lazy-load the fleet controller + its deps. Guard by typeof so re-entry
+ // never re-declares the classes (loadScripts dedupes by URL too). The
+ // headless UpdaterPage supplies all update/CVE/improvement data + rendering.
+ const need = [];
+ if (typeof TabController === 'undefined') need.push('/core/ui-state/js/tab-controller.js');
+ if (typeof UpdaterPage === 'undefined') need.push('/components/updater/js/updater-page.js');
+ if (typeof OverviewManager === 'undefined') need.push('/components/apps/overview/js/overview-manager.js');
+ if (need.length) await ctx.loadScripts(need);
+
+ // Reuse the shared apps layout (keeps the sidebar persistent), same as grid.
+ if (!document.querySelector('.apps-layout')) {
+ const html = await ctx.loadFragment('/components/apps/core/html/apps-unified-layout.html');
+ ctx.setContent(html, 'Overview');
+ }
+
+ // Render the apps sidebar (search + categories) but show the Overview pane
+ // and highlight the Overview entry instead of any category.
+ if (window.appsManager) {
+ window.appsManager.currentView = 'overview';
+ try { window.appsManager.setupSidebar('__overview__'); } catch (_) {}
+ window.appsManager.showView('overview');
+ }
+ document.querySelectorAll('.apps-layout .category.active').forEach((c) => c.classList.remove('active'));
+ const entry = document.getElementById('sidebar-overview-entry');
+ if (entry) entry.classList.add('active');
+
+ if (typeof OverviewManager === 'undefined') throw new Error('OverviewManager failed to load');
+ if (!window.overviewManager) window.overviewManager = new OverviewManager(ctx.services);
+ await window.overviewManager.initialize();
+ },
+
+ async unmount(ctx) {
// appsManager / appTabbedManager are shared singletons (never null them), but
// the detail view's per-mount resources DO need releasing: the reconcile loop,
// the watchdog window/document listeners, and the active tab's Services
// intervals + log SSE. dispose() handles all of it (re-armed on next mount).
// The dirty-config nav guard still fires in navigate() before unmount.
try { window.appTabbedManager && window.appTabbedManager.dispose && window.appTabbedManager.dispose(); } catch (_) {}
+ // Drop the Overview task-refresh registration when leaving the apps feature
+ // so a finished update/backup task can't repaint a torn-down pane. The
+ // overviewManager singleton + its DOM persist with the layout; its run()
+ // self-guards, but unregistering is the clean release.
+ try { ctx && ctx.services.tasks.refresh && ctx.services.tasks.refresh.unregister('overview'); } catch (_) {}
},
});
diff --git a/containers/libreportal/frontend/components/apps/overview/css/overview.css b/containers/libreportal/frontend/components/apps/overview/css/overview.css
new file mode 100644
index 0000000..110ca07
--- /dev/null
+++ b/containers/libreportal/frontend/components/apps/overview/css/overview.css
@@ -0,0 +1,174 @@
+/* components/apps/overview/css/overview.css — the fleet Overview area.
+ *
+ * Lives inside the apps shell (shares the apps sidebar). Deliberately leans on
+ * already-global stylesheets: .tabbed-interface/.main-tab-button/.tab-pane from
+ * apps.css, and .updater-stat/.updater-row/.updater-badge/.sev-* from updater.css.
+ * Only the Overview-specific bits live here. */
+
+/* Hidden until AppsManager.showView('overview') reveals it. */
+#overview-view { display: none; }
+#overview-view.content-view { padding: 0; }
+
+/* Pane show/hide is self-contained so it never depends on where the base
+ .tab-pane rule is scoped. */
+#overview-view .tab-pane { display: none; }
+#overview-view .tab-pane.active { display: block; }
+
+/* ---- sidebar "Overview" entry (pinned above the search box) ------------- */
+.sidebar-overview-entry {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ margin: 4px 8px 10px;
+ padding: 10px 12px;
+ border-radius: 8px;
+ cursor: pointer;
+ font-weight: 600;
+ color: var(--sidebar-text, #cdd6e4);
+ border: 1px solid transparent;
+ transition: background .15s ease, color .15s ease, border-color .15s ease;
+}
+.sidebar-overview-entry:hover { background: rgba(255, 255, 255, .06); }
+.sidebar-overview-entry.active {
+ background: rgba(var(--page-updater-rgb), .16);
+ border-color: rgba(var(--page-updater-rgb), .45);
+ color: #fff;
+}
+.sidebar-overview-entry .ov-entry-icon { width: 18px; height: 18px; flex: 0 0 auto; }
+
+/* ---- header ------------------------------------------------------------- */
+.overview-header {
+ display: flex;
+ align-items: flex-end;
+ justify-content: space-between;
+ gap: 16px;
+ padding: 20px 24px 12px;
+ flex-wrap: wrap;
+}
+.overview-header h2 { margin: 0 0 2px; font-size: 1.5rem; }
+.overview-header p { margin: 0; color: var(--text-muted, rgba(255, 255, 255, .65)); font-size: .9rem; }
+.overview-tabbed { padding: 0 24px 24px; }
+
+/* ---- Updates: filter chips + toolbar ------------------------------------ */
+.ov-toolbar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ flex-wrap: wrap;
+}
+.ov-chips { display: flex; gap: 8px; flex-wrap: wrap; }
+.ov-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 6px 12px;
+ border-radius: 999px;
+ border: 1px solid var(--border-color, rgba(255, 255, 255, .14));
+ background: transparent;
+ color: var(--text-color, #e8edf4);
+ font-size: .82rem;
+ cursor: pointer;
+ transition: background .15s ease, border-color .15s ease;
+}
+.ov-chip:hover { background: rgba(255, 255, 255, .06); }
+.ov-chip.active {
+ background: rgba(var(--page-updater-rgb), .18);
+ border-color: rgba(var(--page-updater-rgb), .5);
+ color: #fff;
+}
+.ov-chip-n {
+ font-variant-numeric: tabular-nums;
+ font-size: .72rem;
+ opacity: .8;
+ background: rgba(255, 255, 255, .08);
+ border-radius: 999px;
+ padding: 0 6px;
+}
+.ov-toolbar-actions { display: flex; gap: 8px; }
+
+/* ---- Updates: expandable rows ------------------------------------------- */
+.ov-row { padding: 0; }
+.ov-row-head {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ width: 100%;
+ cursor: pointer;
+ padding: 12px 14px;
+}
+.ov-row-head .updater-row-ver { margin-left: auto; }
+.ov-row-head .ov-row-actions { margin-left: 12px; }
+.ov-chevron {
+ flex: 0 0 auto;
+ display: inline-block;
+ font-size: .8rem;
+ opacity: .7;
+ transition: transform .18s ease;
+}
+.ov-row-head[aria-expanded="true"] .ov-chevron { transform: rotate(90deg); }
+.ov-row-head:focus-visible { outline: 2px solid rgba(var(--page-updater-rgb), .7); outline-offset: -2px; }
+
+.ov-row-details { display: none; padding: 4px 16px 16px; border-top: 1px solid var(--border-color, rgba(255, 255, 255, .1)); }
+.ov-row-details.ov-open { display: block; }
+
+.updater-detail { display: flex; flex-direction: column; gap: 14px; padding-top: 12px; }
+.updater-detail-section h4 {
+ margin: 0 0 8px;
+ font-size: .8rem;
+ text-transform: uppercase;
+ letter-spacing: .04em;
+ color: var(--text-muted, rgba(255, 255, 255, .6));
+}
+.updater-detail-row {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 6px 0;
+ flex-wrap: wrap;
+}
+.updater-detail-row .updater-btn { margin-left: auto; }
+.updater-detail-meta { color: var(--text-muted, rgba(255, 255, 255, .6)); font-size: .85rem; }
+.updater-detail-empty { color: var(--text-muted, rgba(255, 255, 255, .6)); margin: 0; }
+
+/* ---- Backups tab -------------------------------------------------------- */
+.ov-backup-summary { font-weight: 600; }
+.ov-backup-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
+ gap: 12px;
+ margin-top: 14px;
+}
+.ov-backup-tile {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 12px 14px;
+ border-radius: 10px;
+ border: 1px solid var(--border-color, rgba(255, 255, 255, .12));
+ background: var(--input-bg, rgba(255, 255, 255, .03));
+}
+.ov-backup-tile[role="button"] { cursor: pointer; transition: border-color .15s ease, background .15s ease; }
+.ov-backup-tile[role="button"]:hover { border-color: rgba(var(--page-backups-rgb), .5); background: rgba(var(--page-backups-rgb), .08); }
+.ov-backup-dot { width: 9px; height: 9px; border-radius: 50%; flex: 0 0 auto; }
+.ov-backup-dot.ok { background: var(--page-verify, #1fb88a); }
+.ov-backup-dot.warn { background: var(--page-system, #f0883e); }
+.ov-backup-name { font-weight: 600; }
+.ov-backup-time { margin-left: auto; color: var(--text-muted, rgba(255, 255, 255, .6)); font-size: .82rem; }
+
+.ov-loc-list { margin-top: 20px; }
+.ov-loc-list h4 {
+ margin: 0 0 8px;
+ font-size: .8rem;
+ text-transform: uppercase;
+ letter-spacing: .04em;
+ color: var(--text-muted, rgba(255, 255, 255, .6));
+}
+.ov-loc-row {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 8px 0;
+ border-bottom: 1px solid var(--border-color, rgba(255, 255, 255, .08));
+}
+.ov-loc-row span:first-child { font-weight: 500; }
diff --git a/containers/libreportal/frontend/components/apps/overview/js/overview-manager.js b/containers/libreportal/frontend/components/apps/overview/js/overview-manager.js
new file mode 100644
index 0000000..e9ef486
--- /dev/null
+++ b/containers/libreportal/frontend/components/apps/overview/js/overview-manager.js
@@ -0,0 +1,332 @@
+// components/apps/overview/js/overview-manager.js — the fleet "Overview" area.
+//
+// One place for a whole-fleet picture: Overview (combined health), Updates
+// (per-app expander table + filter chips), Improvements (signed hotfixes), and
+// Backups (per-app backup health). It lives inside the apps feature so it shares
+// the persistent apps sidebar — selecting "Overview" in the sidebar renders this
+// in the main pane, exactly like selecting an app renders the per-app tabs.
+//
+// Reuse, not duplication: a *headless* UpdaterPage supplies all update/CVE/
+// improvement data + row rendering (its renderX() methods are pure HTML-string
+// producers), and we route its action buttons straight to its methods. Backup
+// health is read directly from the same generated JSON the backup page uses.
+// System config edits are never inline here — they bounce to Admin / the per-app
+// tabs (operate/view here; change-the-system there).
+class OverviewManager {
+ constructor(services) {
+ this.services = services || (window.LP && window.LP.services) || {};
+ this.updater = null; // headless UpdaterPage (data + renderers)
+ 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.current = null;
+ }
+
+ // ---- lifecycle -----------------------------------------------------------
+
+ async initialize() {
+ const root = document.getElementById('overview-view');
+ if (!root) return;
+ if (typeof UpdaterPage !== 'undefined' && !this.updater) {
+ this.updater = new UpdaterPage(this.services);
+ }
+ this.bindEvents(root);
+ this.tabs = new TabController(root);
+ const initial = this.parseTabFromUrl() || 'overview';
+ await this.refreshAll();
+ this._applyTab(initial); // active + header + render, no history push
+ }
+
+ bindEvents(root) {
+ // One delegated click listener per fresh root. The apps layout fragment
+ // persists across /apps,/app,/overview, so the same root is reused — the
+ // dataset guard stops us stacking a second listener on revisit; a fresh
+ // fragment (after leaving the apps feature) gets a new root + new listener
+ // while the old one is GC'd with its element. The closure binds the
+ // singleton `this`, so it never goes stale.
+ if (root.dataset.ovBound !== '1') {
+ root.dataset.ovBound = '1';
+ root.addEventListener('click', (e) => this._handleClick(e));
+ }
+ // Repaint when a relevant task finishes — debounced via the shared
+ // coordinator, idempotent by id (re-registering replaces), self-guarded
+ // against a torn-down view. Called on every initialize() so it re-arms after
+ // unmount() unregistered it on the way out of the apps feature.
+ const tr = this.services.tasks && this.services.tasks.refresh;
+ if (tr && tr.register) {
+ tr.register({
+ id: 'overview',
+ match: (d) => /^(updater_|artifact_|backup|restore|libreportal\s+(updater|artifact|backup|restore))/
+ .test((d && (d.action || (d.task && d.task.command))) || ''),
+ run: () => {
+ if (window.overviewManager === this && document.getElementById('overview-view')) {
+ return this.refreshAll().then(() => this._applyTab(this.current || 'overview'));
+ }
+ },
+ debounceMs: 800,
+ });
+ }
+ }
+
+ async refreshAll() {
+ const jobs = [];
+ if (this.updater) jobs.push(this.updater.refreshAll());
+ jobs.push(
+ fetch('/data/backup/generated/dashboard.json', { cache: 'no-store' })
+ .then((r) => (r.ok ? r.json() : null))
+ .catch(() => null)
+ .then((d) => { this.backup = d; })
+ );
+ await Promise.all(jobs);
+ }
+
+ parseTabFromUrl() {
+ const allowed = new Set(['overview', 'updates', 'improvements', 'backups']);
+ const seg = window.location.pathname.replace(/^\/overview\/?/, '').split('/')[0];
+ return (seg && allowed.has(seg)) ? seg : null;
+ }
+
+ // ---- tab switching -------------------------------------------------------
+
+ // Called by the tab buttons' inline onclick AND by in-page "goto" buttons.
+ switchTab(id) {
+ if (!id || id === this.current) return;
+ this._applyTab(id);
+ const url = id === 'overview' ? '/overview' : `/overview/${id}`;
+ window.history.pushState({ route: url }, '', url);
+ }
+
+ _applyTab(id) {
+ if (this.tabs) this.tabs.switch(id);
+ this.updateHeader(id);
+ this.renderTab(id);
+ this.current = id;
+ }
+
+ updateHeader(id) {
+ const titles = {
+ overview: ['Overview', 'Updates, improvements, and backups across all your apps.'],
+ updates: ['Updates', 'Available versions per app — expand a row for CVEs, recovery, and history. Every update is snapshotted first.'],
+ improvements: ['Improvements', 'Signed, individually-reversible hotfixes from the LibrePortal team — applied with a snapshot first.'],
+ backups: ['Backups', 'Backup health across your apps. Open an app for snapshots and restore.'],
+ };
+ const t = titles[id] || titles.overview;
+ const titleEl = document.getElementById('overview-title');
+ const subEl = document.getElementById('overview-subtitle');
+ if (titleEl) titleEl.textContent = t[0];
+ if (subEl) subEl.textContent = t[1];
+ }
+
+ renderTab(id) {
+ const pane = document.querySelector(`#overview-view .tab-pane[data-tab="${id}"]`);
+ if (!pane) return;
+ switch (id) {
+ case 'overview': pane.innerHTML = this.renderOverview(); break;
+ case 'updates': pane.innerHTML = this.renderUpdates(); break;
+ case 'improvements': pane.innerHTML = this.renderImprovements(); break;
+ case 'backups': pane.innerHTML = this.renderBackups(); break;
+ }
+ }
+
+ // ---- clicks --------------------------------------------------------------
+
+ _handleClick(e) {
+ // Updater actions take precedence over the row-toggle they sit inside, so a
+ // click on "Update" never also expands the row.
+ const ua = e.target.closest('[data-updater-action]');
+ if (ua && this.updater) {
+ const app = ua.dataset.app || null;
+ switch (ua.dataset.updaterAction) {
+ case 'check': this.updater.checkForUpdates(); break;
+ case 'update': this.updater.applyUpdate(app); break;
+ case 'update-all': this.updater.applyAll(); break;
+ case 'rollback': this.updater.rollback(app); break;
+ case 'apply-artifact': this.updater.applyArtifact(ua.dataset.id); break;
+ case 'revert-artifact': this.updater.revertArtifact(ua.dataset.id); break;
+ }
+ return;
+ }
+ const oa = e.target.closest('[data-overview-action]');
+ if (oa) {
+ switch (oa.dataset.overviewAction) {
+ case 'goto': this.switchTab(oa.dataset.tab); break;
+ case 'filter': this.filter = oa.dataset.filter || 'all'; this.renderTab('updates'); break;
+ case 'toggle': this.toggleAppDetails(oa.dataset.app); break;
+ case 'open-app': window.navigateToRoute && window.navigateToRoute(`/app/${oa.dataset.app}/backups`); break;
+ case 'open-backup': window.navigateToRoute && window.navigateToRoute('/backup'); break;
+ }
+ }
+ }
+
+ // ---- Overview tab --------------------------------------------------------
+
+ renderOverview() {
+ const u = this.updater
+ ? this.updater.counts()
+ : { updatesAvailable: 0, totalCves: 0, cveTotals: {}, improvements: 0, 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 || ''}
+
`;
+ return `
+
+ ${card('updates', u.updatesAvailable, 'Updates available', u.updatesAvailable ? 'across your apps' : "you're current",
+ `Review `)}
+ ${card('verify', u.totalCves, 'Known CVEs',
+ (sev.critical || sev.high) ? `${sev.critical || 0} critical · ${sev.high || 0} high` : 'no high-severity issues',
+ `View `)}
+ ${card('updater', u.improvements, 'Improvements', u.improvements ? 'signed hotfixes to apply' : 'nothing pending',
+ `View `)}
+ ${card('backups', b.protectedLabel, 'Backups protected', b.sub,
+ `Backups `)}
+ ${card('system', u.apps, 'Apps tracked', `last scan: ${checked}`,
+ `Check now `)}
+
+ ${(this.updater && this.updater.updates) ? '' : `No scan data yet — run Check now to fetch versions, CVEs & improvements.
`}`;
+ }
+
+ backupSummary() {
+ const d = this.backup;
+ if (!d) return { protectedLabel: '—', sub: 'no backup data', stale: 0 };
+ 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;
+ 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';
+ return {
+ protectedLabel: `${protectedCount}/${apps.length || 0}`,
+ sub: stale ? `${stale} need attention · last ${latest}` : `all current · last ${latest}`,
+ stale,
+ };
+ }
+
+ // ---- Updates tab (expander table + filter chips) -------------------------
+
+ renderUpdates() {
+ const up = this.updater;
+ if (!up || !up.apps.length) return `No installed apps to track.
`;
+ const shown = this.filterApps(up.apps);
+ const anyUpdate = up.apps.some((a) => a.update_available);
+ const nUpd = up.apps.filter((a) => a.update_available).length;
+ const nSec = up.apps.filter((a) => (a.cves || []).length).length;
+ const chip = (id, label, n) =>
+ `${label}${n != null ? ` ${n} ` : ''} `;
+ const rows = shown.map((a) => this.updateRow(a)).join('') || `Nothing matches this filter.
`;
+ return `
+
+ ${rows}
`;
+ }
+
+ filterApps(apps) {
+ if (this.filter === 'updates') return apps.filter((a) => a.update_available);
+ if (this.filter === 'security') return apps.filter((a) => (a.cves || []).length);
+ return apps;
+ }
+
+ updateRow(a) {
+ const esc = (s) => this.escape(s);
+ const cur = esc(a.current_version || a.current_image || '—');
+ const avail = a.update_available ? esc(a.available_version || a.available_image || 'newer') : null;
+ const badge = a.update_available
+ ? `update `
+ : (a.scanned ? `up to date ` : `unscanned `);
+ const sev = a.worstSeverity ? `${a.worstSeverity} ` : '';
+ const updBtn = a.update_available
+ ? `Update `
+ : '';
+ return `
+
+ ▸
+ ${esc(a.displayName)} ${badge} ${sev}
+ ${cur}${avail ? ` → ${avail} ` : ''}
+ ${updBtn}
+
+
+
`;
+ }
+
+ toggleAppDetails(app) {
+ if (!app) return;
+ const root = document.getElementById('overview-view');
+ const details = document.getElementById(`ov-detail-${app}`);
+ const head = root && root.querySelector(`.ov-row[data-app="${(window.CSS && CSS.escape) ? CSS.escape(app) : app}"] .ov-row-head`);
+ if (!details) return;
+ const isOpen = !details.hidden;
+ if (isOpen) {
+ details.hidden = true;
+ details.classList.remove('ov-open');
+ if (head) { head.setAttribute('aria-expanded', 'false'); head.classList.remove('ov-open'); }
+ } else {
+ if (!details.dataset.filled && this.updater) {
+ const appObj = (this.updater.apps || []).find((x) => x.name === app);
+ details.innerHTML = appObj ? this.updater.renderAppDetail(appObj) : '';
+ details.dataset.filled = '1';
+ }
+ details.hidden = false;
+ details.classList.add('ov-open');
+ if (head) { head.setAttribute('aria-expanded', 'true'); head.classList.add('ov-open'); }
+ }
+ }
+
+ // ---- Improvements tab (reuse the updater's hotfix renderer) ---------------
+
+ renderImprovements() {
+ return this.updater ? this.updater.renderImprovements() : `Improvements unavailable.
`;
+ }
+
+ // ---- Backups tab (fleet health glance; actions deep-link per app) ---------
+
+ renderBackups() {
+ const d = this.backup;
+ if (!d) return `No backup data yet. Open backup center
`;
+ const apps = Array.isArray(d.apps) ? d.apps : [];
+ const sys = d.system || {};
+ const locs = Array.isArray(d.locations) ? d.locations : [];
+ const esc = (s) => this.escape(s);
+ const rel = (t) => (t && this.updater) ? this.updater.fmtRel(t) : 'never';
+ const tile = (name, snap, time, app) => `
+
+
+ ${esc(name)}
+ ${snap ? `backed up ${rel(time)}` : 'no backup yet'}
+
`;
+ const sysTile = tile('System', sys.latest_snapshot, sys.latest_time, false);
+ const tiles = sysTile + apps.map((a) => tile(a.app, a.latest_snapshot, a.latest_time, true)).join('');
+ const locRows = locs.map((l) =>
+ `${esc(l.name)} ${esc(l.type || '')}
`).join('');
+ const protectedCount = apps.filter((a) => a.latest_snapshot).length;
+ return `
+
+ ${tiles}
+ ${locRows ? `
Locations ${locRows}` : ''}`;
+ }
+
+ // ---- utils ---------------------------------------------------------------
+
+ escape(s) {
+ return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
+ }
+}
+
+window.OverviewManager = OverviewManager;
diff --git a/containers/libreportal/frontend/components/manifest.dev.json b/containers/libreportal/frontend/components/manifest.dev.json
index 408fe14..bc3283a 100644
--- a/containers/libreportal/frontend/components/manifest.dev.json
+++ b/containers/libreportal/frontend/components/manifest.dev.json
@@ -23,7 +23,9 @@
"/apps",
"/apps*",
"/app",
- "/app*"
+ "/app*",
+ "/overview",
+ "/overview*"
],
"module": "/components/apps/index.js",
"handler": "handleApps",
diff --git a/containers/libreportal/frontend/components/updater/js/updater-page.js b/containers/libreportal/frontend/components/updater/js/updater-page.js
index 55dfe4f..4fef375 100644
--- a/containers/libreportal/frontend/components/updater/js/updater-page.js
+++ b/containers/libreportal/frontend/components/updater/js/updater-page.js
@@ -381,6 +381,42 @@ class UpdaterPage {
return `${rows}
`;
}
+ // Per-app detail body — composes one app's security (CVEs), recovery point,
+ // and recent history. Pure HTML string with no DOM assumptions, so it is reused
+ // verbatim as the fleet Updates expander body (overview-manager.js) AND the
+ // per-app Updater tab. Action buttons keep the data-updater-action/data-app
+ // contract, so whichever delegated handler is in scope drives them.
+ renderAppDetail(app) {
+ const a = app || {};
+ const cves = a.cves || [];
+ const cveItems = cves.map((c) => `
+
+
${this.escape((c.severity || '').toUpperCase())}
+
${this.escape(c.id || 'CVE')}
+
${this.escape(c.package || '')}
+ ${c.fixed_in ? `
fixed in ${this.escape(c.fixed_in)} ` : ''}
+
`).join('');
+ const security = `Security ${
+ cves.length ? cveItems : '
No known CVEs. 🎉
'}
`;
+
+ const can = !!a.last_snapshot;
+ const snap = can
+ ? `${this.escape(a.last_snapshot_version || '')} · ${this.fmtRel(a.last_snapshot_at)}`
+ : 'A recovery snapshot is taken automatically before the next update.';
+ const recovery = `Recovery
+
${can ? 'recoverable' : 'protected'}
+ ${snap}
+ ${can ? `Roll back ` : ''}
`;
+
+ const entries = ((this.history && this.history.entries) || []).filter((e) => e.app === a.name).slice(0, 8);
+ const history = entries.length ? `History ${entries.map((e) => `
+
${this.escape(e.action)}${e.result ? ' · ' + this.escape(e.result) : ''}
+ ${this.escape(e.from || '')}${e.to ? ` → ${this.escape(e.to)}` : ''}
+ ${this.fmtRel(e.ts)}
`).join('')}
` : '';
+
+ return `${security}${recovery}${history}
`;
+ }
+
empty(msg, withCheck) {
return `${this.escape(msg)}${withCheck ? `
Check now
` : ''}
`;
}
diff --git a/containers/libreportal/frontend/core/ui-state/js/tab-controller.js b/containers/libreportal/frontend/core/ui-state/js/tab-controller.js
new file mode 100644
index 0000000..bb12918
--- /dev/null
+++ b/containers/libreportal/frontend/core/ui-state/js/tab-controller.js
@@ -0,0 +1,28 @@
+// core/ui-state/js/tab-controller.js — generic, root-scoped top-tab host.
+//
+// Owns ONLY the show/hide contract: toggle `.active` on `.main-tab-button[data-tab]`
+// and `.tab-pane[data-tab]` within a single root element. It has no app/feature
+// knowledge and binds no listeners of its own — consumers wire their own triggers
+// (inline onclick or a delegated listener) and call switch(). Scoping every query
+// to `root` lets several hosts coexist on one page (e.g. the per-app detail tabs
+// and the fleet Overview tabs) without cross-talk, and matching panes by a
+// data-tab attribute (not id) avoids duplicate-id collisions between hosts.
+class TabController {
+ constructor(root) {
+ this.root = typeof root === 'string' ? document.querySelector(root) : root;
+ this.current = null;
+ }
+
+ switch(tabId) {
+ if (!this.root || !tabId) return;
+ this.root.querySelectorAll('.main-tab-button[data-tab]').forEach((b) => {
+ b.classList.toggle('active', b.dataset.tab === tabId);
+ });
+ this.root.querySelectorAll('.tab-pane[data-tab]').forEach((p) => {
+ p.classList.toggle('active', p.dataset.tab === tabId);
+ });
+ this.current = tabId;
+ }
+}
+
+window.TabController = TabController;
diff --git a/containers/libreportal/frontend/index.html b/containers/libreportal/frontend/index.html
index 5f39bd1..b06b509 100755
--- a/containers/libreportal/frontend/index.html
+++ b/containers/libreportal/frontend/index.html
@@ -31,6 +31,7 @@
+