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

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

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

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

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

Tests and linting now pass with the new dashboard features.

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

Performance and reliability improvements:

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

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,15 +1,33 @@
package web
import (
"context"
"html/template"
"log"
"net/http"
"sync"
"time"
)
// fetchTimeout is the maximum time allowed for all data fetches to complete.
const fetchTimeout = 8 * time.Second
// ConvoyFetcher defines the interface for fetching convoy data.
type ConvoyFetcher interface {
FetchConvoys() ([]ConvoyRow, error)
FetchMergeQueue() ([]MergeQueueRow, error)
FetchPolecats() ([]PolecatRow, error)
FetchMail() ([]MailRow, error)
FetchRigs() ([]RigRow, error)
FetchDogs() ([]DogRow, error)
FetchEscalations() ([]EscalationRow, error)
FetchHealth() (*HealthRow, error)
FetchQueues() ([]QueueRow, error)
FetchSessions() ([]SessionRow, error)
FetchHooks() ([]HookRow, error)
FetchMayor() (*MayorStatus, error)
FetchIssues() ([]IssueRow, error)
FetchActivity() ([]ActivityRow, error)
}
// ConvoyHandler handles HTTP requests for the convoy dashboard.
@@ -33,28 +51,181 @@ func NewConvoyHandler(fetcher ConvoyFetcher) (*ConvoyHandler, error) {
// ServeHTTP handles GET / requests and renders the convoy dashboard.
func (h *ConvoyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
convoys, err := h.fetcher.FetchConvoys()
if err != nil {
http.Error(w, "Failed to fetch convoys", http.StatusInternalServerError)
return
// Check for expand parameter (fullscreen a specific panel)
expandPanel := r.URL.Query().Get("expand")
// Create a timeout context for all fetches
ctx, cancel := context.WithTimeout(r.Context(), fetchTimeout)
defer cancel()
var (
convoys []ConvoyRow
mergeQueue []MergeQueueRow
polecats []PolecatRow
mail []MailRow
rigs []RigRow
dogs []DogRow
escalations []EscalationRow
health *HealthRow
queues []QueueRow
sessions []SessionRow
hooks []HookRow
mayor *MayorStatus
issues []IssueRow
activity []ActivityRow
wg sync.WaitGroup
)
// Run all fetches in parallel with error logging
wg.Add(14)
go func() {
defer wg.Done()
var err error
convoys, err = h.fetcher.FetchConvoys()
if err != nil {
log.Printf("dashboard: FetchConvoys failed: %v", err)
}
}()
go func() {
defer wg.Done()
var err error
mergeQueue, err = h.fetcher.FetchMergeQueue()
if err != nil {
log.Printf("dashboard: FetchMergeQueue failed: %v", err)
}
}()
go func() {
defer wg.Done()
var err error
polecats, err = h.fetcher.FetchPolecats()
if err != nil {
log.Printf("dashboard: FetchPolecats failed: %v", err)
}
}()
go func() {
defer wg.Done()
var err error
mail, err = h.fetcher.FetchMail()
if err != nil {
log.Printf("dashboard: FetchMail failed: %v", err)
}
}()
go func() {
defer wg.Done()
var err error
rigs, err = h.fetcher.FetchRigs()
if err != nil {
log.Printf("dashboard: FetchRigs failed: %v", err)
}
}()
go func() {
defer wg.Done()
var err error
dogs, err = h.fetcher.FetchDogs()
if err != nil {
log.Printf("dashboard: FetchDogs failed: %v", err)
}
}()
go func() {
defer wg.Done()
var err error
escalations, err = h.fetcher.FetchEscalations()
if err != nil {
log.Printf("dashboard: FetchEscalations failed: %v", err)
}
}()
go func() {
defer wg.Done()
var err error
health, err = h.fetcher.FetchHealth()
if err != nil {
log.Printf("dashboard: FetchHealth failed: %v", err)
}
}()
go func() {
defer wg.Done()
var err error
queues, err = h.fetcher.FetchQueues()
if err != nil {
log.Printf("dashboard: FetchQueues failed: %v", err)
}
}()
go func() {
defer wg.Done()
var err error
sessions, err = h.fetcher.FetchSessions()
if err != nil {
log.Printf("dashboard: FetchSessions failed: %v", err)
}
}()
go func() {
defer wg.Done()
var err error
hooks, err = h.fetcher.FetchHooks()
if err != nil {
log.Printf("dashboard: FetchHooks failed: %v", err)
}
}()
go func() {
defer wg.Done()
var err error
mayor, err = h.fetcher.FetchMayor()
if err != nil {
log.Printf("dashboard: FetchMayor failed: %v", err)
}
}()
go func() {
defer wg.Done()
var err error
issues, err = h.fetcher.FetchIssues()
if err != nil {
log.Printf("dashboard: FetchIssues failed: %v", err)
}
}()
go func() {
defer wg.Done()
var err error
activity, err = h.fetcher.FetchActivity()
if err != nil {
log.Printf("dashboard: FetchActivity failed: %v", err)
}
}()
// Wait for fetches or timeout
done := make(chan struct{})
go func() {
wg.Wait()
close(done)
}()
select {
case <-done:
// All fetches completed
case <-ctx.Done():
log.Printf("dashboard: fetch timeout after %v", fetchTimeout)
}
mergeQueue, err := h.fetcher.FetchMergeQueue()
if err != nil {
// Non-fatal: show convoys even if merge queue fails
mergeQueue = nil
}
polecats, err := h.fetcher.FetchPolecats()
if err != nil {
// Non-fatal: show convoys even if polecats fail
polecats = nil
}
// Compute summary from already-fetched data
summary := computeSummary(polecats, hooks, issues, convoys, escalations, activity)
data := ConvoyData{
Convoys: convoys,
MergeQueue: mergeQueue,
Polecats: polecats,
Convoys: convoys,
MergeQueue: mergeQueue,
Polecats: polecats,
Mail: mail,
Rigs: rigs,
Dogs: dogs,
Escalations: escalations,
Health: health,
Queues: queues,
Sessions: sessions,
Hooks: hooks,
Mayor: mayor,
Issues: issues,
Activity: activity,
Summary: summary,
Expand: expandPanel,
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
@@ -64,3 +235,60 @@ func (h *ConvoyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}
}
// computeSummary calculates dashboard stats and alerts from fetched data.
func computeSummary(polecats []PolecatRow, hooks []HookRow, issues []IssueRow,
convoys []ConvoyRow, escalations []EscalationRow, activity []ActivityRow) *DashboardSummary {
summary := &DashboardSummary{
PolecatCount: len(polecats),
HookCount: len(hooks),
IssueCount: len(issues),
ConvoyCount: len(convoys),
EscalationCount: len(escalations),
}
// Count stuck polecats (status = "stuck")
for _, p := range polecats {
if p.WorkStatus == "stuck" {
summary.StuckPolecats++
}
}
// Count stale hooks (IsStale = true)
for _, h := range hooks {
if h.IsStale {
summary.StaleHooks++
}
}
// Count unacked escalations
for _, e := range escalations {
if !e.Acked {
summary.UnackedEscalations++
}
}
// Count high priority issues (P1 or P2)
for _, i := range issues {
if i.Priority == 1 || i.Priority == 2 {
summary.HighPriorityIssues++
}
}
// Count recent session deaths from activity
for _, a := range activity {
if a.Type == "session_death" || a.Type == "mass_death" {
summary.DeadSessions++
}
}
// Set HasAlerts flag
summary.HasAlerts = summary.StuckPolecats > 0 ||
summary.StaleHooks > 0 ||
summary.UnackedEscalations > 0 ||
summary.DeadSessions > 0 ||
summary.HighPriorityIssues > 0
return summary
}

View File

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

View File

@@ -14,9 +14,155 @@ var templateFS embed.FS
// ConvoyData represents data passed to the convoy template.
type ConvoyData struct {
Convoys []ConvoyRow
MergeQueue []MergeQueueRow
Polecats []PolecatRow
Convoys []ConvoyRow
MergeQueue []MergeQueueRow
Polecats []PolecatRow
Mail []MailRow
Rigs []RigRow
Dogs []DogRow
Escalations []EscalationRow
Health *HealthRow
Queues []QueueRow
Sessions []SessionRow
Hooks []HookRow
Mayor *MayorStatus
Issues []IssueRow
Activity []ActivityRow
Summary *DashboardSummary
Expand string // Panel to show fullscreen (from ?expand=name)
}
// RigRow represents a registered rig in the dashboard.
type RigRow struct {
Name string
GitURL string
PolecatCount int
CrewCount int
HasWitness bool
HasRefinery bool
}
// DogRow represents a Deacon helper worker.
type DogRow struct {
Name string // Dog name (e.g., "alpha")
State string // idle, working
Work string // Current work assignment
LastActive string // Formatted age (e.g., "5m ago")
RigCount int // Number of worktrees
}
// EscalationRow represents an escalation needing attention.
type EscalationRow struct {
ID string
Title string
Severity string // critical, high, medium, low
EscalatedBy string
Age string
Acked bool
}
// HealthRow represents system health status.
type HealthRow struct {
DeaconHeartbeat string // Age of heartbeat (e.g., "2m ago")
DeaconCycle int64
HealthyAgents int
UnhealthyAgents int
IsPaused bool
PauseReason string
HeartbeatFresh bool // true if < 5min old
}
// QueueRow represents a work queue.
type QueueRow struct {
Name string
Status string // active, paused, closed
Available int
Processing int
Completed int
Failed int
}
// SessionRow represents a tmux session.
type SessionRow struct {
Name string // Session name (e.g., "gt-gastown-witness")
Role string // witness, refinery, polecat, crew, deacon
Rig string // Rig name if applicable
Worker string // Worker name for polecats/crew
Activity string // Age since last activity
IsAlive bool // Whether Claude is running in session
}
// HookRow represents a hooked bead (work pinned to an agent).
type HookRow struct {
ID string // Bead ID (e.g., "gt-abc12")
Title string // Work item title
Assignee string // Agent address (e.g., "gastown/polecats/nux")
Agent string // Formatted agent name
Age string // Time since hooked
IsStale bool // True if hooked > 1 hour (potentially stuck)
}
// MayorStatus represents the Mayor's current state.
type MayorStatus struct {
IsAttached bool // True if gt-mayor tmux session exists
SessionName string // Tmux session name
LastActivity string // Age since last activity
IsActive bool // True if activity < 5 min (likely working)
Runtime string // Which runtime (claude, codex, etc.)
}
// IssueRow represents an open issue in the backlog.
type IssueRow struct {
ID string // Bead ID (e.g., "gt-abc12")
Title string // Issue title
Type string // issue, bug, feature, task
Priority int // 1=critical, 2=high, 3=medium, 4=low
Age string // Time since created
Labels string // Comma-separated labels
}
// ActivityRow represents an event in the activity feed.
type ActivityRow struct {
Time string // Formatted time (e.g., "2m ago")
Icon string // Emoji for event type
Type string // Event type (sling, done, mail, etc.)
Actor string // Who did it
Summary string // Human-readable description
}
// DashboardSummary provides at-a-glance stats and alerts.
type DashboardSummary struct {
// Stats
PolecatCount int
HookCount int
IssueCount int
ConvoyCount int
EscalationCount int
// Alerts (things needing attention)
StuckPolecats int // No activity > 5 min
StaleHooks int // Hooked > 1 hour
UnackedEscalations int
DeadSessions int // Sessions that died recently
HighPriorityIssues int // P1/P2 issues
// Computed
HasAlerts bool
}
// MailRow represents a mail message in the dashboard.
type MailRow struct {
ID string // Message ID (e.g., "hq-msg-abc123")
From string // Sender (e.g., "gastown/polecats/Toast")
FromRaw string // Raw sender address for color hashing
To string // Recipient (e.g., "mayor/")
Subject string // Message subject
Timestamp string // Formatted timestamp
Age string // Human-readable age (e.g., "5m ago")
Priority string // low, normal, high, urgent
Type string // task, notification, reply
Read bool // Whether message has been read
SortKey int64 // Unix timestamp for sorting
}
// PolecatRow represents a polecat worker in the dashboard.
@@ -26,6 +172,9 @@ type PolecatRow struct {
SessionID string // e.g., "gt-roxas-dag"
LastActivity activity.Info // Colored activity display
StatusHint string // Last line from pane (optional)
IssueID string // Currently assigned issue ID (e.g., "hq-1234")
IssueTitle string // Issue title (truncated)
WorkStatus string // working, stale, stuck, idle
}
// MergeQueueRow represents a PR in the merge queue.
@@ -64,10 +213,15 @@ type TrackedIssue struct {
func LoadTemplates() (*template.Template, error) {
// Define template functions
funcMap := template.FuncMap{
"activityClass": activityClass,
"statusClass": statusClass,
"workStatusClass": workStatusClass,
"progressPercent": progressPercent,
"activityClass": activityClass,
"statusClass": statusClass,
"workStatusClass": workStatusClass,
"progressPercent": progressPercent,
"senderColorClass": senderColorClass,
"severityClass": severityClass,
"dogStateClass": dogStateClass,
"queueStatusClass": queueStatusClass,
"polecatStatusClass": polecatStatusClass,
}
// Get the templates subdirectory
@@ -136,3 +290,85 @@ func progressPercent(completed, total int) int {
}
return (completed * 100) / total
}
// senderColorClass returns a CSS class for sender-based color coding.
// Uses a simple hash to assign consistent colors to each sender.
func senderColorClass(fromRaw string) string {
if fromRaw == "" {
return "sender-default"
}
// Simple hash: sum of bytes mod number of colors
var sum int
for _, b := range []byte(fromRaw) {
sum += int(b)
}
colors := []string{
"sender-cyan",
"sender-purple",
"sender-green",
"sender-yellow",
"sender-orange",
"sender-blue",
"sender-red",
"sender-pink",
}
return colors[sum%len(colors)]
}
// severityClass returns CSS class for escalation severity.
func severityClass(severity string) string {
switch severity {
case "critical":
return "severity-critical"
case "high":
return "severity-high"
case "medium":
return "severity-medium"
case "low":
return "severity-low"
default:
return "severity-unknown"
}
}
// dogStateClass returns CSS class for dog state.
func dogStateClass(state string) string {
switch state {
case "idle":
return "dog-idle"
case "working":
return "dog-working"
default:
return "dog-unknown"
}
}
// queueStatusClass returns CSS class for queue status.
func queueStatusClass(status string) string {
switch status {
case "active":
return "queue-active"
case "paused":
return "queue-paused"
case "closed":
return "queue-closed"
default:
return "queue-unknown"
}
}
// polecatStatusClass returns CSS class for polecat work status.
func polecatStatusClass(status string) string {
switch status {
case "working":
return "polecat-working"
case "stale":
return "polecat-stale"
case "stuck":
return "polecat-stuck"
case "idle":
return "polecat-idle"
default:
return "polecat-unknown"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -54,13 +54,8 @@ func TestConvoyTemplate_RendersConvoyList(t *testing.T) {
t.Error("Template should contain convoy ID hq-cv-def")
}
// Check titles are rendered
if !strings.Contains(output, "Feature X") {
t.Error("Template should contain title 'Feature X'")
}
if !strings.Contains(output, "Bugfix Y") {
t.Error("Template should contain title 'Bugfix Y'")
}
// The simplified dashboard no longer shows convoy titles in the table,
// only the convoy IDs. Titles are shown in expanded view.
}
func TestConvoyTemplate_LastActivityColors(t *testing.T) {
@@ -184,14 +179,16 @@ func TestConvoyTemplate_StatusIndicators(t *testing.T) {
data := ConvoyData{
Convoys: []ConvoyRow{
{
ID: "hq-cv-open",
Title: "Open Convoy",
Status: "open",
ID: "hq-cv-active",
Title: "Active Convoy",
Status: "open",
WorkStatus: "active",
},
{
ID: "hq-cv-closed",
Title: "Closed Convoy",
Status: "closed",
ID: "hq-cv-stuck",
Title: "Stuck Convoy",
Status: "open",
WorkStatus: "stuck",
},
},
}
@@ -204,12 +201,12 @@ func TestConvoyTemplate_StatusIndicators(t *testing.T) {
output := buf.String()
// Check status indicators
if !strings.Contains(output, "status-open") {
t.Error("Template should contain status-open class")
// Check work status badges are rendered (replaced status-open/closed classes)
if !strings.Contains(output, "badge-green") {
t.Error("Template should contain badge-green class for active status")
}
if !strings.Contains(output, "status-closed") {
t.Error("Template should contain status-closed class")
if !strings.Contains(output, "badge-red") {
t.Error("Template should contain badge-red class for stuck status")
}
}
@@ -232,7 +229,7 @@ func TestConvoyTemplate_EmptyState(t *testing.T) {
output := buf.String()
// Check for empty state message
if !strings.Contains(output, "No convoys") {
if !strings.Contains(output, "No active convoys") {
t.Error("Template should show empty state message when no convoys")
}
}