feat: add GitHub repository dashboard with Prometheus metrics

ghtracker authenticates as a GitHub App and scans a configured list of repositories on a schedule. It serves a dashboard, a JSON state endpoint, a health probe and Prometheus metrics.

The dashboard lists only items that need attention: failing workflows, pull requests that wait too long or fail their checks, stale issues and projects that failed to scan. It has search, dynamic filters and a button that rescans on demand. The Vue app is embedded in the Go binary.

The client protects the GitHub rate limit with conditional requests, a request reserve and Retry-After handling.
This commit is contained in:
2026-10-02 04:28:52 +01:00
commit 5313b56774
110 changed files with 10967 additions and 0 deletions
+28
View File
@@ -0,0 +1,28 @@
<script setup lang="ts">
import type { PrimitiveProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import type { BadgeVariants } from '.'
import { reactiveOmit } from '@vueuse/core'
import { Primitive } from 'reka-ui'
import { cn } from '@/lib/utils'
import { badgeVariants } from '.'
const props = defineProps<
PrimitiveProps & {
variant?: BadgeVariants['variant']
class?: HTMLAttributes['class']
}
>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<Primitive
data-slot="badge"
:class="cn(badgeVariants({ variant }), props.class)"
v-bind="delegatedProps"
>
<slot />
</Primitive>
</template>
+24
View File
@@ -0,0 +1,24 @@
import type { VariantProps } from 'class-variance-authority'
import { cva } from 'class-variance-authority'
export { default as Badge } from './Badge.vue'
export const badgeVariants = cva(
'inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden',
{
variants: {
variant: {
default: 'border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90',
secondary:
'border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90',
destructive:
'border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
outline: 'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground',
},
},
defaultVariants: {
variant: 'default',
},
},
)
export type BadgeVariants = VariantProps<typeof badgeVariants>
+31
View File
@@ -0,0 +1,31 @@
<script setup lang="ts">
import type { PrimitiveProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import type { ButtonVariants } from '.'
import { Primitive } from 'reka-ui'
import { cn } from '@/lib/utils'
import { buttonVariants } from '.'
interface Props extends PrimitiveProps {
variant?: ButtonVariants['variant']
size?: ButtonVariants['size']
class?: HTMLAttributes['class']
}
const props = withDefaults(defineProps<Props>(), {
as: 'button',
})
</script>
<template>
<Primitive
data-slot="button"
:data-variant="variant"
:data-size="size"
:as="as"
:as-child="asChild"
:class="cn(buttonVariants({ variant, size }), props.class)"
>
<slot />
</Primitive>
</template>
+37
View File
@@ -0,0 +1,37 @@
import type { VariantProps } from 'class-variance-authority'
import { cva } from 'class-variance-authority'
export { default as Button } from './Button.vue'
export const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive:
'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
outline:
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
icon: 'size-9',
'icon-xs': "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
'icon-sm': 'size-8',
'icon-lg': 'size-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
)
export type ButtonVariants = VariantProps<typeof buttonVariants>
+35
View File
@@ -0,0 +1,35 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { useVModel } from '@vueuse/core'
import { cn } from '@/lib/utils'
const props = defineProps<{
defaultValue?: string | number
modelValue?: string | number
class?: HTMLAttributes['class']
}>()
const emits = defineEmits<{
(e: 'update:modelValue', payload: string | number): void
}>()
const modelValue = useVModel(props, 'modelValue', emits, {
passive: true,
defaultValue: props.defaultValue,
})
</script>
<template>
<input
v-model="modelValue"
data-slot="input"
:class="
cn(
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-3',
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
props.class,
)
"
/>
</template>
+1
View File
@@ -0,0 +1 @@
export { default as Input } from './Input.vue'
+27
View File
@@ -0,0 +1,27 @@
<script setup lang="ts">
import type { ProgressRootProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { ProgressIndicator, ProgressRoot } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = withDefaults(defineProps<ProgressRootProps & { class?: HTMLAttributes['class'] }>(), {
modelValue: 0,
})
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<ProgressRoot
data-slot="progress"
v-bind="delegatedProps"
:class="cn('bg-primary/20 relative h-2 w-full overflow-hidden rounded-full', props.class)"
>
<ProgressIndicator
data-slot="progress-indicator"
class="bg-primary h-full w-full flex-1 transition-all"
:style="`transform: translateX(-${100 - (props.modelValue ?? 0)}%);`"
/>
</ProgressRoot>
</template>
+1
View File
@@ -0,0 +1 @@
export { default as Progress } from './Progress.vue'
+27
View File
@@ -0,0 +1,27 @@
<script setup lang="ts">
import type { SeparatorProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { Separator } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = withDefaults(defineProps<SeparatorProps & { class?: HTMLAttributes['class'] }>(), {
orientation: 'horizontal',
decorative: true,
})
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<Separator
data-slot="separator"
v-bind="delegatedProps"
:class="
cn(
'bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px',
props.class,
)
"
/>
</template>
+1
View File
@@ -0,0 +1 @@
export { default as Separator } from './Separator.vue'
+16
View File
@@ -0,0 +1,16 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div data-slot="table-container" class="relative w-full overflow-auto">
<table data-slot="table" :class="cn('w-full caption-bottom text-sm', props.class)">
<slot />
</table>
</div>
</template>
+14
View File
@@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<tbody data-slot="table-body" :class="cn('[&_tr:last-child]:border-0', props.class)">
<slot />
</tbody>
</template>
+14
View File
@@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<caption data-slot="table-caption" :class="cn('text-muted-foreground mt-4 text-sm', props.class)">
<slot />
</caption>
</template>
+22
View File
@@ -0,0 +1,22 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<td
data-slot="table-cell"
:class="
cn(
'p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 *:[[role=checkbox]]:translate-y-0.5',
props.class,
)
"
>
<slot />
</td>
</template>
+32
View File
@@ -0,0 +1,32 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { cn } from '@/lib/utils'
import TableCell from './TableCell.vue'
import TableRow from './TableRow.vue'
const props = withDefaults(
defineProps<{
class?: HTMLAttributes['class']
colspan?: number
}>(),
{
colspan: 1,
},
)
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<TableRow>
<TableCell
:class="cn('p-4 whitespace-nowrap align-middle text-sm text-foreground', props.class)"
v-bind="delegatedProps"
>
<div class="flex items-center justify-center py-10">
<slot />
</div>
</TableCell>
</TableRow>
</template>
+17
View File
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<tfoot
data-slot="table-footer"
:class="cn('bg-muted/50 border-t font-medium [&>tr]:last:border-b-0', props.class)"
>
<slot />
</tfoot>
</template>
+22
View File
@@ -0,0 +1,22 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<th
data-slot="table-head"
:class="
cn(
'text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 *:[[role=checkbox]]:translate-y-0.5',
props.class,
)
"
>
<slot />
</th>
</template>
+14
View File
@@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<thead data-slot="table-header" :class="cn('[&_tr]:border-b', props.class)">
<slot />
</thead>
</template>
+19
View File
@@ -0,0 +1,19 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<tr
data-slot="table-row"
:class="
cn('hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors', props.class)
"
>
<slot />
</tr>
</template>
+9
View File
@@ -0,0 +1,9 @@
export { default as Table } from './Table.vue'
export { default as TableBody } from './TableBody.vue'
export { default as TableCaption } from './TableCaption.vue'
export { default as TableCell } from './TableCell.vue'
export { default as TableEmpty } from './TableEmpty.vue'
export { default as TableFooter } from './TableFooter.vue'
export { default as TableHead } from './TableHead.vue'
export { default as TableHeader } from './TableHeader.vue'
export { default as TableRow } from './TableRow.vue'
@@ -0,0 +1,59 @@
<script setup lang="ts">
import type { VariantProps } from 'class-variance-authority'
import type { ToggleGroupRootEmits, ToggleGroupRootProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import type { toggleVariants } from '@/components/ui/toggle'
import { reactiveOmit } from '@vueuse/core'
import { ToggleGroupRoot, useForwardPropsEmits } from 'reka-ui'
import { provide } from 'vue'
import { cn } from '@/lib/utils'
type ToggleGroupVariants = VariantProps<typeof toggleVariants>
const props = withDefaults(
defineProps<
ToggleGroupRootProps & {
class?: HTMLAttributes['class']
variant?: ToggleGroupVariants['variant']
size?: ToggleGroupVariants['size']
spacing?: number
}
>(),
{
spacing: 0,
},
)
const emits = defineEmits<ToggleGroupRootEmits>()
provide('toggleGroup', {
variant: props.variant,
size: props.size,
spacing: props.spacing,
})
const delegatedProps = reactiveOmit(props, 'class', 'size', 'variant')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<ToggleGroupRoot
v-slot="slotProps"
data-slot="toggle-group"
:data-size="size"
:data-variant="variant"
:data-spacing="spacing"
:style="{
'--gap': spacing,
}"
v-bind="forwarded"
:class="
cn(
'group/toggle-group flex w-fit items-center gap-[--spacing(var(--gap))] rounded-md data-[spacing=default]:data-[variant=outline]:shadow-xs',
props.class,
)
"
>
<slot v-bind="slotProps" />
</ToggleGroupRoot>
</template>
@@ -0,0 +1,51 @@
<script setup lang="ts">
import type { VariantProps } from 'class-variance-authority'
import type { ToggleGroupItemProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { ToggleGroupItem, useForwardProps } from 'reka-ui'
import { inject } from 'vue'
import { cn } from '@/lib/utils'
import { toggleVariants } from '@/components/ui/toggle'
type ToggleGroupVariants = VariantProps<typeof toggleVariants> & {
spacing?: number
}
const props = defineProps<
ToggleGroupItemProps & {
class?: HTMLAttributes['class']
variant?: ToggleGroupVariants['variant']
size?: ToggleGroupVariants['size']
}
>()
const context = inject<ToggleGroupVariants>('toggleGroup')
const delegatedProps = reactiveOmit(props, 'class', 'size', 'variant')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<ToggleGroupItem
v-slot="slotProps"
data-slot="toggle-group-item"
:data-variant="context?.variant || variant"
:data-size="context?.size || size"
:data-spacing="context?.spacing"
v-bind="forwardedProps"
:class="
cn(
toggleVariants({
variant: context?.variant || variant,
size: context?.size || size,
}),
'w-auto min-w-0 shrink-0 px-3 focus:z-10 focus-visible:z-10',
'data-[spacing=0]:rounded-none data-[spacing=0]:shadow-none data-[spacing=0]:first:rounded-l-md data-[spacing=0]:last:rounded-r-md data-[spacing=0]:data-[variant=outline]:border-l-0 data-[spacing=0]:data-[variant=outline]:first:border-l',
props.class,
)
"
>
<slot v-bind="slotProps" />
</ToggleGroupItem>
</template>
+2
View File
@@ -0,0 +1,2 @@
export { default as ToggleGroup } from './ToggleGroup.vue'
export { default as ToggleGroupItem } from './ToggleGroupItem.vue'
+40
View File
@@ -0,0 +1,40 @@
<script setup lang="ts">
import type { ToggleEmits, ToggleProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import type { ToggleVariants } from '.'
import { reactiveOmit } from '@vueuse/core'
import { Toggle, useForwardPropsEmits } from 'reka-ui'
import { cn } from '@/lib/utils'
import { toggleVariants } from '.'
const props = withDefaults(
defineProps<
ToggleProps & {
class?: HTMLAttributes['class']
variant?: ToggleVariants['variant']
size?: ToggleVariants['size']
}
>(),
{
variant: 'default',
size: 'default',
disabled: false,
},
)
const emits = defineEmits<ToggleEmits>()
const delegatedProps = reactiveOmit(props, 'class', 'size', 'variant')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<Toggle
v-slot="slotProps"
data-slot="toggle"
v-bind="forwarded"
:class="cn(toggleVariants({ variant, size }), props.class)"
>
<slot v-bind="slotProps" />
</Toggle>
</template>
+28
View File
@@ -0,0 +1,28 @@
import type { VariantProps } from 'class-variance-authority'
import { cva } from 'class-variance-authority'
export { default as Toggle } from './Toggle.vue'
export const toggleVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-3 outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
{
variants: {
variant: {
default: 'bg-transparent',
outline:
'border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground',
},
size: {
default: 'h-9 px-2 min-w-9',
sm: 'h-8 px-1.5 min-w-8',
lg: 'h-10 px-2.5 min-w-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
)
export type ToggleVariants = VariantProps<typeof toggleVariants>
+15
View File
@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { TooltipRootEmits, TooltipRootProps } from 'reka-ui'
import { TooltipRoot, useForwardPropsEmits } from 'reka-ui'
const props = defineProps<TooltipRootProps>()
const emits = defineEmits<TooltipRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<TooltipRoot v-slot="slotProps" data-slot="tooltip" v-bind="forwarded">
<slot v-bind="slotProps" />
</TooltipRoot>
</template>
@@ -0,0 +1,44 @@
<script setup lang="ts">
import type { TooltipContentEmits, TooltipContentProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { TooltipArrow, TooltipContent, TooltipPortal, useForwardPropsEmits } from 'reka-ui'
import { cn } from '@/lib/utils'
defineOptions({
inheritAttrs: false,
})
const props = withDefaults(
defineProps<TooltipContentProps & { class?: HTMLAttributes['class'] }>(),
{
sideOffset: 4,
},
)
const emits = defineEmits<TooltipContentEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<TooltipPortal>
<TooltipContent
data-slot="tooltip-content"
v-bind="{ ...forwarded, ...$attrs }"
:class="
cn(
'bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit rounded-md px-3 py-1.5 text-xs text-balance',
props.class,
)
"
>
<slot />
<TooltipArrow
class="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-xs"
/>
</TooltipContent>
</TooltipPortal>
</template>
@@ -0,0 +1,14 @@
<script setup lang="ts">
import type { TooltipProviderProps } from 'reka-ui'
import { TooltipProvider } from 'reka-ui'
const props = withDefaults(defineProps<TooltipProviderProps>(), {
delayDuration: 0,
})
</script>
<template>
<TooltipProvider v-bind="props">
<slot />
</TooltipProvider>
</template>
@@ -0,0 +1,12 @@
<script setup lang="ts">
import type { TooltipTriggerProps } from 'reka-ui'
import { TooltipTrigger } from 'reka-ui'
const props = defineProps<TooltipTriggerProps>()
</script>
<template>
<TooltipTrigger data-slot="tooltip-trigger" v-bind="props">
<slot />
</TooltipTrigger>
</template>
+4
View File
@@ -0,0 +1,4 @@
export { default as Tooltip } from './Tooltip.vue'
export { default as TooltipContent } from './TooltipContent.vue'
export { default as TooltipProvider } from './TooltipProvider.vue'
export { default as TooltipTrigger } from './TooltipTrigger.vue'