mirror of
https://github.com/lukaszraczylo/kportal.git
synced 2026-07-14 07:51:00 +00:00
Add an UI to display the current port forwards status.
This commit is contained in:
@@ -9,6 +9,13 @@ import (
|
|||||||
"github.com/nvm/kportal/internal/k8s"
|
"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.
|
// Manager orchestrates all port-forward workers.
|
||||||
// It handles starting, stopping, and hot-reloading forwards.
|
// It handles starting, stopping, and hot-reloading forwards.
|
||||||
type Manager struct {
|
type Manager struct {
|
||||||
@@ -20,6 +27,7 @@ type Manager struct {
|
|||||||
portChecker *PortChecker
|
portChecker *PortChecker
|
||||||
verbose bool
|
verbose bool
|
||||||
currentConfig *config.Config
|
currentConfig *config.Config
|
||||||
|
statusUI StatusUpdater
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewManager creates a new forward Manager.
|
// 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.
|
// Start initializes and starts all port-forwards from the configuration.
|
||||||
func (m *Manager) Start(cfg *config.Config) error {
|
func (m *Manager) Start(cfg *config.Config) error {
|
||||||
if cfg == nil {
|
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())
|
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
|
// Create and start worker
|
||||||
worker := NewForwardWorker(fwd, m.portForwarder, m.verbose)
|
worker := NewForwardWorker(fwd, m.portForwarder, m.verbose, m.statusUI)
|
||||||
worker.Start()
|
worker.Start()
|
||||||
|
|
||||||
// Store worker
|
// Store worker
|
||||||
|
|||||||
@@ -22,10 +22,11 @@ type ForwardWorker struct {
|
|||||||
doneChan chan struct{}
|
doneChan chan struct{}
|
||||||
verbose bool
|
verbose bool
|
||||||
lastPod string // Track the last pod we connected to
|
lastPod string // Track the last pod we connected to
|
||||||
|
statusUI StatusUpdater
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewForwardWorker creates a new ForwardWorker for a single forward configuration.
|
// 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())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
|
||||||
return &ForwardWorker{
|
return &ForwardWorker{
|
||||||
@@ -36,6 +37,7 @@ func NewForwardWorker(fwd config.Forward, portForwarder *k8s.PortForwarder, verb
|
|||||||
stopChan: make(chan struct{}),
|
stopChan: make(chan struct{}),
|
||||||
doneChan: make(chan struct{}),
|
doneChan: make(chan struct{}),
|
||||||
verbose: verbose,
|
verbose: verbose,
|
||||||
|
statusUI: statusUI,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,6 +88,9 @@ func (w *ForwardWorker) run() {
|
|||||||
|
|
||||||
// Check if pod changed (restart detected)
|
// Check if pod changed (restart detected)
|
||||||
if w.lastPod != "" && w.lastPod != podName {
|
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)
|
log.Printf("[%s] Switched to new pod: %s → %s", w.forward.ID(), w.lastPod, podName)
|
||||||
} else if w.lastPod == "" {
|
} else if w.lastPod == "" {
|
||||||
log.Printf("[%s] Forwarding %s → localhost:%d",
|
log.Printf("[%s] Forwarding %s → localhost:%d",
|
||||||
@@ -94,6 +99,11 @@ func (w *ForwardWorker) run() {
|
|||||||
|
|
||||||
w.lastPod = podName
|
w.lastPod = podName
|
||||||
|
|
||||||
|
// Update status to active
|
||||||
|
if w.statusUI != nil {
|
||||||
|
w.statusUI.UpdateStatus(w.forward.ID(), "Active")
|
||||||
|
}
|
||||||
|
|
||||||
// Establish port-forward connection
|
// Establish port-forward connection
|
||||||
err = w.establishForward(podName)
|
err = w.establishForward(podName)
|
||||||
|
|
||||||
@@ -104,6 +114,11 @@ func (w *ForwardWorker) run() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update status to error
|
||||||
|
if w.statusUI != nil {
|
||||||
|
w.statusUI.UpdateStatus(w.forward.ID(), "Reconnecting")
|
||||||
|
}
|
||||||
|
|
||||||
// Log the error
|
// Log the error
|
||||||
log.Printf("[%s] Port-forward connection failed: %v", w.forward.ID(), err)
|
log.Printf("[%s] Port-forward connection failed: %v", w.forward.ID(), err)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user