From 66ed84bdbf2feb0c154396c4d034506dd66b4996 Mon Sep 17 00:00:00 2001 From: Lukasz Raczylo Date: Mon, 5 Oct 2026 14:04:53 +0100 Subject: [PATCH] fix(collector): publish each repository as its scan finishes A rescan held every result until the last repository finished. The dashboard therefore showed new and resolved alerts only at the end. Each repository now enters the view when its fetch completes. The last scan time, loaded flag and refresh hooks still update at the end. --- internal/collector/collector.go | 12 +++++------ internal/collector/collector_test.go | 31 ++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/internal/collector/collector.go b/internal/collector/collector.go index eee0f20..20bedd6 100644 --- a/internal/collector/collector.go +++ b/internal/collector/collector.go @@ -155,25 +155,25 @@ func (c *Collector) Refresh(ctx context.Context) { 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 - for i, name := range c.names { + for _, name := range c.names { wg.Add(1) sem <- struct{}{} go func() { defer wg.Done() defer func() { <-sem }() - results[i] = c.refreshOne(ctx, name, start, true) + r := c.refreshOne(ctx, name, start, true) + // Publish per repo so a running scan shows new and resolved alerts as they land. + c.mu.Lock() + c.repos[r.FullName] = r + c.mu.Unlock() c.scanDone.Add(1) }() } wg.Wait() c.mu.Lock() - for _, r := range results { - c.repos[r.FullName] = r - } c.lastRefresh = start c.lastDur = c.now().Sub(start) c.loaded = true diff --git a/internal/collector/collector_test.go b/internal/collector/collector_test.go index 697a2fe..bb2ae7e 100644 --- a/internal/collector/collector_test.go +++ b/internal/collector/collector_test.go @@ -553,3 +553,34 @@ func TestRefreshRepoFailureSkipsRefreshHooks(t *testing.T) { case <-time.After(200 * time.Millisecond): } } + +func TestRefreshPublishesEachRepoBeforeTheScanEnds(t *testing.T) { + release := make(chan struct{}) + f := &fakeFetcher{fn: func(name string) (model.Repo, error) { + if name == "o/slow" { + <-release + } + return model.Repo{FullName: name, Err: ""}, nil + }} + cfg := &config.Config{Repos: []string{"o/fast", "o/slow"}, RefreshInterval: time.Hour, Concurrency: 2, Thresholds: th()} + c := New(f, cfg, slog.New(slog.NewTextHandler(io.Discard, nil))) + c.now = func() time.Time { return t0 } + done := make(chan struct{}) + go func() { c.Refresh(context.Background()); close(done) }() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if v := c.View(); len(v.Repos) == 1 && v.Repos[0].FullName == "o/fast" { + break + } + time.Sleep(5 * time.Millisecond) + } + v := c.View() + if len(v.Repos) != 1 || v.Repos[0].FullName != "o/fast" || !v.Refreshing || v.Loaded { + t.Fatalf("mid-scan view: repos=%d refreshing=%v loaded=%v", len(v.Repos), v.Refreshing, v.Loaded) + } + close(release) + <-done + if v := c.View(); len(v.Repos) != 2 || !v.Loaded { + t.Fatalf("after scan: repos=%d loaded=%v", len(v.Repos), v.Loaded) + } +}