mirror of
https://github.com/lukaszraczylo/kportal.git
synced 2026-06-05 23:03:40 +00:00
@@ -1,10 +1,16 @@
|
||||
# kportal
|
||||
<p align="center">
|
||||
<img src="docs/kportal-logo-dark.svg" alt="kportal logo" width="400">
|
||||
</p>
|
||||
|
||||
[](https://github.com/lukaszraczylo/kportal/releases)
|
||||
[](LICENSE)
|
||||
[](https://goreportcard.com/report/github.com/lukaszraczylo/kportal)
|
||||
<p align="center">
|
||||
<a href="https://github.com/lukaszraczylo/kportal/releases"><img src="https://img.shields.io/github/v/release/lukaszraczylo/kportal" alt="Release"></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/github/license/lukaszraczylo/kportal" alt="License"></a>
|
||||
<a href="https://goreportcard.com/report/github.com/lukaszraczylo/kportal"><img src="https://goreportcard.com/badge/github.com/lukaszraczylo/kportal" alt="Go Report Card"></a>
|
||||
</p>
|
||||
|
||||
**Modern Kubernetes port-forward manager with interactive terminal UI**
|
||||
<p align="center">
|
||||
<strong>Modern Kubernetes port-forward manager with interactive terminal UI</strong>
|
||||
</p>
|
||||
|
||||
kportal simplifies managing multiple Kubernetes port-forwards with an elegant, interactive terminal interface. Built with [Bubble Tea](https://github.com/charmbracelet/bubbletea), it provides real-time status updates, automatic reconnection, and hot-reload configuration support.
|
||||
|
||||
@@ -13,6 +19,9 @@ kportal simplifies managing multiple Kubernetes port-forwards with an elegant, i
|
||||
## ✨ Features
|
||||
|
||||
- 🎯 **Interactive TUI** - Beautiful terminal interface with keyboard navigation (↑↓/jk, Space to toggle, q to quit)
|
||||
- ➕ **Live Add** - Add new port-forwards on-the-fly without editing config files or restarting
|
||||
- ✏️ **Live Edit** - Modify existing port-forwards (ports, resources, aliases) in real-time
|
||||
- 🗑️ **Live Delete** - Remove port-forwards instantly from the running session
|
||||
- 🔄 **Auto-Reconnect** - Automatic retry with exponential backoff on connection failures (max 10s)
|
||||
- ⚡ **Hot-Reload** - Update configuration without restarting - changes applied automatically
|
||||
- 🏥 **Health Checks** - Real-time port forward status monitoring with 5-second intervals
|
||||
@@ -93,6 +102,9 @@ kportal
|
||||
3. **Navigate the interface**:
|
||||
- `↑↓` or `j/k` - Navigate through forwards
|
||||
- `Space` or `Enter` - Toggle forward on/off
|
||||
- `a` - Add new port-forward interactively
|
||||
- `e` - Edit selected port-forward
|
||||
- `d` - Delete selected port-forward
|
||||
- `q` - Quit application
|
||||
|
||||
## 📖 Configuration
|
||||
|
||||
+171
@@ -0,0 +1,171 @@
|
||||
# Interactive Add/Remove Wizards
|
||||
|
||||
kportal now includes interactive wizards for adding and removing port forwards directly from the running UI!
|
||||
|
||||
## Quick Start
|
||||
|
||||
Run kportal normally:
|
||||
```bash
|
||||
./kportal
|
||||
```
|
||||
|
||||
From the main view:
|
||||
- Press **`n`** to add a new port forward
|
||||
- Press **`d`** to delete existing port forwards
|
||||
|
||||
## Add Forward Wizard (`n` key)
|
||||
|
||||
The wizard guides you through 7 steps to add a new forward:
|
||||
|
||||
### Step 1: Select Context
|
||||
Choose from available Kubernetes contexts in your kubeconfig.
|
||||
|
||||
### Step 2: Select Namespace
|
||||
Pick the namespace where your resource lives.
|
||||
|
||||
### Step 3: Select Resource Type
|
||||
Three options:
|
||||
- **Pod (by name prefix)** - Forward to a specific pod by prefix matching
|
||||
- **Pod (by label selector)** - Forward to pods matching labels (survives restarts)
|
||||
- **Service** - Most stable, load-balanced option
|
||||
|
||||
### Step 4: Enter Resource
|
||||
- **Pod prefix**: Type a prefix like `nginx-` to match pods
|
||||
- **Label selector**: Enter labels like `app=nginx,env=prod`
|
||||
- **Service**: Select from a list of services
|
||||
|
||||
The wizard shows real-time validation and matching resources!
|
||||
|
||||
### Step 5: Remote Port
|
||||
Enter the port number on the remote resource. The wizard displays detected ports from running containers.
|
||||
|
||||
### Step 6: Local Port
|
||||
Enter the local port to bind to. The wizard checks availability in real-time.
|
||||
|
||||
### Step 7: Confirmation
|
||||
Review your configuration and optionally add an alias (friendly name). Confirm to save!
|
||||
|
||||
### Navigation Keys
|
||||
|
||||
- **`↑`/`↓`** or **`j`/`k`** - Navigate options
|
||||
- **`Enter`** - Confirm and proceed to next step
|
||||
- **`Esc`** - Go back one step (or cancel on first step)
|
||||
- **`Ctrl+C`** - Hard cancel and return to main view
|
||||
- **`Backspace`** - Delete characters in text fields
|
||||
|
||||
## Remove Forward Wizard (`d` key)
|
||||
|
||||
Multi-select interface for removing forwards:
|
||||
|
||||
1. **Select forwards**: Use arrow keys to navigate, `Space` to toggle selection
|
||||
2. **Confirm removal**: Press `Enter` and confirm your choice
|
||||
|
||||
### Navigation Keys
|
||||
|
||||
- **`↑`/`↓`** or **`j`/`k`** - Navigate forwards
|
||||
- **`Space`** - Toggle selection of current forward
|
||||
- **`a`** - Select all forwards
|
||||
- **`n`** - Deselect all forwards
|
||||
- **`Enter`** - Proceed to confirmation
|
||||
- **`Esc`** - Cancel and return to main view
|
||||
- **`Ctrl+C`** - Hard cancel
|
||||
|
||||
## Auto Hot-Reload
|
||||
|
||||
When you save a forward via the wizard:
|
||||
1. The wizard writes to `.kportal.yaml` atomically
|
||||
2. The file watcher detects the change (~100ms)
|
||||
3. The manager reloads and starts the new forward
|
||||
4. The UI updates automatically
|
||||
|
||||
No restart needed!
|
||||
|
||||
## Error Handling
|
||||
|
||||
The wizards handle errors gracefully:
|
||||
|
||||
- **Cluster unreachable**: Shows error but allows manual entry
|
||||
- **Port conflicts**: Displays which process is using the port
|
||||
- **Invalid selectors**: Shows validation errors in real-time
|
||||
- **Duplicate ports**: Prevents adding forwards with conflicting ports
|
||||
|
||||
## Tips
|
||||
|
||||
### Pod Prefix Matching
|
||||
When using pod prefix, you can type just the app name:
|
||||
- `nginx` matches `nginx-deployment-abc123`
|
||||
- `postgres` matches `postgres-statefulset-0`
|
||||
|
||||
### Label Selectors
|
||||
Use standard Kubernetes label syntax:
|
||||
- `app=nginx` - Single label
|
||||
- `app=nginx,env=prod` - Multiple labels (comma-separated)
|
||||
- Real-time validation shows matching pods as you type!
|
||||
|
||||
### Aliases
|
||||
Use aliases for cleaner UI display:
|
||||
- Instead of: `production/default/pod/nginx-deployment-abc123:80→8080`
|
||||
- Shows as: `my-nginx:80→8080`
|
||||
|
||||
### Quick Selection
|
||||
In list views, you can use `j`/`k` (Vim-style) or arrow keys for navigation.
|
||||
|
||||
## Example Workflow
|
||||
|
||||
Adding a forward for a PostgreSQL database:
|
||||
|
||||
1. Press `n` in main view
|
||||
2. Select context: `production` (arrow keys + Enter)
|
||||
3. Select namespace: `default` (arrow keys + Enter)
|
||||
4. Select type: `Service` (arrow keys + Enter)
|
||||
5. Select service: `postgres` (arrow keys + Enter)
|
||||
6. Enter remote port: `5432` (type + Enter)
|
||||
7. Enter local port: `5432` (type + Enter)
|
||||
8. Add alias: `prod-db` (optional, type + Enter)
|
||||
9. Confirm: Select "Add to .kportal.yaml" (Enter)
|
||||
|
||||
Done! The forward starts automatically within seconds.
|
||||
|
||||
## Architecture
|
||||
|
||||
The wizards use:
|
||||
- **Config Mutator**: Safe, atomic YAML writes (temp file + rename)
|
||||
- **K8s Discovery**: Lists contexts, namespaces, pods, services
|
||||
- **Modal Overlays**: Wizards appear centered over the main view
|
||||
- **Async Validation**: Port checks and selector validation run in background
|
||||
- **Hot-Reload Integration**: File watcher picks up changes automatically
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Wizards not appearing?
|
||||
Check that kportal can connect to your Kubernetes cluster:
|
||||
```bash
|
||||
kubectl cluster-info
|
||||
```
|
||||
|
||||
### Port check showing wrong status?
|
||||
The port check happens asynchronously. Wait a moment after typing for validation.
|
||||
|
||||
### Changes not appearing?
|
||||
The file watcher triggers within 100ms. If changes aren't visible, check:
|
||||
1. `.kportal.yaml` was written correctly
|
||||
2. No validation errors in the file
|
||||
3. kportal process is still running
|
||||
|
||||
---
|
||||
|
||||
**Navigation Summary**
|
||||
|
||||
Main View:
|
||||
- `n` - New forward wizard
|
||||
- `d` - Delete forward wizard
|
||||
- `Space` - Toggle forward on/off
|
||||
- `↑↓/jk` - Navigate forwards
|
||||
- `q` - Quit
|
||||
|
||||
Wizards:
|
||||
- `Enter` - Next step / Confirm
|
||||
- `Esc` - Previous step / Cancel
|
||||
- `Ctrl+C` - Hard cancel
|
||||
- `↑↓/jk` - Navigate
|
||||
- `Space` - Toggle (in delete wizard)
|
||||
+86
-8
@@ -7,23 +7,30 @@ import (
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/nvm/kportal/internal/config"
|
||||
"github.com/nvm/kportal/internal/converter"
|
||||
"github.com/nvm/kportal/internal/forward"
|
||||
"github.com/nvm/kportal/internal/k8s"
|
||||
"github.com/nvm/kportal/internal/logger"
|
||||
"github.com/nvm/kportal/internal/ui"
|
||||
"k8s.io/klog/v2"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultConfigFile = ".kportal.yaml"
|
||||
defaultConfigFile = ".kportal.yaml"
|
||||
initialForwardSettleTime = 100 * time.Millisecond
|
||||
tableUpdateInterval = 2 * time.Second
|
||||
)
|
||||
|
||||
var (
|
||||
configFile = flag.String("c", defaultConfigFile, "Path to configuration file")
|
||||
verbose = flag.Bool("v", false, "Enable verbose logging")
|
||||
logFormat = flag.String("log-format", "text", "Log format: text or json")
|
||||
check = flag.Bool("check", false, "Validate configuration and exit")
|
||||
showVersion = flag.Bool("version", false, "Show version and exit")
|
||||
convertInput = flag.String("convert", "", "Convert kftray JSON config to kportal YAML (provide input file path)")
|
||||
@@ -39,6 +46,62 @@ func main() {
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
// Validate config path security
|
||||
if *configFile != "" {
|
||||
absConfigPath, err := filepath.Abs(*configFile)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Invalid config path: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
absConfigPath = filepath.Clean(absConfigPath)
|
||||
|
||||
// Block system directories
|
||||
systemDirs := []string{"/etc", "/sys", "/proc", "/dev"}
|
||||
for _, sysDir := range systemDirs {
|
||||
if strings.HasPrefix(absConfigPath, sysDir) {
|
||||
fmt.Fprintf(os.Stderr, "Error: Config file cannot be in system directory: %s\n", sysDir)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
*configFile = absConfigPath
|
||||
}
|
||||
|
||||
// Initialize structured logger
|
||||
var logLevel logger.Level
|
||||
var logFmt logger.Format
|
||||
var logOutput io.Writer
|
||||
|
||||
if *verbose {
|
||||
logLevel = logger.LevelDebug
|
||||
logOutput = os.Stderr
|
||||
} else {
|
||||
logLevel = logger.LevelInfo
|
||||
logOutput = io.Discard // Silence logger in non-verbose mode to prevent UI corruption
|
||||
}
|
||||
|
||||
switch *logFormat {
|
||||
case "json":
|
||||
logFmt = logger.FormatJSON
|
||||
default:
|
||||
logFmt = logger.FormatText
|
||||
}
|
||||
|
||||
logger.Init(logLevel, logFmt, logOutput)
|
||||
|
||||
// Configure klog (used by kubernetes client-go) to route through our logger
|
||||
// This prevents k8s logs from interfering with the UI
|
||||
if *verbose {
|
||||
// In verbose mode, route klog through our structured logger at DEBUG level
|
||||
klogLogger := logger.New(logger.LevelDebug, logFmt, os.Stderr)
|
||||
klogWriter := logger.NewKlogWriter(klogLogger)
|
||||
klog.SetOutput(klogWriter)
|
||||
} else {
|
||||
// In non-verbose mode, completely silence klog
|
||||
klog.SetOutput(io.Discard)
|
||||
}
|
||||
klog.LogToStderr(false)
|
||||
|
||||
// Handle conversion mode
|
||||
if *convertInput != "" {
|
||||
if err := converter.ConvertKFTrayToKPortal(*convertInput, *convertOutput); err != nil {
|
||||
@@ -68,11 +131,8 @@ func main() {
|
||||
log.SetOutput(io.Discard)
|
||||
log.SetPrefix("")
|
||||
log.SetFlags(0)
|
||||
|
||||
// Disable klog (used by kubernetes client-go)
|
||||
klog.SetOutput(io.Discard)
|
||||
klog.LogToStderr(false)
|
||||
} else {
|
||||
// Verbose mode - enable standard log formatting
|
||||
log.SetFlags(log.LstdFlags | log.Lshortfile)
|
||||
}
|
||||
|
||||
@@ -101,8 +161,21 @@ func main() {
|
||||
log.Printf("Loading configuration from: %s", *configFile)
|
||||
}
|
||||
|
||||
// Create Kubernetes client pool and discovery for wizards
|
||||
pool, err := k8s.NewClientPool()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: Failed to create k8s client pool: %v\n", err)
|
||||
fmt.Fprintf(os.Stderr, "Add/remove wizards will not be available\n")
|
||||
}
|
||||
discovery := k8s.NewDiscovery(pool)
|
||||
mutator := config.NewMutator(*configFile)
|
||||
|
||||
// Create forward manager
|
||||
manager := forward.NewManager(*verbose)
|
||||
manager, err := forward.NewManager(*verbose)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error creating forward manager: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Create UI (bubbletea for interactive, simple table for verbose)
|
||||
var bubbleTeaUI *ui.BubbleTeaUI
|
||||
@@ -117,6 +190,11 @@ func main() {
|
||||
manager.DisableForward(id)
|
||||
}
|
||||
}, version)
|
||||
|
||||
// Set wizard dependencies
|
||||
// Note: mutator is always available (for delete/edit), discovery requires valid kubeconfig (for add)
|
||||
bubbleTeaUI.SetWizardDependencies(discovery, mutator, *configFile)
|
||||
|
||||
manager.SetStatusUI(bubbleTeaUI)
|
||||
} else {
|
||||
// Verbose mode with simple table
|
||||
@@ -140,7 +218,7 @@ func main() {
|
||||
|
||||
// Start table update loop
|
||||
go func() {
|
||||
ticker := time.NewTicker(2 * time.Second)
|
||||
ticker := time.NewTicker(tableUpdateInterval)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
tableUI.Render()
|
||||
@@ -211,7 +289,7 @@ func main() {
|
||||
}()
|
||||
|
||||
// Give a moment for initial forwards to be added
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
time.Sleep(initialForwardSettleTime)
|
||||
|
||||
// Start the bubbletea app (blocks until quit)
|
||||
if err := bubbleTeaUI.Start(); err != nil {
|
||||
|
||||
+939
-395
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,132 @@
|
||||
<svg width="310" height="150" viewBox="0 0 310 150" xmlns="http://www.w3.org/2000/svg" id="darkLogo">
|
||||
<defs>
|
||||
<!-- Simple turbulence for portal edges -->
|
||||
<filter id="portalTurbulence" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feTurbulence type="fractalNoise" baseFrequency="0.02 0.03" numOctaves="2" result="turbulence" seed="5">
|
||||
<animate attributeName="seed" values="5;10;5" dur="8s" repeatCount="indefinite"/>
|
||||
</feTurbulence>
|
||||
<feDisplacementMap in2="turbulence" in="SourceGraphic" scale="2" xChannelSelector="R" yChannelSelector="G"/>
|
||||
</filter>
|
||||
|
||||
<!-- Blue glow -->
|
||||
<filter id="blueGlow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur stdDeviation="5" result="coloredBlur"/>
|
||||
<feMerge>
|
||||
<feMergeNode in="coloredBlur"/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
|
||||
<!-- Orange glow -->
|
||||
<filter id="orangeGlow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur stdDeviation="5" result="coloredBlur"/>
|
||||
<feMerge>
|
||||
<feMergeNode in="coloredBlur"/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
|
||||
<!-- Text glow -->
|
||||
<filter id="textGlow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feGaussianBlur stdDeviation="1" result="coloredBlur"/>
|
||||
<feMerge>
|
||||
<feMergeNode in="coloredBlur"/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
|
||||
<!-- Gradients -->
|
||||
<radialGradient id="bluePortal" cx="50%" cy="50%">
|
||||
<stop offset="0%" style="stop-color:#000814;stop-opacity:0.9"/>
|
||||
<stop offset="20%" style="stop-color:#001845;stop-opacity:0.8"/>
|
||||
<stop offset="50%" style="stop-color:#0077B6;stop-opacity:0.95"/>
|
||||
<stop offset="80%" style="stop-color:#00B4D8;stop-opacity:1"/>
|
||||
<stop offset="100%" style="stop-color:#90E0EF;stop-opacity:1"/>
|
||||
</radialGradient>
|
||||
|
||||
<radialGradient id="orangePortal" cx="50%" cy="50%">
|
||||
<stop offset="0%" style="stop-color:#1A0E00;stop-opacity:0.9"/>
|
||||
<stop offset="20%" style="stop-color:#3D2314;stop-opacity:0.8"/>
|
||||
<stop offset="50%" style="stop-color:#F77F00;stop-opacity:0.95"/>
|
||||
<stop offset="80%" style="stop-color:#FCBF49;stop-opacity:1"/>
|
||||
<stop offset="100%" style="stop-color:#FFD6A5;stop-opacity:1"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Blue Portal (LEFT) -->
|
||||
<g id="bluePortalGroup">
|
||||
<!-- Outer rings -->
|
||||
<ellipse cx="50" cy="75" rx="35" ry="50" fill="none" stroke="#90E0EF" stroke-width="0.5" opacity="0.2"/>
|
||||
<ellipse cx="50" cy="75" rx="30" ry="44" fill="none" stroke="#00B4D8" stroke-width="1" opacity="0.3"/>
|
||||
|
||||
<!-- Main portal -->
|
||||
<ellipse cx="50" cy="75" rx="26" ry="40" fill="url(#bluePortal)" filter="url(#blueGlow)" opacity="0.95"/>
|
||||
|
||||
<!-- Inner energy rings -->
|
||||
<ellipse cx="50" cy="75" rx="20" ry="32" fill="none" stroke="#00B4D8" stroke-width="2" opacity="0.7">
|
||||
<animate attributeName="rx" values="20;18;20" dur="3s" repeatCount="indefinite"/>
|
||||
<animate attributeName="ry" values="32;30;32" dur="3s" repeatCount="indefinite"/>
|
||||
</ellipse>
|
||||
<ellipse cx="50" cy="75" rx="14" ry="24" fill="none" stroke="#90E0EF" stroke-width="1.5" opacity="0.5">
|
||||
<animate attributeName="rx" values="14;16;14" dur="2.5s" repeatCount="indefinite"/>
|
||||
<animate attributeName="ry" values="24;26;24" dur="2.5s" repeatCount="indefinite"/>
|
||||
</ellipse>
|
||||
|
||||
<!-- Portal core -->
|
||||
<ellipse cx="50" cy="75" rx="7" ry="12" fill="#000814" opacity="0.95"/>
|
||||
</g>
|
||||
|
||||
<!-- Text: "kportal" -->
|
||||
<!-- Orange K -->
|
||||
<text x="76" y="90" font-family="'Helvetica Neue', Arial, sans-serif" font-size="52" font-weight="300" fill="#FCBF49" filter="url(#textGlow)">
|
||||
k
|
||||
<animate attributeName="x" values="76;79;76" dur="4s" repeatCount="indefinite"/>
|
||||
</text>
|
||||
|
||||
<!-- White "porta" -->
|
||||
<text x="105" y="90" font-family="'Helvetica Neue', Arial, sans-serif" font-size="52" font-weight="300" fill="white" filter="url(#textGlow)">
|
||||
porta
|
||||
</text>
|
||||
|
||||
<!-- Blue L -->
|
||||
<text x="220" y="90" font-family="'Helvetica Neue', Arial, sans-serif" font-size="52" font-weight="300" fill="#00B4D8" filter="url(#textGlow)">
|
||||
l
|
||||
<animate attributeName="x" values="220;223;220" dur="4s" repeatCount="indefinite"/>
|
||||
</text>
|
||||
|
||||
<!-- Orange Portal (RIGHT) at x=260 -->
|
||||
<g id="orangePortalGroup">
|
||||
<!-- Outer rings -->
|
||||
<ellipse cx="260" cy="75" rx="35" ry="50" fill="none" stroke="#FFD6A5" stroke-width="0.5" opacity="0.2"/>
|
||||
<ellipse cx="260" cy="75" rx="30" ry="44" fill="none" stroke="#FCBF49" stroke-width="1" opacity="0.3"/>
|
||||
|
||||
<!-- Main portal -->
|
||||
<ellipse cx="260" cy="75" rx="26" ry="40" fill="url(#orangePortal)" filter="url(#orangeGlow)" opacity="0.95"/>
|
||||
|
||||
<!-- Inner energy rings -->
|
||||
<ellipse cx="260" cy="75" rx="20" ry="32" fill="none" stroke="#FCBF49" stroke-width="2" opacity="0.7">
|
||||
<animate attributeName="rx" values="20;18;20" dur="3s" repeatCount="indefinite"/>
|
||||
<animate attributeName="ry" values="32;30;32" dur="3s" repeatCount="indefinite"/>
|
||||
</ellipse>
|
||||
<ellipse cx="260" cy="75" rx="14" ry="24" fill="none" stroke="#FFD6A5" stroke-width="1.5" opacity="0.5">
|
||||
<animate attributeName="rx" values="14;16;14" dur="2.5s" repeatCount="indefinite"/>
|
||||
<animate attributeName="ry" values="24;26;24" dur="2.5s" repeatCount="indefinite"/>
|
||||
</ellipse>
|
||||
|
||||
<!-- Portal core -->
|
||||
<ellipse cx="260" cy="75" rx="7" ry="12" fill="#1A0E00" opacity="0.95"/>
|
||||
</g>
|
||||
|
||||
<!-- Energy connection between portals -->
|
||||
<path d="M 76 75 Q 180 70 222 75" stroke="url(#energyGradient)" stroke-width="0.5" fill="none" opacity="0.3">
|
||||
<animate attributeName="opacity" values="0.1;0.3;0.1" dur="4s" repeatCount="indefinite"/>
|
||||
</path>
|
||||
|
||||
<defs>
|
||||
<linearGradient id="energyGradient" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" style="stop-color:#00B4D8;stop-opacity:1"/>
|
||||
<stop offset="50%" style="stop-color:#667eea;stop-opacity:1"/>
|
||||
<stop offset="100%" style="stop-color:#FCBF49;stop-opacity:1"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 7.7 KiB |
@@ -0,0 +1,128 @@
|
||||
<svg width="310" height="150" viewBox="0 0 310 150" xmlns="http://www.w3.org/2000/svg" id="lightLogo">
|
||||
<defs>
|
||||
<!-- Simple turbulence for portal edges -->
|
||||
<filter id="portalTurbulenceLight" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feTurbulence type="fractalNoise" baseFrequency="0.02 0.03" numOctaves="2" result="turbulence" seed="5">
|
||||
<animate attributeName="seed" values="5;10;5" dur="8s" repeatCount="indefinite"/>
|
||||
</feTurbulence>
|
||||
<feDisplacementMap in2="turbulence" in="SourceGraphic" scale="2" xChannelSelector="R" yChannelSelector="G"/>
|
||||
</filter>
|
||||
|
||||
<!-- Blue glow for light background -->
|
||||
<filter id="blueGlowLight" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur stdDeviation="4" result="coloredBlur"/>
|
||||
<feMerge>
|
||||
<feMergeNode in="coloredBlur"/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
|
||||
<!-- Orange glow for light background -->
|
||||
<filter id="orangeGlowLight" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur stdDeviation="4" result="coloredBlur"/>
|
||||
<feMerge>
|
||||
<feMergeNode in="coloredBlur"/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
|
||||
<!-- Text shadow for light background -->
|
||||
<filter id="textShadowLight" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feDropShadow dx="0" dy="1" stdDeviation="0.5" flood-opacity="0.15"/>
|
||||
</filter>
|
||||
|
||||
<!-- Enhanced gradients for light background -->
|
||||
<radialGradient id="bluePortalLight" cx="50%" cy="50%">
|
||||
<stop offset="0%" style="stop-color:#001529;stop-opacity:1"/>
|
||||
<stop offset="20%" style="stop-color:#002766;stop-opacity:0.95"/>
|
||||
<stop offset="50%" style="stop-color:#0066CC;stop-opacity:0.98"/>
|
||||
<stop offset="80%" style="stop-color:#0099FF;stop-opacity:1"/>
|
||||
<stop offset="100%" style="stop-color:#66CCFF;stop-opacity:1"/>
|
||||
</radialGradient>
|
||||
|
||||
<radialGradient id="orangePortalLight" cx="50%" cy="50%">
|
||||
<stop offset="0%" style="stop-color:#2E1A00;stop-opacity:1"/>
|
||||
<stop offset="20%" style="stop-color:#5C3317;stop-opacity:0.95"/>
|
||||
<stop offset="50%" style="stop-color:#E66100;stop-opacity:0.98"/>
|
||||
<stop offset="80%" style="stop-color:#FF9933;stop-opacity:1"/>
|
||||
<stop offset="100%" style="stop-color:#FFBB66;stop-opacity:1"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Blue Portal (LEFT) -->
|
||||
<g id="bluePortalGroupLight">
|
||||
<!-- Outer rings -->
|
||||
<ellipse cx="50" cy="75" rx="35" ry="50" fill="none" stroke="#0099FF" stroke-width="0.8" opacity="0.3"/>
|
||||
<ellipse cx="50" cy="75" rx="30" ry="44" fill="none" stroke="#0066CC" stroke-width="1.2" opacity="0.4"/>
|
||||
|
||||
<!-- Main portal -->
|
||||
<ellipse cx="50" cy="75" rx="26" ry="40" fill="url(#bluePortalLight)" filter="url(#blueGlowLight)" opacity="1"/>
|
||||
|
||||
<!-- Inner energy rings -->
|
||||
<ellipse cx="50" cy="75" rx="20" ry="32" fill="none" stroke="#0099FF" stroke-width="2" opacity="0.8">
|
||||
<animate attributeName="rx" values="20;18;20" dur="3s" repeatCount="indefinite"/>
|
||||
<animate attributeName="ry" values="32;30;32" dur="3s" repeatCount="indefinite"/>
|
||||
</ellipse>
|
||||
<ellipse cx="50" cy="75" rx="14" ry="24" fill="none" stroke="#66CCFF" stroke-width="1.5" opacity="0.6">
|
||||
<animate attributeName="rx" values="14;16;14" dur="2.5s" repeatCount="indefinite"/>
|
||||
<animate attributeName="ry" values="24;26;24" dur="2.5s" repeatCount="indefinite"/>
|
||||
</ellipse>
|
||||
|
||||
<!-- Portal core -->
|
||||
<ellipse cx="50" cy="75" rx="7" ry="12" fill="#001529" opacity="1"/>
|
||||
</g>
|
||||
|
||||
<!-- Text: "kportal" with dark colors for light background -->
|
||||
<!-- Orange K -->
|
||||
<text x="76" y="90" font-family="'Helvetica Neue', Arial, sans-serif" font-size="52" font-weight="400" fill="#E66100" filter="url(#textShadowLight)">
|
||||
k
|
||||
<animate attributeName="x" values="76;79;76" dur="4s" repeatCount="indefinite"/>
|
||||
</text>
|
||||
|
||||
<!-- Dark "porta" for light background -->
|
||||
<text x="105" y="90" font-family="'Helvetica Neue', Arial, sans-serif" font-size="52" font-weight="400" fill="#2C3E50" filter="url(#textShadowLight)">
|
||||
porta
|
||||
</text>
|
||||
|
||||
<!-- Blue L -->
|
||||
<text x="220" y="90" font-family="'Helvetica Neue', Arial, sans-serif" font-size="52" font-weight="400" fill="#0066CC" filter="url(#textShadowLight)">
|
||||
l
|
||||
<animate attributeName="x" values="220;223;220" dur="4s" repeatCount="indefinite"/>
|
||||
</text>
|
||||
|
||||
<!-- Orange Portal (RIGHT) at x=260 -->
|
||||
<g id="orangePortalGroupLight">
|
||||
<!-- Outer rings -->
|
||||
<ellipse cx="260" cy="75" rx="35" ry="50" fill="none" stroke="#FFBB66" stroke-width="0.8" opacity="0.3"/>
|
||||
<ellipse cx="260" cy="75" rx="30" ry="44" fill="none" stroke="#FF9933" stroke-width="1.2" opacity="0.4"/>
|
||||
|
||||
<!-- Main portal -->
|
||||
<ellipse cx="260" cy="75" rx="26" ry="40" fill="url(#orangePortalLight)" filter="url(#orangeGlowLight)" opacity="1"/>
|
||||
|
||||
<!-- Inner energy rings -->
|
||||
<ellipse cx="260" cy="75" rx="20" ry="32" fill="none" stroke="#FF9933" stroke-width="2" opacity="0.8">
|
||||
<animate attributeName="rx" values="20;18;20" dur="3s" repeatCount="indefinite"/>
|
||||
<animate attributeName="ry" values="32;30;32" dur="3s" repeatCount="indefinite"/>
|
||||
</ellipse>
|
||||
<ellipse cx="260" cy="75" rx="14" ry="24" fill="none" stroke="#FFBB66" stroke-width="1.5" opacity="0.6">
|
||||
<animate attributeName="rx" values="14;16;14" dur="2.5s" repeatCount="indefinite"/>
|
||||
<animate attributeName="ry" values="24;26;24" dur="2.5s" repeatCount="indefinite"/>
|
||||
</ellipse>
|
||||
|
||||
<!-- Portal core -->
|
||||
<ellipse cx="260" cy="75" rx="7" ry="12" fill="#2E1A00" opacity="1"/>
|
||||
</g>
|
||||
|
||||
<!-- Energy connection between portals -->
|
||||
<path d="M 76 75 Q 180 70 222 75" stroke="url(#energyGradientLight)" stroke-width="0.7" fill="none" opacity="0.4">
|
||||
<animate attributeName="opacity" values="0.2;0.4;0.2" dur="4s" repeatCount="indefinite"/>
|
||||
</path>
|
||||
|
||||
<defs>
|
||||
<linearGradient id="energyGradientLight" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" style="stop-color:#0066CC;stop-opacity:1"/>
|
||||
<stop offset="50%" style="stop-color:#8B7CC6;stop-opacity:1"/>
|
||||
<stop offset="100%" style="stop-color:#E66100;stop-opacity:1"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 7.8 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 199 KiB After Width: | Height: | Size: 184 KiB |
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+20
-10
@@ -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()),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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",
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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...)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+268
-41
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user