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('')
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user