Files
gastown/internal/web/templates.go
Clay Cantrell aca753296b 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.
2026-01-25 18:00:46 -08:00

375 lines
9.8 KiB
Go

// Package web provides HTTP server and templates for the Gas Town dashboard.
package web
import (
"embed"
"html/template"
"io/fs"
"github.com/steveyegge/gastown/internal/activity"
)
//go:embed templates/*.html
var templateFS embed.FS
// ConvoyData represents data passed to the convoy template.
type ConvoyData struct {
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.
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)
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.
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.
type ConvoyRow struct {
ID string
Title string
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
LastActivity activity.Info
TrackedIssues []TrackedIssue
}
// TrackedIssue represents an issue tracked by a convoy.
type TrackedIssue struct {
ID string
Title string
Status string
Assignee string
}
// LoadTemplates loads and parses all HTML templates.
func LoadTemplates() (*template.Template, error) {
// Define template functions
funcMap := template.FuncMap{
"activityClass": activityClass,
"statusClass": statusClass,
"workStatusClass": workStatusClass,
"progressPercent": progressPercent,
"senderColorClass": senderColorClass,
"severityClass": severityClass,
"dogStateClass": dogStateClass,
"queueStatusClass": queueStatusClass,
"polecatStatusClass": polecatStatusClass,
}
// Get the templates subdirectory
subFS, err := fs.Sub(templateFS, "templates")
if err != nil {
return nil, err
}
// Parse all templates
tmpl, err := template.New("").Funcs(funcMap).ParseFS(subFS, "*.html")
if err != nil {
return nil, err
}
return tmpl, nil
}
// activityClass returns the CSS class for an activity color.
func activityClass(info activity.Info) string {
switch info.ColorClass {
case activity.ColorGreen:
return "activity-green"
case activity.ColorYellow:
return "activity-yellow"
case activity.ColorRed:
return "activity-red"
default:
return "activity-unknown"
}
}
// statusClass returns the CSS class for a convoy status.
func statusClass(status string) string {
switch status {
case "open":
return "status-open"
case "closed":
return "status-closed"
default:
return "status-unknown"
}
}
// 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 {
return 0
}
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"
}
}