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
+91
View File
@@ -0,0 +1,91 @@
import { flushPromises, mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import { afterEach, describe, expect, it, vi } from 'vitest'
import App from './App.vue'
import { mixedState, repo, state } from '@/test/fixtures'
import type { TrackerState } from '@/lib/types'
function mountWith(body: TrackerState | Error) {
vi.stubGlobal(
'fetch',
vi.fn(() =>
body instanceof Error
? Promise.reject(body)
: Promise.resolve(new Response(JSON.stringify(body), { status: 200 })),
),
)
return mount(App, { global: { plugins: [createPinia()] } })
}
describe('App', () => {
afterEach(() => vi.unstubAllGlobals())
it('shows the overview, only the problems and every project', async () => {
const w = mountWith(mixedState())
await flushPromises()
expect(w.get('[data-test=headline]').text()).toContain('2')
expect(w.findAll('[data-test=segment]')).toHaveLength(2)
expect(w.findAll('[data-test=repo-block]')).toHaveLength(1)
expect(w.findAll('[data-test=row]')).toHaveLength(2)
expect(w.findAll('[data-test=project]')).toHaveLength(2)
})
it('shows an all-clear state without repo blocks', async () => {
const w = mountWith(state({ repos: [repo()] }))
await flushPromises()
expect(w.get('[data-test=headline]').text()).toBe('All clear')
expect(w.find('[data-test=repo-block]').exists()).toBe(false)
expect(w.findAll('[data-test=project]')).toHaveLength(1)
})
it('focuses one project from the strip and clears the focus', async () => {
const w = mountWith(mixedState())
await flushPromises()
await w.findAll('[data-test=segment]')[1].trigger('click')
expect(w.find('[data-test=repo-block]').exists()).toBe(false)
expect(w.find('[data-test=empty]').exists()).toBe(true)
await w.get('[data-test=clear-all]').trigger('click')
expect(w.findAll('[data-test=repo-block]')).toHaveLength(1)
})
it('narrows the list as you type and restores it when cleared', async () => {
const w = mountWith(mixedState())
await flushPromises()
await w.get('[data-test=search]').setValue('zzz-no-match')
expect(w.find('[data-test=repo-block]').exists()).toBe(false)
expect(w.find('[data-test=no-projects]').exists()).toBe(true)
await w.get('[data-test=clear-all]').trigger('click')
expect(w.findAll('[data-test=repo-block]')).toHaveLength(1)
expect(w.findAll('[data-test=project]')).toHaveLength(2)
})
it('applies a kind filter from the toolbar', async () => {
const w = mountWith(mixedState())
await flushPromises()
await w.get('[data-test=kind-workflow]').trigger('click')
expect(w.findAll('[data-test=row]')).toHaveLength(1)
expect(w.get('[data-test=sev-all]').text()).toBe('All 1')
})
it('waits while the server has not finished its first refresh', async () => {
const w = mountWith(state({ loaded: false }))
await flushPromises()
expect(w.find('[data-test=waiting]').exists()).toBe(true)
expect(w.find('[data-test=headline]').exists()).toBe(false)
})
it('surfaces a connection error', async () => {
const w = mountWith(new Error('offline'))
await flushPromises()
expect(w.get('[data-test=error]').text()).toContain('offline')
})
it('offers a rescan only when the server allows it', async () => {
const on = mountWith(state({ manualRefresh: true, repos: [repo()] }))
await flushPromises()
expect(on.find('[data-test=rescan]').exists()).toBe(true)
const off = mountWith(state({ manualRefresh: false, repos: [repo()] }))
await flushPromises()
expect(off.find('[data-test=rescan]').exists()).toBe(false)
})
})
+134
View File
@@ -0,0 +1,134 @@
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import AppHeader from '@/components/AppHeader.vue'
import AttentionList from '@/components/AttentionList.vue'
import FleetOverview from '@/components/FleetOverview.vue'
import ProjectList from '@/components/ProjectList.vue'
import { filtersFromSearch, filtersToSearch } from '@/lib/filters'
import { useTrackerStore } from '@/stores/tracker'
const POLL_MS = 60_000
const CLOCK_MS = 30_000
const store = useTrackerStore()
const {
state,
error,
notice,
scanning,
blocks,
visibleBlocks,
nowMs,
filters,
facets,
owners,
visibleRepos,
activeFilters,
repoCounts,
facts,
} = storeToRefs(store)
const list = ref<InstanceType<typeof AttentionList> | null>(null)
const matching = computed(() => new Set(visibleRepos.value.map((r) => r.fullName)))
let timers: number[] = []
/** Pressing "/" outside a text field jumps to the search box. */
function onKey(e: KeyboardEvent): void {
const el = e.target as HTMLElement | null
if (e.key !== '/' || e.metaKey || e.ctrlKey || el?.closest('input, textarea, [contenteditable]'))
return
e.preventDefault()
list.value?.focusSearch()
}
// Filters live in the URL so a filtered view can be shared and survives a reload.
watch(
filters,
(f) => window.history.replaceState(null, '', window.location.pathname + filtersToSearch(f)),
{ deep: true },
)
onMounted(() => {
filters.value = filtersFromSearch(window.location.search)
window.addEventListener('keydown', onKey)
void store.load()
timers = [
window.setInterval(() => void store.load(), POLL_MS),
window.setInterval(store.advanceClock, CLOCK_MS),
]
})
onBeforeUnmount(() => {
timers.forEach((t) => window.clearInterval(t))
window.removeEventListener('keydown', onKey)
})
</script>
<template>
<main class="mx-auto max-w-7xl px-5 py-6 sm:px-8">
<AppHeader
:last-refresh="state?.lastRefresh ?? ''"
:now-ms="nowMs"
:interval-seconds="state?.intervalSeconds ?? 0"
:loaded="state?.loaded ?? false"
:scanning="scanning"
:manual-refresh="state?.manualRefresh ?? false"
@rescan="store.rescan()"
/>
<p v-if="error" role="alert" class="text-crit mt-4 text-sm" data-test="error">
Could not reach the server: {{ error }}
</p>
<p v-if="notice" role="status" class="text-warn mt-4 text-sm" data-test="notice">
{{ notice }}
</p>
<p v-if="!state" class="text-muted-foreground mt-16 text-3xl font-semibold">Loading</p>
<p
v-else-if="!state.loaded"
class="text-muted-foreground mt-16 text-3xl font-semibold"
data-test="waiting"
>
Fetching data from GitHub
</p>
<template v-else>
<div class="mt-12">
<FleetOverview
:crit="state.counts.crit"
:warn="state.counts.warn"
:block-count="blocks.length"
:repos="state.repos"
:counts="repoCounts"
:facts="facts!"
:focused="filters.repo"
:matching="matching"
@select="store.toggleRepo"
/>
</div>
<div
class="mt-14 grid grid-cols-[minmax(0,1fr)] gap-x-10 gap-y-12 lg:grid-cols-[minmax(0,1fr)_23rem]"
>
<AttentionList
ref="list"
v-model:filters="filters"
:blocks="visibleBlocks"
:now-ms="nowMs"
:crit-seconds="state.thresholds.prCritSeconds"
:facets="facets"
:owners="owners"
:active="activeFilters"
@clear="store.clearFilters()"
/>
<aside class="lg:sticky lg:top-6 lg:self-start">
<ProjectList
:repos="visibleRepos"
:total="state.repos.length"
:focused="filters.repo"
:now-ms="nowMs"
@select="store.toggleRepo"
/>
</aside>
</div>
</template>
</main>
</template>
+23
View File
@@ -0,0 +1,23 @@
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{ ratio: number; severity: 'warn' | 'crit' }>()
const SEGMENTS = 10
const filled = computed(() => Math.min(SEGMENTS, Math.max(1, Math.ceil(props.ratio * SEGMENTS))))
</script>
<template>
<span
class="inline-flex gap-px"
role="img"
:aria-label="`${Math.round(Math.min(ratio, 1) * 100)}% of the critical age`"
>
<span
v-for="i in SEGMENTS"
:key="i"
class="h-3 w-1 rounded-[1px]"
:class="i <= filled ? (severity === 'crit' ? 'bg-crit' : 'bg-warn') : 'bg-border'"
/>
</span>
</template>
+59
View File
@@ -0,0 +1,59 @@
<script setup lang="ts">
import { faArrowsRotate, faSatelliteDish } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { computed } from 'vue'
import { Button } from '@/components/ui/button'
import { ageLabel, ageSince, isZeroTime } from '@/lib/format'
const props = defineProps<{
lastRefresh: string
nowMs: number
intervalSeconds: number
loaded: boolean
scanning: boolean
manualRefresh: boolean
}>()
defineEmits<{ rescan: [] }>()
const updated = computed(() =>
props.loaded && !isZeroTime(props.lastRefresh) ? ageSince(props.lastRefresh, props.nowMs) : '',
)
const nextIn = computed(() => {
if (!updated.value) return ''
const left = Math.floor(
(Date.parse(props.lastRefresh) + props.intervalSeconds * 1000 - props.nowMs) / 1000,
)
return left > 0 ? ageLabel(left) : 'now'
})
</script>
<template>
<header class="flex items-center justify-between gap-4">
<div class="flex items-center gap-3">
<span
class="border-border bg-surface text-link flex size-9 items-center justify-center rounded-md border"
>
<FontAwesomeIcon :icon="faSatelliteDish" aria-hidden="true" />
</span>
<span class="text-lg font-semibold tracking-tight">ghtracker</span>
</div>
<div class="text-muted-foreground flex items-center gap-5 text-sm">
<span v-if="updated" class="hidden sm:inline" data-test="updated">
Scanned {{ updated }} ago
<span class="text-muted-foreground/70"> · next in {{ nextIn }}</span>
</span>
<span v-else>Loading</span>
<Button
v-if="manualRefresh"
variant="outline"
size="sm"
:disabled="scanning"
data-test="rescan"
@click="$emit('rescan')"
>
<FontAwesomeIcon :icon="faArrowsRotate" :spin="scanning" aria-hidden="true" />
{{ scanning ? 'Rescanning…' : 'Rescan repositories' }}
</Button>
</div>
</header>
</template>
+56
View File
@@ -0,0 +1,56 @@
<script setup lang="ts">
import { faCircleCheck } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { ref } from 'vue'
import FilterBar from '@/components/FilterBar.vue'
import RepoGroup from '@/components/RepoGroup.vue'
import type { Facets, Filters } from '@/lib/filters'
import type { Block } from '@/lib/types'
defineProps<{
blocks: Block[]
nowMs: number
critSeconds: number
facets: Facets
owners: string[]
active: number
}>()
const filters = defineModel<Filters>('filters', { required: true })
defineEmits<{ clear: [] }>()
const bar = ref<InstanceType<typeof FilterBar> | null>(null)
defineExpose({ focusSearch: () => bar.value?.focus() })
</script>
<template>
<section aria-labelledby="attention-title">
<h2 id="attention-title" class="mb-4 text-lg font-semibold tracking-tight">Needs attention</h2>
<FilterBar
ref="bar"
v-model:filters="filters"
:facets="facets"
:owners="owners"
:active="active"
class="mb-5"
@clear="$emit('clear')"
/>
<div
v-if="blocks.length === 0"
class="border-border text-muted-foreground flex items-center gap-3 rounded-lg border border-dashed p-6"
data-test="empty"
>
<FontAwesomeIcon :icon="faCircleCheck" class="text-ok text-xl" aria-hidden="true" />
Nothing matches the current filters.
</div>
<div v-else class="flex flex-col gap-4">
<RepoGroup
v-for="block in blocks"
:key="block.repo.fullName"
:block="block"
:now-ms="nowMs"
:crit-seconds="critSeconds"
/>
</div>
</section>
</template>
+45
View File
@@ -0,0 +1,45 @@
<script setup lang="ts">
import { computed } from 'vue'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import AgeMeter from '@/components/AgeMeter.vue'
import { ageSince, isZeroTime, secondsSince, shorten } from '@/lib/format'
import { iconFor } from '@/lib/kinds'
import type { Row } from '@/lib/types'
const props = defineProps<{ row: Row; nowMs: number; critSeconds: number }>()
const ratio = computed(() => secondsSince(props.row.since, props.nowMs) / props.critSeconds)
</script>
<template>
<li class="grid grid-cols-[2rem_minmax(0,1fr)_auto] items-start gap-x-3 py-3" data-test="row">
<span
class="flex size-8 items-center justify-center rounded-md"
:class="row.severity === 'crit' ? 'bg-crit/12 text-crit' : 'bg-warn/12 text-warn'"
>
<FontAwesomeIcon :icon="iconFor(row.kind)" aria-hidden="true" />
<span class="sr-only">{{ row.severity === 'crit' ? 'Critical' : 'Warning' }}</span>
</span>
<div class="min-w-0">
<a
:href="row.url"
class="hover:text-link leading-snug font-medium wrap-anywhere underline-offset-4 hover:underline"
>
{{ shorten(row.subject) }}
</a>
<p class="text-muted-foreground mt-0.5 text-sm wrap-anywhere">
{{ shorten(row.detail, 120) }}
</p>
</div>
<div class="flex flex-col items-end gap-1.5 pt-0.5">
<span class="text-muted-foreground font-mono text-xs whitespace-nowrap">
{{ isZeroTime(row.since) ? '' : ageSince(row.since, nowMs) }}
</span>
<AgeMeter
v-if="row.kind === 'pr_waiting' && !isZeroTime(row.since)"
:ratio="ratio"
:severity="row.severity"
/>
</div>
</li>
</template>
+150
View File
@@ -0,0 +1,150 @@
<script setup lang="ts">
import {
faCircleDot,
faCodePullRequest,
faGears,
faMagnifyingGlass,
faTriangleExclamation,
faXmark,
type IconDefinition,
} from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { ref } from 'vue'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
import { KIND_GROUPS, type Facets, type Filters, type KindGroup } from '@/lib/filters'
import type { SeverityFilter } from '@/lib/filters'
defineProps<{ facets: Facets; owners: string[]; active: number }>()
const filters = defineModel<Filters>('filters', { required: true })
defineEmits<{ clear: [] }>()
const KIND_META: Record<KindGroup, { label: string; icon: IconDefinition }> = {
workflow: { label: 'Workflows', icon: faGears },
pr: { label: 'Pull requests', icon: faCodePullRequest },
issue: { label: 'Issues', icon: faCircleDot },
data: { label: 'Data', icon: faTriangleExclamation },
}
const search = ref<InstanceType<typeof Input> | null>(null)
defineExpose({ focus: () => (search.value?.$el as HTMLInputElement | undefined)?.focus() })
function patch(change: Partial<Filters>): void {
filters.value = { ...filters.value, ...change }
}
function onSeverity(value: unknown): void {
if (value === 'all' || value === 'crit' || value === 'warn')
patch({ severity: value as SeverityFilter })
else patch({ severity: 'all' })
}
function onKinds(value: unknown): void {
patch({ kinds: (Array.isArray(value) ? value : []) as KindGroup[] })
}
function onOwners(value: unknown): void {
patch({ owners: (Array.isArray(value) ? value : []) as string[] })
}
</script>
<template>
<div class="flex flex-col gap-3" data-test="filter-bar">
<div class="flex flex-wrap items-center gap-3">
<div class="relative min-w-56 flex-1">
<FontAwesomeIcon
:icon="faMagnifyingGlass"
class="text-muted-foreground pointer-events-none absolute top-1/2 left-3 -translate-y-1/2"
aria-hidden="true"
/>
<Input
ref="search"
:model-value="filters.query"
type="search"
placeholder="Search items, projects and versions"
aria-label="Search"
class="bg-surface pr-9 pl-9"
data-test="search"
@update:model-value="patch({ query: String($event ?? '') })"
@keydown.esc="patch({ query: '' })"
/>
<kbd
v-if="!filters.query"
class="border-border text-muted-foreground pointer-events-none absolute top-1/2 right-3 -translate-y-1/2 rounded border px-1.5 font-mono text-[11px]"
aria-hidden="true"
>/</kbd
>
</div>
<ToggleGroup
type="single"
variant="outline"
size="sm"
:model-value="filters.severity"
aria-label="Severity"
@update:model-value="onSeverity"
>
<ToggleGroupItem value="all" data-test="sev-all"
>All {{ facets.severity.all }}</ToggleGroupItem
>
<ToggleGroupItem value="crit" data-test="sev-crit"
>Critical {{ facets.severity.crit }}</ToggleGroupItem
>
<ToggleGroupItem value="warn" data-test="sev-warn"
>Warnings {{ facets.severity.warn }}</ToggleGroupItem
>
</ToggleGroup>
</div>
<div class="flex flex-wrap items-center gap-x-5 gap-y-2">
<ToggleGroup
type="multiple"
variant="outline"
size="sm"
:model-value="filters.kinds"
aria-label="Kind"
@update:model-value="onKinds"
>
<ToggleGroupItem
v-for="kind in KIND_GROUPS"
:key="kind"
:value="kind"
:disabled="facets.kinds[kind] === 0 && !filters.kinds.includes(kind)"
:data-test="`kind-${kind}`"
>
<FontAwesomeIcon :icon="KIND_META[kind].icon" aria-hidden="true" />
{{ KIND_META[kind].label }}
<span class="text-muted-foreground tabular-nums">{{ facets.kinds[kind] }}</span>
</ToggleGroupItem>
</ToggleGroup>
<ToggleGroup
v-if="owners.length > 1"
type="multiple"
variant="outline"
size="sm"
:model-value="filters.owners"
aria-label="Owner"
@update:model-value="onOwners"
>
<ToggleGroupItem
v-for="owner in owners"
:key="owner"
:value="owner"
:disabled="facets.owners[owner] === 0 && !filters.owners.includes(owner)"
:data-test="`owner-${owner}`"
>
{{ owner }}
<span class="text-muted-foreground tabular-nums">{{ facets.owners[owner] }}</span>
</ToggleGroupItem>
</ToggleGroup>
<Button
v-if="active > 0"
variant="ghost"
size="sm"
class="ml-auto"
data-test="clear-all"
@click="$emit('clear')"
>
<FontAwesomeIcon :icon="faXmark" aria-hidden="true" />
Clear {{ active }} {{ active === 1 ? 'filter' : 'filters' }}
</Button>
</div>
</div>
</template>
+122
View File
@@ -0,0 +1,122 @@
<script setup lang="ts">
import {
faCodePullRequest,
faCircleDot,
faGears,
faTriangleExclamation,
type IconDefinition,
} from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { computed } from 'vue'
import FleetStrip from '@/components/FleetStrip.vue'
import type { Repo } from '@/lib/types'
import type { Facts, RepoCounts } from '@/stores/tracker'
const props = defineProps<{
crit: number
warn: number
blockCount: number
repos: Repo[]
counts: Map<string, RepoCounts>
facts: Facts
focused: string | null
matching: Set<string>
}>()
defineEmits<{ select: [fullName: string] }>()
const rows = computed<{ label: string; value: number; icon: IconDefinition; tone: string }[]>(
() => [
{
label: 'Workflows failing',
value: props.facts.failingWorkflows,
icon: faGears,
tone: 'text-crit',
},
{
label: 'Pull requests waiting',
value: props.facts.waitingPrs,
icon: faCodePullRequest,
tone: 'text-crit',
},
{
label: 'PRs with failing checks',
value: props.facts.failingChecks,
icon: faCodePullRequest,
tone: 'text-warn',
},
{ label: 'Stale issues', value: props.facts.staleIssues, icon: faCircleDot, tone: 'text-warn' },
{
label: 'Data problems',
value: props.facts.dataProblems,
icon: faTriangleExclamation,
tone: 'text-warn',
},
],
)
</script>
<template>
<section
class="grid grid-cols-[minmax(0,1fr)] items-end gap-x-14 gap-y-8 lg:grid-cols-[minmax(0,1fr)_20rem]"
aria-label="Fleet status"
>
<div>
<p class="text-muted-foreground mb-3 text-sm" data-test="scope">
<template v-if="blockCount > 0"
>{{ blockCount }} of {{ repos.length }} projects need attention</template
>
<template v-else>All {{ repos.length }} projects are healthy</template>
</p>
<h1
class="flex flex-wrap items-baseline gap-x-10 gap-y-1 font-semibold tracking-tight"
data-test="headline"
>
<template v-if="crit > 0 || warn > 0">
<span v-if="crit > 0" class="text-crit">
<span class="text-7xl leading-none tabular-nums">{{ crit }}</span>
<span class="ml-2 text-xl font-medium">critical</span>
</span>
<span v-if="warn > 0" class="text-warn">
<span class="text-7xl leading-none tabular-nums">{{ warn }}</span>
<span class="ml-2 text-xl font-medium">{{ warn === 1 ? 'warning' : 'warnings' }}</span>
</span>
</template>
<span v-else class="text-ok text-7xl leading-none">All clear</span>
</h1>
<div class="mt-8">
<FleetStrip
:repos="repos"
:counts="counts"
:focused="focused"
:matching="matching"
@select="$emit('select', $event)"
/>
<p class="text-muted-foreground mt-3 flex flex-wrap items-center gap-x-5 gap-y-1 text-xs">
<span class="flex items-center gap-1.5"
><span class="bg-crit size-2 rounded-[2px]"></span>critical</span
>
<span class="flex items-center gap-1.5"
><span class="bg-warn size-2 rounded-[2px]"></span>warning</span
>
<span class="flex items-center gap-1.5"
><span class="bg-ok/40 size-2 rounded-[2px]"></span>healthy</span
>
<span class="ml-auto">Select a segment to focus a project</span>
</p>
</div>
</div>
<dl class="border-border divide-border divide-y border-y text-sm" data-test="facts">
<div v-for="row in rows" :key="row.label" class="flex items-center gap-3 py-2.5">
<FontAwesomeIcon :icon="row.icon" class="text-muted-foreground w-4" aria-hidden="true" />
<dt class="flex-1">{{ row.label }}</dt>
<dd
class="font-mono text-base tabular-nums"
:class="row.value > 0 ? row.tone : 'text-muted-foreground'"
>
{{ row.value }}
</dd>
</div>
</dl>
</section>
</template>
+52
View File
@@ -0,0 +1,52 @@
<script setup lang="ts">
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import type { Repo } from '@/lib/types'
import type { RepoCounts } from '@/stores/tracker'
defineProps<{
repos: Repo[]
counts: Map<string, RepoCounts>
focused: string | null
matching: Set<string>
}>()
defineEmits<{ select: [fullName: string] }>()
const SEGMENT = { crit: 'bg-crit', warn: 'bg-warn', ok: 'bg-ok/30 hover:bg-ok/60' } as const
function summary(c: RepoCounts | undefined): string {
if (!c) return 'healthy'
const parts = []
if (c.crit) parts.push(`${c.crit} critical`)
if (c.warn) parts.push(`${c.warn} ${c.warn === 1 ? 'warning' : 'warnings'}`)
return parts.join(' · ')
}
</script>
<template>
<TooltipProvider :delay-duration="80">
<div class="flex gap-1" role="group" aria-label="Project health" data-test="strip">
<Tooltip v-for="repo in repos" :key="repo.fullName">
<TooltipTrigger as-child>
<button
type="button"
:aria-label="`${repo.fullName}: ${summary(counts.get(repo.fullName))}`"
:aria-pressed="focused === repo.fullName"
class="h-14 min-w-0 flex-1 rounded-[3px] transition-colors focus-visible:outline-offset-1"
:class="[
SEGMENT[repo.health],
focused === repo.fullName
? 'ring-foreground ring-2 ring-offset-2 ring-offset-background'
: '',
]"
data-test="segment"
@click="$emit('select', repo.fullName)"
/>
</TooltipTrigger>
<TooltipContent>
<p class="font-medium">{{ repo.fullName }}</p>
<p class="opacity-80">{{ summary(counts.get(repo.fullName)) }}</p>
</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
</template>
+91
View File
@@ -0,0 +1,91 @@
<script setup lang="ts">
import { faCodePullRequest, faCircleDot, faGears } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { ageSince, isZeroTime } from '@/lib/format'
import type { Repo } from '@/lib/types'
defineProps<{ repos: Repo[]; total: number; focused: string | null; nowMs: number }>()
defineEmits<{ select: [fullName: string] }>()
const DOT = { crit: 'bg-crit', warn: 'bg-warn', ok: 'bg-ok' } as const
const LABEL = { crit: 'Critical', warn: 'Warning', ok: 'Healthy' } as const
</script>
<template>
<section aria-labelledby="projects-title">
<h2 id="projects-title" class="mb-4 text-lg font-semibold tracking-tight">
Projects
<span class="text-muted-foreground text-sm font-normal" data-test="project-count">{{
repos.length === total ? total : `${repos.length} of ${total}`
}}</span>
</h2>
<p
v-if="repos.length === 0"
class="text-muted-foreground border-border rounded-lg border border-dashed p-4 text-sm"
data-test="no-projects"
>
No projects match the search.
</p>
<ul v-else class="border-border bg-surface divide-border divide-y rounded-lg border">
<li
v-for="repo in repos"
:key="repo.fullName"
class="px-3 py-2.5"
:class="focused === repo.fullName ? 'bg-accent' : ''"
data-test="project"
>
<div class="flex items-center gap-2.5">
<span class="size-2 shrink-0 rounded-full" :class="DOT[repo.health]"></span>
<span class="sr-only">{{ LABEL[repo.health] }}:</span>
<button
type="button"
class="hover:text-link min-w-0 flex-1 truncate text-left text-sm font-medium"
:aria-pressed="focused === repo.fullName"
@click="$emit('select', repo.fullName)"
>
{{ repo.fullName.split('/')[1] }}
<span class="text-muted-foreground font-normal">{{ repo.fullName.split('/')[0] }}</span>
</button>
<a
v-if="repo.release"
:href="repo.release.url"
class="text-muted-foreground hover:text-link font-mono text-xs"
>
{{ repo.release.tag }}
</a>
<a v-else :href="repo.releasesUrl" class="text-muted-foreground hover:text-link text-xs"
>no release</a
>
</div>
<div
class="text-muted-foreground mt-1 flex items-center gap-4 pl-[18px] text-xs tabular-nums"
>
<a
:href="`${repo.url}/actions`"
class="hover:text-link flex items-center gap-1.5"
:class="repo.workflows.failing ? 'text-crit' : ''"
>
<FontAwesomeIcon :icon="faGears" aria-hidden="true" />
{{
repo.workflows.failing
? `${repo.workflows.failing}/${repo.workflows.total} failing`
: repo.workflows.total
}}
<span class="sr-only">workflows</span>
</a>
<a :href="`${repo.url}/pulls`" class="hover:text-link flex items-center gap-1.5">
<FontAwesomeIcon :icon="faCodePullRequest" aria-hidden="true" />{{ repo.prs.open }}
<span class="sr-only">pull requests</span>
</a>
<a :href="`${repo.url}/issues`" class="hover:text-link flex items-center gap-1.5">
<FontAwesomeIcon :icon="faCircleDot" aria-hidden="true" />{{ repo.issues.open }}
<span class="sr-only">issues</span>
</a>
<span v-if="repo.release && !isZeroTime(repo.release.publishedAt)" class="ml-auto">
released {{ ageSince(repo.release.publishedAt, nowMs) }} ago
</span>
</div>
</li>
</ul>
</section>
</template>
+41
View File
@@ -0,0 +1,41 @@
<script setup lang="ts">
import { faTag } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import AttentionRow from '@/components/AttentionRow.vue'
import { Badge } from '@/components/ui/badge'
import type { Block } from '@/lib/types'
defineProps<{ block: Block; nowMs: number; critSeconds: number }>()
</script>
<template>
<section class="border-border bg-surface rounded-lg border" data-test="repo-block">
<header class="border-border flex flex-wrap items-center gap-x-3 gap-y-1 border-b px-4 py-3">
<h3 class="font-semibold">
<a :href="block.repo.url" class="hover:text-link underline-offset-4 hover:underline">
<span class="text-muted-foreground font-normal"
>{{ block.repo.fullName.split('/')[0] }}/</span
>{{ block.repo.fullName.split('/')[1] }}
</a>
</h3>
<Badge v-if="block.repo.release" variant="secondary" as-child class="font-mono">
<a :href="block.repo.release.url">
<FontAwesomeIcon :icon="faTag" aria-hidden="true" class="text-[0.7em]" />
{{ block.repo.release.tag }}
</a>
</Badge>
<span class="text-muted-foreground ml-auto text-xs">
{{ block.rows.length }} {{ block.rows.length === 1 ? 'item' : 'items' }}
</span>
</header>
<ul class="divide-border divide-y px-4">
<AttentionRow
v-for="row in block.rows"
:key="row.url"
:row="row"
:now-ms="nowMs"
:crit-seconds="critSeconds"
/>
</ul>
</section>
</template>
+306
View File
@@ -0,0 +1,306 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import AgeMeter from './AgeMeter.vue'
import AppHeader from './AppHeader.vue'
import AttentionList from './AttentionList.vue'
import FilterBar from './FilterBar.vue'
import AttentionRow from './AttentionRow.vue'
import FleetOverview from './FleetOverview.vue'
import ProjectList from './ProjectList.vue'
import RepoGroup from './RepoGroup.vue'
import { activeFilterCount, emptyFilters, facetCounts, type Filters } from '@/lib/filters'
import { buildBlocks, computeFacts, countByRepo } from '@/stores/tracker'
import { hoursAgo, mixedState, NOW, repo } from '@/test/fixtures'
import type { Row } from '@/lib/types'
const nowMs = Date.parse(NOW)
const CRIT_SECONDS = 604800
const text = (w: { text(): string }) => w.text().replace(/\s+/g, ' ')
describe('AppHeader', () => {
const base = {
lastRefresh: '2026-10-02T11:00:00Z',
nowMs,
intervalSeconds: 21600,
loaded: true,
scanning: false,
}
it('shows when data was fetched and when the next refresh is due', () => {
const w = mount(AppHeader, { props: { ...base, manualRefresh: false } })
expect(text(w.get('[data-test=updated]'))).toBe('Scanned 1h ago · next in 5h')
expect(w.find('[data-test=rescan]').exists()).toBe(false)
})
it('shows Loading before the first refresh finishes', () => {
const w = mount(AppHeader, { props: { ...base, loaded: false, manualRefresh: false } })
expect(w.text()).toContain('Loading')
})
it('emits rescan and shows progress while a scan runs', async () => {
const w = mount(AppHeader, { props: { ...base, manualRefresh: true } })
expect(w.get('[data-test=rescan]').text()).toBe('Rescan repositories')
await w.get('[data-test=rescan]').trigger('click')
expect(w.emitted('rescan')).toHaveLength(1)
const busy = mount(AppHeader, { props: { ...base, manualRefresh: true, scanning: true } })
expect(busy.get('[data-test=rescan]').attributes('disabled')).toBeDefined()
expect(busy.get('[data-test=rescan]').text()).toBe('Rescanning…')
})
})
describe('FleetOverview', () => {
const s = mixedState()
const props = {
crit: 2,
warn: 1,
blockCount: 1,
repos: s.repos,
counts: countByRepo(s),
facts: computeFacts(s),
focused: null,
matching: new Set(['o/r', 'o/fine']),
}
it('leads with the critical and warning counts and the scope', () => {
const w = mount(FleetOverview, { props })
const headline = w.get('[data-test=headline]').text()
expect(headline).toMatch(/2\s*critical/)
expect(headline).toMatch(/1\s*warning/)
expect(w.get('[data-test=scope]').text()).toBe('1 of 2 projects need attention')
})
it('lists the headline facts, dimming zeros', () => {
const w = mount(FleetOverview, { props })
const facts = Object.fromEntries(
w.findAll('[data-test=facts] > div').map((d) => [d.get('dt').text(), d.get('dd').text()]),
)
expect(facts).toMatchObject({
'Workflows failing': '1',
'Stale issues': '2',
'Data problems': '0',
})
})
it('says all clear when nothing is wrong', () => {
const w = mount(FleetOverview, { props: { ...props, crit: 0, warn: 0, blockCount: 0 } })
expect(w.get('[data-test=headline]').text()).toBe('All clear')
expect(w.get('[data-test=scope]').text()).toBe('All 2 projects are healthy')
})
it('draws one segment per project, labelled for screen readers, and emits on click', async () => {
const w = mount(FleetOverview, { props: { ...props, focused: 'o/r' } })
const segments = w.findAll('[data-test=segment]')
expect(segments).toHaveLength(2)
expect(segments[0].attributes('aria-label')).toBe('o/r: 2 critical · 1 warning')
expect(segments[0].attributes('aria-pressed')).toBe('true')
expect(segments[1].attributes('aria-label')).toBe('o/fine: healthy')
await segments[1].trigger('click')
expect(w.emitted('select')?.[0]).toEqual(['o/fine'])
})
})
describe('AgeMeter', () => {
it('fills proportionally and always shows at least one segment', () => {
const filled = (ratio: number) =>
mount(AgeMeter, { props: { ratio, severity: 'crit' } })
.findAll('span span')
.filter((s) => s.classes().includes('bg-crit')).length
expect(filled(0)).toBe(1)
expect(filled(0.5)).toBe(5)
expect(filled(3)).toBe(10)
})
it('uses the warning colour for warnings', () => {
const w = mount(AgeMeter, { props: { ratio: 0.2, severity: 'warn' } })
expect(w.find('.bg-warn').exists()).toBe(true)
expect(w.find('.bg-crit').exists()).toBe(false)
})
})
describe('AttentionRow', () => {
const row = (over: Partial<Row> = {}): Row => ({
kind: 'pr_waiting',
subject: '#5 <b>bold</b> title',
detail: 'open 9d',
url: 'https://github.com/o/r/pull/5',
since: hoursAgo(216),
severity: 'crit',
...over,
})
it('links the subject, shows the age and escapes markup', () => {
const w = mount(AttentionRow, { props: { row: row(), nowMs, critSeconds: CRIT_SECONDS } })
expect(w.get('a').attributes('href')).toBe('https://github.com/o/r/pull/5')
expect(w.text()).toContain('<b>bold</b>')
expect(w.find('b').exists()).toBe(false)
expect(w.text()).toContain('9d')
expect(w.text()).toContain('Critical')
})
it('shows the age meter only for waiting pull requests', () => {
const pr = mount(AttentionRow, { props: { row: row(), nowMs, critSeconds: CRIT_SECONDS } })
expect(pr.find('[aria-label$="critical age"]').exists()).toBe(true)
const wf = mount(AttentionRow, {
props: { row: row({ kind: 'workflow_failed' }), nowMs, critSeconds: CRIT_SECONDS },
})
expect(wf.find('[aria-label$="critical age"]').exists()).toBe(false)
})
it('omits the age for an unknown timestamp', () => {
const w = mount(AttentionRow, {
props: {
row: row({ since: '0001-01-01T00:00:00Z', kind: 'issues_stale' }),
nowMs,
critSeconds: CRIT_SECONDS,
},
})
expect(w.find('.font-mono').text()).toBe('')
})
})
describe('RepoGroup', () => {
const block = buildBlocks(mixedState())[0]
it('shows the repo, its version link and an item count', () => {
const w = mount(RepoGroup, { props: { block, nowMs, critSeconds: CRIT_SECONDS } })
expect(w.text()).toContain('o/')
expect(w.get('a[href="https://github.com/o/r"]').text()).toContain('r')
expect(w.get('a[href="https://github.com/o/r/releases/tag/v1.0.0"]').text()).toBe('v1.0.0')
expect(w.text()).toContain('2 items')
expect(w.findAll('[data-test=row]')).toHaveLength(2)
})
it('omits the version badge without a release and uses the singular for one item', () => {
const one = { repo: repo({ release: null }), rows: [block.rows[0]] }
const w = mount(RepoGroup, { props: { block: one, nowMs, critSeconds: CRIT_SECONDS } })
expect(w.find('a[href*="/releases/"]').exists()).toBe(false)
expect(w.text()).toContain('1 item')
})
})
describe('AttentionList', () => {
const blocks = buildBlocks(mixedState())
const props = {
blocks,
nowMs,
critSeconds: CRIT_SECONDS,
facets: facetCounts(blocks, emptyFilters(), ['o']),
owners: ['o'],
active: 0,
filters: emptyFilters(),
}
it('renders one group per repo', () => {
const w = mount(AttentionList, { props })
expect(w.findAll('[data-test=repo-block]')).toHaveLength(1)
})
it('bubbles filter changes up', async () => {
const w = mount(AttentionList, { props })
await w.get('[data-test=sev-crit]').trigger('click')
const [emitted] = w.emitted('update:filters')!.at(-1) as [Filters]
expect(emitted.severity).toBe('crit')
})
it('forwards clear', async () => {
const w = mount(AttentionList, { props: { ...props, active: 2 } })
await w.get('[data-test=clear-all]').trigger('click')
expect(w.emitted('clear')).toHaveLength(1)
})
it('explains an empty selection', () => {
const w = mount(AttentionList, { props: { ...props, blocks: [] } })
expect(w.get('[data-test=empty]').text()).toContain('Nothing matches')
})
})
describe('FilterBar', () => {
const blocks = buildBlocks(mixedState())
const base = (filters: Filters = emptyFilters(), owners = ['o', 'p']) => ({
facets: facetCounts(blocks, filters, owners),
owners,
active: activeFilterCount(filters),
filters,
})
it('shows live counts in every facet', () => {
const w = mount(FilterBar, { props: base() })
expect(w.get('[data-test=sev-all]').text()).toBe('All 2')
expect(w.get('[data-test=sev-crit]').text()).toBe('Critical 2')
expect(w.get('[data-test=sev-warn]').text()).toBe('Warnings 0')
expect(w.get('[data-test=kind-workflow]').text()).toContain('1')
expect(w.get('[data-test=kind-pr]').text()).toContain('1')
expect(w.get('[data-test=owner-o]').text()).toContain('2')
})
it('disables facet values that would match nothing, but not selected ones', () => {
const w = mount(FilterBar, { props: base() })
expect(w.get('[data-test=kind-issue]').attributes('disabled')).toBeDefined()
expect(w.get('[data-test=owner-p]').attributes('disabled')).toBeDefined()
expect(w.get('[data-test=kind-pr]').attributes('disabled')).toBeUndefined()
const sel = mount(FilterBar, { props: base({ ...emptyFilters(), kinds: ['issue'] }) })
expect(sel.get('[data-test=kind-issue]').attributes('disabled')).toBeUndefined()
})
it('narrows the other facets when one is selected', () => {
const w = mount(FilterBar, { props: base({ ...emptyFilters(), kinds: ['workflow'] }) })
expect(w.get('[data-test=sev-all]').text()).toBe('All 1')
expect(w.get('[data-test=kind-pr]').text()).toContain('1')
})
it('hides the owner filter when there is only one owner', () => {
const w = mount(FilterBar, { props: base(emptyFilters(), ['o']) })
expect(w.find('[data-test=owner-o]').exists()).toBe(false)
})
it('emits typed search text and clears it with Escape', async () => {
const w = mount(FilterBar, { props: base() })
const input = w.get('[data-test=search]')
await input.setValue('grpc')
expect((w.emitted('update:filters')!.at(-1) as [Filters])[0].query).toBe('grpc')
const typed = mount(FilterBar, { props: base({ ...emptyFilters(), query: 'grpc' }) })
await typed.get('[data-test=search]').trigger('keydown', { key: 'Escape' })
expect((typed.emitted('update:filters')!.at(-1) as [Filters])[0].query).toBe('')
})
it('offers to clear active filters with a count', async () => {
const f = { ...emptyFilters(), query: 'x', kinds: ['pr' as const] }
const w = mount(FilterBar, { props: base(f) })
expect(w.get('[data-test=clear-all]').text()).toBe('Clear 2 filters')
await w.get('[data-test=clear-all]').trigger('click')
expect(w.emitted('clear')).toHaveLength(1)
expect(mount(FilterBar, { props: base() }).find('[data-test=clear-all]').exists()).toBe(false)
})
})
describe('ProjectList', () => {
const repos = mixedState().repos
it('lists every project with release, counts and links', () => {
const w = mount(ProjectList, { props: { repos, total: 2, focused: null, nowMs } })
const rows = w.findAll('[data-test=project]')
expect(rows).toHaveLength(2)
expect(rows[0].text()).toContain('v1.0.0')
expect(rows[0].text()).toContain('released 3d ago')
expect(rows[0].text()).toContain('1/3 failing')
expect(rows[0].get('a[href="https://github.com/o/r/pulls"]').text()).toContain('2')
expect(rows[0].get('a[href="https://github.com/o/r/issues"]').text()).toContain('4')
expect(rows[1].get('a[href="https://github.com/o/fine/releases"]').text()).toBe('no release')
})
it('shows how many projects match and a hint when none do', () => {
const some = mount(ProjectList, {
props: { repos: repos.slice(0, 1), total: 2, focused: null, nowMs },
})
expect(some.get('[data-test=project-count]').text()).toBe('1 of 2')
const none = mount(ProjectList, { props: { repos: [], total: 2, focused: null, nowMs } })
expect(none.find('[data-test=no-projects]').exists()).toBe(true)
})
it('emits the repo when its name is chosen and highlights the focused one', async () => {
const w = mount(ProjectList, { props: { repos, total: 2, focused: 'o/fine', nowMs } })
await w.findAll('[data-test=project] button')[0].trigger('click')
expect(w.emitted('select')?.[0]).toEqual(['o/r'])
expect(w.findAll('[data-test=project]')[1].classes()).toContain('bg-accent')
})
})
+28
View File
@@ -0,0 +1,28 @@
<script setup lang="ts">
import type { PrimitiveProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import type { BadgeVariants } from '.'
import { reactiveOmit } from '@vueuse/core'
import { Primitive } from 'reka-ui'
import { cn } from '@/lib/utils'
import { badgeVariants } from '.'
const props = defineProps<
PrimitiveProps & {
variant?: BadgeVariants['variant']
class?: HTMLAttributes['class']
}
>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<Primitive
data-slot="badge"
:class="cn(badgeVariants({ variant }), props.class)"
v-bind="delegatedProps"
>
<slot />
</Primitive>
</template>
+24
View File
@@ -0,0 +1,24 @@
import type { VariantProps } from 'class-variance-authority'
import { cva } from 'class-variance-authority'
export { default as Badge } from './Badge.vue'
export const badgeVariants = cva(
'inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden',
{
variants: {
variant: {
default: 'border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90',
secondary:
'border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90',
destructive:
'border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
outline: 'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground',
},
},
defaultVariants: {
variant: 'default',
},
},
)
export type BadgeVariants = VariantProps<typeof badgeVariants>
+31
View File
@@ -0,0 +1,31 @@
<script setup lang="ts">
import type { PrimitiveProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import type { ButtonVariants } from '.'
import { Primitive } from 'reka-ui'
import { cn } from '@/lib/utils'
import { buttonVariants } from '.'
interface Props extends PrimitiveProps {
variant?: ButtonVariants['variant']
size?: ButtonVariants['size']
class?: HTMLAttributes['class']
}
const props = withDefaults(defineProps<Props>(), {
as: 'button',
})
</script>
<template>
<Primitive
data-slot="button"
:data-variant="variant"
:data-size="size"
:as="as"
:as-child="asChild"
:class="cn(buttonVariants({ variant, size }), props.class)"
>
<slot />
</Primitive>
</template>
+37
View File
@@ -0,0 +1,37 @@
import type { VariantProps } from 'class-variance-authority'
import { cva } from 'class-variance-authority'
export { default as Button } from './Button.vue'
export const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive:
'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
outline:
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
icon: 'size-9',
'icon-xs': "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
'icon-sm': 'size-8',
'icon-lg': 'size-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
)
export type ButtonVariants = VariantProps<typeof buttonVariants>
+35
View File
@@ -0,0 +1,35 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { useVModel } from '@vueuse/core'
import { cn } from '@/lib/utils'
const props = defineProps<{
defaultValue?: string | number
modelValue?: string | number
class?: HTMLAttributes['class']
}>()
const emits = defineEmits<{
(e: 'update:modelValue', payload: string | number): void
}>()
const modelValue = useVModel(props, 'modelValue', emits, {
passive: true,
defaultValue: props.defaultValue,
})
</script>
<template>
<input
v-model="modelValue"
data-slot="input"
:class="
cn(
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-3',
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
props.class,
)
"
/>
</template>
+1
View File
@@ -0,0 +1 @@
export { default as Input } from './Input.vue'
+27
View File
@@ -0,0 +1,27 @@
<script setup lang="ts">
import type { ProgressRootProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { ProgressIndicator, ProgressRoot } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = withDefaults(defineProps<ProgressRootProps & { class?: HTMLAttributes['class'] }>(), {
modelValue: 0,
})
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<ProgressRoot
data-slot="progress"
v-bind="delegatedProps"
:class="cn('bg-primary/20 relative h-2 w-full overflow-hidden rounded-full', props.class)"
>
<ProgressIndicator
data-slot="progress-indicator"
class="bg-primary h-full w-full flex-1 transition-all"
:style="`transform: translateX(-${100 - (props.modelValue ?? 0)}%);`"
/>
</ProgressRoot>
</template>
+1
View File
@@ -0,0 +1 @@
export { default as Progress } from './Progress.vue'
+27
View File
@@ -0,0 +1,27 @@
<script setup lang="ts">
import type { SeparatorProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { Separator } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = withDefaults(defineProps<SeparatorProps & { class?: HTMLAttributes['class'] }>(), {
orientation: 'horizontal',
decorative: true,
})
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<Separator
data-slot="separator"
v-bind="delegatedProps"
:class="
cn(
'bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px',
props.class,
)
"
/>
</template>
+1
View File
@@ -0,0 +1 @@
export { default as Separator } from './Separator.vue'
+16
View File
@@ -0,0 +1,16 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div data-slot="table-container" class="relative w-full overflow-auto">
<table data-slot="table" :class="cn('w-full caption-bottom text-sm', props.class)">
<slot />
</table>
</div>
</template>
+14
View File
@@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<tbody data-slot="table-body" :class="cn('[&_tr:last-child]:border-0', props.class)">
<slot />
</tbody>
</template>
+14
View File
@@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<caption data-slot="table-caption" :class="cn('text-muted-foreground mt-4 text-sm', props.class)">
<slot />
</caption>
</template>
+22
View File
@@ -0,0 +1,22 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<td
data-slot="table-cell"
:class="
cn(
'p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 *:[[role=checkbox]]:translate-y-0.5',
props.class,
)
"
>
<slot />
</td>
</template>
+32
View File
@@ -0,0 +1,32 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { cn } from '@/lib/utils'
import TableCell from './TableCell.vue'
import TableRow from './TableRow.vue'
const props = withDefaults(
defineProps<{
class?: HTMLAttributes['class']
colspan?: number
}>(),
{
colspan: 1,
},
)
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<TableRow>
<TableCell
:class="cn('p-4 whitespace-nowrap align-middle text-sm text-foreground', props.class)"
v-bind="delegatedProps"
>
<div class="flex items-center justify-center py-10">
<slot />
</div>
</TableCell>
</TableRow>
</template>
+17
View File
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<tfoot
data-slot="table-footer"
:class="cn('bg-muted/50 border-t font-medium [&>tr]:last:border-b-0', props.class)"
>
<slot />
</tfoot>
</template>
+22
View File
@@ -0,0 +1,22 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<th
data-slot="table-head"
:class="
cn(
'text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 *:[[role=checkbox]]:translate-y-0.5',
props.class,
)
"
>
<slot />
</th>
</template>
+14
View File
@@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<thead data-slot="table-header" :class="cn('[&_tr]:border-b', props.class)">
<slot />
</thead>
</template>
+19
View File
@@ -0,0 +1,19 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<tr
data-slot="table-row"
:class="
cn('hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors', props.class)
"
>
<slot />
</tr>
</template>
+9
View File
@@ -0,0 +1,9 @@
export { default as Table } from './Table.vue'
export { default as TableBody } from './TableBody.vue'
export { default as TableCaption } from './TableCaption.vue'
export { default as TableCell } from './TableCell.vue'
export { default as TableEmpty } from './TableEmpty.vue'
export { default as TableFooter } from './TableFooter.vue'
export { default as TableHead } from './TableHead.vue'
export { default as TableHeader } from './TableHeader.vue'
export { default as TableRow } from './TableRow.vue'
@@ -0,0 +1,59 @@
<script setup lang="ts">
import type { VariantProps } from 'class-variance-authority'
import type { ToggleGroupRootEmits, ToggleGroupRootProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import type { toggleVariants } from '@/components/ui/toggle'
import { reactiveOmit } from '@vueuse/core'
import { ToggleGroupRoot, useForwardPropsEmits } from 'reka-ui'
import { provide } from 'vue'
import { cn } from '@/lib/utils'
type ToggleGroupVariants = VariantProps<typeof toggleVariants>
const props = withDefaults(
defineProps<
ToggleGroupRootProps & {
class?: HTMLAttributes['class']
variant?: ToggleGroupVariants['variant']
size?: ToggleGroupVariants['size']
spacing?: number
}
>(),
{
spacing: 0,
},
)
const emits = defineEmits<ToggleGroupRootEmits>()
provide('toggleGroup', {
variant: props.variant,
size: props.size,
spacing: props.spacing,
})
const delegatedProps = reactiveOmit(props, 'class', 'size', 'variant')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<ToggleGroupRoot
v-slot="slotProps"
data-slot="toggle-group"
:data-size="size"
:data-variant="variant"
:data-spacing="spacing"
:style="{
'--gap': spacing,
}"
v-bind="forwarded"
:class="
cn(
'group/toggle-group flex w-fit items-center gap-[--spacing(var(--gap))] rounded-md data-[spacing=default]:data-[variant=outline]:shadow-xs',
props.class,
)
"
>
<slot v-bind="slotProps" />
</ToggleGroupRoot>
</template>
@@ -0,0 +1,51 @@
<script setup lang="ts">
import type { VariantProps } from 'class-variance-authority'
import type { ToggleGroupItemProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { ToggleGroupItem, useForwardProps } from 'reka-ui'
import { inject } from 'vue'
import { cn } from '@/lib/utils'
import { toggleVariants } from '@/components/ui/toggle'
type ToggleGroupVariants = VariantProps<typeof toggleVariants> & {
spacing?: number
}
const props = defineProps<
ToggleGroupItemProps & {
class?: HTMLAttributes['class']
variant?: ToggleGroupVariants['variant']
size?: ToggleGroupVariants['size']
}
>()
const context = inject<ToggleGroupVariants>('toggleGroup')
const delegatedProps = reactiveOmit(props, 'class', 'size', 'variant')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<ToggleGroupItem
v-slot="slotProps"
data-slot="toggle-group-item"
:data-variant="context?.variant || variant"
:data-size="context?.size || size"
:data-spacing="context?.spacing"
v-bind="forwardedProps"
:class="
cn(
toggleVariants({
variant: context?.variant || variant,
size: context?.size || size,
}),
'w-auto min-w-0 shrink-0 px-3 focus:z-10 focus-visible:z-10',
'data-[spacing=0]:rounded-none data-[spacing=0]:shadow-none data-[spacing=0]:first:rounded-l-md data-[spacing=0]:last:rounded-r-md data-[spacing=0]:data-[variant=outline]:border-l-0 data-[spacing=0]:data-[variant=outline]:first:border-l',
props.class,
)
"
>
<slot v-bind="slotProps" />
</ToggleGroupItem>
</template>
+2
View File
@@ -0,0 +1,2 @@
export { default as ToggleGroup } from './ToggleGroup.vue'
export { default as ToggleGroupItem } from './ToggleGroupItem.vue'
+40
View File
@@ -0,0 +1,40 @@
<script setup lang="ts">
import type { ToggleEmits, ToggleProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import type { ToggleVariants } from '.'
import { reactiveOmit } from '@vueuse/core'
import { Toggle, useForwardPropsEmits } from 'reka-ui'
import { cn } from '@/lib/utils'
import { toggleVariants } from '.'
const props = withDefaults(
defineProps<
ToggleProps & {
class?: HTMLAttributes['class']
variant?: ToggleVariants['variant']
size?: ToggleVariants['size']
}
>(),
{
variant: 'default',
size: 'default',
disabled: false,
},
)
const emits = defineEmits<ToggleEmits>()
const delegatedProps = reactiveOmit(props, 'class', 'size', 'variant')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<Toggle
v-slot="slotProps"
data-slot="toggle"
v-bind="forwarded"
:class="cn(toggleVariants({ variant, size }), props.class)"
>
<slot v-bind="slotProps" />
</Toggle>
</template>
+28
View File
@@ -0,0 +1,28 @@
import type { VariantProps } from 'class-variance-authority'
import { cva } from 'class-variance-authority'
export { default as Toggle } from './Toggle.vue'
export const toggleVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-3 outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
{
variants: {
variant: {
default: 'bg-transparent',
outline:
'border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground',
},
size: {
default: 'h-9 px-2 min-w-9',
sm: 'h-8 px-1.5 min-w-8',
lg: 'h-10 px-2.5 min-w-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
)
export type ToggleVariants = VariantProps<typeof toggleVariants>
+15
View File
@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { TooltipRootEmits, TooltipRootProps } from 'reka-ui'
import { TooltipRoot, useForwardPropsEmits } from 'reka-ui'
const props = defineProps<TooltipRootProps>()
const emits = defineEmits<TooltipRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<TooltipRoot v-slot="slotProps" data-slot="tooltip" v-bind="forwarded">
<slot v-bind="slotProps" />
</TooltipRoot>
</template>
@@ -0,0 +1,44 @@
<script setup lang="ts">
import type { TooltipContentEmits, TooltipContentProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { TooltipArrow, TooltipContent, TooltipPortal, useForwardPropsEmits } from 'reka-ui'
import { cn } from '@/lib/utils'
defineOptions({
inheritAttrs: false,
})
const props = withDefaults(
defineProps<TooltipContentProps & { class?: HTMLAttributes['class'] }>(),
{
sideOffset: 4,
},
)
const emits = defineEmits<TooltipContentEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<TooltipPortal>
<TooltipContent
data-slot="tooltip-content"
v-bind="{ ...forwarded, ...$attrs }"
:class="
cn(
'bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit rounded-md px-3 py-1.5 text-xs text-balance',
props.class,
)
"
>
<slot />
<TooltipArrow
class="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-xs"
/>
</TooltipContent>
</TooltipPortal>
</template>
@@ -0,0 +1,14 @@
<script setup lang="ts">
import type { TooltipProviderProps } from 'reka-ui'
import { TooltipProvider } from 'reka-ui'
const props = withDefaults(defineProps<TooltipProviderProps>(), {
delayDuration: 0,
})
</script>
<template>
<TooltipProvider v-bind="props">
<slot />
</TooltipProvider>
</template>
@@ -0,0 +1,12 @@
<script setup lang="ts">
import type { TooltipTriggerProps } from 'reka-ui'
import { TooltipTrigger } from 'reka-ui'
const props = defineProps<TooltipTriggerProps>()
</script>
<template>
<TooltipTrigger data-slot="tooltip-trigger" v-bind="props">
<slot />
</TooltipTrigger>
</template>
+4
View File
@@ -0,0 +1,4 @@
export { default as Tooltip } from './Tooltip.vue'
export { default as TooltipContent } from './TooltipContent.vue'
export { default as TooltipProvider } from './TooltipProvider.vue'
export { default as TooltipTrigger } from './TooltipTrigger.vue'
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+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))
}
+8
View File
@@ -0,0 +1,8 @@
import '@fontsource-variable/jetbrains-mono'
import '@fontsource-variable/schibsted-grotesk'
import { createPinia } from 'pinia'
import { createApp } from 'vue'
import App from './App.vue'
import './style.css'
createApp(App).use(createPinia()).mount('#app')
+6
View File
@@ -0,0 +1,6 @@
// Lets plain `tsc` resolve .vue imports; vue-tsc reads the real component types.
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<object, object, unknown>
export default component
}
+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,
}
})
+82
View File
@@ -0,0 +1,82 @@
@import 'tailwindcss';
@custom-variant dark (@media (prefers-color-scheme: dark));
:root {
--background: #f3f1ea;
--surface: #fbfaf6;
--foreground: #1b1a17;
--muted-foreground: #66625a;
--border: #dcd7ca;
--link: #0b5c8a;
--crit: #c4291c;
--warn: #8f5b00;
--ok: #17794d;
--primary: #0b5c8a;
--primary-foreground: #ffffff;
--secondary: #e7e3d9;
--secondary-foreground: #1b1a17;
--muted: #ebe8df;
--accent: #e7e3d9;
--accent-foreground: #1b1a17;
--destructive: #c4291c;
--input: #cdc8bb;
--ring: #0b5c8a;
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0d0d0c;
--surface: #151513;
--foreground: #eceae3;
--muted-foreground: #98948a;
--border: #272622;
--link: #7dbdf0;
--crit: #ff6a5c;
--warn: #f2b43a;
--ok: #4cc38a;
--primary: #7dbdf0;
--primary-foreground: #0b1620;
--secondary: #201f1c;
--secondary-foreground: #eceae3;
--muted: #1b1a18;
--accent: #22211e;
--accent-foreground: #eceae3;
--destructive: #ff6a5c;
--input: #38362f;
--ring: #7dbdf0;
}
}
@theme inline {
--color-background: var(--background);
--color-surface: var(--surface);
--color-foreground: var(--foreground);
--color-muted-foreground: var(--muted-foreground);
--color-border: var(--border);
--color-link: var(--link);
--color-crit: var(--crit);
--color-warn: var(--warn);
--color-ok: var(--ok);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-input: var(--input);
--color-ring: var(--ring);
--font-sans: 'Schibsted Grotesk Variable', 'Avenir Next', system-ui, sans-serif;
--font-mono: 'JetBrains Mono Variable', ui-monospace, 'SF Mono', Menlo, monospace;
}
@layer base {
body {
@apply bg-background text-foreground font-sans antialiased;
}
:focus-visible {
@apply outline-2 outline-offset-2 outline-link;
}
}
+104
View File
@@ -0,0 +1,104 @@
import type { Alert, Repo, TrackerState } from '@/lib/types'
export const NOW = '2026-10-02T12:00:00Z'
export const hoursAgo = (h: number) => new Date(Date.parse(NOW) - h * 3600_000).toISOString()
export function repo(over: Partial<Repo> = {}): Repo {
return {
fullName: 'o/r',
url: 'https://github.com/o/r',
releasesUrl: 'https://github.com/o/r/releases',
description: '',
health: 'ok',
error: '',
workflows: { total: 0, failing: 0, running: 0 },
prs: { open: 0, waiting: 0, failingChecks: 0, oldestSeconds: 0 },
issues: { open: 0, stale: 0 },
archived: false,
up: true,
refreshedAt: hoursAgo(1),
release: {
tag: 'v1.0.0',
url: 'https://github.com/o/r/releases/tag/v1.0.0',
publishedAt: hoursAgo(72),
prerelease: false,
fromTag: false,
},
...over,
}
}
export function alert(over: Partial<Alert> = {}): Alert {
return {
repo: 'o/r',
kind: 'workflow_failed',
severity: 'crit',
subject: 'CI',
detail: 'failure on main (push)',
url: 'https://github.com/o/r/actions/runs/1',
since: hoursAgo(2),
...over,
}
}
export function state(over: Partial<TrackerState> = {}): TrackerState {
return {
now: NOW,
lastRefresh: hoursAgo(1),
intervalSeconds: 21600,
loaded: true,
refreshing: false,
manualRefresh: false,
counts: { crit: 0, warn: 0 },
thresholds: {
prWarnSeconds: 172800,
prCritSeconds: 604800,
issueStaleSeconds: 2592000,
workflowStuckSeconds: 21600,
workflowDormantSeconds: 2592000,
},
repos: [],
alerts: [],
...over,
}
}
/** A state with one failing repo (two alerts on one PR) and one healthy repo. */
export function mixedState(): TrackerState {
return state({
counts: { crit: 2, warn: 1 },
repos: [
repo({
health: 'crit',
workflows: { total: 3, failing: 1, running: 0 },
prs: { open: 2, waiting: 1, failingChecks: 1, oldestSeconds: 777600 },
issues: { open: 4, stale: 2 },
}),
repo({
fullName: 'o/fine',
url: 'https://github.com/o/fine',
release: null,
releasesUrl: 'https://github.com/o/fine/releases',
}),
],
alerts: [
alert(),
alert({
kind: 'pr_waiting',
severity: 'crit',
subject: '#5 <b>bold</b> title',
detail: 'open 9d',
url: 'https://github.com/o/r/pull/5',
since: hoursAgo(216),
}),
alert({
kind: 'pr_checks_failing',
severity: 'warn',
subject: '#5 <b>bold</b> title',
detail: 'checks failing: build',
url: 'https://github.com/o/r/pull/5',
since: hoursAgo(10),
}),
],
})
}