feat(ui): order projects by their newest problem

Projects now sort by the newest alert they hold, whether a failing workflow, a waiting pull request or a stale issue. Projects with equal ages keep the server order.
This commit is contained in:
2026-10-03 00:14:23 +01:00
parent 8cbc495dc7
commit 11c7e0f2cb
3 changed files with 62 additions and 7 deletions
+1 -1
View File
@@ -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.
+55 -4
View File
@@ -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([])
})
+6 -2
View File
@@ -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<string, Row>()
const order = new Map<string, string[]>()
@@ -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 {