Compare commits

...

15 Commits

Author SHA1 Message Date
lukaszraczylo 676fd3df39 Update go.mod and go.sum (#35) 2026-01-26 03:45:09 +00:00
lukaszraczylo 00380ca307 Update go.mod and go.sum (#34) 2026-01-25 03:43:03 +00:00
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
lukaszraczylo dcebdf718a Update go.mod and go.sum (#22) 2025-12-20 03:32:25 +00:00
lukaszraczylo 5967f26c21 Update go.mod and go.sum (#21) 2025-12-19 03:37:51 +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.8.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.4.0 // 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-20251002143259-bc988d571ff4 // 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.8.0 h1:/z8v+H+4XLluJKS7rAc7uHZTalT5Z+1430ld3lePSRI=
github.com/clipperhouse/displaywidth v0.6.2/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o= github.com/clipperhouse/displaywidth v0.8.0/go.mod h1:UpOXiIKep+TohQYwvAAM/VDU8v3Z5rnWTxiwueR0XvQ=
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.4.0 h1:RXqE/l5EiAbA4u97giimKNlmpvkmz+GrBVTelsoXy9g=
github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/clipperhouse/uax29/v2 v2.4.0/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-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck= k8s.io/utils v0.0.0-20260108192941-914a6e750570 h1:JT4W8lsdrGENg9W+YwwdLJxklIuKWdRm+BC+xt33FOY=
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= 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)
+8 -10
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,10 +389,8 @@ 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)
} }
+8 -10
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,14 +199,12 @@ 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()), })
})
}
} }
} }
+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",
+36 -6
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 {
Time string `json:"time"` Fields map[string]any `json:"fields,omitempty"`
Level string `json:"level"` Time string `json:"time"`
Message string `json:"message"` Level string `json:"level"`
Fields map[string]interface{} `json:"fields,omitempty"` Message string `json:"message"`
} }
// 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
+349 -236
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
updateVersion string addWizard *AddWizardState
updateURL string updateVersion string
updateURL string
// Modal wizard state configPath string
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,180 +523,222 @@ func (m model) renderMainView() string {
} }
b.WriteString("\n\n") b.WriteString("\n\n")
// No forwards return b.String()
if len(m.ui.forwardOrder) == 0 { }
disabledStyle := lipgloss.NewStyle().Foreground(mutedColor)
b.WriteString(disabledStyle.Render("No forwards configured\n")) // renderEmptyMessage renders the message shown when no forwards are configured
} else { func (m model) renderEmptyMessage(mutedColor lipgloss.Color) string {
// Build table rows disabledStyle := lipgloss.NewStyle().Foreground(mutedColor)
var rows [][]string return disabledStyle.Render("No forwards configured\n")
for _, id := range m.ui.forwardOrder { }
// renderForwardsTable renders the forwards table with all styling
func (m model) renderForwardsTable(colors mainViewColors) string {
var b strings.Builder
// Build table rows
rows := m.buildTableRows()
// Create table with styling (no borders for cleaner look)
t := table.New().
Border(lipgloss.HiddenBorder()).
Headers("CONTEXT", "NAMESPACE", "ALIAS", "TYPE", "RESOURCE", "REMOTE", "LOCAL", "STATUS").
Rows(rows...).
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
if row == table.HeaderRow {
return lipgloss.NewStyle().
Bold(true).
Foreground(colors.header).
Padding(0, 1)
}
baseStyle := lipgloss.NewStyle().Padding(0, 1)
if row >= 0 && row < len(m.ui.forwardOrder) {
id := m.ui.forwardOrder[row]
fwd, ok := m.ui.forwards[id] fwd, ok := m.ui.forwards[id]
if !ok { isSelected := row == m.ui.selectedIndex
continue isDisabled := m.ui.isForwardDisabled(id)
// Selected row gets background highlight
if isSelected {
return baseStyle.
Background(colors.selectedBg).
Foreground(colors.selectedFg)
} }
isDisabled := m.ui.disabledMap[id] || fwd.Status == "Disabled" // Disabled rows are muted
// Status icon and text
statusIcon := "●"
statusText := fwd.Status
if isDisabled { if isDisabled {
statusIcon = "○" return baseStyle.Foreground(colors.muted)
statusText = "Disabled" }
} else {
// Status column gets colored based on status
if col == ColumnStatus && ok {
switch fwd.Status { switch fwd.Status {
case "Starting": case "Active":
statusIcon = "○" return baseStyle.Foreground(colors.active)
case "Reconnecting": case "Starting", "Reconnecting":
statusIcon = "◐" return baseStyle.Foreground(colors.warning)
case "Error": case "Error":
statusIcon = "✗" return baseStyle.Foreground(colors.errorColor)
}
}
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)
t := table.New().
Border(lipgloss.HiddenBorder()).
Headers("CONTEXT", "NAMESPACE", "ALIAS", "TYPE", "RESOURCE", "REMOTE", "LOCAL", "STATUS").
Rows(rows...).
StyleFunc(func(row, col int) lipgloss.Style {
// Header row
if row == table.HeaderRow {
return lipgloss.NewStyle().
Bold(true).
Foreground(headerColor).
Padding(0, 1)
}
// Get the forward for this row to check its status
baseStyle := lipgloss.NewStyle().Padding(0, 1)
if row >= 0 && row < len(m.ui.forwardOrder) {
id := m.ui.forwardOrder[row]
fwd, ok := m.ui.forwards[id]
isSelected := row == m.ui.selectedIndex
isDisabled := m.ui.disabledMap[id] || (ok && fwd.Status == "Disabled")
// Selected row gets background highlight
if isSelected {
return baseStyle.
Background(selectedBg).
Foreground(selectedFg)
}
// Disabled rows are muted
if isDisabled {
return baseStyle.Foreground(mutedColor)
}
// Status column gets colored based on status
if col == 7 && ok { // STATUS column
switch fwd.Status {
case "Active":
return baseStyle.Foreground(activeColor)
case "Starting", "Reconnecting":
return baseStyle.Foreground(warningColor)
case "Error":
return baseStyle.Foreground(errorColor)
}
}
}
return baseStyle
})
b.WriteString(t.Render())
b.WriteString("\n")
}
// Display errors if any (before footer)
if len(m.ui.errors) > 0 {
b.WriteString("\n\n")
errorHeaderStyle := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("196"))
b.WriteString(errorHeaderStyle.Render("Errors:"))
b.WriteString("\n")
errorLineStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("196")).
Width(118). // Slightly less than table width (120) for padding
MaxWidth(118)
for id, errMsg := range m.ui.errors {
// Find the forward to display its alias
if fwd, ok := m.ui.forwards[id]; ok {
// Format: " • alias: error message"
prefix := fmt.Sprintf(" • %s: ", fwd.Alias)
// Wrap the error message if it's too long
// Max line length is 118, subtract prefix length
maxErrLen := 118 - len(prefix)
wrappedMsg := wrapText(errMsg, maxErrLen)
// Render first line with prefix
lines := strings.Split(wrappedMsg, "\n")
if len(lines) > 0 {
b.WriteString(errorLineStyle.Render(prefix + lines[0]))
b.WriteString("\n")
// Render subsequent lines with indentation
indent := strings.Repeat(" ", len(prefix))
for i := 1; i < len(lines); i++ {
b.WriteString(errorLineStyle.Render(indent + lines[i]))
b.WriteString("\n")
}
} }
} }
} }
return baseStyle
} }
}
// renderErrorSection renders the error display section
func (m model) renderErrorSection() string {
var b strings.Builder
b.WriteString("\n\n")
errorHeaderStyle := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("196"))
b.WriteString(errorHeaderStyle.Render("Errors:"))
b.WriteString("\n")
errorLineStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("196")).
Width(ErrorDisplayWidth).
MaxWidth(ErrorDisplayWidth)
for id, errMsg := range m.ui.errors {
// Find the forward to display its alias
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"
prefix := fmt.Sprintf(" • %s: ", alias)
// Wrap the error message if it's too long
maxErrLen := ErrorDisplayWidth - len(prefix)
wrappedMsg := wrapText(errMsg, maxErrLen)
// Render first line with prefix
lines := strings.Split(wrappedMsg, "\n")
if len(lines) > 0 {
b.WriteString(style.Render(prefix + lines[0]))
b.WriteString("\n")
// Render subsequent lines with indentation
indent := strings.Repeat(" ", len(prefix))
for i := 1; i < len(lines); i++ {
b.WriteString(style.Render(indent + lines[i]))
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 {
+33 -53
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 ListContextsErr error
CurrentContext string GetCurrentContextErr error
Namespaces []string ListNamespacesErr error
Pods []k8s.PodInfo LastSelector string
PodsWithSelector []k8s.PodInfo CurrentContext string
Services []k8s.ServiceInfo LastNamespace string
LastContextName string
// Errors to return PodsWithSelector []k8s.PodInfo
ListContextsErr error Services []k8s.ServiceInfo
GetCurrentContextErr error Pods []k8s.PodInfo
ListNamespacesErr error Namespaces []string
ListPodsErr error Contexts []string
ListPodsWithSelectorErr error
ListServicesErr error
// Call tracking
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 LastPredicate func(ctx, ns string, fwd config.Forward) bool
RemoveForwardsCalls int LastContextName string
RemoveForwardByIDCalls int LastOldID string
UpdateForwardCalls int LastNamespaceName string
LastRemovedID string
// Captured arguments Forwards []struct {
LastContextName string
LastNamespaceName string
LastForward config.Forward
LastOldID string
LastRemovedID string
LastPredicate func(ctx, ns string, fwd config.Forward) bool
// Storage for testing
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,14 +170,10 @@ 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
} }
func NewMockHTTPLogSubscriber() *MockHTTPLogSubscriber { func NewMockHTTPLogSubscriber() *MockHTTPLogSubscriber {
@@ -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",
+62 -86
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 error error
inputMode InputMode resourceValue string
cursor int originalID string
scrollOffset int // For scrolling long lists portCheckMsg string
textInput string alias string
searchFilter string // For filtering lists (contexts, namespaces, services) textInput string
loading bool searchFilter string
error error selector string
// Selections made by user
selectedContext string selectedContext string
selectedNamespace string selectedNamespace string
pods []k8s.PodInfo
contexts []string
detectedPorts []k8s.PortInfo
matchingPods []k8s.PodInfo
services []k8s.ServiceInfo
namespaces []string
scrollOffset int
selectedResourceType ResourceType selectedResourceType ResourceType
resourceValue string // pod prefix or service name step AddWizardStep
selector string // for pod selector type
remotePort int
localPort int localPort int
alias string cursor int
remotePort int
// Available options (loaded asynchronously from k8s) inputMode InputMode
contexts []string confirmationFocus ConfirmationFocus
namespaces []string portAvailable bool
pods []k8s.PodInfo isEditing bool
services []k8s.ServiceInfo loading bool
// Validation state
portAvailable bool
portCheckMsg string
matchingPods []k8s.PodInfo
// Edit mode
isEditing bool
originalID string // ID of the forward being edited
// Detected ports from resources
detectedPorts []k8s.PortInfo
// Confirmation focus (alias field vs buttons)
confirmationFocus ConfirmationFocus
} }
// newAddWizardState creates a new add wizard state initialized to the first step // 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
urlPath string
method string
cursor int
progress int
total int
step BenchmarkStep
requests int
concurrency int
localPort int localPort int
running bool
// Configuration
urlPath string
method string
concurrency int
requests int
cursor int // Current field being edited
textInput string
// Running state
running bool
progress int
total int
progressCh chan BenchmarkProgressMsg // Channel for progress updates
cancelFunc func() // Function to cancel the running benchmark
// Results
results *BenchmarkResults
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
@@ -455,41 +437,35 @@ const (
// HTTPLogState maintains the state for HTTP log viewing // HTTPLogState maintains the state for HTTP log viewing
type HTTPLogState struct { type HTTPLogState struct {
forwardID string forwardID string
forwardAlias string forwardAlias string
entries []HTTPLogEntry filterText string
cursor int copyMessage string
scrollOffset int entries []HTTPLogEntry
autoScroll bool cursor int
scrollOffset int
// Filtering filterMode HTTPLogFilterMode
filterMode HTTPLogFilterMode detailScroll int
filterText string autoScroll bool
filterActive bool // true when typing in filter input filterActive bool
showingDetail bool
// Detail view
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