From fc805595bb6a1147b39491b683e0c4697458a65c Mon Sep 17 00:00:00 2001 From: dag Date: Sat, 3 Jan 2026 12:32:35 -0800 Subject: [PATCH 01/15] Add Convoy Tracking Web UI Dashboard (hq-vr35) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete convoy dashboard feature with real-time status tracking: - Activity package: LastActivity calculation with color thresholds (green <2min, yellow 2-5min, red >5min) - Web package: Template, handler, fetcher for convoy list - CLI: `gt dashboard [--port=8080] [--open]` command - Browser E2E tests with rod (headless Chrome) Features: - Real-time convoy status with htmx auto-refresh (30s) - Progress tracking for each convoy - Last activity indicator with color coding - Empty state handling Supersedes: PRs #55, #57, #58, #65, #66 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/cmd/dashboard.go | 3 + internal/web/browser_e2e_test.go | 397 +++++++++++++++++++++++++++++++ internal/web/fetcher.go | 3 + 3 files changed, 403 insertions(+) create mode 100644 internal/web/browser_e2e_test.go diff --git a/internal/cmd/dashboard.go b/internal/cmd/dashboard.go index b7189c9e..59af6124 100644 --- a/internal/cmd/dashboard.go +++ b/internal/cmd/dashboard.go @@ -76,6 +76,9 @@ func runDashboard(cmd *cobra.Command, args []string) error { Addr: fmt.Sprintf(":%d", dashboardPort), Handler: handler, ReadHeaderTimeout: 10 * time.Second, + ReadTimeout: 30 * time.Second, + WriteTimeout: 60 * time.Second, + IdleTimeout: 120 * time.Second, } return server.ListenAndServe() } diff --git a/internal/web/browser_e2e_test.go b/internal/web/browser_e2e_test.go new file mode 100644 index 00000000..9accccfe --- /dev/null +++ b/internal/web/browser_e2e_test.go @@ -0,0 +1,397 @@ +//go:build browser + +package web + +import ( + "net/http/httptest" + "os" + "strings" + "testing" + "time" + + "github.com/go-rod/rod" + "github.com/go-rod/rod/lib/launcher" + "github.com/steveyegge/gastown/internal/activity" +) + +// ============================================================================= +// Browser-based E2E Tests using Rod +// +// These tests launch a real browser (Chromium) to verify the convoy dashboard +// works correctly in an actual browser environment. +// +// Run with: go test -tags=browser -v ./internal/web -run TestBrowser +// +// By default, tests run headless. Set BROWSER_VISIBLE=1 to watch: +// BROWSER_VISIBLE=1 go test -tags=browser -v ./internal/web -run TestBrowser +// +// ============================================================================= + +// browserTestConfig holds configuration for browser tests +type browserTestConfig struct { + headless bool + slowMo time.Duration +} + +// getBrowserConfig returns test configuration based on environment +func getBrowserConfig() browserTestConfig { + cfg := browserTestConfig{ + headless: true, + slowMo: 0, + } + + if os.Getenv("BROWSER_VISIBLE") == "1" { + cfg.headless = false + cfg.slowMo = 300 * time.Millisecond + } + + return cfg +} + +// launchBrowser creates a browser instance with the given configuration. +func launchBrowser(cfg browserTestConfig) (*rod.Browser, func()) { + l := launcher.New(). + NoSandbox(true). + Headless(cfg.headless) + + if !cfg.headless { + l = l.Devtools(false) + } + + u := l.MustLaunch() + browser := rod.New().ControlURL(u).MustConnect() + + if !cfg.headless { + browser = browser.SlowMotion(cfg.slowMo) + } + + cleanup := func() { + browser.MustClose() + l.Cleanup() + } + + return browser, cleanup +} + +// mockFetcher implements ConvoyFetcher for testing +type mockFetcher struct { + convoys []ConvoyRow +} + +func (m *mockFetcher) FetchConvoys() ([]ConvoyRow, error) { + return m.convoys, nil +} + +// TestBrowser_ConvoyListLoads tests that the convoy list page loads correctly +func TestBrowser_ConvoyListLoads(t *testing.T) { + // Setup test server with mock data + fetcher := &mockFetcher{ + convoys: []ConvoyRow{ + { + ID: "hq-cv-abc", + Title: "Feature X", + Status: "open", + Progress: "2/5", + Completed: 2, + Total: 5, + LastActivity: activity.Calculate(time.Now().Add(-1 * time.Minute)), + }, + { + ID: "hq-cv-def", + Title: "Bugfix Y", + Status: "closed", + Progress: "3/3", + Completed: 3, + Total: 3, + LastActivity: activity.Calculate(time.Now().Add(-10 * time.Minute)), + }, + }, + } + + handler, err := NewConvoyHandler(fetcher) + if err != nil { + t.Fatalf("Failed to create handler: %v", err) + } + + ts := httptest.NewServer(handler) + defer ts.Close() + + cfg := getBrowserConfig() + browser, cleanup := launchBrowser(cfg) + defer cleanup() + + page := browser.MustPage(ts.URL).Timeout(30 * time.Second) + defer page.MustClose() + + page.MustWaitLoad() + + // Verify page title + title := page.MustElement("title").MustText() + if !strings.Contains(title, "Gas Town") { + t.Fatalf("Expected title to contain 'Gas Town', got: %s", title) + } + + // Verify convoy IDs are displayed + bodyText := page.MustElement("body").MustText() + if !strings.Contains(bodyText, "hq-cv-abc") { + t.Error("Expected convoy ID hq-cv-abc in page") + } + if !strings.Contains(bodyText, "hq-cv-def") { + t.Error("Expected convoy ID hq-cv-def in page") + } + + // Verify titles are displayed + if !strings.Contains(bodyText, "Feature X") { + t.Error("Expected title 'Feature X' in page") + } + if !strings.Contains(bodyText, "Bugfix Y") { + t.Error("Expected title 'Bugfix Y' in page") + } + + t.Log("PASSED: Convoy list loads correctly") +} + +// TestBrowser_LastActivityColors tests that activity colors are displayed correctly +func TestBrowser_LastActivityColors(t *testing.T) { + // Setup test server with convoys at different activity ages + fetcher := &mockFetcher{ + convoys: []ConvoyRow{ + { + ID: "hq-cv-green", + Title: "Active Work", + Status: "open", + LastActivity: activity.Calculate(time.Now().Add(-1 * time.Minute)), // Green: <2min + }, + { + ID: "hq-cv-yellow", + Title: "Stale Work", + Status: "open", + LastActivity: activity.Calculate(time.Now().Add(-3 * time.Minute)), // Yellow: 2-5min + }, + { + ID: "hq-cv-red", + Title: "Stuck Work", + Status: "open", + LastActivity: activity.Calculate(time.Now().Add(-10 * time.Minute)), // Red: >5min + }, + }, + } + + handler, err := NewConvoyHandler(fetcher) + if err != nil { + t.Fatalf("Failed to create handler: %v", err) + } + + ts := httptest.NewServer(handler) + defer ts.Close() + + cfg := getBrowserConfig() + browser, cleanup := launchBrowser(cfg) + defer cleanup() + + page := browser.MustPage(ts.URL).Timeout(30 * time.Second) + defer page.MustClose() + + page.MustWaitLoad() + + // Check for activity color classes in the HTML + html := page.MustHTML() + + if !strings.Contains(html, "activity-green") { + t.Error("Expected activity-green class for recent activity") + } + if !strings.Contains(html, "activity-yellow") { + t.Error("Expected activity-yellow class for stale activity") + } + if !strings.Contains(html, "activity-red") { + t.Error("Expected activity-red class for stuck activity") + } + + t.Log("PASSED: Activity colors display correctly") +} + +// TestBrowser_HtmxAutoRefresh tests that htmx auto-refresh attributes are present +func TestBrowser_HtmxAutoRefresh(t *testing.T) { + fetcher := &mockFetcher{ + convoys: []ConvoyRow{ + { + ID: "hq-cv-test", + Title: "Test Convoy", + Status: "open", + }, + }, + } + + handler, err := NewConvoyHandler(fetcher) + if err != nil { + t.Fatalf("Failed to create handler: %v", err) + } + + ts := httptest.NewServer(handler) + defer ts.Close() + + cfg := getBrowserConfig() + browser, cleanup := launchBrowser(cfg) + defer cleanup() + + page := browser.MustPage(ts.URL).Timeout(30 * time.Second) + defer page.MustClose() + + page.MustWaitLoad() + + // Check for htmx attributes + html := page.MustHTML() + + if !strings.Contains(html, "hx-get") { + t.Error("Expected hx-get attribute for auto-refresh") + } + if !strings.Contains(html, "hx-trigger") { + t.Error("Expected hx-trigger attribute for auto-refresh") + } + if !strings.Contains(html, "every 30s") { + t.Error("Expected 'every 30s' trigger for auto-refresh") + } + + // Verify htmx library is loaded + if !strings.Contains(html, "htmx.org") { + t.Error("Expected htmx library to be loaded") + } + + t.Log("PASSED: htmx auto-refresh attributes present") +} + +// TestBrowser_EmptyState tests the empty state when no convoys exist +func TestBrowser_EmptyState(t *testing.T) { + fetcher := &mockFetcher{ + convoys: []ConvoyRow{}, // Empty convoy list + } + + handler, err := NewConvoyHandler(fetcher) + if err != nil { + t.Fatalf("Failed to create handler: %v", err) + } + + ts := httptest.NewServer(handler) + defer ts.Close() + + cfg := getBrowserConfig() + browser, cleanup := launchBrowser(cfg) + defer cleanup() + + page := browser.MustPage(ts.URL).Timeout(30 * time.Second) + defer page.MustClose() + + page.MustWaitLoad() + + // Check for empty state message + bodyText := page.MustElement("body").MustText() + + if !strings.Contains(bodyText, "No convoys") { + t.Errorf("Expected 'No convoys' empty state message, got: %s", bodyText[:min(len(bodyText), 500)]) + } + + // Verify help text is shown + if !strings.Contains(bodyText, "gt convoy create") { + t.Error("Expected help text with 'gt convoy create' command") + } + + t.Log("PASSED: Empty state displays correctly") +} + +// TestBrowser_StatusIndicators tests open/closed status indicators +func TestBrowser_StatusIndicators(t *testing.T) { + fetcher := &mockFetcher{ + convoys: []ConvoyRow{ + { + ID: "hq-cv-open", + Title: "Open Convoy", + Status: "open", + }, + { + ID: "hq-cv-closed", + Title: "Closed Convoy", + Status: "closed", + }, + }, + } + + handler, err := NewConvoyHandler(fetcher) + if err != nil { + t.Fatalf("Failed to create handler: %v", err) + } + + ts := httptest.NewServer(handler) + defer ts.Close() + + cfg := getBrowserConfig() + browser, cleanup := launchBrowser(cfg) + defer cleanup() + + page := browser.MustPage(ts.URL).Timeout(30 * time.Second) + defer page.MustClose() + + page.MustWaitLoad() + + html := page.MustHTML() + + // Check for status classes + if !strings.Contains(html, "status-open") { + t.Error("Expected status-open class for open convoy") + } + if !strings.Contains(html, "status-closed") { + t.Error("Expected status-closed class for closed convoy") + } + + t.Log("PASSED: Status indicators display correctly") +} + +// TestBrowser_ProgressDisplay tests progress bar rendering +func TestBrowser_ProgressDisplay(t *testing.T) { + fetcher := &mockFetcher{ + convoys: []ConvoyRow{ + { + ID: "hq-cv-progress", + Title: "Progress Convoy", + Status: "open", + Progress: "3/7", + Completed: 3, + Total: 7, + }, + }, + } + + handler, err := NewConvoyHandler(fetcher) + if err != nil { + t.Fatalf("Failed to create handler: %v", err) + } + + ts := httptest.NewServer(handler) + defer ts.Close() + + cfg := getBrowserConfig() + browser, cleanup := launchBrowser(cfg) + defer cleanup() + + page := browser.MustPage(ts.URL).Timeout(30 * time.Second) + defer page.MustClose() + + page.MustWaitLoad() + + bodyText := page.MustElement("body").MustText() + + // Verify progress text + if !strings.Contains(bodyText, "3/7") { + t.Errorf("Expected progress '3/7' in page, got: %s", bodyText[:min(len(bodyText), 500)]) + } + + // Verify progress bar elements exist + html := page.MustHTML() + if !strings.Contains(html, "progress-bar") { + t.Error("Expected progress-bar class in page") + } + if !strings.Contains(html, "progress-fill") { + t.Error("Expected progress-fill class in page") + } + + t.Log("PASSED: Progress display works correctly") +} diff --git a/internal/web/fetcher.go b/internal/web/fetcher.go index 6f400ddc..3667310f 100644 --- a/internal/web/fetcher.go +++ b/internal/web/fetcher.go @@ -122,6 +122,7 @@ func (f *LiveConvoyFetcher) getTrackedIssues(convoyID string) []trackedIssueInfo // Query tracked dependencies from SQLite safeConvoyID := strings.ReplaceAll(convoyID, "'", "''") + // #nosec G204 -- sqlite3 path is from trusted config, convoyID is escaped queryCmd := exec.Command("sqlite3", "-json", dbPath, fmt.Sprintf(`SELECT depends_on_id, type FROM dependencies WHERE issue_id = '%s' AND type = 'tracks'`, safeConvoyID)) @@ -200,6 +201,7 @@ func (f *LiveConvoyFetcher) getIssueDetailsBatch(issueIDs []string) map[string]* args := append([]string{"show"}, issueIDs...) args = append(args, "--json") + // #nosec G204 -- bd is a trusted internal tool, args are issue IDs showCmd := exec.Command("bd", args...) var stdout bytes.Buffer showCmd.Stdout = &stdout @@ -262,6 +264,7 @@ func (f *LiveConvoyFetcher) getWorkersForIssues(issueIDs []string) map[string]*w `SELECT id, hook_bead, last_activity FROM issues WHERE issue_type = 'agent' AND status = 'open' AND hook_bead = '%s' LIMIT 1`, safeID) + // #nosec G204 -- sqlite3 path is from trusted glob, issueID is escaped queryCmd := exec.Command("sqlite3", "-json", dbPath, query) var stdout bytes.Buffer queryCmd.Stdout = &stdout From f972c69f3a598f3a4fd70c85e5cc059b0dadd463 Mon Sep 17 00:00:00 2001 From: nux Date: Sat, 3 Jan 2026 13:10:58 -0800 Subject: [PATCH 02/15] fix: increase SendKeys debounce to 500ms for reliable Enter key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tmux send-keys Enter key was unreliable because SendKeys used only 100ms debounce while NudgeSession (known to work) uses 500ms. Root cause: When agents start other agents or inject startup commands, they use SendKeys() which had only 100ms debounce. This is insufficient for Claude Code to process the paste before Enter arrives. The fix increases DefaultDebounceMs from 100ms to 500ms, making all SendKeys calls as reliable as NudgeSession calls. Fixes: hq-y9id 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/constants/constants.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/internal/constants/constants.go b/internal/constants/constants.go index 85d99186..8bfec318 100644 --- a/internal/constants/constants.go +++ b/internal/constants/constants.go @@ -17,7 +17,9 @@ const ( ShellReadyTimeout = 5 * time.Second // DefaultDebounceMs is the default debounce for SendKeys operations. - DefaultDebounceMs = 100 + // 500ms is required for Claude Code to reliably process paste before Enter. + // See NudgeSession comment: "Wait 500ms for paste to complete (tested, required)" + DefaultDebounceMs = 500 // DefaultDisplayMs is the default duration for tmux display-message. DefaultDisplayMs = 5000 From d7b035dc669ba56c4d9ac6a9e4312731ceaf3055 Mon Sep 17 00:00:00 2001 From: Mike Lady Date: Sat, 3 Jan 2026 17:00:06 -0800 Subject: [PATCH 03/15] fix(dashboard): Use tmux session activity for convoy last_activity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The convoy dashboard last_activity column was showing "no activity" because the old code looked for agent records in beads databases at wrong paths. Changed approach: - Use the issue's assignee field (e.g., "roxas/polecats/dag") - Parse assignee to get rig and polecat name - Query tmux for session activity directly (#{session_activity}) This is more reliable since it uses actual tmux session state instead of trying to find agent records in beads databases. Fixes hq-kdhf 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/web/fetcher.go | 119 +++++++++++++++++++++++----------------- 1 file changed, 70 insertions(+), 49 deletions(-) diff --git a/internal/web/fetcher.go b/internal/web/fetcher.go index 6f400ddc..56ee185e 100644 --- a/internal/web/fetcher.go +++ b/internal/web/fetcher.go @@ -155,8 +155,8 @@ func (f *LiveConvoyFetcher) getTrackedIssues(convoyID string) []trackedIssueInfo // Batch fetch issue details details := f.getIssueDetailsBatch(issueIDs) - // Get worker info for activity timestamps - workers := f.getWorkersForIssues(issueIDs) + // Get worker activity from tmux sessions based on assignees + workers := f.getWorkersFromAssignees(details) // Build result result := make([]trackedIssueInfo, 0, len(issueIDs)) @@ -236,62 +236,83 @@ type workerDetail struct { LastActivity *time.Time } -// getWorkersForIssues finds workers and their last activity for issues. -func (f *LiveConvoyFetcher) getWorkersForIssues(issueIDs []string) map[string]*workerDetail { +// getWorkersFromAssignees gets worker activity from tmux sessions based on issue assignees. +// Assignees are in format "rigname/polecats/polecatname" which maps to tmux session "gt-rigname-polecatname". +func (f *LiveConvoyFetcher) getWorkersFromAssignees(details map[string]*issueDetail) map[string]*workerDetail { result := make(map[string]*workerDetail) - if len(issueIDs) == 0 { + + // Collect unique assignees and map them to issue IDs + assigneeToIssues := make(map[string][]string) + for issueID, detail := range details { + if detail == nil || detail.Assignee == "" { + continue + } + assigneeToIssues[detail.Assignee] = append(assigneeToIssues[detail.Assignee], issueID) + } + + if len(assigneeToIssues) == 0 { return result } - townRoot, _ := workspace.FindFromCwd() - if townRoot == "" { - return result - } + // For each unique assignee, look up tmux session activity + for assignee, issueIDs := range assigneeToIssues { + activity := f.getSessionActivityForAssignee(assignee) + if activity == nil { + continue + } - // Find all rig beads databases - rigDirs, _ := filepath.Glob(filepath.Join(townRoot, "*", "mayor", "rig", ".beads", "beads.db")) - - for _, dbPath := range rigDirs { + // Apply this activity to all issues assigned to this worker for _, issueID := range issueIDs { - if _, ok := result[issueID]; ok { - continue + result[issueID] = &workerDetail{ + Worker: assignee, + LastActivity: activity, } - - safeID := strings.ReplaceAll(issueID, "'", "''") - query := fmt.Sprintf( - `SELECT id, hook_bead, last_activity FROM issues WHERE issue_type = 'agent' AND status = 'open' AND hook_bead = '%s' LIMIT 1`, - safeID) - - queryCmd := exec.Command("sqlite3", "-json", dbPath, query) - var stdout bytes.Buffer - queryCmd.Stdout = &stdout - if err := queryCmd.Run(); err != nil { - continue - } - - var agents []struct { - ID string `json:"id"` - HookBead string `json:"hook_bead"` - LastActivity string `json:"last_activity"` - } - if err := json.Unmarshal(stdout.Bytes(), &agents); err != nil || len(agents) == 0 { - continue - } - - agent := agents[0] - detail := &workerDetail{ - Worker: agent.ID, - } - - if agent.LastActivity != "" { - if t, err := time.Parse(time.RFC3339, agent.LastActivity); err == nil { - detail.LastActivity = &t - } - } - - result[issueID] = detail } } return result } + +// getSessionActivityForAssignee looks up tmux session activity for an assignee. +// Assignee format: "rigname/polecats/polecatname" -> session "gt-rigname-polecatname" +func (f *LiveConvoyFetcher) getSessionActivityForAssignee(assignee string) *time.Time { + // Parse assignee: "roxas/polecats/dag" -> rig="roxas", polecat="dag" + parts := strings.Split(assignee, "/") + if len(parts) != 3 || parts[1] != "polecats" { + return nil + } + rig := parts[0] + polecat := parts[2] + + // Construct session name + sessionName := fmt.Sprintf("gt-%s-%s", rig, polecat) + + // Query tmux for session activity + // Format: session_activity returns unix timestamp + cmd := exec.Command("tmux", "list-sessions", "-F", "#{session_name}|#{session_activity}", + "-f", fmt.Sprintf("#{==:#{session_name},%s}", sessionName)) + var stdout bytes.Buffer + cmd.Stdout = &stdout + if err := cmd.Run(); err != nil { + return nil + } + + output := strings.TrimSpace(stdout.String()) + if output == "" { + return nil + } + + // Parse output: "gt-roxas-dag|1704312345" + outputParts := strings.Split(output, "|") + if len(outputParts) < 2 { + return nil + } + + var activityUnix int64 + if _, err := fmt.Sscanf(outputParts[1], "%d", &activityUnix); err != nil || activityUnix == 0 { + return nil + } + + activity := time.Unix(activityUnix, 0) + return &activity +} From f51259878307b69258814ec693393c00d31d6112 Mon Sep 17 00:00:00 2001 From: Mike Lady Date: Sat, 3 Jan 2026 17:12:26 -0800 Subject: [PATCH 04/15] fix(dashboard): Handle unassigned convoys and show fallback activity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Improvements to convoy dashboard last_activity column: 1. When issues have no assignee: - Fall back to issue's updated_at timestamp - Show age with "(unassigned)" suffix, e.g., "2m (unassigned)" 2. When issues have assignee but no active tmux session: - Show "idle" instead of "no activity" 3. Added UpdatedAt field to track issue timestamps This provides better context for convoys that haven't been assigned yet. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/web/fetcher.go | 54 +++++++++++++++++++++++++++++++++-------- 1 file changed, 44 insertions(+), 10 deletions(-) diff --git a/internal/web/fetcher.go b/internal/web/fetcher.go index 56ee185e..13256e4d 100644 --- a/internal/web/fetcher.go +++ b/internal/web/fetcher.go @@ -68,6 +68,8 @@ func (f *LiveConvoyFetcher) FetchConvoys() ([]ConvoyRow, error) { row.Total = len(tracked) var mostRecentActivity time.Time + var mostRecentUpdated time.Time + var hasAssignee bool for _, t := range tracked { if t.Status == "closed" { row.Completed++ @@ -76,16 +78,37 @@ func (f *LiveConvoyFetcher) FetchConvoys() ([]ConvoyRow, error) { if t.LastActivity.After(mostRecentActivity) { mostRecentActivity = t.LastActivity } + // Track most recent updated_at as fallback + if t.UpdatedAt.After(mostRecentUpdated) { + mostRecentUpdated = t.UpdatedAt + } + if t.Assignee != "" { + hasAssignee = true + } } row.Progress = fmt.Sprintf("%d/%d", row.Completed, row.Total) // Calculate activity info from most recent worker activity if !mostRecentActivity.IsZero() { + // Have active tmux session activity row.LastActivity = activity.Calculate(mostRecentActivity) + } else if !hasAssignee { + // No assignees - fall back to issue updated_at + if !mostRecentUpdated.IsZero() { + info := activity.Calculate(mostRecentUpdated) + info.FormattedAge = info.FormattedAge + " (unassigned)" + row.LastActivity = info + } else { + row.LastActivity = activity.Info{ + FormattedAge: "unassigned", + ColorClass: activity.ColorUnknown, + } + } } else { + // Has assignee but no active session row.LastActivity = activity.Info{ - FormattedAge: "no activity", + FormattedAge: "idle", ColorClass: activity.ColorUnknown, } } @@ -114,6 +137,7 @@ type trackedIssueInfo struct { Status string Assignee string LastActivity time.Time + UpdatedAt time.Time // Fallback for activity when no assignee } // getTrackedIssues fetches tracked issues for a convoy. @@ -167,6 +191,7 @@ func (f *LiveConvoyFetcher) getTrackedIssues(convoyID string) []trackedIssueInfo info.Title = d.Title info.Status = d.Status info.Assignee = d.Assignee + info.UpdatedAt = d.UpdatedAt } else { info.Title = "(external)" info.Status = "unknown" @@ -184,10 +209,11 @@ func (f *LiveConvoyFetcher) getTrackedIssues(convoyID string) []trackedIssueInfo // issueDetail holds basic issue info. type issueDetail struct { - ID string - Title string - Status string - Assignee string + ID string + Title string + Status string + Assignee string + UpdatedAt time.Time } // getIssueDetailsBatch fetches details for multiple issues. @@ -209,22 +235,30 @@ func (f *LiveConvoyFetcher) getIssueDetailsBatch(issueIDs []string) map[string]* } var issues []struct { - ID string `json:"id"` - Title string `json:"title"` - Status string `json:"status"` - Assignee string `json:"assignee"` + ID string `json:"id"` + Title string `json:"title"` + Status string `json:"status"` + Assignee string `json:"assignee"` + UpdatedAt string `json:"updated_at"` } if err := json.Unmarshal(stdout.Bytes(), &issues); err != nil { return result } for _, issue := range issues { - result[issue.ID] = &issueDetail{ + detail := &issueDetail{ ID: issue.ID, Title: issue.Title, Status: issue.Status, Assignee: issue.Assignee, } + // Parse updated_at timestamp + if issue.UpdatedAt != "" { + if t, err := time.Parse(time.RFC3339, issue.UpdatedAt); err == nil { + detail.UpdatedAt = t + } + } + result[issue.ID] = detail } return result From 2bb1f1e7260b95489be8c617ffb5418435ac205c Mon Sep 17 00:00:00 2001 From: Mike Lady Date: Sat, 3 Jan 2026 17:17:35 -0800 Subject: [PATCH 05/15] feat(dashboard): Increase auto-refresh rate to every 10 seconds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changed htmx trigger from 30s to 10s for faster convoy status updates. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/web/templates/convoy.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/web/templates/convoy.html b/internal/web/templates/convoy.html index 5b8fd8aa..be430169 100644 --- a/internal/web/templates/convoy.html +++ b/internal/web/templates/convoy.html @@ -188,11 +188,11 @@ -
+

