mirror of
https://github.com/lukaszraczylo/ghtracker.git
synced 2026-10-12 00:45:38 +00:00
feat(ui): add a progress loading screen and lazy project list
The loading screen shows live scan progress from the server, with a pulsing outline of the dashboard. The server now reports how many repositories the running scan has finished. The project list renders 20 rows first and adds more as the end scrolls into view. Groups on very wide screens fill two balanced columns, the fact figures sit in a row under the health strip, and the headline counts match the rows listed below it.
This commit is contained in:
@@ -33,6 +33,9 @@ type View struct {
|
||||
Warn int
|
||||
Loaded bool
|
||||
Refreshing bool
|
||||
// ScanDone and ScanTotal count the repos of the running (or last) scan.
|
||||
ScanDone int
|
||||
ScanTotal int
|
||||
}
|
||||
|
||||
// Stats are monotonic counters and timings of the refresh loop.
|
||||
@@ -56,6 +59,8 @@ type Collector struct {
|
||||
th config.Thresholds
|
||||
interval time.Duration
|
||||
refreshes atomic.Uint64
|
||||
scanDone atomic.Int64
|
||||
scanTotal atomic.Int64
|
||||
repoErrs atomic.Uint64
|
||||
lastDur time.Duration
|
||||
concurrency int
|
||||
@@ -118,6 +123,8 @@ func (c *Collector) Refresh(ctx context.Context) {
|
||||
|
||||
start := c.now()
|
||||
c.limited.Store(false)
|
||||
c.scanDone.Store(0)
|
||||
c.scanTotal.Store(int64(len(c.names)))
|
||||
results := make([]model.Repo, len(c.names))
|
||||
sem := make(chan struct{}, c.concurrency)
|
||||
var wg sync.WaitGroup
|
||||
@@ -128,6 +135,7 @@ func (c *Collector) Refresh(ctx context.Context) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
results[i] = c.refreshOne(ctx, name, start)
|
||||
c.scanDone.Add(1)
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
@@ -193,6 +201,7 @@ func (c *Collector) View() View {
|
||||
v := View{
|
||||
Now: now, LastRefresh: c.lastRefresh, Interval: c.interval, Thresholds: c.th,
|
||||
Loaded: c.loaded, Refreshing: c.refreshing.Load(),
|
||||
ScanDone: int(c.scanDone.Load()), ScanTotal: int(c.scanTotal.Load()),
|
||||
}
|
||||
v.Repos = make([]model.Repo, 0, len(c.names))
|
||||
for _, n := range c.names {
|
||||
|
||||
@@ -312,3 +312,28 @@ func TestSummarizeWarningsMergesSharedReason(t *testing.T) {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanProgressAdvancesAsReposFinish(t *testing.T) {
|
||||
release := make(chan struct{})
|
||||
started := make(chan struct{}, 4)
|
||||
f := &fakeFetcher{fn: func(name string) (model.Repo, error) {
|
||||
started <- struct{}{}
|
||||
<-release
|
||||
return model.Repo{FullName: name}, nil
|
||||
}}
|
||||
c := newCollector(f, "o/a", "o/b", "o/c")
|
||||
if v := c.View(); v.ScanDone != 0 || v.ScanTotal != 0 {
|
||||
t.Fatalf("before any scan: %+v", v)
|
||||
}
|
||||
done := make(chan struct{})
|
||||
go func() { c.Refresh(context.Background()); close(done) }()
|
||||
<-started
|
||||
if v := c.View(); v.ScanTotal != 3 || v.ScanDone != 0 || !v.Refreshing {
|
||||
t.Fatalf("during scan: done=%d total=%d refreshing=%v", v.ScanDone, v.ScanTotal, v.Refreshing)
|
||||
}
|
||||
close(release)
|
||||
<-done
|
||||
if v := c.View(); v.ScanDone != 3 || v.ScanTotal != 3 {
|
||||
t.Fatalf("after scan: done=%d total=%d", v.ScanDone, v.ScanTotal)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ type apiState struct {
|
||||
Alerts []apiAlert `json:"alerts"`
|
||||
Thresholds apiThresholds `json:"thresholds"`
|
||||
Counts apiCounts `json:"counts"`
|
||||
Scan apiScan `json:"scan"`
|
||||
IntervalSeconds int64 `json:"intervalSeconds"`
|
||||
Loaded bool `json:"loaded"`
|
||||
Refreshing bool `json:"refreshing"`
|
||||
@@ -30,6 +31,12 @@ type apiThresholds struct {
|
||||
WorkflowDormant int64 `json:"workflowDormantSeconds"`
|
||||
}
|
||||
|
||||
// apiScan reports how far the running scan has got, for the loading screen.
|
||||
type apiScan struct {
|
||||
Done int `json:"done"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
type apiCounts struct {
|
||||
Crit int `json:"crit"`
|
||||
Warn int `json:"warn"`
|
||||
@@ -98,6 +105,7 @@ func buildState(v collector.View, manualRefresh bool) apiState {
|
||||
Refreshing: v.Refreshing,
|
||||
ManualRefresh: manualRefresh,
|
||||
Counts: apiCounts{Crit: v.Crit, Warn: v.Warn},
|
||||
Scan: apiScan{Done: v.ScanDone, Total: v.ScanTotal},
|
||||
Thresholds: apiThresholds{
|
||||
PRWarnSeconds: seconds(v.Thresholds.PRWarnAfter),
|
||||
PRCritSeconds: seconds(v.Thresholds.PRCritAfter),
|
||||
|
||||
@@ -45,7 +45,7 @@ func testView() collector.View {
|
||||
}
|
||||
bare := model.Repo{FullName: "o/bare", URL: "https://github.com/o/bare", ReleasesURL: "https://github.com/o/bare/releases", Err: "boom"}
|
||||
return collector.View{
|
||||
Now: now, LastRefresh: now.Add(-time.Hour), Interval: 6 * time.Hour, Loaded: true, Crit: 1, Warn: 1,
|
||||
Now: now, LastRefresh: now.Add(-time.Hour), Interval: 6 * time.Hour, Loaded: true, Crit: 1, Warn: 1, ScanDone: 5, ScanTotal: 9,
|
||||
Thresholds: config.Thresholds{PRWarnAfter: 48 * time.Hour, PRCritAfter: 168 * time.Hour, IssueStaleAfter: 720 * time.Hour},
|
||||
Repos: []model.Repo{repo, bare},
|
||||
Alerts: []model.Alert{
|
||||
@@ -125,6 +125,7 @@ func TestStateJSONContract(t *testing.T) {
|
||||
Repos []map[string]any
|
||||
Alerts []map[string]any
|
||||
Counts struct{ Crit, Warn int }
|
||||
Scan struct{ Done, Total int }
|
||||
Thresholds struct{ PRWarnSeconds, PRCritSeconds int64 }
|
||||
IntervalSeconds int64
|
||||
Loaded bool
|
||||
@@ -133,6 +134,9 @@ func TestStateJSONContract(t *testing.T) {
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Scan.Done != 5 || got.Scan.Total != 9 {
|
||||
t.Fatalf("scan: %+v", got.Scan)
|
||||
}
|
||||
if got.IntervalSeconds != 21600 || !got.Loaded || !got.ManualRefresh || got.Counts.Crit != 1 || got.Counts.Warn != 1 {
|
||||
t.Fatalf("top level: %+v", got)
|
||||
}
|
||||
|
||||
+2
-1
@@ -68,9 +68,10 @@ describe('App', () => {
|
||||
})
|
||||
|
||||
it('waits while the server has not finished its first refresh', async () => {
|
||||
const w = mountWith(state({ loaded: false }))
|
||||
const w = mountWith(state({ loaded: false, scan: { done: 4, total: 10 } }))
|
||||
await flushPromises()
|
||||
expect(w.find('[data-test=waiting]').exists()).toBe(true)
|
||||
expect(w.get('[data-test=loading-detail]').text()).toContain('4 of 10 scanned')
|
||||
expect(w.find('[data-test=headline]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
|
||||
+16
-10
@@ -3,6 +3,7 @@ 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'
|
||||
@@ -26,9 +27,15 @@ const {
|
||||
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[] = []
|
||||
|
||||
@@ -71,6 +78,7 @@ onBeforeUnmount(() => {
|
||||
:interval-seconds="state?.intervalSeconds ?? 0"
|
||||
:loaded="state?.loaded ?? false"
|
||||
:scanning="scanning"
|
||||
:progress="progress"
|
||||
:manual-refresh="state?.manualRefresh ?? false"
|
||||
@rescan="store.rescan()"
|
||||
/>
|
||||
@@ -83,19 +91,17 @@ onBeforeUnmount(() => {
|
||||
{{ notice }}
|
||||
</p>
|
||||
|
||||
<p v-if="!state" class="text-muted-foreground mt-16 text-3xl font-semibold">Loading</p>
|
||||
<p
|
||||
v-else-if="!state.loaded"
|
||||
class="text-muted-foreground mt-16 text-3xl font-semibold"
|
||||
data-test="waiting"
|
||||
>
|
||||
Fetching data from GitHub
|
||||
</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="state.counts.crit"
|
||||
:warn="state.counts.warn"
|
||||
:crit="rowCounts.crit"
|
||||
:warn="rowCounts.warn"
|
||||
:block-count="blocks.length"
|
||||
:repos="state.repos"
|
||||
:counts="repoCounts"
|
||||
|
||||
@@ -11,6 +11,7 @@ const props = defineProps<{
|
||||
intervalSeconds: number
|
||||
loaded: boolean
|
||||
scanning: boolean
|
||||
progress: string
|
||||
manualRefresh: boolean
|
||||
}>()
|
||||
defineEmits<{ rescan: [] }>()
|
||||
@@ -56,8 +57,10 @@ const nextIn = computed(() => {
|
||||
@click="$emit('rescan')"
|
||||
>
|
||||
<FontAwesomeIcon :icon="faArrowsRotate" :spin="scanning" aria-hidden="true" />
|
||||
<span class="hidden sm:inline">{{ scanning ? 'Rescanning…' : 'Rescan repositories' }}</span>
|
||||
<span class="sm:hidden">{{ scanning ? 'Scanning…' : 'Rescan' }}</span>
|
||||
<span class="hidden sm:inline">{{
|
||||
scanning ? `Scanning ${progress}`.trim() : 'Rescan repositories'
|
||||
}}</span>
|
||||
<span class="sm:hidden">{{ scanning ? progress || 'Scanning…' : 'Rescan' }}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { faCircleCheck } from '@fortawesome/free-solid-svg-icons'
|
||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
|
||||
import { ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import FilterBar from '@/components/FilterBar.vue'
|
||||
import RepoGroup from '@/components/RepoGroup.vue'
|
||||
import { useColumns } from '@/composables/useColumns'
|
||||
import { balanceColumns } from '@/lib/layout'
|
||||
import type { Facets, Filters } from '@/lib/filters'
|
||||
import type { Block } from '@/lib/types'
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
blocks: Block[]
|
||||
nowMs: number
|
||||
critSeconds: number
|
||||
@@ -18,6 +20,12 @@ defineProps<{
|
||||
const filters = defineModel<Filters>('filters', { required: true })
|
||||
defineEmits<{ clear: [] }>()
|
||||
|
||||
const columnCount = useColumns()
|
||||
// A group's height is its header plus its rows, so rows+2 approximates it.
|
||||
const columns = computed(() =>
|
||||
balanceColumns(props.blocks, columnCount.value, (b) => b.rows.length + 2),
|
||||
)
|
||||
|
||||
const bar = ref<InstanceType<typeof FilterBar> | null>(null)
|
||||
defineExpose({ focusSearch: () => bar.value?.focus() })
|
||||
</script>
|
||||
@@ -43,14 +51,16 @@ defineExpose({ focusSearch: () => bar.value?.focus() })
|
||||
<FontAwesomeIcon :icon="faCircleCheck" class="text-ok text-xl" aria-hidden="true" />
|
||||
Nothing matches the current filters.
|
||||
</div>
|
||||
<div v-else class="grid grid-cols-[minmax(0,1fr)] items-start gap-4 2xl:grid-cols-2">
|
||||
<div v-else class="flex items-start gap-4" data-test="columns">
|
||||
<div v-for="(column, i) in columns" :key="i" class="flex min-w-0 flex-1 flex-col gap-4">
|
||||
<RepoGroup
|
||||
v-for="block in blocks"
|
||||
v-for="block in column"
|
||||
:key="block.repo.fullName"
|
||||
:block="block"
|
||||
:now-ms="nowMs"
|
||||
:crit-seconds="critSeconds"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -56,10 +56,7 @@ const rows = computed<{ label: string; value: number; icon: IconDefinition; tone
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="grid grid-cols-[minmax(0,1fr)] items-end gap-x-14 gap-y-8 lg:grid-cols-[minmax(0,1fr)_22rem]"
|
||||
aria-label="Fleet status"
|
||||
>
|
||||
<section class="flex flex-col gap-7 sm:gap-8" aria-label="Fleet status">
|
||||
<div>
|
||||
<p class="text-muted-foreground mb-3 text-sm" data-test="scope">
|
||||
<template v-if="blockCount > 0"
|
||||
@@ -68,7 +65,7 @@ const rows = computed<{ label: string; value: number; icon: IconDefinition; tone
|
||||
<template v-else>All {{ repos.length }} projects are healthy</template>
|
||||
</p>
|
||||
<h1
|
||||
class="flex flex-wrap items-baseline gap-x-6 gap-y-1 sm:gap-x-10 font-semibold tracking-tight"
|
||||
class="flex flex-wrap items-baseline gap-x-6 gap-y-1 font-semibold tracking-tight sm:gap-x-10"
|
||||
data-test="headline"
|
||||
>
|
||||
<template v-if="crit > 0 || warn > 0">
|
||||
@@ -83,7 +80,9 @@ const rows = computed<{ label: string; value: number; icon: IconDefinition; tone
|
||||
</template>
|
||||
<span v-else class="text-ok text-5xl leading-none sm:text-7xl">All clear</span>
|
||||
</h1>
|
||||
<div class="mt-6 sm:mt-8">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FleetStrip
|
||||
:repos="repos"
|
||||
:counts="counts"
|
||||
@@ -104,14 +103,22 @@ const rows = computed<{ label: string; value: number; icon: IconDefinition; tone
|
||||
<span class="sm:ml-auto">Tap a segment to focus a project</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dl class="border-border divide-border divide-y border-y text-sm" data-test="facts">
|
||||
<div v-for="row in rows" :key="row.label" class="flex items-center gap-3 py-2.5">
|
||||
<FontAwesomeIcon :icon="row.icon" class="text-muted-foreground w-4" aria-hidden="true" />
|
||||
<dt class="flex-1">{{ row.label }}</dt>
|
||||
<dl
|
||||
class="border-border grid grid-cols-2 gap-x-6 border-t sm:grid-cols-3 lg:grid-cols-5"
|
||||
data-test="facts"
|
||||
>
|
||||
<div
|
||||
v-for="row in rows"
|
||||
:key="row.label"
|
||||
class="border-border border-b py-3 last:col-span-2 sm:last:col-span-1 lg:border-b-0"
|
||||
>
|
||||
<dt class="text-muted-foreground flex items-center gap-2 text-xs">
|
||||
<FontAwesomeIcon :icon="row.icon" class="w-3.5" aria-hidden="true" />
|
||||
{{ row.label }}
|
||||
</dt>
|
||||
<dd
|
||||
class="font-mono text-base tabular-nums"
|
||||
class="mt-1 font-mono text-3xl tabular-nums"
|
||||
:class="row.value > 0 ? row.tone : 'text-muted-foreground'"
|
||||
>
|
||||
{{ row.value }}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
<script setup lang="ts">
|
||||
import { faSatelliteDish } from '@fortawesome/free-solid-svg-icons'
|
||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
|
||||
import { computed } from 'vue'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
|
||||
const props = defineProps<{ done: number; total: number; connecting: boolean }>()
|
||||
|
||||
// Static class names so Tailwind can see every delay; cells cycle through them for a wave effect.
|
||||
const DELAYS = [
|
||||
'[animation-delay:0ms]',
|
||||
'[animation-delay:140ms]',
|
||||
'[animation-delay:280ms]',
|
||||
'[animation-delay:420ms]',
|
||||
'[animation-delay:560ms]',
|
||||
'[animation-delay:700ms]',
|
||||
]
|
||||
const delay = (i: number) => DELAYS[i % DELAYS.length]
|
||||
|
||||
const MIN_CELLS = 12
|
||||
const MAX_CELLS = 63
|
||||
const cells = computed(() => Math.min(MAX_CELLS, Math.max(MIN_CELLS, props.total)))
|
||||
const percent = computed(() => (props.total > 0 ? Math.round((props.done / props.total) * 100) : 0))
|
||||
const known = computed(() => !props.connecting && props.total > 0)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section role="status" aria-live="polite" class="mt-10 sm:mt-16" data-test="waiting">
|
||||
<div class="flex items-center gap-4">
|
||||
<span
|
||||
class="border-border bg-surface text-link relative flex size-12 items-center justify-center rounded-lg border"
|
||||
>
|
||||
<span
|
||||
class="border-link/50 absolute inset-0 rounded-lg border motion-safe:animate-ping"
|
||||
></span>
|
||||
<FontAwesomeIcon :icon="faSatelliteDish" class="text-xl" aria-hidden="true" />
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<h1 class="text-2xl font-semibold tracking-tight sm:text-3xl" data-test="loading-title">
|
||||
{{ connecting ? 'Connecting' : 'Scanning repositories' }}
|
||||
</h1>
|
||||
<p class="text-muted-foreground mt-0.5 text-sm" data-test="loading-detail">
|
||||
<template v-if="known">{{ done }} of {{ total }} scanned</template>
|
||||
<template v-else-if="connecting">Waiting for the server</template>
|
||||
<template v-else>Contacting GitHub</template>
|
||||
<span class="hidden sm:inline"> · the first scan can take a minute or two</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-7 max-w-2xl">
|
||||
<Progress v-if="known" :model-value="percent" class="h-1.5" aria-label="Scan progress" />
|
||||
<div v-else class="bg-secondary h-1.5 w-full overflow-hidden rounded-full">
|
||||
<div class="bg-primary h-full w-1/3 rounded-full motion-safe:animate-pulse"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-10" aria-hidden="true">
|
||||
<div class="flex items-baseline gap-8">
|
||||
<div
|
||||
class="bg-secondary h-14 w-28 rounded-md motion-safe:animate-pulse sm:h-[4.5rem] sm:w-36"
|
||||
></div>
|
||||
<div
|
||||
class="bg-secondary h-14 w-28 rounded-md [animation-delay:200ms] motion-safe:animate-pulse sm:h-[4.5rem] sm:w-36"
|
||||
></div>
|
||||
</div>
|
||||
<div class="mt-8 grid grid-cols-[repeat(auto-fill,minmax(1.5rem,1fr))] gap-1">
|
||||
<div
|
||||
v-for="i in cells"
|
||||
:key="i"
|
||||
class="bg-secondary h-9 rounded-[3px] motion-safe:animate-pulse sm:h-11"
|
||||
:class="delay(i)"
|
||||
></div>
|
||||
</div>
|
||||
<div
|
||||
class="border-border mt-7 grid grid-cols-2 gap-x-6 border-t pt-3 sm:grid-cols-3 lg:grid-cols-5"
|
||||
>
|
||||
<div v-for="i in 5" :key="i" class="py-2">
|
||||
<div
|
||||
class="bg-secondary h-3 w-24 rounded motion-safe:animate-pulse"
|
||||
:class="delay(i)"
|
||||
></div>
|
||||
<div
|
||||
class="bg-secondary mt-2 h-7 w-12 rounded motion-safe:animate-pulse"
|
||||
:class="delay(i + 1)"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="mt-10 grid grid-cols-[minmax(0,1fr)] gap-4 lg:grid-cols-[minmax(0,1fr)_24rem]"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div v-for="g in 3" :key="g" class="border-border bg-surface rounded-lg border p-4">
|
||||
<div
|
||||
class="bg-secondary h-5 w-48 rounded motion-safe:animate-pulse"
|
||||
:class="delay(g)"
|
||||
></div>
|
||||
<div v-for="r in 2" :key="r" class="mt-4 flex items-center gap-3">
|
||||
<div
|
||||
class="bg-secondary size-8 shrink-0 rounded-md motion-safe:animate-pulse"
|
||||
:class="delay(g + r)"
|
||||
></div>
|
||||
<div class="flex-1 space-y-2">
|
||||
<div
|
||||
class="bg-secondary h-4 w-2/3 rounded motion-safe:animate-pulse"
|
||||
:class="delay(g + r)"
|
||||
></div>
|
||||
<div
|
||||
class="bg-secondary h-3 w-1/3 rounded motion-safe:animate-pulse"
|
||||
:class="delay(g + r + 1)"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-border bg-surface hidden rounded-lg border p-3 lg:block">
|
||||
<div v-for="i in 6" :key="i" class="py-2.5">
|
||||
<div
|
||||
class="bg-secondary h-4 w-3/4 rounded motion-safe:animate-pulse"
|
||||
:class="delay(i)"
|
||||
></div>
|
||||
<div
|
||||
class="bg-secondary mt-2 h-3 w-1/2 rounded motion-safe:animate-pulse"
|
||||
:class="delay(i + 1)"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,12 +1,63 @@
|
||||
<script setup lang="ts">
|
||||
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 { ageSince, isZeroTime } from '@/lib/format'
|
||||
import type { Repo } from '@/lib/types'
|
||||
|
||||
defineProps<{ repos: Repo[]; total: number; focused: string | null; nowMs: number }>()
|
||||
const props = defineProps<{ repos: Repo[]; total: number; focused: string | null; nowMs: number }>()
|
||||
defineEmits<{ select: [fullName: string] }>()
|
||||
|
||||
/** Rows rendered at first and added each time the list is scrolled near its end. */
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
const shown = ref(PAGE_SIZE)
|
||||
const visible = computed(() => props.repos.slice(0, shown.value))
|
||||
const hasMore = computed(() => shown.value < props.repos.length)
|
||||
|
||||
const scroller = ref<HTMLElement | null>(null)
|
||||
const sentinel = ref<HTMLElement | null>(null)
|
||||
let observer: IntersectionObserver | null = null
|
||||
|
||||
function loadMore(): void {
|
||||
if (hasMore.value) shown.value += PAGE_SIZE
|
||||
}
|
||||
|
||||
// A new search or filter starts the list over from the top.
|
||||
watch(
|
||||
() => props.repos,
|
||||
() => {
|
||||
shown.value = PAGE_SIZE
|
||||
if (scroller.value) scroller.value.scrollTop = 0
|
||||
},
|
||||
)
|
||||
|
||||
// Observe the end marker; a fresh marker element is rendered while more rows remain.
|
||||
watch(
|
||||
sentinel,
|
||||
(el, prev) => {
|
||||
if (prev) observer?.unobserve(prev)
|
||||
if (el) observer?.observe(el)
|
||||
},
|
||||
{ flush: 'post' },
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick()
|
||||
if (typeof IntersectionObserver === 'undefined') {
|
||||
shown.value = props.repos.length
|
||||
return
|
||||
}
|
||||
observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries.some((e) => e.isIntersecting)) loadMore()
|
||||
},
|
||||
{ root: scroller.value, rootMargin: '200px' },
|
||||
)
|
||||
if (sentinel.value) observer.observe(sentinel.value)
|
||||
})
|
||||
onBeforeUnmount(() => observer?.disconnect())
|
||||
|
||||
const DOT = { crit: 'bg-crit', warn: 'bg-warn', ok: 'bg-ok' } as const
|
||||
const LABEL = { crit: 'Critical', warn: 'Warning', ok: 'Healthy' } as const
|
||||
</script>
|
||||
@@ -28,10 +79,11 @@ const LABEL = { crit: 'Critical', warn: 'Warning', ok: 'Healthy' } as const
|
||||
</p>
|
||||
<ul
|
||||
v-else
|
||||
class="border-border bg-surface divide-border max-h-[70vh] divide-y overflow-y-auto rounded-lg border lg:max-h-[calc(100vh-7rem)]"
|
||||
ref="scroller"
|
||||
class="border-border bg-surface divide-border max-h-[70vh] divide-y overflow-y-auto rounded-lg border [scrollbar-color:var(--border)_transparent] [scrollbar-width:thin] lg:max-h-[calc(100vh-7rem)]"
|
||||
>
|
||||
<li
|
||||
v-for="repo in repos"
|
||||
v-for="repo in visible"
|
||||
:key="repo.fullName"
|
||||
class="px-3 py-2.5"
|
||||
:class="focused === repo.fullName ? 'bg-accent' : ''"
|
||||
@@ -89,6 +141,14 @@ const LABEL = { crit: 'Critical', warn: 'Warning', ok: 'Healthy' } as const
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
<li
|
||||
v-if="hasMore"
|
||||
ref="sentinel"
|
||||
class="text-muted-foreground px-3 py-3 text-center text-xs"
|
||||
data-test="more"
|
||||
>
|
||||
Showing {{ visible.length }} of {{ repos.length }}
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import AgeMeter from './AgeMeter.vue'
|
||||
import AppHeader from './AppHeader.vue'
|
||||
import AttentionList from './AttentionList.vue'
|
||||
import FilterBar from './FilterBar.vue'
|
||||
import LoadingScreen from './LoadingScreen.vue'
|
||||
import AttentionRow from './AttentionRow.vue'
|
||||
import FleetOverview from './FleetOverview.vue'
|
||||
import ProjectList from './ProjectList.vue'
|
||||
@@ -24,6 +25,7 @@ describe('AppHeader', () => {
|
||||
intervalSeconds: 21600,
|
||||
loaded: true,
|
||||
scanning: false,
|
||||
progress: '',
|
||||
}
|
||||
|
||||
it('shows when data was fetched and when the next refresh is due', () => {
|
||||
@@ -44,7 +46,11 @@ describe('AppHeader', () => {
|
||||
expect(w.emitted('rescan')).toHaveLength(1)
|
||||
const busy = mount(AppHeader, { props: { ...base, manualRefresh: true, scanning: true } })
|
||||
expect(busy.get('[data-test=rescan]').attributes('disabled')).toBeDefined()
|
||||
expect(busy.get('[data-test=rescan]').text()).toContain('Rescanning…')
|
||||
expect(busy.get('[data-test=rescan]').text()).toContain('Scanning')
|
||||
const counted = mount(AppHeader, {
|
||||
props: { ...base, manualRefresh: true, scanning: true, progress: '23/63' },
|
||||
})
|
||||
expect(counted.get('[data-test=rescan]').text()).toContain('Scanning 23/63')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -304,3 +310,141 @@ describe('ProjectList', () => {
|
||||
expect(w.findAll('[data-test=project]')[1].classes()).toContain('bg-accent')
|
||||
})
|
||||
})
|
||||
|
||||
describe('AttentionList columns', () => {
|
||||
const blocks = ['a/one', 'b/two', 'c/three'].map((fullName) => ({
|
||||
repo: repo({ fullName, health: 'crit' }),
|
||||
rows: buildBlocks(mixedState())[0].rows,
|
||||
}))
|
||||
const props = {
|
||||
blocks,
|
||||
nowMs,
|
||||
critSeconds: CRIT_SECONDS,
|
||||
facets: facetCounts(blocks, emptyFilters(), ['a', 'b', 'c']),
|
||||
owners: ['a'],
|
||||
active: 0,
|
||||
filters: emptyFilters(),
|
||||
}
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
it('uses one column on narrow screens', () => {
|
||||
vi.stubGlobal('matchMedia', () => ({
|
||||
matches: false,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
}))
|
||||
const w = mount(AttentionList, { props })
|
||||
expect(w.findAll('[data-test=columns] > div')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('balances groups over two columns on very wide screens', async () => {
|
||||
vi.stubGlobal('matchMedia', () => ({
|
||||
matches: true,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
}))
|
||||
const w = mount(AttentionList, { props })
|
||||
await w.vm.$nextTick()
|
||||
const cols = w.findAll('[data-test=columns] > div')
|
||||
expect(cols).toHaveLength(2)
|
||||
expect(cols[0].findAll('[data-test=repo-block]')).toHaveLength(2)
|
||||
expect(cols[1].findAll('[data-test=repo-block]')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ProjectList lazy loading', () => {
|
||||
type Callback = (entries: { isIntersecting: boolean }[]) => void
|
||||
let callback: Callback = () => {}
|
||||
|
||||
function stubObserver() {
|
||||
vi.stubGlobal(
|
||||
'IntersectionObserver',
|
||||
class {
|
||||
constructor(cb: Callback) {
|
||||
callback = cb
|
||||
}
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
},
|
||||
)
|
||||
}
|
||||
const many = (n: number) => Array.from({ length: n }, (_, i) => repo({ fullName: `o/repo-${i}` }))
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
it('renders a first page, then more each time the end marker scrolls into view', async () => {
|
||||
stubObserver()
|
||||
const w = mount(ProjectList, { props: { repos: many(55), total: 55, focused: null, nowMs } })
|
||||
await w.vm.$nextTick()
|
||||
expect(w.findAll('[data-test=project]')).toHaveLength(20)
|
||||
expect(w.get('[data-test=more]').text()).toBe('Showing 20 of 55')
|
||||
callback([{ isIntersecting: true }])
|
||||
await w.vm.$nextTick()
|
||||
expect(w.findAll('[data-test=project]')).toHaveLength(40)
|
||||
callback([{ isIntersecting: false }])
|
||||
await w.vm.$nextTick()
|
||||
expect(w.findAll('[data-test=project]')).toHaveLength(40)
|
||||
callback([{ isIntersecting: true }])
|
||||
await w.vm.$nextTick()
|
||||
expect(w.findAll('[data-test=project]')).toHaveLength(55)
|
||||
expect(w.find('[data-test=more]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('starts over from the first page when the list changes', async () => {
|
||||
stubObserver()
|
||||
const w = mount(ProjectList, { props: { repos: many(55), total: 55, focused: null, nowMs } })
|
||||
await w.vm.$nextTick()
|
||||
callback([{ isIntersecting: true }])
|
||||
await w.vm.$nextTick()
|
||||
expect(w.findAll('[data-test=project]')).toHaveLength(40)
|
||||
await (w as unknown as { setProps(p: object): Promise<void> }).setProps({ repos: many(30) })
|
||||
expect(w.findAll('[data-test=project]')).toHaveLength(20)
|
||||
})
|
||||
|
||||
it('renders everything when the browser has no IntersectionObserver', async () => {
|
||||
vi.stubGlobal('IntersectionObserver', undefined)
|
||||
const w = mount(ProjectList, { props: { repos: many(55), total: 55, focused: null, nowMs } })
|
||||
await w.vm.$nextTick()
|
||||
await w.vm.$nextTick()
|
||||
expect(w.findAll('[data-test=project]')).toHaveLength(55)
|
||||
})
|
||||
})
|
||||
|
||||
describe('LoadingScreen', () => {
|
||||
it('shows scan progress with a count once the server reports a total', () => {
|
||||
const w = mount(LoadingScreen, { props: { done: 23, total: 63, connecting: false } })
|
||||
expect(w.get('[data-test=loading-title]').text()).toBe('Scanning repositories')
|
||||
expect(w.get('[data-test=loading-detail]').text()).toContain('23 of 63 scanned')
|
||||
expect(w.get('[role=progressbar]').attributes('aria-valuenow')).toBe('37')
|
||||
})
|
||||
|
||||
it('draws one skeleton cell per repo, within sensible limits', () => {
|
||||
const cells = (total: number) =>
|
||||
mount(LoadingScreen, { props: { done: 0, total, connecting: false } }).findAll(
|
||||
'.grid-cols-\\[repeat\\(auto-fill\\,minmax\\(1\\.5rem\\,1fr\\)\\)\\] > div',
|
||||
).length
|
||||
expect(cells(5)).toBe(12)
|
||||
expect(cells(30)).toBe(30)
|
||||
expect(cells(500)).toBe(63)
|
||||
})
|
||||
|
||||
it('says it is connecting before the first response', () => {
|
||||
const w = mount(LoadingScreen, { props: { done: 0, total: 0, connecting: true } })
|
||||
expect(w.get('[data-test=loading-title]').text()).toBe('Connecting')
|
||||
expect(w.find('[role=progressbar]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('uses an indeterminate bar while the total is not known yet', () => {
|
||||
const w = mount(LoadingScreen, { props: { done: 0, total: 0, connecting: false } })
|
||||
expect(w.get('[data-test=loading-detail]').text()).toContain('Contacting GitHub')
|
||||
expect(w.find('[role=progressbar]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('is announced politely to screen readers', () => {
|
||||
const w = mount(LoadingScreen, { props: { done: 1, total: 2, connecting: false } })
|
||||
expect(w.attributes('role')).toBe('status')
|
||||
expect(w.attributes('aria-live')).toBe('polite')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { onBeforeUnmount, onMounted, ref, type Ref } from 'vue'
|
||||
|
||||
/** 1 column below the breakpoint, 2 at or above it; follows the viewport live. */
|
||||
export function useColumns(minWidth = '(min-width: 96rem)'): Ref<number> {
|
||||
const columns = ref(1)
|
||||
let query: MediaQueryList | null = null
|
||||
const update = () => {
|
||||
columns.value = query?.matches ? 2 : 1
|
||||
}
|
||||
onMounted(() => {
|
||||
if (typeof window.matchMedia !== 'function') return
|
||||
query = window.matchMedia(minWidth)
|
||||
update()
|
||||
query.addEventListener('change', update)
|
||||
})
|
||||
onBeforeUnmount(() => query?.removeEventListener('change', update))
|
||||
return columns
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { balanceColumns } from './layout'
|
||||
|
||||
const weight = (n: number) => n
|
||||
|
||||
describe('balanceColumns', () => {
|
||||
it('returns one column holding everything in order', () => {
|
||||
expect(balanceColumns([3, 1, 2], 1, weight)).toEqual([[3, 1, 2]])
|
||||
})
|
||||
|
||||
it('puts each item in the currently shortest column', () => {
|
||||
// heights after each step: [5,0] -> [5,2] -> [5,5] -> [5,6]... ties go to the first column
|
||||
expect(balanceColumns([5, 2, 3, 1], 2, weight)).toEqual([
|
||||
[5, 1],
|
||||
[2, 3],
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps the heaviest-first reading order within each column', () => {
|
||||
const [left, right] = balanceColumns([9, 8, 7, 6, 5], 2, weight)
|
||||
expect(left).toEqual([9, 6, 5])
|
||||
expect(right).toEqual([8, 7])
|
||||
})
|
||||
|
||||
it('handles empty input and clamps invalid column counts', () => {
|
||||
expect(balanceColumns([], 2, weight)).toEqual([[], []])
|
||||
expect(balanceColumns([1, 2], 0, weight)).toEqual([[1, 2]])
|
||||
expect(balanceColumns([1, 2], 2.9, weight)).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Splits items into columns so the columns end at about the same height. Each item goes to the
|
||||
* currently shortest column, which keeps the original order readable (worst items first).
|
||||
*/
|
||||
export function balanceColumns<T>(items: T[], columns: number, weight: (item: T) => number): T[][] {
|
||||
const count = Math.max(1, Math.floor(columns))
|
||||
const out: T[][] = Array.from({ length: count }, () => [])
|
||||
const heights = new Array<number>(count).fill(0)
|
||||
for (const item of items) {
|
||||
let target = 0
|
||||
for (let i = 1; i < count; i++) if (heights[i] < heights[target]) target = i
|
||||
out[target].push(item)
|
||||
heights[target] += weight(item)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -49,6 +49,7 @@ export interface TrackerState {
|
||||
loaded: boolean
|
||||
refreshing: boolean
|
||||
manualRefresh: boolean
|
||||
scan: { done: number; total: number }
|
||||
counts: { crit: number; warn: number }
|
||||
thresholds: Thresholds
|
||||
repos: Repo[]
|
||||
|
||||
@@ -134,6 +134,15 @@ describe('tracker store', () => {
|
||||
expect(store.owners).toEqual(['o'])
|
||||
})
|
||||
|
||||
it('counts rows by severity so the headline matches the lists', async () => {
|
||||
mockFetch(() => json(mixedState()))
|
||||
const store = useTrackerStore()
|
||||
await store.load()
|
||||
// three alerts, but the two on the same pull request merge into one row
|
||||
expect(store.rowCounts).toEqual({ crit: 2, warn: 0 })
|
||||
expect(store.facets.severity.all).toBe(store.rowCounts.crit + store.rowCounts.warn)
|
||||
})
|
||||
|
||||
it('searches projects by name and clears every filter at once', async () => {
|
||||
mockFetch(() => json(mixedState()))
|
||||
const store = useTrackerStore()
|
||||
|
||||
@@ -122,6 +122,12 @@ export const useTrackerStore = defineStore('tracker', () => {
|
||||
)
|
||||
})
|
||||
const activeFilters = computed(() => activeFilterCount(filters.value))
|
||||
/** Rows by severity, so the headline matches the lists below it (alerts on one item merge into one row). */
|
||||
const rowCounts = computed(() => {
|
||||
const out = { crit: 0, warn: 0 }
|
||||
for (const b of blocks.value) for (const r of b.rows) out[r.severity] += 1
|
||||
return out
|
||||
})
|
||||
const repoCounts = computed(() => (state.value ? countByRepo(state.value) : new Map()))
|
||||
const facts = computed(() => (state.value ? computeFacts(state.value) : null))
|
||||
|
||||
@@ -189,6 +195,7 @@ export const useTrackerStore = defineStore('tracker', () => {
|
||||
activeFilters,
|
||||
visibleBlocks,
|
||||
repoCounts,
|
||||
rowCounts,
|
||||
facts,
|
||||
toggleRepo,
|
||||
clearFilters,
|
||||
|
||||
@@ -49,6 +49,7 @@ export function state(over: Partial<TrackerState> = {}): TrackerState {
|
||||
loaded: true,
|
||||
refreshing: false,
|
||||
manualRefresh: false,
|
||||
scan: { done: 0, total: 0 },
|
||||
counts: { crit: 0, warn: 0 },
|
||||
thresholds: {
|
||||
prWarnSeconds: 172800,
|
||||
|
||||
Reference in New Issue
Block a user