Merge claude/1
This commit is contained in:
commit
56f0111d5f
@ -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*"],
|
||||
|
||||
@ -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."
|
||||
}
|
||||
32
containers/libreportal/frontend/features/updater/index.js
Normal file
32
containers/libreportal/frontend/features/updater/index.js
Normal 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;
|
||||
},
|
||||
});
|
||||
348
containers/libreportal/frontend/features/updater/updater-page.js
Normal file
348
containers/libreportal/frontend/features/updater/updater-page.js
Normal 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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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;
|
||||
130
containers/libreportal/frontend/features/updater/updater.css
Normal file
130
containers/libreportal/frontend/features/updater/updater.css
Normal 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; }
|
||||
}
|
||||
@ -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">
|
||||
|
||||
49
containers/libreportal/frontend/html/updater-content.html
Normal file
49
containers/libreportal/frontend/html/updater-content.html
Normal 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>
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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';
|
||||
}
|
||||
|
||||
@ -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 */
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user