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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ eos-tui
| `--ssh` | `EOS_TUI_SSH` | _(local)_ | SSH gateway / initial target |
| `--ssh-accept-new-host-keys` | `EOS_TUI_SSH_ACCEPT_NEW_HOST_KEYS` | `false` | Accept new host keys automatically |
| `--timeout` | `EOS_TUI_TIMEOUT` | `15s` | Per-request timeout |
| `--idle-timeout` | `EOS_TUI_IDLE_TIMEOUT` | `1h` | Quit after this duration without keyboard input (`0` disables) |
| `--no-alt-screen` | `EOS_TUI_NO_ALT_SCREEN` | `false` | Disable alternate screen |
| `--version` | — | — | Print version and exit |

Expand Down
33 changes: 32 additions & 1 deletion eos/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,19 @@ import (
"time"
)

func New(_ context.Context, cfg Config) (*Client, error) {
func New(ctx context.Context, cfg Config) (*Client, error) {
timeout := cfg.Timeout
if timeout <= 0 {
timeout = 15 * time.Second
}
if ctx == nil {
ctx = context.Background()
}
clientCtx, cancel := context.WithCancel(ctx)

c := &Client{
ctx: clientCtx,
cancel: cancel,
sshTarget: cfg.SSHTarget,
timeout: timeout,
acceptNewHostKeys: cfg.AcceptNewHostKeys,
Expand Down Expand Up @@ -63,9 +69,34 @@ func initSessionLog() string {
}

func (c *Client) Close() error {
if c.cancel != nil {
c.cancel()
}
return nil
}

func (c *Client) commandContext(ctx context.Context) (context.Context, context.CancelFunc) {
if ctx == nil {
ctx = context.Background()
}

timeout := c.timeout
if timeout <= 0 {
timeout = 15 * time.Second
}

cmdCtx, cancel := context.WithTimeout(ctx, timeout)
if c.ctx == nil {
return cmdCtx, cancel
}

stop := context.AfterFunc(c.ctx, cancel)
return cmdCtx, func() {
stop()
cancel()
}
}

// effectiveSSHTarget returns the host that runCommand will actually SSH to.
func (c *Client) effectiveSSHTarget() string {
if c.resolvedSSHTarget != "" {
Expand Down
32 changes: 32 additions & 0 deletions eos/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,13 @@ func (deadlineRunner) CombinedOutput(ctx context.Context, name string, args ...s
return nil, errors.New("signal: killed")
}

type contextErrRunner struct{}

func (contextErrRunner) CombinedOutput(ctx context.Context, name string, args ...string) ([]byte, error) {
<-ctx.Done()
return nil, ctx.Err()
}

func TestParseLabeledValues(t *testing.T) {
input := `
ALL Files 78 [booted] (0s)
Expand Down Expand Up @@ -776,6 +783,31 @@ func TestRunCommandContextUsesCallerCancellation(t *testing.T) {
}
}

func TestClientCloseCancelsRunningCommand(t *testing.T) {
client, err := New(context.Background(), Config{Timeout: time.Minute})
if err != nil {
t.Fatalf("New() error: %v", err)
}
client.runner = contextErrRunner{}

errCh := make(chan error, 1)
go func() {
_, err := client.runCommandContext(context.Background(), "eos", "version")
errCh <- err
}()

client.Close()

select {
case err := <-errCh:
if !errors.Is(err, context.Canceled) {
t.Fatalf("expected context canceled, got %v", err)
}
case <-time.After(time.Second):
t.Fatalf("command did not stop after client close")
}
}

func TestRunCommandContextUsesInjectedRunner(t *testing.T) {
runner := &recordingRunner{out: []byte("EOS_SERVER_VERSION=5.4.0\n")}
client := &Client{timeout: time.Minute, runner: runner}
Expand Down
6 changes: 3 additions & 3 deletions eos/ssh.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ func (c *Client) runCommandContext(ctx context.Context, args ...string) ([]byte,
if ctx == nil {
ctx = context.Background()
}
ctx, cancel := context.WithTimeout(ctx, c.timeout)
ctx, cancel := c.commandContext(ctx)
defer cancel()
runner := c.runner
if runner == nil {
Expand Down Expand Up @@ -91,7 +91,7 @@ func (c *Client) runCommandOnHost(ctx context.Context, host string, args ...stri
if ctx == nil {
ctx = context.Background()
}
ctxTimeout, cancel := context.WithTimeout(ctx, c.timeout)
ctxTimeout, cancel := c.commandContext(ctx)
defer cancel()

effective := c.effectiveSSHTarget()
Expand Down Expand Up @@ -173,7 +173,7 @@ func (c *Client) TailLogOnHost(ctx context.Context, host, filePath string, n int
tailCmd := shellJoin(tailArgs)

c.logCommand(append([]string{"→", target}, tailArgs...))
ctxTimeout, cancel := context.WithTimeout(ctx, c.timeout)
ctxTimeout, cancel := c.commandContext(ctx)
defer cancel()

var out []byte
Expand Down
3 changes: 3 additions & 0 deletions eos/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ type commandRunner interface {
}

type Client struct {
ctx context.Context
cancel context.CancelFunc

// sshTarget is the gateway/initial SSH host supplied by the user (e.g. "eospilot").
sshTarget string
// resolvedSSHTarget is set after MGM master discovery and becomes the
Expand Down
22 changes: 17 additions & 5 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@ package main

import (
"context"
"errors"
"flag"
"fmt"
"os"
"os/signal"
"strings"
"syscall"
"time"

tea "github.com/charmbracelet/bubbletea"
Expand All @@ -28,6 +31,7 @@ func main() {
versionFlag = flag.Bool("version", false, "print version and exit")
sshTarget = flag.String("ssh", envOrDefaultCompat([]string{"EOS_TUI_SSH", "EOS_TUI_SSH_TARGET"}, ""), "SSH target for running EOS CLI remotely")
timeout = flag.Duration("timeout", envDurationOrDefault("EOS_TUI_TIMEOUT", 15*time.Second), "per-request timeout")
idleTimeout = flag.Duration("idle-timeout", envDurationOrDefault("EOS_TUI_IDLE_TIMEOUT", time.Hour), "quit after this duration without keyboard input (0 disables)")
noAltScreen = flag.Bool("no-alt-screen", envBoolOrDefault("EOS_TUI_NO_ALT_SCREEN", false), "disable alternate screen mode")
acceptNewHostKeys = flag.Bool("ssh-accept-new-host-keys", envBoolOrDefault("EOS_TUI_SSH_ACCEPT_NEW_HOST_KEYS", false), "auto-accept first-seen SSH host keys using StrictHostKeyChecking=accept-new")
)
Expand All @@ -40,6 +44,8 @@ func main() {
fmt.Fprintln(flag.CommandLine.Output(), " SSH target for running EOS CLI remotely")
fmt.Fprintln(flag.CommandLine.Output(), " --timeout duration")
fmt.Fprintln(flag.CommandLine.Output(), " per-request timeout")
fmt.Fprintln(flag.CommandLine.Output(), " --idle-timeout duration")
fmt.Fprintln(flag.CommandLine.Output(), " quit after this duration without keyboard input (0 disables)")
fmt.Fprintln(flag.CommandLine.Output(), " --no-alt-screen")
fmt.Fprintln(flag.CommandLine.Output(), " disable alternate screen mode")
fmt.Fprintln(flag.CommandLine.Output(), " --ssh-accept-new-host-keys")
Expand All @@ -52,10 +58,10 @@ func main() {
return
}

ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
runCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGHUP, syscall.SIGTERM)
defer stop()

client, err := eos.New(ctx, eos.Config{
client, err := eos.New(runCtx, eos.Config{
SSHTarget: *sshTarget,
Timeout: *timeout,
AcceptNewHostKeys: *acceptNewHostKeys,
Expand All @@ -72,7 +78,7 @@ func main() {

// Discover the MGM master so subsequent EOS commands go directly to
// the command-serving leader rather than through the gateway.
discoverCtx, discoverCancel := context.WithTimeout(context.Background(), *timeout)
discoverCtx, discoverCancel := context.WithTimeout(runCtx, *timeout)
defer discoverCancel()
if resolved, err := client.DiscoverMGMMaster(discoverCtx); err == nil && resolved != "" {
displayTarget = fmt.Sprintf("ssh %s → %s", *sshTarget, resolved)
Expand All @@ -85,13 +91,19 @@ func main() {
if useAltScreen {
options = append(options, tea.WithAltScreen())
}
options = append(options, tea.WithContext(runCtx))

program := tea.NewProgram(
ui.NewModel(client, displayTarget, ""),
ui.NewModelWithOptions(client, displayTarget, "", ui.ModelOptions{
IdleTimeout: *idleTimeout,
}),
options...,
)

if _, err := program.Run(); err != nil {
if errors.Is(err, tea.ErrProgramKilled) || errors.Is(err, context.Canceled) {
return
}
fmt.Fprintf(os.Stderr, "run TUI: %v\n", err)
os.Exit(1)
}
Expand Down
22 changes: 22 additions & 0 deletions ui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package ui
import (
"fmt"
"strings"
"time"

"github.com/charmbracelet/bubbles/table"
"github.com/charmbracelet/bubbles/textinput"
Expand All @@ -12,7 +13,15 @@ import (
"github.com/lobis/eos-tui/eos"
)

type ModelOptions struct {
IdleTimeout time.Duration
}

func NewModel(client *eos.Client, endpoint, rootPath string) tea.Model {
return NewModelWithOptions(client, endpoint, rootPath, ModelOptions{})
}

func NewModelWithOptions(client *eos.Client, endpoint, rootPath string, opts ModelOptions) tea.Model {
input := textinput.New()
input.Prompt = "filter> "
input.CharLimit = 256
Expand Down Expand Up @@ -42,10 +51,13 @@ func NewModel(client *eos.Client, endpoint, rootPath string) tea.Model {
if initialPath == "" {
initialPath = "/eos"
}
now := time.Now()

return model{
client: client,
endpoint: endpoint,
idleTimeout: opts.IdleTimeout,
lastActivity: now,
width: 120,
height: 32,
activeView: activeView,
Expand Down Expand Up @@ -128,6 +140,11 @@ func (m model) toggleCommandLog() (tea.Model, tea.Cmd) {
}

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg.(type) {
case tea.KeyMsg, tea.MouseMsg:
m.lastActivity = time.Now()
}

switch msg := msg.(type) {
case tea.WindowSizeMsg:
if msg.Width > 0 {
Expand Down Expand Up @@ -769,6 +786,11 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, splashTickCmd()
}
case tickMsg:
now := time.Time(msg)
if m.idleTimeout > 0 && !m.lastActivity.IsZero() && !now.Before(m.lastActivity.Add(m.idleTimeout)) {
m.status = fmt.Sprintf("Idle for %s; exiting", m.idleTimeout)
return m, tea.Quit
}
return m, tea.Batch(tickCmd(), loadInfraCmd(m.client))
}

Expand Down
30 changes: 30 additions & 0 deletions ui/model_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,36 @@ func TestStartupSplashHidesAfterInitialDataArrives(t *testing.T) {
}
}

func TestIdleTimeoutQuitsOnTick(t *testing.T) {
m := NewModelWithOptions(nil, "local eos cli", "/", ModelOptions{
IdleTimeout: time.Minute,
}).(model)
m.lastActivity = time.Date(2026, 5, 20, 10, 0, 0, 0, time.UTC)

_, cmd := m.Update(tickMsg(m.lastActivity.Add(time.Minute)))
if cmd == nil {
t.Fatalf("expected idle timeout to quit")
}
if _, ok := cmd().(tea.QuitMsg); !ok {
t.Fatalf("expected idle timeout command to be tea.Quit")
}
}

func TestKeyInputResetsIdleTimeout(t *testing.T) {
m := NewModelWithOptions(nil, "local eos cli", "/", ModelOptions{
IdleTimeout: time.Minute,
}).(model)
oldActivity := time.Date(2000, 1, 1, 10, 0, 0, 0, time.UTC)
m.lastActivity = oldActivity

updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("x")})
m = updated.(model)

if !m.lastActivity.After(oldActivity) {
t.Fatalf("expected key input to refresh last activity")
}
}

func TestModelRendersLoadedNodeData(t *testing.T) {
m := NewModel(nil, "local eos cli", "/").(model)

Expand Down
3 changes: 3 additions & 0 deletions ui/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -741,6 +741,9 @@ type model struct {
client *eos.Client
endpoint string

idleTimeout time.Duration
lastActivity time.Time

width int
height int

Expand Down
Loading