diff --git a/assets/beach.png b/assets/beach.png new file mode 100644 index 00000000..986e6dcb Binary files /dev/null and b/assets/beach.png differ diff --git a/change_logs/release_v0.17.0.md b/change_logs/release_v0.17.0.md new file mode 100644 index 00000000..5ab25dfe --- /dev/null +++ b/change_logs/release_v0.17.0.md @@ -0,0 +1,64 @@ + + +# Release v0.17.0 + +## Notes + +Thank you to all that contributed with flushing out issues and enhancements for K9s! I'll try to mark some of these issues as fixed. But if you don't mind grab the latest rev and see if we're happier with some of the fixes! If you've filed an issue please help me verify and close. Your support, kindness and awesome suggestions to make K9s better is as ever very much noticed and appreciated! + +Also if you dig this tool, please make some noise on social! [@kitesurfer](https://twitter.com/kitesurfer) + +On Slack? Please join us [K9slackers](https://join.slack.com/t/k9sers/shared_invite/enQtOTA5MDEyNzI5MTU0LWQ1ZGI3MzliYzZhZWEyNzYxYzA3NjE0YTk1YmFmNzViZjIyNzhkZGI0MmJjYzhlNjdlMGJhYzE2ZGU1NjkyNTM) + +--- + + + +## Custom Columns? Yes Please!! + +[SneakCast v0.17.0 on The Beach! - Yup! sound is sucking but what a setting!](https://youtu.be/7S33CNLAofk) + +In this drop I've reworked the rendering engine to provide for custom columns support. Now, you should be able to not only tell K9s which columns you would like to display but also which order they should be in. + +To surface this feature, you will need to create a new configuration file, namely `$HOME/.k9s/views.yml`. This file leverages GVR (Group/Version/Resource) to configure the associated table view columns. If no GVR is found for a view the default rendering will take over (ie what we have now). Going wide will add all the remaining columns that are available on the given resource after your custom columns. To boot, you can edit your views config file and tune your resources views live! + +> NOTE: This is experimental and will most likely change as we iron this out! + +Here is a sample views configuration that customize a pods and services views. + +```yaml +# $HOME/.k9s/views.yml +k9s: + views: + v1/pods: + columns: + - AGE + - NAMESPACE + - NAME + - IP + - NODE + - STATUS + - READY + v1/services: + columns: + - AGE + - NAMESPACE + - NAME + - TYPE + - CLUSTER-IP +``` + +## Resolved Bugs/Features/PRs + +- [Issue #581](https://github.com/derailed/k9s/issues/581) +- [Issue #576](https://github.com/derailed/k9s/issues/576) +- [Issue #574](https://github.com/derailed/k9s/issues/574) +- [Issue #573](https://github.com/derailed/k9s/issues/573) +- [Issue #571](https://github.com/derailed/k9s/issues/571) +- [Issue #566](https://github.com/derailed/k9s/issues/566) +- [Issue #563](https://github.com/derailed/k9s/issues/563) +- [Issue #562](https://github.com/derailed/k9s/issues/562) + +--- + + © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) diff --git a/internal/config/helpers.go b/internal/config/helpers.go index 1e6449dc..99943178 100644 --- a/internal/config/helpers.go +++ b/internal/config/helpers.go @@ -13,7 +13,7 @@ const ( // DefaultDirMod default unix perms for k9s directory. DefaultDirMod os.FileMode = 0755 // DefaultFileMod default unix perms for k9s files. - DefaultFileMod os.FileMode = 0644 + DefaultFileMod os.FileMode = 0600 ) // InList check if string is in a collection of strings. diff --git a/internal/config/views.go b/internal/config/views.go index 271def06..2d32394b 100644 --- a/internal/config/views.go +++ b/internal/config/views.go @@ -7,7 +7,7 @@ import ( "gopkg.in/yaml.v2" ) -var K9sViewConfigFile = filepath.Join(K9sHome, "views_config.yml") +var K9sViewConfigFile = filepath.Join(K9sHome, "views.yml") // ViewConfigListener represents a view config listener. type ViewConfigListener interface { diff --git a/internal/dao/dp.go b/internal/dao/dp.go index 4b9216c6..5bbfed77 100644 --- a/internal/dao/dp.go +++ b/internal/dao/dp.go @@ -57,7 +57,7 @@ func (d *Deployment) Scale(path string, replicas int32) error { // Restart a Deployment rollout. func (d *Deployment) Restart(path string) error { - dp, err := d.GetInstance(path) + dp, err := d.Load(d.Factory, path) if err != nil { return err } @@ -81,7 +81,7 @@ func (d *Deployment) Restart(path string) error { // TailLogs tail logs for all pods represented by this Deployment. func (d *Deployment) TailLogs(ctx context.Context, c chan<- []byte, opts LogOptions) error { - dp, err := d.GetInstance(opts.Path) + dp, err := d.Load(d.Factory, opts.Path) if err != nil { return err } @@ -94,7 +94,7 @@ func (d *Deployment) TailLogs(ctx context.Context, c chan<- []byte, opts LogOpti // Pod returns a pod victim by name. func (d *Deployment) Pod(fqn string) (string, error) { - dp, err := d.GetInstance(fqn) + dp, err := d.Load(d.Factory, fqn) if err != nil { return "", err } @@ -103,8 +103,8 @@ func (d *Deployment) Pod(fqn string) (string, error) { } // GetInstance returns a deployment instance. -func (d *Deployment) GetInstance(fqn string) (*appsv1.Deployment, error) { - o, err := d.Factory.Get(d.gvr.String(), fqn, false, labels.Everything()) +func (*Deployment) Load(f Factory, fqn string) (*appsv1.Deployment, error) { + o, err := f.Get("apps/v1/deployments", fqn, false, labels.Everything()) if err != nil { return nil, err } diff --git a/internal/dao/ofaas.go b/internal/dao/ofaas.go index 123e702c..aade1efb 100644 --- a/internal/dao/ofaas.go +++ b/internal/dao/ofaas.go @@ -160,7 +160,6 @@ func deleteFunctionToken(gateway string, functionName string, tlsInsecure bool, req, err := http.NewRequest("DELETE", deleteEndpoint, reader) if err != nil { - fmt.Println(err) return err } req.Header.Set("Content-Type", "application/json") @@ -172,9 +171,7 @@ func deleteFunctionToken(gateway string, functionName string, tlsInsecure bool, } delRes, delErr := c.Do(req) - if delErr != nil { - fmt.Printf("Error removing existing function: %s, gateway=%s, functionName=%s\n", delErr.Error(), gateway, functionName) return delErr } diff --git a/internal/dao/resource.go b/internal/dao/resource.go index 36e04c17..cf459470 100644 --- a/internal/dao/resource.go +++ b/internal/dao/resource.go @@ -32,7 +32,7 @@ func (r *Resource) List(ctx context.Context, ns string) ([]runtime.Object, error } // Get returns a resource instance if found, else an error. -func (r *Resource) Get(ctx context.Context, path string) (runtime.Object, error) { +func (r *Resource) Get(_ context.Context, path string) (runtime.Object, error) { return r.Factory.Get(r.gvr.String(), path, true, labels.Everything()) } diff --git a/internal/dao/rs.go b/internal/dao/rs.go index fdb014f0..4307f591 100644 --- a/internal/dao/rs.go +++ b/internal/dao/rs.go @@ -1,7 +1,19 @@ package dao import ( + "errors" + "fmt" + "strconv" + "strings" + + "github.com/derailed/k9s/internal/client" appsv1 "k8s.io/api/apps/v1" + v1 "k8s.io/api/apps/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/kubectl/pkg/polymorphichelpers" ) // ReplicaSet represents a replicaset K8s resource. @@ -9,15 +21,96 @@ type ReplicaSet struct { Resource } -// IsHappy check for happy deployments. -func (d *ReplicaSet) IsHappy(rs appsv1.ReplicaSet) bool { - if rs.Status.Replicas == 0 && rs.Status.Replicas != rs.Status.ReadyReplicas { - return false +// BOZO!! +// // IsHappy check for happy deployments. +// func (*ReplicaSet) IsHappy(rs appsv1.ReplicaSet) bool { +// if rs.Status.Replicas == 0 && rs.Status.Replicas != rs.Status.ReadyReplicas { +// return false +// } + +// if rs.Status.Replicas != 0 && rs.Status.Replicas != rs.Status.ReadyReplicas { +// return false +// } + +// return true +// } + +func (r *ReplicaSet) Load(f Factory, path string) (*v1.ReplicaSet, error) { + o, err := f.Get("apps/v1/replicasets", path, true, labels.Everything()) + if err != nil { + return nil, err } - if rs.Status.Replicas != 0 && rs.Status.Replicas != rs.Status.ReadyReplicas { - return false + var rs appsv1.ReplicaSet + err = runtime.DefaultUnstructuredConverter.FromUnstructured(o.(*unstructured.Unstructured).Object, &rs) + if err != nil { + return nil, err } - return true + return &rs, nil +} + +func getRSRevision(rs *v1.ReplicaSet) (int64, error) { + revision := rs.ObjectMeta.Annotations["deployment.kubernetes.io/revision"] + if rs.Status.Replicas != 0 { + return 0, errors.New("can not rollback current replica") + } + vers, err := strconv.Atoi(revision) + if err != nil { + return 0, errors.New("revision conversion failed") + } + + return int64(vers), nil +} + +func controllerInfo(rs *v1.ReplicaSet) (string, string, string, error) { + for _, ref := range rs.ObjectMeta.OwnerReferences { + if ref.Controller == nil { + continue + } + group, tokens := ref.APIVersion, strings.Split(ref.APIVersion, "/") + if len(tokens) == 2 { + group = tokens[0] + } + return ref.Name, ref.Kind, group, nil + } + return "", "", "", fmt.Errorf("Unable to find controller for ReplicaSet %s", rs.ObjectMeta.Name) +} + +// Rollback reverses the last deployment. +func (r *ReplicaSet) Rollback(fqn string) error { + rs, err := r.Load(r.Factory, fqn) + if err != nil { + return err + } + + version, err := getRSRevision(rs) + if err != nil { + return err + } + name, kind, apiGroup, err := controllerInfo(rs) + if err != nil { + return err + } + rb, err := polymorphichelpers.RollbackerFor(schema.GroupKind{ + Group: apiGroup, + Kind: kind}, + r.Client().DialOrDie(), + ) + if err != nil { + return err + } + + var ddp Deployment + dp, err := ddp.Load(r.Factory, client.FQN(rs.Namespace, name)) + if err != nil { + return err + } + + _, err = rb.Rollback(dp, map[string]string{}, version, false) + if err != nil { + return err + } + + return nil } diff --git a/internal/model/table_int_test.go b/internal/model/table_int_test.go index df8a6394..3c71b534 100644 --- a/internal/model/table_int_test.go +++ b/internal/model/table_int_test.go @@ -133,7 +133,7 @@ func TestTableGenericHydrate(t *testing.T) { assert.Nil(t, genericHydrate("blee", &tt, rr, &re)) assert.Equal(t, 2, len(rr)) - assert.Equal(t, 2, len(rr[0].Fields)) + assert.Equal(t, 3, len(rr[0].Fields)) } // ---------------------------------------------------------------------------- diff --git a/internal/render/container.go b/internal/render/container.go index 3b8b2ede..ca430692 100644 --- a/internal/render/container.go +++ b/internal/render/container.go @@ -194,7 +194,7 @@ func ToContainerState(s v1.ContainerState) string { if s.Terminated.Reason != "" { return s.Terminated.Reason } - return "Terminated" + return "Terminating" case s.Running != nil: return "Running" default: diff --git a/internal/render/delta.go b/internal/render/delta.go index a50ce833..201e3e82 100644 --- a/internal/render/delta.go +++ b/internal/render/delta.go @@ -50,7 +50,10 @@ func (d DeltaRow) Customize(cols []int, out DeltaRow) { return } for i, c := range cols { - if c < len(d) { + if c < 0 { + continue + } + if c < len(d) && i < len(out) { out[i] = d[c] } } diff --git a/internal/render/delta_test.go b/internal/render/delta_test.go index 9f27fa7d..fba6ecf1 100644 --- a/internal/render/delta_test.go +++ b/internal/render/delta_test.go @@ -72,6 +72,16 @@ func TestDeltaCustomize(t *testing.T) { cols: []int{2, 10, 0}, e: render.DeltaRow{"c", "", "a"}, }, + "diff-negative": { + r1: render.Row{ + Fields: render.Fields{"a", "b", "c"}, + }, + r2: render.Row{ + Fields: render.Fields{"a1", "b1", "c1"}, + }, + cols: []int{2, -1, 0}, + e: render.DeltaRow{"c", "", "a"}, + }, } for k := range uu { diff --git a/internal/render/dp.go b/internal/render/dp.go index 6ae56705..ee98cc8d 100644 --- a/internal/render/dp.go +++ b/internal/render/dp.go @@ -63,9 +63,9 @@ func (d Deployment) Render(o interface{}, ns string, r *Row) error { return nil } -func (Deployment) diagnose(d, r int32) error { - if d != r { - return fmt.Errorf("desiring %d replicas got %d available", d, r) +func (Deployment) diagnose(desired, avail int32) error { + if desired != avail { + return fmt.Errorf("desiring %d replicas got %d available", desired, avail) } return nil } diff --git a/internal/render/generic.go b/internal/render/generic.go index b3072952..9d8b9cdf 100644 --- a/internal/render/generic.go +++ b/internal/render/generic.go @@ -40,6 +40,7 @@ func (g *Generic) Header(ns string) Header { return Header{} } h := make(Header, 0, len(g.table.ColumnDefinitions)) + h = append(h, HeaderColumn{Name: "NAMESPACE"}) for i, c := range g.table.ColumnDefinitions { if c.Name == ageTableCol { g.ageIndex = i @@ -48,7 +49,7 @@ func (g *Generic) Header(ns string) Header { h = append(h, HeaderColumn{Name: strings.ToUpper(c.Name)}) } if g.ageIndex > 0 { - h = append(h, HeaderColumn{Name: "AGE", Time: true,}) + h = append(h, HeaderColumn{Name: "AGE", Time: true}) } return h @@ -72,9 +73,7 @@ func (g *Generic) Render(o interface{}, ns string, r *Row) error { r.ID = client.FQN(nns, n) r.Fields = make(Fields, 0, len(g.Header(ns))) - if client.IsAllNamespaces(ns) && nns != "" { - r.Fields = append(r.Fields, nns) - } + r.Fields = append(r.Fields, nns) var ageCell interface{} for i, c := range row.Cells { if g.ageIndex > 0 && i == g.ageIndex { diff --git a/internal/render/generic_test.go b/internal/render/generic_test.go index 5afac9c4..2b441456 100644 --- a/internal/render/generic_test.go +++ b/internal/render/generic_test.go @@ -22,8 +22,9 @@ func TestGenericRender(t *testing.T) { ns: "ns1", table: makeNSGeneric(), eID: "ns1/c1", - eFields: render.Fields{"c1", "c2", "c3"}, + eFields: render.Fields{"ns1", "c1", "c2", "c3"}, eHeader: render.Header{ + render.HeaderColumn{Name: "NAMESPACE"}, render.HeaderColumn{Name: "A"}, render.HeaderColumn{Name: "B"}, render.HeaderColumn{Name: "C"}, @@ -35,7 +36,7 @@ func TestGenericRender(t *testing.T) { eID: "ns1/c1", eFields: render.Fields{"ns1", "c1", "c2", "c3"}, eHeader: render.Header{ - // render.HeaderColumn{Name: "NAMESPACE"}, + render.HeaderColumn{Name: "NAMESPACE"}, render.HeaderColumn{Name: "A"}, render.HeaderColumn{Name: "B"}, render.HeaderColumn{Name: "C"}, @@ -47,7 +48,7 @@ func TestGenericRender(t *testing.T) { eID: "ns1/c1", eFields: render.Fields{"ns1", "c1", "c2", "c3"}, eHeader: render.Header{ - // render.HeaderColumn{Name: "NAMESPACE"}, + render.HeaderColumn{Name: "NAMESPACE"}, render.HeaderColumn{Name: "A"}, render.HeaderColumn{Name: "B"}, render.HeaderColumn{Name: "C"}, @@ -57,8 +58,9 @@ func TestGenericRender(t *testing.T) { ns: client.ClusterScope, table: makeNoNSGeneric(), eID: "-/c1", - eFields: render.Fields{"c1", "c2", "c3"}, + eFields: render.Fields{"-", "c1", "c2", "c3"}, eHeader: render.Header{ + render.HeaderColumn{Name: "NAMESPACE"}, render.HeaderColumn{Name: "A"}, render.HeaderColumn{Name: "B"}, render.HeaderColumn{Name: "C"}, @@ -68,11 +70,12 @@ func TestGenericRender(t *testing.T) { ns: client.ClusterScope, table: makeAgeGeneric(), eID: "-/c1", - eFields: render.Fields{"c1", "c2", "Age"}, + eFields: render.Fields{"-", "c1", "c2", "Age"}, eHeader: render.Header{ + render.HeaderColumn{Name: "NAMESPACE"}, render.HeaderColumn{Name: "A"}, render.HeaderColumn{Name: "C"}, - render.HeaderColumn{Name: "AGE", Time: true,}, + render.HeaderColumn{Name: "AGE", Time: true}, }, }, } diff --git a/internal/render/header.go b/internal/render/header.go index 12a403d2..b57bd354 100644 --- a/internal/render/header.go +++ b/internal/render/header.go @@ -40,28 +40,27 @@ func (h Header) Clone() Header { } // MapIndices returns a collection of mapped column indices based of the requested columns. -func (h Header) MapIndices(cols []string, wide bool, ii []int) { +func (h Header) MapIndices(cols []string, wide bool) []int { + ii := make([]int, 0, len(cols)) cc := make(map[int]struct{}, len(cols)) - var lastIndex int - log.Debug().Msgf("MAP %d -- %d ", len(cols), len(ii)) - for i, col := range cols { + for _, col := range cols { idx := h.IndexOf(col, true) - ii[i], cc[idx] = idx, struct{}{} - lastIndex = i + if idx < 0 { + log.Warn().Msgf("Column %q not found on resource", col) + } + ii, cc[idx] = append(ii, idx), struct{}{} } if !wide { - return + return ii } for i := range h { if _, ok := cc[i]; ok { continue } - lastIndex++ - if lastIndex < len(ii) { - ii[lastIndex] = i - } + ii = append(ii, i) } + return ii } // Customize builds a header from custom col definitions. @@ -73,8 +72,13 @@ func (h Header) Customize(cols []string, wide bool) Header { xx := make(map[int]struct{}, len(h)) for _, c := range cols { idx := h.IndexOf(c, true) + // BOZO!! if idx == -1 { log.Warn().Msgf("Column %s is not available on this resource", c) + col := HeaderColumn{ + Name: c, + } + cc = append(cc, col) continue } xx[idx] = struct{}{} @@ -153,3 +157,11 @@ func (h Header) IndexOf(colName string, includeWide bool) int { } return -1 } + +// Dump for debuging. +func (h Header) Dump() { + log.Debug().Msgf("HEADER") + for i, c := range h { + log.Debug().Msgf("%d %q -- %t", i, c.Name, c.Wide) + } +} diff --git a/internal/render/header_test.go b/internal/render/header_test.go index 95493817..4fe03012 100644 --- a/internal/render/header_test.go +++ b/internal/render/header_test.go @@ -39,8 +39,7 @@ func TestHeaderMapIndices(t *testing.T) { for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { - ii := make([]int, len(u.cols)) - u.h1.MapIndices(u.cols, u.wide, ii) + ii := u.h1.MapIndices(u.cols, u.wide) assert.Equal(t, u.e, ii) }) } @@ -144,6 +143,7 @@ func TestHeaderCustomize(t *testing.T) { cols: []string{"BLEE", "A"}, wide: true, e: render.Header{ + render.HeaderColumn{Name: "BLEE"}, render.HeaderColumn{Name: "A"}, render.HeaderColumn{Name: "B", Wide: true}, render.HeaderColumn{Name: "C", Wide: true}, diff --git a/internal/render/pod.go b/internal/render/pod.go index 3969f841..fa5d6573 100644 --- a/internal/render/pod.go +++ b/internal/render/pod.go @@ -116,7 +116,7 @@ func (p Pod) diagnose(phase string, cr, ct int) error { if phase == "Completed" { return nil } - if cr != ct { + if cr != ct || ct == 0 { return fmt.Errorf("container ready check failed: %d of %d", cr, ct) } @@ -276,7 +276,7 @@ func (p *Pod) Phase(po *v1.Pod) string { return status } - return "Terminated" + return "Terminating" } func (*Pod) containerPhase(st v1.PodStatus, status string) (string, bool) { diff --git a/internal/render/pv.go b/internal/render/pv.go index 117f2246..a21f66a7 100644 --- a/internal/render/pv.go +++ b/internal/render/pv.go @@ -73,7 +73,7 @@ func (p PersistentVolume) Render(o interface{}, ns string, r *Row) error { phase := pv.Status.Phase if pv.ObjectMeta.DeletionTimestamp != nil { - phase = "Terminating" + phase = "Terminated" } var claim string if pv.Spec.ClaimRef != nil { @@ -92,22 +92,22 @@ func (p PersistentVolume) Render(o interface{}, ns string, r *Row) error { size.String(), accessMode(pv.Spec.AccessModes), string(pv.Spec.PersistentVolumeReclaimPolicy), - string(phase), + string(pv.Status.Phase), claim, class, pv.Status.Reason, p.volumeMode(pv.Spec.VolumeMode), mapToStr(pv.Labels), - asStatus(p.diagnose(string(phase))), + asStatus(p.diagnose(phase)), toAge(pv.ObjectMeta.CreationTimestamp), } return nil } -func (PersistentVolume) diagnose(r string) error { - if r != "Bound" && r != "Available" { - return fmt.Errorf("unexpected status %s", r) +func (PersistentVolume) diagnose(phase v1.PersistentVolumePhase) error { + if phase == v1.VolumeFailed { + return fmt.Errorf("failed to delete or recycle") } return nil } diff --git a/internal/render/row.go b/internal/render/row.go index a9646424..6ae8a805 100644 --- a/internal/render/row.go +++ b/internal/render/row.go @@ -15,7 +15,7 @@ type Fields []string func (f Fields) Customize(cols []int, out Fields) { for i, c := range cols { if c < 0 { - out[i] = "" + out[i] = NAValue continue } if c < len(f) { diff --git a/internal/render/table_data.go b/internal/render/table_data.go index 445aa9f1..beef289e 100644 --- a/internal/render/table_data.go +++ b/internal/render/table_data.go @@ -1,7 +1,5 @@ package render -import "github.com/rs/zerolog/log" - // TableData tracks a K8s resource for tabular display. type TableData struct { Header Header @@ -19,9 +17,7 @@ func (t *TableData) Customize(cols []string, wide bool) TableData { Namespace: t.Namespace, Header: t.Header.Customize(cols, wide), } - ids := make([]int, len(t.Header)) - t.Header.MapIndices(cols, wide, ids) - log.Debug().Msgf("INDICES %#v", ids) + ids := t.Header.MapIndices(cols, wide) res.RowEvents = t.RowEvents.Customize(ids) return res diff --git a/internal/ui/app.go b/internal/ui/app.go index efe187a7..3ef444c8 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -45,7 +45,6 @@ func NewApp(context string) *App { // Init initializes the application. func (a *App) Init() { a.bindKeys() - a.SetInputCapture(a.keyboard) a.cmdBuff.AddListener(a.Cmd()) a.Styles.AddListener(a) a.CmdBuff().AddListener(a) @@ -153,6 +152,11 @@ func (a *App) InCmdMode() bool { return a.Cmd().InCmdMode() } +func (a *App) HasAction(key tcell.Key) (KeyAction, bool) { + act, ok := a.actions[key] + return act, ok +} + // GetActions returns a collection of actiona. func (a *App) GetActions() KeyActions { return a.actions @@ -170,23 +174,6 @@ func (a *App) Views() map[string]tview.Primitive { return a.views } -func (a *App) keyboard(evt *tcell.EventKey) *tcell.EventKey { - key := evt.Key() - if key == tcell.KeyRune { - if a.cmdBuff.IsActive() && evt.Modifiers() == tcell.ModNone { - a.cmdBuff.Add(evt.Rune()) - return nil - } - key = AsKey(evt) - } - - if a, ok := a.actions[key]; ok { - return a.Action(evt) - } - - return evt -} - func (a *App) clearCmd(evt *tcell.EventKey) *tcell.EventKey { if !a.CmdBuff().IsActive() { return evt diff --git a/internal/ui/pages.go b/internal/ui/pages.go index 206e6a09..2f0835ef 100644 --- a/internal/ui/pages.go +++ b/internal/ui/pages.go @@ -25,6 +25,17 @@ func NewPages() *Pages { return &p } +// IsTopDialog checks if front page is a dialog. +func (p *Pages) IsTopDialog() bool { + _, pa := p.GetFrontPage() + switch pa.(type) { + case *tview.ModalForm: + return true + default: + return false + } +} + // Show displays a given page. func (p *Pages) Show(c model.Component) { p.SwitchToPage(componentID(c)) diff --git a/internal/ui/table.go b/internal/ui/table.go index fc6eea51..13e88b7a 100644 --- a/internal/ui/table.go +++ b/internal/ui/table.go @@ -69,7 +69,6 @@ func (t *Table) Init(ctx context.Context) { t.SetBorderPadding(0, 0, 1, 1) t.SetSelectable(true, false) t.SetSelectionChangedFunc(t.selectionChanged) - t.SetInputCapture(t.keyboard) t.SetBackgroundColor(tcell.ColorDefault) if cfg, ok := ctx.Value(internal.KeyViewConfig).(*config.CustomView); ok && cfg != nil { @@ -129,12 +128,7 @@ func (t *Table) Styles() *config.Styles { return t.styles } -// SendKey sends an keyboard event (testing only!). -func (t *Table) SendKey(evt *tcell.EventKey) { - t.keyboard(evt) -} - -func (t *Table) filterInput(r rune) bool { +func (t *Table) FilterInput(r rune) bool { if !t.cmdBuff.IsActive() { return false } @@ -147,26 +141,6 @@ func (t *Table) filterInput(r rune) bool { return true } -func (t *Table) keyboard(evt *tcell.EventKey) *tcell.EventKey { - key := evt.Key() - if key == tcell.KeyUp || key == tcell.KeyDown { - return evt - } - - if key == tcell.KeyRune { - if t.filterInput(evt.Rune()) { - return nil - } - key = AsKey(evt) - } - - if a, ok := t.actions[key]; ok { - return a.Action(evt) - } - - return evt -} - // Hints returns the view hints. func (t *Table) Hints() model.MenuHints { return t.actions.Hints() @@ -214,7 +188,6 @@ func (t *Table) doUpdate(data render.TableData) { t.actions.Delete(KeyShiftP) } - hasMX := t.model.HasMetrics() var cols []string if t.viewSetting != nil { cols = t.viewSetting.Columns @@ -222,19 +195,19 @@ func (t *Table) doUpdate(data render.TableData) { if len(cols) == 0 { cols = t.header.Columns(t.wide) } - data = data.Customize(cols, t.wide) + custData := data.Customize(cols, t.wide) - if t.sortCol.name == "" || data.Header.IndexOf(t.sortCol.name, false) == -1 { - t.sortCol.name = data.Header[0].Name + if t.sortCol.name == "" || custData.Header.IndexOf(t.sortCol.name, false) == -1 { + t.sortCol.name = custData.Header[0].Name } t.Clear() fg := t.styles.Table().Header.FgColor.Color() bg := t.styles.Table().Header.BgColor.Color() + hasMX := t.model.HasMetrics() var col int - fmt.Printf("NS %q\n", t.GetModel().GetNamespace()) - for _, h := range data.Header { + for _, h := range custData.Header { if h.Name == "NAMESPACE" && !t.GetModel().ClusterWide() { continue } @@ -247,29 +220,32 @@ func (t *Table) doUpdate(data render.TableData) { c.SetTextColor(fg) col++ } - data.RowEvents.Sort(data.Namespace, data.Header.IndexOf(t.sortCol.name, false), t.sortCol.name == "AGE", t.sortCol.asc) + custData.RowEvents.Sort(custData.Namespace, custData.Header.IndexOf(t.sortCol.name, false), t.sortCol.name == "AGE", t.sortCol.asc) - pads := make(MaxyPad, len(data.Header)) - ComputeMaxColumns(pads, t.sortCol.name, data.Header, data.RowEvents) - for row, re := range data.RowEvents { - t.buildRow(row+1, re, data.Header, pads, hasMX) + pads := make(MaxyPad, len(custData.Header)) + ComputeMaxColumns(pads, t.sortCol.name, custData.Header, custData.RowEvents) + for row, re := range custData.RowEvents { + idx, _ := data.RowEvents.FindIndex(re.Row.ID) + t.buildRow(row+1, re, data.RowEvents[idx], custData.Header, pads) } t.updateSelection(true) } -func (t *Table) buildRow(r int, re render.RowEvent, h render.Header, pads MaxyPad, hasMX bool) { +func (t *Table) buildRow(r int, re, ore render.RowEvent, h render.Header, pads MaxyPad) { color := render.DefaultColorer if t.colorerFn != nil { color = t.colorerFn } marked := t.IsMarked(re.Row.ID) + hasMX := t.model.HasMetrics() var col int for c, field := range re.Row.Fields { if c >= len(h) { - log.Error().Msgf("field/header overflow detected for %d::%d. Check your mappings!", c, len(h)) + log.Error().Msgf("field/header overflow detected for %q -- %d::%d. Check your mappings!", t.GVR(), c, len(h)) continue } + if h[c].Name == "NAMESPACE" && !t.GetModel().ClusterWide() { continue } @@ -291,7 +267,7 @@ func (t *Table) buildRow(r int, re render.RowEvent, h render.Header, pads MaxyPa cell := tview.NewTableCell(field) cell.SetExpansion(1) cell.SetAlign(h[c].Align) - cell.SetTextColor(color(t.GetModel().GetNamespace(), h, re)) + cell.SetTextColor(color(t.GetModel().GetNamespace(), t.header, ore)) if marked { cell.SetTextColor(t.styles.Table().MarkColor.Color()) } diff --git a/internal/view/actions.go b/internal/view/actions.go index 9bd173b1..5b203e09 100644 --- a/internal/view/actions.go +++ b/internal/view/actions.go @@ -68,14 +68,16 @@ func hotKeyActions(r Runner, aa ui.KeyActions) { } aa[key] = ui.NewSharedKeyAction( hk.Description, - gotoCmd(r, "", hk.Command), + gotoCmd(r, hk.Command, ""), false) } } func gotoCmd(r Runner, cmd, path string) ui.ActionHandler { return func(evt *tcell.EventKey) *tcell.EventKey { + log.Debug().Msgf("YO! %q -- %q", cmd, path) if err := r.App().gotoResource(cmd, path, true); err != nil { + log.Error().Err(err).Msgf("Command fail") r.App().Flash().Err(err) } return nil diff --git a/internal/view/app.go b/internal/view/app.go index 4a3fa4b0..e22ad2ad 100644 --- a/internal/view/app.go +++ b/internal/view/app.go @@ -74,6 +74,7 @@ func (a *App) Init(version string, rate int) error { a.Content.Stack.AddListener(a.Menu()) a.App.Init() + a.SetInputCapture(a.keyboard) a.bindKeys() if a.Conn() == nil { return errors.New("No client connection detected") @@ -114,6 +115,23 @@ func (a *App) Init(version string, rate int) error { return nil } +func (a *App) keyboard(evt *tcell.EventKey) *tcell.EventKey { + key := evt.Key() + if key == tcell.KeyRune { + if a.CmdBuff().IsActive() && evt.Modifiers() == tcell.ModNone { + a.CmdBuff().Add(evt.Rune()) + return nil + } + key = ui.AsKey(evt) + } + + if k, ok := a.HasAction(key); ok && !a.Content.IsTopDialog() { + return k.Action(evt) + } + + return evt +} + func (a *App) bindKeys() { a.AddActions(ui.KeyActions{ tcell.KeyCtrlE: ui.NewSharedKeyAction("ToggleHeader", a.toggleHeaderCmd, false), diff --git a/internal/view/dp.go b/internal/view/dp.go index 44332a1a..f5acbcaa 100644 --- a/internal/view/dp.go +++ b/internal/view/dp.go @@ -45,10 +45,8 @@ func (d *Deploy) bindKeys(aa ui.KeyActions) { } func (d *Deploy) showPods(app *App, model ui.Tabular, gvr, path string) { - var res dao.Deployment - res.Init(app.factory, d.GVR()) - - dp, err := res.GetInstance(path) + var ddp dao.Deployment + dp, err := ddp.Load(app.factory, path) if err != nil { app.Flash().Err(err) return diff --git a/internal/view/pf_dialog.go b/internal/view/pf_dialog.go index 973644df..7f1c44b9 100644 --- a/internal/view/pf_dialog.go +++ b/internal/view/pf_dialog.go @@ -48,13 +48,13 @@ func ShowPortForwards(v ResourceViewer, path string, ports []string, okFn PortFo okFn(v, path, extractContainer(p1), tunnel) }) f.AddButton("Cancel", func() { - DismissPortForwards(v.App(), pages) + DismissPortForwards(v, pages) }) modal := tview.NewModalForm(fmt.Sprintf("", path), f) modal.SetText("Exposed Ports: " + strings.Join(ports, ",")) modal.SetDoneFunc(func(_ int, b string) { - DismissPortForwards(v.App(), pages) + DismissPortForwards(v, pages) }) pages.AddPage(portForwardKey, modal, false, true) @@ -63,9 +63,9 @@ func ShowPortForwards(v ResourceViewer, path string, ports []string, okFn PortFo } // DismissPortForwards dismiss the port forward dialog. -func DismissPortForwards(app *App, p *ui.Pages) { +func DismissPortForwards(v ResourceViewer, p *ui.Pages) { p.RemovePage(portForwardKey) - app.SetFocus(p.CurrentPage().Item) + v.App().SetFocus(p.CurrentPage().Item) } // ---------------------------------------------------------------------------- diff --git a/internal/view/pf_extender.go b/internal/view/pf_extender.go index 120d581c..1b61d988 100644 --- a/internal/view/pf_extender.go +++ b/internal/view/pf_extender.go @@ -85,7 +85,7 @@ func runForward(v ResourceViewer, pf watch.Forwarder, f *portforward.PortForward v.App().QueueUpdateDraw(func() { v.App().Flash().Infof("PortForward activated %s:%s", pf.Path(), pf.Ports()[0]) - DismissPortForwards(v.App(), v.App().Content.Pages) + DismissPortForwards(v, v.App().Content.Pages) }) pf.SetActive(true) diff --git a/internal/view/pod.go b/internal/view/pod.go index 35d95e66..23507ef7 100644 --- a/internal/view/pod.go +++ b/internal/view/pod.go @@ -117,7 +117,7 @@ func (p *Pod) shellCmd(evt *tcell.EventKey) *tcell.EventKey { return evt } - if podIsRunning(p.App().factory, path) { + if !podIsRunning(p.App().factory, path) { p.App().Flash().Errf("%s is not in a running state", path) return nil } @@ -135,7 +135,7 @@ func (p *Pod) attachCmd(evt *tcell.EventKey) *tcell.EventKey { return evt } - if podIsRunning(p.App().factory, path) { + if !podIsRunning(p.App().factory, path) { p.App().Flash().Errf("%s is not in a happy state", path) return nil } diff --git a/internal/view/rs.go b/internal/view/rs.go index eebb364d..9c3c0026 100644 --- a/internal/view/rs.go +++ b/internal/view/rs.go @@ -1,25 +1,14 @@ package view import ( - "errors" "fmt" - "strconv" - "strings" "github.com/derailed/k9s/internal/client" + "github.com/derailed/k9s/internal/dao" "github.com/derailed/k9s/internal/render" "github.com/derailed/k9s/internal/ui" - "github.com/derailed/k9s/internal/watch" "github.com/derailed/tview" "github.com/gdamore/tcell" - "github.com/rs/zerolog/log" - appsv1 "k8s.io/api/apps/v1" - v1 "k8s.io/api/apps/v1" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/apimachinery/pkg/labels" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/runtime/schema" - "k8s.io/kubectl/pkg/polymorphichelpers" ) // ReplicaSet presents a replicaset viewer. @@ -49,38 +38,37 @@ func (r *ReplicaSet) bindKeys(aa ui.KeyActions) { } func (r *ReplicaSet) showPods(app *App, model ui.Tabular, gvr, path string) { - o, err := app.factory.Get(r.GVR().String(), path, true, labels.Everything()) + var drs dao.ReplicaSet + rs, err := drs.Load(app.factory, path) if err != nil { app.Flash().Err(err) return } - var rs appsv1.ReplicaSet - err = runtime.DefaultUnstructuredConverter.FromUnstructured(o.(*unstructured.Unstructured).Object, &rs) - if err != nil { - app.Flash().Err(err) - } - showPodsFromSelector(app, path, rs.Spec.Selector) } func (r *ReplicaSet) rollbackCmd(evt *tcell.EventKey) *tcell.EventKey { - sel := r.GetTable().GetSelectedItem() - if sel == "" { + path := r.GetTable().GetSelectedItem() + if path == "" { return evt } - r.showModal(fmt.Sprintf("Rollback %s %s?", r.GVR(), sel), func(_ int, button string) { - if button == "OK" { - r.App().Flash().Infof("Rolling back %s %s", r.GVR(), sel) - if res, err := rollback(r.App().factory, sel); err != nil { - r.App().Flash().Err(err) - } else { - r.App().Flash().Info(res) - } - r.Refresh() + r.showModal(fmt.Sprintf("Rollback %s %s?", r.GVR(), path), func(_ int, button string) { + defer r.dismissModal() + + if button != "OK" { + return } - r.dismissModal() + r.App().Flash().Infof("Rolling back %s %s", r.GVR(), path) + var drs dao.ReplicaSet + drs.Init(r.App().factory, r.GVR()) + if err := drs.Rollback(path); err != nil { + r.App().Flash().Err(err) + } else { + r.App().Flash().Infof("%s successfully rolled back", path) + } + r.Refresh() }) return nil @@ -99,95 +87,3 @@ func (r *ReplicaSet) showModal(msg string, done func(int, string)) { r.App().Content.AddPage("confirm", confirm, false, false) r.App().Content.ShowPage("confirm") } - -// ---------------------------------------------------------------------------- -// Helpers... - -func findRS(f *watch.Factory, path string) (*v1.ReplicaSet, error) { - o, err := f.Get("apps/v1/replicasets", path, true, labels.Everything()) - if err != nil { - return nil, err - } - - var rs appsv1.ReplicaSet - err = runtime.DefaultUnstructuredConverter.FromUnstructured(o.(*unstructured.Unstructured).Object, &rs) - if err != nil { - return nil, err - } - - return &rs, nil -} - -func findDP(f *watch.Factory, path string) (*appsv1.Deployment, error) { - o, err := f.Get("apps/v1/deployments", path, true, labels.Everything()) - if err != nil { - return nil, err - } - - var dp appsv1.Deployment - err = runtime.DefaultUnstructuredConverter.FromUnstructured(o.(*unstructured.Unstructured).Object, &dp) - if err != nil { - return nil, err - } - - return &dp, nil -} - -func controllerInfo(rs *v1.ReplicaSet) (string, string, string, error) { - for _, ref := range rs.ObjectMeta.OwnerReferences { - if ref.Controller == nil { - continue - } - log.Debug().Msgf("Controller name %s", ref.Name) - tokens := strings.Split(ref.APIVersion, "/") - apiGroup := ref.APIVersion - if len(tokens) == 2 { - apiGroup = tokens[0] - } - return ref.Name, ref.Kind, apiGroup, nil - } - return "", "", "", fmt.Errorf("Unable to find controller for ReplicaSet %s", rs.ObjectMeta.Name) -} - -func getRevision(rs *v1.ReplicaSet) (int64, error) { - revision := rs.ObjectMeta.Annotations["deployment.kubernetes.io/revision"] - if rs.Status.Replicas != 0 { - return 0, errors.New("can not rollback current replica") - } - vers, err := strconv.Atoi(revision) - if err != nil { - return 0, errors.New("revision conversion failed") - } - - return int64(vers), nil -} - -func rollback(f *watch.Factory, path string) (string, error) { - rs, err := findRS(f, path) - if err != nil { - return "", err - } - version, err := getRevision(rs) - if err != nil { - return "", err - } - - name, kind, apiGroup, err := controllerInfo(rs) - if err != nil { - return "", err - } - rb, err := polymorphichelpers.RollbackerFor(schema.GroupKind{Group: apiGroup, Kind: kind}, f.Client().DialOrDie()) - if err != nil { - return "", err - } - dp, err := findDP(f, client.FQN(rs.Namespace, name)) - if err != nil { - return "", err - } - res, err := rb.Rollback(dp, map[string]string{}, version, false) - if err != nil { - return "", err - } - - return res, nil -} diff --git a/internal/view/table.go b/internal/view/table.go index 9f478f65..bb017e83 100644 --- a/internal/view/table.go +++ b/internal/view/table.go @@ -40,12 +40,38 @@ func (t *Table) Init(ctx context.Context) (err error) { ctx = context.WithValue(ctx, internal.KeyStyles, t.app.Styles) ctx = context.WithValue(ctx, internal.KeyViewConfig, t.app.CustomView) t.Table.Init(ctx) + t.SetInputCapture(t.keyboard) t.bindKeys() t.GetModel().SetRefreshRate(time.Duration(t.app.Config.K9s.GetRefreshRate()) * time.Second) return nil } +// SendKey sends an keyboard event (testing only!). +func (t *Table) SendKey(evt *tcell.EventKey) { + t.keyboard(evt) +} + +func (t *Table) keyboard(evt *tcell.EventKey) *tcell.EventKey { + key := evt.Key() + if key == tcell.KeyUp || key == tcell.KeyDown { + return evt + } + + if key == tcell.KeyRune { + if t.FilterInput(evt.Rune()) { + return nil + } + key = ui.AsKey(evt) + } + + if a, ok := t.Actions()[key]; ok && !t.app.Content.IsTopDialog() { + return a.Action(evt) + } + + return evt +} + // Name returns the table name. func (t *Table) Name() string { return t.GVR().R() } diff --git a/internal/view/table_int_test.go b/internal/view/table_int_test.go index f7c14050..285a1110 100644 --- a/internal/view/table_int_test.go +++ b/internal/view/table_int_test.go @@ -2,7 +2,6 @@ package view import ( "context" - "fmt" "io/ioutil" "path/filepath" "testing" @@ -78,11 +77,6 @@ func TestTableViewSort(t *testing.T) { v.SetModel(&testTableModel{}) v.SortColCmd("NAME", true)(nil) assert.Equal(t, 3, v.GetRowCount()) - for i := 0; i < v.GetRowCount(); i++ { - for j := 0; j < v.GetColumnCount(); j++ { - fmt.Println(v.GetCell(i, j).Text) - } - } assert.Equal(t, "blee", v.GetCell(1, 0).Text) v.SortInvertCmd(nil)