diff --git a/.gitignore b/.gitignore index 2ac64c32..07fa4689 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ pod1.go .project faas .settings/* +demos diff --git a/.goreleaser.yml b/.goreleaser.yml index 2643bb05..f5511c2c 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -16,8 +16,8 @@ builds: - 386 - amd64 - arm64 + - arm goarm: - - 6 - 7 ldflags: - -s -w -X github.com/derailed/k9s/cmd.version={{.Version}} -X github.com/derailed/k9s/cmd.commit={{.Commit}} -X github.com/derailed/k9s/cmd.date={{.Date}} diff --git a/README.md b/README.md index 9b88228d..47710d12 100644 --- a/README.md +++ b/README.md @@ -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.13.0](https://www.youtube.com/watch?v=qaeR2iK7U0o&t=15s) * [K9s v0.9.0](https://www.youtube.com/watch?v=bxKfqumjW4I) @@ -141,7 +142,7 @@ K9s uses aliases to navigate most K8s resources. | `:`ns`` | To view and switch to another Kubernetes namespace | `:`+`ns`+`` | | `:screendump`, `:sd` | To view all saved resources | | | `Ctrl-d` | To delete a resource (TAB and ENTER to confirm) | | -| `Ctrl-k` | To delete a resource (no confirmation dialog) | | +| `Ctrl-k` | To kill a resource (no confirmation dialog!) | | | `:q`, `Ctrl-c` | To bail out of K9s | | --- @@ -505,17 +506,17 @@ k9s: highlightColor: skyblue counterColor: slateblue filterColor: slategray - # TableView attributes. - table: - fgColor: blue - bgColor: darkblue - cursorColor: aqua - # Header row styles. - header: - fgColor: white - bgColor: darkblue - sorterColor: orange views: + # TableView attributes. + table: + fgColor: blue + bgColor: darkblue + cursorColor: aqua + # Header row styles. + header: + fgColor: white + bgColor: darkblue + sorterColor: orange # YAML info styles. yaml: keyColor: steelblue diff --git a/assets/k9s_doc.png b/assets/k9s_doc.png new file mode 100644 index 00000000..8627cb21 Binary files /dev/null and b/assets/k9s_doc.png differ diff --git a/assets/k9s_health.png b/assets/k9s_health.png new file mode 100644 index 00000000..e653dcfd Binary files /dev/null and b/assets/k9s_health.png differ diff --git a/change_logs/release_v0.16.0.md b/change_logs/release_v0.16.0.md new file mode 100644 index 00000000..53c52754 --- /dev/null +++ b/change_logs/release_v0.16.0.md @@ -0,0 +1,185 @@ + + +# Release v0.16.0 + +## Notes + +Thank you to all that contributed with flushing out issues and enhancements for K9s! I'll try to mark some of these issues as fixed. But if you don't mind grab the latest rev and see if we're happier with some of the fixes! If you've filed an issue please help me verify and close. Your support, kindness and awesome suggestions to make K9s better is as ever very much noticed and appreciated! + +Also if you dig this tool, please make some noise on social! [@kitesurfer](https://twitter.com/kitesurfer) + +On Slack? Please join us [K9slackers](https://join.slack.com/t/k9sers/shared_invite/enQtOTA5MDEyNzI5MTU0LWQ1ZGI3MzliYzZhZWEyNzYxYzA3NjE0YTk1YmFmNzViZjIyNzhkZGI0MmJjYzhlNjdlMGJhYzE2ZGU1NjkyNTM) + +--- + + + +This is one of these drops that may make you wonder if you'll go from zero to hero or likely the reverse?? Will see how this goes... Please proceed with caution on this one as there could very well be much distrubances in the force... + +Lots of code churns so could have totally hose some stuff, but like my GranPappy used to say `can't cook without making a mess!` + +## Going Wide? + +In this drop, we've enabled a new shortcut namely `wide` as `Ctrl-w`. On table views, you will be able to see more information about the resources such as labels or others depending on the viewed resource. This mnemonic works as a toggle so you can `narrow` the view by hitting it again. + +## Zoom, Zoom, Zoom! + +While viewing some resources that may contain errors, sorting on columns may not achieve the results you're seeking ie `show me all resources in an error state`. We've added a new option to achieve just that aka `zoom errors` as `ctrl-z`. This works as a toggle and will unveil resources that are need of some TLC on your part ;) + +## Does Your Cluster Have A Pulse 💓? + +In this drop, we're introducing a brand new view aka `K9s Pulses` 💓. This is a summary view listing the most sailient resources in your clusters and their current states. This view tracks two main metrics ie Ok and Toast on a 5sec beat. This view affords cluster activity and failure rates. BTW this is the zero to hero deal 🙀 Hopefully you'll dig it as this was much work to put together and I personally think it's the `ducks nuts`... If you like, please give me some luving on social or via GH sponsors as batteries are running low... + +To active, enter command mode by typing in `:pulse` aliases are `pu`, `pulses` or `hz` +To navigate thru the various pulses, you can use `tab`/`backtab` or use the menu index (just like namespaces selectors). Once on a pulse view, you can press `enter` to see the associated resource table view. Pressing `esc` will nav you back. + +As I've may have mentioned before, my front-end/UX FU is weak, so I've also added a way for you to skin the charts via skins yaml to your own liking. Please see the skin section below for an example on how to skin the pulses dials. BONUS you should be able to skin K9s live! How cool is that 😻? + +NOTE: Pulses are very much experimental and could totally bomb on your clusters! So please thread carefully and please do report (kindly!) back. + +## BReaking Bad! + +In this drop I've broken a few things (that I know off...), here is the list as I can recall... + +1. Toggle header aka `my red headed step child`. Key moved (again!) now `Ctrl-e` +2. Skin yaml layout CHANGED! Moved table and xray sections under views and added charts section. + +## Skins Updates! + +The skin file format CHANGE! If you are running skins with K9s, please make sure to update your skin file. If not K9s could bomb coming up! + +NOTE: I don't think I'll get around to update all the contributed skins in this repo `skins` dir. If you're looking for a way to help out and are UI inclined, please take a peek and make them cool! + +```yaml +# my_cluster_skin.yml +# Styles... +foreground: &foreground "#f8f8f2" +background: &background "#282a36" +current_line: ¤t_line "#44475a" +selection: &selection "#44475a" +comment: &comment "#6272a4" +cyan: &cyan "#8be9fd" +green: &green "#50fa7b" +orange: &orange "#ffb86c" +pink: &pink "#ff79c6" +purple: &purple "#bd93f9" +red: &red "#ff5555" +yellow: &yellow "#f1fa8c" + +# Skin... +k9s: + # General K9s styles + body: + fgColor: *foreground + bgColor: *background + logoColor: *purple + # ClusterInfoView styles. + info: + fgColor: *pink + sectionColor: *foreground + frame: + # Borders styles. + border: + fgColor: *selection + focusColor: *current_line + menu: + fgColor: *foreground + keyColor: *pink + # Used for favorite namespaces + numKeyColor: *purple + # CrumbView attributes for history navigation. + crumbs: + fgColor: *foreground + bgColor: *current_line + activeColor: *current_line + # Resource status and update styles + status: + newColor: *cyan + modifyColor: *purple + addColor: *green + errorColor: *red + highlightcolor: *orange + killColor: *comment + completedColor: *comment + # Border title styles. + title: + fgColor: *foreground + bgColor: *current_line + highlightColor: *orange + counterColor: *purple + filterColor: *pink + views: + charts: + bgColor: *background + dialBgColor: "#0A2239" + chartBgColor: "#0A2239" + defaultDialColors: + - "#1E3888" + - "#820101" + defaultChartColors: + - "#1E3888" + - "#820101" + resourceColors: + batch/v1/jobs: + - "#5D737E" + - "#820101" + v1/persistentvolumes: + - "#3E554A" + - "#820101" + cpu: + - "#6EA4BF" + - "#820101" + mem: + - "#17505B" + - "#820101" + v1/events: + - "#073B3A" + - "#820101" + v1/pods: + - "#487FFF" + - "#820101" + # TableView attributes. + table: + fgColor: *foreground + bgColor: *background + cursorColor: *current_line + # Header row styles. + header: + fgColor: *foreground + bgColor: *background + sorterColor: *cyan + # Xray view attributes. + xray: + fgColor: *foreground + bgColor: *background + cursorColor: *current_line + graphicColor: *purple + showIcons: true + # YAML info styles. + yaml: + keyColor: *pink + colonColor: *purple + valueColor: *foreground + # Logs styles. + logs: + fgColor: *foreground + bgColor: *background +``` + +## Resolved Bugs/Features/PRs + +- [Issue #557](https://github.com/derailed/k9s/issues/557) +- [Issue #555](https://github.com/derailed/k9s/issues/555) +- [Issue #554](https://github.com/derailed/k9s/issues/554) +- [Issue #553](https://github.com/derailed/k9s/issues/553) +- [Issue #552](https://github.com/derailed/k9s/issues/552) +- [Issue #551](https://github.com/derailed/k9s/issues/551) +- [Issue #549](https://github.com/derailed/k9s/issues/549) A start with pulses... +- [Issue #540](https://github.com/derailed/k9s/issues/540) +- [Issue #421](https://github.com/derailed/k9s/issues/421) +- [Issue #351](https://github.com/derailed/k9s/issues/351) Solved by Pulses? +- [Issue #25](https://github.com/derailed/k9s/issues/25) Pulses? Oldie but goodie! + +--- + + © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) diff --git a/change_logs/release_v0.16.1.md b/change_logs/release_v0.16.1.md new file mode 100644 index 00000000..29cc927f --- /dev/null +++ b/change_logs/release_v0.16.1.md @@ -0,0 +1,23 @@ + + +# Release v0.16.1 + +## Notes + +Thank you to all that contributed with flushing out issues and enhancements for K9s! I'll try to mark some of these issues as fixed. But if you don't mind grab the latest rev and see if we're happier with some of the fixes! If you've filed an issue please help me verify and close. Your support, kindness and awesome suggestions to make K9s better is as ever very much noticed and appreciated! + +Also if you dig this tool, please make some noise on social! [@kitesurfer](https://twitter.com/kitesurfer) + +On Slack? Please join us [K9slackers](https://join.slack.com/t/k9sers/shared_invite/enQtOTA5MDEyNzI5MTU0LWQ1ZGI3MzliYzZhZWEyNzYxYzA3NjE0YTk1YmFmNzViZjIyNzhkZGI0MmJjYzhlNjdlMGJhYzE2ZGU1NjkyNTM) + +--- + +Maintenance Release! + +## Resolved Bugs/Features/PRs + +- [Issue #561](https://github.com/derailed/k9s/issues/561) + +--- + + © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) diff --git a/go.mod b/go.mod index d6e03b95..4daf737e 100644 --- a/go.mod +++ b/go.mod @@ -34,7 +34,7 @@ require ( github.com/alexellis/go-execute v0.0.0-20200124154445-8697e4e28c5e // indirect github.com/alexellis/hmac v0.0.0-20180624211220-5c52ab81c0de // indirect github.com/atotto/clipboard v0.1.2 - github.com/derailed/tview v0.3.5 + github.com/derailed/tview v0.3.6 github.com/drone/envsubst v1.0.2 // indirect github.com/elazarl/goproxy v0.0.0-20190421051319-9d40249d3c2f // indirect github.com/elazarl/goproxy/ext v0.0.0-20190421051319-9d40249d3c2f // indirect @@ -43,14 +43,14 @@ require ( github.com/gdamore/tcell v1.3.0 github.com/ghodss/yaml v1.0.0 github.com/gregjones/httpcache v0.0.0-20190212212710-3befbb6ad0cc // indirect - github.com/kylelemons/godebug v1.1.0 // indirect github.com/mattn/go-runewidth v0.0.5 github.com/openfaas/faas v0.0.0-20200207215241-6afae214e3ec github.com/openfaas/faas-cli v0.0.0-20200124160744-30b7cec9634c github.com/openfaas/faas-provider v0.15.0 github.com/petergtz/pegomock v2.6.0+incompatible github.com/rakyll/hey v0.1.2 - github.com/rs/zerolog v1.17.2 + github.com/rivo/tview v0.0.0-20191018115645-bacbf5155bc1 + github.com/rs/zerolog v1.18.0 github.com/ryanuber/go-glob v1.0.0 // indirect github.com/sahilm/fuzzy v0.1.0 github.com/spf13/cobra v0.0.5 diff --git a/go.sum b/go.sum index c28d347a..ad8eaeee 100644 --- a/go.sum +++ b/go.sum @@ -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.5 h1:1vKqcJIiZtLAs5moX9c38+BbBSYhPgFq0ZndnVNVNFc= github.com/derailed/tview v0.3.5/go.mod h1:yApPszFU62FoaGkf7swy2nIdV/h7Nid3dhMSVy6+OFI= +github.com/derailed/tview v0.3.6 h1:9PyX6Nu1vs9mCVfvV2q2fwT/dZta0dBGr4ZPjCF1KnU= +github.com/derailed/tview v0.3.6/go.mod h1:GJ3k/TIzEE+sj1L09/usk6HrkjsdadSsb03eHgPbcII= github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM= github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= github.com/dnaeon/go-vcr v1.0.1/go.mod h1:aBB1+wY4s93YsC3HHjMBMrwTj2R9FHDzUr9KyGc8n1E= @@ -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/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/mrunalp/fileutils v0.0.0-20160930181131-4ee1cc9a8058/go.mod h1:x8F1gnqOkIEiO4rqoeEEEqQbo7HjGMTvyoq3gej4iT0= +github.com/mum4k/termdash v0.10.0/go.mod h1:l3tO+lJi9LZqXRq7cu7h5/8rDIK3AzelSuq2v/KncxI= github.com/munnerz/goautoneg v0.0.0-20120707110453-a547fc61f48d h1:7PxY7LVfSZm7PEeBTyK1rj1gABdCO2mbri6GKO1cMDs= github.com/munnerz/goautoneg v0.0.0-20120707110453-a547fc61f48d/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/mvdan/xurls v1.1.0/go.mod h1:tQlNn3BED8bE/15hnSL2HLkDeLWpNPAwtw7wkEq44oU= @@ -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/go.mod h1:S5M+++KwbmxA7w68S92B5NdWiCB+cIhITaMUkq9W608= github.com/remyoudompheng/bigfft v0.0.0-20170806203942-52369c62f446/go.mod h1:uYEyJGbgTkfkS4+E/PavXkNJcbFIpEtjt2B0KDQ5+9M= +github.com/rivo/tview v0.0.0-20191018115645-bacbf5155bc1 h1:s9Lw4phBWkuQJUd+msaBMxP3utLvrFaBQV9jNgG55r0= github.com/rivo/tview v0.0.0-20191018115645-bacbf5155bc1/go.mod h1:+rKjP5+h9HMwWRpAfhIkkQ9KE3m3Nz5rwn7YtUpwgqk= github.com/rivo/uniseg v0.0.0-20190513083848-b9f5b9457d44/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.1.0 h1:+2KBaVoUmb9XzDsrx/Ct0W/EYOSFf/nWTauy++DprtY= @@ -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/zerolog v1.17.2 h1:RMRHFw2+wF7LO0QqtELQwo8hqSmqISyCJeFeAAuWcRo= github.com/rs/zerolog v1.17.2/go.mod h1:9nvC1axdVrAHcu/s9taAVfBuIdTZLVQmKQyvrUjF5+I= +github.com/rs/zerolog v1.18.0 h1:CbAm3kP2Tptby1i9sYy2MGRg0uxIN9cyDb59Ys7W8z8= +github.com/rs/zerolog v1.18.0/go.mod h1:9nvC1axdVrAHcu/s9taAVfBuIdTZLVQmKQyvrUjF5+I= github.com/rubiojr/go-vhd v0.0.0-20160810183302-0bfd3b39853c/go.mod h1:DM5xW0nvfNNm2uytzsvhI3OnX8uzaRAg8UX/CnDqbto= github.com/russross/blackfriday v0.0.0-20170610170232-067529f716f4/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= github.com/russross/blackfriday v1.5.2 h1:HyvC0ARfnZBqnXwABFeSZHpKvJHJJfPz81GNueLj0oo= diff --git a/internal/client/config.go b/internal/client/config.go index 58f9d74d..0be32021 100644 --- a/internal/client/config.go +++ b/internal/client/config.go @@ -3,6 +3,7 @@ package client import ( "errors" "fmt" + "strings" "sync" "github.com/rs/zerolog/log" @@ -177,13 +178,31 @@ func (c *Config) ClusterNames() ([]string, error) { // CurrentGroupNames retrieves the active group names. func (c *Config) CurrentGroupNames() ([]string, error) { - if c.flags.ImpersonateGroup != nil && len(*c.flags.ImpersonateGroup) != 0 { + if areSet(c.flags.ImpersonateGroup) { return *c.flags.ImpersonateGroup, nil } return []string{}, errors.New("unable to locate current group") } +// ImpersonateGroups retrieves the active groupsif set on the CLI. +func (c *Config) ImpersonateGroups() (string, error) { + if areSet(c.flags.ImpersonateGroup) { + return strings.Join(*c.flags.ImpersonateGroup, ","), nil + } + + return "", errors.New("no groups set") +} + +// ImpersonateUser retrieves the active user name if set on the CLI. +func (c *Config) ImpersonateUser() (string, error) { + if isSet(c.flags.Impersonate) { + return *c.flags.Impersonate, nil + } + + return "", errors.New("no user set") +} + // CurrentUserName retrieves the active user name. func (c *Config) CurrentUserName() (string, error) { if isSet(c.flags.Impersonate) { @@ -311,3 +330,7 @@ func (c *Config) ensureConfig() { func isSet(s *string) bool { return s != nil && len(*s) != 0 } + +func areSet(s *[]string) bool { + return s != nil && len(*s) != 0 +} diff --git a/internal/client/metrics.go b/internal/client/metrics.go index a966ec24..7f02787e 100644 --- a/internal/client/metrics.go +++ b/internal/client/metrics.go @@ -3,20 +3,49 @@ package client import ( "fmt" "math" + "time" v1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/cache" mv1beta1 "k8s.io/metrics/pkg/apis/metrics/v1beta1" ) +const ( + mxCacheSize = 100 + mxCacheExpiry = 1 * time.Minute +) + +// MetricsDial tracks global metric server handle. +var MetricsDial *MetricsServer + +// DialMetrics dials the metrics server. +func DialMetrics(c Connection) *MetricsServer { + if MetricsDial == nil { + MetricsDial = NewMetricsServer(c) + } + + return MetricsDial +} + +// ResetMetrics resets the metric server handle. +func ResetMetrics() { + MetricsDial = nil +} + // MetricsServer serves cluster metrics for nodes and pods. type MetricsServer struct { Connection + + cache *cache.LRUExpireCache } // NewMetricsServer return a metric server instance. func NewMetricsServer(c Connection) *MetricsServer { - return &MetricsServer{Connection: c} + return &MetricsServer{ + Connection: c, + cache: cache.NewLRUExpireCache(mxCacheSize), + } } // NodesMetrics retrieves metrics for a given set of nodes. @@ -28,15 +57,15 @@ func (m *MetricsServer) NodesMetrics(nodes *v1.NodeList, metrics *mv1beta1.NodeM for _, no := range nodes.Items { mmx[no.Name] = NodeMetrics{ AvailCPU: no.Status.Allocatable.Cpu().MilliValue(), - AvailMEM: toMB(no.Status.Allocatable.Memory().Value()), + AvailMEM: ToMB(no.Status.Allocatable.Memory().Value()), TotalCPU: no.Status.Capacity.Cpu().MilliValue(), - TotalMEM: toMB(no.Status.Capacity.Memory().Value()), + TotalMEM: ToMB(no.Status.Capacity.Memory().Value()), } } for _, c := range metrics.Items { if mx, ok := mmx[c.Name]; ok { mx.CurrentCPU = c.Usage.Cpu().MilliValue() - mx.CurrentMEM = toMB(c.Usage.Memory().Value()) + mx.CurrentMEM = ToMB(c.Usage.Memory().Value()) mmx[c.Name] = mx } } @@ -51,13 +80,13 @@ func (m *MetricsServer) ClusterLoad(nos *v1.NodeList, nmx *mv1beta1.NodeMetricsL for _, no := range nos.Items { nodeMetrics[no.Name] = NodeMetrics{ AvailCPU: no.Status.Allocatable.Cpu().MilliValue(), - AvailMEM: toMB(no.Status.Allocatable.Memory().Value()), + AvailMEM: ToMB(no.Status.Allocatable.Memory().Value()), } } for _, mx := range nmx.Items { if m, ok := nodeMetrics[mx.Name]; ok { m.CurrentCPU = mx.Usage.Cpu().MilliValue() - m.CurrentMEM = toMB(mx.Usage.Memory().Value()) + m.CurrentMEM = ToMB(mx.Usage.Memory().Value()) nodeMetrics[mx.Name] = m } } @@ -74,86 +103,121 @@ func (m *MetricsServer) ClusterLoad(nos *v1.NodeList, nmx *mv1beta1.NodeMetricsL return nil } -// FetchNodesMetrics return all metrics for pods in a given namespace. -func (m *MetricsServer) FetchNodesMetrics() (*mv1beta1.NodeMetricsList, error) { - var mx mv1beta1.NodeMetricsList +func (m *MetricsServer) checkAccess(ns, gvr, msg string) error { if !m.HasMetrics() { - return &mx, fmt.Errorf("No metrics-server detected on cluster") + return fmt.Errorf("No metrics-server detected on cluster") } - auth, err := m.CanI("", "metrics.k8s.io/v1beta1/nodes", ListAccess) + auth, err := m.CanI(ns, gvr, ListAccess) if err != nil { - return &mx, err + return err } if !auth { - return &mx, fmt.Errorf("user is not authorized to list node metrics") + return fmt.Errorf(msg) + } + return nil +} + +// FetchNodesMetrics return all metrics for nodes. +func (m *MetricsServer) FetchNodesMetrics() (*mv1beta1.NodeMetricsList, error) { + const msg = "user is not authorized to list node metrics" + + mx := new(mv1beta1.NodeMetricsList) + if err := m.checkAccess("", "metrics.k8s.io/v1beta1/nodes", msg); err != nil { + return mx, err + } + + const key = "nodes" + if entry, ok := m.cache.Get(key); ok && entry != nil { + mxList, ok := entry.(*mv1beta1.NodeMetricsList) + if !ok { + return nil, fmt.Errorf("expected nodemetricslist but got %T", entry) + } + return mxList, nil } client, err := m.MXDial() if err != nil { - return &mx, err + return mx, err } - return client.MetricsV1beta1().NodeMetricses().List(metav1.ListOptions{}) + mxList, err := client.MetricsV1beta1().NodeMetricses().List(metav1.ListOptions{}) + if err != nil { + return mx, err + } + m.cache.Add(key, mxList, mxCacheExpiry) + + return mxList, nil } // FetchPodsMetrics return all metrics for pods in a given namespace. func (m *MetricsServer) FetchPodsMetrics(ns string) (*mv1beta1.PodMetricsList, error) { - var mx mv1beta1.PodMetricsList - if m.Connection == nil { - return &mx, fmt.Errorf("no client connection") - } + mx := new(mv1beta1.PodMetricsList) + const msg = "user is not authorized to list pods metrics" - if !m.HasMetrics() { - return &mx, fmt.Errorf("No metrics-server detected on cluster") - } if ns == NamespaceAll { ns = AllNamespaces } - - auth, err := m.CanI(ns, "metrics.k8s.io/v1beta1/pods", ListAccess) - if err != nil { - return &mx, err + if err := m.checkAccess(ns, "metrics.k8s.io/v1beta1/pods", msg); err != nil { + return mx, err } - if !auth { - return &mx, fmt.Errorf("user is not authorized to list pods metrics") + + key := FQN(ns, "pods") + if entry, ok := m.cache.Get(key); ok { + mxList, ok := entry.(*mv1beta1.PodMetricsList) + if !ok { + return mx, fmt.Errorf("expected podmetricslist but got %T", entry) + } + return mxList, nil } client, err := m.MXDial() if err != nil { - return &mx, err + return mx, err } + mxList, err := client.MetricsV1beta1().PodMetricses(ns).List(metav1.ListOptions{}) + if err != nil { + return mx, err + } + m.cache.Add(key, mxList, mxCacheExpiry) - return client.MetricsV1beta1().PodMetricses(ns).List(metav1.ListOptions{}) + return mxList, err } // FetchPodMetrics return all metrics for pods in a given namespace. func (m *MetricsServer) FetchPodMetrics(fqn string) (*mv1beta1.PodMetrics, error) { - var mx mv1beta1.PodMetrics - if m.Connection == nil { - return &mx, fmt.Errorf("no client connection") - } - if !m.HasMetrics() { - return &mx, fmt.Errorf("No metrics-server detected on cluster") - } + var mx *mv1beta1.PodMetrics + const msg = "user is not authorized to list pod metrics" ns, n := Namespaced(fqn) if ns == NamespaceAll { ns = AllNamespaces } - auth, err := m.CanI(ns, "metrics.k8s.io/v1beta1/pods", GetAccess) - if err != nil { - return &mx, err + if err := m.checkAccess(ns, "metrics.k8s.io/v1beta1/pods", msg); err != nil { + return mx, err } - if !auth { - return &mx, fmt.Errorf("user is not authorized to list pod metrics") + + var key = FQN(ns, "pods") + if entry, ok := m.cache.Get(key); ok { + if list, ok := entry.(*mv1beta1.PodMetricsList); ok && list != nil { + for _, m := range list.Items { + if FQN(m.Namespace, m.Name) == fqn { + return &m, nil + } + } + } } client, err := m.MXDial() if err != nil { - return &mx, err + return mx, err } + mx, err = client.MetricsV1beta1().PodMetricses(ns).Get(n, metav1.GetOptions{}) + if err != nil { + return mx, err + } + m.cache.Add(key, mx, mxCacheExpiry) - return client.MetricsV1beta1().PodMetricses(ns).Get(n, metav1.GetOptions{}) + return mx, nil } // PodsMetrics retrieves metrics for all pods in a given namespace. @@ -167,7 +231,7 @@ func (m *MetricsServer) PodsMetrics(pods *mv1beta1.PodMetricsList, mmx PodsMetri var mx PodMetrics for _, c := range p.Containers { mx.CurrentCPU += c.Usage.Cpu().MilliValue() - mx.CurrentMEM += toMB(c.Usage.Memory().Value()) + mx.CurrentMEM += ToMB(c.Usage.Memory().Value()) } mmx[p.Namespace+"/"+p.Name] = mx } @@ -178,8 +242,8 @@ func (m *MetricsServer) PodsMetrics(pods *mv1beta1.PodMetricsList, mmx PodsMetri const megaByte = 1024 * 1024 -// toMB converts bytes to megabytes. -func toMB(v int64) float64 { +// ToMB converts bytes to megabytes. +func ToMB(v int64) float64 { return float64(v) / megaByte } diff --git a/internal/client/types.go b/internal/client/types.go index 48fe4146..48286663 100644 --- a/internal/client/types.go +++ b/internal/client/types.go @@ -22,6 +22,9 @@ const ( // ClusterScope designates a resource is not namespaced. ClusterScope = "-" + + // NotNamespaced designates a non resource namespace. + NotNamespaced = "*" ) const ( diff --git a/internal/config/alias.go b/internal/config/alias.go index 622f94e3..f1f9477d 100644 --- a/internal/config/alias.go +++ b/internal/config/alias.go @@ -2,6 +2,7 @@ package config import ( "io/ioutil" + "os" "path/filepath" "sync" @@ -87,6 +88,13 @@ func (a *Aliases) loadDefaults() { // Load K9s aliases. func (a *Aliases) Load() error { a.loadDefaults() + + _, err := os.Stat(K9sAlias) + if os.IsNotExist(err) { + log.Debug().Err(err).Msgf("No custom aliases found") + return nil + } + return a.LoadAliases(K9sAlias) } @@ -139,8 +147,14 @@ func (a *Aliases) Define(gvr string, aliases ...string) { } } -// LoadAliases loads alias from a given file. -func (a *Aliases) LoadAliases(path string) error { +// Load K9s aliases. +func (a *Aliases) Load() error { + a.loadDefaultAliases() + return a.LoadFileAliases(K9sAlias) +} + +// LoadFileAliases loads alias from a given file. +func (a *Aliases) LoadFileAliases(path string) error { f, err := ioutil.ReadFile(path) if err != nil { log.Debug().Err(err).Msgf("No custom aliases found") @@ -161,6 +175,63 @@ func (a *Aliases) LoadAliases(path string) error { return nil } +func (a *Aliases) loadDefaultAliases() { + a.mx.Lock() + defer a.mx.Unlock() + + a.Alias["dp"] = "apps/v1/deployments" + a.Alias["sec"] = "v1/secrets" + a.Alias["jo"] = "batch/v1/jobs" + a.Alias["cr"] = "rbac.authorization.k8s.io/v1/clusterroles" + a.Alias["crb"] = "rbac.authorization.k8s.io/v1/clusterrolebindings" + a.Alias["ro"] = "rbac.authorization.k8s.io/v1/roles" + a.Alias["rb"] = "rbac.authorization.k8s.io/v1/rolebindings" + a.Alias["np"] = "networking.k8s.io/v1/networkpolicies" + + const contexts = "contexts" + { + a.Alias["ctx"] = contexts + a.Alias[contexts] = contexts + a.Alias["context"] = contexts + } + const users = "users" + { + a.Alias["usr"] = users + a.Alias[users] = users + a.Alias["user"] = users + } + const groups = "groups" + { + a.Alias["grp"] = groups + a.Alias["group"] = groups + a.Alias[groups] = groups + } + const portFwds = "portforwards" + { + a.Alias["pf"] = portFwds + a.Alias[portFwds] = portFwds + a.Alias["portforward"] = portFwds + } + const benchmarks = "benchmarks" + { + a.Alias["be"] = benchmarks + a.Alias["benchmark"] = benchmarks + a.Alias[benchmarks] = benchmarks + } + const dumps = "screendumps" + { + a.Alias["sd"] = dumps + a.Alias["screendump"] = dumps + a.Alias[dumps] = dumps + } + const pulses = "pulses" + { + a.Alias["hz"] = pulses + a.Alias["pu"] = pulses + a.Alias["pulse"] = pulses + } +} + // Save alias to disk. func (a *Aliases) Save() error { log.Debug().Msg("[Config] Saving Aliases...") diff --git a/internal/config/alias_test.go b/internal/config/alias_test.go index 49e3c445..a627ddd2 100644 --- a/internal/config/alias_test.go +++ b/internal/config/alias_test.go @@ -72,7 +72,7 @@ func TestAliasDefine(t *testing.T) { func TestAliasesLoad(t *testing.T) { a := config.NewAliases() - assert.Nil(t, a.LoadAliases("testdata/alias.yml")) + assert.Nil(t, a.LoadFileAliases("testdata/alias.yml")) assert.Equal(t, 2, len(a.Alias)) } @@ -82,6 +82,6 @@ func TestAliasesSave(t *testing.T) { a.Alias["blee"] = "duh" assert.Nil(t, a.SaveAliases("/tmp/a.yml")) - assert.Nil(t, a.LoadAliases("/tmp/a.yml")) + assert.Nil(t, a.LoadFileAliases("/tmp/a.yml")) assert.Equal(t, 2, len(a.Alias)) } diff --git a/internal/config/styles.go b/internal/config/styles.go index 97fe0dbd..cc56482f 100644 --- a/internal/config/styles.go +++ b/internal/config/styles.go @@ -21,17 +21,31 @@ type StyleListener interface { } type ( + // Color represents a color. + Color string + + // Colors tracks multiple colors. + Colors []Color + // Styles tracks K9s styling options. Styles struct { K9s Style `yaml:"k9s"` listeners []StyleListener } + // Style tracks K9s styles. + Style struct { + Body Body `yaml:"body"` + Frame Frame `yaml:"frame"` + Info Info `yaml:"info"` + Views Views `yaml:"views"` + } + // Body tracks body styles. Body struct { - FgColor string `yaml:"fgColor"` - BgColor string `yaml:"bgColor"` - LogoColor string `yaml:"logoColor"` + FgColor Color `yaml:"fgColor"` + BgColor Color `yaml:"bgColor"` + LogoColor Color `yaml:"logoColor"` } // Frame tracks frame styles. @@ -45,120 +59,171 @@ type ( // Views tracks individual view styles. Views struct { - Yaml Yaml `yaml:"yaml"` - Log Log `yaml:"logs"` + Table Table `yaml:"table"` + Xray Xray `yaml:"xray"` + Charts Charts `yaml:"charts"` + Yaml Yaml `yaml:"yaml"` + Log Log `yaml:"logs"` } // Status tracks resource status styles. Status struct { - NewColor string `yaml:"newColor"` - ModifyColor string `yaml:"modifyColor"` - AddColor string `yaml:"addColor"` - ErrorColor string `yaml:"errorColor"` - HighlightColor string `yaml:"highlightColor"` - KillColor string `yaml:"killColor"` - CompletedColor string `yaml:"completedColor"` + NewColor Color `yaml:"newColor"` + ModifyColor Color `yaml:"modifyColor"` + AddColor Color `yaml:"addColor"` + ErrorColor Color `yaml:"errorColor"` + HighlightColor Color `yaml:"highlightColor"` + KillColor Color `yaml:"killColor"` + CompletedColor Color `yaml:"completedColor"` } // Log tracks Log styles. Log struct { - FgColor string `yaml:"fgColor"` - BgColor string `yaml:"bgColor"` + FgColor Color `yaml:"fgColor"` + BgColor Color `yaml:"bgColor"` } // Yaml tracks yaml styles. Yaml struct { - KeyColor string `yaml:"keyColor"` - ValueColor string `yaml:"valueColor"` - ColonColor string `yaml:"colonColor"` + KeyColor Color `yaml:"keyColor"` + ValueColor Color `yaml:"valueColor"` + ColonColor Color `yaml:"colonColor"` } // Title tracks title styles. Title struct { - FgColor string `yaml:"fgColor"` - BgColor string `yaml:"bgColor"` - HighlightColor string `yaml:"highlightColor"` - CounterColor string `yaml:"counterColor"` - FilterColor string `yaml:"filterColor"` + FgColor Color `yaml:"fgColor"` + BgColor Color `yaml:"bgColor"` + HighlightColor Color `yaml:"highlightColor"` + CounterColor Color `yaml:"counterColor"` + FilterColor Color `yaml:"filterColor"` } // Info tracks info styles. Info struct { - SectionColor string `yaml:"sectionColor"` - FgColor string `yaml:"fgColor"` + SectionColor Color `yaml:"sectionColor"` + FgColor Color `yaml:"fgColor"` } // Border tracks border styles. Border struct { - FgColor string `yaml:"fgColor"` - FocusColor string `yaml:"focusColor"` + FgColor Color `yaml:"fgColor"` + FocusColor Color `yaml:"focusColor"` } // Crumb tracks crumbs styles. Crumb struct { - FgColor string `yaml:"fgColor"` - BgColor string `yaml:"bgColor"` - ActiveColor string `yaml:"activeColor"` + FgColor Color `yaml:"fgColor"` + BgColor Color `yaml:"bgColor"` + ActiveColor Color `yaml:"activeColor"` } // Table tracks table styles. Table struct { - FgColor string `yaml:"fgColor"` - BgColor string `yaml:"bgColor"` - CursorColor string `yaml:"cursorColor"` - MarkColor string `yaml:"markColor"` + FgColor Color `yaml:"fgColor"` + BgColor Color `yaml:"bgColor"` + CursorColor Color `yaml:"cursorColor"` + MarkColor Color `yaml:"markColor"` Header TableHeader `yaml:"header"` } // TableHeader tracks table header styles. TableHeader struct { - FgColor string `yaml:"fgColor"` - BgColor string `yaml:"bgColor"` - SorterColor string `yaml:"sorterColor"` + FgColor Color `yaml:"fgColor"` + BgColor Color `yaml:"bgColor"` + SorterColor Color `yaml:"sorterColor"` } // Xray tracks xray styles. Xray struct { - FgColor string `yaml:"fgColor"` - BgColor string `yaml:"bgColor"` - CursorColor string `yaml:"cursorColor"` - GraphicColor string `yaml:"graphicColor"` - ShowIcons bool `yaml:"showIcons"` + FgColor Color `yaml:"fgColor"` + BgColor Color `yaml:"bgColor"` + CursorColor Color `yaml:"cursorColor"` + GraphicColor Color `yaml:"graphicColor"` + ShowIcons bool `yaml:"showIcons"` } // Menu tracks menu styles. Menu struct { - FgColor string `yaml:"fgColor"` - KeyColor string `yaml:"keyColor"` - NumKeyColor string `yaml:"numKeyColor"` + FgColor Color `yaml:"fgColor"` + KeyColor Color `yaml:"keyColor"` + NumKeyColor Color `yaml:"numKeyColor"` } - // Style tracks K9s styles. - Style struct { - Body Body `yaml:"body"` - Frame Frame `yaml:"frame"` - Info Info `yaml:"info"` - Table Table `yaml:"table"` - Xray Xray `yaml:"xray"` - Views Views `yaml:"views"` + // Charts tracks charts styles. + Charts struct { + BgColor Color `yaml:"bgColor"` + DialBgColor Color `yaml:"dialBgColor"` + ChartBgColor Color `yaml:"chartBgColor"` + DefaultDialColors Colors `yaml:"defaultDialColors"` + DefaultChartColors Colors `yaml:"defaultChartColors"` + ResourceColors map[string]Colors `yaml:"resourceColors"` } ) +const ( + // DefaultColor represents a default color. + DefaultColor Color = "default" + + // TransparentColor represents the terminal bg color. + TransparentColor Color = "-" +) + +// NewColor returns a new color. +func NewColor(c string) Color { + return Color(c) +} + +// String returns color as string. +func (c Color) String() string { + return string(c) +} + +// Color returns a view color. +func (c Color) Color() tcell.Color { + if c == DefaultColor { + return tcell.ColorDefault + } + if color, ok := tcell.ColorNames[c.String()]; ok { + return color + } + return tcell.GetColor(c.String()) +} + +// Colors converts series string colors to colors. +func (c Colors) Colors() []tcell.Color { + cc := make([]tcell.Color, 0, len(c)) + for _, color := range c { + cc = append(cc, color.Color()) + } + return cc +} + func newStyle() Style { return Style{ Body: newBody(), Frame: newFrame(), Info: newInfo(), - Table: newTable(), Views: newViews(), - Xray: newXray(), } } +func newCharts() Charts { + return Charts{ + BgColor: "default", + DialBgColor: "default", + ChartBgColor: "default", + DefaultDialColors: Colors{Color("palegreen"), Color("orangered")}, + DefaultChartColors: Colors{Color("palegreen"), Color("orangered")}, + } +} func newViews() Views { return Views{ - Yaml: newYaml(), - Log: newLog(), + Table: newTable(), + Xray: newXray(), + Charts: newCharts(), + Yaml: newYaml(), + Log: newLog(), } } @@ -188,7 +253,7 @@ func newStatus() Status { ErrorColor: "orangered", HighlightColor: "aqua", KillColor: "mediumpurple", - CompletedColor: "gray", + CompletedColor: "lightgray", } } @@ -292,6 +357,11 @@ func NewStyles() *Styles { } } +// Reset resets styles. +func (s *Styles) Reset() { + s.K9s = newStyle() +} + // DefaultSkin loads the default skin func (s *Styles) DefaultSkin() { s.K9s = newStyle() @@ -299,12 +369,12 @@ func (s *Styles) DefaultSkin() { // FgColor returns the foreground color. func (s *Styles) FgColor() tcell.Color { - return AsColor(s.Body().FgColor) + return s.Body().FgColor.Color() } // BgColor returns the background color. func (s *Styles) BgColor() tcell.Color { - return AsColor(s.Body().BgColor) + return s.Body().BgColor.Color() } // AddListener registers a new listener. @@ -353,14 +423,19 @@ func (s *Styles) Title() Title { return s.Frame().Title } +// Charts returns charts styles. +func (s *Styles) Charts() Charts { + return s.K9s.Views.Charts +} + // Table returns table styles. func (s *Styles) Table() Table { - return s.K9s.Table + return s.K9s.Views.Table } // Xray returns xray styles. func (s *Styles) Xray() Xray { - return s.K9s.Xray + return s.K9s.Views.Xray } // Views returns views styles. @@ -388,19 +463,7 @@ func (s *Styles) Update() { tview.Styles.PrimitiveBackgroundColor = s.BgColor() tview.Styles.ContrastBackgroundColor = s.BgColor() tview.Styles.PrimaryTextColor = s.FgColor() - tview.Styles.BorderColor = AsColor(s.K9s.Frame.Border.FgColor) - tview.Styles.FocusColor = AsColor(s.K9s.Frame.Border.FocusColor) + tview.Styles.BorderColor = s.K9s.Frame.Border.FgColor.Color() + tview.Styles.FocusColor = s.K9s.Frame.Border.FocusColor.Color() 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) -} diff --git a/internal/config/styles_test.go b/internal/config/styles_test.go index bdb0d7ee..a7b9c946 100644 --- a/internal/config/styles_test.go +++ b/internal/config/styles_test.go @@ -9,7 +9,7 @@ import ( "github.com/stretchr/testify/assert" ) -func TestAsColor(t *testing.T) { +func TestColor(t *testing.T) { uu := map[string]tcell.Color{ "blah": tcell.ColorDefault, "blue": tcell.ColorBlue, @@ -20,7 +20,7 @@ func TestAsColor(t *testing.T) { for k := range uu { c, u := k, uu[k] t.Run(k, func(t *testing.T) { - assert.Equal(t, u, config.AsColor(c)) + assert.Equal(t, u, config.NewColor(c).Color()) }) } } @@ -30,9 +30,9 @@ func TestSkinNone(t *testing.T) { assert.Nil(t, s.Load("testdata/empty_skin.yml")) s.Update() - assert.Equal(t, "cadetblue", s.Body().FgColor) - assert.Equal(t, "black", s.Body().BgColor) - assert.Equal(t, "black", s.Table().BgColor) + assert.Equal(t, "cadetblue", s.Body().FgColor.String()) + assert.Equal(t, "black", s.Body().BgColor.String()) + assert.Equal(t, "black", s.Table().BgColor.String()) assert.Equal(t, tcell.ColorCadetBlue, s.FgColor()) assert.Equal(t, tcell.ColorBlack, s.BgColor()) assert.Equal(t, tcell.ColorBlack, tview.Styles.PrimitiveBackgroundColor) @@ -43,9 +43,9 @@ func TestSkin(t *testing.T) { assert.Nil(t, s.Load("testdata/black_and_wtf.yml")) s.Update() - assert.Equal(t, "white", s.Body().FgColor) - assert.Equal(t, "black", s.Body().BgColor) - assert.Equal(t, "black", s.Table().BgColor) + assert.Equal(t, "white", s.Body().FgColor.String()) + assert.Equal(t, "black", s.Body().BgColor.String()) + assert.Equal(t, "black", s.Table().BgColor.String()) assert.Equal(t, tcell.ColorWhite, s.FgColor()) assert.Equal(t, tcell.ColorBlack, s.BgColor()) assert.Equal(t, tcell.ColorBlack, tview.Styles.PrimitiveBackgroundColor) diff --git a/internal/dao/container.go b/internal/dao/container.go index a655f505..bedfd2e8 100644 --- a/internal/dao/container.go +++ b/internal/dao/container.go @@ -33,23 +33,21 @@ func (c *Container) List(ctx context.Context, _ string) ([]runtime.Object, error if !ok { return nil, fmt.Errorf("no context path for %q", c.gvr) } + + var ( + pmx *mv1beta1.PodMetrics + err error + ) + if withMx, ok := ctx.Value(internal.KeyWithMetrics).(bool); withMx || !ok { + if pmx, err = client.DialMetrics(c.Client()).FetchPodMetrics(fqn); err != nil { + log.Warn().Err(err).Msgf("No metrics found for pod %q", fqn) + } + } + po, err := c.fetchPod(fqn) if err != nil { return nil, err } - - var pmx *mv1beta1.PodMetrics - if c.Client().HasMetrics() { - mx := client.NewMetricsServer(c.Client()) - if c.Client() != nil { - var err error - pmx, err = mx.FetchPodMetrics(fqn) - if err != nil { - log.Warn().Err(err).Msgf("No metrics found for pod %q", fqn) - } - } - } - res := make([]runtime.Object, 0, len(po.Spec.InitContainers)+len(po.Spec.Containers)) for _, co := range po.Spec.InitContainers { res = append(res, makeContainerRes(co, po, pmx, true)) diff --git a/internal/dao/dp.go b/internal/dao/dp.go index 6f937b25..4b9216c6 100644 --- a/internal/dao/dp.go +++ b/internal/dao/dp.go @@ -29,6 +29,11 @@ type Deployment struct { Resource } +// IsHappy check for happy deployments. +func (d *Deployment) IsHappy(dp appsv1.Deployment) bool { + return dp.Status.Replicas == dp.Status.AvailableReplicas +} + // Scale a Deployment. func (d *Deployment) Scale(path string, replicas int32) error { ns, n := client.Namespaced(path) diff --git a/internal/dao/ds.go b/internal/dao/ds.go index 89a0877d..22ca93bc 100644 --- a/internal/dao/ds.go +++ b/internal/dao/ds.go @@ -32,6 +32,11 @@ type DaemonSet struct { Resource } +// IsHappy check for happy deployments. +func (d *DaemonSet) IsHappy(ds appsv1.DaemonSet) bool { + return ds.Status.DesiredNumberScheduled == ds.Status.CurrentNumberScheduled +} + // Restart a DaemonSet rollout. func (d *DaemonSet) Restart(path string) error { ds, err := d.GetInstance(path) diff --git a/internal/dao/node.go b/internal/dao/node.go index ec58eb6d..27628b7b 100644 --- a/internal/dao/node.go +++ b/internal/dao/node.go @@ -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") } - mx := client.NewMetricsServer(n.Client()) - nmx, err := mx.FetchNodesMetrics() - if err != nil { - log.Warn().Err(err).Msgf("No node metrics") + var ( + nmx *mv1beta1.NodeMetricsList + err error + ) + if withMx, ok := ctx.Value(internal.KeyWithMetrics).(bool); withMx || !ok { + if nmx, err = client.DialMetrics(n.Client()).FetchNodesMetrics(); err != nil { + log.Warn().Err(err).Msgf("No node metrics") + } } nn, err := FetchNodes(n.Factory, labels) diff --git a/internal/dao/pod.go b/internal/dao/pod.go index 1fddf20b..9f97f709 100644 --- a/internal/dao/pod.go +++ b/internal/dao/pod.go @@ -38,6 +38,16 @@ type Pod struct { Resource } +// IsHappy check for happy deployments. +func (p *Pod) IsHappy(po v1.Pod) bool { + for _, c := range po.Status.Conditions { + if c.Status == v1.ConditionFalse { + return false + } + } + return true +} + // Get returns a resource instance if found, else an error. func (p *Pod) Get(ctx context.Context, path string) (runtime.Object, error) { o, err := p.Resource.Get(ctx, path) @@ -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) } - // No Deal! - mx := client.NewMetricsServer(p.Client()) - pmx, err := mx.FetchPodMetrics(path) - if err != nil { - log.Warn().Err(err).Msgf("No pods metrics") + var pmx *mv1beta1.PodMetrics + if withMx, ok := ctx.Value(internal.KeyWithMetrics).(bool); withMx || !ok { + if pmx, err = client.DialMetrics(p.Client()).FetchPodMetrics(path); err != nil { + log.Warn().Err(err).Msgf("No pod metrics") + } } return &render.PodWithMetrics{Raw: u, MX: pmx}, nil @@ -77,10 +87,11 @@ func (p *Pod) List(ctx context.Context, ns string) ([]runtime.Object, error) { return oo, err } - mx := client.NewMetricsServer(p.Client()) - pmx, err := mx.FetchPodsMetrics(ns) - if err != nil { - log.Warn().Err(err).Msgf("No pods metrics") + var pmx *mv1beta1.PodMetricsList + if withMx, ok := ctx.Value(internal.KeyWithMetrics).(bool); withMx || !ok { + if pmx, err = client.DialMetrics(p.Client()).FetchPodsMetrics(ns); err != nil { + log.Warn().Err(err).Msgf("No pods metrics") + } } var res []runtime.Object @@ -128,7 +139,7 @@ func (p *Pod) Containers(path string, includeInit bool) ([]string, error) { return nil, err } - cc := []string{} + cc := make([]string, 0, len(pod.Spec.Containers)+len(pod.Spec.InitContainers)) for _, c := range pod.Spec.Containers { cc = append(cc, c.Name) } diff --git a/internal/dao/pulse.go b/internal/dao/pulse.go new file mode 100644 index 00000000..4c93ad38 --- /dev/null +++ b/internal/dao/pulse.go @@ -0,0 +1,18 @@ +package dao + +import ( + "context" + "fmt" + + "k8s.io/apimachinery/pkg/runtime" +) + +// Pulse tracks pulses. +type Pulse struct { + NonResource +} + +// List lists out pulses. +func (h *Pulse) List(ctx context.Context, ns string) ([]runtime.Object, error) { + return nil, fmt.Errorf("NYI") +} diff --git a/internal/dao/registry.go b/internal/dao/registry.go index 1436efd7..7eb00c9e 100644 --- a/internal/dao/registry.go +++ b/internal/dao/registry.go @@ -142,6 +142,13 @@ func loadNonResource(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{ Name: "xray", Kind: "XRays", diff --git a/internal/dao/rs.go b/internal/dao/rs.go new file mode 100644 index 00000000..fdb014f0 --- /dev/null +++ b/internal/dao/rs.go @@ -0,0 +1,23 @@ +package dao + +import ( + appsv1 "k8s.io/api/apps/v1" +) + +// ReplicaSet represents a replicaset K8s resource. +type ReplicaSet struct { + Resource +} + +// IsHappy check for happy deployments. +func (d *ReplicaSet) IsHappy(rs appsv1.ReplicaSet) bool { + if rs.Status.Replicas == 0 && rs.Status.Replicas != rs.Status.ReadyReplicas { + return false + } + + if rs.Status.Replicas != 0 && rs.Status.Replicas != rs.Status.ReadyReplicas { + return false + } + + return true +} diff --git a/internal/dao/sts.go b/internal/dao/sts.go index e00d849b..8f599f3a 100644 --- a/internal/dao/sts.go +++ b/internal/dao/sts.go @@ -29,6 +29,11 @@ type StatefulSet struct { Resource } +// IsHappy check for happy sts. +func (s *StatefulSet) IsHappy(sts appsv1.StatefulSet) bool { + return sts.Status.Replicas == sts.Status.ReadyReplicas +} + // Scale a StatefulSet. func (s *StatefulSet) Scale(path string, replicas int32) error { ns, n := client.Namespaced(path) diff --git a/internal/health/check.go b/internal/health/check.go new file mode 100644 index 00000000..b3713bfd --- /dev/null +++ b/internal/health/check.go @@ -0,0 +1,54 @@ +package health + +import ( + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +// Check tracks resource health. +type Check struct { + Counts + + GVR string +} + +// Checks represents a collection of health checks. +type Checks []*Check + +// NewCheck returns a new health check. +func NewCheck(gvr string) *Check { + return &Check{ + GVR: gvr, + Counts: make(Counts), + } +} + +// Set sets a health metric. +func (c *Check) Set(l Level, v int) { + c.Counts[l] = v +} + +// Inc increments a health metric. +func (c *Check) Inc(l Level) { + c.Counts[l]++ +} + +// Total stores a metric total. +func (c *Check) Total(n int) { + c.Counts[Corpus] = n +} + +// Tally retrieves a given health metric. +func (c *Check) Tally(l Level) int { + return c.Counts[l] +} + +// GetObjectKind returns a schema object. +func (Check) GetObjectKind() schema.ObjectKind { + return nil +} + +// DeepCopyObject returns a container copy. +func (c Check) DeepCopyObject() runtime.Object { + return c +} diff --git a/internal/health/check_test.go b/internal/health/check_test.go new file mode 100644 index 00000000..f0a40b82 --- /dev/null +++ b/internal/health/check_test.go @@ -0,0 +1,26 @@ +package health_test + +import ( + "testing" + + "github.com/derailed/k9s/internal/health" + "github.com/stretchr/testify/assert" +) + +func TestCheck(t *testing.T) { + var cc health.Checks + + c := health.NewCheck("test") + n := 0 + for i := 0; i < 10; i++ { + c.Inc(health.OK) + cc = append(cc, c) + n++ + } + c.Total(n) + + assert.Equal(t, 10, len(cc)) + assert.Equal(t, 10, c.Tally(health.Corpus)) + assert.Equal(t, 10, c.Tally(health.OK)) + assert.Equal(t, 0, c.Tally(health.Toast)) +} diff --git a/internal/health/types.go b/internal/health/types.go new file mode 100644 index 00000000..63571ea8 --- /dev/null +++ b/internal/health/types.go @@ -0,0 +1,44 @@ +package health + +// Level tracks health count categories. +type Level int + +const ( + // Unknown represents no health level. + Unknown Level = 1 << iota + + // Corpus tracks total health. + Corpus + + // OK tracks healhy. + OK + + // Warn tracks health warnings. + Warn + + // Toast tracks unhealties. + Toast +) + +// Message represents a health message. +type Message struct { + Level Level + Message string + GVR string + FQN string +} + +// Messages tracks a collection of messages. +type Messages []Message + +// Counts tracks health counts by category. +type Counts map[Level]int + +// Vital tracks a resource vitals. +type Vital struct { + Resource string + Total, OK, Toast int +} + +// Vitals tracks a collection of resource health. +type Vitals []Vital diff --git a/internal/keys.go b/internal/keys.go index 240990b5..2cc5c873 100644 --- a/internal/keys.go +++ b/internal/keys.go @@ -25,4 +25,6 @@ const ( KeyApp ContextKey = "app" KeyStyles ContextKey = "styles" KeyMetrics ContextKey = "metrics" + KeyToast ContextKey = "toast" + KeyWithMetrics ContextKey = "withMetrics" ) diff --git a/internal/model/cluster.go b/internal/model/cluster.go index 54593dac..59f7fb7b 100644 --- a/internal/model/cluster.go +++ b/internal/model/cluster.go @@ -35,7 +35,7 @@ type ( func NewCluster(f dao.Factory) *Cluster { return &Cluster{ factory: f, - mx: client.NewMetricsServer(f.Client()), + mx: client.DialMetrics(f.Client()), } } diff --git a/internal/model/flash.go b/internal/model/flash.go new file mode 100644 index 00000000..d7fe8300 --- /dev/null +++ b/internal/model/flash.go @@ -0,0 +1,156 @@ +package model + +import ( + "context" + "fmt" + "time" + + "github.com/rs/zerolog/log" +) + +const ( + // DefaultFlashDelay sets the flash clear delay. + DefaultFlashDelay = 3 * time.Second + + // FlashInfo represents an info message. + FlashInfo FlashLevel = iota + // FlashWarn represents an warning message. + FlashWarn + // FlashErr represents an error message. + FlashErr +) + +// LevelMessage tracks an message and severity. +type LevelMessage struct { + Level FlashLevel + Text string +} + +func newClearMessage() LevelMessage { + return LevelMessage{} +} + +// IsClear returns true if message is empty. +func (l LevelMessage) IsClear() bool { + return l.Text == "" +} + +// FlashLevel represents flash message severity. +type FlashLevel int + +// FlashChan represents a flash event channel. +type FlashChan chan LevelMessage + +// FlashListener represents a text model listener. +type FlashListener interface { + // FlashChanged notifies the model changed. + FlashChanged(FlashLevel, string) + + // FlashCleared notifies when the filter changed. + FlashCleared() +} + +// Flash represents a flash message model. +type Flash struct { + msg LevelMessage + cancel context.CancelFunc + delay time.Duration + msgChan chan LevelMessage +} + +// NewFlash returns a new instance. +func NewFlash(dur time.Duration) *Flash { + return &Flash{ + delay: dur, + msgChan: make(FlashChan, 3), + } +} + +// Channel returns the flash channel. +func (f *Flash) Channel() FlashChan { + return f.msgChan +} + +// Info displays an info flash message. +func (f *Flash) Info(msg string) { + f.SetMessage(FlashInfo, msg) +} + +// Infof displays a formatted info flash message. +func (f *Flash) Infof(fmat string, args ...interface{}) { + f.Info(fmt.Sprintf(fmat, args...)) +} + +// Warn displays a warning flash message. +func (f *Flash) Warn(msg string) { + log.Warn().Msg(msg) + f.SetMessage(FlashWarn, msg) +} + +// Warnf displays a formatted warning flash message. +func (f *Flash) Warnf(fmat string, args ...interface{}) { + f.Warn(fmt.Sprintf(fmat, args...)) +} + +// Err displays an error flash message. +func (f *Flash) Err(err error) { + log.Error().Msg(err.Error()) + f.SetMessage(FlashErr, err.Error()) +} + +// Errf displays a formatted error flash message. +func (f *Flash) Errf(fmat string, args ...interface{}) { + var err error + for _, a := range args { + switch e := a.(type) { + case error: + err = e + } + } + log.Error().Err(err).Msgf(fmat, args...) + f.SetMessage(FlashErr, fmt.Sprintf(fmat, args...)) +} + +// Clear clears the flash message. +func (f *Flash) Clear() { + f.fireCleared() +} + +// SetMessage sets the flash level message. +func (f *Flash) SetMessage(level FlashLevel, msg string) { + if f.cancel != nil { + f.cancel() + f.cancel = nil + } + + f.setLevelMessage(LevelMessage{Level: level, Text: msg}) + f.fireFlashChanged() + + var ctx context.Context + ctx, f.cancel = context.WithCancel(context.Background()) + go f.refresh(ctx) +} + +func (f *Flash) refresh(ctx context.Context) { + for { + select { + case <-ctx.Done(): + return + case <-time.After(f.delay): + f.fireCleared() + return + } + } +} + +func (f *Flash) setLevelMessage(msg LevelMessage) { + f.msg = msg +} + +func (f *Flash) fireFlashChanged() { + f.msgChan <- f.msg +} + +func (f *Flash) fireCleared() { + f.msgChan <- newClearMessage() +} diff --git a/internal/model/flash_test.go b/internal/model/flash_test.go new file mode 100644 index 00000000..2c0561ed --- /dev/null +++ b/internal/model/flash_test.go @@ -0,0 +1,101 @@ +package model_test + +import ( + "errors" + "fmt" + "sync" + "testing" + "time" + + "github.com/derailed/k9s/internal/model" + "github.com/stretchr/testify/assert" +) + +func TestFlash(t *testing.T) { + const delay = 1 * time.Millisecond + + uu := map[string]struct { + level model.FlashLevel + e string + }{ + "info": {level: model.FlashInfo, e: "blee"}, + "warn": {level: model.FlashWarn, e: "blee"}, + "err": {level: model.FlashErr, e: "blee"}, + } + + for k := range uu { + u := uu[k] + + t.Run(k, func(t *testing.T) { + f := model.NewFlash(delay) + v := newFlash() + go v.listen(f.Channel()) + + switch u.level { + case model.FlashInfo: + f.Info(u.e) + case model.FlashWarn: + f.Warn(u.e) + case model.FlashErr: + f.Err(errors.New(u.e)) + } + + time.Sleep(5 * delay) + s, _, l, m := v.getMetrics() + assert.Equal(t, 1, s) + assert.Equal(t, u.level, l) + assert.Equal(t, u.e, m) + }) + } +} + +func TestFlashBurst(t *testing.T) { + const delay = 1 * time.Millisecond + + f := model.NewFlash(delay) + v := newFlash() + go v.listen(f.Channel()) + + count := 5 + for i := 1; i <= count; i++ { + f.Info(fmt.Sprintf("test-%d", i)) + } + + time.Sleep(2 * delay) + s, _, l, m := v.getMetrics() + assert.Equal(t, count, s) + assert.Equal(t, model.FlashInfo, l) + assert.Equal(t, fmt.Sprintf("test-%d", count), m) +} + +type flash struct { + set, clear int + level model.FlashLevel + msg string + mx sync.RWMutex +} + +func newFlash() *flash { + return &flash{} +} + +func (f *flash) getMetrics() (int, int, model.FlashLevel, string) { + f.mx.RLock() + defer f.mx.RUnlock() + return f.set, f.clear, f.level, f.msg +} + +func (f *flash) listen(c model.FlashChan) { + for m := range c { + f.mx.Lock() + { + if m.IsClear() { + f.clear++ + } else { + f.set++ + f.level, f.msg = m.Level, m.Text + } + } + f.mx.Unlock() + } +} diff --git a/internal/model/pulse.go b/internal/model/pulse.go new file mode 100644 index 00000000..d26ecc83 --- /dev/null +++ b/internal/model/pulse.go @@ -0,0 +1,160 @@ +package model + +import ( + "context" + "fmt" + "sync/atomic" + "time" + + "github.com/derailed/k9s/internal" + "github.com/derailed/k9s/internal/dao" + "github.com/derailed/k9s/internal/health" + "github.com/rs/zerolog/log" + "k8s.io/apimachinery/pkg/runtime" +) + +const defaultRefreshRate = 5 * time.Second + +// PulseListener represents a health model listener. +type PulseListener interface { + // PulseChanged notifies the model data changed. + PulseChanged(*health.Check) + + // TreeFailed notifies the health check failed. + PulseFailed(error) +} + +// Pulse tracks multiple resources health. +type Pulse struct { + gvr string + namespace string + inUpdate int32 + listeners []PulseListener + refreshRate time.Duration + health *PulseHealth + data health.Checks +} + +// NewPulse returns a new pulse. +func NewPulse(gvr string) *Pulse { + return &Pulse{ + gvr: gvr, + refreshRate: defaultRefreshRate, + } +} + +// Watch monitors pulses. +func (p *Pulse) Watch(ctx context.Context) { + p.Refresh(ctx) + go p.updater(ctx) +} + +func (p *Pulse) updater(ctx context.Context) { + defer log.Debug().Msgf("Pulse canceled -- %q", p.gvr) + + rate := initRefreshRate + for { + select { + case <-ctx.Done(): + return + case <-time.After(rate): + rate = p.refreshRate + p.refresh(ctx) + } + } +} + +// Refresh update the model now. +func (p *Pulse) Refresh(ctx context.Context) { + for _, d := range p.data { + p.firePulseChanged(d) + } + p.refresh(ctx) +} + +func (p *Pulse) refresh(ctx context.Context) { + if !atomic.CompareAndSwapInt32(&p.inUpdate, 0, 1) { + log.Debug().Msgf("Dropping update...") + return + } + defer atomic.StoreInt32(&p.inUpdate, 0) + + if err := p.reconcile(ctx); err != nil { + log.Error().Err(err).Msg("Reconcile failed") + p.firePulseFailed(err) + return + } +} + +func (p *Pulse) list(ctx context.Context) ([]runtime.Object, error) { + f, ok := ctx.Value(internal.KeyFactory).(dao.Factory) + if !ok { + return nil, fmt.Errorf("expected Factory in context but got %T", ctx.Value(internal.KeyFactory)) + } + if p.health == nil { + p.health = NewPulseHealth(f) + } + ctx = context.WithValue(ctx, internal.KeyFields, "") + ctx = context.WithValue(ctx, internal.KeyWithMetrics, false) + return p.health.List(ctx, p.namespace) +} + +func (p *Pulse) reconcile(ctx context.Context) error { + oo, err := p.list(ctx) + if err != nil { + return err + } + + p.data = health.Checks{} + for _, o := range oo { + c, ok := o.(*health.Check) + if !ok { + return fmt.Errorf("Expecting health check but got %T", o) + } + p.data = append(p.data, c) + p.firePulseChanged(c) + } + return nil +} + +// GetNamespace returns the model namespace. +func (p *Pulse) GetNamespace() string { + return p.namespace +} + +// SetNamespace sets up model namespace. +func (p *Pulse) SetNamespace(ns string) { + p.namespace = ns +} + +// AddListener adds a listener. +func (p *Pulse) AddListener(l PulseListener) { + p.listeners = append(p.listeners, l) +} + +// RemoveListener delete a listener. +func (p *Pulse) RemoveListener(l PulseListener) { + victim := -1 + for i, lis := range p.listeners { + if lis == l { + victim = i + break + } + } + + if victim >= 0 { + p.listeners = append(p.listeners[:victim], p.listeners[victim+1:]...) + } +} + +func (p *Pulse) firePulseChanged(check *health.Check) { + for _, l := range p.listeners { + l.PulseChanged(check) + } +} + +func (p *Pulse) firePulseFailed(err error) { + for _, l := range p.listeners { + l.PulseFailed(err) + } +} diff --git a/internal/model/pulse_health.go b/internal/model/pulse_health.go new file mode 100644 index 00000000..d0a1c5c0 --- /dev/null +++ b/internal/model/pulse_health.go @@ -0,0 +1,117 @@ +package model + +import ( + "context" + "fmt" + "math" + "time" + + "github.com/derailed/k9s/internal/client" + "github.com/derailed/k9s/internal/dao" + "github.com/derailed/k9s/internal/health" + "github.com/derailed/k9s/internal/render" + "github.com/rs/zerolog/log" + "k8s.io/apimachinery/pkg/runtime" +) + +// PulseHealth tracks resources health. +type PulseHealth struct { + factory dao.Factory +} + +// NewPulseHealth returns a new instance. +func NewPulseHealth(f dao.Factory) *PulseHealth { + return &PulseHealth{ + factory: f, + } +} + +// List returns a canned collection of resources health. +func (h *PulseHealth) List(ctx context.Context, ns string) ([]runtime.Object, error) { + defer func(t time.Time) { + log.Debug().Msgf("PulseHealthCheck %v", time.Since(t)) + }(time.Now()) + + gvrs := []string{ + "v1/pods", + "v1/events", + "apps/v1/replicasets", + "apps/v1/deployments", + "apps/v1/statefulsets", + "apps/v1/daemonsets", + "batch/v1/jobs", + "v1/persistentvolumes", + } + + hh := make([]runtime.Object, 0, 10) + for _, gvr := range gvrs { + c, err := h.check(ctx, ns, gvr) + if err != nil { + return nil, err + } + hh = append(hh, c) + } + + mm, err := h.checkMetrics() + if err != nil { + return hh, nil + } + for _, m := range mm { + hh = append(hh, m) + } + + return hh, nil +} + +func (h *PulseHealth) checkMetrics() (health.Checks, error) { + dial := client.DialMetrics(h.factory.Client()) + nmx, err := dial.FetchNodesMetrics() + if err != nil { + log.Error().Err(err).Msgf("Fetching metrics") + return nil, err + } + + var cpu, mem float64 + for _, mx := range nmx.Items { + cpu += float64(mx.Usage.Cpu().MilliValue()) + mem += client.ToMB(mx.Usage.Memory().Value()) + } + c1 := health.NewCheck("cpu") + c1.Set(health.OK, int(math.Round(cpu))) + c2 := health.NewCheck("mem") + c2.Set(health.OK, int(math.Round(mem))) + + return health.Checks{c1, c2}, nil +} + +func (h *PulseHealth) check(ctx context.Context, ns, gvr string) (*health.Check, error) { + meta, ok := Registry[gvr] + if !ok { + return nil, fmt.Errorf("No meta for %q", gvr) + } + if meta.DAO == nil { + meta.DAO = &dao.Resource{} + } + + meta.DAO.Init(h.factory, client.NewGVR(gvr)) + oo, err := meta.DAO.List(ctx, ns) + if err != nil { + return nil, err + } + + c := health.NewCheck(gvr) + c.Total(len(oo)) + rr, re := make(render.Rows, len(oo)), meta.Renderer + for i, o := range oo { + if err := re.Render(o, ns, &rr[i]); err != nil { + return nil, err + } + if !render.Happy(ns, rr[i]) { + c.Inc(health.Toast) + } else { + c.Inc(health.OK) + } + } + + return c, nil +} diff --git a/internal/model/registry.go b/internal/model/registry.go index 997ddfe2..0dde91f1 100644 --- a/internal/model/registry.go +++ b/internal/model/registry.go @@ -14,6 +14,9 @@ var Registry = map[string]ResourceMeta{ DAO: &dao.Chart{}, Renderer: &render.Chart{}, }, + "pulses": { + DAO: &dao.Pulse{}, + }, "openfaas": { DAO: &dao.OpenFaas{}, Renderer: &render.OpenFaas{}, diff --git a/internal/model/stack.go b/internal/model/stack.go index b2ffb3cb..53cd0fea 100644 --- a/internal/model/stack.go +++ b/internal/model/stack.go @@ -108,8 +108,8 @@ func (s *Stack) Pop() (Component, bool) { var c Component s.mx.Lock() { - c = s.components[s.size()] - s.components = s.components[:s.size()] + c = s.components[len(s.components)-1] + s.components = s.components[:len(s.components)-1] } s.mx.Unlock() s.notify(StackPop, c) @@ -163,11 +163,7 @@ func (s *Stack) Top() Component { return nil } - return s.components[s.size()] -} - -func (s *Stack) size() int { - return len(s.components) - 1 + return s.components[len(s.components)-1] } func (s *Stack) notify(a StackAction, c Component) { diff --git a/internal/model/table.go b/internal/model/table.go index e54decbf..75bba3f5 100644 --- a/internal/model/table.go +++ b/internal/model/table.go @@ -162,6 +162,7 @@ func (t *Table) SetRefreshRate(d time.Duration) { // ClusterWide checks if resource is scope for all namespaces. func (t *Table) ClusterWide() bool { + log.Debug().Msgf("CLUSTER-WIDE %q", t.namespace) return client.IsClusterWide(t.namespace) } @@ -219,6 +220,7 @@ func (t *Table) list(ctx context.Context, a dao.Accessor) ([]runtime.Object, err if client.IsClusterScoped(t.namespace) { ns = client.AllNamespaces } + return a.List(ctx, ns) } diff --git a/internal/model/table_int_test.go b/internal/model/table_int_test.go index 123da35c..9033d021 100644 --- a/internal/model/table_int_test.go +++ b/internal/model/table_int_test.go @@ -29,10 +29,11 @@ func TestTableReconcile(t *testing.T) { f.rows = []runtime.Object{load(t, "p1")} ctx := context.WithValue(context.Background(), internal.KeyFactory, f) ctx = context.WithValue(ctx, internal.KeyFields, "") + ctx = context.WithValue(ctx, internal.KeyWithMetrics, false) err := ta.reconcile(ctx) assert.Nil(t, err) data := ta.Peek() - assert.Equal(t, 15, len(data.Header)) + assert.Equal(t, 17, len(data.Header)) assert.Equal(t, 1, len(data.RowEvents)) assert.Equal(t, client.NamespaceAll, data.Namespace) } @@ -55,6 +56,7 @@ func TestTableGet(t *testing.T) { f := makeFactory() f.rows = []runtime.Object{load(t, "p1")} ctx := context.WithValue(context.Background(), internal.KeyFactory, f) + ctx = context.WithValue(ctx, internal.KeyWithMetrics, false) row, err := ta.Get(ctx, "fred") assert.Nil(t, err) assert.NotNil(t, row) @@ -104,7 +106,7 @@ func TestTableHydrate(t *testing.T) { assert.Nil(t, hydrate("blee", oo, rr, render.Pod{})) assert.Equal(t, 1, len(rr)) - assert.Equal(t, 14, len(rr[0].Fields)) + assert.Equal(t, 16, len(rr[0].Fields)) } func TestTableGenericHydrate(t *testing.T) { diff --git a/internal/model/table_test.go b/internal/model/table_test.go index 0ae6da2d..f58187ae 100644 --- a/internal/model/table_test.go +++ b/internal/model/table_test.go @@ -30,9 +30,10 @@ func TestTableRefresh(t *testing.T) { f.rows = []runtime.Object{mustLoad("p1")} ctx := context.WithValue(context.Background(), internal.KeyFactory, f) ctx = context.WithValue(ctx, internal.KeyFields, "") + ctx = context.WithValue(ctx, internal.KeyWithMetrics, false) ta.Refresh(ctx) data := ta.Peek() - assert.Equal(t, 15, len(data.Header)) + assert.Equal(t, 17, len(data.Header)) assert.Equal(t, 1, len(data.RowEvents)) assert.Equal(t, client.NamespaceAll, data.Namespace) assert.Equal(t, 1, l.count) diff --git a/internal/model/tree.go b/internal/model/tree.go index 55dc4284..400944e1 100644 --- a/internal/model/tree.go +++ b/internal/model/tree.go @@ -229,7 +229,7 @@ func (t *Tree) reconcile(ctx context.Context) error { } if t.root == nil || t.root.Diff(root) { t.root = root - t.fireTreeTreeChanged(t.root) + t.fireTreeChanged(t.root) } return nil @@ -251,7 +251,7 @@ func (t *Tree) resourceMeta() ResourceMeta { return meta } -func (t *Tree) fireTreeTreeChanged(root *xray.TreeNode) { +func (t *Tree) fireTreeChanged(root *xray.TreeNode) { for _, l := range t.listeners { l.TreeChanged(root) } diff --git a/internal/render/benchmark.go b/internal/render/benchmark.go index 68da849a..2f137de9 100644 --- a/internal/render/benchmark.go +++ b/internal/render/benchmark.go @@ -1,6 +1,7 @@ package render import ( + "errors" "fmt" "io/ioutil" "os" @@ -8,6 +9,7 @@ import ( "strconv" "strings" + "github.com/derailed/k9s/internal/client" "github.com/derailed/tview" "github.com/gdamore/tcell" "golang.org/x/text/language" @@ -28,11 +30,10 @@ var ( type Benchmark struct{} // ColorerFunc colors a resource row. -func (Benchmark) ColorerFunc() ColorerFunc { +func (b Benchmark) ColorerFunc() ColorerFunc { return func(ns string, re RowEvent) tcell.Color { c := tcell.ColorPaleGreen - statusCol := 2 - if strings.TrimSpace(re.Row.Fields[statusCol]) != "pass" { + if !Happy(ns, re.Row) { c = ErrColor } return c @@ -50,6 +51,7 @@ func (Benchmark) Header(ns string) HeaderRow { Header{Name: "2XX", Align: tview.AlignRight}, Header{Name: "4XX/5XX", Align: tview.AlignRight}, Header{Name: "REPORT"}, + Header{Name: "VALID", Wide: true}, Header{Name: "AGE", Decorator: AgeDecorator}, } } @@ -72,6 +74,24 @@ func (b Benchmark) Render(o interface{}, ns string, r *Row) error { return err } b.augmentRow(r.Fields, data) + r.Fields[8] = asStatus(b.diagnose(ns, r.Fields)) + + return nil +} + +// Happy returns true if resoure is happy, false otherwise +func (Benchmark) diagnose(ns string, ff Fields) error { + statusCol := 3 + if !client.IsAllNamespaces(ns) { + statusCol-- + } + + if len(ff) < statusCol { + return nil + } + if ff[statusCol] != "pass" { + return errors.New("failed benchmark") + } return nil } @@ -87,7 +107,7 @@ func (Benchmark) readFile(file string) (string, error) { return string(data), nil } -func (Benchmark) initRow(row Fields, f os.FileInfo) error { +func (b Benchmark) initRow(row Fields, f os.FileInfo) error { tokens := strings.Split(f.Name(), "_") if len(tokens) < 2 { return fmt.Errorf("Invalid file name %s", f.Name()) @@ -95,7 +115,7 @@ func (Benchmark) initRow(row Fields, f os.FileInfo) error { row[0] = tokens[0] row[1] = tokens[1] row[7] = f.Name() - row[8] = timeToAge(f.ModTime()) + row[9] = timeToAge(f.ModTime()) return nil } diff --git a/internal/render/chart.go b/internal/render/chart.go index f1ab5892..0cfbdfb0 100644 --- a/internal/render/chart.go +++ b/internal/render/chart.go @@ -18,6 +18,10 @@ type Chart struct{} // ColorerFunc colors a resource row. func (Chart) ColorerFunc() ColorerFunc { return func(ns string, re RowEvent) tcell.Color { + if !Happy(ns, re.Row) { + return ErrColor + } + return tcell.ColorMediumSpringGreen } } @@ -35,6 +39,7 @@ func (Chart) Header(ns string) HeaderRow { Header{Name: "STATUS"}, Header{Name: "CHART"}, Header{Name: "APP VERSION"}, + Header{Name: "VALID", Wide: true}, Header{Name: "AGE", Decorator: AgeDecorator}, ) } @@ -57,12 +62,21 @@ func (c Chart) Render(o interface{}, ns string, r *Row) error { h.Release.Info.Status.String(), h.Release.Chart.Metadata.Name+"-"+h.Release.Chart.Metadata.Version, h.Release.Chart.Metadata.AppVersion, + asStatus(c.diagnose(h.Release.Info.Status.String())), toAge(metav1.Time{Time: h.Release.Info.LastDeployed.Time}), ) return nil } +func (c Chart) diagnose(s string) error { + if s != "deployed" { + return fmt.Errorf("chart is in an invalid state") + } + + return nil +} + // ---------------------------------------------------------------------------- // Helpers... diff --git a/internal/render/container.go b/internal/render/container.go index 0e74e04f..410c55c4 100644 --- a/internal/render/container.go +++ b/internal/render/container.go @@ -1,6 +1,7 @@ package render import ( + "errors" "fmt" "strconv" "strings" @@ -36,18 +37,18 @@ type ContainerWithMetrics interface { // Container renders a K8s Container to screen. type Container struct{} +const readyCol = 2 + // ColorerFunc colors a resource row. -func (Container) ColorerFunc() ColorerFunc { - return func(ns string, r RowEvent) tcell.Color { - c := DefaultColorer(ns, r) +func (c Container) ColorerFunc() ColorerFunc { + return func(ns string, re RowEvent) tcell.Color { + color := DefaultColorer(ns, re) - readyCol := 2 - if strings.TrimSpace(r.Row.Fields[readyCol]) == "false" { - c = ErrColor + if !Happy(ns, re.Row) { + color = ErrColor } - stateCol := readyCol + 1 - switch strings.TrimSpace(r.Row.Fields[stateCol]) { + switch strings.TrimSpace(re.Row.Fields[stateCol]) { case ContainerCreating, PodInitializing: return AddColor case Terminating, Initialized: @@ -56,10 +57,10 @@ func (Container) ColorerFunc() ColorerFunc { return CompletedColor case Running: default: - c = ErrColor + color = ErrColor } - return c + return color } } @@ -80,6 +81,7 @@ func (Container) Header(ns string) HeaderRow { Header{Name: "%CPU/L", Align: tview.AlignRight}, Header{Name: "%MEM/L", Align: tview.AlignRight}, Header{Name: "PORTS"}, + Header{Name: "VALID", Wide: true}, Header{Name: "AGE", Decorator: AgeDecorator}, } } @@ -114,12 +116,25 @@ func (c Container) Render(o interface{}, name string, r *Row) error { limit.cpu, limit.mem, toStrPorts(co.Container.Ports), + asStatus(c.diagnose(state, ready)), toAge(co.Age), ) return nil } +// Happy returns true if resoure is happy, false otherwise +func (Container) diagnose(state, ready string) error { + if state == "Completed" { + return nil + } + + if ready == "false" { + return errors.New("container is not ready") + } + return nil +} + // ---------------------------------------------------------------------------- // Helpers... diff --git a/internal/render/container_test.go b/internal/render/container_test.go index 051cb2fb..1d14321a 100644 --- a/internal/render/container_test.go +++ b/internal/render/container_test.go @@ -41,6 +41,7 @@ func TestContainer(t *testing.T) { "50", "20", "", + "container is not ready", }, r.Fields[:len(r.Fields)-1], ) diff --git a/internal/render/cr.go b/internal/render/cr.go index aed5710c..5b7c5734 100644 --- a/internal/render/cr.go +++ b/internal/render/cr.go @@ -21,6 +21,7 @@ func (ClusterRole) ColorerFunc() ColorerFunc { func (ClusterRole) Header(string) HeaderRow { return HeaderRow{ Header{Name: "NAME"}, + Header{Name: "LABELS", Wide: true}, Header{Name: "AGE", Decorator: AgeDecorator}, } } @@ -40,6 +41,7 @@ func (ClusterRole) Render(o interface{}, ns string, r *Row) error { r.ID = client.FQN("-", cr.ObjectMeta.Name) r.Fields = Fields{ cr.Name, + mapToStr(cr.Labels), toAge(cr.ObjectMeta.CreationTimestamp), } diff --git a/internal/render/crb.go b/internal/render/crb.go index 74c4ee37..2543cfb1 100644 --- a/internal/render/crb.go +++ b/internal/render/crb.go @@ -22,8 +22,9 @@ func (ClusterRoleBinding) Header(string) HeaderRow { return HeaderRow{ Header{Name: "NAME"}, Header{Name: "CLUSTERROLE"}, - Header{Name: "KIND"}, + Header{Name: "SUBJECT-KIND"}, Header{Name: "SUBJECTS"}, + Header{Name: "LABELS", Wide: true}, Header{Name: "AGE", Decorator: AgeDecorator}, } } @@ -48,6 +49,7 @@ func (ClusterRoleBinding) Render(o interface{}, ns string, r *Row) error { crb.RoleRef.Name, kind, ss, + mapToStr(crb.Labels), toAge(crb.ObjectMeta.CreationTimestamp), } diff --git a/internal/render/crb_test.go b/internal/render/crb_test.go index 08f41b35..cbed129e 100644 --- a/internal/render/crb_test.go +++ b/internal/render/crb_test.go @@ -13,5 +13,5 @@ func TestClusterRoleBindingRender(t *testing.T) { c.Render(load(t, "crb"), "-", &r) assert.Equal(t, "-/blee", r.ID) - assert.Equal(t, render.Fields{"blee", "blee", "USR", "fernand"}, r.Fields[:4]) + assert.Equal(t, render.Fields{"blee", "blee", "User", "fernand"}, r.Fields[:4]) } diff --git a/internal/render/crd.go b/internal/render/crd.go index 120ebdc8..a29faf01 100644 --- a/internal/render/crd.go +++ b/internal/render/crd.go @@ -22,6 +22,7 @@ func (CustomResourceDefinition) ColorerFunc() ColorerFunc { func (CustomResourceDefinition) Header(string) HeaderRow { return HeaderRow{ Header{Name: "NAME"}, + Header{Name: "LABELS", Wide: true}, Header{Name: "AGE", Decorator: AgeDecorator}, } } @@ -45,6 +46,7 @@ func (CustomResourceDefinition) Render(o interface{}, ns string, r *Row) error { r.ID = client.FQN(client.ClusterScope, extractMetaField(meta, "name")) r.Fields = Fields{ extractMetaField(meta, "name"), + mapToIfc(meta["labels"]), toAge(metav1.Time{Time: t}), } diff --git a/internal/render/cronjob.go b/internal/render/cronjob.go index d389fc1c..5b182bbd 100644 --- a/internal/render/cronjob.go +++ b/internal/render/cronjob.go @@ -3,9 +3,12 @@ package render import ( "fmt" "strconv" + "strings" "github.com/derailed/k9s/internal/client" + batchv1 "k8s.io/api/batch/v1" batchv1beta1 "k8s.io/api/batch/v1beta1" + v1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" ) @@ -31,6 +34,11 @@ func (CronJob) Header(ns string) HeaderRow { Header{Name: "SUSPEND"}, Header{Name: "ACTIVE"}, Header{Name: "LAST_SCHEDULE"}, + Header{Name: "SELECTOR", Wide: true}, + Header{Name: "CONTAINERS", Wide: true}, + Header{Name: "IMAGES", Wide: true}, + Header{Name: "LABELS", Wide: true}, + Header{Name: "VALID", Wide: true}, Header{Name: "AGE", Decorator: AgeDecorator}, ) } @@ -63,8 +71,64 @@ func (c CronJob) Render(o interface{}, ns string, r *Row) error { boolPtrToStr(cj.Spec.Suspend), strconv.Itoa(len(cj.Status.Active)), lastScheduled, + jobSelector(cj.Spec.JobTemplate.Spec), + podContainerNames(cj.Spec.JobTemplate.Spec.Template.Spec, true), + podImageNames(cj.Spec.JobTemplate.Spec.Template.Spec, true), + mapToStr(cj.Labels), + "", toAge(cj.ObjectMeta.CreationTimestamp), ) return nil } + +// Helpers + +func jobSelector(spec batchv1.JobSpec) string { + if spec.Selector == nil { + return MissingValue + } + if len(spec.Selector.MatchLabels) > 0 { + return mapToStr(spec.Selector.MatchLabels) + } + if len(spec.Selector.MatchExpressions) == 0 { + return "" + } + + ss := make([]string, 0, len(spec.Selector.MatchExpressions)) + for _, e := range spec.Selector.MatchExpressions { + ss = append(ss, e.String()) + } + + return strings.Join(ss, " ") +} + +func podContainerNames(spec v1.PodSpec, includeInit bool) string { + cc := make([]string, 0, len(spec.Containers)+len(spec.InitContainers)) + + if includeInit { + for _, c := range spec.InitContainers { + cc = append(cc, c.Name) + } + } + for _, c := range spec.Containers { + cc = append(cc, c.Name) + } + + return strings.Join(cc, ",") +} + +func podImageNames(spec v1.PodSpec, includeInit bool) string { + cc := make([]string, 0, len(spec.Containers)+len(spec.InitContainers)) + + if includeInit { + for _, c := range spec.InitContainers { + cc = append(cc, c.Image) + } + } + for _, c := range spec.Containers { + cc = append(cc, c.Image) + } + + return strings.Join(cc, ",") +} diff --git a/internal/render/dp.go b/internal/render/dp.go index 8e636f72..88424063 100644 --- a/internal/render/dp.go +++ b/internal/render/dp.go @@ -3,7 +3,6 @@ package render import ( "fmt" "strconv" - "strings" "github.com/derailed/k9s/internal/client" "github.com/derailed/tview" @@ -17,19 +16,13 @@ import ( type Deployment struct{} // ColorerFunc colors a resource row. -func (Deployment) ColorerFunc() ColorerFunc { +func (d Deployment) ColorerFunc() ColorerFunc { return func(ns string, r RowEvent) tcell.Color { c := DefaultColorer(ns, r) if r.Kind == EventAdd || r.Kind == EventUpdate { return c } - - readyCol := 2 - if !client.IsAllNamespaces(ns) { - readyCol-- - } - tokens := strings.Split(r.Row.Fields[readyCol], "/") - if tokens[0] != tokens[1] { + if !Happy(ns, r.Row) { return ErrColor } @@ -49,6 +42,9 @@ func (Deployment) Header(ns string) HeaderRow { Header{Name: "READY"}, Header{Name: "UP-TO-DATE", Align: tview.AlignRight}, Header{Name: "AVAILABLE", Align: tview.AlignRight}, + Header{Name: "READY", Align: tview.AlignRight}, + Header{Name: "LABELS", Wide: true}, + Header{Name: "VALID", Wide: true}, Header{Name: "AGE", Decorator: AgeDecorator}, ) } @@ -73,11 +69,21 @@ func (d Deployment) Render(o interface{}, ns string, r *Row) error { } r.Fields = append(r.Fields, dp.Name, - strconv.Itoa(int(dp.Status.AvailableReplicas))+"/"+strconv.Itoa(int(*dp.Spec.Replicas)), + strconv.Itoa(int(dp.Status.AvailableReplicas))+"/"+strconv.Itoa(int(dp.Status.Replicas)), strconv.Itoa(int(dp.Status.UpdatedReplicas)), strconv.Itoa(int(dp.Status.AvailableReplicas)), + strconv.Itoa(int(dp.Status.ReadyReplicas)), + mapToStr(dp.Labels), + asStatus(d.diagnose(dp.Status.Replicas, dp.Status.AvailableReplicas)), toAge(dp.ObjectMeta.CreationTimestamp), ) return nil } + +func (Deployment) diagnose(d, r int32) error { + if d != r { + return fmt.Errorf("desiring %d replicas got %d available", d, r) + } + return nil +} diff --git a/internal/render/ds.go b/internal/render/ds.go index b70f957c..191c59a5 100644 --- a/internal/render/ds.go +++ b/internal/render/ds.go @@ -3,7 +3,6 @@ package render import ( "fmt" "strconv" - "strings" "github.com/derailed/k9s/internal/client" "github.com/derailed/tview" @@ -17,18 +16,14 @@ import ( type DaemonSet struct{} // ColorerFunc colors a resource row. -func (DaemonSet) ColorerFunc() ColorerFunc { +func (d DaemonSet) ColorerFunc() ColorerFunc { return func(ns string, r RowEvent) tcell.Color { c := DefaultColorer(ns, r) if r.Kind == EventAdd || r.Kind == EventUpdate { return c } - desiredCol := 2 - if !client.IsAllNamespaces(ns) { - desiredCol = 1 - } - if strings.TrimSpace(r.Row.Fields[desiredCol]) != strings.TrimSpace(r.Row.Fields[desiredCol+2]) { + if !Happy(ns, r.Row) { return ErrColor } @@ -50,6 +45,8 @@ func (DaemonSet) Header(ns string) HeaderRow { Header{Name: "READY", Align: tview.AlignRight}, Header{Name: "UP-TO-DATE", Align: tview.AlignRight}, Header{Name: "AVAILABLE", Align: tview.AlignRight}, + Header{Name: "LABELS", Wide: true}, + Header{Name: "VALID", Wide: true}, Header{Name: "AGE", Decorator: AgeDecorator}, ) } @@ -78,8 +75,18 @@ func (d DaemonSet) Render(o interface{}, ns string, r *Row) error { strconv.Itoa(int(ds.Status.NumberReady)), strconv.Itoa(int(ds.Status.UpdatedNumberScheduled)), strconv.Itoa(int(ds.Status.NumberAvailable)), + mapToStr(ds.Labels), + asStatus(d.diagnose(ds.Status.DesiredNumberScheduled, ds.Status.NumberReady)), toAge(ds.ObjectMeta.CreationTimestamp), ) return nil } + +// Happy returns true if resoure is happy, false otherwise +func (DaemonSet) diagnose(d, r int32) error { + if d != r { + return fmt.Errorf("desiring %d replicas but %d ready", d, r) + } + return nil +} diff --git a/internal/render/ev.go b/internal/render/ev.go index 67494d2a..ef4b6813 100644 --- a/internal/render/ev.go +++ b/internal/render/ev.go @@ -1,6 +1,7 @@ package render import ( + "errors" "fmt" "strconv" "strings" @@ -17,19 +18,20 @@ import ( type Event struct{} // ColorerFunc colors a resource row. -func (Event) ColorerFunc() ColorerFunc { +func (e Event) ColorerFunc() ColorerFunc { return func(ns string, r RowEvent) tcell.Color { c := DefaultColorer(ns, r) + if !Happy(ns, r.Row) { + return ErrColor + } + markCol := 3 if !client.IsAllNamespaces(ns) { markCol = 2 } - switch strings.TrimSpace(r.Row.Fields[markCol]) { - case "Failed": - c = ErrColor - case "Killing": - c = KillColor + if strings.TrimSpace(r.Row.Fields[markCol]) == "Killing" { + return KillColor } return c @@ -45,10 +47,12 @@ func (Event) Header(ns string) HeaderRow { return append(h, Header{Name: "NAME"}, + Header{Name: "TYPE"}, Header{Name: "REASON"}, Header{Name: "SOURCE"}, Header{Name: "COUNT", Align: tview.AlignRight}, - Header{Name: "MESSAGE"}, + Header{Name: "MESSAGE", Wide: true}, + Header{Name: "VALID", Wide: true}, Header{Name: "AGE", Decorator: AgeDecorator}, ) } @@ -72,15 +76,27 @@ func (e Event) Render(o interface{}, ns string, r *Row) error { } r.Fields = append(r.Fields, asRef(ev.InvolvedObject), + ev.Type, ev.Reason, ev.Source.Component, strconv.Itoa(int(ev.Count)), ev.Message, + asStatus(e.diagnose(ev.Type)), toAge(ev.LastTimestamp)) return nil } +// Happy returns true if resoure is happy, false otherwise +func (Event) diagnose(kind string) error { + if kind != "Normal" { + return errors.New("failed event") + } + return nil +} + +// Helpers... + func asRef(r v1.ObjectReference) string { return strings.ToLower(r.Kind) + ":" + r.Name } diff --git a/internal/render/ev_test.go b/internal/render/ev_test.go index 766e5c7a..9388969e 100644 --- a/internal/render/ev_test.go +++ b/internal/render/ev_test.go @@ -13,5 +13,17 @@ func TestEventRender(t *testing.T) { c.Render(load(t, "ev"), "", &r) assert.Equal(t, "default/hello-1567197780-mn4mv.15bfce150bd764dd", r.ID) - assert.Equal(t, render.Fields{"default", "pod:hello-1567197780-mn4mv", "Pulled", "kubelet", "1", `Successfully pulled image "blang/busybox-bash"`}, r.Fields[:6]) + assert.Equal(t, render.Fields{"default", "pod:hello-1567197780-mn4mv", "Normal", "Pulled", "kubelet", "1", `Successfully pulled image "blang/busybox-bash"`}, r.Fields[:7]) +} + +func BenchmarkEventRender(b *testing.B) { + ev := load(b, "ev") + var re render.Event + r := render.NewRow(7) + + b.ResetTimer() + b.ReportAllocs() + for i := 0; i < b.N; i++ { + _ = re.Render(&ev, "", &r) + } } diff --git a/internal/render/generic.go b/internal/render/generic.go index d1e70bd9..46950ea7 100644 --- a/internal/render/generic.go +++ b/internal/render/generic.go @@ -19,6 +19,11 @@ type Generic struct { ageIndex int } +// Happy returns true if resoure is happy, false otherwise +func (Generic) Happy(ns string, r Row) bool { + return true +} + // SetTable sets the tabular resource. func (g *Generic) SetTable(t *metav1beta1.Table) { g.table = t diff --git a/internal/render/helpers.go b/internal/render/helpers.go index a598065d..1a26f447 100644 --- a/internal/render/helpers.go +++ b/internal/render/helpers.go @@ -13,6 +13,12 @@ import ( "k8s.io/apimachinery/pkg/util/duration" ) +// Happy returns true if resoure is happy, false otherwise +func Happy(ns string, r Row) bool { + validCol := r.Len() - 2 + return strings.TrimSpace(r.Fields[validCol]) == "" +} + const megaByte = 1024 * 1024 // ToMB converts bytes to megabytes. @@ -20,6 +26,13 @@ func ToMB(v int64) float64 { return float64(v) / megaByte } +func asStatus(err error) string { + if err == nil { + return "" + } + return err.Error() +} + func asSelector(s *metav1.LabelSelector) string { sel, err := metav1.LabelSelectorAsSelector(s) if err != nil { @@ -84,7 +97,7 @@ func join(a []string, sep string) string { var buff strings.Builder buff.Grow(n) - buff.WriteString(a[0]) + buff.WriteString(b[0]) for _, s := range b[1:] { buff.WriteString(sep) buff.WriteString(s) @@ -163,7 +176,40 @@ func mapToStr(m map[string]string) (s string) { for i, k := range kk { s += k + "=" + m[k] if i < len(kk)-1 { - s += "," + s += " " + } + } + + return +} + +func mapToIfc(m interface{}) (s string) { + if m == nil { + return "" + } + + mm, ok := m.(map[string]interface{}) + if !ok { + return "" + } + if len(mm) == 0 { + return "" + } + + kk := make([]string, 0, len(mm)) + for k := range mm { + kk = append(kk, k) + } + sort.Strings(kk) + + for i, k := range kk { + str, ok := mm[k].(string) + if !ok { + continue + } + s += k + "=" + str + if i < len(kk)-1 { + s += " " } } diff --git a/internal/render/helpers_test.go b/internal/render/helpers_test.go index ed366a46..51b93339 100644 --- a/internal/render/helpers_test.go +++ b/internal/render/helpers_test.go @@ -82,10 +82,11 @@ func TestJoin(t *testing.T) { i []string e string }{ - "zero": {[]string{}, ""}, - "std": {[]string{"a", "b", "c"}, "a,b,c"}, - "blank": {[]string{"", "", ""}, ""}, - "sparse": {[]string{"a", "", "c"}, "a,c"}, + "zero": {[]string{}, ""}, + "std": {[]string{"a", "b", "c"}, "a,b,c"}, + "blank": {[]string{"", "", ""}, ""}, + "sparse": {[]string{"a", "", "c"}, "a,c"}, + "withBlank": {[]string{"", "a", "c"}, "a,c"}, } for k := range uu { @@ -304,7 +305,7 @@ func TestMapToStr(t *testing.T) { i map[string]string e string }{ - {map[string]string{"blee": "duh", "aa": "bb"}, "aa=bb,blee=duh"}, + {map[string]string{"blee": "duh", "aa": "bb"}, "aa=bb blee=duh"}, {map[string]string{}, ""}, } for _, u := range uu { diff --git a/internal/render/hpa.go b/internal/render/hpa.go index 6042b49f..08bba6a4 100644 --- a/internal/render/hpa.go +++ b/internal/render/hpa.go @@ -36,6 +36,7 @@ func (HorizontalPodAutoscaler) Header(ns string) HeaderRow { Header{Name: "MINPODS", Align: tview.AlignRight}, Header{Name: "MAXPODS", Align: tview.AlignRight}, Header{Name: "REPLICAS", Align: tview.AlignRight}, + Header{Name: "VALID", Wide: true}, Header{Name: "AGE", Decorator: AgeDecorator}, ) } @@ -80,6 +81,7 @@ func (h HorizontalPodAutoscaler) renderV1(raw *unstructured.Unstructured, ns str strconv.Itoa(int(*hpa.Spec.MinReplicas)), strconv.Itoa(int(hpa.Spec.MaxReplicas)), strconv.Itoa(int(hpa.Status.CurrentReplicas)), + "", toAge(hpa.ObjectMeta.CreationTimestamp), ) @@ -106,6 +108,7 @@ func (h HorizontalPodAutoscaler) renderV2b1(raw *unstructured.Unstructured, ns s strconv.Itoa(int(*hpa.Spec.MinReplicas)), strconv.Itoa(int(hpa.Spec.MaxReplicas)), strconv.Itoa(int(hpa.Status.CurrentReplicas)), + "", toAge(hpa.ObjectMeta.CreationTimestamp), ) @@ -132,6 +135,7 @@ func (h HorizontalPodAutoscaler) renderV2b2(raw *unstructured.Unstructured, ns s strconv.Itoa(int(*hpa.Spec.MinReplicas)), strconv.Itoa(int(hpa.Spec.MaxReplicas)), strconv.Itoa(int(hpa.Status.CurrentReplicas)), + "", toAge(hpa.ObjectMeta.CreationTimestamp), ) diff --git a/internal/render/ing.go b/internal/render/ing.go index 9f90ba74..2ba806fa 100644 --- a/internal/render/ing.go +++ b/internal/render/ing.go @@ -31,6 +31,7 @@ func (Ingress) Header(ns string) HeaderRow { Header{Name: "HOSTS"}, Header{Name: "ADDRESS"}, Header{Name: "PORT"}, + Header{Name: "VALID", Wide: true}, Header{Name: "AGE", Decorator: AgeDecorator}, ) } @@ -57,6 +58,7 @@ func (i Ingress) Render(o interface{}, ns string, r *Row) error { toHosts(ing.Spec.Rules), toAddress(ing.Status.LoadBalancer), toTLSPorts(ing.Spec.TLS), + "", toAge(ing.ObjectMeta.CreationTimestamp), ) diff --git a/internal/render/job.go b/internal/render/job.go index 661c7c88..22d32f1a 100644 --- a/internal/render/job.go +++ b/internal/render/job.go @@ -9,6 +9,7 @@ import ( "github.com/derailed/k9s/internal/client" batchv1 "k8s.io/api/batch/v1" v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/util/duration" @@ -33,8 +34,10 @@ func (Job) Header(ns string) HeaderRow { Header{Name: "NAME"}, Header{Name: "COMPLETIONS"}, Header{Name: "DURATION"}, - Header{Name: "CONTAINERS"}, - Header{Name: "IMAGES"}, + Header{Name: "SELECTOR", Wide: true}, + Header{Name: "CONTAINERS", Wide: true}, + Header{Name: "IMAGES", Wide: true}, + Header{Name: "VALID", Wide: true}, Header{Name: "AGE", Decorator: AgeDecorator}, ) } @@ -50,6 +53,7 @@ func (j Job) Render(o interface{}, ns string, r *Row) error { if err != nil { return err } + ready := toCompletion(job.Spec, job.Status) r.ID = client.MetaFQN(job.ObjectMeta) r.Fields = make(Fields, 0, len(j.Header(ns))) @@ -59,16 +63,29 @@ func (j Job) Render(o interface{}, ns string, r *Row) error { cc, ii := toContainers(job.Spec.Template.Spec) r.Fields = append(r.Fields, job.Name, - toCompletion(job.Spec, job.Status), + ready, toDuration(job.Status), + jobSelector(job.Spec), cc, ii, + asStatus(j.diagnose(ready, job.Status.CompletionTime)), toAge(job.ObjectMeta.CreationTimestamp), ) return nil } +func (Job) diagnose(ready string, completed *metav1.Time) error { + if completed == nil { + return nil + } + tokens := strings.Split(ready, "/") + if tokens[0] != tokens[1] { + return fmt.Errorf("expecting %s completion got %s", tokens[1], tokens[0]) + } + return nil +} + // ---------------------------------------------------------------------------- // Helpers... diff --git a/internal/render/job_test.go b/internal/render/job_test.go index 7d375d57..63ff6fb7 100644 --- a/internal/render/job_test.go +++ b/internal/render/job_test.go @@ -13,5 +13,5 @@ func TestJobRender(t *testing.T) { c.Render(load(t, "job"), "", &r) assert.Equal(t, "default/hello-1567179180", r.ID) - assert.Equal(t, render.Fields{"default", "hello-1567179180", "1/1", "8s", "c1", "blang/busybox-bash"}, r.Fields[:6]) + assert.Equal(t, render.Fields{"default", "hello-1567179180", "1/1", "8s", "controller-uid=7473e6d0-cb3b-11e9-990f-42010a800218", "c1", "blang/busybox-bash"}, r.Fields[:7]) } diff --git a/internal/render/node.go b/internal/render/node.go index f64038a7..14dd41cc 100644 --- a/internal/render/node.go +++ b/internal/render/node.go @@ -1,11 +1,14 @@ package render import ( + "errors" "fmt" + "sort" "strings" "github.com/derailed/k9s/internal/client" "github.com/derailed/tview" + "github.com/gdamore/tcell" v1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" @@ -22,8 +25,16 @@ const ( type Node struct{} // ColorerFunc colors a resource row. -func (Node) ColorerFunc() ColorerFunc { - return DefaultColorer +func (n Node) ColorerFunc() ColorerFunc { + return func(ns string, r RowEvent) tcell.Color { + c := DefaultColorer(ns, r) + if !Happy(ns, r.Row) { + return ErrColor + } + + return c + } + } // Header returns a header row. @@ -31,17 +42,19 @@ func (Node) Header(_ string) HeaderRow { return HeaderRow{ Header{Name: "NAME"}, Header{Name: "STATUS"}, - Header{Name: "ROLE"}, - Header{Name: "VERSION"}, - Header{Name: "KERNEL"}, - Header{Name: "INTERNAL-IP"}, - Header{Name: "EXTERNAL-IP"}, + Header{Name: "ROLE", Wide: true}, + Header{Name: "VERSION", Wide: true}, + Header{Name: "KERNEL", Wide: true}, + Header{Name: "INTERNAL-IP", Wide: true}, + Header{Name: "EXTERNAL-IP", Wide: true}, Header{Name: "CPU", Align: tview.AlignRight}, Header{Name: "MEM", Align: tview.AlignRight}, Header{Name: "%CPU", Align: tview.AlignRight}, Header{Name: "%MEM", Align: tview.AlignRight}, Header{Name: "ACPU", Align: tview.AlignRight}, Header{Name: "AMEM", Align: tview.AlignRight}, + Header{Name: "LABELS", Wide: true}, + Header{Name: "VALID", Wide: true}, Header{Name: "AGE", Decorator: AgeDecorator}, } } @@ -69,17 +82,19 @@ func (n Node) Render(o interface{}, ns string, r *Row) error { c, a, p := gatherNodeMX(&no, oo.MX) - sta := make([]string, 10) - status(no.Status, no.Spec.Unschedulable, sta) - ro := make([]string, 10) - nodeRoles(&no, ro) + statuses := make(sort.StringSlice, 10) + status(no.Status, no.Spec.Unschedulable, statuses) + sort.Sort(statuses) + roles := make(sort.StringSlice, 10) + nodeRoles(&no, roles) + sort.Sort(roles) r.ID = client.FQN("", na) r.Fields = make(Fields, 0, len(n.Header(ns))) r.Fields = append(r.Fields, no.Name, - join(sta, ","), - join(ro, ","), + join(statuses, ","), + join(roles, ","), no.Status.NodeInfo.KubeletVersion, no.Status.NodeInfo.KernelVersion, iIP, @@ -90,12 +105,27 @@ func (n Node) Render(o interface{}, ns string, r *Row) error { p.mem, a.cpu, a.mem, + mapToStr(no.Labels), + asStatus(n.diagnose(statuses)), toAge(no.ObjectMeta.CreationTimestamp), ) return nil } +func (Node) diagnose(ss []string) error { + if len(ss) == 0 { + return nil + } + for _, s := range ss { + if s == "Ready" { + return nil + } + } + + return errors.New("node is not ready") +} + // ---------------------------------------------------------------------------- // Helpers... @@ -156,6 +186,9 @@ func nodeRoles(node *v1.Node, res []string) { res[index] = v index++ } + if index >= len(res) { + break + } } if empty(res) { diff --git a/internal/render/np.go b/internal/render/np.go index bdedf401..05062971 100644 --- a/internal/render/np.go +++ b/internal/render/np.go @@ -28,12 +28,14 @@ func (NetworkPolicy) Header(ns string) HeaderRow { return append(h, Header{Name: "NAME"}, - Header{Name: "ING-SELECTOR"}, + Header{Name: "ING-SELECTOR", Wide: true}, Header{Name: "ING-PORTS"}, Header{Name: "ING-BLOCK"}, - Header{Name: "EGR-SELECTOR"}, + Header{Name: "EGR-SELECTOR", Wide: true}, Header{Name: "EGR-PORTS"}, Header{Name: "EGR-BLOCK"}, + Header{Name: "LABELS", Wide: true}, + Header{Name: "VALID", Wide: true}, Header{Name: "AGE", Decorator: AgeDecorator}, ) } @@ -66,6 +68,8 @@ func (n NetworkPolicy) Render(o interface{}, ns string, r *Row) error { es, ep, eb, + mapToStr(np.Labels), + "", toAge(np.ObjectMeta.CreationTimestamp), ) diff --git a/internal/render/ns.go b/internal/render/ns.go index b8547b31..0dc1bb07 100644 --- a/internal/render/ns.go +++ b/internal/render/ns.go @@ -1,6 +1,7 @@ package render import ( + "errors" "fmt" "strings" @@ -15,7 +16,7 @@ import ( type Namespace struct{} // ColorerFunc colors a resource row. -func (Namespace) ColorerFunc() ColorerFunc { +func (n Namespace) ColorerFunc() ColorerFunc { return func(ns string, r RowEvent) tcell.Color { c := DefaultColorer(ns, r) if r.Kind == EventAdd { @@ -25,9 +26,8 @@ func (Namespace) ColorerFunc() ColorerFunc { if r.Kind == EventUpdate { c = StdColor } - switch strings.TrimSpace(r.Row.Fields[1]) { - case "Inactive", Terminating: - c = ErrColor + if !Happy(ns, r.Row) { + return ErrColor } if strings.Contains(strings.TrimSpace(r.Row.Fields[0]), "*") { c = HighlightColor @@ -42,12 +42,14 @@ func (Namespace) Header(string) HeaderRow { return HeaderRow{ Header{Name: "NAME"}, Header{Name: "STATUS"}, + Header{Name: "LABELS", Wide: true}, + Header{Name: "VALID", Wide: true}, Header{Name: "AGE", Decorator: AgeDecorator}, } } // Render renders a K8s resource to screen. -func (Namespace) Render(o interface{}, _ string, r *Row) error { +func (n Namespace) Render(o interface{}, _ string, r *Row) error { raw, ok := o.(*unstructured.Unstructured) if !ok { return fmt.Errorf("Expected Namespace, but got %T", o) @@ -62,8 +64,17 @@ func (Namespace) Render(o interface{}, _ string, r *Row) error { r.Fields = Fields{ ns.Name, string(ns.Status.Phase), + mapToStr(ns.Labels), + asStatus(n.diagnose(ns.Status.Phase)), toAge(ns.ObjectMeta.CreationTimestamp), } return nil } + +func (Namespace) diagnose(phase v1.NamespacePhase) error { + if phase != v1.NamespaceActive && phase != v1.NamespaceTerminating { + return errors.New("namespace not ready") + } + return nil +} diff --git a/internal/render/ofaas.go b/internal/render/ofaas.go index c51dd4c7..1edb3439 100644 --- a/internal/render/ofaas.go +++ b/internal/render/ofaas.go @@ -1,6 +1,7 @@ package render import ( + "errors" "fmt" "strconv" "time" @@ -23,8 +24,12 @@ const ( type OpenFaas struct{} // ColorerFunc colors a resource row. -func (OpenFaas) ColorerFunc() ColorerFunc { +func (o OpenFaas) ColorerFunc() ColorerFunc { return func(ns string, re RowEvent) tcell.Color { + if !Happy(ns, re.Row) { + return ErrColor + } + return tcell.ColorPaleTurquoise } } @@ -44,13 +49,14 @@ func (OpenFaas) Header(ns string) HeaderRow { Header{Name: "INVOCATIONS", Align: tview.AlignRight}, Header{Name: "REPLICAS", Align: tview.AlignRight}, Header{Name: "AVAILABLE", Align: tview.AlignRight}, + Header{Name: "VALID", Wide: true}, Header{Name: "AGE", Decorator: AgeDecorator}, ) } // Render renders a chart to screen. -func (f OpenFaas) Render(o interface{}, ns string, r *Row) error { - fn, ok := o.(OpenFaasRes) +func (o OpenFaas) Render(i interface{}, ns string, r *Row) error { + fn, ok := i.(OpenFaasRes) if !ok { return fmt.Errorf("expected OpenFaasRes, but got %T", o) } @@ -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.Fields = make(Fields, 0, len(f.Header(ns))) + r.Fields = make(Fields, 0, len(o.Header(ns))) if client.IsAllNamespaces(ns) { 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.Replicas)), strconv.Itoa(int(fn.Function.AvailableReplicas)), + asStatus(o.diagnose(status)), toAge(metav1.Time{Time: time.Now()}), ) return nil } +func (OpenFaas) diagnose(status string) error { + if status != "Ready" { + return errors.New("function not ready") + } + + return nil +} + // ---------------------------------------------------------------------------- // Helpers... diff --git a/internal/render/pdb.go b/internal/render/pdb.go index 9103b336..fb94eed4 100644 --- a/internal/render/pdb.go +++ b/internal/render/pdb.go @@ -3,7 +3,6 @@ package render import ( "fmt" "strconv" - "strings" "github.com/derailed/k9s/internal/client" "github.com/derailed/tview" @@ -18,24 +17,19 @@ import ( type PodDisruptionBudget struct{} // ColorerFunc colors a resource row. -func (PodDisruptionBudget) ColorerFunc() ColorerFunc { - return func(ns string, r RowEvent) tcell.Color { - c := DefaultColorer(ns, r) - if r.Kind == EventAdd || r.Kind == EventUpdate { +func (p PodDisruptionBudget) ColorerFunc() ColorerFunc { + return func(ns string, re RowEvent) tcell.Color { + c := DefaultColorer(ns, re) + if re.Kind == EventAdd || re.Kind == EventUpdate { return c } - markCol := 5 - if !client.IsAllNamespaces(ns) { - markCol-- - } - if strings.TrimSpace(r.Row.Fields[markCol]) != strings.TrimSpace(r.Row.Fields[markCol+1]) { + if !Happy(ns, re.Row) { return ErrColor } return StdColor } - } // Header returns a header row. @@ -53,6 +47,8 @@ func (PodDisruptionBudget) Header(ns string) HeaderRow { Header{Name: "CURRENT", Align: tview.AlignRight}, Header{Name: "DESIRED", Align: tview.AlignRight}, Header{Name: "EXPECTED", Align: tview.AlignRight}, + Header{Name: "LABELS", Wide: true}, + Header{Name: "VALID", Wide: true}, Header{Name: "AGE", Decorator: AgeDecorator}, ) } @@ -82,12 +78,21 @@ func (p PodDisruptionBudget) Render(o interface{}, ns string, r *Row) error { strconv.Itoa(int(pdb.Status.CurrentHealthy)), strconv.Itoa(int(pdb.Status.DesiredHealthy)), strconv.Itoa(int(pdb.Status.ExpectedPods)), + mapToStr(pdb.Labels), + asStatus(p.diagnose(pdb.Spec.MinAvailable.IntVal, pdb.Status.CurrentHealthy)), toAge(pdb.ObjectMeta.CreationTimestamp), ) return nil } +func (PodDisruptionBudget) diagnose(min, healthy int32) error { + if min > healthy { + return fmt.Errorf("expected %d but got %d", min, healthy) + } + return nil +} + // Helpers... func numbToStr(n *intstr.IntOrString) string { diff --git a/internal/render/pod.go b/internal/render/pod.go index ded5b483..011fb064 100644 --- a/internal/render/pod.go +++ b/internal/render/pod.go @@ -25,15 +25,11 @@ func (p Pod) ColorerFunc() ColorerFunc { return func(ns string, re RowEvent) tcell.Color { c := DefaultColorer(ns, re) - readyCol := 2 + statusCol := 4 if !client.IsAllNamespaces(ns) { - readyCol-- + statusCol-- } - statusCol := readyCol + 1 - - ready, status := strings.TrimSpace(re.Row.Fields[readyCol]), strings.TrimSpace(re.Row.Fields[statusCol]) - c = p.checkReadyCol(ready, status, c) - + status := strings.TrimSpace(re.Row.Fields[statusCol]) switch status { case ContainerCreating, PodInitializing: c = AddColor @@ -42,28 +38,19 @@ func (p Pod) ColorerFunc() ColorerFunc { case Completed: c = CompletedColor case Running: + c = StdColor case Terminating: c = KillColor default: - c = ErrColor + if !Happy(ns, re.Row) { + c = ErrColor + } } return c } } -func (Pod) checkReadyCol(readyCol, statusCol string, c tcell.Color) tcell.Color { - if statusCol == "Completed" { - return c - } - - tokens := strings.Split(readyCol, "/") - if len(tokens) == 2 && (tokens[0] == "0" || tokens[0] != tokens[1]) { - return ErrColor - } - return c -} - // Header returns a header row. func (Pod) Header(ns string) HeaderRow { var h HeaderRow @@ -74,17 +61,19 @@ func (Pod) Header(ns string) HeaderRow { return append(h, Header{Name: "NAME"}, Header{Name: "READY"}, - Header{Name: "STATUS"}, Header{Name: "RS", Align: tview.AlignRight}, + Header{Name: "STATUS"}, Header{Name: "CPU", Align: tview.AlignRight}, Header{Name: "MEM", Align: tview.AlignRight}, Header{Name: "%CPU/R", Align: tview.AlignRight}, Header{Name: "%MEM/R", Align: tview.AlignRight}, Header{Name: "%CPU/L", Align: tview.AlignRight}, Header{Name: "%MEM/L", Align: tview.AlignRight}, - Header{Name: "IP"}, - Header{Name: "NODE"}, - Header{Name: "QOS"}, + Header{Name: "IP", Wide: true}, + Header{Name: "NODE", Wide: true}, + Header{Name: "QOS", Wide: true}, + Header{Name: "LABELS", Wide: true}, + Header{Name: "VALID", Wide: true}, Header{Name: "AGE", Decorator: AgeDecorator}, ) } @@ -105,7 +94,7 @@ func (p Pod) Render(o interface{}, ns string, r *Row) error { ss := po.Status.ContainerStatuses cr, _, rc := p.Statuses(ss) c, perc := p.gatherPodMX(&po, pwm.MX) - + phase := p.Phase(&po) r.ID = client.MetaFQN(po.ObjectMeta) r.Fields = make(Fields, 0, len(p.Header(ns))) if client.IsAllNamespaces(ns) { @@ -114,8 +103,8 @@ func (p Pod) Render(o interface{}, ns string, r *Row) error { r.Fields = append(r.Fields, po.ObjectMeta.Name, strconv.Itoa(cr)+"/"+strconv.Itoa(len(ss)), - p.Phase(&po), strconv.Itoa(rc), + phase, c.cpu, c.mem, perc.cpu, @@ -125,12 +114,25 @@ func (p Pod) Render(o interface{}, ns string, r *Row) error { na(po.Status.PodIP), na(po.Spec.NodeName), p.mapQOS(po.Status.QOSClass), + mapToStr(po.Labels), + asStatus(p.diagnose(phase, cr, len(ss))), toAge(po.ObjectMeta.CreationTimestamp), ) return nil } +func (p Pod) diagnose(phase string, cr, ct int) error { + if phase == "Completed" { + return nil + } + if cr != ct { + return fmt.Errorf("container ready check failed: %d of %d", cr, ct) + } + + return nil +} + // ---------------------------------------------------------------------------- // Helpers... diff --git a/internal/render/pod_test.go b/internal/render/pod_test.go index 6b0df397..0f746fce 100644 --- a/internal/render/pod_test.go +++ b/internal/render/pod_test.go @@ -24,12 +24,12 @@ type ( func TestPodColorer(t *testing.T) { var ( - nsRow = render.Row{Fields: render.Fields{"blee", "fred", "1/1", "Running"}} - toastNS = render.Row{Fields: render.Fields{"blee", "fred", "1/1", "Boom"}} - notReadyNS = render.Row{Fields: render.Fields{"blee", "fred", "0/1", "Boom"}} - row = render.Row{Fields: render.Fields{"fred", "1/1", "Running"}} - toast = render.Row{Fields: render.Fields{"fred", "1/1", "Boom"}} - notReady = render.Row{Fields: render.Fields{"fred", "0/1", "Boom"}} + nsRow = render.Row{Fields: render.Fields{"blee", "fred", "1/1", "0", "Running"}} + toastNS = render.Row{Fields: render.Fields{"blee", "fred", "1/1", "0", "Boom"}} + notReadyNS = render.Row{Fields: render.Fields{"blee", "fred", "0/1", "0", "Boom"}} + row = render.Row{Fields: render.Fields{"fred", "1/1", "0", "Running"}} + toast = render.Row{Fields: render.Fields{"fred", "1/1", "0", "Boom"}} + notReady = render.Row{Fields: render.Fields{"fred", "0/1", "0", "Boom"}} ) uu := colorerUCs{ @@ -70,7 +70,7 @@ func TestPodRender(t *testing.T) { assert.Nil(t, err) assert.Equal(t, "default/nginx", r.ID) - e := render.Fields{"default", "nginx", "1/1", "Running", "0", "10", "10", "10", "14", "0", "5", "172.17.0.6", "minikube", "BE"} + e := render.Fields{"default", "nginx", "1/1", "0", "Running", "10", "10", "10", "14", "0", "5", "172.17.0.6", "minikube", "BE"} assert.Equal(t, e, r.Fields[:14]) } @@ -101,7 +101,7 @@ func TestPodInitRender(t *testing.T) { assert.Nil(t, err) assert.Equal(t, "default/nginx", r.ID) - e := render.Fields{"default", "nginx", "1/1", "Init:0/1", "0", "10", "10", "10", "14", "0", "5", "172.17.0.6", "minikube", "BE"} + e := render.Fields{"default", "nginx", "1/1", "0", "Init:0/1", "10", "10", "10", "14", "0", "5", "172.17.0.6", "minikube", "BE"} assert.Equal(t, e, r.Fields[:14]) } diff --git a/internal/render/policy.go b/internal/render/policy.go index e29ab3ce..da279118 100644 --- a/internal/render/policy.go +++ b/internal/render/policy.go @@ -18,8 +18,8 @@ func rbacVerbHeader() HeaderRow { Header{Name: "PATCH "}, Header{Name: "UPDATE"}, Header{Name: "DELETE"}, - Header{Name: "DLIST "}, - Header{Name: "EXTRAS"}, + Header{Name: "DEL-LIST "}, + Header{Name: "EXTRAS", Wide: true}, } } @@ -41,7 +41,10 @@ func (Policy) Header(ns string) HeaderRow { Header{Name: "API GROUP"}, Header{Name: "BINDING"}, } - return append(h, rbacVerbHeader()...) + h = append(h, rbacVerbHeader()...) + h = append(h, Header{Name: "VALID", Wide: true}) + + return h } // Render renders a K8s resource to screen. @@ -52,8 +55,14 @@ func (Policy) Render(o interface{}, gvr string, r *Row) error { } r.ID = client.FQN(p.Namespace, p.Resource) - r.Fields = append(r.Fields, p.Namespace, cleanseResource(p.Resource), p.Group, p.Binding) + r.Fields = append(r.Fields, + p.Namespace, + cleanseResource(p.Resource), + p.Group, + p.Binding, + ) r.Fields = append(r.Fields, asVerbs(p.Verbs)...) + r.Fields = append(r.Fields, "") return nil } diff --git a/internal/render/policy_test.go b/internal/render/policy_test.go index 61a30ddf..24408821 100644 --- a/internal/render/policy_test.go +++ b/internal/render/policy_test.go @@ -37,5 +37,6 @@ func TestPolicyRender(t *testing.T) { "[orangered::b] 𐄂 [::]", "[orangered::b] 𐄂 [::]", "", + "", }, r.Fields) } diff --git a/internal/render/port_forward_test.go b/internal/render/port_forward_test.go index 3ce102f1..81c9d7cf 100644 --- a/internal/render/port_forward_test.go +++ b/internal/render/port_forward_test.go @@ -30,6 +30,7 @@ func TestPortForwardRender(t *testing.T) { "http://0.0.0.0:p1/", "1", "1", + "", "2m", }, r.Fields) } diff --git a/internal/render/portforward.go b/internal/render/portforward.go index 450188d2..89dcf258 100644 --- a/internal/render/portforward.go +++ b/internal/render/portforward.go @@ -48,6 +48,7 @@ func (PortForward) Header(ns string) HeaderRow { Header{Name: "URL"}, Header{Name: "C"}, Header{Name: "N"}, + Header{Name: "VALID", Wide: true}, Header{Name: "AGE", Decorator: AgeDecorator}, } } @@ -71,6 +72,7 @@ func (f PortForward) Render(o interface{}, gvr string, r *Row) error { UrlFor(pf.Config.Host, pf.Config.Path, ports[0]), asNum(pf.Config.C), asNum(pf.Config.N), + "", pf.Age(), } diff --git a/internal/render/pv.go b/internal/render/pv.go index ef0ae627..67742ff6 100644 --- a/internal/render/pv.go +++ b/internal/render/pv.go @@ -16,26 +16,26 @@ import ( type PersistentVolume struct{} // ColorerFunc colors a resource row. -func (PersistentVolume) ColorerFunc() ColorerFunc { - return func(ns string, r RowEvent) tcell.Color { - c := DefaultColorer(ns, r) - if r.Kind == EventAdd || r.Kind == EventUpdate { +func (p PersistentVolume) ColorerFunc() ColorerFunc { + return func(ns string, re RowEvent) tcell.Color { + c := DefaultColorer(ns, re) + if re.Kind == EventAdd || re.Kind == EventUpdate { return c } - status := strings.TrimSpace(r.Row.Fields[4]) - switch status { + if !Happy(ns, re.Row) { + return ErrColor + } + + switch strings.TrimSpace(re.Row.Fields[4]) { case "Bound": c = StdColor case "Available": c = tcell.ColorYellow - default: - c = ErrColor } return c } - } // Header returns a header rbw. @@ -49,6 +49,9 @@ func (PersistentVolume) Header(string) HeaderRow { Header{Name: "CLAIM"}, Header{Name: "STORAGECLASS"}, Header{Name: "REASON"}, + Header{Name: "VOLUMEMODE", Wide: true}, + Header{Name: "LABELS", Wide: true}, + Header{Name: "VALID", Wide: true}, Header{Name: "AGE", Decorator: AgeDecorator}, } } @@ -90,12 +93,30 @@ func (p PersistentVolume) Render(o interface{}, ns string, r *Row) error { claim, class, pv.Status.Reason, + p.volumeMode(pv.Spec.VolumeMode), + mapToStr(pv.Labels), + asStatus(p.diagnose(string(phase))), toAge(pv.ObjectMeta.CreationTimestamp), } return nil } +func (PersistentVolume) diagnose(r string) error { + if r != "Bound" && r != "Available" { + return fmt.Errorf("unexpected status %s", r) + } + return nil +} + +func (PersistentVolume) volumeMode(m *v1.PersistentVolumeMode) string { + if m == nil { + return MissingValue + } + + return string(*m) +} + // ---------------------------------------------------------------------------- // Helpers... diff --git a/internal/render/pvc.go b/internal/render/pvc.go index c6ce1484..2b10a867 100644 --- a/internal/render/pvc.go +++ b/internal/render/pvc.go @@ -2,7 +2,6 @@ package render import ( "fmt" - "strings" "github.com/derailed/k9s/internal/client" "github.com/gdamore/tcell" @@ -15,19 +14,14 @@ import ( type PersistentVolumeClaim struct{} // ColorerFunc colors a resource row. -func (PersistentVolumeClaim) ColorerFunc() ColorerFunc { - return func(ns string, r RowEvent) tcell.Color { - c := DefaultColorer(ns, r) - if r.Kind == EventAdd || r.Kind == EventUpdate { +func (p PersistentVolumeClaim) ColorerFunc() ColorerFunc { + return func(ns string, re RowEvent) tcell.Color { + c := DefaultColorer(ns, re) + if re.Kind == EventAdd || re.Kind == EventUpdate { return c } - - markCol := 2 - if !client.IsAllNamespaces(ns) { - markCol-- - } - if strings.TrimSpace(r.Row.Fields[markCol]) != "Bound" { - c = ErrColor + if !Happy(ns, re.Row) { + return ErrColor } return c @@ -49,6 +43,8 @@ func (PersistentVolumeClaim) Header(ns string) HeaderRow { Header{Name: "CAPACITY"}, Header{Name: "ACCESS MODES"}, Header{Name: "STORAGECLASS"}, + Header{Name: "LABELS", Wide: true}, + Header{Name: "VALID", Wide: true}, Header{Name: "AGE", Decorator: AgeDecorator}, ) } @@ -96,8 +92,17 @@ func (p PersistentVolumeClaim) Render(o interface{}, ns string, r *Row) error { capacity, accessModes, class, + mapToStr(pvc.Labels), + asStatus(p.diagnose(string(phase))), toAge(pvc.ObjectMeta.CreationTimestamp), ) return nil } + +func (PersistentVolumeClaim) diagnose(r string) error { + if r != "Bound" && r != "Available" { + return fmt.Errorf("unexpected status %s", r) + } + return nil +} diff --git a/internal/render/rbac.go b/internal/render/rbac.go index 438ef473..93b6fd85 100644 --- a/internal/render/rbac.go +++ b/internal/render/rbac.go @@ -46,7 +46,10 @@ func (Rbac) Header(ns string) HeaderRow { Header{Name: "API GROUP"}, } - return append(h, rbacVerbHeader()...) + h = append(h, rbacVerbHeader()...) + h = append(h, Header{Name: "VALID", Wide: true}) + + return h } // Render renders a K8s resource to screen. @@ -57,8 +60,12 @@ func (Rbac) Render(o interface{}, gvr string, r *Row) error { } r.ID = p.Resource - r.Fields = append(r.Fields, cleanseResource(p.Resource), p.Group) + r.Fields = append(r.Fields, + cleanseResource(p.Resource), + p.Group, + ) r.Fields = append(r.Fields, asVerbs(p.Verbs)...) + r.Fields = append(r.Fields, "") return nil } diff --git a/internal/render/ro.go b/internal/render/ro.go index c388889e..1e54bb81 100644 --- a/internal/render/ro.go +++ b/internal/render/ro.go @@ -26,6 +26,8 @@ func (Role) Header(ns string) HeaderRow { return append(h, Header{Name: "NAME"}, + Header{Name: "LABELS", Wide: true}, + Header{Name: "VALID", Wide: true}, Header{Name: "AGE", Decorator: AgeDecorator}, ) } @@ -49,6 +51,8 @@ func (r Role) Render(o interface{}, ns string, row *Row) error { } row.Fields = append(row.Fields, ro.Name, + mapToStr(ro.Labels), + "", toAge(ro.ObjectMeta.CreationTimestamp), ) diff --git a/internal/render/rob.go b/internal/render/rob.go index 6777c090..e55b01df 100644 --- a/internal/render/rob.go +++ b/internal/render/rob.go @@ -30,6 +30,8 @@ func (RoleBinding) Header(ns string) HeaderRow { Header{Name: "ROLE"}, Header{Name: "KIND"}, Header{Name: "SUBJECTS"}, + Header{Name: "LABELS", Wide: true}, + Header{Name: "VALID", Wide: true}, Header{Name: "AGE", Decorator: AgeDecorator}, ) } @@ -58,6 +60,8 @@ func (r RoleBinding) Render(o interface{}, ns string, row *Row) error { rb.RoleRef.Name, kind, ss, + mapToStr(rb.Labels), + "", toAge(rb.ObjectMeta.CreationTimestamp), ) @@ -87,11 +91,11 @@ func toSubjectAlias(s string) string { switch s { case rbacv1.UserKind: - return "USR" + return "User" case rbacv1.GroupKind: - return "GRP" + return "Group" case rbacv1.ServiceAccountKind: - return "SA" + return "SvcAcct" default: return strings.ToUpper(s) } diff --git a/internal/render/rob_test.go b/internal/render/rob_test.go index 66afcf6a..26a5c138 100644 --- a/internal/render/rob_test.go +++ b/internal/render/rob_test.go @@ -13,5 +13,5 @@ func TestRoleBindingRender(t *testing.T) { c.Render(load(t, "rb"), "", &r) assert.Equal(t, "default/blee", r.ID) - assert.Equal(t, render.Fields{"default", "blee", "blee", "SA", "fernand"}, r.Fields[:5]) + assert.Equal(t, render.Fields{"default", "blee", "blee", "SvcAcct", "fernand"}, r.Fields[:5]) } diff --git a/internal/render/row.go b/internal/render/row.go index 4076b866..dde5cbe1 100644 --- a/internal/render/row.go +++ b/internal/render/row.go @@ -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. diff --git a/internal/render/row_event.go b/internal/render/row_event.go index 5b607c69..c2daae04 100644 --- a/internal/render/row_event.go +++ b/internal/render/row_event.go @@ -181,7 +181,7 @@ func (rr RowEvents) Sort(ns string, col int, asc bool) { func toAgeDuration(dur string) string { d, err := time.ParseDuration(dur) if err != nil { - return "n/a" + return dur } return duration.HumanDuration(d) } diff --git a/internal/render/row_header.go b/internal/render/row_header.go index 4b34ea1a..4bdd87d6 100644 --- a/internal/render/row_header.go +++ b/internal/render/row_header.go @@ -9,6 +9,8 @@ type Header struct { Name string Align int Decorator DecoratorFunc + Hide bool + Wide bool } // Clone copies a header. @@ -56,13 +58,7 @@ func (hh HeaderRow) Columns() []string { // HasAge returns true if table has an age column. func (hh HeaderRow) HasAge() bool { - for _, r := range hh { - if r.Name == ageCol { - return true - } - } - - return false + return hh.IndexOf(ageCol) != -1 } // AgeCol checks if given column index is the age column. @@ -72,3 +68,18 @@ func (hh HeaderRow) AgeCol(col int) bool { } return col == len(hh)-1 } + +// ValidColIndex returns the valid col index or -1 if none. +func (hh HeaderRow) ValidColIndex() int { + return hh.IndexOf("VALID") +} + +// IndexOf returns the col index or -1 if none. +func (hh HeaderRow) IndexOf(c string) int { + for i, h := range hh { + if h.Name == c { + return i + } + } + return -1 +} diff --git a/internal/render/rs.go b/internal/render/rs.go index 5b38fcf1..df0ed8c7 100644 --- a/internal/render/rs.go +++ b/internal/render/rs.go @@ -3,7 +3,6 @@ package render import ( "fmt" "strconv" - "strings" "github.com/derailed/k9s/internal/client" "github.com/derailed/tview" @@ -17,24 +16,19 @@ import ( type ReplicaSet struct{} // ColorerFunc colors a resource row. -func (ReplicaSet) ColorerFunc() ColorerFunc { - return func(ns string, r RowEvent) tcell.Color { - c := DefaultColorer(ns, r) - if r.Kind == EventAdd || r.Kind == EventUpdate { +func (r ReplicaSet) ColorerFunc() ColorerFunc { + return func(ns string, re RowEvent) tcell.Color { + c := DefaultColorer(ns, re) + if re.Kind == EventAdd || re.Kind == EventUpdate { return c } - markCol := 2 - if !client.IsAllNamespaces(ns) { - markCol-- - } - if strings.TrimSpace(r.Row.Fields[markCol]) != strings.TrimSpace(r.Row.Fields[markCol+1]) { + if !Happy(ns, re.Row) { return ErrColor } return StdColor } - } // Header returns a header row. @@ -49,12 +43,14 @@ func (ReplicaSet) Header(ns string) HeaderRow { Header{Name: "DESIRED", Align: tview.AlignRight}, Header{Name: "CURRENT", Align: tview.AlignRight}, Header{Name: "READY", Align: tview.AlignRight}, + Header{Name: "LABELS", Wide: true}, + Header{Name: "VALID", Wide: true}, Header{Name: "AGE", Decorator: AgeDecorator}, ) } // Render renders a K8s resource to screen. -func (s ReplicaSet) Render(o interface{}, ns string, r *Row) error { +func (r ReplicaSet) Render(o interface{}, ns string, row *Row) error { raw, ok := o.(*unstructured.Unstructured) if !ok { return fmt.Errorf("Expected ReplicaSet, but got %T", o) @@ -65,18 +61,31 @@ func (s ReplicaSet) Render(o interface{}, ns string, r *Row) error { return err } - r.ID = client.MetaFQN(rs.ObjectMeta) - r.Fields = make(Fields, 0, len(s.Header(ns))) + row.ID = client.MetaFQN(rs.ObjectMeta) + row.Fields = make(Fields, 0, len(r.Header(ns))) if client.IsAllNamespaces(ns) { - r.Fields = append(r.Fields, rs.Namespace) + row.Fields = append(row.Fields, rs.Namespace) } - r.Fields = append(r.Fields, + row.Fields = append(row.Fields, rs.Name, strconv.Itoa(int(*rs.Spec.Replicas)), strconv.Itoa(int(rs.Status.Replicas)), strconv.Itoa(int(rs.Status.ReadyReplicas)), + mapToStr(rs.Labels), + asStatus(r.diagnose(rs)), toAge(rs.ObjectMeta.CreationTimestamp), ) return nil } + +func (ReplicaSet) diagnose(rs appsv1.ReplicaSet) error { + if rs.Status.Replicas != rs.Status.ReadyReplicas { + if rs.Status.Replicas == 0 { + return fmt.Errorf("did not phase down correctly expecting 0 replicas but got %d", rs.Status.ReadyReplicas) + } + return fmt.Errorf("mismatch desired(%d) vs ready(%d)", rs.Status.Replicas, rs.Status.ReadyReplicas) + } + + return nil +} diff --git a/internal/render/sa.go b/internal/render/sa.go index eafffe7a..d0900374 100644 --- a/internal/render/sa.go +++ b/internal/render/sa.go @@ -28,6 +28,8 @@ func (ServiceAccount) Header(ns string) HeaderRow { return append(h, Header{Name: "NAME"}, Header{Name: "SECRET"}, + Header{Name: "LABELS", Wide: true}, + Header{Name: "VALID", Wide: true}, Header{Name: "AGE", Decorator: AgeDecorator}, ) } @@ -52,6 +54,8 @@ func (s ServiceAccount) Render(o interface{}, ns string, r *Row) error { r.Fields = append(r.Fields, sa.Name, strconv.Itoa(len(sa.Secrets)), + mapToStr(sa.Labels), + "", toAge(sa.ObjectMeta.CreationTimestamp), ) diff --git a/internal/render/sc.go b/internal/render/sc.go index 4c43fbc0..00ab82e0 100644 --- a/internal/render/sc.go +++ b/internal/render/sc.go @@ -22,6 +22,8 @@ func (StorageClass) Header(ns string) HeaderRow { return HeaderRow{ Header{Name: "NAME"}, Header{Name: "PROVISIONER"}, + Header{Name: "LABELS", Wide: true}, + Header{Name: "VALID", Wide: true}, Header{Name: "AGE", Decorator: AgeDecorator}, } } @@ -42,6 +44,8 @@ func (StorageClass) Render(o interface{}, ns string, r *Row) error { r.Fields = Fields{ sc.Name, string(sc.Provisioner), + mapToStr(sc.Labels), + "", toAge(sc.ObjectMeta.CreationTimestamp), } diff --git a/internal/render/screen_dump.go b/internal/render/screen_dump.go index ce1d4a69..5d81b068 100644 --- a/internal/render/screen_dump.go +++ b/internal/render/screen_dump.go @@ -33,6 +33,8 @@ var AgeDecorator = func(a string) string { func (ScreenDump) Header(ns string) HeaderRow { return HeaderRow{ Header{Name: "NAME"}, + Header{Name: "DIR"}, + Header{Name: "VALID", Wide: true}, Header{Name: "AGE", Decorator: AgeDecorator}, } } @@ -47,6 +49,8 @@ func (b ScreenDump) Render(o interface{}, ns string, r *Row) error { r.ID = filepath.Join(f.Dir, f.File.Name()) r.Fields = Fields{ f.File.Name(), + f.Dir, + "", timeToAge(f.File.ModTime()), } diff --git a/internal/render/screen_dump_test.go b/internal/render/screen_dump_test.go index ce6413ab..7dfad7f5 100644 --- a/internal/render/screen_dump_test.go +++ b/internal/render/screen_dump_test.go @@ -21,6 +21,8 @@ func TestScreenDumpRender(t *testing.T) { assert.Equal(t, "fred/blee/bob", r.ID) assert.Equal(t, render.Fields{ "bob", + "fred/blee", + "", }, r.Fields[:len(r.Fields)-1]) } diff --git a/internal/render/sts.go b/internal/render/sts.go index 30038c83..02dcc8c9 100644 --- a/internal/render/sts.go +++ b/internal/render/sts.go @@ -3,7 +3,6 @@ package render import ( "fmt" "strconv" - "strings" "github.com/derailed/k9s/internal/client" "github.com/gdamore/tcell" @@ -16,20 +15,13 @@ import ( type StatefulSet struct{} // ColorerFunc colors a resource row. -func (StatefulSet) ColorerFunc() ColorerFunc { - return func(ns string, r RowEvent) tcell.Color { - c := DefaultColorer(ns, r) - if r.Kind == EventAdd || r.Kind == EventUpdate { +func (s StatefulSet) ColorerFunc() ColorerFunc { + return func(ns string, re RowEvent) tcell.Color { + c := DefaultColorer(ns, re) + if re.Kind == EventAdd || re.Kind == EventUpdate { return c } - - readyCol := 2 - if !client.IsAllNamespaces(ns) { - readyCol-- - } - tokens := strings.Split(strings.TrimSpace(r.Row.Fields[readyCol]), "/") - curr, des := tokens[0], tokens[1] - if curr != des { + if !Happy(ns, re.Row) { return ErrColor } @@ -47,8 +39,12 @@ func (StatefulSet) Header(ns string) HeaderRow { return append(h, Header{Name: "NAME"}, Header{Name: "READY"}, - Header{Name: "SELECTOR"}, + Header{Name: "SELECTOR", Wide: true}, Header{Name: "SERVICE"}, + Header{Name: "CONTAINERS", Wide: true}, + Header{Name: "IMAGES", Wide: true}, + Header{Name: "LABELS", Wide: true}, + Header{Name: "VALID", Wide: true}, Header{Name: "AGE", Decorator: AgeDecorator}, ) } @@ -72,11 +68,22 @@ func (s StatefulSet) Render(o interface{}, ns string, r *Row) error { } r.Fields = append(r.Fields, sts.Name, - strconv.Itoa(int(sts.Status.Replicas))+"/"+strconv.Itoa(int(*sts.Spec.Replicas)), + strconv.Itoa(int(sts.Status.ReadyReplicas))+"/"+strconv.Itoa(int(sts.Status.Replicas)), asSelector(sts.Spec.Selector), na(sts.Spec.ServiceName), + podContainerNames(sts.Spec.Template.Spec, true), + podImageNames(sts.Spec.Template.Spec, true), + mapToStr(sts.Labels), + asStatus(s.diagnose(sts.Status.Replicas, sts.Status.ReadyReplicas)), toAge(sts.ObjectMeta.CreationTimestamp), ) return nil } + +func (StatefulSet) diagnose(d, r int32) error { + if d != r { + return fmt.Errorf("desiring %d replicas got %d available", d, r) + } + return nil +} diff --git a/internal/render/sts_test.go b/internal/render/sts_test.go index 6fe8e4ae..600daa93 100644 --- a/internal/render/sts_test.go +++ b/internal/render/sts_test.go @@ -13,5 +13,5 @@ func TestStatefulSetRender(t *testing.T) { assert.Nil(t, c.Render(load(t, "sts"), "", &r)) assert.Equal(t, "default/nginx-sts", r.ID) - assert.Equal(t, render.Fields{"default", "nginx-sts", "4/4", "app=nginx-sts", "nginx-sts"}, r.Fields[:len(r.Fields)-1]) + assert.Equal(t, render.Fields{"default", "nginx-sts", "4/4", "app=nginx-sts", "nginx-sts", "nginx", "k8s.gcr.io/nginx-slim:0.8", "app=nginx-sts", ""}, r.Fields[:len(r.Fields)-1]) } diff --git a/internal/render/subject.go b/internal/render/subject.go index 5c1e0f83..dae66c77 100644 --- a/internal/render/subject.go +++ b/internal/render/subject.go @@ -11,6 +11,11 @@ import ( // Subject renders a rbac to screen. type Subject struct{} +// Happy returns true if resoure is happy, false otherwise +func (Subject) Happy(_ string, _ Row) bool { + return true +} + // ColorerFunc colors a resource row. func (Subject) ColorerFunc() ColorerFunc { return func(ns string, re RowEvent) tcell.Color { @@ -24,6 +29,7 @@ func (Subject) Header(ns string) HeaderRow { Header{Name: "NAME"}, Header{Name: "KIND"}, Header{Name: "FIRST LOCATION"}, + Header{Name: "VALID", Wide: true}, } } @@ -40,6 +46,7 @@ func (s Subject) Render(o interface{}, ns string, r *Row) error { res.Name, res.Kind, res.FirstLocation, + "", ) return nil diff --git a/internal/render/svc.go b/internal/render/svc.go index 008f2d8d..f8411f55 100644 --- a/internal/render/svc.go +++ b/internal/render/svc.go @@ -32,8 +32,10 @@ func (Service) Header(ns string) HeaderRow { Header{Name: "TYPE"}, Header{Name: "CLUSTER-IP"}, Header{Name: "EXTERNAL-IP"}, - Header{Name: "SELECTOR"}, - Header{Name: "PORTS"}, + Header{Name: "SELECTOR", Wide: true}, + Header{Name: "PORTS", Wide: true}, + Header{Name: "LABELS", Wide: true}, + Header{Name: "VALID", Wide: true}, Header{Name: "AGE", Decorator: AgeDecorator}, ) } @@ -58,19 +60,32 @@ func (s Service) Render(o interface{}, ns string, r *Row) error { r.Fields = append(r.Fields, svc.ObjectMeta.Name, string(svc.Spec.Type), - svc.Spec.ClusterIP, + toIP(svc.Spec.ClusterIP), toIPs(svc.Spec.Type, getSvcExtIPS(&svc)), mapToStr(svc.Spec.Selector), toPorts(svc.Spec.Ports), + mapToStr(svc.Labels), + asStatus(s.diagnose()), toAge(svc.ObjectMeta.CreationTimestamp), ) return nil } +func (Service) diagnose() error { + return nil +} + // ---------------------------------------------------------------------------- // Helpers... +func toIP(ip string) string { + if ip == "" || ip == "None" { + return "" + } + return ip +} + func getSvcExtIPS(svc *v1.Service) []string { results := []string{} @@ -116,7 +131,7 @@ func toIPs(svcType v1.ServiceType, ips []string) string { if svcType == v1.ServiceTypeLoadBalancer { return "" } - return MissingValue + return "" } sort.Strings(ips) diff --git a/internal/render/svc_test.go b/internal/render/svc_test.go index b74bdf64..63c1c433 100644 --- a/internal/render/svc_test.go +++ b/internal/render/svc_test.go @@ -13,5 +13,5 @@ func TestServiceRender(t *testing.T) { c.Render(load(t, "svc"), "", &r) assert.Equal(t, "default/dictionary1", r.ID) - assert.Equal(t, render.Fields{"default", "dictionary1", "ClusterIP", "10.47.248.116", "", "app=dictionary1", "http:4001►0"}, r.Fields[:7]) + assert.Equal(t, render.Fields{"default", "dictionary1", "ClusterIP", "10.47.248.116", "", "app=dictionary1", "http:4001►0"}, r.Fields[:7]) } diff --git a/internal/tchart/component.go b/internal/tchart/component.go new file mode 100644 index 00000000..42733082 --- /dev/null +++ b/internal/tchart/component.go @@ -0,0 +1,128 @@ +package tchart + +import ( + "image" + "sync" + + "github.com/derailed/tview" + "github.com/gdamore/tcell" + "github.com/rs/zerolog/log" +) + +const ( + okColor, faultColor = tcell.ColorPaleGreen, tcell.ColorOrangeRed + okColorName, faultColorName = "palegreen", "orangered" +) + +// Component represents a graphic component. +type Component struct { + *tview.Box + + bgColor, noColor tcell.Color + seriesColors []tcell.Color + dimmed tcell.Style + id, legend string + blur func(tcell.Key) + mx sync.RWMutex +} + +// NewComponent returns a new component. +func NewComponent(id string) *Component { + return &Component{ + Box: tview.NewBox(), + id: id, + noColor: tcell.ColorDefault, + seriesColors: []tcell.Color{tview.Styles.PrimaryTextColor, tview.Styles.FocusColor}, + dimmed: tcell.StyleDefault.Background(tview.Styles.PrimitiveBackgroundColor).Foreground(tcell.ColorGray).Dim(true), + } +} + +// SetBackgroundColor sets the graph bg color. +func (c *Component) SetBackgroundColor(color tcell.Color) { + c.Box.SetBackgroundColor(color) + c.bgColor = color + c.dimmed = c.dimmed.Background(color) +} + +// ID returns the component ID. +func (c *Component) ID() string { + return c.id +} + +// SetLegend sets the component legend. +func (c *Component) SetLegend(l string) { + c.mx.Lock() + defer c.mx.Unlock() + c.legend = l +} + +// InputHandler returns the handler for this primitive. +func (c *Component) InputHandler() func(event *tcell.EventKey, setFocus func(p tview.Primitive)) { + return c.WrapInputHandler(func(event *tcell.EventKey, setFocus func(p tview.Primitive)) { + switch key := event.Key(); key { + case tcell.KeyEnter: + log.Debug().Msgf("YO %s ENTER!!", c.id) + case tcell.KeyBacktab, tcell.KeyTab: + log.Debug().Msgf("YO %s TAB!!", c.id) + if c.blur != nil { + c.blur(key) + } + setFocus(c) + } + }) +} + +// IsDial returns true if chart is a dial +func (c *Component) IsDial() bool { + return false +} + +// SetBlurFunc sets a callback fn when component gets out of focus. +func (c *Component) SetBlurFunc(handler func(key tcell.Key)) *Component { + c.blur = handler + return c +} + +// SetSeriesColors sets the component series colors. +func (c *Component) SetSeriesColors(cc ...tcell.Color) { + c.mx.Lock() + defer c.mx.Unlock() + c.seriesColors = cc +} + +// GetSeriesColorNames returns series colors by name. +func (c *Component) GetSeriesColorNames() []string { + c.mx.RLock() + defer c.mx.RUnlock() + + var nn []string + for _, color := range c.seriesColors { + for name, co := range tcell.ColorNames { + if co == color { + nn = append(nn, name) + } + } + } + if len(nn) < 2 { + nn = append(nn, okColorName, faultColorName) + } + + return nn +} + +func (c *Component) colorForSeries() (tcell.Color, tcell.Color) { + c.mx.RLock() + defer c.mx.RUnlock() + if len(c.seriesColors) > 1 { + return c.seriesColors[0], c.seriesColors[1] + } + return okColor, faultColor +} + +func (c *Component) asRect() image.Rectangle { + x, y, width, height := c.GetInnerRect() + return image.Rectangle{ + Min: image.Point{X: x, Y: y}, + Max: image.Point{X: x + width, Y: y + height}, + } +} diff --git a/internal/tchart/dot_matrix.go b/internal/tchart/dot_matrix.go new file mode 100644 index 00000000..7e9399fb --- /dev/null +++ b/internal/tchart/dot_matrix.go @@ -0,0 +1,112 @@ +package tchart + +import ( + "fmt" +) + +var dots = []rune{' ', '⠂', '▤', '▥'} + +// Segment represents a dial segment. +type Segment []int + +// Segments represents a collection of segments. +type Segments []Segment + +// Matrix represents a number dial. +type Matrix [][]rune + +// Orientation tracks char orientations. +type Orientation int + +// DotMatrix tracks a char matrix. +type DotMatrix struct { + row, col int +} + +// NewDotMatrix returns a new matrix. +func NewDotMatrix(row, col int) DotMatrix { + return DotMatrix{ + row: row, + col: col, + } +} + +// Print prints the matrix. +func (d DotMatrix) Print(n int) Matrix { + m := make(Matrix, d.row) + segs := asSegments(n) + for row := 0; row < d.row; row++ { + for col := 0; col < d.col; col++ { + m[row] = append(m[row], segs.CharFor(row, col)) + } + } + return m +} + +func asSegments(n int) Segment { + switch n { + case 0: + return Segment{1, 1, 1, 0, 1, 1, 1} + case 1: + return Segment{0, 0, 1, 0, 0, 1, 0} + case 2: + return Segment{1, 0, 1, 1, 1, 0, 1} + case 3: + return Segment{1, 0, 1, 1, 0, 1, 1} + case 4: + return Segment{0, 1, 0, 1, 0, 1, 0} + case 5: + return Segment{1, 1, 0, 1, 0, 1, 1} + case 6: + return Segment{0, 1, 0, 1, 1, 1, 1} + case 7: + return Segment{1, 0, 1, 0, 0, 1, 0} + case 8: + return Segment{1, 1, 1, 1, 1, 1, 1} + case 9: + return Segment{1, 1, 1, 1, 0, 1, 0} + + default: + panic(fmt.Sprintf("NYI %d", n)) + } +} + +// CharFor return a char based on row/col. +func (s Segment) CharFor(row, col int) rune { + c := ' ' + segs := ToSegments(row, col) + if segs == nil { + return c + } + for _, seg := range segs { + if s[seg] == 1 { + c = charForSeg(seg, row, col) + } + } + return c +} + +func charForSeg(seg, row, col int) rune { + switch seg { + case 0, 3, 6: + return dots[2] + } + if row == 0 && (col == 0 || col == 2) { + return dots[2] + } + + return dots[3] +} + +var segs = map[int][][]int{ + 0: {{1, 0}, {0}, {2, 0}}, + 1: {{1}, nil, {2}}, + 2: {{1, 3}, {3}, {2, 3}}, + 3: {{4}, nil, {5}}, + 4: {{4, 6}, {6}, {5, 6}}, +} + +// ToSegments return path segments. +func ToSegments(row, col int) []int { + return segs[row][col] +} diff --git a/internal/tchart/dot_matrix_test.go b/internal/tchart/dot_matrix_test.go new file mode 100644 index 00000000..e3e8e122 --- /dev/null +++ b/internal/tchart/dot_matrix_test.go @@ -0,0 +1,126 @@ +package tchart_test + +import ( + "strconv" + "testing" + + "github.com/derailed/k9s/internal/tchart" + "github.com/stretchr/testify/assert" +) + +func TestSegmentFor(t *testing.T) { + uu := map[string]struct { + r, c int + e []int + }{ + "0x0": {r: 0, c: 0, e: []int{1, 0}}, + "0x1": {r: 0, c: 1, e: []int{0}}, + "0x2": {r: 0, c: 2, e: []int{2, 0}}, + "1x0": {r: 1, c: 0, e: []int{1}}, + "1x1": {r: 1, c: 1, e: nil}, + "1x2": {r: 1, c: 2, e: []int{2}}, + "2x0": {r: 2, c: 0, e: []int{1, 3}}, + "2x1": {r: 2, c: 1, e: []int{3}}, + "2x2": {r: 2, c: 2, e: []int{2, 3}}, + "3x0": {r: 3, c: 0, e: []int{4}}, + "3x1": {r: 3, c: 1, e: nil}, + "3x2": {r: 3, c: 2, e: []int{5}}, + "4x0": {r: 4, c: 0, e: []int{4, 6}}, + "4x1": {r: 4, c: 1, e: []int{6}}, + "4x2": {r: 4, c: 2, e: []int{5, 6}}, + } + + for k := range uu { + u := uu[k] + t.Run(k, func(t *testing.T) { + assert.Equal(t, u.e, tchart.ToSegments(u.r, u.c)) + }) + } +} + +func TestDial(t *testing.T) { + d := tchart.NewDotMatrix(5, 3) + for n := 0; n <= 9; n++ { + i := n + t.Run(strconv.Itoa(n), func(t *testing.T) { + assert.Equal(t, numbers[i], d.Print(i)) + }) + } +} + +// Helpers... + +const hChar, vChar = '▤', '▥' + +var numbers = []tchart.Matrix{ + [][]rune{ + {hChar, hChar, hChar}, + {vChar, ' ', vChar}, + {vChar, ' ', vChar}, + {vChar, ' ', vChar}, + {hChar, hChar, hChar}, + }, + [][]rune{ + {' ', ' ', hChar}, + {' ', ' ', vChar}, + {' ', ' ', vChar}, + {' ', ' ', vChar}, + {' ', ' ', vChar}, + }, + [][]rune{ + {hChar, hChar, hChar}, + {' ', ' ', vChar}, + {hChar, hChar, hChar}, + {vChar, ' ', ' '}, + {hChar, hChar, hChar}, + }, + [][]rune{ + {hChar, hChar, hChar}, + {' ', ' ', vChar}, + {hChar, hChar, hChar}, + {' ', ' ', vChar}, + {hChar, hChar, hChar}, + }, + [][]rune{ + {hChar, ' ', ' '}, + {vChar, ' ', ' '}, + {hChar, hChar, hChar}, + {' ', ' ', vChar}, + {' ', ' ', vChar}, + }, + [][]rune{ + {hChar, hChar, hChar}, + {vChar, ' ', ' '}, + {hChar, hChar, hChar}, + {' ', ' ', vChar}, + {hChar, hChar, hChar}, + }, + [][]rune{ + {hChar, ' ', ' '}, + {vChar, ' ', ' '}, + {hChar, hChar, hChar}, + {vChar, ' ', vChar}, + {hChar, hChar, hChar}, + }, + [][]rune{ + {hChar, hChar, hChar}, + {' ', ' ', vChar}, + {' ', ' ', vChar}, + {' ', ' ', vChar}, + {' ', ' ', vChar}, + }, + [][]rune{ + {hChar, hChar, hChar}, + {vChar, ' ', vChar}, + {hChar, hChar, hChar}, + {vChar, ' ', vChar}, + {hChar, hChar, hChar}, + }, + [][]rune{ + {hChar, hChar, hChar}, + {vChar, ' ', vChar}, + {hChar, hChar, hChar}, + {' ', ' ', vChar}, + {' ', ' ', vChar}, + }, +} diff --git a/internal/tchart/gauge.go b/internal/tchart/gauge.go new file mode 100644 index 00000000..f019b4d2 --- /dev/null +++ b/internal/tchart/gauge.go @@ -0,0 +1,159 @@ +package tchart + +import ( + "fmt" + "image" + + "github.com/derailed/tview" + "github.com/gdamore/tcell" +) + +const ( + // DeltaSame represents no difference. + DeltaSame delta = iota + + // DeltaMore represents a higher value. + DeltaMore + + // DeltaLess represents a lower value. + DeltaLess + + gaugeFmt = "0%dd" +) + +type delta int + +// Gauge represents a gauge component. +type Gauge struct { + *Component + + data Metric + deltaOk, deltaFault delta +} + +// NewGauge returns a new gauge. +func NewGauge(id string) *Gauge { + return &Gauge{ + Component: NewComponent(id), + } +} + +// IsDial returns true if chart is a dial +func (g *Gauge) IsDial() bool { + return true +} + +// Add adds a new metric. +func (g *Gauge) Add(m Metric) { + g.mx.Lock() + defer g.mx.Unlock() + + g.deltaOk, g.deltaFault = computeDelta(g.data.OK, m.OK), computeDelta(g.data.Fault, m.Fault) + g.data = m +} + +func (g *Gauge) drawNum(sc tcell.Screen, ok bool, o image.Point, n int, dn delta, ns string, style tcell.Style) { + c1, _ := g.colorForSeries() + if ok { + o.X -= 1 + style = style.Foreground(c1) + printDelta(sc, dn, o, style) + o.X += 1 + } + + dm, sig := NewDotMatrix(5, 3), n == 0 + if n == 0 { + style = g.dimmed + } + for i := 0; i < len(ns); i++ { + if ns[i] == '0' && !sig { + g.drawDial(sc, dm.Print(int(ns[i]-48)), o, g.dimmed) + } else { + sig = true + g.drawDial(sc, dm.Print(int(ns[i]-48)), o, style) + } + o.X += 5 + } + if !ok { + printDelta(sc, dn, o, style) + } +} + +// Draw draws the primitive. +func (g *Gauge) Draw(sc tcell.Screen) { + g.Component.Draw(sc) + + g.mx.RLock() + defer g.mx.RUnlock() + + rect := g.asRect() + mid := image.Point{X: rect.Min.X + rect.Dx()/2 - 2, Y: rect.Min.Y + rect.Dy()/2 - 2} + + style := tcell.StyleDefault.Background(g.bgColor) + style = style.Foreground(tcell.ColorYellow) + sc.SetContent(mid.X+1, mid.Y+2, '⠔', nil, style) + + var ( + max = g.data.MaxDigits() + fmat = "%" + fmt.Sprintf(gaugeFmt, max) + o = image.Point{X: mid.X - 3, Y: mid.Y} + ) + + s1C, s2C := g.colorForSeries() + d1, d2 := fmt.Sprintf(fmat, g.data.OK), fmt.Sprintf(fmat, g.data.Fault) + o.X -= (len(d1) - 1) * 5 + g.drawNum(sc, true, o, g.data.OK, g.deltaOk, d1, style.Foreground(s1C).Dim(false)) + + o.X = mid.X + 3 + g.drawNum(sc, false, o, g.data.Fault, g.deltaFault, d2, style.Foreground(s2C).Dim(false)) + + if rect.Dx() > 0 && rect.Dy() > 0 && g.legend != "" { + legend := g.legend + if g.HasFocus() { + legend = "[:aqua]" + g.legend + "[::]" + } + tview.Print(sc, legend, rect.Min.X, rect.Max.Y, rect.Dx(), tview.AlignCenter, tcell.ColorWhite) + } +} + +func (g *Gauge) drawDial(sc tcell.Screen, m Matrix, o image.Point, style tcell.Style) { + for r := 0; r < len(m); r++ { + for c := 0; c < len(m[r]); c++ { + dot := m[r][c] + if dot == dots[0] { + sc.SetContent(o.X+c, o.Y+r, dots[1], nil, g.dimmed) + } else { + sc.SetContent(o.X+c, o.Y+r, dot, nil, style) + } + } + } +} + +// ---------------------------------------------------------------------------- +// Helpers... + +func computeDelta(d1, d2 int) delta { + if d2 == 0 { + return DeltaSame + } + + d := d2 - d1 + switch { + case d > 0: + return DeltaMore + case d < 0: + return DeltaLess + default: + return DeltaSame + } +} + +func printDelta(sc tcell.Screen, d delta, o image.Point, s tcell.Style) { + s = s.Dim(false) + switch d { + case DeltaLess: + sc.SetContent(o.X-1, o.Y+2, '↓', nil, s) + case DeltaMore: + sc.SetContent(o.X-1, o.Y+2, '↑', nil, s) + } +} diff --git a/internal/tchart/sparkline.go b/internal/tchart/sparkline.go new file mode 100644 index 00000000..5b157bc1 --- /dev/null +++ b/internal/tchart/sparkline.go @@ -0,0 +1,158 @@ +package tchart + +import ( + "fmt" + "math" + + "github.com/derailed/tview" + "github.com/gdamore/tcell" +) + +var sparks = []rune{'▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'} + +type block struct { + full int + partial rune +} + +type blocks struct { + oks, errs block +} + +// Metric tracks a good and error rates. +type Metric struct { + OK, Fault int +} + +// MaxDigits returns the max of the metric. +func (m Metric) MaxDigits() int { + max := int(math.Max(float64(m.OK), float64(m.Fault))) + s := fmt.Sprintf("%d", max) + return len(s) +} + +// Sum returns the sum of the metrics. +func (m Metric) Sum() int { + return m.OK + m.Fault +} + +// SparkLine represents a sparkline component. +type SparkLine struct { + *Component + + data []Metric +} + +// NewSparkLine returns a new graph. +func NewSparkLine(id string) *SparkLine { + return &SparkLine{ + Component: NewComponent(id), + } +} + +// Add adds a metric. +func (s *SparkLine) Add(m Metric) { + s.mx.Lock() + defer s.mx.Unlock() + s.data = append(s.data, m) +} + +// Draw draws the graph. +func (s *SparkLine) Draw(screen tcell.Screen) { + s.Component.Draw(screen) + + s.mx.RLock() + defer s.mx.RUnlock() + + if len(s.data) == 0 { + return + } + + pad := 1 + if s.legend != "" { + pad++ + } + + rect := s.asRect() + s.cutSet(rect.Dx()) + max := s.computeMax() + + cX, idx := rect.Min.X+1, 0 + if len(s.data)*2 < rect.Dx() { + cX = rect.Max.X - len(s.data)*2 + } else { + idx = len(s.data) - rect.Dx()/2 + } + + scale := float64(len(sparks)) * float64((rect.Dy() - pad)) / float64(max) + c1, c2 := s.colorForSeries() + for _, d := range s.data[idx:] { + b := toBlocks(d, scale) + cY := rect.Max.Y - pad + s.drawBlock(screen, cX, cY, b.oks, c1) + s.drawBlock(screen, cX, cY, b.errs, c2) + cX += 2 + } + + if rect.Dx() > 0 && rect.Dy() > 0 && s.legend != "" { + legend := s.legend + if s.HasFocus() { + legend = "[:aqua:]" + s.legend + "[::]" + } + tview.Print(screen, legend, rect.Min.X, rect.Max.Y, rect.Dx(), tview.AlignCenter, tcell.ColorWhite) + } +} + +func (s *SparkLine) drawBlock(screen tcell.Screen, x, y int, b block, c tcell.Color) { + style := tcell.StyleDefault.Foreground(c).Background(s.bgColor) + + for i := 0; i < b.full; i++ { + screen.SetContent(x, y, sparks[len(sparks)-1], nil, style) + y-- + } + if b.partial != 0 { + screen.SetContent(x, y, b.partial, nil, style) + } +} + +func (s *SparkLine) cutSet(width int) { + if width <= 0 || len(s.data) == 0 { + return + } + + if len(s.data) >= width*2 { + s.data = s.data[len(s.data)-width:] + } +} + +func (s *SparkLine) computeMax() int { + var max int + for _, d := range s.data { + if max < d.OK { + max = d.OK + } + } + + return max +} + +func toBlocks(m Metric, scale float64) blocks { + if m.Sum() <= 0 { + return blocks{} + } + return blocks{oks: makeBlocks(m.OK, false, scale), errs: makeBlocks(m.Fault, true, scale)} +} + +func makeBlocks(v int, isErr bool, scale float64) block { + scaled := int(math.Round(float64(v) * scale)) + part, b := scaled%len(sparks), block{full: scaled / len(sparks)} + // Err might get scaled way down if so nudge. + if v > 0 && isErr && scaled == 0 { + part = 1 + } + if part > 0 { + b.partial = sparks[part-1] + } + + return b +} diff --git a/internal/ui/app.go b/internal/ui/app.go index 54f2a833..efe187a7 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -3,6 +3,7 @@ package ui import ( "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/config" + "github.com/derailed/k9s/internal/model" "github.com/derailed/tview" "github.com/gdamore/tcell" "github.com/rs/zerolog/log" @@ -14,6 +15,7 @@ type App struct { Configurator Main *Pages + flash *model.Flash actions KeyActions views map[string]tview.Primitive cmdBuff *CmdBuff @@ -25,6 +27,7 @@ func NewApp(context string) *App { Application: tview.NewApplication(), actions: make(KeyActions), Main: NewPages(), + flash: model.NewFlash(model.DefaultFlashDelay), cmdBuff: NewCmdBuff(':', CommandBuff), } a.ReloadStyles(context) @@ -33,7 +36,6 @@ func NewApp(context string) *App { "menu": NewMenu(a.Styles), "logo": NewLogo(a.Styles), "cmd": NewCommand(a.Styles), - "flash": NewFlash(&a, "Initializing..."), "crumbs": NewCrumbs(a.Styles), } @@ -239,11 +241,6 @@ func (a *App) Logo() *Logo { return a.views["logo"].(*Logo) } -// Flash returns app flash. -func (a *App) Flash() *Flash { - return a.views["flash"].(*Flash) -} - // Cmd returns app cmd. func (a *App) Cmd() *Command { return a.views["cmd"].(*Command) @@ -254,6 +251,11 @@ func (a *App) Menu() *Menu { return a.views["menu"].(*Menu) } +// Flash returns a flash model. +func (a *App) Flash() *model.Flash { + return a.flash +} + // ---------------------------------------------------------------------------- // Helpers... diff --git a/internal/ui/app_test.go b/internal/ui/app_test.go index 4e189c59..2fdde772 100644 --- a/internal/ui/app_test.go +++ b/internal/ui/app_test.go @@ -59,7 +59,7 @@ func TestAppViews(t *testing.T) { a := ui.NewApp("") a.Init() - vv := []string{"crumbs", "logo", "cmd", "flash", "menu"} + vv := []string{"crumbs", "logo", "cmd", "menu"} for i := range vv { v := vv[i] t.Run(v, func(t *testing.T) { @@ -68,7 +68,6 @@ func TestAppViews(t *testing.T) { } assert.NotNil(t, a.Crumbs()) - assert.NotNil(t, a.Flash()) assert.NotNil(t, a.Logo()) assert.NotNil(t, a.Cmd()) assert.NotNil(t, a.Menu()) diff --git a/internal/ui/config.go b/internal/ui/config.go index 857179db..2620aea2 100644 --- a/internal/ui/config.go +++ b/internal/ui/config.go @@ -79,6 +79,8 @@ func (c *Configurator) RefreshStyles(context string) { clusterSkins := filepath.Join(config.K9sHome, fmt.Sprintf("%s_skin.yml", context)) if c.Styles == nil { c.Styles = config.NewStyles() + } else { + c.Styles.Reset() } if err := c.Styles.Load(clusterSkins); err != nil { log.Info().Msgf("No context specific skin file found -- %s", clusterSkins) @@ -102,10 +104,10 @@ func (c *Configurator) updateStyles(f string) { } c.Styles.Update() - render.StdColor = config.AsColor(c.Styles.Frame().Status.NewColor) - render.AddColor = config.AsColor(c.Styles.Frame().Status.AddColor) - render.ModColor = config.AsColor(c.Styles.Frame().Status.ModifyColor) - render.ErrColor = config.AsColor(c.Styles.Frame().Status.ErrorColor) - render.HighlightColor = config.AsColor(c.Styles.Frame().Status.HighlightColor) - render.CompletedColor = config.AsColor(c.Styles.Frame().Status.CompletedColor) + render.StdColor = c.Styles.Frame().Status.NewColor.Color() + render.AddColor = c.Styles.Frame().Status.AddColor.Color() + render.ModColor = c.Styles.Frame().Status.ModifyColor.Color() + render.ErrColor = c.Styles.Frame().Status.ErrorColor.Color() + render.HighlightColor = c.Styles.Frame().Status.HighlightColor.Color() + render.CompletedColor = c.Styles.Frame().Status.CompletedColor.Color() } diff --git a/internal/ui/flash.go b/internal/ui/flash.go index 956cb893..d401fe7e 100644 --- a/internal/ui/flash.go +++ b/internal/ui/flash.go @@ -2,67 +2,45 @@ package ui import ( "context" - "fmt" - "strings" - "time" "github.com/derailed/k9s/internal/config" - "github.com/derailed/k9s/internal/render" + "github.com/derailed/k9s/internal/model" "github.com/derailed/tview" "github.com/gdamore/tcell" "github.com/rs/zerolog/log" ) const ( - // FlashInfo represents an info message. - FlashInfo FlashLevel = iota - // FlashWarn represents an warning message. - FlashWarn - // FlashErr represents an error message. - FlashErr - // FlashFatal represents an fatal message. - FlashFatal - - flashDelay = 3 * time.Second - + emoHappy = "😎" emoDoh = "😗" emoRed = "😡" - emoDead = "💀" - emoHappy = "😎" ) -type ( - // FlashLevel represents flash message severity. - FlashLevel int +// Flash represents a flash message indicator. +type Flash struct { + *tview.TextView - // Flash represents a flash message indicator. - Flash struct { - *tview.TextView - - cancel context.CancelFunc - app *App - flushNow bool - } -) + app *App + testMode bool +} // NewFlash returns a new flash view. -func NewFlash(app *App, m string) *Flash { +func NewFlash(app *App) *Flash { f := Flash{ app: app, TextView: tview.NewTextView(), } f.SetTextColor(tcell.ColorAqua) - f.SetTextAlign(tview.AlignLeft) + f.SetTextAlign(tview.AlignCenter) f.SetBorderPadding(0, 0, 1, 1) - f.SetText(m) f.app.Styles.AddListener(&f) return &f } -// TestMode for testing... -func (f *Flash) TestMode() { - f.flushNow = true +// SetTestMode for testing ONLY! +func (f *Flash) SetTestMode(b bool) { + f.testMode = b } // StylesChanged notifies listener the skin changed. @@ -71,101 +49,54 @@ func (f *Flash) StylesChanged(s *config.Styles) { f.SetTextColor(s.FgColor()) } -// Info displays an info flash message. -func (f *Flash) Info(msg string) { - log.Info().Msg(msg) - f.SetMessage(FlashInfo, msg) -} - -// Infof displays a formatted info flash message. -func (f *Flash) Infof(fmat string, args ...interface{}) { - f.Info(fmt.Sprintf(fmat, args...)) -} - -// Warn displays a warning flash message. -func (f *Flash) Warn(msg string) { - log.Warn().Msg(msg) - f.SetMessage(FlashWarn, msg) -} - -// Warnf displays a formatted warning flash message. -func (f *Flash) Warnf(fmat string, args ...interface{}) { - f.Warn(fmt.Sprintf(fmat, args...)) -} - -// Err displays an error flash message. -func (f *Flash) Err(err error) { - log.Error().Msg(err.Error()) - f.SetMessage(FlashErr, err.Error()) -} - -// Errf displays a formatted error flash message. -func (f *Flash) Errf(fmat string, args ...interface{}) { - var err error - for _, a := range args { - switch e := a.(type) { - case error: - err = e +// Watch watches for flash changes. +func (f *Flash) Watch(ctx context.Context, c model.FlashChan) { + defer log.Debug().Msgf("Flash Canceled!") + for { + select { + case <-ctx.Done(): + return + case msg := <-c: + f.SetMessage(msg) } } - log.Error().Err(err).Msgf(fmat, args...) - f.SetMessage(FlashErr, fmt.Sprintf(fmat, args...)) } // SetMessage sets flash message and level. -func (f *Flash) SetMessage(level FlashLevel, msg ...string) { - if f.cancel != nil { - f.cancel() +func (f *Flash) SetMessage(m model.LevelMessage) { + fn := func() { + if m.Text == "" { + f.Clear() + return + } + f.SetTextColor(flashColor(m.Level)) + f.SetText(flashEmoji(m.Level) + " " + m.Text) } - _, _, width, _ := f.GetRect() - if width <= 15 { - width = 100 - } - m := strings.Join(msg, " ") - if f.flushNow { - f.SetTextColor(flashColor(level)) - f.SetText(render.Truncate(flashEmoji(level)+" "+m, width-3)) + if f.testMode { + fn() } else { - f.app.QueueUpdateDraw(func() { - f.SetTextColor(flashColor(level)) - f.SetText(render.Truncate(flashEmoji(level)+" "+m, width-3)) - }) + f.app.QueueUpdateDraw(fn) } - - var ctx context.Context - ctx, f.cancel = context.WithTimeout(context.Background(), flashDelay) - go f.refresh(ctx) } -func (f *Flash) refresh(ctx context.Context) { - <-ctx.Done() - f.app.QueueUpdateDraw(func() { - f.Clear() - }) -} - -func flashEmoji(l FlashLevel) string { +func flashEmoji(l model.FlashLevel) string { switch l { - case FlashWarn: + case model.FlashWarn: return emoDoh - case FlashErr: + case model.FlashErr: return emoRed - case FlashFatal: - return emoDead default: return emoHappy } } -func flashColor(l FlashLevel) tcell.Color { +func flashColor(l model.FlashLevel) tcell.Color { switch l { - case FlashWarn: + case model.FlashWarn: return tcell.ColorOrange - case FlashErr: + case model.FlashErr: return tcell.ColorOrangeRed - case FlashFatal: - return tcell.ColorFuchsia default: return tcell.ColorNavajoWhite } diff --git a/internal/ui/flash_test.go b/internal/ui/flash_test.go index c158f166..e46c591d 100644 --- a/internal/ui/flash_test.go +++ b/internal/ui/flash_test.go @@ -1,45 +1,40 @@ package ui_test import ( - "errors" + "context" "testing" + "time" + "github.com/derailed/k9s/internal/model" "github.com/derailed/k9s/internal/ui" "github.com/stretchr/testify/assert" ) -func TestFlashInfo(t *testing.T) { - f := newFlash() - f.Info("Blee") +func TestFlash(t *testing.T) { + const delay = 1 * time.Millisecond + uu := map[string]struct { + l model.FlashLevel + i, e string + }{ + "info": {l: model.FlashInfo, i: "hello", e: "😎 hello\n"}, + "warn": {l: model.FlashWarn, i: "hello", e: "😗 hello\n"}, + "err": {l: model.FlashErr, i: "hello", e: "😡 hello\n"}, + } - assert.Equal(t, "😎 Blee\n", f.GetText(false)) - f.Infof("Blee %s", "duh") - assert.Equal(t, "😎 Blee duh\n", f.GetText(false)) -} - -func TestFlashWarn(t *testing.T) { - f := newFlash() - f.Warn("Blee") - - assert.Equal(t, "😗 Blee\n", f.GetText(false)) - f.Warnf("Blee %s", "duh") - assert.Equal(t, "😗 Blee duh\n", f.GetText(false)) -} - -func TestFlashErr(t *testing.T) { - f := newFlash() - - f.Err(errors.New("Blee")) - assert.Equal(t, "😡 Blee\n", f.GetText(false)) - f.Errf("Blee %s", "duh") - assert.Equal(t, "😡 Blee duh\n", f.GetText(false)) -} - -// ---------------------------------------------------------------------------- -// Helpers... - -func newFlash() *ui.Flash { - f := ui.NewFlash(ui.NewApp(""), "YO!") - f.TestMode() - return f + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + a := ui.NewApp("test") + f := ui.NewFlash(a) + f.SetTestMode(true) + go f.Watch(ctx, a.Flash().Channel()) + + for k := range uu { + u := uu[k] + t.Run(k, func(t *testing.T) { + a.Flash().SetMessage(u.l, u.i) + time.Sleep(delay) + assert.Equal(t, u.e, f.GetText(false)) + }) + } } diff --git a/internal/ui/logo.go b/internal/ui/logo.go index e5f2ec9c..3bc0a83b 100644 --- a/internal/ui/logo.go +++ b/internal/ui/logo.go @@ -25,7 +25,7 @@ func NewLogo(styles *config.Styles) *Logo { } l.SetDirection(tview.FlexRow) l.AddItem(l.logo, 0, 6, false) - l.AddItem(l.status, 0, 1, false) + l.AddItem(l.status, 1, 0, false) l.refreshLogo(styles.Body().LogoColor) l.SetBackgroundColor(styles.BgColor()) styles.AddListener(&l) @@ -60,30 +60,30 @@ func (l *Logo) Reset() { // Err displays a log error state. func (l *Logo) Err(msg string) { - l.update(msg, "red") + l.update(msg, config.NewColor("red")) } // Warn displays a log warning state. func (l *Logo) Warn(msg string) { - l.update(msg, "mediumvioletred") + l.update(msg, config.NewColor("mediumvioletred")) } // Info displays a log info state. func (l *Logo) Info(msg string) { - l.update(msg, "green") + l.update(msg, config.NewColor("green")) } -func (l *Logo) update(msg, c string) { +func (l *Logo) update(msg string, c config.Color) { l.refreshStatus(msg, c) l.refreshLogo(c) } -func (l *Logo) refreshStatus(msg, c string) { - l.status.SetBackgroundColor(config.AsColor(c)) +func (l *Logo) refreshStatus(msg string, c config.Color) { + l.status.SetBackgroundColor(c.Color()) l.status.SetText(fmt.Sprintf("[white::b]%s", msg)) } -func (l *Logo) refreshLogo(c string) { +func (l *Logo) refreshLogo(c config.Color) { l.logo.Clear() for i, s := range LogoSmall { fmt.Fprintf(l.logo, "[%s::b]%s", c, s) diff --git a/internal/ui/menu.go b/internal/ui/menu.go index 958cf5d9..cc1ea1d6 100644 --- a/internal/ui/menu.go +++ b/internal/ui/menu.go @@ -188,16 +188,16 @@ func toMnemonic(s string) string { } func formatNSMenu(i int, name string, styles config.Frame) string { - fmat := strings.Replace(menuIndexFmt, "[key", "["+styles.Menu.NumKeyColor, 1) - fmat = strings.Replace(fmat, ":bg:", ":"+styles.Title.BgColor+":", -1) - fmat = strings.Replace(fmat, "[fg", "["+styles.Menu.FgColor, 1) + fmat := strings.Replace(menuIndexFmt, "[key", "["+styles.Menu.NumKeyColor.String(), 1) + fmat = strings.Replace(fmat, ":bg:", ":"+styles.Title.BgColor.String()+":", -1) + fmat = strings.Replace(fmat, "[fg", "["+styles.Menu.FgColor.String(), 1) return fmt.Sprintf(fmat, i, name) } func formatPlainMenu(h model.MenuHint, size int, styles config.Frame) string { menuFmt := " [key:-:b]%-" + strconv.Itoa(size+2) + "s [fg:-:d]%s " - fmat := strings.Replace(menuFmt, "[key", "["+styles.Menu.KeyColor, 1) - fmat = strings.Replace(fmat, "[fg", "["+styles.Menu.FgColor, 1) - fmat = strings.Replace(fmat, ":bg:", ":"+styles.Title.BgColor+":", -1) + fmat := strings.Replace(menuFmt, "[key", "["+styles.Menu.KeyColor.String(), 1) + fmat = strings.Replace(fmat, "[fg", "["+styles.Menu.FgColor.String(), 1) + fmat = strings.Replace(fmat, ":bg:", ":"+styles.Title.BgColor.String()+":", -1) return fmt.Sprintf(fmat, toMnemonic(h.Mnemonic), h.Description) } diff --git a/internal/ui/table.go b/internal/ui/table.go index a9c8c9e5..bcb2e224 100644 --- a/internal/ui/table.go +++ b/internal/ui/table.go @@ -38,6 +38,8 @@ type Table struct { sortCol SortColumn colorerFn render.ColorerFunc decorateFn DecorateFunc + wide bool + toast bool } // NewTable returns a new table view. @@ -65,6 +67,7 @@ func (t *Table) Init(ctx context.Context) { t.SetSelectable(true, false) t.SetSelectionChangedFunc(t.selectionChanged) t.SetInputCapture(t.keyboard) + t.SetBackgroundColor(tcell.ColorDefault) t.styles = mustExtractSyles(ctx) t.StylesChanged(t.styles) @@ -72,17 +75,35 @@ func (t *Table) Init(ctx context.Context) { // StylesChanged notifies the skin changed. func (t *Table) StylesChanged(s *config.Styles) { - t.SetBackgroundColor(config.AsColor(s.Table().BgColor)) - t.SetBorderColor(config.AsColor(s.Table().FgColor)) - t.SetBorderFocusColor(config.AsColor(s.Frame().Border.FocusColor)) + t.SetBackgroundColor(s.Table().BgColor.Color()) + t.SetBorderColor(s.Table().FgColor.Color()) + t.SetBorderFocusColor(s.Frame().Border.FocusColor.Color()) t.SetSelectedStyle( tcell.ColorBlack, - config.AsColor(t.styles.Table().CursorColor), + t.styles.Table().CursorColor.Color(), tcell.AttrBold, ) t.Refresh() } +// ResetToast resets toast flag. +func (t *Table) ResetToast() { + t.toast = false + t.Refresh() +} + +// ToggleToast toggles to show toast resources. +func (t *Table) ToggleToast() { + t.toast = !t.toast + t.Refresh() +} + +// ToggleWide toggles wide col display. +func (t *Table) ToggleWide() { + t.wide = !t.wide + t.Refresh() +} + // Actions returns active menu bindings. func (t *Table) Actions() KeyActions { return t.actions @@ -166,10 +187,7 @@ func (t *Table) Update(data render.TableData) { if t.decorateFn != nil { data = t.decorateFn(data) } - if !t.cmdBuff.Empty() { - data = t.filtered(data) - } - t.doUpdate(data) + t.doUpdate(t.filtered(data)) t.UpdateTitle() } @@ -182,13 +200,18 @@ func (t *Table) doUpdate(data render.TableData) { t.Clear() t.adjustSorter(data) - fg := config.AsColor(t.styles.Table().Header.FgColor) - bg := config.AsColor(t.styles.Table().Header.BgColor) - for col, h := range data.Header { + fg := t.styles.Table().Header.FgColor.Color() + bg := t.styles.Table().Header.BgColor.Color() + var col int + for _, h := range data.Header { + if h.Wide && !t.wide { + continue + } t.AddHeaderCell(col, h) c := t.GetCell(0, col) c.SetBackgroundColor(bg) c.SetTextColor(fg) + col++ } data.RowEvents.Sort(data.Namespace, t.sortCol.index, t.sortCol.asc) @@ -209,6 +232,8 @@ func (t *Table) SortColCmd(col int, asc bool) func(evt *tcell.EventKey) *tcell.E index = 0 case -1: index = t.GetColumnCount() - 1 + case -3: + index = t.GetColumnCount() - 2 default: index = t.NameColIndex() + col } @@ -251,29 +276,33 @@ func (t *Table) buildRow(ns string, r int, re render.RowEvent, header render.Hea color = t.colorerFn } marked := t.IsMarked(re.Row.ID) - for col, field := range re.Row.Fields { - if !re.Deltas.IsBlank() && !header.AgeCol(col) { - field += Deltas(re.Deltas[col], field) + var col int + for c, field := range re.Row.Fields { + if header[c].Wide && !t.wide { + continue + } + if !re.Deltas.IsBlank() && !header.AgeCol(c) { + field += Deltas(re.Deltas[c], field) + } + if header[c].Decorator != nil { + field = header[c].Decorator(field) + } + if header[c].Align == tview.AlignLeft { + field = formatCell(field, pads[c]) } - if header[col].Decorator != nil { - field = header[col].Decorator(field) - } - - if header[col].Align == tview.AlignLeft { - field = formatCell(field, pads[col]) - } - c := tview.NewTableCell(field) - c.SetExpansion(1) - c.SetAlign(header[col].Align) - c.SetTextColor(color(ns, re)) + cell := tview.NewTableCell(field) + cell.SetExpansion(1) + cell.SetAlign(header[c].Align) + cell.SetTextColor(color(ns, re)) if marked { - c.SetTextColor(config.AsColor(t.styles.Table().MarkColor)) + cell.SetTextColor(t.styles.Table().MarkColor.Color()) } if col == 0 { - c.SetReference(re.Row.ID) + cell.SetReference(re.Row.ID) } - t.SetCell(r, col, c) + t.SetCell(r, col, cell) + col++ } } @@ -298,6 +327,9 @@ func (t *Table) GetSelectedRow() render.Row { // NameColIndex returns the index of the resource name column. func (t *Table) NameColIndex() int { col := 0 + if client.IsClusterScoped(t.GetModel().GetNamespace()) { + return col + } if t.GetModel().ClusterWide() { col++ } @@ -313,20 +345,25 @@ func (t *Table) AddHeaderCell(col int, h render.Header) { } func (t *Table) filtered(data render.TableData) render.TableData { - if t.cmdBuff.Empty() || IsLabelSelector(t.cmdBuff.String()) { - return data + filtered := data + if t.toast { + filtered = filterToast(data) } - q := t.cmdBuff.String() - if IsFuzzySelector(q) { - return fuzzyFilter(q[2:], t.NameColIndex(), data) + if t.cmdBuff.Empty() || IsLabelSelector(t.cmdBuff.String()) { + return filtered } - filtered, err := rxFilter(t.cmdBuff.String(), data) + q := t.cmdBuff.String() + if IsFuzzySelector(q) { + return fuzzyFilter(q[2:], t.NameColIndex(), filtered) + } + + filtered, err := rxFilter(t.cmdBuff.String(), filtered) if err != nil { log.Error().Err(errors.New("Invalid filter expression")).Msg("Regexp") t.cmdBuff.Clear() - return data } + return filtered } diff --git a/internal/ui/table_helper.go b/internal/ui/table_helper.go index c3d5b0ef..3f02d7d6 100644 --- a/internal/ui/table_helper.go +++ b/internal/ui/table_helper.go @@ -85,15 +85,15 @@ func TrimLabelSelector(s string) string { // SkinTitle decorates a title. func SkinTitle(fmat string, style config.Frame) string { bgColor := style.Title.BgColor - if bgColor == "default" { - bgColor = "-" + if bgColor == config.DefaultColor { + bgColor = config.TransparentColor } - fmat = strings.Replace(fmat, "[fg:bg", "["+style.Title.FgColor+":"+bgColor, -1) - fmat = strings.Replace(fmat, "[hilite", "["+style.Title.HighlightColor, 1) - fmat = strings.Replace(fmat, "[key", "["+style.Menu.NumKeyColor, 1) - fmat = strings.Replace(fmat, "[filter", "["+style.Title.FilterColor, 1) - fmat = strings.Replace(fmat, "[count", "["+style.Title.CounterColor, 1) - fmat = strings.Replace(fmat, ":bg:", ":"+bgColor+":", -1) + fmat = strings.Replace(fmat, "[fg:bg", "["+style.Title.FgColor.String()+":"+bgColor.String(), -1) + fmat = strings.Replace(fmat, "[hilite", "["+style.Title.HighlightColor.String(), 1) + fmat = strings.Replace(fmat, "[key", "["+style.Menu.NumKeyColor.String(), 1) + fmat = strings.Replace(fmat, "[filter", "["+style.Title.FilterColor.String(), 1) + fmat = strings.Replace(fmat, "[count", "["+style.Title.CounterColor.String(), 1) + fmat = strings.Replace(fmat, ":bg:", ":"+bgColor.String()+":", -1) return fmat } @@ -118,6 +118,25 @@ func formatCell(field string, padding int) string { return field } +func filterToast(data render.TableData) render.TableData { + validX := data.Header.IndexOf("VALID") + if validX == -1 { + return data + } + + toast := render.TableData{ + Header: data.Header, + RowEvents: make(render.RowEvents, 0, len(data.RowEvents)), + Namespace: data.Namespace, + } + for _, re := range data.RowEvents { + if re.Row.Fields[validX] != "" { + toast.RowEvents = append(toast.RowEvents, re) + } + } + return toast +} + func rxFilter(q string, data render.TableData) (render.TableData, error) { rx, err := regexp.Compile(`(?i)` + q) if err != nil { diff --git a/internal/ui/table_test.go b/internal/ui/table_test.go index 9f63fc8e..bc32a21d 100644 --- a/internal/ui/table_test.go +++ b/internal/ui/table_test.go @@ -66,6 +66,7 @@ func (t *testModel) Peek() render.TableData { return makeTableData() } func (t *testModel) ClusterWide() bool { return false } func (t *testModel) GetNamespace() string { return "blee" } func (t *testModel) SetNamespace(string) {} +func (t *testModel) ToggleToast() {} func (t *testModel) AddListener(model.TableListener) {} func (t *testModel) Watch(context.Context) {} func (t *testModel) Get(ctx context.Context, path string) (runtime.Object, error) { diff --git a/internal/view/actions.go b/internal/view/actions.go index f0ec5529..9bd173b1 100644 --- a/internal/view/actions.go +++ b/internal/view/actions.go @@ -68,14 +68,14 @@ func hotKeyActions(r Runner, aa ui.KeyActions) { } aa[key] = ui.NewSharedKeyAction( hk.Description, - gotoCmd(r, hk.Command), + gotoCmd(r, "", hk.Command), false) } } -func gotoCmd(r Runner, cmd string) ui.ActionHandler { +func gotoCmd(r Runner, cmd, path string) ui.ActionHandler { return func(evt *tcell.EventKey) *tcell.EventKey { - if err := r.App().gotoResource(cmd, true); err != nil { + if err := r.App().gotoResource(cmd, path, true); err != nil { r.App().Flash().Err(err) } return nil diff --git a/internal/view/alias.go b/internal/view/alias.go index 6b6b7b43..ac582262 100644 --- a/internal/view/alias.go +++ b/internal/view/alias.go @@ -32,6 +32,15 @@ func NewAlias(gvr client.GVR) ResourceViewer { return &a } +// Init initialiazes the view. +func (a *Alias) Init(ctx context.Context) error { + if err := a.ResourceViewer.Init(ctx); err != nil { + return err + } + a.GetTable().GetModel().SetNamespace("*") + return nil +} + func (a *Alias) aliasContext(ctx context.Context) context.Context { return context.WithValue(ctx, internal.KeyAliases, a.App().command.alias) } @@ -51,7 +60,7 @@ func (a *Alias) gotoCmd(evt *tcell.EventKey) *tcell.EventKey { if r != 0 { s := ui.TrimCell(a.GetTable().SelectTable, r, 1) tokens := strings.Split(s, ",") - if err := a.App().gotoResource(tokens[0], true); err != nil { + if err := a.App().gotoResource(tokens[0], "", true); err != nil { a.App().Flash().Err(err) } return nil diff --git a/internal/view/alias_test.go b/internal/view/alias_test.go index ecc4c8d0..24199528 100644 --- a/internal/view/alias_test.go +++ b/internal/view/alias_test.go @@ -23,7 +23,7 @@ func TestAliasNew(t *testing.T) { assert.Nil(t, v.Init(makeContext())) assert.Equal(t, "Aliases", v.Name()) - assert.Equal(t, 5, len(v.Hints())) + assert.Equal(t, 7, len(v.Hints())) } func TestAliasSearch(t *testing.T) { @@ -105,6 +105,7 @@ func (t *testModel) Peek() render.TableData { return makeTableData() } func (t *testModel) ClusterWide() bool { return false } func (t *testModel) GetNamespace() string { return "blee" } func (t *testModel) SetNamespace(string) {} +func (t *testModel) ToggleToast() {} func (t *testModel) AddListener(model.TableListener) {} func (t *testModel) Watch(context.Context) {} func (t *testModel) Get(context.Context, string) (runtime.Object, error) { diff --git a/internal/view/app.go b/internal/view/app.go index 903917a1..55e0fb01 100644 --- a/internal/view/app.go +++ b/internal/view/app.go @@ -18,7 +18,7 @@ import ( "github.com/rs/zerolog/log" ) -// // ExitStatus indicates UI exit conditions. +// ExitStatus indicates UI exit conditions. var ExitStatus = "" const ( @@ -98,11 +98,14 @@ func (a *App) Init(version string, rate int) error { a.clusterInfo().Init() + flash := ui.NewFlash(a.App) + go flash.Watch(ctx, a.Flash().Channel()) + main := tview.NewFlex().SetDirection(tview.FlexRow) main.AddItem(a.statusIndicator(), 1, 1, false) main.AddItem(a.Content, 0, 10, true) - main.AddItem(a.Crumbs(), 2, 1, false) - main.AddItem(a.Flash(), 2, 1, false) + main.AddItem(a.Crumbs(), 1, 1, false) + main.AddItem(flash, 1, 1, false) a.Main.AddPage("main", main, true, false) a.Main.AddPage("splash", ui.NewSplash(a.Styles, version), true, true) @@ -113,7 +116,7 @@ func (a *App) Init(version string, rate int) error { func (a *App) bindKeys() { a.AddActions(ui.KeyActions{ - ui.KeyT: ui.NewSharedKeyAction("ToggleHeader", a.toggleHeaderCmd, false), + tcell.KeyCtrlE: ui.NewSharedKeyAction("ToggleHeader", a.toggleHeaderCmd, false), ui.KeyHelp: ui.NewSharedKeyAction("Help", a.helpCmd, false), tcell.KeyCtrlA: ui.NewSharedKeyAction("Aliases", a.aliasCmd, false), tcell.KeyEnter: ui.NewKeyAction("Goto", a.gotoCmd, false), @@ -133,7 +136,7 @@ func (a *App) toggleHeader(flag bool) { } if a.showHeader { flex.RemoveItemAtIndex(0) - flex.AddItemAtIndex(0, a.buildHeader(), 7, 1, false) + flex.AddItemAtIndex(0, a.buildHeader(), 8, 1, false) } else { flex.RemoveItemAtIndex(0) flex.AddItemAtIndex(0, a.statusIndicator(), 1, 1, false) @@ -198,7 +201,7 @@ func (a *App) refreshCluster() { if ok := a.Conn().CheckConnectivity(); ok { if atomic.LoadInt32(&a.conRetry) > 0 { atomic.StoreInt32(&a.conRetry, 0) - a.Status(ui.FlashInfo, "K8s connectivity OK") + a.Status(model.FlashInfo, "K8s connectivity OK") if c != nil { c.Start() } @@ -210,7 +213,7 @@ func (a *App) refreshCluster() { } count := atomic.LoadInt32(&a.conRetry) log.Warn().Msgf("Conn check failed (%d/%d)", count, maxConRetry) - a.Status(ui.FlashWarn, fmt.Sprintf("Dial K8s failed (%d)", count)) + a.Status(model.FlashWarn, fmt.Sprintf("Dial K8s failed (%d)", count)) } count := atomic.LoadInt32(&a.conRetry) @@ -258,6 +261,7 @@ func (a *App) switchCtx(name string, loadPods bool) error { } a.initFactory(ns) + client.ResetMetrics() if err := a.command.Reset(true); err != nil { return err } @@ -267,7 +271,7 @@ func (a *App) switchCtx(name string, loadPods bool) error { } a.Flash().Infof("Switching context to %s", name) a.ReloadStyles(name) - if err := a.gotoResource("pods", true); loadPods && err != nil { + if err := a.gotoResource("pods", "", true); loadPods && err != nil { a.Flash().Err(err) } a.clusterModel.Reset(a.factory) @@ -309,7 +313,7 @@ func (a *App) Run() error { } // Status reports a new app status for display. -func (a *App) Status(l ui.FlashLevel, msg string) { +func (a *App) Status(l model.FlashLevel, msg string) { a.QueueUpdateDraw(func() { a.Flash().SetMessage(l, msg) a.setIndicator(l, msg) @@ -327,13 +331,13 @@ func (a *App) ClearStatus(flash bool) { }) } -func (a *App) setLogo(l ui.FlashLevel, msg string) { +func (a *App) setLogo(l model.FlashLevel, msg string) { switch l { - case ui.FlashErr: + case model.FlashErr: a.Logo().Err(msg) - case ui.FlashWarn: + case model.FlashWarn: a.Logo().Warn(msg) - case ui.FlashInfo: + case model.FlashInfo: a.Logo().Info(msg) default: a.Logo().Reset() @@ -341,13 +345,13 @@ func (a *App) setLogo(l ui.FlashLevel, msg string) { a.Draw() } -func (a *App) setIndicator(l ui.FlashLevel, msg string) { +func (a *App) setIndicator(l model.FlashLevel, msg string) { switch l { - case ui.FlashErr: + case model.FlashErr: a.statusIndicator().Err(msg) - case ui.FlashWarn: + case model.FlashWarn: a.statusIndicator().Warn(msg) - case ui.FlashInfo: + case model.FlashInfo: a.statusIndicator().Info(msg) default: a.statusIndicator().Reset() @@ -378,7 +382,7 @@ func (a *App) toggleHeaderCmd(evt *tcell.EventKey) *tcell.EventKey { func (a *App) gotoCmd(evt *tcell.EventKey) *tcell.EventKey { if a.CmdBuff().IsActive() && !a.CmdBuff().Empty() { - if err := a.gotoResource(a.GetCmd(), true); err != nil { + if err := a.gotoResource(a.GetCmd(), "", true); err != nil { log.Error().Err(err).Msgf("Goto resource for %q failed", a.GetCmd()) a.Flash().Err(err) } @@ -427,8 +431,8 @@ func (a *App) viewResource(gvr, path string, clearStack bool) error { return a.command.run(gvr, path, clearStack) } -func (a *App) gotoResource(cmd string, clearStack bool) error { - return a.command.run(cmd, "", clearStack) +func (a *App) gotoResource(cmd, path string, clearStack bool) error { + return a.command.run(cmd, path, clearStack) } func (a *App) inject(c model.Component) error { diff --git a/internal/view/browser.go b/internal/view/browser.go index 80c65281..9d561876 100644 --- a/internal/view/browser.go +++ b/internal/view/browser.go @@ -94,6 +94,10 @@ func (b *Browser) Start() { b.Stop() b.Table.Start() + b.GetModel().Watch(b.prepareContext()) +} + +func (b *Browser) prepareContext() context.Context { ctx := b.defaultContext() ctx, b.cancelFn = context.WithCancel(ctx) if b.contextFn != nil { @@ -102,7 +106,8 @@ func (b *Browser) Start() { if path, ok := ctx.Value(internal.KeyPath).(string); ok && path != "" { b.Path = path } - b.GetModel().Watch(ctx) + + return ctx } // Stop terminates browser updates. @@ -275,20 +280,22 @@ func (b *Browser) editCmd(evt *tcell.EventKey) *tcell.EventKey { path := b.GetSelectedItem() if path == "" { return evt + + } + ns, n := client.Namespaced(path) + + if ok, err := b.app.Conn().CanI(ns, b.GVR(), []string{"edit"}); !ok || err != nil { + b.App().Flash().Err(fmt.Errorf("Current user can't edit resource %s", b.GVR())) + return nil } b.Stop() defer b.Start() { - ns, n := client.Namespaced(path) args := make([]string, 0, 10) args = append(args, "edit") args = append(args, b.meta.SingularName) args = append(args, "-n", ns) - args = append(args, "--context", b.app.Config.K9s.CurrentContext) - if cfg := b.app.Conn().Config().Flags().KubeConfig; cfg != nil && *cfg != "" { - args = append(args, "--kubeconfig", *cfg) - } if !runK(b.app, shellOpts{clear: true, args: append(args, n)}) { b.app.Flash().Err(errors.New("Edit exec failed")) } diff --git a/internal/view/cluster_info.go b/internal/view/cluster_info.go index dc87cfa5..b315441e 100644 --- a/internal/view/cluster_info.go +++ b/internal/view/cluster_info.go @@ -30,6 +30,7 @@ func NewClusterInfo(app *App) *ClusterInfo { // Init initializes the view. func (c *ClusterInfo) Init() { + c.SetBorderPadding(0, 0, 1, 0) c.app.Styles.AddListener(c) c.layout() c.StylesChanged(c.app.Styles) @@ -67,7 +68,7 @@ func (c *ClusterInfo) sectionCell(t string) *tview.TableCell { func (c *ClusterInfo) infoCell(t string) *tview.TableCell { cell := tview.NewTableCell(t) cell.SetExpansion(2) - cell.SetTextColor(config.AsColor(c.styles.K9s.Info.FgColor)) + cell.SetTextColor(c.styles.K9s.Info.FgColor.Color()) cell.SetBackgroundColor(c.app.Styles.BgColor()) return cell @@ -119,9 +120,9 @@ func (c *ClusterInfo) ClusterInfoChanged(prev, curr model.ClusterMeta) { func (c *ClusterInfo) updateStyle() { for row := 0; row < c.GetRowCount(); row++ { - c.GetCell(row, 0).SetTextColor(config.AsColor(c.styles.K9s.Info.FgColor)) + c.GetCell(row, 0).SetTextColor(c.styles.K9s.Info.FgColor.Color()) c.GetCell(row, 0).SetBackgroundColor(c.styles.BgColor()) var s tcell.Style - c.GetCell(row, 1).SetStyle(s.Bold(true).Foreground(config.AsColor(c.styles.K9s.Info.SectionColor))) + c.GetCell(row, 1).SetStyle(s.Bold(true).Foreground(c.styles.K9s.Info.SectionColor.Color())) } } diff --git a/internal/view/container.go b/internal/view/container.go index 749051a7..ceb18da4 100644 --- a/internal/view/container.go +++ b/internal/view/container.go @@ -3,6 +3,7 @@ package view import ( "errors" "fmt" + "net" "strings" "github.com/derailed/k9s/internal/client" @@ -58,7 +59,7 @@ func (c *Container) bindKeys(aa ui.KeyActions) { ui.KeyShiftX: ui.NewKeyAction("Sort %CPU (REQ)", c.GetTable().SortColCmd(8, false), false), ui.KeyShiftZ: ui.NewKeyAction("Sort %MEM (REQ)", c.GetTable().SortColCmd(9, false), false), tcell.KeyCtrlX: ui.NewKeyAction("Sort %CPU (LIM)", c.GetTable().SortColCmd(8, false), false), - tcell.KeyCtrlZ: ui.NewKeyAction("Sort %MEM (LIM)", c.GetTable().SortColCmd(9, false), false), + tcell.KeyCtrlQ: ui.NewKeyAction("Sort %MEM (LIM)", c.GetTable().SortColCmd(9, false), false), }) } @@ -154,3 +155,11 @@ func (c *Container) isForwardable(path string) ([]string, bool) { return pp, true } + +func tryListenPort(port string) error { + server, err := net.Listen("tcp", fmt.Sprintf(":%s", port)) + if err != nil { + return err + } + return server.Close() +} diff --git a/internal/view/container_test.go b/internal/view/container_test.go index 7234fc1b..732c35e9 100644 --- a/internal/view/container_test.go +++ b/internal/view/container_test.go @@ -13,5 +13,5 @@ func TestContainerNew(t *testing.T) { assert.Nil(t, c.Init(makeCtx())) assert.Equal(t, "Containers", c.Name()) - assert.Equal(t, 14, len(c.Hints())) + assert.Equal(t, 15, len(c.Hints())) } diff --git a/internal/view/context_test.go b/internal/view/context_test.go index ebefd197..dc0f693e 100644 --- a/internal/view/context_test.go +++ b/internal/view/context_test.go @@ -13,5 +13,5 @@ func TestContext(t *testing.T) { assert.Nil(t, ctx.Init(makeCtx())) assert.Equal(t, "Contexts", ctx.Name()) - assert.Equal(t, 2, len(ctx.Hints())) + assert.Equal(t, 4, len(ctx.Hints())) } diff --git a/internal/view/details.go b/internal/view/details.go index 66d64624..a468fa1b 100644 --- a/internal/view/details.go +++ b/internal/view/details.go @@ -161,7 +161,7 @@ func (d *Details) filterInput(r rune) bool { func (d *Details) StylesChanged(s *config.Styles) { d.SetBackgroundColor(d.app.Styles.BgColor()) d.SetTextColor(d.app.Styles.FgColor()) - d.SetBorderFocusColor(config.AsColor(d.app.Styles.Frame().Border.FocusColor)) + d.SetBorderFocusColor(d.app.Styles.Frame().Border.FocusColor.Color()) d.TextChanged(d.model.Peek()) } diff --git a/internal/view/dp.go b/internal/view/dp.go index d88464a8..aa92c575 100644 --- a/internal/view/dp.go +++ b/internal/view/dp.go @@ -40,7 +40,7 @@ func (d *Deploy) bindKeys(aa ui.KeyActions) { aa.Add(ui.KeyActions{ ui.KeyShiftR: ui.NewKeyAction("Sort Ready", d.GetTable().SortColCmd(1, true), false), ui.KeyShiftU: ui.NewKeyAction("Sort UpToDate", d.GetTable().SortColCmd(2, true), false), - ui.KeyShiftV: ui.NewKeyAction("Sort Available", d.GetTable().SortColCmd(3, true), false), + ui.KeyShiftL: ui.NewKeyAction("Sort Available", d.GetTable().SortColCmd(3, true), false), }) } diff --git a/internal/view/dp_test.go b/internal/view/dp_test.go index 18e94546..d69fa571 100644 --- a/internal/view/dp_test.go +++ b/internal/view/dp_test.go @@ -13,5 +13,5 @@ func TestDeploy(t *testing.T) { assert.Nil(t, v.Init(makeCtx())) assert.Equal(t, "Deployments", v.Name()) - assert.Equal(t, 11, len(v.Hints())) + assert.Equal(t, 12, len(v.Hints())) } diff --git a/internal/view/ds.go b/internal/view/ds.go index 77c24414..d9f317d3 100644 --- a/internal/view/ds.go +++ b/internal/view/ds.go @@ -34,7 +34,7 @@ func (d *DaemonSet) bindKeys(aa ui.KeyActions) { ui.KeyShiftC: ui.NewKeyAction("Sort Current", d.GetTable().SortColCmd(2, true), false), ui.KeyShiftR: ui.NewKeyAction("Sort Ready", d.GetTable().SortColCmd(3, true), false), ui.KeyShiftU: ui.NewKeyAction("Sort UpToDate", d.GetTable().SortColCmd(4, true), false), - ui.KeyShiftV: ui.NewKeyAction("Sort Available", d.GetTable().SortColCmd(5, true), false), + ui.KeyShiftL: ui.NewKeyAction("Sort Available", d.GetTable().SortColCmd(5, true), false), }) } diff --git a/internal/view/ds_test.go b/internal/view/ds_test.go index 34cfe258..43d45f8b 100644 --- a/internal/view/ds_test.go +++ b/internal/view/ds_test.go @@ -13,5 +13,5 @@ func TestDaemonSet(t *testing.T) { assert.Nil(t, v.Init(makeCtx())) assert.Equal(t, "DaemonSets", v.Name()) - assert.Equal(t, 12, len(v.Hints())) + assert.Equal(t, 13, len(v.Hints())) } diff --git a/internal/view/env.go b/internal/view/env.go index 7ddcb4ba..a2bb9f75 100644 --- a/internal/view/env.go +++ b/internal/view/env.go @@ -13,7 +13,7 @@ import ( type K9sEnv map[string]string // EnvRX match $XXX custom arg. -var envRX = regexp.MustCompile(`\$([\w]+)(\d*)`) +var envRX = regexp.MustCompile(`\$(\!?[\w]+)(\d*)`) func (e K9sEnv) envFor(ns, args string) (string, error) { envs := envRX.FindStringSubmatch(args) @@ -41,10 +41,23 @@ func (e K9sEnv) envFor(ns, args string) (string, error) { } func (e K9sEnv) subOut(args, q string) (string, error) { + var reverse bool + if q[0] == '!' { + reverse = true + q = q[1:] + } env, ok := e[strings.ToUpper(q)] if !ok { return "", fmt.Errorf("no env vars exists for argument %q using key %q", args, q) } + if b, err := strconv.ParseBool(env); err == nil { + if reverse { + env = fmt.Sprintf("%t", !b) + } else { + env = fmt.Sprintf("%t", b) + } + } + return envRX.ReplaceAllString(args, env), nil } diff --git a/internal/view/event.go b/internal/view/event.go index 58ffa82b..a0f6f8f5 100644 --- a/internal/view/event.go +++ b/internal/view/event.go @@ -19,10 +19,17 @@ func NewEvent(gvr client.GVR) ResourceViewer { } e.GetTable().SetColorerFn(render.Event{}.ColorerFunc()) e.SetBindKeysFn(e.bindKeys) + e.GetTable().SetSortCol(7, 0, true) return &e } func (e *Event) bindKeys(aa ui.KeyActions) { aa.Delete(tcell.KeyCtrlD, ui.KeyE) + aa.Add(ui.KeyActions{ + ui.KeyShiftY: ui.NewKeyAction("Sort Type", e.GetTable().SortColCmd(1, true), false), + ui.KeyShiftR: ui.NewKeyAction("Sort Reason", e.GetTable().SortColCmd(2, true), false), + ui.KeyShiftE: ui.NewKeyAction("Sort Source", e.GetTable().SortColCmd(3, true), false), + ui.KeyShiftC: ui.NewKeyAction("Sort Count", e.GetTable().SortColCmd(4, true), false), + }) } diff --git a/internal/view/exec.go b/internal/view/exec.go index ff905ca7..2250b4aa 100644 --- a/internal/view/exec.go +++ b/internal/view/exec.go @@ -25,29 +25,44 @@ type shellOpts struct { args []string } -func runK(app *App, opts shellOpts) bool { +func runK(a *App, opts shellOpts) bool { bin, err := exec.LookPath("kubectl") if err != nil { log.Error().Msgf("Unable to find kubectl command in path %v", err) return false } + var args []string + if u, err := a.Conn().Config().ImpersonateUser(); err == nil { + args = append(args, "--as", u) + } + if g, err := a.Conn().Config().ImpersonateGroups(); err == nil { + args = append(args, "--as-group", g) + } + args = append(args, "--context", a.Config.K9s.CurrentContext) + if cfg := a.Conn().Config().Flags().KubeConfig; cfg != nil && *cfg != "" { + args = append(args, "--kubeconfig", *cfg) + } + if len(args) > 0 { + opts.args = append(opts.args, args...) + } + opts.binary, opts.background = bin, false - return run(app, opts) + return run(a, opts) } -func run(app *App, opts shellOpts) bool { - app.Halt() - defer app.Resume() +func run(a *App, opts shellOpts) bool { + a.Halt() + defer a.Resume() - return app.Suspend(func() { + return a.Suspend(func() { if err := execute(opts); err != nil { - app.Flash().Errf("Command exited: %v", err) + a.Flash().Errf("Command exited: %v", err) } }) } -func edit(app *App, opts shellOpts) bool { +func edit(a *App, opts shellOpts) bool { bin, err := exec.LookPath(os.Getenv("EDITOR")) if err != nil { log.Error().Msgf("Unable to find editor command in path %v", err) @@ -55,7 +70,7 @@ func edit(app *App, opts shellOpts) bool { } opts.binary, opts.background = bin, false - return run(app, opts) + return run(a, opts) } func execute(opts shellOpts) error { @@ -85,7 +100,6 @@ func execute(opts shellOpts) error { err = cmd.Start() } else { cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr - _, _ = cmd.Stdout.Write([]byte(opts.banner)) err = cmd.Run() } diff --git a/internal/view/help.go b/internal/view/help.go index 0758d4ca..959f4580 100644 --- a/internal/view/help.go +++ b/internal/view/help.go @@ -235,7 +235,7 @@ func (h *Help) showGeneral() model.MenuHints { Description: "Clear command", }, { - Mnemonic: "t", + Mnemonic: "Ctrl-e", Description: "Toggle Header", }, { diff --git a/internal/view/help_test.go b/internal/view/help_test.go index 68b13aee..77be3a80 100644 --- a/internal/view/help_test.go +++ b/internal/view/help_test.go @@ -22,9 +22,8 @@ func TestHelp(t *testing.T) { assert.Nil(t, v.Init(ctx)) assert.Equal(t, 21, v.GetRowCount()) + assert.Equal(t, 22, v.GetRowCount()) assert.Equal(t, 8, v.GetColumnCount()) assert.Equal(t, "", strings.TrimSpace(v.GetCell(1, 0).Text)) assert.Equal(t, "Attach", strings.TrimSpace(v.GetCell(1, 1).Text)) -// assert.Equal(t, "", strings.TrimSpace(v.GetCell(1, 0).Text)) -// assert.Equal(t, "Kill", strings.TrimSpace(v.GetCell(1, 1).Text)) } diff --git a/internal/view/log.go b/internal/view/log.go index 7e0e715b..701f8502 100644 --- a/internal/view/log.go +++ b/internal/view/log.go @@ -76,7 +76,7 @@ func (l *Log) Init(ctx context.Context) (err error) { l.logs.SetWrap(false) l.logs.SetMaxBuffer(l.app.Config.K9s.LogBufferSize) - l.ansiWriter = tview.ANSIWriter(l.logs, l.app.Styles.Views().Log.FgColor, l.app.Styles.Views().Log.BgColor) + l.ansiWriter = tview.ANSIWriter(l.logs, l.app.Styles.Views().Log.FgColor.String(), l.app.Styles.Views().Log.BgColor.String()) l.AddItem(l.logs, 0, 1, true) l.bindKeys() l.logs.SetInputCapture(l.keyboard) @@ -127,9 +127,9 @@ func (l *Log) BufferActive(state bool, k ui.BufferKind) { // StylesChanged reports skin changes. func (l *Log) StylesChanged(s *config.Styles) { - l.SetBackgroundColor(config.AsColor(s.Views().Log.BgColor)) - l.logs.SetTextColor(config.AsColor(s.Views().Log.FgColor)) - l.logs.SetBackgroundColor(config.AsColor(s.Views().Log.BgColor)) + l.SetBackgroundColor(s.Views().Log.BgColor.Color()) + l.logs.SetTextColor(s.Views().Log.FgColor.Color()) + l.logs.SetBackgroundColor(s.Views().Log.BgColor.Color()) } // GetModel returns the log model. diff --git a/internal/view/log_indicator.go b/internal/view/log_indicator.go index 11634ad9..31a20ec6 100644 --- a/internal/view/log_indicator.go +++ b/internal/view/log_indicator.go @@ -26,7 +26,7 @@ func NewLogIndicator(cfg *config.Config, styles *config.Styles) *LogIndicator { scrollStatus: 1, fullScreen: cfg.K9s.FullScreenLogs, } - l.SetBackgroundColor(config.AsColor(styles.Views().Log.BgColor)) + l.SetBackgroundColor(styles.Views().Log.BgColor.Color()) l.SetTextAlign(tview.AlignRight) l.SetDynamicColors(true) diff --git a/internal/view/ns.go b/internal/view/ns.go index adf567c0..1a79fb94 100644 --- a/internal/view/ns.go +++ b/internal/view/ns.go @@ -1,6 +1,8 @@ package view import ( + "time" + "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/render" @@ -40,7 +42,7 @@ func (n *Namespace) bindKeys(aa ui.KeyActions) { func (n *Namespace) switchNs(app *App, model ui.Tabular, gvr, path string) { n.useNamespace(path) - if err := app.gotoResource("pods", true); err != nil { + if err := app.gotoResource("pods", "", true); err != nil { app.Flash().Err(err) } } @@ -76,12 +78,13 @@ func (n *Namespace) decorate(data render.TableData) render.TableData { // checks if all ns is in the list if not add it. if _, ok := data.RowEvents.FindIndex(client.NamespaceAll); !ok { + log.Debug().Msg("YO!!") data.RowEvents = append(data.RowEvents, render.RowEvent{ Kind: render.EventUnchanged, Row: render.Row{ ID: client.NamespaceAll, - Fields: render.Fields{client.NamespaceAll, "Active", "0"}, + Fields: render.Fields{client.NamespaceAll, "Active", "", "", time.Now().String()}, }, }, ) diff --git a/internal/view/ns_test.go b/internal/view/ns_test.go index 6a98ac0a..a78b829a 100644 --- a/internal/view/ns_test.go +++ b/internal/view/ns_test.go @@ -13,5 +13,5 @@ func TestNSCleanser(t *testing.T) { assert.Nil(t, ns.Init(makeCtx())) assert.Equal(t, "Namespaces", ns.Name()) - assert.Equal(t, 4, len(ns.Hints())) + assert.Equal(t, 6, len(ns.Hints())) } diff --git a/internal/view/ofaas.go b/internal/view/ofaas.go index 00c93a2c..f2fa81a8 100644 --- a/internal/view/ofaas.go +++ b/internal/view/ofaas.go @@ -28,7 +28,7 @@ func (o *OpenFaas) bindKeys(aa ui.KeyActions) { ui.KeyShiftS: ui.NewKeyAction("Sort Status", o.GetTable().SortColCmd(2, true), false), ui.KeyShiftI: ui.NewKeyAction("Sort Invocations", o.GetTable().SortColCmd(4, false), false), ui.KeyShiftR: ui.NewKeyAction("Sort Replicas", o.GetTable().SortColCmd(5, false), false), - ui.KeyShiftV: ui.NewKeyAction("Sort Available", o.GetTable().SortColCmd(6, false), false), + ui.KeyShiftL: ui.NewKeyAction("Sort Available", o.GetTable().SortColCmd(6, false), false), }) } diff --git a/internal/view/pf_dialog.go b/internal/view/pf_dialog.go index 6b7e3b15..973644df 100644 --- a/internal/view/pf_dialog.go +++ b/internal/view/pf_dialog.go @@ -5,7 +5,6 @@ import ( "strings" "github.com/derailed/k9s/internal/client" - "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/ui" "github.com/derailed/tview" ) @@ -24,27 +23,17 @@ func ShowPortForwards(v ResourceViewer, path string, ports []string, okFn PortFo f.SetButtonsAlign(tview.AlignCenter). SetButtonBackgroundColor(styles.BgColor()). SetButtonTextColor(styles.FgColor()). - SetLabelColor(config.AsColor(styles.K9s.Info.FgColor)). - SetFieldTextColor(config.AsColor(styles.K9s.Info.SectionColor)) + SetLabelColor(styles.K9s.Info.FgColor.Color()). + SetFieldTextColor(styles.K9s.Info.SectionColor.Color()) - p1, p2, address := ports[0], ports[0], "localhost" - f.AddDropDown("Container Ports", ports, 0, func(sel string, _ int) { - p1, p2 = sel, extractPort(sel) + p1, p2, address := ports[0], extractPort(ports[0]), "localhost" + f.AddInputField("Container Port:", p1, 30, nil, func(p string) { + p1 = p }) - - dropD, ok := f.GetFormItem(0).(*tview.DropDown) - if ok { - dropD.SetFieldBackgroundColor(styles.BgColor()) - list := dropD.GetList() - list.SetMainTextColor(styles.FgColor()) - list.SetSelectedTextColor(styles.FgColor()) - list.SetSelectedBackgroundColor(config.AsColor(styles.Table().CursorColor)) - list.SetBackgroundColor(styles.BgColor() + 100) - } - f.AddInputField("Local Port:", p2, 20, nil, func(p string) { + f.AddInputField("Local Port:", p2, 30, nil, func(p string) { p2 = p }) - f.AddInputField("Address:", address, 20, nil, func(h string) { + f.AddInputField("Address:", address, 30, nil, func(h string) { address = h }) @@ -59,21 +48,24 @@ func ShowPortForwards(v ResourceViewer, path string, ports []string, okFn PortFo okFn(v, path, extractContainer(p1), tunnel) }) f.AddButton("Cancel", func() { - DismissPortForwards(pages) + DismissPortForwards(v.App(), pages) }) modal := tview.NewModalForm(fmt.Sprintf("", path), f) + modal.SetText("Exposed Ports: " + strings.Join(ports, ",")) modal.SetDoneFunc(func(_ int, b string) { - DismissPortForwards(pages) + DismissPortForwards(v.App(), pages) }) - pages.AddPage(portForwardKey, modal, false, false) + pages.AddPage(portForwardKey, modal, false, true) pages.ShowPage(portForwardKey) + v.App().SetFocus(pages.GetPrimitive(portForwardKey)) } // DismissPortForwards dismiss the port forward dialog. -func DismissPortForwards(p *ui.Pages) { +func DismissPortForwards(app *App, p *ui.Pages) { p.RemovePage(portForwardKey) + app.SetFocus(p.CurrentPage().Item) } // ---------------------------------------------------------------------------- diff --git a/internal/view/pf_dialog_test.go b/internal/view/pf_dialog_test.go index f4154494..6b654c55 100644 --- a/internal/view/pf_dialog_test.go +++ b/internal/view/pf_dialog_test.go @@ -11,6 +11,9 @@ func TestExtractPort(t *testing.T) { port, e string }{ "full": { + "co/fred:8000", "8000", + }, + "named": { "fred:8000", "8000", }, "port": { @@ -28,3 +31,26 @@ func TestExtractPort(t *testing.T) { }) } } + +func TestExtractContainer(t *testing.T) { + uu := map[string]struct { + port, e string + }{ + "full": { + "co/port:8000", "co", + }, + "unamed": { + "co/:8000", "co", + }, + "protocol": { + "co/dns:53╱UDP", "co", + }, + } + + for k := range uu { + u := uu[k] + t.Run(k, func(t *testing.T) { + assert.Equal(t, u.e, extractContainer(u.port)) + }) + } +} diff --git a/internal/view/pf_extender.go b/internal/view/pf_extender.go index 1393d37a..2907f2a8 100644 --- a/internal/view/pf_extender.go +++ b/internal/view/pf_extender.go @@ -76,7 +76,7 @@ func runForward(v ResourceViewer, pf watch.Forwarder, f *portforward.PortForward v.App().QueueUpdateDraw(func() { v.App().Flash().Infof("PortForward activated %s:%s", pf.Path(), pf.Ports()[0]) - DismissPortForwards(v.App().Content.Pages) + DismissPortForwards(v.App(), v.App().Content.Pages) }) pf.SetActive(true) @@ -124,7 +124,6 @@ func showFwdDialog(v ResourceViewer, path string, cb PortForwardFunc) error { ports = append(ports, client.FQN(co, p.Name)+":"+strconv.Itoa(int(p.ContainerPort))) } } - if len(ports) == 0 { return fmt.Errorf("no tcp ports found on %s", path) } diff --git a/internal/view/pod.go b/internal/view/pod.go index a33cf515..a9f8712c 100644 --- a/internal/view/pod.go +++ b/internal/view/pod.go @@ -54,14 +54,14 @@ func (p *Pod) bindKeys(aa ui.KeyActions) { aa.Add(ui.KeyActions{ ui.KeyShiftR: ui.NewKeyAction("Sort Ready", p.GetTable().SortColCmd(1, true), false), - ui.KeyShiftS: ui.NewKeyAction("Sort Status", p.GetTable().SortColCmd(2, true), false), - ui.KeyShiftT: ui.NewKeyAction("Sort Restart", p.GetTable().SortColCmd(3, false), false), + ui.KeyShiftT: ui.NewKeyAction("Sort Restart", p.GetTable().SortColCmd(2, false), false), + ui.KeyShiftS: ui.NewKeyAction("Sort Status", p.GetTable().SortColCmd(3, true), false), ui.KeyShiftC: ui.NewKeyAction("Sort CPU", p.GetTable().SortColCmd(4, false), false), ui.KeyShiftM: ui.NewKeyAction("Sort MEM", p.GetTable().SortColCmd(5, false), false), ui.KeyShiftX: ui.NewKeyAction("Sort %CPU (REQ)", p.GetTable().SortColCmd(6, false), false), ui.KeyShiftZ: ui.NewKeyAction("Sort %MEM (REQ)", p.GetTable().SortColCmd(7, false), false), tcell.KeyCtrlX: ui.NewKeyAction("Sort %CPU (LIM)", p.GetTable().SortColCmd(8, false), false), - tcell.KeyCtrlZ: ui.NewKeyAction("Sort %MEM (LIM)", p.GetTable().SortColCmd(9, false), false), + tcell.KeyCtrlQ: ui.NewKeyAction("Sort %MEM (LIM)", p.GetTable().SortColCmd(9, false), false), ui.KeyShiftI: ui.NewKeyAction("Sort IP", p.GetTable().SortColCmd(10, true), false), ui.KeyShiftO: ui.NewKeyAction("Sort Node", p.GetTable().SortColCmd(11, true), false), }) @@ -118,7 +118,7 @@ func (p *Pod) shellCmd(evt *tcell.EventKey) *tcell.EventKey { } row := p.GetTable().GetSelectedRowIndex() - status := ui.TrimCell(p.GetTable().SelectTable, row, p.GetTable().NameColIndex()+2) + status := ui.TrimCell(p.GetTable().SelectTable, row, p.GetTable().NameColIndex()+3) if status != render.Running { p.App().Flash().Errf("%s is not in a running state", path) return nil diff --git a/internal/view/pod_test.go b/internal/view/pod_test.go index 1636f88d..28ddaae8 100644 --- a/internal/view/pod_test.go +++ b/internal/view/pod_test.go @@ -17,6 +17,7 @@ func TestPodNew(t *testing.T) { assert.Nil(t, po.Init(makeCtx())) assert.Equal(t, "Pods", po.Name()) assert.Equal(t, 20, len(po.Hints())) + assert.Equal(t, 21, len(po.Hints())) } // Helpers... diff --git a/internal/view/port_forward.go b/internal/view/port_forward.go index 3901ef75..89ecc88a 100644 --- a/internal/view/port_forward.go +++ b/internal/view/port_forward.go @@ -8,6 +8,7 @@ import ( "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/dao" + "github.com/derailed/k9s/internal/model" "github.com/derailed/k9s/internal/perf" "github.com/derailed/k9s/internal/render" "github.com/derailed/k9s/internal/ui" @@ -64,7 +65,7 @@ func (p *PortForward) showBenchCmd(evt *tcell.EventKey) *tcell.EventKey { func (p *PortForward) toggleBenchCmd(evt *tcell.EventKey) *tcell.EventKey { if p.bench != nil { - p.App().Status(ui.FlashErr, "Benchmark Canceled!") + p.App().Status(model.FlashErr, "Benchmark Canceled!") p.bench.Cancel() p.App().ClearStatus(true) return nil @@ -87,7 +88,7 @@ func (p *PortForward) toggleBenchCmd(evt *tcell.EventKey) *tcell.EventKey { return nil } - p.App().Status(ui.FlashWarn, "Benchmark in progress...") + p.App().Status(model.FlashWarn, "Benchmark in progress...") go p.runBenchmark() return nil @@ -100,9 +101,9 @@ func (p *PortForward) runBenchmark() { log.Debug().Msg("Bench Completed!") p.App().QueueUpdate(func() { if p.bench.Canceled() { - p.App().Status(ui.FlashInfo, "Benchmark canceled") + p.App().Status(model.FlashInfo, "Benchmark canceled") } else { - p.App().Status(ui.FlashInfo, "Benchmark Completed!") + p.App().Status(model.FlashInfo, "Benchmark Completed!") p.bench.Cancel() } p.bench = nil diff --git a/internal/view/port_forward_test.go b/internal/view/port_forward_test.go index fabe07b8..eb21f9f4 100644 --- a/internal/view/port_forward_test.go +++ b/internal/view/port_forward_test.go @@ -13,5 +13,5 @@ func TestPortForwardNew(t *testing.T) { assert.Nil(t, pf.Init(makeCtx())) assert.Equal(t, "PortForwards", pf.Name()) - assert.Equal(t, 7, len(pf.Hints())) + assert.Equal(t, 9, len(pf.Hints())) } diff --git a/internal/view/pulse.go b/internal/view/pulse.go new file mode 100644 index 00000000..b936f42b --- /dev/null +++ b/internal/view/pulse.go @@ -0,0 +1,362 @@ +package view + +import ( + "context" + "fmt" + "image" + "strings" + + "github.com/derailed/k9s/internal" + "github.com/derailed/k9s/internal/client" + "github.com/derailed/k9s/internal/config" + "github.com/derailed/k9s/internal/health" + "github.com/derailed/k9s/internal/model" + "github.com/derailed/k9s/internal/tchart" + "github.com/derailed/k9s/internal/ui" + "github.com/derailed/tview" + "github.com/gdamore/tcell" +) + +// Grapheable represents a graphic component. +type Grapheable interface { + tview.Primitive + + // ID returns the graph id. + ID() string + + // Add adds a metric + Add(tchart.Metric) + + // SetLegend sets the graph legend + SetLegend(string) + + // SetSeriesColors sets charts series colors. + SetSeriesColors(...tcell.Color) + + // GetSeriesColorNames returns the series color names. + GetSeriesColorNames() []string + + // SetBackgroundColor sets chart bg color. + SetBackgroundColor(tcell.Color) + + // IsDial returns true if chart is a dial + IsDial() bool +} + +const pulseTitle = "Pulses" + +var _ ResourceViewer = (*Pulse)(nil) + +// Pulse represents a command health view. +type Pulse struct { + *tview.Grid + + app *App + gvr client.GVR + model *model.Pulse + cancelFn context.CancelFunc + actions ui.KeyActions + charts []Grapheable +} + +// NewPulse returns a new alias view. +func NewPulse(gvr client.GVR) ResourceViewer { + return &Pulse{ + Grid: tview.NewGrid(), + model: model.NewPulse(gvr.String()), + actions: make(ui.KeyActions), + } +} + +// Init initializes the view. +func (p *Pulse) Init(ctx context.Context) error { + p.SetBorder(true) + p.SetTitle(fmt.Sprintf(" %s ", pulseTitle)) + p.SetGap(1, 1) + p.SetBorderPadding(0, 0, 1, 1) + var err error + if p.app, err = extractApp(ctx); err != nil { + return err + } + + p.charts = []Grapheable{ + p.makeGA(image.Point{X: 0, Y: 0}, image.Point{X: 4, Y: 2}, "apps/v1/deployments"), + p.makeGA(image.Point{X: 0, Y: 2}, image.Point{X: 4, Y: 2}, "apps/v1/replicasets"), + p.makeGA(image.Point{X: 0, Y: 4}, image.Point{X: 4, Y: 2}, "apps/v1/statefulsets"), + p.makeGA(image.Point{X: 0, Y: 6}, image.Point{X: 4, Y: 2}, "apps/v1/daemonsets"), + p.makeSP(image.Point{X: 4, Y: 0}, image.Point{X: 3, Y: 4}, "v1/pods"), + p.makeSP(image.Point{X: 4, Y: 4}, image.Point{X: 3, Y: 4}, "v1/events"), + p.makeSP(image.Point{X: 7, Y: 0}, image.Point{X: 3, Y: 4}, "batch/v1/jobs"), + p.makeSP(image.Point{X: 7, Y: 4}, image.Point{X: 3, Y: 4}, "v1/persistentvolumes"), + } + if p.app.Conn().HasMetrics() { + p.charts = append(p.charts, + p.makeSP(image.Point{X: 10, Y: 0}, image.Point{X: 2, Y: 4}, "cpu"), + p.makeSP(image.Point{X: 10, Y: 4}, image.Point{X: 2, Y: 4}, "mem"), + ) + } + p.bindKeys() + p.model.AddListener(p) + p.app.SetFocus(p.charts[0]) + p.app.Styles.AddListener(p) + + return nil +} + +// StylesChanged notifies the skin changed. +func (p *Pulse) StylesChanged(s *config.Styles) { + p.SetBackgroundColor(s.Charts().BgColor.Color()) + for _, c := range p.charts { + if c.IsDial() { + c.SetBackgroundColor(s.Charts().DialBgColor.Color()) + c.SetSeriesColors(s.Charts().DefaultDialColors.Colors()...) + } else { + c.SetBackgroundColor(s.Charts().ChartBgColor.Color()) + c.SetSeriesColors(s.Charts().DefaultChartColors.Colors()...) + } + if ss, ok := s.Charts().ResourceColors[c.ID()]; ok { + c.SetSeriesColors(ss.Colors()...) + } + } + p.app.Draw() +} + +// PulseChanged notifies the model data changed. +func (p *Pulse) PulseChanged(c *health.Check) { + index, ok := findIndexGVR(p.charts, c.GVR) + if !ok { + return + } + + v, ok := p.GetItem(index).Item.(Grapheable) + if !ok { + return + } + gvr := client.NewGVR(c.GVR) + switch c.GVR { + case "cpu": + v.SetLegend(fmt.Sprintf(" %s - %dm", strings.Title(gvr.R()), c.Tally(health.OK))) + case "mem": + v.SetLegend(fmt.Sprintf(" %s - %dMi", strings.Title(gvr.R()), c.Tally(health.OK))) + default: + nn := v.GetSeriesColorNames() + if c.Tally(health.OK) == 0 { + nn[0] = "gray" + } + if c.Tally(health.Toast) == 0 { + nn[1] = "gray" + } + v.SetLegend(fmt.Sprintf(" %s - [%s::]%d/[%s::b]%d[-::]", + strings.Title(gvr.R()), + nn[0], + c.Tally(health.OK), + nn[1], + c.Tally(health.Toast), + )) + } + v.Add(tchart.Metric{OK: c.Tally(health.OK), Fault: c.Tally(health.Toast)}) +} + +// PulseFailed notifies the load failed. +func (p *Pulse) PulseFailed(err error) { + p.app.Flash().Err(err) +} + +func (p *Pulse) bindKeys() { + p.actions.Add(ui.KeyActions{ + tcell.KeyEnter: ui.NewKeyAction("Goto", p.enterCmd, true), + tcell.KeyTab: ui.NewKeyAction("Next", p.nextFocusCmd(1), true), + tcell.KeyBacktab: ui.NewKeyAction("Prev", p.nextFocusCmd(-1), true), + }) + + for i, v := range p.charts { + t := strings.Title(client.NewGVR(v.(Grapheable).ID()).R()) + p.actions[tcell.Key(ui.NumKeys[i])] = ui.NewKeyAction(t, p.sparkFocusCmd(i), true) + } +} + +func (p *Pulse) keyboard(evt *tcell.EventKey) *tcell.EventKey { + key := evt.Key() + if key == tcell.KeyRune { + key = tcell.Key(evt.Rune()) + } + if a, ok := p.actions[key]; ok { + return a.Action(evt) + } + + return evt +} + +func (p *Pulse) defaultContext() context.Context { + return context.WithValue(context.Background(), internal.KeyFactory, p.app.factory) +} + +// Start initializes resource watch loop. +func (p *Pulse) Start() { + p.Stop() + + ctx := p.defaultContext() + ctx, p.cancelFn = context.WithCancel(ctx) + p.model.Watch(ctx) +} + +// Stop terminates watch loop. +func (p *Pulse) Stop() { + if p.cancelFn == nil { + return + } + p.cancelFn() + p.cancelFn = nil +} + +// Refresh updates the view +func (p *Pulse) Refresh() { + // p.update(p.model.Peek()) +} + +// GVR returns a resource descriptor. +func (p *Pulse) GVR() string { + return p.gvr.String() +} + +// Name returns the component name. +func (p *Pulse) Name() string { + return pulseTitle +} + +// App returns the current app handle. +func (p *Pulse) App() *App { + return p.app +} + +// SetInstance sets specific resource instance. +func (p *Pulse) SetInstance(string) {} + +// SetEnvFn sets the custom environment function. +func (p *Pulse) SetEnvFn(EnvFunc) {} + +// SetBindKeysFn sets up extra key bindings. +func (p *Pulse) SetBindKeysFn(BindKeysFunc) {} + +// SetContextFn sets custom context. +func (p *Pulse) SetContextFn(ContextFunc) {} + +// GetTable return the view table if any. +func (p *Pulse) GetTable() *Table { + return nil +} + +// Actions returns active menu bindings. +func (p *Pulse) Actions() ui.KeyActions { + return p.actions +} + +// Hints returns the view hints. +func (p *Pulse) Hints() model.MenuHints { + return p.actions.Hints() +} + +// ExtraHints returns additional hints. +func (p *Pulse) ExtraHints() map[string]string { + return nil +} + +func (p *Pulse) sparkFocusCmd(i int) func(evt *tcell.EventKey) *tcell.EventKey { + return func(evt *tcell.EventKey) *tcell.EventKey { + p.app.SetFocus(p.charts[i]) + return nil + } +} + +func (p *Pulse) enterCmd(evt *tcell.EventKey) *tcell.EventKey { + v := p.App().GetFocus() + s, ok := v.(Grapheable) + if !ok { + return nil + } + gvr := client.NewGVR(s.ID()) + if err := p.App().gotoResource(gvr.R()+" all", "", false); err != nil { + p.App().Flash().Err(err) + } + + return nil +} + +func (p *Pulse) nextFocusCmd(direction int) func(evt *tcell.EventKey) *tcell.EventKey { + return func(evt *tcell.EventKey) *tcell.EventKey { + v := p.app.GetFocus() + index := findIndex(p.charts, v) + p.GetItem(index).Focus = false + p.GetItem(index).Item.Blur() + i, v := nextFocus(p.charts, index+direction) + p.GetItem(i).Focus = true + p.app.SetFocus(v) + + return nil + } +} + +func (p *Pulse) makeSP(loc image.Point, span image.Point, gvr string) *tchart.SparkLine { + s := tchart.NewSparkLine(gvr) + s.SetBackgroundColor(p.app.Styles.Charts().BgColor.Color()) + s.SetBorderPadding(0, 1, 0, 1) + if cc, ok := p.app.Styles.Charts().ResourceColors[gvr]; ok { + s.SetSeriesColors(cc.Colors()...) + } else { + s.SetSeriesColors(p.app.Styles.Charts().DefaultChartColors.Colors()...) + } + s.SetLegend(fmt.Sprintf(" %s ", strings.Title(client.NewGVR(gvr).R()))) + s.SetInputCapture(p.keyboard) + p.AddItem(s, loc.X, loc.Y, span.X, span.Y, 0, 0, true) + + return s +} + +func (p *Pulse) makeGA(loc image.Point, span image.Point, gvr string) *tchart.Gauge { + g := tchart.NewGauge(gvr) + g.SetBackgroundColor(p.app.Styles.Charts().BgColor.Color()) + g.SetBorderPadding(0, 1, 0, 1) + if cc, ok := p.app.Styles.Charts().ResourceColors[gvr]; ok { + g.SetSeriesColors(cc.Colors()...) + } else { + g.SetSeriesColors(p.app.Styles.Charts().DefaultDialColors.Colors()...) + } + g.SetLegend(fmt.Sprintf(" %s ", strings.Title(client.NewGVR(gvr).R()))) + g.SetInputCapture(p.keyboard) + p.AddItem(g, loc.X, loc.Y, span.X, span.Y, 0, 0, true) + + return g +} + +// ---------------------------------------------------------------------------- +// Helpers + +func nextFocus(pp []Grapheable, index int) (int, tview.Primitive) { + if index >= len(pp) { + return 0, pp[0] + } + + if index < 0 { + return len(pp) - 1, pp[len(pp)-1] + } + + return index, pp[index] +} + +func findIndex(pp []Grapheable, p tview.Primitive) int { + for i, v := range pp { + if v == p { + return i + } + } + return 0 +} + +func findIndexGVR(pp []Grapheable, gvr string) (int, bool) { + for i, v := range pp { + if v.(Grapheable).ID() == gvr { + return i, true + } + } + return 0, false +} diff --git a/internal/view/rbac_test.go b/internal/view/rbac_test.go index 63cfad8b..2c0972c8 100644 --- a/internal/view/rbac_test.go +++ b/internal/view/rbac_test.go @@ -13,5 +13,5 @@ func TestRbacNew(t *testing.T) { assert.Nil(t, v.Init(makeCtx())) assert.Equal(t, "Rbac", v.Name()) - assert.Equal(t, 3, len(v.Hints())) + assert.Equal(t, 5, len(v.Hints())) } diff --git a/internal/view/registrar.go b/internal/view/registrar.go index 9631348d..9adf1eb4 100644 --- a/internal/view/registrar.go +++ b/internal/view/registrar.go @@ -69,6 +69,9 @@ func miscViewers(vv MetaViewers) { vv[client.NewGVR("aliases")] = MetaViewer{ viewerFn: NewAlias, } + vv[client.NewGVR("pulses")] = MetaViewer{ + viewerFn: NewPulse, + } } func appsViewers(vv MetaViewers) { @@ -134,7 +137,7 @@ func extViewers(vv MetaViewers) { func showCRD(app *App, _ ui.Tabular, _, path string) { _, crdGVR := client.Namespaced(path) tokens := strings.Split(crdGVR, ".") - if err := app.gotoResource(tokens[0], false); err != nil { + if err := app.gotoResource(tokens[0], "", false); err != nil { app.Flash().Err(err) } } diff --git a/internal/view/screen_dump_test.go b/internal/view/screen_dump_test.go index 59b1756f..0e191f1b 100644 --- a/internal/view/screen_dump_test.go +++ b/internal/view/screen_dump_test.go @@ -13,5 +13,5 @@ func TestScreenDumpNew(t *testing.T) { assert.Nil(t, po.Init(makeCtx())) assert.Equal(t, "ScreenDumps", po.Name()) - assert.Equal(t, 3, len(po.Hints())) + assert.Equal(t, 5, len(po.Hints())) } diff --git a/internal/view/secret_test.go b/internal/view/secret_test.go index 8823ab3c..d75cd10f 100644 --- a/internal/view/secret_test.go +++ b/internal/view/secret_test.go @@ -13,5 +13,5 @@ func TestSecretNew(t *testing.T) { assert.Nil(t, s.Init(makeCtx())) assert.Equal(t, "Secrets", s.Name()) - assert.Equal(t, 4, len(s.Hints())) + assert.Equal(t, 6, len(s.Hints())) } diff --git a/internal/view/sts_test.go b/internal/view/sts_test.go index 57deed3f..30a5de22 100644 --- a/internal/view/sts_test.go +++ b/internal/view/sts_test.go @@ -13,5 +13,5 @@ func TestStatefulSetNew(t *testing.T) { assert.Nil(t, s.Init(makeCtx())) assert.Equal(t, "StatefulSets", s.Name()) - assert.Equal(t, 9, len(s.Hints())) + assert.Equal(t, 11, len(s.Hints())) } diff --git a/internal/view/svc.go b/internal/view/svc.go index 51711e63..30f95a90 100644 --- a/internal/view/svc.go +++ b/internal/view/svc.go @@ -9,6 +9,7 @@ import ( "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/dao" + "github.com/derailed/k9s/internal/model" "github.com/derailed/k9s/internal/perf" "github.com/derailed/k9s/internal/ui" "github.com/gdamore/tcell" @@ -85,7 +86,7 @@ func (s *Service) getExternalPort(row int) (string, error) { func (s *Service) toggleBenchCmd(evt *tcell.EventKey) *tcell.EventKey { if s.bench != nil { log.Debug().Msg(">>> Benchmark canceled!!") - s.App().Status(ui.FlashErr, "Benchmark Canceled!") + s.App().Status(model.FlashErr, "Benchmark Canceled!") s.bench.Cancel() s.App().ClearStatus(true) return nil @@ -140,7 +141,7 @@ func (s *Service) runBenchmark(port string, cfg config.BenchConfig) error { return err } - s.App().Status(ui.FlashWarn, "Benchmark in progress...") + s.App().Status(model.FlashWarn, "Benchmark in progress...") log.Debug().Msg("Bench starting...") go s.bench.Run(s.App().Config.K9s.CurrentCluster, s.benchDone) @@ -151,9 +152,9 @@ func (s *Service) benchDone() { log.Debug().Msg("Bench Completed!") s.App().QueueUpdate(func() { if s.bench.Canceled() { - s.App().Status(ui.FlashInfo, "Benchmark canceled") + s.App().Status(model.FlashInfo, "Benchmark canceled") } else { - s.App().Status(ui.FlashInfo, "Benchmark Completed!") + s.App().Status(model.FlashInfo, "Benchmark Completed!") s.bench.Cancel() } s.bench = nil diff --git a/internal/view/svc_test.go b/internal/view/svc_test.go index a9a5bd1c..9bf3756d 100644 --- a/internal/view/svc_test.go +++ b/internal/view/svc_test.go @@ -132,5 +132,5 @@ func TestServiceNew(t *testing.T) { assert.Nil(t, s.Init(makeCtx())) assert.Equal(t, "Services", s.Name()) - assert.Equal(t, 8, len(s.Hints())) + assert.Equal(t, 10, len(s.Hints())) } diff --git a/internal/view/table.go b/internal/view/table.go index faa62541..56641086 100644 --- a/internal/view/table.go +++ b/internal/view/table.go @@ -132,10 +132,24 @@ func (t *Table) bindKeys() { tcell.KeyBackspace: ui.NewSharedKeyAction("Erase", t.eraseCmd, false), tcell.KeyDelete: ui.NewSharedKeyAction("Erase", t.eraseCmd, false), ui.KeyShiftN: ui.NewKeyAction("Sort Name", t.SortColCmd(0, true), false), + tcell.KeyCtrlZ: ui.NewKeyAction("Toggle Faults", t.toggleFaultCmd, false), ui.KeyShiftA: ui.NewKeyAction("Sort Age", t.SortColCmd(-1, true), false), + tcell.KeyCtrlW: ui.NewKeyAction("Show Wide", t.toggleWideCmd, false), }) } +func (t *Table) toggleFaultCmd(evt *tcell.EventKey) *tcell.EventKey { + t.ToggleToast() + + return nil +} + +func (t *Table) toggleWideCmd(evt *tcell.EventKey) *tcell.EventKey { + t.ToggleWide() + + return nil +} + func (t *Table) cpCmd(evt *tcell.EventKey) *tcell.EventKey { path := t.GetSelectedItem() if path == "" { diff --git a/internal/view/table_int_test.go b/internal/view/table_int_test.go index f98700c4..336acadc 100644 --- a/internal/view/table_int_test.go +++ b/internal/view/table_int_test.go @@ -97,6 +97,7 @@ func (t *testTableModel) Peek() render.TableData { return makeTableData func (t *testTableModel) ClusterWide() bool { return false } func (t *testTableModel) GetNamespace() string { return "blee" } func (t *testTableModel) SetNamespace(string) {} +func (t *testTableModel) ToggleToast() {} func (t *testTableModel) AddListener(model.TableListener) {} func (t *testTableModel) Watch(context.Context) {} func (t *testTableModel) Get(context.Context, string) (runtime.Object, error) { diff --git a/internal/view/xray.go b/internal/view/xray.go index 201f26fd..dd91a97e 100644 --- a/internal/view/xray.go +++ b/internal/view/xray.go @@ -25,6 +25,8 @@ import ( const xrayTitle = "Xray" +var _ ResourceViewer = (*Xray)(nil) + // Xray represents an xray tree view. type Xray struct { *ui.Tree @@ -37,8 +39,6 @@ type Xray struct { envFn EnvFunc } -var _ ResourceViewer = (*Xray)(nil) - // NewXray returns a new view. func NewXray(gvr client.GVR) ResourceViewer { return &Xray{ @@ -68,10 +68,10 @@ func (x *Xray) Init(ctx context.Context) error { } x.bindKeys() - x.SetBackgroundColor(config.AsColor(x.app.Styles.Xray().BgColor)) - x.SetBorderColor(config.AsColor(x.app.Styles.Xray().FgColor)) - x.SetBorderFocusColor(config.AsColor(x.app.Styles.Frame().Border.FocusColor)) - x.SetGraphicsColor(config.AsColor(x.app.Styles.Xray().GraphicColor)) + x.SetBackgroundColor(x.app.Styles.Xray().BgColor.Color()) + x.SetBorderColor(x.app.Styles.Xray().FgColor.Color()) + x.SetBorderFocusColor(x.app.Styles.Frame().Border.FocusColor.Color()) + x.SetGraphicsColor(x.app.Styles.Xray().GraphicColor.Color()) x.SetTitle(fmt.Sprintf(" %s-%s ", xrayTitle, strings.Title(x.gvr.R()))) x.model.SetRefreshRate(time.Duration(x.app.Config.K9s.GetRefreshRate()) * time.Second) @@ -504,7 +504,7 @@ func (x *Xray) TreeNodeSelected() { x.app.QueueUpdateDraw(func() { n := x.GetCurrentNode() if n != nil { - n.SetColor(config.AsColor(x.app.Styles.Xray().CursorColor)) + n.SetColor(x.app.Styles.Xray().CursorColor.Color()) } }) } @@ -573,7 +573,7 @@ func (x *Xray) hydrate(parent *tview.TreeNode, n *xray.TreeNode) { // SetEnvFn sets the custom environment function. func (x *Xray) SetEnvFn(EnvFunc) {} -// Refresh refresh the view +// Refresh updates the view func (x *Xray) Refresh() { } @@ -729,7 +729,7 @@ func makeTreeNode(node *xray.TreeNode, expanded bool, styles *config.Styles) *tv } n.SetSelectable(true) n.SetExpanded(expanded) - n.SetColor(config.AsColor(styles.Xray().CursorColor)) + n.SetColor(styles.Xray().CursorColor.Color()) n.SetSelectedFunc(func() { n.SetExpanded(!n.IsExpanded()) }) diff --git a/internal/view/yaml.go b/internal/view/yaml.go index 9ab389e9..356568dc 100644 --- a/internal/view/yaml.go +++ b/internal/view/yaml.go @@ -28,14 +28,14 @@ func colorizeYAML(style config.Yaml, raw string) string { // lines := strings.Split(raw, "\n") lines := strings.Split(tview.Escape(raw), "\n") - fullFmt := strings.Replace(yamlFullFmt, "[key", "["+style.KeyColor, 1) - fullFmt = strings.Replace(fullFmt, "[colon", "["+style.ColonColor, 1) - fullFmt = strings.Replace(fullFmt, "[val", "["+style.ValueColor, 1) + fullFmt := strings.Replace(yamlFullFmt, "[key", "["+style.KeyColor.String(), 1) + fullFmt = strings.Replace(fullFmt, "[colon", "["+style.ColonColor.String(), 1) + fullFmt = strings.Replace(fullFmt, "[val", "["+style.ValueColor.String(), 1) - keyFmt := strings.Replace(yamlKeyFmt, "[key", "["+style.KeyColor, 1) - keyFmt = strings.Replace(keyFmt, "[colon", "["+style.ColonColor, 1) + keyFmt := strings.Replace(yamlKeyFmt, "[key", "["+style.KeyColor.String(), 1) + keyFmt = strings.Replace(keyFmt, "[colon", "["+style.ColonColor.String(), 1) - valFmt := strings.Replace(yamlValueFmt, "[val", "["+style.ValueColor, 1) + valFmt := strings.Replace(yamlValueFmt, "[val", "["+style.ValueColor.String(), 1) buff := make([]string, 0, len(lines)) for _, l := range lines { diff --git a/plugins/job_suspend.yml b/plugins/job_suspend.yml index 3d4b4ae7..06f0de45 100644 --- a/plugins/job_suspend.yml +++ b/plugins/job_suspend.yml @@ -11,9 +11,9 @@ plugin: - patch - cronjobs - $NAME - - ns + - -n - $NAMESPACE - --context - $CONTEXT - -p - - '{"spec" : {"suspend" : $COL3 }}' + - '{"spec" : {"suspend" : $!COL3 }}' diff --git a/skins/black_and_wtf.yml b/skins/black_and_wtf.yml index 73fe07d8..07009922 100644 --- a/skins/black_and_wtf.yml +++ b/skins/black_and_wtf.yml @@ -45,22 +45,22 @@ k9s: highlightColor: *active counterColor: *text filterColor: *slate - table: - fgColor: *fg - bgColor: *bg - cursorColor: *fg - markColor: *mark - header: - fgColor: *dslate - bgColor: *bg - sorterColor: *fg - xray: - fgColor: *fg - bgColor: *bg - cursorColor: *ghost - graphicColor: gray - showIcons: false views: + table: + fgColor: *fg + bgColor: *bg + cursorColor: *fg + markColor: *mark + header: + fgColor: *dslate + bgColor: *bg + sorterColor: *fg + xray: + fgColor: *fg + bgColor: *bg + cursorColor: *ghost + graphicColor: gray + showIcons: false yaml: keyColor: *ghost colorColor: *slate @@ -68,3 +68,11 @@ k9s: logs: fgColor: *ghost bgColor: *bg + charts: + bgColor: default + defaultDialColors: + - *white + - *err + defaultChartColors: + - *white + - *err diff --git a/skins/dracula.yml b/skins/dracula.yml index 801b2250..4ba08cd8 100644 --- a/skins/dracula.yml +++ b/skins/dracula.yml @@ -54,24 +54,33 @@ k9s: highlightColor: *orange counterColor: *purple filterColor: *pink - # TableView attributes. - table: - fgColor: *foreground - bgColor: *background - cursorColor: *current_line - # Header row styles. - header: + views: + # Charts skins... + charts: + bgColor: default + defaultDialColors: + - *purple + - *red + defaultChartColors: + - *purple + - *red + # TableView attributes. + table: fgColor: *foreground bgColor: *background - sorterColor: *cyan - # Xray view attributes. - xray: - fgColor: *foreground - bgColor: *background - cursorColor: *current_line - graphicColor: *purple - showIcons: false - views: + cursorColor: *current_line + # Header row styles. + header: + fgColor: *foreground + bgColor: *background + sorterColor: *cyan + # Xray view attributes. + xray: + fgColor: *foreground + bgColor: *background + cursorColor: *current_line + graphicColor: *purple + showIcons: false # YAML info styles. yaml: keyColor: *pink diff --git a/skins/in_the_navy.yml b/skins/in_the_navy.yml index 3a85f1bb..ed101190 100644 --- a/skins/in_the_navy.yml +++ b/skins/in_the_navy.yml @@ -53,22 +53,30 @@ k9s: highlightColor: *sky counterColor: *slate filterColor: *slate - table: - fgColor: *fg - bgColor: *bg - cursorColor: *aqua - markColor: *mslate - header: + views: + table: fgColor: *fg bgColor: *bg - sorterColor: *cadet - xray: - fgColor: *blue - bgColor: *dark - cursorColor: *aqua - graphicColor: *mslate - showIcons: false - views: + cursorColor: *aqua + markColor: *mslate + header: + fgColor: *fg + bgColor: *bg + sorterColor: *cadet + xray: + fgColor: *blue + bgColor: *dark + cursorColor: *aqua + graphicColor: *mslate + showIcons: false + charts: + bgColor: *bg + defaultDialColors: + - *aqua + - *err + defaultChartColors: + - *aqua + - *err yaml: keyColor: *steel colorColor: *blue diff --git a/skins/snazzy.yml b/skins/snazzy.yml index 55a022ea..11cf5240 100644 --- a/skins/snazzy.yml +++ b/skins/snazzy.yml @@ -32,22 +32,31 @@ k9s: highlightColor: white counterColor: white filterColor: "#57c7ff" - table: - fgColor: "#57c7ff" - bgColor: "#282a36" - cursorColor: "#5af78e" - markColor: darkgoldenrod - header: - fgColor: white - bgColor: "#282a36" - sorterColor: orange - xray: - fgColor: "#57c7ff" - bgColor: "#282a36" - cursorColor: "#5af78e" - graphicColor: darkgoldenrod - showIcons: false views: + # Charts skins... + charts: + bgColor: default + defaultDialColors: + - "#57c7ff" + - "#ff5c57" + defaultChartColors: + - "#57c7ff" + - "#ff5c57" + table: + fgColor: "#57c7ff" + bgColor: "#282a36" + cursorColor: "#5af78e" + markColor: darkgoldenrod + header: + fgColor: white + bgColor: "#282a36" + sorterColor: orange + xray: + fgColor: "#57c7ff" + bgColor: "#282a36" + cursorColor: "#5af78e" + graphicColor: darkgoldenrod + showIcons: false yaml: keyColor: "#ff5c57" colonColor: white diff --git a/skins/stock.yml b/skins/stock.yml index acbf7dc0..33c9538c 100644 --- a/skins/stock.yml +++ b/skins/stock.yml @@ -31,22 +31,31 @@ k9s: highlightColor: fuchsia counterColor: papayawhip filterColor: steelblue - table: - fgColor: blue - bgColor: black - cursorColor: aqua - markColor: darkgoldenrod - header: - fgColor: white - bgColor: black - sorterColor: orange - xray: - fgColor: blue - bgColor: black - cursorColor: aqua - graphicColor: darkgoldenrod - showIcons: false views: + # Charts skins... + charts: + bgColor: black + defaultDialColors: + - linegreen + - orangered + defaultChartColors: + - linegreen + - orangered + table: + fgColor: blue + bgColor: black + cursorColor: aqua + markColor: darkgoldenrod + header: + fgColor: white + bgColor: black + sorterColor: orange + xray: + fgColor: blue + bgColor: black + cursorColor: aqua + graphicColor: darkgoldenrod + showIcons: false yaml: keyColor: steelblue colonColor: white