mirror of
https://github.com/lukaszraczylo/ghtracker.git
synced 2026-10-13 00:48:42 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c7c782d86 | ||
|
|
a56a12a6e0 |
@@ -33,6 +33,9 @@ type View struct {
|
|||||||
Warn int
|
Warn int
|
||||||
Loaded bool
|
Loaded bool
|
||||||
Refreshing bool
|
Refreshing bool
|
||||||
|
// ScanDone and ScanTotal count the repos of the running (or last) scan.
|
||||||
|
ScanDone int
|
||||||
|
ScanTotal int
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stats are monotonic counters and timings of the refresh loop.
|
// Stats are monotonic counters and timings of the refresh loop.
|
||||||
@@ -56,6 +59,8 @@ type Collector struct {
|
|||||||
th config.Thresholds
|
th config.Thresholds
|
||||||
interval time.Duration
|
interval time.Duration
|
||||||
refreshes atomic.Uint64
|
refreshes atomic.Uint64
|
||||||
|
scanDone atomic.Int64
|
||||||
|
scanTotal atomic.Int64
|
||||||
repoErrs atomic.Uint64
|
repoErrs atomic.Uint64
|
||||||
lastDur time.Duration
|
lastDur time.Duration
|
||||||
concurrency int
|
concurrency int
|
||||||
@@ -118,6 +123,8 @@ func (c *Collector) Refresh(ctx context.Context) {
|
|||||||
|
|
||||||
start := c.now()
|
start := c.now()
|
||||||
c.limited.Store(false)
|
c.limited.Store(false)
|
||||||
|
c.scanDone.Store(0)
|
||||||
|
c.scanTotal.Store(int64(len(c.names)))
|
||||||
results := make([]model.Repo, len(c.names))
|
results := make([]model.Repo, len(c.names))
|
||||||
sem := make(chan struct{}, c.concurrency)
|
sem := make(chan struct{}, c.concurrency)
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
@@ -128,6 +135,7 @@ func (c *Collector) Refresh(ctx context.Context) {
|
|||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
defer func() { <-sem }()
|
defer func() { <-sem }()
|
||||||
results[i] = c.refreshOne(ctx, name, start)
|
results[i] = c.refreshOne(ctx, name, start)
|
||||||
|
c.scanDone.Add(1)
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
@@ -193,6 +201,7 @@ func (c *Collector) View() View {
|
|||||||
v := View{
|
v := View{
|
||||||
Now: now, LastRefresh: c.lastRefresh, Interval: c.interval, Thresholds: c.th,
|
Now: now, LastRefresh: c.lastRefresh, Interval: c.interval, Thresholds: c.th,
|
||||||
Loaded: c.loaded, Refreshing: c.refreshing.Load(),
|
Loaded: c.loaded, Refreshing: c.refreshing.Load(),
|
||||||
|
ScanDone: int(c.scanDone.Load()), ScanTotal: int(c.scanTotal.Load()),
|
||||||
}
|
}
|
||||||
v.Repos = make([]model.Repo, 0, len(c.names))
|
v.Repos = make([]model.Repo, 0, len(c.names))
|
||||||
for _, n := range c.names {
|
for _, n := range c.names {
|
||||||
|
|||||||
@@ -312,3 +312,28 @@ func TestSummarizeWarningsMergesSharedReason(t *testing.T) {
|
|||||||
t.Fatalf("got %q", got)
|
t.Fatalf("got %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestScanProgressAdvancesAsReposFinish(t *testing.T) {
|
||||||
|
release := make(chan struct{})
|
||||||
|
started := make(chan struct{}, 4)
|
||||||
|
f := &fakeFetcher{fn: func(name string) (model.Repo, error) {
|
||||||
|
started <- struct{}{}
|
||||||
|
<-release
|
||||||
|
return model.Repo{FullName: name}, nil
|
||||||
|
}}
|
||||||
|
c := newCollector(f, "o/a", "o/b", "o/c")
|
||||||
|
if v := c.View(); v.ScanDone != 0 || v.ScanTotal != 0 {
|
||||||
|
t.Fatalf("before any scan: %+v", v)
|
||||||
|
}
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() { c.Refresh(context.Background()); close(done) }()
|
||||||
|
<-started
|
||||||
|
if v := c.View(); v.ScanTotal != 3 || v.ScanDone != 0 || !v.Refreshing {
|
||||||
|
t.Fatalf("during scan: done=%d total=%d refreshing=%v", v.ScanDone, v.ScanTotal, v.Refreshing)
|
||||||
|
}
|
||||||
|
close(release)
|
||||||
|
<-done
|
||||||
|
if v := c.View(); v.ScanDone != 3 || v.ScanTotal != 3 {
|
||||||
|
t.Fatalf("after scan: done=%d total=%d", v.ScanDone, v.ScanTotal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+41
-8
@@ -66,6 +66,8 @@ type TokenSource interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Client struct {
|
type Client struct {
|
||||||
|
// freshHC opens a new connection for every request.
|
||||||
|
freshHC *http.Client
|
||||||
rateReset time.Time
|
rateReset time.Time
|
||||||
blockedUntil time.Time
|
blockedUntil time.Time
|
||||||
auth TokenSource
|
auth TokenSource
|
||||||
@@ -88,7 +90,7 @@ type cachedResponse struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func NewClient(baseURL string, auth TokenSource, hc *http.Client, maxItems int) *Client {
|
func NewClient(baseURL string, auth TokenSource, hc *http.Client, maxItems int) *Client {
|
||||||
c := &Client{base: strings.TrimRight(baseURL, "/"), hc: hc, auth: auth, maxItems: maxItems, sleep: sleepCtx, now: time.Now, cache: map[string]cachedResponse{}}
|
c := &Client{base: strings.TrimRight(baseURL, "/"), hc: hc, freshHC: freshClient(hc), auth: auth, maxItems: maxItems, sleep: sleepCtx, now: time.Now, cache: map[string]cachedResponse{}}
|
||||||
c.rateRemaining.Store(-1)
|
c.rateRemaining.Store(-1)
|
||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
@@ -111,7 +113,9 @@ func sleepCtx(ctx context.Context, d time.Duration) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// do performs one authenticated GET, retrying transient failures, and returns the body and Link header.
|
// do performs one authenticated GET, retrying transient failures, and returns the body and Link header.
|
||||||
func (c *Client) do(ctx context.Context, repo, target string) ([]byte, string, error) {
|
// do performs one authenticated GET, retrying transient failures. fresh sends it on a new connection
|
||||||
|
// and skips the response cache, so a stale GitHub replica behind a reused connection cannot answer it.
|
||||||
|
func (c *Client) do(ctx context.Context, repo, target string, fresh bool) ([]byte, string, error) {
|
||||||
var lastErr error
|
var lastErr error
|
||||||
for attempt := 0; attempt <= maxRetries; attempt++ {
|
for attempt := 0; attempt <= maxRetries; attempt++ {
|
||||||
if attempt > 0 {
|
if attempt > 0 {
|
||||||
@@ -119,7 +123,7 @@ func (c *Client) do(ctx context.Context, repo, target string) ([]byte, string, e
|
|||||||
return nil, "", err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
body, link, retry, err := c.once(ctx, repo, target)
|
body, link, retry, err := c.once(ctx, repo, target, fresh)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return body, link, nil
|
return body, link, nil
|
||||||
}
|
}
|
||||||
@@ -131,7 +135,7 @@ func (c *Client) do(ctx context.Context, repo, target string) ([]byte, string, e
|
|||||||
return nil, "", lastErr
|
return nil, "", lastErr
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) once(ctx context.Context, repo, target string) (body []byte, link string, retry bool, err error) {
|
func (c *Client) once(ctx context.Context, repo, target string, fresh bool) (body []byte, link string, retry bool, err error) {
|
||||||
if gerr := c.guard(); gerr != nil {
|
if gerr := c.guard(); gerr != nil {
|
||||||
return nil, "", false, gerr
|
return nil, "", false, gerr
|
||||||
}
|
}
|
||||||
@@ -150,10 +154,15 @@ func (c *Client) once(ctx context.Context, repo, target string) (body []byte, li
|
|||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
cached, haveCache := c.cache[target]
|
cached, haveCache := c.cache[target]
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
|
haveCache = haveCache && !fresh
|
||||||
if haveCache {
|
if haveCache {
|
||||||
req.Header.Set("If-None-Match", cached.etag)
|
req.Header.Set("If-None-Match", cached.etag)
|
||||||
}
|
}
|
||||||
resp, err := c.hc.Do(req)
|
hc := c.hc
|
||||||
|
if fresh {
|
||||||
|
hc = c.freshHC
|
||||||
|
}
|
||||||
|
resp, err := hc.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", ctx.Err() == nil, err
|
return nil, "", ctx.Err() == nil, err
|
||||||
}
|
}
|
||||||
@@ -174,7 +183,7 @@ func (c *Client) once(ctx context.Context, repo, target string) (body []byte, li
|
|||||||
return nil, "", resp.StatusCode >= 500, ae
|
return nil, "", resp.StatusCode >= 500, ae
|
||||||
}
|
}
|
||||||
link = resp.Header.Get("Link")
|
link = resp.Header.Get("Link")
|
||||||
if etag := resp.Header.Get("ETag"); etag != "" {
|
if etag := resp.Header.Get("ETag"); etag != "" && !fresh {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
c.cache[target] = cachedResponse{etag: etag, body: data, link: link}
|
c.cache[target] = cachedResponse{etag: etag, body: data, link: link}
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
@@ -241,7 +250,16 @@ func (c *Client) url(path string, q url.Values) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) getJSON(ctx context.Context, repo, path string, q url.Values, out any) error {
|
func (c *Client) getJSON(ctx context.Context, repo, path string, q url.Values, out any) error {
|
||||||
body, _, err := c.do(ctx, repo, c.url(path, q))
|
return c.getJSONMode(ctx, repo, path, q, out, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getJSONFresh is getJSON on a new connection without the response cache.
|
||||||
|
func (c *Client) getJSONFresh(ctx context.Context, repo, path string, q url.Values, out any) error {
|
||||||
|
return c.getJSONMode(ctx, repo, path, q, out, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) getJSONMode(ctx context.Context, repo, path string, q url.Values, out any, fresh bool) error {
|
||||||
|
body, _, err := c.do(ctx, repo, c.url(path, q), fresh)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -257,7 +275,7 @@ func listAll[T any](ctx context.Context, c *Client, repo, path string, q url.Val
|
|||||||
next := c.url(path, q)
|
next := c.url(path, q)
|
||||||
var all []T
|
var all []T
|
||||||
for next != "" && len(all) < limit {
|
for next != "" && len(all) < limit {
|
||||||
body, link, err := c.do(ctx, repo, next)
|
body, link, err := c.do(ctx, repo, next, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return all, err
|
return all, err
|
||||||
}
|
}
|
||||||
@@ -289,3 +307,18 @@ func nextLink(h string) string {
|
|||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// freshClient copies hc but turns off connection reuse. Custom transports that are not an
|
||||||
|
// *http.Transport are used as they are.
|
||||||
|
func freshClient(hc *http.Client) *http.Client {
|
||||||
|
tr, ok := hc.Transport.(*http.Transport)
|
||||||
|
if !ok {
|
||||||
|
if hc.Transport != nil {
|
||||||
|
return hc
|
||||||
|
}
|
||||||
|
tr, _ = http.DefaultTransport.(*http.Transport)
|
||||||
|
}
|
||||||
|
clone := tr.Clone()
|
||||||
|
clone.DisableKeepAlives = true
|
||||||
|
return &http.Client{Transport: clone, Timeout: hc.Timeout}
|
||||||
|
}
|
||||||
|
|||||||
+49
-12
@@ -262,24 +262,28 @@ func (c *Client) workflows(ctx context.Context, full, branch string) ([]model.Wo
|
|||||||
if w.State != "active" || len(out) >= maxWorkflows {
|
if w.State != "active" || len(out) >= maxWorkflows {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
var runs struct {
|
runs, err := c.workflowRuns(ctx, full, w.ID, branch, false)
|
||||||
WorkflowRuns []rawRun `json:"workflow_runs"`
|
if err != nil {
|
||||||
}
|
|
||||||
q := url.Values{
|
|
||||||
"branch": {branch},
|
|
||||||
"exclude_pull_requests": {"true"},
|
|
||||||
"per_page": {fmt.Sprint(runsPerWorkflow)},
|
|
||||||
}
|
|
||||||
path := fmt.Sprintf("/repos/%s/actions/workflows/%d/runs", full, w.ID)
|
|
||||||
if err := c.getJSON(ctx, full, path, q, &runs); err != nil {
|
|
||||||
if first == nil {
|
if first == nil {
|
||||||
first = fmt.Errorf("%s: %w", w.Name, err)
|
first = fmt.Errorf("%s: %w", w.Name, err)
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if wf, ok := buildWorkflow(w.Name, w.HTMLURL, runs.WorkflowRuns); ok {
|
wf, ok := buildWorkflow(w.Name, w.HTMLURL, runs)
|
||||||
out = append(out, wf)
|
if !ok {
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
// GitHub sometimes answers a run listing from a lagging replica, and a reused connection
|
||||||
|
// keeps hitting it. A failure is what raises an alert, so confirm it on a new connection
|
||||||
|
// and keep the union of both answers.
|
||||||
|
if wf.LastDone != nil && wf.LastDone.Failed() {
|
||||||
|
if again, err := c.workflowRuns(ctx, full, w.ID, branch, true); err == nil {
|
||||||
|
if merged, ok := buildWorkflow(w.Name, w.HTMLURL, mergeRuns(runs, again)); ok {
|
||||||
|
wf = merged
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = append(out, wf)
|
||||||
}
|
}
|
||||||
return out, first
|
return out, first
|
||||||
}
|
}
|
||||||
@@ -343,3 +347,36 @@ func (c *Client) latestRelease(ctx context.Context, full, repoURL string) (*mode
|
|||||||
FromTag: true,
|
FromTag: true,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Client) workflowRuns(ctx context.Context, full string, id int64, branch string, fresh bool) ([]rawRun, error) {
|
||||||
|
var out struct {
|
||||||
|
WorkflowRuns []rawRun `json:"workflow_runs"`
|
||||||
|
}
|
||||||
|
q := url.Values{
|
||||||
|
"branch": {branch},
|
||||||
|
"exclude_pull_requests": {"true"},
|
||||||
|
"per_page": {fmt.Sprint(runsPerWorkflow)},
|
||||||
|
}
|
||||||
|
path := fmt.Sprintf("/repos/%s/actions/workflows/%d/runs", full, id)
|
||||||
|
get := c.getJSON
|
||||||
|
if fresh {
|
||||||
|
get = c.getJSONFresh
|
||||||
|
}
|
||||||
|
if err := get(ctx, full, path, q, &out); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out.WorkflowRuns, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// mergeRuns returns the runs of a and b once each, keyed by run ID.
|
||||||
|
func mergeRuns(a, b []rawRun) []rawRun {
|
||||||
|
seen := make(map[int64]bool, len(a)+len(b))
|
||||||
|
out := make([]rawRun, 0, len(a)+len(b))
|
||||||
|
for _, r := range slices.Concat(a, b) {
|
||||||
|
if !seen[r.ID] {
|
||||||
|
seen[r.ID] = true
|
||||||
|
out = append(out, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|||||||
@@ -459,3 +459,97 @@ func TestAPIErrorMessageParsing(t *testing.T) {
|
|||||||
t.Fatalf("len=%d", len(got))
|
t.Fatalf("len=%d", len(got))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// staleThenFresh serves an old failing run on the first request and the current runs afterwards,
|
||||||
|
// like a lagging GitHub replica followed by a healthy one.
|
||||||
|
func staleThenFresh(f *fakeGitHub, path string) *atomic.Int64 {
|
||||||
|
var calls atomic.Int64
|
||||||
|
f.routes[path] = func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
n := calls.Add(1)
|
||||||
|
if n == 1 {
|
||||||
|
_, _ = w.Write([]byte(`{"workflow_runs":[
|
||||||
|
{"id":1,"html_url":"old","event":"schedule","head_branch":"main","status":"completed","conclusion":"failure","created_at":"2026-09-11T03:02:00Z"}]}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Header.Get("If-None-Match") != "" {
|
||||||
|
http.Error(w, "fresh requests must not be conditional", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(`{"workflow_runs":[
|
||||||
|
{"id":3,"html_url":"new","event":"schedule","head_branch":"main","status":"completed","conclusion":"success","created_at":"2026-10-02T03:03:00Z"},
|
||||||
|
{"id":1,"html_url":"old","event":"schedule","head_branch":"main","status":"completed","conclusion":"failure","created_at":"2026-09-11T03:02:00Z"}]}`))
|
||||||
|
}
|
||||||
|
return &calls
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStaleFailureIsConfirmedOnANewConnection(t *testing.T) {
|
||||||
|
f := newFake(t)
|
||||||
|
c := f.client(t, 10)
|
||||||
|
f.json("/repos/o/r/actions/workflows", `{"workflows":[{"id":7,"name":"Autoupdate","html_url":"w","state":"active"}]}`)
|
||||||
|
calls := staleThenFresh(f, "/repos/o/r/actions/workflows/7/runs")
|
||||||
|
wfs, err := c.workflows(context.Background(), "o/r", "main")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(wfs) != 1 || wfs[0].Latest.ID != 3 || wfs[0].LastDone == nil || wfs[0].LastDone.Failed() {
|
||||||
|
t.Fatalf("the newer success must supersede the stale failure: %+v", wfs)
|
||||||
|
}
|
||||||
|
if calls.Load() != 2 {
|
||||||
|
t.Fatalf("runs requested %d times, want 2", calls.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHealthyWorkflowIsNotRequestedTwice(t *testing.T) {
|
||||||
|
f := newFake(t)
|
||||||
|
c := f.client(t, 10)
|
||||||
|
f.json("/repos/o/r/actions/workflows", `{"workflows":[{"id":7,"name":"CI","html_url":"w","state":"active"}]}`)
|
||||||
|
var calls atomic.Int64
|
||||||
|
f.routes["/repos/o/r/actions/workflows/7/runs"] = func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
calls.Add(1)
|
||||||
|
_, _ = w.Write([]byte(`{"workflow_runs":[{"id":3,"event":"push","head_branch":"main","status":"completed","conclusion":"success","created_at":"2026-10-02T03:03:00Z"}]}`))
|
||||||
|
}
|
||||||
|
if _, err := c.workflows(context.Background(), "o/r", "main"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if calls.Load() != 1 {
|
||||||
|
t.Fatalf("a passing workflow needs one request, got %d", calls.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfirmedFailureStaysFailing(t *testing.T) {
|
||||||
|
f := newFake(t)
|
||||||
|
c := f.client(t, 10)
|
||||||
|
f.json("/repos/o/r/actions/workflows", `{"workflows":[{"id":7,"name":"CI","html_url":"w","state":"active"}]}`)
|
||||||
|
f.json("/repos/o/r/actions/workflows/7/runs", `{"workflow_runs":[{"id":3,"event":"push","head_branch":"main","status":"completed","conclusion":"failure","created_at":"2026-10-02T03:03:00Z"}]}`)
|
||||||
|
wfs, err := c.workflows(context.Background(), "o/r", "main")
|
||||||
|
if err != nil || len(wfs) != 1 || wfs[0].LastDone == nil || !wfs[0].LastDone.Failed() {
|
||||||
|
t.Fatalf("a failure that both answers agree on must stay: %+v %v", wfs, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergeRunsKeepsEachRunOnce(t *testing.T) {
|
||||||
|
got := mergeRuns([]rawRun{{ID: 1}, {ID: 2}}, []rawRun{{ID: 2}, {ID: 3}})
|
||||||
|
if len(got) != 3 || got[0].ID != 1 || got[1].ID != 2 || got[2].ID != 3 {
|
||||||
|
t.Fatalf("%+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFreshClientDisablesConnectionReuse(t *testing.T) {
|
||||||
|
base := &http.Client{Transport: &http.Transport{}, Timeout: 5 * time.Second}
|
||||||
|
fc := freshClient(base)
|
||||||
|
tr, ok := fc.Transport.(*http.Transport)
|
||||||
|
if !ok || !tr.DisableKeepAlives || fc.Timeout != 5*time.Second {
|
||||||
|
t.Fatalf("fresh client: %+v", fc)
|
||||||
|
}
|
||||||
|
if base.Transport.(*http.Transport).DisableKeepAlives {
|
||||||
|
t.Fatal("the original client must keep its connections")
|
||||||
|
}
|
||||||
|
custom := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { return nil, errors.New("x") })}
|
||||||
|
if freshClient(custom) != custom {
|
||||||
|
t.Fatal("non-standard transports are used as they are")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||||
|
|
||||||
|
func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ type apiState struct {
|
|||||||
Alerts []apiAlert `json:"alerts"`
|
Alerts []apiAlert `json:"alerts"`
|
||||||
Thresholds apiThresholds `json:"thresholds"`
|
Thresholds apiThresholds `json:"thresholds"`
|
||||||
Counts apiCounts `json:"counts"`
|
Counts apiCounts `json:"counts"`
|
||||||
|
Scan apiScan `json:"scan"`
|
||||||
IntervalSeconds int64 `json:"intervalSeconds"`
|
IntervalSeconds int64 `json:"intervalSeconds"`
|
||||||
Loaded bool `json:"loaded"`
|
Loaded bool `json:"loaded"`
|
||||||
Refreshing bool `json:"refreshing"`
|
Refreshing bool `json:"refreshing"`
|
||||||
@@ -30,6 +31,12 @@ type apiThresholds struct {
|
|||||||
WorkflowDormant int64 `json:"workflowDormantSeconds"`
|
WorkflowDormant int64 `json:"workflowDormantSeconds"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// apiScan reports how far the running scan has got, for the loading screen.
|
||||||
|
type apiScan struct {
|
||||||
|
Done int `json:"done"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
}
|
||||||
|
|
||||||
type apiCounts struct {
|
type apiCounts struct {
|
||||||
Crit int `json:"crit"`
|
Crit int `json:"crit"`
|
||||||
Warn int `json:"warn"`
|
Warn int `json:"warn"`
|
||||||
@@ -98,6 +105,7 @@ func buildState(v collector.View, manualRefresh bool) apiState {
|
|||||||
Refreshing: v.Refreshing,
|
Refreshing: v.Refreshing,
|
||||||
ManualRefresh: manualRefresh,
|
ManualRefresh: manualRefresh,
|
||||||
Counts: apiCounts{Crit: v.Crit, Warn: v.Warn},
|
Counts: apiCounts{Crit: v.Crit, Warn: v.Warn},
|
||||||
|
Scan: apiScan{Done: v.ScanDone, Total: v.ScanTotal},
|
||||||
Thresholds: apiThresholds{
|
Thresholds: apiThresholds{
|
||||||
PRWarnSeconds: seconds(v.Thresholds.PRWarnAfter),
|
PRWarnSeconds: seconds(v.Thresholds.PRWarnAfter),
|
||||||
PRCritSeconds: seconds(v.Thresholds.PRCritAfter),
|
PRCritSeconds: seconds(v.Thresholds.PRCritAfter),
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ func testView() collector.View {
|
|||||||
}
|
}
|
||||||
bare := model.Repo{FullName: "o/bare", URL: "https://github.com/o/bare", ReleasesURL: "https://github.com/o/bare/releases", Err: "boom"}
|
bare := model.Repo{FullName: "o/bare", URL: "https://github.com/o/bare", ReleasesURL: "https://github.com/o/bare/releases", Err: "boom"}
|
||||||
return collector.View{
|
return collector.View{
|
||||||
Now: now, LastRefresh: now.Add(-time.Hour), Interval: 6 * time.Hour, Loaded: true, Crit: 1, Warn: 1,
|
Now: now, LastRefresh: now.Add(-time.Hour), Interval: 6 * time.Hour, Loaded: true, Crit: 1, Warn: 1, ScanDone: 5, ScanTotal: 9,
|
||||||
Thresholds: config.Thresholds{PRWarnAfter: 48 * time.Hour, PRCritAfter: 168 * time.Hour, IssueStaleAfter: 720 * time.Hour},
|
Thresholds: config.Thresholds{PRWarnAfter: 48 * time.Hour, PRCritAfter: 168 * time.Hour, IssueStaleAfter: 720 * time.Hour},
|
||||||
Repos: []model.Repo{repo, bare},
|
Repos: []model.Repo{repo, bare},
|
||||||
Alerts: []model.Alert{
|
Alerts: []model.Alert{
|
||||||
@@ -125,6 +125,7 @@ func TestStateJSONContract(t *testing.T) {
|
|||||||
Repos []map[string]any
|
Repos []map[string]any
|
||||||
Alerts []map[string]any
|
Alerts []map[string]any
|
||||||
Counts struct{ Crit, Warn int }
|
Counts struct{ Crit, Warn int }
|
||||||
|
Scan struct{ Done, Total int }
|
||||||
Thresholds struct{ PRWarnSeconds, PRCritSeconds int64 }
|
Thresholds struct{ PRWarnSeconds, PRCritSeconds int64 }
|
||||||
IntervalSeconds int64
|
IntervalSeconds int64
|
||||||
Loaded bool
|
Loaded bool
|
||||||
@@ -133,6 +134,9 @@ func TestStateJSONContract(t *testing.T) {
|
|||||||
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
if got.Scan.Done != 5 || got.Scan.Total != 9 {
|
||||||
|
t.Fatalf("scan: %+v", got.Scan)
|
||||||
|
}
|
||||||
if got.IntervalSeconds != 21600 || !got.Loaded || !got.ManualRefresh || got.Counts.Crit != 1 || got.Counts.Warn != 1 {
|
if got.IntervalSeconds != 21600 || !got.Loaded || !got.ManualRefresh || got.Counts.Crit != 1 || got.Counts.Warn != 1 {
|
||||||
t.Fatalf("top level: %+v", got)
|
t.Fatalf("top level: %+v", got)
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -68,9 +68,10 @@ describe('App', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('waits while the server has not finished its first refresh', async () => {
|
it('waits while the server has not finished its first refresh', async () => {
|
||||||
const w = mountWith(state({ loaded: false }))
|
const w = mountWith(state({ loaded: false, scan: { done: 4, total: 10 } }))
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
expect(w.find('[data-test=waiting]').exists()).toBe(true)
|
expect(w.find('[data-test=waiting]').exists()).toBe(true)
|
||||||
|
expect(w.get('[data-test=loading-detail]').text()).toContain('4 of 10 scanned')
|
||||||
expect(w.find('[data-test=headline]').exists()).toBe(false)
|
expect(w.find('[data-test=headline]').exists()).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
+16
-10
@@ -3,6 +3,7 @@ import { storeToRefs } from 'pinia'
|
|||||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
import AppHeader from '@/components/AppHeader.vue'
|
import AppHeader from '@/components/AppHeader.vue'
|
||||||
import AttentionList from '@/components/AttentionList.vue'
|
import AttentionList from '@/components/AttentionList.vue'
|
||||||
|
import LoadingScreen from '@/components/LoadingScreen.vue'
|
||||||
import FleetOverview from '@/components/FleetOverview.vue'
|
import FleetOverview from '@/components/FleetOverview.vue'
|
||||||
import ProjectList from '@/components/ProjectList.vue'
|
import ProjectList from '@/components/ProjectList.vue'
|
||||||
import { filtersFromSearch, filtersToSearch } from '@/lib/filters'
|
import { filtersFromSearch, filtersToSearch } from '@/lib/filters'
|
||||||
@@ -26,9 +27,15 @@ const {
|
|||||||
visibleRepos,
|
visibleRepos,
|
||||||
activeFilters,
|
activeFilters,
|
||||||
repoCounts,
|
repoCounts,
|
||||||
|
rowCounts,
|
||||||
facts,
|
facts,
|
||||||
} = storeToRefs(store)
|
} = storeToRefs(store)
|
||||||
const list = ref<InstanceType<typeof AttentionList> | null>(null)
|
const list = ref<InstanceType<typeof AttentionList> | null>(null)
|
||||||
|
const progress = computed(() =>
|
||||||
|
state.value && scanning.value && state.value.scan.total > 0
|
||||||
|
? `${state.value.scan.done}/${state.value.scan.total}`
|
||||||
|
: '',
|
||||||
|
)
|
||||||
const matching = computed(() => new Set(visibleRepos.value.map((r) => r.fullName)))
|
const matching = computed(() => new Set(visibleRepos.value.map((r) => r.fullName)))
|
||||||
let timers: number[] = []
|
let timers: number[] = []
|
||||||
|
|
||||||
@@ -71,6 +78,7 @@ onBeforeUnmount(() => {
|
|||||||
:interval-seconds="state?.intervalSeconds ?? 0"
|
:interval-seconds="state?.intervalSeconds ?? 0"
|
||||||
:loaded="state?.loaded ?? false"
|
:loaded="state?.loaded ?? false"
|
||||||
:scanning="scanning"
|
:scanning="scanning"
|
||||||
|
:progress="progress"
|
||||||
:manual-refresh="state?.manualRefresh ?? false"
|
:manual-refresh="state?.manualRefresh ?? false"
|
||||||
@rescan="store.rescan()"
|
@rescan="store.rescan()"
|
||||||
/>
|
/>
|
||||||
@@ -83,19 +91,17 @@ onBeforeUnmount(() => {
|
|||||||
{{ notice }}
|
{{ notice }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p v-if="!state" class="text-muted-foreground mt-16 text-3xl font-semibold">Loading</p>
|
<LoadingScreen
|
||||||
<p
|
v-if="!state || !state.loaded"
|
||||||
v-else-if="!state.loaded"
|
:done="state?.scan.done ?? 0"
|
||||||
class="text-muted-foreground mt-16 text-3xl font-semibold"
|
:total="state?.scan.total ?? 0"
|
||||||
data-test="waiting"
|
:connecting="!state"
|
||||||
>
|
/>
|
||||||
Fetching data from GitHub
|
|
||||||
</p>
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="mt-8 sm:mt-12">
|
<div class="mt-8 sm:mt-12">
|
||||||
<FleetOverview
|
<FleetOverview
|
||||||
:crit="state.counts.crit"
|
:crit="rowCounts.crit"
|
||||||
:warn="state.counts.warn"
|
:warn="rowCounts.warn"
|
||||||
:block-count="blocks.length"
|
:block-count="blocks.length"
|
||||||
:repos="state.repos"
|
:repos="state.repos"
|
||||||
:counts="repoCounts"
|
:counts="repoCounts"
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const props = defineProps<{
|
|||||||
intervalSeconds: number
|
intervalSeconds: number
|
||||||
loaded: boolean
|
loaded: boolean
|
||||||
scanning: boolean
|
scanning: boolean
|
||||||
|
progress: string
|
||||||
manualRefresh: boolean
|
manualRefresh: boolean
|
||||||
}>()
|
}>()
|
||||||
defineEmits<{ rescan: [] }>()
|
defineEmits<{ rescan: [] }>()
|
||||||
@@ -56,8 +57,10 @@ const nextIn = computed(() => {
|
|||||||
@click="$emit('rescan')"
|
@click="$emit('rescan')"
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon :icon="faArrowsRotate" :spin="scanning" aria-hidden="true" />
|
<FontAwesomeIcon :icon="faArrowsRotate" :spin="scanning" aria-hidden="true" />
|
||||||
<span class="hidden sm:inline">{{ scanning ? 'Rescanning…' : 'Rescan repositories' }}</span>
|
<span class="hidden sm:inline">{{
|
||||||
<span class="sm:hidden">{{ scanning ? 'Scanning…' : 'Rescan' }}</span>
|
scanning ? `Scanning ${progress}`.trim() : 'Rescan repositories'
|
||||||
|
}}</span>
|
||||||
|
<span class="sm:hidden">{{ scanning ? progress || 'Scanning…' : 'Rescan' }}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { faCircleCheck } from '@fortawesome/free-solid-svg-icons'
|
import { faCircleCheck } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
|
||||||
import { ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import FilterBar from '@/components/FilterBar.vue'
|
import FilterBar from '@/components/FilterBar.vue'
|
||||||
import RepoGroup from '@/components/RepoGroup.vue'
|
import RepoGroup from '@/components/RepoGroup.vue'
|
||||||
|
import { useColumns } from '@/composables/useColumns'
|
||||||
|
import { balanceColumns } from '@/lib/layout'
|
||||||
import type { Facets, Filters } from '@/lib/filters'
|
import type { Facets, Filters } from '@/lib/filters'
|
||||||
import type { Block } from '@/lib/types'
|
import type { Block } from '@/lib/types'
|
||||||
|
|
||||||
defineProps<{
|
const props = defineProps<{
|
||||||
blocks: Block[]
|
blocks: Block[]
|
||||||
nowMs: number
|
nowMs: number
|
||||||
critSeconds: number
|
critSeconds: number
|
||||||
@@ -18,6 +20,12 @@ defineProps<{
|
|||||||
const filters = defineModel<Filters>('filters', { required: true })
|
const filters = defineModel<Filters>('filters', { required: true })
|
||||||
defineEmits<{ clear: [] }>()
|
defineEmits<{ clear: [] }>()
|
||||||
|
|
||||||
|
const columnCount = useColumns()
|
||||||
|
// A group's height is its header plus its rows, so rows+2 approximates it.
|
||||||
|
const columns = computed(() =>
|
||||||
|
balanceColumns(props.blocks, columnCount.value, (b) => b.rows.length + 2),
|
||||||
|
)
|
||||||
|
|
||||||
const bar = ref<InstanceType<typeof FilterBar> | null>(null)
|
const bar = ref<InstanceType<typeof FilterBar> | null>(null)
|
||||||
defineExpose({ focusSearch: () => bar.value?.focus() })
|
defineExpose({ focusSearch: () => bar.value?.focus() })
|
||||||
</script>
|
</script>
|
||||||
@@ -43,14 +51,16 @@ defineExpose({ focusSearch: () => bar.value?.focus() })
|
|||||||
<FontAwesomeIcon :icon="faCircleCheck" class="text-ok text-xl" aria-hidden="true" />
|
<FontAwesomeIcon :icon="faCircleCheck" class="text-ok text-xl" aria-hidden="true" />
|
||||||
Nothing matches the current filters.
|
Nothing matches the current filters.
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="grid grid-cols-[minmax(0,1fr)] items-start gap-4 2xl:grid-cols-2">
|
<div v-else class="flex items-start gap-4" data-test="columns">
|
||||||
<RepoGroup
|
<div v-for="(column, i) in columns" :key="i" class="flex min-w-0 flex-1 flex-col gap-4">
|
||||||
v-for="block in blocks"
|
<RepoGroup
|
||||||
:key="block.repo.fullName"
|
v-for="block in column"
|
||||||
:block="block"
|
:key="block.repo.fullName"
|
||||||
:now-ms="nowMs"
|
:block="block"
|
||||||
:crit-seconds="critSeconds"
|
:now-ms="nowMs"
|
||||||
/>
|
:crit-seconds="critSeconds"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -56,10 +56,7 @@ const rows = computed<{ label: string; value: number; icon: IconDefinition; tone
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section
|
<section class="flex flex-col gap-7 sm:gap-8" aria-label="Fleet status">
|
||||||
class="grid grid-cols-[minmax(0,1fr)] items-end gap-x-14 gap-y-8 lg:grid-cols-[minmax(0,1fr)_22rem]"
|
|
||||||
aria-label="Fleet status"
|
|
||||||
>
|
|
||||||
<div>
|
<div>
|
||||||
<p class="text-muted-foreground mb-3 text-sm" data-test="scope">
|
<p class="text-muted-foreground mb-3 text-sm" data-test="scope">
|
||||||
<template v-if="blockCount > 0"
|
<template v-if="blockCount > 0"
|
||||||
@@ -68,7 +65,7 @@ const rows = computed<{ label: string; value: number; icon: IconDefinition; tone
|
|||||||
<template v-else>All {{ repos.length }} projects are healthy</template>
|
<template v-else>All {{ repos.length }} projects are healthy</template>
|
||||||
</p>
|
</p>
|
||||||
<h1
|
<h1
|
||||||
class="flex flex-wrap items-baseline gap-x-6 gap-y-1 sm:gap-x-10 font-semibold tracking-tight"
|
class="flex flex-wrap items-baseline gap-x-6 gap-y-1 font-semibold tracking-tight sm:gap-x-10"
|
||||||
data-test="headline"
|
data-test="headline"
|
||||||
>
|
>
|
||||||
<template v-if="crit > 0 || warn > 0">
|
<template v-if="crit > 0 || warn > 0">
|
||||||
@@ -83,35 +80,45 @@ const rows = computed<{ label: string; value: number; icon: IconDefinition; tone
|
|||||||
</template>
|
</template>
|
||||||
<span v-else class="text-ok text-5xl leading-none sm:text-7xl">All clear</span>
|
<span v-else class="text-ok text-5xl leading-none sm:text-7xl">All clear</span>
|
||||||
</h1>
|
</h1>
|
||||||
<div class="mt-6 sm:mt-8">
|
|
||||||
<FleetStrip
|
|
||||||
:repos="repos"
|
|
||||||
:counts="counts"
|
|
||||||
:focused="focused"
|
|
||||||
:matching="matching"
|
|
||||||
@select="$emit('select', $event)"
|
|
||||||
/>
|
|
||||||
<p class="text-muted-foreground mt-3 flex flex-wrap items-center gap-x-5 gap-y-1 text-xs">
|
|
||||||
<span class="flex items-center gap-1.5"
|
|
||||||
><span class="bg-crit size-2 rounded-[2px]"></span>critical</span
|
|
||||||
>
|
|
||||||
<span class="flex items-center gap-1.5"
|
|
||||||
><span class="bg-warn size-2 rounded-[2px]"></span>warning</span
|
|
||||||
>
|
|
||||||
<span class="flex items-center gap-1.5"
|
|
||||||
><span class="bg-ok/40 size-2 rounded-[2px]"></span>healthy</span
|
|
||||||
>
|
|
||||||
<span class="sm:ml-auto">Tap a segment to focus a project</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<dl class="border-border divide-border divide-y border-y text-sm" data-test="facts">
|
<div>
|
||||||
<div v-for="row in rows" :key="row.label" class="flex items-center gap-3 py-2.5">
|
<FleetStrip
|
||||||
<FontAwesomeIcon :icon="row.icon" class="text-muted-foreground w-4" aria-hidden="true" />
|
:repos="repos"
|
||||||
<dt class="flex-1">{{ row.label }}</dt>
|
:counts="counts"
|
||||||
|
:focused="focused"
|
||||||
|
:matching="matching"
|
||||||
|
@select="$emit('select', $event)"
|
||||||
|
/>
|
||||||
|
<p class="text-muted-foreground mt-3 flex flex-wrap items-center gap-x-5 gap-y-1 text-xs">
|
||||||
|
<span class="flex items-center gap-1.5"
|
||||||
|
><span class="bg-crit size-2 rounded-[2px]"></span>critical</span
|
||||||
|
>
|
||||||
|
<span class="flex items-center gap-1.5"
|
||||||
|
><span class="bg-warn size-2 rounded-[2px]"></span>warning</span
|
||||||
|
>
|
||||||
|
<span class="flex items-center gap-1.5"
|
||||||
|
><span class="bg-ok/40 size-2 rounded-[2px]"></span>healthy</span
|
||||||
|
>
|
||||||
|
<span class="sm:ml-auto">Tap a segment to focus a project</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl
|
||||||
|
class="border-border grid grid-cols-2 gap-x-6 border-t sm:grid-cols-3 lg:grid-cols-5"
|
||||||
|
data-test="facts"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="row in rows"
|
||||||
|
:key="row.label"
|
||||||
|
class="border-border border-b py-3 last:col-span-2 sm:last:col-span-1 lg:border-b-0"
|
||||||
|
>
|
||||||
|
<dt class="text-muted-foreground flex items-center gap-2 text-xs">
|
||||||
|
<FontAwesomeIcon :icon="row.icon" class="w-3.5" aria-hidden="true" />
|
||||||
|
{{ row.label }}
|
||||||
|
</dt>
|
||||||
<dd
|
<dd
|
||||||
class="font-mono text-base tabular-nums"
|
class="mt-1 font-mono text-3xl tabular-nums"
|
||||||
:class="row.value > 0 ? row.tone : 'text-muted-foreground'"
|
:class="row.value > 0 ? row.tone : 'text-muted-foreground'"
|
||||||
>
|
>
|
||||||
{{ row.value }}
|
{{ row.value }}
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { faSatelliteDish } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { Progress } from '@/components/ui/progress'
|
||||||
|
|
||||||
|
const props = defineProps<{ done: number; total: number; connecting: boolean }>()
|
||||||
|
|
||||||
|
// Static class names so Tailwind can see every delay; cells cycle through them for a wave effect.
|
||||||
|
const DELAYS = [
|
||||||
|
'[animation-delay:0ms]',
|
||||||
|
'[animation-delay:140ms]',
|
||||||
|
'[animation-delay:280ms]',
|
||||||
|
'[animation-delay:420ms]',
|
||||||
|
'[animation-delay:560ms]',
|
||||||
|
'[animation-delay:700ms]',
|
||||||
|
]
|
||||||
|
const delay = (i: number) => DELAYS[i % DELAYS.length]
|
||||||
|
|
||||||
|
const MIN_CELLS = 12
|
||||||
|
const MAX_CELLS = 63
|
||||||
|
const cells = computed(() => Math.min(MAX_CELLS, Math.max(MIN_CELLS, props.total)))
|
||||||
|
const percent = computed(() => (props.total > 0 ? Math.round((props.done / props.total) * 100) : 0))
|
||||||
|
const known = computed(() => !props.connecting && props.total > 0)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section role="status" aria-live="polite" class="mt-10 sm:mt-16" data-test="waiting">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<span
|
||||||
|
class="border-border bg-surface text-link relative flex size-12 items-center justify-center rounded-lg border"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="border-link/50 absolute inset-0 rounded-lg border motion-safe:animate-ping"
|
||||||
|
></span>
|
||||||
|
<FontAwesomeIcon :icon="faSatelliteDish" class="text-xl" aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<h1 class="text-2xl font-semibold tracking-tight sm:text-3xl" data-test="loading-title">
|
||||||
|
{{ connecting ? 'Connecting' : 'Scanning repositories' }}
|
||||||
|
</h1>
|
||||||
|
<p class="text-muted-foreground mt-0.5 text-sm" data-test="loading-detail">
|
||||||
|
<template v-if="known">{{ done }} of {{ total }} scanned</template>
|
||||||
|
<template v-else-if="connecting">Waiting for the server</template>
|
||||||
|
<template v-else>Contacting GitHub</template>
|
||||||
|
<span class="hidden sm:inline"> · the first scan can take a minute or two</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-7 max-w-2xl">
|
||||||
|
<Progress v-if="known" :model-value="percent" class="h-1.5" aria-label="Scan progress" />
|
||||||
|
<div v-else class="bg-secondary h-1.5 w-full overflow-hidden rounded-full">
|
||||||
|
<div class="bg-primary h-full w-1/3 rounded-full motion-safe:animate-pulse"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-10" aria-hidden="true">
|
||||||
|
<div class="flex items-baseline gap-8">
|
||||||
|
<div
|
||||||
|
class="bg-secondary h-14 w-28 rounded-md motion-safe:animate-pulse sm:h-[4.5rem] sm:w-36"
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
class="bg-secondary h-14 w-28 rounded-md [animation-delay:200ms] motion-safe:animate-pulse sm:h-[4.5rem] sm:w-36"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-8 grid grid-cols-[repeat(auto-fill,minmax(1.5rem,1fr))] gap-1">
|
||||||
|
<div
|
||||||
|
v-for="i in cells"
|
||||||
|
:key="i"
|
||||||
|
class="bg-secondary h-9 rounded-[3px] motion-safe:animate-pulse sm:h-11"
|
||||||
|
:class="delay(i)"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="border-border mt-7 grid grid-cols-2 gap-x-6 border-t pt-3 sm:grid-cols-3 lg:grid-cols-5"
|
||||||
|
>
|
||||||
|
<div v-for="i in 5" :key="i" class="py-2">
|
||||||
|
<div
|
||||||
|
class="bg-secondary h-3 w-24 rounded motion-safe:animate-pulse"
|
||||||
|
:class="delay(i)"
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
class="bg-secondary mt-2 h-7 w-12 rounded motion-safe:animate-pulse"
|
||||||
|
:class="delay(i + 1)"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="mt-10 grid grid-cols-[minmax(0,1fr)] gap-4 lg:grid-cols-[minmax(0,1fr)_24rem]"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div v-for="g in 3" :key="g" class="border-border bg-surface rounded-lg border p-4">
|
||||||
|
<div
|
||||||
|
class="bg-secondary h-5 w-48 rounded motion-safe:animate-pulse"
|
||||||
|
:class="delay(g)"
|
||||||
|
></div>
|
||||||
|
<div v-for="r in 2" :key="r" class="mt-4 flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
class="bg-secondary size-8 shrink-0 rounded-md motion-safe:animate-pulse"
|
||||||
|
:class="delay(g + r)"
|
||||||
|
></div>
|
||||||
|
<div class="flex-1 space-y-2">
|
||||||
|
<div
|
||||||
|
class="bg-secondary h-4 w-2/3 rounded motion-safe:animate-pulse"
|
||||||
|
:class="delay(g + r)"
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
class="bg-secondary h-3 w-1/3 rounded motion-safe:animate-pulse"
|
||||||
|
:class="delay(g + r + 1)"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="border-border bg-surface hidden rounded-lg border p-3 lg:block">
|
||||||
|
<div v-for="i in 6" :key="i" class="py-2.5">
|
||||||
|
<div
|
||||||
|
class="bg-secondary h-4 w-3/4 rounded motion-safe:animate-pulse"
|
||||||
|
:class="delay(i)"
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
class="bg-secondary mt-2 h-3 w-1/2 rounded motion-safe:animate-pulse"
|
||||||
|
:class="delay(i + 1)"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -1,12 +1,63 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { faCodePullRequest, faCircleDot, faGears } from '@fortawesome/free-solid-svg-icons'
|
import { faCodePullRequest, faCircleDot, faGears } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
|
||||||
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
import { ageSince, isZeroTime } from '@/lib/format'
|
import { ageSince, isZeroTime } from '@/lib/format'
|
||||||
import type { Repo } from '@/lib/types'
|
import type { Repo } from '@/lib/types'
|
||||||
|
|
||||||
defineProps<{ repos: Repo[]; total: number; focused: string | null; nowMs: number }>()
|
const props = defineProps<{ repos: Repo[]; total: number; focused: string | null; nowMs: number }>()
|
||||||
defineEmits<{ select: [fullName: string] }>()
|
defineEmits<{ select: [fullName: string] }>()
|
||||||
|
|
||||||
|
/** Rows rendered at first and added each time the list is scrolled near its end. */
|
||||||
|
const PAGE_SIZE = 20
|
||||||
|
|
||||||
|
const shown = ref(PAGE_SIZE)
|
||||||
|
const visible = computed(() => props.repos.slice(0, shown.value))
|
||||||
|
const hasMore = computed(() => shown.value < props.repos.length)
|
||||||
|
|
||||||
|
const scroller = ref<HTMLElement | null>(null)
|
||||||
|
const sentinel = ref<HTMLElement | null>(null)
|
||||||
|
let observer: IntersectionObserver | null = null
|
||||||
|
|
||||||
|
function loadMore(): void {
|
||||||
|
if (hasMore.value) shown.value += PAGE_SIZE
|
||||||
|
}
|
||||||
|
|
||||||
|
// A new search or filter starts the list over from the top.
|
||||||
|
watch(
|
||||||
|
() => props.repos,
|
||||||
|
() => {
|
||||||
|
shown.value = PAGE_SIZE
|
||||||
|
if (scroller.value) scroller.value.scrollTop = 0
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// Observe the end marker; a fresh marker element is rendered while more rows remain.
|
||||||
|
watch(
|
||||||
|
sentinel,
|
||||||
|
(el, prev) => {
|
||||||
|
if (prev) observer?.unobserve(prev)
|
||||||
|
if (el) observer?.observe(el)
|
||||||
|
},
|
||||||
|
{ flush: 'post' },
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await nextTick()
|
||||||
|
if (typeof IntersectionObserver === 'undefined') {
|
||||||
|
shown.value = props.repos.length
|
||||||
|
return
|
||||||
|
}
|
||||||
|
observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
if (entries.some((e) => e.isIntersecting)) loadMore()
|
||||||
|
},
|
||||||
|
{ root: scroller.value, rootMargin: '200px' },
|
||||||
|
)
|
||||||
|
if (sentinel.value) observer.observe(sentinel.value)
|
||||||
|
})
|
||||||
|
onBeforeUnmount(() => observer?.disconnect())
|
||||||
|
|
||||||
const DOT = { crit: 'bg-crit', warn: 'bg-warn', ok: 'bg-ok' } as const
|
const DOT = { crit: 'bg-crit', warn: 'bg-warn', ok: 'bg-ok' } as const
|
||||||
const LABEL = { crit: 'Critical', warn: 'Warning', ok: 'Healthy' } as const
|
const LABEL = { crit: 'Critical', warn: 'Warning', ok: 'Healthy' } as const
|
||||||
</script>
|
</script>
|
||||||
@@ -28,10 +79,11 @@ const LABEL = { crit: 'Critical', warn: 'Warning', ok: 'Healthy' } as const
|
|||||||
</p>
|
</p>
|
||||||
<ul
|
<ul
|
||||||
v-else
|
v-else
|
||||||
class="border-border bg-surface divide-border max-h-[70vh] divide-y overflow-y-auto rounded-lg border lg:max-h-[calc(100vh-7rem)]"
|
ref="scroller"
|
||||||
|
class="border-border bg-surface divide-border max-h-[70vh] divide-y overflow-y-auto rounded-lg border [scrollbar-color:var(--border)_transparent] [scrollbar-width:thin] lg:max-h-[calc(100vh-7rem)]"
|
||||||
>
|
>
|
||||||
<li
|
<li
|
||||||
v-for="repo in repos"
|
v-for="repo in visible"
|
||||||
:key="repo.fullName"
|
:key="repo.fullName"
|
||||||
class="px-3 py-2.5"
|
class="px-3 py-2.5"
|
||||||
:class="focused === repo.fullName ? 'bg-accent' : ''"
|
:class="focused === repo.fullName ? 'bg-accent' : ''"
|
||||||
@@ -89,6 +141,14 @@ const LABEL = { crit: 'Critical', warn: 'Warning', ok: 'Healthy' } as const
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
<li
|
||||||
|
v-if="hasMore"
|
||||||
|
ref="sentinel"
|
||||||
|
class="text-muted-foreground px-3 py-3 text-center text-xs"
|
||||||
|
data-test="more"
|
||||||
|
>
|
||||||
|
Showing {{ visible.length }} of {{ repos.length }}
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { mount } from '@vue/test-utils'
|
import { mount } from '@vue/test-utils'
|
||||||
import { describe, expect, it } from 'vitest'
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
import AgeMeter from './AgeMeter.vue'
|
import AgeMeter from './AgeMeter.vue'
|
||||||
import AppHeader from './AppHeader.vue'
|
import AppHeader from './AppHeader.vue'
|
||||||
import AttentionList from './AttentionList.vue'
|
import AttentionList from './AttentionList.vue'
|
||||||
import FilterBar from './FilterBar.vue'
|
import FilterBar from './FilterBar.vue'
|
||||||
|
import LoadingScreen from './LoadingScreen.vue'
|
||||||
import AttentionRow from './AttentionRow.vue'
|
import AttentionRow from './AttentionRow.vue'
|
||||||
import FleetOverview from './FleetOverview.vue'
|
import FleetOverview from './FleetOverview.vue'
|
||||||
import ProjectList from './ProjectList.vue'
|
import ProjectList from './ProjectList.vue'
|
||||||
@@ -24,6 +25,7 @@ describe('AppHeader', () => {
|
|||||||
intervalSeconds: 21600,
|
intervalSeconds: 21600,
|
||||||
loaded: true,
|
loaded: true,
|
||||||
scanning: false,
|
scanning: false,
|
||||||
|
progress: '',
|
||||||
}
|
}
|
||||||
|
|
||||||
it('shows when data was fetched and when the next refresh is due', () => {
|
it('shows when data was fetched and when the next refresh is due', () => {
|
||||||
@@ -44,7 +46,11 @@ describe('AppHeader', () => {
|
|||||||
expect(w.emitted('rescan')).toHaveLength(1)
|
expect(w.emitted('rescan')).toHaveLength(1)
|
||||||
const busy = mount(AppHeader, { props: { ...base, manualRefresh: true, scanning: true } })
|
const busy = mount(AppHeader, { props: { ...base, manualRefresh: true, scanning: true } })
|
||||||
expect(busy.get('[data-test=rescan]').attributes('disabled')).toBeDefined()
|
expect(busy.get('[data-test=rescan]').attributes('disabled')).toBeDefined()
|
||||||
expect(busy.get('[data-test=rescan]').text()).toContain('Rescanning…')
|
expect(busy.get('[data-test=rescan]').text()).toContain('Scanning')
|
||||||
|
const counted = mount(AppHeader, {
|
||||||
|
props: { ...base, manualRefresh: true, scanning: true, progress: '23/63' },
|
||||||
|
})
|
||||||
|
expect(counted.get('[data-test=rescan]').text()).toContain('Scanning 23/63')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -304,3 +310,141 @@ describe('ProjectList', () => {
|
|||||||
expect(w.findAll('[data-test=project]')[1].classes()).toContain('bg-accent')
|
expect(w.findAll('[data-test=project]')[1].classes()).toContain('bg-accent')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('AttentionList columns', () => {
|
||||||
|
const blocks = ['a/one', 'b/two', 'c/three'].map((fullName) => ({
|
||||||
|
repo: repo({ fullName, health: 'crit' }),
|
||||||
|
rows: buildBlocks(mixedState())[0].rows,
|
||||||
|
}))
|
||||||
|
const props = {
|
||||||
|
blocks,
|
||||||
|
nowMs,
|
||||||
|
critSeconds: CRIT_SECONDS,
|
||||||
|
facets: facetCounts(blocks, emptyFilters(), ['a', 'b', 'c']),
|
||||||
|
owners: ['a'],
|
||||||
|
active: 0,
|
||||||
|
filters: emptyFilters(),
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => vi.unstubAllGlobals())
|
||||||
|
|
||||||
|
it('uses one column on narrow screens', () => {
|
||||||
|
vi.stubGlobal('matchMedia', () => ({
|
||||||
|
matches: false,
|
||||||
|
addEventListener: () => {},
|
||||||
|
removeEventListener: () => {},
|
||||||
|
}))
|
||||||
|
const w = mount(AttentionList, { props })
|
||||||
|
expect(w.findAll('[data-test=columns] > div')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('balances groups over two columns on very wide screens', async () => {
|
||||||
|
vi.stubGlobal('matchMedia', () => ({
|
||||||
|
matches: true,
|
||||||
|
addEventListener: () => {},
|
||||||
|
removeEventListener: () => {},
|
||||||
|
}))
|
||||||
|
const w = mount(AttentionList, { props })
|
||||||
|
await w.vm.$nextTick()
|
||||||
|
const cols = w.findAll('[data-test=columns] > div')
|
||||||
|
expect(cols).toHaveLength(2)
|
||||||
|
expect(cols[0].findAll('[data-test=repo-block]')).toHaveLength(2)
|
||||||
|
expect(cols[1].findAll('[data-test=repo-block]')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('ProjectList lazy loading', () => {
|
||||||
|
type Callback = (entries: { isIntersecting: boolean }[]) => void
|
||||||
|
let callback: Callback = () => {}
|
||||||
|
|
||||||
|
function stubObserver() {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'IntersectionObserver',
|
||||||
|
class {
|
||||||
|
constructor(cb: Callback) {
|
||||||
|
callback = cb
|
||||||
|
}
|
||||||
|
observe() {}
|
||||||
|
unobserve() {}
|
||||||
|
disconnect() {}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const many = (n: number) => Array.from({ length: n }, (_, i) => repo({ fullName: `o/repo-${i}` }))
|
||||||
|
|
||||||
|
afterEach(() => vi.unstubAllGlobals())
|
||||||
|
|
||||||
|
it('renders a first page, then more each time the end marker scrolls into view', async () => {
|
||||||
|
stubObserver()
|
||||||
|
const w = mount(ProjectList, { props: { repos: many(55), total: 55, focused: null, nowMs } })
|
||||||
|
await w.vm.$nextTick()
|
||||||
|
expect(w.findAll('[data-test=project]')).toHaveLength(20)
|
||||||
|
expect(w.get('[data-test=more]').text()).toBe('Showing 20 of 55')
|
||||||
|
callback([{ isIntersecting: true }])
|
||||||
|
await w.vm.$nextTick()
|
||||||
|
expect(w.findAll('[data-test=project]')).toHaveLength(40)
|
||||||
|
callback([{ isIntersecting: false }])
|
||||||
|
await w.vm.$nextTick()
|
||||||
|
expect(w.findAll('[data-test=project]')).toHaveLength(40)
|
||||||
|
callback([{ isIntersecting: true }])
|
||||||
|
await w.vm.$nextTick()
|
||||||
|
expect(w.findAll('[data-test=project]')).toHaveLength(55)
|
||||||
|
expect(w.find('[data-test=more]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('starts over from the first page when the list changes', async () => {
|
||||||
|
stubObserver()
|
||||||
|
const w = mount(ProjectList, { props: { repos: many(55), total: 55, focused: null, nowMs } })
|
||||||
|
await w.vm.$nextTick()
|
||||||
|
callback([{ isIntersecting: true }])
|
||||||
|
await w.vm.$nextTick()
|
||||||
|
expect(w.findAll('[data-test=project]')).toHaveLength(40)
|
||||||
|
await (w as unknown as { setProps(p: object): Promise<void> }).setProps({ repos: many(30) })
|
||||||
|
expect(w.findAll('[data-test=project]')).toHaveLength(20)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders everything when the browser has no IntersectionObserver', async () => {
|
||||||
|
vi.stubGlobal('IntersectionObserver', undefined)
|
||||||
|
const w = mount(ProjectList, { props: { repos: many(55), total: 55, focused: null, nowMs } })
|
||||||
|
await w.vm.$nextTick()
|
||||||
|
await w.vm.$nextTick()
|
||||||
|
expect(w.findAll('[data-test=project]')).toHaveLength(55)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('LoadingScreen', () => {
|
||||||
|
it('shows scan progress with a count once the server reports a total', () => {
|
||||||
|
const w = mount(LoadingScreen, { props: { done: 23, total: 63, connecting: false } })
|
||||||
|
expect(w.get('[data-test=loading-title]').text()).toBe('Scanning repositories')
|
||||||
|
expect(w.get('[data-test=loading-detail]').text()).toContain('23 of 63 scanned')
|
||||||
|
expect(w.get('[role=progressbar]').attributes('aria-valuenow')).toBe('37')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('draws one skeleton cell per repo, within sensible limits', () => {
|
||||||
|
const cells = (total: number) =>
|
||||||
|
mount(LoadingScreen, { props: { done: 0, total, connecting: false } }).findAll(
|
||||||
|
'.grid-cols-\\[repeat\\(auto-fill\\,minmax\\(1\\.5rem\\,1fr\\)\\)\\] > div',
|
||||||
|
).length
|
||||||
|
expect(cells(5)).toBe(12)
|
||||||
|
expect(cells(30)).toBe(30)
|
||||||
|
expect(cells(500)).toBe(63)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('says it is connecting before the first response', () => {
|
||||||
|
const w = mount(LoadingScreen, { props: { done: 0, total: 0, connecting: true } })
|
||||||
|
expect(w.get('[data-test=loading-title]').text()).toBe('Connecting')
|
||||||
|
expect(w.find('[role=progressbar]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses an indeterminate bar while the total is not known yet', () => {
|
||||||
|
const w = mount(LoadingScreen, { props: { done: 0, total: 0, connecting: false } })
|
||||||
|
expect(w.get('[data-test=loading-detail]').text()).toContain('Contacting GitHub')
|
||||||
|
expect(w.find('[role=progressbar]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is announced politely to screen readers', () => {
|
||||||
|
const w = mount(LoadingScreen, { props: { done: 1, total: 2, connecting: false } })
|
||||||
|
expect(w.attributes('role')).toBe('status')
|
||||||
|
expect(w.attributes('aria-live')).toBe('polite')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { onBeforeUnmount, onMounted, ref, type Ref } from 'vue'
|
||||||
|
|
||||||
|
/** 1 column below the breakpoint, 2 at or above it; follows the viewport live. */
|
||||||
|
export function useColumns(minWidth = '(min-width: 96rem)'): Ref<number> {
|
||||||
|
const columns = ref(1)
|
||||||
|
let query: MediaQueryList | null = null
|
||||||
|
const update = () => {
|
||||||
|
columns.value = query?.matches ? 2 : 1
|
||||||
|
}
|
||||||
|
onMounted(() => {
|
||||||
|
if (typeof window.matchMedia !== 'function') return
|
||||||
|
query = window.matchMedia(minWidth)
|
||||||
|
update()
|
||||||
|
query.addEventListener('change', update)
|
||||||
|
})
|
||||||
|
onBeforeUnmount(() => query?.removeEventListener('change', update))
|
||||||
|
return columns
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { balanceColumns } from './layout'
|
||||||
|
|
||||||
|
const weight = (n: number) => n
|
||||||
|
|
||||||
|
describe('balanceColumns', () => {
|
||||||
|
it('returns one column holding everything in order', () => {
|
||||||
|
expect(balanceColumns([3, 1, 2], 1, weight)).toEqual([[3, 1, 2]])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('puts each item in the currently shortest column', () => {
|
||||||
|
// heights after each step: [5,0] -> [5,2] -> [5,5] -> [5,6]... ties go to the first column
|
||||||
|
expect(balanceColumns([5, 2, 3, 1], 2, weight)).toEqual([
|
||||||
|
[5, 1],
|
||||||
|
[2, 3],
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the heaviest-first reading order within each column', () => {
|
||||||
|
const [left, right] = balanceColumns([9, 8, 7, 6, 5], 2, weight)
|
||||||
|
expect(left).toEqual([9, 6, 5])
|
||||||
|
expect(right).toEqual([8, 7])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles empty input and clamps invalid column counts', () => {
|
||||||
|
expect(balanceColumns([], 2, weight)).toEqual([[], []])
|
||||||
|
expect(balanceColumns([1, 2], 0, weight)).toEqual([[1, 2]])
|
||||||
|
expect(balanceColumns([1, 2], 2.9, weight)).toHaveLength(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* Splits items into columns so the columns end at about the same height. Each item goes to the
|
||||||
|
* currently shortest column, which keeps the original order readable (worst items first).
|
||||||
|
*/
|
||||||
|
export function balanceColumns<T>(items: T[], columns: number, weight: (item: T) => number): T[][] {
|
||||||
|
const count = Math.max(1, Math.floor(columns))
|
||||||
|
const out: T[][] = Array.from({ length: count }, () => [])
|
||||||
|
const heights = new Array<number>(count).fill(0)
|
||||||
|
for (const item of items) {
|
||||||
|
let target = 0
|
||||||
|
for (let i = 1; i < count; i++) if (heights[i] < heights[target]) target = i
|
||||||
|
out[target].push(item)
|
||||||
|
heights[target] += weight(item)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -49,6 +49,7 @@ export interface TrackerState {
|
|||||||
loaded: boolean
|
loaded: boolean
|
||||||
refreshing: boolean
|
refreshing: boolean
|
||||||
manualRefresh: boolean
|
manualRefresh: boolean
|
||||||
|
scan: { done: number; total: number }
|
||||||
counts: { crit: number; warn: number }
|
counts: { crit: number; warn: number }
|
||||||
thresholds: Thresholds
|
thresholds: Thresholds
|
||||||
repos: Repo[]
|
repos: Repo[]
|
||||||
|
|||||||
@@ -134,6 +134,15 @@ describe('tracker store', () => {
|
|||||||
expect(store.owners).toEqual(['o'])
|
expect(store.owners).toEqual(['o'])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('counts rows by severity so the headline matches the lists', async () => {
|
||||||
|
mockFetch(() => json(mixedState()))
|
||||||
|
const store = useTrackerStore()
|
||||||
|
await store.load()
|
||||||
|
// three alerts, but the two on the same pull request merge into one row
|
||||||
|
expect(store.rowCounts).toEqual({ crit: 2, warn: 0 })
|
||||||
|
expect(store.facets.severity.all).toBe(store.rowCounts.crit + store.rowCounts.warn)
|
||||||
|
})
|
||||||
|
|
||||||
it('searches projects by name and clears every filter at once', async () => {
|
it('searches projects by name and clears every filter at once', async () => {
|
||||||
mockFetch(() => json(mixedState()))
|
mockFetch(() => json(mixedState()))
|
||||||
const store = useTrackerStore()
|
const store = useTrackerStore()
|
||||||
|
|||||||
@@ -122,6 +122,12 @@ export const useTrackerStore = defineStore('tracker', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
const activeFilters = computed(() => activeFilterCount(filters.value))
|
const activeFilters = computed(() => activeFilterCount(filters.value))
|
||||||
|
/** Rows by severity, so the headline matches the lists below it (alerts on one item merge into one row). */
|
||||||
|
const rowCounts = computed(() => {
|
||||||
|
const out = { crit: 0, warn: 0 }
|
||||||
|
for (const b of blocks.value) for (const r of b.rows) out[r.severity] += 1
|
||||||
|
return out
|
||||||
|
})
|
||||||
const repoCounts = computed(() => (state.value ? countByRepo(state.value) : new Map()))
|
const repoCounts = computed(() => (state.value ? countByRepo(state.value) : new Map()))
|
||||||
const facts = computed(() => (state.value ? computeFacts(state.value) : null))
|
const facts = computed(() => (state.value ? computeFacts(state.value) : null))
|
||||||
|
|
||||||
@@ -189,6 +195,7 @@ export const useTrackerStore = defineStore('tracker', () => {
|
|||||||
activeFilters,
|
activeFilters,
|
||||||
visibleBlocks,
|
visibleBlocks,
|
||||||
repoCounts,
|
repoCounts,
|
||||||
|
rowCounts,
|
||||||
facts,
|
facts,
|
||||||
toggleRepo,
|
toggleRepo,
|
||||||
clearFilters,
|
clearFilters,
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ export function state(over: Partial<TrackerState> = {}): TrackerState {
|
|||||||
loaded: true,
|
loaded: true,
|
||||||
refreshing: false,
|
refreshing: false,
|
||||||
manualRefresh: false,
|
manualRefresh: false,
|
||||||
|
scan: { done: 0, total: 0 },
|
||||||
counts: { crit: 0, warn: 0 },
|
counts: { crit: 0, warn: 0 },
|
||||||
thresholds: {
|
thresholds: {
|
||||||
prWarnSeconds: 172800,
|
prWarnSeconds: 172800,
|
||||||
|
|||||||
Reference in New Issue
Block a user