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 -

+ 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 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,