diff --git a/README.md b/README.md index 93ba4c3..0fa170b 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ actions: token_env: ACTION_TOKEN # name of an environment variable that holds a bearer token; optional timeout: 10s status_url: https://example.internal/hooks/rerun/status # optional - confirm: "Run this action for the selected alert?" # optional; omit for no dialog + confirm: 'Run this action for the selected alert?' # optional; omit for no dialog ``` - `POST /api/actions/{id}` takes `{"repo": "owner/name", "url": ""}`. It refuses cross-site requests like `POST /refresh`. The action must exist, the repository must be in the configured list, and a current alert with that repo and URL must have a kind listed in `kinds`. Otherwise the answer is 404. @@ -92,6 +92,7 @@ actions: - A 2xx answer succeeds. If its body is JSON `{"state", "label", "link"}`, the UI shows it as the status of that alert. A non-2xx answer becomes an error message with the upstream body cut to 300 characters. An unreachable webhook gives 502. - `GET /api/state` gains `actions` (`id`, `label`, `confirm`, `kinds`; no URLs and no tokens). When `status_url` is set, the server also fetches it with `GET` (same bearer token) and expects a JSON list of `{"repo", "url", "state", "label", "link"}` rows, newest first. It matches rows to alerts by repo and URL and adds `actionStatus` to each alert, keyed by action id. The result is cached for 10 seconds. If `status_url` is down, the state omits `actionStatus` and sets `actionsError`. - A `link` that is not an http or https address is dropped. +- The dashboard shows one button per action on matching alerts, a confirm dialog when `confirm` is set, and a status chip once a status is known. The chip links to `link` when there is one. The page reads the status on its normal 60-second poll and once right after a press. ## Metrics diff --git a/src/components/AttentionRow.vue b/src/components/AttentionRow.vue index 5e9dcb2..5a5ec9e 100644 --- a/src/components/AttentionRow.vue +++ b/src/components/AttentionRow.vue @@ -2,6 +2,7 @@ import { computed } from 'vue' import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome' import AgeMeter from '@/components/AgeMeter.vue' +import RowActions from '@/components/RowActions.vue' import { ageSince, isZeroTime, secondsSince, shorten } from '@/lib/format' import { iconFor } from '@/lib/kinds' import type { Row } from '@/lib/types' @@ -33,6 +34,7 @@ const ratio = computed(() => secondsSince(props.row.since, props.nowMs) / props.

{{ shorten(row.detail, 120) }}

