diff --git a/containers/libreportal/frontend/features/manifest.dev.json b/containers/libreportal/frontend/features/manifest.dev.json index 2c6cf35..c61da5a 100644 --- a/containers/libreportal/frontend/features/manifest.dev.json +++ b/containers/libreportal/frontend/features/manifest.dev.json @@ -47,6 +47,13 @@ "navId": "nav-tasks", "nav": { "label": "Tasks", "order": 60 } }, + { + "id": "updater", + "routes": ["/updater", "/updater*"], + "module": "/features/updater/index.js", + "navId": "nav-updater", + "nav": { "label": "Updates", "order": 30 } + }, { "id": "backup", "routes": ["/backup", "/backup*"], diff --git a/containers/libreportal/frontend/features/updater/feature.json b/containers/libreportal/frontend/features/updater/feature.json new file mode 100644 index 0000000..c37d28d --- /dev/null +++ b/containers/libreportal/frontend/features/updater/feature.json @@ -0,0 +1,9 @@ +{ + "id": "updater", + "routes": ["/updater", "/updater*"], + "module": "/features/updater/index.js", + "navId": "nav-updater", + "nav": { "label": "Updates", "order": 30 }, + "order": 30, + "note": "App Updater — per-app version tracking, CVE/security scanning, and disaster-recovery (snapshot-before-update + rollback). New feature (no legacy handler); routed via its module. Actions go through libreportal updater tasks." +} diff --git a/containers/libreportal/frontend/features/updater/index.js b/containers/libreportal/frontend/features/updater/index.js new file mode 100644 index 0000000..44d8ce2 --- /dev/null +++ b/containers/libreportal/frontend/features/updater/index.js @@ -0,0 +1,32 @@ +// features/updater/index.js — App Updater feature module. +// +// Per-app version tracking + CVE/security scanning + disaster-recovery +// (snapshot-before-update and rollback). Built in the same shape as the backup +// feature: a self-registering module whose mount() lazy-loads the controller, +// renders a fragment, and news the page object; unmount() releases its +// task-refresh registration. All state-changing actions go through the task +// system (libreportal updater …), never a new mutating API. +LP.features.register({ + id: 'updater', + routes: ['/updater', '/updater*'], + scripts: ['/features/updater/updater-page.js'], + + async mount(ctx) { + await ctx.loadScripts(this.scripts); + const html = await ctx.loadFragment('/html/updater-content.html'); + ctx.setContent(html, 'Updates'); + if (typeof UpdaterPage === 'undefined') { + throw new Error('UpdaterPage controller failed to load'); + } + window.updaterPage = new UpdaterPage(ctx.services); + await window.updaterPage.init(); + }, + + async unmount(ctx) { + // Drop the task-refresh registration so a finished update/rollback task + // doesn't repaint a torn-down page. The page self-guards via + // (window.updaterPage === this); nulling it neutralises any pending work. + try { ctx.services.tasks.refresh && ctx.services.tasks.refresh.unregister('updater'); } catch (_) {} + window.updaterPage = null; + }, +}); diff --git a/containers/libreportal/frontend/features/updater/updater-page.js b/containers/libreportal/frontend/features/updater/updater-page.js new file mode 100644 index 0000000..9141750 --- /dev/null +++ b/containers/libreportal/frontend/features/updater/updater-page.js @@ -0,0 +1,348 @@ +// features/updater/updater-page.js — App Updater controller. +// +// Surfaces, per installed app: version state (current -> available), security +// posture (CVEs by severity), and disaster-recovery readiness (a snapshot is +// taken before every update so any update is reversible). Read-only data comes +// from /data/updater/generated/*.json (written host-side by the updater +// generator); every action is dispatched through the task system via +// services.tasks.route(...) — the same locked-down mutation path apps/backups +// use. No mutating API is added. +class UpdaterPage { + constructor(services) { + this.services = services || (window.LP && window.LP.services) || {}; + this.currentTab = 'overview'; + this.updates = null; // { generated_at, apps: [...] } + this.cves = null; // { generated_at, apps: [...], totals: {...} } + this.history = null; // { entries: [...] } + this.apps = []; // merged per-app view rendered in the table + this._pushedAnyTab = false; + this._eventBound = false; + } + + // ---- lifecycle ----------------------------------------------------------- + + async init() { + this.currentTab = this.parseTabFromUrl() || this.currentTab; + this.applyActiveTabUi(this.currentTab); + this.bindEvents(); + await this.refreshAll(); + this.render(); + this.updateHeader(); + } + + parseTabFromUrl() { + const allowed = new Set(['overview', 'updates', 'security', 'recovery', 'history']); + const seg = window.location.pathname.replace(/^\/updater\/?/, '').split('/')[0]; + if (seg && allowed.has(seg)) return seg; + return null; + } + + bindEvents() { + if (this._eventBound) return; + this._eventBound = true; + + // Repaint when an updater/backup task completes (debounced via the + // coordinator). Self-guards against a torn-down page. + this.services.tasks && this.services.tasks.refresh && this.services.tasks.refresh.register({ + id: 'updater', + match: (d) => /^(updater_|libreportal\s+updater)/.test((d && (d.action || (d.task && d.task.command))) || ''), + run: () => { + if (window.updaterPage === this && document.getElementById('updater-page')) { + return this.refreshAll().then(() => this.render()); + } + }, + debounceMs: 800, + }); + + // Delegated click handling for the whole feature root. + const root = document.getElementById('updater-page'); + if (!root) return; + root.addEventListener('click', (e) => { + const tabBtn = e.target.closest('.updater-layout .sidebar .category[data-updater-tab]'); + if (tabBtn) { this.switchTab(tabBtn.dataset.updaterTab); return; } + + const action = e.target.closest('[data-updater-action]'); + if (!action) return; + const app = action.dataset.app || null; + switch (action.dataset.updaterAction) { + case 'check': this.checkForUpdates(); break; + case 'update': this.applyUpdate(app); break; + case 'update-all': this.applyAll(); break; + case 'rollback': this.rollback(app); break; + case 'goto': this.switchTab(action.dataset.tab); break; + } + }); + } + + applyActiveTabUi(tab) { + document.querySelectorAll('.updater-layout .sidebar .category[data-updater-tab]').forEach(b => { + b.classList.toggle('active', b.dataset.updaterTab === tab); + }); + document.querySelectorAll('.updater-tabpanel').forEach(p => { + p.classList.toggle('active', p.id === `updater-panel-${tab}`); + }); + } + + switchTab(tab) { + if (!tab || tab === this.currentTab) return; + this.currentTab = tab; + this.applyActiveTabUi(tab); + this.updateHeader(); + this.render(); + const url = `/updater/${tab}`; + if (!this._pushedAnyTab) { window.history.replaceState({ route: url }, '', url); this._pushedAnyTab = true; } + else { window.history.pushState({ route: url }, '', url); } + } + + // ---- data ---------------------------------------------------------------- + + async refreshAll() { + const get = (url) => fetch(url, { cache: 'no-store' }).then(r => r.ok ? r.json() : null).catch(() => null); + const [u, c, h] = await Promise.all([ + get('/data/updater/generated/updates.json'), + get('/data/updater/generated/cves.json'), + get('/data/updater/generated/history.json'), + ]); + this.updates = u; this.cves = c; this.history = h; + this.apps = this.mergeApps(); + } + + // Build the per-app view. Prefer the generator's updates.json; otherwise fall + // back to the installed-apps list (window.apps / DataLoader) so the page is + // still useful before the first scan ("status unknown — run a check"). + mergeApps() { + const cveByApp = {}; + if (this.cves && Array.isArray(this.cves.apps)) { + for (const a of this.cves.apps) cveByApp[a.name] = a.cves || []; + } + let base = (this.updates && Array.isArray(this.updates.apps)) ? this.updates.apps : null; + if (!base) { + const installed = (window.apps || []).filter(a => a && (a.status === 1 || a.installed || a.is_installed)); + base = installed.map(a => ({ + name: a.name || a.app_name, + displayName: a.displayName || a.title || a.name || a.app_name, + current_version: a.version || null, + available_version: null, + update_available: false, + scanned: false, + })); + } + return base.map(a => { + const cves = cveByApp[a.name] || []; + const sev = this.worstSeverity(cves); + return Object.assign({ displayName: a.displayName || a.name, scanned: a.scanned !== false }, a, { cves, worstSeverity: sev }); + }); + } + + worstSeverity(cves) { + const order = ['critical', 'high', 'medium', 'low']; + for (const s of order) if (cves.some(c => (c.severity || '').toLowerCase() === s)) return s; + return null; + } + + // ---- derived counts ------------------------------------------------------ + + counts() { + const updatesAvailable = this.apps.filter(a => a.update_available).length; + const cveTotals = (this.cves && this.cves.totals) || this.tallyCves(); + const totalCves = (cveTotals.critical || 0) + (cveTotals.high || 0) + (cveTotals.medium || 0) + (cveTotals.low || 0); + const drReady = this.apps.filter(a => a.dr_ready !== false).length; // snapshot-before-update is on by default + return { apps: this.apps.length, updatesAvailable, cveTotals, totalCves, drReady, lastChecked: this.updates && this.updates.generated_at }; + } + + tallyCves() { + const t = { critical: 0, high: 0, medium: 0, low: 0 }; + for (const a of this.apps) for (const c of (a.cves || [])) { + const s = (c.severity || '').toLowerCase(); if (t[s] != null) t[s]++; + } + return t; + } + + // ---- actions (all via the task system) ----------------------------------- + + checkForUpdates() { + this.dispatch('updater_check', {}, 'Checking apps for updates & vulnerabilities…'); + } + applyUpdate(app) { + if (!app) return; + this.dispatch('updater_apply', { app }, `Updating ${app} (a recovery snapshot is taken first)…`); + } + applyAll() { + const list = this.apps.filter(a => a.update_available).map(a => a.name); + if (!list.length) { this.toast('Everything is up to date.', 'info'); return; } + this.dispatch('updater_apply_all', { apps: list.join(',') }, `Updating ${list.length} app(s) — each is snapshotted first…`); + } + rollback(app) { + if (!app) return; + this.dispatch('updater_rollback', { app }, `Rolling ${app} back to its pre-update snapshot…`); + } + + dispatch(action, params, note) { + const route = this.services.tasks && this.services.tasks.route; + if (route && typeof route.routeAction === 'function') { + route.routeAction(action, params || {}); + this.toast(note || 'Working…', 'info'); + } else if (typeof route === 'function') { + route(action, params || {}); + this.toast(note || 'Working…', 'info'); + } else { + this.toast('Task system not ready — try again in a moment.', 'error'); + } + } + + toast(msg, type) { + const n = this.services.notify; + if (n && typeof n.show === 'function') n.show(msg, type || 'info'); + } + + // ---- rendering ----------------------------------------------------------- + + updateHeader() { + const titles = { + overview: ['Overview', 'Update health, security posture, and recovery readiness at a glance.'], + updates: ['Updates', 'Available versions per app. Every update is snapshotted first, so it is reversible.'], + security: ['Security', 'Known vulnerabilities (CVEs) in your installed app images, by severity.'], + recovery: ['Disaster Recovery', 'Pre-update snapshots and rollback points — undo any update.'], + history: ['History', 'A log of update and rollback activity.'], + }; + const t = titles[this.currentTab] || titles.overview; + const titleEl = document.getElementById('updater-section-title'); + const subEl = document.getElementById('updater-section-subtitle'); + if (titleEl) titleEl.textContent = t[0]; + if (subEl) subEl.textContent = t[1]; + } + + render() { + const panel = document.getElementById(`updater-panel-${this.currentTab}`); + if (!panel) return; + switch (this.currentTab) { + case 'overview': panel.innerHTML = this.renderOverview(); break; + case 'updates': panel.innerHTML = this.renderUpdates(); break; + case 'security': panel.innerHTML = this.renderSecurity(); break; + case 'recovery': panel.innerHTML = this.renderRecovery(); break; + case 'history': panel.innerHTML = this.renderHistory(); break; + } + } + + renderOverview() { + const c = this.counts(); + const sev = c.cveTotals; + const checked = c.lastChecked ? this.fmtRel(c.lastChecked) : 'never'; + const card = (hue, big, label, sub, action) => ` +