From 21ed0224342172257b956ddf26748f586ab03132 Mon Sep 17 00:00:00 2001 From: derailed Date: Fri, 24 May 2019 16:25:26 -0600 Subject: [PATCH] add ns checks, percentage + bugz fixes --- .goreleaser.yml | 77 +++++----- README.md | 2 +- change_logs/release_0.6.7.md | 39 ++++++ cmd/root.go | 9 ++ go.mod | 3 + go.sum | 4 + internal/config/mock_connection_test.go | 10 +- internal/config/style.go | 2 +- internal/k8s/api.go | 41 +++--- internal/resource/container.go | 40 ++---- internal/resource/helpers.go | 6 +- internal/resource/helpers_test.go | 20 +-- internal/resource/list.go | 1 + internal/resource/mock_clustermeta_test.go | 10 +- internal/resource/mock_connection_test.go | 10 +- internal/resource/no.go | 12 +- internal/resource/no_test.go | 2 +- internal/resource/ns.go | 15 -- internal/resource/pod.go | 60 ++++++-- internal/resource/pod_test.go | 2 +- internal/views/app.go | 6 +- internal/views/cluster_info.go | 10 +- internal/views/container.go | 15 +- internal/views/flash.go | 39 +++--- internal/views/helpers.go | 39 +++--- internal/views/helpers_test.go | 20 +-- internal/views/job.go | 2 +- internal/views/log.go | 155 ++++++++++++++++++--- internal/views/logs.go | 145 ++++--------------- internal/views/menu.go | 58 ++++++++ internal/views/mock_clustermeta.go | 10 +- internal/views/pod.go | 40 +++--- internal/views/registrar.go | 25 ++-- internal/views/resource.go | 11 +- internal/views/status.go | 38 +++++ internal/views/styles.go | 45 ++++++ internal/views/table.go | 59 +++++--- internal/watch/meta.go | 33 ++++- internal/watch/meta_test.go | 33 +++-- internal/watch/mock_connection_test.go | 10 +- internal/watch/no_mx.go | 5 - internal/watch/pod_mx.go | 6 - main.go | 5 - 43 files changed, 746 insertions(+), 428 deletions(-) create mode 100644 change_logs/release_0.6.7.md create mode 100644 internal/views/status.go create mode 100644 internal/views/styles.go diff --git a/.goreleaser.yml b/.goreleaser.yml index 9dc4fe50..86e508a5 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -9,17 +9,17 @@ builds: - env: - CGO_ENABLED=0 goos: - # - linux - # - darwin + - linux + - darwin - windows goarch: - # - 386 + - 386 - amd64 - # - arm - # - arm64 - # goarm: - # - 6 - # - 7 + - arm + - arm64 + goarm: + - 6 + - 7 ldflags: - -s -w -X github.com/derailed/k9s/cmd.version={{.Version}} -X github.com/derailed/k9s/cmd.commit={{.Commit}} -X github.com/derailed/k9s/cmd.date={{.Date}} archive: @@ -59,33 +59,34 @@ brew: system "k9s version" # Snapcraft -# snapcraft: -# name: k9s -# summary: K9s is a CLI to view and manage your Kubernetes clusters. -# description: | -# K9s is a CLI to view and manage your Kubernetes clusters. -# By leveraging a terminal UI, you can easily traverse Kubernetes resources -# and view the state of you clusters in a single powerful session. -# name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" -# publish: false -# # publish: true -# replacements: -# amd64: 64-bit -# 386: 32-bit -# darwin: macOS -# linux: Tux -# bit: Arm -# bitv6: Arm6 -# bitv7: Arm7 -# grade: devel -# confinement: devmode -# # grade: stable -# # confinement: strict -# apps: -# k9s: -# plugs: ["home", "network"] -# # plugs: ["home", "network", "personal-files"] -# plugs: -# personal-files: -# read: -# - $HOME/.kube \ No newline at end of file +snapcraft: + name: k9s + summary: K9s is a CLI to view and manage your Kubernetes clusters. + description: | + K9s is a CLI to view and manage your Kubernetes clusters. + By leveraging a terminal UI, you can easily traverse Kubernetes resources + and view the state of you clusters in a single powerful session. + name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" + # publish: false + publish: true + replacements: + amd64: 64-bit + 386: 32-bit + darwin: macOS + linux: Tux + bit: Arm + bitv6: Arm6 + bitv7: Arm7 + grade: devel + confinement: devmode + # grade: stable + # confinement: strict + apps: + k9s: + # plugs: ["home", "network"] + plugs: ["home", "network", "kube-config"] + plugs: + kube-config: + interface: personal-files + read: + - $HOME/.kube \ No newline at end of file diff --git a/README.md b/README.md index 9675c5d9..b424ed26 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ for changes and offers subsequent commands to interact with observed resources. [![Go Report Card](https://goreportcard.com/badge/github.com/derailed/k9s?)](https://goreportcard.com/report/github.com/derailed/k9s) [![Build Status](https://travis-ci.com/derailed/k9s.svg?branch=master)](https://travis-ci.com/derailed/k9s) [![release](https://img.shields.io/github/release-pre/derailed/k9s.svg)](https://github.com/derailed/k9s/releases) -[![k9s](https://snapcraft.io/k9s/badge.svg)](https://snapcraft.io/k9s) + [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://github.com/mum4k/termdash/blob/master/LICENSE) --- diff --git a/change_logs/release_0.6.7.md b/change_logs/release_0.6.7.md new file mode 100644 index 00000000..8ea5fd7b --- /dev/null +++ b/change_logs/release_0.6.7.md @@ -0,0 +1,39 @@ + + +# Release v0.6.7 + +## Notes + +Thank you to all that contributed with flushing out issues with K9s! I'll try to mark some of these issues as fixed. But if you don't mind grab the latest rev and see if we're happier with some of the fixes! + +If you've filed an issue please help me verify and close. + +Thank you so much for your support and awesome suggestions to make K9s better!! + +Also if you dig this tool, please make some noise on social! [@kitesurfer](https://twitter.com/kitesurfer) + +--- + +## Change Logs + +This is a maintenance release to mainly resolve outstanding issues and bugs. + +### Tracking Percentages + +Added two new columns to track cpu/memory utilization on metrics-server enabled clusters. These apply to pod,container and node views. + + +--- + +## Resolved Bugs + ++ [Issue #192](https://github.com/derailed/k9s/issues/192) ++ [Issue #190](https://github.com/derailed/k9s/issues/190) ++ [Issue #189](https://github.com/derailed/k9s/issues/189) ++ [Issue #185](https://github.com/derailed/k9s/issues/185) ++ [Issue #171](https://github.com/derailed/k9s/issues/171) ++ [Issue #155](https://github.com/derailed/k9s/issues/155) + +--- + + © 2019 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) diff --git a/cmd/root.go b/cmd/root.go index 2724545d..d758b72b 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -1,6 +1,7 @@ package cmd import ( + "flag" "fmt" "runtime/debug" @@ -12,6 +13,7 @@ import ( "github.com/rs/zerolog/log" "github.com/spf13/cobra" "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/klog" ) const ( @@ -41,6 +43,13 @@ func init() { rootCmd.AddCommand(versionCmd(), infoCmd()) initK9sFlags() initK8sFlags() + + // Klogs (of course) want to print stuff to the screen ;( + klog.InitFlags(nil) + flag.Set("log_file", config.K9sLogs) + flag.Set("stderrthreshold", "fatal") + flag.Set("alsologtostderr", "false") + flag.Set("logtostderr", "false") } // Execute root command diff --git a/go.mod b/go.mod index d3fa6846..479713da 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,9 @@ go 1.12 replace ( k8s.io/api => k8s.io/api v0.0.0-20190222213804-5cb15d344471 + k8s.io/apiextensions-apiserver => k8s.io/apiextensions-apiserver v0.0.0-20190325193600-475668423e9f k8s.io/apimachinery => k8s.io/apimachinery v0.0.0-20190221213512-86fb29eff628 + k8s.io/apiserver => k8s.io/apiserver v0.0.0-20190319190228-a4358799e4fe k8s.io/cli-runtime => k8s.io/cli-runtime v0.0.0-20190325194458-f2b4781c3ae1 k8s.io/client-go => k8s.io/client-go v10.0.0+incompatible k8s.io/metrics => k8s.io/metrics v0.0.0-20190325194013-29123f6a4aa6 @@ -26,6 +28,7 @@ require ( github.com/gregjones/httpcache v0.0.0-20190212212710-3befbb6ad0cc // indirect github.com/hashicorp/golang-lru v0.5.1 // indirect github.com/imdario/mergo v0.3.7 // indirect + github.com/inconshreveable/mousetrap v1.0.0 // indirect github.com/json-iterator/go v1.1.6 // indirect github.com/mattn/go-runewidth v0.0.4 github.com/onsi/ginkgo v1.8.0 // indirect diff --git a/go.sum b/go.sum index 0734971f..487630bf 100644 --- a/go.sum +++ b/go.sum @@ -362,10 +362,14 @@ honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWh honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= k8s.io/api v0.0.0-20190222213804-5cb15d344471 h1:MzQGt8qWQCR+39kbYRd0uQqsvSidpYqJLFeWiJ9l4OE= k8s.io/api v0.0.0-20190222213804-5cb15d344471/go.mod h1:iuAfoD4hCxJ8Onx9kaTIt30j7jUFS00AXQi6QMi99vA= +k8s.io/apiextensions-apiserver v0.0.0-20190325193600-475668423e9f h1:+GpMltIq6SUOswgSQ3HcxgldikyBCreeRDkCYOzwfGk= +k8s.io/apiextensions-apiserver v0.0.0-20190325193600-475668423e9f/go.mod h1:IxkesAMoaCRoLrPJdZNZUQp9NfZnzqaVzLhb2VEQzXE= k8s.io/apiextensions-apiserver v0.0.0-20190426053235-842c4571cde0 h1:blst2tV97kE1/Mxaxx3zzh6zUGpxCbGNq0CdFf9/N8s= k8s.io/apiextensions-apiserver v0.0.0-20190426053235-842c4571cde0/go.mod h1:IPM+7P9C3mY4uik+2wHMNbydKfSZpl9Hnu0Ze0447Wg= k8s.io/apimachinery v0.0.0-20190221213512-86fb29eff628 h1:UYfHH+KEF88OTg+GojQUwFTNxbxwmoktLwutUzR0GPg= k8s.io/apimachinery v0.0.0-20190221213512-86fb29eff628/go.mod h1:ccL7Eh7zubPUSh9A3USN90/OzHNSVN6zxzde07TDCL0= +k8s.io/apiserver v0.0.0-20190319190228-a4358799e4fe h1:zD63Eo0qbcR9JzZ90yQsFMzXYSbfsCa5ICB2D2nX1tg= +k8s.io/apiserver v0.0.0-20190319190228-a4358799e4fe/go.mod h1:6bqaTSOSJavUIXUtfaR9Os9JtTCm8ZqH2SUl2S60C4w= k8s.io/apiserver v0.0.0-20190426012941-33871ad74f4b/go.mod h1:omlj40TPI/OV4YFwPP09JuOkEkKbpS5bNE2T2sPeY80= k8s.io/apiserver v0.0.0-20190426133039-accf7b6d6716 h1:gByi/idNjfDDk+lWNRqWk2uE1/KAsJtYXRMEc2M1a1k= k8s.io/apiserver v0.0.0-20190426133039-accf7b6d6716/go.mod h1:omlj40TPI/OV4YFwPP09JuOkEkKbpS5bNE2T2sPeY80= diff --git a/internal/config/mock_connection_test.go b/internal/config/mock_connection_test.go index a607f2bc..a7ebf61b 100644 --- a/internal/config/mock_connection_test.go +++ b/internal/config/mock_connection_test.go @@ -239,14 +239,15 @@ func (mock *MockConnection) ServerVersion() (*version.Info, error) { return ret0, ret1 } -func (mock *MockConnection) SupportsRes(_param0 string, _param1 []string) (string, bool) { +func (mock *MockConnection) SupportsRes(_param0 string, _param1 []string) (string, bool, error) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockConnection().") } params := []pegomock.Param{_param0, _param1} - result := pegomock.GetGenericMockFrom(mock).Invoke("SupportsRes", params, []reflect.Type{reflect.TypeOf((*string)(nil)).Elem(), reflect.TypeOf((*bool)(nil)).Elem()}) + result := pegomock.GetGenericMockFrom(mock).Invoke("SupportsRes", params, []reflect.Type{reflect.TypeOf((*string)(nil)).Elem(), reflect.TypeOf((*bool)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) var ret0 string var ret1 bool + var ret2 error if len(result) != 0 { if result[0] != nil { ret0 = result[0].(string) @@ -254,8 +255,11 @@ func (mock *MockConnection) SupportsRes(_param0 string, _param1 []string) (strin if result[1] != nil { ret1 = result[1].(bool) } + if result[2] != nil { + ret2 = result[2].(error) + } } - return ret0, ret1 + return ret0, ret1, ret2 } func (mock *MockConnection) SupportsResource(_param0 string) bool { diff --git a/internal/config/style.go b/internal/config/style.go index 381b345d..a8b38e6a 100644 --- a/internal/config/style.go +++ b/internal/config/style.go @@ -176,7 +176,7 @@ func newTableHeader() *TableHeader { return &TableHeader{ FgColor: "white", BgColor: "black", - SorterColor: "orange", + SorterColor: "aqua", } } diff --git a/internal/k8s/api.go b/internal/k8s/api.go index 1fc07e36..8f1eb65b 100644 --- a/internal/k8s/api.go +++ b/internal/k8s/api.go @@ -3,6 +3,7 @@ package k8s import ( "fmt" "strings" + "sync" "github.com/rs/zerolog" "github.com/rs/zerolog/log" @@ -59,7 +60,7 @@ type ( SupportsResource(group string) bool ValidNamespaces() ([]v1.Namespace, error) NodePods(node string) (*v1.PodList, error) - SupportsRes(grp string, versions []string) (string, bool) + SupportsRes(grp string, versions []string) (string, bool, error) ServerVersion() (*version.Info, error) FetchNodes() (*v1.NodeList, error) CurrentNamespaceName() (string, error) @@ -75,6 +76,7 @@ type ( mxsClient *versioned.Clientset useMetricServer bool log zerolog.Logger + mx sync.Mutex } ) @@ -104,6 +106,7 @@ func (a *APIClient) CanIAccess(ns, name, resURL string, verbs []string) bool { var resp *authorizationv1.SelfSubjectAccessReview var err error + var allow bool for _, v := range verbs { sar.Spec.ResourceAttributes.Verb = v resp, err = a.DialOrDie().AuthorizationV1().SelfSubjectAccessReviews().Create(sar) @@ -111,9 +114,14 @@ func (a *APIClient) CanIAccess(ns, name, resURL string, verbs []string) bool { log.Warn().Err(err).Msgf("CanIAccess") return false } + log.Debug().Msgf("CHECKING ACCESS for %s/%s/ in NS %q verb: %s -> %t, %s", resURL, name, ns, v, resp.Status.Allowed, resp.Status.Reason) + if !resp.Status.Allowed { + return false + } + allow = true } - - return resp.Status.Allowed + log.Debug().Msgf("GRANT ACCESS:%t", allow) + return allow } // CurrentNamespaceName return namespace name set via either cli arg or cluster config. @@ -230,28 +238,34 @@ func (a *APIClient) DynDialOrDie() dynamic.Interface { // NSDialOrDie returns a handle to a namespaced resource. func (a *APIClient) NSDialOrDie() dynamic.NamespaceableResourceInterface { + a.mx.Lock() + defer a.mx.Unlock() + if a.nsClient != nil { return a.nsClient } - a.nsClient = a.DynDialOrDie().Resource(schema.GroupVersionResource{ Group: "apiextensions.k8s.io", Version: "v1beta1", Resource: "customresourcedefinitions", }) + return a.nsClient } // MXDial returns a handle to the metrics server. func (a *APIClient) MXDial() (*versioned.Clientset, error) { + a.mx.Lock() + defer a.mx.Unlock() + if a.mxsClient != nil { return a.mxsClient, nil } - var err error if a.mxsClient, err = versioned.NewForConfig(a.RestConfigOrDie()); err != nil { a.log.Debug().Err(err) } + return a.mxsClient, err } @@ -299,27 +313,18 @@ func (a *APIClient) supportsMxServer() bool { } // SupportsRes checks latest supported version. -func (a *APIClient) SupportsRes(group string, versions []string) (string, bool) { +func (a *APIClient) SupportsRes(group string, versions []string) (string, bool, error) { apiGroups, err := a.DialOrDie().Discovery().ServerGroups() if err != nil { - log.Error().Err(err).Msg("Unable to dial api groups") - return "", false + return "", false, err } for _, grp := range apiGroups.Groups { if grp.Name != group { continue } - return grp.PreferredVersion.Version, true - - // for _, version := range grp.Versions { - // for _, supportedVersion := range versions { - // if version.Version == supportedVersion { - // return supportedVersion, true - // } - // } - // } + return grp.PreferredVersion.Version, true, nil } - return "", false + return "", false, nil } diff --git a/internal/resource/container.go b/internal/resource/container.go index 00b1b22b..0e4c9d32 100644 --- a/internal/resource/container.go +++ b/internal/resource/container.go @@ -147,20 +147,17 @@ func (r *Container) List(ns string) (Columnars, error) { // Header return resource header. func (*Container) Header(ns string) Row { - hh := Row{} - - return append(hh, + return append(Row{}, "NAME", "IMAGE", "READY", "STATE", "RS", - "LPROB", - "RPROB", + "PROBES(L:R)", "CPU", "MEM", - "RCPU", - "RMEM", + "%CPU", + "%MEM", "AGE", ) } @@ -170,7 +167,7 @@ func (r *Container) Fields(ns string) Row { ff := make(Row, 0, len(r.Header(ns))) i := r.instance - scpu, smem := NAValue, NAValue + scpu, smem, pcpu, pmem := NAValue, NAValue, NAValue, NAValue if r.metrics != nil { var ( cpu int64 @@ -184,8 +181,14 @@ func (r *Container) Fields(ns string) Row { } } scpu, smem = ToMillicore(cpu), ToMi(mem) + rcpu, rmem := containerResources(i) + if rcpu != nil { + pcpu = AsPerc(toPerc(float64(cpu), float64(rcpu.MilliValue()))) + } + if rmem != nil { + pmem = AsPerc(toPerc(mem, k8s.ToMB(rmem.Value()))) + } } - rcpu, rmem := resources(i) var cs *v1.ContainerStatus for _, c := range r.pod.Status.ContainerStatuses { @@ -215,12 +218,11 @@ func (r *Container) Fields(ns string) Row { ready, state, restarts, - probe(i.LivenessProbe), - probe(i.ReadinessProbe), + probe(i.LivenessProbe)+":"+probe(i.ReadinessProbe), scpu, smem, - rcpu, - rmem, + pcpu, + pmem, toAge(r.pod.CreationTimestamp), ) } @@ -254,18 +256,6 @@ func toRes(r v1.ResourceList) (string, string) { return ToMillicore(cpu.MilliValue()), ToMi(k8s.ToMB(mem.Value())) } -func resources(c v1.Container) (cpu, mem string) { - req, lim := c.Resources.Requests, c.Resources.Limits - if len(req) != 0 { - return toRes(req) - } - if len(lim) != 0 { - return toRes(lim) - } - - return NAValue, NAValue -} - func probe(p *v1.Probe) string { if p == nil { return "on" diff --git a/internal/resource/helpers.go b/internal/resource/helpers.go index 5a3327ed..e588251b 100644 --- a/internal/resource/helpers.go +++ b/internal/resource/helpers.go @@ -81,7 +81,7 @@ func join(a []string, sep string) string { // AsPerc prints a number as a percentage. func AsPerc(f float64) string { - return strconv.Itoa(int(f)) + "%" + return strconv.Itoa(int(f)) } // ToPerc computes the ratio of two numbers as a percentage. @@ -168,12 +168,12 @@ func mapToStr(m map[string]string) (s string) { // ToMillicore shows cpu reading for human. func ToMillicore(v int64) string { - return strconv.Itoa(int(v)) + "m" + return strconv.Itoa(int(v)) } // ToMi shows mem reading for human. func ToMi(v float64) string { - return strconv.Itoa(int(v)) + "Mi" + return strconv.Itoa(int(v)) } func boolPtrToStr(b *bool) string { diff --git a/internal/resource/helpers_test.go b/internal/resource/helpers_test.go index 204f7b08..cbe7ac20 100644 --- a/internal/resource/helpers_test.go +++ b/internal/resource/helpers_test.go @@ -142,9 +142,9 @@ func TestToMillicore(t *testing.T) { v int64 e string }{ - {0, "0m"}, - {2, "2m"}, - {1000, "1000m"}, + {0, "0"}, + {2, "2"}, + {1000, "1000"}, } for _, u := range uu { @@ -157,9 +157,9 @@ func TestToMi(t *testing.T) { v float64 e string }{ - {0, "0Mi"}, - {2, "2Mi"}, - {1000, "1000Mi"}, + {0, "0"}, + {2, "2"}, + {1000, "1000"}, } for _, u := range uu { @@ -172,10 +172,10 @@ func TestAsPerc(t *testing.T) { v float64 e string }{ - {0, "0%"}, - {10.5, "10%"}, - {10, "10%"}, - {0.05, "0%"}, + {0, "0"}, + {10.5, "10"}, + {10, "10"}, + {0.05, "0"}, } for _, u := range uu { diff --git a/internal/resource/list.go b/internal/resource/list.go index b24c809a..95579ee7 100644 --- a/internal/resource/list.go +++ b/internal/resource/list.go @@ -239,6 +239,7 @@ func (l *list) fetchFromStore(m *wa.Meta, ns string) (Columnars, error) { LabelSelector: l.resource.GetLabelSelector(), }) if err != nil { + log.Debug().Msgf(">>>>>> DOH! %#v", err) return nil, err } diff --git a/internal/resource/mock_clustermeta_test.go b/internal/resource/mock_clustermeta_test.go index 59b47ce9..ae681a7e 100644 --- a/internal/resource/mock_clustermeta_test.go +++ b/internal/resource/mock_clustermeta_test.go @@ -288,14 +288,15 @@ func (mock *MockClusterMeta) ServerVersion() (*version.Info, error) { return ret0, ret1 } -func (mock *MockClusterMeta) SupportsRes(_param0 string, _param1 []string) (string, bool) { +func (mock *MockClusterMeta) SupportsRes(_param0 string, _param1 []string) (string, bool, error) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockClusterMeta().") } params := []pegomock.Param{_param0, _param1} - result := pegomock.GetGenericMockFrom(mock).Invoke("SupportsRes", params, []reflect.Type{reflect.TypeOf((*string)(nil)).Elem(), reflect.TypeOf((*bool)(nil)).Elem()}) + result := pegomock.GetGenericMockFrom(mock).Invoke("SupportsRes", params, []reflect.Type{reflect.TypeOf((*string)(nil)).Elem(), reflect.TypeOf((*bool)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) var ret0 string var ret1 bool + var ret2 error if len(result) != 0 { if result[0] != nil { ret0 = result[0].(string) @@ -303,8 +304,11 @@ func (mock *MockClusterMeta) SupportsRes(_param0 string, _param1 []string) (stri if result[1] != nil { ret1 = result[1].(bool) } + if result[2] != nil { + ret2 = result[2].(error) + } } - return ret0, ret1 + return ret0, ret1, ret2 } func (mock *MockClusterMeta) SupportsResource(_param0 string) bool { diff --git a/internal/resource/mock_connection_test.go b/internal/resource/mock_connection_test.go index 19ba2a0b..486048a9 100644 --- a/internal/resource/mock_connection_test.go +++ b/internal/resource/mock_connection_test.go @@ -239,14 +239,15 @@ func (mock *MockConnection) ServerVersion() (*version.Info, error) { return ret0, ret1 } -func (mock *MockConnection) SupportsRes(_param0 string, _param1 []string) (string, bool) { +func (mock *MockConnection) SupportsRes(_param0 string, _param1 []string) (string, bool, error) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockConnection().") } params := []pegomock.Param{_param0, _param1} - result := pegomock.GetGenericMockFrom(mock).Invoke("SupportsRes", params, []reflect.Type{reflect.TypeOf((*string)(nil)).Elem(), reflect.TypeOf((*bool)(nil)).Elem()}) + result := pegomock.GetGenericMockFrom(mock).Invoke("SupportsRes", params, []reflect.Type{reflect.TypeOf((*string)(nil)).Elem(), reflect.TypeOf((*bool)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) var ret0 string var ret1 bool + var ret2 error if len(result) != 0 { if result[0] != nil { ret0 = result[0].(string) @@ -254,8 +255,11 @@ func (mock *MockConnection) SupportsRes(_param0 string, _param1 []string) (strin if result[1] != nil { ret1 = result[1].(bool) } + if result[2] != nil { + ret2 = result[2].(error) + } } - return ret0, ret1 + return ret0, ret1, ret2 } func (mock *MockConnection) SupportsResource(_param0 string) bool { diff --git a/internal/resource/no.go b/internal/resource/no.go index e6023e43..2c441170 100644 --- a/internal/resource/no.go +++ b/internal/resource/no.go @@ -111,6 +111,8 @@ func (*Node) Header(ns string) Row { "EXTERNAL-IP", "CPU", "MEM", + "%CPU", + "%MEM", "ACPU", "AMEM", "AGE", @@ -125,7 +127,7 @@ func (r *Node) Fields(ns string) Row { iIP, eIP := r.getIPs(no.Status.Addresses) iIP, eIP = missing(iIP), missing(eIP) - ccpu, cmem, scpu, smem := NAValue, NAValue, NAValue, NAValue + ccpu, cmem, scpu, smem, pcpu, pmem := NAValue, NAValue, NAValue, NAValue, NAValue, NAValue if r.metrics != nil { var ( cpu int64 @@ -137,8 +139,10 @@ func (r *Node) Fields(ns string) Row { acpu := no.Status.Allocatable.Cpu().MilliValue() amem := k8s.ToMB(no.Status.Allocatable.Memory().Value()) - ccpu = withPerc(ToMillicore(cpu), AsPerc(toPerc(float64(cpu), float64(acpu)))) - cmem = withPerc(ToMi(mem), AsPerc(toPerc(mem, amem))) + ccpu = ToMillicore(cpu) + pcpu = AsPerc(toPerc(float64(cpu), float64(acpu))) + cmem = ToMi(mem) + pmem = AsPerc(toPerc(mem, amem)) scpu = ToMillicore(cpu) smem = ToMi(mem) } @@ -158,6 +162,8 @@ func (r *Node) Fields(ns string) Row { eIP, ccpu, cmem, + pcpu, + pmem, scpu, smem, toAge(no.ObjectMeta.CreationTimestamp), diff --git a/internal/resource/no_test.go b/internal/resource/no_test.go index d2fc3527..e6eefaac 100644 --- a/internal/resource/no_test.go +++ b/internal/resource/no_test.go @@ -81,7 +81,7 @@ func TestNodeListData(t *testing.T) { assert.Equal(t, resource.NotNamespaced, l.GetNamespace()) row, ok := td.Rows["fred"] assert.True(t, ok) - assert.Equal(t, 12, len(row.Deltas)) + assert.Equal(t, 14, len(row.Deltas)) for _, d := range row.Deltas { assert.Equal(t, "", d) } diff --git a/internal/resource/ns.go b/internal/resource/ns.go index ae384019..9c06be51 100644 --- a/internal/resource/ns.go +++ b/internal/resource/ns.go @@ -62,21 +62,6 @@ func (r *Namespace) Marshal(path string) (string, error) { return r.marshalObject(nss) } -// // List resources for a given namespace. -// func (r *Namespace) List(ns string) (Columnars, error) { -// r.Resource. -// nss, err := r.Resource.List(ns) -// if err != nil { -// return nil, err -// } -// cc := make(Columnars, 0, len(nss)) -// for i := range nss { -// cc = append(cc, r.New(&nss[i]).(*Namespace)) -// } - -// return cc, nil -// } - // Header returns resource header. func (*Namespace) Header(ns string) Row { return Row{"NAME", "STATUS", "AGE"} diff --git a/internal/resource/pod.go b/internal/resource/pod.go index f97ed950..065596c5 100644 --- a/internal/resource/pod.go +++ b/internal/resource/pod.go @@ -10,6 +10,7 @@ import ( "github.com/derailed/k9s/internal/k8s" "github.com/rs/zerolog/log" v1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" "k8s.io/kubernetes/pkg/util/node" mv1beta1 "k8s.io/metrics/pkg/apis/metrics/v1beta1" ) @@ -198,6 +199,8 @@ func (*Pod) Header(ns string) Row { "RS", "CPU", "MEM", + "%CPU", + "%MEM", "IP", "NODE", "QOS", @@ -217,17 +220,13 @@ func (r *Pod) Fields(ns string) Row { ss := i.Status.ContainerStatuses cr, _, rc := r.statuses(ss) - scpu, smem := NAValue, NAValue + ccpu, cmem, pcpu, pmem := NAValue, NAValue, NAValue, NAValue if r.metrics != nil { - var cpu int64 - var mem float64 - - for _, c := range r.metrics.Containers { - cpu += c.Usage.Cpu().MilliValue() - mem += k8s.ToMB(c.Usage.Memory().Value()) - } - scpu = ToMillicore(cpu) - smem = ToMi(mem) + c, m := r.currentRes(r.metrics) + ccpu, cmem = ToMillicore(c.MilliValue()), ToMi(k8s.ToMB(m.Value())) + rc, rm := r.requestedRes(i) + pcpu = AsPerc(toPerc(float64(c.MilliValue()), float64(rc.MilliValue()))) + pmem = AsPerc(toPerc(k8s.ToMB(m.Value()), k8s.ToMB(rm.Value()))) } return append(ff, @@ -235,8 +234,10 @@ func (r *Pod) Fields(ns string) Row { strconv.Itoa(cr)+"/"+strconv.Itoa(len(ss)), r.phase(i), strconv.Itoa(rc), - scpu, - smem, + ccpu, + cmem, + pcpu, + pmem, i.Status.PodIP, i.Spec.NodeName, r.mapQOS(i.Status.QOSClass), @@ -247,6 +248,41 @@ func (r *Pod) Fields(ns string) Row { // ---------------------------------------------------------------------------- // Helpers... +func containerResources(co v1.Container) (cpu, mem *resource.Quantity) { + req, limit := co.Resources.Requests, co.Resources.Limits + switch { + case len(req) != 0 && len(limit) != 0: + cpu, mem = limit.Cpu(), limit.Memory() + case len(req) != 0: + cpu, mem = req.Cpu(), req.Memory() + case len(limit) != 0: + cpu, mem = limit.Cpu(), limit.Memory() + } + return +} + +func (r *Pod) requestedRes(po *v1.Pod) (cpu, mem resource.Quantity) { + for _, co := range po.Spec.Containers { + c, m := containerResources(co) + if c != nil { + cpu.Add(*c) + } + if m != nil { + mem.Add(*m) + } + } + return +} + +func (*Pod) currentRes(mx *mv1beta1.PodMetrics) (cpu, mem resource.Quantity) { + for _, co := range mx.Containers { + c, m := co.Usage.Cpu(), co.Usage.Memory() + cpu.Add(*c) + mem.Add(*m) + } + return +} + func (*Pod) mapQOS(class v1.PodQOSClass) string { switch class { case v1.PodQOSGuaranteed: diff --git a/internal/resource/pod_test.go b/internal/resource/pod_test.go index 25c90fb0..a47ccdd7 100644 --- a/internal/resource/pod_test.go +++ b/internal/resource/pod_test.go @@ -79,7 +79,7 @@ func TestPodListData(t *testing.T) { assert.Equal(t, 1, len(td.Rows)) assert.Equal(t, "blee", l.GetNamespace()) row := td.Rows["blee/fred"] - assert.Equal(t, 10, len(row.Deltas)) + assert.Equal(t, 12, len(row.Deltas)) for _, d := range row.Deltas { assert.Equal(t, "", d) } diff --git a/internal/views/app.go b/internal/views/app.go index 040522da..18e75447 100644 --- a/internal/views/app.go +++ b/internal/views/app.go @@ -216,11 +216,14 @@ func (a *appView) Run() { func (a *appView) keyboard(evt *tcell.EventKey) *tcell.EventKey { key := evt.Key() if key == tcell.KeyRune { - if a.cmdBuff.isActive() { + if a.cmdBuff.isActive() && evt.Modifiers() == tcell.ModNone { a.cmdBuff.add(evt.Rune()) return nil } key = tcell.Key(evt.Rune()) + if evt.Modifiers() == tcell.ModAlt { + key = tcell.Key(int16(evt.Rune()) * int16(evt.Modifiers())) + } } if a, ok := a.actions[key]; ok { @@ -340,7 +343,6 @@ func (a *appView) inject(i igniter) { a.cancel() } a.content.RemovePage("main") - var ctx context.Context { ctx, a.cancel = context.WithCancel(context.Background()) diff --git a/internal/views/cluster_info.go b/internal/views/cluster_info.go index c80c8702..fc35df4a 100644 --- a/internal/views/cluster_info.go +++ b/internal/views/cluster_info.go @@ -124,16 +124,22 @@ func (v *clusterInfoView) refresh() { cluster.Metrics(nos, nmx, &cmx) c = v.GetCell(row, 1) cpu := resource.AsPerc(cmx.PercCPU) + if cpu == "0" { + cpu = resource.NAValue + } c.SetText(cpu + deltas(strip(c.Text), cpu)) row++ c = v.GetCell(row, 1) mem := resource.AsPerc(cmx.PercMEM) + if mem == "0" { + mem = resource.NAValue + } c.SetText(mem + deltas(strip(c.Text), mem)) } func strip(s string) string { - t := strings.Replace(s, plus(), "", 1) - t = strings.Replace(t, minus(), "", 1) + t := strings.Replace(s, plusSign, "", 1) + t = strings.Replace(t, minusSign, "", 1) return t } diff --git a/internal/views/container.go b/internal/views/container.go index c586424a..41523a6e 100644 --- a/internal/views/container.go +++ b/internal/views/container.go @@ -10,14 +10,16 @@ type containerView struct { *resourceView current igniter + exitFn func() } -func newContainerView(t string, app *appView, list resource.List, path string) resourceViewer { +func newContainerView(t string, app *appView, list resource.List, path string, exitFn func()) resourceViewer { v := containerView{resourceView: newResourceView(t, app, list).(*resourceView)} { v.path = &path v.extraActionsFn = v.extraActions v.current = app.content.GetPrimitive("main").(igniter) + v.exitFn = exitFn } v.AddPage("logs", newLogsView(list.GetName(), &v), true, false) v.switchPage("co") @@ -97,13 +99,15 @@ func (v *containerView) shellIn(path, co string) { func (v *containerView) extraActions(aa keyActions) { aa[KeyL] = newKeyAction("Logs", v.logsCmd, true) - aa[KeyShiftL] = newKeyAction("Previous Logs", v.prevLogsCmd, true) + aa[KeyShiftL] = newKeyAction("Prev Logs", v.prevLogsCmd, true) aa[KeyS] = newKeyAction("Shell", v.shellCmd, true) aa[tcell.KeyEscape] = newKeyAction("Back", v.backCmd, false) aa[KeyP] = newKeyAction("Previous", v.backCmd, false) aa[tcell.KeyEnter] = newKeyAction("View Logs", v.logsCmd, false) - aa[KeyShiftC] = newKeyAction("Sort CPU", v.sortColCmd(7, false), true) - aa[KeyShiftM] = newKeyAction("Sort MEM", v.sortColCmd(8, false), true) + aa[KeyShiftC] = newKeyAction("Sort CPU", v.sortColCmd(6, false), true) + aa[KeyShiftM] = newKeyAction("Sort MEM", v.sortColCmd(7, false), true) + aa[KeyAltC] = newKeyAction("Sort %CPU", v.sortColCmd(8, false), true) + aa[KeyAltM] = newKeyAction("Sort %MEM", v.sortColCmd(9, false), true) } func (v *containerView) sortColCmd(col int, asc bool) func(evt *tcell.EventKey) *tcell.EventKey { @@ -117,7 +121,8 @@ func (v *containerView) sortColCmd(col int, asc bool) func(evt *tcell.EventKey) } func (v *containerView) backCmd(evt *tcell.EventKey) *tcell.EventKey { - v.app.inject(v.current) + // v.app.inject(v.current) + v.exitFn() return nil } diff --git a/internal/views/flash.go b/internal/views/flash.go index e3d2ff48..87cbefd7 100644 --- a/internal/views/flash.go +++ b/internal/views/flash.go @@ -48,7 +48,27 @@ func (v *flashView) setMessage(level flashLevel, msg ...string) { if v.cancel != nil { v.cancel() } - + var ctx1, ctx2 context.Context + { + ctx1, v.cancel = context.WithCancel(context.TODO()) + ctx2, _ = context.WithTimeout(context.TODO(), flashDelay*time.Second) + go func(ctx1, ctx2 context.Context) { + for { + select { + // Timer canceled bail now + case <-ctx1.Done(): + return + // Timed out clear and bail + case <-ctx2.Done(): + v.app.QueueUpdateDraw(func() { + v.Clear() + v.app.Draw() + }) + return + } + } + }(ctx1, ctx2) + } _, _, width, _ := v.GetRect() if width <= 15 { width = 100 @@ -56,23 +76,6 @@ func (v *flashView) setMessage(level flashLevel, msg ...string) { m := strings.Join(msg, " ") v.SetTextColor(flashColor(level)) v.SetText(resource.Truncate(flashEmoji(level)+" "+m, width-3)) - - var ctx context.Context - { - ctx, v.cancel = context.WithTimeout(context.TODO(), flashDelay*time.Second) - go func(ctx context.Context) { - for { - select { - case <-ctx.Done(): - v.app.QueueUpdateDraw(func() { - v.Clear() - // v.app.Draw() - }) - return - } - } - }(ctx) - } } func flashEmoji(l flashLevel) string { diff --git a/internal/views/helpers.go b/internal/views/helpers.go index 5cc914fd..2f360f7b 100644 --- a/internal/views/helpers.go +++ b/internal/views/helpers.go @@ -10,6 +10,15 @@ import ( "k8s.io/apimachinery/pkg/api/resource" ) +const ( + // deltaSign = "𝜟" + // plusSign = "⬆" + // minusSign = "⬇︎" + deltaSign = "Δ" + plusSign = "↑" + minusSign = "↓" +) + func deltas(o, n string) string { o, n = strings.TrimSpace(o), strings.TrimSpace(n) if o == "" || o == res.NAValue { @@ -20,9 +29,9 @@ func deltas(o, n string) string { j, _ := numerical(n) switch { case i < j: - return plus() + return plusSign case i > j: - return minus() + return minusSign default: return "" } @@ -32,9 +41,9 @@ func deltas(o, n string) string { j, _ := percentage(n) switch { case i < j: - return plus() + return plusSign case i > j: - return minus() + return minusSign default: return "" } @@ -44,9 +53,9 @@ func deltas(o, n string) string { q2, _ := resource.ParseQuantity(n) switch q1.Cmp(q2) { case -1: - return plus() + return plusSign case 1: - return minus() + return minusSign default: return "" } @@ -56,9 +65,9 @@ func deltas(o, n string) string { d2, _ := time.ParseDuration(n) switch { case d2-d1 > 0: - return plus() + return plusSign case d2-d1 < 0: - return minus() + return minusSign default: return "" } @@ -66,7 +75,7 @@ func deltas(o, n string) string { switch strings.Compare(o, n) { case 1, -1: - return delta() + return deltaSign default: return "" } @@ -91,15 +100,3 @@ func numerical(s string) (int, bool) { return n, true } - -func delta() string { - return "𝜟" -} - -func plus() string { - return "⬆" -} - -func minus() string { - return "⬇︎" -} diff --git a/internal/views/helpers_test.go b/internal/views/helpers_test.go index d9c924eb..b48e4e39 100644 --- a/internal/views/helpers_test.go +++ b/internal/views/helpers_test.go @@ -17,22 +17,22 @@ func TestDeltas(t *testing.T) { s1, s2, e string }{ {"", "", ""}, - {resource.MissingValue, "", delta()}, + {resource.MissingValue, "", deltaSign}, {resource.NAValue, "", ""}, {"fred", "fred", ""}, - {"fred", "blee", delta()}, + {"fred", "blee", deltaSign}, {"1", "1", ""}, - {"1", "2", plus()}, - {"2", "1", minus()}, + {"1", "2", plusSign}, + {"2", "1", minusSign}, {"2m33s", "2m33s", ""}, - {"2m33s", "1m", minus()}, - {"33s", "1m", plus()}, + {"2m33s", "1m", minusSign}, + {"33s", "1m", plusSign}, {"10Gi", "10Gi", ""}, - {"10Gi", "20Gi", plus()}, - {"30Gi", "20Gi", minus()}, + {"10Gi", "20Gi", plusSign}, + {"30Gi", "20Gi", minusSign}, {"15%", "15%", ""}, - {"20%", "40%", plus()}, - {"5%", "2%", minus()}, + {"20%", "40%", plusSign}, + {"5%", "2%", minusSign}, } for _, u := range uu { diff --git a/internal/views/job.go b/internal/views/job.go index 647e8bde..dfa1765a 100644 --- a/internal/views/job.go +++ b/internal/views/job.go @@ -104,7 +104,7 @@ func (v *jobView) showLogs(path, co, view string, parent loggable, prev bool) { func (v *jobView) extraActions(aa keyActions) { aa[KeyL] = newKeyAction("Logs", v.logsCmd, true) - aa[KeyShiftL] = newKeyAction("Previous Logs", v.prevLogsCmd, true) + aa[KeyShiftL] = newKeyAction("Prev Logs", v.prevLogsCmd, true) aa[tcell.KeyEnter] = newKeyAction("View Pods", v.showPodsCmd, true) } diff --git a/internal/views/log.go b/internal/views/log.go index 1286a152..757e0757 100644 --- a/internal/views/log.go +++ b/internal/views/log.go @@ -6,45 +6,154 @@ import ( "strings" "github.com/derailed/tview" + "github.com/gdamore/tcell" + "github.com/rs/zerolog/log" ) type logView struct { - *detailsView + *tview.Flex + app *appView + logs *detailsView + status *statusView + parent masterView ansiWriter io.Writer + autoScroll bool + actions keyActions } -func newLogView(title string, parent loggable) *logView { - v := logView{detailsView: newDetailsView(parent.appView(), parent.backFn())} +func newLogView(title string, parent masterView) *logView { + v := logView{Flex: tview.NewFlex(), app: parent.appView()} + v.autoScroll = true + v.parent = parent + v.SetBorder(true) + v.SetBorderPadding(0, 0, 1, 1) + v.logs = newDetailsView(parent.appView(), parent.backFn()) { - v.SetBorderPadding(0, 0, 1, 1) - v.setCategory("Logs") - v.SetDynamicColors(true) - v.SetWrap(true) - v.setTitle(parent.getSelection()) - v.SetMaxBuffer(parent.appView().config.K9s.LogBufferSize) - v.ansiWriter = tview.ANSIWriter(v) + v.logs.SetBorder(false) + v.logs.setCategory("Logs") + v.logs.SetDynamicColors(true) + v.logs.SetWrap(true) + v.logs.SetMaxBuffer(parent.appView().config.K9s.LogBufferSize) } + v.ansiWriter = tview.ANSIWriter(v.logs) + v.status = newStatusView(parent.appView()) + v.SetDirection(tview.FlexRow) + v.AddItem(v.status, 1, 1, false) + v.AddItem(v.logs, 0, 1, true) + + v.actions = keyActions{ + tcell.KeyEscape: {description: "Back", action: v.backCmd, visible: true}, + KeyC: {description: "Clear", action: v.clearCmd, visible: true}, + KeyS: {description: "Toggle AutoScroll", action: v.toggleScrollCmd, visible: true}, + KeyG: {description: "Top", action: v.topCmd, visible: false}, + KeyShiftG: {description: "Bottom", action: v.bottomCmd, visible: false}, + KeyF: {description: "Up", action: v.pageUpCmd, visible: false}, + KeyB: {description: "Down", action: v.pageDownCmd, visible: false}, + } + v.logs.SetInputCapture(v.keyboard) return &v } -func (l *logView) logLine(line string) { - fmt.Fprintln(l.ansiWriter, tview.Escape(line)) +// Hints show action hints +func (v *logView) hints() hints { + return v.actions.toHints() } -func (l *logView) log(lines fmt.Stringer) { - l.Clear() - fmt.Fprintln(l.ansiWriter, lines.String()) +func (v *logView) keyboard(evt *tcell.EventKey) *tcell.EventKey { + key := evt.Key() + if key == tcell.KeyRune { + key = tcell.Key(evt.Rune()) + } + if m, ok := v.actions[key]; ok { + log.Debug().Msgf(">> LogView handled %s", tcell.KeyNames[key]) + return m.action(evt) + } + + return evt } -func (l *logView) flush(index int, buff []string, scroll bool) { - if index > 0 { - l.logLine(strings.Join(buff[:index], "\n")) - if scroll { - l.app.QueueUpdate(func() { - l.ScrollToEnd() - }) - } +func (v *logView) logLine(line string) { + fmt.Fprintln(v.ansiWriter, tview.Escape(line)) +} + +func (v *logView) log(lines fmt.Stringer) { + v.logs.Clear() + fmt.Fprintln(v.ansiWriter, lines.String()) +} + +func (v *logView) flush(index int, buff []string) { + if index == 0 { + return + } + v.logLine(strings.Join(buff[:index], "\n")) + if v.autoScroll { + v.app.QueueUpdateDraw(func() { + v.update() + v.logs.ScrollToEnd() + }) } } + +func (v *logView) update() { + status := "Off" + if v.autoScroll { + status = "On" + } + v.status.update([]string{fmt.Sprintf("Autoscroll: %s", status)}) +} + +// ---------------------------------------------------------------------------- +// Actions... + +func (v *logView) toggleScrollCmd(evt *tcell.EventKey) *tcell.EventKey { + v.autoScroll = !v.autoScroll + if v.autoScroll { + v.app.flash(flashInfo, "Autoscroll is on.") + v.logs.ScrollToEnd() + } else { + v.logs.PageUp() + v.app.flash(flashInfo, "Autoscroll is off.") + } + v.update() + + return nil +} + +func (v *logView) backCmd(evt *tcell.EventKey) *tcell.EventKey { + return v.parent.backFn()(evt) +} + +func (v *logView) topCmd(evt *tcell.EventKey) *tcell.EventKey { + v.app.flash(flashInfo, "Top of logs...") + v.logs.ScrollToBeginning() + return nil +} + +func (v *logView) bottomCmd(*tcell.EventKey) *tcell.EventKey { + v.app.flash(flashInfo, "Bottom of logs...") + v.logs.ScrollToEnd() + return nil +} + +func (v *logView) pageUpCmd(*tcell.EventKey) *tcell.EventKey { + if v.logs.PageUp() { + v.app.flash(flashInfo, "Reached Top ...") + } + return nil +} + +func (v *logView) pageDownCmd(*tcell.EventKey) *tcell.EventKey { + if v.logs.PageDown() { + v.app.flash(flashInfo, "Reached Bottom ...") + } + return nil +} + +func (v *logView) clearCmd(*tcell.EventKey) *tcell.EventKey { + v.app.flash(flashInfo, "Clearing logs...") + v.logs.Clear() + v.logs.ScrollTo(0, 0) + return nil +} diff --git a/internal/views/logs.go b/internal/views/logs.go index 7070ebbc..d3eaed5e 100644 --- a/internal/views/logs.go +++ b/internal/views/logs.go @@ -3,7 +3,7 @@ package views import ( "context" "fmt" - "strconv" + "strings" "time" "github.com/derailed/k9s/internal/resource" @@ -20,6 +20,11 @@ const ( flushTimeout = 200 * time.Millisecond ) +type masterView interface { + backFn() actionHandler + appView() *appView +} + type logsView struct { *tview.Pages @@ -28,7 +33,6 @@ type logsView struct { containers []string actions keyActions cancelFunc context.CancelFunc - autoScroll bool showPrevious bool } @@ -37,19 +41,8 @@ func newLogsView(pview string, parent loggable) *logsView { Pages: tview.NewPages(), parent: parent, parentView: pview, - autoScroll: true, containers: []string{}, } - v.setActions(keyActions{ - tcell.KeyEscape: {description: "Back", action: v.backCmd, visible: true}, - KeyC: {description: "Clear", action: v.clearCmd, visible: true}, - KeyS: {description: "Toggle AutoScroll", action: v.toggleScrollCmd, visible: true}, - KeyG: {description: "Top", action: v.topCmd, visible: false}, - KeyShiftG: {description: "Bottom", action: v.bottomCmd, visible: false}, - KeyF: {description: "Up", action: v.pageUpCmd, visible: false}, - KeyB: {description: "Down", action: v.pageDownCmd, visible: false}, - }) - v.SetInputCapture(v.keyboard) return &v } @@ -63,35 +56,6 @@ func (v *logsView) reload(co string, parent loggable, view string, prevLogs bool v.load(0) } -func (v *logsView) keyboard(evt *tcell.EventKey) *tcell.EventKey { - key := evt.Key() - if key == tcell.KeyRune { - key = tcell.Key(evt.Rune()) - } - - if kv, ok := v.CurrentPage().Item.(keyHandler); ok { - if kv.keyboard(evt) == nil { - return nil - } - } - - if evt.Key() == tcell.KeyRune { - if i, err := strconv.Atoi(string(evt.Rune())); err == nil { - if _, ok := numKeys[i]; ok { - v.load(i - 1) - return nil - } - } - } - - if m, ok := v.actions[key]; ok { - log.Debug().Msgf(">> LogsView handled %s", tcell.KeyNames[key]) - return m.action(evt) - } - - return evt -} - // SetActions to handle keyboard events. func (v *logsView) setActions(aa keyActions) { v.actions = aa @@ -99,25 +63,24 @@ func (v *logsView) setActions(aa keyActions) { // Hints show action hints func (v *logsView) hints() hints { - if len(v.containers) > 1 { - for i, c := range v.containers { - v.actions[tcell.Key(numKeys[i+1])] = newKeyAction(c, nil, true) - } - } - - return v.actions.toHints() + l := v.CurrentPage().Item.(*logView) + return l.actions.toHints() } func (v *logsView) addContainer(n string) { v.containers = append(v.containers, n) - l := newLogView(n, v.parent) - { - l.SetInputCapture(v.keyboard) - l.backFn = v.backCmd - } + l := newLogView(n, v) v.AddPage(n, l, true, false) } +func (v *logsView) appView() *appView { + return v.parent.appView() +} + +func (v *logsView) backFn() actionHandler { + return v.backCmd +} + func (v *logsView) deleteAllPages() { for i, c := range v.containers { v.RemovePage(c) @@ -130,7 +93,6 @@ func (v *logsView) stop() { if v.cancelFunc == nil { return } - v.cancelFunc() log.Debug().Msgf("Canceling logs...") v.cancelFunc = nil @@ -140,7 +102,6 @@ func (v *logsView) load(i int) { if i < 0 || i > len(v.containers)-1 { return } - v.SwitchToPage(v.containers[i]) if err := v.doLoad(v.parent.getSelection(), v.containers[i]); err != nil { v.parent.appView().flash(flashErr, err.Error()) @@ -156,8 +117,12 @@ func (v *logsView) doLoad(path, co string) error { maxBuff := int64(v.parent.appView().config.K9s.LogRequestSize) l := v.CurrentPage().Item.(*logView) - l.Clear() - l.setTitle(path + ":" + co) + l.logs.Clear() + const logFmt = " Logs([fg:bg:]%s:[hilite:bg:b]%s[-:-:-]) " + fmat := fmt.Sprintf(logFmt, path, co) + fmat = strings.Replace(fmat, "[fg:bg", "["+v.parent.appView().styles.Style.Title.FgColor+":"+v.parent.appView().styles.Style.Title.BgColor, -1) + fmat = strings.Replace(fmat, "[hilite", "["+v.parent.appView().styles.Style.Title.HighlightColor, 1) + l.SetTitle(fmat) c := make(chan string, 10) go func(l *logView) { @@ -166,7 +131,7 @@ func (v *logsView) doLoad(path, co string) error { select { case line, ok := <-c: if !ok { - l.flush(index, buff, v.autoScroll) + l.flush(index, buff) index = 0 return } @@ -175,11 +140,11 @@ func (v *logsView) doLoad(path, co string) error { index++ continue } - l.flush(index, buff, v.autoScroll) + l.flush(index, buff) index = 0 buff[index] = line case <-time.After(flushTimeout): - l.flush(index, buff, v.autoScroll) + l.flush(index, buff) index = 0 } } @@ -204,67 +169,9 @@ func (v *logsView) doLoad(path, co string) error { // ---------------------------------------------------------------------------- // Actions... -func (v *logsView) toggleScrollCmd(evt *tcell.EventKey) *tcell.EventKey { - v.autoScroll = !v.autoScroll - if v.autoScroll { - v.parent.appView().flash(flashInfo, "Autoscroll is on.") - } else { - v.parent.appView().flash(flashInfo, "Autoscroll is off.") - } - - return nil -} - func (v *logsView) backCmd(evt *tcell.EventKey) *tcell.EventKey { v.stop() v.parent.switchPage(v.parentView) return evt } - -func (v *logsView) topCmd(evt *tcell.EventKey) *tcell.EventKey { - if p := v.CurrentPage(); p != nil { - v.parent.appView().flash(flashInfo, "Top of logs...") - p.Item.(*logView).ScrollToBeginning() - } - - return nil -} - -func (v *logsView) bottomCmd(*tcell.EventKey) *tcell.EventKey { - if p := v.CurrentPage(); p != nil { - v.parent.appView().flash(flashInfo, "Bottom of logs...") - p.Item.(*logView).ScrollToEnd() - } - - return nil -} - -func (v *logsView) pageUpCmd(*tcell.EventKey) *tcell.EventKey { - if p := v.CurrentPage(); p != nil { - if p.Item.(*logView).PageUp() { - v.parent.appView().flash(flashInfo, "Reached Top ...") - } - } - - return nil -} - -func (v *logsView) pageDownCmd(*tcell.EventKey) *tcell.EventKey { - if p := v.CurrentPage(); p != nil { - if p.Item.(*logView).PageDown() { - v.parent.appView().flash(flashInfo, "Reached Bottom ...") - } - } - - return nil -} - -func (v *logsView) clearCmd(*tcell.EventKey) *tcell.EventKey { - if p := v.CurrentPage(); p != nil { - v.parent.appView().flash(flashInfo, "Clearing logs...") - p.Item.(*logView).Clear() - } - - return nil -} diff --git a/internal/views/menu.go b/internal/views/menu.go index d0ebcca3..33d46c0f 100644 --- a/internal/views/menu.go +++ b/internal/views/menu.go @@ -207,6 +207,36 @@ const ( Key9 ) +// Defines AltKeys +const ( + KeyAltA tcell.Key = 4 * (iota + 97) + KeyAltB + KeyAltC + KeyAltD + KeyAltE + KeyAltF + KeyAltG + KeyAltH + KeyAltI + KeyAltJ + KeyAltK + KeyAltL + KeyAltM + KeyAltN + KeyAltO + KeyAltP + KeyAltQ + KeyAltR + KeyAltS + KeyAltT + KeyAltU + KeyAltV + KeyAltW + KeyAltX + KeyAltY + KeyAltZ +) + // Defines char keystrokes const ( KeyA tcell.Key = iota + 97 @@ -351,4 +381,32 @@ func initKeys() { tcell.KeyNames[tcell.Key(KeyHelp)] = "?" tcell.KeyNames[tcell.Key(KeySlash)] = "/" + + tcell.KeyNames[tcell.Key(KeyAltA)] = "ALT-A" + tcell.KeyNames[tcell.Key(KeyAltB)] = "ALT-B" + tcell.KeyNames[tcell.Key(KeyAltC)] = "ALT-C" + tcell.KeyNames[tcell.Key(KeyAltD)] = "ALT-D" + tcell.KeyNames[tcell.Key(KeyAltE)] = "ALT-E" + tcell.KeyNames[tcell.Key(KeyAltF)] = "ALT-F" + tcell.KeyNames[tcell.Key(KeyAltG)] = "ALT-G" + tcell.KeyNames[tcell.Key(KeyAltH)] = "ALT-H" + tcell.KeyNames[tcell.Key(KeyAltI)] = "ALT-I" + tcell.KeyNames[tcell.Key(KeyAltJ)] = "ALT-J" + tcell.KeyNames[tcell.Key(KeyAltK)] = "ALT-K" + tcell.KeyNames[tcell.Key(KeyAltL)] = "ALT-L" + tcell.KeyNames[tcell.Key(KeyAltM)] = "ALT-M" + tcell.KeyNames[tcell.Key(KeyAltN)] = "ALT-N" + tcell.KeyNames[tcell.Key(KeyAltO)] = "ALT-O" + tcell.KeyNames[tcell.Key(KeyAltP)] = "ALT-P" + tcell.KeyNames[tcell.Key(KeyAltQ)] = "ALT-Q" + tcell.KeyNames[tcell.Key(KeyAltR)] = "ALT-R" + tcell.KeyNames[tcell.Key(KeyAltS)] = "ALT-S" + tcell.KeyNames[tcell.Key(KeyAltT)] = "ALT-T" + tcell.KeyNames[tcell.Key(KeyAltU)] = "ALT-U" + tcell.KeyNames[tcell.Key(KeyAltV)] = "ALT-V" + tcell.KeyNames[tcell.Key(KeyAltW)] = "ALT-W" + tcell.KeyNames[tcell.Key(KeyAltX)] = "ALT-X" + tcell.KeyNames[tcell.Key(KeyAltY)] = "ALT-Y" + tcell.KeyNames[tcell.Key(KeyAltZ)] = "ALT-Z" + } diff --git a/internal/views/mock_clustermeta.go b/internal/views/mock_clustermeta.go index 62ade0b8..890dcd3b 100644 --- a/internal/views/mock_clustermeta.go +++ b/internal/views/mock_clustermeta.go @@ -288,14 +288,15 @@ func (mock *MockClusterMeta) ServerVersion() (*version.Info, error) { return ret0, ret1 } -func (mock *MockClusterMeta) SupportsRes(_param0 string, _param1 []string) (string, bool) { +func (mock *MockClusterMeta) SupportsRes(_param0 string, _param1 []string) (string, bool, error) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockClusterMeta().") } params := []pegomock.Param{_param0, _param1} - result := pegomock.GetGenericMockFrom(mock).Invoke("SupportsRes", params, []reflect.Type{reflect.TypeOf((*string)(nil)).Elem(), reflect.TypeOf((*bool)(nil)).Elem()}) + result := pegomock.GetGenericMockFrom(mock).Invoke("SupportsRes", params, []reflect.Type{reflect.TypeOf((*string)(nil)).Elem(), reflect.TypeOf((*bool)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) var ret0 string var ret1 bool + var ret2 error if len(result) != 0 { if result[0] != nil { ret0 = result[0].(string) @@ -303,8 +304,11 @@ func (mock *MockClusterMeta) SupportsRes(_param0 string, _param1 []string) (stri if result[1] != nil { ret1 = result[1].(bool) } + if result[2] != nil { + ret2 = result[2].(error) + } } - return ret0, ret1 + return ret0, ret1, ret2 } func (mock *MockClusterMeta) SupportsResource(_param0 string) bool { diff --git a/internal/views/pod.go b/internal/views/pod.go index df2dd28c..4f194f3e 100644 --- a/internal/views/pod.go +++ b/internal/views/pod.go @@ -1,6 +1,7 @@ package views import ( + "context" "fmt" "strings" @@ -17,6 +18,8 @@ const containerFmt = "[fg:bg:b]%s([hilite:bg:b]%s[fg:bg:-])" type podView struct { *resourceView + + cancel context.CancelFunc } type loggable interface { @@ -33,7 +36,6 @@ func newPodView(t string, app *appView, list resource.List) resourceViewer { v.extraActionsFn = v.extraActions v.enterFn = v.listContainers } - picker := newSelectList(&v) { picker.setActions(keyActions{ @@ -41,7 +43,6 @@ func newPodView(t string, app *appView, list resource.List) resourceViewer { }) } v.AddPage("picker", picker, true, false) - v.AddPage("logs", newLogsView(list.GetName(), &v), true, false) v.switchPage("po") @@ -52,14 +53,12 @@ func (v *podView) listContainers(app *appView, _, res, sel string) { if !v.rowSelected() { return } - po, err := v.app.informer.Get(watch.PodIndex, sel, metav1.GetOptions{}) if err != nil { log.Error().Err(err).Msgf("Unable to retrieve pod %s", sel) app.flash(flashErr, err.Error()) return } - pod := po.(*v1.Pod) mx := k8s.NewMetricsServer(app.conn()) list := resource.NewContainerList(app.conn(), mx, pod) @@ -68,12 +67,20 @@ func (v *podView) listContainers(app *appView, _, res, sel string) { fmat := strings.Replace(containerFmt, "[fg:bg", "["+v.app.styles.Style.Title.FgColor+":"+v.app.styles.Style.Title.BgColor, -1) fmat = strings.Replace(fmat, "[hilite", "["+v.app.styles.Style.Title.CounterColor, 1) title := fmt.Sprintf(fmat, "Containers", sel) - app.inject(newContainerView( - title, - app, - list, - namespacedName(pod.Namespace, pod.Name), - )) + + v.suspend() + cv := newContainerView(title, app, list, namespacedName(pod.Namespace, pod.Name), v.exitFn) + v.AddPage("containers", cv, true, true) + ctx, cancel := context.WithCancel(context.Background()) + v.cancel = cancel + cv.init(ctx, pod.Namespace) +} + +func (v *podView) exitFn() { + v.cancel() + v.switchPage("po") + v.RemovePage("containers") + v.resume() } // Protocol... @@ -116,19 +123,16 @@ func (v *podView) viewLogs(prev bool) bool { if !v.rowSelected() { return false } - cc, err := fetchContainers(v.list, v.selectedItem, true) if err != nil { v.app.flash(flashErr, err.Error()) log.Error().Err(err) return false } - if len(cc) == 1 { v.showLogs(v.selectedItem, cc[0], v.list.GetName(), v, prev) return true } - picker := v.GetPrimitive("picker").(*selectList) picker.populate(cc) picker.SetSelectedFunc(func(i int, t, d string, r rune) { @@ -142,7 +146,6 @@ func (v *podView) viewLogs(prev bool) bool { func (v *podView) showLogs(path, co, view string, parent loggable, prev bool) { l := v.GetPrimitive("logs").(*logsView) l.reload(co, parent, view, prev) - v.switchPage("logs") } @@ -150,19 +153,16 @@ func (v *podView) shellCmd(evt *tcell.EventKey) *tcell.EventKey { if !v.rowSelected() { return evt } - cc, err := fetchContainers(v.list, v.selectedItem, false) if err != nil { v.app.flash(flashErr, err.Error()) log.Error().Msgf("Error fetching containers %v", err) return evt } - if len(cc) == 1 { v.shellIn(v.selectedItem, "") return nil } - p := v.GetPrimitive("picker").(*selectList) p.populate(cc) p.SetSelectedFunc(func(i int, t, d string, r rune) { @@ -194,14 +194,16 @@ func (v *podView) shellIn(path, co string) { func (v *podView) extraActions(aa keyActions) { aa[KeyL] = newKeyAction("Logs", v.logsCmd, true) - aa[KeyShiftL] = newKeyAction("Logs", v.prevLogsCmd, true) + aa[KeyShiftL] = newKeyAction("Prev Logs", v.prevLogsCmd, true) aa[KeyS] = newKeyAction("Shell", v.shellCmd, true) aa[KeyShiftR] = newKeyAction("Sort Ready", v.sortColCmd(1, false), true) aa[KeyShiftS] = newKeyAction("Sort Status", v.sortColCmd(2, true), true) aa[KeyShiftT] = newKeyAction("Sort Restart", v.sortColCmd(3, false), true) aa[KeyShiftC] = newKeyAction("Sort CPU", v.sortColCmd(4, false), true) aa[KeyShiftM] = newKeyAction("Sort MEM", v.sortColCmd(5, false), true) - aa[KeyShiftO] = newKeyAction("Sort Node", v.sortColCmd(7, true), true) + aa[KeyAltC] = newKeyAction("Sort %CPU", v.sortColCmd(6, false), true) + aa[KeyAltM] = newKeyAction("Sort %MEM", v.sortColCmd(7, false), true) + aa[KeyShiftO] = newKeyAction("Sort Node", v.sortColCmd(8, true), true) } func (v *podView) sortColCmd(col int, asc bool) func(evt *tcell.EventKey) *tcell.EventKey { diff --git a/internal/views/registrar.go b/internal/views/registrar.go index d840c308..32abf4c0 100644 --- a/internal/views/registrar.go +++ b/internal/views/registrar.go @@ -9,19 +9,17 @@ import ( ) type ( - viewFn func(ns string, app *appView, list resource.List) resourceViewer - listFn func(c resource.Connection, ns string) resource.List - // listMxFn func(c resource.Connection, mx resource.MetricsServer, ns string) resource.List + viewFn func(ns string, app *appView, list resource.List) resourceViewer + listFn func(c resource.Connection, ns string) resource.List colorerFn func(ns string, evt *resource.RowEvent) tcell.Color enterFn func(app *appView, ns, resource, selection string) decorateFn func(resource.TableData) resource.TableData resCmd struct { - title string - api string - viewFn viewFn - listFn listFn - // listMxFn listMxFn + title string + api string + viewFn viewFn + listFn listFn enterFn enterFn colorerFn colorerFn decorateFn decorateFn @@ -297,9 +295,14 @@ func resourceViews(c k8s.Connection) map[string]resCmd { }, } - rev, ok := c.SupportsRes("autoscaling", []string{"v1", "v2beta1", "v2beta2"}) + rev, ok, err := c.SupportsRes("autoscaling", []string{"v1", "v2beta1", "v2beta2"}) + if err != nil { + log.Error().Err(err).Msg("Checking HPA") + return cmds + } if !ok { - log.Warn().Msg("HPA are not supported on this cluster") + log.Error().Msg("HPA are not supported on this cluster") + return cmds } switch rev { @@ -328,7 +331,7 @@ func resourceViews(c k8s.Connection) map[string]resCmd { listFn: resource.NewHorizontalPodAutoscalerList, } default: - log.Panic().Msgf("K9s does not currently support HPA version `%s`", rev) + log.Panic().Msgf("K9s unsupported HPA version. Exiting!") } return cmds diff --git a/internal/views/resource.go b/internal/views/resource.go index 3bdbcb48..d20aaf8e 100644 --- a/internal/views/resource.go +++ b/internal/views/resource.go @@ -110,6 +110,15 @@ func (v *resourceView) init(ctx context.Context, ns string) { } } +func (v *resourceView) reloadList(list resource.List, ns string) { + v.suspend() + { + v.list = list + v.list.SetNamespace(ns) + } + v.resume() +} + func (v *resourceView) updater(ctx context.Context) { go func(ctx context.Context) { for { @@ -388,7 +397,6 @@ func (v *resourceView) refresh() { } v.refreshActions() - if err := v.list.Reconcile(v.app.informer, v.path); err != nil { log.Error().Err(err).Msg("Reconciliation failed") v.app.flash(flashErr, err.Error()) @@ -443,6 +451,7 @@ func (v *resourceView) switchPage(p string) { v.app.setHints(h.hints()) } + log.Info().Msgf("Current page %#v", v.CurrentPage()) if _, ok := v.CurrentPage().Item.(*tableView); ok { v.resume() } diff --git a/internal/views/status.go b/internal/views/status.go new file mode 100644 index 00000000..bc5bda38 --- /dev/null +++ b/internal/views/status.go @@ -0,0 +1,38 @@ +package views + +import ( + "fmt" + + "github.com/derailed/tview" +) + +type statusView struct { + *tview.TextView + + app *appView +} + +func newStatusView(app *appView) *statusView { + v := statusView{app: app, TextView: tview.NewTextView()} + { + v.SetBackgroundColor(app.styles.BgColor()) + v.SetTextAlign(tview.AlignRight) + // v.SetBorderPadding(0, 0, 1, 1) + v.SetDynamicColors(true) + } + return &v +} + +func (v *statusView) update(status []string) { + v.Clear() + last, bgColor := len(status)-1, v.app.styles.Style.Crumb.BgColor + for i, c := range status { + if i == last { + bgColor = v.app.styles.Style.Crumb.ActiveColor + } + fmt.Fprintf(v, "[%s:%s:b] %s [-:%s:-] ", + v.app.styles.Style.Crumb.FgColor, + bgColor, c, + v.app.styles.Style.BgColor) + } +} diff --git a/internal/views/styles.go b/internal/views/styles.go new file mode 100644 index 00000000..56b90a61 --- /dev/null +++ b/internal/views/styles.go @@ -0,0 +1,45 @@ +package views + +import ( + "github.com/derailed/tview" + "github.com/gdamore/tcell" +) + +type styles struct { + color tcell.Color + attrs tcell.AttrMask + align int +} + +func stylesFor(app *appView, res string, col int) styles { + switch res { + case "pod": + return podStyles(app, col) + default: + return defaultStyles(app, col) + } +} + +func podStyles(app *appView, col int) styles { + st := styles{ + color: stdColor, + attrs: tcell.AttrReverse, + align: tview.AlignLeft, + } + + switch col { + case 5, 6, 7, 8: + st.align = tview.AlignLeft + st.color = tcell.ColorGreen + } + + return st +} + +func defaultStyles(app *appView, col int) styles { + return styles{ + color: tcell.ColorRed, + attrs: tcell.AttrReverse, + align: tview.AlignLeft, + } +} diff --git a/internal/views/table.go b/internal/views/table.go index fb10c532..fa23b43f 100644 --- a/internal/views/table.go +++ b/internal/views/table.go @@ -16,9 +16,16 @@ import ( ) const ( - titleFmt = "[fg:bg:b] %s[fg:bg:-][[count:bg:b]%d[fg:bg:-]] " - searchFmt = "<[filter:bg:b]/%s[fg:bg:]> " - nsTitleFmt = "[fg:bg:b] %s([hilite:bg:b]%s[fg:bg:-])[fg:bg:-][[count:bg:b]%d[fg:bg:-]][fg:bg:-] " + titleFmt = "[fg:bg:b] %s[fg:bg:-][[count:bg:b]%d[fg:bg:-]] " + searchFmt = "<[filter:bg:b]/%s[fg:bg:]> " + nsTitleFmt = "[fg:bg:b] %s([hilite:bg:b]%s[fg:bg:-])[fg:bg:-][[count:bg:b]%d[fg:bg:-]][fg:bg:-] " + descIndicator = "↓" + ascIndicator = "↑" +) + +var ( + crx = regexp.MustCompile(`\A.{0,1}CPU`) + mrx = regexp.MustCompile(`\A.{0,1}MEM`) ) type ( @@ -110,6 +117,9 @@ func (v *tableView) keyboard(evt *tcell.EventKey) *tcell.EventKey { return nil } key = tcell.Key(evt.Rune()) + if evt.Modifiers() == tcell.ModAlt { + key = tcell.Key(int16(evt.Rune()) * int16(evt.Modifiers())) + } } if a, ok := v.actions[key]; ok { @@ -181,8 +191,8 @@ func (v *tableView) sortColCmd(col int) func(evt *tcell.EventKey) *tcell.EventKe } else { v.sortCol.index, v.sortCol.asc = v.nameColIndex()+col, true } - v.refresh() + return nil } } @@ -289,11 +299,11 @@ func (v *tableView) sortIndicator(index int, name string) string { return name } - order := "↓" + order := descIndicator if v.sortCol.asc { - order = "↑" + order = ascIndicator } - return fmt.Sprintf("%s [%s::]%s[::]", name, v.app.styles.Style.Table.Header.SorterColor, order) + return fmt.Sprintf("%s[%s::]%s[::]", name, v.app.styles.Style.Table.Header.SorterColor, order) } func (v *tableView) doUpdate(data resource.TableData) { @@ -324,7 +334,7 @@ func (v *tableView) doUpdate(data resource.TableData) { fg := config.AsColor(v.app.styles.Style.Table.Header.FgColor) bg := config.AsColor(v.app.styles.Style.Table.Header.BgColor) for col, h := range data.Header { - v.addHeaderCell(col, h, pads, fg, bg) + v.addHeaderCell(col, h, fg, bg) } row++ @@ -340,11 +350,7 @@ func (v *tableView) doUpdate(data resource.TableData) { fgColor = v.colorerFn(data.Namespace, data.Rows[sk]) } for col, field := range data.Rows[sk].Fields { - var age bool - if data.Header[col] == "AGE" { - age = true - } - v.addBodyCell(age, row, col, field, data.Rows[sk].Deltas[col], fgColor, pads) + v.addBodyCell(data.Header[col], row, col, field, data.Rows[sk].Deltas[col], fgColor, pads) } row++ } @@ -372,34 +378,43 @@ func (v *tableView) sortAllRows(rows resource.RowEvents, sortFn sortFn) (resourc return prim, sec } -func (v *tableView) addHeaderCell(col int, name string, pads maxyPad, fg, bg tcell.Color) { +func (v *tableView) addHeaderCell(col int, name string, fg, bg tcell.Color) { c := tview.NewTableCell(v.sortIndicator(col, name)) { c.SetExpansion(1) c.SetTextColor(fg) + if crx.MatchString(name) || mrx.MatchString(name) { + c.SetAlign(tview.AlignRight) + } c.SetBackgroundColor(bg) } v.SetCell(0, col, c) } -func (v *tableView) addBodyCell(age bool, row, col int, field, delta string, color tcell.Color, pads maxyPad) { - dField := field - if age { +func (v *tableView) addBodyCell(header string, row, col int, field, delta string, color tcell.Color, pads maxyPad) { + const colPadding = 3 + + if header == "AGE" { dur, err := time.ParseDuration(field) if err == nil { - dField = duration.HumanDuration(dur) + field = duration.HumanDuration(dur) } } - dField += deltas(delta, field) - if isASCII(field) { - dField = pad(dField, pads[col]+5) + field += deltas(delta, field) + align := tview.AlignLeft + if crx.MatchString(header) || mrx.MatchString(header) { + align = tview.AlignRight + } else if isASCII(field) { + field = pad(field, pads[col]+colPadding) } - c := tview.NewTableCell(dField) + c := tview.NewTableCell(field) { c.SetExpansion(1) + c.SetAlign(align) c.SetTextColor(color) + c.SetMaxWidth(pads[col] + colPadding) } v.SetCell(row, col, c) } diff --git a/internal/watch/meta.go b/internal/watch/meta.go index bdd53d8d..b1dd1ddd 100644 --- a/internal/watch/meta.go +++ b/internal/watch/meta.go @@ -4,11 +4,15 @@ import ( "fmt" "github.com/derailed/k9s/internal/k8s" + "github.com/rs/zerolog/log" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/watch" "k8s.io/client-go/tools/cache" ) +// AllNamespaces designates all namespaces. +const AllNamespaces = "" + type ( // Row represents a collection of string fields. Row []string @@ -53,13 +57,30 @@ type Meta struct { // NewMeta creates a new cluster resource informer func NewMeta(client k8s.Connection, ns string) *Meta { m := Meta{client: client, informers: map[string]StoreInformer{}} - m.init("") + + nsAccess := m.client.CanIAccess("", "", "namespaces", []string{"list", "watch"}) + ns, err := client.Config().CurrentNamespaceName() + // User did not lock NS. Check all ns access if not bail + if err != nil && !nsAccess { + log.Panic().Msg("Unauthorized access to list namespaces. Please specify a namespace") + } + + // Namespace is locks in check if user has auth for this ns access. + if ns != AllNamespaces && !nsAccess { + if !m.client.CanIAccess("", ns, "namespaces", []string{"get", "watch"}) { + log.Panic().Msgf("Unauthorized access to namespace %q", ns) + } + m.init(ns) + } else { + m.init(AllNamespaces) + } return &m } func (m *Meta) init(ns string) { po := NewPod(m.client, ns) + m.informers = map[string]StoreInformer{ NodeIndex: NewNode(m.client), PodIndex: po, @@ -67,17 +88,19 @@ func (m *Meta) init(ns string) { } if m.client.HasMetrics() { - m.informers[NodeMXIndex] = NewNodeMetrics(m.client) - m.informers[PodMXIndex] = NewPodMetrics(m.client, ns) + if m.client.CanIAccess("", ns, "metrics.k8s.io", []string{"list", "watch"}) { + m.informers[NodeMXIndex] = NewNodeMetrics(m.client) + m.informers[PodMXIndex] = NewPodMetrics(m.client, ns) + } } } // CheckAccess checks if current user as enought RBAC fu to access watched resources. func (m *Meta) checkAccess(ns string) error { - if !m.client.CanIAccess(ns, "nodes", "node.v1", []string{"list", "watch"}) { + if !m.client.CanIAccess(ns, "nodes", "nodes", []string{"list", "watch"}) { return fmt.Errorf("Not authorized to list/watch nodes") } - if !m.client.CanIAccess(ns, "pods", "pod.v1", []string{"list", "watch"}) { + if !m.client.CanIAccess(ns, "pods", "pods", []string{"list", "watch"}) { return fmt.Errorf("Not authorized to list/watch pods in namespace %s", ns) } diff --git a/internal/watch/meta_test.go b/internal/watch/meta_test.go index d0eb5ce0..f0944b14 100644 --- a/internal/watch/meta_test.go +++ b/internal/watch/meta_test.go @@ -3,51 +3,64 @@ package watch import ( "testing" + "github.com/derailed/k9s/internal/k8s" + m "github.com/petergtz/pegomock" "gotest.tools/assert" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/cli-runtime/pkg/genericclioptions" ) func TestMetaList(t *testing.T) { + f := new(genericclioptions.ConfigFlags) cmo := NewMockConnection() - m := NewMeta(cmo, "") + m.When(cmo.Config()).ThenReturn(k8s.NewConfig(f)) + meta := NewMeta(cmo, "") - o, err := m.List(PodIndex, "fred", metav1.ListOptions{}) + o, err := meta.List(PodIndex, "fred", metav1.ListOptions{}) assert.NilError(t, err) assert.Assert(t, len(o) == 0) } func TestMetaListNoRes(t *testing.T) { + f := new(genericclioptions.ConfigFlags) cmo := NewMockConnection() - m := NewMeta(cmo, "") + m.When(cmo.Config()).ThenReturn(k8s.NewConfig(f)) + meta := NewMeta(cmo, "") - o, err := m.List("dp", "fred", metav1.ListOptions{}) + o, err := meta.List("dp", "fred", metav1.ListOptions{}) assert.ErrorContains(t, err, "No informer found") assert.Assert(t, len(o) == 0) } func TestMetaGet(t *testing.T) { + f := new(genericclioptions.ConfigFlags) cmo := NewMockConnection() - m := NewMeta(cmo, "") + m.When(cmo.Config()).ThenReturn(k8s.NewConfig(f)) + meta := NewMeta(cmo, "") - o, err := m.Get(PodIndex, "fred", metav1.GetOptions{}) + o, err := meta.Get(PodIndex, "fred", metav1.GetOptions{}) assert.ErrorContains(t, err, "Pod fred not found") assert.Assert(t, o == nil) } func TestMetaGetNoRes(t *testing.T) { + f := new(genericclioptions.ConfigFlags) cmo := NewMockConnection() - m := NewMeta(cmo, "") + m.When(cmo.Config()).ThenReturn(k8s.NewConfig(f)) + meta := NewMeta(cmo, "") - o, err := m.Get("rs", "fred", metav1.GetOptions{}) + o, err := meta.Get("rs", "fred", metav1.GetOptions{}) assert.ErrorContains(t, err, "No informer found") assert.Assert(t, o == nil) } func TestMetaRun(t *testing.T) { + f := new(genericclioptions.ConfigFlags) cmo := NewMockConnection() - m := NewMeta(cmo, "") + m.When(cmo.Config()).ThenReturn(k8s.NewConfig(f)) + meta := NewMeta(cmo, "") c := make(chan struct{}) - m.Run(c) + meta.Run(c) close(c) } diff --git a/internal/watch/mock_connection_test.go b/internal/watch/mock_connection_test.go index a4d7fda8..4026efd5 100644 --- a/internal/watch/mock_connection_test.go +++ b/internal/watch/mock_connection_test.go @@ -239,14 +239,15 @@ func (mock *MockConnection) ServerVersion() (*version.Info, error) { return ret0, ret1 } -func (mock *MockConnection) SupportsRes(_param0 string, _param1 []string) (string, bool) { +func (mock *MockConnection) SupportsRes(_param0 string, _param1 []string) (string, bool, error) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockConnection().") } params := []pegomock.Param{_param0, _param1} - result := pegomock.GetGenericMockFrom(mock).Invoke("SupportsRes", params, []reflect.Type{reflect.TypeOf((*string)(nil)).Elem(), reflect.TypeOf((*bool)(nil)).Elem()}) + result := pegomock.GetGenericMockFrom(mock).Invoke("SupportsRes", params, []reflect.Type{reflect.TypeOf((*string)(nil)).Elem(), reflect.TypeOf((*bool)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) var ret0 string var ret1 bool + var ret2 error if len(result) != 0 { if result[0] != nil { ret0 = result[0].(string) @@ -254,8 +255,11 @@ func (mock *MockConnection) SupportsRes(_param0 string, _param1 []string) (strin if result[1] != nil { ret1 = result[1].(bool) } + if result[2] != nil { + ret2 = result[2].(error) + } } - return ret0, ret1 + return ret0, ret1, ret2 } func (mock *MockConnection) SupportsResource(_param0 string) bool { diff --git a/internal/watch/no_mx.go b/internal/watch/no_mx.go index 8e1d55d1..e23139b5 100644 --- a/internal/watch/no_mx.go +++ b/internal/watch/no_mx.go @@ -64,7 +64,6 @@ func newNodeMetricsInformer(client k8s.Connection, sync time.Duration, idxs cach if err != nil { return nil, err } - l, err := c.MetricsV1beta1().NodeMetricses().List(opts) if err == nil { pw.update(l, false) @@ -113,7 +112,6 @@ func (n *nodeMxWatcher) Run() { if err != nil { return } - list, err := c.MetricsV1beta1().NodeMetricses().List(metav1.ListOptions{}) if err != nil { log.Error().Err(err).Msg("Fetch node metrics") @@ -142,7 +140,6 @@ func (n *nodeMxWatcher) update(list *mv1beta1.NodeMetricsList, notify bool) { fqn := MetaFQN(list.Items[i].ObjectMeta) fqns[fqn] = &list.Items[i] } - for k, v := range n.cache { if _, ok := fqns[k]; !ok { if notify { @@ -154,7 +151,6 @@ func (n *nodeMxWatcher) update(list *mv1beta1.NodeMetricsList, notify bool) { delete(n.cache, k) } } - for k, v := range fqns { kind := watch.Added if v1, ok := n.cache[k]; ok { @@ -163,7 +159,6 @@ func (n *nodeMxWatcher) update(list *mv1beta1.NodeMetricsList, notify bool) { } kind = watch.Modified } - if notify { n.eventChan <- watch.Event{ Type: kind, diff --git a/internal/watch/pod_mx.go b/internal/watch/pod_mx.go index 63a47979..991a950e 100644 --- a/internal/watch/pod_mx.go +++ b/internal/watch/pod_mx.go @@ -1,7 +1,6 @@ package watch import ( - "errors" "fmt" "time" @@ -75,11 +74,6 @@ func newPodMetricsInformer(client k8s.Connection, ns string, sync time.Duration, if err != nil { return nil, err } - - if !client.HasMetrics() { - return nil, errors.New("metrics-server not supported") - } - l, err := c.MetricsV1beta1().PodMetricses(ns).List(opts) if err == nil { pw.update(l, false) diff --git a/main.go b/main.go index fb3882ba..47240c58 100644 --- a/main.go +++ b/main.go @@ -2,14 +2,12 @@ package main import ( "os" - "syscall" "github.com/derailed/k9s/cmd" "github.com/derailed/k9s/internal/config" "github.com/rs/zerolog" "github.com/rs/zerolog/log" _ "k8s.io/client-go/plugin/pkg/client/auth" - "k8s.io/klog" ) func init() { @@ -18,9 +16,6 @@ func init() { mod := os.O_CREATE | os.O_APPEND | os.O_WRONLY if file, err := os.OpenFile(config.K9sLogs, mod, config.DefaultFileMod); err == nil { log.Logger = log.Output(zerolog.ConsoleWriter{Out: file}) - // Klogs (of course) want to print stuff to the screen ;( - klog.SetOutput(file) - syscall.Dup2(int(file.Fd()), 2) } else { panic(err) }