feat(webui): add App Updater feature (versions, CVEs, disaster recovery)

New self-contained feature in features/updater/ (mirrors the backup feature):
- index.js + feature.json (auto-discovered; routes /updater + sub-tabs).
- updater-page.js: 5 tabs — Overview (update/CVE/recovery counts), Updates
  (per-app current->available + Update/Update-all), Security (CVEs by severity,
  links to NVD), Recovery (per-app rollback points; snapshot-before-update),
  History. Reads /data/updater/generated/{updates,cves,history}.json; falls
  back to the installed-apps list so it's useful before the first scan. All
  actions route through services.tasks (updater_check/apply/apply_all/rollback)
  — no new mutating API.
- updater.css (self-contained, teal --page-updater hue) + updater-content.html.
- New topbar 'Updates' nav button (nav-updater) + active-highlighting in
  topbar.js + spa.js. Kernel: setupRoutesFromManifest now allows module-only
  features (no legacy handler) — this is the first such feature.

Backend generator + 'libreportal updater' task land next.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
This commit is contained in:
librelad 2026-05-30 02:45:40 +01:00
parent 55eecd6dfe
commit e1794069cb
11 changed files with 608 additions and 4 deletions

View File

@ -47,6 +47,13 @@
"navId": "nav-tasks",
"nav": { "label": "Tasks", "order": 60 }
},
{
"id": "updater",
"routes": ["/updater", "/updater*"],
"module": "/features/updater/index.js",
"navId": "nav-updater",
"nav": { "label": "Updates", "order": 30 }
},
{
"id": "backup",
"routes": ["/backup", "/backup*"],

View File

@ -0,0 +1,9 @@
{
"id": "updater",
"routes": ["/updater", "/updater*"],
"module": "/features/updater/index.js",
"navId": "nav-updater",
"nav": { "label": "Updates", "order": 30 },
"order": 30,
"note": "App Updater — per-app version tracking, CVE/security scanning, and disaster-recovery (snapshot-before-update + rollback). New feature (no legacy handler); routed via its module. Actions go through libreportal updater tasks."
}

View File

@ -0,0 +1,32 @@
// features/updater/index.js — App Updater feature module.
//
// Per-app version tracking + CVE/security scanning + disaster-recovery
// (snapshot-before-update and rollback). Built in the same shape as the backup
// feature: a self-registering module whose mount() lazy-loads the controller,
// renders a fragment, and news the page object; unmount() releases its
// task-refresh registration. All state-changing actions go through the task
// system (libreportal updater …), never a new mutating API.
LP.features.register({
id: 'updater',
routes: ['/updater', '/updater*'],
scripts: ['/features/updater/updater-page.js'],
async mount(ctx) {
await ctx.loadScripts(this.scripts);
const html = await ctx.loadFragment('/html/updater-content.html');
ctx.setContent(html, 'Updates');
if (typeof UpdaterPage === 'undefined') {
throw new Error('UpdaterPage controller failed to load');
}
window.updaterPage = new UpdaterPage(ctx.services);
await window.updaterPage.init();
},
async unmount(ctx) {
// Drop the task-refresh registration so a finished update/rollback task
// doesn't repaint a torn-down page. The page self-guards via
// (window.updaterPage === this); nulling it neutralises any pending work.
try { ctx.services.tasks.refresh && ctx.services.tasks.refresh.unregister('updater'); } catch (_) {}
window.updaterPage = null;
},
});

View File

@ -0,0 +1,348 @@
// 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;

View File

@ -0,0 +1,130 @@
/* App Updater feature styles. Self-contained (does not borrow other features'
classes); uses the shared palette tokens + the --page-updater identity hue
set on #updater-page. Eager-linked from index.html. */
.updater-page { padding: 4px 2px 40px; }
.updater-layout {
display: grid;
grid-template-columns: 210px 1fr;
gap: 22px;
align-items: start;
}
/* Sidebar */
.updater-sidebar {
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px;
border-radius: 14px;
background: rgba(var(--text-rgb), 0.03);
border: 1px solid rgba(var(--text-rgb), 0.07);
}
.updater-sidebar .sidebar-heading {
font-size: 0.7rem; font-weight: 700; letter-spacing: 0.07em; text-transform: uppercase;
color: rgba(var(--text-rgb), 0.45);
padding: 6px 10px 8px;
}
.updater-sidebar .category {
display: flex; align-items: center; gap: 9px;
width: 100%; text-align: left;
padding: 9px 11px; border: 0; border-radius: 9px;
background: transparent; color: rgba(var(--text-rgb), 0.7);
font-size: 0.86rem; font-weight: 500; cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.updater-sidebar .category .cat-ico { opacity: 0.8; font-size: 0.95em; width: 1.1em; text-align: center; }
.updater-sidebar .category:hover { background: rgba(var(--text-rgb), 0.05); color: rgba(var(--text-rgb), 0.95); }
.updater-sidebar .category.active {
background: rgba(var(--page-rgb, var(--accent-rgb)), 0.16);
color: rgb(var(--page-rgb, var(--accent-rgb)));
}
/* Header */
.updater-header { display: flex; align-items: center; gap: 14px; margin-bottom: 18px; }
.updater-header-icon {
display: grid; place-items: center; width: 42px; height: 42px; border-radius: 11px;
color: rgb(var(--page-rgb, var(--accent-rgb)));
background: rgba(var(--page-rgb, var(--accent-rgb)), 0.14);
border: 1px solid rgba(var(--page-rgb, var(--accent-rgb)), 0.3);
}
.updater-header h1 { margin: 0; font-size: 1.5rem; }
.updater-header p { margin: 2px 0 0; color: rgba(var(--text-rgb), 0.55); font-size: 0.86rem; }
/* Stat grid (overview) */
.updater-stat-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 14px; }
.updater-stat {
position: relative; padding: 18px; border-radius: 14px;
background: rgba(var(--text-rgb), 0.035);
border: 1px solid rgba(var(--page-rgb, var(--accent-rgb)), 0.22);
}
.updater-stat-big { font-size: 2rem; font-weight: 700; line-height: 1; color: rgb(var(--page-rgb, var(--accent-rgb))); }
.updater-stat-label { margin-top: 6px; font-weight: 600; font-size: 0.9rem; }
.updater-stat-sub { color: rgba(var(--text-rgb), 0.5); font-size: 0.78rem; margin-top: 2px; }
.updater-stat .updater-btn { margin-top: 12px; }
/* Lists / rows */
.updater-toolbar { display: flex; gap: 10px; margin-bottom: 14px; }
.updater-list { display: flex; flex-direction: column; gap: 8px; }
.updater-row {
display: grid; grid-template-columns: 1fr auto auto; align-items: center; gap: 14px;
padding: 12px 15px; border-radius: 11px;
background: rgba(var(--text-rgb), 0.035);
border: 1px solid rgba(var(--text-rgb), 0.07);
}
.updater-row-name { font-weight: 600; }
.updater-row-ver { color: rgba(var(--text-rgb), 0.6); font-family: var(--font-mono); font-size: 0.8rem; }
.updater-arrow { color: rgb(var(--page-rgb, var(--accent-rgb))); }
/* Badges */
.updater-badge {
display: inline-block; padding: 1px 8px; border-radius: 999px;
font-size: 0.68rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em;
vertical-align: middle; margin-left: 6px;
}
.updater-badge-update { background: rgba(var(--page-updater-rgb), 0.18); color: rgb(var(--page-updater-rgb)); }
.updater-badge-ok { background: rgba(var(--page-verify-rgb), 0.16); color: rgb(var(--page-verify-rgb)); }
.updater-badge-unknown { background: rgba(var(--text-rgb), 0.1); color: rgba(var(--text-rgb), 0.55); }
.sev-critical { background: rgba(220, 38, 38, 0.18); color: #f87171; }
.sev-high { background: rgba(234, 88, 12, 0.18); color: #fb923c; }
.sev-medium { background: rgba(234, 179, 8, 0.16); color: #fcd34d; }
.sev-low { background: rgba(var(--text-rgb), 0.1); color: rgba(var(--text-rgb), 0.6); }
/* CVE blocks */
.updater-cve-app { padding: 12px 15px; border-radius: 11px; background: rgba(var(--text-rgb), 0.035); border: 1px solid rgba(var(--text-rgb), 0.07); }
.updater-cve-app-name { font-weight: 600; margin-bottom: 8px; }
.updater-cve { display: flex; align-items: center; gap: 10px; padding: 6px 0; border-top: 1px solid rgba(var(--text-rgb), 0.06); font-size: 0.82rem; }
.updater-cve-sev { font-weight: 700; font-size: 0.66rem; min-width: 58px; }
.updater-cve.sev-critical .updater-cve-sev { color: #f87171; }
.updater-cve.sev-high .updater-cve-sev { color: #fb923c; }
.updater-cve.sev-medium .updater-cve-sev { color: #fcd34d; }
.updater-cve-id { font-family: var(--font-mono); color: rgb(var(--page-rgb, var(--accent-rgb))); text-decoration: none; }
.updater-cve-id:hover { text-decoration: underline; }
.updater-cve-pkg { color: rgba(var(--text-rgb), 0.6); }
.updater-cve-fix { margin-left: auto; color: rgba(var(--page-verify-rgb), 0.9); font-size: 0.76rem; }
/* Buttons */
.updater-btn {
padding: 7px 14px; border-radius: 9px; cursor: pointer; font-size: 0.82rem; font-weight: 600;
background: rgba(var(--text-rgb), 0.07); color: rgba(var(--text-rgb), 0.9);
border: 1px solid rgba(var(--text-rgb), 0.12); transition: background 0.15s, border-color 0.15s;
}
.updater-btn:hover { background: rgba(var(--text-rgb), 0.12); }
.updater-btn-primary {
background: rgba(var(--page-rgb, var(--accent-rgb)), 0.2);
border-color: rgba(var(--page-rgb, var(--accent-rgb)), 0.45);
color: rgb(var(--page-rgb, var(--accent-rgb)));
}
.updater-btn-primary:hover { background: rgba(var(--page-rgb, var(--accent-rgb)), 0.3); }
/* Hints / empty states */
.updater-hint { padding: 12px 15px; border-radius: 11px; margin-bottom: 14px; font-size: 0.84rem;
background: rgba(var(--page-rgb, var(--accent-rgb)), 0.08); border: 1px solid rgba(var(--page-rgb, var(--accent-rgb)), 0.2); color: rgba(var(--text-rgb), 0.75); }
.updater-empty { padding: 40px 20px; text-align: center; color: rgba(var(--text-rgb), 0.55); display: flex; flex-direction: column; gap: 14px; align-items: center; }
@media (max-width: 820px) {
.updater-layout { grid-template-columns: 1fr; }
.updater-sidebar { flex-direction: row; flex-wrap: wrap; }
.updater-row { grid-template-columns: 1fr; gap: 6px; }
}

View File

@ -65,6 +65,15 @@
</svg>
Backups
</a>
<a href="/updater" class="nav-item" id="nav-updater">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<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>
Updates
</a>
</nav>
<div class="mobile-drawer-page-section" id="mobile-drawer-page-section"></div>
<div class="topbar-controls">

View File

@ -0,0 +1,49 @@
<!-- App Updater page shell. Panels are filled by features/updater/updater-page.js.
Page identity hue: --page-updater (teal), set on the root below. -->
<div id="updater-page" class="updater-page admin-page" style="--page: var(--page-updater); --page-rgb: var(--page-updater-rgb);">
<div class="updater-layout">
<aside class="sidebar updater-sidebar">
<div class="sidebar-heading">App Updater</div>
<button class="category active" data-updater-tab="overview">
<span class="cat-ico"></span> Overview
</button>
<button class="category" data-updater-tab="updates">
<span class="cat-ico"></span> Updates
</button>
<button class="category" data-updater-tab="security">
<span class="cat-ico">🛡</span> Security
</button>
<button class="category" data-updater-tab="recovery">
<span class="cat-ico"></span> Recovery
</button>
<button class="category" data-updater-tab="history">
<span class="cat-ico">🗒</span> History
</button>
</aside>
<section class="updater-content">
<header class="updater-header">
<div class="updater-header-icon" aria-hidden="true">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<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>
</div>
<div>
<h1 id="updater-section-title">Overview</h1>
<p id="updater-section-subtitle">Update health, security posture, and recovery readiness at a glance.</p>
</div>
</header>
<div class="updater-panels">
<div class="updater-tabpanel active" id="updater-panel-overview"></div>
<div class="updater-tabpanel" id="updater-panel-updates"></div>
<div class="updater-tabpanel" id="updater-panel-security"></div>
<div class="updater-tabpanel" id="updater-panel-recovery"></div>
<div class="updater-tabpanel" id="updater-panel-history"></div>
</div>
</section>
</div>
</div>

View File

@ -36,6 +36,7 @@
<link rel="stylesheet" href="/features/apps/service-buttons.css">
<link rel="stylesheet" href="/features/dashboard/dashboard.css">
<link rel="stylesheet" href="/features/tasks/tasks.css">
<link rel="stylesheet" href="/features/updater/updater.css">
<link rel="stylesheet" href="/css/update-notifier.css">
<script>
// Inline data-theme bootstrap — runs before any rendering so the right

View File

@ -26,6 +26,9 @@ class TopbarComponent {
if (path.startsWith('/backup') || path === '/backup') {
return 'backup';
}
if (path.startsWith('/updater') || path === '/updater') {
return 'updater';
}
if (path === '/' || path === '/dashboard') {
return 'dashboard';
}
@ -436,6 +439,8 @@ class TopbarComponent {
activeNavId = 'nav-tasks';
} else if (path.startsWith('/backup')) {
activeNavId = 'nav-backup';
} else if (path.startsWith('/updater')) {
activeNavId = 'nav-updater';
} else if (path === '/' || path === '/dashboard') {
activeNavId = 'nav-dashboard';
} else {
@ -450,6 +455,8 @@ class TopbarComponent {
activeNavId = 'nav-tasks';
} else if (this.currentPage === 'backup') {
activeNavId = 'nav-backup';
} else if (this.currentPage === 'updater') {
activeNavId = 'nav-updater';
} else {
activeNavId = 'nav-dashboard'; // default
}
@ -517,6 +524,8 @@ class TopbarComponent {
activeNavId = 'nav-tasks';
} else if (path.startsWith('/backup')) {
activeNavId = 'nav-backup';
} else if (path.startsWith('/updater')) {
activeNavId = 'nav-updater';
} else if (path === '/' || path === '/dashboard') {
activeNavId = 'nav-dashboard';
} else {
@ -539,6 +548,9 @@ class TopbarComponent {
case 'backup':
activeNavId = 'nav-backup';
break;
case 'updater':
activeNavId = 'nav-updater';
break;
case 'dashboard':
activeNavId = 'nav-dashboard';
break;

View File

@ -113,11 +113,15 @@ class LibrePortalSPAClean {
await Promise.all(modules.map(src =>
this.loadScript(src).catch(err => console.warn(`[spa] feature module "${src}" failed to load:`, err))
));
// All-or-nothing: a single missing handler means we don't trust the
// manifest enough to route from it — use the known-good built-in table.
// A feature is routable if it has a registered module (mount) OR a legacy
// handler. New features (e.g. updater) ship a module and no handler —
// that's fine. Only bail to the built-in table if an entry has NEITHER.
for (const f of entries) {
if (typeof this[f.handler] !== 'function') {
console.warn(`[spa] manifest handler "${f.handler}" (feature "${f.id}") not found — using built-in routes`);
const m = (window.LP && window.LP.features) ? window.LP.features.get(f.id) : null;
const hasModule = m && typeof m.mount === 'function';
const hasHandler = typeof this[f.handler] === 'function';
if (!hasModule && !hasHandler) {
console.warn(`[spa] feature "${f.id}" has neither a registered module nor a legacy handler — using built-in routes`);
this.setupRoutes();
return;
}
@ -609,6 +613,8 @@ class LibrePortalSPAClean {
activeId = 'nav-tasks';
} else if (path.startsWith('/backup')) {
activeId = 'nav-backup';
} else if (path.startsWith('/updater')) {
activeId = 'nav-updater';
} else if (path === '/' || path === '/dashboard') {
activeId = 'nav-dashboard';
}

View File

@ -24,4 +24,5 @@
--page-backups: #4f8cff; --page-backups-rgb: 79, 140, 255; /* Backups — blue */
--page-ssh: #9b7bf0; --page-ssh-rgb: 155, 123, 240;
--page-system: #f0883e; --page-system-rgb: 240, 136, 62;
--page-updater: #2bb6c4; --page-updater-rgb: 43, 182, 196; /* App Updater — teal */
}