mirror of
https://github.com/lukaszraczylo/ghtracker.git
synced 2026-10-15 01:07:40 +00:00
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:
@@ -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('')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user