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:
2026-10-02 04:50:38 +01:00
parent 1852ea0885
commit a56a12a6e0
19 changed files with 552 additions and 60 deletions
+5 -2
View File
@@ -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>
+20 -10
View File
@@ -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">
<RepoGroup
v-for="block in blocks"
:key="block.repo.fullName"
:block="block"
:now-ms="nowMs"
:crit-seconds="critSeconds"
/>
<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 column"
:key="block.repo.fullName"
:block="block"
:now-ms="nowMs"
:crit-seconds="critSeconds"
/>
</div>
</div>
</section>
</template>
+38 -31
View File
@@ -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,35 +80,45 @@ 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">
<FleetStrip
:repos="repos"
:counts="counts"
:focused="focused"
:matching="matching"
@select="$emit('select', $event)"
/>
<p class="text-muted-foreground mt-3 flex flex-wrap items-center gap-x-5 gap-y-1 text-xs">
<span class="flex items-center gap-1.5"
><span class="bg-crit size-2 rounded-[2px]"></span>critical</span
>
<span class="flex items-center gap-1.5"
><span class="bg-warn size-2 rounded-[2px]"></span>warning</span
>
<span class="flex items-center gap-1.5"
><span class="bg-ok/40 size-2 rounded-[2px]"></span>healthy</span
>
<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>
<div>
<FleetStrip
:repos="repos"
:counts="counts"
:focused="focused"
:matching="matching"
@select="$emit('select', $event)"
/>
<p class="text-muted-foreground mt-3 flex flex-wrap items-center gap-x-5 gap-y-1 text-xs">
<span class="flex items-center gap-1.5"
><span class="bg-crit size-2 rounded-[2px]"></span>critical</span
>
<span class="flex items-center gap-1.5"
><span class="bg-warn size-2 rounded-[2px]"></span>warning</span
>
<span class="flex items-center gap-1.5"
><span class="bg-ok/40 size-2 rounded-[2px]"></span>healthy</span
>
<span class="sm:ml-auto">Tap a segment to focus a project</span>
</p>
</div>
<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 }}
+133
View File
@@ -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>
+63 -3
View File
@@ -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>
+146 -2
View File
@@ -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')
})
})