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
+16
View File
@@ -0,0 +1,16 @@
import type { TrackerState } from './types'
export async function fetchState(signal?: AbortSignal): Promise<TrackerState> {
const res = await fetch('/api/state', { signal, headers: { Accept: 'application/json' } })
if (!res.ok) throw new Error(`state request failed: HTTP ${res.status}`)
return (await res.json()) as TrackerState
}
/** Thrown when the server refuses a rescan because one ran recently. */
export class RescanLimitedError extends Error {}
export async function requestRefresh(): Promise<void> {
const res = await fetch('/refresh', { method: 'POST' })
if (res.status === 429) throw new RescanLimitedError('Rescan is limited to once every 5 minutes')
if (!res.ok) throw new Error(`rescan request failed: HTTP ${res.status}`)
}
+179
View File
@@ -0,0 +1,179 @@
import { describe, expect, it } from 'vitest'
import {
activeFilterCount,
applyFilters,
emptyFilters,
facetCounts,
filtersFromSearch,
filtersToSearch,
kindGroup,
matchesQuery,
repoMatches,
type Filters,
} from './filters'
import { buildBlocks } from '@/stores/tracker'
import { alert, repo, state } from '@/test/fixtures'
const fleet = () =>
buildBlocks(
state({
repos: [
repo({ fullName: 'a/one', health: 'crit' }),
repo({ fullName: 'b/two', health: 'warn', release: null }),
],
alerts: [
alert({
repo: 'a/one',
kind: 'workflow_failed',
severity: 'crit',
subject: 'CI',
url: '1',
}),
alert({
repo: 'a/one',
kind: 'pr_waiting',
severity: 'warn',
subject: '#3 Bump grpc',
url: '2',
}),
alert({
repo: 'b/two',
kind: 'issues_stale',
severity: 'warn',
subject: '4 stale issues',
url: '3',
}),
alert({
repo: 'b/two',
kind: 'partial_data',
severity: 'warn',
subject: 'Incomplete data',
url: '4',
}),
],
}),
)
const withFilters = (over: Partial<Filters>): Filters => ({ ...emptyFilters(), ...over })
describe('kindGroup', () => {
it.each([
['workflow_failed', 'workflow'],
['workflow_stuck', 'workflow'],
['pr_waiting', 'pr'],
['pr_checks_failing', 'pr'],
['issues_stale', 'issue'],
['refresh_failed', 'data'],
['data_stale', 'data'],
['partial_data', 'data'],
['something_new', 'data'],
])('%s -> %s', (kind, group) => expect(kindGroup(kind)).toBe(group))
})
describe('matchesQuery', () => {
it('requires every term, ignores case and accepts an empty query', () => {
expect(matchesQuery('', 'anything')).toBe(true)
expect(matchesQuery(' ', 'anything')).toBe(true)
expect(matchesQuery('bump GRPC', 'o/r', '#3 Bump google.golang.org/grpc')).toBe(true)
expect(matchesQuery('bump nothing', '#3 Bump grpc')).toBe(false)
})
})
describe('applyFilters', () => {
it('returns everything for empty filters', () => {
expect(applyFilters(fleet(), emptyFilters()).flatMap((b) => b.rows)).toHaveLength(4)
})
it('combines severity, kind, owner and search', () => {
const rows = (f: Filters) =>
applyFilters(fleet(), f).flatMap((b) => b.rows.map((r) => r.subject))
expect(rows(withFilters({ severity: 'crit' }))).toEqual(['CI'])
expect(rows(withFilters({ kinds: ['pr', 'issue'] }))).toEqual([
'#3 Bump grpc',
'4 stale issues',
])
expect(rows(withFilters({ owners: ['b'] }))).toEqual(['4 stale issues', 'Incomplete data'])
expect(rows(withFilters({ query: 'grpc' }))).toEqual(['#3 Bump grpc'])
expect(rows(withFilters({ query: 'a/one', kinds: ['pr'] }))).toEqual(['#3 Bump grpc'])
expect(rows(withFilters({ repo: 'b/two', severity: 'crit' }))).toEqual([])
})
it('drops blocks left without rows', () => {
expect(
applyFilters(fleet(), withFilters({ kinds: ['workflow'] })).map((b) => b.repo.fullName),
).toEqual(['a/one'])
})
it('also searches the release tag', () => {
expect(
applyFilters(fleet(), withFilters({ query: 'v1.0.0' })).map((b) => b.repo.fullName),
).toEqual(['a/one'])
})
})
describe('facetCounts', () => {
it('counts each facet with the others applied, but not itself', () => {
const f = withFilters({ kinds: ['pr'] })
const c = facetCounts(fleet(), f, ['a', 'b'])
expect(c.kinds).toEqual({ workflow: 1, pr: 1, issue: 1, data: 1 })
expect(c.severity).toEqual({ all: 1, crit: 0, warn: 1 })
expect(c.owners).toEqual({ a: 1, b: 0 })
})
it('counts everything for empty filters', () => {
const c = facetCounts(fleet(), emptyFilters(), ['a', 'b'])
expect(c.severity).toEqual({ all: 4, crit: 1, warn: 3 })
expect(c.owners).toEqual({ a: 2, b: 2 })
})
})
describe('repoMatches', () => {
it('uses the query and owner filters only', () => {
const r = repo({ fullName: 'a/one', description: 'Kubernetes mirror' })
expect(repoMatches(r, withFilters({ query: 'mirror' }))).toBe(true)
expect(repoMatches(r, withFilters({ query: 'nope' }))).toBe(false)
expect(repoMatches(r, withFilters({ owners: ['b'] }))).toBe(false)
expect(repoMatches(r, withFilters({ severity: 'crit', kinds: ['pr'] }))).toBe(true)
})
})
describe('activeFilterCount', () => {
it('counts each active filter value', () => {
expect(activeFilterCount(emptyFilters())).toBe(0)
expect(
activeFilterCount(
withFilters({
query: ' x ',
severity: 'warn',
kinds: ['pr', 'issue'],
owners: ['a'],
repo: 'a/b',
}),
),
).toBe(6)
expect(activeFilterCount(withFilters({ query: ' ' }))).toBe(0)
})
})
describe('URL round trip', () => {
it('omits defaults', () => {
expect(filtersToSearch(emptyFilters())).toBe('')
})
it('serialises and restores every filter', () => {
const f = withFilters({
query: 'bump grpc',
severity: 'crit',
kinds: ['pr', 'data'],
owners: ['a', 'b'],
repo: 'a/one',
})
expect(filtersFromSearch(filtersToSearch(f))).toEqual(f)
})
it('ignores unknown values from a hand-edited URL', () => {
expect(filtersFromSearch('?sev=nope&kind=pr,bogus&owner=,a')).toEqual(
withFilters({ kinds: ['pr'], owners: ['a'] }),
)
})
})
+129
View File
@@ -0,0 +1,129 @@
import type { Block, Repo, Row } from './types'
export type SeverityFilter = 'all' | 'crit' | 'warn'
export type KindGroup = 'workflow' | 'pr' | 'issue' | 'data'
export interface Filters {
query: string
severity: SeverityFilter
kinds: KindGroup[]
owners: string[]
repo: string | null
}
export const KIND_GROUPS: KindGroup[] = ['workflow', 'pr', 'issue', 'data']
export const emptyFilters = (): Filters => ({
query: '',
severity: 'all',
kinds: [],
owners: [],
repo: null,
})
export function kindGroup(kind: string): KindGroup {
if (kind.startsWith('workflow_')) return 'workflow'
if (kind.startsWith('pr_')) return 'pr'
if (kind === 'issues_stale') return 'issue'
return 'data'
}
export const ownerOf = (fullName: string): string => fullName.split('/')[0]
/** Every whitespace-separated term must appear somewhere in the haystack. */
export function matchesQuery(query: string, ...fields: string[]): boolean {
const terms = query.toLowerCase().split(/\s+/).filter(Boolean)
if (terms.length === 0) return true
const haystack = fields.join(' ').toLowerCase()
return terms.every((t) => haystack.includes(t))
}
/** Facets a row or block can be counted under; the named one is ignored while counting it. */
export type Facet = 'severity' | 'kinds' | 'owners'
function rowMatches(repo: Repo, row: Row, f: Filters, skip?: Facet): boolean {
if (f.repo && repo.fullName !== f.repo) return false
if (skip !== 'severity' && f.severity !== 'all' && row.severity !== f.severity) return false
if (skip !== 'kinds' && f.kinds.length > 0 && !f.kinds.includes(kindGroup(row.kind))) return false
if (skip !== 'owners' && f.owners.length > 0 && !f.owners.includes(ownerOf(repo.fullName)))
return false
return matchesQuery(f.query, repo.fullName, repo.release?.tag ?? '', row.subject, row.detail)
}
export function applyFilters(blocks: Block[], f: Filters, skip?: Facet): Block[] {
const out: Block[] = []
for (const b of blocks) {
const rows = b.rows.filter((r) => rowMatches(b.repo, r, f, skip))
if (rows.length > 0) out.push({ repo: b.repo, rows })
}
return out
}
export interface Facets {
severity: Record<SeverityFilter, number>
kinds: Record<KindGroup, number>
owners: Record<string, number>
}
/** Counts rows per facet value, each with every other filter applied (faceted search). */
export function facetCounts(blocks: Block[], f: Filters, owners: string[]): Facets {
const rows = (skip: Facet) =>
applyFilters(blocks, f, skip).flatMap((b) => b.rows.map((r) => ({ b, r })))
const sev = rows('severity')
const kinds = rows('kinds')
const own = rows('owners')
return {
severity: {
all: sev.length,
crit: sev.filter(({ r }) => r.severity === 'crit').length,
warn: sev.filter(({ r }) => r.severity === 'warn').length,
},
kinds: Object.fromEntries(
KIND_GROUPS.map((g) => [g, kinds.filter(({ r }) => kindGroup(r.kind) === g).length]),
) as Facets['kinds'],
owners: Object.fromEntries(
owners.map((o) => [o, own.filter(({ b }) => ownerOf(b.repo.fullName) === o).length]),
),
}
}
/** Repos that pass the query and owner filters; used for the project list and the strip. */
export function repoMatches(repo: Repo, f: Filters): boolean {
if (f.owners.length > 0 && !f.owners.includes(ownerOf(repo.fullName))) return false
return matchesQuery(f.query, repo.fullName, repo.release?.tag ?? '', repo.description)
}
export function activeFilterCount(f: Filters): number {
return (
(f.query.trim() ? 1 : 0) +
(f.severity !== 'all' ? 1 : 0) +
f.kinds.length +
f.owners.length +
(f.repo ? 1 : 0)
)
}
/** Serialises filters for the URL; defaults are omitted so the plain URL stays clean. */
export function filtersToSearch(f: Filters): string {
const p = new URLSearchParams()
if (f.query.trim()) p.set('q', f.query.trim())
if (f.severity !== 'all') p.set('sev', f.severity)
if (f.kinds.length) p.set('kind', f.kinds.join(','))
if (f.owners.length) p.set('owner', f.owners.join(','))
if (f.repo) p.set('repo', f.repo)
const s = p.toString()
return s ? `?${s}` : ''
}
export function filtersFromSearch(search: string): Filters {
const p = new URLSearchParams(search)
const sev = p.get('sev')
const list = (key: string) => (p.get(key) ?? '').split(',').filter(Boolean)
return {
query: p.get('q') ?? '',
severity: sev === 'crit' || sev === 'warn' ? sev : 'all',
kinds: list('kind').filter((k): k is KindGroup => (KIND_GROUPS as string[]).includes(k)),
owners: list('owner'),
repo: p.get('repo'),
}
}
+44
View File
@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest'
import { ageLabel, ageSince, isZeroTime, secondsSince, shorten } from './format'
describe('ageLabel', () => {
it.each([
[-60, '0m'],
[300, '5m'],
[5400, '1h'],
[47 * 3600, '47h'],
[49 * 3600, '2d'],
[10 * 86400, '10d'],
])('%is -> %s', (seconds, want) => {
expect(ageLabel(seconds)).toBe(want)
})
})
describe('time helpers', () => {
const now = Date.parse('2026-10-02T12:00:00Z')
it('measures seconds since a timestamp', () => {
expect(secondsSince('2026-10-02T11:00:00Z', now)).toBe(3600)
})
it('returns an empty age for unparsable input', () => {
expect(ageSince('not a date', now)).toBe('')
expect(ageSince('2026-10-02T09:00:00Z', now)).toBe('3h')
})
it('treats the Go zero time as unknown', () => {
expect(isZeroTime('0001-01-01T00:00:00Z')).toBe(true)
expect(isZeroTime('')).toBe(true)
expect(isZeroTime('2026-10-02T12:00:00Z')).toBe(false)
})
})
describe('shorten', () => {
it('keeps short text and cuts long text by characters, not bytes', () => {
expect(shorten('ok')).toBe('ok')
const long = 'é'.repeat(200)
const out = shorten(long)
expect(Array.from(out)).toHaveLength(90)
expect(out.endsWith('…')).toBe(true)
})
})
+29
View File
@@ -0,0 +1,29 @@
const HOUR = 3600
const DAY = 24 * HOUR
/** Compact age such as "3d", "5h" or "12m"; mirrors the Go collector.Age helper. */
export function ageLabel(seconds: number): string {
if (seconds < 0) return '0m'
if (seconds >= 2 * DAY) return `${Math.floor(seconds / DAY)}d`
if (seconds >= HOUR) return `${Math.floor(seconds / HOUR)}h`
return `${Math.floor(seconds / 60)}m`
}
/** Seconds between an RFC 3339 timestamp and nowMs. */
export function secondsSince(iso: string, nowMs: number): number {
return Math.floor((nowMs - Date.parse(iso)) / 1000)
}
export function ageSince(iso: string, nowMs: number): string {
return Number.isNaN(Date.parse(iso)) ? '' : ageLabel(secondsSince(iso, nowMs))
}
/** Go encodes the zero time as year 1; treat it as "unknown". */
export function isZeroTime(iso: string): boolean {
return !iso || iso.startsWith('0001-')
}
export function shorten(text: string, max = 90): string {
const chars = Array.from(text)
return chars.length <= max ? text : chars.slice(0, max - 1).join('') + '…'
}
+27
View File
@@ -0,0 +1,27 @@
import {
faClockRotateLeft,
faCodePullRequest,
faCircleDot,
faGears,
faPlugCircleXmark,
faTriangleExclamation,
type IconDefinition,
} from '@fortawesome/free-solid-svg-icons'
/** Maps a server alert kind to the icon and group used in the UI. */
export const KIND_ICON: Record<string, IconDefinition> = {
workflow_failed: faGears,
workflow_stuck: faGears,
pr_waiting: faCodePullRequest,
pr_checks_failing: faCodePullRequest,
issues_stale: faCircleDot,
refresh_failed: faPlugCircleXmark,
data_stale: faClockRotateLeft,
partial_data: faTriangleExclamation,
}
export function iconFor(kind: string): IconDefinition {
return KIND_ICON[kind] ?? faTriangleExclamation
}
export const DATA_KINDS = ['refresh_failed', 'data_stale', 'partial_data']
+71
View File
@@ -0,0 +1,71 @@
export type Severity = 'ok' | 'warn' | 'crit'
export interface Release {
tag: string
url: string
publishedAt: string
prerelease: boolean
fromTag: boolean
}
export interface Repo {
fullName: string
url: string
releasesUrl: string
description: string
health: Severity
error: string
workflows: { total: number; failing: number; running: number }
prs: { open: number; waiting: number; failingChecks: number; oldestSeconds: number }
issues: { open: number; stale: number }
archived: boolean
up: boolean
refreshedAt: string
release: Release | null
}
export interface Alert {
repo: string
kind: string
severity: Severity
subject: string
detail: string
url: string
since: string
}
export interface Thresholds {
prWarnSeconds: number
prCritSeconds: number
issueStaleSeconds: number
workflowStuckSeconds: number
workflowDormantSeconds: number
}
export interface TrackerState {
now: string
lastRefresh: string
intervalSeconds: number
loaded: boolean
refreshing: boolean
manualRefresh: boolean
counts: { crit: number; warn: number }
thresholds: Thresholds
repos: Repo[]
alerts: Alert[]
}
/** One line in a repo block: every alert that targets the same URL, merged. */
export interface Row {
kind: string
subject: string
detail: string
url: string
since: string
severity: Exclude<Severity, 'ok'>
}
export interface Block {
repo: Repo
rows: Row[]
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}