From 79d2a4750dc7fa242380f372bf3a952cdbd00f9b Mon Sep 17 00:00:00 2001 From: librelad Date: Sun, 31 May 2026 21:07:01 +0100 Subject: [PATCH] =?UTF-8?q?feat(webui):=20Phase=204=20=E2=80=94=20Improvem?= =?UTF-8?q?ents=20(hotfix)=20stream=20+=20per-app=20chip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surfaces the hotfix channel in the WebUI. Primary home is the Updates & Improvements page (the updater component) — its own "Improvements" tab — with a secondary chip on the App detail page (fork 3 locality = both). Updater component (components/updater): - New "Improvements" sidebar tab + panel; renderImprovements() reads the host- generated artifacts_available.json (severity badge, scope chip, applied/auto/ not-applicable badges, plain-English why). Apply/Revert buttons dispatch artifact_apply / artifact_revert through the TASK system (services.tasks.route) — no mutating API. Apply is disabled when the index is UNSIGNED. - Overview gains an "Improvements" stat card; task-refresh now also repaints on artifact_* task completion; URL tab routing + dispose teardown extended. Task plumbing (core/tasks): artifactApply/artifactRevert action methods (id is charset-guarded before it enters the command string) + artifact_apply/ artifact_revert routeAction cases. Task list/format gain icons + friendly labels. Apps component: an amber "⚡ N improvements" chip on an installed app's detail header (populated async from artifacts_available.json filtered by app, applicable & not-applied), linking to /updater/improvements. Best-effort, never throws. Co-Authored-By: Claude Opus 4.8 Signed-off-by: librelad --- .../components/apps/core/css/apps.css | 12 ++++ .../components/apps/core/js/apps-manager.js | 28 +++++++++ .../components/tasks/js/tasks-format.js | 3 + .../components/tasks/js/tasks-list-render.js | 2 + .../updater/html/updater-content.html | 7 +++ .../components/updater/js/updater-page.js | 61 +++++++++++++++++-- .../frontend/core/tasks/js/task-actions.js | 16 +++++ .../frontend/core/tasks/js/task-router.js | 7 +++ 8 files changed, 131 insertions(+), 5 deletions(-) diff --git a/containers/libreportal/frontend/components/apps/core/css/apps.css b/containers/libreportal/frontend/components/apps/core/css/apps.css index c20b42a..9fbf4aa 100644 --- a/containers/libreportal/frontend/components/apps/core/css/apps.css +++ b/containers/libreportal/frontend/components/apps/core/css/apps.css @@ -190,6 +190,18 @@ line-height: 1; } +/* Improvements (hotfix) chip — amber, links to Updates & Improvements. Only + shown when signed, app-scoped, applicable hotfixes exist for this app. */ +.app-tag.improvements-chip { + background: rgba(var(--status-warning-rgb, 245, 158, 11), 0.30); + color: #fcd34d; + border-color: rgba(var(--status-warning-rgb, 245, 158, 11), 0.65); + transform: translateY(-2px); +} +.app-tag.improvements-chip:hover { + background: rgba(var(--status-warning-rgb, 245, 158, 11), 0.45); +} + /* Not Installed tags - Gray with a leading ✕ glyph for symmetry with the ✓ on the installed pill. */ .app-tag.not-installed-tag { 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 b8fbe33..f097ab8 100755 --- a/containers/libreportal/frontend/components/apps/core/js/apps-manager.js +++ b/containers/libreportal/frontend/components/apps/core/js/apps-manager.js @@ -472,6 +472,32 @@ class AppsManager { + // Secondary surface for the hotfix channel (fork 3 locality): show a small chip + // on an installed app's detail header when signed, app-scoped hotfixes are + // available for it. The canonical home is the Updates & Improvements page; this + // just links there. Read-only, best-effort, never throws into the render path. + async _populateImprovementsChip(appName) { + try { + const chip = document.getElementById('app-improvements-chip'); + if (!chip) return; + const r = await fetch('/data/updater/generated/artifacts_available.json', { cache: 'no-store' }); + if (!r.ok) return; + const data = await r.json(); + const list = (data && Array.isArray(data.artifacts)) ? data.artifacts : []; + const n = list.filter(a => a && a.app === appName && a.applicable && !a.applied).length; + if (n <= 0) return; + chip.textContent = `⚡ ${n} improvement${n > 1 ? 's' : ''}`; + chip.title = 'Signed hotfixes are available for this app — open Updates & Improvements'; + chip.style.cursor = 'pointer'; + chip.style.display = ''; + chip.onclick = () => { + if (typeof window.navigateToRoute === 'function') window.navigateToRoute('/updater/improvements'); + else if (typeof window.spaClean === 'function') window.spaClean('/updater/improvements'); + else window.location.href = '/updater/improvements'; + }; + } catch (_) { /* best-effort */ } + } + async renderAppDetail(appName, preferredCategory = null, appChanged = true, opts = {}) { //// // console.log(`🎯 renderAppDetail called with: "${appName}", appChanged: ${appChanged}`); //// // console.log(`🎯 Available apps:`, window.apps?.map(a => ({ name: a.name, command: a.command }))); @@ -588,6 +614,7 @@ class AppsManager {
${categoryTag} ${installedTag} + ${app.installed ? `` : ''}
@@ -612,6 +639,7 @@ class AppsManager { } this.wireShowWhenListeners(); this.wireConfigDirtyTracking(cleanAppName); + if (app.installed) this._populateImprovementsChip(cleanAppName); // Only update service buttons if app has changed or installed status changed if (shouldRenderHeader) { this.updateServiceButtonsSidebar(app, cleanAppName); diff --git a/containers/libreportal/frontend/components/tasks/js/tasks-format.js b/containers/libreportal/frontend/components/tasks/js/tasks-format.js index ae9a8ef..06e9d58 100644 --- a/containers/libreportal/frontend/components/tasks/js/tasks-format.js +++ b/containers/libreportal/frontend/components/tasks/js/tasks-format.js @@ -29,6 +29,8 @@ Object.assign(TasksManager.prototype, { { match: /^libreportal updater apply-all\b/, title: 'Apps - Update All' }, { match: /^libreportal updater apply (\S+)/, title: (m) => `${displayName(m[1])} - Update` }, { match: /^libreportal updater rollback (\S+)/, title: (m) => `${displayName(m[1])} - Roll Back` }, + { match: /^libreportal artifact apply (\S+)/, title: (m) => `Hotfix ${m[1]} - Apply` }, + { match: /^libreportal artifact revert (\S+)/, title: (m) => `Hotfix ${m[1]} - Revert` }, // -- Peers ------------------------------------------------------------- { match: /^libreportal peer add\b/, title: 'LibrePortal - Add Peer' }, @@ -138,6 +140,7 @@ Object.assign(TasksManager.prototype, { 'system_image_rm': 'Remove Images', 'verify': 'Verify System', 'updater_check': 'Check for Updates', 'updater_apply': 'Update', 'updater_apply_all': 'Update All', 'updater_rollback': 'Roll Back', + 'artifact_apply': 'Apply Hotfix', 'artifact_revert': 'Revert Hotfix', 'setup-config': 'Apply Configuration', 'setup-finalize': 'Finalize Setup' }; diff --git a/containers/libreportal/frontend/components/tasks/js/tasks-list-render.js b/containers/libreportal/frontend/components/tasks/js/tasks-list-render.js index 1fde72a..e995833 100644 --- a/containers/libreportal/frontend/components/tasks/js/tasks-list-render.js +++ b/containers/libreportal/frontend/components/tasks/js/tasks-list-render.js @@ -313,6 +313,8 @@ Object.assign(TasksManager.prototype, { 'updater_apply': { icon: '⬆️', class: 'update' }, 'updater_apply_all': { icon: '⬆️', class: 'update' }, 'updater_rollback': { icon: '↩️', class: 'restore' }, + 'artifact_apply': { icon: '⚡', class: 'update' }, + 'artifact_revert': { icon: '↩️', class: 'restore' }, 'custom': { icon: '⚙️', class: 'custom' } }; diff --git a/containers/libreportal/frontend/components/updater/html/updater-content.html b/containers/libreportal/frontend/components/updater/html/updater-content.html index 0f1d228..87a9889 100644 --- a/containers/libreportal/frontend/components/updater/html/updater-content.html +++ b/containers/libreportal/frontend/components/updater/html/updater-content.html @@ -20,6 +20,12 @@ Updates +
+ + + + Improvements +
@@ -63,6 +69,7 @@
+
diff --git a/containers/libreportal/frontend/components/updater/js/updater-page.js b/containers/libreportal/frontend/components/updater/js/updater-page.js index 38dbf5c..55dfe4f 100644 --- a/containers/libreportal/frontend/components/updater/js/updater-page.js +++ b/containers/libreportal/frontend/components/updater/js/updater-page.js @@ -14,6 +14,7 @@ class UpdaterPage { this.updates = null; // { generated_at, apps: [...] } this.cves = null; // { generated_at, apps: [...], totals: {...} } this.history = null; // { entries: [...] } + this.artifacts = null; // { signed, serial, artifacts: [...] } (hotfixes) this.apps = []; // merged per-app view rendered in the table this._pushedAnyTab = false; this._eventBound = false; @@ -31,7 +32,7 @@ class UpdaterPage { } parseTabFromUrl() { - const allowed = new Set(['overview', 'updates', 'security', 'recovery', 'history']); + const allowed = new Set(['overview', 'updates', 'improvements', 'security', 'recovery', 'history']); const seg = window.location.pathname.replace(/^\/updater\/?/, '').split('/')[0]; if (seg && allowed.has(seg)) return seg; return null; @@ -45,7 +46,7 @@ class UpdaterPage { // 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))) || ''), + match: (d) => /^(updater_|artifact_|libreportal\s+(updater|artifact))/.test((d && (d.action || (d.task && d.task.command))) || ''), run: () => { if (window.updaterPage === this && document.getElementById('updater-page')) { return this.refreshAll().then(() => this.render()); @@ -71,6 +72,8 @@ class UpdaterPage { case 'update': this.applyUpdate(app); break; case 'update-all': this.applyAll(); break; case 'rollback': this.rollback(app); break; + case 'apply-artifact': this.applyArtifact(action.dataset.id); break; + case 'revert-artifact': this.revertArtifact(action.dataset.id); break; case 'goto': this.switchTab(action.dataset.tab); break; } }); @@ -100,12 +103,13 @@ class UpdaterPage { 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([ + const [u, c, h, av] = await Promise.all([ get('/data/updater/generated/updates.json'), get('/data/updater/generated/cves.json'), get('/data/updater/generated/history.json'), + get('/data/updater/generated/artifacts_available.json'), ]); - this.updates = u; this.cves = c; this.history = h; + this.updates = u; this.cves = c; this.history = h; this.artifacts = av; this.apps = this.mergeApps(); } @@ -149,7 +153,9 @@ class UpdaterPage { 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 }; + const artList = (this.artifacts && Array.isArray(this.artifacts.artifacts)) ? this.artifacts.artifacts : []; + const improvements = artList.filter(a => a.applicable && !a.applied).length; + return { apps: this.apps.length, updatesAvailable, cveTotals, totalCves, drReady, improvements, lastChecked: this.updates && this.updates.generated_at }; } tallyCves() { @@ -178,6 +184,14 @@ class UpdaterPage { if (!app) return; this.dispatch('updater_rollback', { app }, `Rolling ${app} back to its pre-update snapshot…`); } + applyArtifact(id) { + if (!id) return; + this.dispatch('artifact_apply', { id }, `Applying hotfix ${id} (a snapshot is taken first)…`); + } + revertArtifact(id) { + if (!id) return; + this.dispatch('artifact_revert', { id }, `Reverting hotfix ${id}…`); + } dispatch(action, params, note) { const route = this.services.tasks && this.services.tasks.route; @@ -203,6 +217,7 @@ class UpdaterPage { 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.'], + improvements: ['Improvements', 'Signed, individually-reversible hotfixes from the LibrePortal team — applied with a snapshot first.'], 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.'], @@ -225,6 +240,7 @@ class UpdaterPage { switch (this.currentTab) { case 'overview': panel.innerHTML = this.renderOverview(); break; case 'updates': panel.innerHTML = this.renderUpdates(); break; + case 'improvements': panel.innerHTML = this.renderImprovements(); break; case 'security': panel.innerHTML = this.renderSecurity(); break; case 'recovery': panel.innerHTML = this.renderRecovery(); break; case 'history': panel.innerHTML = this.renderHistory(); break; @@ -249,6 +265,8 @@ class UpdaterPage { ${card('verify', c.totalCves, 'Known CVEs', sev.critical || sev.high ? `${sev.critical || 0} critical · ${sev.high || 0} high` : 'no high-severity issues', ``)} + ${card('setup', c.improvements, 'Improvements', c.improvements ? 'signed hotfixes to apply' : 'nothing pending', + ``)} ${card('backups', `${c.drReady}/${c.apps}`, 'Recovery-ready', 'snapshot taken before each update', ``)} ${card('system', c.apps, 'Apps tracked', `last scan: ${checked}`, @@ -284,6 +302,39 @@ class UpdaterPage {
${rows}
`; } + renderImprovements() { + if (!this.artifacts) return this.empty('No hotfix data yet. Run a check to fetch the signed improvements index.', true); + const list = Array.isArray(this.artifacts.artifacts) ? this.artifacts.artifacts : []; + const signed = !!this.artifacts.signed; + if (!list.length) return this.empty('No improvements available right now — you are all caught up. 🎉'); + // Map the hotfix severities onto the existing CVE severity colour classes. + const sevClass = { security: 'sev-critical', breakage: 'sev-high', compat: 'sev-medium', tweak: 'sev-low' }; + const rows = list.map(a => { + const sv = sevClass[a.severity] || 'sev-low'; + const scope = a.app ? this.escape(a.app) : 'system'; + const appliedBadge = a.applied ? 'applied' : ''; + const autoBadge = a.auto ? 'auto' : ''; + const naBadge = a.applicable ? '' : 'not applicable'; + let btn = ''; + if (a.applied) btn = ``; + else if (a.applicable && signed) btn = ``; + return `
+
${this.escape(a.title || a.id)} + ${this.escape(a.severity || 'tweak')} + ${scope} + ${appliedBadge} ${autoBadge} ${naBadge}
+
${this.escape(a.why || '')}
+
${btn}
+
`; + }).join(''); + const banner = signed + ? `
Small, signed, individually-reversible improvements curated by the LibrePortal team. Security & breakage fixes apply automatically (a snapshot is taken first); the rest are one click. Every apply is logged in History and can be reverted.
` + : `
⚠ The improvements index is unsigned (signing not activated on this build) — applying is disabled for safety.
`; + return `${banner} +
+
${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); diff --git a/containers/libreportal/frontend/core/tasks/js/task-actions.js b/containers/libreportal/frontend/core/tasks/js/task-actions.js index 9b58b10..3055bf6 100755 --- a/containers/libreportal/frontend/core/tasks/js/task-actions.js +++ b/containers/libreportal/frontend/core/tasks/js/task-actions.js @@ -287,6 +287,22 @@ async configUpdate(changes) { } catch (error) { throw new Error(`Failed to roll back ${app}: ${error.message}`); } } + // Hotfix (artifact) actions — apply/revert one signed hotfix via the locked-down + // `libreportal artifact` CLI as a task. The id is from the signed index; still + // charset-guard it before it goes into the command string (defense in depth). + async artifactApply(id) { + if (!id || !/^[A-Za-z0-9._-]+$/.test(id)) throw new Error('Invalid hotfix id'); + try { + return await this.executeTask('artifact_apply', id, `libreportal artifact apply ${id}`, `Apply hotfix ${id}`); + } catch (error) { throw new Error(`Failed to apply hotfix ${id}: ${error.message}`); } + } + async artifactRevert(id) { + if (!id || !/^[A-Za-z0-9._-]+$/.test(id)) throw new Error('Invalid hotfix id'); + try { + return await this.executeTask('artifact_revert', id, `libreportal artifact revert ${id}`, `Revert hotfix ${id}`); + } catch (error) { throw new Error(`Failed to revert hotfix ${id}: ${error.message}`); } + } + /** * Create a task object */ diff --git a/containers/libreportal/frontend/core/tasks/js/task-router.js b/containers/libreportal/frontend/core/tasks/js/task-router.js index 6dabb97..70a24bb 100755 --- a/containers/libreportal/frontend/core/tasks/js/task-router.js +++ b/containers/libreportal/frontend/core/tasks/js/task-router.js @@ -79,6 +79,13 @@ class TaskRouter { case 'updater_rollback': return await this.actions.updaterRollback(params.app); + // Hotfix channel (components/updater "Improvements") — apply/revert one + // signed hotfix via the locked-down `libreportal artifact` CLI as a task. + case 'artifact_apply': + return await this.actions.artifactApply(params.id); + case 'artifact_revert': + return await this.actions.artifactRevert(params.id); + default: throw new Error(`Unknown action: ${action}`); }