Merge claude/2
This commit is contained in:
commit
102b0a435c
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
<div class="app-meta" style="margin-top: 10px;">
|
||||
${categoryTag}
|
||||
${installedTag}
|
||||
${app.installed ? `<span class="app-tag improvements-chip" id="app-improvements-chip" style="display:none;"></span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -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);
|
||||
|
||||
@ -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'
|
||||
};
|
||||
|
||||
@ -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' }
|
||||
};
|
||||
|
||||
|
||||
@ -20,6 +20,12 @@
|
||||
</svg>
|
||||
Updates
|
||||
</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">
|
||||
<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>
|
||||
@ -63,6 +69,7 @@
|
||||
<div class="updater-page-body">
|
||||
<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-improvements"></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-history"></section>
|
||||
|
||||
@ -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',
|
||||
`<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',
|
||||
`<button class="updater-btn" data-updater-action="goto" data-tab="recovery">Recovery</button>`)}
|
||||
${card('system', c.apps, 'Apps tracked', `last scan: ${checked}`,
|
||||
@ -284,6 +302,39 @@ class UpdaterPage {
|
||||
<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 & 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() {
|
||||
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);
|
||||
|
||||
@ -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
|
||||
*/
|
||||
|
||||
@ -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}`);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user