Files
ghtracker/src/stores/tracker.ts
T
lukaszraczylo 23bbbedf59 feat(ui): show alert action buttons and status chips
Alerts that match a configured action get a button labelled from the
config. A confirm dialog opens first when the action defines confirm text.
After a press the UI posts the alert, reloads the state through the
existing poll path and shows the reported status as a chip, with an anchor
when the status carries a link.

The alert dialog comes from reka-ui through the usual shadcn-vue wrappers.
2026-10-04 16:37:31 +01:00

277 lines
9.3 KiB
TypeScript

import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import {
fetchState,
RescanLimitedError,
requestAction,
requestRefresh,
requestRepoRefresh,
} from '@/lib/api'
import {
activeFilterCount,
applyFilters,
emptyFilters,
facetCounts,
ownerOf,
repoMatches,
type Filters,
} from '@/lib/filters'
import { DATA_KINDS } from '@/lib/kinds'
import type { ActionStatus, 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
const sinceMs = (since: string) => Date.parse(since) || 0
/** Identifies one action on one alert; the separators cannot occur in an id or a repo name. */
export const actionKey = (id: string, repo: string, url: string) => `${id}\n${repo}\n${url}`
/** Groups alerts into one row per target URL and one block per unhealthy repo, newest problem first. */
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
}
if (sinceMs(alert.since) > sinceMs(existing.since)) existing.since = alert.since
continue
}
const actions = (state.actions ?? []).filter(
(a) => a.kinds.length === 0 || a.kinds.includes(alert.kind),
)
rows.set(key, {
repo: alert.repo,
...(actions.length > 0 && { actions, actionStatus: alert.actionStatus }),
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 })
}
const newest = (b: Block) => Math.max(...b.rows.map((r) => sinceMs(r.since)))
return blocks.sort((a, b) => newest(b) - newest(a))
}
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('')
/** Screen-reader text for a finished single-repository refresh. */
const announcement = ref('')
const refreshingRepos = ref<string[]>([])
/** Action keys with a request in flight. */
const runningActions = ref<string[]>([])
/** Status answered by a webhook, shown until the status list reports one. */
const localStatus = ref<Record<string, ActionStatus>>({})
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))
/** Rows by severity, so the headline matches the lists below it (alerts on one item merge into one row). */
const rowCounts = computed(() => {
const out = { crit: 0, warn: 0 }
for (const b of blocks.value) for (const r of b.rows) out[r.severity] += 1
return out
})
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()
}
function applyState(next: TrackerState): void {
skewMs.value = Date.parse(next.now) - Date.now()
tick.value = Date.now()
state.value = next
}
async function load(): Promise<void> {
loading.value = true
try {
applyState(await fetchState())
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
}
}
/** Refreshes one repository; the answer carries the new state, so no extra fetch follows. */
async function refreshRepo(fullName: string): Promise<void> {
if (refreshingRepos.value.includes(fullName)) return
refreshingRepos.value = [...refreshingRepos.value, fullName]
notice.value = ''
announcement.value = ''
try {
const next = await requestRepoRefresh(fullName)
applyState(next)
const failed = next.repos.find((r) => r.fullName === fullName)?.error
if (failed) notice.value = `Could not refresh ${fullName}: ${failed}`
else announcement.value = `Refreshed ${fullName}`
} catch (e) {
notice.value = e instanceof Error ? e.message : String(e)
} finally {
refreshingRepos.value = refreshingRepos.value.filter((n) => n !== fullName)
}
}
/** Sends one alert to an action's webhook, then reloads the state through the existing poll path. */
async function runAction(id: string, repo: string, url: string): Promise<void> {
const key = actionKey(id, repo, url)
if (runningActions.value.includes(key)) return
runningActions.value = [...runningActions.value, key]
notice.value = ''
try {
const status = await requestAction(id, repo, url)
localStatus.value = { ...localStatus.value, [key]: status ?? { state: 'Requested' } }
await load()
} catch (e) {
notice.value = e instanceof Error ? e.message : String(e)
} finally {
runningActions.value = runningActions.value.filter((k) => k !== key)
}
}
return {
state,
error,
runningActions,
localStatus,
runAction,
loading,
notice,
announcement,
refreshingRepos,
scanning,
nowMs,
blocks,
filters,
owners,
facets,
visibleRepos,
activeFilters,
visibleBlocks,
repoCounts,
rowCounts,
facts,
toggleRepo,
clearFilters,
load,
rescan,
refreshRepo,
advanceClock,
}
})