From e50adf8f970ab06b879fc27c9c1703f9dcb73aeb Mon Sep 17 00:00:00 2001 From: librelad Date: Thu, 20 Aug 2026 00:03:53 +0100 Subject: [PATCH] Sort the Updates list by actionability, add a Newer filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The list rendered in whatever order the updater emitted, so the handful of rows that actually want a decision sat wherever they happened to land — on a long list, below the fold, under a wall of "up to date". Rows are now ranked by what you can DO about them rather than by how alarming they look, since an app with an Update button is one click from resolved while an app with only a Details link is not: 0 update available, and the automatic attempt already failed 1 update available 2 a newer release line is published 3 CVEs, but nothing to apply 4 never scanned 5 up to date Auto-failed leads because that build was tried and rolled back and will NOT retry itself — it is the only row in the update group that strictly needs a person. Worst severity orders within a rank, then display name, so the ordering is total and stable across repaints. Filtering already existed (All / Updates / Security); this adds the one state that was unreachable. `newer_version` is deliberately excluded from `updates` — its action is Upgrade, inside the row detail, not the row's Update button — so it had no chip of its own despite being the state most likely to need a decision, because it never resolves on its own: automatic updates only track the line you are already on. The chip excludes rows already counted under `updates` so the totals partition the list rather than double-count. Verified against the running WebUI. With real data the three newer-version rows lead (critical, critical, high, then alphabetical), followed by the eight remaining CVE rows and the three clean ones — 3 + 8 matching the Security count of 11. Temporarily flagging two apps update-available confirmed ranks 0 and 1 take the top, with the auto-failed row above the plain one; that data file was restored byte-identical afterwards. Every chip was exercised by clicking it: All 14, Updates 0, Newer 3, Security 11. Co-Authored-By: Claude Opus 5 --- .../apps/overview/js/overview-manager.js | 41 ++++++++++++++++++- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/containers/libreportal/frontend/components/apps/overview/js/overview-manager.js b/containers/libreportal/frontend/components/apps/overview/js/overview-manager.js index be6a851..c9c9989 100644 --- a/containers/libreportal/frontend/components/apps/overview/js/overview-manager.js +++ b/containers/libreportal/frontend/components/apps/overview/js/overview-manager.js @@ -579,8 +579,9 @@ class OverviewManager { renderUpdates() { const up = this.updater; if (!up || !up.apps.length) return `
No installed apps to track.
`; - const shown = this.filterApps(up.apps); + const shown = this.sortApps(this.filterApps(up.apps)); const nUpd = up.apps.filter((a) => a.update_available).length; + const nNew = up.apps.filter((a) => !a.update_available && a.newer_version).length; const nSec = up.apps.filter((a) => (a.cves || []).length).length; const chip = (id, label, n) => ``; @@ -591,17 +592,53 @@ class OverviewManager { return ` ${up.renderAutoCheckLine()}
-
${chip('all', 'All', up.apps.length)}${chip('updates', 'Updates', nUpd)}${chip('security', 'Security', nSec)}
+
${chip('all', 'All', up.apps.length)}${chip('updates', 'Updates', nUpd)}${chip('newer', 'Newer', nNew)}${chip('security', 'Security', nSec)}
${rows}
`; } filterApps(apps) { if (this.filter === 'updates') return apps.filter((a) => a.update_available); + // A published newer release LINE, which `updates` deliberately excludes — its + // action is Upgrade (inside the row detail), not the row's Update button. + // Without this chip the state was unreachable by filtering even though it is + // the one most likely to need a decision: it never resolves on its own, since + // automatic updates only ever track the line you are already on. + // Excludes rows already counted under `updates` so the chip totals partition + // the list instead of double-counting an app that is both. + if (this.filter === 'newer') return apps.filter((a) => !a.update_available && a.newer_version); if (this.filter === 'security') return apps.filter((a) => (a.cves || []).length); return apps; } + // Most actionable first. The list was previously in whatever order the updater + // emitted, so the handful of rows that actually want a decision sat wherever + // they happened to land — on a long list, below the fold. + // + // Ranked by what you can DO about a row, not by how alarming it looks: an app + // with an Update button outranks one with only a Details link, whatever their + // CVE counts, because the first is one click from resolved. Severity then + // orders within a rank, so the worst actionable row leads the page. + sortApps(apps) { + const sev = { critical: 0, high: 1, medium: 2, low: 3 }; + const failed = (a) => !!(this.updater && this.updater.autoAttemptFailed && this.updater.autoAttemptFailed(a)); + const rank = (a) => { + // Auto-failed first inside the update group: that build was already tried + // and rolled back, so unlike its neighbours it will NOT retry itself and a + // person has to press Update. + if (a.update_available) return failed(a) ? 0 : 1; + if (a.newer_version) return 2; + if ((a.cves || []).length) return 3; + if (!a.scanned) return 4; + return 5; + }; + const label = (a) => String(a.displayName || a.name || '').toLowerCase(); + return apps.slice().sort((x, y) => + rank(x) - rank(y) + || (sev[x.worstSeverity] ?? 9) - (sev[y.worstSeverity] ?? 9) + || label(x).localeCompare(label(y))); + } + updateRow(a) { const esc = (s) => this.escape(s); const slug = esc(a.name);