Add an UI to display the current port forwards status.

This commit is contained in:
2025-11-23 15:53:31 +00:00
parent 555f21c6f3
commit 9b57431f59
3 changed files with 267 additions and 2 deletions
+19 -1
View File
@@ -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
+16 -1
View File
@@ -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)
+232
View File
@@ -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
}
}