From e211611d4eb1f719b0b7001bbdb6db786f66df9b Mon Sep 17 00:00:00 2001 From: derailed Date: Thu, 2 Apr 2020 13:08:41 -0600 Subject: [PATCH] checkpoint --- .golangci.yml | 2 +- README.md | 83 ++++++++++++++++--------------- change_logs/release_v0.19.0.md | 2 +- go.mod | 4 +- go.sum | 2 + internal/client/client.go | 3 ++ internal/config/alias.go | 3 +- internal/config/config_test.go | 2 + internal/config/k9s.go | 7 +-- internal/config/styles.go | 2 - internal/dao/node.go | 4 +- internal/dao/popeye.go | 39 ++++++++------- internal/dao/registry.go | 15 +++--- internal/dao/sanitizer.go | 80 ----------------------------- internal/model/cmd_buff.go | 11 ---- internal/model/fish_buff.go | 8 +++ internal/model/history.go | 50 +++++++++++++++++++ internal/model/history_test.go | 30 +++++++++++ internal/model/registry.go | 4 +- internal/model/table.go | 2 +- internal/model/tree.go | 1 + internal/render/helpers.go | 30 +++++++++++ internal/render/helpers_test.go | 21 ++++++++ internal/render/popeye.go | 2 + internal/render/row.go | 36 ++++---------- internal/render/row_event.go | 32 +++--------- internal/render/row_event_test.go | 40 +++++++++++++-- internal/ui/app.go | 13 +++-- internal/ui/app_test.go | 13 ++--- internal/ui/command.go | 67 +++++++++++++++++-------- internal/ui/command_test.go | 6 +-- internal/ui/flash.go | 9 +++- internal/ui/flash_test.go | 3 +- internal/ui/indicator_test.go | 8 +-- internal/ui/table.go | 21 ++++++-- internal/ui/table_test.go | 4 +- internal/view/app.go | 23 +++++---- internal/view/command.go | 31 ++++++++++-- internal/view/container.go | 12 ++--- internal/view/helpers.go | 5 +- internal/view/popeye.go | 6 ++- internal/view/registrar.go | 2 +- internal/view/sanitizer.go | 8 ++- internal/view/table.go | 6 ++- internal/view/xray.go | 10 ++-- internal/xray/section.go | 27 +++------- internal/xray/tree_node.go | 49 +++++++++--------- 47 files changed, 485 insertions(+), 353 deletions(-) delete mode 100644 internal/dao/sanitizer.go create mode 100644 internal/model/history.go create mode 100644 internal/model/history_test.go diff --git a/.golangci.yml b/.golangci.yml index ace8c7be..33fcb57f 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -114,7 +114,7 @@ linters-settings: local-prefixes: github.com/org/project gocyclo: # minimal code complexity to report, 30 by default (but we recommend 10-20) - min-complexity: 15 + min-complexity: 20 gocognit: # minimal code complexity to report, 30 by default (but we recommend 10-20) min-complexity: 20 diff --git a/README.md b/README.md index 6cff2b03..8bf7f36b 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,48 @@ K9s is available on Linux, macOS and Windows platforms. --- +## The Command Line + +```shell +# List all available CLI options +k9s help +# To get info about K9s runtime (logs, configs, etc..) +k9s info +# To run K9s in a given namespace +k9s -n mycoolns +# Start K9s in an existing KubeConfig context +k9s --context coolCtx +# Start K9s in readonly mode - with all modification commands disabled +k9s --readonly +``` + +## Key Bindings + +K9s uses aliases to navigate most K8s resources. + +| Action | Command | Comment | +|---------------------------------------------------------------|-----------------------|-------------------------------------------------------------| +| Show active keyboard mnemonics and help | `?` | | +| Show all available resource alias | `ctrl-a` | | +| To bail out of K9s | `:q`, `ctrl-c` | | +| View a Kubernetes resource using singular/plural or shortname | `:`po⏎ | accepts singular, plural, shortname or alias ie pod or pods | +| View a Kubernetes resource in a given namespace | `:`alias namespace⏎ | | +| Filter out a resource view given a filter | `/`filter⏎ | | +| Filter resource view by labels | `/`-l label-selector⏎ | | +| Fuzzy find a resource given a filter | `/`-f filter⏎ | | +| Bails out of view/command/filter mode | `` | | +| Key mapping to describe, view, edit, view logs,... | `d`,`v`, `e`, `l`,... | | +| To view and switch to another Kubernetes context | `:`ctx⏎ | | +| To view and switch to another Kubernetes context | `:`ctx context-name⏎ | | +| To view and switch to another Kubernetes namespace | `:`ns⏎ | | +| To view all saved resources | `:`screendump or sd⏎ | | +| To delete a resource (TAB and ENTER to confirm) | `ctrl-d` | | +| To kill a resource (no confirmation dialog!) | `ctrl-k` | | +| Launch pulses view | `:`pulses or pu⏎ | | +| Launch XRay view | `:`xray pod⏎ | accepts po, svc, dp, rs, sts or ds | + +--- + ## Screenshots 1. Pods @@ -146,47 +188,6 @@ K9s is available on Linux, macOS and Windows platforms. --- -## The Command Line - -```shell -# List all available CLI options -k9s help -# To get info about K9s runtime (logs, configs, etc..) -k9s info -# To run K9s in a given namespace -k9s -n mycoolns -# Start K9s in an existing KubeConfig context -k9s --context coolCtx -# Start K9s in readonly mode - with all modification commands disabled -k9s --readonly -``` - -## Key Bindings - -K9s uses aliases to navigate most K8s resources. - -| Command | Result | Example | -|-----------------------------|----------------------------------------------------|----------------------------| -| `:dp`, `:deploy` | View deployments | | -| `:no`, `:nodes` | View nodes | | -| `:svc`, `:service` | View services | | -| `:`alias`` | View a Kubernetes resource aliases | `:po` | -| `?` | Show keyboard shortcuts and help | | -| `Ctrl-a` | Show all available resource alias | select+`` to view | -| `/`filter`ENTER` | Filter out a resource view given a filter | `/bumblebeetuna` | -| `/`-l label-selector`ENTER` | Filter resource view by labels | `/-l app=fred` | -| `/`-f filter `ENTER` | Fuzzy find a resource given a filter | `/-f ngx` | -| `` | Bails out of view/command/filter mode | | -| `d`,`v`, `e`, `l`,... | Key mapping to describe, view, edit, view logs,... | `d` (describes a resource) | -| `:`ctx`` | To view and switch to another Kubernetes context | `:`+`ctx`+`` | -| `:`ns`` | To view and switch to another Kubernetes namespace | `:`+`ns`+`` | -| `:screendump`, `:sd` | To view all saved resources | | -| `Ctrl-d` | To delete a resource (TAB and ENTER to confirm) | | -| `Ctrl-k` | To kill a resource (no confirmation dialog!) | | -| `:q`, `Ctrl-c` | To bail out of K9s | | - ---- - ## K9s Configuration K9s keeps its configurations in a .k9s directory in your home directory `$HOME/.k9s/config.yml`. diff --git a/change_logs/release_v0.19.0.md b/change_logs/release_v0.19.0.md index 1d7a6d71..225049a3 100644 --- a/change_logs/release_v0.19.0.md +++ b/change_logs/release_v0.19.0.md @@ -15,7 +15,7 @@ On Slack? Please join us [K9slackers](https://join.slack.com/t/k9sers/shared_inv ## A Word From Our Sponsors... It makes me always very happy to hear folks are digging this effort and using K9s daily! If you feel this way please tell us and consider joining our [sponsorship](https://github.com/sponsors/derailed) program. -Big Thanks! to [hornbech](https://github.com/hornbech) for joining our sponsors! +Big THANKS!! to [hornbech](https://github.com/hornbech) for joining our sponsors! ## K8s v1.18.0 Released! diff --git a/go.mod b/go.mod index da189ab3..1478bf1f 100644 --- a/go.mod +++ b/go.mod @@ -2,11 +2,9 @@ module github.com/derailed/k9s go 1.13 -replace github.com/derailed/popeye => /Users/fernand/go_wk/derailed/src/github.com/derailed/popeye - require ( github.com/atotto/clipboard v0.1.2 - github.com/derailed/popeye v0.0.0-00010101000000-000000000000 + github.com/derailed/popeye v0.8.0 github.com/derailed/tview v0.3.9 github.com/drone/envsubst v1.0.2 // indirect github.com/fatih/color v1.9.0 diff --git a/go.sum b/go.sum index 0fa1f975..4901da21 100644 --- a/go.sum +++ b/go.sum @@ -123,6 +123,8 @@ github.com/daviddengcn/go-colortext v0.0.0-20160507010035-511bcaf42ccd/go.mod h1 github.com/deislabs/oras v0.8.1 h1:If674KraJVpujYR00rzdi0QAmW4BxzMJPVAZJKuhQ0c= github.com/deislabs/oras v0.8.1/go.mod h1:Mx0rMSbBNaNfY9hjpccEnxkOqJL6KGjtxNHPLC4G4As= github.com/denverdino/aliyungo v0.0.0-20190125010748-a747050bb1ba/go.mod h1:dV8lFg6daOBZbT6/BDGIz6Y3WFGn8juu6G+CQ6LHtl0= +github.com/derailed/popeye v0.8.0 h1:D+5fHiMmuXqaF5J2bJTI+YLrD77ag5Wb1dkAuR4bPGI= +github.com/derailed/popeye v0.8.0/go.mod h1:OBHcJDa50VpE9QNyOU243bNOtHb29MyLlVHJolwlwas= github.com/derailed/tview v0.3.9 h1:6iUtOmzN6gdk6yx1KNSwhMgrsLYjgldduulKPqHnqwk= github.com/derailed/tview v0.3.9/go.mod h1:GJ3k/TIzEE+sj1L09/usk6HrkjsdadSsb03eHgPbcII= github.com/dgrijalva/jwt-go v0.0.0-20170104182250-a601269ab70c/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= diff --git a/internal/client/client.go b/internal/client/client.go index 0bd567e7..d83cbea1 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -235,6 +235,8 @@ func (a *APIClient) HasMetrics() bool { defer cancel() if _, err := dial.MetricsV1beta1().NodeMetricses().List(ctx, metav1.ListOptions{Limit: 1}); err == nil { flag = true + } else { + log.Error().Err(err).Msgf("List metrics failed") } a.cache.Add(cacheMXKey, flag, cacheExpiry) @@ -359,6 +361,7 @@ func (a *APIClient) supportsMetricsResources() (supported bool) { apiGroups, err := a.CachedDiscoveryOrDie().ServerGroups() if err != nil { + log.Debug().Msgf("Unable to access servergroups %#v", err) return } for _, grp := range apiGroups.Groups { diff --git a/internal/config/alias.go b/internal/config/alias.go index 680d251a..7017dbaa 100644 --- a/internal/config/alias.go +++ b/internal/config/alias.go @@ -143,7 +143,7 @@ func (a *Aliases) loadDefaultAliases() { a.declare("quit", "q", "Q") a.declare("aliases", "alias", "a") a.declare("popeye", "pop") - a.declare("sanitize", "san", "sanitize") + // a.declare("sanitize", "san", "sanitize") a.declare("contexts", "context", "ctx") a.declare("users", "user", "usr") a.declare("groups", "group", "grp") @@ -151,6 +151,7 @@ func (a *Aliases) loadDefaultAliases() { a.declare("benchmarks", "benchmark", "be") a.declare("screendumps", "screendump", "sd") a.declare("pulses", "pulse", "pu", "hz") + a.declare("xrays", "xray", "x") } // Save alias to disk. diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 0b86ccd7..013d9648 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -263,6 +263,7 @@ var expectedConfig = `k9s: refreshRate: 100 headless: false readOnly: true + noIcons: false logger: tail: 500 buffer: 800 @@ -313,6 +314,7 @@ var resetConfig = `k9s: refreshRate: 2 headless: false readOnly: false + noIcons: false logger: tail: 200 buffer: 2000 diff --git a/internal/config/k9s.go b/internal/config/k9s.go index d9ad2aa8..72d3e66f 100644 --- a/internal/config/k9s.go +++ b/internal/config/k9s.go @@ -2,16 +2,14 @@ package config import "github.com/derailed/k9s/internal/client" -const ( - defaultRefreshRate = 2 - defaultReadOnly = false -) +const defaultRefreshRate = 2 // K9s tracks K9s configuration options. type K9s struct { RefreshRate int `yaml:"refreshRate"` Headless bool `yaml:"headless"` ReadOnly bool `yaml:"readOnly"` + NoIcons bool `yaml:"noIcons"` Logger *Logger `yaml:"logger"` CurrentContext string `yaml:"currentContext"` CurrentCluster string `yaml:"currentCluster"` @@ -27,7 +25,6 @@ type K9s struct { func NewK9s() *K9s { return &K9s{ RefreshRate: defaultRefreshRate, - ReadOnly: defaultReadOnly, Logger: NewLogger(), Clusters: make(map[string]*Cluster), Thresholds: NewThreshold(), diff --git a/internal/config/styles.go b/internal/config/styles.go index ffd45c78..b58eab2d 100644 --- a/internal/config/styles.go +++ b/internal/config/styles.go @@ -145,7 +145,6 @@ type ( BgColor Color `yaml:"bgColor"` CursorColor Color `yaml:"cursorColor"` GraphicColor Color `yaml:"graphicColor"` - ShowIcons bool `yaml:"showIcons"` } // Menu tracks menu styles. @@ -312,7 +311,6 @@ func newXray() Xray { BgColor: "black", CursorColor: "whitesmoke", GraphicColor: "floralwhite", - ShowIcons: true, } } diff --git a/internal/dao/node.go b/internal/dao/node.go index c6a71ee1..bd1367a4 100644 --- a/internal/dao/node.go +++ b/internal/dao/node.go @@ -34,13 +34,15 @@ type Node struct { // ToggleCordon toggles cordon/uncordon a node. func (n *Node) ToggleCordon(path string, cordon bool) error { - o, err := n.Get(context.Background(), path) + log.Debug().Msgf("CORDON %q::%t -- %q", path, cordon, n.gvr.GVK()) + o, err := FetchNode(context.Background(), n.Factory, path) if err != nil { return err } h, err := drain.NewCordonHelperFromRuntimeObject(o, scheme.Scheme, n.gvr.GVK()) if err != nil { + log.Debug().Msgf("BOOM %v", err) return err } diff --git a/internal/dao/popeye.go b/internal/dao/popeye.go index 7a3c54b3..7f828518 100644 --- a/internal/dao/popeye.go +++ b/internal/dao/popeye.go @@ -5,10 +5,13 @@ import ( "context" "encoding/json" "errors" + "fmt" + "os" "path/filepath" "sort" "time" + "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/client" cfg "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/render" @@ -17,8 +20,6 @@ import ( "github.com/derailed/popeye/types" "github.com/rs/zerolog/log" "k8s.io/apimachinery/pkg/runtime" - "k8s.io/cli-runtime/pkg/genericclioptions" - restclient "k8s.io/client-go/rest" ) var _ Accessor = (*Popeye)(nil) @@ -51,11 +52,22 @@ func (p *Popeye) List(ctx context.Context, _ string) ([]runtime.Object, error) { log.Debug().Msgf("Popeye -- Elapsed %v", time.Since(t)) }(time.Now()) - js := "json" flags := config.NewFlags() - spinach := filepath.Join(cfg.K9sHome, "spinach.yml") - flags.Spinach = &spinach + js := "json" flags.Output = &js + + if report, ok := ctx.Value(internal.KeyPath).(string); ok && report != "" { + sections := []string{report} + flags.Sections = §ions + } + spinach := filepath.Join(cfg.K9sHome, "spinach.yml") + if c, err := p.Factory.Client().Config().CurrentContextName(); err == nil { + spinach = filepath.Join(cfg.K9sHome, fmt.Sprintf("%s_spinach.yml", c)) + } + if _, err := os.Stat(spinach); err == nil { + flags.Spinach = &spinach + } + popeye, err := pkg.NewPopeye(flags, &log.Logger) if err != nil { return nil, err @@ -68,6 +80,7 @@ func (p *Popeye) List(ctx context.Context, _ string) ([]runtime.Object, error) { buff := readWriteCloser{Buffer: bytes.NewBufferString("")} popeye.SetOutputTarget(buff) if err = popeye.Sanitize(); err != nil { + log.Debug().Msgf("BOOM %#v", *flags.Sections) return nil, err } @@ -93,6 +106,8 @@ func (a *Popeye) Get(_ context.Context, _ string) (runtime.Object, error) { return nil, errors.New("NYI!!") } +// Helpers... + type popFactory struct { Factory } @@ -102,7 +117,6 @@ var _ types.Factory = (*popFactory)(nil) func newPopFactory(f Factory) *popFactory { return &popFactory{Factory: f} } - func (p *popFactory) Client() types.Connection { return &popConnection{Connection: p.Factory.Client()} } @@ -116,16 +130,3 @@ var _ types.Connection = (*popConnection)(nil) func (c *popConnection) Config() types.Config { return c.Connection.Config() } - -func (c *popConnection) CurrentNamespaceName() (string, error) { - return c.ActiveNamespace(), nil -} -func (c *popConnection) CurrentClusterName() (string, error) { - return c.Connection.ActiveCluster(), nil -} -func (c *popConnection) Flags() *genericclioptions.ConfigFlags { - return c.Connection.Config().Flags() -} -func (c *popConnection) RESTConfig() (*restclient.Config, error) { - return c.Connection.Config().RESTConfig() -} diff --git a/internal/dao/registry.go b/internal/dao/registry.go index 72f6d8af..1bccd73b 100644 --- a/internal/dao/registry.go +++ b/internal/dao/registry.go @@ -47,11 +47,12 @@ func AccessorFor(f Factory, gvr client.GVR) (Accessor, error) { client.NewGVR("apps/v1/statefulsets"): &StatefulSet{}, client.NewGVR("batch/v1beta1/cronjobs"): &CronJob{}, client.NewGVR("batch/v1/jobs"): &Job{}, + client.NewGVR("openfaas"): &OpenFaas{}, + client.NewGVR("popeye"): &Popeye{}, + client.NewGVR("sanitizer"): &Popeye{}, + // BOZO!! v1.18.0 // client.NewGVR("charts"): &Chart{}, - client.NewGVR("openfaas"): &OpenFaas{}, - client.NewGVR("popeye"): &Popeye{}, - client.NewGVR("report"): &Sanitizer{}, } r, ok := m[gvr] @@ -173,10 +174,10 @@ func loadK9s(m ResourceMetas) { Verbs: []string{}, Categories: []string{"k9s"}, } - m[client.NewGVR("report")] = metav1.APIResource{ - Name: "report", - Kind: "Report", - SingularName: "report", + m[client.NewGVR("sanitizer")] = metav1.APIResource{ + Name: "sanitizer", + Kind: "Sanitizer", + SingularName: "sanitizer", Verbs: []string{}, Categories: []string{"k9s"}, } diff --git a/internal/dao/sanitizer.go b/internal/dao/sanitizer.go deleted file mode 100644 index b093b4d6..00000000 --- a/internal/dao/sanitizer.go +++ /dev/null @@ -1,80 +0,0 @@ -package dao - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "path/filepath" - - "github.com/derailed/k9s/internal" - "github.com/derailed/k9s/internal/client" - cfg "github.com/derailed/k9s/internal/config" - "github.com/derailed/k9s/internal/render" - "github.com/derailed/popeye/pkg" - "github.com/derailed/popeye/pkg/config" - "github.com/rs/zerolog/log" - "k8s.io/apimachinery/pkg/runtime" -) - -var _ Accessor = (*Sanitizer)(nil) - -// Sanitizer tracks cluster sanitization. -type Sanitizer struct { - NonResource -} - -// NewSanitizer returns a new set of aliases. -func NewSanitizer(f Factory) *Sanitizer { - s := Sanitizer{} - s.Init(f, client.NewGVR("report")) - - return &s -} - -// List returns a collection of aliases. -func (s *Sanitizer) List(ctx context.Context, _ string) ([]runtime.Object, error) { - report, ok := ctx.Value(internal.KeyPath).(string) - if !ok { - return nil, fmt.Errorf("no sanitizer report path") - } - sections := []string{report} - js := "json" - flags := config.NewFlags() - flags.Sections = §ions - spinach := filepath.Join(cfg.K9sHome, "spinach.yml") - flags.Spinach = &spinach - flags.Output = &js - - popeye, err := pkg.NewPopeye(flags, &log.Logger) - if err != nil { - return nil, err - } - popeye.SetFactory(newPopFactory(s.Factory)) - if err = popeye.Init(); err != nil { - return nil, err - } - buff := readWriteCloser{Buffer: bytes.NewBufferString("")} - popeye.SetOutputTarget(buff) - if err = popeye.Sanitize(); err != nil { - return nil, err - } - - var b render.Builder - if err = json.Unmarshal(buff.Bytes(), &b); err != nil { - return nil, err - } - - oo := make([]runtime.Object, len(b.Report.Sections)) - for i, s := range b.Report.Sections { - oo[i] = s - } - - return oo, nil -} - -// Get fetch a resource. -func (*Sanitizer) Get(_ context.Context, _ string) (runtime.Object, error) { - return nil, errors.New("NYI!!") -} diff --git a/internal/model/cmd_buff.go b/internal/model/cmd_buff.go index f3d217aa..16f09f24 100644 --- a/internal/model/cmd_buff.go +++ b/internal/model/cmd_buff.go @@ -27,7 +27,6 @@ type CmdBuff struct { listeners []BuffWatcher hotKey rune kind BufferKind - sticky bool active bool } @@ -41,16 +40,6 @@ func NewCmdBuff(key rune, kind BufferKind) *CmdBuff { } } -// IsSticky checks if the cmd is going to perist or not. -func (c *CmdBuff) IsSticky() bool { - return c.sticky -} - -// SetSticky returns cmd stickness. -func (c *CmdBuff) SetSticky(b bool) { - c.sticky = b -} - // InCmdMode checks if a command exists and the buffer is active. func (c *CmdBuff) InCmdMode() bool { return c.active || len(c.buff) > 0 diff --git a/internal/model/fish_buff.go b/internal/model/fish_buff.go index b3cbe0ca..b0b2c35e 100644 --- a/internal/model/fish_buff.go +++ b/internal/model/fish_buff.go @@ -32,6 +32,14 @@ func (f *FishBuff) SetSuggestionFn(fn SuggestionFunc) { f.suggestionFn = fn } +func (f *FishBuff) Activate() { + if f.suggestionFn == nil { + return + } + cc := f.suggestionFn(string(f.buff)) + f.fireSuggest(cc) +} + // Delete removes the last character from the buffer. func (f *FishBuff) Delete() { f.CmdBuff.Delete() diff --git a/internal/model/history.go b/internal/model/history.go new file mode 100644 index 00000000..77ede546 --- /dev/null +++ b/internal/model/history.go @@ -0,0 +1,50 @@ +package model + +// MaxHistory tracks max command history +const MaxHistory = 20 + +// History represents a command history. +type History struct { + commands []string + limit int +} + +// NewHistory returns a new instance. +func NewHistory(limit int) *History { + return &History{limit: limit} +} + +func (h *History) List() []string { + return h.commands +} + +// Push adds a new item. +func (h *History) Push(c string) { + if i := h.indexOf(c); i != -1 { + h.commands = append(h.commands[:i], h.commands[i+1:]...) + } + if len(h.commands) < h.limit { + h.commands = append(h.commands, c) + return + } + h.commands = append(h.commands[1:], c) +} + +// Clear clear out the stack using pops. +func (h *History) Clear() { + h.commands = nil +} + +// Empty returns true if no history. +func (h *History) Empty() bool { + return len(h.commands) == 0 +} + +func (h *History) indexOf(s string) int { + for i, c := range h.commands { + if c == s { + return i + } + } + return -1 +} diff --git a/internal/model/history_test.go b/internal/model/history_test.go new file mode 100644 index 00000000..e34c7d40 --- /dev/null +++ b/internal/model/history_test.go @@ -0,0 +1,30 @@ +package model_test + +import ( + "fmt" + "testing" + + "github.com/derailed/k9s/internal/model" + "github.com/stretchr/testify/assert" +) + +func TestHistory(t *testing.T) { + h := model.NewHistory(3) + for i := 1; i < 5; i++ { + h.Push(fmt.Sprintf("cmd%d", i)) + } + + assert.Equal(t, []string{"cmd2", "cmd3", "cmd4"}, h.List()) + h.Clear() + assert.True(t, h.Empty()) +} + +func TestHistoryDups(t *testing.T) { + h := model.NewHistory(3) + for i := 1; i < 4; i++ { + h.Push(fmt.Sprintf("cmd%d", i)) + } + h.Push("cmd1") + + assert.Equal(t, []string{"cmd2", "cmd3", "cmd1"}, h.List()) +} diff --git a/internal/model/registry.go b/internal/model/registry.go index 682fd649..4b0839a5 100644 --- a/internal/model/registry.go +++ b/internal/model/registry.go @@ -67,8 +67,8 @@ var Registry = map[string]ResourceMeta{ DAO: &dao.Popeye{}, Renderer: &render.Popeye{}, }, - "report": { - DAO: &dao.Sanitizer{}, + "sanitizer": { + DAO: &dao.Popeye{}, TreeRenderer: &xray.Section{}, }, diff --git a/internal/model/table.go b/internal/model/table.go index 475025ee..c0d1b087 100644 --- a/internal/model/table.go +++ b/internal/model/table.go @@ -179,7 +179,7 @@ func (t *Table) Peek() render.TableData { } func (t *Table) updater(ctx context.Context) { - defer log.Debug().Msgf("Model canceled -- %q", t.gvr) + defer log.Debug().Msgf("TABLE-MODEL canceled -- %q", t.gvr) rate := initRefreshRate for { diff --git a/internal/model/tree.go b/internal/model/tree.go index 333fb93b..7a1723b7 100644 --- a/internal/model/tree.go +++ b/internal/model/tree.go @@ -219,6 +219,7 @@ func (t *Tree) reconcile(ctx context.Context) error { } } else { if err := treeHydrate(ctx, ns, oo, meta.TreeRenderer); err != nil { + return err } } diff --git a/internal/render/helpers.go b/internal/render/helpers.go index 2b693b72..64be8aaa 100644 --- a/internal/render/helpers.go +++ b/internal/render/helpers.go @@ -1,6 +1,7 @@ package render import ( + "regexp" "sort" "strconv" "strings" @@ -15,6 +16,35 @@ import ( "k8s.io/apimachinery/pkg/util/duration" ) +var durationRx = regexp.MustCompile(`\A(\d*d)*?(\d*h)*?(\d*m)*?(\d*s)*?\z`) + +func durationToSeconds(duration string) string { + tokens := durationRx.FindAllStringSubmatch(duration, -1) + if len(tokens) == 0 { + return duration + } + if len(tokens[0]) < 5 { + return duration + } + + d, h, m, s := tokens[0][1], tokens[0][2], tokens[0][3], tokens[0][4] + var n int + if v, err := strconv.Atoi(strings.Replace(d, "d", "", 1)); err == nil { + n += v * 24 * 60 * 60 + } + if v, err := strconv.Atoi(strings.Replace(h, "h", "", 1)); err == nil { + n += v * 60 * 60 + } + if v, err := strconv.Atoi(strings.Replace(m, "m", "", 1)); err == nil { + n += v * 60 + } + if v, err := strconv.Atoi(strings.Replace(s, "s", "", 1)); err == nil { + n += v + } + + return strconv.Itoa(n) +} + // AsThousands prints a number with thousand separator. func AsThousands(n int64) string { p := message.NewPrinter(language.English) diff --git a/internal/render/helpers_test.go b/internal/render/helpers_test.go index fb911c65..506ed826 100644 --- a/internal/render/helpers_test.go +++ b/internal/render/helpers_test.go @@ -9,6 +9,27 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) +func TestDurationToNumber(t *testing.T) { + uu := map[string]struct { + s, e string + }{ + "seconds": {s: "22s", e: "22"}, + "minutes": {s: "22m", e: "1320"}, + "hours": {s: "12h", e: "43200"}, + "days": {s: "3d", e: "259200"}, + "day_hour": {s: "3d9h", e: "291600"}, + "day_hour_minute": {s: "2d22h3m", e: "252180"}, + "day_hour_minute_seconds": {s: "2d22h3m50s", e: "252230"}, + } + + for k := range uu { + u := uu[k] + t.Run(k, func(t *testing.T) { + assert.Equal(t, u.e, durationToSeconds(u.s)) + }) + } +} + func TestToAge(t *testing.T) { uu := map[string]struct { t time.Time diff --git a/internal/render/popeye.go b/internal/render/popeye.go index 046c8e2b..5b07e5db 100644 --- a/internal/render/popeye.go +++ b/internal/render/popeye.go @@ -91,6 +91,7 @@ type ( // Section represents a sanitizer pass Section struct { Title string `json:"sanitizer" yaml:"sanitizer"` + GVR string `yaml:"gvr" json:"gvr"` Tally *Tally `json:"tally" yaml:"tally"` Outcome Outcome `json:"issues,omitempty" yaml:"issues,omitempty"` } @@ -100,6 +101,7 @@ type ( Issue struct { Group string `yaml:"group" json:"group"` + GVR string `yaml:"gvr" json:"gvr"` Level config.Level `yaml:"level" json:"level"` Message string `yaml:"message" json:"message"` } diff --git a/internal/render/row.go b/internal/render/row.go index 6ae8a805..28e7d557 100644 --- a/internal/render/row.go +++ b/internal/render/row.go @@ -3,6 +3,7 @@ package render import ( "reflect" "sort" + "strconv" "time" "vbom.ml/util/sortorder" @@ -160,36 +161,21 @@ func (s RowSorter) Less(i, j int) bool { // ---------------------------------------------------------------------------- // Helpers... -// Less return true if c1 < c2. -func Less(asc bool, c1, c2 string) bool { - if o, ok := isDurationSort(asc, c1, c2); ok { - return o +func toAgeDuration(dur string) string { + d, err := time.ParseDuration(dur) + if err != nil { + return durationToSeconds(dur) } + return strconv.Itoa(int(d.Seconds())) +} + +// Less return true if c1 < c2. +func Less(asc bool, c1, c2 string) bool { + c1, c2 = toAgeDuration(c1), toAgeDuration(c2) b := sortorder.NaturalLess(c1, c2) if asc { return b } return !b } - -func isDurationSort(asc bool, s1, s2 string) (bool, bool) { - d1, ok1 := isDuration(s1) - d2, ok2 := isDuration(s2) - if !ok1 || !ok2 { - return false, false - } - - if asc { - return d1 <= d2, true - } - return d1 >= d2, true -} - -func isDuration(s string) (time.Duration, bool) { - d, err := time.ParseDuration(s) - if err != nil { - return d, false - } - return d, true -} diff --git a/internal/render/row_event.go b/internal/render/row_event.go index 3f233f5c..aa7988da 100644 --- a/internal/render/row_event.go +++ b/internal/render/row_event.go @@ -3,10 +3,8 @@ package render import ( "fmt" "sort" - "time" "github.com/rs/zerolog/log" - "k8s.io/apimachinery/pkg/util/duration" ) const ( @@ -170,39 +168,25 @@ func (r RowEvents) Sort(ns string, sortCol int, ageCol bool, asc bool) { t := RowEventSorter{NS: ns, Events: r, Index: sortCol, Asc: asc} sort.Sort(t) - gg, kk := map[string][]string{}, make(StringSet, 0, len(r)) + iids, fields := map[string][]string{}, make(StringSet, 0, len(r)) for _, re := range r { - g := re.Row.Fields[sortCol] + field := re.Row.Fields[sortCol] if ageCol { - g = toAgeDuration(g) - } - kk = kk.Add(g) - if ss, ok := gg[g]; ok { - gg[g] = append(ss, re.Row.ID) - } else { - gg[g] = []string{re.Row.ID} + field = toAgeDuration(field) } + fields = fields.Add(field) + iids[field] = append(iids[field], re.Row.ID) } ids := make([]string, 0, len(r)) - for _, k := range kk { - sort.StringSlice(gg[k]).Sort() - ids = append(ids, gg[k]...) + for _, field := range fields { + sort.StringSlice(iids[field]).Sort() + ids = append(ids, iids[field]...) } s := IdSorter{Ids: ids, Events: r} sort.Sort(s) } -// Helpers... - -func toAgeDuration(dur string) string { - d, err := time.ParseDuration(dur) - if err != nil { - return dur - } - return duration.HumanDuration(d) -} - // ---------------------------------------------------------------------------- // RowEventSorter sorts row events by a given colon. diff --git a/internal/render/row_event_test.go b/internal/render/row_event_test.go index 8c8aa219..e2c21434 100644 --- a/internal/render/row_event_test.go +++ b/internal/render/row_event_test.go @@ -409,11 +409,41 @@ func TestRowEventsDelete(t *testing.T) { func TestRowEventsSort(t *testing.T) { uu := map[string]struct { - re render.RowEvents - col int - asc bool - e render.RowEvents + re render.RowEvents + col int + age, asc bool + e render.RowEvents }{ + "age_time": { + re: render.RowEvents{ + {Row: render.Row{ID: "A", Fields: render.Fields{"1", "2", "1h10m10.5s"}}}, + {Row: render.Row{ID: "B", Fields: render.Fields{"0", "2", "10.5s"}}}, + {Row: render.Row{ID: "C", Fields: render.Fields{"10", "2", "3h20m5.2s"}}}, + }, + col: 2, + asc: true, + age: true, + e: render.RowEvents{ + {Row: render.Row{ID: "B", Fields: render.Fields{"0", "2", "10.5s"}}}, + {Row: render.Row{ID: "A", Fields: render.Fields{"1", "2", "1h10m10.5s"}}}, + {Row: render.Row{ID: "C", Fields: render.Fields{"10", "2", "3h20m5.2s"}}}, + }, + }, + "age_duration": { + re: render.RowEvents{ + {Row: render.Row{ID: "A", Fields: render.Fields{"1", "2", "32d"}}}, + {Row: render.Row{ID: "B", Fields: render.Fields{"0", "2", "1m10s"}}}, + {Row: render.Row{ID: "C", Fields: render.Fields{"10", "2", "3h20m5s"}}}, + }, + col: 2, + asc: true, + age: true, + e: render.RowEvents{ + {Row: render.Row{ID: "B", Fields: render.Fields{"0", "2", "1m10s"}}}, + {Row: render.Row{ID: "C", Fields: render.Fields{"10", "2", "3h20m5s"}}}, + {Row: render.Row{ID: "A", Fields: render.Fields{"1", "2", "32d"}}}, + }, + }, "col0": { re: render.RowEvents{ {Row: render.Row{ID: "A", Fields: render.Fields{"1", "2", "3"}}}, @@ -453,7 +483,7 @@ func TestRowEventsSort(t *testing.T) { for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { - u.re.Sort("", u.col, false, u.asc) + u.re.Sort("", u.col, u.age, u.asc) assert.Equal(t, u.e, u.re) }) } diff --git a/internal/ui/app.go b/internal/ui/app.go index 39d76bac..7ada1098 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -22,20 +22,23 @@ type App struct { } // NewApp returns a new app. -func NewApp(context string) *App { +func NewApp(cfg *config.Config, context string) *App { a := App{ Application: tview.NewApplication(), actions: make(KeyActions), - Main: NewPages(), - flash: model.NewFlash(model.DefaultFlashDelay), - cmdBuff: model.NewFishBuff(':', model.Command), + Configurator: Configurator{ + Config: cfg, + }, + Main: NewPages(), + flash: model.NewFlash(model.DefaultFlashDelay), + cmdBuff: model.NewFishBuff(':', model.Command), } a.ReloadStyles(context) a.views = map[string]tview.Primitive{ "menu": NewMenu(a.Styles), "logo": NewLogo(a.Styles), - "cmd": NewCommand(a.Styles, a.cmdBuff), + "cmd": NewCommand(a.Config.K9s.NoIcons, a.Styles, a.cmdBuff), "crumbs": NewCrumbs(a.Styles), } diff --git a/internal/ui/app_test.go b/internal/ui/app_test.go index 3d766ca4..20ef222a 100644 --- a/internal/ui/app_test.go +++ b/internal/ui/app_test.go @@ -3,12 +3,13 @@ package ui_test import ( "testing" + "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/ui" "github.com/stretchr/testify/assert" ) func TestAppGetCmd(t *testing.T) { - a := ui.NewApp("") + a := ui.NewApp(config.NewConfig(nil), "") a.Init() a.CmdBuff().Set("blee") @@ -16,7 +17,7 @@ func TestAppGetCmd(t *testing.T) { } func TestAppInCmdMode(t *testing.T) { - a := ui.NewApp("") + a := ui.NewApp(config.NewConfig(nil), "") a.Init() a.CmdBuff().Set("blee") assert.False(t, a.InCmdMode()) @@ -26,7 +27,7 @@ func TestAppInCmdMode(t *testing.T) { } func TestAppResetCmd(t *testing.T) { - a := ui.NewApp("") + a := ui.NewApp(config.NewConfig(nil), "") a.Init() a.CmdBuff().Set("blee") @@ -36,7 +37,7 @@ func TestAppResetCmd(t *testing.T) { } func TestAppHasCmd(t *testing.T) { - a := ui.NewApp("") + a := ui.NewApp(config.NewConfig(nil), "") a.Init() a.ActivateCmd(true) @@ -47,7 +48,7 @@ func TestAppHasCmd(t *testing.T) { } func TestAppGetActions(t *testing.T) { - a := ui.NewApp("") + a := ui.NewApp(config.NewConfig(nil), "") a.Init() a.AddActions(ui.KeyActions{ui.KeyZ: ui.KeyAction{Description: "zorg"}}) @@ -56,7 +57,7 @@ func TestAppGetActions(t *testing.T) { } func TestAppViews(t *testing.T) { - a := ui.NewApp("") + a := ui.NewApp(config.NewConfig(nil), "") a.Init() vv := []string{"crumbs", "logo", "cmd", "menu"} diff --git a/internal/ui/command.go b/internal/ui/command.go index 5f23ee92..18eb31d1 100644 --- a/internal/ui/command.go +++ b/internal/ui/command.go @@ -9,29 +9,40 @@ import ( "github.com/gdamore/tcell" ) -const defaultPrompt = "%c> [::b]%s" +const ( + defaultPrompt = "%c> [::b]%s" + defaultSpacer = 4 +) // Command captures users free from command input. type Command struct { *tview.TextView activated bool + noIcons bool icon rune text string + suggestion string styles *config.Styles model *model.FishBuff suggestions []string suggestionIndex int + spacer int } // NewCommand returns a new command view. -func NewCommand(styles *config.Styles, m *model.FishBuff) *Command { +func NewCommand(noIcons bool, styles *config.Styles, m *model.FishBuff) *Command { c := Command{ styles: styles, + noIcons: noIcons, TextView: tview.NewTextView(), + spacer: defaultSpacer, model: m, suggestionIndex: -1, } + if noIcons { + c.spacer-- + } c.SetWordWrap(true) c.ShowCursor(true) c.SetWrap(true) @@ -55,7 +66,7 @@ func (c *Command) keyboard(evt *tcell.EventKey) *tcell.EventKey { case tcell.KeyCtrlW, tcell.KeyCtrlU: c.model.Clear() case tcell.KeyDown: - if c.text == "" || c.suggestionIndex < 0 { + if c.suggestionIndex < 0 { return evt } c.suggestionIndex++ @@ -64,7 +75,7 @@ func (c *Command) keyboard(evt *tcell.EventKey) *tcell.EventKey { } c.suggest(c.model.String(), c.suggestions[c.suggestionIndex]) case tcell.KeyUp: - if c.text == "" || c.suggestionIndex < 0 { + if c.suggestionIndex < 0 { return evt } c.suggestionIndex-- @@ -95,7 +106,8 @@ func (c *Command) InCmdMode() bool { func (c *Command) activate() { c.SetCursorIndex(len(c.text)) - c.write(c.text, "") + c.write(false, c.text, "") + c.model.Activate() } func (c *Command) update(s string) { @@ -104,20 +116,25 @@ func (c *Command) update(s string) { } c.text = s c.Clear() - c.write(s, "") + c.write(false, s, "") } func (c *Command) suggest(text, suggestion string) { c.Clear() - c.write(text, suggestion) + c.write(false, text, suggestion) } -func (c *Command) write(text, suggest string) { - c.SetCursorIndex(4 + len(text)) +func (c *Command) write(append bool, text, suggest string) { + c.suggestion = suggest + c.SetCursorIndex(c.spacer + len(text)) txt := text if suggest != "" { txt += "[gray::-]" + suggest } + if append { + fmt.Fprintf(c, "[gray::-]%s", suggest) + return + } fmt.Fprintf(c, defaultPrompt, c.icon, txt) } @@ -131,7 +148,10 @@ func (c *Command) SuggestionChanged(ss []string) { c.suggestionIndex = -1 return } - fmt.Fprintf(c, "[gray::-]%s", ss[c.suggestionIndex]) + if c.suggestion == ss[c.suggestionIndex] { + return + } + c.write(true, c.text, ss[c.suggestionIndex]) } // BufferChanged indicates the buffer was changed. @@ -145,7 +165,7 @@ func (c *Command) BufferActive(f bool, k model.BufferKind) { c.SetBorder(true) c.SetTextColor(c.styles.FgColor()) c.SetBorderColor(colorFor(k)) - c.icon = iconFor(k) + c.icon = c.iconFor(k) c.activate() } else { c.SetBorder(false) @@ -154,6 +174,22 @@ func (c *Command) BufferActive(f bool, k model.BufferKind) { } } +func (c *Command) iconFor(k model.BufferKind) rune { + if c.noIcons { + return ' ' + } + + switch k { + case model.Command: + return '🐶' + default: + return '🐩' + } +} + +// ---------------------------------------------------------------------------- +// Helpers... + func colorFor(k model.BufferKind) tcell.Color { switch k { case model.Command: @@ -162,12 +198,3 @@ func colorFor(k model.BufferKind) tcell.Color { return tcell.ColorSeaGreen } } - -func iconFor(k model.BufferKind) rune { - switch k { - case model.Command: - return '🐶' - default: - return '🐩' - } -} diff --git a/internal/ui/command_test.go b/internal/ui/command_test.go index 99f700e5..35c444eb 100644 --- a/internal/ui/command_test.go +++ b/internal/ui/command_test.go @@ -11,7 +11,7 @@ import ( func TestCmdNew(t *testing.T) { model := model.NewFishBuff(':', model.Command) - v := ui.NewCommand(config.NewStyles(), model) + v := ui.NewCommand(true, config.NewStyles(), model) model.AddListener(v) model.Set("blee") @@ -21,7 +21,7 @@ func TestCmdNew(t *testing.T) { func TestCmdUpdate(t *testing.T) { model := model.NewFishBuff(':', model.Command) - v := ui.NewCommand(config.NewStyles(), model) + v := ui.NewCommand(true, config.NewStyles(), model) model.AddListener(v) model.Set("blee") @@ -33,7 +33,7 @@ func TestCmdUpdate(t *testing.T) { func TestCmdMode(t *testing.T) { model := model.NewFishBuff(':', model.Command) - v := ui.NewCommand(config.NewStyles(), model) + v := ui.NewCommand(true, config.NewStyles(), model) model.AddListener(v) for _, f := range []bool{false, true} { diff --git a/internal/ui/flash.go b/internal/ui/flash.go index 5bcafcd4..82abf53b 100644 --- a/internal/ui/flash.go +++ b/internal/ui/flash.go @@ -71,7 +71,7 @@ func (f *Flash) SetMessage(m model.LevelMessage) { return } f.SetTextColor(flashColor(m.Level)) - f.SetText(flashEmoji(m.Level) + " " + m.Text) + f.SetText(f.flashEmoji(m.Level) + " " + m.Text) } if f.testMode { @@ -81,7 +81,10 @@ func (f *Flash) SetMessage(m model.LevelMessage) { } } -func flashEmoji(l model.FlashLevel) string { +func (f *Flash) flashEmoji(l model.FlashLevel) string { + if f.app.Config.K9s.NoIcons { + return "" + } switch l { case model.FlashWarn: return emoDoh @@ -92,6 +95,8 @@ func flashEmoji(l model.FlashLevel) string { } } +// Helpers... + func flashColor(l model.FlashLevel) tcell.Color { switch l { case model.FlashWarn: diff --git a/internal/ui/flash_test.go b/internal/ui/flash_test.go index ee7b2ad9..76acec05 100644 --- a/internal/ui/flash_test.go +++ b/internal/ui/flash_test.go @@ -5,6 +5,7 @@ import ( "testing" "time" + "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/model" "github.com/derailed/k9s/internal/ui" "github.com/stretchr/testify/assert" @@ -21,7 +22,7 @@ func TestFlash(t *testing.T) { "err": {l: model.FlashErr, i: "hello", e: "😡 hello\n"}, } - a := ui.NewApp("test") + a := ui.NewApp(config.NewConfig(nil), "test") f := ui.NewFlash(a) f.SetTestMode(true) ctx, cancel := context.WithCancel(context.Background()) diff --git a/internal/ui/indicator_test.go b/internal/ui/indicator_test.go index 2e3c5a36..7a3a0922 100644 --- a/internal/ui/indicator_test.go +++ b/internal/ui/indicator_test.go @@ -9,7 +9,7 @@ import ( ) func TestIndicatorReset(t *testing.T) { - i := ui.NewStatusIndicator(ui.NewApp(""), config.NewStyles()) + i := ui.NewStatusIndicator(ui.NewApp(config.NewConfig(nil), ""), config.NewStyles()) i.SetPermanent("Blee") i.Info("duh") i.Reset() @@ -18,21 +18,21 @@ func TestIndicatorReset(t *testing.T) { } func TestIndicatorInfo(t *testing.T) { - i := ui.NewStatusIndicator(ui.NewApp(""), config.NewStyles()) + i := ui.NewStatusIndicator(ui.NewApp(config.NewConfig(nil), ""), config.NewStyles()) i.Info("Blee") assert.Equal(t, "[lawngreen::b] \n", i.GetText(false)) } func TestIndicatorWarn(t *testing.T) { - i := ui.NewStatusIndicator(ui.NewApp(""), config.NewStyles()) + i := ui.NewStatusIndicator(ui.NewApp(config.NewConfig(nil), ""), config.NewStyles()) i.Warn("Blee") assert.Equal(t, "[mediumvioletred::b] \n", i.GetText(false)) } func TestIndicatorErr(t *testing.T) { - i := ui.NewStatusIndicator(ui.NewApp(""), config.NewStyles()) + i := ui.NewStatusIndicator(ui.NewApp(config.NewConfig(nil), ""), config.NewStyles()) i.Err("Blee") assert.Equal(t, "[orangered::b] \n", i.GetText(false)) diff --git a/internal/ui/table.go b/internal/ui/table.go index de8322b3..d242670a 100644 --- a/internal/ui/table.go +++ b/internal/ui/table.go @@ -34,6 +34,7 @@ type Table struct { actions KeyActions gvr client.GVR Path string + Extras string cmdBuff *model.CmdBuff styles *config.Styles viewSetting *config.ViewSetting @@ -225,7 +226,12 @@ func (t *Table) doUpdate(data render.TableData) { c.SetTextColor(fg) col++ } - custData.RowEvents.Sort(custData.Namespace, custData.Header.IndexOf(t.sortCol.name, false), t.sortCol.name == "AGE", t.sortCol.asc) + custData.RowEvents.Sort( + custData.Namespace, + custData.Header.IndexOf(t.sortCol.name, false), + t.sortCol.name == "AGE", + t.sortCol.asc, + ) pads := make(MaxyPad, len(custData.Header)) ComputeMaxColumns(pads, t.sortCol.name, custData.Header, custData.RowEvents) @@ -322,8 +328,13 @@ func (t *Table) Refresh() { } // GetSelectedRow returns the entire selected row. -func (t *Table) GetSelectedRow() render.Row { - return t.model.Peek().RowEvents[t.GetSelectedRowIndex()-1].Row +func (t *Table) GetSelectedRow(path string) (render.Row, bool) { + data := t.model.Peek() + i, ok := data.RowEvents.FindIndex(path) + if !ok { + return render.Row{}, ok + } + return data.RowEvents[i].Row, true } // NameColIndex returns the index of the resource name column. @@ -409,7 +420,9 @@ func (t *Table) styleTitle() string { ns = path } } - + if t.Extras != "" { + ns = t.Extras + } var title string if ns == client.ClusterScope { title = SkinTitle(fmt.Sprintf(TitleFmt, base, rc), t.styles.Frame()) diff --git a/internal/ui/table_test.go b/internal/ui/table_test.go index 24b71180..5527ec24 100644 --- a/internal/ui/table_test.go +++ b/internal/ui/table_test.go @@ -41,8 +41,10 @@ func TestTableSelection(t *testing.T) { v.Update(m.Peek()) v.SelectRow(1, true) + r, ok := v.GetSelectedRow("r1") + assert.True(t, ok) assert.Equal(t, "r1", v.GetSelectedItem()) - assert.Equal(t, render.Row{ID: "r1", Fields: render.Fields{"blee", "duh", "fred"}}, v.GetSelectedRow()) + assert.Equal(t, render.Row{ID: "r1", Fields: render.Fields{"blee", "duh", "fred"}}, r) assert.Equal(t, "blee", v.GetSelectedCell(0)) assert.Equal(t, 1, v.GetSelectedRowIndex()) assert.Equal(t, []string{"r1"}, v.GetSelectedItems()) diff --git a/internal/view/app.go b/internal/view/app.go index 82cee333..80f85014 100644 --- a/internal/view/app.go +++ b/internal/view/app.go @@ -43,15 +43,16 @@ type App struct { cancelFn context.CancelFunc conRetry int32 clusterModel *model.ClusterInfo + history *model.History } // NewApp returns a K9s app instance. func NewApp(cfg *config.Config) *App { a := App{ - App: ui.NewApp(cfg.K9s.CurrentContext), + App: ui.NewApp(cfg, cfg.K9s.CurrentContext), Content: NewPageStack(), + history: model.NewHistory(model.MaxHistory), } - a.Config = cfg a.Views()["statusIndicator"] = ui.NewStatusIndicator(a.App, a.Styles) a.Views()["clusterInfo"] = NewClusterInfo(&a) @@ -121,22 +122,26 @@ func (a *App) Init(version string, rate int) error { func (a *App) suggestCommand() func(s string) (entries sort.StringSlice) { return func(s string) (entries sort.StringSlice) { if s == "" { - return + if a.history.Empty() { + return + } + return a.history.List() } + + lowS := strings.ToLower(s) for _, k := range a.command.alias.Aliases.Keys() { - lok, los := strings.ToLower(k), strings.ToLower(s) - if lok == los { + lowK := strings.ToLower(k) + if lowK == lowS { continue } - if strings.HasPrefix(lok, los) { - entries = append(entries, strings.Replace(k, los, "", 1)) + if strings.HasPrefix(lowK, lowS) { + entries = append(entries, strings.Replace(k, lowS, "", 1)) } } if len(entries) == 0 { - entries = nil + return nil } entries.Sort() - return } } diff --git a/internal/view/command.go b/internal/view/command.go index 0646320d..17015cbc 100644 --- a/internal/view/command.go +++ b/internal/view/command.go @@ -85,10 +85,10 @@ func (c *Command) xrayCmd(cmd string) error { } gvr, ok := c.alias.AsGVR(tokens[1]) if !ok { - return fmt.Errorf("Huh? `%s` Command not found", cmd) + return fmt.Errorf("Huh? `%s` command not found", cmd) } if !allowedXRay(gvr) { - return fmt.Errorf("Huh? `%s` Command not found", cmd) + return fmt.Errorf("Huh? `%s` command not found", cmd) } x := NewXray(gvr) @@ -106,6 +106,20 @@ func (c *Command) xrayCmd(cmd string) error { return c.exec(cmd, "xrays", x, true) } +func (c *Command) checkAccess(gvr string) error { + m, err := dao.MetaAccess.MetaFor(client.NewGVR(gvr)) + if err != nil { + return err + } + ns := client.CleanseNamespace(c.app.Config.ActiveNamespace()) + if dao.IsK8sMeta(m) && c.app.ConOK() { + if _, e := c.app.factory.CanForResource(ns, gvr, client.MonitorAccess); e != nil { + return e + } + } + return nil +} + // Exec the Command by showing associated display. func (c *Command) run(cmd, path string, clearStack bool) error { if c.specialCmd(cmd) { @@ -116,6 +130,10 @@ func (c *Command) run(cmd, path string, clearStack bool) error { if err != nil { return err } + if err := c.checkAccess(gvr); err != nil { + return err + } + switch cmds[0] { case "ctx", "context", "contexts": if len(cmds) == 2 { @@ -184,7 +202,7 @@ func (c *Command) specialCmd(cmd string) bool { func (c *Command) viewMetaFor(cmd string) (string, *MetaViewer, error) { gvr, ok := c.alias.AsGVR(cmd) if !ok { - return "", nil, fmt.Errorf("Huh? `%s` Command not found", cmd) + return "", nil, fmt.Errorf("Huh? `%s` command not found", cmd) } v, ok := customViewers[gvr] @@ -224,5 +242,10 @@ func (c *Command) exec(cmd, gvr string, comp model.Component, clearStack bool) e c.app.Content.Stack.Clear() } - return c.app.inject(comp) + if err := c.app.inject(comp); err != nil { + return err + } + + c.app.history.Push(cmd) + return nil } diff --git a/internal/view/container.go b/internal/view/container.go index ee61428f..2f2993cb 100644 --- a/internal/view/container.go +++ b/internal/view/container.go @@ -62,12 +62,12 @@ func (c *Container) bindKeys(aa ui.KeyActions) { } func (c *Container) k9sEnv() Env { - env := defaultEnv( - c.App().Conn().Config(), - c.GetTable().GetSelectedItem(), - c.GetTable().GetModel().Peek().Header, - c.GetTable().GetSelectedRow(), - ) + path := c.GetTable().GetSelectedItem() + row, ok := c.GetTable().GetSelectedRow(path) + if !ok { + log.Error().Msgf("unable to locate seleted row for %q", path) + } + env := defaultEnv(c.App().Conn().Config(), path, c.GetTable().GetModel().Peek().Header, row) env["NAMESPACE"], env["POD"] = client.Namespaced(c.GetTable().Path) return env diff --git a/internal/view/helpers.go b/internal/view/helpers.go index 328dee0c..e084f856 100644 --- a/internal/view/helpers.go +++ b/internal/view/helpers.go @@ -50,9 +50,10 @@ func k8sEnv(c *client.Config) Env { func defaultEnv(c *client.Config, path string, header render.Header, row render.Row) Env { env := k8sEnv(c) + log.Debug().Msgf("PATH %q::%q", path, row.Fields[1]) env["NAMESPACE"], env["NAME"] = client.Namespaced(path) - for i := range header { - env["COL-"+header[i].Name] = row.Fields[i] + for _, col := range header.Columns(true) { + env["COL-"+col] = row.Fields[header.IndexOf(col, true)] } return env diff --git a/internal/view/popeye.go b/internal/view/popeye.go index 29159a06..e2999073 100644 --- a/internal/view/popeye.go +++ b/internal/view/popeye.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "strconv" + "time" "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/client" @@ -38,6 +39,7 @@ func (p *Popeye) Init(ctx context.Context) error { return err } p.GetTable().GetModel().SetNamespace("*") + p.GetTable().GetModel().SetRefreshRate(5 * time.Second) return nil } @@ -52,7 +54,7 @@ func (p *Popeye) decorateRows(data render.TableData) render.TableData { sum += n } score := sum / len(data.RowEvents) - p.GetTable().Path = fmt.Sprintf("Score %d -- %s", score, grade(score)) + p.GetTable().Extras = fmt.Sprintf("Score %d -- %s", score, grade(score)) return data } @@ -75,7 +77,7 @@ func (p *Popeye) describeCmd(evt *tcell.EventKey) *tcell.EventKey { return evt } - v := NewSanitizer(client.NewGVR("report")) + v := NewSanitizer(client.NewGVR("sanitizer")) v.SetContextFn(sanitizerCtx(path)) if err := p.App().inject(v); err != nil { diff --git a/internal/view/registrar.go b/internal/view/registrar.go index 9650a5fc..8703a7ef 100644 --- a/internal/view/registrar.go +++ b/internal/view/registrar.go @@ -75,7 +75,7 @@ func miscViewers(vv MetaViewers) { vv[client.NewGVR("popeye")] = MetaViewer{ viewerFn: NewPopeye, } - vv[client.NewGVR("report")] = MetaViewer{ + vv[client.NewGVR("sanitizer")] = MetaViewer{ viewerFn: NewSanitizer, } diff --git a/internal/view/sanitizer.go b/internal/view/sanitizer.go index 58e444b1..87759eb7 100644 --- a/internal/view/sanitizer.go +++ b/internal/view/sanitizer.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "strings" - "time" "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/client" @@ -68,7 +67,6 @@ func (s *Sanitizer) Init(ctx context.Context) error { s.SetGraphicsColor(s.app.Styles.Xray().GraphicColor.Color()) s.SetTitle(strings.Title(s.gvr.R())) - s.model.SetRefreshRate(time.Duration(s.app.Config.K9s.GetRefreshRate()) * time.Second) s.model.SetNamespace(client.CleanseNamespace(s.app.Config.ActiveNamespace())) s.model.AddListener(s) @@ -88,7 +86,7 @@ func (s *Sanitizer) Init(ctx context.Context) error { // ExtraHints returns additional hints. func (s *Sanitizer) ExtraHints() map[string]string { - if !s.app.Styles.Xray().ShowIcons { + if s.app.Config.K9s.NoIcons { return nil } return xray.EmojiInfo() @@ -282,7 +280,7 @@ func (s *Sanitizer) TreeLoadFailed(err error) { } func (s *Sanitizer) update(node *xray.TreeNode) { - root := makeTreeNode(node, s.ExpandNodes(), s.app.Styles) + root := makeTreeNode(node, s.ExpandNodes(), s.app.Config.K9s.NoIcons, s.app.Styles) if node == nil { s.app.QueueUpdateDraw(func() { s.SetRoot(root) @@ -329,7 +327,7 @@ func (s *Sanitizer) TreeChanged(node *xray.TreeNode) { } func (s *Sanitizer) hydrate(parent *tview.TreeNode, n *xray.TreeNode) { - node := makeTreeNode(n, s.ExpandNodes(), s.app.Styles) + node := makeTreeNode(n, s.ExpandNodes(), s.app.Config.K9s.NoIcons, s.app.Styles) for _, c := range n.Children { s.hydrate(node, c) } diff --git a/internal/view/table.go b/internal/view/table.go index d9e95564..97cfd3ef 100644 --- a/internal/view/table.go +++ b/internal/view/table.go @@ -92,7 +92,11 @@ func (t *Table) EnvFn() EnvFunc { func (t *Table) defaultEnv() Env { path := t.GetSelectedItem() - env := defaultEnv(t.app.Conn().Config(), path, t.GetModel().Peek().Header, t.GetSelectedRow()) + row, ok := t.GetSelectedRow(path) + if !ok { + log.Error().Msgf("unable to locate selected row for %q", path) + } + env := defaultEnv(t.app.Conn().Config(), path, t.GetModel().Peek().Header, row) env["FILTER"] = t.SearchBuff().String() if env["FILTER"] == "" { env["NAMESPACE"], env["FILTER"] = client.Namespaced(path) diff --git a/internal/view/xray.go b/internal/view/xray.go index 1dd219b7..b1dbfc19 100644 --- a/internal/view/xray.go +++ b/internal/view/xray.go @@ -94,7 +94,7 @@ func (x *Xray) Init(ctx context.Context) error { // ExtraHints returns additional hints. func (x *Xray) ExtraHints() map[string]string { - if !x.app.Styles.Xray().ShowIcons { + if x.app.Config.K9s.NoIcons { return nil } return xray.EmojiInfo() @@ -510,7 +510,7 @@ func (x *Xray) TreeLoadFailed(err error) { } func (x *Xray) update(node *xray.TreeNode) { - root := makeTreeNode(node, x.ExpandNodes(), x.app.Styles) + root := makeTreeNode(node, x.ExpandNodes(), x.app.Config.K9s.NoIcons, x.app.Styles) if node == nil { x.app.QueueUpdateDraw(func() { x.SetRoot(root) @@ -557,7 +557,7 @@ func (x *Xray) TreeChanged(node *xray.TreeNode) { } func (x *Xray) hydrate(parent *tview.TreeNode, n *xray.TreeNode) { - node := makeTreeNode(n, x.ExpandNodes(), x.app.Styles) + node := makeTreeNode(n, x.ExpandNodes(), x.app.Config.K9s.NoIcons, x.app.Styles) for _, c := range n.Children { x.hydrate(node, c) } @@ -715,10 +715,10 @@ func rxFilter(q, path string) bool { return false } -func makeTreeNode(node *xray.TreeNode, expanded bool, styles *config.Styles) *tview.TreeNode { +func makeTreeNode(node *xray.TreeNode, expanded bool, showIcons bool, styles *config.Styles) *tview.TreeNode { n := tview.NewTreeNode("No data...") if node != nil { - n.SetText(node.Title(styles.Xray())) + n.SetText(node.Title(showIcons)) n.SetReference(node.Spec()) } n.SetSelectable(true) diff --git a/internal/xray/section.go b/internal/xray/section.go index bc2572e8..b4883151 100644 --- a/internal/xray/section.go +++ b/internal/xray/section.go @@ -18,7 +18,7 @@ func (s *Section) Render(ctx context.Context, ns string, o interface{}) error { if !ok { return fmt.Errorf("Expected Section, but got %T", o) } - root := NewTreeNode(section.Title, section.Title) + root := NewTreeNode(section.GVR, section.Title) parent, ok := ctx.Value(KeyParent).(*TreeNode) if !ok { return fmt.Errorf("Expecting a TreeNode but got %T", ctx.Value(KeyParent)) @@ -38,37 +38,26 @@ func cleanse(s string) string { func (c *Section) outcomeRefs(parent *TreeNode, section render.Section) { for k, issues := range section.Outcome { - p := NewTreeNode(section.Title, cleanse(k)) + p := NewTreeNode(section.GVR, cleanse(k)) parent.Add(p) - for _, i := range issues { - msg := colorize(cleanse(i.Message), i.Level) - c := NewTreeNode(fmt.Sprintf("issue_%d", i.Level), msg) - if i.Group == "__root__" { + for _, issue := range issues { + msg := colorize(cleanse(issue.Message), issue.Level) + c := NewTreeNode(fmt.Sprintf("issue_%d", issue.Level), msg) + if issue.Group == "__root__" { p.Add(c) continue } - if pa := p.Find(childOf(section.Title), i.Group); pa != nil { + if pa := p.Find(issue.GVR, issue.Group); pa != nil { pa.Add(c) continue } - pa := NewTreeNode(childOf(section.Title), i.Group) + pa := NewTreeNode(issue.GVR, issue.Group) pa.Add(c) p.Add(pa) } } } -func childOf(s string) string { - switch s { - case "deployment", "statefulset", "daemonset": - return "v1/pods" - case "pod": - return "containers" - default: - return "" - } -} - func colorize(s string, l config.Level) string { c := "green" switch l { diff --git a/internal/xray/tree_node.go b/internal/xray/tree_node.go index 88b5028b..756b6893 100644 --- a/internal/xray/tree_node.go +++ b/internal/xray/tree_node.go @@ -7,7 +7,6 @@ import ( "strings" "github.com/derailed/k9s/internal/client" - "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/dao" "github.com/rs/zerolog/log" "vbom.ml/util/sortorder" @@ -336,8 +335,8 @@ func (t *TreeNode) Find(gvr, id string) *TreeNode { } // Title computes the node title. -func (t *TreeNode) Title(styles config.Xray) string { - return t.computeTitle(styles) +func (t *TreeNode) Title(noIcons bool) string { + return t.computeTitle(noIcons) } // ---------------------------------------------------------------------------- @@ -384,8 +383,8 @@ func category(gvr string) string { return meta.SingularName } -func (t TreeNode) computeTitle(styles config.Xray) string { - if styles.ShowIcons { +func (t TreeNode) computeTitle(noIcons bool) string { + if !noIcons { return t.toEmojiTitle() } @@ -473,20 +472,22 @@ func toEmoji(gvr string) string { return ic } switch gvr { - case "replicasets", "replicaset": + case "apps/v1/replicasets": return "👯‍♂️" - case "nodes", "node": + case "v1/nodes": return "🖥 " - case "horizontalpodautoscalers", "horizontalpodautoscaler": + case "autoscaling/v1/horizontalpodautoscalers": return "♎️" - case "clusterrolebindings", "clusterrolebinding", "clusterroles", "clusterrole": + case "rbac.authorization.k8s.io/v1/clusterrolebindings", "rbac.authorization.k8s.io/v1/clusterroles": return "👩‍" - case "rolebindings", "rolebinding", "roles", "role": + case "rbac.authorization.k8s.io/v1/rolebindings", "rbac.authorization.k8s.io/v1/roles": return "👨🏻‍" - case "networkpolicies", "networkpolicy": + case "networking.k8s.io/v1/networkpolicies": return "📕" - case "poddisruptionbudgets", "poddisruptionbudget": + case "policy/v1beta1/poddisruptionbudgets": return "🏷 " + case "policy/v1beta1/podsecuritypolicies": + return "👮‍♂️" case "issue_0": return "👍" case "issue_1": @@ -504,29 +505,29 @@ func toEmoji(gvr string) string { func toEmojiXRay(gvr string) string { switch gvr { - case "containers", "container": + case "containers": return "🐳" - case "v1/namespaces", "namespaces", "namespace": + case "v1/namespaces": return "🗂 " - case "v1/pods", "pods", "pod": + case "v1/pods": return "🚛" - case "v1/services", "services", "service": + case "v1/services": return "💁‍♀️" - case "v1/serviceaccounts", "serviceaccounts", "serviceaccount": + case "v1/serviceaccounts": return "💳" - case "v1/persistentvolumes", "persistentvolumes", "persistentvolume": + case "v1/persistentvolumes": return "📚" - case "v1/persistentvolumeclaims", "persistentvolumeclaims", "persistentvolumeclaim": + case "v1/persistentvolumeclaims": return "🎟 " - case "v1/secrets", "secrets", "secret": + case "v1/secrets": return "🔒" - case "v1/configmaps", "configmaps", "configmap": + case "v1/configmaps": return "🗺 " - case "apps/v1/deployments", "deployments", "deployment": + case "apps/v1/deployments": return "🪂" - case "apps/v1/statefulsets", "statefulsets", "statefulset": + case "apps/v1/statefulsets": return "🎎" - case "apps/v1/daemonsets", "daemonsets", "daemonset": + case "apps/v1/daemonsets": return "😈" default: return ""