Add 'gt dashboard' CLI command (hq-s1bg) (#65)

* Add LastActivity calculation for convoy dashboard (hq-x2xy)

Adds internal/activity package with color-coded activity tracking:
- Green: <2 minutes (active)
- Yellow: 2-5 minutes (stale)
- Red: >5 minutes (stuck)

Features:
- Calculate() function returns Info with formatted age and color class
- Helper methods: IsActive(), IsStale(), IsStuck()
- Handles edge cases: zero time, future time (clock skew)

Tests: 8 test functions with 25 sub-tests covering all thresholds.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Add convoy dashboard HTML template with Last Activity (hq-fq1g)

Adds internal/web package with convoy dashboard template:
- convoy.html with Last Activity column and color coding
- Green (<2min), Yellow (2-5min), Red (>5min) activity indicators
- htmx auto-refresh every 30 seconds
- Progress bars for convoy completion
- Status indicators for open/closed convoys
- Empty state when no convoys

Also includes internal/activity package (dependency from hq-x2xy):
- Calculate() returns Info with formatted age and color class
- Helper methods: IsActive(), IsStale(), IsStuck()

Tests: 6 template tests + 8 activity tests, all passing.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Add convoy list handler with activity data (hq-3edt)

Adds HTTP handler that wires convoy dashboard template to real data:
- ConvoyHandler: HTTP handler for GET / rendering convoy dashboard
- LiveConvoyFetcher: Fetches convoys from beads with activity data
- ConvoyFetcher interface: Enables mocking for tests

Features:
- Fetches open convoys from town beads
- Calculates progress (completed/total) from tracked issues
- Gets Last Activity from worker agent beads
- Color codes activity: Green (<2min), Yellow (2-5min), Red (>5min)

Includes dependencies (not yet merged):
- internal/activity: Activity calculation (hq-x2xy)
- internal/web/templates: HTML template (hq-fq1g)

Tests: 5 handler tests + 6 template tests + 8 activity tests = 19 total

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Add 'gt dashboard' CLI command (hq-s1bg)

Add dashboard command to start the convoy tracking web server.

Usage: gt dashboard [--port=8080] [--open]

Features:
- --port: Configurable HTTP port (default 8080)
- --open: Auto-open browser on start
- Cross-platform browser launch (darwin/linux/windows)
- Graceful workspace detection

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Mike Lady
2026-01-03 11:49:42 -08:00
committed by GitHub
parent 7afcea935b
commit 3488933cc2
10 changed files with 1567 additions and 0 deletions

View File

@@ -0,0 +1,133 @@
// Package activity provides last-activity tracking and color-coding for the dashboard.
package activity
import (
"time"
)
// Color class constants for activity status.
const (
ColorGreen = "green" // Active: <2 minutes
ColorYellow = "yellow" // Stale: 2-5 minutes
ColorRed = "red" // Stuck: >5 minutes
ColorUnknown = "unknown" // No activity data
)
// Thresholds for activity color coding.
const (
ThresholdActive = 2 * time.Minute // Green threshold
ThresholdStale = 5 * time.Minute // Yellow threshold (beyond this is red)
)
// Info holds activity information for display.
type Info struct {
LastActivity time.Time // Raw timestamp of last activity
Duration time.Duration // Time since last activity
FormattedAge string // Human-readable age (e.g., "2m", "1h")
ColorClass string // CSS class for coloring (green, yellow, red, unknown)
}
// Calculate computes activity info from a last-activity timestamp.
// Returns color-coded info based on thresholds:
// - Green: <2 minutes (active)
// - Yellow: 2-5 minutes (stale)
// - Red: >5 minutes (stuck)
// - Unknown: zero time value
func Calculate(lastActivity time.Time) Info {
info := Info{
LastActivity: lastActivity,
}
// Handle zero time (no activity data)
if lastActivity.IsZero() {
info.FormattedAge = "unknown"
info.ColorClass = ColorUnknown
return info
}
// Calculate duration since last activity
info.Duration = time.Since(lastActivity)
// Handle future time (clock skew) - treat as just now
if info.Duration < 0 {
info.Duration = 0
}
// Format age string
info.FormattedAge = formatAge(info.Duration)
// Determine color class
info.ColorClass = colorForDuration(info.Duration)
return info
}
// formatAge formats a duration as a short human-readable string.
// Examples: "<1m", "5m", "2h", "1d"
func formatAge(d time.Duration) string {
if d < time.Minute {
return "<1m"
}
if d < time.Hour {
return formatMinutes(d)
}
if d < 24*time.Hour {
return formatHours(d)
}
return formatDays(d)
}
func formatMinutes(d time.Duration) string {
mins := int(d.Minutes())
return formatInt(mins) + "m"
}
func formatHours(d time.Duration) string {
hours := int(d.Hours())
return formatInt(hours) + "h"
}
func formatDays(d time.Duration) string {
days := int(d.Hours() / 24)
return formatInt(days) + "d"
}
func formatInt(n int) string {
if n < 10 {
return string(rune('0'+n))
}
// For larger numbers, use standard conversion
result := ""
for n > 0 {
result = string(rune('0'+n%10)) + result
n /= 10
}
return result
}
// colorForDuration returns the color class for a given duration.
func colorForDuration(d time.Duration) string {
switch {
case d < ThresholdActive:
return ColorGreen
case d < ThresholdStale:
return ColorYellow
default:
return ColorRed
}
}
// IsActive returns true if the activity is within the active threshold (green).
func (i Info) IsActive() bool {
return i.ColorClass == ColorGreen
}
// IsStale returns true if the activity is in the stale range (yellow).
func (i Info) IsStale() bool {
return i.ColorClass == ColorYellow
}
// IsStuck returns true if the activity is beyond the stale threshold (red).
func (i Info) IsStuck() bool {
return i.ColorClass == ColorRed
}

View File

@@ -0,0 +1,178 @@
package activity
import (
"testing"
"time"
)
func TestCalculateActivity_Green(t *testing.T) {
tests := []struct {
name string
age time.Duration
wantAge string
wantColor string
}{
{"just now", 0, "<1m", ColorGreen},
{"30 seconds", 30 * time.Second, "<1m", ColorGreen},
{"1 minute", 1 * time.Minute, "1m", ColorGreen},
{"1m30s", 90 * time.Second, "1m", ColorGreen},
{"1m59s", 119 * time.Second, "1m", ColorGreen},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
lastActivity := time.Now().Add(-tt.age)
info := Calculate(lastActivity)
if info.FormattedAge != tt.wantAge {
t.Errorf("FormattedAge = %q, want %q", info.FormattedAge, tt.wantAge)
}
if info.ColorClass != tt.wantColor {
t.Errorf("ColorClass = %q, want %q", info.ColorClass, tt.wantColor)
}
})
}
}
func TestCalculateActivity_Yellow(t *testing.T) {
tests := []struct {
name string
age time.Duration
wantAge string
wantColor string
}{
{"2 minutes", 2 * time.Minute, "2m", ColorYellow},
{"3 minutes", 3 * time.Minute, "3m", ColorYellow},
{"4 minutes", 4 * time.Minute, "4m", ColorYellow},
{"4m59s", 299 * time.Second, "4m", ColorYellow},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
lastActivity := time.Now().Add(-tt.age)
info := Calculate(lastActivity)
if info.FormattedAge != tt.wantAge {
t.Errorf("FormattedAge = %q, want %q", info.FormattedAge, tt.wantAge)
}
if info.ColorClass != tt.wantColor {
t.Errorf("ColorClass = %q, want %q", info.ColorClass, tt.wantColor)
}
})
}
}
func TestCalculateActivity_Red(t *testing.T) {
tests := []struct {
name string
age time.Duration
wantAge string
wantColor string
}{
{"5 minutes", 5 * time.Minute, "5m", ColorRed},
{"10 minutes", 10 * time.Minute, "10m", ColorRed},
{"30 minutes", 30 * time.Minute, "30m", ColorRed},
{"1 hour", 1 * time.Hour, "1h", ColorRed},
{"2 hours", 2 * time.Hour, "2h", ColorRed},
{"1 day", 24 * time.Hour, "1d", ColorRed},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
lastActivity := time.Now().Add(-tt.age)
info := Calculate(lastActivity)
if info.FormattedAge != tt.wantAge {
t.Errorf("FormattedAge = %q, want %q", info.FormattedAge, tt.wantAge)
}
if info.ColorClass != tt.wantColor {
t.Errorf("ColorClass = %q, want %q", info.ColorClass, tt.wantColor)
}
})
}
}
func TestCalculateActivity_ZeroTime(t *testing.T) {
// Zero time should return unknown state
info := Calculate(time.Time{})
if info.ColorClass != ColorUnknown {
t.Errorf("ColorClass = %q, want %q for zero time", info.ColorClass, ColorUnknown)
}
if info.FormattedAge != "unknown" {
t.Errorf("FormattedAge = %q, want %q for zero time", info.FormattedAge, "unknown")
}
}
func TestCalculateActivity_FutureTime(t *testing.T) {
// Future time (clock skew) should be treated as "just now"
futureTime := time.Now().Add(5 * time.Second)
info := Calculate(futureTime)
if info.ColorClass != ColorGreen {
t.Errorf("ColorClass = %q, want %q for future time", info.ColorClass, ColorGreen)
}
}
func TestInfo_IsActive(t *testing.T) {
tests := []struct {
color string
isActive bool
}{
{ColorGreen, true},
{ColorYellow, false},
{ColorRed, false},
{ColorUnknown, false},
}
for _, tt := range tests {
t.Run(tt.color, func(t *testing.T) {
info := Info{ColorClass: tt.color}
if info.IsActive() != tt.isActive {
t.Errorf("IsActive() = %v, want %v for color %q", info.IsActive(), tt.isActive, tt.color)
}
})
}
}
func TestInfo_IsStale(t *testing.T) {
tests := []struct {
color string
isStale bool
}{
{ColorGreen, false},
{ColorYellow, true},
{ColorRed, false},
{ColorUnknown, false},
}
for _, tt := range tests {
t.Run(tt.color, func(t *testing.T) {
info := Info{ColorClass: tt.color}
if info.IsStale() != tt.isStale {
t.Errorf("IsStale() = %v, want %v for color %q", info.IsStale(), tt.isStale, tt.color)
}
})
}
}
func TestInfo_IsStuck(t *testing.T) {
tests := []struct {
color string
isStuck bool
}{
{ColorGreen, false},
{ColorYellow, false},
{ColorRed, true},
{ColorUnknown, false},
}
for _, tt := range tests {
t.Run(tt.color, func(t *testing.T) {
info := Info{ColorClass: tt.color}
if info.IsStuck() != tt.isStuck {
t.Errorf("IsStuck() = %v, want %v for color %q", info.IsStuck(), tt.isStuck, tt.color)
}
})
}
}