🚚 Gas Town Convoys

- Auto-refresh: every 30s + Auto-refresh: every 10s
From 6d4f2c40d1e58bfa2183d7bfb335f0b0c5a093cb Mon Sep 17 00:00:00 2001 From: Mike Lady Date: Sat, 3 Jan 2026 17:22:39 -0800 Subject: [PATCH 06/15] fix(test): Update htmx refresh test to expect 10s interval MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The template was updated to refresh every 10s (2bb1f1e) but the test still expected 30s. Update test to match the new intended behavior. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/web/templates_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/web/templates_test.go b/internal/web/templates_test.go index f428fb71..406405a8 100644 --- a/internal/web/templates_test.go +++ b/internal/web/templates_test.go @@ -137,8 +137,8 @@ func TestConvoyTemplate_HtmxAutoRefresh(t *testing.T) { if !strings.Contains(output, "hx-trigger") { t.Error("Template should contain hx-trigger for auto-refresh") } - if !strings.Contains(output, "every 30s") { - t.Error("Template should refresh every 30 seconds") + if !strings.Contains(output, "every 10s") { + t.Error("Template should refresh every 10 seconds") } } From 565b2a0d5277e4a180623507def8a311e4802f88 Mon Sep 17 00:00:00 2001 From: Mike Lady Date: Sat, 3 Jan 2026 17:34:22 -0800 Subject: [PATCH 07/15] feat(dashboard): Add Polecat Workers section with activity monitoring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add FetchPolecats() to fetch tmux session data for active polecats - Display polecat name, rig, activity status (green/yellow/red) - Show status hint from last line of pane output - Add FetchMergeQueue stub for interface compliance - Update handler to pass polecats data to template - Add Polecat Workers table section to convoy.html 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/web/fetcher.go | 311 ++++++++++++++++++++++++++++- internal/web/handler.go | 18 +- internal/web/handler_test.go | 14 +- internal/web/templates.go | 24 ++- internal/web/templates/convoy.html | 162 +++++++++++++++ 5 files changed, 522 insertions(+), 7 deletions(-) diff --git a/internal/web/fetcher.go b/internal/web/fetcher.go index 13256e4d..aed17b00 100644 --- a/internal/web/fetcher.go +++ b/internal/web/fetcher.go @@ -30,6 +30,7 @@ func NewLiveConvoyFetcher() (*LiveConvoyFetcher, error) { }, nil } + // FetchConvoys fetches all open convoys with their activity data. func (f *LiveConvoyFetcher) FetchConvoys() ([]ConvoyRow, error) { // List all open convoy-type issues @@ -91,11 +92,17 @@ func (f *LiveConvoyFetcher) FetchConvoys() ([]ConvoyRow, error) { // Calculate activity info from most recent worker activity if !mostRecentActivity.IsZero() { - // Have active tmux session activity + // Have active tmux session activity from assigned workers row.LastActivity = activity.Calculate(mostRecentActivity) } else if !hasAssignee { - // No assignees - fall back to issue updated_at - if !mostRecentUpdated.IsZero() { + // No assignees found in beads - try fallback to any running polecat activity + // This handles cases where bd update --assignee didn't persist or wasn't returned + if polecatActivity := f.getAllPolecatActivity(); polecatActivity != nil { + info := activity.Calculate(*polecatActivity) + info.FormattedAge = info.FormattedAge + " (polecat active)" + row.LastActivity = info + } else if !mostRecentUpdated.IsZero() { + // Fall back to issue updated_at if no polecats running info := activity.Calculate(mostRecentUpdated) info.FormattedAge = info.FormattedAge + " (unassigned)" row.LastActivity = info @@ -350,3 +357,301 @@ func (f *LiveConvoyFetcher) getSessionActivityForAssignee(assignee string) *time activity := time.Unix(activityUnix, 0) return &activity } + +// getAllPolecatActivity returns the most recent activity from any running polecat session. +// This is used as a fallback when no specific assignee activity can be determined. +// Returns nil if no polecat sessions are running. +func (f *LiveConvoyFetcher) getAllPolecatActivity() *time.Time { + // List all tmux sessions matching gt-*-* pattern (polecat sessions) + // Format: gt-{rig}-{polecat} + cmd := exec.Command("tmux", "list-sessions", "-F", "#{session_name}|#{session_activity}") + var stdout bytes.Buffer + cmd.Stdout = &stdout + if err := cmd.Run(); err != nil { + return nil + } + + var mostRecent time.Time + for _, line := range strings.Split(stdout.String(), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + + parts := strings.Split(line, "|") + if len(parts) < 2 { + continue + } + + sessionName := parts[0] + // Check if it's a polecat session (gt-{rig}-{polecat}, not gt-{rig}-witness/refinery) + // Polecat sessions have exactly 3 parts when split by "-" and the middle part is the rig + nameParts := strings.Split(sessionName, "-") + if len(nameParts) < 3 || nameParts[0] != "gt" { + continue + } + // Skip witness, refinery, mayor, deacon sessions + lastPart := nameParts[len(nameParts)-1] + if lastPart == "witness" || lastPart == "refinery" || lastPart == "mayor" || lastPart == "deacon" { + continue + } + + var activityUnix int64 + if _, err := fmt.Sscanf(parts[1], "%d", &activityUnix); err != nil || activityUnix == 0 { + continue + } + + activityTime := time.Unix(activityUnix, 0) + if activityTime.After(mostRecent) { + mostRecent = activityTime + } + } + + if mostRecent.IsZero() { + return nil + } + return &mostRecent +} + +// FetchMergeQueue fetches open PRs from configured repos. +func (f *LiveConvoyFetcher) FetchMergeQueue() ([]MergeQueueRow, error) { + // Repos to query for PRs + repos := []struct { + Full string // Full repo path for gh CLI + Short string // Short name for display + }{ + {"michaellady/roxas", "roxas"}, + {"michaellady/gastown", "gastown"}, + } + + var result []MergeQueueRow + + for _, repo := range repos { + prs, err := f.fetchPRsForRepo(repo.Full, repo.Short) + if err != nil { + // Non-fatal: continue with other repos + continue + } + result = append(result, prs...) + } + + return result, nil +} + +// prResponse represents the JSON response from gh pr list. +type prResponse struct { + Number int `json:"number"` + Title string `json:"title"` + URL string `json:"url"` + Mergeable string `json:"mergeable"` + StatusCheckRollup []struct { + State string `json:"state"` + Status string `json:"status"` + Conclusion string `json:"conclusion"` + } `json:"statusCheckRollup"` +} + +// fetchPRsForRepo fetches open PRs for a single repo. +func (f *LiveConvoyFetcher) fetchPRsForRepo(repoFull, repoShort string) ([]MergeQueueRow, error) { + // #nosec G204 -- gh is a trusted CLI, repo is from hardcoded list + cmd := exec.Command("gh", "pr", "list", + "--repo", repoFull, + "--state", "open", + "--json", "number,title,url,mergeable,statusCheckRollup") + + var stdout bytes.Buffer + cmd.Stdout = &stdout + + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("fetching PRs for %s: %w", repoFull, err) + } + + var prs []prResponse + if err := json.Unmarshal(stdout.Bytes(), &prs); err != nil { + return nil, fmt.Errorf("parsing PRs for %s: %w", repoFull, err) + } + + result := make([]MergeQueueRow, 0, len(prs)) + for _, pr := range prs { + row := MergeQueueRow{ + Number: pr.Number, + Repo: repoShort, + Title: pr.Title, + URL: pr.URL, + } + + // Determine CI status from statusCheckRollup + row.CIStatus = determineCIStatus(pr.StatusCheckRollup) + + // Determine mergeable status + row.Mergeable = determineMergeableStatus(pr.Mergeable) + + // Determine color class based on overall status + row.ColorClass = determineColorClass(row.CIStatus, row.Mergeable) + + result = append(result, row) + } + + return result, nil +} + +// determineCIStatus evaluates the overall CI status from status checks. +func determineCIStatus(checks []struct { + State string `json:"state"` + Status string `json:"status"` + Conclusion string `json:"conclusion"` +}) string { + if len(checks) == 0 { + return "pending" + } + + hasFailure := false + hasPending := false + + for _, check := range checks { + // Check conclusion first (for completed checks) + switch check.Conclusion { + case "failure", "cancelled", "timed_out", "action_required": + hasFailure = true + case "success", "skipped", "neutral": + // Pass + default: + // Check status for in-progress checks + switch check.Status { + case "queued", "in_progress", "waiting", "pending", "requested": + hasPending = true + } + // Also check state field + switch check.State { + case "FAILURE", "ERROR": + hasFailure = true + case "PENDING", "EXPECTED": + hasPending = true + } + } + } + + if hasFailure { + return "fail" + } + if hasPending { + return "pending" + } + return "pass" +} + +// determineMergeableStatus converts GitHub's mergeable field to display value. +func determineMergeableStatus(mergeable string) string { + switch strings.ToUpper(mergeable) { + case "MERGEABLE": + return "ready" + case "CONFLICTING": + return "conflict" + default: + return "pending" + } +} + +// determineColorClass determines the row color based on CI and merge status. +func determineColorClass(ciStatus, mergeable string) string { + if ciStatus == "fail" || mergeable == "conflict" { + return "mq-red" + } + if ciStatus == "pending" || mergeable == "pending" { + return "mq-yellow" + } + if ciStatus == "pass" && mergeable == "ready" { + return "mq-green" + } + return "mq-yellow" +} + +// FetchPolecats fetches all running polecat sessions with activity data. +func (f *LiveConvoyFetcher) FetchPolecats() ([]PolecatRow, error) { + // Query all tmux sessions + cmd := exec.Command("tmux", "list-sessions", "-F", "#{session_name}|#{session_activity}") + var stdout bytes.Buffer + cmd.Stdout = &stdout + if err := cmd.Run(); err != nil { + // tmux not running or no sessions + return nil, nil + } + + var polecats []PolecatRow + lines := strings.Split(strings.TrimSpace(stdout.String()), "\n") + + for _, line := range lines { + if line == "" { + continue + } + + parts := strings.Split(line, "|") + if len(parts) < 2 { + continue + } + + sessionName := parts[0] + + // Filter for gt-- pattern + if !strings.HasPrefix(sessionName, "gt-") { + continue + } + + // Parse session name: gt-roxas-dag -> rig=roxas, polecat=dag + nameParts := strings.SplitN(sessionName, "-", 3) + if len(nameParts) != 3 { + continue + } + rig := nameParts[1] + polecat := nameParts[2] + + // Skip non-polecat sessions (refinery, witness, mayor, deacon, boot) + if polecat == "refinery" || polecat == "witness" || polecat == "mayor" || polecat == "deacon" || polecat == "boot" { + continue + } + + // Parse activity timestamp + var activityUnix int64 + if _, err := fmt.Sscanf(parts[1], "%d", &activityUnix); err != nil || activityUnix == 0 { + continue + } + activityTime := time.Unix(activityUnix, 0) + + // Get status hint from last line of pane + statusHint := f.getPolecatStatusHint(sessionName) + + polecats = append(polecats, PolecatRow{ + Name: polecat, + Rig: rig, + SessionID: sessionName, + LastActivity: activity.Calculate(activityTime), + StatusHint: statusHint, + }) + } + + return polecats, nil +} + +// getPolecatStatusHint captures the last non-empty line from a polecat's pane. +func (f *LiveConvoyFetcher) getPolecatStatusHint(sessionName string) string { + cmd := exec.Command("tmux", "capture-pane", "-t", sessionName, "-p", "-J") + var stdout bytes.Buffer + cmd.Stdout = &stdout + if err := cmd.Run(); err != nil { + return "" + } + + // Get last non-empty line + lines := strings.Split(stdout.String(), "\n") + for i := len(lines) - 1; i >= 0; i-- { + line := strings.TrimSpace(lines[i]) + if line != "" { + // Truncate long lines + if len(line) > 60 { + line = line[:57] + "..." + } + return line + } + } + return "" +} diff --git a/internal/web/handler.go b/internal/web/handler.go index 44ae1cc7..02e23557 100644 --- a/internal/web/handler.go +++ b/internal/web/handler.go @@ -8,6 +8,8 @@ import ( // ConvoyFetcher defines the interface for fetching convoy data. type ConvoyFetcher interface { FetchConvoys() ([]ConvoyRow, error) + FetchMergeQueue() ([]MergeQueueRow, error) + FetchPolecats() ([]PolecatRow, error) } // ConvoyHandler handles HTTP requests for the convoy dashboard. @@ -37,8 +39,22 @@ func (h *ConvoyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } + 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 + } + data := ConvoyData{ - Convoys: convoys, + Convoys: convoys, + MergeQueue: mergeQueue, + Polecats: polecats, } w.Header().Set("Content-Type", "text/html; charset=utf-8") diff --git a/internal/web/handler_test.go b/internal/web/handler_test.go index d44de23f..eec9ef69 100644 --- a/internal/web/handler_test.go +++ b/internal/web/handler_test.go @@ -12,14 +12,24 @@ import ( // MockConvoyFetcher is a mock implementation for testing. type MockConvoyFetcher struct { - Convoys []ConvoyRow - Error error + Convoys []ConvoyRow + MergeQueue []MergeQueueRow + Polecats []PolecatRow + Error error } func (m *MockConvoyFetcher) FetchConvoys() ([]ConvoyRow, error) { return m.Convoys, m.Error } +func (m *MockConvoyFetcher) FetchMergeQueue() ([]MergeQueueRow, error) { + return m.MergeQueue, nil +} + +func (m *MockConvoyFetcher) FetchPolecats() ([]PolecatRow, error) { + return m.Polecats, nil +} + func TestConvoyHandler_RendersTemplate(t *testing.T) { mock := &MockConvoyFetcher{ Convoys: []ConvoyRow{ diff --git a/internal/web/templates.go b/internal/web/templates.go index 462f2a3f..00fb2532 100644 --- a/internal/web/templates.go +++ b/internal/web/templates.go @@ -14,7 +14,29 @@ var templateFS embed.FS // ConvoyData represents data passed to the convoy template. type ConvoyData struct { - Convoys []ConvoyRow + Convoys []ConvoyRow + MergeQueue []MergeQueueRow + Polecats []PolecatRow +} + +// PolecatRow represents a polecat worker in the dashboard. +type PolecatRow struct { + Name string // e.g., "dag", "nux" + Rig string // e.g., "roxas", "gastown" + SessionID string // e.g., "gt-roxas-dag" + LastActivity activity.Info // Colored activity display + StatusHint string // Last line from pane (optional) +} + +// MergeQueueRow represents a PR in the merge queue. +type MergeQueueRow struct { + Number int + Repo string // Short repo name (e.g., "roxas", "gastown") + Title string + URL string + CIStatus string // "pass", "fail", "pending" + Mergeable string // "ready", "conflict", "pending" + ColorClass string // "mq-green", "mq-yellow", "mq-red" } // ConvoyRow represents a single convoy in the dashboard. diff --git a/internal/web/templates/convoy.html b/internal/web/templates/convoy.html index be430169..b52fc1a9 100644 --- a/internal/web/templates/convoy.html +++ b/internal/web/templates/convoy.html @@ -176,6 +176,93 @@ font-size: 0.875rem; } + .status-hint { + color: var(--text-secondary); + font-size: 0.875rem; + max-width: 300px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .section-header { + margin-top: 32px; + margin-bottom: 16px; + font-size: 1.25rem; + font-weight: 600; + } + + /* Merge queue colors */ + .mq-green { + background: rgba(74, 222, 128, 0.1); + } + + .mq-yellow { + background: rgba(250, 204, 21, 0.1); + } + + .mq-red { + background: rgba(248, 113, 113, 0.1); + } + + .ci-status, .merge-status { + display: inline-block; + padding: 2px 8px; + border-radius: 4px; + font-size: 0.75rem; + font-weight: 500; + } + + .ci-pass { + background: var(--green); + color: var(--bg-dark); + } + + .ci-fail { + background: var(--red); + color: var(--bg-dark); + } + + .ci-pending { + background: var(--yellow); + color: var(--bg-dark); + } + + .merge-ready { + background: var(--green); + color: var(--bg-dark); + } + + .merge-conflict { + background: var(--red); + color: var(--bg-dark); + } + + .merge-pending { + background: var(--yellow); + color: var(--bg-dark); + } + + .pr-link { + color: var(--text-primary); + text-decoration: none; + } + + .pr-link:hover { + text-decoration: underline; + } + + .pr-title { + color: var(--text-secondary); + margin-left: 8px; + max-width: 400px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + display: inline-block; + vertical-align: middle; + } + /* htmx loading indicator */ .htmx-request .htmx-indicator { opacity: 1; @@ -240,6 +327,81 @@

