diff --git a/README.md b/README.md index d153b29..eba5e73 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Release archives and the image checksums are signed with cosign. ## What it shows -The dashboard lists only what needs attention, grouped by project: +The dashboard lists only what needs attention, grouped by project. The project with the newest problem comes first: - Failing workflows, including scheduled and other background ones, with a link to the run. - Pull requests that wait too long or fail their checks, with links and age. diff --git a/src/stores/tracker.test.ts b/src/stores/tracker.test.ts index 3d15e5a..03fff06 100644 --- a/src/stores/tracker.test.ts +++ b/src/stores/tracker.test.ts @@ -44,18 +44,69 @@ describe('buildBlocks', () => { expect(buildBlocks(s)[0].rows.map((r) => r.subject)).toEqual(['crit row', 'warn row']) }) - it('keeps the repo order from the server and ignores alerts for unknown repos', () => { + it('keeps the server order for equal ages and ignores alerts for unknown repos', () => { + const since = '2026-10-01T00:00:00Z' const s = state({ repos: [repo({ fullName: 'o/b' }), repo({ fullName: 'o/a' })], alerts: [ - alert({ repo: 'o/a', url: '1' }), - alert({ repo: 'o/b', url: '2' }), - alert({ repo: 'o/ghost', url: '3' }), + alert({ repo: 'o/a', url: '1', since }), + alert({ repo: 'o/b', url: '2', since }), + alert({ repo: 'o/ghost', url: '3', since }), ], }) expect(buildBlocks(s).map((b) => b.repo.fullName)).toEqual(['o/b', 'o/a']) }) + it('puts the repo with the newest problem first, whatever its kind or severity', () => { + const s = state({ + repos: [ + repo({ fullName: 'o/old-crit', health: 'crit' }), + repo({ fullName: 'o/new-warn', health: 'warn' }), + repo({ fullName: 'o/mid', health: 'crit' }), + ], + alerts: [ + alert({ + repo: 'o/old-crit', + url: 'a', + severity: 'crit', + kind: 'pr_waiting', + since: '2026-05-19T00:00:00Z', + }), + alert({ + repo: 'o/new-warn', + url: 'b', + severity: 'warn', + kind: 'workflow_stuck', + since: '2026-10-02T12:00:00Z', + }), + alert({ + repo: 'o/mid', + url: 'c', + severity: 'crit', + kind: 'workflow_failed', + since: '2026-09-20T00:00:00Z', + }), + ], + }) + expect(buildBlocks(s).map((b) => b.repo.fullName)).toEqual([ + 'o/new-warn', + 'o/mid', + 'o/old-crit', + ]) + }) + + it('ranks a repo by its newest alert, not its oldest', () => { + const s = state({ + repos: [repo({ fullName: 'o/a', health: 'crit' }), repo({ fullName: 'o/b', health: 'crit' })], + alerts: [ + alert({ repo: 'o/a', url: 'a1', since: '2026-01-01T00:00:00Z' }), + alert({ repo: 'o/a', url: 'a2', since: '2026-10-02T00:00:00Z' }), + alert({ repo: 'o/b', url: 'b1', since: '2026-06-01T00:00:00Z' }), + ], + }) + expect(buildBlocks(s).map((b) => b.repo.fullName)).toEqual(['o/a', 'o/b']) + }) + it('returns no blocks when everything is healthy', () => { expect(buildBlocks(state({ repos: [repo()] }))).toEqual([]) }) diff --git a/src/stores/tracker.ts b/src/stores/tracker.ts index b7908ce..17fe232 100644 --- a/src/stores/tracker.ts +++ b/src/stores/tracker.ts @@ -19,7 +19,9 @@ const SCAN_TIMEOUT_MS = 10 * 60 * 1000 const SEVERITY_RANK = { ok: 0, warn: 1, crit: 2 } as const -/** Groups alerts into one row per target URL and one block per unhealthy repo. */ +const sinceMs = (since: string) => Date.parse(since) || 0 + +/** Groups alerts into one row per target URL and one block per unhealthy repo, newest problem first. */ export function buildBlocks(state: TrackerState): Block[] { const rows = new Map() const order = new Map() @@ -32,6 +34,7 @@ export function buildBlocks(state: TrackerState): Block[] { if (SEVERITY_RANK[alert.severity] > SEVERITY_RANK[existing.severity]) { existing.severity = alert.severity } + if (sinceMs(alert.since) > sinceMs(existing.since)) existing.since = alert.since continue } rows.set(key, { @@ -52,7 +55,8 @@ export function buildBlocks(state: TrackerState): Block[] { list.sort((a, b) => SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity]) blocks.push({ repo, rows: list }) } - return blocks + const newest = (b: Block) => Math.max(...b.rows.map((r) => sinceMs(r.since))) + return blocks.sort((a, b) => newest(b) - newest(a)) } export interface RepoCounts {