91
internal/cmd/dashboard.go Normal file
View File

@@ -0,0 +1,91 @@
package cmd
import (
"fmt"
"net/http"
"os/exec"
"runtime"
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/web"
"github.com/steveyegge/gastown/internal/workspace"
)
var (
dashboardPort int
dashboardOpen bool
)
var dashboardCmd = &cobra.Command{
Use: "dashboard",
GroupID: GroupDiag,
Short: "Start the convoy tracking web dashboard",
Long: `Start a web server that displays the convoy tracking dashboard.
The dashboard shows real-time convoy status with:
- Convoy list with status indicators
- Progress tracking for each convoy
- Last activity indicator (green/yellow/red)
- Auto-refresh every 30 seconds via htmx
Example:
gt dashboard # Start on default port 8080
gt dashboard --port 3000 # Start on port 3000
gt dashboard --open # Start and open browser`,
RunE: runDashboard,
}
func init() {
dashboardCmd.Flags().IntVar(&dashboardPort, "port", 8080, "HTTP port to listen on")
dashboardCmd.Flags().BoolVar(&dashboardOpen, "open", false, "Open browser automatically")
rootCmd.AddCommand(dashboardCmd)
}
func runDashboard(cmd *cobra.Command, args []string) error {
// Verify we're in a workspace
if _, err := workspace.FindFromCwdOrError(); err != nil {
return fmt.Errorf("not in a Gas Town workspace: %w", err)
}
// Create the live convoy fetcher
fetcher, err := web.NewLiveConvoyFetcher()
if err != nil {
return fmt.Errorf("creating convoy fetcher: %w", err)
}
// Create the handler
handler, err := web.NewConvoyHandler(fetcher)
if err != nil {
return fmt.Errorf("creating convoy handler: %w", err)
}
// Build the URL
url := fmt.Sprintf("http://localhost:%d", dashboardPort)
// Open browser if requested
if dashboardOpen {
go openBrowser(url)
}
// Start the server
fmt.Printf("🚚 Gas Town Dashboard starting at %s\n", url)
fmt.Printf(" Press Ctrl+C to stop\n")
return http.ListenAndServe(fmt.Sprintf(":%d", dashboardPort), handler)
}
// openBrowser opens the specified URL in the default browser.
func openBrowser(url string) {
var cmd *exec.Cmd
switch runtime.GOOS {
case "darwin":
cmd = exec.Command("open", url)
case "linux":
cmd = exec.Command("xdg-open", url)
case "windows":
cmd = exec.Command("cmd", "/c", "start", url)
default:
return
}
_ = cmd.Start()
}