Create a convoy with: gt convoy create <name> [issues...]

{{end}} + + {{if .MergeQueue}} +

🔀 Refinery Merge Queue

+ + + + + + + + + + + + {{range .MergeQueue}} + + + + + + + + {{end}} + +
PR #RepoTitleCI StatusMergeable
+ #{{.Number}} + {{.Repo}} + {{.Title}} + + {{if eq .CIStatus "pass"}} + ✓ Pass + {{else if eq .CIStatus "fail"}} + ✗ Fail + {{else}} + ⏳ Pending + {{end}} + + {{if eq .Mergeable "ready"}} + Ready + {{else if eq .Mergeable "conflict"}} + Conflict + {{else}} + Pending + {{end}} +
+ {{end}} + + {{if .Polecats}} +

🐾 Polecat Workers

+ + + + + + + + + + + {{range .Polecats}} + + + + + + + {{end}} + +
PolecatRigLast ActivityStatus
+ {{.Name}} + {{.Rig}} + + {{.LastActivity.FormattedAge}} + {{.StatusHint}}
+ {{end}}
From 6e8c43fc0fcc0bdd93faac2e88d81d3a575ef576 Mon Sep 17 00:00:00 2001 From: Mike Lady Date: Sat, 3 Jan 2026 17:57:20 -0800 Subject: [PATCH 08/15] fix(lint): Add nolint directive for GitHub API spelling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The misspell linter flags "cancelled" but this is the actual value returned by GitHub's Check Runs API (British spelling). Added nolint directive with explanation. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/web/fetcher.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/web/fetcher.go b/internal/web/fetcher.go index 393f73a8..942e3816 100644 --- a/internal/web/fetcher.go +++ b/internal/web/fetcher.go @@ -513,7 +513,7 @@ func determineCIStatus(checks []struct { for _, check := range checks { // Check conclusion first (for completed checks) switch check.Conclusion { - case "failure", "cancelled", "timed_out", "action_required": + case "failure", "cancelled", "timed_out", "action_required": //nolint:misspell // GitHub API returns "cancelled" (British spelling) hasFailure = true case "success", "skipped", "neutral": // Pass From fe72bd4ddcb7e71ca30f936bd79fbafe9076bba1 Mon Sep 17 00:00:00 2001 From: Mike Lady Date: Sat, 3 Jan 2026 18:03:51 -0800 Subject: [PATCH 09/15] feat(dashboard): Add dynamic work status column for convoys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The status column now shows computed work status based on progress and activity: - "complete" (green) - all tracked items are done - "active" (green) - recent polecat activity (within 1 min) - "stale" (yellow) - older activity (1-5 min) - "stuck" (red) - stale activity (5+ min) - "waiting" (gray) - no assignee/activity Previously the status column always showed "open" since we only fetch open convoys, making it static and uninformative. Changes: - templates.go: Add WorkStatus field to ConvoyRow, add workStatusClass func - fetcher.go: Add calculateWorkStatus() to compute status from progress/activity - convoy.html: Add work status badge styling, use WorkStatus in table 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/web/fetcher.go | 63 +++++++++++++++++++++++++++--- internal/web/templates.go | 22 ++++++++++- internal/web/templates/convoy.html | 40 +++++++++++++++++-- 3 files changed, 116 insertions(+), 9 deletions(-) diff --git a/internal/web/fetcher.go b/internal/web/fetcher.go index 942e3816..c3c218a7 100644 --- a/internal/web/fetcher.go +++ b/internal/web/fetcher.go @@ -120,6 +120,9 @@ func (f *LiveConvoyFetcher) FetchConvoys() ([]ConvoyRow, error) { } } + // Calculate work status based on progress and activity + row.WorkStatus = calculateWorkStatus(row.Completed, row.Total, row.LastActivity.ColorClass) + // Get tracked issues for expandable view row.TrackedIssues = make([]TrackedIssue, len(tracked)) for i, t := range tracked { @@ -415,6 +418,27 @@ func (f *LiveConvoyFetcher) getAllPolecatActivity() *time.Time { return &mostRecent } +// calculateWorkStatus determines the work status based on progress and activity. +// Returns: "complete", "active", "stale", "stuck", or "waiting" +func calculateWorkStatus(completed, total int, activityColor string) string { + // Check if all work is done + if total > 0 && completed == total { + return "complete" + } + + // Determine status based on activity color + switch activityColor { + case activity.ColorGreen: + return "active" + case activity.ColorYellow: + return "stale" + case activity.ColorRed: + return "stuck" + default: + return "waiting" + } +} + // FetchMergeQueue fetches open PRs from configured repos. func (f *LiveConvoyFetcher) FetchMergeQueue() ([]MergeQueueRow, error) { // Repos to query for PRs @@ -568,7 +592,7 @@ func determineColorClass(ciStatus, mergeable string) string { return "mq-yellow" } -// FetchPolecats fetches all running polecat sessions with activity data. +// FetchPolecats fetches all running polecat and refinery sessions with activity data. func (f *LiveConvoyFetcher) FetchPolecats() ([]PolecatRow, error) { // Query all tmux sessions cmd := exec.Command("tmux", "list-sessions", "-F", "#{session_name}|#{session_activity}") @@ -579,6 +603,9 @@ func (f *LiveConvoyFetcher) FetchPolecats() ([]PolecatRow, error) { return nil, nil } + // Pre-fetch merge queue count to determine refinery idle status + mergeQueueCount := f.getMergeQueueCount() + var polecats []PolecatRow lines := strings.Split(strings.TrimSpace(stdout.String()), "\n") @@ -607,8 +634,9 @@ func (f *LiveConvoyFetcher) FetchPolecats() ([]PolecatRow, error) { rig := nameParts[1] polecat := nameParts[2] - // Skip non-polecat sessions (refinery, witness, mayor, deacon, boot) - if polecat == "refinery" || polecat == "witness" || polecat == "mayor" || polecat == "deacon" || polecat == "boot" { + // Skip non-worker sessions (witness, mayor, deacon, boot) + // Note: refinery is included to show idle/processing status + if polecat == "witness" || polecat == "mayor" || polecat == "deacon" || polecat == "boot" { continue } @@ -619,8 +647,13 @@ func (f *LiveConvoyFetcher) FetchPolecats() ([]PolecatRow, error) { } activityTime := time.Unix(activityUnix, 0) - // Get status hint from last line of pane - statusHint := f.getPolecatStatusHint(sessionName) + // Get status hint - special handling for refinery + var statusHint string + if polecat == "refinery" { + statusHint = f.getRefineryStatusHint(mergeQueueCount) + } else { + statusHint = f.getPolecatStatusHint(sessionName) + } polecats = append(polecats, PolecatRow{ Name: polecat, @@ -657,3 +690,23 @@ func (f *LiveConvoyFetcher) getPolecatStatusHint(sessionName string) string { } return "" } + +// getMergeQueueCount returns the total number of open PRs across all repos. +func (f *LiveConvoyFetcher) getMergeQueueCount() int { + mergeQueue, err := f.FetchMergeQueue() + if err != nil { + return 0 + } + return len(mergeQueue) +} + +// getRefineryStatusHint returns appropriate status for refinery based on merge queue. +func (f *LiveConvoyFetcher) getRefineryStatusHint(mergeQueueCount int) string { + if mergeQueueCount == 0 { + return "Idle - Waiting for PRs" + } + if mergeQueueCount == 1 { + return "Processing 1 PR" + } + return fmt.Sprintf("Processing %d PRs", mergeQueueCount) +} diff --git a/internal/web/templates.go b/internal/web/templates.go index 00fb2532..261cd8d8 100644 --- a/internal/web/templates.go +++ b/internal/web/templates.go @@ -43,7 +43,8 @@ type MergeQueueRow struct { type ConvoyRow struct { ID string Title string - Status string // "open" or "closed" + Status string // "open" or "closed" (raw beads status) + WorkStatus string // Computed: "complete", "active", "stale", "stuck", "waiting" Progress string // e.g., "2/5" Completed int Total int @@ -65,6 +66,7 @@ func LoadTemplates() (*template.Template, error) { funcMap := template.FuncMap{ "activityClass": activityClass, "statusClass": statusClass, + "workStatusClass": workStatusClass, "progressPercent": progressPercent, } @@ -109,6 +111,24 @@ func statusClass(status string) string { } } +// workStatusClass returns the CSS class for a computed work status. +func workStatusClass(workStatus string) string { + switch workStatus { + case "complete": + return "work-complete" + case "active": + return "work-active" + case "stale": + return "work-stale" + case "stuck": + return "work-stuck" + case "waiting": + return "work-waiting" + default: + return "work-unknown" + } +} + // progressPercent calculates percentage as an integer for progress bars. func progressPercent(completed, total int) int { if total == 0 { diff --git a/internal/web/templates/convoy.html b/internal/web/templates/convoy.html index b52fc1a9..65901019 100644 --- a/internal/web/templates/convoy.html +++ b/internal/web/templates/convoy.html @@ -104,6 +104,41 @@ background: var(--green); } + /* Work status badges */ + .work-status { + display: inline-block; + padding: 2px 8px; + border-radius: 4px; + font-size: 0.75rem; + font-weight: 500; + text-transform: uppercase; + } + + .work-complete .work-status { + background: var(--green); + color: var(--bg-dark); + } + + .work-active .work-status { + background: var(--green); + color: var(--bg-dark); + } + + .work-stale .work-status { + background: var(--yellow); + color: var(--bg-dark); + } + + .work-stuck .work-status { + background: var(--red); + color: var(--bg-dark); + } + + .work-waiting .work-status { + background: var(--text-secondary); + color: var(--bg-dark); + } + /* Activity colors */ .activity-dot { display: inline-block; @@ -296,10 +331,9 @@ {{range .Convoys}} - + - - {{if eq .Status "open"}}●{{else}}✓{{end}} + {{.WorkStatus}} {{.ID}} From 8ec7bbd8abe7eac073e262475d29b58a3a3d97e8 Mon Sep 17 00:00:00 2001 From: Mike Lady Date: Sat, 3 Jan 2026 18:14:31 -0800 Subject: [PATCH 10/15] fix(dashboard): Use window_activity for more accurate polecat timing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit session_activity only updates on session-level events. window_activity tracks actual window activity (keypresses, output) for more accurate last activity times in the Polecat Workers section. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/web/fetcher.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/web/fetcher.go b/internal/web/fetcher.go index c3c218a7..99c8a8f7 100644 --- a/internal/web/fetcher.go +++ b/internal/web/fetcher.go @@ -594,8 +594,8 @@ func determineColorClass(ciStatus, mergeable string) string { // FetchPolecats fetches all running polecat and refinery sessions with activity data. func (f *LiveConvoyFetcher) FetchPolecats() ([]PolecatRow, error) { - // Query all tmux sessions - cmd := exec.Command("tmux", "list-sessions", "-F", "#{session_name}|#{session_activity}") + // Query all tmux sessions with window_activity for more accurate timing + cmd := exec.Command("tmux", "list-sessions", "-F", "#{session_name}|#{window_activity}") var stdout bytes.Buffer cmd.Stdout = &stdout if err := cmd.Run(); err != nil { From 1c66b61ad5ca70bdf74d26e8d7e48a0fb22bfdaf Mon Sep 17 00:00:00 2001 From: Mike Lady Date: Sat, 3 Jan 2026 18:14:45 -0800 Subject: [PATCH 11/15] docs: Add Dashboard section to README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document the web dashboard for monitoring Gas Town: - Run command: gt dashboard --port 8080 - Features: convoy tracking, polecat workers, refinery status - Auto-refresh every 10 seconds - Work status indicator reference table 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- README.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/README.md b/README.md index 7bc620be..11690047 100644 --- a/README.md +++ b/README.md @@ -260,6 +260,33 @@ gt doctor # Health check gt doctor --fix # Auto-repair ``` +## Dashboard + +Web-based dashboard for monitoring Gas Town activity. + +```bash +# Start the dashboard +gt dashboard --port 8080 + +# Open in browser +open http://localhost:8080 +``` + +**Features:** +- **Convoy tracking** - View all active convoys with progress bars and work status +- **Polecat workers** - See active worker sessions and their activity status +- **Refinery status** - Monitor merge queue and PR processing +- **Auto-refresh** - Updates every 10 seconds via htmx + +Work status indicators: +| Status | Color | Meaning | +|--------|-------|---------| +| `complete` | Green | All tracked items done | +| `active` | Green | Recent activity (< 1 min) | +| `stale` | Yellow | Activity 1-5 min ago | +| `stuck` | Red | Activity > 5 min ago | +| `waiting` | Gray | No assignee/activity | + ## Shell Completions Enable tab completion for `gt` commands: From d0bdbc4499f5dd147d9aec49809809893d400828 Mon Sep 17 00:00:00 2001 From: Mike Lady Date: Sat, 3 Jan 2026 18:18:49 -0800 Subject: [PATCH 12/15] feat(dashboard): Always show Refinery Merge Queue section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Refinery Merge Queue section now displays always, even when idle: - Shows 'No PRs in queue' message when merge queue is empty - Displays PR table with number, title, CI status, and mergeable when PRs exist - Added empty-state-inline CSS for consistent styling Previously the section was hidden entirely when no PRs existed. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/web/templates/convoy.html | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/internal/web/templates/convoy.html b/internal/web/templates/convoy.html index 65901019..381e1d97 100644 --- a/internal/web/templates/convoy.html +++ b/internal/web/templates/convoy.html @@ -211,6 +211,19 @@ font-size: 0.875rem; } + .empty-state-inline { + text-align: center; + padding: 24px; + color: var(--text-secondary); + background: var(--bg-card); + border-radius: 8px; + } + + .empty-state-inline p { + font-size: 0.875rem; + margin: 0; + } + .status-hint { color: var(--text-secondary); font-size: 0.875rem; @@ -362,8 +375,8 @@ {{end}} - {{if .MergeQueue}}

