// SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view import ( "context" "fmt" "log/slog" "regexp" "strings" "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/dao" "github.com/derailed/k9s/internal/model" "github.com/derailed/k9s/internal/render" "github.com/derailed/k9s/internal/slogs" "github.com/derailed/k9s/internal/ui" "github.com/derailed/k9s/internal/ui/dialog" "github.com/derailed/k9s/internal/view/cmd" "github.com/derailed/k9s/internal/xray" "github.com/derailed/tcell/v2" "github.com/derailed/tview" "github.com/sahilm/fuzzy" "golang.org/x/text/cases" "golang.org/x/text/language" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/util/sets" ) const xrayTitle = "Xray" var _ ResourceViewer = (*Xray)(nil) // Xray represents an xray tree view. type Xray struct { *ui.Tree app *App gvr *client.GVR meta *metav1.APIResource model *model.Tree cancelFn context.CancelFunc envFn EnvFunc } // NewXray returns a new view. func NewXray(gvr *client.GVR) ResourceViewer { return &Xray{ gvr: gvr, Tree: ui.NewTree(), model: model.NewTree(gvr), } } func (*Xray) SetCommand(*cmd.Interpreter) {} func (*Xray) SetFilter(string, bool) {} func (*Xray) SetLabelSelector(labels.Selector, bool) {} // Init initializes the view. func (x *Xray) Init(ctx context.Context) error { x.envFn = x.k9sEnv if err := x.Tree.Init(ctx); err != nil { return err } x.SetKeyListenerFn(x.keyEntered) var err error x.meta, err = dao.MetaAccess.MetaFor(x.gvr) if err != nil { return err } if x.app, err = extractApp(ctx); err != nil { return err } x.bindKeys() x.SetBackgroundColor(x.app.Styles.Xray().BgColor.Color()) x.SetBorderColor(x.app.Styles.Xray().FgColor.Color()) x.SetBorderFocusColor(x.app.Styles.Frame().Border.FocusColor.Color()) x.SetGraphicsColor(x.app.Styles.Xray().GraphicColor.Color()) x.SetTitle(fmt.Sprintf(" %s-%s ", xrayTitle, cases.Title(language.Und, cases.NoLower).String(x.gvr.R()))) x.model.SetRefreshRate(x.app.Config.K9s.RefreshDuration()) x.model.SetNamespace(client.CleanseNamespace(x.app.Config.ActiveNamespace())) x.model.AddListener(x) x.SetChangedFunc(func(n *tview.TreeNode) { spec, ok := n.GetReference().(xray.NodeSpec) if !ok { slog.Error("No ref found on node", slogs.FQN, n.GetText()) return } x.SetSelectedItem(spec.AsPath()) x.refreshActions() }) x.refreshActions() return nil } // InCmdMode checks if prompt is active. func (*Xray) InCmdMode() bool { return false } // ExtraHints returns additional hints. func (x *Xray) ExtraHints() map[string]string { if x.app.Config.K9s.UI.NoIcons { return nil } return xray.EmojiInfo() } // SetInstance sets specific resource instance. func (*Xray) SetInstance(string) {} func (x *Xray) bindKeys() { x.Actions().Bulk(ui.KeyMap{ ui.KeySlash: ui.NewSharedKeyAction("Filter Mode", x.activateCmd, false), tcell.KeyEscape: ui.NewSharedKeyAction("Filter Reset", x.resetCmd, false), tcell.KeyEnter: ui.NewKeyAction("Goto", x.gotoCmd, true), }) } func (x *Xray) keyEntered() { x.ClearSelection() x.update(x.filter(x.model.Peek())) } func (x *Xray) refreshActions() { aa := ui.NewKeyActions() defer func() { if err := pluginActions(x, aa); err != nil { slog.Warn("Plugins load failed", slogs.Error, err) } if err := hotKeyActions(x, aa); err != nil { slog.Warn("HotKeys load failed", slogs.Error, err) } x.Actions().Merge(aa) x.app.Menu().HydrateMenu(x.Hints()) }() x.Actions().Clear() x.bindKeys() x.BindKeys() spec := x.selectedSpec() if spec == nil { return } gvr := spec.GVR() var err error x.meta, err = dao.MetaAccess.MetaFor(gvr) if err != nil { slog.Warn("No meta found!", slogs.GVR, gvr, slogs.Error, err, ) return } if client.Can(x.meta.Verbs, "edit") { aa.Add(ui.KeyE, ui.NewKeyAction("Edit", x.editCmd, true)) } if client.Can(x.meta.Verbs, "delete") { aa.Add(tcell.KeyCtrlD, ui.NewKeyAction("Delete", x.deleteCmd, true)) } if !dao.IsK9sMeta(x.meta) { aa.Bulk(ui.KeyMap{ ui.KeyY: ui.NewKeyAction(yamlAction, x.viewCmd, true), ui.KeyD: ui.NewKeyAction("Describe", x.describeCmd, true), }) } switch gvr { case client.NsGVR: x.Actions().Delete(tcell.KeyEnter) case client.CoGVR: x.Actions().Delete(tcell.KeyEnter) aa.Bulk(ui.KeyMap{ ui.KeyS: ui.NewKeyAction("Shell", x.shellCmd, true), ui.KeyL: ui.NewKeyAction("Logs", x.logsCmd(false), true), ui.KeyP: ui.NewKeyAction("Logs Previous", x.logsCmd(true), true), }) case client.PodGVR: aa.Bulk(ui.KeyMap{ ui.KeyS: ui.NewKeyAction("Shell", x.shellCmd, true), ui.KeyA: ui.NewKeyAction("Attach", x.attachCmd, true), ui.KeyL: ui.NewKeyAction("Logs", x.logsCmd(false), true), ui.KeyP: ui.NewKeyAction("Logs Previous", x.logsCmd(true), true), }) } x.Actions().Merge(aa) } // GetSelectedPath returns the current selection as string. func (x *Xray) GetSelectedPath() string { spec := x.selectedSpec() if spec == nil { return "" } return spec.Path() } func (x *Xray) selectedSpec() *xray.NodeSpec { node := x.GetCurrentNode() if node == nil { return nil } ref, ok := node.GetReference().(xray.NodeSpec) if !ok { slog.Error("Expecting a NodeSpec", slogs.Path, node.GetText(), slogs.RefType, fmt.Sprintf("%T", node.GetReference()), ) return nil } return &ref } // EnvFn returns an plugin env function if available. func (x *Xray) EnvFn() EnvFunc { return x.envFn } func (x *Xray) k9sEnv() Env { env := k8sEnv(x.app.Conn().Config()) spec := x.selectedSpec() if spec == nil { return env } env["FILTER"] = x.CmdBuff().GetText() if env["FILTER"] == "" { ns, n := client.Namespaced(spec.Path()) env["NAMESPACE"], env["FILTER"] = ns, n } switch spec.GVR() { case client.CoGVR: _, co := client.Namespaced(spec.Path()) env["CONTAINER"] = co ns, n := client.Namespaced(*spec.ParentPath()) env["NAMESPACE"], env["POD"], env["NAME"] = ns, n, co default: ns, n := client.Namespaced(spec.Path()) env["NAMESPACE"], env["NAME"] = ns, n } return env } // Aliases returns all available aliases. func (x *Xray) Aliases() sets.Set[string] { return aliases(x.meta, x.app.command.AliasesFor(client.NewGVRFromMeta(x.meta))) } func (x *Xray) logsCmd(prev bool) func(evt *tcell.EventKey) *tcell.EventKey { return func(*tcell.EventKey) *tcell.EventKey { spec := x.selectedSpec() if spec == nil { return nil } x.showLogs(spec, prev) return nil } } func (x *Xray) showLogs(spec *xray.NodeSpec, prev bool) { // Need to load and wait for pods path, co := spec.Path(), "" if spec.GVR() == client.CoGVR { _, coName := client.Namespaced(spec.Path()) path, co = *spec.ParentPath(), coName } ns, _ := client.Namespaced(path) _, err := x.app.factory.CanForResource(ns, client.PodGVR, client.ListAccess) if err != nil { x.app.Flash().Err(err) return } opts := dao.LogOptions{ Path: path, Container: co, Previous: prev, } if err := x.app.inject(NewLog(client.PodGVR, &opts), false); err != nil { x.app.Flash().Err(err) } } func (x *Xray) shellCmd(*tcell.EventKey) *tcell.EventKey { spec := x.selectedSpec() if spec == nil { return nil } if spec.Status() != "ok" { x.app.Flash().Errf("%s is not in a running state", spec.Path()) return nil } path, co := spec.Path(), "" if spec.GVR() == client.CoGVR { _, co = client.Namespaced(spec.Path()) path = *spec.ParentPath() } if err := containerShellIn(x.app, x, path, co); err != nil { x.app.Flash().Err(err) } return nil } func (x *Xray) attachCmd(*tcell.EventKey) *tcell.EventKey { spec := x.selectedSpec() if spec == nil { return nil } if spec.Status() != "ok" { x.app.Flash().Errf("%s is not in a running state", spec.Path()) return nil } path, co := spec.Path(), "" if spec.GVR() == client.CoGVR { path = *spec.ParentPath() } if err := containerAttachIn(x.app, x, path, co); err != nil { x.app.Flash().Err(err) } return nil } func (x *Xray) viewCmd(evt *tcell.EventKey) *tcell.EventKey { spec := x.selectedSpec() if spec == nil { return evt } ctx := x.defaultContext() raw, err := x.model.ToYAML(ctx, spec.GVR(), spec.Path()) if err != nil { x.App().Flash().Errf("unable to get resource %q -- %s", spec.GVR(), err) return nil } details := NewDetails(x.app, yamlAction, spec.Path(), contentYAML, true).Update(raw) if err := x.app.inject(details, false); err != nil { x.app.Flash().Err(err) } return nil } func (x *Xray) deleteCmd(evt *tcell.EventKey) *tcell.EventKey { spec := x.selectedSpec() if spec == nil { return evt } x.Stop() defer x.Start() { meta, err := dao.MetaAccess.MetaFor(spec.GVR()) if err != nil { slog.Warn("No meta found!", slogs.GVR, spec.GVR(), slogs.Error, err, ) return nil } x.resourceDelete(spec.GVR(), spec, fmt.Sprintf("Delete %s %s?", meta.SingularName, spec.Path())) } return nil } func (x *Xray) describeCmd(evt *tcell.EventKey) *tcell.EventKey { spec := x.selectedSpec() if spec == nil { return evt } x.describe(spec.GVR(), spec.Path()) return nil } func (x *Xray) describe(gvr *client.GVR, path string) { ctx := context.Background() ctx = context.WithValue(ctx, internal.KeyFactory, x.app.factory) yaml, err := x.model.Describe(ctx, gvr, path) if err != nil { x.app.Flash().Errf("Describe command failed: %s", err) return } details := NewDetails(x.app, "Describe", path, contentYAML, true).Update(yaml) if err := x.app.inject(details, false); err != nil { x.app.Flash().Err(err) } } func (x *Xray) editCmd(evt *tcell.EventKey) *tcell.EventKey { spec := x.selectedSpec() if spec == nil { return evt } x.Stop() defer x.Start() { ns, n := client.Namespaced(spec.Path()) args := make([]string, 0, 10) args = append(args, "edit", spec.GVR().R(), "-n", ns, "--context", x.app.Config.K9s.ActiveContextName(), ) if cfg := x.app.Conn().Config().Flags().KubeConfig; cfg != nil && *cfg != "" { args = append(args, "--kubeconfig", *cfg) } if err := runK(x.app, &shellOpts{args: append(args, n)}); err != nil { x.app.Flash().Errf("Edit exec failed: %s", err) } } return evt } func (x *Xray) activateCmd(evt *tcell.EventKey) *tcell.EventKey { if x.app.InCmdMode() { return evt } x.app.ResetPrompt(x.CmdBuff()) return nil } func (x *Xray) resetCmd(evt *tcell.EventKey) *tcell.EventKey { if !x.CmdBuff().InCmdMode() { x.CmdBuff().Reset() return x.app.PrevCmd(evt) } x.CmdBuff().Reset() x.model.ClearFilter() x.Start() return nil } func (x *Xray) gotoCmd(*tcell.EventKey) *tcell.EventKey { if x.CmdBuff().IsActive() { if internal.IsLabelSelector(x.CmdBuff().GetText()) { x.Start() } x.CmdBuff().SetActive(false) x.GetRoot().ExpandAll() return nil } spec := x.selectedSpec() if spec == nil { return nil } if len(strings.Split(spec.Path(), "/")) == 1 { return nil } x.app.gotoResource(spec.GVR().String(), spec.Path(), false, true) return nil } func (x *Xray) filter(root *xray.TreeNode) *xray.TreeNode { q := x.CmdBuff().GetText() if x.CmdBuff().Empty() || internal.IsLabelSelector(q) { return root } x.UpdateTitle() if f, ok := internal.IsFuzzySelector(q); ok { return root.Filter(f, fuzzyFilter) } if internal.IsInverseSelector(q) { return root.Filter(q, rxInverseFilter) } return root.Filter(q, rxFilter) } // TreeNodeSelected callback for node selection. func (x *Xray) TreeNodeSelected() { x.app.QueueUpdateDraw(func() { n := x.GetCurrentNode() if n != nil { n.SetColor(x.app.Styles.Xray().CursorColor.Color()) } }) } // TreeLoadFailed notifies the load failed. func (x *Xray) TreeLoadFailed(err error) { x.app.Flash().Err(err) } func (x *Xray) update(node *xray.TreeNode) { root := makeTreeNode(node, x.ExpandNodes(), x.app.Config.K9s.UI.NoIcons, x.app.Styles) if node == nil { x.app.QueueUpdateDraw(func() { x.SetRoot(root) }) return } for _, c := range node.Children { x.hydrate(root, c) } if x.GetSelectedItem() == "" { x.SetSelectedItem(node.Spec().Path()) } x.app.QueueUpdateDraw(func() { x.SetRoot(root) root.Walk(func(node, parent *tview.TreeNode) bool { spec, ok := node.GetReference().(xray.NodeSpec) if !ok { slog.Error("Expecting a NodeSpec", slogs.FQN, node.GetText(), slogs.RefType, fmt.Sprintf("%T", node.GetReference()), ) return false } // BOZO!! Figure this out expand/collapse but the root if parent != nil { node.SetExpanded(x.ExpandNodes()) } else { node.SetExpanded(true) } if spec.AsPath() == x.GetSelectedItem() { node.SetExpanded(true).SetSelectable(true) x.SetCurrentNode(node) } return true }) }) } // TreeChanged notifies the model data changed. func (x *Xray) TreeChanged(node *xray.TreeNode) { x.Count = node.Count(x.gvr) x.update(x.filter(node)) x.UpdateTitle() } func (x *Xray) hydrate(parent *tview.TreeNode, n *xray.TreeNode) { node := makeTreeNode(n, x.ExpandNodes(), x.app.Config.K9s.UI.NoIcons, x.app.Styles) for _, c := range n.Children { x.hydrate(node, c) } parent.AddChild(node) } // SetEnvFn sets the custom environment function. func (*Xray) SetEnvFn(EnvFunc) {} // Refresh updates the view. func (*Xray) Refresh() {} // BufferCompleted indicates the buffer was changed. func (x *Xray) BufferCompleted(_, _ string) { x.update(x.filter(x.model.Peek())) } // BufferChanged indicates the buffer was changed. func (*Xray) BufferChanged(_, _ string) {} // BufferActive indicates the buff activity changed. func (x *Xray) BufferActive(state bool, k model.BufferKind) { x.app.BufferActive(state, k) } func (x *Xray) defaultContext() context.Context { ctx := context.WithValue(context.Background(), internal.KeyFactory, x.app.factory) ctx = context.WithValue(ctx, internal.KeyFields, "") if x.CmdBuff().Empty() { ctx = context.WithValue(ctx, internal.KeyLabels, labels.Everything()) } else { if sel, err := ui.ExtractLabelSelector(x.CmdBuff().GetText()); err == nil { ctx = context.WithValue(ctx, internal.KeyLabels, sel) } } return ctx } // Start initializes resource watch loop. func (x *Xray) Start() { x.Stop() x.CmdBuff().AddListener(x) ctx := x.defaultContext() ctx, x.cancelFn = context.WithCancel(ctx) x.model.Watch(ctx) x.UpdateTitle() } // Stop terminates watch loop. func (x *Xray) Stop() { if x.cancelFn == nil { return } x.cancelFn() x.cancelFn = nil x.CmdBuff().RemoveListener(x) } // AddBindKeysFn sets up extra key bindings. func (*Xray) AddBindKeysFn(BindKeysFunc) {} // SetContextFn sets custom context. func (*Xray) SetContextFn(ContextFunc) {} // Name returns the component name. func (*Xray) Name() string { return "XRay" } // GetTable returns the underlying table. func (*Xray) GetTable() *Table { return nil } // GVR returns a resource descriptor. func (x *Xray) GVR() *client.GVR { return x.gvr } // App returns the current app handle. func (x *Xray) App() *App { return x.app } // UpdateTitle updates the view title. func (x *Xray) UpdateTitle() { t := x.styleTitle() x.app.QueueUpdateDraw(func() { x.SetTitle(t) }) } func (x *Xray) styleTitle() string { base := fmt.Sprintf("%s-%s", xrayTitle, cases.Title(language.Und, cases.NoLower).String(x.gvr.R())) ns := x.model.GetNamespace() if client.IsAllNamespaces(ns) { ns = client.NamespaceAll } var ( title string styles = x.app.Styles.Frame() ) if ns == client.ClusterScope { title = ui.SkinTitle(fmt.Sprintf(ui.TitleFmt, base, render.AsThousands(int64(x.Count))), &styles) } else { title = ui.SkinTitle(fmt.Sprintf(ui.NSTitleFmt, base, ns, render.AsThousands(int64(x.Count))), &styles) } buff := x.CmdBuff().GetText() if buff == "" { return title } if internal.IsLabelSelector(buff) { if sel, err := ui.ExtractLabelSelector(buff); err == nil { buff = sel.String() } } return title + ui.SkinTitle(fmt.Sprintf(ui.SearchFmt, buff), &styles) } func (x *Xray) resourceDelete(gvr *client.GVR, spec *xray.NodeSpec, msg string) { d := x.app.Styles.Dialog() dialog.ShowDelete(&d, x.app.Content.Pages, msg, func(_ *metav1.DeletionPropagation, force bool) { x.app.Flash().Infof("Delete resource %s %s", spec.GVR(), spec.Path()) accessor, err := dao.AccessorFor(x.app.factory, gvr) if err != nil { slog.Error("No accessor found", slogs.GVR, gvr, slogs.Error, err, ) return } nuker, ok := accessor.(dao.Nuker) if !ok { x.app.Flash().Errf("Invalid nuker %T", accessor) return } grace := dao.DefaultGrace if force { grace = dao.ForceGrace } if err := nuker.Delete(context.Background(), spec.Path(), nil, grace); err != nil { x.app.Flash().Errf("Delete failed with `%s", err) } else { x.app.Flash().Infof("%s `%s deleted successfully", x.GVR(), spec.Path()) x.app.factory.DeleteForwarder(spec.Path()) } x.Refresh() }, func() {}) } // ---------------------------------------------------------------------------- // Helpers... func fuzzyFilter(q, path string) bool { q = strings.TrimSpace(q[2:]) mm := fuzzy.Find(q, []string{path}) return len(mm) > 0 } func rxFilter(q, path string) bool { rx := regexp.MustCompile(`(?i)` + q) tokens := strings.Split(path, xray.PathSeparator) for _, t := range tokens { if rx.MatchString(t) { return true } } return false } func rxInverseFilter(q, path string) bool { q = strings.TrimSpace(q[1:]) rx := regexp.MustCompile(`(?i)` + q) tokens := strings.Split(path, xray.PathSeparator) for _, t := range tokens { if rx.MatchString(t) { return false } } return true } func makeTreeNode(node *xray.TreeNode, expanded, showIcons bool, styles *config.Styles) *tview.TreeNode { n := tview.NewTreeNode("No data...") if node != nil { n.SetText(node.Title(showIcons)) n.SetReference(node.Spec()) } n.SetSelectable(true) n.SetExpanded(expanded) n.SetColor(styles.Xray().CursorColor.Color()) n.SetSelectedFunc(func() { n.SetExpanded(!n.IsExpanded()) }) return n }