Add user friendly UI allowing to temporarily toggle port forwarding.

This commit is contained in:
2025-11-23 16:50:56 +00:00
parent 112969b82a
commit 21ea41781d
8 changed files with 975 additions and 158 deletions
+429
View File
@@ -0,0 +1,429 @@
package ui
import (
"fmt"
"strings"
"sync"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/nvm/kportal/internal/config"
)
// ForwardUpdateMsg is sent when a forward status changes
type ForwardUpdateMsg struct {
ID string
Status string
}
// ForwardErrorMsg is sent when a forward has an error
type ForwardErrorMsg struct {
ID string
Error string
}
// ForwardAddMsg is sent when a new forward is added
type ForwardAddMsg struct {
ID string
Forward *ForwardStatus
}
// ForwardRemoveMsg is sent when a forward is removed
type ForwardRemoveMsg struct {
ID string
}
// BubbleTeaUI is a bubbletea-based terminal UI
type BubbleTeaUI struct {
mu sync.RWMutex
program *tea.Program
forwards map[string]*ForwardStatus
forwardOrder []string
selectedIndex int
disabledMap map[string]bool
toggleCallback func(id string, enable bool)
version string
errors map[string]string // Track error messages by forward ID
}
// bubbletea model
type model struct {
ui *BubbleTeaUI
}
// NewBubbleTeaUI creates a new bubbletea-based UI
func NewBubbleTeaUI(toggleCallback func(id string, enable bool), version string) *BubbleTeaUI {
ui := &BubbleTeaUI{
forwards: make(map[string]*ForwardStatus),
forwardOrder: make([]string, 0),
selectedIndex: 0,
disabledMap: make(map[string]bool),
toggleCallback: toggleCallback,
version: version,
errors: make(map[string]string),
}
return ui
}
// Start starts the bubbletea application
func (ui *BubbleTeaUI) Start() error {
m := model{ui: ui}
ui.program = tea.NewProgram(m, tea.WithAltScreen())
_, err := ui.program.Run()
return err
}
// Stop stops the application
func (ui *BubbleTeaUI) Stop() {
if ui.program != nil {
ui.program.Quit()
}
}
// AddForward adds a forward to display
func (ui *BubbleTeaUI) AddForward(id string, fwd *config.Forward) {
ui.mu.Lock()
// Check if already exists (re-enabling case)
if existing, ok := ui.forwards[id]; ok {
existing.Status = "Starting"
ui.disabledMap[id] = false
ui.mu.Unlock()
if ui.program != nil {
ui.program.Send(ForwardUpdateMsg{ID: id, Status: "Starting"})
}
return
}
// Parse resource
resourceType := "pod"
resourceName := fwd.Resource
for idx := 0; idx < len(fwd.Resource); idx++ {
if fwd.Resource[idx] == '/' {
resourceType = fwd.Resource[:idx]
resourceName = fwd.Resource[idx+1:]
break
}
}
alias := fwd.Alias
if alias == "" {
alias = resourceName
}
status := &ForwardStatus{
Context: fwd.GetContext(),
Namespace: fwd.GetNamespace(),
Alias: alias,
Type: resourceType,
Resource: resourceName,
RemotePort: fwd.Port,
LocalPort: fwd.LocalPort,
Status: "Starting",
}
ui.forwards[id] = status
ui.forwardOrder = append(ui.forwardOrder, id)
ui.mu.Unlock()
if ui.program != nil {
ui.program.Send(ForwardAddMsg{ID: id, Forward: status})
}
}
// UpdateStatus updates forward status
func (ui *BubbleTeaUI) UpdateStatus(id string, status string) {
ui.mu.Lock()
if fwd, ok := ui.forwards[id]; ok {
fwd.Status = status
}
// Clear error if status is not Error
if status != "Error" {
delete(ui.errors, id)
}
ui.mu.Unlock()
if ui.program != nil {
ui.program.Send(ForwardUpdateMsg{ID: id, Status: status})
}
}
// SetError sets an error message for a forward
func (ui *BubbleTeaUI) SetError(id, msg string) {
ui.mu.Lock()
ui.errors[id] = msg
ui.mu.Unlock()
if ui.program != nil {
ui.program.Send(ForwardErrorMsg{ID: id, Error: msg})
}
}
// Remove removes a forward
func (ui *BubbleTeaUI) Remove(id string) {
ui.mu.Lock()
delete(ui.forwards, id)
// Remove from order
for i, fid := range ui.forwardOrder {
if fid == id {
ui.forwardOrder = append(ui.forwardOrder[:i], ui.forwardOrder[i+1:]...)
break
}
}
ui.mu.Unlock()
if ui.program != nil {
ui.program.Send(ForwardRemoveMsg{ID: id})
}
}
// Bubble Tea Model Implementation
func (m model) Init() tea.Cmd {
return nil
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
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()
}
case ForwardAddMsg:
// Already handled in AddForward, just trigger re-render
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
return m, nil
}
return m, nil
}
func (m model) View() string {
m.ui.mu.RLock()
defer m.ui.mu.RUnlock()
var b strings.Builder
// Styles
titleStyle := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("220")).
Padding(0, 1)
headerStyle := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("220"))
separatorStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("240"))
selectedStyle := lipgloss.NewStyle().
Background(lipgloss.Color("240")).
Foreground(lipgloss.Color("230"))
disabledStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("240"))
activeStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("46"))
startingStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("220"))
errorStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("196"))
// Title with version
title := fmt.Sprintf("kportal v%s - Port Forwarding Status", m.ui.version)
b.WriteString(titleStyle.Render(title))
b.WriteString("\n\n")
// Header
header := fmt.Sprintf("%-15s %-18s %-20s %-10s %-21s %7s %7s %s",
"CONTEXT", "NAMESPACE", "ALIAS", "TYPE", "RESOURCE", "REMOTE", "LOCAL", "STATUS")
b.WriteString(headerStyle.Render(header))
b.WriteString("\n")
b.WriteString(separatorStyle.Render(strings.Repeat("─", 120)))
b.WriteString("\n")
// No forwards
if len(m.ui.forwardOrder) == 0 {
b.WriteString(disabledStyle.Render("\nNo forwards configured\n"))
} else {
// Display forwards
for idx, id := range m.ui.forwardOrder {
fwd, ok := m.ui.forwards[id]
if !ok {
continue
}
isSelected := (idx == m.ui.selectedIndex)
isDisabled := m.ui.disabledMap[id]
// Selection indicator
indicator := " "
if isSelected {
indicator = "> "
}
// Status icon and text
statusIcon := "● "
statusText := fwd.Status
if isDisabled {
statusIcon = "○ "
statusText = "Disabled"
} else {
switch fwd.Status {
case "Starting":
statusIcon = "○ "
case "Reconnecting":
statusIcon = "◐ "
case "Error":
statusIcon = "✗ "
}
}
// Format row
row := fmt.Sprintf("%s%-15s %-18s %-20s %-10s %-21s %7d %7d %s%s",
indicator,
truncate(fwd.Context, 15),
truncate(fwd.Namespace, 18),
truncate(fwd.Alias, 20),
truncate(fwd.Type, 10),
truncate(fwd.Resource, 21),
fwd.RemotePort,
fwd.LocalPort,
statusIcon,
statusText)
// Apply styling
if isSelected {
row = selectedStyle.Render(row)
} else if isDisabled {
row = disabledStyle.Render(row)
} else {
// Color the status part
switch fwd.Status {
case "Active":
parts := strings.Split(row, statusIcon)
if len(parts) == 2 {
row = parts[0] + activeStyle.Render(statusIcon+statusText)
}
case "Starting", "Reconnecting":
parts := strings.Split(row, statusIcon)
if len(parts) == 2 {
row = parts[0] + startingStyle.Render(statusIcon+statusText)
}
case "Error":
parts := strings.Split(row, statusIcon)
if len(parts) == 2 {
row = parts[0] + errorStyle.Render(statusIcon+statusText)
}
}
}
b.WriteString(row)
b.WriteString("\n")
}
}
// 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
if len(m.ui.errors) > 0 {
b.WriteString("\n\n")
errorHeaderStyle := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("196"))
b.WriteString(errorHeaderStyle.Render("Errors:"))
b.WriteString("\n")
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")
}
}
}
return b.String()
}
// moveSelection moves the selection up or down
func (ui *BubbleTeaUI) moveSelection(delta int) {
ui.mu.Lock()
defer ui.mu.Unlock()
if len(ui.forwardOrder) == 0 {
return
}
ui.selectedIndex += delta
if ui.selectedIndex < 0 {
ui.selectedIndex = 0
}
if ui.selectedIndex >= len(ui.forwardOrder) {
ui.selectedIndex = len(ui.forwardOrder) - 1
}
}
// toggleSelected toggles the selected forward on/off
func (ui *BubbleTeaUI) toggleSelected() {
ui.mu.Lock()
if ui.selectedIndex < 0 || ui.selectedIndex >= len(ui.forwardOrder) {
ui.mu.Unlock()
return
}
selectedID := ui.forwardOrder[ui.selectedIndex]
currentlyDisabled := ui.disabledMap[selectedID]
newState := !currentlyDisabled
ui.disabledMap[selectedID] = newState
ui.mu.Unlock()
// Call the toggle callback in a goroutine to avoid blocking the UI
if ui.toggleCallback != nil {
go ui.toggleCallback(selectedID, !newState) // enable is inverse of disabled
}
}
+181
View File
@@ -0,0 +1,181 @@
package ui
import (
"fmt"
"os"
"sync"
"golang.org/x/term"
)
// InteractiveController handles keyboard input and selection state
type InteractiveController struct {
mu sync.RWMutex
selectedIndex int
forwardIDs []string // Ordered list of forward IDs
disabledMap map[string]bool // Tracks which forwards are disabled
toggleCallback func(id string, enable bool)
enabled bool
oldTermState *term.State
}
// NewInteractiveController creates a new interactive controller
func NewInteractiveController(toggleCallback func(id string, enable bool)) *InteractiveController {
return &InteractiveController{
selectedIndex: 0,
forwardIDs: make([]string, 0),
disabledMap: make(map[string]bool),
toggleCallback: toggleCallback,
enabled: false,
}
}
// Enable puts the terminal in raw mode for keyboard input
func (ic *InteractiveController) Enable() error {
ic.mu.Lock()
defer ic.mu.Unlock()
if ic.enabled {
return nil
}
// Save current terminal state
oldState, err := term.MakeRaw(int(os.Stdin.Fd()))
if err != nil {
return fmt.Errorf("failed to enable raw mode: %w", err)
}
ic.oldTermState = oldState
ic.enabled = true
return nil
}
// Disable restores the terminal to normal mode
func (ic *InteractiveController) Disable() error {
ic.mu.Lock()
defer ic.mu.Unlock()
if !ic.enabled {
return nil
}
if ic.oldTermState != nil {
if err := term.Restore(int(os.Stdin.Fd()), ic.oldTermState); err != nil {
return fmt.Errorf("failed to restore terminal: %w", err)
}
}
ic.enabled = false
return nil
}
// UpdateForwardsList updates the list of forwards for navigation
func (ic *InteractiveController) UpdateForwardsList(ids []string) {
ic.mu.Lock()
defer ic.mu.Unlock()
ic.forwardIDs = ids
// Ensure selected index is valid
if ic.selectedIndex >= len(ic.forwardIDs) {
ic.selectedIndex = len(ic.forwardIDs) - 1
}
if ic.selectedIndex < 0 && len(ic.forwardIDs) > 0 {
ic.selectedIndex = 0
}
}
// MoveUp moves selection up
func (ic *InteractiveController) MoveUp() {
ic.mu.Lock()
defer ic.mu.Unlock()
if ic.selectedIndex > 0 {
ic.selectedIndex--
}
}
// MoveDown moves selection down
func (ic *InteractiveController) MoveDown() {
ic.mu.Lock()
defer ic.mu.Unlock()
if ic.selectedIndex < len(ic.forwardIDs)-1 {
ic.selectedIndex++
}
}
// ToggleSelected toggles the enable/disable state of the selected forward
func (ic *InteractiveController) ToggleSelected() {
ic.mu.Lock()
if ic.selectedIndex < 0 || ic.selectedIndex >= len(ic.forwardIDs) {
ic.mu.Unlock()
return
}
selectedID := ic.forwardIDs[ic.selectedIndex]
currentlyDisabled := ic.disabledMap[selectedID]
newState := !currentlyDisabled
ic.disabledMap[selectedID] = newState
ic.mu.Unlock()
// Call the toggle callback
if ic.toggleCallback != nil {
ic.toggleCallback(selectedID, !newState) // enable is inverse of disabled
}
}
// GetSelectedIndex returns the current selection index
func (ic *InteractiveController) GetSelectedIndex() int {
ic.mu.RLock()
defer ic.mu.RUnlock()
return ic.selectedIndex
}
// IsDisabled returns whether a forward is disabled
func (ic *InteractiveController) IsDisabled(id string) bool {
ic.mu.RLock()
defer ic.mu.RUnlock()
return ic.disabledMap[id]
}
// GetSelectedID returns the ID of the currently selected forward
func (ic *InteractiveController) GetSelectedID() string {
ic.mu.RLock()
defer ic.mu.RUnlock()
if ic.selectedIndex < 0 || ic.selectedIndex >= len(ic.forwardIDs) {
return ""
}
return ic.forwardIDs[ic.selectedIndex]
}
// HandleKey processes keyboard input and returns true if should continue
func (ic *InteractiveController) HandleKey(b []byte) bool {
if len(b) == 0 {
return true
}
// Handle single byte keys
if len(b) == 1 {
switch b[0] {
case 'q', 'Q', 3: // q, Q, or Ctrl+C
return false
case ' ', '\r': // Space or Enter to toggle
ic.ToggleSelected()
return true
}
}
// Handle escape sequences (arrow keys)
if len(b) == 3 && b[0] == 27 && b[1] == 91 {
switch b[2] {
case 65: // Up arrow
ic.MoveUp()
case 66: // Down arrow
ic.MoveDown()
}
}
return true
}
+48 -6
View File
@@ -23,9 +23,10 @@ type ForwardStatus struct {
// TableUI manages the terminal table display
type TableUI struct {
mu sync.RWMutex
forwards map[string]*ForwardStatus // key is forward ID
verbose bool
mu sync.RWMutex
forwards map[string]*ForwardStatus // key is forward ID
verbose bool
interactive *InteractiveController
}
// NewTableUI creates a new table UI manager
@@ -36,6 +37,13 @@ func NewTableUI(verbose bool) *TableUI {
}
}
// SetInteractiveController sets the interactive controller
func (t *TableUI) SetInteractiveController(ic *InteractiveController) {
t.mu.Lock()
defer t.mu.Unlock()
t.interactive = ic
}
// AddForward registers a new forward for display
func (t *TableUI) AddForward(id string, fwd *config.Forward) {
t.mu.Lock()
@@ -118,10 +126,27 @@ func (t *TableUI) Render() {
}
}
// Update interactive controller with current forward IDs (in display order)
if t.interactive != nil {
ids := make([]string, len(entries))
for i, entry := range entries {
ids[i] = entry.id
}
t.interactive.UpdateForwardsList(ids)
}
// Print each forward
for _, entry := range entries {
for i, entry := range entries {
fwd := entry.fwd
// Check if this row is selected
isSelected := false
isDisabled := false
if t.interactive != nil {
isSelected = (i == t.interactive.GetSelectedIndex())
isDisabled = t.interactive.IsDisabled(entry.id)
}
// Truncate long names
alias := truncate(fwd.Alias, 25)
resource := truncate(fwd.Resource, 25)
@@ -129,7 +154,8 @@ func (t *TableUI) Render() {
// Color code status with indicator
statusStr := formatStatusWithIndicator(fwd.Status)
fmt.Printf("%-15s %-18s %-25s %-10s %-25s %-12d %-12d %s\n",
// Build the row content
rowContent := fmt.Sprintf(" %-15s %-18s %-25s %-10s %-25s %-12d %-12d %s",
fwd.Context,
fwd.Namespace,
alias,
@@ -138,10 +164,26 @@ func (t *TableUI) Render() {
fwd.RemotePort,
fwd.LocalPort,
statusStr)
// Apply selection highlighting or disabled styling
if isSelected {
// Replace leading spaces with arrow, then apply reverse video to entire line
rowContent = "\033[7m> " + rowContent[2:] + "\033[0m"
} else if isDisabled {
// Apply dimmed styling to entire line
rowContent = "\033[2m" + rowContent + "\033[0m"
}
fmt.Println(rowContent)
}
fmt.Println(strings.Repeat("=", 130))
fmt.Printf("Total forwards: %d | Press Ctrl+C to stop\n", len(t.forwards))
helpText := "Total forwards: %d | ↑↓: Navigate | Space: Toggle | q: Quit"
if !t.verbose {
fmt.Printf(helpText+"\n", len(t.forwards))
} else {
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 {