feat: add GitHub repository dashboard with Prometheus metrics

ghtracker authenticates as a GitHub App and scans a configured list of repositories on a schedule. It serves a dashboard, a JSON state endpoint, a health probe and Prometheus metrics.

The dashboard lists only items that need attention: failing workflows, pull requests that wait too long or fail their checks, stale issues and projects that failed to scan. It has search, dynamic filters and a button that rescans on demand. The Vue app is embedded in the Go binary.

The client protects the GitHub rate limit with conditional requests, a request reserve and Retry-After handling.
This commit is contained in:
2026-10-02 04:28:52 +01:00
commit 5313b56774
110 changed files with 10967 additions and 0 deletions
+213
View File
@@ -0,0 +1,213 @@
import { createPinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { alert, mixedState, repo, state } from '@/test/fixtures'
import { buildBlocks, computeFacts, countByRepo, SCAN_POLL_MS, useTrackerStore } from './tracker'
function mockFetch(handler: (url: string, init?: RequestInit) => Response | Promise<Response>) {
const fn = vi.fn((url: string, init?: RequestInit) => Promise.resolve(handler(url, init)))
vi.stubGlobal('fetch', fn)
return fn
}
const json = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } })
describe('buildBlocks', () => {
it('shows only unhealthy repos and merges alerts on the same target', () => {
const blocks = buildBlocks(mixedState())
expect(blocks.map((b) => b.repo.fullName)).toEqual(['o/r'])
const rows = blocks[0].rows
expect(rows).toHaveLength(2)
const pr = rows.find((r) => r.url.endsWith('/pull/5'))!
expect(pr.severity).toBe('crit')
expect(pr.detail).toBe('open 9d · checks failing: build')
})
it('keeps the worst severity when a warning arrives first', () => {
const s = state({
repos: [repo({ health: 'crit' })],
alerts: [
alert({ severity: 'warn', url: 'u', detail: 'a' }),
alert({ severity: 'crit', url: 'u', detail: 'b' }),
],
})
expect(buildBlocks(s)[0].rows[0].severity).toBe('crit')
})
it('orders rows by severity, critical first', () => {
const s = state({
repos: [repo({ health: 'crit' })],
alerts: [
alert({ severity: 'warn', url: 'w', subject: 'warn row' }),
alert({ severity: 'crit', url: 'c', subject: 'crit row' }),
],
})
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', () => {
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' }),
],
})
expect(buildBlocks(s).map((b) => b.repo.fullName)).toEqual(['o/b', 'o/a'])
})
it('returns no blocks when everything is healthy', () => {
expect(buildBlocks(state({ repos: [repo()] }))).toEqual([])
})
})
describe('overview helpers', () => {
it('counts alerts of each severity per repo', () => {
const counts = countByRepo(mixedState())
expect(counts.get('o/r')).toEqual({ crit: 2, warn: 1 })
expect(counts.has('o/fine')).toBe(false)
})
it('derives headline facts from repos and alert kinds', () => {
const s = mixedState()
s.alerts.push(
alert({ kind: 'partial_data', severity: 'warn', url: 'x', subject: 'Incomplete data' }),
)
expect(computeFacts(s)).toEqual({
failingWorkflows: 1,
waitingPrs: 1,
failingChecks: 1,
staleIssues: 2,
dataProblems: 1,
})
})
})
describe('tracker store', () => {
beforeEach(() => setActivePinia(createPinia()))
afterEach(() => vi.unstubAllGlobals())
it('loads state and derives blocks', async () => {
mockFetch(() => json(mixedState()))
const store = useTrackerStore()
await store.load()
expect(store.error).toBe('')
expect(store.state?.repos).toHaveLength(2)
expect(store.blocks).toHaveLength(1)
expect(store.loading).toBe(false)
})
it('keeps the last good state and reports the error when a poll fails', async () => {
const fetchMock = mockFetch(() => json(mixedState()))
const store = useTrackerStore()
await store.load()
fetchMock.mockImplementationOnce(() => Promise.resolve(new Response('boom', { status: 500 })))
await store.load()
expect(store.error).toContain('HTTP 500')
expect(store.state?.repos).toHaveLength(2)
})
it('reports network failures', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('offline')))
const store = useTrackerStore()
await store.load()
expect(store.error).toBe('offline')
expect(store.state).toBeNull()
})
it('toggles the focused repo and exposes filtered blocks and facets', async () => {
mockFetch(() => json(mixedState()))
const store = useTrackerStore()
await store.load()
store.toggleRepo('o/fine')
expect(store.filters.repo).toBe('o/fine')
expect(store.visibleBlocks).toEqual([])
store.toggleRepo('o/fine')
expect(store.filters.repo).toBeNull()
store.filters.severity = 'warn'
expect(store.visibleBlocks).toEqual([])
expect(store.facets.severity.all).toBe(store.facets.severity.crit + store.facets.severity.warn)
store.filters.severity = 'crit'
expect(store.visibleBlocks).toHaveLength(1)
expect(store.activeFilters).toBe(1)
expect(store.facts?.failingWorkflows).toBe(1)
expect(store.owners).toEqual(['o'])
})
it('searches projects by name and clears every filter at once', async () => {
mockFetch(() => json(mixedState()))
const store = useTrackerStore()
await store.load()
store.filters.query = 'fine'
expect(store.visibleRepos.map((r) => r.fullName)).toEqual(['o/fine'])
expect(store.visibleBlocks).toEqual([])
store.clearFilters()
expect(store.activeFilters).toBe(0)
expect(store.visibleRepos).toHaveLength(2)
})
it('keeps a project in the list when the search matches one of its items', async () => {
mockFetch(() => json(mixedState()))
const store = useTrackerStore()
await store.load()
store.filters.query = 'bold'
expect(store.visibleRepos.map((r) => r.fullName)).toEqual(['o/r'])
store.filters.owners = ['other']
expect(store.visibleRepos).toEqual([])
})
it('anchors the clock to server time so skewed browsers show right ages', async () => {
mockFetch(() => json(state({ now: '2030-01-01T00:00:00Z' })))
const store = useTrackerStore()
await store.load()
expect(Math.abs(store.nowMs - Date.parse('2030-01-01T00:00:00Z'))).toBeLessThan(2000)
})
it('posts a rescan and follows it until new data arrives', async () => {
vi.useFakeTimers()
try {
const calls: string[] = []
let polls = 0
mockFetch((url, init) => {
calls.push(`${init?.method ?? 'GET'} ${url}`)
if (url === '/refresh') return new Response(null, { status: 202 })
polls += 1
// Poll 1: old data. Poll 2: scan running. Poll 3: finished with a new timestamp.
if (polls === 1) return json(state({ lastRefresh: '2026-10-02T11:00:00Z' }))
if (polls === 2)
return json(state({ lastRefresh: '2026-10-02T11:00:00Z', refreshing: true }))
return json(state({ lastRefresh: '2026-10-02T12:00:00Z' }))
})
const store = useTrackerStore()
await store.load()
const done = store.rescan()
await vi.advanceTimersByTimeAsync(0)
expect(store.scanning).toBe(true)
await vi.advanceTimersByTimeAsync(SCAN_POLL_MS * 3)
await done
expect(store.scanning).toBe(false)
expect(store.state?.lastRefresh).toBe('2026-10-02T12:00:00Z')
expect(calls[0]).toBe('GET /api/state')
expect(calls[1]).toBe('POST /refresh')
} finally {
vi.useRealTimers()
}
})
it('shows a notice, not an error, when a rescan is rate limited', async () => {
mockFetch(() => new Response('slow down', { status: 429 }))
const store = useTrackerStore()
await store.rescan()
expect(store.notice).toContain('once every 5 minutes')
expect(store.error).toBe('')
expect(store.scanning).toBe(false)
})
it('reports other rescan failures as errors', async () => {
mockFetch(() => new Response('boom', { status: 500 }))
const store = useTrackerStore()
await store.rescan()
expect(store.error).toContain('HTTP 500')
expect(store.notice).toBe('')
})
})