diff --git a/cmd/spinloop/dashboard_detail.go b/cmd/spinloop/dashboard_detail.go index 10d8b54..692dcc9 100644 --- a/cmd/spinloop/dashboard_detail.go +++ b/cmd/spinloop/dashboard_detail.go @@ -25,11 +25,6 @@ import ( // variable so a test need not wait on it. var detailLogInterval = 3 * time.Second -// dashDetailKeys is the detail view's footer key help, sharing footerLine -// with the grid's own (dashGridKeys) so a status outcome or the stop -// confirmation cannot be worded differently between the two. -const dashDetailKeys = "esc back s start x stop a abort f follow" - // detailLogTickMsg fires on detailLogInterval while the detail view is open. type detailLogTickMsg time.Time @@ -253,6 +248,6 @@ func (m dashModel) detailView() string { parts = append(parts, dashClip(line, w)) } parts = append(parts, divider) - parts = append(parts, m.footerLine(w, dashFooterHints(m.detailKeys(), m.canAbort()))) + parts = append(parts, m.footerLine(w, m.detailKeys())) return strings.Join(parts, "\n") } diff --git a/cmd/spinloop/dashboard_keep_test.go b/cmd/spinloop/dashboard_keep_test.go index 8d1c7ab..a7bf9c5 100644 --- a/cmd/spinloop/dashboard_keep_test.go +++ b/cmd/spinloop/dashboard_keep_test.go @@ -359,39 +359,58 @@ func TestDashAbortDrivesNothingOnAKeep(t *testing.T) { } } -// The keep hint shows only where the key would drive something: an idle remote -// node shows it, a local node hides it, and a busy remote node hides it. +// The keep hint shows only where the key would drive something: a remote node +// shows it, a local node hides it, and a busy remote node hides it. The start +// and stop entries sit beside it by the node's own state: a stopped remote +// environment shows keep and start, a running one shows keep and stop, and a +// busy one shows neither. func TestDashKeepHintOnlyWhereItDrivesSomething(t *testing.T) { - t.Run("idle remote shows it", func(t *testing.T) { + read := func(state string) fleet.NodeResult { + return fleet.NodeResult{Name: "env", Outcome: fleet.OutcomeOK, Metrics: metrics.Stats{State: state}} + } + t.Run("stopped remote shows keep and start", func(t *testing.T) { node := &keeperDashNode{f: newFakeDashNode("stopped")} m := keeperModel(node) - if !strings.Contains(m.gridKeys(), "k keep") { - t.Errorf("grid hint missing the keep key: %q", m.gridKeys()) + m.results[0] = read("stopped") + if got, want := m.gridKeys(), "↑↓←→ move s start k keep g format r refresh q quit"; got != want { + t.Errorf("grid hint:\ngot: %q\nwant: %q", got, want) + } + if got, want := m.detailKeys(), "esc back s start k keep f follow"; got != want { + t.Errorf("detail hint:\ngot: %q\nwant: %q", got, want) + } + }) + t.Run("running remote shows keep and stop", func(t *testing.T) { + node := &keeperDashNode{f: newFakeDashNode("running")} + m := keeperModel(node) + m.results[0] = read("running") + if got, want := m.gridKeys(), "↑↓←→ move k keep x stop g format r refresh q quit"; got != want { + t.Errorf("grid hint:\ngot: %q\nwant: %q", got, want) } - if !strings.Contains(m.detailKeys(), "k keep") { - t.Errorf("detail hint missing the keep key: %q", m.detailKeys()) + if got, want := m.detailKeys(), "esc back k keep x stop f follow"; got != want { + t.Errorf("detail hint:\ngot: %q\nwant: %q", got, want) } }) t.Run("local node hides it", func(t *testing.T) { f := newFakeDashNode("stopped") m := &dashModel{ entries: []dashEntry{{name: "box", kind: fleet.KindDaemon, node: f}}, - results: []fleet.NodeResult{{Name: "box"}}, + results: []fleet.NodeResult{{Name: "box", Outcome: fleet.OutcomeOK, Metrics: metrics.Stats{State: "stopped"}}}, actions: make([]dashAction, 1), width: 120, height: 40, } - if strings.Contains(m.gridKeys(), "k keep") { - t.Errorf("grid hint offers a keep a local node cannot take: %q", m.gridKeys()) + if got, want := m.gridKeys(), "↑↓←→ move s start g format r refresh q quit"; got != want { + t.Errorf("grid hint:\ngot: %q\nwant: %q", got, want) } }) t.Run("busy remote hides it", func(t *testing.T) { node := &keeperDashNode{f: newFakeDashNode("stopped")} m := keeperModel(node) + m.results[0] = read("stopped") m = openKeepPrompt(t, m) next, _ := m.Update(dashKey("enter")) m = next.(*dashModel) - if strings.Contains(m.gridKeys(), "k keep") { - t.Errorf("grid hint offers a second keep while one is in flight: %q", m.gridKeys()) + if got, want := m.gridKeys(), "↑↓←→ move g format r refresh q quit"; got != want { + t.Errorf("grid hint:\ngot: %q\nwant: %q", got, want) } }) } diff --git a/cmd/spinloop/dashboard_model.go b/cmd/spinloop/dashboard_model.go index 424eb75..1a00e58 100644 --- a/cmd/spinloop/dashboard_model.go +++ b/cmd/spinloop/dashboard_model.go @@ -738,6 +738,46 @@ func (m dashModel) canAbort() bool { return len(m.entries) > 0 && m.actions[m.cursor].verb == "start" } +// startOffered and stopOffered report whether the start and stop keys would do +// anything for the node under the cursor, from the same result the node's tile +// draws from: the tile and the footer read one account of the node, so the +// footer cannot name a key for a state the panel does not show. A read answers +// running when it is OK and carries a running state; a read that failed — or +// the absence of one, before the first answer — carries no state, which is not +// running, so start is the key that might still do something, and the one the +// line keeps offering. Both keys need the node to exist and have nothing in +// flight, the same guards the key handlers answer to: an entry that could not +// become a node takes nothing, and a node already acting takes no second +// action. The footer uses the offers to name a key only where it would drive +// something, the same way keepOffered gates the keep hint. +func (m dashModel) startOffered() bool { + return m.actionOffered() && !m.nodeRunning() +} + +func (m dashModel) stopOffered() bool { + return m.actionOffered() && m.nodeRunning() +} + +// actionOffered reports whether the node under the cursor could take an action +// at all: its entry has a node, and the node has nothing in flight. +func (m dashModel) actionOffered() bool { + if len(m.entries) == 0 { + return false + } + return m.entries[m.cursor].node != nil && m.actions[m.cursor].verb == "" +} + +// nodeRunning reports whether the board's current read of the node under the +// cursor answers running: the read answered and carries a running state. A +// model that has no read of the node cannot say it is running. +func (m dashModel) nodeRunning() bool { + if m.cursor >= len(m.results) { + return false + } + r := m.results[m.cursor] + return r.OK() && r.Metrics.State == "running" +} + // keepOffered reports whether the keep key would do anything for the node under // the cursor: the node must support a keep (a remote environment) and have // nothing in flight. A local daemon node has no retention tag to set, and a @@ -755,22 +795,43 @@ func (m dashModel) keepOffered() bool { return ok } -// gridKeys and detailKeys are the two footers' key help with the keep entry -// included only where keepOffered says the node under the cursor can be kept — -// the same gate the k key itself answers to, so a hint is never shown for a key -// that would drive nothing on that node. +// gridKeys and detailKeys are the two footers' key help: the entries that +// always apply, plus each action entry named only where its offer is true — +// start and stop from the node's own state, keep from its support for +// retention, and abort from the start in flight on it — so a hint is never +// shown for a key that would drive nothing on the node the line describes. func (m dashModel) gridKeys() string { + parts := []string{"↑↓←→ move"} + if m.startOffered() { + parts = append(parts, "s start") + } if m.keepOffered() { - return "↑↓←→ move s start k keep a abort x stop r refresh q quit" + parts = append(parts, "k keep") + } + if m.canAbort() { + parts = append(parts, "a abort") + } + if m.stopOffered() { + parts = append(parts, "x stop") } - return dashGridKeys + return strings.Join(append(parts, "g format", "r refresh", "q quit"), dashHintGap) } func (m dashModel) detailKeys() string { + parts := []string{"esc back"} + if m.startOffered() { + parts = append(parts, "s start") + } if m.keepOffered() { - return "esc back s start k keep x stop a abort f follow" + parts = append(parts, "k keep") + } + if m.stopOffered() { + parts = append(parts, "x stop") } - return dashDetailKeys + if m.canAbort() { + parts = append(parts, "a abort") + } + return strings.Join(append(parts, "f follow"), dashHintGap) } // indexOf finds an entry by name. Fleet-file names are unique — the fleet @@ -840,7 +901,7 @@ func (m dashModel) View() string { if hi > lo { parts = append(parts, strings.Join(rows[lo:hi], "\n")) } - parts = append(parts, m.footerLine(w, dashFooterHints(m.gridKeys(), m.canAbort()))) + parts = append(parts, m.footerLine(w, m.gridKeys())) return strings.Join(parts, "\n") } @@ -853,10 +914,6 @@ func (m dashModel) headerLine(w int) string { fmt.Sprintf("%s (%d %s)", m.fleetPath, len(m.entries), word), w) } -// dashGridKeys is the grid's own key help; the detail view's footer shares -// footerLine but names its own keys instead (see dashDetailKeys). -const dashGridKeys = "↑↓←→ move s start a abort x stop g format r refresh q quit" - // footerLine is the frame's bottom line: the given key help, replaced by the // stop confirmation prompt while one is pending, with the status line and a // "refreshing" marker appended — shared by the grid and the detail view so diff --git a/cmd/spinloop/dashboard_render.go b/cmd/spinloop/dashboard_render.go index da6226c..2cb1361 100644 --- a/cmd/spinloop/dashboard_render.go +++ b/cmd/spinloop/dashboard_render.go @@ -91,27 +91,10 @@ func dashClip(line string, width int) string { return ansi.CutWc(line, 0, width) } -// dashHintGap separates one key-help entry from the next, and is what both -// dashFooterHints and dashKeyHints split a hint line on. +// dashHintGap separates one key-help entry from the next, and is what +// dashKeyHints splits a hint line on. const dashHintGap = " " -// dashFooterHints drops the "a abort" entry from a key-help line when -// nothing is currently abortable, so the footer never advertises a key that -// would do nothing for the node it describes. -func dashFooterHints(hints string, abortable bool) string { - if abortable { - return hints - } - parts := strings.Split(hints, dashHintGap) - kept := make([]string, 0, len(parts)) - for _, p := range parts { - if p != "a abort" { - kept = append(kept, p) - } - } - return strings.Join(kept, dashHintGap) -} - // dashKeyHints draws a key-help line: each entry is a key and what that key // does, and entries are three spaces apart (dashHintGap). The key keeps the // terminal's own text colour and what it does is drawn a step back in the diff --git a/cmd/spinloop/fleet_dashboard_test.go b/cmd/spinloop/fleet_dashboard_test.go index 70dd868..30af914 100644 --- a/cmd/spinloop/fleet_dashboard_test.go +++ b/cmd/spinloop/fleet_dashboard_test.go @@ -2454,14 +2454,68 @@ func TestDashCanAbort(t *testing.T) { } } -func TestDashFooterHints(t *testing.T) { - const hints = "j/k move s start a abort x stop r refresh q quit" - if got := dashFooterHints(hints, true); got != hints { - t.Errorf("abortable dropped or changed hints: %q", got) +// start is offered where the key would do something — the node exists, has +// nothing in flight, and the board's current read does not report it running — +// and stop is offered exactly where the read reports it running. A read that +// failed, or none at all, reports no state, which is not running, so start is +// the key the line keeps offering. +func TestDashStartAndStopOffered(t *testing.T) { + read := func(state string) fleet.NodeResult { + return fleet.NodeResult{Name: "a", Outcome: fleet.OutcomeOK, Metrics: metrics.Stats{State: state}} } - want := "j/k move s start x stop r refresh q quit" - if got := dashFooterHints(hints, false); got != want { - t.Errorf("dashFooterHints(false) = %q, want %q", got, want) + cases := []struct { + name string + node fleet.Node + result fleet.NodeResult + verb string + wantStart bool + wantStop bool + }{ + {"idle", newFakeDashNode("idle"), read("idle"), "", true, false}, + {"running", newFakeDashNode("running"), read("running"), "", false, true}, + {"stopped", newFakeDashNode("stopped"), read("stopped"), "", true, false}, + {"crashed", newFakeDashNode("crashed"), read("crashed"), "", true, false}, + {"undeployed", newFakeDashNode("undeployed"), read("undeployed"), "", true, false}, + {"a read that failed", newFakeDashNode("running"), + fleet.NodeResult{Name: "a", Outcome: fleet.OutcomeUnreachable, Err: errors.New("connection refused")}, + "", true, false}, + {"no read yet", newFakeDashNode("running"), fleet.NodeResult{Name: "a"}, "", true, false}, + {"a node that never became one", nil, + fleet.NodeResult{Name: "a", Outcome: fleet.OutcomeConfigError, Err: errors.New("set nowhere")}, + "", false, false}, + {"a start in flight", newFakeDashNode("idle"), read("idle"), "start", false, false}, + {"a stop in flight", newFakeDashNode("running"), read("running"), "stop", false, false}, + {"a keep in flight", newFakeDashNode("stopped"), read("stopped"), "keep", false, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + m := dashModel{ + entries: []dashEntry{{name: "a", kind: fleet.KindDaemon, node: tc.node}}, + results: []fleet.NodeResult{tc.result}, + actions: []dashAction{{verb: tc.verb}}, + } + if got := m.startOffered(); got != tc.wantStart { + t.Errorf("startOffered() = %v, want %v", got, tc.wantStart) + } + if got := m.stopOffered(); got != tc.wantStop { + t.Errorf("stopOffered() = %v, want %v", got, tc.wantStop) + } + }) + } + if m := (dashModel{}); m.startOffered() || m.stopOffered() { + t.Error("an empty fleet offers start or stop") + } + // A model built without any read cannot say the node is running: start + // stays offered, stop does not. + m := dashModel{ + entries: []dashEntry{{name: "a", kind: fleet.KindDaemon, node: newFakeDashNode("running")}}, + actions: make([]dashAction, 1), + } + if got := m.startOffered(); got != true { + t.Errorf("startOffered() with no reads = %v, want true", got) + } + if got := m.stopOffered(); got != false { + t.Errorf("stopOffered() with no reads = %v, want false", got) } } @@ -2551,6 +2605,221 @@ func TestDashDetailFooterOmitsAbortWhenNothingIsAbortable(t *testing.T) { } } +// The footer names each action key only where its offer is true, each screen +// in its own entry order — the grid reads move, start, keep, abort, stop, +// format, refresh, quit, and the detail view reads back, start, keep, stop, +// abort, follow. +func TestDashFooterNamesOnlyTheKeysTheNodeTakes(t *testing.T) { + read := func(state string) fleet.NodeResult { + return fleet.NodeResult{Name: "a", Outcome: fleet.OutcomeOK, Metrics: metrics.Stats{State: state}} + } + kept := &keeperDashNode{f: newFakeDashNode("stopped")} + cases := []struct { + name string + m dashModel + grid string + detail string + }{ + { + "running local node", + dashModel{ + entries: []dashEntry{{name: "a", kind: fleet.KindDaemon, node: newFakeDashNode("running")}}, + results: []fleet.NodeResult{read("running")}, + actions: make([]dashAction, 1), + }, + "↑↓←→ move x stop g format r refresh q quit", + "esc back x stop f follow", + }, + { + "stopped local node", + dashModel{ + entries: []dashEntry{{name: "a", kind: fleet.KindDaemon, node: newFakeDashNode("stopped")}}, + results: []fleet.NodeResult{read("stopped")}, + actions: make([]dashAction, 1), + }, + "↑↓←→ move s start g format r refresh q quit", + "esc back s start f follow", + }, + { + "unknown state", + dashModel{ + entries: []dashEntry{{name: "a", kind: fleet.KindDaemon, node: newFakeDashNode("stopped")}}, + results: []fleet.NodeResult{{Name: "a"}}, + actions: make([]dashAction, 1), + }, + "↑↓←→ move s start g format r refresh q quit", + "esc back s start f follow", + }, + { + "a node that never became one", + dashModel{ + entries: []dashEntry{{name: "broken", kind: fleet.KindDaemon, + standing: fleet.NodeResult{Name: "broken", Outcome: fleet.OutcomeConfigError, Err: errors.New("set nowhere")}}}, + results: []fleet.NodeResult{{Name: "broken", Outcome: fleet.OutcomeConfigError}}, + actions: make([]dashAction, 1), + }, + "↑↓←→ move g format r refresh q quit", + "esc back f follow", + }, + { + "a start in flight", + dashModel{ + entries: []dashEntry{{name: "a", kind: fleet.KindDaemon, node: newFakeDashNode("stopped")}}, + results: []fleet.NodeResult{read("stopped")}, + actions: []dashAction{{verb: "start"}}, + }, + "↑↓←→ move a abort g format r refresh q quit", + "esc back a abort f follow", + }, + { + "a stop in flight", + dashModel{ + entries: []dashEntry{{name: "a", kind: fleet.KindDaemon, node: newFakeDashNode("running")}}, + results: []fleet.NodeResult{read("running")}, + actions: []dashAction{{verb: "stop"}}, + }, + "↑↓←→ move g format r refresh q quit", + "esc back f follow", + }, + { + "a stopped remote environment", + dashModel{ + entries: []dashEntry{{name: "env", kind: fleet.KindRemote, node: kept}}, + results: []fleet.NodeResult{read("stopped")}, + actions: make([]dashAction, 1), + }, + "↑↓←→ move s start k keep g format r refresh q quit", + "esc back s start k keep f follow", + }, + { + "a running remote environment", + dashModel{ + entries: []dashEntry{{name: "env", kind: fleet.KindRemote, node: kept}}, + results: []fleet.NodeResult{read("running")}, + actions: make([]dashAction, 1), + }, + "↑↓←→ move k keep x stop g format r refresh q quit", + "esc back k keep x stop f follow", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := tc.m.gridKeys(); got != tc.grid { + t.Errorf("gridKeys():\ngot: %q\nwant: %q", got, tc.grid) + } + if got := tc.m.detailKeys(); got != tc.detail { + t.Errorf("detailKeys():\ngot: %q\nwant: %q", got, tc.detail) + } + }) + } +} + +// The key help names start only where the key would do something — the +// node's current read does not report it running — and stop only where it +// does, on both screens: a read that failed, or no read at all, offers start +// rather than stop, since the board cannot say the node is running. +func TestDashKeyHelpHidesStartAndStopWhereTheyWouldDoNothing(t *testing.T) { + lipgloss.SetColorProfile(termenv.Ascii) + read := func(state string) fleet.NodeResult { + return fleet.NodeResult{Name: "a", Outcome: fleet.OutcomeOK, Metrics: metrics.Stats{State: state}} + } + cases := []struct { + name string + result fleet.NodeResult + wantStart bool + wantStop bool + }{ + {"running", read("running"), false, true}, + {"idle", read("idle"), true, false}, + {"stopped", read("stopped"), true, false}, + {"crashed", read("crashed"), true, false}, + {"undeployed", read("undeployed"), true, false}, + {"a failed read", fleet.NodeResult{Name: "a", Outcome: fleet.OutcomeUnreachable, Err: errors.New("connection refused")}, true, false}, + {"no read yet", fleet.NodeResult{Name: "a"}, true, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + m := dashModel{ + entries: []dashEntry{{name: "a", kind: fleet.KindDaemon, node: newFakeDashNode("running")}}, + results: []fleet.NodeResult{tc.result}, + actions: make([]dashAction, 1), + width: 80, height: 24, + } + if v := m.View(); (strings.Contains(v, "s start")) != tc.wantStart || (strings.Contains(v, "x stop")) != tc.wantStop { + t.Errorf("grid key help for %s:\n%s", tc.name, v) + } + m.detail = true + if v := m.detailView(); (strings.Contains(v, "s start")) != tc.wantStart || (strings.Contains(v, "x stop")) != tc.wantStop { + t.Errorf("detail key help for %s:\n%s", tc.name, v) + } + }) + } +} + +// The key help is about the node under the cursor: on a multi-node board the +// entries follow the selection — start for a node the read reports not +// running, stop for one it reports running. +func TestDashKeyHelpFollowsTheSelection(t *testing.T) { + lipgloss.SetColorProfile(termenv.Ascii) + read := func(name, state string) fleet.NodeResult { + return fleet.NodeResult{Name: name, Outcome: fleet.OutcomeOK, Metrics: metrics.Stats{State: state}} + } + m := &dashModel{ + entries: []dashEntry{ + {name: "a", kind: fleet.KindDaemon, node: newFakeDashNode("stopped")}, + {name: "b", kind: fleet.KindDaemon, node: newFakeDashNode("running")}, + }, + results: []fleet.NodeResult{read("a", "stopped"), read("b", "running")}, + actions: make([]dashAction, 2), + width: 120, height: 40, + } + if v := m.View(); !strings.Contains(v, "s start") || strings.Contains(v, "x stop") { + t.Errorf("on the stopped node:\n%s", v) + } + m2, _ := m.Update(dashKey("right")) + mm := m2.(*dashModel) + if mm.cursor != 1 { + t.Fatalf("right did not move to the second node: %d", mm.cursor) + } + if v := mm.View(); strings.Contains(v, "s start") || !strings.Contains(v, "x stop") { + t.Errorf("on the running node:\n%s", v) + } +} + +// The hint hides a key, but the key is not gated: pressing start on a node +// the read reports running still makes the call, and the node's own refusal +// lands on the status line with the dashboard still open — the one-shot +// answer, not silence. +func TestDashStartKeyIsNotGatedByItsHint(t *testing.T) { + node := newFakeDashNode("running") + node.startErr = errors.New("already running") + m := &dashModel{ + entries: []dashEntry{{name: "a", kind: fleet.KindDaemon, node: node}}, + results: []fleet.NodeResult{{Name: "a", Outcome: fleet.OutcomeOK, Metrics: metrics.Stats{State: "running"}}}, + actions: make([]dashAction, 1), + width: 120, height: 40, + } + if v := m.View(); strings.Contains(v, "s start") { + t.Fatalf("the hint offers a start the node is already serving:\n%s", v) + } + _, cmd := m.Update(dashKey("s")) + if cmd == nil { + t.Fatal("s drove nothing on a running node") + } + msg, _ := runAction(t, cmd).(dashActionMsg) + m2, _ := m.Update(msg) + mm := m2.(*dashModel) + if mm.statusLine != "a: start failed — already running" { + t.Errorf("status line: %q", mm.statusLine) + } + if mm.actions[0].verb != "" { + t.Errorf("the failed action was not cleared: %+v", mm.actions[0]) + } + if v := mm.View(); !strings.Contains(v, "a: start failed — already running") { + t.Errorf("the outcome is not on the status line:\n%s", v) + } +} + // Quit lives on the grid only: q and ctrl+c inside the detail view drive // nothing, and the view stays open — the operator escapes back first. func TestDashModelDetailQuitIsGridOnly(t *testing.T) { @@ -2963,7 +3232,7 @@ func TestDashDetailViewRendersMetricsLogAndFooter(t *testing.T) { lipgloss.SetColorProfile(termenv.Ascii) m := dashModel{ fleetPath: "fleet.yaml", - entries: []dashEntry{{name: "up", kind: fleet.KindDaemon}}, + entries: []dashEntry{{name: "up", kind: fleet.KindDaemon, node: newFakeDashNode("idle")}}, results: []fleet.NodeResult{{ Name: "up", Outcome: fleet.OutcomeOK, Metrics: metrics.Stats{State: "idle", Runner: "llamacpp", ModelID: "org/qwen"}, @@ -2983,7 +3252,9 @@ func TestDashDetailViewRendersMetricsLogAndFooter(t *testing.T) { if !strings.Contains(view, "line one") || !strings.Contains(view, "line two") { t.Errorf("log section missing the tailed lines:\n%s", view) } - if !strings.Contains(view, dashFooterHints(dashDetailKeys, false)) { + // The node is idle with nothing in flight, so the footer names back, + // start (the node is not running) and follow — not stop, not abort. + if !strings.Contains(view, "esc back s start f follow") { t.Errorf("footer does not name the detail view's keys:\n%s", view) } } diff --git a/docs/commands/fleet.md b/docs/commands/fleet.md index 2d26513..700e163 100644 --- a/docs/commands/fleet.md +++ b/docs/commands/fleet.md @@ -308,10 +308,10 @@ spinloop fleet dashboard --fleet f.yaml # another fleet file | `Enter` | Open a full-screen view of the selected node | | `r` | Force a refresh of every node, now | | `g` | Toggle every tile's resource series between bar (sparklines of the retained history) and gauge (the current reading) | -| `s` | Start the selected node — without confirmation | +| `s` | Start the selected node — without confirmation — shown only for a node that is not running, and only while it has no action in flight | | `k` | Keep a remote environment for a duration you type — shown only for a node that can be kept, and only while it has no action in flight | | `a` | Abandon a start in flight on the selected node — the wait ends, the node is free again (a stop in flight is not abortable) | -| `x` | Stop the selected node — it asks first (`y` sends, `n` or `esc` cancel) | +| `x` | Stop the selected node — it asks first (`y` sends, `n` or `esc` cancel) — shown only for a node that is running, and only while it has no action in flight | | `q` or `Ctrl+C` | Leave | The board keeps its own cadence: local machines are read every two seconds, diff --git a/openspec/changes/state-aware-dashboard-keys/.openspec.yaml b/openspec/changes/state-aware-dashboard-keys/.openspec.yaml new file mode 100644 index 0000000..1a62d62 --- /dev/null +++ b/openspec/changes/state-aware-dashboard-keys/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-06 diff --git a/openspec/changes/state-aware-dashboard-keys/design.md b/openspec/changes/state-aware-dashboard-keys/design.md new file mode 100644 index 0000000..7df5722 --- /dev/null +++ b/openspec/changes/state-aware-dashboard-keys/design.md @@ -0,0 +1,123 @@ +# Design: state-aware dashboard key hints + +## Context + +See proposal.md for the motivation (issue #173). The state of the code the +change lands in: + +- The dashboard's key help is one footer line, drawn by `footerLine` for both + screens. The grid's line comes from `dashModel.gridKeys`, the detail view's + from `dashModel.detailKeys`; each is a fixed string that already drops the + keep entry where `keepOffered` says it would drive nothing, and the abort + entry is dropped after the fact by `dashFooterHints` where `canAbort` says + nothing is abortable. +- The node the line describes is one: the entry under the cursor on the grid, + the entry in view in the detail view (the cursor does not move while the + view is open). +- The board holds one read per node (`results`, parallel to `entries`), and + the tile renders exactly that read — an answered report with a state, or a + failure with no state. A read answers "running" when it is OK and its + metrics carry a running state. +- The keys themselves already guard what they do: `s` and `x` drive nothing on + a node with an action in flight, `beginAction` refuses a nil node, and the + node operations behind them refuse start on a running engine and stop on a + non-running one. The hints simply do not follow those guards yet. + +## Goals / Non-Goals + +**Goals:** + +- The key help names `s` and `x` only where the key it names would drive + something on the node the line describes, on both screens, with the same + gate the key handler answers to. +- The existing entry order and wording of each footer line is preserved; only + entries appear and disappear. + +**Non-Goals:** + +- Changing what `s`, `x`, `k`, or `a` do, or when the key handlers refuse + them: the keys keep their current guards, and the status line keeps saying + so when a key is pressed anyway. +- Showing per-tile key hints inside the tiles: the footer is the one key help + line, and it is already about the selected node. +- Gating the keys that do not depend on the node's state (move, format, + refresh, quit, back, follow, and the keep prompt's own keys). + +## Decisions + +### 1. Gate on the board's current read, not a separate record of the last +answered one + +Each offer predicate reads the same `fleet.NodeResult` the tile renders for +the described node: "running" means that result is OK and reports a running +state. A failed read, and the empty result before the first answered one, +report no state, which is not running — so the line offers start, not stop, +exactly while the tile shows the failure or "waiting for first refresh". + +Keeping one source of truth for what the node is doing is what lets the tile +and the footer never disagree: the footer is read from the same value the +panel was drawn from, with no second store to drift out of step with it. + +Alternative considered: remembering the last answered read per node so a +transient failed read would keep offering stop for a node last seen running. +That stores a second account of the node's state beside the one the tile +draws from, and the tile itself shows no state while the read fails — the +footer would then advertise a stop the panel does not show a reason for. + +### 2. The offers live on the model, one predicate per key + +`gridKeys` and `detailKeys` each build their line from the per-key offers for +the described node — a `startOffered` and a `stopOffered` beside the existing +`keepOffered` and `canAbort` — joined in each screen's existing entry order. +The predicates take no arguments beyond the model: the described node is +`entries[cursor]` for both screens, the read is `results[cursor]`, and the +action is `actions[cursor]`. + +- start is offered when the node exists, has no action in flight, and the + board's current read of it does not report it running. +- stop is offered when the node exists, has no action in flight, and the + board's current read of it reports it running. + +The node-existence check is the one `keepOffered` already gets for free +(its capability assertion fails on a nil node): a node that could not become +a node offers no start and no stop either, matching `beginAction`'s own +refusal for it. + +Alternative considered: keeping the fixed strings and filtering entries out of +the rendered line, as the abort entry is done today. That is the string +surgery the code is moving away from — the keep change already gates at +construction — and a fourth filter for a second key would make the footer's +contents a function of which substrings survived, rather than of which keys +are offered. + +### 3. The abort filter is folded into the construction + +With the abort entry one of the gated parts, `dashFooterHints` — the +string filter that exists only to drop the abort entry — has no work left and +is removed; the grid's `View` passes the constructed line to `footerLine` +directly. The abort predicate is unchanged, so the abort's existing behaviour +and spec wording are untouched. + +### 4. The keys are not gated + +Only the hints change. `s` on a running node and `x` on a stopped one still +drive nothing and leave the node's own reason on the status line, so an +operator who knows the board better than its hints, or a key pressed a beat +ahead of the read that would hide it, still gets the answer the one-shot +surface gives. + +## Risks / Trade-offs + +- [A failed read following a running read offers start rather than stop for + the duration of the failure] → the tile shows no state at the same time, so + the footer agrees with the panel; pressing start on a node that in fact is + running fails with the daemon's own reason on the status line, which is the + same answer the one-shot `fleet start` gives. +- [The line changes length as the selection moves and states refresh] → that + is the existing behaviour for the keep and abort entries; the line is + re-cut to the width on every draw, and a shorter line is never a problem + for a footer. +- [A stop offered on a read that is a beat old] → the offer follows the same + read the tile draws from, so the footer never claims a state the panel does + not show; the confirmation the stop still asks for is the guard against an + operator acting on a moment-stale glance. diff --git a/openspec/changes/state-aware-dashboard-keys/proposal.md b/openspec/changes/state-aware-dashboard-keys/proposal.md new file mode 100644 index 0000000..d67629d --- /dev/null +++ b/openspec/changes/state-aware-dashboard-keys/proposal.md @@ -0,0 +1,53 @@ +# State-aware dashboard key hints + +## Why + +The dashboard's key help line names `s start` and `x stop` for the node under +the cursor whatever that node's state is, so it invites the operator to press +a key that would do nothing there: starting a node that is already running, +stopping one that is not. The abort and keep hints already follow the rule the +spec sets for them — never name a key that would do nothing for the node the +hint describes — and start and stop do not. (issue #173) + +## What Changes + +- The dashboard's key help line — the grid's footer for the node under the + cursor, and the detail view's footer for the node in view — names `s start` + only when that node is not running on its current board read, and names + `x stop` only when it is. A node whose state is unknown — no answered read + yet, or its newest read failed — still shows start, and not stop: there is + no reason to withhold the one key that might do something. +- Neither key is named while that node has an action in flight (a start, a + stop, or a keep), the same gate the keep hint already carries and the same + guard the keys themselves answer to, since an in-flight action leaves both + driving nothing. +- A node that never became a node — a token reference that resolves to + nothing — names neither key, since neither would drive anything on it. +- The keys themselves are unchanged: `s` on a running node and `x` on a + stopped one still drive nothing and say so. Only what the hint line + advertises changes. + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `fleet-client`: the requirement "The dashboard drives the selected node" + gains the rule for when the key help names the start and stop keys, + parallel to its existing rule for the abort key, with scenarios. + +## Impact + +- `cmd/spinloop/dashboard_model.go`: the grid and detail key lines are built + from per-key offers for the node the line describes, in the style of the + existing `keepOffered` and `canAbort`. +- `cmd/spinloop/dashboard_render.go`: the abort-only hint filter is folded + into the key line's construction. +- `cmd/spinloop` tests: the key help tests in `fleet_dashboard_test.go` and + `dashboard_keep_test.go` are extended to the state-by-action matrix. +- `docs/commands/fleet.md`: the dashboard's key table gives `s` and `x` the + same shown-only-where-they-drive-something wording `k` already carries. +- No API, file format, or dependency changes; no node operation changes. diff --git a/openspec/changes/state-aware-dashboard-keys/specs/fleet-client/spec.md b/openspec/changes/state-aware-dashboard-keys/specs/fleet-client/spec.md new file mode 100644 index 0000000..065965a --- /dev/null +++ b/openspec/changes/state-aware-dashboard-keys/specs/fleet-client/spec.md @@ -0,0 +1,223 @@ +## MODIFIED Requirements + +### Requirement: The dashboard drives the selected node + +The dashboard SHALL let the operator start and stop the engine of the node +currently selected, from the keyboard, through the same node operations the one- +shot `fleet start` and `fleet stop` commands use. An action SHALL reach the +node under the cursor and no other. An action is one per node, not one per +board: while one node is starting, the operator selects another and starts it, +and the wakes run side by side. A node that already has an action in flight +SHALL take no further start or stop; the dashboard says it is still working +and drives nothing. + +Start SHALL proceed without confirmation. Stop SHALL require an explicit +confirmation before it is sent, because it ends an engine that may be serving +work: a declined or abandoned confirmation SHALL send nothing. + +While an action is in flight, the node's own tile SHALL carry it: the verb, the +action's current situation, and how long the action has been running, beside the +node's last report rather than in place of it — the action's own account says +what the operator asked for, the report says what the node is doing. For a node +whose last completed refresh answered, the tile SHALL show that answer's state, +what it serves, its last-active record, and its resource usage and token and +request counters whenever the answer carries them, whatever the node's state — a +boot half done is already measuring, and that is the truth the tile keeps showing +while the call works. A node whose action reports nothing and whose refresh has +not yet answered SHALL show the verb alone, and a latest refresh that failed +SHALL change nothing on the tile. Each finished action SHALL clear the action +from its node's tile, which is then the node's report alone, and leave its +outcome on the status line, the one-shot wording. + +An action SHALL have exactly one current situation at a time, and a new one +SHALL replace its predecessor outright rather than being added to it. A +situation that was true of an attempt the action has moved on from SHALL NOT be +shown: the tile reports what the action is doing now, never what it was doing +before. In particular, a start refused for want of capacity SHALL stop reporting +that refusal once a further attempt is under way — so a tile never shows a +capacity wait beside a refresh reporting the node up and running. + +Anything the tile says about time SHALL be computed when the tile is drawn, not +when the situation it describes arose: a wait counts down towards the attempt it +is waiting for, and an action counts up from when the operator issued it. A +start's situation can hold unchanged for minutes — the attempt that succeeds +keeps one request open for the whole boot and reports nothing while it does — so +these are what distinguish an action that is waiting from one that is wedged. + +An action in flight SHALL also carry a spinner beside its verb, whose frame is +likewise chosen when the tile is drawn, and the board SHALL redraw often enough +for it to turn. It says the same thing as the elapsed time to an operator +glancing rather than reading, and it says it on a tile whose every other line +can hold still for minutes. + +The outcome of an action SHALL be shown inside the dashboard (a status line the +operator can read before the next refresh replaces attention), and a refused or +unreachable action SHALL NOT close the dashboard. What an action changes in state +— an engine coming up after a start, coming down after a stop — SHALL appear +through the normal refresh, without the operator asking for it. + +A start in flight SHALL be abortable from the keyboard, on the node under the +cursor. A start carries no deadline — a cold cloud wake takes minutes, and a +deadline would report a failure to a slow success — so the operator's abort is +its exit: an abort SHALL end the dashboard's wait on the start, return the +node's tile to the node's report, free the node to be started or stopped again, +and show its outcome on the status line, without closing the dashboard. An abort +ends the wait, not the work it set in motion: the dashboard SHALL NOT present it +as a cancellation of the wake, and what the wake goes on to do — the node's +state — SHALL appear through the normal refresh. + +A stop in flight SHALL NOT be abortable: it targets an engine that is already +running rather than a cold wake with no deadline of its own, and abandoning +the dashboard's wait on it would leave the operator unsure whether the stop +still went ahead. The abort key SHALL drive nothing on a node whose in-flight +action is a stop. + +The dashboard's key help SHALL name the abort key only while a start is in +flight on the node it describes — the node under the cursor on the grid, the +node in view in the detail view — and SHALL NOT name it for an idle or +running node, or one whose in-flight action is a stop, so the operator is +never invited to press a key that would do nothing there. + +The dashboard's key help SHALL name the start key for the node it describes +only when that node has no action in flight and the board's current read of it +does not report it running, and SHALL name the stop key for that node only when +it has no action in flight and the board's current read of it reports it +running, so the operator is never invited to press a key that would do nothing +there. A read reports its node running when it answered and carries a running +state; a node whose current read reports no state at all — no read has +answered yet, or its newest read failed — SHALL be offered the start key rather +than the stop key, since the board cannot say the node is running and start is +the key that might still do something on it. A node that could not become a +node at all SHALL be offered neither key, since neither would drive anything on +it. + +#### Scenario: Starting a cold node + +- **WHEN** the operator selects a node with no engine running and issues the + start +- **THEN** the start is sent without a prompt, its outcome is shown in the + status line, and the panel shows the node's new state as the refreshes come + around + +#### Scenario: Stopping asks first + +- **WHEN** the operator issues the stop on the selected node +- **THEN** the dashboard asks for confirmation and nothing is sent +- **WHEN** the operator declines +- **THEN** the stop is not sent and the node's state is unchanged + +#### Scenario: A confirmed stop is sent + +- **WHEN** the operator issues the stop and confirms +- **THEN** the stop is sent to the selected node only, its outcome is shown in + the status line, and the panel follows the node's state on subsequent refreshes + +#### Scenario: A start is watched on its own tile + +- **WHEN** the operator starts a node whose start reports its situation as it + works +- **THEN** the node's tile shows the verb and the start's current situation + while the start is in flight +- **AND** a refresh that answers while the start is in flight shows its state + and whatever it measures on the same tile, beneath the start's own account +- **AND** when the start finishes, the tile is the node's report alone and the + outcome is on the status line + +#### Scenario: A refused start does not outlive its refusal + +- **WHEN** a node's start is refused for want of capacity, and a later attempt + is issued once capacity is free +- **THEN** the tile reports the capacity wait while it holds, and stops + reporting it once the next attempt is under way, rather than showing it + beside a refresh reporting the node running + +#### Scenario: A start's elapsed time keeps moving + +- **WHEN** a start is in flight and its situation does not change for some time +- **THEN** the elapsed time beside the verb keeps advancing on the tile, so the + operator can tell the start is still waiting rather than wedged + +#### Scenario: A wait counts down and an action counts up + +- **WHEN** a start is waiting for its next attempt and the operator watches + without pressing anything +- **THEN** the time until that attempt counts down on the tile, and the time + since the operator issued the start counts up, both advancing as the tile is + redrawn rather than standing at the values they held when the wait began +- **AND** the spinner beside the verb turns while they do + +#### Scenario: An in-flight start before any report + +- **WHEN** the operator starts a node before any refresh of it has answered +- **THEN** its tile shows the verb and the start's own account alone +- **AND** the first answered refresh appears on the tile beside them + +#### Scenario: Two nodes wake at once + +- **WHEN** the operator starts one node, selects another, and starts it +- **THEN** both wakes run at the same time, each reported on its own tile +- **AND** each finishing action clears its own node and leaves its outcome on + the status line without disturbing the other + +#### Scenario: A node still starting is not started again + +- **WHEN** the operator presses start on a node whose start is in flight +- **THEN** the dashboard drives nothing and says the node is still starting + +#### Scenario: An in-flight start can be abandoned + +- **WHEN** the operator's start on a node is still in flight — the cloud waiting + for capacity, the boot slow, or the connection behind it dropping and + retrying — and the operator issues the abort on that node +- **THEN** the dashboard stops waiting on the start, the node's tile returns to + the node's report, and the outcome is shown on the status line +- **AND** the node may be started or stopped again, and the dashboard keeps + running with its refreshes + +#### Scenario: An abort is not a cancellation of the wake + +- **WHEN** the operator aborts a start whose wake the cloud is already carrying + on +- **THEN** the dashboard reports that it stopped waiting, not that the wake was + cancelled +- **AND** the node's state, whatever the wake goes on to do, appears through the + normal refresh + +#### Scenario: A stop in flight cannot be aborted + +- **WHEN** the operator issues the abort on a node whose stop is in flight +- **THEN** the dashboard drives nothing: the stop keeps running, the tile keeps + showing it as stopping, and the node's state comes back through the normal + refresh + +#### Scenario: The key help hides abort when nothing is abortable + +- **WHEN** the node under the cursor (or shown in the detail view) is idle, + running with nothing in flight, or has a stop in flight +- **THEN** the key help does not name the abort key +- **WHEN** that node has a start in flight +- **THEN** the key help names the abort key + +#### Scenario: The key help hides start and stop where they would do nothing + +- **WHEN** the node under the cursor (or shown in the detail view) has no + action in flight and the board's current read of it reports it running +- **THEN** the key help names the stop key and does not name the start key +- **WHEN** that node has no action in flight and the board's current read of + it reports it not running +- **THEN** the key help names the start key and does not name the stop key +- **WHEN** that node has an action in flight, or could not become a node at all +- **THEN** the key help names neither the start key nor the stop key + +#### Scenario: The key help offers start when the node's state is unknown + +- **WHEN** the node under the cursor has no action in flight, and no read of + it has answered yet, or its newest read failed +- **THEN** the key help names the start key and does not name the stop key + +#### Scenario: An action that fails keeps the dashboard open + +- **WHEN** an action is sent to a node that cannot be reached, or the node + refuses it +- **THEN** the failure is shown in the status line with the daemon's own reason + and the dashboard keeps running with its refreshes diff --git a/openspec/changes/state-aware-dashboard-keys/tasks.md b/openspec/changes/state-aware-dashboard-keys/tasks.md new file mode 100644 index 0000000..a8612cc --- /dev/null +++ b/openspec/changes/state-aware-dashboard-keys/tasks.md @@ -0,0 +1,23 @@ +# Tasks: state-aware dashboard key hints + +## 1. Key offers on the model + +- [x] 1.1 Add `startOffered` and `stopOffered` predicates on `dashModel` in `cmd/spinloop/dashboard_model.go`, beside `keepOffered` and `canAbort`: start offered when the described node exists, has no action in flight, and the board's current read of it does not report it running; stop offered when it exists, has no action in flight, and the read reports it running. Verify with table tests covering: each state (`idle`, `running`, `stopped`, `crashed`, `undeployed`), a read that failed, no read yet, a nil node, and each in-flight verb (`start`, `stop`, `keep`). +- [x] 1.2 Rebuild `gridKeys` and `detailKeys` to join their entries from the per-key offers — `move`/`back` and the board-wide keys always, then `s start`, `k keep`, `a abort`, `x stop` in each screen's existing order — so a key is named only where its offer is true. Verify with tests asserting each footer's full string for a running node, a stopped node, an unknown-state node, a nil node, and a busy node, on both screens. + +## 2. Fold the abort filter into the construction + +- [x] 2.1 Remove `dashFooterHints` from `cmd/spinloop/dashboard_render.go` and pass `m.gridKeys()` to `footerLine` directly from the grid's `View`, the abort entry now coming from `canAbort` at construction. Verify `go build ./...` succeeds and the abort's key help tests (named in the spec's "The key help hides abort when nothing is abortable" scenario) still pass unchanged in meaning. + +## 3. Key help tests + +- [x] 3.1 Extend the key help tests in `cmd/spinloop/fleet_dashboard_test.go` to the spec's scenarios — "The key help hides start and stop where they would do nothing" and "The key help offers start when the node's state is unknown" — driving the model through selection, reads, and actions rather than calling the predicates alone. Verify with `go test ./cmd/spinloop/`. +- [x] 3.2 Update the keep hint tests in `cmd/spinloop/dashboard_keep_test.go` that build the grid and detail key lines, so they hold the start and stop entries the offers now place beside the keep entry (for example, a kept remote environment that is running shows keep and stop, not keep and start). Verify with `go test ./cmd/spinloop/`. + +## 4. Documentation + +- [x] 4.1 Update the dashboard's key table in `docs/commands/fleet.md` so `s` and `x` carry the shown-only-where-they-drive-something wording `k` already has, and the prose around the detail view's keys agrees. Verify by reading the table against the spec's rule for each key. + +## 5. Verification + +- [x] 5.1 Run the full suite and checks: `go test ./... -cover` (total coverage stays at or above 80%), `go vet ./...`, `gofmt -l .` clean, `go build -o spinloop ./cmd/spinloop`. Verify all four pass.