View File

@@ -0,0 +1,58 @@
package cmd
import (
"testing"
"github.com/spf13/cobra"
)
func TestDashboardCmd_FlagsExist(t *testing.T) {
// Verify required flags exist with correct defaults
portFlag := dashboardCmd.Flags().Lookup("port")
if portFlag == nil {
t.Fatal("--port flag should exist")
}
if portFlag.DefValue != "8080" {
t.Errorf("--port default should be 8080, got %s", portFlag.DefValue)
}
openFlag := dashboardCmd.Flags().Lookup("open")
if openFlag == nil {
t.Fatal("--open flag should exist")
}
if openFlag.DefValue != "false" {
t.Errorf("--open default should be false, got %s", openFlag.DefValue)
}
}
func TestDashboardCmd_IsRegistered(t *testing.T) {
// Verify command is registered under root
found := false
for _, cmd := range rootCmd.Commands() {
if cmd.Name() == "dashboard" {
found = true
break
}
}
if !found {
t.Error("dashboard command should be registered with rootCmd")
}
}
func TestDashboardCmd_HasCorrectGroup(t *testing.T) {
if dashboardCmd.GroupID != GroupDiag {
t.Errorf("dashboard should be in diag group, got %s", dashboardCmd.GroupID)
}
}
func TestDashboardCmd_RequiresWorkspace(t *testing.T) {
// Create a test command that simulates running outside workspace
cmd := &cobra.Command{}
cmd.SetArgs([]string{})
// The actual workspace check happens in runDashboard
// This test verifies the command structure is correct
if dashboardCmd.RunE == nil {
t.Error("dashboard command should have RunE set")
}
}

