-
-
-
-
- kportal
-
-
Professional Kubernetes port-forward manager
-
-
-
-
Built With
-
- - Bubble Tea
- - Lipgloss
- - client-go
-
+
+
+ Made by
+ Lukasz Raczylo, tested on animals. They loved it!
+
+
MIT License
-
-
Made by Lukasz Raczylo
-
MIT License
-
-
-
+
-
-
+
+ // 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()
+}