diff --git a/.gitignore b/.gitignore index b8428f68..81b09721 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,5 @@ .vscode .idea -k9s.log .envrc cov.out execs @@ -10,10 +9,9 @@ dist notes vendor go.mod1 -popeye1.go gen.sh -cluster_info_test.go *.test *.log *~ -pod1.go \ No newline at end of file +faas +demos \ No newline at end of file diff --git a/.goreleaser.yml b/.goreleaser.yml index ffb43d7c..f5511c2c 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -15,23 +15,23 @@ builds: goarch: - 386 - amd64 - - arm - arm64 + - arm goarm: - - 6 - 7 ldflags: - -s -w -X github.com/derailed/k9s/cmd.version={{.Version}} -X github.com/derailed/k9s/cmd.commit={{.Commit}} -X github.com/derailed/k9s/cmd.date={{.Date}} -archive: - replacements: - darwin: Darwin - linux: Linux - windows: Windows - bit: Arm - bitv6: Arm6 - bitv7: Arm7 - 386: i386 - amd64: x86_64 +archives: + - name_template: "{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}" + replacements: + darwin: Darwin + linux: Linux + windows: Windows + bit: Arm + bitv6: Arm6 + bitv7: Arm7 + 386: i386 + amd64: x86_64 checksum: name_template: "checksums.txt" snapshot: @@ -43,7 +43,7 @@ changelog: - "^docs:" - "^test:" -# Homebrew +# Homebrews brews: - name: k9s github: diff --git a/Makefile b/Makefile index afbae2da..4685f5b8 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ NAME := k9s PACKAGE := github.com/derailed/$(NAME) GIT := $(shell git rev-parse --short HEAD) DATE := $(shell date +%FT%T%Z) -VERSION := v0.12.0 +VERSION ?= v0.14.0 IMG_NAME := derailed/k9s IMAGE := ${IMG_NAME}:${VERSION} diff --git a/README.md b/README.md index 2d8e10a6..47710d12 100644 --- a/README.md +++ b/README.md @@ -31,11 +31,11 @@ Wanna discuss K9s features with your fellow `K9sers` or simply show your support ## Installation -K9s is available on Linux, OSX and Windows platforms. +K9s is available on Linux, macOS and Windows platforms. * Binaries for Linux, Windows and Mac are available as tarballs in the [release](https://github.com/derailed/k9s/releases) page. -* Via Homebrew or LinuxBrew for OSX and Linux +* Via Homebrew or LinuxBrew for macOS and Linux ```shell brew install derailed/k9s/k9s @@ -47,12 +47,16 @@ K9s is available on Linux, OSX and Windows platforms. sudo port install k9s ``` -* Archlinux (AUR) - - K9s is available in the Arch User Repository under the name [k9s-bin](https://aur.archlinux.org/packages/k9s-bin/), you can install it with your favorite AUR helper like so: +* On Arch Linux ```shell - yay -S k9s-bin + pacman -S k9s + ``` + +* Via [Scoop](https://scoop.sh) for Windows + + ```shell + scoop install k9s ``` * Building from source @@ -95,8 +99,10 @@ K9s is available on Linux, OSX and Windows platforms. --- -## Demo Video +## Demo Videos/Recordings +* [K9s Pulses](https://asciinema.org/a/UbXKPal6IWpTaVAjBBFmizcGN) +* [K9s v0.15.1](https://youtu.be/7Fx4XQ2ftpM) * [K9s v0.13.0](https://www.youtube.com/watch?v=qaeR2iK7U0o&t=15s) * [K9s v0.9.0](https://www.youtube.com/watch?v=bxKfqumjW4I) * [K9s v0.7.0 Features](https://youtu.be/83jYehwlql8) @@ -136,21 +142,21 @@ K9s uses aliases to navigate most K8s resources. | `:`ns`` | To view and switch to another Kubernetes namespace | `:`+`ns`+`` | | `:screendump`, `:sd` | To view all saved resources | | | `Ctrl-d` | To delete a resource (TAB and ENTER to confirm) | | -| `Ctrl-k` | To delete a resource (no confirmation dialog) | | +| `Ctrl-k` | To kill a resource (no confirmation dialog!) | | | `:q`, `Ctrl-c` | To bail out of K9s | | --- -## K9s config file ($HOME/.k9s/config.yml) +## K9s Configuration - K9s keeps its configurations in a .k9s directory in your home directory. + K9s keeps its configurations in a .k9s directory in your home directory `$HOME/.k9s/config.yml`. > NOTE: This is still in flux and will change while in pre-release stage! ```yaml # config.yml k9s: - # Indicates api-server poll intervals. + # Represents ui poll intervals. refreshRate: 2 # Indicates whether modification commands like delete/kill/edit are disabled. Default is false readOnly: false @@ -185,9 +191,9 @@ K9s uses aliases to navigate most K8s resources. --- -## Aliases +## Command Aliases -In K9s, you can define your own command aliases (shortnames) to access your resources. In your `$HOME/.k9s` define a file called `alias.yml`. A K9s alias defines pairs of alias:gvr. A gvr represents a fully qualified Kubernetes resource identifier. Here is an example of an alias file: +In K9s, you can define your very own command aliases (shortnames) to access your resources. In your `$HOME/.k9s` define a file called `alias.yml`. A K9s alias defines pairs of alias:gvr. A gvr (Group/Version/Resource) represents a fully qualified Kubernetes resource identifier. Here is an example of an alias file: ```yaml # $HOME/.k9s/alias.yml @@ -200,9 +206,44 @@ Using this alias file, you can now type pp/crb to list pods or clusterrolebindin --- +## HotKey Support + +Entering the command mode and typing a resource name or alias, could be cumbersome for navigating thru often used resources. We're introducing hotkeys that allows a user to define their own hotkeys to activate their favorite resource views. In order to enable hotkeys please follow these steps: + +1. Create a file named `$HOME/.k9s/hotkey.yml` +2. Add the following to your `hotkey.yml`. You can use resource name/short name to specify a command ie same as typing it while in command mode. + + ```yaml + # $HOME/.k9s/hotkey.yml + hotKey: + # Hitting Shift-0 navigates to your pod view + shift-0: + shortCut: Shift-0 + description: Viewing pods + command: pods + # Hitting Shift-1 navigates to your deployments + shift-1: + shortCut: Shift-1 + description: View deployments + command: dp + # Hitting Shift-2 navigates to your xray deployments + shift-2: + shortCut: Shift-2 + description: Xray Deployments + command: xray deploy + ``` + + Not feeling so hot? Your custom hotkeys will be listed in the help view `?`. Also your hotkey file will be automatically reloaded so you can readily use your hotkeys as you define them. + + You can choose any keyboard shotcuts that make sense to you, provided they are not part of the standard K9s shortcuts list. + +> NOTE: This feature/configuration might change in future releases! + +--- + ## Plugins -K9s allows you to define your own cluster commands via plugins. K9s will look at `$HOME/.k9s/plugin.yml` to locate available plugins. A plugin is defined as follows: +K9s allows you to extend your command line and tooling by defining your very own cluster commands via plugins. K9s will look at `$HOME/.k9s/plugin.yml` to locate all available plugins. A plugin is defined as follows: ```yaml # $HOME/.k9s/plugin.yml @@ -213,7 +254,7 @@ plugin: description: Pod logs scopes: - po - command: /usr/local/bin/kubectl + command: kubectl background: false args: - logs @@ -233,6 +274,8 @@ K9s does provide additional environment variables for you to customize your plug * `$NAMESPACE` -- the selected resource namespace * `$NAME` -- the selected resource name +* `$CONTAINER` -- the current container if applicable +* `$FILTER` -- the current filter if any * `$KUBECONFIG` -- the KubeConfig location. * `$CLUSTER` the active cluster name * `$CONTEXT` the active context name @@ -240,13 +283,13 @@ K9s does provide additional environment variables for you to customize your plug * `$GROUPS` the active groups * `$COLX` the column at index X for the viewed resource -NOTE: This is an experimental feature! Options and layout may change in future K9s releases as this feature solidifies. +> NOTE: This is an experimental feature! Options and layout may change in future K9s releases as this feature solidifies. --- -## Benchmarking +## Benchmark Your Applications -K9s integrates [Hey](https://github.com/rakyll/hey) from the brilliant and super talented [Jaana Dogan](https://github.com/rakyll) of Google fame. Hey is a CLI tool to benchmark HTTP endpoints similar to AB bench. This preliminary feature currently supports benchmarking port-forwards and services (Read the paint on this is way fresh!). +K9s integrates [Hey](https://github.com/rakyll/hey) from the brilliant and super talented [Jaana Dogan](https://github.com/rakyll). `Hey` is a CLI tool to benchmark HTTP endpoints similar to AB bench. This preliminary feature currently supports benchmarking port-forwards and services (Read the paint on this is way fresh!). To setup a port-forward, you will need to navigate to the PodView, select a pod and a container that exposes a given port. Using `SHIFT-F` a dialog comes up to allow you to specify a local port to forward. Once acknowledged, you can navigate to the PortForward view (alias `pf`) listing out your active port-forwards. Selecting a port-forward and using `CTRL-B` will run a benchmark on that HTTP endpoint. To view the results of your benchmark runs, go to the Benchmarks view (alias `be`). You should now be able to select a benchmark and view the run stats details by pressing ``. NOTE: Port-forwards only last for the duration of the K9s session and will be terminated upon exit. @@ -268,11 +311,11 @@ benchmarks: defaults: # One concurrent connection concurrency: 1 - # 500 requests will be sent to an endpoint + # Number of requests that will be sent to an endpoint requests: 500 containers: # Containers section allows you to configure your http container's endpoints and benchmarking settings. - # NOTE: the container ID syntax uses namespace/pod_name:container_name + # NOTE: the container ID syntax uses namespace/pod-name:container-name default/nginx:nginx: # Benchmark a container named nginx using POST HTTP verb using http://localhost:port/bozo URL and headers. concurrency: 1 @@ -291,15 +334,15 @@ benchmarks: # Similary you can Benchmark an HTTP service exposed either via nodeport, loadbalancer types. # Service ID is ns/svc-name default/nginx: - # Hit the service with 5 concurrent sessions + # Set the concurrency level concurrency: 5 - # Issues a total of 500 requests + # Number of requests to be sent requests: 500 http: method: GET # This setting will depend on whether service is nodeport or loadbalancer. Nodeport may require vendor port tuneling setting. # Set this to a node if nodeport or LB if applicable. IP or dns name. - host: 10.11.13.14 + host: A.B.C.D path: /bumblebeetuna auth: user: jean-baptiste-emmanuel @@ -308,41 +351,6 @@ benchmarks: --- -## HotKeys - -Entering the command mode and typing a resource name or alias, could be cumbersome for navigating thru often used resources. We're introducing hotkeys that allows a user to define their own hotkeys to activate their favorite resource views. In order to enable hotkeys please follow these steps: - -1. In your .k9s home directory create a file named `hotkey.yml` -2. Add the following to your `hotkey.yml`. You can use short names or resource name to specify a command ie same as typing it in command mode. - - ```yaml - hotKey: - shift-0: - shortCut: Shift-0 - description: View pods - command: pods - shift-1: - shortCut: Shift-1 - description: View deployments - command: dp - shift-2: - shortCut: Shift-2 - description: View services - command: service - shift-3: - shortCut: Shift-3 - description: View statefulsets - command: sts - ``` - - Not feeling so hot? Your custom hotkeys list will be listed in the help view.``. Also your hotkey file will be automatically reloaded so you can readily use your hotkeys as you define them. - - You can choose any keyboard shotcuts that make sense to you, provided they are not part of the standard K9s shortcuts list. - -NOTE: This feature/configuration might change in future releases! - ---- - ## K9s RBAC FU On RBAC enabled clusters, you would need to give your users/groups capabilities so that they can use K9s to explore their Kubernetes cluster. K9s needs minimally read privileges at both the cluster and namespace level to display resources and metrics. @@ -450,18 +458,18 @@ You can style K9s based on your own sense of look and style. Skins are YAML file You can also change K9s skins based on the cluster you are connecting too. In this case, you can specify the skin file name as `$HOME/.k9s/mycluster_skin.yml` Below is a sample skin file, more skins are available in the skins directory in this repo, just simply copy any of these in your user's home dir as `skin.yml`. -Colors can be defined by name or uing an hex representation. +Colors can be defined by name or uing an hex representation. Of recent, we've added a color named `default` to indicate a transparent background color to preserve your terminal background color settings if so desired. > NOTE: This is very much an experimental feature at this time, more will be added/modified if this feature has legs so thread accordingly! ```yaml -# InTheNavy Skin... +# Skin InTheNavy... k9s: # General K9s styles body: fgColor: dodgerblue - bgColor: #ffffff - logoColor: #0000ff + bgColor: '#ffffff' + logoColor: '#0000ff' # ClusterInfoView styles. info: fgColor: lightskyblue @@ -484,7 +492,7 @@ k9s: activeColor: skyblue # Resource status and update styles status: - newColor: #00ff00 + newColor: '#00ff00' modifyColor: powderblue addColor: lightskyblue errorColor: indianred @@ -498,17 +506,17 @@ k9s: highlightColor: skyblue counterColor: slateblue filterColor: slategray - # TableView attributes. - table: - fgColor: blue - bgColor: darkblue - cursorColor: aqua - # Header row styles. - header: - fgColor: white - bgColor: darkblue - sorterColor: orange views: + # TableView attributes. + table: + fgColor: blue + bgColor: darkblue + cursorColor: aqua + # Header row styles. + header: + fgColor: white + bgColor: darkblue + sorterColor: orange # YAML info styles. yaml: keyColor: steelblue @@ -585,8 +593,8 @@ to make this project a reality! ## Meet The Core Team! * [Fernand Galiana](https://github.com/derailed) - * fernand@imhotep.io - * [@kitesurfer](https://twitter.com/kitesurfer?lang=en) + * fernand@imhotep.io + * [@kitesurfer](https://twitter.com/kitesurfer?lang=en) --- diff --git a/assets/k9s_doc.png b/assets/k9s_doc.png new file mode 100644 index 00000000..8627cb21 Binary files /dev/null and b/assets/k9s_doc.png differ diff --git a/assets/k9s_fez.png b/assets/k9s_fez.png new file mode 100644 index 00000000..078a33df Binary files /dev/null and b/assets/k9s_fez.png differ diff --git a/assets/k9s_health.png b/assets/k9s_health.png new file mode 100644 index 00000000..e653dcfd Binary files /dev/null and b/assets/k9s_health.png differ diff --git a/change_logs/release_v0.14.0.md b/change_logs/release_v0.14.0.md new file mode 100644 index 00000000..20112695 --- /dev/null +++ b/change_logs/release_v0.14.0.md @@ -0,0 +1,67 @@ + + +# Release v0.14.0 + +## Notes + +Thank you to all that contributed with flushing out issues and enhancements for 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. Your support, kindness and awesome suggestions to make K9s better is as ever very much noticed and appreciated! + +Also if you dig this tool, please make some noise on social! [@kitesurfer](https://twitter.com/kitesurfer) + +On Slack? Please join us [K9slackers](https://join.slack.com/t/k9sers/shared_invite/enQtOTA5MDEyNzI5MTU0LWQ1ZGI3MzliYzZhZWEyNzYxYzA3NjE0YTk1YmFmNzViZjIyNzhkZGI0MmJjYzhlNjdlMGJhYzE2ZGU1NjkyNTM) + +--- + +## Happy Birthday K9s!! + +🎉🥳🎊 Doh! Almost missed it... 🎉🥳🎊 + + Yes sir, it's been a year (already...) since K9s was first launched 🎉. I can't tell you what a year this has been 🙀. Difficult? sure. However, you guys are making this project a total gas, by your candor, kindness and for giving back via your creative issues, prs, sponsorships, slack channel help to name a few... I do think, you've all been all too quiet tho 🐭... So if K9s helps make your K8s life bett'a on a day to day basis, please reach out for your shoe-phones and dial up [@kitesurfer](https://twitter.com/kitesurfer) or write an article/blog and share it! Lastly I am so humbled by this... but we're closing on 5k stars/136k downloads in this repo, so please invite 28 of your closest friends soon... + +Major Thanks to all of you for you patience and for making this project a reality to all our K8s friends! You're all redefining awesomeness!! + +Also I'd like to take this opportunity to recognize and thank a few folks that have willingly volunteered their own time to track down issues and help improve K9s for all of us!! + +* [Gustavo Silva Paiva](https://github.com/paivagustavo) +* [Joscha Alisch](https://github.com/joscha-alisch) +* [Michael Christina](https://github.com/mcristina422) +* [Bruno Meneguello](https://github.com/bkmeneguello) +* [Tuomo Syvänperä](https://github.com/syvanpera) +* [Oskar F](https://github.com/fridokus) +* [Bruno Ohms](https://github.com/brunohms) +* [IgorRamalho](https://github.com/IgorRamalho) +* [Benjamin](https://github.com/binarycoded) +* [Norbert Csibra](https://github.com/ncsibra) +* [Andrew Roth](https://github.com/RothAndrew) +* [Sgandon](https://github.com/sgandon) +* [Chris Werner Rau](https://github.com/cwrau) +* [Eldad Assis](https://github.com/eldada) +* [Tobias](https://github.com/mycrEEpy) +* [Helge Sychla](https://github.com/hsychla) +* [Markusi75](https://github.com/Makusi75) +* [Swe-Covis](https://github.com/swe-covis) +* [Evgeniy Shubin](https://github.com/com30n) + +## Search Enabled For Describe/YAML views + +In this drop we made the Describe/YAML views searchable. So you no longer need to plow thru your resource configurations and get directly to the just of it by using the search command ie `/elvis` + `enter`. You can use the familiar keys `n` and `N` to nav back and forth to the next occurrence in a circular buffer fashion once you've reached the BOF/EOF. It's the little things in life... + +## And On Another Note... + +More bugz...😿 + +## Resolved Bugs/Features/PRs + +* [Issue #536](https://github.com/derailed/k9s/issues/536) +* [Issue #526](https://github.com/derailed/k9s/issues/526) +* [Issue #464](https://github.com/derailed/k9s/issues/464) + +* [PR #532](https://github.com/derailed/k9s/pull/532) Thank you!! [Joscha Alisch](https://github.com/joscha-alisch) +* [PR #525](https://github.com/derailed/k9s/pull/525) Big Thanks!! [darklore](https://github.com/darklore) +* [PR #524](https://github.com/derailed/k9s/pull/524) Thank you (Again)!! [Joscha Alisch](https://github.com/joscha-alisch) +* [PR #514](https://github.com/derailed/k9s/pull/514) ATTA Boy!! [Alexander F. Rødseth](https://github.com/xyproto) +* [PR #483](https://github.com/derailed/k9s/pull/483) Thank you!! [Paul Varache](https://github.com/paulvarache) + +--- + + © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) diff --git a/change_logs/release_v0.14.1.md b/change_logs/release_v0.14.1.md new file mode 100644 index 00000000..584de21f --- /dev/null +++ b/change_logs/release_v0.14.1.md @@ -0,0 +1,41 @@ + + +# Release v0.14.1 + +## Notes + +Thank you to all that contributed with flushing out issues and enhancements for 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. Your support, kindness and awesome suggestions to make K9s better is as ever very much noticed and appreciated! + +Also if you dig this tool, please make some noise on social! [@kitesurfer](https://twitter.com/kitesurfer) + +On Slack? Please join us [K9slackers](https://join.slack.com/t/k9sers/shared_invite/enQtOTA5MDEyNzI5MTU0LWQ1ZGI3MzliYzZhZWEyNzYxYzA3NjE0YTk1YmFmNzViZjIyNzhkZGI0MmJjYzhlNjdlMGJhYzE2ZGU1NjkyNTM) + +--- + +## Term Color Part Duh! + +Some folks had reported issues with skins and wanting to preserve their terminal background colors while in K9s. In this drop, we're introducing a new skin setting called `default` that should enable the skin to keep the original terminal background color. Here is a sample skin snippet that should achieve just that: + +```yaml +# .k9s/pale_rider.yml + +# Styles... +fg: &fg "#ff00ff" +bg: &bg "default" # default keeps your current terminal window background color. + +# Skin... +k9s: + body: + fgColor: *fg + bgColor: "default" +#... +``` + +## Resolved Bugs/Features/PRs + +* [Issue #539](https://github.com/derailed/k9s/issues/539) +* [Issue #538](https://github.com/derailed/k9s/issues/538) Fingers crossed! + +--- + + © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) diff --git a/change_logs/release_v0.15.0.md b/change_logs/release_v0.15.0.md new file mode 100644 index 00000000..c40fa60b --- /dev/null +++ b/change_logs/release_v0.15.0.md @@ -0,0 +1,58 @@ + + +# Release v0.15.0 + +## Notes + +Thank you to all that contributed with flushing out issues and enhancements for 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. Your support, kindness and awesome suggestions to make K9s better is as ever very much noticed and appreciated! + +Also if you dig this tool, please make some noise on social! [@kitesurfer](https://twitter.com/kitesurfer) + +On Slack? Please join us [K9slackers](https://join.slack.com/t/k9sers/shared_invite/enQtOTA5MDEyNzI5MTU0LWQ1ZGI3MzliYzZhZWEyNzYxYzA3NjE0YTk1YmFmNzViZjIyNzhkZGI0MmJjYzhlNjdlMGJhYzE2ZGU1NjkyNTM) + +--- + + + +## Seen This Fez Before? + +The awesome and ever so smart and creative [Alex Ellis](https://github.com/alexellis) of [OpenFaas Fame](https://www.openfaas.com) fame, had pinged me when I had launched K9s to add support for OpenFaas functions. It's been a long time coming indeed, but we now have a very (VERY!) primitive integration with this very cool framework. + +The current approach is to enable a few environment variables to tell K9s that you have an OpenFaas cluster available namely: + +```shell +OPENFAAS_GATEWAY=http://YOUR_CLUSTER_IP:31112 +OPENFAAS_TLS_INSECURE=false +OPENFAAS_JWT_TOKEN=YOUR_TOKEN +``` + +These will tell K9s that an OpenFaas gateway is available and exposed on a given nodeport. + +Next you can navigate to your OpenFaas function view by entering command mode `:openfaas` or using aliases `:ofaas` or `ofa` + +If functions are present in the given namespace they will be displayed here just like any other K8s resources. + +The following operations are currently supported: + +* Describe and YAML to view function definitions (Note: currently yields same results!) +* Enter to view all pods instances associated with the selected function +* Delete a function +* Editing, shelling, logs, etc... are all supported by navigating to the underlying pods + +Keep in mind, the paint is way fresh here and this feature could be a complete dud, but figure will give it a rinse on this drop and Alex can pipe in and helps us ironing this out. + +> NOTE! It's been a while since I've played with OpenFaas so if some of you are more versed in this space by all means please do land a hand so we can make this feature more awesome! + +## Moving Forward! + +A few folks had mentioned the eagerness to port-forward directly from a pod or a service. Well now you can! Port Forwarding is now available on both the pod view and services view. Note! at the end of the day, you are still port-fowarding to a container! So the port-forward dialog is a bit different for these views as there might be several container ports available now when looking at this from a pod perspective. So the first field in the dialog is a combo-box that allows one to pick their desired ports. The rest of the dialog works the same as the container port-forward dialog. + +## Resolved Bugs/Features/PRs + +* [Issue #546](https://github.com/derailed/k9s/issues/546) BREAKING NEWS! Use `t` vs `ctrl-h` now to toggle the K9s header +* [Issue #541](https://github.com/derailed/k9s/issues/541) +* [Issue #227](https://github.com/derailed/k9s/issues/227) + +--- + + © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) diff --git a/change_logs/release_v0.15.1.md b/change_logs/release_v0.15.1.md new file mode 100644 index 00000000..193d9beb --- /dev/null +++ b/change_logs/release_v0.15.1.md @@ -0,0 +1,56 @@ + + +# Release v0.15.1 + +## Notes + +Thank you to all that contributed with flushing out issues and enhancements for 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. Your support, kindness and awesome suggestions to make K9s better is as ever very much noticed and appreciated! + +Also if you dig this tool, please make some noise on social! [@kitesurfer](https://twitter.com/kitesurfer) + +On Slack? Please join us [K9slackers](https://join.slack.com/t/k9sers/shared_invite/enQtOTA5MDEyNzI5MTU0LWQ1ZGI3MzliYzZhZWEyNzYxYzA3NjE0YTk1YmFmNzViZjIyNzhkZGI0MmJjYzhlNjdlMGJhYzE2ZGU1NjkyNTM) + +--- + + + +## OpenFeZ Reloaded? + +🙀With feelings and one less bugZ! + +The awesome and ever so smart and creative [Alex Ellis](https://github.com/alexellis) of [OpenFaas Fame](https://www.openfaas.com) fame, had pinged me when I had launched K9s to add support for OpenFaas functions. It's been a long time coming indeed, but we now have a very (VERY!) primitive integration with this very cool framework. + +The current approach is to enable a few environment variables to tell K9s that you have an OpenFaas cluster available namely: + +```shell +OPENFAAS_GATEWAY=http://YOUR_CLUSTER_IP:31112 +OPENFAAS_TLS_INSECURE=false +OPENFAAS_JWT_TOKEN=YOUR_TOKEN +``` + +These will tell K9s that an OpenFaas gateway is available and exposed on a given nodeport. + +Next you can navigate to your OpenFaas function view by entering command mode `:openfaas` or using aliases `:ofaas` or `ofa` + +If functions are present in the given namespace they will be displayed here just like any other K8s resources. + +The following operations are currently supported: + +* Describe and YAML to view function definitions (Note: currently yields same results!) +* Enter to view all pods instances associated with the selected function +* Delete a function +* Editing, shelling, logs, etc... are all supported by navigating to the underlying pods + +Keep in mind, the paint is way fresh here and this feature could be a complete dud, but figure will give it a rinse on this drop and Alex can pipe in and helps us ironing this out. + +> NOTE! It's been a while since I've played with OpenFaas so if some of you are more versed in this space by all means please do land a hand so we can make this feature more awesome! + +## Moving Forward! + +A few folks had mentioned the eagerness to port-forward directly from a pod or a service. Well now you can! Port Forwarding is now available on both the pod view and services view. Note! at the end of the day, you are still port-fowarding to a container! So the port-forward dialog is a bit different for these views as there might be several container ports available now when looking at this from a pod perspective. So the first field in the dialog is a combo-box that allows one to pick their desired ports. The rest of the dialog works the same as the container port-forward dialog. + +## Resolved Bugs/Features/PRs + +--- + + © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) diff --git a/change_logs/release_v0.15.2.md b/change_logs/release_v0.15.2.md new file mode 100644 index 00000000..6c29b88b --- /dev/null +++ b/change_logs/release_v0.15.2.md @@ -0,0 +1,23 @@ + + +# Release v0.15.2 + +## Notes + +Thank you to all that contributed with flushing out issues and enhancements for 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. Your support, kindness and awesome suggestions to make K9s better is as ever very much noticed and appreciated! + +Also if you dig this tool, please make some noise on social! [@kitesurfer](https://twitter.com/kitesurfer) + +On Slack? Please join us [K9slackers](https://join.slack.com/t/k9sers/shared_invite/enQtOTA5MDEyNzI5MTU0LWQ1ZGI3MzliYzZhZWEyNzYxYzA3NjE0YTk1YmFmNzViZjIyNzhkZGI0MmJjYzhlNjdlMGJhYzE2ZGU1NjkyNTM) + +--- + +## Mo PortForwards... + +While putting together the [OpenFeeZ video](https://youtu.be/7Fx4XQ2ftpM), I've noticed a few issues with port-forwards and benchmarks. While I was doing surgery on that carp, figured why not go pull a full monty on port-forwards and enable for other controller like resources such as deployments, statefulsets and daemonsets. So now you can set up port-forwards on any of these using `shift-f`. This exhibits the same mechanics as service based port-forwards ie pick a container port from pods matching the controller selector. + +## Resolved Bugs/Features/PRs + +--- + + © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) diff --git a/change_logs/release_v0.16.0.md b/change_logs/release_v0.16.0.md new file mode 100644 index 00000000..53c52754 --- /dev/null +++ b/change_logs/release_v0.16.0.md @@ -0,0 +1,185 @@ + + +# Release v0.16.0 + +## Notes + +Thank you to all that contributed with flushing out issues and enhancements for 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. Your support, kindness and awesome suggestions to make K9s better is as ever very much noticed and appreciated! + +Also if you dig this tool, please make some noise on social! [@kitesurfer](https://twitter.com/kitesurfer) + +On Slack? Please join us [K9slackers](https://join.slack.com/t/k9sers/shared_invite/enQtOTA5MDEyNzI5MTU0LWQ1ZGI3MzliYzZhZWEyNzYxYzA3NjE0YTk1YmFmNzViZjIyNzhkZGI0MmJjYzhlNjdlMGJhYzE2ZGU1NjkyNTM) + +--- + + + +This is one of these drops that may make you wonder if you'll go from zero to hero or likely the reverse?? Will see how this goes... Please proceed with caution on this one as there could very well be much distrubances in the force... + +Lots of code churns so could have totally hose some stuff, but like my GranPappy used to say `can't cook without making a mess!` + +## Going Wide? + +In this drop, we've enabled a new shortcut namely `wide` as `Ctrl-w`. On table views, you will be able to see more information about the resources such as labels or others depending on the viewed resource. This mnemonic works as a toggle so you can `narrow` the view by hitting it again. + +## Zoom, Zoom, Zoom! + +While viewing some resources that may contain errors, sorting on columns may not achieve the results you're seeking ie `show me all resources in an error state`. We've added a new option to achieve just that aka `zoom errors` as `ctrl-z`. This works as a toggle and will unveil resources that are need of some TLC on your part ;) + +## Does Your Cluster Have A Pulse 💓? + +In this drop, we're introducing a brand new view aka `K9s Pulses` 💓. This is a summary view listing the most sailient resources in your clusters and their current states. This view tracks two main metrics ie Ok and Toast on a 5sec beat. This view affords cluster activity and failure rates. BTW this is the zero to hero deal 🙀 Hopefully you'll dig it as this was much work to put together and I personally think it's the `ducks nuts`... If you like, please give me some luving on social or via GH sponsors as batteries are running low... + +To active, enter command mode by typing in `:pulse` aliases are `pu`, `pulses` or `hz` +To navigate thru the various pulses, you can use `tab`/`backtab` or use the menu index (just like namespaces selectors). Once on a pulse view, you can press `enter` to see the associated resource table view. Pressing `esc` will nav you back. + +As I've may have mentioned before, my front-end/UX FU is weak, so I've also added a way for you to skin the charts via skins yaml to your own liking. Please see the skin section below for an example on how to skin the pulses dials. BONUS you should be able to skin K9s live! How cool is that 😻? + +NOTE: Pulses are very much experimental and could totally bomb on your clusters! So please thread carefully and please do report (kindly!) back. + +## BReaking Bad! + +In this drop I've broken a few things (that I know off...), here is the list as I can recall... + +1. Toggle header aka `my red headed step child`. Key moved (again!) now `Ctrl-e` +2. Skin yaml layout CHANGED! Moved table and xray sections under views and added charts section. + +## Skins Updates! + +The skin file format CHANGE! If you are running skins with K9s, please make sure to update your skin file. If not K9s could bomb coming up! + +NOTE: I don't think I'll get around to update all the contributed skins in this repo `skins` dir. If you're looking for a way to help out and are UI inclined, please take a peek and make them cool! + +```yaml +# my_cluster_skin.yml +# Styles... +foreground: &foreground "#f8f8f2" +background: &background "#282a36" +current_line: ¤t_line "#44475a" +selection: &selection "#44475a" +comment: &comment "#6272a4" +cyan: &cyan "#8be9fd" +green: &green "#50fa7b" +orange: &orange "#ffb86c" +pink: &pink "#ff79c6" +purple: &purple "#bd93f9" +red: &red "#ff5555" +yellow: &yellow "#f1fa8c" + +# Skin... +k9s: + # General K9s styles + body: + fgColor: *foreground + bgColor: *background + logoColor: *purple + # ClusterInfoView styles. + info: + fgColor: *pink + sectionColor: *foreground + frame: + # Borders styles. + border: + fgColor: *selection + focusColor: *current_line + menu: + fgColor: *foreground + keyColor: *pink + # Used for favorite namespaces + numKeyColor: *purple + # CrumbView attributes for history navigation. + crumbs: + fgColor: *foreground + bgColor: *current_line + activeColor: *current_line + # Resource status and update styles + status: + newColor: *cyan + modifyColor: *purple + addColor: *green + errorColor: *red + highlightcolor: *orange + killColor: *comment + completedColor: *comment + # Border title styles. + title: + fgColor: *foreground + bgColor: *current_line + highlightColor: *orange + counterColor: *purple + filterColor: *pink + views: + charts: + bgColor: *background + dialBgColor: "#0A2239" + chartBgColor: "#0A2239" + defaultDialColors: + - "#1E3888" + - "#820101" + defaultChartColors: + - "#1E3888" + - "#820101" + resourceColors: + batch/v1/jobs: + - "#5D737E" + - "#820101" + v1/persistentvolumes: + - "#3E554A" + - "#820101" + cpu: + - "#6EA4BF" + - "#820101" + mem: + - "#17505B" + - "#820101" + v1/events: + - "#073B3A" + - "#820101" + v1/pods: + - "#487FFF" + - "#820101" + # TableView attributes. + table: + fgColor: *foreground + bgColor: *background + cursorColor: *current_line + # Header row styles. + header: + fgColor: *foreground + bgColor: *background + sorterColor: *cyan + # Xray view attributes. + xray: + fgColor: *foreground + bgColor: *background + cursorColor: *current_line + graphicColor: *purple + showIcons: true + # YAML info styles. + yaml: + keyColor: *pink + colonColor: *purple + valueColor: *foreground + # Logs styles. + logs: + fgColor: *foreground + bgColor: *background +``` + +## Resolved Bugs/Features/PRs + +- [Issue #557](https://github.com/derailed/k9s/issues/557) +- [Issue #555](https://github.com/derailed/k9s/issues/555) +- [Issue #554](https://github.com/derailed/k9s/issues/554) +- [Issue #553](https://github.com/derailed/k9s/issues/553) +- [Issue #552](https://github.com/derailed/k9s/issues/552) +- [Issue #551](https://github.com/derailed/k9s/issues/551) +- [Issue #549](https://github.com/derailed/k9s/issues/549) A start with pulses... +- [Issue #540](https://github.com/derailed/k9s/issues/540) +- [Issue #421](https://github.com/derailed/k9s/issues/421) +- [Issue #351](https://github.com/derailed/k9s/issues/351) Solved by Pulses? +- [Issue #25](https://github.com/derailed/k9s/issues/25) Pulses? Oldie but goodie! + +--- + + © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) diff --git a/change_logs/release_v0.16.1.md b/change_logs/release_v0.16.1.md new file mode 100644 index 00000000..29cc927f --- /dev/null +++ b/change_logs/release_v0.16.1.md @@ -0,0 +1,23 @@ + + +# Release v0.16.1 + +## Notes + +Thank you to all that contributed with flushing out issues and enhancements for 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. Your support, kindness and awesome suggestions to make K9s better is as ever very much noticed and appreciated! + +Also if you dig this tool, please make some noise on social! [@kitesurfer](https://twitter.com/kitesurfer) + +On Slack? Please join us [K9slackers](https://join.slack.com/t/k9sers/shared_invite/enQtOTA5MDEyNzI5MTU0LWQ1ZGI3MzliYzZhZWEyNzYxYzA3NjE0YTk1YmFmNzViZjIyNzhkZGI0MmJjYzhlNjdlMGJhYzE2ZGU1NjkyNTM) + +--- + +Maintenance Release! + +## Resolved Bugs/Features/PRs + +- [Issue #561](https://github.com/derailed/k9s/issues/561) + +--- + + © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) diff --git a/go.mod b/go.mod index 898850ec..4daf737e 100644 --- a/go.mod +++ b/go.mod @@ -28,20 +28,30 @@ replace ( ) require ( + fyne.io/fyne v1.2.2 // indirect + github.com/GeertJohan/gomatrix v0.0.0-20190924221747-74328b69a02f // indirect github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 // indirect + github.com/alexellis/go-execute v0.0.0-20200124154445-8697e4e28c5e // indirect + github.com/alexellis/hmac v0.0.0-20180624211220-5c52ab81c0de // indirect github.com/atotto/clipboard v0.1.2 - github.com/derailed/tview v0.3.3 + github.com/derailed/tview v0.3.6 + github.com/drone/envsubst v1.0.2 // indirect github.com/elazarl/goproxy v0.0.0-20190421051319-9d40249d3c2f // indirect github.com/elazarl/goproxy/ext v0.0.0-20190421051319-9d40249d3c2f // indirect + github.com/fatih/color v1.6.0 github.com/fsnotify/fsnotify v1.4.7 github.com/gdamore/tcell v1.3.0 github.com/ghodss/yaml v1.0.0 github.com/gregjones/httpcache v0.0.0-20190212212710-3befbb6ad0cc // indirect - github.com/kylelemons/godebug v1.1.0 // indirect github.com/mattn/go-runewidth v0.0.5 + github.com/openfaas/faas v0.0.0-20200207215241-6afae214e3ec + github.com/openfaas/faas-cli v0.0.0-20200124160744-30b7cec9634c + github.com/openfaas/faas-provider v0.15.0 github.com/petergtz/pegomock v2.6.0+incompatible github.com/rakyll/hey v0.1.2 - github.com/rs/zerolog v1.17.2 + github.com/rivo/tview v0.0.0-20191018115645-bacbf5155bc1 + github.com/rs/zerolog v1.18.0 + github.com/ryanuber/go-glob v1.0.0 // indirect github.com/sahilm/fuzzy v0.1.0 github.com/spf13/cobra v0.0.5 github.com/stretchr/testify v1.4.0 diff --git a/go.sum b/go.sum index b37542d1..ad8eaeee 100644 --- a/go.sum +++ b/go.sum @@ -3,6 +3,8 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMT cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.38.0 h1:ROfEUZz+Gh5pa62DJWXSaonyu3StP6EA6lPEXPI6mCo= cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= +fyne.io/fyne v1.2.2 h1:mf7EseASp3CAC5vLWVPLnsoKxvp/ARdu3Seh0HvAQak= +fyne.io/fyne v1.2.2/go.mod h1:Ab+3DIB/FVteW0y4DXfmZv4N3JdnCBh2lHkINI02BOU= github.com/Azure/azure-sdk-for-go v32.5.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78 h1:w+iIsaOQNcT7OZ575w+acHgRric5iCyQh+xv+KJ4HB8= github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78/go.mod h1:LmzpDX56iTiv29bbRTIsUNlaFfuhWRQBWjQdVyAevI8= @@ -27,8 +29,11 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03 github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/DATA-DOG/go-sqlmock v1.3.3 h1:CWUqKXe0s8A2z6qCgkP4Kru7wC11YoAnoupUKFDnH08= github.com/DATA-DOG/go-sqlmock v1.3.3/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM= +github.com/GeertJohan/gomatrix v0.0.0-20190924221747-74328b69a02f h1:O4XncXE6+qNjZIvermf2/Z4esEl8K1zFVPbl3l14mjM= +github.com/GeertJohan/gomatrix v0.0.0-20190924221747-74328b69a02f/go.mod h1:HqtsgfzGADJzbZ+MbYAJ+PJnxIxBwBvYjyqd2wWw0j0= github.com/GoogleCloudPlatform/k8s-cloud-provider v0.0.0-20190822182118-27a4ced34534/go.mod h1:iroGtC8B3tQiqtds1l+mgk/BBOrxbqjH+eUfFQYRc14= github.com/JeffAshton/win_pdh v0.0.0-20161109143554-76bb4ee9f0ab/go.mod h1:3VYc5hodBMJ5+l/7J4xAyMeuM2PNuepvHlGs8yilUCA= +github.com/Kodeworks/golang-image-ico v0.0.0-20141118225523-73f0f4cfade9/go.mod h1:7uhhqiBaR4CpN0k9rMjOtjpcfGd6DG2m04zQxKnWQ0I= github.com/MakeNowJust/heredoc v0.0.0-20170808103936-bb23615498cd/go.mod h1:64YHyfSL2R96J44Nlwm39UHepQbyR5q10x7iYa1ks2E= github.com/MakeNowJust/heredoc v0.0.0-20171113091838-e9091a26100e h1:eb0Pzkt15Bm7f2FFYv7sjY7NPFi3cPkS3tv1CcrFBWA= github.com/MakeNowJust/heredoc v0.0.0-20171113091838-e9091a26100e/go.mod h1:64YHyfSL2R96J44Nlwm39UHepQbyR5q10x7iYa1ks2E= @@ -43,6 +48,7 @@ github.com/Microsoft/go-winio v0.4.11/go.mod h1:VhR8bwka0BXejwEJY73c50VrPtXAaKcy github.com/Microsoft/go-winio v0.4.12 h1:xAfWHN1IrQ0NJ9TBC0KBZoqLjzDTr1ML+4MywiUOryc= github.com/Microsoft/go-winio v0.4.12/go.mod h1:VhR8bwka0BXejwEJY73c50VrPtXAaKcyvVC4A4RozmA= github.com/Microsoft/hcsshim v0.0.0-20190417211021-672e52e9209d/go.mod h1:Op3hHsoHPAvb6lceZHDtd9OkTew38wNoXnJs8iY7rUg= +github.com/Microsoft/hcsshim v0.8.6 h1:ZfF0+zZeYdzMIVMZHKtDKJvLHj76XCuVae/jNkjj0IA= github.com/Microsoft/hcsshim v0.8.6/go.mod h1:Op3hHsoHPAvb6lceZHDtd9OkTew38wNoXnJs8iY7rUg= github.com/NYTimes/gziphandler v0.0.0-20170623195520-56545f4a5d46 h1:lsxEuwrXEAokXB9qhlbKWPpo3KMLZQ5WB5WLQRW1uq0= github.com/NYTimes/gziphandler v0.0.0-20170623195520-56545f4a5d46/go.mod h1:3wb06e3pkSAbeQ52E9H9iFoQsEEwGN64994WTCIhntQ= @@ -56,11 +62,17 @@ github.com/PuerkitoBio/urlesc v0.0.0-20160726150825-5bd2802263f2/go.mod h1:uGdko github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M= github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= github.com/Rican7/retry v0.1.0/go.mod h1:FgOROf8P5bebcC1DS0PdOQiqGUridaZvikzUmkFW6gg= +github.com/Shopify/logrus-bugsnag v0.0.0-20171204204709-577dee27f20d h1:UrqY+r/OJnIp5u0s1SbQ8dVfLCZJsnvazdBP5hS4iRs= github.com/Shopify/logrus-bugsnag v0.0.0-20171204204709-577dee27f20d/go.mod h1:HI8ITrYtUY+O+ZhtlqUnD8+KwNPOyugEhfP9fdUIaEQ= +github.com/akavel/rsrc v0.8.0/go.mod h1:uLoCtb9J+EyAqh+26kdrTgmzRBFPGOolLWKpdxkKq+c= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/alexellis/go-execute v0.0.0-20200124154445-8697e4e28c5e h1:0cv4CUENL7e67/ZlNrvExWqa6oKH/9iv0KQn0/+hYaY= +github.com/alexellis/go-execute v0.0.0-20200124154445-8697e4e28c5e/go.mod h1:zfRbgnPVxXCSpiKrg1CE72hNUWInqxExiaz2D9ppTts= +github.com/alexellis/hmac v0.0.0-20180624211220-5c52ab81c0de h1:jiPEvtW8VT0KwJxRyjW2VAAvlssjj9SfecsQ3Vgv5tk= +github.com/alexellis/hmac v0.0.0-20180624211220-5c52ab81c0de/go.mod h1:uAbpy8G7sjNB4qYdY6ymf5OIQ+TLDPApBYiR0Vc3lhk= github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= github.com/asaskevich/govalidator v0.0.0-20180720115003-f9ffefc3facf/go.mod h1:lB+ZfQJz7igIIfQNfa7Ml4HSf2uFQQRzpGGRXenZAgY= @@ -78,13 +90,18 @@ github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+Ce github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bifurcation/mint v0.0.0-20180715133206-93c51c6ce115/go.mod h1:zVt7zX3K/aDCk9Tj+VM7YymsX66ERvzCJzw8rFCX2JU= +github.com/bitly/go-simplejson v0.5.0 h1:6IH+V8/tVMab511d5bn4M7EwGXZf9Hj6i2xSwkNEM+Y= github.com/bitly/go-simplejson v0.5.0/go.mod h1:cXHtHw4XUPsvGaxgjIAn8PhEWG9NfngEKAMDJEczWVA= github.com/blang/semver v3.5.0+incompatible h1:CGxCgetQ64DKk7rdZ++Vfnb1+ogGNnB17OJKJXD2Cfs= github.com/blang/semver v3.5.0+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= +github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 h1:DDGfHa7BWjL4YnC6+E63dPcxHo2sUxDIu8g3QgEJdRY= github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4= github.com/boltdb/bolt v1.3.1/go.mod h1:clJnj/oiGkjum5o1McbSZDSLxVThjynRyGBgiAx27Ps= +github.com/bshuster-repo/logrus-logstash-hook v0.4.1 h1:pgAtgj+A31JBVtEHu2uHuEx0n+2ukqUJnS2vVe5pQNA= github.com/bshuster-repo/logrus-logstash-hook v0.4.1/go.mod h1:zsTqEiSzDgAa/8GZR7E1qaXrhYNDKBYy5/dWPTIflbk= +github.com/bugsnag/bugsnag-go v1.5.0 h1:tP8hiPv1pGGW3LA6LKy5lW6WG+y9J2xWUdPd3WC452k= github.com/bugsnag/bugsnag-go v1.5.0/go.mod h1:2oa8nejYd4cQ/b0hMIopN0lCRxU0bueqREvZLWFrtK8= +github.com/bugsnag/panicwrap v1.2.0 h1:OzrKrRvXis8qEvOkfcxNcYbOd2O7xXS2nnKMEMABFQA= github.com/bugsnag/panicwrap v1.2.0/go.mod h1:D/8v3kj0zr8ZAKg1AQ6crr+5VwKN5eIywRkfhyM/+dE= github.com/caddyserver/caddy v1.0.3/go.mod h1:G+ouvOY32gENkJC+jhgl62TyhvqEsFaDiZ4uw0RzP1E= github.com/cenkalti/backoff v2.1.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= @@ -134,9 +151,14 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/daviddengcn/go-colortext v0.0.0-20160507010035-511bcaf42ccd/go.mod h1:dv4zxwHi5C/8AeI+4gX4dCWOIvNi7I6JCSX0HvlKPgE= github.com/deislabs/oras v0.7.0 h1:RnDoFd3tQYODMiUqxgQ8JxlrlWL0/VMKIKRD01MmNYk= github.com/deislabs/oras v0.7.0/go.mod h1:sqMKPG3tMyIX9xwXUBRLhZ24o+uT4y6jgBD2RzUTKDM= -github.com/deislabs/oras v0.8.0 h1:WZqPI25DlEmth2VE/pIcnEh6msL2yHrzS5lV5gwaCsQ= github.com/derailed/tview v0.3.3 h1:tipPwxcDhx0zRBZuc8VKIrNgWL40FL5JeF/30XVieUE= github.com/derailed/tview v0.3.3/go.mod h1:yApPszFU62FoaGkf7swy2nIdV/h7Nid3dhMSVy6+OFI= +github.com/derailed/tview v0.3.4 h1:PnF64fLqm48LEjC/XwOS7JufDgFuuPYx85YVt5t3rwE= +github.com/derailed/tview v0.3.4/go.mod h1:yApPszFU62FoaGkf7swy2nIdV/h7Nid3dhMSVy6+OFI= +github.com/derailed/tview v0.3.5 h1:1vKqcJIiZtLAs5moX9c38+BbBSYhPgFq0ZndnVNVNFc= +github.com/derailed/tview v0.3.5/go.mod h1:yApPszFU62FoaGkf7swy2nIdV/h7Nid3dhMSVy6+OFI= +github.com/derailed/tview v0.3.6 h1:9PyX6Nu1vs9mCVfvV2q2fwT/dZta0dBGr4ZPjCF1KnU= +github.com/derailed/tview v0.3.6/go.mod h1:GJ3k/TIzEE+sj1L09/usk6HrkjsdadSsb03eHgPbcII= 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/dnaeon/go-vcr v1.0.1/go.mod h1:aBB1+wY4s93YsC3HHjMBMrwTj2R9FHDzUr9KyGc8n1E= @@ -145,7 +167,6 @@ github.com/docker/cli v0.0.0-20190506213505-d88565df0c2d/go.mod h1:JLrzqnKDaYBop github.com/docker/distribution v2.7.1-0.20190205005809-0d3efadf0154+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= 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/docker/docker v0.7.3-0.20190327010347-be7ac8be2ae0/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/docker v1.4.2-0.20181221150755-2cb26cfe9cbf h1:+Hdbkr8QbGSQ4dY50mmgZEGtzjhv0we2Ws2XCz3c0Q8= github.com/docker/docker v1.4.2-0.20181221150755-2cb26cfe9cbf/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/docker-credential-helpers v0.6.1 h1:Dq4iIfcM7cNtddhLVWe9h4QDjsi4OER3Z8voPu/I52g= @@ -159,12 +180,15 @@ github.com/docker/go-units v0.3.3/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDD github.com/docker/go-units v0.4.0 h1:3uh0PgVws3nIA0Q+MwDC8yjEPf9zjRfZZWXZYDct3Tw= github.com/docker/go-units v0.4.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/docker/libnetwork v0.0.0-20180830151422-a9cd636e3789/go.mod h1:93m0aTqz6z+g32wla4l4WxTrdtvBRmVzYRkYvasA5Z8= +github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7 h1:UhxFibDNY/bfvqU5CAUmr9zpesgbU6SWc8/B4mflAE4= github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7/go.mod h1:cyGadeNEkKy96OOhEzfZl+yxihPEzKnqJwvfuSUqbZE= github.com/docker/spdystream v0.0.0-20160310174837-449fdfce4d96 h1:cenwrSVm+Z7QLSV/BsnenAOcDXdX4cMv4wP0B/5QbPg= github.com/docker/spdystream v0.0.0-20160310174837-449fdfce4d96/go.mod h1:Qh8CwZgvJUkLughtfhJv5dyTYa91l1fOUCrgjqmcifM= github.com/docker/spdystream v0.0.0-20181023171402-6480d4af844c h1:ZfSZ3P3BedhKGUhzj7BQlPSU4OvT6tfOKe3DVHzOA7s= github.com/docker/spdystream v0.0.0-20181023171402-6480d4af844c/go.mod h1:Qh8CwZgvJUkLughtfhJv5dyTYa91l1fOUCrgjqmcifM= github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE= +github.com/drone/envsubst v1.0.2 h1:dpYLMAspQHW0a8dZpLRKe9jCNvIGZPhCPrycZzIHdqo= +github.com/drone/envsubst v1.0.2/go.mod h1:bkZbnc/2vh1M12Ecn7EYScpI4YGYU0etwLJICOWi8Z0= github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/elazarl/goproxy v0.0.0-20170405201442-c4fc26588b6e/go.mod h1:/Zj4wYkgs4iZTTu3o/KG3Itv/qCCa8VVMlb3i9OVuzc= github.com/elazarl/goproxy v0.0.0-20190421051319-9d40249d3c2f h1:8GDPb0tCY8LQ+OJ3dbHb5sA6YZWXFORQYZx5sdsTlMs= @@ -185,10 +209,12 @@ github.com/exponent-io/jsonpath v0.0.0-20151013193312-d6023ce2651d h1:105gxyaGwC github.com/exponent-io/jsonpath v0.0.0-20151013193312-d6023ce2651d/go.mod h1:ZZMPRZwes7CROmyNKgQzC3XPs6L/G2EJLHddWejkmf4= github.com/fatih/camelcase v1.0.0 h1:hxNvNX/xYBp0ovncs8WyWZrOrpBNub/JfaMvbURyft8= github.com/fatih/camelcase v1.0.0/go.mod h1:yN2Sb0lFhZJUdVvtELVWefmrXpuZESvPmqwoZc+/fpc= +github.com/fatih/color v1.6.0 h1:66qjqZk8kalYAvDRtM1AdAJQI0tj4Wrue3Eq3B3pmFU= github.com/fatih/color v1.6.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc= github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/garyburd/redigo v1.6.0 h1:0VruCpn7yAIIu7pWVClQC8wxCJEcG3nyzpMSHKi1PQc= github.com/garyburd/redigo v1.6.0/go.mod h1:NR3MbYisc3/PwhQ00EMzDiPmrwpPxAn5GI05/YaO1SY= github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko= github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg= @@ -204,6 +230,10 @@ github.com/globalsign/mgo v0.0.0-20181015135952-eeefdecb41b8 h1:DujepqpGd1hyOd7a github.com/globalsign/mgo v0.0.0-20181015135952-eeefdecb41b8/go.mod h1:xkRDCp4j0OGD1HRkm4kmhM+pmpv3AKq5SU7GMg4oO/Q= github.com/go-acme/lego v2.5.0+incompatible/go.mod h1:yzMNe9CasVUhkquNvti5nAtPmG94USbYxYrZfTkIn0M= github.com/go-bindata/go-bindata v3.1.1+incompatible/go.mod h1:xK8Dsgwmeed+BBsSy2XTopBn/8uK2HWuGSnA11C3Joo= +github.com/go-gl/gl v0.0.0-20190320180904-bf2b1f2f34d7 h1:SCYMcCJ89LjRGwEa0tRluNRiMjZHalQZrVrvTbPh+qw= +github.com/go-gl/gl v0.0.0-20190320180904-bf2b1f2f34d7/go.mod h1:482civXOzJJCPzJ4ZOX/pwvXBWSnzD4OKMdH4ClKGbk= +github.com/go-gl/glfw v0.0.0-20181213070059-819e8ce5125f h1:7MsFMbSn8Lcw0blK4+NEOf8DuHoOBDhJsHz04yh13pM= +github.com/go-gl/glfw v0.0.0-20181213070059-819e8ce5125f/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= @@ -267,6 +297,7 @@ github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= github.com/godbus/dbus v4.1.0+incompatible/go.mod h1:/YcGZj5zSblfDWMMoOzV4fas9FZnQYTkDnsGvmh2Grw= github.com/gofrs/flock v0.7.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU= +github.com/gofrs/uuid v3.2.0+incompatible h1:y12jRkkFxsd7GpqdSZ+/KCs/fJbqpEXSGd4+jfEaewE= github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= @@ -274,10 +305,12 @@ github.com/gogo/protobuf v1.2.2-0.20190723190241-65acae22fc9d h1:3PaI8p3seN09Vjb github.com/gogo/protobuf v1.2.2-0.20190723190241-65acae22fc9d/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o= github.com/gogo/protobuf v1.3.1 h1:DqDEcV5aeaTmdFBePNpYsp3FlcVH/2ISVVM9Qf8PSls= github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o= +github.com/goki/freetype v0.0.0-20181231101311-fa8a33aabaff h1:W71vTCKoxtdXgnm1ECDFkfQnpdqAO00zzGXLA5yaEX8= +github.com/goki/freetype v0.0.0-20181231101311-fa8a33aabaff/go.mod h1:wfqRWLHRBsRgkp5dmbG56SA0DmVtwrF5N3oPdI8t+Aw= +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-20160516000752-02826c3e7903/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -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/groupcache v0.0.0-20191027212112-611e8accdfc9 h1:uHTyIjqVhYRhLbJ8nIiOJHkEZZ+5YoOsAbD3sk82NiE= github.com/golang/groupcache v0.0.0-20191027212112-611e8accdfc9/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= @@ -313,15 +346,16 @@ github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY= github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gnostic v0.0.0-20170729233727-0c5108395e2d/go.mod h1:sJBsCZ4ayReDTBIg8b9dl28c5xFWyhBTVRp3pOg5EKY= -github.com/googleapis/gnostic v0.2.0 h1:l6N3VoaVzTncYYW+9yOz2LJJammFZGBO13sqgEhpy9g= -github.com/googleapis/gnostic v0.2.0/go.mod h1:sJBsCZ4ayReDTBIg8b9dl28c5xFWyhBTVRp3pOg5EKY= github.com/googleapis/gnostic v0.3.1 h1:WeAefnSUHlBb0iJKwxFDZdbfGwkd7xRNuV+IpXMJhYk= github.com/googleapis/gnostic v0.3.1/go.mod h1:on+2t9HRStVgn95RSsFWFz+6Q0Snyqv1awfrALZdbtU= github.com/gophercloud/gophercloud v0.1.0 h1:P/nh25+rzXouhytV2pUHBb65fnds26Ghl8/391+sT5o= github.com/gophercloud/gophercloud v0.1.0/go.mod h1:vxM41WHh5uqHVBMZHzuwNOHh8XEoIEcSTewFxm1c5g8= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gorilla/context v0.0.0-20160226214623-1ea25387ff6f/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= +github.com/gorilla/handlers v1.4.0 h1:XulKRWSQK5uChr4pEgSE4Tc/OcmnU9GJuSwdog/tZsA= github.com/gorilla/handlers v1.4.0/go.mod h1:Qkdc/uu4tH4g6mTK6auzZ766c4CA0Ng8+o/OAirnOIQ= +github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= github.com/gorilla/mux v1.7.0 h1:tOSd0UKHQd6urX6ApfOn4XdBMY6Sh1MfxV3kmaazO+U= github.com/gorilla/mux v1.7.0/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= @@ -358,9 +392,13 @@ github.com/imdario/mergo v0.3.8 h1:CGgOkSJeqMRmt0D9XLWExdT4m4F1vd3FV3VPt+0VxkQ= github.com/imdario/mergo v0.3.8/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= +github.com/jackmordaunt/icns v0.0.0-20181231085925-4f16af745526/go.mod h1:UQkeMHVoNcyXYq9otUupF7/h/2tmHlhrS2zw7ZVvUqc= +github.com/jessevdk/go-flags v1.4.0 h1:4IU2WS7AumrZ/40jfhf4QVDMsQwqA7VEHozFRrGARJA= +github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/jimstudt/http-authentication v0.0.0-20140401203705-3eca13d6893a/go.mod h1:wK6yTYYcgjHE1Z1QtXACPDjcFJyBskHEdagmnq3vsP8= github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= +github.com/josephspurrier/goversioninfo v0.0.0-20190124120936-8611f5a5ff3f/go.mod h1:eJTEwMjXb7kZ633hO3Ln9mBUCOjX2+FlTljvpl9SYdE= github.com/json-iterator/go v0.0.0-20180612202835-f2b4162afba3/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/json-iterator/go v1.1.7 h1:KfgG9LzI+pYjr4xvmz/5H4FXjokeP+rlHLhv3iH62Fo= @@ -368,6 +406,7 @@ github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/u github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= +github.com/kardianos/osext v0.0.0-20170510131534-ae77be60afb1 h1:PJPDf8OUfOK1bb/NeTKd4f1QXZItOX389VN3B6qC8ro= github.com/kardianos/osext v0.0.0-20170510131534-ae77be60afb1/go.mod h1:1NbS8ALrpOvjt0rHPNLyCIeMtbizbir8U//inJ+zuB8= github.com/karrick/godirwalk v1.7.5/go.mod h1:2c9FRhkDxdIbgkOnCEvnSWs71Bhugbl46shStcFDJ34= github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= @@ -407,7 +446,9 @@ github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN github.com/mailru/easyjson v0.7.0 h1:aizVhC/NAAcKWb+5QsU1iNOZb4Yws5UO2I+aIprQITM= github.com/mailru/easyjson v0.7.0/go.mod h1:KAzv3t3aY1NaHWoQz1+4F1ccyAH66Jk7yos7ldAVICs= github.com/marten-seemann/qtls v0.2.3/go.mod h1:xzjG7avBwGGbdZ8dTGxlBnLArsVKLvwmjgmPuiQEcYk= +github.com/mattn/go-colorable v0.0.9 h1:UVL0vNpWh04HeJXV0KLcaT7r06gOH2l4OW6ddYRUIY4= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= +github.com/mattn/go-isatty v0.0.3 h1:ns/ykhmWi7G9O+8a448SecJU3nSMBXJfqQkl0upE1jI= github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= github.com/mattn/go-runewidth v0.0.5 h1:jrGtp51JOKTWgvLFzfG6OtZOJcK2sEnzc/U+zw7TtbA= @@ -419,11 +460,13 @@ github.com/mesos/mesos-go v0.0.9/go.mod h1:kPYCMQ9gsOXVAle1OsoY4I1+9kPu8GHkf88aV github.com/mholt/certmagic v0.6.2-0.20190624175158-6a42ef9fe8c2/go.mod h1:g4cOPxcjV0oFq3qwpjSA30LReKD8AoIfwAY9VvG35NY= github.com/miekg/dns v0.0.0-20181005163659-0d29b283ac0f/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= github.com/miekg/dns v1.1.3/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= +github.com/miekg/dns v1.1.4 h1:rCMZsU2ScVSYcAsOXgmC6+AKOK+6pmQTOcw03nfwYV0= github.com/miekg/dns v1.1.4/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= github.com/mindprince/gonvml v0.0.0-20171110221305-fee913ce8fb2/go.mod h1:2eu9pRWp8mo84xCg6KswZ+USQHjwgRhNp06sozOdsTY= github.com/mistifyio/go-zfs v2.1.1+incompatible/go.mod h1:8AuVvqP/mXw1px98n46wfvcGfQ4ci2FwoAjKYxuo3Z4= github.com/mitchellh/copystructure v1.0.0 h1:Laisrj+bAB6b/yJwB5Bt3ITZhGJdqmxquMKeZ+mmkFQ= github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/go-wordwrap v1.0.0 h1:6GlHJ/LTGMrIJbwgdqdl2eEH8o+Exx/0m8ir9Gns0u4= github.com/mitchellh/go-wordwrap v1.0.0/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo= @@ -439,8 +482,10 @@ github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lN github.com/modern-go/reflect2 v1.0.1 h1:9f412s+6RmYXLWZSEzVVgPGK7C2PphHj5RJrvfx9AWI= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/mohae/deepcopy v0.0.0-20170603005431-491d3605edfb/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= +github.com/morikuni/aec v0.0.0-20170113033406-39771216ff4c h1:nXxl5PrvVm2L/wCy8dQu6DMTwH4oIuGN8GJDAlqDdVE= github.com/morikuni/aec v0.0.0-20170113033406-39771216ff4c/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/mrunalp/fileutils v0.0.0-20160930181131-4ee1cc9a8058/go.mod h1:x8F1gnqOkIEiO4rqoeEEEqQbo7HjGMTvyoq3gej4iT0= +github.com/mum4k/termdash v0.10.0/go.mod h1:l3tO+lJi9LZqXRq7cu7h5/8rDIK3AzelSuq2v/KncxI= github.com/munnerz/goautoneg v0.0.0-20120707110453-a547fc61f48d h1:7PxY7LVfSZm7PEeBTyK1rj1gABdCO2mbri6GKO1cMDs= github.com/munnerz/goautoneg v0.0.0-20120707110453-a547fc61f48d/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/mvdan/xurls v1.1.0/go.mod h1:tQlNn3BED8bE/15hnSL2HLkDeLWpNPAwtw7wkEq44oU= @@ -448,10 +493,13 @@ github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRW github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= github.com/naoina/go-stringutil v0.1.0/go.mod h1:XJ2SJL9jCtBh+P9q5btrd/Ylo8XwT/h1USek5+NqSA0= github.com/naoina/toml v0.1.1/go.mod h1:NBIhNtsFMo3G2szEBne+bO4gS192HuIYRqfvOWb4i1E= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= github.com/onsi/ginkgo v0.0.0-20170829012221-11459a886d9c/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.8.0 h1:VkHVNpR4iVnU8XQR6DBm8BqYjN7CRzw+xKUbVVbbW9w= github.com/onsi/ginkgo v1.8.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.10.1 h1:q/mM8GF/n0shIN8SaAZ0V+jnLPzen6WIVZdiwrRlMlo= github.com/onsi/ginkgo v1.10.1/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/gomega v0.0.0-20170829124025-dcabb60a477c/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA= github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= @@ -468,6 +516,12 @@ github.com/opencontainers/runc v1.0.0-rc2.0.20190611121236-6cc515888830 h1:yvQ/2 github.com/opencontainers/runc v1.0.0-rc2.0.20190611121236-6cc515888830/go.mod h1:qT5XzbpPznkRYVz/mWwUaVBUv2rmF59PVA73FjuZG0U= github.com/opencontainers/runtime-spec v1.0.0/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0= github.com/opencontainers/selinux v1.2.2/go.mod h1:+BLncwf63G4dgOzykXAxcmnFlUaOlkDdmw/CqsW6pjs= +github.com/openfaas/faas v0.0.0-20200207215241-6afae214e3ec h1:S6wtb5ie7KeMcuEaESj0RoSmpyGfvOSuunmKEdX7wg8= +github.com/openfaas/faas v0.0.0-20200207215241-6afae214e3ec/go.mod h1:E0m2rLup0Vvxg53BKxGgaYAGcZa3Xl+vvL7vSi5yQ14= +github.com/openfaas/faas-cli v0.0.0-20200124160744-30b7cec9634c h1:9RGaDpUySgRscx5oiagwUtm9vBZti/4+QYq2GM4FegE= +github.com/openfaas/faas-cli v0.0.0-20200124160744-30b7cec9634c/go.mod h1:u/KO+e43wkagC0lqM1eaqNEWEBdg08Q1ugP/idj39MM= +github.com/openfaas/faas-provider v0.15.0 h1:3x5ma90FL7AqP4NOD6f03AY24y3xBeVF6xGLUx6Xrlc= +github.com/openfaas/faas-provider v0.15.0/go.mod h1:8Fagi2UeMfL+gZAqZWSMQg86i+w1+hBOKtwKRP5sLFI= 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/pelletier/go-toml v1.0.1/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= @@ -476,6 +530,7 @@ github.com/peterbourgon/diskv v2.0.1+incompatible h1:UBdAOUP5p4RWqPBg048CAvpKN+v github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU= github.com/petergtz/pegomock v2.6.0+incompatible h1:gD9YvI42LylIA/il2Cy8lMfg+CncNFMqexYepyEWGaQ= github.com/petergtz/pegomock v2.6.0+incompatible/go.mod h1:nuBLWZpVyv/fLo56qTwt/AUau7jgouO1h7bEvZCq82o= +github.com/phayes/freeport v0.0.0-20171002181615-b8543db493a5 h1:rZQtoozkfsiNs36c7Tdv/gyGNzD1X1XWKO8rptVNZuM= github.com/phayes/freeport v0.0.0-20171002181615-b8543db493a5/go.mod h1:iIss55rKnNBTvrwdmkUpLnDpZoAHvWaiq5+iMmen4AE= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= @@ -513,6 +568,7 @@ github.com/quobyte/api v0.1.2/go.mod h1:jL7lIHrmqQ7yh05OJ+eEEdHr0u/kmT1Ff9iHd+4H github.com/rakyll/hey v0.1.2 h1:XlGaKcBdmXJaPImiTnE+TGLDUWQ2toYuHCwdrylLjmg= github.com/rakyll/hey v0.1.2/go.mod h1:S5M+++KwbmxA7w68S92B5NdWiCB+cIhITaMUkq9W608= github.com/remyoudompheng/bigfft v0.0.0-20170806203942-52369c62f446/go.mod h1:uYEyJGbgTkfkS4+E/PavXkNJcbFIpEtjt2B0KDQ5+9M= +github.com/rivo/tview v0.0.0-20191018115645-bacbf5155bc1 h1:s9Lw4phBWkuQJUd+msaBMxP3utLvrFaBQV9jNgG55r0= github.com/rivo/tview v0.0.0-20191018115645-bacbf5155bc1/go.mod h1:+rKjP5+h9HMwWRpAfhIkkQ9KE3m3Nz5rwn7YtUpwgqk= github.com/rivo/uniseg v0.0.0-20190513083848-b9f5b9457d44/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.1.0 h1:+2KBaVoUmb9XzDsrx/Ct0W/EYOSFf/nWTauy++DprtY= @@ -523,10 +579,14 @@ github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFR github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ= github.com/rs/zerolog v1.17.2 h1:RMRHFw2+wF7LO0QqtELQwo8hqSmqISyCJeFeAAuWcRo= github.com/rs/zerolog v1.17.2/go.mod h1:9nvC1axdVrAHcu/s9taAVfBuIdTZLVQmKQyvrUjF5+I= +github.com/rs/zerolog v1.18.0 h1:CbAm3kP2Tptby1i9sYy2MGRg0uxIN9cyDb59Ys7W8z8= +github.com/rs/zerolog v1.18.0/go.mod h1:9nvC1axdVrAHcu/s9taAVfBuIdTZLVQmKQyvrUjF5+I= github.com/rubiojr/go-vhd v0.0.0-20160810183302-0bfd3b39853c/go.mod h1:DM5xW0nvfNNm2uytzsvhI3OnX8uzaRAg8UX/CnDqbto= github.com/russross/blackfriday v0.0.0-20170610170232-067529f716f4/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= github.com/russross/blackfriday v1.5.2 h1:HyvC0ARfnZBqnXwABFeSZHpKvJHJJfPz81GNueLj0oo= github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= +github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= +github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= github.com/sahilm/fuzzy v0.1.0 h1:FzWGaw2Opqyu+794ZQ9SYifWv2EIXpwP4q8dY1kDAwI= github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= @@ -554,6 +614,10 @@ github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnIn github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s= +github.com/srwiley/oksvg v0.0.0-20190829233741-58e08c8fe40e h1:LJUrNHytcMXWKxnULIHPe5SCb1jDpO9o672VB1x2EuQ= +github.com/srwiley/oksvg v0.0.0-20190829233741-58e08c8fe40e/go.mod h1:afMbS0qvv1m5tfENCwnOdZGOF8RGR/FsZ7bvBxQGZG4= +github.com/srwiley/rasterx v0.0.0-20181219215540-696f7edb7a7e h1:FFotfUvew9Eg02LYRl8YybAnm0HCwjjfY5JlOI1oB00= +github.com/srwiley/rasterx v0.0.0-20181219215540-696f7edb7a7e/go.mod h1:mvWM0+15UqyrFKqdRjY6LuAVJR0HOVhJlEgZ5JWtSWU= github.com/storageos/go-api v0.0.0-20180912212459-343b3eff91fc/go.mod h1:ZrLn+e0ZuF3Y65PNF6dIwbJPZqfmtCXxFm9ckv0agOY= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -563,6 +627,7 @@ github.com/stretchr/testify v0.0.0-20151208002404-e3a8ff8ce365/go.mod h1:a8OnRci github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.3.1-0.20190311161405-34c6fa2dc709/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/syndtr/gocapability v0.0.0-20160928074757-e7cb7fa329f4/go.mod h1:hkRG7XYTFWNJGYcbNJQlaLq0fg1yr4J4t/NcTQtrfww= @@ -580,18 +645,23 @@ github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1: github.com/xeipuuv/gojsonschema v1.1.0 h1:ngVtJC9TY/lg0AA/1k48FYhBrhRoFlEmWzsehpNAaZg= github.com/xeipuuv/gojsonschema v1.1.0/go.mod h1:5yf86TLmAcydyeJq5YvxkGPE2fm/u4myDekKRoLuqhs= github.com/xenolf/lego v0.0.0-20160613233155-a9d8cec0e656/go.mod h1:fwiGnfsIjG7OHPfOvgK7Y/Qo6+2Ox0iozjNTkZICKbY= +github.com/xenolf/lego v0.3.2-0.20160613233155-a9d8cec0e656 h1:BTvU+npm3/yjuBd53EvgiFLl5+YLikf2WvHsjRQ4KrY= github.com/xenolf/lego v0.3.2-0.20160613233155-a9d8cec0e656/go.mod h1:fwiGnfsIjG7OHPfOvgK7Y/Qo6+2Ox0iozjNTkZICKbY= github.com/xiang90/probing v0.0.0-20160813154853-07dd2e8dfe18/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= github.com/xlab/handysort v0.0.0-20150421192137-fb3537ed64a1 h1:j2hhcujLRHAg872RWAV5yaUrEjHEObwDv3aImCaNLek= github.com/xlab/handysort v0.0.0-20150421192137-fb3537ed64a1/go.mod h1:QcJo0QPSfTONNIgpN5RA8prR7fF8nkF6cTWTcNerRO8= github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= +github.com/yvasiyarov/go-metrics v0.0.0-20150112132944-c25f46c4b940 h1:p7OofyZ509h8DmPLh8Hn+EIIZm/xYhdZHJ9GnXHdr6U= github.com/yvasiyarov/go-metrics v0.0.0-20150112132944-c25f46c4b940/go.mod h1:aX5oPXxHm3bOH+xeAttToC8pqch2ScQN/JoXYupl6xs= +github.com/yvasiyarov/gorelic v0.0.6 h1:qMJQYPNdtJ7UNYHjX38KXZtltKTqimMuoQjNnSVIuJg= github.com/yvasiyarov/gorelic v0.0.6/go.mod h1:NUSPSUX/bi6SeDMUh6brw0nXpxHnc96TguQh0+r/ssA= +github.com/yvasiyarov/newrelic_platform_go v0.0.0-20140908184405-b21fdbd4370f h1:ERexzlUfuTvpE74urLSbIQW0Z/6hF9t8U4NsJLaioAY= github.com/yvasiyarov/newrelic_platform_go v0.0.0-20140908184405-b21fdbd4370f/go.mod h1:GlGEuHIJweS1mbCqG+7vt2nvWLzLLnRHbXz5JKd/Qbg= github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q= go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= go.uber.org/atomic v0.0.0-20181018215023-8dc6146f7569 h1:nSQar3Y0E3VQF/VdZ8PTAilaXpER+d7ypdABCrpwMdg= go.uber.org/atomic v0.0.0-20181018215023-8dc6146f7569/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= +go.uber.org/goleak v0.10.0/go.mod h1:VCZuO8V8mFPlL0F5J5GK1rtHV3DrFcQ1R8ryq7FK0aI= go.uber.org/multierr v0.0.0-20180122172545-ddea229ff1df h1:shvkWr0NAZkg4nPuE3XrKP0VuBPijjk3TfX6Y6acFNg= go.uber.org/multierr v0.0.0-20180122172545-ddea229ff1df/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= go.uber.org/zap v0.0.0-20180814183419-67bc79d13d15 h1:Z2sc4+v0JHV6Mn4kX1f2a5nruNjmV+Th32sugE8zwz8= @@ -612,8 +682,12 @@ golang.org/x/crypto v0.0.0-20191028145041-f83a4685e152 h1:ZC1Xn5A1nlpSmQCIva4bZ3 golang.org/x/crypto v0.0.0-20191028145041-f83a4685e152/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190125153040-c74c464bbbf2/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190312203227-4b39c73a6495 h1:I6A9Ag9FpEKOjcKrRNjQkPHawoXIhKyTGfvvjFAiiAk= golang.org/x/exp v0.0.0-20190312203227-4b39c73a6495/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/image v0.0.0-20190802002840-cff245a6509b h1:+qEpEAPhDZ1o0x3tHzZTQDArnOixOzGD9HUJfcg0mb4= +golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= @@ -621,6 +695,8 @@ golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTk golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= +golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028 h1:4+4C/Iv2U4fMZBiMCc98MG1In4gJY5YRhtpDNeDeHWs= +golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= golang.org/x/net v0.0.0-20170114055629-f2499483f923/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -677,6 +753,7 @@ golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20190616124812-15dcb6c0061f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190626150813-e07cf5db2756 h1:9nuHUbU8dRnRRfj9KjWUVrJeoexdbeMjttk6Oh1rD10= golang.org/x/sys v0.0.0-20190626150813-e07cf5db2756/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190626221950-04f50cda93cb/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191010194322-b09406accb47/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191028164358-195ce5e7f934 h1:u/E0NqCIWRDAo9WCFo6Ko49njPFDLSd3z+X1HgWDMpE= golang.org/x/sys v0.0.0-20191028164358-195ce5e7f934/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -686,8 +763,6 @@ golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3 golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20190308202827-9d24e82272b4 h1:SvFZT6jyqRaOeXpc5h/JSfZenJ2O330aBsf7JfSUXmQ= -golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0 h1:/5xXl8Y5W96D+TtHSlonuFqGHIWVuyCkGJLwGh9JJFs= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20170824195420-5d2fd3ccab98/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -718,6 +793,7 @@ google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9Ywl google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.5.0 h1:KxkO13IPW4Lslp2bz+KHP2E3gtFlrIGNThxkZQ3g+4c= google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.6.5 h1:tycE03LOZYQNhDpS27tcQdAzLCVMaj7QT2SXxebnpCM= google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190128161407-8ac453e89fca/go.mod h1:L3J43x8/uS+qIUoksaLKe6OS3nUKxOKuIFz1sl2/jx4= @@ -750,6 +826,7 @@ gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/mcuadros/go-syslog.v2 v2.2.1/go.mod h1:l5LPIyOOyIdQquNg+oU6Z3524YwrcqEm0aKH+5zpt2U= gopkg.in/natefinch/lumberjack.v2 v2.0.0 h1:1Lc07Kr7qY4U2YPouBjpCLxpiyxIVoxqXgkXLknAOE8= gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k= +gopkg.in/square/go-jose.v1 v1.1.2 h1:/5jmADZB+RiKtZGr4HxsEFOEfbfsjTKsVnqpThUpE30= gopkg.in/square/go-jose.v1 v1.1.2/go.mod h1:QpYS+a4WhS+DTlyQIi6Ka7MS3SuR9a055rgXNEe6EiA= gopkg.in/square/go-jose.v2 v2.2.2/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= @@ -764,7 +841,6 @@ gotest.tools v2.1.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81 gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo= gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= gotest.tools/gotestsum v0.3.5/go.mod h1:Mnf3e5FUzXbkCfynWBGOwLssY7gTQgCHObK9tMpAriY= -helm.sh/helm v2.16.1+incompatible h1:np11uYeEtlYcFIFRya8Xs5ZweV1z6MvaWQqJAW+1SZQ= helm.sh/helm/v3 v3.0.2 h1:BggvLisIMrAc+Is5oAHVrlVxgwOOrMN8nddfQbm5gKo= helm.sh/helm/v3 v3.0.2/go.mod h1:KBxE6XWO57XSNA1PA9CvVLYRY0zWqYQTad84bNXp1lw= honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= @@ -828,6 +904,7 @@ modernc.org/golex v1.0.0/go.mod h1:b/QX9oBD/LhixY6NDh+IdGv17hgB+51fET1i2kPSmvk= modernc.org/mathutil v1.0.0/go.mod h1:wU0vUrJsVWBZ4P6e7xtFJEhFSNsfRLJ8H458uRjg03k= modernc.org/strutil v1.0.0/go.mod h1:lstksw84oURvj9y3tn8lGvRxyRC1S2+g5uuIzNfIOBs= modernc.org/xc v1.0.0/go.mod h1:mRNCo0bvLjGhHO9WsyuKVU4q0ceiDDDoEeWDJHrNx8I= +rsc.io/letsencrypt v0.0.1 h1:DV0d09Ne9E7UUa9ZqWktZ9L2VmybgTgfq7xlfFR/bbU= rsc.io/letsencrypt v0.0.1/go.mod h1:buyQKZ6IXrRnB7TdkHP0RyEybLx18HHyOSoTyoOLqNY= sigs.k8s.io/kustomize v2.0.3+incompatible h1:JUufWFNlI44MdtnjUqVnvh29rR37PQFzPbLXqhyOyX0= sigs.k8s.io/kustomize v2.0.3+incompatible/go.mod h1:MkjgH3RdOWrievjo6c9T245dYlB5QeXV4WCbnt/PEpU= diff --git a/internal/client/client.go b/internal/client/client.go index 7c287284..11a1132e 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -77,6 +77,12 @@ func makeCacheKey(ns, gvr string, vv []string) string { return ns + ":" + gvr + "::" + strings.Join(vv, ",") } +func (a *APIClient) clearCache() { + for _, k := range a.cache.Keys() { + a.cache.Remove(k) + } +} + // CanI checks if user has access to a certain resource. func (a *APIClient) CanI(ns, gvr string, verbs []string) (auth bool, err error) { if IsClusterWide(ns) { @@ -131,6 +137,9 @@ func (a *APIClient) ValidNamespaces() ([]v1.Namespace, error) { // BOZO!! No super sure about this approach either?? func (a *APIClient) CheckConnectivity() (status bool) { defer func() { + if !status { + a.clearCache() + } if err := recover(); err != nil { status = false } @@ -149,10 +158,10 @@ func (a *APIClient) CheckConnectivity() (status bool) { } } - if _, err := a.checkClientSet.ServerVersion(); err != nil { - log.Error().Err(err).Msgf("K9s can't connect to cluster") - } else { + if _, err := a.checkClientSet.ServerVersion(); err == nil { status = true + } else { + log.Error().Err(err).Msgf("K9s can't connect to cluster") } return @@ -258,21 +267,24 @@ func (a *APIClient) MXDial() (*versioned.Clientset, error) { return a.mxsClient, err } -// SwitchContextOrDie handles kubeconfig context switches. -func (a *APIClient) SwitchContextOrDie(ctx string) { +// SwitchContext handles kubeconfig context switches. +func (a *APIClient) SwitchContext(ctx string) error { currentCtx, err := a.config.CurrentContextName() if err != nil { - log.Fatal().Err(err).Msg("Fetching current context") + return err + } + if currentCtx == ctx { + return nil } - if currentCtx != ctx { - a.cachedClient = nil - a.reset() - if err := a.config.SwitchContext(ctx); err != nil { - log.Fatal().Err(err).Msg("Switching context") - } - _ = a.supportsMxServer() + if err := a.config.SwitchContext(ctx); err != nil { + return err } + a.clearCache() + a.reset() + _ = a.supportsMxServer() + + return nil } func (a *APIClient) reset() { diff --git a/internal/client/config.go b/internal/client/config.go index bcfec129..0be32021 100644 --- a/internal/client/config.go +++ b/internal/client/config.go @@ -3,6 +3,7 @@ package client import ( "errors" "fmt" + "strings" "sync" "github.com/rs/zerolog/log" @@ -48,6 +49,10 @@ func (c *Config) SwitchContext(name string) error { return err } + if _, err := c.GetContext(name); err != nil { + return fmt.Errorf("context %s does not exist", name) + } + if currentCtx != name { c.reset() c.flags.Context, c.currentContext = &name, name @@ -173,13 +178,31 @@ func (c *Config) ClusterNames() ([]string, error) { // CurrentGroupNames retrieves the active group names. func (c *Config) CurrentGroupNames() ([]string, error) { - if c.flags.ImpersonateGroup != nil && len(*c.flags.ImpersonateGroup) != 0 { + if areSet(c.flags.ImpersonateGroup) { return *c.flags.ImpersonateGroup, nil } return []string{}, errors.New("unable to locate current group") } +// ImpersonateGroups retrieves the active groupsif set on the CLI. +func (c *Config) ImpersonateGroups() (string, error) { + if areSet(c.flags.ImpersonateGroup) { + return strings.Join(*c.flags.ImpersonateGroup, ","), nil + } + + return "", errors.New("no groups set") +} + +// ImpersonateUser retrieves the active user name if set on the CLI. +func (c *Config) ImpersonateUser() (string, error) { + if isSet(c.flags.Impersonate) { + return *c.flags.Impersonate, nil + } + + return "", errors.New("no user set") +} + // CurrentUserName retrieves the active user name. func (c *Config) CurrentUserName() (string, error) { if isSet(c.flags.Impersonate) { @@ -307,3 +330,7 @@ func (c *Config) ensureConfig() { func isSet(s *string) bool { return s != nil && len(*s) != 0 } + +func areSet(s *[]string) bool { + return s != nil && len(*s) != 0 +} diff --git a/internal/client/config_test.go b/internal/client/config_test.go index 19a95703..46137574 100644 --- a/internal/client/config_test.go +++ b/internal/client/config_test.go @@ -18,7 +18,7 @@ func init() { } func TestConfigCurrentContext(t *testing.T) { - name, kubeConfig := "blee", "./assets/config" + name, kubeConfig := "blee", "./testdata/config" uu := []struct { flags *genericclioptions.ConfigFlags context string @@ -36,7 +36,7 @@ func TestConfigCurrentContext(t *testing.T) { } func TestConfigCurrentCluster(t *testing.T) { - name, kubeConfig := "blee", "./assets/config" + name, kubeConfig := "blee", "./testdata/config" uu := []struct { flags *genericclioptions.ConfigFlags cluster string @@ -54,7 +54,7 @@ func TestConfigCurrentCluster(t *testing.T) { } func TestConfigCurrentUser(t *testing.T) { - name, kubeConfig := "blee", "./assets/config" + name, kubeConfig := "blee", "./testdata/config" uu := []struct { flags *genericclioptions.ConfigFlags user string @@ -72,7 +72,7 @@ func TestConfigCurrentUser(t *testing.T) { } func TestConfigCurrentNamespace(t *testing.T) { - name, kubeConfig := "blee", "./assets/config" + name, kubeConfig := "blee", "./testdata/config" uu := []struct { flags *genericclioptions.ConfigFlags namespace string @@ -91,7 +91,7 @@ func TestConfigCurrentNamespace(t *testing.T) { } func TestConfigGetContext(t *testing.T) { - kubeConfig := "./assets/config" + kubeConfig := "./testdata/config" uu := []struct { flags *genericclioptions.ConfigFlags cluster string @@ -114,7 +114,7 @@ func TestConfigGetContext(t *testing.T) { } func TestConfigSwitchContext(t *testing.T) { - cluster, kubeConfig := "duh", "./assets/config" + cluster, kubeConfig := "duh", "./testdata/config" flags := genericclioptions.ConfigFlags{ KubeConfig: &kubeConfig, ClusterName: &cluster, @@ -129,7 +129,7 @@ func TestConfigSwitchContext(t *testing.T) { } func TestConfigClusterNameFromContext(t *testing.T) { - cluster, kubeConfig := "duh", "./assets/config" + cluster, kubeConfig := "duh", "./testdata/config" flags := genericclioptions.ConfigFlags{ KubeConfig: &kubeConfig, ClusterName: &cluster, @@ -142,7 +142,7 @@ func TestConfigClusterNameFromContext(t *testing.T) { } func TestConfigAccess(t *testing.T) { - cluster, kubeConfig := "duh", "./assets/config" + cluster, kubeConfig := "duh", "./testdata/config" flags := genericclioptions.ConfigFlags{ KubeConfig: &kubeConfig, ClusterName: &cluster, @@ -155,7 +155,7 @@ func TestConfigAccess(t *testing.T) { } func TestConfigContexts(t *testing.T) { - cluster, kubeConfig := "duh", "./assets/config" + cluster, kubeConfig := "duh", "./testdata/config" flags := genericclioptions.ConfigFlags{ KubeConfig: &kubeConfig, ClusterName: &cluster, @@ -168,7 +168,7 @@ func TestConfigContexts(t *testing.T) { } func TestConfigContextNames(t *testing.T) { - cluster, kubeConfig := "duh", "./assets/config" + cluster, kubeConfig := "duh", "./testdata/config" flags := genericclioptions.ConfigFlags{ KubeConfig: &kubeConfig, ClusterName: &cluster, @@ -181,7 +181,7 @@ func TestConfigContextNames(t *testing.T) { } func TestConfigClusterNames(t *testing.T) { - cluster, kubeConfig := "duh", "./assets/config" + cluster, kubeConfig := "duh", "./testdata/config" flags := genericclioptions.ConfigFlags{ KubeConfig: &kubeConfig, ClusterName: &cluster, @@ -194,7 +194,7 @@ func TestConfigClusterNames(t *testing.T) { } func TestConfigDelContext(t *testing.T) { - cluster, kubeConfig := "duh", "./assets/config.1" + cluster, kubeConfig := "duh", "./testdata/config.1" flags := genericclioptions.ConfigFlags{ KubeConfig: &kubeConfig, ClusterName: &cluster, @@ -209,7 +209,7 @@ func TestConfigDelContext(t *testing.T) { } func TestConfigRestConfig(t *testing.T) { - kubeConfig := "./assets/config" + kubeConfig := "./testdata/config" flags := genericclioptions.ConfigFlags{ KubeConfig: &kubeConfig, } @@ -221,7 +221,7 @@ func TestConfigRestConfig(t *testing.T) { } func TestConfigBadConfig(t *testing.T) { - kubeConfig := "./assets/bork_config" + kubeConfig := "./testdata/bork_config" flags := genericclioptions.ConfigFlags{ KubeConfig: &kubeConfig, } @@ -232,7 +232,7 @@ func TestConfigBadConfig(t *testing.T) { } func TestNamespaceNames(t *testing.T) { - kubeConfig := "./assets/config" + kubeConfig := "./testdata/config" flags := genericclioptions.ConfigFlags{ KubeConfig: &kubeConfig, diff --git a/internal/client/metrics.go b/internal/client/metrics.go index a966ec24..7f02787e 100644 --- a/internal/client/metrics.go +++ b/internal/client/metrics.go @@ -3,20 +3,49 @@ package client import ( "fmt" "math" + "time" v1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/cache" mv1beta1 "k8s.io/metrics/pkg/apis/metrics/v1beta1" ) +const ( + mxCacheSize = 100 + mxCacheExpiry = 1 * time.Minute +) + +// MetricsDial tracks global metric server handle. +var MetricsDial *MetricsServer + +// DialMetrics dials the metrics server. +func DialMetrics(c Connection) *MetricsServer { + if MetricsDial == nil { + MetricsDial = NewMetricsServer(c) + } + + return MetricsDial +} + +// ResetMetrics resets the metric server handle. +func ResetMetrics() { + MetricsDial = nil +} + // MetricsServer serves cluster metrics for nodes and pods. type MetricsServer struct { Connection + + cache *cache.LRUExpireCache } // NewMetricsServer return a metric server instance. func NewMetricsServer(c Connection) *MetricsServer { - return &MetricsServer{Connection: c} + return &MetricsServer{ + Connection: c, + cache: cache.NewLRUExpireCache(mxCacheSize), + } } // NodesMetrics retrieves metrics for a given set of nodes. @@ -28,15 +57,15 @@ func (m *MetricsServer) NodesMetrics(nodes *v1.NodeList, metrics *mv1beta1.NodeM for _, no := range nodes.Items { mmx[no.Name] = NodeMetrics{ AvailCPU: no.Status.Allocatable.Cpu().MilliValue(), - AvailMEM: toMB(no.Status.Allocatable.Memory().Value()), + AvailMEM: ToMB(no.Status.Allocatable.Memory().Value()), TotalCPU: no.Status.Capacity.Cpu().MilliValue(), - TotalMEM: toMB(no.Status.Capacity.Memory().Value()), + TotalMEM: ToMB(no.Status.Capacity.Memory().Value()), } } for _, c := range metrics.Items { if mx, ok := mmx[c.Name]; ok { mx.CurrentCPU = c.Usage.Cpu().MilliValue() - mx.CurrentMEM = toMB(c.Usage.Memory().Value()) + mx.CurrentMEM = ToMB(c.Usage.Memory().Value()) mmx[c.Name] = mx } } @@ -51,13 +80,13 @@ func (m *MetricsServer) ClusterLoad(nos *v1.NodeList, nmx *mv1beta1.NodeMetricsL for _, no := range nos.Items { nodeMetrics[no.Name] = NodeMetrics{ AvailCPU: no.Status.Allocatable.Cpu().MilliValue(), - AvailMEM: toMB(no.Status.Allocatable.Memory().Value()), + AvailMEM: ToMB(no.Status.Allocatable.Memory().Value()), } } for _, mx := range nmx.Items { if m, ok := nodeMetrics[mx.Name]; ok { m.CurrentCPU = mx.Usage.Cpu().MilliValue() - m.CurrentMEM = toMB(mx.Usage.Memory().Value()) + m.CurrentMEM = ToMB(mx.Usage.Memory().Value()) nodeMetrics[mx.Name] = m } } @@ -74,86 +103,121 @@ func (m *MetricsServer) ClusterLoad(nos *v1.NodeList, nmx *mv1beta1.NodeMetricsL return nil } -// FetchNodesMetrics return all metrics for pods in a given namespace. -func (m *MetricsServer) FetchNodesMetrics() (*mv1beta1.NodeMetricsList, error) { - var mx mv1beta1.NodeMetricsList +func (m *MetricsServer) checkAccess(ns, gvr, msg string) error { if !m.HasMetrics() { - return &mx, fmt.Errorf("No metrics-server detected on cluster") + return fmt.Errorf("No metrics-server detected on cluster") } - auth, err := m.CanI("", "metrics.k8s.io/v1beta1/nodes", ListAccess) + auth, err := m.CanI(ns, gvr, ListAccess) if err != nil { - return &mx, err + return err } if !auth { - return &mx, fmt.Errorf("user is not authorized to list node metrics") + return fmt.Errorf(msg) + } + return nil +} + +// FetchNodesMetrics return all metrics for nodes. +func (m *MetricsServer) FetchNodesMetrics() (*mv1beta1.NodeMetricsList, error) { + const msg = "user is not authorized to list node metrics" + + mx := new(mv1beta1.NodeMetricsList) + if err := m.checkAccess("", "metrics.k8s.io/v1beta1/nodes", msg); err != nil { + return mx, err + } + + const key = "nodes" + if entry, ok := m.cache.Get(key); ok && entry != nil { + mxList, ok := entry.(*mv1beta1.NodeMetricsList) + if !ok { + return nil, fmt.Errorf("expected nodemetricslist but got %T", entry) + } + return mxList, nil } client, err := m.MXDial() if err != nil { - return &mx, err + return mx, err } - return client.MetricsV1beta1().NodeMetricses().List(metav1.ListOptions{}) + mxList, err := client.MetricsV1beta1().NodeMetricses().List(metav1.ListOptions{}) + if err != nil { + return mx, err + } + m.cache.Add(key, mxList, mxCacheExpiry) + + return mxList, nil } // FetchPodsMetrics return all metrics for pods in a given namespace. func (m *MetricsServer) FetchPodsMetrics(ns string) (*mv1beta1.PodMetricsList, error) { - var mx mv1beta1.PodMetricsList - if m.Connection == nil { - return &mx, fmt.Errorf("no client connection") - } + mx := new(mv1beta1.PodMetricsList) + const msg = "user is not authorized to list pods metrics" - if !m.HasMetrics() { - return &mx, fmt.Errorf("No metrics-server detected on cluster") - } if ns == NamespaceAll { ns = AllNamespaces } - - auth, err := m.CanI(ns, "metrics.k8s.io/v1beta1/pods", ListAccess) - if err != nil { - return &mx, err + if err := m.checkAccess(ns, "metrics.k8s.io/v1beta1/pods", msg); err != nil { + return mx, err } - if !auth { - return &mx, fmt.Errorf("user is not authorized to list pods metrics") + + key := FQN(ns, "pods") + if entry, ok := m.cache.Get(key); ok { + mxList, ok := entry.(*mv1beta1.PodMetricsList) + if !ok { + return mx, fmt.Errorf("expected podmetricslist but got %T", entry) + } + return mxList, nil } client, err := m.MXDial() if err != nil { - return &mx, err + return mx, err } + mxList, err := client.MetricsV1beta1().PodMetricses(ns).List(metav1.ListOptions{}) + if err != nil { + return mx, err + } + m.cache.Add(key, mxList, mxCacheExpiry) - return client.MetricsV1beta1().PodMetricses(ns).List(metav1.ListOptions{}) + return mxList, err } // FetchPodMetrics return all metrics for pods in a given namespace. func (m *MetricsServer) FetchPodMetrics(fqn string) (*mv1beta1.PodMetrics, error) { - var mx mv1beta1.PodMetrics - if m.Connection == nil { - return &mx, fmt.Errorf("no client connection") - } - if !m.HasMetrics() { - return &mx, fmt.Errorf("No metrics-server detected on cluster") - } + var mx *mv1beta1.PodMetrics + const msg = "user is not authorized to list pod metrics" ns, n := Namespaced(fqn) if ns == NamespaceAll { ns = AllNamespaces } - auth, err := m.CanI(ns, "metrics.k8s.io/v1beta1/pods", GetAccess) - if err != nil { - return &mx, err + if err := m.checkAccess(ns, "metrics.k8s.io/v1beta1/pods", msg); err != nil { + return mx, err } - if !auth { - return &mx, fmt.Errorf("user is not authorized to list pod metrics") + + var key = FQN(ns, "pods") + if entry, ok := m.cache.Get(key); ok { + if list, ok := entry.(*mv1beta1.PodMetricsList); ok && list != nil { + for _, m := range list.Items { + if FQN(m.Namespace, m.Name) == fqn { + return &m, nil + } + } + } } client, err := m.MXDial() if err != nil { - return &mx, err + return mx, err } + mx, err = client.MetricsV1beta1().PodMetricses(ns).Get(n, metav1.GetOptions{}) + if err != nil { + return mx, err + } + m.cache.Add(key, mx, mxCacheExpiry) - return client.MetricsV1beta1().PodMetricses(ns).Get(n, metav1.GetOptions{}) + return mx, nil } // PodsMetrics retrieves metrics for all pods in a given namespace. @@ -167,7 +231,7 @@ func (m *MetricsServer) PodsMetrics(pods *mv1beta1.PodMetricsList, mmx PodsMetri var mx PodMetrics for _, c := range p.Containers { mx.CurrentCPU += c.Usage.Cpu().MilliValue() - mx.CurrentMEM += toMB(c.Usage.Memory().Value()) + mx.CurrentMEM += ToMB(c.Usage.Memory().Value()) } mmx[p.Namespace+"/"+p.Name] = mx } @@ -178,8 +242,8 @@ func (m *MetricsServer) PodsMetrics(pods *mv1beta1.PodMetricsList, mmx PodsMetri const megaByte = 1024 * 1024 -// toMB converts bytes to megabytes. -func toMB(v int64) float64 { +// ToMB converts bytes to megabytes. +func ToMB(v int64) float64 { return float64(v) / megaByte } diff --git a/internal/client/assets/config b/internal/client/testdata/config similarity index 100% rename from internal/client/assets/config rename to internal/client/testdata/config diff --git a/internal/client/assets/config.1 b/internal/client/testdata/config.1 similarity index 100% rename from internal/client/assets/config.1 rename to internal/client/testdata/config.1 diff --git a/internal/client/tunnel.go b/internal/client/tunnel.go new file mode 100644 index 00000000..2eba5a37 --- /dev/null +++ b/internal/client/tunnel.go @@ -0,0 +1,11 @@ +package client + +// PortTunnel represents a host tunnel port mapper. +type PortTunnel struct { + Address, LocalPort, ContainerPort string +} + +// PortMap returns a port mapping. +func (t PortTunnel) PortMap() string { + return t.LocalPort + ":" + t.ContainerPort +} diff --git a/internal/client/types.go b/internal/client/types.go index 91e1f247..48286663 100644 --- a/internal/client/types.go +++ b/internal/client/types.go @@ -22,6 +22,9 @@ const ( // ClusterScope designates a resource is not namespaced. ClusterScope = "-" + + // NotNamespaced designates a non resource namespace. + NotNamespaced = "*" ) const ( @@ -64,7 +67,7 @@ type Connection interface { Config() *Config DialOrDie() kubernetes.Interface - SwitchContextOrDie(ctx string) + SwitchContext(ctx string) error CachedDiscoveryOrDie() *disk.CachedDiscoveryClient RestConfigOrDie() *restclient.Config MXDial() (*versioned.Clientset, error) diff --git a/internal/config/alias.go b/internal/config/alias.go index ff8075da..f1f9477d 100644 --- a/internal/config/alias.go +++ b/internal/config/alias.go @@ -2,6 +2,7 @@ package config import ( "io/ioutil" + "os" "path/filepath" "sync" @@ -87,6 +88,13 @@ func (a *Aliases) loadDefaults() { // Load K9s aliases. func (a *Aliases) Load() error { a.loadDefaults() + + _, err := os.Stat(K9sAlias) + if os.IsNotExist(err) { + log.Debug().Err(err).Msgf("No custom aliases found") + return nil + } + return a.LoadAliases(K9sAlias) } @@ -139,11 +147,17 @@ func (a *Aliases) Define(gvr string, aliases ...string) { } } -// LoadAliases loads alias from a given file. -func (a *Aliases) LoadAliases(path string) error { +// Load K9s aliases. +func (a *Aliases) Load() error { + a.loadDefaultAliases() + return a.LoadFileAliases(K9sAlias) +} + +// LoadFileAliases loads alias from a given file. +func (a *Aliases) LoadFileAliases(path string) error { f, err := ioutil.ReadFile(path) if err != nil { - log.Warn().Err(err).Msgf("No custom aliases found") + log.Debug().Err(err).Msgf("No custom aliases found") return nil } @@ -161,6 +175,63 @@ func (a *Aliases) LoadAliases(path string) error { return nil } +func (a *Aliases) loadDefaultAliases() { + a.mx.Lock() + defer a.mx.Unlock() + + a.Alias["dp"] = "apps/v1/deployments" + a.Alias["sec"] = "v1/secrets" + a.Alias["jo"] = "batch/v1/jobs" + a.Alias["cr"] = "rbac.authorization.k8s.io/v1/clusterroles" + a.Alias["crb"] = "rbac.authorization.k8s.io/v1/clusterrolebindings" + a.Alias["ro"] = "rbac.authorization.k8s.io/v1/roles" + a.Alias["rb"] = "rbac.authorization.k8s.io/v1/rolebindings" + a.Alias["np"] = "networking.k8s.io/v1/networkpolicies" + + const contexts = "contexts" + { + a.Alias["ctx"] = contexts + a.Alias[contexts] = contexts + a.Alias["context"] = contexts + } + const users = "users" + { + a.Alias["usr"] = users + a.Alias[users] = users + a.Alias["user"] = users + } + const groups = "groups" + { + a.Alias["grp"] = groups + a.Alias["group"] = groups + a.Alias[groups] = groups + } + const portFwds = "portforwards" + { + a.Alias["pf"] = portFwds + a.Alias[portFwds] = portFwds + a.Alias["portforward"] = portFwds + } + const benchmarks = "benchmarks" + { + a.Alias["be"] = benchmarks + a.Alias["benchmark"] = benchmarks + a.Alias[benchmarks] = benchmarks + } + const dumps = "screendumps" + { + a.Alias["sd"] = dumps + a.Alias["screendump"] = dumps + a.Alias[dumps] = dumps + } + const pulses = "pulses" + { + a.Alias["hz"] = pulses + a.Alias["pu"] = pulses + a.Alias["pulse"] = pulses + } +} + // Save alias to disk. func (a *Aliases) Save() error { log.Debug().Msg("[Config] Saving Aliases...") diff --git a/internal/config/alias_test.go b/internal/config/alias_test.go index 80416d70..a627ddd2 100644 --- a/internal/config/alias_test.go +++ b/internal/config/alias_test.go @@ -72,7 +72,7 @@ func TestAliasDefine(t *testing.T) { func TestAliasesLoad(t *testing.T) { a := config.NewAliases() - assert.Nil(t, a.LoadAliases("test_assets/alias.yml")) + assert.Nil(t, a.LoadFileAliases("testdata/alias.yml")) assert.Equal(t, 2, len(a.Alias)) } @@ -82,6 +82,6 @@ func TestAliasesSave(t *testing.T) { a.Alias["blee"] = "duh" assert.Nil(t, a.SaveAliases("/tmp/a.yml")) - assert.Nil(t, a.LoadAliases("/tmp/a.yml")) + assert.Nil(t, a.LoadFileAliases("/tmp/a.yml")) assert.Equal(t, 2, len(a.Alias)) } diff --git a/internal/config/bench.go b/internal/config/bench.go index a224f17c..f3e0a149 100644 --- a/internal/config/bench.go +++ b/internal/config/bench.go @@ -49,11 +49,11 @@ type ( // BenchConfig represents a service benchmark. BenchConfig struct { + Name string C int `yaml:"concurrency"` N int `yaml:"requests"` Auth Auth `yaml:"auth"` HTTP HTTP `yaml:"http"` - Name string } ) @@ -73,7 +73,8 @@ func newBenchmark() Benchmark { } } -func (b Benchmark) empty() bool { +// Empty checks if the benchmark is set +func (b Benchmark) Empty() bool { return b.C == 0 && b.N == 0 } @@ -104,3 +105,15 @@ func (s *Bench) load(path string) error { return yaml.Unmarshal(f, &s) } + +// DefaultBenchSpec returns a default bench spec. +func DefaultBenchSpec() BenchConfig { + return BenchConfig{ + C: DefaultC, + N: DefaultN, + HTTP: HTTP{ + Method: DefaultMethod, + Path: "/", + }, + } +} diff --git a/internal/config/bench_test.go b/internal/config/bench_test.go index 77df32fd..fd6e4d97 100644 --- a/internal/config/bench_test.go +++ b/internal/config/bench_test.go @@ -19,7 +19,7 @@ func TestBenchEmpty(t *testing.T) { for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { - assert.Equal(t, u.e, u.b.empty()) + assert.Equal(t, u.e, u.b.Empty()) }) } } @@ -32,14 +32,14 @@ func TestBenchLoad(t *testing.T) { coCount int }{ "goodConfig": { - "test_assets/b_good.yml", + "testdata/b_good.yml", 2, 1000, 2, 0, }, "malformed": { - "test_assets/b_toast.yml", + "testdata/b_toast.yml", 1, 200, 0, @@ -100,7 +100,7 @@ func TestBenchServiceLoad(t *testing.T) { for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { - b, err := NewBench("test_assets/b_good.yml") + b, err := NewBench("testdata/b_good.yml") assert.Nil(t, err) assert.Equal(t, 2, len(b.Benchmarks.Services)) @@ -119,16 +119,16 @@ func TestBenchServiceLoad(t *testing.T) { } func TestBenchReLoad(t *testing.T) { - b, err := NewBench("test_assets/b_containers.yml") + b, err := NewBench("testdata/b_containers.yml") assert.Nil(t, err) assert.Equal(t, 2, b.Benchmarks.Defaults.C) - assert.Nil(t, b.Reload("test_assets/b_containers_1.yml")) + assert.Nil(t, b.Reload("testdata/b_containers_1.yml")) assert.Equal(t, 20, b.Benchmarks.Defaults.C) } func TestBenchLoadToast(t *testing.T) { - _, err := NewBench("test_assets/toast.yml") + _, err := NewBench("testdata/toast.yml") assert.NotNil(t, err) } @@ -171,7 +171,7 @@ func TestBenchContainerLoad(t *testing.T) { for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { - b, err := NewBench("test_assets/b_containers.yml") + b, err := NewBench("testdata/b_containers.yml") assert.Nil(t, err) assert.Equal(t, 2, len(b.Benchmarks.Services)) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 52a6c570..0f940013 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -18,7 +18,7 @@ func init() { } func TestConfigRefine(t *testing.T) { - cfgFile, ctx, cluster, ns := "test_assets/kubeconfig-test.yml", "test", "c1", "ns1" + cfgFile, ctx, cluster, ns := "testdata/kubeconfig-test.yml", "test", "c1", "ns1" uu := map[string]struct { flags *genericclioptions.ConfigFlags issue bool @@ -85,7 +85,7 @@ func TestConfigValidate(t *testing.T) { cfg := config.NewConfig(mk) cfg.SetConnection(mc) - assert.Nil(t, cfg.Load("test_assets/k9s.yml")) + assert.Nil(t, cfg.Load("testdata/k9s.yml")) cfg.Validate() // mc.VerifyWasCalledOnce().ValidNamespaces() } @@ -93,7 +93,7 @@ func TestConfigValidate(t *testing.T) { func TestConfigLoad(t *testing.T) { mk := NewMockKubeSettings() cfg := config.NewConfig(mk) - assert.Nil(t, cfg.Load("test_assets/k9s.yml")) + assert.Nil(t, cfg.Load("testdata/k9s.yml")) assert.Equal(t, 2, cfg.K9s.RefreshRate) assert.Equal(t, 200, cfg.K9s.LogBufferSize) @@ -119,7 +119,7 @@ func TestConfigCurrentCluster(t *testing.T) { mk := NewMockKubeSettings() cfg := config.NewConfig(mk) - assert.Nil(t, cfg.Load("test_assets/k9s.yml")) + assert.Nil(t, cfg.Load("testdata/k9s.yml")) assert.NotNil(t, cfg.CurrentCluster()) assert.Equal(t, "kube-system", cfg.CurrentCluster().Namespace.Active) assert.Equal(t, "ctx", cfg.CurrentCluster().View.Active) @@ -129,7 +129,7 @@ func TestConfigActiveNamespace(t *testing.T) { mk := NewMockKubeSettings() cfg := config.NewConfig(mk) - assert.Nil(t, cfg.Load("test_assets/k9s.yml")) + assert.Nil(t, cfg.Load("testdata/k9s.yml")) assert.Equal(t, "kube-system", cfg.ActiveNamespace()) } @@ -142,7 +142,7 @@ func TestConfigSetActiveNamespace(t *testing.T) { mk := NewMockKubeSettings() cfg := config.NewConfig(mk) - assert.Nil(t, cfg.Load("test_assets/k9s.yml")) + assert.Nil(t, cfg.Load("testdata/k9s.yml")) assert.Nil(t, cfg.SetActiveNamespace("default")) assert.Equal(t, "default", cfg.ActiveNamespace()) } @@ -151,7 +151,7 @@ func TestConfigActiveView(t *testing.T) { mk := NewMockKubeSettings() cfg := config.NewConfig(mk) - assert.Nil(t, cfg.Load("test_assets/k9s.yml")) + assert.Nil(t, cfg.Load("testdata/k9s.yml")) assert.Equal(t, "ctx", cfg.ActiveView()) } @@ -164,7 +164,7 @@ func TestConfigSetActiveView(t *testing.T) { mk := NewMockKubeSettings() cfg := config.NewConfig(mk) - assert.Nil(t, cfg.Load("test_assets/k9s.yml")) + assert.Nil(t, cfg.Load("testdata/k9s.yml")) cfg.SetActiveView("po") assert.Equal(t, "po", cfg.ActiveView()) } @@ -173,7 +173,7 @@ func TestConfigFavNamespaces(t *testing.T) { mk := NewMockKubeSettings() cfg := config.NewConfig(mk) - assert.Nil(t, cfg.Load("test_assets/k9s.yml")) + assert.Nil(t, cfg.Load("testdata/k9s.yml")) expectedNS := []string{"default", "kube-public", "istio-system", "all", "kube-system"} assert.Equal(t, expectedNS, cfg.FavNamespaces()) } @@ -181,13 +181,13 @@ func TestConfigFavNamespaces(t *testing.T) { func TestConfigLoadOldCfg(t *testing.T) { mk := NewMockKubeSettings() cfg := config.NewConfig(mk) - assert.Nil(t, cfg.Load("test_assets/k9s_old.yml")) + assert.Nil(t, cfg.Load("testdata/k9s_old.yml")) } func TestConfigLoadCrap(t *testing.T) { mk := NewMockKubeSettings() cfg := config.NewConfig(mk) - assert.NotNil(t, cfg.Load("test_assets/k9s_not_there.yml")) + assert.NotNil(t, cfg.Load("testdata/k9s_not_there.yml")) } func TestConfigSaveFile(t *testing.T) { @@ -203,7 +203,7 @@ func TestConfigSaveFile(t *testing.T) { cfg := config.NewConfig(mk) cfg.SetConnection(mc) - assert.Nil(t, cfg.Load("test_assets/k9s.yml")) + assert.Nil(t, cfg.Load("testdata/k9s.yml")) cfg.K9s.RefreshRate = 100 cfg.K9s.ReadOnly = true cfg.K9s.LogBufferSize = 500 @@ -233,7 +233,7 @@ func TestConfigReset(t *testing.T) { cfg := config.NewConfig(mk) cfg.SetConnection(mc) - assert.Nil(t, cfg.Load("test_assets/k9s.yml")) + assert.Nil(t, cfg.Load("testdata/k9s.yml")) cfg.Reset() cfg.Validate() diff --git a/internal/config/hotkey_test.go b/internal/config/hotkey_test.go index 38f8242b..96515b24 100644 --- a/internal/config/hotkey_test.go +++ b/internal/config/hotkey_test.go @@ -9,7 +9,7 @@ import ( func TestHotKeyLoad(t *testing.T) { h := config.NewHotKeys() - assert.Nil(t, h.LoadHotKeys("test_assets/hot_key.yml")) + assert.Nil(t, h.LoadHotKeys("testdata/hot_key.yml")) assert.Equal(t, 1, len(h.HotKey)) diff --git a/internal/config/k9s_test.go b/internal/config/k9s_test.go index 65f45a27..4b061de6 100644 --- a/internal/config/k9s_test.go +++ b/internal/config/k9s_test.go @@ -72,7 +72,7 @@ func TestK9sActiveClusterBlank(t *testing.T) { func TestK9sActiveCluster(t *testing.T) { mk := NewMockKubeSettings() cfg := config.NewConfig(mk) - assert.Nil(t, cfg.Load("test_assets/k9s.yml")) + assert.Nil(t, cfg.Load("testdata/k9s.yml")) cl := cfg.K9s.ActiveCluster() assert.NotNil(t, cl) diff --git a/internal/config/mock_connection_test.go b/internal/config/mock_connection_test.go index 57a8104b..d2fd8c48 100644 --- a/internal/config/mock_connection_test.go +++ b/internal/config/mock_connection_test.go @@ -267,12 +267,14 @@ func (mock *MockConnection) SupportsResource(_param0 string) bool { return ret0 } -func (mock *MockConnection) SwitchContextOrDie(_param0 string) { +func (mock *MockConnection) SwitchContext(_param0 string) error { if mock == nil { panic("mock must not be nil. Use myMock := NewMockConnection().") } params := []pegomock.Param{_param0} - pegomock.GetGenericMockFrom(mock).Invoke("SwitchContextOrDie", params, []reflect.Type{}) + pegomock.GetGenericMockFrom(mock).Invoke("SwitchContext", params, []reflect.Type{reflect.TypeOf((*error)(nil)).Elem()}) + + return nil } func (mock *MockConnection) ValidNamespaces() ([]v1.Namespace, error) { diff --git a/internal/config/plugin_test.go b/internal/config/plugin_test.go index 7a81412b..43f8e278 100644 --- a/internal/config/plugin_test.go +++ b/internal/config/plugin_test.go @@ -9,7 +9,7 @@ import ( func TestPluginLoad(t *testing.T) { p := config.NewPlugins() - assert.Nil(t, p.LoadPlugins("test_assets/plugin.yml")) + assert.Nil(t, p.LoadPlugins("testdata/plugin.yml")) assert.Equal(t, 1, len(p.Plugin)) k, ok := p.Plugin["blah"] diff --git a/internal/config/styles.go b/internal/config/styles.go index f72c8233..cc56482f 100644 --- a/internal/config/styles.go +++ b/internal/config/styles.go @@ -21,17 +21,31 @@ type StyleListener interface { } type ( + // Color represents a color. + Color string + + // Colors tracks multiple colors. + Colors []Color + // Styles tracks K9s styling options. Styles struct { K9s Style `yaml:"k9s"` listeners []StyleListener } + // Style tracks K9s styles. + Style struct { + Body Body `yaml:"body"` + Frame Frame `yaml:"frame"` + Info Info `yaml:"info"` + Views Views `yaml:"views"` + } + // Body tracks body styles. Body struct { - FgColor string `yaml:"fgColor"` - BgColor string `yaml:"bgColor"` - LogoColor string `yaml:"logoColor"` + FgColor Color `yaml:"fgColor"` + BgColor Color `yaml:"bgColor"` + LogoColor Color `yaml:"logoColor"` } // Frame tracks frame styles. @@ -45,120 +59,171 @@ type ( // Views tracks individual view styles. Views struct { - Yaml Yaml `yaml:"yaml"` - Log Log `yaml:"logs"` + Table Table `yaml:"table"` + Xray Xray `yaml:"xray"` + Charts Charts `yaml:"charts"` + Yaml Yaml `yaml:"yaml"` + Log Log `yaml:"logs"` } // Status tracks resource status styles. Status struct { - NewColor string `yaml:"newColor"` - ModifyColor string `yaml:"modifyColor"` - AddColor string `yaml:"addColor"` - ErrorColor string `yaml:"errorColor"` - HighlightColor string `yaml:"highlightColor"` - KillColor string `yaml:"killColor"` - CompletedColor string `yaml:"completedColor"` + NewColor Color `yaml:"newColor"` + ModifyColor Color `yaml:"modifyColor"` + AddColor Color `yaml:"addColor"` + ErrorColor Color `yaml:"errorColor"` + HighlightColor Color `yaml:"highlightColor"` + KillColor Color `yaml:"killColor"` + CompletedColor Color `yaml:"completedColor"` } // Log tracks Log styles. Log struct { - FgColor string `yaml:"fgColor"` - BgColor string `yaml:"bgColor"` + FgColor Color `yaml:"fgColor"` + BgColor Color `yaml:"bgColor"` } // Yaml tracks yaml styles. Yaml struct { - KeyColor string `yaml:"keyColor"` - ValueColor string `yaml:"valueColor"` - ColonColor string `yaml:"colonColor"` + KeyColor Color `yaml:"keyColor"` + ValueColor Color `yaml:"valueColor"` + ColonColor Color `yaml:"colonColor"` } // Title tracks title styles. Title struct { - FgColor string `yaml:"fgColor"` - BgColor string `yaml:"bgColor"` - HighlightColor string `yaml:"highlightColor"` - CounterColor string `yaml:"counterColor"` - FilterColor string `yaml:"filterColor"` + FgColor Color `yaml:"fgColor"` + BgColor Color `yaml:"bgColor"` + HighlightColor Color `yaml:"highlightColor"` + CounterColor Color `yaml:"counterColor"` + FilterColor Color `yaml:"filterColor"` } // Info tracks info styles. Info struct { - SectionColor string `yaml:"sectionColor"` - FgColor string `yaml:"fgColor"` + SectionColor Color `yaml:"sectionColor"` + FgColor Color `yaml:"fgColor"` } // Border tracks border styles. Border struct { - FgColor string `yaml:"fgColor"` - FocusColor string `yaml:"focusColor"` + FgColor Color `yaml:"fgColor"` + FocusColor Color `yaml:"focusColor"` } // Crumb tracks crumbs styles. Crumb struct { - FgColor string `yaml:"fgColor"` - BgColor string `yaml:"bgColor"` - ActiveColor string `yaml:"activeColor"` + FgColor Color `yaml:"fgColor"` + BgColor Color `yaml:"bgColor"` + ActiveColor Color `yaml:"activeColor"` } // Table tracks table styles. Table struct { - FgColor string `yaml:"fgColor"` - BgColor string `yaml:"bgColor"` - CursorColor string `yaml:"cursorColor"` - MarkColor string `yaml:"markColor"` + FgColor Color `yaml:"fgColor"` + BgColor Color `yaml:"bgColor"` + CursorColor Color `yaml:"cursorColor"` + MarkColor Color `yaml:"markColor"` Header TableHeader `yaml:"header"` } // TableHeader tracks table header styles. TableHeader struct { - FgColor string `yaml:"fgColor"` - BgColor string `yaml:"bgColor"` - SorterColor string `yaml:"sorterColor"` + FgColor Color `yaml:"fgColor"` + BgColor Color `yaml:"bgColor"` + SorterColor Color `yaml:"sorterColor"` } // Xray tracks xray styles. Xray struct { - FgColor string `yaml:"fgColor"` - BgColor string `yaml:"bgColor"` - CursorColor string `yaml:"cursorColor"` - GraphicColor string `yaml:"graphicColor"` - ShowIcons bool `yaml:"showIcons"` + FgColor Color `yaml:"fgColor"` + BgColor Color `yaml:"bgColor"` + CursorColor Color `yaml:"cursorColor"` + GraphicColor Color `yaml:"graphicColor"` + ShowIcons bool `yaml:"showIcons"` } // Menu tracks menu styles. Menu struct { - FgColor string `yaml:"fgColor"` - KeyColor string `yaml:"keyColor"` - NumKeyColor string `yaml:"numKeyColor"` + FgColor Color `yaml:"fgColor"` + KeyColor Color `yaml:"keyColor"` + NumKeyColor Color `yaml:"numKeyColor"` } - // Style tracks K9s styles. - Style struct { - Body Body `yaml:"body"` - Frame Frame `yaml:"frame"` - Info Info `yaml:"info"` - Table Table `yaml:"table"` - Xray Xray `yaml:"xray"` - Views Views `yaml:"views"` + // Charts tracks charts styles. + Charts struct { + BgColor Color `yaml:"bgColor"` + DialBgColor Color `yaml:"dialBgColor"` + ChartBgColor Color `yaml:"chartBgColor"` + DefaultDialColors Colors `yaml:"defaultDialColors"` + DefaultChartColors Colors `yaml:"defaultChartColors"` + ResourceColors map[string]Colors `yaml:"resourceColors"` } ) +const ( + // DefaultColor represents a default color. + DefaultColor Color = "default" + + // TransparentColor represents the terminal bg color. + TransparentColor Color = "-" +) + +// NewColor returns a new color. +func NewColor(c string) Color { + return Color(c) +} + +// String returns color as string. +func (c Color) String() string { + return string(c) +} + +// Color returns a view color. +func (c Color) Color() tcell.Color { + if c == DefaultColor { + return tcell.ColorDefault + } + if color, ok := tcell.ColorNames[c.String()]; ok { + return color + } + return tcell.GetColor(c.String()) +} + +// Colors converts series string colors to colors. +func (c Colors) Colors() []tcell.Color { + cc := make([]tcell.Color, 0, len(c)) + for _, color := range c { + cc = append(cc, color.Color()) + } + return cc +} + func newStyle() Style { return Style{ Body: newBody(), Frame: newFrame(), Info: newInfo(), - Table: newTable(), Views: newViews(), - Xray: newXray(), } } +func newCharts() Charts { + return Charts{ + BgColor: "default", + DialBgColor: "default", + ChartBgColor: "default", + DefaultDialColors: Colors{Color("palegreen"), Color("orangered")}, + DefaultChartColors: Colors{Color("palegreen"), Color("orangered")}, + } +} func newViews() Views { return Views{ - Yaml: newYaml(), - Log: newLog(), + Table: newTable(), + Xray: newXray(), + Charts: newCharts(), + Yaml: newYaml(), + Log: newLog(), } } @@ -188,7 +253,7 @@ func newStatus() Status { ErrorColor: "orangered", HighlightColor: "aqua", KillColor: "mediumpurple", - CompletedColor: "gray", + CompletedColor: "lightgray", } } @@ -292,14 +357,24 @@ func NewStyles() *Styles { } } +// Reset resets styles. +func (s *Styles) Reset() { + s.K9s = newStyle() +} + +// DefaultSkin loads the default skin +func (s *Styles) DefaultSkin() { + s.K9s = newStyle() +} + // FgColor returns the foreground color. func (s *Styles) FgColor() tcell.Color { - return AsColor(s.Body().FgColor) + return s.Body().FgColor.Color() } // BgColor returns the background color. func (s *Styles) BgColor() tcell.Color { - return AsColor(s.Body().BgColor) + return s.Body().BgColor.Color() } // AddListener registers a new listener. @@ -348,14 +423,19 @@ func (s *Styles) Title() Title { return s.Frame().Title } +// Charts returns charts styles. +func (s *Styles) Charts() Charts { + return s.K9s.Views.Charts +} + // Table returns table styles. func (s *Styles) Table() Table { - return s.K9s.Table + return s.K9s.Views.Table } // Xray returns xray styles. func (s *Styles) Xray() Xray { - return s.K9s.Xray + return s.K9s.Views.Xray } // Views returns views styles. @@ -383,15 +463,7 @@ func (s *Styles) Update() { tview.Styles.PrimitiveBackgroundColor = s.BgColor() tview.Styles.ContrastBackgroundColor = s.BgColor() tview.Styles.PrimaryTextColor = s.FgColor() - tview.Styles.BorderColor = AsColor(s.K9s.Frame.Border.FgColor) - tview.Styles.FocusColor = AsColor(s.K9s.Frame.Border.FocusColor) -} - -// AsColor checks color index, if match return color otherwise pink it is. -func AsColor(c string) tcell.Color { - if color, ok := tcell.ColorNames[c]; ok { - return color - } - - return tcell.GetColor(c) + tview.Styles.BorderColor = s.K9s.Frame.Border.FgColor.Color() + tview.Styles.FocusColor = s.K9s.Frame.Border.FocusColor.Color() + s.fireStylesChanged() } diff --git a/internal/config/styles_test.go b/internal/config/styles_test.go index ee9fe85a..a7b9c946 100644 --- a/internal/config/styles_test.go +++ b/internal/config/styles_test.go @@ -9,7 +9,7 @@ import ( "github.com/stretchr/testify/assert" ) -func TestAsColor(t *testing.T) { +func TestColor(t *testing.T) { uu := map[string]tcell.Color{ "blah": tcell.ColorDefault, "blue": tcell.ColorBlue, @@ -20,19 +20,19 @@ func TestAsColor(t *testing.T) { for k := range uu { c, u := k, uu[k] t.Run(k, func(t *testing.T) { - assert.Equal(t, u, config.AsColor(c)) + assert.Equal(t, u, config.NewColor(c).Color()) }) } } func TestSkinNone(t *testing.T) { s := config.NewStyles() - assert.Nil(t, s.Load("test_assets/empty_skin.yml")) + assert.Nil(t, s.Load("testdata/empty_skin.yml")) s.Update() - assert.Equal(t, "cadetblue", s.Body().FgColor) - assert.Equal(t, "black", s.Body().BgColor) - assert.Equal(t, "black", s.Table().BgColor) + assert.Equal(t, "cadetblue", s.Body().FgColor.String()) + assert.Equal(t, "black", s.Body().BgColor.String()) + assert.Equal(t, "black", s.Table().BgColor.String()) assert.Equal(t, tcell.ColorCadetBlue, s.FgColor()) assert.Equal(t, tcell.ColorBlack, s.BgColor()) assert.Equal(t, tcell.ColorBlack, tview.Styles.PrimitiveBackgroundColor) @@ -40,12 +40,12 @@ func TestSkinNone(t *testing.T) { func TestSkin(t *testing.T) { s := config.NewStyles() - assert.Nil(t, s.Load("test_assets/black_and_wtf.yml")) + assert.Nil(t, s.Load("testdata/black_and_wtf.yml")) s.Update() - assert.Equal(t, "white", s.Body().FgColor) - assert.Equal(t, "black", s.Body().BgColor) - assert.Equal(t, "black", s.Table().BgColor) + assert.Equal(t, "white", s.Body().FgColor.String()) + assert.Equal(t, "black", s.Body().BgColor.String()) + assert.Equal(t, "black", s.Table().BgColor.String()) assert.Equal(t, tcell.ColorWhite, s.FgColor()) assert.Equal(t, tcell.ColorBlack, s.BgColor()) assert.Equal(t, tcell.ColorBlack, tview.Styles.PrimitiveBackgroundColor) @@ -53,10 +53,10 @@ func TestSkin(t *testing.T) { func TestSkinNotExits(t *testing.T) { s := config.NewStyles() - assert.NotNil(t, s.Load("test_assets/blee.yml")) + assert.NotNil(t, s.Load("testdata/blee.yml")) } func TestSkinBoarked(t *testing.T) { s := config.NewStyles() - assert.NotNil(t, s.Load("test_assets/skin_boarked.yml")) + assert.NotNil(t, s.Load("testdata/skin_boarked.yml")) } diff --git a/internal/config/test_assets/alias.yml b/internal/config/testdata/alias.yml similarity index 100% rename from internal/config/test_assets/alias.yml rename to internal/config/testdata/alias.yml diff --git a/internal/config/test_assets/b_containers.yml b/internal/config/testdata/b_containers.yml similarity index 100% rename from internal/config/test_assets/b_containers.yml rename to internal/config/testdata/b_containers.yml diff --git a/internal/config/test_assets/b_containers_1.yml b/internal/config/testdata/b_containers_1.yml similarity index 100% rename from internal/config/test_assets/b_containers_1.yml rename to internal/config/testdata/b_containers_1.yml diff --git a/internal/config/test_assets/b_good.yml b/internal/config/testdata/b_good.yml similarity index 100% rename from internal/config/test_assets/b_good.yml rename to internal/config/testdata/b_good.yml diff --git a/internal/config/test_assets/b_toast.yml b/internal/config/testdata/b_toast.yml similarity index 100% rename from internal/config/test_assets/b_toast.yml rename to internal/config/testdata/b_toast.yml diff --git a/internal/config/test_assets/bench-fred.yml b/internal/config/testdata/bench-fred.yml similarity index 100% rename from internal/config/test_assets/bench-fred.yml rename to internal/config/testdata/bench-fred.yml diff --git a/internal/config/test_assets/black_and_wtf.yml b/internal/config/testdata/black_and_wtf.yml similarity index 100% rename from internal/config/test_assets/black_and_wtf.yml rename to internal/config/testdata/black_and_wtf.yml diff --git a/internal/config/test_assets/empty_skin.yml b/internal/config/testdata/empty_skin.yml similarity index 100% rename from internal/config/test_assets/empty_skin.yml rename to internal/config/testdata/empty_skin.yml diff --git a/internal/config/test_assets/hot_key.yml b/internal/config/testdata/hot_key.yml similarity index 100% rename from internal/config/test_assets/hot_key.yml rename to internal/config/testdata/hot_key.yml diff --git a/internal/config/test_assets/k8s.yml b/internal/config/testdata/k8s.yml similarity index 100% rename from internal/config/test_assets/k8s.yml rename to internal/config/testdata/k8s.yml diff --git a/internal/config/test_assets/k9s.yml b/internal/config/testdata/k9s.yml similarity index 100% rename from internal/config/test_assets/k9s.yml rename to internal/config/testdata/k9s.yml diff --git a/internal/config/test_assets/k9s1.yml b/internal/config/testdata/k9s1.yml similarity index 100% rename from internal/config/test_assets/k9s1.yml rename to internal/config/testdata/k9s1.yml diff --git a/internal/config/test_assets/k9s_old.yml b/internal/config/testdata/k9s_old.yml similarity index 100% rename from internal/config/test_assets/k9s_old.yml rename to internal/config/testdata/k9s_old.yml diff --git a/internal/config/test_assets/k9s_toast.yml b/internal/config/testdata/k9s_toast.yml similarity index 100% rename from internal/config/test_assets/k9s_toast.yml rename to internal/config/testdata/k9s_toast.yml diff --git a/internal/config/test_assets/kubeconfig-test.yml b/internal/config/testdata/kubeconfig-test.yml similarity index 100% rename from internal/config/test_assets/kubeconfig-test.yml rename to internal/config/testdata/kubeconfig-test.yml diff --git a/internal/config/test_assets/plugin.yml b/internal/config/testdata/plugin.yml similarity index 100% rename from internal/config/test_assets/plugin.yml rename to internal/config/testdata/plugin.yml diff --git a/internal/config/test_assets/skin_boarked.yml b/internal/config/testdata/skin_boarked.yml similarity index 100% rename from internal/config/test_assets/skin_boarked.yml rename to internal/config/testdata/skin_boarked.yml diff --git a/internal/dao/benchmark_test.go b/internal/dao/benchmark_test.go index 767abef1..b3e52f21 100644 --- a/internal/dao/benchmark_test.go +++ b/internal/dao/benchmark_test.go @@ -15,10 +15,10 @@ func TestBenchmarkList(t *testing.T) { a := dao.Benchmark{} a.Init(makeFactory(), client.NewGVR("benchmarks")) - ctx := context.WithValue(context.Background(), internal.KeyDir, "test_assets/bench") + ctx := context.WithValue(context.Background(), internal.KeyDir, "testdata/bench") oo, err := a.List(ctx, "-") assert.Nil(t, err) assert.Equal(t, 1, len(oo)) - assert.Equal(t, "test_assets/bench/default_fred_1577308050814961000.txt", oo[0].(render.BenchInfo).Path) + assert.Equal(t, "testdata/bench/default_fred_1577308050814961000.txt", oo[0].(render.BenchInfo).Path) } diff --git a/internal/dao/chart.go b/internal/dao/chart.go index e69aa050..a407bd0d 100644 --- a/internal/dao/chart.go +++ b/internal/dao/chart.go @@ -90,7 +90,6 @@ func (c *Chart) ToYAML(path string) (string, error) { // Delete uninstall a Chart. func (c *Chart) Delete(path string, cascade, force bool) error { - log.Debug().Msgf("CHART DELETE %q", path) ns, n := client.Namespaced(path) cfg, err := c.EnsureHelmConfig(ns) if err != nil { diff --git a/internal/dao/container.go b/internal/dao/container.go index 60a8ac3c..bedfd2e8 100644 --- a/internal/dao/container.go +++ b/internal/dao/container.go @@ -33,23 +33,21 @@ func (c *Container) List(ctx context.Context, _ string) ([]runtime.Object, error if !ok { return nil, fmt.Errorf("no context path for %q", c.gvr) } + + var ( + pmx *mv1beta1.PodMetrics + err error + ) + if withMx, ok := ctx.Value(internal.KeyWithMetrics).(bool); withMx || !ok { + if pmx, err = client.DialMetrics(c.Client()).FetchPodMetrics(fqn); err != nil { + log.Warn().Err(err).Msgf("No metrics found for pod %q", fqn) + } + } + po, err := c.fetchPod(fqn) if err != nil { return nil, err } - - var pmx *mv1beta1.PodMetrics - if c.Client().HasMetrics() { - mx := client.NewMetricsServer(c.Client()) - if c.Client() != nil { - var err error - pmx, err = mx.FetchPodMetrics(fqn) - if err != nil { - log.Warn().Err(err).Msgf("No metrics found for pod %q", fqn) - } - } - } - res := make([]runtime.Object, 0, len(po.Spec.InitContainers)+len(po.Spec.Containers)) for _, co := range po.Spec.InitContainers { res = append(res, makeContainerRes(co, po, pmx, true)) @@ -62,7 +60,7 @@ func (c *Container) List(ctx context.Context, _ string) ([]runtime.Object, error } // TailLogs tails a given container logs -func (c *Container) TailLogs(ctx context.Context, logChan chan<- string, opts LogOptions) error { +func (c *Container) TailLogs(ctx context.Context, logChan chan<- []byte, opts LogOptions) error { fac, ok := ctx.Value(internal.KeyFactory).(Factory) if !ok { return errors.New("Expecting an informer") diff --git a/internal/dao/container_test.go b/internal/dao/container_test.go index 2b61a9d1..a486017f 100644 --- a/internal/dao/container_test.go +++ b/internal/dao/container_test.go @@ -44,7 +44,7 @@ func makeConn() *conn { func (c *conn) Config() *client.Config { return nil } func (c *conn) DialOrDie() kubernetes.Interface { return nil } -func (c *conn) SwitchContextOrDie(ctx string) {} +func (c *conn) SwitchContext(ctx string) error { return nil } func (c *conn) CachedDiscoveryOrDie() *disk.CachedDiscoveryClient { return nil } func (c *conn) RestConfigOrDie() *restclient.Config { return nil } func (c *conn) MXDial() (*versioned.Clientset, error) { return nil, nil } diff --git a/internal/dao/context.go b/internal/dao/context.go index 1741dbd1..da13e7b2 100644 --- a/internal/dao/context.go +++ b/internal/dao/context.go @@ -58,8 +58,7 @@ func (c *Context) MustCurrentContextName() string { // Switch to another context. func (c *Context) Switch(ctx string) error { - c.Factory.Client().SwitchContextOrDie(ctx) - return nil + return c.Factory.Client().SwitchContext(ctx) } // KubeUpdate modifies kubeconfig default context. diff --git a/internal/dao/crd.go b/internal/dao/crd.go index 18ff56f6..c329560f 100644 --- a/internal/dao/crd.go +++ b/internal/dao/crd.go @@ -21,11 +21,11 @@ type CustomResourceDefinition struct { // List returns a collection of nodes. func (c *CustomResourceDefinition) List(ctx context.Context, _ string) ([]runtime.Object, error) { strLabel, ok := ctx.Value(internal.KeyLabels).(string) - lsel := labels.Everything() + labelSel := labels.Everything() if sel, e := labels.ConvertSelectorToLabelsMap(strLabel); ok && e == nil { - lsel = sel.AsSelector() + labelSel = sel.AsSelector() } const gvr = "apiextensions.k8s.io/v1beta1/customresourcedefinitions" - return c.Factory.List(gvr, "-", true, lsel) + return c.Factory.List(gvr, "-", true, labelSel) } diff --git a/internal/dao/cruiser_test.go b/internal/dao/cruiser_test.go index 33981345..8574d8a4 100644 --- a/internal/dao/cruiser_test.go +++ b/internal/dao/cruiser_test.go @@ -29,7 +29,7 @@ func TestCruiserSlice(t *testing.T) { // Helpers... func loadJSON(t assert.TestingT, n string) *unstructured.Unstructured { - raw, err := ioutil.ReadFile(fmt.Sprintf("test_assets/%s.json", n)) + raw, err := ioutil.ReadFile(fmt.Sprintf("testdata/%s.json", n)) assert.Nil(t, err) var o unstructured.Unstructured diff --git a/internal/dao/dp.go b/internal/dao/dp.go index 43ab784a..4b9216c6 100644 --- a/internal/dao/dp.go +++ b/internal/dao/dp.go @@ -21,6 +21,7 @@ var ( _ Loggable = (*Deployment)(nil) _ Restartable = (*Deployment)(nil) _ Scalable = (*Deployment)(nil) + _ Controller = (*Deployment)(nil) ) // Deployment represents a deployment K8s resource. @@ -28,6 +29,11 @@ type Deployment struct { Resource } +// IsHappy check for happy deployments. +func (d *Deployment) IsHappy(dp appsv1.Deployment) bool { + return dp.Status.Replicas == dp.Status.AvailableReplicas +} + // Scale a Deployment. func (d *Deployment) Scale(path string, replicas int32) error { ns, n := client.Namespaced(path) @@ -51,12 +57,7 @@ func (d *Deployment) Scale(path string, replicas int32) error { // Restart a Deployment rollout. func (d *Deployment) Restart(path string) error { - o, err := d.Factory.Get(d.gvr.String(), path, true, labels.Everything()) - if err != nil { - return err - } - var ds appsv1.Deployment - err = runtime.DefaultUnstructuredConverter.FromUnstructured(o.(*unstructured.Unstructured).Object, &ds) + dp, err := d.GetInstance(path) if err != nil { return err } @@ -69,30 +70,50 @@ func (d *Deployment) Restart(path string) error { if !auth { return fmt.Errorf("user is not authorized to restart a deployment") } - update, err := polymorphichelpers.ObjectRestarterFn(&ds) + update, err := polymorphichelpers.ObjectRestarterFn(dp) if err != nil { return err } - _, err = d.Client().DialOrDie().AppsV1().Deployments(ds.Namespace).Patch(ds.Name, types.StrategicMergePatchType, update) + _, err = d.Client().DialOrDie().AppsV1().Deployments(dp.Namespace).Patch(dp.Name, types.StrategicMergePatchType, update) return err } // TailLogs tail logs for all pods represented by this Deployment. -func (d *Deployment) TailLogs(ctx context.Context, c chan<- string, opts LogOptions) error { - o, err := d.Factory.Get(d.gvr.String(), opts.Path, true, labels.Everything()) +func (d *Deployment) TailLogs(ctx context.Context, c chan<- []byte, opts LogOptions) error { + dp, err := d.GetInstance(opts.Path) if err != nil { return err } - - var dp appsv1.Deployment - err = runtime.DefaultUnstructuredConverter.FromUnstructured(o.(*unstructured.Unstructured).Object, &dp) - if err != nil { - return errors.New("expecting Deployment resource") - } if dp.Spec.Selector == nil || len(dp.Spec.Selector.MatchLabels) == 0 { return fmt.Errorf("No valid selector found on Deployment %s", opts.Path) } return podLogs(ctx, c, dp.Spec.Selector.MatchLabels, opts) } + +// Pod returns a pod victim by name. +func (d *Deployment) Pod(fqn string) (string, error) { + dp, err := d.GetInstance(fqn) + if err != nil { + return "", err + } + + return podFromSelector(d.Factory, dp.Namespace, dp.Spec.Selector.MatchLabels) +} + +// GetInstance returns a deployment instance. +func (d *Deployment) GetInstance(fqn string) (*appsv1.Deployment, error) { + o, err := d.Factory.Get(d.gvr.String(), fqn, false, labels.Everything()) + if err != nil { + return nil, err + } + + var dp appsv1.Deployment + err = runtime.DefaultUnstructuredConverter.FromUnstructured(o.(*unstructured.Unstructured).Object, &dp) + if err != nil { + return nil, errors.New("expecting Deployment resource") + } + + return &dp, nil +} diff --git a/internal/dao/ds.go b/internal/dao/ds.go index c957766b..22ca93bc 100644 --- a/internal/dao/ds.go +++ b/internal/dao/ds.go @@ -9,7 +9,6 @@ import ( "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/watch" - "github.com/rs/zerolog/log" appsv1 "k8s.io/api/apps/v1" v1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -25,6 +24,7 @@ var ( _ Nuker = (*DaemonSet)(nil) _ Loggable = (*DaemonSet)(nil) _ Restartable = (*DaemonSet)(nil) + _ Controller = (*DaemonSet)(nil) ) // DaemonSet represents a K8s daemonset. @@ -32,14 +32,14 @@ type DaemonSet struct { Resource } +// IsHappy check for happy deployments. +func (d *DaemonSet) IsHappy(ds appsv1.DaemonSet) bool { + return ds.Status.DesiredNumberScheduled == ds.Status.CurrentNumberScheduled +} + // Restart a DaemonSet rollout. func (d *DaemonSet) Restart(path string) error { - o, err := d.Factory.Get(d.gvr.String(), path, true, labels.Everything()) - if err != nil { - return err - } - var ds appsv1.DaemonSet - err = runtime.DefaultUnstructuredConverter.FromUnstructured(o.(*unstructured.Unstructured).Object, &ds) + ds, err := d.GetInstance(path) if err != nil { return err } @@ -51,26 +51,21 @@ func (d *DaemonSet) Restart(path string) error { if !auth { return fmt.Errorf("user is not authorized to restart a daemonset") } - update, err := polymorphichelpers.ObjectRestarterFn(&ds) + update, err := polymorphichelpers.ObjectRestarterFn(ds) if err != nil { return err } - _, err = d.Client().DialOrDie().AppsV1().DaemonSets(ds.Namespace).Patch(ds.Name, types.StrategicMergePatchType, update) + _, err = d.Client().DialOrDie().AppsV1().DaemonSets(ds.Namespace).Patch(ds.Name, types.StrategicMergePatchType, update) return err } // TailLogs tail logs for all pods represented by this DaemonSet. -func (d *DaemonSet) TailLogs(ctx context.Context, c chan<- string, opts LogOptions) error { - o, err := d.Factory.Get(d.gvr.String(), opts.Path, true, labels.Everything()) +func (d *DaemonSet) TailLogs(ctx context.Context, c chan<- []byte, opts LogOptions) error { + ds, err := d.GetInstance(opts.Path) if err != nil { return err } - var ds appsv1.DaemonSet - err = runtime.DefaultUnstructuredConverter.FromUnstructured(o.(*unstructured.Unstructured).Object, &ds) - if err != nil { - return errors.New("expecting daemonset resource") - } if ds.Spec.Selector == nil || len(ds.Spec.Selector.MatchLabels) == 0 { return fmt.Errorf("no valid selector found on daemonset %q", opts.Path) @@ -79,7 +74,7 @@ func (d *DaemonSet) TailLogs(ctx context.Context, c chan<- string, opts LogOptio return podLogs(ctx, c, ds.Spec.Selector.MatchLabels, opts) } -func podLogs(ctx context.Context, c chan<- string, sel map[string]string, opts LogOptions) error { +func podLogs(ctx context.Context, c chan<- []byte, sel map[string]string, opts LogOptions) error { f, ok := ctx.Value(internal.KeyFactory).(*watch.Factory) if !ok { return errors.New("expecting a context factory") @@ -94,7 +89,7 @@ func podLogs(ctx context.Context, c chan<- string, sel map[string]string, opts L } ns, _ := client.Namespaced(opts.Path) - oo, err := f.List("v1/pods", ns, true, lsel) + oo, err := f.List("v1/pods", ns, false, lsel) if err != nil { return err } @@ -111,7 +106,6 @@ func podLogs(ctx context.Context, c chan<- string, sel map[string]string, opts L if err != nil { return err } - log.Debug().Msgf("TAILING logs on pod %q", pod.Name) opts.Path = client.FQN(pod.Namespace, pod.Name) if err := po.TailLogs(ctx, c, opts); err != nil { return err @@ -120,6 +114,32 @@ func podLogs(ctx context.Context, c chan<- string, sel map[string]string, opts L return nil } +// Pod returns a pod victim by name. +func (d *DaemonSet) Pod(fqn string) (string, error) { + ds, err := d.GetInstance(fqn) + if err != nil { + return "", err + } + + return podFromSelector(d.Factory, ds.Namespace, ds.Spec.Selector.MatchLabels) +} + +// GetInstance returns a daemonset instance. +func (d *DaemonSet) GetInstance(fqn string) (*appsv1.DaemonSet, error) { + o, err := d.Factory.Get(d.gvr.String(), fqn, false, labels.Everything()) + if err != nil { + return nil, err + } + + var ds appsv1.DaemonSet + err = runtime.DefaultUnstructuredConverter.FromUnstructured(o.(*unstructured.Unstructured).Object, &ds) + if err != nil { + return nil, errors.New("expecting DaemonSet resource") + } + + return &ds, nil +} + // ---------------------------------------------------------------------------- // Helpers... diff --git a/internal/dao/generic.go b/internal/dao/generic.go index 4d60762d..7963019e 100644 --- a/internal/dao/generic.go +++ b/internal/dao/generic.go @@ -28,7 +28,7 @@ type Generic struct { func (g *Generic) List(ctx context.Context, ns string) ([]runtime.Object, error) { labelSel, ok := ctx.Value(internal.KeyLabels).(string) if !ok { - log.Warn().Msgf("No label selector found in context. Listing all resources") + log.Debug().Msgf("No label selector found in context. Listing all resources") } if client.IsAllNamespace(ns) { ns = client.AllNamespaces diff --git a/internal/dao/helpers.go b/internal/dao/helpers.go index e893e3b2..718ad677 100644 --- a/internal/dao/helpers.go +++ b/internal/dao/helpers.go @@ -29,6 +29,7 @@ func ToYAML(o runtime.Object) (string, error) { if o == nil { return "", errors.New("no object to yamlize") } + var ( buff bytes.Buffer p printers.YAMLPrinter diff --git a/internal/dao/job.go b/internal/dao/job.go index ff96a6d4..5f002804 100644 --- a/internal/dao/job.go +++ b/internal/dao/job.go @@ -23,7 +23,7 @@ type Job struct { } // TailLogs tail logs for all pods represented by this Job. -func (j *Job) TailLogs(ctx context.Context, c chan<- string, opts LogOptions) error { +func (j *Job) TailLogs(ctx context.Context, c chan<- []byte, opts LogOptions) error { o, err := j.Factory.Get(j.gvr.String(), opts.Path, true, labels.Everything()) if err != nil { return err diff --git a/internal/dao/log_options.go b/internal/dao/log_options.go index ce31c575..67e5e38c 100644 --- a/internal/dao/log_options.go +++ b/internal/dao/log_options.go @@ -47,19 +47,26 @@ func colorize(c color.Paint, txt string) string { } // DecorateLog add a log header to display po/co information along with the log message. -func (o LogOptions) DecorateLog(msg string) string { - _, n := client.Namespaced(o.Path) - if msg == "" { - return msg +func (o LogOptions) DecorateLog(bytes []byte) []byte { + if len(bytes) == 0 { + return bytes } + bytes = bytes[:len(bytes)-1] + _, n := client.Namespaced(o.Path) + + var prefix []byte if o.MultiPods { - return colorize(o.Color, n+":"+o.Container+" ") + msg + prefix = []byte(colorize(o.Color, n+":"+o.Container+" ")) } if !o.SingleContainer { - return colorize(o.Color, o.Container+" ") + msg + prefix = []byte(colorize(o.Color, o.Container+" ")) } - return msg + if len(prefix) == 0 { + return bytes + } + + return append(prefix, bytes...) } diff --git a/internal/dao/node.go b/internal/dao/node.go index c821fdc1..27628b7b 100644 --- a/internal/dao/node.go +++ b/internal/dao/node.go @@ -29,17 +29,19 @@ type Node struct { // List returns a collection of node resources. func (n *Node) List(ctx context.Context, ns string) ([]runtime.Object, error) { - log.Debug().Msgf("NODE-LIST %q:%q", ns, n.gvr) - labels, ok := ctx.Value(internal.KeyLabels).(string) if !ok { log.Warn().Msgf("No label selector found in context") } - mx := client.NewMetricsServer(n.Client()) - nmx, err := mx.FetchNodesMetrics() - if err != nil { - log.Warn().Err(err).Msgf("No node metrics") + var ( + nmx *mv1beta1.NodeMetricsList + err error + ) + if withMx, ok := ctx.Value(internal.KeyWithMetrics).(bool); withMx || !ok { + if nmx, err = client.DialMetrics(n.Client()).FetchNodesMetrics(); err != nil { + log.Warn().Err(err).Msgf("No node metrics") + } } nn, err := FetchNodes(n.Factory, labels) diff --git a/internal/dao/ofaas.go b/internal/dao/ofaas.go new file mode 100644 index 00000000..123e702c --- /dev/null +++ b/internal/dao/ofaas.go @@ -0,0 +1,219 @@ +package dao + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "net/http" + "net/url" + "os" + "path" + "strings" + "time" + + "github.com/derailed/k9s/internal/client" + "github.com/derailed/k9s/internal/render" + "github.com/openfaas/faas-cli/proxy" + "github.com/openfaas/faas/gateway/requests" + "github.com/rs/zerolog/log" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/yaml" +) + +const ( + oFaasGatewayEnv = "OPENFAAS_GATEWAY" + oFaasJWTTokenEnv = "OPENFAAS_JWT_TOKEN" + oFaasTLSInsecure = "OPENFAAS_TLS_INSECURE" +) + +var ( + _ Accessor = (*OpenFaas)(nil) + _ Nuker = (*OpenFaas)(nil) + _ Describer = (*OpenFaas)(nil) +) + +// OpenFaas represents a faas gateway connection. +type OpenFaas struct { + NonResource +} + +// IsOpenFaasEnabled returns true if a gateway url is set in the environment. +func IsOpenFaasEnabled() bool { + return os.Getenv(oFaasGatewayEnv) != "" +} + +func getOpenFAASFlags() (string, string, bool) { + gw, token := os.Getenv(oFaasGatewayEnv), os.Getenv(oFaasJWTTokenEnv) + tlsInsecure := false + if os.Getenv(oFaasTLSInsecure) == "true" { + tlsInsecure = true + } + + return gw, token, tlsInsecure +} + +// Get returns a function by name. +func (f *OpenFaas) Get(ctx context.Context, path string) (runtime.Object, error) { + ns, n := client.Namespaced(path) + + oo, err := f.List(ctx, ns) + if err != nil { + return nil, err + } + + var found runtime.Object + for _, o := range oo { + r, ok := o.(render.OpenFaasRes) + if !ok { + continue + } + if r.Function.Name == n { + found = o + break + } + } + + if found == nil { + return nil, fmt.Errorf("unable to locate function %q", path) + } + + return found, nil +} + +// List returns a collection of functions. +func (f *OpenFaas) List(_ context.Context, ns string) ([]runtime.Object, error) { + if !IsOpenFaasEnabled() { + return nil, errors.New("OpenFAAS is not enabled on this cluster") + } + + gw, token, tls := getOpenFAASFlags() + ff, err := proxy.ListFunctionsToken(gw, tls, token, ns) + if err != nil { + return nil, err + } + + oo := make([]runtime.Object, 0, len(ff)) + for _, f := range ff { + oo = append(oo, render.OpenFaasRes{Function: f}) + } + + return oo, nil +} + +// Delete removes a function. +func (f *OpenFaas) Delete(path string, _, _ bool) error { + gw, token, tls := getOpenFAASFlags() + ns, n := client.Namespaced(path) + + // BOZO!! openfaas spews to stdout. Not good for us... + return deleteFunctionToken(gw, n, tls, token, ns) +} + +// ToYAML dumps a function to yaml. +func (f *OpenFaas) ToYAML(path string) (string, error) { + return f.Describe(path) +} + +// Describe describes a function. +func (f *OpenFaas) Describe(path string) (string, error) { + o, err := f.Get(context.Background(), path) + if err != nil { + return "", err + } + + fn, ok := o.(render.OpenFaasRes) + if !ok { + return "", fmt.Errorf("expecting OpenFaasRes but got %T", o) + } + + raw, err := json.Marshal(fn) + if err != nil { + return "", err + } + + bytes, err := yaml.JSONToYAML(raw) + if err != nil { + return "", err + } + + return string(bytes), nil +} + +// BOZO!! Meow! openfaas fn prints to stdout have to dup ;( +func deleteFunctionToken(gateway string, functionName string, tlsInsecure bool, token string, namespace string) error { + defaultCommandTimeout := 60 * time.Second + + gateway = strings.TrimRight(gateway, "/") + delReq := requests.DeleteFunctionRequest{FunctionName: functionName} + reqBytes, _ := json.Marshal(&delReq) + reader := bytes.NewReader(reqBytes) + + c := proxy.MakeHTTPClient(&defaultCommandTimeout, tlsInsecure) + + deleteEndpoint, err := createSystemEndpoint(gateway, namespace) + if err != nil { + return err + } + + req, err := http.NewRequest("DELETE", deleteEndpoint, reader) + if err != nil { + fmt.Println(err) + return err + } + req.Header.Set("Content-Type", "application/json") + + if len(token) > 0 { + proxy.SetToken(req, token) + } else { + proxy.SetAuth(req, gateway) + } + + delRes, delErr := c.Do(req) + + if delErr != nil { + fmt.Printf("Error removing existing function: %s, gateway=%s, functionName=%s\n", delErr.Error(), gateway, functionName) + return delErr + } + + if delRes.Body != nil { + defer func() { + if err := delRes.Body.Close(); err != nil { + log.Error().Err(err).Msgf("closing delete-gtw body") + } + }() + } + + switch delRes.StatusCode { + case http.StatusOK, http.StatusCreated, http.StatusAccepted: + return nil + case http.StatusNotFound: + return fmt.Errorf("no function named %s found", functionName) + case http.StatusUnauthorized: + return fmt.Errorf("unauthorized access, run \"faas-cli login\" to setup authentication for this server") + default: + bytesOut, err := ioutil.ReadAll(delRes.Body) + if err != nil { + return err + } + return fmt.Errorf("server returned unexpected status code %d %s", delRes.StatusCode, string(bytesOut)) + } +} + +func createSystemEndpoint(gateway, namespace string) (string, error) { + const systemPath = "/system/functions" + + gatewayURL, err := url.Parse(gateway) + if err != nil { + return "", fmt.Errorf("invalid gateway URL: %s", err.Error()) + } + gatewayURL.Path = path.Join(gatewayURL.Path, systemPath) + if len(namespace) > 0 { + q := gatewayURL.Query() + q.Set("namespace", namespace) + gatewayURL.RawQuery = q.Encode() + } + return gatewayURL.String(), nil +} diff --git a/internal/dao/pod.go b/internal/dao/pod.go index 1258af29..9f97f709 100644 --- a/internal/dao/pod.go +++ b/internal/dao/pod.go @@ -27,9 +27,10 @@ import ( const defaultTimeout = 1 * time.Second var ( - _ Accessor = (*Pod)(nil) - _ Nuker = (*Pod)(nil) - _ Loggable = (*Pod)(nil) + _ Accessor = (*Pod)(nil) + _ Nuker = (*Pod)(nil) + _ Loggable = (*Pod)(nil) + _ Controller = (*Pod)(nil) ) // Pod represents a pod resource. @@ -37,6 +38,16 @@ type Pod struct { Resource } +// IsHappy check for happy deployments. +func (p *Pod) IsHappy(po v1.Pod) bool { + for _, c := range po.Status.Conditions { + if c.Status == v1.ConditionFalse { + return false + } + } + return true +} + // Get returns a resource instance if found, else an error. func (p *Pod) Get(ctx context.Context, path string) (runtime.Object, error) { o, err := p.Resource.Get(ctx, path) @@ -49,11 +60,11 @@ func (p *Pod) Get(ctx context.Context, path string) (runtime.Object, error) { return nil, fmt.Errorf("expecting *unstructured.Unstructured but got `%T", o) } - // No Deal! - mx := client.NewMetricsServer(p.Client()) - pmx, err := mx.FetchPodMetrics(path) - if err != nil { - log.Warn().Err(err).Msgf("No pods metrics") + var pmx *mv1beta1.PodMetrics + if withMx, ok := ctx.Value(internal.KeyWithMetrics).(bool); withMx || !ok { + if pmx, err = client.DialMetrics(p.Client()).FetchPodMetrics(path); err != nil { + log.Warn().Err(err).Msgf("No pod metrics") + } } return &render.PodWithMetrics{Raw: u, MX: pmx}, nil @@ -76,10 +87,11 @@ func (p *Pod) List(ctx context.Context, ns string) ([]runtime.Object, error) { return oo, err } - mx := client.NewMetricsServer(p.Client()) - pmx, err := mx.FetchPodsMetrics(ns) - if err != nil { - log.Warn().Err(err).Msgf("No pods metrics") + var pmx *mv1beta1.PodMetricsList + if withMx, ok := ctx.Value(internal.KeyWithMetrics).(bool); withMx || !ok { + if pmx, err = client.DialMetrics(p.Client()).FetchPodsMetrics(ns); err != nil { + log.Warn().Err(err).Msgf("No pods metrics") + } } var res []runtime.Object @@ -122,18 +134,12 @@ func (p *Pod) Logs(path string, opts *v1.PodLogOptions) (*restclient.Request, er // Containers returns all container names on pod func (p *Pod) Containers(path string, includeInit bool) ([]string, error) { - o, err := p.Factory.Get(p.gvr.String(), path, true, labels.Everything()) + pod, err := p.GetInstance(path) if err != nil { return nil, err } - var pod v1.Pod - err = runtime.DefaultUnstructuredConverter.FromUnstructured(o.(*unstructured.Unstructured).Object, &pod) - if err != nil { - return nil, err - } - - cc := []string{} + cc := make([]string, 0, len(pod.Spec.Containers)+len(pod.Spec.InitContainers)) for _, c := range pod.Spec.Containers { cc = append(cc, c.Name) } @@ -147,15 +153,36 @@ func (p *Pod) Containers(path string, includeInit bool) ([]string, error) { return cc, nil } +// Pod returns a pod victim by name. +func (p *Pod) Pod(fqn string) (string, error) { + return fqn, nil +} + +// GetInstance returns a pod instance. +func (p *Pod) GetInstance(fqn string) (*v1.Pod, error) { + o, err := p.Factory.Get(p.gvr.String(), fqn, false, labels.Everything()) + if err != nil { + return nil, err + } + + var pod v1.Pod + err = runtime.DefaultUnstructuredConverter.FromUnstructured(o.(*unstructured.Unstructured).Object, &pod) + if err != nil { + return nil, err + } + + return &pod, nil +} + // TailLogs tails a given container logs -func (p *Pod) TailLogs(ctx context.Context, c chan<- string, opts LogOptions) error { +func (p *Pod) TailLogs(ctx context.Context, c chan<- []byte, opts LogOptions) error { if !opts.HasContainer() { return p.logs(ctx, c, opts) } return tailLogs(ctx, p, c, opts) } -func (p *Pod) logs(ctx context.Context, c chan<- string, opts LogOptions) error { +func (p *Pod) logs(ctx context.Context, c chan<- []byte, opts LogOptions) error { fac, ok := ctx.Value(internal.KeyFactory).(*watch.Factory) if !ok { return errors.New("Expecting an informer") @@ -194,7 +221,7 @@ func (p *Pod) logs(ctx context.Context, c chan<- string, opts LogOptions) error return nil } -func tailLogs(ctx context.Context, logger Logger, c chan<- string, opts LogOptions) error { +func tailLogs(ctx context.Context, logger Logger, c chan<- []byte, opts LogOptions) error { log.Debug().Msgf("Tailing logs for %q -- %q", opts.Path, opts.Container) o := v1.PodLogOptions{ Container: opts.Container, @@ -206,11 +233,13 @@ func tailLogs(ctx context.Context, logger Logger, c chan<- string, opts LogOptio if err != nil { return err } - ctxt, cancelFunc := context.WithCancel(ctx) - req.Context(ctxt) + + var cancel context.CancelFunc + ctx, cancel = context.WithCancel(ctx) + req.Context(ctx) var blocked int32 = 1 - go logsTimeout(cancelFunc, &blocked) + go logsTimeout(cancel, &blocked) // This call will block if nothing is in the stream!! stream, err := req.Stream() @@ -219,7 +248,7 @@ func tailLogs(ctx context.Context, logger Logger, c chan<- string, opts LogOptio log.Error().Err(err).Msgf("Log stream failed for `%s", opts.Path) return fmt.Errorf("Unable to obtain log stream for %s", opts.Path) } - go readLogs(ctx, stream, c, opts) + go readLogs(stream, c, opts) return nil } @@ -232,7 +261,7 @@ func logsTimeout(cancel context.CancelFunc, blocked *int32) { } } -func readLogs(ctx context.Context, stream io.ReadCloser, c chan<- string, opts LogOptions) { +func readLogs(stream io.ReadCloser, c chan<- []byte, opts LogOptions) { defer func() { log.Debug().Msgf(">>> Closing stream `%s", opts.Path) if err := stream.Close(); err != nil { @@ -240,16 +269,18 @@ func readLogs(ctx context.Context, stream io.ReadCloser, c chan<- string, opts L } }() - scanner := bufio.NewScanner(stream) - for scanner.Scan() { - select { - case <-ctx.Done(): + r := bufio.NewReader(stream) + for { + bytes, err := r.ReadBytes('\n') + if err != nil { + log.Warn().Err(err).Msg("Read error") + if err != io.EOF { + log.Error().Err(err).Msgf("stream reader failed") + } return - default: - c <- opts.DecorateLog(scanner.Text()) } + c <- opts.DecorateLog(bytes) } - log.Error().Msgf("SCAN_ERR %#v", scanner.Err()) } // ---------------------------------------------------------------------------- diff --git a/internal/dao/port_forward.go b/internal/dao/port_forward.go index 02852886..e88f7969 100644 --- a/internal/dao/port_forward.go +++ b/internal/dao/port_forward.go @@ -3,12 +3,14 @@ package dao import ( "context" "fmt" + "regexp" "strings" "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/render" + "github.com/rs/zerolog/log" "k8s.io/apimachinery/pkg/runtime" ) @@ -40,21 +42,26 @@ func (p *PortForward) Delete(path string, cascade, force bool) error { // List returns a collection of screen dumps. func (p *PortForward) List(ctx context.Context, _ string) ([]runtime.Object, error) { - config, ok := ctx.Value(internal.KeyBenchCfg).(*config.Bench) + benchFile, ok := ctx.Value(internal.KeyBenchCfg).(string) if !ok { - return nil, fmt.Errorf("no benchconfig found in context") + return nil, fmt.Errorf("no bench file found in context") + } + + config, err := config.NewBench(benchFile) + if err != nil { + log.Debug().Msgf("No custom benchmark config file found") } cc := config.Benchmarks.Containers oo := make([]runtime.Object, 0, len(p.Factory.Forwarders())) - for _, f := range p.Factory.Forwarders() { + for k, f := range p.Factory.Forwarders() { cfg := render.BenchCfg{ C: config.Benchmarks.Defaults.C, N: config.Benchmarks.Defaults.N, } - if config, ok := cc[containerID(f.Path(), f.Container())]; ok { - cfg.C, cfg.N = config.C, config.N - cfg.Host, cfg.Path = config.HTTP.Host, config.HTTP.Path + if cust, ok := cc[PodToKey(k)]; ok { + cfg.C, cfg.N = cust.C, cust.N + cfg.Host, cfg.Path = cust.HTTP.Host, cust.HTTP.Path } oo = append(oo, render.ForwardRes{ Forwarder: f, @@ -68,10 +75,31 @@ func (p *PortForward) List(ctx context.Context, _ string) ([]runtime.Object, err // ---------------------------------------------------------------------------- // Helpers... -// ContainerID computes container ID based on ns/po/co. -func containerID(path, co string) string { - ns, n := client.Namespaced(path) - po := strings.Split(n, "-")[0] +var podNameRX = regexp.MustCompile(`\A(.+)\-(\w{10})\-(\w{5})\z`) - return ns + "/" + po + ":" + co +// PodToKey converts a pod path to a generic bench config key +func PodToKey(path string) string { + tokens := strings.Split(path, ":") + ns, po := client.Namespaced(tokens[0]) + sections := podNameRX.FindStringSubmatch(po) + if len(sections) >= 1 { + po = sections[1] + } + return client.FQN(ns, po) + ":" + tokens[1] +} + +// BenchConfigFor returns a custom bench spec if defined otherwise returns the default one. +func BenchConfigFor(benchFile, path string) config.BenchConfig { + def := config.DefaultBenchSpec() + cust, err := config.NewBench(benchFile) + if err != nil { + log.Debug().Msgf("No custom benchmark config file found") + return def + } + if b, ok := cust.Benchmarks.Containers[PodToKey(path)]; ok { + return b + } + + def.C, def.N = cust.Benchmarks.Defaults.C, cust.Benchmarks.Defaults.N + return def } diff --git a/internal/dao/port_forward_test.go b/internal/dao/port_forward_test.go new file mode 100644 index 00000000..691eee7f --- /dev/null +++ b/internal/dao/port_forward_test.go @@ -0,0 +1,33 @@ +package dao_test + +import ( + "testing" + + "github.com/derailed/k9s/internal/config" + "github.com/derailed/k9s/internal/dao" + "github.com/stretchr/testify/assert" +) + +func TestBenchForConfig(t *testing.T) { + uu := map[string]struct { + file, key string + spec config.BenchConfig + }{ + "no_file": {file: "", key: "", spec: config.DefaultBenchSpec()}, + "spec": {file: "testdata/benchspec.yml", key: "default/nginx-123-456:nginx", spec: config.BenchConfig{ + C: 2, + N: 3000, + HTTP: config.HTTP{ + Method: "GET", + Path: "/", + }, + }}, + } + + for k := range uu { + u := uu[k] + t.Run(k, func(t *testing.T) { + assert.NotNil(t, u.spec, dao.BenchConfigFor(u.file, u.key)) + }) + } +} diff --git a/internal/dao/port_forwarder.go b/internal/dao/port_forwarder.go index 0b31cf19..f91175df 100644 --- a/internal/dao/port_forwarder.go +++ b/internal/dao/port_forwarder.go @@ -25,7 +25,7 @@ const localhost = "localhost" // PortForwarder tracks a port forward stream. type PortForwarder struct { - client.Connection + Factory genericclioptions.IOStreams stopChan, readyChan chan struct{} @@ -37,11 +37,11 @@ type PortForwarder struct { } // NewPortForwarder returns a new port forward streamer. -func NewPortForwarder(c client.Connection) *PortForwarder { +func NewPortForwarder(f Factory) *PortForwarder { return &PortForwarder{ - Connection: c, - stopChan: make(chan struct{}), - readyChan: make(chan struct{}), + Factory: f, + stopChan: make(chan struct{}), + readyChan: make(chan struct{}), } } @@ -67,7 +67,12 @@ func (p *PortForwarder) Ports() []string { // Path returns the pod resource path. func (p *PortForwarder) Path() string { - return p.path + ":" + p.container + return PortForwardID(p.path, p.container) +} + +// PortForwardID computes port-forward identifier. +func PortForwardID(path, co string) string { + return path + ":" + co } // Container returns the targetes container. @@ -87,19 +92,33 @@ func (p *PortForwarder) FQN() string { return p.path + ":" + p.container } +// HasPortMapping checks if port mapping is defined for this fwd. +func (p *PortForwarder) HasPortMapping(m string) bool { + for _, mapping := range p.ports { + if mapping == m { + return true + } + } + return false +} + // Start initiates a port forward session for a given pod and ports. -func (p *PortForwarder) Start(path, co, address string, ports []string) (*portforward.PortForwarder, error) { - p.path, p.container, p.ports, p.age = path, co, ports, time.Now() +func (p *PortForwarder) Start(path, co string, t client.PortTunnel) (*portforward.PortForwarder, error) { + fwds := []string{t.PortMap()} + p.path, p.container, p.ports, p.age = path, co, fwds, time.Now() ns, n := client.Namespaced(path) - auth, err := p.CanI(ns, "v1/pods", []string{client.GetVerb}) + auth, err := p.Client().CanI(ns, "v1/pods", []string{client.GetVerb}) if err != nil { return nil, err } if !auth { return nil, fmt.Errorf("user is not authorized to get pods") } - pod, err := p.DialOrDie().CoreV1().Pods(ns).Get(n, metav1.GetOptions{}) + + var res Pod + res.Init(p, client.NewGVR("v1/pods")) + pod, err := res.GetInstance(path) if err != nil { return nil, err } @@ -107,7 +126,7 @@ func (p *PortForwarder) Start(path, co, address string, ports []string) (*portfo return nil, fmt.Errorf("unable to forward port because pod is not running. Current status=%v", pod.Status.Phase) } - auth, err = p.CanI(ns, "v1/pods:portforward", []string{client.UpdateVerb}) + auth, err = p.Client().CanI(ns, "v1/pods:portforward", []string{client.UpdateVerb}) if err != nil { return nil, err } @@ -115,7 +134,7 @@ func (p *PortForwarder) Start(path, co, address string, ports []string) (*portfo return nil, fmt.Errorf("user is not authorized to update portforward") } - rcfg := p.RestConfigOrDie() + rcfg := p.Client().RestConfigOrDie() rcfg.GroupVersion = &schema.GroupVersion{Group: "", Version: "v1"} rcfg.APIPath = "/api" codec, _ := codec() @@ -131,11 +150,11 @@ func (p *PortForwarder) Start(path, co, address string, ports []string) (*portfo Name(n). SubResource("portforward") - return p.forwardPorts("POST", req.URL(), address, ports) + return p.forwardPorts("POST", req.URL(), t.Address, fwds) } func (p *PortForwarder) forwardPorts(method string, url *url.URL, address string, ports []string) (*portforward.PortForwarder, error) { - cfg, err := p.Config().RESTConfig() + cfg, err := p.Client().Config().RESTConfig() if err != nil { return nil, err } diff --git a/internal/dao/pulse.go b/internal/dao/pulse.go new file mode 100644 index 00000000..4c93ad38 --- /dev/null +++ b/internal/dao/pulse.go @@ -0,0 +1,18 @@ +package dao + +import ( + "context" + "fmt" + + "k8s.io/apimachinery/pkg/runtime" +) + +// Pulse tracks pulses. +type Pulse struct { + NonResource +} + +// List lists out pulses. +func (h *Pulse) List(ctx context.Context, ns string) ([]runtime.Object, error) { + return nil, fmt.Errorf("NYI") +} diff --git a/internal/dao/registry.go b/internal/dao/registry.go index 56799b5b..7eb00c9e 100644 --- a/internal/dao/registry.go +++ b/internal/dao/registry.go @@ -47,6 +47,7 @@ func AccessorFor(f Factory, gvr client.GVR) (Accessor, error) { client.NewGVR("batch/v1beta1/cronjobs"): &CronJob{}, client.NewGVR("batch/v1/jobs"): &Job{}, client.NewGVR("charts"): &Chart{}, + client.NewGVR("openfaas"): &OpenFaas{}, } r, ok := m[gvr] @@ -96,7 +97,7 @@ func (m *Meta) MetaFor(gvr client.GVR) (metav1.APIResource, error) { // IsK8sMeta checks for non resource meta. func IsK8sMeta(m metav1.APIResource) bool { for _, c := range m.Categories { - if c == "k9s" || c == "helm" { + if c == "k9s" || c == "helm" || c == "faas" { return false } } @@ -135,9 +136,19 @@ func loadNonResource(m ResourceMetas) { loadK9s(m) loadRBAC(m) loadHelm(m) + if IsOpenFaasEnabled() { + loadOpenFaas(m) + } } func loadK9s(m ResourceMetas) { + m[client.NewGVR("pulses")] = metav1.APIResource{ + Name: "pulses", + Kind: "Pulse", + SingularName: "pulses", + ShortNames: []string{"hz", "pu"}, + Categories: []string{"k9s"}, + } m[client.NewGVR("xrays")] = metav1.APIResource{ Name: "xray", Kind: "XRays", @@ -203,6 +214,17 @@ func loadHelm(m ResourceMetas) { } } +func loadOpenFaas(m ResourceMetas) { + m[client.NewGVR("openfaas")] = metav1.APIResource{ + Name: "openfaas", + Kind: "OpenFaaS", + ShortNames: []string{"ofaas", "ofa"}, + Namespaced: true, + Verbs: []string{"delete"}, + Categories: []string{"faas"}, + } +} + func loadRBAC(m ResourceMetas) { m[client.NewGVR("rbac")] = metav1.APIResource{ Name: "rbacs", diff --git a/internal/dao/registry_test.go b/internal/dao/registry_test.go index 241869b8..28ff4ef8 100644 --- a/internal/dao/registry_test.go +++ b/internal/dao/registry_test.go @@ -89,7 +89,7 @@ func TestExtractString(t *testing.T) { // Helpers... func load(t *testing.T, n string) *unstructured.Unstructured { - raw, err := ioutil.ReadFile(fmt.Sprintf("assets/%s.json", n)) + raw, err := ioutil.ReadFile(fmt.Sprintf("testdata/%s.json", n)) assert.Nil(t, err) var o unstructured.Unstructured diff --git a/internal/dao/rs.go b/internal/dao/rs.go new file mode 100644 index 00000000..fdb014f0 --- /dev/null +++ b/internal/dao/rs.go @@ -0,0 +1,23 @@ +package dao + +import ( + appsv1 "k8s.io/api/apps/v1" +) + +// ReplicaSet represents a replicaset K8s resource. +type ReplicaSet struct { + Resource +} + +// IsHappy check for happy deployments. +func (d *ReplicaSet) IsHappy(rs appsv1.ReplicaSet) bool { + if rs.Status.Replicas == 0 && rs.Status.Replicas != rs.Status.ReadyReplicas { + return false + } + + if rs.Status.Replicas != 0 && rs.Status.Replicas != rs.Status.ReadyReplicas { + return false + } + + return true +} diff --git a/internal/dao/sts.go b/internal/dao/sts.go index 3fe02c52..8f599f3a 100644 --- a/internal/dao/sts.go +++ b/internal/dao/sts.go @@ -21,6 +21,7 @@ var ( _ Loggable = (*StatefulSet)(nil) _ Restartable = (*StatefulSet)(nil) _ Scalable = (*StatefulSet)(nil) + _ Controller = (*StatefulSet)(nil) ) // StatefulSet represents a K8s sts. @@ -28,6 +29,11 @@ type StatefulSet struct { Resource } +// IsHappy check for happy sts. +func (s *StatefulSet) IsHappy(sts appsv1.StatefulSet) bool { + return sts.Status.Replicas == sts.Status.ReadyReplicas +} + // Scale a StatefulSet. func (s *StatefulSet) Scale(path string, replicas int32) error { ns, n := client.Namespaced(path) @@ -51,12 +57,7 @@ func (s *StatefulSet) Scale(path string, replicas int32) error { // Restart a StatefulSet rollout. func (s *StatefulSet) Restart(path string) error { - o, err := s.Factory.Get(s.gvr.String(), path, true, labels.Everything()) - if err != nil { - return err - } - var ds appsv1.StatefulSet - err = runtime.DefaultUnstructuredConverter.FromUnstructured(o.(*unstructured.Unstructured).Object, &ds) + sts, err := s.getStatefulSet(path) if err != nil { return err } @@ -70,24 +71,18 @@ func (s *StatefulSet) Restart(path string) error { return fmt.Errorf("user is not authorized to update statefulsets") } - update, err := polymorphichelpers.ObjectRestarterFn(&ds) + update, err := polymorphichelpers.ObjectRestarterFn(sts) if err != nil { return err } - _, err = s.Client().DialOrDie().AppsV1().StatefulSets(ds.Namespace).Patch(ds.Name, types.StrategicMergePatchType, update) + _, err = s.Client().DialOrDie().AppsV1().StatefulSets(sts.Namespace).Patch(sts.Name, types.StrategicMergePatchType, update) return err } // TailLogs tail logs for all pods represented by this StatefulSet. -func (s *StatefulSet) TailLogs(ctx context.Context, c chan<- string, opts LogOptions) error { - o, err := s.Factory.Get(s.gvr.String(), opts.Path, true, labels.Everything()) - if err != nil { - return err - } - - var sts appsv1.StatefulSet - err = runtime.DefaultUnstructuredConverter.FromUnstructured(o.(*unstructured.Unstructured).Object, &sts) +func (s *StatefulSet) TailLogs(ctx context.Context, c chan<- []byte, opts LogOptions) error { + sts, err := s.getStatefulSet(opts.Path) if err != nil { return errors.New("expecting StatefulSet resource") } @@ -97,3 +92,28 @@ func (s *StatefulSet) TailLogs(ctx context.Context, c chan<- string, opts LogOpt return podLogs(ctx, c, sts.Spec.Selector.MatchLabels, opts) } + +// Pod returns a pod victim by name. +func (s *StatefulSet) Pod(fqn string) (string, error) { + sts, err := s.getStatefulSet(fqn) + if err != nil { + return "", err + } + + return podFromSelector(s.Factory, sts.Namespace, sts.Spec.Selector.MatchLabels) +} + +func (s *StatefulSet) getStatefulSet(fqn string) (*appsv1.StatefulSet, error) { + o, err := s.Factory.Get(s.gvr.String(), fqn, false, labels.Everything()) + if err != nil { + return nil, err + } + + var sts appsv1.StatefulSet + err = runtime.DefaultUnstructuredConverter.FromUnstructured(o.(*unstructured.Unstructured).Object, &sts) + if err != nil { + return nil, errors.New("expecting Service resource") + } + + return &sts, nil +} diff --git a/internal/dao/svc.go b/internal/dao/svc.go index 25b82e0f..2369870b 100644 --- a/internal/dao/svc.go +++ b/internal/dao/svc.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" + "github.com/derailed/k9s/internal/client" v1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/labels" @@ -12,8 +13,9 @@ import ( ) var ( - _ Accessor = (*Service)(nil) - _ Loggable = (*Service)(nil) + _ Accessor = (*Service)(nil) + _ Loggable = (*Service)(nil) + _ Controller = (*Service)(nil) ) // Service represents a k8s service. @@ -22,20 +24,62 @@ type Service struct { } // TailLogs tail logs for all pods represented by this Service. -func (s *Service) TailLogs(ctx context.Context, c chan<- string, opts LogOptions) error { - o, err := s.Factory.Get(s.gvr.String(), opts.Path, true, labels.Everything()) +func (s *Service) TailLogs(ctx context.Context, c chan<- []byte, opts LogOptions) error { + svc, err := s.GetInstance(opts.Path) if err != nil { return err } - var svc v1.Service - err = runtime.DefaultUnstructuredConverter.FromUnstructured(o.(*unstructured.Unstructured).Object, &svc) - if err != nil { - return errors.New("expecting Service resource") - } - if svc.Spec.Selector == nil || len(svc.Spec.Selector) == 0 { return fmt.Errorf("no valid selector found on Service %s", opts.Path) } return podLogs(ctx, c, svc.Spec.Selector, opts) } + +// Pod returns a pod victim by name. +func (s *Service) Pod(fqn string) (string, error) { + svc, err := s.GetInstance(fqn) + if err != nil { + return "", err + } + + return podFromSelector(s.Factory, svc.Namespace, svc.Spec.Selector) +} + +// GetInstance returns a service instance. +func (s *Service) GetInstance(fqn string) (*v1.Service, error) { + o, err := s.Factory.Get(s.gvr.String(), fqn, false, labels.Everything()) + if err != nil { + return nil, err + } + + var svc v1.Service + err = runtime.DefaultUnstructuredConverter.FromUnstructured(o.(*unstructured.Unstructured).Object, &svc) + if err != nil { + return nil, errors.New("expecting Service resource") + } + + return &svc, nil +} + +// ---------------------------------------------------------------------------- +// Helpers... + +func podFromSelector(f Factory, ns string, sel map[string]string) (string, error) { + oo, err := f.List("v1/pods", ns, true, labels.Set(sel).AsSelector()) + if err != nil { + return "", err + } + + if len(oo) == 0 { + return "", fmt.Errorf("no matching pods for %v", sel) + } + + var pod v1.Pod + err = runtime.DefaultUnstructuredConverter.FromUnstructured(oo[0].(*unstructured.Unstructured).Object, &pod) + if err != nil { + return "", err + } + + return client.FQN(pod.Namespace, pod.Name), nil +} diff --git a/internal/dao/table.go b/internal/dao/table.go index 3abdf94e..290f2201 100644 --- a/internal/dao/table.go +++ b/internal/dao/table.go @@ -4,6 +4,7 @@ import ( "context" "fmt" + "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/client" "github.com/rs/zerolog/log" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -22,7 +23,6 @@ type Table struct { func (t *Table) Get(ctx context.Context, path string) (runtime.Object, error) { ns, n := client.Namespaced(path) - log.Debug().Msgf("TABLE-GET %q:%q", ns, t.gvr) a := fmt.Sprintf(gvFmt, metav1beta1.SchemeGroupVersion.Version, metav1beta1.GroupName) _, codec := t.codec() @@ -46,7 +46,11 @@ func (t *Table) Get(ctx context.Context, path string) (runtime.Object, error) { // List all Resources in a given namespace. func (t *Table) List(ctx context.Context, ns string) ([]runtime.Object, error) { - log.Debug().Msgf("TABLE-LIST %q:%q", ns, t.gvr) + labelSel, ok := ctx.Value(internal.KeyLabels).(string) + if !ok { + log.Debug().Msgf("No label selector found in context. Listing all resources") + } + a := fmt.Sprintf(gvFmt, metav1beta1.SchemeGroupVersion.Version, metav1beta1.GroupName) _, codec := t.codec() @@ -58,7 +62,7 @@ func (t *Table) List(ctx context.Context, ns string) ([]runtime.Object, error) { SetHeader("Accept", a). Namespace(ns). Resource(t.gvr.R()). - VersionedParams(&metav1beta1.TableOptions{}, codec). + VersionedParams(&metav1.ListOptions{LabelSelector: labelSel}, codec). Do().Get() if err != nil { return nil, err diff --git a/internal/dao/test_assets/bench/default_fred_1577308050814961000.txt b/internal/dao/testdata/bench/default_fred_1577308050814961000.txt similarity index 100% rename from internal/dao/test_assets/bench/default_fred_1577308050814961000.txt rename to internal/dao/testdata/bench/default_fred_1577308050814961000.txt diff --git a/internal/dao/testdata/benchspec.yml b/internal/dao/testdata/benchspec.yml new file mode 100644 index 00000000..e308b288 --- /dev/null +++ b/internal/dao/testdata/benchspec.yml @@ -0,0 +1,19 @@ +benchmarks: + defaults: + concurrency: 2 + requests: 500 + containers: + default/nginx:nginx: + concurrency: 2 + requests: 3000 + http: + method: GET + path: / + services: + default/nginx: + concurrency: 1 + requests: 666 + http: + method: GET + host: 192.168.64.1 + path: / diff --git a/internal/dao/assets/config b/internal/dao/testdata/config similarity index 100% rename from internal/dao/assets/config rename to internal/dao/testdata/config diff --git a/internal/dao/assets/config.1 b/internal/dao/testdata/config.1 similarity index 100% rename from internal/dao/assets/config.1 rename to internal/dao/testdata/config.1 diff --git a/internal/dao/test_assets/crb.json b/internal/dao/testdata/crb.json similarity index 100% rename from internal/dao/test_assets/crb.json rename to internal/dao/testdata/crb.json diff --git a/internal/dao/assets/dr.json b/internal/dao/testdata/dr.json similarity index 100% rename from internal/dao/assets/dr.json rename to internal/dao/testdata/dr.json diff --git a/internal/dao/test_assets/n1.json b/internal/dao/testdata/n1.json similarity index 100% rename from internal/dao/test_assets/n1.json rename to internal/dao/testdata/n1.json diff --git a/internal/dao/test_assets/p1.json b/internal/dao/testdata/p1.json similarity index 100% rename from internal/dao/test_assets/p1.json rename to internal/dao/testdata/p1.json diff --git a/internal/dao/types.go b/internal/dao/types.go index c57ccb70..ccddbb8e 100644 --- a/internal/dao/types.go +++ b/internal/dao/types.go @@ -73,7 +73,7 @@ type Accessor interface { // Loggable represents resources with logs. type Loggable interface { // TaiLogs streams resource logs. - TailLogs(ctx context.Context, c chan<- string, opts LogOptions) error + TailLogs(ctx context.Context, c chan<- []byte, opts LogOptions) error } // Describer describes a resource. @@ -91,6 +91,12 @@ type Scalable interface { Scale(path string, replicas int32) error } +// Controller represents a pod controller. +type Controller interface { + // Pod returns a pod instance matching the selector. + Pod(path string) (string, error) +} + // Nuker represents a resource deleter. type Nuker interface { // Delete removes a resource from the api server. diff --git a/internal/health/check.go b/internal/health/check.go new file mode 100644 index 00000000..b3713bfd --- /dev/null +++ b/internal/health/check.go @@ -0,0 +1,54 @@ +package health + +import ( + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +// Check tracks resource health. +type Check struct { + Counts + + GVR string +} + +// Checks represents a collection of health checks. +type Checks []*Check + +// NewCheck returns a new health check. +func NewCheck(gvr string) *Check { + return &Check{ + GVR: gvr, + Counts: make(Counts), + } +} + +// Set sets a health metric. +func (c *Check) Set(l Level, v int) { + c.Counts[l] = v +} + +// Inc increments a health metric. +func (c *Check) Inc(l Level) { + c.Counts[l]++ +} + +// Total stores a metric total. +func (c *Check) Total(n int) { + c.Counts[Corpus] = n +} + +// Tally retrieves a given health metric. +func (c *Check) Tally(l Level) int { + return c.Counts[l] +} + +// GetObjectKind returns a schema object. +func (Check) GetObjectKind() schema.ObjectKind { + return nil +} + +// DeepCopyObject returns a container copy. +func (c Check) DeepCopyObject() runtime.Object { + return c +} diff --git a/internal/health/check_test.go b/internal/health/check_test.go new file mode 100644 index 00000000..f0a40b82 --- /dev/null +++ b/internal/health/check_test.go @@ -0,0 +1,26 @@ +package health_test + +import ( + "testing" + + "github.com/derailed/k9s/internal/health" + "github.com/stretchr/testify/assert" +) + +func TestCheck(t *testing.T) { + var cc health.Checks + + c := health.NewCheck("test") + n := 0 + for i := 0; i < 10; i++ { + c.Inc(health.OK) + cc = append(cc, c) + n++ + } + c.Total(n) + + assert.Equal(t, 10, len(cc)) + assert.Equal(t, 10, c.Tally(health.Corpus)) + assert.Equal(t, 10, c.Tally(health.OK)) + assert.Equal(t, 0, c.Tally(health.Toast)) +} diff --git a/internal/health/types.go b/internal/health/types.go new file mode 100644 index 00000000..63571ea8 --- /dev/null +++ b/internal/health/types.go @@ -0,0 +1,44 @@ +package health + +// Level tracks health count categories. +type Level int + +const ( + // Unknown represents no health level. + Unknown Level = 1 << iota + + // Corpus tracks total health. + Corpus + + // OK tracks healhy. + OK + + // Warn tracks health warnings. + Warn + + // Toast tracks unhealties. + Toast +) + +// Message represents a health message. +type Message struct { + Level Level + Message string + GVR string + FQN string +} + +// Messages tracks a collection of messages. +type Messages []Message + +// Counts tracks health counts by category. +type Counts map[Level]int + +// Vital tracks a resource vitals. +type Vital struct { + Resource string + Total, OK, Toast int +} + +// Vitals tracks a collection of resource health. +type Vitals []Vital diff --git a/internal/keys.go b/internal/keys.go index 240990b5..2cc5c873 100644 --- a/internal/keys.go +++ b/internal/keys.go @@ -25,4 +25,6 @@ const ( KeyApp ContextKey = "app" KeyStyles ContextKey = "styles" KeyMetrics ContextKey = "metrics" + KeyToast ContextKey = "toast" + KeyWithMetrics ContextKey = "withMetrics" ) diff --git a/internal/model/cluster.go b/internal/model/cluster.go index 54593dac..59f7fb7b 100644 --- a/internal/model/cluster.go +++ b/internal/model/cluster.go @@ -35,7 +35,7 @@ type ( func NewCluster(f dao.Factory) *Cluster { return &Cluster{ factory: f, - mx: client.NewMetricsServer(f.Client()), + mx: client.DialMetrics(f.Client()), } } diff --git a/internal/model/cluster_info_test.go b/internal/model/cluster_info_test.go new file mode 100644 index 00000000..7fabb932 --- /dev/null +++ b/internal/model/cluster_info_test.go @@ -0,0 +1,46 @@ +package model_test + +import ( + "testing" + + "github.com/derailed/k9s/internal/model" + "github.com/stretchr/testify/assert" +) + +func TestClusterMetaDelta(t *testing.T) { + uu := map[string]struct { + o, n model.ClusterMeta + e bool + }{ + "empty": { + o: model.NewClusterMeta(), + n: model.NewClusterMeta(), + }, + "same": { + o: makeClusterMeta("fred"), + n: makeClusterMeta("fred"), + }, + "diff": { + o: makeClusterMeta("fred"), + n: makeClusterMeta("freddie"), + e: true, + }, + } + + for k := range uu { + u := uu[k] + t.Run(k, func(t *testing.T) { + assert.Equal(t, u.e, u.o.Deltas(u.n)) + }) + } +} + +// Helpers... + +func makeClusterMeta(cluster string) model.ClusterMeta { + m := model.NewClusterMeta() + m.Cluster = cluster + m.Cpu, m.Mem = 10, 20 + + return m +} diff --git a/internal/model/flash.go b/internal/model/flash.go new file mode 100644 index 00000000..d7fe8300 --- /dev/null +++ b/internal/model/flash.go @@ -0,0 +1,156 @@ +package model + +import ( + "context" + "fmt" + "time" + + "github.com/rs/zerolog/log" +) + +const ( + // DefaultFlashDelay sets the flash clear delay. + DefaultFlashDelay = 3 * time.Second + + // FlashInfo represents an info message. + FlashInfo FlashLevel = iota + // FlashWarn represents an warning message. + FlashWarn + // FlashErr represents an error message. + FlashErr +) + +// LevelMessage tracks an message and severity. +type LevelMessage struct { + Level FlashLevel + Text string +} + +func newClearMessage() LevelMessage { + return LevelMessage{} +} + +// IsClear returns true if message is empty. +func (l LevelMessage) IsClear() bool { + return l.Text == "" +} + +// FlashLevel represents flash message severity. +type FlashLevel int + +// FlashChan represents a flash event channel. +type FlashChan chan LevelMessage + +// FlashListener represents a text model listener. +type FlashListener interface { + // FlashChanged notifies the model changed. + FlashChanged(FlashLevel, string) + + // FlashCleared notifies when the filter changed. + FlashCleared() +} + +// Flash represents a flash message model. +type Flash struct { + msg LevelMessage + cancel context.CancelFunc + delay time.Duration + msgChan chan LevelMessage +} + +// NewFlash returns a new instance. +func NewFlash(dur time.Duration) *Flash { + return &Flash{ + delay: dur, + msgChan: make(FlashChan, 3), + } +} + +// Channel returns the flash channel. +func (f *Flash) Channel() FlashChan { + return f.msgChan +} + +// Info displays an info flash message. +func (f *Flash) Info(msg string) { + f.SetMessage(FlashInfo, msg) +} + +// Infof displays a formatted info flash message. +func (f *Flash) Infof(fmat string, args ...interface{}) { + f.Info(fmt.Sprintf(fmat, args...)) +} + +// Warn displays a warning flash message. +func (f *Flash) Warn(msg string) { + log.Warn().Msg(msg) + f.SetMessage(FlashWarn, msg) +} + +// Warnf displays a formatted warning flash message. +func (f *Flash) Warnf(fmat string, args ...interface{}) { + f.Warn(fmt.Sprintf(fmat, args...)) +} + +// Err displays an error flash message. +func (f *Flash) Err(err error) { + log.Error().Msg(err.Error()) + f.SetMessage(FlashErr, err.Error()) +} + +// Errf displays a formatted error flash message. +func (f *Flash) Errf(fmat string, args ...interface{}) { + var err error + for _, a := range args { + switch e := a.(type) { + case error: + err = e + } + } + log.Error().Err(err).Msgf(fmat, args...) + f.SetMessage(FlashErr, fmt.Sprintf(fmat, args...)) +} + +// Clear clears the flash message. +func (f *Flash) Clear() { + f.fireCleared() +} + +// SetMessage sets the flash level message. +func (f *Flash) SetMessage(level FlashLevel, msg string) { + if f.cancel != nil { + f.cancel() + f.cancel = nil + } + + f.setLevelMessage(LevelMessage{Level: level, Text: msg}) + f.fireFlashChanged() + + var ctx context.Context + ctx, f.cancel = context.WithCancel(context.Background()) + go f.refresh(ctx) +} + +func (f *Flash) refresh(ctx context.Context) { + for { + select { + case <-ctx.Done(): + return + case <-time.After(f.delay): + f.fireCleared() + return + } + } +} + +func (f *Flash) setLevelMessage(msg LevelMessage) { + f.msg = msg +} + +func (f *Flash) fireFlashChanged() { + f.msgChan <- f.msg +} + +func (f *Flash) fireCleared() { + f.msgChan <- newClearMessage() +} diff --git a/internal/model/flash_test.go b/internal/model/flash_test.go new file mode 100644 index 00000000..2c0561ed --- /dev/null +++ b/internal/model/flash_test.go @@ -0,0 +1,101 @@ +package model_test + +import ( + "errors" + "fmt" + "sync" + "testing" + "time" + + "github.com/derailed/k9s/internal/model" + "github.com/stretchr/testify/assert" +) + +func TestFlash(t *testing.T) { + const delay = 1 * time.Millisecond + + uu := map[string]struct { + level model.FlashLevel + e string + }{ + "info": {level: model.FlashInfo, e: "blee"}, + "warn": {level: model.FlashWarn, e: "blee"}, + "err": {level: model.FlashErr, e: "blee"}, + } + + for k := range uu { + u := uu[k] + + t.Run(k, func(t *testing.T) { + f := model.NewFlash(delay) + v := newFlash() + go v.listen(f.Channel()) + + switch u.level { + case model.FlashInfo: + f.Info(u.e) + case model.FlashWarn: + f.Warn(u.e) + case model.FlashErr: + f.Err(errors.New(u.e)) + } + + time.Sleep(5 * delay) + s, _, l, m := v.getMetrics() + assert.Equal(t, 1, s) + assert.Equal(t, u.level, l) + assert.Equal(t, u.e, m) + }) + } +} + +func TestFlashBurst(t *testing.T) { + const delay = 1 * time.Millisecond + + f := model.NewFlash(delay) + v := newFlash() + go v.listen(f.Channel()) + + count := 5 + for i := 1; i <= count; i++ { + f.Info(fmt.Sprintf("test-%d", i)) + } + + time.Sleep(2 * delay) + s, _, l, m := v.getMetrics() + assert.Equal(t, count, s) + assert.Equal(t, model.FlashInfo, l) + assert.Equal(t, fmt.Sprintf("test-%d", count), m) +} + +type flash struct { + set, clear int + level model.FlashLevel + msg string + mx sync.RWMutex +} + +func newFlash() *flash { + return &flash{} +} + +func (f *flash) getMetrics() (int, int, model.FlashLevel, string) { + f.mx.RLock() + defer f.mx.RUnlock() + return f.set, f.clear, f.level, f.msg +} + +func (f *flash) listen(c model.FlashChan) { + for m := range c { + f.mx.Lock() + { + if m.IsClear() { + f.clear++ + } else { + f.set++ + f.level, f.msg = m.Level, m.Text + } + } + f.mx.Unlock() + } +} diff --git a/internal/model/log.go b/internal/model/log.go index 896f00be..07cbc4ad 100644 --- a/internal/model/log.go +++ b/internal/model/log.go @@ -31,25 +31,23 @@ type LogsListener interface { // Log represents a resource logger. type Log struct { - factory dao.Factory - lines []string - listeners []LogsListener - gvr client.GVR - logOptions dao.LogOptions - cancelFn context.CancelFunc - initialized bool - mx sync.RWMutex - filter string - lastSent int + factory dao.Factory + lines []string + listeners []LogsListener + gvr client.GVR + logOptions dao.LogOptions + cancelFn context.CancelFunc + mx sync.RWMutex + filter string + lastSent int } // NewLog returns a new model. -func NewLog(gvr client.GVR, msg string, opts dao.LogOptions, timeOut time.Duration) *Log { +func NewLog(gvr client.GVR, opts dao.LogOptions, timeOut time.Duration) *Log { return &Log{ - gvr: gvr, - logOptions: opts, - initialized: true, - lines: []string{msg}, + gvr: gvr, + logOptions: opts, + lines: nil, } } @@ -84,12 +82,11 @@ func (l *Log) Start() { // Stop terminates log tailing. func (l *Log) Stop() { - if l.cancelFn == nil { - return + defer log.Debug().Msgf("<<<< Logger STOPPED!") + if l.cancelFn != nil { + l.cancelFn() + l.cancelFn = nil } - log.Debug().Msgf("<<<< Logger STOP!") - l.cancelFn() - l.cancelFn = nil } // Set sets the log lines (for testing only!) @@ -131,7 +128,7 @@ func (l *Log) load() error { ctx = context.WithValue(context.Background(), internal.KeyFactory, l.factory) ctx, l.cancelFn = context.WithCancel(ctx) - c := make(chan string, 10) + c := make(chan []byte, 10) go l.updateLogs(ctx, c) accessor, err := dao.AccessorFor(l.factory, l.gvr) @@ -163,8 +160,7 @@ func (l *Log) Append(line string) { l.mx.Lock() defer l.mx.Unlock() - if l.initialized { - l.lines, l.initialized, l.lastSent = []string{}, false, 0 + if l.lines == nil { l.fireLogCleared() } @@ -190,20 +186,20 @@ func (l *Log) Notify(timedOut bool) { } } -func (l *Log) updateLogs(ctx context.Context, c <-chan string) { +func (l *Log) updateLogs(ctx context.Context, c <-chan []byte) { defer func() { log.Debug().Msgf("updateLogs view bailing out!") }() for { select { - case line, ok := <-c: + case bytes, ok := <-c: if !ok { log.Debug().Msgf("Closed channel detected. Bailing out...") - l.Append(line) + l.Append(string(bytes)) l.Notify(false) return } - l.Append(line) + l.Append(string(bytes)) var overflow bool l.mx.RLock() { diff --git a/internal/model/log_test.go b/internal/model/log_test.go index 6b23ea46..d6ac27d7 100644 --- a/internal/model/log_test.go +++ b/internal/model/log_test.go @@ -18,7 +18,7 @@ import ( func TestLogFullBuffer(t *testing.T) { size := 4 - m := model.NewLog(client.NewGVR("fred"), "Blee", makeLogOpts(size), 10*time.Millisecond) + m := model.NewLog(client.NewGVR("fred"), makeLogOpts(size), 10*time.Millisecond) m.Init(makeFactory()) v := newTestView() @@ -60,7 +60,7 @@ func TestLogFilter(t *testing.T) { for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { - m := model.NewLog(client.NewGVR("fred"), "Blee", makeLogOpts(size), 10*time.Millisecond) + m := model.NewLog(client.NewGVR("fred"), makeLogOpts(size), 10*time.Millisecond) m.Init(makeFactory()) v := newTestView() @@ -89,7 +89,7 @@ func TestLogFilter(t *testing.T) { } func TestLogStartStop(t *testing.T) { - m := model.NewLog(client.NewGVR("fred"), "Blee", makeLogOpts(4), 10*time.Millisecond) + m := model.NewLog(client.NewGVR("fred"), makeLogOpts(4), 10*time.Millisecond) m.Init(makeFactory()) v := newTestView() @@ -110,7 +110,7 @@ func TestLogStartStop(t *testing.T) { } func TestLogClear(t *testing.T) { - m := model.NewLog(client.NewGVR("fred"), "Blee", makeLogOpts(4), 10*time.Millisecond) + m := model.NewLog(client.NewGVR("fred"), makeLogOpts(4), 10*time.Millisecond) m.Init(makeFactory()) assert.Equal(t, "fred", m.GetPath()) assert.Equal(t, "blee", m.GetContainer()) @@ -132,7 +132,7 @@ func TestLogClear(t *testing.T) { } func TestLogBasic(t *testing.T) { - m := model.NewLog(client.NewGVR("fred"), "Blee", makeLogOpts(2), 10*time.Millisecond) + m := model.NewLog(client.NewGVR("fred"), makeLogOpts(2), 10*time.Millisecond) m.Init(makeFactory()) v := newTestView() @@ -148,7 +148,7 @@ func TestLogBasic(t *testing.T) { } func TestLogAppend(t *testing.T) { - m := model.NewLog(client.NewGVR("fred"), "blah blah", makeLogOpts(4), 5*time.Millisecond) + m := model.NewLog(client.NewGVR("fred"), makeLogOpts(4), 5*time.Millisecond) m.Init(makeFactory()) v := newTestView() @@ -161,17 +161,17 @@ func TestLogAppend(t *testing.T) { m.Append(d) } assert.Equal(t, 1, v.dataCalled) - assert.Equal(t, []string{}, v.data) + assert.Equal(t, []string{"blah blah"}, v.data) m.Notify(true) assert.Equal(t, 2, v.dataCalled) - assert.Equal(t, 1, v.clearCalled) + assert.Equal(t, 0, v.clearCalled) assert.Equal(t, 0, v.errCalled) - assert.Equal(t, data, v.data) + assert.Equal(t, append([]string{"blah blah"}, data...), v.data) } func TestLogTimedout(t *testing.T) { - m := model.NewLog(client.NewGVR("fred"), "Blee", makeLogOpts(4), 10*time.Millisecond) + m := model.NewLog(client.NewGVR("fred"), makeLogOpts(4), 10*time.Millisecond) m.Init(makeFactory()) v := newTestView() diff --git a/internal/model/mock_clustermeta_test.go b/internal/model/mock_clustermeta_test.go index cf5426c2..a47a8434 100644 --- a/internal/model/mock_clustermeta_test.go +++ b/internal/model/mock_clustermeta_test.go @@ -332,12 +332,14 @@ func (mock *MockClusterMeta) SupportsResource(_param0 string) bool { return ret0 } -func (mock *MockClusterMeta) SwitchContextOrDie(_param0 string) { +func (mock *MockClusterMeta) SwitchContext(_param0 string) error { if mock == nil { panic("mock must not be nil. Use myMock := NewMockClusterMeta().") } params := []pegomock.Param{_param0} - pegomock.GetGenericMockFrom(mock).Invoke("SwitchContextOrDie", params, []reflect.Type{}) + pegomock.GetGenericMockFrom(mock).Invoke("SwitchContext", params, []reflect.Type{reflect.TypeOf((*error)(nil)).Elem()}) + + return nil } func (mock *MockClusterMeta) UserName() (string, error) { diff --git a/internal/model/pulse.go b/internal/model/pulse.go new file mode 100644 index 00000000..d26ecc83 --- /dev/null +++ b/internal/model/pulse.go @@ -0,0 +1,160 @@ +package model + +import ( + "context" + "fmt" + "sync/atomic" + "time" + + "github.com/derailed/k9s/internal" + "github.com/derailed/k9s/internal/dao" + "github.com/derailed/k9s/internal/health" + "github.com/rs/zerolog/log" + "k8s.io/apimachinery/pkg/runtime" +) + +const defaultRefreshRate = 5 * time.Second + +// PulseListener represents a health model listener. +type PulseListener interface { + // PulseChanged notifies the model data changed. + PulseChanged(*health.Check) + + // TreeFailed notifies the health check failed. + PulseFailed(error) +} + +// Pulse tracks multiple resources health. +type Pulse struct { + gvr string + namespace string + inUpdate int32 + listeners []PulseListener + refreshRate time.Duration + health *PulseHealth + data health.Checks +} + +// NewPulse returns a new pulse. +func NewPulse(gvr string) *Pulse { + return &Pulse{ + gvr: gvr, + refreshRate: defaultRefreshRate, + } +} + +// Watch monitors pulses. +func (p *Pulse) Watch(ctx context.Context) { + p.Refresh(ctx) + go p.updater(ctx) +} + +func (p *Pulse) updater(ctx context.Context) { + defer log.Debug().Msgf("Pulse canceled -- %q", p.gvr) + + rate := initRefreshRate + for { + select { + case <-ctx.Done(): + return + case <-time.After(rate): + rate = p.refreshRate + p.refresh(ctx) + } + } +} + +// Refresh update the model now. +func (p *Pulse) Refresh(ctx context.Context) { + for _, d := range p.data { + p.firePulseChanged(d) + } + p.refresh(ctx) +} + +func (p *Pulse) refresh(ctx context.Context) { + if !atomic.CompareAndSwapInt32(&p.inUpdate, 0, 1) { + log.Debug().Msgf("Dropping update...") + return + } + defer atomic.StoreInt32(&p.inUpdate, 0) + + if err := p.reconcile(ctx); err != nil { + log.Error().Err(err).Msg("Reconcile failed") + p.firePulseFailed(err) + return + } +} + +func (p *Pulse) list(ctx context.Context) ([]runtime.Object, error) { + f, ok := ctx.Value(internal.KeyFactory).(dao.Factory) + if !ok { + return nil, fmt.Errorf("expected Factory in context but got %T", ctx.Value(internal.KeyFactory)) + } + if p.health == nil { + p.health = NewPulseHealth(f) + } + ctx = context.WithValue(ctx, internal.KeyFields, "") + ctx = context.WithValue(ctx, internal.KeyWithMetrics, false) + return p.health.List(ctx, p.namespace) +} + +func (p *Pulse) reconcile(ctx context.Context) error { + oo, err := p.list(ctx) + if err != nil { + return err + } + + p.data = health.Checks{} + for _, o := range oo { + c, ok := o.(*health.Check) + if !ok { + return fmt.Errorf("Expecting health check but got %T", o) + } + p.data = append(p.data, c) + p.firePulseChanged(c) + } + return nil +} + +// GetNamespace returns the model namespace. +func (p *Pulse) GetNamespace() string { + return p.namespace +} + +// SetNamespace sets up model namespace. +func (p *Pulse) SetNamespace(ns string) { + p.namespace = ns +} + +// AddListener adds a listener. +func (p *Pulse) AddListener(l PulseListener) { + p.listeners = append(p.listeners, l) +} + +// RemoveListener delete a listener. +func (p *Pulse) RemoveListener(l PulseListener) { + victim := -1 + for i, lis := range p.listeners { + if lis == l { + victim = i + break + } + } + + if victim >= 0 { + p.listeners = append(p.listeners[:victim], p.listeners[victim+1:]...) + } +} + +func (p *Pulse) firePulseChanged(check *health.Check) { + for _, l := range p.listeners { + l.PulseChanged(check) + } +} + +func (p *Pulse) firePulseFailed(err error) { + for _, l := range p.listeners { + l.PulseFailed(err) + } +} diff --git a/internal/model/pulse_health.go b/internal/model/pulse_health.go new file mode 100644 index 00000000..d0a1c5c0 --- /dev/null +++ b/internal/model/pulse_health.go @@ -0,0 +1,117 @@ +package model + +import ( + "context" + "fmt" + "math" + "time" + + "github.com/derailed/k9s/internal/client" + "github.com/derailed/k9s/internal/dao" + "github.com/derailed/k9s/internal/health" + "github.com/derailed/k9s/internal/render" + "github.com/rs/zerolog/log" + "k8s.io/apimachinery/pkg/runtime" +) + +// PulseHealth tracks resources health. +type PulseHealth struct { + factory dao.Factory +} + +// NewPulseHealth returns a new instance. +func NewPulseHealth(f dao.Factory) *PulseHealth { + return &PulseHealth{ + factory: f, + } +} + +// List returns a canned collection of resources health. +func (h *PulseHealth) List(ctx context.Context, ns string) ([]runtime.Object, error) { + defer func(t time.Time) { + log.Debug().Msgf("PulseHealthCheck %v", time.Since(t)) + }(time.Now()) + + gvrs := []string{ + "v1/pods", + "v1/events", + "apps/v1/replicasets", + "apps/v1/deployments", + "apps/v1/statefulsets", + "apps/v1/daemonsets", + "batch/v1/jobs", + "v1/persistentvolumes", + } + + hh := make([]runtime.Object, 0, 10) + for _, gvr := range gvrs { + c, err := h.check(ctx, ns, gvr) + if err != nil { + return nil, err + } + hh = append(hh, c) + } + + mm, err := h.checkMetrics() + if err != nil { + return hh, nil + } + for _, m := range mm { + hh = append(hh, m) + } + + return hh, nil +} + +func (h *PulseHealth) checkMetrics() (health.Checks, error) { + dial := client.DialMetrics(h.factory.Client()) + nmx, err := dial.FetchNodesMetrics() + if err != nil { + log.Error().Err(err).Msgf("Fetching metrics") + return nil, err + } + + var cpu, mem float64 + for _, mx := range nmx.Items { + cpu += float64(mx.Usage.Cpu().MilliValue()) + mem += client.ToMB(mx.Usage.Memory().Value()) + } + c1 := health.NewCheck("cpu") + c1.Set(health.OK, int(math.Round(cpu))) + c2 := health.NewCheck("mem") + c2.Set(health.OK, int(math.Round(mem))) + + return health.Checks{c1, c2}, nil +} + +func (h *PulseHealth) check(ctx context.Context, ns, gvr string) (*health.Check, error) { + meta, ok := Registry[gvr] + if !ok { + return nil, fmt.Errorf("No meta for %q", gvr) + } + if meta.DAO == nil { + meta.DAO = &dao.Resource{} + } + + meta.DAO.Init(h.factory, client.NewGVR(gvr)) + oo, err := meta.DAO.List(ctx, ns) + if err != nil { + return nil, err + } + + c := health.NewCheck(gvr) + c.Total(len(oo)) + rr, re := make(render.Rows, len(oo)), meta.Renderer + for i, o := range oo { + if err := re.Render(o, ns, &rr[i]); err != nil { + return nil, err + } + if !render.Happy(ns, rr[i]) { + c.Inc(health.Toast) + } else { + c.Inc(health.OK) + } + } + + return c, nil +} diff --git a/internal/model/registry.go b/internal/model/registry.go index 717f8bec..0dde91f1 100644 --- a/internal/model/registry.go +++ b/internal/model/registry.go @@ -14,6 +14,13 @@ var Registry = map[string]ResourceMeta{ DAO: &dao.Chart{}, Renderer: &render.Chart{}, }, + "pulses": { + DAO: &dao.Pulse{}, + }, + "openfaas": { + DAO: &dao.OpenFaas{}, + Renderer: &render.OpenFaas{}, + }, "containers": { DAO: &dao.Container{}, Renderer: &render.Container{}, diff --git a/internal/model/stack.go b/internal/model/stack.go index b2ffb3cb..53cd0fea 100644 --- a/internal/model/stack.go +++ b/internal/model/stack.go @@ -108,8 +108,8 @@ func (s *Stack) Pop() (Component, bool) { var c Component s.mx.Lock() { - c = s.components[s.size()] - s.components = s.components[:s.size()] + c = s.components[len(s.components)-1] + s.components = s.components[:len(s.components)-1] } s.mx.Unlock() s.notify(StackPop, c) @@ -163,11 +163,7 @@ func (s *Stack) Top() Component { return nil } - return s.components[s.size()] -} - -func (s *Stack) size() int { - return len(s.components) - 1 + return s.components[len(s.components)-1] } func (s *Stack) notify(a StackAction, c Component) { diff --git a/internal/model/table.go b/internal/model/table.go index 327d5586..75bba3f5 100644 --- a/internal/model/table.go +++ b/internal/model/table.go @@ -75,10 +75,15 @@ func (t *Table) RemoveListener(l TableListener) { // Watch initiates model updates. func (t *Table) Watch(ctx context.Context) { - t.Refresh(ctx) + t.refresh(ctx) go t.updater(ctx) } +// Refresh updates the table content. +func (t *Table) Refresh(ctx context.Context) { + t.refresh(ctx) +} + // Get returns a resource instance if found, else an error. func (t *Table) Get(ctx context.Context, path string) (runtime.Object, error) { meta, err := t.getMeta(ctx) @@ -134,11 +139,6 @@ func (t *Table) ToYAML(ctx context.Context, path string) (string, error) { return desc.ToYAML(path) } -// Refresh update the model now. -func (t *Table) Refresh(ctx context.Context) { - t.refresh(ctx) -} - // GetNamespace returns the model namespace. func (t *Table) GetNamespace() string { return t.namespace @@ -162,6 +162,7 @@ func (t *Table) SetRefreshRate(d time.Duration) { // ClusterWide checks if resource is scope for all namespaces. func (t *Table) ClusterWide() bool { + log.Debug().Msgf("CLUSTER-WIDE %q", t.namespace) return client.IsClusterWide(t.namespace) } @@ -205,7 +206,7 @@ func (t *Table) refresh(ctx context.Context) { t.fireTableLoadFailed(err) return } - t.fireTableChanged() + t.fireTableChanged(t.Peek()) } func (t *Table) list(ctx context.Context, a dao.Accessor) ([]runtime.Object, error) { @@ -219,6 +220,7 @@ func (t *Table) list(ctx context.Context, a dao.Accessor) ([]runtime.Object, err if client.IsClusterScoped(t.namespace) { ns = client.AllNamespaces } + return a.List(ctx, ns) } @@ -259,7 +261,6 @@ func (t *Table) reconcile(ctx context.Context) error { t.mx.Lock() defer t.mx.Unlock() - // if labelSelector in place might as well clear the model data. sel, ok := ctx.Value(internal.KeyLabels).(string) if ok && sel != "" { @@ -298,8 +299,7 @@ func (t *Table) resourceMeta() ResourceMeta { return meta } -func (t *Table) fireTableChanged() { - data := t.Peek() +func (t *Table) fireTableChanged(data render.TableData) { for _, l := range t.listeners { l.TableDataChanged(data) } diff --git a/internal/model/table_int_test.go b/internal/model/table_int_test.go index 3ba5405d..9033d021 100644 --- a/internal/model/table_int_test.go +++ b/internal/model/table_int_test.go @@ -29,10 +29,11 @@ func TestTableReconcile(t *testing.T) { f.rows = []runtime.Object{load(t, "p1")} ctx := context.WithValue(context.Background(), internal.KeyFactory, f) ctx = context.WithValue(ctx, internal.KeyFields, "") + ctx = context.WithValue(ctx, internal.KeyWithMetrics, false) err := ta.reconcile(ctx) assert.Nil(t, err) data := ta.Peek() - assert.Equal(t, 15, len(data.Header)) + assert.Equal(t, 17, len(data.Header)) assert.Equal(t, 1, len(data.RowEvents)) assert.Equal(t, client.NamespaceAll, data.Namespace) } @@ -55,6 +56,7 @@ func TestTableGet(t *testing.T) { f := makeFactory() f.rows = []runtime.Object{load(t, "p1")} ctx := context.WithValue(context.Background(), internal.KeyFactory, f) + ctx = context.WithValue(ctx, internal.KeyWithMetrics, false) row, err := ta.Get(ctx, "fred") assert.Nil(t, err) assert.NotNil(t, row) @@ -69,12 +71,6 @@ func TestTableMeta(t *testing.T) { accessor dao.Accessor renderer Renderer }{ - // BOZO!! - // "full": { - // gvr: "v1/pods", - // accessor: &pd, - // renderer: &render.Pod{}, - // }, "generic": { gvr: "containers", accessor: &dao.Container{}, @@ -110,7 +106,7 @@ func TestTableHydrate(t *testing.T) { assert.Nil(t, hydrate("blee", oo, rr, render.Pod{})) assert.Equal(t, 1, len(rr)) - assert.Equal(t, 14, len(rr[0].Fields)) + assert.Equal(t, 16, len(rr[0].Fields)) } func TestTableGenericHydrate(t *testing.T) { @@ -144,7 +140,7 @@ func TestTableGenericHydrate(t *testing.T) { // Helpers... func mustLoad(n string) *unstructured.Unstructured { - raw, err := ioutil.ReadFile(fmt.Sprintf("test_assets/%s.json", n)) + raw, err := ioutil.ReadFile(fmt.Sprintf("testdata/%s.json", n)) if err != nil { panic(err) } @@ -156,7 +152,7 @@ func mustLoad(n string) *unstructured.Unstructured { } func load(t *testing.T, n string) *unstructured.Unstructured { - raw, err := ioutil.ReadFile(fmt.Sprintf("test_assets/%s.json", n)) + raw, err := ioutil.ReadFile(fmt.Sprintf("testdata/%s.json", n)) assert.Nil(t, err) var o unstructured.Unstructured err = json.Unmarshal(raw, &o) @@ -165,7 +161,7 @@ func load(t *testing.T, n string) *unstructured.Unstructured { } func raw(t *testing.T, n string) []byte { - raw, err := ioutil.ReadFile(fmt.Sprintf("test_assets/%s.json", n)) + raw, err := ioutil.ReadFile(fmt.Sprintf("testdata/%s.json", n)) assert.Nil(t, err) return raw } diff --git a/internal/model/table_test.go b/internal/model/table_test.go index b72878ca..f58187ae 100644 --- a/internal/model/table_test.go +++ b/internal/model/table_test.go @@ -30,9 +30,10 @@ func TestTableRefresh(t *testing.T) { f.rows = []runtime.Object{mustLoad("p1")} ctx := context.WithValue(context.Background(), internal.KeyFactory, f) ctx = context.WithValue(ctx, internal.KeyFields, "") + ctx = context.WithValue(ctx, internal.KeyWithMetrics, false) ta.Refresh(ctx) data := ta.Peek() - assert.Equal(t, 15, len(data.Header)) + assert.Equal(t, 17, len(data.Header)) assert.Equal(t, 1, len(data.RowEvents)) assert.Equal(t, client.NamespaceAll, data.Namespace) assert.Equal(t, 1, l.count) @@ -116,7 +117,7 @@ func makeTableFactory() tableFactory { } func mustLoad(n string) *unstructured.Unstructured { - raw, err := ioutil.ReadFile(fmt.Sprintf("test_assets/%s.json", n)) + raw, err := ioutil.ReadFile(fmt.Sprintf("testdata/%s.json", n)) if err != nil { panic(err) } diff --git a/internal/model/test_assets/p1.json b/internal/model/testdata/p1.json similarity index 100% rename from internal/model/test_assets/p1.json rename to internal/model/testdata/p1.json diff --git a/internal/model/text.go b/internal/model/text.go new file mode 100644 index 00000000..1da28560 --- /dev/null +++ b/internal/model/text.go @@ -0,0 +1,120 @@ +package model + +import ( + "regexp" + "strings" + + "github.com/sahilm/fuzzy" +) + +// TextListener represents a text model listener. +type TextListener interface { + // TextChanged notifies the model changed. + TextChanged([]string) + + // TextFiltered notifies when the filter changed. + TextFiltered([]string, fuzzy.Matches) +} + +// Text represents a text model. +type Text struct { + lines []string + listeners []TextListener + query string +} + +// NewText returns a new model. +func NewText() *Text { + return &Text{} +} + +// Peek returns the current model state. +func (t *Text) Peek() []string { + return t.lines +} + +// ClearFilter clear out filter. +func (t *Text) ClearFilter() { + t.query = "" + t.filterChanged(t.lines) +} + +// Filter filters out the text. +func (t *Text) Filter(q string) { + t.query = q + t.filterChanged(t.lines) +} + +// SetText sets the current model content. +func (t *Text) SetText(buff string) { + t.lines = strings.Split(buff, "\n") + t.fireTextChanged(t.lines) +} + +// AddListener adds a new model listener. +func (t *Text) AddListener(listener TextListener) { + t.listeners = append(t.listeners, listener) +} + +// RemoveListener delete a listener from the list. +func (t *Text) RemoveListener(listener TextListener) { + victim := -1 + for i, lis := range t.listeners { + if lis == listener { + victim = i + break + } + } + + if victim >= 0 { + t.listeners = append(t.listeners[:victim], t.listeners[victim+1:]...) + } +} + +func (t *Text) filterChanged(lines []string) { + t.fireTextFiltered(lines, t.filter(t.query, lines)) +} + +func (t *Text) fireTextChanged(lines []string) { + for _, lis := range t.listeners { + lis.TextChanged(lines) + } +} + +func (t *Text) fireTextFiltered(lines []string, matches fuzzy.Matches) { + for _, lis := range t.listeners { + lis.TextFiltered(lines, matches) + } +} + +// ---------------------------------------------------------------------------- +// Helpers... + +func (t *Text) filter(q string, lines []string) fuzzy.Matches { + if q == "" { + return nil + } + if isFuzzySelector(q) { + return t.fuzzyFilter(strings.TrimSpace(q[2:]), lines) + } + return t.rxFilter(q, lines) +} + +func (*Text) fuzzyFilter(q string, lines []string) fuzzy.Matches { + return fuzzy.Find(q, lines) +} + +func (*Text) rxFilter(q string, lines []string) fuzzy.Matches { + rx, err := regexp.Compile(`(?i)` + q) + if err != nil { + return nil + } + matches := make(fuzzy.Matches, 0, len(lines)) + for i, l := range lines { + if loc := rx.FindStringIndex(l); len(loc) == 2 { + matches = append(matches, fuzzy.Match{Str: q, Index: i, MatchedIndexes: loc}) + } + } + + return matches +} diff --git a/internal/model/text_test.go b/internal/model/text_test.go new file mode 100644 index 00000000..d8c2f949 --- /dev/null +++ b/internal/model/text_test.go @@ -0,0 +1,90 @@ +package model_test + +import ( + "testing" + + "github.com/derailed/k9s/internal/model" + "github.com/sahilm/fuzzy" + "github.com/stretchr/testify/assert" +) + +func TestNewText(t *testing.T) { + m := model.NewText() + + lis := textLis{} + m.AddListener(&lis) + + m.SetText("Hello World\nBumbleBeeTuna") + + assert.Equal(t, 1, lis.changed) + assert.Equal(t, 2, lis.lines) + assert.Equal(t, 0, lis.filtered) + assert.Equal(t, 0, lis.matches) +} + +func TestTextFilterRXMatch(t *testing.T) { + m := model.NewText() + + lis := textLis{} + m.AddListener(&lis) + + m.SetText("Hello World\nBumbleBeeTuna") + m.Filter("world") + + assert.Equal(t, 1, lis.changed) + assert.Equal(t, 2, lis.lines) + assert.Equal(t, 1, lis.filtered) + assert.Equal(t, 1, lis.matches) + assert.Equal(t, 6, lis.index) +} + +func TestTextFilterFuzzyMatch(t *testing.T) { + m := model.NewText() + + lis := textLis{} + m.AddListener(&lis) + + m.SetText("Hello World\nBumbleBeeTuna") + m.Filter("-f world") + + assert.Equal(t, 1, lis.changed) + assert.Equal(t, 2, lis.lines) + assert.Equal(t, 1, lis.filtered) + assert.Equal(t, 1, lis.matches) + assert.Equal(t, 6, lis.index) +} + +func TestTextFilterNoMatch(t *testing.T) { + m := model.NewText() + + lis := textLis{} + m.AddListener(&lis) + + m.SetText("Hello World\nBumbleBeeTuna") + m.Filter("blee") + + assert.Equal(t, 1, lis.changed) + assert.Equal(t, 2, lis.lines) + assert.Equal(t, 1, lis.filtered) + assert.Equal(t, 0, lis.matches) + assert.Equal(t, 0, lis.index) +} + +// Helpers... + +type textLis struct { + changed, filtered, matches, lines, index int +} + +func (l *textLis) TextChanged(ll []string) { + l.lines = len(ll) + l.changed++ +} + +func (l *textLis) TextFiltered(ll []string, mm fuzzy.Matches) { + l.matches = len(mm) + l.filtered++ + if len(mm) > 0 && len(mm[0].MatchedIndexes) > 0 { + l.index = mm[0].MatchedIndexes[0] + } +} diff --git a/internal/model/tree.go b/internal/model/tree.go index 55dc4284..400944e1 100644 --- a/internal/model/tree.go +++ b/internal/model/tree.go @@ -229,7 +229,7 @@ func (t *Tree) reconcile(ctx context.Context) error { } if t.root == nil || t.root.Diff(root) { t.root = root - t.fireTreeTreeChanged(t.root) + t.fireTreeChanged(t.root) } return nil @@ -251,7 +251,7 @@ func (t *Tree) resourceMeta() ResourceMeta { return meta } -func (t *Tree) fireTreeTreeChanged(root *xray.TreeNode) { +func (t *Tree) fireTreeChanged(root *xray.TreeNode) { for _, l := range t.listeners { l.TreeChanged(root) } diff --git a/internal/perf/benchmark.go b/internal/perf/benchmark.go index 53dfff8c..cbb669b8 100644 --- a/internal/perf/benchmark.go +++ b/internal/perf/benchmark.go @@ -64,6 +64,8 @@ func (b *Benchmark) init(base, version string) error { } req.Header.Set("User-Agent", ua) + log.Debug().Msgf("Benching %d:%d", b.config.N, b.config.C) + b.worker = &requester.Work{ Request: req, RequestBody: []byte(b.config.HTTP.Body), diff --git a/internal/render/benchmark.go b/internal/render/benchmark.go index 68da849a..2f137de9 100644 --- a/internal/render/benchmark.go +++ b/internal/render/benchmark.go @@ -1,6 +1,7 @@ package render import ( + "errors" "fmt" "io/ioutil" "os" @@ -8,6 +9,7 @@ import ( "strconv" "strings" + "github.com/derailed/k9s/internal/client" "github.com/derailed/tview" "github.com/gdamore/tcell" "golang.org/x/text/language" @@ -28,11 +30,10 @@ var ( type Benchmark struct{} // ColorerFunc colors a resource row. -func (Benchmark) ColorerFunc() ColorerFunc { +func (b Benchmark) ColorerFunc() ColorerFunc { return func(ns string, re RowEvent) tcell.Color { c := tcell.ColorPaleGreen - statusCol := 2 - if strings.TrimSpace(re.Row.Fields[statusCol]) != "pass" { + if !Happy(ns, re.Row) { c = ErrColor } return c @@ -50,6 +51,7 @@ func (Benchmark) Header(ns string) HeaderRow { Header{Name: "2XX", Align: tview.AlignRight}, Header{Name: "4XX/5XX", Align: tview.AlignRight}, Header{Name: "REPORT"}, + Header{Name: "VALID", Wide: true}, Header{Name: "AGE", Decorator: AgeDecorator}, } } @@ -72,6 +74,24 @@ func (b Benchmark) Render(o interface{}, ns string, r *Row) error { return err } b.augmentRow(r.Fields, data) + r.Fields[8] = asStatus(b.diagnose(ns, r.Fields)) + + return nil +} + +// Happy returns true if resoure is happy, false otherwise +func (Benchmark) diagnose(ns string, ff Fields) error { + statusCol := 3 + if !client.IsAllNamespaces(ns) { + statusCol-- + } + + if len(ff) < statusCol { + return nil + } + if ff[statusCol] != "pass" { + return errors.New("failed benchmark") + } return nil } @@ -87,7 +107,7 @@ func (Benchmark) readFile(file string) (string, error) { return string(data), nil } -func (Benchmark) initRow(row Fields, f os.FileInfo) error { +func (b Benchmark) initRow(row Fields, f os.FileInfo) error { tokens := strings.Split(f.Name(), "_") if len(tokens) < 2 { return fmt.Errorf("Invalid file name %s", f.Name()) @@ -95,7 +115,7 @@ func (Benchmark) initRow(row Fields, f os.FileInfo) error { row[0] = tokens[0] row[1] = tokens[1] row[7] = f.Name() - row[8] = timeToAge(f.ModTime()) + row[9] = timeToAge(f.ModTime()) return nil } diff --git a/internal/render/benchmark_int_test.go b/internal/render/benchmark_int_test.go index 7489b914..6c7c5384 100644 --- a/internal/render/benchmark_int_test.go +++ b/internal/render/benchmark_int_test.go @@ -18,19 +18,19 @@ func TestAugmentRow(t *testing.T) { e Fields }{ "cool": { - "assets/b1.txt", + "testdata/b1.txt", Fields{"pass", "3.3544", "29.8116", "100", "0"}, }, "2XX": { - "assets/b4.txt", + "testdata/b4.txt", Fields{"pass", "3.3544", "29.8116", "160", "0"}, }, "4XX/5XX": { - "assets/b2.txt", + "testdata/b2.txt", Fields{"pass", "3.3544", "29.8116", "100", "12"}, }, "toast": { - "assets/b3.txt", + "testdata/b3.txt", Fields{"fail", "2.3688", "35.4606", "0", "0"}, }, } diff --git a/internal/render/chart.go b/internal/render/chart.go index cb3aa0c7..0cfbdfb0 100644 --- a/internal/render/chart.go +++ b/internal/render/chart.go @@ -18,6 +18,10 @@ type Chart struct{} // ColorerFunc colors a resource row. func (Chart) ColorerFunc() ColorerFunc { return func(ns string, re RowEvent) tcell.Color { + if !Happy(ns, re.Row) { + return ErrColor + } + return tcell.ColorMediumSpringGreen } } @@ -35,6 +39,7 @@ func (Chart) Header(ns string) HeaderRow { Header{Name: "STATUS"}, Header{Name: "CHART"}, Header{Name: "APP VERSION"}, + Header{Name: "VALID", Wide: true}, Header{Name: "AGE", Decorator: AgeDecorator}, ) } @@ -57,16 +62,25 @@ func (c Chart) Render(o interface{}, ns string, r *Row) error { h.Release.Info.Status.String(), h.Release.Chart.Metadata.Name+"-"+h.Release.Chart.Metadata.Version, h.Release.Chart.Metadata.AppVersion, + asStatus(c.diagnose(h.Release.Info.Status.String())), toAge(metav1.Time{Time: h.Release.Info.LastDeployed.Time}), ) return nil } +func (c Chart) diagnose(s string) error { + if s != "deployed" { + return fmt.Errorf("chart is in an invalid state") + } + + return nil +} + // ---------------------------------------------------------------------------- // Helpers... -// ChartRes represents an alias resource. +// ChartRes represents an helm chart resource. type ChartRes struct { Release *release.Release } diff --git a/internal/render/container.go b/internal/render/container.go index 0e74e04f..410c55c4 100644 --- a/internal/render/container.go +++ b/internal/render/container.go @@ -1,6 +1,7 @@ package render import ( + "errors" "fmt" "strconv" "strings" @@ -36,18 +37,18 @@ type ContainerWithMetrics interface { // Container renders a K8s Container to screen. type Container struct{} +const readyCol = 2 + // ColorerFunc colors a resource row. -func (Container) ColorerFunc() ColorerFunc { - return func(ns string, r RowEvent) tcell.Color { - c := DefaultColorer(ns, r) +func (c Container) ColorerFunc() ColorerFunc { + return func(ns string, re RowEvent) tcell.Color { + color := DefaultColorer(ns, re) - readyCol := 2 - if strings.TrimSpace(r.Row.Fields[readyCol]) == "false" { - c = ErrColor + if !Happy(ns, re.Row) { + color = ErrColor } - stateCol := readyCol + 1 - switch strings.TrimSpace(r.Row.Fields[stateCol]) { + switch strings.TrimSpace(re.Row.Fields[stateCol]) { case ContainerCreating, PodInitializing: return AddColor case Terminating, Initialized: @@ -56,10 +57,10 @@ func (Container) ColorerFunc() ColorerFunc { return CompletedColor case Running: default: - c = ErrColor + color = ErrColor } - return c + return color } } @@ -80,6 +81,7 @@ func (Container) Header(ns string) HeaderRow { Header{Name: "%CPU/L", Align: tview.AlignRight}, Header{Name: "%MEM/L", Align: tview.AlignRight}, Header{Name: "PORTS"}, + Header{Name: "VALID", Wide: true}, Header{Name: "AGE", Decorator: AgeDecorator}, } } @@ -114,12 +116,25 @@ func (c Container) Render(o interface{}, name string, r *Row) error { limit.cpu, limit.mem, toStrPorts(co.Container.Ports), + asStatus(c.diagnose(state, ready)), toAge(co.Age), ) return nil } +// Happy returns true if resoure is happy, false otherwise +func (Container) diagnose(state, ready string) error { + if state == "Completed" { + return nil + } + + if ready == "false" { + return errors.New("container is not ready") + } + return nil +} + // ---------------------------------------------------------------------------- // Helpers... diff --git a/internal/render/container_test.go b/internal/render/container_test.go index 051cb2fb..1d14321a 100644 --- a/internal/render/container_test.go +++ b/internal/render/container_test.go @@ -41,6 +41,7 @@ func TestContainer(t *testing.T) { "50", "20", "", + "container is not ready", }, r.Fields[:len(r.Fields)-1], ) diff --git a/internal/render/cr.go b/internal/render/cr.go index aed5710c..5b7c5734 100644 --- a/internal/render/cr.go +++ b/internal/render/cr.go @@ -21,6 +21,7 @@ func (ClusterRole) ColorerFunc() ColorerFunc { func (ClusterRole) Header(string) HeaderRow { return HeaderRow{ Header{Name: "NAME"}, + Header{Name: "LABELS", Wide: true}, Header{Name: "AGE", Decorator: AgeDecorator}, } } @@ -40,6 +41,7 @@ func (ClusterRole) Render(o interface{}, ns string, r *Row) error { r.ID = client.FQN("-", cr.ObjectMeta.Name) r.Fields = Fields{ cr.Name, + mapToStr(cr.Labels), toAge(cr.ObjectMeta.CreationTimestamp), } diff --git a/internal/render/crb.go b/internal/render/crb.go index 74c4ee37..2543cfb1 100644 --- a/internal/render/crb.go +++ b/internal/render/crb.go @@ -22,8 +22,9 @@ func (ClusterRoleBinding) Header(string) HeaderRow { return HeaderRow{ Header{Name: "NAME"}, Header{Name: "CLUSTERROLE"}, - Header{Name: "KIND"}, + Header{Name: "SUBJECT-KIND"}, Header{Name: "SUBJECTS"}, + Header{Name: "LABELS", Wide: true}, Header{Name: "AGE", Decorator: AgeDecorator}, } } @@ -48,6 +49,7 @@ func (ClusterRoleBinding) Render(o interface{}, ns string, r *Row) error { crb.RoleRef.Name, kind, ss, + mapToStr(crb.Labels), toAge(crb.ObjectMeta.CreationTimestamp), } diff --git a/internal/render/crb_test.go b/internal/render/crb_test.go index 08f41b35..cbed129e 100644 --- a/internal/render/crb_test.go +++ b/internal/render/crb_test.go @@ -13,5 +13,5 @@ func TestClusterRoleBindingRender(t *testing.T) { c.Render(load(t, "crb"), "-", &r) assert.Equal(t, "-/blee", r.ID) - assert.Equal(t, render.Fields{"blee", "blee", "USR", "fernand"}, r.Fields[:4]) + assert.Equal(t, render.Fields{"blee", "blee", "User", "fernand"}, r.Fields[:4]) } diff --git a/internal/render/crd.go b/internal/render/crd.go index 120ebdc8..a29faf01 100644 --- a/internal/render/crd.go +++ b/internal/render/crd.go @@ -22,6 +22,7 @@ func (CustomResourceDefinition) ColorerFunc() ColorerFunc { func (CustomResourceDefinition) Header(string) HeaderRow { return HeaderRow{ Header{Name: "NAME"}, + Header{Name: "LABELS", Wide: true}, Header{Name: "AGE", Decorator: AgeDecorator}, } } @@ -45,6 +46,7 @@ func (CustomResourceDefinition) Render(o interface{}, ns string, r *Row) error { r.ID = client.FQN(client.ClusterScope, extractMetaField(meta, "name")) r.Fields = Fields{ extractMetaField(meta, "name"), + mapToIfc(meta["labels"]), toAge(metav1.Time{Time: t}), } diff --git a/internal/render/cronjob.go b/internal/render/cronjob.go index d389fc1c..5b182bbd 100644 --- a/internal/render/cronjob.go +++ b/internal/render/cronjob.go @@ -3,9 +3,12 @@ package render import ( "fmt" "strconv" + "strings" "github.com/derailed/k9s/internal/client" + batchv1 "k8s.io/api/batch/v1" batchv1beta1 "k8s.io/api/batch/v1beta1" + v1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" ) @@ -31,6 +34,11 @@ func (CronJob) Header(ns string) HeaderRow { Header{Name: "SUSPEND"}, Header{Name: "ACTIVE"}, Header{Name: "LAST_SCHEDULE"}, + Header{Name: "SELECTOR", Wide: true}, + Header{Name: "CONTAINERS", Wide: true}, + Header{Name: "IMAGES", Wide: true}, + Header{Name: "LABELS", Wide: true}, + Header{Name: "VALID", Wide: true}, Header{Name: "AGE", Decorator: AgeDecorator}, ) } @@ -63,8 +71,64 @@ func (c CronJob) Render(o interface{}, ns string, r *Row) error { boolPtrToStr(cj.Spec.Suspend), strconv.Itoa(len(cj.Status.Active)), lastScheduled, + jobSelector(cj.Spec.JobTemplate.Spec), + podContainerNames(cj.Spec.JobTemplate.Spec.Template.Spec, true), + podImageNames(cj.Spec.JobTemplate.Spec.Template.Spec, true), + mapToStr(cj.Labels), + "", toAge(cj.ObjectMeta.CreationTimestamp), ) return nil } + +// Helpers + +func jobSelector(spec batchv1.JobSpec) string { + if spec.Selector == nil { + return MissingValue + } + if len(spec.Selector.MatchLabels) > 0 { + return mapToStr(spec.Selector.MatchLabels) + } + if len(spec.Selector.MatchExpressions) == 0 { + return "" + } + + ss := make([]string, 0, len(spec.Selector.MatchExpressions)) + for _, e := range spec.Selector.MatchExpressions { + ss = append(ss, e.String()) + } + + return strings.Join(ss, " ") +} + +func podContainerNames(spec v1.PodSpec, includeInit bool) string { + cc := make([]string, 0, len(spec.Containers)+len(spec.InitContainers)) + + if includeInit { + for _, c := range spec.InitContainers { + cc = append(cc, c.Name) + } + } + for _, c := range spec.Containers { + cc = append(cc, c.Name) + } + + return strings.Join(cc, ",") +} + +func podImageNames(spec v1.PodSpec, includeInit bool) string { + cc := make([]string, 0, len(spec.Containers)+len(spec.InitContainers)) + + if includeInit { + for _, c := range spec.InitContainers { + cc = append(cc, c.Image) + } + } + for _, c := range spec.Containers { + cc = append(cc, c.Image) + } + + return strings.Join(cc, ",") +} diff --git a/internal/render/dp.go b/internal/render/dp.go index 8e636f72..88424063 100644 --- a/internal/render/dp.go +++ b/internal/render/dp.go @@ -3,7 +3,6 @@ package render import ( "fmt" "strconv" - "strings" "github.com/derailed/k9s/internal/client" "github.com/derailed/tview" @@ -17,19 +16,13 @@ import ( type Deployment struct{} // ColorerFunc colors a resource row. -func (Deployment) ColorerFunc() ColorerFunc { +func (d Deployment) ColorerFunc() ColorerFunc { return func(ns string, r RowEvent) tcell.Color { c := DefaultColorer(ns, r) if r.Kind == EventAdd || r.Kind == EventUpdate { return c } - - readyCol := 2 - if !client.IsAllNamespaces(ns) { - readyCol-- - } - tokens := strings.Split(r.Row.Fields[readyCol], "/") - if tokens[0] != tokens[1] { + if !Happy(ns, r.Row) { return ErrColor } @@ -49,6 +42,9 @@ func (Deployment) Header(ns string) HeaderRow { Header{Name: "READY"}, Header{Name: "UP-TO-DATE", Align: tview.AlignRight}, Header{Name: "AVAILABLE", Align: tview.AlignRight}, + Header{Name: "READY", Align: tview.AlignRight}, + Header{Name: "LABELS", Wide: true}, + Header{Name: "VALID", Wide: true}, Header{Name: "AGE", Decorator: AgeDecorator}, ) } @@ -73,11 +69,21 @@ func (d Deployment) Render(o interface{}, ns string, r *Row) error { } r.Fields = append(r.Fields, dp.Name, - strconv.Itoa(int(dp.Status.AvailableReplicas))+"/"+strconv.Itoa(int(*dp.Spec.Replicas)), + strconv.Itoa(int(dp.Status.AvailableReplicas))+"/"+strconv.Itoa(int(dp.Status.Replicas)), strconv.Itoa(int(dp.Status.UpdatedReplicas)), strconv.Itoa(int(dp.Status.AvailableReplicas)), + strconv.Itoa(int(dp.Status.ReadyReplicas)), + mapToStr(dp.Labels), + asStatus(d.diagnose(dp.Status.Replicas, dp.Status.AvailableReplicas)), toAge(dp.ObjectMeta.CreationTimestamp), ) return nil } + +func (Deployment) diagnose(d, r int32) error { + if d != r { + return fmt.Errorf("desiring %d replicas got %d available", d, r) + } + return nil +} diff --git a/internal/render/ds.go b/internal/render/ds.go index b70f957c..191c59a5 100644 --- a/internal/render/ds.go +++ b/internal/render/ds.go @@ -3,7 +3,6 @@ package render import ( "fmt" "strconv" - "strings" "github.com/derailed/k9s/internal/client" "github.com/derailed/tview" @@ -17,18 +16,14 @@ import ( type DaemonSet struct{} // ColorerFunc colors a resource row. -func (DaemonSet) ColorerFunc() ColorerFunc { +func (d DaemonSet) ColorerFunc() ColorerFunc { return func(ns string, r RowEvent) tcell.Color { c := DefaultColorer(ns, r) if r.Kind == EventAdd || r.Kind == EventUpdate { return c } - desiredCol := 2 - if !client.IsAllNamespaces(ns) { - desiredCol = 1 - } - if strings.TrimSpace(r.Row.Fields[desiredCol]) != strings.TrimSpace(r.Row.Fields[desiredCol+2]) { + if !Happy(ns, r.Row) { return ErrColor } @@ -50,6 +45,8 @@ func (DaemonSet) Header(ns string) HeaderRow { Header{Name: "READY", Align: tview.AlignRight}, Header{Name: "UP-TO-DATE", Align: tview.AlignRight}, Header{Name: "AVAILABLE", Align: tview.AlignRight}, + Header{Name: "LABELS", Wide: true}, + Header{Name: "VALID", Wide: true}, Header{Name: "AGE", Decorator: AgeDecorator}, ) } @@ -78,8 +75,18 @@ func (d DaemonSet) Render(o interface{}, ns string, r *Row) error { strconv.Itoa(int(ds.Status.NumberReady)), strconv.Itoa(int(ds.Status.UpdatedNumberScheduled)), strconv.Itoa(int(ds.Status.NumberAvailable)), + mapToStr(ds.Labels), + asStatus(d.diagnose(ds.Status.DesiredNumberScheduled, ds.Status.NumberReady)), toAge(ds.ObjectMeta.CreationTimestamp), ) return nil } + +// Happy returns true if resoure is happy, false otherwise +func (DaemonSet) diagnose(d, r int32) error { + if d != r { + return fmt.Errorf("desiring %d replicas but %d ready", d, r) + } + return nil +} diff --git a/internal/render/ev.go b/internal/render/ev.go index 67494d2a..ef4b6813 100644 --- a/internal/render/ev.go +++ b/internal/render/ev.go @@ -1,6 +1,7 @@ package render import ( + "errors" "fmt" "strconv" "strings" @@ -17,19 +18,20 @@ import ( type Event struct{} // ColorerFunc colors a resource row. -func (Event) ColorerFunc() ColorerFunc { +func (e Event) ColorerFunc() ColorerFunc { return func(ns string, r RowEvent) tcell.Color { c := DefaultColorer(ns, r) + if !Happy(ns, r.Row) { + return ErrColor + } + markCol := 3 if !client.IsAllNamespaces(ns) { markCol = 2 } - switch strings.TrimSpace(r.Row.Fields[markCol]) { - case "Failed": - c = ErrColor - case "Killing": - c = KillColor + if strings.TrimSpace(r.Row.Fields[markCol]) == "Killing" { + return KillColor } return c @@ -45,10 +47,12 @@ func (Event) Header(ns string) HeaderRow { return append(h, Header{Name: "NAME"}, + Header{Name: "TYPE"}, Header{Name: "REASON"}, Header{Name: "SOURCE"}, Header{Name: "COUNT", Align: tview.AlignRight}, - Header{Name: "MESSAGE"}, + Header{Name: "MESSAGE", Wide: true}, + Header{Name: "VALID", Wide: true}, Header{Name: "AGE", Decorator: AgeDecorator}, ) } @@ -72,15 +76,27 @@ func (e Event) Render(o interface{}, ns string, r *Row) error { } r.Fields = append(r.Fields, asRef(ev.InvolvedObject), + ev.Type, ev.Reason, ev.Source.Component, strconv.Itoa(int(ev.Count)), ev.Message, + asStatus(e.diagnose(ev.Type)), toAge(ev.LastTimestamp)) return nil } +// Happy returns true if resoure is happy, false otherwise +func (Event) diagnose(kind string) error { + if kind != "Normal" { + return errors.New("failed event") + } + return nil +} + +// Helpers... + func asRef(r v1.ObjectReference) string { return strings.ToLower(r.Kind) + ":" + r.Name } diff --git a/internal/render/ev_test.go b/internal/render/ev_test.go index 766e5c7a..9388969e 100644 --- a/internal/render/ev_test.go +++ b/internal/render/ev_test.go @@ -13,5 +13,17 @@ func TestEventRender(t *testing.T) { c.Render(load(t, "ev"), "", &r) assert.Equal(t, "default/hello-1567197780-mn4mv.15bfce150bd764dd", r.ID) - assert.Equal(t, render.Fields{"default", "pod:hello-1567197780-mn4mv", "Pulled", "kubelet", "1", `Successfully pulled image "blang/busybox-bash"`}, r.Fields[:6]) + assert.Equal(t, render.Fields{"default", "pod:hello-1567197780-mn4mv", "Normal", "Pulled", "kubelet", "1", `Successfully pulled image "blang/busybox-bash"`}, r.Fields[:7]) +} + +func BenchmarkEventRender(b *testing.B) { + ev := load(b, "ev") + var re render.Event + r := render.NewRow(7) + + b.ResetTimer() + b.ReportAllocs() + for i := 0; i < b.N; i++ { + _ = re.Render(&ev, "", &r) + } } diff --git a/internal/render/generic.go b/internal/render/generic.go index d1e70bd9..46950ea7 100644 --- a/internal/render/generic.go +++ b/internal/render/generic.go @@ -19,6 +19,11 @@ type Generic struct { ageIndex int } +// Happy returns true if resoure is happy, false otherwise +func (Generic) Happy(ns string, r Row) bool { + return true +} + // SetTable sets the tabular resource. func (g *Generic) SetTable(t *metav1beta1.Table) { g.table = t diff --git a/internal/render/helpers.go b/internal/render/helpers.go index 33996772..1a26f447 100644 --- a/internal/render/helpers.go +++ b/internal/render/helpers.go @@ -13,6 +13,12 @@ import ( "k8s.io/apimachinery/pkg/util/duration" ) +// Happy returns true if resoure is happy, false otherwise +func Happy(ns string, r Row) bool { + validCol := r.Len() - 2 + return strings.TrimSpace(r.Fields[validCol]) == "" +} + const megaByte = 1024 * 1024 // ToMB converts bytes to megabytes. @@ -20,6 +26,13 @@ func ToMB(v int64) float64 { return float64(v) / megaByte } +func asStatus(err error) string { + if err == nil { + return "" + } + return err.Error() +} + func asSelector(s *metav1.LabelSelector) string { sel, err := metav1.LabelSelectorAsSelector(s) if err != nil { @@ -84,7 +97,7 @@ func join(a []string, sep string) string { var buff strings.Builder buff.Grow(n) - buff.WriteString(a[0]) + buff.WriteString(b[0]) for _, s := range b[1:] { buff.WriteString(sep) buff.WriteString(s) @@ -151,7 +164,7 @@ func Truncate(str string, width int) string { func mapToStr(m map[string]string) (s string) { if len(m) == 0 { - return MissingValue + return "" } kk := make([]string, 0, len(m)) @@ -163,7 +176,40 @@ func mapToStr(m map[string]string) (s string) { for i, k := range kk { s += k + "=" + m[k] if i < len(kk)-1 { - s += "," + s += " " + } + } + + return +} + +func mapToIfc(m interface{}) (s string) { + if m == nil { + return "" + } + + mm, ok := m.(map[string]interface{}) + if !ok { + return "" + } + if len(mm) == 0 { + return "" + } + + kk := make([]string, 0, len(mm)) + for k := range mm { + kk = append(kk, k) + } + sort.Strings(kk) + + for i, k := range kk { + str, ok := mm[k].(string) + if !ok { + continue + } + s += k + "=" + str + if i < len(kk)-1 { + s += " " } } diff --git a/internal/render/helpers_test.go b/internal/render/helpers_test.go index b45edd36..51b93339 100644 --- a/internal/render/helpers_test.go +++ b/internal/render/helpers_test.go @@ -82,10 +82,11 @@ func TestJoin(t *testing.T) { i []string e string }{ - "zero": {[]string{}, ""}, - "std": {[]string{"a", "b", "c"}, "a,b,c"}, - "blank": {[]string{"", "", ""}, ""}, - "sparse": {[]string{"a", "", "c"}, "a,c"}, + "zero": {[]string{}, ""}, + "std": {[]string{"a", "b", "c"}, "a,b,c"}, + "blank": {[]string{"", "", ""}, ""}, + "sparse": {[]string{"a", "", "c"}, "a,c"}, + "withBlank": {[]string{"", "a", "c"}, "a,c"}, } for k := range uu { @@ -304,8 +305,8 @@ func TestMapToStr(t *testing.T) { i map[string]string e string }{ - {map[string]string{"blee": "duh", "aa": "bb"}, "aa=bb,blee=duh"}, - {map[string]string{}, MissingValue}, + {map[string]string{"blee": "duh", "aa": "bb"}, "aa=bb blee=duh"}, + {map[string]string{}, ""}, } for _, u := range uu { assert.Equal(t, u.e, mapToStr(u.i)) diff --git a/internal/render/hpa.go b/internal/render/hpa.go index 6042b49f..08bba6a4 100644 --- a/internal/render/hpa.go +++ b/internal/render/hpa.go @@ -36,6 +36,7 @@ func (HorizontalPodAutoscaler) Header(ns string) HeaderRow { Header{Name: "MINPODS", Align: tview.AlignRight}, Header{Name: "MAXPODS", Align: tview.AlignRight}, Header{Name: "REPLICAS", Align: tview.AlignRight}, + Header{Name: "VALID", Wide: true}, Header{Name: "AGE", Decorator: AgeDecorator}, ) } @@ -80,6 +81,7 @@ func (h HorizontalPodAutoscaler) renderV1(raw *unstructured.Unstructured, ns str strconv.Itoa(int(*hpa.Spec.MinReplicas)), strconv.Itoa(int(hpa.Spec.MaxReplicas)), strconv.Itoa(int(hpa.Status.CurrentReplicas)), + "", toAge(hpa.ObjectMeta.CreationTimestamp), ) @@ -106,6 +108,7 @@ func (h HorizontalPodAutoscaler) renderV2b1(raw *unstructured.Unstructured, ns s strconv.Itoa(int(*hpa.Spec.MinReplicas)), strconv.Itoa(int(hpa.Spec.MaxReplicas)), strconv.Itoa(int(hpa.Status.CurrentReplicas)), + "", toAge(hpa.ObjectMeta.CreationTimestamp), ) @@ -132,6 +135,7 @@ func (h HorizontalPodAutoscaler) renderV2b2(raw *unstructured.Unstructured, ns s strconv.Itoa(int(*hpa.Spec.MinReplicas)), strconv.Itoa(int(hpa.Spec.MaxReplicas)), strconv.Itoa(int(hpa.Status.CurrentReplicas)), + "", toAge(hpa.ObjectMeta.CreationTimestamp), ) diff --git a/internal/render/ing.go b/internal/render/ing.go index 9f90ba74..2ba806fa 100644 --- a/internal/render/ing.go +++ b/internal/render/ing.go @@ -31,6 +31,7 @@ func (Ingress) Header(ns string) HeaderRow { Header{Name: "HOSTS"}, Header{Name: "ADDRESS"}, Header{Name: "PORT"}, + Header{Name: "VALID", Wide: true}, Header{Name: "AGE", Decorator: AgeDecorator}, ) } @@ -57,6 +58,7 @@ func (i Ingress) Render(o interface{}, ns string, r *Row) error { toHosts(ing.Spec.Rules), toAddress(ing.Status.LoadBalancer), toTLSPorts(ing.Spec.TLS), + "", toAge(ing.ObjectMeta.CreationTimestamp), ) diff --git a/internal/render/job.go b/internal/render/job.go index 62048990..22d32f1a 100644 --- a/internal/render/job.go +++ b/internal/render/job.go @@ -7,9 +7,9 @@ import ( "time" "github.com/derailed/k9s/internal/client" - "github.com/rs/zerolog/log" batchv1 "k8s.io/api/batch/v1" v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/util/duration" @@ -34,15 +34,16 @@ func (Job) Header(ns string) HeaderRow { Header{Name: "NAME"}, Header{Name: "COMPLETIONS"}, Header{Name: "DURATION"}, - Header{Name: "CONTAINERS"}, - Header{Name: "IMAGES"}, + Header{Name: "SELECTOR", Wide: true}, + Header{Name: "CONTAINERS", Wide: true}, + Header{Name: "IMAGES", Wide: true}, + Header{Name: "VALID", Wide: true}, Header{Name: "AGE", Decorator: AgeDecorator}, ) } // Render renders a K8s resource to screen. func (j Job) Render(o interface{}, ns string, r *Row) error { - log.Debug().Msgf("JOB RENDER %q", ns) raw, ok := o.(*unstructured.Unstructured) if !ok { return fmt.Errorf("Expected Job, but got %T", o) @@ -52,6 +53,7 @@ func (j Job) Render(o interface{}, ns string, r *Row) error { if err != nil { return err } + ready := toCompletion(job.Spec, job.Status) r.ID = client.MetaFQN(job.ObjectMeta) r.Fields = make(Fields, 0, len(j.Header(ns))) @@ -61,16 +63,29 @@ func (j Job) Render(o interface{}, ns string, r *Row) error { cc, ii := toContainers(job.Spec.Template.Spec) r.Fields = append(r.Fields, job.Name, - toCompletion(job.Spec, job.Status), + ready, toDuration(job.Status), + jobSelector(job.Spec), cc, ii, + asStatus(j.diagnose(ready, job.Status.CompletionTime)), toAge(job.ObjectMeta.CreationTimestamp), ) return nil } +func (Job) diagnose(ready string, completed *metav1.Time) error { + if completed == nil { + return nil + } + tokens := strings.Split(ready, "/") + if tokens[0] != tokens[1] { + return fmt.Errorf("expecting %s completion got %s", tokens[1], tokens[0]) + } + return nil +} + // ---------------------------------------------------------------------------- // Helpers... diff --git a/internal/render/job_test.go b/internal/render/job_test.go index 7d375d57..63ff6fb7 100644 --- a/internal/render/job_test.go +++ b/internal/render/job_test.go @@ -13,5 +13,5 @@ func TestJobRender(t *testing.T) { c.Render(load(t, "job"), "", &r) assert.Equal(t, "default/hello-1567179180", r.ID) - assert.Equal(t, render.Fields{"default", "hello-1567179180", "1/1", "8s", "c1", "blang/busybox-bash"}, r.Fields[:6]) + assert.Equal(t, render.Fields{"default", "hello-1567179180", "1/1", "8s", "controller-uid=7473e6d0-cb3b-11e9-990f-42010a800218", "c1", "blang/busybox-bash"}, r.Fields[:7]) } diff --git a/internal/render/node.go b/internal/render/node.go index f64038a7..14dd41cc 100644 --- a/internal/render/node.go +++ b/internal/render/node.go @@ -1,11 +1,14 @@ package render import ( + "errors" "fmt" + "sort" "strings" "github.com/derailed/k9s/internal/client" "github.com/derailed/tview" + "github.com/gdamore/tcell" v1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" @@ -22,8 +25,16 @@ const ( type Node struct{} // ColorerFunc colors a resource row. -func (Node) ColorerFunc() ColorerFunc { - return DefaultColorer +func (n Node) ColorerFunc() ColorerFunc { + return func(ns string, r RowEvent) tcell.Color { + c := DefaultColorer(ns, r) + if !Happy(ns, r.Row) { + return ErrColor + } + + return c + } + } // Header returns a header row. @@ -31,17 +42,19 @@ func (Node) Header(_ string) HeaderRow { return HeaderRow{ Header{Name: "NAME"}, Header{Name: "STATUS"}, - Header{Name: "ROLE"}, - Header{Name: "VERSION"}, - Header{Name: "KERNEL"}, - Header{Name: "INTERNAL-IP"}, - Header{Name: "EXTERNAL-IP"}, + Header{Name: "ROLE", Wide: true}, + Header{Name: "VERSION", Wide: true}, + Header{Name: "KERNEL", Wide: true}, + Header{Name: "INTERNAL-IP", Wide: true}, + Header{Name: "EXTERNAL-IP", Wide: true}, Header{Name: "CPU", Align: tview.AlignRight}, Header{Name: "MEM", Align: tview.AlignRight}, Header{Name: "%CPU", Align: tview.AlignRight}, Header{Name: "%MEM", Align: tview.AlignRight}, Header{Name: "ACPU", Align: tview.AlignRight}, Header{Name: "AMEM", Align: tview.AlignRight}, + Header{Name: "LABELS", Wide: true}, + Header{Name: "VALID", Wide: true}, Header{Name: "AGE", Decorator: AgeDecorator}, } } @@ -69,17 +82,19 @@ func (n Node) Render(o interface{}, ns string, r *Row) error { c, a, p := gatherNodeMX(&no, oo.MX) - sta := make([]string, 10) - status(no.Status, no.Spec.Unschedulable, sta) - ro := make([]string, 10) - nodeRoles(&no, ro) + statuses := make(sort.StringSlice, 10) + status(no.Status, no.Spec.Unschedulable, statuses) + sort.Sort(statuses) + roles := make(sort.StringSlice, 10) + nodeRoles(&no, roles) + sort.Sort(roles) r.ID = client.FQN("", na) r.Fields = make(Fields, 0, len(n.Header(ns))) r.Fields = append(r.Fields, no.Name, - join(sta, ","), - join(ro, ","), + join(statuses, ","), + join(roles, ","), no.Status.NodeInfo.KubeletVersion, no.Status.NodeInfo.KernelVersion, iIP, @@ -90,12 +105,27 @@ func (n Node) Render(o interface{}, ns string, r *Row) error { p.mem, a.cpu, a.mem, + mapToStr(no.Labels), + asStatus(n.diagnose(statuses)), toAge(no.ObjectMeta.CreationTimestamp), ) return nil } +func (Node) diagnose(ss []string) error { + if len(ss) == 0 { + return nil + } + for _, s := range ss { + if s == "Ready" { + return nil + } + } + + return errors.New("node is not ready") +} + // ---------------------------------------------------------------------------- // Helpers... @@ -156,6 +186,9 @@ func nodeRoles(node *v1.Node, res []string) { res[index] = v index++ } + if index >= len(res) { + break + } } if empty(res) { diff --git a/internal/render/np.go b/internal/render/np.go index e1c1b881..05062971 100644 --- a/internal/render/np.go +++ b/internal/render/np.go @@ -28,12 +28,14 @@ func (NetworkPolicy) Header(ns string) HeaderRow { return append(h, Header{Name: "NAME"}, - Header{Name: "ING-SELECTOR"}, + Header{Name: "ING-SELECTOR", Wide: true}, Header{Name: "ING-PORTS"}, Header{Name: "ING-BLOCK"}, - Header{Name: "EGR-SELECTOR"}, + Header{Name: "EGR-SELECTOR", Wide: true}, Header{Name: "EGR-PORTS"}, Header{Name: "EGR-BLOCK"}, + Header{Name: "LABELS", Wide: true}, + Header{Name: "VALID", Wide: true}, Header{Name: "AGE", Decorator: AgeDecorator}, ) } @@ -66,6 +68,8 @@ func (n NetworkPolicy) Render(o interface{}, ns string, r *Row) error { es, ep, eb, + mapToStr(np.Labels), + "", toAge(np.ObjectMeta.CreationTimestamp), ) @@ -111,7 +115,14 @@ func egress(ee []v1beta1.NetworkPolicyEgressRule) (string, string, string) { func portsToStr(pp []v1beta1.NetworkPolicyPort) string { ports := make([]string, 0, len(pp)) for _, p := range pp { - ports = append(ports, string(*p.Protocol)+":"+p.Port.String()) + proto, port := NAValue, NAValue + if p.Protocol != nil { + proto = string(*p.Protocol) + } + if p.Port != nil { + port = p.Port.String() + } + ports = append(ports, proto+":"+port) } return strings.Join(ports, ",") } diff --git a/internal/render/ns.go b/internal/render/ns.go index b8547b31..0dc1bb07 100644 --- a/internal/render/ns.go +++ b/internal/render/ns.go @@ -1,6 +1,7 @@ package render import ( + "errors" "fmt" "strings" @@ -15,7 +16,7 @@ import ( type Namespace struct{} // ColorerFunc colors a resource row. -func (Namespace) ColorerFunc() ColorerFunc { +func (n Namespace) ColorerFunc() ColorerFunc { return func(ns string, r RowEvent) tcell.Color { c := DefaultColorer(ns, r) if r.Kind == EventAdd { @@ -25,9 +26,8 @@ func (Namespace) ColorerFunc() ColorerFunc { if r.Kind == EventUpdate { c = StdColor } - switch strings.TrimSpace(r.Row.Fields[1]) { - case "Inactive", Terminating: - c = ErrColor + if !Happy(ns, r.Row) { + return ErrColor } if strings.Contains(strings.TrimSpace(r.Row.Fields[0]), "*") { c = HighlightColor @@ -42,12 +42,14 @@ func (Namespace) Header(string) HeaderRow { return HeaderRow{ Header{Name: "NAME"}, Header{Name: "STATUS"}, + Header{Name: "LABELS", Wide: true}, + Header{Name: "VALID", Wide: true}, Header{Name: "AGE", Decorator: AgeDecorator}, } } // Render renders a K8s resource to screen. -func (Namespace) Render(o interface{}, _ string, r *Row) error { +func (n Namespace) Render(o interface{}, _ string, r *Row) error { raw, ok := o.(*unstructured.Unstructured) if !ok { return fmt.Errorf("Expected Namespace, but got %T", o) @@ -62,8 +64,17 @@ func (Namespace) Render(o interface{}, _ string, r *Row) error { r.Fields = Fields{ ns.Name, string(ns.Status.Phase), + mapToStr(ns.Labels), + asStatus(n.diagnose(ns.Status.Phase)), toAge(ns.ObjectMeta.CreationTimestamp), } return nil } + +func (Namespace) diagnose(phase v1.NamespacePhase) error { + if phase != v1.NamespaceActive && phase != v1.NamespaceTerminating { + return errors.New("namespace not ready") + } + return nil +} diff --git a/internal/render/ofaas.go b/internal/render/ofaas.go new file mode 100644 index 00000000..1edb3439 --- /dev/null +++ b/internal/render/ofaas.go @@ -0,0 +1,117 @@ +package render + +import ( + "errors" + "fmt" + "strconv" + "time" + + "github.com/derailed/k9s/internal/client" + "github.com/derailed/tview" + "github.com/gdamore/tcell" + ofaas "github.com/openfaas/faas-provider/types" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +const ( + fnStatusReady = "Ready" + fnStatusNotReady = "Not Ready" +) + +// OpenFaas renders an openfaas function to screen. +type OpenFaas struct{} + +// ColorerFunc colors a resource row. +func (o OpenFaas) ColorerFunc() ColorerFunc { + return func(ns string, re RowEvent) tcell.Color { + if !Happy(ns, re.Row) { + return ErrColor + } + + return tcell.ColorPaleTurquoise + } +} + +// Header returns a header row. +func (OpenFaas) Header(ns string) HeaderRow { + var h HeaderRow + if client.IsAllNamespaces(ns) { + h = append(h, Header{Name: "NAMESPACE"}) + } + + return append(h, + Header{Name: "NAME"}, + Header{Name: "STATUS"}, + Header{Name: "IMAGE"}, + Header{Name: "LABELS"}, + Header{Name: "INVOCATIONS", Align: tview.AlignRight}, + Header{Name: "REPLICAS", Align: tview.AlignRight}, + Header{Name: "AVAILABLE", Align: tview.AlignRight}, + Header{Name: "VALID", Wide: true}, + Header{Name: "AGE", Decorator: AgeDecorator}, + ) +} + +// Render renders a chart to screen. +func (o OpenFaas) Render(i interface{}, ns string, r *Row) error { + fn, ok := i.(OpenFaasRes) + if !ok { + return fmt.Errorf("expected OpenFaasRes, but got %T", o) + } + + var labels string + if fn.Function.Labels != nil { + labels = mapToStr(*fn.Function.Labels) + } + var status = fnStatusReady + if fn.Function.AvailableReplicas == 0 { + status = fnStatusNotReady + } + + r.ID = client.FQN(fn.Function.Namespace, fn.Function.Name) + r.Fields = make(Fields, 0, len(o.Header(ns))) + if client.IsAllNamespaces(ns) { + r.Fields = append(r.Fields, fn.Function.Namespace) + } + r.Fields = append(r.Fields, + fn.Function.Name, + status, + fn.Function.Image, + labels, + strconv.Itoa(int(fn.Function.InvocationCount)), + strconv.Itoa(int(fn.Function.Replicas)), + strconv.Itoa(int(fn.Function.AvailableReplicas)), + asStatus(o.diagnose(status)), + toAge(metav1.Time{Time: time.Now()}), + ) + + return nil +} + +func (OpenFaas) diagnose(status string) error { + if status != "Ready" { + return errors.New("function not ready") + } + + return nil +} + +// ---------------------------------------------------------------------------- +// Helpers... + +// OpenFaasRes represents an openfaas function resource. +type OpenFaasRes struct { + Function ofaas.FunctionStatus `json:"function"` +} + +// GetObjectKind returns a schema object. +func (OpenFaasRes) GetObjectKind() schema.ObjectKind { + return nil +} + +// DeepCopyObject returns a container copy. +func (h OpenFaasRes) DeepCopyObject() runtime.Object { + return h +} diff --git a/internal/render/ofaas_test.go b/internal/render/ofaas_test.go new file mode 100644 index 00000000..3deda698 --- /dev/null +++ b/internal/render/ofaas_test.go @@ -0,0 +1,34 @@ +package render_test + +import ( + "testing" + + "github.com/derailed/k9s/internal/render" + ofaas "github.com/openfaas/faas-provider/types" + "github.com/stretchr/testify/assert" +) + +func TestOpenFaasRender(t *testing.T) { + c := render.OpenFaas{} + r := render.NewRow(9) + c.Render(makeFn("blee"), "", &r) + + assert.Equal(t, "default/blee", r.ID) + assert.Equal(t, render.Fields{"default", "blee", "Ready", "nginx:0", "fred=blee", "10", "1", "1"}, r.Fields[:8]) +} + +// Helpers... + +func makeFn(n string) render.OpenFaasRes { + return render.OpenFaasRes{ + Function: ofaas.FunctionStatus{ + Name: n, + Namespace: "default", + Image: "nginx:0", + InvocationCount: 10, + Replicas: 1, + AvailableReplicas: 1, + Labels: &map[string]string{"fred": "blee"}, + }, + } +} diff --git a/internal/render/pdb.go b/internal/render/pdb.go index 9103b336..fb94eed4 100644 --- a/internal/render/pdb.go +++ b/internal/render/pdb.go @@ -3,7 +3,6 @@ package render import ( "fmt" "strconv" - "strings" "github.com/derailed/k9s/internal/client" "github.com/derailed/tview" @@ -18,24 +17,19 @@ import ( type PodDisruptionBudget struct{} // ColorerFunc colors a resource row. -func (PodDisruptionBudget) ColorerFunc() ColorerFunc { - return func(ns string, r RowEvent) tcell.Color { - c := DefaultColorer(ns, r) - if r.Kind == EventAdd || r.Kind == EventUpdate { +func (p PodDisruptionBudget) ColorerFunc() ColorerFunc { + return func(ns string, re RowEvent) tcell.Color { + c := DefaultColorer(ns, re) + if re.Kind == EventAdd || re.Kind == EventUpdate { return c } - markCol := 5 - if !client.IsAllNamespaces(ns) { - markCol-- - } - if strings.TrimSpace(r.Row.Fields[markCol]) != strings.TrimSpace(r.Row.Fields[markCol+1]) { + if !Happy(ns, re.Row) { return ErrColor } return StdColor } - } // Header returns a header row. @@ -53,6 +47,8 @@ func (PodDisruptionBudget) Header(ns string) HeaderRow { Header{Name: "CURRENT", Align: tview.AlignRight}, Header{Name: "DESIRED", Align: tview.AlignRight}, Header{Name: "EXPECTED", Align: tview.AlignRight}, + Header{Name: "LABELS", Wide: true}, + Header{Name: "VALID", Wide: true}, Header{Name: "AGE", Decorator: AgeDecorator}, ) } @@ -82,12 +78,21 @@ func (p PodDisruptionBudget) Render(o interface{}, ns string, r *Row) error { strconv.Itoa(int(pdb.Status.CurrentHealthy)), strconv.Itoa(int(pdb.Status.DesiredHealthy)), strconv.Itoa(int(pdb.Status.ExpectedPods)), + mapToStr(pdb.Labels), + asStatus(p.diagnose(pdb.Spec.MinAvailable.IntVal, pdb.Status.CurrentHealthy)), toAge(pdb.ObjectMeta.CreationTimestamp), ) return nil } +func (PodDisruptionBudget) diagnose(min, healthy int32) error { + if min > healthy { + return fmt.Errorf("expected %d but got %d", min, healthy) + } + return nil +} + // Helpers... func numbToStr(n *intstr.IntOrString) string { diff --git a/internal/render/pod.go b/internal/render/pod.go index ded5b483..011fb064 100644 --- a/internal/render/pod.go +++ b/internal/render/pod.go @@ -25,15 +25,11 @@ func (p Pod) ColorerFunc() ColorerFunc { return func(ns string, re RowEvent) tcell.Color { c := DefaultColorer(ns, re) - readyCol := 2 + statusCol := 4 if !client.IsAllNamespaces(ns) { - readyCol-- + statusCol-- } - statusCol := readyCol + 1 - - ready, status := strings.TrimSpace(re.Row.Fields[readyCol]), strings.TrimSpace(re.Row.Fields[statusCol]) - c = p.checkReadyCol(ready, status, c) - + status := strings.TrimSpace(re.Row.Fields[statusCol]) switch status { case ContainerCreating, PodInitializing: c = AddColor @@ -42,28 +38,19 @@ func (p Pod) ColorerFunc() ColorerFunc { case Completed: c = CompletedColor case Running: + c = StdColor case Terminating: c = KillColor default: - c = ErrColor + if !Happy(ns, re.Row) { + c = ErrColor + } } return c } } -func (Pod) checkReadyCol(readyCol, statusCol string, c tcell.Color) tcell.Color { - if statusCol == "Completed" { - return c - } - - tokens := strings.Split(readyCol, "/") - if len(tokens) == 2 && (tokens[0] == "0" || tokens[0] != tokens[1]) { - return ErrColor - } - return c -} - // Header returns a header row. func (Pod) Header(ns string) HeaderRow { var h HeaderRow @@ -74,17 +61,19 @@ func (Pod) Header(ns string) HeaderRow { return append(h, Header{Name: "NAME"}, Header{Name: "READY"}, - Header{Name: "STATUS"}, Header{Name: "RS", Align: tview.AlignRight}, + Header{Name: "STATUS"}, Header{Name: "CPU", Align: tview.AlignRight}, Header{Name: "MEM", Align: tview.AlignRight}, Header{Name: "%CPU/R", Align: tview.AlignRight}, Header{Name: "%MEM/R", Align: tview.AlignRight}, Header{Name: "%CPU/L", Align: tview.AlignRight}, Header{Name: "%MEM/L", Align: tview.AlignRight}, - Header{Name: "IP"}, - Header{Name: "NODE"}, - Header{Name: "QOS"}, + Header{Name: "IP", Wide: true}, + Header{Name: "NODE", Wide: true}, + Header{Name: "QOS", Wide: true}, + Header{Name: "LABELS", Wide: true}, + Header{Name: "VALID", Wide: true}, Header{Name: "AGE", Decorator: AgeDecorator}, ) } @@ -105,7 +94,7 @@ func (p Pod) Render(o interface{}, ns string, r *Row) error { ss := po.Status.ContainerStatuses cr, _, rc := p.Statuses(ss) c, perc := p.gatherPodMX(&po, pwm.MX) - + phase := p.Phase(&po) r.ID = client.MetaFQN(po.ObjectMeta) r.Fields = make(Fields, 0, len(p.Header(ns))) if client.IsAllNamespaces(ns) { @@ -114,8 +103,8 @@ func (p Pod) Render(o interface{}, ns string, r *Row) error { r.Fields = append(r.Fields, po.ObjectMeta.Name, strconv.Itoa(cr)+"/"+strconv.Itoa(len(ss)), - p.Phase(&po), strconv.Itoa(rc), + phase, c.cpu, c.mem, perc.cpu, @@ -125,12 +114,25 @@ func (p Pod) Render(o interface{}, ns string, r *Row) error { na(po.Status.PodIP), na(po.Spec.NodeName), p.mapQOS(po.Status.QOSClass), + mapToStr(po.Labels), + asStatus(p.diagnose(phase, cr, len(ss))), toAge(po.ObjectMeta.CreationTimestamp), ) return nil } +func (p Pod) diagnose(phase string, cr, ct int) error { + if phase == "Completed" { + return nil + } + if cr != ct { + return fmt.Errorf("container ready check failed: %d of %d", cr, ct) + } + + return nil +} + // ---------------------------------------------------------------------------- // Helpers... diff --git a/internal/render/pod_test.go b/internal/render/pod_test.go index 6b0df397..0f746fce 100644 --- a/internal/render/pod_test.go +++ b/internal/render/pod_test.go @@ -24,12 +24,12 @@ type ( func TestPodColorer(t *testing.T) { var ( - nsRow = render.Row{Fields: render.Fields{"blee", "fred", "1/1", "Running"}} - toastNS = render.Row{Fields: render.Fields{"blee", "fred", "1/1", "Boom"}} - notReadyNS = render.Row{Fields: render.Fields{"blee", "fred", "0/1", "Boom"}} - row = render.Row{Fields: render.Fields{"fred", "1/1", "Running"}} - toast = render.Row{Fields: render.Fields{"fred", "1/1", "Boom"}} - notReady = render.Row{Fields: render.Fields{"fred", "0/1", "Boom"}} + nsRow = render.Row{Fields: render.Fields{"blee", "fred", "1/1", "0", "Running"}} + toastNS = render.Row{Fields: render.Fields{"blee", "fred", "1/1", "0", "Boom"}} + notReadyNS = render.Row{Fields: render.Fields{"blee", "fred", "0/1", "0", "Boom"}} + row = render.Row{Fields: render.Fields{"fred", "1/1", "0", "Running"}} + toast = render.Row{Fields: render.Fields{"fred", "1/1", "0", "Boom"}} + notReady = render.Row{Fields: render.Fields{"fred", "0/1", "0", "Boom"}} ) uu := colorerUCs{ @@ -70,7 +70,7 @@ func TestPodRender(t *testing.T) { assert.Nil(t, err) assert.Equal(t, "default/nginx", r.ID) - e := render.Fields{"default", "nginx", "1/1", "Running", "0", "10", "10", "10", "14", "0", "5", "172.17.0.6", "minikube", "BE"} + e := render.Fields{"default", "nginx", "1/1", "0", "Running", "10", "10", "10", "14", "0", "5", "172.17.0.6", "minikube", "BE"} assert.Equal(t, e, r.Fields[:14]) } @@ -101,7 +101,7 @@ func TestPodInitRender(t *testing.T) { assert.Nil(t, err) assert.Equal(t, "default/nginx", r.ID) - e := render.Fields{"default", "nginx", "1/1", "Init:0/1", "0", "10", "10", "10", "14", "0", "5", "172.17.0.6", "minikube", "BE"} + e := render.Fields{"default", "nginx", "1/1", "0", "Init:0/1", "10", "10", "10", "14", "0", "5", "172.17.0.6", "minikube", "BE"} assert.Equal(t, e, r.Fields[:14]) } diff --git a/internal/render/policy.go b/internal/render/policy.go index e29ab3ce..da279118 100644 --- a/internal/render/policy.go +++ b/internal/render/policy.go @@ -18,8 +18,8 @@ func rbacVerbHeader() HeaderRow { Header{Name: "PATCH "}, Header{Name: "UPDATE"}, Header{Name: "DELETE"}, - Header{Name: "DLIST "}, - Header{Name: "EXTRAS"}, + Header{Name: "DEL-LIST "}, + Header{Name: "EXTRAS", Wide: true}, } } @@ -41,7 +41,10 @@ func (Policy) Header(ns string) HeaderRow { Header{Name: "API GROUP"}, Header{Name: "BINDING"}, } - return append(h, rbacVerbHeader()...) + h = append(h, rbacVerbHeader()...) + h = append(h, Header{Name: "VALID", Wide: true}) + + return h } // Render renders a K8s resource to screen. @@ -52,8 +55,14 @@ func (Policy) Render(o interface{}, gvr string, r *Row) error { } r.ID = client.FQN(p.Namespace, p.Resource) - r.Fields = append(r.Fields, p.Namespace, cleanseResource(p.Resource), p.Group, p.Binding) + r.Fields = append(r.Fields, + p.Namespace, + cleanseResource(p.Resource), + p.Group, + p.Binding, + ) r.Fields = append(r.Fields, asVerbs(p.Verbs)...) + r.Fields = append(r.Fields, "") return nil } diff --git a/internal/render/policy_test.go b/internal/render/policy_test.go index 61a30ddf..24408821 100644 --- a/internal/render/policy_test.go +++ b/internal/render/policy_test.go @@ -37,5 +37,6 @@ func TestPolicyRender(t *testing.T) { "[orangered::b] 𐄂 [::]", "[orangered::b] 𐄂 [::]", "", + "", }, r.Fields) } diff --git a/internal/render/port_forward_test.go b/internal/render/port_forward_test.go index 3ce102f1..81c9d7cf 100644 --- a/internal/render/port_forward_test.go +++ b/internal/render/port_forward_test.go @@ -30,6 +30,7 @@ func TestPortForwardRender(t *testing.T) { "http://0.0.0.0:p1/", "1", "1", + "", "2m", }, r.Fields) } diff --git a/internal/render/portforward.go b/internal/render/portforward.go index 450188d2..89dcf258 100644 --- a/internal/render/portforward.go +++ b/internal/render/portforward.go @@ -48,6 +48,7 @@ func (PortForward) Header(ns string) HeaderRow { Header{Name: "URL"}, Header{Name: "C"}, Header{Name: "N"}, + Header{Name: "VALID", Wide: true}, Header{Name: "AGE", Decorator: AgeDecorator}, } } @@ -71,6 +72,7 @@ func (f PortForward) Render(o interface{}, gvr string, r *Row) error { UrlFor(pf.Config.Host, pf.Config.Path, ports[0]), asNum(pf.Config.C), asNum(pf.Config.N), + "", pf.Age(), } diff --git a/internal/render/pv.go b/internal/render/pv.go index ef0ae627..67742ff6 100644 --- a/internal/render/pv.go +++ b/internal/render/pv.go @@ -16,26 +16,26 @@ import ( type PersistentVolume struct{} // ColorerFunc colors a resource row. -func (PersistentVolume) ColorerFunc() ColorerFunc { - return func(ns string, r RowEvent) tcell.Color { - c := DefaultColorer(ns, r) - if r.Kind == EventAdd || r.Kind == EventUpdate { +func (p PersistentVolume) ColorerFunc() ColorerFunc { + return func(ns string, re RowEvent) tcell.Color { + c := DefaultColorer(ns, re) + if re.Kind == EventAdd || re.Kind == EventUpdate { return c } - status := strings.TrimSpace(r.Row.Fields[4]) - switch status { + if !Happy(ns, re.Row) { + return ErrColor + } + + switch strings.TrimSpace(re.Row.Fields[4]) { case "Bound": c = StdColor case "Available": c = tcell.ColorYellow - default: - c = ErrColor } return c } - } // Header returns a header rbw. @@ -49,6 +49,9 @@ func (PersistentVolume) Header(string) HeaderRow { Header{Name: "CLAIM"}, Header{Name: "STORAGECLASS"}, Header{Name: "REASON"}, + Header{Name: "VOLUMEMODE", Wide: true}, + Header{Name: "LABELS", Wide: true}, + Header{Name: "VALID", Wide: true}, Header{Name: "AGE", Decorator: AgeDecorator}, } } @@ -90,12 +93,30 @@ func (p PersistentVolume) Render(o interface{}, ns string, r *Row) error { claim, class, pv.Status.Reason, + p.volumeMode(pv.Spec.VolumeMode), + mapToStr(pv.Labels), + asStatus(p.diagnose(string(phase))), toAge(pv.ObjectMeta.CreationTimestamp), } return nil } +func (PersistentVolume) diagnose(r string) error { + if r != "Bound" && r != "Available" { + return fmt.Errorf("unexpected status %s", r) + } + return nil +} + +func (PersistentVolume) volumeMode(m *v1.PersistentVolumeMode) string { + if m == nil { + return MissingValue + } + + return string(*m) +} + // ---------------------------------------------------------------------------- // Helpers... diff --git a/internal/render/pvc.go b/internal/render/pvc.go index c6ce1484..2b10a867 100644 --- a/internal/render/pvc.go +++ b/internal/render/pvc.go @@ -2,7 +2,6 @@ package render import ( "fmt" - "strings" "github.com/derailed/k9s/internal/client" "github.com/gdamore/tcell" @@ -15,19 +14,14 @@ import ( type PersistentVolumeClaim struct{} // ColorerFunc colors a resource row. -func (PersistentVolumeClaim) ColorerFunc() ColorerFunc { - return func(ns string, r RowEvent) tcell.Color { - c := DefaultColorer(ns, r) - if r.Kind == EventAdd || r.Kind == EventUpdate { +func (p PersistentVolumeClaim) ColorerFunc() ColorerFunc { + return func(ns string, re RowEvent) tcell.Color { + c := DefaultColorer(ns, re) + if re.Kind == EventAdd || re.Kind == EventUpdate { return c } - - markCol := 2 - if !client.IsAllNamespaces(ns) { - markCol-- - } - if strings.TrimSpace(r.Row.Fields[markCol]) != "Bound" { - c = ErrColor + if !Happy(ns, re.Row) { + return ErrColor } return c @@ -49,6 +43,8 @@ func (PersistentVolumeClaim) Header(ns string) HeaderRow { Header{Name: "CAPACITY"}, Header{Name: "ACCESS MODES"}, Header{Name: "STORAGECLASS"}, + Header{Name: "LABELS", Wide: true}, + Header{Name: "VALID", Wide: true}, Header{Name: "AGE", Decorator: AgeDecorator}, ) } @@ -96,8 +92,17 @@ func (p PersistentVolumeClaim) Render(o interface{}, ns string, r *Row) error { capacity, accessModes, class, + mapToStr(pvc.Labels), + asStatus(p.diagnose(string(phase))), toAge(pvc.ObjectMeta.CreationTimestamp), ) return nil } + +func (PersistentVolumeClaim) diagnose(r string) error { + if r != "Bound" && r != "Available" { + return fmt.Errorf("unexpected status %s", r) + } + return nil +} diff --git a/internal/render/rbac.go b/internal/render/rbac.go index 438ef473..93b6fd85 100644 --- a/internal/render/rbac.go +++ b/internal/render/rbac.go @@ -46,7 +46,10 @@ func (Rbac) Header(ns string) HeaderRow { Header{Name: "API GROUP"}, } - return append(h, rbacVerbHeader()...) + h = append(h, rbacVerbHeader()...) + h = append(h, Header{Name: "VALID", Wide: true}) + + return h } // Render renders a K8s resource to screen. @@ -57,8 +60,12 @@ func (Rbac) Render(o interface{}, gvr string, r *Row) error { } r.ID = p.Resource - r.Fields = append(r.Fields, cleanseResource(p.Resource), p.Group) + r.Fields = append(r.Fields, + cleanseResource(p.Resource), + p.Group, + ) r.Fields = append(r.Fields, asVerbs(p.Verbs)...) + r.Fields = append(r.Fields, "") return nil } diff --git a/internal/render/render_test.go b/internal/render/render_test.go index dd758af1..f526ed70 100644 --- a/internal/render/render_test.go +++ b/internal/render/render_test.go @@ -12,7 +12,7 @@ import ( // Helpers... func load(t assert.TestingT, n string) *unstructured.Unstructured { - raw, err := ioutil.ReadFile(fmt.Sprintf("assets/%s.json", n)) + raw, err := ioutil.ReadFile(fmt.Sprintf("testdata/%s.json", n)) assert.Nil(t, err) var o unstructured.Unstructured diff --git a/internal/render/ro.go b/internal/render/ro.go index c388889e..1e54bb81 100644 --- a/internal/render/ro.go +++ b/internal/render/ro.go @@ -26,6 +26,8 @@ func (Role) Header(ns string) HeaderRow { return append(h, Header{Name: "NAME"}, + Header{Name: "LABELS", Wide: true}, + Header{Name: "VALID", Wide: true}, Header{Name: "AGE", Decorator: AgeDecorator}, ) } @@ -49,6 +51,8 @@ func (r Role) Render(o interface{}, ns string, row *Row) error { } row.Fields = append(row.Fields, ro.Name, + mapToStr(ro.Labels), + "", toAge(ro.ObjectMeta.CreationTimestamp), ) diff --git a/internal/render/rob.go b/internal/render/rob.go index 6777c090..e55b01df 100644 --- a/internal/render/rob.go +++ b/internal/render/rob.go @@ -30,6 +30,8 @@ func (RoleBinding) Header(ns string) HeaderRow { Header{Name: "ROLE"}, Header{Name: "KIND"}, Header{Name: "SUBJECTS"}, + Header{Name: "LABELS", Wide: true}, + Header{Name: "VALID", Wide: true}, Header{Name: "AGE", Decorator: AgeDecorator}, ) } @@ -58,6 +60,8 @@ func (r RoleBinding) Render(o interface{}, ns string, row *Row) error { rb.RoleRef.Name, kind, ss, + mapToStr(rb.Labels), + "", toAge(rb.ObjectMeta.CreationTimestamp), ) @@ -87,11 +91,11 @@ func toSubjectAlias(s string) string { switch s { case rbacv1.UserKind: - return "USR" + return "User" case rbacv1.GroupKind: - return "GRP" + return "Group" case rbacv1.ServiceAccountKind: - return "SA" + return "SvcAcct" default: return strings.ToUpper(s) } diff --git a/internal/render/rob_test.go b/internal/render/rob_test.go index 66afcf6a..26a5c138 100644 --- a/internal/render/rob_test.go +++ b/internal/render/rob_test.go @@ -13,5 +13,5 @@ func TestRoleBindingRender(t *testing.T) { c.Render(load(t, "rb"), "", &r) assert.Equal(t, "default/blee", r.ID) - assert.Equal(t, render.Fields{"default", "blee", "blee", "SA", "fernand"}, r.Fields[:5]) + assert.Equal(t, render.Fields{"default", "blee", "blee", "SvcAcct", "fernand"}, r.Fields[:5]) } diff --git a/internal/render/row.go b/internal/render/row.go index f3e4171e..dde5cbe1 100644 --- a/internal/render/row.go +++ b/internal/render/row.go @@ -13,9 +13,8 @@ type Fields []string // Clone returns a copy of the fields. func (f Fields) Clone() Fields { cp := make(Fields, len(f)) - for i, v := range f { - cp[i] = v - } + copy(cp, f) + return cp } @@ -40,6 +39,11 @@ func (r Row) Clone() Row { } } +// Len returns the length of the row. +func (r Row) Len() int { + return len(r.Fields) +} + // ---------------------------------------------------------------------------- // Rows represents a collection of rows. diff --git a/internal/render/row_event.go b/internal/render/row_event.go index aeb3d0e5..c2daae04 100644 --- a/internal/render/row_event.go +++ b/internal/render/row_event.go @@ -4,8 +4,10 @@ import ( "fmt" "reflect" "sort" + "time" "github.com/rs/zerolog/log" + "k8s.io/apimachinery/pkg/util/duration" ) const ( @@ -139,19 +141,31 @@ func (rr RowEvents) FindIndex(id string) (int, bool) { return 0, false } +func (rr RowEvents) isAgeCol(col int) bool { + var age bool + if len(rr) == 0 { + return age + } + return col == len(rr[0].Row.Fields)-1 +} + // Sort rows based on column index and order. func (rr RowEvents) Sort(ns string, col int, asc bool) { t := RowEventSorter{NS: ns, Events: rr, Index: col, Asc: asc} sort.Sort(t) + ageCol := rr.isAgeCol(col) gg, kk := map[string][]string{}, make(StringSet, 0, len(rr)) - for _, e := range rr { - g := e.Row.Fields[col] + for _, r := range rr { + g := r.Row.Fields[col] + if ageCol { + g = toAgeDuration(g) + } kk = kk.Add(g) if ss, ok := gg[g]; ok { - gg[g] = append(ss, e.Row.ID) + gg[g] = append(ss, r.Row.ID) } else { - gg[g] = []string{e.Row.ID} + gg[g] = []string{r.Row.ID} } } @@ -164,6 +178,14 @@ func (rr RowEvents) Sort(ns string, col int, asc bool) { sort.Sort(s) } +func toAgeDuration(dur string) string { + d, err := time.ParseDuration(dur) + if err != nil { + return dur + } + return duration.HumanDuration(d) +} + // ---------------------------------------------------------------------------- // RowEventSorter sorts row events by a given colon. diff --git a/internal/render/row_event_test.go b/internal/render/row_event_test.go index 496eca65..347940ac 100644 --- a/internal/render/row_event_test.go +++ b/internal/render/row_event_test.go @@ -81,6 +81,26 @@ func TestSort(t *testing.T) { {Row: render.Row{ID: "C", Fields: render.Fields{"10", "2", "3"}}}, }, }, + "id_preserve": { + re: render.RowEvents{ + {Row: render.Row{ID: "ns1/B", Fields: render.Fields{"B", "2", "3"}}}, + {Row: render.Row{ID: "ns1/A", Fields: render.Fields{"A", "2", "3"}}}, + {Row: render.Row{ID: "ns1/C", Fields: render.Fields{"C", "2", "3"}}}, + {Row: render.Row{ID: "ns2/B", Fields: render.Fields{"B", "2", "3"}}}, + {Row: render.Row{ID: "ns2/A", Fields: render.Fields{"A", "2", "3"}}}, + {Row: render.Row{ID: "ns2/C", Fields: render.Fields{"C", "2", "3"}}}, + }, + col: 1, + asc: true, + e: render.RowEvents{ + {Row: render.Row{ID: "ns1/A", Fields: render.Fields{"A", "2", "3"}}}, + {Row: render.Row{ID: "ns1/B", Fields: render.Fields{"B", "2", "3"}}}, + {Row: render.Row{ID: "ns1/C", Fields: render.Fields{"C", "2", "3"}}}, + {Row: render.Row{ID: "ns2/A", Fields: render.Fields{"A", "2", "3"}}}, + {Row: render.Row{ID: "ns2/B", Fields: render.Fields{"B", "2", "3"}}}, + {Row: render.Row{ID: "ns2/C", Fields: render.Fields{"C", "2", "3"}}}, + }, + }, } for k := range uu { diff --git a/internal/render/row_header.go b/internal/render/row_header.go index 4b34ea1a..4bdd87d6 100644 --- a/internal/render/row_header.go +++ b/internal/render/row_header.go @@ -9,6 +9,8 @@ type Header struct { Name string Align int Decorator DecoratorFunc + Hide bool + Wide bool } // Clone copies a header. @@ -56,13 +58,7 @@ func (hh HeaderRow) Columns() []string { // HasAge returns true if table has an age column. func (hh HeaderRow) HasAge() bool { - for _, r := range hh { - if r.Name == ageCol { - return true - } - } - - return false + return hh.IndexOf(ageCol) != -1 } // AgeCol checks if given column index is the age column. @@ -72,3 +68,18 @@ func (hh HeaderRow) AgeCol(col int) bool { } return col == len(hh)-1 } + +// ValidColIndex returns the valid col index or -1 if none. +func (hh HeaderRow) ValidColIndex() int { + return hh.IndexOf("VALID") +} + +// IndexOf returns the col index or -1 if none. +func (hh HeaderRow) IndexOf(c string) int { + for i, h := range hh { + if h.Name == c { + return i + } + } + return -1 +} diff --git a/internal/render/rs.go b/internal/render/rs.go index 5b38fcf1..df0ed8c7 100644 --- a/internal/render/rs.go +++ b/internal/render/rs.go @@ -3,7 +3,6 @@ package render import ( "fmt" "strconv" - "strings" "github.com/derailed/k9s/internal/client" "github.com/derailed/tview" @@ -17,24 +16,19 @@ import ( type ReplicaSet struct{} // ColorerFunc colors a resource row. -func (ReplicaSet) ColorerFunc() ColorerFunc { - return func(ns string, r RowEvent) tcell.Color { - c := DefaultColorer(ns, r) - if r.Kind == EventAdd || r.Kind == EventUpdate { +func (r ReplicaSet) ColorerFunc() ColorerFunc { + return func(ns string, re RowEvent) tcell.Color { + c := DefaultColorer(ns, re) + if re.Kind == EventAdd || re.Kind == EventUpdate { return c } - markCol := 2 - if !client.IsAllNamespaces(ns) { - markCol-- - } - if strings.TrimSpace(r.Row.Fields[markCol]) != strings.TrimSpace(r.Row.Fields[markCol+1]) { + if !Happy(ns, re.Row) { return ErrColor } return StdColor } - } // Header returns a header row. @@ -49,12 +43,14 @@ func (ReplicaSet) Header(ns string) HeaderRow { Header{Name: "DESIRED", Align: tview.AlignRight}, Header{Name: "CURRENT", Align: tview.AlignRight}, Header{Name: "READY", Align: tview.AlignRight}, + Header{Name: "LABELS", Wide: true}, + Header{Name: "VALID", Wide: true}, Header{Name: "AGE", Decorator: AgeDecorator}, ) } // Render renders a K8s resource to screen. -func (s ReplicaSet) Render(o interface{}, ns string, r *Row) error { +func (r ReplicaSet) Render(o interface{}, ns string, row *Row) error { raw, ok := o.(*unstructured.Unstructured) if !ok { return fmt.Errorf("Expected ReplicaSet, but got %T", o) @@ -65,18 +61,31 @@ func (s ReplicaSet) Render(o interface{}, ns string, r *Row) error { return err } - r.ID = client.MetaFQN(rs.ObjectMeta) - r.Fields = make(Fields, 0, len(s.Header(ns))) + row.ID = client.MetaFQN(rs.ObjectMeta) + row.Fields = make(Fields, 0, len(r.Header(ns))) if client.IsAllNamespaces(ns) { - r.Fields = append(r.Fields, rs.Namespace) + row.Fields = append(row.Fields, rs.Namespace) } - r.Fields = append(r.Fields, + row.Fields = append(row.Fields, rs.Name, strconv.Itoa(int(*rs.Spec.Replicas)), strconv.Itoa(int(rs.Status.Replicas)), strconv.Itoa(int(rs.Status.ReadyReplicas)), + mapToStr(rs.Labels), + asStatus(r.diagnose(rs)), toAge(rs.ObjectMeta.CreationTimestamp), ) return nil } + +func (ReplicaSet) diagnose(rs appsv1.ReplicaSet) error { + if rs.Status.Replicas != rs.Status.ReadyReplicas { + if rs.Status.Replicas == 0 { + return fmt.Errorf("did not phase down correctly expecting 0 replicas but got %d", rs.Status.ReadyReplicas) + } + return fmt.Errorf("mismatch desired(%d) vs ready(%d)", rs.Status.Replicas, rs.Status.ReadyReplicas) + } + + return nil +} diff --git a/internal/render/sa.go b/internal/render/sa.go index eafffe7a..d0900374 100644 --- a/internal/render/sa.go +++ b/internal/render/sa.go @@ -28,6 +28,8 @@ func (ServiceAccount) Header(ns string) HeaderRow { return append(h, Header{Name: "NAME"}, Header{Name: "SECRET"}, + Header{Name: "LABELS", Wide: true}, + Header{Name: "VALID", Wide: true}, Header{Name: "AGE", Decorator: AgeDecorator}, ) } @@ -52,6 +54,8 @@ func (s ServiceAccount) Render(o interface{}, ns string, r *Row) error { r.Fields = append(r.Fields, sa.Name, strconv.Itoa(len(sa.Secrets)), + mapToStr(sa.Labels), + "", toAge(sa.ObjectMeta.CreationTimestamp), ) diff --git a/internal/render/sc.go b/internal/render/sc.go index 4c43fbc0..00ab82e0 100644 --- a/internal/render/sc.go +++ b/internal/render/sc.go @@ -22,6 +22,8 @@ func (StorageClass) Header(ns string) HeaderRow { return HeaderRow{ Header{Name: "NAME"}, Header{Name: "PROVISIONER"}, + Header{Name: "LABELS", Wide: true}, + Header{Name: "VALID", Wide: true}, Header{Name: "AGE", Decorator: AgeDecorator}, } } @@ -42,6 +44,8 @@ func (StorageClass) Render(o interface{}, ns string, r *Row) error { r.Fields = Fields{ sc.Name, string(sc.Provisioner), + mapToStr(sc.Labels), + "", toAge(sc.ObjectMeta.CreationTimestamp), } diff --git a/internal/render/screen_dump.go b/internal/render/screen_dump.go index ce1d4a69..5d81b068 100644 --- a/internal/render/screen_dump.go +++ b/internal/render/screen_dump.go @@ -33,6 +33,8 @@ var AgeDecorator = func(a string) string { func (ScreenDump) Header(ns string) HeaderRow { return HeaderRow{ Header{Name: "NAME"}, + Header{Name: "DIR"}, + Header{Name: "VALID", Wide: true}, Header{Name: "AGE", Decorator: AgeDecorator}, } } @@ -47,6 +49,8 @@ func (b ScreenDump) Render(o interface{}, ns string, r *Row) error { r.ID = filepath.Join(f.Dir, f.File.Name()) r.Fields = Fields{ f.File.Name(), + f.Dir, + "", timeToAge(f.File.ModTime()), } diff --git a/internal/render/screen_dump_test.go b/internal/render/screen_dump_test.go index ce6413ab..7dfad7f5 100644 --- a/internal/render/screen_dump_test.go +++ b/internal/render/screen_dump_test.go @@ -21,6 +21,8 @@ func TestScreenDumpRender(t *testing.T) { assert.Equal(t, "fred/blee/bob", r.ID) assert.Equal(t, render.Fields{ "bob", + "fred/blee", + "", }, r.Fields[:len(r.Fields)-1]) } diff --git a/internal/render/sts.go b/internal/render/sts.go index 30038c83..02dcc8c9 100644 --- a/internal/render/sts.go +++ b/internal/render/sts.go @@ -3,7 +3,6 @@ package render import ( "fmt" "strconv" - "strings" "github.com/derailed/k9s/internal/client" "github.com/gdamore/tcell" @@ -16,20 +15,13 @@ import ( type StatefulSet struct{} // ColorerFunc colors a resource row. -func (StatefulSet) ColorerFunc() ColorerFunc { - return func(ns string, r RowEvent) tcell.Color { - c := DefaultColorer(ns, r) - if r.Kind == EventAdd || r.Kind == EventUpdate { +func (s StatefulSet) ColorerFunc() ColorerFunc { + return func(ns string, re RowEvent) tcell.Color { + c := DefaultColorer(ns, re) + if re.Kind == EventAdd || re.Kind == EventUpdate { return c } - - readyCol := 2 - if !client.IsAllNamespaces(ns) { - readyCol-- - } - tokens := strings.Split(strings.TrimSpace(r.Row.Fields[readyCol]), "/") - curr, des := tokens[0], tokens[1] - if curr != des { + if !Happy(ns, re.Row) { return ErrColor } @@ -47,8 +39,12 @@ func (StatefulSet) Header(ns string) HeaderRow { return append(h, Header{Name: "NAME"}, Header{Name: "READY"}, - Header{Name: "SELECTOR"}, + Header{Name: "SELECTOR", Wide: true}, Header{Name: "SERVICE"}, + Header{Name: "CONTAINERS", Wide: true}, + Header{Name: "IMAGES", Wide: true}, + Header{Name: "LABELS", Wide: true}, + Header{Name: "VALID", Wide: true}, Header{Name: "AGE", Decorator: AgeDecorator}, ) } @@ -72,11 +68,22 @@ func (s StatefulSet) Render(o interface{}, ns string, r *Row) error { } r.Fields = append(r.Fields, sts.Name, - strconv.Itoa(int(sts.Status.Replicas))+"/"+strconv.Itoa(int(*sts.Spec.Replicas)), + strconv.Itoa(int(sts.Status.ReadyReplicas))+"/"+strconv.Itoa(int(sts.Status.Replicas)), asSelector(sts.Spec.Selector), na(sts.Spec.ServiceName), + podContainerNames(sts.Spec.Template.Spec, true), + podImageNames(sts.Spec.Template.Spec, true), + mapToStr(sts.Labels), + asStatus(s.diagnose(sts.Status.Replicas, sts.Status.ReadyReplicas)), toAge(sts.ObjectMeta.CreationTimestamp), ) return nil } + +func (StatefulSet) diagnose(d, r int32) error { + if d != r { + return fmt.Errorf("desiring %d replicas got %d available", d, r) + } + return nil +} diff --git a/internal/render/sts_test.go b/internal/render/sts_test.go index 6fe8e4ae..600daa93 100644 --- a/internal/render/sts_test.go +++ b/internal/render/sts_test.go @@ -13,5 +13,5 @@ func TestStatefulSetRender(t *testing.T) { assert.Nil(t, c.Render(load(t, "sts"), "", &r)) assert.Equal(t, "default/nginx-sts", r.ID) - assert.Equal(t, render.Fields{"default", "nginx-sts", "4/4", "app=nginx-sts", "nginx-sts"}, r.Fields[:len(r.Fields)-1]) + assert.Equal(t, render.Fields{"default", "nginx-sts", "4/4", "app=nginx-sts", "nginx-sts", "nginx", "k8s.gcr.io/nginx-slim:0.8", "app=nginx-sts", ""}, r.Fields[:len(r.Fields)-1]) } diff --git a/internal/render/subject.go b/internal/render/subject.go index 5c1e0f83..dae66c77 100644 --- a/internal/render/subject.go +++ b/internal/render/subject.go @@ -11,6 +11,11 @@ import ( // Subject renders a rbac to screen. type Subject struct{} +// Happy returns true if resoure is happy, false otherwise +func (Subject) Happy(_ string, _ Row) bool { + return true +} + // ColorerFunc colors a resource row. func (Subject) ColorerFunc() ColorerFunc { return func(ns string, re RowEvent) tcell.Color { @@ -24,6 +29,7 @@ func (Subject) Header(ns string) HeaderRow { Header{Name: "NAME"}, Header{Name: "KIND"}, Header{Name: "FIRST LOCATION"}, + Header{Name: "VALID", Wide: true}, } } @@ -40,6 +46,7 @@ func (s Subject) Render(o interface{}, ns string, r *Row) error { res.Name, res.Kind, res.FirstLocation, + "", ) return nil diff --git a/internal/render/svc.go b/internal/render/svc.go index 008f2d8d..f8411f55 100644 --- a/internal/render/svc.go +++ b/internal/render/svc.go @@ -32,8 +32,10 @@ func (Service) Header(ns string) HeaderRow { Header{Name: "TYPE"}, Header{Name: "CLUSTER-IP"}, Header{Name: "EXTERNAL-IP"}, - Header{Name: "SELECTOR"}, - Header{Name: "PORTS"}, + Header{Name: "SELECTOR", Wide: true}, + Header{Name: "PORTS", Wide: true}, + Header{Name: "LABELS", Wide: true}, + Header{Name: "VALID", Wide: true}, Header{Name: "AGE", Decorator: AgeDecorator}, ) } @@ -58,19 +60,32 @@ func (s Service) Render(o interface{}, ns string, r *Row) error { r.Fields = append(r.Fields, svc.ObjectMeta.Name, string(svc.Spec.Type), - svc.Spec.ClusterIP, + toIP(svc.Spec.ClusterIP), toIPs(svc.Spec.Type, getSvcExtIPS(&svc)), mapToStr(svc.Spec.Selector), toPorts(svc.Spec.Ports), + mapToStr(svc.Labels), + asStatus(s.diagnose()), toAge(svc.ObjectMeta.CreationTimestamp), ) return nil } +func (Service) diagnose() error { + return nil +} + // ---------------------------------------------------------------------------- // Helpers... +func toIP(ip string) string { + if ip == "" || ip == "None" { + return "" + } + return ip +} + func getSvcExtIPS(svc *v1.Service) []string { results := []string{} @@ -116,7 +131,7 @@ func toIPs(svcType v1.ServiceType, ips []string) string { if svcType == v1.ServiceTypeLoadBalancer { return "" } - return MissingValue + return "" } sort.Strings(ips) diff --git a/internal/render/svc_test.go b/internal/render/svc_test.go index b74bdf64..63c1c433 100644 --- a/internal/render/svc_test.go +++ b/internal/render/svc_test.go @@ -13,5 +13,5 @@ func TestServiceRender(t *testing.T) { c.Render(load(t, "svc"), "", &r) assert.Equal(t, "default/dictionary1", r.ID) - assert.Equal(t, render.Fields{"default", "dictionary1", "ClusterIP", "10.47.248.116", "", "app=dictionary1", "http:4001►0"}, r.Fields[:7]) + assert.Equal(t, render.Fields{"default", "dictionary1", "ClusterIP", "10.47.248.116", "", "app=dictionary1", "http:4001►0"}, r.Fields[:7]) } diff --git a/internal/render/table_data.go b/internal/render/table_data.go index d907277a..4a133a53 100644 --- a/internal/render/table_data.go +++ b/internal/render/table_data.go @@ -19,11 +19,11 @@ func (t *TableData) Clear() { // Clone returns a copy of the table func (t *TableData) Clone() TableData { - return cloneTable(*t) -} - -func cloneTable(t TableData) TableData { - return t + return TableData{ + Header: t.Header.Clone(), + RowEvents: t.RowEvents.Clone(), + Namespace: t.Namespace, + } } // SetHeader sets table header. diff --git a/internal/render/assets/b1.txt b/internal/render/testdata/b1.txt similarity index 100% rename from internal/render/assets/b1.txt rename to internal/render/testdata/b1.txt diff --git a/internal/render/assets/b2.txt b/internal/render/testdata/b2.txt similarity index 100% rename from internal/render/assets/b2.txt rename to internal/render/testdata/b2.txt diff --git a/internal/render/assets/b3.txt b/internal/render/testdata/b3.txt similarity index 100% rename from internal/render/assets/b3.txt rename to internal/render/testdata/b3.txt diff --git a/internal/render/assets/b4.txt b/internal/render/testdata/b4.txt similarity index 100% rename from internal/render/assets/b4.txt rename to internal/render/testdata/b4.txt diff --git a/internal/render/assets/cj.json b/internal/render/testdata/cj.json similarity index 100% rename from internal/render/assets/cj.json rename to internal/render/testdata/cj.json diff --git a/internal/render/assets/cm.json b/internal/render/testdata/cm.json similarity index 100% rename from internal/render/assets/cm.json rename to internal/render/testdata/cm.json diff --git a/internal/render/assets/cr.json b/internal/render/testdata/cr.json similarity index 100% rename from internal/render/assets/cr.json rename to internal/render/testdata/cr.json diff --git a/internal/render/assets/crb.json b/internal/render/testdata/crb.json similarity index 100% rename from internal/render/assets/crb.json rename to internal/render/testdata/crb.json diff --git a/internal/render/assets/crd.json b/internal/render/testdata/crd.json similarity index 100% rename from internal/render/assets/crd.json rename to internal/render/testdata/crd.json diff --git a/internal/render/assets/dp.json b/internal/render/testdata/dp.json similarity index 100% rename from internal/render/assets/dp.json rename to internal/render/testdata/dp.json diff --git a/internal/render/assets/ds.json b/internal/render/testdata/ds.json similarity index 100% rename from internal/render/assets/ds.json rename to internal/render/testdata/ds.json diff --git a/internal/render/assets/ep.json b/internal/render/testdata/ep.json similarity index 100% rename from internal/render/assets/ep.json rename to internal/render/testdata/ep.json diff --git a/internal/render/assets/ev.json b/internal/render/testdata/ev.json similarity index 100% rename from internal/render/assets/ev.json rename to internal/render/testdata/ev.json diff --git a/internal/render/assets/hpa.json b/internal/render/testdata/hpa.json similarity index 100% rename from internal/render/assets/hpa.json rename to internal/render/testdata/hpa.json diff --git a/internal/render/assets/ing.json b/internal/render/testdata/ing.json similarity index 100% rename from internal/render/assets/ing.json rename to internal/render/testdata/ing.json diff --git a/internal/render/assets/job.json b/internal/render/testdata/job.json similarity index 100% rename from internal/render/assets/job.json rename to internal/render/testdata/job.json diff --git a/internal/render/assets/no.json b/internal/render/testdata/no.json similarity index 100% rename from internal/render/assets/no.json rename to internal/render/testdata/no.json diff --git a/internal/render/assets/np.json b/internal/render/testdata/np.json similarity index 100% rename from internal/render/assets/np.json rename to internal/render/testdata/np.json diff --git a/internal/render/assets/ns.json b/internal/render/testdata/ns.json similarity index 100% rename from internal/render/assets/ns.json rename to internal/render/testdata/ns.json diff --git a/internal/render/assets/pdb.json b/internal/render/testdata/pdb.json similarity index 100% rename from internal/render/assets/pdb.json rename to internal/render/testdata/pdb.json diff --git a/internal/render/assets/po.json b/internal/render/testdata/po.json similarity index 100% rename from internal/render/assets/po.json rename to internal/render/testdata/po.json diff --git a/internal/render/assets/po_init.json b/internal/render/testdata/po_init.json similarity index 100% rename from internal/render/assets/po_init.json rename to internal/render/testdata/po_init.json diff --git a/internal/render/assets/pv.json b/internal/render/testdata/pv.json similarity index 100% rename from internal/render/assets/pv.json rename to internal/render/testdata/pv.json diff --git a/internal/render/assets/pvc.json b/internal/render/testdata/pvc.json similarity index 100% rename from internal/render/assets/pvc.json rename to internal/render/testdata/pvc.json diff --git a/internal/render/assets/rb.json b/internal/render/testdata/rb.json similarity index 100% rename from internal/render/assets/rb.json rename to internal/render/testdata/rb.json diff --git a/internal/render/assets/ro.json b/internal/render/testdata/ro.json similarity index 100% rename from internal/render/assets/ro.json rename to internal/render/testdata/ro.json diff --git a/internal/render/assets/rs.json b/internal/render/testdata/rs.json similarity index 100% rename from internal/render/assets/rs.json rename to internal/render/testdata/rs.json diff --git a/internal/render/assets/sa.json b/internal/render/testdata/sa.json similarity index 100% rename from internal/render/assets/sa.json rename to internal/render/testdata/sa.json diff --git a/internal/render/assets/sc.json b/internal/render/testdata/sc.json similarity index 100% rename from internal/render/assets/sc.json rename to internal/render/testdata/sc.json diff --git a/internal/render/assets/sec.json b/internal/render/testdata/sec.json similarity index 100% rename from internal/render/assets/sec.json rename to internal/render/testdata/sec.json diff --git a/internal/render/assets/sts.json b/internal/render/testdata/sts.json similarity index 100% rename from internal/render/assets/sts.json rename to internal/render/testdata/sts.json diff --git a/internal/render/assets/svc.json b/internal/render/testdata/svc.json similarity index 100% rename from internal/render/assets/svc.json rename to internal/render/testdata/svc.json diff --git a/internal/tchart/component.go b/internal/tchart/component.go new file mode 100644 index 00000000..42733082 --- /dev/null +++ b/internal/tchart/component.go @@ -0,0 +1,128 @@ +package tchart + +import ( + "image" + "sync" + + "github.com/derailed/tview" + "github.com/gdamore/tcell" + "github.com/rs/zerolog/log" +) + +const ( + okColor, faultColor = tcell.ColorPaleGreen, tcell.ColorOrangeRed + okColorName, faultColorName = "palegreen", "orangered" +) + +// Component represents a graphic component. +type Component struct { + *tview.Box + + bgColor, noColor tcell.Color + seriesColors []tcell.Color + dimmed tcell.Style + id, legend string + blur func(tcell.Key) + mx sync.RWMutex +} + +// NewComponent returns a new component. +func NewComponent(id string) *Component { + return &Component{ + Box: tview.NewBox(), + id: id, + noColor: tcell.ColorDefault, + seriesColors: []tcell.Color{tview.Styles.PrimaryTextColor, tview.Styles.FocusColor}, + dimmed: tcell.StyleDefault.Background(tview.Styles.PrimitiveBackgroundColor).Foreground(tcell.ColorGray).Dim(true), + } +} + +// SetBackgroundColor sets the graph bg color. +func (c *Component) SetBackgroundColor(color tcell.Color) { + c.Box.SetBackgroundColor(color) + c.bgColor = color + c.dimmed = c.dimmed.Background(color) +} + +// ID returns the component ID. +func (c *Component) ID() string { + return c.id +} + +// SetLegend sets the component legend. +func (c *Component) SetLegend(l string) { + c.mx.Lock() + defer c.mx.Unlock() + c.legend = l +} + +// InputHandler returns the handler for this primitive. +func (c *Component) InputHandler() func(event *tcell.EventKey, setFocus func(p tview.Primitive)) { + return c.WrapInputHandler(func(event *tcell.EventKey, setFocus func(p tview.Primitive)) { + switch key := event.Key(); key { + case tcell.KeyEnter: + log.Debug().Msgf("YO %s ENTER!!", c.id) + case tcell.KeyBacktab, tcell.KeyTab: + log.Debug().Msgf("YO %s TAB!!", c.id) + if c.blur != nil { + c.blur(key) + } + setFocus(c) + } + }) +} + +// IsDial returns true if chart is a dial +func (c *Component) IsDial() bool { + return false +} + +// SetBlurFunc sets a callback fn when component gets out of focus. +func (c *Component) SetBlurFunc(handler func(key tcell.Key)) *Component { + c.blur = handler + return c +} + +// SetSeriesColors sets the component series colors. +func (c *Component) SetSeriesColors(cc ...tcell.Color) { + c.mx.Lock() + defer c.mx.Unlock() + c.seriesColors = cc +} + +// GetSeriesColorNames returns series colors by name. +func (c *Component) GetSeriesColorNames() []string { + c.mx.RLock() + defer c.mx.RUnlock() + + var nn []string + for _, color := range c.seriesColors { + for name, co := range tcell.ColorNames { + if co == color { + nn = append(nn, name) + } + } + } + if len(nn) < 2 { + nn = append(nn, okColorName, faultColorName) + } + + return nn +} + +func (c *Component) colorForSeries() (tcell.Color, tcell.Color) { + c.mx.RLock() + defer c.mx.RUnlock() + if len(c.seriesColors) > 1 { + return c.seriesColors[0], c.seriesColors[1] + } + return okColor, faultColor +} + +func (c *Component) asRect() image.Rectangle { + x, y, width, height := c.GetInnerRect() + return image.Rectangle{ + Min: image.Point{X: x, Y: y}, + Max: image.Point{X: x + width, Y: y + height}, + } +} diff --git a/internal/tchart/dot_matrix.go b/internal/tchart/dot_matrix.go new file mode 100644 index 00000000..7e9399fb --- /dev/null +++ b/internal/tchart/dot_matrix.go @@ -0,0 +1,112 @@ +package tchart + +import ( + "fmt" +) + +var dots = []rune{' ', '⠂', '▤', '▥'} + +// Segment represents a dial segment. +type Segment []int + +// Segments represents a collection of segments. +type Segments []Segment + +// Matrix represents a number dial. +type Matrix [][]rune + +// Orientation tracks char orientations. +type Orientation int + +// DotMatrix tracks a char matrix. +type DotMatrix struct { + row, col int +} + +// NewDotMatrix returns a new matrix. +func NewDotMatrix(row, col int) DotMatrix { + return DotMatrix{ + row: row, + col: col, + } +} + +// Print prints the matrix. +func (d DotMatrix) Print(n int) Matrix { + m := make(Matrix, d.row) + segs := asSegments(n) + for row := 0; row < d.row; row++ { + for col := 0; col < d.col; col++ { + m[row] = append(m[row], segs.CharFor(row, col)) + } + } + return m +} + +func asSegments(n int) Segment { + switch n { + case 0: + return Segment{1, 1, 1, 0, 1, 1, 1} + case 1: + return Segment{0, 0, 1, 0, 0, 1, 0} + case 2: + return Segment{1, 0, 1, 1, 1, 0, 1} + case 3: + return Segment{1, 0, 1, 1, 0, 1, 1} + case 4: + return Segment{0, 1, 0, 1, 0, 1, 0} + case 5: + return Segment{1, 1, 0, 1, 0, 1, 1} + case 6: + return Segment{0, 1, 0, 1, 1, 1, 1} + case 7: + return Segment{1, 0, 1, 0, 0, 1, 0} + case 8: + return Segment{1, 1, 1, 1, 1, 1, 1} + case 9: + return Segment{1, 1, 1, 1, 0, 1, 0} + + default: + panic(fmt.Sprintf("NYI %d", n)) + } +} + +// CharFor return a char based on row/col. +func (s Segment) CharFor(row, col int) rune { + c := ' ' + segs := ToSegments(row, col) + if segs == nil { + return c + } + for _, seg := range segs { + if s[seg] == 1 { + c = charForSeg(seg, row, col) + } + } + return c +} + +func charForSeg(seg, row, col int) rune { + switch seg { + case 0, 3, 6: + return dots[2] + } + if row == 0 && (col == 0 || col == 2) { + return dots[2] + } + + return dots[3] +} + +var segs = map[int][][]int{ + 0: {{1, 0}, {0}, {2, 0}}, + 1: {{1}, nil, {2}}, + 2: {{1, 3}, {3}, {2, 3}}, + 3: {{4}, nil, {5}}, + 4: {{4, 6}, {6}, {5, 6}}, +} + +// ToSegments return path segments. +func ToSegments(row, col int) []int { + return segs[row][col] +} diff --git a/internal/tchart/dot_matrix_test.go b/internal/tchart/dot_matrix_test.go new file mode 100644 index 00000000..e3e8e122 --- /dev/null +++ b/internal/tchart/dot_matrix_test.go @@ -0,0 +1,126 @@ +package tchart_test + +import ( + "strconv" + "testing" + + "github.com/derailed/k9s/internal/tchart" + "github.com/stretchr/testify/assert" +) + +func TestSegmentFor(t *testing.T) { + uu := map[string]struct { + r, c int + e []int + }{ + "0x0": {r: 0, c: 0, e: []int{1, 0}}, + "0x1": {r: 0, c: 1, e: []int{0}}, + "0x2": {r: 0, c: 2, e: []int{2, 0}}, + "1x0": {r: 1, c: 0, e: []int{1}}, + "1x1": {r: 1, c: 1, e: nil}, + "1x2": {r: 1, c: 2, e: []int{2}}, + "2x0": {r: 2, c: 0, e: []int{1, 3}}, + "2x1": {r: 2, c: 1, e: []int{3}}, + "2x2": {r: 2, c: 2, e: []int{2, 3}}, + "3x0": {r: 3, c: 0, e: []int{4}}, + "3x1": {r: 3, c: 1, e: nil}, + "3x2": {r: 3, c: 2, e: []int{5}}, + "4x0": {r: 4, c: 0, e: []int{4, 6}}, + "4x1": {r: 4, c: 1, e: []int{6}}, + "4x2": {r: 4, c: 2, e: []int{5, 6}}, + } + + for k := range uu { + u := uu[k] + t.Run(k, func(t *testing.T) { + assert.Equal(t, u.e, tchart.ToSegments(u.r, u.c)) + }) + } +} + +func TestDial(t *testing.T) { + d := tchart.NewDotMatrix(5, 3) + for n := 0; n <= 9; n++ { + i := n + t.Run(strconv.Itoa(n), func(t *testing.T) { + assert.Equal(t, numbers[i], d.Print(i)) + }) + } +} + +// Helpers... + +const hChar, vChar = '▤', '▥' + +var numbers = []tchart.Matrix{ + [][]rune{ + {hChar, hChar, hChar}, + {vChar, ' ', vChar}, + {vChar, ' ', vChar}, + {vChar, ' ', vChar}, + {hChar, hChar, hChar}, + }, + [][]rune{ + {' ', ' ', hChar}, + {' ', ' ', vChar}, + {' ', ' ', vChar}, + {' ', ' ', vChar}, + {' ', ' ', vChar}, + }, + [][]rune{ + {hChar, hChar, hChar}, + {' ', ' ', vChar}, + {hChar, hChar, hChar}, + {vChar, ' ', ' '}, + {hChar, hChar, hChar}, + }, + [][]rune{ + {hChar, hChar, hChar}, + {' ', ' ', vChar}, + {hChar, hChar, hChar}, + {' ', ' ', vChar}, + {hChar, hChar, hChar}, + }, + [][]rune{ + {hChar, ' ', ' '}, + {vChar, ' ', ' '}, + {hChar, hChar, hChar}, + {' ', ' ', vChar}, + {' ', ' ', vChar}, + }, + [][]rune{ + {hChar, hChar, hChar}, + {vChar, ' ', ' '}, + {hChar, hChar, hChar}, + {' ', ' ', vChar}, + {hChar, hChar, hChar}, + }, + [][]rune{ + {hChar, ' ', ' '}, + {vChar, ' ', ' '}, + {hChar, hChar, hChar}, + {vChar, ' ', vChar}, + {hChar, hChar, hChar}, + }, + [][]rune{ + {hChar, hChar, hChar}, + {' ', ' ', vChar}, + {' ', ' ', vChar}, + {' ', ' ', vChar}, + {' ', ' ', vChar}, + }, + [][]rune{ + {hChar, hChar, hChar}, + {vChar, ' ', vChar}, + {hChar, hChar, hChar}, + {vChar, ' ', vChar}, + {hChar, hChar, hChar}, + }, + [][]rune{ + {hChar, hChar, hChar}, + {vChar, ' ', vChar}, + {hChar, hChar, hChar}, + {' ', ' ', vChar}, + {' ', ' ', vChar}, + }, +} diff --git a/internal/tchart/gauge.go b/internal/tchart/gauge.go new file mode 100644 index 00000000..f019b4d2 --- /dev/null +++ b/internal/tchart/gauge.go @@ -0,0 +1,159 @@ +package tchart + +import ( + "fmt" + "image" + + "github.com/derailed/tview" + "github.com/gdamore/tcell" +) + +const ( + // DeltaSame represents no difference. + DeltaSame delta = iota + + // DeltaMore represents a higher value. + DeltaMore + + // DeltaLess represents a lower value. + DeltaLess + + gaugeFmt = "0%dd" +) + +type delta int + +// Gauge represents a gauge component. +type Gauge struct { + *Component + + data Metric + deltaOk, deltaFault delta +} + +// NewGauge returns a new gauge. +func NewGauge(id string) *Gauge { + return &Gauge{ + Component: NewComponent(id), + } +} + +// IsDial returns true if chart is a dial +func (g *Gauge) IsDial() bool { + return true +} + +// Add adds a new metric. +func (g *Gauge) Add(m Metric) { + g.mx.Lock() + defer g.mx.Unlock() + + g.deltaOk, g.deltaFault = computeDelta(g.data.OK, m.OK), computeDelta(g.data.Fault, m.Fault) + g.data = m +} + +func (g *Gauge) drawNum(sc tcell.Screen, ok bool, o image.Point, n int, dn delta, ns string, style tcell.Style) { + c1, _ := g.colorForSeries() + if ok { + o.X -= 1 + style = style.Foreground(c1) + printDelta(sc, dn, o, style) + o.X += 1 + } + + dm, sig := NewDotMatrix(5, 3), n == 0 + if n == 0 { + style = g.dimmed + } + for i := 0; i < len(ns); i++ { + if ns[i] == '0' && !sig { + g.drawDial(sc, dm.Print(int(ns[i]-48)), o, g.dimmed) + } else { + sig = true + g.drawDial(sc, dm.Print(int(ns[i]-48)), o, style) + } + o.X += 5 + } + if !ok { + printDelta(sc, dn, o, style) + } +} + +// Draw draws the primitive. +func (g *Gauge) Draw(sc tcell.Screen) { + g.Component.Draw(sc) + + g.mx.RLock() + defer g.mx.RUnlock() + + rect := g.asRect() + mid := image.Point{X: rect.Min.X + rect.Dx()/2 - 2, Y: rect.Min.Y + rect.Dy()/2 - 2} + + style := tcell.StyleDefault.Background(g.bgColor) + style = style.Foreground(tcell.ColorYellow) + sc.SetContent(mid.X+1, mid.Y+2, '⠔', nil, style) + + var ( + max = g.data.MaxDigits() + fmat = "%" + fmt.Sprintf(gaugeFmt, max) + o = image.Point{X: mid.X - 3, Y: mid.Y} + ) + + s1C, s2C := g.colorForSeries() + d1, d2 := fmt.Sprintf(fmat, g.data.OK), fmt.Sprintf(fmat, g.data.Fault) + o.X -= (len(d1) - 1) * 5 + g.drawNum(sc, true, o, g.data.OK, g.deltaOk, d1, style.Foreground(s1C).Dim(false)) + + o.X = mid.X + 3 + g.drawNum(sc, false, o, g.data.Fault, g.deltaFault, d2, style.Foreground(s2C).Dim(false)) + + if rect.Dx() > 0 && rect.Dy() > 0 && g.legend != "" { + legend := g.legend + if g.HasFocus() { + legend = "[:aqua]" + g.legend + "[::]" + } + tview.Print(sc, legend, rect.Min.X, rect.Max.Y, rect.Dx(), tview.AlignCenter, tcell.ColorWhite) + } +} + +func (g *Gauge) drawDial(sc tcell.Screen, m Matrix, o image.Point, style tcell.Style) { + for r := 0; r < len(m); r++ { + for c := 0; c < len(m[r]); c++ { + dot := m[r][c] + if dot == dots[0] { + sc.SetContent(o.X+c, o.Y+r, dots[1], nil, g.dimmed) + } else { + sc.SetContent(o.X+c, o.Y+r, dot, nil, style) + } + } + } +} + +// ---------------------------------------------------------------------------- +// Helpers... + +func computeDelta(d1, d2 int) delta { + if d2 == 0 { + return DeltaSame + } + + d := d2 - d1 + switch { + case d > 0: + return DeltaMore + case d < 0: + return DeltaLess + default: + return DeltaSame + } +} + +func printDelta(sc tcell.Screen, d delta, o image.Point, s tcell.Style) { + s = s.Dim(false) + switch d { + case DeltaLess: + sc.SetContent(o.X-1, o.Y+2, '↓', nil, s) + case DeltaMore: + sc.SetContent(o.X-1, o.Y+2, '↑', nil, s) + } +} diff --git a/internal/tchart/sparkline.go b/internal/tchart/sparkline.go new file mode 100644 index 00000000..5b157bc1 --- /dev/null +++ b/internal/tchart/sparkline.go @@ -0,0 +1,158 @@ +package tchart + +import ( + "fmt" + "math" + + "github.com/derailed/tview" + "github.com/gdamore/tcell" +) + +var sparks = []rune{'▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'} + +type block struct { + full int + partial rune +} + +type blocks struct { + oks, errs block +} + +// Metric tracks a good and error rates. +type Metric struct { + OK, Fault int +} + +// MaxDigits returns the max of the metric. +func (m Metric) MaxDigits() int { + max := int(math.Max(float64(m.OK), float64(m.Fault))) + s := fmt.Sprintf("%d", max) + return len(s) +} + +// Sum returns the sum of the metrics. +func (m Metric) Sum() int { + return m.OK + m.Fault +} + +// SparkLine represents a sparkline component. +type SparkLine struct { + *Component + + data []Metric +} + +// NewSparkLine returns a new graph. +func NewSparkLine(id string) *SparkLine { + return &SparkLine{ + Component: NewComponent(id), + } +} + +// Add adds a metric. +func (s *SparkLine) Add(m Metric) { + s.mx.Lock() + defer s.mx.Unlock() + s.data = append(s.data, m) +} + +// Draw draws the graph. +func (s *SparkLine) Draw(screen tcell.Screen) { + s.Component.Draw(screen) + + s.mx.RLock() + defer s.mx.RUnlock() + + if len(s.data) == 0 { + return + } + + pad := 1 + if s.legend != "" { + pad++ + } + + rect := s.asRect() + s.cutSet(rect.Dx()) + max := s.computeMax() + + cX, idx := rect.Min.X+1, 0 + if len(s.data)*2 < rect.Dx() { + cX = rect.Max.X - len(s.data)*2 + } else { + idx = len(s.data) - rect.Dx()/2 + } + + scale := float64(len(sparks)) * float64((rect.Dy() - pad)) / float64(max) + c1, c2 := s.colorForSeries() + for _, d := range s.data[idx:] { + b := toBlocks(d, scale) + cY := rect.Max.Y - pad + s.drawBlock(screen, cX, cY, b.oks, c1) + s.drawBlock(screen, cX, cY, b.errs, c2) + cX += 2 + } + + if rect.Dx() > 0 && rect.Dy() > 0 && s.legend != "" { + legend := s.legend + if s.HasFocus() { + legend = "[:aqua:]" + s.legend + "[::]" + } + tview.Print(screen, legend, rect.Min.X, rect.Max.Y, rect.Dx(), tview.AlignCenter, tcell.ColorWhite) + } +} + +func (s *SparkLine) drawBlock(screen tcell.Screen, x, y int, b block, c tcell.Color) { + style := tcell.StyleDefault.Foreground(c).Background(s.bgColor) + + for i := 0; i < b.full; i++ { + screen.SetContent(x, y, sparks[len(sparks)-1], nil, style) + y-- + } + if b.partial != 0 { + screen.SetContent(x, y, b.partial, nil, style) + } +} + +func (s *SparkLine) cutSet(width int) { + if width <= 0 || len(s.data) == 0 { + return + } + + if len(s.data) >= width*2 { + s.data = s.data[len(s.data)-width:] + } +} + +func (s *SparkLine) computeMax() int { + var max int + for _, d := range s.data { + if max < d.OK { + max = d.OK + } + } + + return max +} + +func toBlocks(m Metric, scale float64) blocks { + if m.Sum() <= 0 { + return blocks{} + } + return blocks{oks: makeBlocks(m.OK, false, scale), errs: makeBlocks(m.Fault, true, scale)} +} + +func makeBlocks(v int, isErr bool, scale float64) block { + scaled := int(math.Round(float64(v) * scale)) + part, b := scaled%len(sparks), block{full: scaled / len(sparks)} + // Err might get scaled way down if so nudge. + if v > 0 && isErr && scaled == 0 { + part = 1 + } + if part > 0 { + b.partial = sparks[part-1] + } + + return b +} diff --git a/internal/ui/app.go b/internal/ui/app.go index e200f282..efe187a7 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -3,6 +3,7 @@ package ui import ( "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/config" + "github.com/derailed/k9s/internal/model" "github.com/derailed/tview" "github.com/gdamore/tcell" "github.com/rs/zerolog/log" @@ -14,6 +15,7 @@ type App struct { Configurator Main *Pages + flash *model.Flash actions KeyActions views map[string]tview.Primitive cmdBuff *CmdBuff @@ -25,6 +27,7 @@ func NewApp(context string) *App { Application: tview.NewApplication(), actions: make(KeyActions), Main: NewPages(), + flash: model.NewFlash(model.DefaultFlashDelay), cmdBuff: NewCmdBuff(':', CommandBuff), } a.ReloadStyles(context) @@ -33,7 +36,6 @@ func NewApp(context string) *App { "menu": NewMenu(a.Styles), "logo": NewLogo(a.Styles), "cmd": NewCommand(a.Styles), - "flash": NewFlash(&a, "Initializing..."), "crumbs": NewCrumbs(a.Styles), } @@ -175,7 +177,7 @@ func (a *App) keyboard(evt *tcell.EventKey) *tcell.EventKey { a.cmdBuff.Add(evt.Rune()) return nil } - key = asKey(evt) + key = AsKey(evt) } if a, ok := a.actions[key]; ok { @@ -239,11 +241,6 @@ func (a *App) Logo() *Logo { return a.views["logo"].(*Logo) } -// Flash returns app flash. -func (a *App) Flash() *Flash { - return a.views["flash"].(*Flash) -} - // Cmd returns app cmd. func (a *App) Cmd() *Command { return a.views["cmd"].(*Command) @@ -254,11 +251,16 @@ func (a *App) Menu() *Menu { return a.views["menu"].(*Menu) } +// Flash returns a flash model. +func (a *App) Flash() *model.Flash { + return a.flash +} + // ---------------------------------------------------------------------------- // Helpers... // AsKey converts rune to keyboard key., -func asKey(evt *tcell.EventKey) tcell.Key { +func AsKey(evt *tcell.EventKey) tcell.Key { key := tcell.Key(evt.Rune()) if evt.Modifiers() == tcell.ModAlt { key = tcell.Key(int16(evt.Rune()) * int16(evt.Modifiers())) diff --git a/internal/ui/app_test.go b/internal/ui/app_test.go index 4e189c59..2fdde772 100644 --- a/internal/ui/app_test.go +++ b/internal/ui/app_test.go @@ -59,7 +59,7 @@ func TestAppViews(t *testing.T) { a := ui.NewApp("") a.Init() - vv := []string{"crumbs", "logo", "cmd", "flash", "menu"} + vv := []string{"crumbs", "logo", "cmd", "menu"} for i := range vv { v := vv[i] t.Run(v, func(t *testing.T) { @@ -68,7 +68,6 @@ func TestAppViews(t *testing.T) { } assert.NotNil(t, a.Crumbs()) - assert.NotNil(t, a.Flash()) assert.NotNil(t, a.Logo()) assert.NotNil(t, a.Cmd()) assert.NotNil(t, a.Menu()) diff --git a/internal/ui/config.go b/internal/ui/config.go index cc4f69e3..2620aea2 100644 --- a/internal/ui/config.go +++ b/internal/ui/config.go @@ -20,20 +20,20 @@ type synchronizer interface { // Configurator represents an application configurationa. type Configurator struct { - skinFile string - Config *config.Config - Styles *config.Styles - Bench *config.Bench + skinFile string + Config *config.Config + Styles *config.Styles + BenchFile string } -// HasSkins returns true if a skin file was located. -func (c *Configurator) HasSkins() bool { +// HasSkin returns true if a skin file was located. +func (c *Configurator) HasSkin() bool { return c.skinFile != "" } // StylesUpdater watches for skin file changes. func (c *Configurator) StylesUpdater(ctx context.Context, s synchronizer) error { - if !c.HasSkins() { + if !c.HasSkin() { return nil } @@ -67,24 +67,20 @@ func (c *Configurator) StylesUpdater(ctx context.Context, s synchronizer) error return w.Add(c.skinFile) } -// InitBench load benchmark configuration if any. -func (c *Configurator) InitBench(cluster string) { - var err error - if c.Bench, err = config.NewBench(BenchConfig(cluster)); err != nil { - log.Info().Msg("No benchmark config file found, using defaults.") - } -} - // BenchConfig location of the benchmarks configuration file. -func BenchConfig(cluster string) string { - return filepath.Join(config.K9sHome, config.K9sBench+"-"+cluster+".yml") +func BenchConfig(context string) string { + return filepath.Join(config.K9sHome, config.K9sBench+"-"+context+".yml") } // RefreshStyles load for skin configuration changes. func (c *Configurator) RefreshStyles(context string) { + c.BenchFile = BenchConfig(context) + clusterSkins := filepath.Join(config.K9sHome, fmt.Sprintf("%s_skin.yml", context)) if c.Styles == nil { c.Styles = config.NewStyles() + } else { + c.Styles.Reset() } if err := c.Styles.Load(clusterSkins); err != nil { log.Info().Msgf("No context specific skin file found -- %s", clusterSkins) @@ -103,12 +99,15 @@ func (c *Configurator) RefreshStyles(context string) { func (c *Configurator) updateStyles(f string) { c.skinFile = f + if !c.HasSkin() { + c.Styles.DefaultSkin() + } c.Styles.Update() - render.StdColor = config.AsColor(c.Styles.Frame().Status.NewColor) - render.AddColor = config.AsColor(c.Styles.Frame().Status.AddColor) - render.ModColor = config.AsColor(c.Styles.Frame().Status.ModifyColor) - render.ErrColor = config.AsColor(c.Styles.Frame().Status.ErrorColor) - render.HighlightColor = config.AsColor(c.Styles.Frame().Status.HighlightColor) - render.CompletedColor = config.AsColor(c.Styles.Frame().Status.CompletedColor) + render.StdColor = c.Styles.Frame().Status.NewColor.Color() + render.AddColor = c.Styles.Frame().Status.AddColor.Color() + render.ModColor = c.Styles.Frame().Status.ModifyColor.Color() + render.ErrColor = c.Styles.Frame().Status.ErrorColor.Color() + render.HighlightColor = c.Styles.Frame().Status.HighlightColor.Color() + render.CompletedColor = c.Styles.Frame().Status.CompletedColor.Color() } diff --git a/internal/ui/config_test.go b/internal/ui/config_test.go index 0c409721..2eb9006f 100644 --- a/internal/ui/config_test.go +++ b/internal/ui/config_test.go @@ -17,24 +17,12 @@ func TestBenchConfig(t *testing.T) { } func TestConfiguratorRefreshStyle(t *testing.T) { - config.K9sStylesFile = filepath.Join("..", "config", "test_assets", "black_and_wtf.yml") + config.K9sStylesFile = filepath.Join("..", "config", "testdata", "black_and_wtf.yml") cfg := ui.Configurator{} cfg.RefreshStyles("") - assert.True(t, cfg.HasSkins()) + assert.True(t, cfg.HasSkin()) assert.Equal(t, tcell.ColorGhostWhite, render.StdColor) assert.Equal(t, tcell.ColorWhiteSmoke, render.ErrColor) } - -func TestInitBench(t *testing.T) { - config.K9sHome = filepath.Join("..", "config", "test_assets") - - cfg := ui.Configurator{} - cfg.InitBench("fred") - - assert.NotNil(t, cfg.Bench) - assert.Equal(t, 2, cfg.Bench.Benchmarks.Defaults.C) - assert.Equal(t, 1000, cfg.Bench.Benchmarks.Defaults.N) - assert.Equal(t, 2, len(cfg.Bench.Benchmarks.Services)) -} diff --git a/internal/ui/dialog/port_forward.go b/internal/ui/dialog/port_forward.go deleted file mode 100644 index 52a6cb7d..00000000 --- a/internal/ui/dialog/port_forward.go +++ /dev/null @@ -1,65 +0,0 @@ -package dialog - -import ( - "strings" - - "github.com/derailed/k9s/internal/ui" - "github.com/derailed/tview" - "github.com/gdamore/tcell" -) - -const portForwardKey = "portforward" - -// ShowPortForward pops a port forwarding configuration dialog. -func ShowPortForward(p *ui.Pages, port string, okFn func(address, lport, cport string)) { - f := tview.NewForm() - f.SetItemPadding(0) - f.SetButtonsAlign(tview.AlignCenter). - SetButtonBackgroundColor(tview.Styles.PrimitiveBackgroundColor). - SetButtonTextColor(tview.Styles.PrimaryTextColor). - SetLabelColor(tcell.ColorAqua). - SetFieldTextColor(tcell.ColorOrange) - - p1, p2, address := port, port, "localhost" - f.AddInputField("Pod Port:", p1, 20, nil, func(p string) { - p1 = p - }) - f.AddInputField("Local Port:", p2, 20, nil, func(p string) { - p2 = p - }) - f.AddInputField("Address:", address, 20, nil, func(h string) { - address = h - }) - - f.AddButton("OK", func() { - okFn(address, stripPort(p2), stripPort(p1)) - }) - f.AddButton("Cancel", func() { - DismissPortForward(p) - }) - - modal := tview.NewModalForm("", f) - modal.SetDoneFunc(func(_ int, b string) { - DismissPortForward(p) - }) - p.AddPage(portForwardKey, modal, false, false) - p.ShowPage(portForwardKey) -} - -// DismissPortForward dismiss the port forward dialog. -func DismissPortForward(p *ui.Pages) { - p.RemovePage(portForwardKey) -} - -// ---------------------------------------------------------------------------- -// Helpers... - -// StripPort removes the named port id if present. -func stripPort(p string) string { - tokens := strings.Split(p, ":") - if len(tokens) == 2 { - return strings.Replace(tokens[1], "╱UDP", "", 1) - } - - return p -} diff --git a/internal/ui/dialog/port_forward_test.go b/internal/ui/dialog/port_forward_test.go deleted file mode 100644 index 1c2461e1..00000000 --- a/internal/ui/dialog/port_forward_test.go +++ /dev/null @@ -1,46 +0,0 @@ -package dialog - -import ( - "testing" - - "github.com/derailed/k9s/internal/ui" - "github.com/derailed/tview" - "github.com/stretchr/testify/assert" -) - -func TestPortForwardDialog(t *testing.T) { - p := ui.NewPages() - - okFunc := func(address, lport, cport string) { - } - ShowPortForward(p, "8080", okFunc) - - d := p.GetPrimitive(portForwardKey).(*tview.ModalForm) - assert.NotNil(t, d) - - DismissPortForward(p) - assert.Nil(t, p.GetPrimitive(portForwardKey)) -} - -func TestStripPort(t *testing.T) { - uu := map[string]struct { - port, e string - }{ - "full": { - "fred:8000", "8000", - }, - "port": { - "8000", "8000", - }, - "protocol": { - "dns:53╱UDP", "53", - }, - } - - for k := range uu { - u := uu[k] - t.Run(k, func(t *testing.T) { - assert.Equal(t, u.e, stripPort(u.port)) - }) - } -} diff --git a/internal/ui/flash.go b/internal/ui/flash.go index 855bba3c..d401fe7e 100644 --- a/internal/ui/flash.go +++ b/internal/ui/flash.go @@ -2,67 +2,45 @@ package ui import ( "context" - "fmt" - "strings" - "time" "github.com/derailed/k9s/internal/config" - "github.com/derailed/k9s/internal/render" + "github.com/derailed/k9s/internal/model" "github.com/derailed/tview" "github.com/gdamore/tcell" "github.com/rs/zerolog/log" ) const ( - // FlashInfo represents an info message. - FlashInfo FlashLevel = iota - // FlashWarn represents an warning message. - FlashWarn - // FlashErr represents an error message. - FlashErr - // FlashFatal represents an fatal message. - FlashFatal - - flashDelay = 3 * time.Second - + emoHappy = "😎" emoDoh = "😗" emoRed = "😡" - emoDead = "💀" - emoHappy = "😎" ) -type ( - // FlashLevel represents flash message severity. - FlashLevel int +// Flash represents a flash message indicator. +type Flash struct { + *tview.TextView - // Flash represents a flash message indicator. - Flash struct { - *tview.TextView - - cancel context.CancelFunc - app *App - flushNow bool - } -) + app *App + testMode bool +} // NewFlash returns a new flash view. -func NewFlash(app *App, m string) *Flash { +func NewFlash(app *App) *Flash { f := Flash{ app: app, TextView: tview.NewTextView(), } f.SetTextColor(tcell.ColorAqua) - f.SetTextAlign(tview.AlignLeft) + f.SetTextAlign(tview.AlignCenter) f.SetBorderPadding(0, 0, 1, 1) - f.SetText(m) f.app.Styles.AddListener(&f) return &f } -// TestMode for testing... -func (f *Flash) TestMode() { - f.flushNow = true +// SetTestMode for testing ONLY! +func (f *Flash) SetTestMode(b bool) { + f.testMode = b } // StylesChanged notifies listener the skin changed. @@ -71,102 +49,54 @@ func (f *Flash) StylesChanged(s *config.Styles) { f.SetTextColor(s.FgColor()) } -// Info displays an info flash message. -func (f *Flash) Info(msg string) { - log.Info().Msg(msg) - f.SetMessage(FlashInfo, msg) -} - -// Infof displays a formatted info flash message. -func (f *Flash) Infof(fmat string, args ...interface{}) { - f.Info(fmt.Sprintf(fmat, args...)) -} - -// Warn displays a warning flash message. -func (f *Flash) Warn(msg string) { - log.Warn().Msg(msg) - f.SetMessage(FlashWarn, msg) -} - -// Warnf displays a formatted warning flash message. -func (f *Flash) Warnf(fmat string, args ...interface{}) { - f.Warn(fmt.Sprintf(fmat, args...)) -} - -// Err displays an error flash message. -func (f *Flash) Err(err error) { - log.Error().Msg(err.Error()) - f.SetMessage(FlashErr, err.Error()) -} - -// Errf displays a formatted error flash message. -func (f *Flash) Errf(fmat string, args ...interface{}) { - var err error - for _, a := range args { - switch e := a.(type) { - case error: - err = e +// Watch watches for flash changes. +func (f *Flash) Watch(ctx context.Context, c model.FlashChan) { + defer log.Debug().Msgf("Flash Canceled!") + for { + select { + case <-ctx.Done(): + return + case msg := <-c: + f.SetMessage(msg) } } - log.Error().Err(err).Msgf(fmat, args...) - f.SetMessage(FlashErr, fmt.Sprintf(fmat, args...)) } // SetMessage sets flash message and level. -func (f *Flash) SetMessage(level FlashLevel, msg ...string) { - if f.cancel != nil { - f.cancel() +func (f *Flash) SetMessage(m model.LevelMessage) { + fn := func() { + if m.Text == "" { + f.Clear() + return + } + f.SetTextColor(flashColor(m.Level)) + f.SetText(flashEmoji(m.Level) + " " + m.Text) } - _, _, width, _ := f.GetRect() - if width <= 15 { - width = 100 - } - m := strings.Join(msg, " ") - if f.flushNow { - f.SetTextColor(flashColor(level)) - f.SetText(render.Truncate(flashEmoji(level)+" "+m, width-3)) + if f.testMode { + fn() } else { - f.app.QueueUpdateDraw(func() { - f.SetTextColor(flashColor(level)) - f.SetText(render.Truncate(flashEmoji(level)+" "+m, width-3)) - }) + f.app.QueueUpdateDraw(fn) } - - var ctx context.Context - ctx, f.cancel = context.WithCancel(context.TODO()) - ctx, f.cancel = context.WithTimeout(ctx, flashDelay) - go f.refresh(ctx) } -func (f *Flash) refresh(ctx context.Context) { - <-ctx.Done() - f.app.QueueUpdateDraw(func() { - f.Clear() - }) -} - -func flashEmoji(l FlashLevel) string { +func flashEmoji(l model.FlashLevel) string { switch l { - case FlashWarn: + case model.FlashWarn: return emoDoh - case FlashErr: + case model.FlashErr: return emoRed - case FlashFatal: - return emoDead default: return emoHappy } } -func flashColor(l FlashLevel) tcell.Color { +func flashColor(l model.FlashLevel) tcell.Color { switch l { - case FlashWarn: + case model.FlashWarn: return tcell.ColorOrange - case FlashErr: + case model.FlashErr: return tcell.ColorOrangeRed - case FlashFatal: - return tcell.ColorFuchsia default: return tcell.ColorNavajoWhite } diff --git a/internal/ui/flash_test.go b/internal/ui/flash_test.go index c158f166..e46c591d 100644 --- a/internal/ui/flash_test.go +++ b/internal/ui/flash_test.go @@ -1,45 +1,40 @@ package ui_test import ( - "errors" + "context" "testing" + "time" + "github.com/derailed/k9s/internal/model" "github.com/derailed/k9s/internal/ui" "github.com/stretchr/testify/assert" ) -func TestFlashInfo(t *testing.T) { - f := newFlash() - f.Info("Blee") +func TestFlash(t *testing.T) { + const delay = 1 * time.Millisecond + uu := map[string]struct { + l model.FlashLevel + i, e string + }{ + "info": {l: model.FlashInfo, i: "hello", e: "😎 hello\n"}, + "warn": {l: model.FlashWarn, i: "hello", e: "😗 hello\n"}, + "err": {l: model.FlashErr, i: "hello", e: "😡 hello\n"}, + } - assert.Equal(t, "😎 Blee\n", f.GetText(false)) - f.Infof("Blee %s", "duh") - assert.Equal(t, "😎 Blee duh\n", f.GetText(false)) -} - -func TestFlashWarn(t *testing.T) { - f := newFlash() - f.Warn("Blee") - - assert.Equal(t, "😗 Blee\n", f.GetText(false)) - f.Warnf("Blee %s", "duh") - assert.Equal(t, "😗 Blee duh\n", f.GetText(false)) -} - -func TestFlashErr(t *testing.T) { - f := newFlash() - - f.Err(errors.New("Blee")) - assert.Equal(t, "😡 Blee\n", f.GetText(false)) - f.Errf("Blee %s", "duh") - assert.Equal(t, "😡 Blee duh\n", f.GetText(false)) -} - -// ---------------------------------------------------------------------------- -// Helpers... - -func newFlash() *ui.Flash { - f := ui.NewFlash(ui.NewApp(""), "YO!") - f.TestMode() - return f + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + a := ui.NewApp("test") + f := ui.NewFlash(a) + f.SetTestMode(true) + go f.Watch(ctx, a.Flash().Channel()) + + for k := range uu { + u := uu[k] + t.Run(k, func(t *testing.T) { + a.Flash().SetMessage(u.l, u.i) + time.Sleep(delay) + assert.Equal(t, u.e, f.GetText(false)) + }) + } } diff --git a/internal/ui/logo.go b/internal/ui/logo.go index 7789833e..3bc0a83b 100644 --- a/internal/ui/logo.go +++ b/internal/ui/logo.go @@ -25,8 +25,9 @@ func NewLogo(styles *config.Styles) *Logo { } l.SetDirection(tview.FlexRow) l.AddItem(l.logo, 0, 6, false) - l.AddItem(l.status, 0, 1, false) + l.AddItem(l.status, 1, 0, false) l.refreshLogo(styles.Body().LogoColor) + l.SetBackgroundColor(styles.BgColor()) styles.AddListener(&l) return &l @@ -59,30 +60,30 @@ func (l *Logo) Reset() { // Err displays a log error state. func (l *Logo) Err(msg string) { - l.update(msg, "red") + l.update(msg, config.NewColor("red")) } // Warn displays a log warning state. func (l *Logo) Warn(msg string) { - l.update(msg, "mediumvioletred") + l.update(msg, config.NewColor("mediumvioletred")) } // Info displays a log info state. func (l *Logo) Info(msg string) { - l.update(msg, "green") + l.update(msg, config.NewColor("green")) } -func (l *Logo) update(msg, c string) { +func (l *Logo) update(msg string, c config.Color) { l.refreshStatus(msg, c) l.refreshLogo(c) } -func (l *Logo) refreshStatus(msg, c string) { - l.status.SetBackgroundColor(config.AsColor(c)) +func (l *Logo) refreshStatus(msg string, c config.Color) { + l.status.SetBackgroundColor(c.Color()) l.status.SetText(fmt.Sprintf("[white::b]%s", msg)) } -func (l *Logo) refreshLogo(c string) { +func (l *Logo) refreshLogo(c config.Color) { l.logo.Clear() for i, s := range LogoSmall { fmt.Fprintf(l.logo, "[%s::b]%s", c, s) diff --git a/internal/ui/menu.go b/internal/ui/menu.go index 55184944..cc1ea1d6 100644 --- a/internal/ui/menu.go +++ b/internal/ui/menu.go @@ -15,8 +15,8 @@ import ( ) const ( - menuIndexFmt = " [key:bg:b]<%d> [fg:bg:d]%s " - maxRows = 7 + menuIndexFmt = " [key:-:b]<%d> [fg:-:d]%s " + maxRows = 6 ) var menuRX = regexp.MustCompile(`\d`) @@ -188,16 +188,16 @@ func toMnemonic(s string) string { } func formatNSMenu(i int, name string, styles config.Frame) string { - fmat := strings.Replace(menuIndexFmt, "[key", "["+styles.Menu.NumKeyColor, 1) - fmat = strings.Replace(fmat, ":bg:", ":"+styles.Title.BgColor+":", -1) - fmat = strings.Replace(fmat, "[fg", "["+styles.Menu.FgColor, 1) + fmat := strings.Replace(menuIndexFmt, "[key", "["+styles.Menu.NumKeyColor.String(), 1) + fmat = strings.Replace(fmat, ":bg:", ":"+styles.Title.BgColor.String()+":", -1) + fmat = strings.Replace(fmat, "[fg", "["+styles.Menu.FgColor.String(), 1) return fmt.Sprintf(fmat, i, name) } func formatPlainMenu(h model.MenuHint, size int, styles config.Frame) string { - menuFmt := " [key:bg:b]%-" + strconv.Itoa(size+2) + "s [fg:bg:d]%s " - fmat := strings.Replace(menuFmt, "[key", "["+styles.Menu.KeyColor, 1) - fmat = strings.Replace(fmat, "[fg", "["+styles.Menu.FgColor, 1) - fmat = strings.Replace(fmat, ":bg:", ":"+styles.Title.BgColor+":", -1) + menuFmt := " [key:-:b]%-" + strconv.Itoa(size+2) + "s [fg:-:d]%s " + fmat := strings.Replace(menuFmt, "[key", "["+styles.Menu.KeyColor.String(), 1) + fmat = strings.Replace(fmat, "[fg", "["+styles.Menu.FgColor.String(), 1) + fmat = strings.Replace(fmat, ":bg:", ":"+styles.Title.BgColor.String()+":", -1) return fmt.Sprintf(fmat, toMnemonic(h.Mnemonic), h.Description) } diff --git a/internal/ui/menu_test.go b/internal/ui/menu_test.go index ead592ca..51511448 100644 --- a/internal/ui/menu_test.go +++ b/internal/ui/menu_test.go @@ -18,9 +18,9 @@ func TestNewMenu(t *testing.T) { {Mnemonic: "0", Description: "zero", Visible: true}, }) - assert.Equal(t, " [fuchsia:black:b]<0> [white:black:d]zero ", v.GetCell(0, 0).Text) - assert.Equal(t, " [dodgerblue:black:b] [white:black:d]bleeA ", v.GetCell(0, 1).Text) - assert.Equal(t, " [dodgerblue:black:b] [white:black:d]bleeB ", v.GetCell(1, 1).Text) + assert.Equal(t, " [fuchsia:-:b]<0> [white:-:d]zero ", v.GetCell(0, 0).Text) + assert.Equal(t, " [dodgerblue:-:b] [white:-:d]bleeA ", v.GetCell(0, 1).Text) + assert.Equal(t, " [dodgerblue:-:b] [white:-:d]bleeB ", v.GetCell(1, 1).Text) } func TestActionHints(t *testing.T) { diff --git a/internal/ui/splash.go b/internal/ui/splash.go index 3c43d318..ba44f209 100644 --- a/internal/ui/splash.go +++ b/internal/ui/splash.go @@ -6,7 +6,6 @@ import ( "github.com/derailed/k9s/internal/config" "github.com/derailed/tview" - "github.com/gdamore/tcell" ) // LogoSmall K9s small log. @@ -37,16 +36,15 @@ type Splash struct { // NewSplash instantiates a new splash screen with product and company info. func NewSplash(styles *config.Styles, version string) *Splash { s := Splash{Flex: tview.NewFlex()} + s.SetBackgroundColor(styles.BgColor()) logo := tview.NewTextView() logo.SetDynamicColors(true) - logo.SetBackgroundColor(tcell.ColorDefault) logo.SetTextAlign(tview.AlignCenter) s.layoutLogo(logo, styles) vers := tview.NewTextView() vers.SetDynamicColors(true) - vers.SetBackgroundColor(tcell.ColorDefault) vers.SetTextAlign(tview.AlignCenter) s.layoutRev(vers, version, styles) diff --git a/internal/ui/table.go b/internal/ui/table.go index 2c091cf2..bcb2e224 100644 --- a/internal/ui/table.go +++ b/internal/ui/table.go @@ -38,6 +38,8 @@ type Table struct { sortCol SortColumn colorerFn render.ColorerFunc decorateFn DecorateFunc + wide bool + toast bool } // NewTable returns a new table view. @@ -65,6 +67,7 @@ func (t *Table) Init(ctx context.Context) { t.SetSelectable(true, false) t.SetSelectionChangedFunc(t.selectionChanged) t.SetInputCapture(t.keyboard) + t.SetBackgroundColor(tcell.ColorDefault) t.styles = mustExtractSyles(ctx) t.StylesChanged(t.styles) @@ -72,17 +75,35 @@ func (t *Table) Init(ctx context.Context) { // StylesChanged notifies the skin changed. func (t *Table) StylesChanged(s *config.Styles) { - t.SetBackgroundColor(config.AsColor(s.Table().BgColor)) - t.SetBorderColor(config.AsColor(s.Table().FgColor)) - t.SetBorderFocusColor(config.AsColor(s.Frame().Border.FocusColor)) + t.SetBackgroundColor(s.Table().BgColor.Color()) + t.SetBorderColor(s.Table().FgColor.Color()) + t.SetBorderFocusColor(s.Frame().Border.FocusColor.Color()) t.SetSelectedStyle( tcell.ColorBlack, - config.AsColor(t.styles.Table().CursorColor), + t.styles.Table().CursorColor.Color(), tcell.AttrBold, ) t.Refresh() } +// ResetToast resets toast flag. +func (t *Table) ResetToast() { + t.toast = false + t.Refresh() +} + +// ToggleToast toggles to show toast resources. +func (t *Table) ToggleToast() { + t.toast = !t.toast + t.Refresh() +} + +// ToggleWide toggles wide col display. +func (t *Table) ToggleWide() { + t.wide = !t.wide + t.Refresh() +} + // Actions returns active menu bindings. func (t *Table) Actions() KeyActions { return t.actions @@ -121,7 +142,7 @@ func (t *Table) keyboard(evt *tcell.EventKey) *tcell.EventKey { if t.filterInput(evt.Rune()) { return nil } - key = asKey(evt) + key = AsKey(evt) } if a, ok := t.actions[key]; ok { @@ -166,10 +187,7 @@ func (t *Table) Update(data render.TableData) { if t.decorateFn != nil { data = t.decorateFn(data) } - if !t.cmdBuff.Empty() { - data = t.filtered(data) - } - t.doUpdate(data) + t.doUpdate(t.filtered(data)) t.UpdateTitle() } @@ -182,13 +200,18 @@ func (t *Table) doUpdate(data render.TableData) { t.Clear() t.adjustSorter(data) - fg := config.AsColor(t.styles.Table().Header.FgColor) - bg := config.AsColor(t.styles.Table().Header.BgColor) - for col, h := range data.Header { + fg := t.styles.Table().Header.FgColor.Color() + bg := t.styles.Table().Header.BgColor.Color() + var col int + for _, h := range data.Header { + if h.Wide && !t.wide { + continue + } t.AddHeaderCell(col, h) c := t.GetCell(0, col) c.SetBackgroundColor(bg) c.SetTextColor(fg) + col++ } data.RowEvents.Sort(data.Namespace, t.sortCol.index, t.sortCol.asc) @@ -209,6 +232,8 @@ func (t *Table) SortColCmd(col int, asc bool) func(evt *tcell.EventKey) *tcell.E index = 0 case -1: index = t.GetColumnCount() - 1 + case -3: + index = t.GetColumnCount() - 2 default: index = t.NameColIndex() + col } @@ -251,29 +276,33 @@ func (t *Table) buildRow(ns string, r int, re render.RowEvent, header render.Hea color = t.colorerFn } marked := t.IsMarked(re.Row.ID) - for col, field := range re.Row.Fields { - if !re.Deltas.IsBlank() && !header.AgeCol(col) { - field += Deltas(re.Deltas[col], field) + var col int + for c, field := range re.Row.Fields { + if header[c].Wide && !t.wide { + continue + } + if !re.Deltas.IsBlank() && !header.AgeCol(c) { + field += Deltas(re.Deltas[c], field) + } + if header[c].Decorator != nil { + field = header[c].Decorator(field) + } + if header[c].Align == tview.AlignLeft { + field = formatCell(field, pads[c]) } - if header[col].Decorator != nil { - field = header[col].Decorator(field) - } - - if header[col].Align == tview.AlignLeft { - field = formatCell(field, pads[col]) - } - c := tview.NewTableCell(field) - c.SetExpansion(1) - c.SetAlign(header[col].Align) - c.SetTextColor(color(ns, re)) + cell := tview.NewTableCell(field) + cell.SetExpansion(1) + cell.SetAlign(header[c].Align) + cell.SetTextColor(color(ns, re)) if marked { - c.SetTextColor(config.AsColor(t.styles.Table().MarkColor)) + cell.SetTextColor(t.styles.Table().MarkColor.Color()) } if col == 0 { - c.SetReference(re.Row.ID) + cell.SetReference(re.Row.ID) } - t.SetCell(r, col, c) + t.SetCell(r, col, cell) + col++ } } @@ -298,6 +327,9 @@ func (t *Table) GetSelectedRow() render.Row { // NameColIndex returns the index of the resource name column. func (t *Table) NameColIndex() int { col := 0 + if client.IsClusterScoped(t.GetModel().GetNamespace()) { + return col + } if t.GetModel().ClusterWide() { col++ } @@ -313,20 +345,25 @@ func (t *Table) AddHeaderCell(col int, h render.Header) { } func (t *Table) filtered(data render.TableData) render.TableData { - if t.cmdBuff.Empty() || IsLabelSelector(t.cmdBuff.String()) { - return data + filtered := data + if t.toast { + filtered = filterToast(data) } - q := t.cmdBuff.String() - if IsFuzzySelector(q) { - return fuzzyFilter(q[2:], t.NameColIndex(), data) + if t.cmdBuff.Empty() || IsLabelSelector(t.cmdBuff.String()) { + return filtered } - filtered, err := rxFilter(t.cmdBuff.String(), data) + q := t.cmdBuff.String() + if IsFuzzySelector(q) { + return fuzzyFilter(q[2:], t.NameColIndex(), filtered) + } + + filtered, err := rxFilter(t.cmdBuff.String(), filtered) if err != nil { log.Error().Err(errors.New("Invalid filter expression")).Msg("Regexp") t.cmdBuff.Clear() - return data } + return filtered } @@ -370,17 +407,17 @@ func (t *Table) styleTitle() string { } } - buff := t.cmdBuff.String() var title string if ns == client.ClusterScope { title = SkinTitle(fmt.Sprintf(TitleFmt, base, rc), t.styles.Frame()) } else { title = SkinTitle(fmt.Sprintf(NSTitleFmt, base, ns, rc), t.styles.Frame()) } + + buff := t.cmdBuff.String() if buff == "" { return title } - if IsLabelSelector(buff) { buff = TrimLabelSelector(buff) } diff --git a/internal/ui/table_helper.go b/internal/ui/table_helper.go index a8f271a1..3f02d7d6 100644 --- a/internal/ui/table_helper.go +++ b/internal/ui/table_helper.go @@ -14,6 +14,9 @@ import ( ) const ( + // DefaultColorName indicator to keep term colors. + DefaultColorName = "default" + // SearchFmt represents a filter view title. SearchFmt = "<[filter:bg:r]/%s[fg:bg:-]> " @@ -81,12 +84,16 @@ func TrimLabelSelector(s string) string { // SkinTitle decorates a title. func SkinTitle(fmat string, style config.Frame) string { - fmat = strings.Replace(fmat, "[fg:bg", "["+style.Title.FgColor+":"+style.Title.BgColor, -1) - fmat = strings.Replace(fmat, "[hilite", "["+style.Title.HighlightColor, 1) - fmat = strings.Replace(fmat, "[key", "["+style.Menu.NumKeyColor, 1) - fmat = strings.Replace(fmat, "[filter", "["+style.Title.FilterColor, 1) - fmat = strings.Replace(fmat, "[count", "["+style.Title.CounterColor, 1) - fmat = strings.Replace(fmat, ":bg:", ":"+style.Title.BgColor+":", -1) + bgColor := style.Title.BgColor + if bgColor == config.DefaultColor { + bgColor = config.TransparentColor + } + fmat = strings.Replace(fmat, "[fg:bg", "["+style.Title.FgColor.String()+":"+bgColor.String(), -1) + fmat = strings.Replace(fmat, "[hilite", "["+style.Title.HighlightColor.String(), 1) + fmat = strings.Replace(fmat, "[key", "["+style.Menu.NumKeyColor.String(), 1) + fmat = strings.Replace(fmat, "[filter", "["+style.Title.FilterColor.String(), 1) + fmat = strings.Replace(fmat, "[count", "["+style.Title.CounterColor.String(), 1) + fmat = strings.Replace(fmat, ":bg:", ":"+bgColor.String()+":", -1) return fmat } @@ -111,6 +118,25 @@ func formatCell(field string, padding int) string { return field } +func filterToast(data render.TableData) render.TableData { + validX := data.Header.IndexOf("VALID") + if validX == -1 { + return data + } + + toast := render.TableData{ + Header: data.Header, + RowEvents: make(render.RowEvents, 0, len(data.RowEvents)), + Namespace: data.Namespace, + } + for _, re := range data.RowEvents { + if re.Row.Fields[validX] != "" { + toast.RowEvents = append(toast.RowEvents, re) + } + } + return toast +} + func rxFilter(q string, data render.TableData) (render.TableData, error) { rx, err := regexp.Compile(`(?i)` + q) if err != nil { diff --git a/internal/ui/table_test.go b/internal/ui/table_test.go index 9f63fc8e..bc32a21d 100644 --- a/internal/ui/table_test.go +++ b/internal/ui/table_test.go @@ -66,6 +66,7 @@ func (t *testModel) Peek() render.TableData { return makeTableData() } func (t *testModel) ClusterWide() bool { return false } func (t *testModel) GetNamespace() string { return "blee" } func (t *testModel) SetNamespace(string) {} +func (t *testModel) ToggleToast() {} func (t *testModel) AddListener(model.TableListener) {} func (t *testModel) Watch(context.Context) {} func (t *testModel) Get(ctx context.Context, path string) (runtime.Object, error) { diff --git a/internal/ui/tree.go b/internal/ui/tree.go index 61363d14..1e0971b0 100644 --- a/internal/ui/tree.go +++ b/internal/ui/tree.go @@ -35,7 +35,7 @@ func NewTree() *Tree { // Init initializes the view func (t *Tree) Init(ctx context.Context) error { - t.bindKeys() + t.BindKeys() t.SetBorder(true) t.SetBorderAttributes(tcell.AttrBold) t.SetBorderPadding(0, 0, 1, 1) @@ -86,7 +86,8 @@ func (t *Tree) ExtraHints() map[string]string { return nil } -func (t *Tree) bindKeys() { +// BindKeys binds default mnemonics. +func (t *Tree) BindKeys() { t.Actions().Add(KeyActions{ KeySpace: NewKeyAction("Expand/Collapse", t.noopCmd, true), KeyX: NewKeyAction("Expand/Collapse All", t.toggleCollapseCmd, true), diff --git a/internal/view/actions.go b/internal/view/actions.go index c15ed77c..9bd173b1 100644 --- a/internal/view/actions.go +++ b/internal/view/actions.go @@ -68,14 +68,14 @@ func hotKeyActions(r Runner, aa ui.KeyActions) { } aa[key] = ui.NewSharedKeyAction( hk.Description, - gotoCmd(r, hk.Command), + gotoCmd(r, "", hk.Command), false) } } -func gotoCmd(r Runner, cmd string) ui.ActionHandler { +func gotoCmd(r Runner, cmd, path string) ui.ActionHandler { return func(evt *tcell.EventKey) *tcell.EventKey { - if err := r.App().gotoResource(cmd, true); err != nil { + if err := r.App().gotoResource(cmd, path, true); err != nil { r.App().Flash().Err(err) } return nil @@ -118,18 +118,22 @@ func execCmd(r Runner, bin string, bg bool, args ...string) ui.ActionHandler { ns, _ := client.Namespaced(path) var ( - env = r.EnvFn()() aa = make([]string, len(args)) err error ) + + if r.EnvFn() == nil { + return nil + } + for i, a := range args { - aa[i], err = env.envFor(ns, a) + aa[i], err = r.EnvFn()().envFor(ns, a) if err != nil { log.Error().Err(err).Msg("Plugin Args match failed") return nil } } - if run(true, r.App(), bin, bg, aa...) { + if run(r.App(), shellOpts{clear: true, binary: bin, background: bg, args: aa}) { r.App().Flash().Info("Plugin command launched successfully!") } else { r.App().Flash().Info("Plugin command failed!") diff --git a/internal/view/alias.go b/internal/view/alias.go index 6b6b7b43..ac582262 100644 --- a/internal/view/alias.go +++ b/internal/view/alias.go @@ -32,6 +32,15 @@ func NewAlias(gvr client.GVR) ResourceViewer { return &a } +// Init initialiazes the view. +func (a *Alias) Init(ctx context.Context) error { + if err := a.ResourceViewer.Init(ctx); err != nil { + return err + } + a.GetTable().GetModel().SetNamespace("*") + return nil +} + func (a *Alias) aliasContext(ctx context.Context) context.Context { return context.WithValue(ctx, internal.KeyAliases, a.App().command.alias) } @@ -51,7 +60,7 @@ func (a *Alias) gotoCmd(evt *tcell.EventKey) *tcell.EventKey { if r != 0 { s := ui.TrimCell(a.GetTable().SelectTable, r, 1) tokens := strings.Split(s, ",") - if err := a.App().gotoResource(tokens[0], true); err != nil { + if err := a.App().gotoResource(tokens[0], "", true); err != nil { a.App().Flash().Err(err) } return nil diff --git a/internal/view/alias_test.go b/internal/view/alias_test.go index ecc4c8d0..24199528 100644 --- a/internal/view/alias_test.go +++ b/internal/view/alias_test.go @@ -23,7 +23,7 @@ func TestAliasNew(t *testing.T) { assert.Nil(t, v.Init(makeContext())) assert.Equal(t, "Aliases", v.Name()) - assert.Equal(t, 5, len(v.Hints())) + assert.Equal(t, 7, len(v.Hints())) } func TestAliasSearch(t *testing.T) { @@ -105,6 +105,7 @@ func (t *testModel) Peek() render.TableData { return makeTableData() } func (t *testModel) ClusterWide() bool { return false } func (t *testModel) GetNamespace() string { return "blee" } func (t *testModel) SetNamespace(string) {} +func (t *testModel) ToggleToast() {} func (t *testModel) AddListener(model.TableListener) {} func (t *testModel) Watch(context.Context) {} func (t *testModel) Get(context.Context, string) (runtime.Object, error) { diff --git a/internal/view/app.go b/internal/view/app.go index 43efb7f8..55e0fb01 100644 --- a/internal/view/app.go +++ b/internal/view/app.go @@ -4,7 +4,7 @@ import ( "context" "errors" "fmt" - "sync" + "sync/atomic" "time" "github.com/derailed/k9s/internal" @@ -24,7 +24,7 @@ var ExitStatus = "" const ( splashDelay = 1 * time.Second clusterRefresh = 5 * time.Second - maxConRetry = 5 + maxConRetry = 10 clusterInfoWidth = 50 clusterInfoPad = 15 ) @@ -39,9 +39,8 @@ type App struct { version string showHeader bool cancelFn context.CancelFunc - conRetry int + conRetry int32 clusterModel *model.ClusterInfo - mx sync.Mutex } // NewApp returns a K9s app instance. @@ -51,7 +50,6 @@ func NewApp(cfg *config.Config) *App { Content: NewPageStack(), } a.Config = cfg - a.InitBench(cfg.K9s.CurrentCluster) a.Views()["statusIndicator"] = ui.NewStatusIndicator(a.App, a.Styles) a.Views()["clusterInfo"] = NewClusterInfo(&a) @@ -61,9 +59,7 @@ func NewApp(cfg *config.Config) *App { // ConOK checks the connection is cool, returns false otherwise. func (a *App) ConOK() bool { - a.mx.Lock() - defer a.mx.Unlock() - return a.conRetry == 0 + return atomic.LoadInt32(&a.conRetry) == 0 } // Init initializes the application. @@ -102,11 +98,14 @@ func (a *App) Init(version string, rate int) error { a.clusterInfo().Init() + flash := ui.NewFlash(a.App) + go flash.Watch(ctx, a.Flash().Channel()) + main := tview.NewFlex().SetDirection(tview.FlexRow) main.AddItem(a.statusIndicator(), 1, 1, false) main.AddItem(a.Content, 0, 10, true) - main.AddItem(a.Crumbs(), 2, 1, false) - main.AddItem(a.Flash(), 2, 1, false) + main.AddItem(a.Crumbs(), 1, 1, false) + main.AddItem(flash, 1, 1, false) a.Main.AddPage("main", main, true, false) a.Main.AddPage("splash", ui.NewSplash(a.Styles, version), true, true) @@ -117,7 +116,7 @@ func (a *App) Init(version string, rate int) error { func (a *App) bindKeys() { a.AddActions(ui.KeyActions{ - tcell.KeyCtrlH: ui.NewSharedKeyAction("ToggleHeader", a.toggleHeaderCmd, false), + tcell.KeyCtrlE: ui.NewSharedKeyAction("ToggleHeader", a.toggleHeaderCmd, false), ui.KeyHelp: ui.NewSharedKeyAction("Help", a.helpCmd, false), tcell.KeyCtrlA: ui.NewSharedKeyAction("Aliases", a.aliasCmd, false), tcell.KeyEnter: ui.NewKeyAction("Goto", a.gotoCmd, false), @@ -137,7 +136,7 @@ func (a *App) toggleHeader(flag bool) { } if a.showHeader { flex.RemoveItemAtIndex(0) - flex.AddItemAtIndex(0, a.buildHeader(), 7, 1, false) + flex.AddItemAtIndex(0, a.buildHeader(), 8, 1, false) } else { flex.RemoveItemAtIndex(0) flex.AddItemAtIndex(0, a.statusIndicator(), 1, 1, false) @@ -147,7 +146,6 @@ func (a *App) toggleHeader(flag bool) { func (a *App) buildHeader() tview.Primitive { header := tview.NewFlex() header.SetBackgroundColor(a.Styles.BgColor()) - header.SetBorderPadding(0, 0, 1, 1) header.SetDirection(tview.FlexColumn) if !a.showHeader { return header @@ -172,6 +170,7 @@ func (a *App) buildHeader() tview.Primitive { func (a *App) Halt() { if a.cancelFn != nil { a.cancelFn() + a.cancelFn = nil } } @@ -198,32 +197,31 @@ func (a *App) clusterUpdater(ctx context.Context) { } func (a *App) refreshCluster() { - a.mx.Lock() - defer a.mx.Unlock() - c := a.Content.Top() if ok := a.Conn().CheckConnectivity(); ok { - if a.conRetry > 0 { + if atomic.LoadInt32(&a.conRetry) > 0 { + atomic.StoreInt32(&a.conRetry, 0) + a.Status(model.FlashInfo, "K8s connectivity OK") if c != nil { c.Start() } - a.Status(ui.FlashInfo, "K8s connectivity OK") } - a.conRetry = 0 } else { - a.conRetry++ - log.Warn().Msgf("Conn check failed (%d/%d)", a.conRetry, maxConRetry) + atomic.AddInt32(&a.conRetry, 1) if c != nil { c.Stop() } - a.Status(ui.FlashWarn, fmt.Sprintf("Dial K8s failed (%d)", a.conRetry)) - + count := atomic.LoadInt32(&a.conRetry) + log.Warn().Msgf("Conn check failed (%d/%d)", count, maxConRetry) + a.Status(model.FlashWarn, fmt.Sprintf("Dial K8s failed (%d)", count)) } - if a.conRetry >= maxConRetry { - ExitStatus = fmt.Sprintf("Lost K8s connection (%d). Bailing out!", a.conRetry) + + count := atomic.LoadInt32(&a.conRetry) + if count >= maxConRetry { + ExitStatus = fmt.Sprintf("Lost K8s connection (%d). Bailing out!", count) a.BailOut() } - if a.conRetry > 0 { + if count > 0 { return } @@ -263,6 +261,7 @@ func (a *App) switchCtx(name string, loadPods bool) error { } a.initFactory(ns) + client.ResetMetrics() if err := a.command.Reset(true); err != nil { return err } @@ -271,11 +270,11 @@ func (a *App) switchCtx(name string, loadPods bool) error { log.Error().Err(err).Msg("Config save failed!") } a.Flash().Infof("Switching context to %s", name) - if err := a.gotoResource("pods", true); loadPods && err != nil { + a.ReloadStyles(name) + if err := a.gotoResource("pods", "", true); loadPods && err != nil { a.Flash().Err(err) } a.clusterModel.Reset(a.factory) - a.ReloadStyles(name) } return nil @@ -314,29 +313,31 @@ func (a *App) Run() error { } // Status reports a new app status for display. -func (a *App) Status(l ui.FlashLevel, msg string) { - a.Flash().SetMessage(l, msg) - a.setIndicator(l, msg) - a.setLogo(l, msg) - a.Draw() +func (a *App) Status(l model.FlashLevel, msg string) { + a.QueueUpdateDraw(func() { + a.Flash().SetMessage(l, msg) + a.setIndicator(l, msg) + a.setLogo(l, msg) + }) } // ClearStatus reset logo back to normal. func (a *App) ClearStatus(flash bool) { - a.Logo().Reset() - if flash { - a.Flash().Clear() - } - a.Draw() + a.QueueUpdateDraw(func() { + a.Logo().Reset() + if flash { + a.Flash().Clear() + } + }) } -func (a *App) setLogo(l ui.FlashLevel, msg string) { +func (a *App) setLogo(l model.FlashLevel, msg string) { switch l { - case ui.FlashErr: + case model.FlashErr: a.Logo().Err(msg) - case ui.FlashWarn: + case model.FlashWarn: a.Logo().Warn(msg) - case ui.FlashInfo: + case model.FlashInfo: a.Logo().Info(msg) default: a.Logo().Reset() @@ -344,13 +345,13 @@ func (a *App) setLogo(l ui.FlashLevel, msg string) { a.Draw() } -func (a *App) setIndicator(l ui.FlashLevel, msg string) { +func (a *App) setIndicator(l model.FlashLevel, msg string) { switch l { - case ui.FlashErr: + case model.FlashErr: a.statusIndicator().Err(msg) - case ui.FlashWarn: + case model.FlashWarn: a.statusIndicator().Warn(msg) - case ui.FlashInfo: + case model.FlashInfo: a.statusIndicator().Info(msg) default: a.statusIndicator().Reset() @@ -381,7 +382,7 @@ func (a *App) toggleHeaderCmd(evt *tcell.EventKey) *tcell.EventKey { func (a *App) gotoCmd(evt *tcell.EventKey) *tcell.EventKey { if a.CmdBuff().IsActive() && !a.CmdBuff().Empty() { - if err := a.gotoResource(a.GetCmd(), true); err != nil { + if err := a.gotoResource(a.GetCmd(), "", true); err != nil { log.Error().Err(err).Msgf("Goto resource for %q failed", a.GetCmd()) a.Flash().Err(err) } @@ -430,8 +431,8 @@ func (a *App) viewResource(gvr, path string, clearStack bool) error { return a.command.run(gvr, path, clearStack) } -func (a *App) gotoResource(cmd string, clearStack bool) error { - return a.command.run(cmd, "", clearStack) +func (a *App) gotoResource(cmd, path string, clearStack bool) error { + return a.command.run(cmd, path, clearStack) } func (a *App) inject(c model.Component) error { diff --git a/internal/view/app_test.go b/internal/view/app_test.go index 4ff96086..b1f3476f 100644 --- a/internal/view/app_test.go +++ b/internal/view/app_test.go @@ -12,5 +12,5 @@ func TestAppNew(t *testing.T) { a := view.NewApp(config.NewConfig(ks{})) a.Init("blee", 10) - assert.Equal(t, 11, len(a.GetActions())) + assert.Equal(t, 12, len(a.GetActions())) } diff --git a/internal/view/benchmark.go b/internal/view/benchmark.go index 681eddee..059c3703 100644 --- a/internal/view/benchmark.go +++ b/internal/view/benchmark.go @@ -46,7 +46,7 @@ func (b *Benchmark) viewBench(app *App, model ui.Tabular, gvr, path string) { return } - details := NewDetails(b.App(), "Benchmark", fileToSubject(path)).Update(data) + details := NewDetails(b.App(), "Results", fileToSubject(path), false).Update(data) if err := app.inject(details); err != nil { app.Flash().Err(err) } diff --git a/internal/view/browser.go b/internal/view/browser.go index b83f9bfb..9d561876 100644 --- a/internal/view/browser.go +++ b/internal/view/browser.go @@ -93,8 +93,11 @@ func (b *Browser) SetInstance(path string) { func (b *Browser) Start() { b.Stop() - log.Debug().Msgf("BROWSER started!") b.Table.Start() + b.GetModel().Watch(b.prepareContext()) +} + +func (b *Browser) prepareContext() context.Context { ctx := b.defaultContext() ctx, b.cancelFn = context.WithCancel(ctx) if b.contextFn != nil { @@ -103,7 +106,8 @@ func (b *Browser) Start() { if path, ok := ctx.Value(internal.KeyPath).(string); ok && path != "" { b.Path = path } - b.GetModel().Watch(ctx) + + return ctx } // Stop terminates browser updates. @@ -111,7 +115,6 @@ func (b *Browser) Stop() { if b.cancelFn == nil { return } - log.Debug().Msgf("BROWSER Stopped!") b.Table.Stop() b.cancelFn() b.cancelFn = nil @@ -147,7 +150,6 @@ func (b *Browser) TableDataChanged(data render.TableData) { b.app.QueueUpdateDraw(func() { b.refreshActions() b.Update(data) - b.App().ClearStatus(false) }) } @@ -175,7 +177,7 @@ func (b *Browser) viewCmd(evt *tcell.EventKey) *tcell.EventKey { return nil } - details := NewDetails(b.app, "YAML", path).Update(raw) + details := NewDetails(b.app, "YAML", path, true).Update(raw) if err := b.App().inject(details); err != nil { b.App().Flash().Err(err) } @@ -278,21 +280,23 @@ func (b *Browser) editCmd(evt *tcell.EventKey) *tcell.EventKey { path := b.GetSelectedItem() if path == "" { return evt + + } + ns, n := client.Namespaced(path) + + if ok, err := b.app.Conn().CanI(ns, b.GVR(), []string{"edit"}); !ok || err != nil { + b.App().Flash().Err(fmt.Errorf("Current user can't edit resource %s", b.GVR())) + return nil } b.Stop() defer b.Start() { - ns, n := client.Namespaced(path) args := make([]string, 0, 10) args = append(args, "edit") args = append(args, b.meta.SingularName) args = append(args, "-n", ns) - args = append(args, "--context", b.app.Config.K9s.CurrentContext) - if cfg := b.app.Conn().Config().Flags().KubeConfig; cfg != nil && *cfg != "" { - args = append(args, "--kubeconfig", *cfg) - } - if !runK(true, b.app, append(args, n)...) { + if !runK(b.app, shellOpts{clear: true, args: append(args, n)}) { b.app.Flash().Err(errors.New("Edit exec failed")) } } @@ -310,6 +314,9 @@ func (b *Browser) switchNamespaceCmd(evt *tcell.EventKey) *tcell.EventKey { auth, err := b.App().factory.Client().CanI(ns, b.GVR(), client.MonitorAccess) if !auth { + if err == nil { + err = fmt.Errorf("current user can't access namespace %s", ns) + } b.App().Flash().Err(err) return nil } @@ -370,7 +377,6 @@ func (b *Browser) refreshActions() { if b.app.ConOK() { b.namespaceActions(aa) - if !b.app.Config.K9s.GetReadOnly() { if client.Can(b.meta.Verbs, "edit") { aa[ui.KeyE] = ui.NewKeyAction("Edit", b.editCmd, true) diff --git a/internal/view/cluster_info.go b/internal/view/cluster_info.go index c69a94a2..b315441e 100644 --- a/internal/view/cluster_info.go +++ b/internal/view/cluster_info.go @@ -30,8 +30,10 @@ func NewClusterInfo(app *App) *ClusterInfo { // Init initializes the view. func (c *ClusterInfo) Init() { + c.SetBorderPadding(0, 0, 1, 0) c.app.Styles.AddListener(c) c.layout() + c.StylesChanged(c.app.Styles) } // StylesChanged notifies skin changed. @@ -51,9 +53,14 @@ func (c *ClusterInfo) layout() { func (c *ClusterInfo) sectionCell(t string) *tview.TableCell { cell := tview.NewTableCell(t + ":") cell.SetAlign(tview.AlignLeft) - var s tcell.Style - cell.SetStyle(s.Bold(true).Foreground(config.AsColor(c.styles.K9s.Info.SectionColor))) - cell.SetBackgroundColor(c.app.Styles.BgColor()) + // var style tcell.Style + // style.Bold(true). + // Background(tcell.ColorGreen). + // Foreground(config.AsColor(c.styles.K9s.Info.SectionColor)) + // cell.SetStyle(style) + // cell.SetBackgroundColor(c.app.Styles.BgColor()) + // cell.SetBackgroundColor(tcell.ColorDefault) + cell.SetBackgroundColor(tcell.ColorGreen) return cell } @@ -61,7 +68,7 @@ func (c *ClusterInfo) sectionCell(t string) *tview.TableCell { func (c *ClusterInfo) infoCell(t string) *tview.TableCell { cell := tview.NewTableCell(t) cell.SetExpansion(2) - cell.SetTextColor(config.AsColor(c.styles.K9s.Info.FgColor)) + cell.SetTextColor(c.styles.K9s.Info.FgColor.Color()) cell.SetBackgroundColor(c.app.Styles.BgColor()) return cell @@ -113,9 +120,9 @@ func (c *ClusterInfo) ClusterInfoChanged(prev, curr model.ClusterMeta) { func (c *ClusterInfo) updateStyle() { for row := 0; row < c.GetRowCount(); row++ { - c.GetCell(row, 0).SetTextColor(config.AsColor(c.styles.K9s.Info.FgColor)) + c.GetCell(row, 0).SetTextColor(c.styles.K9s.Info.FgColor.Color()) c.GetCell(row, 0).SetBackgroundColor(c.styles.BgColor()) var s tcell.Style - c.GetCell(row, 1).SetStyle(s.Bold(true).Foreground(config.AsColor(c.styles.K9s.Info.SectionColor))) + c.GetCell(row, 1).SetStyle(s.Bold(true).Foreground(c.styles.K9s.Info.SectionColor.Color())) } } diff --git a/internal/view/container.go b/internal/view/container.go index 8f47714d..81c3ba35 100644 --- a/internal/view/container.go +++ b/internal/view/container.go @@ -7,13 +7,10 @@ import ( "strings" "github.com/derailed/k9s/internal/client" - "github.com/derailed/k9s/internal/dao" "github.com/derailed/k9s/internal/render" "github.com/derailed/k9s/internal/ui" - "github.com/derailed/k9s/internal/ui/dialog" "github.com/gdamore/tcell" "github.com/rs/zerolog/log" - "k8s.io/client-go/tools/portforward" ) const ( @@ -61,7 +58,7 @@ func (c *Container) bindKeys(aa ui.KeyActions) { ui.KeyShiftX: ui.NewKeyAction("Sort %CPU (REQ)", c.GetTable().SortColCmd(8, false), false), ui.KeyShiftZ: ui.NewKeyAction("Sort %MEM (REQ)", c.GetTable().SortColCmd(9, false), false), tcell.KeyCtrlX: ui.NewKeyAction("Sort %CPU (LIM)", c.GetTable().SortColCmd(8, false), false), - tcell.KeyCtrlZ: ui.NewKeyAction("Sort %MEM (LIM)", c.GetTable().SortColCmd(9, false), false), + tcell.KeyCtrlQ: ui.NewKeyAction("Sort %MEM (LIM)", c.GetTable().SortColCmd(9, false), false), }) } @@ -104,6 +101,7 @@ func (c *Container) portFwdCmd(evt *tcell.EventKey) *tcell.EventKey { return evt } + log.Debug().Msgf("CONTAINER-SEL %q", path) if _, ok := c.App().factory.ForwarderFor(fwFQN(c.GetTable().Path, path)); ok { c.App().Flash().Err(fmt.Errorf("A PortForward already exist on container %s", c.GetTable().Path)) return nil @@ -113,8 +111,8 @@ func (c *Container) portFwdCmd(evt *tcell.EventKey) *tcell.EventKey { if !ok { return nil } - - dialog.ShowPortForward(c.App().Content.Pages, c.preparePort(ports), c.portForward) + log.Debug().Msgf("CONTAINER-PORTS %#v", ports) + ShowPortForwards(c, c.GetTable().Path, ports, startFwdCB) return nil } @@ -133,66 +131,15 @@ func (c *Container) isForwardable(path string) ([]string, bool) { return nil, false } - return ports, true -} - -func (c *Container) preparePort(pp []string) string { - var port string - for _, p := range pp { + pp := make([]string, 0, len(ports)) + for _, p := range ports { if !isTCPPort(p) { continue } - port = strings.TrimSpace(p) - tokens := strings.Split(port, ":") - if len(tokens) == 2 { - port = tokens[1] - } - break - } - if port == "" { - c.App().Flash().Warn("No valid TCP port found on this container. User will specify...") - return "MY_TCP_PORT!" + pp = append(pp, path+"/"+p) } - return port -} - -func (c *Container) portForward(address, lport, cport string) { - err := tryListenPort(lport) - if err != nil { - c.App().Flash().Err(err) - return - } - - co := c.GetTable().GetSelectedCell(0) - pf := dao.NewPortForwarder(c.App().Conn()) - ports := []string{lport + ":" + cport} - fw, err := pf.Start(c.GetTable().Path, co, address, ports) - if err != nil { - c.App().Flash().Err(err) - return - } - - log.Debug().Msgf(">>> Starting port forward %q %v", c.GetTable().Path, ports) - go c.runForward(pf, fw) -} - -func (c *Container) runForward(pf *dao.PortForwarder, f *portforward.PortForwarder) { - c.App().QueueUpdateDraw(func() { - c.App().factory.AddForwarder(pf) - c.App().Flash().Infof("PortForward activated %s:%s", pf.Path(), pf.Ports()[0]) - dialog.DismissPortForward(c.App().Content.Pages) - }) - - pf.SetActive(true) - if err := f.ForwardPorts(); err != nil { - c.App().Flash().Err(err) - return - } - c.App().QueueUpdateDraw(func() { - c.App().factory.DeleteForwarder(pf.FQN()) - pf.SetActive(false) - }) + return pp, true } func tryListenPort(port string) error { diff --git a/internal/view/container_test.go b/internal/view/container_test.go index 7e8497fa..732c35e9 100644 --- a/internal/view/container_test.go +++ b/internal/view/container_test.go @@ -13,5 +13,5 @@ func TestContainerNew(t *testing.T) { assert.Nil(t, c.Init(makeCtx())) assert.Equal(t, "Containers", c.Name()) - assert.Equal(t, 13, len(c.Hints())) + assert.Equal(t, 15, len(c.Hints())) } diff --git a/internal/view/context.go b/internal/view/context.go index 54ffb613..fce01e41 100644 --- a/internal/view/context.go +++ b/internal/view/context.go @@ -55,6 +55,7 @@ func useContext(app *App, name string) error { return errors.New("Expecting a switchable resource") } if err := switcher.Switch(name); err != nil { + log.Error().Err(err).Msgf("Context switch failed") return err } if err := app.switchCtx(name, false); err != nil { diff --git a/internal/view/context_test.go b/internal/view/context_test.go index ebefd197..dc0f693e 100644 --- a/internal/view/context_test.go +++ b/internal/view/context_test.go @@ -13,5 +13,5 @@ func TestContext(t *testing.T) { assert.Nil(t, ctx.Init(makeCtx())) assert.Equal(t, "Contexts", ctx.Name()) - assert.Equal(t, 2, len(ctx.Hints())) + assert.Equal(t, 4, len(ctx.Hints())) } diff --git a/internal/view/details.go b/internal/view/details.go index c1e52f67..a468fa1b 100644 --- a/internal/view/details.go +++ b/internal/view/details.go @@ -3,6 +3,7 @@ package view import ( "context" "fmt" + "strings" "github.com/atotto/clipboard" "github.com/derailed/k9s/internal/config" @@ -10,6 +11,7 @@ import ( "github.com/derailed/k9s/internal/ui" "github.com/derailed/tview" "github.com/gdamore/tcell" + "github.com/sahilm/fuzzy" ) const detailsTitleFmt = "[fg:bg:b] %s([hilite:bg:b]%s[fg:bg:-])[fg:bg:-] " @@ -18,20 +20,26 @@ const detailsTitleFmt = "[fg:bg:b] %s([hilite:bg:b]%s[fg:bg:-])[fg:bg:-] " type Details struct { *tview.TextView - actions ui.KeyActions - app *App - title, subject string - buff string + actions ui.KeyActions + app *App + title, subject string + cmdBuff *ui.CmdBuff + model *model.Text + currentRegion, maxRegions int + searchable bool } // NewDetails returns a details viewer. -func NewDetails(app *App, title, subject string) *Details { +func NewDetails(app *App, title, subject string, searchable bool) *Details { d := Details{ - TextView: tview.NewTextView(), - app: app, - title: title, - subject: subject, - actions: make(ui.KeyActions), + TextView: tview.NewTextView(), + app: app, + title: title, + subject: subject, + actions: make(ui.KeyActions), + cmdBuff: ui.NewCmdBuff('/', ui.FilterBuff), + model: model.NewText(), + searchable: searchable, } return &d @@ -42,37 +50,125 @@ func (d *Details) Init(_ context.Context) error { if d.title != "" { d.SetBorder(true) } - d.SetScrollable(true) - d.SetWrap(true) + d.SetScrollable(true).SetWrap(true).SetRegions(true) d.SetDynamicColors(true) d.SetHighlightColor(tcell.ColorOrange) d.SetTitleColor(tcell.ColorAqua) d.SetInputCapture(d.keyboard) - d.bindKeys() d.SetChangedFunc(func() { d.app.Draw() }) d.updateTitle() + d.app.Styles.AddListener(d) d.StylesChanged(d.app.Styles) + d.cmdBuff.AddListener(d.app.Cmd()) + d.cmdBuff.AddListener(d) + + d.bindKeys() + d.SetInputCapture(d.keyboard) + d.model.AddListener(d) + return nil } +// TextChanged notifies the model changed. +func (d *Details) TextChanged(lines []string) { + d.SetText(colorizeYAML(d.app.Styles.Views().Yaml, strings.Join(lines, "\n"))) + d.ScrollToBeginning() +} + +// TextFiltered notifies when the filter changed. +func (d *Details) TextFiltered(lines []string, matches fuzzy.Matches) { + d.currentRegion, d.maxRegions = 0, 0 + + ll := make([]string, len(lines)) + copy(ll, lines) + for _, m := range matches { + loc, line := m.MatchedIndexes, ll[m.Index] + ll[m.Index] = line[:loc[0]] + fmt.Sprintf(`<<<"search_%d">>>`, d.maxRegions) + line[loc[0]:loc[1]] + `<<<"">>>` + line[loc[1]:] + d.maxRegions++ + } + + d.SetText(colorizeYAML(d.app.Styles.Views().Yaml, strings.Join(ll, "\n"))) + d.Highlight() + if d.maxRegions > 0 { + d.Highlight("search_0") + d.ScrollToHighlight() + } +} + +// BufferChanged indicates the buffer was changed. +func (d *Details) BufferChanged(s string) {} + +// BufferActive indicates the buff activity changed. +func (d *Details) BufferActive(state bool, k ui.BufferKind) { + d.app.BufferActive(state, k) +} + +func (d *Details) bindKeys() { + d.actions.Set(ui.KeyActions{ + tcell.KeyEnter: ui.NewSharedKeyAction("Filter", d.filterCmd, false), + tcell.KeyEscape: ui.NewKeyAction("Back", d.resetCmd, false), + tcell.KeyCtrlS: ui.NewKeyAction("Save", d.saveCmd, false), + ui.KeyC: ui.NewKeyAction("Copy", d.cpCmd, true), + ui.KeyN: ui.NewKeyAction("Next Match", d.nextCmd, true), + ui.KeyShiftN: ui.NewKeyAction("Prev Match", d.prevCmd, true), + ui.KeySlash: ui.NewSharedKeyAction("Filter Mode", d.activateCmd, false), + tcell.KeyCtrlU: ui.NewSharedKeyAction("Clear Filter", d.clearCmd, false), + tcell.KeyBackspace2: ui.NewSharedKeyAction("Erase", d.eraseCmd, false), + tcell.KeyBackspace: ui.NewSharedKeyAction("Erase", d.eraseCmd, false), + tcell.KeyDelete: ui.NewSharedKeyAction("Erase", d.eraseCmd, false), + }) + + if !d.searchable { + d.actions.Delete(ui.KeyN, ui.KeyShiftN) + } +} + +func (d *Details) keyboard(evt *tcell.EventKey) *tcell.EventKey { + key := evt.Key() + if key == tcell.KeyUp || key == tcell.KeyDown { + return evt + } + + if key == tcell.KeyRune { + if d.filterInput(evt.Rune()) { + return nil + } + key = ui.AsKey(evt) + } + + if a, ok := d.actions[key]; ok { + return a.Action(evt) + } + + return evt +} + +func (d *Details) filterInput(r rune) bool { + if !d.cmdBuff.IsActive() { + return false + } + d.cmdBuff.Add(r) + d.updateTitle() + + return true +} + // StylesChanged notifies the skin changed. func (d *Details) StylesChanged(s *config.Styles) { d.SetBackgroundColor(d.app.Styles.BgColor()) d.SetTextColor(d.app.Styles.FgColor()) - d.SetBorderFocusColor(config.AsColor(d.app.Styles.Frame().Border.FocusColor)) + d.SetBorderFocusColor(d.app.Styles.Frame().Border.FocusColor.Color()) - d.Update(d.buff) + d.TextChanged(d.model.Peek()) } // Update updates the view content. func (d *Details) Update(buff string) *Details { - d.buff = buff - d.SetText(colorizeYAML(d.app.Styles.Views().Yaml, buff)) - d.ScrollToBeginning() + d.model.SetText(buff) return d } @@ -108,24 +204,89 @@ func (d *Details) ExtraHints() map[string]string { return nil } -func (d *Details) bindKeys() { - d.actions.Set(ui.KeyActions{ - tcell.KeyEscape: ui.NewKeyAction("Back", d.app.PrevCmd, false), - tcell.KeyCtrlS: ui.NewKeyAction("Save", d.saveCmd, false), - ui.KeyC: ui.NewKeyAction("Copy", d.cpCmd, true), - }) +func (d *Details) nextCmd(evt *tcell.EventKey) *tcell.EventKey { + if d.cmdBuff.Empty() { + return evt + } + + d.currentRegion++ + if d.currentRegion >= d.maxRegions { + d.currentRegion = 0 + } + d.Highlight(fmt.Sprintf("search_%d", d.currentRegion)) + d.ScrollToHighlight() + d.updateTitle() + + return nil } -func (d *Details) keyboard(evt *tcell.EventKey) *tcell.EventKey { - key := evt.Key() - if key == tcell.KeyRune { - key = tcell.Key(evt.Rune()) - } - if a, ok := d.actions[key]; ok { - return a.Action(evt) +func (d *Details) prevCmd(evt *tcell.EventKey) *tcell.EventKey { + if d.cmdBuff.Empty() { + return evt } - return evt + d.currentRegion-- + if d.currentRegion < 0 { + d.currentRegion = d.maxRegions - 1 + } + d.Highlight(fmt.Sprintf("search_%d", d.currentRegion)) + d.ScrollToHighlight() + d.updateTitle() + + return nil +} + +func (d *Details) filterCmd(evt *tcell.EventKey) *tcell.EventKey { + d.model.Filter(d.cmdBuff.String()) + d.cmdBuff.SetActive(false) + d.updateTitle() + + return nil +} + +func (d *Details) activateCmd(evt *tcell.EventKey) *tcell.EventKey { + if d.app.InCmdMode() { + return evt + } + d.app.Flash().Info("Filter mode activated.") + d.cmdBuff.SetActive(true) + + return nil +} + +func (d *Details) clearCmd(*tcell.EventKey) *tcell.EventKey { + if !d.app.InCmdMode() { + return nil + } + d.cmdBuff.Clear() + + return nil +} + +func (d *Details) eraseCmd(evt *tcell.EventKey) *tcell.EventKey { + if !d.cmdBuff.IsActive() { + return nil + } + d.cmdBuff.Delete() + + return nil +} + +func (d *Details) resetCmd(evt *tcell.EventKey) *tcell.EventKey { + if !d.cmdBuff.InCmdMode() { + d.cmdBuff.Reset() + return d.app.PrevCmd(evt) + } + + if d.cmdBuff.String() != "" { + d.model.ClearFilter() + } + d.app.Flash().Info("Clearing filter...") + d.cmdBuff.SetActive(false) + d.cmdBuff.Reset() + d.updateTitle() + + return nil } func (d *Details) saveCmd(evt *tcell.EventKey) *tcell.EventKey { @@ -149,6 +310,19 @@ func (d *Details) updateTitle() { if d.title == "" { return } - title := ui.SkinTitle(fmt.Sprintf(detailsTitleFmt, d.title, d.subject), d.app.Styles.Frame()) - d.SetTitle(title) + fmat := fmt.Sprintf(detailsTitleFmt, d.title, d.subject) + + buff := d.cmdBuff.String() + if buff == "" { + d.SetTitle(ui.SkinTitle(fmat, d.app.Styles.Frame())) + return + } + + search := d.cmdBuff.String() + if d.maxRegions != 0 { + search += fmt.Sprintf("[%d:%d]", d.currentRegion+1, d.maxRegions) + } + fmat += fmt.Sprintf(ui.SearchFmt, search) + + d.SetTitle(ui.SkinTitle(fmat, d.app.Styles.Frame())) } diff --git a/internal/view/dp.go b/internal/view/dp.go index aa9bb793..aa92c575 100644 --- a/internal/view/dp.go +++ b/internal/view/dp.go @@ -2,13 +2,10 @@ package view import ( "github.com/derailed/k9s/internal/client" + "github.com/derailed/k9s/internal/dao" "github.com/derailed/k9s/internal/render" "github.com/derailed/k9s/internal/ui" - appsv1 "k8s.io/api/apps/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/apimachinery/pkg/labels" - "k8s.io/apimachinery/pkg/runtime" ) const scaleDialogKey = "scale" @@ -21,8 +18,15 @@ type Deploy struct { // NewDeploy returns a new deployment view. func NewDeploy(gvr client.GVR) ResourceViewer { d := Deploy{ - ResourceViewer: NewRestartExtender( - NewScaleExtender(NewLogsExtender(NewBrowser(gvr), nil)), + ResourceViewer: NewPortForwardExtender( + NewRestartExtender( + NewScaleExtender( + NewLogsExtender( + NewBrowser(gvr), + nil, + ), + ), + ), ), } d.SetBindKeysFn(d.bindKeys) @@ -36,26 +40,24 @@ func (d *Deploy) bindKeys(aa ui.KeyActions) { aa.Add(ui.KeyActions{ ui.KeyShiftR: ui.NewKeyAction("Sort Ready", d.GetTable().SortColCmd(1, true), false), ui.KeyShiftU: ui.NewKeyAction("Sort UpToDate", d.GetTable().SortColCmd(2, true), false), - ui.KeyShiftV: ui.NewKeyAction("Sort Available", d.GetTable().SortColCmd(3, true), false), + ui.KeyShiftL: ui.NewKeyAction("Sort Available", d.GetTable().SortColCmd(3, true), false), }) } func (d *Deploy) showPods(app *App, model ui.Tabular, gvr, path string) { - o, err := app.factory.Get(d.GVR(), path, true, labels.Everything()) + var res dao.Deployment + res.Init(app.factory, client.NewGVR(d.GVR())) + + dp, err := res.GetInstance(path) if err != nil { app.Flash().Err(err) return } - var dp appsv1.Deployment - err = runtime.DefaultUnstructuredConverter.FromUnstructured(o.(*unstructured.Unstructured).Object, &dp) - if err != nil { - app.Flash().Err(err) - } - showPodsFromSelector(app, path, dp.Spec.Selector) } +// ---------------------------------------------------------------------------- // Helpers... func showPodsFromSelector(app *App, path string, sel *metav1.LabelSelector) { diff --git a/internal/view/dp_test.go b/internal/view/dp_test.go index 6adde7f7..d69fa571 100644 --- a/internal/view/dp_test.go +++ b/internal/view/dp_test.go @@ -13,6 +13,5 @@ func TestDeploy(t *testing.T) { assert.Nil(t, v.Init(makeCtx())) assert.Equal(t, "Deployments", v.Name()) - assert.Equal(t, 10, len(v.Hints())) - + assert.Equal(t, 12, len(v.Hints())) } diff --git a/internal/view/ds.go b/internal/view/ds.go index c18028f2..d9f317d3 100644 --- a/internal/view/ds.go +++ b/internal/view/ds.go @@ -2,12 +2,9 @@ package view import ( "github.com/derailed/k9s/internal/client" + "github.com/derailed/k9s/internal/dao" "github.com/derailed/k9s/internal/render" "github.com/derailed/k9s/internal/ui" - appsv1 "k8s.io/api/apps/v1" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/apimachinery/pkg/labels" - "k8s.io/apimachinery/pkg/runtime" ) // DaemonSet represents a daemon set custom viewer. @@ -18,8 +15,10 @@ type DaemonSet struct { // NewDaemonSet returns a new viewer. func NewDaemonSet(gvr client.GVR) ResourceViewer { d := DaemonSet{ - ResourceViewer: NewRestartExtender( - NewLogsExtender(NewBrowser(gvr), nil), + ResourceViewer: NewPortForwardExtender( + NewRestartExtender( + NewLogsExtender(NewBrowser(gvr), nil), + ), ), } d.SetBindKeysFn(d.bindKeys) @@ -35,19 +34,15 @@ func (d *DaemonSet) bindKeys(aa ui.KeyActions) { ui.KeyShiftC: ui.NewKeyAction("Sort Current", d.GetTable().SortColCmd(2, true), false), ui.KeyShiftR: ui.NewKeyAction("Sort Ready", d.GetTable().SortColCmd(3, true), false), ui.KeyShiftU: ui.NewKeyAction("Sort UpToDate", d.GetTable().SortColCmd(4, true), false), - ui.KeyShiftV: ui.NewKeyAction("Sort Available", d.GetTable().SortColCmd(5, true), false), + ui.KeyShiftL: ui.NewKeyAction("Sort Available", d.GetTable().SortColCmd(5, true), false), }) } func (d *DaemonSet) showPods(app *App, model ui.Tabular, _, path string) { - o, err := app.factory.Get(d.GVR(), path, true, labels.Everything()) - if err != nil { - d.App().Flash().Err(err) - return - } + var res dao.DaemonSet + res.Init(app.factory, client.NewGVR(d.GVR())) - var ds appsv1.DaemonSet - err = runtime.DefaultUnstructuredConverter.FromUnstructured(o.(*unstructured.Unstructured).Object, &ds) + ds, err := res.GetInstance(path) if err != nil { d.App().Flash().Err(err) } diff --git a/internal/view/ds_test.go b/internal/view/ds_test.go index 762ad2b1..43d45f8b 100644 --- a/internal/view/ds_test.go +++ b/internal/view/ds_test.go @@ -13,5 +13,5 @@ func TestDaemonSet(t *testing.T) { assert.Nil(t, v.Init(makeCtx())) assert.Equal(t, "DaemonSets", v.Name()) - assert.Equal(t, 11, len(v.Hints())) + assert.Equal(t, 13, len(v.Hints())) } diff --git a/internal/view/env.go b/internal/view/env.go index 7ddcb4ba..a2bb9f75 100644 --- a/internal/view/env.go +++ b/internal/view/env.go @@ -13,7 +13,7 @@ import ( type K9sEnv map[string]string // EnvRX match $XXX custom arg. -var envRX = regexp.MustCompile(`\$([\w]+)(\d*)`) +var envRX = regexp.MustCompile(`\$(\!?[\w]+)(\d*)`) func (e K9sEnv) envFor(ns, args string) (string, error) { envs := envRX.FindStringSubmatch(args) @@ -41,10 +41,23 @@ func (e K9sEnv) envFor(ns, args string) (string, error) { } func (e K9sEnv) subOut(args, q string) (string, error) { + var reverse bool + if q[0] == '!' { + reverse = true + q = q[1:] + } env, ok := e[strings.ToUpper(q)] if !ok { return "", fmt.Errorf("no env vars exists for argument %q using key %q", args, q) } + if b, err := strconv.ParseBool(env); err == nil { + if reverse { + env = fmt.Sprintf("%t", !b) + } else { + env = fmt.Sprintf("%t", b) + } + } + return envRX.ReplaceAllString(args, env), nil } diff --git a/internal/view/event.go b/internal/view/event.go index 58ffa82b..a0f6f8f5 100644 --- a/internal/view/event.go +++ b/internal/view/event.go @@ -19,10 +19,17 @@ func NewEvent(gvr client.GVR) ResourceViewer { } e.GetTable().SetColorerFn(render.Event{}.ColorerFunc()) e.SetBindKeysFn(e.bindKeys) + e.GetTable().SetSortCol(7, 0, true) return &e } func (e *Event) bindKeys(aa ui.KeyActions) { aa.Delete(tcell.KeyCtrlD, ui.KeyE) + aa.Add(ui.KeyActions{ + ui.KeyShiftY: ui.NewKeyAction("Sort Type", e.GetTable().SortColCmd(1, true), false), + ui.KeyShiftR: ui.NewKeyAction("Sort Reason", e.GetTable().SortColCmd(2, true), false), + ui.KeyShiftE: ui.NewKeyAction("Sort Source", e.GetTable().SortColCmd(3, true), false), + ui.KeyShiftC: ui.NewKeyAction("Sort Count", e.GetTable().SortColCmd(4, true), false), + }) } diff --git a/internal/view/exec.go b/internal/view/exec.go index dc5e39e2..2250b4aa 100644 --- a/internal/view/exec.go +++ b/internal/view/exec.go @@ -13,39 +13,68 @@ import ( "github.com/rs/zerolog/log" ) -func runK(clear bool, app *App, args ...string) bool { +const ( + shellCheck = `command -v bash >/dev/null && exec bash || exec sh` + bannerFmt = "<> Pod: %s | Container: %s \n" +) + +type shellOpts struct { + clear, background bool + binary string + banner string + args []string +} + +func runK(a *App, opts shellOpts) bool { bin, err := exec.LookPath("kubectl") if err != nil { log.Error().Msgf("Unable to find kubectl command in path %v", err) return false } + var args []string + if u, err := a.Conn().Config().ImpersonateUser(); err == nil { + args = append(args, "--as", u) + } + if g, err := a.Conn().Config().ImpersonateGroups(); err == nil { + args = append(args, "--as-group", g) + } + args = append(args, "--context", a.Config.K9s.CurrentContext) + if cfg := a.Conn().Config().Flags().KubeConfig; cfg != nil && *cfg != "" { + args = append(args, "--kubeconfig", *cfg) + } + if len(args) > 0 { + opts.args = append(opts.args, args...) + } - return run(clear, app, bin, false, args...) + opts.binary, opts.background = bin, false + + return run(a, opts) } -func run(clear bool, app *App, bin string, bg bool, args ...string) bool { - app.Halt() - defer app.Resume() +func run(a *App, opts shellOpts) bool { + a.Halt() + defer a.Resume() - return app.Suspend(func() { - if err := execute(clear, bin, bg, args...); err != nil { - app.Flash().Errf("Command exited: %v", err) + return a.Suspend(func() { + if err := execute(opts); err != nil { + a.Flash().Errf("Command exited: %v", err) } }) } -func edit(clear bool, app *App, args ...string) bool { +func edit(a *App, opts shellOpts) bool { bin, err := exec.LookPath(os.Getenv("EDITOR")) if err != nil { log.Error().Msgf("Unable to find editor command in path %v", err) return false } + opts.binary, opts.background = bin, false - return run(clear, app, bin, false, args...) + return run(a, opts) } -func execute(clear bool, bin string, bg bool, args ...string) error { - if clear { +func execute(opts shellOpts) error { + if opts.clear { clearScreen() } ctx, cancel := context.WithCancel(context.Background()) @@ -62,18 +91,19 @@ func execute(clear bool, bin string, bg bool, args ...string) error { cancel() }() - log.Debug().Msgf("Running command > %s %s", bin, strings.Join(args, " ")) + log.Debug().Msgf("Running command> %s %s", opts.binary, strings.Join(opts.args, " ")) - cmd := exec.Command(bin, args...) + cmd := exec.Command(opts.binary, opts.args...) var err error - if bg { + if opts.background { err = cmd.Start() } else { cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr + _, _ = cmd.Stdout.Write([]byte(opts.banner)) err = cmd.Run() } - log.Debug().Msgf("Command returned error?? %v", err) + select { case <-ctx.Done(): return errors.New("canceled by operator") diff --git a/internal/view/help.go b/internal/view/help.go index ca257204..959f4580 100644 --- a/internal/view/help.go +++ b/internal/view/help.go @@ -235,11 +235,11 @@ func (h *Help) showGeneral() model.MenuHints { Description: "Clear command", }, { - Mnemonic: "Ctrl-h", + Mnemonic: "Ctrl-e", Description: "Toggle Header", }, { - Mnemonic: ":q", + Mnemonic: "q", Description: "Quit", }, { diff --git a/internal/view/help_test.go b/internal/view/help_test.go index 18c5ccd3..5f538ad2 100644 --- a/internal/view/help_test.go +++ b/internal/view/help_test.go @@ -21,7 +21,7 @@ func TestHelp(t *testing.T) { v := view.NewHelp() assert.Nil(t, v.Init(ctx)) - assert.Equal(t, 19, v.GetRowCount()) + assert.Equal(t, 22, v.GetRowCount()) assert.Equal(t, 8, v.GetColumnCount()) assert.Equal(t, "", strings.TrimSpace(v.GetCell(1, 0).Text)) assert.Equal(t, "Kill", strings.TrimSpace(v.GetCell(1, 1).Text)) diff --git a/internal/view/helpers.go b/internal/view/helpers.go index bafeacfb..a48e5057 100644 --- a/internal/view/helpers.go +++ b/internal/view/helpers.go @@ -16,39 +16,44 @@ import ( "github.com/rs/zerolog/log" ) -func defaultK9sEnv(app *App, sel string, row render.Row) K9sEnv { - ns, n := client.Namespaced(sel) - ctx, err := app.Conn().Config().CurrentContextName() +func generalEnv(a *App) K9sEnv { + ctx, err := a.Conn().Config().CurrentContextName() if err != nil { ctx = render.NAValue } - cluster, err := app.Conn().Config().CurrentClusterName() + cluster, err := a.Conn().Config().CurrentClusterName() if err != nil { cluster = render.NAValue } - user, err := app.Conn().Config().CurrentUserName() + user, err := a.Conn().Config().CurrentUserName() if err != nil { user = render.NAValue } - groups, err := app.Conn().Config().CurrentGroupNames() + groups, err := a.Conn().Config().CurrentGroupNames() if err != nil { groups = []string{render.NAValue} } + var cfg string - kcfg := app.Conn().Config().Flags().KubeConfig + kcfg := a.Conn().Config().Flags().KubeConfig if kcfg != nil && *kcfg != "" { cfg = *kcfg } - env := K9sEnv{ - "NAMESPACE": ns, - "NAME": n, + return K9sEnv{ "CONTEXT": ctx, "CLUSTER": cluster, "USER": user, "GROUPS": strings.Join(groups, ","), "KUBECONFIG": cfg, } +} + +func defaultK9sEnv(a *App, sel string, row render.Row) K9sEnv { + ns, n := client.Namespaced(sel) + + env := generalEnv(a) + env["NAMESPACE"], env["NAME"] = ns, n for i, r := range row.Fields { env["COL"+strconv.Itoa(i)] = r @@ -67,7 +72,7 @@ func describeResource(app *App, model ui.Tabular, gvr, path string) { return } - details := NewDetails(app, "Describe", path).Update(yaml) + details := NewDetails(app, "Describe", path, true).Update(yaml) if err := app.inject(details); err != nil { app.Flash().Err(err) } diff --git a/internal/view/log.go b/internal/view/log.go index 3865590e..701f8502 100644 --- a/internal/view/log.go +++ b/internal/view/log.go @@ -21,7 +21,7 @@ import ( const ( logTitle = "logs" - logMessage = "Waiting for logs..." + logMessage = "[:orange:b]Waiting for logs...[::]" logCoFmt = " Logs([fg:bg:]%s:[hilite:bg:b]%s[-:bg:-]) " logFmt = " Logs([fg:bg:]%s) " @@ -49,7 +49,7 @@ func NewLog(gvr client.GVR, path, co string, prev bool) *Log { l := Log{ Flex: tview.NewFlex(), cmdBuff: ui.NewCmdBuff('/', ui.FilterBuff), - model: model.NewLog(gvr, logMessage, buildLogOpts(path, co, prev, tailLineCount), defaultTimeout), + model: model.NewLog(gvr, buildLogOpts(path, co, prev, tailLineCount), defaultTimeout), } return &l @@ -66,15 +66,17 @@ func (l *Log) Init(ctx context.Context) (err error) { l.indicator = NewLogIndicator(l.app.Config, l.app.Styles) l.AddItem(l.indicator, 1, 1, false) + l.indicator.Refresh() - l.logs = NewDetails(l.app, "", "") + l.logs = NewDetails(l.app, "", "", false) if err = l.logs.Init(ctx); err != nil { return err } + l.logs.SetText(logMessage) l.logs.SetWrap(false) l.logs.SetMaxBuffer(l.app.Config.K9s.LogBufferSize) - l.ansiWriter = tview.ANSIWriter(l.logs, l.app.Styles.Views().Log.FgColor, l.app.Styles.Views().Log.BgColor) + l.ansiWriter = tview.ANSIWriter(l.logs, l.app.Styles.Views().Log.FgColor.String(), l.app.Styles.Views().Log.BgColor.String()) l.AddItem(l.logs, 0, 1, true) l.bindKeys() l.logs.SetInputCapture(l.keyboard) @@ -125,9 +127,9 @@ func (l *Log) BufferActive(state bool, k ui.BufferKind) { // StylesChanged reports skin changes. func (l *Log) StylesChanged(s *config.Styles) { - l.SetBackgroundColor(config.AsColor(s.Views().Log.BgColor)) - l.logs.SetTextColor(config.AsColor(s.Views().Log.FgColor)) - l.logs.SetBackgroundColor(config.AsColor(s.Views().Log.BgColor)) + l.SetBackgroundColor(s.Views().Log.BgColor.Color()) + l.logs.SetTextColor(s.Views().Log.FgColor.Color()) + l.logs.SetBackgroundColor(s.Views().Log.BgColor.Color()) } // GetModel returns the log model. @@ -173,7 +175,7 @@ func (l *Log) bindKeys() { ui.KeyW: ui.NewKeyAction("Toggle Wrap", l.textWrapCmd, true), tcell.KeyCtrlS: ui.NewKeyAction("Save", l.SaveCmd, true), ui.KeySlash: ui.NewSharedKeyAction("Filter Mode", l.activateCmd, false), - tcell.KeyCtrlU: ui.NewSharedKeyAction("Clear Filter", l.clearCmd, false), + tcell.KeyCtrlU: ui.NewSharedKeyAction("Clear Filter", l.resetCmd, false), tcell.KeyBackspace2: ui.NewSharedKeyAction("Erase", l.eraseCmd, false), tcell.KeyBackspace: ui.NewSharedKeyAction("Erase", l.eraseCmd, false), tcell.KeyDelete: ui.NewSharedKeyAction("Erase", l.eraseCmd, false), diff --git a/internal/view/log_indicator.go b/internal/view/log_indicator.go index 11634ad9..31a20ec6 100644 --- a/internal/view/log_indicator.go +++ b/internal/view/log_indicator.go @@ -26,7 +26,7 @@ func NewLogIndicator(cfg *config.Config, styles *config.Styles) *LogIndicator { scrollStatus: 1, fullScreen: cfg.K9s.FullScreenLogs, } - l.SetBackgroundColor(config.AsColor(styles.Views().Log.BgColor)) + l.SetBackgroundColor(styles.Views().Log.BgColor.Color()) l.SetTextAlign(tview.AlignRight) l.SetDynamicColors(true) diff --git a/internal/view/node.go b/internal/view/node.go index 4d69e677..dafbad67 100644 --- a/internal/view/node.go +++ b/internal/view/node.go @@ -59,7 +59,7 @@ func (n *Node) viewCmd(evt *tcell.EventKey) *tcell.EventKey { return nil } - details := NewDetails(n.App(), "YAML", sel).Update(raw) + details := NewDetails(n.App(), "YAML", sel, true).Update(raw) if err := n.App().inject(details); err != nil { n.App().Flash().Err(err) } diff --git a/internal/view/ns.go b/internal/view/ns.go index adf567c0..1a79fb94 100644 --- a/internal/view/ns.go +++ b/internal/view/ns.go @@ -1,6 +1,8 @@ package view import ( + "time" + "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/render" @@ -40,7 +42,7 @@ func (n *Namespace) bindKeys(aa ui.KeyActions) { func (n *Namespace) switchNs(app *App, model ui.Tabular, gvr, path string) { n.useNamespace(path) - if err := app.gotoResource("pods", true); err != nil { + if err := app.gotoResource("pods", "", true); err != nil { app.Flash().Err(err) } } @@ -76,12 +78,13 @@ func (n *Namespace) decorate(data render.TableData) render.TableData { // checks if all ns is in the list if not add it. if _, ok := data.RowEvents.FindIndex(client.NamespaceAll); !ok { + log.Debug().Msg("YO!!") data.RowEvents = append(data.RowEvents, render.RowEvent{ Kind: render.EventUnchanged, Row: render.Row{ ID: client.NamespaceAll, - Fields: render.Fields{client.NamespaceAll, "Active", "0"}, + Fields: render.Fields{client.NamespaceAll, "Active", "", "", time.Now().String()}, }, }, ) diff --git a/internal/view/ns_test.go b/internal/view/ns_test.go index 6a98ac0a..a78b829a 100644 --- a/internal/view/ns_test.go +++ b/internal/view/ns_test.go @@ -13,5 +13,5 @@ func TestNSCleanser(t *testing.T) { assert.Nil(t, ns.Init(makeCtx())) assert.Equal(t, "Namespaces", ns.Name()) - assert.Equal(t, 4, len(ns.Hints())) + assert.Equal(t, 6, len(ns.Hints())) } diff --git a/internal/view/ofaas.go b/internal/view/ofaas.go new file mode 100644 index 00000000..f2fa81a8 --- /dev/null +++ b/internal/view/ofaas.go @@ -0,0 +1,46 @@ +package view + +import ( + "strings" + + "github.com/derailed/k9s/internal/client" + "github.com/derailed/k9s/internal/render" + "github.com/derailed/k9s/internal/ui" +) + +// OpenFaas represents an OpenFaaS viewer. +type OpenFaas struct { + ResourceViewer +} + +// NewOpenFaas returns a new viewer. +func NewOpenFaas(gvr client.GVR) ResourceViewer { + o := OpenFaas{ResourceViewer: NewBrowser(gvr)} + o.SetBindKeysFn(o.bindKeys) + o.GetTable().SetEnterFn(o.showPods) + o.GetTable().SetColorerFn(render.OpenFaas{}.ColorerFunc()) + + return &o +} + +func (o *OpenFaas) bindKeys(aa ui.KeyActions) { + aa.Add(ui.KeyActions{ + ui.KeyShiftS: ui.NewKeyAction("Sort Status", o.GetTable().SortColCmd(2, true), false), + ui.KeyShiftI: ui.NewKeyAction("Sort Invocations", o.GetTable().SortColCmd(4, false), false), + ui.KeyShiftR: ui.NewKeyAction("Sort Replicas", o.GetTable().SortColCmd(5, false), false), + ui.KeyShiftL: ui.NewKeyAction("Sort Available", o.GetTable().SortColCmd(6, false), false), + }) +} + +func (o *OpenFaas) showPods(a *App, _ ui.Tabular, _, path string) { + labels := o.GetTable().GetSelectedCell(o.GetTable().NameColIndex() + 3) + sels := make(map[string]string) + + tokens := strings.Split(labels, ",") + for _, t := range tokens { + s := strings.Split(t, "=") + sels[s[0]] = s[1] + } + + showPodsWithLabels(a, path, sels) +} diff --git a/internal/view/pf_dialog.go b/internal/view/pf_dialog.go new file mode 100644 index 00000000..973644df --- /dev/null +++ b/internal/view/pf_dialog.go @@ -0,0 +1,94 @@ +package view + +import ( + "fmt" + "strings" + + "github.com/derailed/k9s/internal/client" + "github.com/derailed/k9s/internal/ui" + "github.com/derailed/tview" +) + +const portForwardKey = "portforward" + +// PortForwardFunc represents a port-forward callback function. +type PortForwardFunc func(v ResourceViewer, path, co string, mapper client.PortTunnel) + +// ShowPortForwards pops a port forwarding configuration dialog. +func ShowPortForwards(v ResourceViewer, path string, ports []string, okFn PortForwardFunc) { + styles := v.App().Styles + + f := tview.NewForm() + f.SetItemPadding(0) + f.SetButtonsAlign(tview.AlignCenter). + SetButtonBackgroundColor(styles.BgColor()). + SetButtonTextColor(styles.FgColor()). + SetLabelColor(styles.K9s.Info.FgColor.Color()). + SetFieldTextColor(styles.K9s.Info.SectionColor.Color()) + + p1, p2, address := ports[0], extractPort(ports[0]), "localhost" + f.AddInputField("Container Port:", p1, 30, nil, func(p string) { + p1 = p + }) + f.AddInputField("Local Port:", p2, 30, nil, func(p string) { + p2 = p + }) + f.AddInputField("Address:", address, 30, nil, func(h string) { + address = h + }) + + pages := v.App().Content.Pages + + f.AddButton("OK", func() { + tunnel := client.PortTunnel{ + Address: address, + LocalPort: p2, + ContainerPort: extractPort(p1), + } + okFn(v, path, extractContainer(p1), tunnel) + }) + f.AddButton("Cancel", func() { + DismissPortForwards(v.App(), pages) + }) + + modal := tview.NewModalForm(fmt.Sprintf("", path), f) + modal.SetText("Exposed Ports: " + strings.Join(ports, ",")) + modal.SetDoneFunc(func(_ int, b string) { + DismissPortForwards(v.App(), pages) + }) + + pages.AddPage(portForwardKey, modal, false, true) + pages.ShowPage(portForwardKey) + v.App().SetFocus(pages.GetPrimitive(portForwardKey)) +} + +// DismissPortForwards dismiss the port forward dialog. +func DismissPortForwards(app *App, p *ui.Pages) { + p.RemovePage(portForwardKey) + app.SetFocus(p.CurrentPage().Item) +} + +// ---------------------------------------------------------------------------- +// Helpers... + +func extractPort(p string) string { + tokens := strings.Split(p, ":") + switch { + case len(tokens) < 2: + return tokens[0] + case len(tokens) == 2: + return strings.Replace(tokens[1], "╱UDP", "", 1) + default: + return tokens[1] + } +} + +func extractContainer(p string) string { + tokens := strings.Split(p, ":") + if len(tokens) != 2 { + return "n/a" + } + + co, _ := client.Namespaced(tokens[0]) + return co +} diff --git a/internal/view/pf_dialog_test.go b/internal/view/pf_dialog_test.go new file mode 100644 index 00000000..6b654c55 --- /dev/null +++ b/internal/view/pf_dialog_test.go @@ -0,0 +1,56 @@ +package view + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestExtractPort(t *testing.T) { + uu := map[string]struct { + port, e string + }{ + "full": { + "co/fred:8000", "8000", + }, + "named": { + "fred:8000", "8000", + }, + "port": { + "8000", "8000", + }, + "protocol": { + "dns:53╱UDP", "53", + }, + } + + for k := range uu { + u := uu[k] + t.Run(k, func(t *testing.T) { + assert.Equal(t, u.e, extractPort(u.port)) + }) + } +} + +func TestExtractContainer(t *testing.T) { + uu := map[string]struct { + port, e string + }{ + "full": { + "co/port:8000", "co", + }, + "unamed": { + "co/:8000", "co", + }, + "protocol": { + "co/dns:53╱UDP", "co", + }, + } + + for k := range uu { + u := uu[k] + t.Run(k, func(t *testing.T) { + assert.Equal(t, u.e, extractContainer(u.port)) + }) + } +} diff --git a/internal/view/pf_extender.go b/internal/view/pf_extender.go new file mode 100644 index 00000000..2907f2a8 --- /dev/null +++ b/internal/view/pf_extender.go @@ -0,0 +1,154 @@ +package view + +import ( + "errors" + "fmt" + "strconv" + + "github.com/derailed/k9s/internal/client" + "github.com/derailed/k9s/internal/dao" + "github.com/derailed/k9s/internal/ui" + "github.com/derailed/k9s/internal/watch" + "github.com/gdamore/tcell" + "github.com/rs/zerolog/log" + v1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/tools/portforward" +) + +// PortForwardExtender adds port-forward extensions. +type PortForwardExtender struct { + ResourceViewer +} + +// NewPortForwardExtender returns a new extender. +func NewPortForwardExtender(r ResourceViewer) ResourceViewer { + s := PortForwardExtender{ResourceViewer: r} + s.bindKeys(s.Actions()) + + return &s +} + +func (p *PortForwardExtender) bindKeys(aa ui.KeyActions) { + aa.Add(ui.KeyActions{ + ui.KeyShiftF: ui.NewKeyAction("Port-Forward", p.portFwdCmd, true), + }) +} + +func (p *PortForwardExtender) portFwdCmd(evt *tcell.EventKey) *tcell.EventKey { + path := p.GetTable().GetSelectedItem() + if path == "" { + return evt + } + + pod, err := p.fetchPodName(path) + if err != nil { + p.App().Flash().Err(err) + return nil + } + if err := showFwdDialog(p, pod, startFwdCB); err != nil { + p.App().Flash().Err(err) + } + + return nil +} + +func (p *PortForwardExtender) fetchPodName(path string) (string, error) { + res, err := dao.AccessorFor(p.App().factory, client.NewGVR(p.GVR())) + if err != nil { + return "", nil + } + ctrl, ok := res.(dao.Controller) + if !ok { + return "", fmt.Errorf("expecting a controller resource for %q", p.GVR()) + } + + return ctrl.Pod(path) +} + +// ---------------------------------------------------------------------------- +// Helpers... + +func runForward(v ResourceViewer, pf watch.Forwarder, f *portforward.PortForwarder) { + v.App().factory.AddForwarder(pf) + + v.App().QueueUpdateDraw(func() { + v.App().Flash().Infof("PortForward activated %s:%s", pf.Path(), pf.Ports()[0]) + DismissPortForwards(v.App(), v.App().Content.Pages) + }) + + pf.SetActive(true) + if err := f.ForwardPorts(); err != nil { + v.App().Flash().Err(err) + return + } + + v.App().QueueUpdateDraw(func() { + v.App().factory.DeleteForwarder(pf.FQN()) + pf.SetActive(false) + }) +} + +func startFwdCB(v ResourceViewer, path, co string, t client.PortTunnel) { + log.Debug().Msgf("CURRENT-FWD %#v", v.App().factory.Forwarders()) + + if _, ok := v.App().factory.ForwarderFor(dao.PortForwardID(path, co)); ok { + v.App().Flash().Err(errors.New("A port-forward is already active on this pod")) + return + } + + pf := dao.NewPortForwarder(v.App().factory) + fwd, err := pf.Start(path, co, t) + if err != nil { + v.App().Flash().Err(err) + return + } + + log.Debug().Msgf(">>> Starting port forward %q %#v", path, t) + go runForward(v, pf, fwd) +} + +func showFwdDialog(v ResourceViewer, path string, cb PortForwardFunc) error { + mm, err := fetchPodPorts(v.App().factory, path) + if err != nil { + return nil + } + ports := make([]string, 0, len(mm)) + for co, pp := range mm { + for _, p := range pp { + if p.Protocol != v1.ProtocolTCP { + continue + } + ports = append(ports, client.FQN(co, p.Name)+":"+strconv.Itoa(int(p.ContainerPort))) + } + } + if len(ports) == 0 { + return fmt.Errorf("no tcp ports found on %s", path) + } + ShowPortForwards(v, path, ports, cb) + + return nil +} + +func fetchPodPorts(f *watch.Factory, path string) (map[string][]v1.ContainerPort, error) { + log.Debug().Msgf("Fetching ports on pod %q", path) + o, err := f.Get("v1/pods", path, false, labels.Everything()) + if err != nil { + return nil, err + } + + var pod v1.Pod + err = runtime.DefaultUnstructuredConverter.FromUnstructured(o.(*unstructured.Unstructured).Object, &pod) + if err != nil { + return nil, err + } + + pp := make(map[string][]v1.ContainerPort) + for _, co := range pod.Spec.Containers { + pp[co.Name] = co.Ports + } + + return pp, nil +} diff --git a/internal/view/pod.go b/internal/view/pod.go index cc03de98..08facd08 100644 --- a/internal/view/pod.go +++ b/internal/view/pod.go @@ -4,22 +4,22 @@ import ( "context" "errors" "fmt" + "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/dao" + "github.com/derailed/k9s/internal/model" "github.com/derailed/k9s/internal/render" "github.com/derailed/k9s/internal/ui" "github.com/derailed/k9s/internal/watch" + "github.com/fatih/color" "github.com/gdamore/tcell" - "github.com/rs/zerolog/log" v1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime" ) -const shellCheck = "command -v bash >/dev/null && exec bash || exec sh" - // Pod represents a pod viewer. type Pod struct { ResourceViewer @@ -27,7 +27,11 @@ type Pod struct { // NewPod returns a new viewer. func NewPod(gvr client.GVR) ResourceViewer { - p := Pod{ResourceViewer: NewLogsExtender(NewBrowser(gvr), nil)} + p := Pod{ + ResourceViewer: NewPortForwardExtender( + NewLogsExtender(NewBrowser(gvr), nil), + ), + } p.SetBindKeysFn(p.bindKeys) p.GetTable().SetEnterFn(p.showContainers) p.GetTable().SetColorerFn(render.Pod{}.ColorerFunc()) @@ -49,21 +53,20 @@ func (p *Pod) bindKeys(aa ui.KeyActions) { aa.Add(ui.KeyActions{ ui.KeyShiftR: ui.NewKeyAction("Sort Ready", p.GetTable().SortColCmd(1, true), false), - ui.KeyShiftS: ui.NewKeyAction("Sort Status", p.GetTable().SortColCmd(2, true), false), - ui.KeyShiftT: ui.NewKeyAction("Sort Restart", p.GetTable().SortColCmd(3, false), false), + ui.KeyShiftT: ui.NewKeyAction("Sort Restart", p.GetTable().SortColCmd(2, false), false), + ui.KeyShiftS: ui.NewKeyAction("Sort Status", p.GetTable().SortColCmd(3, true), false), ui.KeyShiftC: ui.NewKeyAction("Sort CPU", p.GetTable().SortColCmd(4, false), false), ui.KeyShiftM: ui.NewKeyAction("Sort MEM", p.GetTable().SortColCmd(5, false), false), ui.KeyShiftX: ui.NewKeyAction("Sort %CPU (REQ)", p.GetTable().SortColCmd(6, false), false), ui.KeyShiftZ: ui.NewKeyAction("Sort %MEM (REQ)", p.GetTable().SortColCmd(7, false), false), tcell.KeyCtrlX: ui.NewKeyAction("Sort %CPU (LIM)", p.GetTable().SortColCmd(8, false), false), - tcell.KeyCtrlZ: ui.NewKeyAction("Sort %MEM (LIM)", p.GetTable().SortColCmd(9, false), false), + tcell.KeyCtrlQ: ui.NewKeyAction("Sort %MEM (LIM)", p.GetTable().SortColCmd(9, false), false), ui.KeyShiftI: ui.NewKeyAction("Sort IP", p.GetTable().SortColCmd(10, true), false), ui.KeyShiftO: ui.NewKeyAction("Sort Node", p.GetTable().SortColCmd(11, true), false), }) } func (p *Pod) showContainers(app *App, model ui.Tabular, gvr, path string) { - log.Debug().Msgf("SHOW CONTAINERS %q -- %q -- %q", gvr, model.GetNamespace(), path) co := NewContainer(client.NewGVR("containers")) co.SetContextFn(p.coContext) if err := app.inject(co); err != nil { @@ -108,75 +111,66 @@ func (p *Pod) killCmd(evt *tcell.EventKey) *tcell.EventKey { } func (p *Pod) shellCmd(evt *tcell.EventKey) *tcell.EventKey { - sel := p.GetTable().GetSelectedItem() - if sel == "" { + path := p.GetTable().GetSelectedItem() + if path == "" { return evt } row := p.GetTable().GetSelectedRowIndex() - status := ui.TrimCell(p.GetTable().SelectTable, row, p.GetTable().NameColIndex()+2) + status := ui.TrimCell(p.GetTable().SelectTable, row, p.GetTable().NameColIndex()+3) if status != render.Running { - p.App().Flash().Errf("%s is not in a running state", sel) + p.App().Flash().Errf("%s is not in a running state", path) return nil } - cc, err := fetchContainers(p.App().factory, sel, false) - if err != nil { - p.App().Flash().Errf("Unable to retrieve containers %s", err) - return evt - } - if len(cc) == 1 { - p.shellIn(sel, "") - return nil - } - picker := NewPicker() - picker.populate(cc) - picker.SetSelectedFunc(func(i int, t, d string, r rune) { - p.shellIn(sel, t) - }) - if err := p.App().inject(picker); err != nil { + + if err := containerShellin(p.App(), p, path, ""); err != nil { p.App().Flash().Err(err) } - return evt -} - -func (p *Pod) shellIn(path, co string) { - p.Stop() - shellIn(p.App(), path, co) - p.Start() + return nil } // ---------------------------------------------------------------------------- // Helpers... -func fetchContainers(f *watch.Factory, path string, includeInit bool) ([]string, error) { - o, err := f.Get("v1/pods", path, true, labels.Everything()) - if err != nil { - return nil, err +func containerShellin(a *App, comp model.Component, path, co string) error { + if co != "" { + resumeShellIn(a, comp, path, co) + return nil } - var pod v1.Pod - err = runtime.DefaultUnstructuredConverter.FromUnstructured(o.(*unstructured.Unstructured).Object, &pod) + cc, err := fetchContainers(a.factory, path, false) if err != nil { - return nil, err + return err + } + if len(cc) == 1 { + resumeShellIn(a, comp, path, cc[0]) + return nil + } + picker := NewPicker() + picker.populate(cc) + picker.SetSelectedFunc(func(_ int, co, _ string, _ rune) { + resumeShellIn(a, comp, path, co) + }) + if err := a.inject(picker); err != nil { + return err } - nn := make([]string, 0, len(pod.Spec.Containers)+len(pod.Spec.InitContainers)) - for _, c := range pod.Spec.Containers { - nn = append(nn, c.Name) - } - if includeInit { - for _, c := range pod.Spec.InitContainers { - nn = append(nn, c.Name) - } - } - return nn, nil + return nil +} + +func resumeShellIn(a *App, c model.Component, path, co string) { + c.Stop() + defer c.Start() + + shellIn(a, path, co) } func shellIn(a *App, path, co string) { args := computeShellArgs(path, co, a.Config.K9s.CurrentContext, a.Conn().Config().Flags().KubeConfig) - log.Debug().Msgf("Shell args %v", args) - if !runK(true, a, args...) { + + c := color.New(color.BgGreen).Add(color.FgBlack).Add(color.Bold) + if !runK(a, shellOpts{clear: true, banner: c.Sprintf(bannerFmt, path, co), args: args}) { a.Flash().Err(errors.New("Shell exec failed")) } } @@ -197,3 +191,29 @@ func computeShellArgs(path, co, context string, kcfg *string) []string { return append(args, "--", "sh", "-c", shellCheck) } + +func fetchContainers(f *watch.Factory, path string, includeInit bool) ([]string, error) { + o, err := f.Get("v1/pods", path, true, labels.Everything()) + if err != nil { + return nil, err + } + + var pod v1.Pod + err = runtime.DefaultUnstructuredConverter.FromUnstructured(o.(*unstructured.Unstructured).Object, &pod) + if err != nil { + return nil, err + } + + nn := make([]string, 0, len(pod.Spec.Containers)+len(pod.Spec.InitContainers)) + for _, c := range pod.Spec.Containers { + nn = append(nn, c.Name) + } + if !includeInit { + return nn, nil + } + for _, c := range pod.Spec.InitContainers { + nn = append(nn, c.Name) + } + + return nn, nil +} diff --git a/internal/view/pod_test.go b/internal/view/pod_test.go index 00878bdb..dd5c43bb 100644 --- a/internal/view/pod_test.go +++ b/internal/view/pod_test.go @@ -16,7 +16,7 @@ func TestPodNew(t *testing.T) { assert.Nil(t, po.Init(makeCtx())) assert.Equal(t, "Pods", po.Name()) - assert.Equal(t, 18, len(po.Hints())) + assert.Equal(t, 21, len(po.Hints())) } // Helpers... diff --git a/internal/view/port_forward.go b/internal/view/port_forward.go index 551e2558..89ecc88a 100644 --- a/internal/view/port_forward.go +++ b/internal/view/port_forward.go @@ -7,8 +7,8 @@ import ( "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/client" - "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/dao" + "github.com/derailed/k9s/internal/model" "github.com/derailed/k9s/internal/perf" "github.com/derailed/k9s/internal/render" "github.com/derailed/k9s/internal/ui" @@ -42,7 +42,7 @@ func NewPortForward(gvr client.GVR) ResourceViewer { } func (p *PortForward) portForwardContext(ctx context.Context) context.Context { - return context.WithValue(ctx, internal.KeyBenchCfg, p.App().Bench) + return context.WithValue(ctx, internal.KeyBenchCfg, p.App().BenchFile) } func (p *PortForward) bindKeys(aa ui.KeyActions) { @@ -65,47 +65,45 @@ func (p *PortForward) showBenchCmd(evt *tcell.EventKey) *tcell.EventKey { func (p *PortForward) toggleBenchCmd(evt *tcell.EventKey) *tcell.EventKey { if p.bench != nil { - p.App().Status(ui.FlashErr, "Benchmark Canceled!") + p.App().Status(model.FlashErr, "Benchmark Canceled!") p.bench.Cancel() p.App().ClearStatus(true) return nil } - sel := p.GetTable().GetSelectedItem() - if sel == "" { + path := p.GetTable().GetSelectedItem() + if path == "" { return nil } + cfg := dao.BenchConfigFor(p.App().BenchFile, path) + cfg.Name = path r, _ := p.GetTable().GetSelection() - cfg := defaultConfig() - if b, ok := p.App().Bench.Benchmarks.Containers[sel]; ok { - cfg = b - } - cfg.Name = sel - base := ui.TrimCell(p.GetTable().SelectTable, r, 4) var err error - if p.bench, err = perf.NewBenchmark(base, p.App().version, cfg); err != nil { + p.bench, err = perf.NewBenchmark(base, p.App().version, cfg) + if err != nil { p.App().Flash().Errf("Bench failed %v", err) p.App().ClearStatus(false) return nil } - p.App().Status(ui.FlashWarn, "Benchmark in progress...") - log.Debug().Msg("Bench starting...") + p.App().Status(model.FlashWarn, "Benchmark in progress...") go p.runBenchmark() return nil } func (p *PortForward) runBenchmark() { + log.Debug().Msg("Bench starting...") + p.bench.Run(p.App().Config.K9s.CurrentCluster, func() { log.Debug().Msg("Bench Completed!") p.App().QueueUpdate(func() { if p.bench.Canceled() { - p.App().Status(ui.FlashInfo, "Benchmark canceled") + p.App().Status(model.FlashInfo, "Benchmark canceled") } else { - p.App().Status(ui.FlashInfo, "Benchmark Completed!") + p.App().Status(model.FlashInfo, "Benchmark Completed!") p.bench.Cancel() } p.bench = nil @@ -127,7 +125,6 @@ func (p *PortForward) deleteCmd(evt *tcell.EventKey) *tcell.EventKey { if path == "" { return nil } - log.Debug().Msgf("PF DELETE %q", path) showModal(p.App().Content.Pages, fmt.Sprintf("Delete PortForward `%s?", path), func() { var pf dao.PortForward @@ -146,17 +143,6 @@ func (p *PortForward) deleteCmd(evt *tcell.EventKey) *tcell.EventKey { // ---------------------------------------------------------------------------- // Helpers... -func defaultConfig() config.BenchConfig { - return config.BenchConfig{ - C: config.DefaultC, - N: config.DefaultN, - HTTP: config.HTTP{ - Method: config.DefaultMethod, - Path: "/", - }, - } -} - func showModal(p *ui.Pages, msg string, ok func()) { m := tview.NewModal(). AddButtons([]string{"Cancel", "OK"}). diff --git a/internal/view/port_forward_test.go b/internal/view/port_forward_test.go index fabe07b8..eb21f9f4 100644 --- a/internal/view/port_forward_test.go +++ b/internal/view/port_forward_test.go @@ -13,5 +13,5 @@ func TestPortForwardNew(t *testing.T) { assert.Nil(t, pf.Init(makeCtx())) assert.Equal(t, "PortForwards", pf.Name()) - assert.Equal(t, 7, len(pf.Hints())) + assert.Equal(t, 9, len(pf.Hints())) } diff --git a/internal/view/pulse.go b/internal/view/pulse.go new file mode 100644 index 00000000..b936f42b --- /dev/null +++ b/internal/view/pulse.go @@ -0,0 +1,362 @@ +package view + +import ( + "context" + "fmt" + "image" + "strings" + + "github.com/derailed/k9s/internal" + "github.com/derailed/k9s/internal/client" + "github.com/derailed/k9s/internal/config" + "github.com/derailed/k9s/internal/health" + "github.com/derailed/k9s/internal/model" + "github.com/derailed/k9s/internal/tchart" + "github.com/derailed/k9s/internal/ui" + "github.com/derailed/tview" + "github.com/gdamore/tcell" +) + +// Grapheable represents a graphic component. +type Grapheable interface { + tview.Primitive + + // ID returns the graph id. + ID() string + + // Add adds a metric + Add(tchart.Metric) + + // SetLegend sets the graph legend + SetLegend(string) + + // SetSeriesColors sets charts series colors. + SetSeriesColors(...tcell.Color) + + // GetSeriesColorNames returns the series color names. + GetSeriesColorNames() []string + + // SetBackgroundColor sets chart bg color. + SetBackgroundColor(tcell.Color) + + // IsDial returns true if chart is a dial + IsDial() bool +} + +const pulseTitle = "Pulses" + +var _ ResourceViewer = (*Pulse)(nil) + +// Pulse represents a command health view. +type Pulse struct { + *tview.Grid + + app *App + gvr client.GVR + model *model.Pulse + cancelFn context.CancelFunc + actions ui.KeyActions + charts []Grapheable +} + +// NewPulse returns a new alias view. +func NewPulse(gvr client.GVR) ResourceViewer { + return &Pulse{ + Grid: tview.NewGrid(), + model: model.NewPulse(gvr.String()), + actions: make(ui.KeyActions), + } +} + +// Init initializes the view. +func (p *Pulse) Init(ctx context.Context) error { + p.SetBorder(true) + p.SetTitle(fmt.Sprintf(" %s ", pulseTitle)) + p.SetGap(1, 1) + p.SetBorderPadding(0, 0, 1, 1) + var err error + if p.app, err = extractApp(ctx); err != nil { + return err + } + + p.charts = []Grapheable{ + p.makeGA(image.Point{X: 0, Y: 0}, image.Point{X: 4, Y: 2}, "apps/v1/deployments"), + p.makeGA(image.Point{X: 0, Y: 2}, image.Point{X: 4, Y: 2}, "apps/v1/replicasets"), + p.makeGA(image.Point{X: 0, Y: 4}, image.Point{X: 4, Y: 2}, "apps/v1/statefulsets"), + p.makeGA(image.Point{X: 0, Y: 6}, image.Point{X: 4, Y: 2}, "apps/v1/daemonsets"), + p.makeSP(image.Point{X: 4, Y: 0}, image.Point{X: 3, Y: 4}, "v1/pods"), + p.makeSP(image.Point{X: 4, Y: 4}, image.Point{X: 3, Y: 4}, "v1/events"), + p.makeSP(image.Point{X: 7, Y: 0}, image.Point{X: 3, Y: 4}, "batch/v1/jobs"), + p.makeSP(image.Point{X: 7, Y: 4}, image.Point{X: 3, Y: 4}, "v1/persistentvolumes"), + } + if p.app.Conn().HasMetrics() { + p.charts = append(p.charts, + p.makeSP(image.Point{X: 10, Y: 0}, image.Point{X: 2, Y: 4}, "cpu"), + p.makeSP(image.Point{X: 10, Y: 4}, image.Point{X: 2, Y: 4}, "mem"), + ) + } + p.bindKeys() + p.model.AddListener(p) + p.app.SetFocus(p.charts[0]) + p.app.Styles.AddListener(p) + + return nil +} + +// StylesChanged notifies the skin changed. +func (p *Pulse) StylesChanged(s *config.Styles) { + p.SetBackgroundColor(s.Charts().BgColor.Color()) + for _, c := range p.charts { + if c.IsDial() { + c.SetBackgroundColor(s.Charts().DialBgColor.Color()) + c.SetSeriesColors(s.Charts().DefaultDialColors.Colors()...) + } else { + c.SetBackgroundColor(s.Charts().ChartBgColor.Color()) + c.SetSeriesColors(s.Charts().DefaultChartColors.Colors()...) + } + if ss, ok := s.Charts().ResourceColors[c.ID()]; ok { + c.SetSeriesColors(ss.Colors()...) + } + } + p.app.Draw() +} + +// PulseChanged notifies the model data changed. +func (p *Pulse) PulseChanged(c *health.Check) { + index, ok := findIndexGVR(p.charts, c.GVR) + if !ok { + return + } + + v, ok := p.GetItem(index).Item.(Grapheable) + if !ok { + return + } + gvr := client.NewGVR(c.GVR) + switch c.GVR { + case "cpu": + v.SetLegend(fmt.Sprintf(" %s - %dm", strings.Title(gvr.R()), c.Tally(health.OK))) + case "mem": + v.SetLegend(fmt.Sprintf(" %s - %dMi", strings.Title(gvr.R()), c.Tally(health.OK))) + default: + nn := v.GetSeriesColorNames() + if c.Tally(health.OK) == 0 { + nn[0] = "gray" + } + if c.Tally(health.Toast) == 0 { + nn[1] = "gray" + } + v.SetLegend(fmt.Sprintf(" %s - [%s::]%d/[%s::b]%d[-::]", + strings.Title(gvr.R()), + nn[0], + c.Tally(health.OK), + nn[1], + c.Tally(health.Toast), + )) + } + v.Add(tchart.Metric{OK: c.Tally(health.OK), Fault: c.Tally(health.Toast)}) +} + +// PulseFailed notifies the load failed. +func (p *Pulse) PulseFailed(err error) { + p.app.Flash().Err(err) +} + +func (p *Pulse) bindKeys() { + p.actions.Add(ui.KeyActions{ + tcell.KeyEnter: ui.NewKeyAction("Goto", p.enterCmd, true), + tcell.KeyTab: ui.NewKeyAction("Next", p.nextFocusCmd(1), true), + tcell.KeyBacktab: ui.NewKeyAction("Prev", p.nextFocusCmd(-1), true), + }) + + for i, v := range p.charts { + t := strings.Title(client.NewGVR(v.(Grapheable).ID()).R()) + p.actions[tcell.Key(ui.NumKeys[i])] = ui.NewKeyAction(t, p.sparkFocusCmd(i), true) + } +} + +func (p *Pulse) keyboard(evt *tcell.EventKey) *tcell.EventKey { + key := evt.Key() + if key == tcell.KeyRune { + key = tcell.Key(evt.Rune()) + } + if a, ok := p.actions[key]; ok { + return a.Action(evt) + } + + return evt +} + +func (p *Pulse) defaultContext() context.Context { + return context.WithValue(context.Background(), internal.KeyFactory, p.app.factory) +} + +// Start initializes resource watch loop. +func (p *Pulse) Start() { + p.Stop() + + ctx := p.defaultContext() + ctx, p.cancelFn = context.WithCancel(ctx) + p.model.Watch(ctx) +} + +// Stop terminates watch loop. +func (p *Pulse) Stop() { + if p.cancelFn == nil { + return + } + p.cancelFn() + p.cancelFn = nil +} + +// Refresh updates the view +func (p *Pulse) Refresh() { + // p.update(p.model.Peek()) +} + +// GVR returns a resource descriptor. +func (p *Pulse) GVR() string { + return p.gvr.String() +} + +// Name returns the component name. +func (p *Pulse) Name() string { + return pulseTitle +} + +// App returns the current app handle. +func (p *Pulse) App() *App { + return p.app +} + +// SetInstance sets specific resource instance. +func (p *Pulse) SetInstance(string) {} + +// SetEnvFn sets the custom environment function. +func (p *Pulse) SetEnvFn(EnvFunc) {} + +// SetBindKeysFn sets up extra key bindings. +func (p *Pulse) SetBindKeysFn(BindKeysFunc) {} + +// SetContextFn sets custom context. +func (p *Pulse) SetContextFn(ContextFunc) {} + +// GetTable return the view table if any. +func (p *Pulse) GetTable() *Table { + return nil +} + +// Actions returns active menu bindings. +func (p *Pulse) Actions() ui.KeyActions { + return p.actions +} + +// Hints returns the view hints. +func (p *Pulse) Hints() model.MenuHints { + return p.actions.Hints() +} + +// ExtraHints returns additional hints. +func (p *Pulse) ExtraHints() map[string]string { + return nil +} + +func (p *Pulse) sparkFocusCmd(i int) func(evt *tcell.EventKey) *tcell.EventKey { + return func(evt *tcell.EventKey) *tcell.EventKey { + p.app.SetFocus(p.charts[i]) + return nil + } +} + +func (p *Pulse) enterCmd(evt *tcell.EventKey) *tcell.EventKey { + v := p.App().GetFocus() + s, ok := v.(Grapheable) + if !ok { + return nil + } + gvr := client.NewGVR(s.ID()) + if err := p.App().gotoResource(gvr.R()+" all", "", false); err != nil { + p.App().Flash().Err(err) + } + + return nil +} + +func (p *Pulse) nextFocusCmd(direction int) func(evt *tcell.EventKey) *tcell.EventKey { + return func(evt *tcell.EventKey) *tcell.EventKey { + v := p.app.GetFocus() + index := findIndex(p.charts, v) + p.GetItem(index).Focus = false + p.GetItem(index).Item.Blur() + i, v := nextFocus(p.charts, index+direction) + p.GetItem(i).Focus = true + p.app.SetFocus(v) + + return nil + } +} + +func (p *Pulse) makeSP(loc image.Point, span image.Point, gvr string) *tchart.SparkLine { + s := tchart.NewSparkLine(gvr) + s.SetBackgroundColor(p.app.Styles.Charts().BgColor.Color()) + s.SetBorderPadding(0, 1, 0, 1) + if cc, ok := p.app.Styles.Charts().ResourceColors[gvr]; ok { + s.SetSeriesColors(cc.Colors()...) + } else { + s.SetSeriesColors(p.app.Styles.Charts().DefaultChartColors.Colors()...) + } + s.SetLegend(fmt.Sprintf(" %s ", strings.Title(client.NewGVR(gvr).R()))) + s.SetInputCapture(p.keyboard) + p.AddItem(s, loc.X, loc.Y, span.X, span.Y, 0, 0, true) + + return s +} + +func (p *Pulse) makeGA(loc image.Point, span image.Point, gvr string) *tchart.Gauge { + g := tchart.NewGauge(gvr) + g.SetBackgroundColor(p.app.Styles.Charts().BgColor.Color()) + g.SetBorderPadding(0, 1, 0, 1) + if cc, ok := p.app.Styles.Charts().ResourceColors[gvr]; ok { + g.SetSeriesColors(cc.Colors()...) + } else { + g.SetSeriesColors(p.app.Styles.Charts().DefaultDialColors.Colors()...) + } + g.SetLegend(fmt.Sprintf(" %s ", strings.Title(client.NewGVR(gvr).R()))) + g.SetInputCapture(p.keyboard) + p.AddItem(g, loc.X, loc.Y, span.X, span.Y, 0, 0, true) + + return g +} + +// ---------------------------------------------------------------------------- +// Helpers + +func nextFocus(pp []Grapheable, index int) (int, tview.Primitive) { + if index >= len(pp) { + return 0, pp[0] + } + + if index < 0 { + return len(pp) - 1, pp[len(pp)-1] + } + + return index, pp[index] +} + +func findIndex(pp []Grapheable, p tview.Primitive) int { + for i, v := range pp { + if v == p { + return i + } + } + return 0 +} + +func findIndexGVR(pp []Grapheable, gvr string) (int, bool) { + for i, v := range pp { + if v.(Grapheable).ID() == gvr { + return i, true + } + } + return 0, false +} diff --git a/internal/view/rbac_test.go b/internal/view/rbac_test.go index 63cfad8b..2c0972c8 100644 --- a/internal/view/rbac_test.go +++ b/internal/view/rbac_test.go @@ -13,5 +13,5 @@ func TestRbacNew(t *testing.T) { assert.Nil(t, v.Init(makeCtx())) assert.Equal(t, "Rbac", v.Name()) - assert.Equal(t, 3, len(v.Hints())) + assert.Equal(t, 5, len(v.Hints())) } diff --git a/internal/view/registrar.go b/internal/view/registrar.go index 40d228a9..9adf1eb4 100644 --- a/internal/view/registrar.go +++ b/internal/view/registrar.go @@ -51,6 +51,9 @@ func miscViewers(vv MetaViewers) { vv[client.NewGVR("contexts")] = MetaViewer{ viewerFn: NewContext, } + vv[client.NewGVR("openfaas")] = MetaViewer{ + viewerFn: NewOpenFaas, + } vv[client.NewGVR("containers")] = MetaViewer{ viewerFn: NewContainer, } @@ -66,6 +69,9 @@ func miscViewers(vv MetaViewers) { vv[client.NewGVR("aliases")] = MetaViewer{ viewerFn: NewAlias, } + vv[client.NewGVR("pulses")] = MetaViewer{ + viewerFn: NewPulse, + } } func appsViewers(vv MetaViewers) { @@ -131,7 +137,7 @@ func extViewers(vv MetaViewers) { func showCRD(app *App, _ ui.Tabular, _, path string) { _, crdGVR := client.Namespaced(path) tokens := strings.Split(crdGVR, ".") - if err := app.gotoResource(tokens[0], false); err != nil { + if err := app.gotoResource(tokens[0], "", false); err != nil { app.Flash().Err(err) } } diff --git a/internal/view/screen_dump.go b/internal/view/screen_dump.go index 41b82088..41799d65 100644 --- a/internal/view/screen_dump.go +++ b/internal/view/screen_dump.go @@ -45,7 +45,7 @@ func (s *ScreenDump) edit(app *App, model ui.Tabular, gvr, path string) { s.Stop() defer s.Start() - if !edit(true, app, path) { + if !edit(app, shellOpts{clear: true, args: []string{path}}) { app.Flash().Err(errors.New("Failed to launch editor")) } } diff --git a/internal/view/screen_dump_test.go b/internal/view/screen_dump_test.go index 59b1756f..0e191f1b 100644 --- a/internal/view/screen_dump_test.go +++ b/internal/view/screen_dump_test.go @@ -13,5 +13,5 @@ func TestScreenDumpNew(t *testing.T) { assert.Nil(t, po.Init(makeCtx())) assert.Equal(t, "ScreenDumps", po.Name()) - assert.Equal(t, 3, len(po.Hints())) + assert.Equal(t, 5, len(po.Hints())) } diff --git a/internal/view/secret.go b/internal/view/secret.go index a8ea94f0..82eca65c 100644 --- a/internal/view/secret.go +++ b/internal/view/secret.go @@ -62,7 +62,7 @@ func (s *Secret) decodeCmd(evt *tcell.EventKey) *tcell.EventKey { return nil } - details := NewDetails(s.App(), "Secret Decoder", path).Update(string(raw)) + details := NewDetails(s.App(), "Secret Decoder", path, false).Update(string(raw)) if err := s.App().inject(details); err != nil { s.App().Flash().Err(err) } diff --git a/internal/view/secret_test.go b/internal/view/secret_test.go index 8823ab3c..d75cd10f 100644 --- a/internal/view/secret_test.go +++ b/internal/view/secret_test.go @@ -13,5 +13,5 @@ func TestSecretNew(t *testing.T) { assert.Nil(t, s.Init(makeCtx())) assert.Equal(t, "Secrets", s.Name()) - assert.Equal(t, 4, len(s.Hints())) + assert.Equal(t, 6, len(s.Hints())) } diff --git a/internal/view/sts.go b/internal/view/sts.go index 01930512..23cf7d97 100644 --- a/internal/view/sts.go +++ b/internal/view/sts.go @@ -18,9 +18,11 @@ type StatefulSet struct { // NewStatefulSet returns a new viewer. func NewStatefulSet(gvr client.GVR) ResourceViewer { s := StatefulSet{ - ResourceViewer: NewRestartExtender( - NewScaleExtender( - NewLogsExtender(NewBrowser(gvr), nil), + ResourceViewer: NewPortForwardExtender( + NewRestartExtender( + NewScaleExtender( + NewLogsExtender(NewBrowser(gvr), nil), + ), ), ), } diff --git a/internal/view/sts_test.go b/internal/view/sts_test.go index bf32d2d7..30a5de22 100644 --- a/internal/view/sts_test.go +++ b/internal/view/sts_test.go @@ -13,5 +13,5 @@ func TestStatefulSetNew(t *testing.T) { assert.Nil(t, s.Init(makeCtx())) assert.Equal(t, "StatefulSets", s.Name()) - assert.Equal(t, 8, len(s.Hints())) + assert.Equal(t, 11, len(s.Hints())) } diff --git a/internal/view/svc.go b/internal/view/svc.go index 5cd7257b..30f95a90 100644 --- a/internal/view/svc.go +++ b/internal/view/svc.go @@ -8,14 +8,12 @@ import ( "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/config" + "github.com/derailed/k9s/internal/dao" + "github.com/derailed/k9s/internal/model" "github.com/derailed/k9s/internal/perf" "github.com/derailed/k9s/internal/ui" "github.com/gdamore/tcell" "github.com/rs/zerolog/log" - v1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/apimachinery/pkg/labels" - "k8s.io/apimachinery/pkg/runtime" ) // Service represents a service viewer. @@ -28,7 +26,9 @@ type Service struct { // NewService returns a new viewer. func NewService(gvr client.GVR) ResourceViewer { s := Service{ - ResourceViewer: NewLogsExtender(NewBrowser(gvr), nil), + ResourceViewer: NewPortForwardExtender( + NewLogsExtender(NewBrowser(gvr), nil), + ), } s.SetBindKeysFn(s.bindKeys) s.GetTable().SetEnterFn(s.showPods) @@ -45,20 +45,17 @@ func (s *Service) bindKeys(aa ui.KeyActions) { }) } -func (s *Service) showPods(app *App, _ ui.Tabular, gvr, path string) { - o, err := app.factory.Get(gvr, path, true, labels.Everything()) +func (s *Service) showPods(a *App, _ ui.Tabular, gvr, path string) { + var res dao.Service + res.Init(a.factory, client.NewGVR(s.GVR())) + + svc, err := res.GetInstance(path) if err != nil { - app.Flash().Err(err) - return - } - var svc v1.Service - err = runtime.DefaultUnstructuredConverter.FromUnstructured(o.(*unstructured.Unstructured).Object, &svc) - if err != nil { - app.Flash().Err(err) + a.Flash().Err(err) return } - showPodsWithLabels(app, path, svc.Spec.Selector) + showPodsWithLabels(a, path, svc.Spec.Selector) } func (s *Service) checkSvc(row int) error { @@ -86,15 +83,10 @@ func (s *Service) getExternalPort(row int) (string, error) { return tokens[1], nil } -func (s *Service) reloadBenchCfg() error { - path := ui.BenchConfig(s.App().Config.K9s.CurrentCluster) - return s.App().Bench.Reload(path) -} - func (s *Service) toggleBenchCmd(evt *tcell.EventKey) *tcell.EventKey { if s.bench != nil { log.Debug().Msg(">>> Benchmark canceled!!") - s.App().Status(ui.FlashErr, "Benchmark Canceled!") + s.App().Status(model.FlashErr, "Benchmark Canceled!") s.bench.Cancel() s.App().ClearStatus(true) return nil @@ -105,12 +97,12 @@ func (s *Service) toggleBenchCmd(evt *tcell.EventKey) *tcell.EventKey { return evt } - if err := s.reloadBenchCfg(); err != nil { - s.App().Flash().Err(err) - return nil + cust, err := config.NewBench(s.App().BenchFile) + if err != nil { + log.Debug().Msgf("No custom benchmark config file found") } - cfg, ok := s.App().Bench.Benchmarks.Services[sel] + cfg, ok := cust.Benchmarks.Services[sel] if !ok { s.App().Flash().Errf("No bench config found for service %s", sel) return nil @@ -119,8 +111,8 @@ func (s *Service) toggleBenchCmd(evt *tcell.EventKey) *tcell.EventKey { log.Debug().Msgf("Benchmark config %#v", cfg) row := s.GetTable().GetSelectedRowIndex() - if err := s.checkSvc(row); err != nil { - s.App().Flash().Err(err) + if e := s.checkSvc(row); e != nil { + s.App().Flash().Err(e) return nil } port, err := s.getExternalPort(row) @@ -149,7 +141,7 @@ func (s *Service) runBenchmark(port string, cfg config.BenchConfig) error { return err } - s.App().Status(ui.FlashWarn, "Benchmark in progress...") + s.App().Status(model.FlashWarn, "Benchmark in progress...") log.Debug().Msg("Bench starting...") go s.bench.Run(s.App().Config.K9s.CurrentCluster, s.benchDone) @@ -160,9 +152,9 @@ func (s *Service) benchDone() { log.Debug().Msg("Bench Completed!") s.App().QueueUpdate(func() { if s.bench.Canceled() { - s.App().Status(ui.FlashInfo, "Benchmark canceled") + s.App().Status(model.FlashInfo, "Benchmark canceled") } else { - s.App().Status(ui.FlashInfo, "Benchmark Completed!") + s.App().Status(model.FlashInfo, "Benchmark Completed!") s.bench.Cancel() } s.bench = nil @@ -170,6 +162,9 @@ func (s *Service) benchDone() { }) } +// ---------------------------------------------------------------------------- +// Helpers... + func benchTimedOut(app *App) { <-time.After(2 * time.Second) app.QueueUpdate(func() { diff --git a/internal/view/svc_test.go b/internal/view/svc_test.go index b09e3dfc..9bf3756d 100644 --- a/internal/view/svc_test.go +++ b/internal/view/svc_test.go @@ -132,5 +132,5 @@ func TestServiceNew(t *testing.T) { assert.Nil(t, s.Init(makeCtx())) assert.Equal(t, "Services", s.Name()) - assert.Equal(t, 7, len(s.Hints())) + assert.Equal(t, 10, len(s.Hints())) } diff --git a/internal/view/table.go b/internal/view/table.go index faa62541..56641086 100644 --- a/internal/view/table.go +++ b/internal/view/table.go @@ -132,10 +132,24 @@ func (t *Table) bindKeys() { tcell.KeyBackspace: ui.NewSharedKeyAction("Erase", t.eraseCmd, false), tcell.KeyDelete: ui.NewSharedKeyAction("Erase", t.eraseCmd, false), ui.KeyShiftN: ui.NewKeyAction("Sort Name", t.SortColCmd(0, true), false), + tcell.KeyCtrlZ: ui.NewKeyAction("Toggle Faults", t.toggleFaultCmd, false), ui.KeyShiftA: ui.NewKeyAction("Sort Age", t.SortColCmd(-1, true), false), + tcell.KeyCtrlW: ui.NewKeyAction("Show Wide", t.toggleWideCmd, false), }) } +func (t *Table) toggleFaultCmd(evt *tcell.EventKey) *tcell.EventKey { + t.ToggleToast() + + return nil +} + +func (t *Table) toggleWideCmd(evt *tcell.EventKey) *tcell.EventKey { + t.ToggleWide() + + return nil +} + func (t *Table) cpCmd(evt *tcell.EventKey) *tcell.EventKey { path := t.GetSelectedItem() if path == "" { diff --git a/internal/view/table_int_test.go b/internal/view/table_int_test.go index f98700c4..336acadc 100644 --- a/internal/view/table_int_test.go +++ b/internal/view/table_int_test.go @@ -97,6 +97,7 @@ func (t *testTableModel) Peek() render.TableData { return makeTableData func (t *testTableModel) ClusterWide() bool { return false } func (t *testTableModel) GetNamespace() string { return "blee" } func (t *testTableModel) SetNamespace(string) {} +func (t *testTableModel) ToggleToast() {} func (t *testTableModel) AddListener(model.TableListener) {} func (t *testTableModel) Watch(context.Context) {} func (t *testTableModel) Get(context.Context, string) (runtime.Object, error) { diff --git a/internal/view/types.go b/internal/view/types.go index e55f9490..8b8020c1 100644 --- a/internal/view/types.go +++ b/internal/view/types.go @@ -77,6 +77,8 @@ type ResourceViewer interface { // SetBindKeys provision additional key bindings. SetBindKeysFn(BindKeysFunc) + + // SetInstance sets a parent FQN SetInstance(string) } diff --git a/internal/view/xray.go b/internal/view/xray.go index d16ce4bc..38a3c86d 100644 --- a/internal/view/xray.go +++ b/internal/view/xray.go @@ -25,6 +25,8 @@ import ( const xrayTitle = "Xray" +var _ ResourceViewer = (*Xray)(nil) + // Xray represents an xray tree view. type Xray struct { *ui.Tree @@ -37,8 +39,6 @@ type Xray struct { envFn EnvFunc } -var _ ResourceViewer = (*Xray)(nil) - // NewXray returns a new view. func NewXray(gvr client.GVR) ResourceViewer { return &Xray{ @@ -50,6 +50,8 @@ func NewXray(gvr client.GVR) ResourceViewer { // Init initializes the view func (x *Xray) Init(ctx context.Context) error { + x.envFn = x.k9sEnv + if err := x.Tree.Init(ctx); err != nil { return err } @@ -66,10 +68,10 @@ func (x *Xray) Init(ctx context.Context) error { } x.bindKeys() - x.SetBackgroundColor(config.AsColor(x.app.Styles.Xray().BgColor)) - x.SetBorderColor(config.AsColor(x.app.Styles.Xray().FgColor)) - x.SetBorderFocusColor(config.AsColor(x.app.Styles.Frame().Border.FocusColor)) - x.SetGraphicsColor(config.AsColor(x.app.Styles.Xray().GraphicColor)) + x.SetBackgroundColor(x.app.Styles.Xray().BgColor.Color()) + x.SetBorderColor(x.app.Styles.Xray().FgColor.Color()) + x.SetBorderFocusColor(x.app.Styles.Frame().Border.FocusColor.Color()) + x.SetGraphicsColor(x.app.Styles.Xray().GraphicColor.Color()) x.SetTitle(fmt.Sprintf(" %s-%s ", xrayTitle, strings.Title(x.gvr.R()))) x.model.SetRefreshRate(time.Duration(x.app.Config.K9s.GetRefreshRate()) * time.Second) @@ -77,12 +79,12 @@ func (x *Xray) Init(ctx context.Context) error { x.model.AddListener(x) x.SetChangedFunc(func(n *tview.TreeNode) { - ref, ok := n.GetReference().(xray.NodeSpec) + spec, ok := n.GetReference().(xray.NodeSpec) if !ok { log.Error().Msgf("No ref found on node %s", n.GetText()) return } - x.SetSelectedItem(ref.Path) + x.SetSelectedItem(spec.AsPath()) x.refreshActions() }) x.refreshActions() @@ -131,16 +133,18 @@ func (x *Xray) refreshActions() { x.Actions().Clear() x.bindKeys() + x.Tree.BindKeys() - ref := x.selectedSpec() - if ref == nil { + spec := x.selectedSpec() + if spec == nil { return } + gvr := spec.GVR() var err error - x.meta, err = dao.MetaAccess.MetaFor(client.NewGVR(ref.GVR)) + x.meta, err = dao.MetaAccess.MetaFor(client.NewGVR(gvr)) if err != nil { - log.Warn().Msgf("NO meta for %q -- %s", ref.GVR, err) + log.Warn().Msgf("NO meta for %q -- %s", gvr, err) return } @@ -155,22 +159,29 @@ func (x *Xray) refreshActions() { aa[ui.KeyD] = ui.NewKeyAction("Describe", x.describeCmd, true) } - if ref.GVR == "containers" { + switch gvr { + case "v1/namespaces": + x.Actions().Delete(tcell.KeyEnter) + case "containers": + x.Actions().Delete(tcell.KeyEnter) + aa[ui.KeyS] = ui.NewKeyAction("Shell", x.shellCmd, true) + aa[ui.KeyL] = ui.NewKeyAction("Logs", x.logsCmd(false), true) + aa[ui.KeyShiftL] = ui.NewKeyAction("Logs Previous", x.logsCmd(true), true) + case "v1/pods": aa[ui.KeyS] = ui.NewKeyAction("Shell", x.shellCmd, true) aa[ui.KeyL] = ui.NewKeyAction("Logs", x.logsCmd(false), true) aa[ui.KeyShiftL] = ui.NewKeyAction("Logs Previous", x.logsCmd(true), true) } - x.Actions().Add(aa) } // GetSelectedPath returns the current selection as string. func (x *Xray) GetSelectedPath() string { - ref := x.selectedSpec() - if ref == nil { + spec := x.selectedSpec() + if spec == nil { return "" } - return ref.Path + return spec.Path() } func (x *Xray) selectedSpec() *xray.NodeSpec { @@ -193,6 +204,34 @@ func (x *Xray) EnvFn() EnvFunc { return x.envFn } +func (x *Xray) k9sEnv() K9sEnv { + env := generalEnv(x.app) + + spec := x.selectedSpec() + if spec == nil { + return env + } + + env["FILTER"] = x.CmdBuff().String() + if env["FILTER"] == "" { + ns, n := client.Namespaced(spec.Path()) + env["NAMESPACE"], env["FILTER"] = ns, n + } + + switch spec.GVR() { + case "containers": + _, co := client.Namespaced(spec.Path()) + env["CONTAINER"] = co + ns, n := client.Namespaced(*spec.ParentPath()) + env["NAMESPACE"], env["POD"], env["NAME"] = ns, n, co + default: + ns, n := client.Namespaced(spec.Path()) + env["NAMESPACE"], env["NAME"] = ns, n + } + + return env +} + // Aliases returns all available aliases. func (x *Xray) Aliases() []string { return append(x.meta.ShortNames, x.meta.SingularName, x.meta.Name) @@ -200,100 +239,98 @@ func (x *Xray) Aliases() []string { func (x *Xray) logsCmd(prev bool) func(evt *tcell.EventKey) *tcell.EventKey { return func(evt *tcell.EventKey) *tcell.EventKey { - ref := x.selectedSpec() - if ref == nil { + spec := x.selectedSpec() + if spec == nil { return nil } - if ref.Parent != nil { - x.showLogs(ref.Parent, ref, prev) - } else { - log.Error().Msgf("No parent found for container %q", ref.Path) - } + x.showLogs(spec, prev) return nil } } -func (x *Xray) showLogs(pod, co *xray.NodeSpec, prev bool) { +func (x *Xray) showLogs(spec *xray.NodeSpec, prev bool) { // Need to load and wait for pods - ns, _ := client.Namespaced(pod.Path) + path, co := spec.Path(), "" + if spec.GVR() == "containers" { + _, coName := client.Namespaced(spec.Path()) + path, co = *spec.ParentPath(), coName + } + + ns, _ := client.Namespaced(path) _, err := x.app.factory.CanForResource(ns, "v1/pods", client.MonitorAccess) if err != nil { x.app.Flash().Err(err) return } - if err := x.app.inject(NewLog(client.NewGVR(co.GVR), pod.Path, co.Path, prev)); err != nil { + if err := x.app.inject(NewLog(client.NewGVR("v1/pods"), path, co, prev)); err != nil { x.app.Flash().Err(err) } } func (x *Xray) shellCmd(evt *tcell.EventKey) *tcell.EventKey { - ref := x.selectedSpec() - if ref == nil { + spec := x.selectedSpec() + if spec == nil { return nil } - if ref.Status != "" { - x.app.Flash().Errf("%s is not in a running state", ref.Path) + if spec.Status() != "ok" { + x.app.Flash().Errf("%s is not in a running state", spec.Path()) return nil } - if ref.Parent != nil { - _, co := client.Namespaced(ref.Path) - x.shellIn(ref.Parent.Path, co) - } else { - log.Error().Msgf("No parent found on container node %q", ref.Path) + path, co := spec.Path(), "" + if spec.GVR() == "containers" { + _, co = client.Namespaced(spec.Path()) + path = *spec.ParentPath() + } + + if err := containerShellin(x.app, x, path, co); err != nil { + x.app.Flash().Err(err) } return nil } -func (x *Xray) shellIn(path, co string) { - x.Stop() - shellIn(x.app, path, co) - x.Start() -} - func (x *Xray) viewCmd(evt *tcell.EventKey) *tcell.EventKey { - ref := x.selectedSpec() - if ref == nil { + spec := x.selectedSpec() + if spec == nil { return evt } ctx := x.defaultContext() - raw, err := x.model.ToYAML(ctx, ref.GVR, ref.Path) + raw, err := x.model.ToYAML(ctx, spec.GVR(), spec.Path()) if err != nil { - x.App().Flash().Errf("unable to get resource %q -- %s", ref.GVR, err) + x.App().Flash().Errf("unable to get resource %q -- %s", spec.GVR(), err) return nil } - details := NewDetails(x.app, "YAML", ref.Path).Update(raw) + details := NewDetails(x.app, "YAML", spec.Path(), true).Update(raw) if err := x.app.inject(details); err != nil { x.app.Flash().Err(err) } return nil - } func (x *Xray) deleteCmd(evt *tcell.EventKey) *tcell.EventKey { - ref := x.selectedSpec() - if ref == nil { + spec := x.selectedSpec() + if spec == nil { return evt } x.Stop() defer x.Start() { - gvr := client.NewGVR(ref.GVR) + gvr := client.NewGVR(spec.GVR()) meta, err := dao.MetaAccess.MetaFor(gvr) if err != nil { - log.Warn().Msgf("NO meta for %q -- %s", ref.GVR, err) + log.Warn().Msgf("NO meta for %q -- %s", spec.GVR(), err) return nil } - x.resourceDelete(gvr, ref, fmt.Sprintf("Delete %s %s?", meta.SingularName, ref.Path)) + x.resourceDelete(gvr, spec, fmt.Sprintf("Delete %s %s?", meta.SingularName, spec.Path())) } return nil @@ -301,12 +338,12 @@ func (x *Xray) deleteCmd(evt *tcell.EventKey) *tcell.EventKey { } func (x *Xray) describeCmd(evt *tcell.EventKey) *tcell.EventKey { - ref := x.selectedSpec() - if ref == nil { + spec := x.selectedSpec() + if spec == nil { return evt } - x.describe(ref.GVR, ref.Path) + x.describe(spec.GVR(), spec.Path()) return nil } @@ -321,31 +358,31 @@ func (x *Xray) describe(gvr, path string) { return } - details := NewDetails(x.app, "Describe", path).Update(yaml) + details := NewDetails(x.app, "Describe", path, true).Update(yaml) if err := x.app.inject(details); err != nil { x.app.Flash().Err(err) } } func (x *Xray) editCmd(evt *tcell.EventKey) *tcell.EventKey { - ref := x.selectedSpec() - if ref == nil { + spec := x.selectedSpec() + if spec == nil { return evt } x.Stop() defer x.Start() { - ns, n := client.Namespaced(ref.Path) + ns, n := client.Namespaced(spec.Path()) args := make([]string, 0, 10) args = append(args, "edit") - args = append(args, client.NewGVR(ref.GVR).R()) + args = append(args, client.NewGVR(spec.GVR()).R()) args = append(args, "-n", ns) args = append(args, "--context", x.app.Config.K9s.CurrentContext) if cfg := x.app.Conn().Config().Flags().KubeConfig; cfg != nil && *cfg != "" { args = append(args, "--kubeconfig", *cfg) } - if !runK(true, x.app, append(args, n)...) { + if !runK(x.app, shellOpts{args: append(args, n)}) { x.app.Flash().Err(errors.New("Edit exec failed")) } } @@ -408,14 +445,15 @@ func (x *Xray) gotoCmd(evt *tcell.EventKey) *tcell.EventKey { return nil } - ref := x.selectedSpec() - if ref == nil { + spec := x.selectedSpec() + if spec == nil { return nil } - if len(strings.Split(ref.Path, "/")) == 1 { + log.Debug().Msgf("SELECTED REF %#v", spec) + if len(strings.Split(spec.Path(), "/")) == 1 { return nil } - if err := x.app.viewResource(client.NewGVR(ref.GVR).R(), ref.Path, false); err != nil { + if err := x.app.viewResource(client.NewGVR(spec.GVR()).R(), spec.Path(), false); err != nil { x.app.Flash().Err(err) } @@ -441,7 +479,7 @@ func (x *Xray) TreeNodeSelected() { x.app.QueueUpdateDraw(func() { n := x.GetCurrentNode() if n != nil { - n.SetColor(config.AsColor(x.app.Styles.Xray().CursorColor)) + n.SetColor(x.app.Styles.Xray().CursorColor.Color()) } }) } @@ -464,13 +502,13 @@ func (x *Xray) update(node *xray.TreeNode) { x.hydrate(root, c) } if x.GetSelectedItem() == "" { - x.SetSelectedItem(node.ID) + x.SetSelectedItem(node.Spec().Path()) } x.app.QueueUpdateDraw(func() { x.SetRoot(root) root.Walk(func(node, parent *tview.TreeNode) bool { - ref, ok := node.GetReference().(xray.NodeSpec) + spec, ok := node.GetReference().(xray.NodeSpec) if !ok { log.Error().Msgf("Expecting a NodeSpec but got %T", node.GetReference()) return false @@ -482,7 +520,8 @@ func (x *Xray) update(node *xray.TreeNode) { node.SetExpanded(true) } - if ref.Path == x.GetSelectedItem() { + if spec.AsPath() == x.GetSelectedItem() { + log.Debug().Msgf("SEL %q--%q", spec.Path(), x.GetSelectedItem()) node.SetExpanded(true).SetSelectable(true) x.SetCurrentNode(node) } @@ -509,7 +548,7 @@ func (x *Xray) hydrate(parent *tview.TreeNode, n *xray.TreeNode) { // SetEnvFn sets the custom environment function. func (x *Xray) SetEnvFn(EnvFunc) {} -// Refresh refresh the view +// Refresh updates the view func (x *Xray) Refresh() { } @@ -611,9 +650,9 @@ func (x *Xray) styleTitle() string { return title + ui.SkinTitle(fmt.Sprintf(ui.SearchFmt, buff), x.app.Styles.Frame()) } -func (x *Xray) resourceDelete(gvr client.GVR, ref *xray.NodeSpec, msg string) { +func (x *Xray) resourceDelete(gvr client.GVR, spec *xray.NodeSpec, msg string) { dialog.ShowDelete(x.app.Content.Pages, msg, func(cascade, force bool) { - x.app.Flash().Infof("Delete resource %s %s", ref.GVR, ref.Path) + x.app.Flash().Infof("Delete resource %s %s", spec.GVR(), spec.Path()) accessor, err := dao.AccessorFor(x.app.factory, gvr) if err != nil { log.Error().Err(err).Msgf("No accessor") @@ -625,11 +664,11 @@ func (x *Xray) resourceDelete(gvr client.GVR, ref *xray.NodeSpec, msg string) { x.app.Flash().Errf("Invalid nuker %T", accessor) return } - if err := nuker.Delete(ref.Path, true, true); err != nil { + if err := nuker.Delete(spec.Path(), true, true); err != nil { x.app.Flash().Errf("Delete failed with `%s", err) } else { - x.app.Flash().Infof("%s `%s deleted successfully", x.GVR(), ref.Path) - x.app.factory.DeleteForwarder(ref.Path) + x.app.Flash().Infof("%s `%s deleted successfully", x.GVR(), spec.Path()) + x.app.factory.DeleteForwarder(spec.Path()) } x.Refresh() }, func() {}) @@ -661,19 +700,11 @@ func makeTreeNode(node *xray.TreeNode, expanded bool, styles *config.Styles) *tv n := tview.NewTreeNode("No data...") if node != nil { n.SetText(node.Title(styles.Xray())) - spec := xray.NodeSpec{} - if p := node.Parent; p != nil { - spec.GVR, spec.Path = p.GVR, p.ID - } - n.SetReference(xray.NodeSpec{ - GVR: node.GVR, - Path: node.ID, - Parent: &spec, - }) + n.SetReference(node.Spec()) } n.SetSelectable(true) n.SetExpanded(expanded) - n.SetColor(config.AsColor(styles.Xray().CursorColor)) + n.SetColor(styles.Xray().CursorColor.Color()) n.SetSelectedFunc(func() { n.SetExpanded(!n.IsExpanded()) }) diff --git a/internal/view/yaml.go b/internal/view/yaml.go index a25580b9..356568dc 100644 --- a/internal/view/yaml.go +++ b/internal/view/yaml.go @@ -8,9 +8,8 @@ import ( "strings" "time" - "github.com/derailed/tview" - "github.com/derailed/k9s/internal/config" + "github.com/derailed/tview" "github.com/rs/zerolog/log" ) @@ -26,37 +25,42 @@ const ( ) func colorizeYAML(style config.Yaml, raw string) string { + // lines := strings.Split(raw, "\n") lines := strings.Split(tview.Escape(raw), "\n") - fullFmt := strings.Replace(yamlFullFmt, "[key", "["+style.KeyColor, 1) - fullFmt = strings.Replace(fullFmt, "[colon", "["+style.ColonColor, 1) - fullFmt = strings.Replace(fullFmt, "[val", "["+style.ValueColor, 1) + fullFmt := strings.Replace(yamlFullFmt, "[key", "["+style.KeyColor.String(), 1) + fullFmt = strings.Replace(fullFmt, "[colon", "["+style.ColonColor.String(), 1) + fullFmt = strings.Replace(fullFmt, "[val", "["+style.ValueColor.String(), 1) - keyFmt := strings.Replace(yamlKeyFmt, "[key", "["+style.KeyColor, 1) - keyFmt = strings.Replace(keyFmt, "[colon", "["+style.ColonColor, 1) + keyFmt := strings.Replace(yamlKeyFmt, "[key", "["+style.KeyColor.String(), 1) + keyFmt = strings.Replace(keyFmt, "[colon", "["+style.ColonColor.String(), 1) - valFmt := strings.Replace(yamlValueFmt, "[val", "["+style.ValueColor, 1) + valFmt := strings.Replace(yamlValueFmt, "[val", "["+style.ValueColor.String(), 1) buff := make([]string, 0, len(lines)) for _, l := range lines { res := keyValRX.FindStringSubmatch(l) if len(res) == 4 { - buff = append(buff, fmt.Sprintf(fullFmt, res[1], res[2], res[3])) + buff = append(buff, enableRegion(fmt.Sprintf(fullFmt, res[1], res[2], res[3]))) continue } res = keyRX.FindStringSubmatch(l) if len(res) == 3 { - buff = append(buff, fmt.Sprintf(keyFmt, res[1], res[2])) + buff = append(buff, enableRegion(fmt.Sprintf(keyFmt, res[1], res[2]))) continue } - buff = append(buff, fmt.Sprintf(valFmt, l)) + buff = append(buff, enableRegion(fmt.Sprintf(valFmt, l))) } return strings.Join(buff, "\n") } +func enableRegion(str string) string { + return strings.ReplaceAll(strings.ReplaceAll(str, "<<<", "["), ">>>", "]") +} + func saveYAML(cluster, name, data string) (string, error) { dir := filepath.Join(config.K9sDumpDir, cluster) if err := ensureDir(dir); err != nil { diff --git a/internal/view/yaml_test.go b/internal/view/yaml_test.go index 41451559..5e7ed5e9 100644 --- a/internal/view/yaml_test.go +++ b/internal/view/yaml_test.go @@ -13,15 +13,21 @@ func TestYaml(t *testing.T) { }{ { `api: fred - version: v1`, + version: v1`, `[steelblue::b]api[white::-]: [papayawhip::]fred - [steelblue::b]version[white::-]: [papayawhip::]v1`, + [steelblue::b]version[white::-]: [papayawhip::]v1`, + }, + { + `api: <<<"search_0">>>fred<<<"">>> + version: v1`, + `[steelblue::b]api[white::-]: [papayawhip::]["search_0"]fred[""] + [steelblue::b]version[white::-]: [papayawhip::]v1`, }, { `api: - version: v1`, + version: v1`, `[steelblue::b]api[white::-]: - [steelblue::b]version[white::-]: [papayawhip::]v1`, + [steelblue::b]version[white::-]: [papayawhip::]v1`, }, { " fred:blee", diff --git a/internal/watch/factory.go b/internal/watch/factory.go index 8b1d9849..32d86ae6 100644 --- a/internal/watch/factory.go +++ b/internal/watch/factory.go @@ -109,10 +109,14 @@ func (f *Factory) waitForCacheSync(ns string) { if f.isClusterWide() { ns = client.AllNamespaces } + + f.mx.RLock() + defer f.mx.RUnlock() fac, ok := f.factories[ns] if !ok { return } + // Hang for a sec for the cache to refresh if still not done bail out! c := make(chan struct{}) go func(c chan struct{}) { @@ -214,6 +218,9 @@ func (f *Factory) ensureFactory(ns string) di.DynamicSharedInformerFactory { // AddForwarder registers a new portforward for a given container. func (f *Factory) AddForwarder(pf Forwarder) { + f.mx.Lock() + defer f.mx.Unlock() + f.forwarders[pf.Path()] = pf } @@ -225,11 +232,17 @@ func (f *Factory) DeleteForwarder(path string) { // Forwarders returns all portforwards. func (f *Factory) Forwarders() Forwarders { + f.mx.RLock() + defer f.mx.RUnlock() + return f.forwarders } // ForwarderFor returns a portforward for a given container or nil if none exists. func (f *Factory) ForwarderFor(path string) (Forwarder, bool) { + f.mx.RLock() + defer f.mx.RUnlock() + fwd, ok := f.forwarders[path] return fwd, ok } diff --git a/internal/watch/forwarders.go b/internal/watch/forwarders.go index aa53cc1b..4ec6dd33 100644 --- a/internal/watch/forwarders.go +++ b/internal/watch/forwarders.go @@ -3,14 +3,15 @@ package watch import ( "strings" + "github.com/derailed/k9s/internal/client" "github.com/rs/zerolog/log" "k8s.io/client-go/tools/portforward" ) // Forwarder represents a port forwarder. type Forwarder interface { - // Start initializes a port forward. - Start(path, co, address string, ports []string) (*portforward.PortForwarder, error) + // Start starts a port-forward. + Start(path, co string, t client.PortTunnel) (*portforward.PortForwarder, error) // Stop terminates a port forward. Stop() @@ -24,11 +25,20 @@ type Forwarder interface { // Ports returns container exposed ports. Ports() []string + // FQN returns the full port-forward name. + FQN() string + // Active returns forwarder current state. Active() bool + // SetActive sets port-forward state. + SetActive(bool) + // Age returns forwarder age. Age() string + + // HasPortMapping returns true if port mapping exists. + HasPortMapping(string) bool } // Forwarders tracks active port forwards. diff --git a/internal/xray/container.go b/internal/xray/container.go index ae1a8eb5..c4ba5ead 100644 --- a/internal/xray/container.go +++ b/internal/xray/container.go @@ -35,9 +35,9 @@ func (c *Container) Render(ctx context.Context, ns string, o interface{}) error } pns, _ := client.Namespaced(parent.ID) c.envRefs(f, root, pns, co.Container) - if !root.IsLeaf() { - parent.Add(root) - } + // if !root.IsLeaf() { + parent.Add(root) + // } return nil } diff --git a/internal/xray/container_test.go b/internal/xray/container_test.go index 31aca2a3..bf18164c 100644 --- a/internal/xray/container_test.go +++ b/internal/xray/container_test.go @@ -235,7 +235,7 @@ func makeDoubleCMKeysContainer(n string, optional bool) *v1.Container { } func load(t *testing.T, n string) *unstructured.Unstructured { - raw, err := ioutil.ReadFile(fmt.Sprintf("test_assets/%s.json", n)) + raw, err := ioutil.ReadFile(fmt.Sprintf("testdata/%s.json", n)) assert.Nil(t, err) var o unstructured.Unstructured diff --git a/internal/xray/pod.go b/internal/xray/pod.go index 9a9e0cf6..434253a0 100644 --- a/internal/xray/pod.go +++ b/internal/xray/pod.go @@ -40,7 +40,6 @@ func (p *Pod) Render(ctx context.Context, ns string, o interface{}) error { if !ok { return fmt.Errorf("Expecting a TreeNode but got %T", ctx.Value(KeyParent)) } - parent.Add(node) if err := p.containerRefs(ctx, node, po.Namespace, po.Spec); err != nil { return err @@ -50,6 +49,14 @@ func (p *Pod) Render(ctx context.Context, ns string, o interface{}) error { return err } + gvr, nsID := "v1/namespaces", client.FQN(client.ClusterScope, po.Namespace) + nsn := parent.Find(gvr, nsID) + if nsn == nil { + nsn = NewTreeNode(gvr, nsID) + parent.Add(nsn) + } + nsn.Add(node) + return p.validate(node, po) } diff --git a/internal/xray/pod_test.go b/internal/xray/pod_test.go index ffa1fbc8..7d144304 100644 --- a/internal/xray/pod_test.go +++ b/internal/xray/pod_test.go @@ -12,27 +12,27 @@ import ( func TestPodRender(t *testing.T) { uu := map[string]struct { - file string - level1, level2 int - status string + file string + count, children int + status string }{ "plain": { - file: "po", - level1: 1, - level2: 3, - status: xray.OkStatus, + file: "po", + children: 1, + count: 7, + status: xray.OkStatus, }, "withInit": { - file: "init", - level1: 1, - level2: 2, - status: xray.OkStatus, + file: "init", + children: 1, + count: 7, + status: xray.OkStatus, }, "cilium": { - file: "cilium", - level1: 1, - level2: 3, - status: xray.OkStatus, + file: "cilium", + children: 1, + count: 8, + status: xray.OkStatus, }, } @@ -46,8 +46,8 @@ func TestPodRender(t *testing.T) { ctx = context.WithValue(ctx, internal.KeyFactory, makeFactory()) assert.Nil(t, re.Render(ctx, "", &render.PodWithMetrics{Raw: o})) - assert.Equal(t, u.level1, root.CountChildren()) - assert.Equal(t, u.level2, root.Children[0].CountChildren()) + assert.Equal(t, u.children, root.CountChildren()) + assert.Equal(t, u.count, root.Count("")) }) } } diff --git a/internal/xray/test_assets/cilium.json b/internal/xray/testdata/cilium.json similarity index 100% rename from internal/xray/test_assets/cilium.json rename to internal/xray/testdata/cilium.json diff --git a/internal/xray/test_assets/dp.json b/internal/xray/testdata/dp.json similarity index 100% rename from internal/xray/test_assets/dp.json rename to internal/xray/testdata/dp.json diff --git a/internal/xray/test_assets/ds.json b/internal/xray/testdata/ds.json similarity index 100% rename from internal/xray/test_assets/ds.json rename to internal/xray/testdata/ds.json diff --git a/internal/xray/test_assets/init.json b/internal/xray/testdata/init.json similarity index 100% rename from internal/xray/test_assets/init.json rename to internal/xray/testdata/init.json diff --git a/internal/xray/test_assets/ns.json b/internal/xray/testdata/ns.json similarity index 100% rename from internal/xray/test_assets/ns.json rename to internal/xray/testdata/ns.json diff --git a/internal/xray/test_assets/po.json b/internal/xray/testdata/po.json similarity index 100% rename from internal/xray/test_assets/po.json rename to internal/xray/testdata/po.json diff --git a/internal/xray/test_assets/rs.json b/internal/xray/testdata/rs.json similarity index 100% rename from internal/xray/test_assets/rs.json rename to internal/xray/testdata/rs.json diff --git a/internal/xray/test_assets/sa.json b/internal/xray/testdata/sa.json similarity index 100% rename from internal/xray/test_assets/sa.json rename to internal/xray/testdata/sa.json diff --git a/internal/xray/test_assets/sts.json b/internal/xray/testdata/sts.json similarity index 100% rename from internal/xray/test_assets/sts.json rename to internal/xray/testdata/sts.json diff --git a/internal/xray/test_assets/svc.json b/internal/xray/testdata/svc.json similarity index 100% rename from internal/xray/test_assets/svc.json rename to internal/xray/testdata/svc.json diff --git a/internal/xray/tree_node.go b/internal/xray/tree_node.go index bab59321..5d2d8577 100644 --- a/internal/xray/tree_node.go +++ b/internal/xray/tree_node.go @@ -52,8 +52,53 @@ type TreeRef string // NodeSpec represents a node resource specification. type NodeSpec struct { - GVR, Path, Status string - Parent *NodeSpec + GVRs, Paths, Statuses []string +} + +// ParentGVR returns the parent GVR. +func (s NodeSpec) ParentGVR() *string { + if len(s.GVRs) > 1 { + return &s.GVRs[1] + } + return nil +} + +// ParentPath returns the parent path. +func (s NodeSpec) ParentPath() *string { + if len(s.Paths) > 1 { + return &s.Paths[1] + } + return nil +} + +// GVR returns the current GVR. +func (s NodeSpec) GVR() string { + return s.GVRs[0] +} + +// Path returns the current path. +func (s NodeSpec) Path() string { + return s.Paths[0] +} + +// Status returns the current status. +func (s NodeSpec) Status() string { + return s.Statuses[0] +} + +// AsPath returns path hierarchy as string. +func (s NodeSpec) AsPath() string { + return strings.Join(s.Paths, PathSeparator) +} + +// AsGVR returns a gvr hierarchy as string. +func (s NodeSpec) AsGVR() string { + return strings.Join(s.GVRs, PathSeparator) +} + +// AsStatus returns a status hierarchy as string. +func (s NodeSpec) AsStatus() string { + return strings.Join(s.Statuses, PathSeparator) } // ---------------------------------------------------------------------------- @@ -145,19 +190,17 @@ func (t *TreeNode) Sort() { // Spec returns this node specification. func (t *TreeNode) Spec() NodeSpec { - parent := t - var gvr, path, status []string - for parent != nil { - gvr = append(gvr, parent.GVR) - path = append(path, parent.ID) - status = append(status, parent.Extras[StatusKey]) - parent = parent.Parent + var GVRs, Paths, Statuses []string + for parent := t; parent != nil; parent = parent.Parent { + GVRs = append(GVRs, parent.GVR) + Paths = append(Paths, parent.ID) + Statuses = append(Statuses, parent.Extras[StatusKey]) } return NodeSpec{ - GVR: strings.Join(gvr, PathSeparator), - Path: strings.Join(path, PathSeparator), - Status: strings.Join(status, PathSeparator), + GVRs: GVRs, + Paths: Paths, + Statuses: Statuses, } } @@ -180,21 +223,18 @@ func (t *TreeNode) Blank() bool { } // Hydrate hydrates a full tree bases on a collection of specifications. -func Hydrate(refs []NodeSpec) *TreeNode { +func Hydrate(specs []NodeSpec) *TreeNode { root := NewTreeNode("", "") nav := root - for _, ref := range refs { - gvrs := strings.Split(ref.GVR, PathSeparator) - paths := strings.Split(ref.Path, PathSeparator) - statuses := strings.Split(ref.Status, PathSeparator) - for i := len(paths) - 1; i >= 0; i-- { + for _, spec := range specs { + for i := len(spec.Paths) - 1; i >= 0; i-- { if nav.Blank() { - nav.GVR, nav.ID, nav.Extras[StatusKey] = gvrs[i], paths[i], statuses[i] + nav.GVR, nav.ID, nav.Extras[StatusKey] = spec.GVRs[i], spec.Paths[i], spec.Statuses[i] continue } - c := NewTreeNode(gvrs[i], paths[i]) - c.Extras[StatusKey] = statuses[i] - if n := nav.Find(gvrs[i], paths[i]); n == nil { + c := NewTreeNode(spec.GVRs[i], spec.Paths[i]) + c.Extras[StatusKey] = spec.Statuses[i] + if n := nav.Find(spec.GVRs[i], spec.Paths[i]); n == nil { nav.Add(c) nav = c } else { @@ -260,7 +300,7 @@ func (t *TreeNode) Filter(q string, filter func(q, path string) bool) *TreeNode specs := t.Flatten() matches := make([]NodeSpec, 0, len(specs)) for _, s := range specs { - if filter(q, s.Path+s.Status) { + if filter(q, s.AsPath()+s.AsStatus()) { matches = append(matches, s) } } @@ -461,7 +501,7 @@ func toEmoji(gvr string) string { // EmojiInfo returns emoji help. func EmojiInfo() map[string]string { - gvrs := []string{ + GVRs := []string{ "containers", "v1/namespaces", "v1/pods", @@ -476,8 +516,8 @@ func EmojiInfo() map[string]string { "apps/v1/daemonsets", } - m := make(map[string]string, len(gvrs)) - for _, g := range gvrs { + m := make(map[string]string, len(GVRs)) + for _, g := range GVRs { m[client.NewGVR(g).R()] = toEmoji(g) } diff --git a/internal/xray/tree_node_test.go b/internal/xray/tree_node_test.go index 652bc7fd..fbce2e10 100644 --- a/internal/xray/tree_node_test.go +++ b/internal/xray/tree_node_test.go @@ -86,8 +86,8 @@ func TestTreeNodeFilter(t *testing.T) { } func TestTreeNodeHydrate(t *testing.T) { - threeOK := strings.Join([]string{"ok", "ok", "ok"}, xray.PathSeparator) - fiveOK := strings.Join([]string{"ok", "ok", "ok", "ok", "ok"}, xray.PathSeparator) + threeOK := []string{"ok", "ok", "ok"} + fiveOK := append(threeOK, "ok", "ok") uu := map[string]struct { spec []xray.NodeSpec @@ -96,14 +96,14 @@ func TestTreeNodeHydrate(t *testing.T) { "flat_simple": { spec: []xray.NodeSpec{ { - GVR: "containers::v1/pods", - Path: "c1::default/p1", - Status: threeOK, + GVRs: []string{"containers", "v1/pods"}, + Paths: []string{"c1", "default/p1"}, + Statuses: threeOK, }, { - GVR: "containers::v1/pods", - Path: "c2::default/p1", - Status: threeOK, + GVRs: []string{"containers", "v1/pods"}, + Paths: []string{"c2", "default/p1"}, + Statuses: threeOK, }, }, e: root1(), @@ -111,14 +111,14 @@ func TestTreeNodeHydrate(t *testing.T) { "flat_complex": { spec: []xray.NodeSpec{ { - GVR: "v1/secrets::containers::v1/pods", - Path: "s1::c1::default/p1", - Status: threeOK, + GVRs: []string{"v1/secrets", "containers", "v1/pods"}, + Paths: []string{"s1", "c1", "default/p1"}, + Statuses: threeOK, }, { - GVR: "v1/secrets::containers::v1/pods", - Path: "s2::c2::default/p1", - Status: threeOK, + GVRs: []string{"v1/secrets", "containers", "v1/pods"}, + Paths: []string{"s2", "c2", "default/p1"}, + Statuses: threeOK, }, }, e: root2(), @@ -126,49 +126,49 @@ func TestTreeNodeHydrate(t *testing.T) { "complex1": { spec: []xray.NodeSpec{ { - GVR: "v1/secrets::v1/pods::apps/v1/deployments::v1/namespaces::apps/v1/deployments", - Path: "default/default-token-rr22g::default/nginx-6b866d578b-c6tcn::default/nginx::-/default::deployments", - Status: fiveOK, + GVRs: []string{"v1/secrets", "v1/pods", "apps/v1/deployments", "v1/namespaces", "apps/v1/deployments"}, + Paths: []string{"default/default-token-rr22g", "default/nginx-6b866d578b-c6tcn", "default/nginx", "-/default", "deployments"}, + Statuses: fiveOK, }, { - GVR: "v1/configmaps::v1/pods::apps/v1/deployments::v1/namespaces::apps/v1/deployments", - Path: "kube-system/coredns::kube-system/coredns-6955765f44-89q2p::kube-system/coredns::-/kube-system::deployments", - Status: fiveOK, + GVRs: []string{"v1/configmaps", "v1/pods", "apps/v1/deployments", "v1/namespaces", "apps/v1/deployments"}, + Paths: []string{"kube-system/coredns", "kube-system/coredns-6955765f44-89q2p", "kube-system/coredns", "-/kube-system", "deployments"}, + Statuses: fiveOK, }, { - GVR: "v1/secrets::v1/pods::apps/v1/deployments::v1/namespaces::apps/v1/deployments", - Path: "kube-system/coredns-token-5cq9j::kube-system/coredns-6955765f44-89q2p::kube-system/coredns::-/kube-system::deployments", - Status: fiveOK, + GVRs: []string{"v1/secrets", "v1/pods", "apps/v1/deployments", "v1/namespaces", "apps/v1/deployments"}, + Paths: []string{"kube-system/coredns-token-5cq9j", "kube-system/coredns-6955765f44-89q2p", "kube-system/coredns", "-/kube-system", "deployments"}, + Statuses: fiveOK, }, { - GVR: "v1/configmaps::v1/pods::apps/v1/deployments::v1/namespaces::apps/v1/deployments", - Path: "kube-system/coredns::kube-system/coredns-6955765f44-r9j9t::kube-system/coredns::-/kube-system::deployments", - Status: fiveOK, + GVRs: []string{"v1/configmaps", "v1/pods", "apps/v1/deployments", "v1/namespaces", "apps/v1/deployments"}, + Paths: []string{"kube-system/coredns", "kube-system/coredns-6955765f44-r9j9t", "kube-system/coredns", "-/kube-system", "deployments"}, + Statuses: fiveOK, }, { - GVR: "v1/secrets::v1/pods::apps/v1/deployments::v1/namespaces::apps/v1/deployments", - Path: "kube-system/coredns-token-5cq9j::kube-system/coredns-6955765f44-r9j9t::kube-system/coredns::-/kube-system::deployments", - Status: fiveOK, + GVRs: []string{"v1/secrets", "v1/pods", "apps/v1/deployments", "v1/namespaces", "apps/v1/deployments"}, + Paths: []string{"kube-system/coredns-token-5cq9j", "kube-system/coredns-6955765f44-r9j9t", "kube-system/coredns", "-/kube-system", "deployments"}, + Statuses: fiveOK, }, { - GVR: "v1/secrets::v1/pods::apps/v1/deployments::v1/namespaces::apps/v1/deployments", - Path: "kube-system/default-token-thzt8::kube-system/metrics-server-6754dbc9df-88bk4::kube-system/metrics-server::-/kube-system::deployments", - Status: fiveOK, + GVRs: []string{"v1/secrets", "v1/pods", "apps/v1/deployments", "v1/namespaces", "apps/v1/deployments"}, + Paths: []string{"kube-system/default-token-thzt8", "kube-system/metrics-server-6754dbc9df-88bk4", "kube-system/metrics-server", "-/kube-system", "deployments"}, + Statuses: fiveOK, }, { - GVR: "v1/secrets::v1/pods::apps/v1/deployments::v1/namespaces::apps/v1/deployments", - Path: "kube-system/nginx-ingress-token-kff5q::kube-system/nginx-ingress-controller-6fc5bcc8c9-cwp55::kube-system/nginx-ingress-controller::-/kube-system::deployments", - Status: fiveOK, + GVRs: []string{"v1/secrets", "v1/pods", "apps/v1/deployments", "v1/namespaces", "apps/v1/deployments"}, + Paths: []string{"kube-system/nginx-ingress-token-kff5q", "kube-system/nginx-ingress-controller-6fc5bcc8c9-cwp55", "kube-system/nginx-ingress-controller", "-/kube-system", "deployments"}, + Statuses: fiveOK, }, { - GVR: "v1/secrets::v1/pods::apps/v1/deployments::v1/namespaces::apps/v1/deployments", - Path: "kubernetes-dashboard/kubernetes-dashboard-token-d6rt4::kubernetes-dashboard/dashboard-metrics-scraper-7b64584c5c-c7b56::kubernetes-dashboard/dashboard-metrics-scraper::-/kubernetes-dashboard::deployments", - Status: fiveOK, + GVRs: []string{"v1/secrets", "v1/pods", "apps/v1/deployments", "v1/namespaces", "apps/v1/deployments"}, + Paths: []string{"kubernetes-dashboard/kubernetes-dashboard-token-d6rt4", "kubernetes-dashboard/dashboard-metrics-scraper-7b64584c5c-c7b56", "kubernetes-dashboard/dashboard-metrics-scraper", "-/kubernetes-dashboard", "deployments"}, + Statuses: fiveOK, }, { - GVR: "v1/secrets::v1/pods::apps/v1/deployments::v1/namespaces::apps/v1/deployments", - Path: "kubernetes-dashboard/kubernetes-dashboard-token-d6rt4::kubernetes-dashboard/kubernetes-dashboard-79d9cd965-b4c7d::kubernetes-dashboard/kubernetes-dashboard::-/kubernetes-dashboard::deployments", - Status: fiveOK, + GVRs: []string{"v1/secrets", "v1/pods", "apps/v1/deployments", "v1/namespaces", "apps/v1/deployments"}, + Paths: []string{"kubernetes-dashboard/kubernetes-dashboard-token-d6rt4", "kubernetes-dashboard/kubernetes-dashboard-79d9cd965-b4c7d", "kubernetes-dashboard/kubernetes-dashboard", "-/kubernetes-dashboard", "deployments"}, + Statuses: fiveOK, }, }, e: root3(), @@ -193,14 +193,14 @@ func TestTreeNodeFlatten(t *testing.T) { root: root1(), e: []xray.NodeSpec{ { - GVR: "containers::v1/pods", - Path: "c1::default/p1", - Status: strings.Join([]string{"ok", "ok"}, xray.PathSeparator), + GVRs: []string{"containers", "v1/pods"}, + Paths: []string{"c1", "default/p1"}, + Statuses: []string{"ok", "ok"}, }, { - GVR: "containers::v1/pods", - Path: "c2::default/p1", - Status: strings.Join([]string{"ok", "ok"}, xray.PathSeparator), + GVRs: []string{"containers", "v1/pods"}, + Paths: []string{"c2", "default/p1"}, + Statuses: []string{"ok", "ok"}, }, }, }, @@ -208,14 +208,14 @@ func TestTreeNodeFlatten(t *testing.T) { root: root2(), e: []xray.NodeSpec{ { - GVR: "v1/secrets::containers::v1/pods", - Path: "s1::c1::default/p1", - Status: strings.Join([]string{"ok", "ok", "ok"}, xray.PathSeparator), + GVRs: []string{"v1/secrets", "containers", "v1/pods"}, + Paths: []string{"s1", "c1", "default/p1"}, + Statuses: []string{"ok", "ok", "ok"}, }, { - GVR: "v1/secrets::containers::v1/pods", - Path: "s2::c2::default/p1", - Status: strings.Join([]string{"ok", "ok", "ok"}, xray.PathSeparator), + GVRs: []string{"v1/secrets", "containers", "v1/pods"}, + Paths: []string{"s2", "c2", "default/p1"}, + Statuses: []string{"ok", "ok", "ok"}, }, }, }, @@ -323,18 +323,18 @@ func diff1() *xray.TreeNode { } func root2() *xray.TreeNode { - n := xray.NewTreeNode("v1/pods", "default/p1") c1 := xray.NewTreeNode("containers", "c1") - c2 := xray.NewTreeNode("containers", "c2") - n.Add(c1) - n.Add(c2) - s1 := xray.NewTreeNode("v1/secrets", "s1") c1.Add(s1) + c2 := xray.NewTreeNode("containers", "c2") s2 := xray.NewTreeNode("v1/secrets", "s2") c2.Add(s2) + n := xray.NewTreeNode("v1/pods", "default/p1") + n.Add(c1) + n.Add(c2) + return n } diff --git a/plugins/README.md b/plugins/README.md new file mode 100644 index 00000000..e23d86d7 --- /dev/null +++ b/plugins/README.md @@ -0,0 +1,4 @@ +# K9s community plugins + +These plugins provide for extending the K9s cli to provide for more cluster management fu. + diff --git a/plugins/job_suspend.yml b/plugins/job_suspend.yml new file mode 100644 index 00000000..06f0de45 --- /dev/null +++ b/plugins/job_suspend.yml @@ -0,0 +1,19 @@ +plugin: + # Suspends/Resumes a cronjob + suspendCronsToggle: + shortCut: Ctrl-S + scopes: + - cj + description: Suspend toggle + command: kubectl + background: true + args: + - patch + - cronjobs + - $NAME + - -n + - $NAMESPACE + - --context + - $CONTEXT + - -p + - '{"spec" : {"suspend" : $!COL3 }}' diff --git a/plugins/kubectl/kubectl-jq b/plugins/kubectl/kubectl-jq new file mode 100755 index 00000000..5229a1d5 --- /dev/null +++ b/plugins/kubectl/kubectl-jq @@ -0,0 +1,3 @@ +#!/bin/bash + +/usr/local/bin/kubectl logs -f $1 -n $2 --context $3 | jq -r '.message' \ No newline at end of file diff --git a/plugins/log_jq.yml b/plugins/log_jq.yml new file mode 100644 index 00000000..4e6d6d3c --- /dev/null +++ b/plugins/log_jq.yml @@ -0,0 +1,14 @@ +plugin: + # Sends logs over to jq for processing. This leverages kubectl plugin kubectl-jq. + jqlogs: + shortCut: Ctrl-J + description: "Logs (jq)" + scopes: + - po + command: kubectl + background: false + args: + - jq + - $NAME + - $NAMESPACE + - $CONTEXT diff --git a/plugins/log_stern.yml b/plugins/log_stern.yml new file mode 100644 index 00000000..83dbe6f1 --- /dev/null +++ b/plugins/log_stern.yml @@ -0,0 +1,17 @@ +plugin: + # Leverage stern (https://github.com/wercker/stern) to output logs. + stern: + shortCut: Ctrl-L + description: "Logs " + scopes: + - pods + command: stern + background: false + args: + - --tail + - 50 + - $FILTER + - -n + - $NAMESPACE + - --context + - $CONTEXT diff --git a/skins/black_and_wtf.yml b/skins/black_and_wtf.yml index a31da501..07009922 100644 --- a/skins/black_and_wtf.yml +++ b/skins/black_and_wtf.yml @@ -1,56 +1,78 @@ +# Styles... +fg: &fg "white" +bg: &bg "black" +mark: &mark "darkgoldenrod" +active: &active "dimgray" +text: &text "navajowhite" +white: &white "whitesmoke" +ghost: &ghost "ghostwhite" +dslate: &dslate "darkslategray" +err: &err "pink" +slate: &slate "slategray" +gray: &gray "gray" + +# Skin k9s: body: - fgColor: white - bgColor: black - logoColor: white + fgColor: *fg + bgColor: *bg + logoColor: *fg info: - fgColor: navajowhite - sectionColor: white + fgColor: *text + sectionColor: *fg frame: border: - fgColor: white - focusColor: white + fgColor: *fg + focusColor: *fg menu: - fgColor: white - keyColor: white - numKeyColor: navajowhite + fgColor: *fg + keyColor: *fg + numKeyColor: *text crumbs: - fgColor: black - bgColor: navajowhite - activeColor: whitesmoke + fgColor: *fg + bgColor: *bg + activeColor: *active status: - newColor: ghostwhite - modifyColor: navajowhite - addColor: darkslategray - errorColor: whitesmoke - highlightcolor: dimgray - killColor: slategray - completedColor: gray + newColor: *white + modifyColor: *text + addColor: *ghost + errorColor: *err + highlightcolor: *dslate + killColor: *slate + completedColor: *gray title: - fgColor: ghostwhite - highlightColor: navajowhite - counterColor: navajowhite - filterColor: slategray - table: - fgColor: white - bgColor: black - cursorColor: white - markColor: darkgoldenrod - header: - fgColor: darkgray - bgColor: black - sorterColor: white - xray: - fgColor: white - bgColor: black - cursorColor: whitesmoke - graphicColor: gray - showIcons: false + fgColor: *fg + highlightColor: *active + counterColor: *text + filterColor: *slate views: + table: + fgColor: *fg + bgColor: *bg + cursorColor: *fg + markColor: *mark + header: + fgColor: *dslate + bgColor: *bg + sorterColor: *fg + xray: + fgColor: *fg + bgColor: *bg + cursorColor: *ghost + graphicColor: gray + showIcons: false yaml: - keyColor: ghostwhite - colorColor: slategray - valueColor: navajowhite + keyColor: *ghost + colorColor: *slate + valueColor: *text logs: - fgColor: ghostwhite - bgColor: black + fgColor: *ghost + bgColor: *bg + charts: + bgColor: default + defaultDialColors: + - *white + - *err + defaultChartColors: + - *white + - *err diff --git a/skins/dracula.yml b/skins/dracula.yml index 8905eb6b..4ba08cd8 100644 --- a/skins/dracula.yml +++ b/skins/dracula.yml @@ -1,3 +1,4 @@ +# Styles... foreground: &foreground "#f8f8f2" background: &background "#282a36" current_line: ¤t_line "#44475a" @@ -10,6 +11,8 @@ pink: &pink "#ff79c6" purple: &purple "#bd93f9" red: &red "#ff5555" yellow: &yellow "#f1fa8c" + +# Skin... k9s: # General K9s styles body: @@ -51,24 +54,33 @@ k9s: highlightColor: *orange counterColor: *purple filterColor: *pink - # TableView attributes. - table: - fgColor: *foreground - bgColor: *background - cursorColor: *current_line - # Header row styles. - header: + views: + # Charts skins... + charts: + bgColor: default + defaultDialColors: + - *purple + - *red + defaultChartColors: + - *purple + - *red + # TableView attributes. + table: fgColor: *foreground bgColor: *background - sorterColor: *cyan - # Xray view attributes. - xray: - fgColor: *foreground - bgColor: *background - cursorColor: *current_line - graphicColor: *purple - showIcons: false - views: + cursorColor: *current_line + # Header row styles. + header: + fgColor: *foreground + bgColor: *background + sorterColor: *cyan + # Xray view attributes. + xray: + fgColor: *foreground + bgColor: *background + cursorColor: *current_line + graphicColor: *purple + showIcons: false # YAML info styles. yaml: keyColor: *pink diff --git a/skins/in_the_navy.yml b/skins/in_the_navy.yml index 382a3f95..ed101190 100644 --- a/skins/in_the_navy.yml +++ b/skins/in_the_navy.yml @@ -1,58 +1,86 @@ +# Styles... + +fg: &fg "dodgerblue" +bg: &bg "white" +blue: &blue "blue" +sky: &sky "lightskyblue" +steel: &steel "steelblue" +dark: &dark "darkblue" +alice: &alice "aliceblue" +corn: &corn "cornflowerblue" +err: &err "indianred" +royal: &royal "royalblue" +slate: &slate "slategray" +gray: &gray "gray" +cadet: &cadet "cadetblue" +powder: &powder "powderblue" +aqua: &aqua "aqua" +mslate: &mslate "mediumslateblue" + +# Skin... k9s: body: - fgColor: dodgerblue - bgColor: white - logoColor: blue + fgColor: *fg + bgColor: *bg + logoColor: *blue info: - fgColor: lightskyblue - sectionColor: steelblue + fgColor: *sky + sectionColor: *steel frame: border: - fgColor: dodgerblue - bgColor: darkblue - focusColor: aliceblue + fgColor: *fg + bgColor: *dark + focusColor: *alice menu: - fgColor: darkblue - keyColor: cornflowerblue - numKeyColor: cadetblue + fgColor: *dark + keyColor: *corn + numKeyColor: *cadet crumbs: - fgColor: white - bgColor: steelblue - activeColor: skyblue + fgColor: *bg + bgColor: *steel + activeColor: *sky status: - newColor: blue - modifyColor: powderblue - addColor: lightskyblue - errorColor: indianred - highlightcolor: royalblue - killColor: slategray - completedColor: gray + newColor: *blue + modifyColor: *powder + addColor: *sky + errorColor: *err + highlightcolor: *royal + killColor: *slate + completedColor: *gray title: - fgColor: aqua - bgColor: darkblue - highlightColor: skyblue - counterColor: slateblue - filterColor: slategray - table: - fgColor: blue - bgColor: darkblue - cursorColor: aqua - markColor: mediumslateblue - header: - fgColor: white - bgColor: darkblue - sorterColor: orange - xray: - fgColor: blue - bgColor: darkblue - cursorColor: aqua - graphicColor: mediumslateblue - showIcons: false + fgColor: *cadet + bgColor: *bg + highlightColor: *sky + counterColor: *slate + filterColor: *slate views: + table: + fgColor: *fg + bgColor: *bg + cursorColor: *aqua + markColor: *mslate + header: + fgColor: *fg + bgColor: *bg + sorterColor: *cadet + xray: + fgColor: *blue + bgColor: *dark + cursorColor: *aqua + graphicColor: *mslate + showIcons: false + charts: + bgColor: *bg + defaultDialColors: + - *aqua + - *err + defaultChartColors: + - *aqua + - *err yaml: - keyColor: steelblue - colorColor: blue - valueColor: royalblue + keyColor: *steel + colorColor: *blue + valueColor: *royal logs: - fgColor: white - bgColor: darkblue + fgColor: *dark + bgColor: *bg diff --git a/skins/snazzy.yml b/skins/snazzy.yml index 55a022ea..11cf5240 100644 --- a/skins/snazzy.yml +++ b/skins/snazzy.yml @@ -32,22 +32,31 @@ k9s: highlightColor: white counterColor: white filterColor: "#57c7ff" - table: - fgColor: "#57c7ff" - bgColor: "#282a36" - cursorColor: "#5af78e" - markColor: darkgoldenrod - header: - fgColor: white - bgColor: "#282a36" - sorterColor: orange - xray: - fgColor: "#57c7ff" - bgColor: "#282a36" - cursorColor: "#5af78e" - graphicColor: darkgoldenrod - showIcons: false views: + # Charts skins... + charts: + bgColor: default + defaultDialColors: + - "#57c7ff" + - "#ff5c57" + defaultChartColors: + - "#57c7ff" + - "#ff5c57" + table: + fgColor: "#57c7ff" + bgColor: "#282a36" + cursorColor: "#5af78e" + markColor: darkgoldenrod + header: + fgColor: white + bgColor: "#282a36" + sorterColor: orange + xray: + fgColor: "#57c7ff" + bgColor: "#282a36" + cursorColor: "#5af78e" + graphicColor: darkgoldenrod + showIcons: false yaml: keyColor: "#ff5c57" colonColor: white diff --git a/skins/stock.yml b/skins/stock.yml index acbf7dc0..33c9538c 100644 --- a/skins/stock.yml +++ b/skins/stock.yml @@ -31,22 +31,31 @@ k9s: highlightColor: fuchsia counterColor: papayawhip filterColor: steelblue - table: - fgColor: blue - bgColor: black - cursorColor: aqua - markColor: darkgoldenrod - header: - fgColor: white - bgColor: black - sorterColor: orange - xray: - fgColor: blue - bgColor: black - cursorColor: aqua - graphicColor: darkgoldenrod - showIcons: false views: + # Charts skins... + charts: + bgColor: black + defaultDialColors: + - linegreen + - orangered + defaultChartColors: + - linegreen + - orangered + table: + fgColor: blue + bgColor: black + cursorColor: aqua + markColor: darkgoldenrod + header: + fgColor: white + bgColor: black + sorterColor: orange + xray: + fgColor: blue + bgColor: black + cursorColor: aqua + graphicColor: darkgoldenrod + showIcons: false yaml: keyColor: steelblue colonColor: white