297
internal/web/fetcher.go Normal file
View File

@@ -0,0 +1,297 @@
package web
import (
"bytes"
"encoding/json"
"fmt"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/steveyegge/gastown/internal/activity"
"github.com/steveyegge/gastown/internal/workspace"
)
// LiveConvoyFetcher fetches convoy data from beads.
type LiveConvoyFetcher struct {
townBeads string
}
// NewLiveConvoyFetcher creates a fetcher for the current workspace.
func NewLiveConvoyFetcher() (*LiveConvoyFetcher, error) {
townRoot, err := workspace.FindFromCwdOrError()
if err != nil {
return nil, fmt.Errorf("not in a Gas Town workspace: %w", err)
}
return &LiveConvoyFetcher{
townBeads: filepath.Join(townRoot, ".beads"),
}, nil
}
// FetchConvoys fetches all open convoys with their activity data.
func (f *LiveConvoyFetcher) FetchConvoys() ([]ConvoyRow, error) {
// List all open convoy-type issues
listArgs := []string{"list", "--type=convoy", "--status=open", "--json"}
listCmd := exec.Command("bd", listArgs...)
listCmd.Dir = f.townBeads
var stdout bytes.Buffer
listCmd.Stdout = &stdout
if err := listCmd.Run(); err != nil {
return nil, fmt.Errorf("listing convoys: %w", err)
}
var convoys []struct {
ID string `json:"id"`
Title string `json:"title"`
Status string `json:"status"`
CreatedAt string `json:"created_at"`
}
if err := json.Unmarshal(stdout.Bytes(), &convoys); err != nil {
return nil, fmt.Errorf("parsing convoy list: %w", err)
}
// Build convoy rows with activity data
rows := make([]ConvoyRow, 0, len(convoys))
for _, c := range convoys {
row := ConvoyRow{
ID: c.ID,
Title: c.Title,
Status: c.Status,
}
// Get tracked issues for progress and activity calculation
tracked := f.getTrackedIssues(c.ID)
row.Total = len(tracked)
var mostRecentActivity time.Time
for _, t := range tracked {
if t.Status == "closed" {
row.Completed++
}
// Track most recent activity from workers
if t.LastActivity.After(mostRecentActivity) {
mostRecentActivity = t.LastActivity
}
}
row.Progress = fmt.Sprintf("%d/%d", row.Completed, row.Total)
// Calculate activity info from most recent worker activity
if !mostRecentActivity.IsZero() {
row.LastActivity = activity.Calculate(mostRecentActivity)
} else {
row.LastActivity = activity.Info{
FormattedAge: "no activity",
ColorClass: activity.ColorUnknown,
}
}
// Get tracked issues for expandable view
row.TrackedIssues = make([]TrackedIssue, len(tracked))
for i, t := range tracked {
row.TrackedIssues[i] = TrackedIssue{
ID: t.ID,
Title: t.Title,
Status: t.Status,
Assignee: t.Assignee,
}
}
rows = append(rows, row)
}
return rows, nil
}
// trackedIssueInfo holds info about an issue being tracked by a convoy.
type trackedIssueInfo struct {
ID string
Title string
Status string
Assignee string
LastActivity time.Time
}
// getTrackedIssues fetches tracked issues for a convoy.
func (f *LiveConvoyFetcher) getTrackedIssues(convoyID string) []trackedIssueInfo {
dbPath := filepath.Join(f.townBeads, "beads.db")
// Query tracked dependencies from SQLite
safeConvoyID := strings.ReplaceAll(convoyID, "'", "''")
queryCmd := exec.Command("sqlite3", "-json", dbPath,
fmt.Sprintf(`SELECT depends_on_id, type FROM dependencies WHERE issue_id = '%s' AND type = 'tracks'`, safeConvoyID))
var stdout bytes.Buffer
queryCmd.Stdout = &stdout
if err := queryCmd.Run(); err != nil {
return nil
}
var deps []struct {
DependsOnID string `json:"depends_on_id"`
Type string `json:"type"`
}
if err := json.Unmarshal(stdout.Bytes(), &deps); err != nil {
return nil
}
// Collect issue IDs (normalize external refs)
issueIDs := make([]string, 0, len(deps))
for _, dep := range deps {
issueID := dep.DependsOnID
if strings.HasPrefix(issueID, "external:") {
parts := strings.SplitN(issueID, ":", 3)
if len(parts) == 3 {
issueID = parts[2]
}
}
issueIDs = append(issueIDs, issueID)
}
// Batch fetch issue details
details := f.getIssueDetailsBatch(issueIDs)
// Get worker info for activity timestamps
workers := f.getWorkersForIssues(issueIDs)
// Build result
result := make([]trackedIssueInfo, 0, len(issueIDs))
for _, id := range issueIDs {
info := trackedIssueInfo{ID: id}
if d, ok := details[id]; ok {
info.Title = d.Title
info.Status = d.Status
info.Assignee = d.Assignee
} else {
info.Title = "(external)"
info.Status = "unknown"
}
if w, ok := workers[id]; ok && w.LastActivity != nil {
info.LastActivity = *w.LastActivity
}
result = append(result, info)
}
return result
}
// issueDetail holds basic issue info.
type issueDetail struct {
ID string
Title string
Status string
Assignee string
}
// getIssueDetailsBatch fetches details for multiple issues.
func (f *LiveConvoyFetcher) getIssueDetailsBatch(issueIDs []string) map[string]*issueDetail {
result := make(map[string]*issueDetail)
if len(issueIDs) == 0 {
return result
}
args := append([]string{"show"}, issueIDs...)
args = append(args, "--json")
showCmd := exec.Command("bd", args...)
var stdout bytes.Buffer
showCmd.Stdout = &stdout
if err := showCmd.Run(); err != nil {
return result
}
var issues []struct {
ID string `json:"id"`
Title string `json:"title"`
Status string `json:"status"`
Assignee string `json:"assignee"`
}
if err := json.Unmarshal(stdout.Bytes(), &issues); err != nil {
return result
}
for _, issue := range issues {
result[issue.ID] = &issueDetail{
ID: issue.ID,
Title: issue.Title,
Status: issue.Status,
Assignee: issue.Assignee,
}
}
return result
}
// workerDetail holds worker info including last activity.
type workerDetail struct {
Worker string
LastActivity *time.Time
}
// getWorkersForIssues finds workers and their last activity for issues.
func (f *LiveConvoyFetcher) getWorkersForIssues(issueIDs []string) map[string]*workerDetail {
result := make(map[string]*workerDetail)
if len(issueIDs) == 0 {
return result
}
townRoot, _ := workspace.FindFromCwd()
if townRoot == "" {
return result
}
// Find all rig beads databases
rigDirs, _ := filepath.Glob(filepath.Join(townRoot, "*", "mayor", "rig", ".beads", "beads.db"))
for _, dbPath := range rigDirs {
for _, issueID := range issueIDs {
if _, ok := result[issueID]; ok {
continue
}
safeID := strings.ReplaceAll(issueID, "'", "''")
query := fmt.Sprintf(
`SELECT id, hook_bead, last_activity FROM issues WHERE issue_type = 'agent' AND status = 'open' AND hook_bead = '%s' LIMIT 1`,
safeID)
queryCmd := exec.Command("sqlite3", "-json", dbPath, query)
var stdout bytes.Buffer
queryCmd.Stdout = &stdout
if err := queryCmd.Run(); err != nil {
continue
}
var agents []struct {
ID string `json:"id"`
HookBead string `json:"hook_bead"`
LastActivity string `json:"last_activity"`
}
if err := json.Unmarshal(stdout.Bytes(), &agents); err != nil || len(agents) == 0 {
continue
}
agent := agents[0]
detail := &workerDetail{
Worker: agent.ID,
}
if agent.LastActivity != "" {
if t, err := time.Parse(time.RFC3339, agent.LastActivity); err == nil {
detail.LastActivity = &t
}
}
result[issueID] = detail
}
}
return result
}

