mirror of
https://github.com/lukaszraczylo/ghtracker.git
synced 2026-10-14 00:58:36 +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,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}`)
|
||||
}
|
||||
@@ -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'] }),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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'),
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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('') + '…'
|
||||
}
|
||||
@@ -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']
|
||||
@@ -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[]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
Reference in New Issue
Block a user