mirror of
https://github.com/lukaszraczylo/kportal.git
synced 2026-10-11 04:27:10 +00:00
* 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]>
301 lines
7.6 KiB
Go
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
|
|
}
|
|
}
|