diff --git a/go.mod b/go.mod index edcfaee..838d749 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.24.9 require ( github.com/AlecAivazis/survey/v2 v2.3.7 - github.com/BurntSushi/toml v1.5.0 + github.com/BurntSushi/toml v1.6.0 github.com/briandowns/spinner v1.23.2 github.com/charmbracelet/bubbles v0.21.0 github.com/charmbracelet/bubbletea v1.3.10 @@ -16,14 +16,14 @@ require ( github.com/logrusorgru/aurora v2.0.3+incompatible github.com/mitchellh/go-homedir v1.1.0 github.com/sirupsen/logrus v1.9.3 - github.com/spf13/cobra v1.10.1 + github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.11.1 github.com/tidwall/pretty v1.2.1 github.com/x-cray/logrus-prefixed-formatter v0.5.2 - golang.org/x/sys v0.38.0 - golang.org/x/term v0.37.0 + golang.org/x/sys v0.40.0 + golang.org/x/term v0.38.0 ) require ( diff --git a/go.sum b/go.sum index 73ca8c7..d39f347 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ= github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo= -github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= -github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s= github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= @@ -135,8 +135,8 @@ github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= -github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= -github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= @@ -193,12 +193,12 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= -golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= -golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= +golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= +golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= diff --git a/pkg/cmd/listen.go b/pkg/cmd/listen.go index 0c54ecb..25e925d 100644 --- a/pkg/cmd/listen.go +++ b/pkg/cmd/listen.go @@ -153,7 +153,7 @@ Destination CLI path will be "/". To set the CLI path, use the "--path" flag.`, lc.cmd.Flags().StringVar(&lc.path, "path", "", "Sets the path to which events are forwarded e.g., /webhooks or /api/stripe") lc.cmd.Flags().IntVar(&lc.maxConnections, "max-connections", 50, "Maximum concurrent connections to local endpoint (default: 50, increase for high-volume testing)") - lc.cmd.Flags().StringVar(&lc.output, "output", "interactive", "Output mode: interactive (full UI), compact (simple logs), quiet (only fatal errors)") + lc.cmd.Flags().StringVar(&lc.output, "output", "interactive", "Output mode: interactive (full UI), compact (simple logs), quiet (errors and warnings only)") lc.cmd.Flags().StringVar(&lc.filterBody, "filter-body", "", "Filter events by request body using Hookdeck filter syntax (JSON)") lc.cmd.Flags().StringVar(&lc.filterHeaders, "filter-headers", "", "Filter events by request headers using Hookdeck filter syntax (JSON)") diff --git a/pkg/listen/healthcheck.go b/pkg/listen/healthcheck.go new file mode 100644 index 0000000..567d4f3 --- /dev/null +++ b/pkg/listen/healthcheck.go @@ -0,0 +1,29 @@ +package listen + +import ( + "net/url" + "time" + + "github.com/hookdeck/hookdeck-cli/pkg/listen/healthcheck" +) + +// Re-export types and constants from healthcheck subpackage for convenience +type ServerHealthStatus = healthcheck.ServerHealthStatus +type HealthCheckResult = healthcheck.HealthCheckResult + +const ( + HealthHealthy = healthcheck.HealthHealthy + HealthUnreachable = healthcheck.HealthUnreachable +) + +// CheckServerHealth performs a TCP connection check to the target URL +// This is a wrapper around the healthcheck package function for backward compatibility +func CheckServerHealth(targetURL *url.URL, timeout time.Duration) HealthCheckResult { + return healthcheck.CheckServerHealth(targetURL, timeout) +} + +// FormatHealthMessage creates a user-friendly health status message +// This is a wrapper around the healthcheck package function for backward compatibility +func FormatHealthMessage(result HealthCheckResult, targetURL *url.URL) string { + return healthcheck.FormatHealthMessage(result, targetURL) +} diff --git a/pkg/listen/healthcheck/healthcheck.go b/pkg/listen/healthcheck/healthcheck.go new file mode 100644 index 0000000..9ac5c48 --- /dev/null +++ b/pkg/listen/healthcheck/healthcheck.go @@ -0,0 +1,88 @@ +package healthcheck + +import ( + "fmt" + "net" + "net/url" + "os" + "time" + + "github.com/hookdeck/hookdeck-cli/pkg/ansi" +) + +// ServerHealthStatus represents the health status of the target server +type ServerHealthStatus int + +const ( + HealthHealthy ServerHealthStatus = iota // TCP connection successful + HealthUnreachable // Connection refused or timeout +) + +// HealthCheckResult contains the result of a health check +type HealthCheckResult struct { + Status ServerHealthStatus + Healthy bool + Error error + Timestamp time.Time + Duration time.Duration +} + +// CheckServerHealth performs a TCP connection check to verify a server is listening. +// The timeout parameter should be appropriate for the deployment context: +// - Local development: 3s is typically sufficient +// - Production/edge: May require longer timeouts due to network conditions +func CheckServerHealth(targetURL *url.URL, timeout time.Duration) HealthCheckResult { + start := time.Now() + + host := targetURL.Hostname() + port := targetURL.Port() + + // Default ports if not specified + if port == "" { + if targetURL.Scheme == "https" { + port = "443" + } else { + port = "80" + } + } + + address := net.JoinHostPort(host, port) + + conn, err := net.DialTimeout("tcp", address, timeout) + duration := time.Since(start) + + result := HealthCheckResult{ + Timestamp: start, + Duration: duration, + } + + if err != nil { + result.Healthy = false + result.Error = err + result.Status = HealthUnreachable + return result + } + + // Successfully connected - server is healthy + conn.Close() + result.Healthy = true + result.Status = HealthHealthy + return result +} + +// FormatHealthMessage creates a user-friendly health status message +func FormatHealthMessage(result HealthCheckResult, targetURL *url.URL) string { + if result.Healthy { + return fmt.Sprintf("→ Local server is reachable at %s", targetURL.String()) + } + + color := ansi.Color(os.Stdout) + errorMessage := "unknown error" + if result.Error != nil { + errorMessage = result.Error.Error() + } + return fmt.Sprintf("%s Cannot connect to local server at %s\n %s\n The server may not be running. Events will fail until the server starts.", + color.Yellow("● Warning:"), + targetURL.String(), + errorMessage) +} diff --git a/pkg/listen/healthcheck/healthcheck_test.go b/pkg/listen/healthcheck/healthcheck_test.go new file mode 100644 index 0000000..a2e8c92 --- /dev/null +++ b/pkg/listen/healthcheck/healthcheck_test.go @@ -0,0 +1,199 @@ +package healthcheck + +import ( + "fmt" + "net" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" +) + +func TestCheckServerHealth_HealthyServer(t *testing.T) { + // Start a test HTTP server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + // Parse server URL + serverURL, err := url.Parse(server.URL) + if err != nil { + t.Fatalf("Failed to parse server URL: %v", err) + } + + // Perform health check + result := CheckServerHealth(serverURL, 3*time.Second) + + // Verify result + if !result.Healthy { + t.Errorf("Expected server to be healthy, got unhealthy") + } + if result.Status != HealthHealthy { + t.Errorf("Expected status HealthHealthy, got %v", result.Status) + } + if result.Error != nil { + t.Errorf("Expected no error, got: %v", result.Error) + } + if result.Duration <= 0 { + t.Errorf("Expected positive duration, got: %v", result.Duration) + } +} + +func TestCheckServerHealth_UnreachableServer(t *testing.T) { + // Use a URL that should not be listening + targetURL, err := url.Parse("http://localhost:59999") + if err != nil { + t.Fatalf("Failed to parse URL: %v", err) + } + + // Perform health check + result := CheckServerHealth(targetURL, 1*time.Second) + + // Verify result + if result.Healthy { + t.Errorf("Expected server to be unhealthy, got healthy") + } + if result.Status != HealthUnreachable { + t.Errorf("Expected status HealthUnreachable, got %v", result.Status) + } + if result.Error == nil { + t.Errorf("Expected error, got nil") + } +} + +func TestCheckServerHealth_DefaultPorts(t *testing.T) { + testCases := []struct { + name string + urlString string + expectedPort string + }{ + { + name: "HTTP default port", + urlString: "http://localhost", + expectedPort: "80", + }, + { + name: "HTTPS default port", + urlString: "https://localhost", + expectedPort: "443", + }, + { + name: "Explicit port", + urlString: "http://localhost:8080", + expectedPort: "8080", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + targetURL, err := url.Parse(tc.urlString) + if err != nil { + t.Fatalf("Failed to parse URL: %v", err) + } + + // Start a listener on the expected port to verify we're checking the right one + listener, err := net.Listen("tcp", "localhost:"+tc.expectedPort) + if err != nil { + t.Skipf("Cannot bind to port %s: %v", tc.expectedPort, err) + } + defer listener.Close() + + // Perform health check + result := CheckServerHealth(targetURL, 1*time.Second) + + // Should be healthy since we have a listener + if !result.Healthy { + t.Errorf("Expected server to be healthy on port %s, got unhealthy: %v", tc.expectedPort, result.Error) + } + }) + } +} + +func TestFormatHealthMessage_Healthy(t *testing.T) { + targetURL, _ := url.Parse("http://localhost:3000") + result := HealthCheckResult{ + Status: HealthHealthy, + Healthy: true, + } + + msg := FormatHealthMessage(result, targetURL) + + if len(msg) == 0 { + t.Errorf("Expected non-empty message") + } + if !strings.Contains(msg, "→") { + t.Errorf("Expected message to contain →") + } + if !strings.Contains(msg, "Local server is reachable") { + t.Errorf("Expected message to contain 'Local server is reachable'") + } +} + +func TestFormatHealthMessage_Unhealthy(t *testing.T) { + targetURL, _ := url.Parse("http://localhost:3000") + result := HealthCheckResult{ + Status: HealthUnreachable, + Healthy: false, + Error: net.ErrClosed, + } + + msg := FormatHealthMessage(result, targetURL) + + if len(msg) == 0 { + t.Errorf("Expected non-empty message") + } + // Should contain warning indicator + if !strings.Contains(msg, "●") { + t.Errorf("Expected message to contain ●") + } + if !strings.Contains(msg, "Warning") { + t.Errorf("Expected message to contain 'Warning'") + } +} + +func TestFormatHealthMessage_NilError(t *testing.T) { + targetURL, _ := url.Parse("http://localhost:3000") + result := HealthCheckResult{ + Status: HealthUnreachable, + Healthy: false, + Error: nil, // Nil error should not cause panic + } + + msg := FormatHealthMessage(result, targetURL) + + if len(msg) == 0 { + t.Errorf("Expected non-empty message") + } + if !strings.Contains(msg, "unknown error") { + t.Errorf("Expected message to contain 'unknown error' when error is nil") + } +} + +func TestCheckServerHealth_PortInURL(t *testing.T) { + // Create a server on a non-standard port + listener, err := net.Listen("tcp", "localhost:0") + if err != nil { + t.Fatalf("Failed to create listener: %v", err) + } + defer listener.Close() + + // Get the actual port assigned by the OS + addr := listener.Addr().(*net.TCPAddr) + targetURL, _ := url.Parse(fmt.Sprintf("http://localhost:%d/path", addr.Port)) + + // Perform health check + result := CheckServerHealth(targetURL, 3*time.Second) + + // Verify that the health check succeeded + // This confirms that when a port is already in the URL, we don't append + // a default port (which would cause localhost:8080 to become localhost:8080:80) + if !result.Healthy { + t.Errorf("Expected healthy=true for server with port in URL, got false: %v", result.Error) + } + if result.Error != nil { + t.Errorf("Expected no error for server with port in URL, got: %v", result.Error) + } +} diff --git a/pkg/listen/listen.go b/pkg/listen/listen.go index e03b48f..bf4bd65 100644 --- a/pkg/listen/listen.go +++ b/pkg/listen/listen.go @@ -23,6 +23,7 @@ import ( "os" "regexp" "strings" + "time" "github.com/hookdeck/hookdeck-cli/pkg/config" "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" @@ -122,6 +123,31 @@ Specify a single destination to update the path. For example, pass a connection return err } + // Perform initial health check on target server + // Using 3-second timeout optimized for local development scenarios. + // This assumes low latency to localhost. For production/edge deployments, + // this timeout may need to be configurable in future iterations. + healthCheckTimeout := 3 * time.Second + healthResult := CheckServerHealth(URL, healthCheckTimeout) + + // For all output modes, warn if server isn't reachable + if !healthResult.Healthy { + warningMsg := FormatHealthMessage(healthResult, URL) + + if flags.Output == "interactive" { + // Interactive mode will show warning before TUI starts + fmt.Println() + fmt.Println(warningMsg) + fmt.Println() + time.Sleep(500 * time.Millisecond) // Give user time to see warning before TUI starts + } else { + // Compact/quiet modes: print warning before connection info + fmt.Println() + fmt.Println(warningMsg) + fmt.Println() + } + } + // Start proxy // For non-interactive modes, print connection info before starting if flags.Output == "compact" || flags.Output == "quiet" { diff --git a/pkg/listen/proxy/proxy.go b/pkg/listen/proxy/proxy.go index 575acb4..5437fae 100644 --- a/pkg/listen/proxy/proxy.go +++ b/pkg/listen/proxy/proxy.go @@ -21,11 +21,15 @@ import ( log "github.com/sirupsen/logrus" "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" + "github.com/hookdeck/hookdeck-cli/pkg/listen/healthcheck" "github.com/hookdeck/hookdeck-cli/pkg/websocket" hookdecksdk "github.com/hookdeck/hookdeck-go-sdk" ) -const timeLayout = "2006-01-02 15:04:05" +const ( + healthyCheckInterval = 15 * time.Second // Check every 15s when server is healthy + unhealthyCheckInterval = 5 * time.Second // Check every 5s when server is unhealthy +) // Config provides the configuration of a Proxy type Config struct { @@ -71,6 +75,9 @@ type Proxy struct { activeRequests int32 maxConnWarned bool // Track if we've warned about connection limit renderer Renderer + + // Server health monitoring + serverHealthy atomic.Bool } func withSIGTERMCancel(ctx context.Context, onCancel func()) context.Context { @@ -150,7 +157,22 @@ func (p *Proxy) Run(parentCtx context.Context) error { go func() { <-p.webSocketClient.Connected() p.renderer.OnConnected() - hasConnectedOnce = true + + // Only start health monitoring on first successful connection to prevent + // goroutine leaks on reconnects. The hasConnectedOnce guard ensures that + // even if the websocket reconnects multiple times (which happens in the + // Run() loop), we only spawn the health monitor goroutine once. + if !hasConnectedOnce { + hasConnectedOnce = true + + // Perform initial health check and notify renderer immediately + healthy, err := checkServerHealth(p.cfg.URL, 3*time.Second) + p.serverHealthy.Store(healthy) + p.renderer.OnServerHealthChanged(healthy, err) + + // Start health check monitor after initial check + go p.startHealthCheckMonitor(signalCtx, p.cfg.URL) + } }() // Run the websocket in the background @@ -436,6 +458,50 @@ func (p *Proxy) processEndpointResponse(eventID string, webhookEvent *websocket. } } +// checkServerHealth is a simple wrapper around the healthcheck package's CheckServerHealth +func checkServerHealth(targetURL *url.URL, timeout time.Duration) (bool, error) { + result := healthcheck.CheckServerHealth(targetURL, timeout) + return result.Healthy, result.Error +} + +// startHealthCheckMonitor runs periodic health checks in the background +func (p *Proxy) startHealthCheckMonitor(ctx context.Context, targetURL *url.URL) { + // Determine initial interval based on current server health state + initialInterval := healthyCheckInterval + if !p.serverHealthy.Load() { + // Server is unhealthy, check more frequently + initialInterval = unhealthyCheckInterval + } + + ticker := time.NewTicker(initialInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + // Perform health check + healthy, err := checkServerHealth(targetURL, 3*time.Second) + + // Only notify on state changes, atomically + prevHealthy := p.serverHealthy.Swap(healthy) + if healthy != prevHealthy { + p.renderer.OnServerHealthChanged(healthy, err) + + // Adjust check interval based on health status + if healthy { + // Server is healthy, check less frequently + ticker.Reset(healthyCheckInterval) + } else { + // Server is unhealthy, check more frequently to detect recovery + ticker.Reset(unhealthyCheckInterval) + } + } + } + } +} + // // Public functions // diff --git a/pkg/listen/proxy/renderer.go b/pkg/listen/proxy/renderer.go index 1dc3d20..cf46420 100644 --- a/pkg/listen/proxy/renderer.go +++ b/pkg/listen/proxy/renderer.go @@ -26,6 +26,9 @@ type Renderer interface { // Connection warnings OnConnectionWarning(activeRequests int32, maxConns int) + // Server health monitoring + OnServerHealthChanged(healthy bool, err error) + // Cleanup is called before exit to clean up resources (e.g., stop TUI, stop spinner) Cleanup() diff --git a/pkg/listen/proxy/renderer_interactive.go b/pkg/listen/proxy/renderer_interactive.go index 12e3207..3bac132 100644 --- a/pkg/listen/proxy/renderer_interactive.go +++ b/pkg/listen/proxy/renderer_interactive.go @@ -210,6 +210,16 @@ func (r *InteractiveRenderer) OnConnectionWarning(activeRequests int32, maxConns }).Warn("High connection load detected; consider increasing --max-connections") } +// OnServerHealthChanged is called when server health status changes +func (r *InteractiveRenderer) OnServerHealthChanged(healthy bool, err error) { + if r.teaProgram != nil { + r.teaProgram.Send(tui.ServerHealthMsg{ + Healthy: healthy, + Error: err, + }) + } +} + // Cleanup gracefully stops the TUI and restores terminal func (r *InteractiveRenderer) Cleanup() { if r.teaProgram != nil { diff --git a/pkg/listen/proxy/renderer_simple.go b/pkg/listen/proxy/renderer_simple.go index 15e4e3d..5faaa8d 100644 --- a/pkg/listen/proxy/renderer_simple.go +++ b/pkg/listen/proxy/renderer_simple.go @@ -16,12 +16,14 @@ const simpleTimeLayout = "2006-01-02 15:04:05" // SimpleRenderer renders events to stdout for compact and quiet modes type SimpleRenderer struct { - cfg *RendererConfig - quietMode bool - doneCh chan struct{} - spinner *spinner.Spinner - hasConnected bool // Track if we've successfully connected at least once - isReconnecting bool // Track if we're currently in reconnection mode + cfg *RendererConfig + quietMode bool + doneCh chan struct{} + spinner *spinner.Spinner + hasConnected bool // Track if we've successfully connected at least once + isReconnecting bool // Track if we're currently in reconnection mode + serverHealthKnown bool // Track if we've received a health status + lastServerHealthy bool // Track the last known server health state } // NewSimpleRenderer creates a new simple renderer @@ -65,7 +67,11 @@ func (r *SimpleRenderer) OnConnected() { fmt.Println() } - fmt.Printf("%s\n\n", color.Faint("Connected. Waiting for events...")) + if r.quietMode { + fmt.Printf("%s\n\n", color.Faint("Connected. Quiet mode: only errors and warnings will be shown.")) + } else { + fmt.Printf("%s\n\n", color.Faint("Connected. Waiting for events...")) + } } } @@ -155,12 +161,43 @@ func (r *SimpleRenderer) OnEventError(eventID string, attempt *websocket.Attempt func (r *SimpleRenderer) OnConnectionWarning(activeRequests int32, maxConns int) { color := ansi.Color(os.Stdout) fmt.Printf("\n%s High connection load detected (%d active requests)\n", - color.Yellow("⚠ WARNING:"), activeRequests) + color.Yellow("● WARNING:"), activeRequests) fmt.Printf(" The CLI is limited to %d concurrent connections per host.\n", maxConns) fmt.Printf(" Consider reducing request rate or increasing connection limit.\n") fmt.Printf(" Run with --max-connections=%d to increase the limit.\n\n", maxConns*2) } +// OnServerHealthChanged is called when server health status changes +func (r *SimpleRenderer) OnServerHealthChanged(healthy bool, err error) { + // Skip if this is the first health check (initial state, not a change) + if !r.serverHealthKnown { + r.serverHealthKnown = true + r.lastServerHealthy = healthy + return + } + + // Only show messages on actual state transitions + if healthy == r.lastServerHealthy { + return // No state change + } + + color := ansi.Color(os.Stdout) + targetURL := r.cfg.TargetURL.Scheme + "://" + r.cfg.TargetURL.Host + + if !healthy { + // Server became unreachable - show warning + fmt.Printf("\n%s %s is unreachable\n", + color.Yellow("● Warning:"), targetURL) + } else { + // Server recovered - show brief success message + fmt.Printf("%s %s is reachable\n", + color.Green("→"), targetURL) + } + + // Update last known state + r.lastServerHealthy = healthy +} + // Cleanup stops the spinner and cleans up resources func (r *SimpleRenderer) Cleanup() { if r.spinner != nil { diff --git a/pkg/listen/tui/model.go b/pkg/listen/tui/model.go index 11f69e4..6073b68 100644 --- a/pkg/listen/tui/model.go +++ b/pkg/listen/tui/model.go @@ -67,6 +67,11 @@ type Model struct { // Header state headerCollapsed bool // Track if connection header is collapsed + + // Server health state + serverHealthy bool + serverHealthError error + serverHealthChecked bool } // Config holds configuration for the TUI @@ -420,3 +425,9 @@ func tickWaitingAnimation() tea.Cmd { return TickWaitingMsg{} }) } + +// ServerHealthMsg is sent when server health status changes +type ServerHealthMsg struct { + Healthy bool + Error error +} diff --git a/pkg/listen/tui/update.go b/pkg/listen/tui/update.go index efe1466..af440fc 100644 --- a/pkg/listen/tui/update.go +++ b/pkg/listen/tui/update.go @@ -57,6 +57,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.isConnected = false return m, nil + case ServerHealthMsg: + m.serverHealthy = msg.Healthy + m.serverHealthError = msg.Error + m.serverHealthChecked = true + return m, nil + case TickWaitingMsg: // Toggle waiting animation if !m.hasReceivedEvent { diff --git a/pkg/listen/tui/view.go b/pkg/listen/tui/view.go index c3b2ee6..63bf589 100644 --- a/pkg/listen/tui/view.go +++ b/pkg/listen/tui/view.go @@ -66,10 +66,12 @@ func (m Model) View() string { // We need: header lines + viewport lines + divider (1) + status (1) = m.height var viewportHeight int - if m.hasReceivedEvent { + if m.isConnected { + // When connected, always show status bar (for server health indicator) // Total lines: header + viewport + divider + status viewportHeight = m.height - headerHeight - 2 } else { + // When not connected, no status bar // Total lines: header + viewport viewportHeight = m.height - headerHeight } @@ -91,7 +93,8 @@ func (m Model) View() string { viewportOutput := m.viewport.View() output += viewportOutput - if m.hasReceivedEvent { + if m.isConnected { + // When connected, always show status bar (includes server health indicator) // Ensure we have a newline before divider if viewport doesn't end with one if !strings.HasSuffix(viewportOutput, "\n") { output += "\n" @@ -184,17 +187,20 @@ func (m Model) renderDetailsView() string { // renderStatusBar renders the bottom status bar with keyboard shortcuts func (m Model) renderStatusBar() string { + // If no events yet, just show quit instruction selectedEvent := m.GetSelectedEvent() if selectedEvent == nil { - return "" + return statusBarStyle.Width(m.width).Render("[q] Quit") } // Determine width-based verbosity - isNarrow := m.width < 100 + // Threshold chosen to show full text only when it fits without wrapping + // Full text requires ~105 chars with some padding + isNarrow := m.width < 108 isVeryNarrow := m.width < 60 - // Build status message - var statusMsg string + // Build event status message + var eventStatusMsg string eventType := "Last event" if m.userNavigated { eventType = "Selected event" @@ -204,12 +210,12 @@ func (m Model) renderStatusBar() string { // Success status checkmark := greenStyle.Render("✓") if isVeryNarrow { - statusMsg = fmt.Sprintf("> %s %s [%d]", checkmark, eventType, selectedEvent.Status) + eventStatusMsg = fmt.Sprintf("> %s %s [%d]", checkmark, eventType, selectedEvent.Status) } else if isNarrow { - statusMsg = fmt.Sprintf("> %s %s succeeded [%d] | [r] [o] [d] [q]", + eventStatusMsg = fmt.Sprintf("> %s %s succeeded [%d] | [r] [o] [d] [q]", checkmark, eventType, selectedEvent.Status) } else { - statusMsg = fmt.Sprintf("> %s %s succeeded with status %d | [r] Retry • [o] Open in dashboard • [d] Show data", + eventStatusMsg = fmt.Sprintf("> %s %s succeeded with status %d | [r] Retry • [o] Open in dashboard • [d] Show data • [q] Quit", checkmark, eventType, selectedEvent.Status) } } else { @@ -224,25 +230,25 @@ func (m Model) renderStatusBar() string { if isVeryNarrow { if selectedEvent.Status == 0 { - statusMsg = fmt.Sprintf("> %s %s [ERR]", xmark, eventType) + eventStatusMsg = fmt.Sprintf("> %s %s [ERR]", xmark, eventType) } else { - statusMsg = fmt.Sprintf("> %s %s [%d]", xmark, eventType, selectedEvent.Status) + eventStatusMsg = fmt.Sprintf("> %s %s [%d]", xmark, eventType, selectedEvent.Status) } } else if isNarrow { if selectedEvent.Status == 0 { - statusMsg = fmt.Sprintf("> %s %s failed | [r] [o] [d] [q]", + eventStatusMsg = fmt.Sprintf("> %s %s failed | [r] [o] [d] [q]", xmark, eventType) } else { - statusMsg = fmt.Sprintf("> %s %s failed [%d] | [r] [o] [d] [q]", + eventStatusMsg = fmt.Sprintf("> %s %s failed [%d] | [r] [o] [d] [q]", xmark, eventType, selectedEvent.Status) } } else { - statusMsg = fmt.Sprintf("> %s %s %s | [r] Retry • [o] Open in dashboard • [d] Show event data", + eventStatusMsg = fmt.Sprintf("> %s %s %s | [r] Retry • [o] Open in dashboard • [d] Show event data • [q] Quit", xmark, eventType, statusText) } } - return statusBarStyle.Render(statusMsg) + return statusBarStyle.Width(m.width).Render(eventStatusMsg) } // FormatEventLog formats an event into a log line matching the current style @@ -396,6 +402,15 @@ func (m Model) renderConnectionInfo() string { } } + // Show server health warning if unhealthy + if m.serverHealthChecked && !m.serverHealthy { + s.WriteString("\n") + targetURL := m.cfg.TargetURL.Scheme + "://" + m.cfg.TargetURL.Host + warningMsg := fmt.Sprintf("%s %s is unreachable. Check the server is running", yellowStyle.Render("● Warning:"), targetURL) + s.WriteString(warningMsg) + s.WriteString("\n") + } + // Show filters if any are active if m.cfg.Filters != nil { // Type assert to SessionFilters and display each filter @@ -491,6 +506,15 @@ func (m Model) renderCompactHeader() string { s.WriteString(faintStyle.Render(summary)) s.WriteString("\n") + // Show server health warning if unhealthy (ensure it's always visible even when collapsed) + if m.serverHealthChecked && !m.serverHealthy { + s.WriteString("\n") + targetURL := m.cfg.TargetURL.Scheme + "://" + m.cfg.TargetURL.Host + warningMsg := fmt.Sprintf("%s %s is unreachable. Check the server is running", yellowStyle.Render("● Warning:"), targetURL) + s.WriteString(warningMsg) + s.WriteString("\n") + } + return s.String() }