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:
Bruno Costa
2026-10-02 02:24:11 +01:00
committed by GitHub
co-authored by lukaszraczylo
parent 232c0f1162
commit f3d3f259a1
15 changed files with 849 additions and 61 deletions
+83 -31
View File
@@ -3,6 +3,8 @@ package ui
import (
"fmt"
"os"
"regexp"
"slices"
"strings"
"sync"
"unicode/utf8"
@@ -26,18 +28,29 @@ type ForwardStatus struct {
// TableUI manages the terminal table display
type TableUI struct {
forwards map[string]*ForwardStatus
columns []ResolvedColumn
mu sync.RWMutex
verbose bool
}
// NewTableUI creates a new table UI manager
func NewTableUI(verbose bool) *TableUI {
// NewTableUI creates a new table UI manager. cfg may be nil, in which case
// the built-in default column set and order is used.
func NewTableUI(verbose bool, cfg *config.Config) *TableUI {
return &TableUI{
forwards: make(map[string]*ForwardStatus),
verbose: verbose,
columns: ResolvePlainColumns(cfg),
}
}
// SetColumns updates the table's column set and order, e.g. after a config
// hot-reload.
func (t *TableUI) SetColumns(cfg *config.Config) {
t.mu.Lock()
defer t.mu.Unlock()
t.columns = ResolvePlainColumns(cfg)
}
// AddForward registers a new forward for display
func (t *TableUI) AddForward(id string, fwd *config.Forward) {
t.mu.Lock()
@@ -94,12 +107,11 @@ func (t *TableUI) Render() {
// Print header
fmt.Println("kportal - Port Forwarding Status")
fmt.Println(strings.Repeat("=", 130))
fmt.Println(plainRule("=", t.columns))
// Table header
fmt.Printf("%-15s %-18s %-25s %-10s %-25s %-12s %-12s %-12s\n",
"CONTEXT", "NAMESPACE", "ALIAS", "TYPE", "RESOURCE", "REMOTE PORT", "LOCAL PORT", "STATUS")
fmt.Println(strings.Repeat("-", 130))
fmt.Println(renderPlainHeaderRow(t.columns))
fmt.Println(plainRule("-", t.columns))
// Sort forwards by local port for consistent display
type sortEntry struct {
@@ -122,28 +134,10 @@ func (t *TableUI) Render() {
// Print each forward
for _, entry := range entries {
fwd := entry.fwd
// Truncate long names
alias := truncate(fwd.Alias, 25)
resource := truncate(fwd.Resource, 25)
// Color code status with indicator
statusStr := formatStatusWithIndicator(fwd.Status)
// Print the row
fmt.Printf(" %-15s %-18s %-25s %-10s %-25s %-12d %-12d %s\n",
fwd.Context,
fwd.Namespace,
alias,
fwd.Type,
resource,
fwd.RemotePort,
fwd.LocalPort,
statusStr)
fmt.Println(renderPlainDataRow(t.columns, entry.fwd))
}
fmt.Println(strings.Repeat("=", 130))
fmt.Println(plainRule("=", t.columns))
fmt.Printf("Total forwards: %d | Press Ctrl+C to stop\n", len(t.forwards))
// In verbose mode, add a newline to separate from logs
@@ -159,19 +153,18 @@ func (t *TableUI) RenderInitial() {
// Print header
fmt.Println("\nkportal - Port Forwarding Status")
fmt.Println(strings.Repeat("=", 130))
fmt.Println(plainRule("=", t.columns))
// Table header
fmt.Printf("%-15s %-18s %-25s %-10s %-25s %-12s %-12s %-12s\n",
"CONTEXT", "NAMESPACE", "ALIAS", "TYPE", "RESOURCE", "REMOTE PORT", "LOCAL PORT", "STATUS")
fmt.Println(strings.Repeat("-", 130))
fmt.Println(renderPlainHeaderRow(t.columns))
fmt.Println(plainRule("-", t.columns))
// Print message if no forwards yet
if len(t.forwards) == 0 {
fmt.Println("Initializing port forwards...")
}
fmt.Println(strings.Repeat("=", 130))
fmt.Println(plainRule("=", t.columns))
fmt.Println()
}
@@ -189,6 +182,65 @@ func (t *TableUI) Remove(id string) {
delete(t.forwards, id)
}
// legacyPlainRuleWidth is the rule width the default plain layout always used.
const legacyPlainRuleWidth = 130
// ansiSeq matches the SGR colour sequences emitted by formatStatusWithIndicator.
var ansiSeq = regexp.MustCompile("\x1b\\[[0-9;]*m")
// visibleWidth returns the number of runes a terminal shows for s.
func visibleWidth(s string) int {
return utf8.RuneCountInString(ansiSeq.ReplaceAllString(s, ""))
}
// padVisible right-pads s with spaces to width visible runes.
func padVisible(s string, width int) string {
if n := width - visibleWidth(s); n > 0 {
return s + strings.Repeat(" ", n)
}
return s
}
// plainRule returns a rule line as wide as the rendered columns: the sum of
// their widths plus one separator between each pair.
func plainRule(ch string, cols []ResolvedColumn) string {
if slices.Equal(cols, plainDefaultColumns) {
return strings.Repeat(ch, legacyPlainRuleWidth)
}
width := len(cols) - 1
for _, col := range cols {
width += col.Width
}
return strings.Repeat(ch, max(width, 0))
}
// renderPlainHeaderRow renders the header row for the plain table, padding
// every column to its width.
func renderPlainHeaderRow(cols []ResolvedColumn) string {
cells := make([]string, len(cols))
for i, col := range cols {
cells[i] = padVisible(col.Header, col.Width)
}
return strings.Join(cells, " ")
}
// renderPlainDataRow renders a single forward's data row for the plain table,
// padding every column but the last so row cells line up with the header.
func renderPlainDataRow(cols []ResolvedColumn, fwd *ForwardStatus) string {
cells := make([]string, len(cols))
for i, col := range cols {
if col.Key == ColKeyStatus {
cells[i] = formatStatusWithIndicator(fwd.Status)
} else {
cells[i] = columnValue(col, fwd)
}
if i < len(cols)-1 {
cells[i] = padVisible(cells[i], col.Width)
}
}
return " " + strings.Join(cells, " ")
}
// hyperlink wraps text in an OSC 8 terminal hyperlink escape sequence.
// Clicking the text opens the URL in terminals that support it (Ghostty, iTerm2,
// Windows Terminal, Kitty, WezTerm, etc.). Unsupported terminals show plain text.