mirror of
https://github.com/lukaszraczylo/ghtracker.git
synced 2026-10-12 00:45:38 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c7c782d86 | ||
|
|
a56a12a6e0 | ||
|
|
1852ea0885 |
@@ -33,6 +33,9 @@ type View struct {
|
||||
Warn int
|
||||
Loaded 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.
|
||||
@@ -56,6 +59,8 @@ type Collector struct {
|
||||
th config.Thresholds
|
||||
interval time.Duration
|
||||
refreshes atomic.Uint64
|
||||
scanDone atomic.Int64
|
||||
scanTotal atomic.Int64
|
||||
repoErrs atomic.Uint64
|
||||
lastDur time.Duration
|
||||
concurrency int
|
||||
@@ -118,6 +123,8 @@ func (c *Collector) Refresh(ctx context.Context) {
|
||||
|
||||
start := c.now()
|
||||
c.limited.Store(false)
|
||||
c.scanDone.Store(0)
|
||||
c.scanTotal.Store(int64(len(c.names)))
|
||||
results := make([]model.Repo, len(c.names))
|
||||
sem := make(chan struct{}, c.concurrency)
|
||||
var wg sync.WaitGroup
|
||||
@@ -128,6 +135,7 @@ func (c *Collector) Refresh(ctx context.Context) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
results[i] = c.refreshOne(ctx, name, start)
|
||||
c.scanDone.Add(1)
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
@@ -193,6 +201,7 @@ func (c *Collector) View() View {
|
||||
v := View{
|
||||
Now: now, LastRefresh: c.lastRefresh, Interval: c.interval, Thresholds: c.th,
|
||||
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))
|
||||
for _, n := range c.names {
|
||||
|
||||
@@ -312,3 +312,28 @@ func TestSummarizeWarningsMergesSharedReason(t *testing.T) {
|
||||
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 {
|
||||
// freshHC opens a new connection for every request.
|
||||
freshHC *http.Client
|
||||
rateReset time.Time
|
||||
blockedUntil time.Time
|
||||
auth TokenSource
|
||||
@@ -88,7 +90,7 @@ type cachedResponse struct {
|
||||
}
|
||||
|
||||
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)
|
||||
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.
|
||||
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
|
||||
for attempt := 0; attempt <= maxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
@@ -119,7 +123,7 @@ func (c *Client) do(ctx context.Context, repo, target string) ([]byte, string, e
|
||||
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 {
|
||||
return body, link, nil
|
||||
}
|
||||
@@ -131,7 +135,7 @@ func (c *Client) do(ctx context.Context, repo, target string) ([]byte, string, e
|
||||
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 {
|
||||
return nil, "", false, gerr
|
||||
}
|
||||
@@ -150,10 +154,15 @@ func (c *Client) once(ctx context.Context, repo, target string) (body []byte, li
|
||||
c.mu.Lock()
|
||||
cached, haveCache := c.cache[target]
|
||||
c.mu.Unlock()
|
||||
haveCache = haveCache && !fresh
|
||||
if haveCache {
|
||||
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 {
|
||||
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
|
||||
}
|
||||
link = resp.Header.Get("Link")
|
||||
if etag := resp.Header.Get("ETag"); etag != "" {
|
||||
if etag := resp.Header.Get("ETag"); etag != "" && !fresh {
|
||||
c.mu.Lock()
|
||||
c.cache[target] = cachedResponse{etag: etag, body: data, link: link}
|
||||
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 {
|
||||
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 {
|
||||
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)
|
||||
var all []T
|
||||
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 {
|
||||
return all, err
|
||||
}
|
||||
@@ -289,3 +307,18 @@ func nextLink(h string) string {
|
||||
}
|
||||
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 {
|
||||
continue
|
||||
}
|
||||
var runs 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, w.ID)
|
||||
if err := c.getJSON(ctx, full, path, q, &runs); err != nil {
|
||||
runs, err := c.workflowRuns(ctx, full, w.ID, branch, false)
|
||||
if err != nil {
|
||||
if first == nil {
|
||||
first = fmt.Errorf("%s: %w", w.Name, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if wf, ok := buildWorkflow(w.Name, w.HTMLURL, runs.WorkflowRuns); ok {
|
||||
out = append(out, wf)
|
||||
wf, ok := buildWorkflow(w.Name, w.HTMLURL, runs)
|
||||
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
|
||||
}
|
||||
@@ -343,3 +347,36 @@ func (c *Client) latestRelease(ctx context.Context, full, repoURL string) (*mode
|
||||
FromTag: true,
|
||||
}, 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))
|
||||
}
|
||||
}
|
||||
|
||||
// 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"`
|
||||
Thresholds apiThresholds `json:"thresholds"`
|
||||
Counts apiCounts `json:"counts"`
|
||||
Scan apiScan `json:"scan"`
|
||||
IntervalSeconds int64 `json:"intervalSeconds"`
|
||||
Loaded bool `json:"loaded"`
|
||||
Refreshing bool `json:"refreshing"`
|
||||
@@ -30,6 +31,12 @@ type apiThresholds struct {
|
||||
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 {
|
||||
Crit int `json:"crit"`
|
||||
Warn int `json:"warn"`
|
||||
@@ -98,6 +105,7 @@ func buildState(v collector.View, manualRefresh bool) apiState {
|
||||
Refreshing: v.Refreshing,
|
||||
ManualRefresh: manualRefresh,
|
||||
Counts: apiCounts{Crit: v.Crit, Warn: v.Warn},
|
||||
Scan: apiScan{Done: v.ScanDone, Total: v.ScanTotal},
|
||||
Thresholds: apiThresholds{
|
||||
PRWarnSeconds: seconds(v.Thresholds.PRWarnAfter),
|
||||
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"}
|
||||
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},
|
||||
Repos: []model.Repo{repo, bare},
|
||||
Alerts: []model.Alert{
|
||||
@@ -125,6 +125,7 @@ func TestStateJSONContract(t *testing.T) {
|
||||
Repos []map[string]any
|
||||
Alerts []map[string]any
|
||||
Counts struct{ Crit, Warn int }
|
||||
Scan struct{ Done, Total int }
|
||||
Thresholds struct{ PRWarnSeconds, PRCritSeconds int64 }
|
||||
IntervalSeconds int64
|
||||
Loaded bool
|
||||
@@ -133,6 +134,9 @@ func TestStateJSONContract(t *testing.T) {
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||
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 {
|
||||
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 () => {
|
||||
const w = mountWith(state({ loaded: false }))
|
||||
const w = mountWith(state({ loaded: false, scan: { done: 4, total: 10 } }))
|
||||
await flushPromises()
|
||||
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)
|
||||
})
|
||||
|
||||
|
||||
+19
-13
@@ -3,6 +3,7 @@ import { storeToRefs } from 'pinia'
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import AppHeader from '@/components/AppHeader.vue'
|
||||
import AttentionList from '@/components/AttentionList.vue'
|
||||
import LoadingScreen from '@/components/LoadingScreen.vue'
|
||||
import FleetOverview from '@/components/FleetOverview.vue'
|
||||
import ProjectList from '@/components/ProjectList.vue'
|
||||
import { filtersFromSearch, filtersToSearch } from '@/lib/filters'
|
||||
@@ -26,9 +27,15 @@ const {
|
||||
visibleRepos,
|
||||
activeFilters,
|
||||
repoCounts,
|
||||
rowCounts,
|
||||
facts,
|
||||
} = storeToRefs(store)
|
||||
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)))
|
||||
let timers: number[] = []
|
||||
|
||||
@@ -64,13 +71,14 @@ onBeforeUnmount(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="mx-auto max-w-7xl px-5 py-6 sm:px-8">
|
||||
<main class="w-full px-4 py-4 sm:px-6 sm:py-6 lg:px-10">
|
||||
<AppHeader
|
||||
:last-refresh="state?.lastRefresh ?? ''"
|
||||
:now-ms="nowMs"
|
||||
:interval-seconds="state?.intervalSeconds ?? 0"
|
||||
:loaded="state?.loaded ?? false"
|
||||
:scanning="scanning"
|
||||
:progress="progress"
|
||||
:manual-refresh="state?.manualRefresh ?? false"
|
||||
@rescan="store.rescan()"
|
||||
/>
|
||||
@@ -83,19 +91,17 @@ onBeforeUnmount(() => {
|
||||
{{ notice }}
|
||||
</p>
|
||||
|
||||
<p v-if="!state" class="text-muted-foreground mt-16 text-3xl font-semibold">Loading</p>
|
||||
<p
|
||||
v-else-if="!state.loaded"
|
||||
class="text-muted-foreground mt-16 text-3xl font-semibold"
|
||||
data-test="waiting"
|
||||
>
|
||||
Fetching data from GitHub
|
||||
</p>
|
||||
<LoadingScreen
|
||||
v-if="!state || !state.loaded"
|
||||
:done="state?.scan.done ?? 0"
|
||||
:total="state?.scan.total ?? 0"
|
||||
:connecting="!state"
|
||||
/>
|
||||
<template v-else>
|
||||
<div class="mt-12">
|
||||
<div class="mt-8 sm:mt-12">
|
||||
<FleetOverview
|
||||
:crit="state.counts.crit"
|
||||
:warn="state.counts.warn"
|
||||
:crit="rowCounts.crit"
|
||||
:warn="rowCounts.warn"
|
||||
:block-count="blocks.length"
|
||||
:repos="state.repos"
|
||||
:counts="repoCounts"
|
||||
@@ -106,7 +112,7 @@ onBeforeUnmount(() => {
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="mt-14 grid grid-cols-[minmax(0,1fr)] gap-x-10 gap-y-12 lg:grid-cols-[minmax(0,1fr)_23rem]"
|
||||
class="mt-10 grid grid-cols-[minmax(0,1fr)] gap-x-10 gap-y-10 sm:mt-14 lg:grid-cols-[minmax(0,1fr)_24rem] 2xl:grid-cols-[minmax(0,1fr)_28rem]"
|
||||
>
|
||||
<AttentionList
|
||||
ref="list"
|
||||
|
||||
@@ -11,6 +11,7 @@ const props = defineProps<{
|
||||
intervalSeconds: number
|
||||
loaded: boolean
|
||||
scanning: boolean
|
||||
progress: string
|
||||
manualRefresh: boolean
|
||||
}>()
|
||||
defineEmits<{ rescan: [] }>()
|
||||
@@ -28,7 +29,7 @@ const nextIn = computed(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="flex items-center justify-between gap-4">
|
||||
<header class="flex flex-wrap items-center justify-between gap-x-4 gap-y-2">
|
||||
<div class="flex items-center gap-3">
|
||||
<span
|
||||
class="border-border bg-surface text-link flex size-9 items-center justify-center rounded-md border"
|
||||
@@ -37,8 +38,12 @@ const nextIn = computed(() => {
|
||||
</span>
|
||||
<span class="text-lg font-semibold tracking-tight">ghtracker</span>
|
||||
</div>
|
||||
<div class="text-muted-foreground flex items-center gap-5 text-sm">
|
||||
<span v-if="updated" class="hidden sm:inline" data-test="updated">
|
||||
<div class="text-muted-foreground flex flex-wrap items-center gap-x-5 gap-y-1 text-sm">
|
||||
<span
|
||||
v-if="updated"
|
||||
class="order-last w-full text-xs sm:order-none sm:w-auto sm:text-sm"
|
||||
data-test="updated"
|
||||
>
|
||||
Scanned {{ updated }} ago
|
||||
<span class="text-muted-foreground/70"> · next in {{ nextIn }}</span>
|
||||
</span>
|
||||
@@ -52,7 +57,10 @@ const nextIn = computed(() => {
|
||||
@click="$emit('rescan')"
|
||||
>
|
||||
<FontAwesomeIcon :icon="faArrowsRotate" :spin="scanning" aria-hidden="true" />
|
||||
{{ scanning ? 'Rescanning…' : 'Rescan repositories' }}
|
||||
<span class="hidden sm:inline">{{
|
||||
scanning ? `Scanning ${progress}`.trim() : 'Rescan repositories'
|
||||
}}</span>
|
||||
<span class="sm:hidden">{{ scanning ? progress || 'Scanning…' : 'Rescan' }}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { faCircleCheck } from '@fortawesome/free-solid-svg-icons'
|
||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
|
||||
import { ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import FilterBar from '@/components/FilterBar.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 { Block } from '@/lib/types'
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
blocks: Block[]
|
||||
nowMs: number
|
||||
critSeconds: number
|
||||
@@ -18,6 +20,12 @@ defineProps<{
|
||||
const filters = defineModel<Filters>('filters', { required: true })
|
||||
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)
|
||||
defineExpose({ focusSearch: () => bar.value?.focus() })
|
||||
</script>
|
||||
@@ -43,14 +51,16 @@ defineExpose({ focusSearch: () => bar.value?.focus() })
|
||||
<FontAwesomeIcon :icon="faCircleCheck" class="text-ok text-xl" aria-hidden="true" />
|
||||
Nothing matches the current filters.
|
||||
</div>
|
||||
<div v-else class="flex flex-col gap-4">
|
||||
<RepoGroup
|
||||
v-for="block in blocks"
|
||||
:key="block.repo.fullName"
|
||||
:block="block"
|
||||
:now-ms="nowMs"
|
||||
:crit-seconds="critSeconds"
|
||||
/>
|
||||
<div v-else class="flex items-start gap-4" data-test="columns">
|
||||
<div v-for="(column, i) in columns" :key="i" class="flex min-w-0 flex-1 flex-col gap-4">
|
||||
<RepoGroup
|
||||
v-for="block in column"
|
||||
:key="block.repo.fullName"
|
||||
:block="block"
|
||||
:now-ms="nowMs"
|
||||
:crit-seconds="critSeconds"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -12,7 +12,10 @@ const ratio = computed(() => secondsSince(props.row.since, props.nowMs) / props.
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li class="grid grid-cols-[2rem_minmax(0,1fr)_auto] items-start gap-x-3 py-3" data-test="row">
|
||||
<li
|
||||
class="grid grid-cols-[2rem_minmax(0,1fr)] items-start gap-x-3 gap-y-1 py-3 sm:grid-cols-[2rem_minmax(0,1fr)_auto]"
|
||||
data-test="row"
|
||||
>
|
||||
<span
|
||||
class="flex size-8 items-center justify-center rounded-md"
|
||||
:class="row.severity === 'crit' ? 'bg-crit/12 text-crit' : 'bg-warn/12 text-warn'"
|
||||
@@ -31,7 +34,9 @@ const ratio = computed(() => secondsSince(props.row.since, props.nowMs) / props.
|
||||
{{ shorten(row.detail, 120) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-col items-end gap-1.5 pt-0.5">
|
||||
<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"
|
||||
>
|
||||
<span class="text-muted-foreground font-mono text-xs whitespace-nowrap">
|
||||
{{ isZeroTime(row.since) ? '' : ageSince(row.since, nowMs) }}
|
||||
</span>
|
||||
|
||||
@@ -49,7 +49,7 @@ function onOwners(value: unknown): void {
|
||||
<template>
|
||||
<div class="flex flex-col gap-3" data-test="filter-bar">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<div class="relative min-w-56 flex-1">
|
||||
<div class="relative min-w-0 basis-full sm:min-w-56 sm:flex-1 sm:basis-0">
|
||||
<FontAwesomeIcon
|
||||
:icon="faMagnifyingGlass"
|
||||
class="text-muted-foreground pointer-events-none absolute top-1/2 left-3 -translate-y-1/2"
|
||||
@@ -75,6 +75,7 @@ function onOwners(value: unknown): void {
|
||||
</div>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
class="max-w-full overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:model-value="filters.severity"
|
||||
@@ -96,6 +97,7 @@ function onOwners(value: unknown): void {
|
||||
<div class="flex flex-wrap items-center gap-x-5 gap-y-2">
|
||||
<ToggleGroup
|
||||
type="multiple"
|
||||
class="max-w-full overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:model-value="filters.kinds"
|
||||
@@ -117,6 +119,7 @@ function onOwners(value: unknown): void {
|
||||
<ToggleGroup
|
||||
v-if="owners.length > 1"
|
||||
type="multiple"
|
||||
class="max-w-full overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:model-value="filters.owners"
|
||||
|
||||
@@ -56,10 +56,7 @@ const rows = computed<{ label: string; value: number; icon: IconDefinition; tone
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="grid grid-cols-[minmax(0,1fr)] items-end gap-x-14 gap-y-8 lg:grid-cols-[minmax(0,1fr)_20rem]"
|
||||
aria-label="Fleet status"
|
||||
>
|
||||
<section class="flex flex-col gap-7 sm:gap-8" aria-label="Fleet status">
|
||||
<div>
|
||||
<p class="text-muted-foreground mb-3 text-sm" data-test="scope">
|
||||
<template v-if="blockCount > 0"
|
||||
@@ -68,50 +65,60 @@ const rows = computed<{ label: string; value: number; icon: IconDefinition; tone
|
||||
<template v-else>All {{ repos.length }} projects are healthy</template>
|
||||
</p>
|
||||
<h1
|
||||
class="flex flex-wrap items-baseline gap-x-10 gap-y-1 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"
|
||||
>
|
||||
<template v-if="crit > 0 || warn > 0">
|
||||
<span v-if="crit > 0" class="text-crit">
|
||||
<span class="text-7xl leading-none tabular-nums">{{ crit }}</span>
|
||||
<span class="text-5xl leading-none tabular-nums sm:text-7xl">{{ crit }}</span>
|
||||
<span class="ml-2 text-xl font-medium">critical</span>
|
||||
</span>
|
||||
<span v-if="warn > 0" class="text-warn">
|
||||
<span class="text-7xl leading-none tabular-nums">{{ warn }}</span>
|
||||
<span class="text-5xl leading-none tabular-nums sm:text-7xl">{{ warn }}</span>
|
||||
<span class="ml-2 text-xl font-medium">{{ warn === 1 ? 'warning' : 'warnings' }}</span>
|
||||
</span>
|
||||
</template>
|
||||
<span v-else class="text-ok text-7xl leading-none">All clear</span>
|
||||
<span v-else class="text-ok text-5xl leading-none sm:text-7xl">All clear</span>
|
||||
</h1>
|
||||
<div class="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="ml-auto">Select a segment to focus a project</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dl class="border-border divide-border divide-y border-y text-sm" data-test="facts">
|
||||
<div v-for="row in rows" :key="row.label" class="flex items-center gap-3 py-2.5">
|
||||
<FontAwesomeIcon :icon="row.icon" class="text-muted-foreground w-4" aria-hidden="true" />
|
||||
<dt class="flex-1">{{ row.label }}</dt>
|
||||
<div>
|
||||
<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>
|
||||
|
||||
<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
|
||||
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'"
|
||||
>
|
||||
{{ row.value }}
|
||||
|
||||
@@ -24,14 +24,19 @@ function summary(c: RepoCounts | undefined): string {
|
||||
|
||||
<template>
|
||||
<TooltipProvider :delay-duration="80">
|
||||
<div class="flex gap-1" role="group" aria-label="Project health" data-test="strip">
|
||||
<div
|
||||
class="grid grid-cols-[repeat(auto-fill,minmax(1.5rem,1fr))] gap-1"
|
||||
role="group"
|
||||
aria-label="Project health"
|
||||
data-test="strip"
|
||||
>
|
||||
<Tooltip v-for="repo in repos" :key="repo.fullName">
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="`${repo.fullName}: ${summary(counts.get(repo.fullName))}`"
|
||||
:aria-pressed="focused === repo.fullName"
|
||||
class="h-14 min-w-0 flex-1 rounded-[3px] transition-colors focus-visible:outline-offset-1"
|
||||
class="h-9 min-w-0 rounded-[3px] sm:h-11 transition-colors focus-visible:outline-offset-1"
|
||||
:class="[
|
||||
SEGMENT[repo.health],
|
||||
focused === repo.fullName
|
||||
|
||||
@@ -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">
|
||||
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 { ageSince, isZeroTime } from '@/lib/format'
|
||||
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] }>()
|
||||
|
||||
/** 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 LABEL = { crit: 'Critical', warn: 'Warning', ok: 'Healthy' } as const
|
||||
</script>
|
||||
@@ -26,9 +77,13 @@ const LABEL = { crit: 'Critical', warn: 'Warning', ok: 'Healthy' } as const
|
||||
>
|
||||
No projects match the search.
|
||||
</p>
|
||||
<ul v-else class="border-border bg-surface divide-border divide-y rounded-lg border">
|
||||
<ul
|
||||
v-else
|
||||
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
|
||||
v-for="repo in repos"
|
||||
v-for="repo in visible"
|
||||
:key="repo.fullName"
|
||||
class="px-3 py-2.5"
|
||||
:class="focused === repo.fullName ? 'bg-accent' : ''"
|
||||
@@ -86,6 +141,14 @@ const LABEL = { crit: 'Critical', warn: 'Warning', ok: 'Healthy' } as const
|
||||
</span>
|
||||
</div>
|
||||
</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>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
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 AppHeader from './AppHeader.vue'
|
||||
import AttentionList from './AttentionList.vue'
|
||||
import FilterBar from './FilterBar.vue'
|
||||
import LoadingScreen from './LoadingScreen.vue'
|
||||
import AttentionRow from './AttentionRow.vue'
|
||||
import FleetOverview from './FleetOverview.vue'
|
||||
import ProjectList from './ProjectList.vue'
|
||||
@@ -24,6 +25,7 @@ describe('AppHeader', () => {
|
||||
intervalSeconds: 21600,
|
||||
loaded: true,
|
||||
scanning: false,
|
||||
progress: '',
|
||||
}
|
||||
|
||||
it('shows when data was fetched and when the next refresh is due', () => {
|
||||
@@ -39,12 +41,16 @@ describe('AppHeader', () => {
|
||||
|
||||
it('emits rescan and shows progress while a scan runs', async () => {
|
||||
const w = mount(AppHeader, { props: { ...base, manualRefresh: true } })
|
||||
expect(w.get('[data-test=rescan]').text()).toBe('Rescan repositories')
|
||||
expect(w.get('[data-test=rescan]').text()).toContain('Rescan repositories')
|
||||
await w.get('[data-test=rescan]').trigger('click')
|
||||
expect(w.emitted('rescan')).toHaveLength(1)
|
||||
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]').text()).toBe('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')
|
||||
})
|
||||
})
|
||||
|
||||
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
|
||||
refreshing: boolean
|
||||
manualRefresh: boolean
|
||||
scan: { done: number; total: number }
|
||||
counts: { crit: number; warn: number }
|
||||
thresholds: Thresholds
|
||||
repos: Repo[]
|
||||
|
||||
@@ -134,6 +134,15 @@ describe('tracker store', () => {
|
||||
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 () => {
|
||||
mockFetch(() => json(mixedState()))
|
||||
const store = useTrackerStore()
|
||||
|
||||
@@ -122,6 +122,12 @@ export const useTrackerStore = defineStore('tracker', () => {
|
||||
)
|
||||
})
|
||||
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 facts = computed(() => (state.value ? computeFacts(state.value) : null))
|
||||
|
||||
@@ -189,6 +195,7 @@ export const useTrackerStore = defineStore('tracker', () => {
|
||||
activeFilters,
|
||||
visibleBlocks,
|
||||
repoCounts,
|
||||
rowCounts,
|
||||
facts,
|
||||
toggleRepo,
|
||||
clearFilters,
|
||||
|
||||
@@ -49,6 +49,7 @@ export function state(over: Partial<TrackerState> = {}): TrackerState {
|
||||
loaded: true,
|
||||
refreshing: false,
|
||||
manualRefresh: false,
|
||||
scan: { done: 0, total: 0 },
|
||||
counts: { crit: 0, warn: 0 },
|
||||
thresholds: {
|
||||
prWarnSeconds: 172800,
|
||||
|
||||
Reference in New Issue
Block a user