From 9b57431f59936935c27fa7748682fabcbacb1fb7 Mon Sep 17 00:00:00 2001 From: Lukasz Raczylo Date: Sun, 23 Nov 2025 15:53:31 +0000 Subject: [PATCH] Add an UI to display the current port forwards status. --- internal/forward/manager.go | 20 +++- internal/forward/worker.go | 17 ++- internal/ui/table.go | 232 ++++++++++++++++++++++++++++++++++++ 3 files changed, 267 insertions(+), 2 deletions(-) create mode 100644 internal/ui/table.go diff --git a/internal/forward/manager.go b/internal/forward/manager.go index 28dfb58..89f8290 100644 --- a/internal/forward/manager.go +++ b/internal/forward/manager.go @@ -9,6 +9,13 @@ import ( "github.com/nvm/kportal/internal/k8s" ) +// StatusUpdater is an interface for updating forward status +type StatusUpdater interface { + UpdateStatus(id string, status string) + AddForward(id string, fwd *config.Forward) + Remove(id string) +} + // Manager orchestrates all port-forward workers. // It handles starting, stopping, and hot-reloading forwards. type Manager struct { @@ -20,6 +27,7 @@ type Manager struct { portChecker *PortChecker verbose bool currentConfig *config.Config + statusUI StatusUpdater } // NewManager creates a new forward Manager. @@ -42,6 +50,11 @@ func NewManager(verbose bool) *Manager { } } +// SetStatusUI sets the status updater for the manager +func (m *Manager) SetStatusUI(ui StatusUpdater) { + m.statusUI = ui +} + // Start initializes and starts all port-forwards from the configuration. func (m *Manager) Start(cfg *config.Config) error { if cfg == nil { @@ -230,8 +243,13 @@ func (m *Manager) startWorker(fwd config.Forward) error { return fmt.Errorf("worker already exists for %s", fwd.ID()) } + // Notify UI about new forward + if m.statusUI != nil { + m.statusUI.AddForward(fwd.ID(), &fwd) + } + // Create and start worker - worker := NewForwardWorker(fwd, m.portForwarder, m.verbose) + worker := NewForwardWorker(fwd, m.portForwarder, m.verbose, m.statusUI) worker.Start() // Store worker diff --git a/internal/forward/worker.go b/internal/forward/worker.go index 99c6cdf..69542f8 100644 --- a/internal/forward/worker.go +++ b/internal/forward/worker.go @@ -22,10 +22,11 @@ type ForwardWorker struct { doneChan chan struct{} verbose bool lastPod string // Track the last pod we connected to + statusUI StatusUpdater } // NewForwardWorker creates a new ForwardWorker for a single forward configuration. -func NewForwardWorker(fwd config.Forward, portForwarder *k8s.PortForwarder, verbose bool) *ForwardWorker { +func NewForwardWorker(fwd config.Forward, portForwarder *k8s.PortForwarder, verbose bool, statusUI StatusUpdater) *ForwardWorker { ctx, cancel := context.WithCancel(context.Background()) return &ForwardWorker{ @@ -36,6 +37,7 @@ func NewForwardWorker(fwd config.Forward, portForwarder *k8s.PortForwarder, verb stopChan: make(chan struct{}), doneChan: make(chan struct{}), verbose: verbose, + statusUI: statusUI, } } @@ -86,6 +88,9 @@ func (w *ForwardWorker) run() { // Check if pod changed (restart detected) if w.lastPod != "" && w.lastPod != podName { + if w.statusUI != nil { + w.statusUI.UpdateStatus(w.forward.ID(), "Reconnecting") + } log.Printf("[%s] Switched to new pod: %s → %s", w.forward.ID(), w.lastPod, podName) } else if w.lastPod == "" { log.Printf("[%s] Forwarding %s → localhost:%d", @@ -94,6 +99,11 @@ func (w *ForwardWorker) run() { w.lastPod = podName + // Update status to active + if w.statusUI != nil { + w.statusUI.UpdateStatus(w.forward.ID(), "Active") + } + // Establish port-forward connection err = w.establishForward(podName) @@ -104,6 +114,11 @@ func (w *ForwardWorker) run() { return } + // Update status to error + if w.statusUI != nil { + w.statusUI.UpdateStatus(w.forward.ID(), "Reconnecting") + } + // Log the error log.Printf("[%s] Port-forward connection failed: %v", w.forward.ID(), err) diff --git a/internal/ui/table.go b/internal/ui/table.go new file mode 100644 index 0000000..f09bf61 --- /dev/null +++ b/internal/ui/table.go @@ -0,0 +1,232 @@ +package ui + +import ( + "fmt" + "os" + "strings" + "sync" + + "github.com/nvm/kportal/internal/config" +) + +// ForwardStatus represents the current status of a port forward +type ForwardStatus struct { + Context string + Namespace string + Alias string + Type string // "service", "pod", etc. + Resource string // name without type prefix + RemotePort int + LocalPort int + Status string // "Starting", "Active", "Reconnecting", "Error" +} + +// TableUI manages the terminal table display +type TableUI struct { + mu sync.RWMutex + forwards map[string]*ForwardStatus // key is forward ID + verbose bool +} + +// NewTableUI creates a new table UI manager +func NewTableUI(verbose bool) *TableUI { + return &TableUI{ + forwards: make(map[string]*ForwardStatus), + verbose: verbose, + } +} + +// AddForward registers a new forward for display +func (t *TableUI) AddForward(id string, fwd *config.Forward) { + t.mu.Lock() + defer t.mu.Unlock() + + // Parse resource type and name + resourceType := "pod" + resourceName := fwd.Resource + + parts := strings.Split(fwd.Resource, "/") + if len(parts) == 2 { + resourceType = parts[0] + resourceName = parts[1] + } + + status := &ForwardStatus{ + Context: fwd.GetContext(), + Namespace: fwd.GetNamespace(), + Alias: fwd.Alias, + Type: resourceType, + Resource: resourceName, + RemotePort: fwd.Port, + LocalPort: fwd.LocalPort, + Status: "Starting", + } + + // If no alias, use resource name as display name + if status.Alias == "" { + status.Alias = resourceName + } + + t.forwards[id] = status +} + +// UpdateStatus updates the status of a forward +func (t *TableUI) UpdateStatus(id string, status string) { + t.mu.Lock() + defer t.mu.Unlock() + + if fwd, ok := t.forwards[id]; ok { + fwd.Status = status + } +} + +// Render displays the current table +func (t *TableUI) Render() { + t.mu.RLock() + defer t.mu.RUnlock() + + // Clear screen and move cursor to top + if !t.verbose { + fmt.Print("\033[2J\033[H") + } + + // Print header + fmt.Println("kportal - Port Forwarding Status") + fmt.Println(strings.Repeat("=", 130)) + + // Table header + fmt.Printf("%-15s %-18s %-25s %-10s %-25s %-12s %-12s %-12s\n", + "CONTEXT", "NAMESPACE", "ALIAS", "TYPE", "RESOURCE", "REMOTE PORT", "LOCAL PORT", "STATUS") + fmt.Println(strings.Repeat("-", 130)) + + // Sort forwards by local port for consistent display + type sortEntry struct { + id string + fwd *ForwardStatus + } + var entries []sortEntry + for id, fwd := range t.forwards { + entries = append(entries, sortEntry{id, fwd}) + } + + // Simple sort by local port + for i := 0; i < len(entries); i++ { + for j := i + 1; j < len(entries); j++ { + if entries[i].fwd.LocalPort > entries[j].fwd.LocalPort { + entries[i], entries[j] = entries[j], entries[i] + } + } + } + + // Print each forward + for _, entry := range entries { + fwd := entry.fwd + + // Truncate long names + alias := truncate(fwd.Alias, 25) + resource := truncate(fwd.Resource, 25) + + // Color code status with indicator + statusStr := formatStatusWithIndicator(fwd.Status) + + fmt.Printf("%-15s %-18s %-25s %-10s %-25s %-12d %-12d %s\n", + fwd.Context, + fwd.Namespace, + alias, + fwd.Type, + resource, + fwd.RemotePort, + fwd.LocalPort, + statusStr) + } + + fmt.Println(strings.Repeat("=", 130)) + fmt.Printf("Total forwards: %d | Press Ctrl+C to stop\n", len(t.forwards)) + + // In verbose mode, add a newline to separate from logs + if t.verbose { + fmt.Println() + } +} + +// RenderInitial renders the table once without clearing screen +func (t *TableUI) RenderInitial() { + t.mu.RLock() + defer t.mu.RUnlock() + + // Print header + fmt.Println("\nkportal - Port Forwarding Status") + fmt.Println(strings.Repeat("=", 130)) + + // Table header + fmt.Printf("%-15s %-18s %-25s %-10s %-25s %-12s %-12s %-12s\n", + "CONTEXT", "NAMESPACE", "ALIAS", "TYPE", "RESOURCE", "REMOTE PORT", "LOCAL PORT", "STATUS") + fmt.Println(strings.Repeat("-", 130)) + + // Print message if no forwards yet + if len(t.forwards) == 0 { + fmt.Println("Initializing port forwards...") + } + + fmt.Println(strings.Repeat("=", 130)) + fmt.Println() +} + +// GetForward returns a forward status by ID +func (t *TableUI) GetForward(id string) *ForwardStatus { + t.mu.RLock() + defer t.mu.RUnlock() + return t.forwards[id] +} + +// Remove removes a forward from the display +func (t *TableUI) Remove(id string) { + t.mu.Lock() + defer t.mu.Unlock() + delete(t.forwards, id) +} + +// truncate truncates a string to maxLen, adding "..." if needed +func truncate(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + if maxLen <= 3 { + return s[:maxLen] + } + return s[:maxLen-3] + "..." +} + +// formatStatusWithIndicator adds color-coded indicator symbols to status +func formatStatusWithIndicator(status string) string { + // Check if stdout is a terminal + if fileInfo, _ := os.Stdout.Stat(); (fileInfo.Mode() & os.ModeCharDevice) == 0 { + // Not a terminal, return plain text with simple indicator + switch status { + case "Active": + return "✓ " + status + case "Starting": + return "⋯ " + status + case "Reconnecting": + return "↻ " + status + case "Error", "Failed": + return "✗ " + status + default: + return status + } + } + + // Terminal with color support + switch status { + case "Active": + return "\033[32m●\033[0m " + status // Green circle + case "Starting": + return "\033[33m○\033[0m " + status // Yellow circle (hollow) + case "Reconnecting": + return "\033[33m◐\033[0m " + status // Yellow half-circle + case "Error", "Failed": + return "\033[31m●\033[0m " + status // Red circle + default: + return status + } +}