diff --git a/.goreleaser.yaml b/.goreleaser.yaml index cf608f4..963b4bf 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -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: | diff --git a/.kportal.yaml b/.kportal.yaml index 75b5ba4..9f28c90 100644 --- a/.kportal.yaml +++ b/.kportal.yaml @@ -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 diff --git a/README.md b/README.md index 38fcb9c..93dbc69 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/cmd/kportal/main.go b/cmd/kportal/main.go index 99a3898..77a824f 100644 --- a/cmd/kportal/main.go +++ b/cmd/kportal/main.go @@ -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() diff --git a/internal/config/config.go b/internal/config/config.go index 1b26244..7f31820 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 .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"` diff --git a/internal/config/validator.go b/internal/config/validator.go index 747aa9e..41cc263 100644 --- a/internal/config/validator.go +++ b/internal/config/validator.go @@ -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. diff --git a/internal/config/validator_test.go b/internal/config/validator_test.go index 6ccc93d..b9891a6 100644 --- a/internal/config/validator_test.go +++ b/internal/config/validator_test.go @@ -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) + } + }) + } +} diff --git a/internal/httplog/proxy.go b/internal/httplog/proxy.go index a231534..b65b63c 100644 --- a/internal/httplog/proxy.go +++ b/internal/httplog/proxy.go @@ -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, diff --git a/internal/httplog/proxy_test.go b/internal/httplog/proxy_test.go index fe41990..499a4a4 100644 --- a/internal/httplog/proxy_test.go +++ b/internal/httplog/proxy_test.go @@ -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")) +} diff --git a/internal/ui/bubbletea_ui.go b/internal/ui/bubbletea_ui.go index 41e3f02..851f219 100644 --- a/internal/ui/bubbletea_ui.go +++ b/internal/ui/bubbletea_ui.go @@ -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) diff --git a/internal/ui/columns.go b/internal/ui/columns.go new file mode 100644 index 0000000..07dc36d --- /dev/null +++ b/internal/ui/columns.go @@ -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 +} diff --git a/internal/ui/columns_test.go b/internal/ui/columns_test.go new file mode 100644 index 0000000..2c7b45b --- /dev/null +++ b/internal/ui/columns_test.go @@ -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) +} diff --git a/internal/ui/table.go b/internal/ui/table.go index 5a02481..6be396f 100644 --- a/internal/ui/table.go +++ b/internal/ui/table.go @@ -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. diff --git a/internal/ui/table_columns_test.go b/internal/ui/table_columns_test.go new file mode 100644 index 0000000..3e3cfd5 --- /dev/null +++ b/internal/ui/table_columns_test.go @@ -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]) +} diff --git a/internal/ui/table_test.go b/internal/ui/table_test.go index 93644af..05b2c36 100644 --- a/internal/ui/table_test.go +++ b/internal/ui/table_test.go @@ -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)