'Checking for improvements… this usually takes a minute.' implied an active scan finishing imminently. The scan is periodic and runs in the background (CFG_UPDATER_SCAN_INTERVAL, default 30m), so reword to reflect that. Kept the cadence generic since the interval is admin-configurable and not exposed here. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: librelad <librelad@digitalangels.vip>
510 lines
28 KiB
JavaScript
510 lines
28 KiB
JavaScript
// 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.artifacts = null; // { signed, serial, artifacts: [...] } (hotfixes)
|
|
this.apps = []; // merged per-app view rendered in the table
|
|
this._pushedAnyTab = false;
|
|
this._eventBound = false;
|
|
this._poll = null; // auto-refresh timer (startAutoRefresh/dispose)
|
|
}
|
|
|
|
// ---- lifecycle -----------------------------------------------------------
|
|
|
|
async init() {
|
|
this.currentTab = this.parseTabFromUrl() || this.currentTab;
|
|
this.applyActiveTabUi(this.currentTab);
|
|
this.bindEvents();
|
|
await this.refreshAll();
|
|
this.render();
|
|
this.updateHeader();
|
|
// Keep the open page in step with the host-side auto-scan (the task
|
|
// processor refreshes the generated JSON on its own schedule, with no task
|
|
// event to hook). Repaints only when the data actually changed.
|
|
this.startAutoRefresh(
|
|
() => this.render(),
|
|
() => window.updaterPage === this && !!document.getElementById('updater-page')
|
|
);
|
|
}
|
|
|
|
// The host auto-scan (`libreportal updater check auto`, run from the task
|
|
// processor's idle poll) rewrites the generated JSON without a task event,
|
|
// so an open page re-reads it on a slow timer: a few static-file GETs per
|
|
// minute, and onChange() only when a generated_at stamp moved. isActive lets
|
|
// the hosting surface skip ticks while it isn't visible; dispose() releases
|
|
// the timer (also called by the feature unmount).
|
|
startAutoRefresh(onChange, isActive) {
|
|
if (this._poll) return;
|
|
this._poll = setInterval(() => {
|
|
if (document.hidden || (isActive && !isActive())) return;
|
|
const before = this._dataStamp();
|
|
this.refreshAll().then(() => {
|
|
if (this._poll && this._dataStamp() !== before && onChange) onChange();
|
|
});
|
|
}, 60000);
|
|
}
|
|
|
|
dispose() {
|
|
if (this._poll) { clearInterval(this._poll); this._poll = null; }
|
|
}
|
|
|
|
// Cheap change detector for the auto-refresh: the generators stamp each file
|
|
// with generated_at; history has no stamp, so its newest entry stands in.
|
|
_dataStamp() {
|
|
const g = (d) => (d && d.generated_at) || '';
|
|
const h = this.history && this.history.entries && this.history.entries[0];
|
|
return [g(this.updates), g(this.cves), g(this.artifacts), (h && h.ts) || ''].join('|');
|
|
}
|
|
|
|
parseTabFromUrl() {
|
|
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;
|
|
}
|
|
|
|
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_|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());
|
|
}
|
|
},
|
|
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 '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;
|
|
}
|
|
});
|
|
}
|
|
|
|
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, 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.artifacts = av;
|
|
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
|
|
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() {
|
|
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…`);
|
|
}
|
|
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;
|
|
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.'],
|
|
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.'],
|
|
};
|
|
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 = '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 2v6h-6"></path><path d="M3 12a9 9 0 0 1 15-6.7L21 8"></path><path d="M3 22v-6h6"></path><path d="M21 12a9 9 0 0 1-15 6.7L3 16"></path></svg>';
|
|
}
|
|
}
|
|
|
|
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 '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;
|
|
}
|
|
}
|
|
|
|
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) => `
|
|
<div class="updater-stat" style="--page: var(--page-${hue}); --page-rgb: var(--page-${hue}-rgb);">
|
|
<div class="updater-stat-big">${big}</div>
|
|
<div class="updater-stat-label">${label}</div>
|
|
${sub ? `<div class="updater-stat-sub">${sub}</div>` : ''}
|
|
${action || ''}
|
|
</div>`;
|
|
return `
|
|
<div class="updater-stat-grid">
|
|
${card('updates', c.updatesAvailable, 'Updates available', c.updatesAvailable ? 'across your apps' : "you're current",
|
|
`<button class="updater-btn updater-btn-primary" data-updater-action="goto" data-tab="updates">Review</button>`)}
|
|
${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}`,
|
|
`<button class="updater-btn" data-updater-action="check">Check now</button>`)}
|
|
</div>
|
|
${this.updates ? '' : `<div class="updater-hint">No scan data yet — showing your installed apps. The first automatic scan runs within a couple of minutes, or hit <strong>Check now</strong>.</div>`}`;
|
|
}
|
|
|
|
renderUpdates() {
|
|
if (!this.apps.length) return this.empty('No apps installed yet — add one from the App Center and its updates will show up here.');
|
|
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
|
|
? `<span class="updater-badge updater-badge-update">update</span>`
|
|
: (a.scanned ? `<span class="updater-badge updater-badge-ok">up to date</span>` : `<span class="updater-badge updater-badge-unknown">unscanned</span>`);
|
|
const sev = a.worstSeverity ? `<span class="updater-badge sev-${a.worstSeverity}">${a.worstSeverity}</span>` : '';
|
|
const btn = a.update_available
|
|
? `<button class="updater-btn updater-btn-primary" data-updater-action="update" data-app="${this.escape(a.name)}">Update</button>`
|
|
: '';
|
|
return `<div class="updater-row">
|
|
<div class="updater-row-main"><span class="updater-row-name">${this.escape(a.displayName)}</span> ${badge} ${sev}</div>
|
|
<div class="updater-row-ver">${cur}${avail ? ` <span class="updater-arrow">→</span> <strong>${avail}</strong>` : ''}</div>
|
|
<div class="updater-row-actions">${btn}</div>
|
|
</div>`;
|
|
}).join('');
|
|
const anyUpdate = this.apps.some(a => a.update_available);
|
|
return `
|
|
<div class="updater-toolbar">
|
|
<button class="updater-btn" data-updater-action="check">↻ Check for updates</button>
|
|
${anyUpdate ? `<button class="updater-btn updater-btn-primary" data-updater-action="update-all">Update all</button>` : ''}
|
|
</div>
|
|
<div class="updater-list">${rows}</div>`;
|
|
}
|
|
|
|
// withToolbar=false lets an embedding surface (the fleet Overview tab) skip
|
|
// the inline Check button because it provides one in its own header.
|
|
renderImprovements(withToolbar = true) {
|
|
// No inline Check button on the empty state: the host auto-scan repopulates
|
|
// this within a couple of minutes (and the embedding header already carries
|
|
// a manual Check), so the message alone is the right, button-free empty UI.
|
|
if (!this.artifacts) return this.empty('Nothing here yet — LibrePortal checks for improvements automatically in the background.');
|
|
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}
|
|
${withToolbar ? `<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);
|
|
// No inline Check button: the host auto-scan runs the vulnerability scan on
|
|
// its own within a couple of minutes (and the embedding header carries a
|
|
// manual Check), so the message alone is the right button-free empty UI.
|
|
if (!this.cves) return this.empty('No vulnerability scan yet — one runs automatically within a couple of minutes.');
|
|
if (!withCves.length) return this.empty('No known vulnerabilities in your installed apps. 🎉');
|
|
const blocks = withCves.map(a => {
|
|
const items = (a.cves || []).map(c => `
|
|
<div class="updater-cve sev-${(c.severity || 'low').toLowerCase()}">
|
|
<span class="updater-cve-sev">${this.escape((c.severity || '').toUpperCase())}</span>
|
|
<a class="updater-cve-id" href="${this.escape(c.url || ('https://nvd.nist.gov/vuln/detail/' + (c.id || '')))}" target="_blank" rel="noopener">${this.escape(c.id || 'CVE')}</a>
|
|
<span class="updater-cve-pkg">${this.escape(c.package || '')}</span>
|
|
${c.fixed_in ? `<span class="updater-cve-fix">fixed in ${this.escape(c.fixed_in)}</span>` : ''}
|
|
</div>`).join('');
|
|
return `<div class="updater-cve-app"><div class="updater-cve-app-name">${this.escape(a.displayName)} <span class="updater-badge sev-${a.worstSeverity}">${(a.cves || []).length}</span></div>${items}</div>`;
|
|
}).join('');
|
|
return `<div class="updater-list">${blocks}</div>`;
|
|
}
|
|
|
|
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 `<div class="updater-row">
|
|
<div class="updater-row-main"><span class="updater-row-name">${this.escape(a.displayName)}</span>
|
|
<span class="updater-badge ${can ? 'updater-badge-ok' : 'updater-badge-unknown'}">${can ? 'recoverable' : 'protected'}</span></div>
|
|
<div class="updater-row-ver">${snap}</div>
|
|
<div class="updater-row-actions">${can ? `<button class="updater-btn" data-updater-action="rollback" data-app="${this.escape(a.name)}">Roll back</button>` : ''}</div>
|
|
</div>`;
|
|
}).join('');
|
|
return `
|
|
<div class="updater-hint">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.</div>
|
|
<div class="updater-list">${rows}</div>`;
|
|
}
|
|
|
|
renderHistory() {
|
|
const entries = (this.history && this.history.entries) || [];
|
|
if (!entries.length) return this.empty('No update activity yet.');
|
|
const rows = entries.map(e => `
|
|
<div class="updater-row">
|
|
<div class="updater-row-main"><span class="updater-row-name">${this.escape(e.app)}</span>
|
|
<span class="updater-badge ${e.result === 'ok' ? 'updater-badge-ok' : (e.result === 'rolled-back' ? 'updater-badge-update' : 'sev-high')}">${this.escape(e.action)}${e.result ? ' · ' + this.escape(e.result) : ''}</span></div>
|
|
<div class="updater-row-ver">${this.escape(e.from || '')}${e.to ? ` <span class="updater-arrow">→</span> ${this.escape(e.to)}` : ''}</div>
|
|
<div class="updater-row-actions">${this.fmtRel(e.ts)}</div>
|
|
</div>`).join('');
|
|
return `<div class="updater-list">${rows}</div>`;
|
|
}
|
|
|
|
// Per-app detail body — composes one app's security (CVEs), recovery point,
|
|
// and recent history. Pure HTML string with no DOM assumptions, so it is reused
|
|
// verbatim as the fleet Updates expander body (overview-manager.js) AND the
|
|
// per-app Updater tab. Action buttons keep the data-updater-action/data-app
|
|
// contract, so whichever delegated handler is in scope drives them.
|
|
renderAppDetail(app, opts = {}) {
|
|
const a = app || {};
|
|
// Optional leading "Version" section — the per-app Updates tab shows the
|
|
// current/available version + status badge as a section in the panel. The
|
|
// fleet rows omit it (the row head already shows the version).
|
|
let versionSection = '';
|
|
if (opts.includeVersion) {
|
|
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
|
|
? `<span class="updater-badge updater-badge-update">update available</span>`
|
|
: (a.scanned ? `<span class="updater-badge updater-badge-ok">up to date</span>` : `<span class="updater-badge updater-badge-unknown">unscanned</span>`);
|
|
versionSection = `<div class="updater-detail-section"><h4>Version</h4>
|
|
<div class="updater-detail-row">${badge} <span class="updater-row-ver">${cur}${avail ? ` <span class="updater-arrow">→</span> <strong>${avail}</strong>` : ''}</span></div></div>`;
|
|
}
|
|
const cves = a.cves || [];
|
|
const cveItems = cves.map((c) => `
|
|
<div class="updater-cve sev-${(c.severity || 'low').toLowerCase()}">
|
|
<span class="updater-cve-sev">${this.escape((c.severity || '').toUpperCase())}</span>
|
|
<a class="updater-cve-id" href="${this.escape(c.url || ('https://nvd.nist.gov/vuln/detail/' + (c.id || '')))}" target="_blank" rel="noopener">${this.escape(c.id || 'CVE')}</a>
|
|
<span class="updater-cve-pkg">${this.escape(c.package || '')}</span>
|
|
${c.fixed_in ? `<span class="updater-cve-fix">fixed in ${this.escape(c.fixed_in)}</span>` : ''}
|
|
</div>`).join('');
|
|
const security = `<div class="updater-detail-section"><h4>Security</h4>${
|
|
cves.length ? cveItems : '<p class="updater-detail-empty">No known CVEs. 🎉</p>'}</div>`;
|
|
|
|
// A rollback target exists if a snapshot field is present (future-proofing)
|
|
// OR — the data the generator actually emits today — this app has a prior
|
|
// update/rollback in history (updater_apply always snapshots first, so that
|
|
// pre-update snapshot is the rollback point). Gating only on the never-
|
|
// written last_snapshot* fields would make the Roll back button unreachable.
|
|
const priorUpdate = ((this.history && this.history.entries) || [])
|
|
.some((e) => e.app === a.name && (e.action === 'update' || e.action === 'rollback'));
|
|
const can = !!a.last_snapshot || priorUpdate;
|
|
const snap = a.last_snapshot
|
|
? `${this.escape(a.last_snapshot_version || '')} · ${this.fmtRel(a.last_snapshot_at)}`
|
|
: (priorUpdate
|
|
? 'Roll back to the snapshot taken before the last update.'
|
|
: 'A recovery snapshot is taken automatically before the next update.');
|
|
const recovery = `<div class="updater-detail-section"><h4>Recovery</h4>
|
|
<div class="updater-detail-row"><span class="updater-badge ${can ? 'updater-badge-ok' : 'updater-badge-unknown'}">${can ? 'recoverable' : 'protected'}</span>
|
|
<span class="updater-detail-meta">${snap}</span>
|
|
${can ? `<button class="updater-btn" data-updater-action="rollback" data-app="${this.escape(a.name)}">Roll back</button>` : ''}</div></div>`;
|
|
|
|
const entries = ((this.history && this.history.entries) || []).filter((e) => e.app === a.name).slice(0, 8);
|
|
const history = entries.length ? `<div class="updater-detail-section"><h4>History</h4>${entries.map((e) => `
|
|
<div class="updater-detail-row"><span class="updater-badge ${e.result === 'ok' ? 'updater-badge-ok' : (e.result === 'rolled-back' ? 'updater-badge-update' : 'sev-high')}">${this.escape(e.action)}${e.result ? ' · ' + this.escape(e.result) : ''}</span>
|
|
<span class="updater-detail-meta">${this.escape(e.from || '')}${e.to ? ` → ${this.escape(e.to)}` : ''}</span>
|
|
<span class="updater-detail-meta">${this.fmtRel(e.ts)}</span></div>`).join('')}</div>` : '';
|
|
|
|
return `<div class="updater-detail">${versionSection}${security}${recovery}${history}</div>`;
|
|
}
|
|
|
|
empty(msg, withCheck) {
|
|
return `<div class="updater-empty">${this.escape(msg)}${withCheck ? `<div><button class="updater-btn updater-btn-primary" data-updater-action="check">Check now</button></div>` : ''}</div>`;
|
|
}
|
|
|
|
// ---- 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;
|