diff --git a/README.md b/README.md index 664ffb5..a3d1b22 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,16 @@ -# kportal +

+ kportal logo +

-[![Release](https://img.shields.io/github/v/release/lukaszraczylo/kportal)](https://github.com/lukaszraczylo/kportal/releases) -[![License](https://img.shields.io/github/license/lukaszraczylo/kportal)](LICENSE) -[![Go Report Card](https://goreportcard.com/badge/github.com/lukaszraczylo/kportal)](https://goreportcard.com/report/github.com/lukaszraczylo/kportal) +

+ Release + License + Go Report Card +

-**Modern Kubernetes port-forward manager with interactive terminal UI** +

+ Modern Kubernetes port-forward manager with interactive terminal UI +

kportal simplifies managing multiple Kubernetes port-forwards with an elegant, interactive terminal interface. Built with [Bubble Tea](https://github.com/charmbracelet/bubbletea), it provides real-time status updates, automatic reconnection, and hot-reload configuration support. @@ -13,6 +19,9 @@ kportal simplifies managing multiple Kubernetes port-forwards with an elegant, i ## ✨ Features - šŸŽÆ **Interactive TUI** - Beautiful terminal interface with keyboard navigation (↑↓/jk, Space to toggle, q to quit) +- āž• **Live Add** - Add new port-forwards on-the-fly without editing config files or restarting +- āœļø **Live Edit** - Modify existing port-forwards (ports, resources, aliases) in real-time +- šŸ—‘ļø **Live Delete** - Remove port-forwards instantly from the running session - šŸ”„ **Auto-Reconnect** - Automatic retry with exponential backoff on connection failures (max 10s) - ⚔ **Hot-Reload** - Update configuration without restarting - changes applied automatically - šŸ„ **Health Checks** - Real-time port forward status monitoring with 5-second intervals @@ -93,6 +102,9 @@ kportal 3. **Navigate the interface**: - `↑↓` or `j/k` - Navigate through forwards - `Space` or `Enter` - Toggle forward on/off + - `a` - Add new port-forward interactively + - `e` - Edit selected port-forward + - `d` - Delete selected port-forward - `q` - Quit application ## šŸ“– Configuration diff --git a/WIZARD_USAGE.md b/WIZARD_USAGE.md new file mode 100644 index 0000000..7ab34ee --- /dev/null +++ b/WIZARD_USAGE.md @@ -0,0 +1,171 @@ +# Interactive Add/Remove Wizards + +kportal now includes interactive wizards for adding and removing port forwards directly from the running UI! + +## Quick Start + +Run kportal normally: +```bash +./kportal +``` + +From the main view: +- Press **`n`** to add a new port forward +- Press **`d`** to delete existing port forwards + +## Add Forward Wizard (`n` key) + +The wizard guides you through 7 steps to add a new forward: + +### Step 1: Select Context +Choose from available Kubernetes contexts in your kubeconfig. + +### Step 2: Select Namespace +Pick the namespace where your resource lives. + +### Step 3: Select Resource Type +Three options: +- **Pod (by name prefix)** - Forward to a specific pod by prefix matching +- **Pod (by label selector)** - Forward to pods matching labels (survives restarts) +- **Service** - Most stable, load-balanced option + +### Step 4: Enter Resource +- **Pod prefix**: Type a prefix like `nginx-` to match pods +- **Label selector**: Enter labels like `app=nginx,env=prod` +- **Service**: Select from a list of services + +The wizard shows real-time validation and matching resources! + +### Step 5: Remote Port +Enter the port number on the remote resource. The wizard displays detected ports from running containers. + +### Step 6: Local Port +Enter the local port to bind to. The wizard checks availability in real-time. + +### Step 7: Confirmation +Review your configuration and optionally add an alias (friendly name). Confirm to save! + +### Navigation Keys + +- **`↑`/`↓`** or **`j`/`k`** - Navigate options +- **`Enter`** - Confirm and proceed to next step +- **`Esc`** - Go back one step (or cancel on first step) +- **`Ctrl+C`** - Hard cancel and return to main view +- **`Backspace`** - Delete characters in text fields + +## Remove Forward Wizard (`d` key) + +Multi-select interface for removing forwards: + +1. **Select forwards**: Use arrow keys to navigate, `Space` to toggle selection +2. **Confirm removal**: Press `Enter` and confirm your choice + +### Navigation Keys + +- **`↑`/`↓`** or **`j`/`k`** - Navigate forwards +- **`Space`** - Toggle selection of current forward +- **`a`** - Select all forwards +- **`n`** - Deselect all forwards +- **`Enter`** - Proceed to confirmation +- **`Esc`** - Cancel and return to main view +- **`Ctrl+C`** - Hard cancel + +## Auto Hot-Reload + +When you save a forward via the wizard: +1. The wizard writes to `.kportal.yaml` atomically +2. The file watcher detects the change (~100ms) +3. The manager reloads and starts the new forward +4. The UI updates automatically + +No restart needed! + +## Error Handling + +The wizards handle errors gracefully: + +- **Cluster unreachable**: Shows error but allows manual entry +- **Port conflicts**: Displays which process is using the port +- **Invalid selectors**: Shows validation errors in real-time +- **Duplicate ports**: Prevents adding forwards with conflicting ports + +## Tips + +### Pod Prefix Matching +When using pod prefix, you can type just the app name: +- `nginx` matches `nginx-deployment-abc123` +- `postgres` matches `postgres-statefulset-0` + +### Label Selectors +Use standard Kubernetes label syntax: +- `app=nginx` - Single label +- `app=nginx,env=prod` - Multiple labels (comma-separated) +- Real-time validation shows matching pods as you type! + +### Aliases +Use aliases for cleaner UI display: +- Instead of: `production/default/pod/nginx-deployment-abc123:80→8080` +- Shows as: `my-nginx:80→8080` + +### Quick Selection +In list views, you can use `j`/`k` (Vim-style) or arrow keys for navigation. + +## Example Workflow + +Adding a forward for a PostgreSQL database: + +1. Press `n` in main view +2. Select context: `production` (arrow keys + Enter) +3. Select namespace: `default` (arrow keys + Enter) +4. Select type: `Service` (arrow keys + Enter) +5. Select service: `postgres` (arrow keys + Enter) +6. Enter remote port: `5432` (type + Enter) +7. Enter local port: `5432` (type + Enter) +8. Add alias: `prod-db` (optional, type + Enter) +9. Confirm: Select "Add to .kportal.yaml" (Enter) + +Done! The forward starts automatically within seconds. + +## Architecture + +The wizards use: +- **Config Mutator**: Safe, atomic YAML writes (temp file + rename) +- **K8s Discovery**: Lists contexts, namespaces, pods, services +- **Modal Overlays**: Wizards appear centered over the main view +- **Async Validation**: Port checks and selector validation run in background +- **Hot-Reload Integration**: File watcher picks up changes automatically + +## Troubleshooting + +### Wizards not appearing? +Check that kportal can connect to your Kubernetes cluster: +```bash +kubectl cluster-info +``` + +### Port check showing wrong status? +The port check happens asynchronously. Wait a moment after typing for validation. + +### Changes not appearing? +The file watcher triggers within 100ms. If changes aren't visible, check: +1. `.kportal.yaml` was written correctly +2. No validation errors in the file +3. kportal process is still running + +--- + +**Navigation Summary** + +Main View: +- `n` - New forward wizard +- `d` - Delete forward wizard +- `Space` - Toggle forward on/off +- `↑↓/jk` - Navigate forwards +- `q` - Quit + +Wizards: +- `Enter` - Next step / Confirm +- `Esc` - Previous step / Cancel +- `Ctrl+C` - Hard cancel +- `↑↓/jk` - Navigate +- `Space` - Toggle (in delete wizard) diff --git a/cmd/kportal/main.go b/cmd/kportal/main.go index 9de7523..9c63e12 100644 --- a/cmd/kportal/main.go +++ b/cmd/kportal/main.go @@ -7,23 +7,30 @@ import ( "log" "os" "os/signal" + "path/filepath" + "strings" "syscall" "time" "github.com/nvm/kportal/internal/config" "github.com/nvm/kportal/internal/converter" "github.com/nvm/kportal/internal/forward" + "github.com/nvm/kportal/internal/k8s" + "github.com/nvm/kportal/internal/logger" "github.com/nvm/kportal/internal/ui" "k8s.io/klog/v2" ) const ( - defaultConfigFile = ".kportal.yaml" + defaultConfigFile = ".kportal.yaml" + initialForwardSettleTime = 100 * time.Millisecond + tableUpdateInterval = 2 * time.Second ) var ( configFile = flag.String("c", defaultConfigFile, "Path to configuration file") verbose = flag.Bool("v", false, "Enable verbose logging") + 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") convertInput = flag.String("convert", "", "Convert kftray JSON config to kportal YAML (provide input file path)") @@ -39,6 +46,62 @@ func main() { os.Exit(0) } + // Validate config path security + if *configFile != "" { + absConfigPath, err := filepath.Abs(*configFile) + if err != nil { + fmt.Fprintf(os.Stderr, "Invalid config path: %v\n", err) + os.Exit(1) + } + absConfigPath = filepath.Clean(absConfigPath) + + // Block system directories + systemDirs := []string{"/etc", "/sys", "/proc", "/dev"} + for _, sysDir := range systemDirs { + if strings.HasPrefix(absConfigPath, sysDir) { + fmt.Fprintf(os.Stderr, "Error: Config file cannot be in system directory: %s\n", sysDir) + os.Exit(1) + } + } + + *configFile = absConfigPath + } + + // Initialize structured logger + var logLevel logger.Level + var logFmt logger.Format + var logOutput io.Writer + + if *verbose { + logLevel = logger.LevelDebug + logOutput = os.Stderr + } else { + logLevel = logger.LevelInfo + logOutput = io.Discard // Silence logger in non-verbose mode to prevent UI corruption + } + + switch *logFormat { + case "json": + logFmt = logger.FormatJSON + default: + logFmt = logger.FormatText + } + + logger.Init(logLevel, logFmt, logOutput) + + // Configure klog (used by kubernetes client-go) to route through our logger + // This prevents k8s logs from interfering with the UI + if *verbose { + // In verbose mode, route klog through our structured logger at DEBUG level + klogLogger := logger.New(logger.LevelDebug, logFmt, os.Stderr) + klogWriter := logger.NewKlogWriter(klogLogger) + klog.SetOutput(klogWriter) + } else { + // In non-verbose mode, completely silence klog + klog.SetOutput(io.Discard) + } + klog.LogToStderr(false) + // Handle conversion mode if *convertInput != "" { if err := converter.ConvertKFTrayToKPortal(*convertInput, *convertOutput); err != nil { @@ -68,11 +131,8 @@ func main() { log.SetOutput(io.Discard) log.SetPrefix("") log.SetFlags(0) - - // Disable klog (used by kubernetes client-go) - klog.SetOutput(io.Discard) - klog.LogToStderr(false) } else { + // Verbose mode - enable standard log formatting log.SetFlags(log.LstdFlags | log.Lshortfile) } @@ -101,8 +161,21 @@ func main() { log.Printf("Loading configuration from: %s", *configFile) } + // Create Kubernetes client pool and discovery for wizards + pool, err := k8s.NewClientPool() + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: Failed to create k8s client pool: %v\n", err) + fmt.Fprintf(os.Stderr, "Add/remove wizards will not be available\n") + } + discovery := k8s.NewDiscovery(pool) + mutator := config.NewMutator(*configFile) + // Create forward manager - manager := forward.NewManager(*verbose) + manager, err := forward.NewManager(*verbose) + if err != nil { + fmt.Fprintf(os.Stderr, "Error creating forward manager: %v\n", err) + os.Exit(1) + } // Create UI (bubbletea for interactive, simple table for verbose) var bubbleTeaUI *ui.BubbleTeaUI @@ -117,6 +190,11 @@ func main() { manager.DisableForward(id) } }, version) + + // Set wizard dependencies + // Note: mutator is always available (for delete/edit), discovery requires valid kubeconfig (for add) + bubbleTeaUI.SetWizardDependencies(discovery, mutator, *configFile) + manager.SetStatusUI(bubbleTeaUI) } else { // Verbose mode with simple table @@ -140,7 +218,7 @@ func main() { // Start table update loop go func() { - ticker := time.NewTicker(2 * time.Second) + ticker := time.NewTicker(tableUpdateInterval) defer ticker.Stop() for range ticker.C { tableUI.Render() @@ -211,7 +289,7 @@ func main() { }() // Give a moment for initial forwards to be added - time.Sleep(100 * time.Millisecond) + time.Sleep(initialForwardSettleTime) // Start the bubbletea app (blocks until quit) if err := bubbleTeaUI.Start(); err != nil { diff --git a/docs/index.html b/docs/index.html index bc204aa..ef2c18a 100644 --- a/docs/index.html +++ b/docs/index.html @@ -1,225 +1,616 @@ - + - - - - kportal - Kubernetes Port-Forward Manager - - - - - - - - - - - - - - -
-
-
-

- Kubernetes Port-Forward
- Manager -

-

- Professional terminal interface for managing multiple Kubernetes port-forwards with auto-reconnect, hot-reload, and real-time health monitoring. -

- -
- Version - License - Go Report -
- -
- kportal terminal interface -
-
-
-
- - -
-
-
-

Features

-

Everything you need for production-grade port-forwarding

-
-
-
-
- + + -

Interactive TUI

-

Beautiful terminal interface powered by Bubble Tea. Toggle forwards on/off, view real-time status updates.

-
- -
-
- -
-

Auto-Reconnect

-

Automatic reconnection on failure with exponential backoff. Never lose connectivity to your services.

-
- -
-
- -
-

Hot-Reload

-

Configuration changes applied automatically. Add, remove, or modify forwards without restarting.

-
- -
-
- -
-

Health Checks

-

Real-time health monitoring with 5-second intervals. Grace period prevents false error alerts.

-
- -
-
- -
-

Error Reporting

-

Detailed error messages displayed in the interface. Know exactly what went wrong.

-
- -
-
- -
-

Multi-Context

-

Support for multiple Kubernetes contexts and namespaces. Manage all your clusters from one place.

-
-
-
-
- - -
-
-
-

Installation

-

Get started in seconds

-
-
-
-
- -
-

Homebrew

-

macOS & Linux

-
-
-
- brew install lukaszraczylo/brew-taps/kportal - -
-
- -
-
- -
-

Quick Install

-

All platforms

-
-
-
- curl -fsSL https://raw.githubusercontent.com/lukaszraczylo/kportal/main/install.sh | bash - -
-
- -
-
- -
-

Manual Download

-

Direct download from GitHub releases

-
-
- - Download Binary - -
-
-
-
- - -
-
-
-

Configuration

-

Simple YAML configuration

-
-
-
-
-
- - .kportal.yaml -
- + + + + +
-
contexts:
+                
+
+ + + + + +
+ +
+ + +
+
+
+ +
+
+ +
+ kportal logo + +
+ +

+ Kubernetes Port-Forward
+ Manager +

+

+ Terminal interface for professionals managing multiple + Kubernetes port-forwards with auto-reconnect, + hot-reload, and real-time health monitoring. No, it's + not a wrapper for the kubectl command. +

+ +
+ Version + License + Go Report +
+ +
+
+
+ kportal terminal interface +
+
+
+
+
+ + +
+
+
+

+ Features +

+

+ Everything you need for production-grade port-forwarding +

+
+ + +
+ +
+
+
+ +
+
+

Live Add

+

Add forwards on-the-fly

+
+
+
+ +
+
+
+ +
+
+

Live Edit

+

Modify in real-time

+
+
+
+ +
+
+
+ +
+
+

Live Delete

+

Remove instantly

+
+
+
+ +
+
+
+ +
+
+

Toggle Forwards

+

Enable/disable with Space

+
+
+
+ + +
+
+
+ +
+
+

Auto-Reconnect

+

Exponential backoff retry

+
+
+
+ +
+
+
+ +
+
+

Hot-Reload

+

Config changes auto-apply

+
+
+
+ +
+
+
+ +
+
+

Health Checks

+

Real-time monitoring

+
+
+
+ +
+
+
+ +
+
+

Multi-Context

+

All clusters in one place

+
+
+
+
+
+
+ + +
+
+
+

+ Installation +

+

+ Get started in seconds +

+
+
+
+
+ +
+

+ Homebrew +

+

+ macOS & Linux +

+
+
+
+ brew install + lukaszraczylo/brew-taps/kportal +
+ +
+
+
+ +
+
+ +
+

+ Quick Install +

+

+ All platforms +

+
+
+
+ curl -fsSL + https://raw.githubusercontent.com/lukaszraczylo/kportal/main/install.sh + | bash +
+ +
+
+
+ +
+
+ +
+

+ Manual Download +

+

+ Direct download from GitHub releases +

+
+
+ + Download Binary + +
+
+
+
+ + +
+
+
+

+ Configuration +

+

+ Simple YAML configuration +

+
+
+
+
+
+
+
+ + .kportal.yaml +
+ +
+
contexts:
   - name: production
     namespaces:
       - name: backend
@@ -237,197 +628,350 @@
             port: 6379
             localPort: 6379
             alias: prod-redis
-
- -
-
-

- Resource Types -

-
    -
  • pod/name - Direct pod
  • -
  • service/name - Service
  • -
  • deployment/name - Deployment
  • -
+
-
-

- Features +
+
+

+ Resource Types +

+
    +
  • + pod/name + - Direct pod +
  • +
  • + service/name + - Service +
  • +
  • + deployment/name + - Deployment +
  • +
+
+ +
+

+ Features +

+
    +
  • Pod prefix matching
  • +
  • Label selectors
  • +
  • Alias support
  • +
  • Auto-reconnect
  • +
+
+
+

+
+
+ + +
+ +
+ + +
+
+
+
+
+ kportal logo +
+

+ Kubernetes port-forward manager for professionals +

+
+
+

Links

+ +
+
+

+ Built With +

+
    +
  • Bubble Tea
  • +
  • Lipgloss
  • +
  • + client-go +
-
-
-
- - -
- -
- - - + - - + + // Copy to clipboard function with fallback + function copyToClipboard(text, button) { + // Modern clipboard API (preferred) + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard + .writeText(text) + .then(() => { + showCopySuccess(button); + }) + .catch((err) => { + console.error("Clipboard API failed:", err); + fallbackCopy(text, button); + }); + } else { + // Fallback for older browsers or insecure contexts + fallbackCopy(text, button); + } + } + + // Fallback copy method using execCommand + function fallbackCopy(text, button) { + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.style.position = "fixed"; + textarea.style.top = "0"; + textarea.style.left = "0"; + textarea.style.opacity = "0"; + document.body.appendChild(textarea); + textarea.focus(); + textarea.select(); + + try { + const successful = document.execCommand("copy"); + if (successful) { + showCopySuccess(button); + } else { + showCopyError(button); + } + } catch (err) { + console.error("Fallback copy failed:", err); + showCopyError(button); + } + + document.body.removeChild(textarea); + } + + // Show success feedback + function showCopySuccess(button) { + const originalHTML = button.innerHTML; + button.innerHTML = + ''; + setTimeout(() => { + button.innerHTML = originalHTML; + }, 2000); + } + + // Show error feedback + function showCopyError(button) { + const originalHTML = button.innerHTML; + button.innerHTML = ''; + setTimeout(() => { + button.innerHTML = originalHTML; + }, 2000); + } + + // Smooth scrolling + document.querySelectorAll('a[href^="#"]').forEach((anchor) => { + anchor.addEventListener("click", function (e) { + e.preventDefault(); + const target = document.querySelector( + this.getAttribute("href"), + ); + if (target) { + target.scrollIntoView({ + behavior: "smooth", + block: "start", + }); + } + }); + }); + + diff --git a/docs/kportal-logo-dark.svg b/docs/kportal-logo-dark.svg new file mode 100644 index 0000000..e0cc408 --- /dev/null +++ b/docs/kportal-logo-dark.svg @@ -0,0 +1,132 @@ + diff --git a/docs/kportal-logo-light.svg b/docs/kportal-logo-light.svg new file mode 100644 index 0000000..9182421 --- /dev/null +++ b/docs/kportal-logo-light.svg @@ -0,0 +1,128 @@ + diff --git a/docs/kportal-screenshot.png b/docs/kportal-screenshot.png index cc6d01d..41d7b09 100644 Binary files a/docs/kportal-screenshot.png and b/docs/kportal-screenshot.png differ diff --git a/internal/config/config.go b/internal/config/config.go index faf3b32..0a9c818 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -7,6 +7,10 @@ import ( "gopkg.in/yaml.v3" ) +const ( + maxConfigSize = 10 * 1024 * 1024 // 10MB +) + // Config represents the root configuration structure from .kportal.yaml type Config struct { Contexts []Context `yaml:"contexts"` @@ -80,6 +84,16 @@ func (f *Forward) GetNamespace() string { // LoadConfig loads and parses the configuration file from the given path. func LoadConfig(path string) (*Config, error) { + // Validate file size before reading + fileInfo, err := os.Stat(path) + if err != nil { + return nil, fmt.Errorf("failed to stat config file: %w", err) + } + + if fileInfo.Size() > maxConfigSize { + return nil, fmt.Errorf("config file too large: %d bytes (max %d)", fileInfo.Size(), maxConfigSize) + } + data, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("failed to read config file: %w", err) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 22ff031..1a6498a 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -97,7 +97,7 @@ func TestLoadConfig_FileNotFound(t *testing.T) { cfg, err := LoadConfig("/non/existent/path/.kportal.yaml") assert.Error(t, err, "LoadConfig should fail with non-existent file") assert.Nil(t, cfg, "config should be nil on error") - assert.Contains(t, err.Error(), "failed to read config file", "error should mention read failure") + assert.Contains(t, err.Error(), "failed to stat config file", "error should mention stat failure") } func TestForward_ID(t *testing.T) { diff --git a/internal/config/mutator.go b/internal/config/mutator.go new file mode 100644 index 0000000..b16d13c --- /dev/null +++ b/internal/config/mutator.go @@ -0,0 +1,273 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + "sync" + + "gopkg.in/yaml.v3" +) + +// Mutator provides safe, atomic mutations to the kportal configuration file. +// All operations use atomic file writes (write to temp, then rename) to prevent +// corruption and ensure the file watcher picks up changes. +type Mutator struct { + configPath string + mu sync.Mutex // Ensure only one mutation at a time +} + +// NewMutator creates a new configuration mutator for the given config file path. +func NewMutator(configPath string) *Mutator { + return &Mutator{ + configPath: configPath, + } +} + +// findOrCreateContext finds an existing context or creates a new one +func (m *Mutator) findOrCreateContext(cfg *Config, contextName string) *Context { + for i := range cfg.Contexts { + if cfg.Contexts[i].Name == contextName { + return &cfg.Contexts[i] + } + } + + // Create new context + cfg.Contexts = append(cfg.Contexts, Context{ + Name: contextName, + Namespaces: []Namespace{}, + }) + return &cfg.Contexts[len(cfg.Contexts)-1] +} + +// findOrCreateNamespace finds an existing namespace or creates a new one +func (m *Mutator) findOrCreateNamespace(ctx *Context, namespaceName string) *Namespace { + for i := range ctx.Namespaces { + if ctx.Namespaces[i].Name == namespaceName { + return &ctx.Namespaces[i] + } + } + + // Create new namespace + ctx.Namespaces = append(ctx.Namespaces, Namespace{ + Name: namespaceName, + Forwards: []Forward{}, + }) + return &ctx.Namespaces[len(ctx.Namespaces)-1] +} + +// AddForward adds a new port forward to the configuration. +// If the context or namespace doesn't exist, they will be created. +// The new configuration is validated before writing. +// Returns an error if the port is already in use or validation fails. +func (m *Mutator) AddForward(contextName, namespaceName string, fwd Forward) error { + m.mu.Lock() + defer m.mu.Unlock() + + // Load current config + cfg, err := LoadConfig(m.configPath) + if err != nil { + // If file doesn't exist, create empty config + if os.IsNotExist(err) { + cfg = &Config{Contexts: []Context{}} + } else { + return fmt.Errorf("failed to load config: %w", err) + } + } + + // Find or create context and namespace + targetContext := m.findOrCreateContext(cfg, contextName) + targetNamespace := m.findOrCreateNamespace(targetContext, namespaceName) + + // Set context/namespace on the forward for validation + fwd.SetContext(contextName, namespaceName) + + // Check for duplicate local port + allForwards := cfg.GetAllForwards() + for _, existing := range allForwards { + if existing.LocalPort == fwd.LocalPort { + return fmt.Errorf("port %d is already in use by %s", fwd.LocalPort, existing.String()) + } + } + + // Add the forward + targetNamespace.Forwards = append(targetNamespace.Forwards, fwd) + + // Validate the new configuration + validator := NewValidator() + if errs := validator.ValidateConfig(cfg); len(errs) > 0 { + return fmt.Errorf("validation failed: %s", FormatValidationErrors(errs)) + } + + // Write atomically + return m.writeAtomic(cfg) +} + +// RemoveForwards removes forwards matching the predicate function. +// The predicate receives the context, namespace, and forward, and should return true +// to remove that forward. +// Empty namespaces and contexts are preserved (not automatically removed). +func (m *Mutator) RemoveForwards(predicate func(ctx, ns string, fwd Forward) bool) error { + m.mu.Lock() + defer m.mu.Unlock() + + // Load current config + cfg, err := LoadConfig(m.configPath) + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + // Iterate and filter + for i := range cfg.Contexts { + ctx := &cfg.Contexts[i] + filteredNamespaces := []Namespace{} + + for j := range ctx.Namespaces { + ns := &ctx.Namespaces[j] + + // Filter forwards + filtered := []Forward{} + for _, fwd := range ns.Forwards { + // CRITICAL: Set context/namespace so fwd.ID() generates correct ID + fwd.SetContext(ctx.Name, ns.Name) + + if !predicate(ctx.Name, ns.Name, fwd) { + // Keep this forward + filtered = append(filtered, fwd) + } + } + + ns.Forwards = filtered + + // Only keep namespaces that have at least one forward + if len(ns.Forwards) > 0 { + filteredNamespaces = append(filteredNamespaces, *ns) + } + } + + ctx.Namespaces = filteredNamespaces + } + + // Validate the new configuration + validator := NewValidator() + if errs := validator.ValidateConfig(cfg); len(errs) > 0 { + return fmt.Errorf("validation failed: %s", FormatValidationErrors(errs)) + } + + // Write atomically + return m.writeAtomic(cfg) +} + +// RemoveForwardByID removes a specific forward by its ID. +func (m *Mutator) RemoveForwardByID(id string) error { + return m.RemoveForwards(func(ctx, ns string, fwd Forward) bool { + return fwd.ID() == id + }) +} + +// UpdateForward atomically replaces an existing forward with a new one. +// This is used for editing - it removes the old forward and adds the new one in a single transaction. +// If the old forward doesn't exist, returns an error. +// If the new forward validation fails, the operation is rolled back (old forward remains). +func (m *Mutator) UpdateForward(oldID, newContextName, newNamespaceName string, newFwd Forward) error { + m.mu.Lock() + defer m.mu.Unlock() + + // Load current config + cfg, err := LoadConfig(m.configPath) + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + // First, verify the old forward exists and remove it + oldForwardFound := false + for i := range cfg.Contexts { + ctx := &cfg.Contexts[i] + for j := range ctx.Namespaces { + ns := &ctx.Namespaces[j] + + // Filter forwards, removing the old one + filtered := []Forward{} + for _, fwd := range ns.Forwards { + // CRITICAL: Set context/namespace so fwd.ID() generates correct ID + fwd.SetContext(ctx.Name, ns.Name) + + if fwd.ID() == oldID { + oldForwardFound = true + // Skip this forward (remove it) + continue + } + + // Keep this forward + filtered = append(filtered, fwd) + } + + ns.Forwards = filtered + } + } + + if !oldForwardFound { + return fmt.Errorf("forward with ID %s not found", oldID) + } + + // Now add the new forward + // Find or create context and namespace + targetContext := m.findOrCreateContext(cfg, newContextName) + targetNamespace := m.findOrCreateNamespace(targetContext, newNamespaceName) + + // Set context/namespace on the forward for validation + newFwd.SetContext(newContextName, newNamespaceName) + + // Check for duplicate local port (excluding the one we just removed) + allForwards := cfg.GetAllForwards() + for _, existing := range allForwards { + if existing.LocalPort == newFwd.LocalPort && existing.ID() != oldID { + return fmt.Errorf("port %d is already in use by %s", newFwd.LocalPort, existing.String()) + } + } + + // Add the new forward + targetNamespace.Forwards = append(targetNamespace.Forwards, newFwd) + + // Validate the new configuration + validator := NewValidator() + if errs := validator.ValidateConfig(cfg); len(errs) > 0 { + return fmt.Errorf("validation failed: %s", FormatValidationErrors(errs)) + } + + // Write atomically + return m.writeAtomic(cfg) +} + +// writeAtomic writes the configuration atomically to prevent corruption. +// Steps: +// 1. Marshal config to YAML +// 2. Write to temporary file (.kportal.yaml.tmp) +// 3. Atomic rename to actual config file +// +// This ensures the file watcher picks up a complete, valid file. +func (m *Mutator) writeAtomic(cfg *Config) error { + // Marshal to YAML + data, err := yaml.Marshal(cfg) + if err != nil { + return fmt.Errorf("failed to marshal config: %w", err) + } + + // Create temporary file in same directory as config + dir := filepath.Dir(m.configPath) + tmpFile := filepath.Join(dir, ".kportal.yaml.tmp") + + // Write to temp file + if err := os.WriteFile(tmpFile, data, 0600); err != nil { + return fmt.Errorf("failed to write temp file: %w", err) + } + + // Atomic rename + if err := os.Rename(tmpFile, m.configPath); err != nil { + // Clean up temp file on failure + os.Remove(tmpFile) + return fmt.Errorf("failed to rename temp file: %w", err) + } + + return nil +} diff --git a/internal/config/watcher.go b/internal/config/watcher.go index 9d50dcc..bc1c05c 100644 --- a/internal/config/watcher.go +++ b/internal/config/watcher.go @@ -6,6 +6,7 @@ import ( "path/filepath" "github.com/fsnotify/fsnotify" + "github.com/nvm/kportal/internal/logger" ) // ReloadCallback is called when the configuration file changes. @@ -113,28 +114,37 @@ func (w *Watcher) handleReload() { // Load new configuration newCfg, err := LoadConfig(w.configPath) if err != nil { - log.Printf("Failed to load configuration: %v", err) - log.Printf("Keeping previous configuration active") + logger.Error("Failed to load configuration during hot-reload", map[string]interface{}{ + "config_path": w.configPath, + "error": err.Error(), + }) + logger.Info("Keeping previous configuration active", nil) return } // Validate new configuration validator := NewValidator() if errs := validator.ValidateConfig(newCfg); len(errs) > 0 { - log.Printf("Configuration validation failed:") - log.Print(FormatValidationErrors(errs)) - log.Printf("Keeping previous configuration active") + logger.Error("Configuration validation failed during hot-reload", map[string]interface{}{ + "config_path": w.configPath, + "validation_errors": len(errs), + }) + logger.Info("Keeping previous configuration active", nil) return } // Call reload callback if err := w.callback(newCfg); err != nil { - log.Printf("Failed to apply new configuration: %v", err) - log.Printf("Keeping previous configuration active") + logger.Error("Failed to apply new configuration", map[string]interface{}{ + "config_path": w.configPath, + "error": err.Error(), + }) + logger.Info("Keeping previous configuration active", nil) return } - if w.verbose { - log.Printf("Configuration reloaded successfully") - } + logger.Info("Configuration reloaded successfully", map[string]interface{}{ + "config_path": w.configPath, + "forwards_count": len(newCfg.GetAllForwards()), + }) } diff --git a/internal/forward/manager.go b/internal/forward/manager.go index d737d9d..d8bf112 100644 --- a/internal/forward/manager.go +++ b/internal/forward/manager.go @@ -9,6 +9,12 @@ import ( "github.com/nvm/kportal/internal/config" "github.com/nvm/kportal/internal/healthcheck" "github.com/nvm/kportal/internal/k8s" + "github.com/nvm/kportal/internal/logger" +) + +const ( + healthCheckInterval = 5 * time.Second + healthCheckTimeout = 2 * time.Second ) // StatusUpdater is an interface for updating forward status @@ -34,17 +40,17 @@ type Manager struct { } // NewManager creates a new forward Manager. -func NewManager(verbose bool) *Manager { +func NewManager(verbose bool) (*Manager, error) { clientPool, err := k8s.NewClientPool() if err != nil { - log.Fatalf("Failed to create client pool: %v", err) + return nil, fmt.Errorf("failed to create client pool: %w", err) } resolver := k8s.NewResourceResolver(clientPool) portForwarder := k8s.NewPortForwarder(clientPool, resolver) // Create health checker: check every 5 seconds with 2 second timeout - healthChecker := healthcheck.NewChecker(5*time.Second, 2*time.Second) + healthChecker := healthcheck.NewChecker(healthCheckInterval, healthCheckTimeout) return &Manager{ workers: make(map[string]*ForwardWorker), @@ -54,7 +60,7 @@ func NewManager(verbose bool) *Manager { portChecker: NewPortChecker(), healthChecker: healthChecker, verbose: verbose, - } + }, nil } // SetStatusUI sets the status updater for the manager @@ -93,7 +99,14 @@ func (m *Manager) Start(cfg *config.Config) error { for _, fwd := range forwards { if err := m.startWorker(fwd); err != nil { - log.Printf("Failed to start worker for %s: %v", fwd.ID(), err) + logger.Error("Failed to start worker", map[string]interface{}{ + "forward_id": fwd.ID(), + "context": fwd.GetContext(), + "namespace": fwd.GetNamespace(), + "resource": fwd.Resource, + "local_port": fwd.LocalPort, + "error": err.Error(), + }) // Continue with other workers } } @@ -146,7 +159,9 @@ func (m *Manager) Reload(newCfg *config.Config) error { return fmt.Errorf("new configuration is nil") } - log.Printf("Reloading configuration...") + logger.Info("Reloading configuration", map[string]interface{}{ + "new_forwards_count": len(newCfg.GetAllForwards()), + }) // Get all forwards from new config newForwards := newCfg.GetAllForwards() @@ -295,8 +310,10 @@ func (m *Manager) stopWorker(id string) error { // Unregister from health checker m.healthChecker.Unregister(id) - // Note: We DON'T call Remove() here anymore - keep it in the UI - // The UI will show it as disabled instead + // Notify UI to remove the forward + if m.statusUI != nil { + m.statusUI.Remove(id) + } // Stop the worker worker.Stop() diff --git a/internal/forward/portcheck.go b/internal/forward/portcheck.go index 031ed58..6bf9d2d 100644 --- a/internal/forward/portcheck.go +++ b/internal/forward/portcheck.go @@ -8,6 +8,19 @@ import ( "strings" ) +// isValidPID validates that a PID string contains only digits +func isValidPID(pid string) bool { + if len(pid) == 0 || len(pid) > 9 { + return false + } + for _, c := range pid { + if c < '0' || c > '9' { + return false + } + } + return true +} + // PortConflict represents a local port that is already in use. type PortConflict struct { Port int // The conflicting port number @@ -93,6 +106,10 @@ func (pc *PortChecker) getProcessUsingPortUnix(port int) string { pids := strings.Split(pidStr, "\n") pid := pids[0] + if !isValidPID(pid) { + return "unknown" + } + // Get process name using ps cmd = exec.Command("ps", "-p", pid, "-o", "comm=") output, err = cmd.Output() @@ -140,6 +157,10 @@ func (pc *PortChecker) getProcessUsingPortWindows(port int) string { pid := fields[len(fields)-1] + if !isValidPID(pid) { + return "unknown" + } + // Get process name using tasklist cmd = exec.Command("tasklist", "/FI", fmt.Sprintf("PID eq %s", pid), "/FO", "CSV", "/NH") output, err = cmd.Output() @@ -188,16 +209,3 @@ func FormatConflicts(conflicts []PortConflict) string { return sb.String() } - -// GetPortsFromForwards extracts all local ports from a list of forward configurations. -func GetPortsFromForwards(forwards []interface{}) []int { - ports := make([]int, 0, len(forwards)) - for _, fwd := range forwards { - // This function expects a generic interface to work with different forward types - // The actual implementation should use the Forward struct from config package - if f, ok := fwd.(interface{ GetLocalPort() int }); ok { - ports = append(ports, f.GetLocalPort()) - } - } - return ports -} diff --git a/internal/forward/worker.go b/internal/forward/worker.go index 9013335..2a4af8f 100644 --- a/internal/forward/worker.go +++ b/internal/forward/worker.go @@ -10,9 +10,14 @@ import ( "github.com/nvm/kportal/internal/config" "github.com/nvm/kportal/internal/healthcheck" "github.com/nvm/kportal/internal/k8s" + "github.com/nvm/kportal/internal/logger" "github.com/nvm/kportal/internal/retry" ) +const ( + portForwardReadyTimeout = 30 * time.Second +) + // ForwardWorker manages a single port-forward connection with automatic retry. type ForwardWorker struct { forward config.Forward @@ -86,7 +91,13 @@ func (w *ForwardWorker) run() { ) if err != nil { - log.Printf("[%s] Failed to resolve resource: %v", w.forward.ID(), err) + logger.Error("Failed to resolve resource", map[string]interface{}{ + "forward_id": w.forward.ID(), + "context": w.forward.GetContext(), + "namespace": w.forward.GetNamespace(), + "resource": w.forward.Resource, + "error": err.Error(), + }) w.sleepWithBackoff(backoff) continue } @@ -96,10 +107,20 @@ func (w *ForwardWorker) run() { if w.healthChecker != nil { w.healthChecker.MarkReconnecting(w.forward.ID()) } - log.Printf("[%s] Switched to new pod: %s → %s", w.forward.ID(), w.lastPod, podName) + logger.Info("Pod restart detected, switching to new pod", map[string]interface{}{ + "forward_id": w.forward.ID(), + "old_pod": w.lastPod, + "new_pod": podName, + "context": w.forward.GetContext(), + "namespace": w.forward.GetNamespace(), + }) } else if w.lastPod == "" { - log.Printf("[%s] Forwarding %s → localhost:%d", - w.forward.ID(), w.forward.String(), w.forward.LocalPort) + logger.Info("Starting port forward", map[string]interface{}{ + "forward_id": w.forward.ID(), + "target": w.forward.String(), + "local_port": w.forward.LocalPort, + "pod": podName, + }) if w.healthChecker != nil { w.healthChecker.MarkStarting(w.forward.ID()) } @@ -123,7 +144,14 @@ func (w *ForwardWorker) run() { } // Log the error - log.Printf("[%s] Port-forward connection failed: %v", w.forward.ID(), err) + logger.Warn("Port-forward connection failed, will retry", map[string]interface{}{ + "forward_id": w.forward.ID(), + "context": w.forward.GetContext(), + "namespace": w.forward.GetNamespace(), + "resource": w.forward.Resource, + "local_port": w.forward.LocalPort, + "error": err.Error(), + }) // Clear last pod so we re-resolve on next attempt w.lastPod = "" @@ -206,7 +234,7 @@ func (w *ForwardWorker) establishForward(podName string) error { return fmt.Errorf("failed to establish forward: %w", err) case <-w.ctx.Done(): return nil - case <-time.After(30 * time.Second): + case <-time.After(portForwardReadyTimeout): return fmt.Errorf("timeout waiting for port-forward to become ready") } diff --git a/internal/forward/worker_unit_test.go b/internal/forward/worker_unit_test.go new file mode 100644 index 0000000..9b076de --- /dev/null +++ b/internal/forward/worker_unit_test.go @@ -0,0 +1,286 @@ +package forward + +import ( + "testing" + + "github.com/nvm/kportal/internal/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLogWriter_Write(t *testing.T) { + tests := []struct { + name string + prefix string + input string + expectedInLog string + description string + }{ + { + name: "write simple message", + prefix: "[worker] ", + input: "test message", + expectedInLog: "[worker] test message", + description: "Should write message with prefix to log", + }, + { + name: "write empty message", + prefix: "[test] ", + input: "", + expectedInLog: "[test] ", + description: "Should handle empty message", + }, + { + name: "write multiline message", + prefix: "[fwd] ", + input: "line1\nline2", + expectedInLog: "[fwd] line1\nline2", + description: "Should handle multiline messages", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Test logWriter + originalWriter := &logWriter{prefix: tt.prefix} + + n, err := originalWriter.Write([]byte(tt.input)) + + require.NoError(t, err, "Write should not return error") + assert.Equal(t, len(tt.input), n, "Write should return number of bytes written") + }) + } +} + +func TestForwardWorker_GetForward(t *testing.T) { + tests := []struct { + name string + forward config.Forward + description string + }{ + { + name: "get pod forward", + forward: config.Forward{ + Resource: "pod/my-app", + LocalPort: 8080, + Port: 80, + Protocol: "tcp", + }, + description: "Should return the forward configuration", + }, + { + name: "get service forward", + forward: config.Forward{ + Resource: "service/postgres", + LocalPort: 5432, + Port: 5432, + Protocol: "tcp", + }, + description: "Should return service forward configuration", + }, + { + name: "get forward with selector", + forward: config.Forward{ + Resource: "pod", + Selector: "app=nginx,env=prod", + LocalPort: 8080, + Port: 80, + Protocol: "tcp", + }, + description: "Should return forward with label selector", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Note: We can't easily test the full worker lifecycle without mocks, + // but we can test the constructor and simple getters + + // This test would require proper mocking setup + // For now, we'll test the Forward struct directly + + id := tt.forward.ID() + assert.NotEmpty(t, id, "Forward should have an ID") + + forwardStr := tt.forward.String() + assert.NotEmpty(t, forwardStr, "Forward should have a string representation") + assert.Contains(t, forwardStr, tt.forward.Resource, "String should contain resource") + }) + } +} + +func TestForwardWorker_IsRunning(t *testing.T) { + // This is a basic test of the goroutine state tracking + // Full integration tests would require mock dependencies + + t.Run("worker state tracking", func(t *testing.T) { + // Test the concept of the done channel + doneChan := make(chan struct{}) + + // Initially, channel is open (worker would be running) + select { + case <-doneChan: + t.Fatal("doneChan should be open initially") + default: + // Expected: channel is open + } + + // Close the channel (simulating worker done) + close(doneChan) + + // Now channel should be closed + select { + case <-doneChan: + // Expected: channel is closed + default: + t.Fatal("doneChan should be closed after close") + } + }) +} + +func TestForwardID(t *testing.T) { + tests := []struct { + name string + forward config.Forward + expectUnique bool + description string + }{ + { + name: "unique IDs for different forwards", + forward: config.Forward{ + Resource: "pod/app1", + LocalPort: 8080, + Port: 80, + }, + expectUnique: true, + description: "Different forwards should have different IDs", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + id1 := tt.forward.ID() + + // Create a different forward + fwd2 := config.Forward{ + Resource: "pod/app2", + LocalPort: 8081, + Port: 80, + } + id2 := fwd2.ID() + + if tt.expectUnique { + assert.NotEqual(t, id1, id2, "Different forwards should have different IDs") + } + + // Same forward should produce same ID + id3 := tt.forward.ID() + assert.Equal(t, id1, id3, "Same forward should produce same ID") + }) + } +} + +func TestForwardString(t *testing.T) { + tests := []struct { + name string + forward config.Forward + expectedContains []string + description string + }{ + { + name: "pod forward string", + forward: config.Forward{ + Resource: "pod/my-app", + LocalPort: 8080, + Port: 80, + }, + expectedContains: []string{"pod/my-app", "8080", "80"}, + description: "Should contain resource and ports", + }, + { + name: "service forward string", + forward: config.Forward{ + Resource: "service/postgres", + LocalPort: 5432, + Port: 5432, + }, + expectedContains: []string{"service/postgres", "5432"}, + description: "Should contain service and port", + }, + { + name: "selector forward string", + forward: config.Forward{ + Resource: "pod", + Selector: "app=nginx", + LocalPort: 8080, + Port: 80, + }, + expectedContains: []string{"app=nginx", "8080", "80"}, + description: "Should contain selector and ports", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.forward.String() + + assert.NotEmpty(t, result, "String representation should not be empty") + + for _, expected := range tt.expectedContains { + assert.Contains(t, result, expected, + "String should contain %s", expected) + } + }) + } +} + +func TestSleepWithBackoffConcept(t *testing.T) { + // Test the backoff concept without actually running a worker + t.Run("backoff delay increases", func(t *testing.T) { + // This tests the retry backoff behavior conceptually + delays := []int{1, 2, 4, 8, 10, 10, 10} + + for i, expected := range delays { + // Simulate backoff calculation + delay := 1 + for j := 0; j < i; j++ { + delay *= 2 + if delay > 10 { + delay = 10 + } + } + + assert.Equal(t, expected, delay, + "Backoff at attempt %d should be %d", i, expected) + } + }) +} + +func TestWorkerVerboseMode(t *testing.T) { + tests := []struct { + name string + verbose bool + description string + }{ + { + name: "verbose mode enabled", + verbose: true, + description: "Worker should respect verbose flag", + }, + { + name: "verbose mode disabled", + verbose: false, + description: "Worker should respect non-verbose flag", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Test that verbose flag is a boolean + assert.IsType(t, bool(true), tt.verbose) + + // In a real worker, this would control logging + // For now, we just verify the type + }) + } +} diff --git a/internal/healthcheck/checker.go b/internal/healthcheck/checker.go index 28155a4..a741c91 100644 --- a/internal/healthcheck/checker.go +++ b/internal/healthcheck/checker.go @@ -8,6 +8,10 @@ import ( "time" ) +const ( + startupGracePeriod = 10 * time.Second +) + // Status represents the health status of a port forward type Status string @@ -85,39 +89,41 @@ func (c *Checker) Unregister(forwardID string) { // MarkReconnecting marks a forward as reconnecting (called by worker) func (c *Checker) MarkReconnecting(forwardID string) { c.mu.Lock() - defer c.mu.Unlock() if health, exists := c.ports[forwardID]; exists { oldStatus := health.Status health.Status = StatusReconnect health.LastCheck = time.Now() - // Notify if status changed + c.mu.Unlock() + if oldStatus != StatusReconnect { - c.mu.Unlock() c.notifyStatusChange(forwardID, StatusReconnect, "") - c.mu.Lock() } + return } + + c.mu.Unlock() } // MarkStarting marks a forward as starting (called by worker) func (c *Checker) MarkStarting(forwardID string) { c.mu.Lock() - defer c.mu.Unlock() if health, exists := c.ports[forwardID]; exists { oldStatus := health.Status health.Status = StatusStarting health.LastCheck = time.Now() - // Notify if status changed + c.mu.Unlock() + if oldStatus != StatusStarting { - c.mu.Unlock() c.notifyStatusChange(forwardID, StatusStarting, "") - c.mu.Lock() } + return } + + c.mu.Unlock() } // GetStatus returns the current health status of a forward @@ -207,7 +213,7 @@ func (c *Checker) checkPort(forwardID string) { // Grace period: if forward is less than 10 seconds old, keep it as "Starting" // This avoids scary "Error" messages during initial connection attempts timeSinceStart := time.Since(registeredAt) - if timeSinceStart < 10*time.Second { + if timeSinceStart < startupGracePeriod { newStatus = StatusStarting } else { newStatus = StatusUnhealthy diff --git a/internal/k8s/discovery.go b/internal/k8s/discovery.go new file mode 100644 index 0000000..0beb27f --- /dev/null +++ b/internal/k8s/discovery.go @@ -0,0 +1,321 @@ +package k8s + +import ( + "context" + "fmt" + "net" + "sort" + "strconv" + "strings" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// Discovery provides cluster introspection capabilities for the UI wizards. +// It queries the Kubernetes API to list contexts, namespaces, pods, and services. +type Discovery struct { + pool *ClientPool +} + +// NewDiscovery creates a new Discovery instance using the provided client pool. +func NewDiscovery(pool *ClientPool) *Discovery { + return &Discovery{ + pool: pool, + } +} + +// PodInfo contains information about a pod relevant for port forwarding. +type PodInfo struct { + Name string + Namespace string + Containers []ContainerInfo + Status string + Created metav1.Time +} + +// ContainerInfo contains information about a container within a pod. +type ContainerInfo struct { + Name string + Ports []PortInfo +} + +// PortInfo describes a port exposed by a container or service. +type PortInfo struct { + Name string + Port int32 + Protocol string +} + +// ServiceInfo contains information about a service. +type ServiceInfo struct { + Name string + Namespace string + Ports []PortInfo + Type string +} + +// ListContexts returns all available Kubernetes contexts from kubeconfig. +func (d *Discovery) ListContexts() ([]string, error) { + return d.pool.ListContexts() +} + +// GetCurrentContext returns the name of the current context from kubeconfig. +func (d *Discovery) GetCurrentContext() (string, error) { + return d.pool.GetCurrentContext() +} + +// ListNamespaces returns all namespaces in the given context. +// Returns an error if the context is invalid or unreachable. +func (d *Discovery) ListNamespaces(ctx context.Context, contextName string) ([]string, error) { + client, err := d.pool.GetClient(contextName) + if err != nil { + return nil, fmt.Errorf("failed to get client: %w", err) + } + + nsList, err := client.CoreV1().Namespaces().List(ctx, metav1.ListOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to list namespaces: %w", err) + } + + namespaces := make([]string, 0, len(nsList.Items)) + for _, ns := range nsList.Items { + namespaces = append(namespaces, ns.Name) + } + + // Sort alphabetically + sort.Strings(namespaces) + + return namespaces, nil +} + +// ListPods returns all running pods in the given namespace with their port information. +// Only returns pods in Running or Pending state. +func (d *Discovery) ListPods(ctx context.Context, contextName, namespace string) ([]PodInfo, error) { + client, err := d.pool.GetClient(contextName) + if err != nil { + return nil, fmt.Errorf("failed to get client: %w", err) + } + + podList, err := client.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to list pods: %w", err) + } + + pods := make([]PodInfo, 0) + for _, pod := range podList.Items { + // Only include Running or Pending pods + if pod.Status.Phase != corev1.PodRunning && pod.Status.Phase != corev1.PodPending { + continue + } + + containers := make([]ContainerInfo, 0, len(pod.Spec.Containers)) + for _, container := range pod.Spec.Containers { + ports := make([]PortInfo, 0, len(container.Ports)) + for _, port := range container.Ports { + ports = append(ports, PortInfo{ + Name: port.Name, + Port: port.ContainerPort, + Protocol: string(port.Protocol), + }) + } + + containers = append(containers, ContainerInfo{ + Name: container.Name, + Ports: ports, + }) + } + + pods = append(pods, PodInfo{ + Name: pod.Name, + Namespace: pod.Namespace, + Containers: containers, + Status: string(pod.Status.Phase), + Created: pod.CreationTimestamp, + }) + } + + // Sort by creation time (newest first) + sort.Slice(pods, func(i, j int) bool { + return pods[i].Created.After(pods[j].Created.Time) + }) + + return pods, nil +} + +// ListPodsWithSelector returns pods matching the given label selector. +// Selector format: "key=value,key2=value2" +// Returns an error if the selector is invalid. +func (d *Discovery) ListPodsWithSelector(ctx context.Context, contextName, namespace, selector string) ([]PodInfo, error) { + client, err := d.pool.GetClient(contextName) + if err != nil { + return nil, fmt.Errorf("failed to get client: %w", err) + } + + // Validate selector format + selector = strings.TrimSpace(selector) + if selector == "" { + return nil, fmt.Errorf("selector cannot be empty") + } + + podList, err := client.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{ + LabelSelector: selector, + }) + if err != nil { + return nil, fmt.Errorf("failed to list pods with selector: %w", err) + } + + pods := make([]PodInfo, 0) + for _, pod := range podList.Items { + // Only include Running pods for selector-based forwards + if pod.Status.Phase != corev1.PodRunning { + continue + } + + containers := make([]ContainerInfo, 0, len(pod.Spec.Containers)) + for _, container := range pod.Spec.Containers { + ports := make([]PortInfo, 0, len(container.Ports)) + for _, port := range container.Ports { + ports = append(ports, PortInfo{ + Name: port.Name, + Port: port.ContainerPort, + Protocol: string(port.Protocol), + }) + } + + containers = append(containers, ContainerInfo{ + Name: container.Name, + Ports: ports, + }) + } + + pods = append(pods, PodInfo{ + Name: pod.Name, + Namespace: pod.Namespace, + Containers: containers, + Status: string(pod.Status.Phase), + Created: pod.CreationTimestamp, + }) + } + + // Sort by creation time (newest first) + sort.Slice(pods, func(i, j int) bool { + return pods[i].Created.After(pods[j].Created.Time) + }) + + return pods, nil +} + +// ListServices returns all services in the given namespace. +func (d *Discovery) ListServices(ctx context.Context, contextName, namespace string) ([]ServiceInfo, error) { + client, err := d.pool.GetClient(contextName) + if err != nil { + return nil, fmt.Errorf("failed to get client: %w", err) + } + + svcList, err := client.CoreV1().Services(namespace).List(ctx, metav1.ListOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to list services: %w", err) + } + + services := make([]ServiceInfo, 0, len(svcList.Items)) + for _, svc := range svcList.Items { + ports := make([]PortInfo, 0, len(svc.Spec.Ports)) + for _, port := range svc.Spec.Ports { + ports = append(ports, PortInfo{ + Name: port.Name, + Port: port.Port, + Protocol: string(port.Protocol), + }) + } + + services = append(services, ServiceInfo{ + Name: svc.Name, + Namespace: svc.Namespace, + Ports: ports, + Type: string(svc.Spec.Type), + }) + } + + // Sort alphabetically + sort.Slice(services, func(i, j int) bool { + return services[i].Name < services[j].Name + }) + + return services, nil +} + +// GetUniquePorts extracts unique ports from a list of pods. +// Returns a sorted list of port numbers with their names (if available). +func GetUniquePorts(pods []PodInfo) []PortInfo { + portMap := make(map[int32]string) + + for _, pod := range pods { + for _, container := range pod.Containers { + for _, port := range container.Ports { + // Prefer named ports + if _, ok := portMap[port.Port]; !ok || port.Name != "" { + if port.Name != "" { + portMap[port.Port] = port.Name + } else if !ok { + portMap[port.Port] = fmt.Sprintf("port-%d", port.Port) + } + } + } + } + } + + // Convert to slice + ports := make([]PortInfo, 0, len(portMap)) + for port, name := range portMap { + ports = append(ports, PortInfo{ + Name: name, + Port: port, + }) + } + + // Sort by port number + sort.Slice(ports, func(i, j int) bool { + return ports[i].Port < ports[j].Port + }) + + return ports +} + +// CheckPortAvailability checks if a local port is available. +// Returns: available (bool), processInfo (string), error +func CheckPortAvailability(port int) (bool, string, error) { + if port < 1 || port > 65535 { + return false, "", fmt.Errorf("invalid port: %d", port) + } + + // Try to listen on the port + addr := fmt.Sprintf(":%d", port) + listener, err := net.Listen("tcp", addr) + if err != nil { + // Port is in use + // Try to get process info (best-effort) + processInfo := "unknown process" + // Note: Getting process info requires platform-specific code + // For now, just return a generic message + return false, processInfo, nil + } + + // Port is available, close the listener + listener.Close() + return true, "", nil +} + +// ValidatePort checks if a port number is valid. +func ValidatePort(portStr string) (int, error) { + port, err := strconv.Atoi(portStr) + if err != nil { + return 0, fmt.Errorf("invalid port number: %s", portStr) + } + + if port < 1 || port > 65535 { + return 0, fmt.Errorf("port must be between 1 and 65535, got %d", port) + } + + return port, nil +} diff --git a/internal/logger/demo_test.go b/internal/logger/demo_test.go new file mode 100644 index 0000000..d1c9bdb --- /dev/null +++ b/internal/logger/demo_test.go @@ -0,0 +1,70 @@ +package logger_test + +import ( + "bytes" + "fmt" + "testing" + + "github.com/nvm/kportal/internal/logger" +) + +// This test demonstrates the logger output formats +func TestLoggerDemo(t *testing.T) { + t.Skip("Demo only - run manually with: go test -v -run TestLoggerDemo") + + fmt.Println("\n=== TEXT FORMAT (DEFAULT) ===") + textBuf := &bytes.Buffer{} + textLogger := logger.New(logger.LevelInfo, logger.FormatText, textBuf) + + textLogger.Info("Port forward started", map[string]interface{}{ + "forward_id": "prod/default/pod/app:8080", + "local_port": 8080, + "pod": "app-xyz123", + }) + + textLogger.Warn("Connection failed, retrying", map[string]interface{}{ + "forward_id": "prod/default/pod/app:8080", + "error": "connection refused", + "retry": 3, + }) + + textLogger.Error("Failed to resolve resource", map[string]interface{}{ + "forward_id": "prod/default/pod/app:8080", + "error": "pod not found", + }) + + fmt.Print(textBuf.String()) + + fmt.Println("\n=== JSON FORMAT ===") + jsonBuf := &bytes.Buffer{} + jsonLogger := logger.New(logger.LevelInfo, logger.FormatJSON, jsonBuf) + + jsonLogger.Info("Port forward started", map[string]interface{}{ + "forward_id": "prod/default/pod/app:8080", + "local_port": 8080, + "pod": "app-xyz123", + }) + + jsonLogger.Warn("Connection failed, retrying", map[string]interface{}{ + "forward_id": "prod/default/pod/app:8080", + "error": "connection refused", + "retry": 3, + }) + + jsonLogger.Error("Failed to resolve resource", map[string]interface{}{ + "forward_id": "prod/default/pod/app:8080", + "error": "pod not found", + }) + + fmt.Print(jsonBuf.String()) + + fmt.Println("\n=== LOG LEVEL FILTERING (Debug level disabled) ===") + filteredBuf := &bytes.Buffer{} + filteredLogger := logger.New(logger.LevelInfo, logger.FormatText, filteredBuf) + + filteredLogger.Debug("This will not appear", nil) + filteredLogger.Info("This will appear", nil) + filteredLogger.Warn("This will also appear", nil) + + fmt.Print(filteredBuf.String()) +} diff --git a/internal/logger/klog_bridge.go b/internal/logger/klog_bridge.go new file mode 100644 index 0000000..8b7a88e --- /dev/null +++ b/internal/logger/klog_bridge.go @@ -0,0 +1,96 @@ +package logger + +import ( + "bytes" + "io" + "strings" + "sync" +) + +// KlogWriter is an io.Writer that routes klog output through our structured logger. +// It parses klog messages and routes them to appropriate log levels. +// It is thread-safe for concurrent writes. +type KlogWriter struct { + logger *Logger + buffer *bytes.Buffer + mu sync.Mutex +} + +// NewKlogWriter creates a new KlogWriter that routes k8s client-go logs +// through our structured logger. +func NewKlogWriter(logger *Logger) *KlogWriter { + return &KlogWriter{ + logger: logger, + buffer: &bytes.Buffer{}, + } +} + +// Write implements io.Writer. +// It parses klog output and routes it through our structured logger. +// This method is thread-safe. +func (w *KlogWriter) Write(p []byte) (n int, err error) { + w.mu.Lock() + defer w.mu.Unlock() + + // Write to buffer first + w.buffer.Write(p) + + // Process complete lines + for { + line, err := w.buffer.ReadString('\n') + if err != nil { + // No complete line yet, write back what we read and wait for more + if err == io.EOF && line != "" { + w.buffer.WriteString(line) + } + break + } + + // Process the complete line + w.processLine(strings.TrimSpace(line)) + } + + return len(p), nil +} + +// processLine parses a klog line and routes it to the appropriate log level. +func (w *KlogWriter) processLine(line string) { + if line == "" { + return + } + + // Parse klog format: "I1124 12:34:56.789012 12345 file.go:123] message" + // First character indicates level: I=Info, W=Warning, E=Error, F=Fatal + if len(line) < 1 { + return + } + + level := line[0] + message := line + + // Try to extract just the message part after "]" + if idx := strings.Index(line, "] "); idx != -1 { + message = line[idx+2:] + } + + // Determine log level and route accordingly + switch level { + case 'I': // Info + w.logger.Debug(message, map[string]interface{}{ + "source": "k8s-client", + }) + case 'W': // Warning + w.logger.Warn(message, map[string]interface{}{ + "source": "k8s-client", + }) + case 'E', 'F': // Error or Fatal + w.logger.Error(message, map[string]interface{}{ + "source": "k8s-client", + }) + default: + // Unknown format, log as debug + w.logger.Debug(message, map[string]interface{}{ + "source": "k8s-client", + }) + } +} diff --git a/internal/logger/klog_bridge_test.go b/internal/logger/klog_bridge_test.go new file mode 100644 index 0000000..29f05c5 --- /dev/null +++ b/internal/logger/klog_bridge_test.go @@ -0,0 +1,280 @@ +package logger + +import ( + "bytes" + "encoding/json" + "fmt" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestKlogWriter(t *testing.T) { + tests := []struct { + name string + input string + expectedLevel string + expectedMsg string + loggerLevel Level + loggerFormat Format + shouldLog bool + description string + }{ + { + name: "info level log", + input: "I1124 12:34:56.789012 12345 portforward.go:123] Starting port forward\n", + expectedLevel: "DEBUG", + expectedMsg: "Starting port forward", + loggerLevel: LevelDebug, + loggerFormat: FormatText, + shouldLog: true, + description: "Info logs from k8s should be routed as DEBUG", + }, + { + name: "warning level log", + input: "W1124 12:34:56.789012 12345 portforward.go:456] Connection unstable\n", + expectedLevel: "WARN", + expectedMsg: "Connection unstable", + loggerLevel: LevelDebug, + loggerFormat: FormatText, + shouldLog: true, + description: "Warning logs should be routed as WARN", + }, + { + name: "error level log", + input: "E1124 12:34:56.789012 12345 portforward.go:789] Connection failed\n", + expectedLevel: "ERROR", + expectedMsg: "Connection failed", + loggerLevel: LevelDebug, + loggerFormat: FormatText, + shouldLog: true, + description: "Error logs should be routed as ERROR", + }, + { + name: "fatal level log", + input: "F1124 12:34:56.789012 12345 portforward.go:999] Fatal error\n", + expectedLevel: "ERROR", + expectedMsg: "Fatal error", + loggerLevel: LevelDebug, + loggerFormat: FormatText, + shouldLog: true, + description: "Fatal logs should be routed as ERROR", + }, + { + name: "multiline input", + input: "I1124 12:34:56.789012 12345 portforward.go:123] First message\nI1124 12:34:57.123456 12345 portforward.go:124] Second message\n", + expectedLevel: "DEBUG", + expectedMsg: "First message", + loggerLevel: LevelDebug, + loggerFormat: FormatText, + shouldLog: true, + description: "Should handle multiple log lines", + }, + { + name: "log filtered by level", + input: "I1124 12:34:56.789012 12345 portforward.go:123] Debug message\n", + expectedLevel: "DEBUG", + expectedMsg: "Debug message", + loggerLevel: LevelInfo, // Logger set to INFO, DEBUG should be filtered + loggerFormat: FormatText, + shouldLog: false, + description: "DEBUG logs should be filtered when logger level is INFO", + }, + { + name: "unknown log format", + input: "X1124 12:34:56.789012 12345 portforward.go:123] Unknown format\n", + expectedLevel: "DEBUG", + expectedMsg: "Unknown format", + loggerLevel: LevelDebug, + loggerFormat: FormatText, + shouldLog: true, + description: "Unknown format should default to DEBUG", + }, + { + name: "empty line", + input: "\n", + expectedLevel: "", + expectedMsg: "", + loggerLevel: LevelDebug, + loggerFormat: FormatText, + shouldLog: false, + description: "Empty lines should be ignored", + }, + { + name: "partial line no newline", + input: "I1124 12:34:56.789012 12345 portforward.go:123] Partial", + expectedLevel: "", + expectedMsg: "", + loggerLevel: LevelDebug, + loggerFormat: FormatText, + shouldLog: false, + description: "Partial lines without newline should be buffered", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create output buffer + var buf bytes.Buffer + + // Create logger with specified level and format + logger := New(tt.loggerLevel, tt.loggerFormat, &buf) + + // Create klog writer + klogWriter := NewKlogWriter(logger) + + // Write input + n, err := klogWriter.Write([]byte(tt.input)) + require.NoError(t, err) + assert.Equal(t, len(tt.input), n) + + // Check output + output := buf.String() + + if !tt.shouldLog { + assert.Empty(t, output, "Expected no log output") + return + } + + if tt.loggerFormat == FormatText { + // Text format: [LEVEL] message + assert.Contains(t, output, fmt.Sprintf("[%s]", tt.expectedLevel)) + assert.Contains(t, output, tt.expectedMsg) + assert.Contains(t, output, "k8s-client") // Should include source field + } else { + // JSON format + var entry logEntry + lines := strings.Split(strings.TrimSpace(output), "\n") + if len(lines) > 0 { + err := json.Unmarshal([]byte(lines[0]), &entry) + require.NoError(t, err) + assert.Equal(t, tt.expectedLevel, entry.Level) + assert.Equal(t, tt.expectedMsg, entry.Message) + assert.Equal(t, "k8s-client", entry.Fields["source"]) + } + } + }) + } +} + +func TestKlogWriterBuffering(t *testing.T) { + tests := []struct { + name string + writes []string + expectCount int + description string + }{ + { + name: "single complete line", + writes: []string{ + "I1124 12:34:56.789012 12345 portforward.go:123] Complete line\n", + }, + expectCount: 1, + description: "Single complete line should produce one log entry", + }, + { + name: "partial then complete", + writes: []string{ + "I1124 12:34:56.789012 12345 portforward.go:123] Partial ", + "line\n", + }, + expectCount: 1, + description: "Partial writes should be buffered and combined", + }, + { + name: "multiple complete lines in chunks", + writes: []string{ + "I1124 12:34:56.789012 12345 portforward.go:123] First\n", + "I1124 12:34:57.123456 12345 portforward.go:124] Second\n", + "I1124 12:34:58.456789 12345 portforward.go:125] Third\n", + }, + expectCount: 3, + description: "Multiple complete lines should produce multiple log entries", + }, + { + name: "mixed partial and complete", + writes: []string{ + "I1124 12:34:56.789012 12345 portforward.go:123] First\nI1124 12:34:57.123456 12345 port", + "forward.go:124] Second\n", + }, + expectCount: 2, + description: "Mixed partial and complete lines should be handled correctly", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var buf bytes.Buffer + logger := New(LevelDebug, FormatText, &buf) + klogWriter := NewKlogWriter(logger) + + // Write all chunks + for _, write := range tt.writes { + _, err := klogWriter.Write([]byte(write)) + require.NoError(t, err) + } + + // Count log entries (each line starts with [LEVEL]) + output := buf.String() + count := strings.Count(output, "[DEBUG]") + + strings.Count(output, "[INFO]") + + strings.Count(output, "[WARN]") + + strings.Count(output, "[ERROR]") + + assert.Equal(t, tt.expectCount, count, "Expected %d log entries, got %d", tt.expectCount, count) + }) + } +} + +func TestKlogWriterJSONFormat(t *testing.T) { + var buf bytes.Buffer + logger := New(LevelDebug, FormatJSON, &buf) + klogWriter := NewKlogWriter(logger) + + // Write a k8s log line + input := "I1124 12:34:56.789012 12345 portforward.go:123] Starting port forward\n" + _, err := klogWriter.Write([]byte(input)) + require.NoError(t, err) + + // Parse JSON output + var entry logEntry + err = json.Unmarshal(buf.Bytes(), &entry) + require.NoError(t, err) + + // Verify JSON structure + assert.Equal(t, "DEBUG", entry.Level) + assert.Equal(t, "Starting port forward", entry.Message) + assert.NotEmpty(t, entry.Time) + assert.Equal(t, "k8s-client", entry.Fields["source"]) +} + +func TestKlogWriterConcurrency(t *testing.T) { + // Test that concurrent writes don't cause data races + var buf bytes.Buffer + logger := New(LevelDebug, FormatText, &buf) + klogWriter := NewKlogWriter(logger) + + done := make(chan bool) + numGoroutines := 10 + numWrites := 100 + + for i := 0; i < numGoroutines; i++ { + go func(id int) { + for j := 0; j < numWrites; j++ { + msg := fmt.Sprintf("I1124 12:34:56.789012 12345 test.go:123] Message from goroutine %d iteration %d\n", id, j) + klogWriter.Write([]byte(msg)) + } + done <- true + }(i) + } + + // Wait for all goroutines + for i := 0; i < numGoroutines; i++ { + <-done + } + + // Just verify we didn't panic (data race detector would catch issues) + assert.NotEmpty(t, buf.String()) +} diff --git a/internal/logger/logger.go b/internal/logger/logger.go new file mode 100644 index 0000000..26f0d9f --- /dev/null +++ b/internal/logger/logger.go @@ -0,0 +1,159 @@ +package logger + +import ( + "encoding/json" + "fmt" + "io" + "os" + "time" +) + +type Level int + +const ( + LevelDebug Level = iota + LevelInfo + LevelWarn + LevelError +) + +type Format int + +const ( + FormatText Format = iota + FormatJSON +) + +type Logger struct { + level Level + format Format + output io.Writer +} + +type logEntry struct { + Time string `json:"time"` + Level string `json:"level"` + Message string `json:"message"` + Fields map[string]interface{} `json:"fields,omitempty"` +} + +func New(level Level, format Format, output io.Writer) *Logger { + if output == nil { + output = os.Stderr + } + return &Logger{ + level: level, + format: format, + output: output, + } +} + +func (l *Logger) log(level Level, msg string, fields map[string]interface{}) { + if level < l.level { + return + } + + levelStr := levelToString(level) + + if l.format == FormatJSON { + entry := logEntry{ + Time: time.Now().Format(time.RFC3339), + Level: levelStr, + Message: msg, + Fields: fields, + } + data, _ := json.Marshal(entry) + fmt.Fprintln(l.output, string(data)) + } else { + // Text format + if len(fields) > 0 { + fmt.Fprintf(l.output, "[%s] %s %v\n", levelStr, msg, fields) + } else { + fmt.Fprintf(l.output, "[%s] %s\n", levelStr, msg) + } + } +} + +func (l *Logger) Debug(msg string, fields ...map[string]interface{}) { + f := make(map[string]interface{}) + if len(fields) > 0 { + f = fields[0] + } + l.log(LevelDebug, msg, f) +} + +func (l *Logger) Info(msg string, fields ...map[string]interface{}) { + f := make(map[string]interface{}) + if len(fields) > 0 { + f = fields[0] + } + l.log(LevelInfo, msg, f) +} + +func (l *Logger) Warn(msg string, fields ...map[string]interface{}) { + f := make(map[string]interface{}) + if len(fields) > 0 { + f = fields[0] + } + l.log(LevelWarn, msg, f) +} + +func (l *Logger) Error(msg string, fields ...map[string]interface{}) { + f := make(map[string]interface{}) + if len(fields) > 0 { + f = fields[0] + } + l.log(LevelError, msg, f) +} + +func levelToString(level Level) string { + switch level { + case LevelDebug: + return "DEBUG" + case LevelInfo: + return "INFO" + case LevelWarn: + return "WARN" + case LevelError: + return "ERROR" + default: + return "UNKNOWN" + } +} + +// Global logger for backward compatibility +var globalLogger *Logger + +func Init(level Level, format Format, output ...io.Writer) { + var out io.Writer + if len(output) > 0 && output[0] != nil { + out = output[0] + } else { + out = os.Stderr + } + globalLogger = New(level, format, out) +} + +func Debug(msg string, fields ...map[string]interface{}) { + if globalLogger != nil { + globalLogger.Debug(msg, fields...) + } +} + +func Info(msg string, fields ...map[string]interface{}) { + if globalLogger != nil { + globalLogger.Info(msg, fields...) + } +} + +func Warn(msg string, fields ...map[string]interface{}) { + if globalLogger != nil { + globalLogger.Warn(msg, fields...) + } +} + +func Error(msg string, fields ...map[string]interface{}) { + if globalLogger != nil { + globalLogger.Error(msg, fields...) + } +} diff --git a/internal/logger/logger_test.go b/internal/logger/logger_test.go new file mode 100644 index 0000000..714ff4b --- /dev/null +++ b/internal/logger/logger_test.go @@ -0,0 +1,521 @@ +package logger + +import ( + "bytes" + "encoding/json" + "io" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLoggerTextFormat(t *testing.T) { + tests := []struct { + name string + level Level + logLevel Level + message string + fields map[string]interface{} + expectOutput bool + expectContains []string + }{ + { + name: "info logged at info level", + level: LevelInfo, + logLevel: LevelInfo, + message: "test message", + fields: nil, + expectOutput: true, + expectContains: []string{"[INFO]", "test message"}, + }, + { + name: "debug filtered at info level", + level: LevelInfo, + logLevel: LevelDebug, + message: "debug message", + fields: nil, + expectOutput: false, + expectContains: []string{}, + }, + { + name: "error logged at info level", + level: LevelInfo, + logLevel: LevelError, + message: "error message", + fields: nil, + expectOutput: true, + expectContains: []string{"[ERROR]", "error message"}, + }, + { + name: "info with fields", + level: LevelInfo, + logLevel: LevelInfo, + message: "test message", + fields: map[string]interface{}{ + "key1": "value1", + "key2": 123, + }, + expectOutput: true, + expectContains: []string{"[INFO]", "test message", "key1", "value1"}, + }, + { + name: "warn logged at warn level", + level: LevelWarn, + logLevel: LevelWarn, + message: "warning message", + fields: nil, + expectOutput: true, + expectContains: []string{"[WARN]", "warning message"}, + }, + { + name: "info filtered at warn level", + level: LevelWarn, + logLevel: LevelInfo, + message: "info message", + fields: nil, + expectOutput: false, + expectContains: []string{}, + }, + { + name: "debug logged at debug level", + level: LevelDebug, + logLevel: LevelDebug, + message: "debug message", + fields: nil, + expectOutput: true, + expectContains: []string{"[DEBUG]", "debug message"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + buf := &bytes.Buffer{} + logger := New(tt.level, FormatText, buf) + + // Log at the specified level + switch tt.logLevel { + case LevelDebug: + if tt.fields != nil { + logger.Debug(tt.message, tt.fields) + } else { + logger.Debug(tt.message) + } + case LevelInfo: + if tt.fields != nil { + logger.Info(tt.message, tt.fields) + } else { + logger.Info(tt.message) + } + case LevelWarn: + if tt.fields != nil { + logger.Warn(tt.message, tt.fields) + } else { + logger.Warn(tt.message) + } + case LevelError: + if tt.fields != nil { + logger.Error(tt.message, tt.fields) + } else { + logger.Error(tt.message) + } + } + + output := buf.String() + + if tt.expectOutput { + assert.NotEmpty(t, output, "Expected log output but got none") + for _, expected := range tt.expectContains { + assert.Contains(t, output, expected, "Expected output to contain: %s", expected) + } + } else { + assert.Empty(t, output, "Expected no log output but got: %s", output) + } + }) + } +} + +func TestLoggerJSONFormat(t *testing.T) { + tests := []struct { + name string + level Level + logLevel Level + message string + fields map[string]interface{} + expectOutput bool + expectLevel string + }{ + { + name: "info logged at info level", + level: LevelInfo, + logLevel: LevelInfo, + message: "test message", + fields: nil, + expectOutput: true, + expectLevel: "INFO", + }, + { + name: "debug filtered at info level", + level: LevelInfo, + logLevel: LevelDebug, + message: "debug message", + fields: nil, + expectOutput: false, + expectLevel: "", + }, + { + name: "error logged at debug level", + level: LevelDebug, + logLevel: LevelError, + message: "error message", + fields: nil, + expectOutput: true, + expectLevel: "ERROR", + }, + { + name: "info with fields", + level: LevelInfo, + logLevel: LevelInfo, + message: "test message", + fields: map[string]interface{}{ + "context": "production", + "port": 8080, + "retry": 3, + }, + expectOutput: true, + expectLevel: "INFO", + }, + { + name: "warn at warn level", + level: LevelWarn, + logLevel: LevelWarn, + message: "warning message", + fields: nil, + expectOutput: true, + expectLevel: "WARN", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + buf := &bytes.Buffer{} + logger := New(tt.level, FormatJSON, buf) + + // Log at the specified level + switch tt.logLevel { + case LevelDebug: + if tt.fields != nil { + logger.Debug(tt.message, tt.fields) + } else { + logger.Debug(tt.message) + } + case LevelInfo: + if tt.fields != nil { + logger.Info(tt.message, tt.fields) + } else { + logger.Info(tt.message) + } + case LevelWarn: + if tt.fields != nil { + logger.Warn(tt.message, tt.fields) + } else { + logger.Warn(tt.message) + } + case LevelError: + if tt.fields != nil { + logger.Error(tt.message, tt.fields) + } else { + logger.Error(tt.message) + } + } + + output := buf.String() + + if tt.expectOutput { + assert.NotEmpty(t, output, "Expected log output but got none") + + // Parse JSON + var entry logEntry + err := json.Unmarshal([]byte(strings.TrimSpace(output)), &entry) + require.NoError(t, err, "Failed to parse JSON output: %s", output) + + // Validate fields + assert.Equal(t, tt.expectLevel, entry.Level) + assert.Equal(t, tt.message, entry.Message) + assert.NotEmpty(t, entry.Time, "Time field should not be empty") + + // Validate custom fields if provided + if tt.fields != nil { + require.NotNil(t, entry.Fields, "Expected fields in JSON output") + for key, expectedValue := range tt.fields { + actualValue, exists := entry.Fields[key] + assert.True(t, exists, "Expected field %s not found in output", key) + // JSON unmarshaling converts numbers to float64 + if floatVal, ok := expectedValue.(int); ok { + assert.Equal(t, float64(floatVal), actualValue) + } else { + assert.Equal(t, expectedValue, actualValue) + } + } + } + } else { + assert.Empty(t, output, "Expected no log output but got: %s", output) + } + }) + } +} + +func TestGlobalLogger(t *testing.T) { + tests := []struct { + name string + initLevel Level + initFormat Format + logFunc func(string, ...map[string]interface{}) + message string + expectContains string + }{ + { + name: "global info logger text", + initLevel: LevelInfo, + initFormat: FormatText, + logFunc: Info, + message: "global info message", + expectContains: "[INFO]", + }, + { + name: "global error logger text", + initLevel: LevelInfo, + initFormat: FormatText, + logFunc: Error, + message: "global error message", + expectContains: "[ERROR]", + }, + { + name: "global warn logger json", + initLevel: LevelWarn, + initFormat: FormatJSON, + logFunc: Warn, + message: "global warn message", + expectContains: `"level":"WARN"`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Capture stderr by replacing globalLogger's output + buf := &bytes.Buffer{} + Init(tt.initLevel, tt.initFormat) + globalLogger.output = buf + + // Call the global log function + tt.logFunc(tt.message) + + output := buf.String() + assert.Contains(t, output, tt.expectContains) + assert.Contains(t, output, tt.message) + }) + } +} + +func TestLogLevelsFiltering(t *testing.T) { + tests := []struct { + name string + loggerLevel Level + logAtLevels []Level + expectOutputs []bool + }{ + { + name: "debug level logs everything", + loggerLevel: LevelDebug, + logAtLevels: []Level{LevelDebug, LevelInfo, LevelWarn, LevelError}, + expectOutputs: []bool{true, true, true, true}, + }, + { + name: "info level filters debug", + loggerLevel: LevelInfo, + logAtLevels: []Level{LevelDebug, LevelInfo, LevelWarn, LevelError}, + expectOutputs: []bool{false, true, true, true}, + }, + { + name: "warn level filters debug and info", + loggerLevel: LevelWarn, + logAtLevels: []Level{LevelDebug, LevelInfo, LevelWarn, LevelError}, + expectOutputs: []bool{false, false, true, true}, + }, + { + name: "error level only logs errors", + loggerLevel: LevelError, + logAtLevels: []Level{LevelDebug, LevelInfo, LevelWarn, LevelError}, + expectOutputs: []bool{false, false, false, true}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + buf := &bytes.Buffer{} + logger := New(tt.loggerLevel, FormatText, buf) + + for i, logLevel := range tt.logAtLevels { + buf.Reset() + + switch logLevel { + case LevelDebug: + logger.Debug("test") + case LevelInfo: + logger.Info("test") + case LevelWarn: + logger.Warn("test") + case LevelError: + logger.Error("test") + } + + hasOutput := buf.Len() > 0 + assert.Equal(t, tt.expectOutputs[i], hasOutput, + "Level %v at logger level %v: expected output=%v, got=%v", + logLevel, tt.loggerLevel, tt.expectOutputs[i], hasOutput) + } + }) + } +} + +func TestLoggerNilOutput(t *testing.T) { + // Test that logger defaults to os.Stderr when output is nil + logger := New(LevelInfo, FormatText, nil) + assert.NotNil(t, logger.output, "Logger output should not be nil") +} + +func TestLevelToString(t *testing.T) { + tests := []struct { + level Level + expected string + }{ + {LevelDebug, "DEBUG"}, + {LevelInfo, "INFO"}, + {LevelWarn, "WARN"}, + {LevelError, "ERROR"}, + {Level(999), "UNKNOWN"}, + } + + for _, tt := range tests { + t.Run(tt.expected, func(t *testing.T) { + result := levelToString(tt.level) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestJSONFieldTypes(t *testing.T) { + tests := []struct { + name string + fields map[string]interface{} + }{ + { + name: "string fields", + fields: map[string]interface{}{ + "key1": "value1", + "key2": "value2", + }, + }, + { + name: "numeric fields", + fields: map[string]interface{}{ + "port": 8080, + "timeout": 30, + "retry": 3, + }, + }, + { + name: "boolean fields", + fields: map[string]interface{}{ + "enabled": true, + "running": false, + }, + }, + { + name: "mixed types", + fields: map[string]interface{}{ + "context": "production", + "port": 8080, + "enabled": true, + "namespace": "default", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + buf := &bytes.Buffer{} + logger := New(LevelInfo, FormatJSON, buf) + + logger.Info("test message", tt.fields) + + var entry logEntry + err := json.Unmarshal([]byte(strings.TrimSpace(buf.String())), &entry) + require.NoError(t, err) + + assert.Equal(t, len(tt.fields), len(entry.Fields), + "Field count mismatch") + + for key := range tt.fields { + _, exists := entry.Fields[key] + assert.True(t, exists, "Field %s not found in JSON output", key) + } + }) + } +} + +func TestInitWithCustomOutput(t *testing.T) { + tests := []struct { + name string + output io.Writer + expectDiscard bool + description string + }{ + { + name: "init with custom buffer", + output: &bytes.Buffer{}, + expectDiscard: false, + description: "Should use provided buffer", + }, + { + name: "init with io.Discard", + output: io.Discard, + expectDiscard: true, + description: "Should use io.Discard to silence output", + }, + { + name: "init without output defaults to stderr", + output: nil, + expectDiscard: false, + description: "Should default to stderr when no output provided", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.output != nil { + Init(LevelInfo, FormatText, tt.output) + } else { + Init(LevelInfo, FormatText) + } + + // Verify global logger was initialized + assert.NotNil(t, globalLogger, "Global logger should be initialized") + + if tt.output != nil && !tt.expectDiscard { + // For buffer, verify output works + if buf, ok := tt.output.(*bytes.Buffer); ok { + Info("test message") + output := buf.String() + assert.Contains(t, output, "test message") + assert.Contains(t, output, "[INFO]") + } + } else if tt.expectDiscard { + // For io.Discard, verify no output appears (we can't really test this directly, + // but we can verify the logger was set with the right output) + assert.Equal(t, io.Discard, globalLogger.output) + } + }) + } +} diff --git a/internal/ui/bubbletea_ui.go b/internal/ui/bubbletea_ui.go index 7029c18..dac36a2 100644 --- a/internal/ui/bubbletea_ui.go +++ b/internal/ui/bubbletea_ui.go @@ -8,6 +8,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/nvm/kportal/internal/config" + "github.com/nvm/kportal/internal/k8s" ) // ForwardUpdateMsg is sent when a forward status changes @@ -44,11 +45,29 @@ type BubbleTeaUI struct { toggleCallback func(id string, enable bool) version string errors map[string]string // Track error messages by forward ID + + // Modal wizard state + viewMode ViewMode + addWizard *AddWizardState + removeWizard *RemoveWizardState + + // Delete confirmation state + deleteConfirming bool + deleteConfirmID string + deleteConfirmAlias string + deleteConfirmCursor int // 0 = Yes, 1 = No + + // Dependencies for wizards + discovery *k8s.Discovery + mutator *config.Mutator + configPath string } // bubbletea model type model struct { - ui *BubbleTeaUI + ui *BubbleTeaUI + termWidth int + termHeight int } // NewBubbleTeaUI creates a new bubbletea-based UI @@ -61,11 +80,22 @@ func NewBubbleTeaUI(toggleCallback func(id string, enable bool), version string) toggleCallback: toggleCallback, version: version, errors: make(map[string]string), + viewMode: ViewModeMain, } return ui } +// SetWizardDependencies sets the dependencies needed for the add/remove wizards +func (ui *BubbleTeaUI) SetWizardDependencies(discovery *k8s.Discovery, mutator *config.Mutator, configPath string) { + ui.mu.Lock() + defer ui.mu.Unlock() + + ui.discovery = discovery + ui.mutator = mutator + ui.configPath = configPath +} + // Start starts the bubbletea application func (ui *BubbleTeaUI) Start() error { m := model{ui: ui} @@ -187,33 +217,55 @@ func (m model) Init() tea.Cmd { } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + m.ui.mu.RLock() + viewMode := m.ui.viewMode + m.ui.mu.RUnlock() + switch msg := msg.(type) { + case tea.WindowSizeMsg: + // Update terminal dimensions on resize + m.termWidth = msg.Width + m.termHeight = msg.Height + return m, nil + case tea.KeyMsg: - switch msg.String() { - case "ctrl+c", "q": - return m, tea.Quit - case "up", "k": - m.ui.moveSelection(-1) - case "down", "j": - m.ui.moveSelection(1) - case " ", "enter": - m.ui.toggleSelected() + // Route based on current view mode + switch viewMode { + case ViewModeMain: + return m.handleMainViewKeys(msg) + case ViewModeAddWizard: + return m.handleAddWizardKeys(msg) + case ViewModeRemoveWizard: + return m.handleRemoveWizardKeys(msg) } - case ForwardAddMsg: - // Already handled in AddForward, just trigger re-render + // Forward management messages (always update main view data) + case ForwardAddMsg, ForwardUpdateMsg, ForwardErrorMsg, ForwardRemoveMsg: return m, nil - case ForwardUpdateMsg: - // Already handled in UpdateStatus, just trigger re-render - return m, nil - - case ForwardErrorMsg: - // Already handled in SetError, just trigger re-render - return m, nil - - case ForwardRemoveMsg: - // Already handled in Remove, just trigger re-render + // Wizard-specific messages + case ContextsLoadedMsg: + return m.handleContextsLoaded(msg) + case NamespacesLoadedMsg: + return m.handleNamespacesLoaded(msg) + case PodsLoadedMsg: + return m.handlePodsLoaded(msg) + case ServicesLoadedMsg: + return m.handleServicesLoaded(msg) + case SelectorValidatedMsg: + return m.handleSelectorValidated(msg) + case PortCheckedMsg: + return m.handlePortChecked(msg) + case ForwardSavedMsg: + return m.handleForwardSaved(msg) + case ForwardsRemovedMsg: + return m.handleForwardsRemoved(msg) + case WizardCompleteMsg: + m.ui.mu.Lock() + m.ui.viewMode = ViewModeMain + m.ui.addWizard = nil + m.ui.removeWizard = nil + m.ui.mu.Unlock() return m, nil } @@ -221,11 +273,57 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (m model) View() string { + m.ui.mu.RLock() + viewMode := m.ui.viewMode + deleteConfirming := m.ui.deleteConfirming + m.ui.mu.RUnlock() + + // Always render main view as base + mainView := m.renderMainView() + + // Use actual terminal dimensions for proper centering + termWidth := m.termWidth + termHeight := m.termHeight + + // Fallback to reasonable defaults if dimensions not yet received + if termWidth == 0 { + termWidth = 120 + } + if termHeight == 0 { + termHeight = 40 + } + + // Overlay delete confirmation if active + if deleteConfirming { + modal := m.renderDeleteConfirmation() + return overlayContent(mainView, modal, termWidth, termHeight) + } + + // Overlay wizard if active + switch viewMode { + case ViewModeAddWizard: + modal := m.renderAddWizard() + return overlayContent(mainView, modal, termWidth, termHeight) + case ViewModeRemoveWizard: + modal := m.renderRemoveWizard() + return overlayContent(mainView, modal, termWidth, termHeight) + default: + return mainView + } +} + +func (m model) renderMainView() string { m.ui.mu.RLock() defer m.ui.mu.RUnlock() var b strings.Builder + // Get terminal dimensions for proper sizing + termHeight := m.termHeight + if termHeight == 0 { + termHeight = 40 // Fallback + } + // Styles titleStyle := lipgloss.NewStyle(). Bold(true). @@ -350,21 +448,7 @@ func (m model) View() string { } } - // Footer - b.WriteString("\n") - footerStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("240")) - keyStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("220")) - - footer := fmt.Sprintf("%s/%s: Navigate %s: Toggle %s: Quit │ Total: %d", - keyStyle.Render("↑↓"), - keyStyle.Render("jk"), - keyStyle.Render("Space"), - keyStyle.Render("q"), - len(m.ui.forwardOrder)) - - b.WriteString(footerStyle.Render(footer)) - - // Display errors if any + // Display errors if any (before footer) if len(m.ui.errors) > 0 { b.WriteString("\n\n") errorHeaderStyle := lipgloss.NewStyle(). @@ -374,20 +458,104 @@ func (m model) View() string { b.WriteString(errorHeaderStyle.Render("Errors:")) b.WriteString("\n") + errorLineStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("196")). + Width(118). // Slightly less than table width (120) for padding + MaxWidth(118) + for id, errMsg := range m.ui.errors { // Find the forward to display its alias if fwd, ok := m.ui.forwards[id]; ok { - errorLineStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("196")) - line := fmt.Sprintf(" • %s: %s", fwd.Alias, errMsg) - b.WriteString(errorLineStyle.Render(line)) - b.WriteString("\n") + // Format: " • alias: error message" + prefix := fmt.Sprintf(" • %s: ", fwd.Alias) + + // Wrap the error message if it's too long + // Max line length is 118, subtract prefix length + maxErrLen := 118 - len(prefix) + wrappedMsg := wrapText(errMsg, maxErrLen) + + // Render first line with prefix + lines := strings.Split(wrappedMsg, "\n") + if len(lines) > 0 { + b.WriteString(errorLineStyle.Render(prefix + lines[0])) + b.WriteString("\n") + + // Render subsequent lines with indentation + indent := strings.Repeat(" ", len(prefix)) + for i := 1; i < len(lines); i++ { + b.WriteString(errorLineStyle.Render(indent + lines[i])) + b.WriteString("\n") + } + } } } } + // Calculate current content height + currentContent := b.String() + currentLines := strings.Count(currentContent, "\n") + 1 + + // Footer styles + 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", + keyStyle.Render("↑↓"), + keyStyle.Render("jk"), + keyStyle.Render("Space"), + keyStyle.Render("n"), + keyStyle.Render("e"), + keyStyle.Render("d"), + keyStyle.Render("q"), + len(m.ui.forwardOrder)) + + // Fill space to push footer to bottom (reserve 2 lines: 1 for spacing, 1 for footer) + footerHeight := 2 + remainingLines := termHeight - currentLines - footerHeight + if remainingLines > 0 { + b.WriteString(strings.Repeat("\n", remainingLines)) + } + + // Add footer at bottom + b.WriteString("\n") + b.WriteString(footerStyle.Render(footer)) + return b.String() } +// wrapText wraps text to the specified width, breaking at word boundaries +func wrapText(text string, width int) string { + if len(text) <= width { + return text + } + + var result strings.Builder + var line strings.Builder + words := strings.Fields(text) + + for i, word := range words { + // If adding this word would exceed width, start new line + if line.Len()+len(word)+1 > width && line.Len() > 0 { + result.WriteString(line.String()) + result.WriteString("\n") + line.Reset() + } + + // Add space before word (except first word on line) + if line.Len() > 0 { + line.WriteString(" ") + } + line.WriteString(word) + + // Last word - flush the line + if i == len(words)-1 { + result.WriteString(line.String()) + } + } + + return result.String() +} + // moveSelection moves the selection up or down func (ui *BubbleTeaUI) moveSelection(delta int) { ui.mu.Lock() @@ -406,6 +574,65 @@ func (ui *BubbleTeaUI) moveSelection(delta int) { } } +// renderDeleteConfirmation renders the delete confirmation dialog +func (m model) renderDeleteConfirmation() string { + m.ui.mu.RLock() + defer m.ui.mu.RUnlock() + + var b strings.Builder + + // Use wizard color palette for consistency + titleStyle := lipgloss.NewStyle(). + Bold(true). + Foreground(warningColor). // Yellow for warning (delete action) + Padding(0, 1) + + buttonSelectedStyle := lipgloss.NewStyle(). + Background(primaryColor). // Pink/Magenta background + Foreground(lipgloss.Color("230")). // Light yellow text + Bold(true). + Padding(0, 1) + + buttonUnselectedStyle := lipgloss.NewStyle(). + Foreground(mutedColor). // Gray + Padding(0, 1) + + deleteInfoStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("252")). // Light gray for info text + Italic(true) + + // Title + b.WriteString(titleStyle.Render("⚠ Delete Port Forward")) + b.WriteString("\n\n") + + // Message + b.WriteString("Are you sure you want to delete:\n\n") + b.WriteString(deleteInfoStyle.Render(" " + m.ui.deleteConfirmAlias)) + b.WriteString("\n\n") + + // Buttons + if m.ui.deleteConfirmCursor == 0 { + b.WriteString(buttonSelectedStyle.Render(" Yes ")) + b.WriteString(" ") + b.WriteString(buttonUnselectedStyle.Render(" No ")) + } else { + b.WriteString(buttonUnselectedStyle.Render(" Yes ")) + b.WriteString(" ") + b.WriteString(buttonSelectedStyle.Render(" No ")) + } + + b.WriteString("\n\n") + b.WriteString(helpStyle.Render("←/→: Navigate Enter: Confirm Esc: Cancel")) + + // Wrap in a box using wizard style + boxStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(accentColor). // Purple border like other wizards + Padding(1, 2) + + return boxStyle.Render(b.String()) +} + // toggleSelected toggles the selected forward on/off func (ui *BubbleTeaUI) toggleSelected() { ui.mu.Lock() diff --git a/internal/ui/wizard_commands.go b/internal/ui/wizard_commands.go new file mode 100644 index 0000000..8758e41 --- /dev/null +++ b/internal/ui/wizard_commands.go @@ -0,0 +1,222 @@ +package ui + +import ( + "context" + "fmt" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/nvm/kportal/internal/config" + "github.com/nvm/kportal/internal/k8s" +) + +const ( + k8sAPITimeout = 10 * time.Second +) + +// Messages sent from async commands back to the update loop + +// ContextsLoadedMsg is sent when contexts have been loaded +type ContextsLoadedMsg struct { + contexts []string + err error +} + +// NamespacesLoadedMsg is sent when namespaces have been loaded +type NamespacesLoadedMsg struct { + namespaces []string + err error +} + +// PodsLoadedMsg is sent when pods have been loaded +type PodsLoadedMsg struct { + pods []k8s.PodInfo + err error +} + +// ServicesLoadedMsg is sent when services have been loaded +type ServicesLoadedMsg struct { + services []k8s.ServiceInfo + err error +} + +// SelectorValidatedMsg is sent when a selector has been validated +type SelectorValidatedMsg struct { + valid bool + pods []k8s.PodInfo + err error +} + +// PortCheckedMsg is sent when a port's availability has been checked +type PortCheckedMsg struct { + port int + available bool + message string +} + +// ForwardSavedMsg is sent when a forward has been saved to config +type ForwardSavedMsg struct { + success bool + err error +} + +// ForwardsRemovedMsg is sent when forwards have been removed from config +type ForwardsRemovedMsg struct { + success bool + count int + err error +} + +// WizardCompleteMsg signals that the wizard has completed +type WizardCompleteMsg struct{} + +// Command functions (return tea.Cmd) + +// loadContextsCmd loads available Kubernetes contexts +func loadContextsCmd(discovery *k8s.Discovery) tea.Cmd { + return func() tea.Msg { + contexts, err := discovery.ListContexts() + if err != nil { + return ContextsLoadedMsg{err: err} + } + return ContextsLoadedMsg{contexts: contexts} + } +} + +// loadNamespacesCmd loads namespaces for the given context +func loadNamespacesCmd(discovery *k8s.Discovery, contextName string) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), k8sAPITimeout) + defer cancel() + + namespaces, err := discovery.ListNamespaces(ctx, contextName) + if err != nil { + return NamespacesLoadedMsg{err: err} + } + return NamespacesLoadedMsg{namespaces: namespaces} + } +} + +// loadPodsCmd loads pods for the given context and namespace +func loadPodsCmd(discovery *k8s.Discovery, contextName, namespace string) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), k8sAPITimeout) + defer cancel() + + pods, err := discovery.ListPods(ctx, contextName, namespace) + if err != nil { + return PodsLoadedMsg{err: err} + } + return PodsLoadedMsg{pods: pods} + } +} + +// loadServicesCmd loads services for the given context and namespace +func loadServicesCmd(discovery *k8s.Discovery, contextName, namespace string) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), k8sAPITimeout) + defer cancel() + + services, err := discovery.ListServices(ctx, contextName, namespace) + if err != nil { + return ServicesLoadedMsg{err: err} + } + return ServicesLoadedMsg{services: services} + } +} + +// validateSelectorCmd validates a label selector and returns matching pods +func validateSelectorCmd(discovery *k8s.Discovery, contextName, namespace, selector string) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), k8sAPITimeout) + defer cancel() + + pods, err := discovery.ListPodsWithSelector(ctx, contextName, namespace, selector) + if err != nil { + return SelectorValidatedMsg{valid: false, err: err} + } + + return SelectorValidatedMsg{ + valid: len(pods) > 0, + pods: pods, + } + } +} + +// checkPortCmd checks if a local port is available +func checkPortCmd(port int) tea.Cmd { + return func() tea.Msg { + available, processInfo, err := k8s.CheckPortAvailability(port) + + msg := "" + if err != nil { + msg = fmt.Sprintf("āœ— Error: %v", err) + } else if available { + msg = fmt.Sprintf("āœ“ Port %d available", port) + } else { + msg = fmt.Sprintf("āœ— Port %d in use by %s", port, processInfo) + } + + return PortCheckedMsg{ + port: port, + available: available, + message: msg, + } + } +} + +// saveForwardCmd saves a new forward to the configuration file +func saveForwardCmd(mutator *config.Mutator, contextName, namespace string, fwd config.Forward) tea.Cmd { + return func() tea.Msg { + err := mutator.AddForward(contextName, namespace, fwd) + return ForwardSavedMsg{ + success: err == nil, + err: err, + } + } +} + +// updateForwardCmd atomically updates an existing forward (used in edit mode) +func updateForwardCmd(mutator *config.Mutator, oldID, contextName, namespace string, fwd config.Forward) tea.Cmd { + return func() tea.Msg { + err := mutator.UpdateForward(oldID, contextName, namespace, fwd) + return ForwardSavedMsg{ + success: err == nil, + err: err, + } + } +} + +// removeForwardsCmd removes selected forwards from the configuration file +func removeForwardsCmd(mutator *config.Mutator, forwards []RemovableForward) tea.Cmd { + return func() tea.Msg { + // Create a map of IDs to remove + idsToRemove := make(map[string]bool) + for _, fwd := range forwards { + idsToRemove[fwd.ID] = true + } + + // Remove forwards matching the IDs + err := mutator.RemoveForwards(func(ctx, ns string, fwd config.Forward) bool { + return idsToRemove[fwd.ID()] + }) + + return ForwardsRemovedMsg{ + success: err == nil, + count: len(forwards), + err: err, + } + } +} + +// removeForwardByIDCmd removes a single forward by its ID +func removeForwardByIDCmd(mutator *config.Mutator, id string) tea.Cmd { + return func() tea.Msg { + err := mutator.RemoveForwardByID(id) + return ForwardsRemovedMsg{ + success: err == nil, + count: 1, + err: err, + } + } +} diff --git a/internal/ui/wizard_handlers.go b/internal/ui/wizard_handlers.go new file mode 100644 index 0000000..ff7a7ec --- /dev/null +++ b/internal/ui/wizard_handlers.go @@ -0,0 +1,763 @@ +package ui + +import ( + "fmt" + "strconv" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/nvm/kportal/internal/config" + "github.com/nvm/kportal/internal/k8s" +) + +// handleMainViewKeys handles keyboard input in the main view +func (m model) handleMainViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + // If delete confirmation is showing, handle it separately + if m.ui.deleteConfirming { + return m.handleDeleteConfirmation(msg) + } + + switch msg.String() { + case "ctrl+c", "q": + return m, tea.Quit + + case "up", "k": + m.ui.moveSelection(-1) + + case "down", "j": + m.ui.moveSelection(1) + + case " ", "enter": + m.ui.toggleSelected() + + case "n": // Enter add wizard + m.ui.mu.Lock() + if m.ui.discovery == nil || m.ui.mutator == nil { + // Dependencies not set up + m.ui.mu.Unlock() + return m, nil + } + + m.ui.viewMode = ViewModeAddWizard + m.ui.addWizard = newAddWizardState() + m.ui.addWizard.loading = true + m.ui.mu.Unlock() + + // Load contexts + return m, loadContextsCmd(m.ui.discovery) + + case "e": // Edit selected forward + m.ui.mu.Lock() + + if len(m.ui.forwardOrder) == 0 { + // No forwards to edit + m.ui.mu.Unlock() + return m, nil + } + + if m.ui.discovery == nil || m.ui.mutator == nil { + // Dependencies not set up + m.ui.mu.Unlock() + return m, nil + } + + // Get the currently selected forward + 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 an add wizard pre-filled with the current forward's values + m.ui.viewMode = ViewModeAddWizard + m.ui.addWizard = newAddWizardState() + + // Pre-fill the wizard with current values + m.ui.addWizard.selectedContext = selectedForward.Context + m.ui.addWizard.selectedNamespace = selectedForward.Namespace + m.ui.addWizard.resourceValue = selectedForward.Resource + m.ui.addWizard.remotePort = selectedForward.RemotePort + m.ui.addWizard.localPort = selectedForward.LocalPort + m.ui.addWizard.alias = selectedForward.Alias + + // Determine resource type from the resource string + if strings.HasPrefix(selectedForward.Type, "service") { + m.ui.addWizard.selectedResourceType = ResourceTypeService + } else { + m.ui.addWizard.selectedResourceType = ResourceTypePodPrefix + } + + // Mark as edit mode and store original ID + m.ui.addWizard.isEditing = true + m.ui.addWizard.originalID = selectedID + + // Start at the remote port step (skip context/namespace/resource selection) + m.ui.addWizard.step = StepEnterRemotePort + + // Load resources to detect ports + m.ui.addWizard.loading = true + m.ui.mu.Unlock() + + // Load pods or services to detect available ports + if m.ui.addWizard.selectedResourceType == ResourceTypeService { + return m, loadServicesCmd(m.ui.discovery, selectedForward.Context, selectedForward.Namespace) + } + return m, loadPodsCmd(m.ui.discovery, selectedForward.Context, selectedForward.Namespace) + + case "d": // Delete currently selected forward - show confirmation + m.ui.mu.Lock() + + if len(m.ui.forwardOrder) == 0 { + // No forwards to delete + m.ui.mu.Unlock() + return m, nil + } + + if m.ui.mutator == nil { + // Dependencies not set up + m.ui.mu.Unlock() + return m, nil + } + + // Get the currently selected forward + 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 + } + + // Show confirmation dialog + m.ui.deleteConfirming = true + m.ui.deleteConfirmID = selectedID + m.ui.deleteConfirmAlias = selectedForward.Alias + m.ui.deleteConfirmCursor = 0 // Default to "No" for safety + + m.ui.mu.Unlock() + return m, nil + } + + return m, nil +} + +// handleDeleteConfirmation handles keyboard input for delete confirmation dialog +func (m model) handleDeleteConfirmation(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + m.ui.mu.Lock() + + switch msg.String() { + case "ctrl+c", "esc": + // Cancel deletion + m.ui.deleteConfirming = false + m.ui.deleteConfirmID = "" + m.ui.deleteConfirmAlias = "" + m.ui.deleteConfirmCursor = 0 // Reset cursor + m.ui.mu.Unlock() + // Force a repaint by returning the model + return m, tea.ClearScreen + + case "left", "h", "right", "l": + // Toggle between Yes/No + m.ui.deleteConfirmCursor = 1 - m.ui.deleteConfirmCursor + m.ui.mu.Unlock() + return m, nil + + case "enter", "y": + // Confirm deletion (either Enter on Yes or pressing 'y') + if m.ui.deleteConfirmCursor == 0 || msg.String() == "y" { + id := m.ui.deleteConfirmID + m.ui.deleteConfirming = false + m.ui.deleteConfirmID = "" + m.ui.deleteConfirmAlias = "" + m.ui.mu.Unlock() + return m, removeForwardByIDCmd(m.ui.mutator, id) + } + // Enter on No = cancel + m.ui.deleteConfirming = false + m.ui.deleteConfirmID = "" + m.ui.deleteConfirmAlias = "" + m.ui.deleteConfirmCursor = 0 // Reset cursor + m.ui.mu.Unlock() + return m, tea.ClearScreen + + case "n": + // Quick 'n' for no + m.ui.deleteConfirming = false + m.ui.deleteConfirmID = "" + m.ui.deleteConfirmAlias = "" + m.ui.deleteConfirmCursor = 0 // Reset cursor + m.ui.mu.Unlock() + return m, tea.ClearScreen + } + + m.ui.mu.Unlock() + return m, nil +} + +// handleAddWizardKeys handles keyboard input in the add wizard +func (m model) handleAddWizardKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + m.ui.mu.Lock() + defer m.ui.mu.Unlock() + + wizard := m.ui.addWizard + if wizard == nil { + return m, nil + } + + switch msg.String() { + case "ctrl+c": + // Hard cancel + m.ui.viewMode = ViewModeMain + m.ui.addWizard = nil + return m, tea.ClearScreen + + case "esc": + // In edit mode, Esc always cancels (don't navigate back through skipped steps) + if wizard.isEditing { + m.ui.viewMode = ViewModeMain + m.ui.addWizard = nil + return m, tea.ClearScreen + } + + // In add mode, go back or cancel + if wizard.step == StepSelectContext { + // On first step, cancel entirely + m.ui.viewMode = ViewModeMain + m.ui.addWizard = nil + return m, tea.ClearScreen + } else { + // Go back one step + wizard.step-- + wizard.cursor = 0 + wizard.clearTextInput() + wizard.error = nil + + // Reset input mode based on the step we're going back to + switch wizard.step { + case StepSelectContext, StepSelectNamespace, StepSelectResourceType: + wizard.inputMode = InputModeList + case StepEnterResource: + if wizard.selectedResourceType == ResourceTypeService { + wizard.inputMode = InputModeList + } else { + wizard.inputMode = InputModeText + } + case StepEnterRemotePort, StepEnterLocalPort: + wizard.inputMode = InputModeText + case StepConfirmation: + wizard.inputMode = InputModeList + } + } + return m, nil + + case "up", "k": + // In confirmation step, toggle between alias and buttons + if wizard.step == StepConfirmation { + if wizard.confirmationFocus == FocusButtons { + wizard.confirmationFocus = FocusAlias + } + } else { + wizard.moveCursor(-1) + } + + case "down", "j": + // In confirmation step, toggle between alias and buttons + if wizard.step == StepConfirmation { + if wizard.confirmationFocus == FocusAlias { + wizard.confirmationFocus = FocusButtons + wizard.cursor = 0 + } else { + wizard.moveCursor(1) // Navigate between buttons + } + } else { + wizard.moveCursor(1) + } + + case "tab": + // Tab moves between alias field and buttons in confirmation + if wizard.step == StepConfirmation { + if wizard.confirmationFocus == FocusAlias { + wizard.confirmationFocus = FocusButtons + wizard.cursor = 0 + } else { + wizard.confirmationFocus = FocusAlias + } + } + + case "enter": + return m.handleAddWizardEnter() + + case "backspace": + // Allow backspace in text input mode OR when focused on alias in confirmation + canBackspace := wizard.inputMode == InputModeText || + (wizard.step == StepConfirmation && wizard.confirmationFocus == FocusAlias) + if canBackspace && len(wizard.textInput) > 0 { + wizard.textInput = wizard.textInput[:len(wizard.textInput)-1] + } + + default: + // Handle text input + canTypeText := wizard.inputMode == InputModeText || + (wizard.step == StepConfirmation && wizard.confirmationFocus == FocusAlias) + if canTypeText && len(msg.String()) == 1 { + wizard.handleTextInput(rune(msg.String()[0])) + + // Trigger validation for selector + if wizard.step == StepEnterResource && wizard.selectedResourceType == ResourceTypePodSelector { + if len(wizard.textInput) > 0 { + wizard.loading = true + wizard.error = nil + return m, validateSelectorCmd(m.ui.discovery, wizard.selectedContext, wizard.selectedNamespace, wizard.textInput) + } + } + } + } + + return m, nil +} + +// handleAddWizardEnter handles Enter key in the add wizard +func (m model) handleAddWizardEnter() (tea.Model, tea.Cmd) { + wizard := m.ui.addWizard + + switch wizard.step { + case StepSelectContext: + if wizard.cursor >= 0 && wizard.cursor < len(wizard.contexts) { + wizard.selectedContext = wizard.contexts[wizard.cursor] + wizard.step = StepSelectNamespace + wizard.cursor = 0 + wizard.loading = true + return m, loadNamespacesCmd(m.ui.discovery, wizard.selectedContext) + } + + case StepSelectNamespace: + if wizard.cursor >= 0 && wizard.cursor < len(wizard.namespaces) { + wizard.selectedNamespace = wizard.namespaces[wizard.cursor] + wizard.step = StepSelectResourceType + wizard.cursor = 0 + wizard.inputMode = InputModeList + } + + case StepSelectResourceType: + if wizard.cursor >= 0 && wizard.cursor < 3 { + wizard.selectedResourceType = ResourceType(wizard.cursor) + wizard.step = StepEnterResource + wizard.cursor = 0 + + if wizard.selectedResourceType == ResourceTypeService { + wizard.inputMode = InputModeList + wizard.loading = true + return m, loadServicesCmd(m.ui.discovery, wizard.selectedContext, wizard.selectedNamespace) + } else { + wizard.inputMode = InputModeText + wizard.loading = true + return m, loadPodsCmd(m.ui.discovery, wizard.selectedContext, wizard.selectedNamespace) + } + } + + case StepEnterResource: + switch wizard.selectedResourceType { + case ResourceTypePodPrefix: + if wizard.textInput != "" { + wizard.resourceValue = wizard.textInput + wizard.step = StepEnterRemotePort + wizard.clearTextInput() + + // Detect ports from matching pods + wizard.detectedPorts = k8s.GetUniquePorts(wizard.pods) + if len(wizard.detectedPorts) > 0 { + wizard.inputMode = InputModeList + wizard.cursor = 0 + } else { + wizard.inputMode = InputModeText + } + } + + case ResourceTypePodSelector: + if wizard.textInput != "" && len(wizard.matchingPods) > 0 { + wizard.resourceValue = "pod" + wizard.selector = wizard.textInput + wizard.step = StepEnterRemotePort + wizard.clearTextInput() + + // Detect ports from matching pods + wizard.detectedPorts = k8s.GetUniquePorts(wizard.matchingPods) + if len(wizard.detectedPorts) > 0 { + wizard.inputMode = InputModeList + wizard.cursor = 0 + } else { + wizard.inputMode = InputModeText + } + } + + case ResourceTypeService: + if wizard.cursor >= 0 && wizard.cursor < len(wizard.services) { + wizard.resourceValue = wizard.services[wizard.cursor].Name + wizard.step = StepEnterRemotePort + wizard.clearTextInput() + + // Get ports from selected service + wizard.detectedPorts = wizard.services[wizard.cursor].Ports + if len(wizard.detectedPorts) > 0 { + wizard.inputMode = InputModeList + wizard.cursor = 0 + } else { + wizard.inputMode = InputModeText + } + } + } + + case StepEnterRemotePort: + if wizard.inputMode == InputModeList && len(wizard.detectedPorts) > 0 { + // List mode - user selected from detected ports + if wizard.cursor == len(wizard.detectedPorts) { + // Selected "Manual entry" option + wizard.inputMode = InputModeText + wizard.clearTextInput() + } else if wizard.cursor >= 0 && wizard.cursor < len(wizard.detectedPorts) { + // Selected a detected port + wizard.remotePort = int(wizard.detectedPorts[wizard.cursor].Port) + wizard.step = StepEnterLocalPort + wizard.clearTextInput() + wizard.inputMode = InputModeText + wizard.error = nil + } + } else { + // Text mode - manual entry + port, err := strconv.Atoi(wizard.textInput) + if err != nil || port < 1 || port > 65535 { + wizard.error = fmt.Errorf("invalid port number") + } else { + wizard.remotePort = port + wizard.step = StepEnterLocalPort + wizard.clearTextInput() + wizard.error = nil + } + } + + case StepEnterLocalPort: + port, err := strconv.Atoi(wizard.textInput) + if err != nil || port < 1 || port > 65535 { + wizard.error = fmt.Errorf("invalid port number") + } else { + wizard.localPort = port + wizard.step = StepConfirmation + wizard.clearTextInput() + wizard.cursor = 0 + wizard.inputMode = InputModeList + wizard.error = nil + wizard.loading = true + return m, checkPortCmd(port) + } + + case StepConfirmation: + // If focused on alias field, move to buttons + if wizard.confirmationFocus == FocusAlias { + wizard.confirmationFocus = FocusButtons + wizard.cursor = 0 + return m, nil + } + + // Handle button selection + if wizard.cursor == 0 { + // Confirmed - save the forward + wizard.alias = wizard.textInput + + // Build the forward config + fwd := config.Forward{ + Protocol: "tcp", + Port: wizard.remotePort, + LocalPort: wizard.localPort, + Alias: wizard.alias, + } + + if wizard.selectedResourceType == ResourceTypePodPrefix { + fwd.Resource = "pod/" + wizard.resourceValue + } else if wizard.selectedResourceType == ResourceTypePodSelector { + fwd.Resource = wizard.resourceValue + fwd.Selector = wizard.selector + } else if wizard.selectedResourceType == ResourceTypeService { + fwd.Resource = "service/" + wizard.resourceValue + } + + wizard.loading = true + + // If editing, use atomic update operation + if wizard.isEditing { + return m, updateForwardCmd(m.ui.mutator, wizard.originalID, wizard.selectedContext, wizard.selectedNamespace, fwd) + } + + return m, saveForwardCmd(m.ui.mutator, wizard.selectedContext, wizard.selectedNamespace, fwd) + } else { + // Cancelled + m.ui.viewMode = ViewModeMain + m.ui.addWizard = nil + } + + case StepSuccess: + if wizard.cursor == 0 { + // Add another + m.ui.addWizard = newAddWizardState() + m.ui.addWizard.loading = true + return m, loadContextsCmd(m.ui.discovery) + } else { + // Return to main view + m.ui.viewMode = ViewModeMain + m.ui.addWizard = nil + } + } + + return m, nil +} + +// handleRemoveWizardKeys handles keyboard input in the remove wizard +func (m model) handleRemoveWizardKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + m.ui.mu.Lock() + defer m.ui.mu.Unlock() + + wizard := m.ui.removeWizard + if wizard == nil { + return m, nil + } + + switch msg.String() { + case "ctrl+c": + // Hard cancel - always exit + m.ui.viewMode = ViewModeMain + m.ui.removeWizard = nil + return m, tea.ClearScreen + + case "esc": + if wizard.confirming { + // In confirmation mode, Esc confirms the removal (same as pressing Yes) + selectedForwards := wizard.getSelectedForwards() + return m, removeForwardsCmd(m.ui.mutator, selectedForwards) + } else { + // Not confirming yet - cancel entirely + m.ui.viewMode = ViewModeMain + m.ui.removeWizard = nil + } + return m, tea.ClearScreen + + case "up", "k": + wizard.moveCursor(-1) + + case "down", "j": + wizard.moveCursor(1) + + case " ": + if !wizard.confirming { + wizard.toggleSelection() + } + + case "a": + wizard.selectAll() + + case "n": + wizard.selectNone() + + case "enter": + if !wizard.confirming { + if wizard.getSelectedCount() == 0 { + // Nothing selected + return m, nil + } + // Show confirmation + wizard.confirming = true + wizard.confirmCursor = 0 + } else { + // Confirmed + if wizard.confirmCursor == 0 { + // Yes, remove + selectedForwards := wizard.getSelectedForwards() + return m, removeForwardsCmd(m.ui.mutator, selectedForwards) + } else { + // No, cancel + wizard.confirming = false + } + } + } + + return m, nil +} + +// Message handlers + +func (m model) handleContextsLoaded(msg ContextsLoadedMsg) (tea.Model, tea.Cmd) { + m.ui.mu.Lock() + defer m.ui.mu.Unlock() + + if m.ui.addWizard != nil { + m.ui.addWizard.loading = false + m.ui.addWizard.error = msg.err + if msg.err == nil { + // Get current context and move it to the top + currentCtx, err := m.ui.discovery.GetCurrentContext() + if err == nil && currentCtx != "" { + // Reorder contexts with current first + reordered := []string{currentCtx} + for _, ctx := range msg.contexts { + if ctx != currentCtx { + reordered = append(reordered, ctx) + } + } + m.ui.addWizard.contexts = reordered + } else { + m.ui.addWizard.contexts = msg.contexts + } + } + } + + return m, nil +} + +func (m model) handleNamespacesLoaded(msg NamespacesLoadedMsg) (tea.Model, tea.Cmd) { + m.ui.mu.Lock() + defer m.ui.mu.Unlock() + + if m.ui.addWizard != nil { + m.ui.addWizard.loading = false + m.ui.addWizard.error = msg.err + if msg.err == nil { + m.ui.addWizard.namespaces = msg.namespaces + } + } + + return m, nil +} + +func (m model) handlePodsLoaded(msg PodsLoadedMsg) (tea.Model, tea.Cmd) { + m.ui.mu.Lock() + defer m.ui.mu.Unlock() + + if m.ui.addWizard != nil { + m.ui.addWizard.loading = false + m.ui.addWizard.error = msg.err + if msg.err == nil { + m.ui.addWizard.pods = msg.pods + + // If we're at the remote port step (edit mode), detect ports now + if m.ui.addWizard.step == StepEnterRemotePort { + m.ui.addWizard.detectedPorts = k8s.GetUniquePorts(msg.pods) + if len(m.ui.addWizard.detectedPorts) > 0 { + m.ui.addWizard.inputMode = InputModeList + m.ui.addWizard.cursor = 0 + } else { + m.ui.addWizard.inputMode = InputModeText + m.ui.addWizard.textInput = fmt.Sprintf("%d", m.ui.addWizard.remotePort) + } + } + } + } + + return m, nil +} + +func (m model) handleServicesLoaded(msg ServicesLoadedMsg) (tea.Model, tea.Cmd) { + m.ui.mu.Lock() + defer m.ui.mu.Unlock() + + if m.ui.addWizard != nil { + m.ui.addWizard.loading = false + m.ui.addWizard.error = msg.err + if msg.err == nil { + m.ui.addWizard.services = msg.services + + // If we're at the remote port step (edit mode), detect ports now + if m.ui.addWizard.step == StepEnterRemotePort { + // Find the service by name + for _, svc := range msg.services { + if svc.Name == m.ui.addWizard.resourceValue { + m.ui.addWizard.detectedPorts = svc.Ports + if len(m.ui.addWizard.detectedPorts) > 0 { + m.ui.addWizard.inputMode = InputModeList + m.ui.addWizard.cursor = 0 + } else { + m.ui.addWizard.inputMode = InputModeText + m.ui.addWizard.textInput = fmt.Sprintf("%d", m.ui.addWizard.remotePort) + } + break + } + } + } + } + } + + return m, nil +} + +func (m model) handleSelectorValidated(msg SelectorValidatedMsg) (tea.Model, tea.Cmd) { + m.ui.mu.Lock() + defer m.ui.mu.Unlock() + + if m.ui.addWizard != nil { + m.ui.addWizard.loading = false + m.ui.addWizard.error = msg.err + if msg.valid { + m.ui.addWizard.matchingPods = msg.pods + } else { + m.ui.addWizard.matchingPods = nil + } + } + + return m, nil +} + +func (m model) handlePortChecked(msg PortCheckedMsg) (tea.Model, tea.Cmd) { + m.ui.mu.Lock() + defer m.ui.mu.Unlock() + + if m.ui.addWizard != nil { + m.ui.addWizard.loading = false + m.ui.addWizard.portAvailable = msg.available + m.ui.addWizard.portCheckMsg = msg.message + } + + return m, nil +} + +func (m model) handleForwardSaved(msg ForwardSavedMsg) (tea.Model, tea.Cmd) { + m.ui.mu.Lock() + defer m.ui.mu.Unlock() + + if m.ui.addWizard != nil { + m.ui.addWizard.loading = false + if msg.success { + // Move to success step + m.ui.addWizard.step = StepSuccess + m.ui.addWizard.cursor = 0 + m.ui.addWizard.inputMode = InputModeList + } else { + m.ui.addWizard.error = msg.err + } + } + + return m, nil +} + +func (m model) handleForwardsRemoved(msg ForwardsRemovedMsg) (tea.Model, tea.Cmd) { + m.ui.mu.Lock() + defer m.ui.mu.Unlock() + + // Delete now happens directly without wizard + // Just ensure we're back in main view + m.ui.viewMode = ViewModeMain + m.ui.removeWizard = nil + + // If there was an error, it will be logged but we don't show it in UI for now + // The config watcher will either reload (success) or keep old config (failure) + + return m, nil +} diff --git a/internal/ui/wizard_state.go b/internal/ui/wizard_state.go new file mode 100644 index 0000000..3ce1870 --- /dev/null +++ b/internal/ui/wizard_state.go @@ -0,0 +1,302 @@ +package ui + +import ( + "github.com/nvm/kportal/internal/k8s" +) + +// ViewMode represents the current view state of the UI +type ViewMode int + +const ( + ViewModeMain ViewMode = iota + ViewModeAddWizard + ViewModeRemoveWizard +) + +// InputMode represents whether the wizard is in list selection or text input mode +type InputMode int + +const ( + InputModeList InputMode = iota + InputModeText +) + +// AddWizardStep represents the current step in the add wizard flow +type AddWizardStep int + +const ( + StepSelectContext AddWizardStep = iota + StepSelectNamespace + StepSelectResourceType + StepEnterResource + StepEnterRemotePort + StepEnterLocalPort + StepConfirmation + StepSuccess +) + +// ConfirmationFocus represents what the user is focused on in confirmation step +type ConfirmationFocus int + +const ( + FocusAlias ConfirmationFocus = iota + FocusButtons +) + +// ResourceType represents the type of Kubernetes resource to forward to +type ResourceType int + +const ( + ResourceTypePodPrefix ResourceType = iota + ResourceTypePodSelector + ResourceTypeService +) + +// String returns a human-readable name for the resource type +func (r ResourceType) String() string { + switch r { + case ResourceTypePodPrefix: + return "Pod (by name prefix)" + case ResourceTypePodSelector: + return "Pod (by label selector)" + case ResourceTypeService: + return "Service" + default: + return "Unknown" + } +} + +// Description returns a description of the resource type +func (r ResourceType) Description() string { + switch r { + case ResourceTypePodPrefix: + return "Recommended for specific pod instances" + case ResourceTypePodSelector: + return "Flexible, survives pod restarts automatically" + case ResourceTypeService: + return "Most stable, load-balanced" + default: + return "" + } +} + +// AddWizardState maintains the state for the add port forward wizard +type AddWizardState struct { + step AddWizardStep + inputMode InputMode + cursor int + scrollOffset int // For scrolling long lists + textInput string + loading bool + error error + + // Selections made by user + selectedContext string + selectedNamespace string + selectedResourceType ResourceType + resourceValue string // pod prefix or service name + selector string // for pod selector type + remotePort int + localPort int + alias string + + // Available options (loaded asynchronously from k8s) + contexts []string + namespaces []string + pods []k8s.PodInfo + services []k8s.ServiceInfo + + // Validation state + portAvailable bool + portCheckMsg string + matchingPods []k8s.PodInfo + + // Edit mode + isEditing bool + originalID string // ID of the forward being edited + + // Detected ports from resources + detectedPorts []k8s.PortInfo + + // Confirmation focus (alias field vs buttons) + confirmationFocus ConfirmationFocus +} + +// newAddWizardState creates a new add wizard state initialized to the first step +func newAddWizardState() *AddWizardState { + return &AddWizardState{ + step: StepSelectContext, + inputMode: InputModeList, + cursor: 0, + contexts: []string{}, + } +} + +// moveCursor moves the cursor up or down in list selection mode +func (w *AddWizardState) moveCursor(delta int) { + if w.inputMode != InputModeList { + return + } + + var maxItems int + + switch w.step { + case StepSelectContext: + maxItems = len(w.contexts) + case StepSelectNamespace: + maxItems = len(w.namespaces) + case StepSelectResourceType: + maxItems = 3 // Three resource types + case StepEnterResource: + if w.selectedResourceType == ResourceTypeService { + maxItems = len(w.services) + } + case StepEnterRemotePort: + if len(w.detectedPorts) > 0 { + maxItems = len(w.detectedPorts) + 1 // +1 for "Manual entry" option + } + } + + w.cursor += delta + if w.cursor < 0 { + w.cursor = 0 + } + if w.cursor >= maxItems && maxItems > 0 { + w.cursor = maxItems - 1 + } + + // Adjust scroll offset to keep cursor visible + // Viewport shows max 20 items at a time + const viewportHeight = 20 + + // If cursor moved below visible area, scroll down + if w.cursor >= w.scrollOffset+viewportHeight { + w.scrollOffset = w.cursor - viewportHeight + 1 + } + + // If cursor moved above visible area, scroll up + if w.cursor < w.scrollOffset { + w.scrollOffset = w.cursor + } + + // Ensure scroll offset is valid + if w.scrollOffset < 0 { + w.scrollOffset = 0 + } +} + +// handleTextInput handles a single character input in text mode +func (w *AddWizardState) handleTextInput(char rune) { + // Note: Caller already checks if text input is allowed (inputMode or confirmation step) + // so we don't need to check inputMode here + + // Handle backspace + if char == 127 || char == 8 { + if len(w.textInput) > 0 { + w.textInput = w.textInput[:len(w.textInput)-1] + } + return + } + + // Only allow printable characters + if char >= 32 && char < 127 { + w.textInput += string(char) + } +} + +// clearTextInput clears the text input field +func (w *AddWizardState) clearTextInput() { + w.textInput = "" +} + +// RemoveWizardState maintains the state for the remove port forward wizard +type RemoveWizardState struct { + forwards []RemovableForward + cursor int + selected map[int]bool + confirming bool + confirmCursor int // 0 = Yes, 1 = No +} + +// RemovableForward represents a forward that can be removed +type RemovableForward struct { + ID string + Context string + Namespace string + Alias string + Resource string + Selector string + Port int + LocalPort int +} + +// moveCursor moves the cursor up or down +func (w *RemoveWizardState) moveCursor(delta int) { + if w.confirming { + // Move between Yes/No in confirmation + w.confirmCursor += delta + if w.confirmCursor < 0 { + w.confirmCursor = 0 + } + if w.confirmCursor > 1 { + w.confirmCursor = 1 + } + } else { + // Move between forwards + w.cursor += delta + if w.cursor < 0 { + w.cursor = 0 + } + if w.cursor >= len(w.forwards) { + w.cursor = len(w.forwards) - 1 + } + } +} + +// toggleSelection toggles the selection of the current forward +func (w *RemoveWizardState) toggleSelection() { + if w.confirming { + return + } + w.selected[w.cursor] = !w.selected[w.cursor] +} + +// selectAll selects all forwards for removal +func (w *RemoveWizardState) selectAll() { + if w.confirming { + return + } + for i := range w.forwards { + w.selected[i] = true + } +} + +// selectNone deselects all forwards +func (w *RemoveWizardState) selectNone() { + if w.confirming { + return + } + w.selected = make(map[int]bool) +} + +// getSelectedCount returns the number of selected forwards +func (w *RemoveWizardState) getSelectedCount() int { + count := 0 + for _, selected := range w.selected { + if selected { + count++ + } + } + return count +} + +// getSelectedForwards returns a list of selected forwards +func (w *RemoveWizardState) getSelectedForwards() []RemovableForward { + selected := make([]RemovableForward, 0) + for i, fwd := range w.forwards { + if w.selected[i] { + selected = append(selected, fwd) + } + } + return selected +} diff --git a/internal/ui/wizard_styles.go b/internal/ui/wizard_styles.go new file mode 100644 index 0000000..70fed1e --- /dev/null +++ b/internal/ui/wizard_styles.go @@ -0,0 +1,211 @@ +package ui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// Color palette for wizards +var ( + primaryColor = lipgloss.Color("205") // Pink/Magenta + successColor = lipgloss.Color("42") // Green + errorColor = lipgloss.Color("196") // Red + warningColor = lipgloss.Color("220") // Yellow + mutedColor = lipgloss.Color("241") // Gray + accentColor = lipgloss.Color("63") // Purple + highlightColor = lipgloss.Color("117") // Light blue +) + +// Text styles +var ( + wizardHeaderStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(primaryColor). + MarginBottom(0) + + wizardStepStyle = lipgloss.NewStyle(). + Foreground(mutedColor). + Italic(true) + + breadcrumbStyle = lipgloss.NewStyle(). + Foreground(highlightColor). + Bold(true) + + selectedStyle = lipgloss.NewStyle(). + Foreground(primaryColor). + Bold(true) + + successStyle = lipgloss.NewStyle(). + Foreground(successColor). + Bold(true) + + errorStyle = lipgloss.NewStyle(). + Foreground(errorColor). + Bold(true) + + warningStyle = lipgloss.NewStyle(). + Foreground(warningColor). + Bold(true) + + mutedStyle = lipgloss.NewStyle(). + Foreground(mutedColor) + + helpStyle = lipgloss.NewStyle(). + Foreground(mutedColor). + Italic(true) + + spinnerStyle = lipgloss.NewStyle(). + Foreground(accentColor). + Bold(true) +) + +// Input styles +var ( + inputStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("252")) + + validInputStyle = lipgloss.NewStyle(). + Foreground(successColor) +) + +// Checkbox styles +var ( + checkedBoxStyle = lipgloss.NewStyle(). + Foreground(successColor). + Bold(true) + + uncheckedBoxStyle = lipgloss.NewStyle(). + Foreground(mutedColor) +) + +// Container styles +var ( + wizardBoxStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(accentColor). + Padding(1, 2). + Width(60) +) + +// Helper functions for rendering + +// renderProgress returns a step indicator like "Step 2/7" +func renderProgress(current, total int) string { + return wizardStepStyle.Render(fmt.Sprintf("Step %d/%d", current, total)) +} + +// renderHeader returns a formatted header with title and progress +func renderHeader(title, progress string) string { + header := wizardHeaderStyle.Render(title) + if progress != "" { + header += " " + progress + } + return header + "\n\n" +} + +// renderBreadcrumb returns a formatted breadcrumb path +func renderBreadcrumb(parts ...string) string { + return breadcrumbStyle.Render(strings.Join(parts, " / ")) +} + +// renderList renders a list of items with cursor selection and viewport scrolling +func renderList(items []string, cursor int, prefix string, scrollOffset int) string { + var b strings.Builder + + const viewportHeight = 20 + totalItems := len(items) + + // Show scroll up indicator if there are items above the viewport + if scrollOffset > 0 { + b.WriteString(mutedStyle.Render(" ↑ More above ↑") + "\n") + } + + // Calculate visible range + start := scrollOffset + end := scrollOffset + viewportHeight + if end > totalItems { + end = totalItems + } + + // Render visible items + for i := start; i < end; i++ { + cursorPrefix := prefix + if i == cursor { + cursorPrefix = "ā–ø " + b.WriteString(selectedStyle.Render(cursorPrefix + items[i])) + } else { + b.WriteString(cursorPrefix + items[i]) + } + b.WriteString("\n") + } + + // Show scroll down indicator if there are items below the viewport + if end < totalItems { + b.WriteString(mutedStyle.Render(" ↓ More below ↓") + "\n") + } + + return b.String() +} + +// renderTextInput renders a text input field with a cursor +func renderTextInput(label, value string, valid bool) string { + var b strings.Builder + + b.WriteString(label) + + inputText := value + "ā–ˆ" + if valid { + b.WriteString(validInputStyle.Render(inputText)) + } else { + b.WriteString(inputStyle.Render(inputText)) + } + + return b.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") +} diff --git a/internal/ui/wizard_views.go b/internal/ui/wizard_views.go new file mode 100644 index 0000000..bc62d3d --- /dev/null +++ b/internal/ui/wizard_views.go @@ -0,0 +1,602 @@ +package ui + +import ( + "fmt" + "strings" +) + +// renderAddWizard renders the appropriate step of the add wizard +func (m model) renderAddWizard() string { + if m.ui.addWizard == nil { + return "" + } + + wizard := m.ui.addWizard + + var content string + switch wizard.step { + case StepSelectContext: + content = m.renderSelectContext() + case StepSelectNamespace: + content = m.renderSelectNamespace() + case StepSelectResourceType: + content = m.renderSelectResourceType() + case StepEnterResource: + content = m.renderEnterResource() + case StepEnterRemotePort: + content = m.renderEnterRemotePort() + case StepEnterLocalPort: + content = m.renderEnterLocalPort() + case StepConfirmation: + content = m.renderConfirmation() + case StepSuccess: + content = m.renderSuccess() + default: + content = "Unknown step" + } + + return wizardBoxStyle.Render(content) +} + +func (m model) renderSelectContext() string { + wizard := m.ui.addWizard + var b strings.Builder + + b.WriteString(renderHeader("Add Port Forward", renderProgress(1, 7))) + b.WriteString("Select Kubernetes Context:\n\n") + + if wizard.loading { + b.WriteString(spinnerStyle.Render("⣾ Loading contexts...")) + } else if wizard.error != nil { + b.WriteString(errorStyle.Render(fmt.Sprintf("āœ— Error: %v", wizard.error))) + } else if len(wizard.contexts) == 0 { + b.WriteString(mutedStyle.Render("No contexts found in kubeconfig")) + } else { + const viewportHeight = 20 + totalItems := len(wizard.contexts) + + // Show scroll up indicator if there are items above the viewport + if wizard.scrollOffset > 0 { + b.WriteString(mutedStyle.Render(" ↑ More above ↑") + "\n") + } + + // Calculate visible range + start := wizard.scrollOffset + end := wizard.scrollOffset + viewportHeight + if end > totalItems { + end = totalItems + } + + // Render visible contexts with (current) marker on first one + for i := start; i < end; i++ { + prefix := " " + text := wizard.contexts[i] + if i == 0 { + text += mutedStyle.Render(" (current)") + } + + if i == wizard.cursor { + prefix = "ā–ø " + b.WriteString(selectedStyle.Render(prefix + text)) + } else { + b.WriteString(prefix + text) + } + b.WriteString("\n") + } + + // Show scroll down indicator if there are items below the viewport + if end < totalItems { + b.WriteString(mutedStyle.Render(" ↓ More below ↓") + "\n") + } + } + + b.WriteString("\n") + b.WriteString(helpStyle.Render("↑/↓: Navigate Enter: Select Esc/Ctrl+C: Cancel")) + + return b.String() +} + +func (m model) renderSelectNamespace() string { + wizard := m.ui.addWizard + var b strings.Builder + + b.WriteString(renderHeader("Add Port Forward", renderProgress(2, 7))) + b.WriteString(fmt.Sprintf("Context: %s\n\n", breadcrumbStyle.Render(wizard.selectedContext))) + + b.WriteString("Select Namespace:\n\n") + + if wizard.loading { + b.WriteString(spinnerStyle.Render("⣾ Loading namespaces...")) + } else if wizard.error != nil { + b.WriteString(errorStyle.Render(fmt.Sprintf("āœ— Error: %v\n", wizard.error))) + b.WriteString(mutedStyle.Render("\nCluster may be unreachable. Check context.")) + } else if len(wizard.namespaces) == 0 { + b.WriteString(mutedStyle.Render("No namespaces found")) + } else { + b.WriteString(renderList(wizard.namespaces, wizard.cursor, " ", wizard.scrollOffset)) + } + + b.WriteString("\n") + b.WriteString(helpStyle.Render("↑/↓: Navigate Enter: Select Esc: Back Ctrl+C: Cancel")) + + return b.String() +} + +func (m model) renderSelectResourceType() string { + wizard := m.ui.addWizard + var b strings.Builder + + b.WriteString(renderHeader("Add Port Forward", renderProgress(3, 7))) + b.WriteString(renderBreadcrumb(wizard.selectedContext, wizard.selectedNamespace)) + b.WriteString("\n\n") + + b.WriteString("Select Resource Type:\n\n") + + resourceTypes := []ResourceType{ + ResourceTypePodPrefix, + ResourceTypePodSelector, + ResourceTypeService, + } + + for i, rt := range resourceTypes { + prefix := " " + if i == wizard.cursor { + prefix = "ā–ø " + b.WriteString(selectedStyle.Render(prefix + rt.String())) + b.WriteString("\n") + b.WriteString(mutedStyle.Render(" " + rt.Description())) + } else { + b.WriteString(prefix + rt.String()) + } + b.WriteString("\n") + if i < len(resourceTypes)-1 { + b.WriteString("\n") + } + } + + b.WriteString("\n") + b.WriteString(helpStyle.Render("↑/↓: Navigate Enter: Select Esc: Back Ctrl+C: Cancel")) + + return b.String() +} + +func (m model) renderEnterResource() string { + wizard := m.ui.addWizard + var b strings.Builder + + b.WriteString(renderHeader("Add Port Forward", renderProgress(4, 7))) + b.WriteString(renderBreadcrumb(wizard.selectedContext, wizard.selectedNamespace)) + b.WriteString("\n\n") + + switch wizard.selectedResourceType { + case ResourceTypePodPrefix: + b.WriteString("Enter pod name prefix:\n\n") + + // Show running pods for reference + if wizard.loading { + b.WriteString(spinnerStyle.Render("⣾ Loading pods...")) + } else if len(wizard.pods) > 0 { + b.WriteString(mutedStyle.Render("Running pods:\n")) + showCount := 0 + for _, pod := range wizard.pods { + if strings.HasPrefix(pod.Name, wizard.textInput) || wizard.textInput == "" { + if showCount < 5 { // Limit to 5 pods + b.WriteString(mutedStyle.Render(fmt.Sprintf(" • %s\n", pod.Name))) + showCount++ + } + } + } + if showCount == 0 && wizard.textInput != "" { + b.WriteString(mutedStyle.Render(" (no matching pods)\n")) + } else if len(wizard.pods) > showCount { + b.WriteString(mutedStyle.Render(fmt.Sprintf(" ... and %d more\n", len(wizard.pods)-showCount))) + } + b.WriteString("\n") + } + + // Text input + b.WriteString(renderTextInput("Prefix: ", wizard.textInput, true)) + b.WriteString("\n\n") + + // Show match count + if wizard.textInput != "" { + matchCount := 0 + for _, pod := range wizard.pods { + if strings.HasPrefix(pod.Name, wizard.textInput) { + matchCount++ + } + } + + if matchCount > 0 { + b.WriteString(successStyle.Render(fmt.Sprintf("āœ“ Matches %d pod(s)", matchCount))) + } else { + b.WriteString(warningStyle.Render("⚠ No matching pods (you can still proceed)")) + } + } + + case ResourceTypePodSelector: + b.WriteString("Enter label selector:\n") + b.WriteString(mutedStyle.Render("Format: key=value,key2=value2\n\n")) + + b.WriteString(renderTextInput("Selector: ", wizard.textInput, true)) + b.WriteString("\n\n") + + if wizard.loading { + b.WriteString(spinnerStyle.Render("⣾ Validating selector...")) + } else if len(wizard.matchingPods) > 0 { + b.WriteString(successStyle.Render(fmt.Sprintf("āœ“ Found %d matching pod(s):\n", len(wizard.matchingPods)))) + showCount := 0 + for _, pod := range wizard.matchingPods { + if showCount < 3 { + b.WriteString(mutedStyle.Render(fmt.Sprintf(" • %s\n", pod.Name))) + showCount++ + } + } + if len(wizard.matchingPods) > 3 { + b.WriteString(mutedStyle.Render(fmt.Sprintf(" ... and %d more\n", len(wizard.matchingPods)-3))) + } + } else if wizard.error != nil { + b.WriteString(errorStyle.Render(fmt.Sprintf("āœ— Invalid selector: %v", wizard.error))) + } + + case ResourceTypeService: + b.WriteString("Select service:\n\n") + + if wizard.loading { + b.WriteString(spinnerStyle.Render("⣾ Loading services...")) + } else if len(wizard.services) == 0 { + b.WriteString(mutedStyle.Render("No services found")) + } else { + serviceNames := make([]string, len(wizard.services)) + for i, svc := range wizard.services { + serviceNames[i] = svc.Name + } + b.WriteString(renderList(serviceNames, wizard.cursor, " ", wizard.scrollOffset)) + } + } + + b.WriteString("\n") + b.WriteString(helpStyle.Render("Enter: Continue Esc: Back Ctrl+C: Cancel")) + + return b.String() +} + +func (m model) renderEnterRemotePort() string { + wizard := m.ui.addWizard + var b strings.Builder + + b.WriteString(renderHeader("Add Port Forward", renderProgress(5, 7))) + b.WriteString(renderBreadcrumb(wizard.selectedContext, wizard.selectedNamespace)) + b.WriteString("\n") + + // Show resource selection + resourceInfo := wizard.resourceValue + if wizard.selector != "" { + resourceInfo = fmt.Sprintf("%s [%s]", wizard.resourceValue, wizard.selector) + } + b.WriteString(mutedStyle.Render(fmt.Sprintf("Resource: %s\n\n", resourceInfo))) + + // 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") + + const viewportHeight = 20 + totalItems := len(wizard.detectedPorts) + 1 // +1 for manual entry option + + // Show scroll up indicator if there are items above the viewport + if wizard.scrollOffset > 0 { + b.WriteString(mutedStyle.Render(" ↑ More above ↑") + "\n") + } + + // Calculate visible range + start := wizard.scrollOffset + end := wizard.scrollOffset + viewportHeight + if end > totalItems { + end = totalItems + } + + // Render detected ports within viewport + for i := start; i < end && i < len(wizard.detectedPorts); i++ { + port := wizard.detectedPorts[i] + portDesc := fmt.Sprintf("%d", port.Port) + if port.Name != "" { + portDesc += fmt.Sprintf(" (%s)", port.Name) + } + + prefix := " " + if i == wizard.cursor { + prefix = "ā–ø " + b.WriteString(selectedStyle.Render(prefix + portDesc)) + } else { + b.WriteString(prefix + portDesc) + } + b.WriteString("\n") + } + + // Add "Manual entry" option if within viewport + manualIdx := len(wizard.detectedPorts) + if manualIdx >= start && manualIdx < end { + manualOption := "Manual entry (type port number)" + prefix := " " + if wizard.cursor == manualIdx { + prefix = "ā–ø " + b.WriteString(selectedStyle.Render(prefix + manualOption)) + } else { + b.WriteString(mutedStyle.Render(prefix + manualOption)) + } + b.WriteString("\n") + } + + // Show scroll down indicator if there are items below the viewport + if end < totalItems { + b.WriteString(mutedStyle.Render(" ↓ More below ↓") + "\n") + } + + b.WriteString("\n") + b.WriteString(helpStyle.Render("↑/↓: Navigate Enter: Select Esc: Back Ctrl+C: Cancel")) + } else { + // Text input mode (no detected ports or user chose manual entry) + if len(wizard.detectedPorts) > 0 { + b.WriteString(mutedStyle.Render("Detected ports:\n")) + for _, port := range wizard.detectedPorts { + portDesc := fmt.Sprintf("%d", port.Port) + if port.Name != "" { + portDesc += fmt.Sprintf(" (%s)", port.Name) + } + b.WriteString(mutedStyle.Render(fmt.Sprintf(" • %s\n", portDesc))) + } + b.WriteString("\n") + } + + b.WriteString(renderTextInput("Remote port: ", wizard.textInput, wizard.error == nil)) + b.WriteString("\n\n") + + if wizard.error != nil { + b.WriteString(errorStyle.Render(fmt.Sprintf("āœ— %v", wizard.error))) + } else if wizard.textInput != "" { + b.WriteString(mutedStyle.Render("Press Enter to continue")) + } + + b.WriteString("\n") + b.WriteString(helpStyle.Render("Enter: Continue Esc: Back Ctrl+C: Cancel")) + } + + return b.String() +} + +func (m model) renderEnterLocalPort() string { + wizard := m.ui.addWizard + var b strings.Builder + + b.WriteString(renderHeader("Add Port Forward", renderProgress(6, 7))) + b.WriteString(renderBreadcrumb(wizard.selectedContext, wizard.selectedNamespace)) + b.WriteString("\n") + + resourceInfo := wizard.resourceValue + 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(renderTextInput("Local port: ", wizard.textInput, wizard.error == nil)) + b.WriteString("\n\n") + + if wizard.loading { + b.WriteString(spinnerStyle.Render("⣾ Checking availability...")) + } else if wizard.error != nil { + b.WriteString(errorStyle.Render(fmt.Sprintf("āœ— %v", wizard.error))) + } else if wizard.portCheckMsg != "" { + if wizard.portAvailable { + b.WriteString(successStyle.Render(wizard.portCheckMsg)) + } else { + b.WriteString(errorStyle.Render(wizard.portCheckMsg)) + } + } else if wizard.textInput != "" && wizard.localPort > 0 { + b.WriteString(mutedStyle.Render("Press Enter to check availability")) + } + + b.WriteString("\n") + b.WriteString(helpStyle.Render("Enter: Continue Esc: Back Ctrl+C: Cancel")) + + return b.String() +} + +func (m model) renderConfirmation() string { + wizard := m.ui.addWizard + var b strings.Builder + + b.WriteString(renderHeader("Add Port Forward", renderProgress(7, 7))) + b.WriteString("\n") + + b.WriteString("Review Configuration:\n\n") + + resourceInfo := wizard.resourceValue + if wizard.selector != "" { + resourceInfo = fmt.Sprintf("pod (selector: %s)", wizard.selector) + } else if wizard.selectedResourceType == ResourceTypePodPrefix { + resourceInfo = fmt.Sprintf("pod/%s", wizard.resourceValue) + } else if wizard.selectedResourceType == ResourceTypeService { + resourceInfo = fmt.Sprintf("service/%s", wizard.resourceValue) + } + + b.WriteString(fmt.Sprintf(" Context: %s\n", wizard.selectedContext)) + b.WriteString(fmt.Sprintf(" Namespace: %s\n", wizard.selectedNamespace)) + b.WriteString(fmt.Sprintf(" Resource: %s\n", resourceInfo)) + b.WriteString(fmt.Sprintf(" Remote Port: %d\n", wizard.remotePort)) + b.WriteString(fmt.Sprintf(" Local Port: %d\n", wizard.localPort)) + b.WriteString(" Protocol: tcp\n") + + b.WriteString("\n") + + // Show alias field with focus indicator + if wizard.confirmationFocus == FocusAlias { + b.WriteString(selectedStyle.Render("ā–ø Optional alias (friendly name):") + "\n") + b.WriteString(" Alias: " + validInputStyle.Render(wizard.textInput+"ā–ˆ") + "\n") + } else { + b.WriteString(mutedStyle.Render(" Optional alias (friendly name):") + "\n") + b.WriteString(mutedStyle.Render(" Alias: "+wizard.textInput) + "\n") + } + + b.WriteString("\n") + + // Show buttons with focus indicator + if wizard.confirmationFocus == FocusButtons { + if wizard.cursor == 0 { + b.WriteString(selectedStyle.Render("ā–ø Add to .kportal.yaml") + "\n") + b.WriteString(" Cancel\n") + } else { + b.WriteString(" Add to .kportal.yaml\n") + b.WriteString(selectedStyle.Render("ā–ø Cancel") + "\n") + } + } else { + b.WriteString(mutedStyle.Render(" Add to .kportal.yaml") + "\n") + b.WriteString(mutedStyle.Render(" Cancel") + "\n") + } + + b.WriteString("\n") + b.WriteString(helpStyle.Render("↑/↓/Tab: Navigate Enter: Confirm Esc: Back")) + + return b.String() +} + +func (m model) renderSuccess() string { + wizard := m.ui.addWizard + var b strings.Builder + + b.WriteString(successStyle.Render("Success! āœ“")) + b.WriteString("\n\n") + + if wizard.error != nil { + b.WriteString(errorStyle.Render(fmt.Sprintf("Error: %v", wizard.error))) + } else { + b.WriteString("Added to .kportal.yaml\n\n") + + forwardDesc := fmt.Sprintf("localhost:%d → %s:%d", + wizard.localPort, + wizard.resourceValue, + wizard.remotePort) + + if wizard.alias != "" { + forwardDesc = fmt.Sprintf("%s (%s)", wizard.alias, forwardDesc) + } + + b.WriteString(successStyle.Render(forwardDesc)) + b.WriteString("\n\n") + b.WriteString(mutedStyle.Render("The port forward will be active shortly.")) + } + + b.WriteString("\n\n") + b.WriteString("Would you like to:\n") + + if wizard.cursor == 0 { + b.WriteString(selectedStyle.Render("ā–ø Add another port forward") + "\n") + b.WriteString(" Return to main view\n") + } else { + b.WriteString(" Add another port forward\n") + b.WriteString(selectedStyle.Render("ā–ø Return to main view") + "\n") + } + + b.WriteString("\n") + b.WriteString(helpStyle.Render("↑/↓: Navigate Enter: Select")) + + return b.String() +} + +// renderRemoveWizard renders the remove wizard +func (m model) renderRemoveWizard() string { + if m.ui.removeWizard == nil { + return "" + } + + wizard := m.ui.removeWizard + + var content string + if wizard.confirming { + content = m.renderRemoveConfirmation() + } else { + content = m.renderRemoveSelection() + } + + return wizardBoxStyle.Render(content) +} + +func (m model) renderRemoveSelection() string { + wizard := m.ui.removeWizard + var b strings.Builder + + b.WriteString(renderHeader("Remove Port Forwards", "")) + b.WriteString("\n") + + b.WriteString("Select forwards to remove (Space to toggle):\n\n") + + for i, fwd := range wizard.forwards { + isSelected := i == wizard.cursor + isChecked := wizard.selected[i] + + line1 := fmt.Sprintf("%s:%d→%d", fwd.Alias, fwd.Port, fwd.LocalPort) + line2 := fmt.Sprintf(" %s/%s/%s", fwd.Context, fwd.Namespace, fwd.Resource) + + checkbox := "[ ] " + if isChecked { + checkbox = "[āœ“] " + } + + fullLine := checkbox + line1 + if isSelected { + b.WriteString(selectedStyle.Render(fullLine)) + } else { + if isChecked { + b.WriteString(checkedBoxStyle.Render(checkbox) + line1) + } else { + b.WriteString(uncheckedBoxStyle.Render(checkbox) + line1) + } + } + + b.WriteString("\n") + b.WriteString(mutedStyle.Render(line2)) + b.WriteString("\n\n") + } + + selectedCount := wizard.getSelectedCount() + b.WriteString(fmt.Sprintf("%d of %d selected\n\n", selectedCount, len(wizard.forwards))) + + b.WriteString(helpStyle.Render("Space: Toggle a: All n: None Enter: Remove Esc: Cancel")) + + return b.String() +} + +func (m model) renderRemoveConfirmation() string { + wizard := m.ui.removeWizard + var b strings.Builder + + b.WriteString(renderHeader("Confirm Removal", "")) + b.WriteString("\n") + + selectedCount := wizard.getSelectedCount() + b.WriteString(fmt.Sprintf("Remove %d port forward(s)?\n\n", selectedCount)) + + selectedForwards := wizard.getSelectedForwards() + for _, fwd := range selectedForwards { + b.WriteString(errorStyle.Render(fmt.Sprintf(" • %s:%d→%d\n", fwd.Alias, fwd.Port, fwd.LocalPort))) + b.WriteString(mutedStyle.Render(fmt.Sprintf(" %s/%s/%s\n", fwd.Context, fwd.Namespace, fwd.Resource))) + } + + b.WriteString("\n") + b.WriteString(warningStyle.Render("This action cannot be undone.")) + b.WriteString("\n\n") + + // Yes/No buttons + if wizard.confirmCursor == 0 { + b.WriteString(selectedStyle.Render("ā–ø Yes, remove them") + "\n") + b.WriteString(" Cancel\n") + } else { + b.WriteString(" Yes, remove them\n") + b.WriteString(selectedStyle.Render("ā–ø Cancel") + "\n") + } + + b.WriteString("\n") + b.WriteString(helpStyle.Render("↑/↓: Navigate Enter: Confirm Esc: Cancel")) + + return b.String() +}