From 4c222f80edc63b59e122e2fe372391bdac9230f6 Mon Sep 17 00:00:00 2001 From: derailed Date: Tue, 31 Dec 2019 12:33:54 -0700 Subject: [PATCH] Fixes for #455 #454 #453 --- change_logs/release_0.10.3.md | 29 +++++ internal/dao/assets/dr.json | 99 ++++++++++++++++ internal/dao/dp.go | 2 + internal/dao/registry.go | 8 +- internal/dao/registry_test.go | 100 ++++++++++++++++ internal/model/table.go | 2 +- internal/render/row_event.go | 12 +- internal/render/row_event_test.go | 62 +++++++++- internal/render/{table.go => table_data.go} | 15 ++- internal/render/table_data_test.go | 53 +++++++++ internal/ui/select_table.go | 22 ++-- internal/ui/table.go | 4 +- internal/view/browser.go | 6 +- internal/view/dp_test.go | 2 +- internal/view/ds_test.go | 2 +- internal/view/help.go | 124 +++++++++----------- internal/view/help_test.go | 5 +- internal/view/helpers.go | 42 +++++++ internal/view/restart_extender.go | 4 +- internal/view/sts_test.go | 2 +- 20 files changed, 486 insertions(+), 109 deletions(-) create mode 100644 change_logs/release_0.10.3.md create mode 100644 internal/dao/assets/dr.json create mode 100644 internal/dao/registry_test.go rename internal/render/{table.go => table_data.go} (89%) create mode 100644 internal/render/table_data_test.go diff --git a/change_logs/release_0.10.3.md b/change_logs/release_0.10.3.md new file mode 100644 index 00000000..4efd1375 --- /dev/null +++ b/change_logs/release_0.10.3.md @@ -0,0 +1,29 @@ + + +# Release v0.10.3 + +## 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) + +--- + +## Change Logs + +Maintenance release! + +Thank you all for kicking the tires on these new drops and in making sure we get back to nominal quickly. You guys ROCK!! + +--- + +## Resolved Bugs/Features + +* [Issue #455](https://github.com/derailed/k9s/issues/455) +* [Issue #454](https://github.com/derailed/k9s/issues/454) +* [Issue #453](https://github.com/derailed/k9s/issues/453) + +--- + + © 2019 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) diff --git a/internal/dao/assets/dr.json b/internal/dao/assets/dr.json new file mode 100644 index 00000000..f9ffbef0 --- /dev/null +++ b/internal/dao/assets/dr.json @@ -0,0 +1,99 @@ +{ + "apiVersion": "apiextensions.k8s.io/v1", + "kind": "CustomResourceDefinition", + "metadata": { + "annotations": { + "helm.sh/resource-policy": "keep", + "kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"apiextensions.k8s.io/v1beta1\",\"kind\":\"CustomResourceDefinition\",\"metadata\":{\"annotations\":{\"helm.sh/resource-policy\":\"keep\"},\"labels\":{\"app\":\"istio-pilot\",\"chart\":\"istio\",\"heritage\":\"Tiller\",\"release\":\"istio\"},\"name\":\"destinationrules.networking.istio.io\"},\"spec\":{\"additionalPrinterColumns\":[{\"JSONPath\":\".spec.host\",\"description\":\"The name of a service from the service registry\",\"name\":\"Host\",\"type\":\"string\"},{\"JSONPath\":\".metadata.creationTimestamp\",\"description\":\"CreationTimestamp is a timestamp representing the server time when this object was created. It is not guaranteed to be set in happens-before order across separate operations. Clients may not set this value. It is represented in RFC3339 form and is in UTC.\\n\\nPopulated by the system. Read-only. Null for lists. More info: https://git.k8s.io/community/contributors/devel/api-conventions.md#metadata\",\"name\":\"Age\",\"type\":\"date\"}],\"group\":\"networking.istio.io\",\"names\":{\"categories\":[\"istio-io\",\"networking-istio-io\"],\"kind\":\"DestinationRule\",\"listKind\":\"DestinationRuleList\",\"plural\":\"destinationrules\",\"shortNames\":[\"dr\"],\"singular\":\"destinationrule\"},\"scope\":\"Namespaced\",\"version\":\"v1alpha3\"}}\n" + }, + "creationTimestamp": "2019-12-30T16:13:02Z", + "generation": 1, + "labels": { + "app": "istio-pilot", + "chart": "istio", + "heritage": "Tiller", + "release": "istio" + }, + "name": "destinationrules.networking.istio.io", + "resourceVersion": "2773373", + "selfLink": "/apis/apiextensions.k8s.io/v1/customresourcedefinitions/destinationrules.networking.istio.io", + "uid": "123a30f8-8fcf-44b5-84b7-35f8c7869828" + }, + "spec": { + "conversion": { + "strategy": "None" + }, + "group": "networking.istio.io", + "version": "v1alpha3", + "names": { + "categories": [ + "istio-io", + "networking-istio-io" + ], + "kind": "DestinationRule", + "listKind": "DestinationRuleList", + "plural": "destinationrules", + "shortNames": [ + "dr" + ], + "singular": "destinationrule" + }, + "preserveUnknownFields": true, + "scope": "Namespaced", + "versions": [ + { + "additionalPrinterColumns": [ + { + "description": "The name of a service from the service registry", + "jsonPath": ".spec.host", + "name": "Host", + "type": "string" + }, + { + "description": "CreationTimestamp is a timestamp representing the server time when this object was created. It is not guaranteed to be set in happens-before order across separate operations. Clients may not set this value. It is represented in RFC3339 form and is in UTC.\n\nPopulated by the system. Read-only. Null for lists. More info: https://git.k8s.io/community/contributors/devel/api-conventions.md#metadata", + "jsonPath": ".metadata.creationTimestamp", + "name": "Age", + "type": "date" + } + ], + "name": "v1alpha3", + "served": true, + "storage": true + } + ] + }, + "status": { + "acceptedNames": { + "categories": [ + "istio-io", + "networking-istio-io" + ], + "kind": "DestinationRule", + "listKind": "DestinationRuleList", + "plural": "destinationrules", + "shortNames": [ + "dr" + ], + "singular": "destinationrule" + }, + "conditions": [ + { + "lastTransitionTime": "2019-12-30T16:13:02Z", + "message": "no conflicts found", + "reason": "NoConflicts", + "status": "True", + "type": "NamesAccepted" + }, + { + "lastTransitionTime": "2019-12-30T16:13:02Z", + "message": "the initial names have been accepted", + "reason": "InitialNamesAccepted", + "status": "True", + "type": "Established" + } + ], + "storedVersions": [ + "v1alpha3" + ] + } +} \ No newline at end of file diff --git a/internal/dao/dp.go b/internal/dao/dp.go index ba6429ae..32753f95 100644 --- a/internal/dao/dp.go +++ b/internal/dao/dp.go @@ -6,6 +6,7 @@ import ( "fmt" "github.com/derailed/k9s/internal/client" + "github.com/rs/zerolog/log" appsv1 "k8s.io/api/apps/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" @@ -27,6 +28,7 @@ var _ Scalable = &Deployment{} // Scale a Deployment. func (d *Deployment) Scale(path string, replicas int32) error { + log.Debug().Msgf("SCALING DEPLOYMENT!! %q:%d", path, replicas) ns, n := client.Namespaced(path) scale, err := d.Client().DialOrDie().AppsV1().Deployments(ns).GetScale(n, metav1.GetOptions{}) if err != nil { diff --git a/internal/dao/registry.go b/internal/dao/registry.go index a46df6c8..b30ba520 100644 --- a/internal/dao/registry.go +++ b/internal/dao/registry.go @@ -218,7 +218,7 @@ func extractMeta(o runtime.Object) (metav1.APIResource, []error) { crd, ok := o.(*unstructured.Unstructured) if !ok { - return m, append(errs, fmt.Errorf("Expected CustomResourceDefinition, but got %T", o)) + return m, append(errs, fmt.Errorf("Expected Unstructured, but got %T", o)) } var spec map[string]interface{} @@ -254,6 +254,7 @@ func extractSlice(m map[string]interface{}, n string, errs []error) ([]string, [ if m[n] == nil { return nil, errs } + s, ok := m[n].([]string) if ok { return s, errs @@ -268,10 +269,11 @@ func extractSlice(m map[string]interface{}, n string, errs []error) ([]string, [ for i, name := range ii { ss[i], ok = name.(string) if !ok { - return s, append(errs, fmt.Errorf("expecting string shortnames")) + return ss, append(errs, fmt.Errorf("expecting string shortnames")) } } - return s, errs + + return ss, errs } func extractStr(m map[string]interface{}, n string, errs []error) (string, []error) { diff --git a/internal/dao/registry_test.go b/internal/dao/registry_test.go new file mode 100644 index 00000000..241869b8 --- /dev/null +++ b/internal/dao/registry_test.go @@ -0,0 +1,100 @@ +package dao + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "testing" + + "github.com/stretchr/testify/assert" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +func TestExtractMeta(t *testing.T) { + c := load(t, "dr") + m, ee := extractMeta(c) + + assert.Equal(t, 0, len(ee)) + assert.Equal(t, "destinationrules", m.Name) + assert.Equal(t, "destinationrule", m.SingularName) + assert.Equal(t, "DestinationRule", m.Kind) + assert.Equal(t, "networking.istio.io", m.Group) + assert.Equal(t, "v1alpha3", m.Version) + assert.Equal(t, true, m.Namespaced) + assert.Equal(t, []string{"dr"}, m.ShortNames) + var vv metav1.Verbs + assert.Equal(t, vv, m.Verbs) +} + +func TestExtractSlice(t *testing.T) { + uu := map[string]struct { + m map[string]interface{} + n string + nn []string + ee []error + }{ + "plain": { + m: map[string]interface{}{"shortNames": []string{"a", "b", "c"}}, + n: "shortNames", + nn: []string{"a", "b", "c"}, + }, + "empty": { + m: map[string]interface{}{}, + n: "shortNames", + }, + } + + var ee []error + for k := range uu { + u := uu[k] + t.Run(k, func(t *testing.T) { + ss, e := extractSlice(u.m, u.n, ee) + assert.Equal(t, u.ee, e) + assert.Equal(t, u.nn, ss) + }) + } +} + +func TestExtractString(t *testing.T) { + uu := map[string]struct { + m map[string]interface{} + n string + s string + ee []error + }{ + "plain": { + m: map[string]interface{}{"blee": "fred"}, + n: "blee", + s: "fred", + }, + "missing": { + m: map[string]interface{}{}, + n: "blee", + ee: []error{fmt.Errorf("failed to extract string blee")}, + }, + } + + var ee []error + for k := range uu { + u := uu[k] + t.Run(k, func(t *testing.T) { + as, ae := extractStr(u.m, u.n, ee) + assert.Equal(t, u.ee, ae) + assert.Equal(t, u.s, as) + }) + } +} + +// Helpers... + +func load(t *testing.T, n string) *unstructured.Unstructured { + raw, err := ioutil.ReadFile(fmt.Sprintf("assets/%s.json", n)) + assert.Nil(t, err) + + var o unstructured.Unstructured + err = json.Unmarshal(raw, &o) + assert.Nil(t, err) + + return &o +} diff --git a/internal/model/table.go b/internal/model/table.go index 38232577..b4de9017 100644 --- a/internal/model/table.go +++ b/internal/model/table.go @@ -13,7 +13,7 @@ import ( ) const ( - refreshRate = 1 * time.Second + refreshRate = 2 * time.Second noDataCount = 2 ) diff --git a/internal/render/row_event.go b/internal/render/row_event.go index 07b2a068..3b962fcd 100644 --- a/internal/render/row_event.go +++ b/internal/render/row_event.go @@ -117,19 +117,11 @@ func (rr RowEvents) Upsert(e RowEvent) RowEvents { // Delete removes an element by id. func (rr RowEvents) Delete(id string) RowEvents { - idx, ok := rr.FindIndex(id) + victim, ok := rr.FindIndex(id) if !ok { return rr } - - if idx == 0 { - return rr[1:] - } - if idx == len(rr)-1 { - return rr[:len(rr)-1] - } - - return append(rr[:idx], rr[idx+1:]...) + return append(rr[0:victim], rr[victim+1:]...) } // Clear delete all row events diff --git a/internal/render/row_event_test.go b/internal/render/row_event_test.go index a0d4b31d..496eca65 100644 --- a/internal/render/row_event_test.go +++ b/internal/render/row_event_test.go @@ -8,6 +8,58 @@ import ( "github.com/stretchr/testify/assert" ) +func TestRowEventsDelete(t *testing.T) { + uu := map[string]struct { + re render.RowEvents + id string + e render.RowEvents + }{ + "first": { + re: render.RowEvents{ + {Row: render.Row{ID: "A", Fields: render.Fields{"1", "2", "3"}}}, + {Row: render.Row{ID: "B", Fields: render.Fields{"0", "2", "3"}}}, + {Row: render.Row{ID: "C", Fields: render.Fields{"10", "2", "3"}}}, + }, + id: "A", + e: render.RowEvents{ + {Row: render.Row{ID: "B", Fields: render.Fields{"0", "2", "3"}}}, + {Row: render.Row{ID: "C", Fields: render.Fields{"10", "2", "3"}}}, + }, + }, + "middle": { + re: render.RowEvents{ + {Row: render.Row{ID: "A", Fields: render.Fields{"1", "2", "3"}}}, + {Row: render.Row{ID: "B", Fields: render.Fields{"0", "2", "3"}}}, + {Row: render.Row{ID: "C", Fields: render.Fields{"10", "2", "3"}}}, + }, + id: "B", + e: render.RowEvents{ + {Row: render.Row{ID: "A", Fields: render.Fields{"1", "2", "3"}}}, + {Row: render.Row{ID: "C", Fields: render.Fields{"10", "2", "3"}}}, + }, + }, + "last": { + re: render.RowEvents{ + {Row: render.Row{ID: "A", Fields: render.Fields{"1", "2", "3"}}}, + {Row: render.Row{ID: "B", Fields: render.Fields{"0", "2", "3"}}}, + {Row: render.Row{ID: "C", Fields: render.Fields{"10", "2", "3"}}}, + }, + id: "C", + e: render.RowEvents{ + {Row: render.Row{ID: "A", Fields: render.Fields{"1", "2", "3"}}}, + {Row: render.Row{ID: "B", Fields: render.Fields{"0", "2", "3"}}}, + }, + }, + } + + for k := range uu { + u := uu[k] + t.Run(k, func(t *testing.T) { + assert.Equal(t, u.e, u.re.Delete(u.id)) + }) + } +} + func TestSort(t *testing.T) { uu := map[string]struct { re render.RowEvents @@ -32,10 +84,10 @@ func TestSort(t *testing.T) { } for k := range uu { - uc := uu[k] + u := uu[k] t.Run(k, func(t *testing.T) { - uc.re.Sort("", uc.col, uc.asc) - assert.Equal(t, uc.e, uc.re) + u.re.Sort("", u.col, u.asc) + assert.Equal(t, u.e, u.re) }) } } @@ -52,9 +104,9 @@ func TestDefaultColorer(t *testing.T) { } for k := range uu { - uc := uu[k] + u := uu[k] t.Run(k, func(t *testing.T) { - assert.Equal(t, uc.e, render.DefaultColorer("", render.RowEvent{})) + assert.Equal(t, u.e, render.DefaultColorer("", render.RowEvent{})) }) } } diff --git a/internal/render/table.go b/internal/render/table_data.go similarity index 89% rename from internal/render/table.go rename to internal/render/table_data.go index 4496d21d..d148f93e 100644 --- a/internal/render/table.go +++ b/internal/render/table_data.go @@ -1,6 +1,10 @@ package render -import "sync" +import ( + "sync" + + "github.com/rs/zerolog/log" +) // TableData tracks a K8s resource for tabular display. type TableData struct { @@ -40,6 +44,7 @@ func (t *TableData) Update(rows Rows) { t.RowEvents = append(t.RowEvents, NewRowEvent(EventAdd, row)) continue } + if index, ok := t.RowEvents.FindIndex(row.ID); ok { delta := NewDeltaRow(t.RowEvents[index].Row, row, t.Header.HasAge()) if delta.IsBlank() { @@ -60,6 +65,7 @@ func (t *TableData) Update(rows Rows) { // Delete delete items in cache that are no longer valid. func (t *TableData) Delete(newKeys []string) { + var victims []string for _, re := range t.RowEvents { var found bool for i, key := range newKeys { @@ -70,9 +76,14 @@ func (t *TableData) Delete(newKeys []string) { } } if !found { - t.RowEvents = t.RowEvents.Delete(re.Row.ID) + victims = append(victims, re.Row.ID) } } + + for _, id := range victims { + log.Debug().Msgf("Deleting %s", id) + t.RowEvents = t.RowEvents.Delete(id) + } } // Diff checks if two tables are equal. diff --git a/internal/render/table_data_test.go b/internal/render/table_data_test.go new file mode 100644 index 00000000..10391f4f --- /dev/null +++ b/internal/render/table_data_test.go @@ -0,0 +1,53 @@ +package render_test + +import ( + "testing" + + "github.com/derailed/k9s/internal/render" + "github.com/stretchr/testify/assert" +) + +func TestTableDataDelete(t *testing.T) { + uu := map[string]struct { + re render.RowEvents + kk []string + e render.RowEvents + }{ + "ordered": { + re: render.RowEvents{ + {Row: render.Row{ID: "A", Fields: render.Fields{"1", "2", "3"}}}, + {Row: render.Row{ID: "B", Fields: render.Fields{"0", "2", "3"}}}, + {Row: render.Row{ID: "C", Fields: render.Fields{"10", "2", "3"}}}, + }, + kk: []string{"A", "C"}, + e: render.RowEvents{ + {Row: render.Row{ID: "A", Fields: render.Fields{"1", "2", "3"}}}, + {Row: render.Row{ID: "C", Fields: render.Fields{"10", "2", "3"}}}, + }, + }, + "unordered": { + re: render.RowEvents{ + {Row: render.Row{ID: "A", Fields: render.Fields{"1", "2", "3"}}}, + {Row: render.Row{ID: "B", Fields: render.Fields{"0", "2", "3"}}}, + {Row: render.Row{ID: "C", Fields: render.Fields{"10", "2", "3"}}}, + {Row: render.Row{ID: "D", Fields: render.Fields{"10", "2", "3"}}}, + }, + kk: []string{"C", "A"}, + e: render.RowEvents{ + {Row: render.Row{ID: "A", Fields: render.Fields{"1", "2", "3"}}}, + {Row: render.Row{ID: "C", Fields: render.Fields{"10", "2", "3"}}}, + }, + }, + } + + var table render.TableData + for k := range uu { + u := uu[k] + t.Run(k, func(t *testing.T) { + table.RowEvents = u.re + table.Delete(u.kk) + assert.Equal(t, u.e, table.RowEvents) + }) + } + +} diff --git a/internal/ui/select_table.go b/internal/ui/select_table.go index 00f8775b..388fd4e9 100644 --- a/internal/ui/select_table.go +++ b/internal/ui/select_table.go @@ -53,7 +53,7 @@ type SelectTable struct { selectedRow int selectedFn func(string) string selectionListeners []SelectedRowFunc - marks map[string]bool + marks map[string]struct{} } // SetModel sets the table model. @@ -86,10 +86,8 @@ func (s *SelectTable) GetSelectedItems() []string { } var items []string - for item, marked := range s.marks { - if marked { - items = append(items, item) - } + for item := range s.marks { + items = append(items, item) } return items @@ -145,7 +143,7 @@ func (s *SelectTable) selectionChanged(r, c int) { return } - if s.marks[s.GetSelectedItem()] { + if _, ok := s.marks[s.GetSelectedItem()]; ok { s.SetSelectedStyle(tcell.ColorBlack, tcell.ColorCadetBlue, tcell.AttrBold) } else { cell := s.GetCell(r, c) @@ -171,10 +169,15 @@ func (s *SelectTable) DeleteMark(k string) { // ToggleMark toggles marked row func (s *SelectTable) ToggleMark() { - s.marks[s.GetSelectedItem()] = !s.marks[s.GetSelectedItem()] - if !s.marks[s.GetSelectedItem()] { + sel := s.GetSelectedItem() + if sel == "" { return } + if _, ok := s.marks[sel]; ok { + delete(s.marks, s.GetSelectedItem()) + } else { + s.marks[sel] = struct{}{} + } cell := s.GetCell(s.GetSelectedRowIndex(), 0) s.SetSelectedStyle( @@ -186,7 +189,8 @@ func (s *SelectTable) ToggleMark() { // IsMarked returns true if this item was marked. func (s *Table) IsMarked(item string) bool { - return s.marks[item] + _, ok := s.marks[item] + return ok } // AddSelectedRowListener add a new selected row listener. diff --git a/internal/ui/table.go b/internal/ui/table.go index 3699874b..c7cf8131 100644 --- a/internal/ui/table.go +++ b/internal/ui/table.go @@ -45,7 +45,7 @@ func NewTable(gvr string) *Table { SelectTable: &SelectTable{ Table: tview.NewTable(), model: model.NewTable(gvr), - marks: make(map[string]bool), + marks: make(map[string]struct{}), }, actions: make(KeyActions), cmdBuff: NewCmdBuff('/', FilterBuff), @@ -277,7 +277,7 @@ func (t *Table) buildRow(ns string, r int, re render.RowEvent, header render.Hea // ClearMarks clear out marked items. func (t *Table) ClearMarks() { - t.marks = map[string]bool{} + t.SelectTable.ClearMarks() t.Refresh() } diff --git a/internal/view/browser.go b/internal/view/browser.go index 323966c2..5dba3c44 100644 --- a/internal/view/browser.go +++ b/internal/view/browser.go @@ -357,12 +357,12 @@ func (b *Browser) refreshActions() { if client.Can(b.meta.Verbs, "delete") { aa[tcell.KeyCtrlD] = ui.NewKeyAction("Delete", b.deleteCmd, true) } - if client.Can(b.meta.Verbs, "view") { + + if !dao.IsK9sMeta(b.meta) { aa[ui.KeyY] = ui.NewKeyAction("YAML", b.viewCmd, true) - } - if client.Can(b.meta.Verbs, "describe") { aa[ui.KeyD] = ui.NewKeyAction("Describe", b.describeCmd, true) } + pluginActions(b, aa) hotKeyActions(b, aa) b.Actions().Add(aa) diff --git a/internal/view/dp_test.go b/internal/view/dp_test.go index 420e65a3..9f766734 100644 --- a/internal/view/dp_test.go +++ b/internal/view/dp_test.go @@ -13,6 +13,6 @@ func TestDeploy(t *testing.T) { assert.Nil(t, v.Init(makeCtx())) assert.Equal(t, "Deployments", v.Name()) - assert.Equal(t, 9, len(v.Hints())) + assert.Equal(t, 10, len(v.Hints())) } diff --git a/internal/view/ds_test.go b/internal/view/ds_test.go index 18b421ac..fe46b3c2 100644 --- a/internal/view/ds_test.go +++ b/internal/view/ds_test.go @@ -13,5 +13,5 @@ func TestDaemonSet(t *testing.T) { assert.Nil(t, v.Init(makeCtx())) assert.Equal(t, "DaemonSets", v.Name()) - assert.Equal(t, 10, len(v.Hints())) + assert.Equal(t, 11, len(v.Hints())) } diff --git a/internal/view/help.go b/internal/view/help.go index 768c789f..e6ff6ec5 100644 --- a/internal/view/help.go +++ b/internal/view/help.go @@ -8,7 +8,6 @@ import ( "strconv" "strings" - "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/model" "github.com/derailed/k9s/internal/render" @@ -25,6 +24,8 @@ const ( // Help presents a help viewer. type Help struct { *Table + + maxKey, maxDesc, maxRows int } // NewHelp returns a new help viewer. @@ -44,7 +45,7 @@ func (v *Help) Init(ctx context.Context) error { v.SetBorder(true) v.SetBorderPadding(0, 0, 1, 1) v.bindKeys() - v.build(v.app.Content.Top().Hints()) + v.build() v.SetBackgroundColor(v.App().Styles.BgColor()) return nil @@ -59,6 +60,40 @@ func (v *Help) bindKeys() { }) } +func (v *Help) computeMaxes(hh model.MenuHints) { + v.maxKey, v.maxDesc = 0, 0 + for _, h := range hh { + if len(h.Mnemonic) > v.maxKey { + v.maxKey = len(h.Mnemonic) + } + if len(h.Description) > v.maxDesc { + v.maxDesc = len(h.Description) + } + } + v.maxKey += 2 +} + +type HelpFunc func() model.MenuHints + +func (v *Help) build() { + v.Clear() + + ff := []HelpFunc{v.app.Content.Top().Hints, v.showGeneral, v.showNav, v.showHelp} + var col int + for i, section := range []string{"RESOURCE", "GENERAL", "NAVIGATION", "HELP"} { + hh := ff[i]() + sort.Sort(hh) + v.computeMaxes(hh) + v.addSection(col, section, hh) + col += 2 + } + + if h, err := v.showHotKeys(); err == nil { + v.computeMaxes(h) + v.addSection(col, "HOTKEYS", h) + } +} + func (v *Help) showHelp() model.MenuHints { return model.MenuHints{ { @@ -186,59 +221,55 @@ func (v *Help) resetTitle() { v.SetTitle(fmt.Sprintf(helpTitleFmt, helpTitle)) } -func (v *Help) build(hh model.MenuHints) { - v.Clear() - sort.Sort(hh) - - var col int - v.addSection(col, "RESOURCE", hh) - col += 2 - v.addSection(col, "GENERAL", v.showGeneral()) - col += 2 - v.addSection(col, "NAVIGATION", v.showNav()) - col += 2 - if h, err := v.showHotKeys(); err == nil { - v.addSection(col, "HOTKEYS", h) - col += 2 - } - v.addSection(col, "HELP", v.showHelp()) -} - func (v *Help) addSpacer(c int) { - cell := tview.NewTableCell("") + cell := tview.NewTableCell(render.Pad("", v.maxKey)) cell.SetBackgroundColor(v.App().Styles.BgColor()) cell.SetExpansion(1) v.SetCell(0, c, cell) } func (v *Help) addSection(c int, title string, hh model.MenuHints) { + if len(hh) > v.maxRows { + v.maxRows = len(hh) + } row := 0 - v.addSpacer(c) cell := tview.NewTableCell(title) cell.SetTextColor(tcell.ColorGreen) cell.SetAttributes(tcell.AttrBold) cell.SetExpansion(1) cell.SetAlign(tview.AlignLeft) - v.SetCell(row, c+1, cell) + v.SetCell(row, c, cell) + v.addSpacer(c + 1) row++ for _, h := range hh { col := c - cell := tview.NewTableCell(toMnemonic(h.Mnemonic)) + cell := tview.NewTableCell(render.Pad(toMnemonic(h.Mnemonic), v.maxKey)) if _, err := strconv.Atoi(h.Mnemonic); err != nil { cell.SetTextColor(tcell.ColorDodgerBlue) } else { cell.SetTextColor(tcell.ColorFuchsia) } cell.SetAttributes(tcell.AttrBold) - cell.SetAlign(tview.AlignRight) v.SetCell(row, col, cell) col++ - cell = tview.NewTableCell(h.Description) + cell = tview.NewTableCell(render.Pad(h.Description, v.maxDesc)) cell.SetTextColor(tcell.ColorWhite) v.SetCell(row, col, cell) row++ } + + if len(hh) < v.maxRows { + for i := v.maxRows - len(hh); i > 0; i-- { + col := c + cell := tview.NewTableCell(render.Pad("", v.maxKey)) + v.SetCell(row, col, cell) + col++ + cell = tview.NewTableCell(render.Pad("", v.maxDesc)) + v.SetCell(row, col, cell) + row++ + } + } } func toMnemonic(s string) string { @@ -260,44 +291,3 @@ func keyConv(s string) string { return strings.Replace(s, "alt", "opt", 1) } - -func defaultK9sEnv(app *App, sel string, row render.Row) K9sEnv { - ns, n := client.Namespaced(sel) - ctx, err := app.Conn().Config().CurrentContextName() - if err != nil { - ctx = render.NAValue - } - cluster, err := app.Conn().Config().CurrentClusterName() - if err != nil { - cluster = render.NAValue - } - user, err := app.Conn().Config().CurrentUserName() - if err != nil { - user = render.NAValue - } - groups, err := app.Conn().Config().CurrentGroupNames() - if err != nil { - groups = []string{render.NAValue} - } - var cfg string - kcfg := app.Conn().Config().Flags().KubeConfig - if kcfg != nil && *kcfg != "" { - cfg = *kcfg - } - - env := K9sEnv{ - "NAMESPACE": ns, - "NAME": n, - "CONTEXT": ctx, - "CLUSTER": cluster, - "USER": user, - "GROUPS": strings.Join(groups, ","), - "KUBECONFIG": cfg, - } - - for i, r := range row.Fields { - env["COL"+strconv.Itoa(i)] = r - } - - return env -} diff --git a/internal/view/help_test.go b/internal/view/help_test.go index 76db30c4..e65adb8f 100644 --- a/internal/view/help_test.go +++ b/internal/view/help_test.go @@ -1,6 +1,7 @@ package view_test import ( + "strings" "testing" "github.com/derailed/k9s/internal" @@ -22,6 +23,6 @@ func TestHelp(t *testing.T) { assert.Nil(t, v.Init(ctx)) assert.Equal(t, 17, v.GetRowCount()) assert.Equal(t, 8, v.GetColumnCount()) - assert.Equal(t, "", v.GetCell(1, 0).Text) - assert.Equal(t, "Kill", v.GetCell(1, 1).Text) + assert.Equal(t, "", strings.TrimSpace(v.GetCell(1, 0).Text)) + assert.Equal(t, "Kill", strings.TrimSpace(v.GetCell(1, 1).Text)) } diff --git a/internal/view/helpers.go b/internal/view/helpers.go index e8729a5f..db85bad9 100644 --- a/internal/view/helpers.go +++ b/internal/view/helpers.go @@ -5,6 +5,7 @@ import ( "context" "errors" "fmt" + "strconv" "strings" "github.com/derailed/k9s/internal" @@ -18,6 +19,47 @@ import ( "k8s.io/cli-runtime/pkg/printers" ) +func defaultK9sEnv(app *App, sel string, row render.Row) K9sEnv { + ns, n := client.Namespaced(sel) + ctx, err := app.Conn().Config().CurrentContextName() + if err != nil { + ctx = render.NAValue + } + cluster, err := app.Conn().Config().CurrentClusterName() + if err != nil { + cluster = render.NAValue + } + user, err := app.Conn().Config().CurrentUserName() + if err != nil { + user = render.NAValue + } + groups, err := app.Conn().Config().CurrentGroupNames() + if err != nil { + groups = []string{render.NAValue} + } + var cfg string + kcfg := app.Conn().Config().Flags().KubeConfig + if kcfg != nil && *kcfg != "" { + cfg = *kcfg + } + + env := K9sEnv{ + "NAMESPACE": ns, + "NAME": n, + "CONTEXT": ctx, + "CLUSTER": cluster, + "USER": user, + "GROUPS": strings.Join(groups, ","), + "KUBECONFIG": cfg, + } + + for i, r := range row.Fields { + env["COL"+strconv.Itoa(i)] = r + } + + return env +} + func describeResource(app *App, _, gvr, path string) { ns, n := client.Namespaced(path) yaml, err := dao.Describe(app.Conn(), client.GVR(gvr), ns, n) diff --git a/internal/view/restart_extender.go b/internal/view/restart_extender.go index d60032b6..8914372c 100644 --- a/internal/view/restart_extender.go +++ b/internal/view/restart_extender.go @@ -18,14 +18,14 @@ type RestartExtender struct { // NewRestartExtender returns a new extender. func NewRestartExtender(v ResourceViewer) ResourceViewer { r := RestartExtender{ResourceViewer: v} - r.SetBindKeysFn(r.bindKeys) + r.bindKeys(v.Actions()) return &r } // BindKeys creates additional menu actions. func (r *RestartExtender) bindKeys(aa ui.KeyActions) { - r.Actions().Add(ui.KeyActions{ + aa.Add(ui.KeyActions{ tcell.KeyCtrlT: ui.NewKeyAction("Restart", r.restartCmd, true), }) } diff --git a/internal/view/sts_test.go b/internal/view/sts_test.go index 3ae4e794..50a9a759 100644 --- a/internal/view/sts_test.go +++ b/internal/view/sts_test.go @@ -13,5 +13,5 @@ func TestStatefulSetNew(t *testing.T) { assert.Nil(t, s.Init(makeCtx())) assert.Equal(t, "StatefulSets", s.Name()) - assert.Equal(t, 7, len(s.Hints())) + assert.Equal(t, 8, len(s.Hints())) }