mirror of
https://github.com/lukaszraczylo/kportal.git
synced 2026-06-05 23:03:40 +00:00
7df161aee0
* Fix enter misbehaving. * Cleanup after previous tui implementation. * Fix race condition and improve logging * Add filtering of the namespaces by text input in the wizard UI
366 lines
8.5 KiB
Go
366 lines
8.5 KiB
Go
package ui
|
|
|
|
import (
|
|
"strings"
|
|
|
|
"github.com/nvm/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
|
|
)
|
|
|
|
// 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 {
|
|
step AddWizardStep
|
|
inputMode InputMode
|
|
cursor int
|
|
scrollOffset int // For scrolling long lists
|
|
textInput string
|
|
searchFilter string // For filtering lists (contexts, namespaces, services)
|
|
loading bool
|
|
error error
|
|
|
|
// Selections made by user
|
|
selectedContext string
|
|
selectedNamespace string
|
|
selectedResourceType ResourceType
|
|
resourceValue string // pod prefix or service name
|
|
selector string // for pod selector type
|
|
remotePort int
|
|
localPort int
|
|
alias string
|
|
|
|
// Available options (loaded asynchronously from k8s)
|
|
contexts []string
|
|
namespaces []string
|
|
pods []k8s.PodInfo
|
|
services []k8s.ServiceInfo
|
|
|
|
// Validation state
|
|
portAvailable bool
|
|
portCheckMsg string
|
|
matchingPods []k8s.PodInfo
|
|
|
|
// Edit mode
|
|
isEditing bool
|
|
originalID string // ID of the forward being edited
|
|
|
|
// Detected ports from resources
|
|
detectedPorts []k8s.PortInfo
|
|
|
|
// Confirmation focus (alias field vs buttons)
|
|
confirmationFocus ConfirmationFocus
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
// 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 {
|
|
forwards []RemovableForward
|
|
cursor int
|
|
selected map[int]bool
|
|
confirming bool
|
|
confirmCursor int // 0 = Yes, 1 = No
|
|
}
|
|
|
|
// 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
|
|
}
|