mirror of
https://github.com/lukaszraczylo/kportal.git
synced 2026-10-12 04:35:10 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4748bc4be5 | ||
|
|
f3d3f259a1 | ||
|
|
232c0f1162 | ||
|
|
6f19ecea6b | ||
|
|
93b5d6496e | ||
|
|
b3c63bd1fa | ||
|
|
374163f463 | ||
|
|
6c695584a4 | ||
|
|
c1dbad858b |
@@ -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: |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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.40.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.49.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
|
||||
|
||||
@@ -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,8 +148,6 @@ 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.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs=
|
||||
golang.org/x/mod v0.40.0/go.mod h1:0/weTWkPWGBikyTWAX3dkjVztMmBA5hM0DH6BElSupE=
|
||||
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=
|
||||
@@ -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.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
|
||||
golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
|
||||
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=
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = ""
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -142,6 +142,10 @@ func (m model) renderSelectNamespace() string {
|
||||
b.WriteString(renderHeader("Add Port Forward", renderProgress(2, 7)))
|
||||
fmt.Fprintf(&b, "Context: %s\n\n", breadcrumbStyle.Render(wizard.selectedContext))
|
||||
|
||||
if wizard.namespaceManual {
|
||||
return m.renderManualNamespace(&b)
|
||||
}
|
||||
|
||||
b.WriteString("Select Namespace:\n\n")
|
||||
|
||||
// Show search input if there's a filter active
|
||||
@@ -177,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
|
||||
|
||||
Reference in New Issue
Block a user