mirror of
https://github.com/lukaszraczylo/kportal.git
synced 2026-10-12 04:35:10 +00:00
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]>
This commit is contained in:
@@ -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"`
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user