Merge claude/2

This commit is contained in:
librelad 2026-05-31 21:07:01 +01:00
commit 102b0a435c
8 changed files with 131 additions and 5 deletions

View File

@ -190,6 +190,18 @@
line-height: 1; 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 /* Not Installed tags - Gray with a leading glyph for symmetry with
the on the installed pill. */ the on the installed pill. */
.app-tag.not-installed-tag { .app-tag.not-installed-tag {

View File

@ -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 = {}) { async renderAppDetail(appName, preferredCategory = null, appChanged = true, opts = {}) {
//// // console.log(`🎯 renderAppDetail called with: "${appName}", appChanged: ${appChanged}`); //// // console.log(`🎯 renderAppDetail called with: "${appName}", appChanged: ${appChanged}`);
//// // console.log(`🎯 Available apps:`, window.apps?.map(a => ({ name: a.name, command: a.command }))); //// // console.log(`🎯 Available apps:`, window.apps?.map(a => ({ name: a.name, command: a.command })));
@ -588,6 +614,7 @@ class AppsManager {
<div class="app-meta" style="margin-top: 10px;"> <div class="app-meta" style="margin-top: 10px;">
${categoryTag} ${categoryTag}
${installedTag} ${installedTag}
${app.installed ? `<span class="app-tag improvements-chip" id="app-improvements-chip" style="display:none;"></span>` : ''}
</div> </div>
</div> </div>
</div> </div>
@ -612,6 +639,7 @@ class AppsManager {
} }
this.wireShowWhenListeners(); this.wireShowWhenListeners();
this.wireConfigDirtyTracking(cleanAppName); this.wireConfigDirtyTracking(cleanAppName);
if (app.installed) this._populateImprovementsChip(cleanAppName);
// Only update service buttons if app has changed or installed status changed // Only update service buttons if app has changed or installed status changed
if (shouldRenderHeader) { if (shouldRenderHeader) {
this.updateServiceButtonsSidebar(app, cleanAppName); this.updateServiceButtonsSidebar(app, cleanAppName);

View File

@ -29,6 +29,8 @@ Object.assign(TasksManager.prototype, {
{ match: /^libreportal updater apply-all\b/, title: 'Apps - Update All' }, { match: /^libreportal updater apply-all\b/, title: 'Apps - Update All' },
{ match: /^libreportal updater apply (\S+)/, title: (m) => `${displayName(m[1])} - Update` }, { 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 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 ------------------------------------------------------------- // -- Peers -------------------------------------------------------------
{ match: /^libreportal peer add\b/, title: 'LibrePortal - Add Peer' }, { 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', 'system_image_rm': 'Remove Images', 'verify': 'Verify System',
'updater_check': 'Check for Updates', 'updater_apply': 'Update', 'updater_check': 'Check for Updates', 'updater_apply': 'Update',
'updater_apply_all': 'Update All', 'updater_rollback': 'Roll Back', 'updater_apply_all': 'Update All', 'updater_rollback': 'Roll Back',
'artifact_apply': 'Apply Hotfix', 'artifact_revert': 'Revert Hotfix',
'setup-config': 'Apply Configuration', 'setup-config': 'Apply Configuration',
'setup-finalize': 'Finalize Setup' 'setup-finalize': 'Finalize Setup'
}; };

View File

@ -313,6 +313,8 @@ Object.assign(TasksManager.prototype, {
'updater_apply': { icon: '⬆️', class: 'update' }, 'updater_apply': { icon: '⬆️', class: 'update' },
'updater_apply_all': { icon: '⬆️', class: 'update' }, 'updater_apply_all': { icon: '⬆️', class: 'update' },
'updater_rollback': { icon: '↩️', class: 'restore' }, 'updater_rollback': { icon: '↩️', class: 'restore' },
'artifact_apply': { icon: '⚡', class: 'update' },
'artifact_revert': { icon: '↩️', class: 'restore' },
'custom': { icon: '⚙️', class: 'custom' } 'custom': { icon: '⚙️', class: 'custom' }
}; };

View File

@ -20,6 +20,12 @@
</svg> </svg>
Updates Updates
</div> </div>
<div class="category" data-updater-tab="improvements">
<svg class="category-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"></path>
</svg>
Improvements
</div>
<div class="category" data-updater-tab="security"> <div class="category" data-updater-tab="security">
<svg class="category-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg class="category-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"></path> <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"></path>
@ -63,6 +69,7 @@
<div class="updater-page-body"> <div class="updater-page-body">
<section class="updater-tabpanel active" id="updater-panel-overview"></section> <section class="updater-tabpanel active" id="updater-panel-overview"></section>
<section class="updater-tabpanel" id="updater-panel-updates"></section> <section class="updater-tabpanel" id="updater-panel-updates"></section>
<section class="updater-tabpanel" id="updater-panel-improvements"></section>
<section class="updater-tabpanel" id="updater-panel-security"></section> <section class="updater-tabpanel" id="updater-panel-security"></section>
<section class="updater-tabpanel" id="updater-panel-recovery"></section> <section class="updater-tabpanel" id="updater-panel-recovery"></section>
<section class="updater-tabpanel" id="updater-panel-history"></section> <section class="updater-tabpanel" id="updater-panel-history"></section>

View File

@ -14,6 +14,7 @@ class UpdaterPage {
this.updates = null; // { generated_at, apps: [...] } this.updates = null; // { generated_at, apps: [...] }
this.cves = null; // { generated_at, apps: [...], totals: {...} } this.cves = null; // { generated_at, apps: [...], totals: {...} }
this.history = null; // { entries: [...] } this.history = null; // { entries: [...] }
this.artifacts = null; // { signed, serial, artifacts: [...] } (hotfixes)
this.apps = []; // merged per-app view rendered in the table this.apps = []; // merged per-app view rendered in the table
this._pushedAnyTab = false; this._pushedAnyTab = false;
this._eventBound = false; this._eventBound = false;
@ -31,7 +32,7 @@ class UpdaterPage {
} }
parseTabFromUrl() { 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]; const seg = window.location.pathname.replace(/^\/updater\/?/, '').split('/')[0];
if (seg && allowed.has(seg)) return seg; if (seg && allowed.has(seg)) return seg;
return null; return null;
@ -45,7 +46,7 @@ class UpdaterPage {
// coordinator). Self-guards against a torn-down page. // coordinator). Self-guards against a torn-down page.
this.services.tasks && this.services.tasks.refresh && this.services.tasks.refresh.register({ this.services.tasks && this.services.tasks.refresh && this.services.tasks.refresh.register({
id: 'updater', 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: () => { run: () => {
if (window.updaterPage === this && document.getElementById('updater-page')) { if (window.updaterPage === this && document.getElementById('updater-page')) {
return this.refreshAll().then(() => this.render()); return this.refreshAll().then(() => this.render());
@ -71,6 +72,8 @@ class UpdaterPage {
case 'update': this.applyUpdate(app); break; case 'update': this.applyUpdate(app); break;
case 'update-all': this.applyAll(); break; case 'update-all': this.applyAll(); break;
case 'rollback': this.rollback(app); 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; case 'goto': this.switchTab(action.dataset.tab); break;
} }
}); });
@ -100,12 +103,13 @@ class UpdaterPage {
async refreshAll() { async refreshAll() {
const get = (url) => fetch(url, { cache: 'no-store' }).then(r => r.ok ? r.json() : null).catch(() => null); 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/updates.json'),
get('/data/updater/generated/cves.json'), get('/data/updater/generated/cves.json'),
get('/data/updater/generated/history.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(); this.apps = this.mergeApps();
} }
@ -149,7 +153,9 @@ class UpdaterPage {
const cveTotals = (this.cves && this.cves.totals) || this.tallyCves(); const cveTotals = (this.cves && this.cves.totals) || this.tallyCves();
const totalCves = (cveTotals.critical || 0) + (cveTotals.high || 0) + (cveTotals.medium || 0) + (cveTotals.low || 0); 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 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() { tallyCves() {
@ -178,6 +184,14 @@ class UpdaterPage {
if (!app) return; if (!app) return;
this.dispatch('updater_rollback', { app }, `Rolling ${app} back to its pre-update snapshot…`); 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) { dispatch(action, params, note) {
const route = this.services.tasks && this.services.tasks.route; const route = this.services.tasks && this.services.tasks.route;
@ -203,6 +217,7 @@ class UpdaterPage {
const titles = { const titles = {
overview: ['Overview', 'Update health, security posture, and recovery readiness at a glance.'], 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.'], 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.'], security: ['Security', 'Known vulnerabilities (CVEs) in your installed app images, by severity.'],
recovery: ['Disaster Recovery', 'Pre-update snapshots and rollback points — undo any update.'], recovery: ['Disaster Recovery', 'Pre-update snapshots and rollback points — undo any update.'],
history: ['History', 'A log of update and rollback activity.'], history: ['History', 'A log of update and rollback activity.'],
@ -225,6 +240,7 @@ class UpdaterPage {
switch (this.currentTab) { switch (this.currentTab) {
case 'overview': panel.innerHTML = this.renderOverview(); break; case 'overview': panel.innerHTML = this.renderOverview(); break;
case 'updates': panel.innerHTML = this.renderUpdates(); break; case 'updates': panel.innerHTML = this.renderUpdates(); break;
case 'improvements': panel.innerHTML = this.renderImprovements(); break;
case 'security': panel.innerHTML = this.renderSecurity(); break; case 'security': panel.innerHTML = this.renderSecurity(); break;
case 'recovery': panel.innerHTML = this.renderRecovery(); break; case 'recovery': panel.innerHTML = this.renderRecovery(); break;
case 'history': panel.innerHTML = this.renderHistory(); break; case 'history': panel.innerHTML = this.renderHistory(); break;
@ -249,6 +265,8 @@ class UpdaterPage {
${card('verify', c.totalCves, 'Known CVEs', ${card('verify', c.totalCves, 'Known CVEs',
sev.critical || sev.high ? `${sev.critical || 0} critical · ${sev.high || 0} high` : 'no high-severity issues', sev.critical || sev.high ? `${sev.critical || 0} critical · ${sev.high || 0} high` : 'no high-severity issues',
`<button class="updater-btn" data-updater-action="goto" data-tab="security">View</button>`)} `<button class="updater-btn" data-updater-action="goto" data-tab="security">View</button>`)}
${card('setup', c.improvements, 'Improvements', c.improvements ? 'signed hotfixes to apply' : 'nothing pending',
`<button class="updater-btn" data-updater-action="goto" data-tab="improvements">View</button>`)}
${card('backups', `${c.drReady}/${c.apps}`, 'Recovery-ready', 'snapshot taken before each update', ${card('backups', `${c.drReady}/${c.apps}`, 'Recovery-ready', 'snapshot taken before each update',
`<button class="updater-btn" data-updater-action="goto" data-tab="recovery">Recovery</button>`)} `<button class="updater-btn" data-updater-action="goto" data-tab="recovery">Recovery</button>`)}
${card('system', c.apps, 'Apps tracked', `last scan: ${checked}`, ${card('system', c.apps, 'Apps tracked', `last scan: ${checked}`,
@ -284,6 +302,39 @@ class UpdaterPage {
<div class="updater-list">${rows}</div>`; <div class="updater-list">${rows}</div>`;
} }
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 ? '<span class="updater-badge updater-badge-ok">applied</span>' : '';
const autoBadge = a.auto ? '<span class="updater-badge updater-badge-update">auto</span>' : '';
const naBadge = a.applicable ? '' : '<span class="updater-badge updater-badge-unknown">not applicable</span>';
let btn = '';
if (a.applied) btn = `<button class="updater-btn" data-updater-action="revert-artifact" data-id="${this.escape(a.id)}">Revert</button>`;
else if (a.applicable && signed) btn = `<button class="updater-btn updater-btn-primary" data-updater-action="apply-artifact" data-id="${this.escape(a.id)}">Apply</button>`;
return `<div class="updater-row">
<div class="updater-row-main"><span class="updater-row-name">${this.escape(a.title || a.id)}</span>
<span class="updater-badge ${sv}">${this.escape(a.severity || 'tweak')}</span>
<span class="updater-badge updater-badge-unknown">${scope}</span>
${appliedBadge} ${autoBadge} ${naBadge}</div>
<div class="updater-row-ver">${this.escape(a.why || '')}</div>
<div class="updater-row-actions">${btn}</div>
</div>`;
}).join('');
const banner = signed
? `<div class="updater-hint">Small, signed, individually-reversible improvements curated by the LibrePortal team. Security &amp; breakage fixes apply automatically (a snapshot is taken first); the rest are one click. Every apply is logged in History and can be reverted.</div>`
: `<div class="updater-hint">⚠ The improvements index is <strong>unsigned</strong> (signing not activated on this build) — applying is disabled for safety.</div>`;
return `${banner}
<div class="updater-toolbar"><button class="updater-btn" data-updater-action="check"> Check for improvements</button></div>
<div class="updater-list">${rows}</div>`;
}
renderSecurity() { renderSecurity() {
const withCves = this.apps.filter(a => (a.cves || []).length); 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 (!this.cves) return this.empty('No vulnerability scan yet. Run a check to scan your app images for known CVEs.', true);

View File

@ -287,6 +287,22 @@ async configUpdate(changes) {
} catch (error) { throw new Error(`Failed to roll back ${app}: ${error.message}`); } } 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 * Create a task object
*/ */

View File

@ -79,6 +79,13 @@ class TaskRouter {
case 'updater_rollback': case 'updater_rollback':
return await this.actions.updaterRollback(params.app); 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: default:
throw new Error(`Unknown action: ${action}`); throw new Error(`Unknown action: ${action}`);
} }