feat: refresh a single repository from its own button (#2)

* feat(refresh): refresh a single repository with POST /refresh?repo=

The endpoint refreshes one configured repository and returns the full state plus the repo name.
Each repository has a 60-second cooldown that is separate from the full-refresh limit.
A running full refresh or a refresh of the same repository returns 409.
An unknown repository returns 404. Cross-site and disabled-config behaviour stay the same.
The collector reuses its fetch path and replaces only that repository.

* feat(ui): add a refresh button beside each repository

The button appears in the project list and in each attention group header.
It stays hidden when the server disables manual refresh.
The button shows a spinner while its repository refreshes and reports refusals in the notice line.
The store swaps in the returned state, so no extra fetch follows.
This commit is contained in:
2026-10-02 23:16:22 +01:00
committed by GitHub
parent 4c2c9d3341
commit 8cbc495dc7
16 changed files with 760 additions and 19 deletions
+48
View File
@@ -89,4 +89,52 @@ describe('App', () => {
await flushPromises()
expect(off.find('[data-test=rescan]').exists()).toBe(false)
})
it('refreshes one repo from its button, shows the new state and announces it', async () => {
const first = state({
manualRefresh: true,
repos: [repo({ refreshedAt: '2026-10-02T10:00:00Z' })],
})
const after = { ...first, repo: 'o/r', repos: [repo({ refreshedAt: '2026-10-02T12:00:00Z' })] }
const fetchMock = vi.fn((_url: string, init?: RequestInit) =>
Promise.resolve(
new Response(JSON.stringify(init?.method === 'POST' ? after : first), { status: 200 }),
),
)
vi.stubGlobal('fetch', fetchMock)
const w = mount(App, { global: { plugins: [createPinia()] } })
await flushPromises()
await w.get('[data-test=project] [data-test=repo-refresh]').trigger('click')
await flushPromises()
expect(fetchMock).toHaveBeenCalledWith('/refresh?repo=o%2Fr', { method: 'POST' })
expect(w.get('[data-test=announcement]').text()).toBe('Refreshed o/r')
})
it('shows the refusal message when a repo is refreshed too soon', async () => {
const body = state({ manualRefresh: true, repos: [repo()] })
vi.stubGlobal(
'fetch',
vi.fn((_url: string, init?: RequestInit) =>
Promise.resolve(
init?.method === 'POST'
? new Response('{}', { status: 429, headers: { 'Retry-After': '30' } })
: new Response(JSON.stringify(body), { status: 200 }),
),
),
)
const w = mount(App, { global: { plugins: [createPinia()] } })
await flushPromises()
await w.get('[data-test=project] [data-test=repo-refresh]').trigger('click')
await flushPromises()
expect(w.get('[data-test=notice]').text()).toBe('o/r was refreshed recently. Try again in 30s')
expect(
w.get('[data-test=project] [data-test=repo-refresh]').attributes('disabled'),
).toBeUndefined()
})
it('hides the repo buttons when the server disables manual refresh', async () => {
const w = mountWith(state({ manualRefresh: false, repos: [repo()] }))
await flushPromises()
expect(w.find('[data-test=repo-refresh]').exists()).toBe(false)
})
})
+12
View File
@@ -17,6 +17,8 @@ const {
state,
error,
notice,
announcement,
refreshingRepos,
scanning,
blocks,
visibleBlocks,
@@ -91,6 +93,8 @@ onBeforeUnmount(() => {
{{ notice }}
</p>
<p class="sr-only" role="status" data-test="announcement">{{ announcement }}</p>
<LoadingScreen
v-if="!state || !state.loaded"
:done="state?.scan.done ?? 0"
@@ -123,7 +127,11 @@ onBeforeUnmount(() => {
:facets="facets"
:owners="owners"
:active="activeFilters"
:manual-refresh="state.manualRefresh"
:refreshing="refreshingRepos"
:scanning="scanning"
@clear="store.clearFilters()"
@refresh="store.refreshRepo"
/>
<aside class="lg:sticky lg:top-6 lg:self-start">
<ProjectList
@@ -131,7 +139,11 @@ onBeforeUnmount(() => {
:total="state.repos.length"
:focused="filters.repo"
:now-ms="nowMs"
:manual-refresh="state.manualRefresh"
:refreshing="refreshingRepos"
:scanning="scanning"
@select="store.toggleRepo"
@refresh="store.refreshRepo"
/>
</aside>
</div>
+8 -1
View File
@@ -16,9 +16,12 @@ const props = defineProps<{
facets: Facets
owners: string[]
active: number
manualRefresh?: boolean
refreshing?: string[]
scanning?: boolean
}>()
const filters = defineModel<Filters>('filters', { required: true })
defineEmits<{ clear: [] }>()
defineEmits<{ clear: []; refresh: [fullName: string] }>()
const columnCount = useColumns()
// A group's height is its header plus its rows, so rows+2 approximates it.
@@ -59,6 +62,10 @@ defineExpose({ focusSearch: () => bar.value?.focus() })
:block="block"
:now-ms="nowMs"
:crit-seconds="critSeconds"
:manual-refresh="manualRefresh"
:refreshing="refreshing"
:scanning="scanning"
@refresh="(name) => $emit('refresh', name)"
/>
</div>
</div>
+19 -2
View File
@@ -2,11 +2,20 @@
import { faCodePullRequest, faCircleDot, faGears } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import RepoRefreshButton from '@/components/RepoRefreshButton.vue'
import { ageSince, isZeroTime } from '@/lib/format'
import type { Repo } from '@/lib/types'
const props = defineProps<{ repos: Repo[]; total: number; focused: string | null; nowMs: number }>()
defineEmits<{ select: [fullName: string] }>()
const props = defineProps<{
repos: Repo[]
total: number
focused: string | null
nowMs: number
manualRefresh?: boolean
refreshing?: string[]
scanning?: boolean
}>()
defineEmits<{ select: [fullName: string]; refresh: [fullName: string] }>()
/** Rows rendered at first and added each time the list is scrolled near its end. */
const PAGE_SIZE = 20
@@ -111,6 +120,14 @@ const LABEL = { crit: 'Critical', warn: 'Warning', ok: 'Healthy' } as const
<a v-else :href="repo.releasesUrl" class="text-muted-foreground hover:text-link text-xs"
>no release</a
>
<RepoRefreshButton
v-if="manualRefresh"
:repo="repo.fullName"
:busy="refreshing?.includes(repo.fullName) ?? false"
:blocked="scanning ?? false"
class="-my-1 -mr-1"
@refresh="$emit('refresh', repo.fullName)"
/>
</div>
<div
class="text-muted-foreground mt-1 flex items-center gap-4 pl-[18px] text-xs tabular-nums"
+18 -2
View File
@@ -2,10 +2,19 @@
import { faTag } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import AttentionRow from '@/components/AttentionRow.vue'
import RepoRefreshButton from '@/components/RepoRefreshButton.vue'
import { Badge } from '@/components/ui/badge'
import type { Block } from '@/lib/types'
defineProps<{ block: Block; nowMs: number; critSeconds: number }>()
defineProps<{
block: Block
nowMs: number
critSeconds: number
manualRefresh?: boolean
refreshing?: string[]
scanning?: boolean
}>()
defineEmits<{ refresh: [fullName: string] }>()
</script>
<template>
@@ -24,8 +33,15 @@ defineProps<{ block: Block; nowMs: number; critSeconds: number }>()
{{ block.repo.release.tag }}
</a>
</Badge>
<span class="text-muted-foreground ml-auto text-xs">
<span class="text-muted-foreground ml-auto flex items-center gap-1 text-xs">
{{ block.rows.length }} {{ block.rows.length === 1 ? 'item' : 'items' }}
<RepoRefreshButton
v-if="manualRefresh"
:repo="block.repo.fullName"
:busy="refreshing?.includes(block.repo.fullName) ?? false"
:blocked="scanning ?? false"
@refresh="$emit('refresh', block.repo.fullName)"
/>
</span>
</header>
<ul class="divide-border divide-y px-4">
+24
View File
@@ -0,0 +1,24 @@
<script setup lang="ts">
import { faArrowsRotate } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { Button } from '@/components/ui/button'
defineProps<{ repo: string; busy: boolean; blocked: boolean }>()
defineEmits<{ refresh: [] }>()
</script>
<template>
<Button
variant="ghost"
size="icon-xs"
class="text-muted-foreground hover:text-link shrink-0"
:disabled="busy || blocked"
:aria-label="`Refresh ${repo}`"
:aria-busy="busy"
:title="`Refresh ${repo}`"
data-test="repo-refresh"
@click="$emit('refresh')"
>
<FontAwesomeIcon :icon="faArrowsRotate" :spin="busy" aria-hidden="true" />
</Button>
</template>
+52
View File
@@ -448,3 +448,55 @@ describe('LoadingScreen', () => {
expect(w.attributes('aria-live')).toBe('polite')
})
})
describe('per-repository refresh button', () => {
const block = buildBlocks(mixedState())[0]
const repos = mixedState().repos
const group = (extra = {}) =>
mount(RepoGroup, { props: { block, nowMs, critSeconds: CRIT_SECONDS, ...extra } })
const list = (extra = {}) =>
mount(ProjectList, { props: { repos, total: 2, focused: null, nowMs, ...extra } })
it('is hidden unless manual refresh is on', () => {
expect(group().find('[data-test=repo-refresh]').exists()).toBe(false)
expect(list().find('[data-test=repo-refresh]').exists()).toBe(false)
expect(list({ manualRefresh: false }).find('[data-test=repo-refresh]').exists()).toBe(false)
})
it('renders one labelled button per project and one per group header', () => {
const l = list({ manualRefresh: true })
const buttons = l.findAll('[data-test=project] [data-test=repo-refresh]')
expect(buttons.map((b) => b.attributes('aria-label'))).toEqual([
'Refresh o/r',
'Refresh o/fine',
])
const g = group({ manualRefresh: true })
expect(g.get('header [data-test=repo-refresh]').attributes('aria-label')).toBe('Refresh o/r')
})
it('emits the repo name on click without selecting the project', async () => {
const l = list({ manualRefresh: true })
await l.findAll('[data-test=repo-refresh]')[1].trigger('click')
expect(l.emitted('refresh')).toEqual([['o/fine']])
expect(l.emitted('select')).toBeUndefined()
const g = group({ manualRefresh: true })
await g.get('[data-test=repo-refresh]').trigger('click')
expect(g.emitted('refresh')).toEqual([['o/r']])
})
it('disables only the refreshing repo and spins its icon', () => {
const l = list({ manualRefresh: true, refreshing: ['o/r'] })
const [busy, idle] = l.findAll('[data-test=repo-refresh]')
expect(busy.attributes('disabled')).toBeDefined()
expect(busy.attributes('aria-busy')).toBe('true')
expect(busy.find('svg').classes()).toContain('fa-spin')
expect(idle.attributes('disabled')).toBeUndefined()
expect(idle.find('svg').classes()).not.toContain('fa-spin')
})
it('disables every button while a full scan runs', () => {
const l = list({ manualRefresh: true, scanning: true })
for (const b of l.findAll('[data-test=repo-refresh]'))
expect(b.attributes('disabled')).toBeDefined()
})
})
+19 -1
View File
@@ -1,4 +1,4 @@
import type { TrackerState } from './types'
import type { RepoRefreshResult, TrackerState } from './types'
export async function fetchState(signal?: AbortSignal): Promise<TrackerState> {
const res = await fetch('/api/state', { signal, headers: { Accept: 'application/json' } })
@@ -14,3 +14,21 @@ export async function requestRefresh(): Promise<void> {
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}`)
}
/** Thrown when one repository was refreshed too recently or a scan is running. */
export class RepoRefreshRefusedError extends Error {}
const REPO_COOLDOWN_FALLBACK_SECONDS = 60
/** Refreshes one repository and returns the full state after it, plus the repo name. */
export async function requestRepoRefresh(fullName: string): Promise<RepoRefreshResult> {
const res = await fetch(`/refresh?repo=${encodeURIComponent(fullName)}`, { method: 'POST' })
if (res.status === 429) {
const wait = Number(res.headers.get('Retry-After')) || REPO_COOLDOWN_FALLBACK_SECONDS
throw new RepoRefreshRefusedError(`${fullName} was refreshed recently. Try again in ${wait}s`)
}
if (res.status === 409)
throw new RepoRefreshRefusedError('A refresh is already running. Try again shortly')
if (!res.ok) throw new Error(`refresh of ${fullName} failed: HTTP ${res.status}`)
return (await res.json()) as RepoRefreshResult
}
+3
View File
@@ -56,6 +56,9 @@ export interface TrackerState {
alerts: Alert[]
}
/** Answer to a single-repository refresh: the state after it, plus the repo name. */
export type RepoRefreshResult = TrackerState & { repo: string }
/** One line in a repo block: every alert that targets the same URL, merged. */
export interface Row {
kind: string
+58
View File
@@ -219,4 +219,62 @@ describe('tracker store', () => {
expect(store.error).toContain('HTTP 500')
expect(store.notice).toBe('')
})
describe('refreshRepo', () => {
const refreshed = (over = {}) => ({
...state({ repos: [repo({ refreshedAt: '2026-10-02T12:00:00Z', ...over })] }),
repo: 'o/r',
})
it('posts the repo, swaps the state in and announces it without another fetch', async () => {
const calls: string[] = []
mockFetch((url, init) => {
calls.push(`${init?.method ?? 'GET'} ${url}`)
return json(refreshed())
})
const store = useTrackerStore()
const done = store.refreshRepo('o/r')
expect(store.refreshingRepos).toEqual(['o/r'])
await done
expect(calls).toEqual(['POST /refresh?repo=o%2Fr'])
expect(store.refreshingRepos).toEqual([])
expect(store.state?.repos[0].refreshedAt).toBe('2026-10-02T12:00:00Z')
expect(store.announcement).toBe('Refreshed o/r')
expect(store.notice).toBe('')
})
it('ignores a second request for the same repo while one is pending', async () => {
const fetchMock = mockFetch(() => json(refreshed()))
const store = useTrackerStore()
await Promise.all([store.refreshRepo('o/r'), store.refreshRepo('o/r')])
expect(fetchMock).toHaveBeenCalledTimes(1)
})
it('shows the wait from Retry-After on 429', async () => {
mockFetch(() => new Response('{}', { status: 429, headers: { 'Retry-After': '42' } }))
const store = useTrackerStore()
await store.refreshRepo('o/r')
expect(store.notice).toBe('o/r was refreshed recently. Try again in 42s')
expect(store.refreshingRepos).toEqual([])
expect(store.error).toBe('')
})
it('says a scan is running on 409', async () => {
mockFetch(() => new Response('{}', { status: 409 }))
const store = useTrackerStore()
await store.refreshRepo('o/r')
expect(store.notice).toContain('already running')
})
it('reports other failures and a repo that came back down', async () => {
mockFetch(() => new Response('{}', { status: 500 }))
const store = useTrackerStore()
await store.refreshRepo('o/r')
expect(store.notice).toContain('HTTP 500')
mockFetch(() => json(refreshed({ up: false, error: 'boom' })))
await store.refreshRepo('o/r')
expect(store.notice).toBe('Could not refresh o/r: boom')
expect(store.announcement).toBe('')
})
})
})
+33 -5
View File
@@ -1,6 +1,6 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { fetchState, RescanLimitedError, requestRefresh } from '@/lib/api'
import { fetchState, RescanLimitedError, requestRefresh, requestRepoRefresh } from '@/lib/api'
import {
activeFilterCount,
applyFilters,
@@ -98,6 +98,9 @@ export const useTrackerStore = defineStore('tracker', () => {
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[]>([])
const skewMs = ref(0)
const tick = ref(Date.now())
@@ -143,13 +146,16 @@ export const useTrackerStore = defineStore('tracker', () => {
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 {
const next = await fetchState()
skewMs.value = Date.parse(next.now) - Date.now()
tick.value = Date.now()
state.value = next
applyState(await fetchState())
error.value = ''
} catch (e) {
error.value = e instanceof Error ? e.message : String(e)
@@ -180,11 +186,32 @@ export const useTrackerStore = defineStore('tracker', () => {
}
}
/** 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)
}
}
return {
state,
error,
loading,
notice,
announcement,
refreshingRepos,
scanning,
nowMs,
blocks,
@@ -201,6 +228,7 @@ export const useTrackerStore = defineStore('tracker', () => {
clearFilters,
load,
rescan,
refreshRepo,
advanceClock,
}
})