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,