mirror of
https://github.com/lukaszraczylo/kportal.git
synced 2026-06-12 00:19:24 +00:00
3f5c1d3a5f
* Add benchmark and httplog modules, update UI for modals artefacts
1073 lines
31 KiB
Go
1073 lines
31 KiB
Go
package ui
|
|
|
|
import (
|
|
"fmt"
|
|
"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")
|
|
if wizard.searchFilter != "" {
|
|
b.WriteString(helpStyle.Render(fmt.Sprintf("↑/↓: Navigate Enter: Select Backspace: Clear filter (%d/%d) Esc: Cancel", len(wizard.getFilteredContexts()), len(wizard.contexts))))
|
|
} else {
|
|
b.WriteString(helpStyle.Render("Type to filter ↑/↓: Navigate Enter: Select Esc/Ctrl+C: Cancel"))
|
|
}
|
|
|
|
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")
|
|
if wizard.searchFilter != "" {
|
|
b.WriteString(helpStyle.Render(fmt.Sprintf("↑/↓: Navigate Enter: Select Backspace: Clear filter (%d/%d) Esc: Back", len(wizard.getFilteredNamespaces()), len(wizard.namespaces))))
|
|
} else {
|
|
b.WriteString(helpStyle.Render("Type to filter ↑/↓: Navigate Enter: Select Esc: Back Ctrl+C: Cancel"))
|
|
}
|
|
|
|
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(helpStyle.Render("↑/↓: Navigate Enter: Select Esc: Back Ctrl+C: Cancel"))
|
|
|
|
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
|
|
if wizard.selectedResourceType == ResourceTypeService {
|
|
if wizard.searchFilter != "" {
|
|
b.WriteString(helpStyle.Render(fmt.Sprintf("↑/↓: Navigate Enter: Select Backspace: Clear filter (%d/%d) Esc: Back", len(wizard.getFilteredServices()), len(wizard.services))))
|
|
} else {
|
|
b.WriteString(helpStyle.Render("Type to filter ↑/↓: Navigate Enter: Select Esc: Back Ctrl+C: Cancel"))
|
|
}
|
|
} else {
|
|
b.WriteString(helpStyle.Render("Enter: Continue Esc: Back Ctrl+C: Cancel"))
|
|
}
|
|
|
|
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(helpStyle.Render("↑/↓: Navigate Enter: Select Esc: Back Ctrl+C: Cancel"))
|
|
} 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(helpStyle.Render("Enter: Continue Esc: Back Ctrl+C: Cancel"))
|
|
}
|
|
|
|
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(helpStyle.Render("Enter: Continue Esc: Back Ctrl+C: Cancel"))
|
|
|
|
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")
|
|
|
|
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(helpStyle.Render("↑/↓/Tab: Navigate Enter: Confirm Esc: Back"))
|
|
|
|
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(helpStyle.Render("↑/↓: Navigate Enter: Select"))
|
|
|
|
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(helpStyle.Render("Space: Toggle a: All n: None Enter: Remove Esc: Cancel"))
|
|
|
|
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(helpStyle.Render("↑/↓: Navigate Enter: Confirm Esc: Cancel"))
|
|
|
|
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()
|
|
}
|