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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user