mirror of
https://github.com/lukaszraczylo/kportal.git
synced 2026-06-11 00:09:31 +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
1569 lines
45 KiB
Go
1569 lines
45 KiB
Go
package ui
|
|
|
|
import (
|
|
"bytes"
|
|
"compress/flate"
|
|
"compress/gzip"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// renderAddWizard renders the appropriate step of the add wizard
|
|
func (m model) renderAddWizard() string {
|
|
if m.ui.addWizard == nil {
|
|
return ""
|
|
}
|
|
|
|
wizard := m.ui.addWizard
|
|
|
|
var content string
|
|
switch wizard.step {
|
|
case StepSelectContext:
|
|
content = m.renderSelectContext()
|
|
case StepSelectNamespace:
|
|
content = m.renderSelectNamespace()
|
|
case StepSelectResourceType:
|
|
content = m.renderSelectResourceType()
|
|
case StepEnterResource:
|
|
content = m.renderEnterResource()
|
|
case StepEnterRemotePort:
|
|
content = m.renderEnterRemotePort()
|
|
case StepEnterLocalPort:
|
|
content = m.renderEnterLocalPort()
|
|
case StepConfirmation:
|
|
content = m.renderConfirmation()
|
|
case StepSuccess:
|
|
content = m.renderSuccess()
|
|
default:
|
|
content = "Unknown step"
|
|
}
|
|
|
|
return wizardBoxStyle.Render(content)
|
|
}
|
|
|
|
func (m model) renderSelectContext() string {
|
|
wizard := m.ui.addWizard
|
|
var b strings.Builder
|
|
|
|
b.WriteString(renderHeader("Add Port Forward", renderProgress(1, 7)))
|
|
b.WriteString("Select Kubernetes Context:\n\n")
|
|
|
|
// Show search input if there's a filter active
|
|
if wizard.searchFilter != "" {
|
|
b.WriteString(renderTextInput("Filter: ", wizard.searchFilter, true))
|
|
b.WriteString("\n\n")
|
|
}
|
|
|
|
if wizard.loading {
|
|
b.WriteString(spinnerStyle.Render("⣾ Loading contexts..."))
|
|
} else if wizard.error != nil {
|
|
b.WriteString(errorStyle.Render(fmt.Sprintf("✗ Error: %v", wizard.error)))
|
|
} else if len(wizard.contexts) == 0 {
|
|
b.WriteString(mutedStyle.Render("No contexts found in kubeconfig"))
|
|
} else {
|
|
filteredContexts := wizard.getFilteredContexts()
|
|
if len(filteredContexts) == 0 {
|
|
b.WriteString(mutedStyle.Render("No matching contexts"))
|
|
} else {
|
|
const viewportHeight = 20
|
|
totalItems := len(filteredContexts)
|
|
|
|
// Show scroll up indicator if there are items above the viewport
|
|
if wizard.scrollOffset > 0 {
|
|
b.WriteString(mutedStyle.Render(" ↑ More above ↑") + "\n")
|
|
}
|
|
|
|
// Calculate visible range
|
|
start := wizard.scrollOffset
|
|
end := wizard.scrollOffset + viewportHeight
|
|
if end > totalItems {
|
|
end = totalItems
|
|
}
|
|
|
|
// Render visible contexts with (current) marker on first one (only if not filtered)
|
|
for i := start; i < end; i++ {
|
|
prefix := " "
|
|
text := filteredContexts[i]
|
|
// Only show (current) marker if no filter and this is the first item in original list
|
|
if wizard.searchFilter == "" && i == 0 {
|
|
text += mutedStyle.Render(" (current)")
|
|
}
|
|
|
|
if i == wizard.cursor {
|
|
prefix = "▸ "
|
|
b.WriteString(selectedStyle.Render(prefix + text))
|
|
} else {
|
|
b.WriteString(prefix + text)
|
|
}
|
|
b.WriteString("\n")
|
|
}
|
|
|
|
// Show scroll down indicator if there are items below the viewport
|
|
if end < totalItems {
|
|
b.WriteString(mutedStyle.Render(" ↓ More below ↓") + "\n")
|
|
}
|
|
}
|
|
}
|
|
|
|
b.WriteString("\n")
|
|
helpWidth := wizardHelpWidth(m.termWidth)
|
|
if wizard.searchFilter != "" {
|
|
b.WriteString(wrapHelpText(fmt.Sprintf("↑/↓: Navigate Enter: Select Backspace: Clear filter (%d/%d) Esc: Cancel", len(wizard.getFilteredContexts()), len(wizard.contexts)), helpWidth))
|
|
} else {
|
|
b.WriteString(wrapHelpText("Type to filter ↑/↓: Navigate Enter: Select Esc/Ctrl+C: Cancel", helpWidth))
|
|
}
|
|
|
|
return b.String()
|
|
}
|
|
|
|
func (m model) renderSelectNamespace() string {
|
|
wizard := m.ui.addWizard
|
|
var b strings.Builder
|
|
|
|
b.WriteString(renderHeader("Add Port Forward", renderProgress(2, 7)))
|
|
b.WriteString(fmt.Sprintf("Context: %s\n\n", breadcrumbStyle.Render(wizard.selectedContext)))
|
|
|
|
b.WriteString("Select Namespace:\n\n")
|
|
|
|
// Show search input if there's a filter active
|
|
if wizard.searchFilter != "" {
|
|
b.WriteString(renderTextInput("Filter: ", wizard.searchFilter, true))
|
|
b.WriteString("\n\n")
|
|
}
|
|
|
|
if wizard.loading {
|
|
b.WriteString(spinnerStyle.Render("⣾ Loading namespaces..."))
|
|
} else if wizard.error != nil {
|
|
b.WriteString(errorStyle.Render(fmt.Sprintf("✗ Error: %v\n", wizard.error)))
|
|
b.WriteString(mutedStyle.Render("\nCluster may be unreachable. Check context."))
|
|
} else if len(wizard.namespaces) == 0 {
|
|
b.WriteString(mutedStyle.Render("No namespaces found"))
|
|
} else {
|
|
filteredNamespaces := wizard.getFilteredNamespaces()
|
|
if len(filteredNamespaces) == 0 {
|
|
b.WriteString(mutedStyle.Render("No matching namespaces"))
|
|
} else {
|
|
b.WriteString(renderList(filteredNamespaces, wizard.cursor, " ", wizard.scrollOffset))
|
|
}
|
|
}
|
|
|
|
b.WriteString("\n")
|
|
helpWidth := wizardHelpWidth(m.termWidth)
|
|
if wizard.searchFilter != "" {
|
|
b.WriteString(wrapHelpText(fmt.Sprintf("↑/↓: Navigate Enter: Select Backspace: Clear filter (%d/%d) Esc: Back", len(wizard.getFilteredNamespaces()), len(wizard.namespaces)), helpWidth))
|
|
} else {
|
|
b.WriteString(wrapHelpText("Type to filter ↑/↓: Navigate Enter: Select Esc: Back Ctrl+C: Cancel", helpWidth))
|
|
}
|
|
|
|
return b.String()
|
|
}
|
|
|
|
func (m model) renderSelectResourceType() string {
|
|
wizard := m.ui.addWizard
|
|
var b strings.Builder
|
|
|
|
b.WriteString(renderHeader("Add Port Forward", renderProgress(3, 7)))
|
|
b.WriteString(renderBreadcrumb(wizard.selectedContext, wizard.selectedNamespace))
|
|
b.WriteString("\n\n")
|
|
|
|
b.WriteString("Select Resource Type:\n\n")
|
|
|
|
resourceTypes := []ResourceType{
|
|
ResourceTypePodPrefix,
|
|
ResourceTypePodSelector,
|
|
ResourceTypeService,
|
|
}
|
|
|
|
for i, rt := range resourceTypes {
|
|
prefix := " "
|
|
if i == wizard.cursor {
|
|
prefix = "▸ "
|
|
b.WriteString(selectedStyle.Render(prefix + rt.String()))
|
|
b.WriteString("\n")
|
|
b.WriteString(mutedStyle.Render(" " + rt.Description()))
|
|
} else {
|
|
b.WriteString(prefix + rt.String())
|
|
}
|
|
b.WriteString("\n")
|
|
if i < len(resourceTypes)-1 {
|
|
b.WriteString("\n")
|
|
}
|
|
}
|
|
|
|
b.WriteString("\n")
|
|
b.WriteString(wrapHelpText("↑/↓: Navigate Enter: Select Esc: Back Ctrl+C: Cancel", wizardHelpWidth(m.termWidth)))
|
|
|
|
return b.String()
|
|
}
|
|
|
|
func (m model) renderEnterResource() string {
|
|
wizard := m.ui.addWizard
|
|
var b strings.Builder
|
|
|
|
b.WriteString(renderHeader("Add Port Forward", renderProgress(4, 7)))
|
|
b.WriteString(renderBreadcrumb(wizard.selectedContext, wizard.selectedNamespace))
|
|
b.WriteString("\n\n")
|
|
|
|
switch wizard.selectedResourceType {
|
|
case ResourceTypePodPrefix:
|
|
b.WriteString("Enter pod name prefix:\n\n")
|
|
|
|
// Show running pods for reference
|
|
if wizard.loading {
|
|
b.WriteString(spinnerStyle.Render("⣾ Loading pods..."))
|
|
} else if len(wizard.pods) > 0 {
|
|
b.WriteString(mutedStyle.Render("Running pods:\n"))
|
|
showCount := 0
|
|
for _, pod := range wizard.pods {
|
|
if strings.HasPrefix(pod.Name, wizard.textInput) || wizard.textInput == "" {
|
|
if showCount < 5 { // Limit to 5 pods
|
|
b.WriteString(mutedStyle.Render(fmt.Sprintf(" • %s\n", pod.Name)))
|
|
showCount++
|
|
}
|
|
}
|
|
}
|
|
if showCount == 0 && wizard.textInput != "" {
|
|
b.WriteString(mutedStyle.Render(" (no matching pods)\n"))
|
|
} else if len(wizard.pods) > showCount {
|
|
b.WriteString(mutedStyle.Render(fmt.Sprintf(" ... and %d more\n", len(wizard.pods)-showCount)))
|
|
}
|
|
b.WriteString("\n")
|
|
}
|
|
|
|
// Text input
|
|
b.WriteString(renderTextInput("Prefix: ", wizard.textInput, true))
|
|
b.WriteString("\n\n")
|
|
|
|
// Show match count
|
|
if wizard.textInput != "" {
|
|
matchCount := 0
|
|
for _, pod := range wizard.pods {
|
|
if strings.HasPrefix(pod.Name, wizard.textInput) {
|
|
matchCount++
|
|
}
|
|
}
|
|
|
|
if matchCount > 0 {
|
|
b.WriteString(successStyle.Render(fmt.Sprintf("✓ Matches %d pod(s)", matchCount)))
|
|
} else {
|
|
b.WriteString(warningStyle.Render("⚠ No matching pods (you can still proceed)"))
|
|
}
|
|
}
|
|
|
|
case ResourceTypePodSelector:
|
|
b.WriteString("Enter label selector:\n")
|
|
b.WriteString(mutedStyle.Render("Format: key=value,key2=value2\n\n"))
|
|
|
|
b.WriteString(renderTextInput("Selector: ", wizard.textInput, true))
|
|
b.WriteString("\n\n")
|
|
|
|
if wizard.loading {
|
|
b.WriteString(spinnerStyle.Render("⣾ Validating selector..."))
|
|
} else if len(wizard.matchingPods) > 0 {
|
|
b.WriteString(successStyle.Render(fmt.Sprintf("✓ Found %d matching pod(s):\n", len(wizard.matchingPods))))
|
|
showCount := 0
|
|
for _, pod := range wizard.matchingPods {
|
|
if showCount < 3 {
|
|
b.WriteString(mutedStyle.Render(fmt.Sprintf(" • %s\n", pod.Name)))
|
|
showCount++
|
|
}
|
|
}
|
|
if len(wizard.matchingPods) > 3 {
|
|
b.WriteString(mutedStyle.Render(fmt.Sprintf(" ... and %d more\n", len(wizard.matchingPods)-3)))
|
|
}
|
|
} else if wizard.error != nil {
|
|
b.WriteString(errorStyle.Render(fmt.Sprintf("✗ Invalid selector: %v", wizard.error)))
|
|
}
|
|
|
|
case ResourceTypeService:
|
|
b.WriteString("Select service:\n\n")
|
|
|
|
// Show search input if there's a filter active
|
|
if wizard.searchFilter != "" {
|
|
b.WriteString(renderTextInput("Filter: ", wizard.searchFilter, true))
|
|
b.WriteString("\n\n")
|
|
}
|
|
|
|
if wizard.loading {
|
|
b.WriteString(spinnerStyle.Render("⣾ Loading services..."))
|
|
} else if len(wizard.services) == 0 {
|
|
b.WriteString(mutedStyle.Render("No services found"))
|
|
} else {
|
|
filteredServices := wizard.getFilteredServices()
|
|
if len(filteredServices) == 0 {
|
|
b.WriteString(mutedStyle.Render("No matching services"))
|
|
} else {
|
|
serviceNames := make([]string, len(filteredServices))
|
|
for i, svc := range filteredServices {
|
|
serviceNames[i] = svc.Name
|
|
}
|
|
b.WriteString(renderList(serviceNames, wizard.cursor, " ", wizard.scrollOffset))
|
|
}
|
|
}
|
|
}
|
|
|
|
b.WriteString("\n")
|
|
// Show appropriate help text based on resource type and filter state
|
|
helpWidth := wizardHelpWidth(m.termWidth)
|
|
if wizard.selectedResourceType == ResourceTypeService {
|
|
if wizard.searchFilter != "" {
|
|
b.WriteString(wrapHelpText(fmt.Sprintf("↑/↓: Navigate Enter: Select Backspace: Clear filter (%d/%d) Esc: Back", len(wizard.getFilteredServices()), len(wizard.services)), helpWidth))
|
|
} else {
|
|
b.WriteString(wrapHelpText("Type to filter ↑/↓: Navigate Enter: Select Esc: Back Ctrl+C: Cancel", helpWidth))
|
|
}
|
|
} else {
|
|
b.WriteString(wrapHelpText("Enter: Continue Esc: Back Ctrl+C: Cancel", helpWidth))
|
|
}
|
|
|
|
return b.String()
|
|
}
|
|
|
|
func (m model) renderEnterRemotePort() string {
|
|
wizard := m.ui.addWizard
|
|
var b strings.Builder
|
|
|
|
b.WriteString(renderHeader("Add Port Forward", renderProgress(5, 7)))
|
|
b.WriteString(renderBreadcrumb(wizard.selectedContext, wizard.selectedNamespace))
|
|
b.WriteString("\n")
|
|
|
|
// Show resource selection
|
|
resourceInfo := wizard.resourceValue
|
|
if wizard.selector != "" {
|
|
resourceInfo = fmt.Sprintf("%s [%s]", wizard.resourceValue, wizard.selector)
|
|
}
|
|
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:")
|
|
b.WriteString("\n\n")
|
|
|
|
const viewportHeight = 20
|
|
totalItems := len(wizard.detectedPorts) + 1 // +1 for manual entry option
|
|
|
|
// Show scroll up indicator if there are items above the viewport
|
|
if wizard.scrollOffset > 0 {
|
|
b.WriteString(mutedStyle.Render(" ↑ More above ↑") + "\n")
|
|
}
|
|
|
|
// Calculate visible range
|
|
start := wizard.scrollOffset
|
|
end := wizard.scrollOffset + viewportHeight
|
|
if end > totalItems {
|
|
end = totalItems
|
|
}
|
|
|
|
// Render detected ports within viewport
|
|
for i := start; i < end && i < len(wizard.detectedPorts); i++ {
|
|
port := wizard.detectedPorts[i]
|
|
// For services, show both service port and target port if they differ
|
|
var portDesc string
|
|
if port.TargetPort > 0 && port.TargetPort != port.Port {
|
|
// Service with different target port: "80 → 8000 (http)"
|
|
portDesc = fmt.Sprintf("%d → %d", port.Port, port.TargetPort)
|
|
if port.Name != "" {
|
|
portDesc += fmt.Sprintf(" (%s)", port.Name)
|
|
}
|
|
} else {
|
|
// Pod port or service with same port
|
|
portDesc = fmt.Sprintf("%d", port.Port)
|
|
if port.Name != "" {
|
|
portDesc += fmt.Sprintf(" (%s)", port.Name)
|
|
}
|
|
}
|
|
|
|
prefix := " "
|
|
if i == wizard.cursor {
|
|
prefix = "▸ "
|
|
b.WriteString(selectedStyle.Render(prefix + portDesc))
|
|
} else {
|
|
b.WriteString(prefix + portDesc)
|
|
}
|
|
b.WriteString("\n")
|
|
}
|
|
|
|
// Add "Manual entry" option if within viewport
|
|
manualIdx := len(wizard.detectedPorts)
|
|
if manualIdx >= start && manualIdx < end {
|
|
manualOption := "Manual entry (type port number)"
|
|
prefix := " "
|
|
if wizard.cursor == manualIdx {
|
|
prefix = "▸ "
|
|
b.WriteString(selectedStyle.Render(prefix + manualOption))
|
|
} else {
|
|
b.WriteString(prefix + mutedStyle.Render(manualOption))
|
|
}
|
|
b.WriteString("\n")
|
|
}
|
|
|
|
// Show scroll down indicator if there are items below the viewport
|
|
if end < totalItems {
|
|
b.WriteString(mutedStyle.Render(" ↓ More below ↓") + "\n")
|
|
}
|
|
|
|
b.WriteString("\n")
|
|
b.WriteString(wrapHelpText("↑/↓: Navigate Enter: Select Esc: Back Ctrl+C: Cancel", wizardHelpWidth(m.termWidth)))
|
|
} else {
|
|
// Text input mode (no detected ports or user chose manual entry)
|
|
if len(wizard.detectedPorts) > 0 {
|
|
b.WriteString(mutedStyle.Render("Detected ports:\n"))
|
|
for _, port := range wizard.detectedPorts {
|
|
var portDesc string
|
|
if port.TargetPort > 0 && port.TargetPort != port.Port {
|
|
portDesc = fmt.Sprintf("%d → %d", port.Port, port.TargetPort)
|
|
if port.Name != "" {
|
|
portDesc += fmt.Sprintf(" (%s)", port.Name)
|
|
}
|
|
} else {
|
|
portDesc = fmt.Sprintf("%d", port.Port)
|
|
if port.Name != "" {
|
|
portDesc += fmt.Sprintf(" (%s)", port.Name)
|
|
}
|
|
}
|
|
b.WriteString(mutedStyle.Render(fmt.Sprintf(" • %s\n", portDesc)))
|
|
}
|
|
b.WriteString("\n")
|
|
}
|
|
|
|
b.WriteString(renderTextInput("Remote port: ", wizard.textInput, wizard.error == nil))
|
|
b.WriteString("\n\n")
|
|
|
|
if wizard.error != nil {
|
|
b.WriteString(errorStyle.Render(fmt.Sprintf("✗ %v", wizard.error)))
|
|
} else if wizard.textInput != "" {
|
|
b.WriteString(mutedStyle.Render("Press Enter to continue"))
|
|
}
|
|
|
|
b.WriteString("\n")
|
|
b.WriteString(wrapHelpText("Enter: Continue Esc: Back Ctrl+C: Cancel", wizardHelpWidth(m.termWidth)))
|
|
}
|
|
|
|
return b.String()
|
|
}
|
|
|
|
func (m model) renderEnterLocalPort() string {
|
|
wizard := m.ui.addWizard
|
|
var b strings.Builder
|
|
|
|
b.WriteString(renderHeader("Add Port Forward", renderProgress(6, 7)))
|
|
b.WriteString(renderBreadcrumb(wizard.selectedContext, wizard.selectedNamespace))
|
|
b.WriteString("\n")
|
|
|
|
resourceInfo := wizard.resourceValue
|
|
if wizard.selector != "" {
|
|
resourceInfo = fmt.Sprintf("%s [%s]", wizard.resourceValue, wizard.selector)
|
|
}
|
|
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")
|
|
|
|
if wizard.loading {
|
|
b.WriteString(spinnerStyle.Render("⣾ Checking availability..."))
|
|
} else if wizard.error != nil {
|
|
b.WriteString(errorStyle.Render(fmt.Sprintf("✗ %v", wizard.error)))
|
|
} else if wizard.portCheckMsg != "" {
|
|
if wizard.portAvailable {
|
|
b.WriteString(successStyle.Render(wizard.portCheckMsg))
|
|
} else {
|
|
b.WriteString(errorStyle.Render(wizard.portCheckMsg))
|
|
}
|
|
} else if wizard.textInput != "" {
|
|
b.WriteString(mutedStyle.Render("Press Enter to check availability"))
|
|
}
|
|
|
|
b.WriteString("\n")
|
|
b.WriteString(wrapHelpText("Enter: Continue Esc: Back Ctrl+C: Cancel", wizardHelpWidth(m.termWidth)))
|
|
|
|
return b.String()
|
|
}
|
|
|
|
func (m model) renderConfirmation() string {
|
|
wizard := m.ui.addWizard
|
|
var b strings.Builder
|
|
|
|
b.WriteString(renderHeader("Add Port Forward", renderProgress(7, 7)))
|
|
b.WriteString("\n")
|
|
|
|
b.WriteString("Review Configuration:\n\n")
|
|
|
|
resourceInfo := wizard.resourceValue
|
|
if wizard.selector != "" {
|
|
resourceInfo = fmt.Sprintf("pod (selector: %s)", wizard.selector)
|
|
} else if wizard.selectedResourceType == ResourceTypePodPrefix {
|
|
resourceInfo = fmt.Sprintf("pod/%s", wizard.resourceValue)
|
|
} else if wizard.selectedResourceType == ResourceTypeService {
|
|
resourceInfo = fmt.Sprintf("service/%s", wizard.resourceValue)
|
|
}
|
|
|
|
b.WriteString(fmt.Sprintf(" Context: %s\n", wizard.selectedContext))
|
|
b.WriteString(fmt.Sprintf(" Namespace: %s\n", wizard.selectedNamespace))
|
|
b.WriteString(fmt.Sprintf(" Resource: %s\n", resourceInfo))
|
|
b.WriteString(fmt.Sprintf(" Remote Port: %d\n", wizard.remotePort))
|
|
b.WriteString(fmt.Sprintf(" Local Port: %d\n", wizard.localPort))
|
|
b.WriteString(" Protocol: tcp\n")
|
|
|
|
httpLogMark := "[ ] disabled"
|
|
if wizard.httpLog {
|
|
httpLogMark = "[x] enabled"
|
|
}
|
|
b.WriteString(fmt.Sprintf(" HTTP Log: %s\n", httpLogMark))
|
|
|
|
b.WriteString("\n")
|
|
|
|
// Show alias field with focus indicator
|
|
if wizard.confirmationFocus == FocusAlias {
|
|
b.WriteString(selectedStyle.Render("▸ Optional alias (friendly name):") + "\n")
|
|
b.WriteString(" Alias: " + validInputStyle.Render(wizard.textInput+"█") + "\n")
|
|
} else {
|
|
b.WriteString(mutedStyle.Render(" Optional alias (friendly name):") + "\n")
|
|
b.WriteString(mutedStyle.Render(" Alias: "+wizard.textInput) + "\n")
|
|
}
|
|
|
|
b.WriteString("\n")
|
|
|
|
// Show buttons with focus indicator
|
|
if wizard.confirmationFocus == FocusButtons {
|
|
if wizard.cursor == 0 {
|
|
b.WriteString(selectedStyle.Render("▸ Add to .kportal.yaml") + "\n")
|
|
b.WriteString(" Cancel\n")
|
|
} else {
|
|
b.WriteString(" Add to .kportal.yaml\n")
|
|
b.WriteString(selectedStyle.Render("▸ Cancel") + "\n")
|
|
}
|
|
} else {
|
|
b.WriteString(mutedStyle.Render(" Add to .kportal.yaml") + "\n")
|
|
b.WriteString(mutedStyle.Render(" Cancel") + "\n")
|
|
}
|
|
|
|
b.WriteString("\n")
|
|
b.WriteString(wrapHelpText("↑/↓/Tab: Navigate h: Toggle HTTP Log Enter: Confirm Esc: Back", wizardHelpWidth(m.termWidth)))
|
|
|
|
return b.String()
|
|
}
|
|
|
|
func (m model) renderSuccess() string {
|
|
wizard := m.ui.addWizard
|
|
var b strings.Builder
|
|
|
|
b.WriteString(successStyle.Render("Success! ✓"))
|
|
b.WriteString("\n\n")
|
|
|
|
if wizard.error != nil {
|
|
b.WriteString(errorStyle.Render(fmt.Sprintf("Error: %v", wizard.error)))
|
|
} else {
|
|
b.WriteString("Added to .kportal.yaml\n\n")
|
|
|
|
forwardDesc := fmt.Sprintf("localhost:%d → %s:%d",
|
|
wizard.localPort,
|
|
wizard.resourceValue,
|
|
wizard.remotePort)
|
|
|
|
if wizard.alias != "" {
|
|
forwardDesc = fmt.Sprintf("%s (%s)", wizard.alias, forwardDesc)
|
|
}
|
|
|
|
b.WriteString(successStyle.Render(forwardDesc))
|
|
b.WriteString("\n\n")
|
|
b.WriteString(mutedStyle.Render("The port forward will be active shortly."))
|
|
}
|
|
|
|
b.WriteString("\n\n")
|
|
b.WriteString("Would you like to:\n")
|
|
|
|
if wizard.cursor == 0 {
|
|
b.WriteString(selectedStyle.Render("▸ Add another port forward") + "\n")
|
|
b.WriteString(" Return to main view\n")
|
|
} else {
|
|
b.WriteString(" Add another port forward\n")
|
|
b.WriteString(selectedStyle.Render("▸ Return to main view") + "\n")
|
|
}
|
|
|
|
b.WriteString("\n")
|
|
b.WriteString(wrapHelpText("↑/↓: Navigate Enter: Select", wizardHelpWidth(m.termWidth)))
|
|
|
|
return b.String()
|
|
}
|
|
|
|
// renderRemoveWizard renders the remove wizard
|
|
func (m model) renderRemoveWizard() string {
|
|
if m.ui.removeWizard == nil {
|
|
return ""
|
|
}
|
|
|
|
wizard := m.ui.removeWizard
|
|
|
|
var content string
|
|
if wizard.confirming {
|
|
content = m.renderRemoveConfirmation()
|
|
} else {
|
|
content = m.renderRemoveSelection()
|
|
}
|
|
|
|
return wizardBoxStyle.Render(content)
|
|
}
|
|
|
|
func (m model) renderRemoveSelection() string {
|
|
wizard := m.ui.removeWizard
|
|
var b strings.Builder
|
|
|
|
b.WriteString(renderHeader("Remove Port Forwards", ""))
|
|
b.WriteString("\n")
|
|
|
|
b.WriteString("Select forwards to remove (Space to toggle):\n\n")
|
|
|
|
for i, fwd := range wizard.forwards {
|
|
isSelected := i == wizard.cursor
|
|
isChecked := wizard.selected[i]
|
|
|
|
line1 := fmt.Sprintf("%s:%d→%d", fwd.Alias, fwd.Port, fwd.LocalPort)
|
|
line2 := fmt.Sprintf(" %s/%s/%s", fwd.Context, fwd.Namespace, fwd.Resource)
|
|
|
|
checkbox := "[ ] "
|
|
if isChecked {
|
|
checkbox = "[✓] "
|
|
}
|
|
|
|
fullLine := checkbox + line1
|
|
if isSelected {
|
|
b.WriteString(selectedStyle.Render(fullLine))
|
|
} else {
|
|
if isChecked {
|
|
b.WriteString(checkedBoxStyle.Render(checkbox) + line1)
|
|
} else {
|
|
b.WriteString(uncheckedBoxStyle.Render(checkbox) + line1)
|
|
}
|
|
}
|
|
|
|
b.WriteString("\n")
|
|
b.WriteString(mutedStyle.Render(line2))
|
|
b.WriteString("\n\n")
|
|
}
|
|
|
|
selectedCount := wizard.getSelectedCount()
|
|
b.WriteString(fmt.Sprintf("%d of %d selected\n\n", selectedCount, len(wizard.forwards)))
|
|
|
|
b.WriteString(wrapHelpText("Space: Toggle a: All n: None Enter: Remove Esc: Cancel", wizardHelpWidth(m.termWidth)))
|
|
|
|
return b.String()
|
|
}
|
|
|
|
func (m model) renderRemoveConfirmation() string {
|
|
wizard := m.ui.removeWizard
|
|
var b strings.Builder
|
|
|
|
b.WriteString(renderHeader("Confirm Removal", ""))
|
|
b.WriteString("\n")
|
|
|
|
selectedCount := wizard.getSelectedCount()
|
|
b.WriteString(fmt.Sprintf("Remove %d port forward(s)?\n\n", selectedCount))
|
|
|
|
selectedForwards := wizard.getSelectedForwards()
|
|
for _, fwd := range selectedForwards {
|
|
b.WriteString(errorStyle.Render(fmt.Sprintf(" • %s:%d→%d\n", fwd.Alias, fwd.Port, fwd.LocalPort)))
|
|
b.WriteString(mutedStyle.Render(fmt.Sprintf(" %s/%s/%s\n", fwd.Context, fwd.Namespace, fwd.Resource)))
|
|
}
|
|
|
|
b.WriteString("\n")
|
|
b.WriteString(warningStyle.Render("This action cannot be undone."))
|
|
b.WriteString("\n\n")
|
|
|
|
// Yes/No buttons
|
|
if wizard.confirmCursor == 0 {
|
|
b.WriteString(selectedStyle.Render("▸ Yes, remove them") + "\n")
|
|
b.WriteString(" Cancel\n")
|
|
} else {
|
|
b.WriteString(" Yes, remove them\n")
|
|
b.WriteString(selectedStyle.Render("▸ Cancel") + "\n")
|
|
}
|
|
|
|
b.WriteString("\n")
|
|
b.WriteString(wrapHelpText("↑/↓: Navigate Enter: Confirm Esc: Cancel", wizardHelpWidth(m.termWidth)))
|
|
|
|
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(wrapHelpText("↑/↓/Tab: Navigate Type to edit Enter: Run Esc: Cancel", wizardHelpWidth(m.termWidth)))
|
|
|
|
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(wrapHelpText("Please wait...", wizardHelpWidth(m.termWidth)))
|
|
|
|
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(wrapHelpText("Press Enter or Esc to return", wizardHelpWidth(m.termWidth)))
|
|
return b.String()
|
|
}
|
|
|
|
if state.results == nil {
|
|
b.WriteString(mutedStyle.Render("No results available"))
|
|
b.WriteString("\n\n")
|
|
b.WriteString(wrapHelpText("Press Enter or Esc to return", wizardHelpWidth(m.termWidth)))
|
|
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(wrapHelpText("Press Enter or Esc to return", wizardHelpWidth(m.termWidth)))
|
|
|
|
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)
|
|
|
|
// If showing detail view, render that instead
|
|
if state.showingDetail && state.cursor >= 0 && state.cursor < len(filteredEntries) {
|
|
return m.renderHTTPLogDetail(filteredEntries[state.cursor], termWidth, termHeight)
|
|
}
|
|
|
|
// 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 (wrap for smaller screens)
|
|
helpText := "↑/↓: Navigate Enter: Details a: Auto-scroll f: Filter /: Search c: Clear q: Close"
|
|
b.WriteString(" ")
|
|
b.WriteString(wrapHelpText(helpText, termWidth-4))
|
|
|
|
return b.String()
|
|
}
|
|
|
|
// renderHTTPLogDetail renders the detailed view of a single HTTP log entry
|
|
func (m model) renderHTTPLogDetail(entry HTTPLogEntry, termWidth, termHeight int) string {
|
|
var b strings.Builder
|
|
|
|
// Header
|
|
title := wizardHeaderStyle.Render("HTTP Request Detail")
|
|
b.WriteString(title)
|
|
b.WriteString("\n\n")
|
|
|
|
// Build content lines for scrolling
|
|
var lines []string
|
|
|
|
// Request summary
|
|
lines = append(lines, accentStyle.Render("─── Request ───────────────────────────────────────────"))
|
|
lines = append(lines, "")
|
|
lines = append(lines, fmt.Sprintf(" %s %s", successStyle.Render(entry.Method), entry.Path))
|
|
lines = append(lines, fmt.Sprintf(" Time: %s", entry.Timestamp))
|
|
lines = append(lines, "")
|
|
|
|
// Request headers (sorted alphabetically)
|
|
if len(entry.RequestHeaders) > 0 {
|
|
lines = append(lines, accentStyle.Render(" Request Headers:"))
|
|
headerKeys := make([]string, 0, len(entry.RequestHeaders))
|
|
for k := range entry.RequestHeaders {
|
|
headerKeys = append(headerKeys, k)
|
|
}
|
|
sort.Strings(headerKeys)
|
|
for _, k := range headerKeys {
|
|
v := entry.RequestHeaders[k]
|
|
// Truncate long header values
|
|
if len(v) > termWidth-20 {
|
|
v = v[:termWidth-23] + "..."
|
|
}
|
|
lines = append(lines, fmt.Sprintf(" %s: %s", mutedStyle.Render(k), v))
|
|
}
|
|
lines = append(lines, "")
|
|
}
|
|
|
|
// Request body
|
|
if entry.RequestBody != "" {
|
|
lines = append(lines, accentStyle.Render(" Request Body:"))
|
|
// Decompress if needed, then check if binary
|
|
reqBody := decompressContent(entry.RequestBody, entry.RequestHeaders)
|
|
if isBinaryContent(reqBody, entry.RequestHeaders) {
|
|
lines = append(lines, mutedStyle.Render(" [Binary data - not displayed]"))
|
|
if ct := entry.RequestHeaders["Content-Type"]; ct != "" {
|
|
lines = append(lines, mutedStyle.Render(fmt.Sprintf(" Content-Type: %s", ct)))
|
|
}
|
|
} else {
|
|
// Format JSON if applicable
|
|
reqBody = formatJSONContent(reqBody, entry.RequestHeaders)
|
|
bodyLines := strings.Split(reqBody, "\n")
|
|
for _, line := range bodyLines {
|
|
// Truncate long lines
|
|
if len(line) > termWidth-6 {
|
|
line = line[:termWidth-9] + "..."
|
|
}
|
|
lines = append(lines, " "+line)
|
|
}
|
|
}
|
|
lines = append(lines, "")
|
|
}
|
|
|
|
// Response summary
|
|
lines = append(lines, "")
|
|
lines = append(lines, accentStyle.Render("─── Response ──────────────────────────────────────────"))
|
|
lines = append(lines, "")
|
|
|
|
// Status code with coloring
|
|
statusStr := fmt.Sprintf("%d", entry.StatusCode)
|
|
if entry.StatusCode >= 500 {
|
|
statusStr = errorStyle.Render(statusStr)
|
|
} else if entry.StatusCode >= 400 {
|
|
statusStr = warningStyle.Render(statusStr)
|
|
} else if entry.StatusCode >= 200 && entry.StatusCode < 300 {
|
|
statusStr = successStyle.Render(statusStr)
|
|
}
|
|
lines = append(lines, fmt.Sprintf(" Status: %s", statusStr))
|
|
|
|
// Timing
|
|
latencyStr := ""
|
|
if entry.LatencyMs >= 1000 {
|
|
latencyStr = fmt.Sprintf("%.2fs", float64(entry.LatencyMs)/1000)
|
|
} else {
|
|
latencyStr = fmt.Sprintf("%dms", entry.LatencyMs)
|
|
}
|
|
lines = append(lines, fmt.Sprintf(" Latency: %s", latencyStr))
|
|
lines = append(lines, fmt.Sprintf(" Body Size: %d bytes", entry.BodySize))
|
|
lines = append(lines, "")
|
|
|
|
// Response headers (sorted alphabetically)
|
|
if len(entry.ResponseHeaders) > 0 {
|
|
lines = append(lines, accentStyle.Render(" Response Headers:"))
|
|
headerKeys := make([]string, 0, len(entry.ResponseHeaders))
|
|
for k := range entry.ResponseHeaders {
|
|
headerKeys = append(headerKeys, k)
|
|
}
|
|
sort.Strings(headerKeys)
|
|
for _, k := range headerKeys {
|
|
v := entry.ResponseHeaders[k]
|
|
// Truncate long header values
|
|
if len(v) > termWidth-20 {
|
|
v = v[:termWidth-23] + "..."
|
|
}
|
|
lines = append(lines, fmt.Sprintf(" %s: %s", mutedStyle.Render(k), v))
|
|
}
|
|
lines = append(lines, "")
|
|
}
|
|
|
|
// Response body
|
|
if entry.ResponseBody != "" {
|
|
lines = append(lines, accentStyle.Render(" Response Body:"))
|
|
// Decompress if needed, then check if binary
|
|
respBody := decompressContent(entry.ResponseBody, entry.ResponseHeaders)
|
|
if isBinaryContent(respBody, entry.ResponseHeaders) {
|
|
lines = append(lines, mutedStyle.Render(" [Binary data - not displayed]"))
|
|
if ct := entry.ResponseHeaders["Content-Type"]; ct != "" {
|
|
lines = append(lines, mutedStyle.Render(fmt.Sprintf(" Content-Type: %s", ct)))
|
|
}
|
|
} else {
|
|
// Format JSON if applicable
|
|
respBody = formatJSONContent(respBody, entry.ResponseHeaders)
|
|
bodyLines := strings.Split(respBody, "\n")
|
|
for _, line := range bodyLines {
|
|
// Truncate long lines
|
|
if len(line) > termWidth-6 {
|
|
line = line[:termWidth-9] + "..."
|
|
}
|
|
lines = append(lines, " "+line)
|
|
}
|
|
}
|
|
lines = append(lines, "")
|
|
}
|
|
|
|
// Error if present
|
|
if entry.Error != "" {
|
|
lines = append(lines, "")
|
|
lines = append(lines, errorStyle.Render(" Error: "+entry.Error))
|
|
lines = append(lines, "")
|
|
}
|
|
|
|
// Calculate visible range based on scroll
|
|
viewportHeight := termHeight - 6 // header, footer, help
|
|
if viewportHeight < 5 {
|
|
viewportHeight = 5
|
|
}
|
|
|
|
state := m.ui.httpLogState
|
|
scroll := state.detailScroll
|
|
|
|
// Clamp scroll to valid range
|
|
maxScroll := len(lines) - viewportHeight
|
|
if maxScroll < 0 {
|
|
maxScroll = 0
|
|
}
|
|
if scroll > maxScroll {
|
|
scroll = maxScroll
|
|
state.detailScroll = scroll
|
|
}
|
|
|
|
// Render visible lines
|
|
end := scroll + viewportHeight
|
|
if end > len(lines) {
|
|
end = len(lines)
|
|
}
|
|
|
|
for i := scroll; i < end; i++ {
|
|
b.WriteString(lines[i])
|
|
b.WriteString("\n")
|
|
}
|
|
|
|
// Pad remaining space
|
|
for i := end - scroll; i < viewportHeight; i++ {
|
|
b.WriteString("\n")
|
|
}
|
|
|
|
// Scroll indicator
|
|
if len(lines) > viewportHeight {
|
|
percent := 0
|
|
if maxScroll > 0 {
|
|
percent = (scroll * 100) / maxScroll
|
|
}
|
|
b.WriteString(mutedStyle.Render(fmt.Sprintf("\n [%d%%] ", percent)))
|
|
} else {
|
|
b.WriteString("\n ")
|
|
}
|
|
|
|
// Show copy message if present, otherwise show help
|
|
if state.copyMessage != "" {
|
|
b.WriteString(successStyle.Render(state.copyMessage))
|
|
b.WriteString(" ")
|
|
b.WriteString(wrapHelpText("↑/↓: Scroll c: Copy Esc: Back", termWidth-10))
|
|
} else {
|
|
b.WriteString(wrapHelpText("↑/↓/PgUp/PgDn: Scroll g: Top c: Copy response Esc: Back", termWidth-10))
|
|
}
|
|
|
|
return b.String()
|
|
}
|
|
|
|
// decompressContent attempts to decompress content based on Content-Encoding header.
|
|
// Returns the decompressed content if successful, or original content if not compressed or on error.
|
|
func decompressContent(content string, headers map[string]string) string {
|
|
enc := headers["Content-Encoding"]
|
|
if enc == "" {
|
|
return content
|
|
}
|
|
|
|
data := []byte(content)
|
|
var reader io.ReadCloser
|
|
var err error
|
|
|
|
switch enc {
|
|
case "gzip":
|
|
reader, err = gzip.NewReader(bytes.NewReader(data))
|
|
if err != nil {
|
|
return content // Return original on error
|
|
}
|
|
defer func() { _ = reader.Close() }()
|
|
case "deflate":
|
|
reader = flate.NewReader(bytes.NewReader(data))
|
|
defer func() { _ = reader.Close() }()
|
|
default:
|
|
// br (brotli), compress, zstd - not in stdlib, return original
|
|
return content
|
|
}
|
|
|
|
decompressed, err := io.ReadAll(reader)
|
|
if err != nil {
|
|
return content // Return original on error
|
|
}
|
|
|
|
return string(decompressed)
|
|
}
|
|
|
|
// isBinaryContent checks if content is binary and shouldn't be displayed as text
|
|
func isBinaryContent(content string, headers map[string]string) bool {
|
|
// Check Content-Type for binary types
|
|
if ct := headers["Content-Type"]; ct != "" {
|
|
// Binary content types
|
|
binaryPrefixes := []string{
|
|
"image/", "audio/", "video/", "application/octet-stream",
|
|
"application/zip", "application/gzip", "application/pdf",
|
|
"application/x-gzip", "application/x-tar", "application/x-bzip",
|
|
}
|
|
for _, prefix := range binaryPrefixes {
|
|
if strings.HasPrefix(ct, prefix) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check for non-printable characters in the content
|
|
// If more than 10% of first 200 bytes are non-printable, treat as binary
|
|
checkLen := len(content)
|
|
if checkLen > 200 {
|
|
checkLen = 200
|
|
}
|
|
nonPrintable := 0
|
|
for i := 0; i < checkLen; i++ {
|
|
c := content[i]
|
|
// Allow printable ASCII, newline, carriage return, tab
|
|
if c < 32 && c != '\n' && c != '\r' && c != '\t' {
|
|
nonPrintable++
|
|
}
|
|
// Check for bytes outside ASCII range (common in compressed/binary data)
|
|
if c > 126 {
|
|
nonPrintable++
|
|
}
|
|
}
|
|
if checkLen > 0 && float64(nonPrintable)/float64(checkLen) > 0.1 {
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// formatJSONContent attempts to pretty-print and colorize JSON content.
|
|
// Returns the formatted JSON if valid, or original content if not JSON.
|
|
func formatJSONContent(content string, headers map[string]string) string {
|
|
// Check Content-Type for JSON
|
|
ct := headers["Content-Type"]
|
|
isJSON := strings.Contains(ct, "application/json") || strings.Contains(ct, "+json")
|
|
|
|
// If not explicitly JSON, try to detect by content
|
|
if !isJSON {
|
|
trimmed := strings.TrimSpace(content)
|
|
// Quick check: must start with { or [ to be JSON
|
|
if len(trimmed) == 0 || (trimmed[0] != '{' && trimmed[0] != '[') {
|
|
return content
|
|
}
|
|
}
|
|
|
|
// Try to parse and format
|
|
var data interface{}
|
|
if err := json.Unmarshal([]byte(content), &data); err != nil {
|
|
return content // Not valid JSON
|
|
}
|
|
|
|
formatted, err := json.MarshalIndent(data, "", " ")
|
|
if err != nil {
|
|
return content
|
|
}
|
|
|
|
// Colorize the formatted JSON
|
|
return colorizeJSON(string(formatted))
|
|
}
|
|
|
|
// colorizeJSON applies syntax highlighting to formatted JSON.
|
|
// It processes line by line to handle the indented output from MarshalIndent.
|
|
func colorizeJSON(jsonStr string) string {
|
|
var result strings.Builder
|
|
lines := strings.Split(jsonStr, "\n")
|
|
|
|
for i, line := range lines {
|
|
result.WriteString(colorizeLine(line))
|
|
if i < len(lines)-1 {
|
|
result.WriteString("\n")
|
|
}
|
|
}
|
|
|
|
return result.String()
|
|
}
|
|
|
|
// colorizeLine colorizes a single line of formatted JSON
|
|
func colorizeLine(line string) string {
|
|
// Find leading whitespace
|
|
trimmed := strings.TrimLeft(line, " \t")
|
|
indent := line[:len(line)-len(trimmed)]
|
|
|
|
if len(trimmed) == 0 {
|
|
return line
|
|
}
|
|
|
|
var result strings.Builder
|
|
result.WriteString(indent)
|
|
|
|
// Check for key: value pattern (key starts with ")
|
|
if strings.HasPrefix(trimmed, "\"") {
|
|
// Find the end of the key
|
|
colonIdx := strings.Index(trimmed, "\":")
|
|
if colonIdx > 0 {
|
|
// This is a key-value line
|
|
key := trimmed[:colonIdx+1] // includes the closing quote
|
|
rest := trimmed[colonIdx+1:]
|
|
|
|
// Colorize the key (without quotes for cleaner look, or with - let's keep quotes)
|
|
result.WriteString(jsonKeyStyle.Render(key))
|
|
result.WriteString(":")
|
|
|
|
// rest starts after the colon
|
|
if len(rest) > 1 {
|
|
value := strings.TrimPrefix(rest, " ")
|
|
hasComma := strings.HasSuffix(value, ",")
|
|
if hasComma {
|
|
value = value[:len(value)-1]
|
|
}
|
|
|
|
result.WriteString(" ")
|
|
result.WriteString(colorizeValue(value))
|
|
if hasComma {
|
|
result.WriteString(",")
|
|
}
|
|
}
|
|
return result.String()
|
|
}
|
|
}
|
|
|
|
// Not a key-value line, could be array element or structural
|
|
// Check for array values or closing braces
|
|
hasComma := strings.HasSuffix(trimmed, ",")
|
|
value := trimmed
|
|
if hasComma {
|
|
value = value[:len(value)-1]
|
|
}
|
|
|
|
result.WriteString(colorizeValue(value))
|
|
if hasComma {
|
|
result.WriteString(",")
|
|
}
|
|
|
|
return result.String()
|
|
}
|
|
|
|
// colorizeValue colorizes a JSON value (string, number, bool, null, or structural)
|
|
func colorizeValue(value string) string {
|
|
value = strings.TrimSpace(value)
|
|
|
|
if len(value) == 0 {
|
|
return value
|
|
}
|
|
|
|
// Structural characters - no color
|
|
if value == "{" || value == "}" || value == "[" || value == "]" ||
|
|
value == "{}" || value == "[]" {
|
|
return value
|
|
}
|
|
|
|
// Null
|
|
if value == "null" {
|
|
return jsonNullStyle.Render(value)
|
|
}
|
|
|
|
// Boolean
|
|
if value == "true" || value == "false" {
|
|
return jsonBoolStyle.Render(value)
|
|
}
|
|
|
|
// String (starts and ends with quotes)
|
|
if strings.HasPrefix(value, "\"") && strings.HasSuffix(value, "\"") {
|
|
return jsonStringStyle.Render(value)
|
|
}
|
|
|
|
// Number (try to detect)
|
|
if isJSONNumber(value) {
|
|
return jsonNumberStyle.Render(value)
|
|
}
|
|
|
|
// Unknown - return as is
|
|
return value
|
|
}
|
|
|
|
// isJSONNumber checks if a string looks like a JSON number
|
|
func isJSONNumber(s string) bool {
|
|
if len(s) == 0 {
|
|
return false
|
|
}
|
|
|
|
i := 0
|
|
// Optional negative sign
|
|
if s[0] == '-' {
|
|
i++
|
|
if i >= len(s) {
|
|
return false
|
|
}
|
|
}
|
|
|
|
// Must have at least one digit
|
|
if s[i] < '0' || s[i] > '9' {
|
|
return false
|
|
}
|
|
|
|
// Skip digits
|
|
for i < len(s) && s[i] >= '0' && s[i] <= '9' {
|
|
i++
|
|
}
|
|
|
|
// Optional decimal part
|
|
if i < len(s) && s[i] == '.' {
|
|
i++
|
|
if i >= len(s) || s[i] < '0' || s[i] > '9' {
|
|
return false
|
|
}
|
|
for i < len(s) && s[i] >= '0' && s[i] <= '9' {
|
|
i++
|
|
}
|
|
}
|
|
|
|
// Optional exponent
|
|
if i < len(s) && (s[i] == 'e' || s[i] == 'E') {
|
|
i++
|
|
if i < len(s) && (s[i] == '+' || s[i] == '-') {
|
|
i++
|
|
}
|
|
if i >= len(s) || s[i] < '0' || s[i] > '9' {
|
|
return false
|
|
}
|
|
for i < len(s) && s[i] >= '0' && s[i] <= '9' {
|
|
i++
|
|
}
|
|
}
|
|
|
|
return i == len(s)
|
|
}
|