Merge branch 'master' into attach

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

1
.gitignore vendored
View File

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

View File

@ -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}}

View File

@ -99,8 +99,9 @@ K9s is available on Linux, macOS and Windows platforms.
---
## Demo Video
## Demo Videos/Recordings
* [K9s Pulses](https://asciinema.org/a/UbXKPal6IWpTaVAjBBFmizcGN)
* [K9s v0.15.1](https://youtu.be/7Fx4XQ2ftpM)
* [K9s v0.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`<ENTER>` | To view and switch to another Kubernetes namespace | `:`+`ns`+`<ENTER>` |
| `:screendump`, `:sd` | To view all saved resources | |
| `Ctrl-d` | To delete a resource (TAB and ENTER to confirm) | |
| `Ctrl-k` | To 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

BIN
assets/k9s_doc.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

BIN
assets/k9s_health.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 731 KiB

View File

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

View File

@ -0,0 +1,23 @@
<img src="https://raw.githubusercontent.com/derailed/k9s/master/assets/k9s_small.png" align="right" width="200" height="auto"/>
# Release v0.16.1
## Notes
Thank you to all that contributed with flushing out issues and enhancements for K9s! I'll try to mark some of these issues as fixed. But if you don't mind grab the latest rev and see if we're happier with some of the fixes! If you've filed an issue please help me verify and close. Your support, kindness and awesome suggestions to make K9s better is as ever very much noticed and appreciated!
Also if you dig this tool, please make some noise on social! [@kitesurfer](https://twitter.com/kitesurfer)
On Slack? Please join us [K9slackers](https://join.slack.com/t/k9sers/shared_invite/enQtOTA5MDEyNzI5MTU0LWQ1ZGI3MzliYzZhZWEyNzYxYzA3NjE0YTk1YmFmNzViZjIyNzhkZGI0MmJjYzhlNjdlMGJhYzE2ZGU1NjkyNTM)
---
Maintenance Release!
## Resolved Bugs/Features/PRs
- [Issue #561](https://github.com/derailed/k9s/issues/561)
---
<img src="https://raw.githubusercontent.com/derailed/k9s/master/assets/imhotep_logo.png" width="32" height="auto"/> © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0)

6
go.mod
View File

@ -34,7 +34,7 @@ require (
github.com/alexellis/go-execute v0.0.0-20200124154445-8697e4e28c5e // indirect
github.com/alexellis/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

6
go.sum
View File

@ -157,6 +157,8 @@ github.com/derailed/tview v0.3.4 h1:PnF64fLqm48LEjC/XwOS7JufDgFuuPYx85YVt5t3rwE=
github.com/derailed/tview v0.3.4/go.mod h1:yApPszFU62FoaGkf7swy2nIdV/h7Nid3dhMSVy6+OFI=
github.com/derailed/tview v0.3.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=

View File

@ -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
}

View File

@ -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
}

View File

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

View File

@ -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...")

View File

@ -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))
}

View File

@ -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)
}

View File

@ -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)

View File

@ -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))

View File

@ -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)

View File

@ -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)

View File

@ -34,10 +34,14 @@ func (n *Node) List(ctx context.Context, ns string) ([]runtime.Object, error) {
log.Warn().Msgf("No label selector found in context")
}
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)

View File

@ -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)
}

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

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

View File

@ -142,6 +142,13 @@ func loadNonResource(m ResourceMetas) {
}
func loadK9s(m ResourceMetas) {
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",

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

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

View File

@ -29,6 +29,11 @@ type StatefulSet struct {
Resource
}
// 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)

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

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

View File

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

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

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

View File

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

View File

@ -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()),
}
}

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

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

View File

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

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

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

View File

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

View File

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

View File

@ -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) {

View File

@ -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)
}

View File

@ -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) {

View File

@ -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)

View File

@ -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)
}

View File

@ -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
}

View File

@ -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...

View File

@ -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...

View File

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

View File

@ -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),
}

View File

@ -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),
}

View File

@ -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])
}

View File

@ -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}),
}

View File

@ -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, ",")
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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

View File

@ -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 += " "
}
}

View File

@ -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 {

View File

@ -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),
)

View File

@ -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),
)

View File

@ -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...

View File

@ -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])
}

View File

@ -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) {

View File

@ -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),
)

View File

@ -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
}

View File

@ -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...

View File

@ -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 {

View File

@ -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...

View File

@ -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])
}

View File

@ -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
}

View File

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

View File

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

View File

@ -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(),
}

View File

@ -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...

View File

@ -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
}

View File

@ -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
}

View File

@ -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),
)

View File

@ -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)
}

View File

@ -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])
}

View File

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

View File

@ -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)
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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),
)

View File

@ -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),
}

View File

@ -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()),
}

View File

@ -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])
}

View File

@ -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
}

View File

@ -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])
}

View File

@ -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

View File

@ -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 "<pending>"
}
return MissingValue
return ""
}
sort.Strings(ips)

View File

@ -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", "<none>", "app=dictionary1", "http:4001►0"}, r.Fields[:7])
assert.Equal(t, render.Fields{"default", "dictionary1", "ClusterIP", "10.47.248.116", "", "app=dictionary1", "http:4001►0"}, r.Fields[:7])
}

View File

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

View File

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

View File

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

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

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

View File

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

View File

@ -3,6 +3,7 @@ package ui
import (
"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...

View File

@ -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())

View File

@ -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()
}

View File

@ -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
}

View File

@ -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))
})
}
}

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