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:
File diff suppressed because it is too large
Load Diff
@@ -1,15 +1,33 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fetchTimeout is the maximum time allowed for all data fetches to complete.
|
||||
const fetchTimeout = 8 * time.Second
|
||||
|
||||
// ConvoyFetcher defines the interface for fetching convoy data.
|
||||
type ConvoyFetcher interface {
|
||||
FetchConvoys() ([]ConvoyRow, error)
|
||||
FetchMergeQueue() ([]MergeQueueRow, error)
|
||||
FetchPolecats() ([]PolecatRow, error)
|
||||
FetchMail() ([]MailRow, error)
|
||||
FetchRigs() ([]RigRow, error)
|
||||
FetchDogs() ([]DogRow, error)
|
||||
FetchEscalations() ([]EscalationRow, error)
|
||||
FetchHealth() (*HealthRow, error)
|
||||
FetchQueues() ([]QueueRow, error)
|
||||
FetchSessions() ([]SessionRow, error)
|
||||
FetchHooks() ([]HookRow, error)
|
||||
FetchMayor() (*MayorStatus, error)
|
||||
FetchIssues() ([]IssueRow, error)
|
||||
FetchActivity() ([]ActivityRow, error)
|
||||
}
|
||||
|
||||
// ConvoyHandler handles HTTP requests for the convoy dashboard.
|
||||
@@ -33,28 +51,181 @@ func NewConvoyHandler(fetcher ConvoyFetcher) (*ConvoyHandler, error) {
|
||||
|
||||
// ServeHTTP handles GET / requests and renders the convoy dashboard.
|
||||
func (h *ConvoyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
convoys, err := h.fetcher.FetchConvoys()
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to fetch convoys", http.StatusInternalServerError)
|
||||
return
|
||||
// Check for expand parameter (fullscreen a specific panel)
|
||||
expandPanel := r.URL.Query().Get("expand")
|
||||
|
||||
// Create a timeout context for all fetches
|
||||
ctx, cancel := context.WithTimeout(r.Context(), fetchTimeout)
|
||||
defer cancel()
|
||||
|
||||
var (
|
||||
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
|
||||
wg sync.WaitGroup
|
||||
)
|
||||
|
||||
// Run all fetches in parallel with error logging
|
||||
wg.Add(14)
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
var err error
|
||||
convoys, err = h.fetcher.FetchConvoys()
|
||||
if err != nil {
|
||||
log.Printf("dashboard: FetchConvoys failed: %v", err)
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
var err error
|
||||
mergeQueue, err = h.fetcher.FetchMergeQueue()
|
||||
if err != nil {
|
||||
log.Printf("dashboard: FetchMergeQueue failed: %v", err)
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
var err error
|
||||
polecats, err = h.fetcher.FetchPolecats()
|
||||
if err != nil {
|
||||
log.Printf("dashboard: FetchPolecats failed: %v", err)
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
var err error
|
||||
mail, err = h.fetcher.FetchMail()
|
||||
if err != nil {
|
||||
log.Printf("dashboard: FetchMail failed: %v", err)
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
var err error
|
||||
rigs, err = h.fetcher.FetchRigs()
|
||||
if err != nil {
|
||||
log.Printf("dashboard: FetchRigs failed: %v", err)
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
var err error
|
||||
dogs, err = h.fetcher.FetchDogs()
|
||||
if err != nil {
|
||||
log.Printf("dashboard: FetchDogs failed: %v", err)
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
var err error
|
||||
escalations, err = h.fetcher.FetchEscalations()
|
||||
if err != nil {
|
||||
log.Printf("dashboard: FetchEscalations failed: %v", err)
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
var err error
|
||||
health, err = h.fetcher.FetchHealth()
|
||||
if err != nil {
|
||||
log.Printf("dashboard: FetchHealth failed: %v", err)
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
var err error
|
||||
queues, err = h.fetcher.FetchQueues()
|
||||
if err != nil {
|
||||
log.Printf("dashboard: FetchQueues failed: %v", err)
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
var err error
|
||||
sessions, err = h.fetcher.FetchSessions()
|
||||
if err != nil {
|
||||
log.Printf("dashboard: FetchSessions failed: %v", err)
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
var err error
|
||||
hooks, err = h.fetcher.FetchHooks()
|
||||
if err != nil {
|
||||
log.Printf("dashboard: FetchHooks failed: %v", err)
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
var err error
|
||||
mayor, err = h.fetcher.FetchMayor()
|
||||
if err != nil {
|
||||
log.Printf("dashboard: FetchMayor failed: %v", err)
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
var err error
|
||||
issues, err = h.fetcher.FetchIssues()
|
||||
if err != nil {
|
||||
log.Printf("dashboard: FetchIssues failed: %v", err)
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
var err error
|
||||
activity, err = h.fetcher.FetchActivity()
|
||||
if err != nil {
|
||||
log.Printf("dashboard: FetchActivity failed: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Wait for fetches or timeout
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
// All fetches completed
|
||||
case <-ctx.Done():
|
||||
log.Printf("dashboard: fetch timeout after %v", fetchTimeout)
|
||||
}
|
||||
|
||||
mergeQueue, err := h.fetcher.FetchMergeQueue()
|
||||
if err != nil {
|
||||
// Non-fatal: show convoys even if merge queue fails
|
||||
mergeQueue = nil
|
||||
}
|
||||
|
||||
polecats, err := h.fetcher.FetchPolecats()
|
||||
if err != nil {
|
||||
// Non-fatal: show convoys even if polecats fail
|
||||
polecats = nil
|
||||
}
|
||||
// Compute summary from already-fetched data
|
||||
summary := computeSummary(polecats, hooks, issues, convoys, escalations, activity)
|
||||
|
||||
data := ConvoyData{
|
||||
Convoys: convoys,
|
||||
MergeQueue: mergeQueue,
|
||||
Polecats: polecats,
|
||||
Convoys: convoys,
|
||||
MergeQueue: mergeQueue,
|
||||
Polecats: polecats,
|
||||
Mail: mail,
|
||||
Rigs: rigs,
|
||||
Dogs: dogs,
|
||||
Escalations: escalations,
|
||||
Health: health,
|
||||
Queues: queues,
|
||||
Sessions: sessions,
|
||||
Hooks: hooks,
|
||||
Mayor: mayor,
|
||||
Issues: issues,
|
||||
Activity: activity,
|
||||
Summary: summary,
|
||||
Expand: expandPanel,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
@@ -64,3 +235,60 @@ func (h *ConvoyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// computeSummary calculates dashboard stats and alerts from fetched data.
|
||||
func computeSummary(polecats []PolecatRow, hooks []HookRow, issues []IssueRow,
|
||||
convoys []ConvoyRow, escalations []EscalationRow, activity []ActivityRow) *DashboardSummary {
|
||||
|
||||
summary := &DashboardSummary{
|
||||
PolecatCount: len(polecats),
|
||||
HookCount: len(hooks),
|
||||
IssueCount: len(issues),
|
||||
ConvoyCount: len(convoys),
|
||||
EscalationCount: len(escalations),
|
||||
}
|
||||
|
||||
// Count stuck polecats (status = "stuck")
|
||||
for _, p := range polecats {
|
||||
if p.WorkStatus == "stuck" {
|
||||
summary.StuckPolecats++
|
||||
}
|
||||
}
|
||||
|
||||
// Count stale hooks (IsStale = true)
|
||||
for _, h := range hooks {
|
||||
if h.IsStale {
|
||||
summary.StaleHooks++
|
||||
}
|
||||
}
|
||||
|
||||
// Count unacked escalations
|
||||
for _, e := range escalations {
|
||||
if !e.Acked {
|
||||
summary.UnackedEscalations++
|
||||
}
|
||||
}
|
||||
|
||||
// Count high priority issues (P1 or P2)
|
||||
for _, i := range issues {
|
||||
if i.Priority == 1 || i.Priority == 2 {
|
||||
summary.HighPriorityIssues++
|
||||
}
|
||||
}
|
||||
|
||||
// Count recent session deaths from activity
|
||||
for _, a := range activity {
|
||||
if a.Type == "session_death" || a.Type == "mass_death" {
|
||||
summary.DeadSessions++
|
||||
}
|
||||
}
|
||||
|
||||
// Set HasAlerts flag
|
||||
summary.HasAlerts = summary.StuckPolecats > 0 ||
|
||||
summary.StaleHooks > 0 ||
|
||||
summary.UnackedEscalations > 0 ||
|
||||
summary.DeadSessions > 0 ||
|
||||
summary.HighPriorityIssues > 0
|
||||
|
||||
return summary
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -14,9 +14,155 @@ var templateFS embed.FS
|
||||
|
||||
// ConvoyData represents data passed to the convoy template.
|
||||
type ConvoyData struct {
|
||||
Convoys []ConvoyRow
|
||||
MergeQueue []MergeQueueRow
|
||||
Polecats []PolecatRow
|
||||
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
|
||||
Summary *DashboardSummary
|
||||
Expand string // Panel to show fullscreen (from ?expand=name)
|
||||
}
|
||||
|
||||
// RigRow represents a registered rig in the dashboard.
|
||||
type RigRow struct {
|
||||
Name string
|
||||
GitURL string
|
||||
PolecatCount int
|
||||
CrewCount int
|
||||
HasWitness bool
|
||||
HasRefinery bool
|
||||
}
|
||||
|
||||
// DogRow represents a Deacon helper worker.
|
||||
type DogRow struct {
|
||||
Name string // Dog name (e.g., "alpha")
|
||||
State string // idle, working
|
||||
Work string // Current work assignment
|
||||
LastActive string // Formatted age (e.g., "5m ago")
|
||||
RigCount int // Number of worktrees
|
||||
}
|
||||
|
||||
// EscalationRow represents an escalation needing attention.
|
||||
type EscalationRow struct {
|
||||
ID string
|
||||
Title string
|
||||
Severity string // critical, high, medium, low
|
||||
EscalatedBy string
|
||||
Age string
|
||||
Acked bool
|
||||
}
|
||||
|
||||
// HealthRow represents system health status.
|
||||
type HealthRow struct {
|
||||
DeaconHeartbeat string // Age of heartbeat (e.g., "2m ago")
|
||||
DeaconCycle int64
|
||||
HealthyAgents int
|
||||
UnhealthyAgents int
|
||||
IsPaused bool
|
||||
PauseReason string
|
||||
HeartbeatFresh bool // true if < 5min old
|
||||
}
|
||||
|
||||
// QueueRow represents a work queue.
|
||||
type QueueRow struct {
|
||||
Name string
|
||||
Status string // active, paused, closed
|
||||
Available int
|
||||
Processing int
|
||||
Completed int
|
||||
Failed int
|
||||
}
|
||||
|
||||
// SessionRow represents a tmux session.
|
||||
type SessionRow struct {
|
||||
Name string // Session name (e.g., "gt-gastown-witness")
|
||||
Role string // witness, refinery, polecat, crew, deacon
|
||||
Rig string // Rig name if applicable
|
||||
Worker string // Worker name for polecats/crew
|
||||
Activity string // Age since last activity
|
||||
IsAlive bool // Whether Claude is running in session
|
||||
}
|
||||
|
||||
// HookRow represents a hooked bead (work pinned to an agent).
|
||||
type HookRow struct {
|
||||
ID string // Bead ID (e.g., "gt-abc12")
|
||||
Title string // Work item title
|
||||
Assignee string // Agent address (e.g., "gastown/polecats/nux")
|
||||
Agent string // Formatted agent name
|
||||
Age string // Time since hooked
|
||||
IsStale bool // True if hooked > 1 hour (potentially stuck)
|
||||
}
|
||||
|
||||
// MayorStatus represents the Mayor's current state.
|
||||
type MayorStatus struct {
|
||||
IsAttached bool // True if gt-mayor tmux session exists
|
||||
SessionName string // Tmux session name
|
||||
LastActivity string // Age since last activity
|
||||
IsActive bool // True if activity < 5 min (likely working)
|
||||
Runtime string // Which runtime (claude, codex, etc.)
|
||||
}
|
||||
|
||||
// IssueRow represents an open issue in the backlog.
|
||||
type IssueRow struct {
|
||||
ID string // Bead ID (e.g., "gt-abc12")
|
||||
Title string // Issue title
|
||||
Type string // issue, bug, feature, task
|
||||
Priority int // 1=critical, 2=high, 3=medium, 4=low
|
||||
Age string // Time since created
|
||||
Labels string // Comma-separated labels
|
||||
}
|
||||
|
||||
// ActivityRow represents an event in the activity feed.
|
||||
type ActivityRow struct {
|
||||
Time string // Formatted time (e.g., "2m ago")
|
||||
Icon string // Emoji for event type
|
||||
Type string // Event type (sling, done, mail, etc.)
|
||||
Actor string // Who did it
|
||||
Summary string // Human-readable description
|
||||
}
|
||||
|
||||
// DashboardSummary provides at-a-glance stats and alerts.
|
||||
type DashboardSummary struct {
|
||||
// Stats
|
||||
PolecatCount int
|
||||
HookCount int
|
||||
IssueCount int
|
||||
ConvoyCount int
|
||||
EscalationCount int
|
||||
|
||||
// Alerts (things needing attention)
|
||||
StuckPolecats int // No activity > 5 min
|
||||
StaleHooks int // Hooked > 1 hour
|
||||
UnackedEscalations int
|
||||
DeadSessions int // Sessions that died recently
|
||||
HighPriorityIssues int // P1/P2 issues
|
||||
|
||||
// Computed
|
||||
HasAlerts bool
|
||||
}
|
||||
|
||||
// MailRow represents a mail message in the dashboard.
|
||||
type MailRow struct {
|
||||
ID string // Message ID (e.g., "hq-msg-abc123")
|
||||
From string // Sender (e.g., "gastown/polecats/Toast")
|
||||
FromRaw string // Raw sender address for color hashing
|
||||
To string // Recipient (e.g., "mayor/")
|
||||
Subject string // Message subject
|
||||
Timestamp string // Formatted timestamp
|
||||
Age string // Human-readable age (e.g., "5m ago")
|
||||
Priority string // low, normal, high, urgent
|
||||
Type string // task, notification, reply
|
||||
Read bool // Whether message has been read
|
||||
SortKey int64 // Unix timestamp for sorting
|
||||
}
|
||||
|
||||
// PolecatRow represents a polecat worker in the dashboard.
|
||||
@@ -26,6 +172,9 @@ type PolecatRow struct {
|
||||
SessionID string // e.g., "gt-roxas-dag"
|
||||
LastActivity activity.Info // Colored activity display
|
||||
StatusHint string // Last line from pane (optional)
|
||||
IssueID string // Currently assigned issue ID (e.g., "hq-1234")
|
||||
IssueTitle string // Issue title (truncated)
|
||||
WorkStatus string // working, stale, stuck, idle
|
||||
}
|
||||
|
||||
// MergeQueueRow represents a PR in the merge queue.
|
||||
@@ -64,10 +213,15 @@ type TrackedIssue struct {
|
||||
func LoadTemplates() (*template.Template, error) {
|
||||
// Define template functions
|
||||
funcMap := template.FuncMap{
|
||||
"activityClass": activityClass,
|
||||
"statusClass": statusClass,
|
||||
"workStatusClass": workStatusClass,
|
||||
"progressPercent": progressPercent,
|
||||
"activityClass": activityClass,
|
||||
"statusClass": statusClass,
|
||||
"workStatusClass": workStatusClass,
|
||||
"progressPercent": progressPercent,
|
||||
"senderColorClass": senderColorClass,
|
||||
"severityClass": severityClass,
|
||||
"dogStateClass": dogStateClass,
|
||||
"queueStatusClass": queueStatusClass,
|
||||
"polecatStatusClass": polecatStatusClass,
|
||||
}
|
||||
|
||||
// Get the templates subdirectory
|
||||
@@ -136,3 +290,85 @@ func progressPercent(completed, total int) int {
|
||||
}
|
||||
return (completed * 100) / total
|
||||
}
|
||||
|
||||
// senderColorClass returns a CSS class for sender-based color coding.
|
||||
// Uses a simple hash to assign consistent colors to each sender.
|
||||
func senderColorClass(fromRaw string) string {
|
||||
if fromRaw == "" {
|
||||
return "sender-default"
|
||||
}
|
||||
// Simple hash: sum of bytes mod number of colors
|
||||
var sum int
|
||||
for _, b := range []byte(fromRaw) {
|
||||
sum += int(b)
|
||||
}
|
||||
colors := []string{
|
||||
"sender-cyan",
|
||||
"sender-purple",
|
||||
"sender-green",
|
||||
"sender-yellow",
|
||||
"sender-orange",
|
||||
"sender-blue",
|
||||
"sender-red",
|
||||
"sender-pink",
|
||||
}
|
||||
return colors[sum%len(colors)]
|
||||
}
|
||||
|
||||
// severityClass returns CSS class for escalation severity.
|
||||
func severityClass(severity string) string {
|
||||
switch severity {
|
||||
case "critical":
|
||||
return "severity-critical"
|
||||
case "high":
|
||||
return "severity-high"
|
||||
case "medium":
|
||||
return "severity-medium"
|
||||
case "low":
|
||||
return "severity-low"
|
||||
default:
|
||||
return "severity-unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// dogStateClass returns CSS class for dog state.
|
||||
func dogStateClass(state string) string {
|
||||
switch state {
|
||||
case "idle":
|
||||
return "dog-idle"
|
||||
case "working":
|
||||
return "dog-working"
|
||||
default:
|
||||
return "dog-unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// queueStatusClass returns CSS class for queue status.
|
||||
func queueStatusClass(status string) string {
|
||||
switch status {
|
||||
case "active":
|
||||
return "queue-active"
|
||||
case "paused":
|
||||
return "queue-paused"
|
||||
case "closed":
|
||||
return "queue-closed"
|
||||
default:
|
||||
return "queue-unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// polecatStatusClass returns CSS class for polecat work status.
|
||||
func polecatStatusClass(status string) string {
|
||||
switch status {
|
||||
case "working":
|
||||
return "polecat-working"
|
||||
case "stale":
|
||||
return "polecat-stale"
|
||||
case "stuck":
|
||||
return "polecat-stuck"
|
||||
case "idle":
|
||||
return "polecat-idle"
|
||||
default:
|
||||
return "polecat-unknown"
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -54,13 +54,8 @@ func TestConvoyTemplate_RendersConvoyList(t *testing.T) {
|
||||
t.Error("Template should contain convoy ID hq-cv-def")
|
||||
}
|
||||
|
||||
// Check titles are rendered
|
||||
if !strings.Contains(output, "Feature X") {
|
||||
t.Error("Template should contain title 'Feature X'")
|
||||
}
|
||||
if !strings.Contains(output, "Bugfix Y") {
|
||||
t.Error("Template should contain title 'Bugfix Y'")
|
||||
}
|
||||
// The simplified dashboard no longer shows convoy titles in the table,
|
||||
// only the convoy IDs. Titles are shown in expanded view.
|
||||
}
|
||||
|
||||
func TestConvoyTemplate_LastActivityColors(t *testing.T) {
|
||||
@@ -184,14 +179,16 @@ func TestConvoyTemplate_StatusIndicators(t *testing.T) {
|
||||
data := ConvoyData{
|
||||
Convoys: []ConvoyRow{
|
||||
{
|
||||
ID: "hq-cv-open",
|
||||
Title: "Open Convoy",
|
||||
Status: "open",
|
||||
ID: "hq-cv-active",
|
||||
Title: "Active Convoy",
|
||||
Status: "open",
|
||||
WorkStatus: "active",
|
||||
},
|
||||
{
|
||||
ID: "hq-cv-closed",
|
||||
Title: "Closed Convoy",
|
||||
Status: "closed",
|
||||
ID: "hq-cv-stuck",
|
||||
Title: "Stuck Convoy",
|
||||
Status: "open",
|
||||
WorkStatus: "stuck",
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -204,12 +201,12 @@ func TestConvoyTemplate_StatusIndicators(t *testing.T) {
|
||||
|
||||
output := buf.String()
|
||||
|
||||
// Check status indicators
|
||||
if !strings.Contains(output, "status-open") {
|
||||
t.Error("Template should contain status-open class")
|
||||
// Check work status badges are rendered (replaced status-open/closed classes)
|
||||
if !strings.Contains(output, "badge-green") {
|
||||
t.Error("Template should contain badge-green class for active status")
|
||||
}
|
||||
if !strings.Contains(output, "status-closed") {
|
||||
t.Error("Template should contain status-closed class")
|
||||
if !strings.Contains(output, "badge-red") {
|
||||
t.Error("Template should contain badge-red class for stuck status")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,7 +229,7 @@ func TestConvoyTemplate_EmptyState(t *testing.T) {
|
||||
output := buf.String()
|
||||
|
||||
// Check for empty state message
|
||||
if !strings.Contains(output, "No convoys") {
|
||||
if !strings.Contains(output, "No active convoys") {
|
||||
t.Error("Template should show empty state message when no convoys")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user