Files
kportal/internal/ui/wizard_state.go
T
4d263319ab feat: add --context to forward only selected contexts (#85)
* feat: add --context to forward only selected contexts

kportal forwards every context in the config file. When the file describes
several clusters that host the same services, that means holding connections
open to clusters nobody is using, and - because all contexts are active at once
- every localPort has to be globally unique. The same service therefore lands on
a different local port per cluster, so switching clusters means reconfiguring
the app being developed.

--context limits a run to the contexts you name. It is repeatable and accepts
comma-separated values, and composes with -headless:

    kportal --context development-team-a
    kportal --context team-a,team-b -headless
    kportal --context team-a --context team-b

An unknown name is an error listing the contexts the file defines, so a typo
fails loudly instead of silently forwarding nothing.

Local-port and mDNS-hostname conflicts are now scoped to forwards that could run
at the same time: same context, or both contexts in the active selection. With
no selection every context is active, so existing behaviour is unchanged.
Contexts that are never selected together may reuse the same localPort, which is
what lets a service keep one fixed local port in every cluster.

Validation still covers the whole file, so a problem in a context outside the
selection is still reported and --check does not get weaker. Loading, validating
and filtering are shared by every path that re-reads the config (startup, file
watcher, SIGHUP in both headless and verbose modes), so no path can forget the
selection.

Also fixes a latent identity bug this makes reachable: Forward.ID() is only
"alias:localPort" when an alias is set, which was unique across contexts only
because duplicate local ports were globally rejected. With ports now shareable,
deleting or editing a forward from the TUI could have matched same-alias
forwards in other contexts and silently removed them. Delete and edit are now
scoped to the context the row came from.

* fix(config): keep the running config when no selected context remains

A reload dropped selected contexts that no longer exist in the file.
When all of them vanished, the reload returned an empty config and the
manager stopped every forward after a single rename.

LoadForRuntime now returns an error in that case. The watcher keeps the
previous config, as it does for any other invalid reload.

Refs #80

* fix(ui): apply new table columns after a successful SIGHUP reload

The context selection moved the SIGHUP reload into reloadFromSignal,
which had no way to update the verbose table columns. The function now
takes a callback that runs only after the manager reloads without error.
The verbose table passes SetColumns.

Refs #80

* style: gofmt run_test.go

---------

Co-authored-by: André Cruz <[email protected]>
2026-10-02 02:33:29 +01:00

550 lines
13 KiB
Go

package ui
import (
"strings"
"github.com/lukaszraczylo/kportal/internal/config"
"github.com/lukaszraczylo/kportal/internal/k8s"
)
// filterStrings filters a slice of strings by a search filter (case-insensitive substring match)
func filterStrings(items []string, filter string) []string {
if filter == "" {
return items
}
filtered := []string{}
filterLower := strings.ToLower(filter)
for _, item := range items {
if strings.Contains(strings.ToLower(item), filterLower) {
filtered = append(filtered, item)
}
}
return filtered
}
// matchesFilter checks if a string matches the filter (case-insensitive substring match)
func matchesFilter(item, filter string) bool {
if filter == "" {
return true
}
return strings.Contains(strings.ToLower(item), strings.ToLower(filter))
}
// ViewMode represents the current view state of the UI
type ViewMode int
const (
ViewModeMain ViewMode = iota
ViewModeAddWizard
ViewModeRemoveWizard
ViewModeBenchmark
ViewModeHTTPLog
)
// InputMode represents whether the wizard is in list selection or text input mode
type InputMode int
const (
InputModeList InputMode = iota
InputModeText
)
// AddWizardStep represents the current step in the add wizard flow
type AddWizardStep int
const (
StepSelectContext AddWizardStep = iota
StepSelectNamespace
StepSelectResourceType
StepEnterResource
StepEnterRemotePort
StepEnterLocalPort
StepConfirmation
StepSuccess
)
// ConfirmationFocus represents what the user is focused on in confirmation step
type ConfirmationFocus int
const (
FocusAlias ConfirmationFocus = iota
FocusButtons
)
// ResourceType represents the type of Kubernetes resource to forward to
type ResourceType int
const (
ResourceTypePodPrefix ResourceType = iota
ResourceTypePodSelector
ResourceTypeService
)
// String returns a human-readable name for the resource type
func (r ResourceType) String() string {
switch r {
case ResourceTypePodPrefix:
return "Pod (by name prefix)"
case ResourceTypePodSelector:
return "Pod (by label selector)"
case ResourceTypeService:
return "Service"
default:
return "Unknown"
}
}
// Description returns a description of the resource type
func (r ResourceType) Description() string {
switch r {
case ResourceTypePodPrefix:
return "Recommended for specific pod instances"
case ResourceTypePodSelector:
return "Flexible, survives pod restarts automatically"
case ResourceTypeService:
return "Most stable, load-balanced"
default:
return ""
}
}
// AddWizardState maintains the state for the add port forward wizard
type AddWizardState struct {
error error
namespaceListErr error
httpLogOriginal *config.HTTPLogSpec
resourceValue string
originalID string
originalContext string
portCheckMsg string
alias string
textInput string
searchFilter string
selector string
selectedContext string
selectedNamespace string
services []k8s.ServiceInfo
detectedPorts []k8s.PortInfo
matchingPods []k8s.PodInfo
contexts []string
namespaces []string
pods []k8s.PodInfo
localPort int
selectedResourceType ResourceType
step AddWizardStep
scrollOffset int
cursor int
remotePort int
inputMode InputMode
confirmationFocus ConfirmationFocus
portAvailable bool
isEditing bool
namespaceManual bool
loading bool
httpLog bool
}
// newAddWizardState creates a new add wizard state initialized to the first step
func newAddWizardState() *AddWizardState {
return &AddWizardState{
step: StepSelectContext,
inputMode: InputModeList,
cursor: 0,
contexts: []string{},
}
}
// moveCursor moves the cursor up or down in list selection mode
func (w *AddWizardState) moveCursor(delta int) {
if w.inputMode != InputModeList {
return
}
var maxItems int
switch w.step {
case StepSelectContext:
maxItems = len(w.getFilteredContexts())
case StepSelectNamespace:
maxItems = len(w.getFilteredNamespaces())
case StepSelectResourceType:
maxItems = 3 // Three resource types
case StepEnterResource:
if w.selectedResourceType == ResourceTypeService {
maxItems = len(w.getFilteredServices())
}
case StepEnterRemotePort:
if len(w.detectedPorts) > 0 {
maxItems = len(w.detectedPorts) + 1 // +1 for "Manual entry" option
}
}
w.cursor += delta
if w.cursor < 0 {
w.cursor = 0
}
if w.cursor >= maxItems && maxItems > 0 {
w.cursor = maxItems - 1
}
// Adjust scroll offset to keep cursor visible
// Viewport shows max 20 items at a time
const viewportHeight = 20
// If cursor moved below visible area, scroll down
if w.cursor >= w.scrollOffset+viewportHeight {
w.scrollOffset = w.cursor - viewportHeight + 1
}
// If cursor moved above visible area, scroll up
if w.cursor < w.scrollOffset {
w.scrollOffset = w.cursor
}
// Ensure scroll offset is valid
if w.scrollOffset < 0 {
w.scrollOffset = 0
}
}
// handleTextInput handles a single character input in text mode
func (w *AddWizardState) handleTextInput(char rune) {
// Note: Caller already checks if text input is allowed (inputMode or confirmation step)
// so we don't need to check inputMode here
// Handle backspace
if char == 127 || char == 8 {
if len(w.textInput) > 0 {
w.textInput = w.textInput[:len(w.textInput)-1]
}
return
}
// Only allow printable characters
if char >= 32 && char < 127 {
w.textInput += string(char)
}
}
// isTypingText reports whether printable keys must go into a text field,
// including keys that otherwise navigate (j, k).
func (w *AddWizardState) isTypingText() bool {
return w.inputMode == InputModeText ||
(w.step == StepConfirmation && w.confirmationFocus == FocusAlias)
}
// clearTextInput clears the text input field
func (w *AddWizardState) clearTextInput() {
w.textInput = ""
}
// RemoveWizardState maintains the state for the remove port forward wizard
type RemoveWizardState struct {
selected map[int]bool
forwards []RemovableForward
cursor int
confirmCursor int
confirming bool
}
// RemovableForward represents a forward that can be removed
type RemovableForward struct {
ID string
Context string
Namespace string
Alias string
Resource string
Selector string
Port int
LocalPort int
}
// moveCursor moves the cursor up or down
func (w *RemoveWizardState) moveCursor(delta int) {
if w.confirming {
// Move between Yes/No in confirmation
w.confirmCursor += delta
if w.confirmCursor < 0 {
w.confirmCursor = 0
}
if w.confirmCursor > 1 {
w.confirmCursor = 1
}
} else {
// Move between forwards
w.cursor += delta
if w.cursor < 0 {
w.cursor = 0
}
if w.cursor >= len(w.forwards) {
w.cursor = len(w.forwards) - 1
}
}
}
// toggleSelection toggles the selection of the current forward
func (w *RemoveWizardState) toggleSelection() {
if w.confirming {
return
}
w.selected[w.cursor] = !w.selected[w.cursor]
}
// selectAll selects all forwards for removal
func (w *RemoveWizardState) selectAll() {
if w.confirming {
return
}
for i := range w.forwards {
w.selected[i] = true
}
}
// selectNone deselects all forwards
func (w *RemoveWizardState) selectNone() {
if w.confirming {
return
}
w.selected = make(map[int]bool)
}
// getSelectedCount returns the number of selected forwards
func (w *RemoveWizardState) getSelectedCount() int {
count := 0
for _, selected := range w.selected {
if selected {
count++
}
}
return count
}
// getSelectedForwards returns a list of selected forwards
func (w *RemoveWizardState) getSelectedForwards() []RemovableForward {
selected := make([]RemovableForward, 0)
for i, fwd := range w.forwards {
if w.selected[i] {
selected = append(selected, fwd)
}
}
return selected
}
// getFilteredContexts returns contexts filtered by search string
func (w *AddWizardState) getFilteredContexts() []string {
if w.searchFilter == "" {
return w.contexts
}
return filterStrings(w.contexts, w.searchFilter)
}
// getFilteredNamespaces returns namespaces filtered by search string
func (w *AddWizardState) getFilteredNamespaces() []string {
if w.searchFilter == "" {
return w.namespaces
}
return filterStrings(w.namespaces, w.searchFilter)
}
// getFilteredServices returns services filtered by search string
func (w *AddWizardState) getFilteredServices() []k8s.ServiceInfo {
if w.searchFilter == "" {
return w.services
}
filtered := []k8s.ServiceInfo{}
for _, svc := range w.services {
if matchesFilter(svc.Name, w.searchFilter) {
filtered = append(filtered, svc)
}
}
return filtered
}
// clearSearchFilter clears the search filter and resets cursor/scroll
func (w *AddWizardState) clearSearchFilter() {
w.searchFilter = ""
w.cursor = 0
w.scrollOffset = 0
}
// resetInput clears text input, search filter, and error state.
// Use this when navigating between wizard steps.
func (w *AddWizardState) resetInput() {
w.textInput = ""
w.searchFilter = ""
w.cursor = 0
w.scrollOffset = 0
w.error = nil
}
// BenchmarkStep represents the current step in the benchmark wizard
type BenchmarkStep int
const (
BenchmarkStepConfig BenchmarkStep = iota
BenchmarkStepRunning
BenchmarkStepResults
)
// BenchmarkState maintains the state for the benchmark wizard
type BenchmarkState struct {
error error
results *BenchmarkResults
cancelFunc func()
progressCh chan BenchmarkProgressMsg
textInput string
forwardID string
forwardAlias string
urlPath string
method string
cursor int
progress int
total int
step BenchmarkStep
requests int
concurrency int
localPort int
running bool
}
// BenchmarkResults holds benchmark results for display
type BenchmarkResults struct {
StatusCodes map[int]int
TotalRequests int
Successful int
Failed int
MinLatency float64
MaxLatency float64
AvgLatency float64
P50Latency float64
P95Latency float64
P99Latency float64
Throughput float64
BytesRead int64
}
// newBenchmarkState creates a new benchmark state for a forward
func newBenchmarkState(forwardID, alias string, localPort int) *BenchmarkState {
return &BenchmarkState{
step: BenchmarkStepConfig,
forwardID: forwardID,
forwardAlias: alias,
localPort: localPort,
urlPath: "/",
method: "GET",
concurrency: 10,
requests: 100,
cursor: 0,
}
}
// HTTPLogFilterMode represents the active filter type
type HTTPLogFilterMode int
const (
HTTPLogFilterNone HTTPLogFilterMode = iota
HTTPLogFilterText
HTTPLogFilterNon200
HTTPLogFilterErrors // 4xx and 5xx only
)
// HTTPLogState maintains the state for HTTP log viewing
type HTTPLogState struct {
forwardID string
forwardAlias string
filterText string
copyMessage string
entries []HTTPLogEntry
cursor int
scrollOffset int
filterMode HTTPLogFilterMode
detailScroll int
autoScroll bool
filterActive bool
showingDetail bool
}
// HTTPLogEntry represents a single HTTP log entry for display
type HTTPLogEntry struct {
RequestHeaders map[string]string
ResponseHeaders map[string]string
Method string
RequestID string
Path string
Direction string
Timestamp string
RequestBody string
ResponseBody string
Error string
StatusCode int
LatencyMs int64
BodySize int
}
// newHTTPLogState creates a new HTTP log viewing state
func newHTTPLogState(forwardID, alias string) *HTTPLogState {
return &HTTPLogState{
forwardID: forwardID,
forwardAlias: alias,
entries: make([]HTTPLogEntry, 0),
autoScroll: true,
filterMode: HTTPLogFilterNone,
}
}
// getFilteredEntries returns entries matching the current filter
// Only returns entries with status codes (responses) since requests don't have useful info
func (s *HTTPLogState) getFilteredEntries() []HTTPLogEntry {
filtered := make([]HTTPLogEntry, 0, len(s.entries))
filterLower := strings.ToLower(s.filterText)
for _, entry := range s.entries {
// Only show entries with status codes (completed responses)
// Requests, streaming connections, and errors without status are filtered out
if entry.StatusCode == 0 {
continue
}
// Apply filter mode
switch s.filterMode {
case HTTPLogFilterNon200:
if entry.StatusCode >= 200 && entry.StatusCode < 300 {
continue
}
case HTTPLogFilterErrors:
if entry.StatusCode < 400 {
continue
}
}
// Apply text filter
if s.filterText != "" {
matchPath := strings.Contains(strings.ToLower(entry.Path), filterLower)
matchMethod := strings.Contains(strings.ToLower(entry.Method), filterLower)
if !matchPath && !matchMethod {
continue
}
}
filtered = append(filtered, entry)
}
return filtered
}
// getFilterModeLabel returns a label for the current filter mode
func (s *HTTPLogState) getFilterModeLabel() string {
switch s.filterMode {
case HTTPLogFilterNone:
return "All"
case HTTPLogFilterText:
return "Text"
case HTTPLogFilterNon200:
return "Non-2xx"
case HTTPLogFilterErrors:
return "Errors (4xx/5xx)"
default:
return "All"
}
}