checkpoint

mine
derailed 2020-02-19 18:25:36 -07:00
parent a76d5809b8
commit 355f4ce396
142 changed files with 3150 additions and 785 deletions

View File

@ -16,6 +16,7 @@ builds:
- 386
- amd64
- arm64
- armhf
goarm:
- 6
- 7

BIN
assets/k9s_health.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 731 KiB

4
go.mod
View File

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

4
go.sum
View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -21,17 +21,31 @@ type StyleListener interface {
}
type (
// Color represents a color.
Color string
// Colors tracks multiple colors.
Colors []Color
// Styles tracks K9s styling options.
Styles struct {
K9s Style `yaml:"k9s"`
listeners []StyleListener
}
// Style tracks K9s styles.
Style struct {
Body Body `yaml:"body"`
Frame Frame `yaml:"frame"`
Info Info `yaml:"info"`
Views Views `yaml:"views"`
}
// Body tracks body styles.
Body struct {
FgColor string `yaml:"fgColor"`
BgColor string `yaml:"bgColor"`
LogoColor string `yaml:"logoColor"`
FgColor Color `yaml:"fgColor"`
BgColor Color `yaml:"bgColor"`
LogoColor Color `yaml:"logoColor"`
}
// Frame tracks frame styles.
@ -45,120 +59,171 @@ type (
// Views tracks individual view styles.
Views struct {
Yaml Yaml `yaml:"yaml"`
Log Log `yaml:"logs"`
Table Table `yaml:"table"`
Xray Xray `yaml:"xray"`
Charts Charts `yaml:"charts"`
Yaml Yaml `yaml:"yaml"`
Log Log `yaml:"logs"`
}
// Status tracks resource status styles.
Status struct {
NewColor string `yaml:"newColor"`
ModifyColor string `yaml:"modifyColor"`
AddColor string `yaml:"addColor"`
ErrorColor string `yaml:"errorColor"`
HighlightColor string `yaml:"highlightColor"`
KillColor string `yaml:"killColor"`
CompletedColor string `yaml:"completedColor"`
NewColor Color `yaml:"newColor"`
ModifyColor Color `yaml:"modifyColor"`
AddColor Color `yaml:"addColor"`
ErrorColor Color `yaml:"errorColor"`
HighlightColor Color `yaml:"highlightColor"`
KillColor Color `yaml:"killColor"`
CompletedColor Color `yaml:"completedColor"`
}
// Log tracks Log styles.
Log struct {
FgColor string `yaml:"fgColor"`
BgColor string `yaml:"bgColor"`
FgColor Color `yaml:"fgColor"`
BgColor Color `yaml:"bgColor"`
}
// Yaml tracks yaml styles.
Yaml struct {
KeyColor string `yaml:"keyColor"`
ValueColor string `yaml:"valueColor"`
ColonColor string `yaml:"colonColor"`
KeyColor Color `yaml:"keyColor"`
ValueColor Color `yaml:"valueColor"`
ColonColor Color `yaml:"colonColor"`
}
// Title tracks title styles.
Title struct {
FgColor string `yaml:"fgColor"`
BgColor string `yaml:"bgColor"`
HighlightColor string `yaml:"highlightColor"`
CounterColor string `yaml:"counterColor"`
FilterColor string `yaml:"filterColor"`
FgColor Color `yaml:"fgColor"`
BgColor Color `yaml:"bgColor"`
HighlightColor Color `yaml:"highlightColor"`
CounterColor Color `yaml:"counterColor"`
FilterColor Color `yaml:"filterColor"`
}
// Info tracks info styles.
Info struct {
SectionColor string `yaml:"sectionColor"`
FgColor string `yaml:"fgColor"`
SectionColor Color `yaml:"sectionColor"`
FgColor Color `yaml:"fgColor"`
}
// Border tracks border styles.
// 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)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

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

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

View File

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

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

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

View File

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

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

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

View File

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

View File

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

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

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,7 @@
package render
import (
"errors"
"fmt"
"strconv"
"time"
@ -23,8 +24,12 @@ const (
type OpenFaas struct{}
// ColorerFunc colors a resource row.
func (OpenFaas) ColorerFunc() ColorerFunc {
func (o OpenFaas) ColorerFunc() ColorerFunc {
return func(ns string, re RowEvent) tcell.Color {
if !Happy(ns, re.Row) {
return ErrColor
}
return tcell.ColorPaleTurquoise
}
}
@ -44,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...

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -3,7 +3,6 @@ package render
import (
"fmt"
"strconv"
"strings"
"github.com/derailed/k9s/internal/client"
"github.com/derailed/tview"
@ -17,24 +16,19 @@ import (
type ReplicaSet struct{}
// ColorerFunc colors a resource row.
func (ReplicaSet) ColorerFunc() ColorerFunc {
return func(ns string, r RowEvent) tcell.Color {
c := DefaultColorer(ns, r)
if r.Kind == EventAdd || r.Kind == EventUpdate {
func (r ReplicaSet) ColorerFunc() ColorerFunc {
return func(ns string, re RowEvent) tcell.Color {
c := DefaultColorer(ns, re)
if re.Kind == EventAdd || re.Kind == EventUpdate {
return c
}
markCol := 2
if !client.IsAllNamespaces(ns) {
markCol--
}
if strings.TrimSpace(r.Row.Fields[markCol]) != strings.TrimSpace(r.Row.Fields[markCol+1]) {
if !Happy(ns, re.Row) {
return ErrColor
}
return StdColor
}
}
// Header returns a header row.
@ -49,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
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,128 @@
package tchart
import (
"image"
"sync"
"github.com/derailed/tview"
"github.com/gdamore/tcell"
"github.com/rs/zerolog/log"
)
const (
okColor, faultColor = tcell.ColorPaleGreen, tcell.ColorOrangeRed
okColorName, faultColorName = "palegreen", "orangered"
)
// Component represents a graphic component.
type Component struct {
*tview.Box
bgColor, noColor tcell.Color
seriesColors []tcell.Color
dimmed tcell.Style
id, legend string
blur func(tcell.Key)
mx sync.RWMutex
}
// NewComponent returns a new component.
func NewComponent(id string) *Component {
return &Component{
Box: tview.NewBox(),
id: id,
noColor: tcell.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},
}
}

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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