diff --git a/README.md b/README.md index a4d8b9d..35528d7 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/eos/client.go b/eos/client.go index 912d6c2..a3fdf29 100644 --- a/eos/client.go +++ b/eos/client.go @@ -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, @@ -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 != "" { diff --git a/eos/client_test.go b/eos/client_test.go index 7afa777..8bcb918 100644 --- a/eos/client_test.go +++ b/eos/client_test.go @@ -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) @@ -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} diff --git a/eos/ssh.go b/eos/ssh.go index 68fdcb3..0951e1e 100644 --- a/eos/ssh.go +++ b/eos/ssh.go @@ -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 { @@ -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() @@ -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 diff --git a/eos/types.go b/eos/types.go index 3e81579..61d0dc1 100644 --- a/eos/types.go +++ b/eos/types.go @@ -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 diff --git a/main.go b/main.go index e4dec5d..f93010c 100644 --- a/main.go +++ b/main.go @@ -2,10 +2,13 @@ package main import ( "context" + "errors" "flag" "fmt" "os" + "os/signal" "strings" + "syscall" "time" tea "github.com/charmbracelet/bubbletea" @@ -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") ) @@ -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") @@ -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, @@ -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) @@ -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) } diff --git a/ui/model.go b/ui/model.go index 9da034f..b87099e 100644 --- a/ui/model.go +++ b/ui/model.go @@ -3,6 +3,7 @@ package ui import ( "fmt" "strings" + "time" "github.com/charmbracelet/bubbles/table" "github.com/charmbracelet/bubbles/textinput" @@ -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 @@ -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, @@ -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 { @@ -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)) } diff --git a/ui/model_test.go b/ui/model_test.go index 37aefbf..94e93fb 100644 --- a/ui/model_test.go +++ b/ui/model_test.go @@ -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) diff --git a/ui/types.go b/ui/types.go index 5fce8ba..ca60d57 100644 --- a/ui/types.go +++ b/ui/types.go @@ -741,6 +741,9 @@ type model struct { client *eos.Client endpoint string + idleTimeout time.Duration + lastActivity time.Time + width int height int