Merge branch 'master' into attach

mine
Fernand Galiana 2020-02-20 13:51:58 -07:00 committed by GitHub
commit 184ea664eb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
154 changed files with 3589 additions and 802 deletions

1
.gitignore vendored
View File

@ -17,3 +17,4 @@ pod1.go
.project .project
faas faas
.settings/* .settings/*
demos

View File

@ -16,8 +16,8 @@ builds:
- 386 - 386
- amd64 - amd64
- arm64 - arm64
- arm
goarm: goarm:
- 6
- 7 - 7
ldflags: 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}} - -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}}

View File

@ -99,8 +99,9 @@ K9s is available on Linux, macOS and Windows platforms.
--- ---
## Demo Video ## Demo Videos/Recordings
* [K9s Pulses](https://asciinema.org/a/UbXKPal6IWpTaVAjBBFmizcGN)
* [K9s v0.15.1](https://youtu.be/7Fx4XQ2ftpM) * [K9s v0.15.1](https://youtu.be/7Fx4XQ2ftpM)
* [K9s v0.13.0](https://www.youtube.com/watch?v=qaeR2iK7U0o&t=15s) * [K9s v0.13.0](https://www.youtube.com/watch?v=qaeR2iK7U0o&t=15s)
* [K9s v0.9.0](https://www.youtube.com/watch?v=bxKfqumjW4I) * [K9s v0.9.0](https://www.youtube.com/watch?v=bxKfqumjW4I)
@ -141,7 +142,7 @@ K9s uses aliases to navigate most K8s resources.
| `:`ns`<ENTER>` | To view and switch to another Kubernetes namespace | `:`+`ns`+`<ENTER>` | | `:`ns`<ENTER>` | To view and switch to another Kubernetes namespace | `:`+`ns`+`<ENTER>` |
| `:screendump`, `:sd` | To view all saved resources | | | `:screendump`, `:sd` | To view all saved resources | |
| `Ctrl-d` | To delete a resource (TAB and ENTER to confirm) | | | `Ctrl-d` | To delete a resource (TAB and ENTER to confirm) | |
| `Ctrl-k` | To delete a resource (no confirmation dialog) | | | `Ctrl-k` | To kill a resource (no confirmation dialog!) | |
| `:q`, `Ctrl-c` | To bail out of K9s | | | `:q`, `Ctrl-c` | To bail out of K9s | |
--- ---
@ -505,17 +506,17 @@ k9s:
highlightColor: skyblue highlightColor: skyblue
counterColor: slateblue counterColor: slateblue
filterColor: slategray filterColor: slategray
# TableView attributes.
table:
fgColor: blue
bgColor: darkblue
cursorColor: aqua
# Header row styles.
header:
fgColor: white
bgColor: darkblue
sorterColor: orange
views: views:
# TableView attributes.
table:
fgColor: blue
bgColor: darkblue
cursorColor: aqua
# Header row styles.
header:
fgColor: white
bgColor: darkblue
sorterColor: orange
# YAML info styles. # YAML info styles.
yaml: yaml:
keyColor: steelblue keyColor: steelblue

BIN
assets/k9s_doc.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

BIN
assets/k9s_health.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 731 KiB

View File

@ -0,0 +1,185 @@
<img src="https://raw.githubusercontent.com/derailed/k9s/master/assets/k9s_small.png" align="right" width="200" height="auto"/>
# Release v0.16.0
## Notes
Thank you to all that contributed with flushing out issues and enhancements for K9s! I'll try to mark some of these issues as fixed. But if you don't mind grab the latest rev and see if we're happier with some of the fixes! If you've filed an issue please help me verify and close. Your support, kindness and awesome suggestions to make K9s better is as ever very much noticed and appreciated!
Also if you dig this tool, please make some noise on social! [@kitesurfer](https://twitter.com/kitesurfer)
On Slack? Please join us [K9slackers](https://join.slack.com/t/k9sers/shared_invite/enQtOTA5MDEyNzI5MTU0LWQ1ZGI3MzliYzZhZWEyNzYxYzA3NjE0YTk1YmFmNzViZjIyNzhkZGI0MmJjYzhlNjdlMGJhYzE2ZGU1NjkyNTM)
---
<img src="https://raw.githubusercontent.com/derailed/k9s/master/assets/k9s_doc.png" align="center"/>
This is one of these drops that may make you wonder if you'll go from zero to hero or likely the reverse?? Will see how this goes... Please proceed with caution on this one as there could very well be much distrubances in the force...
Lots of code churns so could have totally hose some stuff, but like my GranPappy used to say `can't cook without making a mess!`
## Going Wide?
In this drop, we've enabled a new shortcut namely `wide` as `Ctrl-w`. On table views, you will be able to see more information about the resources such as labels or others depending on the viewed resource. This mnemonic works as a toggle so you can `narrow` the view by hitting it again.
## Zoom, Zoom, Zoom!
While viewing some resources that may contain errors, sorting on columns may not achieve the results you're seeking ie `show me all resources in an error state`. We've added a new option to achieve just that aka `zoom errors` as `ctrl-z`. This works as a toggle and will unveil resources that are need of some TLC on your part ;)
## Does Your Cluster Have A Pulse 💓?
In this drop, we're introducing a brand new view aka `K9s Pulses` 💓. This is a summary view listing the most sailient resources in your clusters and their current states. This view tracks two main metrics ie Ok and Toast on a 5sec beat. This view affords cluster activity and failure rates. BTW this is the zero to hero deal 🙀 Hopefully you'll dig it as this was much work to put together and I personally think it's the `ducks nuts`... If you like, please give me some luving on social or via GH sponsors as batteries are running low...
To active, enter command mode by typing in `:pulse` aliases are `pu`, `pulses` or `hz`
To navigate thru the various pulses, you can use `tab`/`backtab` or use the menu index (just like namespaces selectors). Once on a pulse view, you can press `enter` to see the associated resource table view. Pressing `esc` will nav you back.
As I've may have mentioned before, my front-end/UX FU is weak, so I've also added a way for you to skin the charts via skins yaml to your own liking. Please see the skin section below for an example on how to skin the pulses dials. BONUS you should be able to skin K9s live! How cool is that 😻?
NOTE: Pulses are very much experimental and could totally bomb on your clusters! So please thread carefully and please do report (kindly!) back.
## BReaking Bad!
In this drop I've broken a few things (that I know off...), here is the list as I can recall...
1. Toggle header aka `my red headed step child`. Key moved (again!) now `Ctrl-e`
2. Skin yaml layout CHANGED! Moved table and xray sections under views and added charts section.
## Skins Updates!
The skin file format CHANGE! If you are running skins with K9s, please make sure to update your skin file. If not K9s could bomb coming up!
NOTE: I don't think I'll get around to update all the contributed skins in this repo `skins` dir. If you're looking for a way to help out and are UI inclined, please take a peek and make them cool!
```yaml
# my_cluster_skin.yml
# Styles...
foreground: &foreground "#f8f8f2"
background: &background "#282a36"
current_line: &current_line "#44475a"
selection: &selection "#44475a"
comment: &comment "#6272a4"
cyan: &cyan "#8be9fd"
green: &green "#50fa7b"
orange: &orange "#ffb86c"
pink: &pink "#ff79c6"
purple: &purple "#bd93f9"
red: &red "#ff5555"
yellow: &yellow "#f1fa8c"
# Skin...
k9s:
# General K9s styles
body:
fgColor: *foreground
bgColor: *background
logoColor: *purple
# ClusterInfoView styles.
info:
fgColor: *pink
sectionColor: *foreground
frame:
# Borders styles.
border:
fgColor: *selection
focusColor: *current_line
menu:
fgColor: *foreground
keyColor: *pink
# Used for favorite namespaces
numKeyColor: *purple
# CrumbView attributes for history navigation.
crumbs:
fgColor: *foreground
bgColor: *current_line
activeColor: *current_line
# Resource status and update styles
status:
newColor: *cyan
modifyColor: *purple
addColor: *green
errorColor: *red
highlightcolor: *orange
killColor: *comment
completedColor: *comment
# Border title styles.
title:
fgColor: *foreground
bgColor: *current_line
highlightColor: *orange
counterColor: *purple
filterColor: *pink
views:
charts:
bgColor: *background
dialBgColor: "#0A2239"
chartBgColor: "#0A2239"
defaultDialColors:
- "#1E3888"
- "#820101"
defaultChartColors:
- "#1E3888"
- "#820101"
resourceColors:
batch/v1/jobs:
- "#5D737E"
- "#820101"
v1/persistentvolumes:
- "#3E554A"
- "#820101"
cpu:
- "#6EA4BF"
- "#820101"
mem:
- "#17505B"
- "#820101"
v1/events:
- "#073B3A"
- "#820101"
v1/pods:
- "#487FFF"
- "#820101"
# TableView attributes.
table:
fgColor: *foreground
bgColor: *background
cursorColor: *current_line
# Header row styles.
header:
fgColor: *foreground
bgColor: *background
sorterColor: *cyan
# Xray view attributes.
xray:
fgColor: *foreground
bgColor: *background
cursorColor: *current_line
graphicColor: *purple
showIcons: true
# YAML info styles.
yaml:
keyColor: *pink
colonColor: *purple
valueColor: *foreground
# Logs styles.
logs:
fgColor: *foreground
bgColor: *background
```
## Resolved Bugs/Features/PRs
- [Issue #557](https://github.com/derailed/k9s/issues/557)
- [Issue #555](https://github.com/derailed/k9s/issues/555)
- [Issue #554](https://github.com/derailed/k9s/issues/554)
- [Issue #553](https://github.com/derailed/k9s/issues/553)
- [Issue #552](https://github.com/derailed/k9s/issues/552)
- [Issue #551](https://github.com/derailed/k9s/issues/551)
- [Issue #549](https://github.com/derailed/k9s/issues/549) A start with pulses...
- [Issue #540](https://github.com/derailed/k9s/issues/540)
- [Issue #421](https://github.com/derailed/k9s/issues/421)
- [Issue #351](https://github.com/derailed/k9s/issues/351) Solved by Pulses?
- [Issue #25](https://github.com/derailed/k9s/issues/25) Pulses? Oldie but goodie!
---
<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,23 @@
<img src="https://raw.githubusercontent.com/derailed/k9s/master/assets/k9s_small.png" align="right" width="200" height="auto"/>
# Release v0.16.1
## Notes
Thank you to all that contributed with flushing out issues and enhancements for K9s! I'll try to mark some of these issues as fixed. But if you don't mind grab the latest rev and see if we're happier with some of the fixes! If you've filed an issue please help me verify and close. Your support, kindness and awesome suggestions to make K9s better is as ever very much noticed and appreciated!
Also if you dig this tool, please make some noise on social! [@kitesurfer](https://twitter.com/kitesurfer)
On Slack? Please join us [K9slackers](https://join.slack.com/t/k9sers/shared_invite/enQtOTA5MDEyNzI5MTU0LWQ1ZGI3MzliYzZhZWEyNzYxYzA3NjE0YTk1YmFmNzViZjIyNzhkZGI0MmJjYzhlNjdlMGJhYzE2ZGU1NjkyNTM)
---
Maintenance Release!
## Resolved Bugs/Features/PRs
- [Issue #561](https://github.com/derailed/k9s/issues/561)
---
<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)

6
go.mod
View File

@ -34,7 +34,7 @@ require (
github.com/alexellis/go-execute v0.0.0-20200124154445-8697e4e28c5e // indirect github.com/alexellis/go-execute v0.0.0-20200124154445-8697e4e28c5e // indirect
github.com/alexellis/hmac v0.0.0-20180624211220-5c52ab81c0de // indirect github.com/alexellis/hmac v0.0.0-20180624211220-5c52ab81c0de // indirect
github.com/atotto/clipboard v0.1.2 github.com/atotto/clipboard v0.1.2
github.com/derailed/tview v0.3.5 github.com/derailed/tview v0.3.6
github.com/drone/envsubst v1.0.2 // indirect github.com/drone/envsubst v1.0.2 // indirect
github.com/elazarl/goproxy v0.0.0-20190421051319-9d40249d3c2f // indirect github.com/elazarl/goproxy v0.0.0-20190421051319-9d40249d3c2f // indirect
github.com/elazarl/goproxy/ext v0.0.0-20190421051319-9d40249d3c2f // indirect github.com/elazarl/goproxy/ext v0.0.0-20190421051319-9d40249d3c2f // indirect
@ -43,14 +43,14 @@ require (
github.com/gdamore/tcell v1.3.0 github.com/gdamore/tcell v1.3.0
github.com/ghodss/yaml v1.0.0 github.com/ghodss/yaml v1.0.0
github.com/gregjones/httpcache v0.0.0-20190212212710-3befbb6ad0cc // indirect github.com/gregjones/httpcache v0.0.0-20190212212710-3befbb6ad0cc // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/mattn/go-runewidth v0.0.5 github.com/mattn/go-runewidth v0.0.5
github.com/openfaas/faas v0.0.0-20200207215241-6afae214e3ec github.com/openfaas/faas v0.0.0-20200207215241-6afae214e3ec
github.com/openfaas/faas-cli v0.0.0-20200124160744-30b7cec9634c github.com/openfaas/faas-cli v0.0.0-20200124160744-30b7cec9634c
github.com/openfaas/faas-provider v0.15.0 github.com/openfaas/faas-provider v0.15.0
github.com/petergtz/pegomock v2.6.0+incompatible github.com/petergtz/pegomock v2.6.0+incompatible
github.com/rakyll/hey v0.1.2 github.com/rakyll/hey v0.1.2
github.com/rs/zerolog v1.17.2 github.com/rivo/tview v0.0.0-20191018115645-bacbf5155bc1
github.com/rs/zerolog v1.18.0
github.com/ryanuber/go-glob v1.0.0 // indirect github.com/ryanuber/go-glob v1.0.0 // indirect
github.com/sahilm/fuzzy v0.1.0 github.com/sahilm/fuzzy v0.1.0
github.com/spf13/cobra v0.0.5 github.com/spf13/cobra v0.0.5

6
go.sum
View File

@ -157,6 +157,8 @@ github.com/derailed/tview v0.3.4 h1:PnF64fLqm48LEjC/XwOS7JufDgFuuPYx85YVt5t3rwE=
github.com/derailed/tview v0.3.4/go.mod h1:yApPszFU62FoaGkf7swy2nIdV/h7Nid3dhMSVy6+OFI= github.com/derailed/tview v0.3.4/go.mod h1:yApPszFU62FoaGkf7swy2nIdV/h7Nid3dhMSVy6+OFI=
github.com/derailed/tview v0.3.5 h1:1vKqcJIiZtLAs5moX9c38+BbBSYhPgFq0ZndnVNVNFc= github.com/derailed/tview v0.3.5 h1:1vKqcJIiZtLAs5moX9c38+BbBSYhPgFq0ZndnVNVNFc=
github.com/derailed/tview v0.3.5/go.mod h1:yApPszFU62FoaGkf7swy2nIdV/h7Nid3dhMSVy6+OFI= github.com/derailed/tview v0.3.5/go.mod h1:yApPszFU62FoaGkf7swy2nIdV/h7Nid3dhMSVy6+OFI=
github.com/derailed/tview v0.3.6 h1:9PyX6Nu1vs9mCVfvV2q2fwT/dZta0dBGr4ZPjCF1KnU=
github.com/derailed/tview v0.3.6/go.mod h1:GJ3k/TIzEE+sj1L09/usk6HrkjsdadSsb03eHgPbcII=
github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM= github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM=
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
github.com/dnaeon/go-vcr v1.0.1/go.mod h1:aBB1+wY4s93YsC3HHjMBMrwTj2R9FHDzUr9KyGc8n1E= github.com/dnaeon/go-vcr v1.0.1/go.mod h1:aBB1+wY4s93YsC3HHjMBMrwTj2R9FHDzUr9KyGc8n1E=
@ -483,6 +485,7 @@ github.com/mohae/deepcopy v0.0.0-20170603005431-491d3605edfb/go.mod h1:TaXosZuwd
github.com/morikuni/aec v0.0.0-20170113033406-39771216ff4c h1:nXxl5PrvVm2L/wCy8dQu6DMTwH4oIuGN8GJDAlqDdVE= github.com/morikuni/aec v0.0.0-20170113033406-39771216ff4c h1:nXxl5PrvVm2L/wCy8dQu6DMTwH4oIuGN8GJDAlqDdVE=
github.com/morikuni/aec v0.0.0-20170113033406-39771216ff4c/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/morikuni/aec v0.0.0-20170113033406-39771216ff4c/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/mrunalp/fileutils v0.0.0-20160930181131-4ee1cc9a8058/go.mod h1:x8F1gnqOkIEiO4rqoeEEEqQbo7HjGMTvyoq3gej4iT0= github.com/mrunalp/fileutils v0.0.0-20160930181131-4ee1cc9a8058/go.mod h1:x8F1gnqOkIEiO4rqoeEEEqQbo7HjGMTvyoq3gej4iT0=
github.com/mum4k/termdash v0.10.0/go.mod h1:l3tO+lJi9LZqXRq7cu7h5/8rDIK3AzelSuq2v/KncxI=
github.com/munnerz/goautoneg v0.0.0-20120707110453-a547fc61f48d h1:7PxY7LVfSZm7PEeBTyK1rj1gABdCO2mbri6GKO1cMDs= github.com/munnerz/goautoneg v0.0.0-20120707110453-a547fc61f48d h1:7PxY7LVfSZm7PEeBTyK1rj1gABdCO2mbri6GKO1cMDs=
github.com/munnerz/goautoneg v0.0.0-20120707110453-a547fc61f48d/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/munnerz/goautoneg v0.0.0-20120707110453-a547fc61f48d/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/mvdan/xurls v1.1.0/go.mod h1:tQlNn3BED8bE/15hnSL2HLkDeLWpNPAwtw7wkEq44oU= github.com/mvdan/xurls v1.1.0/go.mod h1:tQlNn3BED8bE/15hnSL2HLkDeLWpNPAwtw7wkEq44oU=
@ -565,6 +568,7 @@ github.com/quobyte/api v0.1.2/go.mod h1:jL7lIHrmqQ7yh05OJ+eEEdHr0u/kmT1Ff9iHd+4H
github.com/rakyll/hey v0.1.2 h1:XlGaKcBdmXJaPImiTnE+TGLDUWQ2toYuHCwdrylLjmg= github.com/rakyll/hey v0.1.2 h1:XlGaKcBdmXJaPImiTnE+TGLDUWQ2toYuHCwdrylLjmg=
github.com/rakyll/hey v0.1.2/go.mod h1:S5M+++KwbmxA7w68S92B5NdWiCB+cIhITaMUkq9W608= github.com/rakyll/hey v0.1.2/go.mod h1:S5M+++KwbmxA7w68S92B5NdWiCB+cIhITaMUkq9W608=
github.com/remyoudompheng/bigfft v0.0.0-20170806203942-52369c62f446/go.mod h1:uYEyJGbgTkfkS4+E/PavXkNJcbFIpEtjt2B0KDQ5+9M= github.com/remyoudompheng/bigfft v0.0.0-20170806203942-52369c62f446/go.mod h1:uYEyJGbgTkfkS4+E/PavXkNJcbFIpEtjt2B0KDQ5+9M=
github.com/rivo/tview v0.0.0-20191018115645-bacbf5155bc1 h1:s9Lw4phBWkuQJUd+msaBMxP3utLvrFaBQV9jNgG55r0=
github.com/rivo/tview v0.0.0-20191018115645-bacbf5155bc1/go.mod h1:+rKjP5+h9HMwWRpAfhIkkQ9KE3m3Nz5rwn7YtUpwgqk= github.com/rivo/tview v0.0.0-20191018115645-bacbf5155bc1/go.mod h1:+rKjP5+h9HMwWRpAfhIkkQ9KE3m3Nz5rwn7YtUpwgqk=
github.com/rivo/uniseg v0.0.0-20190513083848-b9f5b9457d44/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.0.0-20190513083848-b9f5b9457d44/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.1.0 h1:+2KBaVoUmb9XzDsrx/Ct0W/EYOSFf/nWTauy++DprtY= github.com/rivo/uniseg v0.1.0 h1:+2KBaVoUmb9XzDsrx/Ct0W/EYOSFf/nWTauy++DprtY=
@ -575,6 +579,8 @@ github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFR
github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ= github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
github.com/rs/zerolog v1.17.2 h1:RMRHFw2+wF7LO0QqtELQwo8hqSmqISyCJeFeAAuWcRo= github.com/rs/zerolog v1.17.2 h1:RMRHFw2+wF7LO0QqtELQwo8hqSmqISyCJeFeAAuWcRo=
github.com/rs/zerolog v1.17.2/go.mod h1:9nvC1axdVrAHcu/s9taAVfBuIdTZLVQmKQyvrUjF5+I= github.com/rs/zerolog v1.17.2/go.mod h1:9nvC1axdVrAHcu/s9taAVfBuIdTZLVQmKQyvrUjF5+I=
github.com/rs/zerolog v1.18.0 h1:CbAm3kP2Tptby1i9sYy2MGRg0uxIN9cyDb59Ys7W8z8=
github.com/rs/zerolog v1.18.0/go.mod h1:9nvC1axdVrAHcu/s9taAVfBuIdTZLVQmKQyvrUjF5+I=
github.com/rubiojr/go-vhd v0.0.0-20160810183302-0bfd3b39853c/go.mod h1:DM5xW0nvfNNm2uytzsvhI3OnX8uzaRAg8UX/CnDqbto= github.com/rubiojr/go-vhd v0.0.0-20160810183302-0bfd3b39853c/go.mod h1:DM5xW0nvfNNm2uytzsvhI3OnX8uzaRAg8UX/CnDqbto=
github.com/russross/blackfriday v0.0.0-20170610170232-067529f716f4/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= github.com/russross/blackfriday v0.0.0-20170610170232-067529f716f4/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
github.com/russross/blackfriday v1.5.2 h1:HyvC0ARfnZBqnXwABFeSZHpKvJHJJfPz81GNueLj0oo= github.com/russross/blackfriday v1.5.2 h1:HyvC0ARfnZBqnXwABFeSZHpKvJHJJfPz81GNueLj0oo=

View File

@ -3,6 +3,7 @@ package client
import ( import (
"errors" "errors"
"fmt" "fmt"
"strings"
"sync" "sync"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
@ -177,13 +178,31 @@ func (c *Config) ClusterNames() ([]string, error) {
// CurrentGroupNames retrieves the active group names. // CurrentGroupNames retrieves the active group names.
func (c *Config) CurrentGroupNames() ([]string, error) { func (c *Config) CurrentGroupNames() ([]string, error) {
if c.flags.ImpersonateGroup != nil && len(*c.flags.ImpersonateGroup) != 0 { if areSet(c.flags.ImpersonateGroup) {
return *c.flags.ImpersonateGroup, nil return *c.flags.ImpersonateGroup, nil
} }
return []string{}, errors.New("unable to locate current group") return []string{}, errors.New("unable to locate current group")
} }
// ImpersonateGroups retrieves the active groupsif set on the CLI.
func (c *Config) ImpersonateGroups() (string, error) {
if areSet(c.flags.ImpersonateGroup) {
return strings.Join(*c.flags.ImpersonateGroup, ","), nil
}
return "", errors.New("no groups set")
}
// ImpersonateUser retrieves the active user name if set on the CLI.
func (c *Config) ImpersonateUser() (string, error) {
if isSet(c.flags.Impersonate) {
return *c.flags.Impersonate, nil
}
return "", errors.New("no user set")
}
// CurrentUserName retrieves the active user name. // CurrentUserName retrieves the active user name.
func (c *Config) CurrentUserName() (string, error) { func (c *Config) CurrentUserName() (string, error) {
if isSet(c.flags.Impersonate) { if isSet(c.flags.Impersonate) {
@ -311,3 +330,7 @@ func (c *Config) ensureConfig() {
func isSet(s *string) bool { func isSet(s *string) bool {
return s != nil && len(*s) != 0 return s != nil && len(*s) != 0
} }
func areSet(s *[]string) bool {
return s != nil && len(*s) != 0
}

View File

@ -3,20 +3,49 @@ package client
import ( import (
"fmt" "fmt"
"math" "math"
"time"
v1 "k8s.io/api/core/v1" v1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/util/cache"
mv1beta1 "k8s.io/metrics/pkg/apis/metrics/v1beta1" mv1beta1 "k8s.io/metrics/pkg/apis/metrics/v1beta1"
) )
const (
mxCacheSize = 100
mxCacheExpiry = 1 * time.Minute
)
// MetricsDial tracks global metric server handle.
var MetricsDial *MetricsServer
// DialMetrics dials the metrics server.
func DialMetrics(c Connection) *MetricsServer {
if MetricsDial == nil {
MetricsDial = NewMetricsServer(c)
}
return MetricsDial
}
// ResetMetrics resets the metric server handle.
func ResetMetrics() {
MetricsDial = nil
}
// MetricsServer serves cluster metrics for nodes and pods. // MetricsServer serves cluster metrics for nodes and pods.
type MetricsServer struct { type MetricsServer struct {
Connection Connection
cache *cache.LRUExpireCache
} }
// NewMetricsServer return a metric server instance. // NewMetricsServer return a metric server instance.
func NewMetricsServer(c Connection) *MetricsServer { func NewMetricsServer(c Connection) *MetricsServer {
return &MetricsServer{Connection: c} return &MetricsServer{
Connection: c,
cache: cache.NewLRUExpireCache(mxCacheSize),
}
} }
// NodesMetrics retrieves metrics for a given set of nodes. // NodesMetrics retrieves metrics for a given set of nodes.
@ -28,15 +57,15 @@ func (m *MetricsServer) NodesMetrics(nodes *v1.NodeList, metrics *mv1beta1.NodeM
for _, no := range nodes.Items { for _, no := range nodes.Items {
mmx[no.Name] = NodeMetrics{ mmx[no.Name] = NodeMetrics{
AvailCPU: no.Status.Allocatable.Cpu().MilliValue(), AvailCPU: no.Status.Allocatable.Cpu().MilliValue(),
AvailMEM: toMB(no.Status.Allocatable.Memory().Value()), AvailMEM: ToMB(no.Status.Allocatable.Memory().Value()),
TotalCPU: no.Status.Capacity.Cpu().MilliValue(), TotalCPU: no.Status.Capacity.Cpu().MilliValue(),
TotalMEM: toMB(no.Status.Capacity.Memory().Value()), TotalMEM: ToMB(no.Status.Capacity.Memory().Value()),
} }
} }
for _, c := range metrics.Items { for _, c := range metrics.Items {
if mx, ok := mmx[c.Name]; ok { if mx, ok := mmx[c.Name]; ok {
mx.CurrentCPU = c.Usage.Cpu().MilliValue() mx.CurrentCPU = c.Usage.Cpu().MilliValue()
mx.CurrentMEM = toMB(c.Usage.Memory().Value()) mx.CurrentMEM = ToMB(c.Usage.Memory().Value())
mmx[c.Name] = mx mmx[c.Name] = mx
} }
} }
@ -51,13 +80,13 @@ func (m *MetricsServer) ClusterLoad(nos *v1.NodeList, nmx *mv1beta1.NodeMetricsL
for _, no := range nos.Items { for _, no := range nos.Items {
nodeMetrics[no.Name] = NodeMetrics{ nodeMetrics[no.Name] = NodeMetrics{
AvailCPU: no.Status.Allocatable.Cpu().MilliValue(), AvailCPU: no.Status.Allocatable.Cpu().MilliValue(),
AvailMEM: toMB(no.Status.Allocatable.Memory().Value()), AvailMEM: ToMB(no.Status.Allocatable.Memory().Value()),
} }
} }
for _, mx := range nmx.Items { for _, mx := range nmx.Items {
if m, ok := nodeMetrics[mx.Name]; ok { if m, ok := nodeMetrics[mx.Name]; ok {
m.CurrentCPU = mx.Usage.Cpu().MilliValue() m.CurrentCPU = mx.Usage.Cpu().MilliValue()
m.CurrentMEM = toMB(mx.Usage.Memory().Value()) m.CurrentMEM = ToMB(mx.Usage.Memory().Value())
nodeMetrics[mx.Name] = m nodeMetrics[mx.Name] = m
} }
} }
@ -74,86 +103,121 @@ func (m *MetricsServer) ClusterLoad(nos *v1.NodeList, nmx *mv1beta1.NodeMetricsL
return nil return nil
} }
// FetchNodesMetrics return all metrics for pods in a given namespace. func (m *MetricsServer) checkAccess(ns, gvr, msg string) error {
func (m *MetricsServer) FetchNodesMetrics() (*mv1beta1.NodeMetricsList, error) {
var mx mv1beta1.NodeMetricsList
if !m.HasMetrics() { if !m.HasMetrics() {
return &mx, fmt.Errorf("No metrics-server detected on cluster") return fmt.Errorf("No metrics-server detected on cluster")
} }
auth, err := m.CanI("", "metrics.k8s.io/v1beta1/nodes", ListAccess) auth, err := m.CanI(ns, gvr, ListAccess)
if err != nil { if err != nil {
return &mx, err return err
} }
if !auth { if !auth {
return &mx, fmt.Errorf("user is not authorized to list node metrics") return fmt.Errorf(msg)
}
return nil
}
// FetchNodesMetrics return all metrics for nodes.
func (m *MetricsServer) FetchNodesMetrics() (*mv1beta1.NodeMetricsList, error) {
const msg = "user is not authorized to list node metrics"
mx := new(mv1beta1.NodeMetricsList)
if err := m.checkAccess("", "metrics.k8s.io/v1beta1/nodes", msg); err != nil {
return mx, err
}
const key = "nodes"
if entry, ok := m.cache.Get(key); ok && entry != nil {
mxList, ok := entry.(*mv1beta1.NodeMetricsList)
if !ok {
return nil, fmt.Errorf("expected nodemetricslist but got %T", entry)
}
return mxList, nil
} }
client, err := m.MXDial() client, err := m.MXDial()
if err != nil { if err != nil {
return &mx, err return mx, err
} }
return client.MetricsV1beta1().NodeMetricses().List(metav1.ListOptions{}) mxList, err := client.MetricsV1beta1().NodeMetricses().List(metav1.ListOptions{})
if err != nil {
return mx, err
}
m.cache.Add(key, mxList, mxCacheExpiry)
return mxList, nil
} }
// FetchPodsMetrics return all metrics for pods in a given namespace. // FetchPodsMetrics return all metrics for pods in a given namespace.
func (m *MetricsServer) FetchPodsMetrics(ns string) (*mv1beta1.PodMetricsList, error) { func (m *MetricsServer) FetchPodsMetrics(ns string) (*mv1beta1.PodMetricsList, error) {
var mx mv1beta1.PodMetricsList mx := new(mv1beta1.PodMetricsList)
if m.Connection == nil { const msg = "user is not authorized to list pods metrics"
return &mx, fmt.Errorf("no client connection")
}
if !m.HasMetrics() {
return &mx, fmt.Errorf("No metrics-server detected on cluster")
}
if ns == NamespaceAll { if ns == NamespaceAll {
ns = AllNamespaces ns = AllNamespaces
} }
if err := m.checkAccess(ns, "metrics.k8s.io/v1beta1/pods", msg); err != nil {
auth, err := m.CanI(ns, "metrics.k8s.io/v1beta1/pods", ListAccess) return mx, err
if err != nil {
return &mx, err
} }
if !auth {
return &mx, fmt.Errorf("user is not authorized to list pods metrics") key := FQN(ns, "pods")
if entry, ok := m.cache.Get(key); ok {
mxList, ok := entry.(*mv1beta1.PodMetricsList)
if !ok {
return mx, fmt.Errorf("expected podmetricslist but got %T", entry)
}
return mxList, nil
} }
client, err := m.MXDial() client, err := m.MXDial()
if err != nil { if err != nil {
return &mx, err return mx, err
} }
mxList, err := client.MetricsV1beta1().PodMetricses(ns).List(metav1.ListOptions{})
if err != nil {
return mx, err
}
m.cache.Add(key, mxList, mxCacheExpiry)
return client.MetricsV1beta1().PodMetricses(ns).List(metav1.ListOptions{}) return mxList, err
} }
// FetchPodMetrics return all metrics for pods in a given namespace. // FetchPodMetrics return all metrics for pods in a given namespace.
func (m *MetricsServer) FetchPodMetrics(fqn string) (*mv1beta1.PodMetrics, error) { func (m *MetricsServer) FetchPodMetrics(fqn string) (*mv1beta1.PodMetrics, error) {
var mx mv1beta1.PodMetrics var mx *mv1beta1.PodMetrics
if m.Connection == nil { const msg = "user is not authorized to list pod metrics"
return &mx, fmt.Errorf("no client connection")
}
if !m.HasMetrics() {
return &mx, fmt.Errorf("No metrics-server detected on cluster")
}
ns, n := Namespaced(fqn) ns, n := Namespaced(fqn)
if ns == NamespaceAll { if ns == NamespaceAll {
ns = AllNamespaces ns = AllNamespaces
} }
auth, err := m.CanI(ns, "metrics.k8s.io/v1beta1/pods", GetAccess) if err := m.checkAccess(ns, "metrics.k8s.io/v1beta1/pods", msg); err != nil {
if err != nil { return mx, err
return &mx, err
} }
if !auth {
return &mx, fmt.Errorf("user is not authorized to list pod metrics") var key = FQN(ns, "pods")
if entry, ok := m.cache.Get(key); ok {
if list, ok := entry.(*mv1beta1.PodMetricsList); ok && list != nil {
for _, m := range list.Items {
if FQN(m.Namespace, m.Name) == fqn {
return &m, nil
}
}
}
} }
client, err := m.MXDial() client, err := m.MXDial()
if err != nil { if err != nil {
return &mx, err return mx, err
} }
mx, err = client.MetricsV1beta1().PodMetricses(ns).Get(n, metav1.GetOptions{})
if err != nil {
return mx, err
}
m.cache.Add(key, mx, mxCacheExpiry)
return client.MetricsV1beta1().PodMetricses(ns).Get(n, metav1.GetOptions{}) return mx, nil
} }
// PodsMetrics retrieves metrics for all pods in a given namespace. // PodsMetrics retrieves metrics for all pods in a given namespace.
@ -167,7 +231,7 @@ func (m *MetricsServer) PodsMetrics(pods *mv1beta1.PodMetricsList, mmx PodsMetri
var mx PodMetrics var mx PodMetrics
for _, c := range p.Containers { for _, c := range p.Containers {
mx.CurrentCPU += c.Usage.Cpu().MilliValue() mx.CurrentCPU += c.Usage.Cpu().MilliValue()
mx.CurrentMEM += toMB(c.Usage.Memory().Value()) mx.CurrentMEM += ToMB(c.Usage.Memory().Value())
} }
mmx[p.Namespace+"/"+p.Name] = mx mmx[p.Namespace+"/"+p.Name] = mx
} }
@ -178,8 +242,8 @@ func (m *MetricsServer) PodsMetrics(pods *mv1beta1.PodMetricsList, mmx PodsMetri
const megaByte = 1024 * 1024 const megaByte = 1024 * 1024
// toMB converts bytes to megabytes. // ToMB converts bytes to megabytes.
func toMB(v int64) float64 { func ToMB(v int64) float64 {
return float64(v) / megaByte return float64(v) / megaByte
} }

View File

@ -22,6 +22,9 @@ const (
// ClusterScope designates a resource is not namespaced. // ClusterScope designates a resource is not namespaced.
ClusterScope = "-" ClusterScope = "-"
// NotNamespaced designates a non resource namespace.
NotNamespaced = "*"
) )
const ( const (

View File

@ -2,6 +2,7 @@ package config
import ( import (
"io/ioutil" "io/ioutil"
"os"
"path/filepath" "path/filepath"
"sync" "sync"
@ -87,6 +88,13 @@ func (a *Aliases) loadDefaults() {
// Load K9s aliases. // Load K9s aliases.
func (a *Aliases) Load() error { func (a *Aliases) Load() error {
a.loadDefaults() a.loadDefaults()
_, err := os.Stat(K9sAlias)
if os.IsNotExist(err) {
log.Debug().Err(err).Msgf("No custom aliases found")
return nil
}
return a.LoadAliases(K9sAlias) return a.LoadAliases(K9sAlias)
} }
@ -139,8 +147,14 @@ func (a *Aliases) Define(gvr string, aliases ...string) {
} }
} }
// LoadAliases loads alias from a given file. // Load K9s aliases.
func (a *Aliases) LoadAliases(path string) error { func (a *Aliases) Load() error {
a.loadDefaultAliases()
return a.LoadFileAliases(K9sAlias)
}
// LoadFileAliases loads alias from a given file.
func (a *Aliases) LoadFileAliases(path string) error {
f, err := ioutil.ReadFile(path) f, err := ioutil.ReadFile(path)
if err != nil { if err != nil {
log.Debug().Err(err).Msgf("No custom aliases found") log.Debug().Err(err).Msgf("No custom aliases found")
@ -161,6 +175,63 @@ func (a *Aliases) LoadAliases(path string) error {
return nil return nil
} }
func (a *Aliases) loadDefaultAliases() {
a.mx.Lock()
defer a.mx.Unlock()
a.Alias["dp"] = "apps/v1/deployments"
a.Alias["sec"] = "v1/secrets"
a.Alias["jo"] = "batch/v1/jobs"
a.Alias["cr"] = "rbac.authorization.k8s.io/v1/clusterroles"
a.Alias["crb"] = "rbac.authorization.k8s.io/v1/clusterrolebindings"
a.Alias["ro"] = "rbac.authorization.k8s.io/v1/roles"
a.Alias["rb"] = "rbac.authorization.k8s.io/v1/rolebindings"
a.Alias["np"] = "networking.k8s.io/v1/networkpolicies"
const contexts = "contexts"
{
a.Alias["ctx"] = contexts
a.Alias[contexts] = contexts
a.Alias["context"] = contexts
}
const users = "users"
{
a.Alias["usr"] = users
a.Alias[users] = users
a.Alias["user"] = users
}
const groups = "groups"
{
a.Alias["grp"] = groups
a.Alias["group"] = groups
a.Alias[groups] = groups
}
const portFwds = "portforwards"
{
a.Alias["pf"] = portFwds
a.Alias[portFwds] = portFwds
a.Alias["portforward"] = portFwds
}
const benchmarks = "benchmarks"
{
a.Alias["be"] = benchmarks
a.Alias["benchmark"] = benchmarks
a.Alias[benchmarks] = benchmarks
}
const dumps = "screendumps"
{
a.Alias["sd"] = dumps
a.Alias["screendump"] = dumps
a.Alias[dumps] = dumps
}
const pulses = "pulses"
{
a.Alias["hz"] = pulses
a.Alias["pu"] = pulses
a.Alias["pulse"] = pulses
}
}
// Save alias to disk. // Save alias to disk.
func (a *Aliases) Save() error { func (a *Aliases) Save() error {
log.Debug().Msg("[Config] Saving Aliases...") log.Debug().Msg("[Config] Saving Aliases...")

View File

@ -72,7 +72,7 @@ func TestAliasDefine(t *testing.T) {
func TestAliasesLoad(t *testing.T) { func TestAliasesLoad(t *testing.T) {
a := config.NewAliases() a := config.NewAliases()
assert.Nil(t, a.LoadAliases("testdata/alias.yml")) assert.Nil(t, a.LoadFileAliases("testdata/alias.yml"))
assert.Equal(t, 2, len(a.Alias)) assert.Equal(t, 2, len(a.Alias))
} }
@ -82,6 +82,6 @@ func TestAliasesSave(t *testing.T) {
a.Alias["blee"] = "duh" a.Alias["blee"] = "duh"
assert.Nil(t, a.SaveAliases("/tmp/a.yml")) assert.Nil(t, a.SaveAliases("/tmp/a.yml"))
assert.Nil(t, a.LoadAliases("/tmp/a.yml")) assert.Nil(t, a.LoadFileAliases("/tmp/a.yml"))
assert.Equal(t, 2, len(a.Alias)) assert.Equal(t, 2, len(a.Alias))
} }

View File

@ -21,17 +21,31 @@ type StyleListener interface {
} }
type ( type (
// Color represents a color.
Color string
// Colors tracks multiple colors.
Colors []Color
// Styles tracks K9s styling options. // Styles tracks K9s styling options.
Styles struct { Styles struct {
K9s Style `yaml:"k9s"` K9s Style `yaml:"k9s"`
listeners []StyleListener listeners []StyleListener
} }
// Style tracks K9s styles.
Style struct {
Body Body `yaml:"body"`
Frame Frame `yaml:"frame"`
Info Info `yaml:"info"`
Views Views `yaml:"views"`
}
// Body tracks body styles. // Body tracks body styles.
Body struct { Body struct {
FgColor string `yaml:"fgColor"` FgColor Color `yaml:"fgColor"`
BgColor string `yaml:"bgColor"` BgColor Color `yaml:"bgColor"`
LogoColor string `yaml:"logoColor"` LogoColor Color `yaml:"logoColor"`
} }
// Frame tracks frame styles. // Frame tracks frame styles.
@ -45,120 +59,171 @@ type (
// Views tracks individual view styles. // Views tracks individual view styles.
Views struct { Views struct {
Yaml Yaml `yaml:"yaml"` Table Table `yaml:"table"`
Log Log `yaml:"logs"` Xray Xray `yaml:"xray"`
Charts Charts `yaml:"charts"`
Yaml Yaml `yaml:"yaml"`
Log Log `yaml:"logs"`
} }
// Status tracks resource status styles. // Status tracks resource status styles.
Status struct { Status struct {
NewColor string `yaml:"newColor"` NewColor Color `yaml:"newColor"`
ModifyColor string `yaml:"modifyColor"` ModifyColor Color `yaml:"modifyColor"`
AddColor string `yaml:"addColor"` AddColor Color `yaml:"addColor"`
ErrorColor string `yaml:"errorColor"` ErrorColor Color `yaml:"errorColor"`
HighlightColor string `yaml:"highlightColor"` HighlightColor Color `yaml:"highlightColor"`
KillColor string `yaml:"killColor"` KillColor Color `yaml:"killColor"`
CompletedColor string `yaml:"completedColor"` CompletedColor Color `yaml:"completedColor"`
} }
// Log tracks Log styles. // Log tracks Log styles.
Log struct { Log struct {
FgColor string `yaml:"fgColor"` FgColor Color `yaml:"fgColor"`
BgColor string `yaml:"bgColor"` BgColor Color `yaml:"bgColor"`
} }
// Yaml tracks yaml styles. // Yaml tracks yaml styles.
Yaml struct { Yaml struct {
KeyColor string `yaml:"keyColor"` KeyColor Color `yaml:"keyColor"`
ValueColor string `yaml:"valueColor"` ValueColor Color `yaml:"valueColor"`
ColonColor string `yaml:"colonColor"` ColonColor Color `yaml:"colonColor"`
} }
// Title tracks title styles. // Title tracks title styles.
Title struct { Title struct {
FgColor string `yaml:"fgColor"` FgColor Color `yaml:"fgColor"`
BgColor string `yaml:"bgColor"` BgColor Color `yaml:"bgColor"`
HighlightColor string `yaml:"highlightColor"` HighlightColor Color `yaml:"highlightColor"`
CounterColor string `yaml:"counterColor"` CounterColor Color `yaml:"counterColor"`
FilterColor string `yaml:"filterColor"` FilterColor Color `yaml:"filterColor"`
} }
// Info tracks info styles. // Info tracks info styles.
Info struct { Info struct {
SectionColor string `yaml:"sectionColor"` SectionColor Color `yaml:"sectionColor"`
FgColor string `yaml:"fgColor"` FgColor Color `yaml:"fgColor"`
} }
// Border tracks border styles. // Border tracks border styles.
Border struct { Border struct {
FgColor string `yaml:"fgColor"` FgColor Color `yaml:"fgColor"`
FocusColor string `yaml:"focusColor"` FocusColor Color `yaml:"focusColor"`
} }
// Crumb tracks crumbs styles. // Crumb tracks crumbs styles.
Crumb struct { Crumb struct {
FgColor string `yaml:"fgColor"` FgColor Color `yaml:"fgColor"`
BgColor string `yaml:"bgColor"` BgColor Color `yaml:"bgColor"`
ActiveColor string `yaml:"activeColor"` ActiveColor Color `yaml:"activeColor"`
} }
// Table tracks table styles. // Table tracks table styles.
Table struct { Table struct {
FgColor string `yaml:"fgColor"` FgColor Color `yaml:"fgColor"`
BgColor string `yaml:"bgColor"` BgColor Color `yaml:"bgColor"`
CursorColor string `yaml:"cursorColor"` CursorColor Color `yaml:"cursorColor"`
MarkColor string `yaml:"markColor"` MarkColor Color `yaml:"markColor"`
Header TableHeader `yaml:"header"` Header TableHeader `yaml:"header"`
} }
// TableHeader tracks table header styles. // TableHeader tracks table header styles.
TableHeader struct { TableHeader struct {
FgColor string `yaml:"fgColor"` FgColor Color `yaml:"fgColor"`
BgColor string `yaml:"bgColor"` BgColor Color `yaml:"bgColor"`
SorterColor string `yaml:"sorterColor"` SorterColor Color `yaml:"sorterColor"`
} }
// Xray tracks xray styles. // Xray tracks xray styles.
Xray struct { Xray struct {
FgColor string `yaml:"fgColor"` FgColor Color `yaml:"fgColor"`
BgColor string `yaml:"bgColor"` BgColor Color `yaml:"bgColor"`
CursorColor string `yaml:"cursorColor"` CursorColor Color `yaml:"cursorColor"`
GraphicColor string `yaml:"graphicColor"` GraphicColor Color `yaml:"graphicColor"`
ShowIcons bool `yaml:"showIcons"` ShowIcons bool `yaml:"showIcons"`
} }
// Menu tracks menu styles. // Menu tracks menu styles.
Menu struct { Menu struct {
FgColor string `yaml:"fgColor"` FgColor Color `yaml:"fgColor"`
KeyColor string `yaml:"keyColor"` KeyColor Color `yaml:"keyColor"`
NumKeyColor string `yaml:"numKeyColor"` NumKeyColor Color `yaml:"numKeyColor"`
} }
// Style tracks K9s styles. // Charts tracks charts styles.
Style struct { Charts struct {
Body Body `yaml:"body"` BgColor Color `yaml:"bgColor"`
Frame Frame `yaml:"frame"` DialBgColor Color `yaml:"dialBgColor"`
Info Info `yaml:"info"` ChartBgColor Color `yaml:"chartBgColor"`
Table Table `yaml:"table"` DefaultDialColors Colors `yaml:"defaultDialColors"`
Xray Xray `yaml:"xray"` DefaultChartColors Colors `yaml:"defaultChartColors"`
Views Views `yaml:"views"` ResourceColors map[string]Colors `yaml:"resourceColors"`
} }
) )
const (
// DefaultColor represents a default color.
DefaultColor Color = "default"
// TransparentColor represents the terminal bg color.
TransparentColor Color = "-"
)
// NewColor returns a new color.
func NewColor(c string) Color {
return Color(c)
}
// String returns color as string.
func (c Color) String() string {
return string(c)
}
// Color returns a view color.
func (c Color) Color() tcell.Color {
if c == DefaultColor {
return tcell.ColorDefault
}
if color, ok := tcell.ColorNames[c.String()]; ok {
return color
}
return tcell.GetColor(c.String())
}
// Colors converts series string colors to colors.
func (c Colors) Colors() []tcell.Color {
cc := make([]tcell.Color, 0, len(c))
for _, color := range c {
cc = append(cc, color.Color())
}
return cc
}
func newStyle() Style { func newStyle() Style {
return Style{ return Style{
Body: newBody(), Body: newBody(),
Frame: newFrame(), Frame: newFrame(),
Info: newInfo(), Info: newInfo(),
Table: newTable(),
Views: newViews(), Views: newViews(),
Xray: newXray(),
} }
} }
func newCharts() Charts {
return Charts{
BgColor: "default",
DialBgColor: "default",
ChartBgColor: "default",
DefaultDialColors: Colors{Color("palegreen"), Color("orangered")},
DefaultChartColors: Colors{Color("palegreen"), Color("orangered")},
}
}
func newViews() Views { func newViews() Views {
return Views{ return Views{
Yaml: newYaml(), Table: newTable(),
Log: newLog(), Xray: newXray(),
Charts: newCharts(),
Yaml: newYaml(),
Log: newLog(),
} }
} }
@ -188,7 +253,7 @@ func newStatus() Status {
ErrorColor: "orangered", ErrorColor: "orangered",
HighlightColor: "aqua", HighlightColor: "aqua",
KillColor: "mediumpurple", KillColor: "mediumpurple",
CompletedColor: "gray", CompletedColor: "lightgray",
} }
} }
@ -292,6 +357,11 @@ func NewStyles() *Styles {
} }
} }
// Reset resets styles.
func (s *Styles) Reset() {
s.K9s = newStyle()
}
// DefaultSkin loads the default skin // DefaultSkin loads the default skin
func (s *Styles) DefaultSkin() { func (s *Styles) DefaultSkin() {
s.K9s = newStyle() s.K9s = newStyle()
@ -299,12 +369,12 @@ func (s *Styles) DefaultSkin() {
// FgColor returns the foreground color. // FgColor returns the foreground color.
func (s *Styles) FgColor() tcell.Color { func (s *Styles) FgColor() tcell.Color {
return AsColor(s.Body().FgColor) return s.Body().FgColor.Color()
} }
// BgColor returns the background color. // BgColor returns the background color.
func (s *Styles) BgColor() tcell.Color { func (s *Styles) BgColor() tcell.Color {
return AsColor(s.Body().BgColor) return s.Body().BgColor.Color()
} }
// AddListener registers a new listener. // AddListener registers a new listener.
@ -353,14 +423,19 @@ func (s *Styles) Title() Title {
return s.Frame().Title return s.Frame().Title
} }
// Charts returns charts styles.
func (s *Styles) Charts() Charts {
return s.K9s.Views.Charts
}
// Table returns table styles. // Table returns table styles.
func (s *Styles) Table() Table { func (s *Styles) Table() Table {
return s.K9s.Table return s.K9s.Views.Table
} }
// Xray returns xray styles. // Xray returns xray styles.
func (s *Styles) Xray() Xray { func (s *Styles) Xray() Xray {
return s.K9s.Xray return s.K9s.Views.Xray
} }
// Views returns views styles. // Views returns views styles.
@ -388,19 +463,7 @@ func (s *Styles) Update() {
tview.Styles.PrimitiveBackgroundColor = s.BgColor() tview.Styles.PrimitiveBackgroundColor = s.BgColor()
tview.Styles.ContrastBackgroundColor = s.BgColor() tview.Styles.ContrastBackgroundColor = s.BgColor()
tview.Styles.PrimaryTextColor = s.FgColor() tview.Styles.PrimaryTextColor = s.FgColor()
tview.Styles.BorderColor = AsColor(s.K9s.Frame.Border.FgColor) tview.Styles.BorderColor = s.K9s.Frame.Border.FgColor.Color()
tview.Styles.FocusColor = AsColor(s.K9s.Frame.Border.FocusColor) tview.Styles.FocusColor = s.K9s.Frame.Border.FocusColor.Color()
s.fireStylesChanged() s.fireStylesChanged()
} }
// AsColor checks color index, if match return color otherwise pink it is.
func AsColor(c string) tcell.Color {
if c == "default" {
return tcell.ColorDefault
}
if color, ok := tcell.ColorNames[c]; ok {
return color
}
return tcell.GetColor(c)
}

View File

@ -9,7 +9,7 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
func TestAsColor(t *testing.T) { func TestColor(t *testing.T) {
uu := map[string]tcell.Color{ uu := map[string]tcell.Color{
"blah": tcell.ColorDefault, "blah": tcell.ColorDefault,
"blue": tcell.ColorBlue, "blue": tcell.ColorBlue,
@ -20,7 +20,7 @@ func TestAsColor(t *testing.T) {
for k := range uu { for k := range uu {
c, u := k, uu[k] c, u := k, uu[k]
t.Run(k, func(t *testing.T) { t.Run(k, func(t *testing.T) {
assert.Equal(t, u, config.AsColor(c)) assert.Equal(t, u, config.NewColor(c).Color())
}) })
} }
} }
@ -30,9 +30,9 @@ func TestSkinNone(t *testing.T) {
assert.Nil(t, s.Load("testdata/empty_skin.yml")) assert.Nil(t, s.Load("testdata/empty_skin.yml"))
s.Update() s.Update()
assert.Equal(t, "cadetblue", s.Body().FgColor) assert.Equal(t, "cadetblue", s.Body().FgColor.String())
assert.Equal(t, "black", s.Body().BgColor) assert.Equal(t, "black", s.Body().BgColor.String())
assert.Equal(t, "black", s.Table().BgColor) assert.Equal(t, "black", s.Table().BgColor.String())
assert.Equal(t, tcell.ColorCadetBlue, s.FgColor()) assert.Equal(t, tcell.ColorCadetBlue, s.FgColor())
assert.Equal(t, tcell.ColorBlack, s.BgColor()) assert.Equal(t, tcell.ColorBlack, s.BgColor())
assert.Equal(t, tcell.ColorBlack, tview.Styles.PrimitiveBackgroundColor) assert.Equal(t, tcell.ColorBlack, tview.Styles.PrimitiveBackgroundColor)
@ -43,9 +43,9 @@ func TestSkin(t *testing.T) {
assert.Nil(t, s.Load("testdata/black_and_wtf.yml")) assert.Nil(t, s.Load("testdata/black_and_wtf.yml"))
s.Update() s.Update()
assert.Equal(t, "white", s.Body().FgColor) assert.Equal(t, "white", s.Body().FgColor.String())
assert.Equal(t, "black", s.Body().BgColor) assert.Equal(t, "black", s.Body().BgColor.String())
assert.Equal(t, "black", s.Table().BgColor) assert.Equal(t, "black", s.Table().BgColor.String())
assert.Equal(t, tcell.ColorWhite, s.FgColor()) assert.Equal(t, tcell.ColorWhite, s.FgColor())
assert.Equal(t, tcell.ColorBlack, s.BgColor()) assert.Equal(t, tcell.ColorBlack, s.BgColor())
assert.Equal(t, tcell.ColorBlack, tview.Styles.PrimitiveBackgroundColor) assert.Equal(t, tcell.ColorBlack, tview.Styles.PrimitiveBackgroundColor)

View File

@ -33,23 +33,21 @@ func (c *Container) List(ctx context.Context, _ string) ([]runtime.Object, error
if !ok { if !ok {
return nil, fmt.Errorf("no context path for %q", c.gvr) return nil, fmt.Errorf("no context path for %q", c.gvr)
} }
var (
pmx *mv1beta1.PodMetrics
err error
)
if withMx, ok := ctx.Value(internal.KeyWithMetrics).(bool); withMx || !ok {
if pmx, err = client.DialMetrics(c.Client()).FetchPodMetrics(fqn); err != nil {
log.Warn().Err(err).Msgf("No metrics found for pod %q", fqn)
}
}
po, err := c.fetchPod(fqn) po, err := c.fetchPod(fqn)
if err != nil { if err != nil {
return nil, err return nil, err
} }
var pmx *mv1beta1.PodMetrics
if c.Client().HasMetrics() {
mx := client.NewMetricsServer(c.Client())
if c.Client() != nil {
var err error
pmx, err = mx.FetchPodMetrics(fqn)
if err != nil {
log.Warn().Err(err).Msgf("No metrics found for pod %q", fqn)
}
}
}
res := make([]runtime.Object, 0, len(po.Spec.InitContainers)+len(po.Spec.Containers)) res := make([]runtime.Object, 0, len(po.Spec.InitContainers)+len(po.Spec.Containers))
for _, co := range po.Spec.InitContainers { for _, co := range po.Spec.InitContainers {
res = append(res, makeContainerRes(co, po, pmx, true)) res = append(res, makeContainerRes(co, po, pmx, true))

View File

@ -29,6 +29,11 @@ type Deployment struct {
Resource Resource
} }
// IsHappy check for happy deployments.
func (d *Deployment) IsHappy(dp appsv1.Deployment) bool {
return dp.Status.Replicas == dp.Status.AvailableReplicas
}
// Scale a Deployment. // Scale a Deployment.
func (d *Deployment) Scale(path string, replicas int32) error { func (d *Deployment) Scale(path string, replicas int32) error {
ns, n := client.Namespaced(path) ns, n := client.Namespaced(path)

View File

@ -32,6 +32,11 @@ type DaemonSet struct {
Resource Resource
} }
// IsHappy check for happy deployments.
func (d *DaemonSet) IsHappy(ds appsv1.DaemonSet) bool {
return ds.Status.DesiredNumberScheduled == ds.Status.CurrentNumberScheduled
}
// Restart a DaemonSet rollout. // Restart a DaemonSet rollout.
func (d *DaemonSet) Restart(path string) error { func (d *DaemonSet) Restart(path string) error {
ds, err := d.GetInstance(path) ds, err := d.GetInstance(path)

View File

@ -34,10 +34,14 @@ func (n *Node) List(ctx context.Context, ns string) ([]runtime.Object, error) {
log.Warn().Msgf("No label selector found in context") log.Warn().Msgf("No label selector found in context")
} }
mx := client.NewMetricsServer(n.Client()) var (
nmx, err := mx.FetchNodesMetrics() nmx *mv1beta1.NodeMetricsList
if err != nil { err error
log.Warn().Err(err).Msgf("No node metrics") )
if withMx, ok := ctx.Value(internal.KeyWithMetrics).(bool); withMx || !ok {
if nmx, err = client.DialMetrics(n.Client()).FetchNodesMetrics(); err != nil {
log.Warn().Err(err).Msgf("No node metrics")
}
} }
nn, err := FetchNodes(n.Factory, labels) nn, err := FetchNodes(n.Factory, labels)

View File

@ -38,6 +38,16 @@ type Pod struct {
Resource Resource
} }
// IsHappy check for happy deployments.
func (p *Pod) IsHappy(po v1.Pod) bool {
for _, c := range po.Status.Conditions {
if c.Status == v1.ConditionFalse {
return false
}
}
return true
}
// Get returns a resource instance if found, else an error. // Get returns a resource instance if found, else an error.
func (p *Pod) Get(ctx context.Context, path string) (runtime.Object, error) { func (p *Pod) Get(ctx context.Context, path string) (runtime.Object, error) {
o, err := p.Resource.Get(ctx, path) o, err := p.Resource.Get(ctx, path)
@ -50,11 +60,11 @@ func (p *Pod) Get(ctx context.Context, path string) (runtime.Object, error) {
return nil, fmt.Errorf("expecting *unstructured.Unstructured but got `%T", o) return nil, fmt.Errorf("expecting *unstructured.Unstructured but got `%T", o)
} }
// No Deal! var pmx *mv1beta1.PodMetrics
mx := client.NewMetricsServer(p.Client()) if withMx, ok := ctx.Value(internal.KeyWithMetrics).(bool); withMx || !ok {
pmx, err := mx.FetchPodMetrics(path) if pmx, err = client.DialMetrics(p.Client()).FetchPodMetrics(path); err != nil {
if err != nil { log.Warn().Err(err).Msgf("No pod metrics")
log.Warn().Err(err).Msgf("No pods metrics") }
} }
return &render.PodWithMetrics{Raw: u, MX: pmx}, nil return &render.PodWithMetrics{Raw: u, MX: pmx}, nil
@ -77,10 +87,11 @@ func (p *Pod) List(ctx context.Context, ns string) ([]runtime.Object, error) {
return oo, err return oo, err
} }
mx := client.NewMetricsServer(p.Client()) var pmx *mv1beta1.PodMetricsList
pmx, err := mx.FetchPodsMetrics(ns) if withMx, ok := ctx.Value(internal.KeyWithMetrics).(bool); withMx || !ok {
if err != nil { if pmx, err = client.DialMetrics(p.Client()).FetchPodsMetrics(ns); err != nil {
log.Warn().Err(err).Msgf("No pods metrics") log.Warn().Err(err).Msgf("No pods metrics")
}
} }
var res []runtime.Object var res []runtime.Object
@ -128,7 +139,7 @@ func (p *Pod) Containers(path string, includeInit bool) ([]string, error) {
return nil, err return nil, err
} }
cc := []string{} cc := make([]string, 0, len(pod.Spec.Containers)+len(pod.Spec.InitContainers))
for _, c := range pod.Spec.Containers { for _, c := range pod.Spec.Containers {
cc = append(cc, c.Name) cc = append(cc, c.Name)
} }

18
internal/dao/pulse.go Normal file
View File

@ -0,0 +1,18 @@
package dao
import (
"context"
"fmt"
"k8s.io/apimachinery/pkg/runtime"
)
// Pulse tracks pulses.
type Pulse struct {
NonResource
}
// List lists out pulses.
func (h *Pulse) List(ctx context.Context, ns string) ([]runtime.Object, error) {
return nil, fmt.Errorf("NYI")
}

View File

@ -142,6 +142,13 @@ func loadNonResource(m ResourceMetas) {
} }
func loadK9s(m ResourceMetas) { func loadK9s(m ResourceMetas) {
m[client.NewGVR("pulses")] = metav1.APIResource{
Name: "pulses",
Kind: "Pulse",
SingularName: "pulses",
ShortNames: []string{"hz", "pu"},
Categories: []string{"k9s"},
}
m[client.NewGVR("xrays")] = metav1.APIResource{ m[client.NewGVR("xrays")] = metav1.APIResource{
Name: "xray", Name: "xray",
Kind: "XRays", Kind: "XRays",

23
internal/dao/rs.go Normal file
View File

@ -0,0 +1,23 @@
package dao
import (
appsv1 "k8s.io/api/apps/v1"
)
// ReplicaSet represents a replicaset K8s resource.
type ReplicaSet struct {
Resource
}
// IsHappy check for happy deployments.
func (d *ReplicaSet) IsHappy(rs appsv1.ReplicaSet) bool {
if rs.Status.Replicas == 0 && rs.Status.Replicas != rs.Status.ReadyReplicas {
return false
}
if rs.Status.Replicas != 0 && rs.Status.Replicas != rs.Status.ReadyReplicas {
return false
}
return true
}

View File

@ -29,6 +29,11 @@ type StatefulSet struct {
Resource Resource
} }
// IsHappy check for happy sts.
func (s *StatefulSet) IsHappy(sts appsv1.StatefulSet) bool {
return sts.Status.Replicas == sts.Status.ReadyReplicas
}
// Scale a StatefulSet. // Scale a StatefulSet.
func (s *StatefulSet) Scale(path string, replicas int32) error { func (s *StatefulSet) Scale(path string, replicas int32) error {
ns, n := client.Namespaced(path) ns, n := client.Namespaced(path)

54
internal/health/check.go Normal file
View File

@ -0,0 +1,54 @@
package health
import (
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
)
// Check tracks resource health.
type Check struct {
Counts
GVR string
}
// Checks represents a collection of health checks.
type Checks []*Check
// NewCheck returns a new health check.
func NewCheck(gvr string) *Check {
return &Check{
GVR: gvr,
Counts: make(Counts),
}
}
// Set sets a health metric.
func (c *Check) Set(l Level, v int) {
c.Counts[l] = v
}
// Inc increments a health metric.
func (c *Check) Inc(l Level) {
c.Counts[l]++
}
// Total stores a metric total.
func (c *Check) Total(n int) {
c.Counts[Corpus] = n
}
// Tally retrieves a given health metric.
func (c *Check) Tally(l Level) int {
return c.Counts[l]
}
// GetObjectKind returns a schema object.
func (Check) GetObjectKind() schema.ObjectKind {
return nil
}
// DeepCopyObject returns a container copy.
func (c Check) DeepCopyObject() runtime.Object {
return c
}

View File

@ -0,0 +1,26 @@
package health_test
import (
"testing"
"github.com/derailed/k9s/internal/health"
"github.com/stretchr/testify/assert"
)
func TestCheck(t *testing.T) {
var cc health.Checks
c := health.NewCheck("test")
n := 0
for i := 0; i < 10; i++ {
c.Inc(health.OK)
cc = append(cc, c)
n++
}
c.Total(n)
assert.Equal(t, 10, len(cc))
assert.Equal(t, 10, c.Tally(health.Corpus))
assert.Equal(t, 10, c.Tally(health.OK))
assert.Equal(t, 0, c.Tally(health.Toast))
}

44
internal/health/types.go Normal file
View File

@ -0,0 +1,44 @@
package health
// Level tracks health count categories.
type Level int
const (
// Unknown represents no health level.
Unknown Level = 1 << iota
// Corpus tracks total health.
Corpus
// OK tracks healhy.
OK
// Warn tracks health warnings.
Warn
// Toast tracks unhealties.
Toast
)
// Message represents a health message.
type Message struct {
Level Level
Message string
GVR string
FQN string
}
// Messages tracks a collection of messages.
type Messages []Message
// Counts tracks health counts by category.
type Counts map[Level]int
// Vital tracks a resource vitals.
type Vital struct {
Resource string
Total, OK, Toast int
}
// Vitals tracks a collection of resource health.
type Vitals []Vital

View File

@ -25,4 +25,6 @@ const (
KeyApp ContextKey = "app" KeyApp ContextKey = "app"
KeyStyles ContextKey = "styles" KeyStyles ContextKey = "styles"
KeyMetrics ContextKey = "metrics" KeyMetrics ContextKey = "metrics"
KeyToast ContextKey = "toast"
KeyWithMetrics ContextKey = "withMetrics"
) )

View File

@ -35,7 +35,7 @@ type (
func NewCluster(f dao.Factory) *Cluster { func NewCluster(f dao.Factory) *Cluster {
return &Cluster{ return &Cluster{
factory: f, factory: f,
mx: client.NewMetricsServer(f.Client()), mx: client.DialMetrics(f.Client()),
} }
} }

156
internal/model/flash.go Normal file
View File

@ -0,0 +1,156 @@
package model
import (
"context"
"fmt"
"time"
"github.com/rs/zerolog/log"
)
const (
// DefaultFlashDelay sets the flash clear delay.
DefaultFlashDelay = 3 * time.Second
// FlashInfo represents an info message.
FlashInfo FlashLevel = iota
// FlashWarn represents an warning message.
FlashWarn
// FlashErr represents an error message.
FlashErr
)
// LevelMessage tracks an message and severity.
type LevelMessage struct {
Level FlashLevel
Text string
}
func newClearMessage() LevelMessage {
return LevelMessage{}
}
// IsClear returns true if message is empty.
func (l LevelMessage) IsClear() bool {
return l.Text == ""
}
// FlashLevel represents flash message severity.
type FlashLevel int
// FlashChan represents a flash event channel.
type FlashChan chan LevelMessage
// FlashListener represents a text model listener.
type FlashListener interface {
// FlashChanged notifies the model changed.
FlashChanged(FlashLevel, string)
// FlashCleared notifies when the filter changed.
FlashCleared()
}
// Flash represents a flash message model.
type Flash struct {
msg LevelMessage
cancel context.CancelFunc
delay time.Duration
msgChan chan LevelMessage
}
// NewFlash returns a new instance.
func NewFlash(dur time.Duration) *Flash {
return &Flash{
delay: dur,
msgChan: make(FlashChan, 3),
}
}
// Channel returns the flash channel.
func (f *Flash) Channel() FlashChan {
return f.msgChan
}
// Info displays an info flash message.
func (f *Flash) Info(msg string) {
f.SetMessage(FlashInfo, msg)
}
// Infof displays a formatted info flash message.
func (f *Flash) Infof(fmat string, args ...interface{}) {
f.Info(fmt.Sprintf(fmat, args...))
}
// Warn displays a warning flash message.
func (f *Flash) Warn(msg string) {
log.Warn().Msg(msg)
f.SetMessage(FlashWarn, msg)
}
// Warnf displays a formatted warning flash message.
func (f *Flash) Warnf(fmat string, args ...interface{}) {
f.Warn(fmt.Sprintf(fmat, args...))
}
// Err displays an error flash message.
func (f *Flash) Err(err error) {
log.Error().Msg(err.Error())
f.SetMessage(FlashErr, err.Error())
}
// Errf displays a formatted error flash message.
func (f *Flash) Errf(fmat string, args ...interface{}) {
var err error
for _, a := range args {
switch e := a.(type) {
case error:
err = e
}
}
log.Error().Err(err).Msgf(fmat, args...)
f.SetMessage(FlashErr, fmt.Sprintf(fmat, args...))
}
// Clear clears the flash message.
func (f *Flash) Clear() {
f.fireCleared()
}
// SetMessage sets the flash level message.
func (f *Flash) SetMessage(level FlashLevel, msg string) {
if f.cancel != nil {
f.cancel()
f.cancel = nil
}
f.setLevelMessage(LevelMessage{Level: level, Text: msg})
f.fireFlashChanged()
var ctx context.Context
ctx, f.cancel = context.WithCancel(context.Background())
go f.refresh(ctx)
}
func (f *Flash) refresh(ctx context.Context) {
for {
select {
case <-ctx.Done():
return
case <-time.After(f.delay):
f.fireCleared()
return
}
}
}
func (f *Flash) setLevelMessage(msg LevelMessage) {
f.msg = msg
}
func (f *Flash) fireFlashChanged() {
f.msgChan <- f.msg
}
func (f *Flash) fireCleared() {
f.msgChan <- newClearMessage()
}

View File

@ -0,0 +1,101 @@
package model_test
import (
"errors"
"fmt"
"sync"
"testing"
"time"
"github.com/derailed/k9s/internal/model"
"github.com/stretchr/testify/assert"
)
func TestFlash(t *testing.T) {
const delay = 1 * time.Millisecond
uu := map[string]struct {
level model.FlashLevel
e string
}{
"info": {level: model.FlashInfo, e: "blee"},
"warn": {level: model.FlashWarn, e: "blee"},
"err": {level: model.FlashErr, e: "blee"},
}
for k := range uu {
u := uu[k]
t.Run(k, func(t *testing.T) {
f := model.NewFlash(delay)
v := newFlash()
go v.listen(f.Channel())
switch u.level {
case model.FlashInfo:
f.Info(u.e)
case model.FlashWarn:
f.Warn(u.e)
case model.FlashErr:
f.Err(errors.New(u.e))
}
time.Sleep(5 * delay)
s, _, l, m := v.getMetrics()
assert.Equal(t, 1, s)
assert.Equal(t, u.level, l)
assert.Equal(t, u.e, m)
})
}
}
func TestFlashBurst(t *testing.T) {
const delay = 1 * time.Millisecond
f := model.NewFlash(delay)
v := newFlash()
go v.listen(f.Channel())
count := 5
for i := 1; i <= count; i++ {
f.Info(fmt.Sprintf("test-%d", i))
}
time.Sleep(2 * delay)
s, _, l, m := v.getMetrics()
assert.Equal(t, count, s)
assert.Equal(t, model.FlashInfo, l)
assert.Equal(t, fmt.Sprintf("test-%d", count), m)
}
type flash struct {
set, clear int
level model.FlashLevel
msg string
mx sync.RWMutex
}
func newFlash() *flash {
return &flash{}
}
func (f *flash) getMetrics() (int, int, model.FlashLevel, string) {
f.mx.RLock()
defer f.mx.RUnlock()
return f.set, f.clear, f.level, f.msg
}
func (f *flash) listen(c model.FlashChan) {
for m := range c {
f.mx.Lock()
{
if m.IsClear() {
f.clear++
} else {
f.set++
f.level, f.msg = m.Level, m.Text
}
}
f.mx.Unlock()
}
}

160
internal/model/pulse.go Normal file
View File

@ -0,0 +1,160 @@
package model
import (
"context"
"fmt"
"sync/atomic"
"time"
"github.com/derailed/k9s/internal"
"github.com/derailed/k9s/internal/dao"
"github.com/derailed/k9s/internal/health"
"github.com/rs/zerolog/log"
"k8s.io/apimachinery/pkg/runtime"
)
const defaultRefreshRate = 5 * time.Second
// PulseListener represents a health model listener.
type PulseListener interface {
// PulseChanged notifies the model data changed.
PulseChanged(*health.Check)
// TreeFailed notifies the health check failed.
PulseFailed(error)
}
// Pulse tracks multiple resources health.
type Pulse struct {
gvr string
namespace string
inUpdate int32
listeners []PulseListener
refreshRate time.Duration
health *PulseHealth
data health.Checks
}
// NewPulse returns a new pulse.
func NewPulse(gvr string) *Pulse {
return &Pulse{
gvr: gvr,
refreshRate: defaultRefreshRate,
}
}
// Watch monitors pulses.
func (p *Pulse) Watch(ctx context.Context) {
p.Refresh(ctx)
go p.updater(ctx)
}
func (p *Pulse) updater(ctx context.Context) {
defer log.Debug().Msgf("Pulse canceled -- %q", p.gvr)
rate := initRefreshRate
for {
select {
case <-ctx.Done():
return
case <-time.After(rate):
rate = p.refreshRate
p.refresh(ctx)
}
}
}
// Refresh update the model now.
func (p *Pulse) Refresh(ctx context.Context) {
for _, d := range p.data {
p.firePulseChanged(d)
}
p.refresh(ctx)
}
func (p *Pulse) refresh(ctx context.Context) {
if !atomic.CompareAndSwapInt32(&p.inUpdate, 0, 1) {
log.Debug().Msgf("Dropping update...")
return
}
defer atomic.StoreInt32(&p.inUpdate, 0)
if err := p.reconcile(ctx); err != nil {
log.Error().Err(err).Msg("Reconcile failed")
p.firePulseFailed(err)
return
}
}
func (p *Pulse) list(ctx context.Context) ([]runtime.Object, error) {
f, ok := ctx.Value(internal.KeyFactory).(dao.Factory)
if !ok {
return nil, fmt.Errorf("expected Factory in context but got %T", ctx.Value(internal.KeyFactory))
}
if p.health == nil {
p.health = NewPulseHealth(f)
}
ctx = context.WithValue(ctx, internal.KeyFields, "")
ctx = context.WithValue(ctx, internal.KeyWithMetrics, false)
return p.health.List(ctx, p.namespace)
}
func (p *Pulse) reconcile(ctx context.Context) error {
oo, err := p.list(ctx)
if err != nil {
return err
}
p.data = health.Checks{}
for _, o := range oo {
c, ok := o.(*health.Check)
if !ok {
return fmt.Errorf("Expecting health check but got %T", o)
}
p.data = append(p.data, c)
p.firePulseChanged(c)
}
return nil
}
// GetNamespace returns the model namespace.
func (p *Pulse) GetNamespace() string {
return p.namespace
}
// SetNamespace sets up model namespace.
func (p *Pulse) SetNamespace(ns string) {
p.namespace = ns
}
// AddListener adds a listener.
func (p *Pulse) AddListener(l PulseListener) {
p.listeners = append(p.listeners, l)
}
// RemoveListener delete a listener.
func (p *Pulse) RemoveListener(l PulseListener) {
victim := -1
for i, lis := range p.listeners {
if lis == l {
victim = i
break
}
}
if victim >= 0 {
p.listeners = append(p.listeners[:victim], p.listeners[victim+1:]...)
}
}
func (p *Pulse) firePulseChanged(check *health.Check) {
for _, l := range p.listeners {
l.PulseChanged(check)
}
}
func (p *Pulse) firePulseFailed(err error) {
for _, l := range p.listeners {
l.PulseFailed(err)
}
}

View File

@ -0,0 +1,117 @@
package model
import (
"context"
"fmt"
"math"
"time"
"github.com/derailed/k9s/internal/client"
"github.com/derailed/k9s/internal/dao"
"github.com/derailed/k9s/internal/health"
"github.com/derailed/k9s/internal/render"
"github.com/rs/zerolog/log"
"k8s.io/apimachinery/pkg/runtime"
)
// PulseHealth tracks resources health.
type PulseHealth struct {
factory dao.Factory
}
// NewPulseHealth returns a new instance.
func NewPulseHealth(f dao.Factory) *PulseHealth {
return &PulseHealth{
factory: f,
}
}
// List returns a canned collection of resources health.
func (h *PulseHealth) List(ctx context.Context, ns string) ([]runtime.Object, error) {
defer func(t time.Time) {
log.Debug().Msgf("PulseHealthCheck %v", time.Since(t))
}(time.Now())
gvrs := []string{
"v1/pods",
"v1/events",
"apps/v1/replicasets",
"apps/v1/deployments",
"apps/v1/statefulsets",
"apps/v1/daemonsets",
"batch/v1/jobs",
"v1/persistentvolumes",
}
hh := make([]runtime.Object, 0, 10)
for _, gvr := range gvrs {
c, err := h.check(ctx, ns, gvr)
if err != nil {
return nil, err
}
hh = append(hh, c)
}
mm, err := h.checkMetrics()
if err != nil {
return hh, nil
}
for _, m := range mm {
hh = append(hh, m)
}
return hh, nil
}
func (h *PulseHealth) checkMetrics() (health.Checks, error) {
dial := client.DialMetrics(h.factory.Client())
nmx, err := dial.FetchNodesMetrics()
if err != nil {
log.Error().Err(err).Msgf("Fetching metrics")
return nil, err
}
var cpu, mem float64
for _, mx := range nmx.Items {
cpu += float64(mx.Usage.Cpu().MilliValue())
mem += client.ToMB(mx.Usage.Memory().Value())
}
c1 := health.NewCheck("cpu")
c1.Set(health.OK, int(math.Round(cpu)))
c2 := health.NewCheck("mem")
c2.Set(health.OK, int(math.Round(mem)))
return health.Checks{c1, c2}, nil
}
func (h *PulseHealth) check(ctx context.Context, ns, gvr string) (*health.Check, error) {
meta, ok := Registry[gvr]
if !ok {
return nil, fmt.Errorf("No meta for %q", gvr)
}
if meta.DAO == nil {
meta.DAO = &dao.Resource{}
}
meta.DAO.Init(h.factory, client.NewGVR(gvr))
oo, err := meta.DAO.List(ctx, ns)
if err != nil {
return nil, err
}
c := health.NewCheck(gvr)
c.Total(len(oo))
rr, re := make(render.Rows, len(oo)), meta.Renderer
for i, o := range oo {
if err := re.Render(o, ns, &rr[i]); err != nil {
return nil, err
}
if !render.Happy(ns, rr[i]) {
c.Inc(health.Toast)
} else {
c.Inc(health.OK)
}
}
return c, nil
}

View File

@ -14,6 +14,9 @@ var Registry = map[string]ResourceMeta{
DAO: &dao.Chart{}, DAO: &dao.Chart{},
Renderer: &render.Chart{}, Renderer: &render.Chart{},
}, },
"pulses": {
DAO: &dao.Pulse{},
},
"openfaas": { "openfaas": {
DAO: &dao.OpenFaas{}, DAO: &dao.OpenFaas{},
Renderer: &render.OpenFaas{}, Renderer: &render.OpenFaas{},

View File

@ -108,8 +108,8 @@ func (s *Stack) Pop() (Component, bool) {
var c Component var c Component
s.mx.Lock() s.mx.Lock()
{ {
c = s.components[s.size()] c = s.components[len(s.components)-1]
s.components = s.components[:s.size()] s.components = s.components[:len(s.components)-1]
} }
s.mx.Unlock() s.mx.Unlock()
s.notify(StackPop, c) s.notify(StackPop, c)
@ -163,11 +163,7 @@ func (s *Stack) Top() Component {
return nil return nil
} }
return s.components[s.size()] return s.components[len(s.components)-1]
}
func (s *Stack) size() int {
return len(s.components) - 1
} }
func (s *Stack) notify(a StackAction, c Component) { func (s *Stack) notify(a StackAction, c Component) {

View File

@ -162,6 +162,7 @@ func (t *Table) SetRefreshRate(d time.Duration) {
// ClusterWide checks if resource is scope for all namespaces. // ClusterWide checks if resource is scope for all namespaces.
func (t *Table) ClusterWide() bool { func (t *Table) ClusterWide() bool {
log.Debug().Msgf("CLUSTER-WIDE %q", t.namespace)
return client.IsClusterWide(t.namespace) return client.IsClusterWide(t.namespace)
} }
@ -219,6 +220,7 @@ func (t *Table) list(ctx context.Context, a dao.Accessor) ([]runtime.Object, err
if client.IsClusterScoped(t.namespace) { if client.IsClusterScoped(t.namespace) {
ns = client.AllNamespaces ns = client.AllNamespaces
} }
return a.List(ctx, ns) return a.List(ctx, ns)
} }

View File

@ -29,10 +29,11 @@ func TestTableReconcile(t *testing.T) {
f.rows = []runtime.Object{load(t, "p1")} f.rows = []runtime.Object{load(t, "p1")}
ctx := context.WithValue(context.Background(), internal.KeyFactory, f) ctx := context.WithValue(context.Background(), internal.KeyFactory, f)
ctx = context.WithValue(ctx, internal.KeyFields, "") ctx = context.WithValue(ctx, internal.KeyFields, "")
ctx = context.WithValue(ctx, internal.KeyWithMetrics, false)
err := ta.reconcile(ctx) err := ta.reconcile(ctx)
assert.Nil(t, err) assert.Nil(t, err)
data := ta.Peek() data := ta.Peek()
assert.Equal(t, 15, len(data.Header)) assert.Equal(t, 17, len(data.Header))
assert.Equal(t, 1, len(data.RowEvents)) assert.Equal(t, 1, len(data.RowEvents))
assert.Equal(t, client.NamespaceAll, data.Namespace) assert.Equal(t, client.NamespaceAll, data.Namespace)
} }
@ -55,6 +56,7 @@ func TestTableGet(t *testing.T) {
f := makeFactory() f := makeFactory()
f.rows = []runtime.Object{load(t, "p1")} f.rows = []runtime.Object{load(t, "p1")}
ctx := context.WithValue(context.Background(), internal.KeyFactory, f) ctx := context.WithValue(context.Background(), internal.KeyFactory, f)
ctx = context.WithValue(ctx, internal.KeyWithMetrics, false)
row, err := ta.Get(ctx, "fred") row, err := ta.Get(ctx, "fred")
assert.Nil(t, err) assert.Nil(t, err)
assert.NotNil(t, row) assert.NotNil(t, row)
@ -104,7 +106,7 @@ func TestTableHydrate(t *testing.T) {
assert.Nil(t, hydrate("blee", oo, rr, render.Pod{})) assert.Nil(t, hydrate("blee", oo, rr, render.Pod{}))
assert.Equal(t, 1, len(rr)) assert.Equal(t, 1, len(rr))
assert.Equal(t, 14, len(rr[0].Fields)) assert.Equal(t, 16, len(rr[0].Fields))
} }
func TestTableGenericHydrate(t *testing.T) { func TestTableGenericHydrate(t *testing.T) {

View File

@ -30,9 +30,10 @@ func TestTableRefresh(t *testing.T) {
f.rows = []runtime.Object{mustLoad("p1")} f.rows = []runtime.Object{mustLoad("p1")}
ctx := context.WithValue(context.Background(), internal.KeyFactory, f) ctx := context.WithValue(context.Background(), internal.KeyFactory, f)
ctx = context.WithValue(ctx, internal.KeyFields, "") ctx = context.WithValue(ctx, internal.KeyFields, "")
ctx = context.WithValue(ctx, internal.KeyWithMetrics, false)
ta.Refresh(ctx) ta.Refresh(ctx)
data := ta.Peek() data := ta.Peek()
assert.Equal(t, 15, len(data.Header)) assert.Equal(t, 17, len(data.Header))
assert.Equal(t, 1, len(data.RowEvents)) assert.Equal(t, 1, len(data.RowEvents))
assert.Equal(t, client.NamespaceAll, data.Namespace) assert.Equal(t, client.NamespaceAll, data.Namespace)
assert.Equal(t, 1, l.count) assert.Equal(t, 1, l.count)

View File

@ -229,7 +229,7 @@ func (t *Tree) reconcile(ctx context.Context) error {
} }
if t.root == nil || t.root.Diff(root) { if t.root == nil || t.root.Diff(root) {
t.root = root t.root = root
t.fireTreeTreeChanged(t.root) t.fireTreeChanged(t.root)
} }
return nil return nil
@ -251,7 +251,7 @@ func (t *Tree) resourceMeta() ResourceMeta {
return meta return meta
} }
func (t *Tree) fireTreeTreeChanged(root *xray.TreeNode) { func (t *Tree) fireTreeChanged(root *xray.TreeNode) {
for _, l := range t.listeners { for _, l := range t.listeners {
l.TreeChanged(root) l.TreeChanged(root)
} }

View File

@ -1,6 +1,7 @@
package render package render
import ( import (
"errors"
"fmt" "fmt"
"io/ioutil" "io/ioutil"
"os" "os"
@ -8,6 +9,7 @@ import (
"strconv" "strconv"
"strings" "strings"
"github.com/derailed/k9s/internal/client"
"github.com/derailed/tview" "github.com/derailed/tview"
"github.com/gdamore/tcell" "github.com/gdamore/tcell"
"golang.org/x/text/language" "golang.org/x/text/language"
@ -28,11 +30,10 @@ var (
type Benchmark struct{} type Benchmark struct{}
// ColorerFunc colors a resource row. // ColorerFunc colors a resource row.
func (Benchmark) ColorerFunc() ColorerFunc { func (b Benchmark) ColorerFunc() ColorerFunc {
return func(ns string, re RowEvent) tcell.Color { return func(ns string, re RowEvent) tcell.Color {
c := tcell.ColorPaleGreen c := tcell.ColorPaleGreen
statusCol := 2 if !Happy(ns, re.Row) {
if strings.TrimSpace(re.Row.Fields[statusCol]) != "pass" {
c = ErrColor c = ErrColor
} }
return c return c
@ -50,6 +51,7 @@ func (Benchmark) Header(ns string) HeaderRow {
Header{Name: "2XX", Align: tview.AlignRight}, Header{Name: "2XX", Align: tview.AlignRight},
Header{Name: "4XX/5XX", Align: tview.AlignRight}, Header{Name: "4XX/5XX", Align: tview.AlignRight},
Header{Name: "REPORT"}, Header{Name: "REPORT"},
Header{Name: "VALID", Wide: true},
Header{Name: "AGE", Decorator: AgeDecorator}, Header{Name: "AGE", Decorator: AgeDecorator},
} }
} }
@ -72,6 +74,24 @@ func (b Benchmark) Render(o interface{}, ns string, r *Row) error {
return err return err
} }
b.augmentRow(r.Fields, data) b.augmentRow(r.Fields, data)
r.Fields[8] = asStatus(b.diagnose(ns, r.Fields))
return nil
}
// Happy returns true if resoure is happy, false otherwise
func (Benchmark) diagnose(ns string, ff Fields) error {
statusCol := 3
if !client.IsAllNamespaces(ns) {
statusCol--
}
if len(ff) < statusCol {
return nil
}
if ff[statusCol] != "pass" {
return errors.New("failed benchmark")
}
return nil return nil
} }
@ -87,7 +107,7 @@ func (Benchmark) readFile(file string) (string, error) {
return string(data), nil return string(data), nil
} }
func (Benchmark) initRow(row Fields, f os.FileInfo) error { func (b Benchmark) initRow(row Fields, f os.FileInfo) error {
tokens := strings.Split(f.Name(), "_") tokens := strings.Split(f.Name(), "_")
if len(tokens) < 2 { if len(tokens) < 2 {
return fmt.Errorf("Invalid file name %s", f.Name()) return fmt.Errorf("Invalid file name %s", f.Name())
@ -95,7 +115,7 @@ func (Benchmark) initRow(row Fields, f os.FileInfo) error {
row[0] = tokens[0] row[0] = tokens[0]
row[1] = tokens[1] row[1] = tokens[1]
row[7] = f.Name() row[7] = f.Name()
row[8] = timeToAge(f.ModTime()) row[9] = timeToAge(f.ModTime())
return nil return nil
} }

View File

@ -18,6 +18,10 @@ type Chart struct{}
// ColorerFunc colors a resource row. // ColorerFunc colors a resource row.
func (Chart) ColorerFunc() ColorerFunc { func (Chart) ColorerFunc() ColorerFunc {
return func(ns string, re RowEvent) tcell.Color { return func(ns string, re RowEvent) tcell.Color {
if !Happy(ns, re.Row) {
return ErrColor
}
return tcell.ColorMediumSpringGreen return tcell.ColorMediumSpringGreen
} }
} }
@ -35,6 +39,7 @@ func (Chart) Header(ns string) HeaderRow {
Header{Name: "STATUS"}, Header{Name: "STATUS"},
Header{Name: "CHART"}, Header{Name: "CHART"},
Header{Name: "APP VERSION"}, Header{Name: "APP VERSION"},
Header{Name: "VALID", Wide: true},
Header{Name: "AGE", Decorator: AgeDecorator}, Header{Name: "AGE", Decorator: AgeDecorator},
) )
} }
@ -57,12 +62,21 @@ func (c Chart) Render(o interface{}, ns string, r *Row) error {
h.Release.Info.Status.String(), h.Release.Info.Status.String(),
h.Release.Chart.Metadata.Name+"-"+h.Release.Chart.Metadata.Version, h.Release.Chart.Metadata.Name+"-"+h.Release.Chart.Metadata.Version,
h.Release.Chart.Metadata.AppVersion, h.Release.Chart.Metadata.AppVersion,
asStatus(c.diagnose(h.Release.Info.Status.String())),
toAge(metav1.Time{Time: h.Release.Info.LastDeployed.Time}), toAge(metav1.Time{Time: h.Release.Info.LastDeployed.Time}),
) )
return nil return nil
} }
func (c Chart) diagnose(s string) error {
if s != "deployed" {
return fmt.Errorf("chart is in an invalid state")
}
return nil
}
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
// Helpers... // Helpers...

View File

@ -1,6 +1,7 @@
package render package render
import ( import (
"errors"
"fmt" "fmt"
"strconv" "strconv"
"strings" "strings"
@ -36,18 +37,18 @@ type ContainerWithMetrics interface {
// Container renders a K8s Container to screen. // Container renders a K8s Container to screen.
type Container struct{} type Container struct{}
const readyCol = 2
// ColorerFunc colors a resource row. // ColorerFunc colors a resource row.
func (Container) ColorerFunc() ColorerFunc { func (c Container) ColorerFunc() ColorerFunc {
return func(ns string, r RowEvent) tcell.Color { return func(ns string, re RowEvent) tcell.Color {
c := DefaultColorer(ns, r) color := DefaultColorer(ns, re)
readyCol := 2 if !Happy(ns, re.Row) {
if strings.TrimSpace(r.Row.Fields[readyCol]) == "false" { color = ErrColor
c = ErrColor
} }
stateCol := readyCol + 1 stateCol := readyCol + 1
switch strings.TrimSpace(r.Row.Fields[stateCol]) { switch strings.TrimSpace(re.Row.Fields[stateCol]) {
case ContainerCreating, PodInitializing: case ContainerCreating, PodInitializing:
return AddColor return AddColor
case Terminating, Initialized: case Terminating, Initialized:
@ -56,10 +57,10 @@ func (Container) ColorerFunc() ColorerFunc {
return CompletedColor return CompletedColor
case Running: case Running:
default: default:
c = ErrColor color = ErrColor
} }
return c return color
} }
} }
@ -80,6 +81,7 @@ func (Container) Header(ns string) HeaderRow {
Header{Name: "%CPU/L", Align: tview.AlignRight}, Header{Name: "%CPU/L", Align: tview.AlignRight},
Header{Name: "%MEM/L", Align: tview.AlignRight}, Header{Name: "%MEM/L", Align: tview.AlignRight},
Header{Name: "PORTS"}, Header{Name: "PORTS"},
Header{Name: "VALID", Wide: true},
Header{Name: "AGE", Decorator: AgeDecorator}, Header{Name: "AGE", Decorator: AgeDecorator},
} }
} }
@ -114,12 +116,25 @@ func (c Container) Render(o interface{}, name string, r *Row) error {
limit.cpu, limit.cpu,
limit.mem, limit.mem,
toStrPorts(co.Container.Ports), toStrPorts(co.Container.Ports),
asStatus(c.diagnose(state, ready)),
toAge(co.Age), toAge(co.Age),
) )
return nil return nil
} }
// Happy returns true if resoure is happy, false otherwise
func (Container) diagnose(state, ready string) error {
if state == "Completed" {
return nil
}
if ready == "false" {
return errors.New("container is not ready")
}
return nil
}
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
// Helpers... // Helpers...

View File

@ -41,6 +41,7 @@ func TestContainer(t *testing.T) {
"50", "50",
"20", "20",
"", "",
"container is not ready",
}, },
r.Fields[:len(r.Fields)-1], r.Fields[:len(r.Fields)-1],
) )

View File

@ -21,6 +21,7 @@ func (ClusterRole) ColorerFunc() ColorerFunc {
func (ClusterRole) Header(string) HeaderRow { func (ClusterRole) Header(string) HeaderRow {
return HeaderRow{ return HeaderRow{
Header{Name: "NAME"}, Header{Name: "NAME"},
Header{Name: "LABELS", Wide: true},
Header{Name: "AGE", Decorator: AgeDecorator}, Header{Name: "AGE", Decorator: AgeDecorator},
} }
} }
@ -40,6 +41,7 @@ func (ClusterRole) Render(o interface{}, ns string, r *Row) error {
r.ID = client.FQN("-", cr.ObjectMeta.Name) r.ID = client.FQN("-", cr.ObjectMeta.Name)
r.Fields = Fields{ r.Fields = Fields{
cr.Name, cr.Name,
mapToStr(cr.Labels),
toAge(cr.ObjectMeta.CreationTimestamp), toAge(cr.ObjectMeta.CreationTimestamp),
} }

View File

@ -22,8 +22,9 @@ func (ClusterRoleBinding) Header(string) HeaderRow {
return HeaderRow{ return HeaderRow{
Header{Name: "NAME"}, Header{Name: "NAME"},
Header{Name: "CLUSTERROLE"}, Header{Name: "CLUSTERROLE"},
Header{Name: "KIND"}, Header{Name: "SUBJECT-KIND"},
Header{Name: "SUBJECTS"}, Header{Name: "SUBJECTS"},
Header{Name: "LABELS", Wide: true},
Header{Name: "AGE", Decorator: AgeDecorator}, Header{Name: "AGE", Decorator: AgeDecorator},
} }
} }
@ -48,6 +49,7 @@ func (ClusterRoleBinding) Render(o interface{}, ns string, r *Row) error {
crb.RoleRef.Name, crb.RoleRef.Name,
kind, kind,
ss, ss,
mapToStr(crb.Labels),
toAge(crb.ObjectMeta.CreationTimestamp), toAge(crb.ObjectMeta.CreationTimestamp),
} }

View File

@ -13,5 +13,5 @@ func TestClusterRoleBindingRender(t *testing.T) {
c.Render(load(t, "crb"), "-", &r) c.Render(load(t, "crb"), "-", &r)
assert.Equal(t, "-/blee", r.ID) assert.Equal(t, "-/blee", r.ID)
assert.Equal(t, render.Fields{"blee", "blee", "USR", "fernand"}, r.Fields[:4]) assert.Equal(t, render.Fields{"blee", "blee", "User", "fernand"}, r.Fields[:4])
} }

View File

@ -22,6 +22,7 @@ func (CustomResourceDefinition) ColorerFunc() ColorerFunc {
func (CustomResourceDefinition) Header(string) HeaderRow { func (CustomResourceDefinition) Header(string) HeaderRow {
return HeaderRow{ return HeaderRow{
Header{Name: "NAME"}, Header{Name: "NAME"},
Header{Name: "LABELS", Wide: true},
Header{Name: "AGE", Decorator: AgeDecorator}, Header{Name: "AGE", Decorator: AgeDecorator},
} }
} }
@ -45,6 +46,7 @@ func (CustomResourceDefinition) Render(o interface{}, ns string, r *Row) error {
r.ID = client.FQN(client.ClusterScope, extractMetaField(meta, "name")) r.ID = client.FQN(client.ClusterScope, extractMetaField(meta, "name"))
r.Fields = Fields{ r.Fields = Fields{
extractMetaField(meta, "name"), extractMetaField(meta, "name"),
mapToIfc(meta["labels"]),
toAge(metav1.Time{Time: t}), toAge(metav1.Time{Time: t}),
} }

View File

@ -3,9 +3,12 @@ package render
import ( import (
"fmt" "fmt"
"strconv" "strconv"
"strings"
"github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/client"
batchv1 "k8s.io/api/batch/v1"
batchv1beta1 "k8s.io/api/batch/v1beta1" batchv1beta1 "k8s.io/api/batch/v1beta1"
v1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime"
) )
@ -31,6 +34,11 @@ func (CronJob) Header(ns string) HeaderRow {
Header{Name: "SUSPEND"}, Header{Name: "SUSPEND"},
Header{Name: "ACTIVE"}, Header{Name: "ACTIVE"},
Header{Name: "LAST_SCHEDULE"}, Header{Name: "LAST_SCHEDULE"},
Header{Name: "SELECTOR", Wide: true},
Header{Name: "CONTAINERS", Wide: true},
Header{Name: "IMAGES", Wide: true},
Header{Name: "LABELS", Wide: true},
Header{Name: "VALID", Wide: true},
Header{Name: "AGE", Decorator: AgeDecorator}, Header{Name: "AGE", Decorator: AgeDecorator},
) )
} }
@ -63,8 +71,64 @@ func (c CronJob) Render(o interface{}, ns string, r *Row) error {
boolPtrToStr(cj.Spec.Suspend), boolPtrToStr(cj.Spec.Suspend),
strconv.Itoa(len(cj.Status.Active)), strconv.Itoa(len(cj.Status.Active)),
lastScheduled, lastScheduled,
jobSelector(cj.Spec.JobTemplate.Spec),
podContainerNames(cj.Spec.JobTemplate.Spec.Template.Spec, true),
podImageNames(cj.Spec.JobTemplate.Spec.Template.Spec, true),
mapToStr(cj.Labels),
"",
toAge(cj.ObjectMeta.CreationTimestamp), toAge(cj.ObjectMeta.CreationTimestamp),
) )
return nil return nil
} }
// Helpers
func jobSelector(spec batchv1.JobSpec) string {
if spec.Selector == nil {
return MissingValue
}
if len(spec.Selector.MatchLabels) > 0 {
return mapToStr(spec.Selector.MatchLabels)
}
if len(spec.Selector.MatchExpressions) == 0 {
return ""
}
ss := make([]string, 0, len(spec.Selector.MatchExpressions))
for _, e := range spec.Selector.MatchExpressions {
ss = append(ss, e.String())
}
return strings.Join(ss, " ")
}
func podContainerNames(spec v1.PodSpec, includeInit bool) string {
cc := make([]string, 0, len(spec.Containers)+len(spec.InitContainers))
if includeInit {
for _, c := range spec.InitContainers {
cc = append(cc, c.Name)
}
}
for _, c := range spec.Containers {
cc = append(cc, c.Name)
}
return strings.Join(cc, ",")
}
func podImageNames(spec v1.PodSpec, includeInit bool) string {
cc := make([]string, 0, len(spec.Containers)+len(spec.InitContainers))
if includeInit {
for _, c := range spec.InitContainers {
cc = append(cc, c.Image)
}
}
for _, c := range spec.Containers {
cc = append(cc, c.Image)
}
return strings.Join(cc, ",")
}

View File

@ -3,7 +3,6 @@ package render
import ( import (
"fmt" "fmt"
"strconv" "strconv"
"strings"
"github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/client"
"github.com/derailed/tview" "github.com/derailed/tview"
@ -17,19 +16,13 @@ import (
type Deployment struct{} type Deployment struct{}
// ColorerFunc colors a resource row. // ColorerFunc colors a resource row.
func (Deployment) ColorerFunc() ColorerFunc { func (d Deployment) ColorerFunc() ColorerFunc {
return func(ns string, r RowEvent) tcell.Color { return func(ns string, r RowEvent) tcell.Color {
c := DefaultColorer(ns, r) c := DefaultColorer(ns, r)
if r.Kind == EventAdd || r.Kind == EventUpdate { if r.Kind == EventAdd || r.Kind == EventUpdate {
return c return c
} }
if !Happy(ns, r.Row) {
readyCol := 2
if !client.IsAllNamespaces(ns) {
readyCol--
}
tokens := strings.Split(r.Row.Fields[readyCol], "/")
if tokens[0] != tokens[1] {
return ErrColor return ErrColor
} }
@ -49,6 +42,9 @@ func (Deployment) Header(ns string) HeaderRow {
Header{Name: "READY"}, Header{Name: "READY"},
Header{Name: "UP-TO-DATE", Align: tview.AlignRight}, Header{Name: "UP-TO-DATE", Align: tview.AlignRight},
Header{Name: "AVAILABLE", Align: tview.AlignRight}, Header{Name: "AVAILABLE", Align: tview.AlignRight},
Header{Name: "READY", Align: tview.AlignRight},
Header{Name: "LABELS", Wide: true},
Header{Name: "VALID", Wide: true},
Header{Name: "AGE", Decorator: AgeDecorator}, Header{Name: "AGE", Decorator: AgeDecorator},
) )
} }
@ -73,11 +69,21 @@ func (d Deployment) Render(o interface{}, ns string, r *Row) error {
} }
r.Fields = append(r.Fields, r.Fields = append(r.Fields,
dp.Name, dp.Name,
strconv.Itoa(int(dp.Status.AvailableReplicas))+"/"+strconv.Itoa(int(*dp.Spec.Replicas)), strconv.Itoa(int(dp.Status.AvailableReplicas))+"/"+strconv.Itoa(int(dp.Status.Replicas)),
strconv.Itoa(int(dp.Status.UpdatedReplicas)), strconv.Itoa(int(dp.Status.UpdatedReplicas)),
strconv.Itoa(int(dp.Status.AvailableReplicas)), strconv.Itoa(int(dp.Status.AvailableReplicas)),
strconv.Itoa(int(dp.Status.ReadyReplicas)),
mapToStr(dp.Labels),
asStatus(d.diagnose(dp.Status.Replicas, dp.Status.AvailableReplicas)),
toAge(dp.ObjectMeta.CreationTimestamp), toAge(dp.ObjectMeta.CreationTimestamp),
) )
return nil return nil
} }
func (Deployment) diagnose(d, r int32) error {
if d != r {
return fmt.Errorf("desiring %d replicas got %d available", d, r)
}
return nil
}

View File

@ -3,7 +3,6 @@ package render
import ( import (
"fmt" "fmt"
"strconv" "strconv"
"strings"
"github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/client"
"github.com/derailed/tview" "github.com/derailed/tview"
@ -17,18 +16,14 @@ import (
type DaemonSet struct{} type DaemonSet struct{}
// ColorerFunc colors a resource row. // ColorerFunc colors a resource row.
func (DaemonSet) ColorerFunc() ColorerFunc { func (d DaemonSet) ColorerFunc() ColorerFunc {
return func(ns string, r RowEvent) tcell.Color { return func(ns string, r RowEvent) tcell.Color {
c := DefaultColorer(ns, r) c := DefaultColorer(ns, r)
if r.Kind == EventAdd || r.Kind == EventUpdate { if r.Kind == EventAdd || r.Kind == EventUpdate {
return c return c
} }
desiredCol := 2 if !Happy(ns, r.Row) {
if !client.IsAllNamespaces(ns) {
desiredCol = 1
}
if strings.TrimSpace(r.Row.Fields[desiredCol]) != strings.TrimSpace(r.Row.Fields[desiredCol+2]) {
return ErrColor return ErrColor
} }
@ -50,6 +45,8 @@ func (DaemonSet) Header(ns string) HeaderRow {
Header{Name: "READY", Align: tview.AlignRight}, Header{Name: "READY", Align: tview.AlignRight},
Header{Name: "UP-TO-DATE", Align: tview.AlignRight}, Header{Name: "UP-TO-DATE", Align: tview.AlignRight},
Header{Name: "AVAILABLE", Align: tview.AlignRight}, Header{Name: "AVAILABLE", Align: tview.AlignRight},
Header{Name: "LABELS", Wide: true},
Header{Name: "VALID", Wide: true},
Header{Name: "AGE", Decorator: AgeDecorator}, Header{Name: "AGE", Decorator: AgeDecorator},
) )
} }
@ -78,8 +75,18 @@ func (d DaemonSet) Render(o interface{}, ns string, r *Row) error {
strconv.Itoa(int(ds.Status.NumberReady)), strconv.Itoa(int(ds.Status.NumberReady)),
strconv.Itoa(int(ds.Status.UpdatedNumberScheduled)), strconv.Itoa(int(ds.Status.UpdatedNumberScheduled)),
strconv.Itoa(int(ds.Status.NumberAvailable)), strconv.Itoa(int(ds.Status.NumberAvailable)),
mapToStr(ds.Labels),
asStatus(d.diagnose(ds.Status.DesiredNumberScheduled, ds.Status.NumberReady)),
toAge(ds.ObjectMeta.CreationTimestamp), toAge(ds.ObjectMeta.CreationTimestamp),
) )
return nil return nil
} }
// Happy returns true if resoure is happy, false otherwise
func (DaemonSet) diagnose(d, r int32) error {
if d != r {
return fmt.Errorf("desiring %d replicas but %d ready", d, r)
}
return nil
}

View File

@ -1,6 +1,7 @@
package render package render
import ( import (
"errors"
"fmt" "fmt"
"strconv" "strconv"
"strings" "strings"
@ -17,19 +18,20 @@ import (
type Event struct{} type Event struct{}
// ColorerFunc colors a resource row. // ColorerFunc colors a resource row.
func (Event) ColorerFunc() ColorerFunc { func (e Event) ColorerFunc() ColorerFunc {
return func(ns string, r RowEvent) tcell.Color { return func(ns string, r RowEvent) tcell.Color {
c := DefaultColorer(ns, r) c := DefaultColorer(ns, r)
if !Happy(ns, r.Row) {
return ErrColor
}
markCol := 3 markCol := 3
if !client.IsAllNamespaces(ns) { if !client.IsAllNamespaces(ns) {
markCol = 2 markCol = 2
} }
switch strings.TrimSpace(r.Row.Fields[markCol]) { if strings.TrimSpace(r.Row.Fields[markCol]) == "Killing" {
case "Failed": return KillColor
c = ErrColor
case "Killing":
c = KillColor
} }
return c return c
@ -45,10 +47,12 @@ func (Event) Header(ns string) HeaderRow {
return append(h, return append(h,
Header{Name: "NAME"}, Header{Name: "NAME"},
Header{Name: "TYPE"},
Header{Name: "REASON"}, Header{Name: "REASON"},
Header{Name: "SOURCE"}, Header{Name: "SOURCE"},
Header{Name: "COUNT", Align: tview.AlignRight}, Header{Name: "COUNT", Align: tview.AlignRight},
Header{Name: "MESSAGE"}, Header{Name: "MESSAGE", Wide: true},
Header{Name: "VALID", Wide: true},
Header{Name: "AGE", Decorator: AgeDecorator}, Header{Name: "AGE", Decorator: AgeDecorator},
) )
} }
@ -72,15 +76,27 @@ func (e Event) Render(o interface{}, ns string, r *Row) error {
} }
r.Fields = append(r.Fields, r.Fields = append(r.Fields,
asRef(ev.InvolvedObject), asRef(ev.InvolvedObject),
ev.Type,
ev.Reason, ev.Reason,
ev.Source.Component, ev.Source.Component,
strconv.Itoa(int(ev.Count)), strconv.Itoa(int(ev.Count)),
ev.Message, ev.Message,
asStatus(e.diagnose(ev.Type)),
toAge(ev.LastTimestamp)) toAge(ev.LastTimestamp))
return nil return nil
} }
// Happy returns true if resoure is happy, false otherwise
func (Event) diagnose(kind string) error {
if kind != "Normal" {
return errors.New("failed event")
}
return nil
}
// Helpers...
func asRef(r v1.ObjectReference) string { func asRef(r v1.ObjectReference) string {
return strings.ToLower(r.Kind) + ":" + r.Name return strings.ToLower(r.Kind) + ":" + r.Name
} }

View File

@ -13,5 +13,17 @@ func TestEventRender(t *testing.T) {
c.Render(load(t, "ev"), "", &r) c.Render(load(t, "ev"), "", &r)
assert.Equal(t, "default/hello-1567197780-mn4mv.15bfce150bd764dd", r.ID) assert.Equal(t, "default/hello-1567197780-mn4mv.15bfce150bd764dd", r.ID)
assert.Equal(t, render.Fields{"default", "pod:hello-1567197780-mn4mv", "Pulled", "kubelet", "1", `Successfully pulled image "blang/busybox-bash"`}, r.Fields[:6]) assert.Equal(t, render.Fields{"default", "pod:hello-1567197780-mn4mv", "Normal", "Pulled", "kubelet", "1", `Successfully pulled image "blang/busybox-bash"`}, r.Fields[:7])
}
func BenchmarkEventRender(b *testing.B) {
ev := load(b, "ev")
var re render.Event
r := render.NewRow(7)
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_ = re.Render(&ev, "", &r)
}
} }

View File

@ -19,6 +19,11 @@ type Generic struct {
ageIndex int ageIndex int
} }
// Happy returns true if resoure is happy, false otherwise
func (Generic) Happy(ns string, r Row) bool {
return true
}
// SetTable sets the tabular resource. // SetTable sets the tabular resource.
func (g *Generic) SetTable(t *metav1beta1.Table) { func (g *Generic) SetTable(t *metav1beta1.Table) {
g.table = t g.table = t

View File

@ -13,6 +13,12 @@ import (
"k8s.io/apimachinery/pkg/util/duration" "k8s.io/apimachinery/pkg/util/duration"
) )
// Happy returns true if resoure is happy, false otherwise
func Happy(ns string, r Row) bool {
validCol := r.Len() - 2
return strings.TrimSpace(r.Fields[validCol]) == ""
}
const megaByte = 1024 * 1024 const megaByte = 1024 * 1024
// ToMB converts bytes to megabytes. // ToMB converts bytes to megabytes.
@ -20,6 +26,13 @@ func ToMB(v int64) float64 {
return float64(v) / megaByte return float64(v) / megaByte
} }
func asStatus(err error) string {
if err == nil {
return ""
}
return err.Error()
}
func asSelector(s *metav1.LabelSelector) string { func asSelector(s *metav1.LabelSelector) string {
sel, err := metav1.LabelSelectorAsSelector(s) sel, err := metav1.LabelSelectorAsSelector(s)
if err != nil { if err != nil {
@ -84,7 +97,7 @@ func join(a []string, sep string) string {
var buff strings.Builder var buff strings.Builder
buff.Grow(n) buff.Grow(n)
buff.WriteString(a[0]) buff.WriteString(b[0])
for _, s := range b[1:] { for _, s := range b[1:] {
buff.WriteString(sep) buff.WriteString(sep)
buff.WriteString(s) buff.WriteString(s)
@ -163,7 +176,40 @@ func mapToStr(m map[string]string) (s string) {
for i, k := range kk { for i, k := range kk {
s += k + "=" + m[k] s += k + "=" + m[k]
if i < len(kk)-1 { if i < len(kk)-1 {
s += "," s += " "
}
}
return
}
func mapToIfc(m interface{}) (s string) {
if m == nil {
return ""
}
mm, ok := m.(map[string]interface{})
if !ok {
return ""
}
if len(mm) == 0 {
return ""
}
kk := make([]string, 0, len(mm))
for k := range mm {
kk = append(kk, k)
}
sort.Strings(kk)
for i, k := range kk {
str, ok := mm[k].(string)
if !ok {
continue
}
s += k + "=" + str
if i < len(kk)-1 {
s += " "
} }
} }

View File

@ -82,10 +82,11 @@ func TestJoin(t *testing.T) {
i []string i []string
e string e string
}{ }{
"zero": {[]string{}, ""}, "zero": {[]string{}, ""},
"std": {[]string{"a", "b", "c"}, "a,b,c"}, "std": {[]string{"a", "b", "c"}, "a,b,c"},
"blank": {[]string{"", "", ""}, ""}, "blank": {[]string{"", "", ""}, ""},
"sparse": {[]string{"a", "", "c"}, "a,c"}, "sparse": {[]string{"a", "", "c"}, "a,c"},
"withBlank": {[]string{"", "a", "c"}, "a,c"},
} }
for k := range uu { for k := range uu {
@ -304,7 +305,7 @@ func TestMapToStr(t *testing.T) {
i map[string]string i map[string]string
e string e string
}{ }{
{map[string]string{"blee": "duh", "aa": "bb"}, "aa=bb,blee=duh"}, {map[string]string{"blee": "duh", "aa": "bb"}, "aa=bb blee=duh"},
{map[string]string{}, ""}, {map[string]string{}, ""},
} }
for _, u := range uu { for _, u := range uu {

View File

@ -36,6 +36,7 @@ func (HorizontalPodAutoscaler) Header(ns string) HeaderRow {
Header{Name: "MINPODS", Align: tview.AlignRight}, Header{Name: "MINPODS", Align: tview.AlignRight},
Header{Name: "MAXPODS", Align: tview.AlignRight}, Header{Name: "MAXPODS", Align: tview.AlignRight},
Header{Name: "REPLICAS", Align: tview.AlignRight}, Header{Name: "REPLICAS", Align: tview.AlignRight},
Header{Name: "VALID", Wide: true},
Header{Name: "AGE", Decorator: AgeDecorator}, Header{Name: "AGE", Decorator: AgeDecorator},
) )
} }
@ -80,6 +81,7 @@ func (h HorizontalPodAutoscaler) renderV1(raw *unstructured.Unstructured, ns str
strconv.Itoa(int(*hpa.Spec.MinReplicas)), strconv.Itoa(int(*hpa.Spec.MinReplicas)),
strconv.Itoa(int(hpa.Spec.MaxReplicas)), strconv.Itoa(int(hpa.Spec.MaxReplicas)),
strconv.Itoa(int(hpa.Status.CurrentReplicas)), strconv.Itoa(int(hpa.Status.CurrentReplicas)),
"",
toAge(hpa.ObjectMeta.CreationTimestamp), toAge(hpa.ObjectMeta.CreationTimestamp),
) )
@ -106,6 +108,7 @@ func (h HorizontalPodAutoscaler) renderV2b1(raw *unstructured.Unstructured, ns s
strconv.Itoa(int(*hpa.Spec.MinReplicas)), strconv.Itoa(int(*hpa.Spec.MinReplicas)),
strconv.Itoa(int(hpa.Spec.MaxReplicas)), strconv.Itoa(int(hpa.Spec.MaxReplicas)),
strconv.Itoa(int(hpa.Status.CurrentReplicas)), strconv.Itoa(int(hpa.Status.CurrentReplicas)),
"",
toAge(hpa.ObjectMeta.CreationTimestamp), toAge(hpa.ObjectMeta.CreationTimestamp),
) )
@ -132,6 +135,7 @@ func (h HorizontalPodAutoscaler) renderV2b2(raw *unstructured.Unstructured, ns s
strconv.Itoa(int(*hpa.Spec.MinReplicas)), strconv.Itoa(int(*hpa.Spec.MinReplicas)),
strconv.Itoa(int(hpa.Spec.MaxReplicas)), strconv.Itoa(int(hpa.Spec.MaxReplicas)),
strconv.Itoa(int(hpa.Status.CurrentReplicas)), strconv.Itoa(int(hpa.Status.CurrentReplicas)),
"",
toAge(hpa.ObjectMeta.CreationTimestamp), toAge(hpa.ObjectMeta.CreationTimestamp),
) )

View File

@ -31,6 +31,7 @@ func (Ingress) Header(ns string) HeaderRow {
Header{Name: "HOSTS"}, Header{Name: "HOSTS"},
Header{Name: "ADDRESS"}, Header{Name: "ADDRESS"},
Header{Name: "PORT"}, Header{Name: "PORT"},
Header{Name: "VALID", Wide: true},
Header{Name: "AGE", Decorator: AgeDecorator}, Header{Name: "AGE", Decorator: AgeDecorator},
) )
} }
@ -57,6 +58,7 @@ func (i Ingress) Render(o interface{}, ns string, r *Row) error {
toHosts(ing.Spec.Rules), toHosts(ing.Spec.Rules),
toAddress(ing.Status.LoadBalancer), toAddress(ing.Status.LoadBalancer),
toTLSPorts(ing.Spec.TLS), toTLSPorts(ing.Spec.TLS),
"",
toAge(ing.ObjectMeta.CreationTimestamp), toAge(ing.ObjectMeta.CreationTimestamp),
) )

View File

@ -9,6 +9,7 @@ import (
"github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/client"
batchv1 "k8s.io/api/batch/v1" batchv1 "k8s.io/api/batch/v1"
v1 "k8s.io/api/core/v1" v1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/util/duration" "k8s.io/apimachinery/pkg/util/duration"
@ -33,8 +34,10 @@ func (Job) Header(ns string) HeaderRow {
Header{Name: "NAME"}, Header{Name: "NAME"},
Header{Name: "COMPLETIONS"}, Header{Name: "COMPLETIONS"},
Header{Name: "DURATION"}, Header{Name: "DURATION"},
Header{Name: "CONTAINERS"}, Header{Name: "SELECTOR", Wide: true},
Header{Name: "IMAGES"}, Header{Name: "CONTAINERS", Wide: true},
Header{Name: "IMAGES", Wide: true},
Header{Name: "VALID", Wide: true},
Header{Name: "AGE", Decorator: AgeDecorator}, Header{Name: "AGE", Decorator: AgeDecorator},
) )
} }
@ -50,6 +53,7 @@ func (j Job) Render(o interface{}, ns string, r *Row) error {
if err != nil { if err != nil {
return err return err
} }
ready := toCompletion(job.Spec, job.Status)
r.ID = client.MetaFQN(job.ObjectMeta) r.ID = client.MetaFQN(job.ObjectMeta)
r.Fields = make(Fields, 0, len(j.Header(ns))) r.Fields = make(Fields, 0, len(j.Header(ns)))
@ -59,16 +63,29 @@ func (j Job) Render(o interface{}, ns string, r *Row) error {
cc, ii := toContainers(job.Spec.Template.Spec) cc, ii := toContainers(job.Spec.Template.Spec)
r.Fields = append(r.Fields, r.Fields = append(r.Fields,
job.Name, job.Name,
toCompletion(job.Spec, job.Status), ready,
toDuration(job.Status), toDuration(job.Status),
jobSelector(job.Spec),
cc, cc,
ii, ii,
asStatus(j.diagnose(ready, job.Status.CompletionTime)),
toAge(job.ObjectMeta.CreationTimestamp), toAge(job.ObjectMeta.CreationTimestamp),
) )
return nil return nil
} }
func (Job) diagnose(ready string, completed *metav1.Time) error {
if completed == nil {
return nil
}
tokens := strings.Split(ready, "/")
if tokens[0] != tokens[1] {
return fmt.Errorf("expecting %s completion got %s", tokens[1], tokens[0])
}
return nil
}
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
// Helpers... // Helpers...

View File

@ -13,5 +13,5 @@ func TestJobRender(t *testing.T) {
c.Render(load(t, "job"), "", &r) c.Render(load(t, "job"), "", &r)
assert.Equal(t, "default/hello-1567179180", r.ID) assert.Equal(t, "default/hello-1567179180", r.ID)
assert.Equal(t, render.Fields{"default", "hello-1567179180", "1/1", "8s", "c1", "blang/busybox-bash"}, r.Fields[:6]) assert.Equal(t, render.Fields{"default", "hello-1567179180", "1/1", "8s", "controller-uid=7473e6d0-cb3b-11e9-990f-42010a800218", "c1", "blang/busybox-bash"}, r.Fields[:7])
} }

View File

@ -1,11 +1,14 @@
package render package render
import ( import (
"errors"
"fmt" "fmt"
"sort"
"strings" "strings"
"github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/client"
"github.com/derailed/tview" "github.com/derailed/tview"
"github.com/gdamore/tcell"
v1 "k8s.io/api/core/v1" v1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime"
@ -22,8 +25,16 @@ const (
type Node struct{} type Node struct{}
// ColorerFunc colors a resource row. // ColorerFunc colors a resource row.
func (Node) ColorerFunc() ColorerFunc { func (n Node) ColorerFunc() ColorerFunc {
return DefaultColorer return func(ns string, r RowEvent) tcell.Color {
c := DefaultColorer(ns, r)
if !Happy(ns, r.Row) {
return ErrColor
}
return c
}
} }
// Header returns a header row. // Header returns a header row.
@ -31,17 +42,19 @@ func (Node) Header(_ string) HeaderRow {
return HeaderRow{ return HeaderRow{
Header{Name: "NAME"}, Header{Name: "NAME"},
Header{Name: "STATUS"}, Header{Name: "STATUS"},
Header{Name: "ROLE"}, Header{Name: "ROLE", Wide: true},
Header{Name: "VERSION"}, Header{Name: "VERSION", Wide: true},
Header{Name: "KERNEL"}, Header{Name: "KERNEL", Wide: true},
Header{Name: "INTERNAL-IP"}, Header{Name: "INTERNAL-IP", Wide: true},
Header{Name: "EXTERNAL-IP"}, Header{Name: "EXTERNAL-IP", Wide: true},
Header{Name: "CPU", Align: tview.AlignRight}, Header{Name: "CPU", Align: tview.AlignRight},
Header{Name: "MEM", Align: tview.AlignRight}, Header{Name: "MEM", Align: tview.AlignRight},
Header{Name: "%CPU", Align: tview.AlignRight}, Header{Name: "%CPU", Align: tview.AlignRight},
Header{Name: "%MEM", Align: tview.AlignRight}, Header{Name: "%MEM", Align: tview.AlignRight},
Header{Name: "ACPU", Align: tview.AlignRight}, Header{Name: "ACPU", Align: tview.AlignRight},
Header{Name: "AMEM", Align: tview.AlignRight}, Header{Name: "AMEM", Align: tview.AlignRight},
Header{Name: "LABELS", Wide: true},
Header{Name: "VALID", Wide: true},
Header{Name: "AGE", Decorator: AgeDecorator}, Header{Name: "AGE", Decorator: AgeDecorator},
} }
} }
@ -69,17 +82,19 @@ func (n Node) Render(o interface{}, ns string, r *Row) error {
c, a, p := gatherNodeMX(&no, oo.MX) c, a, p := gatherNodeMX(&no, oo.MX)
sta := make([]string, 10) statuses := make(sort.StringSlice, 10)
status(no.Status, no.Spec.Unschedulable, sta) status(no.Status, no.Spec.Unschedulable, statuses)
ro := make([]string, 10) sort.Sort(statuses)
nodeRoles(&no, ro) roles := make(sort.StringSlice, 10)
nodeRoles(&no, roles)
sort.Sort(roles)
r.ID = client.FQN("", na) r.ID = client.FQN("", na)
r.Fields = make(Fields, 0, len(n.Header(ns))) r.Fields = make(Fields, 0, len(n.Header(ns)))
r.Fields = append(r.Fields, r.Fields = append(r.Fields,
no.Name, no.Name,
join(sta, ","), join(statuses, ","),
join(ro, ","), join(roles, ","),
no.Status.NodeInfo.KubeletVersion, no.Status.NodeInfo.KubeletVersion,
no.Status.NodeInfo.KernelVersion, no.Status.NodeInfo.KernelVersion,
iIP, iIP,
@ -90,12 +105,27 @@ func (n Node) Render(o interface{}, ns string, r *Row) error {
p.mem, p.mem,
a.cpu, a.cpu,
a.mem, a.mem,
mapToStr(no.Labels),
asStatus(n.diagnose(statuses)),
toAge(no.ObjectMeta.CreationTimestamp), toAge(no.ObjectMeta.CreationTimestamp),
) )
return nil return nil
} }
func (Node) diagnose(ss []string) error {
if len(ss) == 0 {
return nil
}
for _, s := range ss {
if s == "Ready" {
return nil
}
}
return errors.New("node is not ready")
}
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
// Helpers... // Helpers...
@ -156,6 +186,9 @@ func nodeRoles(node *v1.Node, res []string) {
res[index] = v res[index] = v
index++ index++
} }
if index >= len(res) {
break
}
} }
if empty(res) { if empty(res) {

View File

@ -28,12 +28,14 @@ func (NetworkPolicy) Header(ns string) HeaderRow {
return append(h, return append(h,
Header{Name: "NAME"}, Header{Name: "NAME"},
Header{Name: "ING-SELECTOR"}, Header{Name: "ING-SELECTOR", Wide: true},
Header{Name: "ING-PORTS"}, Header{Name: "ING-PORTS"},
Header{Name: "ING-BLOCK"}, Header{Name: "ING-BLOCK"},
Header{Name: "EGR-SELECTOR"}, Header{Name: "EGR-SELECTOR", Wide: true},
Header{Name: "EGR-PORTS"}, Header{Name: "EGR-PORTS"},
Header{Name: "EGR-BLOCK"}, Header{Name: "EGR-BLOCK"},
Header{Name: "LABELS", Wide: true},
Header{Name: "VALID", Wide: true},
Header{Name: "AGE", Decorator: AgeDecorator}, Header{Name: "AGE", Decorator: AgeDecorator},
) )
} }
@ -66,6 +68,8 @@ func (n NetworkPolicy) Render(o interface{}, ns string, r *Row) error {
es, es,
ep, ep,
eb, eb,
mapToStr(np.Labels),
"",
toAge(np.ObjectMeta.CreationTimestamp), toAge(np.ObjectMeta.CreationTimestamp),
) )

View File

@ -1,6 +1,7 @@
package render package render
import ( import (
"errors"
"fmt" "fmt"
"strings" "strings"
@ -15,7 +16,7 @@ import (
type Namespace struct{} type Namespace struct{}
// ColorerFunc colors a resource row. // ColorerFunc colors a resource row.
func (Namespace) ColorerFunc() ColorerFunc { func (n Namespace) ColorerFunc() ColorerFunc {
return func(ns string, r RowEvent) tcell.Color { return func(ns string, r RowEvent) tcell.Color {
c := DefaultColorer(ns, r) c := DefaultColorer(ns, r)
if r.Kind == EventAdd { if r.Kind == EventAdd {
@ -25,9 +26,8 @@ func (Namespace) ColorerFunc() ColorerFunc {
if r.Kind == EventUpdate { if r.Kind == EventUpdate {
c = StdColor c = StdColor
} }
switch strings.TrimSpace(r.Row.Fields[1]) { if !Happy(ns, r.Row) {
case "Inactive", Terminating: return ErrColor
c = ErrColor
} }
if strings.Contains(strings.TrimSpace(r.Row.Fields[0]), "*") { if strings.Contains(strings.TrimSpace(r.Row.Fields[0]), "*") {
c = HighlightColor c = HighlightColor
@ -42,12 +42,14 @@ func (Namespace) Header(string) HeaderRow {
return HeaderRow{ return HeaderRow{
Header{Name: "NAME"}, Header{Name: "NAME"},
Header{Name: "STATUS"}, Header{Name: "STATUS"},
Header{Name: "LABELS", Wide: true},
Header{Name: "VALID", Wide: true},
Header{Name: "AGE", Decorator: AgeDecorator}, Header{Name: "AGE", Decorator: AgeDecorator},
} }
} }
// Render renders a K8s resource to screen. // Render renders a K8s resource to screen.
func (Namespace) Render(o interface{}, _ string, r *Row) error { func (n Namespace) Render(o interface{}, _ string, r *Row) error {
raw, ok := o.(*unstructured.Unstructured) raw, ok := o.(*unstructured.Unstructured)
if !ok { if !ok {
return fmt.Errorf("Expected Namespace, but got %T", o) return fmt.Errorf("Expected Namespace, but got %T", o)
@ -62,8 +64,17 @@ func (Namespace) Render(o interface{}, _ string, r *Row) error {
r.Fields = Fields{ r.Fields = Fields{
ns.Name, ns.Name,
string(ns.Status.Phase), string(ns.Status.Phase),
mapToStr(ns.Labels),
asStatus(n.diagnose(ns.Status.Phase)),
toAge(ns.ObjectMeta.CreationTimestamp), toAge(ns.ObjectMeta.CreationTimestamp),
} }
return nil return nil
} }
func (Namespace) diagnose(phase v1.NamespacePhase) error {
if phase != v1.NamespaceActive && phase != v1.NamespaceTerminating {
return errors.New("namespace not ready")
}
return nil
}

View File

@ -1,6 +1,7 @@
package render package render
import ( import (
"errors"
"fmt" "fmt"
"strconv" "strconv"
"time" "time"
@ -23,8 +24,12 @@ const (
type OpenFaas struct{} type OpenFaas struct{}
// ColorerFunc colors a resource row. // ColorerFunc colors a resource row.
func (OpenFaas) ColorerFunc() ColorerFunc { func (o OpenFaas) ColorerFunc() ColorerFunc {
return func(ns string, re RowEvent) tcell.Color { return func(ns string, re RowEvent) tcell.Color {
if !Happy(ns, re.Row) {
return ErrColor
}
return tcell.ColorPaleTurquoise return tcell.ColorPaleTurquoise
} }
} }
@ -44,13 +49,14 @@ func (OpenFaas) Header(ns string) HeaderRow {
Header{Name: "INVOCATIONS", Align: tview.AlignRight}, Header{Name: "INVOCATIONS", Align: tview.AlignRight},
Header{Name: "REPLICAS", Align: tview.AlignRight}, Header{Name: "REPLICAS", Align: tview.AlignRight},
Header{Name: "AVAILABLE", Align: tview.AlignRight}, Header{Name: "AVAILABLE", Align: tview.AlignRight},
Header{Name: "VALID", Wide: true},
Header{Name: "AGE", Decorator: AgeDecorator}, Header{Name: "AGE", Decorator: AgeDecorator},
) )
} }
// Render renders a chart to screen. // Render renders a chart to screen.
func (f OpenFaas) Render(o interface{}, ns string, r *Row) error { func (o OpenFaas) Render(i interface{}, ns string, r *Row) error {
fn, ok := o.(OpenFaasRes) fn, ok := i.(OpenFaasRes)
if !ok { if !ok {
return fmt.Errorf("expected OpenFaasRes, but got %T", o) return fmt.Errorf("expected OpenFaasRes, but got %T", o)
} }
@ -65,7 +71,7 @@ func (f OpenFaas) Render(o interface{}, ns string, r *Row) error {
} }
r.ID = client.FQN(fn.Function.Namespace, fn.Function.Name) r.ID = client.FQN(fn.Function.Namespace, fn.Function.Name)
r.Fields = make(Fields, 0, len(f.Header(ns))) r.Fields = make(Fields, 0, len(o.Header(ns)))
if client.IsAllNamespaces(ns) { if client.IsAllNamespaces(ns) {
r.Fields = append(r.Fields, fn.Function.Namespace) r.Fields = append(r.Fields, fn.Function.Namespace)
} }
@ -77,12 +83,21 @@ func (f OpenFaas) Render(o interface{}, ns string, r *Row) error {
strconv.Itoa(int(fn.Function.InvocationCount)), strconv.Itoa(int(fn.Function.InvocationCount)),
strconv.Itoa(int(fn.Function.Replicas)), strconv.Itoa(int(fn.Function.Replicas)),
strconv.Itoa(int(fn.Function.AvailableReplicas)), strconv.Itoa(int(fn.Function.AvailableReplicas)),
asStatus(o.diagnose(status)),
toAge(metav1.Time{Time: time.Now()}), toAge(metav1.Time{Time: time.Now()}),
) )
return nil return nil
} }
func (OpenFaas) diagnose(status string) error {
if status != "Ready" {
return errors.New("function not ready")
}
return nil
}
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
// Helpers... // Helpers...

View File

@ -3,7 +3,6 @@ package render
import ( import (
"fmt" "fmt"
"strconv" "strconv"
"strings"
"github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/client"
"github.com/derailed/tview" "github.com/derailed/tview"
@ -18,24 +17,19 @@ import (
type PodDisruptionBudget struct{} type PodDisruptionBudget struct{}
// ColorerFunc colors a resource row. // ColorerFunc colors a resource row.
func (PodDisruptionBudget) ColorerFunc() ColorerFunc { func (p PodDisruptionBudget) ColorerFunc() ColorerFunc {
return func(ns string, r RowEvent) tcell.Color { return func(ns string, re RowEvent) tcell.Color {
c := DefaultColorer(ns, r) c := DefaultColorer(ns, re)
if r.Kind == EventAdd || r.Kind == EventUpdate { if re.Kind == EventAdd || re.Kind == EventUpdate {
return c return c
} }
markCol := 5 if !Happy(ns, re.Row) {
if !client.IsAllNamespaces(ns) {
markCol--
}
if strings.TrimSpace(r.Row.Fields[markCol]) != strings.TrimSpace(r.Row.Fields[markCol+1]) {
return ErrColor return ErrColor
} }
return StdColor return StdColor
} }
} }
// Header returns a header row. // Header returns a header row.
@ -53,6 +47,8 @@ func (PodDisruptionBudget) Header(ns string) HeaderRow {
Header{Name: "CURRENT", Align: tview.AlignRight}, Header{Name: "CURRENT", Align: tview.AlignRight},
Header{Name: "DESIRED", Align: tview.AlignRight}, Header{Name: "DESIRED", Align: tview.AlignRight},
Header{Name: "EXPECTED", Align: tview.AlignRight}, Header{Name: "EXPECTED", Align: tview.AlignRight},
Header{Name: "LABELS", Wide: true},
Header{Name: "VALID", Wide: true},
Header{Name: "AGE", Decorator: AgeDecorator}, Header{Name: "AGE", Decorator: AgeDecorator},
) )
} }
@ -82,12 +78,21 @@ func (p PodDisruptionBudget) Render(o interface{}, ns string, r *Row) error {
strconv.Itoa(int(pdb.Status.CurrentHealthy)), strconv.Itoa(int(pdb.Status.CurrentHealthy)),
strconv.Itoa(int(pdb.Status.DesiredHealthy)), strconv.Itoa(int(pdb.Status.DesiredHealthy)),
strconv.Itoa(int(pdb.Status.ExpectedPods)), strconv.Itoa(int(pdb.Status.ExpectedPods)),
mapToStr(pdb.Labels),
asStatus(p.diagnose(pdb.Spec.MinAvailable.IntVal, pdb.Status.CurrentHealthy)),
toAge(pdb.ObjectMeta.CreationTimestamp), toAge(pdb.ObjectMeta.CreationTimestamp),
) )
return nil return nil
} }
func (PodDisruptionBudget) diagnose(min, healthy int32) error {
if min > healthy {
return fmt.Errorf("expected %d but got %d", min, healthy)
}
return nil
}
// Helpers... // Helpers...
func numbToStr(n *intstr.IntOrString) string { func numbToStr(n *intstr.IntOrString) string {

View File

@ -25,15 +25,11 @@ func (p Pod) ColorerFunc() ColorerFunc {
return func(ns string, re RowEvent) tcell.Color { return func(ns string, re RowEvent) tcell.Color {
c := DefaultColorer(ns, re) c := DefaultColorer(ns, re)
readyCol := 2 statusCol := 4
if !client.IsAllNamespaces(ns) { if !client.IsAllNamespaces(ns) {
readyCol-- statusCol--
} }
statusCol := readyCol + 1 status := strings.TrimSpace(re.Row.Fields[statusCol])
ready, status := strings.TrimSpace(re.Row.Fields[readyCol]), strings.TrimSpace(re.Row.Fields[statusCol])
c = p.checkReadyCol(ready, status, c)
switch status { switch status {
case ContainerCreating, PodInitializing: case ContainerCreating, PodInitializing:
c = AddColor c = AddColor
@ -42,28 +38,19 @@ func (p Pod) ColorerFunc() ColorerFunc {
case Completed: case Completed:
c = CompletedColor c = CompletedColor
case Running: case Running:
c = StdColor
case Terminating: case Terminating:
c = KillColor c = KillColor
default: default:
c = ErrColor if !Happy(ns, re.Row) {
c = ErrColor
}
} }
return c return c
} }
} }
func (Pod) checkReadyCol(readyCol, statusCol string, c tcell.Color) tcell.Color {
if statusCol == "Completed" {
return c
}
tokens := strings.Split(readyCol, "/")
if len(tokens) == 2 && (tokens[0] == "0" || tokens[0] != tokens[1]) {
return ErrColor
}
return c
}
// Header returns a header row. // Header returns a header row.
func (Pod) Header(ns string) HeaderRow { func (Pod) Header(ns string) HeaderRow {
var h HeaderRow var h HeaderRow
@ -74,17 +61,19 @@ func (Pod) Header(ns string) HeaderRow {
return append(h, return append(h,
Header{Name: "NAME"}, Header{Name: "NAME"},
Header{Name: "READY"}, Header{Name: "READY"},
Header{Name: "STATUS"},
Header{Name: "RS", Align: tview.AlignRight}, Header{Name: "RS", Align: tview.AlignRight},
Header{Name: "STATUS"},
Header{Name: "CPU", Align: tview.AlignRight}, Header{Name: "CPU", Align: tview.AlignRight},
Header{Name: "MEM", Align: tview.AlignRight}, Header{Name: "MEM", Align: tview.AlignRight},
Header{Name: "%CPU/R", Align: tview.AlignRight}, Header{Name: "%CPU/R", Align: tview.AlignRight},
Header{Name: "%MEM/R", Align: tview.AlignRight}, Header{Name: "%MEM/R", Align: tview.AlignRight},
Header{Name: "%CPU/L", Align: tview.AlignRight}, Header{Name: "%CPU/L", Align: tview.AlignRight},
Header{Name: "%MEM/L", Align: tview.AlignRight}, Header{Name: "%MEM/L", Align: tview.AlignRight},
Header{Name: "IP"}, Header{Name: "IP", Wide: true},
Header{Name: "NODE"}, Header{Name: "NODE", Wide: true},
Header{Name: "QOS"}, Header{Name: "QOS", Wide: true},
Header{Name: "LABELS", Wide: true},
Header{Name: "VALID", Wide: true},
Header{Name: "AGE", Decorator: AgeDecorator}, Header{Name: "AGE", Decorator: AgeDecorator},
) )
} }
@ -105,7 +94,7 @@ func (p Pod) Render(o interface{}, ns string, r *Row) error {
ss := po.Status.ContainerStatuses ss := po.Status.ContainerStatuses
cr, _, rc := p.Statuses(ss) cr, _, rc := p.Statuses(ss)
c, perc := p.gatherPodMX(&po, pwm.MX) c, perc := p.gatherPodMX(&po, pwm.MX)
phase := p.Phase(&po)
r.ID = client.MetaFQN(po.ObjectMeta) r.ID = client.MetaFQN(po.ObjectMeta)
r.Fields = make(Fields, 0, len(p.Header(ns))) r.Fields = make(Fields, 0, len(p.Header(ns)))
if client.IsAllNamespaces(ns) { if client.IsAllNamespaces(ns) {
@ -114,8 +103,8 @@ func (p Pod) Render(o interface{}, ns string, r *Row) error {
r.Fields = append(r.Fields, r.Fields = append(r.Fields,
po.ObjectMeta.Name, po.ObjectMeta.Name,
strconv.Itoa(cr)+"/"+strconv.Itoa(len(ss)), strconv.Itoa(cr)+"/"+strconv.Itoa(len(ss)),
p.Phase(&po),
strconv.Itoa(rc), strconv.Itoa(rc),
phase,
c.cpu, c.cpu,
c.mem, c.mem,
perc.cpu, perc.cpu,
@ -125,12 +114,25 @@ func (p Pod) Render(o interface{}, ns string, r *Row) error {
na(po.Status.PodIP), na(po.Status.PodIP),
na(po.Spec.NodeName), na(po.Spec.NodeName),
p.mapQOS(po.Status.QOSClass), p.mapQOS(po.Status.QOSClass),
mapToStr(po.Labels),
asStatus(p.diagnose(phase, cr, len(ss))),
toAge(po.ObjectMeta.CreationTimestamp), toAge(po.ObjectMeta.CreationTimestamp),
) )
return nil return nil
} }
func (p Pod) diagnose(phase string, cr, ct int) error {
if phase == "Completed" {
return nil
}
if cr != ct {
return fmt.Errorf("container ready check failed: %d of %d", cr, ct)
}
return nil
}
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
// Helpers... // Helpers...

View File

@ -24,12 +24,12 @@ type (
func TestPodColorer(t *testing.T) { func TestPodColorer(t *testing.T) {
var ( var (
nsRow = render.Row{Fields: render.Fields{"blee", "fred", "1/1", "Running"}} nsRow = render.Row{Fields: render.Fields{"blee", "fred", "1/1", "0", "Running"}}
toastNS = render.Row{Fields: render.Fields{"blee", "fred", "1/1", "Boom"}} toastNS = render.Row{Fields: render.Fields{"blee", "fred", "1/1", "0", "Boom"}}
notReadyNS = render.Row{Fields: render.Fields{"blee", "fred", "0/1", "Boom"}} notReadyNS = render.Row{Fields: render.Fields{"blee", "fred", "0/1", "0", "Boom"}}
row = render.Row{Fields: render.Fields{"fred", "1/1", "Running"}} row = render.Row{Fields: render.Fields{"fred", "1/1", "0", "Running"}}
toast = render.Row{Fields: render.Fields{"fred", "1/1", "Boom"}} toast = render.Row{Fields: render.Fields{"fred", "1/1", "0", "Boom"}}
notReady = render.Row{Fields: render.Fields{"fred", "0/1", "Boom"}} notReady = render.Row{Fields: render.Fields{"fred", "0/1", "0", "Boom"}}
) )
uu := colorerUCs{ uu := colorerUCs{
@ -70,7 +70,7 @@ func TestPodRender(t *testing.T) {
assert.Nil(t, err) assert.Nil(t, err)
assert.Equal(t, "default/nginx", r.ID) assert.Equal(t, "default/nginx", r.ID)
e := render.Fields{"default", "nginx", "1/1", "Running", "0", "10", "10", "10", "14", "0", "5", "172.17.0.6", "minikube", "BE"} e := render.Fields{"default", "nginx", "1/1", "0", "Running", "10", "10", "10", "14", "0", "5", "172.17.0.6", "minikube", "BE"}
assert.Equal(t, e, r.Fields[:14]) assert.Equal(t, e, r.Fields[:14])
} }
@ -101,7 +101,7 @@ func TestPodInitRender(t *testing.T) {
assert.Nil(t, err) assert.Nil(t, err)
assert.Equal(t, "default/nginx", r.ID) assert.Equal(t, "default/nginx", r.ID)
e := render.Fields{"default", "nginx", "1/1", "Init:0/1", "0", "10", "10", "10", "14", "0", "5", "172.17.0.6", "minikube", "BE"} e := render.Fields{"default", "nginx", "1/1", "0", "Init:0/1", "10", "10", "10", "14", "0", "5", "172.17.0.6", "minikube", "BE"}
assert.Equal(t, e, r.Fields[:14]) assert.Equal(t, e, r.Fields[:14])
} }

View File

@ -18,8 +18,8 @@ func rbacVerbHeader() HeaderRow {
Header{Name: "PATCH "}, Header{Name: "PATCH "},
Header{Name: "UPDATE"}, Header{Name: "UPDATE"},
Header{Name: "DELETE"}, Header{Name: "DELETE"},
Header{Name: "DLIST "}, Header{Name: "DEL-LIST "},
Header{Name: "EXTRAS"}, Header{Name: "EXTRAS", Wide: true},
} }
} }
@ -41,7 +41,10 @@ func (Policy) Header(ns string) HeaderRow {
Header{Name: "API GROUP"}, Header{Name: "API GROUP"},
Header{Name: "BINDING"}, Header{Name: "BINDING"},
} }
return append(h, rbacVerbHeader()...) h = append(h, rbacVerbHeader()...)
h = append(h, Header{Name: "VALID", Wide: true})
return h
} }
// Render renders a K8s resource to screen. // Render renders a K8s resource to screen.
@ -52,8 +55,14 @@ func (Policy) Render(o interface{}, gvr string, r *Row) error {
} }
r.ID = client.FQN(p.Namespace, p.Resource) r.ID = client.FQN(p.Namespace, p.Resource)
r.Fields = append(r.Fields, p.Namespace, cleanseResource(p.Resource), p.Group, p.Binding) r.Fields = append(r.Fields,
p.Namespace,
cleanseResource(p.Resource),
p.Group,
p.Binding,
)
r.Fields = append(r.Fields, asVerbs(p.Verbs)...) r.Fields = append(r.Fields, asVerbs(p.Verbs)...)
r.Fields = append(r.Fields, "")
return nil return nil
} }

View File

@ -37,5 +37,6 @@ func TestPolicyRender(t *testing.T) {
"[orangered::b] 𐄂 [::]", "[orangered::b] 𐄂 [::]",
"[orangered::b] 𐄂 [::]", "[orangered::b] 𐄂 [::]",
"", "",
"",
}, r.Fields) }, r.Fields)
} }

View File

@ -30,6 +30,7 @@ func TestPortForwardRender(t *testing.T) {
"http://0.0.0.0:p1/", "http://0.0.0.0:p1/",
"1", "1",
"1", "1",
"",
"2m", "2m",
}, r.Fields) }, r.Fields)
} }

View File

@ -48,6 +48,7 @@ func (PortForward) Header(ns string) HeaderRow {
Header{Name: "URL"}, Header{Name: "URL"},
Header{Name: "C"}, Header{Name: "C"},
Header{Name: "N"}, Header{Name: "N"},
Header{Name: "VALID", Wide: true},
Header{Name: "AGE", Decorator: AgeDecorator}, Header{Name: "AGE", Decorator: AgeDecorator},
} }
} }
@ -71,6 +72,7 @@ func (f PortForward) Render(o interface{}, gvr string, r *Row) error {
UrlFor(pf.Config.Host, pf.Config.Path, ports[0]), UrlFor(pf.Config.Host, pf.Config.Path, ports[0]),
asNum(pf.Config.C), asNum(pf.Config.C),
asNum(pf.Config.N), asNum(pf.Config.N),
"",
pf.Age(), pf.Age(),
} }

View File

@ -16,26 +16,26 @@ import (
type PersistentVolume struct{} type PersistentVolume struct{}
// ColorerFunc colors a resource row. // ColorerFunc colors a resource row.
func (PersistentVolume) ColorerFunc() ColorerFunc { func (p PersistentVolume) ColorerFunc() ColorerFunc {
return func(ns string, r RowEvent) tcell.Color { return func(ns string, re RowEvent) tcell.Color {
c := DefaultColorer(ns, r) c := DefaultColorer(ns, re)
if r.Kind == EventAdd || r.Kind == EventUpdate { if re.Kind == EventAdd || re.Kind == EventUpdate {
return c return c
} }
status := strings.TrimSpace(r.Row.Fields[4]) if !Happy(ns, re.Row) {
switch status { return ErrColor
}
switch strings.TrimSpace(re.Row.Fields[4]) {
case "Bound": case "Bound":
c = StdColor c = StdColor
case "Available": case "Available":
c = tcell.ColorYellow c = tcell.ColorYellow
default:
c = ErrColor
} }
return c return c
} }
} }
// Header returns a header rbw. // Header returns a header rbw.
@ -49,6 +49,9 @@ func (PersistentVolume) Header(string) HeaderRow {
Header{Name: "CLAIM"}, Header{Name: "CLAIM"},
Header{Name: "STORAGECLASS"}, Header{Name: "STORAGECLASS"},
Header{Name: "REASON"}, Header{Name: "REASON"},
Header{Name: "VOLUMEMODE", Wide: true},
Header{Name: "LABELS", Wide: true},
Header{Name: "VALID", Wide: true},
Header{Name: "AGE", Decorator: AgeDecorator}, Header{Name: "AGE", Decorator: AgeDecorator},
} }
} }
@ -90,12 +93,30 @@ func (p PersistentVolume) Render(o interface{}, ns string, r *Row) error {
claim, claim,
class, class,
pv.Status.Reason, pv.Status.Reason,
p.volumeMode(pv.Spec.VolumeMode),
mapToStr(pv.Labels),
asStatus(p.diagnose(string(phase))),
toAge(pv.ObjectMeta.CreationTimestamp), toAge(pv.ObjectMeta.CreationTimestamp),
} }
return nil return nil
} }
func (PersistentVolume) diagnose(r string) error {
if r != "Bound" && r != "Available" {
return fmt.Errorf("unexpected status %s", r)
}
return nil
}
func (PersistentVolume) volumeMode(m *v1.PersistentVolumeMode) string {
if m == nil {
return MissingValue
}
return string(*m)
}
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
// Helpers... // Helpers...

View File

@ -2,7 +2,6 @@ package render
import ( import (
"fmt" "fmt"
"strings"
"github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/client"
"github.com/gdamore/tcell" "github.com/gdamore/tcell"
@ -15,19 +14,14 @@ import (
type PersistentVolumeClaim struct{} type PersistentVolumeClaim struct{}
// ColorerFunc colors a resource row. // ColorerFunc colors a resource row.
func (PersistentVolumeClaim) ColorerFunc() ColorerFunc { func (p PersistentVolumeClaim) ColorerFunc() ColorerFunc {
return func(ns string, r RowEvent) tcell.Color { return func(ns string, re RowEvent) tcell.Color {
c := DefaultColorer(ns, r) c := DefaultColorer(ns, re)
if r.Kind == EventAdd || r.Kind == EventUpdate { if re.Kind == EventAdd || re.Kind == EventUpdate {
return c return c
} }
if !Happy(ns, re.Row) {
markCol := 2 return ErrColor
if !client.IsAllNamespaces(ns) {
markCol--
}
if strings.TrimSpace(r.Row.Fields[markCol]) != "Bound" {
c = ErrColor
} }
return c return c
@ -49,6 +43,8 @@ func (PersistentVolumeClaim) Header(ns string) HeaderRow {
Header{Name: "CAPACITY"}, Header{Name: "CAPACITY"},
Header{Name: "ACCESS MODES"}, Header{Name: "ACCESS MODES"},
Header{Name: "STORAGECLASS"}, Header{Name: "STORAGECLASS"},
Header{Name: "LABELS", Wide: true},
Header{Name: "VALID", Wide: true},
Header{Name: "AGE", Decorator: AgeDecorator}, Header{Name: "AGE", Decorator: AgeDecorator},
) )
} }
@ -96,8 +92,17 @@ func (p PersistentVolumeClaim) Render(o interface{}, ns string, r *Row) error {
capacity, capacity,
accessModes, accessModes,
class, class,
mapToStr(pvc.Labels),
asStatus(p.diagnose(string(phase))),
toAge(pvc.ObjectMeta.CreationTimestamp), toAge(pvc.ObjectMeta.CreationTimestamp),
) )
return nil return nil
} }
func (PersistentVolumeClaim) diagnose(r string) error {
if r != "Bound" && r != "Available" {
return fmt.Errorf("unexpected status %s", r)
}
return nil
}

View File

@ -46,7 +46,10 @@ func (Rbac) Header(ns string) HeaderRow {
Header{Name: "API GROUP"}, Header{Name: "API GROUP"},
} }
return append(h, rbacVerbHeader()...) h = append(h, rbacVerbHeader()...)
h = append(h, Header{Name: "VALID", Wide: true})
return h
} }
// Render renders a K8s resource to screen. // Render renders a K8s resource to screen.
@ -57,8 +60,12 @@ func (Rbac) Render(o interface{}, gvr string, r *Row) error {
} }
r.ID = p.Resource r.ID = p.Resource
r.Fields = append(r.Fields, cleanseResource(p.Resource), p.Group) r.Fields = append(r.Fields,
cleanseResource(p.Resource),
p.Group,
)
r.Fields = append(r.Fields, asVerbs(p.Verbs)...) r.Fields = append(r.Fields, asVerbs(p.Verbs)...)
r.Fields = append(r.Fields, "")
return nil return nil
} }

View File

@ -26,6 +26,8 @@ func (Role) Header(ns string) HeaderRow {
return append(h, return append(h,
Header{Name: "NAME"}, Header{Name: "NAME"},
Header{Name: "LABELS", Wide: true},
Header{Name: "VALID", Wide: true},
Header{Name: "AGE", Decorator: AgeDecorator}, Header{Name: "AGE", Decorator: AgeDecorator},
) )
} }
@ -49,6 +51,8 @@ func (r Role) Render(o interface{}, ns string, row *Row) error {
} }
row.Fields = append(row.Fields, row.Fields = append(row.Fields,
ro.Name, ro.Name,
mapToStr(ro.Labels),
"",
toAge(ro.ObjectMeta.CreationTimestamp), toAge(ro.ObjectMeta.CreationTimestamp),
) )

View File

@ -30,6 +30,8 @@ func (RoleBinding) Header(ns string) HeaderRow {
Header{Name: "ROLE"}, Header{Name: "ROLE"},
Header{Name: "KIND"}, Header{Name: "KIND"},
Header{Name: "SUBJECTS"}, Header{Name: "SUBJECTS"},
Header{Name: "LABELS", Wide: true},
Header{Name: "VALID", Wide: true},
Header{Name: "AGE", Decorator: AgeDecorator}, Header{Name: "AGE", Decorator: AgeDecorator},
) )
} }
@ -58,6 +60,8 @@ func (r RoleBinding) Render(o interface{}, ns string, row *Row) error {
rb.RoleRef.Name, rb.RoleRef.Name,
kind, kind,
ss, ss,
mapToStr(rb.Labels),
"",
toAge(rb.ObjectMeta.CreationTimestamp), toAge(rb.ObjectMeta.CreationTimestamp),
) )
@ -87,11 +91,11 @@ func toSubjectAlias(s string) string {
switch s { switch s {
case rbacv1.UserKind: case rbacv1.UserKind:
return "USR" return "User"
case rbacv1.GroupKind: case rbacv1.GroupKind:
return "GRP" return "Group"
case rbacv1.ServiceAccountKind: case rbacv1.ServiceAccountKind:
return "SA" return "SvcAcct"
default: default:
return strings.ToUpper(s) return strings.ToUpper(s)
} }

View File

@ -13,5 +13,5 @@ func TestRoleBindingRender(t *testing.T) {
c.Render(load(t, "rb"), "", &r) c.Render(load(t, "rb"), "", &r)
assert.Equal(t, "default/blee", r.ID) assert.Equal(t, "default/blee", r.ID)
assert.Equal(t, render.Fields{"default", "blee", "blee", "SA", "fernand"}, r.Fields[:5]) assert.Equal(t, render.Fields{"default", "blee", "blee", "SvcAcct", "fernand"}, r.Fields[:5])
} }

View File

@ -39,6 +39,11 @@ func (r Row) Clone() Row {
} }
} }
// Len returns the length of the row.
func (r Row) Len() int {
return len(r.Fields)
}
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
// Rows represents a collection of rows. // Rows represents a collection of rows.

View File

@ -181,7 +181,7 @@ func (rr RowEvents) Sort(ns string, col int, asc bool) {
func toAgeDuration(dur string) string { func toAgeDuration(dur string) string {
d, err := time.ParseDuration(dur) d, err := time.ParseDuration(dur)
if err != nil { if err != nil {
return "n/a" return dur
} }
return duration.HumanDuration(d) return duration.HumanDuration(d)
} }

View File

@ -9,6 +9,8 @@ type Header struct {
Name string Name string
Align int Align int
Decorator DecoratorFunc Decorator DecoratorFunc
Hide bool
Wide bool
} }
// Clone copies a header. // Clone copies a header.
@ -56,13 +58,7 @@ func (hh HeaderRow) Columns() []string {
// HasAge returns true if table has an age column. // HasAge returns true if table has an age column.
func (hh HeaderRow) HasAge() bool { func (hh HeaderRow) HasAge() bool {
for _, r := range hh { return hh.IndexOf(ageCol) != -1
if r.Name == ageCol {
return true
}
}
return false
} }
// AgeCol checks if given column index is the age column. // AgeCol checks if given column index is the age column.
@ -72,3 +68,18 @@ func (hh HeaderRow) AgeCol(col int) bool {
} }
return col == len(hh)-1 return col == len(hh)-1
} }
// ValidColIndex returns the valid col index or -1 if none.
func (hh HeaderRow) ValidColIndex() int {
return hh.IndexOf("VALID")
}
// IndexOf returns the col index or -1 if none.
func (hh HeaderRow) IndexOf(c string) int {
for i, h := range hh {
if h.Name == c {
return i
}
}
return -1
}

View File

@ -3,7 +3,6 @@ package render
import ( import (
"fmt" "fmt"
"strconv" "strconv"
"strings"
"github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/client"
"github.com/derailed/tview" "github.com/derailed/tview"
@ -17,24 +16,19 @@ import (
type ReplicaSet struct{} type ReplicaSet struct{}
// ColorerFunc colors a resource row. // ColorerFunc colors a resource row.
func (ReplicaSet) ColorerFunc() ColorerFunc { func (r ReplicaSet) ColorerFunc() ColorerFunc {
return func(ns string, r RowEvent) tcell.Color { return func(ns string, re RowEvent) tcell.Color {
c := DefaultColorer(ns, r) c := DefaultColorer(ns, re)
if r.Kind == EventAdd || r.Kind == EventUpdate { if re.Kind == EventAdd || re.Kind == EventUpdate {
return c return c
} }
markCol := 2 if !Happy(ns, re.Row) {
if !client.IsAllNamespaces(ns) {
markCol--
}
if strings.TrimSpace(r.Row.Fields[markCol]) != strings.TrimSpace(r.Row.Fields[markCol+1]) {
return ErrColor return ErrColor
} }
return StdColor return StdColor
} }
} }
// Header returns a header row. // Header returns a header row.
@ -49,12 +43,14 @@ func (ReplicaSet) Header(ns string) HeaderRow {
Header{Name: "DESIRED", Align: tview.AlignRight}, Header{Name: "DESIRED", Align: tview.AlignRight},
Header{Name: "CURRENT", Align: tview.AlignRight}, Header{Name: "CURRENT", Align: tview.AlignRight},
Header{Name: "READY", Align: tview.AlignRight}, Header{Name: "READY", Align: tview.AlignRight},
Header{Name: "LABELS", Wide: true},
Header{Name: "VALID", Wide: true},
Header{Name: "AGE", Decorator: AgeDecorator}, Header{Name: "AGE", Decorator: AgeDecorator},
) )
} }
// Render renders a K8s resource to screen. // Render renders a K8s resource to screen.
func (s ReplicaSet) Render(o interface{}, ns string, r *Row) error { func (r ReplicaSet) Render(o interface{}, ns string, row *Row) error {
raw, ok := o.(*unstructured.Unstructured) raw, ok := o.(*unstructured.Unstructured)
if !ok { if !ok {
return fmt.Errorf("Expected ReplicaSet, but got %T", o) return fmt.Errorf("Expected ReplicaSet, but got %T", o)
@ -65,18 +61,31 @@ func (s ReplicaSet) Render(o interface{}, ns string, r *Row) error {
return err return err
} }
r.ID = client.MetaFQN(rs.ObjectMeta) row.ID = client.MetaFQN(rs.ObjectMeta)
r.Fields = make(Fields, 0, len(s.Header(ns))) row.Fields = make(Fields, 0, len(r.Header(ns)))
if client.IsAllNamespaces(ns) { if client.IsAllNamespaces(ns) {
r.Fields = append(r.Fields, rs.Namespace) row.Fields = append(row.Fields, rs.Namespace)
} }
r.Fields = append(r.Fields, row.Fields = append(row.Fields,
rs.Name, rs.Name,
strconv.Itoa(int(*rs.Spec.Replicas)), strconv.Itoa(int(*rs.Spec.Replicas)),
strconv.Itoa(int(rs.Status.Replicas)), strconv.Itoa(int(rs.Status.Replicas)),
strconv.Itoa(int(rs.Status.ReadyReplicas)), strconv.Itoa(int(rs.Status.ReadyReplicas)),
mapToStr(rs.Labels),
asStatus(r.diagnose(rs)),
toAge(rs.ObjectMeta.CreationTimestamp), toAge(rs.ObjectMeta.CreationTimestamp),
) )
return nil return nil
} }
func (ReplicaSet) diagnose(rs appsv1.ReplicaSet) error {
if rs.Status.Replicas != rs.Status.ReadyReplicas {
if rs.Status.Replicas == 0 {
return fmt.Errorf("did not phase down correctly expecting 0 replicas but got %d", rs.Status.ReadyReplicas)
}
return fmt.Errorf("mismatch desired(%d) vs ready(%d)", rs.Status.Replicas, rs.Status.ReadyReplicas)
}
return nil
}

View File

@ -28,6 +28,8 @@ func (ServiceAccount) Header(ns string) HeaderRow {
return append(h, return append(h,
Header{Name: "NAME"}, Header{Name: "NAME"},
Header{Name: "SECRET"}, Header{Name: "SECRET"},
Header{Name: "LABELS", Wide: true},
Header{Name: "VALID", Wide: true},
Header{Name: "AGE", Decorator: AgeDecorator}, Header{Name: "AGE", Decorator: AgeDecorator},
) )
} }
@ -52,6 +54,8 @@ func (s ServiceAccount) Render(o interface{}, ns string, r *Row) error {
r.Fields = append(r.Fields, r.Fields = append(r.Fields,
sa.Name, sa.Name,
strconv.Itoa(len(sa.Secrets)), strconv.Itoa(len(sa.Secrets)),
mapToStr(sa.Labels),
"",
toAge(sa.ObjectMeta.CreationTimestamp), toAge(sa.ObjectMeta.CreationTimestamp),
) )

View File

@ -22,6 +22,8 @@ func (StorageClass) Header(ns string) HeaderRow {
return HeaderRow{ return HeaderRow{
Header{Name: "NAME"}, Header{Name: "NAME"},
Header{Name: "PROVISIONER"}, Header{Name: "PROVISIONER"},
Header{Name: "LABELS", Wide: true},
Header{Name: "VALID", Wide: true},
Header{Name: "AGE", Decorator: AgeDecorator}, Header{Name: "AGE", Decorator: AgeDecorator},
} }
} }
@ -42,6 +44,8 @@ func (StorageClass) Render(o interface{}, ns string, r *Row) error {
r.Fields = Fields{ r.Fields = Fields{
sc.Name, sc.Name,
string(sc.Provisioner), string(sc.Provisioner),
mapToStr(sc.Labels),
"",
toAge(sc.ObjectMeta.CreationTimestamp), toAge(sc.ObjectMeta.CreationTimestamp),
} }

View File

@ -33,6 +33,8 @@ var AgeDecorator = func(a string) string {
func (ScreenDump) Header(ns string) HeaderRow { func (ScreenDump) Header(ns string) HeaderRow {
return HeaderRow{ return HeaderRow{
Header{Name: "NAME"}, Header{Name: "NAME"},
Header{Name: "DIR"},
Header{Name: "VALID", Wide: true},
Header{Name: "AGE", Decorator: AgeDecorator}, Header{Name: "AGE", Decorator: AgeDecorator},
} }
} }
@ -47,6 +49,8 @@ func (b ScreenDump) Render(o interface{}, ns string, r *Row) error {
r.ID = filepath.Join(f.Dir, f.File.Name()) r.ID = filepath.Join(f.Dir, f.File.Name())
r.Fields = Fields{ r.Fields = Fields{
f.File.Name(), f.File.Name(),
f.Dir,
"",
timeToAge(f.File.ModTime()), timeToAge(f.File.ModTime()),
} }

View File

@ -21,6 +21,8 @@ func TestScreenDumpRender(t *testing.T) {
assert.Equal(t, "fred/blee/bob", r.ID) assert.Equal(t, "fred/blee/bob", r.ID)
assert.Equal(t, render.Fields{ assert.Equal(t, render.Fields{
"bob", "bob",
"fred/blee",
"",
}, r.Fields[:len(r.Fields)-1]) }, r.Fields[:len(r.Fields)-1])
} }

View File

@ -3,7 +3,6 @@ package render
import ( import (
"fmt" "fmt"
"strconv" "strconv"
"strings"
"github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/client"
"github.com/gdamore/tcell" "github.com/gdamore/tcell"
@ -16,20 +15,13 @@ import (
type StatefulSet struct{} type StatefulSet struct{}
// ColorerFunc colors a resource row. // ColorerFunc colors a resource row.
func (StatefulSet) ColorerFunc() ColorerFunc { func (s StatefulSet) ColorerFunc() ColorerFunc {
return func(ns string, r RowEvent) tcell.Color { return func(ns string, re RowEvent) tcell.Color {
c := DefaultColorer(ns, r) c := DefaultColorer(ns, re)
if r.Kind == EventAdd || r.Kind == EventUpdate { if re.Kind == EventAdd || re.Kind == EventUpdate {
return c return c
} }
if !Happy(ns, re.Row) {
readyCol := 2
if !client.IsAllNamespaces(ns) {
readyCol--
}
tokens := strings.Split(strings.TrimSpace(r.Row.Fields[readyCol]), "/")
curr, des := tokens[0], tokens[1]
if curr != des {
return ErrColor return ErrColor
} }
@ -47,8 +39,12 @@ func (StatefulSet) Header(ns string) HeaderRow {
return append(h, return append(h,
Header{Name: "NAME"}, Header{Name: "NAME"},
Header{Name: "READY"}, Header{Name: "READY"},
Header{Name: "SELECTOR"}, Header{Name: "SELECTOR", Wide: true},
Header{Name: "SERVICE"}, Header{Name: "SERVICE"},
Header{Name: "CONTAINERS", Wide: true},
Header{Name: "IMAGES", Wide: true},
Header{Name: "LABELS", Wide: true},
Header{Name: "VALID", Wide: true},
Header{Name: "AGE", Decorator: AgeDecorator}, Header{Name: "AGE", Decorator: AgeDecorator},
) )
} }
@ -72,11 +68,22 @@ func (s StatefulSet) Render(o interface{}, ns string, r *Row) error {
} }
r.Fields = append(r.Fields, r.Fields = append(r.Fields,
sts.Name, sts.Name,
strconv.Itoa(int(sts.Status.Replicas))+"/"+strconv.Itoa(int(*sts.Spec.Replicas)), strconv.Itoa(int(sts.Status.ReadyReplicas))+"/"+strconv.Itoa(int(sts.Status.Replicas)),
asSelector(sts.Spec.Selector), asSelector(sts.Spec.Selector),
na(sts.Spec.ServiceName), na(sts.Spec.ServiceName),
podContainerNames(sts.Spec.Template.Spec, true),
podImageNames(sts.Spec.Template.Spec, true),
mapToStr(sts.Labels),
asStatus(s.diagnose(sts.Status.Replicas, sts.Status.ReadyReplicas)),
toAge(sts.ObjectMeta.CreationTimestamp), toAge(sts.ObjectMeta.CreationTimestamp),
) )
return nil return nil
} }
func (StatefulSet) diagnose(d, r int32) error {
if d != r {
return fmt.Errorf("desiring %d replicas got %d available", d, r)
}
return nil
}

View File

@ -13,5 +13,5 @@ func TestStatefulSetRender(t *testing.T) {
assert.Nil(t, c.Render(load(t, "sts"), "", &r)) assert.Nil(t, c.Render(load(t, "sts"), "", &r))
assert.Equal(t, "default/nginx-sts", r.ID) assert.Equal(t, "default/nginx-sts", r.ID)
assert.Equal(t, render.Fields{"default", "nginx-sts", "4/4", "app=nginx-sts", "nginx-sts"}, r.Fields[:len(r.Fields)-1]) assert.Equal(t, render.Fields{"default", "nginx-sts", "4/4", "app=nginx-sts", "nginx-sts", "nginx", "k8s.gcr.io/nginx-slim:0.8", "app=nginx-sts", ""}, r.Fields[:len(r.Fields)-1])
} }

View File

@ -11,6 +11,11 @@ import (
// Subject renders a rbac to screen. // Subject renders a rbac to screen.
type Subject struct{} type Subject struct{}
// Happy returns true if resoure is happy, false otherwise
func (Subject) Happy(_ string, _ Row) bool {
return true
}
// ColorerFunc colors a resource row. // ColorerFunc colors a resource row.
func (Subject) ColorerFunc() ColorerFunc { func (Subject) ColorerFunc() ColorerFunc {
return func(ns string, re RowEvent) tcell.Color { return func(ns string, re RowEvent) tcell.Color {
@ -24,6 +29,7 @@ func (Subject) Header(ns string) HeaderRow {
Header{Name: "NAME"}, Header{Name: "NAME"},
Header{Name: "KIND"}, Header{Name: "KIND"},
Header{Name: "FIRST LOCATION"}, Header{Name: "FIRST LOCATION"},
Header{Name: "VALID", Wide: true},
} }
} }
@ -40,6 +46,7 @@ func (s Subject) Render(o interface{}, ns string, r *Row) error {
res.Name, res.Name,
res.Kind, res.Kind,
res.FirstLocation, res.FirstLocation,
"",
) )
return nil return nil

View File

@ -32,8 +32,10 @@ func (Service) Header(ns string) HeaderRow {
Header{Name: "TYPE"}, Header{Name: "TYPE"},
Header{Name: "CLUSTER-IP"}, Header{Name: "CLUSTER-IP"},
Header{Name: "EXTERNAL-IP"}, Header{Name: "EXTERNAL-IP"},
Header{Name: "SELECTOR"}, Header{Name: "SELECTOR", Wide: true},
Header{Name: "PORTS"}, Header{Name: "PORTS", Wide: true},
Header{Name: "LABELS", Wide: true},
Header{Name: "VALID", Wide: true},
Header{Name: "AGE", Decorator: AgeDecorator}, Header{Name: "AGE", Decorator: AgeDecorator},
) )
} }
@ -58,19 +60,32 @@ func (s Service) Render(o interface{}, ns string, r *Row) error {
r.Fields = append(r.Fields, r.Fields = append(r.Fields,
svc.ObjectMeta.Name, svc.ObjectMeta.Name,
string(svc.Spec.Type), string(svc.Spec.Type),
svc.Spec.ClusterIP, toIP(svc.Spec.ClusterIP),
toIPs(svc.Spec.Type, getSvcExtIPS(&svc)), toIPs(svc.Spec.Type, getSvcExtIPS(&svc)),
mapToStr(svc.Spec.Selector), mapToStr(svc.Spec.Selector),
toPorts(svc.Spec.Ports), toPorts(svc.Spec.Ports),
mapToStr(svc.Labels),
asStatus(s.diagnose()),
toAge(svc.ObjectMeta.CreationTimestamp), toAge(svc.ObjectMeta.CreationTimestamp),
) )
return nil return nil
} }
func (Service) diagnose() error {
return nil
}
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
// Helpers... // Helpers...
func toIP(ip string) string {
if ip == "" || ip == "None" {
return ""
}
return ip
}
func getSvcExtIPS(svc *v1.Service) []string { func getSvcExtIPS(svc *v1.Service) []string {
results := []string{} results := []string{}
@ -116,7 +131,7 @@ func toIPs(svcType v1.ServiceType, ips []string) string {
if svcType == v1.ServiceTypeLoadBalancer { if svcType == v1.ServiceTypeLoadBalancer {
return "<pending>" return "<pending>"
} }
return MissingValue return ""
} }
sort.Strings(ips) sort.Strings(ips)

View File

@ -13,5 +13,5 @@ func TestServiceRender(t *testing.T) {
c.Render(load(t, "svc"), "", &r) c.Render(load(t, "svc"), "", &r)
assert.Equal(t, "default/dictionary1", r.ID) assert.Equal(t, "default/dictionary1", r.ID)
assert.Equal(t, render.Fields{"default", "dictionary1", "ClusterIP", "10.47.248.116", "<none>", "app=dictionary1", "http:4001►0"}, r.Fields[:7]) assert.Equal(t, render.Fields{"default", "dictionary1", "ClusterIP", "10.47.248.116", "", "app=dictionary1", "http:4001►0"}, r.Fields[:7])
} }

View File

@ -0,0 +1,128 @@
package tchart
import (
"image"
"sync"
"github.com/derailed/tview"
"github.com/gdamore/tcell"
"github.com/rs/zerolog/log"
)
const (
okColor, faultColor = tcell.ColorPaleGreen, tcell.ColorOrangeRed
okColorName, faultColorName = "palegreen", "orangered"
)
// Component represents a graphic component.
type Component struct {
*tview.Box
bgColor, noColor tcell.Color
seriesColors []tcell.Color
dimmed tcell.Style
id, legend string
blur func(tcell.Key)
mx sync.RWMutex
}
// NewComponent returns a new component.
func NewComponent(id string) *Component {
return &Component{
Box: tview.NewBox(),
id: id,
noColor: tcell.ColorDefault,
seriesColors: []tcell.Color{tview.Styles.PrimaryTextColor, tview.Styles.FocusColor},
dimmed: tcell.StyleDefault.Background(tview.Styles.PrimitiveBackgroundColor).Foreground(tcell.ColorGray).Dim(true),
}
}
// SetBackgroundColor sets the graph bg color.
func (c *Component) SetBackgroundColor(color tcell.Color) {
c.Box.SetBackgroundColor(color)
c.bgColor = color
c.dimmed = c.dimmed.Background(color)
}
// ID returns the component ID.
func (c *Component) ID() string {
return c.id
}
// SetLegend sets the component legend.
func (c *Component) SetLegend(l string) {
c.mx.Lock()
defer c.mx.Unlock()
c.legend = l
}
// InputHandler returns the handler for this primitive.
func (c *Component) InputHandler() func(event *tcell.EventKey, setFocus func(p tview.Primitive)) {
return c.WrapInputHandler(func(event *tcell.EventKey, setFocus func(p tview.Primitive)) {
switch key := event.Key(); key {
case tcell.KeyEnter:
log.Debug().Msgf("YO %s ENTER!!", c.id)
case tcell.KeyBacktab, tcell.KeyTab:
log.Debug().Msgf("YO %s TAB!!", c.id)
if c.blur != nil {
c.blur(key)
}
setFocus(c)
}
})
}
// IsDial returns true if chart is a dial
func (c *Component) IsDial() bool {
return false
}
// SetBlurFunc sets a callback fn when component gets out of focus.
func (c *Component) SetBlurFunc(handler func(key tcell.Key)) *Component {
c.blur = handler
return c
}
// SetSeriesColors sets the component series colors.
func (c *Component) SetSeriesColors(cc ...tcell.Color) {
c.mx.Lock()
defer c.mx.Unlock()
c.seriesColors = cc
}
// GetSeriesColorNames returns series colors by name.
func (c *Component) GetSeriesColorNames() []string {
c.mx.RLock()
defer c.mx.RUnlock()
var nn []string
for _, color := range c.seriesColors {
for name, co := range tcell.ColorNames {
if co == color {
nn = append(nn, name)
}
}
}
if len(nn) < 2 {
nn = append(nn, okColorName, faultColorName)
}
return nn
}
func (c *Component) colorForSeries() (tcell.Color, tcell.Color) {
c.mx.RLock()
defer c.mx.RUnlock()
if len(c.seriesColors) > 1 {
return c.seriesColors[0], c.seriesColors[1]
}
return okColor, faultColor
}
func (c *Component) asRect() image.Rectangle {
x, y, width, height := c.GetInnerRect()
return image.Rectangle{
Min: image.Point{X: x, Y: y},
Max: image.Point{X: x + width, Y: y + height},
}
}

View File

@ -0,0 +1,112 @@
package tchart
import (
"fmt"
)
var dots = []rune{' ', '⠂', '▤', '▥'}
// Segment represents a dial segment.
type Segment []int
// Segments represents a collection of segments.
type Segments []Segment
// Matrix represents a number dial.
type Matrix [][]rune
// Orientation tracks char orientations.
type Orientation int
// DotMatrix tracks a char matrix.
type DotMatrix struct {
row, col int
}
// NewDotMatrix returns a new matrix.
func NewDotMatrix(row, col int) DotMatrix {
return DotMatrix{
row: row,
col: col,
}
}
// Print prints the matrix.
func (d DotMatrix) Print(n int) Matrix {
m := make(Matrix, d.row)
segs := asSegments(n)
for row := 0; row < d.row; row++ {
for col := 0; col < d.col; col++ {
m[row] = append(m[row], segs.CharFor(row, col))
}
}
return m
}
func asSegments(n int) Segment {
switch n {
case 0:
return Segment{1, 1, 1, 0, 1, 1, 1}
case 1:
return Segment{0, 0, 1, 0, 0, 1, 0}
case 2:
return Segment{1, 0, 1, 1, 1, 0, 1}
case 3:
return Segment{1, 0, 1, 1, 0, 1, 1}
case 4:
return Segment{0, 1, 0, 1, 0, 1, 0}
case 5:
return Segment{1, 1, 0, 1, 0, 1, 1}
case 6:
return Segment{0, 1, 0, 1, 1, 1, 1}
case 7:
return Segment{1, 0, 1, 0, 0, 1, 0}
case 8:
return Segment{1, 1, 1, 1, 1, 1, 1}
case 9:
return Segment{1, 1, 1, 1, 0, 1, 0}
default:
panic(fmt.Sprintf("NYI %d", n))
}
}
// CharFor return a char based on row/col.
func (s Segment) CharFor(row, col int) rune {
c := ' '
segs := ToSegments(row, col)
if segs == nil {
return c
}
for _, seg := range segs {
if s[seg] == 1 {
c = charForSeg(seg, row, col)
}
}
return c
}
func charForSeg(seg, row, col int) rune {
switch seg {
case 0, 3, 6:
return dots[2]
}
if row == 0 && (col == 0 || col == 2) {
return dots[2]
}
return dots[3]
}
var segs = map[int][][]int{
0: {{1, 0}, {0}, {2, 0}},
1: {{1}, nil, {2}},
2: {{1, 3}, {3}, {2, 3}},
3: {{4}, nil, {5}},
4: {{4, 6}, {6}, {5, 6}},
}
// ToSegments return path segments.
func ToSegments(row, col int) []int {
return segs[row][col]
}

View File

@ -0,0 +1,126 @@
package tchart_test
import (
"strconv"
"testing"
"github.com/derailed/k9s/internal/tchart"
"github.com/stretchr/testify/assert"
)
func TestSegmentFor(t *testing.T) {
uu := map[string]struct {
r, c int
e []int
}{
"0x0": {r: 0, c: 0, e: []int{1, 0}},
"0x1": {r: 0, c: 1, e: []int{0}},
"0x2": {r: 0, c: 2, e: []int{2, 0}},
"1x0": {r: 1, c: 0, e: []int{1}},
"1x1": {r: 1, c: 1, e: nil},
"1x2": {r: 1, c: 2, e: []int{2}},
"2x0": {r: 2, c: 0, e: []int{1, 3}},
"2x1": {r: 2, c: 1, e: []int{3}},
"2x2": {r: 2, c: 2, e: []int{2, 3}},
"3x0": {r: 3, c: 0, e: []int{4}},
"3x1": {r: 3, c: 1, e: nil},
"3x2": {r: 3, c: 2, e: []int{5}},
"4x0": {r: 4, c: 0, e: []int{4, 6}},
"4x1": {r: 4, c: 1, e: []int{6}},
"4x2": {r: 4, c: 2, e: []int{5, 6}},
}
for k := range uu {
u := uu[k]
t.Run(k, func(t *testing.T) {
assert.Equal(t, u.e, tchart.ToSegments(u.r, u.c))
})
}
}
func TestDial(t *testing.T) {
d := tchart.NewDotMatrix(5, 3)
for n := 0; n <= 9; n++ {
i := n
t.Run(strconv.Itoa(n), func(t *testing.T) {
assert.Equal(t, numbers[i], d.Print(i))
})
}
}
// Helpers...
const hChar, vChar = '▤', '▥'
var numbers = []tchart.Matrix{
[][]rune{
{hChar, hChar, hChar},
{vChar, ' ', vChar},
{vChar, ' ', vChar},
{vChar, ' ', vChar},
{hChar, hChar, hChar},
},
[][]rune{
{' ', ' ', hChar},
{' ', ' ', vChar},
{' ', ' ', vChar},
{' ', ' ', vChar},
{' ', ' ', vChar},
},
[][]rune{
{hChar, hChar, hChar},
{' ', ' ', vChar},
{hChar, hChar, hChar},
{vChar, ' ', ' '},
{hChar, hChar, hChar},
},
[][]rune{
{hChar, hChar, hChar},
{' ', ' ', vChar},
{hChar, hChar, hChar},
{' ', ' ', vChar},
{hChar, hChar, hChar},
},
[][]rune{
{hChar, ' ', ' '},
{vChar, ' ', ' '},
{hChar, hChar, hChar},
{' ', ' ', vChar},
{' ', ' ', vChar},
},
[][]rune{
{hChar, hChar, hChar},
{vChar, ' ', ' '},
{hChar, hChar, hChar},
{' ', ' ', vChar},
{hChar, hChar, hChar},
},
[][]rune{
{hChar, ' ', ' '},
{vChar, ' ', ' '},
{hChar, hChar, hChar},
{vChar, ' ', vChar},
{hChar, hChar, hChar},
},
[][]rune{
{hChar, hChar, hChar},
{' ', ' ', vChar},
{' ', ' ', vChar},
{' ', ' ', vChar},
{' ', ' ', vChar},
},
[][]rune{
{hChar, hChar, hChar},
{vChar, ' ', vChar},
{hChar, hChar, hChar},
{vChar, ' ', vChar},
{hChar, hChar, hChar},
},
[][]rune{
{hChar, hChar, hChar},
{vChar, ' ', vChar},
{hChar, hChar, hChar},
{' ', ' ', vChar},
{' ', ' ', vChar},
},
}

159
internal/tchart/gauge.go Normal file
View File

@ -0,0 +1,159 @@
package tchart
import (
"fmt"
"image"
"github.com/derailed/tview"
"github.com/gdamore/tcell"
)
const (
// DeltaSame represents no difference.
DeltaSame delta = iota
// DeltaMore represents a higher value.
DeltaMore
// DeltaLess represents a lower value.
DeltaLess
gaugeFmt = "0%dd"
)
type delta int
// Gauge represents a gauge component.
type Gauge struct {
*Component
data Metric
deltaOk, deltaFault delta
}
// NewGauge returns a new gauge.
func NewGauge(id string) *Gauge {
return &Gauge{
Component: NewComponent(id),
}
}
// IsDial returns true if chart is a dial
func (g *Gauge) IsDial() bool {
return true
}
// Add adds a new metric.
func (g *Gauge) Add(m Metric) {
g.mx.Lock()
defer g.mx.Unlock()
g.deltaOk, g.deltaFault = computeDelta(g.data.OK, m.OK), computeDelta(g.data.Fault, m.Fault)
g.data = m
}
func (g *Gauge) drawNum(sc tcell.Screen, ok bool, o image.Point, n int, dn delta, ns string, style tcell.Style) {
c1, _ := g.colorForSeries()
if ok {
o.X -= 1
style = style.Foreground(c1)
printDelta(sc, dn, o, style)
o.X += 1
}
dm, sig := NewDotMatrix(5, 3), n == 0
if n == 0 {
style = g.dimmed
}
for i := 0; i < len(ns); i++ {
if ns[i] == '0' && !sig {
g.drawDial(sc, dm.Print(int(ns[i]-48)), o, g.dimmed)
} else {
sig = true
g.drawDial(sc, dm.Print(int(ns[i]-48)), o, style)
}
o.X += 5
}
if !ok {
printDelta(sc, dn, o, style)
}
}
// Draw draws the primitive.
func (g *Gauge) Draw(sc tcell.Screen) {
g.Component.Draw(sc)
g.mx.RLock()
defer g.mx.RUnlock()
rect := g.asRect()
mid := image.Point{X: rect.Min.X + rect.Dx()/2 - 2, Y: rect.Min.Y + rect.Dy()/2 - 2}
style := tcell.StyleDefault.Background(g.bgColor)
style = style.Foreground(tcell.ColorYellow)
sc.SetContent(mid.X+1, mid.Y+2, '⠔', nil, style)
var (
max = g.data.MaxDigits()
fmat = "%" + fmt.Sprintf(gaugeFmt, max)
o = image.Point{X: mid.X - 3, Y: mid.Y}
)
s1C, s2C := g.colorForSeries()
d1, d2 := fmt.Sprintf(fmat, g.data.OK), fmt.Sprintf(fmat, g.data.Fault)
o.X -= (len(d1) - 1) * 5
g.drawNum(sc, true, o, g.data.OK, g.deltaOk, d1, style.Foreground(s1C).Dim(false))
o.X = mid.X + 3
g.drawNum(sc, false, o, g.data.Fault, g.deltaFault, d2, style.Foreground(s2C).Dim(false))
if rect.Dx() > 0 && rect.Dy() > 0 && g.legend != "" {
legend := g.legend
if g.HasFocus() {
legend = "[:aqua]" + g.legend + "[::]"
}
tview.Print(sc, legend, rect.Min.X, rect.Max.Y, rect.Dx(), tview.AlignCenter, tcell.ColorWhite)
}
}
func (g *Gauge) drawDial(sc tcell.Screen, m Matrix, o image.Point, style tcell.Style) {
for r := 0; r < len(m); r++ {
for c := 0; c < len(m[r]); c++ {
dot := m[r][c]
if dot == dots[0] {
sc.SetContent(o.X+c, o.Y+r, dots[1], nil, g.dimmed)
} else {
sc.SetContent(o.X+c, o.Y+r, dot, nil, style)
}
}
}
}
// ----------------------------------------------------------------------------
// Helpers...
func computeDelta(d1, d2 int) delta {
if d2 == 0 {
return DeltaSame
}
d := d2 - d1
switch {
case d > 0:
return DeltaMore
case d < 0:
return DeltaLess
default:
return DeltaSame
}
}
func printDelta(sc tcell.Screen, d delta, o image.Point, s tcell.Style) {
s = s.Dim(false)
switch d {
case DeltaLess:
sc.SetContent(o.X-1, o.Y+2, '↓', nil, s)
case DeltaMore:
sc.SetContent(o.X-1, o.Y+2, '↑', nil, s)
}
}

View File

@ -0,0 +1,158 @@
package tchart
import (
"fmt"
"math"
"github.com/derailed/tview"
"github.com/gdamore/tcell"
)
var sparks = []rune{'▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'}
type block struct {
full int
partial rune
}
type blocks struct {
oks, errs block
}
// Metric tracks a good and error rates.
type Metric struct {
OK, Fault int
}
// MaxDigits returns the max of the metric.
func (m Metric) MaxDigits() int {
max := int(math.Max(float64(m.OK), float64(m.Fault)))
s := fmt.Sprintf("%d", max)
return len(s)
}
// Sum returns the sum of the metrics.
func (m Metric) Sum() int {
return m.OK + m.Fault
}
// SparkLine represents a sparkline component.
type SparkLine struct {
*Component
data []Metric
}
// NewSparkLine returns a new graph.
func NewSparkLine(id string) *SparkLine {
return &SparkLine{
Component: NewComponent(id),
}
}
// Add adds a metric.
func (s *SparkLine) Add(m Metric) {
s.mx.Lock()
defer s.mx.Unlock()
s.data = append(s.data, m)
}
// Draw draws the graph.
func (s *SparkLine) Draw(screen tcell.Screen) {
s.Component.Draw(screen)
s.mx.RLock()
defer s.mx.RUnlock()
if len(s.data) == 0 {
return
}
pad := 1
if s.legend != "" {
pad++
}
rect := s.asRect()
s.cutSet(rect.Dx())
max := s.computeMax()
cX, idx := rect.Min.X+1, 0
if len(s.data)*2 < rect.Dx() {
cX = rect.Max.X - len(s.data)*2
} else {
idx = len(s.data) - rect.Dx()/2
}
scale := float64(len(sparks)) * float64((rect.Dy() - pad)) / float64(max)
c1, c2 := s.colorForSeries()
for _, d := range s.data[idx:] {
b := toBlocks(d, scale)
cY := rect.Max.Y - pad
s.drawBlock(screen, cX, cY, b.oks, c1)
s.drawBlock(screen, cX, cY, b.errs, c2)
cX += 2
}
if rect.Dx() > 0 && rect.Dy() > 0 && s.legend != "" {
legend := s.legend
if s.HasFocus() {
legend = "[:aqua:]" + s.legend + "[::]"
}
tview.Print(screen, legend, rect.Min.X, rect.Max.Y, rect.Dx(), tview.AlignCenter, tcell.ColorWhite)
}
}
func (s *SparkLine) drawBlock(screen tcell.Screen, x, y int, b block, c tcell.Color) {
style := tcell.StyleDefault.Foreground(c).Background(s.bgColor)
for i := 0; i < b.full; i++ {
screen.SetContent(x, y, sparks[len(sparks)-1], nil, style)
y--
}
if b.partial != 0 {
screen.SetContent(x, y, b.partial, nil, style)
}
}
func (s *SparkLine) cutSet(width int) {
if width <= 0 || len(s.data) == 0 {
return
}
if len(s.data) >= width*2 {
s.data = s.data[len(s.data)-width:]
}
}
func (s *SparkLine) computeMax() int {
var max int
for _, d := range s.data {
if max < d.OK {
max = d.OK
}
}
return max
}
func toBlocks(m Metric, scale float64) blocks {
if m.Sum() <= 0 {
return blocks{}
}
return blocks{oks: makeBlocks(m.OK, false, scale), errs: makeBlocks(m.Fault, true, scale)}
}
func makeBlocks(v int, isErr bool, scale float64) block {
scaled := int(math.Round(float64(v) * scale))
part, b := scaled%len(sparks), block{full: scaled / len(sparks)}
// Err might get scaled way down if so nudge.
if v > 0 && isErr && scaled == 0 {
part = 1
}
if part > 0 {
b.partial = sparks[part-1]
}
return b
}

View File

@ -3,6 +3,7 @@ package ui
import ( import (
"github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/client"
"github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/config"
"github.com/derailed/k9s/internal/model"
"github.com/derailed/tview" "github.com/derailed/tview"
"github.com/gdamore/tcell" "github.com/gdamore/tcell"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
@ -14,6 +15,7 @@ type App struct {
Configurator Configurator
Main *Pages Main *Pages
flash *model.Flash
actions KeyActions actions KeyActions
views map[string]tview.Primitive views map[string]tview.Primitive
cmdBuff *CmdBuff cmdBuff *CmdBuff
@ -25,6 +27,7 @@ func NewApp(context string) *App {
Application: tview.NewApplication(), Application: tview.NewApplication(),
actions: make(KeyActions), actions: make(KeyActions),
Main: NewPages(), Main: NewPages(),
flash: model.NewFlash(model.DefaultFlashDelay),
cmdBuff: NewCmdBuff(':', CommandBuff), cmdBuff: NewCmdBuff(':', CommandBuff),
} }
a.ReloadStyles(context) a.ReloadStyles(context)
@ -33,7 +36,6 @@ func NewApp(context string) *App {
"menu": NewMenu(a.Styles), "menu": NewMenu(a.Styles),
"logo": NewLogo(a.Styles), "logo": NewLogo(a.Styles),
"cmd": NewCommand(a.Styles), "cmd": NewCommand(a.Styles),
"flash": NewFlash(&a, "Initializing..."),
"crumbs": NewCrumbs(a.Styles), "crumbs": NewCrumbs(a.Styles),
} }
@ -239,11 +241,6 @@ func (a *App) Logo() *Logo {
return a.views["logo"].(*Logo) return a.views["logo"].(*Logo)
} }
// Flash returns app flash.
func (a *App) Flash() *Flash {
return a.views["flash"].(*Flash)
}
// Cmd returns app cmd. // Cmd returns app cmd.
func (a *App) Cmd() *Command { func (a *App) Cmd() *Command {
return a.views["cmd"].(*Command) return a.views["cmd"].(*Command)
@ -254,6 +251,11 @@ func (a *App) Menu() *Menu {
return a.views["menu"].(*Menu) return a.views["menu"].(*Menu)
} }
// Flash returns a flash model.
func (a *App) Flash() *model.Flash {
return a.flash
}
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
// Helpers... // Helpers...

View File

@ -59,7 +59,7 @@ func TestAppViews(t *testing.T) {
a := ui.NewApp("") a := ui.NewApp("")
a.Init() a.Init()
vv := []string{"crumbs", "logo", "cmd", "flash", "menu"} vv := []string{"crumbs", "logo", "cmd", "menu"}
for i := range vv { for i := range vv {
v := vv[i] v := vv[i]
t.Run(v, func(t *testing.T) { t.Run(v, func(t *testing.T) {
@ -68,7 +68,6 @@ func TestAppViews(t *testing.T) {
} }
assert.NotNil(t, a.Crumbs()) assert.NotNil(t, a.Crumbs())
assert.NotNil(t, a.Flash())
assert.NotNil(t, a.Logo()) assert.NotNil(t, a.Logo())
assert.NotNil(t, a.Cmd()) assert.NotNil(t, a.Cmd())
assert.NotNil(t, a.Menu()) assert.NotNil(t, a.Menu())

View File

@ -79,6 +79,8 @@ func (c *Configurator) RefreshStyles(context string) {
clusterSkins := filepath.Join(config.K9sHome, fmt.Sprintf("%s_skin.yml", context)) clusterSkins := filepath.Join(config.K9sHome, fmt.Sprintf("%s_skin.yml", context))
if c.Styles == nil { if c.Styles == nil {
c.Styles = config.NewStyles() c.Styles = config.NewStyles()
} else {
c.Styles.Reset()
} }
if err := c.Styles.Load(clusterSkins); err != nil { if err := c.Styles.Load(clusterSkins); err != nil {
log.Info().Msgf("No context specific skin file found -- %s", clusterSkins) log.Info().Msgf("No context specific skin file found -- %s", clusterSkins)
@ -102,10 +104,10 @@ func (c *Configurator) updateStyles(f string) {
} }
c.Styles.Update() c.Styles.Update()
render.StdColor = config.AsColor(c.Styles.Frame().Status.NewColor) render.StdColor = c.Styles.Frame().Status.NewColor.Color()
render.AddColor = config.AsColor(c.Styles.Frame().Status.AddColor) render.AddColor = c.Styles.Frame().Status.AddColor.Color()
render.ModColor = config.AsColor(c.Styles.Frame().Status.ModifyColor) render.ModColor = c.Styles.Frame().Status.ModifyColor.Color()
render.ErrColor = config.AsColor(c.Styles.Frame().Status.ErrorColor) render.ErrColor = c.Styles.Frame().Status.ErrorColor.Color()
render.HighlightColor = config.AsColor(c.Styles.Frame().Status.HighlightColor) render.HighlightColor = c.Styles.Frame().Status.HighlightColor.Color()
render.CompletedColor = config.AsColor(c.Styles.Frame().Status.CompletedColor) render.CompletedColor = c.Styles.Frame().Status.CompletedColor.Color()
} }

View File

@ -2,67 +2,45 @@ package ui
import ( import (
"context" "context"
"fmt"
"strings"
"time"
"github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/config"
"github.com/derailed/k9s/internal/render" "github.com/derailed/k9s/internal/model"
"github.com/derailed/tview" "github.com/derailed/tview"
"github.com/gdamore/tcell" "github.com/gdamore/tcell"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
) )
const ( const (
// FlashInfo represents an info message. emoHappy = "😎"
FlashInfo FlashLevel = iota
// FlashWarn represents an warning message.
FlashWarn
// FlashErr represents an error message.
FlashErr
// FlashFatal represents an fatal message.
FlashFatal
flashDelay = 3 * time.Second
emoDoh = "😗" emoDoh = "😗"
emoRed = "😡" emoRed = "😡"
emoDead = "💀"
emoHappy = "😎"
) )
type ( // Flash represents a flash message indicator.
// FlashLevel represents flash message severity. type Flash struct {
FlashLevel int *tview.TextView
// Flash represents a flash message indicator. app *App
Flash struct { testMode bool
*tview.TextView }
cancel context.CancelFunc
app *App
flushNow bool
}
)
// NewFlash returns a new flash view. // NewFlash returns a new flash view.
func NewFlash(app *App, m string) *Flash { func NewFlash(app *App) *Flash {
f := Flash{ f := Flash{
app: app, app: app,
TextView: tview.NewTextView(), TextView: tview.NewTextView(),
} }
f.SetTextColor(tcell.ColorAqua) f.SetTextColor(tcell.ColorAqua)
f.SetTextAlign(tview.AlignLeft) f.SetTextAlign(tview.AlignCenter)
f.SetBorderPadding(0, 0, 1, 1) f.SetBorderPadding(0, 0, 1, 1)
f.SetText(m)
f.app.Styles.AddListener(&f) f.app.Styles.AddListener(&f)
return &f return &f
} }
// TestMode for testing... // SetTestMode for testing ONLY!
func (f *Flash) TestMode() { func (f *Flash) SetTestMode(b bool) {
f.flushNow = true f.testMode = b
} }
// StylesChanged notifies listener the skin changed. // StylesChanged notifies listener the skin changed.
@ -71,101 +49,54 @@ func (f *Flash) StylesChanged(s *config.Styles) {
f.SetTextColor(s.FgColor()) f.SetTextColor(s.FgColor())
} }
// Info displays an info flash message. // Watch watches for flash changes.
func (f *Flash) Info(msg string) { func (f *Flash) Watch(ctx context.Context, c model.FlashChan) {
log.Info().Msg(msg) defer log.Debug().Msgf("Flash Canceled!")
f.SetMessage(FlashInfo, msg) for {
} select {
case <-ctx.Done():
// Infof displays a formatted info flash message. return
func (f *Flash) Infof(fmat string, args ...interface{}) { case msg := <-c:
f.Info(fmt.Sprintf(fmat, args...)) f.SetMessage(msg)
}
// Warn displays a warning flash message.
func (f *Flash) Warn(msg string) {
log.Warn().Msg(msg)
f.SetMessage(FlashWarn, msg)
}
// Warnf displays a formatted warning flash message.
func (f *Flash) Warnf(fmat string, args ...interface{}) {
f.Warn(fmt.Sprintf(fmat, args...))
}
// Err displays an error flash message.
func (f *Flash) Err(err error) {
log.Error().Msg(err.Error())
f.SetMessage(FlashErr, err.Error())
}
// Errf displays a formatted error flash message.
func (f *Flash) Errf(fmat string, args ...interface{}) {
var err error
for _, a := range args {
switch e := a.(type) {
case error:
err = e
} }
} }
log.Error().Err(err).Msgf(fmat, args...)
f.SetMessage(FlashErr, fmt.Sprintf(fmat, args...))
} }
// SetMessage sets flash message and level. // SetMessage sets flash message and level.
func (f *Flash) SetMessage(level FlashLevel, msg ...string) { func (f *Flash) SetMessage(m model.LevelMessage) {
if f.cancel != nil { fn := func() {
f.cancel() if m.Text == "" {
f.Clear()
return
}
f.SetTextColor(flashColor(m.Level))
f.SetText(flashEmoji(m.Level) + " " + m.Text)
} }
_, _, width, _ := f.GetRect() if f.testMode {
if width <= 15 { fn()
width = 100
}
m := strings.Join(msg, " ")
if f.flushNow {
f.SetTextColor(flashColor(level))
f.SetText(render.Truncate(flashEmoji(level)+" "+m, width-3))
} else { } else {
f.app.QueueUpdateDraw(func() { f.app.QueueUpdateDraw(fn)
f.SetTextColor(flashColor(level))
f.SetText(render.Truncate(flashEmoji(level)+" "+m, width-3))
})
} }
var ctx context.Context
ctx, f.cancel = context.WithTimeout(context.Background(), flashDelay)
go f.refresh(ctx)
} }
func (f *Flash) refresh(ctx context.Context) { func flashEmoji(l model.FlashLevel) string {
<-ctx.Done()
f.app.QueueUpdateDraw(func() {
f.Clear()
})
}
func flashEmoji(l FlashLevel) string {
switch l { switch l {
case FlashWarn: case model.FlashWarn:
return emoDoh return emoDoh
case FlashErr: case model.FlashErr:
return emoRed return emoRed
case FlashFatal:
return emoDead
default: default:
return emoHappy return emoHappy
} }
} }
func flashColor(l FlashLevel) tcell.Color { func flashColor(l model.FlashLevel) tcell.Color {
switch l { switch l {
case FlashWarn: case model.FlashWarn:
return tcell.ColorOrange return tcell.ColorOrange
case FlashErr: case model.FlashErr:
return tcell.ColorOrangeRed return tcell.ColorOrangeRed
case FlashFatal:
return tcell.ColorFuchsia
default: default:
return tcell.ColorNavajoWhite return tcell.ColorNavajoWhite
} }

View File

@ -1,45 +1,40 @@
package ui_test package ui_test
import ( import (
"errors" "context"
"testing" "testing"
"time"
"github.com/derailed/k9s/internal/model"
"github.com/derailed/k9s/internal/ui" "github.com/derailed/k9s/internal/ui"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
func TestFlashInfo(t *testing.T) { func TestFlash(t *testing.T) {
f := newFlash() const delay = 1 * time.Millisecond
f.Info("Blee") uu := map[string]struct {
l model.FlashLevel
i, e string
}{
"info": {l: model.FlashInfo, i: "hello", e: "😎 hello\n"},
"warn": {l: model.FlashWarn, i: "hello", e: "😗 hello\n"},
"err": {l: model.FlashErr, i: "hello", e: "😡 hello\n"},
}
assert.Equal(t, "😎 Blee\n", f.GetText(false)) ctx, cancel := context.WithCancel(context.Background())
f.Infof("Blee %s", "duh") defer cancel()
assert.Equal(t, "😎 Blee duh\n", f.GetText(false))
} a := ui.NewApp("test")
f := ui.NewFlash(a)
func TestFlashWarn(t *testing.T) { f.SetTestMode(true)
f := newFlash() go f.Watch(ctx, a.Flash().Channel())
f.Warn("Blee")
for k := range uu {
assert.Equal(t, "😗 Blee\n", f.GetText(false)) u := uu[k]
f.Warnf("Blee %s", "duh") t.Run(k, func(t *testing.T) {
assert.Equal(t, "😗 Blee duh\n", f.GetText(false)) a.Flash().SetMessage(u.l, u.i)
} time.Sleep(delay)
assert.Equal(t, u.e, f.GetText(false))
func TestFlashErr(t *testing.T) { })
f := newFlash() }
f.Err(errors.New("Blee"))
assert.Equal(t, "😡 Blee\n", f.GetText(false))
f.Errf("Blee %s", "duh")
assert.Equal(t, "😡 Blee duh\n", f.GetText(false))
}
// ----------------------------------------------------------------------------
// Helpers...
func newFlash() *ui.Flash {
f := ui.NewFlash(ui.NewApp(""), "YO!")
f.TestMode()
return f
} }

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