Compare commits

..
6 Commits
Author SHA1 Message Date
github-actions[bot]andGitHub 32de5d6704 chore(deps): update dependencies
Automated dependency update. All tests passed.
2026-10-04 04:16:19 +00:00
lukaszraczylo 3ce2c50f5a ci(deps): update dependencies with Renovate [skip ci] 2026-10-03 18:44:56 +01:00
lukaszraczylo 9446caaf1b feat(ui): order projects by their newest problem
Projects now sort by the newest alert they hold, whether a failing workflow, a waiting pull request or a stale issue. Projects with equal ages keep the server order.
2026-10-03 00:14:23 +01:00
lukaszraczyloandGitHub 8cbc495dc7 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.
2026-10-02 23:16:22 +01:00
lukaszraczyloandGitHub 4c2c9d3341 fix(gh): confirm a failing workflow with a different query string (#1)
GitHub can answer one exact run-listing query from an old cached result
for weeks. The same query on a new connection returned the same old
failure, so a workflow whose latest runs passed still showed as failing.

The confirmation request now adds page=1. The changed query skips the
cached result and returns current runs. The test models a cache that
serves the plain query only.
2026-10-02 23:16:09 +01:00
lukaszraczylo b74396f005 fix(collector): link a failed repository to the configured host
When the first scan of a repository failed, the repository link pointed at github.com. It now follows github.api_url: Enterprise Server hosts drop the /api/v3 suffix and Enterprise Cloud hosts drop the api. prefix.
2026-10-02 10:15:48 +01:00
25 changed files with 1483 additions and 1522 deletions
+19
View File
@@ -0,0 +1,19 @@
name: Update dependencies
on:
workflow_dispatch:
schedule:
- cron: "0 3 * * *"
permissions:
actions: write
contents: write
issues: write
pull-requests: write
jobs:
update:
uses: lukaszraczylo/shared-actions/.github/workflows/renovate-autoupdate.yaml@main
with:
go-version: ">=1.24"
release-workflow: release.yaml
+9 -1
View File
@@ -14,7 +14,7 @@ Release archives and the image checksums are signed with cosign.
## What it shows
The dashboard lists only what needs attention, grouped by project:
The dashboard lists only what needs attention, grouped by project. The project with the newest problem comes first:
- Failing workflows, including scheduled and other background ones, with a link to the run.
- Pull requests that wait too long or fail their checks, with links and age.
@@ -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
+6 -6
View File
@@ -1,6 +1,6 @@
module github.com/lukaszraczylo/ghtracker
go 1.25.0
go 1.26.0
require (
github.com/prometheus/client_golang v1.24.1
@@ -12,9 +12,9 @@ require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.70.1 // indirect
github.com/prometheus/procfs v0.21.1 // indirect
golang.org/x/sys v0.47.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
github.com/prometheus/client_model v0.6.3 // indirect
github.com/prometheus/common v0.72.0 // indirect
github.com/prometheus/procfs v0.22.0 // indirect
golang.org/x/sys v0.48.0 // indirect
google.golang.org/protobuf v1.36.12 // indirect
)
+14 -16
View File
@@ -2,8 +2,6 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
@@ -12,26 +10,26 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU=
github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY=
github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc=
github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/prometheus/client_model v0.6.3 h1:O0jaTVAYNxTHYInEPFJt5I3+sN8zqBtVMPTB1qyxiEo=
github.com/prometheus/client_model v0.6.3/go.mod h1:gpN5P9S7Rr6Yr92PiQ+Ixvhf6JZEkF1dnxsYL2aPBEM=
github.com/prometheus/common v0.72.0 h1:tAYsE+sPJxIncDAobm4H5aQjmox9ZxEIIqPbiffa8G4=
github.com/prometheus/common v0.72.0/go.mod h1:77NWqAQ2tXT7BIK40qjJdw5Acrsrg1TlHAnsQi3i6mk=
github.com/prometheus/procfs v0.22.0 h1:6q9+/JL9IKAPbCmBrv9n5O5Ty3NKnciV5X7YGw0oics=
github.com/prometheus/procfs v0.22.0/go.mod h1:CvmFr/GVhIjIvWJZW3tgkODBQMRIf0EyWMQLHCHab58=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+80 -8
View File
@@ -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,8 +60,10 @@ 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
kick chan struct{}
names []string
th config.Thresholds
@@ -78,9 +88,11 @@ func New(f Fetcher, cfg *config.Config, log *slog.Logger) *Collector {
interval: cfg.RefreshInterval,
concurrency: cfg.Concurrency,
now: time.Now,
webBase: config.WebBase(cfg.GitHub.APIURL),
log: log,
kick: make(chan struct{}, 1),
repos: make(map[string]model.Repo, len(cfg.Repos)),
inflight: make(map[string]struct{}),
}
}
@@ -134,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)
}()
}
@@ -152,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
@@ -174,12 +196,62 @@ func (c *Collector) refreshOne(ctx context.Context, name string, at time.Time) m
prev, ok := c.repos[name]
c.mu.RUnlock()
if !ok {
prev = model.Repo{FullName: name, URL: "https://github.com/" + name}
prev = model.Repo{FullName: name, URL: c.webBase + "/" + name}
}
prev.Up, prev.Err = false, err.Error()
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()
+120
View File
@@ -337,3 +337,123 @@ func TestScanProgressAdvancesAsReposFinish(t *testing.T) {
t.Fatalf("after scan: done=%d total=%d", v.ScanDone, v.ScanTotal)
}
}
func TestFailedFirstScanLinksToTheConfiguredHost(t *testing.T) {
fail := &fakeFetcher{fn: func(string) (model.Repo, error) { return model.Repo{}, errors.New("nope") }}
cfg := &config.Config{
Repos: []string{"o/a"}, RefreshInterval: time.Hour, Concurrency: 1, Thresholds: th(),
GitHub: config.GitHub{APIURL: "https://ghe.example.com/api/v3"},
}
c := New(fail, cfg, slog.New(slog.NewTextHandler(io.Discard, nil)))
c.now = func() time.Time { return t0 }
c.Refresh(context.Background())
if got := c.View().Repos[0].URL; got != "https://ghe.example.com/o/a" {
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)
}
}
+22
View File
@@ -4,6 +4,7 @@ package config
import (
"errors"
"fmt"
"net/url"
"os"
"strings"
"time"
@@ -161,3 +162,24 @@ func normalizeRepos(in []string) ([]string, error) {
}
return out, nil
}
const (
defaultWebURL = "https://github.com"
apiV3Suffix = "/api/v3"
apiHostPrefix = "api."
)
// WebBase returns the address of the web interface that belongs to a GitHub API address:
// github.com for the public API, the host without /api/v3 for Enterprise Server, and the host
// without its "api." prefix for Enterprise Cloud with data residency.
func WebBase(apiURL string) string {
u, err := url.Parse(strings.TrimRight(apiURL, "/"))
if err != nil || u.Host == "" || strings.EqualFold(u.Host, "api.github.com") {
return defaultWebURL
}
u.Path = strings.TrimSuffix(u.Path, apiV3Suffix)
if u.Path == "" {
u.Host = strings.TrimPrefix(u.Host, apiHostPrefix)
}
return strings.TrimRight(u.String(), "/")
}
+19
View File
@@ -126,3 +126,22 @@ func TestManualRefreshCanBeDisabled(t *testing.T) {
t.Fatal("explicit false must stay off")
}
}
func TestWebBase(t *testing.T) {
tests := map[string]string{
"": "https://github.com",
"https://api.github.com": "https://github.com",
"https://api.github.com/": "https://github.com",
"https://ghe.example.com/api/v3": "https://ghe.example.com",
"https://ghe.example.com/api/v3/": "https://ghe.example.com",
"http://ghe.internal:8080/api/v3": "http://ghe.internal:8080",
"https://api.acme.ghe.com": "https://acme.ghe.com",
"https://github.example.com/sub/api/v3": "https://github.example.com/sub",
"not a url": "https://github.com",
}
for in, want := range tests {
if got := WebBase(in); got != want {
t.Errorf("WebBase(%q) = %q, want %q", in, got, want)
}
}
}
+3
View File
@@ -360,6 +360,9 @@ func (c *Client) workflowRuns(ctx context.Context, full string, id int64, branch
path := fmt.Sprintf("/repos/%s/actions/workflows/%d/runs", full, id)
get := c.getJSON
if fresh {
// GitHub can keep answering one exact query from an old cached result, for weeks, whatever
// the connection. A different query string skips that entry and reads current runs.
q.Set("page", "1")
get = c.getJSONFresh
}
if err := get(ctx, full, path, q, &out); err != nil {
+5 -5
View File
@@ -460,13 +460,13 @@ func TestAPIErrorMessageParsing(t *testing.T) {
}
}
// 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.
// staleThenFresh models GitHub answering one exact query from an old cached result: the plain
// query returns an old failing run every time, and the same query with page=1 returns current runs.
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 {
calls.Add(1)
if r.URL.Query().Get("page") == "" {
_, _ = 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
@@ -482,7 +482,7 @@ func staleThenFresh(f *fakeGitHub, path string) *atomic.Int64 {
return &calls
}
func TestStaleFailureIsConfirmedOnANewConnection(t *testing.T) {
func TestStaleFailureIsConfirmedWithADifferentQuery(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"}]}`)
+69 -1
View File
@@ -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})
}
+206
View File
@@ -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())
}
}
+1 -1
View File
@@ -43,5 +43,5 @@
"vitest": "^5.0.1",
"vue-tsc": "^3.3.11"
},
"packageManager": "[email protected]1.0"
"packageManager": "[email protected]8.3"
}
+555 -1467
View File
File diff suppressed because it is too large Load Diff
+48
View File
@@ -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)
})
})
+12
View File
@@ -17,6 +17,8 @@ const {
state,
error,
notice,
announcement,
refreshingRepos,
scanning,
blocks,
visibleBlocks,
@@ -91,6 +93,8 @@ onBeforeUnmount(() => {
{{ notice }}
</p>
<p class="sr-only" role="status" data-test="announcement">{{ announcement }}</p>
<LoadingScreen
v-if="!state || !state.loaded"
:done="state?.scan.done ?? 0"
@@ -123,7 +127,11 @@ onBeforeUnmount(() => {
:facets="facets"
:owners="owners"
:active="activeFilters"
:manual-refresh="state.manualRefresh"
:refreshing="refreshingRepos"
:scanning="scanning"
@clear="store.clearFilters()"
@refresh="store.refreshRepo"
/>
<aside class="lg:sticky lg:top-6 lg:self-start">
<ProjectList
@@ -131,7 +139,11 @@ onBeforeUnmount(() => {
:total="state.repos.length"
:focused="filters.repo"
:now-ms="nowMs"
:manual-refresh="state.manualRefresh"
:refreshing="refreshingRepos"
:scanning="scanning"
@select="store.toggleRepo"
@refresh="store.refreshRepo"
/>
</aside>
</div>
+8 -1
View File
@@ -16,9 +16,12 @@ const props = defineProps<{
facets: Facets
owners: string[]
active: number
manualRefresh?: boolean
refreshing?: string[]
scanning?: boolean
}>()
const filters = defineModel<Filters>('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)"
/>
</div>
</div>
+19 -2
View File
@@ -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
<a v-else :href="repo.releasesUrl" class="text-muted-foreground hover:text-link text-xs"
>no release</a
>
<RepoRefreshButton
v-if="manualRefresh"
:repo="repo.fullName"
:busy="refreshing?.includes(repo.fullName) ?? false"
:blocked="scanning ?? false"
class="-my-1 -mr-1"
@refresh="$emit('refresh', repo.fullName)"
/>
</div>
<div
class="text-muted-foreground mt-1 flex items-center gap-4 pl-[18px] text-xs tabular-nums"
+18 -2
View File
@@ -2,10 +2,19 @@
import { faTag } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import AttentionRow from '@/components/AttentionRow.vue'
import RepoRefreshButton from '@/components/RepoRefreshButton.vue'
import { Badge } from '@/components/ui/badge'
import type { Block } from '@/lib/types'
defineProps<{ block: Block; nowMs: number; critSeconds: number }>()
defineProps<{
block: Block
nowMs: number
critSeconds: number
manualRefresh?: boolean
refreshing?: string[]
scanning?: boolean
}>()
defineEmits<{ refresh: [fullName: string] }>()
</script>
<template>
@@ -24,8 +33,15 @@ defineProps<{ block: Block; nowMs: number; critSeconds: number }>()
{{ block.repo.release.tag }}
</a>
</Badge>
<span class="text-muted-foreground ml-auto text-xs">
<span class="text-muted-foreground ml-auto flex items-center gap-1 text-xs">
{{ block.rows.length }} {{ block.rows.length === 1 ? 'item' : 'items' }}
<RepoRefreshButton
v-if="manualRefresh"
:repo="block.repo.fullName"
:busy="refreshing?.includes(block.repo.fullName) ?? false"
:blocked="scanning ?? false"
@refresh="$emit('refresh', block.repo.fullName)"
/>
</span>
</header>
<ul class="divide-border divide-y px-4">
+24
View File
@@ -0,0 +1,24 @@
<script setup lang="ts">
import { faArrowsRotate } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { Button } from '@/components/ui/button'
defineProps<{ repo: string; busy: boolean; blocked: boolean }>()
defineEmits<{ refresh: [] }>()
</script>
<template>
<Button
variant="ghost"
size="icon-xs"
class="text-muted-foreground hover:text-link shrink-0"
:disabled="busy || blocked"
:aria-label="`Refresh ${repo}`"
:aria-busy="busy"
:title="`Refresh ${repo}`"
data-test="repo-refresh"
@click="$emit('refresh')"
>
<FontAwesomeIcon :icon="faArrowsRotate" :spin="busy" aria-hidden="true" />
</Button>
</template>
+52
View File
@@ -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()
})
})
+19 -1
View File
@@ -1,4 +1,4 @@
import type { TrackerState } from './types'
import type { RepoRefreshResult, TrackerState } from './types'
export async function fetchState(signal?: AbortSignal): Promise<TrackerState> {
const res = await fetch('/api/state', { signal, headers: { Accept: 'application/json' } })
@@ -14,3 +14,21 @@ export async function requestRefresh(): Promise<void> {
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<RepoRefreshResult> {
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
}
+3
View File
@@ -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
+113 -4
View File
@@ -44,18 +44,69 @@ describe('buildBlocks', () => {
expect(buildBlocks(s)[0].rows.map((r) => r.subject)).toEqual(['crit row', 'warn row'])
})
it('keeps the repo order from the server and ignores alerts for unknown repos', () => {
it('keeps the server order for equal ages and ignores alerts for unknown repos', () => {
const since = '2026-10-01T00:00:00Z'
const s = state({
repos: [repo({ fullName: 'o/b' }), repo({ fullName: 'o/a' })],
alerts: [
alert({ repo: 'o/a', url: '1' }),
alert({ repo: 'o/b', url: '2' }),
alert({ repo: 'o/ghost', url: '3' }),
alert({ repo: 'o/a', url: '1', since }),
alert({ repo: 'o/b', url: '2', since }),
alert({ repo: 'o/ghost', url: '3', since }),
],
})
expect(buildBlocks(s).map((b) => b.repo.fullName)).toEqual(['o/b', 'o/a'])
})
it('puts the repo with the newest problem first, whatever its kind or severity', () => {
const s = state({
repos: [
repo({ fullName: 'o/old-crit', health: 'crit' }),
repo({ fullName: 'o/new-warn', health: 'warn' }),
repo({ fullName: 'o/mid', health: 'crit' }),
],
alerts: [
alert({
repo: 'o/old-crit',
url: 'a',
severity: 'crit',
kind: 'pr_waiting',
since: '2026-05-19T00:00:00Z',
}),
alert({
repo: 'o/new-warn',
url: 'b',
severity: 'warn',
kind: 'workflow_stuck',
since: '2026-10-02T12:00:00Z',
}),
alert({
repo: 'o/mid',
url: 'c',
severity: 'crit',
kind: 'workflow_failed',
since: '2026-09-20T00:00:00Z',
}),
],
})
expect(buildBlocks(s).map((b) => b.repo.fullName)).toEqual([
'o/new-warn',
'o/mid',
'o/old-crit',
])
})
it('ranks a repo by its newest alert, not its oldest', () => {
const s = state({
repos: [repo({ fullName: 'o/a', health: 'crit' }), repo({ fullName: 'o/b', health: 'crit' })],
alerts: [
alert({ repo: 'o/a', url: 'a1', since: '2026-01-01T00:00:00Z' }),
alert({ repo: 'o/a', url: 'a2', since: '2026-10-02T00:00:00Z' }),
alert({ repo: 'o/b', url: 'b1', since: '2026-06-01T00:00:00Z' }),
],
})
expect(buildBlocks(s).map((b) => b.repo.fullName)).toEqual(['o/a', 'o/b'])
})
it('returns no blocks when everything is healthy', () => {
expect(buildBlocks(state({ repos: [repo()] }))).toEqual([])
})
@@ -219,4 +270,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('')
})
})
})
+39 -7
View File
@@ -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,
@@ -19,7 +19,9 @@ const SCAN_TIMEOUT_MS = 10 * 60 * 1000
const SEVERITY_RANK = { ok: 0, warn: 1, crit: 2 } as const
/** Groups alerts into one row per target URL and one block per unhealthy repo. */
const sinceMs = (since: string) => Date.parse(since) || 0
/** Groups alerts into one row per target URL and one block per unhealthy repo, newest problem first. */
export function buildBlocks(state: TrackerState): Block[] {
const rows = new Map<string, Row>()
const order = new Map<string, string[]>()
@@ -32,6 +34,7 @@ export function buildBlocks(state: TrackerState): Block[] {
if (SEVERITY_RANK[alert.severity] > SEVERITY_RANK[existing.severity]) {
existing.severity = alert.severity
}
if (sinceMs(alert.since) > sinceMs(existing.since)) existing.since = alert.since
continue
}
rows.set(key, {
@@ -52,7 +55,8 @@ export function buildBlocks(state: TrackerState): Block[] {
list.sort((a, b) => SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity])
blocks.push({ repo, rows: list })
}
return blocks
const newest = (b: Block) => Math.max(...b.rows.map((r) => sinceMs(r.since)))
return blocks.sort((a, b) => newest(b) - newest(a))
}
export interface RepoCounts {
@@ -98,6 +102,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<string[]>([])
const skewMs = ref(0)
const tick = ref(Date.now())
@@ -143,13 +150,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<void> {
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 +190,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<void> {
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 +232,7 @@ export const useTrackerStore = defineStore('tracker', () => {
clearFilters,
load,
rescan,
refreshRepo,
advanceClock,
}
})