Compare commits

..
3 Commits
Author SHA1 Message Date
lukaszraczylo 0c7c782d86 fix(gh): confirm failing workflows on a new connection
GitHub sometimes answers a workflow run listing from a lagging replica. For one workflow the answer held 135 runs and ended three weeks ago, while the current listing held 414 runs. The client reuses one connection, so a stale replica could answer every request of a scan and the dashboard showed a failure that a newer run had already replaced.

A failing workflow is now requested again on a new connection that skips the response cache. The client keeps the union of both answers, so the newest run decides.
2026-10-02 05:24:46 +01:00
lukaszraczylo a56a12a6e0 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.
2026-10-02 04:50:38 +01:00
lukaszraczylo 1852ea0885 fix(ui): use the full page width and fit phone screens
The page no longer caps its width. On very wide screens the attention list shows two columns of projects.

On phones the header, the health strip and the filter chips wrap or scroll inside their own box, and each item shows its age under the text. The side panel scrolls on its own.
2026-10-02 04:40:27 +01:00
25 changed files with 771 additions and 94 deletions
+9
View File
@@ -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 {
+25
View File
@@ -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)
}
}
+41 -8
View File
@@ -66,6 +66,8 @@ type TokenSource interface {
}
type Client struct {
// freshHC opens a new connection for every request.
freshHC *http.Client
rateReset time.Time
blockedUntil time.Time
auth TokenSource
@@ -88,7 +90,7 @@ type cachedResponse struct {
}
func NewClient(baseURL string, auth TokenSource, hc *http.Client, maxItems int) *Client {
c := &Client{base: strings.TrimRight(baseURL, "/"), hc: hc, auth: auth, maxItems: maxItems, sleep: sleepCtx, now: time.Now, cache: map[string]cachedResponse{}}
c := &Client{base: strings.TrimRight(baseURL, "/"), hc: hc, freshHC: freshClient(hc), auth: auth, maxItems: maxItems, sleep: sleepCtx, now: time.Now, cache: map[string]cachedResponse{}}
c.rateRemaining.Store(-1)
return c
}
@@ -111,7 +113,9 @@ func sleepCtx(ctx context.Context, d time.Duration) error {
}
// do performs one authenticated GET, retrying transient failures, and returns the body and Link header.
func (c *Client) do(ctx context.Context, repo, target string) ([]byte, string, error) {
// do performs one authenticated GET, retrying transient failures. fresh sends it on a new connection
// and skips the response cache, so a stale GitHub replica behind a reused connection cannot answer it.
func (c *Client) do(ctx context.Context, repo, target string, fresh bool) ([]byte, string, error) {
var lastErr error
for attempt := 0; attempt <= maxRetries; attempt++ {
if attempt > 0 {
@@ -119,7 +123,7 @@ func (c *Client) do(ctx context.Context, repo, target string) ([]byte, string, e
return nil, "", err
}
}
body, link, retry, err := c.once(ctx, repo, target)
body, link, retry, err := c.once(ctx, repo, target, fresh)
if err == nil {
return body, link, nil
}
@@ -131,7 +135,7 @@ func (c *Client) do(ctx context.Context, repo, target string) ([]byte, string, e
return nil, "", lastErr
}
func (c *Client) once(ctx context.Context, repo, target string) (body []byte, link string, retry bool, err error) {
func (c *Client) once(ctx context.Context, repo, target string, fresh bool) (body []byte, link string, retry bool, err error) {
if gerr := c.guard(); gerr != nil {
return nil, "", false, gerr
}
@@ -150,10 +154,15 @@ func (c *Client) once(ctx context.Context, repo, target string) (body []byte, li
c.mu.Lock()
cached, haveCache := c.cache[target]
c.mu.Unlock()
haveCache = haveCache && !fresh
if haveCache {
req.Header.Set("If-None-Match", cached.etag)
}
resp, err := c.hc.Do(req)
hc := c.hc
if fresh {
hc = c.freshHC
}
resp, err := hc.Do(req)
if err != nil {
return nil, "", ctx.Err() == nil, err
}
@@ -174,7 +183,7 @@ func (c *Client) once(ctx context.Context, repo, target string) (body []byte, li
return nil, "", resp.StatusCode >= 500, ae
}
link = resp.Header.Get("Link")
if etag := resp.Header.Get("ETag"); etag != "" {
if etag := resp.Header.Get("ETag"); etag != "" && !fresh {
c.mu.Lock()
c.cache[target] = cachedResponse{etag: etag, body: data, link: link}
c.mu.Unlock()
@@ -241,7 +250,16 @@ func (c *Client) url(path string, q url.Values) string {
}
func (c *Client) getJSON(ctx context.Context, repo, path string, q url.Values, out any) error {
body, _, err := c.do(ctx, repo, c.url(path, q))
return c.getJSONMode(ctx, repo, path, q, out, false)
}
// getJSONFresh is getJSON on a new connection without the response cache.
func (c *Client) getJSONFresh(ctx context.Context, repo, path string, q url.Values, out any) error {
return c.getJSONMode(ctx, repo, path, q, out, true)
}
func (c *Client) getJSONMode(ctx context.Context, repo, path string, q url.Values, out any, fresh bool) error {
body, _, err := c.do(ctx, repo, c.url(path, q), fresh)
if err != nil {
return err
}
@@ -257,7 +275,7 @@ func listAll[T any](ctx context.Context, c *Client, repo, path string, q url.Val
next := c.url(path, q)
var all []T
for next != "" && len(all) < limit {
body, link, err := c.do(ctx, repo, next)
body, link, err := c.do(ctx, repo, next, false)
if err != nil {
return all, err
}
@@ -289,3 +307,18 @@ func nextLink(h string) string {
}
return ""
}
// freshClient copies hc but turns off connection reuse. Custom transports that are not an
// *http.Transport are used as they are.
func freshClient(hc *http.Client) *http.Client {
tr, ok := hc.Transport.(*http.Transport)
if !ok {
if hc.Transport != nil {
return hc
}
tr, _ = http.DefaultTransport.(*http.Transport)
}
clone := tr.Clone()
clone.DisableKeepAlives = true
return &http.Client{Transport: clone, Timeout: hc.Timeout}
}
+49 -12
View File
@@ -262,24 +262,28 @@ func (c *Client) workflows(ctx context.Context, full, branch string) ([]model.Wo
if w.State != "active" || len(out) >= maxWorkflows {
continue
}
var runs struct {
WorkflowRuns []rawRun `json:"workflow_runs"`
}
q := url.Values{
"branch": {branch},
"exclude_pull_requests": {"true"},
"per_page": {fmt.Sprint(runsPerWorkflow)},
}
path := fmt.Sprintf("/repos/%s/actions/workflows/%d/runs", full, w.ID)
if err := c.getJSON(ctx, full, path, q, &runs); err != nil {
runs, err := c.workflowRuns(ctx, full, w.ID, branch, false)
if err != nil {
if first == nil {
first = fmt.Errorf("%s: %w", w.Name, err)
}
continue
}
if wf, ok := buildWorkflow(w.Name, w.HTMLURL, runs.WorkflowRuns); ok {
out = append(out, wf)
wf, ok := buildWorkflow(w.Name, w.HTMLURL, runs)
if !ok {
continue
}
// GitHub sometimes answers a run listing from a lagging replica, and a reused connection
// keeps hitting it. A failure is what raises an alert, so confirm it on a new connection
// and keep the union of both answers.
if wf.LastDone != nil && wf.LastDone.Failed() {
if again, err := c.workflowRuns(ctx, full, w.ID, branch, true); err == nil {
if merged, ok := buildWorkflow(w.Name, w.HTMLURL, mergeRuns(runs, again)); ok {
wf = merged
}
}
}
out = append(out, wf)
}
return out, first
}
@@ -343,3 +347,36 @@ func (c *Client) latestRelease(ctx context.Context, full, repoURL string) (*mode
FromTag: true,
}, nil
}
func (c *Client) workflowRuns(ctx context.Context, full string, id int64, branch string, fresh bool) ([]rawRun, error) {
var out struct {
WorkflowRuns []rawRun `json:"workflow_runs"`
}
q := url.Values{
"branch": {branch},
"exclude_pull_requests": {"true"},
"per_page": {fmt.Sprint(runsPerWorkflow)},
}
path := fmt.Sprintf("/repos/%s/actions/workflows/%d/runs", full, id)
get := c.getJSON
if fresh {
get = c.getJSONFresh
}
if err := get(ctx, full, path, q, &out); err != nil {
return nil, err
}
return out.WorkflowRuns, nil
}
// mergeRuns returns the runs of a and b once each, keyed by run ID.
func mergeRuns(a, b []rawRun) []rawRun {
seen := make(map[int64]bool, len(a)+len(b))
out := make([]rawRun, 0, len(a)+len(b))
for _, r := range slices.Concat(a, b) {
if !seen[r.ID] {
seen[r.ID] = true
out = append(out, r)
}
}
return out
}
+94
View File
@@ -459,3 +459,97 @@ func TestAPIErrorMessageParsing(t *testing.T) {
t.Fatalf("len=%d", len(got))
}
}
// staleThenFresh serves an old failing run on the first request and the current runs afterwards,
// like a lagging GitHub replica followed by a healthy one.
func staleThenFresh(f *fakeGitHub, path string) *atomic.Int64 {
var calls atomic.Int64
f.routes[path] = func(w http.ResponseWriter, r *http.Request) {
n := calls.Add(1)
if n == 1 {
_, _ = w.Write([]byte(`{"workflow_runs":[
{"id":1,"html_url":"old","event":"schedule","head_branch":"main","status":"completed","conclusion":"failure","created_at":"2026-09-11T03:02:00Z"}]}`))
return
}
if r.Header.Get("If-None-Match") != "" {
http.Error(w, "fresh requests must not be conditional", http.StatusBadRequest)
return
}
_, _ = w.Write([]byte(`{"workflow_runs":[
{"id":3,"html_url":"new","event":"schedule","head_branch":"main","status":"completed","conclusion":"success","created_at":"2026-10-02T03:03:00Z"},
{"id":1,"html_url":"old","event":"schedule","head_branch":"main","status":"completed","conclusion":"failure","created_at":"2026-09-11T03:02:00Z"}]}`))
}
return &calls
}
func TestStaleFailureIsConfirmedOnANewConnection(t *testing.T) {
f := newFake(t)
c := f.client(t, 10)
f.json("/repos/o/r/actions/workflows", `{"workflows":[{"id":7,"name":"Autoupdate","html_url":"w","state":"active"}]}`)
calls := staleThenFresh(f, "/repos/o/r/actions/workflows/7/runs")
wfs, err := c.workflows(context.Background(), "o/r", "main")
if err != nil {
t.Fatal(err)
}
if len(wfs) != 1 || wfs[0].Latest.ID != 3 || wfs[0].LastDone == nil || wfs[0].LastDone.Failed() {
t.Fatalf("the newer success must supersede the stale failure: %+v", wfs)
}
if calls.Load() != 2 {
t.Fatalf("runs requested %d times, want 2", calls.Load())
}
}
func TestHealthyWorkflowIsNotRequestedTwice(t *testing.T) {
f := newFake(t)
c := f.client(t, 10)
f.json("/repos/o/r/actions/workflows", `{"workflows":[{"id":7,"name":"CI","html_url":"w","state":"active"}]}`)
var calls atomic.Int64
f.routes["/repos/o/r/actions/workflows/7/runs"] = func(w http.ResponseWriter, _ *http.Request) {
calls.Add(1)
_, _ = w.Write([]byte(`{"workflow_runs":[{"id":3,"event":"push","head_branch":"main","status":"completed","conclusion":"success","created_at":"2026-10-02T03:03:00Z"}]}`))
}
if _, err := c.workflows(context.Background(), "o/r", "main"); err != nil {
t.Fatal(err)
}
if calls.Load() != 1 {
t.Fatalf("a passing workflow needs one request, got %d", calls.Load())
}
}
func TestConfirmedFailureStaysFailing(t *testing.T) {
f := newFake(t)
c := f.client(t, 10)
f.json("/repos/o/r/actions/workflows", `{"workflows":[{"id":7,"name":"CI","html_url":"w","state":"active"}]}`)
f.json("/repos/o/r/actions/workflows/7/runs", `{"workflow_runs":[{"id":3,"event":"push","head_branch":"main","status":"completed","conclusion":"failure","created_at":"2026-10-02T03:03:00Z"}]}`)
wfs, err := c.workflows(context.Background(), "o/r", "main")
if err != nil || len(wfs) != 1 || wfs[0].LastDone == nil || !wfs[0].LastDone.Failed() {
t.Fatalf("a failure that both answers agree on must stay: %+v %v", wfs, err)
}
}
func TestMergeRunsKeepsEachRunOnce(t *testing.T) {
got := mergeRuns([]rawRun{{ID: 1}, {ID: 2}}, []rawRun{{ID: 2}, {ID: 3}})
if len(got) != 3 || got[0].ID != 1 || got[1].ID != 2 || got[2].ID != 3 {
t.Fatalf("%+v", got)
}
}
func TestFreshClientDisablesConnectionReuse(t *testing.T) {
base := &http.Client{Transport: &http.Transport{}, Timeout: 5 * time.Second}
fc := freshClient(base)
tr, ok := fc.Transport.(*http.Transport)
if !ok || !tr.DisableKeepAlives || fc.Timeout != 5*time.Second {
t.Fatalf("fresh client: %+v", fc)
}
if base.Transport.(*http.Transport).DisableKeepAlives {
t.Fatal("the original client must keep its connections")
}
custom := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { return nil, errors.New("x") })}
if freshClient(custom) != custom {
t.Fatal("non-standard transports are used as they are")
}
}
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
+8
View File
@@ -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),
+5 -1
View File
@@ -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
View File
@@ -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)
})
+19 -13
View File
@@ -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[] = []
@@ -64,13 +71,14 @@ onBeforeUnmount(() => {
</script>
<template>
<main class="mx-auto max-w-7xl px-5 py-6 sm:px-8">
<main class="w-full px-4 py-4 sm:px-6 sm:py-6 lg:px-10">
<AppHeader
:last-refresh="state?.lastRefresh ?? ''"
:now-ms="nowMs"
: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-12">
<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"
@@ -106,7 +112,7 @@ onBeforeUnmount(() => {
/>
</div>
<div
class="mt-14 grid grid-cols-[minmax(0,1fr)] gap-x-10 gap-y-12 lg:grid-cols-[minmax(0,1fr)_23rem]"
class="mt-10 grid grid-cols-[minmax(0,1fr)] gap-x-10 gap-y-10 sm:mt-14 lg:grid-cols-[minmax(0,1fr)_24rem] 2xl:grid-cols-[minmax(0,1fr)_28rem]"
>
<AttentionList
ref="list"
+12 -4
View File
@@ -11,6 +11,7 @@ const props = defineProps<{
intervalSeconds: number
loaded: boolean
scanning: boolean
progress: string
manualRefresh: boolean
}>()
defineEmits<{ rescan: [] }>()
@@ -28,7 +29,7 @@ const nextIn = computed(() => {
</script>
<template>
<header class="flex items-center justify-between gap-4">
<header class="flex flex-wrap items-center justify-between gap-x-4 gap-y-2">
<div class="flex items-center gap-3">
<span
class="border-border bg-surface text-link flex size-9 items-center justify-center rounded-md border"
@@ -37,8 +38,12 @@ const nextIn = computed(() => {
</span>
<span class="text-lg font-semibold tracking-tight">ghtracker</span>
</div>
<div class="text-muted-foreground flex items-center gap-5 text-sm">
<span v-if="updated" class="hidden sm:inline" data-test="updated">
<div class="text-muted-foreground flex flex-wrap items-center gap-x-5 gap-y-1 text-sm">
<span
v-if="updated"
class="order-last w-full text-xs sm:order-none sm:w-auto sm:text-sm"
data-test="updated"
>
Scanned {{ updated }} ago
<span class="text-muted-foreground/70"> · next in {{ nextIn }}</span>
</span>
@@ -52,7 +57,10 @@ const nextIn = computed(() => {
@click="$emit('rescan')"
>
<FontAwesomeIcon :icon="faArrowsRotate" :spin="scanning" aria-hidden="true" />
{{ scanning ? 'Rescanning…' : 'Rescan repositories' }}
<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="flex flex-col gap-4">
<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>
+7 -2
View File
@@ -12,7 +12,10 @@ const ratio = computed(() => secondsSince(props.row.since, props.nowMs) / props.
</script>
<template>
<li class="grid grid-cols-[2rem_minmax(0,1fr)_auto] items-start gap-x-3 py-3" data-test="row">
<li
class="grid grid-cols-[2rem_minmax(0,1fr)] items-start gap-x-3 gap-y-1 py-3 sm:grid-cols-[2rem_minmax(0,1fr)_auto]"
data-test="row"
>
<span
class="flex size-8 items-center justify-center rounded-md"
:class="row.severity === 'crit' ? 'bg-crit/12 text-crit' : 'bg-warn/12 text-warn'"
@@ -31,7 +34,9 @@ const ratio = computed(() => secondsSince(props.row.since, props.nowMs) / props.
{{ shorten(row.detail, 120) }}
</p>
</div>
<div class="flex flex-col items-end gap-1.5 pt-0.5">
<div
class="col-start-2 flex items-center gap-3 sm:col-start-auto sm:flex-col sm:items-end sm:gap-1.5 sm:pt-0.5"
>
<span class="text-muted-foreground font-mono text-xs whitespace-nowrap">
{{ isZeroTime(row.since) ? '' : ageSince(row.since, nowMs) }}
</span>
+4 -1
View File
@@ -49,7 +49,7 @@ function onOwners(value: unknown): void {
<template>
<div class="flex flex-col gap-3" data-test="filter-bar">
<div class="flex flex-wrap items-center gap-3">
<div class="relative min-w-56 flex-1">
<div class="relative min-w-0 basis-full sm:min-w-56 sm:flex-1 sm:basis-0">
<FontAwesomeIcon
:icon="faMagnifyingGlass"
class="text-muted-foreground pointer-events-none absolute top-1/2 left-3 -translate-y-1/2"
@@ -75,6 +75,7 @@ function onOwners(value: unknown): void {
</div>
<ToggleGroup
type="single"
class="max-w-full overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
variant="outline"
size="sm"
:model-value="filters.severity"
@@ -96,6 +97,7 @@ function onOwners(value: unknown): void {
<div class="flex flex-wrap items-center gap-x-5 gap-y-2">
<ToggleGroup
type="multiple"
class="max-w-full overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
variant="outline"
size="sm"
:model-value="filters.kinds"
@@ -117,6 +119,7 @@ function onOwners(value: unknown): void {
<ToggleGroup
v-if="owners.length > 1"
type="multiple"
class="max-w-full overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
variant="outline"
size="sm"
:model-value="filters.owners"
+41 -34
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)_20rem]"
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,50 +65,60 @@ 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-10 gap-y-1 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">
<span v-if="crit > 0" class="text-crit">
<span class="text-7xl leading-none tabular-nums">{{ crit }}</span>
<span class="text-5xl leading-none tabular-nums sm:text-7xl">{{ crit }}</span>
<span class="ml-2 text-xl font-medium">critical</span>
</span>
<span v-if="warn > 0" class="text-warn">
<span class="text-7xl leading-none tabular-nums">{{ warn }}</span>
<span class="text-5xl leading-none tabular-nums sm:text-7xl">{{ warn }}</span>
<span class="ml-2 text-xl font-medium">{{ warn === 1 ? 'warning' : 'warnings' }}</span>
</span>
</template>
<span v-else class="text-ok text-7xl leading-none">All clear</span>
<span v-else class="text-ok text-5xl leading-none sm:text-7xl">All clear</span>
</h1>
<div class="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="ml-auto">Select 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 }}
+7 -2
View File
@@ -24,14 +24,19 @@ function summary(c: RepoCounts | undefined): string {
<template>
<TooltipProvider :delay-duration="80">
<div class="flex gap-1" role="group" aria-label="Project health" data-test="strip">
<div
class="grid grid-cols-[repeat(auto-fill,minmax(1.5rem,1fr))] gap-1"
role="group"
aria-label="Project health"
data-test="strip"
>
<Tooltip v-for="repo in repos" :key="repo.fullName">
<TooltipTrigger as-child>
<button
type="button"
:aria-label="`${repo.fullName}: ${summary(counts.get(repo.fullName))}`"
:aria-pressed="focused === repo.fullName"
class="h-14 min-w-0 flex-1 rounded-[3px] transition-colors focus-visible:outline-offset-1"
class="h-9 min-w-0 rounded-[3px] sm:h-11 transition-colors focus-visible:outline-offset-1"
:class="[
SEGMENT[repo.health],
focused === repo.fullName
+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>
+66 -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>
@@ -26,9 +77,13 @@ const LABEL = { crit: 'Critical', warn: 'Warning', ok: 'Healthy' } as const
>
No projects match the search.
</p>
<ul v-else class="border-border bg-surface divide-border divide-y rounded-lg border">
<ul
v-else
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' : ''"
@@ -86,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>
+147 -3
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', () => {
@@ -39,12 +41,16 @@ describe('AppHeader', () => {
it('emits rescan and shows progress while a scan runs', async () => {
const w = mount(AppHeader, { props: { ...base, manualRefresh: true } })
expect(w.get('[data-test=rescan]').text()).toBe('Rescan repositories')
expect(w.get('[data-test=rescan]').text()).toContain('Rescan repositories')
await w.get('[data-test=rescan]').trigger('click')
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()).toBe('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')
})
})
+18
View File
@@ -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
}
+30
View File
@@ -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)
})
})
+16
View File
@@ -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
}
+1
View File
@@ -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[]
+9
View File
@@ -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()
+7
View File
@@ -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,
+1
View File
@@ -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,