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('')
})
})
+199
View File
@@ -0,0 +1,199 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { fetchState, RescanLimitedError, requestRefresh } from '@/lib/api'
import {
activeFilterCount,
applyFilters,
emptyFilters,
facetCounts,
ownerOf,
repoMatches,
type Filters,
} from '@/lib/filters'
import { DATA_KINDS } from '@/lib/kinds'
import type { Block, Row, TrackerState } from '@/lib/types'
/** While a rescan runs the server takes ~40s, so check back often instead of every minute. */
export const SCAN_POLL_MS = 2000
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. */
export function buildBlocks(state: TrackerState): Block[] {
const rows = new Map<string, Row>()
const order = new Map<string, string[]>()
for (const alert of state.alerts) {
if (alert.severity === 'ok') continue
const key = `${alert.repo}\n${alert.url}`
const existing = rows.get(key)
if (existing) {
existing.detail += ` · ${alert.detail}`
if (SEVERITY_RANK[alert.severity] > SEVERITY_RANK[existing.severity]) {
existing.severity = alert.severity
}
continue
}
rows.set(key, {
kind: alert.kind,
subject: alert.subject,
detail: alert.detail,
url: alert.url,
since: alert.since,
severity: alert.severity,
})
order.set(alert.repo, [...(order.get(alert.repo) ?? []), key])
}
const blocks: Block[] = []
for (const repo of state.repos) {
const keys = order.get(repo.fullName)
if (!keys) continue
const list = keys.map((k) => rows.get(k)!)
list.sort((a, b) => SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity])
blocks.push({ repo, rows: list })
}
return blocks
}
export interface RepoCounts {
crit: number
warn: number
}
export interface Facts {
failingWorkflows: number
waitingPrs: number
failingChecks: number
staleIssues: number
dataProblems: number
}
/** Counts the alerts of each severity per repo, for the fleet strip. */
export function countByRepo(state: TrackerState): Map<string, RepoCounts> {
const out = new Map<string, RepoCounts>()
for (const a of state.alerts) {
if (a.severity === 'ok') continue
const c = out.get(a.repo) ?? { crit: 0, warn: 0 }
c[a.severity] += 1
out.set(a.repo, c)
}
return out
}
/** Headline numbers for the overview; issue counts come from the repos, not alert lines. */
export function computeFacts(state: TrackerState): Facts {
const count = (kinds: string[]) => state.alerts.filter((a) => kinds.includes(a.kind)).length
return {
failingWorkflows: state.repos.reduce((n, r) => n + r.workflows.failing, 0),
waitingPrs: count(['pr_waiting']),
failingChecks: state.repos.reduce((n, r) => n + r.prs.failingChecks, 0),
staleIssues: state.repos.reduce((n, r) => n + r.issues.stale, 0),
dataProblems: count(DATA_KINDS),
}
}
export const useTrackerStore = defineStore('tracker', () => {
const state = ref<TrackerState | null>(null)
const error = ref('')
const loading = ref(false)
const requesting = ref(false)
const notice = ref('')
const skewMs = ref(0)
const tick = ref(Date.now())
/** Server time advanced by the local clock, so ages stay right when the clocks differ. */
const nowMs = computed(() => tick.value + skewMs.value)
/** True from the click until the server finished the new scan. */
const scanning = computed(() => requesting.value || (state.value?.refreshing ?? false))
const blocks = computed(() => (state.value ? buildBlocks(state.value) : []))
const filters = ref<Filters>(emptyFilters())
const visibleBlocks = computed(() => applyFilters(blocks.value, filters.value))
const owners = computed(() => [
...new Set((state.value?.repos ?? []).map((r) => ownerOf(r.fullName))),
])
const facets = computed(() => facetCounts(blocks.value, filters.value, owners.value))
/** Repos that match by name or version, plus repos owning an item the search matched. */
const visibleRepos = computed(() => {
const withItems = new Set(visibleBlocks.value.map((b) => b.repo.fullName))
return (state.value?.repos ?? []).filter(
(r) =>
repoMatches(r, filters.value) ||
(filters.value.query.trim() !== '' && withItems.has(r.fullName)),
)
})
const activeFilters = computed(() => activeFilterCount(filters.value))
const repoCounts = computed(() => (state.value ? countByRepo(state.value) : new Map()))
const facts = computed(() => (state.value ? computeFacts(state.value) : null))
function toggleRepo(fullName: string): void {
filters.value.repo = filters.value.repo === fullName ? null : fullName
}
function clearFilters(): void {
filters.value = emptyFilters()
}
function advanceClock(): void {
tick.value = Date.now()
}
async function load(): Promise<void> {
loading.value = true
try {
const next = await fetchState()
skewMs.value = Date.parse(next.now) - Date.now()
tick.value = Date.now()
state.value = next
error.value = ''
} catch (e) {
error.value = e instanceof Error ? e.message : String(e)
} finally {
loading.value = false
}
}
/** Asks the server to rescan every repository, then follows it until the new data lands. */
async function rescan(): Promise<void> {
const before = state.value?.lastRefresh ?? ''
requesting.value = true
notice.value = ''
try {
await requestRefresh()
error.value = ''
const deadline = Date.now() + SCAN_TIMEOUT_MS
do {
await load()
if (state.value && state.value.lastRefresh !== before && !state.value.refreshing) break
await new Promise((resolve) => setTimeout(resolve, SCAN_POLL_MS))
} while (Date.now() < deadline)
} catch (e) {
if (e instanceof RescanLimitedError) notice.value = e.message
else error.value = e instanceof Error ? e.message : String(e)
} finally {
requesting.value = false
}
}
return {
state,
error,
loading,
notice,
scanning,
nowMs,
blocks,
filters,
owners,
facets,
visibleRepos,
activeFilters,
visibleBlocks,
repoCounts,
facts,
toggleRepo,
clearFilters,
load,
rescan,
advanceClock,
}
})