Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,19 @@ Projects sort by Stoplight priority so the most actionable rows are at the top:
🔴 broken → 🟢 healthy → ⚪ unknown. Order is stable within a tier so rows do not jitter between polls.
_Avoid_: bubbling, floating

## Auto-expansion

A project row opens by itself when its Stoplight needs attention (🔴 or 🟡) and closes when it returns
to 🟢/⚪. This is **edge-triggered**: expansion changes only when a project's Stoplight changes, or the
first time the project is seen. Level-triggering would re-open a row on every poll, fighting a user
who collapsed it deliberately, so a hand-collapsed row stays collapsed until something actually
happens to it.

Only the Project row auto-expands; Resource rows are always expanded by hand, since expanding one
fetches its deploy log. Because expansion inserts rows, the cursor is resolved back to the same
logical row after each snapshot rather than kept at the same index.
_Avoid_: auto-open, smart expand

## Dashboard tree

```
Expand All @@ -85,7 +98,8 @@ _Avoid_: bubbling, floating
🟢 n8n ✓ healthy
```

- **Project row**: expand/collapse with `enter`/`space`.
- **Project row**: expand/collapse with `enter`/`space`. Rows that need attention (🔴 🟡) expand on
their own and collapse again on recovery; see **Auto-expansion**.
- **Resource row**: expand to see the fqdn, repo, and the tail of the latest Deployment's log.
- **Log lines**: not navigable; fetched on expand, never during a poll.

Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ project, application, service, and database on your instance, without a browser.
- **Deploy log drill-down** — expand a resource to see the tail of its build log, fetched on
demand, never during a poll
- **Active-first sorting** — deploying and broken projects surface to the top automatically
- **Auto-expand what needs attention** — a project opens on its own when it goes red or yellow, and
closes when it recovers. Collapse one by hand and it stays collapsed until its status actually
changes
- **Auto-polling** — every 30 seconds by default; last-known state is retained and marked stale on
API errors
- **Smart fix** — one confirmed key press redeploys a failed build, starts a stopped resource,
Expand Down
129 changes: 129 additions & 0 deletions internal/ui/autoexpand_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
package ui

import (
"context"
"strings"
"testing"

"github.com/ericdahl-dev/coolify-green/internal/aggregator"
"github.com/ericdahl-dev/coolify-green/internal/coolify"
"github.com/ericdahl-dev/coolify-green/internal/state"
)

// project builds a one-application project whose container is at the given
// stoplight, so there is a resource row to expand into.
func project(uuid, name string, light aggregator.Stoplight) state.ProjectState {
status := map[aggregator.Stoplight]string{
aggregator.StoplightGreen: "running:healthy",
aggregator.StoplightYellow: "starting:unknown",
aggregator.StoplightRed: "exited:unhealthy",
aggregator.StoplightGrey: "",
}[light]
return state.ProjectState{
Instance: "studio", InstanceURL: "https://coolify.test",
UUID: uuid, Name: name, EnvironmentUUID: "e-" + uuid,
Resources: []state.ResourceState{{
Kind: coolify.KindApplication, UUID: "r-" + uuid, Name: name + "-web",
Status: status, ContainerLight: light,
}},
}
}

func snapOf(projects ...state.ProjectState) state.Snapshot {
return state.NewSnapshot(projects, []state.InstanceState{{Name: "studio", URL: "https://coolify.test"}})
}

func showsResource(d Dashboard, name string) bool {
return strings.Contains(d.BodyView(), name+"-web")
}

func TestAutoExpandOnFirstSighting(t *testing.T) {
d := NewDashboard(snapOf(
project("p1", "red", aggregator.StoplightRed),
project("p2", "yellow", aggregator.StoplightYellow),
project("p3", "green", aggregator.StoplightGreen),
), nil, nil, context.Background())

if !showsResource(d, "red") || !showsResource(d, "yellow") {
t.Errorf("red and yellow projects should open on first sighting:\n%s", d.BodyView())
}
if showsResource(d, "green") {
t.Errorf("a green project should stay collapsed:\n%s", d.BodyView())
}
}

func TestAutoExpandOnStatusChangeAndCollapseOnRecovery(t *testing.T) {
d := NewDashboard(snapOf(project("p1", "alpha", aggregator.StoplightGreen)), nil, nil, context.Background())
if showsResource(d, "alpha") {
t.Fatal("green project should start collapsed")
}
d, _ = d.Update(snapOf(project("p1", "alpha", aggregator.StoplightYellow)))
if !showsResource(d, "alpha") {
t.Errorf("going yellow should open the row:\n%s", d.BodyView())
}
d, _ = d.Update(snapOf(project("p1", "alpha", aggregator.StoplightGreen)))
if showsResource(d, "alpha") {
t.Errorf("recovering to green should close the row:\n%s", d.BodyView())
}
}

