k9s/internal/view/resource.go

514 lines
12 KiB
Go

package view
import (
"context"
"fmt"
"strconv"
"strings"
"time"
"github.com/atotto/clipboard"
"github.com/derailed/k9s/internal/config"
"github.com/derailed/k9s/internal/model"
"github.com/derailed/k9s/internal/resource"
"github.com/derailed/k9s/internal/ui"
"github.com/derailed/k9s/internal/ui/dialog"
"github.com/gdamore/tcell"
"github.com/rs/zerolog/log"
)
// EnvFn represent the current view exposed environment.
type envFn func() K9sEnv
// Resource represents a generic resource viewer.
type Resource struct {
*MasterDetail
namespaces map[int]string
list resource.List
cancelFn context.CancelFunc
path *string
colorerFn ui.ColorerFunc
decorateFn decorateFn
envFn envFn
gvr string
currentNS string
}
// NewResource returns a new viewer.
func NewResource(title, gvr string, list resource.List) *Resource {
return &Resource{
MasterDetail: NewMasterDetail(),
list: list,
gvr: gvr,
}
}
// Init watches all running pods in given namespace
func (r *Resource) Init(ctx context.Context) {
r.MasterDetail.Init(ctx)
r.envFn = r.defaultK9sEnv
table := r.masterPage()
table.setFilterFn(r.filterResource)
colorer := ui.DefaultColorer
if r.colorerFn != nil {
colorer = r.colorerFn
}
table.SetColorerFn(colorer)
row, _ := table.GetSelection()
if row == 0 && table.GetRowCount() > 0 {
table.Select(1, 0)
}
r.DumpPages()
r.refresh()
}
// Start initializes updates.
func (r *Resource) Start() {
r.Stop()
var ctx context.Context
ctx, r.cancelFn = context.WithCancel(context.Background())
r.update(ctx)
}
// Stop terminates updates.
func (r *Resource) Stop() {
if r.cancelFn != nil {
r.cancelFn()
}
}
// Name returns the component name.
func (r *Resource) Name() string {
return r.list.GetName()
}
// Hints returns the current viewer hints
func (r *Resource) Hints() model.MenuHints {
if r.CurrentPage() == nil {
return nil
}
if c, ok := r.CurrentPage().Item.(model.Hinter); ok {
return c.Hints()
}
return nil
}
func (r *Resource) setColorerFn(f ui.ColorerFunc) {
r.colorerFn = f
}
func (r *Resource) setDecorateFn(f decorateFn) {
r.decorateFn = f
}
func (r *Resource) filterResource(sel string) {
r.list.SetLabelSelector(sel)
r.refresh()
}
func (r *Resource) update(ctx context.Context) {
go func(ctx context.Context) {
for {
select {
case <-ctx.Done():
log.Debug().Msgf("%s updater canceled!", r.list.GetName())
return
case <-time.After(time.Duration(r.app.Config.K9s.GetRefreshRate()) * time.Second):
r.app.QueueUpdateDraw(func() {
r.refresh()
})
}
}
}(ctx)
}
func (r *Resource) backCmd(*tcell.EventKey) *tcell.EventKey {
r.switchPage("master")
return nil
}
func (r *Resource) switchPage(p string) {
log.Debug().Msgf("Switching page to %s", p)
if _, ok := r.CurrentPage().Item.(*Table); ok {
r.Stop()
}
r.SwitchToPage(p)
if _, ok := r.CurrentPage().Item.(*Table); ok {
r.Start()
}
}
// ----------------------------------------------------------------------------
// Actions...
func (r *Resource) cpCmd(evt *tcell.EventKey) *tcell.EventKey {
if !r.masterPage().RowSelected() {
return evt
}
_, n := namespaced(r.masterPage().GetSelectedItem())
log.Debug().Msgf("Copied selection to clipboard %q", n)
r.app.Flash().Info("Current selection copied to clipboard...")
if err := clipboard.WriteAll(n); err != nil {
r.app.Flash().Err(err)
}
return nil
}
func (r *Resource) enterCmd(evt *tcell.EventKey) *tcell.EventKey {
// If in command mode run filter otherwise enter function.
if r.masterPage().filterCmd(evt) == nil || !r.masterPage().RowSelected() {
return nil
}
f := r.defaultEnter
if r.enterFn != nil {
f = r.enterFn
}
f(r.app, r.list.GetNamespace(), r.list.GetName(), r.masterPage().GetSelectedItem())
return nil
}
func (r *Resource) refreshCmd(*tcell.EventKey) *tcell.EventKey {
r.app.Flash().Info("Refreshing...")
r.refresh()
return nil
}
func (r *Resource) deleteCmd(evt *tcell.EventKey) *tcell.EventKey {
if !r.masterPage().RowSelected() {
return evt
}
sel := r.masterPage().GetSelectedItems()
var msg string
if len(sel) > 1 {
msg = fmt.Sprintf("Delete %d selected %s?", len(sel), r.list.GetName())
} else {
msg = fmt.Sprintf("Delete %s %s?", r.list.GetName(), sel[0])
}
dialog.ShowDelete(r.Pages, msg, func(cascade, force bool) {
r.masterPage().ShowDeleted()
if len(sel) > 1 {
r.app.Flash().Infof("Delete %d selected %s", len(sel), r.list.GetName())
} else {
r.app.Flash().Infof("Delete resource %s %s", r.list.GetName(), sel[0])
}
for _, res := range sel {
if err := r.list.Resource().Delete(res, cascade, force); err != nil {
r.app.Flash().Errf("Delete failed with %s", err)
} else {
deletePortForward(r.app.forwarders, res)
}
}
r.refresh()
}, func() {
r.switchPage("master")
})
return nil
}
func (r *Resource) markCmd(evt *tcell.EventKey) *tcell.EventKey {
if !r.masterPage().RowSelected() {
return evt
}
r.masterPage().ToggleMark()
r.refresh()
r.app.Draw()
return nil
}
func deletePortForward(ff map[string]forwarder, sel string) {
for k, f := range ff {
tokens := strings.Split(k, ":")
if tokens[0] == sel {
log.Debug().Msgf("Deleting associated portForward %s", k)
f.Stop()
}
}
}
func (r *Resource) defaultEnter(app *App, ns, _, selection string) {
if !r.list.Access(resource.DescribeAccess) {
return
}
yaml, err := r.list.Resource().Describe(r.gvr, selection)
if err != nil {
r.app.Flash().Errf("Describe command failed: %s", err)
return
}
details := r.detailsPage()
details.setCategory("Describe")
details.setTitle(selection)
details.SetTextColor(r.app.Styles.FgColor())
details.SetText(colorizeYAML(r.app.Styles.Views().Yaml, yaml))
details.ScrollToBeginning()
r.switchPage("details")
}
func (r *Resource) describeCmd(evt *tcell.EventKey) *tcell.EventKey {
if !r.masterPage().RowSelected() {
return evt
}
r.defaultEnter(r.app, r.list.GetNamespace(), r.list.GetName(), r.masterPage().GetSelectedItem())
return nil
}
func (r *Resource) viewCmd(evt *tcell.EventKey) *tcell.EventKey {
if !r.masterPage().RowSelected() {
return evt
}
sel := r.masterPage().GetSelectedItem()
raw, err := r.list.Resource().Marshal(sel)
if err != nil {
r.app.Flash().Errf("Unable to marshal resource %s", err)
return evt
}
details := r.detailsPage()
details.setCategory("YAML")
details.setTitle(sel)
details.SetTextColor(r.app.Styles.FgColor())
details.SetText(colorizeYAML(r.app.Styles.Views().Yaml, raw))
details.ScrollToBeginning()
r.app.Content.Push(details)
return nil
}
func (r *Resource) editCmd(evt *tcell.EventKey) *tcell.EventKey {
if !r.masterPage().RowSelected() {
return evt
}
r.Stop()
{
ns, po := namespaced(r.masterPage().GetSelectedItem())
args := make([]string, 0, 10)
args = append(args, "edit")
args = append(args, r.list.GetName())
args = append(args, "-n", ns)
args = append(args, "--context", r.app.Config.K9s.CurrentContext)
if cfg := r.app.Conn().Config().Flags().KubeConfig; cfg != nil && *cfg != "" {
args = append(args, "--kubeconfig", *cfg)
}
runK(true, r.app, append(args, po)...)
}
r.Start()
return evt
}
func (r *Resource) setNamespace(ns string) {
if r.list.Namespaced() {
r.list.SetNamespace(ns)
}
}
func (r *Resource) switchNamespaceCmd(evt *tcell.EventKey) *tcell.EventKey {
i, _ := strconv.Atoi(string(evt.Rune()))
ns := r.namespaces[i]
if ns == "" {
ns = resource.AllNamespace
}
if r.currentNS == ns {
return nil
}
r.app.switchNS(ns)
r.setNamespace(ns)
r.app.Flash().Infof("Viewing namespace `%s`...", ns)
r.refresh()
r.masterPage().UpdateTitle()
r.masterPage().SelectRow(1, true)
r.app.CmdBuff().Reset()
if err := r.app.Config.SetActiveNamespace(r.currentNS); err != nil {
log.Error().Err(err).Msg("Config save failed!")
}
r.app.Config.Save()
return nil
}
func (r *Resource) refresh() {
if r.CurrentPage() == nil {
return
}
if _, ok := r.CurrentPage().Item.(*Table); !ok {
return
}
r.refreshActions()
if r.list.Namespaced() {
r.list.SetNamespace(r.currentNS)
}
if err := r.list.Reconcile(r.app.informer, r.path); err != nil {
r.app.Flash().Err(err)
}
data := r.list.Data()
if r.decorateFn != nil {
data = r.decorateFn(data)
}
r.masterPage().Update(data)
}
func (r *Resource) namespaceActions(aa ui.KeyActions) {
if !r.list.Access(resource.NamespaceAccess) {
return
}
r.namespaces = make(map[int]string, config.MaxFavoritesNS)
// User can't list namespace. Don't offer a choice.
if r.app.Conn() == nil || r.app.Conn().CheckListNSAccess() != nil {
return
}
aa[tcell.Key(ui.NumKeys[0])] = ui.NewKeyAction(resource.AllNamespace, r.switchNamespaceCmd, true)
r.namespaces[0] = resource.AllNamespace
index := 1
for _, n := range r.app.Config.FavNamespaces() {
if n == resource.AllNamespace {
continue
}
aa[tcell.Key(ui.NumKeys[index])] = ui.NewKeyAction(n, r.switchNamespaceCmd, true)
r.namespaces[index] = n
index++
}
}
func (r *Resource) refreshActions() {
aa := ui.KeyActions{
ui.KeyC: ui.NewKeyAction("Copy", r.cpCmd, false),
tcell.KeyEnter: ui.NewKeyAction("Enter", r.enterCmd, false),
tcell.KeyCtrlR: ui.NewKeyAction("Refresh", r.refreshCmd, false),
}
aa[ui.KeySpace] = ui.NewKeyAction("Mark", r.markCmd, true)
r.namespaceActions(aa)
r.defaultActions(aa)
if r.list.Access(resource.EditAccess) {
aa[ui.KeyE] = ui.NewKeyAction("Edit", r.editCmd, true)
}
if r.list.Access(resource.DeleteAccess) {
aa[tcell.KeyCtrlD] = ui.NewKeyAction("Delete", r.deleteCmd, true)
}
if r.list.Access(resource.ViewAccess) {
aa[ui.KeyY] = ui.NewKeyAction("YAML", r.viewCmd, true)
}
if r.list.Access(resource.DescribeAccess) {
aa[ui.KeyD] = ui.NewKeyAction("Describe", r.describeCmd, true)
}
r.customActions(aa)
t := r.masterPage()
t.AddActions(aa)
}
func (r *Resource) customActions(aa ui.KeyActions) {
pp := config.NewPlugins()
if err := pp.Load(); err != nil {
log.Warn().Msgf("No plugin configuration found")
return
}
for k, plugin := range pp.Plugin {
if !in(plugin.Scopes, r.list.GetName()) {
continue
}
key, err := asKey(plugin.ShortCut)
if err != nil {
log.Error().Err(err).Msg("Unable to map shortcut to a key")
continue
}
_, ok := aa[key]
if ok {
log.Error().Err(fmt.Errorf("Doh! you are trying to overide an existing command `%s", k)).Msg("Invalid shortcut")
continue
}
aa[key] = ui.NewKeyAction(
plugin.Description,
r.execCmd(plugin.Command, plugin.Background, plugin.Args...),
true)
}
}
func (r *Resource) execCmd(bin string, bg bool, args ...string) ui.ActionHandler {
return func(evt *tcell.EventKey) *tcell.EventKey {
if !r.masterPage().RowSelected() {
return evt
}
var (
env = r.envFn()
aa = make([]string, len(args))
err error
)
for i, a := range args {
aa[i], err = env.envFor(a)
if err != nil {
log.Error().Err(err).Msg("Args match failed")
return nil
}
}
if run(true, r.app, bin, bg, aa...) {
r.app.Flash().Info("Custom CMD launched!")
} else {
r.app.Flash().Info("Custom CMD failed!")
}
return nil
}
}
func (r *Resource) defaultK9sEnv() K9sEnv {
ns, n := namespaced(r.masterPage().GetSelectedItem())
ctx, err := r.app.Conn().Config().CurrentContextName()
if err != nil {
ctx = "n/a"
}
cluster, err := r.app.Conn().Config().CurrentClusterName()
if err != nil {
cluster = "n/a"
}
user, err := r.app.Conn().Config().CurrentUserName()
if err != nil {
user = "n/a"
}
groups, err := r.app.Conn().Config().CurrentGroupNames()
if err != nil {
groups = []string{"n/a"}
}
var cfg string
kcfg := r.app.Conn().Config().Flags().KubeConfig
if kcfg != nil && *kcfg != "" {
cfg = *kcfg
}
env := K9sEnv{
"NAMESPACE": ns,
"NAME": n,
"CONTEXT": ctx,
"CLUSTER": cluster,
"USER": user,
"GROUPS": strings.Join(groups, ","),
"KUBECONFIG": cfg,
}
row := r.masterPage().GetRow()
for i, r := range row {
env["COL"+strconv.Itoa(i)] = r
}
return env
}