Files
gastown/internal/web/handler.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

295 lines
6.7 KiB
Go

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.
type ConvoyHandler struct {
fetcher ConvoyFetcher
template *template.Template
}
// NewConvoyHandler creates a new convoy handler with the given fetcher.
func NewConvoyHandler(fetcher ConvoyFetcher) (*ConvoyHandler, error) {
tmpl, err := LoadTemplates()
if err != nil {
return nil, err
}
return &ConvoyHandler{
fetcher: fetcher,
template: tmpl,
}, nil
}
// ServeHTTP handles GET / requests and renders the convoy dashboard.
func (h *ConvoyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// 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)
}
// Compute summary from already-fetched data
summary := computeSummary(polecats, hooks, issues, convoys, escalations, activity)
data := ConvoyData{
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")
if err := h.template.ExecuteTemplate(w, "convoy.html", data); err != nil {
http.Error(w, "Failed to render template", http.StatusInternalServerError)
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
}