From 565b2a0d5277e4a180623507def8a311e4802f88 Mon Sep 17 00:00:00 2001 From: Mike Lady Date: Sat, 3 Jan 2026 17:34:22 -0800 Subject: [PATCH] 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}}