librelad d39852aa3d refactor(webui): reorganize into components/ + core/ taxonomy
Final modularization layout (user-chosen): every page is a self-contained
folder under components/<id>/ (controllers + CSS + its html fragment), and all
shared/framework code folds into core/:
  core/kernel  (feature-registry, lifecycle, services, spa)
  core/boot    (auth, system-loader/orchestrator, setup, loaders)
  core/lib     (data-loader, router, helpers, the task kernel, shared modules)
  core/ui      (topbar, modal, notifications, … + topbar.html)
  core/css     (all shared stylesheets)
  core/icons
Top level is now just: components/, core/, themes/, index.html (+ runtime data/).

Every path reference rewritten (index.html, scripts arrays, fetch()/
loadFragment()/loadScript() literals, system-loader + config-manager controller
paths, kernel manifest URL, feature.json, backend FEATURES_DIR). The
/api/features/list endpoint NAME is unchanged (it now scans components/).
Deleted 3 dead files (app-content.html, apps-content.html, html-cache.js).
Verified: 0 stale prefixes, 0 double-rewrites, all JS/JSON valid.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-30 07:13:52 +01:00

349 lines
17 KiB
JavaScript

// 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) => `
<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('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. Run <strong>Check now</strong> to fetch versions & vulnerabilities.</div>`}`;
}
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
? `<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>`;
}
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 => `
<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>`;
}
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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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;