Files
ghtracker/src/App.vue
T
lukaszraczyloandGitHub 8cbc495dc7 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.
2026-10-02 23:16:22 +01:00

153 lines
4.5 KiB
Vue

<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import AppHeader from '@/components/AppHeader.vue'
import AttentionList from '@/components/AttentionList.vue'
import LoadingScreen from '@/components/LoadingScreen.vue'
import FleetOverview from '@/components/FleetOverview.vue'
import ProjectList from '@/components/ProjectList.vue'
import { filtersFromSearch, filtersToSearch } from '@/lib/filters'
import { useTrackerStore } from '@/stores/tracker'
const POLL_MS = 60_000
const CLOCK_MS = 30_000
const store = useTrackerStore()
const {
state,
error,
notice,
announcement,
refreshingRepos,
scanning,
blocks,
visibleBlocks,
nowMs,
filters,
facets,
owners,
visibleRepos,
activeFilters,
repoCounts,
rowCounts,
facts,
} = storeToRefs(store)
const list = ref<InstanceType<typeof AttentionList> | null>(null)
const progress = computed(() =>
state.value && scanning.value && state.value.scan.total > 0
? `${state.value.scan.done}/${state.value.scan.total}`
: '',
)
const matching = computed(() => new Set(visibleRepos.value.map((r) => r.fullName)))
let timers: number[] = []
/** Pressing "/" outside a text field jumps to the search box. */
function onKey(e: KeyboardEvent): void {
const el = e.target as HTMLElement | null
if (e.key !== '/' || e.metaKey || e.ctrlKey || el?.closest('input, textarea, [contenteditable]'))
return
e.preventDefault()
list.value?.focusSearch()
}
// Filters live in the URL so a filtered view can be shared and survives a reload.
watch(
filters,
(f) => window.history.replaceState(null, '', window.location.pathname + filtersToSearch(f)),
{ deep: true },
)
onMounted(() => {
filters.value = filtersFromSearch(window.location.search)
window.addEventListener('keydown', onKey)
void store.load()
timers = [
window.setInterval(() => void store.load(), POLL_MS),
window.setInterval(store.advanceClock, CLOCK_MS),
]
})
onBeforeUnmount(() => {
timers.forEach((t) => window.clearInterval(t))
window.removeEventListener('keydown', onKey)
})
</script>
<template>
<main class="w-full px-4 py-4 sm:px-6 sm:py-6 lg:px-10">
<AppHeader
:last-refresh="state?.lastRefresh ?? ''"
:now-ms="nowMs"
:interval-seconds="state?.intervalSeconds ?? 0"
:loaded="state?.loaded ?? false"
:scanning="scanning"
:progress="progress"
:manual-refresh="state?.manualRefresh ?? false"
@rescan="store.rescan()"
/>
<p v-if="error" role="alert" class="text-crit mt-4 text-sm" data-test="error">
Could not reach the server: {{ error }}
</p>
<p v-if="notice" role="status" class="text-warn mt-4 text-sm" data-test="notice">
{{ notice }}
</p>
<p class="sr-only" role="status" data-test="announcement">{{ announcement }}</p>
<LoadingScreen
v-if="!state || !state.loaded"
:done="state?.scan.done ?? 0"
:total="state?.scan.total ?? 0"
:connecting="!state"
/>
<template v-else>
<div class="mt-8 sm:mt-12">
<FleetOverview
:crit="rowCounts.crit"
:warn="rowCounts.warn"
:block-count="blocks.length"
:repos="state.repos"
:counts="repoCounts"
:facts="facts!"
:focused="filters.repo"
:matching="matching"
@select="store.toggleRepo"
/>
</div>
<div
class="mt-10 grid grid-cols-[minmax(0,1fr)] gap-x-10 gap-y-10 sm:mt-14 lg:grid-cols-[minmax(0,1fr)_24rem] 2xl:grid-cols-[minmax(0,1fr)_28rem]"
>
<AttentionList
ref="list"
v-model:filters="filters"
:blocks="visibleBlocks"
:now-ms="nowMs"
:crit-seconds="state.thresholds.prCritSeconds"
: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
:repos="visibleRepos"
: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>
</template>
</main>
</template>