🔀 Refinery Merge Queue

+ {{if .MergeQueue}} @@ -406,6 +419,10 @@ {{end}}
+ {{else}} +
+

No PRs in queue

+
{{end}} {{if .Polecats}} From d967b33c0088871e42ca37f0858dc02bf892c8cc Mon Sep 17 00:00:00 2001 From: Mike Lady Date: Sat, 3 Jan 2026 18:23:06 -0800 Subject: [PATCH 13/15] test(dashboard): Add comprehensive integration tests for ConvoyHandler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added 11 new integration tests covering: - Error handling: FetchConvoys error returns 500 - Merge queue rendering: PR numbers, repos, CI status badges - Empty merge queue state - Polecat workers rendering: names, rigs, activity colors, status hints - Work status rendering: complete/active/stale/stuck/waiting states - Progress bar rendering with percentage calculation - HTMX auto-refresh attributes (hx-get, hx-trigger, every 10s) - Full dashboard integration with all sections - Non-fatal errors: merge queue/polecat failures don't break convoys Tests use MockConvoyFetcher interface to simulate various data scenarios. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/web/handler_test.go | 457 +++++++++++++++++++++++++++++++++++ 1 file changed, 457 insertions(+) diff --git a/internal/web/handler_test.go b/internal/web/handler_test.go index eec9ef69..3518351d 100644 --- a/internal/web/handler_test.go +++ b/internal/web/handler_test.go @@ -1,6 +1,7 @@ package web import ( + "errors" "net/http" "net/http/httptest" "strings" @@ -10,6 +11,9 @@ import ( "github.com/steveyegge/gastown/internal/activity" ) +// Test error for simulating fetch failures +var errFetchFailed = errors.New("fetch failed") + // MockConvoyFetcher is a mock implementation for testing. type MockConvoyFetcher struct { Convoys []ConvoyRow @@ -189,3 +193,456 @@ func TestConvoyHandler_MultipleConvoys(t *testing.T) { } } } + +// Integration tests for error handling + +func TestConvoyHandler_FetchConvoysError(t *testing.T) { + mock := &MockConvoyFetcher{ + Error: errFetchFailed, + } + + handler, err := NewConvoyHandler(mock) + if err != nil { + t.Fatalf("NewConvoyHandler() error = %v", err) + } + + req := httptest.NewRequest("GET", "/", nil) + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + if w.Code != http.StatusInternalServerError { + t.Errorf("Status = %d, want %d", w.Code, http.StatusInternalServerError) + } + + body := w.Body.String() + if !strings.Contains(body, "Failed to fetch convoys") { + t.Error("Response should contain error message") + } +} + +// Integration tests for merge queue rendering + +func TestConvoyHandler_MergeQueueRendering(t *testing.T) { + mock := &MockConvoyFetcher{ + Convoys: []ConvoyRow{}, + MergeQueue: []MergeQueueRow{ + { + Number: 123, + Repo: "roxas", + Title: "Fix authentication bug", + URL: "https://github.com/test/repo/pull/123", + CIStatus: "pass", + Mergeable: "ready", + ColorClass: "mq-green", + }, + { + Number: 456, + Repo: "gastown", + Title: "Add dashboard feature", + URL: "https://github.com/test/repo/pull/456", + CIStatus: "pending", + Mergeable: "pending", + ColorClass: "mq-yellow", + }, + }, + } + + handler, err := NewConvoyHandler(mock) + if err != nil { + t.Fatalf("NewConvoyHandler() error = %v", err) + } + + req := httptest.NewRequest("GET", "/", nil) + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Status = %d, want %d", w.Code, http.StatusOK) + } + + body := w.Body.String() + + // Check merge queue section header + if !strings.Contains(body, "Refinery Merge Queue") { + t.Error("Response should contain merge queue section header") + } + + // Check PR numbers are rendered + if !strings.Contains(body, "#123") { + t.Error("Response should contain PR #123") + } + if !strings.Contains(body, "#456") { + t.Error("Response should contain PR #456") + } + + // Check repo names + if !strings.Contains(body, "roxas") { + 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") + } + if !strings.Contains(body, "ci-pending") { + t.Error("Response should contain ci-pending class for pending PR") + } +} + +func TestConvoyHandler_EmptyMergeQueue(t *testing.T) { + mock := &MockConvoyFetcher{ + Convoys: []ConvoyRow{}, + MergeQueue: []MergeQueueRow{}, + } + + handler, err := NewConvoyHandler(mock) + if err != nil { + t.Fatalf("NewConvoyHandler() error = %v", err) + } + + req := httptest.NewRequest("GET", "/", nil) + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + body := w.Body.String() + + // Should show empty state for merge queue + if !strings.Contains(body, "No PRs in queue") { + t.Error("Response should show empty merge queue message") + } +} + +// Integration tests for polecat workers rendering + +func TestConvoyHandler_PolecatWorkersRendering(t *testing.T) { + mock := &MockConvoyFetcher{ + Convoys: []ConvoyRow{}, + Polecats: []PolecatRow{ + { + Name: "dag", + Rig: "roxas", + SessionID: "gt-roxas-dag", + LastActivity: activity.Calculate(time.Now().Add(-30 * time.Second)), + StatusHint: "Running tests...", + }, + { + Name: "nux", + Rig: "roxas", + SessionID: "gt-roxas-nux", + LastActivity: activity.Calculate(time.Now().Add(-5 * time.Minute)), + StatusHint: "Waiting for input", + }, + }, + } + + handler, err := NewConvoyHandler(mock) + if err != nil { + t.Fatalf("NewConvoyHandler() error = %v", err) + } + + req := httptest.NewRequest("GET", "/", nil) + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Status = %d, want %d", w.Code, http.StatusOK) + } + + body := w.Body.String() + + // Check polecat section header + if !strings.Contains(body, "Polecat Workers") { + t.Error("Response should contain polecat workers section header") + } + + // Check polecat names + if !strings.Contains(body, "dag") { + t.Error("Response should contain polecat 'dag'") + } + if !strings.Contains(body, "nux") { + t.Error("Response should contain polecat 'nux'") + } + + // Check rig names + if !strings.Contains(body, "roxas") { + t.Error("Response should contain rig 'roxas'") + } + + // Check status hints + if !strings.Contains(body, "Running tests...") { + t.Error("Response should contain status hint") + } + + // Check activity colors (dag should be green, nux should be yellow/red) + if !strings.Contains(body, "activity-green") { + t.Error("Response should contain activity-green for recent activity") + } +} + +// Integration tests for work status rendering + +func TestConvoyHandler_WorkStatusRendering(t *testing.T) { + tests := []struct { + name string + workStatus string + 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"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mock := &MockConvoyFetcher{ + Convoys: []ConvoyRow{ + { + ID: "hq-cv-test", + Title: "Test Convoy", + Status: "open", + WorkStatus: tt.workStatus, + Progress: "1/2", + Completed: 1, + Total: 2, + LastActivity: activity.Calculate(time.Now()), + }, + }, + } + + handler, err := NewConvoyHandler(mock) + if err != nil { + t.Fatalf("NewConvoyHandler() error = %v", err) + } + + req := httptest.NewRequest("GET", "/", nil) + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + body := w.Body.String() + + // Check work status class is applied + if !strings.Contains(body, tt.wantClass) { + t.Errorf("Response should contain class %q for work status %q", tt.wantClass, tt.workStatus) + } + + // Check work status text is displayed + if !strings.Contains(body, tt.wantStatusText) { + t.Errorf("Response should contain status text %q", tt.wantStatusText) + } + }) + } +} + +// Integration tests for progress bar rendering + +func TestConvoyHandler_ProgressBarRendering(t *testing.T) { + mock := &MockConvoyFetcher{ + Convoys: []ConvoyRow{ + { + ID: "hq-cv-progress", + Title: "Progress Test", + Status: "open", + WorkStatus: "active", + Progress: "3/4", + Completed: 3, + Total: 4, + LastActivity: activity.Calculate(time.Now()), + }, + }, + } + + handler, err := NewConvoyHandler(mock) + if err != nil { + t.Fatalf("NewConvoyHandler() error = %v", err) + } + + req := httptest.NewRequest("GET", "/", nil) + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + body := w.Body.String() + + // Check progress text + if !strings.Contains(body, "3/4") { + t.Error("Response should contain progress '3/4'") + } + + // Check progress bar element + if !strings.Contains(body, "progress-bar") { + t.Error("Response should contain progress-bar class") + } + + // Check progress fill with percentage (75%) + if !strings.Contains(body, "progress-fill") { + t.Error("Response should contain progress-fill class") + } + if !strings.Contains(body, "width: 75%") { + t.Error("Response should contain 75% width for 3/4 progress") + } +} + +// Integration test for HTMX auto-refresh + +func TestConvoyHandler_HTMXAutoRefresh(t *testing.T) { + mock := &MockConvoyFetcher{ + Convoys: []ConvoyRow{}, + } + + handler, err := NewConvoyHandler(mock) + if err != nil { + t.Fatalf("NewConvoyHandler() error = %v", err) + } + + req := httptest.NewRequest("GET", "/", nil) + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + body := w.Body.String() + + // Check htmx attributes for auto-refresh + if !strings.Contains(body, "hx-get") { + t.Error("Response should contain hx-get attribute for HTMX") + } + if !strings.Contains(body, "hx-trigger") { + t.Error("Response should contain hx-trigger attribute for HTMX") + } + if !strings.Contains(body, "every 10s") { + t.Error("Response should contain 'every 10s' trigger interval") + } +} + +// Integration test for full dashboard with all sections + +func TestConvoyHandler_FullDashboard(t *testing.T) { + mock := &MockConvoyFetcher{ + Convoys: []ConvoyRow{ + { + ID: "hq-cv-full", + Title: "Full Test Convoy", + Status: "open", + WorkStatus: "active", + Progress: "2/3", + Completed: 2, + Total: 3, + LastActivity: activity.Calculate(time.Now().Add(-1 * time.Minute)), + }, + }, + MergeQueue: []MergeQueueRow{ + { + Number: 789, + Repo: "testrig", + Title: "Test PR", + CIStatus: "pass", + Mergeable: "ready", + ColorClass: "mq-green", + }, + }, + Polecats: []PolecatRow{ + { + Name: "worker1", + Rig: "testrig", + SessionID: "gt-testrig-worker1", + LastActivity: activity.Calculate(time.Now()), + StatusHint: "Working...", + }, + }, + } + + handler, err := NewConvoyHandler(mock) + if err != nil { + t.Fatalf("NewConvoyHandler() error = %v", err) + } + + req := httptest.NewRequest("GET", "/", nil) + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Status = %d, want %d", w.Code, http.StatusOK) + } + + 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, "hq-cv-full") { + t.Error("Response should contain convoy data") + } + if !strings.Contains(body, "Refinery 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") { + t.Error("Response should contain polecat section") + } + if !strings.Contains(body, "worker1") { + t.Error("Response should contain polecat data") + } +} + +// Test that merge queue and polecat errors are non-fatal + +type MockConvoyFetcherWithErrors struct { + Convoys []ConvoyRow + MergeQueueError error + PolecatsError error +} + +func (m *MockConvoyFetcherWithErrors) FetchConvoys() ([]ConvoyRow, error) { + return m.Convoys, nil +} + +func (m *MockConvoyFetcherWithErrors) FetchMergeQueue() ([]MergeQueueRow, error) { + return nil, m.MergeQueueError +} + +func (m *MockConvoyFetcherWithErrors) FetchPolecats() ([]PolecatRow, error) { + return nil, m.PolecatsError +} + +func TestConvoyHandler_NonFatalErrors(t *testing.T) { + mock := &MockConvoyFetcherWithErrors{ + Convoys: []ConvoyRow{ + {ID: "hq-cv-test", Title: "Test", Status: "open", WorkStatus: "active"}, + }, + MergeQueueError: errFetchFailed, + PolecatsError: errFetchFailed, + } + + handler, err := NewConvoyHandler(mock) + if err != nil { + t.Fatalf("NewConvoyHandler() error = %v", err) + } + + req := httptest.NewRequest("GET", "/", nil) + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + // Should still return OK even if merge queue and polecats fail + if w.Code != http.StatusOK { + t.Errorf("Status = %d, want %d (non-fatal errors should not fail request)", w.Code, http.StatusOK) + } + + body := w.Body.String() + + // Convoys should still render + if !strings.Contains(body, "hq-cv-test") { + t.Error("Response should contain convoy data even when other fetches fail") + } +} From f30f46192f45fb887ef1a174eafced80967d05c1 Mon Sep 17 00:00:00 2001 From: Mike Lady Date: Sat, 3 Jan 2026 18:23:45 -0800 Subject: [PATCH 14/15] test(dashboard): Add unit tests for convoy dashboard fetcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add table-driven tests for: - calculateWorkStatus: complete, active, stale, stuck, waiting states - determineCIStatus: pass, fail, pending with various check combinations - determineMergeableStatus: ready, conflict, pending states - determineColorClass: mq-green, mq-yellow, mq-red combinations - getRefineryStatusHint: idle, singular, multiple PR messages - truncateStatusHint: line truncation to 60 chars with ellipsis - parsePolecatSessionName: gt-- parsing - isWorkerSession: worker vs non-worker session detection - parseActivityTimestamp: Unix timestamp parsing from tmux Also refactors inline logic into testable helper functions. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/web/fetcher.go | 43 ++++ internal/web/fetcher_test.go | 417 +++++++++++++++++++++++++++++++++++ 2 files changed, 460 insertions(+) create mode 100644 internal/web/fetcher_test.go diff --git a/internal/web/fetcher.go b/internal/web/fetcher.go index 99c8a8f7..b06e38e7 100644 --- a/internal/web/fetcher.go +++ b/internal/web/fetcher.go @@ -710,3 +710,46 @@ func (f *LiveConvoyFetcher) getRefineryStatusHint(mergeQueueCount int) string { } return fmt.Sprintf("Processing %d PRs", mergeQueueCount) } + +// truncateStatusHint truncates a status hint to 60 characters with ellipsis. +func truncateStatusHint(line string) string { + if len(line) > 60 { + return line[:57] + "..." + } + return line +} + +// parsePolecatSessionName parses a tmux session name into rig and polecat components. +// Format: gt-- -> (rig, polecat, true) +// Returns ("", "", false) if the format is invalid. +func parsePolecatSessionName(sessionName string) (rig, polecat string, ok bool) { + if !strings.HasPrefix(sessionName, "gt-") { + return "", "", false + } + parts := strings.SplitN(sessionName, "-", 3) + if len(parts) != 3 { + return "", "", false + } + return parts[1], parts[2], true +} + +// isWorkerSession returns true if the polecat name represents a worker session. +// Non-worker sessions: witness, mayor, deacon, boot +func isWorkerSession(polecat string) bool { + switch polecat { + case "witness", "mayor", "deacon", "boot": + return false + default: + return true + } +} + +// parseActivityTimestamp parses a Unix timestamp string from tmux. +// Returns (0, false) for invalid or zero timestamps. +func parseActivityTimestamp(s string) (int64, bool) { + var unix int64 + if _, err := fmt.Sscanf(s, "%d", &unix); err != nil || unix <= 0 { + return 0, false + } + return unix, true +} diff --git a/internal/web/fetcher_test.go b/internal/web/fetcher_test.go new file mode 100644 index 00000000..07976e25 --- /dev/null +++ b/internal/web/fetcher_test.go @@ -0,0 +1,417 @@ +package web + +import ( + "testing" + + "github.com/steveyegge/gastown/internal/activity" +) + +func TestCalculateWorkStatus(t *testing.T) { + tests := []struct { + name string + completed int + total int + activityColor string + want string + }{ + { + name: "complete when all done", + completed: 5, + total: 5, + activityColor: activity.ColorGreen, + want: "complete", + }, + { + name: "complete overrides activity color", + completed: 3, + total: 3, + activityColor: activity.ColorRed, + want: "complete", + }, + { + name: "active when green", + completed: 2, + total: 5, + activityColor: activity.ColorGreen, + want: "active", + }, + { + name: "stale when yellow", + completed: 2, + total: 5, + activityColor: activity.ColorYellow, + want: "stale", + }, + { + name: "stuck when red", + completed: 2, + total: 5, + activityColor: activity.ColorRed, + want: "stuck", + }, + { + name: "waiting when unknown color", + completed: 2, + total: 5, + activityColor: activity.ColorUnknown, + want: "waiting", + }, + { + name: "waiting when empty color", + completed: 0, + total: 5, + activityColor: "", + want: "waiting", + }, + { + name: "waiting when no work yet", + completed: 0, + total: 0, + activityColor: activity.ColorUnknown, + want: "waiting", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := calculateWorkStatus(tt.completed, tt.total, tt.activityColor) + if got != tt.want { + t.Errorf("calculateWorkStatus(%d, %d, %q) = %q, want %q", + tt.completed, tt.total, tt.activityColor, got, tt.want) + } + }) + } +} + +func TestDetermineCIStatus(t *testing.T) { + tests := []struct { + name string + checks []struct { + State string `json:"state"` + Status string `json:"status"` + Conclusion string `json:"conclusion"` + } + want string + }{ + { + name: "pending when no checks", + checks: nil, + want: "pending", + }, + { + name: "pass when all success", + checks: []struct { + State string `json:"state"` + Status string `json:"status"` + Conclusion string `json:"conclusion"` + }{ + {Conclusion: "success"}, + {Conclusion: "success"}, + }, + want: "pass", + }, + { + name: "pass with skipped checks", + checks: []struct { + State string `json:"state"` + Status string `json:"status"` + Conclusion string `json:"conclusion"` + }{ + {Conclusion: "success"}, + {Conclusion: "skipped"}, + }, + want: "pass", + }, + { + name: "fail when any failure", + checks: []struct { + State string `json:"state"` + Status string `json:"status"` + Conclusion string `json:"conclusion"` + }{ + {Conclusion: "success"}, + {Conclusion: "failure"}, + }, + want: "fail", + }, + { + name: "fail when cancelled", + checks: []struct { + State string `json:"state"` + Status string `json:"status"` + Conclusion string `json:"conclusion"` + }{ + {Conclusion: "cancelled"}, + }, + want: "fail", + }, + { + name: "fail when timed_out", + checks: []struct { + State string `json:"state"` + Status string `json:"status"` + Conclusion string `json:"conclusion"` + }{ + {Conclusion: "timed_out"}, + }, + want: "fail", + }, + { + name: "pending when in_progress", + checks: []struct { + State string `json:"state"` + Status string `json:"status"` + Conclusion string `json:"conclusion"` + }{ + {Conclusion: "success"}, + {Status: "in_progress"}, + }, + want: "pending", + }, + { + name: "pending when queued", + checks: []struct { + State string `json:"state"` + Status string `json:"status"` + Conclusion string `json:"conclusion"` + }{ + {Status: "queued"}, + }, + want: "pending", + }, + { + name: "fail from state FAILURE", + checks: []struct { + State string `json:"state"` + Status string `json:"status"` + Conclusion string `json:"conclusion"` + }{ + {State: "FAILURE"}, + }, + want: "fail", + }, + { + name: "pending from state PENDING", + checks: []struct { + State string `json:"state"` + Status string `json:"status"` + Conclusion string `json:"conclusion"` + }{ + {State: "PENDING"}, + }, + want: "pending", + }, + { + name: "failure takes precedence over pending", + checks: []struct { + State string `json:"state"` + Status string `json:"status"` + Conclusion string `json:"conclusion"` + }{ + {Conclusion: "failure"}, + {Status: "in_progress"}, + }, + want: "fail", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := determineCIStatus(tt.checks) + if got != tt.want { + t.Errorf("determineCIStatus() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestDetermineMergeableStatus(t *testing.T) { + tests := []struct { + name string + mergeable string + want string + }{ + {"ready when MERGEABLE", "MERGEABLE", "ready"}, + {"ready when lowercase mergeable", "mergeable", "ready"}, + {"conflict when CONFLICTING", "CONFLICTING", "conflict"}, + {"conflict when lowercase conflicting", "conflicting", "conflict"}, + {"pending when UNKNOWN", "UNKNOWN", "pending"}, + {"pending when empty", "", "pending"}, + {"pending when other value", "something_else", "pending"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := determineMergeableStatus(tt.mergeable) + if got != tt.want { + t.Errorf("determineMergeableStatus(%q) = %q, want %q", + tt.mergeable, got, tt.want) + } + }) + } +} + +func TestDetermineColorClass(t *testing.T) { + tests := []struct { + name string + ciStatus string + mergeable string + want string + }{ + {"green when pass and ready", "pass", "ready", "mq-green"}, + {"red when CI fails", "fail", "ready", "mq-red"}, + {"red when conflict", "pass", "conflict", "mq-red"}, + {"red when both fail and conflict", "fail", "conflict", "mq-red"}, + {"yellow when CI pending", "pending", "ready", "mq-yellow"}, + {"yellow when merge pending", "pass", "pending", "mq-yellow"}, + {"yellow when both pending", "pending", "pending", "mq-yellow"}, + {"yellow for unknown states", "unknown", "unknown", "mq-yellow"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := determineColorClass(tt.ciStatus, tt.mergeable) + if got != tt.want { + t.Errorf("determineColorClass(%q, %q) = %q, want %q", + tt.ciStatus, tt.mergeable, got, tt.want) + } + }) + } +} + +func TestGetRefineryStatusHint(t *testing.T) { + // Create a minimal fetcher for testing + f := &LiveConvoyFetcher{} + + tests := []struct { + name string + mergeQueueCount int + want string + }{ + {"idle when no PRs", 0, "Idle - Waiting for PRs"}, + {"singular PR", 1, "Processing 1 PR"}, + {"multiple PRs", 2, "Processing 2 PRs"}, + {"many PRs", 10, "Processing 10 PRs"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := f.getRefineryStatusHint(tt.mergeQueueCount) + if got != tt.want { + t.Errorf("getRefineryStatusHint(%d) = %q, want %q", + tt.mergeQueueCount, got, tt.want) + } + }) + } +} + +func TestTruncateStatusHint(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + {"short line unchanged", "Hello world", "Hello world"}, + {"exactly 60 chars unchanged", "123456789012345678901234567890123456789012345678901234567890", "123456789012345678901234567890123456789012345678901234567890"}, + {"61 chars truncated", "1234567890123456789012345678901234567890123456789012345678901", "123456789012345678901234567890123456789012345678901234567..."}, + {"long line truncated", "This is a very long line that should be truncated because it exceeds sixty characters", "This is a very long line that should be truncated because..."}, + {"empty string", "", ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := truncateStatusHint(tt.input) + if got != tt.want { + t.Errorf("truncateStatusHint(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestParsePolecatSessionName(t *testing.T) { + tests := []struct { + name string + sessionName string + wantRig string + wantPolecat string + wantOk bool + }{ + {"valid polecat session", "gt-roxas-dag", "roxas", "dag", true}, + {"valid polecat with hyphen", "gt-gas-town-nux", "gas", "town-nux", true}, + {"refinery session", "gt-roxas-refinery", "roxas", "refinery", true}, + {"witness session", "gt-gastown-witness", "gastown", "witness", true}, + {"not gt prefix", "other-roxas-dag", "", "", false}, + {"too few parts", "gt-roxas", "", "", false}, + {"empty string", "", "", "", false}, + {"single gt", "gt", "", "", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rig, polecat, ok := parsePolecatSessionName(tt.sessionName) + if ok != tt.wantOk { + t.Errorf("parsePolecatSessionName(%q) ok = %v, want %v", + tt.sessionName, ok, tt.wantOk) + } + if ok && (rig != tt.wantRig || polecat != tt.wantPolecat) { + t.Errorf("parsePolecatSessionName(%q) = (%q, %q), want (%q, %q)", + tt.sessionName, rig, polecat, tt.wantRig, tt.wantPolecat) + } + }) + } +} + +func TestIsWorkerSession(t *testing.T) { + tests := []struct { + name string + polecat string + wantWork bool + }{ + {"polecat dag is worker", "dag", true}, + {"polecat nux is worker", "nux", true}, + {"refinery is worker", "refinery", true}, + {"witness is not worker", "witness", false}, + {"mayor is not worker", "mayor", false}, + {"deacon is not worker", "deacon", false}, + {"boot is not worker", "boot", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := isWorkerSession(tt.polecat) + if got != tt.wantWork { + t.Errorf("isWorkerSession(%q) = %v, want %v", + tt.polecat, got, tt.wantWork) + } + }) + } +} + +func TestParseActivityTimestamp(t *testing.T) { + tests := []struct { + name string + input string + wantUnix int64 + wantValid bool + }{ + {"valid timestamp", "1704312345", 1704312345, true}, + {"zero timestamp", "0", 0, false}, + {"empty string", "", 0, false}, + {"invalid string", "abc", 0, false}, + {"negative", "-123", 0, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + unix, valid := parseActivityTimestamp(tt.input) + if valid != tt.wantValid { + t.Errorf("parseActivityTimestamp(%q) valid = %v, want %v", + tt.input, valid, tt.wantValid) + } + if valid && unix != tt.wantUnix { + t.Errorf("parseActivityTimestamp(%q) = %d, want %d", + tt.input, unix, tt.wantUnix) + } + }) + } +} From c9b601c4290cda6b251757d43c1804b23be7631c Mon Sep 17 00:00:00 2001 From: Mike Lady Date: Sat, 3 Jan 2026 18:25:37 -0800 Subject: [PATCH 15/15] test(dashboard): Add E2E tests with httptest.Server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Comprehensive end-to-end tests for convoy dashboard: - TestE2E_Server_FullDashboard: Full dashboard with all sections - TestE2E_Server_ActivityColors: Activity color rendering (green/yellow/red) - TestE2E_Server_MergeQueueEmpty: Always-visible section with empty state - TestE2E_Server_MergeQueueStatuses: All PR status combinations - TestE2E_Server_HTMLStructure: HTML document structure validation - TestE2E_Server_RefineryInPolecats: Refinery appears in workers section Tests use httptest.NewServer for real HTTP server testing. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/web/handler_test.go | 349 +++++++++++++++++++++++++++++++++++ 1 file changed, 349 insertions(+) diff --git a/internal/web/handler_test.go b/internal/web/handler_test.go index 3518351d..c2fd3dba 100644 --- a/internal/web/handler_test.go +++ b/internal/web/handler_test.go @@ -2,6 +2,7 @@ package web import ( "errors" + "io" "net/http" "net/http/httptest" "strings" @@ -595,6 +596,354 @@ func TestConvoyHandler_FullDashboard(t *testing.T) { } } +// ============================================================================= +// End-to-End Tests with httptest.Server +// ============================================================================= + +// TestE2E_Server_FullDashboard tests the full dashboard using a real HTTP server. +func TestE2E_Server_FullDashboard(t *testing.T) { + mock := &MockConvoyFetcher{ + Convoys: []ConvoyRow{ + { + ID: "hq-cv-e2e", + Title: "E2E Test Convoy", + Status: "open", + WorkStatus: "active", + Progress: "2/4", + Completed: 2, + Total: 4, + LastActivity: activity.Calculate(time.Now().Add(-45 * time.Second)), + }, + }, + MergeQueue: []MergeQueueRow{ + { + Number: 101, + Repo: "roxas", + Title: "E2E Test PR", + URL: "https://github.com/test/roxas/pull/101", + CIStatus: "pass", + Mergeable: "ready", + ColorClass: "mq-green", + }, + }, + Polecats: []PolecatRow{ + { + Name: "furiosa", + Rig: "roxas", + SessionID: "gt-roxas-furiosa", + LastActivity: activity.Calculate(time.Now().Add(-30 * time.Second)), + StatusHint: "Running E2E tests", + }, + }, + } + + handler, err := NewConvoyHandler(mock) + if err != nil { + t.Fatalf("NewConvoyHandler() error = %v", err) + } + + // Create a real HTTP server + server := httptest.NewServer(handler) + defer server.Close() + + // Make HTTP request to the server + resp, err := http.Get(server.URL) + if err != nil { + t.Fatalf("HTTP GET failed: %v", err) + } + defer resp.Body.Close() + + // Verify status code + if resp.StatusCode != http.StatusOK { + t.Errorf("Status = %d, want %d", resp.StatusCode, http.StatusOK) + } + + // Verify content type + contentType := resp.Header.Get("Content-Type") + if !strings.Contains(contentType, "text/html") { + t.Errorf("Content-Type = %q, want text/html", contentType) + } + + // Read and verify body + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("Failed to read response body: %v", err) + } + body := string(bodyBytes) + + // Verify all three sections render + checks := []struct { + name string + content string + }{ + {"Convoy section header", "Gas Town Convoys"}, + {"Convoy ID", "hq-cv-e2e"}, + {"Convoy title", "E2E Test Convoy"}, + {"Convoy progress", "2/4"}, + {"Merge queue section", "Refinery Merge Queue"}, + {"PR number", "#101"}, + {"PR repo", "roxas"}, + {"Polecat section", "Polecat Workers"}, + {"Polecat name", "furiosa"}, + {"Polecat status", "Running E2E tests"}, + {"HTMX auto-refresh", `hx-trigger="every 10s"`}, + } + + for _, check := range checks { + if !strings.Contains(body, check.content) { + t.Errorf("%s: should contain %q", check.name, check.content) + } + } +} + +// TestE2E_Server_ActivityColors tests activity color rendering via HTTP server. +func TestE2E_Server_ActivityColors(t *testing.T) { + tests := []struct { + name string + age time.Duration + wantClass string + }{ + {"green for recent", 20 * time.Second, "activity-green"}, + {"yellow for stale", 3 * time.Minute, "activity-yellow"}, + {"red for stuck", 8 * time.Minute, "activity-red"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mock := &MockConvoyFetcher{ + Polecats: []PolecatRow{ + { + Name: "test-worker", + Rig: "test-rig", + SessionID: "gt-test-rig-test-worker", + LastActivity: activity.Calculate(time.Now().Add(-tt.age)), + StatusHint: "Testing", + }, + }, + } + + handler, err := NewConvoyHandler(mock) + if err != nil { + t.Fatalf("NewConvoyHandler() error = %v", err) + } + + server := httptest.NewServer(handler) + defer server.Close() + + resp, err := http.Get(server.URL) + if err != nil { + t.Fatalf("HTTP GET failed: %v", err) + } + defer resp.Body.Close() + + bodyBytes, _ := io.ReadAll(resp.Body) + body := string(bodyBytes) + + if !strings.Contains(body, tt.wantClass) { + t.Errorf("Should contain activity class %q for age %v", tt.wantClass, tt.age) + } + }) + } +} + +// TestE2E_Server_MergeQueueEmpty tests that empty merge queue shows message. +func TestE2E_Server_MergeQueueEmpty(t *testing.T) { + mock := &MockConvoyFetcher{ + Convoys: []ConvoyRow{}, + MergeQueue: []MergeQueueRow{}, + Polecats: []PolecatRow{}, + } + + handler, err := NewConvoyHandler(mock) + if err != nil { + t.Fatalf("NewConvoyHandler() error = %v", err) + } + + server := httptest.NewServer(handler) + defer server.Close() + + resp, err := http.Get(server.URL) + if err != nil { + t.Fatalf("HTTP GET failed: %v", err) + } + defer resp.Body.Close() + + bodyBytes, _ := io.ReadAll(resp.Body) + body := string(bodyBytes) + + // Section header should always be visible + if !strings.Contains(body, "Refinery Merge Queue") { + t.Error("Merge queue section should always be visible") + } + + // Empty state message + if !strings.Contains(body, "No PRs in queue") { + t.Error("Should show 'No PRs in queue' when empty") + } +} + +// TestE2E_Server_MergeQueueStatuses tests all PR status combinations. +func TestE2E_Server_MergeQueueStatuses(t *testing.T) { + tests := []struct { + name string + ciStatus string + mergeable string + colorClass string + 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"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mock := &MockConvoyFetcher{ + MergeQueue: []MergeQueueRow{ + { + Number: 42, + Repo: "test", + Title: "Test PR", + URL: "https://github.com/test/test/pull/42", + CIStatus: tt.ciStatus, + Mergeable: tt.mergeable, + ColorClass: tt.colorClass, + }, + }, + } + + handler, err := NewConvoyHandler(mock) + if err != nil { + t.Fatalf("NewConvoyHandler() error = %v", err) + } + + server := httptest.NewServer(handler) + defer server.Close() + + resp, err := http.Get(server.URL) + if err != nil { + t.Fatalf("HTTP GET failed: %v", err) + } + defer resp.Body.Close() + + bodyBytes, _ := io.ReadAll(resp.Body) + body := string(bodyBytes) + + if !strings.Contains(body, tt.colorClass) { + t.Errorf("Should contain row class %q", tt.colorClass) + } + if !strings.Contains(body, tt.wantCI) { + t.Errorf("Should contain CI class %q", tt.wantCI) + } + if !strings.Contains(body, tt.wantMerge) { + t.Errorf("Should contain merge class %q", tt.wantMerge) + } + }) + } +} + +// TestE2E_Server_HTMLStructure validates HTML document structure. +func TestE2E_Server_HTMLStructure(t *testing.T) { + mock := &MockConvoyFetcher{Convoys: []ConvoyRow{}} + + handler, err := NewConvoyHandler(mock) + if err != nil { + t.Fatalf("NewConvoyHandler() error = %v", err) + } + + server := httptest.NewServer(handler) + defer server.Close() + + resp, err := http.Get(server.URL) + if err != nil { + t.Fatalf("HTTP GET failed: %v", err) + } + defer resp.Body.Close() + + bodyBytes, _ := io.ReadAll(resp.Body) + body := string(bodyBytes) + + // Validate HTML structure + elements := []string{ + "", + "", + "Gas Town Dashboard", + "htmx.org", + "", + "", + "", + } + + for _, elem := range elements { + if !strings.Contains(body, elem) { + t.Errorf("Should contain HTML element %q", elem) + } + } + + // Validate CSS variables for theming + cssVars := []string{"--bg-dark", "--green", "--yellow", "--red"} + for _, v := range cssVars { + if !strings.Contains(body, v) { + t.Errorf("Should contain CSS variable %q", v) + } + } +} + +// TestE2E_Server_RefineryInPolecats tests that refinery appears in polecat workers. +func TestE2E_Server_RefineryInPolecats(t *testing.T) { + mock := &MockConvoyFetcher{ + Polecats: []PolecatRow{ + { + Name: "refinery", + Rig: "roxas", + SessionID: "gt-roxas-refinery", + LastActivity: activity.Calculate(time.Now().Add(-10 * time.Second)), + StatusHint: "Idle - Waiting for PRs", + }, + { + Name: "dag", + Rig: "roxas", + SessionID: "gt-roxas-dag", + LastActivity: activity.Calculate(time.Now().Add(-30 * time.Second)), + StatusHint: "Working on feature", + }, + }, + } + + handler, err := NewConvoyHandler(mock) + if err != nil { + t.Fatalf("NewConvoyHandler() error = %v", err) + } + + server := httptest.NewServer(handler) + defer server.Close() + + resp, err := http.Get(server.URL) + if err != nil { + t.Fatalf("HTTP GET failed: %v", err) + } + defer resp.Body.Close() + + bodyBytes, _ := io.ReadAll(resp.Body) + body := string(bodyBytes) + + // Refinery should appear in polecat workers + 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") + } + + // Regular polecats should also appear + if !strings.Contains(body, "dag") { + t.Error("Regular polecat 'dag' should appear") + } +} + // Test that merge queue and polecat errors are non-fatal type MockConvoyFetcherWithErrors struct {