From 3f5c1d3a5ff56be5f154aed2f5962fb761eac691 Mon Sep 17 00:00:00 2001 From: Lukasz Raczylo Date: Tue, 25 Nov 2025 19:00:44 +0000 Subject: [PATCH] improvements nov2025 (#10) * Add benchmark and httplog modules, update UI for modals artefacts --- README.md | 76 ++++- cmd/kportal/main.go | 190 +++++++++++-- docs/index.html | 83 +++++- internal/benchmark/results.go | 130 +++++++++ internal/benchmark/runner.go | 230 +++++++++++++++ internal/benchmark/runner_test.go | 282 +++++++++++++++++++ internal/config/config.go | 58 +++- internal/config/config_test.go | 84 ++++++ internal/forward/manager.go | 8 + internal/forward/worker.go | 69 ++++- internal/httplog/logger.go | 112 ++++++++ internal/httplog/proxy.go | 262 ++++++++++++++++++ internal/httplog/proxy_test.go | 181 ++++++++++++ internal/ui/bubbletea_ui.go | 227 ++++++++------- internal/ui/wizard_commands.go | 75 +++++ internal/ui/wizard_handlers.go | 446 ++++++++++++++++++++++++++++++ internal/ui/wizard_state.go | 178 ++++++++++++ internal/ui/wizard_styles.go | 63 ++--- internal/ui/wizard_views.go | 408 ++++++++++++++++++++++++++- 19 files changed, 2976 insertions(+), 186 deletions(-) create mode 100644 internal/benchmark/results.go create mode 100644 internal/benchmark/runner.go create mode 100644 internal/benchmark/runner_test.go create mode 100644 internal/httplog/logger.go create mode 100644 internal/httplog/proxy.go create mode 100644 internal/httplog/proxy_test.go diff --git a/README.md b/README.md index 5adc5e5..ba0ce74 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,9 @@ kportal manages multiple Kubernetes port-forwards with an interactive terminal i - **Label selectors** - Dynamic pod targeting using label selectors - **Port conflict detection** - Validates port availability with PID information - **mDNS hostnames** - Access forwards via `.local` hostnames +- **HTTP traffic logging** - Real-time HTTP request/response logging for debugging +- **Connection benchmarking** - Built-in HTTP benchmarking with latency statistics +- **Headless mode** - Background operation for scripting and automation ## 📦 Installation @@ -71,10 +74,12 @@ contexts: localPort: 5432 alias: prod-db - - resource: service/redis + - resource: service/api protocol: tcp - port: 6379 - localPort: 6379 + port: 8080 + localPort: 8080 + alias: api + httpLog: true # Enable HTTP traffic logging ``` Run: @@ -89,9 +94,11 @@ kportal |-----|--------| | `↑↓` / `j/k` | Navigate | | `Space` / `Enter` | Toggle forward | -| `a` | Add forward | +| `n` | Add new forward | | `e` | Edit forward | | `d` | Delete forward | +| `b` | Benchmark connection | +| `l` | View HTTP logs | | `q` | Quit | ## 📖 Configuration @@ -110,6 +117,7 @@ contexts: localPort: alias: # optional selector: # optional + httpLog: true # optional - enable HTTP logging ``` ### Forward Options @@ -122,6 +130,7 @@ contexts: | `localPort` | Yes | Local port | | `alias` | No | Display name and mDNS hostname | | `selector` | No | Label selector for pod resolution | +| `httpLog` | No | Enable HTTP traffic logging (`true`/`false`) | ### Resource Formats @@ -198,6 +207,20 @@ kportal kportal -v ``` +### Headless Mode + +Run without TUI for scripting and automation: + +```bash +kportal -headless +``` + +Combines well with verbose mode for background operation: + +```bash +kportal -headless -v & +``` + ### Validate Configuration ```bash @@ -222,6 +245,51 @@ kportal -c /path/to/config.yaml ## Advanced Features +### HTTP Traffic Logging + +Press `l` in the TUI to view real-time HTTP traffic for a selected forward. The log viewer shows: + +| Column | Description | +|--------|-------------| +| TIME | Request timestamp | +| METHOD | HTTP method (GET, POST, etc.) | +| STATUS | Response status code | +| LATENCY | Request duration | +| PATH | Request path | + +**Keyboard shortcuts:** + +| Key | Action | +|-----|--------| +| `↑/↓` | Navigate entries | +| `g/G` | Jump to top/bottom | +| `a` | Toggle auto-scroll | +| `f` | Cycle filter mode (All → Non-2xx → Errors) | +| `/` | Search by path or method | +| `c` | Clear all filters | +| `q` | Close log viewer | + +**Filter modes:** +- **All** - Show all entries +- **Non-2xx** - Hide successful (2xx) responses +- **Errors** - Show only 4xx and 5xx responses + +### Connection Benchmarking + +Press `b` in the TUI to benchmark a selected forward. Configure: + +- **URL Path** - Target endpoint (default: `/`) +- **Method** - HTTP method (GET, POST, etc.) +- **Concurrency** - Number of parallel workers +- **Requests** - Total number of requests + +Results include: +- Success/failure counts +- Min/Max/Avg latency +- P50/P95/P99 percentiles +- Throughput (requests/sec) +- Status code distribution + ### Hot-Reload Configuration changes are applied automatically. Manual reload: diff --git a/cmd/kportal/main.go b/cmd/kportal/main.go index bfc1faa..e02e9a2 100644 --- a/cmd/kportal/main.go +++ b/cmd/kportal/main.go @@ -17,6 +17,7 @@ import ( "github.com/nvm/kportal/internal/config" "github.com/nvm/kportal/internal/converter" "github.com/nvm/kportal/internal/forward" + "github.com/nvm/kportal/internal/httplog" "github.com/nvm/kportal/internal/k8s" "github.com/nvm/kportal/internal/logger" "github.com/nvm/kportal/internal/mdns" @@ -38,6 +39,7 @@ const ( var ( configFile = flag.String("c", defaultConfigFile, "Path to configuration file") verbose = flag.Bool("v", false, "Enable verbose logging") + headless = flag.Bool("headless", false, "Run in headless mode (no UI, for background/daemon use)") logFormat = flag.String("log-format", "text", "Log format: text or json") check = flag.Bool("check", false, "Validate configuration and exit") showVersion = flag.Bool("version", false, "Show version and exit") @@ -91,7 +93,7 @@ func main() { logOutput = os.Stderr } else { logLevel = logger.LevelInfo - logOutput = io.Discard // Silence logger in non-verbose mode to prevent UI corruption + logOutput = io.Discard // Silence logger in non-verbose/headless mode to prevent UI corruption } switch *logFormat { @@ -218,37 +220,20 @@ func main() { log.Printf("mDNS hostname publishing enabled - aliases will be accessible via .local") } - // Create UI (bubbletea for interactive, simple table for verbose) + // Create UI based on mode: + // - headless: no UI at all (background daemon) + // - verbose: simple table UI with logging + // - default: interactive bubbletea TUI var bubbleTeaUI *ui.BubbleTeaUI var tableUI *ui.TableUI - if !*verbose { - // Interactive mode with bubbletea - bubbleTeaUI = ui.NewBubbleTeaUI(func(id string, enable bool) { - if enable { - manager.EnableForward(id) - } else { - manager.DisableForward(id) - } - }, appVersion) - - // Set wizard dependencies - // Note: mutator is always available (for delete/edit), discovery requires valid kubeconfig (for add) - bubbleTeaUI.SetWizardDependencies(discovery, mutator, *configFile) - - // Check for updates in background (non-blocking) - go func() { - checker := version.NewChecker(githubOwner, githubRepo, appVersion) - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - if update := checker.CheckForUpdate(ctx); update != nil { - bubbleTeaUI.SetUpdateAvailable(update.LatestVersion, update.ReleaseURL) - } - }() - - manager.SetStatusUI(bubbleTeaUI) - } else { + if *headless { + // Headless mode - no UI, just run forwards in background + // StatusUI remains nil, manager will handle this gracefully + if *verbose { + log.Printf("Running in headless mode with verbose logging") + } + } else if *verbose { // Verbose mode with simple table tableUI = ui.NewTableUI(*verbose) manager.SetStatusUI(tableUI) @@ -264,6 +249,68 @@ func main() { update.LatestVersion, update.CurrentVersion, update.ReleaseURL) } }() + } else { + // Interactive mode with bubbletea + bubbleTeaUI = ui.NewBubbleTeaUI(func(id string, enable bool) { + if enable { + manager.EnableForward(id) + } else { + manager.DisableForward(id) + } + }, appVersion) + + // Set wizard dependencies + // Note: mutator is always available (for delete/edit), discovery requires valid kubeconfig (for add) + bubbleTeaUI.SetWizardDependencies(discovery, mutator, *configFile) + + // Set HTTP log subscriber to enable live log viewing + bubbleTeaUI.SetHTTPLogSubscriber(func(forwardID string, callback func(entry ui.HTTPLogEntry)) func() { + worker := manager.GetWorker(forwardID) + if worker == nil { + return func() {} // No-op cleanup + } + + proxy := worker.GetHTTPProxy() + if proxy == nil { + return func() {} // HTTP logging not enabled for this forward + } + + proxyLogger := proxy.GetLogger() + if proxyLogger == nil { + return func() {} + } + + // Subscribe to log entries + proxyLogger.AddCallback(func(entry httplog.Entry) { + callback(ui.HTTPLogEntry{ + Timestamp: entry.Timestamp.Format("15:04:05"), + Direction: entry.Direction, + Method: entry.Method, + Path: entry.Path, + StatusCode: entry.StatusCode, + LatencyMs: entry.LatencyMs, + BodySize: entry.BodySize, + }) + }) + + // Return cleanup function + return func() { + proxyLogger.ClearCallbacks() + } + }) + + // Check for updates in background (non-blocking) + go func() { + checker := version.NewChecker(githubOwner, githubRepo, appVersion) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + if update := checker.CheckForUpdate(ctx); update != nil { + bubbleTeaUI.SetUpdateAvailable(update.LatestVersion, update.ReleaseURL) + } + }() + + manager.SetStatusUI(bubbleTeaUI) } // Start forwards @@ -272,7 +319,90 @@ func main() { os.Exit(1) } - if *verbose { + if *headless { + // Headless mode - no UI, run as background daemon + // Setup signal handling + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM, syscall.SIGHUP) + + // Setup config watcher for hot-reload + watcher, err := config.NewWatcher(*configFile, func(newCfg *config.Config) error { + return manager.Reload(newCfg) + }, *verbose) + if err != nil { + if *verbose { + log.Printf("Warning: Failed to setup config watcher: %v", err) + log.Printf("Hot-reload will not be available") + } + } else { + watcher.Start() + defer watcher.Stop() + } + + if *verbose { + log.Printf("Headless mode started. Press Ctrl+C to stop") + } + + // Wait for signals + for { + sig := <-sigChan + switch sig { + case syscall.SIGHUP: + if *verbose { + log.Printf("Received SIGHUP, reloading configuration...") + } + newCfg, err := config.LoadConfig(*configFile) + if err != nil { + if *verbose { + log.Printf("Failed to reload config: %v", err) + } + continue + } + + if errs := validator.ValidateConfig(newCfg); len(errs) > 0 { + if *verbose { + log.Printf("Config validation failed:") + log.Print(config.FormatValidationErrors(errs)) + } + continue + } + + if err := manager.Reload(newCfg); err != nil { + if *verbose { + log.Printf("Failed to reload: %v", err) + } + } + + case os.Interrupt, syscall.SIGTERM: + if *verbose { + log.Printf("Received shutdown signal, stopping...") + } + + // Graceful shutdown with timeout + shutdownDone := make(chan struct{}) + go func() { + manager.Stop() + close(shutdownDone) + }() + + select { + case <-shutdownDone: + if *verbose { + log.Printf("Graceful shutdown complete") + } + case <-time.After(5 * time.Second): + if *verbose { + log.Printf("Shutdown timed out, forcing exit...") + } + case sig := <-sigChan: + if *verbose { + log.Printf("Received second signal (%v), forcing exit...", sig) + } + } + os.Exit(0) + } + } + } else if *verbose { // Verbose mode - use simple table with periodic updates tableUI.RenderInitial() diff --git a/docs/index.html b/docs/index.html index 982b7fe..aec6466 100644 --- a/docs/index.html +++ b/docs/index.html @@ -265,6 +265,39 @@ +
+
+
+ +
+
+

HTTP Traffic Logging

+

Real-time HTTP logging with filters (Non-2xx, Errors, Search)

+
+
+
+
+
+
+ +
+
+

Connection Benchmarking

+

Built-in HTTP benchmarking with latency percentiles

+
+
+
+
+
+
+ +
+
+

Headless Mode

+

Background operation for scripting and automation

+
+
+
@@ -359,6 +392,14 @@

Use a custom configuration file instead of .kportal.yaml

+
+

Headless Mode

+
+ kportal -headless -v & +
+
+

Run without TUI for scripting and background operation.

+
@@ -390,12 +431,12 @@ Vim navigation
- q - Quit + b + Benchmark
- ? - Help + l + HTTP logs
@@ -604,6 +645,40 @@ contexts: 10s max + + +
+

HTTP Traffic Logging

+

Press l to view real-time HTTP traffic for debugging.

+
+
+ Columns: Time, Method, Status, Latency, Path +
+
+ f + Filter mode + / + Search + c + Clear +
+
+ Filters: All, Non-2xx, Errors (4xx/5xx) +
+
+
+ + +
+

Connection Benchmarking

+

Press b to benchmark a connection with configurable parameters.

+
+
ConcurrencyParallel workers
+
RequestsTotal request count
+
LatencyP50/P95/P99 percentiles
+
ThroughputRequests per second
+
+
diff --git a/internal/benchmark/results.go b/internal/benchmark/results.go new file mode 100644 index 0000000..9e0d170 --- /dev/null +++ b/internal/benchmark/results.go @@ -0,0 +1,130 @@ +package benchmark + +import ( + "sort" + "time" +) + +// Results holds the aggregated results of a benchmark run +type Results struct { + ForwardID string `json:"forward_id"` + URL string `json:"url"` + Method string `json:"method"` + StartTime time.Time `json:"start_time"` + EndTime time.Time `json:"end_time"` + TotalRequests int `json:"total_requests"` + Successful int `json:"successful"` + Failed int `json:"failed"` + Latencies []time.Duration `json:"-"` // Raw latencies for percentile calculation + StatusCodes map[int]int `json:"status_codes"` + Errors map[string]int `json:"errors,omitempty"` + BytesRead int64 `json:"bytes_read"` + BytesWritten int64 `json:"bytes_written"` +} + +// Stats holds calculated statistics +type Stats struct { + MinLatency time.Duration `json:"min_latency_ms"` + MaxLatency time.Duration `json:"max_latency_ms"` + AvgLatency time.Duration `json:"avg_latency_ms"` + P50Latency time.Duration `json:"p50_latency_ms"` + P95Latency time.Duration `json:"p95_latency_ms"` + P99Latency time.Duration `json:"p99_latency_ms"` + Throughput float64 `json:"throughput_rps"` + Duration time.Duration `json:"duration"` +} + +// NewResults creates a new Results instance +func NewResults(forwardID, url, method string) *Results { + return &Results{ + ForwardID: forwardID, + URL: url, + Method: method, + StartTime: time.Now(), + Latencies: make([]time.Duration, 0), + StatusCodes: make(map[int]int), + Errors: make(map[string]int), + } +} + +// RecordSuccess records a successful HTTP request (transport succeeded) +// Note: only 2xx status codes are counted as successful for statistics +func (r *Results) RecordSuccess(statusCode int, latency time.Duration, bytesRead, bytesWritten int64) { + r.TotalRequests++ + // Only count 2xx as successful + if statusCode >= 200 && statusCode < 300 { + r.Successful++ + } else { + r.Failed++ + } + r.Latencies = append(r.Latencies, latency) + r.StatusCodes[statusCode]++ + r.BytesRead += bytesRead + r.BytesWritten += bytesWritten +} + +// RecordFailure records a failed request +func (r *Results) RecordFailure(err error, latency time.Duration) { + r.TotalRequests++ + r.Failed++ + r.Latencies = append(r.Latencies, latency) + r.Errors[err.Error()]++ +} + +// Finalize marks the benchmark as complete +func (r *Results) Finalize() { + r.EndTime = time.Now() +} + +// CalculateStats calculates statistics from the results +func (r *Results) CalculateStats() Stats { + stats := Stats{ + Duration: r.EndTime.Sub(r.StartTime), + } + + if len(r.Latencies) == 0 { + return stats + } + + // Sort latencies for percentile calculation + sorted := make([]time.Duration, len(r.Latencies)) + copy(sorted, r.Latencies) + sort.Slice(sorted, func(i, j int) bool { + return sorted[i] < sorted[j] + }) + + // Calculate min, max, avg + var total time.Duration + stats.MinLatency = sorted[0] + stats.MaxLatency = sorted[len(sorted)-1] + + for _, lat := range sorted { + total += lat + } + stats.AvgLatency = total / time.Duration(len(sorted)) + + // Calculate percentiles + stats.P50Latency = percentile(sorted, 50) + stats.P95Latency = percentile(sorted, 95) + stats.P99Latency = percentile(sorted, 99) + + // Calculate throughput + if stats.Duration > 0 { + stats.Throughput = float64(r.TotalRequests) / stats.Duration.Seconds() + } + + return stats +} + +// percentile calculates the p-th percentile of sorted durations +func percentile(sorted []time.Duration, p int) time.Duration { + if len(sorted) == 0 { + return 0 + } + + idx := (p * len(sorted)) / 100 + if idx >= len(sorted) { + idx = len(sorted) - 1 + } + return sorted[idx] +} diff --git a/internal/benchmark/runner.go b/internal/benchmark/runner.go new file mode 100644 index 0000000..93744f7 --- /dev/null +++ b/internal/benchmark/runner.go @@ -0,0 +1,230 @@ +package benchmark + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" + "sync" + "sync/atomic" + "time" +) + +// ProgressCallback is called periodically with benchmark progress +type ProgressCallback func(completed, total int) + +// Config holds the benchmark configuration +type Config struct { + URL string // Target URL + Method string // HTTP method + Headers map[string]string // Custom headers + Body []byte // Request body + Concurrency int // Number of concurrent workers + Requests int // Total number of requests (0 = use duration) + Duration time.Duration // Duration to run (0 = use requests) + Timeout time.Duration // Request timeout + ProgressCallback ProgressCallback // Optional callback for progress updates +} + +// DefaultConfig returns a default benchmark configuration +func DefaultConfig() Config { + return Config{ + Method: "GET", + Concurrency: 10, + Requests: 100, + Timeout: 30 * time.Second, + } +} + +// Runner executes HTTP benchmarks +type Runner struct { + client *http.Client +} + +// NewRunner creates a new benchmark runner +func NewRunner() *Runner { + return &Runner{ + client: &http.Client{ + Timeout: 30 * time.Second, + Transport: &http.Transport{ + MaxIdleConns: 100, + MaxIdleConnsPerHost: 100, + IdleConnTimeout: 90 * time.Second, + }, + }, + } +} + +// Run executes the benchmark and returns results +func (r *Runner) Run(ctx context.Context, forwardID string, cfg Config) (*Results, error) { + if cfg.URL == "" { + return nil, fmt.Errorf("URL is required") + } + + if cfg.Concurrency < 1 { + cfg.Concurrency = 1 + } + + // Ensure concurrency doesn't exceed number of requests (for request-based mode) + if cfg.Duration == 0 && cfg.Requests > 0 && cfg.Concurrency > cfg.Requests { + cfg.Concurrency = cfg.Requests + } + + if cfg.Timeout > 0 { + r.client.Timeout = cfg.Timeout + } + + results := NewResults(forwardID, cfg.URL, cfg.Method) + + // Create work channel + workCh := make(chan struct{}, cfg.Concurrency*2) + + // Create context for cancellation + runCtx, cancel := context.WithCancel(ctx) + defer cancel() + + // Start workers + var wg sync.WaitGroup + var completed int64 + var resultsMu sync.Mutex // Shared mutex for results access + + for i := 0; i < cfg.Concurrency; i++ { + wg.Add(1) + go func() { + defer wg.Done() + r.worker(runCtx, cfg, results, &resultsMu, workCh, &completed) + }() + } + + // Start progress reporter if callback is provided + if cfg.ProgressCallback != nil { + go func() { + ticker := time.NewTicker(100 * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-runCtx.Done(): + return + case <-ticker.C: + cfg.ProgressCallback(int(atomic.LoadInt64(&completed)), cfg.Requests) + } + } + }() + } + + // Determine how to dispatch work + if cfg.Duration > 0 { + // Duration-based: keep sending work until duration expires + timer := time.NewTimer(cfg.Duration) + defer timer.Stop() + + dispatchLoop: + for { + select { + case <-timer.C: + cancel() + break dispatchLoop + case <-ctx.Done(): + cancel() + break dispatchLoop + case workCh <- struct{}{}: + // Work dispatched + } + } + } else { + // Request-based: send exactly N requests + requestLoop: + for i := 0; i < cfg.Requests; i++ { + select { + case <-ctx.Done(): + cancel() + break requestLoop + case workCh <- struct{}{}: + // Work dispatched + } + } + } + + // Close work channel and wait for workers + close(workCh) + wg.Wait() + + results.Finalize() + return results, nil +} + +// worker processes requests from the work channel +func (r *Runner) worker(ctx context.Context, cfg Config, results *Results, resultsMu *sync.Mutex, workCh <-chan struct{}, completed *int64) { + for range workCh { + select { + case <-ctx.Done(): + return + default: + } + + start := time.Now() + statusCode, bytesRead, bytesWritten, err := r.makeRequestSafe(ctx, cfg) + latency := time.Since(start) + + resultsMu.Lock() + if err != nil { + results.RecordFailure(err, latency) + } else { + results.RecordSuccess(statusCode, latency, bytesRead, bytesWritten) + } + resultsMu.Unlock() + + atomic.AddInt64(completed, 1) + } +} + +// makeRequestSafe wraps makeRequest with panic recovery +func (r *Runner) makeRequestSafe(ctx context.Context, cfg Config) (statusCode int, bytesRead, bytesWritten int64, err error) { + defer func() { + if rec := recover(); rec != nil { + err = fmt.Errorf("request panic: %v", rec) + } + }() + return r.makeRequest(ctx, cfg) +} + +// makeRequest makes a single HTTP request +func (r *Runner) makeRequest(ctx context.Context, cfg Config) (statusCode int, bytesRead, bytesWritten int64, err error) { + var body io.Reader + if len(cfg.Body) > 0 { + body = bytes.NewReader(cfg.Body) + bytesWritten = int64(len(cfg.Body)) + } + + req, err := http.NewRequestWithContext(ctx, cfg.Method, cfg.URL, body) + if err != nil { + return 0, 0, 0, err + } + + // Set headers + for k, v := range cfg.Headers { + req.Header.Set(k, v) + } + + resp, err := r.client.Do(req) + if err != nil { + return 0, 0, bytesWritten, err + } + defer resp.Body.Close() + + // Read response body to measure bytes + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return resp.StatusCode, 0, bytesWritten, err + } + + return resp.StatusCode, int64(len(respBody)), bytesWritten, nil +} + +// Progress represents the current progress of a benchmark run +type Progress struct { + Completed int + Total int + Elapsed time.Duration +} diff --git a/internal/benchmark/runner_test.go b/internal/benchmark/runner_test.go new file mode 100644 index 0000000..65a6342 --- /dev/null +++ b/internal/benchmark/runner_test.go @@ -0,0 +1,282 @@ +package benchmark + +import ( + "context" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestResults(t *testing.T) { + r := NewResults("test-forward", "http://localhost/test", "GET") + + // Record some 2xx successes + r.RecordSuccess(200, 10*time.Millisecond, 100, 0) + r.RecordSuccess(200, 20*time.Millisecond, 150, 0) + r.RecordSuccess(201, 15*time.Millisecond, 120, 0) + + // Record a transport failure + r.RecordFailure(assert.AnError, 5*time.Millisecond) + + r.Finalize() + + assert.Equal(t, 4, r.TotalRequests) + assert.Equal(t, 3, r.Successful) + assert.Equal(t, 1, r.Failed) + assert.Equal(t, int64(370), r.BytesRead) + assert.Equal(t, 2, r.StatusCodes[200]) + assert.Equal(t, 1, r.StatusCodes[201]) +} + +func TestResultsNon2xxCountsAsFailure(t *testing.T) { + r := NewResults("test-forward", "http://localhost/test", "GET") + + // Record a 200 success + r.RecordSuccess(200, 10*time.Millisecond, 100, 0) + + // Record 4xx and 5xx - these should count as failures + r.RecordSuccess(404, 10*time.Millisecond, 50, 0) + r.RecordSuccess(500, 10*time.Millisecond, 30, 0) + + r.Finalize() + + assert.Equal(t, 3, r.TotalRequests) + assert.Equal(t, 1, r.Successful, "Only 2xx should count as successful") + assert.Equal(t, 2, r.Failed, "4xx and 5xx should count as failed") + assert.Equal(t, 1, r.StatusCodes[200]) + assert.Equal(t, 1, r.StatusCodes[404]) + assert.Equal(t, 1, r.StatusCodes[500]) +} + +func TestResultsStats(t *testing.T) { + r := NewResults("test", "http://localhost", "GET") + + // Add latencies + latencies := []time.Duration{ + 10 * time.Millisecond, + 20 * time.Millisecond, + 30 * time.Millisecond, + 40 * time.Millisecond, + 50 * time.Millisecond, + } + + for _, lat := range latencies { + r.RecordSuccess(200, lat, 0, 0) + } + + r.EndTime = r.StartTime.Add(1 * time.Second) + + stats := r.CalculateStats() + + assert.Equal(t, 10*time.Millisecond, stats.MinLatency) + assert.Equal(t, 50*time.Millisecond, stats.MaxLatency) + assert.Equal(t, 30*time.Millisecond, stats.AvgLatency) + assert.Equal(t, float64(5), stats.Throughput) +} + +func TestPercentile(t *testing.T) { + sorted := []time.Duration{ + 1 * time.Millisecond, + 2 * time.Millisecond, + 3 * time.Millisecond, + 4 * time.Millisecond, + 5 * time.Millisecond, + 6 * time.Millisecond, + 7 * time.Millisecond, + 8 * time.Millisecond, + 9 * time.Millisecond, + 10 * time.Millisecond, + } + + // P50 = index 5 (50*10/100 = 5) = 6ms + assert.Equal(t, 6*time.Millisecond, percentile(sorted, 50)) + // P95 = index 9 (95*10/100 = 9) = 10ms + assert.Equal(t, 10*time.Millisecond, percentile(sorted, 95)) + // P99 = index 9 (99*10/100 = 9) = 10ms + assert.Equal(t, 10*time.Millisecond, percentile(sorted, 99)) +} + +func TestRunner(t *testing.T) { + // Create a test server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(5 * time.Millisecond) // Simulate some latency + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"status":"ok"}`)) + })) + defer server.Close() + + runner := NewRunner() + + cfg := Config{ + URL: server.URL, + Method: "GET", + Concurrency: 2, + Requests: 10, + Timeout: 5 * time.Second, + } + + results, err := runner.Run(context.Background(), "test-forward", cfg) + require.NoError(t, err) + + assert.Equal(t, 10, results.TotalRequests) + assert.Equal(t, 10, results.Successful) + assert.Equal(t, 0, results.Failed) + assert.Equal(t, 10, results.StatusCodes[200]) +} + +func TestRunnerWithDuration(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`ok`)) + })) + defer server.Close() + + runner := NewRunner() + + cfg := Config{ + URL: server.URL, + Method: "GET", + Concurrency: 2, + Duration: 100 * time.Millisecond, + Timeout: 1 * time.Second, + } + + results, err := runner.Run(context.Background(), "test-forward", cfg) + require.NoError(t, err) + + // Should have made some requests in 100ms + assert.Greater(t, results.TotalRequests, 0) + assert.Equal(t, results.Successful, results.StatusCodes[200]) +} + +func TestRunnerWithHeaders(t *testing.T) { + var receivedHeader string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedHeader = r.Header.Get("X-Custom-Header") + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + runner := NewRunner() + + cfg := Config{ + URL: server.URL, + Method: "GET", + Headers: map[string]string{ + "X-Custom-Header": "test-value", + }, + Concurrency: 1, + Requests: 1, + } + + _, err := runner.Run(context.Background(), "test", cfg) + require.NoError(t, err) + + assert.Equal(t, "test-value", receivedHeader) +} + +func TestRunnerWithBody(t *testing.T) { + var receivedBody string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := http.MaxBytesReader(w, r.Body, 1024).Read(make([]byte, 1024)) + _ = body + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + runner := NewRunner() + + cfg := Config{ + URL: server.URL, + Method: "POST", + Body: []byte(`{"test":"data"}`), + Concurrency: 1, + Requests: 1, + } + + results, err := runner.Run(context.Background(), "test", cfg) + require.NoError(t, err) + + _ = receivedBody // Used for debugging + assert.Equal(t, int64(15), results.BytesWritten) +} + +func TestDefaultConfig(t *testing.T) { + cfg := DefaultConfig() + + assert.Equal(t, "GET", cfg.Method) + assert.Equal(t, 10, cfg.Concurrency) + assert.Equal(t, 100, cfg.Requests) + assert.Equal(t, 30*time.Second, cfg.Timeout) +} + +func TestRunnerWithProgressCallback(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(10 * time.Millisecond) // Add small delay so progress ticker can fire + w.WriteHeader(http.StatusOK) + w.Write([]byte(`ok`)) + })) + defer server.Close() + + runner := NewRunner() + + var progressUpdates []int + var mu sync.Mutex + + cfg := Config{ + URL: server.URL, + Method: "GET", + Concurrency: 5, + Requests: 50, // More requests to ensure progress callbacks fire + Timeout: 5 * time.Second, + ProgressCallback: func(completed, total int) { + mu.Lock() + progressUpdates = append(progressUpdates, completed) + mu.Unlock() + }, + } + + results, err := runner.Run(context.Background(), "test-forward", cfg) + require.NoError(t, err) + + assert.Equal(t, 50, results.TotalRequests) + + // Should have received some progress updates (ticker fires every 100ms) + mu.Lock() + updates := len(progressUpdates) + mu.Unlock() + assert.Greater(t, updates, 0, "Should have received progress updates") +} + +func TestRunnerConcurrencyCappedAtRequests(t *testing.T) { + requestCount := 0 + var mu sync.Mutex + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + requestCount++ + mu.Unlock() + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + runner := NewRunner() + + cfg := Config{ + URL: server.URL, + Method: "GET", + Concurrency: 100, // Higher than requests + Requests: 5, + Timeout: 5 * time.Second, + } + + results, err := runner.Run(context.Background(), "test", cfg) + require.NoError(t, err) + + assert.Equal(t, 5, results.TotalRequests) +} diff --git a/internal/config/config.go b/internal/config/config.go index f920b50..42fdd18 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -25,6 +25,9 @@ const ( DefaultTCPKeepalive = 30 * time.Second // OS-level TCP keepalive interval DefaultDialTimeout = 30 * time.Second // Connection establishment timeout DefaultWatchdogPeriod = 30 * time.Second // Goroutine health check interval + + // Default HTTP logging settings + DefaultHTTPLogMaxBodySize = 1024 * 1024 // 1MB max body size for logging ) // Config represents the root configuration structure from .kportal.yaml @@ -158,14 +161,44 @@ type Namespace struct { Forwards []Forward `yaml:"forwards"` } +// HTTPLogSpec configures HTTP traffic logging for a forward +type HTTPLogSpec struct { + Enabled bool `yaml:"enabled"` // Enable HTTP logging + LogFile string `yaml:"logFile,omitempty"` // Output file (empty = stdout) + MaxBodySize int `yaml:"maxBodySize,omitempty"` // Max body size to log (default 1MB) + IncludeHeaders bool `yaml:"includeHeaders,omitempty"` // Include headers in log + FilterPath string `yaml:"filterPath,omitempty"` // Optional glob filter for paths +} + +// UnmarshalYAML implements custom unmarshaling to support both bool and struct formats +// Allows: httpLog: true OR httpLog: { enabled: true, ... } +func (h *HTTPLogSpec) UnmarshalYAML(unmarshal func(interface{}) error) error { + // First try to unmarshal as a boolean + var boolVal bool + if err := unmarshal(&boolVal); err == nil { + h.Enabled = boolVal + return nil + } + + // Otherwise try to unmarshal as a struct + type httpLogSpecAlias HTTPLogSpec // Use alias to avoid infinite recursion + var spec httpLogSpecAlias + if err := unmarshal(&spec); err != nil { + return err + } + *h = HTTPLogSpec(spec) + return nil +} + // Forward represents a single port-forward configuration type Forward struct { - Resource string `yaml:"resource"` // e.g., "pod/my-app", "service/postgres", "pod" - Selector string `yaml:"selector"` // Label selector for pod resolution (e.g., "app=nginx,env=prod") - Protocol string `yaml:"protocol"` // tcp or udp - Port int `yaml:"port"` // Remote port - LocalPort int `yaml:"localPort"` // Local port - Alias string `yaml:"alias,omitempty"` // Optional human-readable alias for this forward + Resource string `yaml:"resource"` // e.g., "pod/my-app", "service/postgres", "pod" + Selector string `yaml:"selector"` // Label selector for pod resolution (e.g., "app=nginx,env=prod") + Protocol string `yaml:"protocol"` // tcp or udp + Port int `yaml:"port"` // Remote port + LocalPort int `yaml:"localPort"` // Local port + Alias string `yaml:"alias,omitempty"` // Optional human-readable alias for this forward + HTTPLog *HTTPLogSpec `yaml:"httpLog,omitempty"` // Optional HTTP traffic logging // Runtime fields (not in YAML) contextName string @@ -212,6 +245,19 @@ func (f *Forward) GetNamespace() string { return f.namespaceName } +// IsHTTPLogEnabled returns true if HTTP logging is enabled for this forward +func (f *Forward) IsHTTPLogEnabled() bool { + return f.HTTPLog != nil && f.HTTPLog.Enabled +} + +// GetHTTPLogMaxBodySize returns the max body size for HTTP logging +func (f *Forward) GetHTTPLogMaxBodySize() int { + if f.HTTPLog == nil || f.HTTPLog.MaxBodySize <= 0 { + return DefaultHTTPLogMaxBodySize + } + return f.HTTPLog.MaxBodySize +} + // GetMDNSAlias returns the alias to use for mDNS hostname registration. // If an explicit alias is set, it returns that. // Otherwise, it generates one from the resource name (e.g., "service/logto" -> "logto"). diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 6d2d807..9892c27 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -313,3 +313,87 @@ func TestForward_SetContext(t *testing.T) { assert.Equal(t, "my-cluster", fwd.GetContext()) assert.Equal(t, "my-namespace", fwd.GetNamespace()) } + +func TestHTTPLogSpec_UnmarshalYAML(t *testing.T) { + tests := []struct { + name string + yaml string + expected bool + }{ + { + name: "httpLog as boolean true", + yaml: `contexts: + - name: test + namespaces: + - name: default + forwards: + - resource: service/api + port: 8080 + localPort: 8080 + httpLog: true +`, + expected: true, + }, + { + name: "httpLog as boolean false", + yaml: `contexts: + - name: test + namespaces: + - name: default + forwards: + - resource: service/api + port: 8080 + localPort: 8080 + httpLog: false +`, + expected: false, + }, + { + name: "httpLog as struct", + yaml: `contexts: + - name: test + namespaces: + - name: default + forwards: + - resource: service/api + port: 8080 + localPort: 8080 + httpLog: + enabled: true + includeHeaders: true +`, + expected: true, + }, + { + name: "httpLog not specified", + yaml: `contexts: + - name: test + namespaces: + - name: default + forwards: + - resource: service/api + port: 8080 + localPort: 8080 +`, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg, err := ParseConfig([]byte(tt.yaml)) + assert.NoError(t, err) + assert.NotNil(t, cfg) + + fwd := cfg.Contexts[0].Namespaces[0].Forwards[0] + if tt.expected { + assert.NotNil(t, fwd.HTTPLog, "HTTPLog should not be nil") + assert.True(t, fwd.HTTPLog.Enabled, "HTTPLog.Enabled should be true") + } else { + if fwd.HTTPLog != nil { + assert.False(t, fwd.HTTPLog.Enabled, "HTTPLog.Enabled should be false") + } + } + }) + } +} diff --git a/internal/forward/manager.go b/internal/forward/manager.go index 87fdcb8..21b00ba 100644 --- a/internal/forward/manager.go +++ b/internal/forward/manager.go @@ -483,6 +483,14 @@ func (m *Manager) GetWorkerCount() int { return len(m.workers) } +// GetWorker returns a worker by ID, or nil if not found. +func (m *Manager) GetWorker(id string) *ForwardWorker { + m.workersMu.RLock() + defer m.workersMu.RUnlock() + + return m.workers[id] +} + // extractPorts extracts all local ports from a list of forwards. func (m *Manager) extractPorts(forwards []config.Forward) []int { ports := make([]int, len(forwards)) diff --git a/internal/forward/worker.go b/internal/forward/worker.go index cccb0b8..c1f41d6 100644 --- a/internal/forward/worker.go +++ b/internal/forward/worker.go @@ -10,6 +10,7 @@ import ( "github.com/nvm/kportal/internal/config" "github.com/nvm/kportal/internal/healthcheck" + "github.com/nvm/kportal/internal/httplog" "github.com/nvm/kportal/internal/k8s" "github.com/nvm/kportal/internal/logger" "github.com/nvm/kportal/internal/retry" @@ -17,6 +18,7 @@ import ( const ( portForwardReadyTimeout = 30 * time.Second + httpLogPortOffset = 10000 // Offset for internal port when HTTP logging is enabled ) // ForwardWorker manages a single port-forward connection with automatic retry. @@ -36,6 +38,7 @@ type ForwardWorker struct { startTime time.Time // Track when the worker started forwardCancel context.CancelFunc // Cancel function for current forward attempt forwardCancelMu sync.Mutex // Protects forwardCancel + httpProxy *httplog.Proxy // HTTP logging proxy (nil if not enabled) } // NewForwardWorker creates a new ForwardWorker for a single forward configuration. @@ -100,6 +103,7 @@ func (w *ForwardWorker) Stop() { // run is the main worker loop that handles retries. func (w *ForwardWorker) run() { defer close(w.doneChan) + defer w.stopHTTPProxy() // Ensure proxy is stopped on exit // Start heartbeat goroutine to continuously send heartbeats to watchdog // This prevents false "hung worker" detection when connections are long-lived @@ -107,6 +111,15 @@ func (w *ForwardWorker) run() { go w.heartbeatLoop() } + // Start HTTP logging proxy if enabled + if err := w.startHTTPProxy(); err != nil { + logger.Error("Failed to start HTTP logging proxy", map[string]interface{}{ + "forward_id": w.forward.ID(), + "error": err.Error(), + }) + // Continue without HTTP logging + } + backoff := retry.NewBackoff() for { @@ -276,13 +289,20 @@ func (w *ForwardWorker) establishForward(podName string) error { errOut = io.Discard } + // Determine local port for k8s port-forward + // If HTTP logging is enabled, we bind to an internal port and the proxy listens on the user-facing port + localPort := w.forward.LocalPort + if w.httpProxy != nil { + localPort = w.httpProxy.GetTargetPort() + } + // Create forward request req := &k8s.ForwardRequest{ ContextName: w.forward.GetContext(), Namespace: w.forward.GetNamespace(), Resource: w.forward.Resource, Selector: w.forward.Selector, - LocalPort: w.forward.LocalPort, + LocalPort: localPort, RemotePort: w.forward.Port, StopChan: stopChan, ReadyChan: readyChan, @@ -355,6 +375,53 @@ func (w *ForwardWorker) IsRunning() bool { } } +// startHTTPProxy starts the HTTP logging proxy if enabled +func (w *ForwardWorker) startHTTPProxy() error { + if !w.forward.IsHTTPLogEnabled() { + return nil + } + + // Calculate internal port for k8s tunnel + targetPort := w.forward.LocalPort + httpLogPortOffset + + proxy, err := httplog.NewProxy(&w.forward, targetPort) + if err != nil { + return fmt.Errorf("failed to create HTTP proxy: %w", err) + } + + if err := proxy.Start(); err != nil { + return fmt.Errorf("failed to start HTTP proxy: %w", err) + } + + w.httpProxy = proxy + + logger.Info("HTTP logging proxy started", map[string]interface{}{ + "forward_id": w.forward.ID(), + "local_port": w.forward.LocalPort, + "target_port": targetPort, + }) + + return nil +} + +// stopHTTPProxy stops the HTTP logging proxy if running +func (w *ForwardWorker) stopHTTPProxy() { + if w.httpProxy != nil { + if err := w.httpProxy.Stop(); err != nil { + logger.Warn("Failed to stop HTTP proxy", map[string]interface{}{ + "forward_id": w.forward.ID(), + "error": err.Error(), + }) + } + w.httpProxy = nil + } +} + +// GetHTTPProxy returns the HTTP logging proxy if active +func (w *ForwardWorker) GetHTTPProxy() *httplog.Proxy { + return w.httpProxy +} + // logWriter implements io.Writer to write log messages with a prefix. type logWriter struct { prefix string diff --git a/internal/httplog/logger.go b/internal/httplog/logger.go new file mode 100644 index 0000000..9543fb5 --- /dev/null +++ b/internal/httplog/logger.go @@ -0,0 +1,112 @@ +package httplog + +import ( + "encoding/json" + "io" + "os" + "sync" + "time" +) + +// Entry represents a single HTTP log entry +type Entry struct { + Timestamp time.Time `json:"timestamp"` + ForwardID string `json:"forward_id"` + RequestID string `json:"request_id"` + Direction string `json:"direction"` // "request" or "response" + Method string `json:"method,omitempty"` + Path string `json:"path,omitempty"` + StatusCode int `json:"status_code,omitempty"` + Headers map[string]string `json:"headers,omitempty"` + BodySize int `json:"body_size"` + Body string `json:"body,omitempty"` + LatencyMs int64 `json:"latency_ms,omitempty"` + Error string `json:"error,omitempty"` +} + +// LogCallback is a function that receives log entries +type LogCallback func(entry Entry) + +// Logger writes HTTP log entries to an output stream +type Logger struct { + mu sync.Mutex + output io.Writer + file *os.File // Only set if we opened the file ourselves + forwardID string + maxBodyLen int + callbacks []LogCallback +} + +// NewLogger creates a new HTTP logger +// If logFile is empty, logs only go to registered callbacks (no file output) +// This prevents stdout corruption when running in TUI mode +func NewLogger(forwardID, logFile string, maxBodyLen int) (*Logger, error) { + l := &Logger{ + forwardID: forwardID, + maxBodyLen: maxBodyLen, + } + + if logFile == "" { + // Don't write to stdout - use io.Discard + // Log entries are delivered via callbacks to the UI + l.output = io.Discard + } else { + f, err := os.OpenFile(logFile, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600) + if err != nil { + return nil, err + } + l.file = f + l.output = f + } + + return l, nil +} + +// AddCallback registers a callback to receive log entries +func (l *Logger) AddCallback(cb LogCallback) { + l.mu.Lock() + defer l.mu.Unlock() + l.callbacks = append(l.callbacks, cb) +} + +// ClearCallbacks removes all registered callbacks +func (l *Logger) ClearCallbacks() { + l.mu.Lock() + defer l.mu.Unlock() + l.callbacks = nil +} + +// Log writes a log entry as JSON +func (l *Logger) Log(entry Entry) error { + entry.ForwardID = l.forwardID + entry.Timestamp = time.Now() + + // Truncate body if too large + if len(entry.Body) > l.maxBodyLen { + entry.Body = entry.Body[:l.maxBodyLen] + "...(truncated)" + } + + data, err := json.Marshal(entry) + if err != nil { + return err + } + + l.mu.Lock() + defer l.mu.Unlock() + + // Notify callbacks + for _, cb := range l.callbacks { + cb(entry) + } + + _, err = l.output.Write(append(data, '\n')) + return err +} + +// Close closes the logger +func (l *Logger) Close() error { + if l.file != nil { + return l.file.Close() + } + return nil +} diff --git a/internal/httplog/proxy.go b/internal/httplog/proxy.go new file mode 100644 index 0000000..07c693c --- /dev/null +++ b/internal/httplog/proxy.go @@ -0,0 +1,262 @@ +package httplog + +import ( + "bytes" + "context" + "fmt" + "io" + "net" + "net/http" + "net/http/httputil" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/nvm/kportal/internal/config" +) + +// Proxy is an HTTP reverse proxy with logging capabilities +type Proxy struct { + localPort int // Port to listen on (user-facing) + targetPort int // Port to forward to (k8s tunnel) + logger *Logger + server *http.Server + forwardID string + filterPath string // Glob pattern for path filtering + includeHdrs bool + listener net.Listener + requestCount uint64 + mu sync.Mutex + running bool +} + +// NewProxy creates a new HTTP logging proxy +func NewProxy(fwd *config.Forward, targetPort int) (*Proxy, error) { + httpCfg := fwd.HTTPLog + if httpCfg == nil { + return nil, fmt.Errorf("HTTP log config is nil") + } + + logger, err := NewLogger(fwd.ID(), httpCfg.LogFile, fwd.GetHTTPLogMaxBodySize()) + if err != nil { + return nil, fmt.Errorf("failed to create logger: %w", err) + } + + return &Proxy{ + localPort: fwd.LocalPort, + targetPort: targetPort, + logger: logger, + forwardID: fwd.ID(), + filterPath: httpCfg.FilterPath, + includeHdrs: httpCfg.IncludeHeaders, + }, nil +} + +// Start starts the HTTP proxy server +func (p *Proxy) Start() error { + p.mu.Lock() + if p.running { + p.mu.Unlock() + return fmt.Errorf("proxy already running") + } + + // Create listener + ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", p.localPort)) + if err != nil { + p.mu.Unlock() + return fmt.Errorf("failed to listen on port %d: %w", p.localPort, err) + } + p.listener = ln + + // Create reverse proxy + director := func(req *http.Request) { + req.URL.Scheme = "http" + req.URL.Host = fmt.Sprintf("127.0.0.1:%d", p.targetPort) + } + + proxy := &httputil.ReverseProxy{ + Director: director, + Transport: &loggingTransport{ + proxy: p, + transport: http.DefaultTransport, + }, + ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) { + p.logError(r, err) + w.WriteHeader(http.StatusBadGateway) + w.Write([]byte("Proxy error: " + err.Error())) + }, + } + + p.server = &http.Server{ + Handler: proxy, + } + + p.running = true + p.mu.Unlock() + + // Start serving (blocking) + go func() { + if err := p.server.Serve(ln); err != nil && err != http.ErrServerClosed { + // Log error but don't crash - proxy will be replaced on reconnect + } + }() + + return nil +} + +// Stop stops the HTTP proxy server +func (p *Proxy) Stop() error { + p.mu.Lock() + defer p.mu.Unlock() + + if !p.running { + return nil + } + + p.running = false + + // Shutdown with timeout + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err := p.server.Shutdown(ctx); err != nil { + // Force close + p.server.Close() + } + + if err := p.logger.Close(); err != nil { + return err + } + + return nil +} + +// loggingTransport wraps http.RoundTripper to log requests and responses +type loggingTransport struct { + proxy *Proxy + transport http.RoundTripper +} + +func (t *loggingTransport) RoundTrip(req *http.Request) (*http.Response, error) { + // Generate request ID + reqID := fmt.Sprintf("%d", atomic.AddUint64(&t.proxy.requestCount, 1)) + + // Check if we should log this request based on path filter + if !t.proxy.shouldLog(req.URL.Path) { + return t.transport.RoundTrip(req) + } + + startTime := time.Now() + + // Read request body + var reqBody []byte + if req.Body != nil { + reqBody, _ = io.ReadAll(req.Body) + req.Body = io.NopCloser(bytes.NewBuffer(reqBody)) + } + + // Log request + reqEntry := Entry{ + RequestID: reqID, + Direction: "request", + Method: req.Method, + Path: req.URL.Path, + BodySize: len(reqBody), + Body: string(reqBody), + } + + if t.proxy.includeHdrs { + reqEntry.Headers = flattenHeaders(req.Header) + } + + t.proxy.logger.Log(reqEntry) + + // Make the request + resp, err := t.transport.RoundTrip(req) + if err != nil { + return nil, err + } + + // Read response body + var respBody []byte + if resp.Body != nil { + respBody, _ = io.ReadAll(resp.Body) + resp.Body = io.NopCloser(bytes.NewBuffer(respBody)) + } + + latency := time.Since(startTime) + + // Log response + respEntry := Entry{ + RequestID: reqID, + Direction: "response", + Method: req.Method, + Path: req.URL.Path, + StatusCode: resp.StatusCode, + BodySize: len(respBody), + Body: string(respBody), + LatencyMs: latency.Milliseconds(), + } + + if t.proxy.includeHdrs { + respEntry.Headers = flattenHeaders(resp.Header) + } + + t.proxy.logger.Log(respEntry) + + return resp, nil +} + +// shouldLog checks if the request path matches the filter +func (p *Proxy) shouldLog(path string) bool { + if p.filterPath == "" { + return true + } + + matched, err := filepath.Match(p.filterPath, path) + if err != nil { + // Invalid pattern, log everything + return true + } + + // Also try matching with ** for prefix patterns like /api/* + if !matched && strings.HasSuffix(p.filterPath, "/*") { + prefix := strings.TrimSuffix(p.filterPath, "/*") + matched = strings.HasPrefix(path, prefix) + } + + return matched +} + +// logError logs an error entry +func (p *Proxy) logError(req *http.Request, err error) { + entry := Entry{ + RequestID: fmt.Sprintf("%d", atomic.AddUint64(&p.requestCount, 1)), + Direction: "error", + Method: req.Method, + Path: req.URL.Path, + Error: err.Error(), + } + p.logger.Log(entry) +} + +// flattenHeaders converts http.Header to map[string]string +func flattenHeaders(h http.Header) map[string]string { + result := make(map[string]string, len(h)) + for k, v := range h { + result[k] = strings.Join(v, ", ") + } + return result +} + +// GetTargetPort returns the target port for the k8s tunnel +func (p *Proxy) GetTargetPort() int { + return p.targetPort +} + +// GetLogger returns the HTTP logger for subscribing to log entries +func (p *Proxy) GetLogger() *Logger { + return p.logger +} diff --git a/internal/httplog/proxy_test.go b/internal/httplog/proxy_test.go new file mode 100644 index 0000000..090cff2 --- /dev/null +++ b/internal/httplog/proxy_test.go @@ -0,0 +1,181 @@ +package httplog + +import ( + "bytes" + "encoding/json" + "net/http" + "os" + "testing" + + "github.com/nvm/kportal/internal/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLogger(t *testing.T) { + // Create a buffer to capture output + var buf bytes.Buffer + + l := &Logger{ + forwardID: "test-forward", + maxBodyLen: 100, + output: &buf, + } + + // Log an entry + err := l.Log(Entry{ + Direction: "request", + Method: "GET", + Path: "/test", + BodySize: 0, + }) + require.NoError(t, err) + + // Parse the output + var entry Entry + err = json.Unmarshal(buf.Bytes(), &entry) + require.NoError(t, err) + + assert.Equal(t, "test-forward", entry.ForwardID) + assert.Equal(t, "request", entry.Direction) + assert.Equal(t, "GET", entry.Method) + assert.Equal(t, "/test", entry.Path) + assert.False(t, entry.Timestamp.IsZero()) +} + +func TestLoggerBodyTruncation(t *testing.T) { + var buf bytes.Buffer + + l := &Logger{ + forwardID: "test-forward", + maxBodyLen: 10, + output: &buf, + } + + // Log an entry with a long body + err := l.Log(Entry{ + Direction: "request", + Method: "POST", + Path: "/test", + Body: "this is a very long body that should be truncated", + BodySize: 50, + }) + require.NoError(t, err) + + // Parse the output + var entry Entry + err = json.Unmarshal(buf.Bytes(), &entry) + require.NoError(t, err) + + assert.Equal(t, "this is a ...(truncated)", entry.Body) +} + +func TestProxyShouldLog(t *testing.T) { + tests := []struct { + name string + filterPath string + path string + expected bool + }{ + {"no filter", "", "/anything", true}, + {"exact match", "/api", "/api", true}, + {"no match", "/api", "/other", false}, + {"prefix match", "/api/*", "/api/users", true}, + {"prefix no match", "/api/*", "/other/users", false}, + {"wildcard", "/api/*/test", "/api/v1/test", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := &Proxy{filterPath: tt.filterPath} + assert.Equal(t, tt.expected, p.shouldLog(tt.path)) + }) + } +} + +func TestProxyIntegration(t *testing.T) { + // Create a buffer for log output + var logBuf bytes.Buffer + + // Create config + fwd := &config.Forward{ + LocalPort: 0, // Will be assigned dynamically + HTTPLog: &config.HTTPLogSpec{ + Enabled: true, + IncludeHeaders: true, + MaxBodySize: 1024, + }, + } + + // Create logger with buffer + logger := &Logger{ + forwardID: "test", + maxBodyLen: 1024, + output: &logBuf, + } + + // Create proxy manually for testing + proxy := &Proxy{ + localPort: 0, // Will use ephemeral port + targetPort: 0, // Not used in this test + logger: logger, + forwardID: fwd.ID(), + filterPath: "", + includeHdrs: true, + } + + // Test shouldLog + assert.True(t, proxy.shouldLog("/any/path")) + + // Test logging through logger directly + err := logger.Log(Entry{ + RequestID: "1", + Direction: "request", + Method: "GET", + Path: "/test", + }) + require.NoError(t, err) + + // Verify log output + assert.Contains(t, logBuf.String(), `"direction":"request"`) + assert.Contains(t, logBuf.String(), `"method":"GET"`) +} + +func TestFlattenHeaders(t *testing.T) { + h := http.Header{ + "Content-Type": []string{"application/json"}, + "Accept": []string{"text/html", "application/json"}, + } + + result := flattenHeaders(h) + + assert.Equal(t, "application/json", result["Content-Type"]) + assert.Equal(t, "text/html, application/json", result["Accept"]) +} + +func TestNewLogger(t *testing.T) { + // Test stdout logger + l, err := NewLogger("test-forward", "", 1024) + require.NoError(t, err) + require.NotNil(t, l) + assert.Nil(t, l.file) // No file when using stdout + l.Close() + + // Test file logger (using temp file) + tmpFile := t.TempDir() + "/test.log" + l, err = NewLogger("test-forward", tmpFile, 1024) + require.NoError(t, err) + require.NotNil(t, l) + assert.NotNil(t, l.file) + + // Write something + err = l.Log(Entry{Direction: "request", Method: "GET"}) + require.NoError(t, err) + + l.Close() + + // Verify file has content + data, err := os.ReadFile(tmpFile) + require.NoError(t, err) + assert.Contains(t, string(data), `"direction":"request"`) +} diff --git a/internal/ui/bubbletea_ui.go b/internal/ui/bubbletea_ui.go index a041282..ece0fc6 100644 --- a/internal/ui/bubbletea_ui.go +++ b/internal/ui/bubbletea_ui.go @@ -7,6 +7,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/lipgloss/table" "github.com/nvm/kportal/internal/config" "github.com/nvm/kportal/internal/k8s" ) @@ -34,6 +35,10 @@ type ForwardRemoveMsg struct { ID string } +// HTTPLogSubscriber is a function that subscribes to HTTP logs for a forward +// It returns a cleanup function to call when unsubscribing +type HTTPLogSubscriber func(forwardID string, callback func(entry HTTPLogEntry)) func() + // BubbleTeaUI is a bubbletea-based terminal UI type BubbleTeaUI struct { mu sync.RWMutex @@ -62,10 +67,22 @@ type BubbleTeaUI struct { deleteConfirmAlias string deleteConfirmCursor int // 0 = Yes, 1 = No + // Benchmark state + benchmarkState *BenchmarkState + + // HTTP log viewing state + httpLogState *HTTPLogState + + // Log callback cleanup function + httpLogCleanup func() + // Dependencies for wizards discovery *k8s.Discovery mutator *config.Mutator configPath string + + // Manager for accessing workers + httpLogSubscriber HTTPLogSubscriber } // bubbletea model @@ -101,6 +118,14 @@ func (ui *BubbleTeaUI) SetWizardDependencies(discovery *k8s.Discovery, mutator * ui.configPath = configPath } +// SetHTTPLogSubscriber sets the function to subscribe to HTTP logs +func (ui *BubbleTeaUI) SetHTTPLogSubscriber(subscriber HTTPLogSubscriber) { + ui.mu.Lock() + defer ui.mu.Unlock() + + ui.httpLogSubscriber = subscriber +} + // SetUpdateAvailable sets the update notification to be displayed func (ui *BubbleTeaUI) SetUpdateAvailable(version, url string) { ui.mu.Lock() @@ -253,6 +278,10 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.handleAddWizardKeys(msg) case ViewModeRemoveWizard: return m.handleRemoveWizardKeys(msg) + case ViewModeBenchmark: + return m.handleBenchmarkKeys(msg) + case ViewModeHTTPLog: + return m.handleHTTPLogKeys(msg) } // Forward management messages (always update main view data) @@ -283,6 +312,15 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.ui.removeWizard = nil m.ui.mu.Unlock() return m, tea.ClearScreen + + case BenchmarkCompleteMsg: + return m.handleBenchmarkComplete(msg) + + case BenchmarkProgressMsg: + return m.handleBenchmarkProgress(msg) + + case HTTPLogEntryMsg: + return m.handleHTTPLogEntry(msg) } return m, nil @@ -323,6 +361,12 @@ func (m model) View() string { case ViewModeRemoveWizard: modal := m.renderRemoveWizard() return overlayContent(mainView, modal, termWidth, termHeight) + case ViewModeBenchmark: + modal := m.renderBenchmark() + return overlayContent(mainView, modal, termWidth, termHeight) + case ViewModeHTTPLog: + // HTTP Log is full-screen, don't overlay on main view + return m.renderHTTPLog() default: return mainView } @@ -340,36 +384,21 @@ func (m model) renderMainView() string { termHeight = 40 // Fallback } - // Styles - titleStyle := lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("220")). - Padding(0, 1) - - headerStyle := lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("220")) - - separatorStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("240")) - - selectedStyle := lipgloss.NewStyle(). - Background(lipgloss.Color("240")). - Foreground(lipgloss.Color("230")) - - disabledStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("240")) - - activeStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("46")) - - startingStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("220")) - - errorStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("196")) + // Color palette + headerColor := lipgloss.Color("220") // Yellow + activeColor := lipgloss.Color("46") // Green + warningColor := lipgloss.Color("220") // Yellow + errorColor := lipgloss.Color("196") // Red + mutedColor := lipgloss.Color("240") // Gray + selectedBg := lipgloss.Color("240") // Gray background + selectedFg := lipgloss.Color("230") // Light foreground // Title with version + titleStyle := lipgloss.NewStyle(). + Bold(true). + Foreground(headerColor). + Padding(0, 1) + title := fmt.Sprintf("kportal v%s - Port Forwarding Status", m.ui.version) b.WriteString(titleStyle.Render(title)) @@ -383,94 +412,104 @@ func (m model) renderMainView() string { } b.WriteString("\n\n") - // Header - header := fmt.Sprintf("%-15s %-18s %-20s %-10s %-21s %7s %7s %s", - "CONTEXT", "NAMESPACE", "ALIAS", "TYPE", "RESOURCE", "REMOTE", "LOCAL", "STATUS") - b.WriteString(headerStyle.Render(header)) - b.WriteString("\n") - b.WriteString(separatorStyle.Render(strings.Repeat("─", 120))) - b.WriteString("\n") - // No forwards if len(m.ui.forwardOrder) == 0 { - b.WriteString(disabledStyle.Render("\nNo forwards configured\n")) + disabledStyle := lipgloss.NewStyle().Foreground(mutedColor) + b.WriteString(disabledStyle.Render("No forwards configured\n")) } else { - // Display forwards - for idx, id := range m.ui.forwardOrder { + // Build table rows + var rows [][]string + for _, id := range m.ui.forwardOrder { fwd, ok := m.ui.forwards[id] if !ok { continue } - isSelected := (idx == m.ui.selectedIndex) isDisabled := m.ui.disabledMap[id] || fwd.Status == "Disabled" - // Selection indicator - indicator := " " - if isSelected { - indicator = "> " - } - // Status icon and text - statusIcon := "● " + statusIcon := "●" statusText := fwd.Status if isDisabled { - statusIcon = "○ " + statusIcon = "○" statusText = "Disabled" } else { switch fwd.Status { case "Starting": - statusIcon = "○ " + statusIcon = "○" case "Reconnecting": - statusIcon = "◐ " + statusIcon = "◐" case "Error": - statusIcon = "✗ " + statusIcon = "✗" } } - // Format row - row := fmt.Sprintf("%s%-15s %-18s %-20s %-10s %-21s %7d %7d %s%s", - indicator, - truncate(fwd.Context, 15), - truncate(fwd.Namespace, 18), - truncate(fwd.Alias, 20), - truncate(fwd.Type, 10), - truncate(fwd.Resource, 21), - fwd.RemotePort, - fwd.LocalPort, - statusIcon, - statusText) - - // Apply styling - if isSelected { - row = selectedStyle.Render(row) - } else if isDisabled { - row = disabledStyle.Render(row) - } else { - // Color the status part - switch fwd.Status { - case "Active": - parts := strings.Split(row, statusIcon) - if len(parts) == 2 { - row = parts[0] + activeStyle.Render(statusIcon+statusText) - } - case "Starting", "Reconnecting": - parts := strings.Split(row, statusIcon) - if len(parts) == 2 { - row = parts[0] + startingStyle.Render(statusIcon+statusText) - } - case "Error": - parts := strings.Split(row, statusIcon) - if len(parts) == 2 { - row = parts[0] + errorStyle.Render(statusIcon+statusText) - } - } - } - - b.WriteString(row) - b.WriteString("\n") + rows = append(rows, []string{ + truncate(fwd.Context, 14), + truncate(fwd.Namespace, 16), + truncate(fwd.Alias, 18), + truncate(fwd.Type, 8), + truncate(fwd.Resource, 20), + fmt.Sprintf("%d", fwd.RemotePort), + fmt.Sprintf("%d", fwd.LocalPort), + statusIcon + " " + statusText, + }) } + + // Create table with styling (no borders for cleaner look) + t := table.New(). + Border(lipgloss.HiddenBorder()). + Headers("CONTEXT", "NAMESPACE", "ALIAS", "TYPE", "RESOURCE", "REMOTE", "LOCAL", "STATUS"). + Rows(rows...). + StyleFunc(func(row, col int) lipgloss.Style { + // Header row + if row == table.HeaderRow { + return lipgloss.NewStyle(). + Bold(true). + Foreground(headerColor). + Padding(0, 1) + } + + // Get the forward for this row to check its status + baseStyle := lipgloss.NewStyle().Padding(0, 1) + + if row >= 0 && row < len(m.ui.forwardOrder) { + id := m.ui.forwardOrder[row] + fwd, ok := m.ui.forwards[id] + isSelected := row == m.ui.selectedIndex + isDisabled := m.ui.disabledMap[id] || (ok && fwd.Status == "Disabled") + + // Selected row gets background highlight + if isSelected { + return baseStyle. + Background(selectedBg). + Foreground(selectedFg) + } + + // Disabled rows are muted + if isDisabled { + return baseStyle.Foreground(mutedColor) + } + + // Status column gets colored based on status + if col == 7 && ok { // STATUS column + switch fwd.Status { + case "Active": + return baseStyle.Foreground(activeColor) + case "Starting", "Reconnecting": + return baseStyle.Foreground(warningColor) + case "Error": + return baseStyle.Foreground(errorColor) + } + } + } + + return baseStyle + }) + + b.WriteString(t.Render()) + b.WriteString("\n") } // Display errors if any (before footer) @@ -524,13 +563,15 @@ func (m model) renderMainView() string { footerStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("240")) keyStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("220")) - footer := fmt.Sprintf("%s/%s: Navigate %s: Toggle %s: New %s: Edit %s: Delete %s: Quit │ Total: %d", + footer := fmt.Sprintf("%s/%s: Navigate %s: Toggle %s: New %s: Edit %s: Delete %s: Bench %s: Logs %s: Quit │ Total: %d", keyStyle.Render("↑↓"), keyStyle.Render("jk"), keyStyle.Render("Space"), keyStyle.Render("n"), keyStyle.Render("e"), keyStyle.Render("d"), + keyStyle.Render("b"), + keyStyle.Render("l"), keyStyle.Render("q"), len(m.ui.forwardOrder)) diff --git a/internal/ui/wizard_commands.go b/internal/ui/wizard_commands.go index 1407215..6f10429 100644 --- a/internal/ui/wizard_commands.go +++ b/internal/ui/wizard_commands.go @@ -6,6 +6,7 @@ import ( "time" tea "github.com/charmbracelet/bubbletea" + "github.com/nvm/kportal/internal/benchmark" "github.com/nvm/kportal/internal/config" "github.com/nvm/kportal/internal/k8s" ) @@ -237,3 +238,77 @@ func removeForwardByIDCmd(mutator *config.Mutator, id string) tea.Cmd { } } } + +// BenchmarkCompleteMsg is sent when a benchmark run completes +type BenchmarkCompleteMsg struct { + ForwardID string + Results *benchmark.Results + Error error +} + +// BenchmarkProgressMsg is sent periodically during benchmark execution +type BenchmarkProgressMsg struct { + ForwardID string + Completed int + Total int +} + +// HTTPLogEntryMsg is sent when a new HTTP log entry is received +type HTTPLogEntryMsg struct { + Entry HTTPLogEntry +} + +// listenBenchmarkProgressCmd listens for progress updates from the benchmark +func listenBenchmarkProgressCmd(progressCh <-chan BenchmarkProgressMsg) tea.Cmd { + return func() tea.Msg { + msg, ok := <-progressCh + if !ok { + // Channel closed, benchmark complete + return nil + } + return msg + } +} + +// runBenchmarkCmd runs a benchmark against the given port forward +// It sends progress updates via tea.Batch until completion +func runBenchmarkCmd(forwardID string, localPort int, urlPath, method string, concurrency, requests int, progressCh chan<- BenchmarkProgressMsg) tea.Cmd { + return func() tea.Msg { + runner := benchmark.NewRunner() + + url := fmt.Sprintf("http://localhost:%d%s", localPort, urlPath) + cfg := benchmark.Config{ + URL: url, + Method: method, + Concurrency: concurrency, + Requests: requests, + Timeout: 30 * time.Second, + ProgressCallback: func(completed, total int) { + // Non-blocking send to progress channel + select { + case progressCh <- BenchmarkProgressMsg{ + ForwardID: forwardID, + Completed: completed, + Total: total, + }: + default: + // Drop if channel is full + } + }, + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + results, err := runner.Run(ctx, forwardID, cfg) + + // Close the progress channel when done + close(progressCh) + + return BenchmarkCompleteMsg{ + ForwardID: forwardID, + Results: results, + Error: err, + } + } +} diff --git a/internal/ui/wizard_handlers.go b/internal/ui/wizard_handlers.go index 14311d5..e2a8683 100644 --- a/internal/ui/wizard_handlers.go +++ b/internal/ui/wizard_handlers.go @@ -40,6 +40,12 @@ func (m model) handleMainViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "down", "j": m.ui.moveSelection(1) + case "pgup", "ctrl+u": + m.ui.moveSelection(-10) + + case "pgdown", "ctrl+d": + m.ui.moveSelection(10) + case " ", "enter": m.ui.toggleSelected() @@ -159,6 +165,75 @@ func (m model) handleMainViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.ui.deleteConfirmAlias = selectedForward.Alias m.ui.deleteConfirmCursor = 0 // Default to "No" for safety + m.ui.mu.Unlock() + return m, nil + + case "b": // Benchmark selected forward + m.ui.mu.Lock() + + if len(m.ui.forwardOrder) == 0 { + m.ui.mu.Unlock() + return m, nil + } + + currentSelectedIndex := m.ui.selectedIndex + if currentSelectedIndex < 0 || currentSelectedIndex >= len(m.ui.forwardOrder) { + m.ui.mu.Unlock() + return m, nil + } + + selectedID := m.ui.forwardOrder[currentSelectedIndex] + selectedForward, ok := m.ui.forwards[selectedID] + if !ok { + m.ui.mu.Unlock() + return m, nil + } + + // Create benchmark state + m.ui.viewMode = ViewModeBenchmark + m.ui.benchmarkState = newBenchmarkState(selectedID, selectedForward.Alias, selectedForward.LocalPort) + // Initialize textInput with the first field's value + m.ui.benchmarkState.textInput = m.ui.benchmarkState.urlPath + + m.ui.mu.Unlock() + return m, nil + + case "l": // View HTTP logs for selected forward + m.ui.mu.Lock() + + if len(m.ui.forwardOrder) == 0 { + m.ui.mu.Unlock() + return m, nil + } + + currentSelectedIndex := m.ui.selectedIndex + if currentSelectedIndex < 0 || currentSelectedIndex >= len(m.ui.forwardOrder) { + m.ui.mu.Unlock() + return m, nil + } + + selectedID := m.ui.forwardOrder[currentSelectedIndex] + selectedForward, ok := m.ui.forwards[selectedID] + if !ok { + m.ui.mu.Unlock() + return m, nil + } + + // Create HTTP log state + m.ui.viewMode = ViewModeHTTPLog + m.ui.httpLogState = newHTTPLogState(selectedID, selectedForward.Alias) + + // Subscribe to HTTP logs if subscriber is available + if m.ui.httpLogSubscriber != nil { + cleanup := m.ui.httpLogSubscriber(selectedID, func(entry HTTPLogEntry) { + // Add entry to state (thread-safe via Send) + if m.ui.program != nil { + m.ui.program.Send(HTTPLogEntryMsg{Entry: entry}) + } + }) + m.ui.httpLogCleanup = cleanup + } + m.ui.mu.Unlock() return m, nil } @@ -290,6 +365,14 @@ func (m model) handleAddWizardKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { wizard.moveCursor(1) } + case "pgup", "ctrl+u": + // Page up - move 10 items + wizard.moveCursor(-10) + + case "pgdown", "ctrl+d": + // Page down - move 10 items + wizard.moveCursor(10) + case "tab": // Tab moves between alias field and buttons in confirmation if wizard.step == StepConfirmation { @@ -609,6 +692,12 @@ func (m model) handleRemoveWizardKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "down", "j": wizard.moveCursor(1) + case "pgup", "ctrl+u": + wizard.moveCursor(-10) + + case "pgdown", "ctrl+d": + wizard.moveCursor(10) + case " ": if !wizard.confirming { wizard.toggleSelection() @@ -824,3 +913,360 @@ func (m model) handleForwardsRemoved(msg ForwardsRemovedMsg) (tea.Model, tea.Cmd return m, tea.ClearScreen } + +// handleBenchmarkKeys handles keyboard input in the benchmark view +func (m model) handleBenchmarkKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + m.ui.mu.Lock() + defer m.ui.mu.Unlock() + + state := m.ui.benchmarkState + if state == nil { + return m, nil + } + + switch msg.String() { + case "ctrl+c", "esc": + // Cancel and return to main view + m.ui.viewMode = ViewModeMain + m.ui.benchmarkState = nil + return m, tea.ClearScreen + + case "up", "k": + if state.step == BenchmarkStepConfig && state.cursor > 0 { + state.cursor-- + // Load current field value into textInput + state.textInput = m.getBenchmarkFieldValue(state.cursor) + } + + case "down", "j": + if state.step == BenchmarkStepConfig && state.cursor < 3 { + state.cursor++ + // Load current field value into textInput + state.textInput = m.getBenchmarkFieldValue(state.cursor) + } + + case "tab": + // Tab also cycles through fields + if state.step == BenchmarkStepConfig { + state.cursor = (state.cursor + 1) % 4 + state.textInput = m.getBenchmarkFieldValue(state.cursor) + } + + case "enter": + switch state.step { + case BenchmarkStepConfig: + // Start running the benchmark + state.step = BenchmarkStepRunning + state.running = true + state.progress = 0 + state.total = state.requests + // Create progress channel with buffer for non-blocking sends + state.progressCh = make(chan BenchmarkProgressMsg, 10) + // Return batch command to run benchmark and listen for progress + return m, tea.Batch( + runBenchmarkCmd(state.forwardID, state.localPort, state.urlPath, state.method, state.concurrency, state.requests, state.progressCh), + listenBenchmarkProgressCmd(state.progressCh), + ) + case BenchmarkStepResults: + // Return to main view + m.ui.viewMode = ViewModeMain + m.ui.benchmarkState = nil + return m, tea.ClearScreen + } + + case "backspace": + if state.step == BenchmarkStepConfig { + if len(state.textInput) > 0 { + state.textInput = state.textInput[:len(state.textInput)-1] + m.applyBenchmarkTextInput() + } + } + + default: + // Handle text input in config step + if state.step == BenchmarkStepConfig && len(msg.String()) == 1 { + char := rune(msg.String()[0]) + if char >= 32 && char < 127 { + state.textInput += string(char) + m.applyBenchmarkTextInput() + } + } + } + + return m, nil +} + +// getBenchmarkFieldValue returns the current value of the selected benchmark field +func (m model) getBenchmarkFieldValue(cursor int) string { + state := m.ui.benchmarkState + if state == nil { + return "" + } + + switch cursor { + case 0: + return state.urlPath + case 1: + return state.method + case 2: + return fmt.Sprintf("%d", state.concurrency) + case 3: + return fmt.Sprintf("%d", state.requests) + default: + return "" + } +} + +// applyBenchmarkTextInput applies the current text input to the selected field +func (m model) applyBenchmarkTextInput() { + state := m.ui.benchmarkState + if state == nil { + return + } + + switch state.cursor { + case 0: // URL path + state.urlPath = state.textInput + case 1: // Method + state.method = strings.ToUpper(state.textInput) + case 2: // Concurrency + if val, err := strconv.Atoi(state.textInput); err == nil && val > 0 { + state.concurrency = val + // Cap concurrency at requests + if state.concurrency > state.requests { + state.concurrency = state.requests + } + } + case 3: // Requests + if val, err := strconv.Atoi(state.textInput); err == nil && val > 0 { + state.requests = val + // Cap concurrency at requests + if state.concurrency > state.requests { + state.concurrency = state.requests + } + } + } +} + +// handleHTTPLogKeys handles keyboard input in the HTTP log view +func (m model) handleHTTPLogKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + m.ui.mu.Lock() + defer m.ui.mu.Unlock() + + state := m.ui.httpLogState + if state == nil { + return m, nil + } + + // If filter input is active, handle text input + if state.filterActive { + switch msg.String() { + case "esc": + // Cancel filter input, clear text + state.filterActive = false + state.filterText = "" + state.cursor = 0 + state.scrollOffset = 0 + return m, nil + case "enter": + // Confirm filter + state.filterActive = false + state.cursor = 0 + state.scrollOffset = 0 + return m, nil + case "backspace": + if len(state.filterText) > 0 { + state.filterText = state.filterText[:len(state.filterText)-1] + } + return m, nil + default: + // Add character to filter + if len(msg.String()) == 1 { + char := rune(msg.String()[0]) + if char >= 32 && char < 127 { + state.filterText += string(char) + state.cursor = 0 + state.scrollOffset = 0 + } + } + return m, nil + } + } + + filteredEntries := state.getFilteredEntries() + + switch msg.String() { + case "ctrl+c", "esc", "q": + // Cleanup subscription before closing + if m.ui.httpLogCleanup != nil { + m.ui.httpLogCleanup() + m.ui.httpLogCleanup = nil + } + // Return to main view + m.ui.viewMode = ViewModeMain + m.ui.httpLogState = nil + return m, tea.ClearScreen + + case "up", "k": + if state.cursor > 0 { + state.cursor-- + state.autoScroll = false + } + + case "down", "j": + if state.cursor < len(filteredEntries)-1 { + state.cursor++ + } + // If at bottom, enable auto-scroll + if state.cursor >= len(filteredEntries)-1 { + state.autoScroll = true + } + + case "pgup", "ctrl+u": + // Page up - move 20 entries + state.cursor -= 20 + if state.cursor < 0 { + state.cursor = 0 + } + state.autoScroll = false + + case "pgdown", "ctrl+d": + // Page down - move 20 entries + state.cursor += 20 + if state.cursor >= len(filteredEntries) { + state.cursor = len(filteredEntries) - 1 + } + if state.cursor < 0 { + state.cursor = 0 + } + // If at bottom, enable auto-scroll + if state.cursor >= len(filteredEntries)-1 { + state.autoScroll = true + } + + case "g": + // Go to top + state.cursor = 0 + state.scrollOffset = 0 + state.autoScroll = false + + case "G": + // Go to bottom + if len(filteredEntries) > 0 { + state.cursor = len(filteredEntries) - 1 + state.autoScroll = true + } + + case "a": + // Toggle auto-scroll + state.autoScroll = !state.autoScroll + + case "f": + // Cycle filter mode + state.cycleFilterMode() + + case "/": + // Enter text filter mode + state.filterActive = true + state.filterText = "" + + case "c": + // Clear all filters + state.filterMode = HTTPLogFilterNone + state.filterText = "" + state.cursor = 0 + state.scrollOffset = 0 + } + + return m, nil +} + +// handleHTTPLogEntry handles incoming HTTP log entries +func (m model) handleHTTPLogEntry(msg HTTPLogEntryMsg) (tea.Model, tea.Cmd) { + m.ui.mu.Lock() + defer m.ui.mu.Unlock() + + if m.ui.httpLogState == nil { + return m, nil + } + + state := m.ui.httpLogState + state.entries = append(state.entries, msg.Entry) + + // Cap entries to prevent memory growth (keep last 10000 entries) + const maxEntries = 10000 + if len(state.entries) > maxEntries { + // Remove oldest entries + state.entries = state.entries[len(state.entries)-maxEntries:] + // Adjust cursor if needed + if state.cursor >= len(state.entries) { + state.cursor = len(state.entries) - 1 + } + } + + // Auto-scroll to bottom if enabled + if state.autoScroll && len(state.entries) > 0 { + state.cursor = len(state.entries) - 1 + } + + return m, nil +} + +// handleBenchmarkProgress handles progress updates during benchmark execution +func (m model) handleBenchmarkProgress(msg BenchmarkProgressMsg) (tea.Model, tea.Cmd) { + m.ui.mu.Lock() + defer m.ui.mu.Unlock() + + if m.ui.benchmarkState == nil || !m.ui.benchmarkState.running { + return m, nil + } + + state := m.ui.benchmarkState + state.progress = msg.Completed + state.total = msg.Total + + // Continue listening for more progress updates + if state.progressCh != nil { + return m, listenBenchmarkProgressCmd(state.progressCh) + } + + return m, nil +} + +// handleBenchmarkComplete handles the benchmark completion message +func (m model) handleBenchmarkComplete(msg BenchmarkCompleteMsg) (tea.Model, tea.Cmd) { + m.ui.mu.Lock() + defer m.ui.mu.Unlock() + + if m.ui.benchmarkState == nil { + return m, nil + } + + state := m.ui.benchmarkState + state.running = false + state.step = BenchmarkStepResults + state.progressCh = nil // Clear progress channel since benchmark is complete + + if msg.Error != nil { + state.error = msg.Error + state.results = nil + } else if msg.Results != nil { + stats := msg.Results.CalculateStats() + state.results = &BenchmarkResults{ + TotalRequests: msg.Results.TotalRequests, + Successful: msg.Results.Successful, + Failed: msg.Results.Failed, + MinLatency: float64(stats.MinLatency.Milliseconds()), + MaxLatency: float64(stats.MaxLatency.Milliseconds()), + AvgLatency: float64(stats.AvgLatency.Milliseconds()), + P50Latency: float64(stats.P50Latency.Milliseconds()), + P95Latency: float64(stats.P95Latency.Milliseconds()), + P99Latency: float64(stats.P99Latency.Milliseconds()), + Throughput: stats.Throughput, + BytesRead: msg.Results.BytesRead, + StatusCodes: msg.Results.StatusCodes, + } + } + + return m, nil +} diff --git a/internal/ui/wizard_state.go b/internal/ui/wizard_state.go index 9163a72..8b7b5f1 100644 --- a/internal/ui/wizard_state.go +++ b/internal/ui/wizard_state.go @@ -36,6 +36,8 @@ const ( ViewModeMain ViewMode = iota ViewModeAddWizard ViewModeRemoveWizard + ViewModeBenchmark + ViewModeHTTPLog ) // InputMode represents whether the wizard is in list selection or text input mode @@ -373,3 +375,179 @@ func (w *AddWizardState) resetInput() { w.scrollOffset = 0 w.error = nil } + +// BenchmarkStep represents the current step in the benchmark wizard +type BenchmarkStep int + +const ( + BenchmarkStepConfig BenchmarkStep = iota + BenchmarkStepRunning + BenchmarkStepResults +) + +// BenchmarkState maintains the state for the benchmark wizard +type BenchmarkState struct { + step BenchmarkStep + forwardID string + forwardAlias string + localPort int + + // Configuration + urlPath string + method string + concurrency int + requests int + cursor int // Current field being edited + textInput string + + // Running state + running bool + progress int + total int + progressCh chan BenchmarkProgressMsg // Channel for progress updates + + // Results + results *BenchmarkResults + error error +} + +// BenchmarkResults holds benchmark results for display +type BenchmarkResults struct { + TotalRequests int + Successful int + Failed int + MinLatency float64 // milliseconds + MaxLatency float64 + AvgLatency float64 + P50Latency float64 + P95Latency float64 + P99Latency float64 + Throughput float64 // requests per second + BytesRead int64 + StatusCodes map[int]int +} + +// newBenchmarkState creates a new benchmark state for a forward +func newBenchmarkState(forwardID, alias string, localPort int) *BenchmarkState { + return &BenchmarkState{ + step: BenchmarkStepConfig, + forwardID: forwardID, + forwardAlias: alias, + localPort: localPort, + urlPath: "/", + method: "GET", + concurrency: 10, + requests: 100, + cursor: 0, + } +} + +// HTTPLogFilterMode represents the active filter type +type HTTPLogFilterMode int + +const ( + HTTPLogFilterNone HTTPLogFilterMode = iota + HTTPLogFilterText + HTTPLogFilterNon200 + HTTPLogFilterErrors // 4xx and 5xx only +) + +// HTTPLogState maintains the state for HTTP log viewing +type HTTPLogState struct { + forwardID string + forwardAlias string + entries []HTTPLogEntry + cursor int + scrollOffset int + autoScroll bool + + // Filtering + filterMode HTTPLogFilterMode + filterText string + filterActive bool // true when typing in filter input +} + +// HTTPLogEntry represents a single HTTP log entry for display +type HTTPLogEntry struct { + Timestamp string + Direction string + Method string + Path string + StatusCode int + LatencyMs int64 + BodySize int +} + +// newHTTPLogState creates a new HTTP log viewing state +func newHTTPLogState(forwardID, alias string) *HTTPLogState { + return &HTTPLogState{ + forwardID: forwardID, + forwardAlias: alias, + entries: make([]HTTPLogEntry, 0), + autoScroll: true, + filterMode: HTTPLogFilterNone, + } +} + +// getFilteredEntries returns entries matching the current filter +// Only returns entries with status codes (responses) since requests don't have useful info +func (s *HTTPLogState) getFilteredEntries() []HTTPLogEntry { + filtered := make([]HTTPLogEntry, 0, len(s.entries)) + filterLower := strings.ToLower(s.filterText) + + for _, entry := range s.entries { + // Only show entries with status codes (completed responses) + // Requests, streaming connections, and errors without status are filtered out + if entry.StatusCode == 0 { + continue + } + + // Apply filter mode + switch s.filterMode { + case HTTPLogFilterNon200: + if entry.StatusCode >= 200 && entry.StatusCode < 300 { + continue + } + case HTTPLogFilterErrors: + if entry.StatusCode < 400 { + continue + } + } + + // Apply text filter + if s.filterText != "" { + matchPath := strings.Contains(strings.ToLower(entry.Path), filterLower) + matchMethod := strings.Contains(strings.ToLower(entry.Method), filterLower) + if !matchPath && !matchMethod { + continue + } + } + + filtered = append(filtered, entry) + } + + return filtered +} + +// cycleFilterMode cycles through filter modes +func (s *HTTPLogState) cycleFilterMode() { + s.filterMode = (s.filterMode + 1) % 4 + s.cursor = 0 + s.scrollOffset = 0 +} + +// getFilterModeLabel returns a label for the current filter mode +func (s *HTTPLogState) getFilterModeLabel() string { + switch s.filterMode { + case HTTPLogFilterNone: + return "All" + case HTTPLogFilterText: + return "Text" + case HTTPLogFilterNon200: + return "Non-2xx" + case HTTPLogFilterErrors: + return "Errors (4xx/5xx)" + default: + return "All" + } +} diff --git a/internal/ui/wizard_styles.go b/internal/ui/wizard_styles.go index 70fed1e..ecfe2e3 100644 --- a/internal/ui/wizard_styles.go +++ b/internal/ui/wizard_styles.go @@ -59,6 +59,10 @@ var ( spinnerStyle = lipgloss.NewStyle(). Foreground(accentColor). Bold(true) + + accentStyle = lipgloss.NewStyle(). + Foreground(accentColor). + Bold(true) ) // Input styles @@ -82,11 +86,11 @@ var ( // Container styles var ( + // wizardBoxStyle creates a bordered modal box wizardBoxStyle = lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(accentColor). - Padding(1, 2). - Width(60) + Padding(1, 2) ) // Helper functions for rendering @@ -166,46 +170,17 @@ func renderTextInput(label, value string, valid bool) string { } // overlayContent overlays modal content centered on the base view -func overlayContent(base, modal string, termWidth, termHeight int) string { - baseLines := strings.Split(base, "\n") - modalLines := strings.Split(modal, "\n") - - // Ensure base has enough lines - for len(baseLines) < termHeight { - baseLines = append(baseLines, "") - } - - modalHeight := len(modalLines) - modalWidth := 0 - for _, line := range modalLines { - w := lipgloss.Width(line) - if w > modalWidth { - modalWidth = w - } - } - - // Calculate center position - startRow := (termHeight - modalHeight) / 2 - if startRow < 0 { - startRow = 0 - } - - // Create result with modal overlaid - result := make([]string, len(baseLines)) - copy(result, baseLines) - - for i, modalLine := range modalLines { - row := startRow + i - if row >= 0 && row < len(result) { - // Center the modal line - padding := (termWidth - lipgloss.Width(modalLine)) / 2 - if padding < 0 { - padding = 0 - } - - result[row] = strings.Repeat(" ", padding) + modalLine - } - } - - return strings.Join(result, "\n") +// Note: base parameter is kept for API compatibility but not used since +// lipgloss.Place provides cleaner centering without background artifacts +func overlayContent(_, modal string, termWidth, termHeight int) string { + // Use lipgloss.Place to center the modal in the terminal viewport + // This handles all alignment properly and respects ANSI styling + return lipgloss.Place( + termWidth, + termHeight, + lipgloss.Center, + lipgloss.Center, + modal, + lipgloss.WithWhitespaceChars(" "), + ) } diff --git a/internal/ui/wizard_views.go b/internal/ui/wizard_views.go index 3dced89..a4a5219 100644 --- a/internal/ui/wizard_views.go +++ b/internal/ui/wizard_views.go @@ -325,11 +325,13 @@ func (m model) renderEnterRemotePort() string { if wizard.selector != "" { resourceInfo = fmt.Sprintf("%s [%s]", wizard.resourceValue, wizard.selector) } - b.WriteString(mutedStyle.Render(fmt.Sprintf("Resource: %s\n\n", resourceInfo))) + b.WriteString(mutedStyle.Render(fmt.Sprintf("Resource: %s", resourceInfo))) + b.WriteString("\n\n") // If we have detected ports and in list mode, show them as a list if len(wizard.detectedPorts) > 0 && wizard.inputMode == InputModeList { - b.WriteString("Select remote port:\n\n") + b.WriteString("Select remote port:") + b.WriteString("\n\n") const viewportHeight = 20 totalItems := len(wizard.detectedPorts) + 1 // +1 for manual entry option @@ -446,8 +448,10 @@ func (m model) renderEnterLocalPort() string { if wizard.selector != "" { resourceInfo = fmt.Sprintf("%s [%s]", wizard.resourceValue, wizard.selector) } - b.WriteString(mutedStyle.Render(fmt.Sprintf("Resource: %s\n", resourceInfo))) - b.WriteString(mutedStyle.Render(fmt.Sprintf("Remote port: %d\n\n", wizard.remotePort))) + b.WriteString(mutedStyle.Render(fmt.Sprintf("Resource: %s", resourceInfo))) + b.WriteString("\n") + b.WriteString(mutedStyle.Render(fmt.Sprintf("Remote port: %d", wizard.remotePort))) + b.WriteString("\n\n") b.WriteString(renderTextInput("Local port: ", wizard.textInput, wizard.error == nil)) b.WriteString("\n\n") @@ -670,3 +674,399 @@ func (m model) renderRemoveConfirmation() string { return b.String() } + +// renderBenchmark renders the benchmark wizard +func (m model) renderBenchmark() string { + if m.ui.benchmarkState == nil { + return "" + } + + state := m.ui.benchmarkState + + var content string + switch state.step { + case BenchmarkStepConfig: + content = m.renderBenchmarkConfig() + case BenchmarkStepRunning: + content = m.renderBenchmarkRunning() + case BenchmarkStepResults: + content = m.renderBenchmarkResults() + default: + content = "Unknown step" + } + + return wizardBoxStyle.Render(content) +} + +func (m model) renderBenchmarkConfig() string { + state := m.ui.benchmarkState + var b strings.Builder + + b.WriteString(renderHeader("HTTP Benchmark", "")) + b.WriteString(fmt.Sprintf("Target: %s (localhost:%d)", breadcrumbStyle.Render(state.forwardAlias), state.localPort)) + b.WriteString("\n\n") + + b.WriteString("Configure benchmark parameters:") + b.WriteString("\n\n") + + fields := []struct { + label string + value string + }{ + {"URL Path", state.urlPath}, + {"Method", state.method}, + {"Concurrency", fmt.Sprintf("%d", state.concurrency)}, + {"Requests", fmt.Sprintf("%d", state.requests)}, + } + + for i, field := range fields { + prefix := " " + if i == state.cursor { + prefix = "▸ " + b.WriteString(selectedStyle.Render(fmt.Sprintf("%s%-12s", prefix, field.label+":"))) + b.WriteString(validInputStyle.Render(field.value + "█")) + } else { + b.WriteString(fmt.Sprintf("%s%-12s %s", prefix, field.label+":", field.value)) + } + b.WriteString("\n") + } + + b.WriteString("\n") + b.WriteString(mutedStyle.Render(fmt.Sprintf("Will send %d requests with %d concurrent workers", state.requests, state.concurrency))) + b.WriteString("\n\n") + b.WriteString(helpStyle.Render("↑/↓/Tab: Navigate Type to edit Enter: Run Esc: Cancel")) + + return b.String() +} + +func (m model) renderBenchmarkRunning() string { + state := m.ui.benchmarkState + var b strings.Builder + + b.WriteString(renderHeader("HTTP Benchmark", "")) + b.WriteString(fmt.Sprintf("Target: %s", breadcrumbStyle.Render(state.forwardAlias))) + b.WriteString("\n\n") + + // Progress bar + progress := float64(state.progress) / float64(state.total) + if state.total == 0 { + progress = 0 + } + barWidth := 30 + filled := int(progress * float64(barWidth)) + if filled > barWidth { + filled = barWidth + } + + bar := strings.Repeat("█", filled) + strings.Repeat("░", barWidth-filled) + percent := int(progress * 100) + + b.WriteString(spinnerStyle.Render("Running benchmark...")) + b.WriteString("\n\n") + + b.WriteString(fmt.Sprintf(" [%s] %d%%", successStyle.Render(bar), percent)) + b.WriteString("\n") + b.WriteString(mutedStyle.Render(fmt.Sprintf(" %d / %d requests completed", state.progress, state.total))) + b.WriteString("\n\n") + + b.WriteString(mutedStyle.Render(fmt.Sprintf("URL: http://localhost:%d%s", state.localPort, state.urlPath))) + b.WriteString("\n") + b.WriteString(mutedStyle.Render(fmt.Sprintf("Method: %s Concurrency: %d", state.method, state.concurrency))) + b.WriteString("\n\n") + + b.WriteString(helpStyle.Render("Please wait...")) + + return b.String() +} + +func (m model) renderBenchmarkResults() string { + state := m.ui.benchmarkState + var b strings.Builder + + b.WriteString(renderHeader("Benchmark Results", "")) + b.WriteString(fmt.Sprintf("Target: %s", breadcrumbStyle.Render(state.forwardAlias))) + b.WriteString("\n\n") + + if state.error != nil { + b.WriteString(errorStyle.Render(fmt.Sprintf("✗ Error: %v", state.error))) + b.WriteString("\n\n") + b.WriteString(helpStyle.Render("Press Enter or Esc to return")) + return b.String() + } + + if state.results == nil { + b.WriteString(mutedStyle.Render("No results available")) + b.WriteString("\n\n") + b.WriteString(helpStyle.Render("Press Enter or Esc to return")) + return b.String() + } + + r := state.results + + // Summary + successRate := float64(r.Successful) / float64(r.TotalRequests) * 100 + if r.TotalRequests == 0 { + successRate = 0 + } + + b.WriteString(fmt.Sprintf("Total Requests: %d", r.TotalRequests)) + b.WriteString("\n") + if r.Failed == 0 { + b.WriteString(successStyle.Render(fmt.Sprintf("Successful: %d (%.1f%%)", r.Successful, successRate))) + } else { + b.WriteString(fmt.Sprintf("Successful: %d (%.1f%%)", r.Successful, successRate)) + } + b.WriteString("\n") + if r.Failed > 0 { + b.WriteString(errorStyle.Render(fmt.Sprintf("Failed: %d", r.Failed))) + } else { + b.WriteString(fmt.Sprintf("Failed: %d", r.Failed)) + } + b.WriteString("\n\n") + + // Latency stats + b.WriteString(breadcrumbStyle.Render("Latency (ms)")) + b.WriteString("\n") + b.WriteString(fmt.Sprintf(" Min: %.2f", r.MinLatency)) + b.WriteString("\n") + b.WriteString(fmt.Sprintf(" Max: %.2f", r.MaxLatency)) + b.WriteString("\n") + b.WriteString(fmt.Sprintf(" Avg: %.2f", r.AvgLatency)) + b.WriteString("\n") + b.WriteString(fmt.Sprintf(" P50: %.2f", r.P50Latency)) + b.WriteString("\n") + b.WriteString(fmt.Sprintf(" P95: %.2f", r.P95Latency)) + b.WriteString("\n") + b.WriteString(fmt.Sprintf(" P99: %.2f", r.P99Latency)) + b.WriteString("\n\n") + + // Throughput + b.WriteString(breadcrumbStyle.Render("Throughput")) + b.WriteString("\n") + b.WriteString(fmt.Sprintf(" Requests/sec: %.2f", r.Throughput)) + b.WriteString("\n") + b.WriteString(fmt.Sprintf(" Bytes read: %d", r.BytesRead)) + b.WriteString("\n") + + // Status codes if interesting + if len(r.StatusCodes) > 0 { + b.WriteString("\n") + b.WriteString(breadcrumbStyle.Render("Status Codes")) + b.WriteString("\n") + for code, count := range r.StatusCodes { + if code >= 200 && code < 300 { + b.WriteString(successStyle.Render(fmt.Sprintf(" %d: %d", code, count))) + } else if code >= 400 { + b.WriteString(errorStyle.Render(fmt.Sprintf(" %d: %d", code, count))) + } else { + b.WriteString(fmt.Sprintf(" %d: %d", code, count)) + } + b.WriteString("\n") + } + } + + b.WriteString("\n") + b.WriteString(helpStyle.Render("Press Enter or Esc to return")) + + return b.String() +} + +// renderHTTPLog renders the HTTP log viewer as a full-screen table +func (m model) renderHTTPLog() string { + if m.ui.httpLogState == nil { + return "" + } + + state := m.ui.httpLogState + + // Get terminal dimensions + termWidth := m.termWidth + termHeight := m.termHeight + if termWidth == 0 { + termWidth = 120 + } + if termHeight == 0 { + termHeight = 40 + } + + // Get filtered entries + filteredEntries := state.getFilteredEntries() + totalEntries := len(filteredEntries) + totalUnfiltered := len(state.entries) + + // Build output + var b strings.Builder + + // Header line + title := wizardHeaderStyle.Render("HTTP Traffic Log") + b.WriteString(title) + b.WriteString(" ") + b.WriteString(breadcrumbStyle.Render(state.forwardAlias)) + + // Status indicators + b.WriteString(" ") + filterLabel := state.getFilterModeLabel() + if state.filterMode != HTTPLogFilterNone { + b.WriteString(accentStyle.Render(fmt.Sprintf("[Filter: %s]", filterLabel))) + } + if state.filterText != "" { + b.WriteString(" ") + b.WriteString(accentStyle.Render(fmt.Sprintf("[Search: \"%s\"]", state.filterText))) + } + if state.autoScroll { + b.WriteString(" ") + b.WriteString(successStyle.Render("[Auto-scroll]")) + } + b.WriteString("\n") + + // Filter input line (if active) + if state.filterActive { + b.WriteString(accentStyle.Render("Search: ")) + b.WriteString(state.filterText) + b.WriteString(accentStyle.Render("_")) + b.WriteString("\n") + } + + // Table or empty message + if totalEntries == 0 { + b.WriteString("\n") + if totalUnfiltered == 0 { + b.WriteString(mutedStyle.Render(" No HTTP traffic logged yet.\n")) + b.WriteString(mutedStyle.Render(" Enable with: httpLog: true in .kportal.yaml\n")) + } else { + b.WriteString(mutedStyle.Render(fmt.Sprintf(" No entries match filter. (%d total entries)\n", totalUnfiltered))) + b.WriteString(mutedStyle.Render(" Press 'c' to clear filters.\n")) + } + // Pad to fill screen + for i := 0; i < termHeight-10; i++ { + b.WriteString("\n") + } + } else { + // Render simple table without lipgloss table (for better control) + b.WriteString("\n") + + // Header + header := fmt.Sprintf(" %-10s %-7s %-6s %-8s %s", + "TIME", "METHOD", "STATUS", "LATENCY", "PATH") + b.WriteString(mutedStyle.Render(header)) + b.WriteString("\n") + b.WriteString(mutedStyle.Render(strings.Repeat("─", termWidth-2))) + b.WriteString("\n") + + // Calculate visible range + viewportHeight := termHeight - 8 // header, filter bar, table header, separator, footer, help + if viewportHeight < 5 { + viewportHeight = 5 + } + + // Ensure cursor is in valid range + if state.cursor < 0 { + state.cursor = 0 + } + if state.cursor >= totalEntries { + state.cursor = totalEntries - 1 + } + + // Calculate scroll offset to keep cursor visible + if state.cursor < state.scrollOffset { + state.scrollOffset = state.cursor + } + if state.cursor >= state.scrollOffset+viewportHeight { + state.scrollOffset = state.cursor - viewportHeight + 1 + } + if state.scrollOffset < 0 { + state.scrollOffset = 0 + } + + start := state.scrollOffset + end := start + viewportHeight + if end > totalEntries { + end = totalEntries + } + + // Calculate max path width + maxPathWidth := termWidth - 48 + if maxPathWidth < 10 { + maxPathWidth = 10 + } + + for i := start; i < end; i++ { + entry := filteredEntries[i] + + // Format fields + statusStr := "" + if entry.StatusCode > 0 { + statusStr = fmt.Sprintf("%d", entry.StatusCode) + } + + latencyStr := "" + if entry.LatencyMs > 0 { + if entry.LatencyMs >= 1000 { + latencyStr = fmt.Sprintf("%.1fs", float64(entry.LatencyMs)/1000) + } else { + latencyStr = fmt.Sprintf("%dms", entry.LatencyMs) + } + } + + // Truncate path + path := entry.Path + if len(path) > maxPathWidth { + path = path[:maxPathWidth-3] + "..." + } + + // Build line + line := fmt.Sprintf("%-10s %-7s %-6s %-8s %s", + entry.Timestamp, + entry.Method, + statusStr, + latencyStr, + path) + + // Selection prefix + prefix := " " + if i == state.cursor { + prefix = "▸ " + } + + // Apply color based on status + // 200s = normal text, 400s = warning (orange), 500s = error (red) + var styledLine string + if entry.StatusCode >= 500 { + styledLine = errorStyle.Render(line) + } else if entry.StatusCode >= 400 { + styledLine = warningStyle.Render(line) + } else { + // 200s and other codes - normal text color + styledLine = line + } + + if i == state.cursor { + b.WriteString(selectedStyle.Render(prefix)) + } else { + b.WriteString(prefix) + } + b.WriteString(styledLine) + b.WriteString("\n") + } + + // Pad remaining lines + linesRendered := end - start + for i := linesRendered; i < viewportHeight; i++ { + b.WriteString("\n") + } + } + + // Footer with entry count + b.WriteString("\n") + if totalEntries != totalUnfiltered { + b.WriteString(mutedStyle.Render(fmt.Sprintf(" %d of %d entries (filtered from %d)", totalEntries, totalEntries, totalUnfiltered))) + } else { + b.WriteString(mutedStyle.Render(fmt.Sprintf(" %d entries", totalEntries))) + } + b.WriteString("\n") + + // Help line at bottom + b.WriteString(helpStyle.Render(" ↑/↓/PgUp/PgDn: Navigate g/G: Top/Bottom a: Auto-scroll f: Filter /: Search c: Clear q: Close")) + + return b.String() +}