mirror of
https://github.com/lukaszraczylo/ghtracker.git
synced 2026-10-14 00:58:36 +00:00
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.
This commit is contained in:
@@ -84,7 +84,7 @@ actions:
|
|||||||
token_env: ACTION_TOKEN # name of an environment variable that holds a bearer token; optional
|
token_env: ACTION_TOKEN # name of an environment variable that holds a bearer token; optional
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
status_url: https://example.internal/hooks/rerun/status # optional
|
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": "<alert 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.
|
- `POST /api/actions/{id}` takes `{"repo": "owner/name", "url": "<alert 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.
|
- 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`.
|
- `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.
|
- 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
|
## Metrics
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
|
||||||
import AgeMeter from '@/components/AgeMeter.vue'
|
import AgeMeter from '@/components/AgeMeter.vue'
|
||||||
|
import RowActions from '@/components/RowActions.vue'
|
||||||
import { ageSince, isZeroTime, secondsSince, shorten } from '@/lib/format'
|
import { ageSince, isZeroTime, secondsSince, shorten } from '@/lib/format'
|
||||||
import { iconFor } from '@/lib/kinds'
|
import { iconFor } from '@/lib/kinds'
|
||||||
import type { Row } from '@/lib/types'
|
import type { Row } from '@/lib/types'
|
||||||
@@ -33,6 +34,7 @@ const ratio = computed(() => secondsSince(props.row.since, props.nowMs) / props.
|
|||||||
<p class="text-muted-foreground mt-0.5 text-sm wrap-anywhere">
|
<p class="text-muted-foreground mt-0.5 text-sm wrap-anywhere">
|
||||||
{{ shorten(row.detail, 120) }}
|
{{ shorten(row.detail, 120) }}
|
||||||
</p>
|
</p>
|
||||||
|
<RowActions v-if="row.actions?.length" :row="row" />
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="col-start-2 flex items-center gap-3 sm:col-start-auto sm:flex-col sm:items-end sm:gap-1.5 sm:pt-0.5"
|
class="col-start-2 flex items-center gap-3 sm:col-start-auto sm:flex-col sm:items-end sm:gap-1.5 sm:pt-0.5"
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import AttentionRow from './AttentionRow.vue'
|
||||||
|
import { alert, hoursAgo, repo, state } from '@/test/fixtures'
|
||||||
|
import { buildBlocks } from '@/stores/tracker'
|
||||||
|
import type { ActionDef, Alert, Row } from '@/lib/types'
|
||||||
|
|
||||||
|
const rerun: ActionDef = {
|
||||||
|
id: 'rerun',
|
||||||
|
label: 'Re-run',
|
||||||
|
confirm: 'Run this action?',
|
||||||
|
kinds: ['workflow_failed'],
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowFor = (actions: ActionDef[], over: Partial<Alert> = {}): 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<Response>>(() =>
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { faSpinner } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from '@/components/ui/alert-dialog'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { actionKey, useTrackerStore } from '@/stores/tracker'
|
||||||
|
import type { ActionDef, ActionStatus, Row } from '@/lib/types'
|
||||||
|
|
||||||
|
const props = defineProps<{ row: Row }>()
|
||||||
|
const store = useTrackerStore()
|
||||||
|
|
||||||
|
// `asking` outlives the dialog so the confirm click still knows which action to run.
|
||||||
|
const asking = ref<ActionDef | null>(null)
|
||||||
|
const open = ref(false)
|
||||||
|
|
||||||
|
const keyOf = (a: ActionDef) => actionKey(a.id, props.row.repo, props.row.url)
|
||||||
|
const busy = (a: ActionDef) => store.runningActions.includes(keyOf(a))
|
||||||
|
const statusOf = (a: ActionDef): ActionStatus | undefined =>
|
||||||
|
props.row.actionStatus?.[a.id] ?? store.localStatus[keyOf(a)]
|
||||||
|
|
||||||
|
function run(a: ActionDef): void {
|
||||||
|
void store.runAction(a.id, props.row.repo, props.row.url)
|
||||||
|
}
|
||||||
|
|
||||||
|
function press(a: ActionDef): void {
|
||||||
|
if (!a.confirm) return run(a)
|
||||||
|
asking.value = a
|
||||||
|
open.value = true
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="mt-2 flex flex-wrap items-center gap-2" data-test="row-actions">
|
||||||
|
<template v-for="a in row.actions" :key="a.id">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="xs"
|
||||||
|
:disabled="busy(a)"
|
||||||
|
:aria-busy="busy(a)"
|
||||||
|
:data-test="`action-${a.id}`"
|
||||||
|
@click="press(a)"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon v-if="busy(a)" :icon="faSpinner" spin aria-hidden="true" />
|
||||||
|
{{ a.label }}
|
||||||
|
</Button>
|
||||||
|
<Badge v-if="busy(a)" variant="secondary" data-test="action-status">Sending</Badge>
|
||||||
|
<Badge v-else-if="statusOf(a)" variant="secondary" data-test="action-status">
|
||||||
|
<a
|
||||||
|
v-if="statusOf(a)?.link"
|
||||||
|
:href="statusOf(a)?.link"
|
||||||
|
class="underline-offset-4 hover:underline"
|
||||||
|
>{{ statusOf(a)?.label || statusOf(a)?.state }}</a
|
||||||
|
>
|
||||||
|
<template v-else>{{ statusOf(a)?.label || statusOf(a)?.state }}</template>
|
||||||
|
</Badge>
|
||||||
|
</template>
|
||||||
|
<AlertDialog v-model:open="open">
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{{ asking?.label }}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>{{ asking?.confirm }}</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction data-test="action-confirm" @click="asking && run(asking)">
|
||||||
|
{{ asking?.label }}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -125,6 +125,7 @@ describe('AgeMeter', () => {
|
|||||||
|
|
||||||
describe('AttentionRow', () => {
|
describe('AttentionRow', () => {
|
||||||
const row = (over: Partial<Row> = {}): Row => ({
|
const row = (over: Partial<Row> = {}): Row => ({
|
||||||
|
repo: 'o/r',
|
||||||
kind: 'pr_waiting',
|
kind: 'pr_waiting',
|
||||||
subject: '#5 <b>bold</b> title',
|
subject: '#5 <b>bold</b> title',
|
||||||
detail: 'open 9d',
|
detail: 'open 9d',
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { AlertDialogEmits, AlertDialogProps } from 'reka-ui'
|
||||||
|
import { AlertDialogRoot, useForwardPropsEmits } from 'reka-ui'
|
||||||
|
|
||||||
|
const props = defineProps<AlertDialogProps>()
|
||||||
|
const emits = defineEmits<AlertDialogEmits>()
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(props, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AlertDialogRoot data-slot="alert-dialog" v-bind="forwarded">
|
||||||
|
<slot />
|
||||||
|
</AlertDialogRoot>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { AlertDialogActionProps } from 'reka-ui'
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import { AlertDialogAction } from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { buttonVariants } from '@/components/ui/button'
|
||||||
|
|
||||||
|
const props = defineProps<AlertDialogActionProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AlertDialogAction v-bind="delegatedProps" :class="cn(buttonVariants(), props.class)">
|
||||||
|
<slot />
|
||||||
|
</AlertDialogAction>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { AlertDialogCancelProps } from 'reka-ui'
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import { AlertDialogCancel } from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { buttonVariants } from '@/components/ui/button'
|
||||||
|
|
||||||
|
const props = defineProps<AlertDialogCancelProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AlertDialogCancel
|
||||||
|
v-bind="delegatedProps"
|
||||||
|
:class="cn(buttonVariants({ variant: 'outline' }), props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</AlertDialogCancel>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { AlertDialogContentEmits, AlertDialogContentProps } from 'reka-ui'
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import {
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogOverlay,
|
||||||
|
AlertDialogPortal,
|
||||||
|
useForwardPropsEmits,
|
||||||
|
} from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
inheritAttrs: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const props = defineProps<AlertDialogContentProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
const emits = defineEmits<AlertDialogContentEmits>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class')
|
||||||
|
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AlertDialogPortal>
|
||||||
|
<AlertDialogOverlay
|
||||||
|
data-slot="alert-dialog-overlay"
|
||||||
|
class="data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50"
|
||||||
|
/>
|
||||||
|
<AlertDialogContent
|
||||||
|
data-slot="alert-dialog-content"
|
||||||
|
v-bind="{ ...forwarded, ...$attrs }"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialogPortal>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { AlertDialogDescriptionProps } from 'reka-ui'
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import { AlertDialogDescription, useForwardProps } from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<AlertDialogDescriptionProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class')
|
||||||
|
const forwardedProps = useForwardProps(delegatedProps)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AlertDialogDescription
|
||||||
|
data-slot="alert-dialog-description"
|
||||||
|
v-bind="forwardedProps"
|
||||||
|
:class="cn('text-muted-foreground text-sm', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<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="alert-dialog-footer"
|
||||||
|
:class="cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<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="alert-dialog-header"
|
||||||
|
:class="cn('flex flex-col gap-2 text-center sm:text-left', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { AlertDialogTitleProps } from 'reka-ui'
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import { AlertDialogTitle, useForwardProps } from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<AlertDialogTitleProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class')
|
||||||
|
const forwardedProps = useForwardProps(delegatedProps)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AlertDialogTitle
|
||||||
|
data-slot="alert-dialog-title"
|
||||||
|
v-bind="forwardedProps"
|
||||||
|
:class="cn('text-lg font-semibold', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</AlertDialogTitle>
|
||||||
|
</template>
|
||||||
@@ -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'
|
||||||
+20
-1
@@ -1,4 +1,4 @@
|
|||||||
import type { RepoRefreshResult, TrackerState } from './types'
|
import type { ActionStatus, RepoRefreshResult, TrackerState } from './types'
|
||||||
|
|
||||||
export async function fetchState(signal?: AbortSignal): Promise<TrackerState> {
|
export async function fetchState(signal?: AbortSignal): Promise<TrackerState> {
|
||||||
const res = await fetch('/api/state', { signal, headers: { Accept: 'application/json' } })
|
const res = await fetch('/api/state', { signal, headers: { Accept: 'application/json' } })
|
||||||
@@ -32,3 +32,22 @@ export async function requestRepoRefresh(fullName: string): Promise<RepoRefreshR
|
|||||||
if (!res.ok) throw new Error(`refresh of ${fullName} failed: HTTP ${res.status}`)
|
if (!res.ok) throw new Error(`refresh of ${fullName} failed: HTTP ${res.status}`)
|
||||||
return (await res.json()) as RepoRefreshResult
|
return (await res.json()) as RepoRefreshResult
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Sends an alert to the webhook behind an action; returns the status the webhook reported, if any. */
|
||||||
|
export async function requestAction(
|
||||||
|
id: string,
|
||||||
|
repo: string,
|
||||||
|
url: string,
|
||||||
|
): Promise<ActionStatus | null> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,6 +24,21 @@ export interface Repo {
|
|||||||
release: Release | null
|
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 {
|
export interface Alert {
|
||||||
repo: string
|
repo: string
|
||||||
kind: string
|
kind: string
|
||||||
@@ -32,6 +47,7 @@ export interface Alert {
|
|||||||
detail: string
|
detail: string
|
||||||
url: string
|
url: string
|
||||||
since: string
|
since: string
|
||||||
|
actionStatus?: Record<string, ActionStatus>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Thresholds {
|
export interface Thresholds {
|
||||||
@@ -54,6 +70,8 @@ export interface TrackerState {
|
|||||||
thresholds: Thresholds
|
thresholds: Thresholds
|
||||||
repos: Repo[]
|
repos: Repo[]
|
||||||
alerts: Alert[]
|
alerts: Alert[]
|
||||||
|
actions?: ActionDef[]
|
||||||
|
actionsError?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Answer to a single-repository refresh: the state after it, plus the repo name. */
|
/** 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. */
|
/** One line in a repo block: every alert that targets the same URL, merged. */
|
||||||
export interface Row {
|
export interface Row {
|
||||||
|
repo: string
|
||||||
kind: string
|
kind: string
|
||||||
subject: string
|
subject: string
|
||||||
detail: string
|
detail: string
|
||||||
url: string
|
url: string
|
||||||
since: string
|
since: string
|
||||||
severity: Exclude<Severity, 'ok'>
|
severity: Exclude<Severity, 'ok'>
|
||||||
|
actions?: ActionDef[]
|
||||||
|
actionStatus?: Record<string, ActionStatus>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Block {
|
export interface Block {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
+40
-2
@@ -1,6 +1,12 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { fetchState, RescanLimitedError, requestRefresh, requestRepoRefresh } from '@/lib/api'
|
import {
|
||||||
|
fetchState,
|
||||||
|
RescanLimitedError,
|
||||||
|
requestAction,
|
||||||
|
requestRefresh,
|
||||||
|
requestRepoRefresh,
|
||||||
|
} from '@/lib/api'
|
||||||
import {
|
import {
|
||||||
activeFilterCount,
|
activeFilterCount,
|
||||||
applyFilters,
|
applyFilters,
|
||||||
@@ -11,7 +17,7 @@ import {
|
|||||||
type Filters,
|
type Filters,
|
||||||
} from '@/lib/filters'
|
} from '@/lib/filters'
|
||||||
import { DATA_KINDS } from '@/lib/kinds'
|
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. */
|
/** While a rescan runs the server takes ~40s, so check back often instead of every minute. */
|
||||||
export const SCAN_POLL_MS = 2000
|
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
|
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. */
|
/** Groups alerts into one row per target URL and one block per unhealthy repo, newest problem first. */
|
||||||
export function buildBlocks(state: TrackerState): Block[] {
|
export function buildBlocks(state: TrackerState): Block[] {
|
||||||
const rows = new Map<string, Row>()
|
const rows = new Map<string, Row>()
|
||||||
@@ -37,7 +46,12 @@ export function buildBlocks(state: TrackerState): Block[] {
|
|||||||
if (sinceMs(alert.since) > sinceMs(existing.since)) existing.since = alert.since
|
if (sinceMs(alert.since) > sinceMs(existing.since)) existing.since = alert.since
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
const actions = (state.actions ?? []).filter(
|
||||||
|
(a) => a.kinds.length === 0 || a.kinds.includes(alert.kind),
|
||||||
|
)
|
||||||
rows.set(key, {
|
rows.set(key, {
|
||||||
|
repo: alert.repo,
|
||||||
|
...(actions.length > 0 && { actions, actionStatus: alert.actionStatus }),
|
||||||
kind: alert.kind,
|
kind: alert.kind,
|
||||||
subject: alert.subject,
|
subject: alert.subject,
|
||||||
detail: alert.detail,
|
detail: alert.detail,
|
||||||
@@ -105,6 +119,10 @@ export const useTrackerStore = defineStore('tracker', () => {
|
|||||||
/** Screen-reader text for a finished single-repository refresh. */
|
/** Screen-reader text for a finished single-repository refresh. */
|
||||||
const announcement = ref('')
|
const announcement = ref('')
|
||||||
const refreshingRepos = ref<string[]>([])
|
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 skewMs = ref(0)
|
||||||
const tick = ref(Date.now())
|
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<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 {
|
return {
|
||||||
state,
|
state,
|
||||||
error,
|
error,
|
||||||
|
runningActions,
|
||||||
|
localStatus,
|
||||||
|
runAction,
|
||||||
loading,
|
loading,
|
||||||
notice,
|
notice,
|
||||||
announcement,
|
announcement,
|
||||||
|
|||||||
Reference in New Issue
Block a user