From 8cbc495dc71e9d2bdc0f42acb32de46f855c9e40 Mon Sep 17 00:00:00 2001
From: Lukasz Raczylo
Date: Fri, 2 Oct 2026 23:16:22 +0100
Subject: [PATCH] feat: refresh a single repository from its own button (#2)
* feat(refresh): refresh a single repository with POST /refresh?repo=
The endpoint refreshes one configured repository and returns the full state plus the repo name.
Each repository has a 60-second cooldown that is separate from the full-refresh limit.
A running full refresh or a refresh of the same repository returns 409.
An unknown repository returns 404. Cross-site and disabled-config behaviour stay the same.
The collector reuses its fetch path and replaces only that repository.
* feat(ui): add a refresh button beside each repository
The button appears in the project list and in each attention group header.
It stays hidden when the server disables manual refresh.
The button shows a spinner while its repository refreshes and reports refusals in the notice line.
The store swaps in the returned state, so no extra fetch follows.
---
README.md | 8 ++
internal/collector/collector.go | 84 ++++++++++-
internal/collector/collector_test.go | 106 ++++++++++++++
internal/web/server.go | 70 ++++++++-
internal/web/server_test.go | 206 +++++++++++++++++++++++++++
src/App.test.ts | 48 +++++++
src/App.vue | 12 ++
src/components/AttentionList.vue | 9 +-
src/components/ProjectList.vue | 21 ++-
src/components/RepoGroup.vue | 20 ++-
src/components/RepoRefreshButton.vue | 24 ++++
src/components/components.test.ts | 52 +++++++
src/lib/api.ts | 20 ++-
src/lib/types.ts | 3 +
src/stores/tracker.test.ts | 58 ++++++++
src/stores/tracker.ts | 38 ++++-
16 files changed, 760 insertions(+), 19 deletions(-)
create mode 100644 src/components/RepoRefreshButton.vue
diff --git a/README.md b/README.md
index c44959c..d153b29 100644
--- a/README.md
+++ b/README.md
@@ -61,6 +61,14 @@ Alerts are evaluated when you read them, so PR ages keep advancing between refre
- `/api/state` full state as JSON.
- `/healthz` returns 503 until the first refresh completes.
- `POST /refresh` runs an extra refresh and backs the Refresh button. It is on by default, limited to once per 5 minutes, and refuses cross-site requests. Set `manual_refresh: false` to disable it.
+- `POST /refresh?repo=owner/name` refreshes only that repository and answers when it finishes. An empty `repo` value runs the full refresh above.
+ - The repository must be in the configured list, or the answer is 404 with a JSON `{"error": ...}` body. The match ignores case.
+ - On success the answer is 200 with the same JSON as `/api/state`, plus a `repo` field. Only that repository changes. The last scan time and the schedule do not move.
+ - Each repository has its own cooldown of 60 seconds. Inside it, the answer is 429 with a `Retry-After` header. A repository refresh does not use the 5-minute budget of the full refresh, and the reverse also holds.
+ - While a full refresh or a refresh of the same repository runs, the answer is 409. The cooldown does not start, so you can retry at once.
+ - The same cross-site refusal and `manual_refresh: false` switch apply. The GitHub rate-limit protections below apply too.
+
+The dashboard shows a refresh button beside each project when `manual_refresh` is on.
## Metrics
diff --git a/internal/collector/collector.go b/internal/collector/collector.go
index d7e678b..059f50f 100644
--- a/internal/collector/collector.go
+++ b/internal/collector/collector.go
@@ -6,6 +6,7 @@ import (
"errors"
"log/slog"
"slices"
+ "strings"
"sync"
"sync/atomic"
"time"
@@ -16,6 +17,13 @@ import (
const repoFetchTimeout = 5 * time.Minute
+var (
+ // ErrUnknownRepo means the name is not in the configured repository list.
+ ErrUnknownRepo = errors.New("repository is not configured")
+ // ErrRefreshBusy means a full refresh or a refresh of the same repository is running.
+ ErrRefreshBusy = errors.New("a refresh is already running")
+)
+
// Fetcher loads the data for one "owner/name" repository.
type Fetcher interface {
FetchRepo(ctx context.Context, full string) (model.Repo, error)
@@ -52,6 +60,7 @@ type Collector struct {
lastRefresh time.Time
fetch Fetcher
repos map[string]model.Repo
+ inflight map[string]struct{}
now func() time.Time
log *slog.Logger
webBase string
@@ -83,6 +92,7 @@ func New(f Fetcher, cfg *config.Config, log *slog.Logger) *Collector {
log: log,
kick: make(chan struct{}, 1),
repos: make(map[string]model.Repo, len(cfg.Repos)),
+ inflight: make(map[string]struct{}),
}
}
@@ -136,7 +146,7 @@ func (c *Collector) Refresh(ctx context.Context) {
go func() {
defer wg.Done()
defer func() { <-sem }()
- results[i] = c.refreshOne(ctx, name, start)
+ results[i] = c.refreshOne(ctx, name, start, true)
c.scanDone.Add(1)
}()
}
@@ -154,18 +164,28 @@ func (c *Collector) Refresh(ctx context.Context) {
c.log.Info("refresh complete", "repos", len(c.names), "duration", c.lastDur.Round(time.Millisecond))
}
-func (c *Collector) refreshOne(ctx context.Context, name string, at time.Time) model.Repo {
- ctx, cancel := context.WithTimeout(ctx, repoFetchTimeout)
- defer cancel()
+// refreshOne fetches one repo. With respectLimit set, it skips the fetch once the scan hit a rate limit.
+func (c *Collector) refreshOne(ctx context.Context, name string, at time.Time, respectLimit bool) model.Repo {
var r model.Repo
err := model.ErrRateLimited
// Once any repo hits the limit, remaining repos are skipped so no further requests go out.
- if !c.limited.Load() {
- r, err = c.fetch.FetchRepo(ctx, name)
+ if !respectLimit || !c.limited.Load() {
+ r, err = c.fetchRepo(ctx, name)
}
- if errors.Is(err, model.ErrRateLimited) {
+ if respectLimit && errors.Is(err, model.ErrRateLimited) {
c.limited.Store(true)
}
+ return c.settle(name, at, r, err)
+}
+
+func (c *Collector) fetchRepo(ctx context.Context, name string) (model.Repo, error) {
+ ctx, cancel := context.WithTimeout(ctx, repoFetchTimeout)
+ defer cancel()
+ return c.fetch.FetchRepo(ctx, name)
+}
+
+// settle turns a fetch result into the repo to store; a failure keeps the last good data.
+func (c *Collector) settle(name string, at time.Time, r model.Repo, err error) model.Repo {
if err == nil {
r.FullName, r.Up, r.RefreshedAt = name, true, at
return r
@@ -182,6 +202,56 @@ func (c *Collector) refreshOne(ctx context.Context, name string, at time.Time) m
return prev
}
+// RefreshRepo fetches one configured repo now and replaces only its state, leaving the schedule,
+// last scan time and other repos untouched. It fails with ErrRefreshBusy while a full refresh or a
+// refresh of the same repo runs. The GitHub client still enforces the rate-limit reserve.
+func (c *Collector) RefreshRepo(ctx context.Context, name string) (View, error) {
+ canon, ok := c.configured(name)
+ if !ok {
+ return View{}, ErrUnknownRepo
+ }
+ if c.refreshing.Load() || !c.claim(canon) {
+ return View{}, ErrRefreshBusy
+ }
+ defer c.release(canon)
+
+ at := c.now()
+ r, err := c.fetchRepo(ctx, canon)
+ if err != nil && ctx.Err() != nil {
+ return View{}, ctx.Err()
+ }
+ r = c.settle(canon, at, r, err)
+ c.mu.Lock()
+ c.repos[canon] = r
+ c.mu.Unlock()
+ return c.View(), nil
+}
+
+func (c *Collector) configured(name string) (string, bool) {
+ for _, n := range c.names {
+ if strings.EqualFold(n, name) {
+ return n, true
+ }
+ }
+ return "", false
+}
+
+func (c *Collector) claim(name string) bool {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ if _, busy := c.inflight[name]; busy {
+ return false
+ }
+ c.inflight[name] = struct{}{}
+ return true
+}
+
+func (c *Collector) release(name string) {
+ c.mu.Lock()
+ delete(c.inflight, name)
+ c.mu.Unlock()
+}
+
// Stats returns refresh-loop counters.
func (c *Collector) Stats() Stats {
c.mu.RLock()
diff --git a/internal/collector/collector_test.go b/internal/collector/collector_test.go
index 0c2d98b..af7010b 100644
--- a/internal/collector/collector_test.go
+++ b/internal/collector/collector_test.go
@@ -351,3 +351,109 @@ func TestFailedFirstScanLinksToTheConfiguredHost(t *testing.T) {
t.Fatalf("repo link = %q", got)
}
}
+
+func TestRefreshRepoReplacesOnlyThatRepo(t *testing.T) {
+ gen := 0
+ f := &fakeFetcher{fn: func(name string) (model.Repo, error) {
+ return model.Repo{FullName: name, URL: "u", Issues: make([]model.Issue, gen)}, nil
+ }}
+ c := newCollector(f, "o/a", "o/b")
+ c.Refresh(context.Background())
+ before := c.View()
+ gen = 2
+ v, err := c.RefreshRepo(context.Background(), "O/B")
+ if err != nil {
+ t.Fatal(err)
+ }
+ issues := map[string]int{}
+ for _, r := range v.Repos {
+ issues[r.FullName] = len(r.Issues)
+ }
+ if issues["o/a"] != 0 || issues["o/b"] != 2 {
+ t.Fatalf("only o/b must change: %v", issues)
+ }
+ if !v.LastRefresh.Equal(before.LastRefresh) || c.Stats().RefreshTotal != 1 {
+ t.Fatalf("full-scan bookkeeping must not move: %v %d", v.LastRefresh, c.Stats().RefreshTotal)
+ }
+ if len(f.calls) != 3 || f.calls[2] != "o/b" {
+ t.Fatalf("calls %v", f.calls)
+ }
+}
+
+func TestRefreshRepoFailureKeepsLastGoodDataAndAlerts(t *testing.T) {
+ fail := false
+ f := &fakeFetcher{fn: func(name string) (model.Repo, error) {
+ if fail {
+ return model.Repo{}, errors.New("boom")
+ }
+ return model.Repo{FullName: name, URL: "u", Issues: []model.Issue{{Number: 1, UpdatedAt: t0}}}, nil
+ }}
+ c := newCollector(f, "o/a")
+ c.Refresh(context.Background())
+ fail = true
+ v, err := c.RefreshRepo(context.Background(), "o/a")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if r := v.Repos[0]; r.Up || r.Err != "boom" || len(r.Issues) != 1 || v.Crit != 1 {
+ t.Fatalf("%+v crit=%d", r, v.Crit)
+ }
+ if c.Stats().RepoErrorsTotal != 1 {
+ t.Fatalf("stats %+v", c.Stats())
+ }
+}
+
+func TestRefreshRepoIgnoresScanRateLimitFlag(t *testing.T) {
+ c := newCollector(&fakeFetcher{fn: func(n string) (model.Repo, error) { return model.Repo{FullName: n}, nil }}, "o/a")
+ c.limited.Store(true)
+ v, err := c.RefreshRepo(context.Background(), "o/a")
+ if err != nil || !v.Repos[0].Up {
+ t.Fatalf("err=%v %+v", err, v.Repos)
+ }
+}
+
+func TestRefreshRepoRejections(t *testing.T) {
+ c := newCollector(&fakeFetcher{fn: func(n string) (model.Repo, error) { return model.Repo{FullName: n}, nil }}, "o/a")
+ if _, err := c.RefreshRepo(context.Background(), "x/y"); !errors.Is(err, ErrUnknownRepo) {
+ t.Fatalf("unknown: %v", err)
+ }
+ c.refreshing.Store(true)
+ if _, err := c.RefreshRepo(context.Background(), "o/a"); !errors.Is(err, ErrRefreshBusy) {
+ t.Fatalf("full refresh running: %v", err)
+ }
+ c.refreshing.Store(false)
+ if !c.claim("o/a") {
+ t.Fatal("claim")
+ }
+ if _, err := c.RefreshRepo(context.Background(), "o/a"); !errors.Is(err, ErrRefreshBusy) {
+ t.Fatalf("same repo running: %v", err)
+ }
+}
+
+func TestRefreshRepoCancelledKeepsState(t *testing.T) {
+ ctx, cancel := context.WithCancel(context.Background())
+ f := &fakeFetcher{fn: func(string) (model.Repo, error) { cancel(); return model.Repo{}, context.Canceled }}
+ c := newCollector(f, "o/a")
+ if _, err := c.RefreshRepo(ctx, "o/a"); !errors.Is(err, context.Canceled) {
+ t.Fatalf("err=%v", err)
+ }
+ if len(c.View().Repos) != 0 || c.Stats().RepoErrorsTotal != 0 {
+ t.Fatal("a cancelled request must not mark the repo down")
+ }
+}
+
+func TestRefreshRepoConcurrentWithFullRefreshAndScheduled(t *testing.T) {
+ f := &fakeFetcher{fn: func(n string) (model.Repo, error) { return model.Repo{FullName: n}, nil }}
+ c := newCollector(f, "o/a", "o/b")
+ var wg sync.WaitGroup
+ for range 8 {
+ wg.Add(3)
+ go func() { defer wg.Done(); c.Refresh(context.Background()) }()
+ go func() { defer wg.Done(); _, _ = c.RefreshRepo(context.Background(), "o/a") }()
+ go func() { defer wg.Done(); _ = c.View(); _ = c.Stats() }()
+ }
+ wg.Wait()
+ if len(c.View().Repos) != 2 {
+ t.Fatalf("%+v", c.View().Repos)
+ }
+}
diff --git a/internal/web/server.go b/internal/web/server.go
index fb95125..900e89b 100644
--- a/internal/web/server.go
+++ b/internal/web/server.go
@@ -2,11 +2,14 @@
package web
import (
+ "context"
"encoding/json"
+ "errors"
"io/fs"
"log/slog"
"net/http"
"path"
+ "strconv"
"strings"
"sync"
"time"
@@ -16,6 +19,8 @@ import (
const (
minManualRefreshGap = 5 * time.Minute
+ // repoRefreshCooldown limits each repository separately from the full-refresh gap.
+ repoRefreshCooldown = 60 * time.Second
indexFile = "index.html"
assetsPrefix = "/assets/"
immutableCache = "public, max-age=31536000, immutable"
@@ -28,10 +33,12 @@ type Source interface {
View() collector.View
Stats() collector.Stats
RequestRefresh() bool
+ RefreshRepo(ctx context.Context, name string) (collector.View, error)
}
type Server struct {
lastManual time.Time
+ lastRepo map[string]time.Time
now func() time.Time
src Source
ui fs.FS
@@ -43,7 +50,8 @@ type Server struct {
// New builds the server; ui is the built frontend (index.html at its root).
func New(src Source, metrics http.Handler, ui fs.FS, manualRefresh bool, log *slog.Logger) *Server {
- return &Server{src: src, ui: ui, metrics: metrics, manualRefresh: manualRefresh, log: log, now: time.Now}
+ return &Server{src: src, ui: ui, metrics: metrics, manualRefresh: manualRefresh, log: log, now: time.Now,
+ lastRepo: make(map[string]time.Time)}
}
func (s *Server) Handler() http.Handler {
@@ -114,6 +122,10 @@ func (s *Server) refresh(w http.ResponseWriter, r *http.Request) {
http.Error(w, "cross-site refresh refused", http.StatusForbidden)
return
}
+ if repo := r.URL.Query().Get("repo"); repo != "" {
+ s.refreshRepo(w, r, repo)
+ return
+ }
s.mu.Lock()
tooSoon := s.now().Sub(s.lastManual) < minManualRefreshGap
if !tooSoon {
@@ -127,3 +139,59 @@ func (s *Server) refresh(w http.ResponseWriter, r *http.Request) {
s.src.RequestRefresh()
w.WriteHeader(http.StatusAccepted)
}
+
+// repoRefreshResponse is the full state after the refresh, so the UI can swap it in, plus the repo name.
+type repoRefreshResponse struct {
+ Repo string `json:"repo"`
+ apiState
+}
+
+// refreshRepo refreshes one repository and answers when it finishes. It has its own cooldown and
+// never touches the full-refresh gap. A running full refresh or same-repo refresh gives 409.
+func (s *Server) refreshRepo(w http.ResponseWriter, r *http.Request, repo string) {
+ key := strings.ToLower(repo)
+ s.mu.Lock()
+ prev, had := s.lastRepo[key]
+ wait := repoRefreshCooldown - s.now().Sub(prev)
+ if had && wait > 0 {
+ s.mu.Unlock()
+ w.Header().Set("Retry-After", strconv.Itoa(int((wait+time.Second-1)/time.Second)))
+ writeJSONError(w, http.StatusTooManyRequests, "repository refresh allowed once per "+repoRefreshCooldown.String())
+ return
+ }
+ s.lastRepo[key] = s.now()
+ s.mu.Unlock()
+
+ v, err := s.src.RefreshRepo(r.Context(), repo)
+ if err != nil {
+ // A refused or cancelled attempt spent no GitHub budget, so it must not start a cooldown.
+ s.mu.Lock()
+ if had {
+ s.lastRepo[key] = prev
+ } else {
+ delete(s.lastRepo, key)
+ }
+ s.mu.Unlock()
+ switch {
+ case errors.Is(err, collector.ErrUnknownRepo):
+ writeJSONError(w, http.StatusNotFound, "unknown repository")
+ case errors.Is(err, collector.ErrRefreshBusy):
+ writeJSONError(w, http.StatusConflict, "a refresh is already running; try again shortly")
+ default:
+ s.log.Error("refresh repo", "repo", repo, "err", err)
+ writeJSONError(w, http.StatusInternalServerError, "refresh failed")
+ }
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ w.Header().Set("Cache-Control", "no-store")
+ if err := json.NewEncoder(w).Encode(repoRefreshResponse{Repo: repo, apiState: buildState(v, s.manualRefresh)}); err != nil {
+ s.log.Error("encode repo refresh", "err", err)
+ }
+}
+
+func writeJSONError(w http.ResponseWriter, code int, msg string) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(code)
+ _ = json.NewEncoder(w).Encode(map[string]string{"error": msg})
+}
diff --git a/internal/web/server_test.go b/internal/web/server_test.go
index 24db35d..da174f7 100644
--- a/internal/web/server_test.go
+++ b/internal/web/server_test.go
@@ -1,12 +1,15 @@
package web
import (
+ "context"
"encoding/json"
+ "errors"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
+ "sync"
"testing"
"testing/fstest"
"time"
@@ -19,15 +22,34 @@ import (
var now = time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC)
type fakeSource struct {
+ repoFn func(name string) (collector.View, error)
+ repoCalls []string
st collector.Stats
v collector.View
refreshed int
+ mu sync.Mutex
}
func (f *fakeSource) View() collector.View { return f.v }
func (f *fakeSource) Stats() collector.Stats { return f.st }
func (f *fakeSource) RequestRefresh() bool { f.refreshed++; return true }
+func (f *fakeSource) RefreshRepo(_ context.Context, name string) (collector.View, error) {
+ f.mu.Lock()
+ f.repoCalls = append(f.repoCalls, name)
+ f.mu.Unlock()
+ if f.repoFn != nil {
+ return f.repoFn(name)
+ }
+ return f.v, nil
+}
+
+func (f *fakeSource) calls() int {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ return len(f.repoCalls)
+}
+
func testView() collector.View {
repo := model.Repo{
FullName: "o/r", URL: "https://github.com/o/r", ReleasesURL: "https://github.com/o/r/releases", Up: true,
@@ -241,3 +263,187 @@ func TestManualRefreshRefusesCrossSiteRequests(t *testing.T) {
t.Fatalf("same-origin: %d", rec.Code)
}
}
+
+func post(h http.Handler, target string, hdr map[string]string) *httptest.ResponseRecorder {
+ req := httptest.NewRequest("POST", target, nil)
+ for k, v := range hdr {
+ req.Header.Set(k, v)
+ }
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ return rec
+}
+
+func repoServer(src *fakeSource, manual bool) (*Server, *time.Time) {
+ srv := newServer(src, testUI(), manual)
+ clock := now
+ srv.now = func() time.Time { return clock }
+ return srv, &clock
+}
+
+func TestRepoRefreshReturnsStateAndRepoName(t *testing.T) {
+ src := &fakeSource{v: testView()}
+ srv, _ := repoServer(src, true)
+ rec := post(srv.Handler(), "/refresh?repo=o/r", nil)
+ if rec.Code != http.StatusOK || rec.Header().Get("Content-Type") != "application/json" {
+ t.Fatalf("%d %v", rec.Code, rec.Header())
+ }
+ var got struct {
+ Repo string `json:"repo"`
+ Repos []struct {
+ FullName string `json:"fullName"`
+ } `json:"repos"`
+ Counts struct{ Crit int } `json:"counts"`
+ Loaded bool `json:"loaded"`
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
+ t.Fatal(err)
+ }
+ if got.Repo != "o/r" || !got.Loaded || len(got.Repos) != 2 || got.Counts.Crit != 1 {
+ t.Fatalf("body: %s", rec.Body.String())
+ }
+ if len(src.repoCalls) != 1 || src.repoCalls[0] != "o/r" || src.refreshed != 0 {
+ t.Fatalf("calls=%v full=%d", src.repoCalls, src.refreshed)
+ }
+}
+
+func TestRepoRefreshUnknownRepoIs404JSON(t *testing.T) {
+ src := &fakeSource{repoFn: func(string) (collector.View, error) { return collector.View{}, collector.ErrUnknownRepo }}
+ srv, _ := repoServer(src, true)
+ h := srv.Handler()
+ rec := post(h, "/refresh?repo=x/y", nil)
+ var body map[string]string
+ if rec.Code != http.StatusNotFound || json.Unmarshal(rec.Body.Bytes(), &body) != nil || body["error"] == "" {
+ t.Fatalf("%d %s", rec.Code, rec.Body.String())
+ }
+ if rec := post(h, "/refresh?repo=x/y", nil); rec.Code != http.StatusNotFound {
+ t.Fatalf("a rejected name must not start a cooldown: %d", rec.Code)
+ }
+}
+
+func TestRepoRefreshCooldownPerRepo(t *testing.T) {
+ src := &fakeSource{v: testView()}
+ srv, clock := repoServer(src, true)
+ h := srv.Handler()
+ if rec := post(h, "/refresh?repo=o/r", nil); rec.Code != http.StatusOK {
+ t.Fatalf("first: %d", rec.Code)
+ }
+ *clock = clock.Add(20 * time.Second)
+ rec := post(h, "/refresh?repo=O/R", nil)
+ if rec.Code != http.StatusTooManyRequests || rec.Header().Get("Retry-After") != "40" {
+ t.Fatalf("cooldown: %d retry-after=%q", rec.Code, rec.Header().Get("Retry-After"))
+ }
+ if rec := post(h, "/refresh?repo=o/bare", nil); rec.Code != http.StatusOK {
+ t.Fatalf("other repo must be independent: %d", rec.Code)
+ }
+ *clock = clock.Add(repoRefreshCooldown)
+ if rec := post(h, "/refresh?repo=o/r", nil); rec.Code != http.StatusOK || src.calls() != 3 {
+ t.Fatalf("after cooldown: %d calls=%d", rec.Code, src.calls())
+ }
+}
+
+func TestRepoRefreshDoesNotUseFullRefreshBudget(t *testing.T) {
+ src := &fakeSource{v: testView()}
+ srv, _ := repoServer(src, true)
+ h := srv.Handler()
+ if rec := post(h, "/refresh?repo=o/r", nil); rec.Code != http.StatusOK {
+ t.Fatalf("repo: %d", rec.Code)
+ }
+ if rec := post(h, "/refresh", nil); rec.Code != http.StatusAccepted {
+ t.Fatalf("full refresh after repo refresh: %d", rec.Code)
+ }
+ if rec := post(h, "/refresh", nil); rec.Code != http.StatusTooManyRequests {
+ t.Fatalf("full refresh gap must still apply: %d", rec.Code)
+ }
+ if rec := post(h, "/refresh?repo=o/bare", nil); rec.Code != http.StatusOK {
+ t.Fatalf("repo refresh must not be blocked by the full gap: %d", rec.Code)
+ }
+}
+
+func TestRepoRefreshEmptyParamKeepsFullRefresh(t *testing.T) {
+ src := &fakeSource{v: testView()}
+ srv, _ := repoServer(src, true)
+ rec := post(srv.Handler(), "/refresh?repo=", nil)
+ if rec.Code != http.StatusAccepted || rec.Body.Len() != 0 || src.refreshed != 1 || src.calls() != 0 {
+ t.Fatalf("%d body=%q full=%d repo=%d", rec.Code, rec.Body.String(), src.refreshed, src.calls())
+ }
+}
+
+func TestRepoRefreshBusyIs409AndKeepsCooldownFree(t *testing.T) {
+ busy := true
+ src := &fakeSource{v: testView(), repoFn: func(string) (collector.View, error) {
+ if busy {
+ return collector.View{}, collector.ErrRefreshBusy
+ }
+ return testView(), nil
+ }}
+ srv, _ := repoServer(src, true)
+ h := srv.Handler()
+ if rec := post(h, "/refresh?repo=o/r", nil); rec.Code != http.StatusConflict {
+ t.Fatalf("busy: %d", rec.Code)
+ }
+ busy = false
+ if rec := post(h, "/refresh?repo=o/r", nil); rec.Code != http.StatusOK {
+ t.Fatalf("retry after busy: %d", rec.Code)
+ }
+}
+
+func TestRepoRefreshUnexpectedErrorIs500(t *testing.T) {
+ src := &fakeSource{repoFn: func(string) (collector.View, error) { return collector.View{}, errors.New("x") }}
+ srv, _ := repoServer(src, true)
+ if rec := post(srv.Handler(), "/refresh?repo=o/r", nil); rec.Code != http.StatusInternalServerError {
+ t.Fatalf("%d", rec.Code)
+ }
+}
+
+func TestRepoRefreshRefusesCrossSite(t *testing.T) {
+ src := &fakeSource{v: testView()}
+ srv, _ := repoServer(src, true)
+ h := srv.Handler()
+ for _, site := range []string{"cross-site", "same-site"} {
+ if rec := post(h, "/refresh?repo=o/r", map[string]string{"Sec-Fetch-Site": site}); rec.Code != http.StatusForbidden {
+ t.Fatalf("%s: %d", site, rec.Code)
+ }
+ }
+ if rec := post(h, "/refresh?repo=o/r", map[string]string{"Sec-Fetch-Site": "same-origin"}); rec.Code != http.StatusOK || src.calls() != 1 {
+ t.Fatalf("same-origin: %d calls=%d", rec.Code, src.calls())
+ }
+}
+
+func TestRepoRefreshDisabledByConfig(t *testing.T) {
+ src := &fakeSource{v: testView()}
+ srv, _ := repoServer(src, false)
+ rec := post(srv.Handler(), "/refresh?repo=o/r", nil)
+ if rec.Code == http.StatusOK || src.calls() != 0 {
+ t.Fatalf("%d calls=%d", rec.Code, src.calls())
+ }
+}
+
+func TestRepoRefreshConcurrentRequests(t *testing.T) {
+ src := &fakeSource{v: testView()}
+ srv, _ := repoServer(src, true)
+ h := srv.Handler()
+ var wg sync.WaitGroup
+ codes := make(chan int, 16)
+ for range 16 {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ codes <- post(h, "/refresh?repo=o/r", nil).Code
+ }()
+ }
+ wg.Wait()
+ close(codes)
+ ok, limited := 0, 0
+ for c := range codes {
+ switch c {
+ case http.StatusOK:
+ ok++
+ case http.StatusTooManyRequests:
+ limited++
+ }
+ }
+ if ok != 1 || limited != 15 || src.calls() != 1 {
+ t.Fatalf("ok=%d limited=%d calls=%d", ok, limited, src.calls())
+ }
+}
diff --git a/src/App.test.ts b/src/App.test.ts
index f14e7e5..4476029 100644
--- a/src/App.test.ts
+++ b/src/App.test.ts
@@ -89,4 +89,52 @@ describe('App', () => {
await flushPromises()
expect(off.find('[data-test=rescan]').exists()).toBe(false)
})
+
+ it('refreshes one repo from its button, shows the new state and announces it', async () => {
+ const first = state({
+ manualRefresh: true,
+ repos: [repo({ refreshedAt: '2026-10-02T10:00:00Z' })],
+ })
+ const after = { ...first, repo: 'o/r', repos: [repo({ refreshedAt: '2026-10-02T12:00:00Z' })] }
+ const fetchMock = vi.fn((_url: string, init?: RequestInit) =>
+ Promise.resolve(
+ new Response(JSON.stringify(init?.method === 'POST' ? after : first), { status: 200 }),
+ ),
+ )
+ vi.stubGlobal('fetch', fetchMock)
+ const w = mount(App, { global: { plugins: [createPinia()] } })
+ await flushPromises()
+ await w.get('[data-test=project] [data-test=repo-refresh]').trigger('click')
+ await flushPromises()
+ expect(fetchMock).toHaveBeenCalledWith('/refresh?repo=o%2Fr', { method: 'POST' })
+ expect(w.get('[data-test=announcement]').text()).toBe('Refreshed o/r')
+ })
+
+ it('shows the refusal message when a repo is refreshed too soon', async () => {
+ const body = state({ manualRefresh: true, repos: [repo()] })
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn((_url: string, init?: RequestInit) =>
+ Promise.resolve(
+ init?.method === 'POST'
+ ? new Response('{}', { status: 429, headers: { 'Retry-After': '30' } })
+ : new Response(JSON.stringify(body), { status: 200 }),
+ ),
+ ),
+ )
+ const w = mount(App, { global: { plugins: [createPinia()] } })
+ await flushPromises()
+ await w.get('[data-test=project] [data-test=repo-refresh]').trigger('click')
+ await flushPromises()
+ expect(w.get('[data-test=notice]').text()).toBe('o/r was refreshed recently. Try again in 30s')
+ expect(
+ w.get('[data-test=project] [data-test=repo-refresh]').attributes('disabled'),
+ ).toBeUndefined()
+ })
+
+ it('hides the repo buttons when the server disables manual refresh', async () => {
+ const w = mountWith(state({ manualRefresh: false, repos: [repo()] }))
+ await flushPromises()
+ expect(w.find('[data-test=repo-refresh]').exists()).toBe(false)
+ })
})
diff --git a/src/App.vue b/src/App.vue
index 24ad9f0..9ac7cef 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -17,6 +17,8 @@ const {
state,
error,
notice,
+ announcement,
+ refreshingRepos,
scanning,
blocks,
visibleBlocks,
@@ -91,6 +93,8 @@ onBeforeUnmount(() => {
{{ notice }}
+ {{ announcement }}
+
{
:facets="facets"
:owners="owners"
:active="activeFilters"
+ :manual-refresh="state.manualRefresh"
+ :refreshing="refreshingRepos"
+ :scanning="scanning"
@clear="store.clearFilters()"
+ @refresh="store.refreshRepo"
/>
diff --git a/src/components/AttentionList.vue b/src/components/AttentionList.vue
index f5934e2..417b070 100644
--- a/src/components/AttentionList.vue
+++ b/src/components/AttentionList.vue
@@ -16,9 +16,12 @@ const props = defineProps<{
facets: Facets
owners: string[]
active: number
+ manualRefresh?: boolean
+ refreshing?: string[]
+ scanning?: boolean
}>()
const filters = defineModel('filters', { required: true })
-defineEmits<{ clear: [] }>()
+defineEmits<{ clear: []; refresh: [fullName: string] }>()
const columnCount = useColumns()
// A group's height is its header plus its rows, so rows+2 approximates it.
@@ -59,6 +62,10 @@ defineExpose({ focusSearch: () => bar.value?.focus() })
:block="block"
:now-ms="nowMs"
:crit-seconds="critSeconds"
+ :manual-refresh="manualRefresh"
+ :refreshing="refreshing"
+ :scanning="scanning"
+ @refresh="(name) => $emit('refresh', name)"
/>
diff --git a/src/components/ProjectList.vue b/src/components/ProjectList.vue
index 8aca60e..e1206a3 100644
--- a/src/components/ProjectList.vue
+++ b/src/components/ProjectList.vue
@@ -2,11 +2,20 @@
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 RepoRefreshButton from '@/components/RepoRefreshButton.vue'
import { ageSince, isZeroTime } from '@/lib/format'
import type { Repo } from '@/lib/types'
-const props = defineProps<{ repos: Repo[]; total: number; focused: string | null; nowMs: number }>()
-defineEmits<{ select: [fullName: string] }>()
+const props = defineProps<{
+ repos: Repo[]
+ total: number
+ focused: string | null
+ nowMs: number
+ manualRefresh?: boolean
+ refreshing?: string[]
+ scanning?: boolean
+}>()
+defineEmits<{ select: [fullName: string]; refresh: [fullName: string] }>()
/** Rows rendered at first and added each time the list is scrolled near its end. */
const PAGE_SIZE = 20
@@ -111,6 +120,14 @@ const LABEL = { crit: 'Critical', warn: 'Warning', ok: 'Healthy' } as const
no release
+
()
+defineProps<{
+ block: Block
+ nowMs: number
+ critSeconds: number
+ manualRefresh?: boolean
+ refreshing?: string[]
+ scanning?: boolean
+}>()
+defineEmits<{ refresh: [fullName: string] }>()
@@ -24,8 +33,15 @@ defineProps<{ block: Block; nowMs: number; critSeconds: number }>()
{{ block.repo.release.tag }}
-
+
{{ block.rows.length }} {{ block.rows.length === 1 ? 'item' : 'items' }}
+
diff --git a/src/components/RepoRefreshButton.vue b/src/components/RepoRefreshButton.vue
new file mode 100644
index 0000000..78e4dc0
--- /dev/null
+++ b/src/components/RepoRefreshButton.vue
@@ -0,0 +1,24 @@
+
+
+
+
+
diff --git a/src/components/components.test.ts b/src/components/components.test.ts
index 79036f6..66305b1 100644
--- a/src/components/components.test.ts
+++ b/src/components/components.test.ts
@@ -448,3 +448,55 @@ describe('LoadingScreen', () => {
expect(w.attributes('aria-live')).toBe('polite')
})
})
+
+describe('per-repository refresh button', () => {
+ const block = buildBlocks(mixedState())[0]
+ const repos = mixedState().repos
+ const group = (extra = {}) =>
+ mount(RepoGroup, { props: { block, nowMs, critSeconds: CRIT_SECONDS, ...extra } })
+ const list = (extra = {}) =>
+ mount(ProjectList, { props: { repos, total: 2, focused: null, nowMs, ...extra } })
+
+ it('is hidden unless manual refresh is on', () => {
+ expect(group().find('[data-test=repo-refresh]').exists()).toBe(false)
+ expect(list().find('[data-test=repo-refresh]').exists()).toBe(false)
+ expect(list({ manualRefresh: false }).find('[data-test=repo-refresh]').exists()).toBe(false)
+ })
+
+ it('renders one labelled button per project and one per group header', () => {
+ const l = list({ manualRefresh: true })
+ const buttons = l.findAll('[data-test=project] [data-test=repo-refresh]')
+ expect(buttons.map((b) => b.attributes('aria-label'))).toEqual([
+ 'Refresh o/r',
+ 'Refresh o/fine',
+ ])
+ const g = group({ manualRefresh: true })
+ expect(g.get('header [data-test=repo-refresh]').attributes('aria-label')).toBe('Refresh o/r')
+ })
+
+ it('emits the repo name on click without selecting the project', async () => {
+ const l = list({ manualRefresh: true })
+ await l.findAll('[data-test=repo-refresh]')[1].trigger('click')
+ expect(l.emitted('refresh')).toEqual([['o/fine']])
+ expect(l.emitted('select')).toBeUndefined()
+ const g = group({ manualRefresh: true })
+ await g.get('[data-test=repo-refresh]').trigger('click')
+ expect(g.emitted('refresh')).toEqual([['o/r']])
+ })
+
+ it('disables only the refreshing repo and spins its icon', () => {
+ const l = list({ manualRefresh: true, refreshing: ['o/r'] })
+ const [busy, idle] = l.findAll('[data-test=repo-refresh]')
+ expect(busy.attributes('disabled')).toBeDefined()
+ expect(busy.attributes('aria-busy')).toBe('true')
+ expect(busy.find('svg').classes()).toContain('fa-spin')
+ expect(idle.attributes('disabled')).toBeUndefined()
+ expect(idle.find('svg').classes()).not.toContain('fa-spin')
+ })
+
+ it('disables every button while a full scan runs', () => {
+ const l = list({ manualRefresh: true, scanning: true })
+ for (const b of l.findAll('[data-test=repo-refresh]'))
+ expect(b.attributes('disabled')).toBeDefined()
+ })
+})
diff --git a/src/lib/api.ts b/src/lib/api.ts
index d8ef660..62e2131 100644
--- a/src/lib/api.ts
+++ b/src/lib/api.ts
@@ -1,4 +1,4 @@
-import type { TrackerState } from './types'
+import type { RepoRefreshResult, TrackerState } from './types'
export async function fetchState(signal?: AbortSignal): Promise {
const res = await fetch('/api/state', { signal, headers: { Accept: 'application/json' } })
@@ -14,3 +14,21 @@ export async function requestRefresh(): Promise {
if (res.status === 429) throw new RescanLimitedError('Rescan is limited to once every 5 minutes')
if (!res.ok) throw new Error(`rescan request failed: HTTP ${res.status}`)
}
+
+/** Thrown when one repository was refreshed too recently or a scan is running. */
+export class RepoRefreshRefusedError extends Error {}
+
+const REPO_COOLDOWN_FALLBACK_SECONDS = 60
+
+/** Refreshes one repository and returns the full state after it, plus the repo name. */
+export async function requestRepoRefresh(fullName: string): Promise {
+ const res = await fetch(`/refresh?repo=${encodeURIComponent(fullName)}`, { method: 'POST' })
+ if (res.status === 429) {
+ const wait = Number(res.headers.get('Retry-After')) || REPO_COOLDOWN_FALLBACK_SECONDS
+ throw new RepoRefreshRefusedError(`${fullName} was refreshed recently. Try again in ${wait}s`)
+ }
+ if (res.status === 409)
+ throw new RepoRefreshRefusedError('A refresh is already running. Try again shortly')
+ if (!res.ok) throw new Error(`refresh of ${fullName} failed: HTTP ${res.status}`)
+ return (await res.json()) as RepoRefreshResult
+}
diff --git a/src/lib/types.ts b/src/lib/types.ts
index b9be961..9172403 100644
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -56,6 +56,9 @@ export interface TrackerState {
alerts: Alert[]
}
+/** Answer to a single-repository refresh: the state after it, plus the repo name. */
+export type RepoRefreshResult = TrackerState & { repo: string }
+
/** One line in a repo block: every alert that targets the same URL, merged. */
export interface Row {
kind: string
diff --git a/src/stores/tracker.test.ts b/src/stores/tracker.test.ts
index 74c315a..3d15e5a 100644
--- a/src/stores/tracker.test.ts
+++ b/src/stores/tracker.test.ts
@@ -219,4 +219,62 @@ describe('tracker store', () => {
expect(store.error).toContain('HTTP 500')
expect(store.notice).toBe('')
})
+
+ describe('refreshRepo', () => {
+ const refreshed = (over = {}) => ({
+ ...state({ repos: [repo({ refreshedAt: '2026-10-02T12:00:00Z', ...over })] }),
+ repo: 'o/r',
+ })
+
+ it('posts the repo, swaps the state in and announces it without another fetch', async () => {
+ const calls: string[] = []
+ mockFetch((url, init) => {
+ calls.push(`${init?.method ?? 'GET'} ${url}`)
+ return json(refreshed())
+ })
+ const store = useTrackerStore()
+ const done = store.refreshRepo('o/r')
+ expect(store.refreshingRepos).toEqual(['o/r'])
+ await done
+ expect(calls).toEqual(['POST /refresh?repo=o%2Fr'])
+ expect(store.refreshingRepos).toEqual([])
+ expect(store.state?.repos[0].refreshedAt).toBe('2026-10-02T12:00:00Z')
+ expect(store.announcement).toBe('Refreshed o/r')
+ expect(store.notice).toBe('')
+ })
+
+ it('ignores a second request for the same repo while one is pending', async () => {
+ const fetchMock = mockFetch(() => json(refreshed()))
+ const store = useTrackerStore()
+ await Promise.all([store.refreshRepo('o/r'), store.refreshRepo('o/r')])
+ expect(fetchMock).toHaveBeenCalledTimes(1)
+ })
+
+ it('shows the wait from Retry-After on 429', async () => {
+ mockFetch(() => new Response('{}', { status: 429, headers: { 'Retry-After': '42' } }))
+ const store = useTrackerStore()
+ await store.refreshRepo('o/r')
+ expect(store.notice).toBe('o/r was refreshed recently. Try again in 42s')
+ expect(store.refreshingRepos).toEqual([])
+ expect(store.error).toBe('')
+ })
+
+ it('says a scan is running on 409', async () => {
+ mockFetch(() => new Response('{}', { status: 409 }))
+ const store = useTrackerStore()
+ await store.refreshRepo('o/r')
+ expect(store.notice).toContain('already running')
+ })
+
+ it('reports other failures and a repo that came back down', async () => {
+ mockFetch(() => new Response('{}', { status: 500 }))
+ const store = useTrackerStore()
+ await store.refreshRepo('o/r')
+ expect(store.notice).toContain('HTTP 500')
+ mockFetch(() => json(refreshed({ up: false, error: 'boom' })))
+ await store.refreshRepo('o/r')
+ expect(store.notice).toBe('Could not refresh o/r: boom')
+ expect(store.announcement).toBe('')
+ })
+ })
})
diff --git a/src/stores/tracker.ts b/src/stores/tracker.ts
index f883220..b7908ce 100644
--- a/src/stores/tracker.ts
+++ b/src/stores/tracker.ts
@@ -1,6 +1,6 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
-import { fetchState, RescanLimitedError, requestRefresh } from '@/lib/api'
+import { fetchState, RescanLimitedError, requestRefresh, requestRepoRefresh } from '@/lib/api'
import {
activeFilterCount,
applyFilters,
@@ -98,6 +98,9 @@ export const useTrackerStore = defineStore('tracker', () => {
const loading = ref(false)
const requesting = ref(false)
const notice = ref('')
+ /** Screen-reader text for a finished single-repository refresh. */
+ const announcement = ref('')
+ const refreshingRepos = ref([])
const skewMs = ref(0)
const tick = ref(Date.now())
@@ -143,13 +146,16 @@ export const useTrackerStore = defineStore('tracker', () => {
tick.value = Date.now()
}
+ function applyState(next: TrackerState): void {
+ skewMs.value = Date.parse(next.now) - Date.now()
+ tick.value = Date.now()
+ state.value = next
+ }
+
async function load(): Promise {
loading.value = true
try {
- const next = await fetchState()
- skewMs.value = Date.parse(next.now) - Date.now()
- tick.value = Date.now()
- state.value = next
+ applyState(await fetchState())
error.value = ''
} catch (e) {
error.value = e instanceof Error ? e.message : String(e)
@@ -180,11 +186,32 @@ export const useTrackerStore = defineStore('tracker', () => {
}
}
+ /** Refreshes one repository; the answer carries the new state, so no extra fetch follows. */
+ async function refreshRepo(fullName: string): Promise {
+ if (refreshingRepos.value.includes(fullName)) return
+ refreshingRepos.value = [...refreshingRepos.value, fullName]
+ notice.value = ''
+ announcement.value = ''
+ try {
+ const next = await requestRepoRefresh(fullName)
+ applyState(next)
+ const failed = next.repos.find((r) => r.fullName === fullName)?.error
+ if (failed) notice.value = `Could not refresh ${fullName}: ${failed}`
+ else announcement.value = `Refreshed ${fullName}`
+ } catch (e) {
+ notice.value = e instanceof Error ? e.message : String(e)
+ } finally {
+ refreshingRepos.value = refreshingRepos.value.filter((n) => n !== fullName)
+ }
+ }
+
return {
state,
error,
loading,
notice,
+ announcement,
+ refreshingRepos,
scanning,
nowMs,
blocks,
@@ -201,6 +228,7 @@ export const useTrackerStore = defineStore('tracker', () => {
clearFilters,
load,
rescan,
+ refreshRepo,
advanceClock,
}
})