// Polling must never fight the user: a hand-collapsed red row stays collapsed
// while its status is unchanged...
func TestManualCollapseSurvivesIdenticalSnapshots(t *testing.T) {
red := snapOf(project("p1", "alpha", aggregator.StoplightRed))
d := NewDashboard(red, nil, nil, context.Background())
d, _ = d.Update(key("enter")) // collapse by hand
if showsResource(d, "alpha") {
t.Fatal("enter should collapse the row")
}
d, _ = d.Update(red)
d, _ = d.Update(red)
if showsResource(d, "alpha") {
t.Errorf("an unchanged snapshot re-opened a hand-collapsed row:\n%s", d.BodyView())
}
}

// ...but a genuine status change is a new event, so it opens again.
func TestStatusChangeOverridesManualCollapse(t *testing.T) {
d := NewDashboard(snapOf(project("p1", "alpha", aggregator.StoplightYellow)), nil, nil, context.Background())
d, _ = d.Update(key("enter"))
d, _ = d.Update(snapOf(project("p1", "alpha", aggregator.StoplightRed)))
if !showsResource(d, "alpha") {
t.Errorf("yellow turning red should open the row again:\n%s", d.BodyView())
}
}

// Auto-expansion inserts rows above the cursor, so the selection has to land
// on the same logical row it was on before.
func TestCursorStaysOnSameRowWhenAProjectAboveExpands(t *testing.T) {
d := NewDashboard(snapOf(
project("p1", "alpha", aggregator.StoplightGreen),
project("p2", "beta", aggregator.StoplightGreen),
), nil, nil, context.Background())
d, _ = d.Update(key("down"))
if r := d.SelectedURL(); !strings.HasSuffix(r, "/project/p2/environment/e-p2") {
t.Fatalf("setup: selected %q, want beta", r)
}

// alpha goes red: it sorts first and opens, inserting a row above beta.
d, _ = d.Update(snapOf(
project("p1", "alpha", aggregator.StoplightRed),
project("p2", "beta", aggregator.StoplightGreen),
))
if r := d.SelectedURL(); !strings.HasSuffix(r, "/project/p2/environment/e-p2") {
t.Errorf("cursor moved off beta to %q", r)
}
}