50
internal/web/handler.go Normal file
View File

@@ -0,0 +1,50 @@
package web
import (
"html/template"
"net/http"
)
// ConvoyFetcher defines the interface for fetching convoy data.
type ConvoyFetcher interface {
FetchConvoys() ([]ConvoyRow, 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) {
convoys, err := h.fetcher.FetchConvoys()
if err != nil {
http.Error(w, "Failed to fetch convoys", http.StatusInternalServerError)
return
}
data := ConvoyData{
Convoys: convoys,
}
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
}
}

View File

@@ -0,0 +1,181 @@
package web
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/steveyegge/gastown/internal/activity"
)
// MockConvoyFetcher is a mock implementation for testing.
type MockConvoyFetcher struct {
Convoys []ConvoyRow
Error error
}
func (m *MockConvoyFetcher) FetchConvoys() ([]ConvoyRow, error) {
return m.Convoys, m.Error
}
func TestConvoyHandler_RendersTemplate(t *testing.T) {
mock := &MockConvoyFetcher{
Convoys: []ConvoyRow{
{
ID: "hq-cv-abc",
Title: "Test Convoy",
Status: "open",
Progress: "2/5",
Completed: 2,
Total: 5,
LastActivity: activity.Calculate(time.Now().Add(-1 * time.Minute)),
},
},
}
handler, err := NewConvoyHandler(mock)
if err != nil {
t.Fatalf("NewConvoyHandler() error = %v", err)
}
req := httptest.NewRequest("GET", "/", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Status = %d, want %d", w.Code, http.StatusOK)
}
body := w.Body.String()
// Check convoy data is rendered
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")
}
if !strings.Contains(body, "2/5") {
t.Error("Response should contain progress")
}
}
func TestConvoyHandler_LastActivityColors(t *testing.T) {
tests := []struct {
name string
age time.Duration
wantClass string
}{
{"green for active", 30 * time.Second, "activity-green"},
{"yellow for stale", 3 * time.Minute, "activity-yellow"},
{"red for stuck", 10 * time.Minute, "activity-red"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mock := &MockConvoyFetcher{
Convoys: []ConvoyRow{
{
ID: "hq-cv-test",
Title: "Test",
Status: "open",
LastActivity: activity.Calculate(time.Now().Add(-tt.age)),
},
},
}
handler, err := NewConvoyHandler(mock)
if err != nil {
t.Fatalf("NewConvoyHandler() error = %v", err)
}
req := httptest.NewRequest("GET", "/", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
body := w.Body.String()
if !strings.Contains(body, tt.wantClass) {
t.Errorf("Response should contain %q", tt.wantClass)
}
})
}
}
func TestConvoyHandler_EmptyConvoys(t *testing.T) {
mock := &MockConvoyFetcher{
Convoys: []ConvoyRow{},
}
handler, err := NewConvoyHandler(mock)
if err != nil {
t.Fatalf("NewConvoyHandler() error = %v", err)
}
req := httptest.NewRequest("GET", "/", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Status = %d, want %d", w.Code, http.StatusOK)
}
body := w.Body.String()
if !strings.Contains(body, "No convoys") {
t.Error("Response should show empty state message")
}
}
func TestConvoyHandler_ContentType(t *testing.T) {
mock := &MockConvoyFetcher{
Convoys: []ConvoyRow{},
}
handler, err := NewConvoyHandler(mock)
if err != nil {
t.Fatalf("NewConvoyHandler() error = %v", err)
}
req := httptest.NewRequest("GET", "/", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
contentType := w.Header().Get("Content-Type")
if !strings.Contains(contentType, "text/html") {
t.Errorf("Content-Type = %q, want text/html", contentType)
}
}
func TestConvoyHandler_MultipleConvoys(t *testing.T) {
mock := &MockConvoyFetcher{
Convoys: []ConvoyRow{
{ID: "hq-cv-1", Title: "First Convoy", Status: "open"},
{ID: "hq-cv-2", Title: "Second Convoy", Status: "closed"},
{ID: "hq-cv-3", Title: "Third Convoy", Status: "open"},
},
}
handler, err := NewConvoyHandler(mock)
if err != nil {
t.Fatalf("NewConvoyHandler() error = %v", err)
}
req := httptest.NewRequest("GET", "/", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
body := w.Body.String()
// Check all convoys are rendered
for _, id := range []string{"hq-cv-1", "hq-cv-2", "hq-cv-3"} {
if !strings.Contains(body, id) {
t.Errorf("Response should contain convoy %s", id)
}
}
}

96
internal/web/templates.go Normal file
View File

@@ -0,0 +1,96 @@
// 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
}
// ConvoyRow represents a single convoy in the dashboard.
type ConvoyRow struct {
ID string
Title string
Status string // "open" or "closed"
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,
"progressPercent": progressPercent,
}
// 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"
}
}
// progressPercent calculates percentage as an integer for progress bars.
func progressPercent(completed, total int) int {
if total == 0 {
return 0
}
return (completed * 100) / total
}

