Files
kportal/internal/ui/table.go
T
f3d3f259a1 Allow configuring forwards-table columns, order, and width (#82)
* Allow configuring forwards-table columns, order, and width

Adds an optional tui.columns section to .kportal.yaml so the visible
columns, their order, and their widths can be customized instead of
always rendering the hardcoded 8-column layout. Omitting the section
keeps the existing default columns/order/widths.

Closes #81

* fix(ui): keep the plain table layout and check column widths

The plain verbose table must look the same when no tui section is
set. It had narrower columns and shorter port headers than before. The
default layout is now identical to the old one, and a golden test
guards it.

A status column that is not last now pads by visible width. Table
rules follow the column widths. The UI applies new columns only after a
reload succeeds, including the SIGHUP path of the verbose table.

The validator rejects a column width above 200. The README describes
the tui.columns setting.

Refs #81

* fix(httplog): replace the deprecated ReverseProxy Director with Rewrite

Staticcheck reports SA1019 because Go 1.26 deprecates Director. The
proxy now sets the target in a Rewrite function. Rewrite drops the
X-Forwarded-For header that Director sent, so the function calls
SetXForwarded to keep that header.

A new test checks the target port, the path and the header.

* fix(release): drop the deprecated cask url verified key

Homebrew warns about the verified parameter of the cask url stanza.
The setting is not needed. GoReleaser check now passes without a
deprecation warning.

The postflight warning remains. GoReleaser generates it from the
post install hook, and the upstream fix is not released yet.

Refs #79

---------

Co-authored-by: Lukasz Raczylo <[email protected]>
2026-10-02 02:24:11 +01:00

301 lines
7.6 KiB
Go

package ui
import (
"fmt"
"os"
"regexp"
"slices"
"strings"
"sync"
"unicode/utf8"
"github.com/lukaszraczylo/kportal/internal/config"
)
// ForwardStatus represents the current status of a port forward
type ForwardStatus struct {
HTTPLog *config.HTTPLogSpec
Context string
Namespace string
Alias string
Type string
Resource string
Status string
RemotePort int
LocalPort int
}
// 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. 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()
defer t.mu.Unlock()
// Parse resource type and name
resourceType := "pod"
resourceName := fwd.Resource
parts := strings.Split(fwd.Resource, "/")
if len(parts) == 2 {
resourceType = parts[0]
resourceName = parts[1]
}
status := &ForwardStatus{
Context: fwd.GetContext(),
Namespace: fwd.GetNamespace(),
Alias: fwd.Alias,
Type: resourceType,
Resource: resourceName,
RemotePort: fwd.Port,
LocalPort: fwd.LocalPort,
Status: "Starting",
}
// If no alias, use resource name as display name
if status.Alias == "" {
status.Alias = resourceName
}
t.forwards[id] = status
}
// UpdateStatus updates the status of a forward
func (t *TableUI) UpdateStatus(id string, status string) {
t.mu.Lock()
defer t.mu.Unlock()
if fwd, ok := t.forwards[id]; ok {
fwd.Status = status
}
}
// Render displays the current table
func (t *TableUI) Render() {
t.mu.RLock()
defer t.mu.RUnlock()
// Clear screen and move cursor to top
if !t.verbose {
fmt.Print("\033[2J\033[H")
}
// Print header
fmt.Println("kportal - Port Forwarding Status")
fmt.Println(plainRule("=", t.columns))
// Table header
fmt.Println(renderPlainHeaderRow(t.columns))
fmt.Println(plainRule("-", t.columns))
// Sort forwards by local port for consistent display
type sortEntry struct {
fwd *ForwardStatus
id string
}
var entries []sortEntry
for id, fwd := range t.forwards {
entries = append(entries, sortEntry{fwd: fwd, id: id})
}
// Simple sort by local port
for i := 0; i < len(entries); i++ {
for j := i + 1; j < len(entries); j++ {
if entries[i].fwd.LocalPort > entries[j].fwd.LocalPort {
entries[i], entries[j] = entries[j], entries[i]
}
}
}
// Print each forward
for _, entry := range entries {
fmt.Println(renderPlainDataRow(t.columns, entry.fwd))
}
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
if t.verbose {
fmt.Println()
}
}
// RenderInitial renders the table once without clearing screen
func (t *TableUI) RenderInitial() {
t.mu.RLock()
defer t.mu.RUnlock()
// Print header
fmt.Println("\nkportal - Port Forwarding Status")
fmt.Println(plainRule("=", t.columns))
// Table header
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(plainRule("=", t.columns))
fmt.Println()
}
// GetForward returns a forward status by ID
func (t *TableUI) GetForward(id string) *ForwardStatus {
t.mu.RLock()
defer t.mu.RUnlock()
return t.forwards[id]
}
// Remove removes a forward from the display
func (t *TableUI) Remove(id string) {
t.mu.Lock()
defer t.mu.Unlock()
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.
func hyperlink(url, text string) string {
return fmt.Sprintf("\x1b]8;;%s\x1b\\%s\x1b]8;;\x1b\\", url, text)
}
// truncate truncates a string to maxLen runes, adding "..." if needed.
// It counts and slices by rune (not byte) so multibyte aliases/resource names
// are never cut mid-rune into mojibake.
func truncate(s string, maxLen int) string {
if maxLen <= 0 {
return ""
}
if utf8.RuneCountInString(s) <= maxLen {
return s
}
r := []rune(s)
if maxLen <= 3 {
return string(r[:maxLen])
}
return string(r[:maxLen-3]) + "..."
}
// formatStatusWithIndicator adds color-coded indicator symbols to status
func formatStatusWithIndicator(status string) string {
// Check if stdout is a terminal
if fileInfo, _ := os.Stdout.Stat(); (fileInfo.Mode() & os.ModeCharDevice) == 0 {
// Not a terminal, return plain text with simple indicator
switch status {
case "Active":
return "✓ " + status
case "Starting":
return "⋯ " + status
case "Reconnecting":
return "↻ " + status
case "Error", "Failed":
return "✗ " + status
default:
return status
}
}
// Terminal with color support
switch status {
case "Active":
return "\033[32m●\033[0m " + status // Green circle
case "Starting":
return "\033[33m○\033[0m " + status // Yellow circle (hollow)
case "Reconnecting":
return "\033[33m◐\033[0m " + status // Yellow half-circle
case "Error", "Failed":
return "\033[31m●\033[0m " + status // Red circle
default:
return status
}
}