diff --git a/cmd/root.go b/cmd/root.go index b48fcffe..8f53a174 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -17,17 +17,14 @@ import ( ) const ( - appName = "k9s" - defaultRefreshRate = 2 // secs - defaultLogLevel = "info" - shortAppDesc = "A graphical CLI for your Kubernetes cluster management." - longAppDesc = "K9s is a CLI to view and manage your Kubernetes clusters." + appName = "k9s" + shortAppDesc = "A graphical CLI for your Kubernetes cluster management." + longAppDesc = "K9s is a CLI to view and manage your Kubernetes clusters." ) var ( version, commit, date = "dev", "dev", "n/a" - refreshRate int - logLevel string + k9sFlags *config.Flags k8sFlags *genericclioptions.ConfigFlags rootCmd = &cobra.Command{ @@ -71,12 +68,12 @@ func run(cmd *cobra.Command, args []string) { } }() - zerolog.SetGlobalLevel(parseLevel(logLevel)) + zerolog.SetGlobalLevel(parseLevel(*k9sFlags.LogLevel)) cfg := loadConfiguration() app := views.NewApp(cfg) { defer app.BailOut() - app.Init(version, refreshRate) + app.Init(version, *k9sFlags.RefreshRate) app.Run() } } @@ -91,8 +88,12 @@ func loadConfiguration() *config.Config { log.Warn().Msg("Unable to locate K9s config. Generating new configuration...") } - if refreshRate != defaultRefreshRate { - k9sCfg.K9s.OverrideRefreshRate(refreshRate) + if *k9sFlags.RefreshRate != config.DefaultRefreshRate { + k9sCfg.K9s.OverrideRefreshRate(*k9sFlags.RefreshRate) + } + + if k9sFlags.Headless != nil { + k9sCfg.K9s.OverrideHeadless(*k9sFlags.Headless) } if err := k9sCfg.Refine(k8sFlags); err != nil { @@ -126,18 +127,25 @@ func parseLevel(level string) zerolog.Level { } func initK9sFlags() { + k9sFlags = config.NewFlags() rootCmd.Flags().IntVarP( - &refreshRate, + k9sFlags.RefreshRate, "refresh", "r", - defaultRefreshRate, + config.DefaultRefreshRate, "Specifies the default refresh rate as an integer (sec)", ) rootCmd.Flags().StringVarP( - &logLevel, + k9sFlags.LogLevel, "logLevel", "l", - defaultLogLevel, + config.DefaultLogLevel, "Specify a log level (info, warn, debug, error, fatal, panic, trace)", ) + rootCmd.Flags().BoolVar( + k9sFlags.Headless, + "headless", + false, + "Turn K9s header off", + ) } func initK8sFlags() { diff --git a/go.sum b/go.sum index 33a9d7f8..399d1322 100644 --- a/go.sum +++ b/go.sum @@ -380,6 +380,7 @@ google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7 google.golang.org/appengine v1.5.0 h1:KxkO13IPW4Lslp2bz+KHP2E3gtFlrIGNThxkZQ3g+4c= google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/genproto v0.0.0-20170731182057-09f6ed296fc6/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/grpc v1.13.0 h1:bHIbVsCwmvbArgCJmLdgOdHFXlKqTOVjbibbS19cXHc= google.golang.org/grpc v1.13.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= diff --git a/internal/config/config.go b/internal/config/config.go index f2a36ae8..f3b9f710 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -174,6 +174,7 @@ func (c *Config) Load(path string) error { if cfg.K9s != nil { c.K9s = cfg.K9s } + log.Debug().Msgf("Headless ? %t", c.K9s.Headless) return nil } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index a3d2323a..b7566343 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -259,6 +259,7 @@ func TestSetup(t *testing.T) { var expectedConfig = `k9s: refreshRate: 100 + headless: false logBufferSize: 500 logRequestSize: 100 currentContext: blee @@ -310,6 +311,7 @@ var expectedConfig = `k9s: var resetConfig = `k9s: refreshRate: 2 + headless: false logBufferSize: 200 logRequestSize: 200 currentContext: blee diff --git a/internal/config/flags.go b/internal/config/flags.go new file mode 100644 index 00000000..e6e07ffe --- /dev/null +++ b/internal/config/flags.go @@ -0,0 +1,36 @@ +package config + +const ( + // DefaultRefreshRate represents the refresh interval. + DefaultRefreshRate = 2 // secs + // DefaultLogLevel represents the default log level. + DefaultLogLevel = "info" +) + +// Flags represents K9s configuration flags. +type Flags struct { + RefreshRate *int + LogLevel *string + Headless *bool +} + +// NewFlags returns new configuration flags. +func NewFlags() *Flags { + return &Flags{ + RefreshRate: intPtr(DefaultRefreshRate), + LogLevel: strPtr(DefaultLogLevel), + Headless: boolPtr(false), + } +} + +func boolPtr(b bool) *bool { + return &b +} + +func intPtr(i int) *int { + return &i +} + +func strPtr(s string) *string { + return &s +} diff --git a/internal/config/k9s.go b/internal/config/k9s.go index 128777a7..c8b0ae66 100644 --- a/internal/config/k9s.go +++ b/internal/config/k9s.go @@ -12,14 +12,16 @@ const ( // K9s tracks K9s configuration options. type K9s struct { - RefreshRate int `yaml:"refreshRate"` - manualRefreshRate int + RefreshRate int `yaml:"refreshRate"` + Headless bool `yaml:"headless"` LogBufferSize int `yaml:"logBufferSize"` LogRequestSize int `yaml:"logRequestSize"` CurrentContext string `yaml:"currentContext"` CurrentCluster string `yaml:"currentCluster"` Clusters map[string]*Cluster `yaml:"clusters,omitempty"` Plugins map[string]*Plugin `yaml:"plugins,omitempty"` + manualRefreshRate int + manualHeadless *bool } // NewK9s create a new K9s configuration. @@ -38,6 +40,21 @@ func (k *K9s) OverrideRefreshRate(r int) { k.manualRefreshRate = r } +// OverrideHeadless set the headlessness manually. +func (k *K9s) OverrideHeadless(b bool) { + k.manualHeadless = &b +} + +// GetHeadless returns headless setting. +func (k *K9s) GetHeadless() bool { + h := k.Headless + if k.manualHeadless != nil && *k.manualHeadless { + h = *k.manualHeadless + } + + return h +} + // GetRefreshRate returns the current refresh rate. func (k *K9s) GetRefreshRate() int { rate := k.RefreshRate diff --git a/internal/perf/benchmark.go b/internal/perf/benchmark.go index 703a1459..05c84bd4 100644 --- a/internal/perf/benchmark.go +++ b/internal/perf/benchmark.go @@ -45,6 +45,7 @@ func (b *Benchmark) init(base string) error { if err != nil { return err } + log.Debug().Msgf("Benchmarking Request %s", req.URL.String()) if b.config.Auth.User != "" || b.config.Auth.Password != "" { req.SetBasicAuth(b.config.Auth.User, b.config.Auth.Password) diff --git a/internal/ui/action.go b/internal/ui/action.go index 0ad031e7..ed1d7ab6 100644 --- a/internal/ui/action.go +++ b/internal/ui/action.go @@ -41,8 +41,8 @@ func (a KeyActions) Hints() Hints { for _, k := range kk { if name, ok := tcell.KeyNames[tcell.Key(k)]; ok { hh = append(hh, Hint{ - mnemonic: name, - description: a[tcell.Key(k)].Description}) + Mnemonic: name, + Description: a[tcell.Key(k)].Description}) } else { log.Error().Msgf("Unable to locate KeyName for %#v", string(k)) } diff --git a/internal/ui/app.go b/internal/ui/app.go index 8080d233..5a2f5175 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -44,6 +44,7 @@ type ( content *tview.Pages views map[string]tview.Primitive cmdBuff *CmdBuff + hints Hints } ) @@ -224,9 +225,15 @@ func (a *App) ActiveView() Igniter { // SetHints updates menu hints. func (a *App) SetHints(h Hints) { + a.hints = h a.views["menu"].(*MenuView).HydrateMenu(h) } +// GetHints retrieves the currently active hints. +func (a *App) GetHints() Hints { + return a.hints +} + // StatusReset reset log back to normal. func (a *App) StatusReset() { a.Logo().Reset() diff --git a/internal/ui/hint.go b/internal/ui/hint.go index bb671710..5e84fd99 100644 --- a/internal/ui/hint.go +++ b/internal/ui/hint.go @@ -8,7 +8,7 @@ import ( type ( // Hint represents keyboard mnemonic. Hint struct { - mnemonic, description string + Mnemonic, Description string } // Hints a collection of keyboard mnemonics. Hints []Hint @@ -28,8 +28,8 @@ func (h Hints) Swap(i, j int) { } func (h Hints) Less(i, j int) bool { - n, err1 := strconv.Atoi(h[i].mnemonic) - m, err2 := strconv.Atoi(h[j].mnemonic) + n, err1 := strconv.Atoi(h[i].Mnemonic) + m, err2 := strconv.Atoi(h[j].Mnemonic) if err1 == nil && err2 == nil { return n < m } @@ -39,5 +39,5 @@ func (h Hints) Less(i, j int) bool { if err1 != nil && err2 == nil { return false } - return strings.Compare(h[i].description, h[j].description) < 0 + return strings.Compare(h[i].Description, h[j].Description) < 0 } diff --git a/internal/ui/indicator.go b/internal/ui/indicator.go new file mode 100644 index 00000000..3a0ffa11 --- /dev/null +++ b/internal/ui/indicator.go @@ -0,0 +1,87 @@ +package ui + +import ( + "context" + "fmt" + "time" + + "github.com/derailed/k9s/internal/config" + "github.com/derailed/tview" + "github.com/gdamore/tcell" +) + +// IndicatorView represents a status indicator. +type IndicatorView struct { + *tview.TextView + app *App + styles *config.Styles + permanent string + + cancel context.CancelFunc +} + +// NewIndicatorView returns a new logo. +func NewIndicatorView(app *App, styles *config.Styles) *IndicatorView { + v := IndicatorView{ + TextView: tview.NewTextView(), + app: app, + styles: styles, + } + v.SetTextAlign(tview.AlignCenter) + v.SetTextColor(tcell.ColorWhite) + v.SetBackgroundColor(styles.BgColor()) + v.SetDynamicColors(true) + + return &v +} + +// SetPermanent sets permanent title to be reset to after updates +func (v *IndicatorView) SetPermanent(info string) { + v.permanent = info + v.SetText(info) +} + +// Reset clears out the logo view and resets colors. +func (v *IndicatorView) Reset() { + v.Clear() + v.SetPermanent(v.permanent) +} + +// Err displays a log error state. +func (v *IndicatorView) Err(msg string) { + v.update(msg, "orangered") +} + +// Warn displays a log warning state. +func (v *IndicatorView) Warn(msg string) { + v.update(msg, "mediumvioletred") +} + +// Info displays a log info state. +func (v *IndicatorView) Info(msg string) { + v.update(msg, "lawngreen") +} + +func (v *IndicatorView) update(msg, c string) { + v.setText(fmt.Sprintf("[%s::b] <%s> ", c, msg)) +} + +func (v *IndicatorView) setText(msg string) { + if v.cancel != nil { + v.cancel() + } + v.SetText(msg) + + var ctx context.Context + ctx, v.cancel = context.WithCancel(context.Background()) + go func(ctx context.Context) { + select { + case <-ctx.Done(): + return + case <-time.After(5 * time.Second): + v.app.QueueUpdateDraw(func() { + v.Reset() + }) + } + }(ctx) +} diff --git a/internal/ui/menu.go b/internal/ui/menu.go index 579673a4..a738cde8 100644 --- a/internal/ui/menu.go +++ b/internal/ui/menu.go @@ -3,6 +3,7 @@ package ui import ( "fmt" "regexp" + "runtime" "sort" "strconv" "strings" @@ -68,12 +69,12 @@ func (v *MenuView) buildMenuTable(hh Hints) [][]string { firstCmd := true maxKeys := make([]int, colCount+1) for _, h := range hh { - isDigit := menuRX.MatchString(h.mnemonic) + isDigit := menuRX.MatchString(h.Mnemonic) if !isDigit && firstCmd { row, col, firstCmd = 0, col+1, false } - if maxKeys[col] < len(h.mnemonic) { - maxKeys[col] = len(h.mnemonic) + if maxKeys[col] < len(h.Mnemonic) { + maxKeys[col] = len(h.Mnemonic) } table[row][col] = h row++ @@ -89,7 +90,7 @@ func (v *MenuView) buildMenuTable(hh Hints) [][]string { } for row := range strTable { for col := range strTable[row] { - strTable[row][col] = v.formatMenu(table[row][col], maxKeys[col]) + strTable[row][col] = keyConv(v.formatMenu(table[row][col], maxKeys[col])) } } @@ -99,6 +100,18 @@ func (v *MenuView) buildMenuTable(hh Hints) [][]string { // ---------------------------------------------------------------------------- // Helpers... +func keyConv(s string) string { + if !strings.Contains(s, "alt") { + return s + } + + if runtime.GOOS != "darwin" { + return s + } + + return strings.Replace(s, "alt", "opt", 1) +} + func toMnemonic(s string) string { if len(s) == 0 { return s @@ -108,9 +121,9 @@ func toMnemonic(s string) string { } func (v *MenuView) formatMenu(h Hint, size int) string { - i, err := strconv.Atoi(h.mnemonic) + i, err := strconv.Atoi(h.Mnemonic) if err == nil { - return formatNSMenu(i, h.description, v.styles.Frame()) + return formatNSMenu(i, h.Description, v.styles.Frame()) } return formatPlainMenu(h, size, v.styles.Frame()) @@ -128,7 +141,7 @@ func formatPlainMenu(h Hint, size int, styles config.Frame) string { fmat := strings.Replace(menuFmt, "[key", "["+styles.Menu.KeyColor, 1) fmat = strings.Replace(fmat, "[fg", "["+styles.Menu.FgColor, 1) fmat = strings.Replace(fmat, ":bg:", ":"+styles.Title.BgColor+":", -1) - return fmt.Sprintf(fmat, toMnemonic(h.mnemonic), h.description) + return fmt.Sprintf(fmat, toMnemonic(h.Mnemonic), h.Description) } // ----------------------------------------------------------------------------- diff --git a/internal/ui/table.go b/internal/ui/table.go index c43dc43a..a9836410 100644 --- a/internal/ui/table.go +++ b/internal/ui/table.go @@ -1,8 +1,10 @@ package ui import ( + "errors" "fmt" "path" + "regexp" "strings" "time" @@ -381,6 +383,37 @@ func (v *Table) filtered() resource.TableData { } q := v.cmdBuff.String() + if isFuzzySelector(q) { + return v.fuzzFilter(q[2:]) + } + + return v.rxFilter(q) +} + +func (v *Table) rxFilter(q string) resource.TableData { + rx, err := regexp.Compile(`(?i)` + v.cmdBuff.String()) + if err != nil { + log.Error().Err(errors.New("Invalid filter expression")).Msg("Regexp") + v.cmdBuff.Clear() + return v.data + } + + filtered := resource.TableData{ + Header: v.data.Header, + Rows: resource.RowEvents{}, + Namespace: v.data.Namespace, + } + for k, row := range v.data.Rows { + f := strings.Join(row.Fields, " ") + if rx.MatchString(f) { + filtered.Rows[k] = row + } + } + + return filtered +} + +func (v *Table) fuzzFilter(q string) resource.TableData { var ss, kk []string for k, row := range v.data.Rows { ss = append(ss, row.Fields[v.NameColIndex()]) diff --git a/internal/ui/table_helper.go b/internal/ui/table_helper.go index 0f31b53c..c8f90b30 100644 --- a/internal/ui/table_helper.go +++ b/internal/ui/table_helper.go @@ -26,6 +26,7 @@ var ( cpuRX = regexp.MustCompile(`\A.{0,1}CPU`) memRX = regexp.MustCompile(`\A.{0,1}MEM`) labelCmd = regexp.MustCompile(`\A\-l`) + fuzzyCmd = regexp.MustCompile(`\A\-f`) ) type cleanseFn func(string) string @@ -47,6 +48,13 @@ func isLabelSelector(s string) bool { return labelCmd.MatchString(s) } +func isFuzzySelector(s string) bool { + if s == "" { + return false + } + return fuzzyCmd.MatchString(s) +} + func trimLabelSelector(s string) string { return strings.TrimSpace(s[2:]) } diff --git a/internal/views/app.go b/internal/views/app.go index c41e56e2..38d0a40d 100644 --- a/internal/views/app.go +++ b/internal/views/app.go @@ -2,10 +2,12 @@ package views import ( "context" + "fmt" "time" "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/k8s" + "github.com/derailed/k9s/internal/resource" "github.com/derailed/k9s/internal/ui" "github.com/derailed/k9s/internal/watch" "github.com/derailed/tview" @@ -17,7 +19,7 @@ import ( const ( splashTime = 1 devMode = "dev" - clusterRefresh = time.Duration(15 * time.Second) + clusterRefresh = time.Duration(5 * time.Second) ) type ( @@ -50,6 +52,7 @@ type ( informer *watch.Informer stopCh chan struct{} forwarders map[string]forwarder + version string } ) @@ -63,6 +66,7 @@ func NewApp(cfg *config.Config) *appView { v.InitBench(cfg.K9s.CurrentCluster) v.command = newCommand(&v) + v.Views()["indicator"] = ui.NewIndicatorView(v.App, v.Styles) v.Views()["flash"] = ui.NewFlashView(v.Application, "Initializing...") v.Views()["clusterInfo"] = newClusterInfoView(&v, k8s.NewMetricsServer(cfg.GetConnection())) @@ -70,6 +74,7 @@ func NewApp(cfg *config.Config) *appView { } func (a *appView) Init(version string, rate int) { + a.version = version a.App.Init() a.AddActions(ui.KeyActions{ @@ -85,6 +90,9 @@ func (a *appView) Init(version string, rate int) { } a.startInformer(ns) a.clusterInfo().init(version) + if a.Config.K9s.GetHeadless() { + a.refreshIndicator() + } } header := tview.NewFlex() @@ -96,14 +104,17 @@ func (a *appView) Init(version string, rate int) { } main := tview.NewFlex() - { - main.SetDirection(tview.FlexRow) + main.SetDirection(tview.FlexRow) + + if !a.Config.K9s.GetHeadless() { main.AddItem(header, 7, 1, false) - main.AddItem(a.Cmd(), 3, 1, false) - main.AddItem(a.Frame(), 0, 10, true) - main.AddItem(a.Crumbs(), 2, 1, false) - main.AddItem(a.Flash(), 1, 1, false) + } else { + main.AddItem(a.indicator(), 1, 1, false) } + main.AddItem(a.Cmd(), 3, 1, false) + main.AddItem(a.Frame(), 0, 10, true) + main.AddItem(a.Crumbs(), 2, 1, false) + main.AddItem(a.Flash(), 1, 1, false) a.Main().AddPage("main", main, true, false) a.Main().AddPage("splash", ui.NewSplash(a.Styles, version), true, true) @@ -117,12 +128,45 @@ func (a *appView) clusterUpdater(ctx context.Context) { return case <-time.After(clusterRefresh): a.QueueUpdateDraw(func() { + if a.Config.K9s.GetHeadless() { + a.refreshIndicator() + } a.clusterInfo().refresh() }) } } } +func (a *appView) refreshIndicator() { + mx := k8s.NewMetricsServer(a.Conn()) + cluster := resource.NewCluster(a.Conn(), &log.Logger, mx) + var cmx k8s.ClusterMetrics + nos, nmx, err := fetchResources(a) + cpu, mem := "0", "0" + if err == nil { + cluster.Metrics(nos, nmx, &cmx) + cpu = resource.AsPerc(cmx.PercCPU) + if cpu == "0" { + cpu = resource.NAValue + } + mem = resource.AsPerc(cmx.PercMEM) + if mem == "0" { + mem = resource.NAValue + } + } + + info := fmt.Sprintf( + "[orange::b]K9s [aqua::]%s [white::]%s:%s:%s [lawngreen::]%s%%[white::]::[darkturquoise::]%s%%", + a.version, + cluster.ClusterName(), + cluster.UserName(), + cluster.Version(), + cpu, + mem, + ) + a.indicator().SetPermanent(info) +} + func (a *appView) startInformer(ns string) { if a.stopCh != nil { close(a.stopCh) @@ -135,6 +179,10 @@ func (a *appView) startInformer(ns string) { log.Panic().Err(err).Msgf("%v", err) } a.informer.Run(a.stopCh) + + if a.Config.K9s.GetHeadless() { + a.refreshIndicator() + } } // BailOut exists the application. @@ -188,6 +236,15 @@ func (a *appView) Run() { func (a *appView) status(l ui.FlashLevel, msg string) { a.Flash().Info(msg) + if a.Config.K9s.GetHeadless() { + a.setIndicator(l, msg) + } else { + a.setLogo(l, msg) + } + a.Draw() +} + +func (a *appView) setLogo(l ui.FlashLevel, msg string) { switch l { case ui.FlashErr: a.Logo().Err(msg) @@ -201,6 +258,20 @@ func (a *appView) status(l ui.FlashLevel, msg string) { a.Draw() } +func (a *appView) setIndicator(l ui.FlashLevel, msg string) { + switch l { + case ui.FlashErr: + a.indicator().Err(msg) + case ui.FlashWarn: + a.indicator().Warn(msg) + case ui.FlashInfo: + a.indicator().Info(msg) + default: + a.indicator().Reset() + } + a.Draw() +} + func (a *appView) prevCmd(evt *tcell.EventKey) *tcell.EventKey { if top, ok := a.command.previousCmd(); ok { log.Debug().Msgf("Previous command %s", top) @@ -225,7 +296,12 @@ func (a *appView) helpCmd(evt *tcell.EventKey) *tcell.EventKey { if a.InCmdMode() { return evt } - a.inject(newHelpView(a, a.ActiveView())) + if _, ok := a.Frame().GetPrimitive("main").(*helpView); ok { + return evt + } + + h := newHelpView(a, a.ActiveView(), a.GetHints()) + a.inject(h) return nil } @@ -233,6 +309,10 @@ func (a *appView) aliasCmd(evt *tcell.EventKey) *tcell.EventKey { if a.InCmdMode() { return evt } + if _, ok := a.Frame().GetPrimitive("main").(*aliasView); ok { + return evt + } + a.inject(newAliasView(a, a.ActiveView())) return nil @@ -267,3 +347,7 @@ func (a *appView) inject(i ui.Igniter) { func (a *appView) clusterInfo() *clusterInfoView { return a.Views()["clusterInfo"].(*clusterInfoView) } + +func (a *appView) indicator() *ui.IndicatorView { + return a.Views()["indicator"].(*ui.IndicatorView) +} diff --git a/internal/views/cluster_info.go b/internal/views/cluster_info.go index c6922ad0..b4be4d8a 100644 --- a/internal/views/cluster_info.go +++ b/internal/views/cluster_info.go @@ -125,13 +125,13 @@ func (v *clusterInfoView) refresh() { v.refreshMetrics(cluster, row) } -func (v *clusterInfoView) fetchResources() (k8s.Collection, k8s.Collection, error) { - nos, err := v.app.informer.List(watch.NodeIndex, "", metav1.ListOptions{}) +func fetchResources(app *appView) (k8s.Collection, k8s.Collection, error) { + nos, err := app.informer.List(watch.NodeIndex, "", metav1.ListOptions{}) if err != nil { return nil, nil, err } - nmx, err := v.app.informer.List(watch.NodeMXIndex, "", metav1.ListOptions{}) + nmx, err := app.informer.List(watch.NodeMXIndex, "", metav1.ListOptions{}) if err != nil { return nil, nil, err } @@ -140,7 +140,7 @@ func (v *clusterInfoView) fetchResources() (k8s.Collection, k8s.Collection, erro } func (v *clusterInfoView) refreshMetrics(cluster *resource.Cluster, row int) { - nos, nmx, err := v.fetchResources() + nos, nmx, err := fetchResources(v.app) if err != nil { log.Warn().Err(err).Msg("NodeMetrics") return diff --git a/internal/views/command.go b/internal/views/command.go index a6981974..84e65a2a 100644 --- a/internal/views/command.go +++ b/internal/views/command.go @@ -55,10 +55,10 @@ func (c *command) isStdCmd(cmd string) bool { c.app.BailOut() return true case cmd == "?", cmd == "help": - c.app.inject(newHelpView(c.app, c.app.ActiveView())) + c.app.helpCmd(nil) return true case cmd == "alias": - c.app.inject(newAliasView(c.app, c.app.ActiveView())) + c.app.aliasCmd(nil) return true case policyMatcher.MatchString(cmd): tokens := policyMatcher.FindAllStringSubmatch(cmd, -1) diff --git a/internal/views/help.go b/internal/views/help.go index 2adbd360..ef884a52 100644 --- a/internal/views/help.go +++ b/internal/views/help.go @@ -3,6 +3,9 @@ package views import ( "context" "fmt" + "runtime" + "sort" + "strings" "github.com/derailed/k9s/internal/ui" "github.com/derailed/tview" @@ -21,7 +24,7 @@ type ( } helpView struct { - *tview.TextView + *tview.Table app *appView current ui.Igniter @@ -29,19 +32,18 @@ type ( } ) -func newHelpView(app *appView, current ui.Igniter) *helpView { +func newHelpView(app *appView, current ui.Igniter, hh ui.Hints) *helpView { v := helpView{ - TextView: tview.NewTextView(), - app: app, - actions: make(ui.KeyActions), + Table: tview.NewTable(), + app: app, + actions: make(ui.KeyActions), } - v.SetTextColor(tcell.ColorAqua) v.SetBorder(true) v.SetBorderPadding(0, 0, 1, 1) - v.SetDynamicColors(true) v.SetInputCapture(v.keyboard) v.current = current v.bindKeys() + v.build(hh) return &v } @@ -73,26 +75,18 @@ func (v *helpView) backCmd(evt *tcell.EventKey) *tcell.EventKey { func (v *helpView) Init(_ context.Context, _ string) { v.resetTitle() - - v.showGeneral() - v.showNav() - v.showHelp() v.app.SetHints(v.Hints()) } -func (v *helpView) showHelp() { - views := []helpItem{ +func (v *helpView) showHelp() ui.Hints { + return ui.Hints{ {"?", "Help"}, {"Ctrl-a", "Aliases view"}, } - fmt.Fprintf(v, "️️\n😱 [aqua::b]%s\n", "Help") - for _, h := range views { - v.printHelp(h.key, h.description) - } } -func (v *helpView) showNav() { - navigation := []helpItem{ +func (v *helpView) showNav() ui.Hints { + return ui.Hints{ {"g", "Goto Top"}, {"G", "Goto Bottom"}, {"Ctrl-b", "Page Down"}, @@ -102,14 +96,10 @@ func (v *helpView) showNav() { {"k", "Up"}, {"j", "Down"}, } - fmt.Fprintf(v, "\n🤖 [aqua::b]%s\n", "View Navigation") - for _, h := range navigation { - v.printHelp(h.key, h.description) - } } -func (v *helpView) showGeneral() { - general := []helpItem{ +func (v *helpView) showGeneral() ui.Hints { + return ui.Hints{ {":", "Command mode"}, {"/", "Filter mode"}, {"esc", "Clear filter"}, @@ -120,14 +110,6 @@ func (v *helpView) showGeneral() { {"p", "Previous resource view"}, {":q", "Quit"}, } - fmt.Fprintf(v, "🏠 [aqua::b]%s\n", "General") - for _, h := range general { - v.printHelp(h.key, h.description) - } -} - -func (v *helpView) printHelp(key, desc string) { - fmt.Fprintf(v, "[dodgerblue::b]%9s [white::]%s\n", key, desc) } func (v *helpView) Hints() ui.Hints { @@ -141,3 +123,55 @@ func (v *helpView) getTitle() string { func (v *helpView) resetTitle() { v.SetTitle(fmt.Sprintf(helpTitleFmt, helpTitle)) } + +func (v *helpView) build(hh ui.Hints) { + v.Clear() + sort.Sort(hh) + v.addSection(0, 0, "Resource", hh) + v.addSection(0, 4, "General", v.showGeneral()) + v.addSection(0, 6, "Navigation", v.showNav()) + v.addSection(0, 8, "Help", v.showHelp()) +} + +func (v *helpView) addSection(r, c int, title string, hh ui.Hints) { + row := r + cell := tview.NewTableCell(title) + cell.SetTextColor(tcell.ColorWhite) + cell.SetAttributes(tcell.AttrBold) + v.SetCell(r, c, cell) + row++ + + for _, h := range hh { + col := c + cell := tview.NewTableCell(toMnemonic(h.Mnemonic)) + cell.SetTextColor(tcell.ColorDodgerBlue) + cell.SetAttributes(tcell.AttrBold) + cell.SetAlign(tview.AlignRight) + v.SetCell(row, col, cell) + col++ + cell = tview.NewTableCell(h.Description) + cell.SetTextColor(tcell.ColorWhite) + v.SetCell(row, col, cell) + row++ + } +} + +func toMnemonic(s string) string { + if len(s) == 0 { + return s + } + + return "<" + keyConv(strings.ToLower(s)) + ">" +} + +func keyConv(s string) string { + if !strings.Contains(s, "alt") { + return s + } + + if runtime.GOOS != "darwin" { + return s + } + + return strings.Replace(s, "alt", "opt", 1) +} diff --git a/internal/views/help_test.go b/internal/views/help_test.go index 4b0eed9a..f3dbf5c5 100644 --- a/internal/views/help_test.go +++ b/internal/views/help_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/derailed/k9s/internal/config" + "github.com/derailed/k9s/internal/ui" "github.com/stretchr/testify/assert" v1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -40,10 +41,10 @@ func newNS(n string) v1.Namespace { func TestNewHelpView(t *testing.T) { cfg := config.NewConfig(ks{}) a := NewApp(cfg) - v := newHelpView(a, nil) + + v := newHelpView(a, nil, ui.Hints{{"blee", "duh"}}) v.Init(nil, "") - const e = "🏠 General\n : Command mode\n / Filter mode\n esc Clear filter\n tab Next term match\n backtab Previous term match\n Ctrl-r Refresh\n Shift-i Invert Sort\n p Previous resource view\n :q Quit\n\n🤖 View Navigation\n g Goto Top\n G Goto Bottom\n Ctrl-b Page Down\n Ctrl-f Page Up\n h Left\n l Right\n k Up\n j Down\n️️\n😱 Help\n ? Help\n Ctrl-a Aliases view\n" - assert.Equal(t, e, v.GetText(true)) - assert.Equal(t, "Help", v.getTitle()) + assert.Equal(t, "", v.GetCell(1, 0).Text) + assert.Equal(t, "duh", v.GetCell(1, 1).Text) }