diff --git a/README.md b/README.md index 9a56db1e..512e6754 100644 --- a/README.md +++ b/README.md @@ -80,10 +80,17 @@ k9s --context coolCtx ```yaml k9s: + # Indicates api-server poll intervals. refreshRate: 2 + # Indicates log view maximum buffer size. Default 1k lines. logBufferSize: 200 + # Indicates how many lines of logs to retrieve from the api-server. Default 200 lines. + logRequestSize: 200 + # Indicates the current kube context. Defaults to current context currentContext: minikube + # Indicates the current kube cluster. Defaults to current context cluster currentCluster: minikube + # Persists per cluster preferences for favorite namespaces and view. clusters: bitchn: namespace: @@ -143,12 +150,103 @@ K9s uses aliases to navigate most K8s resources. This initial drop is brittle. K9s will most likely blow up... -1. You're running older versions of Kubernetes. K9s works best Kubernetes 1.10+ -1. You don't have enough RBAC fu to manage your cluster +1. You're running older versions of Kubernetes. K9s works best Kubernetes 1.10+. +1. You don't have enough RBAC fu to manage your cluster (see RBAC section below). 1. Your cluster does not run a metric server. --- +## K9s RBAC FU + +On RBAC enabled clusters, you would need to give your users/groups capabilities so that they can use K9s to explore Kubernetes cluster. +K9s needs minimaly read privileges at both the cluster and namespace level to display resources and metrics. + +These rules below are just suggestions. You will need to customize them based on your environment policies. If you need to edit/delete resources extra Fu will be necessary. + +> NOTE! Cluster/Namespace access may change in the future as K9s evolves. + +> NOTE! We expect K9s to keep running even in atrophied clusters/namespaces. Please file issues if this is not the case! + +### Cluster RBAC scope + +```yaml +--- +# K9s Reader ClusterRole +kind: ClusterRole +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: k9s +rules: + # Grants RO access to cluster resources node and namespace + - apiGroups: [""] + resources: ["nodes", "namespaces"] + verbs: ["get", "list"] + # Grants RO access to RBAC resources + - apiGroups: ["rbac.authorization.k8s.io"] + resources: ["clusterroles", "roles", "clusterrolebindings", "rolebindings"] + verbs: ["get", "list"] + # Grants RO access to CRD resources + - apiGroups: ["apiextensions.k8s.io"] + resources: ["customresourcedefinitions"] + verbs: ["get", "list"] + # Grants RO access to netric server + - apiGroups: ["metrics.k8s.io"] + resources: ["nodes", "pods"] + verbs: ["list"] + +--- +# Sample K9s user ClusterRoleBinding +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: k9s +subjects: + - kind: User + name: fernand + apiGroup: rbac.authorization.k8s.io +roleRef: + kind: ClusterRole + name: k9s + apiGroup: rbac.authorization.k8s.io +``` + +### Namespace RBAC scope + +If your users are constrained to certain namespaces, K9s will need to following role to enable read access to namespaced resources. + +```yaml +--- +# K9s Reader Role (default namespace) +kind: Role +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: k9s + namespace: default +rules: + # Grants RO access to most namespaced resources + - apiGroups: ["", "apps", "autoscaling", "batch", "extensions"] + resources: ["*"] + verbs: ["get", "list"] + +--- +# Sample K9s user RoleBinding +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: k9s + namespace: default +subjects: + - kind: User + name: fernand + apiGroup: rbac.authorization.k8s.io +roleRef: + kind: Role + name: k9s + apiGroup: rbac.authorization.k8s.io +``` + +--- + ## Disclaimer This is still work in progress! If there is enough interest in the Kubernetes @@ -169,7 +267,7 @@ to make this project a reality! ## Contact Info 1. **Email**: fernand@imhotep.io -1. **Twitter**: [@kitesurfer](https://twitter.com/kitesurfer?lang=en) +2. **Twitter**: [@kitesurfer](https://twitter.com/kitesurfer?lang=en) --- diff --git a/change_logs/release_0.2.5.md b/change_logs/release_0.2.5.md new file mode 100644 index 00000000..2de9e711 --- /dev/null +++ b/change_logs/release_0.2.5.md @@ -0,0 +1,30 @@ +# Release v0.2.5 + +## Notes + +Thank you to all that contributed with flushing out issues with K9s! I'll try +to mark some of these issues as fixed. But if you don't mind grab the latest +rev and see if we're happier with some of the fixes! + +If you've filed an issue please help me verify and close. + +Thank you so much for your support!! + +--- + +## Change Logs + ++ Added an actual help view to show available key bindings. Use `` to access it. ++ Changed alias view to now be accessible via key `` ++ Pressing `` while on the namespace/context views will navigate directly to the pods view. ++ Added resource view breadcrumbs to easily navigate back in history. Use key `

