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) => ` +
+
${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; diff --git a/containers/libreportal/frontend/features/updater/updater.css b/containers/libreportal/frontend/features/updater/updater.css new file mode 100644 index 0000000..cf198f1 --- /dev/null +++ b/containers/libreportal/frontend/features/updater/updater.css @@ -0,0 +1,130 @@ +/* App Updater feature styles. Self-contained (does not borrow other features' + classes); uses the shared palette tokens + the --page-updater identity hue + set on #updater-page. Eager-linked from index.html. */ + +.updater-page { padding: 4px 2px 40px; } + +.updater-layout { + display: grid; + grid-template-columns: 210px 1fr; + gap: 22px; + align-items: start; +} + +/* Sidebar */ +.updater-sidebar { + display: flex; + flex-direction: column; + gap: 4px; + padding: 10px; + border-radius: 14px; + background: rgba(var(--text-rgb), 0.03); + border: 1px solid rgba(var(--text-rgb), 0.07); +} +.updater-sidebar .sidebar-heading { + font-size: 0.7rem; font-weight: 700; letter-spacing: 0.07em; text-transform: uppercase; + color: rgba(var(--text-rgb), 0.45); + padding: 6px 10px 8px; +} +.updater-sidebar .category { + display: flex; align-items: center; gap: 9px; + width: 100%; text-align: left; + padding: 9px 11px; border: 0; border-radius: 9px; + background: transparent; color: rgba(var(--text-rgb), 0.7); + font-size: 0.86rem; font-weight: 500; cursor: pointer; + transition: background 0.15s, color 0.15s; +} +.updater-sidebar .category .cat-ico { opacity: 0.8; font-size: 0.95em; width: 1.1em; text-align: center; } +.updater-sidebar .category:hover { background: rgba(var(--text-rgb), 0.05); color: rgba(var(--text-rgb), 0.95); } +.updater-sidebar .category.active { + background: rgba(var(--page-rgb, var(--accent-rgb)), 0.16); + color: rgb(var(--page-rgb, var(--accent-rgb))); +} + +/* Header */ +.updater-header { display: flex; align-items: center; gap: 14px; margin-bottom: 18px; } +.updater-header-icon { + display: grid; place-items: center; width: 42px; height: 42px; border-radius: 11px; + color: rgb(var(--page-rgb, var(--accent-rgb))); + background: rgba(var(--page-rgb, var(--accent-rgb)), 0.14); + border: 1px solid rgba(var(--page-rgb, var(--accent-rgb)), 0.3); +} +.updater-header h1 { margin: 0; font-size: 1.5rem; } +.updater-header p { margin: 2px 0 0; color: rgba(var(--text-rgb), 0.55); font-size: 0.86rem; } + +/* Stat grid (overview) */ +.updater-stat-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 14px; } +.updater-stat { + position: relative; padding: 18px; border-radius: 14px; + background: rgba(var(--text-rgb), 0.035); + border: 1px solid rgba(var(--page-rgb, var(--accent-rgb)), 0.22); +} +.updater-stat-big { font-size: 2rem; font-weight: 700; line-height: 1; color: rgb(var(--page-rgb, var(--accent-rgb))); } +.updater-stat-label { margin-top: 6px; font-weight: 600; font-size: 0.9rem; } +.updater-stat-sub { color: rgba(var(--text-rgb), 0.5); font-size: 0.78rem; margin-top: 2px; } +.updater-stat .updater-btn { margin-top: 12px; } + +/* Lists / rows */ +.updater-toolbar { display: flex; gap: 10px; margin-bottom: 14px; } +.updater-list { display: flex; flex-direction: column; gap: 8px; } +.updater-row { + display: grid; grid-template-columns: 1fr auto auto; align-items: center; gap: 14px; + padding: 12px 15px; border-radius: 11px; + background: rgba(var(--text-rgb), 0.035); + border: 1px solid rgba(var(--text-rgb), 0.07); +} +.updater-row-name { font-weight: 600; } +.updater-row-ver { color: rgba(var(--text-rgb), 0.6); font-family: var(--font-mono); font-size: 0.8rem; } +.updater-arrow { color: rgb(var(--page-rgb, var(--accent-rgb))); } + +/* Badges */ +.updater-badge { + display: inline-block; padding: 1px 8px; border-radius: 999px; + font-size: 0.68rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; + vertical-align: middle; margin-left: 6px; +} +.updater-badge-update { background: rgba(var(--page-updater-rgb), 0.18); color: rgb(var(--page-updater-rgb)); } +.updater-badge-ok { background: rgba(var(--page-verify-rgb), 0.16); color: rgb(var(--page-verify-rgb)); } +.updater-badge-unknown { background: rgba(var(--text-rgb), 0.1); color: rgba(var(--text-rgb), 0.55); } +.sev-critical { background: rgba(220, 38, 38, 0.18); color: #f87171; } +.sev-high { background: rgba(234, 88, 12, 0.18); color: #fb923c; } +.sev-medium { background: rgba(234, 179, 8, 0.16); color: #fcd34d; } +.sev-low { background: rgba(var(--text-rgb), 0.1); color: rgba(var(--text-rgb), 0.6); } + +/* CVE blocks */ +.updater-cve-app { padding: 12px 15px; border-radius: 11px; background: rgba(var(--text-rgb), 0.035); border: 1px solid rgba(var(--text-rgb), 0.07); } +.updater-cve-app-name { font-weight: 600; margin-bottom: 8px; } +.updater-cve { display: flex; align-items: center; gap: 10px; padding: 6px 0; border-top: 1px solid rgba(var(--text-rgb), 0.06); font-size: 0.82rem; } +.updater-cve-sev { font-weight: 700; font-size: 0.66rem; min-width: 58px; } +.updater-cve.sev-critical .updater-cve-sev { color: #f87171; } +.updater-cve.sev-high .updater-cve-sev { color: #fb923c; } +.updater-cve.sev-medium .updater-cve-sev { color: #fcd34d; } +.updater-cve-id { font-family: var(--font-mono); color: rgb(var(--page-rgb, var(--accent-rgb))); text-decoration: none; } +.updater-cve-id:hover { text-decoration: underline; } +.updater-cve-pkg { color: rgba(var(--text-rgb), 0.6); } +.updater-cve-fix { margin-left: auto; color: rgba(var(--page-verify-rgb), 0.9); font-size: 0.76rem; } + +/* Buttons */ +.updater-btn { + padding: 7px 14px; border-radius: 9px; cursor: pointer; font-size: 0.82rem; font-weight: 600; + background: rgba(var(--text-rgb), 0.07); color: rgba(var(--text-rgb), 0.9); + border: 1px solid rgba(var(--text-rgb), 0.12); transition: background 0.15s, border-color 0.15s; +} +.updater-btn:hover { background: rgba(var(--text-rgb), 0.12); } +.updater-btn-primary { + background: rgba(var(--page-rgb, var(--accent-rgb)), 0.2); + border-color: rgba(var(--page-rgb, var(--accent-rgb)), 0.45); + color: rgb(var(--page-rgb, var(--accent-rgb))); +} +.updater-btn-primary:hover { background: rgba(var(--page-rgb, var(--accent-rgb)), 0.3); } + +/* Hints / empty states */ +.updater-hint { padding: 12px 15px; border-radius: 11px; margin-bottom: 14px; font-size: 0.84rem; + background: rgba(var(--page-rgb, var(--accent-rgb)), 0.08); border: 1px solid rgba(var(--page-rgb, var(--accent-rgb)), 0.2); color: rgba(var(--text-rgb), 0.75); } +.updater-empty { padding: 40px 20px; text-align: center; color: rgba(var(--text-rgb), 0.55); display: flex; flex-direction: column; gap: 14px; align-items: center; } + +@media (max-width: 820px) { + .updater-layout { grid-template-columns: 1fr; } + .updater-sidebar { flex-direction: row; flex-wrap: wrap; } + .updater-row { grid-template-columns: 1fr; gap: 6px; } +} diff --git a/containers/libreportal/frontend/html/topbar.html b/containers/libreportal/frontend/html/topbar.html index e0f06e8..9445f97 100755 --- a/containers/libreportal/frontend/html/topbar.html +++ b/containers/libreportal/frontend/html/topbar.html @@ -65,6 +65,15 @@ Backups + + + + + + + + Updates +
diff --git a/containers/libreportal/frontend/html/updater-content.html b/containers/libreportal/frontend/html/updater-content.html new file mode 100644 index 0000000..0171747 --- /dev/null +++ b/containers/libreportal/frontend/html/updater-content.html @@ -0,0 +1,49 @@ + +
+
+ + +
+
+ +
+

Overview

+

Update health, security posture, and recovery readiness at a glance.

+
+
+ +
+
+
+
+
+
+
+
+
+
diff --git a/containers/libreportal/frontend/index.html b/containers/libreportal/frontend/index.html index 8684b29..9099876 100755 --- a/containers/libreportal/frontend/index.html +++ b/containers/libreportal/frontend/index.html @@ -36,6 +36,7 @@ +