mirror of
https://github.com/skoelle/kctl-tui.git
synced 2026-09-17 20:10:24 +00:00
Redesign full-mode navigation: start at team selection with default context, resolve k9s panes from config envs instead of context-pairs
This commit is contained in:
+68
-48
@@ -20,9 +20,12 @@ const (
|
|||||||
screenNamespace
|
screenNamespace
|
||||||
)
|
)
|
||||||
|
|
||||||
// fullModel drives the interactive context -> team -> namespace navigation
|
// fullModel drives the interactive navigation. It starts directly at the
|
||||||
// and, once a namespace is chosen, launches the 3-pane tmux session
|
// team-selection screen using the configured default context, and only
|
||||||
// (control pane + two k9s panes) via tea.ExecProcess.
|
// shows the context screen when the user explicitly goes back via Esc.
|
||||||
|
// Once a namespace is chosen, it launches the 3-pane tmux session
|
||||||
|
// (control pane + two k9s panes, one per configured env) via
|
||||||
|
// tea.ExecProcess.
|
||||||
type fullModel struct {
|
type fullModel struct {
|
||||||
list list.Model
|
list list.Model
|
||||||
state screenState
|
state screenState
|
||||||
@@ -42,48 +45,43 @@ func newFullModel() *fullModel {
|
|||||||
l := list.New(nil, list.NewDefaultDelegate(), 0, 0)
|
l := list.New(nil, list.NewDefaultDelegate(), 0, 0)
|
||||||
l.Title = "kctl-tui"
|
l.Title = "kctl-tui"
|
||||||
l.SetShowStatusBar(false)
|
l.SetShowStatusBar(false)
|
||||||
return &fullModel{list: l, state: screenContext}
|
return &fullModel{list: l}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *fullModel) Init() tea.Cmd {
|
func (m *fullModel) Init() tea.Cmd {
|
||||||
return m.loadContexts
|
return m.bootstrap
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *fullModel) loadContexts() tea.Msg {
|
// bootstrap loads the config and applies the default context so the tool
|
||||||
contexts, err := kubeexec.GetContexts()
|
// can jump straight to the team-selection screen.
|
||||||
|
func (m *fullModel) bootstrap() tea.Msg {
|
||||||
|
cfgPath, _ := config.DefaultPath()
|
||||||
|
cfg, err := config.Load(cfgPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errMsg{err}
|
return errMsg{err}
|
||||||
}
|
}
|
||||||
current := kubeexec.GetCurrentContext()
|
if len(cfg.Contexts) == 0 {
|
||||||
|
return errMsg{fmt.Errorf("no 'contexts' configured in ~/.kctl-tui/config.yaml (see config.example.yaml)")}
|
||||||
cfgPath, _ := config.DefaultPath()
|
|
||||||
cfg, _ := config.Load(cfgPath)
|
|
||||||
|
|
||||||
items := make([]list.Item, 0, len(contexts))
|
|
||||||
if current != "" {
|
|
||||||
items = append(items, simpleItem{label: "(current) " + current, value: current})
|
|
||||||
}
|
}
|
||||||
for _, c := range contexts {
|
if len(cfg.Envs) == 0 {
|
||||||
if c != current {
|
return errMsg{fmt.Errorf("no 'envs' configured in ~/.kctl-tui/config.yaml (see config.example.yaml)")}
|
||||||
items = append(items, simpleItem{label: c, value: c})
|
|
||||||
}
|
}
|
||||||
}
|
return bootstrapMsg{cfg: cfg, context: cfg.EffectiveDefaultContext()}
|
||||||
return contextsLoadedMsg{items: items, cfg: cfg}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type contextsLoadedMsg struct {
|
type bootstrapMsg struct {
|
||||||
items []list.Item
|
|
||||||
cfg config.Config
|
cfg config.Config
|
||||||
|
context string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type contextsLoadedMsg struct{ items []list.Item }
|
||||||
|
|
||||||
type teamsLoadedMsg struct {
|
type teamsLoadedMsg struct {
|
||||||
items []list.Item
|
items []list.Item
|
||||||
namespaces map[string]map[string]string
|
namespaces map[string]map[string]string
|
||||||
}
|
}
|
||||||
|
|
||||||
type namespacesLoadedMsg struct {
|
type namespacesLoadedMsg struct{ items []list.Item }
|
||||||
items []list.Item
|
|
||||||
}
|
|
||||||
|
|
||||||
type tmuxDoneMsg struct{ err error }
|
type tmuxDoneMsg struct{ err error }
|
||||||
|
|
||||||
@@ -100,8 +98,12 @@ func (m *fullModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.err = msg.err
|
m.err = msg.err
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
case contextsLoadedMsg:
|
case bootstrapMsg:
|
||||||
m.cfg = msg.cfg
|
m.cfg = msg.cfg
|
||||||
|
m.selectedContext = msg.context
|
||||||
|
return m, m.loadTeams
|
||||||
|
|
||||||
|
case contextsLoadedMsg:
|
||||||
m.state = screenContext
|
m.state = screenContext
|
||||||
m.list.Title = "Select context (enter = confirm, esc/ctrl+c = quit)"
|
m.list.Title = "Select context (enter = confirm, esc/ctrl+c = quit)"
|
||||||
m.list.SetItems(msg.items)
|
m.list.SetItems(msg.items)
|
||||||
@@ -110,7 +112,7 @@ func (m *fullModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
case teamsLoadedMsg:
|
case teamsLoadedMsg:
|
||||||
m.namespaces = msg.namespaces
|
m.namespaces = msg.namespaces
|
||||||
m.state = screenTeam
|
m.state = screenTeam
|
||||||
m.list.Title = "Select team (esc = back to context)"
|
m.list.Title = fmt.Sprintf("Select team [context=%s] (esc = back to context)", m.selectedContext)
|
||||||
m.list.SetItems(msg.items)
|
m.list.SetItems(msg.items)
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
@@ -157,6 +159,18 @@ func (m *fullModel) handleBack() (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *fullModel) loadContexts() tea.Msg {
|
||||||
|
items := make([]list.Item, 0, len(m.cfg.Contexts))
|
||||||
|
for _, c := range m.cfg.Contexts {
|
||||||
|
label := c
|
||||||
|
if c == m.selectedContext {
|
||||||
|
label = "(current) " + c
|
||||||
|
}
|
||||||
|
items = append(items, simpleItem{label: label, value: c})
|
||||||
|
}
|
||||||
|
return contextsLoadedMsg{items: items}
|
||||||
|
}
|
||||||
|
|
||||||
func (m *fullModel) handleSelect() (tea.Model, tea.Cmd) {
|
func (m *fullModel) handleSelect() (tea.Model, tea.Cmd) {
|
||||||
item, ok := m.list.SelectedItem().(simpleItem)
|
item, ok := m.list.SelectedItem().(simpleItem)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -166,9 +180,6 @@ func (m *fullModel) handleSelect() (tea.Model, tea.Cmd) {
|
|||||||
switch m.state {
|
switch m.state {
|
||||||
case screenContext:
|
case screenContext:
|
||||||
m.selectedContext = item.value
|
m.selectedContext = item.value
|
||||||
if err := kubeexec.UseContext(item.value); err != nil {
|
|
||||||
return m, func() tea.Msg { return errMsg{err} }
|
|
||||||
}
|
|
||||||
return m, m.loadTeams
|
return m, m.loadTeams
|
||||||
|
|
||||||
case screenTeam:
|
case screenTeam:
|
||||||
@@ -177,16 +188,24 @@ func (m *fullModel) handleSelect() (tea.Model, tea.Cmd) {
|
|||||||
|
|
||||||
case screenNamespace:
|
case screenNamespace:
|
||||||
m.selectedNamespace = item.value
|
m.selectedNamespace = item.value
|
||||||
if err := kubeexec.SetNamespace(item.value); err != nil {
|
|
||||||
return m, func() tea.Msg { return errMsg{err} }
|
|
||||||
}
|
|
||||||
return m, m.startTmuxSession()
|
return m, m.startTmuxSession()
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// bootstrapContext resolves a kubectl context purely to discover
|
||||||
|
// namespaces/labels for the team/namespace screens. The first configured
|
||||||
|
// env is used as a stable default for this discovery step, since
|
||||||
|
// namespace names are assumed to be identical across envs.
|
||||||
|
func (m *fullModel) bootstrapContext() string {
|
||||||
|
if len(m.cfg.Envs) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return m.cfg.ResolveContext(m.cfg.Envs[0], m.selectedContext)
|
||||||
|
}
|
||||||
|
|
||||||
func (m *fullModel) loadTeams() tea.Msg {
|
func (m *fullModel) loadTeams() tea.Msg {
|
||||||
namespaces, err := kubeexec.GetNamespacesWithLabels()
|
namespaces, err := kubeexec.GetNamespacesWithLabels(m.bootstrapContext())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errMsg{err}
|
return errMsg{err}
|
||||||
}
|
}
|
||||||
@@ -227,25 +246,26 @@ func (m *fullModel) loadNamespacesFor(teamValue string) tea.Cmd {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// startTmuxSession builds the 3-pane tmux command (control pane running
|
// startTmuxSession builds the 3-pane tmux command: the control pane runs
|
||||||
// this binary in "panel" mode, plus two k9s status panes) and runs it via
|
// this binary in "panel" mode (letting the user pick an env and an
|
||||||
// tea.ExecProcess so the Bubble Tea UI cleanly hands over the terminal.
|
// action), and the two status panes run k9s against the first two
|
||||||
//
|
// configured envs, resolved via the context template, so both are
|
||||||
// Layout: even-vertical stacks all three panes evenly from top to bottom
|
// visible side by side.
|
||||||
// (control pane, then the two k9s status panes). remain-on-exit keeps a
|
|
||||||
// pane visible (showing its exit status/output) instead of tmux silently
|
|
||||||
// closing it if the control pane's process crashes on startup.
|
|
||||||
func (m *fullModel) startTmuxSession() tea.Cmd {
|
func (m *fullModel) startTmuxSession() tea.Cmd {
|
||||||
selfPath := "kctl-tui" // resolved via PATH; see README for install instructions
|
selfPath := "kctl-tui" // resolved via PATH; see README for install instructions
|
||||||
panelCmd := fmt.Sprintf("%s panel --ctx=%s --ns=%s --team=%s",
|
panelCmd := fmt.Sprintf("%s panel --context=%s --ns=%s --team=%s",
|
||||||
selfPath, m.selectedContext, m.selectedNamespace, m.selectedTeam)
|
selfPath, m.selectedContext, m.selectedNamespace, m.selectedTeam)
|
||||||
k9sCmdA := fmt.Sprintf("k9s --context %s -n %s", m.selectedContext, m.selectedNamespace)
|
|
||||||
|
|
||||||
secondCtx := m.selectedContext
|
envA := m.cfg.Envs[0]
|
||||||
if next, ok := findNextContext(m.selectedContext, m.cfg.ContextPairs); ok {
|
envB := m.cfg.Envs[0]
|
||||||
secondCtx = next
|
if len(m.cfg.Envs) > 1 {
|
||||||
|
envB = m.cfg.Envs[1]
|
||||||
}
|
}
|
||||||
k9sCmdB := fmt.Sprintf("k9s --context %s -n %s", secondCtx, m.selectedNamespace)
|
ctxA := m.cfg.ResolveContext(envA, m.selectedContext)
|
||||||
|
ctxB := m.cfg.ResolveContext(envB, m.selectedContext)
|
||||||
|
|
||||||
|
k9sCmdA := fmt.Sprintf("k9s --context %s -n %s", ctxA, m.selectedNamespace)
|
||||||
|
k9sCmdB := fmt.Sprintf("k9s --context %s -n %s", ctxB, m.selectedNamespace)
|
||||||
|
|
||||||
c := exec.Command("tmux", "new-session", "-d", "-s", "kctl",
|
c := exec.Command("tmux", "new-session", "-d", "-s", "kctl",
|
||||||
panelCmd, ";",
|
panelCmd, ";",
|
||||||
|
|||||||
@@ -10,10 +10,6 @@ func namespacesForLabelValue(namespaces map[string]map[string]string, labelKey,
|
|||||||
return kctl.NamespacesForLabelValue(namespaces, labelKey, value)
|
return kctl.NamespacesForLabelValue(namespaces, labelKey, value)
|
||||||
}
|
}
|
||||||
|
|
||||||
func findNextContext(current string, pairs []kctl.ContextPair) (string, bool) {
|
|
||||||
return kctl.FindNextContext(current, pairs)
|
|
||||||
}
|
|
||||||
|
|
||||||
func diffSecretValues(left, right map[string]string) []kctl.SecretDiffEntry {
|
func diffSecretValues(left, right map[string]string) []kctl.SecretDiffEntry {
|
||||||
return kctl.DiffSecretValues(left, right)
|
return kctl.DiffSecretValues(left, right)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user