mirror of
https://github.com/lukaszraczylo/kportal.git
synced 2026-10-17 04:57:58 +00:00
feat(ui): type the namespace when the cluster does not list them (#84)
Some users cannot list namespaces on a cluster. The add wizard showed an error and offered no way forward. The namespace step now switches to a text prompt when the list call fails or returns nothing. The prompt shows the reason and checks that the name is a valid DNS label. Esc from a later step returns to the prompt. The j and k keys now type into text fields. They moved the list cursor before, so a name such as kube-system could not be typed. Closes #66
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -697,6 +698,14 @@ func validateDNS1123Label(name, field, entityType string) *ValidationError {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ValidateNamespaceName validates a Kubernetes namespace name (a DNS-1123 label).
|
||||||
|
func ValidateNamespaceName(name string) error {
|
||||||
|
if verr := validateDNS1123Label(name, "namespace", "Namespace"); verr != nil {
|
||||||
|
return errors.New(verr.Message)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// validateDNS1123Subdomain validates that a name is a valid DNS subdomain name (RFC 1123).
|
// validateDNS1123Subdomain validates that a name is a valid DNS subdomain name (RFC 1123).
|
||||||
// Used for resource names which can contain dots.
|
// Used for resource names which can contain dots.
|
||||||
func validateDNS1123Subdomain(name, field, entityType string) *ValidationError {
|
func validateDNS1123Subdomain(name, field, entityType string) *ValidationError {
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ import (
|
|||||||
"github.com/lukaszraczylo/kportal/internal/k8s"
|
"github.com/lukaszraczylo/kportal/internal/k8s"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// keyTypedText routes j and k to text input while a text field has focus.
|
||||||
|
const keyTypedText = "<typed-text>"
|
||||||
|
|
||||||
// isFilterableStep returns true if the step supports search/filter
|
// isFilterableStep returns true if the step supports search/filter
|
||||||
func isFilterableStep(step AddWizardStep) bool {
|
func isFilterableStep(step AddWizardStep) bool {
|
||||||
switch step {
|
switch step {
|
||||||
@@ -339,7 +342,12 @@ func (m model) handleAddWizardKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
switch msg.String() {
|
key := msg.String()
|
||||||
|
if (key == "j" || key == "k") && wizard.isTypingText() {
|
||||||
|
key = keyTypedText
|
||||||
|
}
|
||||||
|
|
||||||
|
switch key {
|
||||||
case "ctrl+c":
|
case "ctrl+c":
|
||||||
// Hard cancel
|
// Hard cancel
|
||||||
m.ui.viewMode = ViewModeMain
|
m.ui.viewMode = ViewModeMain
|
||||||
@@ -373,8 +381,14 @@ func (m model) handleAddWizardKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
|
|
||||||
// Reset input mode based on the step we're going back to
|
// Reset input mode based on the step we're going back to
|
||||||
switch wizard.step {
|
switch wizard.step {
|
||||||
case StepSelectContext, StepSelectNamespace, StepSelectResourceType:
|
case StepSelectContext, StepSelectResourceType:
|
||||||
wizard.inputMode = InputModeList
|
wizard.inputMode = InputModeList
|
||||||
|
case StepSelectNamespace:
|
||||||
|
if wizard.namespaceManual {
|
||||||
|
wizard.inputMode = InputModeText
|
||||||
|
} else {
|
||||||
|
wizard.inputMode = InputModeList
|
||||||
|
}
|
||||||
case StepEnterResource:
|
case StepEnterResource:
|
||||||
if wizard.selectedResourceType == ResourceTypeService {
|
if wizard.selectedResourceType == ResourceTypeService {
|
||||||
wizard.inputMode = InputModeList
|
wizard.inputMode = InputModeList
|
||||||
@@ -526,10 +540,27 @@ func (m model) handleAddWizardEnter() (tea.Model, tea.Cmd) {
|
|||||||
wizard.cursor = 0
|
wizard.cursor = 0
|
||||||
wizard.clearSearchFilter()
|
wizard.clearSearchFilter()
|
||||||
wizard.loading = true
|
wizard.loading = true
|
||||||
|
wizard.namespaceManual = false
|
||||||
|
wizard.namespaceListErr = nil
|
||||||
|
wizard.inputMode = InputModeList
|
||||||
return m, loadNamespacesCmd(m.ui.discovery, wizard.selectedContext)
|
return m, loadNamespacesCmd(m.ui.discovery, wizard.selectedContext)
|
||||||
}
|
}
|
||||||
|
|
||||||
case StepSelectNamespace:
|
case StepSelectNamespace:
|
||||||
|
if wizard.namespaceManual {
|
||||||
|
name := strings.TrimSpace(wizard.textInput)
|
||||||
|
if err := config.ValidateNamespaceName(name); err != nil {
|
||||||
|
wizard.error = err
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
wizard.error = nil
|
||||||
|
wizard.selectedNamespace = name
|
||||||
|
wizard.step = StepSelectResourceType
|
||||||
|
wizard.cursor = 0
|
||||||
|
wizard.clearTextInput()
|
||||||
|
wizard.inputMode = InputModeList
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
filteredNamespaces := wizard.getFilteredNamespaces()
|
filteredNamespaces := wizard.getFilteredNamespaces()
|
||||||
if wizard.cursor >= 0 && wizard.cursor < len(filteredNamespaces) {
|
if wizard.cursor >= 0 && wizard.cursor < len(filteredNamespaces) {
|
||||||
wizard.selectedNamespace = filteredNamespaces[wizard.cursor]
|
wizard.selectedNamespace = filteredNamespaces[wizard.cursor]
|
||||||
@@ -863,10 +894,18 @@ func (m model) handleNamespacesLoaded(msg NamespacesLoadedMsg) (tea.Model, tea.C
|
|||||||
defer m.ui.mu.Unlock()
|
defer m.ui.mu.Unlock()
|
||||||
|
|
||||||
if m.ui.addWizard != nil {
|
if m.ui.addWizard != nil {
|
||||||
m.ui.addWizard.loading = false
|
w := m.ui.addWizard
|
||||||
m.ui.addWizard.error = msg.err
|
w.loading = false
|
||||||
|
w.error = nil
|
||||||
if msg.err == nil {
|
if msg.err == nil {
|
||||||
m.ui.addWizard.namespaces = msg.namespaces
|
w.namespaces = msg.namespaces
|
||||||
|
}
|
||||||
|
// Without permission to list namespaces the user types the name instead.
|
||||||
|
if (msg.err != nil || len(msg.namespaces) == 0) && w.step == StepSelectNamespace {
|
||||||
|
w.namespaceManual = true
|
||||||
|
w.namespaceListErr = msg.err
|
||||||
|
w.inputMode = InputModeText
|
||||||
|
w.textInput = ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func typeKeys(t *testing.T, m model, text string) {
|
||||||
|
t.Helper()
|
||||||
|
for _, r := range text {
|
||||||
|
m.handleAddWizardKeys(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNamespacesLoaded_ListFailureSwitchesToManualEntry(t *testing.T) {
|
||||||
|
m := newModelWithWizard(StepSelectNamespace)
|
||||||
|
m.ui.addWizard.loading = true
|
||||||
|
|
||||||
|
m.handleNamespacesLoaded(NamespacesLoadedMsg{err: errors.New("namespaces is forbidden")})
|
||||||
|
|
||||||
|
w := m.ui.addWizard
|
||||||
|
assert.True(t, w.namespaceManual)
|
||||||
|
assert.Equal(t, InputModeText, w.inputMode)
|
||||||
|
assert.NoError(t, w.error)
|
||||||
|
assert.False(t, w.loading)
|
||||||
|
assert.Contains(t, m.renderSelectNamespace(), "Enter Namespace")
|
||||||
|
assert.Contains(t, m.renderSelectNamespace(), "forbidden")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNamespacesLoaded_EmptyListSwitchesToManualEntry(t *testing.T) {
|
||||||
|
m := newModelWithWizard(StepSelectNamespace)
|
||||||
|
|
||||||
|
m.handleNamespacesLoaded(NamespacesLoadedMsg{namespaces: []string{}})
|
||||||
|
|
||||||
|
assert.True(t, m.ui.addWizard.namespaceManual)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNamespacesLoaded_ListedNamespacesKeepSelectionMode(t *testing.T) {
|
||||||
|
m := newModelWithWizard(StepSelectNamespace)
|
||||||
|
|
||||||
|
m.handleNamespacesLoaded(NamespacesLoadedMsg{namespaces: []string{"default", "kube-system"}})
|
||||||
|
|
||||||
|
w := m.ui.addWizard
|
||||||
|
assert.False(t, w.namespaceManual)
|
||||||
|
assert.Equal(t, InputModeList, w.inputMode)
|
||||||
|
assert.Equal(t, []string{"default", "kube-system"}, w.namespaces)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManualNamespace_TypeAndConfirm(t *testing.T) {
|
||||||
|
m := newModelWithWizard(StepSelectNamespace)
|
||||||
|
m.handleNamespacesLoaded(NamespacesLoadedMsg{err: errors.New("forbidden")})
|
||||||
|
|
||||||
|
// k and j must be typed, not treated as navigation
|
||||||
|
typeKeys(t, m, "kube-jack")
|
||||||
|
assert.Equal(t, "kube-jack", m.ui.addWizard.textInput)
|
||||||
|
|
||||||
|
m.handleAddWizardEnter()
|
||||||
|
|
||||||
|
w := m.ui.addWizard
|
||||||
|
assert.Equal(t, "kube-jack", w.selectedNamespace)
|
||||||
|
assert.Equal(t, StepSelectResourceType, w.step)
|
||||||
|
assert.Equal(t, InputModeList, w.inputMode)
|
||||||
|
assert.Empty(t, w.textInput)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManualNamespace_RejectsInvalidName(t *testing.T) {
|
||||||
|
m := newModelWithWizard(StepSelectNamespace)
|
||||||
|
m.handleNamespacesLoaded(NamespacesLoadedMsg{err: errors.New("forbidden")})
|
||||||
|
m.ui.addWizard.selectedNamespace = ""
|
||||||
|
|
||||||
|
for _, bad := range []string{"", "Has_Upper", "-leading", "trailing-"} {
|
||||||
|
m.ui.addWizard.textInput = bad
|
||||||
|
m.handleAddWizardEnter()
|
||||||
|
|
||||||
|
w := m.ui.addWizard
|
||||||
|
require.Error(t, w.error, "name %q", bad)
|
||||||
|
assert.Equal(t, StepSelectNamespace, w.step, "name %q", bad)
|
||||||
|
assert.Empty(t, w.selectedNamespace, "name %q", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManualNamespace_BackFromLaterStepRestoresTextInput(t *testing.T) {
|
||||||
|
m := newModelWithWizard(StepSelectResourceType)
|
||||||
|
m.ui.addWizard.namespaceManual = true
|
||||||
|
|
||||||
|
m.handleAddWizardKeys(tea.KeyMsg{Type: tea.KeyEsc})
|
||||||
|
|
||||||
|
w := m.ui.addWizard
|
||||||
|
assert.Equal(t, StepSelectNamespace, w.step)
|
||||||
|
assert.Equal(t, InputModeText, w.inputMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKeysJK_StillNavigateInListMode(t *testing.T) {
|
||||||
|
m := newModelWithWizard(StepSelectResourceType)
|
||||||
|
m.ui.addWizard.inputMode = InputModeList
|
||||||
|
|
||||||
|
m.handleAddWizardKeys(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}})
|
||||||
|
|
||||||
|
assert.Equal(t, 1, m.ui.addWizard.cursor)
|
||||||
|
}
|
||||||
@@ -111,6 +111,7 @@ func (r ResourceType) Description() string {
|
|||||||
// AddWizardState maintains the state for the add port forward wizard
|
// AddWizardState maintains the state for the add port forward wizard
|
||||||
type AddWizardState struct {
|
type AddWizardState struct {
|
||||||
error error
|
error error
|
||||||
|
namespaceListErr error
|
||||||
httpLogOriginal *config.HTTPLogSpec
|
httpLogOriginal *config.HTTPLogSpec
|
||||||
resourceValue string
|
resourceValue string
|
||||||
originalID string
|
originalID string
|
||||||
@@ -137,6 +138,7 @@ type AddWizardState struct {
|
|||||||
confirmationFocus ConfirmationFocus
|
confirmationFocus ConfirmationFocus
|
||||||
portAvailable bool
|
portAvailable bool
|
||||||
isEditing bool
|
isEditing bool
|
||||||
|
namespaceManual bool
|
||||||
loading bool
|
loading bool
|
||||||
httpLog bool
|
httpLog bool
|
||||||
}
|
}
|
||||||
@@ -223,6 +225,13 @@ func (w *AddWizardState) handleTextInput(char rune) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isTypingText reports whether printable keys must go into a text field,
|
||||||
|
// including keys that otherwise navigate (j, k).
|
||||||
|
func (w *AddWizardState) isTypingText() bool {
|
||||||
|
return w.inputMode == InputModeText ||
|
||||||
|
(w.step == StepConfirmation && w.confirmationFocus == FocusAlias)
|
||||||
|
}
|
||||||
|
|
||||||
// clearTextInput clears the text input field
|
// clearTextInput clears the text input field
|
||||||
func (w *AddWizardState) clearTextInput() {
|
func (w *AddWizardState) clearTextInput() {
|
||||||
w.textInput = ""
|
w.textInput = ""
|
||||||
|
|||||||
@@ -142,6 +142,10 @@ func (m model) renderSelectNamespace() string {
|
|||||||
b.WriteString(renderHeader("Add Port Forward", renderProgress(2, 7)))
|
b.WriteString(renderHeader("Add Port Forward", renderProgress(2, 7)))
|
||||||
fmt.Fprintf(&b, "Context: %s\n\n", breadcrumbStyle.Render(wizard.selectedContext))
|
fmt.Fprintf(&b, "Context: %s\n\n", breadcrumbStyle.Render(wizard.selectedContext))
|
||||||
|
|
||||||
|
if wizard.namespaceManual {
|
||||||
|
return m.renderManualNamespace(&b)
|
||||||
|
}
|
||||||
|
|
||||||
b.WriteString("Select Namespace:\n\n")
|
b.WriteString("Select Namespace:\n\n")
|
||||||
|
|
||||||
// Show search input if there's a filter active
|
// Show search input if there's a filter active
|
||||||
@@ -177,6 +181,32 @@ func (m model) renderSelectNamespace() string {
|
|||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// renderManualNamespace shows a free-text namespace prompt for clusters where
|
||||||
|
// the namespace list is not available to the user.
|
||||||
|
func (m model) renderManualNamespace(b *strings.Builder) string {
|
||||||
|
wizard := m.ui.addWizard
|
||||||
|
|
||||||
|
b.WriteString("Enter Namespace:\n\n")
|
||||||
|
if wizard.namespaceListErr != nil {
|
||||||
|
b.WriteString(mutedStyle.Render(fmt.Sprintf("Could not list namespaces (%v).", wizard.namespaceListErr)))
|
||||||
|
} else {
|
||||||
|
b.WriteString(mutedStyle.Render("No namespaces found."))
|
||||||
|
}
|
||||||
|
b.WriteString("\n")
|
||||||
|
b.WriteString(mutedStyle.Render("Type the namespace name instead."))
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
|
||||||
|
b.WriteString(renderTextInput("Namespace: ", wizard.textInput, wizard.textInput != ""))
|
||||||
|
b.WriteString("\n")
|
||||||
|
if wizard.error != nil {
|
||||||
|
b.WriteString(errorStyle.Render(fmt.Sprintf("✗ %v\n", wizard.error)))
|
||||||
|
}
|
||||||
|
|
||||||
|
b.WriteString("\n")
|
||||||
|
b.WriteString(wrapHelpText("Enter: Continue Esc: Back Ctrl+C: Cancel", wizardHelpWidth(m.termWidth)))
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
func (m model) renderSelectResourceType() string {
|
func (m model) renderSelectResourceType() string {
|
||||||
wizard := m.ui.addWizard
|
wizard := m.ui.addWizard
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
|
|||||||
Reference in New Issue
Block a user