// components/updater/js/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 on the whole layout (sidebar is a sibling of the // page card, both inside .updater-layout). The element is replaced on // navigation, so the listener is GC'd with it — no cross-page leak. const root = document.querySelector('.updater-layout'); if (!root) return; root.addEventListener('click', (e) => { const tabBtn = e.target.closest('.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]; // Fill the shared page-header icon slot once (update-cycle glyph). const iconEl = document.getElementById('updater-page-header-icon'); if (iconEl && !iconEl.firstChild) { iconEl.innerHTML = ''; } } 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) => `
${big}
${label}
${sub ? `
${sub}
` : ''} ${action || ''}
`; return `
${card('updates', c.updatesAvailable, 'Updates available', c.updatesAvailable ? 'across your apps' : "you're current", ``)} ${card('verify', c.totalCves, 'Known CVEs', sev.critical || sev.high ? `${sev.critical || 0} critical · ${sev.high || 0} high` : 'no high-severity issues', ``)} ${card('backups', `${c.drReady}/${c.apps}`, 'Recovery-ready', 'snapshot taken before each update', ``)} ${card('system', c.apps, 'Apps tracked', `last scan: ${checked}`, ``)}
${this.updates ? '' : `
No scan data yet — showing your installed apps. Run Check now to fetch versions & vulnerabilities.
`}`; } renderUpdates() { if (!this.apps.length) return this.empty('No installed apps to track.'); const rows = this.apps.map(a => { const cur = this.escape(a.current_version || a.current_image || '—'); const avail = a.update_available ? this.escape(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 btn = a.update_available ? `` : ''; return `
${this.escape(a.displayName)} ${badge} ${sev}
${cur}${avail ? ` ${avail}` : ''}
${btn}
`; }).join(''); const anyUpdate = this.apps.some(a => a.update_available); return `
${anyUpdate ? `` : ''}
${rows}
`; } renderSecurity() { const withCves = this.apps.filter(a => (a.cves || []).length); if (!this.cves) return this.empty('No vulnerability scan yet. Run a check to scan your app images for known CVEs.', true); if (!withCves.length) return this.empty('No known vulnerabilities in your installed apps. 🎉'); const blocks = withCves.map(a => { const items = (a.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(''); return `
${this.escape(a.displayName)} ${(a.cves || []).length}
${items}
`; }).join(''); return `
${blocks}
`; } renderRecovery() { const rows = this.apps.map(a => { const snap = a.last_snapshot ? `${this.escape(a.last_snapshot_version || '')} · ${this.fmtRel(a.last_snapshot_at)}` : 'will be created on next update'; const can = !!a.last_snapshot; return `
${this.escape(a.displayName)} ${can ? 'recoverable' : 'protected'}
${snap}
${can ? `` : ''}
`; }).join(''); return `
Disaster recovery is automatic: before any app update, LibrePortal snapshots that app (via the Backup engine) so the update can be rolled back. Apps below show their latest rollback point.
${rows}
`; } renderHistory() { const entries = (this.history && this.history.entries) || []; if (!entries.length) return this.empty('No update activity yet.'); const rows = entries.map(e => `
${this.escape(e.app)} ${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 `
${rows}
`; } empty(msg, withCheck) { return `
${this.escape(msg)}${withCheck ? `
` : ''}
`; } // ---- utils --------------------------------------------------------------- escape(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); } fmtRel(ts) { if (!ts) return 'never'; const t = typeof ts === 'number' ? ts : Date.parse(ts); if (!t || isNaN(t)) return this.escape(String(ts)); const s = Math.max(0, (Date.now() - t) / 1000); if (s < 60) return 'just now'; if (s < 3600) return `${Math.floor(s / 60)}m ago`; if (s < 86400) return `${Math.floor(s / 3600)}h ago`; return `${Math.floor(s / 86400)}d ago`; } } window.UpdaterPage = UpdaterPage;