Skip to content

Commit 92bc38f

Browse files
authored
Merge pull request #75 from shvbsle/userexp
feat: implement cluster namespace switching
2 parents fe73496 + 3cea448 commit 92bc38f

File tree

5 files changed

+190
-1
lines changed

5 files changed

+190
-1
lines changed

internal/k8s/client.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,43 @@ func (c *Client) SwitchContext(contextName string) error {
233233
return c.Reconnect()
234234
}
235235

236+
// TODO: get claude/ai to write the doc string
237+
func (c *Client) GetAvailableNamespaces() ([]string, error) {
238+
if !c.isConnected || c.clientset == nil {
239+
return nil, fmt.Errorf("not connected to cluster")
240+
}
241+
242+
ctx := context.Background()
243+
244+
namespaceList, err := c.clientset.CoreV1().Namespaces().List(ctx, metav1.ListOptions{})
245+
if err != nil {
246+
c.markDisconnected()
247+
return nil, fmt.Errorf("failed to list namespaces: %w", err)
248+
}
249+
250+
namespaces := make([]string, 0, len(namespaceList.Items))
251+
for _, ns := range namespaceList.Items {
252+
namespaces = append(namespaces, ns.Name)
253+
}
254+
255+
return namespaces, nil
256+
}
257+
258+
// TODO: get claude/ai to write the doc string
259+
func (c *Client) NamespaceExists(name string) (bool, error) {
260+
if !c.isConnected || c.clientset == nil {
261+
return false, fmt.Errorf("not connected to cluster")
262+
}
263+
264+
ctx := context.Background()
265+
_, err := c.clientset.CoreV1().Namespaces().Get(ctx, name, metav1.GetOptions{})
266+
if err != nil {
267+
return false, nil
268+
}
269+
270+
return true, nil
271+
}
272+
236273
// ListContainersForPod retrieves all containers (init and regular) for a specific pod.
237274
// Returns an error if the client is not connected or if the API request fails.
238275
func (c *Client) ListContainersForPod(podName, namespace string) ([]OrderedResourceFields, error) {

internal/tui/commands.go

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@ func (m *Model) executeCommand(command string) tea.Cmd {
6363
return m.executeCplogsCommand(args)
6464
case "ctx":
6565
return m.executeCtxCommand(args)
66+
case "ns":
67+
return m.executeNsCommand(args)
6668
}
6769

6870
return m.showCommandError(fmt.Sprintf("did not recognize command `%s`", originalCommand))
@@ -563,3 +565,74 @@ func (m *Model) executeCtxCommand(args []string) tea.Cmd {
563565
}
564566
}
565567
}
568+
569+
// executeNsCommand handles the ns command for listing and switching kubernetes namespaces.
570+
func (m *Model) executeNsCommand(args []string) tea.Cmd {
571+
// if no args then show table with all namespaces
572+
if len(args) == 0 {
573+
return func() tea.Msg {
574+
namespaces, err := m.k8sClient.GetAvailableNamespaces()
575+
if err != nil {
576+
log.G().Error("failed to get namespaces", "error", err)
577+
return commandErrMsg{message: fmt.Sprintf("failed to get namespaces: %v", err)}
578+
}
579+
580+
// Sort namespaces alphabetically
581+
sort.Strings(namespaces)
582+
583+
// Create rows for table display, marking the current namespaces
584+
rows := lo.Map(namespaces, func(ns string, _ int) k8s.OrderedResourceFields {
585+
isCurrent := ""
586+
if ns == m.currentNamespace {
587+
isCurrent = "*"
588+
}
589+
return k8s.OrderedResourceFields{
590+
ns,
591+
isCurrent,
592+
}
593+
})
594+
595+
return namespaceLoadedMsg{
596+
namespaces: rows,
597+
}
598+
}
599+
}
600+
601+
// handle "all" argument
602+
namespaceName := args[0]
603+
if namespaceName == "all" {
604+
return func() tea.Msg {
605+
log.G().Info("switching to all namespaces")
606+
return namespaceSwitchedMsg{
607+
namespace: metav1.NamespaceAll,
608+
success: true,
609+
}
610+
}
611+
}
612+
613+
// case where namespace arg is provided
614+
// also gotta confirm that the namespace exists
615+
616+
return func() tea.Msg {
617+
log.G().Info("switching namespace", "namespace", namespaceName)
618+
619+
exists, err := m.k8sClient.NamespaceExists(namespaceName)
620+
if err != nil {
621+
log.G().Warn("failed to check namespace", "error", err)
622+
return commandErrMsg{message: fmt.Sprintf("failed to check namespace: %v", err)}
623+
}
624+
625+
if !exists {
626+
log.G().Warn("namespace not found", "namespace", namespaceName)
627+
return commandErrMsg{message: fmt.Sprintf("namespace '%s' not found", namespaceName)}
628+
}
629+
630+
log.G().Info("namespace switched successfully", "namespace", namespaceName)
631+
632+
return namespaceSwitchedMsg{
633+
namespace: namespaceName,
634+
success: true,
635+
}
636+
}
637+
638+
}

internal/tui/model.go

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,15 @@ type contextSwitchedMsg struct {
135135
success bool
136136
}
137137

