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:
133
internal/activity/activity.go
Normal file
133
internal/activity/activity.go
Normal 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
|
||||
}
|
||||
178
internal/activity/activity_test.go
Normal file
178
internal/activity/activity_test.go
Normal 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
91
internal/cmd/dashboard.go
Normal 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()
|
||||
}
|
||||
58
internal/cmd/dashboard_test.go
Normal file
58
internal/cmd/dashboard_test.go
Normal 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
297
internal/web/fetcher.go
Normal 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
50
internal/web/handler.go
Normal 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
|
||||
}
|
||||
}
|
||||
181
internal/web/handler_test.go
Normal file
181
internal/web/handler_test.go
Normal 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
96
internal/web/templates.go
Normal 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
|
||||
}
|
||||
245
internal/web/templates/convoy.html
Normal file
245
internal/web/templates/convoy.html
Normal 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 <name> [issues...]</p>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
238
internal/web/templates_test.go
Normal file
238
internal/web/templates_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user