Merge pull request #1 from derailed/master

update to latest
mine
Pavel Tumik 2020-04-26 06:55:54 -07:00 committed by GitHub
commit c3c77b6e50
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
160 changed files with 5132 additions and 2013 deletions

View File

@ -79,7 +79,7 @@ linters-settings:
# exclude: /path/to/file.txt
funlen:
lines: 65
lines: 75
statements: 40
govet:
@ -114,7 +114,7 @@ linters-settings:
local-prefixes: github.com/org/project
gocyclo:
# minimal code complexity to report, 30 by default (but we recommend 10-20)
min-complexity: 15
min-complexity: 20
gocognit:
# minimal code complexity to report, 30 by default (but we recommend 10-20)
min-complexity: 20

View File

@ -13,12 +13,14 @@ builds:
- darwin
- windows
goarch:
- 386
# - 386
- amd64
- arm64
- arm
goarm:
- 7
flags:
- -trimpath
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}}
archives:
@ -30,7 +32,7 @@ archives:
bit: Arm
bitv6: Arm6
bitv7: Arm7
386: i386
# 386: i386
amd64: x86_64
checksum:
name_template: "checksums.txt"
@ -48,12 +50,12 @@ brews:
- name: k9s
github:
owner: derailed
name: k9s-homebrew-tap
name: homebrew-k9s
commit_author:
name: derailed
email: fernand@imhotep.io
folder: Formula
homepage: https://k8sk9s.dev/
homepage: https://k9scli.io/
description: Kubernetes CLI To Manage Your Clusters In Style!
test: |
system "k9s version"

View File

@ -1,5 +1,5 @@
# Build...
FROM golang:1.13.6-alpine3.11 AS build
FROM golang:1.14.1-alpine3.11 AS build
WORKDIR /k9s
COPY go.mod go.sum main.go Makefile ./
@ -13,7 +13,7 @@ RUN apk --no-cache add make git gcc libc-dev curl && make build
FROM alpine:3.10.0
COPY --from=build /k9s/execs/k9s /bin/k9s
ENV KUBE_LATEST_VERSION="v1.16.2"
ENV KUBE_LATEST_VERSION="v1.18.1"
RUN apk add --update ca-certificates \
&& apk add --update -t deps curl \
&& curl -L https://storage.googleapis.com/kubernetes-release/release/${KUBE_LATEST_VERSION}/bin/linux/amd64/kubectl -o /usr/local/bin/kubectl \

View File

@ -3,26 +3,26 @@ PACKAGE := github.com/derailed/$(NAME)
GIT := $(shell git rev-parse --short HEAD)
SOURCE_DATE_EPOCH ?= $(shell date +%s)
DATE := $(shell date -u -d @${SOURCE_DATE_EPOCH} +%FT%T%Z)
VERSION ?= v0.17.6
VERSION ?= v0.19.1
IMG_NAME := derailed/k9s
IMAGE := ${IMG_NAME}:${VERSION}
default: help
test: ## Run all tests
test: ## Run all tests
@go clean --testcache && go test ./...
cover: ## Run test coverage suite
cover: ## Run test coverage suite
@go test ./... --coverprofile=cov.out
@go tool cover --html=cov.out
build: ## Builds the CLI
build: ## Builds the CLI
@go build \
-ldflags "-w -s -X ${PACKAGE}/cmd.version=${VERSION} -X ${PACKAGE}/cmd.commit=${GIT} -X ${PACKAGE}/cmd.date=${DATE}" \
-a -tags netgo -o execs/${NAME} main.go
img: ## Build Docker Image
img: ## Build Docker Image
@docker build --rm -t ${IMAGE} .
help:

179
README.md
View File

