diff --git a/README.md b/README.md index b424ed26..5e598aa9 100644 --- a/README.md +++ b/README.md @@ -315,6 +315,10 @@ k9s: keyColor: steelblue colonColor: blue valueColor: royalblue + # Logs styles. + yaml: + fgColor: white + bgColor: black ``` Available color names are defined below: diff --git a/go.mod b/go.mod index 479713da..38b189f6 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/derailed/k9s go 1.12 replace ( + github.com/derailed/tview => /Users/fernand/go_wk/derailed/src/github.com/derailed/tview 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 @@ -15,6 +16,7 @@ replace ( require ( github.com/Azure/go-autorest/autorest v0.1.0 // indirect github.com/derailed/tview v0.1.6 + github.com/docker/spdystream v0.0.0-20181023171402-6480d4af844c // indirect github.com/evanphx/json-patch v4.1.0+incompatible // indirect github.com/fatih/camelcase v1.0.0 // indirect github.com/fsnotify/fsnotify v1.4.7 @@ -35,6 +37,7 @@ require ( github.com/onsi/gomega v1.5.0 // indirect github.com/peterbourgon/diskv v2.0.1+incompatible // indirect github.com/petergtz/pegomock v0.0.0-20181206220228-b113d17a7e81 + github.com/rakyll/hey v0.1.2 github.com/rs/zerolog v1.14.3 github.com/spf13/cobra v0.0.3 github.com/spf13/pflag v1.0.3 // indirect diff --git a/go.sum b/go.sum index 487630bf..2bea4440 100644 --- a/go.sum +++ b/go.sum @@ -51,6 +51,8 @@ github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumC github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= github.com/docker/docker v0.7.3-0.20190327010347-be7ac8be2ae0/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/go-units v0.3.3/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/docker/spdystream v0.0.0-20181023171402-6480d4af844c h1:ZfSZ3P3BedhKGUhzj7BQlPSU4OvT6tfOKe3DVHzOA7s= +github.com/docker/spdystream v0.0.0-20181023171402-6480d4af844c/go.mod h1:Qh8CwZgvJUkLughtfhJv5dyTYa91l1fOUCrgjqmcifM= github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs= github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU= github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I= @@ -211,6 +213,8 @@ github.com/prometheus/common v0.2.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y8 github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/prometheus/procfs v0.0.0-20181204211112-1dc9a6cbc91a/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/prometheus/procfs v0.0.0-20190117184657-bf6a532e95b1/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +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/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= github.com/remyoudompheng/bigfft v0.0.0-20170806203942-52369c62f446/go.mod h1:uYEyJGbgTkfkS4+E/PavXkNJcbFIpEtjt2B0KDQ5+9M= github.com/rivo/tview v0.0.0-20190213202703-b373355e9db4/go.mod h1:J4W+hErFfITUbyFAEXizpmkuxX7ZN56dopxHB4XQhMw= @@ -267,6 +271,7 @@ golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73r golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181005035420-146acd28ed58/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181017193950-04a2e542c03f/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= diff --git a/internal/config/config.go b/internal/config/config.go index 75e1d12b..a5d2b6c8 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -23,7 +23,7 @@ var ( // K9sConfigFile represents K9s config file location. K9sConfigFile = filepath.Join(K9sHome, "config.yml") // K9sLogs represents K9s log. - K9sLogs = filepath.Join(os.TempDir(), fmt.Sprintf("k9s-%s.log", mustK9sUser())) + K9sLogs = filepath.Join(os.TempDir(), fmt.Sprintf("k9s-%s.log", MustK9sUser())) ) type ( diff --git a/internal/config/helpers.go b/internal/config/helpers.go index 726d85bb..91faf1ff 100644 --- a/internal/config/helpers.go +++ b/internal/config/helpers.go @@ -45,7 +45,8 @@ func mustK9sHome() string { return usr.HomeDir } -func mustK9sUser() string { +// MustK9sUser establishes current user identity or fail. +func MustK9sUser() string { usr, err := user.Current() if err != nil { panic(err) @@ -57,7 +58,7 @@ func mustK9sUser() string { func EnsurePath(path string, mod os.FileMode) { dir := filepath.Dir(path) if _, err := os.Stat(dir); os.IsNotExist(err) { - if err = os.Mkdir(dir, mod); err != nil { + if err = os.MkdirAll(dir, mod); err != nil { log.Error().Msgf("Unable to create K9s home config dir: %v", err) panic(err) } diff --git a/internal/config/style.go b/internal/config/style.go index a8b38e6a..f73a2ced 100644 --- a/internal/config/style.go +++ b/internal/config/style.go @@ -34,6 +34,7 @@ type ( Status *Status `yaml:"status"` Title *Title `yaml:"title"` Yaml *Yaml `yaml:"yaml"` + Log *Log `yaml:"logs"` } // Status tracks resource status styles. @@ -47,6 +48,12 @@ type ( CompletedColor string `yaml:"completedColor"` } + // Log tracks Log styles. + Log struct { + FgColor string `yaml:"fgColor"` + BgColor string `yaml:"bgColor"` + } + // Yaml tracks yaml styles. Yaml struct { KeyColor string `yaml:"keyColor"` @@ -118,6 +125,7 @@ func newStyle() *Style { Status: newStatus(), Title: newTitle(), Yaml: newYaml(), + Log: newLog(), } } @@ -133,6 +141,14 @@ func newStatus() *Status { } } +// NewLog returns a new log style. +func newLog() *Log { + return &Log{ + FgColor: "lightskyblue", + BgColor: "black", + } +} + // NewYaml returns a new yaml style. func newYaml() *Yaml { return &Yaml{ @@ -251,6 +267,10 @@ func (s *Styles) ensure() { if s.Style.Yaml == nil { s.Style.Yaml = newYaml() } + + if s.Style.Log == nil { + s.Style.Log = newLog() + } } // FgColor returns the foreground color. diff --git a/internal/k8s/helpers.go b/internal/k8s/helpers.go index 44c46100..ca946311 100644 --- a/internal/k8s/helpers.go +++ b/internal/k8s/helpers.go @@ -2,6 +2,8 @@ package k8s import ( "math" + "path" + "strings" ) const megaByte = 1024 * 1024 @@ -17,3 +19,9 @@ func toPerc(v1, v2 float64) float64 { } return math.Round((v1 / v2) * 100) } + +func namespaced(n string) (string, string) { + ns, po := path.Split(n) + + return strings.Trim(ns, "/"), po +} diff --git a/internal/k8s/port_forward.go b/internal/k8s/port_forward.go new file mode 100644 index 00000000..d33de85c --- /dev/null +++ b/internal/k8s/port_forward.go @@ -0,0 +1,177 @@ +package k8s + +import ( + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/rs/zerolog" + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + metav1beta1 "k8s.io/apimachinery/pkg/apis/meta/v1beta1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/runtime/serializer" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/portforward" + "k8s.io/client-go/transport/spdy" + "k8s.io/kubernetes/pkg/kubectl/util" +) + +const localhost = "localhost" + +// PortForward tracks a port forward stream. +type PortForward struct { + Connection + genericclioptions.IOStreams + + stopChan, readyChan chan struct{} + logger *zerolog.Logger + active bool + path string + ports []string + age time.Time +} + +// NewPortForward returns a new port forward streamer. +func NewPortForward(c Connection, l *zerolog.Logger) *PortForward { + return &PortForward{ + Connection: c, + logger: l, + stopChan: make(chan struct{}), + readyChan: make(chan struct{}), + } +} + +// Age returns the port forward age. +func (p *PortForward) Age() string { + return time.Since(p.age).String() +} + +// Active returns the forward status. +func (p *PortForward) Active() bool { + return p.active +} + +func (p *PortForward) SetActive(b bool) { + p.active = b +} + +// Ports returns the forwarded ports mappings. +func (p *PortForward) Ports() []string { + return p.ports +} + +// Path returns the pod resource path. +func (p *PortForward) Path() string { + return p.path +} + +// Stop terminates a port forard +func (p *PortForward) Stop() { + p.logger.Debug().Msgf("<<< Stopping port forward %q %v", p.path, p.ports) + p.active = false + close(p.stopChan) +} + +// Start initiates a port foward session for a given pod and ports. +func (p *PortForward) Start(path string, ports []string) (*portforward.PortForwarder, error) { + p.path, p.ports, p.age = path, ports, time.Now() + + ns, n := namespaced(path) + pod, err := p.DialOrDie().CoreV1().Pods(ns).Get(n, metav1.GetOptions{}) + if err != nil { + return nil, err + } + + if pod.Status.Phase != v1.PodRunning { + return nil, fmt.Errorf("unable to forward port because pod is not running. Current status=%v", pod.Status.Phase) + } + + rcfg := p.RestConfigOrDie() + rcfg.GroupVersion = &schema.GroupVersion{Group: "", Version: "v1"} + rcfg.APIPath = "/api" + codecs, _ := codecs() + rcfg.NegotiatedSerializer = serializer.DirectCodecFactory{CodecFactory: codecs} + clt, err := rest.RESTClientFor(rcfg) + if err != nil { + p.logger.Debug().Msgf("Boom! %#v", err) + return nil, err + } + req := clt.Post(). + Resource("pods"). + Namespace(ns). + Name(n). + SubResource("portforward") + + return p.forwardPorts("POST", req.URL(), ports) +} + +func (p *PortForward) forwardPorts(method string, url *url.URL, ports []string) (*portforward.PortForwarder, error) { + cfg, err := p.Config().RESTConfig() + if err != nil { + return nil, err + } + transport, upgrader, err := spdy.RoundTripperFor(cfg) + if err != nil { + return nil, err + } + + dialer := spdy.NewDialer(upgrader, &http.Client{Transport: transport}, method, url) + addrs := []string{localhost} + return portforward.NewOnAddresses(dialer, addrs, ports, p.stopChan, p.readyChan, p.Out, p.ErrOut) +} + +// ---------------------------------------------------------------------------- +// Helpers... + +func codecs() (serializer.CodecFactory, runtime.ParameterCodec) { + scheme := runtime.NewScheme() + gv := schema.GroupVersion{Group: "", Version: "v1"} + metav1.AddToGroupVersion(scheme, gv) + scheme.AddKnownTypes(gv, &metav1beta1.Table{}, &metav1beta1.TableOptions{}) + scheme.AddKnownTypes(metav1beta1.SchemeGroupVersion, &metav1beta1.Table{}, &metav1beta1.TableOptions{}) + + return serializer.NewCodecFactory(scheme), runtime.NewParameterCodec(scheme) +} + +func svcPortToTargetPort(ports []string, svc v1.Service, pod v1.Pod) ([]string, error) { + var translated []string + for _, port := range ports { + localPort, remotePort := splitPort(port) + portnum, err := strconv.Atoi(remotePort) + if err != nil { + svcPort, err := util.LookupServicePortNumberByName(svc, remotePort) + if err != nil { + return nil, err + } + portnum = int(svcPort) + if localPort == remotePort { + localPort = strconv.Itoa(portnum) + } + } + containerPort, err := util.LookupContainerPortNumberByServicePort(svc, pod, int32(portnum)) + if err != nil { + return nil, err + } + if int32(portnum) != containerPort { + port = fmt.Sprintf("%s:%d", localPort, containerPort) + } + translated = append(translated, port) + } + + return translated, nil +} + +func splitPort(port string) (local, remote string) { + parts := strings.Split(port, ":") + if len(parts) == 2 { + return parts[0], parts[1] + } + + return parts[0], parts[0] +} diff --git a/internal/resource/container.go b/internal/resource/container.go index 0e4c9d32..3ecee5bf 100644 --- a/internal/resource/container.go +++ b/internal/resource/container.go @@ -4,6 +4,7 @@ import ( "bufio" "context" "strconv" + "strings" "sync" "time" @@ -158,6 +159,7 @@ func (*Container) Header(ns string) Row { "MEM", "%CPU", "%MEM", + "PORTS", "AGE", ) } @@ -223,6 +225,7 @@ func (r *Container) Fields(ns string) Row { smem, pcpu, pmem, + toStrPorts(i.Ports), toAge(r.pod.CreationTimestamp), ) } @@ -230,6 +233,21 @@ func (r *Container) Fields(ns string) Row { // ---------------------------------------------------------------------------- // Helpers... +func toStrPorts(pp []v1.ContainerPort) string { + ports := make([]string, len(pp)) + for i, p := range pp { + if len(p.Name) > 0 { + ports[i] = p.Name + ":" + } + ports[i] += strconv.Itoa(int(p.ContainerPort)) + if p.Protocol != "TCP" { + ports[i] += "╱" + string(p.Protocol) + } + } + + return strings.Join(ports, ",") +} + func toState(s v1.ContainerState) string { switch { case s.Waiting != nil: diff --git a/internal/resource/helpers.go b/internal/resource/helpers.go index e588251b..ec6a7d8a 100644 --- a/internal/resource/helpers.go +++ b/internal/resource/helpers.go @@ -35,6 +35,13 @@ const ( NAValue = "n/a" ) +func fqn(ns, n string) string { + if ns == "" { + return n + } + return ns + "/" + n +} + func empty(s []string) bool { for _, v := range s { if len(v) != 0 { diff --git a/internal/resource/list.go b/internal/resource/list.go index 95579ee7..518ea431 100644 --- a/internal/resource/list.go +++ b/internal/resource/list.go @@ -230,7 +230,7 @@ func metaFQN(m metav1.ObjectMeta) string { return m.Name } - return m.Namespace + "/" + m.Name + return fqn(m.Namespace, m.Name) } func (l *list) fetchFromStore(m *wa.Meta, ns string) (Columnars, error) { @@ -239,7 +239,6 @@ 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/pod.go b/internal/resource/pod.go index 065596c5..f57d656d 100644 --- a/internal/resource/pod.go +++ b/internal/resource/pod.go @@ -238,8 +238,8 @@ func (r *Pod) Fields(ns string) Row { cmem, pcpu, pmem, - i.Status.PodIP, - i.Spec.NodeName, + na(i.Status.PodIP), + na(i.Spec.NodeName), r.mapQOS(i.Status.QOSClass), toAge(i.ObjectMeta.CreationTimestamp), ) diff --git a/internal/resource/svc.go b/internal/resource/svc.go index a9ae820a..72208787 100644 --- a/internal/resource/svc.go +++ b/internal/resource/svc.go @@ -80,7 +80,8 @@ func (*Service) Header(ns string) Row { "TYPE", "CLUSTER-IP", "EXTERNAL-IP", - "PORT(S)", + "SELECTOR", + "PORTS", "AGE", ) } @@ -99,6 +100,7 @@ func (r *Service) Fields(ns string) Row { string(i.Spec.Type), i.Spec.ClusterIP, r.toIPs(i.Spec.Type, r.getSvcExtIPS(i)), + mapToStr(i.Spec.Selector), r.toPorts(i.Spec.Ports), toAge(i.ObjectMeta.CreationTimestamp), ) diff --git a/internal/resource/svc_test.go b/internal/resource/svc_test.go index 4ec42e78..35a3abf8 100644 --- a/internal/resource/svc_test.go +++ b/internal/resource/svc_test.go @@ -57,6 +57,7 @@ func TestSvcFields(t *testing.T) { "ClusterIP", "1.1.1.1", "2.2.2.2", + "fred=blee", "http:90►0", }, }, @@ -98,7 +99,7 @@ func TestSVCListData(t *testing.T) { assert.Equal(t, 1, len(td.Rows)) assert.Equal(t, "blee", l.GetNamespace()) row := td.Rows["blee/fred"] - assert.Equal(t, 6, len(row.Deltas)) + assert.Equal(t, 7, len(row.Deltas)) for _, d := range row.Deltas { assert.Equal(t, "", d) } @@ -141,7 +142,8 @@ func svcHeader() resource.Row { "TYPE", "CLUSTER-IP", "EXTERNAL-IP", - "PORT(S)", + "SELECTOR", + "PORTS", "AGE", } } diff --git a/internal/views/alias.go b/internal/views/alias.go index 0a0288c5..aa9bc3d0 100644 --- a/internal/views/alias.go +++ b/internal/views/alias.go @@ -11,7 +11,7 @@ import ( const ( aliasTitle = "Aliases" - aliasTitleFmt = " [aqua::b]%s[[aqua::b]%d[aqua::-]][aqua::-] " + aliasTitleFmt = " [aqua::b]%s([fuchsia::b]%d[fuchsia::-])[aqua::-] " ) type aliasView struct { @@ -24,6 +24,7 @@ type aliasView struct { func newAliasView(app *appView) *aliasView { v := aliasView{tableView: newTableView(app, aliasTitle)} { + v.SetBorderFocusColor(tcell.ColorFuchsia) v.SetSelectedStyle(tcell.ColorWhite, tcell.ColorFuchsia, tcell.AttrNone) v.colorerFn = aliasColorer v.current = app.content.GetPrimitive("main").(igniter) diff --git a/internal/views/app.go b/internal/views/app.go index 18e75447..80c8ae8c 100644 --- a/internal/views/app.go +++ b/internal/views/app.go @@ -2,7 +2,6 @@ package views import ( "context" - "fmt" "time" "github.com/derailed/k9s/internal/config" @@ -13,6 +12,7 @@ import ( "github.com/gdamore/tcell" "github.com/rs/zerolog/log" "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/client-go/tools/portforward" ) const ( @@ -23,6 +23,15 @@ const ( type ( focusHandler func(tview.Primitive) + forwarder interface { + Start(path string, ports []string) (*portforward.PortForwarder, error) + Stop() + Path() string + Ports() []string + Active() bool + Age() string + } + igniter interface { tview.Primitive @@ -56,31 +65,38 @@ type ( content *tview.Pages flashView *flashView crumbsView *crumbsView + logoView *logoView menuView *menuView clusterInfoView *clusterInfoView + cmdView *cmdView command *command focusGroup []tview.Primitive focusCurrent int focusChanged focusHandler cancel context.CancelFunc + cancelSkin context.CancelFunc cmdBuff *cmdBuff - cmdView *cmdView actions keyActions stopCh chan struct{} informer *watch.Meta + forwarders []forwarder } ) // NewApp returns a K9s app instance. func NewApp(cfg *config.Config) *appView { - v := appView{Application: tview.NewApplication(), config: cfg} + v := appView{ + Application: tview.NewApplication(), + config: cfg, + pages: tview.NewPages(), + actions: make(keyActions), + content: tview.NewPages(), + cmdBuff: newCmdBuff(':'), + } { v.refreshStyles() - v.pages = tview.NewPages() - v.actions = make(keyActions) v.menuView = newMenuView(&v) - v.content = tview.NewPages() - v.cmdBuff = newCmdBuff(':') + v.logoView = newLogoView(&v) v.cmdView = newCmdView(&v, '🐶') v.command = newCommand(&v) v.flashView = newFlashView(&v, "Initializing...") @@ -116,7 +132,7 @@ func (a *appView) Init(v string, rate int, flags *genericclioptions.ConfigFlags) header.SetDirection(tview.FlexColumn) header.AddItem(a.clusterInfoView, 35, 1, false) header.AddItem(a.menuView, 0, 1, false) - header.AddItem(a.logoView(), 26, 1, false) + header.AddItem(a.logoView, 26, 1, false) } main := tview.NewFlex() @@ -154,6 +170,18 @@ func (a *appView) bailOut() { log.Debug().Msg("<<<< Stopping Watcher") close(a.stopCh) } + + if a.cancel != nil { + a.cancel() + } + if a.cancelSkin != nil { + a.cancelSkin() + } + + for _, f := range a.forwarders { + f.Stop() + } + a.Stop() } @@ -161,10 +189,10 @@ func (a *appView) conn() k8s.Connection { return a.config.GetConnection() } -func (a *appView) stylesUpdater() (*fsnotify.Watcher, error) { +func (a *appView) stylesUpdater(ctx context.Context) error { w, err := fsnotify.NewWatcher() if err != nil { - return w, err + return err } go func() { @@ -177,27 +205,26 @@ func (a *appView) stylesUpdater() (*fsnotify.Watcher, error) { }) case err := <-w.Errors: log.Info().Err(err).Msg("Skin watcher failed") + return + case <-ctx.Done(): + w.Close() + return } } }() - if err := w.Add(config.K9sStylesFile); err != nil { - return w, err - } - - return w, nil + return w.Add(config.K9sStylesFile) } // Run starts the application loop func (a *appView) Run() { - // Only enable updater while in dev mode. + // Only enable skin updater while in dev mode. if a.version == devMode { - w, err := a.stylesUpdater() - defer func() { - if err != nil { - w.Close() - } - }() + var ctx context.Context + ctx, a.cancelSkin = context.WithCancel(context.Background()) + if err := a.stylesUpdater(ctx); err != nil { + log.Error().Err(err).Msg("Unable to track skin changes") + } } go func() { @@ -213,6 +240,23 @@ func (a *appView) Run() { } } +func (a *appView) statusReset() { + a.logoView.reset() + a.Draw() +} + +func (a *appView) status(l flashLevel, msg string) { + switch l { + case flashWarn: + a.logoView.warn(msg) + case flashInfo: + a.logoView.info(msg) + default: + a.logoView.reset() + } + a.Draw() +} + func (a *appView) keyboard(evt *tcell.EventKey) *tcell.EventKey { key := evt.Key() if key == tcell.KeyRune { @@ -287,7 +331,7 @@ func (a *appView) activateCmd(evt *tcell.EventKey) *tcell.EventKey { if a.cmdView.inCmdMode() { return evt } - a.flash(flashInfo, "Command mode activated.") + a.flashView.info("Command mode activated.") log.Debug().Msg("Entering command mode...") a.cmdBuff.setActive(true) a.cmdBuff.clear() @@ -298,7 +342,8 @@ func (a *appView) quitCmd(evt *tcell.EventKey) *tcell.EventKey { if a.cmdMode() { return evt } - a.Stop() + a.bailOut() + return nil } @@ -314,10 +359,20 @@ func (a *appView) aliasCmd(evt *tcell.EventKey) *tcell.EventKey { if a.cmdView.inCmdMode() { return evt } + a.inject(newAliasView(a)) return nil } +func (a *appView) fwdCmd(evt *tcell.EventKey) *tcell.EventKey { + if a.cmdView.inCmdMode() { + return evt + } + + a.inject(newForwardView(a)) + return nil +} + func (a *appView) noopCmd(*tcell.EventKey) *tcell.EventKey { return nil } @@ -327,10 +382,14 @@ func (a *appView) puntCmd(evt *tcell.EventKey) *tcell.EventKey { } func (a *appView) gotoResource(res string, record bool) bool { + if a.cancel != nil { + a.cancel() + } valid := a.command.run(res) if valid && record { a.command.pushCmd(res) } + return valid } @@ -360,30 +419,14 @@ func (a *appView) cmdMode() bool { return a.cmdView.inCmdMode() } -func (a *appView) flash(level flashLevel, m ...string) { - a.flashView.setMessage(level, m...) +func (a *appView) flash() *flashView { + return a.flashView } func (a *appView) setHints(h hints) { a.menuView.populateMenu(h) } -func (a *appView) logoView() tview.Primitive { - v := tview.NewTextView() - { - v.SetWordWrap(false) - v.SetWrap(false) - v.SetDynamicColors(true) - for i, s := range LogoSmall { - fmt.Fprintf(v, "[%s::b]%s", a.styles.Style.LogoColor, s) - if i+1 < len(LogoSmall) { - fmt.Fprintf(v, "\n") - } - } - } - return v -} - func (a *appView) fireFocusChanged(p tview.Primitive) { if a.focusChanged != nil { a.focusChanged(p) diff --git a/internal/views/assets/b1.txt b/internal/views/assets/b1.txt new file mode 100644 index 00000000..b4b8f111 --- /dev/null +++ b/internal/views/assets/b1.txt @@ -0,0 +1,43 @@ + +Summary: + Total: 3.3544 secs + Slowest: 0.1031 secs + Fastest: 0.0310 secs + Average: 0.0335 secs + Requests/sec: 29.8116 + + Total data: 61200 bytes + Size/request: 612 bytes + +Response time histogram: + 0.031 [1] | + 0.038 [92] |■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■ + 0.045 [6] |■■■ + 0.053 [0] | + 0.060 [0] | + 0.067 [0] | + 0.074 [0] | + 0.081 [0] | + 0.089 [0] | + 0.096 [0] | + 0.103 [1] | + + +Latency distribution: + 10% in 0.0314 secs + 25% in 0.0317 secs + 50% in 0.0320 secs + 75% in 0.0327 secs + 90% in 0.0369 secs + 95% in 0.0394 secs + 99% in 0.1031 secs + +Details (average, fastest, slowest): + DNS+dialup: 0.0001 secs, 0.0310 secs, 0.1031 secs + DNS-lookup: 0.0000 secs, 0.0000 secs, 0.0049 secs + req write: 0.0000 secs, 0.0000 secs, 0.0001 secs + resp wait: 0.0330 secs, 0.0305 secs, 0.0973 secs + resp read: 0.0005 secs, 0.0000 secs, 0.0039 secs + +Status code distribution: + [200] 100 responses \ No newline at end of file diff --git a/internal/views/assets/b2.txt b/internal/views/assets/b2.txt new file mode 100644 index 00000000..91124218 --- /dev/null +++ b/internal/views/assets/b2.txt @@ -0,0 +1,44 @@ +Summary: + Total: 3.3544 secs + Slowest: 0.1031 secs + Fastest: 0.0310 secs + Average: 0.0335 secs + Requests/sec: 29.8116 + + Total data: 61200 bytes + Size/request: 612 bytes + +Response time histogram: + 0.031 [1] | + 0.038 [92] |■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■ + 0.045 [6] |■■■ + 0.053 [0] | + 0.060 [0] | + 0.067 [0] | + 0.074 [0] | + 0.081 [0] | + 0.089 [0] | + 0.096 [0] | + 0.103 [1] | + + +Latency distribution: + 10% in 0.0314 secs + 25% in 0.0317 secs + 50% in 0.0320 secs + 75% in 0.0327 secs + 90% in 0.0369 secs + 95% in 0.0394 secs + 99% in 0.1031 secs + +Details (average, fastest, slowest): + DNS+dialup: 0.0001 secs, 0.0310 secs, 0.1031 secs + DNS-lookup: 0.0000 secs, 0.0000 secs, 0.0049 secs + req write: 0.0000 secs, 0.0000 secs, 0.0001 secs + resp wait: 0.0330 secs, 0.0305 secs, 0.0973 secs + resp read: 0.0005 secs, 0.0000 secs, 0.0039 secs + +Status code distribution: + [200] 100 responses + [404] 2 responses + [500] 10 responses \ No newline at end of file diff --git a/internal/views/assets/b3.txt b/internal/views/assets/b3.txt new file mode 100644 index 00000000..c627a305 --- /dev/null +++ b/internal/views/assets/b3.txt @@ -0,0 +1,25 @@ + +Summary: + Total: 2.3688 secs + Slowest: 0.0000 secs + Fastest: 0.0000 secs + Average: NaN secs + Requests/sec: 35.4606 + + +Response time histogram: + + +Latency distribution: + +Details (average, fastest, slowest): + DNS+dialup: NaN secs, 0.0000 secs, 0.0000 secs + DNS-lookup: NaN secs, 0.0000 secs, 0.0000 secs + req write: NaN secs, 0.0000 secs, 0.0000 secs + resp wait: NaN secs, 0.0000 secs, 0.0000 secs + resp read: NaN secs, 0.0000 secs, 0.0000 secs + +Status code distribution: + +Error distribution: + [84] Get http://localhost:8081: dial tcp [::1]:8081: connect: connection refused \ No newline at end of file diff --git a/internal/views/assets/b4.txt b/internal/views/assets/b4.txt new file mode 100644 index 00000000..66bdce0c --- /dev/null +++ b/internal/views/assets/b4.txt @@ -0,0 +1,45 @@ + +Summary: + Total: 3.3544 secs + Slowest: 0.1031 secs + Fastest: 0.0310 secs + Average: 0.0335 secs + Requests/sec: 29.8116 + + Total data: 61200 bytes + Size/request: 612 bytes + +Response time histogram: + 0.031 [1] | + 0.038 [92] |■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■ + 0.045 [6] |■■■ + 0.053 [0] | + 0.060 [0] | + 0.067 [0] | + 0.074 [0] | + 0.081 [0] | + 0.089 [0] | + 0.096 [0] | + 0.103 [1] | + + +Latency distribution: + 10% in 0.0314 secs + 25% in 0.0317 secs + 50% in 0.0320 secs + 75% in 0.0327 secs + 90% in 0.0369 secs + 95% in 0.0394 secs + 99% in 0.1031 secs + +Details (average, fastest, slowest): + DNS+dialup: 0.0001 secs, 0.0310 secs, 0.1031 secs + DNS-lookup: 0.0000 secs, 0.0000 secs, 0.0049 secs + req write: 0.0000 secs, 0.0000 secs, 0.0001 secs + resp wait: 0.0330 secs, 0.0305 secs, 0.0973 secs + resp read: 0.0005 secs, 0.0000 secs, 0.0039 secs + +Status code distribution: + [200] 100 responses + [204] 50 responses + [202] 10 responses \ No newline at end of file diff --git a/internal/views/bench.go b/internal/views/bench.go new file mode 100644 index 00000000..2a16e08d --- /dev/null +++ b/internal/views/bench.go @@ -0,0 +1,318 @@ +package views + +import ( + "context" + "fmt" + "io/ioutil" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" + + "github.com/derailed/k9s/internal/resource" + "github.com/derailed/tview" + "github.com/fsnotify/fsnotify" + "github.com/gdamore/tcell" + "github.com/rs/zerolog/log" +) + +const ( + benchTitle = "Benchmarks" + benchTitleFmt = " [seagreen::b]%s([fuchsia::b]%d[fuchsia::-])[seagreen::-] " +) + +var ( + totalRx = regexp.MustCompile(`Total:\s+([0-9.]+)\ssecs`) + reqRx = regexp.MustCompile(`Requests/sec:\s+([0-9.]+)`) + okRx = regexp.MustCompile(`\[2\d{2}\]\s+(\d+)\s+responses`) + errRx = regexp.MustCompile(`\[[4-5]\d{2}\]\s+(\d+)\s+responses`) + toastRx = regexp.MustCompile(`Error distribution`) + benchHeader = resource.Row{"NAMESPACE", "NAME", "STATUS", "TIME", "REQ/S", "2XX", "4XX/5XX", "REPORT", "AGE"} +) + +type benchView struct { + *tview.Pages + + app *appView + current igniter + cancel context.CancelFunc + selectedItem string + selectedRow int + actions keyActions +} + +func newBenchView(app *appView) *benchView { + v := benchView{ + Pages: tview.NewPages(), + actions: make(keyActions), + app: app, + current: app.content.GetPrimitive("main").(igniter), + } + + tv := newTableView(app, benchTitle) + { + tv.SetSelectionChangedFunc(v.selChanged) + tv.SetBorderFocusColor(tcell.ColorSeaGreen) + tv.SetSelectedStyle(tcell.ColorWhite, tcell.ColorSeaGreen, tcell.AttrNone) + tv.colorerFn = benchColorer + tv.currentNS = "" + } + v.AddPage("table", tv, true, true) + + details := newDetailsView(app, v.backCmd) + v.AddPage("details", details, true, false) + + v.registerActions() + + return &v +} + +// Init the view. +func (v *benchView) init(ctx context.Context, _ string) { + if err := v.watchBenchDir(ctx); err != nil { + log.Error().Err(err).Msg("Benchdir watch failed!") + v.app.flash().errf("Unable to watch benchmarks directory %s", err) + } + + tv := v.getTV() + v.refresh() + tv.sortCol.index, tv.sortCol.asc = tv.nameColIndex()+7, true + tv.refresh() + v.app.SetFocus(tv) +} + +func (v *benchView) refresh() { + tv := v.getTV() + tv.update(v.hydrate()) + tv.resetTitle() + v.selChanged(v.selectedRow, 0) +} + +func (v *benchView) getTV() *tableView { + if vu, ok := v.GetPrimitive("table").(*tableView); ok { + return vu + } + return nil +} + +func (v *benchView) getDetails() *detailsView { + if vu, ok := v.GetPrimitive("details").(*detailsView); ok { + return vu + } + return nil +} + +func (v *benchView) registerActions() { + v.actions[KeyP] = newKeyAction("Previous", v.app.prevCmd, false) + v.actions[tcell.KeyEnter] = newKeyAction("Enter", v.enterCmd, false) + v.actions[tcell.KeyCtrlD] = newKeyAction("Delete", v.deleteCmd, false) + + vu := v.getTV() + vu.setActions(v.actions) + v.app.setHints(vu.hints()) +} + +func (v *benchView) getTitle() string { + return benchTitle +} + +func (v *benchView) selChanged(r, c int) { + tv := v.getTV() + if r == 0 || tv.GetCell(r, 0) == nil { + v.selectedItem = "" + return + } + v.selectedRow = r + v.selectedItem = strings.TrimSpace(tv.GetCell(r, 7).Text) + v.getTV().cmdBuff.setActive(false) +} + +func (v *benchView) sortColCmd(col int, asc bool) func(evt *tcell.EventKey) *tcell.EventKey { + return func(evt *tcell.EventKey) *tcell.EventKey { + tv := v.getTV() + tv.sortCol.index, tv.sortCol.asc = tv.nameColIndex()+col, asc + tv.refresh() + + return nil + } +} + +func (v *benchView) enterCmd(evt *tcell.EventKey) *tcell.EventKey { + if v.getTV().cmdBuff.isActive() { + return v.getTV().filterCmd(evt) + } + + sel := v.selectedItem + if sel == "" { + return nil + } + + data, err := ioutil.ReadFile(filepath.Join(K9sBenchDir, sel)) + if err != nil { + log.Error().Err(err).Msg("Read failed") + v.app.flash().errf("Unable to load bench file %s", err) + return nil + } + log.Debug().Msgf("Bench %v", string(data)) + vu := v.getDetails() + vu.Clear() + fmt.Fprintln(vu, string(data)) + { + vu.setCategory("Bench") + vu.setTitle(sel) + vu.SetTextColor(tcell.ColorAqua) + vu.ScrollToBeginning() + } + v.SwitchToPage("details") + + return nil +} + +func (v *benchView) deleteCmd(evt *tcell.EventKey) *tcell.EventKey { + sel := v.selectedItem + if sel == "" { + return nil + } + + showModal(v.Pages, fmt.Sprintf("Deleting `%s are you sure?", sel), "table", func() { + if err := os.Remove(filepath.Join(K9sBenchDir, sel)); err != nil { + v.app.flash().errf("Unable to delete file %s", err) + log.Error().Err(err).Msg("Delete failed") + return + } + v.refresh() + v.app.flash().infof("Benchmark %s deleted!", sel) + }) + + return nil +} + +func (v *benchView) backCmd(evt *tcell.EventKey) *tcell.EventKey { + if v.cancel != nil { + v.cancel() + } + v.SwitchToPage("table") + + return nil +} + +func (v *benchView) hints() hints { + return v.CurrentPage().Item.(hinter).hints() +} + +func (v *benchView) hydrate() resource.TableData { + cmds := helpCmds(v.app.conn()) + + data := resource.TableData{ + Header: benchHeader, + Rows: make(resource.RowEvents, len(cmds)), + Namespace: resource.AllNamespaces, + } + + ff, err := ioutil.ReadDir(K9sBenchDir) + if err != nil { + log.Error().Err(err).Msg("Reading bench dir") + v.app.flash().errf("Unable to read bench directory %s", err) + } + + for _, f := range ff { + bench, err := ioutil.ReadFile(filepath.Join(K9sBenchDir, f.Name())) + if err != nil { + continue + } + tokens := strings.Split(f.Name(), "_") + fields := resource.Row{0: tokens[0], 1: tokens[1], 7: f.Name(), 8: time.Since(f.ModTime()).String()} + augmentRow(fields, string(bench)) + data.Rows[f.Name()] = &resource.RowEvent{ + Action: resource.New, + Fields: fields, + Deltas: fields, + } + } + + return data +} + +func augmentRow(fields resource.Row, data string) { + if len(data) == 0 { + return + } + + col := 2 + fields[col] = "pass" + mf := toastRx.FindAllStringSubmatch(data, 1) + if len(mf) > 0 { + fields[col] = "fail" + } + col++ + + mt := totalRx.FindAllStringSubmatch(data, 1) + if len(mt) > 0 { + fields[col] = mt[0][1] + } + col++ + + mr := reqRx.FindAllStringSubmatch(data, 1) + if len(mr) > 0 { + fields[col] = mr[0][1] + } + col++ + + ms := okRx.FindAllStringSubmatch(data, -1) + fields[col] = "0" + if len(ms) > 0 { + var sum int + for _, m := range ms { + if m, err := strconv.Atoi(string(m[1])); err == nil { + sum += m + } + } + fields[col] = strconv.Itoa(sum) + } + col++ + + me := errRx.FindAllStringSubmatch(data, -1) + fields[col] = "0" + if len(me) > 0 { + var sum int + for _, m := range me { + if m, err := strconv.Atoi(string(m[1])); err == nil { + sum += m + } + } + fields[col] = strconv.Itoa(sum) + } +} + +func (v *benchView) resetTitle() { + v.SetTitle(fmt.Sprintf(benchTitleFmt, benchTitle, v.getTV().GetRowCount()-1)) +} + +func (v *benchView) watchBenchDir(ctx context.Context) error { + w, err := fsnotify.NewWatcher() + if err != nil { + return err + } + + go func() { + for { + select { + case evt := <-w.Events: + log.Debug().Msgf("Bench event %#v", evt) + v.app.QueueUpdateDraw(func() { + v.refresh() + }) + case err := <-w.Errors: + log.Info().Err(err).Msg("Skin watcher failed") + return + case <-ctx.Done(): + w.Close() + return + } + } + }() + + return w.Add(K9sBenchDir) +} diff --git a/internal/views/bench_test.go b/internal/views/bench_test.go new file mode 100644 index 00000000..f0e21c5b --- /dev/null +++ b/internal/views/bench_test.go @@ -0,0 +1,44 @@ +package views + +import ( + "io/ioutil" + "testing" + + "github.com/derailed/k9s/internal/resource" + "github.com/stretchr/testify/assert" +) + +func TestAugmentRow(t *testing.T) { + uu := map[string]struct { + file string + e resource.Row + }{ + "cool": { + "assets/b1.txt", + resource.Row{"pass", "3.3544", "29.8116", "100", "0"}, + }, + "2XX": { + "assets/b4.txt", + resource.Row{"pass", "3.3544", "29.8116", "160", "0"}, + }, + "4XX/5XX": { + "assets/b2.txt", + resource.Row{"pass", "3.3544", "29.8116", "100", "12"}, + }, + "toast": { + "assets/b3.txt", + resource.Row{"fail", "2.3688", "35.4606", "0", "0"}, + }, + } + + for k, u := range uu { + t.Run(k, func(t *testing.T) { + data, err := ioutil.ReadFile(u.file) + + assert.Nil(t, err) + fields := make(resource.Row, 8) + augmentRow(fields, string(data)) + assert.Equal(t, u.e, fields[2:7]) + }) + } +} diff --git a/internal/views/benchmark.go b/internal/views/benchmark.go new file mode 100644 index 00000000..211d87ce --- /dev/null +++ b/internal/views/benchmark.go @@ -0,0 +1,99 @@ +package views + +import ( + "bytes" + "fmt" + "io" + "io/ioutil" + "net/http" + "os" + "path/filepath" + "time" + + "github.com/derailed/k9s/internal/config" + "github.com/rakyll/hey/requester" + "github.com/rs/zerolog/log" +) + +const benchFmat = "%s_%s_%d.txt" + +// K9sBenchDir directory to store K9s benchmark files. +var K9sBenchDir = filepath.Join(os.TempDir(), fmt.Sprintf("k9s-bench-%s", config.MustK9sUser())) + +type ( + benchmark struct { + canceled bool + config benchConfig + worker *requester.Work + } + + benchConfig struct { + Method, Path, URL string + C, N int + } +) + +func newBenchmark(cfg benchConfig) (*benchmark, error) { + b := benchmark{config: cfg} + return &b, b.init() +} + +func (b *benchmark) init() error { + req, err := http.NewRequest(b.config.Method, b.config.URL, nil) + if err != nil { + return err + } + + b.worker = &requester.Work{ + Request: req, + N: b.config.N, + C: b.config.C, + Output: "", + } + + return nil +} + +func (b *benchmark) annuled() bool { + return b.canceled +} + +func (b *benchmark) cancel() { + b.canceled = true + b.worker.Stop() +} + +func (b *benchmark) run(done func()) { + buff := new(bytes.Buffer) + b.worker.Writer = buff + b.worker.Run() + if !b.canceled { + if err := b.save(buff); err != nil { + log.Error().Err(err).Msg("Saving benchmark") + } + } + done() +} + +func (b *benchmark) save(r io.Reader) error { + if err := os.MkdirAll(K9sBenchDir, 0777); err != nil { + return err + } + + ns, n := namespaced(b.config.Path) + file := filepath.Join(K9sBenchDir, fmt.Sprintf(benchFmat, ns, n, time.Now().UnixNano())) + f, err := os.Create(file) + if err != nil { + return err + } + defer f.Close() + + bb, err := ioutil.ReadAll(r) + if err != nil { + return err + } + + f.Write(bb) + + return nil +} diff --git a/internal/views/colorer.go b/internal/views/colorer.go index 81984c7b..0aa39366 100644 --- a/internal/views/colorer.go +++ b/internal/views/colorer.go @@ -29,24 +29,45 @@ func defaultColorer(ns string, r *resource.RowEvent) tcell.Color { return c } +func forwardColorer(string, *resource.RowEvent) tcell.Color { + return tcell.ColorSkyblue +} + +func benchColorer(ns string, r *resource.RowEvent) tcell.Color { + c := tcell.ColorPaleGreen + + statusCol := 2 + if strings.TrimSpace(r.Fields[statusCol]) != "pass" { + c = errColor + } + + return c +} + func aliasColorer(string, *resource.RowEvent) tcell.Color { return tcell.ColorFuchsia } func rbacColorer(ns string, r *resource.RowEvent) tcell.Color { - c := defaultColorer(ns, r) - - // return tcell.ColorDarkOliveGreen - return c + return defaultColorer(ns, r) } func podColorer(ns string, r *resource.RowEvent) tcell.Color { c := defaultColorer(ns, r) - statusCol := 3 + readyCol := 2 if len(ns) != 0 { - statusCol = 2 + readyCol = 1 } + statusCol := readyCol + 1 + + tokens := strings.Split(strings.TrimSpace(r.Fields[readyCol]), "/") + if len(tokens) == 2 && (tokens[0] == "0" || tokens[0] != tokens[1]) { + if strings.TrimSpace(r.Fields[statusCol]) != "Completed" { + c = errColor + } + } + switch strings.TrimSpace(r.Fields[statusCol]) { case "ContainerCreating", "PodInitializing": return addColor @@ -59,15 +80,28 @@ func podColorer(ns string, r *resource.RowEvent) tcell.Color { c = errColor } + return c +} + +func containerColorer(ns string, r *resource.RowEvent) tcell.Color { + c := defaultColorer(ns, r) + readyCol := 2 - if len(ns) != 0 { - readyCol = 1 + if strings.TrimSpace(r.Fields[readyCol]) == "false" { + c = errColor } - tokens := strings.Split(strings.TrimSpace(r.Fields[readyCol]), "/") - if len(tokens) == 2 && (tokens[0] == "0" || tokens[0] != tokens[1]) { - if strings.TrimSpace(r.Fields[statusCol]) != "Completed" { - c = errColor - } + + stateCol := readyCol + 1 + switch strings.TrimSpace(r.Fields[stateCol]) { + case "ContainerCreating", "PodInitializing": + return addColor + case "Terminating", "Initialized": + return highlightColor + case "Completed": + return completedColor + case "Running": + default: + c = errColor } return c diff --git a/internal/views/command.go b/internal/views/command.go index 94bf9d35..d7a8bc56 100644 --- a/internal/views/command.go +++ b/internal/views/command.go @@ -1,7 +1,6 @@ package views import ( - "fmt" "regexp" "github.com/derailed/k9s/internal/resource" @@ -58,6 +57,12 @@ func (c *command) run(cmd string) bool { case cmd == "?", cmd == "help": c.app.inject(newHelpView(c.app)) return true + case cmd == "pf": + c.app.inject(newForwardView(c.app)) + return true + case cmd == "be": + c.app.inject(newBenchView(c.app)) + return true case cmd == "alias": c.app.inject(newAliasView(c.app)) return true @@ -84,7 +89,7 @@ func (c *command) run(cmd string) bool { v.setDecorateFn(res.decorateFn) } const fmat = "Viewing resource %s..." - c.app.flash(flashInfo, fmt.Sprintf(fmat, res.title)) + c.app.flash().infof(fmat, res.title) log.Debug().Msgf("Running command %s", cmd) c.exec(cmd, v) return true @@ -93,7 +98,7 @@ func (c *command) run(cmd string) bool { res, ok := allCRDs(c.app.conn())[cmd] if !ok { - c.app.flash(flashWarn, fmt.Sprintf("Huh? `%s` command not found", cmd)) + c.app.flash().warnf("Huh? `%s` command not found", cmd) return false } @@ -108,6 +113,7 @@ func (c *command) run(cmd string) bool { ) v.setColorerFn(defaultColorer) c.exec(cmd, v) + return true } diff --git a/internal/views/container.go b/internal/views/container.go index 41523a6e..4f57e9f7 100644 --- a/internal/views/container.go +++ b/internal/views/container.go @@ -1,9 +1,15 @@ package views import ( + "errors" + "strings" + + "github.com/derailed/k9s/internal/k8s" "github.com/derailed/k9s/internal/resource" + "github.com/derailed/tview" "github.com/gdamore/tcell" "github.com/rs/zerolog/log" + "k8s.io/client-go/tools/portforward" ) type containerView struct { @@ -18,6 +24,7 @@ func newContainerView(t string, app *appView, list resource.List, path string, e { v.path = &path v.extraActionsFn = v.extraActions + v.colorerFn = containerColorer v.current = app.content.GetPrimitive("main").(igniter) v.exitFn = exitFn } @@ -52,6 +59,12 @@ func (v *containerView) logsCmd(evt *tcell.EventKey) *tcell.EventKey { if !v.rowSelected() { return evt } + + cell := v.getTV().GetCell(v.selectedRow, 3) + if cell != nil && strings.TrimSpace(cell.Text) != "Running" { + v.app.flash().err(errors.New("No logs for a non running container")) + return evt + } v.showLogs(v.selectedItem, v.list.GetName(), v, false) return nil @@ -76,38 +89,28 @@ func (v *containerView) shellCmd(evt *tcell.EventKey) *tcell.EventKey { if !v.rowSelected() { return evt } - log.Debug().Msgf("Selected %s", v.selectedItem) - v.shellIn(*v.path, v.selectedItem) + + v.suspend() + { + shellIn(v.app, *v.path, v.selectedItem) + } + v.resume() return nil } -func (v *containerView) shellIn(path, co string) { - ns, po := namespaced(path) - args := make([]string, 0, 12) - args = append(args, "exec", "-it") - args = append(args, "--context", v.app.config.K9s.CurrentContext) - args = append(args, "-n", ns) - args = append(args, po) - if len(co) != 0 { - args = append(args, "-c", co) - } - args = append(args, "--", "sh") - log.Debug().Msgf("Shell args %v", args) - runK(true, v.app, args...) -} - func (v *containerView) extraActions(aa keyActions) { aa[KeyL] = newKeyAction("Logs", v.logsCmd, true) - aa[KeyShiftL] = newKeyAction("Prev Logs", v.prevLogsCmd, true) + aa[KeyShiftF] = newKeyAction("PortFwd", v.portFwdCmd, true) + aa[KeyShiftL] = newKeyAction("Logs Previous", 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(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) + 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 { @@ -120,8 +123,87 @@ func (v *containerView) sortColCmd(col int, asc bool) func(evt *tcell.EventKey) } } +func (v *containerView) portFwdCmd(evt *tcell.EventKey) *tcell.EventKey { + if !v.rowSelected() { + return evt + } + + cell := v.getTV().GetCell(v.selectedRow, 10) + ports := strings.Split(cell.Text, ",") + if len(ports) == 0 { + v.app.flash().err(errors.New("No ports to foward to")) + return nil + } + port := strings.TrimSpace(ports[0]) + if port == "" { + v.app.flash().err(errors.New("No ports to foward to")) + return nil + } + f := tview.NewForm() + f.SetItemPadding(0) + f.SetButtonsAlign(tview.AlignCenter). + SetButtonBackgroundColor(tview.Styles.PrimitiveBackgroundColor). + SetButtonTextColor(tview.Styles.PrimaryTextColor). + SetLabelColor(tcell.ColorAqua). + SetFieldTextColor(tcell.ColorOrange) + + f1, f2 := port, port + f.AddInputField("Pod Port:", f1, 10, nil, func(changed string) { + f1 = changed + }) + f.AddInputField("Local Port:", f2, 10, nil, func(changed string) { + f2 = changed + }) + + f.AddButton("OK", func() { + pf := k8s.NewPortForward(v.app.conn(), &log.Logger) + ports := []string{f2 + ":" + f1} + fw, err := pf.Start(*v.path, ports) + if err != nil { + log.Error().Err(err).Msg("Fort Forward") + v.app.flash().errf("PortForward failed! %v", err) + return + } + + log.Debug().Msgf(">>> Starting port forward %q %v", *v.path, ports) + go func(f *portforward.PortForwarder) { + v.app.QueueUpdate(func() { + v.app.forwarders = append(v.app.forwarders, pf) + v.app.flash().infof("PortForward activated %s:%s", pf.Path(), pf.Ports()[0]) + v.app.gotoResource("pf", true) + }) + pf.SetActive(true) + if err := f.ForwardPorts(); err != nil { + v.app.forwarders = v.app.forwarders[:len(v.app.forwarders)-1] + v.app.QueueUpdate(func() { + pf.SetActive(false) + log.Error().Err(err).Msg("Port forward failed") + v.app.flash().errf("PortForward failed %s", err) + }) + } + }(fw) + }) + f.AddButton("Cancel", func() { + v.app.flash().info("Canceled!!") + v.dismissModal() + }) + + modal := tview.NewModalForm("", f) + modal.SetDoneFunc(func(_ int, b string) { + v.dismissModal() + }) + v.AddPage("dialog", modal, false, false) + v.ShowPage("dialog") + + return nil +} + +func (v *containerView) dismissModal() { + v.RemovePage("dialog") + v.switchPage(v.list.GetName()) +} + func (v *containerView) backCmd(evt *tcell.EventKey) *tcell.EventKey { - // v.app.inject(v.current) v.exitFn() return nil diff --git a/internal/views/context.go b/internal/views/context.go index feed63e7..0dc7993f 100644 --- a/internal/views/context.go +++ b/internal/views/context.go @@ -30,7 +30,7 @@ func (v *contextView) useCmd(evt *tcell.EventKey) *tcell.EventKey { return evt } if err := v.useContext(v.selectedItem); err != nil { - v.app.flash(flashWarn, err.Error()) + v.app.flash().err(err) return evt } @@ -57,14 +57,9 @@ func (v *contextView) useContext(name string) error { } v.app.startInformer() - // // Update cluster info on context switch. - // v.app.QueueUpdateDraw(func() { - // v.app.clusterInfoView.refresh() - // }) - v.app.config.Reset() v.app.config.Save() - v.app.flash(flashInfo, "Switching context to", ctx) + v.app.flash().infof("Switching context to %s", ctx) v.refresh() if tv, ok := v.GetPrimitive("ctx").(*tableView); ok { tv.Select(0, 0) diff --git a/internal/views/cronjob.go b/internal/views/cronjob.go index e96ffafe..8a1ece49 100644 --- a/internal/views/cronjob.go +++ b/internal/views/cronjob.go @@ -1,8 +1,6 @@ package views import ( - "fmt" - "github.com/derailed/k9s/internal/resource" "github.com/gdamore/tcell" ) @@ -26,11 +24,11 @@ func (v *cronJobView) trigger(evt *tcell.EventKey) *tcell.EventKey { return evt } - v.app.flash(flashInfo, fmt.Sprintf("Triggering %s %s", v.list.GetName(), v.selectedItem)) if err := v.list.Resource().(resource.Runner).Run(v.selectedItem); err != nil { - v.app.flash(flashErr, "Boom!", err.Error()) + v.app.flash().errf("Cronjob trigger failed %v", err) return evt } + v.app.flash().infof("Triggering %s %s", v.list.GetName(), v.selectedItem) return nil } diff --git a/internal/views/details.go b/internal/views/details.go index a046ce86..92f77caf 100644 --- a/internal/views/details.go +++ b/internal/views/details.go @@ -114,7 +114,7 @@ func (v *detailsView) activateCmd(evt *tcell.EventKey) *tcell.EventKey { func (v *detailsView) searchCmd(evt *tcell.EventKey) *tcell.EventKey { if v.cmdBuff.isActive() && !v.cmdBuff.empty() { - v.app.flash(flashInfo, fmt.Sprintf("Searching for %s...", v.cmdBuff)) + v.app.flash().infof("Searching for %s...", v.cmdBuff) v.search(evt) highlights := v.GetHighlights() if len(highlights) > 0 { @@ -135,15 +135,15 @@ func (v *detailsView) search(evt *tcell.EventKey) { v.SetText(v.decorateLines(v.GetText(false), v.cmdBuff.String())) if v.cmdBuff.empty() { - v.app.flash(flashWarn, "Clearing out search query...") + v.app.flash().info("Clearing out search query...") v.refreshTitle() return } if v.numSelections == 0 { - v.app.flash(flashWarn, "No matches found!") + v.app.flash().warn("No matches found!") return } - v.app.flash(flashWarn, fmt.Sprintf("Found <%d> matches! / for next/previous", v.numSelections)) + v.app.flash().infof("Found <%d> matches! / for next/previous", v.numSelections) } func (v *detailsView) nextCmd(evt *tcell.EventKey) *tcell.EventKey { @@ -154,7 +154,7 @@ func (v *detailsView) nextCmd(evt *tcell.EventKey) *tcell.EventKey { index, _ := strconv.Atoi(highlights[0]) index = (index + 1) % v.numSelections if index+1 == v.numSelections { - v.app.flash(flashInfo, "Search hit BOTTOM, continuing at TOP") + v.app.flash().info("Search hit BOTTOM, continuing at TOP") } v.Highlight(strconv.Itoa(index)).ScrollToHighlight() return nil @@ -168,7 +168,7 @@ func (v *detailsView) prevCmd(evt *tcell.EventKey) *tcell.EventKey { index, _ := strconv.Atoi(highlights[0]) index = (index - 1 + v.numSelections) % v.numSelections if index == 0 { - v.app.flash(flashInfo, "Search hit TOP, continuing at BOTTOM") + v.app.flash().info("Search hit TOP, continuing at BOTTOM") } v.Highlight(strconv.Itoa(index)).ScrollToHighlight() return nil @@ -196,13 +196,9 @@ func (v *detailsView) refreshTitle() { func (v *detailsView) setTitle(t string) { v.title = t - fmat := strings.Replace(detailsTitleFmt, "[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, v.category, t) + title := skinTitle(fmt.Sprintf(detailsTitleFmt, v.category, t), v.app.styles.Style) if !v.cmdBuff.empty() { - fmat := strings.Replace(searchFmt, "[fg:bg", "["+v.app.styles.Style.Title.FgColor+":"+v.app.styles.Style.Title.BgColor, -1) - fmat = strings.Replace(fmat, "[filter", "["+v.app.styles.Style.Title.FilterColor, 1) - title += fmt.Sprintf(fmat, v.cmdBuff.String()) + title += skinTitle(fmt.Sprintf(searchFmt, v.cmdBuff.String()), v.app.styles.Style) } v.SetTitle(title) } diff --git a/internal/views/dp.go b/internal/views/dp.go index 095cb825..e5085e73 100644 --- a/internal/views/dp.go +++ b/internal/views/dp.go @@ -49,7 +49,7 @@ func (v *deployView) showPodsCmd(evt *tcell.EventKey) *tcell.EventKey { dep, err := d.Get(ns, n) if err != nil { log.Error().Err(err).Msgf("Fetching Deployment %s", v.selectedItem) - v.app.flash(flashErr, err.Error()) + v.app.flash().err(err) return evt } dp := dep.(*v1.Deployment) @@ -57,7 +57,7 @@ func (v *deployView) showPodsCmd(evt *tcell.EventKey) *tcell.EventKey { sel, err := metav1.LabelSelectorAsSelector(dp.Spec.Selector) if err != nil { log.Error().Err(err).Msgf("Converting selector for Deployment %s", v.selectedItem) - v.app.flash(flashErr, err.Error()) + v.app.flash().err(err) return evt } showPods(v.app, ns, "Deployment", v.selectedItem, sel.String(), "", v.backCmd) diff --git a/internal/views/ds.go b/internal/views/ds.go index fcd10924..29aaccac 100644 --- a/internal/views/ds.go +++ b/internal/views/ds.go @@ -49,7 +49,7 @@ func (v *daemonSetView) showPodsCmd(evt *tcell.EventKey) *tcell.EventKey { dset, err := d.Get(ns, n) if err != nil { log.Error().Err(err).Msgf("Fetching DeaemonSet %s", v.selectedItem) - v.app.flash(flashErr, err.Error()) + v.app.flash().err(err) return evt } ds := dset.(*extv1beta1.DaemonSet) @@ -57,7 +57,7 @@ func (v *daemonSetView) showPodsCmd(evt *tcell.EventKey) *tcell.EventKey { sel, err := metav1.LabelSelectorAsSelector(ds.Spec.Selector) if err != nil { log.Error().Err(err).Msgf("Converting selector for DaemonSet %s", v.selectedItem) - v.app.flash(flashErr, err.Error()) + v.app.flash().err(err) return evt } showPods(v.app, "", "DaemonSet", v.selectedItem, sel.String(), "", v.backCmd) diff --git a/internal/views/exec.go b/internal/views/exec.go index 8201177c..1bfdbc1b 100644 --- a/internal/views/exec.go +++ b/internal/views/exec.go @@ -32,7 +32,7 @@ func runK(clear bool, app *appView, args ...string) bool { } if err := execute(clear, bin, args...); err != nil { log.Error().Msgf("Command exited: %T %v %v", err, err, args) - app.flash(flashErr, "Command exited:", err.Error()) + app.flash().errf("Command exited: %v", err) } }) } diff --git a/internal/views/flash.go b/internal/views/flash.go index db659d59..16fc6e29 100644 --- a/internal/views/flash.go +++ b/internal/views/flash.go @@ -2,6 +2,7 @@ package views import ( "context" + "fmt" "strings" "time" @@ -44,6 +45,30 @@ func newFlashView(app *appView, m string) *flashView { return &f } +func (v *flashView) info(msg string) { + v.setMessage(flashInfo, msg) +} + +func (v *flashView) infof(fmat string, args ...interface{}) { + v.info(fmt.Sprintf(fmat, args...)) +} + +func (v *flashView) warn(msg string) { + v.setMessage(flashWarn, msg) +} + +func (v *flashView) warnf(fmat string, args ...interface{}) { + v.warn(fmt.Sprintf(fmat, args...)) +} + +func (v *flashView) err(err error) { + v.setMessage(flashErr, err.Error()) +} + +func (v *flashView) errf(fmat string, args ...interface{}) { + v.setMessage(flashErr, fmt.Sprintf(fmat, args...)) +} + func (v *flashView) setMessage(level flashLevel, msg ...string) { if v.cancel != nil { v.cancel() diff --git a/internal/views/forward.go b/internal/views/forward.go new file mode 100644 index 00000000..ccca4dfa --- /dev/null +++ b/internal/views/forward.go @@ -0,0 +1,291 @@ +package views + +import ( + "context" + "errors" + "fmt" + "runtime" + "strings" + "time" + + "github.com/derailed/k9s/internal/resource" + "github.com/derailed/tview" + "github.com/gdamore/tcell" + "github.com/rs/zerolog/log" +) + +const ( + forwardTitle = "Port Forwards" + forwardTitleFmt = " [aqua::b]%s([fuchsia::b]%d[fuchsia::-])[aqua::-] " +) + +type forwardView struct { + *tview.Pages + + app *appView + current igniter + cancel context.CancelFunc + bench *benchmark +} + +func newForwardView(app *appView) *forwardView { + v := forwardView{ + Pages: tview.NewPages(), + app: app, + } + + tv := newTableView(app, forwardTitle) + tv.SetBorderFocusColor(tcell.ColorDodgerBlue) + tv.SetSelectedStyle(tcell.ColorWhite, tcell.ColorDodgerBlue, tcell.AttrNone) + tv.colorerFn = forwardColorer + tv.currentNS = "" + v.AddPage("table", tv, true, true) + + v.current = app.content.GetPrimitive("main").(igniter) + v.registerActions() + + return &v +} + +// Init the view. +func (v *forwardView) init(context.Context, string) { + tv := v.getTV() + v.refresh() + tv.sortCol.index, tv.sortCol.asc = tv.nameColIndex()+4, true + tv.refresh() + tv.Select(1, 0) + v.app.SetFocus(tv) +} + +func (v *forwardView) getTV() *tableView { + if vu, ok := v.GetPrimitive("table").(*tableView); ok { + return vu + } + + return nil +} + +func (v *forwardView) refresh() { + tv := v.getTV() + tv.update(v.hydrate()) + v.app.SetFocus(tv) + tv.resetTitle() +} + +func (v *forwardView) registerActions() { + tv := v.getTV() + tv.actions[tcell.KeyCtrlB] = newKeyAction("Bench", v.benchCmd, true) + tv.actions[KeyAltB] = newKeyAction("Bench Stop", v.benchStopCmd, true) + tv.actions[tcell.KeyCtrlD] = newKeyAction("Delete", v.deleteCmd, true) + tv.actions[KeySlash] = newKeyAction("Filter", tv.activateCmd, false) + tv.actions[KeyP] = newKeyAction("Previous", v.app.prevCmd, false) + tv.actions[KeyShiftP] = newKeyAction("Sort Ports", v.sortColCmd(2, true), true) + tv.actions[KeyShiftU] = newKeyAction("Sort URL", v.sortColCmd(4, true), true) +} + +func (v *forwardView) getTitle() string { + return forwardTitle +} + +func (v *forwardView) sortColCmd(col int, asc bool) func(evt *tcell.EventKey) *tcell.EventKey { + return func(evt *tcell.EventKey) *tcell.EventKey { + tv := v.getTV() + tv.sortCol.index, tv.sortCol.asc = tv.nameColIndex()+col, asc + v.refresh() + + return nil + } +} + +func (v *forwardView) benchStopCmd(evt *tcell.EventKey) *tcell.EventKey { + if v.bench != nil { + log.Debug().Msg(">>> Benchmark canceled!!") + v.app.flash().info("Benchmark canceled!") + v.bench.cancel() + v.bench = nil + } + v.app.statusReset() + + return nil +} + +func (v *forwardView) benchCmd(evt *tcell.EventKey) *tcell.EventKey { + sel := v.getSelectedItem() + if sel == "" { + return nil + } + + if v.bench != nil { + v.app.flash().err(errors.New("Only one benchmark allowed at a time")) + return nil + } + + tv := v.getTV() + r, _ := tv.GetSelection() + url := strings.TrimSpace(tv.GetCell(r, 4).Text) + log.Debug().Msgf("Go Routines before %d", runtime.NumGoroutine()) + cfg := benchConfig{ + Method: "GET", + Path: sel, + URL: url, + C: 1, + N: 200, + } + var err error + if v.bench, err = newBenchmark(cfg); err != nil { + log.Error().Err(err).Msg("Bench failed!") + v.app.flash().errf("Bench failed %v", err) + v.app.statusReset() + return nil + } + + v.app.status(flashWarn, "Starting Benchmark...") + log.Debug().Msg("Bench starting...") + go v.bench.run(func() { + log.Debug().Msg("Bench Completed!") + v.app.QueueUpdate(func() { + v.bench = nil + v.app.flash().infof("Benchmark for %s is done!", sel) + v.app.status(flashInfo, "Benchmark Completed!") + go func() { + <-time.After(2 * time.Second) + v.app.QueueUpdate(func() { + v.app.statusReset() + }) + }() + }) + }) + log.Debug().Msgf("Go Routines after %d", runtime.NumGoroutine()) + + return nil +} + +func (v *forwardView) getSelectedItem() string { + tv := v.getTV() + r, _ := tv.GetSelection() + if r == 0 { + return "" + } + + return fqn(strings.TrimSpace(tv.GetCell(r, 0).Text), strings.TrimSpace(tv.GetCell(r, 1).Text)) +} + +func (v *forwardView) deleteCmd(evt *tcell.EventKey) *tcell.EventKey { + tv := v.getTV() + if !tv.cmdBuff.empty() { + tv.cmdBuff.reset() + return nil + } + + sel := v.getSelectedItem() + if sel == "" { + return nil + } + + showModal(v.Pages, fmt.Sprintf("Deleting `%s are you sure?", sel), "table", func() { + index := -1 + for i, f := range v.app.forwarders { + if sel == f.Path() { + index = i + } + } + if index == -1 { + return + } + if index == 0 && len(v.app.forwarders) == 1 { + v.app.forwarders = []forwarder{} + } else { + v.app.forwarders = append(v.app.forwarders[:index], v.app.forwarders[index+1:]...) + } + v.getTV().update(v.hydrate()) + v.app.flash().infof("PortForward %s deleted!", sel) + }) + + return nil +} + +func (v *forwardView) backCmd(evt *tcell.EventKey) *tcell.EventKey { + if v.cancel != nil { + v.cancel() + } + + tv := v.getTV() + if tv.cmdBuff.isActive() { + tv.cmdBuff.reset() + } else { + v.app.inject(v.current) + } + + return nil +} + +func (v *forwardView) runCmd(evt *tcell.EventKey) *tcell.EventKey { + tv := v.getTV() + r, _ := tv.GetSelection() + if r > 0 { + v.app.gotoResource(strings.TrimSpace(tv.GetCell(r, 0).Text), true) + } + + return nil +} + +func (v *forwardView) hints() hints { + return v.getTV().actions.toHints() +} + +func (v *forwardView) hydrate() resource.TableData { + cmds := helpCmds(v.app.conn()) + + data := resource.TableData{ + Header: resource.Row{"NAMESPACE", "NAME", "PORTS", "ACTIVE", "URL", "AGE"}, + Rows: make(resource.RowEvents, len(cmds)), + Namespace: resource.AllNamespaces, + } + + for _, f := range v.app.forwarders { + ports := strings.Split(f.Ports()[0], ":") + ns, n := namespaced(f.Path()) + fields := resource.Row{ + ns, + n, + strings.Join(f.Ports(), ","), + fmt.Sprintf("%t", f.Active()), + "http://localhost" + ":" + ports[0], + f.Age(), + } + data.Rows[f.Path()] = &resource.RowEvent{ + Action: resource.New, + Fields: fields, + Deltas: fields, + } + } + + return data +} + +func (v *forwardView) resetTitle() { + v.SetTitle(fmt.Sprintf(forwardTitleFmt, forwardTitle, v.getTV().GetRowCount()-1)) +} + +const genericPrompt = "prompt" + +func showModal(pv *tview.Pages, msg, back string, ok func()) { + m := tview.NewModal(). + AddButtons([]string{"Cancel", "OK"}). + SetTextColor(tcell.ColorFuchsia). + SetText(msg). + SetDoneFunc(func(_ int, b string) { + if b == "OK" { + ok() + } + dismissModal(pv, back) + }) + m.SetTitle("") + pv.AddPage(genericPrompt, m, false, false) + pv.ShowPage(genericPrompt) +} + +func dismissModal(pv *tview.Pages, page string) { + pv.RemovePage(genericPrompt) + pv.SwitchToPage(page) +} diff --git a/internal/views/help.go b/internal/views/help.go index 6127f235..548d4840 100644 --- a/internal/views/help.go +++ b/internal/views/help.go @@ -82,10 +82,10 @@ func (v *helpView) init(_ context.Context, _ string) { navigation := []helpItem{ {"g", "Goto Top"}, {"G", "Goto Bottom"}, - {"b", "Page Down"}, - {"f", "Page Up"}, - {"l", "Left"}, - {"h", "Right"}, + {"Ctrl-b", "Page Down"}, + {"Ctrl-f", "Page Up"}, + {"h", "Left"}, + {"l", "Right"}, {"k", "Up"}, {"j", "Down"}, } diff --git a/internal/views/helpers.go b/internal/views/helpers.go index 2f360f7b..aafd7f5f 100644 --- a/internal/views/helpers.go +++ b/internal/views/helpers.go @@ -19,6 +19,13 @@ const ( minusSign = "↓" ) +func fqn(ns, n string) string { + if ns == "" { + return n + } + return ns + "/" + n +} + func deltas(o, n string) string { o, n = strings.TrimSpace(o), strings.TrimSpace(n) if o == "" || o == res.NAValue { diff --git a/internal/views/job.go b/internal/views/job.go index dfa1765a..dafa5204 100644 --- a/internal/views/job.go +++ b/internal/views/job.go @@ -76,7 +76,7 @@ func (v *jobView) viewLogs(previous bool) bool { cc, err := fetchContainers(v.list, v.selectedItem, true) if err != nil { - v.app.flash(flashErr, err.Error()) + v.app.flash().err(err) log.Error().Err(err).Msgf("Unable to fetch containers for %s", v.selectedItem) return false } @@ -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("Prev Logs", v.prevLogsCmd, true) + aa[KeyShiftL] = newKeyAction("Logs Previous", v.prevLogsCmd, true) aa[tcell.KeyEnter] = newKeyAction("View Pods", v.showPodsCmd, true) } @@ -118,7 +118,7 @@ func (v *jobView) showPodsCmd(evt *tcell.EventKey) *tcell.EventKey { job, err := j.Get(ns, n) if err != nil { log.Error().Err(err).Msgf("Fetching Job %s", v.selectedItem) - v.app.flash(flashErr, err.Error()) + v.app.flash().err(err) return evt } jo := job.(*batchv1.Job) @@ -126,7 +126,7 @@ func (v *jobView) showPodsCmd(evt *tcell.EventKey) *tcell.EventKey { sel, err := metav1.LabelSelectorAsSelector(jo.Spec.Selector) if err != nil { log.Error().Err(err).Msgf("Converting selector for Job %s", v.selectedItem) - v.app.flash(flashErr, err.Error()) + v.app.flash().err(err) return evt } showPods(v.app, "", "Job", v.selectedItem, sel.String(), "", v.backCmd) diff --git a/internal/views/log.go b/internal/views/log.go index 757e0757..79079614 100644 --- a/internal/views/log.go +++ b/internal/views/log.go @@ -5,6 +5,7 @@ import ( "io" "strings" + "github.com/derailed/k9s/internal/config" "github.com/derailed/tview" "github.com/gdamore/tcell" "github.com/rs/zerolog/log" @@ -27,12 +28,15 @@ func newLogView(title string, parent masterView) *logView { v.autoScroll = true v.parent = parent v.SetBorder(true) + v.SetBackgroundColor(config.AsColor(parent.appView().styles.Style.Log.BgColor)) v.SetBorderPadding(0, 0, 1, 1) v.logs = newDetailsView(parent.appView(), parent.backFn()) { v.logs.SetBorder(false) v.logs.setCategory("Logs") v.logs.SetDynamicColors(true) + v.logs.SetTextColor(config.AsColor(parent.appView().styles.Style.Log.FgColor)) + v.logs.SetBackgroundColor(config.AsColor(parent.appView().styles.Style.Log.BgColor)) v.logs.SetWrap(true) v.logs.SetMaxBuffer(parent.appView().config.K9s.LogBufferSize) } @@ -110,11 +114,11 @@ func (v *logView) update() { func (v *logView) toggleScrollCmd(evt *tcell.EventKey) *tcell.EventKey { v.autoScroll = !v.autoScroll if v.autoScroll { - v.app.flash(flashInfo, "Autoscroll is on.") + v.app.flash().info("Autoscroll is on.") v.logs.ScrollToEnd() } else { v.logs.PageUp() - v.app.flash(flashInfo, "Autoscroll is off.") + v.app.flash().info("Autoscroll is off.") } v.update() @@ -126,33 +130,33 @@ func (v *logView) backCmd(evt *tcell.EventKey) *tcell.EventKey { } func (v *logView) topCmd(evt *tcell.EventKey) *tcell.EventKey { - v.app.flash(flashInfo, "Top of logs...") + v.app.flash().info("Top of logs...") v.logs.ScrollToBeginning() return nil } func (v *logView) bottomCmd(*tcell.EventKey) *tcell.EventKey { - v.app.flash(flashInfo, "Bottom of logs...") + v.app.flash().info("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 ...") + v.app.flash().info("Reached Top ...") } return nil } func (v *logView) pageDownCmd(*tcell.EventKey) *tcell.EventKey { if v.logs.PageDown() { - v.app.flash(flashInfo, "Reached Bottom ...") + v.app.flash().info("Reached Bottom ...") } return nil } func (v *logView) clearCmd(*tcell.EventKey) *tcell.EventKey { - v.app.flash(flashInfo, "Clearing logs...") + v.app.flash().info("Clearing logs...") v.logs.Clear() v.logs.ScrollTo(0, 0) return nil diff --git a/internal/views/logo.go b/internal/views/logo.go new file mode 100644 index 00000000..7b4aef53 --- /dev/null +++ b/internal/views/logo.go @@ -0,0 +1,83 @@ +package views + +import ( + "fmt" + + "github.com/derailed/k9s/internal/config" + "github.com/derailed/tview" +) + +type logoView struct { + *tview.Flex + logo, status *tview.TextView + app *appView +} + +func newLogoView(app *appView) *logoView { + v := logoView{ + Flex: tview.NewFlex(), + logo: logo(), + status: status(), + app: app, + } + v.SetDirection(tview.FlexRow) + v.AddItem(v.logo, 0, 6, false) + v.AddItem(v.status, 0, 1, false) + v.refreshLogo(app.styles.Style.LogoColor) + + return &v +} + +func (v *logoView) reset() { + v.status.Clear() + v.status.SetBackgroundColor(v.app.styles.BgColor()) + v.refreshLogo(v.app.styles.Style.LogoColor) +} + +func (v *logoView) warn(msg string) { + v.update(msg, "red") +} + +func (v *logoView) info(msg string) { + v.update(msg, "green") +} + +func (v *logoView) update(msg, c string) { + v.refreshStatus(msg, c) + v.refreshLogo(c) +} + +func (v *logoView) refreshStatus(msg, c string) { + v.status.SetBackgroundColor(config.AsColor(c)) + v.status.SetText(fmt.Sprintf("[white::b]%s", msg)) +} + +func (v *logoView) refreshLogo(c string) { + v.logo.Clear() + for i, s := range LogoSmall { + fmt.Fprintf(v.logo, "[%s::b]%s", c, s) + if i+1 < len(LogoSmall) { + fmt.Fprintf(v.logo, "\n") + } + } +} + +func logo() *tview.TextView { + v := tview.NewTextView() + v.SetWordWrap(false) + v.SetWrap(false) + v.SetTextAlign(tview.AlignLeft) + v.SetDynamicColors(true) + + return v +} + +func status() *tview.TextView { + v := tview.NewTextView() + v.SetWordWrap(false) + v.SetWrap(false) + v.SetTextAlign(tview.AlignCenter) + v.SetDynamicColors(true) + + return v +} diff --git a/internal/views/logs.go b/internal/views/logs.go index d3eaed5e..ac41fdda 100644 --- a/internal/views/logs.go +++ b/internal/views/logs.go @@ -3,7 +3,6 @@ package views import ( "context" "fmt" - "strings" "time" "github.com/derailed/k9s/internal/resource" @@ -18,6 +17,8 @@ const ( maxCleanse = 100 logBuffSize = 100 flushTimeout = 200 * time.Millisecond + + logFmt = " Logs([fg:bg:]%s:[hilite:bg:b]%s[-:bg:-]) " ) type masterView interface { @@ -104,7 +105,7 @@ func (v *logsView) load(i int) { } v.SwitchToPage(v.containers[i]) if err := v.doLoad(v.parent.getSelection(), v.containers[i]); err != nil { - v.parent.appView().flash(flashErr, err.Error()) + v.parent.appView().flash().err(err) l := v.CurrentPage().Item.(*logView) l.logLine("😂 Doh! No logs are available at this time. Check again later on...") return @@ -118,10 +119,7 @@ func (v *logsView) doLoad(path, co string) error { maxBuff := int64(v.parent.appView().config.K9s.LogRequestSize) l := v.CurrentPage().Item.(*logView) 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) + fmat := skinTitle(fmt.Sprintf(logFmt, path, co), v.parent.appView().styles.Style) l.SetTitle(fmat) c := make(chan string, 10) diff --git a/internal/views/ns.go b/internal/views/ns.go index 5d0daf10..aed7cdb9 100644 --- a/internal/views/ns.go +++ b/internal/views/ns.go @@ -1,7 +1,6 @@ package views import ( - "fmt" "regexp" "github.com/derailed/k9s/internal/config" @@ -60,9 +59,9 @@ func (v *namespaceView) useNsCmd(evt *tcell.EventKey) *tcell.EventKey { func (v *namespaceView) useNamespace(name string) { if err := v.app.config.SetActiveNamespace(name); err != nil { - v.app.flash(flashErr, err.Error()) + v.app.flash().err(err) } else { - v.app.flash(flashInfo, fmt.Sprintf("Namespace %s is now active!", name)) + v.app.flash().infof("Namespace %s is now active!", name) } v.app.config.Save() } diff --git a/internal/views/padding.go b/internal/views/padding.go index 5e7c0ef7..e196d11d 100644 --- a/internal/views/padding.go +++ b/internal/views/padding.go @@ -2,14 +2,18 @@ package views import ( "strings" + "time" "unicode" "github.com/derailed/k9s/internal/resource" + "k8s.io/apimachinery/pkg/util/duration" ) type maxyPad []int func computeMaxColumns(pads maxyPad, sortCol int, table resource.TableData) { + const colPadding = 1 + for index, h := range table.Header { pads[index] = len(h) if index == sortCol { @@ -18,10 +22,20 @@ func computeMaxColumns(pads maxyPad, sortCol int, table resource.TableData) { } var row int - for _, rev := range table.Rows { + for k, rev := range table.Rows { + ageIndex := len(rev.Fields) - 1 for index, field := range rev.Fields { - if len(field) > pads[index] { - pads[index] = len([]rune(field)) + // Date field comes out as timestamp. + if index == ageIndex { + dur, err := time.ParseDuration(field) + if err == nil { + field = duration.HumanDuration(dur) + } + table.Rows[k].Fields[index] = field + } + width := len(field) + colPadding + if width > pads[index] { + pads[index] = width } } row++ diff --git a/internal/views/padding_test.go b/internal/views/padding_test.go index 337c7eab..072a5352 100644 --- a/internal/views/padding_test.go +++ b/internal/views/padding_test.go @@ -22,7 +22,7 @@ func TestMaxColumn(t *testing.T) { }, }, 0, - maxyPad{5, 5}, + maxyPad{6, 6}, }, { resource.TableData{ @@ -33,7 +33,7 @@ func TestMaxColumn(t *testing.T) { }, }, 1, - maxyPad{5, 5}, + maxyPad{6, 6}, }, { resource.TableData{ @@ -44,7 +44,7 @@ func TestMaxColumn(t *testing.T) { }, }, 0, - maxyPad{28, 5}, + maxyPad{32, 6}, }, } diff --git a/internal/views/pod.go b/internal/views/pod.go index 4f194f3e..577957a9 100644 --- a/internal/views/pod.go +++ b/internal/views/pod.go @@ -3,7 +3,6 @@ package views import ( "context" "fmt" - "strings" "github.com/derailed/k9s/internal/k8s" "github.com/derailed/k9s/internal/resource" @@ -56,22 +55,18 @@ func (v *podView) listContainers(app *appView, _, res, sel string) { 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()) + app.flash().errf("Unable to retrieve pods %s", err) return } pod := po.(*v1.Pod) mx := k8s.NewMetricsServer(app.conn()) list := resource.NewContainerList(app.conn(), mx, pod) - log.Debug().Msgf(">>>> Got pod %s", pod.Name) - - 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) + title := skinTitle(fmt.Sprintf(containerFmt, "Containers", sel), v.app.styles.Style) v.suspend() - cv := newContainerView(title, app, list, namespacedName(pod.Namespace, pod.Name), v.exitFn) + cv := newContainerView(title, app, list, fqn(pod.Namespace, pod.Name), v.exitFn) v.AddPage("containers", cv, true, true) - ctx, cancel := context.WithCancel(context.Background()) + ctx, cancel := context.WithCancel(v.context) v.cancel = cancel cv.init(ctx, pod.Namespace) } @@ -125,7 +120,7 @@ func (v *podView) viewLogs(prev bool) bool { } cc, err := fetchContainers(v.list, v.selectedItem, true) if err != nil { - v.app.flash(flashErr, err.Error()) + v.app.flash().errf("Unable to retrieve containers %s", err) log.Error().Err(err) return false } @@ -155,7 +150,7 @@ func (v *podView) shellCmd(evt *tcell.EventKey) *tcell.EventKey { } cc, err := fetchContainers(v.list, v.selectedItem, false) if err != nil { - v.app.flash(flashErr, err.Error()) + v.app.flash().errf("Unable to retrieve containers %s", err) log.Error().Msgf("Error fetching containers %v", err) return evt } @@ -176,33 +171,46 @@ func (v *podView) shellCmd(evt *tcell.EventKey) *tcell.EventKey { func (v *podView) shellIn(path, co string) { v.suspend() { - ns, po := namespaced(path) - args := make([]string, 0, 12) - args = append(args, "exec", "-it") - args = append(args, "--context", v.app.config.K9s.CurrentContext) - args = append(args, "-n", ns) - args = append(args, po) - if len(co) != 0 { - args = append(args, "-c", co) - } - args = append(args, "--", "sh") - log.Debug().Msgf("Shell args %v", args) - runK(true, v.app, args...) + shellIn(v.app, path, co) } v.resume() } +func shellIn(a *appView, path, co string) { + args := computeShellArgs(path, co, a.config.K9s.CurrentContext, a.conn().Config().Flags().KubeConfig) + log.Debug().Msgf("Shell args %v", args) + runK(true, a, args...) +} + +func computeShellArgs(path, co, context string, cfg *string) []string { + a := make([]string, 0, 15) + a = append(a, "exec", "-it") + a = append(a, "--context", context) + ns, po := namespaced(path) + a = append(a, "-n", ns) + a = append(a, po) + if cfg != nil && *cfg != "" { + a = append(a, "--kubeconfig", *cfg) + } + if co != "" { + a = append(a, "-c", co) + } + a = append(a, "--", "sh") + + return a +} + func (v *podView) extraActions(aa keyActions) { aa[KeyL] = newKeyAction("Logs", v.logsCmd, true) - aa[KeyShiftL] = newKeyAction("Prev Logs", v.prevLogsCmd, true) + aa[KeyShiftL] = newKeyAction("Logs Previous", 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[KeyAltC] = newKeyAction("Sort %CPU", v.sortColCmd(6, false), true) - aa[KeyAltM] = newKeyAction("Sort %MEM", v.sortColCmd(7, false), 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) } diff --git a/internal/views/pod_test.go b/internal/views/pod_test.go new file mode 100644 index 00000000..39c41b06 --- /dev/null +++ b/internal/views/pod_test.go @@ -0,0 +1,54 @@ +package views + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestComputeShellArgs(t *testing.T) { + config, empty := "coolConfig", "" + uu := map[string]struct { + path, co, context string + cfg *string + e string + }{ + "config": { + "fred/blee", + "c1", + "ctx1", + &config, + "exec -it --context ctx1 -n fred blee --kubeconfig coolConfig -c c1 -- sh", + }, + "noconfig": { + "fred/blee", + "c1", + "ctx1", + nil, + "exec -it --context ctx1 -n fred blee -c c1 -- sh", + }, + "emptyConfig": { + "fred/blee", + "c1", + "ctx1", + &empty, + "exec -it --context ctx1 -n fred blee -c c1 -- sh", + }, + "singleContainer": { + "fred/blee", + "", + "ctx1", + &empty, + "exec -it --context ctx1 -n fred blee -- sh", + }, + } + + for k, u := range uu { + t.Run(k, func(t *testing.T) { + args := computeShellArgs(u.path, u.co, u.context, u.cfg) + + assert.Equal(t, u.e, strings.Join(args, " ")) + }) + } +} diff --git a/internal/views/policy.go b/internal/views/policy.go index e4aebaf0..9550396d 100644 --- a/internal/views/policy.go +++ b/internal/views/policy.go @@ -223,10 +223,6 @@ func (v *policyView) namespacePolicies() (resource.RowEvents, []error) { return evts, errs } -func namespacedName(ns, n string) string { - return ns + "/" + n -} - func (v *policyView) parseRules(ns, binding string, rules []rbacv1.PolicyRule) resource.RowEvents { m := make(resource.RowEvents, len(rules)) for _, r := range rules { @@ -237,12 +233,12 @@ func (v *policyView) parseRules(ns, binding string, rules []rbacv1.PolicyRule) r k = res + "." + grp } for _, na := range r.ResourceNames { - n := k + "/" + na - m[namespacedName(ns, n)] = &resource.RowEvent{ + n := fqn(k, na) + m[fqn(ns, n)] = &resource.RowEvent{ Fields: v.prepRow(ns, n, grp, binding, r.Verbs), } } - m[namespacedName(ns, k)] = &resource.RowEvent{ + m[fqn(ns, k)] = &resource.RowEvent{ Fields: v.prepRow(ns, k, grp, binding, r.Verbs), } } @@ -251,7 +247,7 @@ func (v *policyView) parseRules(ns, binding string, rules []rbacv1.PolicyRule) r if nres[0] != '/' { nres = "/" + nres } - m[namespacedName(ns, nres)] = &resource.RowEvent{ + m[fqn(ns, nres)] = &resource.RowEvent{ Fields: v.prepRow(ns, nres, resource.NAValue, binding, r.Verbs), } } diff --git a/internal/views/rbac.go b/internal/views/rbac.go index 809f5834..a61eaca5 100644 --- a/internal/views/rbac.go +++ b/internal/views/rbac.go @@ -126,11 +126,7 @@ func (v *rbacView) bindKeys() { } func (v *rbacView) getTitle() string { - fmat := strings.Replace(rbacTitleFmt, "[fg", "["+v.app.styles.Style.Title.FgColor, -1) - fmat = strings.Replace(fmat, ":bg:", ":"+v.app.styles.Style.Title.BgColor+":", -1) - fmat = strings.Replace(fmat, "[hilite", "["+v.app.styles.Style.Title.HighlightColor, 1) - - return fmt.Sprintf(fmat, rbacTitle, v.roleName) + return skinTitle(fmt.Sprintf(rbacTitleFmt, rbacTitle, v.roleName), v.app.styles.Style) } func (v *rbacView) hints() hints { @@ -244,7 +240,7 @@ func (v *rbacView) parseRules(rules []rbacv1.PolicyRule) resource.RowEvents { k = res + "." + grp } for _, na := range r.ResourceNames { - n := k + "/" + na + n := fqn(k, na) m[n] = &resource.RowEvent{ Fields: prepRow(n, grp, r.Verbs), } diff --git a/internal/views/registrar.go b/internal/views/registrar.go index 32abf4c0..ed2de94d 100644 --- a/internal/views/registrar.go +++ b/internal/views/registrar.go @@ -86,7 +86,7 @@ func showRBAC(app *appView, ns, resource, selection string) { func showClusterRole(app *appView, ns, resource, selection string) { crb, err := app.conn().DialOrDie().Rbac().ClusterRoleBindings().Get(selection, metav1.GetOptions{}) if err != nil { - app.flash(flashErr, "Unable to retrieve crb", selection) + app.flash().errf("Unable to retrieve clusterrolebindings for %s", selection) return } app.inject(newRBACView(app, ns, crb.RoleRef.Name, clusterRole)) @@ -96,10 +96,10 @@ func showRole(app *appView, _, resource, selection string) { ns, n := namespaced(selection) rb, err := app.conn().DialOrDie().Rbac().RoleBindings(ns).Get(n, metav1.GetOptions{}) if err != nil { - app.flash(flashErr, "Unable to retrieve rb", selection) + app.flash().errf("Unable to retrieve rolebindings for %s", selection) return } - app.inject(newRBACView(app, ns, namespacedName(ns, rb.RoleRef.Name), role)) + app.inject(newRBACView(app, ns, fqn(ns, rb.RoleRef.Name), role)) } func showSAPolicy(app *appView, _, _, selection string) { @@ -280,7 +280,7 @@ func resourceViews(c k8s.Connection) map[string]resCmd { "svc": { title: "Services", api: "", - viewFn: newResourceView, + viewFn: newSvcView, listFn: resource.NewServiceList, }, "usr": { @@ -307,7 +307,6 @@ func resourceViews(c k8s.Connection) map[string]resCmd { switch rev { case "v1": - log.Debug().Msg("Using HPA V1!") cmds["hpa"] = resCmd{ title: "HorizontalPodAutoscalers", api: "autoscaling", @@ -315,7 +314,6 @@ func resourceViews(c k8s.Connection) map[string]resCmd { listFn: resource.NewHorizontalPodAutoscalerV1List, } case "v2beta1": - log.Debug().Msg("Using HPA V2Beta1!") cmds["hpa"] = resCmd{ title: "HorizontalPodAutoscalers", api: "autoscaling", @@ -323,7 +321,6 @@ func resourceViews(c k8s.Connection) map[string]resCmd { listFn: resource.NewHorizontalPodAutoscalerV2Beta1List, } case "v2beta2": - log.Debug().Msg("Using HPA V2Beta2!") cmds["hpa"] = resCmd{ title: "HorizontalPodAutoscalers", api: "autoscaling", diff --git a/internal/views/resource.go b/internal/views/resource.go index d20aaf8e..5461c974 100644 --- a/internal/views/resource.go +++ b/internal/views/resource.go @@ -22,37 +22,28 @@ const ( clusterRefresh = time.Duration(15 * time.Second) ) -type ( - details interface { - tview.Primitive - setTitle(string) - clear() - setActions(keyActions) - update(resource.Properties) - } +type resourceView struct { + *tview.Pages - resourceView struct { - *tview.Pages - - app *appView - title string - selectedItem string - selectedRow int - namespaces map[int]string - selectedNS string - list resource.List - enterFn enterFn - extraActionsFn func(keyActions) - selectedFn func() string - decorateFn decorateFn - colorerFn colorerFn - actions keyActions - mx sync.Mutex - suspended bool - nsListAccess bool - path *string - } -) + app *appView + title string + selectedItem string + selectedRow int + namespaces map[int]string + selectedNS string + list resource.List + enterFn enterFn + extraActionsFn func(keyActions) + selectedFn func() string + decorateFn decorateFn + colorerFn colorerFn + actions keyActions + mx sync.Mutex + suspended bool + nsListAccess bool + path *string + context context.Context +} func newResourceView(title string, app *appView, list resource.List) resourceViewer { v := resourceView{ @@ -78,7 +69,7 @@ func newResourceView(title string, app *appView, list resource.List) resourceVie // Init watches all running pods in given namespace func (v *resourceView) init(ctx context.Context, ns string) { - v.selectedItem, v.selectedNS = noSelection, ns + v.context, v.selectedItem, v.selectedNS = ctx, noSelection, ns colorer := defaultColorer if v.colorerFn != nil { @@ -91,7 +82,7 @@ func (v *resourceView) init(ctx context.Context, ns string) { nn, err := k8s.NewNamespace(v.app.conn()).List(resource.AllNamespaces) if err != nil { log.Warn().Err(err).Msg("List namespaces") - v.app.flash(flashErr, err.Error()) + v.app.flash().errf("Unable to list namespaces %s", err) } if v.list.Namespaced() && !v.list.AllNamespaces() { @@ -232,17 +223,17 @@ func (v *resourceView) enterCmd(evt *tcell.EventKey) *tcell.EventKey { if v.getTV().filterCmd(evt) == nil { return nil } - if v.enterFn != nil { v.enterFn(v.app, v.list.GetNamespace(), v.list.GetName(), v.selectedItem) } else { v.defaultEnter(v.list.GetNamespace(), v.list.GetName(), v.selectedItem) } + return nil } func (v *resourceView) refreshCmd(*tcell.EventKey) *tcell.EventKey { - v.app.flash(flashInfo, "Refreshing...") + v.app.flash().info("Refreshing...") v.refresh() return nil } @@ -261,9 +252,9 @@ func (v *resourceView) deleteCmd(evt *tcell.EventKey) *tcell.EventKey { v.showModal(fmt.Sprintf("Delete %s %s?", v.list.GetName(), sel), func(_ int, button string) { if button == "OK" { v.getTV().setDeleted() - v.app.flash(flashInfo, fmt.Sprintf("Deleting %s %s", v.list.GetName(), sel)) + v.app.flash().infof("Deleting %s %s", v.list.GetName(), sel) if err := v.list.Resource().Delete(sel); err != nil { - v.app.flash(flashErr, "Boom!", err.Error()) + v.app.flash().errf("Delete failed with %s", err) } else { v.refresh() } @@ -290,10 +281,9 @@ func (v *resourceView) dismissModal() { } func (v *resourceView) defaultEnter(ns, resource, selection string) { - log.Debug().Msgf("Title %s", v.title) yaml, err := v.list.Resource().Describe(v.title, selection, v.app.flags) if err != nil { - v.app.flash(flashErr, err.Error()) + v.app.flash().errf("Describe command failed %s", err) log.Warn().Msgf("Describe %v", err.Error()) return } @@ -328,7 +318,7 @@ func (v *resourceView) viewCmd(evt *tcell.EventKey) *tcell.EventKey { sel := v.getSelectedItem() raw, err := v.list.Resource().Marshal(sel) if err != nil { - v.app.flash(flashErr, "Unable to marshal resource", err.Error()) + v.app.flash().errf("Unable to marshal resource %s", err) log.Error().Err(err) return evt } @@ -375,7 +365,7 @@ func (v *resourceView) doSwitchNamespace(ns string) { ns = resource.AllNamespace } v.selectedNS = ns - v.app.flash(flashInfo, fmt.Sprintf("Viewing `%s namespace...", ns)) + v.app.flash().infof("Viewing `%s namespace...", ns) v.list.SetNamespace(v.selectedNS) v.refresh() @@ -392,14 +382,14 @@ func (v *resourceView) refresh() { return } + v.refreshActions() + if v.list.Namespaced() { v.list.SetNamespace(v.selectedNS) } - - 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()) + v.app.flash().errf("Reconciliation failed %s", err) } data := v.list.Data() if v.decorateFn != nil { @@ -413,7 +403,6 @@ func (v *resourceView) getTV() *tableView { if tv, ok := v.GetPrimitive(v.list.GetName()).(*tableView); ok { return tv } - return nil } @@ -424,19 +413,14 @@ func (v *resourceView) selectItem(r, c int) { return } + col0 := strings.TrimSpace(t.GetCell(r, 0).Text) switch v.list.GetNamespace() { case resource.NotNamespaced: - v.selectedItem = strings.TrimSpace(t.GetCell(r, 0).Text) + v.selectedItem = col0 case resource.AllNamespaces: - v.selectedItem = path.Join( - strings.TrimSpace(t.GetCell(r, 0).Text), - strings.TrimSpace(t.GetCell(r, 1).Text), - ) + v.selectedItem = path.Join(col0, strings.TrimSpace(t.GetCell(r, 1).Text)) default: - v.selectedItem = path.Join( - v.selectedNS, - strings.TrimSpace(t.GetCell(r, 0).Text), - ) + v.selectedItem = path.Join(v.selectedNS, col0) } } diff --git a/internal/views/rs.go b/internal/views/rs.go index 27c60abe..77de827b 100644 --- a/internal/views/rs.go +++ b/internal/views/rs.go @@ -57,7 +57,7 @@ func (v *replicaSetView) showPodsCmd(evt *tcell.EventKey) *tcell.EventKey { r, err := rset.Get(ns, n) if err != nil { log.Error().Err(err).Msgf("Fetching ReplicaSet %s", v.selectedItem) - v.app.flash(flashErr, err.Error()) + v.app.flash().errf("Replicaset failed %s", err) return evt } rs := r.(*v1.ReplicaSet) @@ -65,7 +65,7 @@ func (v *replicaSetView) showPodsCmd(evt *tcell.EventKey) *tcell.EventKey { sel, err := metav1.LabelSelectorAsSelector(rs.Spec.Selector) if err != nil { log.Error().Err(err).Msgf("Converting selector for ReplicaSet %s", v.selectedItem) - v.app.flash(flashErr, err.Error()) + v.app.flash().errf("Selector failed %s", err) return evt } showPods(v.app, "", "ReplicaSet", v.selectedItem, sel.String(), "", v.backCmd) @@ -86,7 +86,7 @@ func (v *replicaSetView) rollbackCmd(evt *tcell.EventKey) *tcell.EventKey { v.showModal(fmt.Sprintf("Rollback %s %s?", v.list.GetName(), v.selectedItem), func(_ int, button string) { if button == "OK" { - v.app.flash(flashInfo, fmt.Sprintf("Rolling back %s %s", v.list.GetName(), v.selectedItem)) + v.app.flash().infof("Rolling back %s %s", v.list.GetName(), v.selectedItem) rollback(v.app, v.selectedItem) v.refresh() } @@ -102,7 +102,7 @@ func rollback(app *appView, selectedItem string) bool { r, err := rset.Get(ns, n) if err != nil { log.Error().Err(err).Msgf("Fetching ReplicaSet %s", selectedItem) - app.flash(flashErr, err.Error()) + app.flash().errf("Failed retrieving replicaset %s", err) return false } rs := r.(*v1.ReplicaSet) @@ -115,13 +115,13 @@ func rollback(app *appView, selectedItem string) bool { } } if ctrlName == "" || ctrlKind == "" || ctrlAPI == "" { - app.flash(flashErr, "Unable to find controller for ReplicaSet %s", selectedItem) + app.flash().errf("Unable to find controller for ReplicaSet %s", selectedItem) return false } revision := rs.ObjectMeta.Annotations["deployment.kubernetes.io/revision"] if rs.Status.Replicas != 0 { - app.flash(flashWarn, "Can not rollback the current replica!") + app.flash().warn("Can not rollback the current replica!") return false } @@ -129,7 +129,7 @@ func rollback(app *appView, selectedItem string) bool { dep, err := dpr.Get(ns, ctrlName) if err != nil { log.Error().Err(err).Msgf("Fetching Deployment %s", selectedItem) - app.flash(flashErr, err.Error()) + app.flash().errf("Unable to retrieve deployments %s", err) return false } dp := dep.(*appsv1.Deployment) @@ -157,7 +157,7 @@ func rollback(app *appView, selectedItem string) bool { return false } log.Debug().Msgf("Version %s %s", revision, res) - app.flash(flashInfo, fmt.Sprintf("Version %s %s", revision, res)) + app.flash().infof("Version %s %s", revision, res) return true } diff --git a/internal/views/secret.go b/internal/views/secret.go index f80651d6..10794fd9 100644 --- a/internal/views/secret.go +++ b/internal/views/secret.go @@ -36,7 +36,7 @@ func (v *secretView) decodeCmd(evt *tcell.EventKey) *tcell.EventKey { ns, n := namespaced(sel) sec, err := v.app.conn().DialOrDie().CoreV1().Secrets(ns).Get(n, metav1.GetOptions{}) if err != nil { - v.app.flash(flashErr, "Unable to retrieve secret", sel) + v.app.flash().errf("Unable to retrieve secret %s", err) return evt } @@ -46,7 +46,7 @@ func (v *secretView) decodeCmd(evt *tcell.EventKey) *tcell.EventKey { } raw, err := yaml.Marshal(d) if err != nil { - v.app.flash(flashErr, "Error decoding secret for `", sel) + v.app.flash().errf("Error decoding secret %s", err) log.Error().Err(err).Msgf("Marshal error getting secret %s", sel) return nil } diff --git a/internal/views/status.go b/internal/views/status.go index bc5bda38..267a3339 100644 --- a/internal/views/status.go +++ b/internal/views/status.go @@ -3,6 +3,7 @@ package views import ( "fmt" + "github.com/derailed/k9s/internal/config" "github.com/derailed/tview" ) @@ -15,9 +16,8 @@ type statusView struct { func newStatusView(app *appView) *statusView { v := statusView{app: app, TextView: tview.NewTextView()} { - v.SetBackgroundColor(app.styles.BgColor()) + v.SetBackgroundColor(config.AsColor(app.styles.Style.Log.BgColor)) v.SetTextAlign(tview.AlignRight) - // v.SetBorderPadding(0, 0, 1, 1) v.SetDynamicColors(true) } return &v diff --git a/internal/views/sts.go b/internal/views/sts.go index 889c3b00..40e9b3f1 100644 --- a/internal/views/sts.go +++ b/internal/views/sts.go @@ -49,7 +49,7 @@ func (v *statefulSetView) showPodsCmd(evt *tcell.EventKey) *tcell.EventKey { s, err := d.Get(ns, n) if err != nil { log.Error().Err(err).Msgf("Fetching StatefulSet %s", v.selectedItem) - v.app.flash(flashErr, err.Error()) + v.app.flash().errf("Unable to fetch statefulset %s", err) return evt } sts := s.(*v1.StatefulSet) @@ -57,7 +57,7 @@ func (v *statefulSetView) showPodsCmd(evt *tcell.EventKey) *tcell.EventKey { sel, err := metav1.LabelSelectorAsSelector(sts.Spec.Selector) if err != nil { log.Error().Err(err).Msgf("Converting selector for StatefulSet %s", v.selectedItem) - v.app.flash(flashErr, err.Error()) + v.app.flash().errf("Selector failed %s", err) return evt } showPods(v.app, "", "StatefulSet", v.selectedItem, sel.String(), "", v.backCmd) diff --git a/internal/views/svc.go b/internal/views/svc.go new file mode 100644 index 00000000..215ed68a --- /dev/null +++ b/internal/views/svc.go @@ -0,0 +1,84 @@ +package views + +import ( + "fmt" + "strings" + + "github.com/derailed/k9s/internal/k8s" + "github.com/derailed/k9s/internal/resource" + "github.com/gdamore/tcell" + "github.com/rs/zerolog/log" + v1 "k8s.io/api/core/v1" +) + +type svcView struct { + *resourceView +} + +func newSvcView(t string, app *appView, list resource.List) resourceViewer { + v := svcView{newResourceView(t, app, list).(*resourceView)} + { + v.extraActionsFn = v.extraActions + v.switchPage("svc") + } + + return &v +} + +func (v *svcView) extraActions(aa keyActions) { + aa[KeyShiftT] = newKeyAction("Sort Type", v.sortColCmd(1, false), true) + aa[tcell.KeyEnter] = newKeyAction("View Pods", v.showPodsCmd, true) +} + +func (v *svcView) sortColCmd(col int, asc bool) func(evt *tcell.EventKey) *tcell.EventKey { + return func(evt *tcell.EventKey) *tcell.EventKey { + t := v.getTV() + t.sortCol.index, t.sortCol.asc = t.nameColIndex()+col, asc + t.refresh() + + return nil + } +} + +func (v *svcView) showPodsCmd(evt *tcell.EventKey) *tcell.EventKey { + if !v.rowSelected() { + return evt + } + + s := k8s.NewService(v.app.conn()) + ns, n := namespaced(v.selectedItem) + res, err := s.Get(ns, n) + if err != nil { + log.Error().Err(err).Msgf("Fetch service %s", v.selectedItem) + return nil + } + svc := res.(*v1.Service) + + v.showSvcPods(ns, svc.Spec.Selector, v.backCmd) + + return nil +} + +func (v *svcView) backCmd(evt *tcell.EventKey) *tcell.EventKey { + v.app.inject(v) + + return nil +} + +func (v *svcView) showSvcPods(ns string, sel map[string]string, b actionHandler) { + var s []string + for k, v := range sel { + s = append(s, fmt.Sprintf("%s=%s", k, v)) + } + list := resource.NewPodList(v.app.conn(), ns) + list.SetLabelSelector(strings.Join(s, ",")) + + pv := newPodView("Pods", v.app, list) + pv.setColorerFn(podColorer) + pv.setExtraActionsFn(func(aa keyActions) { + aa[tcell.KeyEsc] = newKeyAction("Back", b, true) + }) + // Reset active namespace to all. + v.app.config.SetActiveNamespace(ns) + v.app.inject(pv) +} diff --git a/internal/views/table.go b/internal/views/table.go index fa23b43f..bde3d0e9 100644 --- a/internal/views/table.go +++ b/internal/views/table.go @@ -1,18 +1,17 @@ package views import ( + "errors" "fmt" "regexp" "sort" "strings" - "time" "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/resource" "github.com/derailed/tview" "github.com/gdamore/tcell" "github.com/rs/zerolog/log" - "k8s.io/apimachinery/pkg/util/duration" ) const ( @@ -56,29 +55,31 @@ type ( ) func newTableView(app *appView, title string) *tableView { - v := tableView{app: app, Table: tview.NewTable(), sortCol: sortColumn{0, 0, true}} - { - v.baseTitle = title - v.actions = make(keyActions) - v.SetFixed(1, 0) - v.SetBorder(true) - v.SetBackgroundColor(config.AsColor(app.styles.Style.Table.BgColor)) - v.SetBorderColor(config.AsColor(app.styles.Style.Table.FgColor)) - v.SetBorderFocusColor(config.AsColor(app.styles.Style.Border.FocusColor)) - v.SetBorderAttributes(tcell.AttrBold) - v.SetBorderPadding(0, 0, 1, 1) - v.cmdBuff = newCmdBuff('/') - v.cmdBuff.addListener(app.cmdView) - v.cmdBuff.reset() - v.SetSelectable(true, false) - v.SetSelectedStyle( - tcell.ColorBlack, - config.AsColor(app.styles.Style.Table.CursorColor), - tcell.AttrBold, - ) - v.SetInputCapture(v.keyboard) - v.bindKeys() + v := tableView{ + app: app, + Table: tview.NewTable(), + sortCol: sortColumn{0, 0, true}, + actions: make(keyActions), + baseTitle: title, + cmdBuff: newCmdBuff('/'), } + v.SetFixed(1, 0) + v.SetBorder(true) + v.SetBackgroundColor(config.AsColor(app.styles.Style.Table.BgColor)) + v.SetBorderColor(config.AsColor(app.styles.Style.Table.FgColor)) + v.SetBorderFocusColor(config.AsColor(app.styles.Style.Border.FocusColor)) + v.SetBorderAttributes(tcell.AttrBold) + v.SetBorderPadding(0, 0, 1, 1) + v.cmdBuff.addListener(app.cmdView) + v.cmdBuff.reset() + v.SetSelectable(true, false) + v.SetSelectedStyle( + tcell.ColorBlack, + config.AsColor(app.styles.Style.Table.CursorColor), + tcell.AttrBold, + ) + v.SetInputCapture(v.keyboard) + v.bindKeys() return &v } @@ -95,10 +96,6 @@ func (v *tableView) bindKeys() { v.actions[tcell.KeyBackspace2] = newKeyAction("Erase", v.eraseCmd, false) v.actions[tcell.KeyBackspace] = newKeyAction("Erase", v.eraseCmd, false) v.actions[tcell.KeyDelete] = newKeyAction("Erase", v.eraseCmd, false) - v.actions[KeyG] = newKeyAction("Top", v.app.puntCmd, false) - v.actions[KeyShiftG] = newKeyAction("Bottom", v.app.puntCmd, false) - v.actions[KeyB] = newKeyAction("Down", v.pageDownCmd, false) - v.actions[KeyF] = newKeyAction("Up", v.pageUpCmd, false) } func (v *tableView) clearSelection() { @@ -136,18 +133,6 @@ func (v *tableView) setSelection() { } } -func (v *tableView) pageUpCmd(evt *tcell.EventKey) *tcell.EventKey { - v.PageUp() - - return nil -} - -func (v *tableView) pageDownCmd(evt *tcell.EventKey) *tcell.EventKey { - v.PageDown() - - return nil -} - func (v *tableView) filterCmd(evt *tcell.EventKey) *tcell.EventKey { if v.cmdBuff.isActive() { v.cmdBuff.setActive(false) @@ -168,7 +153,7 @@ func (v *tableView) eraseCmd(evt *tcell.EventKey) *tcell.EventKey { func (v *tableView) resetCmd(evt *tcell.EventKey) *tcell.EventKey { if !v.cmdBuff.empty() { - v.app.flash(flashInfo, "Clearing filter...") + v.app.flash().info("Clearing filter...") } v.cmdBuff.reset() v.refresh() @@ -216,7 +201,7 @@ func (v *tableView) activateCmd(evt *tcell.EventKey) *tcell.EventKey { return evt } - v.app.flash(flashInfo, "Filter mode activated.") + v.app.flash().info("Filter mode activated.") v.cmdBuff.reset() v.cmdBuff.setActive(true) @@ -274,7 +259,7 @@ func (v *tableView) filtered() resource.TableData { rx, err := regexp.Compile(`(?i)` + v.cmdBuff.String()) if err != nil { - v.app.flash(flashErr, "Invalid filter expression") + v.app.flash().err(errors.New("Invalid filter expression")) v.cmdBuff.clear() return v.data } @@ -392,21 +377,12 @@ func (v *tableView) addHeaderCell(col int, name string, fg, bg tcell.Color) { } 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 { - field = duration.HumanDuration(dur) - } - } - 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) + field = pad(field, pads[col]) } c := tview.NewTableCell(field) @@ -414,7 +390,6 @@ func (v *tableView) addBodyCell(header string, row, col int, field, delta string c.SetExpansion(1) c.SetAlign(align) c.SetTextColor(color) - c.SetMaxWidth(pads[col] + colPadding) } v.SetCell(row, col, c) } @@ -449,25 +424,17 @@ func (v *tableView) resetTitle() { } switch v.currentNS { case resource.NotNamespaced, "*": - fmat := strings.Replace(titleFmt, "[fg:bg", "["+v.app.styles.Style.Title.FgColor+":"+v.app.styles.Style.Title.BgColor, -1) - fmat = strings.Replace(fmat, "[count", "["+v.app.styles.Style.Title.CounterColor, 1) - title = fmt.Sprintf(fmat, v.baseTitle, rc) + title = skinTitle(fmt.Sprintf(titleFmt, v.baseTitle, rc), v.app.styles.Style) default: ns := v.currentNS if ns == resource.AllNamespaces { ns = resource.AllNamespace } - fmat := strings.Replace(nsTitleFmt, "[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.HighlightColor, 1) - fmat = strings.Replace(fmat, "[count", "["+v.app.styles.Style.Title.CounterColor, 1) - title = fmt.Sprintf(fmat, v.baseTitle, ns, rc) + title = skinTitle(fmt.Sprintf(nsTitleFmt, v.baseTitle, ns, rc), v.app.styles.Style) } if !v.cmdBuff.isActive() && !v.cmdBuff.empty() { - fmat := strings.Replace(searchFmt, "[fg:bg", "["+v.app.styles.Style.Title.FgColor+":"+v.app.styles.Style.Title.BgColor, -1) - fmat = strings.Replace(fmat, ":bg:", ":"+v.app.styles.Style.Title.BgColor+":", -1) - fmat = strings.Replace(fmat, "[filter", "["+v.app.styles.Style.Title.FilterColor, 1) - title += fmt.Sprintf(fmat, v.cmdBuff) + title += skinTitle(fmt.Sprintf(searchFmt, v.cmdBuff), v.app.styles.Style) } v.SetTitle(title) } @@ -475,6 +442,16 @@ func (v *tableView) resetTitle() { // ---------------------------------------------------------------------------- // Event listeners... +func skinTitle(fmat string, style *config.Style) string { + fmat = strings.Replace(fmat, "[fg:bg", "["+style.Title.FgColor+":"+style.Title.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:", ":"+style.Title.BgColor+":", -1) + return fmat +} + func (v *tableView) changed(s string) {} func (v *tableView) active(b bool) { diff --git a/internal/watch/no_mx.go b/internal/watch/no_mx.go index e23139b5..8f4bdf41 100644 --- a/internal/watch/no_mx.go +++ b/internal/watch/no_mx.go @@ -160,10 +160,7 @@ func (n *nodeMxWatcher) update(list *mv1beta1.NodeMetricsList, notify bool) { kind = watch.Modified } if notify { - n.eventChan <- watch.Event{ - Type: kind, - Object: v, - } + n.eventChan <- watch.Event{Type: kind, Object: v} } n.cache[k] = v } diff --git a/internal/watch/pod_mx.go b/internal/watch/pod_mx.go index 991a950e..fdc97060 100644 --- a/internal/watch/pod_mx.go +++ b/internal/watch/pod_mx.go @@ -164,10 +164,7 @@ func (p *podMxWatcher) update(list *mv1beta1.PodMetricsList, notify bool) { } if notify { - p.eventChan <- watch.Event{ - Type: kind, - Object: v, - } + p.eventChan <- watch.Event{Type: kind, Object: v} } p.cache[k] = v }