Compare commits

...
10 Commits
Author SHA1 Message Date
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
17 changed files with 923 additions and 144 deletions
-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
+20
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
+14 -3
View File
@@ -497,7 +497,7 @@ 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).
@@ -539,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 {
@@ -576,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)
}
}
}
@@ -590,6 +596,7 @@ 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))
@@ -611,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()
+22 -26
View File
@@ -11,9 +11,9 @@ require (
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.3
k8s.io/apimachinery v0.36.3
k8s.io/client-go v0.36.3
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
)
@@ -29,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/fxamacker/cbor/v2 v2.9.3 // indirect
github.com/go-openapi/jsonpointer v1.0.0 // indirect
github.com/go-openapi/jsonreference v1.0.0 // indirect
github.com/go-openapi/swag v0.28.0 // indirect
github.com/go-openapi/swag/cmdutils v0.28.0 // indirect
github.com/go-openapi/swag/conv v0.28.0 // indirect
github.com/go-openapi/swag/fileutils v0.28.0 // indirect
github.com/go-openapi/swag/jsonutils v0.28.0 // indirect
github.com/go-openapi/swag/loading v0.28.0 // indirect
github.com/go-openapi/swag/mangling v0.28.0 // indirect
github.com/go-openapi/swag/netutils v0.28.0 // indirect
github.com/go-openapi/swag/pools v0.28.0 // indirect
github.com/go-openapi/swag/stringutils v0.28.0 // indirect
github.com/go-openapi/swag/typeutils v0.28.0 // indirect
github.com/go-openapi/swag/yamlutils v0.28.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.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.27 // 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
@@ -68,20 +67,17 @@ require (
github.com/xo/terminfo v1.0.0 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/mod v0.39.0 // indirect
golang.org/x/net v0.57.0 // indirect
golang.org/x/net v0.58.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sync v0.22.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.48.0 // 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-20260721132016-d427ff9ee9ad // indirect
k8s.io/streaming v0.36.3 // 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
+52 -57
View File
@@ -24,7 +24,6 @@ github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSE
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/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
@@ -35,44 +34,44 @@ github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
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.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78=
github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
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.0 h1:jlmTr6torcd1YgDQvSfNmRtKzYDO4FGBkrAdlAVWnpY=
github.com/go-openapi/jsonreference v1.0.0/go.mod h1:jtwdyGbJk0Xhe5Y+rwtglQP6Sb1WZST4rT32LWB+sv0=
github.com/go-openapi/swag v0.28.0 h1:xkgbOSKj6DZziNpyqRRAOt3GJGtgjgsd2RoyT30VWuw=
github.com/go-openapi/swag v0.28.0/go.mod h1:4qYnT3Cqr1p1VknOdPo70evN4rgQnAg6jwApHyxSGIg=
github.com/go-openapi/swag/cmdutils v0.28.0 h1:7TOeNtkYru1SG8Y34tDh9WBbLsMqGnptuxWiHREPZ4Q=
github.com/go-openapi/swag/cmdutils v0.28.0/go.mod h1:Sm1MVFMkF6guJJ+pQqHnQA3N0j9qALV3NxzDSv6bETM=
github.com/go-openapi/swag/conv v0.28.0 h1:GtqqbyFe7vR5Y7ehxG9W6/OvrSFdf1OLeTGp40TqxH8=
github.com/go-openapi/swag/conv v0.28.0/go.mod h1:mbUE+mzctnhxi864m0Q07SpN8OowD9JhxmxuYvZZD/k=
github.com/go-openapi/swag/fileutils v0.28.0 h1:Z04XWQD7R8Eq+7GnOrjovBxPPmZzsS4gt2H2GPGIViU=
github.com/go-openapi/swag/fileutils v0.28.0/go.mod h1:VvJFZLTZS0AI854gEQz5tk7dBESdLjiNUMSZ/th2ry8=
github.com/go-openapi/swag/jsonutils v0.28.0 h1:YIch6FwO7RXzeAnbO8Tu7dWBZeUEH+4nA0HXltVTnv4=
github.com/go-openapi/swag/jsonutils v0.28.0/go.mod h1:CYM3WlTUcagR2ZoHdz54di/cbBqt82tuxuXgAjxw+mg=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.28.0 h1:qV+VVUAx5Oro8WjVWpZeql7YReTKhT4smR4zhcOQZr0=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.28.0/go.mod h1:mofwUWx70wvskwESqRJ//k/9kURmCgyJl5m5Ppoh5kY=
github.com/go-openapi/swag/loading v0.28.0 h1:td8QZdZC9MIYGGSnSPKShKiK22I2tU5UQvuUhIBPRLU=
github.com/go-openapi/swag/loading v0.28.0/go.mod h1:rXB0QiQX5mMveXEA7ouM4KiiM9jVJe4K6BVbwhD1M4k=
github.com/go-openapi/swag/mangling v0.28.0 h1:pH8eyeNO9SLYsTMWJrurnNfKmDa28XrlA+HePVD53VM=
github.com/go-openapi/swag/mangling v0.28.0/go.mod h1:jtBE2+V+3pILxOR7Vgce+Cwp6A2PgZbvVqfNntbVs0w=
github.com/go-openapi/swag/netutils v0.28.0 h1:YXN6TALEi2pzts8/8GNm6T61HTAZsieukGZidap989k=
github.com/go-openapi/swag/netutils v0.28.0/go.mod h1:J+WYyFMLtvtCGqa6jLv+YNUmIKI3ZRQRrvfNDMoQoEQ=
github.com/go-openapi/swag/pools v0.28.0 h1:HPMZWSAfce3rdVTFcjFiCIBtDg9h4x2QlRrHipwhxeU=
github.com/go-openapi/swag/pools v0.28.0/go.mod h1:kVQefhSK5RWuRe7BXsL8htgBPAMpN7HDGpGEknqugeE=
github.com/go-openapi/swag/stringutils v0.28.0 h1:ixsc9iYgDPubHL/8nSkbnryEHpD2VRlBMLKpQyPXcDU=
github.com/go-openapi/swag/stringutils v0.28.0/go.mod h1:lzRN95CxXmA03XcDWHLOb6nOMcxCqR5rGY0lOgsfRoM=
github.com/go-openapi/swag/typeutils v0.28.0 h1:nRBKSBXjDgf01VDPB3fWeD9nQuhCOVeIYAkUx2tbkyY=
github.com/go-openapi/swag/typeutils v0.28.0/go.mod h1:Srm0xFNRZ1Y+vCxJclo5qzx8aj+1pAKda/YfFPrG0dQ=
github.com/go-openapi/swag/yamlutils v0.28.0 h1:TV3JXH6DS46KUroDtMLAYHGkdWf5VDq3wVWFirmzROY=
github.com/go-openapi/swag/yamlutils v0.28.0/go.mod h1:x0q/yndZHEgk9Rx3DyDqzFUmHy55KTvIZldvF2dTJXs=
github.com/go-openapi/testify/enable/yaml/v2 v2.6.0 h1:gGHwAJ0R/5jU8BEGDbfRNR3hL68dAVi84WuOApp29B0=
github.com/go-openapi/testify/enable/yaml/v2 v2.6.0/go.mod h1:tY+St1SGq4NFl0QIqdTY4aEdbChAHxhyB77XQi9iJCo=
github.com/go-openapi/testify/v2 v2.6.0 h1:5PKH2HE7YJ/LuRPQGvSxBRlFXNQhSetBLlGAgUEu3ug=
github.com/go-openapi/testify/v2 v2.6.0/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw=
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=
@@ -98,11 +97,11 @@ github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsRe
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.27 h1:Feg/Oou5zI/wnpgDF6omIU0OokC9GxLC/WRknhVlIR0=
github.com/mattn/go-runewidth v0.0.27/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=
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/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=
@@ -131,8 +130,8 @@ 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=
@@ -149,14 +148,12 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.39.0 h1:UF5zwQdCRRUpHfyPwr7d4UrGiVeldIsogtzWVnczL74=
golang.org/x/mod v0.39.0/go.mod h1:bvIbwjQ0HUFFf5AKukeeYQG4ZBUG9yxQbR9aEweIwYY=
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.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
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=
@@ -176,8 +173,6 @@ 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.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
@@ -190,18 +185,18 @@ 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.36.3 h1:NxB+05W2UGqXWFXcLO0RB5cnqnUPP5v5sVlaOH0Iz4w=
k8s.io/api v0.36.3/go.mod h1:JzLQKqRHC5+I8RVj/lS3lCg0mg6nWI9Fo/Sk3ElxHzg=
k8s.io/apimachinery v0.36.3 h1:PkzMRBRG8joFD8EhCuQAtNPvJlxb82FwplP26HIzvAM=
k8s.io/apimachinery v0.36.3/go.mod h1:cTSjBWgPe/6CQyBKzY/hDIRWCQQQeK0mfLbml0UYFHE=
k8s.io/client-go v0.36.3 h1:M4JdVzXxYcZk4fGpfDdYnxSwhLKWCFoQsHW6t+z8Hfg=
k8s.io/client-go v0.36.3/go.mod h1:gcPwr0c87vjjG6HB6pWEqOeuYVoXSsREjzux2j6GF30=
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-20260721132016-d427ff9ee9ad h1:oXImqH8mQNk7PmvzKhmN3ddJoY6OnyM225MXwGHPm0A=
k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad/go.mod h1:0/mqHCVhlumdJ3BhCfnjSZQE037nAhNodh1/hK0T8/I=
k8s.io/streaming v0.36.3 h1:9rAaqBk0C0Pc7+/fqGekj07NV+/Xrew58p647A0JT8w=
k8s.io/streaming v0.36.3/go.mod h1:z6fV3D+NVkoeqRMtWwlUZK6U17SY/LqNzOxWL6GyR/s=
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=
+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"`
+72
View File
@@ -85,6 +85,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 +112,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
}
@@ -507,6 +511,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.
+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)
}
})
}
}
+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"))
}
+34 -15
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()
@@ -545,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))
@@ -570,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
@@ -613,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 {
@@ -643,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)
+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)
}
+83 -31
View File
@@ -3,6 +3,8 @@ package ui
import (
"fmt"
"os"
"regexp"
"slices"
"strings"
"sync"
"unicode/utf8"
@@ -26,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()
@@ -94,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 {
@@ -122,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
@@ -159,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()
}
@@ -189,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.
+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])
}
+6 -6
View File
@@ -11,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)
}
@@ -58,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,
@@ -84,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)
@@ -100,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)
@@ -114,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)