Compare commits

...
34 Commits
Author SHA1 Message Date
lukaszraczyloandGitHub 4748bc4be5 feat(ui): type the namespace when the cluster does not list them (#84)
Some users cannot list namespaces on a cluster. The add wizard showed
an error and offered no way forward.

The namespace step now switches to a text prompt when the list call
fails or returns nothing. The prompt shows the reason and checks that
the name is a valid DNS label. Esc from a later step returns to the
prompt.

The j and k keys now type into text fields. They moved the list cursor
before, so a name such as kube-system could not be typed.

Closes #66
2026-10-02 02:28:58 +01:00
f3d3f259a1 Allow configuring forwards-table columns, order, and width (#82)
* Allow configuring forwards-table columns, order, and width

Adds an optional tui.columns section to .kportal.yaml so the visible
columns, their order, and their widths can be customized instead of
always rendering the hardcoded 8-column layout. Omitting the section
keeps the existing default columns/order/widths.

Closes #81

* fix(ui): keep the plain table layout and check column widths

The plain verbose table must look the same when no tui section is
set. It had narrower columns and shorter port headers than before. The
default layout is now identical to the old one, and a golden test
guards it.

A status column that is not last now pads by visible width. Table
rules follow the column widths. The UI applies new columns only after a
reload succeeds, including the SIGHUP path of the verbose table.

The validator rejects a column width above 200. The README describes
the tui.columns setting.

Refs #81

* fix(httplog): replace the deprecated ReverseProxy Director with Rewrite

Staticcheck reports SA1019 because Go 1.26 deprecates Director. The
proxy now sets the target in a Rewrite function. Rewrite drops the
X-Forwarded-For header that Director sent, so the function calls
SetXForwarded to keep that header.

A new test checks the target port, the path and the header.

* fix(release): drop the deprecated cask url verified key

Homebrew warns about the verified parameter of the cask url stanza.
The setting is not needed. GoReleaser check now passes without a
deprecation warning.

The postflight warning remains. GoReleaser generates it from the
post install hook, and the upstream fix is not released yet.

Refs #79

---------

Co-authored-by: Lukasz Raczylo <[email protected]>
2026-10-02 02:24:11 +01:00
lukaszraczyloandGitHub 232c0f1162 Update go.mod and go.sum (#78) 2026-09-02 04:17:26 +01:00
lukaszraczyloandGitHub 6f19ecea6b Update go.mod and go.sum (#77) 2026-08-27 05:54:28 +01:00
lukaszraczyloandGitHub 93b5d6496e Update go.mod and go.sum (#76) 2026-08-26 04:25:59 +01:00
lukaszraczyloandGitHub b3c63bd1fa Update go.mod and go.sum (#75) 2026-08-22 04:20:17 +01:00
lukaszraczyloandGitHub 374163f463 Update go.mod and go.sum (#74) 2026-08-21 04:25:07 +01:00
lukaszraczyloandGitHub 6c695584a4 Update go.mod and go.sum (#73) 2026-08-20 04:22:09 +01:00
lukaszraczyloandGitHub c1dbad858b Update go.mod and go.sum (#72) 2026-08-18 04:21:24 +01:00
lukaszraczyloandGitHub 3c1726cdec Update go.mod and go.sum (#71) 2026-08-14 04:48:03 +01:00
lukaszraczyloandGitHub 49cc69927a Update go.mod and go.sum (#70) 2026-08-13 04:49:32 +01:00
lukaszraczyloandGitHub 4571552f61 Update go.mod and go.sum (#69) 2026-08-12 04:48:15 +01:00
lukaszraczyloandGitHub 6b80c9ecc5 Update go.mod and go.sum (#68) 2026-08-11 04:36:05 +01:00
lukaszraczyloandGitHub bcfa2eb035 Update go.mod and go.sum (#67) 2026-08-03 07:25:42 +01:00
lukaszraczyloandGitHub ba475be9de Update go.mod and go.sum (#65) 2026-07-28 06:48:08 +01:00
lukaszraczyloandGitHub ad35ed05c8 Update go.mod and go.sum (#64) 2026-07-27 07:33:11 +01:00
lukaszraczyloandGitHub 698ccd510a Update go.mod and go.sum (#63) 2026-07-24 06:52:14 +01:00
lukaszraczyloandGitHub abe90e330d Update go.mod and go.sum (#62) 2026-07-23 06:56:53 +01:00
lukaszraczyloandGitHub add1cd2eb7 Update go.mod and go.sum (#61) 2026-07-22 06:53:15 +01:00
lukaszraczyloandGitHub 407ae1842c Update go.mod and go.sum (#60) 2026-07-21 06:54:04 +01:00
lukaszraczyloandGitHub 89258e70bb Update go.mod and go.sum (#59) 2026-07-20 07:20:26 +01:00
lukaszraczyloandGitHub dc24fe5d88 Update go.mod and go.sum (#58) 2026-07-19 06:56:12 +01:00
lukaszraczyloandGitHub 99485450a6 Update go.mod and go.sum (#57) 2026-07-17 06:45:47 +01:00
lukaszraczyloandGitHub 233671730a Update go.mod and go.sum (#56) 2026-07-09 05:24:01 +01:00
lukaszraczyloandGitHub 0d1f8560c3 Update go.mod and go.sum (#55) 2026-07-08 05:07:35 +01:00
lukaszraczyloandGitHub 5f62ca3ed1 Update go.mod and go.sum (#54) 2026-07-07 05:25:53 +01:00
lukaszraczyloandGitHub c2528f7eec Update go.mod and go.sum (#53) 2026-06-30 05:31:08 +01:00
lukaszraczyloandGitHub a37574371f Update go.mod and go.sum (#52) 2026-06-29 05:37:13 +01:00
lukaszraczyloandGitHub 0bab59d97d Update go.mod and go.sum (#51) 2026-06-27 05:26:45 +01:00
lukaszraczyloandGitHub 7acac8e885 Update go.mod and go.sum (#50) 2026-06-26 05:34:24 +01:00
lukaszraczyloandGitHub 87ce85b07b Update go.mod and go.sum (#49) 2026-06-25 05:31:14 +01:00
lukaszraczylo d5125a0d62 Optimisation & fixes 2026-06-21 12:59:55 +01:00
lukaszraczylo ab65c2e17b docs: add Telemetry section linking to oss-telemetry opt-out docs
Discloses the single anonymous adoption ping sent on startup and points
users to the upstream README section for full opt-out instructions
instead of duplicating the table here.
2026-05-21 04:06:27 +01:00
lukaszraczylo cda8b4be47 feat: anonymous usage telemetry via oss-telemetry
Send a single fire-and-forget ping at startup to help track adoption
and version spread. No persistent identifiers are collected.

main() is wrapped via runMain() so deferred Wait drains in-flight pings
before os.Exit.

Opt out via any of:
  DO_NOT_TRACK=1
  OSS_TELEMETRY_DISABLED=1
  KPORTAL_DISABLE_TELEMETRY=1
2026-05-21 02:59:46 +01:00
29 changed files with 2262 additions and 420 deletions
+4
View File
@@ -3,3 +3,7 @@ CLAUDE.md
DEPLOYMENT_SUMMARY.md
HOMEBREW_COMPLIANCE.md
RELEASE_SETUP.md
# Local/live test configs (cluster-specific, never committed)
.kportal.test.yaml
.kportal.*.local.yaml
-2
View File
@@ -62,8 +62,6 @@ homebrew_casks:
homepage: https://lukaszraczylo.github.io/kportal
description: "Modern Kubernetes port-forward manager with interactive TUI"
license: MIT
url:
verified: github.com/lukaszraczylo/kportal
hooks:
post:
install: |
+13
View File
@@ -22,6 +22,19 @@ reliability:
retryOnStale: true # Automatically reconnect when stale connections detected (default: true)
watchdogPeriod: "30s" # Goroutine watchdog check interval to detect hung workers (default: 30s)
# Optional: TUI table configuration
# Controls which columns are visible in the forwards table, in what order,
# and how wide each one renders. Omit this section (or leave it empty) to
# use the built-in default columns/order/widths.
# Valid column names: context, namespace, alias, type, resource, remote, local, status
# tui:
# columns:
# - name: alias
# width: 30 # optional; falls back to the built-in default width
# - name: resource
# - name: local
# - name: status
contexts:
# Production context
- name: production
+32
View File
@@ -238,6 +238,26 @@ dns-sd -B _kportal._tcp local # macOS
avahi-browse -t _kportal._tcp # Linux
```
### Table Columns
Choose which columns the forwards table shows, and in what order. The setting applies to the interactive UI and the `-verbose` table:
```yaml
tui:
columns:
- name: alias
width: 30 # optional, 1-200
- name: resource
- name: local
- name: status
```
- Valid names: `context`, `namespace`, `alias`, `type`, `resource`, `remote`, `local`, `status`
- The list order is the render order, and unlisted columns are hidden
- `width` is the maximum text width, from 1 to 200. If you omit it, the column keeps its default width
- If you omit `tui`, the table keeps its default columns and order
- Changes apply on hot-reload
## Usage
### Interactive Mode
@@ -490,6 +510,18 @@ make install # Install locally
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
## Telemetry
On startup this binary sends a single anonymous adoption ping — project name,
version, timestamp; no identifiers, no command output, no payload contents.
Fire-and-forget with a 2-second timeout; cannot block startup or panic.
See **[oss-telemetry — Disabling telemetry](https://github.com/lukaszraczylo/oss-telemetry#disabling-telemetry)**
for the exact wire format, source, and full opt-out documentation.
Quick opt-out: set any of `DO_NOT_TRACK=1`, `OSS_TELEMETRY_DISABLED=1`,
or `KPORTAL_DISABLE_TELEMETRY=1`.
## License
MIT License - see [LICENSE](LICENSE).
+119
View File
@@ -0,0 +1,119 @@
package main
import (
"flag"
"fmt"
"os"
"github.com/lukaszraczylo/kportal/internal/complete"
)
// completionCmd handles shell completion generation and installation
func completionCmd(args []string) int {
fs := flag.NewFlagSet("completion", flag.ContinueOnError)
fs.SetOutput(os.Stderr)
var (
installFlag bool
shellFlag string
uninstall bool
)
fs.BoolVar(&installFlag, "install", false, "Install completions for the shell")
fs.BoolVar(&uninstall, "uninstall", false, "Uninstall completions")
fs.StringVar(&shellFlag, "shell", "", "Shell type: bash, zsh, or fish (auto-detected if empty)")
if err := fs.Parse(args); err != nil {
if err == flag.ErrHelp {
printCompletionHelp()
return 0
}
return 2
}
// Determine shell type
var shell complete.Shell
if shellFlag != "" {
switch shellFlag {
case "bash":
shell = complete.ShellBash
case "zsh":
shell = complete.ShellZsh
case "fish":
shell = complete.ShellFish
default:
fprintf(os.Stderr, "Error: unknown shell %q (use bash, zsh, or fish)\n", shellFlag)
return 1
}
} else {
shell = complete.AutoDetectShell()
}
// Handle uninstall
if uninstall {
installer := complete.NewInstaller(shell)
if err := installer.Uninstall(); err != nil {
fprintf(os.Stderr, "Error uninstalling completions: %v\n", err)
return 1
}
fmt.Println("✅ Completions uninstalled")
return 0
}
// Handle install
if installFlag {
if err := complete.InstallCompletions(shell); err != nil {
fprintf(os.Stderr, "Error installing completions: %v\n", err)
return 1
}
return 0
}
// Print completion script to stdout
if err := complete.Print(shell); err != nil {
fprintf(os.Stderr, "Error generating completions: %v\n", err)
return 1
}
return 0
}
func printCompletionHelp() {
fprintf(os.Stdout, `Generate shell completions for kportal.
Usage:
kportal completion [flags]
Flags:
--install Install completions for the current shell
--uninstall Remove installed completions
--shell <type> Shell type: bash, zsh, or fish (auto-detected)
Examples:
# Generate and source completions (bash)
source <(kportal completion)
# Install completions (requires shell restart)
kportal completion --install
# Install for specific shell
kportal completion --install --shell zsh
# Uninstall completions
kportal completion --uninstall
Shell-specific setup:
Bash (~/.bashrc):
source <(kportal completion)
Zsh (~/.zshrc):
autoload -Uz compinit && compinit
source <(kportal completion)
Fish (~/.config/fish/config.fish):
kportal completion --install --shell fish
# Or manually:
kportal completion --shell fish > ~/.config/fish/completions/kportal.fish
`)
}
+37 -11
View File
@@ -24,6 +24,7 @@ import (
"github.com/lukaszraczylo/kportal/internal/mdns"
"github.com/lukaszraczylo/kportal/internal/ui"
"github.com/lukaszraczylo/kportal/internal/version"
telemetry "github.com/lukaszraczylo/oss-telemetry"
"k8s.io/klog/v2"
)
@@ -96,10 +97,19 @@ func promptCreateConfig(path string, stdin io.Reader, stdout io.Writer) bool {
}
func main() {
os.Exit(runMain())
}
// runMain wraps the real entry point so that deferred cleanup (telemetry
// drain, signal context cancel) runs before the surrounding os.Exit.
func runMain() int {
telemetry.Send("kportal", appVersion)
defer telemetry.Wait(2 * time.Second)
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
code := run(ctx, os.Args[1:], os.Stdin, os.Stdout, os.Stderr)
cancel()
os.Exit(code)
defer cancel()
return run(ctx, os.Args[1:], os.Stdin, os.Stdout, os.Stderr)
}
// run is the testable entry point. It returns the desired process exit code
@@ -107,9 +117,14 @@ func main() {
// of long-running modes (headless, verbose-loop, interactive).
func run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) int {
// Subcommand dispatch must run BEFORE the main flag set is parsed because
// generate has its own FlagSet and must not see kportal's top-level flags.
if len(args) >= 1 && args[0] == "generate" {
return runGenerate(args[1:])
// generate and completion have their own FlagSets and must not see kportal's top-level flags.
if len(args) >= 1 {
switch args[0] {
case "generate":
return runGenerate(args[1:])
case "completion":
return completionCmd(args[1:])
}
}
opts, code, handled := parseFlags(args, stderr)
@@ -482,13 +497,13 @@ func runHeadless(ctx context.Context, opts runOptions, cfg *config.Config, deps
// runVerboseTable runs the simple table UI with periodic redraws and SIGHUP
// reload, exiting cleanly when ctx is cancelled.
func runVerboseTable(ctx context.Context, opts runOptions, cfg *config.Config, deps *runtimeDeps, validator *config.Validator, stderr io.Writer) int {
tableUI := ui.NewTableUI(opts.verbose)
tableUI := ui.NewTableUI(opts.verbose, cfg)
deps.manager.SetStatusUI(tableUI)
// Background update check (best effort).
go func() {
checker := version.NewChecker(githubOwner, githubRepo, appVersion)
uctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
uctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
if update := checker.CheckForUpdate(uctx); update != nil {
log.Printf("Update available: v%s (current: v%s) - %s",
@@ -524,7 +539,11 @@ func runVerboseTable(ctx context.Context, opts runOptions, cfg *config.Config, d
}()
watcher, watchErr := config.NewWatcher(opts.configFile, func(newCfg *config.Config) error {
return deps.manager.Reload(newCfg)
if err := deps.manager.Reload(newCfg); err != nil {
return err
}
tableUI.SetColumns(newCfg)
return nil
}, opts.verbose)
watcherActive := false
if watchErr != nil {
@@ -561,7 +580,9 @@ func runVerboseTable(ctx context.Context, opts runOptions, cfg *config.Config, d
}
if reloadErr := deps.manager.Reload(newCfg); reloadErr != nil {
log.Printf("Failed to reload: %v", reloadErr)
continue
}
tableUI.SetColumns(newCfg)
}
}
}
@@ -575,12 +596,13 @@ func runInteractive(ctx context.Context, opts runOptions, cfg *config.Config, de
_ = deps.manager.DisableForward(id)
}
}, appVersion)
bubbleTeaUI.SetColumns(cfg)
bubbleTeaUI.SetWizardDependencies(deps.discovery, deps.mutator, opts.configFile)
bubbleTeaUI.SetHTTPLogSubscriber(makeHTTPLogSubscriber(deps.manager))
go func() {
checker := version.NewChecker(githubOwner, githubRepo, appVersion)
uctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
uctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
if update := checker.CheckForUpdate(uctx); update != nil {
bubbleTeaUI.SetUpdateAvailable(update.LatestVersion, update.ReleaseURL)
@@ -596,7 +618,11 @@ func runInteractive(ctx context.Context, opts runOptions, cfg *config.Config, de
var watcher *config.Watcher
watcher, err := config.NewWatcher(opts.configFile, func(newCfg *config.Config) error {
return deps.manager.Reload(newCfg)
if err := deps.manager.Reload(newCfg); err != nil {
return err
}
bubbleTeaUI.SetColumns(newCfg)
return nil
}, opts.verbose)
if err == nil {
watcher.Start()
+36 -40
View File
@@ -6,13 +6,14 @@ require (
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
github.com/fsnotify/fsnotify v1.10.1
github.com/go-logr/logr v1.4.3
github.com/go-logr/logr v1.4.4
github.com/grandcat/zeroconf v1.0.0
github.com/lukaszraczylo/oss-telemetry v0.2.3
github.com/stretchr/testify v1.11.1
gopkg.in/yaml.v3 v3.0.1
k8s.io/api v0.36.0
k8s.io/apimachinery v0.36.0
k8s.io/client-go v0.36.0
k8s.io/api v0.37.0
k8s.io/apimachinery v0.37.0
k8s.io/client-go v0.37.0
k8s.io/klog/v2 v2.140.0
)
@@ -20,7 +21,7 @@ require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/cenkalti/backoff v2.2.1+incompatible // indirect
github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/charmbracelet/x/ansi v0.11.7 // indirect
github.com/charmbracelet/x/ansi v0.11.8 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
@@ -28,31 +29,30 @@ require (
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/emicklei/go-restful/v3 v3.13.0 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/fxamacker/cbor/v2 v2.9.2 // indirect
github.com/go-openapi/jsonpointer v0.23.1 // indirect
github.com/go-openapi/jsonreference v0.21.5 // indirect
github.com/go-openapi/swag v0.26.0 // indirect
github.com/go-openapi/swag/cmdutils v0.26.0 // indirect
github.com/go-openapi/swag/conv v0.26.0 // indirect
github.com/go-openapi/swag/fileutils v0.26.0 // indirect
github.com/go-openapi/swag/jsonname v0.26.0 // indirect
github.com/go-openapi/swag/jsonutils v0.26.0 // indirect
github.com/go-openapi/swag/loading v0.26.0 // indirect
github.com/go-openapi/swag/mangling v0.26.0 // indirect
github.com/go-openapi/swag/netutils v0.26.0 // indirect
github.com/go-openapi/swag/stringutils v0.26.0 // indirect
github.com/go-openapi/swag/typeutils v0.26.0 // indirect
github.com/go-openapi/swag/yamlutils v0.26.0 // indirect
github.com/fxamacker/cbor/v2 v2.9.3 // indirect
github.com/go-openapi/jsonpointer v1.0.0 // indirect
github.com/go-openapi/jsonreference v1.0.1 // indirect
github.com/go-openapi/swag v0.29.1 // indirect
github.com/go-openapi/swag/cmdutils v0.29.1 // indirect
github.com/go-openapi/swag/conv v0.29.1 // indirect
github.com/go-openapi/swag/fileutils v0.29.1 // indirect
github.com/go-openapi/swag/jsonutils v0.29.1 // indirect
github.com/go-openapi/swag/loading v0.29.1 // indirect
github.com/go-openapi/swag/mangling v0.29.1 // indirect
github.com/go-openapi/swag/netutils v0.29.1 // indirect
github.com/go-openapi/swag/pools v0.29.1 // indirect
github.com/go-openapi/swag/stringutils v0.29.1 // indirect
github.com/go-openapi/swag/typeutils v0.29.1 // indirect
github.com/go-openapi/swag/yamlutils v0.29.1 // indirect
github.com/google/gnostic-models v0.7.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.22 // indirect
github.com/lucasb-eyer/go-colorful v1.4.1 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.23 // indirect
github.com/miekg/dns v1.1.72 // indirect
github.com/mattn/go-runewidth v0.0.29 // indirect
github.com/miekg/dns v1.1.73 // indirect
github.com/moby/spdystream v0.5.1 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
@@ -60,31 +60,27 @@ require (
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/xo/terminfo v1.0.0 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/mod v0.35.0 // indirect
golang.org/x/net v0.53.0 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/net v0.58.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/term v0.42.0 // indirect
golang.org/x/text v0.36.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/term v0.45.0 // indirect
golang.org/x/text v0.41.0 // indirect
golang.org/x/time v0.15.0 // indirect
golang.org/x/tools v0.44.0 // indirect
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect
google.golang.org/protobuf v1.36.12 // indirect
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
k8s.io/kube-openapi v0.0.0-20260505163821-33341827b392 // indirect
k8s.io/streaming v0.36.0 // indirect
k8s.io/utils v0.0.0-20260319190234-28399d86e0b5 // indirect
k8s.io/kube-openapi v0.0.0-20260821135717-be32def86098 // indirect
k8s.io/streaming v0.37.0 // indirect
k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3 // indirect
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
sigs.k8s.io/randfill v1.0.0 // indirect
sigs.k8s.io/structured-merge-diff/v6 v6.4.0 // indirect
sigs.k8s.io/structured-merge-diff/v6 v6.4.2 // indirect
sigs.k8s.io/yaml v1.6.0 // indirect
)
+82 -185
View File
@@ -1,5 +1,3 @@
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
@@ -10,31 +8,23 @@ github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEe
github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY=
github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8=
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
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/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
github.com/charmbracelet/x/ansi v0.11.8 h1:JMFwp0CgDC2+jcOB162HH5k7I3FVbgFSMMYg7dSPBQQ=
github.com/charmbracelet/x/ansi v0.11.8/go.mod h1:ZNN+3mXny/516oTQPLMPIBeSINvNJJQ8uQXDgbeJxY0=
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
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/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/clipperhouse/displaywidth v0.10.0 h1:GhBG8WuerxjFQQYeuZAeVTuyxuX+UraiZGD4HJQ3Y8g=
github.com/clipperhouse/displaywidth v0.10.0/go.mod h1:XqJajYsaiEwkxOj4bowCTMcT1SgvHo9flfF3jQasdbs=
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
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.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -42,90 +32,51 @@ github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bF
github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78=
github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4=
github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80=
github.com/go-openapi/jsonpointer v0.23.1 h1:1HBACs7XIwR2RcmItfdSFlALhGbe6S92p0ry4d1GWg4=
github.com/go-openapi/jsonpointer v0.23.1/go.mod h1:iWRmZTrGn7XwYhtPt/fvdSFj1OfNBngqRT2UG3BxSqY=
github.com/go-openapi/jsonreference v0.21.4 h1:24qaE2y9bx/q3uRK/qN+TDwbok1NhbSmGjjySRCHtC8=
github.com/go-openapi/jsonreference v0.21.4/go.mod h1:rIENPTjDbLpzQmQWCj5kKj3ZlmEh+EFVbz3RTUh30/4=
github.com/go-openapi/jsonreference v0.21.5 h1:6uCGVXU/aNF13AQNggxfysJ+5ZcU4nEAe+pJyVWRdiE=
github.com/go-openapi/jsonreference v0.21.5/go.mod h1:u25Bw85sX4E2jzFodh1FOKMTZLcfifd1Q+iKKOUxExw=
github.com/go-openapi/swag v0.25.4 h1:OyUPUFYDPDBMkqyxOTkqDYFnrhuhi9NR6QVUvIochMU=
github.com/go-openapi/swag v0.25.4/go.mod h1:zNfJ9WZABGHCFg2RnY0S4IOkAcVTzJ6z2Bi+Q4i6qFQ=
github.com/go-openapi/swag v0.26.0 h1:GVDXCmfvhfu1BxiHo8/FA+BbKmhecHnG3varjON5/RI=
github.com/go-openapi/swag v0.26.0/go.mod h1:82g3193sZJRbocs7bNCqGfIgq8pkuwVwCfhKIRlEQF0=
github.com/go-openapi/swag/cmdutils v0.25.4 h1:8rYhB5n6WawR192/BfUu2iVlxqVR9aRgGJP6WaBoW+4=
github.com/go-openapi/swag/cmdutils v0.25.4/go.mod h1:pdae/AFo6WxLl5L0rq87eRzVPm/XRHM3MoYgRMvG4A0=
github.com/go-openapi/swag/cmdutils v0.26.0 h1:iowihOcvq7y4egO8cOq0dmfohz6wfeQ63U1EnuhO2TU=
github.com/go-openapi/swag/cmdutils v0.26.0/go.mod h1:Sm1MVFMkF6guJJ+pQqHnQA3N0j9qALV3NxzDSv6bETM=
github.com/go-openapi/swag/conv v0.25.4 h1:/Dd7p0LZXczgUcC/Ikm1+YqVzkEeCc9LnOWjfkpkfe4=
github.com/go-openapi/swag/conv v0.25.4/go.mod h1:3LXfie/lwoAv0NHoEuY1hjoFAYkvlqI/Bn5EQDD3PPU=
github.com/go-openapi/swag/conv v0.26.0 h1:5yGGsPYI1ZCva93U0AoKi/iZrNhaJEjr324YVsiD89I=
github.com/go-openapi/swag/conv v0.26.0/go.mod h1:tpAmIL7X58VPnHHiSO4uE3jBeRamGsFsfdDeDtb5ECE=
github.com/go-openapi/swag/fileutils v0.25.4 h1:2oI0XNW5y6UWZTC7vAxC8hmsK/tOkWXHJQH4lKjqw+Y=
github.com/go-openapi/swag/fileutils v0.25.4/go.mod h1:cdOT/PKbwcysVQ9Tpr0q20lQKH7MGhOEb6EwmHOirUk=
github.com/go-openapi/swag/fileutils v0.26.0 h1:WJoPRvsA7QRiiWluowkLJa9jaYR7FCuxmDvnCgaRRxU=
github.com/go-openapi/swag/fileutils v0.26.0/go.mod h1:0WDJ7lp67eNjPMO50wAWYlKvhOb6CQ37rzR7wrgI8Tc=
github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI=
github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag=
github.com/go-openapi/swag/jsonname v0.26.0 h1:gV1NFX9M8avo0YSpmWogqfQISigCmpaiNci8cGECU5w=
github.com/go-openapi/swag/jsonname v0.26.0/go.mod h1:urBBR8bZNoDYGr653ynhIx+gTeIz0ARZxHkAPktJK2M=
github.com/go-openapi/swag/jsonutils v0.25.4 h1:VSchfbGhD4UTf4vCdR2F4TLBdLwHyUDTd1/q4i+jGZA=
github.com/go-openapi/swag/jsonutils v0.25.4/go.mod h1:7OYGXpvVFPn4PpaSdPHJBtF0iGnbEaTk8AvBkoWnaAY=
github.com/go-openapi/swag/jsonutils v0.26.0 h1:FawFML2iAXsPqmERscuMPIHmFsoP1tOqWkxBaKNMsnA=
github.com/go-openapi/swag/jsonutils v0.26.0/go.mod h1:2VmA0CJlyFqgawOaPI9psnjFDqzyivIqLYN34t9p91E=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4 h1:IACsSvBhiNJwlDix7wq39SS2Fh7lUOCJRmx/4SN4sVo=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4/go.mod h1:Mt0Ost9l3cUzVv4OEZG+WSeoHwjWLnarzMePNDAOBiM=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.0 h1:apqeINu/ICHouqiRZbyFvuDge5jCmmLTqGQ9V95EaOM=
github.com/go-openapi/swag/loading v0.25.4 h1:jN4MvLj0X6yhCDduRsxDDw1aHe+ZWoLjW+9ZQWIKn2s=
github.com/go-openapi/swag/loading v0.25.4/go.mod h1:rpUM1ZiyEP9+mNLIQUdMiD7dCETXvkkC30z53i+ftTE=
github.com/go-openapi/swag/loading v0.26.0 h1:Apg6zaKhCJurpJer0DCxq99qwmhFddBhaMX7kilDcko=
github.com/go-openapi/swag/loading v0.26.0/go.mod h1:dBxQ/6V2uBaAQdevN18VELE6xSpJWZxLX4txe12JwDg=
github.com/go-openapi/swag/mangling v0.25.4 h1:2b9kBJk9JvPgxr36V23FxJLdwBrpijI26Bx5JH4Hp48=
github.com/go-openapi/swag/mangling v0.25.4/go.mod h1:6dxwu6QyORHpIIApsdZgb6wBk/DPU15MdyYj/ikn0Hg=
github.com/go-openapi/swag/mangling v0.26.0 h1:Du2YC4YLA/Y5m/YKQd7AnY5qq0wRKSFZTTt8ktFaXcQ=
github.com/go-openapi/swag/mangling v0.26.0/go.mod h1:jifS7W9vbg+pw63bT+GI53otluMQL3CeemuyCHKwVx0=
github.com/go-openapi/swag/netutils v0.25.4 h1:Gqe6K71bGRb3ZQLusdI8p/y1KLgV4M/k+/HzVSqT8H0=
github.com/go-openapi/swag/netutils v0.25.4/go.mod h1:m2W8dtdaoX7oj9rEttLyTeEFFEBvnAx9qHd5nJEBzYg=
github.com/go-openapi/swag/netutils v0.26.0 h1:CmZp+ZT7HrmFwrC3GdGsXBq2+42T1bjKBapcqVpIs3c=
github.com/go-openapi/swag/netutils v0.26.0/go.mod h1:5iK+Ok3ZohWWex1C50BFTPexi03UaPwjW4Oj8kgrpwo=
github.com/go-openapi/swag/stringutils v0.25.4 h1:O6dU1Rd8bej4HPA3/CLPciNBBDwZj9HiEpdVsb8B5A8=
github.com/go-openapi/swag/stringutils v0.25.4/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0=
github.com/go-openapi/swag/stringutils v0.26.0 h1:qZQngLxs5s7SLijc3N2ZO+fUq2o8LjuWAASSrJuh+xg=
github.com/go-openapi/swag/stringutils v0.26.0/go.mod h1:sWn5uY+QIIspwPhvgnqJsH8xqFT2ZbYcvbcFanRyhFE=
github.com/go-openapi/swag/typeutils v0.25.4 h1:1/fbZOUN472NTc39zpa+YGHn3jzHWhv42wAJSN91wRw=
github.com/go-openapi/swag/typeutils v0.25.4/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE=
github.com/go-openapi/swag/typeutils v0.26.0 h1:2kdEwdiNWy+JJdOvu5MA2IIg2SylWAFuuyQIKYybfq4=
github.com/go-openapi/swag/typeutils v0.26.0/go.mod h1:oovDuIUvTrEHVMqWilQzKzV4YlSKgyZmFh7AlfABNVE=
github.com/go-openapi/swag/yamlutils v0.25.4 h1:6jdaeSItEUb7ioS9lFoCZ65Cne1/RZtPBZ9A56h92Sw=
github.com/go-openapi/swag/yamlutils v0.25.4/go.mod h1:MNzq1ulQu+yd8Kl7wPOut/YHAAU/H6hL91fF+E2RFwc=
github.com/go-openapi/swag/yamlutils v0.26.0 h1:H7O8l/8NJJQ/oiReEN+oMpnGMyt8G0hl460nRZxhLMQ=
github.com/go-openapi/swag/yamlutils v0.26.0/go.mod h1:1evKEGAtP37Pkwcc7EWMF0hedX0/x3Rkvei2wtG/TbU=
github.com/go-openapi/testify/enable/yaml/v2 v2.0.2 h1:0+Y41Pz1NkbTHz8NngxTuAXxEodtNSI1WG1c/m5Akw4=
github.com/go-openapi/testify/enable/yaml/v2 v2.0.2/go.mod h1:kme83333GCtJQHXQ8UKX3IBZu6z8T5Dvy5+CW3NLUUg=
github.com/go-openapi/testify/enable/yaml/v2 v2.4.2 h1:5zRca5jw7lzVREKCZVNBpysDNBjj74rBh0N2BGQbSR0=
github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls=
github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
github.com/go-openapi/testify/v2 v2.4.2 h1:tiByHpvE9uHrrKjOszax7ZvKB7QOgizBWGBLuq0ePx4=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/fxamacker/cbor/v2 v2.9.3 h1:oQBnFATpNdY8gJHTndDDv5Xl4QqNaz51G5LLEPhng3Q=
github.com/fxamacker/cbor/v2 v2.9.3/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-openapi/jsonpointer v1.0.0 h1:kR9tHqY0CtZaOPVFm622dPVNhrvYpwr4uCxgL3h1H8s=
github.com/go-openapi/jsonpointer v1.0.0/go.mod h1:Z3rw7dWu1p9IgitXCFamSlA5lmDiklEB6vkaxcNZW5Y=
github.com/go-openapi/jsonreference v1.0.1 h1:4zJ7AmYDKNmD3aSpfPnFNCFA5E80/xMHUNKgydaLh38=
github.com/go-openapi/jsonreference v1.0.1/go.mod h1:dYplQXa6p5lXprLcJ8LE2iU7vNpXsAHDQ5ZAgL+Qx3A=
github.com/go-openapi/swag v0.29.1 h1:C6EeWzUwQtcWEhE9eqBdUubGXxhWY4PlzHMLD7kLaiQ=
github.com/go-openapi/swag v0.29.1/go.mod h1:BzxEXKiPlSXRsRTv1KSBF/BpGKHxA/YciCnr4tv9bvA=
github.com/go-openapi/swag/cmdutils v0.29.1 h1:3DorPGfUdE80BogKY22EzoHBcHMrkVomZMoV7kS4ANY=
github.com/go-openapi/swag/cmdutils v0.29.1/go.mod h1:Sm1MVFMkF6guJJ+pQqHnQA3N0j9qALV3NxzDSv6bETM=
github.com/go-openapi/swag/conv v0.29.1 h1:AC4Eh/5c/eUDOUCzzsRC9ghmFgOSBHeRMGIngY0ZUGA=
github.com/go-openapi/swag/conv v0.29.1/go.mod h1:S1X7/ZrBEZOC0Wc8AGxjbcGS92l3WEjA7aPtpl+RaqM=
github.com/go-openapi/swag/fileutils v0.29.1 h1:ZcPzMceVhU1WPbK6N1G6sNQKdd1CWJlf3cA08UHuoM0=
github.com/go-openapi/swag/fileutils v0.29.1/go.mod h1:/wofKYckbtRl2p3+EwQsosie5CT1B38+dQ+PS579BzI=
github.com/go-openapi/swag/jsonutils v0.29.1 h1:AFCxs0eQZ24/QyfhVHM2t49rMz7Vv3XCsZQI6yrNy+c=
github.com/go-openapi/swag/jsonutils v0.29.1/go.mod h1:u3+sCfJpttDpcmS5kpm0yxL6GK0eWgODsx8Yw8fcqNM=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.29.1 h1:BiiXE31Bx9SfpsMmOQj5KYpUhTZBpLVriVhJDuLuY2o=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.29.1/go.mod h1:julgTUKZ9/D0j6O7GKajmRs+812FWxQg/mMpGunWSjg=
github.com/go-openapi/swag/loading v0.29.1 h1:FCv5fG8UhTdDJa2R7w+5O9Ekpcbw7tt0nFWvmDKGBjc=
github.com/go-openapi/swag/loading v0.29.1/go.mod h1:N0ESuem4p2oedKal8EJhciqnJ9Q9Wmt83L1CRB3Fouw=
github.com/go-openapi/swag/mangling v0.29.1 h1:lHALtvYCdxVnRl4GrHmFPwfBTZYIObqdGNSKyu/8D6I=
github.com/go-openapi/swag/mangling v0.29.1/go.mod h1:SAop9pB7PUjQ/CGCNf/JmCKTRK+GDO+RqE9UHqC/N6s=
github.com/go-openapi/swag/netutils v0.29.1 h1:IjIvdEP5duKcghFqJEPSUraRnkKYHoM65kTluTu+Jb4=
github.com/go-openapi/swag/netutils v0.29.1/go.mod h1:DUde7x4Bx00k5jYl2AdRpNAO0m7atUvD2x6X+bWkbno=
github.com/go-openapi/swag/pools v0.29.1 h1:NRogYxdEW9SjRM4mkAOji9iefO4MRXq3p/ZJcoQbUKg=
github.com/go-openapi/swag/pools v0.29.1/go.mod h1:leDcaghjkRAhCuCRv9NfJU5f0mjoU3cT/XZObhMk3pc=
github.com/go-openapi/swag/stringutils v0.29.1 h1:1ykunK7iJQk1uOO7+oUH1ukbsK85fFCOiCFMOVSY+F0=
github.com/go-openapi/swag/stringutils v0.29.1/go.mod h1:7fSqZ+z8Qc0tOfAAK0jVa5qFGrnIlRi6n7NeGGrr1vc=
github.com/go-openapi/swag/typeutils v0.29.1 h1:Nzv9nhnlLCRBPQqfOX+7lB6Guju370or8StT+lIOf6M=
github.com/go-openapi/swag/typeutils v0.29.1/go.mod h1:hxpgDZJVBkBsi/d3MIUosafoFdE5exaQRmVp0zwu3YE=
github.com/go-openapi/swag/yamlutils v0.29.1 h1:69w3tsBajm7MR/fejLy7HD/3J68Ys1SeeZMEzZ3w2sk=
github.com/go-openapi/swag/yamlutils v0.29.1/go.mod h1:rgsp3vT/QdWzKwn43CigDwjOGIenPyTZMKnxEM8jZOA=
github.com/go-openapi/testify/enable/yaml/v2 v2.6.1 h1:Jm+/ze2rMtbD98yen92AhATGLGREDYXG56Xr4gMjEtE=
github.com/go-openapi/testify/enable/yaml/v2 v2.6.1/go.mod h1:YDPnwCRDu38/oJBVMBVXOUDiJ9cIeBHWvfImHaXqnv4=
github.com/go-openapi/testify/v2 v2.6.1 h1:6CNJhTjMzgaeaH8WhshcsZNPIvRemiOcFpU7seO/y7Q=
github.com/go-openapi/testify/v2 v2.6.1/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw=
github.com/google/gnostic-models v0.7.1 h1:SisTfuFKJSKM5CPZkffwi6coztzzeYUhc3v4yxLWH8c=
github.com/google/gnostic-models v0.7.1/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8=
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
@@ -138,25 +89,19 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss=
github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/lukaszraczylo/oss-telemetry v0.2.3 h1:xoDtBqeZGmXj7IteiE1M5WMuzeoqag58qEleI0Cf2Ms=
github.com/lukaszraczylo/oss-telemetry v0.2.3/go.mod h1:+Cn78qZo8rc3T9eZt0v3oICYRdd75wORtSidc8lNjDQ=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ=
github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-runewidth v0.0.29 h1:3oGF3R/S2N9DQ3ptftzVIvg2eicmojCzlwBEmqEPDfQ=
github.com/mattn/go-runewidth v0.0.29/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=
github.com/miekg/dns v1.1.27/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM=
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
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/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI=
github.com/miekg/dns v1.1.73 h1:uhT8nJxmTrPJYClxVxTCX+CVn6qnzSiybRk72Z6DgrE=
github.com/miekg/dns v1.1.73/go.mod h1:RW2Obtfd5NZHvOFe3zYG0W8koWOQtAzyHaLo8vASBuQ=
github.com/moby/spdystream v0.5.1 h1:9sNYeYZUcci9R6/w7KDaFWEWeV4LStVG78Mpyq/Zm/Y=
github.com/moby/spdystream v0.5.1/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -173,15 +118,8 @@ github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus=
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw=
github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns=
github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A=
github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@@ -192,79 +130,52 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
github.com/xo/terminfo v1.0.0 h1:2ZpYzqWzyyytjk3TP6aJVDhkMAkc99/1xKQdA3TDTBY=
github.com/xo/terminfo v1.0.0/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
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/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
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-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.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
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/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.0.0-20191216052735-49a3e744a425/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
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/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI=
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
@@ -274,39 +185,25 @@ gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
k8s.io/api v0.35.1 h1:0PO/1FhlK/EQNVK5+txc4FuhQibV25VLSdLMmGpDE/Q=
k8s.io/api v0.35.1/go.mod h1:28uR9xlXWml9eT0uaGo6y71xK86JBELShLy4wR1XtxM=
k8s.io/api v0.36.0 h1:SgqDhZzHdOtMk40xVSvCXkP9ME0H05hPM3p9AB1kL80=
k8s.io/api v0.36.0/go.mod h1:m1LVrGPNYax5NBHdO+QuAedXyuzTt4RryI/qnmNvs34=
k8s.io/apimachinery v0.35.1 h1:yxO6gV555P1YV0SANtnTjXYfiivaTPvCTKX6w6qdDsU=
k8s.io/apimachinery v0.35.1/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns=
k8s.io/apimachinery v0.36.0 h1:jZyPzhd5Z+3h9vJLt0z9XdzW9VzNzWAUw+P1xZ9PXtQ=
k8s.io/apimachinery v0.36.0/go.mod h1:FklypaRJt6n5wUIwWXIP6GJlIpUizTgfo1T/As+Tyxc=
k8s.io/client-go v0.35.1 h1:+eSfZHwuo/I19PaSxqumjqZ9l5XiTEKbIaJ+j1wLcLM=
k8s.io/client-go v0.35.1/go.mod h1:1p1KxDt3a0ruRfc/pG4qT/3oHmUj1AhSHEcxNSGg+OA=
k8s.io/client-go v0.36.0 h1:pOYi7C4RHChYjMiHpZSpSbIM6ZxVbRXBy7CuiIwqA3c=
k8s.io/client-go v0.36.0/go.mod h1:ZKKcpwF0aLYfkHFCjillCKaTK/yBkEDHTDXCFY6AS9Y=
k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
k8s.io/api v0.37.0 h1:Z//Vj9N7RA/yS2sDmxyeo7h+RR4zbUrd2vrd3Z0TbB4=
k8s.io/api v0.37.0/go.mod h1:LKXgcJWMc+f4OLbP5SFR8rulEg07zZhpi/zMULiBImk=
k8s.io/apimachinery v0.37.0 h1:Np2AbDtf8x6RDHiD8T9LbKJ9gaegeVNa8yNm5FuGKm0=
k8s.io/apimachinery v0.37.0/go.mod h1:RN3nhprFSCxOi5Selxd7oMTXOe/c+ZbcE7Im+TS2zkE=
k8s.io/client-go v0.37.0 h1:nsN31fy8wBySuZ+QRnKmrjRSQLOG2rvoGN0tKd12zhQ=
k8s.io/client-go v0.37.0/go.mod h1:FcGqw+Ll/gNQiq+nPGY1Oyt9y7SgDh1d3MW3RFDEbn0=
k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc=
k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0=
k8s.io/kube-openapi v0.0.0-20260127142750-a19766b6e2d4 h1:HhDfevmPS+OalTjQRKbTHppRIz01AWi8s45TMXStgYY=
k8s.io/kube-openapi v0.0.0-20260127142750-a19766b6e2d4/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ=
k8s.io/kube-openapi v0.0.0-20260505163821-33341827b392 h1:B7Ylb1OUptHKVX/3kpvXB0i05pDmXU66cGED/4Ta9Bw=
k8s.io/kube-openapi v0.0.0-20260505163821-33341827b392/go.mod h1:V/QaCUYDa+0QpcHhVVc5l99Uz56wEMEXBSj9oCDkNDY=
k8s.io/streaming v0.36.0 h1:agnTxU+NFulUrtYzXUGKO3ndEa8jKwht1Kwn9nu9x+4=
k8s.io/streaming v0.36.0/go.mod h1:z6fV3D+NVkoeqRMtWwlUZK6U17SY/LqNzOxWL6GyR/s=
k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 h1:AZYQSJemyQB5eRxqcPky+/7EdBj0xi3g0ZcxxJ7vbWU=
k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk=
k8s.io/utils v0.0.0-20260319190234-28399d86e0b5 h1:kBawHLSnx/mYHmRnNUf9d4CpjREbeZuxoSGOX/J+aYM=
k8s.io/utils v0.0.0-20260319190234-28399d86e0b5/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk=
k8s.io/kube-openapi v0.0.0-20260821135717-be32def86098 h1:z5+pcu1jTyKK5mNTe2/+x+U6Uuv9jRVOJQLaBJJMpeI=
k8s.io/kube-openapi v0.0.0-20260821135717-be32def86098/go.mod h1:0/mqHCVhlumdJ3BhCfnjSZQE037nAhNodh1/hK0T8/I=
k8s.io/streaming v0.37.0 h1:iPBUZLZiKt5bV+lxJurASMOV07VuBhNpiwJt2//AWrM=
k8s.io/streaming v0.37.0/go.mod h1:APlJR26ZWRcVy5bIEj0QRrKUXROtBHPcxl2NT7EAzPU=
k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3 h1:jVkFFVfXdXP74B/zbO3hM3hpSFD0xvhQ5U686DPurkE=
k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3/go.mod h1:M2s5JB1lIYP3jzZdorPLHXIPJzt9vv2muW5a6L9DtNM=
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/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU=
sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=
sigs.k8s.io/structured-merge-diff/v6 v6.3.2 h1:kwVWMx5yS1CrnFWA/2QHyRVJ8jM6dBA80uLmm0wJkk8=
sigs.k8s.io/structured-merge-diff/v6 v6.3.2/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=
sigs.k8s.io/structured-merge-diff/v6 v6.4.0 h1:qmp2e3ZfFi1/jJbDGpD4mt3wyp6PE1NfKHCYLqgNQJo=
sigs.k8s.io/structured-merge-diff/v6 v6.4.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=
sigs.k8s.io/structured-merge-diff/v6 v6.4.2 h1:qdOxHwrl2Kaag1aQEarlYcOA9vSyGCp3CIki3aW8c4Q=
sigs.k8s.io/structured-merge-diff/v6 v6.4.2/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=
sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=
+458
View File
@@ -0,0 +1,458 @@
// Package complete provides shell completion generation for kportal.
// It supports bash, zsh, and fish shells with context-aware completions
// for flags, subcommands, config values, and Kubernetes resources.
package complete
import (
"fmt"
"os"
"path/filepath"
"strings"
)
// Shell represents a supported shell type
type Shell string
const (
ShellBash Shell = "bash"
ShellZsh Shell = "zsh"
ShellFish Shell = "fish"
)
// Installer handles shell completion installation
type Installer struct {
shell Shell
prefixDir string
}
// NewInstaller creates a new completion installer for the specified shell
func NewInstaller(shell Shell) *Installer {
return &Installer{
shell: shell,
prefixDir: getDefaultPrefixDir(shell),
}
}
// getDefaultPrefixDir returns the default completion directory for a shell
func getDefaultPrefixDir(shell Shell) string {
home, err := os.UserHomeDir()
if err != nil {
home = os.Getenv("HOME")
}
if home == "" {
home = "/tmp"
}
switch shell {
case ShellBash:
// Check common bash completion directories
dirs := []string{
"/etc/bash_completion.d",
filepath.Join(home, ".local", "share", "bash-completion", "completions"),
filepath.Join(home, ".bash_completion.d"),
}
for _, dir := range dirs {
if pathExists(dir) {
return dir
}
}
// Fallback to user dir (best-effort create; Install reports write errors)
userDir := filepath.Join(home, ".local", "share", "bash-completion", "completions")
ensureDir(userDir)
return userDir
case ShellZsh:
dirs := []string{
filepath.Join(home, ".zsh", "completions"),
filepath.Join(home, ".oh-my-zsh", "completions"),
}
for _, dir := range dirs {
if pathExists(dir) {
return dir
}
}
// Fallback to standard zsh site-functions
usrShare := "/usr/local/share/zsh/site-functions"
if pathExists(usrShare) {
return usrShare
}
userDir := filepath.Join(home, ".zsh", "completions")
ensureDir(userDir)
return userDir
case ShellFish:
dir := filepath.Join(home, ".config", "fish", "completions")
ensureDir(dir)
return dir
}
return ""
}
// Install installs the completion script for the shell
func (i *Installer) Install() error {
script, err := Generate(i.shell)
if err != nil {
return fmt.Errorf("failed to generate completion script: %w", err)
}
filename := i.getCompletionFilename()
filepath := filepath.Join(i.prefixDir, filename)
// Check if already installed
if pathExists(filepath) {
return fmt.Errorf("completion file already exists: %s (remove it first to reinstall)", filepath)
}
// #nosec G306 -- completion scripts are non-secret and must be world-readable by the shell
if err := os.WriteFile(filepath, []byte(script), 0o644); err != nil {
return fmt.Errorf("failed to write completion file: %w", err)
}
return nil
}
// Uninstall removes the completion script
func (i *Installer) Uninstall() error {
filename := i.getCompletionFilename()
filepath := filepath.Join(i.prefixDir, filename)
if err := os.Remove(filepath); err != nil {
return fmt.Errorf("failed to remove completion file: %w", err)
}
return nil
}
// getCompletionFilename returns the filename for the completion script
func (i *Installer) getCompletionFilename() string {
switch i.shell {
case ShellBash:
return "_kportal"
case ShellZsh:
return "_kportal"
case ShellFish:
return "kportal.fish"
}
return "_kportal"
}
// Print prints the completion script to stdout
func Print(shell Shell) error {
script, err := Generate(shell)
if err != nil {
return err
}
fmt.Print(script)
return nil
}
// Generate generates the completion script for the specified shell
func Generate(shell Shell) (string, error) {
switch shell {
case ShellBash:
return generateBash()
case ShellZsh:
return generateZsh()
case ShellFish:
return generateFish()
default:
return "", fmt.Errorf("unsupported shell: %s", shell)
}
}
// generateBash generates bash completion script
func generateBash() (string, error) {
var sb strings.Builder
bashScript := `# kportal shell completion - bash
# Generated by kportal
# Don't interfere with other completions
if [[ -n "${BASH_COMPLETION_VERSINFO:-}" ]]; then
return
fi
_kportal()
{
local cur prev words cword split=false
_init_completion -s || return
# Complete the value expected after the previous flag
case "$prev" in
-c|--config)
_filedir yaml
return
;;
--log-format)
COMPREPLY=( $(compgen -W "text json" -- "$cur") )
return
;;
--convert)
_filedir json
return
;;
--convert-output)
_filedir yaml
return
;;
--context)
# Complete from kubectl contexts
if command -v kubectl &> /dev/null; then
COMPREPLY=( $(compgen -W "$(kubectl config get-contexts -o name 2>/dev/null)" -- "$cur") )
fi
return
;;
--shell)
COMPREPLY=( $(compgen -W "bash zsh fish" -- "$cur") )
return
;;
esac
# Handle option splitting (e.g., -c=value)
[[ "$cur" == -*=* ]] && split=true
[[ "$split" == true ]] && prev="${cur%%=*}"
# Subcommand-specific completion
case "${words[1]}" in
generate)
if [[ "$cur" == -* ]]; then
COMPREPLY=( $(compgen -W "--context --config --dry-run" -- "$cur") )
elif command -v kubectl &> /dev/null; then
COMPREPLY=( $(compgen -W "$(kubectl config get-contexts -o name 2>/dev/null)" -- "$cur") )
fi
return
;;
completion)
COMPREPLY=( $(compgen -W "--install --uninstall --shell" -- "$cur") )
return
;;
esac
# Top-level options
if [[ "$cur" == -* ]]; then
COMPREPLY=( $(compgen -W "-c -v --check --headless --log-format --version --update --convert --convert-output" -- "$cur") )
return
fi
# Top-level subcommands
COMPREPLY=( $(compgen -W "generate completion" -- "$cur") )
}
# Register completion
complete -F _kportal kportal
# Also complete for common aliases
complete -F _kportal kp
`
sb.WriteString(bashScript)
return sb.String(), nil
}
// generateZsh generates zsh completion script
func generateZsh() (string, error) {
var sb strings.Builder
// Per-flag zsh _arguments descriptors. Completion scripts are static text, so
// these are literals (single source of truth — keep in sync with the CLI flags).
flagDescs := []string{
`'-c[Path to configuration file]:config file:_files -g "*.yaml"'`,
`'-v[Enable verbose logging]'`,
`'--version[Show version and exit]'`,
`'--update[Check for updates]'`,
`'--check[Validate configuration]'`,
`'--headless[Run without UI]'`,
`'--log-format[Log format: text or json]:format:(text json)'`,
`'--convert[Convert kftray config]:input file:_files -g "*.json"'`,
`'--convert-output[Output file]:output file:_files -g "*.yaml"'`,
}
sb.WriteString(`#compdef kportal
# kportal shell completion - zsh
# Generated by kportal
_kportal()
{
local -a commands flags generate_flags completion_flags
commands=(
'generate:Interactively generate forwards from cluster'
'completion:Generate shell completion scripts'
)
flags=(
`)
for _, desc := range flagDescs {
fmt.Fprintf(&sb, " %s\n", desc)
}
sb.WriteString(` )
generate_flags=(
'--context[Kubernetes context]:context:->ctx'
'--config[Config file]:file:_files -g "*.yaml"'
'--dry-run[Print without saving]'
)
completion_flags=(
'--install[Install completions for the shell]'
'--uninstall[Remove installed completions]'
'--shell[Shell type]:shell:(bash zsh fish)'
)
_arguments -s $flags '1: :->command' '*:: :->args'
case $state in
command)
_describe 'command' commands
;;
args)
case ${words[1]} in
generate)
_arguments -s $generate_flags
if [[ "$words[CURRENT]" == --context ]]; then
if command -v kubectl &> /dev/null; then
local -a contexts
contexts=(${(f)"$(kubectl config get-contexts -o name 2>/dev/null)"})
_describe 'context' contexts
fi
fi
;;
completion)
_arguments -s $completion_flags
;;
esac
;;
esac
}
_kportal "$@"
`)
return sb.String(), nil
}
// generateFish generates fish completion script
func generateFish() (string, error) {
var sb strings.Builder
sb.WriteString(`# kportal shell completion - fish
# Generated by kportal
# Main completion
complete -c kportal -f
# Subcommands
complete -c kportal -n '__fish_use_subcommand' -a 'generate' -d 'Interactively generate forwards from cluster'
complete -c kportal -n '__fish_use_subcommand' -a 'completion' -d 'Generate shell completion scripts'
# Global flags (main command uses single-dash -c and -v; words accept --)
complete -c kportal -s c -r -f -a '( __fish_complete_suffix .yaml )' -d 'Path to configuration file'
complete -c kportal -s v -d 'Enable verbose logging'
complete -c kportal -l version -d 'Show version and exit'
complete -c kportal -l update -d 'Check for updates'
complete -c kportal -l check -d 'Validate configuration'
complete -c kportal -l headless -d 'Run without UI'
complete -c kportal -l log-format -d 'Log format' -a 'text json' -f
complete -c kportal -l convert -r -f -a '( __fish_complete_suffix .json )' -d 'Convert kftray config'
complete -c kportal -l convert-output -r -f -a '( __fish_complete_suffix .yaml )' -d 'Output file'
# generate subcommand flags
complete -c kportal -n '__fish_seen_subcommand_from generate' -l context -d 'Kubernetes context' -a '(kubectl config get-contexts -o name 2>/dev/null)' -f
complete -c kportal -n '__fish_seen_subcommand_from generate' -l config -r -f -a '( __fish_complete_suffix .yaml )' -d 'Config file'
complete -c kportal -n '__fish_seen_subcommand_from generate' -l dry-run -d 'Print without saving'
# completion subcommand flags
complete -c kportal -n '__fish_seen_subcommand_from completion' -l install -d 'Install completions for the shell'
complete -c kportal -n '__fish_seen_subcommand_from completion' -l uninstall -d 'Remove installed completions'
complete -c kportal -n '__fish_seen_subcommand_from completion' -l shell -d 'Shell type' -a 'bash zsh fish' -f
# Aliases
complete -c kp -f
`)
return sb.String(), nil
}
// InstallCompletions installs completions for the specified shell
// Prints instructions for manual installation if auto-install fails
func InstallCompletions(shell Shell) error {
installer := NewInstaller(shell)
if err := installer.Install(); err != nil {
// Check if it's just "already exists" error
if strings.Contains(err.Error(), "already exists") {
fmt.Printf("Completion already installed at: %s/%s\n",
installer.prefixDir, installer.getCompletionFilename())
fmt.Println("Remove it first to reinstall, or source it manually:")
return nil
}
// Try to print the script instead
fmt.Println("Could not auto-install completions. To install manually, run:")
fmt.Printf(" # %s\n", getInstallInstructions(shell))
return nil
}
fmt.Printf("✅ Completions installed to: %s/%s\n",
installer.prefixDir, installer.getCompletionFilename())
fmt.Printf("\nTo enable %s completions, restart your shell or run:\n", shell)
fmt.Printf(" source ~/%s\n", getSourceInstruction(shell))
return nil
}
func getInstallInstructions(shell Shell) string {
return fmt.Sprintf("kportal completion %s > <your-completion-dir>/_kportal", shell)
}
func getSourceInstruction(shell Shell) string {
switch shell {
case ShellBash:
return ".bashrc"
case ShellZsh:
return ".zshrc"
case ShellFish:
return ".config/fish/config.fish # completions load automatically"
}
return "shell config"
}
// AutoDetectShell detects the current shell
func AutoDetectShell() Shell {
shell := os.Getenv("SHELL")
if shell == "" {
return ShellBash // Default
}
if strings.HasSuffix(shell, "/bash") {
return ShellBash
}
if strings.HasSuffix(shell, "/zsh") {
return ShellZsh
}
if strings.HasSuffix(shell, "/fish") {
return ShellFish
}
return ShellBash
}
// pathExists reports whether path exists on disk.
// #nosec G703 -- callers pass fixed completion paths derived from the user's own
// HOME and constant subdirectory names, never external/untrusted input.
func pathExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
// ensureDir best-effort creates a completion directory. Errors are intentionally
// ignored: Install() surfaces any later write failure with a clear message.
// #nosec G703 -- path is derived from the user's own HOME and constant
// subdirectory names; 0o750 is appropriate for user-owned completion dirs.
func ensureDir(path string) {
_ = os.MkdirAll(path, 0o750)
}
+327
View File
@@ -0,0 +1,327 @@
package complete
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestGenerateBash(t *testing.T) {
script, err := Generate(ShellBash)
if err != nil {
t.Fatalf("Generate(ShellBash) failed: %v", err)
}
// Verify script contains key elements
if !strings.Contains(script, "_kportal()") {
t.Error("Bash script missing _kportal function")
}
if !strings.Contains(script, "complete -F _kportal kportal") {
t.Error("Bash script missing completion registration")
}
if !strings.Contains(script, "--version") {
t.Error("Bash script missing --version flag")
}
if !strings.Contains(script, "generate") {
t.Error("Bash script missing generate subcommand")
}
}
func TestGenerateZsh(t *testing.T) {
script, err := Generate(ShellZsh)
if err != nil {
t.Fatalf("Generate(ShellZsh) failed: %v", err)
}
// Verify script contains key elements
if !strings.Contains(script, "#compdef kportal") {
t.Error("Zsh script missing #compdef directive")
}
if !strings.Contains(script, "_kportal()") {
t.Error("Zsh script missing _kportal function")
}
if !strings.Contains(script, "'generate:") {
t.Error("Zsh script missing generate subcommand")
}
if !strings.Contains(script, "'--context[") {
t.Error("Zsh script missing --context flag")
}
}
func TestGenerateFish(t *testing.T) {
script, err := Generate(ShellFish)
if err != nil {
t.Fatalf("Generate(ShellFish) failed: %v", err)
}
// Verify script contains key elements
if !strings.Contains(script, "complete -c kportal") {
t.Error("Fish script missing complete directive")
}
if !strings.Contains(script, "-n '__fish_use_subcommand'") {
t.Error("Fish script missing subcommand detection")
}
if !strings.Contains(script, "-l context") {
t.Error("Fish script missing --context flag")
}
}
func TestGenerateUnsupported(t *testing.T) {
_, err := Generate(Shell("unsupported"))
if err == nil {
t.Error("Expected error for unsupported shell")
}
}
func TestAutoDetectShell(t *testing.T) {
tests := []struct {
name string
shellEnv string
expected Shell
}{
{"bash", "/bin/bash", ShellBash},
{"zsh", "/usr/bin/zsh", ShellZsh},
{"fish", "/usr/local/bin/fish", ShellFish},
{"tcsh", "/bin/tcsh", ShellBash}, // Falls back to bash
{"empty", "", ShellBash}, // Falls back to bash
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
detected := autoDetectShellFromEnv(tt.shellEnv)
if detected != tt.expected {
t.Errorf("AutoDetectShell() = %v, want %v", detected, tt.expected)
}
})
}
}
// autoDetectShellFromEnv is a test helper that simulates shell detection
func autoDetectShellFromEnv(shellEnv string) Shell {
if shellEnv == "" {
return ShellBash
}
if strings.HasSuffix(shellEnv, "/bash") {
return ShellBash
}
if strings.HasSuffix(shellEnv, "/zsh") {
return ShellZsh
}
if strings.HasSuffix(shellEnv, "/fish") {
return ShellFish
}
return ShellBash
}
func TestInstaller(t *testing.T) {
// Test with bash
installer := NewInstaller(ShellBash)
if installer.prefixDir == "" {
t.Error("Installer should have a prefix directory")
}
// Test filename generation
t.Run("filename", func(t *testing.T) {
tests := []struct {
shell Shell
expected string
}{
{ShellBash, "_kportal"},
{ShellZsh, "_kportal"},
{ShellFish, "kportal.fish"},
}
for _, tt := range tests {
inst := NewInstaller(tt.shell)
got := inst.getCompletionFilename()
if got != tt.expected {
t.Errorf("getCompletionFilename() for %v = %v, want %v", tt.shell, got, tt.expected)
}
}
})
}
func TestInstallCompletion(t *testing.T) {
// Temp directory (auto-removed by the test framework)
tempDir := t.TempDir()
// Test installation to temp directory
installer := &Installer{
shell: ShellBash,
prefixDir: tempDir,
}
// Should fail because file doesn't exist (doesn't matter, just test the method exists)
_ = installer.Install()
}
func TestPrint(t *testing.T) {
// Test that Print doesn't crash and outputs something
orig := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w
err := Print(ShellBash)
_ = w.Close()
os.Stdout = orig
if err != nil {
t.Fatalf("Print(ShellBash) failed: %v", err)
}
// Read output
buf := make([]byte, 1024)
n, _ := r.Read(buf)
output := string(buf[:n])
if !strings.Contains(output, "_kportal") {
t.Error("Print output should contain _kportal")
}
}
func TestGetCompletionFilename(t *testing.T) {
tests := []struct {
shell Shell
expected string
}{
{ShellBash, "_kportal"},
{ShellZsh, "_kportal"},
{ShellFish, "kportal.fish"},
{Shell("unknown"), "_kportal"}, // Falls back
}
for _, tt := range tests {
inst := &Installer{shell: tt.shell}
got := inst.getCompletionFilename()
if got != tt.expected {
t.Errorf("getCompletionFilename() for %v = %v, want %v", tt.shell, got, tt.expected)
}
}
}
func TestBashCompletionIncludesContextCompletion(t *testing.T) {
script, err := Generate(ShellBash)
if err != nil {
t.Fatalf("Generate(ShellBash) failed: %v", err)
}
// Should include kubectl context completion
if !strings.Contains(script, "kubectl config get-contexts") {
t.Error("Bash completion should include kubectl context completion")
}
}
func TestZshCompletionIncludesContextCompletion(t *testing.T) {
script, err := Generate(ShellZsh)
if err != nil {
t.Fatalf("Generate(ShellZsh) failed: %v", err)
}
// Should include kubectl context completion
if !strings.Contains(script, "kubectl config get-contexts") {
t.Error("Zsh completion should include kubectl context completion")
}
}
func TestFishCompletionIncludesContextCompletion(t *testing.T) {
script, err := Generate(ShellFish)
if err != nil {
t.Fatalf("Generate(ShellFish) failed: %v", err)
}
// Should include kubectl context completion
if !strings.Contains(script, "kubectl config get-contexts") {
t.Error("Fish completion should include kubectl context completion")
}
}
func TestAllFlagsPresent(t *testing.T) {
script, err := Generate(ShellBash)
if err != nil {
t.Fatalf("Generate(ShellBash) failed: %v", err)
}
// Check all main flags are present. The main command exposes single-dash
// -c/-v (no --config/--verbose long forms); word flags use --.
flags := []string{
"-c",
"-v",
"--version",
"--update",
"--check",
"--headless",
"--log-format",
"--convert",
"--convert-output",
}
for _, flag := range flags {
if !strings.Contains(script, flag) {
t.Errorf("Bash completion missing flag: %s", flag)
}
}
}
func TestSubcommandsPresent(t *testing.T) {
for _, shell := range []Shell{ShellBash, ShellZsh, ShellFish} {
script, err := Generate(shell)
if err != nil {
t.Fatalf("Generate(%v) failed: %v", shell, err)
}
for _, sub := range []string{"generate", "completion"} {
if !strings.Contains(script, sub) {
t.Errorf("%s completion missing %q subcommand", shell, sub)
}
}
}
}
func TestGenerateFlagsPresent(t *testing.T) {
script, err := Generate(ShellBash)
if err != nil {
t.Fatalf("Generate(ShellBash) failed: %v", err)
}
generateFlags := []string{
"--context",
"--config",
"--dry-run",
}
for _, flag := range generateFlags {
if !strings.Contains(script, flag) {
t.Errorf("Bash completion missing generate flag: %s", flag)
}
}
}
func TestCompletionScriptPermissions(t *testing.T) {
// Create temp file and verify permissions handling
tempDir := t.TempDir()
installer := &Installer{
shell: ShellBash,
prefixDir: tempDir,
}
filename := filepath.Join(tempDir, installer.getCompletionFilename())
// Write a test file
err := os.WriteFile(filename, []byte("test"), 0o600)
if err != nil {
t.Fatalf("Failed to write test file: %v", err)
}
// Verify file exists and is readable
data, err := os.ReadFile(filename)
if err != nil {
t.Fatalf("Failed to read test file: %v", err)
}
if string(data) != "test" {
t.Error("File content mismatch")
}
}
+29
View File
@@ -60,9 +60,29 @@ type Config struct {
HealthCheck *HealthCheckSpec `yaml:"healthCheck,omitempty"`
Reliability *ReliabilitySpec `yaml:"reliability,omitempty"`
MDNS *MDNSSpec `yaml:"mdns,omitempty"`
TUI *TUISpec `yaml:"tui,omitempty"`
Contexts []Context `yaml:"contexts"`
}
// TUISpec configures the terminal UI table display
type TUISpec struct {
// Columns lists the forwards-table columns to display, in the order they
// should appear. Omitting this field (or leaving it empty) keeps the
// built-in default column set and order. Any column not listed here is
// hidden.
Columns []TableColumn `yaml:"columns,omitempty"`
}
// TableColumn configures a single column of the forwards table.
type TableColumn struct {
// Name is the column identifier: context, namespace, alias, type,
// resource, remote, local, or status.
Name string `yaml:"name"`
// Width is the maximum display width for the column, in characters.
// If zero or omitted, the column's built-in default width is used.
Width int `yaml:"width,omitempty"`
}
// MDNSSpec configures mDNS (multicast DNS) hostname publishing
// When enabled, forwards with aliases can be accessed via <alias>.local hostnames
type MDNSSpec struct {
@@ -174,6 +194,15 @@ func (c *Config) IsMDNSEnabled() bool {
return c.MDNS != nil && c.MDNS.Enabled
}
// GetTableColumns returns the configured TUI table columns, or nil if the
// built-in default column set and order should be used.
func (c *Config) GetTableColumns() []TableColumn {
if c == nil || c.TUI == nil || len(c.TUI.Columns) == 0 {
return nil
}
return c.TUI.Columns
}
// Context represents a Kubernetes context with its namespaces
type Context struct {
Name string `yaml:"name"`
+83 -2
View File
@@ -1,6 +1,7 @@
package config
import (
"errors"
"fmt"
"regexp"
"strings"
@@ -85,6 +86,7 @@ func (v *Validator) ValidateConfigWithOptions(cfg *Config, allowEmpty bool) []Va
if allowEmpty && cfg.IsEmpty() {
// Still validate health check and reliability if present (they don't require forwards)
errs = append(errs, v.validateSpecDurations(cfg)...)
errs = append(errs, v.validateTUI(cfg)...)
return errs
}
@@ -111,6 +113,9 @@ func (v *Validator) ValidateConfigWithOptions(cfg *Config, allowEmpty bool) []Va
// Validate duration fields in specs
errs = append(errs, v.validateSpecDurations(cfg)...)
// Validate TUI table column configuration
errs = append(errs, v.validateTUI(cfg)...)
return errs
}
@@ -447,10 +452,10 @@ func FormatValidationErrors(errs []ValidationError) string {
sb.WriteString(strings.Repeat("=", 50) + "\n\n")
for i, err := range errs {
sb.WriteString(fmt.Sprintf("%d. %s\n", i+1, err.Message))
fmt.Fprintf(&sb, "%d. %s\n", i+1, err.Message)
if len(err.Context) > 0 {
for k, v := range err.Context {
sb.WriteString(fmt.Sprintf(" %s: %s\n", k, v))
fmt.Fprintf(&sb, " %s: %s\n", k, v)
}
}
sb.WriteString("\n")
@@ -507,6 +512,74 @@ func (v *Validator) validateMDNS(cfg *Config) []ValidationError {
return errs
}
// maxTableColumnWidth caps tui.columns width so a typo cannot blow up the table.
const maxTableColumnWidth = 200
// validTableColumnNames are the recognized forwards-table column identifiers.
var validTableColumnNames = map[string]bool{
"context": true,
"namespace": true,
"alias": true,
"type": true,
"resource": true,
"remote": true,
"local": true,
"status": true,
}
// validateTUI validates the tui.columns configuration.
func (v *Validator) validateTUI(cfg *Config) []ValidationError {
var errs []ValidationError
if cfg.TUI == nil {
return errs
}
seen := make(map[string]bool)
for i, col := range cfg.TUI.Columns {
name := strings.ToLower(strings.TrimSpace(col.Name))
if name == "" {
errs = append(errs, ValidationError{
Field: fmt.Sprintf("tui.columns[%d].name", i),
Message: "Column name cannot be empty",
})
continue
}
if !validTableColumnNames[name] {
errs = append(errs, ValidationError{
Field: fmt.Sprintf("tui.columns[%d].name", i),
Message: fmt.Sprintf("Invalid column name '%s' (must be one of: context, namespace, alias, type, resource, remote, local, status)", col.Name),
})
continue
}
if seen[name] {
errs = append(errs, ValidationError{
Field: fmt.Sprintf("tui.columns[%d].name", i),
Message: fmt.Sprintf("Duplicate column '%s' in tui.columns", name),
})
continue
}
seen[name] = true
if col.Width < 0 {
errs = append(errs, ValidationError{
Field: fmt.Sprintf("tui.columns[%d].width", i),
Message: fmt.Sprintf("Column '%s' width cannot be negative", name),
})
} else if col.Width > maxTableColumnWidth {
errs = append(errs, ValidationError{
Field: fmt.Sprintf("tui.columns[%d].width", i),
Message: fmt.Sprintf("Column '%s' width cannot exceed %d", name, maxTableColumnWidth),
})
}
}
return errs
}
// isValidHostname checks if a string is a valid RFC 1123 hostname.
// Hostnames must start with alphanumeric, contain only alphanumeric and hyphens,
// and be 1-63 characters long.
@@ -625,6 +698,14 @@ func validateDNS1123Label(name, field, entityType string) *ValidationError {
return nil
}
// ValidateNamespaceName validates a Kubernetes namespace name (a DNS-1123 label).
func ValidateNamespaceName(name string) error {
if verr := validateDNS1123Label(name, "namespace", "Namespace"); verr != nil {
return errors.New(verr.Message)
}
return nil
}
// validateDNS1123Subdomain validates that a name is a valid DNS subdomain name (RFC 1123).
// Used for resource names which can contain dots.
func validateDNS1123Subdomain(name, field, entityType string) *ValidationError {
+134
View File
@@ -2050,3 +2050,137 @@ func TestValidateProtocol(t *testing.T) {
})
}
}
func TestValidator_ValidateTUI(t *testing.T) {
validator := NewValidator()
baseContexts := []Context{
{
Name: "dev",
Namespaces: []Namespace{
{
Name: "default",
Forwards: []Forward{
{Resource: "pod/app", Protocol: "tcp", Port: 8080, LocalPort: 8080, contextName: "dev", namespaceName: "default"},
},
},
},
},
}
tests := []struct {
config *Config
name string
errorContains string
expectErrors bool
}{
{
name: "no tui config - no validation",
config: &Config{Contexts: baseContexts},
expectErrors: false,
},
{
name: "valid columns",
config: &Config{
Contexts: baseContexts,
TUI: &TUISpec{
Columns: []TableColumn{
{Name: "alias", Width: 30},
{Name: "status"},
},
},
},
expectErrors: false,
},
{
name: "unknown column name",
config: &Config{
Contexts: baseContexts,
TUI: &TUISpec{Columns: []TableColumn{{Name: "bogus"}}},
},
expectErrors: true,
errorContains: "Invalid column name",
},
{
name: "empty column name",
config: &Config{
Contexts: baseContexts,
TUI: &TUISpec{Columns: []TableColumn{{Name: ""}}},
},
expectErrors: true,
errorContains: "cannot be empty",
},
{
name: "duplicate column",
config: &Config{
Contexts: baseContexts,
TUI: &TUISpec{Columns: []TableColumn{{Name: "alias"}, {Name: "Alias"}}},
},
expectErrors: true,
errorContains: "Duplicate column",
},
{
name: "negative width",
config: &Config{
Contexts: baseContexts,
TUI: &TUISpec{Columns: []TableColumn{{Name: "alias", Width: -1}}},
},
expectErrors: true,
errorContains: "cannot be negative",
},
{
name: "width at cap",
config: &Config{
Contexts: baseContexts,
TUI: &TUISpec{Columns: []TableColumn{{Name: "alias", Width: 200}}},
},
expectErrors: false,
},
{
name: "width above cap",
config: &Config{
Contexts: baseContexts,
TUI: &TUISpec{Columns: []TableColumn{{Name: "alias", Width: 201}}},
},
expectErrors: true,
errorContains: "cannot exceed 200",
},
{
name: "name is case and whitespace insensitive",
config: &Config{
Contexts: baseContexts,
TUI: &TUISpec{Columns: []TableColumn{{Name: " Alias "}, {Name: "STATUS"}}},
},
expectErrors: false,
},
{
name: "padded duplicate is detected",
config: &Config{
Contexts: baseContexts,
TUI: &TUISpec{Columns: []TableColumn{{Name: "alias"}, {Name: " Alias "}}},
},
expectErrors: true,
errorContains: "Duplicate column",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
errs := validator.ValidateConfig(tt.config)
if tt.expectErrors {
assert.NotEmpty(t, errs)
found := false
for _, err := range errs {
if strings.Contains(err.Message, tt.errorContains) {
found = true
break
}
}
assert.True(t, found, "expected error message '%s' not found in errors: %v", tt.errorContains, errs)
} else {
assert.Empty(t, errs, "expected no validation errors, got: %v", errs)
}
})
}
}
+7 -3
View File
@@ -67,6 +67,10 @@ func formatProcessList(processes []processInfo) string {
// getProcessNameByPID retrieves the process name for a given PID on Unix systems
func getProcessNameByPID(pid string) string {
if !isValidPID(pid) {
return ""
}
// #nosec G204 -- pid is validated by isValidPID() to contain only digits
cmd := exec.Command("ps", "-p", pid, "-o", "comm=")
output, err := cmd.Output()
if err != nil {
@@ -302,11 +306,11 @@ func FormatConflicts(conflicts []PortConflict) string {
sb.WriteString(strings.Repeat("=", 50) + "\n\n")
for _, conflict := range conflicts {
sb.WriteString(fmt.Sprintf("Port %d\n", conflict.Port))
fmt.Fprintf(&sb, "Port %d\n", conflict.Port)
if conflict.Resource != "" {
sb.WriteString(fmt.Sprintf(" Needed for: %s\n", conflict.Resource))
fmt.Fprintf(&sb, " Needed for: %s\n", conflict.Resource)
}
sb.WriteString(fmt.Sprintf(" Currently used by: %s\n", conflict.UsedBy))
fmt.Fprintf(&sb, " Currently used by: %s\n", conflict.UsedBy)
sb.WriteString("\n")
}
+2 -1
View File
@@ -1,6 +1,7 @@
package forward
import (
"fmt"
"sync"
"testing"
"time"
@@ -264,7 +265,7 @@ func (s *WatchdogTestSuite) TestConcurrentOperations() {
wg.Add(1)
go func(id int) {
defer wg.Done()
forwardID := string(rune('a' + id))
forwardID := fmt.Sprintf("worker-%d", id)
s.watchdog.RegisterWorker(forwardID, nil)
for j := 0; j < 10; j++ {
s.watchdog.Heartbeat(forwardID)
+6 -4
View File
@@ -91,13 +91,15 @@ func (p *Proxy) Start() error {
p.listener = ln
// Create reverse proxy
director := func(req *http.Request) {
req.URL.Scheme = "http"
req.URL.Host = fmt.Sprintf("127.0.0.1:%d", p.targetPort)
rewrite := func(pr *httputil.ProxyRequest) {
pr.Out.URL.Scheme = "http"
pr.Out.URL.Host = fmt.Sprintf("127.0.0.1:%d", p.targetPort)
// Rewrite drops X-Forwarded-For, which Director used to send.
pr.SetXForwarded()
}
proxy := &httputil.ReverseProxy{
Director: director,
Rewrite: rewrite,
Transport: &loggingTransport{
proxy: p,
transport: http.DefaultTransport,
+32
View File
@@ -5,7 +5,10 @@ import (
"encoding/json"
"net"
"net/http"
"net/http/httptest"
"net/url"
"os"
"strconv"
"testing"
"github.com/lukaszraczylo/kportal/internal/config"
@@ -421,3 +424,32 @@ func TestProxy_LogError(t *testing.T) {
assert.Equal(t, "/test", entry.Path)
assert.Contains(t, entry.Error, "assert.AnError")
}
func TestProxy_ForwardsToTargetPortAndSendsForwardedFor(t *testing.T) {
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Seen-Path", r.URL.Path)
w.Header().Set("X-Seen-Forwarded-For", r.Header.Get("X-Forwarded-For"))
}))
defer backend.Close()
backendURL, err := url.Parse(backend.URL)
require.NoError(t, err)
targetPort, err := strconv.Atoi(backendURL.Port())
require.NoError(t, err)
var buf bytes.Buffer
proxy := &Proxy{
targetPort: targetPort,
logger: &Logger{forwardID: "test", maxBodyLen: 1024, output: &buf},
forwardID: "test-fwd",
}
require.NoError(t, proxy.Start())
defer func() { _ = proxy.Stop() }()
resp, err := http.Get("http://" + proxy.listener.Addr().String() + "/hello")
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.Equal(t, "/hello", resp.Header.Get("X-Seen-Path"))
assert.Equal(t, "127.0.0.1", resp.Header.Get("X-Seen-Forwarded-For"))
}
+74 -30
View File
@@ -72,6 +72,7 @@ type BubbleTeaUI struct {
discovery *k8s.Discovery
program *tea.Program
forwards map[string]*ForwardStatus
columns []ResolvedColumn
benchmarkState *BenchmarkState
httpLogSubscriber HTTPLogSubscriber
disabledMap map[string]bool
@@ -115,11 +116,20 @@ func NewBubbleTeaUI(toggleCallback func(id string, enable bool), version string)
version: version,
errors: make(map[string]string),
viewMode: ViewModeMain,
columns: ResolveColumns(nil),
}
return ui
}
// SetColumns updates the forwards-table column set and order, e.g. after a
// config hot-reload. cfg may be nil to reset to the built-in defaults.
func (ui *BubbleTeaUI) SetColumns(cfg *config.Config) {
ui.mu.Lock()
defer ui.mu.Unlock()
ui.columns = ResolveColumns(cfg)
}
// SetWizardDependencies sets the dependencies needed for the add/remove wizards
func (ui *BubbleTeaUI) SetWizardDependencies(discovery *k8s.Discovery, mutator *config.Mutator, configPath string) {
ui.mu.Lock()
@@ -448,6 +458,7 @@ type keyBinding struct {
func mainViewKeyBindings() []keyBinding {
return []keyBinding{
{"↑↓/jk", "Navigate"},
{"PgUp/Dn", "Page"},
{"Space", "Toggle"},
{"n", "New"},
{"e", "Edit"},
@@ -480,7 +491,7 @@ func (m model) renderMainView() string {
// Render error section if any errors exist
if len(m.ui.errors) > 0 {
b.WriteString(m.renderErrorSection())
b.WriteString(m.renderErrorSection(termWidth))
}
// Render footer with proper spacing
@@ -527,10 +538,14 @@ func (m model) renderTitle(headerColor lipgloss.Color) string {
return b.String()
}
// renderEmptyMessage renders the message shown when no forwards are configured
// renderEmptyMessage renders the message shown when no forwards are configured.
// It includes an actionable hint so a first-time user knows how to proceed.
func (m model) renderEmptyMessage(mutedColor lipgloss.Color) string {
disabledStyle := lipgloss.NewStyle().Foreground(mutedColor)
return disabledStyle.Render("No forwards configured\n")
mutedStyle := lipgloss.NewStyle().Foreground(mutedColor)
hintStyle := lipgloss.NewStyle().Foreground(highlightColor)
return mutedStyle.Render("No forwards configured") + "\n\n" +
hintStyle.Render(" Press ") + selectedStyle.Render("n") +
hintStyle.Render(" to add your first port forward.") + "\n"
}
// renderForwardsTable renders the forwards table with all styling
@@ -540,10 +555,15 @@ func (m model) renderForwardsTable(colors mainViewColors) string {
// Build table rows
rows := m.buildTableRows()
headers := make([]string, len(m.ui.columns))
for i, col := range m.ui.columns {
headers[i] = col.Header
}
// Create table with styling (no borders for cleaner look)
t := table.New().
Border(lipgloss.HiddenBorder()).
Headers("CONTEXT", "NAMESPACE", "ALIAS", "TYPE", "RESOURCE", "REMOTE", "LOCAL", "STATUS").
Headers(headers...).
Rows(rows...).
StyleFunc(m.createTableStyleFunc(colors))
@@ -565,21 +585,23 @@ func (m model) buildTableRows() [][]string {
statusIcon, statusText := m.getStatusIconAndText(id, fwd)
localPortText := fmt.Sprintf("%d", fwd.LocalPort)
if fwd.Status == "Active" && !m.ui.isForwardDisabled(id) {
localPortText = hyperlink(fmt.Sprintf("http://127.0.0.1:%d", fwd.LocalPort), fmt.Sprintf("%d→", fwd.LocalPort))
row := make([]string, len(m.ui.columns))
for i, col := range m.ui.columns {
switch col.Key {
case ColKeyLocal:
localPortText := fmt.Sprintf("%d", fwd.LocalPort)
if fwd.Status == "Active" && !m.ui.isForwardDisabled(id) {
localPortText = hyperlink(fmt.Sprintf("http://127.0.0.1:%d", fwd.LocalPort), fmt.Sprintf("%d→", fwd.LocalPort))
}
row[i] = localPortText
case ColKeyStatus:
row[i] = statusIcon + " " + statusText
default:
row[i] = columnValue(col, 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),
localPortText,
statusIcon + " " + statusText,
})
rows = append(rows, row)
}
return rows
@@ -608,6 +630,8 @@ func (m model) getStatusIconAndText(id string, fwd *ForwardStatus) (icon, text s
// createTableStyleFunc creates the style function for the forwards table
func (m model) createTableStyleFunc(colors mainViewColors) func(row, col int) lipgloss.Style {
statusCol := columnIndex(m.ui.columns, ColKeyStatus)
return func(row, col int) lipgloss.Style {
// Header row
if row == table.HeaderRow {
@@ -638,7 +662,7 @@ func (m model) createTableStyleFunc(colors mainViewColors) func(row, col int) li
}
// Status column gets colored based on status
if col == ColumnStatus && ok {
if col == statusCol && ok {
switch fwd.Status {
case "Active":
return baseStyle.Foreground(colors.active)
@@ -655,10 +679,12 @@ func (m model) createTableStyleFunc(colors mainViewColors) func(row, col int) li
}
}
// renderErrorSection renders the error display section
func (m model) renderErrorSection() string {
// renderErrorSection renders the error display section, sized to the terminal.
func (m model) renderErrorSection(termWidth int) string {
var b strings.Builder
width := errorWidth(termWidth)
b.WriteString("\n\n")
errorHeaderStyle := lipgloss.NewStyle().
Bold(true).
@@ -669,28 +695,44 @@ func (m model) renderErrorSection() string {
errorLineStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("196")).
Width(ErrorDisplayWidth).
MaxWidth(ErrorDisplayWidth)
Width(width).
MaxWidth(width)
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))
b.WriteString(m.renderErrorLine(fwd.Alias, errMsg, width, errorLineStyle))
}
}
return b.String()
}
// errorWidth clamps the error display to the terminal width, falling back to the
// default cap so errors never overflow narrow terminals nor sprawl on wide ones.
func errorWidth(termWidth int) int {
width := ErrorDisplayWidth
if termWidth > 0 && termWidth-2 < width {
width = termWidth - 2
}
if width < 20 {
width = 20
}
return width
}
// renderErrorLine renders a single error line with proper wrapping
func (m model) renderErrorLine(alias, errMsg string, style lipgloss.Style) string {
func (m model) renderErrorLine(alias, errMsg string, width int, 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)
maxErrLen := width - len(prefix)
if maxErrLen < 1 {
maxErrLen = 1
}
wrappedMsg := wrapText(errMsg, maxErrLen)
// Render first line with prefix
@@ -750,9 +792,10 @@ func (m model) buildFooterLines(termWidth int) []string {
var currentLine strings.Builder
currentLineVisualLen := 0
// Calculate how much space we need for the total count suffix
// Calculate how much space we need for the total count suffix.
// Use lipgloss.Width for true display width (the "│" glyph is 3 bytes / 1 col).
totalSuffix := fmt.Sprintf(" │ Total: %d", len(m.ui.forwardOrder))
totalSuffixLen := len(totalSuffix)
totalSuffixLen := lipgloss.Width(totalSuffix)
// Available width (account for some margin)
availableWidth := termWidth - 4
@@ -761,8 +804,9 @@ func (m model) buildFooterLines(termWidth int) []string {
// Build this binding's text
keyRendered := keyStyle.Render(binding.key)
bindingText := keyRendered + ": " + binding.desc
// Visual length without ANSI codes
bindingVisualLen := len(binding.key) + 2 + len(binding.desc)
// True display width: strips ANSI and counts wide/unicode glyphs (e.g. ↑↓)
// correctly, where len() would over-count multibyte runes and wrap early.
bindingVisualLen := lipgloss.Width(bindingText)
// Add separator if not first item on line
separator := ""
+160
View File
@@ -0,0 +1,160 @@
package ui
import (
"fmt"
"slices"
"strings"
"github.com/lukaszraczylo/kportal/internal/config"
)
// ColumnKey identifies a forwards-table column.
type ColumnKey string
// Recognized column keys, matching the names accepted in tui.columns.
const (
ColKeyContext ColumnKey = "context"
ColKeyNamespace ColumnKey = "namespace"
ColKeyAlias ColumnKey = "alias"
ColKeyType ColumnKey = "type"
ColKeyResource ColumnKey = "resource"
ColKeyRemote ColumnKey = "remote"
ColKeyLocal ColumnKey = "local"
ColKeyStatus ColumnKey = "status"
)
// ResolvedColumn describes a single column ready for rendering: its header
// text, pad width, truncation limit, and identity.
type ResolvedColumn struct {
Key ColumnKey
Header string
// Width is the padded width, used by the plain table only.
Width int
// Max is the truncation limit; zero leaves the value untruncated.
Max int
}
// tuiDefaultColumns is the Bubble Tea column set and order, used when the
// config does not specify tui.columns.
var tuiDefaultColumns = []ResolvedColumn{
{Key: ColKeyContext, Header: "CONTEXT", Width: ColumnWidthContext, Max: ColumnWidthContext},
{Key: ColKeyNamespace, Header: "NAMESPACE", Width: ColumnWidthNamespace, Max: ColumnWidthNamespace},
{Key: ColKeyAlias, Header: "ALIAS", Width: ColumnWidthAlias, Max: ColumnWidthAlias},
{Key: ColKeyType, Header: "TYPE", Width: ColumnWidthType, Max: ColumnWidthType},
{Key: ColKeyResource, Header: "RESOURCE", Width: ColumnWidthResource, Max: ColumnWidthResource},
{Key: ColKeyRemote, Header: "REMOTE"},
{Key: ColKeyLocal, Header: "LOCAL"},
{Key: ColKeyStatus, Header: "STATUS"},
}
// plainDefaultColumns is the -verbose table column set, widths and headers as
// rendered before columns became configurable.
var plainDefaultColumns = []ResolvedColumn{
{Key: ColKeyContext, Header: "CONTEXT", Width: 15},
{Key: ColKeyNamespace, Header: "NAMESPACE", Width: 18},
{Key: ColKeyAlias, Header: "ALIAS", Width: 25, Max: 25},
{Key: ColKeyType, Header: "TYPE", Width: 10},
{Key: ColKeyResource, Header: "RESOURCE", Width: 25, Max: 25},
{Key: ColKeyRemote, Header: "REMOTE PORT", Width: 12},
{Key: ColKeyLocal, Header: "LOCAL PORT", Width: 12},
{Key: ColKeyStatus, Header: "STATUS", Width: 12},
}
// ResolveColumns builds the Bubble Tea columns from the tui.columns
// configuration. A nil config, or one with no tui.columns entries, yields the
// built-in default column set and order.
func ResolveColumns(cfg *config.Config) []ResolvedColumn {
return resolveColumns(cfg, tuiDefaultColumns)
}
// plainStatusWidth fits the longest status with indicator ("↻ Reconnecting"),
// so a status column that is not last cannot push later columns out of line.
const plainStatusWidth = 14
// ResolvePlainColumns is ResolveColumns for the -verbose table.
func ResolvePlainColumns(cfg *config.Config) []ResolvedColumn {
defaults := plainDefaultColumns
if len(cfg.GetTableColumns()) > 0 {
defaults = slices.Clone(plainDefaultColumns)
defaults[columnIndex(defaults, ColKeyStatus)].Width = plainStatusWidth
}
return resolveColumns(cfg, defaults)
}
func resolveColumns(cfg *config.Config, defaults []ResolvedColumn) []ResolvedColumn {
var configured []config.TableColumn
if cfg != nil {
configured = cfg.GetTableColumns()
}
byKey := make(map[ColumnKey]ResolvedColumn, len(defaults))
for _, c := range defaults {
byKey[c.Key] = c
}
resolved := make([]ResolvedColumn, 0, len(configured))
for _, c := range configured {
col, ok := byKey[ColumnKey(strings.ToLower(strings.TrimSpace(c.Name)))]
if !ok {
// Unknown column names are rejected by the validator before this
// point is reached; skip defensively rather than render garbage.
continue
}
if c.Width > 0 {
col.Width = c.Width
col.Max = c.Width
}
resolved = append(resolved, col)
}
if len(resolved) == 0 {
return slices.Clone(defaults)
}
return resolved
}
// columnValue returns the display value for the given column of a forward,
// truncated to the column's Max when set.
func columnValue(col ResolvedColumn, fwd *ForwardStatus) string {
switch col.Key {
case ColKeyContext:
return clip(fwd.Context, col.Max)
case ColKeyNamespace:
return clip(fwd.Namespace, col.Max)
case ColKeyAlias:
return clip(fwd.Alias, col.Max)
case ColKeyType:
return clip(fwd.Type, col.Max)
case ColKeyResource:
return clip(fwd.Resource, col.Max)
case ColKeyRemote:
return fmt.Sprintf("%d", fwd.RemotePort)
case ColKeyLocal:
return fmt.Sprintf("%d", fwd.LocalPort)
case ColKeyStatus:
return fwd.Status
default:
return ""
}
}
// clip truncates s to limit runes; a non-positive limit leaves it untouched.
func clip(s string, limit int) string {
if limit <= 0 {
return s
}
return truncate(s, limit)
}
// columnIndex returns the position of key within cols, or -1 if absent.
func columnIndex(cols []ResolvedColumn, key ColumnKey) int {
for i, c := range cols {
if c.Key == key {
return i
}
}
return -1
}
+107
View File
@@ -0,0 +1,107 @@
package ui
import (
"testing"
"github.com/lukaszraczylo/kportal/internal/config"
"github.com/stretchr/testify/assert"
)
func TestResolveColumns_Default(t *testing.T) {
cols := ResolveColumns(nil)
assert.Equal(t, tuiDefaultColumns, cols)
cols = ResolveColumns(&config.Config{})
assert.Equal(t, tuiDefaultColumns, cols)
}
func TestResolveColumns_CustomOrderAndVisibility(t *testing.T) {
cfg := &config.Config{
TUI: &config.TUISpec{
Columns: []config.TableColumn{
{Name: "status"},
{Name: "alias", Width: 40},
},
},
}
cols := ResolveColumns(cfg)
assert.Len(t, cols, 2)
assert.Equal(t, ColKeyStatus, cols[0].Key)
assert.Equal(t, ColKeyAlias, cols[1].Key)
assert.Equal(t, 40, cols[1].Width)
}
func TestResolveColumns_UsesDefaultWidthWhenUnset(t *testing.T) {
cfg := &config.Config{
TUI: &config.TUISpec{
Columns: []config.TableColumn{
{Name: "namespace"},
},
},
}
cols := ResolveColumns(cfg)
assert.Len(t, cols, 1)
assert.Equal(t, ColumnWidthNamespace, cols[0].Width)
}
func TestResolveColumns_UnknownNamesSkipped(t *testing.T) {
cfg := &config.Config{
TUI: &config.TUISpec{
Columns: []config.TableColumn{
{Name: "bogus"},
},
},
}
// All entries invalid -> fall back to defaults.
assert.Equal(t, tuiDefaultColumns, ResolveColumns(cfg))
}
func TestColumnValue(t *testing.T) {
fwd := &ForwardStatus{
Context: "prod",
Namespace: "default",
Alias: "api",
Type: "service",
Resource: "api",
RemotePort: 8080,
LocalPort: 8081,
Status: "Active",
}
assert.Equal(t, "prod", columnValue(ResolvedColumn{Key: ColKeyContext, Max: 10}, fwd))
assert.Equal(t, "8080", columnValue(ResolvedColumn{Key: ColKeyRemote}, fwd))
assert.Equal(t, "8081", columnValue(ResolvedColumn{Key: ColKeyLocal}, fwd))
assert.Equal(t, "Active", columnValue(ResolvedColumn{Key: ColKeyStatus}, fwd))
}
func TestColumnIndex(t *testing.T) {
cols := []ResolvedColumn{{Key: ColKeyContext}, {Key: ColKeyStatus}}
assert.Equal(t, 1, columnIndex(cols, ColKeyStatus))
assert.Equal(t, -1, columnIndex(cols, ColKeyLocal))
}
func TestResolveColumns_DefaultIsCopy(t *testing.T) {
cols := ResolveColumns(nil)
cols[0].Header = "MUTATED"
assert.Equal(t, "CONTEXT", ResolveColumns(nil)[0].Header)
assert.Equal(t, "CONTEXT", tuiDefaultColumns[0].Header)
}
func TestResolvePlainColumns_LegacyDefaults(t *testing.T) {
cols := ResolvePlainColumns(nil)
assert.Equal(t, plainDefaultColumns, cols)
assert.Equal(t, "REMOTE PORT", cols[5].Header)
assert.Equal(t, 15, cols[0].Width)
custom := ResolvePlainColumns(&config.Config{TUI: &config.TUISpec{Columns: []config.TableColumn{{Name: " Status "}, {Name: "alias", Width: 7}}}})
assert.Equal(t, plainStatusWidth, custom[0].Width)
assert.Equal(t, 7, custom[1].Width)
assert.Equal(t, 7, custom[1].Max)
}
+12
View File
@@ -43,3 +43,15 @@ const (
// MaxPathWidth is the maximum width for displaying file paths
MaxPathWidth = 48
)
// HTTP log table layout
const (
// HTTPLogRowFormat is the column layout shared by the HTTP-log table header
// and its rows (TIME, METHOD, STATUS, LATENCY, PATH) so they stay aligned.
HTTPLogRowFormat = "%-10s %-7s %-6s %-8s %s"
// HTTPLogFixedCols is the width consumed by every column except PATH
// (prefix + the four fixed columns and their separators), used to size the
// remaining space for the path column responsively.
HTTPLogFixedCols = 48
)
+94 -35
View File
@@ -3,8 +3,11 @@ package ui
import (
"fmt"
"os"
"regexp"
"slices"
"strings"
"sync"
"unicode/utf8"
"github.com/lukaszraczylo/kportal/internal/config"
)
@@ -25,18 +28,29 @@ type ForwardStatus struct {
// TableUI manages the terminal table display
type TableUI struct {
forwards map[string]*ForwardStatus
columns []ResolvedColumn
mu sync.RWMutex
verbose bool
}
// NewTableUI creates a new table UI manager
func NewTableUI(verbose bool) *TableUI {
// NewTableUI creates a new table UI manager. cfg may be nil, in which case
// the built-in default column set and order is used.
func NewTableUI(verbose bool, cfg *config.Config) *TableUI {
return &TableUI{
forwards: make(map[string]*ForwardStatus),
verbose: verbose,
columns: ResolvePlainColumns(cfg),
}
}
// SetColumns updates the table's column set and order, e.g. after a config
// hot-reload.
func (t *TableUI) SetColumns(cfg *config.Config) {
t.mu.Lock()
defer t.mu.Unlock()
t.columns = ResolvePlainColumns(cfg)
}
// AddForward registers a new forward for display
func (t *TableUI) AddForward(id string, fwd *config.Forward) {
t.mu.Lock()
@@ -93,12 +107,11 @@ func (t *TableUI) Render() {
// Print header
fmt.Println("kportal - Port Forwarding Status")
fmt.Println(strings.Repeat("=", 130))
fmt.Println(plainRule("=", t.columns))
// Table header
fmt.Printf("%-15s %-18s %-25s %-10s %-25s %-12s %-12s %-12s\n",
"CONTEXT", "NAMESPACE", "ALIAS", "TYPE", "RESOURCE", "REMOTE PORT", "LOCAL PORT", "STATUS")
fmt.Println(strings.Repeat("-", 130))
fmt.Println(renderPlainHeaderRow(t.columns))
fmt.Println(plainRule("-", t.columns))
// Sort forwards by local port for consistent display
type sortEntry struct {
@@ -121,28 +134,10 @@ func (t *TableUI) Render() {
// Print each forward
for _, entry := range entries {
fwd := entry.fwd
// Truncate long names
alias := truncate(fwd.Alias, 25)
resource := truncate(fwd.Resource, 25)
// Color code status with indicator
statusStr := formatStatusWithIndicator(fwd.Status)
// Print the row
fmt.Printf(" %-15s %-18s %-25s %-10s %-25s %-12d %-12d %s\n",
fwd.Context,
fwd.Namespace,
alias,
fwd.Type,
resource,
fwd.RemotePort,
fwd.LocalPort,
statusStr)
fmt.Println(renderPlainDataRow(t.columns, entry.fwd))
}
fmt.Println(strings.Repeat("=", 130))
fmt.Println(plainRule("=", t.columns))
fmt.Printf("Total forwards: %d | Press Ctrl+C to stop\n", len(t.forwards))
// In verbose mode, add a newline to separate from logs
@@ -158,19 +153,18 @@ func (t *TableUI) RenderInitial() {
// Print header
fmt.Println("\nkportal - Port Forwarding Status")
fmt.Println(strings.Repeat("=", 130))
fmt.Println(plainRule("=", t.columns))
// Table header
fmt.Printf("%-15s %-18s %-25s %-10s %-25s %-12s %-12s %-12s\n",
"CONTEXT", "NAMESPACE", "ALIAS", "TYPE", "RESOURCE", "REMOTE PORT", "LOCAL PORT", "STATUS")
fmt.Println(strings.Repeat("-", 130))
fmt.Println(renderPlainHeaderRow(t.columns))
fmt.Println(plainRule("-", t.columns))
// Print message if no forwards yet
if len(t.forwards) == 0 {
fmt.Println("Initializing port forwards...")
}
fmt.Println(strings.Repeat("=", 130))
fmt.Println(plainRule("=", t.columns))
fmt.Println()
}
@@ -188,6 +182,65 @@ func (t *TableUI) Remove(id string) {
delete(t.forwards, id)
}
// legacyPlainRuleWidth is the rule width the default plain layout always used.
const legacyPlainRuleWidth = 130
// ansiSeq matches the SGR colour sequences emitted by formatStatusWithIndicator.
var ansiSeq = regexp.MustCompile("\x1b\\[[0-9;]*m")
// visibleWidth returns the number of runes a terminal shows for s.
func visibleWidth(s string) int {
return utf8.RuneCountInString(ansiSeq.ReplaceAllString(s, ""))
}
// padVisible right-pads s with spaces to width visible runes.
func padVisible(s string, width int) string {
if n := width - visibleWidth(s); n > 0 {
return s + strings.Repeat(" ", n)
}
return s
}
// plainRule returns a rule line as wide as the rendered columns: the sum of
// their widths plus one separator between each pair.
func plainRule(ch string, cols []ResolvedColumn) string {
if slices.Equal(cols, plainDefaultColumns) {
return strings.Repeat(ch, legacyPlainRuleWidth)
}
width := len(cols) - 1
for _, col := range cols {
width += col.Width
}
return strings.Repeat(ch, max(width, 0))
}
// renderPlainHeaderRow renders the header row for the plain table, padding
// every column to its width.
func renderPlainHeaderRow(cols []ResolvedColumn) string {
cells := make([]string, len(cols))
for i, col := range cols {
cells[i] = padVisible(col.Header, col.Width)
}
return strings.Join(cells, " ")
}
// renderPlainDataRow renders a single forward's data row for the plain table,
// padding every column but the last so row cells line up with the header.
func renderPlainDataRow(cols []ResolvedColumn, fwd *ForwardStatus) string {
cells := make([]string, len(cols))
for i, col := range cols {
if col.Key == ColKeyStatus {
cells[i] = formatStatusWithIndicator(fwd.Status)
} else {
cells[i] = columnValue(col, fwd)
}
if i < len(cols)-1 {
cells[i] = padVisible(cells[i], col.Width)
}
}
return " " + strings.Join(cells, " ")
}
// hyperlink wraps text in an OSC 8 terminal hyperlink escape sequence.
// Clicking the text opens the URL in terminals that support it (Ghostty, iTerm2,
// Windows Terminal, Kitty, WezTerm, etc.). Unsupported terminals show plain text.
@@ -195,15 +248,21 @@ func hyperlink(url, text string) string {
return fmt.Sprintf("\x1b]8;;%s\x1b\\%s\x1b]8;;\x1b\\", url, text)
}
// truncate truncates a string to maxLen, adding "..." if needed
// truncate truncates a string to maxLen runes, adding "..." if needed.
// It counts and slices by rune (not byte) so multibyte aliases/resource names
// are never cut mid-rune into mojibake.
func truncate(s string, maxLen int) string {
if len(s) <= maxLen {
if maxLen <= 0 {
return ""
}
if utf8.RuneCountInString(s) <= maxLen {
return s
}
r := []rune(s)
if maxLen <= 3 {
return s[:maxLen]
return string(r[:maxLen])
}
return s[:maxLen-3] + "..."
return string(r[:maxLen-3]) + "..."
}
// formatStatusWithIndicator adds color-coded indicator symbols to status
+139
View File
@@ -0,0 +1,139 @@
package ui
import (
"fmt"
"io"
"os"
"strings"
"testing"
"github.com/lukaszraczylo/kportal/internal/config"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func captureStdout(t *testing.T, fn func()) string {
t.Helper()
orig := os.Stdout
r, w, err := os.Pipe()
require.NoError(t, err)
os.Stdout = w
defer func() { os.Stdout = orig }()
fn()
require.NoError(t, w.Close())
out, err := io.ReadAll(r)
require.NoError(t, err)
return string(out)
}
func columnsConfig(cols ...config.TableColumn) *config.Config {
return &config.Config{TUI: &config.TUISpec{Columns: cols}}
}
func newPlainTestTable(cfg *config.Config) *TableUI {
tbl := NewTableUI(true, cfg)
tbl.forwards["a"] = &ForwardStatus{
Context: "prod-cluster-with-a-long-name", Namespace: "default", Alias: "api",
Type: "service", Resource: "api-server", RemotePort: 8080, LocalPort: 18080, Status: "Active",
}
tbl.forwards["b"] = &ForwardStatus{
Context: "dev", Namespace: "kube-system", Alias: "db", Type: "pod",
Resource: "postgres-0", RemotePort: 5432, LocalPort: 15432, Status: "Reconnecting",
}
return tbl
}
func TestTableUI_Render_DefaultMatchesLegacyLayout(t *testing.T) {
tbl := newPlainTestTable(nil)
got := captureStdout(t, tbl.Render)
legacyHeader := fmt.Sprintf("%-15s %-18s %-25s %-10s %-25s %-12s %-12s %-12s",
"CONTEXT", "NAMESPACE", "ALIAS", "TYPE", "RESOURCE", "REMOTE PORT", "LOCAL PORT", "STATUS")
legacyRow := func(f *ForwardStatus, status string) string {
return fmt.Sprintf(" %-15s %-18s %-25s %-10s %-25s %-12d %-12d %s",
f.Context, f.Namespace, truncate(f.Alias, 25), f.Type, truncate(f.Resource, 25), f.RemotePort, f.LocalPort, status)
}
want := "kportal - Port Forwarding Status\n" +
strings.Repeat("=", 130) + "\n" +
legacyHeader + "\n" +
strings.Repeat("-", 130) + "\n" +
legacyRow(tbl.forwards["b"], "↻ Reconnecting") + "\n" +
legacyRow(tbl.forwards["a"], "✓ Active") + "\n" +
strings.Repeat("=", 130) + "\n" +
"Total forwards: 2 | Press Ctrl+C to stop\n\n"
assert.Equal(t, want, got)
}
func TestPlainRows_CustomOrderStaysAligned(t *testing.T) {
cols := ResolvePlainColumns(columnsConfig(
config.TableColumn{Name: "alias"},
config.TableColumn{Name: "status"},
config.TableColumn{Name: "local"},
config.TableColumn{Name: "remote"},
))
rows := []*ForwardStatus{
{Alias: "api", Status: "Active", LocalPort: 18080, RemotePort: 80},
{Alias: "a-much-longer-alias", Status: "Reconnecting", LocalPort: 1, RemotePort: 5432},
{Alias: "db", Status: "Error", LocalPort: 15432, RemotePort: 5432},
}
// Prefix width up to the last column must match the header for every row.
lead := visibleWidth(renderPlainHeaderRow(cols[:len(cols)-1])) + 1
for _, f := range rows {
line := renderPlainDataRow(cols, f)
last := strings.LastIndex(line, " ") + 1
assert.Equal(t, lead+2, visibleWidth(line[:last]), "row %q misaligned", line)
}
}
func TestPadVisible_IgnoresColourCodes(t *testing.T) {
coloured := "\033[32m●\033[0m Active"
assert.Equal(t, 8, visibleWidth(coloured))
assert.Equal(t, 12, visibleWidth(padVisible(coloured, 12)))
assert.Equal(t, "toolong", padVisible("toolong", 3))
}
func TestPlainRule_MatchesColumnWidthSum(t *testing.T) {
cols := ResolvePlainColumns(columnsConfig(
config.TableColumn{Name: "alias", Width: 20},
config.TableColumn{Name: "status"},
config.TableColumn{Name: "local"},
))
assert.Equal(t, strings.Repeat("=", 20+plainStatusWidth+12+2), plainRule("=", cols))
assert.Equal(t, strings.Repeat("-", 130), plainRule("-", ResolvePlainColumns(nil)))
}
func TestTableUI_SetColumns_ChangesRendering(t *testing.T) {
tbl := newPlainTestTable(nil)
before := captureStdout(t, tbl.Render)
tbl.SetColumns(columnsConfig(config.TableColumn{Name: "status"}, config.TableColumn{Name: "alias"}))
after := captureStdout(t, tbl.Render)
assert.Contains(t, before, "CONTEXT")
assert.NotContains(t, after, "CONTEXT")
assert.True(t, strings.Contains(after, "STATUS ALIAS"), after)
assert.Contains(t, after, strings.Repeat("=", plainStatusWidth+25+1)+"\n")
}
func TestBubbleTeaUI_SetColumns_ChangesRows(t *testing.T) {
m := newTestModelWithForward()
m.ui.mu.RLock()
before := m.buildTableRows()
m.ui.mu.RUnlock()
require.Len(t, before, 1)
assert.Len(t, before[0], 8)
m.ui.SetColumns(columnsConfig(config.TableColumn{Name: "alias"}, config.TableColumn{Name: "remote"}))
m.ui.mu.RLock()
after := m.buildTableRows()
m.ui.mu.RUnlock()
require.Len(t, after, 1)
assert.Equal(t, []string{"my-app", "8080"}, after[0])
}
+12 -7
View File
@@ -1,6 +1,7 @@
package ui
import (
"fmt"
"testing"
"github.com/lukaszraczylo/kportal/internal/config"
@@ -10,12 +11,12 @@ import (
// TestNewTableUI tests the constructor.
func TestNewTableUI(t *testing.T) {
tui := NewTableUI(false)
tui := NewTableUI(false, nil)
require.NotNil(t, tui)
assert.NotNil(t, tui.forwards)
assert.False(t, tui.verbose)
tuiVerbose := NewTableUI(true)
tuiVerbose := NewTableUI(true, nil)
assert.True(t, tuiVerbose.verbose)
}
@@ -57,7 +58,7 @@ func TestTableUI_AddForward(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tui := NewTableUI(false)
tui := NewTableUI(false, nil)
fwd := &config.Forward{
Resource: tt.resource,
Port: 8080,
@@ -83,7 +84,7 @@ func TestTableUI_AddForward(t *testing.T) {
// TestTableUI_UpdateStatus verifies status mutation.
func TestTableUI_UpdateStatus(t *testing.T) {
tui := NewTableUI(false)
tui := NewTableUI(false, nil)
fwd := &config.Forward{Resource: "pod/app", Port: 80, LocalPort: 8080}
tui.AddForward("id-1", fwd)
@@ -99,7 +100,7 @@ func TestTableUI_UpdateStatus(t *testing.T) {
// TestTableUI_GetForward covers the lookup path.
func TestTableUI_GetForward(t *testing.T) {
tui := NewTableUI(false)
tui := NewTableUI(false, nil)
fwd := &config.Forward{Resource: "pod/app", Port: 80, LocalPort: 8080}
tui.AddForward("id-1", fwd)
@@ -113,7 +114,7 @@ func TestTableUI_GetForward(t *testing.T) {
// TestTableUI_Remove tests deletion.
func TestTableUI_Remove(t *testing.T) {
tui := NewTableUI(false)
tui := NewTableUI(false, nil)
fwd := &config.Forward{Resource: "pod/app", Port: 80, LocalPort: 8080}
tui.AddForward("id-1", fwd)
tui.AddForward("id-2", fwd)
@@ -140,10 +141,14 @@ func TestTruncate(t *testing.T) {
{"hi!", "hi", 2}, // maxLen <= 3 branch: no ellipsis
{"abcd", "abc", 3}, // maxLen <= 3 branch
{"", "", 5},
{"café-service", "café-...", 8}, // multibyte: count/slice by rune, no mojibake
{"日本語ポッド", "日本...", 5}, // CJK runes truncated cleanly
{"naïve", "naïve", 5}, // exactly maxLen runes (6 bytes) — unchanged
{"abc", "", 0}, // non-positive maxLen
}
for _, tt := range tests {
t.Run(tt.input+"_"+string(rune('0'+tt.maxLen)), func(t *testing.T) {
t.Run(fmt.Sprintf("%s_%d", tt.input, tt.maxLen), func(t *testing.T) {
assert.Equal(t, tt.expected, truncate(tt.input, tt.maxLen))
})
}
+44 -5
View File
@@ -14,6 +14,9 @@ import (
"github.com/lukaszraczylo/kportal/internal/k8s"
)
// keyTypedText routes j and k to text input while a text field has focus.
const keyTypedText = "<typed-text>"
// isFilterableStep returns true if the step supports search/filter
func isFilterableStep(step AddWizardStep) bool {
switch step {
@@ -339,7 +342,12 @@ func (m model) handleAddWizardKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil
}
switch msg.String() {
key := msg.String()
if (key == "j" || key == "k") && wizard.isTypingText() {
key = keyTypedText
}
switch key {
case "ctrl+c":
// Hard cancel
m.ui.viewMode = ViewModeMain
@@ -373,8 +381,14 @@ func (m model) handleAddWizardKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
// Reset input mode based on the step we're going back to
switch wizard.step {
case StepSelectContext, StepSelectNamespace, StepSelectResourceType:
case StepSelectContext, StepSelectResourceType:
wizard.inputMode = InputModeList
case StepSelectNamespace:
if wizard.namespaceManual {
wizard.inputMode = InputModeText
} else {
wizard.inputMode = InputModeList
}
case StepEnterResource:
if wizard.selectedResourceType == ResourceTypeService {
wizard.inputMode = InputModeList
@@ -526,10 +540,27 @@ func (m model) handleAddWizardEnter() (tea.Model, tea.Cmd) {
wizard.cursor = 0
wizard.clearSearchFilter()
wizard.loading = true
wizard.namespaceManual = false
wizard.namespaceListErr = nil
wizard.inputMode = InputModeList
return m, loadNamespacesCmd(m.ui.discovery, wizard.selectedContext)
}
case StepSelectNamespace:
if wizard.namespaceManual {
name := strings.TrimSpace(wizard.textInput)
if err := config.ValidateNamespaceName(name); err != nil {
wizard.error = err
return m, nil
}
wizard.error = nil
wizard.selectedNamespace = name
wizard.step = StepSelectResourceType
wizard.cursor = 0
wizard.clearTextInput()
wizard.inputMode = InputModeList
return m, nil
}
filteredNamespaces := wizard.getFilteredNamespaces()
if wizard.cursor >= 0 && wizard.cursor < len(filteredNamespaces) {
wizard.selectedNamespace = filteredNamespaces[wizard.cursor]
@@ -863,10 +894,18 @@ func (m model) handleNamespacesLoaded(msg NamespacesLoadedMsg) (tea.Model, tea.C
defer m.ui.mu.Unlock()
if m.ui.addWizard != nil {
m.ui.addWizard.loading = false
m.ui.addWizard.error = msg.err
w := m.ui.addWizard
w.loading = false
w.error = nil
if msg.err == nil {
m.ui.addWizard.namespaces = msg.namespaces
w.namespaces = msg.namespaces
}
// Without permission to list namespaces the user types the name instead.
if (msg.err != nil || len(msg.namespaces) == 0) && w.step == StepSelectNamespace {
w.namespaceManual = true
w.namespaceListErr = msg.err
w.inputMode = InputModeText
w.textInput = ""
}
}
+104
View File
@@ -0,0 +1,104 @@
package ui
import (
"errors"
"testing"
tea "github.com/charmbracelet/bubbletea"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func typeKeys(t *testing.T, m model, text string) {
t.Helper()
for _, r := range text {
m.handleAddWizardKeys(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
}
}
func TestNamespacesLoaded_ListFailureSwitchesToManualEntry(t *testing.T) {
m := newModelWithWizard(StepSelectNamespace)
m.ui.addWizard.loading = true
m.handleNamespacesLoaded(NamespacesLoadedMsg{err: errors.New("namespaces is forbidden")})
w := m.ui.addWizard
assert.True(t, w.namespaceManual)
assert.Equal(t, InputModeText, w.inputMode)
assert.NoError(t, w.error)
assert.False(t, w.loading)
assert.Contains(t, m.renderSelectNamespace(), "Enter Namespace")
assert.Contains(t, m.renderSelectNamespace(), "forbidden")
}
func TestNamespacesLoaded_EmptyListSwitchesToManualEntry(t *testing.T) {
m := newModelWithWizard(StepSelectNamespace)
m.handleNamespacesLoaded(NamespacesLoadedMsg{namespaces: []string{}})
assert.True(t, m.ui.addWizard.namespaceManual)
}
func TestNamespacesLoaded_ListedNamespacesKeepSelectionMode(t *testing.T) {
m := newModelWithWizard(StepSelectNamespace)
m.handleNamespacesLoaded(NamespacesLoadedMsg{namespaces: []string{"default", "kube-system"}})
w := m.ui.addWizard
assert.False(t, w.namespaceManual)
assert.Equal(t, InputModeList, w.inputMode)
assert.Equal(t, []string{"default", "kube-system"}, w.namespaces)
}
func TestManualNamespace_TypeAndConfirm(t *testing.T) {
m := newModelWithWizard(StepSelectNamespace)
m.handleNamespacesLoaded(NamespacesLoadedMsg{err: errors.New("forbidden")})
// k and j must be typed, not treated as navigation
typeKeys(t, m, "kube-jack")
assert.Equal(t, "kube-jack", m.ui.addWizard.textInput)
m.handleAddWizardEnter()
w := m.ui.addWizard
assert.Equal(t, "kube-jack", w.selectedNamespace)
assert.Equal(t, StepSelectResourceType, w.step)
assert.Equal(t, InputModeList, w.inputMode)
assert.Empty(t, w.textInput)
}
func TestManualNamespace_RejectsInvalidName(t *testing.T) {
m := newModelWithWizard(StepSelectNamespace)
m.handleNamespacesLoaded(NamespacesLoadedMsg{err: errors.New("forbidden")})
m.ui.addWizard.selectedNamespace = ""
for _, bad := range []string{"", "Has_Upper", "-leading", "trailing-"} {
m.ui.addWizard.textInput = bad
m.handleAddWizardEnter()
w := m.ui.addWizard
require.Error(t, w.error, "name %q", bad)
assert.Equal(t, StepSelectNamespace, w.step, "name %q", bad)
assert.Empty(t, w.selectedNamespace, "name %q", bad)
}
}
func TestManualNamespace_BackFromLaterStepRestoresTextInput(t *testing.T) {
m := newModelWithWizard(StepSelectResourceType)
m.ui.addWizard.namespaceManual = true
m.handleAddWizardKeys(tea.KeyMsg{Type: tea.KeyEsc})
w := m.ui.addWizard
assert.Equal(t, StepSelectNamespace, w.step)
assert.Equal(t, InputModeText, w.inputMode)
}
func TestKeysJK_StillNavigateInListMode(t *testing.T) {
m := newModelWithWizard(StepSelectResourceType)
m.ui.addWizard.inputMode = InputModeList
m.handleAddWizardKeys(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}})
assert.Equal(t, 1, m.ui.addWizard.cursor)
}
+9
View File
@@ -111,6 +111,7 @@ func (r ResourceType) Description() string {
// AddWizardState maintains the state for the add port forward wizard
type AddWizardState struct {
error error
namespaceListErr error
httpLogOriginal *config.HTTPLogSpec
resourceValue string
originalID string
@@ -137,6 +138,7 @@ type AddWizardState struct {
confirmationFocus ConfirmationFocus
portAvailable bool
isEditing bool
namespaceManual bool
loading bool
httpLog bool
}
@@ -223,6 +225,13 @@ func (w *AddWizardState) handleTextInput(char rune) {
}
}
// isTypingText reports whether printable keys must go into a text field,
// including keys that otherwise navigate (j, k).
func (w *AddWizardState) isTypingText() bool {
return w.inputMode == InputModeText ||
(w.step == StepConfirmation && w.confirmationFocus == FocusAlias)
}
// clearTextInput clears the text input field
func (w *AddWizardState) clearTextInput() {
w.textInput = ""
+14 -2
View File
@@ -139,6 +139,18 @@ func renderBreadcrumb(parts ...string) string {
return breadcrumbStyle.Render(strings.Join(parts, " / "))
}
// scrollUpIndicator returns the styled "more items above" hint shown when a
// viewport is scrolled down past its first item.
func scrollUpIndicator() string {
return mutedStyle.Render(" ↑ More above ↑") + "\n"
}
// scrollDownIndicator returns the styled "more items below" hint shown when a
// viewport has items beyond its last visible row.
func scrollDownIndicator() string {
return mutedStyle.Render(" ↓ More below ↓") + "\n"
}
// renderList renders a list of items with cursor selection and viewport scrolling
func renderList(items []string, cursor int, prefix string, scrollOffset int) string {
var b strings.Builder
@@ -147,7 +159,7 @@ func renderList(items []string, cursor int, prefix string, scrollOffset int) str
// Show scroll up indicator if there are items above the viewport
if scrollOffset > 0 {
b.WriteString(mutedStyle.Render(" ↑ More above ↑") + "\n")
b.WriteString(scrollUpIndicator())
}
// Calculate visible range
@@ -171,7 +183,7 @@ func renderList(items []string, cursor int, prefix string, scrollOffset int) str
// Show scroll down indicator if there are items below the viewport
if end < totalItems {
b.WriteString(mutedStyle.Render(" ↓ More below ↓") + "\n")
b.WriteString(scrollDownIndicator())
}
return b.String()
+92 -93
View File
@@ -9,8 +9,23 @@ import (
"io"
"sort"
"strings"
"github.com/lukaszraczylo/kportal/internal/k8s"
)
// formatDetectedPort renders a discovered port for the port-selection list,
// e.g. "8080", "80 → 8000" (service target differs), optionally "(http)".
func formatDetectedPort(port k8s.PortInfo) string {
desc := fmt.Sprintf("%d", port.Port)
if port.TargetPort > 0 && port.TargetPort != port.Port {
desc = fmt.Sprintf("%d → %d", port.Port, port.TargetPort)
}
if port.Name != "" {
desc += fmt.Sprintf(" (%s)", port.Name)
}
return desc
}
// renderAddWizard renders the appropriate step of the add wizard
func (m model) renderAddWizard() string {
if m.ui.addWizard == nil {
@@ -61,24 +76,25 @@ func (m model) renderSelectContext() string {
b.WriteString(spinnerStyle.Render("⣾ Loading contexts..."))
} else if wizard.error != nil {
b.WriteString(errorStyle.Render(fmt.Sprintf("✗ Error: %v", wizard.error)))
b.WriteString(mutedStyle.Render("\n\nCould not read kubeconfig. Press Esc to cancel."))
} else if len(wizard.contexts) == 0 {
b.WriteString(mutedStyle.Render("No contexts found in kubeconfig"))
b.WriteString(mutedStyle.Render("\n\nAdd one with: kubectl config use-context <name>"))
} else {
filteredContexts := wizard.getFilteredContexts()
if len(filteredContexts) == 0 {
b.WriteString(mutedStyle.Render("No matching contexts"))
} else {
const viewportHeight = 20
totalItems := len(filteredContexts)
// Show scroll up indicator if there are items above the viewport
if wizard.scrollOffset > 0 {
b.WriteString(mutedStyle.Render(" ↑ More above ↑") + "\n")
b.WriteString(scrollUpIndicator())
}
// Calculate visible range
start := wizard.scrollOffset
end := wizard.scrollOffset + viewportHeight
end := wizard.scrollOffset + ViewportHeight
if end > totalItems {
end = totalItems
}
@@ -103,7 +119,7 @@ func (m model) renderSelectContext() string {
// Show scroll down indicator if there are items below the viewport
if end < totalItems {
b.WriteString(mutedStyle.Render(" ↓ More below ↓") + "\n")
b.WriteString(scrollDownIndicator())
}
}
}
@@ -124,7 +140,11 @@ func (m model) renderSelectNamespace() string {
var b strings.Builder
b.WriteString(renderHeader("Add Port Forward", renderProgress(2, 7)))
b.WriteString(fmt.Sprintf("Context: %s\n\n", breadcrumbStyle.Render(wizard.selectedContext)))
fmt.Fprintf(&b, "Context: %s\n\n", breadcrumbStyle.Render(wizard.selectedContext))
if wizard.namespaceManual {
return m.renderManualNamespace(&b)
}
b.WriteString("Select Namespace:\n\n")
@@ -161,6 +181,32 @@ func (m model) renderSelectNamespace() string {
return b.String()
}
// renderManualNamespace shows a free-text namespace prompt for clusters where
// the namespace list is not available to the user.
func (m model) renderManualNamespace(b *strings.Builder) string {
wizard := m.ui.addWizard
b.WriteString("Enter Namespace:\n\n")
if wizard.namespaceListErr != nil {
b.WriteString(mutedStyle.Render(fmt.Sprintf("Could not list namespaces (%v).", wizard.namespaceListErr)))
} else {
b.WriteString(mutedStyle.Render("No namespaces found."))
}
b.WriteString("\n")
b.WriteString(mutedStyle.Render("Type the namespace name instead."))
b.WriteString("\n\n")
b.WriteString(renderTextInput("Namespace: ", wizard.textInput, wizard.textInput != ""))
b.WriteString("\n")
if wizard.error != nil {
b.WriteString(errorStyle.Render(fmt.Sprintf("✗ %v\n", wizard.error)))
}
b.WriteString("\n")
b.WriteString(wrapHelpText("Enter: Continue Esc: Back Ctrl+C: Cancel", wizardHelpWidth(m.termWidth)))
return b.String()
}
func (m model) renderSelectResourceType() string {
wizard := m.ui.addWizard
var b strings.Builder
@@ -342,39 +388,23 @@ func (m model) renderEnterRemotePort() string {
b.WriteString("Select remote port:")
b.WriteString("\n\n")
const viewportHeight = 20
totalItems := len(wizard.detectedPorts) + 1 // +1 for manual entry option
// Show scroll up indicator if there are items above the viewport
if wizard.scrollOffset > 0 {
b.WriteString(mutedStyle.Render(" ↑ More above ↑") + "\n")
b.WriteString(scrollUpIndicator())
}
// Calculate visible range
start := wizard.scrollOffset
end := wizard.scrollOffset + viewportHeight
end := wizard.scrollOffset + ViewportHeight
if end > totalItems {
end = totalItems
}
// Render detected ports within viewport
for i := start; i < end && i < len(wizard.detectedPorts); i++ {
port := wizard.detectedPorts[i]
// For services, show both service port and target port if they differ
var portDesc string
if port.TargetPort > 0 && port.TargetPort != port.Port {
// Service with different target port: "80 → 8000 (http)"
portDesc = fmt.Sprintf("%d → %d", port.Port, port.TargetPort)
if port.Name != "" {
portDesc += fmt.Sprintf(" (%s)", port.Name)
}
} else {
// Pod port or service with same port
portDesc = fmt.Sprintf("%d", port.Port)
if port.Name != "" {
portDesc += fmt.Sprintf(" (%s)", port.Name)
}
}
portDesc := formatDetectedPort(wizard.detectedPorts[i])
prefix := " "
if i == wizard.cursor {
@@ -402,7 +432,7 @@ func (m model) renderEnterRemotePort() string {
// Show scroll down indicator if there are items below the viewport
if end < totalItems {
b.WriteString(mutedStyle.Render(" ↓ More below ↓") + "\n")
b.WriteString(scrollDownIndicator())
}
b.WriteString("\n")
@@ -412,19 +442,7 @@ func (m model) renderEnterRemotePort() string {
if len(wizard.detectedPorts) > 0 {
b.WriteString(mutedStyle.Render("Detected ports:\n"))
for _, port := range wizard.detectedPorts {
var portDesc string
if port.TargetPort > 0 && port.TargetPort != port.Port {
portDesc = fmt.Sprintf("%d → %d", port.Port, port.TargetPort)
if port.Name != "" {
portDesc += fmt.Sprintf(" (%s)", port.Name)
}
} else {
portDesc = fmt.Sprintf("%d", port.Port)
if port.Name != "" {
portDesc += fmt.Sprintf(" (%s)", port.Name)
}
}
b.WriteString(mutedStyle.Render(fmt.Sprintf(" • %s\n", portDesc)))
b.WriteString(mutedStyle.Render(fmt.Sprintf(" • %s\n", formatDetectedPort(port))))
}
b.WriteString("\n")
}
@@ -503,18 +521,18 @@ func (m model) renderConfirmation() string {
resourceInfo = fmt.Sprintf("service/%s", wizard.resourceValue)
}
b.WriteString(fmt.Sprintf(" Context: %s\n", wizard.selectedContext))
b.WriteString(fmt.Sprintf(" Namespace: %s\n", wizard.selectedNamespace))
b.WriteString(fmt.Sprintf(" Resource: %s\n", resourceInfo))
b.WriteString(fmt.Sprintf(" Remote Port: %d\n", wizard.remotePort))
b.WriteString(fmt.Sprintf(" Local Port: %d\n", wizard.localPort))
fmt.Fprintf(&b, " Context: %s\n", wizard.selectedContext)
fmt.Fprintf(&b, " Namespace: %s\n", wizard.selectedNamespace)
fmt.Fprintf(&b, " Resource: %s\n", resourceInfo)
fmt.Fprintf(&b, " Remote Port: %d\n", wizard.remotePort)
fmt.Fprintf(&b, " Local Port: %d\n", wizard.localPort)
b.WriteString(" Protocol: tcp\n")
httpLogMark := "[ ] disabled"
if wizard.httpLog {
httpLogMark = "[x] enabled"
}
b.WriteString(fmt.Sprintf(" HTTP Log: %s\n", httpLogMark))
fmt.Fprintf(&b, " HTTP Log: %s\n", httpLogMark)
b.WriteString("\n")
@@ -648,7 +666,7 @@ func (m model) renderRemoveSelection() string {
}
selectedCount := wizard.getSelectedCount()
b.WriteString(fmt.Sprintf("%d of %d selected\n\n", selectedCount, len(wizard.forwards)))
fmt.Fprintf(&b, "%d of %d selected\n\n", selectedCount, len(wizard.forwards))
b.WriteString(wrapHelpText("Space: Toggle a: All n: None Enter: Remove Esc: Cancel", wizardHelpWidth(m.termWidth)))
@@ -663,7 +681,7 @@ func (m model) renderRemoveConfirmation() string {
b.WriteString("\n")
selectedCount := wizard.getSelectedCount()
b.WriteString(fmt.Sprintf("Remove %d port forward(s)?\n\n", selectedCount))
fmt.Fprintf(&b, "Remove %d port forward(s)?\n\n", selectedCount)
selectedForwards := wizard.getSelectedForwards()
for _, fwd := range selectedForwards {
@@ -718,7 +736,7 @@ func (m model) renderBenchmarkConfig() string {
var b strings.Builder
b.WriteString(renderHeader("HTTP Benchmark", ""))
b.WriteString(fmt.Sprintf("Target: %s (localhost:%d)", breadcrumbStyle.Render(state.forwardAlias), state.localPort))
fmt.Fprintf(&b, "Target: %s (localhost:%d)", breadcrumbStyle.Render(state.forwardAlias), state.localPort)
b.WriteString("\n\n")
b.WriteString("Configure benchmark parameters:")
@@ -741,7 +759,7 @@ func (m model) renderBenchmarkConfig() string {
b.WriteString(selectedStyle.Render(fmt.Sprintf("%s%-12s", prefix, field.label+":")))
b.WriteString(validInputStyle.Render(field.value + "█"))
} else {
b.WriteString(fmt.Sprintf("%s%-12s %s", prefix, field.label+":", field.value))
fmt.Fprintf(&b, "%s%-12s %s", prefix, field.label+":", field.value)
}
b.WriteString("\n")
}
@@ -759,7 +777,7 @@ func (m model) renderBenchmarkRunning() string {
var b strings.Builder
b.WriteString(renderHeader("HTTP Benchmark", ""))
b.WriteString(fmt.Sprintf("Target: %s", breadcrumbStyle.Render(state.forwardAlias)))
fmt.Fprintf(&b, "Target: %s", breadcrumbStyle.Render(state.forwardAlias))
b.WriteString("\n\n")
// Progress bar
@@ -779,7 +797,7 @@ func (m model) renderBenchmarkRunning() string {
b.WriteString(spinnerStyle.Render("Running benchmark..."))
b.WriteString("\n\n")
b.WriteString(fmt.Sprintf(" [%s] %d%%", successStyle.Render(bar), percent))
fmt.Fprintf(&b, " [%s] %d%%", successStyle.Render(bar), percent)
b.WriteString("\n")
b.WriteString(mutedStyle.Render(fmt.Sprintf(" %d / %d requests completed", state.progress, state.total)))
b.WriteString("\n\n")
@@ -799,7 +817,7 @@ func (m model) renderBenchmarkResults() string {
var b strings.Builder
b.WriteString(renderHeader("Benchmark Results", ""))
b.WriteString(fmt.Sprintf("Target: %s", breadcrumbStyle.Render(state.forwardAlias)))
fmt.Fprintf(&b, "Target: %s", breadcrumbStyle.Render(state.forwardAlias))
b.WriteString("\n\n")
if state.error != nil {
@@ -824,43 +842,43 @@ func (m model) renderBenchmarkResults() string {
successRate = 0
}
b.WriteString(fmt.Sprintf("Total Requests: %d", r.TotalRequests))
fmt.Fprintf(&b, "Total Requests: %d", r.TotalRequests)
b.WriteString("\n")
if r.Failed == 0 {
b.WriteString(successStyle.Render(fmt.Sprintf("Successful: %d (%.1f%%)", r.Successful, successRate)))
} else {
b.WriteString(fmt.Sprintf("Successful: %d (%.1f%%)", r.Successful, successRate))
fmt.Fprintf(&b, "Successful: %d (%.1f%%)", r.Successful, successRate)
}
b.WriteString("\n")
if r.Failed > 0 {
b.WriteString(errorStyle.Render(fmt.Sprintf("Failed: %d", r.Failed)))
} else {
b.WriteString(fmt.Sprintf("Failed: %d", r.Failed))
fmt.Fprintf(&b, "Failed: %d", r.Failed)
}
b.WriteString("\n\n")
// Latency stats
b.WriteString(breadcrumbStyle.Render("Latency (ms)"))
b.WriteString("\n")
b.WriteString(fmt.Sprintf(" Min: %.2f", r.MinLatency))
fmt.Fprintf(&b, " Min: %.2f", r.MinLatency)
b.WriteString("\n")
b.WriteString(fmt.Sprintf(" Max: %.2f", r.MaxLatency))
fmt.Fprintf(&b, " Max: %.2f", r.MaxLatency)
b.WriteString("\n")
b.WriteString(fmt.Sprintf(" Avg: %.2f", r.AvgLatency))
fmt.Fprintf(&b, " Avg: %.2f", r.AvgLatency)
b.WriteString("\n")
b.WriteString(fmt.Sprintf(" P50: %.2f", r.P50Latency))
fmt.Fprintf(&b, " P50: %.2f", r.P50Latency)
b.WriteString("\n")
b.WriteString(fmt.Sprintf(" P95: %.2f", r.P95Latency))
fmt.Fprintf(&b, " P95: %.2f", r.P95Latency)
b.WriteString("\n")
b.WriteString(fmt.Sprintf(" P99: %.2f", r.P99Latency))
fmt.Fprintf(&b, " P99: %.2f", r.P99Latency)
b.WriteString("\n\n")
// Throughput
b.WriteString(breadcrumbStyle.Render("Throughput"))
b.WriteString("\n")
b.WriteString(fmt.Sprintf(" Requests/sec: %.2f", r.Throughput))
fmt.Fprintf(&b, " Requests/sec: %.2f", r.Throughput)
b.WriteString("\n")
b.WriteString(fmt.Sprintf(" Bytes read: %d", r.BytesRead))
fmt.Fprintf(&b, " Bytes read: %d", r.BytesRead)
b.WriteString("\n")
// Status codes if interesting
@@ -874,7 +892,7 @@ func (m model) renderBenchmarkResults() string {
} else if code >= 400 {
b.WriteString(errorStyle.Render(fmt.Sprintf(" %d: %d", code, count)))
} else {
b.WriteString(fmt.Sprintf(" %d: %d", code, count))
fmt.Fprintf(&b, " %d: %d", code, count)
}
b.WriteString("\n")
}
@@ -966,7 +984,7 @@ func (m model) renderHTTPLog() string {
b.WriteString("\n")
// Header
header := fmt.Sprintf(" %-10s %-7s %-6s %-8s %s",
header := " " + fmt.Sprintf(HTTPLogRowFormat,
"TIME", "METHOD", "STATUS", "LATENCY", "PATH")
b.WriteString(mutedStyle.Render(header))
b.WriteString("\n")
@@ -1004,8 +1022,8 @@ func (m model) renderHTTPLog() string {
end = totalEntries
}
// Calculate max path width
maxPathWidth := termWidth - 48
// Calculate max path width (remaining space after the fixed columns)
maxPathWidth := termWidth - HTTPLogFixedCols
if maxPathWidth < 10 {
maxPathWidth = 10
}
@@ -1028,14 +1046,11 @@ func (m model) renderHTTPLog() string {
}
}
// Truncate path
path := entry.Path
if len(path) > maxPathWidth {
path = path[:maxPathWidth-3] + "..."
}
// Truncate path (rune-aware, no mid-rune mojibake)
path := truncate(entry.Path, maxPathWidth)
// Build line
line := fmt.Sprintf("%-10s %-7s %-6s %-8s %s",
line := fmt.Sprintf(HTTPLogRowFormat,
entry.Timestamp,
entry.Method,
statusStr,
@@ -1079,7 +1094,7 @@ func (m model) renderHTTPLog() string {
// Footer with entry count
b.WriteString("\n")
if totalEntries != totalUnfiltered {
b.WriteString(mutedStyle.Render(fmt.Sprintf(" %d of %d entries (filtered from %d)", totalEntries, totalEntries, totalUnfiltered)))
b.WriteString(mutedStyle.Render(fmt.Sprintf(" %d of %d entries (filtered)", totalEntries, totalUnfiltered)))
} else {
b.WriteString(mutedStyle.Render(fmt.Sprintf(" %d entries", totalEntries)))
}
@@ -1121,11 +1136,7 @@ func (m model) renderHTTPLogDetail(entry HTTPLogEntry, termWidth, termHeight int
}
sort.Strings(headerKeys)
for _, k := range headerKeys {
v := entry.RequestHeaders[k]
// Truncate long header values
if len(v) > termWidth-20 {
v = v[:termWidth-23] + "..."
}
v := truncate(entry.RequestHeaders[k], termWidth-20)
lines = append(lines, fmt.Sprintf(" %s: %s", mutedStyle.Render(k), v))
}
lines = append(lines, "")
@@ -1146,11 +1157,7 @@ func (m model) renderHTTPLogDetail(entry HTTPLogEntry, termWidth, termHeight int
reqBody = formatJSONContent(reqBody, entry.RequestHeaders)
bodyLines := strings.Split(reqBody, "\n")
for _, line := range bodyLines {
// Truncate long lines
if len(line) > termWidth-6 {
line = line[:termWidth-9] + "..."
}
lines = append(lines, " "+line)
lines = append(lines, " "+truncate(line, termWidth-6))
}
}
lines = append(lines, "")
@@ -1192,11 +1199,7 @@ func (m model) renderHTTPLogDetail(entry HTTPLogEntry, termWidth, termHeight int
}
sort.Strings(headerKeys)
for _, k := range headerKeys {
v := entry.ResponseHeaders[k]
// Truncate long header values
if len(v) > termWidth-20 {
v = v[:termWidth-23] + "..."
}
v := truncate(entry.ResponseHeaders[k], termWidth-20)
lines = append(lines, fmt.Sprintf(" %s: %s", mutedStyle.Render(k), v))
}
lines = append(lines, "")
@@ -1217,11 +1220,7 @@ func (m model) renderHTTPLogDetail(entry HTTPLogEntry, termWidth, termHeight int
respBody = formatJSONContent(respBody, entry.ResponseHeaders)
bodyLines := strings.Split(respBody, "\n")
for _, line := range bodyLines {
// Truncate long lines
if len(line) > termWidth-6 {
line = line[:termWidth-9] + "..."
}
lines = append(lines, " "+line)
lines = append(lines, " "+truncate(line, termWidth-6))
}
}
lines = append(lines, "")