Compare commits

..
5 Commits
Author SHA1 Message Date
github-actions[bot]andGitHub eff9559aee chore(deps): update pnpm to v11.28.4
Automated dependency update. All tests passed.
2026-10-06 03:16:32 +00:00
lukaszraczylo 5c5416f6f0 fix(web): keep the action status fetch alive when a client disconnects
The status fetch used the request context. A client that dropped the
connection, such as a readiness probe with a one second timeout,
cancelled the fetch. The failure was then cached and every later
request showed no action status. The fetch now ignores the caller
cancellation and keeps its own timeout.
2026-10-05 15:53:52 +01:00
lukaszraczylo a0fd6d6856 feat(ui): make the alert action button prominent
The action button was a small outline control that was easy to miss.
It is now a solid primary button with an icon. The spinner still
replaces the icon while the action runs.
2026-10-05 14:05:12 +01:00
lukaszraczylo ac5eb2f16a fix(ui): stack the project list below 1280px and use two columns from 90rem
At 1024px the project list took 24rem and squeezed the alert column to
about 500px, which clipped the search box and filter counts. The list
now sits beside the alerts from 1280px. The alert cards use two columns
from 90rem, so a 1440px screen no longer shows one very wide column.
2026-10-05 14:05:12 +01:00
lukaszraczylo 66ed84bdbf fix(collector): publish each repository as its scan finishes
A rescan held every result until the last repository finished. The
dashboard therefore showed new and resolved alerts only at the end.
Each repository now enters the view when its fetch completes. The
last scan time, loaded flag and refresh hooks still update at the end.
2026-10-05 14:05:12 +01:00
9 changed files with 65 additions and 16 deletions
+6 -6
View File
@@ -155,25 +155,25 @@ func (c *Collector) Refresh(ctx context.Context) {
c.limited.Store(false) c.limited.Store(false)
c.scanDone.Store(0) c.scanDone.Store(0)
c.scanTotal.Store(int64(len(c.names))) c.scanTotal.Store(int64(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
for i, name := range c.names { for _, name := range c.names {
wg.Add(1) wg.Add(1)
sem <- struct{}{} sem <- struct{}{}
go func() { go func() {
defer wg.Done() defer wg.Done()
defer func() { <-sem }() defer func() { <-sem }()
results[i] = c.refreshOne(ctx, name, start, true) r := c.refreshOne(ctx, name, start, true)
// Publish per repo so a running scan shows new and resolved alerts as they land.
c.mu.Lock()
c.repos[r.FullName] = r
c.mu.Unlock()
c.scanDone.Add(1) c.scanDone.Add(1)
}() }()
} }
wg.Wait() wg.Wait()
c.mu.Lock() c.mu.Lock()
for _, r := range results {
c.repos[r.FullName] = r
}
c.lastRefresh = start c.lastRefresh = start
c.lastDur = c.now().Sub(start) c.lastDur = c.now().Sub(start)
c.loaded = true c.loaded = true
+31
View File
@@ -553,3 +553,34 @@ func TestRefreshRepoFailureSkipsRefreshHooks(t *testing.T) {
case <-time.After(200 * time.Millisecond): case <-time.After(200 * time.Millisecond):
} }
} }
func TestRefreshPublishesEachRepoBeforeTheScanEnds(t *testing.T) {
release := make(chan struct{})
f := &fakeFetcher{fn: func(name string) (model.Repo, error) {
if name == "o/slow" {
<-release
}
return model.Repo{FullName: name, Err: ""}, nil
}}
cfg := &config.Config{Repos: []string{"o/fast", "o/slow"}, RefreshInterval: time.Hour, Concurrency: 2, Thresholds: th()}
c := New(f, cfg, slog.New(slog.NewTextHandler(io.Discard, nil)))
c.now = func() time.Time { return t0 }
done := make(chan struct{})
go func() { c.Refresh(context.Background()); close(done) }()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if v := c.View(); len(v.Repos) == 1 && v.Repos[0].FullName == "o/fast" {
break
}
time.Sleep(5 * time.Millisecond)
}
v := c.View()
if len(v.Repos) != 1 || v.Repos[0].FullName != "o/fast" || !v.Refreshing || v.Loaded {
t.Fatalf("mid-scan view: repos=%d refreshing=%v loaded=%v", len(v.Repos), v.Refreshing, v.Loaded)
}
close(release)
<-done
if v := c.View(); len(v.Repos) != 2 || !v.Loaded {
t.Fatalf("after scan: repos=%d loaded=%v", len(v.Repos), v.Loaded)
}
}
+2 -1
View File
@@ -158,7 +158,8 @@ func (a *Actions) statusRows(ctx context.Context, rt *actionRuntime) (map[alertR
if !rt.fetched.IsZero() && a.now().Sub(rt.fetched) < statusCacheTTL { if !rt.fetched.IsZero() && a.now().Sub(rt.fetched) < statusCacheTTL {
return rt.rows, rt.err return rt.rows, rt.err
} }
rows, err := a.fetchStatus(ctx, rt.cfg) // A client that disconnects mid-request must not fail the fetch that every later request reuses.
rows, err := a.fetchStatus(context.WithoutCancel(ctx), rt.cfg)
if err != nil { if err != nil {
a.log.Warn("action status unavailable", "action", rt.cfg.ID, "err", err) a.log.Warn("action status unavailable", "action", rt.cfg.ID, "err", err)
} }
+13
View File
@@ -2,6 +2,7 @@ package web
import ( import (
"bytes" "bytes"
"context"
"encoding/json" "encoding/json"
"io" "io"
"log/slog" "log/slog"
@@ -407,3 +408,15 @@ func TestRedirectDoesNotForwardToken(t *testing.T) {
t.Fatal("bearer token forwarded across a redirect") t.Fatal("bearer token forwarded across a redirect")
} }
} }
func TestStatusFetchSurvivesCancelledRequest(t *testing.T) {
fh := newFakeHook(t)
fh.statBody = `[{"repo":"o/r","url":"` + alertURL + `","state":"running"}]`
h := newActionServer(t, nil, fh.srv.URL)
ctx, cancel := context.WithCancel(context.Background())
cancel()
rows, err := h.actions.statusRows(ctx, h.actions.list[0])
if err != nil || len(rows) != 1 {
t.Fatalf("rows=%d err=%v, want the fetch to complete despite the cancelled request", len(rows), err)
}
}
+1 -1
View File
@@ -43,5 +43,5 @@
"vitest": "^5.0.1", "vitest": "^5.0.1",
"vue-tsc": "^3.3.11" "vue-tsc": "^3.3.11"
}, },
"packageManager": "[email protected].3" "packageManager": "[email protected].4"
} }
+2 -2
View File
@@ -116,7 +116,7 @@ onBeforeUnmount(() => {
/> />
</div> </div>
<div <div
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]" class="mt-10 grid grid-cols-[minmax(0,1fr)] gap-x-10 gap-y-10 sm:mt-14 xl:grid-cols-[minmax(0,1fr)_24rem] 2xl:grid-cols-[minmax(0,1fr)_28rem]"
> >
<AttentionList <AttentionList
ref="list" ref="list"
@@ -133,7 +133,7 @@ onBeforeUnmount(() => {
@clear="store.clearFilters()" @clear="store.clearFilters()"
@refresh="store.refreshRepo" @refresh="store.refreshRepo"
/> />
<aside class="lg:sticky lg:top-6 lg:self-start"> <aside class="xl:sticky xl:top-6 xl:self-start">
<ProjectList <ProjectList
:repos="visibleRepos" :repos="visibleRepos"
:total="state.repos.length" :total="state.repos.length"
+1 -1
View File
@@ -89,7 +89,7 @@ const LABEL = { crit: 'Critical', warn: 'Warning', ok: 'Healthy' } as const
<ul <ul
v-else v-else
ref="scroller" 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)]" 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] xl:max-h-[calc(100vh-7rem)]"
> >
<li <li
v-for="repo in visible" v-for="repo in visible"
+8 -4
View File
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref } from 'vue'
import { faSpinner } from '@fortawesome/free-solid-svg-icons' import { faScrewdriverWrench, faSpinner } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome' import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { import {
AlertDialog, AlertDialog,
@@ -44,14 +44,18 @@ function press(a: ActionDef): void {
<div class="mt-2 flex flex-wrap items-center gap-2" data-test="row-actions"> <div class="mt-2 flex flex-wrap items-center gap-2" data-test="row-actions">
<template v-for="a in row.actions" :key="a.id"> <template v-for="a in row.actions" :key="a.id">
<Button <Button
variant="outline" size="sm"
size="xs" class="font-semibold shadow-sm"
:disabled="busy(a)" :disabled="busy(a)"
:aria-busy="busy(a)" :aria-busy="busy(a)"
:data-test="`action-${a.id}`" :data-test="`action-${a.id}`"
@click="press(a)" @click="press(a)"
> >
<FontAwesomeIcon v-if="busy(a)" :icon="faSpinner" spin aria-hidden="true" /> <FontAwesomeIcon
:icon="busy(a) ? faSpinner : faScrewdriverWrench"
:spin="busy(a)"
aria-hidden="true"
/>
{{ a.label }} {{ a.label }}
</Button> </Button>
<Badge v-if="busy(a)" variant="secondary" data-test="action-status">Sending</Badge> <Badge v-if="busy(a)" variant="secondary" data-test="action-status">Sending</Badge>
+1 -1
View File
@@ -1,7 +1,7 @@
import { onBeforeUnmount, onMounted, ref, type Ref } from 'vue' import { onBeforeUnmount, onMounted, ref, type Ref } from 'vue'
/** 1 column below the breakpoint, 2 at or above it; follows the viewport live. */ /** 1 column below the breakpoint, 2 at or above it; follows the viewport live. */
export function useColumns(minWidth = '(min-width: 96rem)'): Ref<number> { export function useColumns(minWidth = '(min-width: 90rem)'): Ref<number> {
const columns = ref(1) const columns = ref(1)
let query: MediaQueryList | null = null let query: MediaQueryList | null = null
const update = () => { const update = () => {