Compare commits

..
7 Commits
Author SHA1 Message Date
lukaszraczylo 23bbbedf59 feat(ui): show alert action buttons and status chips
Alerts that match a configured action get a button labelled from the
config. A confirm dialog opens first when the action defines confirm text.
After a press the UI posts the alert, reloads the state through the
existing poll path and shows the reported status as a chip, with an anchor
when the status carries a link.

The alert dialog comes from reka-ui through the usual shadcn-vue wrappers.
2026-10-04 16:37:31 +01:00
lukaszraczylo 5824c4dd10 feat(web): add opt-in webhook alert actions
An optional actions block in the config adds buttons to alerts. POST
/api/actions/{id} checks that the repository and the current alert match,
then sends the alert as JSON to the configured webhook with an optional
bearer token. The request refuses cross-site callers like POST /refresh.

GET /api/state lists the actions and merges per-alert status rows from an
optional status_url, cached for 10 seconds. Without an actions block the
API output does not change.

The ghtracker_action_requests_total counter records each result.
2026-10-04 16:35:26 +01:00
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
42 changed files with 2897 additions and 1535 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
+34 -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,38 @@ 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.
## Alert actions
An action adds a button to alerts. Pressing it sends the alert as JSON to a webhook that you run, for example to re-run a failed pipeline or to open a ticket. Without an `actions` block the feature is off: the state document, the endpoints and the UI do not change.
```yaml
actions:
- id: rerun # [a-z0-9-]+, appears in the payload and the endpoint
label: Re-run # button text
kinds: [workflow_failed] # alert kinds that show the button; omit for every kind
webhook:
url: https://example.internal/hooks/rerun
token_env: ACTION_TOKEN # name of an environment variable that holds a bearer token; optional
timeout: 10s
status_url: https://example.internal/hooks/rerun/status # optional
confirm: 'Run this action for the selected alert?' # optional; omit for no dialog
```
- `POST /api/actions/{id}` takes `{"repo": "owner/name", "url": "<alert url>"}`. It refuses cross-site requests like `POST /refresh`. The action must exist, the repository must be in the configured list, and a current alert with that repo and URL must have a kind listed in `kinds`. Otherwise the answer is 404.
- The server then POSTs `{"action": "<id>", "alert": {"repo", "kind", "severity", "subject", "detail", "url", "since"}, "requestedAt": "<RFC 3339>"}` to `webhook.url`. When `token_env` is set, the request carries `Authorization: Bearer <token>`. The server never logs the token and never follows redirects.
- A 2xx answer succeeds. If its body is JSON `{"state", "label", "link"}`, the UI shows it as the status of that alert. A non-2xx answer becomes an error message with the upstream body cut to 300 characters. An unreachable webhook gives 502.
- `GET /api/state` gains `actions` (`id`, `label`, `confirm`, `kinds`; no URLs and no tokens). When `status_url` is set, the server also fetches it with `GET` (same bearer token) and expects a JSON list of `{"repo", "url", "state", "label", "link"}` rows, newest first. It matches rows to alerts by repo and URL and adds `actionStatus` to each alert, keyed by action id. The result is cached for 10 seconds. If `status_url` is down, the state omits `actionStatus` and sets `actionsError`.
- A `link` that is not an http or https address is dropped.
- The dashboard shows one button per action on matching alerts, a confirm dialog when `confirm` is set, and a status chip once a status is known. The chip links to `link` when there is one. The page reads the status on its normal 60-second poll and once right after a press.
## Metrics
@@ -76,6 +108,7 @@ All metrics use the `ghtracker_` prefix. Gauges are computed at scrape time.
| `release_info`, `release_published_timestamp_seconds` | repo, version, url |
| `alerts` | severity |
| `refreshes_total`, `repo_refresh_errors_total`, `last_refresh_duration_seconds`, `github_rate_limit_remaining` | none |
| `action_requests_total` (counter; result is `ok`, `rejected` or `error`; only with `actions`) | action, result |
Example alert rules:
+12
View File
@@ -14,6 +14,18 @@ thresholds: # negative disables a check
issue_stale_after: 720h # no activity this long = warning (aggregated per repo)
workflow_stuck_after: 6h # queued / in progress this long = warning
# Optional buttons on alerts. Each one POSTs the alert as JSON to a webhook. Omit the block to turn the feature off.
# actions:
# - id: rerun # [a-z0-9-]+
# label: Re-run # button text
# kinds: [workflow_failed] # omit for every alert kind
# webhook:
# url: https://example.internal/hooks/rerun
# token_env: ACTION_TOKEN # env var holding a bearer token; optional
# timeout: 10s
# status_url: https://example.internal/hooks/rerun/status # optional
# confirm: "Run this action for the selected alert?" # optional; omit for no dialog
max_items: 100 # per repo, issues and PRs
concurrency: 2
+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=
+77 -7
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,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()
+106
View File
@@ -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)
}
}
+81
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"net/url"
"os"
"regexp"
"strings"
"time"
@@ -28,13 +29,49 @@ const (
MinRefresh = time.Minute
dataStaleGrace = 30 * time.Minute
defaultDataStaleFactor = 2
DefaultActionTimeout = 10 * time.Second
)
var actionIDPattern = regexp.MustCompile(`^[a-z0-9-]+$`)
// Action is an operator-defined button on alerts. Pressing it POSTs the alert to a webhook.
type Action struct {
ID string `yaml:"id"`
Label string `yaml:"label"`
Confirm string `yaml:"confirm"`
StatusURL string `yaml:"status_url"`
// Token comes from the environment variable named by Webhook.TokenEnv, never from the file.
Token string `yaml:"-"`
Webhook Webhook `yaml:"webhook"`
// Kinds limits the action to these alert kinds; empty means every kind.
Kinds []string `yaml:"kinds"`
}
type Webhook struct {
URL string `yaml:"url"`
TokenEnv string `yaml:"token_env"`
Timeout time.Duration `yaml:"timeout"`
}
// AppliesTo reports whether the action is offered for an alert kind.
func (a Action) AppliesTo(kind string) bool {
if len(a.Kinds) == 0 {
return true
}
for _, k := range a.Kinds {
if k == kind {
return true
}
}
return false
}
type Config struct {
// ManualRefresh enables POST /refresh and the rescan button; it defaults to on.
ManualRefresh *bool `yaml:"manual_refresh"`
Listen string `yaml:"listen"`
Repos []string `yaml:"repos"`
Actions []Action `yaml:"actions"`
GitHub GitHub `yaml:"github"`
Thresholds Thresholds `yaml:"thresholds"`
RefreshInterval time.Duration `yaml:"refresh_interval"`
@@ -127,6 +164,10 @@ func (c *Config) finalize(getenv func(string) string, readFile func(string) ([]b
c.GitHub.PrivateKeyPEM = string(pem)
}
if err := finalizeActions(c.Actions, getenv); err != nil {
return err
}
repos, err := normalizeRepos(c.Repos)
if err != nil {
return err
@@ -135,6 +176,46 @@ func (c *Config) finalize(getenv func(string) string, readFile func(string) ([]b
return nil
}
func finalizeActions(actions []Action, getenv func(string) string) error {
seen := make(map[string]bool, len(actions))
for i := range actions {
a := &actions[i]
if !actionIDPattern.MatchString(a.ID) {
return fmt.Errorf("actions[%d].id %q must match [a-z0-9-]+", i, a.ID)
}
if seen[a.ID] {
return fmt.Errorf("actions[%d].id %q is used twice", i, a.ID)
}
seen[a.ID] = true
if a.Label == "" {
a.Label = a.ID
}
if !isHTTPURL(a.Webhook.URL) {
return fmt.Errorf("actions[%s].webhook.url %q must be an http or https address", a.ID, a.Webhook.URL)
}
if a.StatusURL != "" && !isHTTPURL(a.StatusURL) {
return fmt.Errorf("actions[%s].status_url %q must be an http or https address", a.ID, a.StatusURL)
}
if a.Webhook.Timeout < 0 {
return fmt.Errorf("actions[%s].webhook.timeout must not be negative", a.ID)
}
if a.Webhook.Timeout == 0 {
a.Webhook.Timeout = DefaultActionTimeout
}
if env := a.Webhook.TokenEnv; env != "" {
if a.Token = getenv(env); a.Token == "" {
return fmt.Errorf("actions[%s].webhook.token_env: $%s is empty", a.ID, env)
}
}
}
return nil
}
func isHTTPURL(raw string) bool {
u, err := url.Parse(raw)
return err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Host != ""
}
func setDefault(d *time.Duration, def time.Duration) {
if *d == 0 {
*d = def
+76
View File
@@ -4,6 +4,7 @@ import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
@@ -145,3 +146,78 @@ func TestWebBase(t *testing.T) {
}
}
}
func TestActionsDefaultOff(t *testing.T) {
c := valid()
if err := c.finalize(env(nil), keyFile); err != nil {
t.Fatal(err)
}
if len(c.Actions) != 0 {
t.Fatalf("actions must default to none: %+v", c.Actions)
}
}
func TestActionsFinalize(t *testing.T) {
good := func() Action {
return Action{ID: "rerun", Webhook: Webhook{URL: "https://hooks.example.test/rerun", TokenEnv: "T"}}
}
tests := map[string]struct {
mutate func(*Action)
extra []Action
wantErr bool
}{
"ok": {mutate: func(*Action) {}},
"no token env": {mutate: func(a *Action) { a.Webhook.TokenEnv = "" }},
"bad id upper": {mutate: func(a *Action) { a.ID = "Re_run" }, wantErr: true},
"empty id": {mutate: func(a *Action) { a.ID = "" }, wantErr: true},
"duplicate id": {mutate: func(*Action) {}, extra: []Action{good()}, wantErr: true},
"bad webhook": {mutate: func(a *Action) { a.Webhook.URL = "ftp://x" }, wantErr: true},
"no webhook": {mutate: func(a *Action) { a.Webhook.URL = "" }, wantErr: true},
"bad status url": {mutate: func(a *Action) { a.StatusURL = "nope" }, wantErr: true},
"status url ok": {mutate: func(a *Action) { a.StatusURL = "http://status.example.test/s" }},
"negative timeout": {mutate: func(a *Action) { a.Webhook.Timeout = -time.Second }, wantErr: true},
"token env unset": {mutate: func(a *Action) { a.Webhook.TokenEnv = "MISSING" }, wantErr: true},
}
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
a := good()
tc.mutate(&a)
c := valid()
c.Actions = append([]Action{a}, tc.extra...)
err := c.finalize(env(map[string]string{"T": "s3cret"}), keyFile)
if (err != nil) != tc.wantErr {
t.Fatalf("err = %v, wantErr %v", err, tc.wantErr)
}
if err != nil && strings.Contains(err.Error(), "s3cret") {
t.Fatal("error leaks token")
}
if err == nil {
got := c.Actions[0]
if got.Label != "rerun" || got.Webhook.Timeout != DefaultActionTimeout {
t.Fatalf("defaults wrong: %+v", got)
}
if a.Webhook.TokenEnv == "T" && got.Token != "s3cret" {
t.Fatalf("token not read: %+v", got)
}
}
})
}
}
func TestActionAppliesTo(t *testing.T) {
tests := []struct {
kind string
kinds []string
want bool
}{
{kinds: nil, kind: "anything", want: true},
{kinds: []string{"workflow_failed"}, kind: "workflow_failed", want: true},
{kinds: []string{"workflow_failed"}, kind: "pr_waiting", want: false},
{kinds: []string{"a", "b"}, kind: "b", want: true},
}
for _, tc := range tests {
if got := (Action{Kinds: tc.kinds}).AppliesTo(tc.kind); got != tc.want {
t.Errorf("%v.AppliesTo(%q) = %v", tc.kinds, tc.kind, got)
}
}
}
+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"}]}`)
+343
View File
@@ -0,0 +1,343 @@
package web
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/lukaszraczylo/ghtracker/internal/config"
"github.com/lukaszraczylo/ghtracker/internal/model"
)
const (
statusCacheTTL = 10 * time.Second
maxActionBody = 4 << 10
maxUpstreamBody = 1 << 20
maxErrorChars = 300
actionsUnavail = "action status unavailable"
resultOK = "ok"
resultRejected = "rejected"
resultFailed = "error"
actionMetricName = "action_requests_total"
contentTypeHeader = "Content-Type"
)
// apiAction is the public description of an action; it never carries URLs or tokens.
type apiAction struct {
ID string `json:"id"`
Label string `json:"label"`
Confirm string `json:"confirm,omitempty"`
Kinds []string `json:"kinds"`
}
// apiActionStatus is what the UI shows next to an alert after an action ran.
type apiActionStatus struct {
State string `json:"state"`
Label string `json:"label,omitempty"`
Link string `json:"link,omitempty"`
}
type statusRow struct {
Repo string `json:"repo"`
URL string `json:"url"`
apiActionStatus
}
type alertRef struct{ repo, url string }
// actionRuntime holds one action and the cache of its status rows.
type actionRuntime struct {
fetched time.Time
rows map[alertRef]apiActionStatus
err error
cfg config.Action
mu sync.Mutex
}
// Actions runs the configured webhook actions and merges their status into the state.
type Actions struct {
now func() time.Time
hc *http.Client
log *slog.Logger
counter *prometheus.CounterVec
byID map[string]*actionRuntime
list []*actionRuntime
}
// NewActions builds the runner and registers ghtracker_action_requests_total on reg when reg is not nil.
func NewActions(cfgs []config.Action, log *slog.Logger, reg prometheus.Registerer) *Actions {
a := &Actions{
now: time.Now, log: log, byID: make(map[string]*actionRuntime, len(cfgs)),
// Redirects would carry the bearer token to another host, so none are followed.
hc: &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }},
counter: prometheus.NewCounterVec(prometheus.CounterOpts{
Namespace: "ghtracker", Name: actionMetricName,
Help: "Action requests sent to webhooks, by action and result.",
}, []string{"action", "result"}),
}
for _, c := range cfgs {
rt := &actionRuntime{cfg: c}
a.list = append(a.list, rt)
a.byID[c.ID] = rt
for _, r := range []string{resultOK, resultRejected, resultFailed} {
a.counter.WithLabelValues(c.ID, r)
}
}
if reg != nil {
reg.MustRegister(a.counter)
}
return a
}
// WithActions enables POST /api/actions/{id} and the action data in the state document.
func (s *Server) WithActions(a *Actions) *Server {
if a != nil && len(a.list) > 0 {
s.actions = a
}
return s
}
// decorate adds the action list and per-alert status to a state document.
func (s *Server) decorate(ctx context.Context, st *apiState) {
if s.actions == nil {
return
}
for _, rt := range s.actions.list {
kinds := rt.cfg.Kinds
if kinds == nil {
kinds = []string{}
}
st.Actions = append(st.Actions, apiAction{ID: rt.cfg.ID, Label: rt.cfg.Label, Confirm: rt.cfg.Confirm, Kinds: kinds})
if rt.cfg.StatusURL == "" {
continue
}
rows, err := s.actions.statusRows(ctx, rt)
if err != nil {
st.ActionsError = actionsUnavail
continue
}
for i := range st.Alerts {
al := &st.Alerts[i]
if !rt.cfg.AppliesTo(al.Kind) {
continue
}
if row, ok := rows[alertRef{strings.ToLower(al.Repo), al.URL}]; ok {
if al.ActionStatus == nil {
al.ActionStatus = map[string]apiActionStatus{}
}
al.ActionStatus[rt.cfg.ID] = row
}
}
}
}
// statusRows returns the cached rows of an action's status_url; failures are cached for the TTL too.
func (a *Actions) statusRows(ctx context.Context, rt *actionRuntime) (map[alertRef]apiActionStatus, error) {
rt.mu.Lock()
defer rt.mu.Unlock()
if !rt.fetched.IsZero() && a.now().Sub(rt.fetched) < statusCacheTTL {
return rt.rows, rt.err
}
rows, err := a.fetchStatus(ctx, rt.cfg)
if err != nil {
a.log.Warn("action status unavailable", "action", rt.cfg.ID, "err", err)
}
rt.rows, rt.err, rt.fetched = rows, err, a.now()
return rows, err
}
func (rt *actionRuntime) invalidate() {
rt.mu.Lock()
rt.fetched = time.Time{}
rt.mu.Unlock()
}
func (a *Actions) fetchStatus(ctx context.Context, c config.Action) (map[alertRef]apiActionStatus, error) {
ctx, cancel := context.WithTimeout(ctx, c.Webhook.Timeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.StatusURL, nil)
if err != nil {
return nil, errors.New("invalid status url")
}
req.Header.Set("Accept", "application/json")
setBearer(req, c.Token)
resp, err := a.hc.Do(req)
if err != nil {
return nil, errors.New("request failed")
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode/100 != 2 {
return nil, fmt.Errorf("status %d", resp.StatusCode)
}
var list []statusRow
if err := json.NewDecoder(io.LimitReader(resp.Body, maxUpstreamBody)).Decode(&list); err != nil {
return nil, errors.New("invalid reply")
}
out := make(map[alertRef]apiActionStatus, len(list))
for _, r := range list { // the first row per alert wins, so put the newest first
k := alertRef{strings.ToLower(r.Repo), r.URL}
if _, seen := out[k]; !seen {
out[k] = cleanStatus(r.apiActionStatus)
}
}
return out, nil
}
// cleanStatus drops a link that is not http(s), so the UI never renders a script URL as an anchor.
func cleanStatus(st apiActionStatus) apiActionStatus {
if u, err := url.Parse(st.Link); err != nil || (u.Scheme != "http" && u.Scheme != "https") {
st.Link = ""
}
return st
}
func setBearer(req *http.Request, token string) {
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
}
type actionRequest struct {
Repo string `json:"repo"`
URL string `json:"url"`
}
type webhookAlert struct {
Since time.Time `json:"since"`
Repo string `json:"repo"`
Kind string `json:"kind"`
Severity string `json:"severity"`
Subject string `json:"subject"`
Detail string `json:"detail"`
URL string `json:"url"`
}
type webhookPayload struct {
RequestedAt time.Time `json:"requestedAt"`
Action string `json:"action"`
Alert webhookAlert `json:"alert"`
}
// runAction validates the request against the current state and forwards it to the action's webhook.
func (s *Server) runAction(w http.ResponseWriter, r *http.Request) {
if crossSite(r) {
http.Error(w, "cross-site action refused", http.StatusForbidden)
return
}
rt, ok := s.actions.byID[r.PathValue("id")]
if !ok {
writeJSONError(w, http.StatusNotFound, "unknown action")
return
}
var req actionRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxActionBody)).Decode(&req); err != nil || req.Repo == "" || req.URL == "" {
writeJSONError(w, http.StatusBadRequest, `body must be {"repo":"owner/name","url":"..."}`)
return
}
alert, code, msg := s.findAlert(rt.cfg, req)
if code != 0 {
writeJSONError(w, code, msg)
return
}
status, body, err := s.actions.post(r.Context(), rt.cfg, webhookPayload{
Action: rt.cfg.ID, RequestedAt: s.now().UTC(),
Alert: webhookAlert{Repo: alert.Repo, Kind: alert.Kind, Severity: alert.Severity.String(), Subject: alert.Subject,
Detail: alert.Detail, URL: alert.URL, Since: alert.Since},
})
if err != nil {
s.actions.count(rt.cfg.ID, resultFailed)
s.log.Warn("action webhook failed", "action", rt.cfg.ID, "repo", alert.Repo, "err", err)
writeJSONError(w, http.StatusBadGateway, "action webhook unreachable")
return
}
s.log.Info("action webhook", "action", rt.cfg.ID, "repo", alert.Repo, "status", status)
if status/100 != 2 {
result, out := resultFailed, http.StatusBadGateway
switch status / 100 {
case 4:
result, out = resultRejected, status
case 5:
out = status
}
s.actions.count(rt.cfg.ID, result)
writeJSONError(w, out, fmt.Sprintf("action webhook answered HTTP %d: %s", status, truncate(string(body), maxErrorChars)))
return
}
s.actions.count(rt.cfg.ID, resultOK)
rt.invalidate()
var st apiActionStatus
if json.Unmarshal(body, &st) != nil {
st = apiActionStatus{}
}
w.Header().Set(contentTypeHeader, "application/json")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(cleanStatus(st))
}
// findAlert returns the current alert a request targets, or a status and message when it has none.
func (s *Server) findAlert(c config.Action, req actionRequest) (model.Alert, int, string) {
v := s.src.View()
tracked := false
for _, repo := range v.Repos {
if strings.EqualFold(repo.FullName, req.Repo) {
tracked = true
break
}
}
if !tracked {
return model.Alert{}, http.StatusNotFound, "unknown repository"
}
for _, a := range v.Alerts {
if strings.EqualFold(a.Repo, req.Repo) && a.URL == req.URL && c.AppliesTo(a.Kind) {
return a, 0, ""
}
}
return model.Alert{}, http.StatusNotFound, "no such alert"
}
func (a *Actions) post(ctx context.Context, c config.Action, p webhookPayload) (int, []byte, error) {
ctx, cancel := context.WithTimeout(ctx, c.Webhook.Timeout)
defer cancel()
payload, err := json.Marshal(p)
if err != nil {
return 0, nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.Webhook.URL, bytes.NewReader(payload))
if err != nil {
return 0, nil, errors.New("invalid webhook url")
}
req.Header.Set(contentTypeHeader, "application/json")
setBearer(req, c.Token)
resp, err := a.hc.Do(req)
if err != nil {
return 0, nil, errors.New("request failed")
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(io.LimitReader(resp.Body, maxUpstreamBody))
if err != nil {
return 0, nil, errors.New("read reply failed")
}
return resp.StatusCode, body, nil
}
func (a *Actions) count(action, result string) { a.counter.WithLabelValues(action, result).Inc() }
func truncate(s string, n int) string {
s = strings.TrimSpace(s)
if r := []rune(s); len(r) > n {
return string(r[:n]) + "..."
}
return s
}
+409
View File
@@ -0,0 +1,409 @@
package web
import (
"bytes"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/testutil"
"github.com/lukaszraczylo/ghtracker/internal/config"
)
const (
testToken = "tok-SECRET-123"
alertURL = "https://github.com/o/r/actions/runs/1"
prURL = "https://github.com/o/r/pull/5"
)
type fakeHook struct {
srv *httptest.Server
postBody string
statBody string
lastAuth string
lastBody string
lastMethod string
postCode int
statCode int
mu sync.Mutex
postCalls atomic.Int32
statCalls atomic.Int32
}
func newFakeHook(t *testing.T) *fakeHook {
t.Helper()
f := &fakeHook{postCode: 200, postBody: `{"state":"queued","label":"Queued","link":"https://ci.example.test/run/9"}`, statCode: 200, statBody: `[]`}
f.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
b, _ := io.ReadAll(r.Body)
f.mu.Lock()
f.lastAuth, f.lastBody, f.lastMethod = r.Header.Get("Authorization"), string(b), r.Method
f.mu.Unlock()
w.Header().Set("Content-Type", "application/json")
if r.URL.Path == "/status" {
f.statCalls.Add(1)
w.WriteHeader(f.statCode)
_, _ = w.Write([]byte(f.statBody))
return
}
f.postCalls.Add(1)
w.WriteHeader(f.postCode)
_, _ = w.Write([]byte(f.postBody))
}))
t.Cleanup(f.srv.Close)
return f
}
type actionHarness struct {
srv *Server
actions *Actions
logs *bytes.Buffer
clock time.Time
}
func newActionServer(t *testing.T, mutate func(*config.Action), base string) *actionHarness {
t.Helper()
c := config.Action{ID: "rerun", Label: "Re-run", Kinds: []string{"workflow_failed"}, Confirm: "Run it?",
StatusURL: base + "/status", Token: testToken,
Webhook: config.Webhook{URL: base + "/hook", TokenEnv: "T", Timeout: 2 * time.Second}}
if mutate != nil {
mutate(&c)
}
h := &actionHarness{logs: &bytes.Buffer{}, clock: now}
log := slog.New(slog.NewTextHandler(h.logs, nil))
h.actions = NewActions([]config.Action{c}, log, prometheus.NewRegistry())
h.actions.now = func() time.Time { return h.clock }
h.srv = New(&fakeSource{v: testView()}, http.NotFoundHandler(), testUI(), true, log).WithActions(h.actions)
h.srv.now = func() time.Time { return now }
return h
}
func (h *actionHarness) post(path, body string, hdr map[string]string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
for k, v := range hdr {
req.Header.Set(k, v)
}
rec := httptest.NewRecorder()
h.srv.Handler().ServeHTTP(rec, req)
return rec
}
func (h *actionHarness) state(t *testing.T) map[string]any {
t.Helper()
var m map[string]any
if err := json.Unmarshal(do(h.srv.Handler(), "GET", "/api/state").Body.Bytes(), &m); err != nil {
t.Fatal(err)
}
return m
}
func reqBody(repo, url string) string {
b, _ := json.Marshal(map[string]string{"repo": repo, "url": url})
return string(b)
}
func (h *actionHarness) counter(result string) float64 {
return testutil.ToFloat64(h.actions.counter.WithLabelValues("rerun", result))
}
func TestActionRequest(t *testing.T) {
tests := []struct {
hdr map[string]string
mutate func(*config.Action)
name string
path string
body string
postBody string
wantResult string
wantBody string
postCode int
wantCode int
wantPosts int32
}{
{name: "ok passes status through", wantCode: 200, wantPosts: 1, wantResult: "ok",
wantBody: `"state":"queued"`},
{name: "ok with empty body", postBody: "not json", wantCode: 200, wantPosts: 1, wantResult: "ok", wantBody: `"state":""`},
{name: "repo case-insensitive", body: reqBody("O/R", alertURL), wantCode: 200, wantPosts: 1, wantResult: "ok"},
{name: "javascript link dropped", postBody: `{"state":"x","link":"javascript:alert(1)"}`,
wantCode: 200, wantPosts: 1, wantResult: "ok", wantBody: `{"state":"x"}`},
{name: "no token configured", mutate: func(a *config.Action) { a.Token = "" }, wantCode: 200, wantPosts: 1, wantResult: "ok"},
{name: "upstream 4xx", postCode: 429, postBody: `{"error":"busy"}`, wantCode: 429, wantPosts: 1, wantResult: "rejected", wantBody: "HTTP 429"},
{name: "upstream 5xx", postCode: 500, postBody: "boom", wantCode: 500, wantPosts: 1, wantResult: "error", wantBody: "boom"},
{name: "upstream redirect not followed", postCode: 302, postBody: "moved", wantCode: 502, wantPosts: 1, wantResult: "error"},
{name: "upstream body truncated", postCode: 500, postBody: strings.Repeat("x", 1000), wantCode: 500, wantPosts: 1, wantResult: "error",
wantBody: strings.Repeat("x", 300) + "..."},
{name: "cross-site refused", hdr: map[string]string{"Sec-Fetch-Site": "cross-site"}, wantCode: 403},
{name: "same-site refused", hdr: map[string]string{"Sec-Fetch-Site": "same-site"}, wantCode: 403},
{name: "same-origin allowed", hdr: map[string]string{"Sec-Fetch-Site": "same-origin"}, wantCode: 200, wantPosts: 1, wantResult: "ok"},
{name: "unknown action", path: "/api/actions/nope", wantCode: 404, wantBody: "unknown action"},
{name: "unknown repo", body: reqBody("x/y", alertURL), wantCode: 404, wantBody: "unknown repository"},
{name: "unknown alert", body: reqBody("o/r", alertURL+"9"), wantCode: 404, wantBody: "no such alert"},
{name: "kind not in kinds", body: reqBody("o/r", prURL), wantCode: 404, wantBody: "no such alert"},
{name: "empty kinds matches every kind", body: reqBody("o/r", prURL), mutate: func(a *config.Action) { a.Kinds = nil },
wantCode: 200, wantPosts: 1, wantResult: "ok"},
{name: "malformed body", body: `{`, wantCode: 400},
{name: "missing url", body: `{"repo":"o/r"}`, wantCode: 400},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
fh := newFakeHook(t)
if tc.postCode != 0 {
fh.postCode = tc.postCode
}
if tc.postBody != "" {
fh.postBody = tc.postBody
}
h := newActionServer(t, tc.mutate, fh.srv.URL)
path, body := tc.path, tc.body
if path == "" {
path = "/api/actions/rerun"
}
if body == "" {
body = reqBody("o/r", alertURL)
}
rec := h.post(path, body, tc.hdr)
if rec.Code != tc.wantCode {
t.Fatalf("code = %d, want %d: %s", rec.Code, tc.wantCode, rec.Body)
}
if fh.postCalls.Load() != tc.wantPosts {
t.Fatalf("webhook posts = %d, want %d", fh.postCalls.Load(), tc.wantPosts)
}
if tc.wantBody != "" && !strings.Contains(rec.Body.String(), tc.wantBody) {
t.Fatalf("body %q lacks %q", rec.Body, tc.wantBody)
}
if tc.wantResult != "" && h.counter(tc.wantResult) != 1 {
t.Fatalf("counter %s = %v, want 1", tc.wantResult, h.counter(tc.wantResult))
}
if tc.wantPosts > 0 {
wantAuth := "Bearer " + testToken
if tc.mutate != nil && tc.name == "no token configured" {
wantAuth = ""
}
if fh.lastAuth != wantAuth {
t.Fatalf("auth = %q, want %q", fh.lastAuth, wantAuth)
}
}
if strings.Contains(h.logs.String(), testToken) || strings.Contains(rec.Body.String(), testToken) {
t.Fatal("token leaked into logs or response")
}
})
}
}
func TestActionWebhookPayload(t *testing.T) {
fh := newFakeHook(t)
h := newActionServer(t, nil, fh.srv.URL)
if rec := h.post("/api/actions/rerun", reqBody("o/r", alertURL), nil); rec.Code != 200 {
t.Fatal(rec.Body)
}
var got struct {
RequestedAt time.Time
Alert map[string]string
Action string
}
if err := json.Unmarshal([]byte(fh.lastBody), &got); err != nil {
t.Fatal(err)
}
if fh.lastMethod != "POST" || got.Action != "rerun" || !got.RequestedAt.Equal(now) {
t.Fatalf("payload: %+v (%s)", got, fh.lastBody)
}
want := map[string]string{"repo": "o/r", "kind": "workflow_failed", "severity": "crit", "subject": "CI", "url": alertURL}
for k, v := range want {
if got.Alert[k] != v {
t.Errorf("alert.%s = %q, want %q", k, got.Alert[k], v)
}
}
if got.Alert["detail"] == "" || got.Alert["since"] == "" {
t.Errorf("alert lacks detail or since: %v", got.Alert)
}
}
func TestActionWebhookDown(t *testing.T) {
fh := newFakeHook(t)
base := fh.srv.URL
fh.srv.Close()
h := newActionServer(t, nil, base)
rec := h.post("/api/actions/rerun", reqBody("o/r", alertURL), nil)
if rec.Code != http.StatusBadGateway || h.counter("error") != 1 {
t.Fatalf("%d %s", rec.Code, rec.Body)
}
if strings.Contains(h.logs.String(), testToken) {
t.Fatal("token leaked into logs")
}
}
func TestActionRouteAbsentWithoutActions(t *testing.T) {
h := newServer(&fakeSource{v: testView()}, testUI(), true).Handler()
if rec := do(h, "POST", "/api/actions/rerun"); rec.Code != http.StatusNotFound && rec.Code != http.StatusMethodNotAllowed {
t.Fatalf("code = %d", rec.Code)
}
}
func TestStateUnchangedWithoutActions(t *testing.T) {
body := do(newServer(&fakeSource{v: testView()}, testUI(), true).Handler(), "GET", "/api/state").Body.String()
for _, key := range []string{`"actions"`, `"actionStatus"`, `"actionsError"`} {
if strings.Contains(body, key) {
t.Fatalf("state gained %s without actions: %s", key, body)
}
}
}
func alertField(m map[string]any, kind, field string) any {
for _, a := range m["alerts"].([]any) {
if al := a.(map[string]any); al["kind"] == kind {
return al[field]
}
}
return nil
}
func TestStateMergesActionStatus(t *testing.T) {
tests := []struct {
wantState any
wantLink any
mutate func(*config.Action)
name string
rows string
}{
{name: "match by repo and url", rows: `[{"repo":"O/R","url":"` + alertURL + `","state":"running","label":"Running","link":"https://ci.example.test/1"}]`,
wantState: "running", wantLink: "https://ci.example.test/1"},
{name: "first row wins", rows: `[{"repo":"o/r","url":"` + alertURL + `","state":"new"},{"repo":"o/r","url":"` + alertURL + `","state":"old"}]`,
wantState: "new"},
{name: "other url ignored", rows: `[{"repo":"o/r","url":"` + alertURL + `x","state":"running"}]`},
{name: "other repo ignored", rows: `[{"repo":"o/other","url":"` + alertURL + `","state":"running"}]`},
{name: "unsafe link dropped", rows: `[{"repo":"o/r","url":"` + alertURL + `","state":"done","link":"javascript:x"}]`, wantState: "done"},
{name: "kind filter respected", rows: `[{"repo":"o/r","url":"` + alertURL + `","state":"running"}]`,
mutate: func(a *config.Action) { a.Kinds = []string{"pr_waiting"} }},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
fh := newFakeHook(t)
fh.statBody = tc.rows
m := newActionServer(t, tc.mutate, fh.srv.URL).state(t)
if _, ok := m["actionsError"]; ok {
t.Fatalf("unexpected actionsError: %v", m["actionsError"])
}
byAction, _ := alertField(m, "workflow_failed", "actionStatus").(map[string]any)
st, _ := byAction["rerun"].(map[string]any)
if tc.wantState == nil {
if st != nil {
t.Fatalf("status present: %v", st)
}
return
}
if st["state"] != tc.wantState || st["link"] != tc.wantLink {
t.Fatalf("status = %v, want state %v link %v", st, tc.wantState, tc.wantLink)
}
})
}
}
func TestStateListsActionsWithoutSecrets(t *testing.T) {
fh := newFakeHook(t)
h := newActionServer(t, nil, fh.srv.URL)
rec := do(h.srv.Handler(), "GET", "/api/state")
raw := rec.Body.String()
for _, secret := range []string{testToken, fh.srv.URL, "/hook", "/status"} {
if strings.Contains(raw, secret) {
t.Fatalf("state leaks %q", secret)
}
}
var m struct {
Actions []map[string]any
}
if err := json.Unmarshal(rec.Body.Bytes(), &m); err != nil || len(m.Actions) != 1 {
t.Fatalf("%v %s", err, raw)
}
a := m.Actions[0]
if a["id"] != "rerun" || a["label"] != "Re-run" || a["confirm"] != "Run it?" || len(a["kinds"].([]any)) != 1 {
t.Fatalf("action = %v", a)
}
}
func TestStateStatusDown(t *testing.T) {
tests := map[string]func(*fakeHook, *string){
"server error": func(f *fakeHook, _ *string) { f.statCode = 500 },
"invalid json": func(f *fakeHook, _ *string) { f.statBody = "nope" },
"unreachable": func(f *fakeHook, base *string) { f.srv.Close(); *base = f.srv.URL },
}
for name, breakIt := range tests {
t.Run(name, func(t *testing.T) {
fh := newFakeHook(t)
base := fh.srv.URL
breakIt(fh, &base)
h := newActionServer(t, nil, base)
m := h.state(t)
if m["actionsError"] != actionsUnavail {
t.Fatalf("actionsError = %v", m["actionsError"])
}
if alertField(m, "workflow_failed", "actionStatus") != nil {
t.Fatal("actionStatus present while status is down")
}
if len(m["actions"].([]any)) != 1 {
t.Fatal("actions must still be listed so the buttons work")
}
if strings.Contains(h.logs.String(), testToken) {
t.Fatal("token leaked into logs")
}
})
}
}
func TestStateWithoutStatusURL(t *testing.T) {
fh := newFakeHook(t)
h := newActionServer(t, func(a *config.Action) { a.StatusURL = "" }, fh.srv.URL)
m := h.state(t)
if _, ok := m["actionsError"]; ok || fh.statCalls.Load() != 0 || len(m["actions"].([]any)) != 1 {
t.Fatalf("state: %v, status calls %d", m, fh.statCalls.Load())
}
}
func TestStatusCachedAndInvalidatedByRequest(t *testing.T) {
fh := newFakeHook(t)
h := newActionServer(t, nil, fh.srv.URL)
h.state(t)
h.state(t)
if fh.statCalls.Load() != 1 {
t.Fatalf("status calls = %d, want 1 inside the TTL", fh.statCalls.Load())
}
h.clock = h.clock.Add(statusCacheTTL + time.Second)
h.state(t)
if fh.statCalls.Load() != 2 {
t.Fatalf("status calls = %d, want 2 after the TTL", fh.statCalls.Load())
}
h.post("/api/actions/rerun", reqBody("o/r", alertURL), nil)
h.state(t)
if fh.statCalls.Load() != 3 {
t.Fatalf("status calls = %d, want 3 after a request", fh.statCalls.Load())
}
}
func TestRedirectDoesNotForwardToken(t *testing.T) {
var leaked atomic.Bool
other := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "" {
leaked.Store(true)
}
}))
t.Cleanup(other.Close)
fh := newFakeHook(t)
hook := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, other.URL, http.StatusTemporaryRedirect)
}))
t.Cleanup(hook.Close)
h := newActionServer(t, func(a *config.Action) { a.Webhook.URL = hook.URL }, fh.srv.URL)
if rec := h.post("/api/actions/rerun", reqBody("o/r", alertURL), nil); rec.Code != http.StatusBadGateway {
t.Fatalf("code = %d", rec.Code)
}
if leaked.Load() {
t.Fatal("bearer token forwarded across a redirect")
}
}
+12 -9
View File
@@ -10,13 +10,15 @@ import (
// apiState is the JSON contract with the UI. Lists of healthy items stay on GitHub;
// the UI gets counts, release info and the alerts that explain every non-green state.
type apiState struct {
Now time.Time `json:"now"`
LastRefresh time.Time `json:"lastRefresh"`
Now time.Time `json:"now"`
ActionsError string `json:"actionsError,omitempty"`
Actions []apiAction `json:"actions,omitempty"`
Repos []apiRepo `json:"repos"`
Alerts []apiAlert `json:"alerts"`
Thresholds apiThresholds `json:"thresholds"`
Counts apiCounts `json:"counts"`
Scan apiScan `json:"scan"`
Counts apiCounts `json:"counts"`
IntervalSeconds int64 `json:"intervalSeconds"`
Loaded bool `json:"loaded"`
Refreshing bool `json:"refreshing"`
@@ -85,13 +87,14 @@ type apiRelease struct {
}
type apiAlert struct {
Since time.Time `json:"since"`
Repo string `json:"repo"`
Kind string `json:"kind"`
Severity string `json:"severity"`
Subject string `json:"subject"`
Detail string `json:"detail"`
URL string `json:"url"`
Since time.Time `json:"since"`
ActionStatus map[string]apiActionStatus `json:"actionStatus,omitempty"`
Repo string `json:"repo"`
Kind string `json:"kind"`
Severity string `json:"severity"`
Subject string `json:"subject"`
Detail string `json:"detail"`
URL string `json:"url"`
}
func seconds(d time.Duration) int64 { return int64(d / time.Second) }
+87 -5
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,12 +33,15 @@ 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
actions *Actions
ui fs.FS
metrics http.Handler
log *slog.Logger
@@ -43,7 +51,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 {
@@ -56,6 +65,9 @@ func (s *Server) Handler() http.Handler {
if s.manualRefresh {
mux.HandleFunc("POST /refresh", s.refresh)
}
if s.actions != nil {
mux.HandleFunc("POST /api/actions/{id}", s.runAction)
}
return securityHeaders(mux)
}
@@ -91,10 +103,12 @@ func (s *Server) asset(w http.ResponseWriter, r *http.Request) {
http.ServeFileFS(w, r, s.ui, name)
}
func (s *Server) state(w http.ResponseWriter, _ *http.Request) {
func (s *Server) state(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
if err := json.NewEncoder(w).Encode(buildState(s.src.View(), s.manualRefresh)); err != nil {
st := buildState(s.src.View(), s.manualRefresh)
s.decorate(r.Context(), &st)
if err := json.NewEncoder(w).Encode(st); err != nil {
s.log.Error("encode state", "err", err)
}
}
@@ -109,11 +123,14 @@ func (s *Server) health(w http.ResponseWriter, _ *http.Request) {
}
func (s *Server) refresh(w http.ResponseWriter, r *http.Request) {
// Browsers label cross-site requests; refuse them so another page cannot spend the API budget.
if site := r.Header.Get("Sec-Fetch-Site"); site != "" && site != "same-origin" && site != "none" {
if crossSite(r) {
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 +144,68 @@ func (s *Server) refresh(w http.ResponseWriter, r *http.Request) {
s.src.RequestRefresh()
w.WriteHeader(http.StatusAccepted)
}
// crossSite reports whether the browser labelled the request as cross-site, so another page
// cannot spend the API budget or fire a webhook.
func crossSite(r *http.Request) bool {
site := r.Header.Get("Sec-Fetch-Site")
return site != "" && site != "same-origin" && site != "none"
}
// 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")
st := buildState(v, s.manualRefresh)
s.decorate(r.Context(), &st)
if err := json.NewEncoder(w).Encode(repoRefreshResponse{Repo: repo, apiState: st}); 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())
}
}
+4
View File
@@ -80,6 +80,10 @@ func run(ctx context.Context, cfgPath string, log *slog.Logger) error {
}
srv := web.New(coll, promhttp.HandlerFor(reg, promhttp.HandlerOpts{}), ui, *cfg.ManualRefresh, log)
if len(cfg.Actions) > 0 {
srv.WithActions(web.NewActions(cfg.Actions, log, reg))
}
go coll.Run(ctx)
hs := &http.Server{Addr: cfg.Listen, Handler: srv.Handler(), ReadHeaderTimeout: headerTimeout}
+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>
+2
View File
@@ -2,6 +2,7 @@
import { computed } from 'vue'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import AgeMeter from '@/components/AgeMeter.vue'
import RowActions from '@/components/RowActions.vue'
import { ageSince, isZeroTime, secondsSince, shorten } from '@/lib/format'
import { iconFor } from '@/lib/kinds'
import type { Row } from '@/lib/types'
@@ -33,6 +34,7 @@ const ratio = computed(() => secondsSince(props.row.since, props.nowMs) / props.
<p class="text-muted-foreground mt-0.5 text-sm wrap-anywhere">
{{ shorten(row.detail, 120) }}
</p>
<RowActions v-if="row.actions?.length" :row="row" />
</div>
<div
class="col-start-2 flex items-center gap-3 sm:col-start-auto sm:flex-col sm:items-end sm:gap-1.5 sm:pt-0.5"
+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>
+121
View File
@@ -0,0 +1,121 @@
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import AttentionRow from './AttentionRow.vue'
import { alert, hoursAgo, repo, state } from '@/test/fixtures'
import { buildBlocks } from '@/stores/tracker'
import type { ActionDef, Alert, Row } from '@/lib/types'
const rerun: ActionDef = {
id: 'rerun',
label: 'Re-run',
confirm: 'Run this action?',
kinds: ['workflow_failed'],
}
const rowFor = (actions: ActionDef[], over: Partial<Alert> = {}): Row =>
buildBlocks(state({ repos: [repo()], alerts: [alert(over)], actions }))[0].rows[0]
describe('buildBlocks with actions', () => {
it('leaves rows untouched when no actions are configured', () => {
const r = rowFor([])
expect('actions' in r).toBe(false)
expect('actionStatus' in r).toBe(false)
})
it.each([
['kind listed', ['workflow_failed'], true],
['kind not listed', ['pr_waiting'], false],
['empty kinds match everything', [], true],
])('%s', (_name, kinds, shown) => {
const r = rowFor([{ ...rerun, kinds }])
expect(Boolean(r.actions?.length)).toBe(shown)
})
it('carries the status of the alert', () => {
const r = rowFor([rerun], { actionStatus: { rerun: { state: 'running' } } })
expect(r.actionStatus).toEqual({ rerun: { state: 'running' } })
})
})
describe('RowActions', () => {
beforeEach(() => setActivePinia(createPinia()))
afterEach(() => {
vi.unstubAllGlobals()
document.body.innerHTML = ''
})
const mountRow = (row: Row) =>
mount(AttentionRow, {
props: { row, nowMs: Date.parse(hoursAgo(0)), critSeconds: 604800 },
attachTo: document.body,
})
it('shows no button without actions', () => {
const w = mountRow(rowFor([]))
expect(w.find('[data-test=row-actions]').exists()).toBe(false)
})
it('shows a button labelled from the config', () => {
const w = mountRow(rowFor([rerun]))
expect(w.find('[data-test=action-rerun]').text()).toBe('Re-run')
expect(w.find('[data-test=action-status]').exists()).toBe(false)
})
it('asks for confirmation, then posts the alert', async () => {
const fetchMock = vi.fn((url: string) =>
Promise.resolve(
new Response(
JSON.stringify(
url.startsWith('/api/actions/')
? { state: 'queued', link: 'https://ci.example.test/9' }
: state(),
),
{ status: 200 },
),
),
)
vi.stubGlobal('fetch', fetchMock)
const w = mountRow(rowFor([rerun]))
await w.get('[data-test=action-rerun]').trigger('click')
expect(document.body.textContent).toContain('Run this action?')
expect(fetchMock).not.toHaveBeenCalled()
;(document.body.querySelector('[data-test=action-confirm]') as HTMLElement).click()
await vi.waitFor(() => expect(w.find('[data-test=action-status]').exists()).toBe(true))
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]
expect(url).toBe('/api/actions/rerun')
expect(JSON.parse(init.body as string)).toEqual({
repo: 'o/r',
url: 'https://github.com/o/r/actions/runs/1',
})
await vi.waitFor(() =>
expect(w.get('[data-test=action-status] a').attributes('href')).toBe(
'https://ci.example.test/9',
),
)
})
it('posts at once when the action has no confirm text', async () => {
const fetchMock = vi.fn<(url: string, init?: RequestInit) => Promise<Response>>(() =>
Promise.resolve(new Response('{}', { status: 200 })),
)
vi.stubGlobal('fetch', fetchMock)
const w = mountRow(rowFor([{ ...rerun, confirm: undefined }]))
await w.get('[data-test=action-rerun]').trigger('click')
expect(fetchMock).toHaveBeenCalledWith('/api/actions/rerun', expect.anything())
})
it('shows the status reported by the server as text, or as a link', () => {
const plain = mountRow(
rowFor([rerun], { actionStatus: { rerun: { state: 'done', label: 'Finished' } } }),
)
expect(plain.get('[data-test=action-status]').text()).toBe('Finished')
expect(plain.find('[data-test=action-status] a').exists()).toBe(false)
const linked = mountRow(
rowFor([rerun], {
actionStatus: { rerun: { state: 'done', link: 'https://x.example.test/' } },
}),
)
expect(linked.get('[data-test=action-status] a').text()).toBe('done')
})
})
+83
View File
@@ -0,0 +1,83 @@
<script setup lang="ts">
import { ref } from 'vue'
import { faSpinner } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { actionKey, useTrackerStore } from '@/stores/tracker'
import type { ActionDef, ActionStatus, Row } from '@/lib/types'
const props = defineProps<{ row: Row }>()
const store = useTrackerStore()
// `asking` outlives the dialog so the confirm click still knows which action to run.
const asking = ref<ActionDef | null>(null)
const open = ref(false)
const keyOf = (a: ActionDef) => actionKey(a.id, props.row.repo, props.row.url)
const busy = (a: ActionDef) => store.runningActions.includes(keyOf(a))
const statusOf = (a: ActionDef): ActionStatus | undefined =>
props.row.actionStatus?.[a.id] ?? store.localStatus[keyOf(a)]
function run(a: ActionDef): void {
void store.runAction(a.id, props.row.repo, props.row.url)
}
function press(a: ActionDef): void {
if (!a.confirm) return run(a)
asking.value = a
open.value = true
}
</script>
<template>
<div class="mt-2 flex flex-wrap items-center gap-2" data-test="row-actions">
<template v-for="a in row.actions" :key="a.id">
<Button
variant="outline"
size="xs"
:disabled="busy(a)"
:aria-busy="busy(a)"
:data-test="`action-${a.id}`"
@click="press(a)"
>
<FontAwesomeIcon v-if="busy(a)" :icon="faSpinner" spin aria-hidden="true" />
{{ a.label }}
</Button>
<Badge v-if="busy(a)" variant="secondary" data-test="action-status">Sending</Badge>
<Badge v-else-if="statusOf(a)" variant="secondary" data-test="action-status">
<a
v-if="statusOf(a)?.link"
:href="statusOf(a)?.link"
class="underline-offset-4 hover:underline"
>{{ statusOf(a)?.label || statusOf(a)?.state }}</a
>
<template v-else>{{ statusOf(a)?.label || statusOf(a)?.state }}</template>
</Badge>
</template>
<AlertDialog v-model:open="open">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{{ asking?.label }}</AlertDialogTitle>
<AlertDialogDescription>{{ asking?.confirm }}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction data-test="action-confirm" @click="asking && run(asking)">
{{ asking?.label }}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</template>
+53
View File
@@ -125,6 +125,7 @@ describe('AgeMeter', () => {
describe('AttentionRow', () => {
const row = (over: Partial<Row> = {}): Row => ({
repo: 'o/r',
kind: 'pr_waiting',
subject: '#5 <b>bold</b> title',
detail: 'open 9d',
@@ -448,3 +449,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()
})
})
@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { AlertDialogEmits, AlertDialogProps } from 'reka-ui'
import { AlertDialogRoot, useForwardPropsEmits } from 'reka-ui'
const props = defineProps<AlertDialogProps>()
const emits = defineEmits<AlertDialogEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<AlertDialogRoot data-slot="alert-dialog" v-bind="forwarded">
<slot />
</AlertDialogRoot>
</template>
@@ -0,0 +1,18 @@
<script setup lang="ts">
import type { AlertDialogActionProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { AlertDialogAction } from 'reka-ui'
import { cn } from '@/lib/utils'
import { buttonVariants } from '@/components/ui/button'
const props = defineProps<AlertDialogActionProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<AlertDialogAction v-bind="delegatedProps" :class="cn(buttonVariants(), props.class)">
<slot />
</AlertDialogAction>
</template>
@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { AlertDialogCancelProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { AlertDialogCancel } from 'reka-ui'
import { cn } from '@/lib/utils'
import { buttonVariants } from '@/components/ui/button'
const props = defineProps<AlertDialogCancelProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<AlertDialogCancel
v-bind="delegatedProps"
:class="cn(buttonVariants({ variant: 'outline' }), props.class)"
>
<slot />
</AlertDialogCancel>
</template>
@@ -0,0 +1,43 @@
<script setup lang="ts">
import type { AlertDialogContentEmits, AlertDialogContentProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import {
AlertDialogContent,
AlertDialogOverlay,
AlertDialogPortal,
useForwardPropsEmits,
} from 'reka-ui'
import { cn } from '@/lib/utils'
defineOptions({
inheritAttrs: false,
})
const props = defineProps<AlertDialogContentProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<AlertDialogContentEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<AlertDialogPortal>
<AlertDialogOverlay
data-slot="alert-dialog-overlay"
class="data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50"
/>
<AlertDialogContent
data-slot="alert-dialog-content"
v-bind="{ ...forwarded, ...$attrs }"
:class="
cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
props.class,
)
"
>
<slot />
</AlertDialogContent>
</AlertDialogPortal>
</template>
@@ -0,0 +1,22 @@
<script setup lang="ts">
import type { AlertDialogDescriptionProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { AlertDialogDescription, useForwardProps } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<AlertDialogDescriptionProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<AlertDialogDescription
data-slot="alert-dialog-description"
v-bind="forwardedProps"
:class="cn('text-muted-foreground text-sm', props.class)"
>
<slot />
</AlertDialogDescription>
</template>
@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{ class?: HTMLAttributes['class'] }>()
</script>
<template>
<div
data-slot="alert-dialog-footer"
:class="cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', props.class)"
>
<slot />
</div>
</template>
@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{ class?: HTMLAttributes['class'] }>()
</script>
<template>
<div
data-slot="alert-dialog-header"
:class="cn('flex flex-col gap-2 text-center sm:text-left', props.class)"
>
<slot />
</div>
</template>
@@ -0,0 +1,22 @@
<script setup lang="ts">
import type { AlertDialogTitleProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { AlertDialogTitle, useForwardProps } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<AlertDialogTitleProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<AlertDialogTitle
data-slot="alert-dialog-title"
v-bind="forwardedProps"
:class="cn('text-lg font-semibold', props.class)"
>
<slot />
</AlertDialogTitle>
</template>
+8
View File
@@ -0,0 +1,8 @@
export { default as AlertDialog } from './AlertDialog.vue'
export { default as AlertDialogAction } from './AlertDialogAction.vue'
export { default as AlertDialogCancel } from './AlertDialogCancel.vue'
export { default as AlertDialogContent } from './AlertDialogContent.vue'
export { default as AlertDialogDescription } from './AlertDialogDescription.vue'
export { default as AlertDialogFooter } from './AlertDialogFooter.vue'
export { default as AlertDialogHeader } from './AlertDialogHeader.vue'
export { default as AlertDialogTitle } from './AlertDialogTitle.vue'
+38 -1
View File
@@ -1,4 +1,4 @@
import type { TrackerState } from './types'
import type { ActionStatus, 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,40 @@ 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
}
/** Sends an alert to the webhook behind an action; returns the status the webhook reported, if any. */
export async function requestAction(
id: string,
repo: string,
url: string,
): Promise<ActionStatus | null> {
const res = await fetch(`/api/actions/${encodeURIComponent(id)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ repo, url }),
})
if (!res.ok) {
const body = (await res.json().catch(() => null)) as { error?: string } | null
throw new Error(body?.error ?? `action ${id} failed: HTTP ${res.status}`)
}
const status = (await res.json().catch(() => null)) as ActionStatus | null
return status && typeof status.state === 'string' ? status : null
}
+24
View File
@@ -24,6 +24,21 @@ export interface Repo {
release: Release | null
}
/** An operator-defined button, as listed by the server. An empty `kinds` matches every alert kind. */
export interface ActionDef {
id: string
label: string
confirm?: string
kinds: string[]
}
/** Status of an action for one alert; `link` is an http(s) address when present. */
export interface ActionStatus {
state: string
label?: string
link?: string
}
export interface Alert {
repo: string
kind: string
@@ -32,6 +47,7 @@ export interface Alert {
detail: string
url: string
since: string
actionStatus?: Record<string, ActionStatus>
}
export interface Thresholds {
@@ -54,16 +70,24 @@ export interface TrackerState {
thresholds: Thresholds
repos: Repo[]
alerts: Alert[]
actions?: ActionDef[]
actionsError?: string
}
/** 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 {
repo: string
kind: string
subject: string
detail: string
url: string
since: string
severity: Exclude<Severity, 'ok'>
actions?: ActionDef[]
actionStatus?: Record<string, ActionStatus>
}
export interface Block {
+140 -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,89 @@ 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('')
})
})
})
describe('runAction', () => {
beforeEach(() => setActivePinia(createPinia()))
afterEach(() => vi.unstubAllGlobals())
it('shows the server error and keeps no status when the webhook fails', async () => {
mockFetch((url) =>
url.startsWith('/api/actions/')
? json({ error: 'action webhook answered HTTP 500' }, 500)
: json(state()),
)
const store = useTrackerStore()
await store.runAction('rerun', 'o/r', 'u')
expect(store.notice).toContain('HTTP 500')
expect(store.localStatus).toEqual({})
expect(store.runningActions).toEqual([])
})
it('ignores a second press while the first request runs', async () => {
const fn = mockFetch((url) =>
url.startsWith('/api/actions/') ? json({ state: 'queued' }) : json(state()),
)
const store = useTrackerStore()
await Promise.all([store.runAction('rerun', 'o/r', 'u'), store.runAction('rerun', 'o/r', 'u')])
expect(fn.mock.calls.filter(([u]) => u.startsWith('/api/actions/'))).toHaveLength(1)
})
})
+78 -8
View File
@@ -1,6 +1,12 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { fetchState, RescanLimitedError, requestRefresh } from '@/lib/api'
import {
fetchState,
RescanLimitedError,
requestAction,
requestRefresh,
requestRepoRefresh,
} from '@/lib/api'
import {
activeFilterCount,
applyFilters,
@@ -11,7 +17,7 @@ import {
type Filters,
} from '@/lib/filters'
import { DATA_KINDS } from '@/lib/kinds'
import type { Block, Row, TrackerState } from '@/lib/types'
import type { ActionStatus, Block, Row, TrackerState } from '@/lib/types'
/** While a rescan runs the server takes ~40s, so check back often instead of every minute. */
export const SCAN_POLL_MS = 2000
@@ -19,7 +25,12 @@ 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
/** Identifies one action on one alert; the separators cannot occur in an id or a repo name. */
export const actionKey = (id: string, repo: string, url: string) => `${id}\n${repo}\n${url}`
/** 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,9 +43,15 @@ 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
}
const actions = (state.actions ?? []).filter(
(a) => a.kinds.length === 0 || a.kinds.includes(alert.kind),
)
rows.set(key, {
repo: alert.repo,
...(actions.length > 0 && { actions, actionStatus: alert.actionStatus }),
kind: alert.kind,
subject: alert.subject,
detail: alert.detail,
@@ -52,7 +69,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 +116,13 @@ 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[]>([])
/** Action keys with a request in flight. */
const runningActions = ref<string[]>([])
/** Status answered by a webhook, shown until the status list reports one. */
const localStatus = ref<Record<string, ActionStatus>>({})
const skewMs = ref(0)
const tick = ref(Date.now())
@@ -143,13 +168,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 +208,52 @@ 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)
}
}
/** Sends one alert to an action's webhook, then reloads the state through the existing poll path. */
async function runAction(id: string, repo: string, url: string): Promise<void> {
const key = actionKey(id, repo, url)
if (runningActions.value.includes(key)) return
runningActions.value = [...runningActions.value, key]
notice.value = ''
try {
const status = await requestAction(id, repo, url)
localStatus.value = { ...localStatus.value, [key]: status ?? { state: 'Requested' } }
await load()
} catch (e) {
notice.value = e instanceof Error ? e.message : String(e)
} finally {
runningActions.value = runningActions.value.filter((k) => k !== key)
}
}
return {
state,
error,
runningActions,
localStatus,
runAction,
loading,
notice,
announcement,
refreshingRepos,
scanning,
nowMs,
blocks,
@@ -201,6 +270,7 @@ export const useTrackerStore = defineStore('tracker', () => {
clearFilters,
load,
rescan,
refreshRepo,
advanceClock,
}
})