diff --git a/.goreleaser.yml b/.goreleaser.yml index 2643bb05..dbc015af 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -16,6 +16,7 @@ builds: - 386 - amd64 - arm64 + - armhf goarm: - 6 - 7 diff --git a/assets/k9s_health.png b/assets/k9s_health.png new file mode 100644 index 00000000..e653dcfd Binary files /dev/null and b/assets/k9s_health.png differ diff --git a/go.mod b/go.mod index d6e03b95..25af63b4 100644 --- a/go.mod +++ b/go.mod @@ -43,14 +43,14 @@ require ( github.com/gdamore/tcell v1.3.0 github.com/ghodss/yaml v1.0.0 github.com/gregjones/httpcache v0.0.0-20190212212710-3befbb6ad0cc // indirect - github.com/kylelemons/godebug v1.1.0 // indirect github.com/mattn/go-runewidth v0.0.5 github.com/openfaas/faas v0.0.0-20200207215241-6afae214e3ec github.com/openfaas/faas-cli v0.0.0-20200124160744-30b7cec9634c github.com/openfaas/faas-provider v0.15.0 github.com/petergtz/pegomock v2.6.0+incompatible github.com/rakyll/hey v0.1.2 - github.com/rs/zerolog v1.17.2 + github.com/rivo/tview v0.0.0-20191018115645-bacbf5155bc1 + github.com/rs/zerolog v1.18.0 github.com/ryanuber/go-glob v1.0.0 // indirect github.com/sahilm/fuzzy v0.1.0 github.com/spf13/cobra v0.0.5 diff --git a/go.sum b/go.sum index c28d347a..0980b97e 100644 --- a/go.sum +++ b/go.sum @@ -483,6 +483,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 +566,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 +577,8 @@ github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFR github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ= github.com/rs/zerolog v1.17.2 h1:RMRHFw2+wF7LO0QqtELQwo8hqSmqISyCJeFeAAuWcRo= github.com/rs/zerolog v1.17.2/go.mod h1:9nvC1axdVrAHcu/s9taAVfBuIdTZLVQmKQyvrUjF5+I= +github.com/rs/zerolog v1.18.0 h1:CbAm3kP2Tptby1i9sYy2MGRg0uxIN9cyDb59Ys7W8z8= +github.com/rs/zerolog v1.18.0/go.mod h1:9nvC1axdVrAHcu/s9taAVfBuIdTZLVQmKQyvrUjF5+I= github.com/rubiojr/go-vhd v0.0.0-20160810183302-0bfd3b39853c/go.mod h1:DM5xW0nvfNNm2uytzsvhI3OnX8uzaRAg8UX/CnDqbto= github.com/russross/blackfriday v0.0.0-20170610170232-067529f716f4/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= github.com/russross/blackfriday v1.5.2 h1:HyvC0ARfnZBqnXwABFeSZHpKvJHJJfPz81GNueLj0oo= diff --git a/internal/client/metrics.go b/internal/client/metrics.go index a966ec24..25b137f9 100644 --- a/internal/client/metrics.go +++ b/internal/client/metrics.go @@ -3,20 +3,46 @@ 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 +) + +var MetricsDial *MetricsServer + +func DialMetrics(c Connection) *MetricsServer { + if MetricsDial == nil { + MetricsDial = NewMetricsServer(c) + } + + return MetricsDial +} + +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 +54,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 +77,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 +100,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 +228,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 +239,8 @@ func (m *MetricsServer) PodsMetrics(pods *mv1beta1.PodMetricsList, mmx PodsMetri const megaByte = 1024 * 1024 -// toMB converts bytes to megabytes. -func toMB(v int64) float64 { +// ToMB converts bytes to megabytes. +func ToMB(v int64) float64 { return float64(v) / megaByte } diff --git a/internal/client/types.go b/internal/client/types.go index 48fe4146..48286663 100644 --- a/internal/client/types.go +++ b/internal/client/types.go @@ -22,6 +22,9 @@ const ( // ClusterScope designates a resource is not namespaced. ClusterScope = "-" + + // NotNamespaced designates a non resource namespace. + NotNamespaced = "*" ) const ( diff --git a/internal/config/alias.go b/internal/config/alias.go index 622f94e3..04265498 100644 --- a/internal/config/alias.go +++ b/internal/config/alias.go @@ -31,65 +31,6 @@ func NewAliases() *Aliases { } } -func (a *Aliases) loadDefaults() { - const ( - contexts = "contexts" - portFwds = "portforwards" - benchmarks = "benchmarks" - dumps = "screendumps" - groups = "groups" - users = "users" - ) - - 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" - { - a.Alias["ctx"] = contexts - a.Alias[contexts] = contexts - a.Alias["context"] = contexts - } - { - a.Alias["usr"] = users - a.Alias[users] = users - a.Alias["user"] = users - } - { - a.Alias["grp"] = groups - a.Alias["group"] = groups - a.Alias[groups] = groups - } - { - a.Alias["pf"] = portFwds - a.Alias[portFwds] = portFwds - a.Alias["portforward"] = portFwds - } - { - a.Alias["be"] = benchmarks - a.Alias["benchmark"] = benchmarks - a.Alias[benchmarks] = benchmarks - } - { - a.Alias["sd"] = dumps - a.Alias["screendump"] = dumps - a.Alias[dumps] = dumps - } -} - -// Load K9s aliases. -func (a *Aliases) Load() error { - a.loadDefaults() - return a.LoadAliases(K9sAlias) -} - // ShortNames return all shortnames. func (a *Aliases) ShortNames() ShortNames { a.mx.RLock() @@ -139,8 +80,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 +108,63 @@ func (a *Aliases) LoadAliases(path string) error { return nil } +func (a *Aliases) loadDefaultAliases() { + a.mx.Lock() + defer a.mx.Unlock() + + a.Alias["dp"] = "apps/v1/deployments" + a.Alias["sec"] = "v1/secrets" + a.Alias["jo"] = "batch/v1/jobs" + a.Alias["cr"] = "rbac.authorization.k8s.io/v1/clusterroles" + a.Alias["crb"] = "rbac.authorization.k8s.io/v1/clusterrolebindings" + a.Alias["ro"] = "rbac.authorization.k8s.io/v1/roles" + a.Alias["rb"] = "rbac.authorization.k8s.io/v1/rolebindings" + a.Alias["np"] = "networking.k8s.io/v1/networkpolicies" + + const contexts = "contexts" + { + a.Alias["ctx"] = contexts + a.Alias[contexts] = contexts + a.Alias["context"] = contexts + } + const users = "users" + { + a.Alias["usr"] = users + a.Alias[users] = users + a.Alias["user"] = users + } + const groups = "groups" + { + a.Alias["grp"] = groups + a.Alias["group"] = groups + a.Alias[groups] = groups + } + const portFwds = "portforwards" + { + a.Alias["pf"] = portFwds + a.Alias[portFwds] = portFwds + a.Alias["portforward"] = portFwds + } + const benchmarks = "benchmarks" + { + a.Alias["be"] = benchmarks + a.Alias["benchmark"] = benchmarks + a.Alias[benchmarks] = benchmarks + } + const dumps = "screendumps" + { + a.Alias["sd"] = dumps + a.Alias["screendump"] = dumps + a.Alias[dumps] = dumps + } + const pulses = "pulses" + { + a.Alias["hz"] = pulses + a.Alias["pu"] = pulses + a.Alias["pulse"] = pulses + } +} + // Save alias to disk. func (a *Aliases) Save() error { log.Debug().Msg("[Config] Saving Aliases...") diff --git a/internal/config/alias_test.go b/internal/config/alias_test.go index 49e3c445..a627ddd2 100644 --- a/internal/config/alias_test.go +++ b/internal/config/alias_test.go @@ -72,7 +72,7 @@ func TestAliasDefine(t *testing.T) { func TestAliasesLoad(t *testing.T) { a := config.NewAliases() - assert.Nil(t, a.LoadAliases("testdata/alias.yml")) + assert.Nil(t, a.LoadFileAliases("testdata/alias.yml")) assert.Equal(t, 2, len(a.Alias)) } @@ -82,6 +82,6 @@ func TestAliasesSave(t *testing.T) { a.Alias["blee"] = "duh" assert.Nil(t, a.SaveAliases("/tmp/a.yml")) - assert.Nil(t, a.LoadAliases("/tmp/a.yml")) + assert.Nil(t, a.LoadFileAliases("/tmp/a.yml")) assert.Equal(t, 2, len(a.Alias)) } diff --git a/internal/config/styles.go b/internal/config/styles.go index 97fe0dbd..b45c17a0 100644 --- a/internal/config/styles.go +++ b/internal/config/styles.go @@ -21,17 +21,31 @@ type StyleListener interface { } type ( + // Color represents a color. + Color string + + // Colors tracks multiple colors. + Colors []Color + // Styles tracks K9s styling options. Styles struct { K9s Style `yaml:"k9s"` listeners []StyleListener } + // Style tracks K9s styles. + Style struct { + Body Body `yaml:"body"` + Frame Frame `yaml:"frame"` + Info Info `yaml:"info"` + Views Views `yaml:"views"` + } + // Body tracks body styles. Body struct { - FgColor string `yaml:"fgColor"` - BgColor string `yaml:"bgColor"` - LogoColor string `yaml:"logoColor"` + FgColor Color `yaml:"fgColor"` + BgColor Color `yaml:"bgColor"` + LogoColor Color `yaml:"logoColor"` } // Frame tracks frame styles. @@ -45,120 +59,171 @@ type ( // Views tracks individual view styles. Views struct { - Yaml Yaml `yaml:"yaml"` - Log Log `yaml:"logs"` + Table Table `yaml:"table"` + Xray Xray `yaml:"xray"` + Charts Charts `yaml:"charts"` + Yaml Yaml `yaml:"yaml"` + Log Log `yaml:"logs"` } // Status tracks resource status styles. Status struct { - NewColor string `yaml:"newColor"` - ModifyColor string `yaml:"modifyColor"` - AddColor string `yaml:"addColor"` - ErrorColor string `yaml:"errorColor"` - HighlightColor string `yaml:"highlightColor"` - KillColor string `yaml:"killColor"` - CompletedColor string `yaml:"completedColor"` + NewColor Color `yaml:"newColor"` + ModifyColor Color `yaml:"modifyColor"` + AddColor Color `yaml:"addColor"` + ErrorColor Color `yaml:"errorColor"` + HighlightColor Color `yaml:"highlightColor"` + KillColor Color `yaml:"killColor"` + CompletedColor Color `yaml:"completedColor"` } // Log tracks Log styles. Log struct { - FgColor string `yaml:"fgColor"` - BgColor string `yaml:"bgColor"` + FgColor Color `yaml:"fgColor"` + BgColor Color `yaml:"bgColor"` } // Yaml tracks yaml styles. Yaml struct { - KeyColor string `yaml:"keyColor"` - ValueColor string `yaml:"valueColor"` - ColonColor string `yaml:"colonColor"` + KeyColor Color `yaml:"keyColor"` + ValueColor Color `yaml:"valueColor"` + ColonColor Color `yaml:"colonColor"` } // Title tracks title styles. Title struct { - FgColor string `yaml:"fgColor"` - BgColor string `yaml:"bgColor"` - HighlightColor string `yaml:"highlightColor"` - CounterColor string `yaml:"counterColor"` - FilterColor string `yaml:"filterColor"` + FgColor Color `yaml:"fgColor"` + BgColor Color `yaml:"bgColor"` + HighlightColor Color `yaml:"highlightColor"` + CounterColor Color `yaml:"counterColor"` + FilterColor Color `yaml:"filterColor"` } // Info tracks info styles. Info struct { - SectionColor string `yaml:"sectionColor"` - FgColor string `yaml:"fgColor"` + SectionColor Color `yaml:"sectionColor"` + FgColor Color `yaml:"fgColor"` } - // Border tracks border styles. + // ColorBorder 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) +} + +// AsColor 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()) +} + +// AsColors 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: "#111111", + DialBgColor: "#111111", + ChartBgColor: "#111111", + DefaultDialColors: Colors{Color("palegreen"), Color("orangered")}, + DefaultChartColors: Colors{Color("palegreen"), Color("orangered")}, + } +} func newViews() Views { return Views{ - Yaml: newYaml(), - Log: newLog(), + Table: newTable(), + Xray: newXray(), + Charts: newCharts(), + Yaml: newYaml(), + Log: newLog(), } } @@ -188,7 +253,7 @@ func newStatus() Status { ErrorColor: "orangered", HighlightColor: "aqua", KillColor: "mediumpurple", - CompletedColor: "gray", + CompletedColor: "lightgray", } } @@ -292,6 +357,11 @@ func NewStyles() *Styles { } } +// Reset resets styles. +func (s *Styles) Reset() { + s.K9s = newStyle() +} + // DefaultSkin loads the default skin func (s *Styles) DefaultSkin() { s.K9s = newStyle() @@ -299,12 +369,12 @@ func (s *Styles) DefaultSkin() { // FgColor returns the foreground color. func (s *Styles) FgColor() tcell.Color { - return AsColor(s.Body().FgColor) + return s.Body().FgColor.Color() } // BgColor returns the background color. func (s *Styles) BgColor() tcell.Color { - return AsColor(s.Body().BgColor) + return s.Body().BgColor.Color() } // AddListener registers a new listener. @@ -353,14 +423,19 @@ func (s *Styles) Title() Title { return s.Frame().Title } +// Charts returns charts styles. +func (s *Styles) Charts() Charts { + return s.K9s.Views.Charts +} + // Table returns table styles. func (s *Styles) Table() Table { - return s.K9s.Table + return s.K9s.Views.Table } // Xray returns xray styles. func (s *Styles) Xray() Xray { - return s.K9s.Xray + return s.K9s.Views.Xray } // Views returns views styles. @@ -388,19 +463,7 @@ func (s *Styles) Update() { tview.Styles.PrimitiveBackgroundColor = s.BgColor() tview.Styles.ContrastBackgroundColor = s.BgColor() tview.Styles.PrimaryTextColor = s.FgColor() - tview.Styles.BorderColor = AsColor(s.K9s.Frame.Border.FgColor) - tview.Styles.FocusColor = AsColor(s.K9s.Frame.Border.FocusColor) + tview.Styles.BorderColor = s.K9s.Frame.Border.FgColor.Color() + tview.Styles.FocusColor = s.K9s.Frame.Border.FocusColor.Color() s.fireStylesChanged() } - -// AsColor checks color index, if match return color otherwise pink it is. -func AsColor(c string) tcell.Color { - if c == "default" { - return tcell.ColorDefault - } - if color, ok := tcell.ColorNames[c]; ok { - return color - } - - return tcell.GetColor(c) -} diff --git a/internal/config/styles_test.go b/internal/config/styles_test.go index bdb0d7ee..a7b9c946 100644 --- a/internal/config/styles_test.go +++ b/internal/config/styles_test.go @@ -9,7 +9,7 @@ import ( "github.com/stretchr/testify/assert" ) -func TestAsColor(t *testing.T) { +func TestColor(t *testing.T) { uu := map[string]tcell.Color{ "blah": tcell.ColorDefault, "blue": tcell.ColorBlue, @@ -20,7 +20,7 @@ func TestAsColor(t *testing.T) { for k := range uu { c, u := k, uu[k] t.Run(k, func(t *testing.T) { - assert.Equal(t, u, config.AsColor(c)) + assert.Equal(t, u, config.NewColor(c).Color()) }) } } @@ -30,9 +30,9 @@ func TestSkinNone(t *testing.T) { assert.Nil(t, s.Load("testdata/empty_skin.yml")) s.Update() - assert.Equal(t, "cadetblue", s.Body().FgColor) - assert.Equal(t, "black", s.Body().BgColor) - assert.Equal(t, "black", s.Table().BgColor) + assert.Equal(t, "cadetblue", s.Body().FgColor.String()) + assert.Equal(t, "black", s.Body().BgColor.String()) + assert.Equal(t, "black", s.Table().BgColor.String()) assert.Equal(t, tcell.ColorCadetBlue, s.FgColor()) assert.Equal(t, tcell.ColorBlack, s.BgColor()) assert.Equal(t, tcell.ColorBlack, tview.Styles.PrimitiveBackgroundColor) @@ -43,9 +43,9 @@ func TestSkin(t *testing.T) { assert.Nil(t, s.Load("testdata/black_and_wtf.yml")) s.Update() - assert.Equal(t, "white", s.Body().FgColor) - assert.Equal(t, "black", s.Body().BgColor) - assert.Equal(t, "black", s.Table().BgColor) + assert.Equal(t, "white", s.Body().FgColor.String()) + assert.Equal(t, "black", s.Body().BgColor.String()) + assert.Equal(t, "black", s.Table().BgColor.String()) assert.Equal(t, tcell.ColorWhite, s.FgColor()) assert.Equal(t, tcell.ColorBlack, s.BgColor()) assert.Equal(t, tcell.ColorBlack, tview.Styles.PrimitiveBackgroundColor) diff --git a/internal/dao/container.go b/internal/dao/container.go index a655f505..bedfd2e8 100644 --- a/internal/dao/container.go +++ b/internal/dao/container.go @@ -33,23 +33,21 @@ func (c *Container) List(ctx context.Context, _ string) ([]runtime.Object, error if !ok { return nil, fmt.Errorf("no context path for %q", c.gvr) } + + var ( + pmx *mv1beta1.PodMetrics + err error + ) + if withMx, ok := ctx.Value(internal.KeyWithMetrics).(bool); withMx || !ok { + if pmx, err = client.DialMetrics(c.Client()).FetchPodMetrics(fqn); err != nil { + log.Warn().Err(err).Msgf("No metrics found for pod %q", fqn) + } + } + po, err := c.fetchPod(fqn) if err != nil { return nil, err } - - var pmx *mv1beta1.PodMetrics - if c.Client().HasMetrics() { - mx := client.NewMetricsServer(c.Client()) - if c.Client() != nil { - var err error - pmx, err = mx.FetchPodMetrics(fqn) - if err != nil { - log.Warn().Err(err).Msgf("No metrics found for pod %q", fqn) - } - } - } - res := make([]runtime.Object, 0, len(po.Spec.InitContainers)+len(po.Spec.Containers)) for _, co := range po.Spec.InitContainers { res = append(res, makeContainerRes(co, po, pmx, true)) diff --git a/internal/dao/dp.go b/internal/dao/dp.go index 6f937b25..4b9216c6 100644 --- a/internal/dao/dp.go +++ b/internal/dao/dp.go @@ -29,6 +29,11 @@ type Deployment struct { Resource } +// IsHappy check for happy deployments. +func (d *Deployment) IsHappy(dp appsv1.Deployment) bool { + return dp.Status.Replicas == dp.Status.AvailableReplicas +} + // Scale a Deployment. func (d *Deployment) Scale(path string, replicas int32) error { ns, n := client.Namespaced(path) diff --git a/internal/dao/ds.go b/internal/dao/ds.go index 89a0877d..22ca93bc 100644 --- a/internal/dao/ds.go +++ b/internal/dao/ds.go @@ -32,6 +32,11 @@ type DaemonSet struct { Resource } +// IsHappy check for happy deployments. +func (d *DaemonSet) IsHappy(ds appsv1.DaemonSet) bool { + return ds.Status.DesiredNumberScheduled == ds.Status.CurrentNumberScheduled +} + // Restart a DaemonSet rollout. func (d *DaemonSet) Restart(path string) error { ds, err := d.GetInstance(path) diff --git a/internal/dao/healthz.go b/internal/dao/healthz.go deleted file mode 100644 index 75e18971..00000000 --- a/internal/dao/healthz.go +++ /dev/null @@ -1,45 +0,0 @@ -package dao - -import ( - "context" - - v1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/apimachinery/pkg/runtime" -) - -type Healthz struct { - Resource string - Count int64 - Errors int64 -} - -type Pulse interface{} -type Pulses []Pulse - - -func ClusterHealth(ctx context.Context, gvr string) (Pulses, error) { - var h Healthz - - oo, err := p.List(ctx, "") - if err != nil { - return nil, err - } - - h.Count = len(oo) - for _, o := range oo { - var pod v1.Pod - err = runtime.DefaultUnstructuredConverter.FromUnstructured(o.(*unstructured.Unstructured).Object, &pod) - if err != nil { - return nil, err - } - - if !happy(pod) { - h.Errors++ - } - } -} - -func happy(p v1.Pod) bool { - -} diff --git a/internal/dao/node.go b/internal/dao/node.go index ec58eb6d..27628b7b 100644 --- a/internal/dao/node.go +++ b/internal/dao/node.go @@ -34,10 +34,14 @@ func (n *Node) List(ctx context.Context, ns string) ([]runtime.Object, error) { log.Warn().Msgf("No label selector found in context") } - mx := client.NewMetricsServer(n.Client()) - nmx, err := mx.FetchNodesMetrics() - if err != nil { - log.Warn().Err(err).Msgf("No node metrics") + var ( + nmx *mv1beta1.NodeMetricsList + err error + ) + if withMx, ok := ctx.Value(internal.KeyWithMetrics).(bool); withMx || !ok { + if nmx, err = client.DialMetrics(n.Client()).FetchNodesMetrics(); err != nil { + log.Warn().Err(err).Msgf("No node metrics") + } } nn, err := FetchNodes(n.Factory, labels) diff --git a/internal/dao/pod.go b/internal/dao/pod.go index 1fddf20b..9f97f709 100644 --- a/internal/dao/pod.go +++ b/internal/dao/pod.go @@ -38,6 +38,16 @@ type Pod struct { Resource } +// IsHappy check for happy deployments. +func (p *Pod) IsHappy(po v1.Pod) bool { + for _, c := range po.Status.Conditions { + if c.Status == v1.ConditionFalse { + return false + } + } + return true +} + // Get returns a resource instance if found, else an error. func (p *Pod) Get(ctx context.Context, path string) (runtime.Object, error) { o, err := p.Resource.Get(ctx, path) @@ -50,11 +60,11 @@ func (p *Pod) Get(ctx context.Context, path string) (runtime.Object, error) { return nil, fmt.Errorf("expecting *unstructured.Unstructured but got `%T", o) } - // No Deal! - mx := client.NewMetricsServer(p.Client()) - pmx, err := mx.FetchPodMetrics(path) - if err != nil { - log.Warn().Err(err).Msgf("No pods metrics") + var pmx *mv1beta1.PodMetrics + if withMx, ok := ctx.Value(internal.KeyWithMetrics).(bool); withMx || !ok { + if pmx, err = client.DialMetrics(p.Client()).FetchPodMetrics(path); err != nil { + log.Warn().Err(err).Msgf("No pod metrics") + } } return &render.PodWithMetrics{Raw: u, MX: pmx}, nil @@ -77,10 +87,11 @@ func (p *Pod) List(ctx context.Context, ns string) ([]runtime.Object, error) { return oo, err } - mx := client.NewMetricsServer(p.Client()) - pmx, err := mx.FetchPodsMetrics(ns) - if err != nil { - log.Warn().Err(err).Msgf("No pods metrics") + var pmx *mv1beta1.PodMetricsList + if withMx, ok := ctx.Value(internal.KeyWithMetrics).(bool); withMx || !ok { + if pmx, err = client.DialMetrics(p.Client()).FetchPodsMetrics(ns); err != nil { + log.Warn().Err(err).Msgf("No pods metrics") + } } var res []runtime.Object @@ -128,7 +139,7 @@ func (p *Pod) Containers(path string, includeInit bool) ([]string, error) { return nil, err } - cc := []string{} + cc := make([]string, 0, len(pod.Spec.Containers)+len(pod.Spec.InitContainers)) for _, c := range pod.Spec.Containers { cc = append(cc, c.Name) } diff --git a/internal/dao/pulse.go b/internal/dao/pulse.go new file mode 100644 index 00000000..6b323021 --- /dev/null +++ b/internal/dao/pulse.go @@ -0,0 +1,16 @@ +package dao + +import ( + "context" + "fmt" + + "k8s.io/apimachinery/pkg/runtime" +) + +type Pulse struct { + NonResource +} + +func (h *Pulse) List(ctx context.Context, ns string) ([]runtime.Object, error) { + return nil, fmt.Errorf("NYI") +} diff --git a/internal/dao/registry.go b/internal/dao/registry.go index 1436efd7..7eb00c9e 100644 --- a/internal/dao/registry.go +++ b/internal/dao/registry.go @@ -142,6 +142,13 @@ func loadNonResource(m ResourceMetas) { } func loadK9s(m ResourceMetas) { + m[client.NewGVR("pulses")] = metav1.APIResource{ + Name: "pulses", + Kind: "Pulse", + SingularName: "pulses", + ShortNames: []string{"hz", "pu"}, + Categories: []string{"k9s"}, + } m[client.NewGVR("xrays")] = metav1.APIResource{ Name: "xray", Kind: "XRays", diff --git a/internal/dao/rs.go b/internal/dao/rs.go new file mode 100644 index 00000000..fdb014f0 --- /dev/null +++ b/internal/dao/rs.go @@ -0,0 +1,23 @@ +package dao + +import ( + appsv1 "k8s.io/api/apps/v1" +) + +// ReplicaSet represents a replicaset K8s resource. +type ReplicaSet struct { + Resource +} + +// IsHappy check for happy deployments. +func (d *ReplicaSet) IsHappy(rs appsv1.ReplicaSet) bool { + if rs.Status.Replicas == 0 && rs.Status.Replicas != rs.Status.ReadyReplicas { + return false + } + + if rs.Status.Replicas != 0 && rs.Status.Replicas != rs.Status.ReadyReplicas { + return false + } + + return true +} diff --git a/internal/dao/sts.go b/internal/dao/sts.go index e00d849b..8f599f3a 100644 --- a/internal/dao/sts.go +++ b/internal/dao/sts.go @@ -29,6 +29,11 @@ type StatefulSet struct { Resource } +// IsHappy check for happy sts. +func (s *StatefulSet) IsHappy(sts appsv1.StatefulSet) bool { + return sts.Status.Replicas == sts.Status.ReadyReplicas +} + // Scale a StatefulSet. func (s *StatefulSet) Scale(path string, replicas int32) error { ns, n := client.Namespaced(path) diff --git a/internal/health/check.go b/internal/health/check.go new file mode 100644 index 00000000..b3713bfd --- /dev/null +++ b/internal/health/check.go @@ -0,0 +1,54 @@ +package health + +import ( + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +// Check tracks resource health. +type Check struct { + Counts + + GVR string +} + +// Checks represents a collection of health checks. +type Checks []*Check + +// NewCheck returns a new health check. +func NewCheck(gvr string) *Check { + return &Check{ + GVR: gvr, + Counts: make(Counts), + } +} + +// Set sets a health metric. +func (c *Check) Set(l Level, v int) { + c.Counts[l] = v +} + +// Inc increments a health metric. +func (c *Check) Inc(l Level) { + c.Counts[l]++ +} + +// Total stores a metric total. +func (c *Check) Total(n int) { + c.Counts[Corpus] = n +} + +// Tally retrieves a given health metric. +func (c *Check) Tally(l Level) int { + return c.Counts[l] +} + +// GetObjectKind returns a schema object. +func (Check) GetObjectKind() schema.ObjectKind { + return nil +} + +// DeepCopyObject returns a container copy. +func (c Check) DeepCopyObject() runtime.Object { + return c +} diff --git a/internal/health/check_test.go b/internal/health/check_test.go new file mode 100644 index 00000000..f0a40b82 --- /dev/null +++ b/internal/health/check_test.go @@ -0,0 +1,26 @@ +package health_test + +import ( + "testing" + + "github.com/derailed/k9s/internal/health" + "github.com/stretchr/testify/assert" +) + +func TestCheck(t *testing.T) { + var cc health.Checks + + c := health.NewCheck("test") + n := 0 + for i := 0; i < 10; i++ { + c.Inc(health.OK) + cc = append(cc, c) + n++ + } + c.Total(n) + + assert.Equal(t, 10, len(cc)) + assert.Equal(t, 10, c.Tally(health.Corpus)) + assert.Equal(t, 10, c.Tally(health.OK)) + assert.Equal(t, 0, c.Tally(health.Toast)) +} diff --git a/internal/health/types.go b/internal/health/types.go new file mode 100644 index 00000000..63571ea8 --- /dev/null +++ b/internal/health/types.go @@ -0,0 +1,44 @@ +package health + +// Level tracks health count categories. +type Level int + +const ( + // Unknown represents no health level. + Unknown Level = 1 << iota + + // Corpus tracks total health. + Corpus + + // OK tracks healhy. + OK + + // Warn tracks health warnings. + Warn + + // Toast tracks unhealties. + Toast +) + +// Message represents a health message. +type Message struct { + Level Level + Message string + GVR string + FQN string +} + +// Messages tracks a collection of messages. +type Messages []Message + +// Counts tracks health counts by category. +type Counts map[Level]int + +// Vital tracks a resource vitals. +type Vital struct { + Resource string + Total, OK, Toast int +} + +// Vitals tracks a collection of resource health. +type Vitals []Vital diff --git a/internal/keys.go b/internal/keys.go index 240990b5..2cc5c873 100644 --- a/internal/keys.go +++ b/internal/keys.go @@ -25,4 +25,6 @@ const ( KeyApp ContextKey = "app" KeyStyles ContextKey = "styles" KeyMetrics ContextKey = "metrics" + KeyToast ContextKey = "toast" + KeyWithMetrics ContextKey = "withMetrics" ) diff --git a/internal/model/cluster.go b/internal/model/cluster.go index 54593dac..59f7fb7b 100644 --- a/internal/model/cluster.go +++ b/internal/model/cluster.go @@ -35,7 +35,7 @@ type ( func NewCluster(f dao.Factory) *Cluster { return &Cluster{ factory: f, - mx: client.NewMetricsServer(f.Client()), + mx: client.DialMetrics(f.Client()), } } diff --git a/internal/model/flash.go b/internal/model/flash.go new file mode 100644 index 00000000..284985b8 --- /dev/null +++ b/internal/model/flash.go @@ -0,0 +1,153 @@ +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 +) + +type LevelMessage struct { + Level FlashLevel + Text string +} + +func newClearMessage() LevelMessage { + return LevelMessage{} +} + +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 +} + +func NewFlash(dur time.Duration) *Flash { + return &Flash{ + delay: dur, + msgChan: make(FlashChan, 3), + } +} + +// Channel returns the flash channel. +func (f *Flash) Channel() FlashChan { + return f.msgChan +} + +// Info displays an info flash message. +func (f *Flash) Info(msg string) { + f.SetMessage(FlashInfo, msg) +} + +// Infof displays a formatted info flash message. +func (f *Flash) Infof(fmat string, args ...interface{}) { + f.Info(fmt.Sprintf(fmat, args...)) +} + +// Warn displays a warning flash message. +func (f *Flash) Warn(msg string) { + log.Warn().Msg(msg) + f.SetMessage(FlashWarn, msg) +} + +// Warnf displays a formatted warning flash message. +func (f *Flash) Warnf(fmat string, args ...interface{}) { + f.Warn(fmt.Sprintf(fmat, args...)) +} + +// Err displays an error flash message. +func (f *Flash) Err(err error) { + log.Error().Msg(err.Error()) + f.SetMessage(FlashErr, err.Error()) +} + +// Errf displays a formatted error flash message. +func (f *Flash) Errf(fmat string, args ...interface{}) { + var err error + for _, a := range args { + switch e := a.(type) { + case error: + err = e + } + } + log.Error().Err(err).Msgf(fmat, args...) + f.SetMessage(FlashErr, fmt.Sprintf(fmat, args...)) +} + +// Clear clears the flash message. +func (f *Flash) Clear() { + f.fireCleared() +} + +// SetMessage sets the flash level message. +func (f *Flash) SetMessage(level FlashLevel, msg string) { + if f.cancel != nil { + f.cancel() + f.cancel = nil + } + + f.setLevelMessage(LevelMessage{Level: level, Text: msg}) + f.fireFlashChanged() + + var ctx context.Context + ctx, f.cancel = context.WithCancel(context.Background()) + go f.refresh(ctx) +} + +func (f *Flash) refresh(ctx context.Context) { + for { + select { + case <-ctx.Done(): + return + case <-time.After(f.delay): + f.fireCleared() + return + } + } +} + +func (f *Flash) setLevelMessage(msg LevelMessage) { + f.msg = msg +} + +func (f *Flash) fireFlashChanged() { + f.msgChan <- f.msg +} + +func (f *Flash) fireCleared() { + f.msgChan <- newClearMessage() +} diff --git a/internal/model/flash_test.go b/internal/model/flash_test.go new file mode 100644 index 00000000..f191b444 --- /dev/null +++ b/internal/model/flash_test.go @@ -0,0 +1,105 @@ +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(2 * delay) + s, c, l, m := v.getMetrics() + assert.Equal(t, 1, s) + assert.Equal(t, u.level, l) + assert.Equal(t, u.e, m) + assert.Equal(t, 1, c) + + close(f.Channel()) + }) + } +} + +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, c, l, m := v.getMetrics() + assert.Equal(t, count, s) + assert.Equal(t, model.FlashInfo, l) + assert.Equal(t, fmt.Sprintf("test-%d", count), m) + assert.Equal(t, 1, c) +} + +type flash struct { + set, clear int + level model.FlashLevel + msg string + mx sync.RWMutex +} + +func newFlash() *flash { + return &flash{} +} + +func (f *flash) getMetrics() (int, int, model.FlashLevel, string) { + f.mx.RLock() + defer f.mx.RUnlock() + return f.set, f.clear, f.level, f.msg +} + +func (f *flash) listen(c model.FlashChan) { + for m := range c { + f.mx.Lock() + { + if m.IsClear() { + f.clear++ + } else { + f.set++ + f.level, f.msg = m.Level, m.Text + } + } + f.mx.Unlock() + } +} diff --git a/internal/model/health_one.go b/internal/model/health_one.go new file mode 100644 index 00000000..99372c36 --- /dev/null +++ b/internal/model/health_one.go @@ -0,0 +1,118 @@ +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" +) + +type Health struct { + factory dao.Factory +} + +func NewHealth(f dao.Factory) *Health { + return &Health{ + factory: f, + } +} + +func (h *Health) List(ctx context.Context, ns string) ([]runtime.Object, error) { + defer func(t time.Time) { + log.Debug().Msgf("HealthCheck %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 *Health) 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 *Health) check(ctx context.Context, ns, gvr string) (*health.Check, error) { + defer func(t time.Time) { + log.Debug().Msgf(" CHECK %s - %v", gvr, time.Since(t)) + }(time.Now()) + + meta, ok := Registry[gvr] + if !ok { + return nil, fmt.Errorf("No meta for %q", gvr) + } + if meta.DAO == nil { + meta.DAO = &dao.Resource{} + } + + meta.DAO.Init(h.factory, client.NewGVR(gvr)) + oo, err := meta.DAO.List(ctx, ns) + if err != nil { + return nil, err + } + + c := health.NewCheck(gvr) + c.Total(len(oo)) + rr, re := make(render.Rows, len(oo)), meta.Renderer + for i, o := range oo { + if err := re.Render(o, ns, &rr[i]); err != nil { + return nil, err + } + if !render.Happy(ns, rr[i]) { + c.Inc(health.Toast) + } else { + c.Inc(health.OK) + } + } + + return c, nil +} diff --git a/internal/model/pulse.go b/internal/model/pulse.go new file mode 100644 index 00000000..c19c79c4 --- /dev/null +++ b/internal/model/pulse.go @@ -0,0 +1,156 @@ +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" +) + +// 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 *Health + data health.Checks +} + +func NewPulse(gvr string) *Pulse { + return &Pulse{ + gvr: gvr, + refreshRate: 2 * time.Second, + } +} + +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 := initTreeRefreshRate + 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 = NewHealth(f) + } + ctx = context.WithValue(ctx, internal.KeyFields, "") + ctx = context.WithValue(ctx, internal.KeyWithMetrics, false) + return p.health.List(ctx, p.namespace) +} + +func (p *Pulse) reconcile(ctx context.Context) error { + oo, err := p.list(ctx) + if err != nil { + return err + } + + p.data = health.Checks{} + for _, o := range oo { + c, ok := o.(*health.Check) + if !ok { + return fmt.Errorf("Expecting health check but got %T", o) + } + p.data = append(p.data, c) + p.firePulseChanged(c) + } + return nil +} + +// GetNamespace returns the model namespace. +func (p *Pulse) GetNamespace() string { + return p.namespace +} + +// SetNamespace sets up model namespace. +func (p *Pulse) SetNamespace(ns string) { + p.namespace = ns +} + +// AddListener adds a listener. +func (p *Pulse) AddListener(l PulseListener) { + p.listeners = append(p.listeners, l) +} + +// RemoveListener delete a listener. +func (p *Pulse) RemoveListener(l PulseListener) { + victim := -1 + for i, lis := range p.listeners { + if lis == l { + victim = i + break + } + } + + if victim >= 0 { + p.listeners = append(p.listeners[:victim], p.listeners[victim+1:]...) + } +} + +func (p *Pulse) firePulseChanged(check *health.Check) { + for _, l := range p.listeners { + l.PulseChanged(check) + } +} + +func (p *Pulse) firePulseFailed(err error) { + for _, l := range p.listeners { + l.PulseFailed(err) + } +} diff --git a/internal/model/registry.go b/internal/model/registry.go index 997ddfe2..0dde91f1 100644 --- a/internal/model/registry.go +++ b/internal/model/registry.go @@ -14,6 +14,9 @@ var Registry = map[string]ResourceMeta{ DAO: &dao.Chart{}, Renderer: &render.Chart{}, }, + "pulses": { + DAO: &dao.Pulse{}, + }, "openfaas": { DAO: &dao.OpenFaas{}, Renderer: &render.OpenFaas{}, diff --git a/internal/model/stack.go b/internal/model/stack.go index b2ffb3cb..53cd0fea 100644 --- a/internal/model/stack.go +++ b/internal/model/stack.go @@ -108,8 +108,8 @@ func (s *Stack) Pop() (Component, bool) { var c Component s.mx.Lock() { - c = s.components[s.size()] - s.components = s.components[:s.size()] + c = s.components[len(s.components)-1] + s.components = s.components[:len(s.components)-1] } s.mx.Unlock() s.notify(StackPop, c) @@ -163,11 +163,7 @@ func (s *Stack) Top() Component { return nil } - return s.components[s.size()] -} - -func (s *Stack) size() int { - return len(s.components) - 1 + return s.components[len(s.components)-1] } func (s *Stack) notify(a StackAction, c Component) { diff --git a/internal/model/table.go b/internal/model/table.go index e54decbf..75bba3f5 100644 --- a/internal/model/table.go +++ b/internal/model/table.go @@ -162,6 +162,7 @@ func (t *Table) SetRefreshRate(d time.Duration) { // ClusterWide checks if resource is scope for all namespaces. func (t *Table) ClusterWide() bool { + log.Debug().Msgf("CLUSTER-WIDE %q", t.namespace) return client.IsClusterWide(t.namespace) } @@ -219,6 +220,7 @@ func (t *Table) list(ctx context.Context, a dao.Accessor) ([]runtime.Object, err if client.IsClusterScoped(t.namespace) { ns = client.AllNamespaces } + return a.List(ctx, ns) } diff --git a/internal/model/table_int_test.go b/internal/model/table_int_test.go index 123da35c..9033d021 100644 --- a/internal/model/table_int_test.go +++ b/internal/model/table_int_test.go @@ -29,10 +29,11 @@ func TestTableReconcile(t *testing.T) { f.rows = []runtime.Object{load(t, "p1")} ctx := context.WithValue(context.Background(), internal.KeyFactory, f) ctx = context.WithValue(ctx, internal.KeyFields, "") + ctx = context.WithValue(ctx, internal.KeyWithMetrics, false) err := ta.reconcile(ctx) assert.Nil(t, err) data := ta.Peek() - assert.Equal(t, 15, len(data.Header)) + assert.Equal(t, 17, len(data.Header)) assert.Equal(t, 1, len(data.RowEvents)) assert.Equal(t, client.NamespaceAll, data.Namespace) } @@ -55,6 +56,7 @@ func TestTableGet(t *testing.T) { f := makeFactory() f.rows = []runtime.Object{load(t, "p1")} ctx := context.WithValue(context.Background(), internal.KeyFactory, f) + ctx = context.WithValue(ctx, internal.KeyWithMetrics, false) row, err := ta.Get(ctx, "fred") assert.Nil(t, err) assert.NotNil(t, row) @@ -104,7 +106,7 @@ func TestTableHydrate(t *testing.T) { assert.Nil(t, hydrate("blee", oo, rr, render.Pod{})) assert.Equal(t, 1, len(rr)) - assert.Equal(t, 14, len(rr[0].Fields)) + assert.Equal(t, 16, len(rr[0].Fields)) } func TestTableGenericHydrate(t *testing.T) { diff --git a/internal/model/table_test.go b/internal/model/table_test.go index 0ae6da2d..f58187ae 100644 --- a/internal/model/table_test.go +++ b/internal/model/table_test.go @@ -30,9 +30,10 @@ func TestTableRefresh(t *testing.T) { f.rows = []runtime.Object{mustLoad("p1")} ctx := context.WithValue(context.Background(), internal.KeyFactory, f) ctx = context.WithValue(ctx, internal.KeyFields, "") + ctx = context.WithValue(ctx, internal.KeyWithMetrics, false) ta.Refresh(ctx) data := ta.Peek() - assert.Equal(t, 15, len(data.Header)) + assert.Equal(t, 17, len(data.Header)) assert.Equal(t, 1, len(data.RowEvents)) assert.Equal(t, client.NamespaceAll, data.Namespace) assert.Equal(t, 1, l.count) diff --git a/internal/model/tree.go b/internal/model/tree.go index 55dc4284..400944e1 100644 --- a/internal/model/tree.go +++ b/internal/model/tree.go @@ -229,7 +229,7 @@ func (t *Tree) reconcile(ctx context.Context) error { } if t.root == nil || t.root.Diff(root) { t.root = root - t.fireTreeTreeChanged(t.root) + t.fireTreeChanged(t.root) } return nil @@ -251,7 +251,7 @@ func (t *Tree) resourceMeta() ResourceMeta { return meta } -func (t *Tree) fireTreeTreeChanged(root *xray.TreeNode) { +func (t *Tree) fireTreeChanged(root *xray.TreeNode) { for _, l := range t.listeners { l.TreeChanged(root) } diff --git a/internal/render/benchmark.go b/internal/render/benchmark.go index 68da849a..2f137de9 100644 --- a/internal/render/benchmark.go +++ b/internal/render/benchmark.go @@ -1,6 +1,7 @@ package render import ( + "errors" "fmt" "io/ioutil" "os" @@ -8,6 +9,7 @@ import ( "strconv" "strings" + "github.com/derailed/k9s/internal/client" "github.com/derailed/tview" "github.com/gdamore/tcell" "golang.org/x/text/language" @@ -28,11 +30,10 @@ var ( type Benchmark struct{} // ColorerFunc colors a resource row. -func (Benchmark) ColorerFunc() ColorerFunc { +func (b Benchmark) ColorerFunc() ColorerFunc { return func(ns string, re RowEvent) tcell.Color { c := tcell.ColorPaleGreen - statusCol := 2 - if strings.TrimSpace(re.Row.Fields[statusCol]) != "pass" { + if !Happy(ns, re.Row) { c = ErrColor } return c @@ -50,6 +51,7 @@ func (Benchmark) Header(ns string) HeaderRow { Header{Name: "2XX", Align: tview.AlignRight}, Header{Name: "4XX/5XX", Align: tview.AlignRight}, Header{Name: "REPORT"}, + Header{Name: "VALID", Wide: true}, Header{Name: "AGE", Decorator: AgeDecorator}, } } @@ -72,6 +74,24 @@ func (b Benchmark) Render(o interface{}, ns string, r *Row) error { return err } b.augmentRow(r.Fields, data) + r.Fields[8] = asStatus(b.diagnose(ns, r.Fields)) + + return nil +} + +// Happy returns true if resoure is happy, false otherwise +func (Benchmark) diagnose(ns string, ff Fields) error { + statusCol := 3 + if !client.IsAllNamespaces(ns) { + statusCol-- + } + + if len(ff) < statusCol { + return nil + } + if ff[statusCol] != "pass" { + return errors.New("failed benchmark") + } return nil } @@ -87,7 +107,7 @@ func (Benchmark) readFile(file string) (string, error) { return string(data), nil } -func (Benchmark) initRow(row Fields, f os.FileInfo) error { +func (b Benchmark) initRow(row Fields, f os.FileInfo) error { tokens := strings.Split(f.Name(), "_") if len(tokens) < 2 { return fmt.Errorf("Invalid file name %s", f.Name()) @@ -95,7 +115,7 @@ func (Benchmark) initRow(row Fields, f os.FileInfo) error { row[0] = tokens[0] row[1] = tokens[1] row[7] = f.Name() - row[8] = timeToAge(f.ModTime()) + row[9] = timeToAge(f.ModTime()) return nil } diff --git a/internal/render/chart.go b/internal/render/chart.go index f1ab5892..0cfbdfb0 100644 --- a/internal/render/chart.go +++ b/internal/render/chart.go @@ -18,6 +18,10 @@ type Chart struct{} // ColorerFunc colors a resource row. func (Chart) ColorerFunc() ColorerFunc { return func(ns string, re RowEvent) tcell.Color { + if !Happy(ns, re.Row) { + return ErrColor + } + return tcell.ColorMediumSpringGreen } } @@ -35,6 +39,7 @@ func (Chart) Header(ns string) HeaderRow { Header{Name: "STATUS"}, Header{Name: "CHART"}, Header{Name: "APP VERSION"}, + Header{Name: "VALID", Wide: true}, Header{Name: "AGE", Decorator: AgeDecorator}, ) } @@ -57,12 +62,21 @@ func (c Chart) Render(o interface{}, ns string, r *Row) error { h.Release.Info.Status.String(), h.Release.Chart.Metadata.Name+"-"+h.Release.Chart.Metadata.Version, h.Release.Chart.Metadata.AppVersion, + asStatus(c.diagnose(h.Release.Info.Status.String())), toAge(metav1.Time{Time: h.Release.Info.LastDeployed.Time}), ) return nil } +func (c Chart) diagnose(s string) error { + if s != "deployed" { + return fmt.Errorf("chart is in an invalid state") + } + + return nil +} + // ---------------------------------------------------------------------------- // Helpers... diff --git a/internal/render/container.go b/internal/render/container.go index 0e74e04f..410c55c4 100644 --- a/internal/render/container.go +++ b/internal/render/container.go @@ -1,6 +1,7 @@ package render import ( + "errors" "fmt" "strconv" "strings" @@ -36,18 +37,18 @@ type ContainerWithMetrics interface { // Container renders a K8s Container to screen. type Container struct{} +const readyCol = 2 + // ColorerFunc colors a resource row. -func (Container) ColorerFunc() ColorerFunc { - return func(ns string, r RowEvent) tcell.Color { - c := DefaultColorer(ns, r) +func (c Container) ColorerFunc() ColorerFunc { + return func(ns string, re RowEvent) tcell.Color { + color := DefaultColorer(ns, re) - readyCol := 2 - if strings.TrimSpace(r.Row.Fields[readyCol]) == "false" { - c = ErrColor + if !Happy(ns, re.Row) { + color = ErrColor } - stateCol := readyCol + 1 - switch strings.TrimSpace(r.Row.Fields[stateCol]) { + switch strings.TrimSpace(re.Row.Fields[stateCol]) { case ContainerCreating, PodInitializing: return AddColor case Terminating, Initialized: @@ -56,10 +57,10 @@ func (Container) ColorerFunc() ColorerFunc { return CompletedColor case Running: default: - c = ErrColor + color = ErrColor } - return c + return color } } @@ -80,6 +81,7 @@ func (Container) Header(ns string) HeaderRow { Header{Name: "%CPU/L", Align: tview.AlignRight}, Header{Name: "%MEM/L", Align: tview.AlignRight}, Header{Name: "PORTS"}, + Header{Name: "VALID", Wide: true}, Header{Name: "AGE", Decorator: AgeDecorator}, } } @@ -114,12 +116,25 @@ func (c Container) Render(o interface{}, name string, r *Row) error { limit.cpu, limit.mem, toStrPorts(co.Container.Ports), + asStatus(c.diagnose(state, ready)), toAge(co.Age), ) return nil } +// Happy returns true if resoure is happy, false otherwise +func (Container) diagnose(state, ready string) error { + if state == "Completed" { + return nil + } + + if ready == "false" { + return errors.New("container is not ready") + } + return nil +} + // ---------------------------------------------------------------------------- // Helpers... diff --git a/internal/render/container_test.go b/internal/render/container_test.go index 051cb2fb..1d14321a 100644 --- a/internal/render/container_test.go +++ b/internal/render/container_test.go @@ -41,6 +41,7 @@ func TestContainer(t *testing.T) { "50", "20", "", + "container is not ready", }, r.Fields[:len(r.Fields)-1], ) diff --git a/internal/render/cr.go b/internal/render/cr.go index aed5710c..5b7c5734 100644 --- a/internal/render/cr.go +++ b/internal/render/cr.go @@ -21,6 +21,7 @@ func (ClusterRole) ColorerFunc() ColorerFunc { func (ClusterRole) Header(string) HeaderRow { return HeaderRow{ Header{Name: "NAME"}, + Header{Name: "LABELS", Wide: true}, Header{Name: "AGE", Decorator: AgeDecorator}, } } @@ -40,6 +41,7 @@ func (ClusterRole) Render(o interface{}, ns string, r *Row) error { r.ID = client.FQN("-", cr.ObjectMeta.Name) r.Fields = Fields{ cr.Name, + mapToStr(cr.Labels), toAge(cr.ObjectMeta.CreationTimestamp), } diff --git a/internal/render/crb.go b/internal/render/crb.go index 74c4ee37..2543cfb1 100644 --- a/internal/render/crb.go +++ b/internal/render/crb.go @@ -22,8 +22,9 @@ func (ClusterRoleBinding) Header(string) HeaderRow { return HeaderRow{ Header{Name: "NAME"}, Header{Name: "CLUSTERROLE"}, - Header{Name: "KIND"}, + Header{Name: "SUBJECT-KIND"}, Header{Name: "SUBJECTS"}, + Header{Name: "LABELS", Wide: true}, Header{Name: "AGE", Decorator: AgeDecorator}, } } @@ -48,6 +49,7 @@ func (ClusterRoleBinding) Render(o interface{}, ns string, r *Row) error { crb.RoleRef.Name, kind, ss, + mapToStr(crb.Labels), toAge(crb.ObjectMeta.CreationTimestamp), } diff --git a/internal/render/crb_test.go b/internal/render/crb_test.go index 08f41b35..cbed129e 100644 --- a/internal/render/crb_test.go +++ b/internal/render/crb_test.go @@ -13,5 +13,5 @@ func TestClusterRoleBindingRender(t *testing.T) { c.Render(load(t, "crb"), "-", &r) assert.Equal(t, "-/blee", r.ID) - assert.Equal(t, render.Fields{"blee", "blee", "USR", "fernand"}, r.Fields[:4]) + assert.Equal(t, render.Fields{"blee", "blee", "User", "fernand"}, r.Fields[:4]) } diff --git a/internal/render/crd.go b/internal/render/crd.go index 120ebdc8..a29faf01 100644 --- a/internal/render/crd.go +++ b/internal/render/crd.go @@ -22,6 +22,7 @@ func (CustomResourceDefinition) ColorerFunc() ColorerFunc { func (CustomResourceDefinition) Header(string) HeaderRow { return HeaderRow{ Header{Name: "NAME"}, + Header{Name: "LABELS", Wide: true}, Header{Name: "AGE", Decorator: AgeDecorator}, } } @@ -45,6 +46,7 @@ func (CustomResourceDefinition) Render(o interface{}, ns string, r *Row) error { r.ID = client.FQN(client.ClusterScope, extractMetaField(meta, "name")) r.Fields = Fields{ extractMetaField(meta, "name"), + mapToIfc(meta["labels"]), toAge(metav1.Time{Time: t}), } diff --git a/internal/render/cronjob.go b/internal/render/cronjob.go index d389fc1c..5b182bbd 100644 --- a/internal/render/cronjob.go +++ b/internal/render/cronjob.go @@ -3,9 +3,12 @@ package render import ( "fmt" "strconv" + "strings" "github.com/derailed/k9s/internal/client" + batchv1 "k8s.io/api/batch/v1" batchv1beta1 "k8s.io/api/batch/v1beta1" + v1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" ) @@ -31,6 +34,11 @@ func (CronJob) Header(ns string) HeaderRow { Header{Name: "SUSPEND"}, Header{Name: "ACTIVE"}, Header{Name: "LAST_SCHEDULE"}, + Header{Name: "SELECTOR", Wide: true}, + Header{Name: "CONTAINERS", Wide: true}, + Header{Name: "IMAGES", Wide: true}, + Header{Name: "LABELS", Wide: true}, + Header{Name: "VALID", Wide: true}, Header{Name: "AGE", Decorator: AgeDecorator}, ) } @@ -63,8 +71,64 @@ func (c CronJob) Render(o interface{}, ns string, r *Row) error { boolPtrToStr(cj.Spec.Suspend), strconv.Itoa(len(cj.Status.Active)), lastScheduled, + jobSelector(cj.Spec.JobTemplate.Spec), + podContainerNames(cj.Spec.JobTemplate.Spec.Template.Spec, true), + podImageNames(cj.Spec.JobTemplate.Spec.Template.Spec, true), + mapToStr(cj.Labels), + "", toAge(cj.ObjectMeta.CreationTimestamp), ) return nil } + +// Helpers + +func jobSelector(spec batchv1.JobSpec) string { + if spec.Selector == nil { + return MissingValue + } + if len(spec.Selector.MatchLabels) > 0 { + return mapToStr(spec.Selector.MatchLabels) + } + if len(spec.Selector.MatchExpressions) == 0 { + return "" + } + + ss := make([]string, 0, len(spec.Selector.MatchExpressions)) + for _, e := range spec.Selector.MatchExpressions { + ss = append(ss, e.String()) + } + + return strings.Join(ss, " ") +} + +func podContainerNames(spec v1.PodSpec, includeInit bool) string { + cc := make([]string, 0, len(spec.Containers)+len(spec.InitContainers)) + + if includeInit { + for _, c := range spec.InitContainers { + cc = append(cc, c.Name) + } + } + for _, c := range spec.Containers { + cc = append(cc, c.Name) + } + + return strings.Join(cc, ",") +} + +func podImageNames(spec v1.PodSpec, includeInit bool) string { + cc := make([]string, 0, len(spec.Containers)+len(spec.InitContainers)) + + if includeInit { + for _, c := range spec.InitContainers { + cc = append(cc, c.Image) + } + } + for _, c := range spec.Containers { + cc = append(cc, c.Image) + } + + return strings.Join(cc, ",") +} diff --git a/internal/render/dp.go b/internal/render/dp.go index 8e636f72..88424063 100644 --- a/internal/render/dp.go +++ b/internal/render/dp.go @@ -3,7 +3,6 @@ package render import ( "fmt" "strconv" - "strings" "github.com/derailed/k9s/internal/client" "github.com/derailed/tview" @@ -17,19 +16,13 @@ import ( type Deployment struct{} // ColorerFunc colors a resource row. -func (Deployment) ColorerFunc() ColorerFunc { +func (d Deployment) ColorerFunc() ColorerFunc { return func(ns string, r RowEvent) tcell.Color { c := DefaultColorer(ns, r) if r.Kind == EventAdd || r.Kind == EventUpdate { return c } - - readyCol := 2 - if !client.IsAllNamespaces(ns) { - readyCol-- - } - tokens := strings.Split(r.Row.Fields[readyCol], "/") - if tokens[0] != tokens[1] { + if !Happy(ns, r.Row) { return ErrColor } @@ -49,6 +42,9 @@ func (Deployment) Header(ns string) HeaderRow { Header{Name: "READY"}, Header{Name: "UP-TO-DATE", Align: tview.AlignRight}, Header{Name: "AVAILABLE", Align: tview.AlignRight}, + Header{Name: "READY", Align: tview.AlignRight}, + Header{Name: "LABELS", Wide: true}, + Header{Name: "VALID", Wide: true}, Header{Name: "AGE", Decorator: AgeDecorator}, ) } @@ -73,11 +69,21 @@ func (d Deployment) Render(o interface{}, ns string, r *Row) error { } r.Fields = append(r.Fields, dp.Name, - strconv.Itoa(int(dp.Status.AvailableReplicas))+"/"+strconv.Itoa(int(*dp.Spec.Replicas)), + strconv.Itoa(int(dp.Status.AvailableReplicas))+"/"+strconv.Itoa(int(dp.Status.Replicas)), strconv.Itoa(int(dp.Status.UpdatedReplicas)), strconv.Itoa(int(dp.Status.AvailableReplicas)), + strconv.Itoa(int(dp.Status.ReadyReplicas)), + mapToStr(dp.Labels), + asStatus(d.diagnose(dp.Status.Replicas, dp.Status.AvailableReplicas)), toAge(dp.ObjectMeta.CreationTimestamp), ) return nil } + +func (Deployment) diagnose(d, r int32) error { + if d != r { + return fmt.Errorf("desiring %d replicas got %d available", d, r) + } + return nil +} diff --git a/internal/render/ds.go b/internal/render/ds.go index b70f957c..191c59a5 100644 --- a/internal/render/ds.go +++ b/internal/render/ds.go @@ -3,7 +3,6 @@ package render import ( "fmt" "strconv" - "strings" "github.com/derailed/k9s/internal/client" "github.com/derailed/tview" @@ -17,18 +16,14 @@ import ( type DaemonSet struct{} // ColorerFunc colors a resource row. -func (DaemonSet) ColorerFunc() ColorerFunc { +func (d DaemonSet) ColorerFunc() ColorerFunc { return func(ns string, r RowEvent) tcell.Color { c := DefaultColorer(ns, r) if r.Kind == EventAdd || r.Kind == EventUpdate { return c } - desiredCol := 2 - if !client.IsAllNamespaces(ns) { - desiredCol = 1 - } - if strings.TrimSpace(r.Row.Fields[desiredCol]) != strings.TrimSpace(r.Row.Fields[desiredCol+2]) { + if !Happy(ns, r.Row) { return ErrColor } @@ -50,6 +45,8 @@ func (DaemonSet) Header(ns string) HeaderRow { Header{Name: "READY", Align: tview.AlignRight}, Header{Name: "UP-TO-DATE", Align: tview.AlignRight}, Header{Name: "AVAILABLE", Align: tview.AlignRight}, + Header{Name: "LABELS", Wide: true}, + Header{Name: "VALID", Wide: true}, Header{Name: "AGE", Decorator: AgeDecorator}, ) } @@ -78,8 +75,18 @@ func (d DaemonSet) Render(o interface{}, ns string, r *Row) error { strconv.Itoa(int(ds.Status.NumberReady)), strconv.Itoa(int(ds.Status.UpdatedNumberScheduled)), strconv.Itoa(int(ds.Status.NumberAvailable)), + mapToStr(ds.Labels), + asStatus(d.diagnose(ds.Status.DesiredNumberScheduled, ds.Status.NumberReady)), toAge(ds.ObjectMeta.CreationTimestamp), ) return nil } + +// Happy returns true if resoure is happy, false otherwise +func (DaemonSet) diagnose(d, r int32) error { + if d != r { + return fmt.Errorf("desiring %d replicas but %d ready", d, r) + } + return nil +} diff --git a/internal/render/ev.go b/internal/render/ev.go index 67494d2a..ef4b6813 100644 --- a/internal/render/ev.go +++ b/internal/render/ev.go @@ -1,6 +1,7 @@ package render import ( + "errors" "fmt" "strconv" "strings" @@ -17,19 +18,20 @@ import ( type Event struct{} // ColorerFunc colors a resource row. -func (Event) ColorerFunc() ColorerFunc { +func (e Event) ColorerFunc() ColorerFunc { return func(ns string, r RowEvent) tcell.Color { c := DefaultColorer(ns, r) + if !Happy(ns, r.Row) { + return ErrColor + } + markCol := 3 if !client.IsAllNamespaces(ns) { markCol = 2 } - switch strings.TrimSpace(r.Row.Fields[markCol]) { - case "Failed": - c = ErrColor - case "Killing": - c = KillColor + if strings.TrimSpace(r.Row.Fields[markCol]) == "Killing" { + return KillColor } return c @@ -45,10 +47,12 @@ func (Event) Header(ns string) HeaderRow { return append(h, Header{Name: "NAME"}, + Header{Name: "TYPE"}, Header{Name: "REASON"}, Header{Name: "SOURCE"}, Header{Name: "COUNT", Align: tview.AlignRight}, - Header{Name: "MESSAGE"}, + Header{Name: "MESSAGE", Wide: true}, + Header{Name: "VALID", Wide: true}, Header{Name: "AGE", Decorator: AgeDecorator}, ) } @@ -72,15 +76,27 @@ func (e Event) Render(o interface{}, ns string, r *Row) error { } r.Fields = append(r.Fields, asRef(ev.InvolvedObject), + ev.Type, ev.Reason, ev.Source.Component, strconv.Itoa(int(ev.Count)), ev.Message, + asStatus(e.diagnose(ev.Type)), toAge(ev.LastTimestamp)) return nil } +// Happy returns true if resoure is happy, false otherwise +func (Event) diagnose(kind string) error { + if kind != "Normal" { + return errors.New("failed event") + } + return nil +} + +// Helpers... + func asRef(r v1.ObjectReference) string { return strings.ToLower(r.Kind) + ":" + r.Name } diff --git a/internal/render/ev_test.go b/internal/render/ev_test.go index 766e5c7a..9388969e 100644 --- a/internal/render/ev_test.go +++ b/internal/render/ev_test.go @@ -13,5 +13,17 @@ func TestEventRender(t *testing.T) { c.Render(load(t, "ev"), "", &r) assert.Equal(t, "default/hello-1567197780-mn4mv.15bfce150bd764dd", r.ID) - assert.Equal(t, render.Fields{"default", "pod:hello-1567197780-mn4mv", "Pulled", "kubelet", "1", `Successfully pulled image "blang/busybox-bash"`}, r.Fields[:6]) + assert.Equal(t, render.Fields{"default", "pod:hello-1567197780-mn4mv", "Normal", "Pulled", "kubelet", "1", `Successfully pulled image "blang/busybox-bash"`}, r.Fields[:7]) +} + +func BenchmarkEventRender(b *testing.B) { + ev := load(b, "ev") + var re render.Event + r := render.NewRow(7) + + b.ResetTimer() + b.ReportAllocs() + for i := 0; i < b.N; i++ { + _ = re.Render(&ev, "", &r) + } } diff --git a/internal/render/generic.go b/internal/render/generic.go index d1e70bd9..46950ea7 100644 --- a/internal/render/generic.go +++ b/internal/render/generic.go @@ -19,6 +19,11 @@ type Generic struct { ageIndex int } +// Happy returns true if resoure is happy, false otherwise +func (Generic) Happy(ns string, r Row) bool { + return true +} + // SetTable sets the tabular resource. func (g *Generic) SetTable(t *metav1beta1.Table) { g.table = t diff --git a/internal/render/helpers.go b/internal/render/helpers.go index a598065d..1a26f447 100644 --- a/internal/render/helpers.go +++ b/internal/render/helpers.go @@ -13,6 +13,12 @@ import ( "k8s.io/apimachinery/pkg/util/duration" ) +// Happy returns true if resoure is happy, false otherwise +func Happy(ns string, r Row) bool { + validCol := r.Len() - 2 + return strings.TrimSpace(r.Fields[validCol]) == "" +} + const megaByte = 1024 * 1024 // ToMB converts bytes to megabytes. @@ -20,6 +26,13 @@ func ToMB(v int64) float64 { return float64(v) / megaByte } +func asStatus(err error) string { + if err == nil { + return "" + } + return err.Error() +} + func asSelector(s *metav1.LabelSelector) string { sel, err := metav1.LabelSelectorAsSelector(s) if err != nil { @@ -84,7 +97,7 @@ func join(a []string, sep string) string { var buff strings.Builder buff.Grow(n) - buff.WriteString(a[0]) + buff.WriteString(b[0]) for _, s := range b[1:] { buff.WriteString(sep) buff.WriteString(s) @@ -163,7 +176,40 @@ func mapToStr(m map[string]string) (s string) { for i, k := range kk { s += k + "=" + m[k] if i < len(kk)-1 { - s += "," + s += " " + } + } + + return +} + +func mapToIfc(m interface{}) (s string) { + if m == nil { + return "" + } + + mm, ok := m.(map[string]interface{}) + if !ok { + return "" + } + if len(mm) == 0 { + return "" + } + + kk := make([]string, 0, len(mm)) + for k := range mm { + kk = append(kk, k) + } + sort.Strings(kk) + + for i, k := range kk { + str, ok := mm[k].(string) + if !ok { + continue + } + s += k + "=" + str + if i < len(kk)-1 { + s += " " } } diff --git a/internal/render/helpers_test.go b/internal/render/helpers_test.go index ed366a46..51b93339 100644 --- a/internal/render/helpers_test.go +++ b/internal/render/helpers_test.go @@ -82,10 +82,11 @@ func TestJoin(t *testing.T) { i []string e string }{ - "zero": {[]string{}, ""}, - "std": {[]string{"a", "b", "c"}, "a,b,c"}, - "blank": {[]string{"", "", ""}, ""}, - "sparse": {[]string{"a", "", "c"}, "a,c"}, + "zero": {[]string{}, ""}, + "std": {[]string{"a", "b", "c"}, "a,b,c"}, + "blank": {[]string{"", "", ""}, ""}, + "sparse": {[]string{"a", "", "c"}, "a,c"}, + "withBlank": {[]string{"", "a", "c"}, "a,c"}, } for k := range uu { @@ -304,7 +305,7 @@ func TestMapToStr(t *testing.T) { i map[string]string e string }{ - {map[string]string{"blee": "duh", "aa": "bb"}, "aa=bb,blee=duh"}, + {map[string]string{"blee": "duh", "aa": "bb"}, "aa=bb blee=duh"}, {map[string]string{}, ""}, } for _, u := range uu { diff --git a/internal/render/hpa.go b/internal/render/hpa.go index 6042b49f..08bba6a4 100644 --- a/internal/render/hpa.go +++ b/internal/render/hpa.go @@ -36,6 +36,7 @@ func (HorizontalPodAutoscaler) Header(ns string) HeaderRow { Header{Name: "MINPODS", Align: tview.AlignRight}, Header{Name: "MAXPODS", Align: tview.AlignRight}, Header{Name: "REPLICAS", Align: tview.AlignRight}, + Header{Name: "VALID", Wide: true}, Header{Name: "AGE", Decorator: AgeDecorator}, ) } @@ -80,6 +81,7 @@ func (h HorizontalPodAutoscaler) renderV1(raw *unstructured.Unstructured, ns str strconv.Itoa(int(*hpa.Spec.MinReplicas)), strconv.Itoa(int(hpa.Spec.MaxReplicas)), strconv.Itoa(int(hpa.Status.CurrentReplicas)), + "", toAge(hpa.ObjectMeta.CreationTimestamp), ) @@ -106,6 +108,7 @@ func (h HorizontalPodAutoscaler) renderV2b1(raw *unstructured.Unstructured, ns s strconv.Itoa(int(*hpa.Spec.MinReplicas)), strconv.Itoa(int(hpa.Spec.MaxReplicas)), strconv.Itoa(int(hpa.Status.CurrentReplicas)), + "", toAge(hpa.ObjectMeta.CreationTimestamp), ) @@ -132,6 +135,7 @@ func (h HorizontalPodAutoscaler) renderV2b2(raw *unstructured.Unstructured, ns s strconv.Itoa(int(*hpa.Spec.MinReplicas)), strconv.Itoa(int(hpa.Spec.MaxReplicas)), strconv.Itoa(int(hpa.Status.CurrentReplicas)), + "", toAge(hpa.ObjectMeta.CreationTimestamp), ) diff --git a/internal/render/ing.go b/internal/render/ing.go index 9f90ba74..2ba806fa 100644 --- a/internal/render/ing.go +++ b/internal/render/ing.go @@ -31,6 +31,7 @@ func (Ingress) Header(ns string) HeaderRow { Header{Name: "HOSTS"}, Header{Name: "ADDRESS"}, Header{Name: "PORT"}, + Header{Name: "VALID", Wide: true}, Header{Name: "AGE", Decorator: AgeDecorator}, ) } @@ -57,6 +58,7 @@ func (i Ingress) Render(o interface{}, ns string, r *Row) error { toHosts(ing.Spec.Rules), toAddress(ing.Status.LoadBalancer), toTLSPorts(ing.Spec.TLS), + "", toAge(ing.ObjectMeta.CreationTimestamp), ) diff --git a/internal/render/job.go b/internal/render/job.go index 661c7c88..22d32f1a 100644 --- a/internal/render/job.go +++ b/internal/render/job.go @@ -9,6 +9,7 @@ import ( "github.com/derailed/k9s/internal/client" batchv1 "k8s.io/api/batch/v1" v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/util/duration" @@ -33,8 +34,10 @@ func (Job) Header(ns string) HeaderRow { Header{Name: "NAME"}, Header{Name: "COMPLETIONS"}, Header{Name: "DURATION"}, - Header{Name: "CONTAINERS"}, - Header{Name: "IMAGES"}, + Header{Name: "SELECTOR", Wide: true}, + Header{Name: "CONTAINERS", Wide: true}, + Header{Name: "IMAGES", Wide: true}, + Header{Name: "VALID", Wide: true}, Header{Name: "AGE", Decorator: AgeDecorator}, ) } @@ -50,6 +53,7 @@ func (j Job) Render(o interface{}, ns string, r *Row) error { if err != nil { return err } + ready := toCompletion(job.Spec, job.Status) r.ID = client.MetaFQN(job.ObjectMeta) r.Fields = make(Fields, 0, len(j.Header(ns))) @@ -59,16 +63,29 @@ func (j Job) Render(o interface{}, ns string, r *Row) error { cc, ii := toContainers(job.Spec.Template.Spec) r.Fields = append(r.Fields, job.Name, - toCompletion(job.Spec, job.Status), + ready, toDuration(job.Status), + jobSelector(job.Spec), cc, ii, + asStatus(j.diagnose(ready, job.Status.CompletionTime)), toAge(job.ObjectMeta.CreationTimestamp), ) return nil } +func (Job) diagnose(ready string, completed *metav1.Time) error { + if completed == nil { + return nil + } + tokens := strings.Split(ready, "/") + if tokens[0] != tokens[1] { + return fmt.Errorf("expecting %s completion got %s", tokens[1], tokens[0]) + } + return nil +} + // ---------------------------------------------------------------------------- // Helpers... diff --git a/internal/render/job_test.go b/internal/render/job_test.go index 7d375d57..63ff6fb7 100644 --- a/internal/render/job_test.go +++ b/internal/render/job_test.go @@ -13,5 +13,5 @@ func TestJobRender(t *testing.T) { c.Render(load(t, "job"), "", &r) assert.Equal(t, "default/hello-1567179180", r.ID) - assert.Equal(t, render.Fields{"default", "hello-1567179180", "1/1", "8s", "c1", "blang/busybox-bash"}, r.Fields[:6]) + assert.Equal(t, render.Fields{"default", "hello-1567179180", "1/1", "8s", "controller-uid=7473e6d0-cb3b-11e9-990f-42010a800218", "c1", "blang/busybox-bash"}, r.Fields[:7]) } diff --git a/internal/render/node.go b/internal/render/node.go index f64038a7..14dd41cc 100644 --- a/internal/render/node.go +++ b/internal/render/node.go @@ -1,11 +1,14 @@ package render import ( + "errors" "fmt" + "sort" "strings" "github.com/derailed/k9s/internal/client" "github.com/derailed/tview" + "github.com/gdamore/tcell" v1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" @@ -22,8 +25,16 @@ const ( type Node struct{} // ColorerFunc colors a resource row. -func (Node) ColorerFunc() ColorerFunc { - return DefaultColorer +func (n Node) ColorerFunc() ColorerFunc { + return func(ns string, r RowEvent) tcell.Color { + c := DefaultColorer(ns, r) + if !Happy(ns, r.Row) { + return ErrColor + } + + return c + } + } // Header returns a header row. @@ -31,17 +42,19 @@ func (Node) Header(_ string) HeaderRow { return HeaderRow{ Header{Name: "NAME"}, Header{Name: "STATUS"}, - Header{Name: "ROLE"}, - Header{Name: "VERSION"}, - Header{Name: "KERNEL"}, - Header{Name: "INTERNAL-IP"}, - Header{Name: "EXTERNAL-IP"}, + Header{Name: "ROLE", Wide: true}, + Header{Name: "VERSION", Wide: true}, + Header{Name: "KERNEL", Wide: true}, + Header{Name: "INTERNAL-IP", Wide: true}, + Header{Name: "EXTERNAL-IP", Wide: true}, Header{Name: "CPU", Align: tview.AlignRight}, Header{Name: "MEM", Align: tview.AlignRight}, Header{Name: "%CPU", Align: tview.AlignRight}, Header{Name: "%MEM", Align: tview.AlignRight}, Header{Name: "ACPU", Align: tview.AlignRight}, Header{Name: "AMEM", Align: tview.AlignRight}, + Header{Name: "LABELS", Wide: true}, + Header{Name: "VALID", Wide: true}, Header{Name: "AGE", Decorator: AgeDecorator}, } } @@ -69,17 +82,19 @@ func (n Node) Render(o interface{}, ns string, r *Row) error { c, a, p := gatherNodeMX(&no, oo.MX) - sta := make([]string, 10) - status(no.Status, no.Spec.Unschedulable, sta) - ro := make([]string, 10) - nodeRoles(&no, ro) + statuses := make(sort.StringSlice, 10) + status(no.Status, no.Spec.Unschedulable, statuses) + sort.Sort(statuses) + roles := make(sort.StringSlice, 10) + nodeRoles(&no, roles) + sort.Sort(roles) r.ID = client.FQN("", na) r.Fields = make(Fields, 0, len(n.Header(ns))) r.Fields = append(r.Fields, no.Name, - join(sta, ","), - join(ro, ","), + join(statuses, ","), + join(roles, ","), no.Status.NodeInfo.KubeletVersion, no.Status.NodeInfo.KernelVersion, iIP, @@ -90,12 +105,27 @@ func (n Node) Render(o interface{}, ns string, r *Row) error { p.mem, a.cpu, a.mem, + mapToStr(no.Labels), + asStatus(n.diagnose(statuses)), toAge(no.ObjectMeta.CreationTimestamp), ) return nil } +func (Node) diagnose(ss []string) error { + if len(ss) == 0 { + return nil + } + for _, s := range ss { + if s == "Ready" { + return nil + } + } + + return errors.New("node is not ready") +} + // ---------------------------------------------------------------------------- // Helpers... @@ -156,6 +186,9 @@ func nodeRoles(node *v1.Node, res []string) { res[index] = v index++ } + if index >= len(res) { + break + } } if empty(res) { diff --git a/internal/render/np.go b/internal/render/np.go index bdedf401..05062971 100644 --- a/internal/render/np.go +++ b/internal/render/np.go @@ -28,12 +28,14 @@ func (NetworkPolicy) Header(ns string) HeaderRow { return append(h, Header{Name: "NAME"}, - Header{Name: "ING-SELECTOR"}, + Header{Name: "ING-SELECTOR", Wide: true}, Header{Name: "ING-PORTS"}, Header{Name: "ING-BLOCK"}, - Header{Name: "EGR-SELECTOR"}, + Header{Name: "EGR-SELECTOR", Wide: true}, Header{Name: "EGR-PORTS"}, Header{Name: "EGR-BLOCK"}, + Header{Name: "LABELS", Wide: true}, + Header{Name: "VALID", Wide: true}, Header{Name: "AGE", Decorator: AgeDecorator}, ) } @@ -66,6 +68,8 @@ func (n NetworkPolicy) Render(o interface{}, ns string, r *Row) error { es, ep, eb, + mapToStr(np.Labels), + "", toAge(np.ObjectMeta.CreationTimestamp), ) diff --git a/internal/render/ns.go b/internal/render/ns.go index b8547b31..0dc1bb07 100644 --- a/internal/render/ns.go +++ b/internal/render/ns.go @@ -1,6 +1,7 @@ package render import ( + "errors" "fmt" "strings" @@ -15,7 +16,7 @@ import ( type Namespace struct{} // ColorerFunc colors a resource row. -func (Namespace) ColorerFunc() ColorerFunc { +func (n Namespace) ColorerFunc() ColorerFunc { return func(ns string, r RowEvent) tcell.Color { c := DefaultColorer(ns, r) if r.Kind == EventAdd { @@ -25,9 +26,8 @@ func (Namespace) ColorerFunc() ColorerFunc { if r.Kind == EventUpdate { c = StdColor } - switch strings.TrimSpace(r.Row.Fields[1]) { - case "Inactive", Terminating: - c = ErrColor + if !Happy(ns, r.Row) { + return ErrColor } if strings.Contains(strings.TrimSpace(r.Row.Fields[0]), "*") { c = HighlightColor @@ -42,12 +42,14 @@ func (Namespace) Header(string) HeaderRow { return HeaderRow{ Header{Name: "NAME"}, Header{Name: "STATUS"}, + Header{Name: "LABELS", Wide: true}, + Header{Name: "VALID", Wide: true}, Header{Name: "AGE", Decorator: AgeDecorator}, } } // Render renders a K8s resource to screen. -func (Namespace) Render(o interface{}, _ string, r *Row) error { +func (n Namespace) Render(o interface{}, _ string, r *Row) error { raw, ok := o.(*unstructured.Unstructured) if !ok { return fmt.Errorf("Expected Namespace, but got %T", o) @@ -62,8 +64,17 @@ func (Namespace) Render(o interface{}, _ string, r *Row) error { r.Fields = Fields{ ns.Name, string(ns.Status.Phase), + mapToStr(ns.Labels), + asStatus(n.diagnose(ns.Status.Phase)), toAge(ns.ObjectMeta.CreationTimestamp), } return nil } + +func (Namespace) diagnose(phase v1.NamespacePhase) error { + if phase != v1.NamespaceActive && phase != v1.NamespaceTerminating { + return errors.New("namespace not ready") + } + return nil +} diff --git a/internal/render/ofaas.go b/internal/render/ofaas.go index c51dd4c7..4d285197 100644 --- a/internal/render/ofaas.go +++ b/internal/render/ofaas.go @@ -1,6 +1,7 @@ package render import ( + "errors" "fmt" "strconv" "time" @@ -23,8 +24,12 @@ const ( type OpenFaas struct{} // ColorerFunc colors a resource row. -func (OpenFaas) ColorerFunc() ColorerFunc { +func (o OpenFaas) ColorerFunc() ColorerFunc { return func(ns string, re RowEvent) tcell.Color { + if !Happy(ns, re.Row) { + return ErrColor + } + return tcell.ColorPaleTurquoise } } @@ -44,6 +49,7 @@ 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}, ) } @@ -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(f.diagnose(status)), toAge(metav1.Time{Time: time.Now()}), ) return nil } +func (OpenFaas) diagnose(status string) error { + if status != "Ready" { + return errors.New("function not ready") + } + + return nil +} + // ---------------------------------------------------------------------------- // Helpers... diff --git a/internal/render/pdb.go b/internal/render/pdb.go index 9103b336..fb94eed4 100644 --- a/internal/render/pdb.go +++ b/internal/render/pdb.go @@ -3,7 +3,6 @@ package render import ( "fmt" "strconv" - "strings" "github.com/derailed/k9s/internal/client" "github.com/derailed/tview" @@ -18,24 +17,19 @@ import ( type PodDisruptionBudget struct{} // ColorerFunc colors a resource row. -func (PodDisruptionBudget) ColorerFunc() ColorerFunc { - return func(ns string, r RowEvent) tcell.Color { - c := DefaultColorer(ns, r) - if r.Kind == EventAdd || r.Kind == EventUpdate { +func (p PodDisruptionBudget) ColorerFunc() ColorerFunc { + return func(ns string, re RowEvent) tcell.Color { + c := DefaultColorer(ns, re) + if re.Kind == EventAdd || re.Kind == EventUpdate { return c } - markCol := 5 - if !client.IsAllNamespaces(ns) { - markCol-- - } - if strings.TrimSpace(r.Row.Fields[markCol]) != strings.TrimSpace(r.Row.Fields[markCol+1]) { + if !Happy(ns, re.Row) { return ErrColor } return StdColor } - } // Header returns a header row. @@ -53,6 +47,8 @@ func (PodDisruptionBudget) Header(ns string) HeaderRow { Header{Name: "CURRENT", Align: tview.AlignRight}, Header{Name: "DESIRED", Align: tview.AlignRight}, Header{Name: "EXPECTED", Align: tview.AlignRight}, + Header{Name: "LABELS", Wide: true}, + Header{Name: "VALID", Wide: true}, Header{Name: "AGE", Decorator: AgeDecorator}, ) } @@ -82,12 +78,21 @@ func (p PodDisruptionBudget) Render(o interface{}, ns string, r *Row) error { strconv.Itoa(int(pdb.Status.CurrentHealthy)), strconv.Itoa(int(pdb.Status.DesiredHealthy)), strconv.Itoa(int(pdb.Status.ExpectedPods)), + mapToStr(pdb.Labels), + asStatus(p.diagnose(pdb.Spec.MinAvailable.IntVal, pdb.Status.CurrentHealthy)), toAge(pdb.ObjectMeta.CreationTimestamp), ) return nil } +func (PodDisruptionBudget) diagnose(min, healthy int32) error { + if min > healthy { + return fmt.Errorf("expected %d but got %d", min, healthy) + } + return nil +} + // Helpers... func numbToStr(n *intstr.IntOrString) string { diff --git a/internal/render/pod.go b/internal/render/pod.go index ded5b483..011fb064 100644 --- a/internal/render/pod.go +++ b/internal/render/pod.go @@ -25,15 +25,11 @@ func (p Pod) ColorerFunc() ColorerFunc { return func(ns string, re RowEvent) tcell.Color { c := DefaultColorer(ns, re) - readyCol := 2 + statusCol := 4 if !client.IsAllNamespaces(ns) { - readyCol-- + statusCol-- } - statusCol := readyCol + 1 - - ready, status := strings.TrimSpace(re.Row.Fields[readyCol]), strings.TrimSpace(re.Row.Fields[statusCol]) - c = p.checkReadyCol(ready, status, c) - + status := strings.TrimSpace(re.Row.Fields[statusCol]) switch status { case ContainerCreating, PodInitializing: c = AddColor @@ -42,28 +38,19 @@ func (p Pod) ColorerFunc() ColorerFunc { case Completed: c = CompletedColor case Running: + c = StdColor case Terminating: c = KillColor default: - c = ErrColor + if !Happy(ns, re.Row) { + c = ErrColor + } } return c } } -func (Pod) checkReadyCol(readyCol, statusCol string, c tcell.Color) tcell.Color { - if statusCol == "Completed" { - return c - } - - tokens := strings.Split(readyCol, "/") - if len(tokens) == 2 && (tokens[0] == "0" || tokens[0] != tokens[1]) { - return ErrColor - } - return c -} - // Header returns a header row. func (Pod) Header(ns string) HeaderRow { var h HeaderRow @@ -74,17 +61,19 @@ func (Pod) Header(ns string) HeaderRow { return append(h, Header{Name: "NAME"}, Header{Name: "READY"}, - Header{Name: "STATUS"}, Header{Name: "RS", Align: tview.AlignRight}, + Header{Name: "STATUS"}, Header{Name: "CPU", Align: tview.AlignRight}, Header{Name: "MEM", Align: tview.AlignRight}, Header{Name: "%CPU/R", Align: tview.AlignRight}, Header{Name: "%MEM/R", Align: tview.AlignRight}, Header{Name: "%CPU/L", Align: tview.AlignRight}, Header{Name: "%MEM/L", Align: tview.AlignRight}, - Header{Name: "IP"}, - Header{Name: "NODE"}, - Header{Name: "QOS"}, + Header{Name: "IP", Wide: true}, + Header{Name: "NODE", Wide: true}, + Header{Name: "QOS", Wide: true}, + Header{Name: "LABELS", Wide: true}, + Header{Name: "VALID", Wide: true}, Header{Name: "AGE", Decorator: AgeDecorator}, ) } @@ -105,7 +94,7 @@ func (p Pod) Render(o interface{}, ns string, r *Row) error { ss := po.Status.ContainerStatuses cr, _, rc := p.Statuses(ss) c, perc := p.gatherPodMX(&po, pwm.MX) - + phase := p.Phase(&po) r.ID = client.MetaFQN(po.ObjectMeta) r.Fields = make(Fields, 0, len(p.Header(ns))) if client.IsAllNamespaces(ns) { @@ -114,8 +103,8 @@ func (p Pod) Render(o interface{}, ns string, r *Row) error { r.Fields = append(r.Fields, po.ObjectMeta.Name, strconv.Itoa(cr)+"/"+strconv.Itoa(len(ss)), - p.Phase(&po), strconv.Itoa(rc), + phase, c.cpu, c.mem, perc.cpu, @@ -125,12 +114,25 @@ func (p Pod) Render(o interface{}, ns string, r *Row) error { na(po.Status.PodIP), na(po.Spec.NodeName), p.mapQOS(po.Status.QOSClass), + mapToStr(po.Labels), + asStatus(p.diagnose(phase, cr, len(ss))), toAge(po.ObjectMeta.CreationTimestamp), ) return nil } +func (p Pod) diagnose(phase string, cr, ct int) error { + if phase == "Completed" { + return nil + } + if cr != ct { + return fmt.Errorf("container ready check failed: %d of %d", cr, ct) + } + + return nil +} + // ---------------------------------------------------------------------------- // Helpers... diff --git a/internal/render/pod_test.go b/internal/render/pod_test.go index 6b0df397..0f746fce 100644 --- a/internal/render/pod_test.go +++ b/internal/render/pod_test.go @@ -24,12 +24,12 @@ type ( func TestPodColorer(t *testing.T) { var ( - nsRow = render.Row{Fields: render.Fields{"blee", "fred", "1/1", "Running"}} - toastNS = render.Row{Fields: render.Fields{"blee", "fred", "1/1", "Boom"}} - notReadyNS = render.Row{Fields: render.Fields{"blee", "fred", "0/1", "Boom"}} - row = render.Row{Fields: render.Fields{"fred", "1/1", "Running"}} - toast = render.Row{Fields: render.Fields{"fred", "1/1", "Boom"}} - notReady = render.Row{Fields: render.Fields{"fred", "0/1", "Boom"}} + nsRow = render.Row{Fields: render.Fields{"blee", "fred", "1/1", "0", "Running"}} + toastNS = render.Row{Fields: render.Fields{"blee", "fred", "1/1", "0", "Boom"}} + notReadyNS = render.Row{Fields: render.Fields{"blee", "fred", "0/1", "0", "Boom"}} + row = render.Row{Fields: render.Fields{"fred", "1/1", "0", "Running"}} + toast = render.Row{Fields: render.Fields{"fred", "1/1", "0", "Boom"}} + notReady = render.Row{Fields: render.Fields{"fred", "0/1", "0", "Boom"}} ) uu := colorerUCs{ @@ -70,7 +70,7 @@ func TestPodRender(t *testing.T) { assert.Nil(t, err) assert.Equal(t, "default/nginx", r.ID) - e := render.Fields{"default", "nginx", "1/1", "Running", "0", "10", "10", "10", "14", "0", "5", "172.17.0.6", "minikube", "BE"} + e := render.Fields{"default", "nginx", "1/1", "0", "Running", "10", "10", "10", "14", "0", "5", "172.17.0.6", "minikube", "BE"} assert.Equal(t, e, r.Fields[:14]) } @@ -101,7 +101,7 @@ func TestPodInitRender(t *testing.T) { assert.Nil(t, err) assert.Equal(t, "default/nginx", r.ID) - e := render.Fields{"default", "nginx", "1/1", "Init:0/1", "0", "10", "10", "10", "14", "0", "5", "172.17.0.6", "minikube", "BE"} + e := render.Fields{"default", "nginx", "1/1", "0", "Init:0/1", "10", "10", "10", "14", "0", "5", "172.17.0.6", "minikube", "BE"} assert.Equal(t, e, r.Fields[:14]) } diff --git a/internal/render/policy.go b/internal/render/policy.go index e29ab3ce..da279118 100644 --- a/internal/render/policy.go +++ b/internal/render/policy.go @@ -18,8 +18,8 @@ func rbacVerbHeader() HeaderRow { Header{Name: "PATCH "}, Header{Name: "UPDATE"}, Header{Name: "DELETE"}, - Header{Name: "DLIST "}, - Header{Name: "EXTRAS"}, + Header{Name: "DEL-LIST "}, + Header{Name: "EXTRAS", Wide: true}, } } @@ -41,7 +41,10 @@ func (Policy) Header(ns string) HeaderRow { Header{Name: "API GROUP"}, Header{Name: "BINDING"}, } - return append(h, rbacVerbHeader()...) + h = append(h, rbacVerbHeader()...) + h = append(h, Header{Name: "VALID", Wide: true}) + + return h } // Render renders a K8s resource to screen. @@ -52,8 +55,14 @@ func (Policy) Render(o interface{}, gvr string, r *Row) error { } r.ID = client.FQN(p.Namespace, p.Resource) - r.Fields = append(r.Fields, p.Namespace, cleanseResource(p.Resource), p.Group, p.Binding) + r.Fields = append(r.Fields, + p.Namespace, + cleanseResource(p.Resource), + p.Group, + p.Binding, + ) r.Fields = append(r.Fields, asVerbs(p.Verbs)...) + r.Fields = append(r.Fields, "") return nil } diff --git a/internal/render/policy_test.go b/internal/render/policy_test.go index 61a30ddf..24408821 100644 --- a/internal/render/policy_test.go +++ b/internal/render/policy_test.go @@ -37,5 +37,6 @@ func TestPolicyRender(t *testing.T) { "[orangered::b] 𐄂 [::]", "[orangered::b] 𐄂 [::]", "", + "", }, r.Fields) } diff --git a/internal/render/port_forward_test.go b/internal/render/port_forward_test.go index 3ce102f1..81c9d7cf 100644 --- a/internal/render/port_forward_test.go +++ b/internal/render/port_forward_test.go @@ -30,6 +30,7 @@ func TestPortForwardRender(t *testing.T) { "http://0.0.0.0:p1/", "1", "1", + "", "2m", }, r.Fields) } diff --git a/internal/render/portforward.go b/internal/render/portforward.go index 450188d2..89dcf258 100644 --- a/internal/render/portforward.go +++ b/internal/render/portforward.go @@ -48,6 +48,7 @@ func (PortForward) Header(ns string) HeaderRow { Header{Name: "URL"}, Header{Name: "C"}, Header{Name: "N"}, + Header{Name: "VALID", Wide: true}, Header{Name: "AGE", Decorator: AgeDecorator}, } } @@ -71,6 +72,7 @@ func (f PortForward) Render(o interface{}, gvr string, r *Row) error { UrlFor(pf.Config.Host, pf.Config.Path, ports[0]), asNum(pf.Config.C), asNum(pf.Config.N), + "", pf.Age(), } diff --git a/internal/render/pv.go b/internal/render/pv.go index ef0ae627..67742ff6 100644 --- a/internal/render/pv.go +++ b/internal/render/pv.go @@ -16,26 +16,26 @@ import ( type PersistentVolume struct{} // ColorerFunc colors a resource row. -func (PersistentVolume) ColorerFunc() ColorerFunc { - return func(ns string, r RowEvent) tcell.Color { - c := DefaultColorer(ns, r) - if r.Kind == EventAdd || r.Kind == EventUpdate { +func (p PersistentVolume) ColorerFunc() ColorerFunc { + return func(ns string, re RowEvent) tcell.Color { + c := DefaultColorer(ns, re) + if re.Kind == EventAdd || re.Kind == EventUpdate { return c } - status := strings.TrimSpace(r.Row.Fields[4]) - switch status { + if !Happy(ns, re.Row) { + return ErrColor + } + + switch strings.TrimSpace(re.Row.Fields[4]) { case "Bound": c = StdColor case "Available": c = tcell.ColorYellow - default: - c = ErrColor } return c } - } // Header returns a header rbw. @@ -49,6 +49,9 @@ func (PersistentVolume) Header(string) HeaderRow { Header{Name: "CLAIM"}, Header{Name: "STORAGECLASS"}, Header{Name: "REASON"}, + Header{Name: "VOLUMEMODE", Wide: true}, + Header{Name: "LABELS", Wide: true}, + Header{Name: "VALID", Wide: true}, Header{Name: "AGE", Decorator: AgeDecorator}, } } @@ -90,12 +93,30 @@ func (p PersistentVolume) Render(o interface{}, ns string, r *Row) error { claim, class, pv.Status.Reason, + p.volumeMode(pv.Spec.VolumeMode), + mapToStr(pv.Labels), + asStatus(p.diagnose(string(phase))), toAge(pv.ObjectMeta.CreationTimestamp), } return nil } +func (PersistentVolume) diagnose(r string) error { + if r != "Bound" && r != "Available" { + return fmt.Errorf("unexpected status %s", r) + } + return nil +} + +func (PersistentVolume) volumeMode(m *v1.PersistentVolumeMode) string { + if m == nil { + return MissingValue + } + + return string(*m) +} + // ---------------------------------------------------------------------------- // Helpers... diff --git a/internal/render/pvc.go b/internal/render/pvc.go index c6ce1484..2b10a867 100644 --- a/internal/render/pvc.go +++ b/internal/render/pvc.go @@ -2,7 +2,6 @@ package render import ( "fmt" - "strings" "github.com/derailed/k9s/internal/client" "github.com/gdamore/tcell" @@ -15,19 +14,14 @@ import ( type PersistentVolumeClaim struct{} // ColorerFunc colors a resource row. -func (PersistentVolumeClaim) ColorerFunc() ColorerFunc { - return func(ns string, r RowEvent) tcell.Color { - c := DefaultColorer(ns, r) - if r.Kind == EventAdd || r.Kind == EventUpdate { +func (p PersistentVolumeClaim) ColorerFunc() ColorerFunc { + return func(ns string, re RowEvent) tcell.Color { + c := DefaultColorer(ns, re) + if re.Kind == EventAdd || re.Kind == EventUpdate { return c } - - markCol := 2 - if !client.IsAllNamespaces(ns) { - markCol-- - } - if strings.TrimSpace(r.Row.Fields[markCol]) != "Bound" { - c = ErrColor + if !Happy(ns, re.Row) { + return ErrColor } return c @@ -49,6 +43,8 @@ func (PersistentVolumeClaim) Header(ns string) HeaderRow { Header{Name: "CAPACITY"}, Header{Name: "ACCESS MODES"}, Header{Name: "STORAGECLASS"}, + Header{Name: "LABELS", Wide: true}, + Header{Name: "VALID", Wide: true}, Header{Name: "AGE", Decorator: AgeDecorator}, ) } @@ -96,8 +92,17 @@ func (p PersistentVolumeClaim) Render(o interface{}, ns string, r *Row) error { capacity, accessModes, class, + mapToStr(pvc.Labels), + asStatus(p.diagnose(string(phase))), toAge(pvc.ObjectMeta.CreationTimestamp), ) return nil } + +func (PersistentVolumeClaim) diagnose(r string) error { + if r != "Bound" && r != "Available" { + return fmt.Errorf("unexpected status %s", r) + } + return nil +} diff --git a/internal/render/rbac.go b/internal/render/rbac.go index 438ef473..93b6fd85 100644 --- a/internal/render/rbac.go +++ b/internal/render/rbac.go @@ -46,7 +46,10 @@ func (Rbac) Header(ns string) HeaderRow { Header{Name: "API GROUP"}, } - return append(h, rbacVerbHeader()...) + h = append(h, rbacVerbHeader()...) + h = append(h, Header{Name: "VALID", Wide: true}) + + return h } // Render renders a K8s resource to screen. @@ -57,8 +60,12 @@ func (Rbac) Render(o interface{}, gvr string, r *Row) error { } r.ID = p.Resource - r.Fields = append(r.Fields, cleanseResource(p.Resource), p.Group) + r.Fields = append(r.Fields, + cleanseResource(p.Resource), + p.Group, + ) r.Fields = append(r.Fields, asVerbs(p.Verbs)...) + r.Fields = append(r.Fields, "") return nil } diff --git a/internal/render/ro.go b/internal/render/ro.go index c388889e..1e54bb81 100644 --- a/internal/render/ro.go +++ b/internal/render/ro.go @@ -26,6 +26,8 @@ func (Role) Header(ns string) HeaderRow { return append(h, Header{Name: "NAME"}, + Header{Name: "LABELS", Wide: true}, + Header{Name: "VALID", Wide: true}, Header{Name: "AGE", Decorator: AgeDecorator}, ) } @@ -49,6 +51,8 @@ func (r Role) Render(o interface{}, ns string, row *Row) error { } row.Fields = append(row.Fields, ro.Name, + mapToStr(ro.Labels), + "", toAge(ro.ObjectMeta.CreationTimestamp), ) diff --git a/internal/render/rob.go b/internal/render/rob.go index 6777c090..e55b01df 100644 --- a/internal/render/rob.go +++ b/internal/render/rob.go @@ -30,6 +30,8 @@ func (RoleBinding) Header(ns string) HeaderRow { Header{Name: "ROLE"}, Header{Name: "KIND"}, Header{Name: "SUBJECTS"}, + Header{Name: "LABELS", Wide: true}, + Header{Name: "VALID", Wide: true}, Header{Name: "AGE", Decorator: AgeDecorator}, ) } @@ -58,6 +60,8 @@ func (r RoleBinding) Render(o interface{}, ns string, row *Row) error { rb.RoleRef.Name, kind, ss, + mapToStr(rb.Labels), + "", toAge(rb.ObjectMeta.CreationTimestamp), ) @@ -87,11 +91,11 @@ func toSubjectAlias(s string) string { switch s { case rbacv1.UserKind: - return "USR" + return "User" case rbacv1.GroupKind: - return "GRP" + return "Group" case rbacv1.ServiceAccountKind: - return "SA" + return "SvcAcct" default: return strings.ToUpper(s) } diff --git a/internal/render/rob_test.go b/internal/render/rob_test.go index 66afcf6a..26a5c138 100644 --- a/internal/render/rob_test.go +++ b/internal/render/rob_test.go @@ -13,5 +13,5 @@ func TestRoleBindingRender(t *testing.T) { c.Render(load(t, "rb"), "", &r) assert.Equal(t, "default/blee", r.ID) - assert.Equal(t, render.Fields{"default", "blee", "blee", "SA", "fernand"}, r.Fields[:5]) + assert.Equal(t, render.Fields{"default", "blee", "blee", "SvcAcct", "fernand"}, r.Fields[:5]) } diff --git a/internal/render/row.go b/internal/render/row.go index 4076b866..dde5cbe1 100644 --- a/internal/render/row.go +++ b/internal/render/row.go @@ -39,6 +39,11 @@ func (r Row) Clone() Row { } } +// Len returns the length of the row. +func (r Row) Len() int { + return len(r.Fields) +} + // ---------------------------------------------------------------------------- // Rows represents a collection of rows. diff --git a/internal/render/row_event.go b/internal/render/row_event.go index 5b607c69..c2daae04 100644 --- a/internal/render/row_event.go +++ b/internal/render/row_event.go @@ -181,7 +181,7 @@ func (rr RowEvents) Sort(ns string, col int, asc bool) { func toAgeDuration(dur string) string { d, err := time.ParseDuration(dur) if err != nil { - return "n/a" + return dur } return duration.HumanDuration(d) } diff --git a/internal/render/row_header.go b/internal/render/row_header.go index 4b34ea1a..343fff3f 100644 --- a/internal/render/row_header.go +++ b/internal/render/row_header.go @@ -9,6 +9,8 @@ type Header struct { Name string Align int Decorator DecoratorFunc + Hide bool + Wide bool } // Clone copies a header. @@ -56,13 +58,7 @@ func (hh HeaderRow) Columns() []string { // HasAge returns true if table has an age column. func (hh HeaderRow) HasAge() bool { - for _, r := range hh { - if r.Name == ageCol { - return true - } - } - - return false + return hh.IndexOf(ageCol) != -1 } // AgeCol checks if given column index is the age column. @@ -72,3 +68,18 @@ func (hh HeaderRow) AgeCol(col int) bool { } return col == len(hh)-1 } + +// ValidColIndex returns the valid col index or -1 if none. +func (hh HeaderRow) ValidColIndex() int { + return hh.IndexOf("VALID") +} + +// IndeOf returns the col index or -1 if none. +func (hh HeaderRow) IndexOf(c string) int { + for i, h := range hh { + if h.Name == c { + return i + } + } + return -1 +} diff --git a/internal/render/rs.go b/internal/render/rs.go index 5b38fcf1..f0412d56 100644 --- a/internal/render/rs.go +++ b/internal/render/rs.go @@ -3,7 +3,6 @@ package render import ( "fmt" "strconv" - "strings" "github.com/derailed/k9s/internal/client" "github.com/derailed/tview" @@ -17,24 +16,19 @@ import ( type ReplicaSet struct{} // ColorerFunc colors a resource row. -func (ReplicaSet) ColorerFunc() ColorerFunc { - return func(ns string, r RowEvent) tcell.Color { - c := DefaultColorer(ns, r) - if r.Kind == EventAdd || r.Kind == EventUpdate { +func (r ReplicaSet) ColorerFunc() ColorerFunc { + return func(ns string, re RowEvent) tcell.Color { + c := DefaultColorer(ns, re) + if re.Kind == EventAdd || re.Kind == EventUpdate { return c } - markCol := 2 - if !client.IsAllNamespaces(ns) { - markCol-- - } - if strings.TrimSpace(r.Row.Fields[markCol]) != strings.TrimSpace(r.Row.Fields[markCol+1]) { + if !Happy(ns, re.Row) { return ErrColor } return StdColor } - } // Header returns a header row. @@ -49,6 +43,8 @@ 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}, ) } @@ -75,8 +71,21 @@ func (s ReplicaSet) Render(o interface{}, ns string, r *Row) error { strconv.Itoa(int(*rs.Spec.Replicas)), strconv.Itoa(int(rs.Status.Replicas)), strconv.Itoa(int(rs.Status.ReadyReplicas)), + mapToStr(rs.Labels), + asStatus(s.diagnose(rs)), toAge(rs.ObjectMeta.CreationTimestamp), ) return nil } + +func (s ReplicaSet) diagnose(rs appsv1.ReplicaSet) error { + if rs.Status.Replicas != rs.Status.ReadyReplicas { + if rs.Status.Replicas == 0 { + return fmt.Errorf("did not phase down correctly expecting 0 replicas but got %d", rs.Status.ReadyReplicas) + } + return fmt.Errorf("mismatch desired(%d) vs ready(%d)", rs.Status.Replicas, rs.Status.ReadyReplicas) + } + + return nil +} diff --git a/internal/render/sa.go b/internal/render/sa.go index eafffe7a..d0900374 100644 --- a/internal/render/sa.go +++ b/internal/render/sa.go @@ -28,6 +28,8 @@ func (ServiceAccount) Header(ns string) HeaderRow { return append(h, Header{Name: "NAME"}, Header{Name: "SECRET"}, + Header{Name: "LABELS", Wide: true}, + Header{Name: "VALID", Wide: true}, Header{Name: "AGE", Decorator: AgeDecorator}, ) } @@ -52,6 +54,8 @@ func (s ServiceAccount) Render(o interface{}, ns string, r *Row) error { r.Fields = append(r.Fields, sa.Name, strconv.Itoa(len(sa.Secrets)), + mapToStr(sa.Labels), + "", toAge(sa.ObjectMeta.CreationTimestamp), ) diff --git a/internal/render/sc.go b/internal/render/sc.go index 4c43fbc0..00ab82e0 100644 --- a/internal/render/sc.go +++ b/internal/render/sc.go @@ -22,6 +22,8 @@ func (StorageClass) Header(ns string) HeaderRow { return HeaderRow{ Header{Name: "NAME"}, Header{Name: "PROVISIONER"}, + Header{Name: "LABELS", Wide: true}, + Header{Name: "VALID", Wide: true}, Header{Name: "AGE", Decorator: AgeDecorator}, } } @@ -42,6 +44,8 @@ func (StorageClass) Render(o interface{}, ns string, r *Row) error { r.Fields = Fields{ sc.Name, string(sc.Provisioner), + mapToStr(sc.Labels), + "", toAge(sc.ObjectMeta.CreationTimestamp), } diff --git a/internal/render/screen_dump.go b/internal/render/screen_dump.go index ce1d4a69..5d81b068 100644 --- a/internal/render/screen_dump.go +++ b/internal/render/screen_dump.go @@ -33,6 +33,8 @@ var AgeDecorator = func(a string) string { func (ScreenDump) Header(ns string) HeaderRow { return HeaderRow{ Header{Name: "NAME"}, + Header{Name: "DIR"}, + Header{Name: "VALID", Wide: true}, Header{Name: "AGE", Decorator: AgeDecorator}, } } @@ -47,6 +49,8 @@ func (b ScreenDump) Render(o interface{}, ns string, r *Row) error { r.ID = filepath.Join(f.Dir, f.File.Name()) r.Fields = Fields{ f.File.Name(), + f.Dir, + "", timeToAge(f.File.ModTime()), } diff --git a/internal/render/screen_dump_test.go b/internal/render/screen_dump_test.go index ce6413ab..7dfad7f5 100644 --- a/internal/render/screen_dump_test.go +++ b/internal/render/screen_dump_test.go @@ -21,6 +21,8 @@ func TestScreenDumpRender(t *testing.T) { assert.Equal(t, "fred/blee/bob", r.ID) assert.Equal(t, render.Fields{ "bob", + "fred/blee", + "", }, r.Fields[:len(r.Fields)-1]) } diff --git a/internal/render/sts.go b/internal/render/sts.go index 30038c83..02dcc8c9 100644 --- a/internal/render/sts.go +++ b/internal/render/sts.go @@ -3,7 +3,6 @@ package render import ( "fmt" "strconv" - "strings" "github.com/derailed/k9s/internal/client" "github.com/gdamore/tcell" @@ -16,20 +15,13 @@ import ( type StatefulSet struct{} // ColorerFunc colors a resource row. -func (StatefulSet) ColorerFunc() ColorerFunc { - return func(ns string, r RowEvent) tcell.Color { - c := DefaultColorer(ns, r) - if r.Kind == EventAdd || r.Kind == EventUpdate { +func (s StatefulSet) ColorerFunc() ColorerFunc { + return func(ns string, re RowEvent) tcell.Color { + c := DefaultColorer(ns, re) + if re.Kind == EventAdd || re.Kind == EventUpdate { return c } - - readyCol := 2 - if !client.IsAllNamespaces(ns) { - readyCol-- - } - tokens := strings.Split(strings.TrimSpace(r.Row.Fields[readyCol]), "/") - curr, des := tokens[0], tokens[1] - if curr != des { + if !Happy(ns, re.Row) { return ErrColor } @@ -47,8 +39,12 @@ func (StatefulSet) Header(ns string) HeaderRow { return append(h, Header{Name: "NAME"}, Header{Name: "READY"}, - Header{Name: "SELECTOR"}, + Header{Name: "SELECTOR", Wide: true}, Header{Name: "SERVICE"}, + Header{Name: "CONTAINERS", Wide: true}, + Header{Name: "IMAGES", Wide: true}, + Header{Name: "LABELS", Wide: true}, + Header{Name: "VALID", Wide: true}, Header{Name: "AGE", Decorator: AgeDecorator}, ) } @@ -72,11 +68,22 @@ func (s StatefulSet) Render(o interface{}, ns string, r *Row) error { } r.Fields = append(r.Fields, sts.Name, - strconv.Itoa(int(sts.Status.Replicas))+"/"+strconv.Itoa(int(*sts.Spec.Replicas)), + strconv.Itoa(int(sts.Status.ReadyReplicas))+"/"+strconv.Itoa(int(sts.Status.Replicas)), asSelector(sts.Spec.Selector), na(sts.Spec.ServiceName), + podContainerNames(sts.Spec.Template.Spec, true), + podImageNames(sts.Spec.Template.Spec, true), + mapToStr(sts.Labels), + asStatus(s.diagnose(sts.Status.Replicas, sts.Status.ReadyReplicas)), toAge(sts.ObjectMeta.CreationTimestamp), ) return nil } + +func (StatefulSet) diagnose(d, r int32) error { + if d != r { + return fmt.Errorf("desiring %d replicas got %d available", d, r) + } + return nil +} diff --git a/internal/render/sts_test.go b/internal/render/sts_test.go index 6fe8e4ae..600daa93 100644 --- a/internal/render/sts_test.go +++ b/internal/render/sts_test.go @@ -13,5 +13,5 @@ func TestStatefulSetRender(t *testing.T) { assert.Nil(t, c.Render(load(t, "sts"), "", &r)) assert.Equal(t, "default/nginx-sts", r.ID) - assert.Equal(t, render.Fields{"default", "nginx-sts", "4/4", "app=nginx-sts", "nginx-sts"}, r.Fields[:len(r.Fields)-1]) + assert.Equal(t, render.Fields{"default", "nginx-sts", "4/4", "app=nginx-sts", "nginx-sts", "nginx", "k8s.gcr.io/nginx-slim:0.8", "app=nginx-sts", ""}, r.Fields[:len(r.Fields)-1]) } diff --git a/internal/render/subject.go b/internal/render/subject.go index 5c1e0f83..dae66c77 100644 --- a/internal/render/subject.go +++ b/internal/render/subject.go @@ -11,6 +11,11 @@ import ( // Subject renders a rbac to screen. type Subject struct{} +// Happy returns true if resoure is happy, false otherwise +func (Subject) Happy(_ string, _ Row) bool { + return true +} + // ColorerFunc colors a resource row. func (Subject) ColorerFunc() ColorerFunc { return func(ns string, re RowEvent) tcell.Color { @@ -24,6 +29,7 @@ func (Subject) Header(ns string) HeaderRow { Header{Name: "NAME"}, Header{Name: "KIND"}, Header{Name: "FIRST LOCATION"}, + Header{Name: "VALID", Wide: true}, } } @@ -40,6 +46,7 @@ func (s Subject) Render(o interface{}, ns string, r *Row) error { res.Name, res.Kind, res.FirstLocation, + "", ) return nil diff --git a/internal/render/svc.go b/internal/render/svc.go index 008f2d8d..f8411f55 100644 --- a/internal/render/svc.go +++ b/internal/render/svc.go @@ -32,8 +32,10 @@ func (Service) Header(ns string) HeaderRow { Header{Name: "TYPE"}, Header{Name: "CLUSTER-IP"}, Header{Name: "EXTERNAL-IP"}, - Header{Name: "SELECTOR"}, - Header{Name: "PORTS"}, + Header{Name: "SELECTOR", Wide: true}, + Header{Name: "PORTS", Wide: true}, + Header{Name: "LABELS", Wide: true}, + Header{Name: "VALID", Wide: true}, Header{Name: "AGE", Decorator: AgeDecorator}, ) } @@ -58,19 +60,32 @@ func (s Service) Render(o interface{}, ns string, r *Row) error { r.Fields = append(r.Fields, svc.ObjectMeta.Name, string(svc.Spec.Type), - svc.Spec.ClusterIP, + toIP(svc.Spec.ClusterIP), toIPs(svc.Spec.Type, getSvcExtIPS(&svc)), mapToStr(svc.Spec.Selector), toPorts(svc.Spec.Ports), + mapToStr(svc.Labels), + asStatus(s.diagnose()), toAge(svc.ObjectMeta.CreationTimestamp), ) return nil } +func (Service) diagnose() error { + return nil +} + // ---------------------------------------------------------------------------- // Helpers... +func toIP(ip string) string { + if ip == "" || ip == "None" { + return "" + } + return ip +} + func getSvcExtIPS(svc *v1.Service) []string { results := []string{} @@ -116,7 +131,7 @@ func toIPs(svcType v1.ServiceType, ips []string) string { if svcType == v1.ServiceTypeLoadBalancer { return "" } - return MissingValue + return "" } sort.Strings(ips) diff --git a/internal/render/svc_test.go b/internal/render/svc_test.go index b74bdf64..63c1c433 100644 --- a/internal/render/svc_test.go +++ b/internal/render/svc_test.go @@ -13,5 +13,5 @@ func TestServiceRender(t *testing.T) { c.Render(load(t, "svc"), "", &r) assert.Equal(t, "default/dictionary1", r.ID) - assert.Equal(t, render.Fields{"default", "dictionary1", "ClusterIP", "10.47.248.116", "", "app=dictionary1", "http:4001►0"}, r.Fields[:7]) + assert.Equal(t, render.Fields{"default", "dictionary1", "ClusterIP", "10.47.248.116", "", "app=dictionary1", "http:4001►0"}, r.Fields[:7]) } diff --git a/internal/tchart/component.go b/internal/tchart/component.go new file mode 100644 index 00000000..64e45757 --- /dev/null +++ b/internal/tchart/component.go @@ -0,0 +1,128 @@ +package tchart + +import ( + "image" + "sync" + + "github.com/derailed/tview" + "github.com/gdamore/tcell" + "github.com/rs/zerolog/log" +) + +const ( + okColor, faultColor = tcell.ColorPaleGreen, tcell.ColorOrangeRed + okColorName, faultColorName = "palegreen", "orangered" +) + +// Component represents a graphic component. +type Component struct { + *tview.Box + + bgColor, noColor tcell.Color + seriesColors []tcell.Color + dimmed tcell.Style + id, legend string + blur func(tcell.Key) + mx sync.RWMutex +} + +// NewComponent returns a new component. +func NewComponent(id string) *Component { + return &Component{ + Box: tview.NewBox(), + id: id, + noColor: tcell.ColorBlack, + seriesColors: []tcell.Color{tview.Styles.PrimaryTextColor, tview.Styles.FocusColor}, + dimmed: tcell.StyleDefault.Background(tview.Styles.PrimitiveBackgroundColor).Foreground(tcell.ColorGray).Dim(true), + } +} + +// SetBackgroundColor sets the graph bg color. +func (c *Component) SetBackgroundColor(color tcell.Color) { + c.Box.SetBackgroundColor(color) + c.bgColor = color + c.dimmed = c.dimmed.Background(color) +} + +// ID returns the component ID. +func (c *Component) ID() string { + return c.id +} + +// SetLegend sets the component legend. +func (c *Component) SetLegend(l string) { + c.mx.Lock() + defer c.mx.Unlock() + c.legend = l +} + +// InputHandler returns the handler for this primitive. +func (c *Component) InputHandler() func(event *tcell.EventKey, setFocus func(p tview.Primitive)) { + return c.WrapInputHandler(func(event *tcell.EventKey, setFocus func(p tview.Primitive)) { + switch key := event.Key(); key { + case tcell.KeyEnter: + log.Debug().Msgf("YO %s ENTER!!", c.id) + case tcell.KeyBacktab, tcell.KeyTab: + log.Debug().Msgf("YO %s TAB!!", c.id) + if c.blur != nil { + c.blur(key) + } + setFocus(c) + } + }) +} + +// IsDial returns true if chart is a dial +func (c *Component) IsDial() bool { + return false +} + +// SetBlurFunc sets a callback fn when component gets out of focus. +func (c *Component) SetBlurFunc(handler func(key tcell.Key)) *Component { + c.blur = handler + return c +} + +// SetSeriesColors sets the component series colors. +func (c *Component) SetSeriesColors(cc ...tcell.Color) { + c.mx.Lock() + defer c.mx.Unlock() + c.seriesColors = cc +} + +// GetSeriesColorNames returns series colors by name. +func (c *Component) GetSeriesColorNames() []string { + c.mx.RLock() + defer c.mx.RUnlock() + + var nn []string + for _, color := range c.seriesColors { + for name, co := range tcell.ColorNames { + if co == color { + nn = append(nn, name) + } + } + } + if len(nn) < 2 { + nn = append(nn, okColorName, faultColorName) + } + + return nn +} + +func (c *Component) colorForSeries() (tcell.Color, tcell.Color) { + c.mx.RLock() + defer c.mx.RUnlock() + if len(c.seriesColors) > 1 { + return c.seriesColors[0], c.seriesColors[1] + } + return okColor, faultColor +} + +func (c *Component) asRect() image.Rectangle { + x, y, width, height := c.GetInnerRect() + return image.Rectangle{ + Min: image.Point{X: x, Y: y}, + Max: image.Point{X: x + width, Y: y + height}, + } +} diff --git a/internal/tchart/dot_matrix.go b/internal/tchart/dot_matrix.go new file mode 100644 index 00000000..8115a314 --- /dev/null +++ b/internal/tchart/dot_matrix.go @@ -0,0 +1,106 @@ +package tchart + +import ( + "fmt" +) + +// var dots = []rune{' ', '⠂', '⠶', '⠿'} +var dots = []rune{' ', '⠂', '▤', '▥'} + +// var dots = []rune{' ', '⠂', '▤', '▇'} + +type Segment []int + +type Segments []Segment + +type Matrix [][]rune + +type Orientation int + +type DotMatrix struct { + row, col int +} + +func NewDotMatrix(row, col int) DotMatrix { + return DotMatrix{ + row: row, + col: col, + } +} + +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)) + } +} + +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: [][]int{[]int{1, 0}, []int{0}, []int{2, 0}}, + 1: [][]int{[]int{1}, nil, []int{2}}, + 2: [][]int{[]int{1, 3}, []int{3}, []int{2, 3}}, + 3: [][]int{[]int{4}, nil, []int{5}}, + 4: [][]int{[]int{4, 6}, []int{6}, []int{5, 6}}, +} + +func ToSegments(row, col int) []int { + return segs[row][col] +} diff --git a/internal/tchart/dot_matrix_test.go b/internal/tchart/dot_matrix_test.go new file mode 100644 index 00000000..8f032e7d --- /dev/null +++ b/internal/tchart/dot_matrix_test.go @@ -0,0 +1,126 @@ +package tchart_test + +import ( + "strconv" + "testing" + + "github.com/derailed/k9s/internal/tchart" + "github.com/stretchr/testify/assert" +) + +func TestSegmentFor(t *testing.T) { + uu := map[string]struct { + r, c int + e []int + }{ + "0x0": {r: 0, c: 0, e: []int{1, 0}}, + "0x1": {r: 0, c: 1, e: []int{0}}, + "0x2": {r: 0, c: 2, e: []int{2, 0}}, + "1x0": {r: 1, c: 0, e: []int{1}}, + "1x1": {r: 1, c: 1, e: nil}, + "1x2": {r: 1, c: 2, e: []int{2}}, + "2x0": {r: 2, c: 0, e: []int{1, 3}}, + "2x1": {r: 2, c: 1, e: []int{3}}, + "2x2": {r: 2, c: 2, e: []int{2, 3}}, + "3x0": {r: 3, c: 0, e: []int{4}}, + "3x1": {r: 3, c: 1, e: nil}, + "3x2": {r: 3, c: 2, e: []int{5}}, + "4x0": {r: 4, c: 0, e: []int{4, 6}}, + "4x1": {r: 4, c: 1, e: []int{6}}, + "4x2": {r: 4, c: 2, e: []int{5, 6}}, + } + + for k := range uu { + u := uu[k] + t.Run(k, func(t *testing.T) { + assert.Equal(t, u.e, tchart.ToSegments(u.r, u.c)) + }) + } +} + +func TestDial(t *testing.T) { + d := tchart.NewDotMatrix(5, 3) + for n := 0; n <= 9; n++ { + i := n + t.Run(strconv.Itoa(n), func(t *testing.T) { + assert.Equal(t, numbers[i], d.Print(i)) + }) + } +} + +// Helpers... + +const hChar, vChar = '⠶', '⠿' + +var numbers = []tchart.Matrix{ + [][]rune{ + {hChar, hChar, hChar}, + {vChar, ' ', vChar}, + {vChar, ' ', vChar}, + {vChar, ' ', vChar}, + {hChar, hChar, hChar}, + }, + [][]rune{ + {' ', ' ', hChar}, + {' ', ' ', vChar}, + {' ', ' ', vChar}, + {' ', ' ', vChar}, + {' ', ' ', vChar}, + }, + [][]rune{ + {hChar, hChar, hChar}, + {' ', ' ', vChar}, + {hChar, hChar, hChar}, + {vChar, ' ', ' '}, + {hChar, hChar, hChar}, + }, + [][]rune{ + {hChar, hChar, hChar}, + {' ', ' ', vChar}, + {hChar, hChar, hChar}, + {' ', ' ', vChar}, + {hChar, hChar, hChar}, + }, + [][]rune{ + {hChar, ' ', ' '}, + {vChar, ' ', ' '}, + {hChar, hChar, hChar}, + {' ', ' ', vChar}, + {' ', ' ', vChar}, + }, + [][]rune{ + {hChar, hChar, hChar}, + {vChar, ' ', ' '}, + {hChar, hChar, hChar}, + {' ', ' ', vChar}, + {hChar, hChar, hChar}, + }, + [][]rune{ + {hChar, ' ', ' '}, + {vChar, ' ', ' '}, + {hChar, hChar, hChar}, + {vChar, ' ', vChar}, + {hChar, hChar, hChar}, + }, + [][]rune{ + {hChar, hChar, hChar}, + {' ', ' ', vChar}, + {' ', ' ', vChar}, + {' ', ' ', vChar}, + {' ', ' ', vChar}, + }, + [][]rune{ + {hChar, hChar, hChar}, + {vChar, ' ', vChar}, + {hChar, hChar, hChar}, + {vChar, ' ', vChar}, + {hChar, hChar, hChar}, + }, + [][]rune{ + {hChar, hChar, hChar}, + {vChar, ' ', vChar}, + {hChar, hChar, hChar}, + {' ', ' ', vChar}, + {' ', ' ', vChar}, + }, +} diff --git a/internal/tchart/gauge.go b/internal/tchart/gauge.go new file mode 100644 index 00000000..87fa9ce4 --- /dev/null +++ b/internal/tchart/gauge.go @@ -0,0 +1,149 @@ +package tchart + +import ( + "fmt" + "image" + + "github.com/derailed/tview" + "github.com/gdamore/tcell" +) + +const ( + DeltaSame delta = iota + DeltaMore + 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 +} + +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 + 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) + } +} + +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-1, rect.Dx(), tview.AlignCenter, tcell.ColorWhite) + } +} + +func (g *Gauge) drawDial(sc tcell.Screen, m Matrix, o image.Point, style tcell.Style) { + for r := 0; r < len(m); r++ { + for c := 0; c < len(m[r]); c++ { + dot := m[r][c] + if dot == dots[0] { + sc.SetContent(o.X+c, o.Y+r, dots[1], nil, g.dimmed) + } else { + sc.SetContent(o.X+c, o.Y+r, dot, nil, style) + } + } + } +} + +// ---------------------------------------------------------------------------- +// Helpers... + +func computeDelta(d1, d2 int) delta { + if d2 == 0 { + return DeltaSame + } + + d := d2 - d1 + switch { + case d > 0: + return DeltaMore + case d < 0: + return DeltaLess + default: + return DeltaSame + } +} + +func printDelta(sc tcell.Screen, d delta, o image.Point, s tcell.Style) { + s = s.Dim(false) + switch d { + case DeltaLess: + sc.SetContent(o.X-1, o.Y+2, '↓', nil, s) + case DeltaMore: + sc.SetContent(o.X-1, o.Y+2, '↑', nil, s) + } +} diff --git a/internal/tchart/sparkline.go b/internal/tchart/sparkline.go new file mode 100644 index 00000000..e4031892 --- /dev/null +++ b/internal/tchart/sparkline.go @@ -0,0 +1,161 @@ +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 +} + +// Max 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 + lastWidth int +} + +// 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.lastWidth = rect.Dx() + s.cutSet(rect.Dx()) + max := s.computeMax() + + cX := rect.Min.X + 1 + if len(s.data) < rect.Dx() { + cX = rect.Max.X - len(s.data) + } + + scale := float64(len(sparks)) * float64((rect.Dy() - pad)) / float64(max) + + c1, c2 := s.colorForSeries() + for _, d := range s.data { + b := toBlocks(d, scale) + cY := rect.Max.Y - pad + cY = s.drawBlock(screen, cX, cY, b.oks, c1) + s.drawBlock(screen, cX, cY, b.errs, c2) + cX++ + } + + 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-1, rect.Dx(), tview.AlignCenter, tcell.ColorWhite) + } +} + +func (s *SparkLine) drawBlock(screen tcell.Screen, x, y int, b block, c tcell.Color) int { + 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) + } + + return y +} + +func (s *SparkLine) cutSet(w int) { + if w <= 0 || len(s.data) == 0 { + return + } + + if w < len(s.data) { + s.data = s.data[len(s.data)-w:] + } +} + +func (s *SparkLine) computeMax() int { + var max int + for _, d := range s.data { + sum := d.Sum() + if sum > max { + max = sum + } + } + + return max +} + +func toBlocks(value Metric, scale float64) blocks { + if value.Sum() <= 0 { + return blocks{} + } + + oks := int(math.Floor(float64(value.OK) * scale)) + part, okB := oks%len(sparks), block{full: oks / len(sparks)} + if part > 0 { + okB.partial = sparks[part-1] + } + + errs := int(math.Round(float64(value.Fault) * scale)) + part, errB := errs%len(sparks), block{full: errs / len(sparks)} + if part > 0 { + errB.partial = sparks[part-1] + } + + return blocks{oks: okB, errs: errB} +} diff --git a/internal/ui/app.go b/internal/ui/app.go index 54f2a833..efe187a7 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -3,6 +3,7 @@ package ui import ( "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/config" + "github.com/derailed/k9s/internal/model" "github.com/derailed/tview" "github.com/gdamore/tcell" "github.com/rs/zerolog/log" @@ -14,6 +15,7 @@ type App struct { Configurator Main *Pages + flash *model.Flash actions KeyActions views map[string]tview.Primitive cmdBuff *CmdBuff @@ -25,6 +27,7 @@ func NewApp(context string) *App { Application: tview.NewApplication(), actions: make(KeyActions), Main: NewPages(), + flash: model.NewFlash(model.DefaultFlashDelay), cmdBuff: NewCmdBuff(':', CommandBuff), } a.ReloadStyles(context) @@ -33,7 +36,6 @@ func NewApp(context string) *App { "menu": NewMenu(a.Styles), "logo": NewLogo(a.Styles), "cmd": NewCommand(a.Styles), - "flash": NewFlash(&a, "Initializing..."), "crumbs": NewCrumbs(a.Styles), } @@ -239,11 +241,6 @@ func (a *App) Logo() *Logo { return a.views["logo"].(*Logo) } -// Flash returns app flash. -func (a *App) Flash() *Flash { - return a.views["flash"].(*Flash) -} - // Cmd returns app cmd. func (a *App) Cmd() *Command { return a.views["cmd"].(*Command) @@ -254,6 +251,11 @@ func (a *App) Menu() *Menu { return a.views["menu"].(*Menu) } +// Flash returns a flash model. +func (a *App) Flash() *model.Flash { + return a.flash +} + // ---------------------------------------------------------------------------- // Helpers... diff --git a/internal/ui/app_test.go b/internal/ui/app_test.go index 4e189c59..2fdde772 100644 --- a/internal/ui/app_test.go +++ b/internal/ui/app_test.go @@ -59,7 +59,7 @@ func TestAppViews(t *testing.T) { a := ui.NewApp("") a.Init() - vv := []string{"crumbs", "logo", "cmd", "flash", "menu"} + vv := []string{"crumbs", "logo", "cmd", "menu"} for i := range vv { v := vv[i] t.Run(v, func(t *testing.T) { @@ -68,7 +68,6 @@ func TestAppViews(t *testing.T) { } assert.NotNil(t, a.Crumbs()) - assert.NotNil(t, a.Flash()) assert.NotNil(t, a.Logo()) assert.NotNil(t, a.Cmd()) assert.NotNil(t, a.Menu()) diff --git a/internal/ui/config.go b/internal/ui/config.go index 857179db..2620aea2 100644 --- a/internal/ui/config.go +++ b/internal/ui/config.go @@ -79,6 +79,8 @@ func (c *Configurator) RefreshStyles(context string) { clusterSkins := filepath.Join(config.K9sHome, fmt.Sprintf("%s_skin.yml", context)) if c.Styles == nil { c.Styles = config.NewStyles() + } else { + c.Styles.Reset() } if err := c.Styles.Load(clusterSkins); err != nil { log.Info().Msgf("No context specific skin file found -- %s", clusterSkins) @@ -102,10 +104,10 @@ func (c *Configurator) updateStyles(f string) { } c.Styles.Update() - render.StdColor = config.AsColor(c.Styles.Frame().Status.NewColor) - render.AddColor = config.AsColor(c.Styles.Frame().Status.AddColor) - render.ModColor = config.AsColor(c.Styles.Frame().Status.ModifyColor) - render.ErrColor = config.AsColor(c.Styles.Frame().Status.ErrorColor) - render.HighlightColor = config.AsColor(c.Styles.Frame().Status.HighlightColor) - render.CompletedColor = config.AsColor(c.Styles.Frame().Status.CompletedColor) + render.StdColor = c.Styles.Frame().Status.NewColor.Color() + render.AddColor = c.Styles.Frame().Status.AddColor.Color() + render.ModColor = c.Styles.Frame().Status.ModifyColor.Color() + render.ErrColor = c.Styles.Frame().Status.ErrorColor.Color() + render.HighlightColor = c.Styles.Frame().Status.HighlightColor.Color() + render.CompletedColor = c.Styles.Frame().Status.CompletedColor.Color() } diff --git a/internal/ui/flash.go b/internal/ui/flash.go index 956cb893..acd6d588 100644 --- a/internal/ui/flash.go +++ b/internal/ui/flash.go @@ -2,51 +2,30 @@ 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(), @@ -54,15 +33,14 @@ func NewFlash(app *App, m string) *Flash { f.SetTextColor(tcell.ColorAqua) f.SetTextAlign(tview.AlignLeft) 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,53 @@ 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 +func (f *Flash) Watch(ctx context.Context, c model.FlashChan) { + defer log.Debug().Msgf("Flash Canceled!") + for { + select { + case <-ctx.Done(): + return + case msg := <-c: + f.SetMessage(msg) } } - log.Error().Err(err).Msgf(fmat, args...) - f.SetMessage(FlashErr, fmt.Sprintf(fmat, args...)) } // SetMessage sets flash message and level. -func (f *Flash) SetMessage(level FlashLevel, msg ...string) { - if f.cancel != nil { - f.cancel() +func (f *Flash) SetMessage(m model.LevelMessage) { + fn := func() { + if m.Text == "" { + f.Clear() + return + } + f.SetTextColor(flashColor(m.Level)) + f.SetText(flashEmoji(m.Level) + " " + m.Text) } - _, _, width, _ := f.GetRect() - if width <= 15 { - width = 100 - } - m := strings.Join(msg, " ") - if f.flushNow { - f.SetTextColor(flashColor(level)) - f.SetText(render.Truncate(flashEmoji(level)+" "+m, width-3)) + if f.testMode { + fn() } else { - f.app.QueueUpdateDraw(func() { - f.SetTextColor(flashColor(level)) - f.SetText(render.Truncate(flashEmoji(level)+" "+m, width-3)) - }) + f.app.QueueUpdateDraw(fn) } - - var ctx context.Context - ctx, f.cancel = context.WithTimeout(context.Background(), flashDelay) - go f.refresh(ctx) } -func (f *Flash) refresh(ctx context.Context) { - <-ctx.Done() - f.app.QueueUpdateDraw(func() { - f.Clear() - }) -} - -func flashEmoji(l FlashLevel) string { +func flashEmoji(l model.FlashLevel) string { switch l { - case FlashWarn: + case model.FlashWarn: return emoDoh - case FlashErr: + case model.FlashErr: return emoRed - case FlashFatal: - return emoDead default: return emoHappy } } -func flashColor(l FlashLevel) tcell.Color { +func flashColor(l model.FlashLevel) tcell.Color { switch l { - case FlashWarn: + case model.FlashWarn: return tcell.ColorOrange - case FlashErr: + case model.FlashErr: return tcell.ColorOrangeRed - case FlashFatal: - return tcell.ColorFuchsia default: return tcell.ColorNavajoWhite } diff --git a/internal/ui/flash_test.go b/internal/ui/flash_test.go index c158f166..e46c591d 100644 --- a/internal/ui/flash_test.go +++ b/internal/ui/flash_test.go @@ -1,45 +1,40 @@ package ui_test import ( - "errors" + "context" "testing" + "time" + "github.com/derailed/k9s/internal/model" "github.com/derailed/k9s/internal/ui" "github.com/stretchr/testify/assert" ) -func TestFlashInfo(t *testing.T) { - f := newFlash() - f.Info("Blee") +func TestFlash(t *testing.T) { + const delay = 1 * time.Millisecond + uu := map[string]struct { + l model.FlashLevel + i, e string + }{ + "info": {l: model.FlashInfo, i: "hello", e: "😎 hello\n"}, + "warn": {l: model.FlashWarn, i: "hello", e: "😗 hello\n"}, + "err": {l: model.FlashErr, i: "hello", e: "😡 hello\n"}, + } - assert.Equal(t, "😎 Blee\n", f.GetText(false)) - f.Infof("Blee %s", "duh") - assert.Equal(t, "😎 Blee duh\n", f.GetText(false)) -} - -func TestFlashWarn(t *testing.T) { - f := newFlash() - f.Warn("Blee") - - assert.Equal(t, "😗 Blee\n", f.GetText(false)) - f.Warnf("Blee %s", "duh") - assert.Equal(t, "😗 Blee duh\n", f.GetText(false)) -} - -func TestFlashErr(t *testing.T) { - f := newFlash() - - f.Err(errors.New("Blee")) - assert.Equal(t, "😡 Blee\n", f.GetText(false)) - f.Errf("Blee %s", "duh") - assert.Equal(t, "😡 Blee duh\n", f.GetText(false)) -} - -// ---------------------------------------------------------------------------- -// Helpers... - -func newFlash() *ui.Flash { - f := ui.NewFlash(ui.NewApp(""), "YO!") - f.TestMode() - return f + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + a := ui.NewApp("test") + f := ui.NewFlash(a) + f.SetTestMode(true) + go f.Watch(ctx, a.Flash().Channel()) + + for k := range uu { + u := uu[k] + t.Run(k, func(t *testing.T) { + a.Flash().SetMessage(u.l, u.i) + time.Sleep(delay) + assert.Equal(t, u.e, f.GetText(false)) + }) + } } diff --git a/internal/ui/logo.go b/internal/ui/logo.go index e5f2ec9c..500b8840 100644 --- a/internal/ui/logo.go +++ b/internal/ui/logo.go @@ -60,30 +60,30 @@ func (l *Logo) Reset() { // Err displays a log error state. func (l *Logo) Err(msg string) { - l.update(msg, "red") + l.update(msg, config.NewColor("red")) } // Warn displays a log warning state. func (l *Logo) Warn(msg string) { - l.update(msg, "mediumvioletred") + l.update(msg, config.NewColor("mediumvioletred")) } // Info displays a log info state. func (l *Logo) Info(msg string) { - l.update(msg, "green") + l.update(msg, config.NewColor("green")) } -func (l *Logo) update(msg, c string) { +func (l *Logo) update(msg string, c config.Color) { l.refreshStatus(msg, c) l.refreshLogo(c) } -func (l *Logo) refreshStatus(msg, c string) { - l.status.SetBackgroundColor(config.AsColor(c)) +func (l *Logo) refreshStatus(msg string, c config.Color) { + l.status.SetBackgroundColor(c.Color()) l.status.SetText(fmt.Sprintf("[white::b]%s", msg)) } -func (l *Logo) refreshLogo(c string) { +func (l *Logo) refreshLogo(c config.Color) { l.logo.Clear() for i, s := range LogoSmall { fmt.Fprintf(l.logo, "[%s::b]%s", c, s) diff --git a/internal/ui/menu.go b/internal/ui/menu.go index 958cf5d9..cc1ea1d6 100644 --- a/internal/ui/menu.go +++ b/internal/ui/menu.go @@ -188,16 +188,16 @@ func toMnemonic(s string) string { } func formatNSMenu(i int, name string, styles config.Frame) string { - fmat := strings.Replace(menuIndexFmt, "[key", "["+styles.Menu.NumKeyColor, 1) - fmat = strings.Replace(fmat, ":bg:", ":"+styles.Title.BgColor+":", -1) - fmat = strings.Replace(fmat, "[fg", "["+styles.Menu.FgColor, 1) + fmat := strings.Replace(menuIndexFmt, "[key", "["+styles.Menu.NumKeyColor.String(), 1) + fmat = strings.Replace(fmat, ":bg:", ":"+styles.Title.BgColor.String()+":", -1) + fmat = strings.Replace(fmat, "[fg", "["+styles.Menu.FgColor.String(), 1) return fmt.Sprintf(fmat, i, name) } func formatPlainMenu(h model.MenuHint, size int, styles config.Frame) string { menuFmt := " [key:-:b]%-" + strconv.Itoa(size+2) + "s [fg:-:d]%s " - fmat := strings.Replace(menuFmt, "[key", "["+styles.Menu.KeyColor, 1) - fmat = strings.Replace(fmat, "[fg", "["+styles.Menu.FgColor, 1) - fmat = strings.Replace(fmat, ":bg:", ":"+styles.Title.BgColor+":", -1) + fmat := strings.Replace(menuFmt, "[key", "["+styles.Menu.KeyColor.String(), 1) + fmat = strings.Replace(fmat, "[fg", "["+styles.Menu.FgColor.String(), 1) + fmat = strings.Replace(fmat, ":bg:", ":"+styles.Title.BgColor.String()+":", -1) return fmt.Sprintf(fmat, toMnemonic(h.Mnemonic), h.Description) } diff --git a/internal/ui/table.go b/internal/ui/table.go index a9c8c9e5..bcb2e224 100644 --- a/internal/ui/table.go +++ b/internal/ui/table.go @@ -38,6 +38,8 @@ type Table struct { sortCol SortColumn colorerFn render.ColorerFunc decorateFn DecorateFunc + wide bool + toast bool } // NewTable returns a new table view. @@ -65,6 +67,7 @@ func (t *Table) Init(ctx context.Context) { t.SetSelectable(true, false) t.SetSelectionChangedFunc(t.selectionChanged) t.SetInputCapture(t.keyboard) + t.SetBackgroundColor(tcell.ColorDefault) t.styles = mustExtractSyles(ctx) t.StylesChanged(t.styles) @@ -72,17 +75,35 @@ func (t *Table) Init(ctx context.Context) { // StylesChanged notifies the skin changed. func (t *Table) StylesChanged(s *config.Styles) { - t.SetBackgroundColor(config.AsColor(s.Table().BgColor)) - t.SetBorderColor(config.AsColor(s.Table().FgColor)) - t.SetBorderFocusColor(config.AsColor(s.Frame().Border.FocusColor)) + t.SetBackgroundColor(s.Table().BgColor.Color()) + t.SetBorderColor(s.Table().FgColor.Color()) + t.SetBorderFocusColor(s.Frame().Border.FocusColor.Color()) t.SetSelectedStyle( tcell.ColorBlack, - config.AsColor(t.styles.Table().CursorColor), + t.styles.Table().CursorColor.Color(), tcell.AttrBold, ) t.Refresh() } +// ResetToast resets toast flag. +func (t *Table) ResetToast() { + t.toast = false + t.Refresh() +} + +// ToggleToast toggles to show toast resources. +func (t *Table) ToggleToast() { + t.toast = !t.toast + t.Refresh() +} + +// ToggleWide toggles wide col display. +func (t *Table) ToggleWide() { + t.wide = !t.wide + t.Refresh() +} + // Actions returns active menu bindings. func (t *Table) Actions() KeyActions { return t.actions @@ -166,10 +187,7 @@ func (t *Table) Update(data render.TableData) { if t.decorateFn != nil { data = t.decorateFn(data) } - if !t.cmdBuff.Empty() { - data = t.filtered(data) - } - t.doUpdate(data) + t.doUpdate(t.filtered(data)) t.UpdateTitle() } @@ -182,13 +200,18 @@ func (t *Table) doUpdate(data render.TableData) { t.Clear() t.adjustSorter(data) - fg := config.AsColor(t.styles.Table().Header.FgColor) - bg := config.AsColor(t.styles.Table().Header.BgColor) - for col, h := range data.Header { + fg := t.styles.Table().Header.FgColor.Color() + bg := t.styles.Table().Header.BgColor.Color() + var col int + for _, h := range data.Header { + if h.Wide && !t.wide { + continue + } t.AddHeaderCell(col, h) c := t.GetCell(0, col) c.SetBackgroundColor(bg) c.SetTextColor(fg) + col++ } data.RowEvents.Sort(data.Namespace, t.sortCol.index, t.sortCol.asc) @@ -209,6 +232,8 @@ func (t *Table) SortColCmd(col int, asc bool) func(evt *tcell.EventKey) *tcell.E index = 0 case -1: index = t.GetColumnCount() - 1 + case -3: + index = t.GetColumnCount() - 2 default: index = t.NameColIndex() + col } @@ -251,29 +276,33 @@ func (t *Table) buildRow(ns string, r int, re render.RowEvent, header render.Hea color = t.colorerFn } marked := t.IsMarked(re.Row.ID) - for col, field := range re.Row.Fields { - if !re.Deltas.IsBlank() && !header.AgeCol(col) { - field += Deltas(re.Deltas[col], field) + var col int + for c, field := range re.Row.Fields { + if header[c].Wide && !t.wide { + continue + } + if !re.Deltas.IsBlank() && !header.AgeCol(c) { + field += Deltas(re.Deltas[c], field) + } + if header[c].Decorator != nil { + field = header[c].Decorator(field) + } + if header[c].Align == tview.AlignLeft { + field = formatCell(field, pads[c]) } - if header[col].Decorator != nil { - field = header[col].Decorator(field) - } - - if header[col].Align == tview.AlignLeft { - field = formatCell(field, pads[col]) - } - c := tview.NewTableCell(field) - c.SetExpansion(1) - c.SetAlign(header[col].Align) - c.SetTextColor(color(ns, re)) + cell := tview.NewTableCell(field) + cell.SetExpansion(1) + cell.SetAlign(header[c].Align) + cell.SetTextColor(color(ns, re)) if marked { - c.SetTextColor(config.AsColor(t.styles.Table().MarkColor)) + cell.SetTextColor(t.styles.Table().MarkColor.Color()) } if col == 0 { - c.SetReference(re.Row.ID) + cell.SetReference(re.Row.ID) } - t.SetCell(r, col, c) + t.SetCell(r, col, cell) + col++ } } @@ -298,6 +327,9 @@ func (t *Table) GetSelectedRow() render.Row { // NameColIndex returns the index of the resource name column. func (t *Table) NameColIndex() int { col := 0 + if client.IsClusterScoped(t.GetModel().GetNamespace()) { + return col + } if t.GetModel().ClusterWide() { col++ } @@ -313,20 +345,25 @@ func (t *Table) AddHeaderCell(col int, h render.Header) { } func (t *Table) filtered(data render.TableData) render.TableData { - if t.cmdBuff.Empty() || IsLabelSelector(t.cmdBuff.String()) { - return data + filtered := data + if t.toast { + filtered = filterToast(data) } - q := t.cmdBuff.String() - if IsFuzzySelector(q) { - return fuzzyFilter(q[2:], t.NameColIndex(), data) + if t.cmdBuff.Empty() || IsLabelSelector(t.cmdBuff.String()) { + return filtered } - filtered, err := rxFilter(t.cmdBuff.String(), data) + q := t.cmdBuff.String() + if IsFuzzySelector(q) { + return fuzzyFilter(q[2:], t.NameColIndex(), filtered) + } + + filtered, err := rxFilter(t.cmdBuff.String(), filtered) if err != nil { log.Error().Err(errors.New("Invalid filter expression")).Msg("Regexp") t.cmdBuff.Clear() - return data } + return filtered } diff --git a/internal/ui/table_helper.go b/internal/ui/table_helper.go index c3d5b0ef..3f02d7d6 100644 --- a/internal/ui/table_helper.go +++ b/internal/ui/table_helper.go @@ -85,15 +85,15 @@ func TrimLabelSelector(s string) string { // SkinTitle decorates a title. func SkinTitle(fmat string, style config.Frame) string { bgColor := style.Title.BgColor - if bgColor == "default" { - bgColor = "-" + if bgColor == config.DefaultColor { + bgColor = config.TransparentColor } - fmat = strings.Replace(fmat, "[fg:bg", "["+style.Title.FgColor+":"+bgColor, -1) - fmat = strings.Replace(fmat, "[hilite", "["+style.Title.HighlightColor, 1) - fmat = strings.Replace(fmat, "[key", "["+style.Menu.NumKeyColor, 1) - fmat = strings.Replace(fmat, "[filter", "["+style.Title.FilterColor, 1) - fmat = strings.Replace(fmat, "[count", "["+style.Title.CounterColor, 1) - fmat = strings.Replace(fmat, ":bg:", ":"+bgColor+":", -1) + fmat = strings.Replace(fmat, "[fg:bg", "["+style.Title.FgColor.String()+":"+bgColor.String(), -1) + fmat = strings.Replace(fmat, "[hilite", "["+style.Title.HighlightColor.String(), 1) + fmat = strings.Replace(fmat, "[key", "["+style.Menu.NumKeyColor.String(), 1) + fmat = strings.Replace(fmat, "[filter", "["+style.Title.FilterColor.String(), 1) + fmat = strings.Replace(fmat, "[count", "["+style.Title.CounterColor.String(), 1) + fmat = strings.Replace(fmat, ":bg:", ":"+bgColor.String()+":", -1) return fmat } @@ -118,6 +118,25 @@ func formatCell(field string, padding int) string { return field } +func filterToast(data render.TableData) render.TableData { + validX := data.Header.IndexOf("VALID") + if validX == -1 { + return data + } + + toast := render.TableData{ + Header: data.Header, + RowEvents: make(render.RowEvents, 0, len(data.RowEvents)), + Namespace: data.Namespace, + } + for _, re := range data.RowEvents { + if re.Row.Fields[validX] != "" { + toast.RowEvents = append(toast.RowEvents, re) + } + } + return toast +} + func rxFilter(q string, data render.TableData) (render.TableData, error) { rx, err := regexp.Compile(`(?i)` + q) if err != nil { diff --git a/internal/ui/table_test.go b/internal/ui/table_test.go index 9f63fc8e..bc32a21d 100644 --- a/internal/ui/table_test.go +++ b/internal/ui/table_test.go @@ -66,6 +66,7 @@ func (t *testModel) Peek() render.TableData { return makeTableData() } func (t *testModel) ClusterWide() bool { return false } func (t *testModel) GetNamespace() string { return "blee" } func (t *testModel) SetNamespace(string) {} +func (t *testModel) ToggleToast() {} func (t *testModel) AddListener(model.TableListener) {} func (t *testModel) Watch(context.Context) {} func (t *testModel) Get(ctx context.Context, path string) (runtime.Object, error) { diff --git a/internal/view/alias.go b/internal/view/alias.go index 6b6b7b43..4e822f2f 100644 --- a/internal/view/alias.go +++ b/internal/view/alias.go @@ -32,6 +32,14 @@ func NewAlias(gvr client.GVR) ResourceViewer { return &a } +func (a *Alias) Init(ctx context.Context) error { + if err := a.ResourceViewer.Init(ctx); err != nil { + return err + } + a.GetTable().GetModel().SetNamespace("*") + return nil +} + func (a *Alias) aliasContext(ctx context.Context) context.Context { return context.WithValue(ctx, internal.KeyAliases, a.App().command.alias) } diff --git a/internal/view/alias_test.go b/internal/view/alias_test.go index ecc4c8d0..24199528 100644 --- a/internal/view/alias_test.go +++ b/internal/view/alias_test.go @@ -23,7 +23,7 @@ func TestAliasNew(t *testing.T) { assert.Nil(t, v.Init(makeContext())) assert.Equal(t, "Aliases", v.Name()) - assert.Equal(t, 5, len(v.Hints())) + assert.Equal(t, 7, len(v.Hints())) } func TestAliasSearch(t *testing.T) { @@ -105,6 +105,7 @@ func (t *testModel) Peek() render.TableData { return makeTableData() } func (t *testModel) ClusterWide() bool { return false } func (t *testModel) GetNamespace() string { return "blee" } func (t *testModel) SetNamespace(string) {} +func (t *testModel) ToggleToast() {} func (t *testModel) AddListener(model.TableListener) {} func (t *testModel) Watch(context.Context) {} func (t *testModel) Get(context.Context, string) (runtime.Object, error) { diff --git a/internal/view/app.go b/internal/view/app.go index 903917a1..98997efe 100644 --- a/internal/view/app.go +++ b/internal/view/app.go @@ -98,11 +98,14 @@ func (a *App) Init(version string, rate int) error { a.clusterInfo().Init() + flash := ui.NewFlash(a.App) + go flash.Watch(ctx, a.Flash().Channel()) + main := tview.NewFlex().SetDirection(tview.FlexRow) main.AddItem(a.statusIndicator(), 1, 1, false) main.AddItem(a.Content, 0, 10, true) main.AddItem(a.Crumbs(), 2, 1, false) - main.AddItem(a.Flash(), 2, 1, false) + main.AddItem(flash, 2, 1, false) a.Main.AddPage("main", main, true, false) a.Main.AddPage("splash", ui.NewSplash(a.Styles, version), true, true) @@ -113,7 +116,7 @@ func (a *App) Init(version string, rate int) error { func (a *App) bindKeys() { a.AddActions(ui.KeyActions{ - ui.KeyT: ui.NewSharedKeyAction("ToggleHeader", a.toggleHeaderCmd, false), + tcell.KeyCtrlE: ui.NewSharedKeyAction("ToggleHeader", a.toggleHeaderCmd, false), ui.KeyHelp: ui.NewSharedKeyAction("Help", a.helpCmd, false), tcell.KeyCtrlA: ui.NewSharedKeyAction("Aliases", a.aliasCmd, false), tcell.KeyEnter: ui.NewKeyAction("Goto", a.gotoCmd, false), @@ -198,7 +201,7 @@ func (a *App) refreshCluster() { if ok := a.Conn().CheckConnectivity(); ok { if atomic.LoadInt32(&a.conRetry) > 0 { atomic.StoreInt32(&a.conRetry, 0) - a.Status(ui.FlashInfo, "K8s connectivity OK") + a.Status(model.FlashInfo, "K8s connectivity OK") if c != nil { c.Start() } @@ -210,7 +213,7 @@ func (a *App) refreshCluster() { } count := atomic.LoadInt32(&a.conRetry) log.Warn().Msgf("Conn check failed (%d/%d)", count, maxConRetry) - a.Status(ui.FlashWarn, fmt.Sprintf("Dial K8s failed (%d)", count)) + a.Status(model.FlashWarn, fmt.Sprintf("Dial K8s failed (%d)", count)) } count := atomic.LoadInt32(&a.conRetry) @@ -258,6 +261,7 @@ func (a *App) switchCtx(name string, loadPods bool) error { } a.initFactory(ns) + client.ResetMetrics() if err := a.command.Reset(true); err != nil { return err } @@ -309,7 +313,7 @@ func (a *App) Run() error { } // Status reports a new app status for display. -func (a *App) Status(l ui.FlashLevel, msg string) { +func (a *App) Status(l model.FlashLevel, msg string) { a.QueueUpdateDraw(func() { a.Flash().SetMessage(l, msg) a.setIndicator(l, msg) @@ -327,13 +331,13 @@ func (a *App) ClearStatus(flash bool) { }) } -func (a *App) setLogo(l ui.FlashLevel, msg string) { +func (a *App) setLogo(l model.FlashLevel, msg string) { switch l { - case ui.FlashErr: + case model.FlashErr: a.Logo().Err(msg) - case ui.FlashWarn: + case model.FlashWarn: a.Logo().Warn(msg) - case ui.FlashInfo: + case model.FlashInfo: a.Logo().Info(msg) default: a.Logo().Reset() @@ -341,13 +345,13 @@ func (a *App) setLogo(l ui.FlashLevel, msg string) { a.Draw() } -func (a *App) setIndicator(l ui.FlashLevel, msg string) { +func (a *App) setIndicator(l model.FlashLevel, msg string) { switch l { - case ui.FlashErr: + case model.FlashErr: a.statusIndicator().Err(msg) - case ui.FlashWarn: + case model.FlashWarn: a.statusIndicator().Warn(msg) - case ui.FlashInfo: + case model.FlashInfo: a.statusIndicator().Info(msg) default: a.statusIndicator().Reset() diff --git a/internal/view/browser.go b/internal/view/browser.go index 80c65281..9d561876 100644 --- a/internal/view/browser.go +++ b/internal/view/browser.go @@ -94,6 +94,10 @@ func (b *Browser) Start() { b.Stop() b.Table.Start() + b.GetModel().Watch(b.prepareContext()) +} + +func (b *Browser) prepareContext() context.Context { ctx := b.defaultContext() ctx, b.cancelFn = context.WithCancel(ctx) if b.contextFn != nil { @@ -102,7 +106,8 @@ func (b *Browser) Start() { if path, ok := ctx.Value(internal.KeyPath).(string); ok && path != "" { b.Path = path } - b.GetModel().Watch(ctx) + + return ctx } // Stop terminates browser updates. @@ -275,20 +280,22 @@ func (b *Browser) editCmd(evt *tcell.EventKey) *tcell.EventKey { path := b.GetSelectedItem() if path == "" { return evt + + } + ns, n := client.Namespaced(path) + + if ok, err := b.app.Conn().CanI(ns, b.GVR(), []string{"edit"}); !ok || err != nil { + b.App().Flash().Err(fmt.Errorf("Current user can't edit resource %s", b.GVR())) + return nil } b.Stop() defer b.Start() { - ns, n := client.Namespaced(path) args := make([]string, 0, 10) args = append(args, "edit") args = append(args, b.meta.SingularName) args = append(args, "-n", ns) - args = append(args, "--context", b.app.Config.K9s.CurrentContext) - if cfg := b.app.Conn().Config().Flags().KubeConfig; cfg != nil && *cfg != "" { - args = append(args, "--kubeconfig", *cfg) - } if !runK(b.app, shellOpts{clear: true, args: append(args, n)}) { b.app.Flash().Err(errors.New("Edit exec failed")) } diff --git a/internal/view/cluster_info.go b/internal/view/cluster_info.go index dc87cfa5..df75851e 100644 --- a/internal/view/cluster_info.go +++ b/internal/view/cluster_info.go @@ -67,7 +67,7 @@ func (c *ClusterInfo) sectionCell(t string) *tview.TableCell { func (c *ClusterInfo) infoCell(t string) *tview.TableCell { cell := tview.NewTableCell(t) cell.SetExpansion(2) - cell.SetTextColor(config.AsColor(c.styles.K9s.Info.FgColor)) + cell.SetTextColor(c.styles.K9s.Info.FgColor.Color()) cell.SetBackgroundColor(c.app.Styles.BgColor()) return cell @@ -119,9 +119,9 @@ func (c *ClusterInfo) ClusterInfoChanged(prev, curr model.ClusterMeta) { func (c *ClusterInfo) updateStyle() { for row := 0; row < c.GetRowCount(); row++ { - c.GetCell(row, 0).SetTextColor(config.AsColor(c.styles.K9s.Info.FgColor)) + c.GetCell(row, 0).SetTextColor(c.styles.K9s.Info.FgColor.Color()) c.GetCell(row, 0).SetBackgroundColor(c.styles.BgColor()) var s tcell.Style - c.GetCell(row, 1).SetStyle(s.Bold(true).Foreground(config.AsColor(c.styles.K9s.Info.SectionColor))) + c.GetCell(row, 1).SetStyle(s.Bold(true).Foreground(c.styles.K9s.Info.SectionColor.Color())) } } diff --git a/internal/view/container.go b/internal/view/container.go index e128ccac..5d126494 100644 --- a/internal/view/container.go +++ b/internal/view/container.go @@ -57,7 +57,7 @@ func (c *Container) bindKeys(aa ui.KeyActions) { ui.KeyShiftX: ui.NewKeyAction("Sort %CPU (REQ)", c.GetTable().SortColCmd(8, false), false), ui.KeyShiftZ: ui.NewKeyAction("Sort %MEM (REQ)", c.GetTable().SortColCmd(9, false), false), tcell.KeyCtrlX: ui.NewKeyAction("Sort %CPU (LIM)", c.GetTable().SortColCmd(8, false), false), - tcell.KeyCtrlZ: ui.NewKeyAction("Sort %MEM (LIM)", c.GetTable().SortColCmd(9, false), false), + tcell.KeyCtrlQ: ui.NewKeyAction("Sort %MEM (LIM)", c.GetTable().SortColCmd(9, false), false), }) } diff --git a/internal/view/container_test.go b/internal/view/container_test.go index 7e8497fa..732c35e9 100644 --- a/internal/view/container_test.go +++ b/internal/view/container_test.go @@ -13,5 +13,5 @@ func TestContainerNew(t *testing.T) { assert.Nil(t, c.Init(makeCtx())) assert.Equal(t, "Containers", c.Name()) - assert.Equal(t, 13, len(c.Hints())) + assert.Equal(t, 15, len(c.Hints())) } diff --git a/internal/view/context_test.go b/internal/view/context_test.go index ebefd197..dc0f693e 100644 --- a/internal/view/context_test.go +++ b/internal/view/context_test.go @@ -13,5 +13,5 @@ func TestContext(t *testing.T) { assert.Nil(t, ctx.Init(makeCtx())) assert.Equal(t, "Contexts", ctx.Name()) - assert.Equal(t, 2, len(ctx.Hints())) + assert.Equal(t, 4, len(ctx.Hints())) } diff --git a/internal/view/details.go b/internal/view/details.go index 66d64624..a468fa1b 100644 --- a/internal/view/details.go +++ b/internal/view/details.go @@ -161,7 +161,7 @@ func (d *Details) filterInput(r rune) bool { func (d *Details) StylesChanged(s *config.Styles) { d.SetBackgroundColor(d.app.Styles.BgColor()) d.SetTextColor(d.app.Styles.FgColor()) - d.SetBorderFocusColor(config.AsColor(d.app.Styles.Frame().Border.FocusColor)) + d.SetBorderFocusColor(d.app.Styles.Frame().Border.FocusColor.Color()) d.TextChanged(d.model.Peek()) } diff --git a/internal/view/dp.go b/internal/view/dp.go index d88464a8..aa92c575 100644 --- a/internal/view/dp.go +++ b/internal/view/dp.go @@ -40,7 +40,7 @@ func (d *Deploy) bindKeys(aa ui.KeyActions) { aa.Add(ui.KeyActions{ ui.KeyShiftR: ui.NewKeyAction("Sort Ready", d.GetTable().SortColCmd(1, true), false), ui.KeyShiftU: ui.NewKeyAction("Sort UpToDate", d.GetTable().SortColCmd(2, true), false), - ui.KeyShiftV: ui.NewKeyAction("Sort Available", d.GetTable().SortColCmd(3, true), false), + ui.KeyShiftL: ui.NewKeyAction("Sort Available", d.GetTable().SortColCmd(3, true), false), }) } diff --git a/internal/view/dp_test.go b/internal/view/dp_test.go index 18e94546..d69fa571 100644 --- a/internal/view/dp_test.go +++ b/internal/view/dp_test.go @@ -13,5 +13,5 @@ func TestDeploy(t *testing.T) { assert.Nil(t, v.Init(makeCtx())) assert.Equal(t, "Deployments", v.Name()) - assert.Equal(t, 11, len(v.Hints())) + assert.Equal(t, 12, len(v.Hints())) } diff --git a/internal/view/ds.go b/internal/view/ds.go index 77c24414..d9f317d3 100644 --- a/internal/view/ds.go +++ b/internal/view/ds.go @@ -34,7 +34,7 @@ func (d *DaemonSet) bindKeys(aa ui.KeyActions) { ui.KeyShiftC: ui.NewKeyAction("Sort Current", d.GetTable().SortColCmd(2, true), false), ui.KeyShiftR: ui.NewKeyAction("Sort Ready", d.GetTable().SortColCmd(3, true), false), ui.KeyShiftU: ui.NewKeyAction("Sort UpToDate", d.GetTable().SortColCmd(4, true), false), - ui.KeyShiftV: ui.NewKeyAction("Sort Available", d.GetTable().SortColCmd(5, true), false), + ui.KeyShiftL: ui.NewKeyAction("Sort Available", d.GetTable().SortColCmd(5, true), false), }) } diff --git a/internal/view/ds_test.go b/internal/view/ds_test.go index 34cfe258..43d45f8b 100644 --- a/internal/view/ds_test.go +++ b/internal/view/ds_test.go @@ -13,5 +13,5 @@ func TestDaemonSet(t *testing.T) { assert.Nil(t, v.Init(makeCtx())) assert.Equal(t, "DaemonSets", v.Name()) - assert.Equal(t, 12, len(v.Hints())) + assert.Equal(t, 13, len(v.Hints())) } diff --git a/internal/view/env.go b/internal/view/env.go index 7ddcb4ba..a2bb9f75 100644 --- a/internal/view/env.go +++ b/internal/view/env.go @@ -13,7 +13,7 @@ import ( type K9sEnv map[string]string // EnvRX match $XXX custom arg. -var envRX = regexp.MustCompile(`\$([\w]+)(\d*)`) +var envRX = regexp.MustCompile(`\$(\!?[\w]+)(\d*)`) func (e K9sEnv) envFor(ns, args string) (string, error) { envs := envRX.FindStringSubmatch(args) @@ -41,10 +41,23 @@ func (e K9sEnv) envFor(ns, args string) (string, error) { } func (e K9sEnv) subOut(args, q string) (string, error) { + var reverse bool + if q[0] == '!' { + reverse = true + q = q[1:] + } env, ok := e[strings.ToUpper(q)] if !ok { return "", fmt.Errorf("no env vars exists for argument %q using key %q", args, q) } + if b, err := strconv.ParseBool(env); err == nil { + if reverse { + env = fmt.Sprintf("%t", !b) + } else { + env = fmt.Sprintf("%t", b) + } + } + return envRX.ReplaceAllString(args, env), nil } diff --git a/internal/view/event.go b/internal/view/event.go index 58ffa82b..a0f6f8f5 100644 --- a/internal/view/event.go +++ b/internal/view/event.go @@ -19,10 +19,17 @@ func NewEvent(gvr client.GVR) ResourceViewer { } e.GetTable().SetColorerFn(render.Event{}.ColorerFunc()) e.SetBindKeysFn(e.bindKeys) + e.GetTable().SetSortCol(7, 0, true) return &e } func (e *Event) bindKeys(aa ui.KeyActions) { aa.Delete(tcell.KeyCtrlD, ui.KeyE) + aa.Add(ui.KeyActions{ + ui.KeyShiftY: ui.NewKeyAction("Sort Type", e.GetTable().SortColCmd(1, true), false), + ui.KeyShiftR: ui.NewKeyAction("Sort Reason", e.GetTable().SortColCmd(2, true), false), + ui.KeyShiftE: ui.NewKeyAction("Sort Source", e.GetTable().SortColCmd(3, true), false), + ui.KeyShiftC: ui.NewKeyAction("Sort Count", e.GetTable().SortColCmd(4, true), false), + }) } diff --git a/internal/view/exec.go b/internal/view/exec.go index ff905ca7..e302231a 100644 --- a/internal/view/exec.go +++ b/internal/view/exec.go @@ -25,29 +25,44 @@ type shellOpts struct { args []string } -func runK(app *App, opts shellOpts) bool { +func runK(a *App, opts shellOpts) bool { bin, err := exec.LookPath("kubectl") if err != nil { log.Error().Msgf("Unable to find kubectl command in path %v", err) return false } + var args []string + if u, err := a.Conn().Config().CurrentUserName(); err == nil { + args = append(args, "--as", u) + } + if g, err := a.Conn().Config().CurrentGroupNames(); err == nil { + args = append(args, "--as-group", strings.Join(g, ",")) + } + args = append(args, "--context", a.Config.K9s.CurrentContext) + if cfg := a.Conn().Config().Flags().KubeConfig; cfg != nil && *cfg != "" { + args = append(args, "--kubeconfig", *cfg) + } + if len(args) > 0 { + opts.args = append(opts.args, args...) + } + opts.binary, opts.background = bin, false - return run(app, opts) + return run(a, opts) } -func run(app *App, opts shellOpts) bool { - app.Halt() - defer app.Resume() +func run(a *App, opts shellOpts) bool { + a.Halt() + defer a.Resume() - return app.Suspend(func() { + return a.Suspend(func() { if err := execute(opts); err != nil { - app.Flash().Errf("Command exited: %v", err) + a.Flash().Errf("Command exited: %v", err) } }) } -func edit(app *App, opts shellOpts) bool { +func edit(a *App, opts shellOpts) bool { bin, err := exec.LookPath(os.Getenv("EDITOR")) if err != nil { log.Error().Msgf("Unable to find editor command in path %v", err) @@ -55,7 +70,7 @@ func edit(app *App, opts shellOpts) bool { } opts.binary, opts.background = bin, false - return run(app, opts) + return run(a, opts) } func execute(opts shellOpts) error { @@ -85,7 +100,6 @@ func execute(opts shellOpts) error { err = cmd.Start() } else { cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr - _, _ = cmd.Stdout.Write([]byte(opts.banner)) err = cmd.Run() } diff --git a/internal/view/help.go b/internal/view/help.go index 0758d4ca..959f4580 100644 --- a/internal/view/help.go +++ b/internal/view/help.go @@ -235,7 +235,7 @@ func (h *Help) showGeneral() model.MenuHints { Description: "Clear command", }, { - Mnemonic: "t", + Mnemonic: "Ctrl-e", Description: "Toggle Header", }, { diff --git a/internal/view/help_test.go b/internal/view/help_test.go index 443633c3..5f538ad2 100644 --- a/internal/view/help_test.go +++ b/internal/view/help_test.go @@ -21,7 +21,7 @@ func TestHelp(t *testing.T) { v := view.NewHelp() assert.Nil(t, v.Init(ctx)) - assert.Equal(t, 20, v.GetRowCount()) + assert.Equal(t, 22, v.GetRowCount()) assert.Equal(t, 8, v.GetColumnCount()) assert.Equal(t, "", strings.TrimSpace(v.GetCell(1, 0).Text)) assert.Equal(t, "Kill", strings.TrimSpace(v.GetCell(1, 1).Text)) diff --git a/internal/view/log.go b/internal/view/log.go index 7e0e715b..701f8502 100644 --- a/internal/view/log.go +++ b/internal/view/log.go @@ -76,7 +76,7 @@ func (l *Log) Init(ctx context.Context) (err error) { l.logs.SetWrap(false) l.logs.SetMaxBuffer(l.app.Config.K9s.LogBufferSize) - l.ansiWriter = tview.ANSIWriter(l.logs, l.app.Styles.Views().Log.FgColor, l.app.Styles.Views().Log.BgColor) + l.ansiWriter = tview.ANSIWriter(l.logs, l.app.Styles.Views().Log.FgColor.String(), l.app.Styles.Views().Log.BgColor.String()) l.AddItem(l.logs, 0, 1, true) l.bindKeys() l.logs.SetInputCapture(l.keyboard) @@ -127,9 +127,9 @@ func (l *Log) BufferActive(state bool, k ui.BufferKind) { // StylesChanged reports skin changes. func (l *Log) StylesChanged(s *config.Styles) { - l.SetBackgroundColor(config.AsColor(s.Views().Log.BgColor)) - l.logs.SetTextColor(config.AsColor(s.Views().Log.FgColor)) - l.logs.SetBackgroundColor(config.AsColor(s.Views().Log.BgColor)) + l.SetBackgroundColor(s.Views().Log.BgColor.Color()) + l.logs.SetTextColor(s.Views().Log.FgColor.Color()) + l.logs.SetBackgroundColor(s.Views().Log.BgColor.Color()) } // GetModel returns the log model. diff --git a/internal/view/log_indicator.go b/internal/view/log_indicator.go index 11634ad9..31a20ec6 100644 --- a/internal/view/log_indicator.go +++ b/internal/view/log_indicator.go @@ -26,7 +26,7 @@ func NewLogIndicator(cfg *config.Config, styles *config.Styles) *LogIndicator { scrollStatus: 1, fullScreen: cfg.K9s.FullScreenLogs, } - l.SetBackgroundColor(config.AsColor(styles.Views().Log.BgColor)) + l.SetBackgroundColor(styles.Views().Log.BgColor.Color()) l.SetTextAlign(tview.AlignRight) l.SetDynamicColors(true) diff --git a/internal/view/ns.go b/internal/view/ns.go index adf567c0..1ad2a585 100644 --- a/internal/view/ns.go +++ b/internal/view/ns.go @@ -1,6 +1,8 @@ package view import ( + "time" + "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/render" @@ -76,12 +78,13 @@ func (n *Namespace) decorate(data render.TableData) render.TableData { // checks if all ns is in the list if not add it. if _, ok := data.RowEvents.FindIndex(client.NamespaceAll); !ok { + log.Debug().Msg("YO!!") data.RowEvents = append(data.RowEvents, render.RowEvent{ Kind: render.EventUnchanged, Row: render.Row{ ID: client.NamespaceAll, - Fields: render.Fields{client.NamespaceAll, "Active", "0"}, + Fields: render.Fields{client.NamespaceAll, "Active", "", "", time.Now().String()}, }, }, ) diff --git a/internal/view/ns_test.go b/internal/view/ns_test.go index 6a98ac0a..a78b829a 100644 --- a/internal/view/ns_test.go +++ b/internal/view/ns_test.go @@ -13,5 +13,5 @@ func TestNSCleanser(t *testing.T) { assert.Nil(t, ns.Init(makeCtx())) assert.Equal(t, "Namespaces", ns.Name()) - assert.Equal(t, 4, len(ns.Hints())) + assert.Equal(t, 6, len(ns.Hints())) } diff --git a/internal/view/ofaas.go b/internal/view/ofaas.go index 00c93a2c..f2fa81a8 100644 --- a/internal/view/ofaas.go +++ b/internal/view/ofaas.go @@ -28,7 +28,7 @@ func (o *OpenFaas) bindKeys(aa ui.KeyActions) { ui.KeyShiftS: ui.NewKeyAction("Sort Status", o.GetTable().SortColCmd(2, true), false), ui.KeyShiftI: ui.NewKeyAction("Sort Invocations", o.GetTable().SortColCmd(4, false), false), ui.KeyShiftR: ui.NewKeyAction("Sort Replicas", o.GetTable().SortColCmd(5, false), false), - ui.KeyShiftV: ui.NewKeyAction("Sort Available", o.GetTable().SortColCmd(6, false), false), + ui.KeyShiftL: ui.NewKeyAction("Sort Available", o.GetTable().SortColCmd(6, false), false), }) } diff --git a/internal/view/pf_dialog.go b/internal/view/pf_dialog.go index 6b7e3b15..a7b0e4b8 100644 --- a/internal/view/pf_dialog.go +++ b/internal/view/pf_dialog.go @@ -5,7 +5,6 @@ import ( "strings" "github.com/derailed/k9s/internal/client" - "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/ui" "github.com/derailed/tview" ) @@ -24,23 +23,13 @@ func ShowPortForwards(v ResourceViewer, path string, ports []string, okFn PortFo f.SetButtonsAlign(tview.AlignCenter). SetButtonBackgroundColor(styles.BgColor()). SetButtonTextColor(styles.FgColor()). - SetLabelColor(config.AsColor(styles.K9s.Info.FgColor)). - SetFieldTextColor(config.AsColor(styles.K9s.Info.SectionColor)) + SetLabelColor(styles.K9s.Info.FgColor.Color()). + SetFieldTextColor(styles.K9s.Info.SectionColor.Color()) - p1, p2, address := ports[0], ports[0], "localhost" - f.AddDropDown("Container Ports", ports, 0, func(sel string, _ int) { - p1, p2 = sel, extractPort(sel) + p1, p2, address := ports[0], extractPort(ports[0]), "localhost" + f.AddInputField("Container Port:", p1, 20, nil, func(p string) { + p1 = p }) - - dropD, ok := f.GetFormItem(0).(*tview.DropDown) - if ok { - dropD.SetFieldBackgroundColor(styles.BgColor()) - list := dropD.GetList() - list.SetMainTextColor(styles.FgColor()) - list.SetSelectedTextColor(styles.FgColor()) - list.SetSelectedBackgroundColor(config.AsColor(styles.Table().CursorColor)) - list.SetBackgroundColor(styles.BgColor() + 100) - } f.AddInputField("Local Port:", p2, 20, nil, func(p string) { p2 = p }) @@ -63,6 +52,7 @@ func ShowPortForwards(v ResourceViewer, path string, ports []string, okFn PortFo }) modal := tview.NewModalForm(fmt.Sprintf("", path), f) + modal.SetText("Exposed Ports: " + strings.Join(ports, ",")) modal.SetDoneFunc(func(_ int, b string) { DismissPortForwards(pages) }) diff --git a/internal/view/pf_dialog_test.go b/internal/view/pf_dialog_test.go index f4154494..6b654c55 100644 --- a/internal/view/pf_dialog_test.go +++ b/internal/view/pf_dialog_test.go @@ -11,6 +11,9 @@ func TestExtractPort(t *testing.T) { port, e string }{ "full": { + "co/fred:8000", "8000", + }, + "named": { "fred:8000", "8000", }, "port": { @@ -28,3 +31,26 @@ func TestExtractPort(t *testing.T) { }) } } + +func TestExtractContainer(t *testing.T) { + uu := map[string]struct { + port, e string + }{ + "full": { + "co/port:8000", "co", + }, + "unamed": { + "co/:8000", "co", + }, + "protocol": { + "co/dns:53╱UDP", "co", + }, + } + + for k := range uu { + u := uu[k] + t.Run(k, func(t *testing.T) { + assert.Equal(t, u.e, extractContainer(u.port)) + }) + } +} diff --git a/internal/view/pod.go b/internal/view/pod.go index dd90fcaf..0ba9937a 100644 --- a/internal/view/pod.go +++ b/internal/view/pod.go @@ -53,14 +53,14 @@ func (p *Pod) bindKeys(aa ui.KeyActions) { aa.Add(ui.KeyActions{ ui.KeyShiftR: ui.NewKeyAction("Sort Ready", p.GetTable().SortColCmd(1, true), false), - ui.KeyShiftS: ui.NewKeyAction("Sort Status", p.GetTable().SortColCmd(2, true), false), - ui.KeyShiftT: ui.NewKeyAction("Sort Restart", p.GetTable().SortColCmd(3, false), false), + ui.KeyShiftT: ui.NewKeyAction("Sort Restart", p.GetTable().SortColCmd(2, false), false), + ui.KeyShiftS: ui.NewKeyAction("Sort Status", p.GetTable().SortColCmd(3, true), false), ui.KeyShiftC: ui.NewKeyAction("Sort CPU", p.GetTable().SortColCmd(4, false), false), ui.KeyShiftM: ui.NewKeyAction("Sort MEM", p.GetTable().SortColCmd(5, false), false), ui.KeyShiftX: ui.NewKeyAction("Sort %CPU (REQ)", p.GetTable().SortColCmd(6, false), false), ui.KeyShiftZ: ui.NewKeyAction("Sort %MEM (REQ)", p.GetTable().SortColCmd(7, false), false), tcell.KeyCtrlX: ui.NewKeyAction("Sort %CPU (LIM)", p.GetTable().SortColCmd(8, false), false), - tcell.KeyCtrlZ: ui.NewKeyAction("Sort %MEM (LIM)", p.GetTable().SortColCmd(9, false), false), + tcell.KeyCtrlQ: ui.NewKeyAction("Sort %MEM (LIM)", p.GetTable().SortColCmd(9, false), false), ui.KeyShiftI: ui.NewKeyAction("Sort IP", p.GetTable().SortColCmd(10, true), false), ui.KeyShiftO: ui.NewKeyAction("Sort Node", p.GetTable().SortColCmd(11, true), false), }) diff --git a/internal/view/pod_test.go b/internal/view/pod_test.go index 8588155c..dd5c43bb 100644 --- a/internal/view/pod_test.go +++ b/internal/view/pod_test.go @@ -16,7 +16,7 @@ func TestPodNew(t *testing.T) { assert.Nil(t, po.Init(makeCtx())) assert.Equal(t, "Pods", po.Name()) - assert.Equal(t, 19, len(po.Hints())) + assert.Equal(t, 21, len(po.Hints())) } // Helpers... diff --git a/internal/view/port_forward.go b/internal/view/port_forward.go index 3901ef75..89ecc88a 100644 --- a/internal/view/port_forward.go +++ b/internal/view/port_forward.go @@ -8,6 +8,7 @@ import ( "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/dao" + "github.com/derailed/k9s/internal/model" "github.com/derailed/k9s/internal/perf" "github.com/derailed/k9s/internal/render" "github.com/derailed/k9s/internal/ui" @@ -64,7 +65,7 @@ func (p *PortForward) showBenchCmd(evt *tcell.EventKey) *tcell.EventKey { func (p *PortForward) toggleBenchCmd(evt *tcell.EventKey) *tcell.EventKey { if p.bench != nil { - p.App().Status(ui.FlashErr, "Benchmark Canceled!") + p.App().Status(model.FlashErr, "Benchmark Canceled!") p.bench.Cancel() p.App().ClearStatus(true) return nil @@ -87,7 +88,7 @@ func (p *PortForward) toggleBenchCmd(evt *tcell.EventKey) *tcell.EventKey { return nil } - p.App().Status(ui.FlashWarn, "Benchmark in progress...") + p.App().Status(model.FlashWarn, "Benchmark in progress...") go p.runBenchmark() return nil @@ -100,9 +101,9 @@ func (p *PortForward) runBenchmark() { log.Debug().Msg("Bench Completed!") p.App().QueueUpdate(func() { if p.bench.Canceled() { - p.App().Status(ui.FlashInfo, "Benchmark canceled") + p.App().Status(model.FlashInfo, "Benchmark canceled") } else { - p.App().Status(ui.FlashInfo, "Benchmark Completed!") + p.App().Status(model.FlashInfo, "Benchmark Completed!") p.bench.Cancel() } p.bench = nil diff --git a/internal/view/port_forward_test.go b/internal/view/port_forward_test.go index fabe07b8..eb21f9f4 100644 --- a/internal/view/port_forward_test.go +++ b/internal/view/port_forward_test.go @@ -13,5 +13,5 @@ func TestPortForwardNew(t *testing.T) { assert.Nil(t, pf.Init(makeCtx())) assert.Equal(t, "PortForwards", pf.Name()) - assert.Equal(t, 7, len(pf.Hints())) + assert.Equal(t, 9, len(pf.Hints())) } diff --git a/internal/view/pulse.go b/internal/view/pulse.go new file mode 100644 index 00000000..fcebef6a --- /dev/null +++ b/internal/view/pulse.go @@ -0,0 +1,362 @@ +package view + +import ( + "context" + "fmt" + "image" + "strings" + + "github.com/derailed/k9s/internal" + "github.com/derailed/k9s/internal/client" + "github.com/derailed/k9s/internal/config" + "github.com/derailed/k9s/internal/health" + "github.com/derailed/k9s/internal/model" + "github.com/derailed/k9s/internal/tchart" + "github.com/derailed/k9s/internal/ui" + "github.com/derailed/tview" + "github.com/gdamore/tcell" + "github.com/rs/zerolog/log" +) + +// Grapheable represents a graphic component. +type Grapheable interface { + tview.Primitive + + // ID returns the graph id. + ID() string + + // Add adds a metric + Add(tchart.Metric) + + // SetLegend sets the graph legend + SetLegend(string) + + // SetSeriesColors sets charts series colors. + SetSeriesColors(...tcell.Color) + + // GetSeriesColorNames returns the series color names. + GetSeriesColorNames() []string + + // SetBackgroundColor sets chart bg color. + SetBackgroundColor(tcell.Color) + + // IsDial returns true if chart is a dial + IsDial() bool +} + +const pulseTitle = "Pulses" + +var _ ResourceViewer = (*Pulse)(nil) + +// Pulse represents a command health view. +type Pulse struct { + *tview.Grid + + app *App + gvr client.GVR + model *model.Pulse + cancelFn context.CancelFunc + actions ui.KeyActions + charts []Grapheable +} + +// NewPulse returns a new alias view. +func NewPulse(gvr client.GVR) ResourceViewer { + return &Pulse{ + Grid: tview.NewGrid(), + model: model.NewPulse(gvr.String()), + actions: make(ui.KeyActions), + } +} + +// Init initializes the view. +func (p *Pulse) Init(ctx context.Context) error { + p.SetBorder(true) + p.SetTitle(fmt.Sprintf(" %s ", pulseTitle)) + p.SetGap(1, 1) + p.SetBorderPadding(0, 0, 1, 1) + var err error + if p.app, err = extractApp(ctx); err != nil { + return err + } + + p.charts = []Grapheable{ + p.makeGA(image.Point{X: 0, Y: 0}, image.Point{X: 4, Y: 2}, "apps/v1/deployments"), + p.makeGA(image.Point{X: 0, Y: 2}, image.Point{X: 4, Y: 2}, "apps/v1/replicasets"), + p.makeGA(image.Point{X: 0, Y: 4}, image.Point{X: 4, Y: 2}, "apps/v1/statefulsets"), + p.makeGA(image.Point{X: 0, Y: 6}, image.Point{X: 4, Y: 2}, "apps/v1/daemonsets"), + p.makeSP(image.Point{X: 4, Y: 0}, image.Point{X: 3, Y: 4}, "v1/pods"), + p.makeSP(image.Point{X: 4, Y: 4}, image.Point{X: 3, Y: 4}, "v1/events"), + p.makeSP(image.Point{X: 7, Y: 0}, image.Point{X: 3, Y: 4}, "batch/v1/jobs"), + p.makeSP(image.Point{X: 7, Y: 4}, image.Point{X: 3, Y: 4}, "v1/persistentvolumes"), + } + if p.app.Conn().HasMetrics() { + p.charts = append(p.charts, + p.makeSP(image.Point{X: 10, Y: 0}, image.Point{X: 2, Y: 4}, "cpu"), + p.makeSP(image.Point{X: 10, Y: 4}, image.Point{X: 2, Y: 4}, "mem"), + ) + } + p.bindKeys() + p.model.AddListener(p) + p.app.SetFocus(p.charts[0]) + p.app.Styles.AddListener(p) + + return nil +} + +// StylesChanged notifies the skin changed. +func (p *Pulse) StylesChanged(s *config.Styles) { + p.SetBackgroundColor(s.Charts().BgColor.Color()) + for _, c := range p.charts { + if c.IsDial() { + c.SetBackgroundColor(s.Charts().DialBgColor.Color()) + c.SetSeriesColors(s.Charts().DefaultDialColors.Colors()...) + } else { + c.SetBackgroundColor(s.Charts().ChartBgColor.Color()) + c.SetSeriesColors(s.Charts().DefaultChartColors.Colors()...) + } + if ss, ok := s.Charts().ResourceColors[c.ID()]; ok { + c.SetSeriesColors(ss.Colors()...) + } + } + p.app.Draw() +} + +// PulseChanged notifies the model data changed. +func (p *Pulse) PulseChanged(c *health.Check) { + index, ok := findIndexGVR(p.charts, c.GVR) + if !ok { + return + } + + v, ok := p.GetItem(index).Item.(Grapheable) + if !ok { + return + } + gvr := client.NewGVR(c.GVR) + switch c.GVR { + case "cpu": + v.SetLegend(fmt.Sprintf(" %s - %dm", strings.Title(gvr.R()), c.Tally(health.OK))) + case "mem": + v.SetLegend(fmt.Sprintf(" %s - %dMi", strings.Title(gvr.R()), c.Tally(health.OK))) + default: + nn := v.GetSeriesColorNames() + v.SetLegend(fmt.Sprintf(" %s(%d:[%s::]%d:[%s::b]%d[-::])", + strings.Title(gvr.R()), + c.Tally(health.Corpus), + nn[0], + c.Tally(health.OK), + nn[1], + c.Tally(health.Toast), + ), + ) + } + v.Add(tchart.Metric{OK: c.Tally(health.OK), Fault: c.Tally(health.Toast)}) +} + +// PulseLoadFailed notifies the load failed. +func (p *Pulse) PulseFailed(err error) { + p.app.Flash().Err(err) +} + +func (p *Pulse) bindKeys() { + p.actions.Add(ui.KeyActions{ + tcell.KeyEnter: ui.NewKeyAction("Goto", p.enterCmd, true), + tcell.KeyTab: ui.NewKeyAction("Next", p.nextFocusCmd(1), true), + tcell.KeyBacktab: ui.NewKeyAction("Prev", p.nextFocusCmd(-1), true), + }) + + for i, v := range p.charts { + t := strings.Title(client.NewGVR(v.(Grapheable).ID()).R()) + p.actions[tcell.Key(ui.NumKeys[i])] = ui.NewKeyAction(t, p.sparkFocusCmd(i), true) + } +} + +func (p *Pulse) keyboard(evt *tcell.EventKey) *tcell.EventKey { + log.Debug().Msgf("Pulse GOT EVENT %#v", evt) + key := evt.Key() + if key == tcell.KeyRune { + key = tcell.Key(evt.Rune()) + } + + if a, ok := p.actions[key]; ok { + return a.Action(evt) + } + + return evt +} + +func (p *Pulse) defaultContext() context.Context { + return context.WithValue(context.Background(), internal.KeyFactory, p.app.factory) +} + +// Start initializes resource watch loop. +func (p *Pulse) Start() { + p.Stop() + + ctx := p.defaultContext() + ctx, p.cancelFn = context.WithCancel(ctx) + p.model.Watch(ctx) +} + +// Stop terminates watch loop. +func (p *Pulse) Stop() { + if p.cancelFn == nil { + return + } + p.cancelFn() + p.cancelFn = nil +} + +// Refresh updates the view +func (p *Pulse) Refresh() { + // p.update(p.model.Peek()) +} + +// GVR returns a resource descriptor. +func (p *Pulse) GVR() string { + return p.gvr.String() +} + +// Name returns the component name. +func (p *Pulse) Name() string { + return pulseTitle +} + +// App returns the current app handle. +func (p *Pulse) App() *App { + return p.app +} + +// SetInstance sets specific resource instance. +func (p *Pulse) SetInstance(string) {} + +// SetEnvFn sets the custom environment function. +func (p *Pulse) SetEnvFn(EnvFunc) {} + +// SetBindKeysFn sets up extra key bindings. +func (p *Pulse) SetBindKeysFn(BindKeysFunc) {} + +// SetContextFn sets custom context. +func (p *Pulse) SetContextFn(ContextFunc) {} + +// GetTable return the view table if any. +func (p *Pulse) GetTable() *Table { + return nil +} + +// Actions returns active menu bindings. +func (p *Pulse) Actions() ui.KeyActions { + return p.actions +} + +// Hints returns the view hints. +func (p *Pulse) Hints() model.MenuHints { + return p.actions.Hints() +} + +// ExtraHints returns additional hints. +func (p *Pulse) ExtraHints() map[string]string { + return nil +} + +func (p *Pulse) sparkFocusCmd(i int) func(evt *tcell.EventKey) *tcell.EventKey { + return func(evt *tcell.EventKey) *tcell.EventKey { + p.app.SetFocus(p.charts[i]) + return nil + } +} + +func (p *Pulse) enterCmd(evt *tcell.EventKey) *tcell.EventKey { + v := p.App().GetFocus() + s, ok := v.(Grapheable) + if !ok { + return nil + } + log.Debug().Msgf("Selected %s", s.ID()) + gvr := client.NewGVR(s.ID()) + if err := p.App().gotoResource(gvr.R(), false); err != nil { + p.App().Flash().Err(err) + } + + return nil +} + +func (p *Pulse) nextFocusCmd(direction int) func(evt *tcell.EventKey) *tcell.EventKey { + return func(evt *tcell.EventKey) *tcell.EventKey { + v := p.app.GetFocus() + index := findIndex(p.charts, v) + p.GetItem(index).Focus = false + p.GetItem(index).Item.Blur() + i, v := nextFocus(p.charts, index+direction) + p.GetItem(i).Focus = true + p.app.SetFocus(v) + + return nil + } +} + +func (p *Pulse) makeSP(loc image.Point, span image.Point, gvr string) *tchart.SparkLine { + s := tchart.NewSparkLine(gvr) + s.SetBackgroundColor(p.app.Styles.Charts().BgColor.Color()) + s.SetBorderPadding(0, 1, 0, 1) + if cc, ok := p.app.Styles.Charts().ResourceColors[gvr]; ok { + s.SetSeriesColors(cc.Colors()...) + } else { + s.SetSeriesColors(p.app.Styles.Charts().DefaultChartColors.Colors()...) + } + s.SetLegend(fmt.Sprintf(" %s ", strings.Title(client.NewGVR(gvr).R()))) + s.SetInputCapture(p.keyboard) + p.AddItem(s, loc.X, loc.Y, span.X, span.Y, 0, 0, true) + + return s +} + +func (p *Pulse) makeGA(loc image.Point, span image.Point, gvr string) *tchart.Gauge { + g := tchart.NewGauge(gvr) + g.SetBackgroundColor(p.app.Styles.Charts().BgColor.Color()) + g.SetBorderPadding(0, 1, 0, 1) + if cc, ok := p.app.Styles.Charts().ResourceColors[gvr]; ok { + g.SetSeriesColors(cc.Colors()...) + } else { + g.SetSeriesColors(p.app.Styles.Charts().DefaultDialColors.Colors()...) + } + g.SetLegend(fmt.Sprintf(" %s ", strings.Title(client.NewGVR(gvr).R()))) + g.SetInputCapture(p.keyboard) + p.AddItem(g, loc.X, loc.Y, span.X, span.Y, 0, 0, true) + + return g +} + +// ---------------------------------------------------------------------------- +// Helpers + +func nextFocus(pp []Grapheable, index int) (int, tview.Primitive) { + if index >= len(pp) { + return 0, pp[0] + } + + if index < 0 { + return len(pp) - 1, pp[len(pp)-1] + } + + return index, pp[index] +} + +func findIndex(pp []Grapheable, p tview.Primitive) int { + for i, v := range pp { + if v == p { + return i + } + } + return 0 +} + +func findIndexGVR(pp []Grapheable, gvr string) (int, bool) { + for i, v := range pp { + if v.(Grapheable).ID() == gvr { + return i, true + } + } + return 0, false +} diff --git a/internal/view/rbac_test.go b/internal/view/rbac_test.go index 63cfad8b..2c0972c8 100644 --- a/internal/view/rbac_test.go +++ b/internal/view/rbac_test.go @@ -13,5 +13,5 @@ func TestRbacNew(t *testing.T) { assert.Nil(t, v.Init(makeCtx())) assert.Equal(t, "Rbac", v.Name()) - assert.Equal(t, 3, len(v.Hints())) + assert.Equal(t, 5, len(v.Hints())) } diff --git a/internal/view/registrar.go b/internal/view/registrar.go index 9631348d..417f79e8 100644 --- a/internal/view/registrar.go +++ b/internal/view/registrar.go @@ -69,6 +69,9 @@ func miscViewers(vv MetaViewers) { vv[client.NewGVR("aliases")] = MetaViewer{ viewerFn: NewAlias, } + vv[client.NewGVR("pulses")] = MetaViewer{ + viewerFn: NewPulse, + } } func appsViewers(vv MetaViewers) { diff --git a/internal/view/screen_dump_test.go b/internal/view/screen_dump_test.go index 59b1756f..0e191f1b 100644 --- a/internal/view/screen_dump_test.go +++ b/internal/view/screen_dump_test.go @@ -13,5 +13,5 @@ func TestScreenDumpNew(t *testing.T) { assert.Nil(t, po.Init(makeCtx())) assert.Equal(t, "ScreenDumps", po.Name()) - assert.Equal(t, 3, len(po.Hints())) + assert.Equal(t, 5, len(po.Hints())) } diff --git a/internal/view/secret_test.go b/internal/view/secret_test.go index 8823ab3c..d75cd10f 100644 --- a/internal/view/secret_test.go +++ b/internal/view/secret_test.go @@ -13,5 +13,5 @@ func TestSecretNew(t *testing.T) { assert.Nil(t, s.Init(makeCtx())) assert.Equal(t, "Secrets", s.Name()) - assert.Equal(t, 4, len(s.Hints())) + assert.Equal(t, 6, len(s.Hints())) } diff --git a/internal/view/sts_test.go b/internal/view/sts_test.go index 57deed3f..30a5de22 100644 --- a/internal/view/sts_test.go +++ b/internal/view/sts_test.go @@ -13,5 +13,5 @@ func TestStatefulSetNew(t *testing.T) { assert.Nil(t, s.Init(makeCtx())) assert.Equal(t, "StatefulSets", s.Name()) - assert.Equal(t, 9, len(s.Hints())) + assert.Equal(t, 11, len(s.Hints())) } diff --git a/internal/view/svc.go b/internal/view/svc.go index 51711e63..30f95a90 100644 --- a/internal/view/svc.go +++ b/internal/view/svc.go @@ -9,6 +9,7 @@ import ( "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/dao" + "github.com/derailed/k9s/internal/model" "github.com/derailed/k9s/internal/perf" "github.com/derailed/k9s/internal/ui" "github.com/gdamore/tcell" @@ -85,7 +86,7 @@ func (s *Service) getExternalPort(row int) (string, error) { func (s *Service) toggleBenchCmd(evt *tcell.EventKey) *tcell.EventKey { if s.bench != nil { log.Debug().Msg(">>> Benchmark canceled!!") - s.App().Status(ui.FlashErr, "Benchmark Canceled!") + s.App().Status(model.FlashErr, "Benchmark Canceled!") s.bench.Cancel() s.App().ClearStatus(true) return nil @@ -140,7 +141,7 @@ func (s *Service) runBenchmark(port string, cfg config.BenchConfig) error { return err } - s.App().Status(ui.FlashWarn, "Benchmark in progress...") + s.App().Status(model.FlashWarn, "Benchmark in progress...") log.Debug().Msg("Bench starting...") go s.bench.Run(s.App().Config.K9s.CurrentCluster, s.benchDone) @@ -151,9 +152,9 @@ func (s *Service) benchDone() { log.Debug().Msg("Bench Completed!") s.App().QueueUpdate(func() { if s.bench.Canceled() { - s.App().Status(ui.FlashInfo, "Benchmark canceled") + s.App().Status(model.FlashInfo, "Benchmark canceled") } else { - s.App().Status(ui.FlashInfo, "Benchmark Completed!") + s.App().Status(model.FlashInfo, "Benchmark Completed!") s.bench.Cancel() } s.bench = nil diff --git a/internal/view/svc_test.go b/internal/view/svc_test.go index a9a5bd1c..9bf3756d 100644 --- a/internal/view/svc_test.go +++ b/internal/view/svc_test.go @@ -132,5 +132,5 @@ func TestServiceNew(t *testing.T) { assert.Nil(t, s.Init(makeCtx())) assert.Equal(t, "Services", s.Name()) - assert.Equal(t, 8, len(s.Hints())) + assert.Equal(t, 10, len(s.Hints())) } diff --git a/internal/view/table.go b/internal/view/table.go index faa62541..56641086 100644 --- a/internal/view/table.go +++ b/internal/view/table.go @@ -132,10 +132,24 @@ func (t *Table) bindKeys() { tcell.KeyBackspace: ui.NewSharedKeyAction("Erase", t.eraseCmd, false), tcell.KeyDelete: ui.NewSharedKeyAction("Erase", t.eraseCmd, false), ui.KeyShiftN: ui.NewKeyAction("Sort Name", t.SortColCmd(0, true), false), + tcell.KeyCtrlZ: ui.NewKeyAction("Toggle Faults", t.toggleFaultCmd, false), ui.KeyShiftA: ui.NewKeyAction("Sort Age", t.SortColCmd(-1, true), false), + tcell.KeyCtrlW: ui.NewKeyAction("Show Wide", t.toggleWideCmd, false), }) } +func (t *Table) toggleFaultCmd(evt *tcell.EventKey) *tcell.EventKey { + t.ToggleToast() + + return nil +} + +func (t *Table) toggleWideCmd(evt *tcell.EventKey) *tcell.EventKey { + t.ToggleWide() + + return nil +} + func (t *Table) cpCmd(evt *tcell.EventKey) *tcell.EventKey { path := t.GetSelectedItem() if path == "" { diff --git a/internal/view/table_int_test.go b/internal/view/table_int_test.go index f98700c4..336acadc 100644 --- a/internal/view/table_int_test.go +++ b/internal/view/table_int_test.go @@ -97,6 +97,7 @@ func (t *testTableModel) Peek() render.TableData { return makeTableData func (t *testTableModel) ClusterWide() bool { return false } func (t *testTableModel) GetNamespace() string { return "blee" } func (t *testTableModel) SetNamespace(string) {} +func (t *testTableModel) ToggleToast() {} func (t *testTableModel) AddListener(model.TableListener) {} func (t *testTableModel) Watch(context.Context) {} func (t *testTableModel) Get(context.Context, string) (runtime.Object, error) { diff --git a/internal/view/xray.go b/internal/view/xray.go index 2858233a..38a3c86d 100644 --- a/internal/view/xray.go +++ b/internal/view/xray.go @@ -25,6 +25,8 @@ import ( const xrayTitle = "Xray" +var _ ResourceViewer = (*Xray)(nil) + // Xray represents an xray tree view. type Xray struct { *ui.Tree @@ -37,8 +39,6 @@ type Xray struct { envFn EnvFunc } -var _ ResourceViewer = (*Xray)(nil) - // NewXray returns a new view. func NewXray(gvr client.GVR) ResourceViewer { return &Xray{ @@ -68,10 +68,10 @@ func (x *Xray) Init(ctx context.Context) error { } x.bindKeys() - x.SetBackgroundColor(config.AsColor(x.app.Styles.Xray().BgColor)) - x.SetBorderColor(config.AsColor(x.app.Styles.Xray().FgColor)) - x.SetBorderFocusColor(config.AsColor(x.app.Styles.Frame().Border.FocusColor)) - x.SetGraphicsColor(config.AsColor(x.app.Styles.Xray().GraphicColor)) + x.SetBackgroundColor(x.app.Styles.Xray().BgColor.Color()) + x.SetBorderColor(x.app.Styles.Xray().FgColor.Color()) + x.SetBorderFocusColor(x.app.Styles.Frame().Border.FocusColor.Color()) + x.SetGraphicsColor(x.app.Styles.Xray().GraphicColor.Color()) x.SetTitle(fmt.Sprintf(" %s-%s ", xrayTitle, strings.Title(x.gvr.R()))) x.model.SetRefreshRate(time.Duration(x.app.Config.K9s.GetRefreshRate()) * time.Second) @@ -479,7 +479,7 @@ func (x *Xray) TreeNodeSelected() { x.app.QueueUpdateDraw(func() { n := x.GetCurrentNode() if n != nil { - n.SetColor(config.AsColor(x.app.Styles.Xray().CursorColor)) + n.SetColor(x.app.Styles.Xray().CursorColor.Color()) } }) } @@ -548,7 +548,7 @@ func (x *Xray) hydrate(parent *tview.TreeNode, n *xray.TreeNode) { // SetEnvFn sets the custom environment function. func (x *Xray) SetEnvFn(EnvFunc) {} -// Refresh refresh the view +// Refresh updates the view func (x *Xray) Refresh() { } @@ -704,7 +704,7 @@ func makeTreeNode(node *xray.TreeNode, expanded bool, styles *config.Styles) *tv } n.SetSelectable(true) n.SetExpanded(expanded) - n.SetColor(config.AsColor(styles.Xray().CursorColor)) + n.SetColor(styles.Xray().CursorColor.Color()) n.SetSelectedFunc(func() { n.SetExpanded(!n.IsExpanded()) }) diff --git a/internal/view/yaml.go b/internal/view/yaml.go index 9ab389e9..356568dc 100644 --- a/internal/view/yaml.go +++ b/internal/view/yaml.go @@ -28,14 +28,14 @@ func colorizeYAML(style config.Yaml, raw string) string { // lines := strings.Split(raw, "\n") lines := strings.Split(tview.Escape(raw), "\n") - fullFmt := strings.Replace(yamlFullFmt, "[key", "["+style.KeyColor, 1) - fullFmt = strings.Replace(fullFmt, "[colon", "["+style.ColonColor, 1) - fullFmt = strings.Replace(fullFmt, "[val", "["+style.ValueColor, 1) + fullFmt := strings.Replace(yamlFullFmt, "[key", "["+style.KeyColor.String(), 1) + fullFmt = strings.Replace(fullFmt, "[colon", "["+style.ColonColor.String(), 1) + fullFmt = strings.Replace(fullFmt, "[val", "["+style.ValueColor.String(), 1) - keyFmt := strings.Replace(yamlKeyFmt, "[key", "["+style.KeyColor, 1) - keyFmt = strings.Replace(keyFmt, "[colon", "["+style.ColonColor, 1) + keyFmt := strings.Replace(yamlKeyFmt, "[key", "["+style.KeyColor.String(), 1) + keyFmt = strings.Replace(keyFmt, "[colon", "["+style.ColonColor.String(), 1) - valFmt := strings.Replace(yamlValueFmt, "[val", "["+style.ValueColor, 1) + valFmt := strings.Replace(yamlValueFmt, "[val", "["+style.ValueColor.String(), 1) buff := make([]string, 0, len(lines)) for _, l := range lines { diff --git a/plugins/job_suspend.yml b/plugins/job_suspend.yml index 3d4b4ae7..06f0de45 100644 --- a/plugins/job_suspend.yml +++ b/plugins/job_suspend.yml @@ -11,9 +11,9 @@ plugin: - patch - cronjobs - $NAME - - ns + - -n - $NAMESPACE - --context - $CONTEXT - -p - - '{"spec" : {"suspend" : $COL3 }}' + - '{"spec" : {"suspend" : $!COL3 }}'