From f802d3948a40821fbf5cdf20aae4be2ca5248504 Mon Sep 17 00:00:00 2001 From: Jayson Wang Date: Sat, 18 May 2024 22:49:41 +0800 Subject: [PATCH] allow jumping to the owner of the resource (#2700) --- internal/dao/registry.go | 15 ++++ internal/ui/dialog/selection.go | 30 ++++++++ internal/ui/modal_list.go | 109 +++++++++++++++++++++++++++++ internal/ui/pages.go | 3 +- internal/view/help_test.go | 2 +- internal/view/job.go | 4 +- internal/view/owner_extender.go | 118 ++++++++++++++++++++++++++++++++ internal/view/pod.go | 8 ++- internal/view/pod_test.go | 2 +- internal/view/rs.go | 6 +- 10 files changed, 288 insertions(+), 9 deletions(-) create mode 100644 internal/ui/dialog/selection.go create mode 100644 internal/ui/modal_list.go create mode 100644 internal/view/owner_extender.go diff --git a/internal/dao/registry.go b/internal/dao/registry.go index e19ad58600..047a0d9f5b 100644 --- a/internal/dao/registry.go +++ b/internal/dao/registry.go @@ -15,6 +15,7 @@ import ( "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" ) const ( @@ -123,6 +124,20 @@ func (m *Meta) AllGVRs() client.GVRs { return kk } +// GVK2GVR convert gvk to gvr +func (m *Meta) GVK2GVR(gv schema.GroupVersion, kind string) (client.GVR, bool) { + m.mx.RLock() + defer m.mx.RUnlock() + + for gvr, meta := range m.resMetas { + if gv.Group == meta.Group && gv.Version == meta.Version && kind == meta.Kind { + return gvr, true + } + } + + return client.NoGVR, false +} + // IsCRD checks if resource represents a CRD func IsCRD(r metav1.APIResource) bool { for _, c := range r.Categories { diff --git a/internal/ui/dialog/selection.go b/internal/ui/dialog/selection.go new file mode 100644 index 0000000000..fc6e545cca --- /dev/null +++ b/internal/ui/dialog/selection.go @@ -0,0 +1,30 @@ +package dialog + +import ( + "github.com/derailed/k9s/internal/config" + "github.com/derailed/k9s/internal/ui" + "github.com/derailed/tview" +) + +type SelectAction func(index int) + +func ShowSelection(styles config.Dialog, pages *ui.Pages, title string, options []string, action SelectAction) { + list := tview.NewList() + list.ShowSecondaryText(false) + list.SetSelectedTextColor(styles.ButtonFocusFgColor.Color()) + list.SetSelectedBackgroundColor(styles.ButtonFocusBgColor.Color()) + + for _, option := range options { + list.AddItem(option, "", 0, nil) + list.AddItem(option, "", 0, nil) + } + + modal := ui.NewModalList("<"+title+">", list) + modal.SetDoneFunc(func(i int, s string) { + dismiss(pages) + action(i) + }) + + pages.AddPage(dialogKey, modal, false, false) + pages.ShowPage(dialogKey) +} diff --git a/internal/ui/modal_list.go b/internal/ui/modal_list.go new file mode 100644 index 0000000000..1827561a36 --- /dev/null +++ b/internal/ui/modal_list.go @@ -0,0 +1,109 @@ +package ui + +import ( + "github.com/derailed/tcell/v2" + "github.com/derailed/tview" +) + +type ModalList struct { + *tview.Box + + // The list embedded in the modal's frame. + list *tview.List + + // The frame embedded in the modal. + frame *tview.Frame + + // The optional callback for when the user clicked one of the items. It + // receives the index of the clicked item and the item's text. + done func(int, string) +} + +func NewModalList(title string, list *tview.List) *ModalList { + m := &ModalList{Box: tview.NewBox()} + + m.list = list + m.list.SetBackgroundColor(tview.Styles.ContrastBackgroundColor).SetBorderPadding(0, 0, 0, 0) + m.list.SetSelectedFunc(func(i int, main string, _ string, _ rune) { + if m.done != nil { + m.done(i, main) + } + }) + m.list.SetDoneFunc(func() { + if m.done != nil { + m.done(-1, "") + } + }) + + m.frame = tview.NewFrame(m.list).SetBorders(0, 0, 1, 0, 0, 0) + m.frame.SetBorder(true). + SetBackgroundColor(tview.Styles.ContrastBackgroundColor). + SetBorderPadding(1, 1, 1, 1) + m.frame.SetTitle(title) + m.frame.SetTitleColor(tcell.ColorAqua) + + return m +} + +// Draw draws this primitive onto the screen. +func (m *ModalList) Draw(screen tcell.Screen) { + // Calculate the width of this modal. + width := 0 + for i := 0; i < m.list.GetItemCount(); i++ { + main, secondary := m.list.GetItemText(i) + width = max(width, len(main)+len(secondary)+2) + } + + screenWidth, screenHeight := screen.Size() + + // Set the modal's position and size. + height := m.list.GetItemCount() + 4 + width += 2 + x := (screenWidth - width) / 2 + y := (screenHeight - height) / 2 + m.SetRect(x, y, width, height) + + // Draw the frame. + m.frame.SetRect(x, y, width, height) + m.frame.Draw(screen) +} + +func (m *ModalList) SetDoneFunc(handler func(int, string)) *ModalList { + m.done = handler + return m +} + +// Focus is called when this primitive receives focus. +func (m *ModalList) Focus(delegate func(p tview.Primitive)) { + delegate(m.list) +} + +// HasFocus returns whether this primitive has focus. +func (m *ModalList) HasFocus() bool { + return m.list.HasFocus() +} + +// MouseHandler returns the mouse handler for this primitive. +func (m *ModalList) MouseHandler() func(action tview.MouseAction, event *tcell.EventMouse, setFocus func(p tview.Primitive)) (consumed bool, capture tview.Primitive) { + return m.WrapMouseHandler(func(action tview.MouseAction, event *tcell.EventMouse, setFocus func(p tview.Primitive)) (consumed bool, capture tview.Primitive) { + // Pass mouse events on to the form. + consumed, capture = m.list.MouseHandler()(action, event, setFocus) + if !consumed && action == tview.MouseLeftClick && m.InRect(event.Position()) { + setFocus(m) + consumed = true + } + return + }) +} + +// InputHandler returns the handler for this primitive. +func (m *ModalList) InputHandler() func(event *tcell.EventKey, setFocus func(p tview.Primitive)) { + return m.WrapInputHandler(func(event *tcell.EventKey, setFocus func(p tview.Primitive)) { + if m.frame.HasFocus() { + if handler := m.frame.InputHandler(); handler != nil { + handler(event, setFocus) + return + } + } + }) +} diff --git a/internal/ui/pages.go b/internal/ui/pages.go index 446457156c..b5864e3bec 100644 --- a/internal/ui/pages.go +++ b/internal/ui/pages.go @@ -5,7 +5,6 @@ package ui import ( "fmt" - "github.com/derailed/k9s/internal/model" "github.com/derailed/tview" "github.com/rs/zerolog/log" @@ -32,7 +31,7 @@ func NewPages() *Pages { func (p *Pages) IsTopDialog() bool { _, pa := p.GetFrontPage() switch pa.(type) { - case *tview.ModalForm: + case *tview.ModalForm, *ModalList: return true default: return false diff --git a/internal/view/help_test.go b/internal/view/help_test.go index f45a2cf839..b6f19c7831 100644 --- a/internal/view/help_test.go +++ b/internal/view/help_test.go @@ -24,7 +24,7 @@ func TestHelp(t *testing.T) { v := view.NewHelp(app) assert.Nil(t, v.Init(ctx)) - assert.Equal(t, 28, v.GetRowCount()) + assert.Equal(t, 29, v.GetRowCount()) assert.Equal(t, 8, v.GetColumnCount()) assert.Equal(t, "", strings.TrimSpace(v.GetCell(1, 0).Text)) assert.Equal(t, "Attach", strings.TrimSpace(v.GetCell(1, 1).Text)) diff --git a/internal/view/job.go b/internal/view/job.go index e414e3e5c9..c456227392 100644 --- a/internal/view/job.go +++ b/internal/view/job.go @@ -25,7 +25,9 @@ func NewJob(gvr client.GVR) ResourceViewer { var j Job j.ResourceViewer = NewVulnerabilityExtender( - NewLogsExtender(NewBrowser(gvr), j.logOptions), + NewOwnerExtender( + NewLogsExtender(NewBrowser(gvr), j.logOptions), + ), ) j.GetTable().SetEnterFn(j.showPods) j.GetTable().SetSortCol("AGE", true) diff --git a/internal/view/owner_extender.go b/internal/view/owner_extender.go new file mode 100644 index 0000000000..5fcc3129a0 --- /dev/null +++ b/internal/view/owner_extender.go @@ -0,0 +1,118 @@ +package view + +import ( + "context" + "fmt" + "github.com/derailed/k9s/internal/ui/dialog" + "github.com/rs/zerolog/log" + + "github.com/derailed/tcell/v2" + "github.com/go-errors/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + + "github.com/derailed/k9s/internal" + "github.com/derailed/k9s/internal/client" + "github.com/derailed/k9s/internal/dao" + "github.com/derailed/k9s/internal/render" + "github.com/derailed/k9s/internal/ui" +) + +// OwnerExtender adds owner actions to a given viewer. +type OwnerExtender struct { + ResourceViewer +} + +// NewOwnerExtender returns a new extender. +func NewOwnerExtender(r ResourceViewer) ResourceViewer { + v := &OwnerExtender{ResourceViewer: r} + v.AddBindKeysFn(v.bindKeys) + + return v +} + +func (v *OwnerExtender) bindKeys(aa *ui.KeyActions) { + aa.Add(ui.KeyShiftJ, ui.NewKeyAction("Jump Owner", v.ownerCmd, true)) +} + +func (v *OwnerExtender) ownerCmd(evt *tcell.EventKey) *tcell.EventKey { + path := v.GetTable().GetSelectedItem() + if path == "" { + return evt + } + + if err := v.findOwnerFor(path); err != nil { + log.Warn().Msgf("Unable to jump to the owner of resource %q: %s", path, err) + v.App().Flash().Warnf("Unable to jump owner: %s", err) + } + return nil +} + +func (v *OwnerExtender) findOwnerFor(path string) error { + res, err := dao.AccessorFor(v.App().factory, v.GVR()) + if err != nil { + return err + } + + o, err := res.Get(v.defaultCtx(), path) + if err != nil { + return err + } + + u, ok := v.asUnstructuredObject(o) + if !ok { + return errors.Errorf("unsupported object type: %t", o) + } + + ns, _ := client.Namespaced(path) + ownerReferences := u.GetOwnerReferences() + if len(ownerReferences) == 1 { + return v.jumpOwner(ns, ownerReferences[0]) + } else if len(ownerReferences) > 1 { + owners := make([]string, 0, len(ownerReferences)) + for idx, ownerRef := range ownerReferences { + owners = append(owners, fmt.Sprintf("%d: %s", idx, ownerRef.Kind)) + } + + dialog.ShowSelection(v.App().Styles.Dialog(), v.App().Content.Pages, "Jump To", owners, func(index int) { + if index >= 0 { + err = v.jumpOwner(ns, ownerReferences[index]) + } + }) + return err + } + + return errors.Errorf("no owner found") +} + +func (v *OwnerExtender) jumpOwner(ns string, owner metav1.OwnerReference) error { + gv, err := schema.ParseGroupVersion(owner.APIVersion) + if err != nil { + return err + } + + gvr, found := dao.MetaAccess.GVK2GVR(gv, owner.Kind) + if !found { + return errors.Errorf("unsupported GVK: %s/%s", owner.APIVersion, owner.Kind) + } + + v.App().gotoResource(gvr.String(), client.FQN(ns, owner.Name), false) + return nil +} + +func (v *OwnerExtender) defaultCtx() context.Context { + return context.WithValue(context.Background(), internal.KeyFactory, v.App().factory) +} + +func (v *OwnerExtender) asUnstructuredObject(o runtime.Object) (*unstructured.Unstructured, bool) { + switch v := o.(type) { + case *unstructured.Unstructured: + return v, true + case *render.PodWithMetrics: + return v.Raw, true + default: + return nil, false + } +} diff --git a/internal/view/pod.go b/internal/view/pod.go index 418029e542..75e5a2fb04 100644 --- a/internal/view/pod.go +++ b/internal/view/pod.go @@ -50,9 +50,11 @@ type Pod struct { func NewPod(gvr client.GVR) ResourceViewer { var p Pod p.ResourceViewer = NewPortForwardExtender( - NewVulnerabilityExtender( - NewImageExtender( - NewLogsExtender(NewBrowser(gvr), p.logOptions), + NewOwnerExtender( + NewVulnerabilityExtender( + NewImageExtender( + NewLogsExtender(NewBrowser(gvr), p.logOptions), + ), ), ), ) diff --git a/internal/view/pod_test.go b/internal/view/pod_test.go index bbbf0378b9..23bdebaf74 100644 --- a/internal/view/pod_test.go +++ b/internal/view/pod_test.go @@ -19,7 +19,7 @@ func TestPodNew(t *testing.T) { assert.Nil(t, po.Init(makeCtx())) assert.Equal(t, "Pods", po.Name()) - assert.Equal(t, 27, len(po.Hints())) + assert.Equal(t, 28, len(po.Hints())) } // Helpers... diff --git a/internal/view/rs.go b/internal/view/rs.go index 86e2c5956b..07178563a1 100644 --- a/internal/view/rs.go +++ b/internal/view/rs.go @@ -21,7 +21,11 @@ type ReplicaSet struct { // NewReplicaSet returns a new viewer. func NewReplicaSet(gvr client.GVR) ResourceViewer { r := ReplicaSet{ - ResourceViewer: NewVulnerabilityExtender(NewBrowser(gvr)), + ResourceViewer: NewOwnerExtender( + NewVulnerabilityExtender( + NewBrowser(gvr), + ), + ), } r.AddBindKeysFn(r.bindKeys) r.GetTable().SetEnterFn(r.showPods)