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);