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)