138+
type namespaceLoadedMsg struct {
139+
namespaces []k8s.OrderedResourceFields
140+
}
141+
142+
type namespaceSwitchedMsg struct {
143+
namespace string
144+
success bool
145+
}
146+
138147
// New creates a new TUI model with the provided configuration and Kubernetes client.
139148
// The client may be nil or disconnected - the TUI will handle this gracefully and
140149
// display appropriate status messages.
@@ -211,6 +220,14 @@ func New(cfg *config.Config, client *k8s.Client, registry *plugins.Registry) *Mo
211220
return k8s.FormatGVR(gvr)
212221
})
213222

223+
//fetch available namespaces to generate suggestions
224+
availableNamespaces := []string{"all"}
225+
if client != nil && client.IsConnected() {
226+
if namespaces, err := client.GetAvailableNamespaces(); err == nil {
227+
availableNamespaces = append(availableNamespaces, namespaces...)
228+
}
229+
}
230+
214231
return &Model{
215232
config: cfg,
216233
k8sClient: client,
@@ -235,6 +252,7 @@ func New(cfg *config.Config, client *k8s.Client, registry *plugins.Registry) *Mo
235252
"cp": struct{}{},
236253
"cplogs": struct{}{},
237254
"ctx": struct{}{},
255+
"ns": availableNamespaces,
238256
},
239257
// kubernetes resources
240258
map[string]any{
@@ -253,7 +271,7 @@ func New(cfg *config.Config, client *k8s.Client, registry *plugins.Registry) *Mo
253271
describeView: NewDescribeViewState(),
254272
pluginRegistry: registry,
255273
helpModal: NewHelpModal(),
256-
describeViewport: NewDescribeViewport(),
274+
describeViewport: NewDescribeViewport(),
257275
}
258276
}
259277

@@ -523,6 +541,40 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
523541
}
524542
return m, nil
525543

544+
case namespaceLoadedMsg:
545+
m.resources = msg.namespaces
546+
m.logLines = nil
547+
m.currentGVR = schema.GroupVersionResource{Resource: "namespaces"}
548+
549+
m.updateKeysForResourceType()
550+
m.updateColumns(m.viewWidth)
551+
m.updateTableData()
552+
m.table.SetCursor(0)
553+
554+
return m, nil
555+
556+
case namespaceSwitchedMsg:
557+
if msg.success {
558+
m.currentNamespace = msg.namespace
559+
displayName := msg.namespace
560+
if msg.namespace == metav1.NamespaceAll {
561+
displayName = "all namespaces"
562+
}
563+
m.commandSuccess = fmt.Sprintf("Switched to namespace: %s", displayName)
564+
565+
return m, tea.Batch(
566+
tea.Tick(5*time.Second, func(t time.Time) tea.Msg {
567+
return clearCommandSuccessMsg{}
568+
}),
569+
m.loadResourcesWithNamespace(
570+
schema.GroupVersionResource{Group: "", Version: "v1", Resource: "pods"},
571+
m.currentNamespace,
572+
metav1.ListOptions{},
573+
),
574+
)
575+
}
576+
return m, nil
577+
526578
case tea.KeyMsg:
527579
// Handle help modal input first if visible
528580
if m.helpModal.IsVisible() {
@@ -650,6 +702,24 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
650702
return m, nil
651703
}
652704

705+
// Special handling for namespaces view
706+
if m.currentGVR.Resource == "namespaces" {
707+
if len(m.resources) == 0 {
708+
return m, nil
709+
}
710+
actualIdx := m.paginator.Page*m.paginator.PerPage + m.table.Cursor()
711+
if actualIdx >= len(m.resources) {
712+
return m, nil
713+
}
714+
selectedResource := m.resources[actualIdx]
715+
// The namespaces name is in the first column
716+
if len(selectedResource) > 0 {
717+
namespaceName := selectedResource[0]
718+
return m, m.executeNsCommand([]string{namespaceName})
719+
}
720+
return m, nil
721+
}
722+
653723
if m.currentGVR.Resource == k8s.ResourceLogs {
654724
return m, nil
655725
}

internal/tui/resources/resource_views.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,13 @@ func GetColumns(totalWidth int, resource k8s.ResourceType) []table.Column {
4040
}
4141
}
4242

43+
if resource == "namespaces" {
44+
return []table.Column{
45+
{Title: "Namespace", Width: int(float32(totalWidth) * 0.8)},
46+
{Title: "Current", Width: int(float32(totalWidth) * 0.2)},
47+
}
48+
}
49+
4350
var columns []table.Column
4451
for _, field := range GetResourceView(resource).Fields {
4552
columns = append(columns, table.Column{

roadmap.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
- [ ] **SSH into containers** ([#65](https://github.com/shvbsle/k10s/issues/65)) - Triggered by `s` keybinding when cursor is on any resource. Equivalent to `kubectl exec -it <pod> -- /bin/sh`
1111
- [ ] **Logs shortcut** ([#66](https://github.com/shvbsle/k10s/issues/66)) - Triggered by `l` keybinding to display logs for selected pod/container
1212
- [ ] **Real-time logs** ([#67](https://github.com/shvbsle/k10s/issues/67)) - Stream logs in real-time with auto-scroll and filtering
13+
- [x] **Switch namespaces** ([]()) - Use `:ns` command to switch between namespaces
14+
1315

1416
## Developer Experience
1517

0 commit comments

Comments
 (0)