+
= {}): Row => + buildBlocks(state({ repos: [repo()], alerts: [alert(over)], actions }))[0].rows[0] + +describe('buildBlocks with actions', () => { + it('leaves rows untouched when no actions are configured', () => { + const r = rowFor([]) + expect('actions' in r).toBe(false) + expect('actionStatus' in r).toBe(false) + }) + + it.each([ + ['kind listed', ['workflow_failed'], true], + ['kind not listed', ['pr_waiting'], false], + ['empty kinds match everything', [], true], + ])('%s', (_name, kinds, shown) => { + const r = rowFor([{ ...rerun, kinds }]) + expect(Boolean(r.actions?.length)).toBe(shown) + }) + + it('carries the status of the alert', () => { + const r = rowFor([rerun], { actionStatus: { rerun: { state: 'running' } } }) + expect(r.actionStatus).toEqual({ rerun: { state: 'running' } }) + }) +}) + +describe('RowActions', () => { + beforeEach(() => setActivePinia(createPinia())) + afterEach(() => { + vi.unstubAllGlobals() + document.body.innerHTML = '' + }) + + const mountRow = (row: Row) => + mount(AttentionRow, { + props: { row, nowMs: Date.parse(hoursAgo(0)), critSeconds: 604800 }, + attachTo: document.body, + }) + + it('shows no button without actions', () => { + const w = mountRow(rowFor([])) + expect(w.find('[data-test=row-actions]').exists()).toBe(false) + }) + + it('shows a button labelled from the config', () => { + const w = mountRow(rowFor([rerun])) + expect(w.find('[data-test=action-rerun]').text()).toBe('Re-run') + expect(w.find('[data-test=action-status]').exists()).toBe(false) + }) + + it('asks for confirmation, then posts the alert', async () => { + const fetchMock = vi.fn((url: string) => + Promise.resolve( + new Response( + JSON.stringify( + url.startsWith('/api/actions/') + ? { state: 'queued', link: 'https://ci.example.test/9' } + : state(), + ), + { status: 200 }, + ), + ), + ) + vi.stubGlobal('fetch', fetchMock) + const w = mountRow(rowFor([rerun])) + await w.get('[data-test=action-rerun]').trigger('click') + expect(document.body.textContent).toContain('Run this action?') + expect(fetchMock).not.toHaveBeenCalled() + ;(document.body.querySelector('[data-test=action-confirm]') as HTMLElement).click() + await vi.waitFor(() => expect(w.find('[data-test=action-status]').exists()).toBe(true)) + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] + expect(url).toBe('/api/actions/rerun') + expect(JSON.parse(init.body as string)).toEqual({ + repo: 'o/r', + url: 'https://github.com/o/r/actions/runs/1', + }) + await vi.waitFor(() => + expect(w.get('[data-test=action-status] a').attributes('href')).toBe( + 'https://ci.example.test/9', + ), + ) + }) + + it('posts at once when the action has no confirm text', async () => { + const fetchMock = vi.fn<(url: string, init?: RequestInit) => Promise>(() => + Promise.resolve(new Response('{}', { status: 200 })), + ) + vi.stubGlobal('fetch', fetchMock) + const w = mountRow(rowFor([{ ...rerun, confirm: undefined }])) + await w.get('[data-test=action-rerun]').trigger('click') + expect(fetchMock).toHaveBeenCalledWith('/api/actions/rerun', expect.anything()) + }) + + it('shows the status reported by the server as text, or as a link', () => { + const plain = mountRow( + rowFor([rerun], { actionStatus: { rerun: { state: 'done', label: 'Finished' } } }), + ) + expect(plain.get('[data-test=action-status]').text()).toBe('Finished') + expect(plain.find('[data-test=action-status] a').exists()).toBe(false) + const linked = mountRow( + rowFor([rerun], { + actionStatus: { rerun: { state: 'done', link: 'https://x.example.test/' } }, + }), + ) + expect(linked.get('[data-test=action-status] a').text()).toBe('done') + }) +}) diff --git a/src/components/RowActions.vue b/src/components/RowActions.vue new file mode 100644 index 0000000..57f7c7b --- /dev/null +++ b/src/components/RowActions.vue @@ -0,0 +1,83 @@ + + + diff --git a/src/components/components.test.ts b/src/components/components.test.ts index 66305b1..a1257c4 100644 --- a/src/components/components.test.ts +++ b/src/components/components.test.ts @@ -125,6 +125,7 @@ describe('AgeMeter', () => { describe('AttentionRow', () => { const row = (over: Partial = {}): Row => ({ + repo: 'o/r', kind: 'pr_waiting', subject: '#5 bold title', detail: 'open 9d', diff --git a/src/components/ui/alert-dialog/AlertDialog.vue b/src/components/ui/alert-dialog/AlertDialog.vue new file mode 100644 index 0000000..ab8c774 --- /dev/null +++ b/src/components/ui/alert-dialog/AlertDialog.vue @@ -0,0 +1,15 @@ + + + diff --git a/src/components/ui/alert-dialog/AlertDialogAction.vue b/src/components/ui/alert-dialog/AlertDialogAction.vue new file mode 100644 index 0000000..a556c83 --- /dev/null +++ b/src/components/ui/alert-dialog/AlertDialogAction.vue @@ -0,0 +1,18 @@ + + + diff --git a/src/components/ui/alert-dialog/AlertDialogCancel.vue b/src/components/ui/alert-dialog/AlertDialogCancel.vue new file mode 100644 index 0000000..aac9edf --- /dev/null +++ b/src/components/ui/alert-dialog/AlertDialogCancel.vue @@ -0,0 +1,21 @@ + + + diff --git a/src/components/ui/alert-dialog/AlertDialogContent.vue b/src/components/ui/alert-dialog/AlertDialogContent.vue new file mode 100644 index 0000000..499c116 --- /dev/null +++ b/src/components/ui/alert-dialog/AlertDialogContent.vue @@ -0,0 +1,43 @@ + + + diff --git a/src/components/ui/alert-dialog/AlertDialogDescription.vue b/src/components/ui/alert-dialog/AlertDialogDescription.vue new file mode 100644 index 0000000..fa115c1 --- /dev/null +++ b/src/components/ui/alert-dialog/AlertDialogDescription.vue @@ -0,0 +1,22 @@ + + + diff --git a/src/components/ui/alert-dialog/AlertDialogFooter.vue b/src/components/ui/alert-dialog/AlertDialogFooter.vue new file mode 100644 index 0000000..b399cee --- /dev/null +++ b/src/components/ui/alert-dialog/AlertDialogFooter.vue @@ -0,0 +1,15 @@ + + + diff --git a/src/components/ui/alert-dialog/AlertDialogHeader.vue b/src/components/ui/alert-dialog/AlertDialogHeader.vue new file mode 100644 index 0000000..7b3469d --- /dev/null +++ b/src/components/ui/alert-dialog/AlertDialogHeader.vue @@ -0,0 +1,15 @@ + + + diff --git a/src/components/ui/alert-dialog/AlertDialogTitle.vue b/src/components/ui/alert-dialog/AlertDialogTitle.vue new file mode 100644 index 0000000..bac08c4 --- /dev/null +++ b/src/components/ui/alert-dialog/AlertDialogTitle.vue @@ -0,0 +1,22 @@ + + + diff --git a/src/components/ui/alert-dialog/index.ts b/src/components/ui/alert-dialog/index.ts new file mode 100644 index 0000000..2b135d0 --- /dev/null +++ b/src/components/ui/alert-dialog/index.ts @@ -0,0 +1,8 @@ +export { default as AlertDialog } from './AlertDialog.vue' +export { default as AlertDialogAction } from './AlertDialogAction.vue' +export { default as AlertDialogCancel } from './AlertDialogCancel.vue' +export { default as AlertDialogContent } from './AlertDialogContent.vue' +export { default as AlertDialogDescription } from './AlertDialogDescription.vue' +export { default as AlertDialogFooter } from './AlertDialogFooter.vue' +export { default as AlertDialogHeader } from './AlertDialogHeader.vue' +export { default as AlertDialogTitle } from './AlertDialogTitle.vue' diff --git a/src/lib/api.ts b/src/lib/api.ts index 62e2131..fc6351c 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1,4 +1,4 @@ -import type { RepoRefreshResult, TrackerState } from './types' +import type { ActionStatus, RepoRefreshResult, TrackerState } from './types' export async function fetchState(signal?: AbortSignal): Promise { const res = await fetch('/api/state', { signal, headers: { Accept: 'application/json' } }) @@ -32,3 +32,22 @@ export async function requestRepoRefresh(fullName: string): Promise { + const res = await fetch(`/api/actions/${encodeURIComponent(id)}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ repo, url }), + }) + if (!res.ok) { + const body = (await res.json().catch(() => null)) as { error?: string } | null + throw new Error(body?.error ?? `action ${id} failed: HTTP ${res.status}`) + } + const status = (await res.json().catch(() => null)) as ActionStatus | null + return status && typeof status.state === 'string' ? status : null +} diff --git a/src/lib/types.ts b/src/lib/types.ts index 9172403..54c1891 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -24,6 +24,21 @@ export interface Repo { release: Release | null } +/** An operator-defined button, as listed by the server. An empty `kinds` matches every alert kind. */ +export interface ActionDef { + id: string + label: string + confirm?: string + kinds: string[] +} + +/** Status of an action for one alert; `link` is an http(s) address when present. */ +export interface ActionStatus { + state: string + label?: string + link?: string +} + export interface Alert { repo: string kind: string @@ -32,6 +47,7 @@ export interface Alert { detail: string url: string since: string + actionStatus?: Record } export interface Thresholds { @@ -54,6 +70,8 @@ export interface TrackerState { thresholds: Thresholds repos: Repo[] alerts: Alert[] + actions?: ActionDef[] + actionsError?: string } /** Answer to a single-repository refresh: the state after it, plus the repo name. */ @@ -61,12 +79,15 @@ export type RepoRefreshResult = TrackerState & { repo: string } /** One line in a repo block: every alert that targets the same URL, merged. */ export interface Row { + repo: string kind: string subject: string detail: string url: string since: string severity: Exclude + actions?: ActionDef[] + actionStatus?: Record } export interface Block { diff --git a/src/stores/tracker.test.ts b/src/stores/tracker.test.ts index 03fff06..ea10cc9 100644 --- a/src/stores/tracker.test.ts +++ b/src/stores/tracker.test.ts @@ -329,3 +329,30 @@ describe('tracker store', () => { }) }) }) + +describe('runAction', () => { + beforeEach(() => setActivePinia(createPinia())) + afterEach(() => vi.unstubAllGlobals()) + + it('shows the server error and keeps no status when the webhook fails', async () => { + mockFetch((url) => + url.startsWith('/api/actions/') + ? json({ error: 'action webhook answered HTTP 500' }, 500) + : json(state()), + ) + const store = useTrackerStore() + await store.runAction('rerun', 'o/r', 'u') + expect(store.notice).toContain('HTTP 500') + expect(store.localStatus).toEqual({}) + expect(store.runningActions).toEqual([]) + }) + + it('ignores a second press while the first request runs', async () => { + const fn = mockFetch((url) => + url.startsWith('/api/actions/') ? json({ state: 'queued' }) : json(state()), + ) + const store = useTrackerStore() + await Promise.all([store.runAction('rerun', 'o/r', 'u'), store.runAction('rerun', 'o/r', 'u')]) + expect(fn.mock.calls.filter(([u]) => u.startsWith('/api/actions/'))).toHaveLength(1) + }) +}) diff --git a/src/stores/tracker.ts b/src/stores/tracker.ts index 17fe232..68d212f 100644 --- a/src/stores/tracker.ts +++ b/src/stores/tracker.ts @@ -1,6 +1,12 @@ import { defineStore } from 'pinia' import { computed, ref } from 'vue' -import { fetchState, RescanLimitedError, requestRefresh, requestRepoRefresh } from '@/lib/api' +import { + fetchState, + RescanLimitedError, + requestAction, + requestRefresh, + requestRepoRefresh, +} from '@/lib/api' import { activeFilterCount, applyFilters, @@ -11,7 +17,7 @@ import { type Filters, } from '@/lib/filters' import { DATA_KINDS } from '@/lib/kinds' -import type { Block, Row, TrackerState } from '@/lib/types' +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 @@ -21,6 +27,9 @@ 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() @@ -37,7 +46,12 @@ export function buildBlocks(state: TrackerState): Block[] { 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, @@ -105,6 +119,10 @@ export const useTrackerStore = defineStore('tracker', () => { /** Screen-reader text for a finished single-repository refresh. */ const announcement = ref('') const refreshingRepos = ref([]) + /** Action keys with a request in flight. */ + const runningActions = ref([]) + /** Status answered by a webhook, shown until the status list reports one. */ + const localStatus = ref>({}) const skewMs = ref(0) const tick = ref(Date.now()) @@ -209,9 +227,29 @@ export const useTrackerStore = defineStore('tracker', () => { } } + /** 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 { + 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,