feat(ui): add a progress loading screen and lazy project list

The loading screen shows live scan progress from the server, with a pulsing outline of the dashboard. The server now reports how many repositories the running scan has finished.

The project list renders 20 rows first and adds more as the end scrolls into view. Groups on very wide screens fill two balanced columns, the fact figures sit in a row under the health strip, and the headline counts match the rows listed below it.
This commit is contained in:
2026-10-02 04:50:38 +01:00
parent 1852ea0885
commit a56a12a6e0
19 changed files with 552 additions and 60 deletions
+8
View File
@@ -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),
+5 -1
View File
@@ -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)
}