mirror of
https://github.com/lukaszraczylo/ghtracker.git
synced 2026-10-15 01:07:40 +00:00
feat: add GitHub repository dashboard with Prometheus metrics
ghtracker authenticates as a GitHub App and scans a configured list of repositories on a schedule. It serves a dashboard, a JSON state endpoint, a health probe and Prometheus metrics. The dashboard lists only items that need attention: failing workflows, pull requests that wait too long or fail their checks, stale issues and projects that failed to scan. It has search, dynamic filters and a button that rescans on demand. The Vue app is embedded in the Go binary. The client protects the GitHub rate limit with conditional requests, a request reserve and Retry-After handling.
This commit is contained in:
@@ -0,0 +1,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
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as Input } from './Input.vue'
|
||||
@@ -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>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as Progress } from './Progress.vue'
|
||||
@@ -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>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as Separator } from './Separator.vue'
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as ToggleGroup } from './ToggleGroup.vue'
|
||||
export { default as ToggleGroupItem } from './ToggleGroupItem.vue'
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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'
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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')
|
||||
Vendored
+6
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { alert, mixedState, repo, state } from '@/test/fixtures'
|
||||
import { buildBlocks, computeFacts, countByRepo, SCAN_POLL_MS, useTrackerStore } from './tracker'
|
||||
|
||||
function mockFetch(handler: (url: string, init?: RequestInit) => Response | Promise<Response>) {
|
||||
const fn = vi.fn((url: string, init?: RequestInit) => Promise.resolve(handler(url, init)))
|
||||
vi.stubGlobal('fetch', fn)
|
||||
return fn
|
||||
}
|
||||
const json = (body: unknown, status = 200) =>
|
||||
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } })
|
||||
|
||||
describe('buildBlocks', () => {
|
||||
it('shows only unhealthy repos and merges alerts on the same target', () => {
|
||||
const blocks = buildBlocks(mixedState())
|
||||
expect(blocks.map((b) => b.repo.fullName)).toEqual(['o/r'])
|
||||
const rows = blocks[0].rows
|
||||
expect(rows).toHaveLength(2)
|
||||
const pr = rows.find((r) => r.url.endsWith('/pull/5'))!
|
||||
expect(pr.severity).toBe('crit')
|
||||
expect(pr.detail).toBe('open 9d · checks failing: build')
|
||||
})
|
||||
|
||||
it('keeps the worst severity when a warning arrives first', () => {
|
||||
const s = state({
|
||||
repos: [repo({ health: 'crit' })],
|
||||
alerts: [
|
||||
alert({ severity: 'warn', url: 'u', detail: 'a' }),
|
||||
alert({ severity: 'crit', url: 'u', detail: 'b' }),
|
||||
],
|
||||
})
|
||||
expect(buildBlocks(s)[0].rows[0].severity).toBe('crit')
|
||||
})
|
||||
|
||||
it('orders rows by severity, critical first', () => {
|
||||
const s = state({
|
||||
repos: [repo({ health: 'crit' })],
|
||||
alerts: [
|
||||
alert({ severity: 'warn', url: 'w', subject: 'warn row' }),
|
||||
alert({ severity: 'crit', url: 'c', subject: 'crit row' }),
|
||||
],
|
||||
})
|
||||
expect(buildBlocks(s)[0].rows.map((r) => r.subject)).toEqual(['crit row', 'warn row'])
|
||||
})
|
||||
|
||||
it('keeps the repo order from the server and ignores alerts for unknown repos', () => {
|
||||
const s = state({
|
||||
repos: [repo({ fullName: 'o/b' }), repo({ fullName: 'o/a' })],
|
||||
alerts: [
|
||||
alert({ repo: 'o/a', url: '1' }),
|
||||
alert({ repo: 'o/b', url: '2' }),
|
||||
alert({ repo: 'o/ghost', url: '3' }),
|
||||
],
|
||||
})
|
||||
expect(buildBlocks(s).map((b) => b.repo.fullName)).toEqual(['o/b', 'o/a'])
|
||||
})
|
||||
|
||||
it('returns no blocks when everything is healthy', () => {
|
||||
expect(buildBlocks(state({ repos: [repo()] }))).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('overview helpers', () => {
|
||||
it('counts alerts of each severity per repo', () => {
|
||||
const counts = countByRepo(mixedState())
|
||||
expect(counts.get('o/r')).toEqual({ crit: 2, warn: 1 })
|
||||
expect(counts.has('o/fine')).toBe(false)
|
||||
})
|
||||
|
||||
it('derives headline facts from repos and alert kinds', () => {
|
||||
const s = mixedState()
|
||||
s.alerts.push(
|
||||
alert({ kind: 'partial_data', severity: 'warn', url: 'x', subject: 'Incomplete data' }),
|
||||
)
|
||||
expect(computeFacts(s)).toEqual({
|
||||
failingWorkflows: 1,
|
||||
waitingPrs: 1,
|
||||
failingChecks: 1,
|
||||
staleIssues: 2,
|
||||
dataProblems: 1,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('tracker store', () => {
|
||||
beforeEach(() => setActivePinia(createPinia()))
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
it('loads state and derives blocks', async () => {
|
||||
mockFetch(() => json(mixedState()))
|
||||
const store = useTrackerStore()
|
||||
await store.load()
|
||||
expect(store.error).toBe('')
|
||||
expect(store.state?.repos).toHaveLength(2)
|
||||
expect(store.blocks).toHaveLength(1)
|
||||
expect(store.loading).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the last good state and reports the error when a poll fails', async () => {
|
||||
const fetchMock = mockFetch(() => json(mixedState()))
|
||||
const store = useTrackerStore()
|
||||
await store.load()
|
||||
fetchMock.mockImplementationOnce(() => Promise.resolve(new Response('boom', { status: 500 })))
|
||||
await store.load()
|
||||
expect(store.error).toContain('HTTP 500')
|
||||
expect(store.state?.repos).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('reports network failures', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('offline')))
|
||||
const store = useTrackerStore()
|
||||
await store.load()
|
||||
expect(store.error).toBe('offline')
|
||||
expect(store.state).toBeNull()
|
||||
})
|
||||
|
||||
it('toggles the focused repo and exposes filtered blocks and facets', async () => {
|
||||
mockFetch(() => json(mixedState()))
|
||||
const store = useTrackerStore()
|
||||
await store.load()
|
||||
store.toggleRepo('o/fine')
|
||||
expect(store.filters.repo).toBe('o/fine')
|
||||
expect(store.visibleBlocks).toEqual([])
|
||||
store.toggleRepo('o/fine')
|
||||
expect(store.filters.repo).toBeNull()
|
||||
store.filters.severity = 'warn'
|
||||
expect(store.visibleBlocks).toEqual([])
|
||||
expect(store.facets.severity.all).toBe(store.facets.severity.crit + store.facets.severity.warn)
|
||||
store.filters.severity = 'crit'
|
||||
expect(store.visibleBlocks).toHaveLength(1)
|
||||
expect(store.activeFilters).toBe(1)
|
||||
expect(store.facts?.failingWorkflows).toBe(1)
|
||||
expect(store.owners).toEqual(['o'])
|
||||
})
|
||||
|
||||
it('searches projects by name and clears every filter at once', async () => {
|
||||
mockFetch(() => json(mixedState()))
|
||||
const store = useTrackerStore()
|
||||
await store.load()
|
||||
store.filters.query = 'fine'
|
||||
expect(store.visibleRepos.map((r) => r.fullName)).toEqual(['o/fine'])
|
||||
expect(store.visibleBlocks).toEqual([])
|
||||
store.clearFilters()
|
||||
expect(store.activeFilters).toBe(0)
|
||||
expect(store.visibleRepos).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('keeps a project in the list when the search matches one of its items', async () => {
|
||||
mockFetch(() => json(mixedState()))
|
||||
const store = useTrackerStore()
|
||||
await store.load()
|
||||
store.filters.query = 'bold'
|
||||
expect(store.visibleRepos.map((r) => r.fullName)).toEqual(['o/r'])
|
||||
store.filters.owners = ['other']
|
||||
expect(store.visibleRepos).toEqual([])
|
||||
})
|
||||
|
||||
it('anchors the clock to server time so skewed browsers show right ages', async () => {
|
||||
mockFetch(() => json(state({ now: '2030-01-01T00:00:00Z' })))
|
||||
const store = useTrackerStore()
|
||||
await store.load()
|
||||
expect(Math.abs(store.nowMs - Date.parse('2030-01-01T00:00:00Z'))).toBeLessThan(2000)
|
||||
})
|
||||
|
||||
it('posts a rescan and follows it until new data arrives', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const calls: string[] = []
|
||||
let polls = 0
|
||||
mockFetch((url, init) => {
|
||||
calls.push(`${init?.method ?? 'GET'} ${url}`)
|
||||
if (url === '/refresh') return new Response(null, { status: 202 })
|
||||
polls += 1
|
||||
// Poll 1: old data. Poll 2: scan running. Poll 3: finished with a new timestamp.
|
||||
if (polls === 1) return json(state({ lastRefresh: '2026-10-02T11:00:00Z' }))
|
||||
if (polls === 2)
|
||||
return json(state({ lastRefresh: '2026-10-02T11:00:00Z', refreshing: true }))
|
||||
return json(state({ lastRefresh: '2026-10-02T12:00:00Z' }))
|
||||
})
|
||||
const store = useTrackerStore()
|
||||
await store.load()
|
||||
const done = store.rescan()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(store.scanning).toBe(true)
|
||||
await vi.advanceTimersByTimeAsync(SCAN_POLL_MS * 3)
|
||||
await done
|
||||
expect(store.scanning).toBe(false)
|
||||
expect(store.state?.lastRefresh).toBe('2026-10-02T12:00:00Z')
|
||||
expect(calls[0]).toBe('GET /api/state')
|
||||
expect(calls[1]).toBe('POST /refresh')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('shows a notice, not an error, when a rescan is rate limited', async () => {
|
||||
mockFetch(() => new Response('slow down', { status: 429 }))
|
||||
const store = useTrackerStore()
|
||||
await store.rescan()
|
||||
expect(store.notice).toContain('once every 5 minutes')
|
||||
expect(store.error).toBe('')
|
||||
expect(store.scanning).toBe(false)
|
||||
})
|
||||
|
||||
it('reports other rescan failures as errors', async () => {
|
||||
mockFetch(() => new Response('boom', { status: 500 }))
|
||||
const store = useTrackerStore()
|
||||
await store.rescan()
|
||||
expect(store.error).toContain('HTTP 500')
|
||||
expect(store.notice).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,199 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
import { fetchState, RescanLimitedError, requestRefresh } from '@/lib/api'
|
||||
import {
|
||||
activeFilterCount,
|
||||
applyFilters,
|
||||
emptyFilters,
|
||||
facetCounts,
|
||||
ownerOf,
|
||||
repoMatches,
|
||||
type Filters,
|
||||
} from '@/lib/filters'
|
||||
import { DATA_KINDS } from '@/lib/kinds'
|
||||
import type { Block, Row, TrackerState } from '@/lib/types'
|
||||
|
||||
/** While a rescan runs the server takes ~40s, so check back often instead of every minute. */
|
||||
export const SCAN_POLL_MS = 2000
|
||||
const SCAN_TIMEOUT_MS = 10 * 60 * 1000
|
||||
|
||||
const SEVERITY_RANK = { ok: 0, warn: 1, crit: 2 } as const
|
||||
|
||||
/** Groups alerts into one row per target URL and one block per unhealthy repo. */
|
||||
export function buildBlocks(state: TrackerState): Block[] {
|
||||
const rows = new Map<string, Row>()
|
||||
const order = new Map<string, string[]>()
|
||||
for (const alert of state.alerts) {
|
||||
if (alert.severity === 'ok') continue
|
||||
const key = `${alert.repo}\n${alert.url}`
|
||||
const existing = rows.get(key)
|
||||
if (existing) {
|
||||
existing.detail += ` · ${alert.detail}`
|
||||
if (SEVERITY_RANK[alert.severity] > SEVERITY_RANK[existing.severity]) {
|
||||
existing.severity = alert.severity
|
||||
}
|
||||
continue
|
||||
}
|
||||
rows.set(key, {
|
||||
kind: alert.kind,
|
||||
subject: alert.subject,
|
||||
detail: alert.detail,
|
||||
url: alert.url,
|
||||
since: alert.since,
|
||||
severity: alert.severity,
|
||||
})
|
||||
order.set(alert.repo, [...(order.get(alert.repo) ?? []), key])
|
||||
}
|
||||
const blocks: Block[] = []
|
||||
for (const repo of state.repos) {
|
||||
const keys = order.get(repo.fullName)
|
||||
if (!keys) continue
|
||||
const list = keys.map((k) => rows.get(k)!)
|
||||
list.sort((a, b) => SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity])
|
||||
blocks.push({ repo, rows: list })
|
||||
}
|
||||
return blocks
|
||||
}
|
||||
|
||||
export interface RepoCounts {
|
||||
crit: number
|
||||
warn: number
|
||||
}
|
||||
|
||||
export interface Facts {
|
||||
failingWorkflows: number
|
||||
waitingPrs: number
|
||||
failingChecks: number
|
||||
staleIssues: number
|
||||
dataProblems: number
|
||||
}
|
||||
|
||||
/** Counts the alerts of each severity per repo, for the fleet strip. */
|
||||
export function countByRepo(state: TrackerState): Map<string, RepoCounts> {
|
||||
const out = new Map<string, RepoCounts>()
|
||||
for (const a of state.alerts) {
|
||||
if (a.severity === 'ok') continue
|
||||
const c = out.get(a.repo) ?? { crit: 0, warn: 0 }
|
||||
c[a.severity] += 1
|
||||
out.set(a.repo, c)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Headline numbers for the overview; issue counts come from the repos, not alert lines. */
|
||||
export function computeFacts(state: TrackerState): Facts {
|
||||
const count = (kinds: string[]) => state.alerts.filter((a) => kinds.includes(a.kind)).length
|
||||
return {
|
||||
failingWorkflows: state.repos.reduce((n, r) => n + r.workflows.failing, 0),
|
||||
waitingPrs: count(['pr_waiting']),
|
||||
failingChecks: state.repos.reduce((n, r) => n + r.prs.failingChecks, 0),
|
||||
staleIssues: state.repos.reduce((n, r) => n + r.issues.stale, 0),
|
||||
dataProblems: count(DATA_KINDS),
|
||||
}
|
||||
}
|
||||
|
||||
export const useTrackerStore = defineStore('tracker', () => {
|
||||
const state = ref<TrackerState | null>(null)
|
||||
const error = ref('')
|
||||
const loading = ref(false)
|
||||
const requesting = ref(false)
|
||||
const notice = ref('')
|
||||
const skewMs = ref(0)
|
||||
const tick = ref(Date.now())
|
||||
|
||||
/** Server time advanced by the local clock, so ages stay right when the clocks differ. */
|
||||
const nowMs = computed(() => tick.value + skewMs.value)
|
||||
/** True from the click until the server finished the new scan. */
|
||||
const scanning = computed(() => requesting.value || (state.value?.refreshing ?? false))
|
||||
const blocks = computed(() => (state.value ? buildBlocks(state.value) : []))
|
||||
const filters = ref<Filters>(emptyFilters())
|
||||
const visibleBlocks = computed(() => applyFilters(blocks.value, filters.value))
|
||||
const owners = computed(() => [
|
||||
...new Set((state.value?.repos ?? []).map((r) => ownerOf(r.fullName))),
|
||||
])
|
||||
const facets = computed(() => facetCounts(blocks.value, filters.value, owners.value))
|
||||
/** Repos that match by name or version, plus repos owning an item the search matched. */
|
||||
const visibleRepos = computed(() => {
|
||||
const withItems = new Set(visibleBlocks.value.map((b) => b.repo.fullName))
|
||||
return (state.value?.repos ?? []).filter(
|
||||
(r) =>
|
||||
repoMatches(r, filters.value) ||
|
||||
(filters.value.query.trim() !== '' && withItems.has(r.fullName)),
|
||||
)
|
||||
})
|
||||
const activeFilters = computed(() => activeFilterCount(filters.value))
|
||||
const repoCounts = computed(() => (state.value ? countByRepo(state.value) : new Map()))
|
||||
const facts = computed(() => (state.value ? computeFacts(state.value) : null))
|
||||
|
||||
function toggleRepo(fullName: string): void {
|
||||
filters.value.repo = filters.value.repo === fullName ? null : fullName
|
||||
}
|
||||
|
||||
function clearFilters(): void {
|
||||
filters.value = emptyFilters()
|
||||
}
|
||||
|
||||
function advanceClock(): void {
|
||||
tick.value = Date.now()
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true
|
||||
try {
|
||||
const next = await fetchState()
|
||||
skewMs.value = Date.parse(next.now) - Date.now()
|
||||
tick.value = Date.now()
|
||||
state.value = next
|
||||
error.value = ''
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : String(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** Asks the server to rescan every repository, then follows it until the new data lands. */
|
||||
async function rescan(): Promise<void> {
|
||||
const before = state.value?.lastRefresh ?? ''
|
||||
requesting.value = true
|
||||
notice.value = ''
|
||||
try {
|
||||
await requestRefresh()
|
||||
error.value = ''
|
||||
const deadline = Date.now() + SCAN_TIMEOUT_MS
|
||||
do {
|
||||
await load()
|
||||
if (state.value && state.value.lastRefresh !== before && !state.value.refreshing) break
|
||||
await new Promise((resolve) => setTimeout(resolve, SCAN_POLL_MS))
|
||||
} while (Date.now() < deadline)
|
||||
} catch (e) {
|
||||
if (e instanceof RescanLimitedError) notice.value = e.message
|
||||
else error.value = e instanceof Error ? e.message : String(e)
|
||||
} finally {
|
||||
requesting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
state,
|
||||
error,
|
||||
loading,
|
||||
notice,
|
||||
scanning,
|
||||
nowMs,
|
||||
blocks,
|
||||
filters,
|
||||
owners,
|
||||
facets,
|
||||
visibleRepos,
|
||||
activeFilters,
|
||||
visibleBlocks,
|
||||
repoCounts,
|
||||
facts,
|
||||
toggleRepo,
|
||||
clearFilters,
|
||||
load,
|
||||
rescan,
|
||||
advanceClock,
|
||||
}
|
||||
})
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}),
|
||||
],
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user