mirror of
https://github.com/lukaszraczylo/kportal.git
synced 2026-07-12 07:41:42 +00:00
bfe541565b
Adds an HTTP-log enable toggle to the wizard's confirmation step so
users can flip httpLog on a forward without editing YAML by hand.
Behaviour:
- 'h' on the confirmation step toggles HTTPLog when not focused on
the alias text input. When focus is on alias, 'h' is treated as
text so users can still type aliases like 'host' or 'http-proxy'.
- The confirmation summary shows '[x] enabled' or '[ ] disabled'.
- New forwards: toggle on -> &HTTPLogSpec{Enabled: true}; off -> nil.
- Edit mode: pre-populates the toggle from the existing forward and
preserves any advanced HTTPLog fields the user had configured in
YAML (logFile, includeHeaders, maxBodySize, filterPath) by copying
the original spec on save. Toggling off discards the advanced
fields (consistent with 'absent in YAML = disabled').
State changes:
- ForwardStatus gains *config.HTTPLogSpec so the wizard can see the
full original spec on edit.
- AddWizardState gains httpLog bool + httpLogOriginal *HTTPLogSpec.
Three new tests:
- TestHandleAddWizardKeys_HToggleHTTPLog
- TestHandleAddWizardKeys_HOnAliasFocusIsTextInput
- TestEditPrefill_PreservesHTTPLog
1504 lines
40 KiB
Go
1504 lines
40 KiB
Go
package ui
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os/exec"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/lukaszraczylo/kportal/internal/config"
|
|
"github.com/lukaszraczylo/kportal/internal/k8s"
|
|
)
|
|
|
|
// isFilterableStep returns true if the step supports search/filter
|
|
func isFilterableStep(step AddWizardStep) bool {
|
|
switch step {
|
|
case StepSelectContext, StepSelectNamespace:
|
|
return true
|
|
case StepEnterResource:
|
|
// Only service selection is filterable (pod prefix and selector are text input)
|
|
return true // We'll check resource type in the handler
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// handleMainViewKeys handles keyboard input in the main view
|
|
func (m model) handleMainViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
// If delete confirmation is showing, handle it separately
|
|
if m.ui.deleteConfirming {
|
|
return m.handleDeleteConfirmation(msg)
|
|
}
|
|
|
|
switch msg.String() {
|
|
case "ctrl+c", "q":
|
|
return m, tea.Quit
|
|
|
|
case "up", "k":
|
|
m.ui.moveSelection(-1)
|
|
|
|
case "down", "j":
|
|
m.ui.moveSelection(1)
|
|
|
|
case "pgup", "ctrl+u":
|
|
m.ui.moveSelection(-10)
|
|
|
|
case "pgdown", "ctrl+d":
|
|
m.ui.moveSelection(10)
|
|
|
|
case " ", "enter":
|
|
m.ui.toggleSelected()
|
|
|
|
case "n": // Enter add wizard
|
|
m.ui.mu.Lock()
|
|
// Don't create a new wizard if one is already active
|
|
if m.ui.addWizard != nil || m.ui.removeWizard != nil || m.ui.benchmarkState != nil || m.ui.httpLogState != nil {
|
|
m.ui.mu.Unlock()
|
|
return m, nil
|
|
}
|
|
if m.ui.discovery == nil || m.ui.mutator == nil {
|
|
// Dependencies not set up
|
|
m.ui.mu.Unlock()
|
|
return m, nil
|
|
}
|
|
|
|
m.ui.viewMode = ViewModeAddWizard
|
|
m.ui.addWizard = newAddWizardState()
|
|
m.ui.addWizard.loading = true
|
|
m.ui.mu.Unlock()
|
|
|
|
// Load contexts
|
|
return m, loadContextsCmd(m.ui.discovery)
|
|
|
|
case "e": // Edit selected forward
|
|
m.ui.mu.Lock()
|
|
// Don't create a new wizard if one is already active
|
|
if m.ui.addWizard != nil || m.ui.removeWizard != nil || m.ui.benchmarkState != nil || m.ui.httpLogState != nil {
|
|
m.ui.mu.Unlock()
|
|
return m, nil
|
|
}
|
|
|
|
if len(m.ui.forwardOrder) == 0 {
|
|
// No forwards to edit
|
|
m.ui.mu.Unlock()
|
|
return m, nil
|
|
}
|
|
|
|
if m.ui.discovery == nil || m.ui.mutator == nil {
|
|
// Dependencies not set up
|
|
m.ui.mu.Unlock()
|
|
return m, nil
|
|
}
|
|
|
|
// Get the currently selected forward
|
|
currentSelectedIndex := m.ui.selectedIndex
|
|
if currentSelectedIndex < 0 || currentSelectedIndex >= len(m.ui.forwardOrder) {
|
|
m.ui.mu.Unlock()
|
|
return m, nil
|
|
}
|
|
|
|
selectedID := m.ui.forwardOrder[currentSelectedIndex]
|
|
selectedForward, ok := m.ui.forwards[selectedID]
|
|
if !ok {
|
|
m.ui.mu.Unlock()
|
|
return m, nil
|
|
}
|
|
|
|
// Create an add wizard pre-filled with the current forward's values
|
|
m.ui.viewMode = ViewModeAddWizard
|
|
m.ui.addWizard = newAddWizardState()
|
|
|
|
// Pre-fill the wizard with current values
|
|
m.ui.addWizard.selectedContext = selectedForward.Context
|
|
m.ui.addWizard.selectedNamespace = selectedForward.Namespace
|
|
m.ui.addWizard.resourceValue = selectedForward.Resource
|
|
m.ui.addWizard.remotePort = selectedForward.RemotePort
|
|
m.ui.addWizard.localPort = selectedForward.LocalPort
|
|
m.ui.addWizard.alias = selectedForward.Alias
|
|
m.ui.addWizard.httpLogOriginal = selectedForward.HTTPLog
|
|
m.ui.addWizard.httpLog = selectedForward.HTTPLog != nil && selectedForward.HTTPLog.Enabled
|
|
|
|
// Determine resource type from the resource string
|
|
if strings.HasPrefix(selectedForward.Type, "service") {
|
|
m.ui.addWizard.selectedResourceType = ResourceTypeService
|
|
} else {
|
|
m.ui.addWizard.selectedResourceType = ResourceTypePodPrefix
|
|
}
|
|
|
|
// Mark as edit mode and store original ID
|
|
m.ui.addWizard.isEditing = true
|
|
m.ui.addWizard.originalID = selectedID
|
|
|
|
// Start at the remote port step (skip context/namespace/resource selection)
|
|
m.ui.addWizard.step = StepEnterRemotePort
|
|
|
|
// Load resources to detect ports
|
|
m.ui.addWizard.loading = true
|
|
m.ui.mu.Unlock()
|
|
|
|
// Load pods or services to detect available ports
|
|
if m.ui.addWizard.selectedResourceType == ResourceTypeService {
|
|
return m, loadServicesCmd(m.ui.discovery, selectedForward.Context, selectedForward.Namespace)
|
|
}
|
|
return m, loadPodsCmd(m.ui.discovery, selectedForward.Context, selectedForward.Namespace)
|
|
|
|
case "d": // Delete currently selected forward - show confirmation
|
|
m.ui.mu.Lock()
|
|
|
|
// Don't overwrite existing confirmation dialog
|
|
if m.ui.deleteConfirming {
|
|
m.ui.mu.Unlock()
|
|
return m, nil
|
|
}
|
|
|
|
if len(m.ui.forwardOrder) == 0 {
|
|
// No forwards to delete
|
|
m.ui.mu.Unlock()
|
|
return m, nil
|
|
}
|
|
|
|
if m.ui.mutator == nil {
|
|
// Dependencies not set up
|
|
m.ui.mu.Unlock()
|
|
return m, nil
|
|
}
|
|
|
|
// Get the currently selected forward
|
|
currentSelectedIndex := m.ui.selectedIndex
|
|
if currentSelectedIndex < 0 || currentSelectedIndex >= len(m.ui.forwardOrder) {
|
|
m.ui.mu.Unlock()
|
|
return m, nil
|
|
}
|
|
|
|
selectedID := m.ui.forwardOrder[currentSelectedIndex]
|
|
selectedForward, ok := m.ui.forwards[selectedID]
|
|
if !ok {
|
|
m.ui.mu.Unlock()
|
|
return m, nil
|
|
}
|
|
|
|
// Show confirmation dialog
|
|
m.ui.deleteConfirming = true
|
|
m.ui.deleteConfirmID = selectedID
|
|
m.ui.deleteConfirmAlias = selectedForward.Alias
|
|
m.ui.deleteConfirmCursor = 1 // Default to "No" for safety
|
|
|
|
m.ui.mu.Unlock()
|
|
return m, nil
|
|
|
|
case "b": // Benchmark selected forward
|
|
m.ui.mu.Lock()
|
|
// Don't create benchmark view if another modal is active
|
|
if m.ui.addWizard != nil || m.ui.removeWizard != nil || m.ui.benchmarkState != nil || m.ui.httpLogState != nil {
|
|
m.ui.mu.Unlock()
|
|
return m, nil
|
|
}
|
|
|
|
if len(m.ui.forwardOrder) == 0 {
|
|
m.ui.mu.Unlock()
|
|
return m, nil
|
|
}
|
|
|
|
currentSelectedIndex := m.ui.selectedIndex
|
|
if currentSelectedIndex < 0 || currentSelectedIndex >= len(m.ui.forwardOrder) {
|
|
m.ui.mu.Unlock()
|
|
return m, nil
|
|
}
|
|
|
|
selectedID := m.ui.forwardOrder[currentSelectedIndex]
|
|
selectedForward, ok := m.ui.forwards[selectedID]
|
|
if !ok {
|
|
m.ui.mu.Unlock()
|
|
return m, nil
|
|
}
|
|
|
|
// Create benchmark state
|
|
m.ui.viewMode = ViewModeBenchmark
|
|
m.ui.benchmarkState = newBenchmarkState(selectedID, selectedForward.Alias, selectedForward.LocalPort)
|
|
// Initialize textInput with the first field's value
|
|
m.ui.benchmarkState.textInput = m.ui.benchmarkState.urlPath
|
|
|
|
m.ui.mu.Unlock()
|
|
return m, nil
|
|
|
|
case "l": // View HTTP logs for selected forward
|
|
m.ui.mu.Lock()
|
|
// Don't create log view if another modal is active
|
|
if m.ui.addWizard != nil || m.ui.removeWizard != nil || m.ui.benchmarkState != nil || m.ui.httpLogState != nil {
|
|
m.ui.mu.Unlock()
|
|
return m, nil
|
|
}
|
|
|
|
if len(m.ui.forwardOrder) == 0 {
|
|
m.ui.mu.Unlock()
|
|
return m, nil
|
|
}
|
|
|
|
currentSelectedIndex := m.ui.selectedIndex
|
|
if currentSelectedIndex < 0 || currentSelectedIndex >= len(m.ui.forwardOrder) {
|
|
m.ui.mu.Unlock()
|
|
return m, nil
|
|
}
|
|
|
|
selectedID := m.ui.forwardOrder[currentSelectedIndex]
|
|
selectedForward, ok := m.ui.forwards[selectedID]
|
|
if !ok {
|
|
m.ui.mu.Unlock()
|
|
return m, nil
|
|
}
|
|
|
|
// Create HTTP log state
|
|
m.ui.viewMode = ViewModeHTTPLog
|
|
m.ui.httpLogState = newHTTPLogState(selectedID, selectedForward.Alias)
|
|
|
|
// Capture subscriber and UI reference for the callback
|
|
subscriber := m.ui.httpLogSubscriber
|
|
ui := m.ui
|
|
m.ui.mu.Unlock()
|
|
|
|
// Subscribe to HTTP logs if subscriber is available
|
|
// This is done outside the lock to prevent deadlocks in the callback
|
|
if subscriber != nil {
|
|
cleanup := subscriber(selectedID, func(entry HTTPLogEntry) {
|
|
// Recover from panics in the callback
|
|
defer safeRecover("HTTPLogSubscriber callback")
|
|
|
|
// Use RLock to safely access program
|
|
ui.mu.RLock()
|
|
program := ui.program
|
|
ui.mu.RUnlock()
|
|
|
|
// Send entry to program (thread-safe via Send)
|
|
if program != nil {
|
|
program.Send(HTTPLogEntryMsg{Entry: entry})
|
|
}
|
|
})
|
|
ui.mu.Lock()
|
|
ui.httpLogCleanup = cleanup
|
|
ui.mu.Unlock()
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
// handleDeleteConfirmation handles keyboard input for delete confirmation dialog
|
|
func (m model) handleDeleteConfirmation(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
m.ui.mu.Lock()
|
|
|
|
switch msg.String() {
|
|
case "ctrl+c", "esc":
|
|
// Cancel deletion
|
|
m.ui.resetDeleteConfirmation()
|
|
m.ui.mu.Unlock()
|
|
return m, tea.ClearScreen
|
|
|
|
case "left", "h", "right", "l":
|
|
// Toggle between Yes/No
|
|
m.ui.deleteConfirmCursor = 1 - m.ui.deleteConfirmCursor
|
|
m.ui.mu.Unlock()
|
|
return m, nil
|
|
|
|
case "enter", "y":
|
|
// Confirm deletion (either Enter on Yes or pressing 'y')
|
|
if m.ui.deleteConfirmCursor == 0 || msg.String() == "y" {
|
|
id := m.ui.deleteConfirmID
|
|
m.ui.resetDeleteConfirmation()
|
|
m.ui.mu.Unlock()
|
|
return m, removeForwardByIDCmd(m.ui.mutator, id)
|
|
}
|
|
// Enter on No = cancel
|
|
m.ui.resetDeleteConfirmation()
|
|
m.ui.mu.Unlock()
|
|
return m, tea.ClearScreen
|
|
|
|
case "n":
|
|
// Quick 'n' for no
|
|
m.ui.resetDeleteConfirmation()
|
|
m.ui.mu.Unlock()
|
|
return m, tea.ClearScreen
|
|
}
|
|
|
|
m.ui.mu.Unlock()
|
|
return m, nil
|
|
}
|
|
|
|
// handleAddWizardKeys handles keyboard input in the add wizard
|
|
func (m model) handleAddWizardKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
m.ui.mu.Lock()
|
|
defer m.ui.mu.Unlock()
|
|
|
|
wizard := m.ui.addWizard
|
|
if wizard == nil {
|
|
return m, nil
|
|
}
|
|
|
|
switch msg.String() {
|
|
case "ctrl+c":
|
|
// Hard cancel
|
|
m.ui.viewMode = ViewModeMain
|
|
m.ui.addWizard = nil
|
|
return m, tea.ClearScreen
|
|
|
|
case "esc":
|
|
// If there's an active search filter, clear it instead of going back
|
|
if wizard.searchFilter != "" && isFilterableStep(wizard.step) {
|
|
wizard.clearSearchFilter()
|
|
return m, nil
|
|
}
|
|
|
|
// In edit mode, Esc always cancels (don't navigate back through skipped steps)
|
|
if wizard.isEditing {
|
|
m.ui.viewMode = ViewModeMain
|
|
m.ui.addWizard = nil
|
|
return m, tea.ClearScreen
|
|
}
|
|
|
|
// In add mode, go back or cancel
|
|
if wizard.step == StepSelectContext {
|
|
// On first step, cancel entirely
|
|
m.ui.viewMode = ViewModeMain
|
|
m.ui.addWizard = nil
|
|
return m, tea.ClearScreen
|
|
} else {
|
|
// Go back one step
|
|
wizard.step--
|
|
wizard.resetInput()
|
|
|
|
// Reset input mode based on the step we're going back to
|
|
switch wizard.step {
|
|
case StepSelectContext, StepSelectNamespace, StepSelectResourceType:
|
|
wizard.inputMode = InputModeList
|
|
case StepEnterResource:
|
|
if wizard.selectedResourceType == ResourceTypeService {
|
|
wizard.inputMode = InputModeList
|
|
} else {
|
|
wizard.inputMode = InputModeText
|
|
}
|
|
case StepEnterRemotePort, StepEnterLocalPort:
|
|
wizard.inputMode = InputModeText
|
|
case StepConfirmation:
|
|
wizard.inputMode = InputModeList
|
|
}
|
|
}
|
|
return m, nil
|
|
|
|
case "up", "k":
|
|
// In confirmation step, toggle between alias and buttons
|
|
if wizard.step == StepConfirmation {
|
|
if wizard.confirmationFocus == FocusButtons {
|
|
wizard.confirmationFocus = FocusAlias
|
|
}
|
|
} else {
|
|
wizard.moveCursor(-1)
|
|
}
|
|
|
|
case "down", "j":
|
|
// In confirmation step, toggle between alias and buttons
|
|
if wizard.step == StepConfirmation {
|
|
if wizard.confirmationFocus == FocusAlias {
|
|
wizard.confirmationFocus = FocusButtons
|
|
wizard.cursor = 0
|
|
} else {
|
|
wizard.moveCursor(1) // Navigate between buttons
|
|
}
|
|
} else {
|
|
wizard.moveCursor(1)
|
|
}
|
|
|
|
case "pgup", "ctrl+u":
|
|
// Page up - move 10 items
|
|
wizard.moveCursor(-10)
|
|
|
|
case "pgdown", "ctrl+d":
|
|
// Page down - move 10 items
|
|
wizard.moveCursor(10)
|
|
|
|
case "tab":
|
|
// Tab moves between alias field and buttons in confirmation
|
|
if wizard.step == StepConfirmation {
|
|
if wizard.confirmationFocus == FocusAlias {
|
|
wizard.confirmationFocus = FocusButtons
|
|
wizard.cursor = 0
|
|
} else {
|
|
wizard.confirmationFocus = FocusAlias
|
|
}
|
|
}
|
|
|
|
case "h":
|
|
// In confirmation step (when not typing into the alias field), 'h'
|
|
// toggles whether this forward has HTTP traffic logging enabled.
|
|
// When the alias field is focused, fall through to text input below.
|
|
if wizard.step == StepConfirmation && wizard.confirmationFocus != FocusAlias {
|
|
wizard.httpLog = !wizard.httpLog
|
|
return m, nil
|
|
}
|
|
// Otherwise treat as text input (filter or alias).
|
|
canTypeText := wizard.inputMode == InputModeText ||
|
|
(wizard.step == StepConfirmation && wizard.confirmationFocus == FocusAlias) ||
|
|
(wizard.inputMode == InputModeList && isFilterableStep(wizard.step))
|
|
if canTypeText {
|
|
if wizard.inputMode == InputModeList && isFilterableStep(wizard.step) {
|
|
wizard.searchFilter += "h"
|
|
wizard.cursor = 0
|
|
wizard.scrollOffset = 0
|
|
} else {
|
|
wizard.handleTextInput('h')
|
|
}
|
|
}
|
|
return m, nil
|
|
|
|
case "enter":
|
|
return m.handleAddWizardEnter()
|
|
|
|
case "backspace":
|
|
// Allow backspace in text input mode OR when focused on alias in confirmation OR when filtering
|
|
canBackspace := wizard.inputMode == InputModeText ||
|
|
(wizard.step == StepConfirmation && wizard.confirmationFocus == FocusAlias) ||
|
|
(wizard.inputMode == InputModeList && isFilterableStep(wizard.step) && len(wizard.searchFilter) > 0)
|
|
|
|
if canBackspace {
|
|
if isFilterableStep(wizard.step) && wizard.inputMode == InputModeList && len(wizard.searchFilter) > 0 {
|
|
// Backspace in search filter
|
|
wizard.searchFilter = wizard.searchFilter[:len(wizard.searchFilter)-1]
|
|
wizard.cursor = 0
|
|
wizard.scrollOffset = 0
|
|
} else if len(wizard.textInput) > 0 {
|
|
wizard.textInput = wizard.textInput[:len(wizard.textInput)-1]
|
|
}
|
|
}
|
|
|
|
default:
|
|
// Handle text input
|
|
canTypeText := wizard.inputMode == InputModeText ||
|
|
(wizard.step == StepConfirmation && wizard.confirmationFocus == FocusAlias) ||
|
|
(wizard.inputMode == InputModeList && isFilterableStep(wizard.step))
|
|
|
|
if canTypeText && len(msg.String()) == 1 {
|
|
// If in list mode on filterable step, add to search filter instead of textInput
|
|
if wizard.inputMode == InputModeList && isFilterableStep(wizard.step) {
|
|
char := rune(msg.String()[0])
|
|
// Only allow printable characters
|
|
if char >= 32 && char < 127 {
|
|
wizard.searchFilter += string(char)
|
|
wizard.cursor = 0
|
|
wizard.scrollOffset = 0
|
|
}
|
|
} else {
|
|
wizard.handleTextInput(rune(msg.String()[0]))
|
|
|
|
// Trigger validation for selector
|
|
if wizard.step == StepEnterResource && wizard.selectedResourceType == ResourceTypePodSelector {
|
|
if len(wizard.textInput) > 0 {
|
|
wizard.loading = true
|
|
wizard.error = nil
|
|
return m, validateSelectorCmd(m.ui.discovery, wizard.selectedContext, wizard.selectedNamespace, wizard.textInput)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
// handleAddWizardEnter handles Enter key in the add wizard
|
|
func (m model) handleAddWizardEnter() (tea.Model, tea.Cmd) {
|
|
wizard := m.ui.addWizard
|
|
|
|
// Don't process Enter if we're currently loading
|
|
if wizard.loading {
|
|
return m, nil
|
|
}
|
|
|
|
switch wizard.step {
|
|
case StepSelectContext:
|
|
filteredContexts := wizard.getFilteredContexts()
|
|
if wizard.cursor >= 0 && wizard.cursor < len(filteredContexts) {
|
|
wizard.selectedContext = filteredContexts[wizard.cursor]
|
|
wizard.step = StepSelectNamespace
|
|
wizard.cursor = 0
|
|
wizard.clearSearchFilter()
|
|
wizard.loading = true
|
|
return m, loadNamespacesCmd(m.ui.discovery, wizard.selectedContext)
|
|
}
|
|
|
|
case StepSelectNamespace:
|
|
filteredNamespaces := wizard.getFilteredNamespaces()
|
|
if wizard.cursor >= 0 && wizard.cursor < len(filteredNamespaces) {
|
|
wizard.selectedNamespace = filteredNamespaces[wizard.cursor]
|
|
wizard.step = StepSelectResourceType
|
|
wizard.cursor = 0
|
|
wizard.clearSearchFilter()
|
|
wizard.inputMode = InputModeList
|
|
}
|
|
|
|
case StepSelectResourceType:
|
|
if wizard.cursor >= 0 && wizard.cursor < 3 {
|
|
wizard.selectedResourceType = ResourceType(wizard.cursor)
|
|
wizard.step = StepEnterResource
|
|
wizard.cursor = 0
|
|
|
|
if wizard.selectedResourceType == ResourceTypeService {
|
|
wizard.inputMode = InputModeList
|
|
wizard.loading = true
|
|
return m, loadServicesCmd(m.ui.discovery, wizard.selectedContext, wizard.selectedNamespace)
|
|
} else {
|
|
wizard.inputMode = InputModeText
|
|
wizard.loading = true
|
|
return m, loadPodsCmd(m.ui.discovery, wizard.selectedContext, wizard.selectedNamespace)
|
|
}
|
|
}
|
|
|
|
case StepEnterResource:
|
|
switch wizard.selectedResourceType {
|
|
case ResourceTypePodPrefix:
|
|
if wizard.textInput != "" {
|
|
wizard.resourceValue = wizard.textInput
|
|
wizard.step = StepEnterRemotePort
|
|
wizard.clearTextInput()
|
|
|
|
// Detect ports from matching pods
|
|
wizard.detectedPorts = k8s.GetUniquePorts(wizard.pods)
|
|
if len(wizard.detectedPorts) > 0 {
|
|
wizard.inputMode = InputModeList
|
|
wizard.cursor = 0
|
|
} else {
|
|
wizard.inputMode = InputModeText
|
|
}
|
|
}
|
|
|
|
case ResourceTypePodSelector:
|
|
if wizard.textInput != "" && len(wizard.matchingPods) > 0 {
|
|
wizard.resourceValue = "pod"
|
|
wizard.selector = wizard.textInput
|
|
wizard.step = StepEnterRemotePort
|
|
wizard.clearTextInput()
|
|
|
|
// Detect ports from matching pods
|
|
wizard.detectedPorts = k8s.GetUniquePorts(wizard.matchingPods)
|
|
if len(wizard.detectedPorts) > 0 {
|
|
wizard.inputMode = InputModeList
|
|
wizard.cursor = 0
|
|
} else {
|
|
wizard.inputMode = InputModeText
|
|
}
|
|
}
|
|
|
|
case ResourceTypeService:
|
|
filteredServices := wizard.getFilteredServices()
|
|
if wizard.cursor >= 0 && wizard.cursor < len(filteredServices) {
|
|
wizard.resourceValue = filteredServices[wizard.cursor].Name
|
|
|
|
// Get ports from selected service (must do this BEFORE clearing search filter)
|
|
wizard.detectedPorts = filteredServices[wizard.cursor].Ports
|
|
|
|
wizard.step = StepEnterRemotePort
|
|
wizard.clearTextInput()
|
|
wizard.clearSearchFilter()
|
|
|
|
if len(wizard.detectedPorts) > 0 {
|
|
wizard.inputMode = InputModeList
|
|
wizard.cursor = 0
|
|
} else {
|
|
wizard.inputMode = InputModeText
|
|
}
|
|
}
|
|
}
|
|
|
|
case StepEnterRemotePort:
|
|
if wizard.inputMode == InputModeList && len(wizard.detectedPorts) > 0 {
|
|
// List mode - user selected from detected ports
|
|
if wizard.cursor == len(wizard.detectedPorts) {
|
|
// Selected "Manual entry" option
|
|
wizard.inputMode = InputModeText
|
|
wizard.clearTextInput()
|
|
} else if wizard.cursor >= 0 && wizard.cursor < len(wizard.detectedPorts) {
|
|
// Selected a detected port
|
|
// For services, use TargetPort (actual pod port) if available
|
|
// For pods, TargetPort is 0, so use Port (container port)
|
|
selectedPort := wizard.detectedPorts[wizard.cursor]
|
|
if selectedPort.TargetPort > 0 {
|
|
wizard.remotePort = int(selectedPort.TargetPort)
|
|
} else {
|
|
wizard.remotePort = int(selectedPort.Port)
|
|
}
|
|
wizard.step = StepEnterLocalPort
|
|
wizard.clearTextInput()
|
|
wizard.inputMode = InputModeText
|
|
wizard.error = nil
|
|
}
|
|
} else {
|
|
// Text mode - manual entry
|
|
port, err := strconv.Atoi(wizard.textInput)
|
|
if err != nil || !config.IsValidPort(port) {
|
|
wizard.error = fmt.Errorf("invalid port number")
|
|
} else {
|
|
wizard.remotePort = port
|
|
wizard.step = StepEnterLocalPort
|
|
wizard.clearTextInput()
|
|
wizard.error = nil
|
|
}
|
|
}
|
|
|
|
case StepEnterLocalPort:
|
|
port, err := strconv.Atoi(wizard.textInput)
|
|
if err != nil || !config.IsValidPort(port) {
|
|
wizard.error = fmt.Errorf("invalid port number")
|
|
} else {
|
|
// Check port availability before proceeding
|
|
wizard.localPort = port
|
|
wizard.loading = true
|
|
wizard.error = nil
|
|
return m, checkPortCmd(port, m.ui.configPath)
|
|
}
|
|
|
|
case StepConfirmation:
|
|
// If focused on alias field, move to buttons
|
|
if wizard.confirmationFocus == FocusAlias {
|
|
wizard.confirmationFocus = FocusButtons
|
|
wizard.cursor = 0
|
|
return m, nil
|
|
}
|
|
|
|
// Handle button selection
|
|
if wizard.cursor == 0 {
|
|
// Check if port is available before saving
|
|
if !wizard.portAvailable {
|
|
wizard.error = fmt.Errorf("port %d is not available. Please choose a different port", wizard.localPort)
|
|
return m, nil
|
|
}
|
|
|
|
// Confirmed - save the forward
|
|
wizard.alias = wizard.textInput
|
|
|
|
// Build the forward config
|
|
fwd := config.Forward{
|
|
Protocol: "tcp",
|
|
Port: wizard.remotePort,
|
|
LocalPort: wizard.localPort,
|
|
Alias: wizard.alias,
|
|
}
|
|
|
|
switch wizard.selectedResourceType {
|
|
case ResourceTypePodPrefix:
|
|
fwd.Resource = "pod/" + wizard.resourceValue
|
|
case ResourceTypePodSelector:
|
|
fwd.Resource = wizard.resourceValue
|
|
fwd.Selector = wizard.selector
|
|
case ResourceTypeService:
|
|
fwd.Resource = "service/" + wizard.resourceValue
|
|
}
|
|
|
|
// HTTPLog: when toggled on, preserve any advanced fields the
|
|
// user had configured in YAML (logFile, includeHeaders, etc.)
|
|
// so the wizard does not silently strip them. When toggled
|
|
// off, leave HTTPLog nil (= absent in YAML = disabled).
|
|
if wizard.httpLog {
|
|
if wizard.httpLogOriginal != nil {
|
|
spec := *wizard.httpLogOriginal
|
|
spec.Enabled = true
|
|
fwd.HTTPLog = &spec
|
|
} else {
|
|
fwd.HTTPLog = &config.HTTPLogSpec{Enabled: true}
|
|
}
|
|
}
|
|
|
|
wizard.loading = true
|
|
|
|
// If editing, use atomic update operation
|
|
if wizard.isEditing {
|
|
return m, updateForwardCmd(m.ui.mutator, wizard.originalID, wizard.selectedContext, wizard.selectedNamespace, fwd)
|
|
}
|
|
|
|
return m, saveForwardCmd(m.ui.mutator, wizard.selectedContext, wizard.selectedNamespace, fwd)
|
|
} else {
|
|
// Cancelled - return to main view with screen clear
|
|
m.ui.viewMode = ViewModeMain
|
|
m.ui.addWizard = nil
|
|
return m, tea.ClearScreen
|
|
}
|
|
|
|
case StepSuccess:
|
|
if wizard.cursor == 0 {
|
|
// Add another
|
|
m.ui.addWizard = newAddWizardState()
|
|
m.ui.addWizard.loading = true
|
|
return m, loadContextsCmd(m.ui.discovery)
|
|
} else {
|
|
// Return to main view with screen clear
|
|
m.ui.viewMode = ViewModeMain
|
|
m.ui.addWizard = nil
|
|
return m, tea.ClearScreen
|
|
}
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
// handleRemoveWizardKeys handles keyboard input in the remove wizard
|
|
func (m model) handleRemoveWizardKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
m.ui.mu.Lock()
|
|
defer m.ui.mu.Unlock()
|
|
|
|
wizard := m.ui.removeWizard
|
|
if wizard == nil {
|
|
return m, nil
|
|
}
|
|
|
|
switch msg.String() {
|
|
case "ctrl+c":
|
|
// Hard cancel - always exit
|
|
m.ui.viewMode = ViewModeMain
|
|
m.ui.removeWizard = nil
|
|
return m, tea.ClearScreen
|
|
|
|
case "esc":
|
|
if wizard.confirming {
|
|
// In confirmation mode, Esc cancels the confirmation (matches help text "Esc: Cancel")
|
|
// Returns to selection state without dispatching removal.
|
|
wizard.confirming = false
|
|
wizard.confirmCursor = 0
|
|
return m, nil
|
|
}
|
|
// Not confirming yet - cancel entirely
|
|
m.ui.viewMode = ViewModeMain
|
|
m.ui.removeWizard = nil
|
|
return m, tea.ClearScreen
|
|
|
|
case "up", "k":
|
|
wizard.moveCursor(-1)
|
|
|
|
case "down", "j":
|
|
wizard.moveCursor(1)
|
|
|
|
case "pgup", "ctrl+u":
|
|
wizard.moveCursor(-10)
|
|
|
|
case "pgdown", "ctrl+d":
|
|
wizard.moveCursor(10)
|
|
|
|
case " ":
|
|
if !wizard.confirming {
|
|
wizard.toggleSelection()
|
|
}
|
|
|
|
case "a":
|
|
wizard.selectAll()
|
|
|
|
case "n":
|
|
wizard.selectNone()
|
|
|
|
case "enter":
|
|
if !wizard.confirming {
|
|
if wizard.getSelectedCount() == 0 {
|
|
// Nothing selected
|
|
return m, nil
|
|
}
|
|
// Show confirmation
|
|
wizard.confirming = true
|
|
wizard.confirmCursor = 0
|
|
} else {
|
|
// Confirmed
|
|
if wizard.confirmCursor == 0 {
|
|
// Yes, remove
|
|
selectedForwards := wizard.getSelectedForwards()
|
|
return m, removeForwardsCmd(m.ui.mutator, selectedForwards)
|
|
} else {
|
|
// No, cancel
|
|
wizard.confirming = false
|
|
}
|
|
}
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
// Message handlers
|
|
|
|
func (m model) handleContextsLoaded(msg ContextsLoadedMsg) (tea.Model, tea.Cmd) {
|
|
m.ui.mu.Lock()
|
|
defer m.ui.mu.Unlock()
|
|
|
|
if m.ui.addWizard != nil {
|
|
m.ui.addWizard.loading = false
|
|
m.ui.addWizard.error = msg.err
|
|
if msg.err == nil {
|
|
// Get current context and move it to the top (if discovery is available)
|
|
if m.ui.discovery != nil {
|
|
currentCtx, err := m.ui.discovery.GetCurrentContext()
|
|
if err == nil && currentCtx != "" {
|
|
// Reorder contexts with current first
|
|
reordered := []string{currentCtx}
|
|
for _, ctx := range msg.contexts {
|
|
if ctx != currentCtx {
|
|
reordered = append(reordered, ctx)
|
|
}
|
|
}
|
|
m.ui.addWizard.contexts = reordered
|
|
} else {
|
|
m.ui.addWizard.contexts = msg.contexts
|
|
}
|
|
} else {
|
|
m.ui.addWizard.contexts = msg.contexts
|
|
}
|
|
}
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
func (m model) handleNamespacesLoaded(msg NamespacesLoadedMsg) (tea.Model, tea.Cmd) {
|
|
m.ui.mu.Lock()
|
|
defer m.ui.mu.Unlock()
|
|
|
|
if m.ui.addWizard != nil {
|
|
m.ui.addWizard.loading = false
|
|
m.ui.addWizard.error = msg.err
|
|
if msg.err == nil {
|
|
m.ui.addWizard.namespaces = msg.namespaces
|
|
}
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
func (m model) handlePodsLoaded(msg PodsLoadedMsg) (tea.Model, tea.Cmd) {
|
|
m.ui.mu.Lock()
|
|
defer m.ui.mu.Unlock()
|
|
|
|
if m.ui.addWizard != nil {
|
|
m.ui.addWizard.loading = false
|
|
m.ui.addWizard.error = msg.err
|
|
if msg.err == nil {
|
|
m.ui.addWizard.pods = msg.pods
|
|
|
|
// If we're at the remote port step (edit mode), detect ports now
|
|
if m.ui.addWizard.step == StepEnterRemotePort {
|
|
m.ui.addWizard.detectedPorts = k8s.GetUniquePorts(msg.pods)
|
|
if len(m.ui.addWizard.detectedPorts) > 0 {
|
|
m.ui.addWizard.inputMode = InputModeList
|
|
m.ui.addWizard.cursor = 0
|
|
} else {
|
|
m.ui.addWizard.inputMode = InputModeText
|
|
m.ui.addWizard.textInput = fmt.Sprintf("%d", m.ui.addWizard.remotePort)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
func (m model) handleServicesLoaded(msg ServicesLoadedMsg) (tea.Model, tea.Cmd) {
|
|
m.ui.mu.Lock()
|
|
defer m.ui.mu.Unlock()
|
|
|
|
if m.ui.addWizard != nil {
|
|
m.ui.addWizard.loading = false
|
|
m.ui.addWizard.error = msg.err
|
|
if msg.err == nil {
|
|
m.ui.addWizard.services = msg.services
|
|
|
|
// If we're at the remote port step (edit mode), detect ports now
|
|
if m.ui.addWizard.step == StepEnterRemotePort {
|
|
// Find the service by name
|
|
for _, svc := range msg.services {
|
|
if svc.Name == m.ui.addWizard.resourceValue {
|
|
m.ui.addWizard.detectedPorts = svc.Ports
|
|
if len(m.ui.addWizard.detectedPorts) > 0 {
|
|
m.ui.addWizard.inputMode = InputModeList
|
|
m.ui.addWizard.cursor = 0
|
|
} else {
|
|
m.ui.addWizard.inputMode = InputModeText
|
|
m.ui.addWizard.textInput = fmt.Sprintf("%d", m.ui.addWizard.remotePort)
|
|
}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
func (m model) handleSelectorValidated(msg SelectorValidatedMsg) (tea.Model, tea.Cmd) {
|
|
m.ui.mu.Lock()
|
|
defer m.ui.mu.Unlock()
|
|
|
|
if m.ui.addWizard != nil {
|
|
m.ui.addWizard.loading = false
|
|
m.ui.addWizard.error = msg.err
|
|
if msg.valid {
|
|
m.ui.addWizard.matchingPods = msg.pods
|
|
} else {
|
|
m.ui.addWizard.matchingPods = nil
|
|
}
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
func (m model) handlePortChecked(msg PortCheckedMsg) (tea.Model, tea.Cmd) {
|
|
m.ui.mu.Lock()
|
|
defer m.ui.mu.Unlock()
|
|
|
|
if m.ui.addWizard != nil {
|
|
m.ui.addWizard.loading = false
|
|
m.ui.addWizard.portAvailable = msg.available
|
|
m.ui.addWizard.portCheckMsg = msg.message
|
|
|
|
// Only proceed to confirmation if port is available
|
|
if msg.available {
|
|
m.ui.addWizard.step = StepConfirmation
|
|
m.ui.addWizard.clearTextInput()
|
|
m.ui.addWizard.cursor = 0
|
|
m.ui.addWizard.inputMode = InputModeList
|
|
} else {
|
|
// Port is not available - show error and stay on local port step
|
|
m.ui.addWizard.error = fmt.Errorf("port %d is in use, please choose another port", msg.port)
|
|
}
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
func (m model) handleForwardSaved(msg ForwardSavedMsg) (tea.Model, tea.Cmd) {
|
|
m.ui.mu.Lock()
|
|
defer m.ui.mu.Unlock()
|
|
|
|
if m.ui.addWizard != nil {
|
|
m.ui.addWizard.loading = false
|
|
if msg.success {
|
|
// Move to success step
|
|
m.ui.addWizard.step = StepSuccess
|
|
m.ui.addWizard.cursor = 0
|
|
m.ui.addWizard.inputMode = InputModeList
|
|
} else {
|
|
m.ui.addWizard.error = msg.err
|
|
}
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
func (m model) handleForwardsRemoved(msg ForwardsRemovedMsg) (tea.Model, tea.Cmd) {
|
|
m.ui.mu.Lock()
|
|
defer m.ui.mu.Unlock()
|
|
|
|
// Delete now happens directly without wizard
|
|
// Just ensure we're back in main view
|
|
m.ui.viewMode = ViewModeMain
|
|
m.ui.removeWizard = nil
|
|
|
|
// If there was an error, it will be logged but we don't show it in UI for now
|
|
// The config watcher will either reload (success) or keep old config (failure)
|
|
|
|
return m, tea.ClearScreen
|
|
}
|
|
|
|
// handleBenchmarkKeys handles keyboard input in the benchmark view
|
|
func (m model) handleBenchmarkKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
m.ui.mu.Lock()
|
|
defer m.ui.mu.Unlock()
|
|
|
|
state := m.ui.benchmarkState
|
|
if state == nil {
|
|
return m, nil
|
|
}
|
|
|
|
switch msg.String() {
|
|
case "ctrl+c", "esc":
|
|
// Cancel the running benchmark if active
|
|
if state.cancelFunc != nil {
|
|
state.cancelFunc()
|
|
}
|
|
// Return to main view
|
|
m.ui.viewMode = ViewModeMain
|
|
m.ui.benchmarkState = nil
|
|
return m, tea.ClearScreen
|
|
|
|
case "up", "k":
|
|
if state.step == BenchmarkStepConfig && state.cursor > 0 {
|
|
state.cursor--
|
|
// Load current field value into textInput
|
|
state.textInput = m.getBenchmarkFieldValue(state.cursor)
|
|
}
|
|
|
|
case "down", "j":
|
|
if state.step == BenchmarkStepConfig && state.cursor < 3 {
|
|
state.cursor++
|
|
// Load current field value into textInput
|
|
state.textInput = m.getBenchmarkFieldValue(state.cursor)
|
|
}
|
|
|
|
case "tab":
|
|
// Tab also cycles through fields
|
|
if state.step == BenchmarkStepConfig {
|
|
state.cursor = (state.cursor + 1) % 4
|
|
state.textInput = m.getBenchmarkFieldValue(state.cursor)
|
|
}
|
|
|
|
case "enter":
|
|
switch state.step {
|
|
case BenchmarkStepConfig:
|
|
// Start running the benchmark
|
|
state.step = BenchmarkStepRunning
|
|
state.running = true
|
|
state.progress = 0
|
|
state.total = state.requests
|
|
// Create progress channel with buffer for non-blocking sends
|
|
state.progressCh = make(chan BenchmarkProgressMsg, 10)
|
|
// Create cancellable context for the benchmark
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
state.cancelFunc = cancel
|
|
// Return batch command to run benchmark and listen for progress
|
|
return m, tea.Batch(
|
|
runBenchmarkCmd(ctx, state.forwardID, state.localPort, state.urlPath, state.method, state.concurrency, state.requests, state.progressCh),
|
|
listenBenchmarkProgressCmd(state.progressCh),
|
|
)
|
|
case BenchmarkStepResults:
|
|
// Return to main view
|
|
m.ui.viewMode = ViewModeMain
|
|
m.ui.benchmarkState = nil
|
|
return m, tea.ClearScreen
|
|
}
|
|
|
|
case "backspace":
|
|
if state.step == BenchmarkStepConfig {
|
|
if len(state.textInput) > 0 {
|
|
state.textInput = state.textInput[:len(state.textInput)-1]
|
|
m.applyBenchmarkTextInput()
|
|
}
|
|
}
|
|
|
|
default:
|
|
// Handle text input in config step
|
|
if state.step == BenchmarkStepConfig && len(msg.String()) == 1 {
|
|
char := rune(msg.String()[0])
|
|
if char >= 32 && char < 127 {
|
|
state.textInput += string(char)
|
|
m.applyBenchmarkTextInput()
|
|
}
|
|
}
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
// getBenchmarkFieldValue returns the current value of the selected benchmark field
|
|
func (m model) getBenchmarkFieldValue(cursor int) string {
|
|
state := m.ui.benchmarkState
|
|
if state == nil {
|
|
return ""
|
|
}
|
|
|
|
switch cursor {
|
|
case 0:
|
|
return state.urlPath
|
|
case 1:
|
|
return state.method
|
|
case 2:
|
|
return fmt.Sprintf("%d", state.concurrency)
|
|
case 3:
|
|
return fmt.Sprintf("%d", state.requests)
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
// applyBenchmarkTextInput applies the current text input to the selected field
|
|
func (m model) applyBenchmarkTextInput() {
|
|
state := m.ui.benchmarkState
|
|
if state == nil {
|
|
return
|
|
}
|
|
|
|
switch state.cursor {
|
|
case 0: // URL path
|
|
state.urlPath = state.textInput
|
|
case 1: // Method
|
|
state.method = strings.ToUpper(state.textInput)
|
|
case 2: // Concurrency
|
|
if val, err := strconv.Atoi(state.textInput); err == nil && val > 0 {
|
|
state.concurrency = val
|
|
// Cap concurrency at requests
|
|
if state.concurrency > state.requests {
|
|
state.concurrency = state.requests
|
|
}
|
|
}
|
|
case 3: // Requests
|
|
if val, err := strconv.Atoi(state.textInput); err == nil && val > 0 {
|
|
state.requests = val
|
|
// Cap concurrency at requests
|
|
if state.concurrency > state.requests {
|
|
state.concurrency = state.requests
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// handleHTTPLogKeys handles keyboard input in the HTTP log view
|
|
func (m model) handleHTTPLogKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
m.ui.mu.Lock()
|
|
defer m.ui.mu.Unlock()
|
|
|
|
state := m.ui.httpLogState
|
|
if state == nil {
|
|
return m, nil
|
|
}
|
|
|
|
// If filter input is active, handle text input
|
|
if state.filterActive {
|
|
switch msg.String() {
|
|
case "esc":
|
|
// Cancel filter input, clear text
|
|
state.filterActive = false
|
|
state.filterText = ""
|
|
state.cursor = 0
|
|
state.scrollOffset = 0
|
|
return m, nil
|
|
case "enter":
|
|
// Confirm filter
|
|
state.filterActive = false
|
|
state.cursor = 0
|
|
state.scrollOffset = 0
|
|
return m, nil
|
|
case "backspace":
|
|
if len(state.filterText) > 0 {
|
|
state.filterText = state.filterText[:len(state.filterText)-1]
|
|
}
|
|
return m, nil
|
|
default:
|
|
// Add character to filter
|
|
if len(msg.String()) == 1 {
|
|
char := rune(msg.String()[0])
|
|
if char >= 32 && char < 127 {
|
|
state.filterText += string(char)
|
|
state.cursor = 0
|
|
state.scrollOffset = 0
|
|
}
|
|
}
|
|
return m, nil
|
|
}
|
|
}
|
|
|
|
filteredEntries := state.getFilteredEntries()
|
|
|
|
// If viewing detail, handle detail view keys
|
|
if state.showingDetail {
|
|
switch msg.String() {
|
|
case "esc", "q", "enter":
|
|
// Return to list view
|
|
state.showingDetail = false
|
|
state.detailScroll = 0
|
|
state.copyMessage = ""
|
|
return m, nil
|
|
case "up", "k":
|
|
if state.detailScroll > 0 {
|
|
state.detailScroll--
|
|
}
|
|
return m, nil
|
|
case "down", "j":
|
|
state.detailScroll++
|
|
return m, nil
|
|
case "pgup", "ctrl+u":
|
|
state.detailScroll -= 20
|
|
if state.detailScroll < 0 {
|
|
state.detailScroll = 0
|
|
}
|
|
return m, nil
|
|
case "pgdown", "ctrl+d":
|
|
state.detailScroll += 20
|
|
return m, nil
|
|
case "g":
|
|
state.detailScroll = 0
|
|
return m, nil
|
|
case "c":
|
|
// Copy response body to clipboard
|
|
if state.cursor >= 0 && state.cursor < len(filteredEntries) {
|
|
entry := filteredEntries[state.cursor]
|
|
if entry.ResponseBody != "" {
|
|
// Decompress if needed before copying
|
|
body := decompressContent(entry.ResponseBody, entry.ResponseHeaders)
|
|
if err := copyToClipboard(body); err == nil {
|
|
state.copyMessage = "Copied!"
|
|
} else {
|
|
state.copyMessage = "Clipboard unavailable"
|
|
}
|
|
// Clear the message after 2 seconds
|
|
return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
|
|
return clearCopyMessageMsg{}
|
|
})
|
|
}
|
|
}
|
|
return m, nil
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
switch msg.String() {
|
|
case "ctrl+c", "esc", "q":
|
|
// Cleanup subscription before closing
|
|
if m.ui.httpLogCleanup != nil {
|
|
m.ui.httpLogCleanup()
|
|
m.ui.httpLogCleanup = nil
|
|
}
|
|
// Return to main view
|
|
m.ui.viewMode = ViewModeMain
|
|
m.ui.httpLogState = nil
|
|
return m, tea.ClearScreen
|
|
|
|
case "enter":
|
|
// Show detail view for selected entry
|
|
if len(filteredEntries) > 0 && state.cursor >= 0 && state.cursor < len(filteredEntries) {
|
|
state.showingDetail = true
|
|
state.detailScroll = 0
|
|
}
|
|
return m, nil
|
|
|
|
case "up", "k":
|
|
if state.cursor > 0 {
|
|
state.cursor--
|
|
state.autoScroll = false
|
|
}
|
|
|
|
case "down", "j":
|
|
if state.cursor < len(filteredEntries)-1 {
|
|
state.cursor++
|
|
}
|
|
// If at bottom, enable auto-scroll
|
|
if state.cursor >= len(filteredEntries)-1 {
|
|
state.autoScroll = true
|
|
}
|
|
|
|
case "pgup", "ctrl+u":
|
|
// Page up - move 20 entries
|
|
state.cursor -= 20
|
|
if state.cursor < 0 {
|
|
state.cursor = 0
|
|
}
|
|
state.autoScroll = false
|
|
|
|
case "pgdown", "ctrl+d":
|
|
// Page down - move 20 entries
|
|
state.cursor += 20
|
|
if state.cursor >= len(filteredEntries) {
|
|
state.cursor = len(filteredEntries) - 1
|
|
}
|
|
if state.cursor < 0 {
|
|
state.cursor = 0
|
|
}
|
|
// If at bottom, enable auto-scroll
|
|
if state.cursor >= len(filteredEntries)-1 {
|
|
state.autoScroll = true
|
|
}
|
|
|
|
case "g":
|
|
// Go to top
|
|
state.cursor = 0
|
|
state.scrollOffset = 0
|
|
state.autoScroll = false
|
|
|
|
case "G":
|
|
// Go to bottom
|
|
if len(filteredEntries) > 0 {
|
|
state.cursor = len(filteredEntries) - 1
|
|
state.autoScroll = true
|
|
}
|
|
|
|
case "a":
|
|
// Toggle auto-scroll
|
|
state.autoScroll = !state.autoScroll
|
|
|
|
case "f":
|
|
// Cycle filter mode (skip Text mode when cycling - use '/' for text filter)
|
|
state.filterMode = (state.filterMode + 1) % 4
|
|
if state.filterMode == HTTPLogFilterText {
|
|
// Skip Text mode when using 'f' - it's only accessible via '/'
|
|
state.filterMode = HTTPLogFilterNon200
|
|
}
|
|
state.cursor = 0
|
|
state.scrollOffset = 0
|
|
|
|
case "/":
|
|
// Enter text filter mode
|
|
state.filterActive = true
|
|
state.filterText = ""
|
|
|
|
case "c":
|
|
// Clear all filters
|
|
state.filterMode = HTTPLogFilterNone
|
|
state.filterText = ""
|
|
state.cursor = 0
|
|
state.scrollOffset = 0
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
// handleHTTPLogEntry handles incoming HTTP log entries
|
|
func (m model) handleHTTPLogEntry(msg HTTPLogEntryMsg) (tea.Model, tea.Cmd) {
|
|
m.ui.mu.Lock()
|
|
defer m.ui.mu.Unlock()
|
|
|
|
if m.ui.httpLogState == nil {
|
|
return m, nil
|
|
}
|
|
|
|
state := m.ui.httpLogState
|
|
entry := msg.Entry
|
|
|
|
// If this is a response, try to find and merge with the matching request
|
|
if entry.Direction == "response" && entry.RequestID != "" {
|
|
// Search backwards (responses follow requests closely)
|
|
for i := len(state.entries) - 1; i >= 0 && i >= len(state.entries)-100; i-- {
|
|
if state.entries[i].RequestID == entry.RequestID && state.entries[i].Direction == "request" {
|
|
// Merge response data into the existing request entry
|
|
state.entries[i].Direction = "response"
|
|
state.entries[i].StatusCode = entry.StatusCode
|
|
state.entries[i].LatencyMs = entry.LatencyMs
|
|
state.entries[i].BodySize = entry.BodySize
|
|
state.entries[i].ResponseHeaders = entry.ResponseHeaders
|
|
state.entries[i].ResponseBody = entry.ResponseBody
|
|
state.entries[i].Error = entry.Error
|
|
return m, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
// For requests or unmatched responses, append as new entry
|
|
state.entries = append(state.entries, entry)
|
|
|
|
// Cap entries to prevent memory growth (keep last 10000 entries)
|
|
const maxEntries = 10000
|
|
if len(state.entries) > maxEntries {
|
|
// Remove oldest entries
|
|
state.entries = state.entries[len(state.entries)-maxEntries:]
|
|
// Adjust cursor if needed
|
|
if state.cursor >= len(state.entries) {
|
|
state.cursor = len(state.entries) - 1
|
|
}
|
|
}
|
|
|
|
// Auto-scroll to bottom if enabled
|
|
if state.autoScroll && len(state.entries) > 0 {
|
|
filteredEntries := state.getFilteredEntries()
|
|
state.cursor = len(filteredEntries) - 1
|
|
if state.cursor < 0 {
|
|
state.cursor = 0
|
|
}
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
// handleBenchmarkProgress handles progress updates during benchmark execution
|
|
func (m model) handleBenchmarkProgress(msg BenchmarkProgressMsg) (tea.Model, tea.Cmd) {
|
|
m.ui.mu.Lock()
|
|
defer m.ui.mu.Unlock()
|
|
|
|
if m.ui.benchmarkState == nil || !m.ui.benchmarkState.running {
|
|
return m, nil
|
|
}
|
|
|
|
state := m.ui.benchmarkState
|
|
state.progress = msg.Completed
|
|
state.total = msg.Total
|
|
|
|
// Continue listening for more progress updates
|
|
if state.progressCh != nil {
|
|
return m, listenBenchmarkProgressCmd(state.progressCh)
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
// handleBenchmarkComplete handles the benchmark completion message
|
|
func (m model) handleBenchmarkComplete(msg BenchmarkCompleteMsg) (tea.Model, tea.Cmd) {
|
|
m.ui.mu.Lock()
|
|
defer m.ui.mu.Unlock()
|
|
|
|
if m.ui.benchmarkState == nil {
|
|
return m, nil
|
|
}
|
|
|
|
state := m.ui.benchmarkState
|
|
state.running = false
|
|
state.step = BenchmarkStepResults
|
|
state.progressCh = nil // Clear progress channel since benchmark is complete
|
|
|
|
if msg.Error != nil {
|
|
state.error = msg.Error
|
|
state.results = nil
|
|
} else if msg.Results != nil {
|
|
stats := msg.Results.CalculateStats()
|
|
state.results = &BenchmarkResults{
|
|
TotalRequests: msg.Results.TotalRequests,
|
|
Successful: msg.Results.Successful,
|
|
Failed: msg.Results.Failed,
|
|
MinLatency: float64(stats.MinLatency.Milliseconds()),
|
|
MaxLatency: float64(stats.MaxLatency.Milliseconds()),
|
|
AvgLatency: float64(stats.AvgLatency.Milliseconds()),
|
|
P50Latency: float64(stats.P50Latency.Milliseconds()),
|
|
P95Latency: float64(stats.P95Latency.Milliseconds()),
|
|
P99Latency: float64(stats.P99Latency.Milliseconds()),
|
|
Throughput: stats.Throughput,
|
|
BytesRead: msg.Results.BytesRead,
|
|
StatusCodes: msg.Results.StatusCodes,
|
|
}
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
// copyToClipboard copies text to the system clipboard using OS-specific commands.
|
|
// This avoids CGO dependencies that cause issues in CI environments.
|
|
func copyToClipboard(text string) error {
|
|
var cmd *exec.Cmd
|
|
|
|
switch runtime.GOOS {
|
|
case "darwin":
|
|
cmd = exec.Command("pbcopy")
|
|
case "linux":
|
|
// Try xclip first, fall back to xsel
|
|
if _, err := exec.LookPath("xclip"); err == nil {
|
|
cmd = exec.Command("xclip", "-selection", "clipboard")
|
|
} else if _, err := exec.LookPath("xsel"); err == nil {
|
|
cmd = exec.Command("xsel", "--clipboard", "--input")
|
|
} else {
|
|
return fmt.Errorf("no clipboard tool found (install xclip or xsel)")
|
|
}
|
|
case "windows":
|
|
cmd = exec.Command("clip")
|
|
default:
|
|
return fmt.Errorf("unsupported platform: %s", runtime.GOOS)
|
|
}
|
|
|
|
stdin, err := cmd.StdinPipe()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := cmd.Start(); err != nil {
|
|
return err
|
|
}
|
|
|
|
if _, err := stdin.Write([]byte(text)); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := stdin.Close(); err != nil {
|
|
return err
|
|
}
|
|
|
|
return cmd.Wait()
|
|
}
|