` to navigate back. ++ Added configuration `logBufferSize` to limit the size of the log view while viewing chatty or big logs. + +--- + +## Resolved Bugs + ++ [Issue #116](https://github.com/derailed/k9s/issues/116) ++ [Issue #113](https://github.com/derailed/k9s/issues/113) ++ [Issue #111](https://github.com/derailed/k9s/issues/111) ++ [Issue #110](https://github.com/derailed/k9s/issues/110) \ No newline at end of file diff --git a/cmd/info.go b/cmd/info.go index 308163ed..cf2d44cd 100644 --- a/cmd/info.go +++ b/cmd/info.go @@ -2,9 +2,9 @@ package cmd import ( "fmt" - "strings" "github.com/derailed/k9s/internal/config" + "github.com/derailed/k9s/internal/printer" "github.com/spf13/cobra" ) @@ -14,18 +14,11 @@ func infoCmd() *cobra.Command { Short: "Print configuration information", Long: "Print configuration information", Run: func(cmd *cobra.Command, args []string) { - const ( - cyan = "\033[1;36m%s\033[0m" - green = "\033[1;32m%s\033[0m" - magenta = "\033[1;35m%s\033[0m" - ) - fmt.Printf(cyan+"\n", strings.Repeat("-", 80)) - fmt.Printf(green+"\n", "🐶 K9s Information") - fmt.Printf(magenta, fmt.Sprintf("%-10s", "LogFile:")) - fmt.Printf("%s\n", config.K9sLogs) - fmt.Printf(magenta, fmt.Sprintf("%-10s", "Config:")) - fmt.Printf("%s\n", config.K9sConfigFile) - fmt.Printf(cyan+"\n", strings.Repeat("-", 80)) + fmt.Printf(printer.Colorize(fmt.Sprintf("%-15s", "Configuration:"), printer.ColorMagenta)) + fmt.Println(printer.Colorize(config.K9sConfigFile, printer.ColorDarkGray)) + + fmt.Printf(printer.Colorize(fmt.Sprintf("%-15s", "Logs:"), printer.ColorMagenta)) + fmt.Println(printer.Colorize(config.K9sLogs, printer.ColorDarkGray)) }, } } diff --git a/cmd/root.go b/cmd/root.go index b49df7ed..9890a941 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -6,6 +6,7 @@ import ( "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/k8s" + "github.com/derailed/k9s/internal/printer" "github.com/derailed/k9s/internal/views" "github.com/rs/zerolog" "github.com/rs/zerolog/log" @@ -14,22 +15,23 @@ 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." ) var ( - version = "dev" - commit = "dev" - date = "n/a" - refreshRate int - logLevel string - k8sFlags *genericclioptions.ConfigFlags + version, commit, date = "dev", "dev", "n/a" + refreshRate int + logLevel string + k8sFlags *genericclioptions.ConfigFlags rootCmd = &cobra.Command{ - Use: "k9s", - Short: "A graphical CLI for your Kubernetes cluster management.", - Long: `K9s is a CLI to view and manage your Kubernetes clusters.`, + Use: appName, + Short: shortAppDesc, + Long: longAppDesc, Run: run, } _ config.KubeSettings = &k8s.Config{} @@ -37,63 +39,77 @@ var ( func init() { rootCmd.AddCommand(versionCmd(), infoCmd()) - - rootCmd.Flags().IntVarP( - &refreshRate, - "refresh", "r", - defaultRefreshRate, - "Specifies the default refresh rate as an integer (sec)", - ) - rootCmd.Flags().StringVarP( - &logLevel, - "logLevel", "l", - defaultLogLevel, - "Specify a log level (info, warn, debug, error, fatal, panic, trace)", - ) - + initK9sFlags() initK8sFlags() } -func initK9s() { +// Execute root command +func Execute() { + if err := rootCmd.Execute(); err != nil { + log.Panic().Err(err) + } +} + +func run(cmd *cobra.Command, args []string) { + defer func() { + clearScreen() + if err := recover(); err != nil { + log.Error().Msgf("%v", err) + log.Error().Msg(string(debug.Stack())) + fmt.Printf(printer.Colorize("Boom!! ", printer.ColorRed)) + fmt.Println(printer.Colorize(fmt.Sprintf("%v.", err), printer.ColorDarkGray)) + // debug.PrintStack() + } + }() + + zerolog.SetGlobalLevel(parseLevel(logLevel)) + loadConfiguration() + app := views.NewApp() + { + defer app.Stop() + app.Init(version, refreshRate, k8sFlags) + app.Run() + } +} + +func loadConfiguration() { log.Info().Msg("🐶 K9s starting up...") // Load K9s config file... cfg := k8s.NewConfig(k8sFlags) config.Root = config.NewConfig(cfg) - initK9sConfig() - - // Init K8s connection... - k8s.InitConnectionOrDie(cfg) - log.Info().Msg("✅ Kubernetes connectivity") - - config.Root.Save() -} - -func initK9sConfig() { if err := config.Root.Load(config.K9sConfigFile); err != nil { log.Warn().Msg("Unable to locate K9s config. Generating new configuration...") } config.Root.K9s.RefreshRate = refreshRate + mergeConfigs() + // Init K8s connection... + k8s.InitConnectionOrDie(cfg) + log.Info().Msg("✅ Kubernetes connectivity") + config.Root.Save() +} +func mergeConfigs() { cfg, err := k8sFlags.ToRawKubeConfigLoader().RawConfig() if err != nil { panic("Invalid configuration. Unable to connect to api") } - ctx := cfg.CurrentContext - switch { - case isSet(k8sFlags.Context): - ctx = *k8sFlags.Context - config.Root.K9s.CurrentContext = ctx - case isSet(&config.Root.K9s.CurrentContext): - k8sFlags.Context = &config.Root.K9s.CurrentContext - default: - config.Root.K9s.CurrentContext = ctx - if isSet(&cfg.Contexts[ctx].Namespace) { - config.Root.SetActiveNamespace(cfg.Contexts[ctx].Namespace) - } + if isSet(k8sFlags.Context) { + config.Root.K9s.CurrentContext = *k8sFlags.Context + } else { + config.Root.K9s.CurrentContext = cfg.CurrentContext + } + log.Debug().Msgf("Active Context `%v`", config.Root.K9s.CurrentContext) + + if c, ok := cfg.Contexts[config.Root.K9s.CurrentContext]; ok { + config.Root.K9s.CurrentCluster = c.Cluster + if len(c.Namespace) != 0 { + config.Root.SetActiveNamespace(c.Namespace) + } + } else { + log.Panic().Msg(fmt.Sprintf("The specified context `%s does not exists in kubeconfig", config.Root.K9s.CurrentContext)) } - log.Debug().Msgf("Active Context `%v`", ctx) if isSet(k8sFlags.Namespace) { config.Root.SetActiveNamespace(*k8sFlags.Namespace) @@ -102,26 +118,6 @@ func initK9sConfig() { if isSet(k8sFlags.ClusterName) { config.Root.K9s.CurrentCluster = *k8sFlags.ClusterName } - - if c, ok := cfg.Contexts[ctx]; ok { - config.Root.K9s.CurrentCluster = c.Cluster - if len(c.Namespace) != 0 { - config.Root.SetActiveNamespace(c.Namespace) - } - } else { - panic(fmt.Sprintf("The specified context `%s does not exists in kubeconfig", cfg.CurrentContext)) - } -} - -func isSet(s *string) bool { - return s != nil && len(*s) > 0 -} - -// Execute root command -func Execute() { - if err := rootCmd.Execute(); err != nil { - log.Panic().Err(err) - } } func parseLevel(level string) zerolog.Level { @@ -139,28 +135,19 @@ func parseLevel(level string) zerolog.Level { } } -func run(cmd *cobra.Command, args []string) { - zerolog.SetGlobalLevel(parseLevel(logLevel)) - - initK9s() - - app := views.NewApp() - { - app.Init(version, refreshRate, k8sFlags) - defer func() { - clearScreen() - if err := recover(); err != nil { - app.Stop() - log.Error().Msgf("Boom! %#v", err) - debug.PrintStack() - } - }() - app.Run() - } -} - -func clearScreen() { - fmt.Print("\033[H\033[2J") +func initK9sFlags() { + rootCmd.Flags().IntVarP( + &refreshRate, + "refresh", "r", + defaultRefreshRate, + "Specifies the default refresh rate as an integer (sec)", + ) + rootCmd.Flags().StringVarP( + &logLevel, + "logLevel", "l", + defaultLogLevel, + "Specify a log level (info, warn, debug, error, fatal, panic, trace)", + ) } func initK8sFlags() { @@ -257,3 +244,14 @@ func initK8sFlags() { "If present, the namespace scope for this CLI request", ) } + +// ---------------------------------------------------------------------------- +// Helpers... + +func clearScreen() { + fmt.Print("\033[H\033[2J") +} + +func isSet(s *string) bool { + return s != nil && len(*s) > 0 +} diff --git a/cmd/version.go b/cmd/version.go index 3c7eb4ea..75868e3f 100644 --- a/cmd/version.go +++ b/cmd/version.go @@ -3,6 +3,7 @@ package cmd import ( "fmt" + "github.com/derailed/k9s/internal/printer" "github.com/spf13/cobra" ) @@ -12,7 +13,13 @@ func versionCmd() *cobra.Command { Short: "Print version info", Long: "Prints version info", Run: func(cmd *cobra.Command, args []string) { - fmt.Printf("Version:%s GitCommit:%s On %s\n", version, commit, date) + const secFmt = "%-10s" + fmt.Printf(printer.Colorize(fmt.Sprintf(secFmt, "Version:"), printer.ColorMagenta)) + fmt.Println(printer.Colorize(version, printer.ColorDarkGray)) + fmt.Printf(printer.Colorize(fmt.Sprintf(secFmt, "Commit:"), printer.ColorMagenta)) + fmt.Println(printer.Colorize(commit, printer.ColorDarkGray)) + fmt.Printf(printer.Colorize(fmt.Sprintf(secFmt, "Date:"), printer.ColorMagenta)) + fmt.Println(printer.Colorize(date, printer.ColorDarkGray)) }, } } diff --git a/go.mod b/go.mod index 84a15244..88956fba 100644 --- a/go.mod +++ b/go.mod @@ -1,30 +1,37 @@ module github.com/derailed/k9s +replace github.com/derailed/tview => /Users/fernand/go_wk/derailed/src/github.com/derailed/tview + require ( contrib.go.opencensus.io/exporter/ocagent v0.4.3 // indirect github.com/Azure/go-autorest v11.4.0+incompatible // indirect github.com/derailed/tview v0.1.3 github.com/dgrijalva/jwt-go v3.2.0+incompatible // indirect + github.com/docker/distribution v2.7.1+incompatible // indirect github.com/evanphx/json-patch v4.1.0+incompatible // indirect github.com/fatih/camelcase v1.0.0 // indirect github.com/gdamore/tcell v1.1.1 github.com/gogo/protobuf v1.1.1 // indirect + github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef // indirect github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c // indirect github.com/google/gofuzz v0.0.0-20170612174753-24818f796faf // indirect github.com/googleapis/gnostic v0.2.0 // indirect github.com/gophercloud/gophercloud v0.0.0-20190206201033-83b528acebb4 // indirect github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7 // indirect + github.com/hashicorp/golang-lru v0.5.1 // indirect github.com/imdario/mergo v0.3.6 // indirect github.com/inconshreveable/mousetrap v1.0.0 // indirect github.com/json-iterator/go v1.1.5 // indirect github.com/kr/pretty v0.1.0 // indirect + github.com/mattn/go-runewidth v0.0.4 github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.1 // indirect github.com/onsi/gomega v1.4.3 // indirect + github.com/opencontainers/go-digest v1.0.0-rc1 // indirect + github.com/pborman/uuid v1.2.0 // indirect github.com/peterbourgon/diskv v2.0.1+incompatible // indirect github.com/petergtz/pegomock v0.0.0-20181206220228-b113d17a7e81 github.com/rs/zerolog v1.12.0 - github.com/sirupsen/logrus v1.3.0 // indirect github.com/spf13/cobra v0.0.3 github.com/spf13/pflag v1.0.3 // indirect github.com/stretchr/testify v1.2.2 @@ -32,9 +39,12 @@ require ( golang.org/x/crypto v0.0.0-20181106171534-e4dc69e5b2fd // indirect golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2 // indirect gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/square/go-jose.v2 v2.3.0 // indirect gopkg.in/yaml.v2 v2.2.2 k8s.io/api v0.0.0-20190202010724-74b699b93c15 + k8s.io/apiextensions-apiserver v0.0.0-20190313122605-80ebb0f65ac1 // indirect k8s.io/apimachinery v0.0.0-20190207091153-095b9d203467 + k8s.io/apiserver v0.0.0-20190313120755-39e839dff034 // indirect k8s.io/cli-runtime v0.0.0-20190207094101-a32b78e5dd0a k8s.io/client-go v10.0.0+incompatible k8s.io/klog v0.1.0 // indirect diff --git a/go.sum b/go.sum index 0ac9bd2b..74b0cf05 100644 --- a/go.sum +++ b/go.sum @@ -7,6 +7,7 @@ git.apache.org/thrift.git v0.0.0-20180902110319-2566ecd5d999/go.mod h1:fPE2ZNJGy git.apache.org/thrift.git v0.0.0-20181218151757-9b75e4fe745a/go.mod h1:fPE2ZNJGynbRyZ4dJvy6G277gSllfV2HJqblrnkyeyg= github.com/Azure/go-autorest v11.4.0+incompatible h1:z3Yr6KYqs0nhSNwqGXEBpWK977hxVqsLv2n9PVYcixY= github.com/Azure/go-autorest v11.4.0+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24= +github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973 h1:xJ4a3vCFaGF/jqvzLMYoU8P317H5OQ+Via4RmuPwCS0= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/census-instrumentation/opencensus-proto v0.1.0-0.20181214143942-ba49f56771b8 h1:gUqsFVdUKoRHNg8fkFd8gB5OOEa/g5EwlAHznb4zjbI= github.com/census-instrumentation/opencensus-proto v0.1.0-0.20181214143942-ba49f56771b8/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= @@ -17,6 +18,8 @@ github.com/derailed/tview v0.1.3 h1:2/Rz0Sdfg3tepSKt4yCcY2g8IlRtPTrA4UYIQJZs6DI= github.com/derailed/tview v0.1.3/go.mod h1:WRYVfgb2PBMLZ/muaSpOc/4H4fYsOPnHOaGnBoJ+hGE= github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM= github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= +github.com/docker/distribution v2.7.1+incompatible h1:a5mlkVzth6W5A4fOsS3D2EO5BUmsJpcB+cRlLU7cSug= +github.com/docker/distribution v2.7.1+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= github.com/evanphx/json-patch v4.1.0+incompatible h1:K1MDoo4AZ4wU0GIU/fPmtZg7VpzLjCxu+UwBD1FvwOc= github.com/evanphx/json-patch v4.1.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= github.com/fatih/camelcase v1.0.0 h1:hxNvNX/xYBp0ovncs8WyWZrOrpBNub/JfaMvbURyft8= @@ -32,6 +35,8 @@ github.com/gogo/protobuf v1.1.1 h1:72R+M5VuhED/KujmZVcIquuo8mBgX4oVda//DQb3PXo= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b h1:VKtxabqXZkF25pY9ekfRL6a582T4P37/31XEstQ5p58= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef h1:veQD95Isof8w9/WXiA+pa3tz3fJXkt5B7QaRBrM62gk= +github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:tluoj9z5200jBnyusfRPU2LqT6J+DAorxEvtC7LHB+E= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= @@ -43,6 +48,8 @@ github.com/google/go-cmp v0.2.0 h1:+dTQ8DZQJz0Mb/HjFlkptS1FeQ4cWSnN941F8aEG4SQ= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/gofuzz v0.0.0-20170612174753-24818f796faf h1:+RRA9JqSOZFfKrOeqr2z77+8R2RKyh8PG66dcu1V0ck= github.com/google/gofuzz v0.0.0-20170612174753-24818f796faf/go.mod h1:HP5RmnzzSNb993RKQDq4+1A4ia9nllfqcQFTQJedwGI= +github.com/google/uuid v1.0.0 h1:b4Gk+7WdP/d3HZH8EJsZpvV7EtDOgaZLtnaNGIu1adA= +github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/gnostic v0.2.0 h1:l6N3VoaVzTncYYW+9yOz2LJJammFZGBO13sqgEhpy9g= github.com/googleapis/gnostic v0.2.0/go.mod h1:sJBsCZ4ayReDTBIg8b9dl28c5xFWyhBTVRp3pOg5EKY= github.com/gophercloud/gophercloud v0.0.0-20190206201033-83b528acebb4 h1:wAcVWwS69gs5c6cFkCa/ns/eaL2gC761nF8Ugvd1dGw= @@ -52,6 +59,8 @@ github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:Fecb github.com/grpc-ecosystem/grpc-gateway v1.5.0/go.mod h1:RSKVYQBd5MCa4OVpNdGskqpgL2+G+NZTnrVHpWWfpdw= github.com/grpc-ecosystem/grpc-gateway v1.6.2 h1:8KyC64BiO8ndiGHY5DlFWWdangUPC9QHPakFRre/Ud0= github.com/grpc-ecosystem/grpc-gateway v1.6.2/go.mod h1:RSKVYQBd5MCa4OVpNdGskqpgL2+G+NZTnrVHpWWfpdw= +github.com/hashicorp/golang-lru v0.5.1 h1:0hERBMJE1eitiLkihrMvRVBYAkpHzc/J3QdDN+dAcgU= +github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/imdario/mergo v0.3.6 h1:xTNEAn+kxVO7dTZGu0CegyqKZmoWFI0rF8UxjlB2d28= @@ -72,6 +81,7 @@ github.com/lucasb-eyer/go-colorful v0.0.0-20181028223441-12d3b2882a08 h1:5MnxBC1 github.com/lucasb-eyer/go-colorful v0.0.0-20181028223441-12d3b2882a08/go.mod h1:NXg0ArsFk0Y01623LgUqoqcouGDB+PwCCQlrwrG6xJ4= github.com/mattn/go-runewidth v0.0.4 h1:2BvfKmzob6Bmd4YsL0zygOqfdFnK7GR4QL06Do4/p7Y= github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= +github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -81,8 +91,12 @@ github.com/onsi/ginkgo v1.6.0 h1:Ix8l273rp3QzYgXSR+c8d1fTG7UPgYkOSELPhiY/YGw= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/gomega v1.4.3 h1:RE1xgDvH7imwFD45h+u2SgIfERHlS2yNG4DObb5BSKU= github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/opencontainers/go-digest v1.0.0-rc1 h1:WzifXhOVOEOuFYOJAW6aQqW0TooG2iki3E3Ii+WN7gQ= +github.com/opencontainers/go-digest v1.0.0-rc1/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s= github.com/openzipkin/zipkin-go v0.1.1/go.mod h1:NtoC/o8u3JlF1lSlyPNswIbeQH9bJTmOf0Erfk+hxe8= github.com/openzipkin/zipkin-go v0.1.3/go.mod h1:NtoC/o8u3JlF1lSlyPNswIbeQH9bJTmOf0Erfk+hxe8= +github.com/pborman/uuid v1.2.0 h1:J7Q5mO4ysT1dv8hyrUGHb9+ooztCXu1D8MY8DZYsu3g= +github.com/pborman/uuid v1.2.0/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k= github.com/peterbourgon/diskv v2.0.1+incompatible h1:UBdAOUP5p4RWqPBg048CAvpKN+vxiaj6gdUUzhl4XmI= github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU= github.com/petergtz/pegomock v0.0.0-20181206220228-b113d17a7e81 h1:MhSbvsIs4KvpPYr4taOvb6j+r9VNbj/08AfjsKi+Ui0= @@ -90,12 +104,16 @@ github.com/petergtz/pegomock v0.0.0-20181206220228-b113d17a7e81/go.mod h1:nuBLWZ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_golang v0.8.0/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_golang v0.9.2 h1:awm861/B8OKDd2I/6o1dy3ra4BamzKhYOiGItCeZ740= github.com/prometheus/client_golang v0.9.2/go.mod h1:OsXs2jCmiKlQ1lTBmv21f2mNfw4xf/QclQDMrYNZzcM= +github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910 h1:idejC8f05m9MGOsuEi1ATq9shN03HrxNkD/luQvxCv8= github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= github.com/prometheus/common v0.0.0-20180801064454-c7de2306084e/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= github.com/prometheus/common v0.0.0-20181126121408-4724e9255275/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= +github.com/prometheus/common v0.0.0-20181218105931-67670fe90761 h1:z6tvbDJ5OLJ48FFmnksv04a78maSTRBUIhkdHYV5Y98= github.com/prometheus/common v0.0.0-20181218105931-67670fe90761/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= github.com/prometheus/procfs v0.0.0-20180725123919-05ee40e3a273/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.0-20181204211112-1dc9a6cbc91a h1:9a8MnZMP0X2nLJdBg+pBmGgkJlSaKC2KaQmTCk1XDtE= github.com/prometheus/procfs v0.0.0-20181204211112-1dc9a6cbc91a/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/rivo/tview v0.0.0-20190213202703-b373355e9db4/go.mod h1:J4W+hErFfITUbyFAEXizpmkuxX7ZN56dopxHB4XQhMw= github.com/rs/zerolog v1.12.0 h1:aqZ1XRadoS8IBknR5IDFvGzbHly1X9ApIqOroooQF/c= @@ -167,6 +185,8 @@ gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/square/go-jose.v2 v2.3.0 h1:nLzhkFyl5bkblqYBoiWJUt5JkWOzmiaBtCxdJAqJd3U= +gopkg.in/square/go-jose.v2 v2.3.0/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= @@ -176,8 +196,12 @@ honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWh honnef.co/go/tools v0.0.0-20180920025451-e3ad64cb4ed3/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= k8s.io/api v0.0.0-20190202010724-74b699b93c15 h1:AoUGjnJ3PJMFz+Rkp4lx3X+6mPUnY1MESJhbUSGX+pc= k8s.io/api v0.0.0-20190202010724-74b699b93c15/go.mod h1:iuAfoD4hCxJ8Onx9kaTIt30j7jUFS00AXQi6QMi99vA= +k8s.io/apiextensions-apiserver v0.0.0-20190313122605-80ebb0f65ac1 h1:YfQqwXg6zropY1zGFmoKq/XlXW283XNmYoHS6lSOHcw= +k8s.io/apiextensions-apiserver v0.0.0-20190313122605-80ebb0f65ac1/go.mod h1:IxkesAMoaCRoLrPJdZNZUQp9NfZnzqaVzLhb2VEQzXE= k8s.io/apimachinery v0.0.0-20190207091153-095b9d203467 h1:zmz9UYvvXrK/B8EDqFuqreJEaXbIWdzEkNgWrN/Cd3o= k8s.io/apimachinery v0.0.0-20190207091153-095b9d203467/go.mod h1:ccL7Eh7zubPUSh9A3USN90/OzHNSVN6zxzde07TDCL0= +k8s.io/apiserver v0.0.0-20190313120755-39e839dff034 h1:I/bl2Ni4Cn6bsjPIIZZCiuAodcZgRijgkXm/2Z2EbDg= +k8s.io/apiserver v0.0.0-20190313120755-39e839dff034/go.mod h1:6bqaTSOSJavUIXUtfaR9Os9JtTCm8ZqH2SUl2S60C4w= k8s.io/cli-runtime v0.0.0-20190207094101-a32b78e5dd0a h1:MrGQxLLZ09Bl5hYYU9VlKnhY60bpPlYd9yXOPnxkdc0= k8s.io/cli-runtime v0.0.0-20190207094101-a32b78e5dd0a/go.mod h1:qWnH3/b8sp/l7EvlDh7ulDU3UWA4P4N1NFbEEP791tM= k8s.io/client-go v10.0.0+incompatible h1:F1IqCqw7oMBzDkqlcBymRq1450wD0eNqLE9jzUrIi34= diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 7a1cc18d..dd6dde38 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -141,6 +141,7 @@ func TestConfigSaveFile(t *testing.T) { cfg.Load("test_assets/k9s.yml") cfg.K9s.RefreshRate = 100 cfg.K9s.LogBufferSize = 500 + cfg.K9s.LogRequestSize = 100 cfg.K9s.CurrentContext = "blee" cfg.K9s.CurrentCluster = "blee" cfg.Validate() @@ -190,6 +191,7 @@ func setup(t *testing.T) { var expectedConfig = `k9s: refreshRate: 100 logBufferSize: 500 + logRequestSize: 100 currentContext: blee currentCluster: blee clusters: @@ -227,6 +229,7 @@ var expectedConfig = `k9s: var resetConfig = `k9s: refreshRate: 2 logBufferSize: 200 + logRequestSize: 200 currentContext: blee currentCluster: blee clusters: diff --git a/internal/config/k9s.go b/internal/config/k9s.go index cd7cc0e3..75af8461 100644 --- a/internal/config/k9s.go +++ b/internal/config/k9s.go @@ -1,14 +1,16 @@ package config const ( - defaultRefreshRate = 2 - defaultLogBufferSize = 200 + defaultRefreshRate = 2 + defaultLogRequestSize = 200 + defaultLogBufferSize = 1000 ) // K9s tracks K9s configuration options. type K9s struct { RefreshRate int `yaml:"refreshRate"` LogBufferSize int `yaml:"logBufferSize"` + LogRequestSize int `yaml:"logRequestSize"` CurrentContext string `yaml:"currentContext"` CurrentCluster string `yaml:"currentCluster"` Clusters map[string]*Cluster `yaml:"clusters,omitempty"` @@ -18,10 +20,11 @@ type K9s struct { // NewK9s create a new K9s configuration. func NewK9s() *K9s { return &K9s{ - RefreshRate: defaultRefreshRate, - LogBufferSize: defaultLogBufferSize, - Clusters: map[string]*Cluster{}, - Aliases: map[string]string{}, + RefreshRate: defaultRefreshRate, + LogBufferSize: defaultLogBufferSize, + LogRequestSize: defaultLogRequestSize, + Clusters: map[string]*Cluster{}, + Aliases: map[string]string{}, } } @@ -51,6 +54,10 @@ func (k *K9s) Validate(ks KubeSettings) { k.LogBufferSize = defaultLogBufferSize } + if k.LogRequestSize <= 0 { + k.LogRequestSize = defaultLogRequestSize + } + if k.Clusters == nil { k.Clusters = map[string]*Cluster{} } diff --git a/internal/config/k9s_test.go b/internal/config/k9s_test.go index e0a9faf5..211b2321 100644 --- a/internal/config/k9s_test.go +++ b/internal/config/k9s_test.go @@ -20,7 +20,8 @@ func TestK9sValidate(t *testing.T) { c.Validate(ksMock) assert.Equal(t, 2, c.RefreshRate) - assert.Equal(t, 200, c.LogBufferSize) + assert.Equal(t, 1000, c.LogBufferSize) + assert.Equal(t, 200, c.LogRequestSize) assert.Equal(t, "ctx1", c.CurrentContext) assert.Equal(t, "c1", c.CurrentCluster) assert.Equal(t, 1, len(c.Clusters)) @@ -40,7 +41,8 @@ func TestK9sValidateBlank(t *testing.T) { c.Validate(ksMock) assert.Equal(t, 2, c.RefreshRate) - assert.Equal(t, 200, c.LogBufferSize) + assert.Equal(t, 1000, c.LogBufferSize) + assert.Equal(t, 200, c.LogRequestSize) assert.Equal(t, "ctx1", c.CurrentContext) assert.Equal(t, "c1", c.CurrentCluster) assert.Equal(t, 1, len(c.Clusters)) diff --git a/internal/config/test_assets/k9s.yml b/internal/config/test_assets/k9s.yml index 189706a6..91f4ea26 100644 --- a/internal/config/test_assets/k9s.yml +++ b/internal/config/test_assets/k9s.yml @@ -1,6 +1,7 @@ k9s: refreshRate: 2 logBufferSize: 200 + logRequestSize: 200 currentContext: minikube currentCluster: minikube clusters: @@ -8,21 +9,21 @@ k9s: namespace: active: kube-system favorites: - - default - - kube-public - - istio-system - - all - - kube-system + - default + - kube-public + - istio-system + - all + - kube-system view: active: ctx fred: namespace: active: default favorites: - - default - - kube-public - - istio-system - - all - - kube-system + - default + - kube-public + - istio-system + - all + - kube-system view: - active: po \ No newline at end of file + active: po diff --git a/internal/k8s/access.go b/internal/k8s/access.go new file mode 100644 index 00000000..4702ae85 --- /dev/null +++ b/internal/k8s/access.go @@ -0,0 +1,41 @@ +package k8s + +import ( + "strings" + + "github.com/rs/zerolog/log" + authorizationv1 "k8s.io/api/authorization/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/kubernetes" +) + +// CanIAccess checks if user has access to a certain resource. +func CanIAccess(ns, verb, name, resURL string) bool { + _, gr := schema.ParseResourceArg(strings.ToLower(resURL)) + sar := &authorizationv1.SelfSubjectAccessReview{ + Spec: authorizationv1.SelfSubjectAccessReviewSpec{ + ResourceAttributes: &authorizationv1.ResourceAttributes{ + Namespace: ns, + Verb: verb, + Group: gr.Group, + Resource: gr.Resource, + Subresource: "", + Name: name, + }, + }, + } + + auth, err := kubernetes.NewForConfig(conn.restConfigOrDie()) + if err != nil { + log.Warn().Msgf("%s", err) + return false + } + + response, err := auth.AuthorizationV1().SelfSubjectAccessReviews().Create(sar) + if err != nil { + log.Warn().Msgf("%s", err) + return false + } + + return response.Status.Allowed +} diff --git a/internal/k8s/cluster.go b/internal/k8s/cluster.go index 3647ed40..97440774 100644 --- a/internal/k8s/cluster.go +++ b/internal/k8s/cluster.go @@ -1,5 +1,7 @@ package k8s +import "github.com/rs/zerolog/log" + // Cluster represents a Kubernetes cluster. type Cluster struct{} @@ -12,6 +14,7 @@ func NewCluster() *Cluster { func (c *Cluster) Version() (string, error) { rev, err := conn.dialOrDie().Discovery().ServerVersion() if err != nil { + log.Warn().Msgf("%s", err) return "", err } return rev.GitVersion, nil @@ -21,6 +24,7 @@ func (c *Cluster) Version() (string, error) { func (c *Cluster) ContextName() string { ctx, err := conn.config.CurrentContextName() if err != nil { + log.Warn().Msgf("%s", err) return "N/A" } return ctx @@ -30,6 +34,7 @@ func (c *Cluster) ContextName() string { func (c *Cluster) ClusterName() string { ctx, err := conn.config.CurrentClusterName() if err != nil { + log.Warn().Msgf("%s", err) return "N/A" } return ctx @@ -39,6 +44,7 @@ func (c *Cluster) ClusterName() string { func (c *Cluster) UserName() string { usr, err := conn.config.CurrentUserName() if err != nil { + log.Warn().Msgf("%s", err) return "N/A" } return usr diff --git a/internal/k8s/context.go b/internal/k8s/context.go index 9ad73913..a6c22685 100644 --- a/internal/k8s/context.go +++ b/internal/k8s/context.go @@ -19,9 +19,9 @@ type NamedContext struct { Context *api.Context } -// MustCurrentClusterName return the active cluster name. -func (c *NamedContext) MustCurrentClusterName() string { - cl, err := conn.config.CurrentClusterName() +// MustCurrentContextName return the active context name. +func (c *NamedContext) MustCurrentContextName() string { + cl, err := conn.config.CurrentContextName() if err != nil { panic(err) } diff --git a/internal/k8s/mapper.go b/internal/k8s/mapper.go index 6fdec9da..faf11a54 100644 --- a/internal/k8s/mapper.go +++ b/internal/k8s/mapper.go @@ -2,9 +2,16 @@ package k8s import ( "fmt" + "os/user" + "path/filepath" + "regexp" + "strings" + "time" "k8s.io/apimachinery/pkg/api/meta" "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/discovery" + "k8s.io/client-go/restmapper" ) // RestMapping holds k8s resource mapping @@ -15,13 +22,92 @@ var RestMapping = &RestMapper{} type RestMapper struct{} // Find a mapping given a resource name. -func (*RestMapper) Find(res string) (*meta.RESTMapping, error) { +func (*RestMapper) Find1(res string) (*meta.RESTMapping, error) { if m, ok := resMap[res]; ok { return m, nil } return nil, fmt.Errorf("no mapping for resource %s", res) } +func (*RestMapper) ToRESTMapper() (meta.RESTMapper, error) { + rc := conn.restConfigOrDie() + + httpCacheDir := filepath.Join(mustHomeDir(), ".kube", "http-cache") + discCacheDir := filepath.Join(mustHomeDir(), ".kube", "cache", "discovery", toHostDir(rc.Host)) + + disc, err := discovery.NewCachedDiscoveryClientForConfig(rc, discCacheDir, httpCacheDir, 10*time.Minute) + if err != nil { + return nil, err + } + mapper := restmapper.NewDeferredDiscoveryRESTMapper(disc) + expander := restmapper.NewShortcutExpander(mapper, disc) + return expander, nil +} + +var toFileName = regexp.MustCompile(`[^(\w/\.)]`) + +func toHostDir(host string) string { + h := strings.Replace(strings.Replace(host, "https://", "", 1), "http://", "", 1) + // now do a simple collapse of non-AZ09 characters. Collisions are possible but unlikely. Even if we do collide the problem is short lived + return toFileName.ReplaceAllString(h, "_") +} + +func mustHomeDir() string { + usr, err := user.Current() + if err != nil { + panic(err) + } + return usr.HomeDir +} + +// ResourceFor produces a rest mapping from a given resource. +// Support full res name ie deployment.v1.apps. +func (r *RestMapper) ResourceFor(resourceArg string) (*meta.RESTMapping, error) { + res, err := r.resourceFor(resourceArg) + if err != nil { + return nil, err + } + return r.toRESTMapping(res, resourceArg), nil +} + +func (r *RestMapper) resourceFor(resourceArg string) (schema.GroupVersionResource, error) { + if resourceArg == "*" { + return schema.GroupVersionResource{Resource: resourceArg}, nil + } + + var ( + gvr schema.GroupVersionResource + err error + ) + + mapper, err := r.ToRESTMapper() + if err != nil { + return gvr, err + } + + fullGVR, gr := schema.ParseResourceArg(strings.ToLower(resourceArg)) + if fullGVR != nil { + return mapper.ResourceFor(*fullGVR) + } + + gvr, err = mapper.ResourceFor(gr.WithVersion("")) + if err != nil { + if len(gr.Group) == 0 { + return gvr, fmt.Errorf("the server doesn't have a resource type '%s'", gr.Resource) + } + return gvr, fmt.Errorf("the server doesn't have a resource type '%s' in group '%s'", gr.Resource, gr.Group) + } + return gvr, nil +} + +func (*RestMapper) toRESTMapping(gvr schema.GroupVersionResource, res string) *meta.RESTMapping { + return &meta.RESTMapping{ + Resource: gvr, + GroupVersionKind: schema.GroupVersionKind{Group: gvr.Group, Version: gvr.Version, Kind: res}, + Scope: RestMapping, + } +} + // Name protocol returns rest scope name. func (*RestMapper) Name() meta.RESTScopeName { return meta.RESTScopeNameNamespace @@ -155,28 +241,3 @@ var resMap = map[string]*meta.RESTMapping{ Scope: RestMapping, }, } - -// {Group: "certificates.k8s.io", Version: "v1beta1", Kind: "CertificateSigningRequest"}: {}, -// {Group: "certificates.k8s.io", Version: "v1beta1", Kind: "CertificateSigningRequestList"}: {}, -// {Group: "kubeadm.k8s.io", Version: "v1alpha1", Kind: "MasterConfiguration"}: {}, -// {Group: "extensions", Version: "v1beta1", Kind: "PodSecurityPolicy"}: {}, -// {Group: "extensions", Version: "v1beta1", Kind: "PodSecurityPolicyList"}: {}, -// {Group: "extensions", Version: "v1beta1", Kind: "NetworkPolicy"}: {}, -// {Group: "extensions", Version: "v1beta1", Kind: "NetworkPolicyList"}: {}, -// {Group: "policy", Version: "v1beta1", Kind: "PodSecurityPolicy"}: {}, -// {Group: "policy", Version: "v1beta1", Kind: "PodSecurityPolicyList"}: {}, -// {Group: "settings.k8s.io", Version: "v1alpha1", Kind: "PodPreset"}: {}, -// {Group: "settings.k8s.io", Version: "v1alpha1", Kind: "PodPresetList"}: {}, -// {Group: "admissionregistration.k8s.io", Version: "v1beta1", Kind: "ValidatingWebhookConfiguration"}: {}, -// {Group: "admissionregistration.k8s.io", Version: "v1beta1", Kind: "ValidatingWebhookConfigurationList"}: {}, -// {Group: "admissionregistration.k8s.io", Version: "v1beta1", Kind: "MutatingWebhookConfiguration"}: {}, -// {Group: "admissionregistration.k8s.io", Version: "v1beta1", Kind: "MutatingWebhookConfigurationList"}: {}, -// {Group: "auditregistration.k8s.io", Version: "v1alpha1", Kind: "AuditSink"}: {}, -// {Group: "auditregistration.k8s.io", Version: "v1alpha1", Kind: "AuditSinkList"}: {}, -// {Group: "networking.k8s.io", Version: "v1", Kind: "NetworkPolicy"}: {}, -// {Group: "networking.k8s.io", Version: "v1", Kind: "NetworkPolicyList"}: {}, -// {Group: "storage.k8s.io", Version: "v1beta1", Kind: "StorageClass"}: {}, -// {Group: "storage.k8s.io", Version: "v1beta1", Kind: "StorageClassList"}: {}, -// {Group: "storage.k8s.io", Version: "v1", Kind: "StorageClass"}: {}, -// {Group: "storage.k8s.io", Version: "v1", Kind: "StorageClassList"}: {}, -// {Group: "authentication.k8s.io", Version: "v1", Kind: "TokenRequest"}: {}, diff --git a/internal/k8s/metrics.go b/internal/k8s/metrics.go index 98458518..088d712a 100644 --- a/internal/k8s/metrics.go +++ b/internal/k8s/metrics.go @@ -5,7 +5,8 @@ import ( "math" "path" - "k8s.io/api/core/v1" + "github.com/rs/zerolog/log" + v1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/labels" metricsapi "k8s.io/metrics/pkg/apis/metrics" @@ -37,8 +38,10 @@ func (m *MetricsServer) NodeMetrics() (Metric, error) { opts := metav1.ListOptions{} nn, err := conn.dialOrDie().CoreV1().Nodes().List(opts) if err != nil { + log.Warn().Msgf("%s", err) return mx, err } + nods := make([]string, len(nn.Items)) var maxCPU, maxMem float64 for i, n := range nn.Items { @@ -51,6 +54,7 @@ func (m *MetricsServer) NodeMetrics() (Metric, error) { mm, err := m.getNodeMetrics() if err != nil { + log.Warn().Msgf("%s", err) return mx, err } @@ -67,6 +71,7 @@ func (m *MetricsServer) NodeMetrics() (Metric, error) { CPU: fmt.Sprintf("%0.f%%", math.Round((cpu/maxCPU)*100)), Mem: fmt.Sprintf("%0.f%%", math.Round((mem/maxMem)*100)), } + return mx, nil } @@ -76,6 +81,7 @@ func (m *MetricsServer) PodMetrics() (map[string]Metric, error) { mm, err := m.getPodMetrics() if err != nil { + log.Warn().Msgf("%s", err) return mx, err } @@ -88,6 +94,7 @@ func (m *MetricsServer) PodMetrics() (map[string]Metric, error) { pa := path.Join(m.Namespace, m.Name) mx[pa] = Metric{CPU: fmt.Sprintf("%dm", cpu), Mem: fmt.Sprintf("%dMi", mem)} } + return mx, nil } @@ -97,6 +104,7 @@ func (m *MetricsServer) PerNodeMetrics(nn []v1.Node) (map[string]Metric, error) mm, err := m.getNodeMetrics() if err != nil { + log.Warn().Msgf("%s", err) return mx, err } @@ -117,6 +125,7 @@ func (m *MetricsServer) PerNodeMetrics(nn []v1.Node) (map[string]Metric, error) AvailMem: fmt.Sprintf("%dMi", amem.Value()/(1024*1024)), } } + return mx, nil } @@ -124,70 +133,86 @@ func (m *MetricsServer) getPodMetrics() (*metricsapi.PodMetricsList, error) { if conn.hasMetricsServer() { return m.podMetricsViaService() } - selector := labels.Everything() var mx *metricsapi.PodMetricsList conn, err := conn.heapsterDial() if err != nil { + log.Warn().Msgf("%s", err) return mx, err } - return conn.GetPodMetrics("", "", true, selector) + + return conn.GetPodMetrics("", "", true, labels.Everything()) } func (m *MetricsServer) getNodeMetrics() (*metricsapi.NodeMetricsList, error) { if conn.hasMetricsServer() { return m.nodeMetricsViaService() } - selector := labels.Everything() var mx *metricsapi.NodeMetricsList conn, err := conn.heapsterDial() if err != nil { + log.Warn().Msgf("%s", err) return mx, err } - return conn.GetNodeMetrics("", selector.String()) + + return conn.GetNodeMetrics("", labels.Everything().String()) } func (*MetricsServer) nodeMetricsViaService() (*metricsapi.NodeMetricsList, error) { var mx *metricsapi.NodeMetricsList + clt, err := conn.mxsDial() if err != nil { + log.Warn().Msgf("%s", err) return mx, err } + selector := labels.Everything() var versionedMetrics *metricsV1beta1api.NodeMetricsList mc := clt.Metrics() nm := mc.NodeMetricses() versionedMetrics, err = nm.List(metav1.ListOptions{LabelSelector: selector.String()}) if err != nil { + log.Warn().Msgf("%s", err) return nil, err } + metrics := &metricsapi.NodeMetricsList{} err = metricsV1beta1api.Convert_v1beta1_NodeMetricsList_To_metrics_NodeMetricsList(versionedMetrics, metrics, nil) if err != nil { + log.Warn().Msgf("%s", err) return nil, err } + return metrics, nil } func (*MetricsServer) podMetricsViaService() (*metricsapi.PodMetricsList, error) { var mx *metricsapi.PodMetricsList + clt, err := conn.mxsDial() if err != nil { + log.Warn().Msgf("%s", err) return mx, err } + selector := labels.Everything() var versionedMetrics *metricsV1beta1api.PodMetricsList mc := clt.Metrics() nm := mc.PodMetricses("") versionedMetrics, err = nm.List(metav1.ListOptions{LabelSelector: selector.String()}) if err != nil { + log.Warn().Msgf("%s", err) return nil, err } + metrics := &metricsapi.PodMetricsList{} err = metricsV1beta1api.Convert_v1beta1_PodMetricsList_To_metrics_PodMetricsList(versionedMetrics, metrics, nil) if err != nil { + log.Warn().Msgf("%s", err) return nil, err } + return metrics, nil } diff --git a/internal/k8s/ns.go b/internal/k8s/ns.go index b0bfb560..1933912e 100644 --- a/internal/k8s/ns.go +++ b/internal/k8s/ns.go @@ -1,6 +1,7 @@ package k8s import ( + v1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -12,13 +13,14 @@ func NewNamespace() Res { return &Namespace{} } -// Get a namespace. +// Get a active namespace. func (*Namespace) Get(_, n string) (interface{}, error) { opts := metav1.GetOptions{} + return conn.dialOrDie().CoreV1().Namespaces().Get(n, opts) } -// List all namespaces on the cluster. +// List all active namespaces on the cluster. func (*Namespace) List(_ string) (Collection, error) { opts := metav1.ListOptions{} @@ -29,7 +31,9 @@ func (*Namespace) List(_ string) (Collection, error) { cc := make(Collection, len(rr.Items)) for i, r := range rr.Items { - cc[i] = r + if r.Status.Phase == v1.NamespaceActive { + cc[i] = r + } } return cc, nil @@ -38,5 +42,6 @@ func (*Namespace) List(_ string) (Collection, error) { // Delete a namespace. func (*Namespace) Delete(_, n string) error { opts := metav1.DeleteOptions{} + return conn.dialOrDie().CoreV1().Namespaces().Delete(n, &opts) } diff --git a/internal/printer/colorize.go b/internal/printer/colorize.go new file mode 100644 index 00000000..b1854e40 --- /dev/null +++ b/internal/printer/colorize.go @@ -0,0 +1,25 @@ +package printer + +import ( + "fmt" +) + +// Defines basic ANSI colors. +const ( + ColorBlack = iota + 30 + ColorRed + ColorGreen + ColorYellow + ColorBlue + ColorMagenta + ColorCyan + ColorWhite + + ColorBold = 1 + ColorDarkGray = 90 +) + +// Colorize a string based on given color. +func Colorize(s string, c int) string { + return fmt.Sprintf("\x1b[%dm%s\x1b[0m", c, s) +} diff --git a/internal/resource/base.go b/internal/resource/base.go index 4a8a4e12..6b2dfb1a 100644 --- a/internal/resource/base.go +++ b/internal/resource/base.go @@ -70,12 +70,13 @@ func (b *Base) List(ns string) (Columnars, error) { // Describe a given resource. func (b *Base) Describe(kind, pa string) (string, error) { - ns, n := namespaced(pa) - mapping, err := k8s.RestMapping.Find(kind) + + mapping, err := k8s.RestMapping.Find1(kind) if err != nil { return "", err } + d, err := versioned.Describer(k8s.KubeConfig.Flags(), mapping) if err != nil { return "", err diff --git a/internal/resource/context.go b/internal/resource/context.go index 12d8cfcc..61f281bb 100644 --- a/internal/resource/context.go +++ b/internal/resource/context.go @@ -79,7 +79,7 @@ func (r *Context) Fields(ns string) Row { i := r.instance name := i.Name - if i.MustCurrentClusterName() == name { + if i.MustCurrentContextName() == name { name += "*" } diff --git a/internal/resource/helpers.go b/internal/resource/helpers.go index 3b6a3bd1..ec727d11 100644 --- a/internal/resource/helpers.go +++ b/internal/resource/helpers.go @@ -9,6 +9,7 @@ import ( "time" "github.com/derailed/tview" + runewidth "github.com/mattn/go-runewidth" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/util/duration" "k8s.io/apimachinery/pkg/watch" @@ -82,12 +83,8 @@ func Pad(s string, l int) string { } // Truncate a string to the given l and suffix ellipsis if needed. -func Truncate(s string, l int) string { - if len(s) > l { - fmat := "%." + strconv.Itoa(l) + "s%s" - return fmt.Sprintf(fmat, s, string(tview.SemigraphicsHorizontalEllipsis)) - } - return s +func Truncate(str string, width int) string { + return runewidth.Truncate(str, width, string(tview.SemigraphicsHorizontalEllipsis)) } func mapToStr(m map[string]string) (s string) { diff --git a/internal/resource/helpers_test.go b/internal/resource/helpers_test.go index 28310365..39353353 100644 --- a/internal/resource/helpers_test.go +++ b/internal/resource/helpers_test.go @@ -82,8 +82,8 @@ func TestTruncate(t *testing.T) { l int e string }{ - {"fred", 2, "fr…"}, - {"fred", 1, "f…"}, + {"fred", 3, "fr…"}, + {"fred", 2, "f…"}, {"fred", 10, "fred"}, } diff --git a/internal/views/alias.go b/internal/views/alias.go index e942fd05..83662065 100644 --- a/internal/views/alias.go +++ b/internal/views/alias.go @@ -19,6 +19,7 @@ const ( type aliasView struct { *tableView + current igniter cancel context.CancelFunc } @@ -32,9 +33,9 @@ func newAliasView(app *appView) *aliasView { v.sortFn = v.sorterFn v.currentNS = "" } - v.actions[tcell.KeyEnter] = newKeyAction("Search", v.gotoCmd) - v.actions[tcell.KeyEscape] = newKeyAction("Reset", v.resetCmd) - v.actions[KeySlash] = newKeyAction("Filter", v.activateCmd) + v.actions[tcell.KeyEnter] = newKeyAction("Goto", v.gotoCmd, true) + v.actions[tcell.KeyEscape] = newKeyAction("Reset", v.resetCmd, false) + v.actions[KeySlash] = newKeyAction("Filter", v.activateCmd, false) ctx, cancel := context.WithCancel(context.TODO()) v.cancel = cancel @@ -50,6 +51,7 @@ func newAliasView(app *appView) *aliasView { } } }(ctx) + return &v } @@ -73,6 +75,7 @@ func (v *aliasView) resetCmd(evt *tcell.EventKey) *tcell.EventKey { v.cmdBuff.reset() return nil } + return v.backCmd(evt) } @@ -85,6 +88,7 @@ func (v *aliasView) gotoCmd(evt *tcell.EventKey) *tcell.EventKey { if v.cmdBuff.isActive() { return v.filterCmd(evt) } + return evt } @@ -92,19 +96,22 @@ func (v *aliasView) backCmd(evt *tcell.EventKey) *tcell.EventKey { if v.cancel != nil { v.cancel() } + if v.cmdBuff.isActive() { v.cmdBuff.reset() } else { v.app.inject(v.current) } + return nil } func (v *aliasView) runCmd(evt *tcell.EventKey) *tcell.EventKey { r, _ := v.GetSelection() if r > 0 { - v.app.command.run(strings.TrimSpace(v.GetCell(r, 0).Text)) + v.app.gotoResource(strings.TrimSpace(v.GetCell(r, 0).Text), true) } + return nil } @@ -133,6 +140,7 @@ func (v *aliasView) hydrate() resource.TableData { Deltas: fields, } } + return data } diff --git a/internal/views/app.go b/internal/views/app.go index 60cb6170..11bdefbe 100644 --- a/internal/views/app.go +++ b/internal/views/app.go @@ -34,20 +34,21 @@ type ( appView struct { *tview.Application - version string - pages *tview.Pages - content *tview.Pages - flashView *flashView - menuView *menuView - infoView *infoView - command *command - focusGroup []tview.Primitive - focusCurrent int - focusChanged focusHandler - cancel context.CancelFunc - cmdBuff *cmdBuff - cmdView *cmdView - actions keyActions + version string + pages *tview.Pages + content *tview.Pages + flashView *flashView + crumbsView *crumbsView + menuView *menuView + clusterInfoView *clusterInfoView + command *command + focusGroup []tview.Primitive + focusCurrent int + focusChanged focusHandler + cancel context.CancelFunc + cmdBuff *cmdBuff + cmdView *cmdView + actions keyActions } ) @@ -68,32 +69,35 @@ func NewApp() *appView { v.cmdView = newCmdView('🐶') v.command = newCommand(&v) v.flashView = newFlashView(v.Application, "Initializing...") - v.infoView = newInfoView(&v) + v.crumbsView = newCrumbsView(v.Application) + v.clusterInfoView = newInfoView(&v) v.focusChanged = v.changedFocus v.SetInputCapture(v.keyboard) } - v.actions[KeyColon] = newKeyAction("Cmd", v.activateCmd) - v.actions[tcell.KeyCtrlR] = newKeyAction("Redraw", v.redrawCmd) - v.actions[KeyQ] = newKeyAction("Quit", v.quitCmd) - v.actions[KeyHelp] = newKeyAction("Help", v.helpCmd) - v.actions[tcell.KeyEscape] = newKeyAction("Exit Cmd", v.deactivateCmd) - v.actions[tcell.KeyEnter] = newKeyAction("Goto", v.gotoCmd) - v.actions[tcell.KeyBackspace2] = newKeyAction("Goto", v.eraseCmd) - v.actions[tcell.KeyTab] = newKeyAction("Focus", v.focusCmd) + v.actions[KeyColon] = newKeyAction("Cmd", v.activateCmd, false) + v.actions[tcell.KeyCtrlR] = newKeyAction("Redraw", v.redrawCmd, false) + v.actions[KeyQ] = newKeyAction("Quit", v.quitCmd, false) + v.actions[KeyHelp] = newKeyAction("Help", v.helpCmd, false) + v.actions[KeyA] = newKeyAction("Aliases", v.aliasCmd, true) + v.actions[tcell.KeyEscape] = newKeyAction("Exit Cmd", v.deactivateCmd, false) + v.actions[tcell.KeyEnter] = newKeyAction("Goto", v.gotoCmd, false) + v.actions[tcell.KeyBackspace2] = newKeyAction("Erase", v.eraseCmd, false) + v.actions[tcell.KeyBackspace] = newKeyAction("Erase", v.eraseCmd, false) + v.actions[tcell.KeyTab] = newKeyAction("Focus", v.focusCmd, false) return &v } func (a *appView) Init(v string, rate int, flags *genericclioptions.ConfigFlags) { a.version = v - a.infoView.init() + a.clusterInfoView.init() a.cmdBuff.addListener(a.cmdView) header := tview.NewFlex() { header.SetDirection(tview.FlexColumn) - header.AddItem(a.infoView, 55, 1, false) + header.AddItem(a.clusterInfoView, 55, 1, false) header.AddItem(a.menuView, 0, 1, false) header.AddItem(logoView(), 26, 1, false) } @@ -104,11 +108,12 @@ func (a *appView) Init(v string, rate int, flags *genericclioptions.ConfigFlags) main.AddItem(header, 7, 1, false) main.AddItem(a.cmdView, 1, 1, false) main.AddItem(a.content, 0, 10, true) - main.AddItem(a.flashView, 2, 1, false) + main.AddItem(a.crumbsView, 2, 1, false) + main.AddItem(a.flashView, 1, 1, false) } a.pages.AddPage("main", main, true, false) - a.pages.AddPage("splash", NewSplash(a.version), true, true) + a.pages.AddPage("splash", newSplash(a.version), true, true) a.SetRoot(a.pages, true) } @@ -167,9 +172,18 @@ func (a *appView) deactivateCmd(evt *tcell.EventKey) *tcell.EventKey { return evt } +func (a *appView) prevCmd(evt *tcell.EventKey) *tcell.EventKey { + if top, ok := a.command.previousCmd(); ok { + log.Debug().Msgf("Previous command %s", top) + a.gotoResource(top, false) + return nil + } + return evt +} + func (a *appView) gotoCmd(evt *tcell.EventKey) *tcell.EventKey { if a.cmdBuff.isActive() && !a.cmdBuff.empty() { - a.command.run(a.cmdBuff.String()) + a.gotoResource(a.cmdBuff.String(), true) a.cmdBuff.reset() return nil } @@ -197,6 +211,14 @@ func (a *appView) quitCmd(evt *tcell.EventKey) *tcell.EventKey { } func (a *appView) helpCmd(evt *tcell.EventKey) *tcell.EventKey { + if a.cmdView.inCmdMode() { + return evt + } + a.inject(newHelpView(a)) + return nil +} + +func (a *appView) aliasCmd(evt *tcell.EventKey) *tcell.EventKey { if a.cmdView.inCmdMode() { return evt } @@ -212,6 +234,14 @@ func (a *appView) puntCmd(evt *tcell.EventKey) *tcell.EventKey { return evt } +func (a *appView) gotoResource(res string, record bool) bool { + valid := a.command.run(res) + if valid && record { + a.command.pushCmd(res) + } + return valid +} + func (a *appView) showPage(p string) { a.pages.SwitchToPage(p) } @@ -240,7 +270,7 @@ func (a *appView) cmdMode() bool { } func (a *appView) refresh() { - a.infoView.refresh() + a.clusterInfoView.refresh() } func (a *appView) flash(level flashLevel, m ...string) { @@ -248,7 +278,7 @@ func (a *appView) flash(level flashLevel, m ...string) { } func (a *appView) setHints(h hints) { - a.menuView.setMenu(h) + a.menuView.populateMenu(h) } func logoView() tview.Primitive { diff --git a/internal/views/info.go b/internal/views/cluster_info.go similarity index 81% rename from internal/views/info.go rename to internal/views/cluster_info.go index 2100ab41..7fb8badf 100644 --- a/internal/views/info.go +++ b/internal/views/cluster_info.go @@ -7,17 +7,17 @@ import ( "github.com/rs/zerolog/log" ) -type infoView struct { +type clusterInfoView struct { *tview.Table app *appView } -func newInfoView(app *appView) *infoView { - return &infoView{app: app, Table: tview.NewTable()} +func newInfoView(app *appView) *clusterInfoView { + return &clusterInfoView{app: app, Table: tview.NewTable()} } -func (v *infoView) init() { +func (v *clusterInfoView) init() { cluster := resource.NewCluster() var row int @@ -49,20 +49,22 @@ func (v *infoView) init() { v.refresh() } -func (*infoView) sectionCell(t string) *tview.TableCell { +func (*clusterInfoView) sectionCell(t string) *tview.TableCell { c := tview.NewTableCell(t + ":") c.SetAlign(tview.AlignLeft) + return c } -func (*infoView) infoCell(t string) *tview.TableCell { +func (*clusterInfoView) infoCell(t string) *tview.TableCell { c := tview.NewTableCell(t) c.SetExpansion(2) c.SetTextColor(tcell.ColorOrange) + return c } -func (v *infoView) refresh() { +func (v *clusterInfoView) refresh() { var row int cluster := resource.NewCluster() @@ -77,7 +79,7 @@ func (v *infoView) refresh() { mx, err := cluster.Metrics() if err != nil { - log.Error().Err(err) + log.Warn().Msgf("%s", err) return } c := v.GetCell(row, 1) diff --git a/internal/views/cmd_stack.go b/internal/views/cmd_stack.go new file mode 100644 index 00000000..3d07c42b --- /dev/null +++ b/internal/views/cmd_stack.go @@ -0,0 +1,46 @@ +package views + +import "github.com/rs/zerolog/log" + +const maxStackSize = 10 + +type cmdStack struct { + index int + stack []string +} + +func newCmdStack() *cmdStack { + return &cmdStack{stack: make([]string, 0, maxStackSize)} +} + +func (s *cmdStack) push(cmd string) { + if len(s.stack) == maxStackSize { + s.stack = s.stack[1 : len(s.stack)-1] + } + s.stack = append(s.stack, cmd) + log.Info().Msgf("Pushed %s %v", cmd, s.stack) +} + +func (s *cmdStack) pop() (string, bool) { + if s.empty() { + return "", false + } + log.Info().Msgf("Before Pop %v", s.stack) + top := s.stack[len(s.stack)-1] + s.stack = s.stack[:len(s.stack)-1] + log.Info().Msgf("After Pop %v", s.stack) + return top, true +} + +func (s *cmdStack) top() (string, bool) { + if s.empty() { + return "", false + } + log.Info().Msgf("Top %v -- %s", s.stack, s.stack[len(s.stack)-1]) + + return s.stack[len(s.stack)-1], true +} + +func (s *cmdStack) empty() bool { + return len(s.stack) == 0 +} diff --git a/internal/views/cmd_stack_test.go b/internal/views/cmd_stack_test.go new file mode 100644 index 00000000..cf5f5463 --- /dev/null +++ b/internal/views/cmd_stack_test.go @@ -0,0 +1,76 @@ +package views + +import ( + "fmt" + "testing" + + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" +) + +func init() { + zerolog.SetGlobalLevel(zerolog.FatalLevel) +} + +func TestCmdStackPushMax(t *testing.T) { + s := newCmdStack() + for i := 0; i < 20; i++ { + s.push(fmt.Sprintf("cmd_%d", i)) + } + top, ok := s.top() + assert.True(t, ok) + assert.Equal(t, "cmd_19", top) +} + +func TestCmdStackPop(t *testing.T) { + type expect struct { + val string + ok bool + } + + uu := []struct { + cmds []string + popCount int + e expect + }{ + {[]string{}, 2, expect{"", false}}, + {[]string{"a", "b", "c"}, 2, expect{"a", true}}, + {[]string{"a", "b", "c"}, 1, expect{"b", true}}, + } + + for _, u := range uu { + s := newCmdStack() + for _, v := range u.cmds { + s.push(v) + } + for i := 0; i < u.popCount; i++ { + s.pop() + } + top, ok := s.pop() + assert.Equal(t, u.e.ok, ok) + assert.Equal(t, u.e.val, top) + } +} + +func TestCmdStackEmpty(t *testing.T) { + uu := []struct { + cmds []string + popCount int + e bool + }{ + {[]string{}, 0, true}, + {[]string{"a", "b", "c"}, 0, false}, + {[]string{"a", "b", "c"}, 3, true}, + } + + for _, u := range uu { + s := newCmdStack() + for _, v := range u.cmds { + s.push(v) + } + for i := 0; i < u.popCount; i++ { + s.pop() + } + assert.Equal(t, u.e, s.empty()) + } +} diff --git a/internal/views/command.go b/internal/views/command.go index 2917cdf5..eb62f568 100644 --- a/internal/views/command.go +++ b/internal/views/command.go @@ -8,43 +8,56 @@ import ( ) type command struct { - app *appView + app *appView + history *cmdStack } func newCommand(app *appView) *command { - return &command{app: app} + return &command{app: app, history: newCmdStack()} +} + +func (c *command) pushCmd(cmd string) { + c.history.push(cmd) + c.app.crumbsView.update(c.history.stack) +} + +func (c *command) previousCmd() (string, bool) { + c.history.pop() + c.app.crumbsView.update(c.history.stack) + return c.history.top() } // DefaultCmd reset default command ie show pods. func (c *command) defaultCmd() { + c.pushCmd(config.Root.ActiveView()) c.run(config.Root.ActiveView()) } // Helpers... // Exec the command by showing associated display. -func (c *command) run(cmd string) { +func (c *command) run(cmd string) bool { var v igniter switch cmd { case "q", "quit": c.app.Stop() - return + return true case "?", "help", "alias": c.app.inject(newAliasView(c.app)) - return + return true default: - if res, ok := cmdMap[cmd]; ok { + if res, ok := resourceViews()[cmd]; ok { v = res.viewFn(res.title, c.app, res.listFn(resource.DefaultNamespace), res.colorerFn) - c.app.flash(flashInfo, "Viewing all "+res.title+"...") + c.app.flash(flashInfo, fmt.Sprintf("Viewing %s in namespace %s...", res.title, config.Root.ActiveNamespace())) c.exec(cmd, v) - return + return true } } - res, ok := getCRDS()[cmd] + res, ok := allCRDs()[cmd] if !ok { c.app.flash(flashWarn, fmt.Sprintf("Huh? `%s` command not found", cmd)) - return + return false } n := res.Plural @@ -58,6 +71,7 @@ func (c *command) run(cmd string) { defaultColorer, ) c.exec(cmd, v) + return true } func (c *command) exec(cmd string, v igniter) { diff --git a/internal/views/context.go b/internal/views/context.go index ece347df..c6948f56 100644 --- a/internal/views/context.go +++ b/internal/views/context.go @@ -14,19 +14,33 @@ type contextView struct { func newContextView(t string, app *appView, list resource.List, c colorerFn) resourceViewer { v := contextView{newResourceView(t, app, list, c).(*resourceView)} - v.extraActionsFn = v.extraActions - - v.switchPage("ctx") - + { + v.extraActionsFn = v.extraActions + v.switchPage("ctx") + } return &v } -func (v *contextView) useContext(evt *tcell.EventKey) *tcell.EventKey { +func (v *contextView) extraActions(aa keyActions) { + aa[tcell.KeyEnter] = newKeyAction("Switch", v.useCmd, true) +} + +func (v *contextView) useCmd(evt *tcell.EventKey) *tcell.EventKey { if !v.rowSelected() { return evt } + if err := v.useContext(v.selectedItem); err != nil { + v.app.flash(flashWarn, err.Error()) + return evt + } - ctx := strings.TrimSpace(v.selectedItem) + v.app.gotoResource("po", true) + + return nil +} + +func (v *contextView) useContext(name string) error { + ctx := strings.TrimSpace(name) if strings.HasSuffix(ctx, "*") { ctx = strings.TrimRight(ctx, "*") } @@ -34,15 +48,12 @@ func (v *contextView) useContext(evt *tcell.EventKey) *tcell.EventKey { ctx = strings.TrimRight(ctx, "(𝜟)") } - err := v.list.Resource().(*resource.Context).Switch(ctx) - if err != nil { - v.app.flash(flashWarn, err.Error()) - return evt + if err := v.list.Resource().(*resource.Context).Switch(ctx); err != nil { + return err } config.Root.Reset() config.Root.Save() - v.app.flash(flashInfo, "Switching context to", ctx) v.refresh() if tv, ok := v.GetPrimitive("ctx").(*tableView); ok { @@ -50,7 +61,3 @@ func (v *contextView) useContext(evt *tcell.EventKey) *tcell.EventKey { } return nil } - -func (v *contextView) extraActions(aa keyActions) { - aa[KeyU] = newKeyAction("Use", v.useContext) -} diff --git a/internal/views/cronjob.go b/internal/views/cronjob.go index 691b2733..1f464cc2 100644 --- a/internal/views/cronjob.go +++ b/internal/views/cronjob.go @@ -34,5 +34,5 @@ func (v *cronJobView) trigger(evt *tcell.EventKey) *tcell.EventKey { } func (v *cronJobView) extraActions(aa keyActions) { - aa[tcell.KeyCtrlT] = newKeyAction("Trigger", v.trigger) + aa[tcell.KeyCtrlT] = newKeyAction("Trigger", v.trigger, true) } diff --git a/internal/views/crumbs.go b/internal/views/crumbs.go new file mode 100644 index 00000000..69dac44f --- /dev/null +++ b/internal/views/crumbs.go @@ -0,0 +1,36 @@ +package views + +import ( + "fmt" + + "github.com/derailed/tview" + "github.com/gdamore/tcell" +) + +type crumbsView struct { + *tview.TextView + + app *tview.Application +} + +func newCrumbsView(app *tview.Application) *crumbsView { + v := crumbsView{app: app, TextView: tview.NewTextView()} + { + v.SetTextColor(tcell.ColorAqua) + v.SetTextAlign(tview.AlignLeft) + v.SetBorderPadding(0, 0, 1, 1) + v.SetDynamicColors(true) + } + return &v +} + +func (v *crumbsView) update(crumbs []string) { + v.Clear() + last, bgColor := len(crumbs)-1, "aqua" + for i, c := range crumbs { + if i == last { + bgColor = "orange" + } + fmt.Fprintf(v, "[black:%s:b] <%s> [-:-:-] ", bgColor, c) + } +} diff --git a/internal/views/details.go b/internal/views/details.go index 84ef630a..73564ec1 100644 --- a/internal/views/details.go +++ b/internal/views/details.go @@ -53,12 +53,13 @@ func newDetailsView(app *appView, backFn actionHandler) *detailsView { }) } - v.actions[KeySlash] = newKeyAction("Search", v.activateCmd) - v.actions[tcell.KeyEnter] = newKeyAction("Search", v.searchCmd) - v.actions[tcell.KeyBackspace2] = newKeyAction("Erase", v.eraseCmd) - v.actions[tcell.KeyEscape] = newKeyAction("Reset", v.backCmd) - v.actions[tcell.KeyTab] = newKeyAction("Next", v.nextCmd) - v.actions[tcell.KeyBacktab] = newKeyAction("Previous", v.prevCmd) + // v.actions[KeySlash] = newKeyAction("Search", v.activateCmd) + // v.actions[tcell.KeyEnter] = newKeyAction("Search", v.searchCmd) + v.actions[tcell.KeyBackspace2] = newKeyAction("Erase", v.eraseCmd, false) + v.actions[tcell.KeyBackspace] = newKeyAction("Erase", v.eraseCmd, false) + v.actions[tcell.KeyEscape] = newKeyAction("Back", v.backCmd, true) + v.actions[tcell.KeyTab] = newKeyAction("Next Match", v.nextCmd, false) + v.actions[tcell.KeyBacktab] = newKeyAction("Previous Match", v.prevCmd, false) return &v } diff --git a/internal/views/flash.go b/internal/views/flash.go index f6fb2719..753143c8 100644 --- a/internal/views/flash.go +++ b/internal/views/flash.go @@ -5,6 +5,7 @@ import ( "strings" "time" + "github.com/derailed/k9s/internal/resource" "github.com/derailed/tview" "github.com/gdamore/tcell" ) @@ -34,12 +35,12 @@ type ( ) func newFlashView(app *tview.Application, m string) *flashView { - var f flashView + f := flashView{app: app, TextView: tview.NewTextView()} { - f = flashView{app: app, TextView: tview.NewTextView()} f.SetTextColor(tcell.ColorAqua) f.SetTextAlign(tview.AlignLeft) f.SetBorderPadding(0, 0, 1, 1) + f.SetText(m) } return &f } @@ -48,14 +49,17 @@ func (f *flashView) setMessage(level flashLevel, msg ...string) { if f.cancel != nil { f.cancel() } - var ctx context.Context { ctx, f.cancel = context.WithTimeout(context.TODO(), flashDelay*time.Second) go func(ctx context.Context) { + _, _, width, _ := f.GetRect() + if width <= 15 { + width = 100 + } m := strings.Join(msg, " ") f.SetTextColor(flashColor(level)) - f.SetText(flashEmoji(level) + " " + m) + f.SetText(resource.Truncate(flashEmoji(level)+" "+m, width-3)) f.app.Draw() for { select { diff --git a/internal/views/help.go b/internal/views/help.go new file mode 100644 index 00000000..92526358 --- /dev/null +++ b/internal/views/help.go @@ -0,0 +1,120 @@ +package views + +import ( + "context" + "fmt" + + "github.com/derailed/tview" + "github.com/gdamore/tcell" + "github.com/rs/zerolog/log" +) + +const ( + helpTitle = "Help" + helpTitleFmt = " [aqua::b]%s " +) + +type helpView struct { + *tview.TextView + + app *appView + current igniter + actions keyActions +} + +func newHelpView(app *appView) *helpView { + v := helpView{TextView: tview.NewTextView(), app: app, actions: make(keyActions)} + { + v.SetTextColor(tcell.ColorAqua) + v.SetBorder(true) + v.SetBorderPadding(0, 0, 1, 1) + v.SetDynamicColors(true) + v.SetInputCapture(v.keyboard) + v.current = app.content.GetPrimitive("main").(igniter) + } + v.actions[tcell.KeyEsc] = newKeyAction("Back", v.backCmd, true) + v.actions[tcell.KeyEnter] = newKeyAction("Back", v.backCmd, false) + + return &v +} + +func (v *helpView) keyboard(evt *tcell.EventKey) *tcell.EventKey { + key := evt.Key() + if key == tcell.KeyRune { + key = tcell.Key(evt.Rune()) + } + + if a, ok := v.actions[key]; ok { + log.Debug().Msgf(">> TableView handled %s", tcell.KeyNames[key]) + return a.action(evt) + } + return evt +} + +func (v *helpView) backCmd(evt *tcell.EventKey) *tcell.EventKey { + v.app.inject(v.current) + return nil +} + +func (v *helpView) init(_ context.Context, _ string) { + v.resetTitle() + + type helpItem struct { + key, description string + } + + general := []helpItem{ + {":", "Command mode"}, + {"/", "Filter mode"}, + {"esc", "Clear filter"}, + {"tab", "Next term match"}, + {"backtab", "Previous term match"}, + {"Ctrl-r", "Refresh"}, + {"p", "Previous resource view"}, + {"q", "Quit"}, + } + fmt.Fprintf(v, "🏠 [aqua::b]%s\n", "General") + for _, h := range general { + fmt.Fprintf(v, "[pink::b]%9s [gray::]%s\n", h.key, h.description) + } + + navigation := []helpItem{ + {"g", "Goto Top"}, + {"G", "Goto Bottom"}, + {"b", "Page Down"}, + {"f", "Page Up"}, + {"l", "Left"}, + {"h", "Right"}, + {"k", "Up"}, + {"j", "Down"}, + } + fmt.Fprintln(v) + fmt.Fprintf(v, "🖲 [aqua::b]%s\n", "View Navigation") + for _, h := range navigation { + fmt.Fprintf(v, "[pink::b]%9s [gray::]%s\n", h.key, h.description) + } + + views := []helpItem{ + {"?", "Help"}, + {"a", "Aliases view"}, + } + fmt.Fprintln(v) + fmt.Fprintf(v, "️️⁉️ [aqua::b]%s\n", "Help") + for _, h := range views { + fmt.Fprintf(v, "[pink::b]%9s [gray::]%s\n", h.key, h.description) + } + + v.app.setHints(v.hints()) +} + +func (v *helpView) hints() hints { + return v.actions.toHints() +} + +func (v *helpView) getTitle() string { + return helpTitle +} + +func (v *helpView) resetTitle() { + v.SetTitle(fmt.Sprintf(helpTitleFmt, helpTitle)) +} diff --git a/internal/views/job.go b/internal/views/job.go index 6b055c92..be080239 100644 --- a/internal/views/job.go +++ b/internal/views/job.go @@ -64,5 +64,5 @@ func (v *jobView) logs(evt *tcell.EventKey) *tcell.EventKey { } func (v *jobView) extraActions(aa keyActions) { - aa[KeyL] = newKeyAction("Logs", v.logs) + aa[KeyL] = newKeyAction("Logs", v.logs, true) } diff --git a/internal/views/log.go b/internal/views/log.go index 3b8d6e00..6a284a58 100644 --- a/internal/views/log.go +++ b/internal/views/log.go @@ -4,6 +4,7 @@ import ( "fmt" "io" + "github.com/derailed/k9s/internal/config" "github.com/derailed/tview" ) @@ -20,6 +21,7 @@ func newLogView(title string, parent loggable) *logView { v.SetDynamicColors(true) v.SetWrap(true) v.setTitle(parent.getSelection()) + v.SetMaxBuffer(config.Root.K9s.LogBufferSize) } v.ansiWriter = tview.ANSIWriter(v) return &v diff --git a/internal/views/logs.go b/internal/views/logs.go index 23438e3c..a5e8de63 100644 --- a/internal/views/logs.go +++ b/internal/views/logs.go @@ -35,12 +35,12 @@ func newLogsView(parent loggable) *logsView { containers: []string{}, } v.setActions(keyActions{ - tcell.KeyEscape: {description: "Back", action: v.back}, - KeyC: {description: "Clear", action: v.clearLogs}, - KeyG: {description: "Top", action: v.top}, - KeyShiftG: {description: "Bottom", action: v.bottom}, - KeyF: {description: "Up", action: v.pageUp}, - KeyB: {description: "Down", action: v.pageDown}, + tcell.KeyEscape: {description: "Back", action: v.back, visible: true}, + KeyC: {description: "Clear", action: v.clearLogs, visible: true}, + KeyG: {description: "Top", action: v.top, visible: false}, + KeyShiftG: {description: "Bottom", action: v.bottom, visible: false}, + KeyF: {description: "Up", action: v.pageUp, visible: false}, + KeyB: {description: "Down", action: v.pageDown, visible: false}, }) v.SetInputCapture(v.keyboard) @@ -91,7 +91,7 @@ func (v *logsView) setActions(aa keyActions) { func (v *logsView) hints() hints { if len(v.containers) > 1 { for i, c := range v.containers { - v.actions[tcell.Key(numKeys[i+1])] = newKeyAction(c, nil) + v.actions[tcell.Key(numKeys[i+1])] = newKeyAction(c, nil, true) } } return v.actions.toHints() @@ -163,7 +163,7 @@ func (v *logsView) doLoad(path, co string) error { if !ok { return fmt.Errorf("Resource %T is not tailable", v.parent.getList().Resource) } - maxBuff := int64(config.Root.K9s.LogBufferSize) + maxBuff := int64(config.Root.K9s.LogRequestSize) cancelFn, err := res.Logs(c, ns, po, co, maxBuff, false) if err != nil { cancelFn() @@ -200,14 +200,18 @@ func (v *logsView) bottom(*tcell.EventKey) *tcell.EventKey { func (v *logsView) pageUp(*tcell.EventKey) *tcell.EventKey { if p := v.CurrentPage(); p != nil { - p.Item.(*logView).PageUp() + if p.Item.(*logView).PageUp() { + v.parent.appView().flash(flashInfo, "Reached Top ...") + } } return nil } func (v *logsView) pageDown(*tcell.EventKey) *tcell.EventKey { if p := v.CurrentPage(); p != nil { - p.Item.(*logView).PageDown() + if p.Item.(*logView).PageDown() { + v.parent.appView().flash(flashInfo, "Reached Bottom ...") + } } return nil } diff --git a/internal/views/menu.go b/internal/views/menu.go index d9504c37..42bb2818 100644 --- a/internal/views/menu.go +++ b/internal/views/menu.go @@ -14,17 +14,16 @@ import ( ) const ( - menuSepFmt = " [dodgerblue::b]%-9s [white::d]%s " + menuSepFmt = " [dodgerblue::b]%-%ds [white::d]%s " menuIndexFmt = " [fuchsia::b]<%d> [white::d]%s " - maxRows = 6 - colLen = 20 + maxRows = 7 ) var menuRX = regexp.MustCompile(`\d`) type ( hint struct { - mnemonic, display string + mnemonic, description string } hints []hint @@ -53,7 +52,7 @@ func (h hints) Less(i, j int) bool { if err1 != nil && err2 == nil { return false } - return strings.Compare(h[i].mnemonic, h[j].mnemonic) < 0 + return strings.Compare(h[i].description, h[j].description) < 0 } // ----------------------------------------------------------------------------- @@ -63,12 +62,13 @@ type ( keyAction struct { description string action actionHandler + visible bool } keyActions map[tcell.Key]keyAction ) -func newKeyAction(d string, a actionHandler) keyAction { - return keyAction{description: d, action: a} +func newKeyAction(d string, a actionHandler, display bool) keyAction { + return keyAction{description: d, action: a, visible: display} } func newMenuView() *menuView { @@ -76,72 +76,21 @@ func newMenuView() *menuView { return &v } -// ----------------------------------------------------------------------------- -type menuView struct { - *tview.Table -} - -func (v *menuView) setMenu(hh hints) { - v.Clear() - sort.Sort(hh) - - var row, col int - firstNS, firstCmd := true, true - for _, h := range hh { - isDigit := menuRX.MatchString(h.mnemonic) - if isDigit && firstNS { - row, col, firstNS = 0, 2, false - } - if !isDigit && firstCmd { - row, col, firstCmd = 0, 0, false - } - c := tview.NewTableCell(v.item(h)) - v.SetCell(row, col, c) - row++ - if row > maxRows { - col++ - row = 0 - } - } -} - -func (*menuView) toMnemonic(s string) string { - return "<" + strings.ToLower(s) + ">" -} - -func (v *menuView) item(h hint) string { - i, err := strconv.Atoi(h.mnemonic) - if err == nil { - return fmt.Sprintf(menuIndexFmt, i, resource.Truncate(h.display, 14)) - } - return fmt.Sprintf(menuSepFmt, v.toMnemonic(h.mnemonic), h.display) -} - -func (a keyActions) skipKey(k tcell.Key) bool { - switch k { - case tcell.KeyBackspace2: - fallthrough - case tcell.KeyEnter: - return true - } - return false -} - func (a keyActions) toHints() hints { kk := make([]int, 0, len(a)) - for k := range a { - if !a.skipKey(k) { + for k, v := range a { + if v.visible { kk = append(kk, int(k)) } } sort.Ints(kk) - hh := make(hints, 0, len(a)) + hh := make(hints, 0, len(kk)) for _, k := range kk { if name, ok := tcell.KeyNames[tcell.Key(k)]; ok { hh = append(hh, hint{ - mnemonic: name, - display: a[tcell.Key(k)].description}) + mnemonic: name, + description: a[tcell.Key(k)].description}) } else { log.Error().Msgf("Unable to local KeyName for %#v", k) } @@ -149,6 +98,85 @@ func (a keyActions) toHints() hints { return hh } +// ----------------------------------------------------------------------------- +type menuView struct { + *tview.Table +} + +func (v *menuView) populateMenu(hh hints) { + v.Clear() + sort.Sort(hh) + + t := v.buildMenuTable(hh) + for row := 0; row < len(t); row++ { + for col := 0; col < len(t[row]); col++ { + if len(t[row][col]) == 0 { + continue + } + c := tview.NewTableCell(t[row][col]) + v.SetCell(row, col, c) + } + } +} + +func (v *menuView) buildMenuTable(hh hints) [][]string { + table := make([][]hint, maxRows+1) + + colCount := (len(hh) / maxRows) + 1 + for row := 0; row < maxRows; row++ { + table[row] = make([]hint, colCount) + } + + var row, col int + firstNS, firstCmd := true, true + maxKeys := make([]int, colCount) + for _, h := range hh { + isDigit := menuRX.MatchString(h.mnemonic) + if isDigit && firstNS { + row, col, firstNS = 0, col+1, false + } + if !isDigit && firstCmd { + row, col, firstCmd = 0, 0, false + } + if maxKeys[col] < len(h.mnemonic) { + maxKeys[col] = len(h.mnemonic) + } + table[row][col] = h + row++ + if row >= maxRows { + col++ + row = 0 + } + } + + strTable := make([][]string, maxRows+1) + for r := 0; r < len(table); r++ { + strTable[r] = make([]string, len(table[r])) + } + for row := range strTable { + for col := range strTable[row] { + strTable[row][col] = v.formatMenu(table[row][col], maxKeys[col]) + } + } + return strTable +} + +func (*menuView) toMnemonic(s string) string { + if len(s) == 0 { + return s + } + return "<" + strings.ToLower(s) + ">" +} + +func (v *menuView) formatMenu(h hint, size int) string { + i, err := strconv.Atoi(h.mnemonic) + if err == nil { + return fmt.Sprintf(menuIndexFmt, i, resource.Truncate(h.description, 14)) + } + menuFmt := " [dodgerblue::b]%-" + strconv.Itoa(size+2) + "s [white::d]%s " + return fmt.Sprintf(menuFmt, v.toMnemonic(h.mnemonic), h.description) +} + // ----------------------------------------------------------------------------- // Key mapping Constants diff --git a/internal/views/namespace.go b/internal/views/namespace.go index 33b24acc..25039ce0 100644 --- a/internal/views/namespace.go +++ b/internal/views/namespace.go @@ -5,6 +5,7 @@ import ( "regexp" "github.com/derailed/k9s/internal/config" + "github.com/derailed/k9s/internal/k8s" "github.com/derailed/k9s/internal/resource" "github.com/gdamore/tcell" ) @@ -31,21 +32,34 @@ func newNamespaceView(t string, app *appView, list resource.List, c colorerFn) r } func (v *namespaceView) extraActions(aa keyActions) { - aa[KeyU] = newKeyAction("Use", v.useNamespace) + aa[tcell.KeyEnter] = newKeyAction("Switch", v.switchNsCmd, true) + aa[KeyU] = newKeyAction("Use", v.useNsCmd, true) } -func (v *namespaceView) useNamespace(evt *tcell.EventKey) *tcell.EventKey { +func (v *namespaceView) switchNsCmd(evt *tcell.EventKey) *tcell.EventKey { if !v.rowSelected() { return evt } - ns := v.getSelectedItem() - if err := config.Root.SetActiveNamespace(ns); err != nil { + v.useNamespace(v.getSelectedItem()) + v.app.gotoResource("po", true) + return nil +} + +func (v *namespaceView) useNsCmd(evt *tcell.EventKey) *tcell.EventKey { + if !v.rowSelected() { + return evt + } + v.useNamespace(v.getSelectedItem()) + return nil +} + +func (v *namespaceView) useNamespace(name string) { + if err := config.Root.SetActiveNamespace(name); err != nil { v.app.flash(flashErr, err.Error()) } else { - v.app.flash(flashInfo, fmt.Sprintf("Namespace %s is now active!", ns)) + v.app.flash(flashInfo, fmt.Sprintf("Namespace %s is now active!", name)) } config.Root.Save() - return nil } func (v *namespaceView) getSelectedItem() string { @@ -58,10 +72,12 @@ func (*namespaceView) cleanser(s string) string { func (v *namespaceView) decorate(data resource.TableData) resource.TableData { if _, ok := data.Rows[resource.AllNamespaces]; !ok { - data.Rows[resource.AllNamespace] = &resource.RowEvent{ - Action: resource.Unchanged, - Fields: resource.Row{resource.AllNamespace, "Active", "0"}, - Deltas: resource.Row{"", "", ""}, + if k8s.CanIAccess("", "list", "namespaces", "namespace.v1") { + data.Rows[resource.AllNamespace] = &resource.RowEvent{ + Action: resource.Unchanged, + Fields: resource.Row{resource.AllNamespace, "Active", "0"}, + Deltas: resource.Row{"", "", ""}, + } } } for k, v := range data.Rows { diff --git a/internal/views/pod.go b/internal/views/pod.go index b070a2d8..4e94f260 100644 --- a/internal/views/pod.go +++ b/internal/views/pod.go @@ -141,8 +141,8 @@ func (v *podView) showLogs(path, co string, previous bool) { } func (v *podView) extraActions(aa keyActions) { - aa[KeyL] = newKeyAction("Logs", v.logsCmd) - aa[KeyS] = newKeyAction("Shell", v.shellCmd) + aa[KeyL] = newKeyAction("Logs", v.logsCmd, true) + aa[KeyS] = newKeyAction("Shell", v.shellCmd, true) } func fetchContainers(l resource.List, po string, includeInit bool) ([]string, error) { diff --git a/internal/views/registrar.go b/internal/views/registrar.go index d7ffce64..6d0a80cf 100644 --- a/internal/views/registrar.go +++ b/internal/views/registrar.go @@ -20,203 +20,20 @@ type ( } ) -var cmdMap = map[string]resCmd{ - "cm": { - title: "ConfigMaps", - api: "core", - viewFn: newResourceView, - listFn: resource.NewConfigMapList, - colorerFn: defaultColorer, - }, - "cr": { - title: "ClusterRoles", - api: "rbac.authorization.k8s.io", - viewFn: newResourceView, - listFn: resource.NewClusterRoleList, - colorerFn: defaultColorer, - }, - "crb": { - title: "ClusterRoleBindings", - api: "rbac.authorization.k8s.io", - viewFn: newResourceView, - listFn: resource.NewClusterRoleBindingList, - colorerFn: defaultColorer, - }, - "crd": { - title: "CustomResourceDefinitions", - api: "apiextensions.k8s.io", - viewFn: newResourceView, - listFn: resource.NewCRDList, - colorerFn: defaultColorer, - }, - "cron": { - title: "CronJobs", - api: "batch", - viewFn: newCronJobView, - listFn: resource.NewCronJobList, - colorerFn: defaultColorer, - }, - "ctx": { - title: "Contexts", - api: "core", - viewFn: newContextView, - listFn: resource.NewContextList, - colorerFn: ctxColorer, - }, - "ds": { - title: "DaemonSets", - api: "core", - viewFn: newResourceView, - listFn: resource.NewDaemonSetList, - colorerFn: dpColorer, - }, - "dp": { - title: "Deployments", - api: "apps", - viewFn: newResourceView, - listFn: resource.NewDeploymentList, - colorerFn: dpColorer, - }, - "ep": { - title: "EndPoints", - api: "core", - viewFn: newResourceView, - listFn: resource.NewEndpointsList, - colorerFn: defaultColorer, - }, - "ev": { - title: "Events", - api: "core", - viewFn: newResourceView, - listFn: resource.NewEventList, - colorerFn: evColorer, - }, - "hpa": { - title: "HorizontalPodAutoscalers", - api: "autoscaling", - viewFn: newResourceView, - listFn: resource.NewHPAList, - colorerFn: defaultColorer, - }, - "ing": { - title: "Ingress", - api: "extensions", - viewFn: newResourceView, - listFn: resource.NewIngressList, - colorerFn: defaultColorer, - }, - "job": { - title: "Jobs", - api: "batch", - viewFn: newJobView, - listFn: resource.NewJobList, - colorerFn: defaultColorer, - }, - "no": { - title: "Nodes", - api: "core", - viewFn: newResourceView, - listFn: resource.NewNodeList, - colorerFn: nsColorer, - }, - "ns": { - title: "Namespaces", - api: "core", - viewFn: newNamespaceView, - listFn: resource.NewNamespaceList, - colorerFn: nsColorer, - }, - "po": { - title: "Pods", - api: "core", - viewFn: newPodView, - listFn: resource.NewPodList, - colorerFn: podColorer, - }, - "pv": { - title: "PersistentVolumes", - api: "core", - viewFn: newResourceView, - listFn: resource.NewPVList, - colorerFn: pvColorer, - }, - "pvc": { - title: "PersistentVolumeClaims", - api: "core", - viewFn: newResourceView, - listFn: resource.NewPVCList, - colorerFn: pvcColorer, - }, - "rb": { - title: "RoleBindings", - api: "rbac.authorization.k8s.io", - viewFn: newResourceView, - listFn: resource.NewRoleBindingList, - colorerFn: defaultColorer, - }, - "rc": { - title: "ReplicationControllers", - api: "v1", - viewFn: newResourceView, - listFn: resource.NewReplicationControllerList, - colorerFn: rsColorer, - }, - "ro": { - title: "Roles", - api: "rbac.authorization.k8s.io", - viewFn: newResourceView, - listFn: resource.NewRoleList, - colorerFn: defaultColorer, - }, - "rs": { - title: "ReplicaSets", - api: "apps", - viewFn: newResourceView, - listFn: resource.NewReplicaSetList, - colorerFn: rsColorer, - }, - "sa": { - title: "ServiceAccounts", - api: "core", - viewFn: newResourceView, - listFn: resource.NewServiceAccountList, - colorerFn: defaultColorer, - }, - "sec": { - title: "Secrets", - api: "core", - viewFn: newResourceView, - listFn: resource.NewSecretList, - colorerFn: defaultColorer, - }, - "sts": { - title: "StatefulSets", - api: "apps", - viewFn: newResourceView, - listFn: resource.NewStatefulSetList, - colorerFn: stsColorer, - }, - "svc": { - title: "Services", - api: "core", - viewFn: newResourceView, - listFn: resource.NewServiceList, - colorerFn: defaultColorer, - }, -} - func helpCmds() map[string]resCmd { + cmdMap := resourceViews() cmds := make(map[string]resCmd, len(cmdMap)) for k, v := range cmdMap { cmds[k] = v } - for k, v := range getCRDS() { + for k, v := range allCRDs() { cmds[k] = resCmd{title: v.Kind, api: v.Group} } + return cmds } -func getCRDS() map[string]k8s.APIGroup { +func allCRDs() map[string]k8s.APIGroup { m := map[string]k8s.APIGroup{} list := resource.NewCRDList(resource.AllNamespaces) ll, _ := list.Resource().List(resource.AllNamespaces) @@ -248,5 +65,193 @@ func getCRDS() map[string]k8s.APIGroup { m[s] = grp } } + return m } + +func resourceViews() map[string]resCmd { + return map[string]resCmd{ + "cm": { + title: "ConfigMaps", + api: "core", + viewFn: newResourceView, + listFn: resource.NewConfigMapList, + colorerFn: defaultColorer, + }, + "cr": { + title: "ClusterRoles", + api: "rbac.authorization.k8s.io", + viewFn: newResourceView, + listFn: resource.NewClusterRoleList, + colorerFn: defaultColorer, + }, + "crb": { + title: "ClusterRoleBindings", + api: "rbac.authorization.k8s.io", + viewFn: newResourceView, + listFn: resource.NewClusterRoleBindingList, + colorerFn: defaultColorer, + }, + "crd": { + title: "CustomResourceDefinitions", + api: "apiextensions.k8s.io", + viewFn: newResourceView, + listFn: resource.NewCRDList, + colorerFn: defaultColorer, + }, + "cron": { + title: "CronJobs", + api: "batch", + viewFn: newCronJobView, + listFn: resource.NewCronJobList, + colorerFn: defaultColorer, + }, + "ctx": { + title: "Contexts", + api: "core", + viewFn: newContextView, + listFn: resource.NewContextList, + colorerFn: ctxColorer, + }, + "ds": { + title: "DaemonSets", + api: "core", + viewFn: newResourceView, + listFn: resource.NewDaemonSetList, + colorerFn: dpColorer, + }, + "dp": { + title: "Deployments", + api: "apps", + viewFn: newResourceView, + listFn: resource.NewDeploymentList, + colorerFn: dpColorer, + }, + "ep": { + title: "EndPoints", + api: "core", + viewFn: newResourceView, + listFn: resource.NewEndpointsList, + colorerFn: defaultColorer, + }, + "ev": { + title: "Events", + api: "core", + viewFn: newResourceView, + listFn: resource.NewEventList, + colorerFn: evColorer, + }, + "hpa": { + title: "HorizontalPodAutoscalers", + api: "autoscaling", + viewFn: newResourceView, + listFn: resource.NewHPAList, + colorerFn: defaultColorer, + }, + "ing": { + title: "Ingress", + api: "extensions", + viewFn: newResourceView, + listFn: resource.NewIngressList, + colorerFn: defaultColorer, + }, + "job": { + title: "Jobs", + api: "batch", + viewFn: newJobView, + listFn: resource.NewJobList, + colorerFn: defaultColorer, + }, + "no": { + title: "Nodes", + api: "core", + viewFn: newResourceView, + listFn: resource.NewNodeList, + colorerFn: nsColorer, + }, + "ns": { + title: "Namespaces", + api: "core", + viewFn: newNamespaceView, + listFn: resource.NewNamespaceList, + colorerFn: nsColorer, + }, + "po": { + title: "Pods", + api: "core", + viewFn: newPodView, + listFn: resource.NewPodList, + colorerFn: podColorer, + }, + "pv": { + title: "PersistentVolumes", + api: "core", + viewFn: newResourceView, + listFn: resource.NewPVList, + colorerFn: pvColorer, + }, + "pvc": { + title: "PersistentVolumeClaims", + api: "core", + viewFn: newResourceView, + listFn: resource.NewPVCList, + colorerFn: pvcColorer, + }, + "rb": { + title: "RoleBindings", + api: "rbac.authorization.k8s.io", + viewFn: newResourceView, + listFn: resource.NewRoleBindingList, + colorerFn: defaultColorer, + }, + "rc": { + title: "ReplicationControllers", + api: "v1", + viewFn: newResourceView, + listFn: resource.NewReplicationControllerList, + colorerFn: rsColorer, + }, + "ro": { + title: "Roles", + api: "rbac.authorization.k8s.io", + viewFn: newResourceView, + listFn: resource.NewRoleList, + colorerFn: defaultColorer, + }, + "rs": { + title: "ReplicaSets", + api: "apps", + viewFn: newResourceView, + listFn: resource.NewReplicaSetList, + colorerFn: rsColorer, + }, + "sa": { + title: "ServiceAccounts", + api: "core", + viewFn: newResourceView, + listFn: resource.NewServiceAccountList, + colorerFn: defaultColorer, + }, + "sec": { + title: "Secrets", + api: "core", + viewFn: newResourceView, + listFn: resource.NewSecretList, + colorerFn: defaultColorer, + }, + "sts": { + title: "StatefulSets", + api: "apps", + viewFn: newResourceView, + listFn: resource.NewStatefulSetList, + colorerFn: stsColorer, + }, + "svc": { + title: "Services", + api: "core", + viewFn: newResourceView, + listFn: resource.NewServiceList, + colorerFn: defaultColorer, + }, + } +} diff --git a/internal/views/resource.go b/internal/views/resource.go index 3d4ddab1..b794f67d 100644 --- a/internal/views/resource.go +++ b/internal/views/resource.go @@ -17,7 +17,10 @@ import ( "github.com/rs/zerolog/log" ) -const noSelection = "" +const ( + refreshDelay = 0.1 + noSelection = "" +) type ( details interface { @@ -77,7 +80,7 @@ func (v *resourceView) init(ctx context.Context, ns string) { v.selectedItem, v.selectedNS = noSelection, ns go func(ctx context.Context) { - initTick := 0.1 + initTick := refreshDelay for { select { case <-ctx.Done(): @@ -169,8 +172,8 @@ func (v *resourceView) describeCmd(evt *tcell.EventKey) *tcell.EventKey { sel := v.getSelectedItem() raw, err := v.list.Resource().Describe(v.title, sel) if err != nil { - v.app.flash(flashErr, "Unable to describeCmd this resource", err.Error()) - log.Error().Err(err) + v.app.flash(flashErr, err.Error()) + log.Warn().Msg(err.Error()) return evt } details := v.GetPrimitive("details").(*detailsView) @@ -250,8 +253,6 @@ func (v *resourceView) doSwitchNamespace(ns string) { config.Root.Save() } -// Utils... - func (v *resourceView) refresh() { if _, ok := v.CurrentPage().Item.(*tableView); !ok { return @@ -263,6 +264,7 @@ func (v *resourceView) refresh() { v.list.SetNamespace(v.selectedNS) } if err := v.list.Reconcile(); err != nil { + log.Warn().Msgf("%s", err) v.app.flash(flashErr, err.Error()) } data := v.list.Data() @@ -272,7 +274,7 @@ func (v *resourceView) refresh() { v.getTV().update(data) v.selectItem(v.selectedRow, 0) v.refreshActions() - v.app.infoView.refresh() + v.app.clusterInfoView.refresh() v.app.Draw() } v.update.Unlock() @@ -334,49 +336,51 @@ func (v *resourceView) refreshActions() { return } - nn, err := k8s.NewNamespace().List(resource.AllNamespaces) - if err != nil { - v.app.flash(flashErr, "Unable to retrieve namespaces", err.Error()) - return - } - - if v.list.Namespaced() && !v.list.AllNamespaces() { - if !config.InNSList(nn, v.list.GetNamespace()) { - v.list.SetNamespace(resource.DefaultNamespace) - } - } - + var nn []interface{} aa := make(keyActions) - if v.list.Access(resource.NamespaceAccess) { - v.namespaces = make(map[int]string, config.MaxFavoritesNS) - for i, n := range config.Root.FavNamespaces() { - aa[tcell.Key(numKeys[i])] = newKeyAction(n, v.switchNamespaceCmd) - v.namespaces[i] = n + if k8s.CanIAccess("", "list", "namespaces", "namespace.v1") { + var err error + nn, err = k8s.NewNamespace().List(resource.AllNamespaces) + if err != nil { + log.Warn().Msgf("%s", err) + v.app.flash(flashErr, err.Error()) + } + + if v.list.Namespaced() && !v.list.AllNamespaces() { + if !config.InNSList(nn, v.list.GetNamespace()) { + v.list.SetNamespace(resource.DefaultNamespace) + } + } + + if v.list.Access(resource.NamespaceAccess) { + v.namespaces = make(map[int]string, config.MaxFavoritesNS) + for i, n := range config.Root.FavNamespaces() { + aa[tcell.Key(numKeys[i])] = newKeyAction(n, v.switchNamespaceCmd, true) + v.namespaces[i] = n + } } } - aa[tcell.KeyCtrlR] = newKeyAction("Refresh", v.refreshCmd) + aa[tcell.KeyCtrlR] = newKeyAction("Refresh", v.refreshCmd, false) + aa[KeyHelp] = newKeyAction("Help", v.app.noopCmd, false) + aa[KeyP] = newKeyAction("Previous", v.app.prevCmd, false) if v.list.Access(resource.EditAccess) { - aa[KeyE] = newKeyAction("Edit", v.editCmd) + aa[KeyE] = newKeyAction("Edit", v.editCmd, true) } - if v.list.Access(resource.DeleteAccess) { - aa[tcell.KeyCtrlD] = newKeyAction("Delete", v.deleteCmd) + aa[tcell.KeyCtrlD] = newKeyAction("Delete", v.deleteCmd, true) } if v.list.Access(resource.ViewAccess) { - aa[KeyV] = newKeyAction("View", v.viewCmd) + aa[KeyV] = newKeyAction("View", v.viewCmd, true) } if v.list.Access(resource.DescribeAccess) { - aa[KeyD] = newKeyAction("Describe", v.describeCmd) + aa[KeyD] = newKeyAction("Describe", v.describeCmd, true) } - aa[KeyHelp] = newKeyAction("Help", v.app.noopCmd) - if v.extraActionsFn != nil { v.extraActionsFn(aa) } - t := v.getTV() t.setActions(aa) v.app.setHints(t.hints()) diff --git a/internal/views/splash.go b/internal/views/splash.go index 062f0d5c..18794878 100644 --- a/internal/views/splash.go +++ b/internal/views/splash.go @@ -32,13 +32,13 @@ var logo = []string{ } // Splash screen definition -type Splash struct { +type splashView struct { *tview.Flex } // NewSplash instantiates a new splash screen with product and company info. -func NewSplash(rev string) *Splash { - v := Splash{tview.NewFlex()} +func newSplash(rev string) *splashView { + v := splashView{tview.NewFlex()} logo := tview.NewTextView() { @@ -62,11 +62,11 @@ func NewSplash(rev string) *Splash { return &v } -func (v *Splash) layoutLogo(t *tview.TextView) { +func (v *splashView) layoutLogo(t *tview.TextView) { logo := strings.Join(logo, "\n[orange::b]") fmt.Fprintf(t, "%s[orange::b]%s\n", strings.Repeat("\n", 2), logo) } -func (v *Splash) layoutRev(t *tview.TextView, rev string) { +func (v *splashView) layoutRev(t *tview.TextView, rev string) { fmt.Fprintf(t, "[white::b]Revision [red::b]%s", rev) } diff --git a/internal/views/table.go b/internal/views/table.go index 6c23fb70..6a96e3a7 100644 --- a/internal/views/table.go +++ b/internal/views/table.go @@ -53,14 +53,14 @@ func newTableView(app *appView, title string, sortFn resource.SortFn) *tableView v.SetInputCapture(v.keyboard) } - v.actions[KeySlash] = newKeyAction("Filter", v.activateCmd) - v.actions[tcell.KeyEnter] = newKeyAction("Search", v.filterCmd) - v.actions[tcell.KeyEscape] = newKeyAction("Reset", v.resetCmd) - v.actions[tcell.KeyBackspace2] = newKeyAction("Erase", v.eraseCmd) - v.actions[KeyG] = newKeyAction("Top", app.puntCmd) - v.actions[KeyShiftG] = newKeyAction("Bottom", app.puntCmd) - v.actions[KeyB] = newKeyAction("Down", v.pageDownCmd) - v.actions[KeyF] = newKeyAction("Up", v.pageUpCmd) + v.actions[KeySlash] = newKeyAction("Filter", v.activateCmd, false) + v.actions[tcell.KeyEnter] = newKeyAction("Search", v.filterCmd, false) + v.actions[tcell.KeyEscape] = newKeyAction("Reset Filter", v.resetCmd, false) + v.actions[tcell.KeyBackspace2] = newKeyAction("Erase", v.eraseCmd, false) + v.actions[KeyG] = newKeyAction("Top", app.puntCmd, false) + v.actions[KeyShiftG] = newKeyAction("Bottom", app.puntCmd, false) + v.actions[KeyB] = newKeyAction("Down", v.pageDownCmd, false) + v.actions[KeyF] = newKeyAction("Up", v.pageUpCmd, false) return &v } @@ -77,6 +77,7 @@ func (v *tableView) keyboard(evt *tcell.EventKey) *tcell.EventKey { v.cmdBuff.add(evt.Rune()) v.clearSelection() v.doUpdate(v.filtered()) + v.setSelection() return nil } key = tcell.Key(evt.Rune()) @@ -89,6 +90,12 @@ func (v *tableView) keyboard(evt *tcell.EventKey) *tcell.EventKey { return evt } +func (v *tableView) setSelection() { + if v.GetRowCount() > 0 { + v.Select(1, 0) + } +} + func (v *tableView) pageUpCmd(evt *tcell.EventKey) *tcell.EventKey { v.PageUp() return nil @@ -113,7 +120,9 @@ func (v *tableView) eraseCmd(evt *tcell.EventKey) *tcell.EventKey { } func (v *tableView) resetCmd(evt *tcell.EventKey) *tcell.EventKey { - v.app.flash(flashInfo, "Filtering off...") + if !v.cmdBuff.empty() { + v.app.flash(flashInfo, "Clearing filter...") + } v.cmdBuff.reset() v.refresh() return nil