From a56a12a6e0ec9e4dac2e4c544e2b7f3d1cdae51a Mon Sep 17 00:00:00 2001
From: Lukasz Raczylo
Date: Fri, 2 Oct 2026 04:50:38 +0100
Subject: [PATCH] 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.
---
internal/collector/collector.go | 9 ++
internal/collector/collector_test.go | 25 +++++
internal/web/api.go | 8 ++
internal/web/server_test.go | 6 +-
src/App.test.ts | 3 +-
src/App.vue | 26 +++--
src/components/AppHeader.vue | 7 +-
src/components/AttentionList.vue | 30 ++++--
src/components/FleetOverview.vue | 69 +++++++------
src/components/LoadingScreen.vue | 133 ++++++++++++++++++++++++
src/components/ProjectList.vue | 66 +++++++++++-
src/components/components.test.ts | 148 ++++++++++++++++++++++++++-
src/composables/useColumns.ts | 18 ++++
src/lib/layout.test.ts | 30 ++++++
src/lib/layout.ts | 16 +++
src/lib/types.ts | 1 +
src/stores/tracker.test.ts | 9 ++
src/stores/tracker.ts | 7 ++
src/test/fixtures.ts | 1 +
19 files changed, 552 insertions(+), 60 deletions(-)
create mode 100644 src/components/LoadingScreen.vue
create mode 100644 src/composables/useColumns.ts
create mode 100644 src/lib/layout.test.ts
create mode 100644 src/lib/layout.ts
diff --git a/internal/collector/collector.go b/internal/collector/collector.go
index 394fb0e..ce626f8 100644
--- a/internal/collector/collector.go
+++ b/internal/collector/collector.go
@@ -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 {
diff --git a/internal/collector/collector_test.go b/internal/collector/collector_test.go
index d86711b..0ede1dc 100644
--- a/internal/collector/collector_test.go
+++ b/internal/collector/collector_test.go
@@ -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)
+ }
+}
diff --git a/internal/web/api.go b/internal/web/api.go
index d694cb5..82cf359 100644
--- a/internal/web/api.go
+++ b/internal/web/api.go
@@ -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),
diff --git a/internal/web/server_test.go b/internal/web/server_test.go
index 3f85a46..24db35d 100644
--- a/internal/web/server_test.go
+++ b/internal/web/server_test.go
@@ -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)
}
diff --git a/src/App.test.ts b/src/App.test.ts
index 4de9b45..f14e7e5 100644
--- a/src/App.test.ts
+++ b/src/App.test.ts
@@ -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)
})
diff --git a/src/App.vue b/src/App.vue
index 9133268..24ad9f0 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -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 | 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 }}
- Loading
-
- Fetching data from GitHub
-
+
()
defineEmits<{ rescan: [] }>()
@@ -56,8 +57,10 @@ const nextIn = computed(() => {
@click="$emit('rescan')"
>
- {{ scanning ? 'Rescanning…' : 'Rescan repositories' }}
- {{ scanning ? 'Scanning…' : 'Rescan' }}
+ {{
+ scanning ? `Scanning ${progress}`.trim() : 'Rescan repositories'
+ }}
+ {{ scanning ? progress || 'Scanning…' : 'Rescan' }}
diff --git a/src/components/AttentionList.vue b/src/components/AttentionList.vue
index baf60f2..f5934e2 100644
--- a/src/components/AttentionList.vue
+++ b/src/components/AttentionList.vue
@@ -1,13 +1,15 @@
@@ -43,14 +51,16 @@ defineExpose({ focusSearch: () => bar.value?.focus() })
Nothing matches the current filters.
-
-
+
diff --git a/src/components/FleetOverview.vue b/src/components/FleetOverview.vue
index ca33f76..e3e2099 100644
--- a/src/components/FleetOverview.vue
+++ b/src/components/FleetOverview.vue
@@ -56,10 +56,7 @@ const rows = computed<{ label: string; value: number; icon: IconDefinition; tone
-
+
All {{ repos.length }} projects are healthy
@@ -83,35 +80,45 @@ const rows = computed<{ label: string; value: number; icon: IconDefinition; tone
All clear
-
-
-
- critical
- warning
- healthy
- Tap a segment to focus a project
-
-
-
-
-
-
- {{ row.label }}
+
+
+
+ critical
+ warning
+ healthy
+ Tap a segment to focus a project
+
+
+
+
+
+
-
+
+ {{ row.label }}
+
-
{{ row.value }}
diff --git a/src/components/LoadingScreen.vue b/src/components/LoadingScreen.vue
new file mode 100644
index 0000000..a58b71c
--- /dev/null
+++ b/src/components/LoadingScreen.vue
@@ -0,0 +1,133 @@
+
+
+
+
+
+
+
+
+
+
+
+ {{ connecting ? 'Connecting' : 'Scanning repositories' }}
+
+
+ {{ done }} of {{ total }} scanned
+ Waiting for the server
+ Contacting GitHub
+ · the first scan can take a minute or two
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/ProjectList.vue b/src/components/ProjectList.vue
index b54d592..8aca60e 100644
--- a/src/components/ProjectList.vue
+++ b/src/components/ProjectList.vue
@@ -1,12 +1,63 @@
@@ -28,10 +79,11 @@ const LABEL = { crit: 'Critical', warn: 'Warning', ok: 'Healthy' } as const
+
-
+ Showing {{ visible.length }} of {{ repos.length }}
+
diff --git a/src/components/components.test.ts b/src/components/components.test.ts
index e2f9539..79036f6 100644
--- a/src/components/components.test.ts
+++ b/src/components/components.test.ts
@@ -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
}).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')
+ })
+})
diff --git a/src/composables/useColumns.ts b/src/composables/useColumns.ts
new file mode 100644
index 0000000..7244e51
--- /dev/null
+++ b/src/composables/useColumns.ts
@@ -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 {
+ 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
+}
diff --git a/src/lib/layout.test.ts b/src/lib/layout.test.ts
new file mode 100644
index 0000000..4a1ff5f
--- /dev/null
+++ b/src/lib/layout.test.ts
@@ -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)
+ })
+})
diff --git a/src/lib/layout.ts b/src/lib/layout.ts
new file mode 100644
index 0000000..b7e72de
--- /dev/null
+++ b/src/lib/layout.ts
@@ -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(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(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
+}
diff --git a/src/lib/types.ts b/src/lib/types.ts
index bea5202..b9be961 100644
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -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[]
diff --git a/src/stores/tracker.test.ts b/src/stores/tracker.test.ts
index acea24b..74c315a 100644
--- a/src/stores/tracker.test.ts
+++ b/src/stores/tracker.test.ts
@@ -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()
diff --git a/src/stores/tracker.ts b/src/stores/tracker.ts
index f887ef2..f883220 100644
--- a/src/stores/tracker.ts
+++ b/src/stores/tracker.ts
@@ -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,
diff --git a/src/test/fixtures.ts b/src/test/fixtures.ts
index ef7e506..7775e9b 100644
--- a/src/test/fixtures.ts
+++ b/src/test/fixtures.ts
@@ -49,6 +49,7 @@ export function state(over: Partial = {}): TrackerState {
loaded: true,
refreshing: false,
manualRefresh: false,
+ scan: { done: 0, total: 0 },
counts: { crit: 0, warn: 0 },
thresholds: {
prWarnSeconds: 172800,