From d35519a0f31c08bf94ebb7bf2addab689eecb451 Mon Sep 17 00:00:00 2001 From: Eric Dahl Date: Sat, 26 Sep 2026 11:01:01 -0400 Subject: [PATCH] feat: auto-expand projects that need attention A project row now opens by itself when its stoplight goes red or yellow and closes when it recovers. Expansion is edge-triggered: it changes only when a project's stoplight changes or on its first sighting, so a row collapsed by hand stays collapsed until something actually happens to it. Resource rows are still expanded by hand, since expanding one fetches its deploy log. Expansion inserts rows, so after each snapshot the cursor is put back on the same logical row instead of the same index. Existing tests that hard-coded cursor positions below the red Beta project shift down one row. Ported from aws-green #60. Co-Authored-By: Claude Opus 5.5 --- CONTEXT.md | 16 +++- README.md | 3 + internal/ui/autoexpand_test.go | 129 +++++++++++++++++++++++++++++++++ internal/ui/dashboard.go | 93 +++++++++++++++++++++--- internal/ui/dashboard_test.go | 14 ++-- 5 files changed, 236 insertions(+), 19 deletions(-) create mode 100644 internal/ui/autoexpand_test.go diff --git a/CONTEXT.md b/CONTEXT.md index f09a2aa..b13893c 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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 ``` @@ -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. diff --git a/README.md b/README.md index 4d427ed..43f4df9 100644 --- a/README.md +++ b/README.md @@ -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, diff --git a/internal/ui/autoexpand_test.go b/internal/ui/autoexpand_test.go new file mode 100644 index 0000000..d12f9c7 --- /dev/null +++ b/internal/ui/autoexpand_test.go @@ -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()) + } +} diff --git a/internal/ui/dashboard.go b/internal/ui/dashboard.go index c0e37e4..080409e 100644 --- a/internal/ui/dashboard.go +++ b/internal/ui/dashboard.go @@ -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" @@ -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 @@ -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 { @@ -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 { @@ -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 } diff --git a/internal/ui/dashboard_test.go b/internal/ui/dashboard_test.go index 4480b90..090cf48 100644 --- a/internal/ui/dashboard_test.go +++ b/internal/ui/dashboard_test.go @@ -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() @@ -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" { @@ -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 { @@ -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()) @@ -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)