Compare commits

...

11 Commits

Author SHA1 Message Date
lukaszraczylo e4930071fc Update go.mod and go.sum (#33) 2026-01-23 03:40:40 +00:00
lukaszraczylo c43aca3805 Update go.mod and go.sum (#32) 2026-01-19 03:42:14 +00:00
lukaszraczylo 4add04e3be Update go.mod and go.sum (#31) 2026-01-16 03:39:04 +00:00
lukaszraczylo 96ae1d45e0 style: Extract UI constants and refactor main view rendering (#30)
- [x] Add golangci-lint configuration with gocritic ifElseChain disabled
- [x] Rename error variables to avoid shadowing (createErr, watcherErr, watchErr, etc.)
- [x] Replace `interface{}` with `any` type alias throughout codebase
- [x] Add package-level documentation comments to all internal packages
- [x] Reorder struct fields alphabetically for consistency
- [x] Extract UI constants (terminal dimensions, column widths, colors) to constants.go
- [x] Refactor BubbleTeaUI main view rendering into smaller helper functions
- [x] Simplify nested conditionals and improve code clarity
- [x] Add `isForwardDisabled()` helper method to BubbleTeaUI
- [x] Update file permissions from 0644 to 0600 in config tests
- [x] Add `#nosec` comments and error suppression where appropriate
- [x] Improve test table struct field ordering for readability
- [x] Fix resource parsing in AddForward using strings.SplitN
- [x] Add comprehensive tests for new UI helper functions and constants
2026-01-13 09:37:45 +00:00
lukaszraczylo 3d71f64901 Update go.mod and go.sum (#29) 2026-01-13 03:39:00 +00:00
lukaszraczylo 38b7a06c53 Update go.mod and go.sum (#28) 2026-01-12 03:41:55 +00:00
lukaszraczylo 7ad96e3f72 Update go.mod and go.sum (#27) 2026-01-10 03:37:23 +00:00
lukaszraczylo ac7c855de5 Update go.mod and go.sum (#26) 2026-01-09 03:39:39 +00:00
lukaszraczylo 4074a7186c Update go.mod and go.sum (#25) 2026-01-07 03:39:19 +00:00
lukaszraczylo a5cc95a26e Update go.mod and go.sum (#24) 2025-12-23 03:39:14 +00:00
lukaszraczylo 0f977683cd Update go.mod and go.sum (#23) 2025-12-21 03:39:29 +00:00
56 changed files with 1352 additions and 763 deletions
+29
View File
@@ -0,0 +1,29 @@
# golangci-lint configuration
# https://golangci-lint.run/usage/configuration/
run:
timeout: 5m
tests: true
linters:
enable:
- errcheck
- gosimple
- govet
- ineffassign
- staticcheck
- unused
- gosec
- gocritic
- gofmt
linters-settings:
govet:
enable:
- fieldalignment
gosec:
excludes:
- G304 # File path provided as taint input - handled with #nosec comments where needed
gocritic:
disabled-checks:
- ifElseChain # Complex conditionals are clearer as if-else than switch true
+35 -25
View File
@@ -199,8 +199,8 @@ func main() {
os.Exit(0) os.Exit(0)
} }
// Create empty config file // Create empty config file
if err := config.CreateEmptyConfigFile(*configFile); err != nil { if createErr := config.CreateEmptyConfigFile(*configFile); createErr != nil {
fmt.Fprintf(os.Stderr, "Error creating config file: %v\n", err) fmt.Fprintf(os.Stderr, "Error creating config file: %v\n", createErr)
os.Exit(1) os.Exit(1)
} }
fmt.Printf("Created %s\n", *configFile) fmt.Printf("Created %s\n", *configFile)
@@ -309,7 +309,7 @@ func main() {
bubbleTeaUI.SetHTTPLogSubscriber(func(forwardID string, callback func(entry ui.HTTPLogEntry)) func() { bubbleTeaUI.SetHTTPLogSubscriber(func(forwardID string, callback func(entry ui.HTTPLogEntry)) func() {
worker := manager.GetWorker(forwardID) worker := manager.GetWorker(forwardID)
if worker == nil { if worker == nil {
logger.Debug("HTTP log subscription failed: worker not found", map[string]interface{}{ logger.Debug("HTTP log subscription failed: worker not found", map[string]any{
"forward_id": forwardID, "forward_id": forwardID,
}) })
return func() {} // No-op cleanup return func() {} // No-op cleanup
@@ -318,7 +318,7 @@ func main() {
proxy := worker.GetHTTPProxy() proxy := worker.GetHTTPProxy()
if proxy == nil { if proxy == nil {
// This is expected for forwards without httpLog enabled - not an error // This is expected for forwards without httpLog enabled - not an error
logger.Debug("HTTP log subscription skipped: proxy not enabled", map[string]interface{}{ logger.Debug("HTTP log subscription skipped: proxy not enabled", map[string]any{
"forward_id": forwardID, "forward_id": forwardID,
}) })
return func() {} // HTTP logging not enabled for this forward return func() {} // HTTP logging not enabled for this forward
@@ -326,7 +326,7 @@ func main() {
proxyLogger := proxy.GetLogger() proxyLogger := proxy.GetLogger()
if proxyLogger == nil { if proxyLogger == nil {
logger.Debug("HTTP log subscription failed: logger not available", map[string]interface{}{ logger.Debug("HTTP log subscription failed: logger not available", map[string]any{
"forward_id": forwardID, "forward_id": forwardID,
}) })
return func() {} return func() {}
@@ -379,8 +379,8 @@ func main() {
} }
// Start forwards // Start forwards
if err := manager.Start(cfg); err != nil { if startErr := manager.Start(cfg); startErr != nil {
fmt.Fprintf(os.Stderr, "Error starting forwards: %v\n", err) fmt.Fprintf(os.Stderr, "Error starting forwards: %v\n", startErr)
os.Exit(1) os.Exit(1)
} }
@@ -391,17 +391,18 @@ func main() {
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM, syscall.SIGHUP) signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM, syscall.SIGHUP)
// Setup config watcher for hot-reload // Setup config watcher for hot-reload
watcher, err := config.NewWatcher(*configFile, func(newCfg *config.Config) error { watcher, watcherErr := config.NewWatcher(*configFile, func(newCfg *config.Config) error {
return manager.Reload(newCfg) return manager.Reload(newCfg)
}, *verbose) }, *verbose)
if err != nil { watcherStarted := false
if watcherErr != nil {
if *verbose { if *verbose {
log.Printf("Warning: Failed to setup config watcher: %v", err) log.Printf("Warning: Failed to setup config watcher: %v", watcherErr)
log.Printf("Hot-reload will not be available") log.Printf("Hot-reload will not be available")
} }
} else { } else {
watcher.Start() watcher.Start()
defer watcher.Stop() watcherStarted = true
} }
if *verbose { if *verbose {
@@ -416,10 +417,10 @@ func main() {
if *verbose { if *verbose {
log.Printf("Received SIGHUP, reloading configuration...") log.Printf("Received SIGHUP, reloading configuration...")
} }
newCfg, err := config.LoadConfig(*configFile) newCfg, loadErr := config.LoadConfig(*configFile)
if err != nil { if loadErr != nil {
if *verbose { if *verbose {
log.Printf("Failed to reload config: %v", err) log.Printf("Failed to reload config: %v", loadErr)
} }
continue continue
} }
@@ -432,9 +433,9 @@ func main() {
continue continue
} }
if err := manager.Reload(newCfg); err != nil { if reloadErr := manager.Reload(newCfg); reloadErr != nil {
if *verbose { if *verbose {
log.Printf("Failed to reload: %v", err) log.Printf("Failed to reload: %v", reloadErr)
} }
} }
@@ -464,6 +465,10 @@ func main() {
log.Printf("Received second signal (%v), forcing exit...", sig) log.Printf("Received second signal (%v), forcing exit...", sig)
} }
} }
// Stop the watcher before exiting (defers won't run after os.Exit)
if watcherStarted {
watcher.Stop()
}
os.Exit(0) os.Exit(0)
} }
} }
@@ -485,15 +490,16 @@ func main() {
}() }()
// Setup config watcher for hot-reload // Setup config watcher for hot-reload
watcher, err := config.NewWatcher(*configFile, func(newCfg *config.Config) error { watcher, watchErr := config.NewWatcher(*configFile, func(newCfg *config.Config) error {
return manager.Reload(newCfg) return manager.Reload(newCfg)
}, *verbose) }, *verbose)
if err != nil { watcherActive := false
log.Printf("Warning: Failed to setup config watcher: %v", err) if watchErr != nil {
log.Printf("Warning: Failed to setup config watcher: %v", watchErr)
log.Printf("Hot-reload will not be available") log.Printf("Hot-reload will not be available")
} else { } else {
watcher.Start() watcher.Start()
defer watcher.Stop() watcherActive = true
} }
log.Printf("Press Ctrl+C to stop") log.Printf("Press Ctrl+C to stop")
@@ -504,9 +510,9 @@ func main() {
switch sig { switch sig {
case syscall.SIGHUP: case syscall.SIGHUP:
log.Printf("Received SIGHUP, reloading configuration...") log.Printf("Received SIGHUP, reloading configuration...")
newCfg, err := config.LoadConfig(*configFile) newCfg, loadErr := config.LoadConfig(*configFile)
if err != nil { if loadErr != nil {
log.Printf("Failed to reload config: %v", err) log.Printf("Failed to reload config: %v", loadErr)
continue continue
} }
@@ -516,8 +522,8 @@ func main() {
continue continue
} }
if err := manager.Reload(newCfg); err != nil { if reloadErr := manager.Reload(newCfg); reloadErr != nil {
log.Printf("Failed to reload: %v", err) log.Printf("Failed to reload: %v", reloadErr)
} }
case os.Interrupt, syscall.SIGTERM: case os.Interrupt, syscall.SIGTERM:
@@ -539,6 +545,10 @@ func main() {
// Second signal received - force exit immediately // Second signal received - force exit immediately
log.Printf("Received second signal (%v), forcing exit...", sig) log.Printf("Received second signal (%v), forcing exit...", sig)
} }
// Stop the watcher before exiting (defers won't run after os.Exit)
if watcherActive {
watcher.Stop()
}
os.Exit(0) os.Exit(0)
} }
} }
+11 -11
View File
@@ -20,12 +20,12 @@ require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/cenkalti/backoff v2.2.1+incompatible // indirect github.com/cenkalti/backoff v2.2.1+incompatible // indirect
github.com/charmbracelet/colorprofile v0.4.1 // indirect github.com/charmbracelet/colorprofile v0.4.1 // indirect
github.com/charmbracelet/x/ansi v0.11.3 // indirect github.com/charmbracelet/x/ansi v0.11.4 // indirect
github.com/charmbracelet/x/cellbuf v0.0.14 // indirect github.com/charmbracelet/x/cellbuf v0.0.14 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.6.2 // indirect github.com/clipperhouse/displaywidth v0.7.0 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect github.com/clipperhouse/uax29/v2 v2.3.1 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/emicklei/go-restful/v3 v3.13.0 // indirect github.com/emicklei/go-restful/v3 v3.13.0 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
@@ -53,7 +53,7 @@ require (
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/miekg/dns v1.1.69 // indirect github.com/miekg/dns v1.1.72 // indirect
github.com/moby/spdystream v0.5.0 // indirect github.com/moby/spdystream v0.5.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
@@ -69,20 +69,20 @@ require (
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/mod v0.31.0 // indirect golang.org/x/mod v0.32.0 // indirect
golang.org/x/net v0.48.0 // indirect golang.org/x/net v0.49.0 // indirect
golang.org/x/oauth2 v0.34.0 // indirect golang.org/x/oauth2 v0.34.0 // indirect
golang.org/x/sync v0.19.0 // indirect golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect golang.org/x/sys v0.40.0 // indirect
golang.org/x/term v0.38.0 // indirect golang.org/x/term v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect golang.org/x/text v0.33.0 // indirect
golang.org/x/time v0.14.0 // indirect golang.org/x/time v0.14.0 // indirect
golang.org/x/tools v0.40.0 // indirect golang.org/x/tools v0.41.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/inf.v0 v0.9.1 // indirect
k8s.io/kube-openapi v0.0.0-20251125145642-4e65d59e963e // indirect k8s.io/kube-openapi v0.0.0-20251125145642-4e65d59e963e // indirect
k8s.io/utils v0.0.0-20251219084037-98d557b7f1e7 // indirect k8s.io/utils v0.0.0-20260108192941-914a6e750570 // indirect
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/randfill v1.0.0 // indirect
sigs.k8s.io/structured-merge-diff/v6 v6.3.1 // indirect sigs.k8s.io/structured-merge-diff/v6 v6.3.1 // indirect
+22 -22
View File
@@ -14,20 +14,20 @@ github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.11.3 h1:6DcVaqWI82BBVM/atTyq6yBoRLZFBsnoDoX9GCu2YOI= github.com/charmbracelet/x/ansi v0.11.4 h1:6G65PLu6HjmE858CnTUQY1LXT3ZUWwfvqEROLF8vqHI=
github.com/charmbracelet/x/ansi v0.11.3/go.mod h1:yI7Zslym9tCJcedxz5+WBq+eUGMJT0bM06Fqy1/Y4dI= github.com/charmbracelet/x/ansi v0.11.4/go.mod h1:/5AZ+UfWExW3int5H5ugnsG/PWjNcSQcwYsHBlPFQN4=
github.com/charmbracelet/x/cellbuf v0.0.14 h1:iUEMryGyFTelKW3THW4+FfPgi4fkmKnnaLOXuc+/Kj4= github.com/charmbracelet/x/cellbuf v0.0.14 h1:iUEMryGyFTelKW3THW4+FfPgi4fkmKnnaLOXuc+/Kj4=
github.com/charmbracelet/x/cellbuf v0.0.14/go.mod h1:P447lJl49ywBbil/KjCk2HexGh4tEY9LH0/1QrZZ9rA= github.com/charmbracelet/x/cellbuf v0.0.14/go.mod h1:P447lJl49ywBbil/KjCk2HexGh4tEY9LH0/1QrZZ9rA=
github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a h1:G99klV19u0QnhiizODirwVksQB91TJKV/UaTnACcG30= github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a h1:G99klV19u0QnhiizODirwVksQB91TJKV/UaTnACcG30=
github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/clipperhouse/displaywidth v0.6.2 h1:ZDpTkFfpHOKte4RG5O/BOyf3ysnvFswpyYrV7z2uAKo= github.com/clipperhouse/displaywidth v0.7.0 h1:QNv1GYsnLX9QBrcWUtMlogpTXuM5FVnBwKWp1O5NwmE=
github.com/clipperhouse/displaywidth v0.6.2/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o= github.com/clipperhouse/displaywidth v0.7.0/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4= github.com/clipperhouse/uax29/v2 v2.3.1 h1:RjM8gnVbFbgI67SBekIC7ihFpyXwRPYWXn9BZActHbw=
github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/clipperhouse/uax29/v2 v2.3.1/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
@@ -106,8 +106,8 @@ github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+Ei
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/miekg/dns v1.1.27/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM= github.com/miekg/dns v1.1.27/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM=
github.com/miekg/dns v1.1.69 h1:Kb7Y/1Jo+SG+a2GtfoFUfDkG//csdRPwRLkCsxDG9Sc= github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
github.com/miekg/dns v1.1.69/go.mod h1:7OyjD9nEba5OkqQ/hB4fy3PIoxafSZJtducccIelz3g= github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
github.com/moby/spdystream v0.5.0 h1:7r0J1Si3QO/kjRitvSLVVFUjxMEb/YLj6S9FF62JBCU= github.com/moby/spdystream v0.5.0 h1:7r0J1Si3QO/kjRitvSLVVFUjxMEb/YLj6S9FF62JBCU=
github.com/moby/spdystream v0.5.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI= github.com/moby/spdystream v0.5.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -159,14 +159,14 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -177,18 +177,18 @@ golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20191216052735-49a3e744a425/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20191216052735-49a3e744a425/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
@@ -211,8 +211,8 @@ k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
k8s.io/kube-openapi v0.0.0-20251125145642-4e65d59e963e h1:iW9ChlU0cU16w8MpVYjXk12dqQ4BPFBEgif+ap7/hqQ= k8s.io/kube-openapi v0.0.0-20251125145642-4e65d59e963e h1:iW9ChlU0cU16w8MpVYjXk12dqQ4BPFBEgif+ap7/hqQ=
k8s.io/kube-openapi v0.0.0-20251125145642-4e65d59e963e/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= k8s.io/kube-openapi v0.0.0-20251125145642-4e65d59e963e/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ=
k8s.io/utils v0.0.0-20251219084037-98d557b7f1e7 h1:H6xtwB5tC+KFSHoEhA1o7DnOtHDEo+n9OBSHjlajVKc= k8s.io/utils v0.0.0-20260108192941-914a6e750570 h1:JT4W8lsdrGENg9W+YwwdLJxklIuKWdRm+BC+xt33FOY=
k8s.io/utils v0.0.0-20251219084037-98d557b7f1e7/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= k8s.io/utils v0.0.0-20260108192941-914a6e750570/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk=
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg=
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=
sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU=
+17 -6
View File
@@ -1,3 +1,14 @@
// Package benchmark provides HTTP benchmarking capabilities for port forwards.
// It measures latency, throughput, and reliability of forwarded connections.
//
// The benchmark runner sends configurable numbers of concurrent requests
// and collects statistics including:
// - Latency percentiles (P50, P95, P99)
// - Request success/failure rates
// - Throughput (requests/second)
// - Status code distribution
//
// Results can be displayed in the UI or exported for analysis.
package benchmark package benchmark
import ( import (
@@ -7,17 +18,17 @@ import (
// Results holds the aggregated results of a benchmark run // Results holds the aggregated results of a benchmark run
type Results struct { type Results struct {
ForwardID string `json:"forward_id"`
URL string `json:"url"`
Method string `json:"method"`
StartTime time.Time `json:"start_time"` StartTime time.Time `json:"start_time"`
EndTime time.Time `json:"end_time"` EndTime time.Time `json:"end_time"`
StatusCodes map[int]int `json:"status_codes"`
Errors map[string]int `json:"errors,omitempty"`
Method string `json:"method"`
URL string `json:"url"`
ForwardID string `json:"forward_id"`
Latencies []time.Duration `json:"-"`
TotalRequests int `json:"total_requests"` TotalRequests int `json:"total_requests"`
Successful int `json:"successful"` Successful int `json:"successful"`
Failed int `json:"failed"` Failed int `json:"failed"`
Latencies []time.Duration `json:"-"` // Raw latencies for percentile calculation
StatusCodes map[int]int `json:"status_codes"`
Errors map[string]int `json:"errors,omitempty"`
BytesRead int64 `json:"bytes_read"` BytesRead int64 `json:"bytes_read"`
BytesWritten int64 `json:"bytes_written"` BytesWritten int64 `json:"bytes_written"`
} }
+9 -9
View File
@@ -16,15 +16,15 @@ type ProgressCallback func(completed, total int)
// Config holds the benchmark configuration // Config holds the benchmark configuration
type Config struct { type Config struct {
URL string // Target URL Headers map[string]string
Method string // HTTP method ProgressCallback ProgressCallback
Headers map[string]string // Custom headers URL string
Body []byte // Request body Method string
Concurrency int // Number of concurrent workers Body []byte
Requests int // Total number of requests (0 = use duration) Concurrency int
Duration time.Duration // Duration to run (0 = use requests) Requests int
Timeout time.Duration // Request timeout Duration time.Duration
ProgressCallback ProgressCallback // Optional callback for progress updates Timeout time.Duration
} }
// Runner executes HTTP benchmarks // Runner executes HTTP benchmarks
+3 -3
View File
@@ -106,7 +106,7 @@ func TestRunner(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(5 * time.Millisecond) // Simulate some latency time.Sleep(5 * time.Millisecond) // Simulate some latency
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status":"ok"}`)) _, _ = w.Write([]byte(`{"status":"ok"}`))
})) }))
defer server.Close() defer server.Close()
@@ -132,7 +132,7 @@ func TestRunner(t *testing.T) {
func TestRunnerWithDuration(t *testing.T) { func TestRunnerWithDuration(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
w.Write([]byte(`ok`)) _, _ = w.Write([]byte(`ok`))
})) }))
defer server.Close() defer server.Close()
@@ -210,7 +210,7 @@ func TestRunnerWithProgressCallback(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(10 * time.Millisecond) // Add small delay so progress ticker can fire time.Sleep(10 * time.Millisecond) // Add small delay so progress ticker can fire
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
w.Write([]byte(`ok`)) _, _ = w.Write([]byte(`ok`))
})) }))
defer server.Close() defer server.Close()
+38 -19
View File
@@ -1,3 +1,24 @@
// Package config provides configuration loading, validation, watching, and
// mutation for kportal. It handles parsing the .kportal.yaml configuration
// file and provides hot-reload support via file watching.
//
// The configuration structure supports multiple Kubernetes contexts, each
// with namespaces containing port-forward definitions. Additional settings
// for health checks, reliability, and mDNS hostname publishing are also
// supported.
//
// Basic usage:
//
// cfg, err := config.Load("~/.kportal.yaml")
// if err != nil {
// log.Fatal(err)
// }
//
// For hot-reload support, use the ConfigWatcher:
//
// watcher, err := config.NewConfigWatcher(path, func(cfg *config.Config) {
// // Handle configuration changes
// })
package config package config
import ( import (
@@ -36,10 +57,10 @@ const (
// Config represents the root configuration structure from .kportal.yaml // Config represents the root configuration structure from .kportal.yaml
type Config struct { type Config struct {
Contexts []Context `yaml:"contexts"`
HealthCheck *HealthCheckSpec `yaml:"healthCheck,omitempty"` HealthCheck *HealthCheckSpec `yaml:"healthCheck,omitempty"`
Reliability *ReliabilitySpec `yaml:"reliability,omitempty"` Reliability *ReliabilitySpec `yaml:"reliability,omitempty"`
MDNS *MDNSSpec `yaml:"mdns,omitempty"` MDNS *MDNSSpec `yaml:"mdns,omitempty"`
Contexts []Context `yaml:"contexts"`
} }
// MDNSSpec configures mDNS (multicast DNS) hostname publishing // MDNSSpec configures mDNS (multicast DNS) hostname publishing
@@ -59,10 +80,10 @@ type HealthCheckSpec struct {
// ReliabilitySpec configures connection reliability features // ReliabilitySpec configures connection reliability features
type ReliabilitySpec struct { type ReliabilitySpec struct {
TCPKeepalive string `yaml:"tcpKeepalive,omitempty"` // e.g., "30s" - OS-level keepalive TCPKeepalive string `yaml:"tcpKeepalive,omitempty"`
DialTimeout string `yaml:"dialTimeout,omitempty"` // e.g., "30s" - connection dial timeout DialTimeout string `yaml:"dialTimeout,omitempty"`
RetryOnStale bool `yaml:"retryOnStale,omitempty"` // Auto-reconnect on stale detection WatchdogPeriod string `yaml:"watchdogPeriod,omitempty"`
WatchdogPeriod string `yaml:"watchdogPeriod,omitempty"` // e.g., "30s" - goroutine watchdog interval RetryOnStale bool `yaml:"retryOnStale,omitempty"`
} }
// parseDurationOrDefault parses a duration string and returns the default if empty or invalid. // parseDurationOrDefault parses a duration string and returns the default if empty or invalid.
@@ -167,11 +188,11 @@ type Namespace struct {
// HTTPLogSpec configures HTTP traffic logging for a forward // HTTPLogSpec configures HTTP traffic logging for a forward
type HTTPLogSpec struct { type HTTPLogSpec struct {
Enabled bool `yaml:"enabled"` // Enable HTTP logging LogFile string `yaml:"logFile,omitempty"`
LogFile string `yaml:"logFile,omitempty"` // Output file (empty = stdout) FilterPath string `yaml:"filterPath,omitempty"`
MaxBodySize int `yaml:"maxBodySize,omitempty"` // Max body size to log (default 1MB) MaxBodySize int `yaml:"maxBodySize,omitempty"`
IncludeHeaders bool `yaml:"includeHeaders,omitempty"` // Include headers in log Enabled bool `yaml:"enabled"`
FilterPath string `yaml:"filterPath,omitempty"` // Optional glob filter for paths IncludeHeaders bool `yaml:"includeHeaders,omitempty"`
} }
// UnmarshalYAML implements custom unmarshaling to support both bool and struct formats // UnmarshalYAML implements custom unmarshaling to support both bool and struct formats
@@ -196,17 +217,15 @@ func (h *HTTPLogSpec) UnmarshalYAML(unmarshal func(interface{}) error) error {
// Forward represents a single port-forward configuration // Forward represents a single port-forward configuration
type Forward struct { type Forward struct {
Resource string `yaml:"resource"` // e.g., "pod/my-app", "service/postgres", "pod" HTTPLog *HTTPLogSpec `yaml:"httpLog,omitempty"`
Selector string `yaml:"selector"` // Label selector for pod resolution (e.g., "app=nginx,env=prod") Resource string `yaml:"resource"`
Protocol string `yaml:"protocol"` // tcp or udp Selector string `yaml:"selector"`
Port int `yaml:"port"` // Remote port Protocol string `yaml:"protocol"`
LocalPort int `yaml:"localPort"` // Local port Alias string `yaml:"alias,omitempty"`
Alias string `yaml:"alias,omitempty"` // Optional human-readable alias for this forward
HTTPLog *HTTPLogSpec `yaml:"httpLog,omitempty"` // Optional HTTP traffic logging
// Runtime fields (not in YAML)
contextName string contextName string
namespaceName string namespaceName string
Port int `yaml:"port"`
LocalPort int `yaml:"localPort"`
} }
// ID returns a unique identifier for this forward configuration. // ID returns a unique identifier for this forward configuration.
+12 -12
View File
@@ -40,8 +40,8 @@ func TestParseDurationOrDefault(t *testing.T) {
// TestConfig_GetHealthCheckIntervalOrDefault tests health check interval getter // TestConfig_GetHealthCheckIntervalOrDefault tests health check interval getter
func TestConfig_GetHealthCheckIntervalOrDefault(t *testing.T) { func TestConfig_GetHealthCheckIntervalOrDefault(t *testing.T) {
tests := []struct { tests := []struct {
name string
config *Config config *Config
name string
expected time.Duration expected time.Duration
}{ }{
{ {
@@ -83,8 +83,8 @@ func TestConfig_GetHealthCheckIntervalOrDefault(t *testing.T) {
// TestConfig_GetHealthCheckTimeoutOrDefault tests health check timeout getter // TestConfig_GetHealthCheckTimeoutOrDefault tests health check timeout getter
func TestConfig_GetHealthCheckTimeoutOrDefault(t *testing.T) { func TestConfig_GetHealthCheckTimeoutOrDefault(t *testing.T) {
tests := []struct { tests := []struct {
name string
config *Config config *Config
name string
expected time.Duration expected time.Duration
}{ }{
{ {
@@ -162,8 +162,8 @@ func TestConfig_GetHealthCheckMethod(t *testing.T) {
// TestConfig_GetMaxConnectionAge tests max connection age getter // TestConfig_GetMaxConnectionAge tests max connection age getter
func TestConfig_GetMaxConnectionAge(t *testing.T) { func TestConfig_GetMaxConnectionAge(t *testing.T) {
tests := []struct { tests := []struct {
name string
config *Config config *Config
name string
expected time.Duration expected time.Duration
}{ }{
{ {
@@ -198,8 +198,8 @@ func TestConfig_GetMaxConnectionAge(t *testing.T) {
// TestConfig_GetMaxIdleTime tests max idle time getter // TestConfig_GetMaxIdleTime tests max idle time getter
func TestConfig_GetMaxIdleTime(t *testing.T) { func TestConfig_GetMaxIdleTime(t *testing.T) {
tests := []struct { tests := []struct {
name string
config *Config config *Config
name string
expected time.Duration expected time.Duration
}{ }{
{ {
@@ -234,8 +234,8 @@ func TestConfig_GetMaxIdleTime(t *testing.T) {
// TestConfig_GetTCPKeepalive tests TCP keepalive getter // TestConfig_GetTCPKeepalive tests TCP keepalive getter
func TestConfig_GetTCPKeepalive(t *testing.T) { func TestConfig_GetTCPKeepalive(t *testing.T) {
tests := []struct { tests := []struct {
name string
config *Config config *Config
name string
expected time.Duration expected time.Duration
}{ }{
{ {
@@ -270,8 +270,8 @@ func TestConfig_GetTCPKeepalive(t *testing.T) {
// TestConfig_GetRetryOnStale tests retry on stale getter // TestConfig_GetRetryOnStale tests retry on stale getter
func TestConfig_GetRetryOnStale(t *testing.T) { func TestConfig_GetRetryOnStale(t *testing.T) {
tests := []struct { tests := []struct {
name string
config *Config config *Config
name string
expected bool expected bool
}{ }{
{ {
@@ -306,8 +306,8 @@ func TestConfig_GetRetryOnStale(t *testing.T) {
// TestConfig_GetWatchdogPeriod tests watchdog period getter // TestConfig_GetWatchdogPeriod tests watchdog period getter
func TestConfig_GetWatchdogPeriod(t *testing.T) { func TestConfig_GetWatchdogPeriod(t *testing.T) {
tests := []struct { tests := []struct {
name string
config *Config config *Config
name string
expected time.Duration expected time.Duration
}{ }{
{ {
@@ -342,8 +342,8 @@ func TestConfig_GetWatchdogPeriod(t *testing.T) {
// TestConfig_GetDialTimeout tests dial timeout getter // TestConfig_GetDialTimeout tests dial timeout getter
func TestConfig_GetDialTimeout(t *testing.T) { func TestConfig_GetDialTimeout(t *testing.T) {
tests := []struct { tests := []struct {
name string
config *Config config *Config
name string
expected time.Duration expected time.Duration
}{ }{
{ {
@@ -378,8 +378,8 @@ func TestConfig_GetDialTimeout(t *testing.T) {
// TestConfig_IsMDNSEnabled tests mDNS enabled getter // TestConfig_IsMDNSEnabled tests mDNS enabled getter
func TestConfig_IsMDNSEnabled(t *testing.T) { func TestConfig_IsMDNSEnabled(t *testing.T) {
tests := []struct { tests := []struct {
name string
config *Config config *Config
name string
expected bool expected bool
}{ }{
{ {
@@ -509,8 +509,8 @@ func TestForward_GetHTTPLogMaxBodySize(t *testing.T) {
func TestForward_GetMDNSAlias(t *testing.T) { func TestForward_GetMDNSAlias(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
forward Forward
expected string expected string
forward Forward
}{ }{
{ {
name: "explicit alias", name: "explicit alias",
@@ -591,7 +591,7 @@ func TestLoadConfig_FileTooLarge(t *testing.T) {
largeData[i] = 'a' largeData[i] = 'a'
} }
err := os.WriteFile(configPath, largeData, 0644) err := os.WriteFile(configPath, largeData, 0600)
require.NoError(t, err) require.NoError(t, err)
cfg, err := LoadConfig(configPath) cfg, err := LoadConfig(configPath)
@@ -628,7 +628,7 @@ mdns:
enabled: true enabled: true
` `
err := os.WriteFile(configPath, []byte(yaml), 0644) err := os.WriteFile(configPath, []byte(yaml), 0600)
require.NoError(t, err) require.NoError(t, err)
cfg, err := LoadConfig(configPath) cfg, err := LoadConfig(configPath)
+7 -9
View File
@@ -39,7 +39,7 @@ func TestLoadConfig_ValidYAML(t *testing.T) {
localPort: 8081 localPort: 8081
` `
err := os.WriteFile(configPath, []byte(validYAML), 0644) err := os.WriteFile(configPath, []byte(validYAML), 0600)
assert.NoError(t, err, "should write temp config file") assert.NoError(t, err, "should write temp config file")
// Load the config // Load the config
@@ -82,7 +82,7 @@ func TestLoadConfig_InvalidYAML(t *testing.T) {
forwards: [this is invalid yaml syntax forwards: [this is invalid yaml syntax
` `
err := os.WriteFile(configPath, []byte(invalidYAML), 0644) err := os.WriteFile(configPath, []byte(invalidYAML), 0600)
assert.NoError(t, err, "should write temp config file") assert.NoError(t, err, "should write temp config file")
// Load the config // Load the config
@@ -103,8 +103,8 @@ func TestLoadConfig_FileNotFound(t *testing.T) {
func TestForward_ID(t *testing.T) { func TestForward_ID(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
forward Forward
expectedID string expectedID string
forward Forward
}{ }{
{ {
name: "pod with explicit name", name: "pod with explicit name",
@@ -165,8 +165,8 @@ func TestForward_ID(t *testing.T) {
func TestForward_String(t *testing.T) { func TestForward_String(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
forward Forward
expectedString string expectedString string
forward Forward
}{ }{
{ {
name: "pod without selector", name: "pod without selector",
@@ -389,11 +389,9 @@ func TestHTTPLogSpec_UnmarshalYAML(t *testing.T) {
if tt.expected { if tt.expected {
assert.NotNil(t, fwd.HTTPLog, "HTTPLog should not be nil") assert.NotNil(t, fwd.HTTPLog, "HTTPLog should not be nil")
assert.True(t, fwd.HTTPLog.Enabled, "HTTPLog.Enabled should be true") assert.True(t, fwd.HTTPLog.Enabled, "HTTPLog.Enabled should be true")
} else { } else if fwd.HTTPLog != nil {
if fwd.HTTPLog != nil {
assert.False(t, fwd.HTTPLog.Enabled, "HTTPLog.Enabled should be false") assert.False(t, fwd.HTTPLog.Enabled, "HTTPLog.Enabled should be false")
} }
}
}) })
} }
} }
@@ -407,8 +405,8 @@ func TestNewEmptyConfig(t *testing.T) {
func TestConfig_IsEmpty(t *testing.T) { func TestConfig_IsEmpty(t *testing.T) {
tests := []struct { tests := []struct {
name string
config *Config config *Config
name string
expected bool expected bool
}{ }{
{ {
@@ -505,7 +503,7 @@ func TestCreateEmptyConfigFile_AlreadyExists(t *testing.T) {
configPath := filepath.Join(tmpDir, ".kportal.yaml") configPath := filepath.Join(tmpDir, ".kportal.yaml")
// Create existing file // Create existing file
err := os.WriteFile(configPath, []byte("existing content"), 0644) err := os.WriteFile(configPath, []byte("existing content"), 0600)
assert.NoError(t, err) assert.NoError(t, err)
// Try to create config file - should fail // Try to create config file - should fail
+1 -1
View File
@@ -648,7 +648,7 @@ func TestMutator_Concurrent(t *testing.T) {
} }
// Some will succeed, some will fail due to validation // Some will succeed, some will fail due to validation
// The important thing is no race condition // The important thing is no race condition
mutator.AddForward("dev", "default", fwd) _ = mutator.AddForward("dev", "default", fwd)
}(i) }(i)
} }
+4 -6
View File
@@ -17,9 +17,9 @@ func IsValidPort(port int) bool {
// ValidationError represents a configuration validation error with context. // ValidationError represents a configuration validation error with context.
type ValidationError struct { type ValidationError struct {
Field string // The field that failed validation Context map[string]string
Message string // Error message Field string
Context map[string]string // Additional context information Message string
} }
// Validator validates configuration files. // Validator validates configuration files.
@@ -199,16 +199,14 @@ func (v *Validator) validateResource(fwd *Forward) []ValidationError {
Message: fmt.Sprintf("Pod name cannot be empty for forward %s", fwd.ID()), Message: fmt.Sprintf("Pod name cannot be empty for forward %s", fwd.ID()),
}) })
} }
} else { } else if fwd.Selector == "" {
// pod (no name) - must have selector // pod (no name) - must have selector
if fwd.Selector == "" {
errs = append(errs, ValidationError{ errs = append(errs, ValidationError{
Field: "selector", Field: "selector",
Message: fmt.Sprintf("Forward %s uses generic 'pod' resource and must have a selector", fwd.ID()), Message: fmt.Sprintf("Forward %s uses generic 'pod' resource and must have a selector", fwd.ID()),
}) })
} }
} }
}
// For service resources // For service resources
if resourceType == "service" { if resourceType == "service" {
+11 -11
View File
@@ -11,10 +11,10 @@ func TestValidator_ValidateConfig(t *testing.T) {
validator := NewValidator() validator := NewValidator()
tests := []struct { tests := []struct {
name string
config *Config config *Config
expectErrors bool name string
errorContains []string errorContains []string
expectErrors bool
}{ }{
{ {
name: "valid config", name: "valid config",
@@ -227,9 +227,9 @@ func TestValidator_ValidateResourceFormat(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
errorContains []string
forward Forward forward Forward
expectErrors bool expectErrors bool
errorContains []string
}{ }{
{ {
name: "valid pod with name", name: "valid pod with name",
@@ -370,10 +370,10 @@ func TestValidator_CheckDuplicatePorts(t *testing.T) {
validator := NewValidator() validator := NewValidator()
tests := []struct { tests := []struct {
name string
config *Config config *Config
expectErrors bool name string
errorContains []string errorContains []string
expectErrors bool
}{ }{
{ {
name: "no duplicate ports", name: "no duplicate ports",
@@ -552,8 +552,8 @@ func TestFormatValidationErrors(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
errors []ValidationError errors []ValidationError
expectEmpty bool
expectContains []string expectContains []string
expectEmpty bool
}{ }{
{ {
name: "no errors", name: "no errors",
@@ -625,10 +625,10 @@ func TestValidator_ValidateStructure(t *testing.T) {
validator := NewValidator() validator := NewValidator()
tests := []struct { tests := []struct {
name string
config *Config config *Config
expectErrors bool name string
errorContains []string errorContains []string
expectErrors bool
}{ }{
{ {
name: "empty context name", name: "empty context name",
@@ -697,10 +697,10 @@ func TestValidator_ValidateMDNS(t *testing.T) {
validator := NewValidator() validator := NewValidator()
tests := []struct { tests := []struct {
name string
config *Config config *Config
expectErrors bool name string
errorContains []string errorContains []string
expectErrors bool
}{ }{
{ {
name: "mDNS disabled - no validation", name: "mDNS disabled - no validation",
@@ -968,8 +968,8 @@ func TestValidator_ValidateConfigWithOptions(t *testing.T) {
validator := NewValidator() validator := NewValidator()
tests := []struct { tests := []struct {
name string
config *Config config *Config
name string
allowEmpty bool allowEmpty bool
expectErrors bool expectErrors bool
}{ }{
+6 -6
View File
@@ -16,13 +16,13 @@ type ReloadCallback func(*Config) error
// Watcher watches a configuration file for changes and triggers hot-reload. // Watcher watches a configuration file for changes and triggers hot-reload.
type Watcher struct { type Watcher struct {
configPath string
callback ReloadCallback callback ReloadCallback
watcher *fsnotify.Watcher watcher *fsnotify.Watcher
done chan struct{} done chan struct{}
configPath string
wg sync.WaitGroup
stopOnce sync.Once
verbose bool verbose bool
wg sync.WaitGroup // Ensures watch goroutine exits before Stop returns
stopOnce sync.Once // Ensures Stop is safe to call multiple times
} }
// NewWatcher creates a new file watcher for the given config file. // NewWatcher creates a new file watcher for the given config file.
@@ -34,7 +34,7 @@ func NewWatcher(configPath string, callback ReloadCallback, verbose bool) (*Watc
absPath, err := filepath.Abs(configPath) absPath, err := filepath.Abs(configPath)
if err != nil { if err != nil {
_ = watcher.Close() _ = watcher.Close() // Cleanup on error path; already returning error
return nil, fmt.Errorf("failed to resolve absolute path: %w", err) return nil, fmt.Errorf("failed to resolve absolute path: %w", err)
} }
@@ -42,7 +42,7 @@ func NewWatcher(configPath string, callback ReloadCallback, verbose bool) (*Watc
// (many editors delete and recreate files on save) // (many editors delete and recreate files on save)
dir := filepath.Dir(absPath) dir := filepath.Dir(absPath)
if err := watcher.Add(dir); err != nil { if err := watcher.Add(dir); err != nil {
_ = watcher.Close() _ = watcher.Close() // Cleanup on error path; already returning error
return nil, fmt.Errorf("failed to watch directory %s: %w", dir, err) return nil, fmt.Errorf("failed to watch directory %s: %w", dir, err)
} }
@@ -66,7 +66,7 @@ func (w *Watcher) Start() {
func (w *Watcher) Stop() { func (w *Watcher) Stop() {
w.stopOnce.Do(func() { w.stopOnce.Do(func() {
close(w.done) close(w.done)
_ = w.watcher.Close() _ = w.watcher.Close() // Best-effort cleanup during shutdown
}) })
w.wg.Wait() // Wait for watch goroutine to exit w.wg.Wait() // Wait for watch goroutine to exit
} }
+20 -18
View File
@@ -27,7 +27,7 @@ func TestNewWatcher(t *testing.T) {
port: 8080 port: 8080
localPort: 8080 localPort: 8080
` `
err := os.WriteFile(configPath, []byte(initial), 0644) err := os.WriteFile(configPath, []byte(initial), 0600)
require.NoError(t, err) require.NoError(t, err)
callback := func(cfg *Config) error { return nil } callback := func(cfg *Config) error { return nil }
@@ -57,7 +57,7 @@ func TestNewWatcher_Verbose(t *testing.T) {
port: 8080 port: 8080
localPort: 8080 localPort: 8080
` `
err := os.WriteFile(configPath, []byte(initial), 0644) err := os.WriteFile(configPath, []byte(initial), 0600)
require.NoError(t, err) require.NoError(t, err)
callback := func(cfg *Config) error { return nil } callback := func(cfg *Config) error { return nil }
@@ -85,13 +85,15 @@ func TestNewWatcher_RelativePath(t *testing.T) {
port: 8080 port: 8080
localPort: 8080 localPort: 8080
` `
err := os.WriteFile(configPath, []byte(initial), 0644) err := os.WriteFile(configPath, []byte(initial), 0600)
require.NoError(t, err) require.NoError(t, err)
// Change to tmpDir and use relative path // Change to tmpDir and use relative path
originalDir, _ := os.Getwd() originalDir, err := os.Getwd()
defer os.Chdir(originalDir) require.NoError(t, err)
os.Chdir(tmpDir) defer func() { _ = os.Chdir(originalDir) }()
err = os.Chdir(tmpDir)
require.NoError(t, err)
callback := func(cfg *Config) error { return nil } callback := func(cfg *Config) error { return nil }
@@ -119,7 +121,7 @@ func TestWatcher_StartStop(t *testing.T) {
port: 8080 port: 8080
localPort: 8080 localPort: 8080
` `
err := os.WriteFile(configPath, []byte(initial), 0644) err := os.WriteFile(configPath, []byte(initial), 0600)
require.NoError(t, err) require.NoError(t, err)
callback := func(cfg *Config) error { return nil } callback := func(cfg *Config) error { return nil }
@@ -161,7 +163,7 @@ func TestWatcher_DetectsFileChange(t *testing.T) {
port: 8080 port: 8080
localPort: 8080 localPort: 8080
` `
err := os.WriteFile(configPath, []byte(initial), 0644) err := os.WriteFile(configPath, []byte(initial), 0600)
require.NoError(t, err) require.NoError(t, err)
var mu sync.Mutex var mu sync.Mutex
@@ -199,7 +201,7 @@ func TestWatcher_DetectsFileChange(t *testing.T) {
port: 9090 port: 9090
localPort: 9090 localPort: 9090
` `
err = os.WriteFile(configPath, []byte(updated), 0644) err = os.WriteFile(configPath, []byte(updated), 0600)
require.NoError(t, err) require.NoError(t, err)
// Wait for callback with timeout // Wait for callback with timeout
@@ -239,7 +241,7 @@ func TestWatcher_IgnoresInvalidConfig(t *testing.T) {
port: 8080 port: 8080
localPort: 8080 localPort: 8080
` `
err := os.WriteFile(configPath, []byte(initial), 0644) err := os.WriteFile(configPath, []byte(initial), 0600)
require.NoError(t, err) require.NoError(t, err)
callbackCount := 0 callbackCount := 0
@@ -267,7 +269,7 @@ func TestWatcher_IgnoresInvalidConfig(t *testing.T) {
- name: default - name: default
forwards: [this is invalid forwards: [this is invalid
` `
err = os.WriteFile(configPath, []byte(invalid), 0644) err = os.WriteFile(configPath, []byte(invalid), 0600)
require.NoError(t, err) require.NoError(t, err)
// Wait a bit // Wait a bit
@@ -294,7 +296,7 @@ func TestWatcher_IgnoresValidationErrors(t *testing.T) {
port: 8080 port: 8080
localPort: 8080 localPort: 8080
` `
err := os.WriteFile(configPath, []byte(initial), 0644) err := os.WriteFile(configPath, []byte(initial), 0600)
require.NoError(t, err) require.NoError(t, err)
callbackCount := 0 callbackCount := 0
@@ -328,7 +330,7 @@ func TestWatcher_IgnoresValidationErrors(t *testing.T) {
port: 9090 port: 9090
localPort: 8080 localPort: 8080
` `
err = os.WriteFile(configPath, []byte(invalid), 0644) err = os.WriteFile(configPath, []byte(invalid), 0600)
require.NoError(t, err) require.NoError(t, err)
// Wait a bit // Wait a bit
@@ -356,7 +358,7 @@ func TestWatcher_IgnoresOtherFiles(t *testing.T) {
port: 8080 port: 8080
localPort: 8080 localPort: 8080
` `
err := os.WriteFile(configPath, []byte(initial), 0644) err := os.WriteFile(configPath, []byte(initial), 0600)
require.NoError(t, err) require.NoError(t, err)
callbackCount := 0 callbackCount := 0
@@ -378,7 +380,7 @@ func TestWatcher_IgnoresOtherFiles(t *testing.T) {
time.Sleep(100 * time.Millisecond) time.Sleep(100 * time.Millisecond)
// Write to a different file // Write to a different file
err = os.WriteFile(otherPath, []byte("some content"), 0644) err = os.WriteFile(otherPath, []byte("some content"), 0600)
require.NoError(t, err) require.NoError(t, err)
// Wait a bit // Wait a bit
@@ -405,7 +407,7 @@ func TestWatcher_HandleReload_LoadError(t *testing.T) {
port: 8080 port: 8080
localPort: 8080 localPort: 8080
` `
err := os.WriteFile(configPath, []byte(initial), 0644) err := os.WriteFile(configPath, []byte(initial), 0600)
require.NoError(t, err) require.NoError(t, err)
callbackCalled := false callbackCalled := false
@@ -445,7 +447,7 @@ func TestWatcher_DoubleStop(t *testing.T) {
port: 8080 port: 8080
localPort: 8080 localPort: 8080
` `
err := os.WriteFile(configPath, []byte(initial), 0644) err := os.WriteFile(configPath, []byte(initial), 0600)
require.NoError(t, err) require.NoError(t, err)
callback := func(cfg *Config) error { return nil } callback := func(cfg *Config) error { return nil }
@@ -479,7 +481,7 @@ func TestWatcher_StopWithoutStart(t *testing.T) {
port: 8080 port: 8080
localPort: 8080 localPort: 8080
` `
err := os.WriteFile(configPath, []byte(initial), 0644) err := os.WriteFile(configPath, []byte(initial), 0600)
require.NoError(t, err) require.NoError(t, err)
callback := func(cfg *Config) error { return nil } callback := func(cfg *Config) error { return nil }
+14 -5
View File
@@ -1,3 +1,12 @@
// Package converter provides configuration migration from other port-forwarding
// tools to kportal's YAML format. Currently supports kftray JSON format.
//
// Basic usage:
//
// err := converter.ConvertKFTrayToKPortal("kftray.json", ".kportal.yaml")
// if err != nil {
// log.Fatal(err)
// }
package converter package converter
import ( import (
@@ -14,12 +23,12 @@ import (
type KFTrayConfig struct { type KFTrayConfig struct {
Service string `json:"service"` Service string `json:"service"`
Namespace string `json:"namespace"` Namespace string `json:"namespace"`
LocalPort int `json:"local_port"`
RemotePort int `json:"remote_port"`
Context string `json:"context"` Context string `json:"context"`
WorkloadType string `json:"workload_type"` WorkloadType string `json:"workload_type"`
Protocol string `json:"protocol"` Protocol string `json:"protocol"`
Alias string `json:"alias"` Alias string `json:"alias"`
LocalPort int `json:"local_port"`
RemotePort int `json:"remote_port"`
} }
// ConvertKFTrayToKPortal converts kftray JSON configuration to kportal YAML format // ConvertKFTrayToKPortal converts kftray JSON configuration to kportal YAML format
@@ -32,8 +41,8 @@ func ConvertKFTrayToKPortal(inputFile, outputFile string) error {
} }
var kftrayConfigs []KFTrayConfig var kftrayConfigs []KFTrayConfig
if err := json.Unmarshal(data, &kftrayConfigs); err != nil { if unmarshalErr := json.Unmarshal(data, &kftrayConfigs); unmarshalErr != nil {
return fmt.Errorf("failed to parse JSON: %w", err) return fmt.Errorf("failed to parse JSON: %w", unmarshalErr)
} }
// Convert to kportal format // Convert to kportal format
@@ -169,9 +178,9 @@ type namespaceEntry struct {
type forwardEntry struct { type forwardEntry struct {
Resource string `yaml:"resource"` Resource string `yaml:"resource"`
Protocol string `yaml:"protocol"` Protocol string `yaml:"protocol"`
Alias string `yaml:"alias,omitempty"`
Port int `yaml:"port"` Port int `yaml:"port"`
LocalPort int `yaml:"localPort"` LocalPort int `yaml:"localPort"`
Alias string `yaml:"alias,omitempty"`
} }
// Convert internal types to config package types // Convert internal types to config package types
+20 -2
View File
@@ -1,3 +1,21 @@
// Package events provides a publish-subscribe event bus for decoupled
// communication between kportal components. Events are typed and carry
// contextual data about forward lifecycle, health status, and configuration
// changes.
//
// Event types include:
// - Forward lifecycle: starting, connected, disconnected, reconnecting, stopped, error
// - Health: status_changed, stale
// - Watchdog: worker_hung
// - Config: reloaded
//
// Basic usage:
//
// bus := events.NewBus()
// bus.Subscribe(events.EventForwardConnected, func(e events.Event) {
// fmt.Printf("Forward %s connected\n", e.ForwardID)
// })
// bus.Publish(events.Event{Type: events.EventForwardConnected, ForwardID: "..."})
package events package events
import ( import (
@@ -29,9 +47,9 @@ const (
// Event represents a system event // Event represents a system event
type Event struct { type Event struct {
Data map[string]interface{}
Type EventType Type EventType
ForwardID string ForwardID string
Data map[string]interface{}
} }
// Handler is a function that handles events // Handler is a function that handles events
@@ -39,8 +57,8 @@ type Handler func(event Event)
// Bus is a simple event bus for decoupled communication between components // Bus is a simple event bus for decoupled communication between components
type Bus struct { type Bus struct {
mu sync.RWMutex
handlers map[EventType][]Handler handlers map[EventType][]Handler
mu sync.RWMutex
closed bool closed bool
} }
+22 -8
View File
@@ -1,3 +1,17 @@
// Package forward provides the core port-forwarding orchestration for kportal.
// It manages the lifecycle of port-forward workers, handles hot-reload of
// configuration changes, and coordinates with the health checker and watchdog.
//
// The Manager is the central orchestrator that:
// - Creates and manages ForwardWorker instances for each configured forward
// - Handles graceful startup, shutdown, and reconfiguration
// - Coordinates with the HealthChecker for connection monitoring
// - Integrates with mDNS for hostname publishing
//
// ForwardWorker handles individual port-forward connections with:
// - Automatic retry with exponential backoff (1s → 2s → 4s → 8s → 10s max)
// - Pod restart detection and re-resolution
// - Graceful shutdown support
package forward package forward
import ( import (
@@ -24,19 +38,19 @@ type StatusUpdater interface {
// Manager orchestrates all port-forward workers. // Manager orchestrates all port-forward workers.
// It handles starting, stopping, and hot-reloading forwards. // It handles starting, stopping, and hot-reloading forwards.
type Manager struct { type Manager struct {
workers map[string]*ForwardWorker // key: forward.ID() statusUI StatusUpdater
workersMu sync.RWMutex healthChecker *healthcheck.Checker
clientPool *k8s.ClientPool clientPool *k8s.ClientPool
resolver *k8s.ResourceResolver resolver *k8s.ResourceResolver
portForwarder *k8s.PortForwarder portForwarder *k8s.PortForwarder
portChecker *PortChecker portChecker *PortChecker
healthChecker *healthcheck.Checker workers map[string]*ForwardWorker
watchdog *Watchdog watchdog *Watchdog
mdnsPublisher *mdns.Publisher mdnsPublisher *mdns.Publisher
eventBus *events.Bus // Event bus for decoupled communication eventBus *events.Bus
verbose bool
currentConfig *config.Config currentConfig *config.Config
statusUI StatusUpdater workersMu sync.RWMutex
verbose bool
} }
// NewManager creates a new forward Manager. // NewManager creates a new forward Manager.
@@ -414,11 +428,11 @@ func (m *Manager) startWorker(fwd config.Forward) error {
// Find and notify the worker to reconnect // Find and notify the worker to reconnect
m.workersMu.RLock() m.workersMu.RLock()
worker, exists := m.workers[forwardID] staleWorker, exists := m.workers[forwardID]
m.workersMu.RUnlock() m.workersMu.RUnlock()
if exists { if exists {
worker.TriggerReconnect("stale connection") staleWorker.TriggerReconnect("stale connection")
} }
} }
}) })
+1 -1
View File
@@ -185,8 +185,8 @@ type StatusUpdate struct {
} }
type ForwardAdd struct { type ForwardAdd struct {
ID string
Fwd *config.Forward Fwd *config.Forward
ID string
} }
type ErrorSet struct { type ErrorSet struct {
+4 -4
View File
@@ -99,9 +99,9 @@ func getProcessNameByPIDWindows(pid string) string {
// PortConflict represents a local port that is already in use. // PortConflict represents a local port that is already in use.
type PortConflict struct { type PortConflict struct {
Port int // The conflicting port number Resource string
Resource string // The forward resource that needs this port UsedBy string
UsedBy string // Process information (PID, command) using the port Port int
} }
// PortChecker checks port availability on the local system. // PortChecker checks port availability on the local system.
@@ -146,7 +146,7 @@ func (pc *PortChecker) isPortAvailable(port int) bool {
if err != nil { if err != nil {
return false return false
} }
_ = listener.Close() _ = listener.Close() // Best-effort cleanup; port check succeeded, Close error is non-critical
return true return true
} }
+9 -5
View File
@@ -40,8 +40,8 @@ func TestIsValidPID(t *testing.T) {
func TestFormatProcessInfo(t *testing.T) { func TestFormatProcessInfo(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
info processInfo
expected string expected string
info processInfo
}{ }{
{ {
name: "invalid process", name: "invalid process",
@@ -72,8 +72,8 @@ func TestFormatProcessInfo(t *testing.T) {
func TestFormatProcessList(t *testing.T) { func TestFormatProcessList(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
processes []processInfo
expected string expected string
processes []processInfo
}{ }{
{ {
name: "empty list", name: "empty list",
@@ -206,7 +206,8 @@ func TestPortChecker_CheckAvailability_EmptyPorts(t *testing.T) {
func TestPortChecker_CheckAvailability_ExcludeMap(t *testing.T) { func TestPortChecker_CheckAvailability_ExcludeMap(t *testing.T) {
pc := NewPortChecker() pc := NewPortChecker()
// Create a listener to occupy a port // Create a listener to occupy a port on all interfaces (matching production behavior)
// #nosec G102 -- test intentionally binds to all interfaces to match production port checking
listener, err := net.Listen("tcp", ":0") listener, err := net.Listen("tcp", ":0")
assert.NoError(t, err, "should create listener") assert.NoError(t, err, "should create listener")
defer listener.Close() defer listener.Close()
@@ -231,11 +232,13 @@ func TestPortChecker_CheckAvailability_ExcludeMap(t *testing.T) {
func TestPortChecker_CheckAvailability_MultipleSkipPorts(t *testing.T) { func TestPortChecker_CheckAvailability_MultipleSkipPorts(t *testing.T) {
pc := NewPortChecker() pc := NewPortChecker()
// Create multiple listeners // Create multiple listeners on all interfaces (matching production behavior)
// #nosec G102 -- test intentionally binds to all interfaces to match production port checking
listener1, err := net.Listen("tcp", ":0") listener1, err := net.Listen("tcp", ":0")
assert.NoError(t, err) assert.NoError(t, err)
defer listener1.Close() defer listener1.Close()
// #nosec G102 -- test intentionally binds to all interfaces to match production port checking
listener2, err := net.Listen("tcp", ":0") listener2, err := net.Listen("tcp", ":0")
assert.NoError(t, err) assert.NoError(t, err)
defer listener2.Close() defer listener2.Close()
@@ -353,7 +356,8 @@ func TestNewPortChecker(t *testing.T) {
func TestPortChecker_PortAvailability_Integration(t *testing.T) { func TestPortChecker_PortAvailability_Integration(t *testing.T) {
pc := NewPortChecker() pc := NewPortChecker()
// Create a listener to occupy a port // Create a listener to occupy a port on all interfaces (matching production behavior)
// #nosec G102 -- test intentionally binds to all interfaces to match production port checking
listener, err := net.Listen("tcp", ":0") listener, err := net.Listen("tcp", ":0")
assert.NoError(t, err, "should create listener") assert.NoError(t, err, "should create listener")
defer listener.Close() defer listener.Close()
+10 -10
View File
@@ -19,25 +19,25 @@ const (
// the watchdog polls workers periodically. This reduces goroutine count and // the watchdog polls workers periodically. This reduces goroutine count and
// simplifies worker implementation. // simplifies worker implementation.
type Watchdog struct { type Watchdog struct {
mu sync.RWMutex
workers map[string]*workerState // key: forward ID
checkInterval time.Duration
hangThreshold time.Duration // How long without heartbeat before considered hung
heartbeatInterval time.Duration // How often to poll workers for heartbeat
ctx context.Context ctx context.Context
workers map[string]*workerState
cancel context.CancelFunc cancel context.CancelFunc
eventBus *events.Bus
wg sync.WaitGroup wg sync.WaitGroup
eventBus *events.Bus // Optional event bus for decoupled communication checkInterval time.Duration
hangThreshold time.Duration
heartbeatInterval time.Duration
mu sync.RWMutex
} }
// workerState tracks the health of a single worker // workerState tracks the health of a single worker
type workerState struct { type workerState struct {
forwardID string
lastHeartbeat time.Time lastHeartbeat time.Time
worker HeartbeatResponder
onHungCallback func(forwardID string)
forwardID string
heartbeatCount uint64 heartbeatCount uint64
isHung bool isHung bool
onHungCallback func(forwardID string)
worker HeartbeatResponder // Reference to worker for heartbeat polling
} }
// HeartbeatResponder is an interface for workers that can respond to heartbeat checks // HeartbeatResponder is an interface for workers that can respond to heartbeat checks
@@ -204,8 +204,8 @@ func (w *Watchdog) pollHeartbeats() {
// hungWorkerInfo stores information about a hung worker for deferred callback execution // hungWorkerInfo stores information about a hung worker for deferred callback execution
type hungWorkerInfo struct { type hungWorkerInfo struct {
forwardID string
callback func(string) callback func(string)
forwardID string
} }
// checkWorkers checks all registered workers for hung state // checkWorkers checks all registered workers for hung state
+22 -22
View File
@@ -23,23 +23,23 @@ const (
// ForwardWorker manages a single port-forward connection with automatic retry. // ForwardWorker manages a single port-forward connection with automatic retry.
type ForwardWorker struct { type ForwardWorker struct {
forward config.Forward startTime time.Time
portForwarder *k8s.PortForwarder
ctx context.Context
cancel context.CancelFunc
stopChan chan struct{}
doneChan chan struct{}
reconnectChan chan string // Channel to trigger reconnection
successChan chan struct{} // Channel to signal successful connection (for backoff reset)
verbose bool
lastPod string // Track the last pod we connected to
statusUI StatusUpdater statusUI StatusUpdater
healthChecker *healthcheck.Checker ctx context.Context
reconnectChan chan string
httpProxy *httplog.Proxy
watchdog *Watchdog watchdog *Watchdog
startTime time.Time // Track when the worker started cancel context.CancelFunc
forwardCancel context.CancelFunc // Cancel function for current forward attempt doneChan chan struct{}
forwardCancelMu sync.Mutex // Protects forwardCancel portForwarder *k8s.PortForwarder
httpProxy *httplog.Proxy // HTTP logging proxy (nil if not enabled) successChan chan struct{}
healthChecker *healthcheck.Checker
forwardCancel context.CancelFunc
stopChan chan struct{}
lastPod string
forward config.Forward
forwardCancelMu sync.Mutex
verbose bool
} }
// NewForwardWorker creates a new ForwardWorker for a single forward configuration. // NewForwardWorker creates a new ForwardWorker for a single forward configuration.
@@ -142,7 +142,7 @@ func (w *ForwardWorker) run() {
// Start HTTP logging proxy if enabled // Start HTTP logging proxy if enabled
if err := w.startHTTPProxy(); err != nil { if err := w.startHTTPProxy(); err != nil {
logger.Error("Failed to start HTTP logging proxy", map[string]interface{}{ logger.Error("Failed to start HTTP logging proxy", map[string]any{
"forward_id": w.forward.ID(), "forward_id": w.forward.ID(),
"error": err.Error(), "error": err.Error(),
}) })
@@ -175,7 +175,7 @@ func (w *ForwardWorker) run() {
) )
if err != nil { if err != nil {
logger.Error("Failed to resolve resource", map[string]interface{}{ logger.Error("Failed to resolve resource", map[string]any{
"forward_id": w.forward.ID(), "forward_id": w.forward.ID(),
"context": w.forward.GetContext(), "context": w.forward.GetContext(),
"namespace": w.forward.GetNamespace(), "namespace": w.forward.GetNamespace(),
@@ -191,7 +191,7 @@ func (w *ForwardWorker) run() {
if w.healthChecker != nil { if w.healthChecker != nil {
w.healthChecker.MarkReconnecting(w.forward.ID()) w.healthChecker.MarkReconnecting(w.forward.ID())
} }
logger.Info("Pod restart detected, switching to new pod", map[string]interface{}{ logger.Info("Pod restart detected, switching to new pod", map[string]any{
"forward_id": w.forward.ID(), "forward_id": w.forward.ID(),
"old_pod": w.lastPod, "old_pod": w.lastPod,
"new_pod": podName, "new_pod": podName,
@@ -199,7 +199,7 @@ func (w *ForwardWorker) run() {
"namespace": w.forward.GetNamespace(), "namespace": w.forward.GetNamespace(),
}) })
} else if w.lastPod == "" { } else if w.lastPod == "" {
logger.Info("Starting port forward", map[string]interface{}{ logger.Info("Starting port forward", map[string]any{
"forward_id": w.forward.ID(), "forward_id": w.forward.ID(),
"target": w.forward.String(), "target": w.forward.String(),
"local_port": w.forward.LocalPort, "local_port": w.forward.LocalPort,
@@ -228,7 +228,7 @@ func (w *ForwardWorker) run() {
} }
// Log the error // Log the error
logger.Warn("Port-forward connection failed, will retry", map[string]interface{}{ logger.Warn("Port-forward connection failed, will retry", map[string]any{
"forward_id": w.forward.ID(), "forward_id": w.forward.ID(),
"context": w.forward.GetContext(), "context": w.forward.GetContext(),
"namespace": w.forward.GetNamespace(), "namespace": w.forward.GetNamespace(),
@@ -433,7 +433,7 @@ func (w *ForwardWorker) startHTTPProxy() error {
w.httpProxy = proxy w.httpProxy = proxy
logger.Info("HTTP logging proxy started", map[string]interface{}{ logger.Info("HTTP logging proxy started", map[string]any{
"forward_id": w.forward.ID(), "forward_id": w.forward.ID(),
"local_port": w.forward.LocalPort, "local_port": w.forward.LocalPort,
"target_port": targetPort, "target_port": targetPort,
@@ -446,7 +446,7 @@ func (w *ForwardWorker) startHTTPProxy() error {
func (w *ForwardWorker) stopHTTPProxy() { func (w *ForwardWorker) stopHTTPProxy() {
if w.httpProxy != nil { if w.httpProxy != nil {
if err := w.httpProxy.Stop(); err != nil { if err := w.httpProxy.Stop(); err != nil {
logger.Warn("Failed to stop HTTP proxy", map[string]interface{}{ logger.Warn("Failed to stop HTTP proxy", map[string]any{
"forward_id": w.forward.ID(), "forward_id": w.forward.ID(),
"error": err.Error(), "error": err.Error(),
}) })
+5 -5
View File
@@ -55,8 +55,8 @@ func TestLogWriter_Write(t *testing.T) {
func TestForwardWorker_GetForward(t *testing.T) { func TestForwardWorker_GetForward(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
forward config.Forward
description string description string
forward config.Forward
}{ }{
{ {
name: "get pod forward", name: "get pod forward",
@@ -141,9 +141,9 @@ func TestForwardWorker_IsRunning(t *testing.T) {
func TestForwardID(t *testing.T) { func TestForwardID(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
description string
forward config.Forward forward config.Forward
expectUnique bool expectUnique bool
description string
}{ }{
{ {
name: "unique IDs for different forwards", name: "unique IDs for different forwards",
@@ -183,9 +183,9 @@ func TestForwardID(t *testing.T) {
func TestForwardString(t *testing.T) { func TestForwardString(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
forward config.Forward
expectedContains []string
description string description string
expectedContains []string
forward config.Forward
}{ }{
{ {
name: "pod forward string", name: "pod forward string",
@@ -259,8 +259,8 @@ func TestSleepWithBackoffConcept(t *testing.T) {
func TestWorkerVerboseMode(t *testing.T) { func TestWorkerVerboseMode(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
verbose bool
description string description string
verbose bool
}{ }{
{ {
name: "verbose mode enabled", name: "verbose mode enabled",
+33 -16
View File
@@ -1,3 +1,17 @@
// Package healthcheck provides connection health monitoring for port-forwards.
// It detects stale, hung, or broken connections and triggers reconnection.
//
// The Checker supports two health check methods:
// - tcp-dial: Simple TCP connection test (fast but less reliable)
// - data-transfer: Attempts to read data from the connection (more reliable)
//
// Stale connection detection prevents issues during long-running operations
// like database dumps by monitoring:
// - Connection age (default: 25 minutes, before k8s 30-minute timeout)
// - Idle time (default: 10 minutes, detects hung tunnels)
//
// The package uses a sync.Pool for buffer reuse to minimize GC pressure
// during frequent health checks.
package healthcheck package healthcheck
import ( import (
@@ -47,13 +61,13 @@ const (
// PortHealth represents the health status of a single port // PortHealth represents the health status of a single port
type PortHealth struct { type PortHealth struct {
Port int
LastCheck time.Time LastCheck time.Time
RegisteredAt time.Time
ConnectionTime time.Time
LastActivity time.Time
Status Status Status Status
ErrorMessage string ErrorMessage string
RegisteredAt time.Time // When this port was registered Port int
ConnectionTime time.Time // When current connection was established
LastActivity time.Time // Last time data was transferred
} }
// StatusCallback is called when a port's health status changes // StatusCallback is called when a port's health status changes
@@ -63,26 +77,26 @@ type StatusCallback func(forwardID string, status Status, errorMsg string)
// Uses a single goroutine to check all registered ports, reducing overhead // Uses a single goroutine to check all registered ports, reducing overhead
// compared to one goroutine per port. // compared to one goroutine per port.
type Checker struct { type Checker struct {
mu sync.RWMutex
ports map[string]*PortHealth // key: forward ID
callbacks map[string]StatusCallback
interval time.Duration
timeout time.Duration
method CheckMethod
maxConnectionAge time.Duration
maxIdleTime time.Duration
ctx context.Context ctx context.Context
ports map[string]*PortHealth
callbacks map[string]StatusCallback
eventBus *events.Bus
cancel context.CancelFunc cancel context.CancelFunc
method CheckMethod
wg sync.WaitGroup wg sync.WaitGroup
interval time.Duration
maxIdleTime time.Duration
maxConnectionAge time.Duration
timeout time.Duration
mu sync.RWMutex
started bool started bool
eventBus *events.Bus // Optional event bus for decoupled communication
} }
// CheckerOptions configures the health checker // CheckerOptions configures the health checker
type CheckerOptions struct { type CheckerOptions struct {
Method CheckMethod
Interval time.Duration Interval time.Duration
Timeout time.Duration Timeout time.Duration
Method CheckMethod
MaxConnectionAge time.Duration MaxConnectionAge time.Duration
MaxIdleTime time.Duration MaxIdleTime time.Duration
} }
@@ -339,7 +353,10 @@ func (c *Checker) checkPort(forwardID string) {
connectionAge.Round(time.Second), c.maxConnectionAge, idleTime.Round(time.Second)) connectionAge.Round(time.Second), c.maxConnectionAge, idleTime.Round(time.Second))
} else if c.maxIdleTime > 0 && idleTime > c.maxIdleTime { } else if c.maxIdleTime > 0 && idleTime > c.maxIdleTime {
newStatus = StatusStale newStatus = StatusStale
errorMsg = fmt.Sprintf("idle time %v exceeds max %v", idleTime.Round(time.Second), c.maxIdleTime) // Round up to next second to ensure displayed time is always > max
// (avoids confusing "10m0s exceeds max 10m0s" when actual is 10m0.1s)
displayIdle := idleTime.Truncate(time.Second) + time.Second
errorMsg = fmt.Sprintf("idle time %v exceeds max %v", displayIdle, c.maxIdleTime)
} else { } else {
// Perform connectivity check // Perform connectivity check
var checkErr error var checkErr error
@@ -408,7 +425,7 @@ func (c *Checker) checkTCPDial(port int) error {
if err != nil { if err != nil {
return err return err
} }
_ = conn.Close() _ = conn.Close() // Best-effort cleanup; health check succeeded
return nil return nil
} }
+3 -8
View File
@@ -88,9 +88,9 @@ func (s *HealthCheckTestSuite) TestRegisterAndUnregister() {
func (s *HealthCheckTestSuite) TestTCPDialMethod() { func (s *HealthCheckTestSuite) TestTCPDialMethod() {
tests := []struct { tests := []struct {
name string name string
setupPort bool
expectedStatus Status expectedStatus Status
description string description string
setupPort bool
}{ }{
{ {
name: "port available - healthy", name: "port available - healthy",
@@ -109,10 +109,9 @@ func (s *HealthCheckTestSuite) TestTCPDialMethod() {
for _, tt := range tests { for _, tt := range tests {
s.Run(tt.name, func() { s.Run(tt.name, func() {
var testPort int var testPort int
var testListener net.Listener
if tt.setupPort { if tt.setupPort {
// Use the existing listener // Use the existing listener from suite setup
testPort = s.port testPort = s.port
} else { } else {
// Use a port that's not listening // Use a port that's not listening
@@ -143,10 +142,6 @@ func (s *HealthCheckTestSuite) TestTCPDialMethod() {
status, exists := checker.GetStatus("test-forward") status, exists := checker.GetStatus("test-forward")
assert.True(s.T(), exists) assert.True(s.T(), exists)
assert.Equal(s.T(), tt.expectedStatus, status, tt.description) assert.Equal(s.T(), tt.expectedStatus, status, tt.description)
if testListener != nil {
testListener.Close()
}
}) })
} }
} }
@@ -201,7 +196,7 @@ func (s *HealthCheckTestSuite) TestDataTransferMethod() {
} }
switch tt.serverBehavior { switch tt.serverBehavior {
case "banner": case "banner":
conn.Write([]byte("220 Welcome\r\n")) _, _ = conn.Write([]byte("220 Welcome\r\n"))
time.Sleep(50 * time.Millisecond) time.Sleep(50 * time.Millisecond)
conn.Close() conn.Close()
case "close": case "close":
+20 -8
View File
@@ -1,3 +1,15 @@
// Package httplog provides HTTP request/response logging for port forwards.
// It captures HTTP traffic passing through the forward proxy and stores
// entries for viewing in the UI.
//
// The logger supports:
// - Request and response capture with headers and bodies
// - Configurable body size limits to prevent memory issues
// - Callback-based notifications for real-time log viewing
// - Thread-safe operation for concurrent forwards
//
// Bodies are truncated if they exceed the configured maximum size
// (default: 1MB) and marked as truncated in the log entry.
package httplog package httplog
import ( import (
@@ -11,17 +23,17 @@ import (
// Entry represents a single HTTP log entry // Entry represents a single HTTP log entry
type Entry struct { type Entry struct {
Timestamp time.Time `json:"timestamp"` Timestamp time.Time `json:"timestamp"`
Headers map[string]string `json:"headers,omitempty"`
ForwardID string `json:"forward_id"` ForwardID string `json:"forward_id"`
RequestID string `json:"request_id"` RequestID string `json:"request_id"`
Direction string `json:"direction"` // "request" or "response" Direction string `json:"direction"`
Method string `json:"method,omitempty"` Method string `json:"method,omitempty"`
Path string `json:"path,omitempty"` Path string `json:"path,omitempty"`
StatusCode int `json:"status_code,omitempty"`
Headers map[string]string `json:"headers,omitempty"`
BodySize int `json:"body_size"`
Body string `json:"body,omitempty"` Body string `json:"body,omitempty"`
LatencyMs int64 `json:"latency_ms,omitempty"`
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
StatusCode int `json:"status_code,omitempty"`
BodySize int `json:"body_size"`
LatencyMs int64 `json:"latency_ms,omitempty"`
} }
// LogCallback is a function that receives log entries // LogCallback is a function that receives log entries
@@ -29,12 +41,12 @@ type LogCallback func(entry Entry)
// Logger writes HTTP log entries to an output stream // Logger writes HTTP log entries to an output stream
type Logger struct { type Logger struct {
mu sync.Mutex
output io.Writer output io.Writer
file *os.File // Only set if we opened the file ourselves file *os.File
forwardID string forwardID string
maxBodyLen int
callbacks []LogCallback callbacks []LogCallback
maxBodyLen int
mu sync.Mutex
} }
// NewLogger creates a new HTTP logger // NewLogger creates a new HTTP logger
+15 -15
View File
@@ -166,15 +166,15 @@ func TestLogger_Log_Error(t *testing.T) {
func TestLogger_BodyTruncation(t *testing.T) { func TestLogger_BodyTruncation(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
maxBodyLen int
body string body string
maxBodyLen int
expectTrunc bool expectTrunc bool
}{ }{
{"body under limit", 100, "short", false}, {name: "body under limit", maxBodyLen: 100, body: "short", expectTrunc: false},
{"body at limit", 5, "exact", false}, {name: "body at limit", maxBodyLen: 5, body: "exact", expectTrunc: false},
{"body over limit", 5, "this is too long", true}, {name: "body over limit", maxBodyLen: 5, body: "this is too long", expectTrunc: true},
{"empty body", 100, "", false}, {name: "empty body", maxBodyLen: 100, body: "", expectTrunc: false},
{"zero max", 0, "any", true}, {name: "zero max", maxBodyLen: 0, body: "any", expectTrunc: true},
} }
for _, tt := range tests { for _, tt := range tests {
@@ -186,10 +186,10 @@ func TestLogger_BodyTruncation(t *testing.T) {
output: &buf, output: &buf,
} }
l.Log(Entry{Body: tt.body}) _ = l.Log(Entry{Body: tt.body})
var entry Entry var entry Entry
json.Unmarshal(buf.Bytes(), &entry) _ = json.Unmarshal(buf.Bytes(), &entry)
if tt.expectTrunc { if tt.expectTrunc {
assert.Contains(t, entry.Body, "...(truncated)") assert.Contains(t, entry.Body, "...(truncated)")
@@ -219,9 +219,9 @@ func TestLogger_Callbacks(t *testing.T) {
}) })
// Log entries // Log entries
l.Log(Entry{Direction: "request", Path: "/api/1"}) _ = l.Log(Entry{Direction: "request", Path: "/api/1"})
l.Log(Entry{Direction: "response", Path: "/api/1"}) _ = l.Log(Entry{Direction: "response", Path: "/api/1"})
l.Log(Entry{Direction: "request", Path: "/api/2"}) _ = l.Log(Entry{Direction: "request", Path: "/api/2"})
mu.Lock() mu.Lock()
assert.Len(t, received, 3) assert.Len(t, received, 3)
@@ -244,7 +244,7 @@ func TestLogger_MultipleCallbacks(t *testing.T) {
l.AddCallback(func(entry Entry) { count1++ }) l.AddCallback(func(entry Entry) { count1++ })
l.AddCallback(func(entry Entry) { count2++ }) l.AddCallback(func(entry Entry) { count2++ })
l.Log(Entry{}) _ = l.Log(Entry{})
assert.Equal(t, 1, count1) assert.Equal(t, 1, count1)
assert.Equal(t, 1, count2) assert.Equal(t, 1, count2)
@@ -261,12 +261,12 @@ func TestLogger_ClearCallbacks(t *testing.T) {
count := 0 count := 0
l.AddCallback(func(entry Entry) { count++ }) l.AddCallback(func(entry Entry) { count++ })
l.Log(Entry{}) _ = l.Log(Entry{})
assert.Equal(t, 1, count) assert.Equal(t, 1, count)
l.ClearCallbacks() l.ClearCallbacks()
l.Log(Entry{}) _ = l.Log(Entry{})
assert.Equal(t, 1, count) // Still 1 - callback was cleared assert.Equal(t, 1, count) // Still 1 - callback was cleared
} }
@@ -321,7 +321,7 @@ func TestLogger_Concurrent(t *testing.T) {
wg.Add(1) wg.Add(1)
go func(n int) { go func(n int) {
defer wg.Done() defer wg.Done()
l.Log(Entry{ _ = l.Log(Entry{
Direction: "request", Direction: "request",
Path: "/api/" + string(rune('a'+n%26)), Path: "/api/" + string(rune('a'+n%26)),
}) })
+7 -6
View File
@@ -15,20 +15,21 @@ import (
"time" "time"
"github.com/nvm/kportal/internal/config" "github.com/nvm/kportal/internal/config"
"github.com/nvm/kportal/internal/logger"
) )
// Proxy is an HTTP reverse proxy with logging capabilities // Proxy is an HTTP reverse proxy with logging capabilities
type Proxy struct { type Proxy struct {
localPort int // Port to listen on (user-facing) listener net.Listener
targetPort int // Port to forward to (k8s tunnel)
logger *Logger logger *Logger
server *http.Server server *http.Server
forwardID string forwardID string
filterPath string // Glob pattern for path filtering filterPath string
includeHdrs bool localPort int
listener net.Listener targetPort int
requestCount uint64 requestCount uint64
mu sync.Mutex mu sync.Mutex
includeHdrs bool
running bool running bool
} }
@@ -100,7 +101,7 @@ func (p *Proxy) Start() error {
// Start serving (blocking) // Start serving (blocking)
go func() { go func() {
if err := p.server.Serve(ln); err != nil && err != http.ErrServerClosed { if err := p.server.Serve(ln); err != nil && err != http.ErrServerClosed {
// Log error but don't crash - proxy will be replaced on reconnect logger.Debug("HTTP proxy serve error (will be replaced on reconnect)", map[string]any{"error": err.Error()})
} }
}() }()
+2 -2
View File
@@ -331,7 +331,7 @@ func TestProxy_Start_PortInUse(t *testing.T) {
} }
err := proxy1.Start() err := proxy1.Start()
require.NoError(t, err) require.NoError(t, err)
defer proxy1.Stop() defer func() { _ = proxy1.Stop() }()
// Get the actual port // Get the actual port
addr := proxy1.listener.Addr().(*net.TCPAddr) addr := proxy1.listener.Addr().(*net.TCPAddr)
@@ -353,9 +353,9 @@ func TestProxy_Start_PortInUse(t *testing.T) {
// TestFlattenHeaders_EdgeCases tests header flattening edge cases // TestFlattenHeaders_EdgeCases tests header flattening edge cases
func TestFlattenHeaders_EdgeCases(t *testing.T) { func TestFlattenHeaders_EdgeCases(t *testing.T) {
tests := []struct { tests := []struct {
name string
headers http.Header headers http.Header
expected map[string]string expected map[string]string
name string
}{ }{
{ {
name: "empty headers", name: "empty headers",
+17 -6
View File
@@ -1,3 +1,14 @@
// Package k8s provides Kubernetes client management, resource resolution,
// and port-forwarding capabilities for kportal.
//
// Key components:
// - ClientPool: Thread-safe management of Kubernetes clients per context
// - ResourceResolver: Resolves pod/service/selector targets to actual pods
// - PortForwarder: Establishes and manages port-forward connections
// - Discovery: Provides resource discovery for the UI wizards
//
// The package handles automatic pod restart detection through re-resolution,
// caching with 30-second TTL, and graceful connection management.
package k8s package k8s
import ( import (
@@ -12,10 +23,10 @@ import (
// ClientPool manages Kubernetes clients per context with thread-safe access. // ClientPool manages Kubernetes clients per context with thread-safe access.
type ClientPool struct { type ClientPool struct {
mu sync.RWMutex loader clientcmd.ClientConfig
clients map[string]*kubernetes.Clientset clients map[string]*kubernetes.Clientset
configs map[string]*rest.Config configs map[string]*rest.Config
loader clientcmd.ClientConfig mu sync.RWMutex
} }
// NewClientPool creates a new ClientPool instance. // NewClientPool creates a new ClientPool instance.
@@ -51,8 +62,8 @@ func (p *ClientPool) GetClient(contextName string) (*kubernetes.Clientset, error
defer p.mu.Unlock() defer p.mu.Unlock()
// Double-check in case another goroutine created it while we waited // Double-check in case another goroutine created it while we waited
if client, exists := p.clients[contextName]; exists { if cachedClient, ok := p.clients[contextName]; ok {
return client, nil return cachedClient, nil
} }
// Create new client // Create new client
@@ -91,8 +102,8 @@ func (p *ClientPool) GetRestConfig(contextName string) (*rest.Config, error) {
defer p.mu.Unlock() defer p.mu.Unlock()
// Double-check in case another goroutine created it while we waited // Double-check in case another goroutine created it while we waited
if config, exists := p.configs[contextName]; exists { if cachedConfig, ok := p.configs[contextName]; ok {
return config, nil return cachedConfig, nil
} }
// Create new config // Create new config
+2 -2
View File
@@ -146,8 +146,8 @@ func TestClientPool_ThreadSafety(t *testing.T) {
go func() { go func() {
pool.ClearCache() pool.ClearCache()
pool.RemoveContext("test-context") pool.RemoveContext("test-context")
pool.GetCurrentContext() _, _ = pool.GetCurrentContext()
pool.ListContexts() _, _ = pool.ListContexts()
done <- true done <- true
}() }()
} }
+5 -5
View File
@@ -28,11 +28,11 @@ func NewDiscovery(pool *ClientPool) *Discovery {
// PodInfo contains information about a pod relevant for port forwarding. // PodInfo contains information about a pod relevant for port forwarding.
type PodInfo struct { type PodInfo struct {
Created metav1.Time
Name string Name string
Namespace string Namespace string
Containers []ContainerInfo
Status string Status string
Created metav1.Time Containers []ContainerInfo
} }
// ContainerInfo contains information about a container within a pod. // ContainerInfo contains information about a container within a pod.
@@ -44,17 +44,17 @@ type ContainerInfo struct {
// PortInfo describes a port exposed by a container or service. // PortInfo describes a port exposed by a container or service.
type PortInfo struct { type PortInfo struct {
Name string Name string
Port int32
TargetPort int32 // For services: the actual pod port to forward to
Protocol string Protocol string
Port int32
TargetPort int32
} }
// ServiceInfo contains information about a service. // ServiceInfo contains information about a service.
type ServiceInfo struct { type ServiceInfo struct {
Name string Name string
Namespace string Namespace string
Ports []PortInfo
Type string Type string
Ports []PortInfo
} }
// ListContexts returns all available Kubernetes contexts from kubeconfig. // ListContexts returns all available Kubernetes contexts from kubeconfig.
+3 -3
View File
@@ -14,12 +14,12 @@ import (
func TestResolveTargetPort(t *testing.T) { func TestResolveTargetPort(t *testing.T) {
tests := []struct { tests := []struct {
name string
servicePort corev1.ServicePort
service *corev1.Service service *corev1.Service
name string
description string
servicePort corev1.ServicePort
pods []corev1.Pod pods []corev1.Pod
expectedPort int32 expectedPort int32
description string
}{ }{
{ {
name: "numeric targetPort", name: "numeric targetPort",
+8 -8
View File
@@ -49,16 +49,16 @@ func (pf *PortForwarder) SetDialTimeout(timeout time.Duration) {
// ForwardRequest contains the parameters for a port-forward request. // ForwardRequest contains the parameters for a port-forward request.
type ForwardRequest struct { type ForwardRequest struct {
ContextName string // Kubernetes context name Out io.Writer
Namespace string // Namespace ErrOut io.Writer
Resource string // Resource (pod/name or service/name)
Selector string // Label selector (for pod resolution)
LocalPort int // Local port
RemotePort int // Remote port
StopChan chan struct{} StopChan chan struct{}
ReadyChan chan struct{} ReadyChan chan struct{}
Out io.Writer // Output writer for logs ContextName string
ErrOut io.Writer // Error output writer Namespace string
Resource string
Selector string
LocalPort int
RemotePort int
} }
// Forward establishes a port-forward connection to a Kubernetes resource. // Forward establishes a port-forward connection to a Kubernetes resource.
+5 -5
View File
@@ -19,15 +19,15 @@ const (
// ResolvedResource represents a resolved Kubernetes resource. // ResolvedResource represents a resolved Kubernetes resource.
type ResolvedResource struct { type ResolvedResource struct {
Name string // The resolved pod or service name Timestamp time.Time
Namespace string // The namespace Name string
Timestamp time.Time // When this was resolved Namespace string
} }
// cacheEntry stores a cached resolution result with expiry. // cacheEntry stores a cached resolution result with expiry.
type cacheEntry struct { type cacheEntry struct {
resource ResolvedResource
expiresAt time.Time expiresAt time.Time
resource ResolvedResource
} }
// ResourceResolver resolves Kubernetes resources with caching. // ResourceResolver resolves Kubernetes resources with caching.
@@ -188,7 +188,7 @@ func (r *ResourceResolver) getFromCache(key string) string {
// Upgrade to write lock and delete expired entry // Upgrade to write lock and delete expired entry
r.cacheMu.Lock() r.cacheMu.Lock()
// Double-check entry still exists and is still expired (may have been updated) // Double-check entry still exists and is still expired (may have been updated)
if entry, exists := r.cache[key]; exists && time.Now().After(entry.expiresAt) { if expiredEntry, ok := r.cache[key]; ok && time.Now().After(expiredEntry.expiresAt) {
delete(r.cache, key) delete(r.cache, key)
} }
r.cacheMu.Unlock() r.cacheMu.Unlock()
+3 -3
View File
@@ -17,10 +17,10 @@ func TestKlogWriter(t *testing.T) {
input string input string
expectedLevel string expectedLevel string
expectedMsg string expectedMsg string
description string
loggerLevel Level loggerLevel Level
loggerFormat Format loggerFormat Format
shouldLog bool shouldLog bool
description string
}{ }{
{ {
name: "info level log", name: "info level log",
@@ -162,9 +162,9 @@ func TestKlogWriter(t *testing.T) {
func TestKlogWriterBuffering(t *testing.T) { func TestKlogWriterBuffering(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
description string
writes []string writes []string
expectCount int expectCount int
description string
}{ }{
{ {
name: "single complete line", name: "single complete line",
@@ -264,7 +264,7 @@ func TestKlogWriterConcurrency(t *testing.T) {
go func(id int) { go func(id int) {
for j := 0; j < numWrites; j++ { 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) 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)) _, _ = klogWriter.Write([]byte(msg))
} }
done <- true done <- true
}(i) }(i)
+7 -7
View File
@@ -14,12 +14,12 @@ import (
func TestLogrAdapter_Info(t *testing.T) { func TestLogrAdapter_Info(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
loggerLevel Level
logrLevel int
message string message string
keysAndValues []interface{} keysAndValues []interface{}
expectOutput bool
expectContains []string expectContains []string
loggerLevel Level
logrLevel int
expectOutput bool
}{ }{
{ {
name: "info log v0 with debug logger", name: "info log v0 with debug logger",
@@ -109,13 +109,13 @@ func TestLogrAdapter_Info(t *testing.T) {
func TestLogrAdapter_Error(t *testing.T) { func TestLogrAdapter_Error(t *testing.T) {
tests := []struct { tests := []struct {
name string
loggerLevel Level
err error err error
name string
message string message string
keysAndValues []interface{} keysAndValues []interface{}
expectOutput bool
expectContains []string expectContains []string
loggerLevel Level
expectOutput bool
}{ }{
{ {
name: "error with error object", name: "error with error object",
@@ -179,9 +179,9 @@ func TestLogrAdapter_Error(t *testing.T) {
func TestLogrAdapter_WithName(t *testing.T) { func TestLogrAdapter_WithName(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
loggerNames []string
message string message string
expectContains string expectContains string
loggerNames []string
}{ }{
{ {
name: "single logger name", name: "single logger name",
+33 -3
View File
@@ -1,3 +1,19 @@
// Package logger provides structured logging with support for text and JSON
// output formats. It intercepts Kubernetes client-go logs and routes them
// through the structured logger.
//
// The package provides both instance-based and global logging:
//
// // Instance-based logging
// log := logger.New(logger.LevelInfo, logger.FormatJSON, os.Stderr)
// log.Info("message", "key", "value")
//
// // Global logging (after Init)
// logger.Init(logger.LevelInfo, logger.FormatText, os.Stderr)
// logger.Info("message", "key", "value")
//
// Log levels: DEBUG < INFO < WARN < ERROR
// Output formats: FormatText (human-readable), FormatJSON (structured)
package logger package logger
import ( import (
@@ -9,36 +25,50 @@ import (
"time" "time"
) )
// Level represents the logging level.
// Higher levels include all lower levels (e.g., LevelInfo includes WARN and ERROR).
type Level int type Level int
const ( const (
// LevelDebug is for detailed troubleshooting information.
LevelDebug Level = iota LevelDebug Level = iota
// LevelInfo is for general operational information.
LevelInfo LevelInfo
// LevelWarn is for unexpected but handled situations.
LevelWarn LevelWarn
// LevelError is for failures that require attention.
LevelError LevelError
) )
// Format represents the output format for log entries.
type Format int type Format int
const ( const (
// FormatText outputs human-readable log lines.
FormatText Format = iota FormatText Format = iota
// FormatJSON outputs structured JSON log entries.
FormatJSON FormatJSON
) )
// Logger is a structured logger with configurable level and format.
// It is safe for concurrent use.
type Logger struct { type Logger struct {
output io.Writer
level Level level Level
format Format format Format
output io.Writer mu sync.Mutex
mu sync.Mutex // Protects concurrent writes to output
} }
// logEntry represents a single log entry for JSON output.
type logEntry struct { type logEntry struct {
Fields map[string]any `json:"fields,omitempty"`
Time string `json:"time"` Time string `json:"time"`
Level string `json:"level"` Level string `json:"level"`
Message string `json:"message"` Message string `json:"message"`
Fields map[string]interface{} `json:"fields,omitempty"`
} }
// New creates a new Logger with the specified level, format, and output writer.
// If output is nil, os.Stderr is used.
func New(level Level, format Format, output io.Writer) *Logger { func New(level Level, format Format, output io.Writer) *Logger {
if output == nil { if output == nil {
output = os.Stderr output = os.Stderr
+19 -19
View File
@@ -13,13 +13,13 @@ import (
func TestLoggerTextFormat(t *testing.T) { func TestLoggerTextFormat(t *testing.T) {
tests := []struct { tests := []struct {
fields map[string]interface{}
name string name string
message string
expectContains []string
level Level level Level
logLevel Level logLevel Level
message string
fields map[string]interface{}
expectOutput bool expectOutput bool
expectContains []string
}{ }{
{ {
name: "info logged at info level", name: "info logged at info level",
@@ -138,13 +138,13 @@ func TestLoggerTextFormat(t *testing.T) {
func TestLoggerJSONFormat(t *testing.T) { func TestLoggerJSONFormat(t *testing.T) {
tests := []struct { tests := []struct {
fields map[string]interface{}
name string name string
message string
expectLevel string
level Level level Level
logLevel Level logLevel Level
message string
fields map[string]interface{}
expectOutput bool expectOutput bool
expectLevel string
}{ }{
{ {
name: "info logged at info level", name: "info logged at info level",
@@ -268,12 +268,12 @@ func TestLoggerJSONFormat(t *testing.T) {
func TestGlobalLogger(t *testing.T) { func TestGlobalLogger(t *testing.T) {
tests := []struct { tests := []struct {
name string
initLevel Level
initFormat Format
logFunc func(string, ...map[string]interface{}) logFunc func(string, ...map[string]interface{})
name string
message string message string
expectContains string expectContains string
initLevel Level
initFormat Format
}{ }{
{ {
name: "global info logger text", name: "global info logger text",
@@ -321,9 +321,9 @@ func TestGlobalLogger(t *testing.T) {
func TestLogLevelsFiltering(t *testing.T) { func TestLogLevelsFiltering(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
loggerLevel Level
logAtLevels []Level logAtLevels []Level
expectOutputs []bool expectOutputs []bool
loggerLevel Level
}{ }{
{ {
name: "debug level logs everything", name: "debug level logs everything",
@@ -387,14 +387,14 @@ func TestLoggerNilOutput(t *testing.T) {
func TestLevelToString(t *testing.T) { func TestLevelToString(t *testing.T) {
tests := []struct { tests := []struct {
level Level
expected string expected string
level Level
}{ }{
{LevelDebug, "DEBUG"}, {level: LevelDebug, expected: "DEBUG"},
{LevelInfo, "INFO"}, {level: LevelInfo, expected: "INFO"},
{LevelWarn, "WARN"}, {level: LevelWarn, expected: "WARN"},
{LevelError, "ERROR"}, {level: LevelError, expected: "ERROR"},
{Level(999), "UNKNOWN"}, {level: Level(999), expected: "UNKNOWN"},
} }
for _, tt := range tests { for _, tt := range tests {
@@ -407,8 +407,8 @@ func TestLevelToString(t *testing.T) {
func TestJSONFieldTypes(t *testing.T) { func TestJSONFieldTypes(t *testing.T) {
tests := []struct { tests := []struct {
name string
fields map[string]interface{} fields map[string]interface{}
name string
}{ }{
{ {
name: "string fields", name: "string fields",
@@ -467,10 +467,10 @@ func TestJSONFieldTypes(t *testing.T) {
func TestInitWithCustomOutput(t *testing.T) { func TestInitWithCustomOutput(t *testing.T) {
tests := []struct { tests := []struct {
name string
output io.Writer output io.Writer
expectDiscard bool name string
description string description string
expectDiscard bool
}{ }{
{ {
name: "init with custom buffer", name: "init with custom buffer",
+17 -4
View File
@@ -1,3 +1,16 @@
// Package mdns provides multicast DNS (mDNS/Bonjour) hostname publishing
// for port forwards. When enabled, forwards with aliases can be accessed
// via <alias>.local hostnames on the local network.
//
// The Publisher manages mDNS service registrations using zeroconf:
// - Registers hostnames when forwards become active
// - Unregisters hostnames when forwards are stopped
// - Provides service discovery via the _kportal._tcp service type
//
// mDNS discovery commands:
//
// dns-sd -B _kportal._tcp local # macOS
// avahi-browse -t _kportal._tcp # Linux
package mdns package mdns
import ( import (
@@ -23,11 +36,11 @@ const (
// Publisher manages mDNS hostname registrations for port forwards. // Publisher manages mDNS hostname registrations for port forwards.
// It allows forwards with aliases to be accessible via <alias>.local hostnames. // It allows forwards with aliases to be accessible via <alias>.local hostnames.
type Publisher struct { type Publisher struct {
mu sync.RWMutex servers map[string]*zeroconf.Server
servers map[string]*zeroconf.Server // forwardID -> server aliases map[string]string
aliases map[string]string // forwardID -> alias (for logging)
enabled bool
localIPs []string localIPs []string
mu sync.RWMutex
enabled bool
} }
// NewPublisher creates a new mDNS Publisher. // NewPublisher creates a new mDNS Publisher.
+18 -2
View File
@@ -1,3 +1,19 @@
// Package retry provides exponential backoff with jitter for retry logic.
// It implements a backoff sequence of 1s → 2s → 4s → 8s → 10s (max),
// with 10% random jitter to prevent thundering herd problems.
//
// Basic usage:
//
// backoff := retry.NewBackoff()
// for {
// err := doSomething()
// if err == nil {
// backoff.Reset()
// break
// }
// delay := backoff.Next()
// time.Sleep(delay)
// }
package retry package retry
import ( import (
@@ -19,8 +35,8 @@ const (
// Backoff implements exponential backoff with jitter for retry logic. // Backoff implements exponential backoff with jitter for retry logic.
// The backoff sequence is: 1s → 2s → 4s → 8s → 10s (max, then stays at 10s). // The backoff sequence is: 1s → 2s → 4s → 8s → 10s (max, then stays at 10s).
type Backoff struct { type Backoff struct {
attempt int
rng *rand.Rand rng *rand.Rand
attempt int
} }
// NewBackoff creates a new Backoff instance with a seeded random number generator. // NewBackoff creates a new Backoff instance with a seeded random number generator.
@@ -53,7 +69,7 @@ func (b *Backoff) Next() time.Duration {
// Add jitter (±10%) // Add jitter (±10%)
jitter := b.calculateJitter(delay) jitter := b.calculateJitter(delay)
delay = delay + jitter delay += jitter
b.attempt++ b.attempt++
return delay return delay
+279 -166
View File
@@ -1,3 +1,22 @@
// Package ui provides the terminal user interface for kportal using bubbletea.
// It displays port-forward status in an interactive table and provides wizards
// for adding, editing, and removing forwards.
//
// The main components are:
// - BubbleTeaUI: The interactive TUI with table display and modal dialogs
// - TableUI: A simpler non-interactive status display for verbose mode
// - Wizards: Step-by-step interfaces for configuration changes
// - Controller: Coordinates UI with the forward manager
//
// Key bindings in the main view:
// - ↑↓/jk: Navigate forwards
// - Space: Toggle forward enabled/disabled
// - n: New forward wizard
// - e: Edit forward wizard
// - d: Delete forward
// - b: Benchmark forward
// - l: View HTTP logs
// - q: Quit
package ui package ui
import ( import (
@@ -35,8 +54,8 @@ type ForwardErrorMsg struct {
// ForwardAddMsg is sent when a new forward is added // ForwardAddMsg is sent when a new forward is added
type ForwardAddMsg struct { type ForwardAddMsg struct {
ID string
Forward *ForwardStatus Forward *ForwardStatus
ID string
} }
// ForwardRemoveMsg is sent when a forward is removed // ForwardRemoveMsg is sent when a forward is removed
@@ -50,48 +69,32 @@ type HTTPLogSubscriber func(forwardID string, callback func(entry HTTPLogEntry))
// BubbleTeaUI is a bubbletea-based terminal UI // BubbleTeaUI is a bubbletea-based terminal UI
type BubbleTeaUI struct { type BubbleTeaUI struct {
mu sync.RWMutex discovery *k8s.Discovery
program *tea.Program program *tea.Program
forwards map[string]*ForwardStatus forwards map[string]*ForwardStatus
forwardOrder []string benchmarkState *BenchmarkState
selectedIndex int httpLogSubscriber HTTPLogSubscriber
disabledMap map[string]bool disabledMap map[string]bool
toggleCallback func(id string, enable bool) toggleCallback func(id string, enable bool)
version string httpLogCleanup func()
errors map[string]string // Track error messages by forward ID httpLogState *HTTPLogState
errors map[string]string
// Update notification mutator *config.Mutator
updateAvailable bool removeWizard *RemoveWizardState
addWizard *AddWizardState
updateVersion string updateVersion string
updateURL string updateURL string
configPath string
// Modal wizard state
viewMode ViewMode
addWizard *AddWizardState
removeWizard *RemoveWizardState
// Delete confirmation state
deleteConfirming bool
deleteConfirmID string deleteConfirmID string
deleteConfirmAlias string deleteConfirmAlias string
deleteConfirmCursor int // 0 = Yes, 1 = No version string
forwardOrder []string
// Benchmark state viewMode ViewMode
benchmarkState *BenchmarkState deleteConfirmCursor int
selectedIndex int
// HTTP log viewing state mu sync.RWMutex
httpLogState *HTTPLogState deleteConfirming bool
updateAvailable bool
// Log callback cleanup function
httpLogCleanup func()
// Dependencies for wizards
discovery *k8s.Discovery
mutator *config.Mutator
configPath string
// Manager for accessing workers
httpLogSubscriber HTTPLogSubscriber
} }
// bubbletea model // bubbletea model
@@ -168,6 +171,8 @@ func (ui *BubbleTeaUI) AddForward(id string, fwd *config.Forward) {
if existing, ok := ui.forwards[id]; ok { if existing, ok := ui.forwards[id]; ok {
existing.Status = "Starting" existing.Status = "Starting"
ui.disabledMap[id] = false ui.disabledMap[id] = false
// Clear any previous error when re-enabling
delete(ui.errors, id)
ui.mu.Unlock() ui.mu.Unlock()
if ui.program != nil { if ui.program != nil {
@@ -176,15 +181,12 @@ func (ui *BubbleTeaUI) AddForward(id string, fwd *config.Forward) {
return return
} }
// Parse resource // Parse resource (e.g., "pod/my-app" -> type="pod", name="my-app")
resourceType := "pod" resourceType := "pod"
resourceName := fwd.Resource resourceName := fwd.Resource
for idx := 0; idx < len(fwd.Resource); idx++ { if parts := strings.SplitN(fwd.Resource, "/", 2); len(parts) == 2 {
if fwd.Resource[idx] == '/' { resourceType = parts[0]
resourceType = fwd.Resource[:idx] resourceName = parts[1]
resourceName = fwd.Resource[idx+1:]
break
}
} }
alias := fwd.Alias alias := fwd.Alias
@@ -380,10 +382,10 @@ func (m model) View() string {
// Fallback to reasonable defaults if dimensions not yet received // Fallback to reasonable defaults if dimensions not yet received
if termWidth == 0 { if termWidth == 0 {
termWidth = 120 termWidth = DefaultTermWidth
} }
if termHeight == 0 { if termHeight == 0 {
termHeight = 40 termHeight = DefaultTermHeight
} }
// Overlay delete confirmation if active // Overlay delete confirmation if active
@@ -411,28 +413,98 @@ func (m model) View() string {
} }
} }
// mainViewColors holds the color palette for the main view
type mainViewColors struct {
header lipgloss.Color
active lipgloss.Color
warning lipgloss.Color
errorColor lipgloss.Color
muted lipgloss.Color
selectedBg lipgloss.Color
selectedFg lipgloss.Color
}
// defaultMainViewColors returns the default color palette
func defaultMainViewColors() mainViewColors {
return mainViewColors{
header: lipgloss.Color("220"), // Yellow
active: lipgloss.Color("46"), // Green
warning: lipgloss.Color("220"), // Yellow
errorColor: lipgloss.Color("196"), // Red
muted: lipgloss.Color("240"), // Gray
selectedBg: lipgloss.Color("240"), // Gray background
selectedFg: lipgloss.Color("230"), // Light foreground
}
}
// keyBinding represents a keyboard shortcut and its description
type keyBinding struct {
key string
desc string
}
// mainViewKeyBindings returns the key bindings for the main view
func mainViewKeyBindings() []keyBinding {
return []keyBinding{
{"↑↓/jk", "Navigate"},
{"Space", "Toggle"},
{"n", "New"},
{"e", "Edit"},
{"d", "Delete"},
{"b", "Bench"},
{"l", "Logs"},
{"q", "Quit"},
}
}
func (m model) renderMainView() string { func (m model) renderMainView() string {
m.ui.mu.RLock() m.ui.mu.RLock()
defer m.ui.mu.RUnlock() defer m.ui.mu.RUnlock()
var b strings.Builder var b strings.Builder
colors := defaultMainViewColors()
// Get terminal dimensions for proper sizing // Get terminal dimensions for proper sizing
termHeight := m.termHeight termWidth, termHeight := m.getTermDimensions()
if termHeight == 0 {
termHeight = 40 // Fallback // Render title header
b.WriteString(m.renderTitle(colors.header))
// Render forwards table or empty message
if len(m.ui.forwardOrder) == 0 {
b.WriteString(m.renderEmptyMessage(colors.muted))
} else {
b.WriteString(m.renderForwardsTable(colors))
} }
// Color palette // Render error section if any errors exist
headerColor := lipgloss.Color("220") // Yellow if len(m.ui.errors) > 0 {
activeColor := lipgloss.Color("46") // Green b.WriteString(m.renderErrorSection())
warningColor := lipgloss.Color("220") // Yellow }
errorColor := lipgloss.Color("196") // Red
mutedColor := lipgloss.Color("240") // Gray // Render footer with proper spacing
selectedBg := lipgloss.Color("240") // Gray background b.WriteString(m.renderFooterWithSpacing(termWidth, termHeight, &b))
selectedFg := lipgloss.Color("230") // Light foreground
return b.String()
}
// getTermDimensions returns terminal dimensions with fallback defaults
func (m model) getTermDimensions() (width, height int) {
width = m.termWidth
height = m.termHeight
if width == 0 {
width = DefaultTermWidth
}
if height == 0 {
height = DefaultTermHeight
}
return
}
// renderTitle renders the title bar with version and optional update notification
func (m model) renderTitle(headerColor lipgloss.Color) string {
var b strings.Builder
// Title with version
titleStyle := lipgloss.NewStyle(). titleStyle := lipgloss.NewStyle().
Bold(true). Bold(true).
Foreground(headerColor). Foreground(headerColor).
@@ -451,108 +523,135 @@ func (m model) renderMainView() string {
} }
b.WriteString("\n\n") b.WriteString("\n\n")
// No forwards return b.String()
if len(m.ui.forwardOrder) == 0 { }
// renderEmptyMessage renders the message shown when no forwards are configured
func (m model) renderEmptyMessage(mutedColor lipgloss.Color) string {
disabledStyle := lipgloss.NewStyle().Foreground(mutedColor) disabledStyle := lipgloss.NewStyle().Foreground(mutedColor)
b.WriteString(disabledStyle.Render("No forwards configured\n")) return disabledStyle.Render("No forwards configured\n")
} else { }
// renderForwardsTable renders the forwards table with all styling
func (m model) renderForwardsTable(colors mainViewColors) string {
var b strings.Builder
// Build table rows // Build table rows
var rows [][]string rows := m.buildTableRows()
for _, id := range m.ui.forwardOrder {
fwd, ok := m.ui.forwards[id]
if !ok {
continue
}
isDisabled := m.ui.disabledMap[id] || fwd.Status == "Disabled"
// Status icon and text
statusIcon := "●"
statusText := fwd.Status
if isDisabled {
statusIcon = "○"
statusText = "Disabled"
} else {
switch fwd.Status {
case "Starting":
statusIcon = "○"
case "Reconnecting":
statusIcon = "◐"
case "Error":
statusIcon = "✗"
}
}
rows = append(rows, []string{
truncate(fwd.Context, 14),
truncate(fwd.Namespace, 16),
truncate(fwd.Alias, 18),
truncate(fwd.Type, 8),
truncate(fwd.Resource, 20),
fmt.Sprintf("%d", fwd.RemotePort),
fmt.Sprintf("%d", fwd.LocalPort),
statusIcon + " " + statusText,
})
}
// Create table with styling (no borders for cleaner look) // Create table with styling (no borders for cleaner look)
t := table.New(). t := table.New().
Border(lipgloss.HiddenBorder()). Border(lipgloss.HiddenBorder()).
Headers("CONTEXT", "NAMESPACE", "ALIAS", "TYPE", "RESOURCE", "REMOTE", "LOCAL", "STATUS"). Headers("CONTEXT", "NAMESPACE", "ALIAS", "TYPE", "RESOURCE", "REMOTE", "LOCAL", "STATUS").
Rows(rows...). Rows(rows...).
StyleFunc(func(row, col int) lipgloss.Style { StyleFunc(m.createTableStyleFunc(colors))
b.WriteString(t.Render())
b.WriteString("\n")
return b.String()
}
// buildTableRows builds the data rows for the forwards table
func (m model) buildTableRows() [][]string {
var rows [][]string
for _, id := range m.ui.forwardOrder {
fwd, ok := m.ui.forwards[id]
if !ok {
continue
}
statusIcon, statusText := m.getStatusIconAndText(id, fwd)
rows = append(rows, []string{
truncate(fwd.Context, ColumnWidthContext),
truncate(fwd.Namespace, ColumnWidthNamespace),
truncate(fwd.Alias, ColumnWidthAlias),
truncate(fwd.Type, ColumnWidthType),
truncate(fwd.Resource, ColumnWidthResource),
fmt.Sprintf("%d", fwd.RemotePort),
fmt.Sprintf("%d", fwd.LocalPort),
statusIcon + " " + statusText,
})
}
return rows
}
// getStatusIconAndText returns the appropriate status icon and text for a forward
func (m model) getStatusIconAndText(id string, fwd *ForwardStatus) (icon, text string) {
icon = "●"
text = fwd.Status
if m.ui.isForwardDisabled(id) {
return "○", "Disabled"
}
switch fwd.Status {
case "Starting":
icon = "○"
case "Reconnecting":
icon = "◐"
case "Error":
icon = "✗"
}
return icon, text
}
// createTableStyleFunc creates the style function for the forwards table
func (m model) createTableStyleFunc(colors mainViewColors) func(row, col int) lipgloss.Style {
return func(row, col int) lipgloss.Style {
// Header row // Header row
if row == table.HeaderRow { if row == table.HeaderRow {
return lipgloss.NewStyle(). return lipgloss.NewStyle().
Bold(true). Bold(true).
Foreground(headerColor). Foreground(colors.header).
Padding(0, 1) Padding(0, 1)
} }
// Get the forward for this row to check its status
baseStyle := lipgloss.NewStyle().Padding(0, 1) baseStyle := lipgloss.NewStyle().Padding(0, 1)
if row >= 0 && row < len(m.ui.forwardOrder) { if row >= 0 && row < len(m.ui.forwardOrder) {
id := m.ui.forwardOrder[row] id := m.ui.forwardOrder[row]
fwd, ok := m.ui.forwards[id] fwd, ok := m.ui.forwards[id]
isSelected := row == m.ui.selectedIndex isSelected := row == m.ui.selectedIndex
isDisabled := m.ui.disabledMap[id] || (ok && fwd.Status == "Disabled") isDisabled := m.ui.isForwardDisabled(id)
// Selected row gets background highlight // Selected row gets background highlight
if isSelected { if isSelected {
return baseStyle. return baseStyle.
Background(selectedBg). Background(colors.selectedBg).
Foreground(selectedFg) Foreground(colors.selectedFg)
} }
// Disabled rows are muted // Disabled rows are muted
if isDisabled { if isDisabled {
return baseStyle.Foreground(mutedColor) return baseStyle.Foreground(colors.muted)
} }
// Status column gets colored based on status // Status column gets colored based on status
if col == 7 && ok { // STATUS column if col == ColumnStatus && ok {
switch fwd.Status { switch fwd.Status {
case "Active": case "Active":
return baseStyle.Foreground(activeColor) return baseStyle.Foreground(colors.active)
case "Starting", "Reconnecting": case "Starting", "Reconnecting":
return baseStyle.Foreground(warningColor) return baseStyle.Foreground(colors.warning)
case "Error": case "Error":
return baseStyle.Foreground(errorColor) return baseStyle.Foreground(colors.errorColor)
} }
} }
} }
return baseStyle return baseStyle
}) }
b.WriteString(t.Render())
b.WriteString("\n")
} }
// Display errors if any (before footer) // renderErrorSection renders the error display section
if len(m.ui.errors) > 0 { func (m model) renderErrorSection() string {
var b strings.Builder
b.WriteString("\n\n") b.WriteString("\n\n")
errorHeaderStyle := lipgloss.NewStyle(). errorHeaderStyle := lipgloss.NewStyle().
Bold(true). Bold(true).
@@ -563,68 +662,83 @@ func (m model) renderMainView() string {
errorLineStyle := lipgloss.NewStyle(). errorLineStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("196")). Foreground(lipgloss.Color("196")).
Width(118). // Slightly less than table width (120) for padding Width(ErrorDisplayWidth).
MaxWidth(118) MaxWidth(ErrorDisplayWidth)
for id, errMsg := range m.ui.errors { for id, errMsg := range m.ui.errors {
// Find the forward to display its alias // Find the forward to display its alias
if fwd, ok := m.ui.forwards[id]; ok { if fwd, ok := m.ui.forwards[id]; ok {
b.WriteString(m.renderErrorLine(fwd.Alias, errMsg, errorLineStyle))
}
}
return b.String()
}
// renderErrorLine renders a single error line with proper wrapping
func (m model) renderErrorLine(alias, errMsg string, style lipgloss.Style) string {
var b strings.Builder
// Format: " • alias: error message" // Format: " • alias: error message"
prefix := fmt.Sprintf(" • %s: ", fwd.Alias) prefix := fmt.Sprintf(" • %s: ", alias)
// Wrap the error message if it's too long // Wrap the error message if it's too long
// Max line length is 118, subtract prefix length maxErrLen := ErrorDisplayWidth - len(prefix)
maxErrLen := 118 - len(prefix)
wrappedMsg := wrapText(errMsg, maxErrLen) wrappedMsg := wrapText(errMsg, maxErrLen)
// Render first line with prefix // Render first line with prefix
lines := strings.Split(wrappedMsg, "\n") lines := strings.Split(wrappedMsg, "\n")
if len(lines) > 0 { if len(lines) > 0 {
b.WriteString(errorLineStyle.Render(prefix + lines[0])) b.WriteString(style.Render(prefix + lines[0]))
b.WriteString("\n") b.WriteString("\n")
// Render subsequent lines with indentation // Render subsequent lines with indentation
indent := strings.Repeat(" ", len(prefix)) indent := strings.Repeat(" ", len(prefix))
for i := 1; i < len(lines); i++ { for i := 1; i < len(lines); i++ {
b.WriteString(errorLineStyle.Render(indent + lines[i])) b.WriteString(style.Render(indent + lines[i]))
b.WriteString("\n") b.WriteString("\n")
} }
} }
return b.String()
} }
}
} // renderFooterWithSpacing renders the footer with proper vertical spacing
func (m model) renderFooterWithSpacing(termWidth, termHeight int, content *strings.Builder) string {
var b strings.Builder
// Calculate current content height // Calculate current content height
currentContent := b.String() currentContent := content.String()
currentLines := strings.Count(currentContent, "\n") + 1 currentLines := strings.Count(currentContent, "\n") + 1
// Footer styles // Build footer content
footerLines := m.buildFooterLines(termWidth)
// Calculate footer height and add spacing
footerHeight := len(footerLines) + 1 // +1 for the blank line before footer
remainingLines := termHeight - currentLines - footerHeight
if remainingLines > 0 {
b.WriteString(strings.Repeat("\n", remainingLines))
}
// Add footer at bottom
footerStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("240")) footerStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
b.WriteString("\n")
for i, line := range footerLines {
if i > 0 {
b.WriteString("\n")
}
b.WriteString(footerStyle.Render(line))
}
return b.String()
}
// buildFooterLines builds the footer lines that fit within terminal width
func (m model) buildFooterLines(termWidth int) []string {
keyStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("220")) keyStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("220"))
bindings := mainViewKeyBindings()
// Get terminal width for footer wrapping
termWidth := m.termWidth
if termWidth == 0 {
termWidth = 120
}
// Define key bindings as structured data for flexible rendering
type keyBinding struct {
key string
desc string
}
bindings := []keyBinding{
{"↑↓/jk", "Navigate"},
{"Space", "Toggle"},
{"n", "New"},
{"e", "Edit"},
{"d", "Delete"},
{"b", "Bench"},
{"l", "Logs"},
{"q", "Quit"},
}
// Build footer lines that fit within terminal width
var footerLines []string var footerLines []string
var currentLine strings.Builder var currentLine strings.Builder
currentLineVisualLen := 0 currentLineVisualLen := 0
@@ -676,23 +790,7 @@ func (m model) renderMainView() string {
currentLine.WriteString(totalSuffix) currentLine.WriteString(totalSuffix)
footerLines = append(footerLines, currentLine.String()) footerLines = append(footerLines, currentLine.String())
// Calculate footer height return footerLines
footerHeight := len(footerLines) + 1 // +1 for the blank line before footer
remainingLines := termHeight - currentLines - footerHeight
if remainingLines > 0 {
b.WriteString(strings.Repeat("\n", remainingLines))
}
// Add footer at bottom
b.WriteString("\n")
for i, line := range footerLines {
if i > 0 {
b.WriteString("\n")
}
b.WriteString(footerStyle.Render(line))
}
return b.String()
} }
// wrapText wraps text to the specified width, breaking at word boundaries // wrapText wraps text to the specified width, breaking at word boundaries
@@ -835,3 +933,18 @@ func (ui *BubbleTeaUI) toggleSelected() {
go ui.toggleCallback(selectedID, !newState) // enable is inverse of disabled go ui.toggleCallback(selectedID, !newState) // enable is inverse of disabled
} }
} }
// isForwardDisabled checks if a forward is disabled.
// A forward is considered disabled if either:
// 1. The user has disabled it via the UI (tracked in disabledMap)
// 2. The forward's status is "Disabled" (from the manager)
// Caller must hold ui.mu.RLock or ui.mu.Lock.
func (ui *BubbleTeaUI) isForwardDisabled(id string) bool {
if ui.disabledMap[id] {
return true
}
if fwd, ok := ui.forwards[id]; ok && fwd.Status == "Disabled" {
return true
}
return false
}
+254 -1
View File
@@ -243,9 +243,9 @@ func TestBubbleTeaUI_Remove_ClearsErrors(t *testing.T) {
func TestBubbleTeaUI_Remove_AdjustsSelectedIndex(t *testing.T) { func TestBubbleTeaUI_Remove_AdjustsSelectedIndex(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
removeID string
forwards []string forwards []string
selectedIndex int selectedIndex int
removeID string
expectedIndex int expectedIndex int
expectedRemaining int expectedRemaining int
}{ }{
@@ -527,3 +527,256 @@ func TestBubbleTeaUI_ResetDeleteConfirmation(t *testing.T) {
assert.Empty(t, ui.deleteConfirmAlias) assert.Empty(t, ui.deleteConfirmAlias)
assert.Equal(t, 0, ui.deleteConfirmCursor) assert.Equal(t, 0, ui.deleteConfirmCursor)
} }
// TestBubbleTeaUI_IsForwardDisabled tests the disabled state helper
func TestBubbleTeaUI_IsForwardDisabled(t *testing.T) {
tests := []struct {
name string
forwardStatus string
disabledMap bool
expectedResult bool
}{
{
name: "not disabled in map, Active status",
disabledMap: false,
forwardStatus: "Active",
expectedResult: false,
},
{
name: "disabled in map, Active status",
disabledMap: true,
forwardStatus: "Active",
expectedResult: true,
},
{
name: "not disabled in map, Disabled status",
disabledMap: false,
forwardStatus: "Disabled",
expectedResult: true,
},
{
name: "both disabled in map and Disabled status",
disabledMap: true,
forwardStatus: "Disabled",
expectedResult: true,
},
{
name: "not disabled in map, Error status",
disabledMap: false,
forwardStatus: "Error",
expectedResult: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
fwd := &config.Forward{
Resource: "pod/my-app",
Port: 8080,
LocalPort: 8080,
}
ui.AddForward("test-id", fwd)
ui.mu.Lock()
ui.disabledMap["test-id"] = tt.disabledMap
ui.forwards["test-id"].Status = tt.forwardStatus
ui.mu.Unlock()
ui.mu.RLock()
result := ui.isForwardDisabled("test-id")
ui.mu.RUnlock()
assert.Equal(t, tt.expectedResult, result)
})
}
}
// TestBubbleTeaUI_IsForwardDisabled_NonExistent tests disabled check for non-existent forward
func TestBubbleTeaUI_IsForwardDisabled_NonExistent(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
ui.mu.RLock()
result := ui.isForwardDisabled("non-existent")
ui.mu.RUnlock()
assert.False(t, result, "Non-existent forward should not be disabled")
}
// TestBubbleTeaUI_AddForward_ReEnableClearsError tests that re-enabling clears previous errors
func TestBubbleTeaUI_AddForward_ReEnableClearsError(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
fwd := &config.Forward{
Resource: "pod/my-app",
Port: 8080,
LocalPort: 8080,
}
// Add forward
ui.AddForward("test-id", fwd)
// Set error and disable
ui.SetError("test-id", "connection refused")
ui.mu.Lock()
ui.disabledMap["test-id"] = true
ui.forwards["test-id"].Status = "Disabled"
ui.mu.Unlock()
// Verify error exists
ui.mu.RLock()
_, hasError := ui.errors["test-id"]
ui.mu.RUnlock()
assert.True(t, hasError, "Error should exist before re-enable")
// Re-enable (re-add)
ui.AddForward("test-id", fwd)
// Verify error is cleared
ui.mu.RLock()
_, hasError = ui.errors["test-id"]
ui.mu.RUnlock()
assert.False(t, hasError, "Error should be cleared after re-enable")
}
// TestWrapText tests the text wrapping function
func TestWrapText(t *testing.T) {
tests := []struct {
name string
text string
expected string
width int
}{
{
name: "short text fits",
text: "hello world",
width: 20,
expected: "hello world",
},
{
name: "single long word",
text: "superlongwordthatexceedswidth",
width: 10,
expected: "superlongwordthatexceedswidth",
},
{
name: "wraps at word boundary",
text: "hello world this is a test",
width: 15,
expected: "hello world\nthis is a test",
},
{
name: "multiple wraps",
text: "one two three four five six",
width: 10,
expected: "one two\nthree four\nfive six",
},
{
name: "empty string",
text: "",
width: 10,
expected: "",
},
{
name: "single word",
text: "hello",
width: 10,
expected: "hello",
},
{
name: "exact width",
text: "hello wor",
width: 9,
expected: "hello wor",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := wrapText(tt.text, tt.width)
assert.Equal(t, tt.expected, result)
})
}
}
// TestBubbleTeaUI_AddForward_ResourceParsing tests various resource format parsing
func TestBubbleTeaUI_AddForward_ResourceParsing(t *testing.T) {
tests := []struct {
name string
resource string
expectedType string
expectedName string
}{
{
name: "pod with prefix",
resource: "pod/my-app",
expectedType: "pod",
expectedName: "my-app",
},
{
name: "service resource",
resource: "service/postgres",
expectedType: "service",
expectedName: "postgres",
},
{
name: "deployment resource",
resource: "deployment/api-server",
expectedType: "deployment",
expectedName: "api-server",
},
{
name: "no type prefix (pod default)",
resource: "my-pod",
expectedType: "pod",
expectedName: "my-pod",
},
{
name: "resource with multiple slashes",
resource: "custom/type/resource",
expectedType: "custom",
expectedName: "type/resource",
},
{
name: "empty resource",
resource: "",
expectedType: "pod",
expectedName: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
fwd := &config.Forward{
Resource: tt.resource,
Port: 8080,
LocalPort: 8080,
}
ui.AddForward("test-id", fwd)
ui.mu.RLock()
status := ui.forwards["test-id"]
ui.mu.RUnlock()
assert.Equal(t, tt.expectedType, status.Type)
assert.Equal(t, tt.expectedName, status.Resource)
})
}
}
// TestConstants tests that UI constants are properly defined
func TestConstants(t *testing.T) {
assert.Equal(t, 120, DefaultTermWidth)
assert.Equal(t, 40, DefaultTermHeight)
assert.Equal(t, 7, ColumnStatus)
assert.Equal(t, 14, ColumnWidthContext)
assert.Equal(t, 16, ColumnWidthNamespace)
assert.Equal(t, 18, ColumnWidthAlias)
assert.Equal(t, 8, ColumnWidthType)
assert.Equal(t, 20, ColumnWidthResource)
assert.Equal(t, 118, ErrorDisplayWidth)
assert.Equal(t, 20, ViewportHeight)
}
+6 -3
View File
@@ -82,13 +82,16 @@ func TestMessageTypes(t *testing.T) {
} }
assert.Equal(t, 8080, availableMsg.port) assert.Equal(t, 8080, availableMsg.port)
assert.True(t, availableMsg.available) assert.True(t, availableMsg.available)
assert.Equal(t, "Port 8080 available", availableMsg.message)
unavailableMsg := PortCheckedMsg{ unavailableMsg := PortCheckedMsg{
port: 8080, port: 8080,
available: false, available: false,
message: "Port 8080 in use by process", message: "Port 8080 in use by process",
} }
assert.Equal(t, 8080, unavailableMsg.port)
assert.False(t, unavailableMsg.available) assert.False(t, unavailableMsg.available)
assert.Equal(t, "Port 8080 in use by process", unavailableMsg.message)
}) })
t.Run("ForwardSavedMsg", func(t *testing.T) { t.Run("ForwardSavedMsg", func(t *testing.T) {
@@ -117,10 +120,10 @@ func TestMessageTypes(t *testing.T) {
t.Run("BenchmarkCompleteMsg", func(t *testing.T) { t.Run("BenchmarkCompleteMsg", func(t *testing.T) {
msg := BenchmarkCompleteMsg{ msg := BenchmarkCompleteMsg{
ForwardID: "fwd-123", ForwardID: "fwd-123",
Results: nil,
Error: nil,
} }
assert.Equal(t, "fwd-123", msg.ForwardID) assert.Equal(t, "fwd-123", msg.ForwardID)
assert.Nil(t, msg.Results)
assert.Nil(t, msg.Error)
}) })
t.Run("BenchmarkProgressMsg", func(t *testing.T) { t.Run("BenchmarkProgressMsg", func(t *testing.T) {
@@ -256,7 +259,7 @@ func TestRunBenchmarkCmd_Cancellation(t *testing.T) {
// Run with timeout to prevent hanging // Run with timeout to prevent hanging
done := make(chan bool, 1) done := make(chan bool, 1)
var msg interface{} var msg any
go func() { go func() {
msg = cmd() msg = cmd()
done <- true done <- true
+45
View File
@@ -0,0 +1,45 @@
package ui
// Terminal dimension constants
const (
// DefaultTermWidth is the fallback terminal width when not detected
DefaultTermWidth = 120
// DefaultTermHeight is the fallback terminal height when not detected
DefaultTermHeight = 40
)
// Table column constants
const (
// Column indices in the forwards table
ColumnContext = 0
ColumnNamespace = 1
ColumnAlias = 2
ColumnType = 3
ColumnResource = 4
ColumnRemote = 5
ColumnLocal = 6
ColumnStatus = 7
// Column widths for truncation
ColumnWidthContext = 14
ColumnWidthNamespace = 16
ColumnWidthAlias = 18
ColumnWidthType = 8
ColumnWidthResource = 20
// Error display widths
ErrorDisplayWidth = 118 // Slightly less than table width (120) for padding
)
// Viewport constants
const (
// ViewportHeight is the number of items visible in list views
ViewportHeight = 20
)
// Path display constants
const (
// MaxPathWidth is the maximum width for displaying file paths
MaxPathWidth = 48
)
+3 -3
View File
@@ -695,12 +695,12 @@ func TestHandleSelectorValidated(t *testing.T) {
func TestHandlePortChecked(t *testing.T) { func TestHandlePortChecked(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
available bool
expectStep AddWizardStep expectStep AddWizardStep
available bool
expectError bool expectError bool
}{ }{
{"port available", true, StepConfirmation, false}, {name: "port available", available: true, expectStep: StepConfirmation, expectError: false},
{"port in use", false, StepEnterLocalPort, true}, {name: "port in use", available: false, expectStep: StepEnterLocalPort, expectError: true},
} }
for _, tt := range tests { for _, tt := range tests {
+5 -5
View File
@@ -180,13 +180,13 @@ func TestHTTPLogState_GetFilterModeLabel(t *testing.T) {
state := newHTTPLogState("fwd", "alias") state := newHTTPLogState("fwd", "alias")
tests := []struct { tests := []struct {
mode HTTPLogFilterMode
expected string expected string
mode HTTPLogFilterMode
}{ }{
{HTTPLogFilterNone, "All"}, {mode: HTTPLogFilterNone, expected: "All"},
{HTTPLogFilterText, "Text"}, {mode: HTTPLogFilterText, expected: "Text"},
{HTTPLogFilterNon200, "Non-2xx"}, {mode: HTTPLogFilterNon200, expected: "Non-2xx"},
{HTTPLogFilterErrors, "Errors (4xx/5xx)"}, {mode: HTTPLogFilterErrors, expected: "Errors (4xx/5xx)"},
} }
for _, tt := range tests { for _, tt := range tests {
+27 -47
View File
@@ -10,36 +10,28 @@ import (
// MockDiscovery is a mock implementation of DiscoveryInterface for testing // MockDiscovery is a mock implementation of DiscoveryInterface for testing
type MockDiscovery struct { type MockDiscovery struct {
mu sync.Mutex ListPodsErr error
ListServicesErr error
// Return values ListPodsWithSelectorErr error
Contexts []string
CurrentContext string
Namespaces []string
Pods []k8s.PodInfo
PodsWithSelector []k8s.PodInfo
Services []k8s.ServiceInfo
// Errors to return
ListContextsErr error ListContextsErr error
GetCurrentContextErr error GetCurrentContextErr error
ListNamespacesErr error ListNamespacesErr error
ListPodsErr error LastSelector string
ListPodsWithSelectorErr error CurrentContext string
ListServicesErr error LastNamespace string
LastContextName string
// Call tracking PodsWithSelector []k8s.PodInfo
Services []k8s.ServiceInfo
Pods []k8s.PodInfo
Namespaces []string
Contexts []string
ListContextsCalls int ListContextsCalls int
GetCurrentContextCalls int GetCurrentContextCalls int
ListNamespacesCalls int ListNamespacesCalls int
ListPodsCalls int ListPodsCalls int
ListPodsWithSelectorCalls int ListPodsWithSelectorCalls int
ListServicesCalls int ListServicesCalls int
mu sync.Mutex
// Captured arguments
LastContextName string
LastNamespace string
LastSelector string
} }
func NewMockDiscovery() *MockDiscovery { func NewMockDiscovery() *MockDiscovery {
@@ -104,34 +96,26 @@ func (m *MockDiscovery) ListServices(ctx context.Context, contextName, namespace
// MockMutator is a mock implementation of MutatorInterface for testing // MockMutator is a mock implementation of MutatorInterface for testing
type MockMutator struct { type MockMutator struct {
mu sync.Mutex
// Errors to return
AddForwardErr error
RemoveForwardsErr error
RemoveForwardByIDErr error RemoveForwardByIDErr error
UpdateForwardErr error UpdateForwardErr error
AddForwardErr error
// Call tracking RemoveForwardsErr error
AddForwardCalls int
RemoveForwardsCalls int
RemoveForwardByIDCalls int
UpdateForwardCalls int
// Captured arguments
LastContextName string
LastNamespaceName string
LastForward config.Forward
LastOldID string
LastRemovedID string
LastPredicate func(ctx, ns string, fwd config.Forward) bool LastPredicate func(ctx, ns string, fwd config.Forward) bool
LastContextName string
// Storage for testing LastOldID string
LastNamespaceName string
LastRemovedID string
Forwards []struct { Forwards []struct {
Context string Context string
Namespace string Namespace string
Forward config.Forward Forward config.Forward
} }
LastForward config.Forward
RemoveForwardByIDCalls int
UpdateForwardCalls int
RemoveForwardsCalls int
AddForwardCalls int
mu sync.Mutex
} }
func NewMockMutator() *MockMutator { func NewMockMutator() *MockMutator {
@@ -186,13 +170,9 @@ func (m *MockMutator) UpdateForward(oldID, newContextName, newNamespaceName stri
// MockHTTPLogSubscriber is a mock for HTTP log subscription // MockHTTPLogSubscriber is a mock for HTTP log subscription
type MockHTTPLogSubscriber struct { type MockHTTPLogSubscriber struct {
mu sync.Mutex
// Subscription tracking
Subscriptions map[string]func(HTTPLogEntry) Subscriptions map[string]func(HTTPLogEntry)
CleanupCalls int CleanupCalls int
mu sync.Mutex
// Control
ShouldFail bool ShouldFail bool
} }
@@ -237,11 +217,11 @@ func (m *MockHTTPLogSubscriber) GetSubscriberFunc() HTTPLogSubscriber {
// MockToggleCallback tracks toggle callback invocations // MockToggleCallback tracks toggle callback invocations
type MockToggleCallback struct { type MockToggleCallback struct {
mu sync.Mutex
Calls []struct { Calls []struct {
ID string ID string
Enable bool Enable bool
} }
mu sync.Mutex
} }
func NewMockToggleCallback() *MockToggleCallback { func NewMockToggleCallback() *MockToggleCallback {
+6 -6
View File
@@ -14,17 +14,17 @@ type ForwardStatus struct {
Context string Context string
Namespace string Namespace string
Alias string Alias string
Type string // "service", "pod", etc. Type string
Resource string // name without type prefix Resource string
Status string
RemotePort int RemotePort int
LocalPort int LocalPort int
Status string // "Starting", "Active", "Reconnecting", "Error"
} }
// TableUI manages the terminal table display // TableUI manages the terminal table display
type TableUI struct { type TableUI struct {
forwards map[string]*ForwardStatus
mu sync.RWMutex mu sync.RWMutex
forwards map[string]*ForwardStatus // key is forward ID
verbose bool verbose bool
} }
@@ -101,12 +101,12 @@ func (t *TableUI) Render() {
// Sort forwards by local port for consistent display // Sort forwards by local port for consistent display
type sortEntry struct { type sortEntry struct {
id string
fwd *ForwardStatus fwd *ForwardStatus
id string
} }
var entries []sortEntry var entries []sortEntry
for id, fwd := range t.forwards { for id, fwd := range t.forwards {
entries = append(entries, sortEntry{id, fwd}) entries = append(entries, sortEntry{fwd: fwd, id: id})
} }
// Simple sort by local port // Simple sort by local port
+14 -13
View File
@@ -9,6 +9,7 @@ import (
"github.com/nvm/kportal/internal/benchmark" "github.com/nvm/kportal/internal/benchmark"
"github.com/nvm/kportal/internal/config" "github.com/nvm/kportal/internal/config"
"github.com/nvm/kportal/internal/k8s" "github.com/nvm/kportal/internal/k8s"
"github.com/nvm/kportal/internal/logger"
) )
const ( const (
@@ -19,53 +20,53 @@ const (
// ContextsLoadedMsg is sent when contexts have been loaded // ContextsLoadedMsg is sent when contexts have been loaded
type ContextsLoadedMsg struct { type ContextsLoadedMsg struct {
contexts []string
err error err error
contexts []string
} }
// NamespacesLoadedMsg is sent when namespaces have been loaded // NamespacesLoadedMsg is sent when namespaces have been loaded
type NamespacesLoadedMsg struct { type NamespacesLoadedMsg struct {
namespaces []string
err error err error
namespaces []string
} }
// PodsLoadedMsg is sent when pods have been loaded // PodsLoadedMsg is sent when pods have been loaded
type PodsLoadedMsg struct { type PodsLoadedMsg struct {
pods []k8s.PodInfo
err error err error
pods []k8s.PodInfo
} }
// ServicesLoadedMsg is sent when services have been loaded // ServicesLoadedMsg is sent when services have been loaded
type ServicesLoadedMsg struct { type ServicesLoadedMsg struct {
services []k8s.ServiceInfo
err error err error
services []k8s.ServiceInfo
} }
// SelectorValidatedMsg is sent when a selector has been validated // SelectorValidatedMsg is sent when a selector has been validated
type SelectorValidatedMsg struct { type SelectorValidatedMsg struct {
valid bool
pods []k8s.PodInfo
err error err error
pods []k8s.PodInfo
valid bool
} }
// PortCheckedMsg is sent when a port's availability has been checked // PortCheckedMsg is sent when a port's availability has been checked
type PortCheckedMsg struct { type PortCheckedMsg struct {
message string
port int port int
available bool available bool
message string
} }
// ForwardSavedMsg is sent when a forward has been saved to config // ForwardSavedMsg is sent when a forward has been saved to config
type ForwardSavedMsg struct { type ForwardSavedMsg struct {
success bool
err error err error
success bool
} }
// ForwardsRemovedMsg is sent when forwards have been removed from config // ForwardsRemovedMsg is sent when forwards have been removed from config
type ForwardsRemovedMsg struct { type ForwardsRemovedMsg struct {
success bool
count int
err error err error
count int
success bool
} }
// WizardCompleteMsg signals that the wizard has completed // WizardCompleteMsg signals that the wizard has completed
@@ -241,9 +242,9 @@ func removeForwardByIDCmd(mutator *config.Mutator, id string) tea.Cmd {
// BenchmarkCompleteMsg is sent when a benchmark run completes // BenchmarkCompleteMsg is sent when a benchmark run completes
type BenchmarkCompleteMsg struct { type BenchmarkCompleteMsg struct {
ForwardID string
Results *benchmark.Results
Error error Error error
Results *benchmark.Results
ForwardID string
} }
// BenchmarkProgressMsg is sent periodically during benchmark execution // BenchmarkProgressMsg is sent periodically during benchmark execution
@@ -291,7 +292,7 @@ func runBenchmarkCmd(ctx context.Context, forwardID string, localPort int, urlPa
// Recover from panics in the callback // Recover from panics in the callback
defer func() { defer func() {
if r := recover(); r != nil { if r := recover(); r != nil {
// Silently recover - progress callback failure shouldn't crash the benchmark logger.Debug("recovered from panic in progress callback", map[string]any{"panic": r})
} }
}() }()
// Non-blocking send to progress channel // Non-blocking send to progress channel
+2 -2
View File
@@ -86,10 +86,10 @@ func TestWizardMutualExclusion_HTTPLogBlocksOthers(t *testing.T) {
// TestWizardMutualExclusion_CheckActiveModal tests the modal activity check logic // TestWizardMutualExclusion_CheckActiveModal tests the modal activity check logic
func TestWizardMutualExclusion_CheckActiveModal(t *testing.T) { func TestWizardMutualExclusion_CheckActiveModal(t *testing.T) {
tests := []struct { tests := []struct {
name string
setupFunc func(*BubbleTeaUI) setupFunc func(*BubbleTeaUI)
expectActive bool name string
activeModalStr string activeModalStr string
expectActive bool
}{ }{
{ {
name: "no modal active", name: "no modal active",
+51 -75
View File
@@ -109,45 +109,33 @@ func (r ResourceType) Description() string {
// AddWizardState maintains the state for the add port forward wizard // AddWizardState maintains the state for the add port forward wizard
type AddWizardState struct { type AddWizardState struct {
step AddWizardStep
inputMode InputMode
cursor int
scrollOffset int // For scrolling long lists
textInput string
searchFilter string // For filtering lists (contexts, namespaces, services)
loading bool
error error error error
resourceValue string
// Selections made by user originalID string
portCheckMsg string
alias string
textInput string
searchFilter string
selector string
selectedContext string selectedContext string
selectedNamespace 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 pods []k8s.PodInfo
services []k8s.ServiceInfo contexts []string
// 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 detectedPorts []k8s.PortInfo
matchingPods []k8s.PodInfo
// Confirmation focus (alias field vs buttons) services []k8s.ServiceInfo
namespaces []string
scrollOffset int
selectedResourceType ResourceType
step AddWizardStep
localPort int
cursor int
remotePort int
inputMode InputMode
confirmationFocus ConfirmationFocus confirmationFocus ConfirmationFocus
portAvailable bool
isEditing bool
loading bool
} }
// newAddWizardState creates a new add wizard state initialized to the first step // newAddWizardState creates a new add wizard state initialized to the first step
@@ -239,11 +227,11 @@ func (w *AddWizardState) clearTextInput() {
// RemoveWizardState maintains the state for the remove port forward wizard // RemoveWizardState maintains the state for the remove port forward wizard
type RemoveWizardState struct { type RemoveWizardState struct {
selected map[int]bool
forwards []RemovableForward forwards []RemovableForward
cursor int cursor int
selected map[int]bool confirmCursor int
confirming bool confirming bool
confirmCursor int // 0 = Yes, 1 = No
} }
// RemovableForward represents a forward that can be removed // RemovableForward represents a forward that can be removed
@@ -387,45 +375,39 @@ const (
// BenchmarkState maintains the state for the benchmark wizard // BenchmarkState maintains the state for the benchmark wizard
type BenchmarkState struct { type BenchmarkState struct {
step BenchmarkStep error error
results *BenchmarkResults
cancelFunc func()
progressCh chan BenchmarkProgressMsg
textInput string
forwardID string forwardID string
forwardAlias string forwardAlias string
localPort int
// Configuration
urlPath string urlPath string
method string method string
concurrency int cursor int
requests int
cursor int // Current field being edited
textInput string
// Running state
running bool
progress int progress int
total int total int
progressCh chan BenchmarkProgressMsg // Channel for progress updates step BenchmarkStep
cancelFunc func() // Function to cancel the running benchmark requests int
concurrency int
// Results localPort int
results *BenchmarkResults running bool
error error
} }
// BenchmarkResults holds benchmark results for display // BenchmarkResults holds benchmark results for display
type BenchmarkResults struct { type BenchmarkResults struct {
StatusCodes map[int]int
TotalRequests int TotalRequests int
Successful int Successful int
Failed int Failed int
MinLatency float64 // milliseconds MinLatency float64
MaxLatency float64 MaxLatency float64
AvgLatency float64 AvgLatency float64
P50Latency float64 P50Latency float64
P95Latency float64 P95Latency float64
P99Latency float64 P99Latency float64
Throughput float64 // requests per second Throughput float64
BytesRead int64 BytesRead int64
StatusCodes map[int]int
} }
// newBenchmarkState creates a new benchmark state for a forward // newBenchmarkState creates a new benchmark state for a forward
@@ -457,39 +439,33 @@ const (
type HTTPLogState struct { type HTTPLogState struct {
forwardID string forwardID string
forwardAlias string forwardAlias string
filterText string
copyMessage string
entries []HTTPLogEntry entries []HTTPLogEntry
cursor int cursor int
scrollOffset int scrollOffset int
autoScroll bool
// Filtering
filterMode HTTPLogFilterMode filterMode HTTPLogFilterMode
filterText string detailScroll int
filterActive bool // true when typing in filter input autoScroll bool
filterActive bool
// Detail view showingDetail bool
showingDetail bool // true when viewing full entry details
detailScroll int // scroll position in detail view
copyMessage string // temporary message after copying (e.g., "Copied!")
} }
// HTTPLogEntry represents a single HTTP log entry for display // HTTPLogEntry represents a single HTTP log entry for display
type HTTPLogEntry struct { type HTTPLogEntry struct {
RequestID string // Used to match request/response pairs
Timestamp string
Direction string
Method string
Path string
StatusCode int
LatencyMs int64
BodySize int
// Detail fields - for viewing full request/response
RequestHeaders map[string]string RequestHeaders map[string]string
ResponseHeaders map[string]string ResponseHeaders map[string]string
Method string
RequestID string
Path string
Direction string
Timestamp string
RequestBody string RequestBody string
ResponseBody string ResponseBody string
Error string Error string
StatusCode int
LatencyMs int64
BodySize int
} }
// newHTTPLogState creates a new HTTP log viewing state // newHTTPLogState creates a new HTTP log viewing state
+2 -2
View File
@@ -285,10 +285,10 @@ func TestClearSearchFilter(t *testing.T) {
func TestMoveCursorWithFilteredLists(t *testing.T) { func TestMoveCursorWithFilteredLists(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
step AddWizardStep searchFilter string
contexts []string contexts []string
namespaces []string namespaces []string
searchFilter string step AddWizardStep
initialCursor int initialCursor int
delta int delta int
expectedCursor int expectedCursor int
+1 -2
View File
@@ -143,7 +143,6 @@ func renderBreadcrumb(parts ...string) string {
func renderList(items []string, cursor int, prefix string, scrollOffset int) string { func renderList(items []string, cursor int, prefix string, scrollOffset int) string {
var b strings.Builder var b strings.Builder
const viewportHeight = 20
totalItems := len(items) totalItems := len(items)
// Show scroll up indicator if there are items above the viewport // Show scroll up indicator if there are items above the viewport
@@ -153,7 +152,7 @@ func renderList(items []string, cursor int, prefix string, scrollOffset int) str
// Calculate visible range // Calculate visible range
start := scrollOffset start := scrollOffset
end := scrollOffset + viewportHeight end := scrollOffset + ViewportHeight
if end > totalItems { if end > totalItems {
end = totalItems end = totalItems
} }
+13 -1
View File
@@ -1,3 +1,15 @@
// Package version provides version checking against GitHub releases.
// It queries the GitHub API to check for newer versions of kportal
// and provides update notifications.
//
// Basic usage:
//
// info, err := version.CheckForUpdate(ctx, "owner", "repo", "v1.0.0")
// if err != nil {
// log.Printf("Version check failed: %v", err)
// } else if info.UpdateAvailable {
// fmt.Printf("Update available: %s -> %s\n", info.CurrentVersion, info.LatestVersion)
// }
package version package version
import ( import (
@@ -33,10 +45,10 @@ type UpdateInfo struct {
// Checker checks for new versions on GitHub // Checker checks for new versions on GitHub
type Checker struct { type Checker struct {
client *http.Client
owner string owner string
repo string repo string
current string current string
client *http.Client
} }
// NewChecker creates a new version checker // NewChecker creates a new version checker