From 7ab60a801d21be630b0c80a54ce18085b88b7a69 Mon Sep 17 00:00:00 2001 From: librelad Date: Thu, 16 Jul 2026 17:28:07 +0100 Subject: [PATCH] feat(updater): scrollable, worst-first CVE lists so long lists stay compact An app with many CVEs (e.g. Trivy's 28) rendered every row full-height, pushing the page down. Extract one renderCveList() shared by the standalone Security tab and the per-app expander: sort worst-severity-first, and once past ~6 rows cap the height (260px) with an internal scroll + themed scrollbar and a bottom fade hint. Add a count pill to the expander's "Security" heading. Per-app sections stay stacked and independently collapsible as before. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../components/updater/css/updater.css | 29 ++++++++++++ .../components/updater/js/updater-page.js | 46 +++++++++++-------- 2 files changed, 56 insertions(+), 19 deletions(-) diff --git a/containers/libreportal/frontend/components/updater/css/updater.css b/containers/libreportal/frontend/components/updater/css/updater.css index 71bfb36..02cb4a4 100644 --- a/containers/libreportal/frontend/components/updater/css/updater.css +++ b/containers/libreportal/frontend/components/updater/css/updater.css @@ -88,6 +88,35 @@ .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; } +/* Count pill next to the "Security" detail heading (e.g. "Security 28"). */ +.updater-cve-count { + display: inline-block; vertical-align: middle; margin-left: 4px; + font-size: 0.68rem; font-weight: 700; line-height: 1; padding: 3px 8px; + border-radius: 999px; + background: rgba(var(--page-verify-rgb, var(--accent-rgb)), 0.16); + color: rgb(var(--page-verify-rgb, var(--accent-rgb))); +} + +/* A long CVE list stays compact: once past a handful of rows it caps its height + and scrolls internally rather than pushing the whole page down. The first row + already carries a top border, so no seam is needed at the box edge. */ +.updater-cve-scroll.is-scrollable { + max-height: 260px; overflow-y: auto; + padding-right: 8px; margin-right: -4px; + scrollbar-width: thin; + scrollbar-color: rgba(var(--text-rgb), 0.28) transparent; + /* Bottom-only fade hints there's more below; the top stays crisp so the + worst-severity rows (sorted first) are never dimmed. */ + -webkit-mask-image: linear-gradient(to bottom, #000 calc(100% - 18px), transparent 100%); + mask-image: linear-gradient(to bottom, #000 calc(100% - 18px), transparent 100%); +} +.updater-cve-scroll.is-scrollable::-webkit-scrollbar { width: 8px; } +.updater-cve-scroll.is-scrollable::-webkit-scrollbar-track { background: transparent; } +.updater-cve-scroll.is-scrollable::-webkit-scrollbar-thumb { + background: rgba(var(--text-rgb), 0.22); border-radius: 999px; +} +.updater-cve-scroll.is-scrollable::-webkit-scrollbar-thumb:hover { background: rgba(var(--text-rgb), 0.34); } + /* ---- Buttons ---- */ .updater-btn { display: inline-flex; align-items: center; gap: 6px; diff --git a/containers/libreportal/frontend/components/updater/js/updater-page.js b/containers/libreportal/frontend/components/updater/js/updater-page.js index 1bc8de4..e3d6aa0 100644 --- a/containers/libreportal/frontend/components/updater/js/updater-page.js +++ b/containers/libreportal/frontend/components/updater/js/updater-page.js @@ -183,6 +183,27 @@ class UpdaterPage { return null; } + sevRank(s) { + const r = { critical: 0, high: 1, medium: 2, low: 3 }; + return r[(s || '').toLowerCase()] ?? 4; + } + + // One CVE list, shared by the standalone Security tab and the per-app expander. + // Sorted worst-first (so the most severe are visible before any scroll) and + // wrapped in a height-capped scroll box once the list is long, so an app with + // dozens of CVEs (e.g. 28) stays compact instead of pushing the page down. + renderCveList(cves) { + const list = [...(cves || [])].sort((x, y) => this.sevRank(x.severity) - this.sevRank(y.severity)); + const items = list.map(c => ` +
+ ${this.escape((c.severity || '').toUpperCase())} + ${this.escape(c.id || 'CVE')} + ${this.escape(c.package || '')} + ${c.fixed_in ? `fixed in ${this.escape(c.fixed_in)}` : ''} +
`).join(''); + return `
${items}
`; + } + // The CVE scanner's live state, stamped on cves.json by the updater generator: // 'ready' — Trivy's vulnerability DB is present; results are real // 'db_updating' — Trivy is installed but still downloading its DB (no @@ -417,16 +438,9 @@ class UpdaterPage { // 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 => ` -
- ${this.escape((c.severity || '').toUpperCase())} - ${this.escape(c.id || 'CVE')} - ${this.escape(c.package || '')} - ${c.fixed_in ? `fixed in ${this.escape(c.fixed_in)}` : ''} -
`).join(''); - return `
${this.escape(a.displayName)} ${(a.cves || []).length}
${items}
`; - }).join(''); + const blocks = withCves.map(a => + `
${this.escape(a.displayName)} ${(a.cves || []).length}
${this.renderCveList(a.cves)}
` + ).join(''); return `
${blocks}
`; } @@ -480,15 +494,9 @@ class UpdaterPage {
${badge} ${cur}${avail ? ` ${avail}` : ''}
`; } const cves = a.cves || []; - const cveItems = cves.map((c) => ` -
- ${this.escape((c.severity || '').toUpperCase())} - ${this.escape(c.id || 'CVE')} - ${this.escape(c.package || '')} - ${c.fixed_in ? `fixed in ${this.escape(c.fixed_in)}` : ''} -
`).join(''); - const security = `

Security

${ - cves.length ? cveItems : '

No known CVEs. 🎉

'}
`; + const security = `

Security${ + cves.length ? ` ${cves.length}` : ''}

${ + cves.length ? this.renderCveList(cves) : '

No known CVEs. 🎉

'}
`; // 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