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
7 changes: 1 addition & 6 deletions cmd/spinloop/dashboard_detail.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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")
}
43 changes: 31 additions & 12 deletions cmd/spinloop/dashboard_keep_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
})
}
Expand Down
83 changes: 70 additions & 13 deletions cmd/spinloop/dashboard_model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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")
}

Expand All @@ -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
Expand Down
21 changes: 2 additions & 19 deletions cmd/spinloop/dashboard_render.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading