diff --git a/main.go b/main.go index 0d69d91..7826ebb 100644 --- a/main.go +++ b/main.go @@ -23,21 +23,21 @@ var ( Foreground(lipgloss.Color("#00d4ff")). Padding(0, 1) - statusRunning = lipgloss.NewStyle().Foreground(lipgloss.Color("#00ff88")).SetString("● running") - statusIdle = lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")).SetString("○ idle") - statusBlocked = lipgloss.NewStyle().Foreground(lipgloss.Color("#ffaa00")).SetString("⚠ blocked") - statusError = lipgloss.NewStyle().Foreground(lipgloss.Color("#ff4444")).SetString("✕ error") - statusClosed = lipgloss.NewStyle().Foreground(lipgloss.Color("#555555")).SetString("✗ closed") - statusLost = lipgloss.NewStyle().Foreground(lipgloss.Color("#773333")).SetString("✗ lost") - subagentMark = lipgloss.NewStyle().Foreground(lipgloss.Color("#55aaff")).SetString("◇") + statusRunning = lipgloss.NewStyle().Foreground(lipgloss.Color("#00ff88")).SetString("● running") + statusIdle = lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")).SetString("○ idle") + statusBlocked = lipgloss.NewStyle().Foreground(lipgloss.Color("#ffaa00")).SetString("⚠ blocked") + statusError = lipgloss.NewStyle().Foreground(lipgloss.Color("#ff4444")).SetString("✕ error") + statusClosed = lipgloss.NewStyle().Foreground(lipgloss.Color("#555555")).SetString("✗ closed") + statusLost = lipgloss.NewStyle().Foreground(lipgloss.Color("#773333")).SetString("✗ lost") + subagentMark = lipgloss.NewStyle().Foreground(lipgloss.Color("#55aaff")).SetString("◇") detailKeyStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#00d4ff")).Bold(true) detailLabelStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#aaaaaa")) detailValStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#dddddd")) detailBoxStyle = lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("240")). - Padding(0, 1) + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("240")). + Padding(0, 1) infoStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("#aaaaaa")). @@ -50,10 +50,10 @@ var ( // Sort modes const ( - sortSmart = iota // live first, running first, then last activity - sortActivity // most recent activity first - sortStarted // most recently started first - sortFolder // folder name A-Z + sortSmart = iota // live first, running first, then last activity + sortActivity // most recent activity first + sortStarted // most recently started first + sortFolder // folder name A-Z ) var sortNames = map[int]string{ @@ -795,8 +795,7 @@ Config: ~/.config/pi-dashboard.yaml remote: - host: 192.168.20.13 user: sam - path: /home/sam/.pi/agent/dashboard/ -`) + path: /home/sam/.pi/agent/dashboard/`) return case "--list": cfg := defaultConfig() diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..136f766 --- /dev/null +++ b/main_test.go @@ -0,0 +1,120 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// Simulates the remote `cat *.json` output: several pretty-printed, +// multi-line JSON objects concatenated together. +func TestReadRemoteStream(t *testing.T) { + var sb strings.Builder + for _, name := range []string{"alpha", "beta"} { + s := AgentState{ + Session: name, + Machine: "test", + Cwd: "/home/sam/proj/" + name, + AgentStatus: "idle", + SessionState: "open", + LastSeenAt: strptr(time.Now().UTC().Format(time.RFC3339)), + } + b, _ := json.MarshalIndent(s, "", " ") + sb.Write(b) + sb.WriteString("\n") + } + + dec := json.NewDecoder(strings.NewReader(sb.String())) + var states []AgentState + for { + var s AgentState + if err := dec.Decode(&s); err != nil { + break + } + states = append(states, s) + } + if len(states) != 2 { + t.Fatalf("expected 2 states, got %d", len(states)) + } + if states[1].Session != "beta" { + t.Fatalf("expected beta second, got %s", states[1].Session) + } +} + +// A legacy state file (no heartbeat) should use file mtime as liveness. +func TestLegacyMtimeFallback(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "legacy.json") + legacy := `{"session":"legacy","status":"running"}` + os.WriteFile(path, []byte(legacy), 0644) + + s, err := readStateFile(path) + if err != nil { + t.Fatal(err) + } + if s.LastSeenAt == nil || *s.LastSeenAt == "" { + t.Fatal("expected mtime fallback to fill LastSeenAt") + } + if s.IsClosed(time.Hour) { + t.Fatal("fresh file should not be closed") + } +} + +// Stale heartbeat => closed, regardless of graceful state. +func TestStaleness(t *testing.T) { + old := time.Now().UTC().Add(-10 * time.Minute).Format(time.RFC3339) + fresh := time.Now().UTC().Format(time.RFC3339) + + stale := AgentState{SessionState: "open", LastSeenAt: &old} + if !stale.IsClosed(45 * time.Second) { + t.Fatal("old heartbeat should be closed") + } + if !stale.WasKilled(45 * time.Second) { + t.Fatal("no closed_at + stale => killed") + } + + graceful := AgentState{SessionState: "closed", ClosedAt: &old} + if !graceful.IsClosed(45 * time.Second) { + t.Fatal("closed state should be closed") + } + if graceful.WasKilled(45 * time.Second) { + t.Fatal("graceful close should not be killed") + } + + alive := AgentState{SessionState: "open", LastSeenAt: &fresh} + if alive.IsClosed(45 * time.Second) { + t.Fatal("fresh heartbeat should be live") + } +} + +// Sorting: live first, running before idle, then by activity. +func TestSortSmart(t *testing.T) { + now := time.Now().UTC() + mk := func(name, status string, age time.Duration) AgentState { + t := now.Add(-age).Format(time.RFC3339) + return AgentState{Session: name, AgentStatus: status, SessionState: "open", LastSeenAt: &t} + } + agents := []AgentState{ + mk("old-idle", "idle", 30*time.Minute), + mk("fresh-running", "running", time.Minute), + mk("old-closed", "idle", 10*time.Hour), + mk("fresh-idle", "idle", 2*time.Minute), + } + sortAgents(agents, sortSmart, 45*time.Second) + + got := []string{} + for _, a := range agents { + got = append(got, a.Session) + } + want := []string{"fresh-running", "fresh-idle", "old-idle", "old-closed"} + for i := range want { + if got[i] != want[i] { + t.Fatalf("sort mismatch at %d: got %v want %v", i, got, want) + } + } +} + +func strptr(s string) *string { return &s }