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, and backups across all your apps.

+
+ +
+ +
+
+ + + + +
+ +
+
+
+
+
+
+
+
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", + ``)} + ${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}`, + ``)} +
+ ${(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) => + ``; + const rows = shown.map((a) => this.updateRow(a)).join('') || `
Nothing matches this filter.
`; + return ` +
+
${chip('all', 'All', up.apps.length)}${chip('updates', 'Updates', nUpd)}${chip('security', 'Security', nSec)}
+
+ + ${anyUpdate ? `` : ''} +
+
+
${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 + ? `` + : ''; + return `
+ + +
`; + } + + 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.
`; + 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 ` +
+
${protectedCount}/${apps.length} apps protected
+
+ +
+
+
${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 ? `` : ''}
`; + + 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 ? `
` : ''}
`; } 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 @@ +