mirror of
https://github.com/lukaszraczylo/ghtracker.git
synced 2026-10-15 01:07:40 +00:00
* 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.
153 lines
4.5 KiB
Vue
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>
|