@ -90,6 +90,79 @@ K9s is available on Linux, macOS and Windows platforms.
export TERM=xterm-256color
```
* In order to issue manifest edit commands make sure your EDITOR env is set.
```shell
export EDITOR=my_fav_editor_here!
```
---
## The Command Line
```shell
# List all available CLI options
k9s help
# To get info about K9s runtime (logs, configs, etc..)
k9s info
# To run K9s in a given namespace
k9s -n mycoolns
# Start K9s in an existing KubeConfig context
k9s --context coolCtx
# Start K9s in readonly mode - with all modification commands disabled
k9s --readonly
```
## Logs
Given the nature of the ui k9s does produce logs to a specific location. To view the logs and turn on debug mode, use the following commands:
```shell
k9s info
# Will produces something like this
# ____ __.________
# | |/ _/ __ \______
# | < \____ / ___/
# | | \ / /\___ \
# |____|__ \ /____//____ >
# \/ \/
#
# Configuration: /Users/fernand/.k9s/config.yml
# Logs: /var/folders/8c/hh6rqbgs5nx_c_8k9_17ghfh0000gn/T/k9s-fernand.log
# Screen Dumps: /var/folders/8c/hh6rqbgs5nx_c_8k9_17ghfh0000gn/T/k9s-screens-fernand
# To view k9s logs
tail -f /var/folders/8c/hh6rqbgs5nx_c_8k9_17ghfh0000gn/T/k9s-fernand.log
# Start K9s in debug mode
k9s -l debug
```
## Key Bindings
K9s uses aliases to navigate most K8s resources.
| Action | Command | Comment |
|---------------------------------------------------------------|-----------------------|-------------------------------------------------------------|
| Show active keyboard mnemonics and help | `?` | |
| Show all available resource alias | `ctrl-a` | |
| To bail out of K9s | `:q`, `ctrl-c` | |
| View a Kubernetes resource using singular/plural or shortname | `:`po⏎ | accepts singular, plural, shortname or alias ie pod or pods |
| View a Kubernetes resource in a given namespace | `:`alias namespace⏎ | |
| Filter out a resource view given a filter | `/`filter⏎ | |
| Filter resource view by labels | `/`-l label-selector⏎ | |
| Fuzzy find a resource given a filter | `/`-f filter⏎ | |
| Bails out of view/command/filter mode | `<esc>` | |
| Key mapping to describe, view, edit, view logs,... | `d`,`v`, `e`, `l`,... | |
| To view and switch to another Kubernetes context | `:`ctx⏎ | |
| To view and switch to another Kubernetes context | `:`ctx context-name⏎ | |
| To view and switch to another Kubernetes namespace | `:`ns⏎ | |
| To view all saved resources | `:`screendump or sd⏎ | |
| To delete a resource (TAB and ENTER to confirm) | `ctrl-d` | |
| To kill a resource (no confirmation dialog!) | `ctrl-k` | |
| Launch pulses view | `:`pulses or pu⏎ | |
| Launch XRay view | `:`xray RESOURCE [NAMESPACE]⏎ | RESOURCE can be one of po, svc, dp, rs, sts, ds, NAMESPACE is optional |
---
## Screenshots
@ -129,6 +202,7 @@ K9s is available on Linux, macOS and Windows platforms.
## Demo Videos/Recordings
* [K9s v0.18.0](https://www.youtube.com/watch?v=zMnD5e53yRw)
* [K9s v0.17.0](https://www.youtube.com/watch?v=7S33CNLAofk&feature=youtu.be)
* [K9s Pulses](https://asciinema.org/a/UbXKPal6IWpTaVAjBBFmizcGN)
* [K9s v0.15.1](https://youtu.be/7Fx4XQ2ftpM)
@ -139,46 +213,6 @@ K9s is available on Linux, macOS and Windows platforms.
---
## The Command Line
```shell
# List all available CLI options
k9s help
# To get info about K9s runtime (logs, configs, etc..)
k9s info
# To run K9s in a given namespace
k9s -n mycoolns
# Start K9s in an existing KubeConfig context
k9s --context coolCtx
# Start K9s in readonly mode - with all modification commands disabled
k9s --readonly
```
## Key Bindings
K9s uses aliases to navigate most K8s resources.
| Command | Result | Example |
|-----------------------------|----------------------------------------------------|----------------------------|
| `:dp`, `:deploy` | View deployments | |
| `:no`, `:nodes` | View nodes | |
| `:svc`, `:service` | View services | |
| `:`alias`<ENTER>` | View a Kubernetes resource aliases | `:po<ENTER>` |
| `?` | Show keyboard shortcuts and help | |
| `Ctrl-a` | Show all available resource alias | select+`<ENTER>` to view |
| `/`filter`ENTER` | Filter out a resource view given a filter | `/bumblebeetuna` |
| `/`-l label-selector`ENTER` | Filter resource view by labels | `/-l app=fred` |
| `<Esc>` | Bails out of view/command/filter mode | |
| `d`,`v`, `e`, `l`,... | Key mapping to describe, view, edit, view logs,... | `d` (describes a resource) |
| `:`ctx`<ENTER>` | To view and switch to another Kubernetes context | `:`+`ctx`+`<ENTER>` |
| `:`ns`<ENTER>` | To view and switch to another Kubernetes namespace | `:`+`ns`+`<ENTER>` |
| `:screendump`, `:sd` | To view all saved resources | |
| `Ctrl-d` | To delete a resource (TAB and ENTER to confirm) | |
| `Ctrl-k` | To kill a resource (no confirmation dialog!) | |
| `:q`, `Ctrl-c` | To bail out of K9s | |
---
## K9s Configuration
K9s keeps its configurations in a .k9s directory in your home directory `$HOME/.k9s/config.yml`.
@ -192,10 +226,14 @@ K9s uses aliases to navigate most K8s resources.
refreshRate: 2
# Indicates whether modification commands like delete/kill/edit are disabled. Default is false
readOnly: false
# Indicates log view maximum buffer size. Default 1k lines.
logBufferSize: 200
# Indicates how many lines of logs to retrieve from the api-server. Default 200 lines.
logRequestSize: 200
# Logs configuration
logger:
# Defines the number of lines to return. Default 100
tail: 200
# Defines the total number of log lines to allow in the view. Default 1000
buffer: 500
# Represents how far to go back in the log timeline in seconds. Default is 5min
sinceSeconds: 300
# Indicates the current kube context. Defaults to current context
currentContext: minikube
# Indicates the current kube cluster. Defaults to current context cluster
@ -275,17 +313,42 @@ Entering the command mode and typing a resource name or alias, could be cumberso
## Plugins
K9s allows you to extend your command line and tooling by defining your very own cluster commands via plugins. K9s looks at `$HOME/.k9s/plugin.yml` to locate all 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:
* Shortcut option represents the key combination a user would type to activate the plugin
* Description will be printed next to the shortcut in the k9s menu
* Scopes defines a collection of resources names/shortnames for the views associated with the plugin. You can specify `all` to provide this shortcut for all views.
* Command represents adhoc commands the plugin runs upon activation
* Background specifies whether or not the command runs in the background
* Args specifies the various arguments that should apply to the command above
K9s does provide additional environment variables for you to customize your plugins arguments. Currently, the available environment variables are as follows:
* `$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
* `$USER` the active user
* `$GROUPS` the active groups
* `$POD` while in a container view
* `$COL-<RESOURCE_COLUMN_NAME>` use a given column name for a viewed resource. Must be prefixed by `COL-`!
### Example
This defines a plugin for viewing logs on a selected pod using `ctrl-l` for shorcut.
```yaml
# $HOME/.k9s/plugin.yml
plugin:
# Defines a plugin to provide a `Ctrl-l` shortcut to tail the logs while in pod view.
# Defines a plugin to provide a `ctrl-l` shorcut to tail the logs while in pod view.
fred:
shortCut: Ctrl-L
description: Pod logs
scopes:
- po
- pods
command: kubectl
background: false
args:
@ -298,28 +361,6 @@ plugin:
- $CONTEXT
```
This defines a plugin for viewing logs on a selected pod using `Ctrl-l` mnemonic while in the pods view.
* Shortcut: the key a user must enter to activate the plugin.
* Command: the shell commands the plugin runs upon activation.
* Scopes: select the resources that can access the plugin command. Defines a collection of resource names/shortnames for which the plugin shortcut will be made available to the user. You can specify `all` to make a plugin available in all views.
* Background: boolean to indicate whether to run the command in the background or not.
* Description: a short description of the command that will be shown in the ui next to the action mnemonic.
* Args: a collection of arguments for the given command.
K9s does provide additional environment variables for you to further customize your plugins. Currently, the available environment variables are as follows:
* `$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
* `$USER` the active user
* `$GROUPS` the active groups
* `$COL-<RESOURCE_COLUMN_NAME>` use a given column name for a viewed resource. Must be prefixed by `COL-`!
> NOTE: This is an experimental feature! Options and layout may change in future K9s releases as this feature solidifies.
---

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 MiB

BIN
assets/k9s_popeye.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

BIN
assets/popeye/report.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 749 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 653 KiB

BIN
assets/shirts/k9s_back.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 448 KiB

BIN
assets/shirts/k9s_front.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 414 KiB

View File

@ -0,0 +1,69 @@
<img src="https://raw.githubusercontent.com/derailed/k9s/master/assets/k9s_small.png" align="right" width="200" height="auto"/>
# Release v0.18.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 consider sponsoring 👆us or 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)
---
## GH Sponsors
Big `ThankYou` to the following folks that I've decided to dig in and give back!! 👏🙏🎊
Thank you for your gesture of kindness and for supporting K9s!!
* [Bob Johnson](https://github.com/bbobjohnson)
* [Poundex](https://github.com/Poundex)
* [thllxb](https://github.com/thllxb)
If you've contributed $25 or more please reach out to me on slack with your earth coordinates so I can send you your K9s swags!
<img src="https://raw.githubusercontent.com/derailed/k9s/master/assets/shirts/k9s_front.png" align="center" width="auto" height="100"/>
<img src="https://raw.githubusercontent.com/derailed/k9s/master/assets/shirts/k9s_back.png" align="center" width="auto" height="100"/>
NOTE: I am one not to pressure folks into giving. However, it does make me sad to see postings out there with clear indications that K9s is being used and yet zero mentions of the web site nor this repo. K9s marketing budget relies entirely on word of mouth and is not pimped out by big corps. So if you publish your work and leverage K9s, please give us a shoutout or at the very least reference this repo or website!
---
## AutoSuggestions
K9s command mode now provides for auto complete. Suggestions are pulled from available kubernetes resources and custom aliases. The command mode supports the following keyboard triggers:
| Key | Description |
|---------------------|------------------------------------------|
| ⬆️ ⬇️ | Navigate up or down thru the suggestions |
| `Ctrl-w`, `Ctrl-u` | Clear out the command |
| `Tab`, `Ctrl-f`, ➡️ | Accept the suggestion |
## Logs Revisited
Breaking Change! This drop changes how logs are viewed and configured. The log view now support for pulling logs based on the log timeline current settings are: all, 1m, 5m, 15m and 1h. The following log configuration is in effect as of this drop:
```yaml
# $HOME/.k9s/config.yml
k9s:
refreshRate: 2
readOnly: false
# NOTE: New logger configuration!
logger:
tail: 200 # Tail the last 100 lines. Default 100
buffer: 5000 # Max number of lines displayed in the view. Default 1000
sinceSeconds: 900 # Displays the last x seconds from the logs timeline. Default 5m
...
```
## Resolved Bugs/Features/PRs
* [Issue #628](https://github.com/derailed/k9s/issues/628)
* [Issue #623](https://github.com/derailed/k9s/issues/623)
* [Issue #622](https://github.com/derailed/k9s/issues/622)
* [Issue #565](https://github.com/derailed/k9s/issues/565)
---
<img src="https://raw.githubusercontent.com/derailed/k9s/master/assets/imhotep_logo.png" width="32" height="auto"/> © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0)

View File

@ -0,0 +1,24 @@
<img src="https://raw.githubusercontent.com/derailed/k9s/master/assets/k9s_small.png" align="right" width="200" height="auto"/>
# Release v0.18.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 consider sponsoring 👆us or 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 #632](https://github.com/derailed/k9s/issues/632)
* [Issue #631](https://github.com/derailed/k9s/issues/631)
---
<img src="https://raw.githubusercontent.com/derailed/k9s/master/assets/imhotep_logo.png" width="32" height="auto"/> © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0)

View File

@ -0,0 +1,73 @@
<img src="https://raw.githubusercontent.com/derailed/k9s/master/assets/k9s_small.png" align="right" width="200" height="auto"/>
# Release v0.19.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 consider sponsoring 👆us or 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)
---
## A Word From Our Sponsors...
It makes me always very happy to hear folks are digging this effort and using K9s daily! If you feel this way please tell us and consider joining our [sponsorship](https://github.com/sponsors/derailed) program.
Big Thank You! to [hornbech](https://github.com/hornbech) for joining our sponsors!
## K8s v1.18.0 Support
As you might have heard, the good Kubernetes folks just dropped some big features in this new release. ATTA Girls/Boys!! We've (painfully) updated K9s to now link with the latest and greatest apis. Likely more work will need to take place here as I am still trying to catch up with the latest enhancements. This is great to see and excellent for all our Kubernetes friends!
## Oh Biffs'em And Buffs'em Popeye!
<img src="https://raw.githubusercontent.com/derailed/k9s/master/assets/k9s_popeye.png" align="center" width="400" height="auto"/>
As you may know, I am the author of [Popeye](https://popeyecli.io) a Kubernetes cluster linter/sanitizer. Popeye scans your clusters live and reports potential issues, things like: referential integrity, misconfiguration, resource usage, etc...
In this drop, we've integrated K9s and Popeye to produce what I believe is a killer combo. Not only can you manage/observe your cluster resources in the wild, but you can now assert that your resources are indeed cool and potentially get ride of dead weights that might add up to your monthly cloud service bills. How cool is that?
In order to run your sanitization and produce reports, you can enter a new command `:popeye`. Once your cluster sanitization is complete, you can use familiar keyboard shortcuts to sort columms and view the sanitization reports by pressing `enter` on a given resource linter. Popeye also supports a configuration file namely `spinach.yml`, this file provides for customizing what resources get scanned as well as setting different severity levels to your own company policies. Please read the Popeye docs on how to customize your reports. The spinach.yml file will be read from K9s home directory `$HOME/.k9s/MY_CLUSTER_CONTEXT_NAME_spinach.yml`
NOTE! This is very much still experimental, so you may experience some disturbances in the force! And remember PRs are always open ;)
<img src="https://raw.githubusercontent.com/derailed/k9s/master/assets/popeye/sanitizers.png" align="center" width="400" height="auto"/>
<img src="https://raw.githubusercontent.com/derailed/k9s/master/assets/popeye/report.png" align="center" width="400" height="auto"/>
## Command History Support
K9s now supports for command history. Entering command mode via `:` you can now up/down arrow to navigate thru your command history. Pression `tab` or `ctrl-e` or `->` will activate the selected command upon `enter`.
## K9s Icons
Some terminals often don't offer icon support. In this release there is a new option `noIcons` available to enable/disable K9s icons. By default this option is set `false`. You can now set your icon preference in the K9s config file as follows:
```yaml
# $HOME/.k9s/config.yml
k9s:
refreshRate: 2
headless: false
readOnly: false
noIcons: true # Enable/Disable K9s icons display.
```
## Videos!
* [video v0.19.0](https://www.youtube.com/watch?v=kj-WverKZ24)
* [video v0.18.0](https://www.youtube.com/watch?v=zMnD5e53yRw)
## Resolved Bugs/Features/PRs
* [Issue #647](https://github.com/derailed/k9s/issues/647)
* [Issue #645](https://github.com/derailed/k9s/issues/645)
* [Issue #640](https://github.com/derailed/k9s/issues/640)
* [Issue #639](https://github.com/derailed/k9s/issues/639)
* [Issue #635](https://github.com/derailed/k9s/issues/635)
* [Issue #634](https://github.com/derailed/k9s/issues/634) Thank you!! [David Němec](https://github.com/davidnemec)
* [Issue #626](https://github.com/derailed/k9s/issues/626)
---
<img src="https://raw.githubusercontent.com/derailed/k9s/master/assets/imhotep_logo.png" width="32" height="auto"/> © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0)

View File

@ -0,0 +1,30 @@
<img src="https://raw.githubusercontent.com/derailed/k9s/master/assets/k9s_small.png" align="right" width="200" height="auto"/>
# Release v0.19.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 consider sponsoring 👆us or 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)
---
## A Word From Our Sponsors...
It makes me always very happy to hear folks are digging this effort and using K9s daily! If you feel this way please tell us and consider joining our [sponsorship](https://github.com/sponsors/derailed) program.
Big Thank You! to [Azar](https://github.com/azarudeena) for joining our sponsors!
Maintenance Release!
## Resolved Bugs/Features/PRs
* [Issue #649](https://github.com/derailed/k9s/issues/649)
* [PR #638](https://github.com/derailed/k9s/pull/638) Thank you! [Shang Yuanchun](https://github.com/ideal)
---
<img src="https://raw.githubusercontent.com/derailed/k9s/master/assets/imhotep_logo.png" width="32" height="auto"/> © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0)

View File

@ -0,0 +1,36 @@
<img src="https://raw.githubusercontent.com/derailed/k9s/master/assets/k9s_small.png" align="right" width="200" height="auto"/>
# Release v0.19.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 consider sponsoring 👆us or 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)
---
## A Word From Our Sponsors...
It makes me always very happy to hear folks are digging this effort and using K9s daily! If you feel this way please tell us and consider joining our [sponsorship](https://github.com/sponsors/derailed) program.
Big Thank You! to the following folks for joining our program:
* [Nick Hobart](https://github.com/nwhobart)
* [Shopeonarope](https://github.com/shopeonarope)
Maintenance Release!
NOTE! During K9s update to support the latest version of Kubernetes (v1.18), K9s Helm charts support took one for the team ;( At this time Helm as yet to be released k8s v1.18 support. We will track for updates and enable this feature once HelmV3 releases it.
## Resolved Bugs/Features/PRs
* [Issue #665](https://github.com/derailed/k9s/issues/665)
* [Issue #662](https://github.com/derailed/k9s/issues/662)
* [PR #660](https://github.com/derailed/k9s/pull/660) Thank you! [Tomáš Pospíšek](https://github.com/tpo)
---
<img src="https://raw.githubusercontent.com/derailed/k9s/master/assets/imhotep_logo.png" width="32" height="auto"/> © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0)

View File

@ -0,0 +1,70 @@
<img src="https://raw.githubusercontent.com/derailed/k9s/master/assets/k9s_small.png" align="right" width="200" height="auto"/>
# Release v0.19.3
## 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, consider joining our [sponsorhip program](https://github.com/sponsors/derailed) and/or 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)
---
## Look Who Is Back?
Thanks to the good Helm folks, we're now back on par with the Helm charts support feature. As you may recall, when we've updated to K8s v1.18, the Helm feature took one for the team ;( as they had yet to upgrade to the latest k8s rev. So K9s Helm chart feature is back in this drop! On that note, we've added new aliases to allow you to view your currently installed Helm charts aka `helm` | `hm` | `chart` | `charts`.
## Boh-Bye Windows 386!
As of this drop, I've decided to axe Windows 386 support. Our good friend [Guy Barrette](https://github.com/guybarrette) reported K9s Windows-386 binary is triping his virus scanner. After double checking my installed shas/binaries/dependencies/etc... and performing vulnerabily scans on various win-i386 K9s binaries, I just could not figure out which dependencies are causing the exec to bomb on the scans??
Note: This does not necessary entails that there is a deliberate or malicious intent with the software, but likely a false positive thrown by the Windows virus scanner. This has been [reported](https://golang.org/doc/faq#virus) with other GO binaries on windows as well ;(
That said, I've repeatdly scanned the K9s Windows-x64 and ended up with a clean bill of health on every single scans. So I've decided to drop the 386 windows support for the time being. If that causes you some grief, please land a hand as I am fresh out of ideas...
## And Now For Something A Bit More... Controversial?
There has been a lot of requests for K9s to support shelling directly into cluster nodes. I was resisting the temptation to support this useful feature as depending on your cluster hosting solution, this involved less than ideal solutions. My clusters are provisioned in a multitude of platforms ranging from bare metal to cloud vendor self/managed hosting. I wanted the same experience shelling into an GKE/AWS node as a local KiND cluster node. To this end, we've opted to experimentally support shelling into nodes using the following approach:
1. While in the Node view, we are introducing a new `s` mnemonic to shell into nodes on your cluster.
2. K9s will spin up a `k9s-shell` pod in the `default` namespace with an official Busybox container running in `privileged` mode. This may require extra RBAC and PSPs (This will need Docs!)
3. Once shelled-in, you can poke around any of your nodes.
4. Upon exiting the node shell, K9s will automatically delete the `k9s-shell` pod for that node.
This feature is `OPT-IN` only ie you will need to manually enable the feature gate to make this functionality available to K9s on a specific cluster as follows:
```yaml
# $HOME/.k9s/config.yml
k9s:
...
clusters:
fred:
namespace:
active: "default"
favorites:
- default
view:
active: po
featureGates:
nodeShell: true # Defaults to false!
```
Please let us know if you dig this feature? This very much experimental and we're open to your suggestions. Thank you!
## New Sheriff In Town K9S_EDITOR
As you may know K9s currently uses your `EDITOR` env var to launch an editor while editing a k8s resource or viewing a screen dump or a performance benchmark. So folks voiced they are using some editors that require different CLI args when editing k8s resources vs files on disk. In this drop, we're introducing a new env var `K9S_EDITOR` to provide an affordance to deal with these discrepancies. If you are using emacs/vi/nano no action should be required. K9s will now check for `K9S_EDITOR` existence to view K9s artifacts such as screen_dumps. K9s still honors `KUBE_EDITOR` or `EDITOR` for K8s resource edits. K9s will fallback to the `EDITOR` env var if `K9S_EDITOR` is not set.
## Resolved Bugs/Features/PRs
* [Issue #669](https://github.com/derailed/k9s/issues/669)
* [Issue #677](https://github.com/derailed/k9s/issues/677)
* [Issue #673](https://github.com/derailed/k9s/issues/673)
* [Issue #671](https://github.com/derailed/k9s/issues/671)
* [Issue #670](https://github.com/derailed/k9s/issues/670)
---
<img src="https://raw.githubusercontent.com/derailed/k9s/master/assets/imhotep_logo.png" width="32" height="auto"/> © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0)

View File

@ -74,7 +74,7 @@ func run(cmd *cobra.Command, args []string) {
log.Error().Msg(string(debug.Stack()))
printLogo(color.Red)
fmt.Printf("%s", color.Colorize("Boom!! ", color.Red))
fmt.Println(color.Colorize(fmt.Sprintf("%v.", err), color.White))
fmt.Println(color.Colorize(fmt.Sprintf("%v.", err), color.LightGray))
}
}()

View File

@ -41,7 +41,7 @@ func printVersion(short bool) {
func printTuple(fmat, section, value string, outputColor color.Paint) {
if outputColor != -1 {
fmt.Printf(fmat, color.Colorize(section+":", outputColor), color.Colorize(value, color.White))
fmt.Printf(fmat, color.Colorize(section+":", outputColor), color.Colorize(value, color.LightGray))
return
}
fmt.Printf(fmat, section, value)

64
go.mod
View File

@ -2,65 +2,37 @@ module github.com/derailed/k9s
go 1.13
replace (
github.com/docker/docker => github.com/docker/docker v1.4.2-0.20181221150755-2cb26cfe9cbf
k8s.io/api => k8s.io/api v0.0.0-20190918155943-95b840bb6a1f
k8s.io/apiextensions-apiserver => k8s.io/apiextensions-apiserver v0.0.0-20190918161926-8f644eb6e783
k8s.io/apimachinery => k8s.io/apimachinery v0.0.0-20190913080033-27d36303b655
k8s.io/apiserver => k8s.io/apiserver v0.0.0-20190918160949-bfa5e2e684ad
k8s.io/cli-runtime => k8s.io/cli-runtime v0.0.0-20190918162238-f783a3654da8
k8s.io/client-go => k8s.io/client-go v0.0.0-20190918160344-1fbdaa4c8d90
k8s.io/cloud-provider => k8s.io/cloud-provider v0.0.0-20190918163234-a9c1f33e9fb9
k8s.io/cluster-bootstrap => k8s.io/cluster-bootstrap v0.0.0-20190918163108-da9fdfce26bb
k8s.io/code-generator => k8s.io/code-generator v0.0.0-20190912054826-cd179ad6a269
k8s.io/component-base => k8s.io/component-base v0.0.0-20190918160511-547f6c5d7090
k8s.io/cri-api => k8s.io/cri-api v0.0.0-20190828162817-608eb1dad4ac
k8s.io/csi-translation-lib => k8s.io/csi-translation-lib v0.0.0-20190918163402-db86a8c7bb21
k8s.io/kube-aggregator => k8s.io/kube-aggregator v0.0.0-20190918161219-8c8f079fddc3
k8s.io/kube-controller-manager => k8s.io/kube-controller-manager v0.0.0-20190918162944-7a93a0ddadd8
k8s.io/kube-proxy => k8s.io/kube-proxy v0.0.0-20190918162534-de037b596c1e
k8s.io/kube-scheduler => k8s.io/kube-scheduler v0.0.0-20190918162820-3b5c1246eb18
k8s.io/kubectl => k8s.io/kubectl v0.0.0-20190918164019-21692a0861df
k8s.io/kubelet => k8s.io/kubelet v0.0.0-20190918162654-250a1838aa2c
k8s.io/legacy-cloud-providers => k8s.io/legacy-cloud-providers v0.0.0-20190918163543-cfa506e53441
k8s.io/metrics => k8s.io/metrics v0.0.0-20190918162108-227c654b2546
k8s.io/sample-apiserver => k8s.io/sample-apiserver v0.0.0-20190918161442-d4c9c65c82af
)
require (
github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 // indirect
github.com/atotto/clipboard v0.1.2
github.com/derailed/tview v0.3.7
github.com/derailed/popeye v0.8.1
github.com/derailed/tview v0.3.10
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/fatih/color v1.9.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/mattn/go-runewidth v0.0.8
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/mattn/go-runewidth v0.0.9
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/petergtz/pegomock v2.7.0+incompatible
github.com/rakyll/hey v0.1.3
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
github.com/spf13/cobra v1.0.0
github.com/stretchr/testify v1.5.1
golang.org/x/text v0.3.2
gopkg.in/yaml.v2 v2.2.4
helm.sh/helm/v3 v3.0.2
k8s.io/api v0.0.0
k8s.io/apimachinery v0.0.0
k8s.io/cli-runtime v0.0.0
k8s.io/client-go v0.0.0
gopkg.in/yaml.v2 v2.2.8
helm.sh/helm/v3 v3.2.0
k8s.io/api v0.18.2
k8s.io/apimachinery v0.18.2
k8s.io/cli-runtime v0.18.2
k8s.io/client-go v0.18.2
k8s.io/klog v1.0.0
k8s.io/kubectl v0.0.0
k8s.io/kubernetes v1.16.3
k8s.io/metrics v0.0.0
sigs.k8s.io/yaml v1.1.0
k8s.io/kubectl v0.18.2
k8s.io/metrics v0.18.2
sigs.k8s.io/yaml v1.2.0
vbom.ml/util v0.0.0-20180919145318-efcd4e0f9787
)

709
go.sum

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,7 @@
package client
import (
"context"
"fmt"
"path/filepath"
"strings"
@ -27,22 +28,24 @@ const (
cacheMXKey = "metrics"
cacheMXAPIKey = "metricsAPI"
checkConnTimeout = 10 * time.Second
// CallTimeout represents default api call timeout.
CallTimeout = 5 * time.Second
)
var supportedMetricsAPIVersions = []string{"v1beta1"}
// APIClient represents a Kubernetes api client.
type APIClient struct {
checkClientSet *kubernetes.Clientset
client kubernetes.Interface
dClient dynamic.Interface
nsClient dynamic.NamespaceableResourceInterface
mxsClient *versioned.Clientset
cachedClient *disk.CachedDiscoveryClient
config *Config
mx sync.Mutex
cache *cache.LRUExpireCache
metricsAPI bool
client kubernetes.Interface
dClient dynamic.Interface
nsClient dynamic.NamespaceableResourceInterface
mxsClient *versioned.Clientset
cachedClient *disk.CachedDiscoveryClient
config *Config
mx sync.Mutex
cache *cache.LRUExpireCache
metricsAPI bool
}
// NewTestClient for testing ONLY!!
@ -86,6 +89,33 @@ func makeCacheKey(ns, gvr string, vv []string) string {
return ns + ":" + gvr + "::" + strings.Join(vv, ",")
}
// ActiveCluster returns the current cluster name.
func (a *APIClient) ActiveCluster() string {
c, err := a.config.CurrentClusterName()
if err != nil {
log.Error().Msgf("Unable to located active cluster")
return ""
}
return c
}
// IsActiveNamespace returns true if namespaces matches.
func (a *APIClient) IsActiveNamespace(ns string) bool {
if a.ActiveNamespace() == AllNamespaces {
return true
}
return a.ActiveNamespace() == ns
}
// ActiveNamespace returns the current namespace.
func (a *APIClient) ActiveNamespace() string {
ns, err := a.CurrentNamespaceName()
if err != nil {
return AllNamespaces
}
return ns
}
func (a *APIClient) clearCache() {
for _, k := range a.cache.Keys() {
a.cache.Remove(k)
@ -104,9 +134,12 @@ func (a *APIClient) CanI(ns, gvr string, verbs []string) (auth bool, err error)
}
}
dial, sar := a.DialOrDie().AuthorizationV1().SelfSubjectAccessReviews(), makeSAR(ns, gvr)
ctx, cancel := context.WithTimeout(context.Background(), CallTimeout)
defer cancel()
for _, v := range verbs {
sar.Spec.ResourceAttributes.Verb = v
resp, err := dial.Create(sar)
resp, err := dial.Create(ctx, sar, metav1.CreateOptions{})
if err != nil {
log.Warn().Err(err).Msgf(" Dial Failed!")
a.cache.Add(key, false, cacheExpiry)
@ -135,7 +168,9 @@ func (a *APIClient) ServerVersion() (*version.Info, error) {
// ValidNamespaces returns all available namespaces.
func (a *APIClient) ValidNamespaces() ([]v1.Namespace, error) {
nn, err := a.DialOrDie().CoreV1().Namespaces().List(metav1.ListOptions{})
ctx, cancel := context.WithTimeout(context.Background(), CallTimeout)
defer cancel()
nn, err := a.DialOrDie().CoreV1().Namespaces().List(ctx, metav1.ListOptions{})
if err != nil {
return nil, err
}
@ -143,31 +178,30 @@ func (a *APIClient) ValidNamespaces() ([]v1.Namespace, error) {
}
// CheckConnectivity return true if api server is cool or false otherwise.
// 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
}
if !status {
a.clearCache()
}
}()
if a.checkClientSet == nil {
cfg, err := a.config.flags.ToRESTConfig()
if err != nil {
return
}
cfg.Timeout = checkConnTimeout
cfg, err := a.config.flags.ToRESTConfig()
if err != nil {
return
}
cfg.Timeout = checkConnTimeout
if a.checkClientSet, err = kubernetes.NewForConfig(cfg); err != nil {
log.Error().Err(err).Msgf("Unable to connect to api server")
return
}
client, err := kubernetes.NewForConfig(cfg)
if err != nil {
log.Error().Err(err).Msgf("Unable to connect to api server")
return
}
if _, err := a.checkClientSet.ServerVersion(); err == nil {
if _, err := client.ServerVersion(); err == nil {
a.reset()
status = true
} else {
log.Error().Err(err).Msgf("K9s can't connect to cluster")
@ -198,8 +232,12 @@ func (a *APIClient) HasMetrics() bool {
a.cache.Add(cacheMXKey, flag, cacheExpiry)
return flag
}
if _, err := dial.MetricsV1beta1().NodeMetricses().List(metav1.ListOptions{Limit: 1}); err == nil {
ctx, cancel := context.WithTimeout(context.Background(), CallTimeout)
defer cancel()
if _, err := dial.MetricsV1beta1().NodeMetricses().List(ctx, metav1.ListOptions{Limit: 1}); err == nil {
flag = true
} else {
log.Error().Err(err).Msgf("List metrics failed")
}
a.cache.Add(cacheMXKey, flag, cacheExpiry)
@ -214,8 +252,9 @@ func (a *APIClient) DialOrDie() kubernetes.Interface {
var err error
if a.client, err = kubernetes.NewForConfig(a.RestConfigOrDie()); err != nil {
log.Fatal().Err(err).Msgf("Unable to connect to api server")
log.Panic().Err(err).Msgf("Unable to connect to api server")
}
return a.client
}
@ -223,7 +262,7 @@ func (a *APIClient) DialOrDie() kubernetes.Interface {
func (a *APIClient) RestConfigOrDie() *restclient.Config {
cfg, err := a.config.RESTConfig()
if err != nil {
log.Fatal().Err(err).Msgf("Unable to connect to api server")
log.Panic().Err(err).Msgf("Unable to connect to api server")
}
return cfg
}
@ -303,6 +342,7 @@ func (a *APIClient) reset() {
a.mx.Lock()
defer a.mx.Unlock()
a.config.reset()
a.cache = cache.NewLRUExpireCache(cacheSize)
a.client, a.dClient, a.nsClient, a.mxsClient = nil, nil, nil, nil
a.cachedClient = nil
@ -324,6 +364,7 @@ func (a *APIClient) supportsMetricsResources() (supported bool) {
apiGroups, err := a.CachedDiscoveryOrDie().ServerGroups()
if err != nil {
log.Debug().Msgf("Unable to access servergroups %#v", err)
return
}
for _, grp := range apiGroups.Groups {

View File

@ -319,8 +319,6 @@ func (c *Config) ensureConfig() {
if c.clientConfig != nil {
return
}
log.Debug().Msg("Loading raw config from flags...")
c.clientConfig = c.flags.ToRawKubeConfigLoader()
}

View File

@ -1,6 +1,7 @@
package client
import (
"context"
"fmt"
"math"
"time"
@ -79,9 +80,9 @@ func (m *MetricsServer) ClusterLoad(nos *v1.NodeList, nmx *mv1beta1.NodeMetricsL
tmem += mx.AllocatableMEM
teph += mx.AllocatableEphemeral
}
mx.PercCPU, mx.PercMEM, mx.PercEphemeral = ToPercentage(ccpu, tcpu),
ToPercentage(cmem, tmem),
ToPercentage(ceph, teph)
mx.PercCPU = ToPercentage(ccpu, tcpu)
mx.PercMEM = ToPercentage(cmem, tmem)
mx.PercEphemeral = ToPercentage(ceph, teph)
return nil
}
@ -129,7 +130,7 @@ func (m *MetricsServer) NodesMetrics(nodes *v1.NodeList, metrics *mv1beta1.NodeM
}
// FetchNodesMetrics return all metrics for nodes.
func (m *MetricsServer) FetchNodesMetrics() (*mv1beta1.NodeMetricsList, error) {
func (m *MetricsServer) FetchNodesMetrics(ctx context.Context) (*mv1beta1.NodeMetricsList, error) {
const msg = "user is not authorized to list node metrics"
mx := new(mv1beta1.NodeMetricsList)
@ -150,7 +151,7 @@ func (m *MetricsServer) FetchNodesMetrics() (*mv1beta1.NodeMetricsList, error) {
if err != nil {
return mx, err
}
mxList, err := client.MetricsV1beta1().NodeMetricses().List(metav1.ListOptions{})
mxList, err := client.MetricsV1beta1().NodeMetricses().List(ctx, metav1.ListOptions{})
if err != nil {
return mx, err
}
@ -160,7 +161,7 @@ func (m *MetricsServer) FetchNodesMetrics() (*mv1beta1.NodeMetricsList, error) {
}
// FetchPodsMetrics return all metrics for pods in a given namespace.
func (m *MetricsServer) FetchPodsMetrics(ns string) (*mv1beta1.PodMetricsList, error) {
func (m *MetricsServer) FetchPodsMetrics(ctx context.Context, ns string) (*mv1beta1.PodMetricsList, error) {
mx := new(mv1beta1.PodMetricsList)
const msg = "user is not authorized to list pods metrics"
@ -184,7 +185,7 @@ func (m *MetricsServer) FetchPodsMetrics(ns string) (*mv1beta1.PodMetricsList, e
if err != nil {
return mx, err
}
mxList, err := client.MetricsV1beta1().PodMetricses(ns).List(metav1.ListOptions{})
mxList, err := client.MetricsV1beta1().PodMetricses(ns).List(ctx, metav1.ListOptions{})
if err != nil {
return mx, err
}
@ -194,7 +195,7 @@ func (m *MetricsServer) FetchPodsMetrics(ns string) (*mv1beta1.PodMetricsList, e
}
// FetchPodMetrics return all metrics for pods in a given namespace.
func (m *MetricsServer) FetchPodMetrics(fqn string) (*mv1beta1.PodMetrics, error) {
func (m *MetricsServer) FetchPodMetrics(ctx context.Context, fqn string) (*mv1beta1.PodMetrics, error) {
var mx *mv1beta1.PodMetrics
const msg = "user is not authorized to list pod metrics"
@ -218,7 +219,7 @@ func (m *MetricsServer) FetchPodMetrics(fqn string) (*mv1beta1.PodMetrics, error
if err != nil {
return mx, err
}
mx, err = client.MetricsV1beta1().PodMetricses(ns).Get(n, metav1.GetOptions{})
mx, err = client.MetricsV1beta1().PodMetricses(ns).Get(ctx, n, metav1.GetOptions{})
if err != nil {
return mx, err
}

View File

@ -101,6 +101,15 @@ type Connection interface {
// CheckConnectivity checks if api server connection is happy or not.
CheckConnectivity() bool
// ActiveCluster returns the current cluster name.
ActiveCluster() string
// ActiveNamespace returns the current namespace.
ActiveNamespace() string
// IsActiveNamespace checks if given ns is active.
IsActiveNamespace(string) bool
}
// CurrentMetrics tracks current cpu/mem.

View File

@ -4,20 +4,23 @@ import (
"fmt"
)
// ColorFmt colorize a string with ansi colors.
const ColorFmt = "\x1b[%dm%s\x1b[0m"
// Paint describes a terminal color.
type Paint int
// Defines basic ANSI colors.
const (
Black Paint = iota + 30
Red
Green
Yellow
Blue
Magenta
Cyan
White
DarkGray = 90
Black Paint = iota + 30 // 30
Red // 31
Green // 32
Yellow // 33
Blue // 34
Magenta // 35
Cyan // 36
LightGray // 37
DarkGray = 90
Bold = 1
)
@ -25,7 +28,7 @@ const (
// Colorize returns an ASCII colored string based on given color.
func Colorize(s string, c Paint) string {
if c == 0 {
c = White
return s
}
return fmt.Sprintf("\x1b[%dm%s\x1b[0m", c, s)
return fmt.Sprintf(ColorFmt, c, s)
}

View File

@ -1,26 +1,27 @@
package color
package color_test
import (
"testing"
"github.com/derailed/k9s/internal/color"
"github.com/stretchr/testify/assert"
)
func TestColorize(t *testing.T) {
uu := map[string]struct {
s string
c Paint
c color.Paint
e string
}{
"white": {"blee", White, "\x1b[37mblee\x1b[0m"},
"black": {"blee", Black, "\x1b[30mblee\x1b[0m"},
"default": {"blee", 0, "\x1b[37mblee\x1b[0m"},
"white": {"blee", color.LightGray, "\x1b[37mblee\x1b[0m"},
"black": {"blee", color.Black, "\x1b[30mblee\x1b[0m"},
"default": {"blee", 0, "blee"},
}
for k := range uu {
u := uu[k]
t.Run(k, func(t *testing.T) {
assert.Equal(t, u.e, Colorize(u.s, u.c))
assert.Equal(t, u.e, color.Colorize(u.s, u.c))
})
}
}

View File

@ -31,6 +31,18 @@ func NewAliases() *Aliases {
}
}
// Keys returns all aliases keys.
func (a *Aliases) Keys() []string {
a.mx.RLock()
defer a.mx.RUnlock()
ss := make([]string, 0, len(a.Alias))
for k := range a.Alias {
ss = append(ss, k)
}
return ss
}
// ShortNames return all shortnames.
func (a *Aliases) ShortNames() ShortNames {
a.mx.RLock()
@ -107,6 +119,13 @@ func (a *Aliases) LoadFileAliases(path string) error {
return nil
}
func (a *Aliases) declare(key string, aliases ...string) {
a.Alias[key] = key
for _, alias := range aliases {
a.Alias[alias] = key
}
}
func (a *Aliases) loadDefaultAliases() {
a.mx.Lock()
defer a.mx.Unlock()
@ -120,49 +139,19 @@ func (a *Aliases) loadDefaultAliases() {
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
a.Alias["pulses"] = pulses
}
a.declare("help", "h", "?")
a.declare("quit", "q", "Q")
a.declare("aliases", "alias", "a")
a.declare("popeye", "pop")
a.declare("helm", "charts", "chart", "hm")
a.declare("contexts", "context", "ctx")
a.declare("users", "user", "usr")
a.declare("groups", "group", "grp")
a.declare("portforwards", "portforward", "pf")
a.declare("benchmarks", "benchmark", "be")
a.declare("screendumps", "screendump", "sd")
a.declare("pulses", "pulse", "pu", "hz")
a.declare("xrays", "xray", "x")
}
// Save alias to disk.

View File

@ -4,13 +4,18 @@ import "github.com/derailed/k9s/internal/client"
// Cluster tracks K9s cluster configuration.
type Cluster struct {
Namespace *Namespace `yaml:"namespace"`
View *View `yaml:"view"`
Namespace *Namespace `yaml:"namespace"`
View *View `yaml:"view"`
FeatureGates *FeatureGates `yaml:"featureGates"`
}
// NewCluster creates a new cluster configuration.
func NewCluster() *Cluster {
return &Cluster{Namespace: NewNamespace(), View: NewView()}
return &Cluster{
Namespace: NewNamespace(),
View: NewView(),
FeatureGates: NewFeatureGates(),
}
}
// Validate a cluster config.
@ -20,6 +25,10 @@ func (c *Cluster) Validate(conn client.Connection, ks KubeSettings) {
}
c.Namespace.Validate(conn, ks)
if c.FeatureGates == nil {
c.FeatureGates = NewFeatureGates()
}
if c.View == nil {
c.View = NewView()
}

View File

@ -199,6 +199,9 @@ func (c *Config) Load(path string) error {
if cfg.K9s != nil {
c.K9s = cfg.K9s
}
if c.K9s.Logger == nil {
c.K9s.Logger = NewLogger()
}
return nil
}

View File

@ -96,7 +96,8 @@ func TestConfigLoad(t *testing.T) {
assert.Nil(t, cfg.Load("testdata/k9s.yml"))
assert.Equal(t, 2, cfg.K9s.RefreshRate)
assert.Equal(t, 200, cfg.K9s.LogBufferSize)
assert.Equal(t, 2000, cfg.K9s.Logger.BufferSize)
assert.Equal(t, int64(200), cfg.K9s.Logger.TailCount)
assert.Equal(t, "minikube", cfg.K9s.CurrentContext)
assert.Equal(t, "minikube", cfg.K9s.CurrentCluster)
assert.NotNil(t, cfg.K9s.Clusters)
@ -206,8 +207,8 @@ func TestConfigSaveFile(t *testing.T) {
assert.Nil(t, cfg.Load("testdata/k9s.yml"))
cfg.K9s.RefreshRate = 100
cfg.K9s.ReadOnly = true
cfg.K9s.LogBufferSize = 500
cfg.K9s.LogRequestSize = 100
cfg.K9s.Logger.TailCount = 500
cfg.K9s.Logger.BufferSize = 800
cfg.K9s.CurrentContext = "blee"
cfg.K9s.CurrentCluster = "blee"
cfg.Validate()
@ -262,11 +263,16 @@ var expectedConfig = `k9s:
refreshRate: 100
headless: false
readOnly: true
logBufferSize: 500
logRequestSize: 100
noIcons: false
logger:
tail: 500
buffer: 800
sinceSeconds: -1
fullScreenLogs: false
textWrap: false
showTime: false
currentContext: blee
currentCluster: blee
fullScreenLogs: false
clusters:
blee:
namespace:
@ -275,28 +281,30 @@ var expectedConfig = `k9s:
- default
view:
active: po
featureGates:
nodeShell: false
fred:
namespace:
active: default
favorites:
- default
- kube-public
- istio-system
- all
- kube-system
view:
active: po
featureGates:
nodeShell: false
minikube:
namespace:
active: kube-system
favorites:
- default
- kube-public
- istio-system
- all
- kube-system
view:
active: ctx
featureGates:
nodeShell: false
thresholds:
cpu:
critical: 90
@ -310,11 +318,16 @@ var resetConfig = `k9s:
refreshRate: 2
headless: false
readOnly: false
logBufferSize: 200
logRequestSize: 200
noIcons: false
logger:
tail: 200
buffer: 2000
sinceSeconds: -1
fullScreenLogs: false
textWrap: false
showTime: false
currentContext: blee
currentCluster: blee
fullScreenLogs: false
clusters:
blee:
namespace:
@ -323,6 +336,8 @@ var resetConfig = `k9s:
- default
view:
active: po
featureGates:
nodeShell: false
thresholds:
cpu:
critical: 90

View File

@ -0,0 +1,11 @@
package config
// FeatureGates represents K9s opt-in features.
type FeatureGates struct {
NodeShell bool `yaml:"nodeShell"`
}
// NewFeatureGate returns a new feature gate.
func NewFeatureGates() *FeatureGates {
return &FeatureGates{}
}

View File

@ -2,23 +2,17 @@ package config
import "github.com/derailed/k9s/internal/client"
const (
defaultRefreshRate = 2
defaultLogRequestSize = 200
defaultLogBufferSize = 1000
defaultReadOnly = false
)
const defaultRefreshRate = 2
// K9s tracks K9s configuration options.
type K9s struct {
RefreshRate int `yaml:"refreshRate"`
Headless bool `yaml:"headless"`
ReadOnly bool `yaml:"readOnly"`
LogBufferSize int `yaml:"logBufferSize"`
LogRequestSize int `yaml:"logRequestSize"`
NoIcons bool `yaml:"noIcons"`
Logger *Logger `yaml:"logger"`
CurrentContext string `yaml:"currentContext"`
CurrentCluster string `yaml:"currentCluster"`
FullScreenLogs bool `yaml:"fullScreenLogs"`
Clusters map[string]*Cluster `yaml:"clusters,omitempty"`
Thresholds Threshold `yaml:"thresholds"`
manualRefreshRate int
@ -30,12 +24,10 @@ type K9s struct {
// NewK9s create a new K9s configuration.
func NewK9s() *K9s {
return &K9s{
RefreshRate: defaultRefreshRate,
ReadOnly: defaultReadOnly,
LogBufferSize: defaultLogBufferSize,
LogRequestSize: defaultLogRequestSize,
Clusters: make(map[string]*Cluster),
Thresholds: NewThreshold(),
RefreshRate: defaultRefreshRate,
Logger: NewLogger(),
Clusters: make(map[string]*Cluster),
Thresholds: NewThreshold(),
}
}
@ -106,22 +98,15 @@ func (k *K9s) validateDefaults() {
if k.RefreshRate <= 0 {
k.RefreshRate = defaultRefreshRate
}
if k.LogBufferSize <= 0 {
k.LogBufferSize = defaultLogBufferSize
}
if k.LogRequestSize <= 0 {
k.LogRequestSize = defaultLogRequestSize
}
}
func (k *K9s) checkClusters(ks KubeSettings) {
func (k *K9s) checkClusters(c client.Connection, ks KubeSettings) {
cc, err := ks.ClusterNames()
if err != nil {
return
}
for key := range k.Clusters {
k.Clusters[key].Validate(c, ks)
if InList(cc, key) {
continue
}
@ -138,8 +123,13 @@ func (k *K9s) Validate(c client.Connection, ks KubeSettings) {
if k.Clusters == nil {
k.Clusters = map[string]*Cluster{}
}
k.checkClusters(ks)
k.checkClusters(c, ks)
if k.Logger == nil {
k.Logger = NewLogger()
} else {
k.Logger.Validate(c, ks)
}
if k.Thresholds == nil {
k.Thresholds = NewThreshold()
}

View File

@ -22,8 +22,8 @@ func TestK9sValidate(t *testing.T) {
c.Validate(mc, mk)
assert.Equal(t, 2, c.RefreshRate)
assert.Equal(t, 1000, c.LogBufferSize)
assert.Equal(t, 200, c.LogRequestSize)
assert.Equal(t, int64(100), c.Logger.TailCount)
assert.Equal(t, 5000, c.Logger.BufferSize)
assert.Equal(t, "ctx1", c.CurrentContext)
assert.Equal(t, "c1", c.CurrentCluster)
assert.Equal(t, 1, len(c.Clusters))
@ -45,8 +45,8 @@ func TestK9sValidateBlank(t *testing.T) {
c.Validate(mc, mk)
assert.Equal(t, 2, c.RefreshRate)
assert.Equal(t, 1000, c.LogBufferSize)
assert.Equal(t, 200, c.LogRequestSize)
assert.Equal(t, int64(100), c.Logger.TailCount)
assert.Equal(t, 5000, c.Logger.BufferSize)
assert.Equal(t, "ctx1", c.CurrentContext)
assert.Equal(t, "c1", c.CurrentCluster)
assert.Equal(t, 1, len(c.Clusters))

49
internal/config/logger.go Normal file
View File

@ -0,0 +1,49 @@
package config
import (
"github.com/derailed/k9s/internal/client"
)
const (
// DefaultLoggerTailCount tracks default log tail size.
DefaultLoggerTailCount = 100
// MaxLogThreshold sets the max value for log size.
MaxLogThreshold = 5000
// DefaultSinceSeconds tracks default log age.
DefaultSinceSeconds = -1 // all logs
)
// Logger tracks logger options
type Logger struct {
TailCount int64 `yaml:"tail"`
BufferSize int `yaml:"buffer"`
SinceSeconds int64 `yaml:"sinceSeconds"`
FullScreenLogs bool `yaml:"fullScreenLogs"`
TextWrap bool `yaml:"textWrap"`
ShowTime bool `yaml:"showTime"`
}
// NewLogger returns a new instance.
func NewLogger() *Logger {
return &Logger{
TailCount: DefaultLoggerTailCount,
BufferSize: MaxLogThreshold,
SinceSeconds: DefaultSinceSeconds,
}
}
// Validate checks thresholds and make sure we're cool. If not use defaults.
func (l *Logger) Validate(_ client.Connection, _ KubeSettings) {
if l.TailCount <= 0 {
l.TailCount = DefaultLoggerTailCount
}
if l.TailCount > MaxLogThreshold {
l.TailCount = MaxLogThreshold
}
if l.BufferSize <= 0 || l.BufferSize > MaxLogThreshold {
l.BufferSize = MaxLogThreshold
}
if l.SinceSeconds == 0 {
l.SinceSeconds = DefaultSinceSeconds
}
}

View File

@ -0,0 +1,24 @@
package config_test
import (
"testing"
"github.com/derailed/k9s/internal/config"
"github.com/stretchr/testify/assert"
)
func TestNewLogger(t *testing.T) {
l := config.NewLogger()
l.Validate(nil, nil)
assert.Equal(t, int64(100), l.TailCount)
assert.Equal(t, 5000, l.BufferSize)
}
func TestLoggerValidate(t *testing.T) {
var l config.Logger
l.Validate(nil, nil)
assert.Equal(t, int64(100), l.TailCount)
assert.Equal(t, 5000, l.BufferSize)
}

View File

@ -142,6 +142,51 @@ func (mock *MockConnection) HasMetrics() bool {
return ret0
}
func (mock *MockConnection) ActiveCluster() string {
if mock == nil {
panic("mock must not be nil. Use myMock := NewMockConnection().")
}
params := []pegomock.Param{}
result := pegomock.GetGenericMockFrom(mock).Invoke("ActiveCluster", params, []reflect.Type{reflect.TypeOf((*string)(nil)).Elem()})
var ret0 string
if len(result) != 0 {
if result[0] != nil {
ret0 = result[0].(string)
}
}
return ret0
}
func (mock *MockConnection) ActiveNamespace() string {
if mock == nil {
panic("mock must not be nil. Use myMock := NewMockConnection().")
}
params := []pegomock.Param{}
result := pegomock.GetGenericMockFrom(mock).Invoke("ActiveNamespace", params, []reflect.Type{reflect.TypeOf((*string)(nil)).Elem()})
var ret0 string
if len(result) != 0 {
if result[0] != nil {
ret0 = result[0].(string)
}
}
return ret0
}
func (mock *MockConnection) IsActiveNamespace(s string) bool {
if mock == nil {
panic("mock must not be nil. Use myMock := NewMockConnection().")
}
params := []pegomock.Param{}
result := pegomock.GetGenericMockFrom(mock).Invoke("IsActiveNamespace", params, []reflect.Type{reflect.TypeOf((*bool)(nil)).Elem()})
var ret0 bool
if len(result) != 0 {
if result[0] != nil {
ret0 = result[0].(bool)
}
}
return ret0
}
func (mock *MockConnection) IsNamespaced(_param0 string) bool {
if mock == nil {
panic("mock must not be nil. Use myMock := NewMockConnection().")

View File

@ -77,6 +77,13 @@ type (
// Log tracks Log styles.
Log struct {
FgColor Color `yaml:"fgColor"`
BgColor Color `yaml:"bgColor"`
Indicator LogIndicator `yaml:"indicator"`
}
// LogIndicator tracks log view indicator.
LogIndicator struct {
FgColor Color `yaml:"fgColor"`
BgColor Color `yaml:"bgColor"`
}
@ -138,7 +145,6 @@ type (
BgColor Color `yaml:"bgColor"`
CursorColor Color `yaml:"cursorColor"`
GraphicColor Color `yaml:"graphicColor"`
ShowIcons bool `yaml:"showIcons"`
}
// Menu tracks menu styles.
@ -259,15 +265,21 @@ func newStatus() Status {
}
}
// NewLog returns a new log style.
func newLog() Log {
return Log{
FgColor: "lightskyblue",
FgColor: "lightskyblue",
BgColor: "black",
Indicator: newLogIndicator(),
}
}
func newLogIndicator() LogIndicator {
return LogIndicator{
FgColor: "dodgerblue",
BgColor: "black",
}
}
// NewYaml returns a new yaml style.
func newYaml() Yaml {
return Yaml{
KeyColor: "steelblue",
@ -276,7 +288,6 @@ func newYaml() Yaml {
}
}
// NewTitle returns a new title style.
func newTitle() Title {
return Title{
FgColor: "aqua",
@ -287,7 +298,6 @@ func newTitle() Title {
}
}
// NewInfo returns a new info style.
func newInfo() Info {
return Info{
SectionColor: "white",
@ -295,18 +305,15 @@ func newInfo() Info {
}
}
// NewXray returns a new xray style.
func newXray() Xray {
return Xray{
FgColor: "aqua",
BgColor: "black",
CursorColor: "whitesmoke",
GraphicColor: "floralwhite",
ShowIcons: true,
}
}
// NewTable returns a new table style.
func newTable() Table {
return Table{
FgColor: "aqua",
@ -317,7 +324,6 @@ func newTable() Table {
}
}
// NewTableHeader returns a new table header style.
func newTableHeader() TableHeader {
return TableHeader{
FgColor: "white",
@ -326,7 +332,6 @@ func newTableHeader() TableHeader {
}
}
// NewCrumb returns a new crumbs style.
func newCrumb() Crumb {
return Crumb{
FgColor: "black",
@ -335,7 +340,6 @@ func newCrumb() Crumb {
}
}
// NewBorder returns a new border style.
func newBorder() Border {
return Border{
FgColor: "dodgerblue",
@ -343,7 +347,6 @@ func newBorder() Border {
}
}
// NewMenu returns a new menu style.
func newMenu() Menu {
return Menu{
FgColor: "white",
@ -464,6 +467,7 @@ func (s *Styles) Load(path string) error {
func (s *Styles) Update() {
tview.Styles.PrimitiveBackgroundColor = s.BgColor()
tview.Styles.ContrastBackgroundColor = s.BgColor()
tview.Styles.MoreContrastBackgroundColor = s.BgColor()
tview.Styles.PrimaryTextColor = s.FgColor()
tview.Styles.BorderColor = s.K9s.Frame.Border.FgColor.Color()
tview.Styles.FocusColor = s.K9s.Frame.Border.FocusColor.Color()

View File

@ -1,7 +1,8 @@
k9s:
refreshRate: 2
logBufferSize: 200
logRequestSize: 200
logger:
tail: 200
buffer: 2000
currentContext: minikube
currentCluster: minikube
clusters:

View File

@ -2,7 +2,6 @@ package dao
import (
"context"
"errors"
"fmt"
"github.com/derailed/k9s/internal"
@ -13,7 +12,6 @@ import (
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/labels"
"k8s.io/apimachinery/pkg/runtime"
restclient "k8s.io/client-go/rest"
mv1beta1 "k8s.io/metrics/pkg/apis/metrics/v1beta1"
)
@ -39,7 +37,7 @@ func (c *Container) List(ctx context.Context, _ string) ([]runtime.Object, error
err error
)
if withMx, ok := ctx.Value(internal.KeyWithMetrics).(bool); withMx || !ok {
if pmx, err = client.DialMetrics(c.Client()).FetchPodMetrics(fqn); err != nil {
if pmx, err = client.DialMetrics(c.Client()).FetchPodMetrics(ctx, fqn); err != nil {
log.Warn().Err(err).Msgf("No metrics found for pod %q", fqn)
}
}
@ -60,37 +58,12 @@ 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<- []byte, opts LogOptions) error {
fac, ok := ctx.Value(internal.KeyFactory).(Factory)
if !ok {
return errors.New("Expecting an informer")
}
o, err := fac.Get("v1/pods", opts.Path, true, labels.Everything())
if err != nil {
return err
}
func (c *Container) TailLogs(ctx context.Context, logChan LogChan, opts LogOptions) error {
log.Debug().Msgf("CONTAINER-LOGS")
po := Pod{}
po.Init(c.Factory, client.NewGVR("v1/pods"))
var po v1.Pod
if err := runtime.DefaultUnstructuredConverter.FromUnstructured(o.(*unstructured.Unstructured).Object, &po); err != nil {
return err
}
return tailLogs(ctx, c, logChan, opts)
}
// Logs fetch container logs for a given pod and container.
func (c *Container) Logs(path string, opts *v1.PodLogOptions) (*restclient.Request, error) {
ns, _ := client.Namespaced(path)
auth, err := c.Client().CanI(ns, "v1/pods:log", client.GetAccess)
if err != nil {
return nil, err
}
if !auth {
return nil, fmt.Errorf("user is not authorized to view pod logs")
}
ns, n := client.Namespaced(path)
return c.Client().DialOrDie().CoreV1().Pods(ns).GetLogs(n, opts), nil
return po.TailLogs(ctx, logChan, opts)
}
// ----------------------------------------------------------------------------

View File

@ -60,6 +60,9 @@ func (c *conn) SupportsRes(grp string, versions []string) (string, bool, error)
func (c *conn) ServerVersion() (*version.Info, error) { return nil, nil }
func (c *conn) CurrentNamespaceName() (string, error) { return "", nil }
func (c *conn) CanI(ns, gvr string, verbs []string) (bool, error) { return true, nil }
func (c *conn) ActiveCluster() string { return "" }
func (c *conn) ActiveNamespace() string { return "" }
func (c *conn) IsActiveNamespace(string) bool { return false }
type podFactory struct{}

View File

@ -1,6 +1,7 @@
package dao
import (
"context"
"fmt"
"github.com/derailed/k9s/internal/client"
@ -33,7 +34,9 @@ func (c *CronJob) Run(path string) error {
}
// BOZO!! Factory resource??
cj, err := c.Client().DialOrDie().BatchV1beta1().CronJobs(ns).Get(n, metav1.GetOptions{})
ctx, cancel := context.WithTimeout(context.Background(), client.CallTimeout)
defer cancel()
cj, err := c.Client().DialOrDie().BatchV1beta1().CronJobs(ns).Get(ctx, n, metav1.GetOptions{})
if err != nil {
return err
}
@ -51,7 +54,7 @@ func (c *CronJob) Run(path string) error {
},
Spec: cj.Spec.JobTemplate.Spec,
}
_, err = c.Client().DialOrDie().BatchV1().Jobs(ns).Create(job)
_, err = c.Client().DialOrDie().BatchV1().Jobs(ns).Create(ctx, job, metav1.CreateOptions{})
return err
}

View File

@ -4,7 +4,6 @@ import (
"github.com/derailed/k9s/internal/client"
"github.com/rs/zerolog/log"
"k8s.io/kubectl/pkg/describe"
"k8s.io/kubectl/pkg/describe/versioned"
)
// Describe describes a resource.
@ -31,7 +30,7 @@ func Describe(c client.Connection, gvr client.GVR, path string) (string, error)
log.Error().Err(err).Msgf("Unable to find mapper for %s %s", gvr, n)
return "", err
}
d, err := versioned.Describer(c.Config().Flags(), mapping)
d, err := describe.Describer(c.Config().Flags(), mapping)
if err != nil {
log.Error().Err(err).Msgf("Unable to find describer for %#v", mapping)
return "", err

View File

@ -35,7 +35,7 @@ func (d *Deployment) IsHappy(dp appsv1.Deployment) bool {
}
// Scale a Deployment.
func (d *Deployment) Scale(path string, replicas int32) error {
func (d *Deployment) Scale(ctx context.Context, path string, replicas int32) error {
ns, n := client.Namespaced(path)
auth, err := d.Client().CanI(ns, "apps/v1/deployments:scale", []string{client.GetVerb, client.UpdateVerb})
if err != nil {
@ -45,18 +45,18 @@ func (d *Deployment) Scale(path string, replicas int32) error {
return fmt.Errorf("user is not authorized to scale a deployment")
}
scale, err := d.Client().DialOrDie().AppsV1().Deployments(ns).GetScale(n, metav1.GetOptions{})
scale, err := d.Client().DialOrDie().AppsV1().Deployments(ns).GetScale(ctx, n, metav1.GetOptions{})
if err != nil {
return err
}
scale.Spec.Replicas = replicas
_, err = d.Client().DialOrDie().AppsV1().Deployments(ns).UpdateScale(n, scale)
_, err = d.Client().DialOrDie().AppsV1().Deployments(ns).UpdateScale(ctx, n, scale, metav1.UpdateOptions{})
return err
}
// Restart a Deployment rollout.
func (d *Deployment) Restart(path string) error {
func (d *Deployment) Restart(ctx context.Context, path string) error {
dp, err := d.Load(d.Factory, path)
if err != nil {
return err
@ -75,12 +75,18 @@ func (d *Deployment) Restart(path string) error {
return err
}
_, err = d.Client().DialOrDie().AppsV1().Deployments(dp.Namespace).Patch(dp.Name, types.StrategicMergePatchType, update)
_, err = d.Client().DialOrDie().AppsV1().Deployments(dp.Namespace).Patch(
ctx,
dp.Name,
types.StrategicMergePatchType,
update,
metav1.PatchOptions{},
)
return err
}
// TailLogs tail logs for all pods represented by this Deployment.
func (d *Deployment) TailLogs(ctx context.Context, c chan<- []byte, opts LogOptions) error {
func (d *Deployment) TailLogs(ctx context.Context, c LogChan, opts LogOptions) error {
dp, err := d.Load(d.Factory, opts.Path)
if err != nil {
return err

View File

@ -38,7 +38,7 @@ func (d *DaemonSet) IsHappy(ds appsv1.DaemonSet) bool {
}
// Restart a DaemonSet rollout.
func (d *DaemonSet) Restart(path string) error {
func (d *DaemonSet) Restart(ctx context.Context, path string) error {
ds, err := d.GetInstance(path)
if err != nil {
return err
@ -56,12 +56,18 @@ func (d *DaemonSet) Restart(path string) error {
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(
ctx,
ds.Name,
types.StrategicMergePatchType,
update,
metav1.PatchOptions{},
)
return err
}
// TailLogs tail logs for all pods represented by this DaemonSet.
func (d *DaemonSet) TailLogs(ctx context.Context, c chan<- []byte, opts LogOptions) error {
func (d *DaemonSet) TailLogs(ctx context.Context, c LogChan, opts LogOptions) error {
ds, err := d.GetInstance(opts.Path)
if err != nil {
return err
@ -74,7 +80,7 @@ func (d *DaemonSet) TailLogs(ctx context.Context, c chan<- []byte, opts LogOptio
return podLogs(ctx, c, ds.Spec.Selector.MatchLabels, opts)
}
func podLogs(ctx context.Context, c chan<- []byte, sel map[string]string, opts LogOptions) error {
func podLogs(ctx context.Context, c LogChan, sel map[string]string, opts LogOptions) error {
f, ok := ctx.Value(internal.KeyFactory).(*watch.Factory)
if !ok {
return errors.New("expecting a context factory")
@ -89,14 +95,11 @@ func podLogs(ctx context.Context, c chan<- []byte, sel map[string]string, opts L
}
ns, _ := client.Namespaced(opts.Path)
oo, err := f.List("v1/pods", ns, false, lsel)
oo, err := f.List("v1/pods", ns, true, lsel)
if err != nil {
return err
}
if len(oo) > 1 {
opts.MultiPods = true
}
opts.MultiPods = true
po := Pod{}
po.Init(f, client.NewGVR("v1/pods"))

View File

@ -39,9 +39,9 @@ func (g *Generic) List(ctx context.Context, ns string) ([]runtime.Object, error)
err error
)
if client.IsClusterScoped(ns) {
ll, err = g.dynClient().List(metav1.ListOptions{LabelSelector: labelSel})
ll, err = g.dynClient().List(ctx, metav1.ListOptions{LabelSelector: labelSel})
} else {
ll, err = g.dynClient().Namespace(ns).List(metav1.ListOptions{LabelSelector: labelSel})
ll, err = g.dynClient().Namespace(ns).List(ctx, metav1.ListOptions{LabelSelector: labelSel})
}
if err != nil {
return nil, err
@ -57,15 +57,15 @@ func (g *Generic) List(ctx context.Context, ns string) ([]runtime.Object, error)
// Get returns a given resource.
func (g *Generic) Get(ctx context.Context, path string) (runtime.Object, error) {
log.Debug().Msgf("GENERIC-GET %q", path)
var opts metav1.GetOptions
ns, n := client.Namespaced(path)
dial := g.dynClient()
if client.IsClusterScoped(ns) {
return dial.Get(n, opts)
return dial.Get(ctx, n, opts)
}
return dial.Namespace(ns).Get(n, opts)
return dial.Namespace(ns).Get(ctx, n, opts)
}
// Describe describes a resource.
@ -111,11 +111,14 @@ func (g *Generic) Delete(path string, cascade, force bool) error {
PropagationPolicy: &p,
GracePeriodSeconds: grace,
}
// BOZO!! Move to caller!
ctx, cancel := context.WithTimeout(context.Background(), client.CallTimeout)
defer cancel()
if client.IsClusterScoped(ns) {
return g.dynClient().Delete(n, &opts)
return g.dynClient().Delete(ctx, n, opts)
}
return g.dynClient().Namespace(ns).Delete(n, &opts)
return g.dynClient().Namespace(ns).Delete(ctx, n, opts)
}
func (g *Generic) dynClient() dynamic.NamespaceableResourceInterface {

View File

@ -13,18 +13,18 @@ import (
)
var (
_ Accessor = (*Chart)(nil)
_ Nuker = (*Chart)(nil)
_ Describer = (*Chart)(nil)
_ Accessor = (*Helm)(nil)
_ Nuker = (*Helm)(nil)
_ Describer = (*Helm)(nil)
)
// Chart represents a helm chart.
type Chart struct {
// Helm represents a helm chart.
type Helm struct {
NonResource
}
// List returns a collection of resources.
func (c *Chart) List(ctx context.Context, ns string) ([]runtime.Object, error) {
func (c *Helm) List(ctx context.Context, ns string) ([]runtime.Object, error) {
cfg, err := c.EnsureHelmConfig(ns)
if err != nil {
return nil, err
@ -37,14 +37,14 @@ func (c *Chart) List(ctx context.Context, ns string) ([]runtime.Object, error) {
oo := make([]runtime.Object, 0, len(rr))
for _, r := range rr {
oo = append(oo, render.ChartRes{Release: r})
oo = append(oo, render.HelmRes{Release: r})
}
return oo, nil
}
// Get returns a resource.
func (c *Chart) Get(_ context.Context, path string) (runtime.Object, error) {
func (c *Helm) Get(_ context.Context, path string) (runtime.Object, error) {
ns, n := client.Namespaced(path)
cfg, err := c.EnsureHelmConfig(ns)
if err != nil {
@ -55,11 +55,11 @@ func (c *Chart) Get(_ context.Context, path string) (runtime.Object, error) {
return nil, err
}
return render.ChartRes{Release: resp}, nil
return render.HelmRes{Release: resp}, nil
}
// Describe returns the chart notes.
func (c *Chart) Describe(path string) (string, error) {
func (c *Helm) Describe(path string) (string, error) {
ns, n := client.Namespaced(path)
cfg, err := c.EnsureHelmConfig(ns)
if err != nil {
@ -74,7 +74,7 @@ func (c *Chart) Describe(path string) (string, error) {
}
// ToYAML returns the chart manifest.
func (c *Chart) ToYAML(path string) (string, error) {
func (c *Helm) ToYAML(path string) (string, error) {
ns, n := client.Namespaced(path)
cfg, err := c.EnsureHelmConfig(ns)
if err != nil {
@ -88,8 +88,8 @@ func (c *Chart) ToYAML(path string) (string, error) {
return resp.Manifest, nil
}
// Delete uninstall a Chart.
func (c *Chart) Delete(path string, cascade, force bool) error {
// Delete uninstall a Helm.
func (c *Helm) Delete(path string, cascade, force bool) error {
ns, n := client.Namespaced(path)
cfg, err := c.EnsureHelmConfig(ns)
if err != nil {
@ -109,7 +109,7 @@ func (c *Chart) Delete(path string, cascade, force bool) error {
}
// EnsureHelmConfig return a new configuration.
func (c *Chart) EnsureHelmConfig(ns string) (*action.Configuration, error) {
func (c *Helm) EnsureHelmConfig(ns string) (*action.Configuration, error) {
cfg := new(action.Configuration)
flags := c.Client().Config().Flags()
if err := cfg.Init(flags, ns, os.Getenv("HELM_DRIVER"), helmLogger); err != nil {

View File

@ -12,6 +12,14 @@ import (
"k8s.io/cli-runtime/pkg/printers"
)
// IsFuzzySelector checks if filter is fuzzy or not.
func IsFuzzySelector(s string) bool {
if s == "" {
return false
}
return fuzzyRx.MatchString(s)
}
func toPerc(v1, v2 float64) float64 {
if v2 == 0 {
return 0

View File

@ -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<- []byte, opts LogOptions) error {
func (j *Job) TailLogs(ctx context.Context, c LogChan, opts LogOptions) error {
o, err := j.Factory.Get(j.gvr.String(), opts.Path, true, labels.Everything())
if err != nil {
return err

182
internal/dao/log_item.go Normal file
View File

@ -0,0 +1,182 @@
package dao
import (
"bytes"
"fmt"
"math/rand"
"regexp"
"strings"
"time"
"github.com/derailed/tview"
"github.com/rs/zerolog/log"
"github.com/sahilm/fuzzy"
)
// LogChan represents a channel for logs.
type LogChan chan *LogItem
// LogItem represents a container log line.
type LogItem struct {
Pod, Container, Timestamp string
Bytes []byte
}
// NewLogItem returns a new item.
func NewLogItem(b []byte) *LogItem {
space := []byte(" ")
var l LogItem
cols := bytes.Split(b[:len(b)-1], space)
l.Timestamp = string(cols[0])
l.Bytes = bytes.Join(cols[1:], space)
return &l
}
// NewLogItemFromString returns a new item.
func NewLogItemFromString(s string) *LogItem {
l := LogItem{Bytes: []byte(s)}
l.Timestamp = time.Now().String()
return &l
}
// ID returns pod and or container based id.
func (l *LogItem) ID() string {
if l.Pod != "" {
return l.Pod
}
return l.Container
}
// Info returns pod and container information.
func (l *LogItem) Info() string {
return fmt.Sprintf("%q::%q", l.Pod, l.Container)
}
// IsEmpty checks if the entry is empty.
func (l *LogItem) IsEmpty() bool {
return len(l.Bytes) == 0
}
const colorFmt = "\033[38;5;%dm%s\033[0m"
// colorize me
func colorize(s string, c int) string {
return fmt.Sprintf(colorFmt, c, s)
}
// Render returns a log line as string.
func (l *LogItem) Render(c int, showTime bool) []byte {
bb := make([]byte, 0, 30+len(l.Bytes)+len(l.Info()))
if showTime {
bb = append(bb, colorize(fmt.Sprintf("%-30s ", l.Timestamp), 106)...)
}
if l.Pod != "" {
bb = append(bb, []byte(colorize(l.Pod, c))...)
bb = append(bb, ':')
}
if l.Container != "" {
bb = append(bb, []byte(colorize(l.Container, c))...)
bb = append(bb, ' ')
}
bb = append(bb, []byte(tview.Escape(string(l.Bytes)))...)
return bb
}
func colorFor(n string) int {
var sum int
for _, r := range n {
sum += int(r)
}
c := sum % 256
if c == 0 {
c = 207 + rand.Intn(10)
}
return c
}
// ----------------------------------------------------------------------------
// LogItems represents a collection of log items.
type LogItems []*LogItem
// Lines returns a collection of log lines.
func (l LogItems) Lines() []string {
ll := make([]string, len(l))
for i, item := range l {
ll[i] = string(item.Render(0, false))
}
return ll
}
// Render returns logs as a collection of strings.
func (l LogItems) Render(showTime bool, ll [][]byte) {
colors := map[string]int{}
for i, item := range l {
info := item.ID()
c, ok := colors[item.ID()]
if !ok {
c = colorFor(info)
colors[info] = c
}
ll[i] = item.Render(c, showTime)
}
}
// DumpDebug for debuging
func (l LogItems) DumpDebug(m string) {
fmt.Println(m + strings.Repeat("-", 50))
for i, line := range l {
fmt.Println(i, string(line.Bytes))
}
}
// Filter filters out log items based on given filter.
func (l LogItems) Filter(q string) ([]int, error) {
if q == "" {
return nil, nil
}
if IsFuzzySelector(q) {
return l.fuzzyFilter(strings.TrimSpace(q[2:])), nil
}
indexes, err := l.filterLogs(q)
if err != nil {
log.Error().Err(err).Msgf("Logs filter failed")
return nil, err
}
return indexes, nil
}
var fuzzyRx = regexp.MustCompile(`\A\-f`)
func (l LogItems) fuzzyFilter(q string) []int {
q = strings.TrimSpace(q)
matches := make([]int, 0, len(l))
mm := fuzzy.Find(q, l.Lines())
for _, m := range mm {
matches = append(matches, m.Index)
}
return matches
}
func (l LogItems) filterLogs(q string) ([]int, error) {
rx, err := regexp.Compile(`(?i)` + q)
if err != nil {
return nil, err
}
matches := make([]int, 0, len(l))
for i, line := range l.Lines() {
if rx.MatchString(line) {
matches = append(matches, i)
}
}
return matches, nil
}

View File

@ -0,0 +1,200 @@
package dao_test
import (
"fmt"
"testing"
"github.com/derailed/k9s/internal/client"
"github.com/derailed/k9s/internal/dao"
"github.com/rs/zerolog"
"github.com/stretchr/testify/assert"
)
func init() {
zerolog.SetGlobalLevel(zerolog.FatalLevel)
}
func TestLogItemsFilter(t *testing.T) {
uu := map[string]struct {
q string
opts dao.LogOptions
e []int
err error
}{
"empty": {
opts: dao.LogOptions{},
},
"pod-name": {
q: "blee",
opts: dao.LogOptions{
Path: "fred/blee",
Container: "c1",
},
e: []int{0, 1, 2},
},
"container-name": {
q: "c1",
opts: dao.LogOptions{
Path: "fred/blee",
Container: "c1",
},
e: []int{0, 1, 2},
},
"message": {
q: "zorg",
opts: dao.LogOptions{
Path: "fred/blee",
Container: "c1",
},
e: []int{2},
},
"fuzzy": {
q: "-f zorg",
opts: dao.LogOptions{
Path: "fred/blee",
Container: "c1",
},
e: []int{2},
},
}
for k := range uu {
u := uu[k]
ii := dao.LogItems{
dao.NewLogItem([]byte(fmt.Sprintf("%s %s\n", "2018-12-14T10:36:43.326972-07:00", "Testing 1,2,3..."))),
dao.NewLogItemFromString("Bumble bee tuna"),
dao.NewLogItemFromString("Jean Batiste Emmanuel Zorg"),
}
t.Run(k, func(t *testing.T) {
_, n := client.Namespaced(u.opts.Path)
for _, i := range ii {
i.Pod, i.Container = n, u.opts.Container
}
res, err := ii.Filter(u.q)
assert.Equal(t, u.err, err)
if err == nil {
assert.Equal(t, u.e, res)
}
})
}
}
func TestLogItemsRender(t *testing.T) {
uu := map[string]struct {
opts dao.LogOptions
e string
}{
"empty": {
opts: dao.LogOptions{},
e: "Testing 1,2,3...",
},
"container": {
opts: dao.LogOptions{
Container: "fred",
},
e: "\x1b[38;5;161mfred\x1b[0m Testing 1,2,3...",
},
"pod": {
opts: dao.LogOptions{
Path: "blee/fred",
Container: "blee",
},
e: "\x1b[38;5;161mfred\x1b[0m:\x1b[38;5;161mblee\x1b[0m Testing 1,2,3...",
},
"full": {
opts: dao.LogOptions{
Path: "blee/fred",
Container: "blee",
ShowTimestamp: true,
},
e: "\x1b[38;5;106m2018-12-14T10:36:43.326972-07:00 \x1b[0m\x1b[38;5;161mfred\x1b[0m:\x1b[38;5;161mblee\x1b[0m Testing 1,2,3...",
},
}
s := []byte(fmt.Sprintf("%s %s\n", "2018-12-14T10:36:43.326972-07:00", "Testing 1,2,3..."))
ii := dao.LogItems{dao.NewLogItem(s)}
for k := range uu {
u := uu[k]
_, n := client.Namespaced(u.opts.Path)
ii[0].Pod, ii[0].Container = n, u.opts.Container
t.Run(k, func(t *testing.T) {
res := make([][]byte, 1)
ii.Render(u.opts.ShowTimestamp, res)
assert.Equal(t, u.e, string(res[0]))
})
}
}
func TestLogItemEmpty(t *testing.T) {
uu := map[string]struct {
s string
e bool
}{
"empty": {s: "", e: true},
"full": {s: "Testing 1,2,3..."},
}
for k := range uu {
u := uu[k]
t.Run(k, func(t *testing.T) {
i := dao.NewLogItemFromString(u.s)
assert.Equal(t, u.e, i.IsEmpty())
})
}
}
func TestLogItemRender(t *testing.T) {
uu := map[string]struct {
opts dao.LogOptions
e string
}{
"empty": {
opts: dao.LogOptions{},
e: "Testing 1,2,3...",
},
"container": {
opts: dao.LogOptions{
Container: "fred",
},
e: "\x1b[38;5;0mfred\x1b[0m Testing 1,2,3...",
},
"pod": {
opts: dao.LogOptions{
Path: "blee/fred",
Container: "blee",
},
e: "\x1b[38;5;0mfred\x1b[0m:\x1b[38;5;0mblee\x1b[0m Testing 1,2,3...",
},
"full": {
opts: dao.LogOptions{
Path: "blee/fred",
Container: "blee",
ShowTimestamp: true,
},
e: "\x1b[38;5;106m2018-12-14T10:36:43.326972-07:00 \x1b[0m\x1b[38;5;0mfred\x1b[0m:\x1b[38;5;0mblee\x1b[0m Testing 1,2,3...",
},
}
s := []byte(fmt.Sprintf("%s %s\n", "2018-12-14T10:36:43.326972-07:00", "Testing 1,2,3..."))
for k := range uu {
u := uu[k]
t.Run(k, func(t *testing.T) {
i := dao.NewLogItem(s)
_, n := client.Namespaced(u.opts.Path)
i.Pod, i.Container = n, u.opts.Container
assert.Equal(t, u.e, string(i.Render(0, u.opts.ShowTimestamp)))
})
}
}
func BenchmarkLogItemRender(b *testing.B) {
s := []byte(fmt.Sprintf("%s %s\n", "2018-12-14T10:36:43.326972-07:00", "Testing 1,2,3..."))
i := dao.NewLogItem(s)
i.Pod, i.Container = "fred", "blee"
b.ResetTimer()
b.ReportAllocs()
for n := 0; n < b.N; n++ {
i.Render(0, true)
}
}

View File

@ -1,10 +1,13 @@
package dao
import (
"fmt"
"strings"
"time"
"github.com/derailed/k9s/internal/client"
"github.com/derailed/k9s/internal/color"
v1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// LogOptions represent logger options.
@ -12,11 +15,18 @@ type LogOptions struct {
Path string
Container string
Lines int64
Color color.Paint
Previous bool
SingleContainer bool
MultiPods bool
ShowTimestamp bool
SinceTime string
SinceSeconds int64
In, Out string
}
// Info returns the option pod and container info.
func (o LogOptions) Info() string {
return fmt.Sprintf("%q::%q", o.Path, o.Container)
}
// HasContainer checks if a container is present.
@ -24,6 +34,33 @@ func (o LogOptions) HasContainer() bool {
return o.Container != ""
}
// ToPodLogOptions returns pod log options.
func (o LogOptions) ToPodLogOptions() *v1.PodLogOptions {
opts := v1.PodLogOptions{
Follow: true,
Timestamps: true,
Container: o.Container,
Previous: o.Previous,
TailLines: &o.Lines,
}
if o.SinceSeconds < 0 {
return &opts
}
if o.SinceSeconds != 0 {
opts.SinceSeconds = &o.SinceSeconds
return &opts
}
if o.SinceTime == "" {
return &opts
}
if t, err := time.Parse(time.RFC3339, o.SinceTime); err == nil {
opts.SinceTime = &metav1.Time{Time: t.Add(time.Second)}
}
return &opts
}
// FixedSizeName returns a normalize fixed size pod name if possible.
func (o LogOptions) FixedSizeName() string {
_, n := client.Namespaced(o.Path)
@ -39,35 +76,19 @@ func (o LogOptions) FixedSizeName() string {
return Truncate(strings.Join(s, "-"), 15) + "-" + tokens[len(tokens)-1]
}
func colorize(c color.Paint, txt string) string {
if c == 0 {
return ""
}
return color.Colorize(txt, c)
}
// DecorateLog add a log header to display po/co information along with the log message.
func (o LogOptions) DecorateLog(bytes []byte) []byte {
func (o LogOptions) DecorateLog(bytes []byte) *LogItem {
item := NewLogItem(bytes)
if len(bytes) == 0 {
return bytes
return item
}
bytes = bytes[:len(bytes)-1]
_, n := client.Namespaced(o.Path)
var prefix []byte
if o.MultiPods {
prefix = []byte(colorize(o.Color, n+":"+o.Container+" "))
_, pod := client.Namespaced(o.Path)
item.Pod, item.Container = pod, o.Container
} else {
item.Container = o.Container
}
if !o.SingleContainer {
prefix = []byte(colorize(o.Color, o.Container+" "))
}
if len(prefix) == 0 {
return bytes
}
return append(prefix, bytes...)
return item
}

View File

@ -34,13 +34,15 @@ type Node struct {
// ToggleCordon toggles cordon/uncordon a node.
func (n *Node) ToggleCordon(path string, cordon bool) error {
o, err := n.Get(context.Background(), path)
log.Debug().Msgf("CORDON %q::%t -- %q", path, cordon, n.gvr.GVK())
o, err := FetchNode(context.Background(), n.Factory, path)
if err != nil {
return err
}
h, err := drain.NewCordonHelperFromRuntimeObject(o, scheme.Scheme, n.gvr.GVK())
if err != nil {
log.Debug().Msgf("BOOM %v", err)
return err
}
@ -50,7 +52,7 @@ func (n *Node) ToggleCordon(path string, cordon bool) error {
}
return fmt.Errorf("node is already uncordoned")
}
err, patchErr := h.PatchOrReplace(n.Factory.Client().DialOrDie())
err, patchErr := h.PatchOrReplace(n.Factory.Client().DialOrDie(), false)
if patchErr != nil {
return patchErr
}
@ -97,8 +99,30 @@ func (n *Node) Drain(path string, opts DrainOptions, w io.Writer) error {
}
// Get returns a node resource.
func (n *Node) Get(_ context.Context, path string) (runtime.Object, error) {
return FetchNode(n.Factory, path)
func (n *Node) Get(ctx context.Context, path string) (runtime.Object, error) {
var (
nmx *mv1beta1.NodeMetricsList
err error
)
if withMx, ok := ctx.Value(internal.KeyWithMetrics).(bool); withMx || !ok {
if nmx, err = client.DialMetrics(n.Client()).FetchNodesMetrics(ctx); err != nil {
log.Warn().Err(err).Msgf("No node metrics")
}
}
no, err := FetchNode(ctx, n.Factory, path)
if err != nil {
return nil, err
}
o, err := runtime.DefaultUnstructuredConverter.ToUnstructured(&no)
if err != nil {
return nil, err
}
return &render.NodeWithMetrics{
Raw: &unstructured.Unstructured{Object: o},
MX: nodeMetricsFor(MetaFQN(no.ObjectMeta), nmx),
}, nil
}
// List returns a collection of node resources.
@ -113,12 +137,12 @@ func (n *Node) List(ctx context.Context, ns string) ([]runtime.Object, error) {
err error
)
if withMx, ok := ctx.Value(internal.KeyWithMetrics).(bool); withMx || !ok {
if nmx, err = client.DialMetrics(n.Client()).FetchNodesMetrics(); err != nil {
if nmx, err = client.DialMetrics(n.Client()).FetchNodesMetrics(ctx); err != nil {
log.Warn().Err(err).Msgf("No node metrics")
}
}
nn, err := FetchNodes(n.Factory, labels)
nn, err := FetchNodes(ctx, n.Factory, labels)
if err != nil {
return nil, err
}
@ -141,7 +165,7 @@ func (n *Node) List(ctx context.Context, ns string) ([]runtime.Object, error) {
// Helpers...
// FetchNode retrieves a node.
func FetchNode(f Factory, path string) (*v1.Node, error) {
func FetchNode(ctx context.Context, f Factory, path string) (*v1.Node, error) {
auth, err := f.Client().CanI("", "v1/nodes", []string{"get"})
if err != nil {
return nil, err
@ -150,11 +174,11 @@ func FetchNode(f Factory, path string) (*v1.Node, error) {
return nil, fmt.Errorf("user is not authorized to list nodes")
}
return f.Client().DialOrDie().CoreV1().Nodes().Get(path, metav1.GetOptions{})
return f.Client().DialOrDie().CoreV1().Nodes().Get(ctx, path, metav1.GetOptions{})
}
// FetchNodes retrieves all nodes.
func FetchNodes(f Factory, labelsSel string) (*v1.NodeList, error) {
func FetchNodes(ctx context.Context, f Factory, labelsSel string) (*v1.NodeList, error) {
auth, err := f.Client().CanI("", "v1/nodes", []string{client.ListVerb})
if err != nil {
return nil, err
@ -163,7 +187,7 @@ func FetchNodes(f Factory, labelsSel string) (*v1.NodeList, error) {
return nil, fmt.Errorf("user is not authorized to list nodes")
}
return f.Client().DialOrDie().CoreV1().Nodes().List(metav1.ListOptions{
return f.Client().DialOrDie().CoreV1().Nodes().List(ctx, metav1.ListOptions{
LabelSelector: labelsSel,
})
}

View File

@ -9,7 +9,6 @@ import (
"github.com/derailed/k9s/internal"
"github.com/derailed/k9s/internal/client"
"github.com/derailed/k9s/internal/color"
"github.com/derailed/k9s/internal/render"
"github.com/derailed/k9s/internal/watch"
"github.com/rs/zerolog/log"
@ -58,7 +57,7 @@ func (p *Pod) Get(ctx context.Context, path string) (runtime.Object, error) {
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 {
if pmx, err = client.DialMetrics(p.Client()).FetchPodMetrics(ctx, path); err != nil {
log.Debug().Err(err).Msgf("No pod metrics")
}
}
@ -85,7 +84,7 @@ func (p *Pod) List(ctx context.Context, ns string) ([]runtime.Object, error) {
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 {
if pmx, err = client.DialMetrics(p.Client()).FetchPodsMetrics(ctx, ns); err != nil {
log.Debug().Err(err).Msgf("No pods metrics")
}
}
@ -171,14 +170,8 @@ func (p *Pod) GetInstance(fqn string) (*v1.Pod, error) {
}
// TailLogs tails a given container logs
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<- []byte, opts LogOptions) error {
func (p *Pod) TailLogs(ctx context.Context, c LogChan, opts LogOptions) error {
log.Debug().Msgf("TAIL-LOGS for %q:%q", opts.Path, opts.Container)
fac, ok := ctx.Value(internal.KeyFactory).(*watch.Factory)
if !ok {
return errors.New("Expecting an informer")
@ -187,68 +180,79 @@ func (p *Pod) logs(ctx context.Context, c chan<- []byte, opts LogOptions) error
if err != nil {
return err
}
var po v1.Pod
if err := runtime.DefaultUnstructuredConverter.FromUnstructured(o.(*unstructured.Unstructured).Object, &po); err != nil {
return err
}
opts.Color = asColor(po.Name)
if opts.HasContainer() {
opts.SingleContainer = true
if err := tailLogs(ctx, p, c, opts); err != nil {
return err
}
return nil
}
if len(po.Spec.InitContainers)+len(po.Spec.Containers) == 1 {
opts.SingleContainer = true
}
var tailed bool
for _, co := range po.Spec.InitContainers {
log.Debug().Msgf("Tailing INIT-CO %q", co.Name)
opts.Container = co.Name
if err := p.TailLogs(ctx, c, opts); err != nil {
return err
}
tailed = true
}
rcos := loggableContainers(po.Status)
for _, co := range po.Spec.Containers {
if in(rcos, co.Name) {
opts.Container = co.Name
if err := p.TailLogs(ctx, c, opts); err != nil {
log.Error().Err(err).Msgf("Getting logs for %s failed", co.Name)
return err
}
log.Debug().Msgf("Tailing CO %q", co.Name)
opts.Container = co.Name
if err := tailLogs(ctx, p, c, opts); err != nil {
return err
}
tailed = true
}
for _, co := range po.Spec.EphemeralContainers {
log.Debug().Msgf("Tailing EPH-CO %q", co.Name)
opts.Container = co.Name
if err := tailLogs(ctx, p, c, opts); err != nil {
return err
}
tailed = true
}
if !tailed {
return fmt.Errorf("no loggable containers found for pod %s", opts.Path)
}
return nil
}
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{
Follow: true,
Timestamps: false,
Container: opts.Container,
Previous: opts.Previous,
TailLines: &opts.Lines,
}
req, err := logger.Logs(opts.Path, &o)
func tailLogs(ctx context.Context, logger Logger, c LogChan, opts LogOptions) error {
log.Debug().Msgf("Tailing logs for %q:%q", opts.Path, opts.Container)
req, err := logger.Logs(opts.Path, opts.ToPodLogOptions())
if err != nil {
return err
}
req.Context(ctx)
// This call will block if nothing is in the stream!!
stream, err := req.Stream()
stream, err := req.Stream(ctx)
if err != nil {
c <- opts.DecorateLog([]byte(err.Error() + "\n"))
log.Error().Err(err).Msgf("Log stream failed for `%s", opts.Path)
return fmt.Errorf("Unable to obtain log stream for %s", opts.Path)
log.Error().Err(err).Msgf("Unable to obtain log stream failed for `%s", opts.Path)
return err
}
go readLogs(stream, c, opts)
return nil
}
func readLogs(stream io.ReadCloser, c chan<- []byte, opts LogOptions) {
func readLogs(stream io.ReadCloser, c LogChan, opts LogOptions) {
defer func() {
log.Debug().Msgf(">>> Closing stream `%s", opts.Path)
log.Debug().Msgf(">>> Closing stream %s", opts.Info())
if err := stream.Close(); err != nil {
log.Error().Err(err).Msg("Cloing stream")
log.Error().Err(err).Msgf("Fail to close stream %s", opts.Info())
}
}()
@ -256,13 +260,13 @@ func readLogs(stream io.ReadCloser, c chan<- []byte, opts LogOptions) {
for {
bytes, err := r.ReadBytes('\n')
if err != nil {
log.Warn().Err(err).Msg("Read error")
if err == io.EOF {
c <- opts.DecorateLog([]byte("<STREAM> closed\n"))
log.Warn().Err(err).Msgf("Stream closed for %s", opts.Info())
c <- opts.DecorateLog([]byte("log stream closed\n"))
return
}
log.Error().Err(err).Msgf("stream reader failed")
c <- opts.DecorateLog([]byte("<STREAM> failed\n"))
log.Warn().Err(err).Msgf("Stream READ error %s", opts.Info())
c <- opts.DecorateLog([]byte("log stream failed\n"))
return
}
c <- opts.DecorateLog(bytes)
@ -328,22 +332,6 @@ func extractFQN(o runtime.Object) string {
return FQN(ns, n)
}
func loggableContainers(s v1.PodStatus) []string {
var rcos []string
for _, c := range s.ContainerStatuses {
rcos = append(rcos, c.Name)
}
return rcos
}
func asColor(n string) color.Paint {
var sum int
for _, r := range n {
sum += int(r)
}
return color.Paint(30 + 2 + sum%6)
}
// Check if string is in a string list.
func in(ll []string, s string) bool {
for _, l := range ll {

136
internal/dao/popeye.go Normal file
View File

@ -0,0 +1,136 @@
package dao
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"time"
"github.com/derailed/k9s/internal"
"github.com/derailed/k9s/internal/client"
cfg "github.com/derailed/k9s/internal/config"
"github.com/derailed/k9s/internal/render"
"github.com/derailed/popeye/pkg"
"github.com/derailed/popeye/pkg/config"
"github.com/derailed/popeye/types"
"github.com/rs/zerolog/log"
"k8s.io/apimachinery/pkg/runtime"
)
var _ Accessor = (*Popeye)(nil)
// Popeye tracks cluster sanitization.
type Popeye struct {
NonResource
}
// NewPopeye returns a new set of aliases.
func NewPopeye(f Factory) *Popeye {
a := Popeye{}
a.Init(f, client.NewGVR("popeye"))
return &a
}
type readWriteCloser struct {
*bytes.Buffer
}
// Close close read stream.
func (readWriteCloser) Close() error {
return nil
}
// List returns a collection of aliases.
func (p *Popeye) List(ctx context.Context, _ string) ([]runtime.Object, error) {
defer func(t time.Time) {
log.Debug().Msgf("Popeye -- Elapsed %v", time.Since(t))
if err := recover(); err != nil {
log.Debug().Msgf("POPEYE DIED!")
}
}(time.Now())
flags := config.NewFlags()
js := "json"
flags.Output = &js
if report, ok := ctx.Value(internal.KeyPath).(string); ok && report != "" {
sections := []string{report}
flags.Sections = &sections
}
spinach := filepath.Join(cfg.K9sHome, "spinach.yml")
if c, err := p.Factory.Client().Config().CurrentContextName(); err == nil {
spinach = filepath.Join(cfg.K9sHome, fmt.Sprintf("%s_spinach.yml", c))
}
if _, err := os.Stat(spinach); err == nil {
flags.Spinach = &spinach
}
popeye, err := pkg.NewPopeye(flags, &log.Logger)
if err != nil {
return nil, err
}
popeye.SetFactory(newPopFactory(p.Factory))
if err = popeye.Init(); err != nil {
return nil, err
}
buff := readWriteCloser{Buffer: bytes.NewBufferString("")}
popeye.SetOutputTarget(buff)
if err = popeye.Sanitize(); err != nil {
log.Debug().Msgf("BOOM %#v", *flags.Sections)
return nil, err
}
var b render.Builder
if err = json.Unmarshal(buff.Bytes(), &b); err != nil {
return nil, err
}
oo := make([]runtime.Object, 0, len(b.Report.Sections))
sort.Sort(b.Report.Sections)
for _, s := range b.Report.Sections {
s.Tally.Count = len(s.Outcome)
if s.Tally.Sum() > 0 {
oo = append(oo, s)
}
}
return oo, nil
}
// Get retrieves a resource.
func (p *Popeye) Get(_ context.Context, _ string) (runtime.Object, error) {
return nil, errors.New("NYI!!")
}
// ----------------------------------------------------------------------------
// Helpers...
type popFactory struct {
Factory
}
var _ types.Factory = (*popFactory)(nil)
func newPopFactory(f Factory) *popFactory {
return &popFactory{Factory: f}
}
func (p *popFactory) Client() types.Connection {
return &popConnection{Connection: p.Factory.Client()}
}
type popConnection struct {
client.Connection
}
var _ types.Connection = (*popConnection)(nil)
func (c *popConnection) Config() types.Config {
return c.Connection.Config()
}

View File

@ -126,7 +126,7 @@ func (p *PortForwarder) Start(path, co string, t client.PortTunnel) (*portforwar
return nil, fmt.Errorf("unable to forward port because pod is not running. Current status=%v", pod.Status.Phase)
}
auth, err = p.Client().CanI(ns, "v1/pods:portforward", []string{client.UpdateVerb})
auth, err = p.Client().CanI(ns, "v1/pods:portforward", []string{client.CreateVerb})
if err != nil {
return nil, err
}

View File

@ -7,6 +7,7 @@ import (
"github.com/derailed/k9s/internal"
"github.com/derailed/k9s/internal/client"
"github.com/derailed/k9s/internal/render"
"github.com/rs/zerolog/log"
rbacv1 "k8s.io/api/rbac/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/labels"
@ -119,6 +120,7 @@ func (r *Rbac) loadRoleBinding(path string) ([]runtime.Object, error) {
}
func (r *Rbac) loadClusterRole(path string) ([]runtime.Object, error) {
log.Debug().Msgf("LOAD-CR %q", path)
o, err := r.Factory.Get(crGVR, path, true, labels.Everything())
if err != nil {
return nil, err

View File

@ -47,8 +47,10 @@ func AccessorFor(f Factory, gvr client.GVR) (Accessor, error) {
client.NewGVR("apps/v1/statefulsets"): &StatefulSet{},
client.NewGVR("batch/v1beta1/cronjobs"): &CronJob{},
client.NewGVR("batch/v1/jobs"): &Job{},
client.NewGVR("charts"): &Chart{},
client.NewGVR("openfaas"): &OpenFaas{},
client.NewGVR("popeye"): &Popeye{},
client.NewGVR("sanitizer"): &Popeye{},
client.NewGVR("helm"): &Helm{},
}
r, ok := m[gvr]
@ -163,6 +165,20 @@ func loadK9s(m ResourceMetas) {
Verbs: []string{},
Categories: []string{"k9s"},
}
m[client.NewGVR("popeye")] = metav1.APIResource{
Name: "popeye",
Kind: "Popeye",
SingularName: "popeye",
Verbs: []string{},
Categories: []string{"k9s"},
}
m[client.NewGVR("sanitizer")] = metav1.APIResource{
Name: "sanitizer",
Kind: "Sanitizer",
SingularName: "sanitizer",
Verbs: []string{},
Categories: []string{"k9s"},
}
m[client.NewGVR("contexts")] = metav1.APIResource{
Name: "contexts",
Kind: "Contexts",
@ -206,9 +222,9 @@ func loadK9s(m ResourceMetas) {
}
func loadHelm(m ResourceMetas) {
m[client.NewGVR("charts")] = metav1.APIResource{
Name: "charts",
Kind: "Charts",
m[client.NewGVR("helm")] = metav1.APIResource{
Name: "helm",
Kind: "Helm",
Namespaced: true,
Verbs: []string{"delete"},
Categories: []string{"helm"},

View File

@ -22,10 +22,12 @@ type Resource struct {
// List returns a collection of resources.
func (r *Resource) List(ctx context.Context, ns string) ([]runtime.Object, error) {
strLabel, ok := ctx.Value(internal.KeyLabels).(string)
strLabel, _ := ctx.Value(internal.KeyLabels).(string)
lsel := labels.Everything()
if sel, err := labels.ConvertSelectorToLabelsMap(strLabel); ok && err == nil {
lsel = sel.AsSelector()
if strLabel != "" {
if sel, err := labels.Parse(strLabel); err == nil {
lsel = sel
}
}
return r.Factory.List(r.gvr.String(), ns, false, lsel)

View File

@ -13,6 +13,7 @@ import (
"k8s.io/apimachinery/pkg/labels"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
cmdutil "k8s.io/kubectl/pkg/cmd/util"
"k8s.io/kubectl/pkg/polymorphichelpers"
)
@ -94,7 +95,7 @@ func (r *ReplicaSet) Rollback(fqn string) error {
return err
}
_, err = rb.Rollback(dp, map[string]string{}, version, false)
_, err = rb.Rollback(dp, map[string]string{}, version, cmdutil.DryRunNone)
if err != nil {
return err
}

View File

@ -35,7 +35,7 @@ func (s *StatefulSet) IsHappy(sts appsv1.StatefulSet) bool {
}
// Scale a StatefulSet.
func (s *StatefulSet) Scale(path string, replicas int32) error {
func (s *StatefulSet) Scale(ctx context.Context, path string, replicas int32) error {
ns, n := client.Namespaced(path)
auth, err := s.Client().CanI(ns, "apps/v1/statefulsets:scale", []string{client.GetVerb, client.UpdateVerb})
if err != nil {
@ -45,18 +45,18 @@ func (s *StatefulSet) Scale(path string, replicas int32) error {
return fmt.Errorf("user is not authorized to scale statefulsets")
}
scale, err := s.Client().DialOrDie().AppsV1().StatefulSets(ns).GetScale(n, metav1.GetOptions{})
scale, err := s.Client().DialOrDie().AppsV1().StatefulSets(ns).GetScale(ctx, n, metav1.GetOptions{})
if err != nil {
return err
}
scale.Spec.Replicas = replicas
_, err = s.Client().DialOrDie().AppsV1().StatefulSets(ns).UpdateScale(n, scale)
_, err = s.Client().DialOrDie().AppsV1().StatefulSets(ns).UpdateScale(ctx, n, scale, metav1.UpdateOptions{})
return err
}
// Restart a StatefulSet rollout.
func (s *StatefulSet) Restart(path string) error {
func (s *StatefulSet) Restart(ctx context.Context, path string) error {
sts, err := s.getStatefulSet(path)
if err != nil {
return err
@ -76,12 +76,18 @@ func (s *StatefulSet) Restart(path string) error {
return err
}
_, err = s.Client().DialOrDie().AppsV1().StatefulSets(sts.Namespace).Patch(sts.Name, types.StrategicMergePatchType, update)
_, err = s.Client().DialOrDie().AppsV1().StatefulSets(sts.Namespace).Patch(
ctx,
sts.Name,
types.StrategicMergePatchType,
update,
metav1.PatchOptions{},
)
return err
}
// TailLogs tail logs for all pods represented by this StatefulSet.
func (s *StatefulSet) TailLogs(ctx context.Context, c chan<- []byte, opts LogOptions) error {
func (s *StatefulSet) TailLogs(ctx context.Context, c LogChan, opts LogOptions) error {
sts, err := s.getStatefulSet(opts.Path)
if err != nil {
return errors.New("expecting StatefulSet resource")

View File

@ -24,7 +24,7 @@ type Service struct {
}
// TailLogs tail logs for all pods represented by this Service.
func (s *Service) TailLogs(ctx context.Context, c chan<- []byte, opts LogOptions) error {
func (s *Service) TailLogs(ctx context.Context, c LogChan, opts LogOptions) error {
svc, err := s.GetInstance(opts.Path)
if err != nil {
return err

View File

@ -21,8 +21,6 @@ type Table struct {
// Get returns a given resource.
func (t *Table) Get(ctx context.Context, path string) (runtime.Object, error) {
ns, n := client.Namespaced(path)
a := fmt.Sprintf(gvFmt, metav1beta1.SchemeGroupVersion.Version, metav1beta1.GroupName)
_, codec := t.codec()
@ -30,18 +28,17 @@ func (t *Table) Get(ctx context.Context, path string) (runtime.Object, error) {
if err != nil {
return nil, err
}
o, err := c.Get().
ns, n := client.Namespaced(path)
req := c.Get().
SetHeader("Accept", a).
Namespace(ns).
Name(n).
Resource(t.gvr.R()).
VersionedParams(&metav1beta1.TableOptions{}, codec).
Do().Get()
if err != nil {
return nil, err
VersionedParams(&metav1beta1.TableOptions{}, codec)
if ns != client.ClusterScope {
req = req.Namespace(ns)
}
return o, nil
return req.Do(ctx).Get()
}
// List all Resources in a given namespace.
@ -63,7 +60,7 @@ func (t *Table) List(ctx context.Context, ns string) ([]runtime.Object, error) {
Namespace(ns).
Resource(t.gvr.R()).
VersionedParams(&metav1.ListOptions{LabelSelector: labelSel}, codec).
Do().Get()
Do(ctx).Get()
if err != nil {
return nil, err
}

View File

@ -93,7 +93,7 @@ type NodeMaintainer interface {
// Loggable represents resources with logs.
type Loggable interface {
// TaiLogs streams resource logs.
TailLogs(ctx context.Context, c chan<- []byte, opts LogOptions) error
TailLogs(ctx context.Context, c LogChan, opts LogOptions) error
}
// Describer describes a resource.
@ -108,7 +108,7 @@ type Describer interface {
// Scalable represents resources that can scale.
type Scalable interface {
// Scale scales a resource up or down.
Scale(path string, replicas int32) error
Scale(ctx context.Context, path string, replicas int32) error
}
// Controller represents a pod controller.
@ -132,7 +132,7 @@ type Switchable interface {
// Restartable represents a restartable resource.
type Restartable interface {
// Restart performs a rollout restart.
Restart(path string) error
Restart(ctx context.Context, path string) error
}
// Runnable represents a runnable resource.

View File

@ -1,6 +1,8 @@
package model
import (
"context"
"github.com/derailed/k9s/internal/client"
"github.com/derailed/k9s/internal/dao"
v1 "k8s.io/api/core/v1"
@ -20,8 +22,8 @@ type (
// MetricsService calls the metrics server for metrics info.
MetricsService interface {
HasMetrics() bool
FetchNodesMetrics() (*mv1beta1.NodeMetricsList, error)
FetchPodsMetrics(ns string) (*mv1beta1.PodMetricsList, error)
FetchNodesMetrics(ctx context.Context) (*mv1beta1.NodeMetricsList, error)
FetchPodsMetrics(ctx context.Context, ns string) (*mv1beta1.PodMetricsList, error)
}
// Cluster represents a kubernetes resource.
@ -77,13 +79,13 @@ func (c *Cluster) UserName() string {
}
// Metrics gathers node level metrics and compute utilization percentages.
func (c *Cluster) Metrics(mx *client.ClusterMetrics) error {
nn, err := dao.FetchNodes(c.factory, "")
func (c *Cluster) Metrics(ctx context.Context, mx *client.ClusterMetrics) error {
nn, err := dao.FetchNodes(ctx, c.factory, "")
if err != nil {
return err
}
nmx, err := c.mx.FetchNodesMetrics()
nmx, err := c.mx.FetchNodesMetrics(ctx)
if err != nil {
return err
}

View File

@ -1,6 +1,8 @@
package model
import (
"context"
"github.com/derailed/k9s/internal/client"
"github.com/derailed/k9s/internal/dao"
)
@ -90,8 +92,10 @@ func (c *ClusterInfo) Refresh() {
data.K9sVer = c.version
data.K8sVer = c.cluster.Version()
ctx, cancel := context.WithTimeout(context.Background(), client.CallTimeout)
defer cancel()
var mx client.ClusterMetrics
if err := c.cluster.Metrics(&mx); err == nil {
if err := c.cluster.Metrics(ctx, &mx); err == nil {
data.Cpu, data.Mem, data.Ephemeral = mx.PercCPU, mx.PercMEM, mx.PercEphemeral
}

168
internal/model/cmd_buff.go Normal file
View File

@ -0,0 +1,168 @@
package model
const maxBuff = 10
const (
// CommandBuffer represents a command buffer.
CommandBuffer BufferKind = 1 << iota
// FilterBuffer represents a filter buffer.
FilterBuffer
)
// BufferKind indicates a buffer type
type BufferKind int8
// BuffWatcher represents a command buffer listener.
type BuffWatcher interface {
// Changed indicates the buffer was changed.
BufferChanged(s string)
// Active indicates the buff activity changed.
BufferActive(state bool, kind BufferKind)
}
// CmdBuff represents user command input.
type CmdBuff struct {
buff []rune
listeners []BuffWatcher
hotKey rune
kind BufferKind
active bool
}
// NewCmdBuff returns a new command buffer.
func NewCmdBuff(key rune, kind BufferKind) *CmdBuff {
return &CmdBuff{
hotKey: key,
kind: kind,
buff: make([]rune, 0, maxBuff),
listeners: []BuffWatcher{},
}
}
// CurrentSuggestion returns the current suggestion.
func (c *CmdBuff) CurrentSuggestion() (string, bool) {
return "", false
}
// NextSuggestion returns the next suggestion.
func (c *CmdBuff) NextSuggestion() (string, bool) {
return "", false
}
// PrevSuggestion returns the prev suggestion.
func (c *CmdBuff) PrevSuggestion() (string, bool) {
return "", false
}
// ClearSuggestions clear out all suggestions.
func (c *CmdBuff) ClearSuggestions() {}
// AutoSuggests returns true if model implements auto suggestions.
func (c *CmdBuff) AutoSuggests() bool {
return false
}
// Suggestions returns suggestions.
func (c *CmdBuff) Suggestions() []string {
return nil
}
// Notify notifies all listener of current suggestions.
func (c *CmdBuff) Notify() {}
// InCmdMode checks if a command exists and the buffer is active.
func (c *CmdBuff) InCmdMode() bool {
return c.active || len(c.buff) > 0
}
// IsActive checks if command buffer is active.
func (c *CmdBuff) IsActive() bool {
return c.active
}
// SetActive toggles cmd buffer active state.
func (c *CmdBuff) SetActive(b bool) {
c.active = b
c.fireActive(c.active)
}
// GetText returns the current text.
func (c *CmdBuff) GetText() string {
return string(c.buff)
}
// SetText initializes the buffer with a command.
func (c *CmdBuff) SetText(cmd string) {
c.buff = []rune(cmd)
c.fireBufferChanged()
}
// Add adds a new charater to the buffer.
func (c *CmdBuff) Add(r rune) {
c.buff = append(c.buff, r)
c.fireBufferChanged()
}
// Delete removes the last character from the buffer.
func (c *CmdBuff) Delete() {
if c.Empty() {
return
}
c.buff = c.buff[:len(c.buff)-1]
c.fireBufferChanged()
}
// ClearText clears out command buffer.
func (c *CmdBuff) ClearText() {
c.buff = make([]rune, 0, maxBuff)
c.fireBufferChanged()
}
// Reset clears out the command buffer and deactivates it.
func (c *CmdBuff) Reset() {
c.ClearText()
c.fireBufferChanged()
c.SetActive(false)
}
// Empty returns true if no cmd, false otherwise.
func (c *CmdBuff) Empty() bool {
return len(c.buff) == 0
}
// ----------------------------------------------------------------------------
// Event Listeners...
// AddListener registers a cmd buffer listener.
func (c *CmdBuff) AddListener(w BuffWatcher) {
c.listeners = append(c.listeners, w)
}
// RemoveListener unregisters a listener.
func (c *CmdBuff) RemoveListener(l BuffWatcher) {
victim := -1
for i, lis := range c.listeners {
if l == lis {
victim = i
break
}
}
if victim == -1 {
return
}
c.listeners = append(c.listeners[:victim], c.listeners[victim+1:]...)
}
func (c *CmdBuff) fireBufferChanged() {
for _, l := range c.listeners {
l.BufferChanged(c.GetText())
}
}
func (c *CmdBuff) fireActive(b bool) {
for _, l := range c.listeners {
l.BufferActive(b, c.kind)
}
}

View File

@ -1,9 +1,9 @@
package ui_test
package model_test
import (
"testing"
"github.com/derailed/k9s/internal/ui"
"github.com/derailed/k9s/internal/model"
"github.com/stretchr/testify/assert"
)
@ -17,7 +17,7 @@ func (l *testListener) BufferChanged(s string) {
l.text = s
}
func (l *testListener) BufferActive(s bool, _ ui.BufferKind) {
func (l *testListener) BufferActive(s bool, _ model.BufferKind) {
if s {
l.act++
return
@ -26,7 +26,7 @@ func (l *testListener) BufferActive(s bool, _ ui.BufferKind) {
}
func TestCmdBuffActivate(t *testing.T) {
b, l := ui.NewCmdBuff('>', ui.CommandBuff), testListener{}
b, l := model.NewCmdBuff('>', model.CommandBuffer), testListener{}
b.AddListener(&l)
b.SetActive(true)
@ -36,7 +36,7 @@ func TestCmdBuffActivate(t *testing.T) {
}
func TestCmdBuffDeactivate(t *testing.T) {
b, l := ui.NewCmdBuff('>', ui.CommandBuff), testListener{}
b, l := model.NewCmdBuff('>', model.CommandBuffer), testListener{}
b.AddListener(&l)
b.SetActive(false)
@ -46,39 +46,39 @@ func TestCmdBuffDeactivate(t *testing.T) {
}
func TestCmdBuffChanged(t *testing.T) {
b, l := ui.NewCmdBuff('>', ui.CommandBuff), testListener{}
b, l := model.NewCmdBuff('>', model.CommandBuffer), testListener{}
b.AddListener(&l)
b.Add('b')
assert.Equal(t, 0, l.act)
assert.Equal(t, 0, l.inact)
assert.Equal(t, "b", l.text)
assert.Equal(t, "b", b.String())
assert.Equal(t, "b", b.GetText())
b.Delete()
assert.Equal(t, 0, l.act)
assert.Equal(t, 0, l.inact)
assert.Equal(t, "", l.text)
assert.Equal(t, "", b.String())
assert.Equal(t, "", b.GetText())
b.Add('c')
b.Clear()
b.ClearText()
assert.Equal(t, 0, l.act)
assert.Equal(t, 0, l.inact)
assert.Equal(t, "", l.text)
assert.Equal(t, "", b.String())
assert.Equal(t, "", b.GetText())
b.Add('c')
b.Reset()
assert.Equal(t, 0, l.act)
assert.Equal(t, 1, l.inact)
assert.Equal(t, "", l.text)
assert.Equal(t, "", b.String())
assert.Equal(t, "", b.GetText())
assert.True(t, b.Empty())
}
func TestCmdBuffAdd(t *testing.T) {
b := ui.NewCmdBuff('>', ui.CommandBuff)
b := model.NewCmdBuff('>', model.CommandBuffer)
uu := []struct {
runes []rune
@ -93,13 +93,13 @@ func TestCmdBuffAdd(t *testing.T) {
for _, r := range u.runes {
b.Add(r)
}
assert.Equal(t, u.cmd, b.String())
assert.Equal(t, u.cmd, b.GetText())
b.Reset()
}
}
func TestCmdBuffDel(t *testing.T) {
b := ui.NewCmdBuff('>', ui.CommandBuff)
b := model.NewCmdBuff('>', model.CommandBuffer)
uu := []struct {
runes []rune
@ -115,13 +115,13 @@ func TestCmdBuffDel(t *testing.T) {
b.Add(r)
}
b.Delete()
assert.Equal(t, u.cmd, b.String())
assert.Equal(t, u.cmd, b.GetText())
b.Reset()
}
}
func TestCmdBuffEmpty(t *testing.T) {
b := ui.NewCmdBuff('>', ui.CommandBuff)
b := model.NewCmdBuff('>', model.CommandBuffer)
uu := []struct {
runes []rune

141
internal/model/fish_buff.go Normal file
View File

@ -0,0 +1,141 @@
package model
import (
"sort"
)
// SuggestionListener listens for suggestions.
type SuggestionListener interface {
BuffWatcher
// SuggestionChanged notifies suggestion changes.
SuggestionChanged(text, sugg string)
}
// SuggestionFunc produces suggestions.
type SuggestionFunc func(text string) sort.StringSlice
// FishBuff represents a suggestion buffer.
type FishBuff struct {
*CmdBuff
suggestionFn SuggestionFunc
suggestion string
suggestions []string
suggestionIndex int
}
// NewFishBuff returns a new command buffer.
func NewFishBuff(key rune, kind BufferKind) *FishBuff {
return &FishBuff{
CmdBuff: NewCmdBuff(key, kind),
suggestionIndex: -1,
}
}
// PrevSuggestion returns the prev suggestion.
func (f *FishBuff) PrevSuggestion() (string, bool) {
if f.suggestionIndex < 0 {
return "", false
}
f.suggestionIndex--
if f.suggestionIndex < 0 {
f.suggestionIndex = len(f.suggestions) - 1
}
return f.suggestions[f.suggestionIndex], true
}
// NextSuggestion returns the next suggestion.
func (f *FishBuff) NextSuggestion() (string, bool) {
if f.suggestionIndex < 0 {
return "", false
}
if f.suggestionIndex >= len(f.suggestions) {
f.suggestionIndex = 0
}
s := f.suggestions[f.suggestionIndex]
f.suggestionIndex++
return s, true
}
// ClearSuggestions clear out all suggestions.
func (f *FishBuff) ClearSuggestions() {
f.suggestion, f.suggestionIndex = "", -1
}
// CurrentSuggestion returns the current suggestion.
func (f *FishBuff) CurrentSuggestion() (string, bool) {
if f.suggestionIndex < 0 {
return "", false
}
if f.suggestionIndex >= len(f.suggestions) {
return "", false
}
return f.suggestions[f.suggestionIndex], true
}
// AutoSuggests returns true if model implements auto suggestions.
func (f *FishBuff) AutoSuggests() bool {
return true
}
// Suggestions returns suggestions.
func (f *FishBuff) Suggestions() []string {
if f.suggestionFn != nil {
return f.suggestionFn(string(f.buff))
}
return nil
}
// SetSuggestionFn sets up suggestions.
func (f *FishBuff) SetSuggestionFn(fn SuggestionFunc) {
f.suggestionFn = fn
}
// Notify publish suggestions to all listeners.
func (f *FishBuff) Notify() {
if f.suggestionFn == nil {
return
}
cc := f.suggestionFn(string(f.buff))
f.fireSuggestionChanged(cc)
}
// Add adds a new charater to the buffer.
func (f *FishBuff) Add(r rune) {
f.CmdBuff.Add(r)
if f.suggestionFn == nil {
return
}
cc := f.suggestionFn(string(f.buff))
f.fireSuggestionChanged(cc)
}
// Delete removes the last character from the buffer.
func (f *FishBuff) Delete() {
f.CmdBuff.Delete()
if f.suggestionFn == nil {
return
}
cc := f.suggestionFn(string(f.buff))
f.fireSuggestionChanged(cc)
}
func (f *FishBuff) fireSuggestionChanged(ss []string) {
f.suggestions, f.suggestionIndex = ss, 0
if len(ss) == 0 {
f.suggestionIndex, f.suggestion = -1, ""
return
}
f.suggestion = ss[f.suggestionIndex]
for _, l := range f.listeners {
if listener, ok := l.(SuggestionListener); ok {
listener.SuggestionChanged(f.GetText(), f.suggestion)
}
}
}

62
internal/model/history.go Normal file
View File

@ -0,0 +1,62 @@
package model
import (
"strings"
)
// MaxHistory tracks max command history
const MaxHistory = 20
// History represents a command history.
type History struct {
commands []string
limit int
}
// NewHistory returns a new instance.
func NewHistory(limit int) *History {
return &History{
limit: limit,
}
}
// List returns the current command history.
func (h *History) List() []string {
return h.commands
}
// Push adds a new item.
func (h *History) Push(c string) {
if c == "" {
return
}
c = strings.ToLower(c)
if i := h.indexOf(c); i != -1 {
return
}
if len(h.commands) < h.limit {
h.commands = append([]string{c}, h.commands...)
return
}
h.commands = append([]string{c}, h.commands[:len(h.commands)-1]...)
}
// Clear clear out the stack using pops.
func (h *History) Clear() {
h.commands = nil
}
// Empty returns true if no history.
func (h *History) Empty() bool {
return len(h.commands) == 0
}
func (h *History) indexOf(s string) int {
for i, c := range h.commands {
if c == s {
return i
}
}
return -1
}

View File

@ -0,0 +1,31 @@
package model_test
import (
"fmt"
"testing"
"github.com/derailed/k9s/internal/model"
"github.com/stretchr/testify/assert"
)
func TestHistory(t *testing.T) {
h := model.NewHistory(3)
for i := 1; i < 5; i++ {
h.Push(fmt.Sprintf("cmd%d", i))
}
assert.Equal(t, []string{"cmd4", "cmd3", "cmd2"}, h.List())
h.Clear()
assert.True(t, h.Empty())
}
func TestHistoryDups(t *testing.T) {
h := model.NewHistory(3)
for i := 1; i < 4; i++ {
h.Push(fmt.Sprintf("cmd%d", i))
}
h.Push("cmd1")
h.Push("")
assert.Equal(t, []string{"cmd3", "cmd2", "cmd1"}, h.List())
}

View File

@ -3,24 +3,20 @@ package model
import (
"context"
"fmt"
"regexp"
"strings"
"sync"
"time"
"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/rs/zerolog/log"
"github.com/sahilm/fuzzy"
)
const logMaxBufferSize = 100
// LogsListener represents a log model listener.
type LogsListener interface {
// LogChanged notifies the model changed.
LogChanged([]string)
LogChanged(dao.LogItems)
// LogCleanred indicates logs are cleared.
LogCleared()
@ -31,29 +27,52 @@ 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
mx sync.RWMutex
filter string
lastSent int
showTimestamp bool
timeOut time.Duration
factory dao.Factory
lines dao.LogItems
listeners []LogsListener
gvr client.GVR
logOptions dao.LogOptions
cancelFn context.CancelFunc
mx sync.RWMutex
filter string
lastSent int
flushTimeout time.Duration
}
// NewLog returns a new model.
func NewLog(gvr client.GVR, opts dao.LogOptions, timeOut time.Duration) *Log {
func NewLog(gvr client.GVR, opts dao.LogOptions, flushTimeout time.Duration) *Log {
return &Log{
gvr: gvr,
logOptions: opts,
lines: nil,
timeOut: timeOut,
gvr: gvr,
logOptions: opts,
lines: nil,
flushTimeout: flushTimeout,
}
}
// LogOptions returns the current log options.
func (l *Log) LogOptions() dao.LogOptions {
return l.logOptions
}
// SinceSeconds returns since seconds option.
func (l *Log) SinceSeconds() int64 {
l.mx.RLock()
defer l.mx.RUnlock()
return l.logOptions.SinceSeconds
}
// SetLogOptions updates logger options.
func (l *Log) SetLogOptions(opts dao.LogOptions) {
l.logOptions = opts
l.Restart()
}
// Configure sets logger configuration.
func (l *Log) Configure(opts *config.Logger) {
l.logOptions.Lines = int64(opts.BufferSize)
l.logOptions.SinceSeconds = opts.SinceSeconds
}
// GetPath returns resource path.
func (l *Log) GetPath() string { return l.logOptions.Path }
@ -69,22 +88,25 @@ func (l *Log) Init(f dao.Factory) {
func (l *Log) Clear() {
l.mx.Lock()
{
l.lines, l.lastSent = []string{}, 0
l.lines, l.lastSent = dao.LogItems{}, 0
}
l.mx.Unlock()
l.fireLogCleared()
}
// ShowTimestamp toggles timestamp on logs.
func (l *Log) ShowTimestamp(b bool) {
l.mx.RLock()
defer l.mx.RUnlock()
l.showTimestamp = b
// Refresh refreshes the logs.
func (l *Log) Refresh() {
l.fireLogCleared()
l.fireLogChanged(l.lines)
}
// Restart restarts the logger.
func (l *Log) Restart() {
l.Clear()
l.Stop()
l.Start()
}
// Start initialize log tailer.
func (l *Log) Start() {
if err := l.load(); err != nil {
@ -103,21 +125,21 @@ func (l *Log) Stop() {
}
// Set sets the log lines (for testing only!)
func (l *Log) Set(lines []string) {
func (l *Log) Set(items dao.LogItems) {
l.mx.Lock()
defer l.mx.Unlock()
l.lines = lines
l.fireLogChanged(lines)
l.lines = items
l.fireLogCleared()
l.fireLogChanged(items)
}
// ClearFilter resets the log filter if any.
func (l *Log) ClearFilter() {
log.Debug().Msgf("CLEARED!!")
l.mx.RLock()
defer l.mx.RUnlock()
l.filter = ""
l.fireLogCleared()
l.fireLogChanged(l.lines)
}
@ -126,14 +148,9 @@ func (l *Log) Filter(q string) error {
l.mx.RLock()
defer l.mx.RUnlock()
log.Debug().Msgf("FILTER!")
l.filter = q
filtered, err := applyFilter(l.filter, l.lines)
if err != nil {
return err
}
l.fireLogCleared()
l.fireLogChanged(filtered)
l.fireLogBuffChanged(l.lines)
return nil
}
@ -143,7 +160,7 @@ func (l *Log) load() error {
ctx = context.WithValue(context.Background(), internal.KeyFactory, l.factory)
ctx, l.cancelFn = context.WithCancel(ctx)
c := make(chan []byte, 10)
c := make(dao.LogChan, 10)
go l.updateLogs(ctx, c)
accessor, err := dao.AccessorFor(l.factory, l.gvr)
@ -155,10 +172,10 @@ func (l *Log) load() error {
return fmt.Errorf("Resource %s is not Loggable", l.gvr)
}
if err := logger.TailLogs(ctx, c, l.logOptions); err != nil {
log.Error().Err(err).Msgf("Tail logs failed")
if l.cancelFn != nil {
l.cancelFn()
}
close(c)
return err
}
@ -166,14 +183,15 @@ func (l *Log) load() error {
}
// Append adds a log line.
func (l *Log) Append(line string) {
if line == "" {
func (l *Log) Append(line *dao.LogItem) {
if line == nil || line.IsEmpty() {
return
}
l.mx.Lock()
defer l.mx.Unlock()
l.logOptions.SinceTime = line.Timestamp
if l.lines == nil {
l.fireLogCleared()
}
@ -200,30 +218,30 @@ func (l *Log) Notify(timedOut bool) {
}
}
func (l *Log) updateLogs(ctx context.Context, c <-chan []byte) {
func (l *Log) updateLogs(ctx context.Context, c dao.LogChan) {
defer func() {
log.Debug().Msgf("updateLogs view bailing out!")
}()
for {
select {
case bytes, ok := <-c:
case item, ok := <-c:
if !ok {
log.Debug().Msgf("Closed channel detected. Bailing out...")
l.Append(string(bytes))
l.Notify(false)
l.Append(item)
l.Notify(true)
return
}
l.Append(string(bytes))
l.Append(item)
var overflow bool
l.mx.RLock()
{
overflow = len(l.lines)-l.lastSent > logMaxBufferSize
overflow = int64(len(l.lines)-l.lastSent) > l.logOptions.Lines
}
l.mx.RUnlock()
if overflow {
l.Notify(true)
}
case <-time.After(l.timeOut):
case <-time.After(l.flushTimeout):
l.Notify(true)
case <-ctx.Done():
return
@ -251,11 +269,11 @@ func (l *Log) RemoveListener(listener LogsListener) {
}
}
func applyFilter(q string, lines []string) ([]string, error) {
func applyFilter(q string, lines dao.LogItems) (dao.LogItems, error) {
if q == "" {
return lines, nil
}
indexes, err := filter(q, lines)
indexes, err := lines.Filter(q)
if err != nil {
return nil, err
}
@ -267,7 +285,7 @@ func applyFilter(q string, lines []string) ([]string, error) {
if len(indexes) == 0 {
return nil, nil
}
filtered := make([]string, 0, len(indexes))
filtered := make(dao.LogItems, 0, len(indexes))
for _, idx := range indexes {
filtered = append(filtered, lines[idx])
}
@ -275,7 +293,7 @@ func applyFilter(q string, lines []string) ([]string, error) {
return filtered, nil
}
func (l *Log) fireLogBuffChanged(lines []string) {
func (l *Log) fireLogBuffChanged(lines dao.LogItems) {
filtered, err := applyFilter(l.filter, lines)
if err != nil {
l.fireLogError(err)
@ -292,7 +310,7 @@ func (l *Log) fireLogError(err error) {
}
}
func (l *Log) fireLogChanged(lines []string) {
func (l *Log) fireLogChanged(lines dao.LogItems) {
for _, lis := range l.listeners {
lis.LogChanged(lines)
}
@ -303,55 +321,3 @@ func (l *Log) fireLogCleared() {
lis.LogCleared()
}
}
// ----------------------------------------------------------------------------
// Helpers...
var fuzzyRx = regexp.MustCompile(`\A\-f`)
func isFuzzySelector(s string) bool {
if s == "" {
return false
}
return fuzzyRx.MatchString(s)
}
func filter(q string, lines []string) ([]int, error) {
if q == "" {
return nil, nil
}
if isFuzzySelector(q) {
return fuzzyFilter(strings.TrimSpace(q[2:]), lines), nil
}
indexes, err := filterLogs(q, lines)
if err != nil {
log.Error().Err(err).Msgf("Logs filter failed")
return nil, err
}
return indexes, nil
}
func fuzzyFilter(q string, lines []string) []int {
matches := make([]int, 0, len(lines))
mm := fuzzy.Find(q, lines)
for _, m := range mm {
matches = append(matches, m.Index)
}
return matches
}
func filterLogs(q string, lines []string) ([]int, error) {
rx, err := regexp.Compile(`(?i)` + q)
if err != nil {
return nil, err
}
matches := make([]int, 0, len(lines))
for i, l := range lines {
if rx.MatchString(l) {
matches = append(matches, i)
}
}
return matches, nil
}

View File

@ -24,9 +24,9 @@ func TestLogFullBuffer(t *testing.T) {
v := newTestView()
m.AddListener(v)
data := make([]string, 0, 2*size)
data := make(dao.LogItems, 0, 2*size)
for i := 0; i < 2*size; i++ {
data = append(data, "line"+strconv.Itoa(i))
data = append(data, dao.NewLogItemFromString("line"+strconv.Itoa(i)))
m.Append(data[i])
}
m.Notify(true)
@ -47,8 +47,8 @@ func TestLogFilter(t *testing.T) {
e: 2,
},
"regexp": {
q: `\Apod-line-[1-3]{1}\z`,
e: 3,
q: `pod-line-[1-3]{1}`,
e: 4,
},
"fuzzy": {
q: `-f po-l1`,
@ -67,21 +67,21 @@ func TestLogFilter(t *testing.T) {
m.AddListener(v)
m.Filter(u.q)
var data []string
var data dao.LogItems
for i := 0; i < size; i++ {
data = append(data, fmt.Sprintf("pod-line-%d", i+1))
data = append(data, dao.NewLogItemFromString(fmt.Sprintf("pod-line-%d", i+1)))
m.Append(data[i])
}
m.Notify(true)
assert.Equal(t, 2, v.dataCalled)
assert.Equal(t, 1, v.dataCalled)
assert.Equal(t, 2, v.clearCalled)
assert.Equal(t, 0, v.errCalled)
assert.Equal(t, u.e, len(v.data))
m.ClearFilter()
assert.Equal(t, 3, v.dataCalled)
assert.Equal(t, 2, v.clearCalled)
assert.Equal(t, 2, v.dataCalled)
assert.Equal(t, 3, v.clearCalled)
assert.Equal(t, 0, v.errCalled)
assert.Equal(t, size, len(v.data))
})
@ -96,7 +96,7 @@ func TestLogStartStop(t *testing.T) {
m.AddListener(v)
m.Start()
data := []string{"line1", "line2"}
data := dao.LogItems{dao.NewLogItemFromString("line1"), dao.NewLogItemFromString("line2")}
for _, d := range data {
m.Append(d)
}
@ -118,7 +118,7 @@ func TestLogClear(t *testing.T) {
v := newTestView()
m.AddListener(v)
data := []string{"line1", "line2"}
data := dao.LogItems{dao.NewLogItemFromString("line1"), dao.NewLogItemFromString("line2")}
for _, d := range data {
m.Append(d)
}
@ -138,11 +138,11 @@ func TestLogBasic(t *testing.T) {
v := newTestView()
m.AddListener(v)
data := []string{"line1", "line2"}
data := dao.LogItems{dao.NewLogItemFromString("line1"), dao.NewLogItemFromString("line2")}
m.Set(data)
assert.Equal(t, 1, v.dataCalled)
assert.Equal(t, 0, v.clearCalled)
assert.Equal(t, 1, v.clearCalled)
assert.Equal(t, 0, v.errCalled)
assert.Equal(t, data, v.data)
}
@ -153,21 +153,25 @@ func TestLogAppend(t *testing.T) {
v := newTestView()
m.AddListener(v)
m.Set([]string{"blah blah"})
assert.Equal(t, []string{"blah blah"}, v.data)
items := dao.LogItems{dao.NewLogItemFromString("blah blah")}
m.Set(items)
assert.Equal(t, items, v.data)
data := []string{"line1", "line2"}
data := dao.LogItems{
dao.NewLogItemFromString("line1"),
dao.NewLogItemFromString("line2"),
}
for _, d := range data {
m.Append(d)
}
assert.Equal(t, 1, v.dataCalled)
assert.Equal(t, []string{"blah blah"}, v.data)
assert.Equal(t, items, v.data)
m.Notify(true)
assert.Equal(t, 2, v.dataCalled)
assert.Equal(t, 0, v.clearCalled)
assert.Equal(t, 1, v.clearCalled)
assert.Equal(t, 0, v.errCalled)
assert.Equal(t, append([]string{"blah blah"}, data...), v.data)
assert.Equal(t, append(items, data...), v.data)
}
func TestLogTimedout(t *testing.T) {
@ -178,15 +182,20 @@ func TestLogTimedout(t *testing.T) {
m.AddListener(v)
m.Filter("line1")
data := []string{"line1", "line2", "line3", "line4"}
data := dao.LogItems{
dao.NewLogItemFromString("line1"),
dao.NewLogItemFromString("line2"),
dao.NewLogItemFromString("line3"),
dao.NewLogItemFromString("line4"),
}
for _, d := range data {
m.Append(d)
}
m.Notify(true)
assert.Equal(t, 2, v.dataCalled)
assert.Equal(t, 1, v.dataCalled)
assert.Equal(t, 2, v.clearCalled)
assert.Equal(t, 0, v.errCalled)
assert.Equal(t, []string{"line1"}, v.data)
assert.Equal(t, dao.LogItems{data[0]}, v.data)
}
// ----------------------------------------------------------------------------
@ -203,7 +212,7 @@ func makeLogOpts(count int) dao.LogOptions {
// ----------------------------------------------------------------------------
type testView struct {
data []string
data dao.LogItems
dataCalled int
clearCalled int
errCalled int
@ -213,13 +222,13 @@ func newTestView() *testView {
return &testView{}
}
func (t *testView) LogChanged(d []string) {
func (t *testView) LogChanged(d dao.LogItems) {
t.data = d
t.dataCalled++
}
func (t *testView) LogCleared() {
t.clearCalled++
t.data = []string{}
t.data = dao.LogItems{}
}
func (t *testView) LogFailed(err error) {
fmt.Println("LogErr", err)

View File

@ -51,7 +51,7 @@ func (h *PulseHealth) List(ctx context.Context, ns string) ([]runtime.Object, er
hh = append(hh, c)
}
mm, err := h.checkMetrics()
mm, err := h.checkMetrics(ctx)
if err != nil {
return hh, nil
}
@ -62,15 +62,15 @@ func (h *PulseHealth) List(ctx context.Context, ns string) ([]runtime.Object, er
return hh, nil
}
func (h *PulseHealth) checkMetrics() (health.Checks, error) {
func (h *PulseHealth) checkMetrics(ctx context.Context) (health.Checks, error) {
dial := client.DialMetrics(h.factory.Client())
nn, err := dao.FetchNodes(h.factory, "")
nn, err := dao.FetchNodes(ctx, h.factory, "")
if err != nil {
return nil, err
}
nmx, err := dial.FetchNodesMetrics()
nmx, err := dial.FetchNodesMetrics(ctx)
if err != nil {
log.Error().Err(err).Msgf("Fetching metrics")
return nil, err

View File

@ -10,9 +10,9 @@ import (
// BOZO!! Break up deps and merge into single registrar
var Registry = map[string]ResourceMeta{
// Custom...
"charts": {
DAO: &dao.Chart{},
Renderer: &render.Chart{},
"helm": {
DAO: &dao.Helm{},
Renderer: &render.Helm{},
},
"pulses": {
DAO: &dao.Pulse{},
@ -62,6 +62,14 @@ var Registry = map[string]ResourceMeta{
DAO: &dao.Alias{},
Renderer: &render.Alias{},
},
"popeye": {
DAO: &dao.Popeye{},
Renderer: &render.Popeye{},
},
"sanitizer": {
DAO: &dao.Popeye{},
TreeRenderer: &xray.Section{},
},
// Core...
"v1/endpoints": {

View File

@ -179,7 +179,7 @@ func (t *Table) Peek() render.TableData {
}
func (t *Table) updater(ctx context.Context) {
defer log.Debug().Msgf("Model canceled -- %q", t.gvr)
defer log.Debug().Msgf("TABLE-MODEL canceled -- %q", t.gvr)
rate := initRefreshRate
for {
@ -290,7 +290,6 @@ func (t *Table) getMeta(ctx context.Context) (ResourceMeta, error) {
func (t *Table) resourceMeta() ResourceMeta {
meta, ok := Registry[t.gvr.String()]
if !ok {
log.Debug().Msgf("Resource %s not found in registry. Going generic!", t.gvr)
meta = ResourceMeta{
DAO: &dao.Table{},
Renderer: &render.Generic{},

View File

@ -4,6 +4,7 @@ import (
"regexp"
"strings"
"github.com/derailed/k9s/internal/dao"
"github.com/sahilm/fuzzy"
)
@ -94,7 +95,7 @@ func (t *Text) filter(q string, lines []string) fuzzy.Matches {
if q == "" {
return nil
}
if isFuzzySelector(q) {
if dao.IsFuzzySelector(q) {
return t.fuzzyFilter(strings.TrimSpace(q[2:]), lines)
}
return t.rxFilter(q, lines)

View File

@ -219,6 +219,7 @@ func (t *Tree) reconcile(ctx context.Context) error {
}
} else {
if err := treeHydrate(ctx, ns, oo, meta.TreeRenderer); err != nil {
return err
}
}
@ -238,7 +239,6 @@ func (t *Tree) reconcile(ctx context.Context) error {
func (t *Tree) resourceMeta() ResourceMeta {
meta, ok := Registry[t.gvr.String()]
if !ok {
log.Debug().Msgf("Resource %s not found in registry. Going generic!", t.gvr)
meta = ResourceMeta{
DAO: &dao.Table{},
Renderer: &render.Generic{},

View File

@ -117,7 +117,6 @@ func (b *Benchmark) Run(cluster string, done func()) {
// this call will block until the benchmark is complete or timesout.
b.worker.Run()
b.worker.Stop()
log.Debug().Msgf("YO!! %t %s", b.canceled, buff)
if len(buff.Bytes()) > 0 {
if err := b.save(cluster, buff); err != nil {
log.Error().Err(err).Msg("Saving Benchmark")

View File

@ -24,6 +24,23 @@ func NewDeltaRow(o, n Row, excludeLast bool) DeltaRow {
return deltas
}
// Labelize returns a new deltaRow based on labels.
func (d DeltaRow) Labelize(cols []int, labelCol int) DeltaRow {
if len(d) == 0 {
return d
}
_, vals := sortLabels(labelize(d[labelCol]))
out := make(DeltaRow, 0, len(cols)+len(vals))
for _, i := range cols {
out = append(out, d[i])
}
for _, v := range vals {
out = append(out, v)
}
return out
}
// Diff returns true if deltas differ or false otherwise.
func (d DeltaRow) Diff(r DeltaRow, ageCol int) bool {
if len(d) != len(r) {
@ -77,9 +94,7 @@ func (d DeltaRow) IsBlank() bool {
// Clone returns a delta copy.
func (d DeltaRow) Clone() DeltaRow {
res := make(DeltaRow, len(d))
for i, f := range d {
res[i] = f
}
copy(res, d)
return res
}

View File

@ -7,6 +7,33 @@ import (
"github.com/stretchr/testify/assert"
)
func TestDeltaLabelize(t *testing.T) {
uu := map[string]struct {
o render.Row
n render.Row
e render.DeltaRow
}{
"same": {
o: render.Row{
Fields: render.Fields{"a", "b", "blee=fred,doh=zorg"},
},
n: render.Row{
Fields: render.Fields{"a", "b", "blee=fred1,doh=zorg"},
},
e: render.DeltaRow{"", "", "fred", "zorg"},
},
}
for k := range uu {
u := uu[k]
t.Run(k, func(t *testing.T) {
d := render.NewDeltaRow(u.o, u.n, false)
d = d.Labelize([]int{0, 1}, 2)
assert.Equal(t, u.e, d)
})
}
}
func TestDeltaCustomize(t *testing.T) {
uu := map[string]struct {
r1, r2 render.Row

View File

@ -70,7 +70,6 @@ func (g *Generic) Render(o interface{}, ns string, r *Row) error {
if !ok {
return fmt.Errorf("expecting row 0 to be a string but got %T", row.Cells[0])
}
r.ID = client.FQN(nns, n)
r.Fields = make(Fields, 0, len(g.Header(ns)))
r.Fields = append(r.Fields, nns)

View File

@ -39,6 +39,20 @@ func (h Header) Clone() Header {
return header
}
// Labelize returns a new Header based on labels.
func (h Header) Labelize(cols []int, labelCol int, rr RowEvents) Header {
header := make(Header, 0, len(cols)+1)
for _, c := range cols {
header = append(header, h[c])
}
cc := rr.ExtractHeaderLabels(labelCol)
for _, c := range cc {
header = append(header, HeaderColumn{Name: c})
}
return header
}
// MapIndices returns a collection of mapped column indices based of the requested columns.
func (h Header) MapIndices(cols []string, wide bool) []int {
ii := make([]int, 0, len(cols))

View File

@ -12,11 +12,11 @@ import (
"k8s.io/apimachinery/pkg/runtime/schema"
)
// Chart renders a helm chart to screen.
type Chart struct{}
// Helm renders a helm chart to screen.
type Helm struct{}
// ColorerFunc colors a resource row.
func (Chart) ColorerFunc() ColorerFunc {
func (Helm) ColorerFunc() ColorerFunc {
return func(ns string, h Header, re RowEvent) tcell.Color {
if !Happy(ns, h, re.Row) {
return ErrColor
@ -27,7 +27,7 @@ func (Chart) ColorerFunc() ColorerFunc {
}
// Header returns a header row.
func (Chart) Header(_ string) Header {
func (Helm) Header(_ string) Header {
return Header{
HeaderColumn{Name: "NAMESPACE"},
HeaderColumn{Name: "NAME"},
@ -41,10 +41,10 @@ func (Chart) Header(_ string) Header {
}
// Render renders a chart to screen.
func (c Chart) Render(o interface{}, ns string, r *Row) error {
h, ok := o.(ChartRes)
func (c Helm) Render(o interface{}, ns string, r *Row) error {
h, ok := o.(HelmRes)
if !ok {
return fmt.Errorf("expected ChartRes, but got %T", o)
return fmt.Errorf("expected HelmRes, but got %T", o)
}
r.ID = client.FQN(h.Release.Namespace, h.Release.Name)
@ -62,7 +62,7 @@ func (c Chart) Render(o interface{}, ns string, r *Row) error {
return nil
}
func (c Chart) diagnose(s string) error {
func (c Helm) diagnose(s string) error {
if s != "deployed" {
return fmt.Errorf("chart is in an invalid state")
}
@ -73,17 +73,17 @@ func (c Chart) diagnose(s string) error {
// ----------------------------------------------------------------------------
// Helpers...
// ChartRes represents an helm chart resource.
type ChartRes struct {
// HelmRes represents an helm chart resource.
type HelmRes struct {
Release *release.Release
}
// GetObjectKind returns a schema object.
func (ChartRes) GetObjectKind() schema.ObjectKind {
func (HelmRes) GetObjectKind() schema.ObjectKind {
return nil
}
// DeepCopyObject returns a container copy.
func (h ChartRes) DeepCopyObject() runtime.Object {
func (h HelmRes) DeepCopyObject() runtime.Object {
return h
}

View File

@ -1,6 +1,7 @@
package render
import (
"regexp"
"sort"
"strconv"
"strings"
@ -15,6 +16,35 @@ import (
"k8s.io/apimachinery/pkg/util/duration"
)
var durationRx = regexp.MustCompile(`\A(\d*d)*?(\d*h)*?(\d*m)*?(\d*s)*?\z`)
func durationToSeconds(duration string) string {
tokens := durationRx.FindAllStringSubmatch(duration, -1)
if len(tokens) == 0 {
return duration
}
if len(tokens[0]) < 5 {
return duration
}
d, h, m, s := tokens[0][1], tokens[0][2], tokens[0][3], tokens[0][4]
var n int
if v, err := strconv.Atoi(strings.Replace(d, "d", "", 1)); err == nil {
n += v * 24 * 60 * 60
}
if v, err := strconv.Atoi(strings.Replace(h, "h", "", 1)); err == nil {
n += v * 60 * 60
}
if v, err := strconv.Atoi(strings.Replace(m, "m", "", 1)); err == nil {
n += v * 60
}
if v, err := strconv.Atoi(strings.Replace(s, "s", "", 1)); err == nil {
n += v
}
return strconv.Itoa(n)
}
// AsThousands prints a number with thousand separator.
func AsThousands(n int64) string {
p := message.NewPrinter(language.English)
@ -267,3 +297,30 @@ func Pad(s string, width int) string {
return s + strings.Repeat(" ", width-len(s))
}
// Converts labels string to map
func labelize(labels string) map[string]string {
ll := strings.Split(labels, ",")
data := make(map[string]string, len(ll))
for _, l := range ll {
tokens := strings.Split(l, "=")
if len(tokens) == 2 {
data[tokens[0]] = tokens[1]
}
}
return data
}
func sortLabels(m map[string]string) (keys, vals []string) {
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
vals = append(vals, m[k])
}
return
}

View File

@ -9,6 +9,70 @@ import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
func TestSortLabels(t *testing.T) {
uu := map[string]struct {
labels string
e [][]string
}{
"simple": {
labels: "a=b,c=d",
e: [][]string{
{"a", "c"},
{"b", "d"},
},
},
}
for k := range uu {
u := uu[k]
t.Run(k, func(t *testing.T) {
hh, vv := sortLabels(labelize(u.labels))
assert.Equal(t, u.e[0], hh)
assert.Equal(t, u.e[1], vv)
})
}
}
func TestLabelize(t *testing.T) {
uu := map[string]struct {
labels string
e map[string]string
}{
"simple": {
labels: "a=b,c=d",
e: map[string]string{"a": "b", "c": "d"},
},
}
for k := range uu {
u := uu[k]
t.Run(k, func(t *testing.T) {
assert.Equal(t, u.e, labelize(u.labels))
})
}
}
func TestDurationToNumber(t *testing.T) {
uu := map[string]struct {
s, e string
}{
"seconds": {s: "22s", e: "22"},
"minutes": {s: "22m", e: "1320"},
"hours": {s: "12h", e: "43200"},
"days": {s: "3d", e: "259200"},
"day_hour": {s: "3d9h", e: "291600"},
"day_hour_minute": {s: "2d22h3m", e: "252180"},
"day_hour_minute_seconds": {s: "2d22h3m50s", e: "252230"},
}
for k := range uu {
u := uu[k]
t.Run(k, func(t *testing.T) {
assert.Equal(t, u.e, durationToSeconds(u.s))
})
}
}
func TestToAge(t *testing.T) {
uu := map[string]struct {
t time.Time

View File

@ -55,7 +55,7 @@ func (p PodDisruptionBudget) Render(o interface{}, ns string, r *Row) error {
pdb.Name,
numbToStr(pdb.Spec.MinAvailable),
numbToStr(pdb.Spec.MaxUnavailable),
strconv.Itoa(int(pdb.Status.PodDisruptionsAllowed)),
strconv.Itoa(int(pdb.Status.DisruptionsAllowed)),
strconv.Itoa(int(pdb.Status.CurrentHealthy)),
strconv.Itoa(int(pdb.Status.DesiredHealthy)),
strconv.Itoa(int(pdb.Status.ExpectedPods)),

View File

@ -13,7 +13,6 @@ import (
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/kubernetes/pkg/util/node"
mv1beta1 "k8s.io/metrics/pkg/apis/metrics/v1beta1"
)
@ -84,8 +83,7 @@ func (p Pod) Render(o interface{}, ns string, r *Row) error {
}
var po v1.Pod
err := runtime.DefaultUnstructuredConverter.FromUnstructured(pwm.Raw.Object, &po)
if err != nil {
if err := runtime.DefaultUnstructuredConverter.FromUnstructured(pwm.Raw.Object, &po); err != nil {
return err
}
@ -262,7 +260,7 @@ func (*Pod) Statuses(ss []v1.ContainerStatus) (cr, ct, rc int) {
func (p *Pod) Phase(po *v1.Pod) string {
status := string(po.Status.Phase)
if po.Status.Reason != "" {
if po.DeletionTimestamp != nil && po.Status.Reason == node.NodeUnreachablePodReason {
if po.DeletionTimestamp != nil && po.Status.Reason == "NodeLost" {
return "Unknown"
}
status = po.Status.Reason

198
internal/render/popeye.go Normal file
View File

@ -0,0 +1,198 @@
package render
import (
"fmt"
"math"
"strconv"
"strings"
"github.com/derailed/popeye/pkg/config"
"github.com/derailed/tview"
"github.com/gdamore/tcell"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
)
// Popeye renders a sanitizer to screen.
type Popeye struct{}
// ColorerFunc colors a resource row.
func (Popeye) ColorerFunc() ColorerFunc {
return func(ns string, h Header, re RowEvent) tcell.Color {
c := DefaultColorer(ns, h, re)
warnCol := h.IndexOf("WARNING", true)
status, _ := strconv.Atoi(strings.TrimSpace(re.Row.Fields[warnCol]))
if status > 0 {
c = tcell.ColorOrange
}
errCol := h.IndexOf("ERROR", true)
status, _ = strconv.Atoi(strings.TrimSpace(re.Row.Fields[errCol]))
if status > 0 {
c = ErrColor
}
return c
}
}
// Header returns a header row.
func (Popeye) Header(ns string) Header {
return Header{
HeaderColumn{Name: "RESOURCE"},
HeaderColumn{Name: "SCORE%", Align: tview.AlignRight},
HeaderColumn{Name: "SCANNED", Align: tview.AlignRight},
HeaderColumn{Name: "OK", Align: tview.AlignRight},
HeaderColumn{Name: "INFO", Align: tview.AlignRight},
HeaderColumn{Name: "WARNING", Align: tview.AlignRight},
HeaderColumn{Name: "ERROR", Align: tview.AlignRight},
}
}
// Render renders a K8s resource to screen.
func (Popeye) Render(o interface{}, ns string, r *Row) error {
s, ok := o.(Section)
if !ok {
return fmt.Errorf("expected Section, but got %T", o)
}
r.ID = s.Title
r.Fields = append(r.Fields,
s.Title,
strconv.Itoa(s.Tally.Score()),
strconv.Itoa(s.Tally.OK+s.Tally.Info+s.Tally.Warning+s.Tally.Error),
strconv.Itoa(s.Tally.OK),
strconv.Itoa(s.Tally.Info),
strconv.Itoa(s.Tally.Warning),
strconv.Itoa(s.Tally.Error),
)
return nil
}
// ----------------------------------------------------------------------------
// Helpers...
type (
// Builder represents a popeye report.
Builder struct {
Report Report `json:"popeye" yaml:"popeye"`
}
// Report represents the output of a sanitization pass.
Report struct {
Score int `json:"score" yaml:"score"`
Grade string `json:"grade" yaml:"grade"`
Sections Sections `json:"sanitizers,omitempty" yaml:"sanitizers,omitempty"`
}
// Sections represents a collection of sections.
Sections []Section
// Section represents a sanitizer pass
Section struct {
Title string `json:"sanitizer" yaml:"sanitizer"`
GVR string `yaml:"gvr" json:"gvr"`
Tally *Tally `json:"tally" yaml:"tally"`
Outcome Outcome `json:"issues,omitempty" yaml:"issues,omitempty"`
}
// Outcome represents a classification of reports outcome.
Outcome map[string]Issues
// Issues represents a collection of issues.
Issues []Issue
// Issue represents a sanitization issue.
Issue struct {
Group string `yaml:"group" json:"group"`
GVR string `yaml:"gvr" json:"gvr"`
Level config.Level `yaml:"level" json:"level"`
Message string `yaml:"message" json:"message"`
}
// Tally tracks a section scores.
Tally struct {
OK, Info, Warning, Error int
Count int
}
)
// Sum sums up tally counts.
func (t *Tally) Sum() int {
return t.OK + t.Info + t.Warning + t.Error
}
// Score returns the overall sections score in percent.
func (t *Tally) Score() int {
oks := t.OK + t.Info
return toPerc(float64(oks), float64(oks+t.Warning+t.Error))
}
func toPerc(v1, v2 float64) int {
if v2 == 0 {
return 0
}
return int(math.Floor((v1 / v2) * 100))
}
// Len returns a section length.
func (s Sections) Len() int {
return len(s)
}
// Swap swaps values.
func (s Sections) Swap(i, j int) {
s[i], s[j] = s[j], s[i]
}
// Less compares section scores.
func (s Sections) Less(i, j int) bool {
t1, t2 := s[i].Tally, s[j].Tally
return t1.Score() < t2.Score()
}
// GetObjectKind returns a schema object.
func (Section) GetObjectKind() schema.ObjectKind {
return nil
}
// DeepCopyObject returns a container copy.
func (s Section) DeepCopyObject() runtime.Object {
return s
}
// MaxSeverity gather the max severity in a collection of issues.
func (s Section) MaxSeverity() config.Level {
max := config.OkLevel
for _, issues := range s.Outcome {
m := issues.MaxSeverity()
if m > max {
max = m
}
}
return max
}
// MaxSeverity gather the max severity in a collection of issues.
func (i Issues) MaxSeverity() config.Level {
max := config.OkLevel
for _, is := range i {
if is.Level > max {
max = is.Level
}
}
return max
}
// CountSeverity counts severity level instances
func (i Issues) CountSeverity(l config.Level) int {
var count int
for _, is := range i {
if is.Level == l {
count++
}
}
return count
}

View File

@ -3,6 +3,7 @@ package render
import (
"reflect"
"sort"
"strconv"
"time"
"vbom.ml/util/sortorder"
@ -56,6 +57,20 @@ func NewRow(size int) Row {
return Row{Fields: make([]string, size)}
}
// Labelize returns a new row based on labels.
func (r Row) Labelize(cols []int, labelCol int, labels []string) Row {
out := NewRow(len(cols) + len(labels))
for _, col := range cols {
out.Fields = append(out.Fields, r.Fields[col])
}
m := labelize(r.Fields[labelCol])
for _, label := range labels {
out.Fields = append(out.Fields, m[label])
}
return out
}
// Customize returns a row subset based on given col indices.
func (r Row) Customize(cols []int) Row {
out := NewRow(len(cols))
@ -160,36 +175,21 @@ func (s RowSorter) Less(i, j int) bool {
// ----------------------------------------------------------------------------
// Helpers...
// Less return true if c1 < c2.
func Less(asc bool, c1, c2 string) bool {
if o, ok := isDurationSort(asc, c1, c2); ok {
return o
func toAgeDuration(dur string) string {
d, err := time.ParseDuration(dur)
if err != nil {
return durationToSeconds(dur)
}
return strconv.Itoa(int(d.Seconds()))
}
// Less return true if c1 < c2.
func Less(asc bool, c1, c2 string) bool {
c1, c2 = toAgeDuration(c1), toAgeDuration(c2)
b := sortorder.NaturalLess(c1, c2)
if asc {
return b
}
return !b
}
func isDurationSort(asc bool, s1, s2 string) (bool, bool) {
d1, ok1 := isDuration(s1)
d2, ok2 := isDuration(s2)
if !ok1 || !ok2 {
return false, false
}
if asc {
return d1 <= d2, true
}
return d1 >= d2, true
}
func isDuration(s string) (time.Duration, bool) {
d, err := time.ParseDuration(s)
if err != nil {
return d, false
}
return d, true
}

View File

@ -3,10 +3,8 @@ package render
import (
"fmt"
"sort"
"time"
"github.com/rs/zerolog/log"
"k8s.io/apimachinery/pkg/util/duration"
)
const (
@ -44,8 +42,8 @@ func NewRowEvent(kind ResEvent, row Row) RowEvent {
}
}
// NewDeltaRowEvent returns a new row event with deltas.
func NewDeltaRowEvent(row Row, delta DeltaRow) RowEvent {
// NewRowEventWithDeltas returns a new row event with deltas.
func NewRowEventWithDeltas(row Row, delta DeltaRow) RowEvent {
return RowEvent{
Kind: EventUpdate,
Row: row,
@ -77,6 +75,20 @@ func (r RowEvent) Customize(cols []int) RowEvent {
}
}
func (r RowEvent) ExtractHeaderLabels(labelCol int) []string {
hh, _ := sortLabels(labelize(r.Row.Fields[labelCol]))
return hh
}
// Labelize returns a new row event based on labels.
func (r RowEvent) Labelize(cols []int, labelCol int, labels []string) RowEvent {
return RowEvent{
Kind: r.Kind,
Deltas: r.Deltas.Labelize(cols, labelCol),
Row: r.Row.Labelize(cols, labelCol, labels),
}
}
// Diff returns true if the row changed.
func (r RowEvent) Diff(re RowEvent, ageCol int) bool {
if r.Kind != re.Kind {
@ -93,6 +105,24 @@ func (r RowEvent) Diff(re RowEvent, ageCol int) bool {
// RowEvents a collection of row events.
type RowEvents []RowEvent
func (r RowEvents) ExtractHeaderLabels(labelCol int) []string {
ll := make([]string, 0, 10)
for _, re := range r {
ll = append(ll, re.ExtractHeaderLabels(labelCol)...)
}
return ll
}
func (r RowEvents) Labelize(cols []int, labelCol int, labels []string) RowEvents {
out := make(RowEvents, 0, len(r))
for _, re := range r {
out = append(out, re.Labelize(cols, labelCol, labels))
}
return out
}
// Customize returns custom row events based on columns layout.
func (r RowEvents) Customize(cols []int) RowEvents {
ee := make(RowEvents, 0, len(cols))
@ -164,42 +194,32 @@ func (r RowEvents) FindIndex(id string) (int, bool) {
// Sort rows based on column index and order.
func (r RowEvents) Sort(ns string, sortCol int, ageCol bool, asc bool) {
if sortCol == -1 {
return
}
t := RowEventSorter{NS: ns, Events: r, Index: sortCol, Asc: asc}
sort.Sort(t)
gg, kk := map[string][]string{}, make(StringSet, 0, len(r))
iids, fields := map[string][]string{}, make(StringSet, 0, len(r))
for _, re := range r {
g := re.Row.Fields[sortCol]
field := re.Row.Fields[sortCol]
if ageCol {
g = toAgeDuration(g)
}
kk = kk.Add(g)
if ss, ok := gg[g]; ok {
gg[g] = append(ss, re.Row.ID)
} else {
gg[g] = []string{re.Row.ID}
field = toAgeDuration(field)
}
fields = fields.Add(field)
iids[field] = append(iids[field], re.Row.ID)
}
ids := make([]string, 0, len(r))
for _, k := range kk {
sort.StringSlice(gg[k]).Sort()
ids = append(ids, gg[k]...)
for _, field := range fields {
sort.StringSlice(iids[field]).Sort()
ids = append(ids, iids[field]...)
}
s := IdSorter{Ids: ids, Events: r}
sort.Sort(s)
}
// Helpers...
func toAgeDuration(dur string) string {
d, err := time.ParseDuration(dur)
if err != nil {
return dur
}
return duration.HumanDuration(d)
}
// ----------------------------------------------------------------------------
// RowEventSorter sorts row events by a given colon.

View File

@ -409,11 +409,41 @@ func TestRowEventsDelete(t *testing.T) {
func TestRowEventsSort(t *testing.T) {
uu := map[string]struct {
re render.RowEvents
col int
asc bool
e render.RowEvents
re render.RowEvents
col int
age, asc bool
e render.RowEvents
}{
"age_time": {
re: render.RowEvents{
{Row: render.Row{ID: "A", Fields: render.Fields{"1", "2", "1h10m10.5s"}}},
{Row: render.Row{ID: "B", Fields: render.Fields{"0", "2", "10.5s"}}},
{Row: render.Row{ID: "C", Fields: render.Fields{"10", "2", "3h20m5.2s"}}},
},
col: 2,
asc: true,
age: true,
e: render.RowEvents{
{Row: render.Row{ID: "B", Fields: render.Fields{"0", "2", "10.5s"}}},
{Row: render.Row{ID: "A", Fields: render.Fields{"1", "2", "1h10m10.5s"}}},
{Row: render.Row{ID: "C", Fields: render.Fields{"10", "2", "3h20m5.2s"}}},
},
},
"age_duration": {
re: render.RowEvents{
{Row: render.Row{ID: "A", Fields: render.Fields{"1", "2", "32d"}}},
{Row: render.Row{ID: "B", Fields: render.Fields{"0", "2", "1m10s"}}},
{Row: render.Row{ID: "C", Fields: render.Fields{"10", "2", "3h20m5s"}}},
},
col: 2,
asc: true,
age: true,
e: render.RowEvents{
{Row: render.Row{ID: "B", Fields: render.Fields{"0", "2", "1m10s"}}},
{Row: render.Row{ID: "C", Fields: render.Fields{"10", "2", "3h20m5s"}}},
{Row: render.Row{ID: "A", Fields: render.Fields{"1", "2", "32d"}}},
},
},
"col0": {
re: render.RowEvents{
{Row: render.Row{ID: "A", Fields: render.Fields{"1", "2", "3"}}},
@ -453,7 +483,7 @@ func TestRowEventsSort(t *testing.T) {
for k := range uu {
u := uu[k]
t.Run(k, func(t *testing.T) {
u.re.Sort("", u.col, false, u.asc)
u.re.Sort("", u.col, u.age, u.asc)
assert.Equal(t, u.e, u.re)
})
}

View File

@ -65,6 +65,38 @@ func TestFieldClone(t *testing.T) {
assert.NotEqual(t, fmt.Sprintf("%p", f), fmt.Sprintf("%p", f1))
}
func TestRowlabelize(t *testing.T) {
uu := map[string]struct {
row render.Row
cols []int
e render.Row
}{
"empty": {
row: render.Row{},
cols: []int{0, 1, 2},
e: render.Row{ID: "", Fields: render.Fields{"", "", ""}},
},
"no-cols-no-data": {
row: render.Row{},
cols: []int{},
e: render.Row{ID: "", Fields: render.Fields{}},
},
"no-cols-data": {
row: render.Row{ID: "fred", Fields: render.Fields{"f1", "f2", "f3"}},
cols: []int{},
e: render.Row{ID: "fred", Fields: render.Fields{}},
},
}
for k := range uu {
u := uu[k]
t.Run(k, func(t *testing.T) {
row := u.row.Customize(u.cols)
assert.Equal(t, u.e, row)
})
}
}
func TestRowCustomize(t *testing.T) {
uu := map[string]struct {
row render.Row

View File

@ -29,7 +29,7 @@ func (Service) Header(ns string) Header {
HeaderColumn{Name: "CLUSTER-IP"},
HeaderColumn{Name: "EXTERNAL-IP"},
HeaderColumn{Name: "SELECTOR", Wide: true},
HeaderColumn{Name: "PORTS", Wide: true},
HeaderColumn{Name: "PORTS", Wide: false},
HeaderColumn{Name: "LABELS", Wide: true},
HeaderColumn{Name: "VALID", Wide: true},
HeaderColumn{Name: "AGE", Time: true, Decorator: AgeDecorator},

View File

@ -1,5 +1,7 @@
package render
import "github.com/derailed/k9s/internal/client"
// TableData tracks a K8s resource for tabular display.
type TableData struct {
Header Header
@ -12,6 +14,22 @@ func NewTableData() *TableData {
return &TableData{}
}
// Labelize prints out specific label columns
func (t *TableData) Labelize(labels []string) TableData {
labelCol := t.Header.IndexOf("LABELS", true)
cols := []int{0, 1}
if client.IsNamespaced(t.Namespace) {
cols = cols[1:]
}
data := TableData{
Namespace: t.Namespace,
Header: t.Header.Labelize(cols, labelCol, t.RowEvents),
}
data.RowEvents = t.RowEvents.Labelize(cols, labelCol, labels)
return data
}
// Customize returns a new model with customized column layout.
func (t *TableData) Customize(cols []string, wide bool) TableData {
res := TableData{
@ -61,7 +79,7 @@ func (t *TableData) Update(rows Rows) {
t.RowEvents[index].Kind, t.RowEvents[index].Deltas = EventUnchanged, blankDelta
t.RowEvents[index].Row = row
} else {
t.RowEvents[index] = NewDeltaRowEvent(row, delta)
t.RowEvents[index] = NewRowEventWithDeltas(row, delta)
}
continue
}

View File

@ -14,28 +14,29 @@ type App struct {
*tview.Application
Configurator
Main *Pages
flash *model.Flash
actions KeyActions
views map[string]tview.Primitive
cmdBuff *CmdBuff
Main *Pages
flash *model.Flash
actions KeyActions
views map[string]tview.Primitive
cmdModel *model.FishBuff
}
// NewApp returns a new app.
func NewApp(context string) *App {
func NewApp(cfg *config.Config, context string) *App {
a := App{
Application: tview.NewApplication(),
actions: make(KeyActions),
Main: NewPages(),
flash: model.NewFlash(model.DefaultFlashDelay),
cmdBuff: NewCmdBuff(':', CommandBuff),
Application: tview.NewApplication(),
actions: make(KeyActions),
Configurator: Configurator{Config: cfg},
Main: NewPages(),
flash: model.NewFlash(model.DefaultFlashDelay),
cmdModel: model.NewFishBuff(':', model.CommandBuffer),
}
a.ReloadStyles(context)
a.views = map[string]tview.Primitive{
"menu": NewMenu(a.Styles),
"logo": NewLogo(a.Styles),
"cmd": NewCommand(a.Styles),
"prompt": NewPrompt(a.Config.K9s.NoIcons, a.Styles),
"crumbs": NewCrumbs(a.Styles),
}
@ -45,9 +46,9 @@ func NewApp(context string) *App {
// Init initializes the application.
func (a *App) Init() {
a.bindKeys()
a.cmdBuff.AddListener(a.Cmd())
a.Prompt().SetModel(a.cmdModel)
a.cmdModel.AddListener(a)
a.Styles.AddListener(a)
a.CmdBuff().AddListener(a)
a.SetRoot(a.Main, true)
}
@ -56,20 +57,24 @@ func (a *App) Init() {
func (a *App) BufferChanged(s string) {}
// BufferActive indicates the buff activity changed.
func (a *App) BufferActive(state bool, _ BufferKind) {
func (a *App) BufferActive(state bool, kind model.BufferKind) {
flex, ok := a.Main.GetPrimitive("main").(*tview.Flex)
if !ok {
return
}
if state && flex.ItemAt(1) != a.Cmd() {
flex.AddItemAtIndex(1, a.Cmd(), 3, 1, false)
} else if !state && flex.ItemAt(1) == a.Cmd() {
if state && flex.ItemAt(1) != a.Prompt() {
flex.AddItemAtIndex(1, a.Prompt(), 3, 1, false)
} else if !state && flex.ItemAt(1) == a.Prompt() {
flex.RemoveItemAtIndex(1)
a.SetFocus(flex)
}
a.Draw()
}
// SuggestionChanged notifies of update to command suggestions.
func (a *App) SuggestionChanged(ss []string) {}
// StylesChanged notifies the skin changed.
func (a *App) StylesChanged(s *config.Styles) {
a.Main.SetBackgroundColor(s.BgColor())
@ -97,14 +102,11 @@ func (a *App) Conn() client.Connection {
func (a *App) bindKeys() {
a.actions = KeyActions{
KeyColon: NewKeyAction("Cmd", a.activateCmd, false),
tcell.KeyCtrlR: NewKeyAction("Redraw", a.redrawCmd, false),
tcell.KeyCtrlC: NewKeyAction("Quit", a.quitCmd, false),
tcell.KeyEscape: NewKeyAction("Escape", a.escapeCmd, false),
tcell.KeyBackspace2: NewKeyAction("Erase", a.eraseCmd, false),
tcell.KeyBackspace: NewKeyAction("Erase", a.eraseCmd, false),
tcell.KeyDelete: NewKeyAction("Erase", a.eraseCmd, false),
tcell.KeyCtrlU: NewSharedKeyAction("Clear Filter", a.clearCmd, false),
KeyColon: NewKeyAction("Cmd", a.activateCmd, false),
tcell.KeyCtrlR: NewKeyAction("Redraw", a.redrawCmd, false),
tcell.KeyCtrlC: NewKeyAction("Quit", a.quitCmd, false),
tcell.KeyCtrlU: NewSharedKeyAction("Clear Filter", a.clearCmd, false),
tcell.KeyCtrlQ: NewSharedKeyAction("Clear Filter", a.clearCmd, false),
}
}
@ -113,29 +115,36 @@ func (a *App) BailOut() {
a.Stop()
}
// ResetPrompt reset the prompt model and marks buffer as active.
func (a *App) ResetPrompt(m PromptModel) {
a.Prompt().SetModel(m)
a.SetFocus(a.Prompt())
m.SetActive(true)
}
// ResetCmd clear out user command.
func (a *App) ResetCmd() {
a.cmdBuff.Reset()
a.cmdModel.Reset()
}
// ActivateCmd toggle command mode.
func (a *App) ActivateCmd(b bool) {
a.cmdBuff.SetActive(b)
a.cmdModel.SetActive(b)
}
// GetCmd retrieves user command.
func (a *App) GetCmd() string {
return a.cmdBuff.String()
return a.cmdModel.GetText()
}
// CmdBuff returns a cmd buffer.
func (a *App) CmdBuff() *CmdBuff {
return a.cmdBuff
func (a *App) CmdBuff() *model.FishBuff {
return a.cmdModel
}
// HasCmd check if cmd buffer is active and has a command.
func (a *App) HasCmd() bool {
return a.cmdBuff.IsActive() && !a.cmdBuff.Empty()
return a.cmdModel.IsActive() && !a.cmdModel.Empty()
}
func (a *App) quitCmd(evt *tcell.EventKey) *tcell.EventKey {
@ -149,7 +158,7 @@ func (a *App) quitCmd(evt *tcell.EventKey) *tcell.EventKey {
// InCmdMode check if command mode is active.
func (a *App) InCmdMode() bool {
return a.Cmd().InCmdMode()
return a.Prompt().InCmdMode()
}
// HasAction checks if key matches a registered binding.
@ -179,7 +188,7 @@ func (a *App) clearCmd(evt *tcell.EventKey) *tcell.EventKey {
if !a.CmdBuff().IsActive() {
return evt
}
a.CmdBuff().Clear()
a.CmdBuff().ClearText()
return nil
}
@ -188,36 +197,19 @@ func (a *App) activateCmd(evt *tcell.EventKey) *tcell.EventKey {
if a.InCmdMode() {
return evt
}
a.cmdBuff.SetActive(true)
a.cmdBuff.Clear()
a.ResetPrompt(a.cmdModel)
a.cmdModel.ClearText()
return nil
}
// EraseCmd removes the last char from a command.
func (a *App) eraseCmd(evt *tcell.EventKey) *tcell.EventKey {
if a.cmdBuff.IsActive() {
a.cmdBuff.Delete()
return nil
}
return evt
}
// EscapeCmd dismiss cmd mode.
func (a *App) escapeCmd(evt *tcell.EventKey) *tcell.EventKey {
if a.cmdBuff.IsActive() {
a.cmdBuff.Reset()
}
return evt
}
// RedrawCmd forces a redraw.
func (a *App) redrawCmd(evt *tcell.EventKey) *tcell.EventKey {
a.Draw()
return evt
}
// View Accessora...
// View Accessors...
// Crumbs return app crumba.
func (a *App) Crumbs() *Crumbs {
@ -229,9 +221,9 @@ func (a *App) Logo() *Logo {
return a.views["logo"].(*Logo)
}
// Cmd returns app cmd.
func (a *App) Cmd() *Command {
return a.views["cmd"].(*Command)
// Prompt returns command prompt.
func (a *App) Prompt() *Prompt {
return a.views["prompt"].(*Prompt)
}
// Menu returns app menu.
@ -249,6 +241,9 @@ func (a *App) Flash() *model.Flash {
// AsKey converts rune to keyboard key.,
func AsKey(evt *tcell.EventKey) tcell.Key {
if evt.Key() != tcell.KeyRune {
return evt.Key()
}
key := tcell.Key(evt.Rune())
if evt.Modifiers() == tcell.ModAlt {
key = tcell.Key(int16(evt.Rune()) * int16(evt.Modifiers()))

View File

@ -3,63 +3,64 @@ package ui_test
import (
"testing"
"github.com/derailed/k9s/internal/config"
"github.com/derailed/k9s/internal/ui"
"github.com/stretchr/testify/assert"
)
func TestAppGetCmd(t *testing.T) {
a := ui.NewApp("")
a := ui.NewApp(config.NewConfig(nil), "")
a.Init()
a.CmdBuff().Set("blee")
a.CmdBuff().SetText("blee")
assert.Equal(t, "blee", a.GetCmd())
}
func TestAppInCmdMode(t *testing.T) {
a := ui.NewApp("")
a := ui.NewApp(config.NewConfig(nil), "")
a.Init()
a.CmdBuff().Set("blee")
a.CmdBuff().SetText("blee")
assert.False(t, a.InCmdMode())
a.CmdBuff().SetActive(true)
assert.True(t, a.InCmdMode())
a.CmdBuff().SetActive(false)
assert.False(t, a.InCmdMode())
}
func TestAppResetCmd(t *testing.T) {
a := ui.NewApp("")
a := ui.NewApp(config.NewConfig(nil), "")
a.Init()
a.CmdBuff().Set("blee")
a.CmdBuff().SetText("blee")
a.ResetCmd()
assert.Equal(t, "", a.CmdBuff().String())
assert.Equal(t, "", a.CmdBuff().GetText())
}
func TestAppHasCmd(t *testing.T) {
a := ui.NewApp("")
a := ui.NewApp(config.NewConfig(nil), "")
a.Init()
a.ActivateCmd(true)
assert.False(t, a.HasCmd())
a.CmdBuff().Set("blee")
a.CmdBuff().SetText("blee")
assert.True(t, a.InCmdMode())
}
func TestAppGetActions(t *testing.T) {
a := ui.NewApp("")
a := ui.NewApp(config.NewConfig(nil), "")
a.Init()
a.AddActions(ui.KeyActions{ui.KeyZ: ui.KeyAction{Description: "zorg"}})
assert.Equal(t, 9, len(a.GetActions()))
assert.Equal(t, 6, len(a.GetActions()))
}
func TestAppViews(t *testing.T) {
a := ui.NewApp("")
a := ui.NewApp(config.NewConfig(nil), "")
a.Init()
vv := []string{"crumbs", "logo", "cmd", "menu"}
vv := []string{"crumbs", "logo", "prompt", "menu"}
for i := range vv {
v := vv[i]
t.Run(v, func(t *testing.T) {
@ -69,6 +70,6 @@ func TestAppViews(t *testing.T) {
assert.NotNil(t, a.Crumbs())
assert.NotNil(t, a.Logo())
assert.NotNil(t, a.Cmd())
assert.NotNil(t, a.Prompt())
assert.NotNil(t, a.Menu())
}

View File

@ -1,150 +0,0 @@
package ui
const maxBuff = 10
const (
// CommandBuff indicates a command buffer.
CommandBuff BufferKind = 1 << iota
// FilterBuff indicates a search buffer.
FilterBuff
)
type (
// BufferKind indicates a buffer type
BufferKind int8
// BuffWatcher represents a command buffer listener.
BuffWatcher interface {
// Changed indicates the buffer was changed.
BufferChanged(s string)
// Active indicates the buff activity changed.
BufferActive(state bool, kind BufferKind)
}
// CmdBuff represents user command input.
CmdBuff struct {
buff []rune
listeners []BuffWatcher
hotKey rune
kind BufferKind
sticky bool
active bool
}
)
// NewCmdBuff returns a new command buffer.
func NewCmdBuff(key rune, kind BufferKind) *CmdBuff {
return &CmdBuff{
hotKey: key,
kind: kind,
buff: make([]rune, 0, maxBuff),
listeners: []BuffWatcher{},
}
}
// IsSticky checks if the cmd is going to perist or not.
func (c *CmdBuff) IsSticky() bool {
return c.sticky
}
// SetSticky returns cmd stickness.
func (c *CmdBuff) SetSticky(b bool) {
c.sticky = b
}
// InCmdMode checks if a command exists and the buffer is active.
func (c *CmdBuff) InCmdMode() bool {
return c.active || len(c.buff) > 0
}
// IsActive checks if command buffer is active.
func (c *CmdBuff) IsActive() bool {
return c.active
}
// SetActive toggles cmd buffer active state.
func (c *CmdBuff) SetActive(b bool) {
c.active = b
c.fireActive(c.active)
}
// String turns rune to string (Stringer protocol)
func (c *CmdBuff) String() string {
return string(c.buff)
}
// Set initializes the buffer with a command.
func (c *CmdBuff) Set(cmd string) {
c.buff = []rune(cmd)
c.fireChanged()
}
// Add adds a new charater to the buffer.
func (c *CmdBuff) Add(r rune) {
c.buff = append(c.buff, r)
c.fireChanged()
}
// Delete removes the last character from the buffer.
func (c *CmdBuff) Delete() {
if c.Empty() {
return
}
c.buff = c.buff[:len(c.buff)-1]
c.fireChanged()
}
// Clear clears out command buffer.
func (c *CmdBuff) Clear() {
c.buff = make([]rune, 0, maxBuff)
c.fireChanged()
}
// Reset clears out the command buffer and deactives it.
func (c *CmdBuff) Reset() {
c.Clear()
c.fireChanged()
c.SetActive(false)
}
// Empty returns true is no cmd, false otherwise.
func (c *CmdBuff) Empty() bool {
return len(c.buff) == 0
}
// ----------------------------------------------------------------------------
// Event Listeners...
// AddListener registers a cmd buffer listener.
func (c *CmdBuff) AddListener(w ...BuffWatcher) {
c.listeners = append(c.listeners, w...)
}
// RemoveListener unregisters a listener.
func (c *CmdBuff) RemoveListener(l BuffWatcher) {
victim := -1
for i, lis := range c.listeners {
if l == lis {
victim = i
break
}
}
if victim == -1 {
return
}
c.listeners = append(c.listeners[:victim], c.listeners[victim+1:]...)
}
func (c *CmdBuff) fireChanged() {
for _, l := range c.listeners {
l.BufferChanged(c.String())
}
}
func (c *CmdBuff) fireActive(b bool) {
for _, l := range c.listeners {
l.BufferActive(b, c.kind)
}
}

View File

@ -1,107 +0,0 @@
package ui
import (
"fmt"
"github.com/derailed/k9s/internal/config"
"github.com/derailed/tview"
"github.com/gdamore/tcell"
)
const defaultPrompt = "%c> %s"
// Command captures users free from command input.
type Command struct {
*tview.TextView
activated bool
icon rune
text string
styles *config.Styles
}
// NewCommand returns a new command view.
func NewCommand(styles *config.Styles) *Command {
c := Command{styles: styles, TextView: tview.NewTextView()}
c.SetWordWrap(true)
c.SetWrap(true)
c.SetDynamicColors(true)
c.SetBorder(true)
c.SetBorderPadding(0, 0, 1, 1)
c.SetBackgroundColor(styles.BgColor())
c.SetTextColor(styles.FgColor())
styles.AddListener(&c)
return &c
}
// StylesChanged notifies skin changed.
func (c *Command) StylesChanged(s *config.Styles) {
c.styles = s
c.SetBackgroundColor(s.BgColor())
c.SetTextColor(s.FgColor())
}
// InCmdMode returns true if command is active, false otherwise.
func (c *Command) InCmdMode() bool {
return c.activated
}
func (c *Command) activate() {
c.write(c.text)
}
func (c *Command) update(s string) {
if c.text == s {
return
}
c.text = s
c.Clear()
c.write(c.text)
}
func (c *Command) write(s string) {
fmt.Fprintf(c, defaultPrompt, c.icon, s)
}
// ----------------------------------------------------------------------------
// Event Listener protocol...
// BufferChanged indicates the buffer was changed.
func (c *Command) BufferChanged(s string) {
c.update(s)
}
// BufferActive indicates the buff activity changed.
func (c *Command) BufferActive(f bool, k BufferKind) {
if c.activated = f; f {
c.SetBorder(true)
c.SetTextColor(c.styles.FgColor())
c.SetBorderColor(colorFor(k))
c.icon = iconFor(k)
// c.reset()
c.activate()
} else {
c.SetBorder(false)
c.SetBackgroundColor(c.styles.BgColor())
c.Clear()
}
}
func colorFor(k BufferKind) tcell.Color {
switch k {
case CommandBuff:
return tcell.ColorAqua
default:
return tcell.ColorSeaGreen
}
}
func iconFor(k BufferKind) rune {
switch k {
case CommandBuff:
return '🐶'
default:
return '🐩'
}
}

View File

@ -1,44 +0,0 @@
package ui_test
import (
"testing"
"github.com/derailed/k9s/internal/config"
"github.com/derailed/k9s/internal/ui"
"github.com/stretchr/testify/assert"
)
func TestCmdNew(t *testing.T) {
v := ui.NewCommand(config.NewStyles())
buff := ui.NewCmdBuff(':', ui.CommandBuff)
buff.AddListener(v)
buff.Set("blee")
assert.Equal(t, "\x00> blee\n", v.GetText(false))
}
func TestCmdUpdate(t *testing.T) {
v := ui.NewCommand(config.NewStyles())
buff := ui.NewCmdBuff(':', ui.CommandBuff)
buff.AddListener(v)
buff.Set("blee")
buff.Add('!')
assert.Equal(t, "\x00> blee!\n", v.GetText(false))
assert.False(t, v.InCmdMode())
}
func TestCmdMode(t *testing.T) {
v := ui.NewCommand(config.NewStyles())
buff := ui.NewCmdBuff(':', ui.CommandBuff)
buff.AddListener(v)
for _, f := range []bool{false, true} {
buff.SetActive(f)
assert.Equal(t, f, v.InCmdMode())
}
}

Some files were not shown because too many files have changed in this diff Show More