diff --git a/go.mod b/go.mod index c4f5a10..47c36ec 100644 --- a/go.mod +++ b/go.mod @@ -25,6 +25,7 @@ require ( github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect github.com/emicklei/go-restful/v3 v3.13.0 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect diff --git a/go.sum b/go.sum index cfbec4b..cf0c246 100644 --- a/go.sum +++ b/go.sum @@ -23,6 +23,8 @@ github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsV github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes= github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= diff --git a/internal/forward/manager.go b/internal/forward/manager.go index 50cd8c6..390bef3 100644 --- a/internal/forward/manager.go +++ b/internal/forward/manager.go @@ -357,6 +357,7 @@ func (m *Manager) startWorker(fwd config.Forward) error { m.healthChecker.Register(fwd.ID(), fwd.LocalPort, func(forwardID string, status healthcheck.Status, errorMsg string) { if m.statusUI != nil { m.statusUI.UpdateStatus(forwardID, string(status)) + // Send error separately if there is one if (status == healthcheck.StatusUnhealthy || status == healthcheck.StatusStale) && errorMsg != "" { if ui, ok := m.statusUI.(interface{ SetError(id, msg string) }); ok { diff --git a/internal/healthcheck/checker.go b/internal/healthcheck/checker.go index fd16d2e..db0455c 100644 --- a/internal/healthcheck/checker.go +++ b/internal/healthcheck/checker.go @@ -201,6 +201,17 @@ func (c *Checker) GetStatus(forwardID string) (Status, bool) { return StatusUnhealthy, false } +// GetLastCheckTime returns the last health check time for a forward +func (c *Checker) GetLastCheckTime(forwardID string) (time.Time, bool) { + c.mu.RLock() + defer c.mu.RUnlock() + + if health, exists := c.ports[forwardID]; exists { + return health.LastCheck, true + } + return time.Time{}, false +} + // GetAllErrors returns all forwards with errors and their error messages func (c *Checker) GetAllErrors() map[string]string { c.mu.RLock() diff --git a/internal/k8s/discovery.go b/internal/k8s/discovery.go index 9f677d0..6ac053c 100644 --- a/internal/k8s/discovery.go +++ b/internal/k8s/discovery.go @@ -292,12 +292,8 @@ func CheckPortAvailability(port int) (bool, string, error) { addr := fmt.Sprintf(":%d", port) listener, err := net.Listen("tcp", addr) if err != nil { - // Port is in use - // Try to get process info (best-effort) - processInfo := "unknown process" - // Note: Getting process info requires platform-specific code - // For now, just return a generic message - return false, processInfo, nil + // Port is in use - return error details + return false, err.Error(), nil } // Port is available, close the listener diff --git a/internal/ui/wizard_commands.go b/internal/ui/wizard_commands.go index 8758e41..1407215 100644 --- a/internal/ui/wizard_commands.go +++ b/internal/ui/wizard_commands.go @@ -144,8 +144,25 @@ func validateSelectorCmd(discovery *k8s.Discovery, contextName, namespace, selec } // checkPortCmd checks if a local port is available -func checkPortCmd(port int) tea.Cmd { +func checkPortCmd(port int, configPath string) tea.Cmd { return func() tea.Msg { + // First check if port is already in the configuration + cfg, err := config.LoadConfig(configPath) + if err == nil { + // Check all forwards in config for this port + allForwards := cfg.GetAllForwards() + for _, fwd := range allForwards { + if fwd.LocalPort == port { + return PortCheckedMsg{ + port: port, + available: false, + message: fmt.Sprintf("✗ Port %d already assigned to %s", port, fwd.ID()), + } + } + } + } + + // Then check if port is available at OS level available, processInfo, err := k8s.CheckPortAvailability(port) msg := "" diff --git a/internal/ui/wizard_handlers.go b/internal/ui/wizard_handlers.go index 2e3d9f6..a202924 100644 --- a/internal/ui/wizard_handlers.go +++ b/internal/ui/wizard_handlers.go @@ -374,6 +374,11 @@ func (m model) handleAddWizardKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { func (m model) handleAddWizardEnter() (tea.Model, tea.Cmd) { wizard := m.ui.addWizard + // Don't process Enter if we're currently loading + if wizard.loading { + return m, nil + } + switch wizard.step { case StepSelectContext: filteredContexts := wizard.getFilteredContexts() @@ -452,12 +457,14 @@ func (m model) handleAddWizardEnter() (tea.Model, tea.Cmd) { filteredServices := wizard.getFilteredServices() if wizard.cursor >= 0 && wizard.cursor < len(filteredServices) { wizard.resourceValue = filteredServices[wizard.cursor].Name + + // Get ports from selected service (must do this BEFORE clearing search filter) + wizard.detectedPorts = filteredServices[wizard.cursor].Ports + wizard.step = StepEnterRemotePort wizard.clearTextInput() wizard.clearSearchFilter() - // Get ports from selected service - wizard.detectedPorts = filteredServices[wizard.cursor].Ports if len(wizard.detectedPorts) > 0 { wizard.inputMode = InputModeList wizard.cursor = 0 @@ -500,14 +507,11 @@ func (m model) handleAddWizardEnter() (tea.Model, tea.Cmd) { if err != nil || port < 1 || port > 65535 { wizard.error = fmt.Errorf("invalid port number") } else { + // Check port availability before proceeding wizard.localPort = port - wizard.step = StepConfirmation - wizard.clearTextInput() - wizard.cursor = 0 - wizard.inputMode = InputModeList - wizard.error = nil wizard.loading = true - return m, checkPortCmd(port) + wizard.error = nil + return m, checkPortCmd(port, m.ui.configPath) } case StepConfirmation: @@ -520,6 +524,12 @@ func (m model) handleAddWizardEnter() (tea.Model, tea.Cmd) { // Handle button selection if wizard.cursor == 0 { + // Check if port is available before saving + if !wizard.portAvailable { + wizard.error = fmt.Errorf("port %d is not available. Please choose a different port", wizard.localPort) + return m, nil + } + // Confirmed - save the forward wizard.alias = wizard.textInput @@ -771,6 +781,17 @@ func (m model) handlePortChecked(msg PortCheckedMsg) (tea.Model, tea.Cmd) { m.ui.addWizard.loading = false m.ui.addWizard.portAvailable = msg.available m.ui.addWizard.portCheckMsg = msg.message + + // Only proceed to confirmation if port is available + if msg.available { + m.ui.addWizard.step = StepConfirmation + m.ui.addWizard.clearTextInput() + m.ui.addWizard.cursor = 0 + m.ui.addWizard.inputMode = InputModeList + } else { + // Port is not available - show error and stay on local port step + m.ui.addWizard.error = fmt.Errorf("port %d is in use, please choose another port", msg.port) + } } return m, nil diff --git a/internal/ui/wizard_views.go b/internal/ui/wizard_views.go index 43d927b..9e88bc7 100644 --- a/internal/ui/wizard_views.go +++ b/internal/ui/wizard_views.go @@ -373,7 +373,7 @@ func (m model) renderEnterRemotePort() string { prefix = "▸ " b.WriteString(selectedStyle.Render(prefix + manualOption)) } else { - b.WriteString(mutedStyle.Render(prefix + manualOption)) + b.WriteString(prefix + mutedStyle.Render(manualOption)) } b.WriteString("\n") } @@ -443,7 +443,7 @@ func (m model) renderEnterLocalPort() string { } else { b.WriteString(errorStyle.Render(wizard.portCheckMsg)) } - } else if wizard.textInput != "" && wizard.localPort > 0 { + } else if wizard.textInput != "" { b.WriteString(mutedStyle.Render("Press Enter to check availability")) }