View File

@@ -0,0 +1,245 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Gas Town Dashboard</title>
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<style>
:root {
--bg-dark: #1a1a2e;
--bg-card: #16213e;
--text-primary: #eee;
--text-secondary: #aaa;
--border: #0f3460;
--green: #4ade80;
--yellow: #facc15;
--red: #f87171;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'SF Mono', 'Menlo', 'Monaco', monospace;
background: var(--bg-dark);
color: var(--text-primary);
padding: 20px;
min-height: 100vh;
}
.dashboard {
max-width: 1200px;
margin: 0 auto;
}
header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 1px solid var(--border);
}
h1 {
font-size: 1.5rem;
font-weight: 600;
}
.refresh-info {
color: var(--text-secondary);
font-size: 0.875rem;
}
.convoy-table {
width: 100%;
border-collapse: collapse;
background: var(--bg-card);
border-radius: 8px;
overflow: hidden;
}
.convoy-table th,
.convoy-table td {
padding: 12px 16px;
text-align: left;
border-bottom: 1px solid var(--border);
}
.convoy-table th {
background: var(--bg-dark);
font-weight: 500;
color: var(--text-secondary);
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.convoy-table tr:last-child td {
border-bottom: none;
}
.convoy-table tr:hover {
background: rgba(255, 255, 255, 0.02);
}
/* Status indicators */
.status-indicator {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 8px;
}
.status-open .status-indicator {
background: var(--yellow);
}
.status-closed .status-indicator {
background: var(--green);
}
/* Activity colors */
.activity-dot {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
margin-right: 8px;
}
.activity-green .activity-dot {
background: var(--green);
box-shadow: 0 0 8px var(--green);
}
.activity-yellow .activity-dot {
background: var(--yellow);
box-shadow: 0 0 8px var(--yellow);
}
.activity-red .activity-dot {
background: var(--red);
box-shadow: 0 0 8px var(--red);
}
.activity-unknown .activity-dot {
background: var(--text-secondary);
}
.convoy-id {
font-weight: 500;
color: var(--text-primary);
}
.convoy-title {
color: var(--text-secondary);
margin-left: 8px;
}
.progress {
font-variant-numeric: tabular-nums;
}
.progress-bar {
width: 60px;
height: 4px;
background: var(--border);
border-radius: 2px;
overflow: hidden;
margin-top: 4px;
}
.progress-fill {
height: 100%;
background: var(--green);
border-radius: 2px;
}
.empty-state {
text-align: center;
padding: 48px;
color: var(--text-secondary);
}
.empty-state h2 {
font-size: 1.25rem;
margin-bottom: 8px;
}
.empty-state p {
font-size: 0.875rem;
}
/* htmx loading indicator */
.htmx-request .htmx-indicator {
opacity: 1;
}
.htmx-indicator {
opacity: 0;
transition: opacity 200ms ease-in;
}
</style>
</head>
<body>
<div class="dashboard" hx-get="/" hx-trigger="every 30s" hx-swap="outerHTML">
<header>
<h1>🚚 Gas Town Convoys</h1>
<span class="refresh-info">
Auto-refresh: every 30s
<span class="htmx-indicator"></span>
</span>
</header>
{{if .Convoys}}
<table class="convoy-table">
<thead>
<tr>
<th>Status</th>
<th>Convoy</th>
<th>Progress</th>
<th>Last Activity</th>
</tr>
</thead>
<tbody>
{{range .Convoys}}
<tr class="{{statusClass .Status}}">
<td>
<span class="status-indicator"></span>
{{if eq .Status "open"}}●{{else}}✓{{end}}
</td>
<td>
<span class="convoy-id">{{.ID}}</span>
<span class="convoy-title">{{.Title}}</span>
</td>
<td class="progress">
{{.Progress}}
{{if .Total}}
<div class="progress-bar">
<div class="progress-fill" style="width: {{progressPercent .Completed .Total}}%;"></div>
</div>
{{end}}
</td>
<td class="{{activityClass .LastActivity}}">
<span class="activity-dot"></span>
{{.LastActivity.FormattedAge}}
</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<div class="empty-state">
<h2>No convoys found</h2>
<p>Create a convoy with: gt convoy create &lt;name&gt; [issues...]</p>
</div>
{{end}}
</div>
</body>
</html>

View File

@@ -0,0 +1,238 @@
package web
import (
"bytes"
"strings"
"testing"
"time"
"github.com/steveyegge/gastown/internal/activity"
)
func TestConvoyTemplate_RendersConvoyList(t *testing.T) {
tmpl, err := LoadTemplates()
if err != nil {
t.Fatalf("LoadTemplates() error = %v", err)
}
data := ConvoyData{
Convoys: []ConvoyRow{
{
ID: "hq-cv-abc",
Title: "Feature X",
Status: "open",
Progress: "2/5",
Completed: 2,
Total: 5,
LastActivity: activity.Calculate(time.Now().Add(-1 * time.Minute)),
},
{
ID: "hq-cv-def",
Title: "Bugfix Y",
Status: "open",
Progress: "1/3",
Completed: 1,
Total: 3,
LastActivity: activity.Calculate(time.Now().Add(-3 * time.Minute)),
},
},
}
var buf bytes.Buffer
err = tmpl.ExecuteTemplate(&buf, "convoy.html", data)
if err != nil {
t.Fatalf("ExecuteTemplate() error = %v", err)
}
output := buf.String()
// Check convoy IDs are rendered
if !strings.Contains(output, "hq-cv-abc") {
t.Error("Template should contain convoy ID hq-cv-abc")
}
if !strings.Contains(output, "hq-cv-def") {
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'")
}
}
func TestConvoyTemplate_LastActivityColors(t *testing.T) {
tmpl, err := LoadTemplates()
if err != nil {
t.Fatalf("LoadTemplates() error = %v", err)
}
tests := []struct {
name string
age time.Duration
wantClass string
}{
{"green for 1 minute", 1 * time.Minute, "activity-green"},
{"yellow for 3 minutes", 3 * time.Minute, "activity-yellow"},
{"red for 10 minutes", 10 * time.Minute, "activity-red"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
data := ConvoyData{
Convoys: []ConvoyRow{
{
ID: "hq-cv-test",
Title: "Test",
Status: "open",
LastActivity: activity.Calculate(time.Now().Add(-tt.age)),
},
},
}
var buf bytes.Buffer
err = tmpl.ExecuteTemplate(&buf, "convoy.html", data)
if err != nil {
t.Fatalf("ExecuteTemplate() error = %v", err)
}
output := buf.String()
if !strings.Contains(output, tt.wantClass) {
t.Errorf("Template should contain class %q for %v age", tt.wantClass, tt.age)
}
})
}
}
func TestConvoyTemplate_HtmxAutoRefresh(t *testing.T) {
tmpl, err := LoadTemplates()
if err != nil {
t.Fatalf("LoadTemplates() error = %v", err)
}
data := ConvoyData{
Convoys: []ConvoyRow{
{
ID: "hq-cv-test",
Title: "Test",
Status: "open",
},
},
}
var buf bytes.Buffer
err = tmpl.ExecuteTemplate(&buf, "convoy.html", data)
if err != nil {
t.Fatalf("ExecuteTemplate() error = %v", err)
}
output := buf.String()
// Check for htmx attributes
if !strings.Contains(output, "hx-get") {
t.Error("Template should contain hx-get for auto-refresh")
}
if !strings.Contains(output, "hx-trigger") {
t.Error("Template should contain hx-trigger for auto-refresh")
}
if !strings.Contains(output, "every 30s") {
t.Error("Template should refresh every 30 seconds")
}
}
func TestConvoyTemplate_ProgressDisplay(t *testing.T) {
tmpl, err := LoadTemplates()
if err != nil {
t.Fatalf("LoadTemplates() error = %v", err)
}
data := ConvoyData{
Convoys: []ConvoyRow{
{
ID: "hq-cv-test",
Title: "Test",
Status: "open",
Progress: "3/7",
Completed: 3,
Total: 7,
},
},
}
var buf bytes.Buffer
err = tmpl.ExecuteTemplate(&buf, "convoy.html", data)
if err != nil {
t.Fatalf("ExecuteTemplate() error = %v", err)
}
output := buf.String()
// Check progress is displayed
if !strings.Contains(output, "3/7") {
t.Error("Template should display progress '3/7'")
}
}
func TestConvoyTemplate_StatusIndicators(t *testing.T) {
tmpl, err := LoadTemplates()
if err != nil {
t.Fatalf("LoadTemplates() error = %v", err)
}
data := ConvoyData{
Convoys: []ConvoyRow{
{
ID: "hq-cv-open",
Title: "Open Convoy",
Status: "open",
},
{
ID: "hq-cv-closed",
Title: "Closed Convoy",
Status: "closed",
},
},
}
var buf bytes.Buffer
err = tmpl.ExecuteTemplate(&buf, "convoy.html", data)
if err != nil {
t.Fatalf("ExecuteTemplate() error = %v", err)
}
output := buf.String()
// Check status indicators
if !strings.Contains(output, "status-open") {
t.Error("Template should contain status-open class")
}
if !strings.Contains(output, "status-closed") {
t.Error("Template should contain status-closed class")
}
}
func TestConvoyTemplate_EmptyState(t *testing.T) {
tmpl, err := LoadTemplates()
if err != nil {
t.Fatalf("LoadTemplates() error = %v", err)
}
data := ConvoyData{
Convoys: []ConvoyRow{},
}
var buf bytes.Buffer
err = tmpl.ExecuteTemplate(&buf, "convoy.html", data)
if err != nil {
t.Fatalf("ExecuteTemplate() error = %v", err)
}
output := buf.String()
// Check for empty state message
if !strings.Contains(output, "No convoys") {
t.Error("Template should show empty state message when no convoys")
}
}