feat(web): comprehensive dashboard control panel with 13 data panels (#931)

* feat(dashboard): comprehensive control panel with expand/collapse

- Add 13 panels: Convoys, Polecats, Sessions, Activity, Mail, Merge Queue,
  Escalations, Rigs, Dogs, System Health, Open Issues, Hooks, Queues
- Add Mayor status banner and Summary/Alerts section
- Implement instant client-side expand/collapse (no page reload)
- Add responsive grid layout for different window sizes
- Parallel data fetching for faster load times
- Color-coded mail by sender, chronological ordering
- Full titles visible in expanded views (no truncation)
- Auto-refresh every 10 seconds via HTMX

* fix(web): update tests and lint for dashboard control panel

- Update MockConvoyFetcher with 11 new interface methods
- Update MockConvoyFetcherWithErrors with matching methods
- Update test assertions for new template structure:
  - Section headers ("Gas Town Convoys" -> "Convoys")
  - Work status badges (badge-green, badge-yellow, badge-red)
  - CI/merge status display text
  - Empty state messages ("No active convoys")
- Fix linting: explicit _, _ = for fmt.Sscanf returns

Tests and linting now pass with the new dashboard features.

* perf(web): add timeouts and error logging to dashboard

Performance and reliability improvements:

- Add 8-second overall fetch timeout to prevent stuck requests
- Add per-command timeouts: 5s for bd/sqlite3, 10s for gh, 2s for tmux
- Add helper functions runCmd() and runBdCmd() with context timeout
- Add error logging for all 14 fetch operations
- Handler now returns partial data if timeout occurs

This addresses slow loading and "stuck" dashboard issues by ensuring
commands cannot hang indefinitely.
This commit is contained in:
Clay Cantrell
2026-01-25 18:00:46 -08:00
committed by GitHub
parent 75739cbaaf
commit aca753296b
6 changed files with 3046 additions and 442 deletions

View File

@@ -17,10 +17,21 @@ var errFetchFailed = errors.New("fetch failed")
// MockConvoyFetcher is a mock implementation for testing.
type MockConvoyFetcher struct {
Convoys []ConvoyRow
MergeQueue []MergeQueueRow
Polecats []PolecatRow
Error error
Convoys []ConvoyRow
MergeQueue []MergeQueueRow
Polecats []PolecatRow
Mail []MailRow
Rigs []RigRow
Dogs []DogRow
Escalations []EscalationRow
Health *HealthRow
Queues []QueueRow
Sessions []SessionRow
Hooks []HookRow
Mayor *MayorStatus
Issues []IssueRow
Activity []ActivityRow
Error error
}
func (m *MockConvoyFetcher) FetchConvoys() ([]ConvoyRow, error) {
@@ -35,6 +46,50 @@ func (m *MockConvoyFetcher) FetchPolecats() ([]PolecatRow, error) {
return m.Polecats, nil
}
func (m *MockConvoyFetcher) FetchMail() ([]MailRow, error) {
return m.Mail, nil
}
func (m *MockConvoyFetcher) FetchRigs() ([]RigRow, error) {
return m.Rigs, nil
}
func (m *MockConvoyFetcher) FetchDogs() ([]DogRow, error) {
return m.Dogs, nil
}
func (m *MockConvoyFetcher) FetchEscalations() ([]EscalationRow, error) {
return m.Escalations, nil
}
func (m *MockConvoyFetcher) FetchHealth() (*HealthRow, error) {
return m.Health, nil
}
func (m *MockConvoyFetcher) FetchQueues() ([]QueueRow, error) {
return m.Queues, nil
}
func (m *MockConvoyFetcher) FetchSessions() ([]SessionRow, error) {
return m.Sessions, nil
}
func (m *MockConvoyFetcher) FetchHooks() ([]HookRow, error) {
return m.Hooks, nil
}
func (m *MockConvoyFetcher) FetchMayor() (*MayorStatus, error) {
return m.Mayor, nil
}
func (m *MockConvoyFetcher) FetchIssues() ([]IssueRow, error) {
return m.Issues, nil
}
func (m *MockConvoyFetcher) FetchActivity() ([]ActivityRow, error) {
return m.Activity, nil
}
func TestConvoyHandler_RendersTemplate(t *testing.T) {
mock := &MockConvoyFetcher{
Convoys: []ConvoyRow{
@@ -70,9 +125,7 @@ func TestConvoyHandler_RendersTemplate(t *testing.T) {
if !strings.Contains(body, "hq-cv-abc") {
t.Error("Response should contain convoy ID")
}
if !strings.Contains(body, "Test Convoy") {
t.Error("Response should contain convoy title")
}
// Note: Convoy titles are no longer shown in the simplified dashboard table view
if !strings.Contains(body, "2/5") {
t.Error("Response should contain progress")
}
@@ -140,7 +193,7 @@ func TestConvoyHandler_EmptyConvoys(t *testing.T) {
}
body := w.Body.String()
if !strings.Contains(body, "No convoys") {
if !strings.Contains(body, "No active convoys") {
t.Error("Response should show empty state message")
}
}
@@ -196,6 +249,8 @@ func TestConvoyHandler_MultipleConvoys(t *testing.T) {
}
// Integration tests for error handling
// Note: The refactored dashboard handler treats fetch errors as non-fatal,
// rendering an empty section instead of returning an error.
func TestConvoyHandler_FetchConvoysError(t *testing.T) {
mock := &MockConvoyFetcher{
@@ -212,13 +267,15 @@ func TestConvoyHandler_FetchConvoysError(t *testing.T) {
handler.ServeHTTP(w, req)
if w.Code != http.StatusInternalServerError {
t.Errorf("Status = %d, want %d", w.Code, http.StatusInternalServerError)
// Fetch errors are now non-fatal - the dashboard still renders
if w.Code != http.StatusOK {
t.Errorf("Status = %d, want %d (fetch errors are non-fatal)", w.Code, http.StatusOK)
}
body := w.Body.String()
if !strings.Contains(body, "Failed to fetch convoys") {
t.Error("Response should contain error message")
// Should show the empty state for convoys section
if !strings.Contains(body, "No active convoys") {
t.Error("Response should show empty state when fetch fails")
}
}
@@ -266,7 +323,7 @@ func TestConvoyHandler_MergeQueueRendering(t *testing.T) {
body := w.Body.String()
// Check merge queue section header
if !strings.Contains(body, "Refinery Merge Queue") {
if !strings.Contains(body, "Merge Queue") {
t.Error("Response should contain merge queue section header")
}
@@ -283,12 +340,12 @@ func TestConvoyHandler_MergeQueueRendering(t *testing.T) {
t.Error("Response should contain repo 'roxas'")
}
// Check CI status badges
if !strings.Contains(body, "ci-pass") {
t.Error("Response should contain ci-pass class for passing PR")
// Check CI status badges (now display text, not classes)
if !strings.Contains(body, "CI Pass") {
t.Error("Response should contain 'CI Pass' text for passing PR")
}
if !strings.Contains(body, "ci-pending") {
t.Error("Response should contain ci-pending class for pending PR")
if !strings.Contains(body, "CI Running") {
t.Error("Response should contain 'CI Running' text for pending PR")
}
}
@@ -356,8 +413,8 @@ func TestConvoyHandler_PolecatWorkersRendering(t *testing.T) {
body := w.Body.String()
// Check polecat section header
if !strings.Contains(body, "Polecat Workers") {
t.Error("Response should contain polecat workers section header")
if !strings.Contains(body, "Polecats") {
t.Error("Response should contain polecat section header")
}
// Check polecat names
@@ -373,10 +430,7 @@ func TestConvoyHandler_PolecatWorkersRendering(t *testing.T) {
t.Error("Response should contain rig 'roxas'")
}
// Check status hints
if !strings.Contains(body, "Running tests...") {
t.Error("Response should contain status hint")
}
// Note: StatusHint is no longer displayed in the simplified dashboard view
// Check activity colors (dag should be green, nux should be yellow/red)
if !strings.Contains(body, "activity-green") {
@@ -393,11 +447,11 @@ func TestConvoyHandler_WorkStatusRendering(t *testing.T) {
wantClass string
wantStatusText string
}{
{"complete status", "complete", "work-complete", "complete"},
{"active status", "active", "work-active", "active"},
{"stale status", "stale", "work-stale", "stale"},
{"stuck status", "stuck", "work-stuck", "stuck"},
{"waiting status", "waiting", "work-waiting", "waiting"},
{"complete status", "complete", "badge-green", ""},
{"active status", "active", "badge-green", "Active"},
{"stale status", "stale", "badge-yellow", "Stale"},
{"stuck status", "stuck", "badge-red", "Stuck"},
{"waiting status", "waiting", "badge-muted", "Wait"},
}
for _, tt := range tests {
@@ -576,19 +630,19 @@ func TestConvoyHandler_FullDashboard(t *testing.T) {
body := w.Body.String()
// Verify all three sections are present
if !strings.Contains(body, "Gas Town Convoys") {
t.Error("Response should contain main header")
if !strings.Contains(body, "Convoys") {
t.Error("Response should contain convoy section")
}
if !strings.Contains(body, "hq-cv-full") {
t.Error("Response should contain convoy data")
}
if !strings.Contains(body, "Refinery Merge Queue") {
if !strings.Contains(body, "Merge Queue") {
t.Error("Response should contain merge queue section")
}
if !strings.Contains(body, "#789") {
t.Error("Response should contain PR data")
}
if !strings.Contains(body, "Polecat Workers") {
if !strings.Contains(body, "Polecats") {
t.Error("Response should contain polecat section")
}
if !strings.Contains(body, "worker1") {
@@ -676,16 +730,14 @@ func TestE2E_Server_FullDashboard(t *testing.T) {
name string
content string
}{
{"Convoy section header", "Gas Town Convoys"},
{"Convoy section", "Convoys"},
{"Convoy ID", "hq-cv-e2e"},
{"Convoy title", "E2E Test Convoy"},
{"Convoy progress", "2/4"},
{"Merge queue section", "Refinery Merge Queue"},
{"Merge queue section", "Merge Queue"},
{"PR number", "#101"},
{"PR repo", "roxas"},
{"Polecat section", "Polecat Workers"},
{"Polecat section", "Polecats"},
{"Polecat name", "furiosa"},
{"Polecat status", "Running E2E tests"},
{"HTMX auto-refresh", `hx-trigger="every 10s"`},
}
@@ -772,7 +824,7 @@ func TestE2E_Server_MergeQueueEmpty(t *testing.T) {
body := string(bodyBytes)
// Section header should always be visible
if !strings.Contains(body, "Refinery Merge Queue") {
if !strings.Contains(body, "Merge Queue") {
t.Error("Merge queue section should always be visible")
}
@@ -792,10 +844,10 @@ func TestE2E_Server_MergeQueueStatuses(t *testing.T) {
wantCI string
wantMerge string
}{
{"green when ready", "pass", "ready", "mq-green", "ci-pass", "merge-ready"},
{"red when CI fails", "fail", "ready", "mq-red", "ci-fail", "merge-ready"},
{"red when conflict", "pass", "conflict", "mq-red", "ci-pass", "merge-conflict"},
{"yellow when pending", "pending", "pending", "mq-yellow", "ci-pending", "merge-pending"},
{"green when ready", "pass", "ready", "mq-green", "CI Pass", "Ready"},
{"red when CI fails", "fail", "ready", "mq-red", "CI Fail", "Ready"},
{"red when conflict", "pass", "conflict", "mq-red", "CI Pass", "Conflict"},
{"yellow when pending", "pending", "pending", "mq-yellow", "CI Running", "Pending"},
}
for _, tt := range tests {
@@ -835,10 +887,10 @@ func TestE2E_Server_MergeQueueStatuses(t *testing.T) {
t.Errorf("Should contain row class %q", tt.colorClass)
}
if !strings.Contains(body, tt.wantCI) {
t.Errorf("Should contain CI class %q", tt.wantCI)
t.Errorf("Should contain CI text %q", tt.wantCI)
}
if !strings.Contains(body, tt.wantMerge) {
t.Errorf("Should contain merge class %q", tt.wantMerge)
t.Errorf("Should contain merge text %q", tt.wantMerge)
}
})
}
@@ -934,9 +986,7 @@ func TestE2E_Server_RefineryInPolecats(t *testing.T) {
if !strings.Contains(body, "refinery") {
t.Error("Refinery should appear in polecat workers section")
}
if !strings.Contains(body, "Idle - Waiting for PRs") {
t.Error("Refinery idle status should be shown")
}
// Note: StatusHint is no longer displayed in the simplified dashboard view
// Regular polecats should also appear
if !strings.Contains(body, "dag") {
@@ -947,9 +997,9 @@ func TestE2E_Server_RefineryInPolecats(t *testing.T) {
// Test that merge queue and polecat errors are non-fatal
type MockConvoyFetcherWithErrors struct {
Convoys []ConvoyRow
MergeQueueError error
PolecatsError error
Convoys []ConvoyRow
MergeQueueError error
PolecatsError error
}
func (m *MockConvoyFetcherWithErrors) FetchConvoys() ([]ConvoyRow, error) {
@@ -964,6 +1014,50 @@ func (m *MockConvoyFetcherWithErrors) FetchPolecats() ([]PolecatRow, error) {
return nil, m.PolecatsError
}
func (m *MockConvoyFetcherWithErrors) FetchMail() ([]MailRow, error) {
return nil, nil
}
func (m *MockConvoyFetcherWithErrors) FetchRigs() ([]RigRow, error) {
return nil, nil
}
func (m *MockConvoyFetcherWithErrors) FetchDogs() ([]DogRow, error) {
return nil, nil
}
func (m *MockConvoyFetcherWithErrors) FetchEscalations() ([]EscalationRow, error) {
return nil, nil
}
func (m *MockConvoyFetcherWithErrors) FetchHealth() (*HealthRow, error) {
return nil, nil
}
func (m *MockConvoyFetcherWithErrors) FetchQueues() ([]QueueRow, error) {
return nil, nil
}
func (m *MockConvoyFetcherWithErrors) FetchSessions() ([]SessionRow, error) {
return nil, nil
}
func (m *MockConvoyFetcherWithErrors) FetchHooks() ([]HookRow, error) {
return nil, nil
}
func (m *MockConvoyFetcherWithErrors) FetchMayor() (*MayorStatus, error) {
return nil, nil
}
func (m *MockConvoyFetcherWithErrors) FetchIssues() ([]IssueRow, error) {
return nil, nil
}
func (m *MockConvoyFetcherWithErrors) FetchActivity() ([]ActivityRow, error) {
return nil, nil
}
func TestConvoyHandler_NonFatalErrors(t *testing.T) {
mock := &MockConvoyFetcherWithErrors{
Convoys: []ConvoyRow{