improvements nov2025 (#10)

* Add benchmark and httplog modules, update UI for modals artefacts
This commit is contained in:
2025-11-25 19:00:44 +00:00
committed by GitHub
parent 035b1cdd01
commit 3f5c1d3a5f
19 changed files with 2976 additions and 186 deletions
+134 -93
View File
@@ -7,6 +7,7 @@ import (
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/lipgloss/table"
"github.com/nvm/kportal/internal/config"
"github.com/nvm/kportal/internal/k8s"
)
@@ -34,6 +35,10 @@ type ForwardRemoveMsg struct {
ID string
}
// HTTPLogSubscriber is a function that subscribes to HTTP logs for a forward
// It returns a cleanup function to call when unsubscribing
type HTTPLogSubscriber func(forwardID string, callback func(entry HTTPLogEntry)) func()
// BubbleTeaUI is a bubbletea-based terminal UI
type BubbleTeaUI struct {
mu sync.RWMutex
@@ -62,10 +67,22 @@ type BubbleTeaUI struct {
deleteConfirmAlias string
deleteConfirmCursor int // 0 = Yes, 1 = No
// Benchmark state
benchmarkState *BenchmarkState
// HTTP log viewing state
httpLogState *HTTPLogState
// Log callback cleanup function
httpLogCleanup func()
// Dependencies for wizards
discovery *k8s.Discovery
mutator *config.Mutator
configPath string
// Manager for accessing workers
httpLogSubscriber HTTPLogSubscriber
}
// bubbletea model
@@ -101,6 +118,14 @@ func (ui *BubbleTeaUI) SetWizardDependencies(discovery *k8s.Discovery, mutator *
ui.configPath = configPath
}
// SetHTTPLogSubscriber sets the function to subscribe to HTTP logs
func (ui *BubbleTeaUI) SetHTTPLogSubscriber(subscriber HTTPLogSubscriber) {
ui.mu.Lock()
defer ui.mu.Unlock()
ui.httpLogSubscriber = subscriber
}
// SetUpdateAvailable sets the update notification to be displayed
func (ui *BubbleTeaUI) SetUpdateAvailable(version, url string) {
ui.mu.Lock()
@@ -253,6 +278,10 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.handleAddWizardKeys(msg)
case ViewModeRemoveWizard:
return m.handleRemoveWizardKeys(msg)
case ViewModeBenchmark:
return m.handleBenchmarkKeys(msg)
case ViewModeHTTPLog:
return m.handleHTTPLogKeys(msg)
}
// Forward management messages (always update main view data)
@@ -283,6 +312,15 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.ui.removeWizard = nil
m.ui.mu.Unlock()
return m, tea.ClearScreen
case BenchmarkCompleteMsg:
return m.handleBenchmarkComplete(msg)
case BenchmarkProgressMsg:
return m.handleBenchmarkProgress(msg)
case HTTPLogEntryMsg:
return m.handleHTTPLogEntry(msg)
}
return m, nil
@@ -323,6 +361,12 @@ func (m model) View() string {
case ViewModeRemoveWizard:
modal := m.renderRemoveWizard()
return overlayContent(mainView, modal, termWidth, termHeight)
case ViewModeBenchmark:
modal := m.renderBenchmark()
return overlayContent(mainView, modal, termWidth, termHeight)
case ViewModeHTTPLog:
// HTTP Log is full-screen, don't overlay on main view
return m.renderHTTPLog()
default:
return mainView
}
@@ -340,36 +384,21 @@ func (m model) renderMainView() string {
termHeight = 40 // Fallback
}
// Styles
titleStyle := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("220")).
Padding(0, 1)
headerStyle := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("220"))
separatorStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("240"))
selectedStyle := lipgloss.NewStyle().
Background(lipgloss.Color("240")).
Foreground(lipgloss.Color("230"))
disabledStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("240"))
activeStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("46"))
startingStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("220"))
errorStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("196"))
// Color palette
headerColor := lipgloss.Color("220") // Yellow
activeColor := lipgloss.Color("46") // Green
warningColor := lipgloss.Color("220") // Yellow
errorColor := lipgloss.Color("196") // Red
mutedColor := lipgloss.Color("240") // Gray
selectedBg := lipgloss.Color("240") // Gray background
selectedFg := lipgloss.Color("230") // Light foreground
// Title with version
titleStyle := lipgloss.NewStyle().
Bold(true).
Foreground(headerColor).
Padding(0, 1)
title := fmt.Sprintf("kportal v%s - Port Forwarding Status", m.ui.version)
b.WriteString(titleStyle.Render(title))
@@ -383,94 +412,104 @@ func (m model) renderMainView() string {
}
b.WriteString("\n\n")
// Header
header := fmt.Sprintf("%-15s %-18s %-20s %-10s %-21s %7s %7s %s",
"CONTEXT", "NAMESPACE", "ALIAS", "TYPE", "RESOURCE", "REMOTE", "LOCAL", "STATUS")
b.WriteString(headerStyle.Render(header))
b.WriteString("\n")
b.WriteString(separatorStyle.Render(strings.Repeat("─", 120)))
b.WriteString("\n")
// No forwards
if len(m.ui.forwardOrder) == 0 {
b.WriteString(disabledStyle.Render("\nNo forwards configured\n"))
disabledStyle := lipgloss.NewStyle().Foreground(mutedColor)
b.WriteString(disabledStyle.Render("No forwards configured\n"))
} else {
// Display forwards
for idx, id := range m.ui.forwardOrder {
// Build table rows
var rows [][]string
for _, id := range m.ui.forwardOrder {
fwd, ok := m.ui.forwards[id]
if !ok {
continue
}
isSelected := (idx == m.ui.selectedIndex)
isDisabled := m.ui.disabledMap[id] || fwd.Status == "Disabled"
// Selection indicator
indicator := " "
if isSelected {
indicator = "> "
}
// Status icon and text
statusIcon := "● "
statusIcon := "●"
statusText := fwd.Status
if isDisabled {
statusIcon = "○ "
statusIcon = "○"
statusText = "Disabled"
} else {
switch fwd.Status {
case "Starting":
statusIcon = "○ "
statusIcon = "○"
case "Reconnecting":
statusIcon = "◐ "
statusIcon = "◐"
case "Error":
statusIcon = "✗ "
statusIcon = "✗"
}
}
// Format row
row := fmt.Sprintf("%s%-15s %-18s %-20s %-10s %-21s %7d %7d %s%s",
indicator,
truncate(fwd.Context, 15),
truncate(fwd.Namespace, 18),
truncate(fwd.Alias, 20),
truncate(fwd.Type, 10),
truncate(fwd.Resource, 21),
fwd.RemotePort,
fwd.LocalPort,
statusIcon,
statusText)
// Apply styling
if isSelected {
row = selectedStyle.Render(row)
} else if isDisabled {
row = disabledStyle.Render(row)
} else {
// Color the status part
switch fwd.Status {
case "Active":
parts := strings.Split(row, statusIcon)
if len(parts) == 2 {
row = parts[0] + activeStyle.Render(statusIcon+statusText)
}
case "Starting", "Reconnecting":
parts := strings.Split(row, statusIcon)
if len(parts) == 2 {
row = parts[0] + startingStyle.Render(statusIcon+statusText)
}
case "Error":
parts := strings.Split(row, statusIcon)
if len(parts) == 2 {
row = parts[0] + errorStyle.Render(statusIcon+statusText)
}
}
}
b.WriteString(row)
b.WriteString("\n")
rows = append(rows, []string{
truncate(fwd.Context, 14),
truncate(fwd.Namespace, 16),
truncate(fwd.Alias, 18),
truncate(fwd.Type, 8),
truncate(fwd.Resource, 20),
fmt.Sprintf("%d", fwd.RemotePort),
fmt.Sprintf("%d", fwd.LocalPort),
statusIcon + " " + statusText,
})
}
// Create table with styling (no borders for cleaner look)
t := table.New().
Border(lipgloss.HiddenBorder()).
Headers("CONTEXT", "NAMESPACE", "ALIAS", "TYPE", "RESOURCE", "REMOTE", "LOCAL", "STATUS").
Rows(rows...).
StyleFunc(func(row, col int) lipgloss.Style {
// Header row
if row == table.HeaderRow {
return lipgloss.NewStyle().
Bold(true).
Foreground(headerColor).
Padding(0, 1)
}
// Get the forward for this row to check its status
baseStyle := lipgloss.NewStyle().Padding(0, 1)
if row >= 0 && row < len(m.ui.forwardOrder) {
id := m.ui.forwardOrder[row]
fwd, ok := m.ui.forwards[id]
isSelected := row == m.ui.selectedIndex
isDisabled := m.ui.disabledMap[id] || (ok && fwd.Status == "Disabled")
// Selected row gets background highlight
if isSelected {
return baseStyle.
Background(selectedBg).
Foreground(selectedFg)
}
// Disabled rows are muted
if isDisabled {
return baseStyle.Foreground(mutedColor)
}
// Status column gets colored based on status
if col == 7 && ok { // STATUS column
switch fwd.Status {
case "Active":
return baseStyle.Foreground(activeColor)
case "Starting", "Reconnecting":
return baseStyle.Foreground(warningColor)
case "Error":
return baseStyle.Foreground(errorColor)
}
}
}
return baseStyle
})
b.WriteString(t.Render())
b.WriteString("\n")
}
// Display errors if any (before footer)
@@ -524,13 +563,15 @@ func (m model) renderMainView() string {
footerStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
keyStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("220"))
footer := fmt.Sprintf("%s/%s: Navigate %s: Toggle %s: New %s: Edit %s: Delete %s: Quit │ Total: %d",
footer := fmt.Sprintf("%s/%s: Navigate %s: Toggle %s: New %s: Edit %s: Delete %s: Bench %s: Logs %s: Quit │ Total: %d",
keyStyle.Render("↑↓"),
keyStyle.Render("jk"),
keyStyle.Render("Space"),
keyStyle.Render("n"),
keyStyle.Render("e"),
keyStyle.Render("d"),
keyStyle.Render("b"),
keyStyle.Render("l"),
keyStyle.Render("q"),
len(m.ui.forwardOrder))
+75
View File
@@ -6,6 +6,7 @@ import (
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/nvm/kportal/internal/benchmark"
"github.com/nvm/kportal/internal/config"
"github.com/nvm/kportal/internal/k8s"
)
@@ -237,3 +238,77 @@ func removeForwardByIDCmd(mutator *config.Mutator, id string) tea.Cmd {
}
}
}
// BenchmarkCompleteMsg is sent when a benchmark run completes
type BenchmarkCompleteMsg struct {
ForwardID string
Results *benchmark.Results
Error error
}
// BenchmarkProgressMsg is sent periodically during benchmark execution
type BenchmarkProgressMsg struct {
ForwardID string
Completed int
Total int
}
// HTTPLogEntryMsg is sent when a new HTTP log entry is received
type HTTPLogEntryMsg struct {
Entry HTTPLogEntry
}
// listenBenchmarkProgressCmd listens for progress updates from the benchmark
func listenBenchmarkProgressCmd(progressCh <-chan BenchmarkProgressMsg) tea.Cmd {
return func() tea.Msg {
msg, ok := <-progressCh
if !ok {
// Channel closed, benchmark complete
return nil
}
return msg
}
}
// runBenchmarkCmd runs a benchmark against the given port forward
// It sends progress updates via tea.Batch until completion
func runBenchmarkCmd(forwardID string, localPort int, urlPath, method string, concurrency, requests int, progressCh chan<- BenchmarkProgressMsg) tea.Cmd {
return func() tea.Msg {
runner := benchmark.NewRunner()
url := fmt.Sprintf("http://localhost:%d%s", localPort, urlPath)
cfg := benchmark.Config{
URL: url,
Method: method,
Concurrency: concurrency,
Requests: requests,
Timeout: 30 * time.Second,
ProgressCallback: func(completed, total int) {
// Non-blocking send to progress channel
select {
case progressCh <- BenchmarkProgressMsg{
ForwardID: forwardID,
Completed: completed,
Total: total,
}:
default:
// Drop if channel is full
}
},
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
results, err := runner.Run(ctx, forwardID, cfg)
// Close the progress channel when done
close(progressCh)
return BenchmarkCompleteMsg{
ForwardID: forwardID,
Results: results,
Error: err,
}
}
}
+446
View File
@@ -40,6 +40,12 @@ func (m model) handleMainViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
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()
@@ -159,6 +165,75 @@ func (m model) handleMainViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.ui.deleteConfirmAlias = selectedForward.Alias
m.ui.deleteConfirmCursor = 0 // Default to "No" for safety
m.ui.mu.Unlock()
return m, nil
case "b": // Benchmark selected forward
m.ui.mu.Lock()
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()
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)
// Subscribe to HTTP logs if subscriber is available
if m.ui.httpLogSubscriber != nil {
cleanup := m.ui.httpLogSubscriber(selectedID, func(entry HTTPLogEntry) {
// Add entry to state (thread-safe via Send)
if m.ui.program != nil {
m.ui.program.Send(HTTPLogEntryMsg{Entry: entry})
}
})
m.ui.httpLogCleanup = cleanup
}
m.ui.mu.Unlock()
return m, nil
}
@@ -290,6 +365,14 @@ func (m model) handleAddWizardKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
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 {
@@ -609,6 +692,12 @@ func (m model) handleRemoveWizardKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
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()
@@ -824,3 +913,360 @@ func (m model) handleForwardsRemoved(msg ForwardsRemovedMsg) (tea.Model, tea.Cmd
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 and 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)
// Return batch command to run benchmark and listen for progress
return m, tea.Batch(
runBenchmarkCmd(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()
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 "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
state.cycleFilterMode()
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
state.entries = append(state.entries, msg.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 {
state.cursor = len(state.entries) - 1
}
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
}
+178
View File
@@ -36,6 +36,8 @@ const (
ViewModeMain ViewMode = iota
ViewModeAddWizard
ViewModeRemoveWizard
ViewModeBenchmark
ViewModeHTTPLog
)
// InputMode represents whether the wizard is in list selection or text input mode
@@ -373,3 +375,179 @@ func (w *AddWizardState) resetInput() {
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 {
step BenchmarkStep
forwardID string
forwardAlias string
localPort int
// Configuration
urlPath string
method string
concurrency int
requests int
cursor int // Current field being edited
textInput string
// Running state
running bool
progress int
total int
progressCh chan BenchmarkProgressMsg // Channel for progress updates
// Results
results *BenchmarkResults
error error
}
// BenchmarkResults holds benchmark results for display
type BenchmarkResults struct {
TotalRequests int
Successful int
Failed int
MinLatency float64 // milliseconds
MaxLatency float64
AvgLatency float64
P50Latency float64
P95Latency float64
P99Latency float64
Throughput float64 // requests per second
BytesRead int64
StatusCodes map[int]int
}
// 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
entries []HTTPLogEntry
cursor int
scrollOffset int
autoScroll bool
// Filtering
filterMode HTTPLogFilterMode
filterText string
filterActive bool // true when typing in filter input
}
// HTTPLogEntry represents a single HTTP log entry for display
type HTTPLogEntry struct {
Timestamp string
Direction string
Method string
Path 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
}
// cycleFilterMode cycles through filter modes
func (s *HTTPLogState) cycleFilterMode() {
s.filterMode = (s.filterMode + 1) % 4
s.cursor = 0
s.scrollOffset = 0
}
// 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"
}
}
+19 -44
View File
@@ -59,6 +59,10 @@ var (
spinnerStyle = lipgloss.NewStyle().
Foreground(accentColor).
Bold(true)
accentStyle = lipgloss.NewStyle().
Foreground(accentColor).
Bold(true)
)
// Input styles
@@ -82,11 +86,11 @@ var (
// Container styles
var (
// wizardBoxStyle creates a bordered modal box
wizardBoxStyle = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(accentColor).
Padding(1, 2).
Width(60)
Padding(1, 2)
)
// Helper functions for rendering
@@ -166,46 +170,17 @@ func renderTextInput(label, value string, valid bool) string {
}
// overlayContent overlays modal content centered on the base view
func overlayContent(base, modal string, termWidth, termHeight int) string {
baseLines := strings.Split(base, "\n")
modalLines := strings.Split(modal, "\n")
// Ensure base has enough lines
for len(baseLines) < termHeight {
baseLines = append(baseLines, "")
}
modalHeight := len(modalLines)
modalWidth := 0
for _, line := range modalLines {
w := lipgloss.Width(line)
if w > modalWidth {
modalWidth = w
}
}
// Calculate center position
startRow := (termHeight - modalHeight) / 2
if startRow < 0 {
startRow = 0
}
// Create result with modal overlaid
result := make([]string, len(baseLines))
copy(result, baseLines)
for i, modalLine := range modalLines {
row := startRow + i
if row >= 0 && row < len(result) {
// Center the modal line
padding := (termWidth - lipgloss.Width(modalLine)) / 2
if padding < 0 {
padding = 0
}
result[row] = strings.Repeat(" ", padding) + modalLine
}
}
return strings.Join(result, "\n")
// Note: base parameter is kept for API compatibility but not used since
// lipgloss.Place provides cleaner centering without background artifacts
func overlayContent(_, modal string, termWidth, termHeight int) string {
// Use lipgloss.Place to center the modal in the terminal viewport
// This handles all alignment properly and respects ANSI styling
return lipgloss.Place(
termWidth,
termHeight,
lipgloss.Center,
lipgloss.Center,
modal,
lipgloss.WithWhitespaceChars(" "),
)
}
+404 -4
View File
@@ -325,11 +325,13 @@ func (m model) renderEnterRemotePort() string {
if wizard.selector != "" {
resourceInfo = fmt.Sprintf("%s [%s]", wizard.resourceValue, wizard.selector)
}
b.WriteString(mutedStyle.Render(fmt.Sprintf("Resource: %s\n\n", resourceInfo)))
b.WriteString(mutedStyle.Render(fmt.Sprintf("Resource: %s", resourceInfo)))
b.WriteString("\n\n")
// If we have detected ports and in list mode, show them as a list
if len(wizard.detectedPorts) > 0 && wizard.inputMode == InputModeList {
b.WriteString("Select remote port:\n\n")
b.WriteString("Select remote port:")
b.WriteString("\n\n")
const viewportHeight = 20
totalItems := len(wizard.detectedPorts) + 1 // +1 for manual entry option
@@ -446,8 +448,10 @@ func (m model) renderEnterLocalPort() string {
if wizard.selector != "" {
resourceInfo = fmt.Sprintf("%s [%s]", wizard.resourceValue, wizard.selector)
}
b.WriteString(mutedStyle.Render(fmt.Sprintf("Resource: %s\n", resourceInfo)))
b.WriteString(mutedStyle.Render(fmt.Sprintf("Remote port: %d\n\n", wizard.remotePort)))
b.WriteString(mutedStyle.Render(fmt.Sprintf("Resource: %s", resourceInfo)))
b.WriteString("\n")
b.WriteString(mutedStyle.Render(fmt.Sprintf("Remote port: %d", wizard.remotePort)))
b.WriteString("\n\n")
b.WriteString(renderTextInput("Local port: ", wizard.textInput, wizard.error == nil))
b.WriteString("\n\n")
@@ -670,3 +674,399 @@ func (m model) renderRemoveConfirmation() string {
return b.String()
}
// renderBenchmark renders the benchmark wizard
func (m model) renderBenchmark() string {
if m.ui.benchmarkState == nil {
return ""
}
state := m.ui.benchmarkState
var content string
switch state.step {
case BenchmarkStepConfig:
content = m.renderBenchmarkConfig()
case BenchmarkStepRunning:
content = m.renderBenchmarkRunning()
case BenchmarkStepResults:
content = m.renderBenchmarkResults()
default:
content = "Unknown step"
}
return wizardBoxStyle.Render(content)
}
func (m model) renderBenchmarkConfig() string {
state := m.ui.benchmarkState
var b strings.Builder
b.WriteString(renderHeader("HTTP Benchmark", ""))
b.WriteString(fmt.Sprintf("Target: %s (localhost:%d)", breadcrumbStyle.Render(state.forwardAlias), state.localPort))
b.WriteString("\n\n")
b.WriteString("Configure benchmark parameters:")
b.WriteString("\n\n")
fields := []struct {
label string
value string
}{
{"URL Path", state.urlPath},
{"Method", state.method},
{"Concurrency", fmt.Sprintf("%d", state.concurrency)},
{"Requests", fmt.Sprintf("%d", state.requests)},
}
for i, field := range fields {
prefix := " "
if i == state.cursor {
prefix = "▸ "
b.WriteString(selectedStyle.Render(fmt.Sprintf("%s%-12s", prefix, field.label+":")))
b.WriteString(validInputStyle.Render(field.value + "█"))
} else {
b.WriteString(fmt.Sprintf("%s%-12s %s", prefix, field.label+":", field.value))
}
b.WriteString("\n")
}
b.WriteString("\n")
b.WriteString(mutedStyle.Render(fmt.Sprintf("Will send %d requests with %d concurrent workers", state.requests, state.concurrency)))
b.WriteString("\n\n")
b.WriteString(helpStyle.Render("↑/↓/Tab: Navigate Type to edit Enter: Run Esc: Cancel"))
return b.String()
}
func (m model) renderBenchmarkRunning() string {
state := m.ui.benchmarkState
var b strings.Builder
b.WriteString(renderHeader("HTTP Benchmark", ""))
b.WriteString(fmt.Sprintf("Target: %s", breadcrumbStyle.Render(state.forwardAlias)))
b.WriteString("\n\n")
// Progress bar
progress := float64(state.progress) / float64(state.total)
if state.total == 0 {
progress = 0
}
barWidth := 30
filled := int(progress * float64(barWidth))
if filled > barWidth {
filled = barWidth
}
bar := strings.Repeat("█", filled) + strings.Repeat("░", barWidth-filled)
percent := int(progress * 100)
b.WriteString(spinnerStyle.Render("Running benchmark..."))
b.WriteString("\n\n")
b.WriteString(fmt.Sprintf(" [%s] %d%%", successStyle.Render(bar), percent))
b.WriteString("\n")
b.WriteString(mutedStyle.Render(fmt.Sprintf(" %d / %d requests completed", state.progress, state.total)))
b.WriteString("\n\n")
b.WriteString(mutedStyle.Render(fmt.Sprintf("URL: http://localhost:%d%s", state.localPort, state.urlPath)))
b.WriteString("\n")
b.WriteString(mutedStyle.Render(fmt.Sprintf("Method: %s Concurrency: %d", state.method, state.concurrency)))
b.WriteString("\n\n")
b.WriteString(helpStyle.Render("Please wait..."))
return b.String()
}
func (m model) renderBenchmarkResults() string {
state := m.ui.benchmarkState
var b strings.Builder
b.WriteString(renderHeader("Benchmark Results", ""))
b.WriteString(fmt.Sprintf("Target: %s", breadcrumbStyle.Render(state.forwardAlias)))
b.WriteString("\n\n")
if state.error != nil {
b.WriteString(errorStyle.Render(fmt.Sprintf("✗ Error: %v", state.error)))
b.WriteString("\n\n")
b.WriteString(helpStyle.Render("Press Enter or Esc to return"))
return b.String()
}
if state.results == nil {
b.WriteString(mutedStyle.Render("No results available"))
b.WriteString("\n\n")
b.WriteString(helpStyle.Render("Press Enter or Esc to return"))
return b.String()
}
r := state.results
// Summary
successRate := float64(r.Successful) / float64(r.TotalRequests) * 100
if r.TotalRequests == 0 {
successRate = 0
}
b.WriteString(fmt.Sprintf("Total Requests: %d", r.TotalRequests))
b.WriteString("\n")
if r.Failed == 0 {
b.WriteString(successStyle.Render(fmt.Sprintf("Successful: %d (%.1f%%)", r.Successful, successRate)))
} else {
b.WriteString(fmt.Sprintf("Successful: %d (%.1f%%)", r.Successful, successRate))
}
b.WriteString("\n")
if r.Failed > 0 {
b.WriteString(errorStyle.Render(fmt.Sprintf("Failed: %d", r.Failed)))
} else {
b.WriteString(fmt.Sprintf("Failed: %d", r.Failed))
}
b.WriteString("\n\n")
// Latency stats
b.WriteString(breadcrumbStyle.Render("Latency (ms)"))
b.WriteString("\n")
b.WriteString(fmt.Sprintf(" Min: %.2f", r.MinLatency))
b.WriteString("\n")
b.WriteString(fmt.Sprintf(" Max: %.2f", r.MaxLatency))
b.WriteString("\n")
b.WriteString(fmt.Sprintf(" Avg: %.2f", r.AvgLatency))
b.WriteString("\n")
b.WriteString(fmt.Sprintf(" P50: %.2f", r.P50Latency))
b.WriteString("\n")
b.WriteString(fmt.Sprintf(" P95: %.2f", r.P95Latency))
b.WriteString("\n")
b.WriteString(fmt.Sprintf(" P99: %.2f", r.P99Latency))
b.WriteString("\n\n")
// Throughput
b.WriteString(breadcrumbStyle.Render("Throughput"))
b.WriteString("\n")
b.WriteString(fmt.Sprintf(" Requests/sec: %.2f", r.Throughput))
b.WriteString("\n")
b.WriteString(fmt.Sprintf(" Bytes read: %d", r.BytesRead))
b.WriteString("\n")
// Status codes if interesting
if len(r.StatusCodes) > 0 {
b.WriteString("\n")
b.WriteString(breadcrumbStyle.Render("Status Codes"))
b.WriteString("\n")
for code, count := range r.StatusCodes {
if code >= 200 && code < 300 {
b.WriteString(successStyle.Render(fmt.Sprintf(" %d: %d", code, count)))
} else if code >= 400 {
b.WriteString(errorStyle.Render(fmt.Sprintf(" %d: %d", code, count)))
} else {
b.WriteString(fmt.Sprintf(" %d: %d", code, count))
}
b.WriteString("\n")
}
}
b.WriteString("\n")
b.WriteString(helpStyle.Render("Press Enter or Esc to return"))
return b.String()
}
// renderHTTPLog renders the HTTP log viewer as a full-screen table
func (m model) renderHTTPLog() string {
if m.ui.httpLogState == nil {
return ""
}
state := m.ui.httpLogState
// Get terminal dimensions
termWidth := m.termWidth
termHeight := m.termHeight
if termWidth == 0 {
termWidth = 120
}
if termHeight == 0 {
termHeight = 40
}
// Get filtered entries
filteredEntries := state.getFilteredEntries()
totalEntries := len(filteredEntries)
totalUnfiltered := len(state.entries)
// Build output
var b strings.Builder
// Header line
title := wizardHeaderStyle.Render("HTTP Traffic Log")
b.WriteString(title)
b.WriteString(" ")
b.WriteString(breadcrumbStyle.Render(state.forwardAlias))
// Status indicators
b.WriteString(" ")
filterLabel := state.getFilterModeLabel()
if state.filterMode != HTTPLogFilterNone {
b.WriteString(accentStyle.Render(fmt.Sprintf("[Filter: %s]", filterLabel)))
}
if state.filterText != "" {
b.WriteString(" ")
b.WriteString(accentStyle.Render(fmt.Sprintf("[Search: \"%s\"]", state.filterText)))
}
if state.autoScroll {
b.WriteString(" ")
b.WriteString(successStyle.Render("[Auto-scroll]"))
}
b.WriteString("\n")
// Filter input line (if active)
if state.filterActive {
b.WriteString(accentStyle.Render("Search: "))
b.WriteString(state.filterText)
b.WriteString(accentStyle.Render("_"))
b.WriteString("\n")
}
// Table or empty message
if totalEntries == 0 {
b.WriteString("\n")
if totalUnfiltered == 0 {
b.WriteString(mutedStyle.Render(" No HTTP traffic logged yet.\n"))
b.WriteString(mutedStyle.Render(" Enable with: httpLog: true in .kportal.yaml\n"))
} else {
b.WriteString(mutedStyle.Render(fmt.Sprintf(" No entries match filter. (%d total entries)\n", totalUnfiltered)))
b.WriteString(mutedStyle.Render(" Press 'c' to clear filters.\n"))
}
// Pad to fill screen
for i := 0; i < termHeight-10; i++ {
b.WriteString("\n")
}
} else {
// Render simple table without lipgloss table (for better control)
b.WriteString("\n")
// Header
header := fmt.Sprintf(" %-10s %-7s %-6s %-8s %s",
"TIME", "METHOD", "STATUS", "LATENCY", "PATH")
b.WriteString(mutedStyle.Render(header))
b.WriteString("\n")
b.WriteString(mutedStyle.Render(strings.Repeat("─", termWidth-2)))
b.WriteString("\n")
// Calculate visible range
viewportHeight := termHeight - 8 // header, filter bar, table header, separator, footer, help
if viewportHeight < 5 {
viewportHeight = 5
}
// Ensure cursor is in valid range
if state.cursor < 0 {
state.cursor = 0
}
if state.cursor >= totalEntries {
state.cursor = totalEntries - 1
}
// Calculate scroll offset to keep cursor visible
if state.cursor < state.scrollOffset {
state.scrollOffset = state.cursor
}
if state.cursor >= state.scrollOffset+viewportHeight {
state.scrollOffset = state.cursor - viewportHeight + 1
}
if state.scrollOffset < 0 {
state.scrollOffset = 0
}
start := state.scrollOffset
end := start + viewportHeight
if end > totalEntries {
end = totalEntries
}
// Calculate max path width
maxPathWidth := termWidth - 48
if maxPathWidth < 10 {
maxPathWidth = 10
}
for i := start; i < end; i++ {
entry := filteredEntries[i]
// Format fields
statusStr := ""
if entry.StatusCode > 0 {
statusStr = fmt.Sprintf("%d", entry.StatusCode)
}
latencyStr := ""
if entry.LatencyMs > 0 {
if entry.LatencyMs >= 1000 {
latencyStr = fmt.Sprintf("%.1fs", float64(entry.LatencyMs)/1000)
} else {
latencyStr = fmt.Sprintf("%dms", entry.LatencyMs)
}
}
// Truncate path
path := entry.Path
if len(path) > maxPathWidth {
path = path[:maxPathWidth-3] + "..."
}
// Build line
line := fmt.Sprintf("%-10s %-7s %-6s %-8s %s",
entry.Timestamp,
entry.Method,
statusStr,
latencyStr,
path)
// Selection prefix
prefix := " "
if i == state.cursor {
prefix = "▸ "
}
// Apply color based on status
// 200s = normal text, 400s = warning (orange), 500s = error (red)
var styledLine string
if entry.StatusCode >= 500 {
styledLine = errorStyle.Render(line)
} else if entry.StatusCode >= 400 {
styledLine = warningStyle.Render(line)
} else {
// 200s and other codes - normal text color
styledLine = line
}
if i == state.cursor {
b.WriteString(selectedStyle.Render(prefix))
} else {
b.WriteString(prefix)
}
b.WriteString(styledLine)
b.WriteString("\n")
}
// Pad remaining lines
linesRendered := end - start
for i := linesRendered; i < viewportHeight; i++ {
b.WriteString("\n")
}
}
// Footer with entry count
b.WriteString("\n")
if totalEntries != totalUnfiltered {
b.WriteString(mutedStyle.Render(fmt.Sprintf(" %d of %d entries (filtered from %d)", totalEntries, totalEntries, totalUnfiltered)))
} else {
b.WriteString(mutedStyle.Render(fmt.Sprintf(" %d entries", totalEntries)))
}
b.WriteString("\n")
// Help line at bottom
b.WriteString(helpStyle.Render(" ↑/↓/PgUp/PgDn: Navigate g/G: Top/Bottom a: Auto-scroll f: Filter /: Search c: Clear q: Close"))
return b.String()
}