* 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.
295 lines
6.7 KiB
Go
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
|
|
}
|