// A project that disappears and comes back is a first sighting again, not a
// status carried over from before.
func TestRemovedProjectIsForgotten(t *testing.T) {
red := snapOf(project("p1", "alpha", aggregator.StoplightRed))
d := NewDashboard(red, nil, nil, context.Background())
d, _ = d.Update(key("enter")) // collapse by hand
d, _ = d.Update(snapOf())
d, _ = d.Update(red)
if !showsResource(d, "alpha") {
t.Errorf("a returning red project should open on sighting:\n%s", d.BodyView())
}
}
93 changes: 82 additions & 11 deletions internal/ui/dashboard.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (

tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/ericdahl-dev/coolify-green/internal/aggregator"
"github.com/ericdahl-dev/coolify-green/internal/coolify"
"github.com/ericdahl-dev/coolify-green/internal/fix"
"github.com/ericdahl-dev/coolify-green/internal/state"
Expand Down Expand Up @@ -87,11 +88,15 @@ type logEntry struct {

// Dashboard is the main screen.
type Dashboard struct {
snapshot state.Snapshot
cursor int
expanded map[string]bool // project key -> expanded
resExpanded map[string]bool // project key + "/" + resource uuid -> expanded
logs map[string]logEntry
snapshot state.Snapshot
cursor int
expanded map[string]bool // project key -> expanded
resExpanded map[string]bool // project key + "/" + resource uuid -> expanded
logs map[string]logEntry
// lastStoplight is the stoplight each project was last seen at, so
// auto-expansion can be edge-triggered. Level-triggering would re-open a
// row the user collapsed by hand on every poll.
lastStoplight map[string]aggregator.Stoplight
lastActivity time.Time
selectionFade bool

Expand All @@ -111,16 +116,85 @@ type Dashboard struct {

// NewDashboard builds the dashboard around an initial snapshot.
func NewDashboard(snap state.Snapshot, actionerFactory ActionerFactory, logFetcher LogFetcher, ctx context.Context) Dashboard {
return Dashboard{
snapshot: snap,
d := Dashboard{
expanded: make(map[string]bool),
resExpanded: make(map[string]bool),
logs: make(map[string]logEntry),
lastStoplight: make(map[string]aggregator.Stoplight),
lastActivity: time.Now(),
actionerFactory: actionerFactory,
logFetcher: logFetcher,
ctx: ctx,
}
d.applySnapshot(snap)
return d
}

// needsAttention reports whether a project should have its row opened for the
// user: something in it is broken or moving.
func needsAttention(s aggregator.Stoplight) bool {
return s == aggregator.StoplightRed || s == aggregator.StoplightYellow
}

// applySnapshot installs a new snapshot, auto-expanding projects that have
// just started needing attention and collapsing those that have recovered.
// Expansion changes only when a project's stoplight changes (or on its first
// sighting), so a row the user collapsed by hand stays collapsed until
// something actually happens to it.
func (d *Dashboard) applySnapshot(snap state.Snapshot) {
prev := d.currentNavItem()

d.snapshot = snap

seen := make(map[string]struct{}, len(snap.Projects))
for _, proj := range snap.Projects {
key := proj.Key()
seen[key] = struct{}{}
light := proj.Stoplight()
if last, ok := d.lastStoplight[key]; ok && last == light {
continue
}
d.lastStoplight[key] = light
d.expanded[key] = needsAttention(light)
}
// A project that disappeared is a first sighting again if it returns.
for key := range d.lastStoplight {
if _, ok := seen[key]; !ok {
delete(d.lastStoplight, key)
}
}

d.restoreCursor(prev)
}

// restoreCursor puts the cursor back on the row it was on before the rebuild.
// Auto-expansion inserts rows above it, so the index alone is meaningless. A
// resource row that disappeared falls back to its project row.
func (d *Dashboard) restoreCursor(prev *navItem) {
items := d.buildNavList()
if prev == nil || len(items) == 0 {
if d.cursor >= len(items) {
d.cursor = max(len(items)-1, 0)
}
return
}
fallback := -1
for i, it := range items {
if it == *prev {
d.cursor = i
return
}
if fallback < 0 && it.kind == navProject && it.projKey == prev.projKey {
fallback = i
}
}
if fallback >= 0 {
d.cursor = fallback
return
}
if d.cursor >= len(items) {
d.cursor = len(items) - 1
}
}

func selectionTimeoutCmd() tea.Cmd {
Expand Down Expand Up @@ -267,7 +341,7 @@ func (d Dashboard) Update(msg tea.Msg) (Dashboard, tea.Cmd) {
d.fixResultMsg = ""

case state.Snapshot:
d.snapshot = msg
d.applySnapshot(msg)
// Drop cached logs for deployments that are still running so the
// next expand shows fresh output rather than a frozen tail.
for _, proj := range msg.Projects {
Expand All @@ -277,9 +351,6 @@ func (d Dashboard) Update(msg tea.Msg) (Dashboard, tea.Cmd) {
}
}
}
if count := len(d.buildNavList()); d.cursor >= count && count > 0 {
d.cursor = count - 1
}
}
return d, nil
}
Expand Down
14 changes: 7 additions & 7 deletions internal/ui/dashboard_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ func TestCollapsedProjectsHideResources(t *testing.T) {

func TestExpandProjectShowsResources(t *testing.T) {
d := newTestDashboard()
d.cursor = 1 // Alpha, since Beta sorts first
d.cursor = 2 // Alpha: Beta sorts first and is open, being red
d, _ = d.Update(key("enter"))

out := d.BodyView()
Expand Down Expand Up @@ -128,9 +128,9 @@ func TestCollapsingClampsTheCursor(t *testing.T) {

func TestSelectedResourceAndURL(t *testing.T) {
d := newTestDashboard()
d.cursor = 1 // Alpha
d.cursor = 2 // Alpha, below the open Beta
d, _ = d.Update(key("enter"))
d.cursor = 2 // alpha-web
d.cursor = 3 // alpha-web

r := d.SelectedResource()
if r == nil || r.Name != "alpha-web" {
Expand Down Expand Up @@ -158,9 +158,9 @@ func TestExpandingAResourceFetchesItsLog(t *testing.T) {
return []coolify.LogLine{{Output: "Build finished", Type: "stdout"}}, nil
}
d := NewDashboard(testSnapshot(), nil, fetcher, context.Background())
d.cursor = 1
d.cursor = 2
d, _ = d.Update(key("enter")) // expand Alpha
d.cursor = 2 // alpha-web
d.cursor = 3 // alpha-web
d, cmd := d.Update(key("enter"))

if cmd == nil {
Expand Down Expand Up @@ -221,7 +221,7 @@ func TestLogFetchErrorIsShown(t *testing.T) {
d := newTestDashboard()
d.resExpanded["studio/p1/a1"] = true
d, _ = d.Update(logsFetchedMsg{deploymentUUID: "dep1", err: errors.New("403 forbidden")})
d.cursor = 1
d.cursor = 2
d, _ = d.Update(key("enter"))
if !strings.Contains(d.BodyView(), "log unavailable: 403 forbidden") {
t.Errorf("log error not rendered:\n%s", d.BodyView())
Expand Down Expand Up @@ -278,7 +278,7 @@ func TestFixConfirmFlow(t *testing.T) {

func TestFixIsNotOfferedForHealthyProject(t *testing.T) {
d := newTestDashboard()
d.cursor = 1 // Alpha, healthy
d.cursor = 2 // Alpha, healthy
d, _ = d.Update(key("f"))
if d.fixStatus != fixIdle {
t.Errorf("healthy project armed a fix: %+v", d.fixPlan)
Expand Down
Loading