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:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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