Skip to content

Commit d80366b

Browse files
committed
Add command execution functionality to TUI
1 parent e5789d1 commit d80366b

2 files changed

Lines changed: 228 additions & 40 deletions

File tree

internal/tui/manager_test.go

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
package tui
22

33
import (
4-
"bytes"
5-
"context"
6-
"io"
7-
"testing"
8-
"time"
9-
10-
tea "github.com/charmbracelet/bubbletea"
11-
"github.com/monster0506/meshexec/internal"
12-
"github.com/monster0506/meshexec/internal/logging"
4+
"bytes"
5+
"context"
6+
"io"
7+
"testing"
8+
"time"
9+
10+
tea "github.com/charmbracelet/bubbletea"
11+
"github.com/monster0506/meshexec/internal"
12+
"github.com/monster0506/meshexec/internal/logging"
1313
)
1414

1515
func TestManager_ConstructorsAndOptions_Basics(t *testing.T) {
@@ -39,12 +39,12 @@ func TestManager_StartTUI_ImmediateCancel_Basics(t *testing.T) {
3939
ctx, cancel := context.WithCancel(context.Background())
4040
cancel()
4141
// Should return quickly; tolerate transient bubbletea error values
42-
_ = m.StartTUI(
43-
ctx,
44-
WithInitialView("peers"),
45-
// Avoid blocking on console input and disable output in CI
46-
WithProgramOptions(tea.WithInput(bytes.NewReader(nil)), tea.WithOutput(io.Discard)),
47-
)
42+
_ = m.StartTUI(
43+
ctx,
44+
WithInitialView("peers"),
45+
// Avoid blocking on console input and disable output in CI
46+
WithProgramOptions(tea.WithInput(bytes.NewReader(nil)), tea.WithOutput(io.Discard)),
47+
)
4848

4949
// Now send updates after program has exited; should be safe no-ops
5050
m.UpdateResults(&internal.ExecutionResults{CommandID: "c2"})

internal/tui/model.go

Lines changed: 213 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
package tui
22

33
import (
4+
"bufio"
5+
"context"
6+
"encoding/json"
47
"fmt"
58
"math"
9+
"net"
610
"sort"
711
"strings"
812
"time"
@@ -16,6 +20,7 @@ import (
1620
tea "github.com/charmbracelet/bubbletea"
1721
"github.com/charmbracelet/lipgloss"
1822
"github.com/monster0506/meshexec/internal"
23+
"github.com/monster0506/meshexec/internal/discovery"
1924
"github.com/monster0506/meshexec/internal/logging"
2025
)
2126

@@ -82,12 +87,155 @@ type model struct {
8287
input textinput.Model
8388
suggList list.Model
8489
cmdHistory []string
90+
targetExpr string
8591

8692
// Toasts
8793
lastToast string
8894
lastToastAt time.Time
8995
}
9096

97+
// command execution
98+
type execDoneMsg struct{ Results *internal.ExecutionResults }
99+
100+
func (m model) executeCommand(command string) tea.Cmd {
101+
m.toast("Executing: " + command)
102+
// Discover peers then send, off the UI thread
103+
return func() tea.Msg {
104+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
105+
defer cancel()
106+
discovery.SetLogger(m.logger)
107+
peers, _ := discovery.Discover(ctx, 4500*time.Millisecond)
108+
// Apply simple target filter from m.targetExpr (supports name/role/os/arch=val words)
109+
if q := strings.TrimSpace(strings.ToLower(m.targetExpr)); q != "" && q != "all" {
110+
want := map[string]string{}
111+
parts := strings.FieldsFunc(q, func(r rune) bool { return r == '&' || r == '|' || r == ' ' })
112+
for _, pr := range parts {
113+
if eq := strings.IndexByte(pr, '='); eq > 0 {
114+
k := strings.ToLower(strings.TrimSpace(pr[:eq]))
115+
v := strings.Trim(strings.TrimSpace(pr[eq+1:]), "\"")
116+
want[k] = v
117+
}
118+
}
119+
filtered := make([]internal.PeerInfo, 0, len(peers))
120+
for _, peer := range peers {
121+
ok := true
122+
for k, v := range want {
123+
switch k {
124+
case "name":
125+
ok = ok && strings.EqualFold(peer.Name, v)
126+
case "role":
127+
ok = ok && strings.EqualFold(peer.Role, v)
128+
case "os":
129+
ok = ok && strings.EqualFold(peer.OS, v)
130+
case "arch":
131+
ok = ok && strings.EqualFold(peer.Arch, v)
132+
default:
133+
if tv, exists := peer.Tags[k]; exists {
134+
ok = ok && strings.EqualFold(tv, v)
135+
} else {
136+
ok = false
137+
}
138+
}
139+
if !ok {
140+
break
141+
}
142+
}
143+
if ok {
144+
filtered = append(filtered, peer)
145+
}
146+
}
147+
peers = filtered
148+
}
149+
if len(peers) == 0 {
150+
res := &internal.ExecutionResults{
151+
CommandID: "local-" + time.Now().Format("150405"),
152+
Command: command,
153+
Target: "none",
154+
Results: []internal.ExecutionResult{},
155+
Summary: internal.ResultSummary{TotalDevices: 0},
156+
Timestamp: time.Now(),
157+
}
158+
return execDoneMsg{Results: res}
159+
}
160+
rows := make([]internal.ExecutionResult, 0, len(peers))
161+
successes, failures, timeouts := 0, 0, 0
162+
var totalDur int64
163+
for _, p := range peers {
164+
addr := p.Address
165+
if !strings.Contains(addr, ":") {
166+
addr = addr + ":9876"
167+
}
168+
start := time.Now()
169+
r, err := tuiSendCommandTCP(addr, command, 30*time.Second)
170+
durMs := int64(time.Since(start) / time.Millisecond)
171+
if err != nil {
172+
rows = append(rows, internal.ExecutionResult{Device: p.Name, ExitCode: 1, Stderr: err.Error(), Status: "failed", Duration: durMs})
173+
failures++
174+
totalDur += durMs
175+
continue
176+
}
177+
if r.Status == "timeout" {
178+
timeouts++
179+
} else if r.ExitCode == 0 {
180+
successes++
181+
} else {
182+
failures++
183+
}
184+
r.Device = p.Name
185+
if r.Duration <= 0 {
186+
r.Duration = durMs
187+
}
188+
rows = append(rows, *r)
189+
totalDur += r.Duration
190+
}
191+
avg := int64(0)
192+
if len(rows) > 0 {
193+
avg = totalDur / int64(len(rows))
194+
}
195+
res := &internal.ExecutionResults{
196+
CommandID: "local-" + time.Now().Format("150405"),
197+
Command: command,
198+
Target: "tui",
199+
Results: rows,
200+
Summary: internal.ResultSummary{TotalDevices: len(rows), Successful: successes, Failed: failures, Timeout: timeouts, AverageDuration: avg},
201+
Timestamp: time.Now(),
202+
}
203+
return execDoneMsg{Results: res}
204+
}
205+
}
206+
207+
func tuiSendCommandTCP(addr, command string, timeout time.Duration) (*internal.ExecutionResult, error) {
208+
if timeout <= 0 {
209+
timeout = 5 * time.Second
210+
}
211+
conn, err := net.DialTimeout("tcp", addr, 3*time.Second)
212+
if err != nil {
213+
return nil, err
214+
}
215+
defer func() { _ = conn.Close() }()
216+
_ = conn.SetDeadline(time.Now().Add(timeout))
217+
enc := json.NewEncoder(conn)
218+
if err := enc.Encode(map[string]string{"cmd": command}); err != nil {
219+
return nil, err
220+
}
221+
var resp struct {
222+
Ok bool `json:"ok"`
223+
Result *internal.ExecutionResult `json:"result"`
224+
}
225+
dec := json.NewDecoder(bufio.NewReader(conn))
226+
if err := dec.Decode(&resp); err != nil {
227+
return nil, err
228+
}
229+
if !resp.Ok {
230+
return nil, fmt.Errorf("remote error")
231+
}
232+
if resp.Result == nil {
233+
r := &internal.ExecutionResult{Status: "unknown"}
234+
return r, nil
235+
}
236+
return resp.Result, nil
237+
}
238+
91239
type uiStyles struct {
92240
bg lipgloss.Style
93241
container lipgloss.Style
@@ -134,6 +282,16 @@ func buildStyles(th theme) uiStyles {
134282
}
135283
}
136284

285+
func defaultSuggestionItems() []list.Item {
286+
return []list.Item{
287+
list.Item(peerItem{Address: "", Name: "uptime", Role: "cmd"}),
288+
list.Item(peerItem{Address: "", Name: "whoami", Role: "cmd"}),
289+
list.Item(peerItem{Address: "", Name: "hostname", Role: "cmd"}),
290+
list.Item(peerItem{Address: "", Name: "date", Role: "cmd"}),
291+
list.Item(peerItem{Address: "", Name: "echo hello", Role: "cmd"}),
292+
}
293+
}
294+
137295
func newModel(logger *logging.Logger, th theme, useEmoji bool) model {
138296
items := []list.Item{}
139297
// Custom delegate with professional colors
@@ -167,22 +325,15 @@ func newModel(logger *logging.Logger, th theme, useEmoji bool) model {
167325
rf.CharLimit = 80
168326
rf.Prompt = "Filter: "
169327

170-
// Suggestions list for commands
171-
suggItems := []list.Item{
172-
list.Item(peerItem{Address: "", Name: "uptime", Role: "cmd"}),
173-
list.Item(peerItem{Address: "", Name: "df -h", Role: "cmd"}),
174-
list.Item(peerItem{Address: "", Name: "whoami", Role: "cmd"}),
175-
list.Item(peerItem{Address: "", Name: "hostname", Role: "cmd"}),
176-
list.Item(peerItem{Address: "", Name: "date", Role: "cmd"}),
177-
list.Item(peerItem{Address: "", Name: "echo hello", Role: "cmd"}),
178-
}
328+
// Suggestions list for commands (defaults; history will be prepended dynamically)
329+
suggItems := defaultSuggestionItems()
179330
suggDel := list.NewDefaultDelegate()
180331
suggDel.Styles.SelectedTitle = lipgloss.NewStyle().Foreground(th.SelectionText).Background(th.SelectionBg).Bold(true)
181332
suggDel.Styles.SelectedDesc = lipgloss.NewStyle().Foreground(th.SelectionText).Background(th.SelectionBg)
182333
suggDel.Styles.NormalTitle = lipgloss.NewStyle().Foreground(th.Text)
183334
suggDel.Styles.NormalDesc = lipgloss.NewStyle().Foreground(th.Muted)
184335
sl := list.New(suggItems, suggDel, 0, 0)
185-
sl.Title = "Suggestions"
336+
sl.Title = "Suggestions (history + common)"
186337
sl.SetShowHelp(false)
187338
sl.SetShowFilter(false)
188339
sl.SetStatusBarItemName("suggestion", "suggestions")
@@ -289,6 +440,10 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
289440
case tabCommands:
290441
m.input.Focus()
291442
m.resultFilter.Blur()
443+
// capture targetExpr from peers list filter input, if any
444+
m.targetExpr = strings.TrimSpace(m.peerList.FilterValue())
445+
// refresh suggestions with history
446+
m.refreshSuggestions()
292447
case tabResults:
293448
m.resultFilter.Focus()
294449
m.input.Blur()
@@ -308,6 +463,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
308463
case tabCommands:
309464
m.input.Focus()
310465
m.resultFilter.Blur()
466+
m.targetExpr = strings.TrimSpace(m.peerList.FilterValue())
467+
m.refreshSuggestions()
311468
case tabResults:
312469
m.resultFilter.Focus()
313470
m.input.Blur()
@@ -333,6 +490,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
333490
m.tab = tabCommands
334491
m.input.Focus()
335492
m.resultFilter.Blur()
493+
m.targetExpr = strings.TrimSpace(m.peerList.FilterValue())
494+
m.refreshSuggestions()
336495
return m, nil
337496
}
338497
}
@@ -341,21 +500,15 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
341500
// Simulate execution producing results
342501
cmd := strings.TrimSpace(m.input.Value())
343502
if cmd != "" {
344-
res := &internal.ExecutionResults{
345-
CommandID: "local-" + time.Now().Format("150405"),
346-
Command: cmd,
347-
Target: "demo",
348-
Results: []internal.ExecutionResult{
349-
{ID: "x1", Device: "local", ExitCode: 0, Stdout: cmd + " - ok", Duration: 500, Status: "ok"},
350-
},
351-
Summary: internal.ResultSummary{TotalDevices: 1, Successful: 1, Failed: 0, Timeout: 0, AverageDuration: 500},
352-
Timestamp: time.Now(),
503+
// record history (dedupe recent)
504+
if len(m.cmdHistory) == 0 || m.cmdHistory[0] != cmd {
505+
m.cmdHistory = append([]string{cmd}, m.cmdHistory...)
506+
if len(m.cmdHistory) > 20 {
507+
m.cmdHistory = m.cmdHistory[:20]
508+
}
353509
}
354-
// Update results and switch to results tab
355-
m.results = res
356-
m.cmdHistory = append([]string{cmd}, m.cmdHistory...)
357-
m.toast("Executed: " + cmd)
358-
m.tab = tabResults
510+
m.refreshSuggestions()
511+
return m, m.executeCommand(cmd)
359512
}
360513
return m, nil
361514
}
@@ -386,6 +539,10 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
386539
case resultsUpdateMsg:
387540
m.results = msg.Results
388541
return m, nil
542+
case execDoneMsg:
543+
m.results = msg.Results
544+
m.tab = tabResults
545+
return m, nil
389546
case time.Time:
390547
// periodic tick to refresh animations/toasts
391548
return m, tea.Tick(time.Second, func(t time.Time) tea.Msg { return t })
@@ -400,6 +557,16 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
400557
if m.tab == tabCommands {
401558
var cmd tea.Cmd
402559
m.input, cmd = m.input.Update(msg)
560+
// allow selecting from suggestions
561+
if km, ok := msg.(tea.KeyMsg); ok {
562+
if km.String() == "tab" { // autocomplete from selected suggestion
563+
if it := m.suggList.SelectedItem(); it != nil {
564+
if pi, ok := it.(peerItem); ok {
565+
m.input.SetValue(pi.Name)
566+
}
567+
}
568+
}
569+
}
403570
return m, cmd
404571
}
405572
if m.tab == tabResults {
@@ -513,7 +680,7 @@ func (m model) renderResults() string {
513680
}
514681

515682
func (m model) renderCommands() string {
516-
hint := lipgloss.NewStyle().Foreground(m.theme.Muted).Render("Press Enter to simulate execution (demo)")
683+
hint := lipgloss.NewStyle().Foreground(m.theme.Muted).Render("Enter to run. Tab to take suggestion. Peers filtered by target: " + choose(m.targetExpr != "", m.targetExpr, "all"))
517684
return lipgloss.JoinVertical(lipgloss.Top, m.input.View(), m.suggList.View(), hint)
518685
}
519686

@@ -678,3 +845,24 @@ func (m model) currentViewName() string {
678845
return ""
679846
}
680847
}
848+
849+
func (m *model) refreshSuggestions() {
850+
// Build items: history first, then defaults (no duplicates)
851+
seen := map[string]bool{}
852+
items := make([]list.Item, 0, len(m.cmdHistory)+5)
853+
for _, h := range m.cmdHistory {
854+
if h = strings.TrimSpace(h); h != "" && !seen[h] {
855+
items = append(items, list.Item(peerItem{Address: "", Name: h, Role: "cmd"}))
856+
seen[h] = true
857+
}
858+
}
859+
for _, it := range defaultSuggestionItems() {
860+
if pi, ok := it.(peerItem); ok {
861+
if !seen[pi.Name] {
862+
items = append(items, it)
863+
seen[pi.Name] = true
864+
}
865+
}
866+
}
867+
m.suggList.SetItems(items)
868+
}

0 commit comments

Comments
 (0)