From fec714f0ca0ed7fd927b726411d5ff25697a5981 Mon Sep 17 00:00:00 2001 From: derailed Date: Wed, 27 Mar 2019 13:37:34 -0600 Subject: [PATCH 1/7] checkpoint --- internal/views/alias.go | 32 +--- internal/views/app.go | 9 + internal/views/colorer.go | 16 ++ internal/views/command.go | 13 +- internal/views/help.go | 11 +- internal/views/helpers.go | 2 + internal/views/rbac.go | 363 ++++++++++++++++++++++++++++++++++++ internal/views/rbac_test.go | 115 ++++++++++++ internal/views/registrar.go | 15 ++ internal/views/resource.go | 17 +- internal/views/table.go | 42 +++-- 11 files changed, 585 insertions(+), 50 deletions(-) create mode 100644 internal/views/rbac.go create mode 100644 internal/views/rbac_test.go diff --git a/internal/views/alias.go b/internal/views/alias.go index 036b4d67..aaf26662 100644 --- a/internal/views/alias.go +++ b/internal/views/alias.go @@ -4,11 +4,9 @@ import ( "context" "fmt" "strings" - "time" "github.com/derailed/k9s/internal/resource" "github.com/gdamore/tcell" - "github.com/rs/zerolog/log" ) const ( @@ -30,28 +28,8 @@ func newAliasView(app *appView) *aliasView { v.colorerFn = aliasColorer v.current = app.content.GetPrimitive("main").(igniter) v.currentNS = "" + v.registerActions() } - v.actions[tcell.KeyEnter] = newKeyAction("Goto", v.gotoCmd, true) - v.actions[tcell.KeyEscape] = newKeyAction("Reset", v.resetCmd, false) - v.actions[KeySlash] = newKeyAction("Filter", v.activateCmd, false) - v.actions[KeyShiftR] = newKeyAction("Sort Resources", v.sortResourceCmd, true) - v.actions[KeyShiftO] = newKeyAction("Sort Groups", v.sortGroupCmd, true) - - ctx, cancel := context.WithCancel(context.TODO()) - v.cancel = cancel - go func(ctx context.Context) { - for { - select { - case <-ctx.Done(): - log.Debug().Msg("Alias GR bailing out!") - return - case <-time.After(1 * time.Second): - v.update(v.hydrate()) - v.app.Draw() - } - } - }(ctx) - return &v } @@ -62,6 +40,14 @@ func (v *aliasView) init(context.Context, string) { v.resetTitle() } +func (v *aliasView) registerActions() { + v.actions[tcell.KeyEnter] = newKeyAction("Goto", v.gotoCmd, true) + v.actions[tcell.KeyEscape] = newKeyAction("Reset", v.resetCmd, false) + v.actions[KeySlash] = newKeyAction("Filter", v.activateCmd, false) + v.actions[KeyShiftR] = newKeyAction("Sort Resources", v.sortResourceCmd, true) + v.actions[KeyShiftO] = newKeyAction("Sort Groups", v.sortGroupCmd, true) +} + func (v *aliasView) getTitle() string { return aliasTitle } diff --git a/internal/views/app.go b/internal/views/app.go index bf05a982..c1aba4a7 100644 --- a/internal/views/app.go +++ b/internal/views/app.go @@ -20,6 +20,7 @@ type ( igniter interface { tview.Primitive + getTitle() string init(ctx context.Context, ns string) } @@ -30,6 +31,7 @@ type ( resourceViewer interface { igniter + setEnterFn(enterFn) } appView struct { @@ -90,6 +92,8 @@ func NewApp(cfg *config.Config) *appView { v.actions[tcell.KeyDelete] = newKeyAction("Erase", v.eraseCmd, false) v.actions[tcell.KeyTab] = newKeyAction("Focus", v.focusCmd, false) + // v.actions[KeyO] = newKeyAction("RBAC", v.rbacCmd, false) + return &v } @@ -156,6 +160,11 @@ func (a *appView) keyboard(evt *tcell.EventKey) *tcell.EventKey { return evt } +func (a *appView) rbacCmd(evt *tcell.EventKey) *tcell.EventKey { + a.inject(newRBACView(a, "", "aa_k9s", clusterRole)) + return evt +} + func (a *appView) redrawCmd(evt *tcell.EventKey) *tcell.EventKey { a.Draw() return evt diff --git a/internal/views/colorer.go b/internal/views/colorer.go index 2e14faa4..274ef69d 100644 --- a/internal/views/colorer.go +++ b/internal/views/colorer.go @@ -33,6 +33,13 @@ 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 +} + func podColorer(ns string, r *resource.RowEvent) tcell.Color { c := defaultColorer(ns, r) @@ -62,6 +69,7 @@ func podColorer(ns string, r *resource.RowEvent) tcell.Color { c = errColor } } + return c } @@ -74,6 +82,7 @@ func ctxColorer(ns string, r *resource.RowEvent) tcell.Color { if strings.Contains(strings.TrimSpace(r.Fields[0]), "*") { c = highlightColor } + return c } @@ -86,6 +95,7 @@ func pvColorer(ns string, r *resource.RowEvent) tcell.Color { if strings.TrimSpace(r.Fields[4]) != "Bound" { return errColor } + return stdColor } @@ -103,6 +113,7 @@ func pvcColorer(ns string, r *resource.RowEvent) tcell.Color { if strings.TrimSpace(r.Fields[markCol]) != "Bound" { c = errColor } + return c } @@ -119,6 +130,7 @@ func pdbColorer(ns string, r *resource.RowEvent) tcell.Color { if strings.TrimSpace(r.Fields[markCol]) != strings.TrimSpace(r.Fields[markCol+1]) { return errColor } + return stdColor } @@ -135,6 +147,7 @@ func dpColorer(ns string, r *resource.RowEvent) tcell.Color { if strings.TrimSpace(r.Fields[markCol]) != strings.TrimSpace(r.Fields[markCol+1]) { return errColor } + return stdColor } @@ -151,6 +164,7 @@ func stsColorer(ns string, r *resource.RowEvent) tcell.Color { if strings.TrimSpace(r.Fields[markCol]) != strings.TrimSpace(r.Fields[markCol+1]) { return errColor } + return stdColor } @@ -167,6 +181,7 @@ func rsColorer(ns string, r *resource.RowEvent) tcell.Color { if strings.TrimSpace(r.Fields[markCol]) != strings.TrimSpace(r.Fields[markCol+1]) { return errColor } + return stdColor } @@ -184,6 +199,7 @@ func evColorer(ns string, r *resource.RowEvent) tcell.Color { case "Killing": c = killColor } + return c } diff --git a/internal/views/command.go b/internal/views/command.go index cf5ed75e..991de100 100644 --- a/internal/views/command.go +++ b/internal/views/command.go @@ -44,7 +44,7 @@ func (c *command) run(cmd string) bool { } }() - var v igniter + var v resourceViewer switch cmd { case "q", "quit": c.app.Stop() @@ -56,12 +56,19 @@ func (c *command) run(cmd string) bool { if res, ok := resourceViews()[cmd]; ok { var r resource.List if res.listMxFn != nil { - r = res.listMxFn(c.app.conn(), k8s.NewMetricsServer(c.app.conn()), resource.DefaultNamespace) + r = res.listMxFn(c.app.conn(), + k8s.NewMetricsServer(c.app.conn()), + resource.DefaultNamespace, + ) } else { r = res.listFn(c.app.conn(), resource.DefaultNamespace) } v = res.viewFn(res.title, c.app, r, res.colorerFn) - c.app.flash(flashInfo, fmt.Sprintf("Viewing %s in namespace %s...", res.title, c.app.config.ActiveNamespace())) + if res.enterFn != nil { + v.setEnterFn(res.enterFn) + } + const fmat = "Viewing %s in namespace %s..." + c.app.flash(flashInfo, fmt.Sprintf(fmat, res.title, c.app.config.ActiveNamespace())) log.Debug().Msgf("Running command %s", cmd) c.exec(cmd, v) return true diff --git a/internal/views/help.go b/internal/views/help.go index 3326c93c..c1b13b4f 100644 --- a/internal/views/help.go +++ b/internal/views/help.go @@ -75,7 +75,7 @@ func (v *helpView) init(_ context.Context, _ string) { } fmt.Fprintf(v, "🏠 [aqua::b]%s\n", "General") for _, h := range general { - fmt.Fprintf(v, "[pink::b]%9s [gray::]%s\n", h.key, h.description) + v.printHelp(h.key, h.description) } navigation := []helpItem{ @@ -90,7 +90,7 @@ func (v *helpView) init(_ context.Context, _ string) { } fmt.Fprintf(v, "\n🤖 [aqua::b]%s\n", "View Navigation") for _, h := range navigation { - fmt.Fprintf(v, "[pink::b]%9s [gray::]%s\n", h.key, h.description) + v.printHelp(h.key, h.description) } views := []helpItem{ @@ -99,12 +99,15 @@ func (v *helpView) init(_ context.Context, _ string) { } fmt.Fprintf(v, "️️\n😱 [aqua::b]%s\n", "Help") for _, h := range views { - fmt.Fprintf(v, "[pink::b]%9s [gray::]%s\n", h.key, h.description) + v.printHelp(h.key, h.description) } - v.app.setHints(v.hints()) } +func (v *helpView) printHelp(key, desc string) { + fmt.Fprintf(v, "[pink::b]%9s [white::]%s\n", key, desc) +} + func (v *helpView) hints() hints { return v.actions.toHints() } diff --git a/internal/views/helpers.go b/internal/views/helpers.go index a5b381ba..b4be66b5 100644 --- a/internal/views/helpers.go +++ b/internal/views/helpers.go @@ -46,6 +46,7 @@ func deltas(c, n string) string { if len(c) == 0 { return n } + switch strings.Compare(c, n) { case 1, -1: return delta(n) @@ -76,6 +77,7 @@ func numerical(s string) (int, bool) { func delta(s string) string { return suffix(s, "𝜟") } + func plus(s string) string { return suffix(s, "+") } diff --git a/internal/views/rbac.go b/internal/views/rbac.go new file mode 100644 index 00000000..76cbc88e --- /dev/null +++ b/internal/views/rbac.go @@ -0,0 +1,363 @@ +package views + +import ( + "context" + "fmt" + "reflect" + "strings" + "time" + + "github.com/derailed/k9s/internal/resource" + "github.com/gdamore/tcell" + "github.com/rs/zerolog/log" + rbacv1 "k8s.io/api/rbac/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/watch" +) + +const ( + clusterRole roleKind = iota + role + + all = "*" + rbacTitle = "RBAC" + rbacTitleFmt = " [aqua::b]%s([fuchsia::b]%s[aqua::-])" +) + +type ( + roleKind = int8 + + rbacView struct { + *tableView + + current igniter + cancel context.CancelFunc + roleType roleKind + roleName string + cache resource.RowEvents + } +) + +var ( + rbacHeaderVerbs = resource.Row{ + "GET ", + "LIST ", + "DLIST ", + "WATCH ", + "CREATE", + "PATCH ", + "UPDATE", + "DELETE", + "EXTRAS", + } + rbacHeader = append(resource.Row{"NAME", "GROUP"}, rbacHeaderVerbs...) + + k8sVerbs = []string{ + "get", + "list", + "deletecollection", + "watch", + "create", + "patch", + "update", + "delete", + } + + httpVerbs = []string{ + "get", + "post", + "put", + "patch", + "delete", + "options", + } + + httpTok8sVerbs = map[string]string{ + "post": "create", + "put": "update", + } +) + +func newRBACView(app *appView, ns, name string, kind roleKind) *rbacView { + v := rbacView{} + { + v.roleName, v.roleType = name, kind + v.tableView = newTableView(app, v.getTitle()) + v.currentNS = ns + // v.SetSelectedStyle(tcell.ColorWhite, tcell.ColorDarkSeaGreen, tcell.AttrNone) + v.colorerFn = rbacColorer + v.current = app.content.GetPrimitive("main").(igniter) + } + v.actions[tcell.KeyEscape] = newKeyAction("Reset", v.resetCmd, false) + v.actions[KeySlash] = newKeyAction("Filter", v.activateCmd, false) + v.actions[KeyShiftO] = newKeyAction("Sort Groups", v.sortColCmd(1), true) + + ctx, cancel := context.WithCancel(context.Background()) + v.cancel = cancel + go func(ctx context.Context) { + for { + select { + case <-ctx.Done(): + log.Debug().Msg("RBAC Watch bailing out!") + return + case <-time.After(time.Duration(app.config.K9s.RefreshRate) * time.Second): + v.refresh() + v.app.Draw() + } + } + }(ctx) + + return &v +} + +// Init the view. +func (v *rbacView) init(_ context.Context, ns string) { + // v.baseTitle = v.getTitle() + v.sortCol = sortColumn{1, len(rbacHeader), true} + v.refresh() + v.app.SetFocus(v) +} + +func (v *rbacView) getTitle() string { + title := "ClusterRole" + if v.roleType == role { + title = "Role" + } + + return fmt.Sprintf(rbacTitleFmt, title, v.roleName) +} + +func (v *rbacView) refresh() { + data, err := v.reconcile(v.currentNS, v.roleName, v.roleType) + if err != nil { + log.Error().Err(err).Msgf("Unable to reconcile for %s:%d", v.roleName, v.roleType) + } + v.update(data) +} + +func (v *rbacView) resetCmd(evt *tcell.EventKey) *tcell.EventKey { + if !v.cmdBuff.empty() { + v.cmdBuff.reset() + return nil + } + + return v.backCmd(evt) +} + +func (v *rbacView) backCmd(evt *tcell.EventKey) *tcell.EventKey { + if v.cancel != nil { + v.cancel() + } + + if v.cmdBuff.isActive() { + v.cmdBuff.reset() + } else { + v.app.inject(v.current) + } + + return nil +} + +func (v *rbacView) hints() hints { + return v.actions.toHints() +} + +func (v *rbacView) reconcile(ns, name string, kind roleKind) (resource.TableData, error) { + evts, err := v.rowEvents(ns, name, kind) + if err != nil { + return resource.TableData{}, err + } + + data := resource.TableData{ + Header: rbacHeader, + Rows: make(resource.RowEvents, len(evts)), + Namespace: resource.NotNamespaced, + } + + noDeltas := make(resource.Row, len(rbacHeader)) + if len(v.cache) == 0 { + for k, ev := range evts { + ev.Action = resource.New + ev.Deltas = noDeltas + data.Rows[k] = ev + } + v.cache = evts + + return data, nil + } + + for k, ev := range evts { + data.Rows[k] = ev + + newr := ev.Fields + if _, ok := v.cache[k]; !ok { + ev.Action, ev.Deltas = watch.Added, noDeltas + continue + } + oldr := v.cache[k].Fields + deltas := make(resource.Row, len(newr)) + if !reflect.DeepEqual(oldr, newr) { + ev.Action = watch.Modified + for i, field := range oldr { + if field != newr[i] { + deltas[i] = field + } + } + ev.Deltas = deltas + } else { + ev.Action = resource.Unchanged + ev.Deltas = noDeltas + } + } + v.cache = evts + + for k := range v.cache { + if _, ok := data.Rows[k]; !ok { + delete(v.cache, k) + } + } + return data, nil +} + +func (v *rbacView) rowEvents(ns, name string, kind roleKind) (resource.RowEvents, error) { + var ( + evts resource.RowEvents + err error + ) + + switch kind { + case clusterRole: + evts, err = v.clusterPolicies(name) + case role: + evts, err = v.namespacedPolicies(name) + default: + return evts, fmt.Errorf("Expecting clusterrole/role but found %d", kind) + } + if err != nil { + log.Error().Err(err).Msg("Unable to load CR") + return evts, err + } + + return evts, nil +} + +func toGroup(g string) string { + if g == "" { + return "v1" + } + return g +} + +func (v *rbacView) clusterPolicies(name string) (resource.RowEvents, error) { + cr, err := v.app.conn().DialOrDie().Rbac().ClusterRoles().Get(name, metav1.GetOptions{}) + if err != nil { + return nil, err + } + + return v.parseRules(cr.Rules), nil +} + +func (v *rbacView) namespacedPolicies(path string) (resource.RowEvents, error) { + ns, na := namespaced(path) + cr, err := v.app.conn().DialOrDie().Rbac().Roles(ns).Get(na, metav1.GetOptions{}) + if err != nil { + return nil, err + } + + return v.parseRules(cr.Rules), nil +} + +func (*rbacView) parseRules(rules []rbacv1.PolicyRule) resource.RowEvents { + const ( + nameLen = 60 + groupLen = 30 + ) + + m := make(resource.RowEvents, len(rules)) + for _, r := range rules { + for _, grp := range r.APIGroups { + for _, res := range r.Resources { + k := res + if grp != "" { + k = res + "." + grp + } + for _, na := range r.ResourceNames { + n := k + "/" + na + m[n] = &resource.RowEvent{ + Fields: makeRow(resource.Pad(n, nameLen), resource.Pad(toGroup(grp), groupLen), asVerbs(r.Verbs...)), + } + } + m[k] = &resource.RowEvent{ + Fields: makeRow(resource.Pad(k, nameLen), resource.Pad(toGroup(grp), groupLen), asVerbs(r.Verbs...)), + } + } + } + for _, nres := range r.NonResourceURLs { + if nres[0] != '/' { + nres = "/" + nres + } + m[nres] = &resource.RowEvent{ + Fields: makeRow(resource.Pad(nres, nameLen), resource.Pad(resource.NAValue, groupLen), asVerbs(r.Verbs...)), + } + } + } + + return m +} + +func makeRow(res, group string, verbs []string) resource.Row { + r := make(resource.Row, 0, 12) + r = append(r, res, group) + + return append(r, verbs...) +} + +func asVerbs(verbs ...string) resource.Row { + const ( + verbLen = 4 + unknownLen = 30 + ) + + r := make(resource.Row, 0, len(k8sVerbs)+1) + for _, v := range k8sVerbs { + r = append(r, resource.Pad(toVerbIcon(hasVerb(verbs, v)), 4)) + } + + var unknowns []string + for _, v := range verbs { + if hv, ok := httpTok8sVerbs[v]; ok { + v = hv + } + if !hasVerb(k8sVerbs, v) && v != all { + unknowns = append(unknowns, v) + } + } + + return append(r, resource.Truncate(strings.Join(unknowns, ","), unknownLen)) +} + +func toVerbIcon(ok bool) string { + if ok { + return "[green::b] ✓ [::]" + } + return "[orangered::b] 𐄂 [::]" +} + +func hasVerb(verbs []string, verb string) bool { + if len(verbs) == 1 && verbs[0] == all { + return true + } + + for _, v := range verbs { + if hv, ok := httpTok8sVerbs[v]; ok { + if hv == verb { + return true + } + } + if v == verb { + return true + } + } + + return false +} diff --git a/internal/views/rbac_test.go b/internal/views/rbac_test.go new file mode 100644 index 00000000..7c341cc2 --- /dev/null +++ b/internal/views/rbac_test.go @@ -0,0 +1,115 @@ +package views + +import ( + "testing" + + "github.com/derailed/k9s/internal/resource" + "github.com/stretchr/testify/assert" + rbacv1 "k8s.io/api/rbac/v1" +) + +func TestHasVerb(t *testing.T) { + uu := []struct { + vv []string + v string + e bool + }{ + {[]string{"*"}, "get", true}, + {[]string{"get", "list", "watch"}, "watch", true}, + {[]string{"get", "dope", "list"}, "watch", false}, + {[]string{"get"}, "get", true}, + {[]string{"post"}, "create", true}, + {[]string{"put"}, "update", true}, + {[]string{"list", "deletecollection"}, "deletecollection", true}, + } + + for _, u := range uu { + assert.Equal(t, u.e, hasVerb(u.vv, u.v)) + } +} + +func TestAsVerbs(t *testing.T) { + ok, nok := toVerbIcon(true), toVerbIcon(false) + + uu := []struct { + vv []string + e resource.Row + }{ + {[]string{"*"}, resource.Row{ok, ok, ok, ok, ok, ok, ok, ok, ""}}, + {[]string{"get", "list", "patch"}, resource.Row{ok, ok, nok, nok, nok, ok, nok, nok, ""}}, + {[]string{"get", "list", "deletecollection", "post"}, resource.Row{ok, ok, ok, nok, ok, nok, nok, nok, ""}}, + {[]string{"get", "list", "blee"}, resource.Row{ok, ok, nok, nok, nok, nok, nok, nok, "blee"}}, + } + + for _, u := range uu { + assert.Equal(t, u.e, asVerbs(u.vv...)) + } +} + +func TestParseRules(t *testing.T) { + ok, nok := toVerbIcon(true), toVerbIcon(false) + _ = nok + + uu := []struct { + pp []rbacv1.PolicyRule + e map[string]resource.Row + }{ + { + []rbacv1.PolicyRule{ + {APIGroups: []string{"*"}, Resources: []string{"*"}, Verbs: []string{"*"}}, + }, + map[string]resource.Row{ + "*.*": resource.Row{resource.Pad("*.*", 60), resource.Pad("*", 30), ok, ok, ok, ok, ok, ok, ok, ok, ""}, + }, + }, + { + []rbacv1.PolicyRule{ + {APIGroups: []string{"*"}, Resources: []string{"*"}, Verbs: []string{"get"}}, + }, + map[string]resource.Row{ + "*.*": resource.Row{resource.Pad("*.*", 60), resource.Pad("*", 30), ok, nok, nok, nok, nok, nok, nok, nok, ""}, + }, + }, + { + []rbacv1.PolicyRule{ + {APIGroups: []string{""}, Resources: []string{"*"}, Verbs: []string{"list"}}, + }, + map[string]resource.Row{ + "*": resource.Row{resource.Pad("*", 60), resource.Pad("v1", 30), nok, ok, nok, nok, nok, nok, nok, nok, ""}, + }, + }, + { + []rbacv1.PolicyRule{ + {APIGroups: []string{""}, Resources: []string{"pods"}, Verbs: []string{"list"}, ResourceNames: []string{"fred"}}, + }, + map[string]resource.Row{ + "pods": resource.Row{resource.Pad("pods", 60), resource.Pad("v1", 30), nok, ok, nok, nok, nok, nok, nok, nok, ""}, + "pods/fred": resource.Row{resource.Pad("pods/fred", 60), resource.Pad("v1", 30), nok, ok, nok, nok, nok, nok, nok, nok, ""}, + }, + }, + { + []rbacv1.PolicyRule{ + {APIGroups: []string{}, Resources: []string{}, Verbs: []string{"get"}, NonResourceURLs: []string{"/fred"}}, + }, + map[string]resource.Row{ + "/fred": resource.Row{resource.Pad("/fred", 60), resource.Pad("", 30), ok, nok, nok, nok, nok, nok, nok, nok, ""}, + }, + }, + { + []rbacv1.PolicyRule{ + {APIGroups: []string{}, Resources: []string{}, Verbs: []string{"get"}, NonResourceURLs: []string{"fred"}}, + }, + map[string]resource.Row{ + "/fred": resource.Row{resource.Pad("/fred", 60), resource.Pad("", 30), ok, nok, nok, nok, nok, nok, nok, nok, ""}, + }, + }, + } + + var v rbacView + for _, u := range uu { + evts := v.parseRules(u.pp) + for k, v := range u.e { + assert.Equal(t, v, evts[k].Fields) + } + } +} diff --git a/internal/views/registrar.go b/internal/views/registrar.go index fa5eed7e..96579bd0 100644 --- a/internal/views/registrar.go +++ b/internal/views/registrar.go @@ -4,6 +4,7 @@ import ( "github.com/derailed/k9s/internal/k8s" "github.com/derailed/k9s/internal/resource" "github.com/gdamore/tcell" + "github.com/rs/zerolog/log" ) type ( @@ -11,6 +12,7 @@ type ( listFn func(c resource.Connection, ns string) resource.List listMxFn func(c resource.Connection, mx resource.MetricsServer, ns string) resource.List colorerFn func(ns string, evt *resource.RowEvent) tcell.Color + enterFn func(app *appView, ns, resource, selection string) resCmd struct { title string @@ -18,6 +20,7 @@ type ( viewFn viewFn listFn listFn listMxFn listMxFn + enterFn enterFn colorerFn colorerFn } ) @@ -72,6 +75,16 @@ func allCRDs(c k8s.Connection) map[string]k8s.APIGroup { return m } +func showRBAC(app *appView, ns, resource, selection string) { + log.Debug().Msgf("Entered FN on `%s`--%s:%s", ns, resource, selection) + kind := clusterRole + if resource == "role" { + kind = role + } + app.command.pushCmd("policies") + app.inject(newRBACView(app, ns, selection, kind)) +} + func resourceViews() map[string]resCmd { return map[string]resCmd{ "cm": { @@ -86,6 +99,7 @@ func resourceViews() map[string]resCmd { api: "rbac.authorization.k8s.io", viewFn: newResourceView, listFn: resource.NewClusterRoleList, + enterFn: showRBAC, colorerFn: defaultColorer, }, "crb": { @@ -226,6 +240,7 @@ func resourceViews() map[string]resCmd { api: "rbac.authorization.k8s.io", viewFn: newResourceView, listFn: resource.NewRoleList, + enterFn: showRBAC, colorerFn: defaultColorer, }, "rs": { diff --git a/internal/views/resource.go b/internal/views/resource.go index 32f08140..30c4befb 100644 --- a/internal/views/resource.go +++ b/internal/views/resource.go @@ -42,6 +42,7 @@ type ( selectedNS string update sync.Mutex list resource.List + enterFn enterFn extraActionsFn func(keyActions) selectedFn func() string decorateDataFn func(resource.TableData) resource.TableData @@ -124,9 +125,21 @@ func (v *resourceView) hints() hints { return v.CurrentPage().Item.(hinter).hints() } +func (v *resourceView) setEnterFn(f enterFn) { + v.enterFn = f +} + // ---------------------------------------------------------------------------- // Actions... +func (v *resourceView) enterCmd(*tcell.EventKey) *tcell.EventKey { + v.app.flash(flashInfo, "Enter pressed...") + if v.enterFn != nil { + v.enterFn(v.app, v.list.GetNamespace(), v.list.GetName(), v.selectedItem) + } + return nil +} + func (v *resourceView) refreshCmd(*tcell.EventKey) *tcell.EventKey { v.app.flash(flashInfo, "Refreshing...") v.refresh() @@ -359,6 +372,8 @@ func (v *resourceView) refreshActions() { } } + aa[tcell.KeyEnter] = newKeyAction("Enter", v.enterCmd, true) + aa[tcell.KeyCtrlR] = newKeyAction("Refresh", v.refreshCmd, false) aa[KeyHelp] = newKeyAction("Help", v.app.noopCmd, false) aa[KeyP] = newKeyAction("Previous", v.app.prevCmd, false) @@ -370,7 +385,7 @@ func (v *resourceView) refreshActions() { aa[tcell.KeyCtrlD] = newKeyAction("Delete", v.deleteCmd, true) } if v.list.Access(resource.ViewAccess) { - aa[KeyV] = newKeyAction("View", v.viewCmd, true) + aa[KeyY] = newKeyAction("YAML", v.viewCmd, true) } if v.list.Access(resource.DescribeAccess) { aa[KeyD] = newKeyAction("Describe", v.describeCmd, true) diff --git a/internal/views/table.go b/internal/views/table.go index c586d7c4..beb4d373 100644 --- a/internal/views/table.go +++ b/internal/views/table.go @@ -14,7 +14,7 @@ import ( ) const ( - titleFmt = " [aqua::b]%s[aqua::-]([fuchsia::b]%d[aqua::-]) " + titleFmt = " [aqua::b]%s[aqua::-][[fuchsia::b]%d[aqua::-]] " searchFmt = "<[green::b]/%s[aqua::]> " nsTitleFmt = " [aqua::b]%s([fuchsia::b]%s[aqua::-])[aqua::-][[aqua::b]%d[aqua::-]][aqua::-] " ) @@ -63,24 +63,8 @@ func newTableView(app *appView, title string) *tableView { v.SetSelectable(true, false) v.SetSelectedStyle(tcell.ColorBlack, tcell.ColorAqua, tcell.AttrBold) v.SetInputCapture(v.keyboard) + v.registerHandlers() } - - v.actions[KeyShiftI] = newKeyAction("Invert", v.sortInvertCmd, true) - v.actions[KeyShiftN] = newKeyAction("Sort Name", v.sortColCmd(0), true) - v.actions[KeyShiftA] = newKeyAction("Sort Age", v.sortColCmd(-1), true) - - v.actions[KeySlash] = newKeyAction("Filter Mode", v.activateCmd, false) - v.actions[tcell.KeyEscape] = newKeyAction("Filter Reset", v.resetCmd, false) - v.actions[tcell.KeyEnter] = newKeyAction("Filter", v.filterCmd, false) - - 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", app.puntCmd, false) - v.actions[KeyShiftG] = newKeyAction("Bottom", app.puntCmd, false) - v.actions[KeyB] = newKeyAction("Down", v.pageDownCmd, false) - v.actions[KeyF] = newKeyAction("Up", v.pageUpCmd, false) - return &v } @@ -128,6 +112,7 @@ func (v *tableView) pageDownCmd(evt *tcell.EventKey) *tcell.EventKey { func (v *tableView) filterCmd(evt *tcell.EventKey) *tcell.EventKey { v.cmdBuff.setActive(false) v.refresh() + return nil } @@ -309,10 +294,11 @@ func (v *tableView) doUpdate(data resource.TableData) { row++ // for k := range data.Rows { - // log.Debug().Msgf("Keys: %s", k) + // log.Debug().Msgf("Keys: `%s`", k) // } keys := v.sortRows(data) + // log.Debug().Msgf("KEYS %#v", keys) groupKeys := map[string][]string{} for _, k := range keys { // log.Debug().Msgf("RKEY: %s", k) @@ -447,6 +433,24 @@ func (v *tableView) resetTitle() { // ---------------------------------------------------------------------------- // Event listeners... +func (v *tableView) registerHandlers() { + v.actions[KeyShiftI] = newKeyAction("Invert", v.sortInvertCmd, true) + v.actions[KeyShiftN] = newKeyAction("Sort Name", v.sortColCmd(0), true) + v.actions[KeyShiftA] = newKeyAction("Sort Age", v.sortColCmd(-1), true) + + v.actions[KeySlash] = newKeyAction("Filter Mode", v.activateCmd, false) + v.actions[tcell.KeyEscape] = newKeyAction("Filter Reset", v.resetCmd, false) + v.actions[tcell.KeyEnter] = newKeyAction("Filter", v.filterCmd, false) + + 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) changed(s string) { } From 437e73ae6b1aa304f8afe0582f0a4c4573732c12 Mon Sep 17 00:00:00 2001 From: derailed Date: Wed, 27 Mar 2019 22:46:31 -0600 Subject: [PATCH 2/7] added rbac and fu views --- .goreleaser.yml | 28 +-- internal/resource/helpers.go | 8 + internal/resource/helpers_test.go | 16 ++ internal/resource/job_int_test.go | 4 +- internal/resource/list.go | 4 + internal/resource/pod.go | 19 +- internal/resource/pod_test.go | 2 +- internal/views/app.go | 2 +- internal/views/command.go | 24 ++- internal/views/fu.go | 326 ++++++++++++++++++++++++++++++ internal/views/rbac.go | 84 ++++---- internal/views/registrar.go | 2 - internal/views/table.go | 110 +++++----- internal/views/table_test.go | 72 +++++++ 14 files changed, 566 insertions(+), 135 deletions(-) create mode 100644 internal/views/fu.go create mode 100644 internal/views/table_test.go diff --git a/.goreleaser.yml b/.goreleaser.yml index 4f679b79..03150ea3 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -3,10 +3,8 @@ before: hooks: - go mod download - go generate ./... - release: prerelease: true - builds: - env: - CGO_ENABLED=0 @@ -24,14 +22,14 @@ builds: - 7 ldflags: - -s -w -X github.com/derailed/k9s/cmd.version={{.Version}} -X github.com/derailed/k9s/cmd.commit={{.Commit}} -X github.com/derailed/k9s/cmd.date={{.Date}} - archive: replacements: darwin: Darwin linux: Linux windows: Windows - arm: 32-bit - arm64: 64-bit + bit: Arm + bitv6: Arm6 + bitv7: Arm7 386: i386 amd64: x86_64 checksum: @@ -63,29 +61,23 @@ brew: # Snap snapcraft: name: k9s - name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" - - replacements: - amd64: 64-bit - 386: 32-bit - darwin: macOS - linux: Tux - - publish: false - summary: K9s is a CLI to view and manage your Kubernetes clusters. description: | K9s is a CLI to view and manage your Kubernetes clusters. By leveraging a terminal UI, you can easily traverse Kubernetes resources and view the state of you clusters in a single powerful session. - + name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" + replacements: + amd64: 64-bit + 386: 32-bit + darwin: macOS + linux: Tux + publish: false grade: devel confinement: devmode - apps: k9s: plugs: ["home", "network", "home-dir"] - plugs: home-dir: read: diff --git a/internal/resource/helpers.go b/internal/resource/helpers.go index a8857499..a57b87dd 100644 --- a/internal/resource/helpers.go +++ b/internal/resource/helpers.go @@ -90,6 +90,14 @@ func toAge(timestamp metav1.Time) string { return duration.HumanDuration(time.Since(timestamp.Time)) } +// FixCol set column width to specified size by either truncating or padding. +func FixCol(s string, size int) string { + if len(s) > size { + return Truncate(s, size) + } + return s + strings.Repeat(" ", size-len(s)) +} + // Pad a string up to the given length. func Pad(s string, l int) string { fmat := "%-" + strconv.Itoa(l) + "s" diff --git a/internal/resource/helpers_test.go b/internal/resource/helpers_test.go index aa8a0cda..422e64da 100644 --- a/internal/resource/helpers_test.go +++ b/internal/resource/helpers_test.go @@ -109,6 +109,22 @@ func TestTruncate(t *testing.T) { } } +func TestSizeCol(t *testing.T) { + uu := []struct { + s string + l int + e string + }{ + {"fred", 3, "fr…"}, + {"01234567890", 10, "012345678…"}, + {"fred", 10, "fred "}, + } + + for _, u := range uu { + assert.Equal(t, u.e, FixCol(u.s, u.l)) + } +} + func TestMapToStr(t *testing.T) { uu := []struct { i map[string]string diff --git a/internal/resource/job_int_test.go b/internal/resource/job_int_test.go index 62257df3..4b3110cd 100644 --- a/internal/resource/job_int_test.go +++ b/internal/resource/job_int_test.go @@ -96,9 +96,9 @@ func TestJobToDuration(t *testing.T) { }, { batchv1.JobStatus{ - StartTime: &t1, + StartTime: &metav1.Time{time.Now().Add(-10 * time.Second)}, }, - "101d", + "10s", }, { batchv1.JobStatus{ diff --git a/internal/resource/list.go b/internal/resource/list.go index 837173d8..c3de5a51 100644 --- a/internal/resource/list.go +++ b/internal/resource/list.go @@ -145,6 +145,10 @@ func (l *list) GetNamespace() string { // SetNamespace updates the namespace on the list. Default ns is "" for all // namespaces. func (l *list) SetNamespace(n string) { + if l.namespace == NotNamespaced { + return + } + if n == AllNamespace { n = AllNamespaces } diff --git a/internal/resource/pod.go b/internal/resource/pod.go index b042e796..7cd3efed 100644 --- a/internal/resource/pod.go +++ b/internal/resource/pod.go @@ -14,7 +14,6 @@ import ( const ( defaultTimeout = 1 * time.Second - podNameSize = 42 ) type ( @@ -188,7 +187,7 @@ func (*Pod) Header(ns string) Row { "NAME", "READY", "STATUS", - "RESTARTS", + "RS", "CPU", "MEM", "IP", @@ -204,14 +203,14 @@ func (r *Pod) Fields(ns string) Row { i := r.instance if ns == AllNamespaces { - ff = append(ff, i.Namespace) + ff = append(ff, FixCol(i.Namespace, 13)) } ss := i.Status.ContainerStatuses cr, _, rc := r.statuses(ss) return append(ff, - Pad(i.ObjectMeta.Name, podNameSize), + FixCol(i.ObjectMeta.Name, 50), strconv.Itoa(cr)+"/"+strconv.Itoa(len(ss)), r.phase(i.Status), strconv.Itoa(rc), @@ -219,7 +218,7 @@ func (r *Pod) Fields(ns string) Row { ToMi(r.metrics.CurrentMEM), i.Status.PodIP, i.Spec.NodeName, - string(i.Status.QOSClass), + r.mapQOS(i.Status.QOSClass), toAge(i.ObjectMeta.CreationTimestamp), ) } @@ -227,6 +226,16 @@ func (r *Pod) Fields(ns string) Row { // ---------------------------------------------------------------------------- // Helpers... +func (*Pod) mapQOS(class v1.PodQOSClass) string { + switch class { + case v1.PodQOSGuaranteed: + return "Ga" + case v1.PodQOSBurstable: + return "Bu" + default: + return "BE" + } +} func (r *Pod) statuses(ss []v1.ContainerStatus) (cr, ct, rc int) { for _, c := range ss { if c.State.Terminated != nil { diff --git a/internal/resource/pod_test.go b/internal/resource/pod_test.go index ccc0ddfb..80d01d33 100644 --- a/internal/resource/pod_test.go +++ b/internal/resource/pod_test.go @@ -41,7 +41,7 @@ func TestPodListAccess(t *testing.T) { func TestPodFields(t *testing.T) { r := newPod().Fields("blee") - assert.Equal(t, resource.Pad("fred", 42), r[0]) + assert.Equal(t, resource.FixCol("fred", 50), r[0]) } func TestPodMarshal(t *testing.T) { diff --git a/internal/views/app.go b/internal/views/app.go index c1aba4a7..54bfefb4 100644 --- a/internal/views/app.go +++ b/internal/views/app.go @@ -272,7 +272,7 @@ func (a *appView) inject(p igniter) { var ctx context.Context { - ctx, a.cancel = context.WithCancel(context.TODO()) + ctx, a.cancel = context.WithCancel(context.Background()) p.init(ctx, a.config.ActiveNamespace()) } a.content.AddPage("main", p, true, true) diff --git a/internal/views/command.go b/internal/views/command.go index 991de100..776f1cea 100644 --- a/internal/views/command.go +++ b/internal/views/command.go @@ -2,6 +2,7 @@ package views import ( "fmt" + "regexp" "github.com/derailed/k9s/internal/k8s" "github.com/derailed/k9s/internal/resource" @@ -36,22 +37,27 @@ func (c *command) defaultCmd() { // Helpers... +var fuMatcher = regexp.MustCompile(`\Afu\s([u|g|s]):([\w-:]+)\b`) + // Exec the command by showing associated display. func (c *command) run(cmd string) bool { - defer func() { - if err := recover(); err != nil { - log.Debug().Msgf("Command failed %v", err) - } - }() - var v resourceViewer - switch cmd { - case "q", "quit": + switch { + case cmd == "q", cmd == "quit": c.app.Stop() return true - case "?", "help", "alias": + case cmd == "?", cmd == "help": + c.app.inject(newHelpView(c.app)) + return true + case cmd == "alias": c.app.inject(newAliasView(c.app)) return true + case fuMatcher.MatchString(cmd): + tokens := fuMatcher.FindAllStringSubmatch(cmd, -1) + if len(tokens) == 1 && len(tokens[0]) == 3 { + c.app.inject(newFuView(c.app, tokens[0][1], tokens[0][2])) + return true + } default: if res, ok := resourceViews()[cmd]; ok { var r resource.List diff --git a/internal/views/fu.go b/internal/views/fu.go new file mode 100644 index 00000000..0dcb6901 --- /dev/null +++ b/internal/views/fu.go @@ -0,0 +1,326 @@ +package views + +import ( + "context" + "fmt" + "reflect" + "time" + + "github.com/derailed/k9s/internal/resource" + "github.com/gdamore/tcell" + "github.com/rs/zerolog/log" + rbacv1 "k8s.io/api/rbac/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/watch" +) + +var fuHeader = append(resource.Row{"NAMESPACE", "NAME", "GROUP", "BINDING"}, rbacHeaderVerbs...) + +type fuView struct { + *tableView + + current igniter + cancel context.CancelFunc + subjectKind string + subjectName string + cache resource.RowEvents +} + +func newFuView(app *appView, subject, name string) *fuView { + v := fuView{} + { + v.subjectKind, v.subjectName = v.mapSubject(subject), name + v.tableView = newTableView(app, v.getTitle()) + v.colorerFn = rbacColorer + v.current = app.content.GetPrimitive("main").(igniter) + v.bindKeys() + } + + return &v +} + +// Init the view. +func (v *fuView) init(_ context.Context, ns string) { + v.sortCol = sortColumn{1, len(rbacHeader), true} + + ctx, cancel := context.WithCancel(context.Background()) + v.cancel = cancel + go func(ctx context.Context) { + for { + select { + case <-ctx.Done(): + log.Debug().Msg("FU Watch bailing out!") + return + case <-time.After(time.Duration(v.app.config.K9s.RefreshRate) * time.Second): + v.refresh() + v.app.Draw() + } + } + }(ctx) + + v.refresh() + v.app.SetFocus(v) +} + +func (v *fuView) bindKeys() { + delete(v.actions, KeyShiftA) + + v.actions[tcell.KeyEscape] = newKeyAction("Reset", v.resetCmd, false) + v.actions[KeySlash] = newKeyAction("Filter", v.activateCmd, false) + v.actions[KeyP] = newKeyAction("Previous", v.app.prevCmd, false) + + v.actions[KeyShiftS] = newKeyAction("Sort Namespace", v.sortColCmd(0), true) + v.actions[KeyShiftN] = newKeyAction("Sort Name", v.sortColCmd(1), true) + v.actions[KeyShiftO] = newKeyAction("Sort Group", v.sortColCmd(2), true) + v.actions[KeyShiftB] = newKeyAction("Sort Binding", v.sortColCmd(3), true) +} + +func (v *fuView) getTitle() string { + return fmt.Sprintf(rbacTitleFmt, "Fu", v.subjectKind+":"+v.subjectName) +} + +func (v *fuView) refresh() { + data, err := v.reconcile() + if err != nil { + log.Error().Err(err).Msgf("Unable to reconcile for %s:%s", v.subjectKind, v.subjectName) + } + v.update(data) +} + +func (v *fuView) resetCmd(evt *tcell.EventKey) *tcell.EventKey { + if !v.cmdBuff.empty() { + v.cmdBuff.reset() + return nil + } + + return v.backCmd(evt) +} + +func (v *fuView) backCmd(evt *tcell.EventKey) *tcell.EventKey { + if v.cancel != nil { + v.cancel() + } + + if v.cmdBuff.isActive() { + v.cmdBuff.reset() + } else { + v.app.prevCmd(evt) + } + + return nil +} + +func (v *fuView) hints() hints { + return v.actions.toHints() +} + +func (v *fuView) reconcile() (resource.TableData, error) { + log.Debug().Msg("ClusterRoles...") + evts, errs := v.clusterPolicies() + if len(errs) > 0 { + for _, err := range errs { + log.Debug().Err(err).Msg("Unable to find cluster policies") + } + return resource.TableData{}, errs[0] + } + + log.Debug().Msg("Roles...") + nevts, errs := v.namespacePolicies() + if len(errs) > 0 { + for _, err := range errs { + log.Debug().Err(err).Msg("Unable to find cluster policies") + } + return resource.TableData{}, errs[0] + } + + for k, v := range nevts { + evts[k] = v + } + + data := resource.TableData{ + Header: fuHeader, + Rows: make(resource.RowEvents, len(evts)), + Namespace: "*", + } + + noDeltas := make(resource.Row, len(fuHeader)) + if len(v.cache) == 0 { + for k, ev := range evts { + ev.Action = resource.New + ev.Deltas = noDeltas + data.Rows[k] = ev + } + v.cache = evts + + return data, nil + } + + for k, ev := range evts { + data.Rows[k] = ev + + newr := ev.Fields + if _, ok := v.cache[k]; !ok { + ev.Action, ev.Deltas = watch.Added, noDeltas + continue + } + oldr := v.cache[k].Fields + deltas := make(resource.Row, len(newr)) + if !reflect.DeepEqual(oldr, newr) { + ev.Action = watch.Modified + for i, field := range oldr { + if field != newr[i] { + deltas[i] = field + } + } + ev.Deltas = deltas + } else { + ev.Action = resource.Unchanged + ev.Deltas = noDeltas + } + } + v.cache = evts + + for k := range v.cache { + if _, ok := data.Rows[k]; !ok { + delete(v.cache, k) + } + } + + return data, nil +} + +func (v *fuView) clusterPolicies() (resource.RowEvents, []error) { + var errs []error + evts := make(resource.RowEvents) + + crbs, err := v.app.conn().DialOrDie().Rbac().ClusterRoleBindings().List(metav1.ListOptions{}) + if err != nil { + return evts, errs + } + + var roles []string + for _, c := range crbs.Items { + for _, s := range c.Subjects { + if s.Kind == v.subjectKind && s.Name == v.subjectName { + roles = append(roles, c.RoleRef.Name) + } + } + } + + for _, r := range roles { + cr, err := v.app.conn().DialOrDie().Rbac().ClusterRoles().Get(r, metav1.GetOptions{}) + if err != nil { + errs = append(errs, err) + } + e := v.parseRules("*", r, cr.Rules) + for k, v := range e { + evts[k] = v + } + } + + return evts, errs +} + +func (v *fuView) namespacePolicies() (resource.RowEvents, []error) { + var errs []error + evts := make(resource.RowEvents) + + rbs, err := v.app.conn().DialOrDie().Rbac().RoleBindings("").List(metav1.ListOptions{}) + if err != nil { + return evts, errs + } + + type nsRole struct { + ns, role string + } + var roles []nsRole + for _, rb := range rbs.Items { + for _, s := range rb.Subjects { + if s.Kind == v.subjectKind && s.Name == v.subjectName { + roles = append(roles, nsRole{rb.Namespace, rb.RoleRef.Name}) + } + } + } + + for _, r := range roles { + cr, err := v.app.conn().DialOrDie().Rbac().Roles(r.ns).Get(r.role, metav1.GetOptions{}) + if err != nil { + errs = append(errs, err) + } + e := v.parseRules(r.ns, r.role, cr.Rules) + for k, v := range e { + evts[k] = v + } + } + + return evts, errs +} + +func (v *fuView) namespace(ns, n string) string { + return ns + "/" + n +} + +func (v *fuView) parseRules(ns, binding string, rules []rbacv1.PolicyRule) resource.RowEvents { + m := make(resource.RowEvents, len(rules)) + for _, r := range rules { + for _, grp := range r.APIGroups { + for _, res := range r.Resources { + k := res + if grp != "" { + k = res + "." + grp + } + for _, na := range r.ResourceNames { + n := k + "/" + na + m[v.namespace(ns, n)] = &resource.RowEvent{ + Fields: v.prepRow(ns, n, grp, binding, r.Verbs), + } + } + m[v.namespace(ns, k)] = &resource.RowEvent{ + Fields: v.prepRow(ns, k, grp, binding, r.Verbs), + } + } + } + for _, nres := range r.NonResourceURLs { + if nres[0] != '/' { + nres = "/" + nres + } + m[v.namespace(ns, nres)] = &resource.RowEvent{ + Fields: v.prepRow(ns, nres, resource.NAValue, binding, r.Verbs), + } + } + } + + return m +} + +func (v *fuView) prepRow(ns, res, grp, binding string, verbs []string) resource.Row { + const ( + nameLen = 60 + groupLen = 30 + nsLen = 30 + ) + + if grp != resource.NAValue { + grp = toGroup(grp) + } + + return v.makeRow(resource.Pad(ns, nsLen), resource.Pad(res, nameLen), resource.Pad(grp, groupLen), binding, asVerbs(verbs...)) +} + +func (*fuView) makeRow(ns, res, group, binding string, verbs []string) resource.Row { + r := make(resource.Row, 0, len(fuHeader)) + r = append(r, ns, res, group, binding) + + return append(r, verbs...) +} + +func (v *fuView) mapSubject(subject string) string { + switch subject { + case "g": + return "Group" + case "s": + return "ServiceAccount" + default: + return "User" + } +} diff --git a/internal/views/rbac.go b/internal/views/rbac.go index 76cbc88e..9a658fc9 100644 --- a/internal/views/rbac.go +++ b/internal/views/rbac.go @@ -84,13 +84,17 @@ func newRBACView(app *appView, ns, name string, kind roleKind) *rbacView { v.roleName, v.roleType = name, kind v.tableView = newTableView(app, v.getTitle()) v.currentNS = ns - // v.SetSelectedStyle(tcell.ColorWhite, tcell.ColorDarkSeaGreen, tcell.AttrNone) v.colorerFn = rbacColorer v.current = app.content.GetPrimitive("main").(igniter) + v.bindKeys() } - v.actions[tcell.KeyEscape] = newKeyAction("Reset", v.resetCmd, false) - v.actions[KeySlash] = newKeyAction("Filter", v.activateCmd, false) - v.actions[KeyShiftO] = newKeyAction("Sort Groups", v.sortColCmd(1), true) + + return &v +} + +// Init the view. +func (v *rbacView) init(_ context.Context, ns string) { + v.sortCol = sortColumn{1, len(rbacHeader), true} ctx, cancel := context.WithCancel(context.Background()) v.cancel = cancel @@ -100,24 +104,27 @@ func newRBACView(app *appView, ns, name string, kind roleKind) *rbacView { case <-ctx.Done(): log.Debug().Msg("RBAC Watch bailing out!") return - case <-time.After(time.Duration(app.config.K9s.RefreshRate) * time.Second): + case <-time.After(time.Duration(v.app.config.K9s.RefreshRate) * time.Second): v.refresh() v.app.Draw() } } }(ctx) - return &v -} - -// Init the view. -func (v *rbacView) init(_ context.Context, ns string) { - // v.baseTitle = v.getTitle() - v.sortCol = sortColumn{1, len(rbacHeader), true} v.refresh() v.app.SetFocus(v) } +func (v *rbacView) bindKeys() { + delete(v.actions, KeyShiftA) + + v.actions[tcell.KeyEscape] = newKeyAction("Reset", v.resetCmd, false) + v.actions[KeySlash] = newKeyAction("Filter", v.activateCmd, false) + v.actions[KeyP] = newKeyAction("Previous", v.app.prevCmd, false) + + v.actions[KeyShiftO] = newKeyAction("Sort Groups", v.sortColCmd(1), true) +} + func (v *rbacView) getTitle() string { title := "ClusterRole" if v.roleType == role { @@ -127,6 +134,10 @@ func (v *rbacView) getTitle() string { return fmt.Sprintf(rbacTitleFmt, title, v.roleName) } +func (v *rbacView) hints() hints { + return v.actions.toHints() +} + func (v *rbacView) refresh() { data, err := v.reconcile(v.currentNS, v.roleName, v.roleType) if err != nil { @@ -152,16 +163,12 @@ func (v *rbacView) backCmd(evt *tcell.EventKey) *tcell.EventKey { if v.cmdBuff.isActive() { v.cmdBuff.reset() } else { - v.app.inject(v.current) + v.app.prevCmd(evt) } return nil } -func (v *rbacView) hints() hints { - return v.actions.toHints() -} - func (v *rbacView) reconcile(ns, name string, kind roleKind) (resource.TableData, error) { evts, err := v.rowEvents(ns, name, kind) if err != nil { @@ -216,6 +223,7 @@ func (v *rbacView) reconcile(ns, name string, kind roleKind) (resource.TableData delete(v.cache, k) } } + return data, nil } @@ -241,13 +249,6 @@ func (v *rbacView) rowEvents(ns, name string, kind roleKind) (resource.RowEvents return evts, nil } -func toGroup(g string) string { - if g == "" { - return "v1" - } - return g -} - func (v *rbacView) clusterPolicies(name string) (resource.RowEvents, error) { cr, err := v.app.conn().DialOrDie().Rbac().ClusterRoles().Get(name, metav1.GetOptions{}) if err != nil { @@ -267,12 +268,7 @@ func (v *rbacView) namespacedPolicies(path string) (resource.RowEvents, error) { return v.parseRules(cr.Rules), nil } -func (*rbacView) parseRules(rules []rbacv1.PolicyRule) resource.RowEvents { - const ( - nameLen = 60 - groupLen = 30 - ) - +func (v *rbacView) parseRules(rules []rbacv1.PolicyRule) resource.RowEvents { m := make(resource.RowEvents, len(rules)) for _, r := range rules { for _, grp := range r.APIGroups { @@ -284,11 +280,11 @@ func (*rbacView) parseRules(rules []rbacv1.PolicyRule) resource.RowEvents { for _, na := range r.ResourceNames { n := k + "/" + na m[n] = &resource.RowEvent{ - Fields: makeRow(resource.Pad(n, nameLen), resource.Pad(toGroup(grp), groupLen), asVerbs(r.Verbs...)), + Fields: prepRow(n, grp, r.Verbs), } } m[k] = &resource.RowEvent{ - Fields: makeRow(resource.Pad(k, nameLen), resource.Pad(toGroup(grp), groupLen), asVerbs(r.Verbs...)), + Fields: prepRow(k, grp, r.Verbs), } } } @@ -297,7 +293,7 @@ func (*rbacView) parseRules(rules []rbacv1.PolicyRule) resource.RowEvents { nres = "/" + nres } m[nres] = &resource.RowEvent{ - Fields: makeRow(resource.Pad(nres, nameLen), resource.Pad(resource.NAValue, groupLen), asVerbs(r.Verbs...)), + Fields: prepRow(nres, resource.NAValue, r.Verbs), } } } @@ -305,8 +301,21 @@ func (*rbacView) parseRules(rules []rbacv1.PolicyRule) resource.RowEvents { return m } +func prepRow(res, grp string, verbs []string) resource.Row { + const ( + nameLen = 60 + groupLen = 30 + ) + + if grp != resource.NAValue { + grp = toGroup(grp) + } + + return makeRow(resource.Pad(res, nameLen), resource.Pad(grp, groupLen), asVerbs(verbs...)) +} + func makeRow(res, group string, verbs []string) resource.Row { - r := make(resource.Row, 0, 12) + r := make(resource.Row, 0, len(rbacHeader)) r = append(r, res, group) return append(r, verbs...) @@ -361,3 +370,10 @@ func hasVerb(verbs []string, verb string) bool { return false } + +func toGroup(g string) string { + if g == "" { + return "v1" + } + return g +} diff --git a/internal/views/registrar.go b/internal/views/registrar.go index 96579bd0..83f6f883 100644 --- a/internal/views/registrar.go +++ b/internal/views/registrar.go @@ -4,7 +4,6 @@ import ( "github.com/derailed/k9s/internal/k8s" "github.com/derailed/k9s/internal/resource" "github.com/gdamore/tcell" - "github.com/rs/zerolog/log" ) type ( @@ -76,7 +75,6 @@ func allCRDs(c k8s.Connection) map[string]k8s.APIGroup { } func showRBAC(app *appView, ns, resource, selection string) { - log.Debug().Msgf("Entered FN on `%s`--%s:%s", ns, resource, selection) kind := clusterRole if resource == "role" { kind = role diff --git a/internal/views/table.go b/internal/views/table.go index beb4d373..1645b176 100644 --- a/internal/views/table.go +++ b/internal/views/table.go @@ -54,6 +54,7 @@ func newTableView(app *appView, title string) *tableView { v.baseTitle = title v.actions = make(keyActions) v.SetBorder(true) + v.SetFixed(1, 0) v.SetBorderColor(tcell.ColorDodgerBlue) v.SetBorderAttributes(tcell.AttrBold) v.SetBorderPadding(0, 0, 1, 1) @@ -63,11 +64,30 @@ func newTableView(app *appView, title string) *tableView { v.SetSelectable(true, false) v.SetSelectedStyle(tcell.ColorBlack, tcell.ColorAqua, tcell.AttrBold) v.SetInputCapture(v.keyboard) - v.registerHandlers() + v.bindKeys() } + return &v } +func (v *tableView) bindKeys() { + v.actions[KeyShiftI] = newKeyAction("Invert", v.sortInvertCmd, true) + v.actions[KeyShiftN] = newKeyAction("Sort Name", v.sortColCmd(0), true) + v.actions[KeyShiftA] = newKeyAction("Sort Age", v.sortColCmd(-1), true) + + v.actions[KeySlash] = newKeyAction("Filter Mode", v.activateCmd, false) + v.actions[tcell.KeyEscape] = newKeyAction("Filter Reset", v.resetCmd, false) + v.actions[tcell.KeyEnter] = newKeyAction("Filter", v.filterCmd, false) + + 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() { v.Select(0, 0) v.ScrollToBeginning() @@ -90,6 +110,7 @@ func (v *tableView) keyboard(evt *tcell.EventKey) *tcell.EventKey { log.Debug().Msgf(">> TableView handled %s", tcell.KeyNames[key]) return a.action(evt) } + return evt } @@ -101,11 +122,13 @@ 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 } @@ -120,6 +143,7 @@ func (v *tableView) eraseCmd(evt *tcell.EventKey) *tcell.EventKey { if v.cmdBuff.isActive() { v.cmdBuff.del() } + return nil } @@ -129,6 +153,7 @@ func (v *tableView) resetCmd(evt *tcell.EventKey) *tcell.EventKey { } v.cmdBuff.reset() v.refresh() + return nil } @@ -156,12 +181,14 @@ func (v *tableView) sortColCmd(col int) func(evt *tcell.EventKey) *tcell.EventKe func (v *tableView) sortNamespaceCmd(evt *tcell.EventKey) *tcell.EventKey { v.sortCol.index, v.sortCol.asc = 0, true v.refresh() + return nil } func (v *tableView) sortInvertCmd(evt *tcell.EventKey) *tcell.EventKey { v.sortCol.asc = !v.sortCol.asc v.refresh() + return nil } @@ -173,6 +200,7 @@ func (v *tableView) activateCmd(evt *tcell.EventKey) *tcell.EventKey { v.app.flash(flashInfo, "Filtering...") v.cmdBuff.reset() v.cmdBuff.setActive(true) + return nil } @@ -205,6 +233,7 @@ func (v *tableView) hints() hints { if v.actions != nil { return v.actions.toHints() } + return nil } @@ -250,6 +279,7 @@ func (v *tableView) filtered() resource.TableData { filtered.Rows[k] = row } } + return filtered } @@ -267,7 +297,7 @@ func (v *tableView) displayCol(index int, name string) string { func (v *tableView) doUpdate(data resource.TableData) { v.currentNS = data.Namespace - if v.currentNS == resource.AllNamespaces { + if v.currentNS == resource.AllNamespaces || v.currentNS == "*" { v.actions[KeyShiftS] = newKeyAction("Sort Namespace", v.sortNamespaceCmd, true) } else { delete(v.actions, KeyShiftS) @@ -293,15 +323,15 @@ func (v *tableView) doUpdate(data resource.TableData) { } row++ - // for k := range data.Rows { - // log.Debug().Msgf("Keys: `%s`", k) - // } + sortFn := v.defaultSort + if v.sortFn != nil { + sortFn = v.sortFn + } - keys := v.sortRows(data) - // log.Debug().Msgf("KEYS %#v", keys) + keys := make([]string, len(data.Rows)) + v.sortRows(data.Rows, sortFn, v.sortCol, keys) groupKeys := map[string][]string{} for _, k := range keys { - // log.Debug().Msgf("RKEY: %s", k) grp := data.Rows[k].Fields[v.sortCol.index] if s, ok := groupKeys[grp]; ok { s = append(s, k) @@ -358,48 +388,21 @@ func (v *tableView) addBodyCell(row, col int, field, delta string, color tcell.C v.SetCell(row, col, c) } -func (v *tableView) defaultSort(rows resource.Rows) { - t := rowSorter{rows: rows, index: v.sortCol.index, asc: v.sortCol.asc} +func (v *tableView) defaultSort(rows resource.Rows, sortCol sortColumn) { + t := rowSorter{rows: rows, index: sortCol.index, asc: sortCol.asc} sort.Sort(t) } -func (v *tableView) sortRows(data resource.TableData) []string { - rows := make(resource.Rows, 0, len(data.Rows)) - for _, r := range data.Rows { - rows = append(rows, r.Fields) +func (*tableView) sortRows(evts resource.RowEvents, sortFn sortFn, sortCol sortColumn, keys []string) { + rows := make(resource.Rows, 0, len(evts)) + for k, r := range evts { + rows = append(rows, append(r.Fields, k)) } + sortFn(rows, sortCol) - if v.sortFn != nil { - v.sortFn(rows, v.sortCol) - } else { - v.defaultSort(rows) - } - - keys := make([]string, len(rows)) for i, r := range rows { - col, prefix := 0, v.currentNS - switch v.currentNS { - case resource.AllNamespaces: - col, prefix = 1, r[0] - case resource.NotNamespaced: - prefix = "" - } - - key := r[col] - if v.cleanseFn != nil { - key = v.cleanseFn(key) - } else { - key = v.defaultColCleanse(key) - } - - if len(prefix) == 0 { - keys[i] = key - } else { - keys[i] = prefix + "/" + key - } + keys[i] = r[len(r)-1] } - - return keys } func (*tableView) defaultColCleanse(s string) string { @@ -433,26 +436,7 @@ func (v *tableView) resetTitle() { // ---------------------------------------------------------------------------- // Event listeners... -func (v *tableView) registerHandlers() { - v.actions[KeyShiftI] = newKeyAction("Invert", v.sortInvertCmd, true) - v.actions[KeyShiftN] = newKeyAction("Sort Name", v.sortColCmd(0), true) - v.actions[KeyShiftA] = newKeyAction("Sort Age", v.sortColCmd(-1), true) - - v.actions[KeySlash] = newKeyAction("Filter Mode", v.activateCmd, false) - v.actions[tcell.KeyEscape] = newKeyAction("Filter Reset", v.resetCmd, false) - v.actions[tcell.KeyEnter] = newKeyAction("Filter", v.filterCmd, false) - - 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) changed(s string) { -} +func (v *tableView) changed(s string) {} func (v *tableView) active(b bool) { if b { diff --git a/internal/views/table_test.go b/internal/views/table_test.go new file mode 100644 index 00000000..e4e977d2 --- /dev/null +++ b/internal/views/table_test.go @@ -0,0 +1,72 @@ +package views + +import ( + "testing" + + "github.com/derailed/k9s/internal/resource" + "github.com/stretchr/testify/assert" +) + +func TestTVSortRows(t *testing.T) { + uu := []struct { + rows resource.RowEvents + col int + asc bool + first resource.Row + e []string + }{ + { + resource.RowEvents{ + "row1": {Fields: resource.Row{"x", "y"}}, + "row2": {Fields: resource.Row{"a", "b"}}, + }, + 0, + true, + resource.Row{"a", "b"}, + []string{"row2", "row1"}, + }, + { + resource.RowEvents{ + "row1": {Fields: resource.Row{"x", "y"}}, + "row2": {Fields: resource.Row{"a", "b"}}, + }, + 1, + true, + resource.Row{"a", "b"}, + []string{"row2", "row1"}, + }, + { + resource.RowEvents{ + "row1": {Fields: resource.Row{"x", "y"}}, + "row2": {Fields: resource.Row{"a", "b"}}, + }, + 1, + false, + resource.Row{"x", "y"}, + []string{"row1", "row2"}, + }, + } + + var v *tableView + for _, u := range uu { + keys := make([]string, len(u.rows)) + v.sortRows(u.rows, v.defaultSort, sortColumn{u.col, len(u.rows), u.asc}, keys) + assert.Equal(t, u.e, keys) + assert.Equal(t, u.first, u.rows[u.e[0]].Fields) + } +} + +func BenchmarkTVSortRows(b *testing.B) { + evts := resource.RowEvents{ + "row1": {Fields: resource.Row{"x", "y"}}, + "row2": {Fields: resource.Row{"a", "b"}}, + } + sc := sortColumn{0, 2, true} + var v *tableView + keys := make([]string, len(evts)) + b.ResetTimer() + b.ReportAllocs() + for i := 0; i < b.N; i++ { + v.sortRows(evts, v.defaultSort, sc, keys) + } +} From 6001b004d1f1ddfeeb0f6c5ffcf381a9a957cda2 Mon Sep 17 00:00:00 2001 From: derailed Date: Thu, 28 Mar 2019 00:03:16 -0600 Subject: [PATCH 3/7] first pass scroll jitter --- change_logs/release_0.3.3.md | 43 +++++++++++++++++++++++++++++++ internal/resource/cr.go | 4 +-- internal/resource/cr_test.go | 7 +++-- internal/resource/cronjob.go | 6 ++--- internal/resource/cronjob_test.go | 4 +-- internal/resource/helpers.go | 8 ++++++ internal/resource/job.go | 6 ++--- internal/resource/job_test.go | 4 +-- internal/resource/pod.go | 6 ++--- internal/resource/ro.go | 6 ++--- internal/resource/ro_test.go | 2 +- internal/resource/rs.go | 6 ++--- internal/resource/rs_test.go | 2 +- internal/resource/svc.go | 6 ++--- internal/resource/svc_test.go | 6 ++--- internal/views/fu.go | 2 -- 16 files changed, 83 insertions(+), 35 deletions(-) create mode 100644 change_logs/release_0.3.3.md diff --git a/change_logs/release_0.3.3.md b/change_logs/release_0.3.3.md new file mode 100644 index 00000000..d331af0d --- /dev/null +++ b/change_logs/release_0.3.3.md @@ -0,0 +1,43 @@ +# Release v0.4.0 + +## Notes + +Thank you to all that contributed with flushing out issues with K9s! I'll try +to mark some of these issues as fixed. But if you don't mind grab the latest +rev and see if we're happier with some of the fixes! + +If you've filed an issue please help me verify and close. + +Thank you so much for your support and awesome suggestions to make K9s better!! + +--- + +## Change Logs + +1. [Feature #82](https://github.com/derailed/k9s/issues/82) + 1. Added ability to view RBAC policies while in clusterrole or role view. + 1. 😃 The RBAC view will auto-refresh just like any K9s views hence showing live RBAC updates! + 1. RBAC view supports standard K8s verbs ie get,list,deletecollection,watch,create,patch,update,delete. + 1. Any verbs not in this standard K8s verb list, will end up in the EXTRAS column. + 1. For non resource URLS, we map standard REST verbs to K8s verbs ie post=create patch=update, etc.. + 1. Added initial sorts by name and group while in RBAC view. + 1. Usage: To activate, enter command mode via `:cr` or `:ro` for clusterole(cr)/role(ro), select a row and press `` + 1. To bail out of the view and return to previous use `p` or `` +1. One feature that was mentioned in the comments for the RBAC feature above Tx [faheem-cliqz](https://github.com/faheem-cliqz)! was the ability to check RBAC rules for a given user. Namely reverse RBAC lookup + 1. Added a new view, code name *Fu* view to show all the clusterroles/roles associated with a given user. + 1. The view also supports for checking RBAC Fu for a user, a group or an app via a serviceaccount. + 1. To activate: Enter command mode via `:fu` followed by u|g|s:subject + ``. + For example: To view user *fred* Fu enter `:fu u:fred` + `` will show all clusterroles/roles and verbs associated + with the user *fred* + 1. For group Fu lookup, use the same command as above and substitute `u:fred` with `g:fred` + 1. For ServiceAccount *fred* Fu check: use `s:fred` +1. Try to eliminate a tad of a jitter while scrolling on most views. Please report back if that's not the case + +> NOTE!: This feature is very much an alpha feature right now. +> I find it really powerful and useful, hopefully I am not the only one hunanimous on that?? + +--- + +## Resolved Bugs + ++ None diff --git a/internal/resource/cr.go b/internal/resource/cr.go index e42ff812..a356e93b 100644 --- a/internal/resource/cr.go +++ b/internal/resource/cr.go @@ -72,7 +72,7 @@ func (r *ClusterRole) Fields(ns string) Row { i := r.instance return append(ff, - Pad(i.Name, 70), - toAge(i.ObjectMeta.CreationTimestamp), + Pad(i.Name, RBACPad), + Pad(toAge(i.ObjectMeta.CreationTimestamp), AgePad), ) } diff --git a/internal/resource/cr_test.go b/internal/resource/cr_test.go index 8fc7bc94..45dc00ab 100644 --- a/internal/resource/cr_test.go +++ b/internal/resource/cr_test.go @@ -2,7 +2,6 @@ package resource_test import ( "fmt" - "strings" "testing" "time" @@ -42,12 +41,12 @@ func TestCRListAccess(t *testing.T) { func TestCRFields(t *testing.T) { r := newClusterRole().Fields("blee") - assert.Equal(t, "fred"+strings.Repeat(" ", 66), r[0]) + assert.Equal(t, resource.Pad("fred", resource.RBACPad), r[0]) } func TestCRFieldsAllNS(t *testing.T) { r := newClusterRole().Fields(resource.AllNamespaces) - assert.Equal(t, "fred"+strings.Repeat(" ", 66), r[0]) + assert.Equal(t, resource.Pad("fred", resource.RBACPad), r[0]) } func TestCRMarshal(t *testing.T) { @@ -85,7 +84,7 @@ func TestCRListData(t *testing.T) { for _, d := range row.Deltas { assert.Equal(t, "", d) } - assert.Equal(t, resource.Row{"fred" + strings.Repeat(" ", 66)}, row.Fields[:1]) + assert.Equal(t, resource.Row{resource.Pad("fred", resource.RBACPad)}, row.Fields[:1]) } // Helpers... diff --git a/internal/resource/cronjob.go b/internal/resource/cronjob.go index 14e449cb..e42899e9 100644 --- a/internal/resource/cronjob.go +++ b/internal/resource/cronjob.go @@ -102,7 +102,7 @@ func (r *CronJob) Fields(ns string) Row { i := r.instance if ns == AllNamespaces { - ff = append(ff, i.Namespace) + ff = append(ff, Pad(i.Namespace, NSPad)) } lastScheduled := "" @@ -111,11 +111,11 @@ func (r *CronJob) Fields(ns string) Row { } return append(ff, - i.Name, + Pad(i.Name, NamePad), i.Spec.Schedule, boolPtrToStr(i.Spec.Suspend), strconv.Itoa(len(i.Status.Active)), lastScheduled, - toAge(i.ObjectMeta.CreationTimestamp), + Pad(toAge(i.ObjectMeta.CreationTimestamp), AgePad), ) } diff --git a/internal/resource/cronjob_test.go b/internal/resource/cronjob_test.go index 51f21d6a..560eebec 100644 --- a/internal/resource/cronjob_test.go +++ b/internal/resource/cronjob_test.go @@ -39,7 +39,7 @@ func TestCronJobListAccess(t *testing.T) { func TestCronJobFields(t *testing.T) { r := newCronJob().Fields("blee") - assert.Equal(t, "fred", r[0]) + assert.Equal(t, resource.Pad("fred", resource.NamePad), r[0]) } func TestCronJobMarshal(t *testing.T) { @@ -75,7 +75,7 @@ func TestCronJobListData(t *testing.T) { for _, d := range row.Deltas { assert.Equal(t, "", d) } - assert.Equal(t, resource.Row{"fred"}, row.Fields[:1]) + assert.Equal(t, resource.Row{resource.Pad("fred", resource.NamePad)}, row.Fields[:1]) } // Helpers... diff --git a/internal/resource/helpers.go b/internal/resource/helpers.go index a57b87dd..5868c6d0 100644 --- a/internal/resource/helpers.go +++ b/internal/resource/helpers.go @@ -36,6 +36,14 @@ const ( NAValue = "" ) +// Columns Padding... +const ( + NSPad = 13 + NamePad = 50 + AgePad = 5 + RBACPad = 80 +) + func asPerc(f float64) string { return fmt.Sprintf("%d%%", int(f)) } diff --git a/internal/resource/job.go b/internal/resource/job.go index 5354a66c..027d81ac 100644 --- a/internal/resource/job.go +++ b/internal/resource/job.go @@ -132,18 +132,18 @@ func (r *Job) Fields(ns string) Row { i := r.instance if ns == AllNamespaces { - ff = append(ff, i.Namespace) + ff = append(ff, Pad(i.Namespace, NSPad)) } cc, ii := r.toContainers(i.Spec.Template.Spec) return append(ff, - i.Name, + Pad(i.Name, NamePad), r.toCompletion(i.Spec, i.Status), r.toDuration(i.Status), cc, ii, - toAge(i.ObjectMeta.CreationTimestamp), + Pad(toAge(i.ObjectMeta.CreationTimestamp), AgePad), ) } diff --git a/internal/resource/job_test.go b/internal/resource/job_test.go index 1501bbc3..d437377f 100644 --- a/internal/resource/job_test.go +++ b/internal/resource/job_test.go @@ -38,7 +38,7 @@ func TestJobListAccess(t *testing.T) { func TestJobFields(t *testing.T) { r := newJob().Fields("blee") - assert.Equal(t, "fred", r[0]) + assert.Equal(t, resource.Pad("fred", resource.NamePad), r[0]) } func TestJobMarshal(t *testing.T) { @@ -74,7 +74,7 @@ func TestJobListData(t *testing.T) { for _, d := range row.Deltas { assert.Equal(t, "", d) } - assert.Equal(t, resource.Row{"fred"}, row.Fields[:1]) + assert.Equal(t, resource.Row{resource.Pad("fred", resource.NamePad)}, row.Fields[:1]) } // Helpers... diff --git a/internal/resource/pod.go b/internal/resource/pod.go index 7cd3efed..62498e98 100644 --- a/internal/resource/pod.go +++ b/internal/resource/pod.go @@ -203,14 +203,14 @@ func (r *Pod) Fields(ns string) Row { i := r.instance if ns == AllNamespaces { - ff = append(ff, FixCol(i.Namespace, 13)) + ff = append(ff, FixCol(i.Namespace, NSPad)) } ss := i.Status.ContainerStatuses cr, _, rc := r.statuses(ss) return append(ff, - FixCol(i.ObjectMeta.Name, 50), + FixCol(i.ObjectMeta.Name, NamePad), strconv.Itoa(cr)+"/"+strconv.Itoa(len(ss)), r.phase(i.Status), strconv.Itoa(rc), @@ -219,7 +219,7 @@ func (r *Pod) Fields(ns string) Row { i.Status.PodIP, i.Spec.NodeName, r.mapQOS(i.Status.QOSClass), - toAge(i.ObjectMeta.CreationTimestamp), + Pad(toAge(i.ObjectMeta.CreationTimestamp), AgePad), ) } diff --git a/internal/resource/ro.go b/internal/resource/ro.go index 0d69057b..b47572c3 100644 --- a/internal/resource/ro.go +++ b/internal/resource/ro.go @@ -79,12 +79,12 @@ func (r *Role) Fields(ns string) Row { i := r.instance if ns == AllNamespaces { - ff = append(ff, i.Namespace) + ff = append(ff, Pad(i.Namespace, NSPad)) } return append(ff, - i.Name, - toAge(i.ObjectMeta.CreationTimestamp), + Pad(i.Name, RBACPad), + Pad(toAge(i.ObjectMeta.CreationTimestamp), AgePad), ) } diff --git a/internal/resource/ro_test.go b/internal/resource/ro_test.go index 82bef2bd..1932c772 100644 --- a/internal/resource/ro_test.go +++ b/internal/resource/ro_test.go @@ -54,7 +54,7 @@ func TestRoleListData(t *testing.T) { for _, d := range row.Deltas { assert.Equal(t, "", d) } - assert.Equal(t, resource.Row{"fred"}, row.Fields[:1]) + assert.Equal(t, resource.Row{resource.Pad("fred", resource.RBACPad)}, row.Fields[:1]) } // Helpers... diff --git a/internal/resource/rs.go b/internal/resource/rs.go index 3852ece1..4bd87ef7 100644 --- a/internal/resource/rs.go +++ b/internal/resource/rs.go @@ -77,16 +77,16 @@ func (*ReplicaSet) Header(ns string) Row { func (r *ReplicaSet) Fields(ns string) Row { ff := make(Row, 0, len(r.Header(ns))) if ns == AllNamespaces { - ff = append(ff, r.instance.Namespace) + ff = append(ff, Pad(r.instance.Namespace, NSPad)) } i := r.instance return append(ff, - i.Name, + Pad(i.Name, NamePad), strconv.Itoa(int(*i.Spec.Replicas)), strconv.Itoa(int(i.Status.Replicas)), strconv.Itoa(int(i.Status.ReadyReplicas)), - toAge(i.ObjectMeta.CreationTimestamp), + Pad(toAge(i.ObjectMeta.CreationTimestamp), AgePad), ) } diff --git a/internal/resource/rs_test.go b/internal/resource/rs_test.go index 486c509e..6462d73d 100644 --- a/internal/resource/rs_test.go +++ b/internal/resource/rs_test.go @@ -54,7 +54,7 @@ func TestReplicaSetListData(t *testing.T) { for _, d := range row.Deltas { assert.Equal(t, "", d) } - assert.Equal(t, resource.Row{"fred"}, row.Fields[:1]) + assert.Equal(t, resource.Row{resource.Pad("fred", resource.NamePad)}, row.Fields[:1]) } // Helpers... diff --git a/internal/resource/svc.go b/internal/resource/svc.go index a9ae820a..6fc8bb6a 100644 --- a/internal/resource/svc.go +++ b/internal/resource/svc.go @@ -91,16 +91,16 @@ func (r *Service) Fields(ns string) Row { i := r.instance if ns == AllNamespaces { - ff = append(ff, i.Namespace) + ff = append(ff, Pad(i.Namespace, NSPad)) } return append(ff, - i.ObjectMeta.Name, + Pad(i.ObjectMeta.Name, NamePad), string(i.Spec.Type), i.Spec.ClusterIP, r.toIPs(i.Spec.Type, r.getSvcExtIPS(i)), r.toPorts(i.Spec.Ports), - toAge(i.ObjectMeta.CreationTimestamp), + Pad(toAge(i.ObjectMeta.CreationTimestamp), AgePad), ) } diff --git a/internal/resource/svc_test.go b/internal/resource/svc_test.go index 517abccc..994f3199 100644 --- a/internal/resource/svc_test.go +++ b/internal/resource/svc_test.go @@ -52,8 +52,8 @@ func TestSvcFields(t *testing.T) { { i: newSvc(), e: resource.Row{ - "blee", - "fred", + resource.Pad("blee", resource.NSPad), + resource.Pad("fred", resource.NamePad), "ClusterIP", "1.1.1.1", "2.2.2.2", @@ -102,7 +102,7 @@ func TestSVCListData(t *testing.T) { for _, d := range row.Deltas { assert.Equal(t, "", d) } - assert.Equal(t, resource.Row{"fred"}, row.Fields[:1]) + assert.Equal(t, resource.Row{resource.Pad("fred", 50)}, row.Fields[:1]) } // Helpers... diff --git a/internal/views/fu.go b/internal/views/fu.go index 0dcb6901..ee6a838b 100644 --- a/internal/views/fu.go +++ b/internal/views/fu.go @@ -115,7 +115,6 @@ func (v *fuView) hints() hints { } func (v *fuView) reconcile() (resource.TableData, error) { - log.Debug().Msg("ClusterRoles...") evts, errs := v.clusterPolicies() if len(errs) > 0 { for _, err := range errs { @@ -124,7 +123,6 @@ func (v *fuView) reconcile() (resource.TableData, error) { return resource.TableData{}, errs[0] } - log.Debug().Msg("Roles...") nevts, errs := v.namespacePolicies() if len(errs) > 0 { for _, err := range errs { From c4beaf920730b14c15567727f712069b99f386e9 Mon Sep 17 00:00:00 2001 From: derailed Date: Thu, 28 Mar 2019 14:34:02 -0600 Subject: [PATCH 4/7] address scroll jitter --- internal/resource/cr.go | 4 +- internal/resource/cr_binding.go | 47 ++++++- internal/resource/cr_binding_test.go | 2 +- internal/resource/cr_test.go | 6 +- internal/resource/cronjob.go | 6 +- internal/resource/cronjob_test.go | 4 +- internal/resource/helpers.go | 23 ---- internal/resource/helpers_test.go | 32 ----- internal/resource/job.go | 6 +- internal/resource/job_test.go | 4 +- internal/resource/pod.go | 11 +- internal/resource/pod_test.go | 2 +- internal/resource/ro.go | 6 +- internal/resource/ro_binding.go | 40 +----- internal/resource/ro_binding_int_test.go | 30 +++-- internal/resource/ro_binding_test.go | 2 +- internal/resource/ro_test.go | 2 +- internal/resource/rs.go | 14 +- internal/resource/rs_test.go | 2 +- internal/resource/svc.go | 6 +- internal/resource/svc_test.go | 6 +- internal/views/alias.go | 6 +- internal/views/app.go | 3 + internal/views/command.go | 18 ++- internal/views/context.go | 4 +- internal/views/cronjob.go | 6 +- internal/views/fu.go | 2 +- internal/views/helpers.go | 4 + internal/views/job.go | 10 +- internal/views/namespace.go | 20 ++- internal/views/no.go | 4 +- internal/views/padding.go | 51 ++++++++ internal/views/padding_test.go | 107 +++++++++++++++ internal/views/pod.go | 4 +- internal/views/rbac.go | 4 +- internal/views/rbac_test.go | 14 +- internal/views/registrar.go | 158 +++++++++++------------ internal/views/resource.go | 31 +++-- internal/views/table.go | 94 +++++++------- 39 files changed, 473 insertions(+), 322 deletions(-) create mode 100644 internal/views/padding.go create mode 100644 internal/views/padding_test.go diff --git a/internal/resource/cr.go b/internal/resource/cr.go index a356e93b..397e589d 100644 --- a/internal/resource/cr.go +++ b/internal/resource/cr.go @@ -72,7 +72,7 @@ func (r *ClusterRole) Fields(ns string) Row { i := r.instance return append(ff, - Pad(i.Name, RBACPad), - Pad(toAge(i.ObjectMeta.CreationTimestamp), AgePad), + i.Name, + toAge(i.ObjectMeta.CreationTimestamp), ) } diff --git a/internal/resource/cr_binding.go b/internal/resource/cr_binding.go index f6ddb96c..236127af 100644 --- a/internal/resource/cr_binding.go +++ b/internal/resource/cr_binding.go @@ -1,6 +1,8 @@ package resource import ( + "strings" + "github.com/derailed/k9s/internal/k8s" "github.com/rs/zerolog/log" v1 "k8s.io/api/rbac/v1" @@ -63,15 +65,54 @@ func (r *ClusterRoleBinding) Marshal(path string) (string, error) { // Header return resource header. func (*ClusterRoleBinding) Header(_ string) Row { - return append(Row{}, "NAME", "AGE") + return append(Row{}, "NAME", "ROLE", "KIND", "SUBJECTS", "AGE") } // Fields retrieves displayable fields. func (r *ClusterRoleBinding) Fields(ns string) Row { ff := make(Row, 0, len(r.Header(ns))) + i := r.instance + kind, ss := renderSubjects(i.Subjects) + return append(ff, - r.instance.Name, - toAge(r.instance.ObjectMeta.CreationTimestamp), + i.Name, + i.RoleRef.Name, + kind, + ss, + toAge(i.ObjectMeta.CreationTimestamp), ) } + +// ---------------------------------------------------------------------------- +// Helpers... + +func renderSubjects(ss []v1.Subject) (kind string, subjects string) { + if len(ss) == 0 { + return NAValue, "" + } + + var tt []string + for _, s := range ss { + kind = toSubjectAlias(s.Kind) + tt = append(tt, s.Name) + } + return kind, strings.Join(tt, ",") +} + +func toSubjectAlias(s string) string { + if len(s) == 0 { + return s + } + + switch s { + case v1.UserKind: + return "USR" + case v1.GroupKind: + return "GRP" + case v1.ServiceAccountKind: + return "SA" + default: + return strings.ToUpper(s) + } +} diff --git a/internal/resource/cr_binding_test.go b/internal/resource/cr_binding_test.go index 4325a6ea..75eb1408 100644 --- a/internal/resource/cr_binding_test.go +++ b/internal/resource/cr_binding_test.go @@ -59,7 +59,7 @@ func TestCRBListData(t *testing.T) { assert.Equal(t, 1, len(td.Rows)) assert.Equal(t, resource.NotNamespaced, l.GetNamespace()) row := td.Rows["fred"] - assert.Equal(t, 2, len(row.Deltas)) + assert.Equal(t, 5, len(row.Deltas)) for _, d := range row.Deltas { assert.Equal(t, "", d) } diff --git a/internal/resource/cr_test.go b/internal/resource/cr_test.go index 45dc00ab..c13f274f 100644 --- a/internal/resource/cr_test.go +++ b/internal/resource/cr_test.go @@ -41,12 +41,12 @@ func TestCRListAccess(t *testing.T) { func TestCRFields(t *testing.T) { r := newClusterRole().Fields("blee") - assert.Equal(t, resource.Pad("fred", resource.RBACPad), r[0]) + assert.Equal(t, "fred", r[0]) } func TestCRFieldsAllNS(t *testing.T) { r := newClusterRole().Fields(resource.AllNamespaces) - assert.Equal(t, resource.Pad("fred", resource.RBACPad), r[0]) + assert.Equal(t, "fred", r[0]) } func TestCRMarshal(t *testing.T) { @@ -84,7 +84,7 @@ func TestCRListData(t *testing.T) { for _, d := range row.Deltas { assert.Equal(t, "", d) } - assert.Equal(t, resource.Row{resource.Pad("fred", resource.RBACPad)}, row.Fields[:1]) + assert.Equal(t, resource.Row{"fred"}, row.Fields[:1]) } // Helpers... diff --git a/internal/resource/cronjob.go b/internal/resource/cronjob.go index e42899e9..14e449cb 100644 --- a/internal/resource/cronjob.go +++ b/internal/resource/cronjob.go @@ -102,7 +102,7 @@ func (r *CronJob) Fields(ns string) Row { i := r.instance if ns == AllNamespaces { - ff = append(ff, Pad(i.Namespace, NSPad)) + ff = append(ff, i.Namespace) } lastScheduled := "" @@ -111,11 +111,11 @@ func (r *CronJob) Fields(ns string) Row { } return append(ff, - Pad(i.Name, NamePad), + i.Name, i.Spec.Schedule, boolPtrToStr(i.Spec.Suspend), strconv.Itoa(len(i.Status.Active)), lastScheduled, - Pad(toAge(i.ObjectMeta.CreationTimestamp), AgePad), + toAge(i.ObjectMeta.CreationTimestamp), ) } diff --git a/internal/resource/cronjob_test.go b/internal/resource/cronjob_test.go index 560eebec..51f21d6a 100644 --- a/internal/resource/cronjob_test.go +++ b/internal/resource/cronjob_test.go @@ -39,7 +39,7 @@ func TestCronJobListAccess(t *testing.T) { func TestCronJobFields(t *testing.T) { r := newCronJob().Fields("blee") - assert.Equal(t, resource.Pad("fred", resource.NamePad), r[0]) + assert.Equal(t, "fred", r[0]) } func TestCronJobMarshal(t *testing.T) { @@ -75,7 +75,7 @@ func TestCronJobListData(t *testing.T) { for _, d := range row.Deltas { assert.Equal(t, "", d) } - assert.Equal(t, resource.Row{resource.Pad("fred", resource.NamePad)}, row.Fields[:1]) + assert.Equal(t, resource.Row{"fred"}, row.Fields[:1]) } // Helpers... diff --git a/internal/resource/helpers.go b/internal/resource/helpers.go index 5868c6d0..d11cf7f3 100644 --- a/internal/resource/helpers.go +++ b/internal/resource/helpers.go @@ -36,14 +36,6 @@ const ( NAValue = "" ) -// Columns Padding... -const ( - NSPad = 13 - NamePad = 50 - AgePad = 5 - RBACPad = 80 -) - func asPerc(f float64) string { return fmt.Sprintf("%d%%", int(f)) } @@ -98,21 +90,6 @@ func toAge(timestamp metav1.Time) string { return duration.HumanDuration(time.Since(timestamp.Time)) } -// FixCol set column width to specified size by either truncating or padding. -func FixCol(s string, size int) string { - if len(s) > size { - return Truncate(s, size) - } - return s + strings.Repeat(" ", size-len(s)) -} - -// Pad a string up to the given length. -func Pad(s string, l int) string { - fmat := "%-" + strconv.Itoa(l) + "s" - - return fmt.Sprintf(fmat, s) -} - // Truncate a string to the given l and suffix ellipsis if needed. func Truncate(str string, width int) string { return runewidth.Truncate(str, width, string(tview.SemigraphicsHorizontalEllipsis)) diff --git a/internal/resource/helpers_test.go b/internal/resource/helpers_test.go index 422e64da..f9f6a17d 100644 --- a/internal/resource/helpers_test.go +++ b/internal/resource/helpers_test.go @@ -77,22 +77,6 @@ func TestNa(t *testing.T) { } } -func TestPad(t *testing.T) { - uu := []struct { - s string - l int - e string - }{ - {"fred", 10, "fred "}, - {"fred", 6, "fred "}, - {"fred", 4, "fred"}, - } - - for _, u := range uu { - assert.Equal(t, u.e, Pad(u.s, u.l)) - } -} - func TestTruncate(t *testing.T) { uu := []struct { s string @@ -109,22 +93,6 @@ func TestTruncate(t *testing.T) { } } -func TestSizeCol(t *testing.T) { - uu := []struct { - s string - l int - e string - }{ - {"fred", 3, "fr…"}, - {"01234567890", 10, "012345678…"}, - {"fred", 10, "fred "}, - } - - for _, u := range uu { - assert.Equal(t, u.e, FixCol(u.s, u.l)) - } -} - func TestMapToStr(t *testing.T) { uu := []struct { i map[string]string diff --git a/internal/resource/job.go b/internal/resource/job.go index 027d81ac..5354a66c 100644 --- a/internal/resource/job.go +++ b/internal/resource/job.go @@ -132,18 +132,18 @@ func (r *Job) Fields(ns string) Row { i := r.instance if ns == AllNamespaces { - ff = append(ff, Pad(i.Namespace, NSPad)) + ff = append(ff, i.Namespace) } cc, ii := r.toContainers(i.Spec.Template.Spec) return append(ff, - Pad(i.Name, NamePad), + i.Name, r.toCompletion(i.Spec, i.Status), r.toDuration(i.Status), cc, ii, - Pad(toAge(i.ObjectMeta.CreationTimestamp), AgePad), + toAge(i.ObjectMeta.CreationTimestamp), ) } diff --git a/internal/resource/job_test.go b/internal/resource/job_test.go index d437377f..1501bbc3 100644 --- a/internal/resource/job_test.go +++ b/internal/resource/job_test.go @@ -38,7 +38,7 @@ func TestJobListAccess(t *testing.T) { func TestJobFields(t *testing.T) { r := newJob().Fields("blee") - assert.Equal(t, resource.Pad("fred", resource.NamePad), r[0]) + assert.Equal(t, "fred", r[0]) } func TestJobMarshal(t *testing.T) { @@ -74,7 +74,7 @@ func TestJobListData(t *testing.T) { for _, d := range row.Deltas { assert.Equal(t, "", d) } - assert.Equal(t, resource.Row{resource.Pad("fred", resource.NamePad)}, row.Fields[:1]) + assert.Equal(t, resource.Row{"fred"}, row.Fields[:1]) } // Helpers... diff --git a/internal/resource/pod.go b/internal/resource/pod.go index 62498e98..d898b126 100644 --- a/internal/resource/pod.go +++ b/internal/resource/pod.go @@ -203,14 +203,14 @@ func (r *Pod) Fields(ns string) Row { i := r.instance if ns == AllNamespaces { - ff = append(ff, FixCol(i.Namespace, NSPad)) + ff = append(ff, i.Namespace) } ss := i.Status.ContainerStatuses cr, _, rc := r.statuses(ss) return append(ff, - FixCol(i.ObjectMeta.Name, NamePad), + i.ObjectMeta.Name, strconv.Itoa(cr)+"/"+strconv.Itoa(len(ss)), r.phase(i.Status), strconv.Itoa(rc), @@ -219,7 +219,7 @@ func (r *Pod) Fields(ns string) Row { i.Status.PodIP, i.Spec.NodeName, r.mapQOS(i.Status.QOSClass), - Pad(toAge(i.ObjectMeta.CreationTimestamp), AgePad), + toAge(i.ObjectMeta.CreationTimestamp), ) } @@ -229,13 +229,14 @@ func (r *Pod) Fields(ns string) Row { func (*Pod) mapQOS(class v1.PodQOSClass) string { switch class { case v1.PodQOSGuaranteed: - return "Ga" + return "GA" case v1.PodQOSBurstable: - return "Bu" + return "BU" default: return "BE" } } + func (r *Pod) statuses(ss []v1.ContainerStatus) (cr, ct, rc int) { for _, c := range ss { if c.State.Terminated != nil { diff --git a/internal/resource/pod_test.go b/internal/resource/pod_test.go index 80d01d33..21109463 100644 --- a/internal/resource/pod_test.go +++ b/internal/resource/pod_test.go @@ -41,7 +41,7 @@ func TestPodListAccess(t *testing.T) { func TestPodFields(t *testing.T) { r := newPod().Fields("blee") - assert.Equal(t, resource.FixCol("fred", 50), r[0]) + assert.Equal(t, "fred", r[0]) } func TestPodMarshal(t *testing.T) { diff --git a/internal/resource/ro.go b/internal/resource/ro.go index b47572c3..0d69057b 100644 --- a/internal/resource/ro.go +++ b/internal/resource/ro.go @@ -79,12 +79,12 @@ func (r *Role) Fields(ns string) Row { i := r.instance if ns == AllNamespaces { - ff = append(ff, Pad(i.Namespace, NSPad)) + ff = append(ff, i.Namespace) } return append(ff, - Pad(i.Name, RBACPad), - Pad(toAge(i.ObjectMeta.CreationTimestamp), AgePad), + i.Name, + toAge(i.ObjectMeta.CreationTimestamp), ) } diff --git a/internal/resource/ro_binding.go b/internal/resource/ro_binding.go index 5788f7d6..5b800755 100644 --- a/internal/resource/ro_binding.go +++ b/internal/resource/ro_binding.go @@ -1,8 +1,6 @@ package resource import ( - "strings" - "github.com/derailed/k9s/internal/k8s" "github.com/rs/zerolog/log" v1 "k8s.io/api/rbac/v1" @@ -70,49 +68,25 @@ func (*RoleBinding) Header(ns string) Row { hh = append(hh, "NAMESPACE") } - return append(hh, "NAME", "ROLE", "SUBJECTS", "AGE") + return append(hh, "NAME", "ROLE", "KIND", "SUBJECTS", "AGE") } // Fields retrieves displayable fields. func (r *RoleBinding) Fields(ns string) Row { - ff := make(Row, 0, len(r.Header(ns))) i := r.instance + + ff := make(Row, 0, len(r.Header(ns))) if ns == AllNamespaces { ff = append(ff, i.Namespace) } + kind, ss := renderSubjects(i.Subjects) + return append(ff, i.Name, i.RoleRef.Name, - r.toSubjects(i.Subjects), + kind, + ss, toAge(i.ObjectMeta.CreationTimestamp), ) } - -// ---------------------------------------------------------------------------- -// Helpers... - -func (r *RoleBinding) toSubjects(ss []v1.Subject) string { - var acc string - for i, s := range ss { - acc += s.Name + "/" + r.toSubjectAlias(s.Kind) - if i < len(ss)-1 { - acc += "," - } - } - - return acc -} - -func (r *RoleBinding) toSubjectAlias(s string) string { - switch s { - case v1.UserKind: - return "USR" - case v1.GroupKind: - return "GRP" - case v1.ServiceAccountKind: - return "SA" - default: - return strings.ToUpper(s) - } -} diff --git a/internal/resource/ro_binding_int_test.go b/internal/resource/ro_binding_int_test.go index 537a946a..d3ef208d 100644 --- a/internal/resource/ro_binding_int_test.go +++ b/internal/resource/ro_binding_int_test.go @@ -8,8 +8,6 @@ import ( ) func TestToSubjectAlias(t *testing.T) { - r := RoleBinding{} - uu := []struct { i string e string @@ -20,38 +18,44 @@ func TestToSubjectAlias(t *testing.T) { {"fred", "FRED"}, } for _, u := range uu { - assert.Equal(t, u.e, r.toSubjectAlias(u.i)) + assert.Equal(t, u.e, toSubjectAlias(u.i)) } } -func TestToSubjects(t *testing.T) { - r := RoleBinding{} - +func TestRenderSubjects(t *testing.T) { uu := []struct { - i []rbacv1.Subject - e string + ss []rbacv1.Subject + ek string + e string }{ { []rbacv1.Subject{ {Name: "blee", Kind: rbacv1.UserKind}, }, - "blee/USR", + "USR", + "blee", + }, + { + []rbacv1.Subject{}, + "", + "", }, } for _, u := range uu { - assert.Equal(t, u.e, r.toSubjects(u.i)) + kind, ss := renderSubjects(u.ss) + assert.Equal(t, u.e, ss) + assert.Equal(t, u.ek, kind) } } func BenchmarkToSubjects(b *testing.B) { - var r RoleBinding ss := []rbacv1.Subject{ {Name: "blee", Kind: rbacv1.UserKind}, } + b.ResetTimer() b.ReportAllocs() - for i := 0; i < b.N; i++ { - r.toSubjects(ss) + renderSubjects(ss) } } diff --git a/internal/resource/ro_binding_test.go b/internal/resource/ro_binding_test.go index cba2306c..d3bb82a9 100644 --- a/internal/resource/ro_binding_test.go +++ b/internal/resource/ro_binding_test.go @@ -51,7 +51,7 @@ func TestRBListData(t *testing.T) { assert.Equal(t, 1, len(td.Rows)) assert.Equal(t, "blee", l.GetNamespace()) row := td.Rows["blee/fred"] - assert.Equal(t, 4, len(row.Deltas)) + assert.Equal(t, 5, len(row.Deltas)) for _, d := range row.Deltas { assert.Equal(t, "", d) } diff --git a/internal/resource/ro_test.go b/internal/resource/ro_test.go index 1932c772..82bef2bd 100644 --- a/internal/resource/ro_test.go +++ b/internal/resource/ro_test.go @@ -54,7 +54,7 @@ func TestRoleListData(t *testing.T) { for _, d := range row.Deltas { assert.Equal(t, "", d) } - assert.Equal(t, resource.Row{resource.Pad("fred", resource.RBACPad)}, row.Fields[:1]) + assert.Equal(t, resource.Row{"fred"}, row.Fields[:1]) } // Helpers... diff --git a/internal/resource/rs.go b/internal/resource/rs.go index 4bd87ef7..c096d1d3 100644 --- a/internal/resource/rs.go +++ b/internal/resource/rs.go @@ -75,18 +75,18 @@ func (*ReplicaSet) Header(ns string) Row { // Fields retrieves displayable fields. func (r *ReplicaSet) Fields(ns string) Row { - ff := make(Row, 0, len(r.Header(ns))) - if ns == AllNamespaces { - ff = append(ff, Pad(r.instance.Namespace, NSPad)) - } - i := r.instance + ff := make(Row, 0, len(r.Header(ns))) + if ns == AllNamespaces { + ff = append(ff, i.Namespace) + } + return append(ff, - Pad(i.Name, NamePad), + i.Name, strconv.Itoa(int(*i.Spec.Replicas)), strconv.Itoa(int(i.Status.Replicas)), strconv.Itoa(int(i.Status.ReadyReplicas)), - Pad(toAge(i.ObjectMeta.CreationTimestamp), AgePad), + toAge(i.ObjectMeta.CreationTimestamp), ) } diff --git a/internal/resource/rs_test.go b/internal/resource/rs_test.go index 6462d73d..486c509e 100644 --- a/internal/resource/rs_test.go +++ b/internal/resource/rs_test.go @@ -54,7 +54,7 @@ func TestReplicaSetListData(t *testing.T) { for _, d := range row.Deltas { assert.Equal(t, "", d) } - assert.Equal(t, resource.Row{resource.Pad("fred", resource.NamePad)}, row.Fields[:1]) + assert.Equal(t, resource.Row{"fred"}, row.Fields[:1]) } // Helpers... diff --git a/internal/resource/svc.go b/internal/resource/svc.go index 6fc8bb6a..a9ae820a 100644 --- a/internal/resource/svc.go +++ b/internal/resource/svc.go @@ -91,16 +91,16 @@ func (r *Service) Fields(ns string) Row { i := r.instance if ns == AllNamespaces { - ff = append(ff, Pad(i.Namespace, NSPad)) + ff = append(ff, i.Namespace) } return append(ff, - Pad(i.ObjectMeta.Name, NamePad), + i.ObjectMeta.Name, string(i.Spec.Type), i.Spec.ClusterIP, r.toIPs(i.Spec.Type, r.getSvcExtIPS(i)), r.toPorts(i.Spec.Ports), - Pad(toAge(i.ObjectMeta.CreationTimestamp), AgePad), + toAge(i.ObjectMeta.CreationTimestamp), ) } diff --git a/internal/resource/svc_test.go b/internal/resource/svc_test.go index 994f3199..517abccc 100644 --- a/internal/resource/svc_test.go +++ b/internal/resource/svc_test.go @@ -52,8 +52,8 @@ func TestSvcFields(t *testing.T) { { i: newSvc(), e: resource.Row{ - resource.Pad("blee", resource.NSPad), - resource.Pad("fred", resource.NamePad), + "blee", + "fred", "ClusterIP", "1.1.1.1", "2.2.2.2", @@ -102,7 +102,7 @@ func TestSVCListData(t *testing.T) { for _, d := range row.Deltas { assert.Equal(t, "", d) } - assert.Equal(t, resource.Row{resource.Pad("fred", 50)}, row.Fields[:1]) + assert.Equal(t, resource.Row{"fred"}, row.Fields[:1]) } // Helpers... diff --git a/internal/views/alias.go b/internal/views/alias.go index aaf26662..0a0288c5 100644 --- a/internal/views/alias.go +++ b/internal/views/alias.go @@ -124,9 +124,9 @@ func (v *aliasView) hydrate() resource.TableData { for k := range cmds { fields := resource.Row{ - resource.Pad(k, 30), - resource.Pad(cmds[k].title, 30), - resource.Pad(cmds[k].api, 30), + pad(k, 30), + pad(cmds[k].title, 30), + pad(cmds[k].api, 30), } data.Rows[k] = &resource.RowEvent{ Action: resource.New, diff --git a/internal/views/app.go b/internal/views/app.go index 54bfefb4..684b8dde 100644 --- a/internal/views/app.go +++ b/internal/views/app.go @@ -31,7 +31,10 @@ type ( resourceViewer interface { igniter + setEnterFn(enterFn) + setColorerFn(colorerFn) + setDecorateFn(decorateFn) } appView struct { diff --git a/internal/views/command.go b/internal/views/command.go index 776f1cea..167b99ec 100644 --- a/internal/views/command.go +++ b/internal/views/command.go @@ -69,10 +69,16 @@ func (c *command) run(cmd string) bool { } else { r = res.listFn(c.app.conn(), resource.DefaultNamespace) } - v = res.viewFn(res.title, c.app, r, res.colorerFn) + v = res.viewFn(res.title, c.app, r) + if res.colorerFn != nil { + v.setColorerFn(res.colorerFn) + } if res.enterFn != nil { v.setEnterFn(res.enterFn) } + if res.decorateFn != nil { + v.setDecorateFn(res.decorateFn) + } const fmat = "Viewing %s in namespace %s..." c.app.flash(flashInfo, fmt.Sprintf(fmat, res.title, c.app.config.ActiveNamespace())) log.Debug().Msgf("Running command %s", cmd) @@ -87,16 +93,16 @@ func (c *command) run(cmd string) bool { return false } - n := res.Plural - if len(n) == 0 { - n = res.Singular + name := res.Plural + if len(name) == 0 { + name = res.Singular } v = newResourceView( res.Kind, c.app, - resource.NewCustomList(c.app.conn(), "", res.Group, res.Version, n), - defaultColorer, + resource.NewCustomList(c.app.conn(), "", res.Group, res.Version, name), ) + v.setColorerFn(defaultColorer) c.exec(cmd, v) return true } diff --git a/internal/views/context.go b/internal/views/context.go index 6183a110..a47a21b2 100644 --- a/internal/views/context.go +++ b/internal/views/context.go @@ -11,8 +11,8 @@ type contextView struct { *resourceView } -func newContextView(t string, app *appView, list resource.List, c colorerFn) resourceViewer { - v := contextView{newResourceView(t, app, list, c).(*resourceView)} +func newContextView(t string, app *appView, list resource.List) resourceViewer { + v := contextView{newResourceView(t, app, list).(*resourceView)} { v.extraActionsFn = v.extraActions v.getTV().cleanseFn = v.cleanser diff --git a/internal/views/cronjob.go b/internal/views/cronjob.go index 1f464cc2..e96ffafe 100644 --- a/internal/views/cronjob.go +++ b/internal/views/cronjob.go @@ -11,12 +11,13 @@ type cronJobView struct { *resourceView } -func newCronJobView(t string, app *appView, list resource.List, c colorerFn) resourceViewer { +func newCronJobView(t string, app *appView, list resource.List) resourceViewer { v := cronJobView{ - resourceView: newResourceView(t, app, list, c).(*resourceView), + resourceView: newResourceView(t, app, list).(*resourceView), } v.extraActionsFn = v.extraActions v.switchPage("cronjob") + return &v } @@ -30,6 +31,7 @@ func (v *cronJobView) trigger(evt *tcell.EventKey) *tcell.EventKey { v.app.flash(flashErr, "Boom!", err.Error()) return evt } + return nil } diff --git a/internal/views/fu.go b/internal/views/fu.go index ee6a838b..0371335a 100644 --- a/internal/views/fu.go +++ b/internal/views/fu.go @@ -302,7 +302,7 @@ func (v *fuView) prepRow(ns, res, grp, binding string, verbs []string) resource. grp = toGroup(grp) } - return v.makeRow(resource.Pad(ns, nsLen), resource.Pad(res, nameLen), resource.Pad(grp, groupLen), binding, asVerbs(verbs...)) + return v.makeRow(ns, res, grp, binding, asVerbs(verbs...)) } func (*fuView) makeRow(ns, res, group, binding string, verbs []string) resource.Row { diff --git a/internal/views/helpers.go b/internal/views/helpers.go index b4be66b5..2421e97e 100644 --- a/internal/views/helpers.go +++ b/internal/views/helpers.go @@ -11,6 +11,10 @@ func toPerc(f float64) string { } func deltas(c, n string) string { + c, n = strings.TrimSpace(c), strings.TrimSpace(n) + + // log.Debug().Msgf("`%s` vs `%s`", c, n) + if c == "n/a" { return n } diff --git a/internal/views/job.go b/internal/views/job.go index be080239..bf148060 100644 --- a/internal/views/job.go +++ b/internal/views/job.go @@ -10,13 +10,14 @@ type jobView struct { *resourceView } -func newJobView(t string, app *appView, list resource.List, c colorerFn) resourceViewer { - v := jobView{newResourceView(t, app, list, c).(*resourceView)} +func newJobView(t string, app *appView, list resource.List) resourceViewer { + v := jobView{newResourceView(t, app, list).(*resourceView)} { v.extraActionsFn = v.extraActions + v.AddPage("logs", newLogsView(&v), true, false) + v.switchPage("job") } - v.AddPage("logs", newLogsView(&v), true, false) - v.switchPage("job") + return &v } @@ -60,6 +61,7 @@ func (v *jobView) logs(evt *tcell.EventKey) *tcell.EventKey { v.switchPage("logs") l.init() + return nil } diff --git a/internal/views/namespace.go b/internal/views/namespace.go index e4429319..51ff022e 100644 --- a/internal/views/namespace.go +++ b/internal/views/namespace.go @@ -23,13 +23,16 @@ type namespaceView struct { *resourceView } -func newNamespaceView(t string, app *appView, list resource.List, c colorerFn) resourceViewer { - v := namespaceView{newResourceView(t, app, list, c).(*resourceView)} - v.extraActionsFn = v.extraActions - v.selectedFn = v.getSelectedItem - v.decorateDataFn = v.decorate - v.getTV().cleanseFn = v.cleanser - v.switchPage("ns") +func newNamespaceView(t string, app *appView, list resource.List) resourceViewer { + v := namespaceView{newResourceView(t, app, list).(*resourceView)} + { + v.extraActionsFn = v.extraActions + v.selectedFn = v.getSelectedItem + v.decorateFn = v.decorate + v.getTV().cleanseFn = v.cleanser + v.switchPage("ns") + } + return &v } @@ -44,6 +47,7 @@ func (v *namespaceView) switchNsCmd(evt *tcell.EventKey) *tcell.EventKey { } v.useNamespace(v.getSelectedItem()) v.app.gotoResource("po", true) + return nil } @@ -52,6 +56,7 @@ func (v *namespaceView) useNsCmd(evt *tcell.EventKey) *tcell.EventKey { return evt } v.useNamespace(v.getSelectedItem()) + return nil } @@ -92,5 +97,6 @@ func (v *namespaceView) decorate(data resource.TableData) resource.TableData { r.Action = resource.Unchanged } } + return data } diff --git a/internal/views/no.go b/internal/views/no.go index 0be37178..4650a731 100644 --- a/internal/views/no.go +++ b/internal/views/no.go @@ -9,8 +9,8 @@ type nodeView struct { *resourceView } -func newNodeView(t string, app *appView, list resource.List, c colorerFn) resourceViewer { - v := nodeView{newResourceView(t, app, list, c).(*resourceView)} +func newNodeView(t string, app *appView, list resource.List) resourceViewer { + v := nodeView{newResourceView(t, app, list).(*resourceView)} { v.extraActionsFn = v.extraActions v.switchPage("no") diff --git a/internal/views/padding.go b/internal/views/padding.go new file mode 100644 index 00000000..3deb740f --- /dev/null +++ b/internal/views/padding.go @@ -0,0 +1,51 @@ +package views + +import ( + "strings" + "unicode" + + "github.com/derailed/k9s/internal/resource" +) + +type maxyPad []int + +func computeMaxColumns(pads maxyPad, sortCol int, table resource.TableData) { + for index, h := range table.Header { + pads[index] = len(h) + if index == sortCol { + pads[index] = len(h) + 2 + } + } + + var row int + for _, rev := range table.Rows { + for index, field := range rev.Fields { + if len(field) > pads[index] && isASCII(field) { + pads[index] = len([]rune(field)) + } + } + row++ + } +} + +func isASCII(s string) bool { + for i := 0; i < len(s); i++ { + if s[i] > unicode.MaxASCII { + return false + } + } + return true +} + +// Pad a string up to the given length or truncates if greater than length. +func pad(s string, width int) string { + if len(s) == width { + return s + } + + if len(s) > width { + return resource.Truncate(s, width) + } + + return s + strings.Repeat(" ", width-len(s)) +} diff --git a/internal/views/padding_test.go b/internal/views/padding_test.go new file mode 100644 index 00000000..76256418 --- /dev/null +++ b/internal/views/padding_test.go @@ -0,0 +1,107 @@ +package views + +import ( + "testing" + + "github.com/derailed/k9s/internal/resource" + "github.com/stretchr/testify/assert" +) + +func TestMaxColumn(t *testing.T) { + uu := []struct { + t resource.TableData + s int + e maxyPad + }{ + { + resource.TableData{ + Header: resource.Row{"A", "B"}, + Rows: resource.RowEvents{ + "r1": &resource.RowEvent{Fields: resource.Row{"hello", "world"}}, + "r2": &resource.RowEvent{Fields: resource.Row{"yo", "mama"}}, + }, + }, + 0, + maxyPad{5, 5}, + }, + { + resource.TableData{ + Header: resource.Row{"A", "B"}, + Rows: resource.RowEvents{ + "r1": &resource.RowEvent{Fields: resource.Row{"hello", "world"}}, + "r2": &resource.RowEvent{Fields: resource.Row{"yo", "mama"}}, + }, + }, + 1, + maxyPad{5, 5}, + }, + { + resource.TableData{ + Header: resource.Row{"A", "B"}, + Rows: resource.RowEvents{ + "r1": &resource.RowEvent{Fields: resource.Row{"Hello World lord of ipsums 😅", "world"}}, + "r2": &resource.RowEvent{Fields: resource.Row{"o", "mama"}}, + }, + }, + 0, + maxyPad{3, 5}, + }, + } + + for _, u := range uu { + pads := make(maxyPad, len(u.t.Header)) + computeMaxColumns(pads, u.s, u.t) + assert.Equal(t, u.e, pads) + } +} + +func TestIsASCII(t *testing.T) { + uu := []struct { + s string + e bool + }{ + {"hello", true}, + {"Yo! 😄", false}, + {"😄", false}, + } + + for _, u := range uu { + assert.Equal(t, u.e, isASCII(u.s)) + } +} + +func TestPad(t *testing.T) { + uu := []struct { + s string + l int + e string + }{ + {"fred", 3, "fr…"}, + {"01234567890", 10, "012345678…"}, + {"fred", 10, "fred "}, + {"fred", 6, "fred "}, + {"fred", 4, "fred"}, + } + + for _, u := range uu { + assert.Equal(t, u.e, pad(u.s, u.l)) + } +} + +func BenchmarkMaxColumn(b *testing.B) { + table := resource.TableData{ + Header: resource.Row{"A", "B"}, + Rows: resource.RowEvents{ + "r1": &resource.RowEvent{Fields: resource.Row{"hello", "world"}}, + "r2": &resource.RowEvent{Fields: resource.Row{"yo", "mama"}}, + }, + } + + pads := make(maxyPad, len(table.Header)) + + b.ReportAllocs() + b.ResetTimer() + for n := 0; n < b.N; n++ { + computeMaxColumns(pads, 0, table) + } +} diff --git a/internal/views/pod.go b/internal/views/pod.go index b3b9aeb5..6a1cd2e1 100644 --- a/internal/views/pod.go +++ b/internal/views/pod.go @@ -20,8 +20,8 @@ type loggable interface { switchPage(n string) } -func newPodView(t string, app *appView, list resource.List, c colorerFn) resourceViewer { - v := podView{newResourceView(t, app, list, c).(*resourceView)} +func newPodView(t string, app *appView, list resource.List) resourceViewer { + v := podView{newResourceView(t, app, list).(*resourceView)} { v.extraActionsFn = v.extraActions } diff --git a/internal/views/rbac.go b/internal/views/rbac.go index 9a658fc9..bc783714 100644 --- a/internal/views/rbac.go +++ b/internal/views/rbac.go @@ -311,7 +311,7 @@ func prepRow(res, grp string, verbs []string) resource.Row { grp = toGroup(grp) } - return makeRow(resource.Pad(res, nameLen), resource.Pad(grp, groupLen), asVerbs(verbs...)) + return makeRow(res, grp, asVerbs(verbs...)) } func makeRow(res, group string, verbs []string) resource.Row { @@ -329,7 +329,7 @@ func asVerbs(verbs ...string) resource.Row { r := make(resource.Row, 0, len(k8sVerbs)+1) for _, v := range k8sVerbs { - r = append(r, resource.Pad(toVerbIcon(hasVerb(verbs, v)), 4)) + r = append(r, toVerbIcon(hasVerb(verbs, v))) } var unknowns []string diff --git a/internal/views/rbac_test.go b/internal/views/rbac_test.go index 7c341cc2..fb25db88 100644 --- a/internal/views/rbac_test.go +++ b/internal/views/rbac_test.go @@ -59,7 +59,7 @@ func TestParseRules(t *testing.T) { {APIGroups: []string{"*"}, Resources: []string{"*"}, Verbs: []string{"*"}}, }, map[string]resource.Row{ - "*.*": resource.Row{resource.Pad("*.*", 60), resource.Pad("*", 30), ok, ok, ok, ok, ok, ok, ok, ok, ""}, + "*.*": resource.Row{"*.*", "*", ok, ok, ok, ok, ok, ok, ok, ok, ""}, }, }, { @@ -67,7 +67,7 @@ func TestParseRules(t *testing.T) { {APIGroups: []string{"*"}, Resources: []string{"*"}, Verbs: []string{"get"}}, }, map[string]resource.Row{ - "*.*": resource.Row{resource.Pad("*.*", 60), resource.Pad("*", 30), ok, nok, nok, nok, nok, nok, nok, nok, ""}, + "*.*": resource.Row{"*.*", "*", ok, nok, nok, nok, nok, nok, nok, nok, ""}, }, }, { @@ -75,7 +75,7 @@ func TestParseRules(t *testing.T) { {APIGroups: []string{""}, Resources: []string{"*"}, Verbs: []string{"list"}}, }, map[string]resource.Row{ - "*": resource.Row{resource.Pad("*", 60), resource.Pad("v1", 30), nok, ok, nok, nok, nok, nok, nok, nok, ""}, + "*": resource.Row{"*", "v1", nok, ok, nok, nok, nok, nok, nok, nok, ""}, }, }, { @@ -83,8 +83,8 @@ func TestParseRules(t *testing.T) { {APIGroups: []string{""}, Resources: []string{"pods"}, Verbs: []string{"list"}, ResourceNames: []string{"fred"}}, }, map[string]resource.Row{ - "pods": resource.Row{resource.Pad("pods", 60), resource.Pad("v1", 30), nok, ok, nok, nok, nok, nok, nok, nok, ""}, - "pods/fred": resource.Row{resource.Pad("pods/fred", 60), resource.Pad("v1", 30), nok, ok, nok, nok, nok, nok, nok, nok, ""}, + "pods": resource.Row{"pods", "v1", nok, ok, nok, nok, nok, nok, nok, nok, ""}, + "pods/fred": resource.Row{"pods/fred", "v1", nok, ok, nok, nok, nok, nok, nok, nok, ""}, }, }, { @@ -92,7 +92,7 @@ func TestParseRules(t *testing.T) { {APIGroups: []string{}, Resources: []string{}, Verbs: []string{"get"}, NonResourceURLs: []string{"/fred"}}, }, map[string]resource.Row{ - "/fred": resource.Row{resource.Pad("/fred", 60), resource.Pad("", 30), ok, nok, nok, nok, nok, nok, nok, nok, ""}, + "/fred": resource.Row{"/fred", "", ok, nok, nok, nok, nok, nok, nok, nok, ""}, }, }, { @@ -100,7 +100,7 @@ func TestParseRules(t *testing.T) { {APIGroups: []string{}, Resources: []string{}, Verbs: []string{"get"}, NonResourceURLs: []string{"fred"}}, }, map[string]resource.Row{ - "/fred": resource.Row{resource.Pad("/fred", 60), resource.Pad("", 30), ok, nok, nok, nok, nok, nok, nok, nok, ""}, + "/fred": resource.Row{"/fred", "", ok, nok, nok, nok, nok, nok, nok, nok, ""}, }, }, } diff --git a/internal/views/registrar.go b/internal/views/registrar.go index 83f6f883..4b368e12 100644 --- a/internal/views/registrar.go +++ b/internal/views/registrar.go @@ -7,20 +7,22 @@ import ( ) type ( - viewFn func(ns string, app *appView, list resource.List, colorer colorerFn) resourceViewer - listFn func(c resource.Connection, ns string) resource.List - listMxFn func(c resource.Connection, mx resource.MetricsServer, ns string) resource.List - colorerFn func(ns string, evt *resource.RowEvent) tcell.Color - enterFn func(app *appView, ns, resource, selection string) + viewFn func(ns string, app *appView, list resource.List) resourceViewer + listFn func(c resource.Connection, ns string) resource.List + listMxFn func(c resource.Connection, mx resource.MetricsServer, ns string) resource.List + colorerFn func(ns string, evt *resource.RowEvent) tcell.Color + enterFn func(app *appView, ns, resource, selection string) + decorateFn func(resource.TableData) resource.TableData resCmd struct { - title string - api string - viewFn viewFn - listFn listFn - listMxFn listMxFn - enterFn enterFn - colorerFn colorerFn + title string + api string + viewFn viewFn + listFn listFn + listMxFn listMxFn + enterFn enterFn + colorerFn colorerFn + decorateFn decorateFn } ) @@ -86,40 +88,36 @@ func showRBAC(app *appView, ns, resource, selection string) { func resourceViews() map[string]resCmd { return map[string]resCmd{ "cm": { - title: "ConfigMaps", - api: "", - viewFn: newResourceView, - listFn: resource.NewConfigMapList, - colorerFn: defaultColorer, + title: "ConfigMaps", + api: "", + viewFn: newResourceView, + listFn: resource.NewConfigMapList, }, "cr": { - title: "ClusterRoles", - api: "rbac.authorization.k8s.io", - viewFn: newResourceView, - listFn: resource.NewClusterRoleList, - enterFn: showRBAC, - colorerFn: defaultColorer, + title: "ClusterRoles", + api: "rbac.authorization.k8s.io", + viewFn: newResourceView, + listFn: resource.NewClusterRoleList, + enterFn: showRBAC, }, "crb": { - title: "ClusterRoleBindings", - api: "rbac.authorization.k8s.io", - viewFn: newResourceView, - listFn: resource.NewClusterRoleBindingList, - colorerFn: defaultColorer, + title: "ClusterRoleBindings", + api: "rbac.authorization.k8s.io", + viewFn: newResourceView, + listFn: resource.NewClusterRoleBindingList, + // decorateFn: crbDecorator, }, "crd": { - title: "CustomResourceDefinitions", - api: "apiextensions.k8s.io", - viewFn: newResourceView, - listFn: resource.NewCRDList, - colorerFn: defaultColorer, + title: "CustomResourceDefinitions", + api: "apiextensions.k8s.io", + viewFn: newResourceView, + listFn: resource.NewCRDList, }, "cj": { - title: "CronJobs", - api: "batch", - viewFn: newCronJobView, - listFn: resource.NewCronJobList, - colorerFn: defaultColorer, + title: "CronJobs", + api: "batch", + viewFn: newCronJobView, + listFn: resource.NewCronJobList, }, "ctx": { title: "Contexts", @@ -143,11 +141,10 @@ func resourceViews() map[string]resCmd { colorerFn: dpColorer, }, "ep": { - title: "EndPoints", - api: "", - viewFn: newResourceView, - listFn: resource.NewEndpointsList, - colorerFn: defaultColorer, + title: "EndPoints", + api: "", + viewFn: newResourceView, + listFn: resource.NewEndpointsList, }, "ev": { title: "Events", @@ -157,25 +154,22 @@ func resourceViews() map[string]resCmd { colorerFn: evColorer, }, "hpa": { - title: "HorizontalPodAutoscalers", - api: "autoscaling", - viewFn: newResourceView, - listFn: resource.NewHPAList, - colorerFn: defaultColorer, + title: "HorizontalPodAutoscalers", + api: "autoscaling", + viewFn: newResourceView, + listFn: resource.NewHPAList, }, "ing": { - title: "Ingress", - api: "extensions", - viewFn: newResourceView, - listFn: resource.NewIngressList, - colorerFn: defaultColorer, + title: "Ingress", + api: "extensions", + viewFn: newResourceView, + listFn: resource.NewIngressList, }, "jo": { - title: "Jobs", - api: "batch", - viewFn: newJobView, - listFn: resource.NewJobList, - colorerFn: defaultColorer, + title: "Jobs", + api: "batch", + viewFn: newJobView, + listFn: resource.NewJobList, }, "no": { title: "Nodes", @@ -220,11 +214,10 @@ func resourceViews() map[string]resCmd { colorerFn: pvcColorer, }, "rb": { - title: "RoleBindings", - api: "rbac.authorization.k8s.io", - viewFn: newResourceView, - listFn: resource.NewRoleBindingList, - colorerFn: defaultColorer, + title: "RoleBindings", + api: "rbac.authorization.k8s.io", + viewFn: newResourceView, + listFn: resource.NewRoleBindingList, }, "rc": { title: "ReplicationControllers", @@ -234,12 +227,11 @@ func resourceViews() map[string]resCmd { colorerFn: rsColorer, }, "ro": { - title: "Roles", - api: "rbac.authorization.k8s.io", - viewFn: newResourceView, - listFn: resource.NewRoleList, - enterFn: showRBAC, - colorerFn: defaultColorer, + title: "Roles", + api: "rbac.authorization.k8s.io", + viewFn: newResourceView, + listFn: resource.NewRoleList, + enterFn: showRBAC, }, "rs": { title: "ReplicaSets", @@ -249,18 +241,16 @@ func resourceViews() map[string]resCmd { colorerFn: rsColorer, }, "sa": { - title: "ServiceAccounts", - api: "", - viewFn: newResourceView, - listFn: resource.NewServiceAccountList, - colorerFn: defaultColorer, + title: "ServiceAccounts", + api: "", + viewFn: newResourceView, + listFn: resource.NewServiceAccountList, }, "sec": { - title: "Secrets", - api: "", - viewFn: newResourceView, - listFn: resource.NewSecretList, - colorerFn: defaultColorer, + title: "Secrets", + api: "", + viewFn: newResourceView, + listFn: resource.NewSecretList, }, "sts": { title: "StatefulSets", @@ -270,11 +260,11 @@ func resourceViews() map[string]resCmd { colorerFn: stsColorer, }, "svc": { - title: "Services", - api: "", - viewFn: newResourceView, - listFn: resource.NewServiceList, - colorerFn: defaultColorer, + title: "Services", + api: "", + viewFn: newResourceView, + listFn: resource.NewServiceList, + // decorateFn: svcDecorator, }, } } diff --git a/internal/views/resource.go b/internal/views/resource.go index 30c4befb..df9c0f63 100644 --- a/internal/views/resource.go +++ b/internal/views/resource.go @@ -45,11 +45,12 @@ type ( enterFn enterFn extraActionsFn func(keyActions) selectedFn func() string - decorateDataFn func(resource.TableData) resource.TableData + decorateFn decorateFn + colorerFn colorerFn } ) -func newResourceView(title string, app *appView, list resource.List, c colorerFn) resourceViewer { +func newResourceView(title string, app *appView, list resource.List) resourceViewer { v := resourceView{ app: app, title: title, @@ -60,7 +61,6 @@ func newResourceView(title string, app *appView, list resource.List, c colorerFn tv := newTableView(app, v.title) { - tv.SetColorer(c) tv.SetSelectionChangedFunc(v.selChanged) } v.AddPage(v.list.GetName(), tv, true, true) @@ -80,6 +80,12 @@ func newResourceView(title string, app *appView, list resource.List, c colorerFn func (v *resourceView) init(ctx context.Context, ns string) { v.selectedItem, v.selectedNS = noSelection, ns + colorer := defaultColorer + if v.colorerFn != nil { + colorer = v.colorerFn + } + v.getTV().setColorer(colorer) + go func(ctx context.Context) { for { select { @@ -107,10 +113,6 @@ func (v *resourceView) selChanged(r, c int) { v.getTV().cmdBuff.setActive(false) } -func (v *resourceView) colorFn(f colorerFn) { - v.getTV().SetColorer(f) -} - func (v *resourceView) getSelectedItem() string { if v.selectedFn != nil { return v.selectedFn() @@ -125,10 +127,19 @@ func (v *resourceView) hints() hints { return v.CurrentPage().Item.(hinter).hints() } +func (v *resourceView) setColorerFn(f colorerFn) { + v.colorerFn = f + v.getTV().setColorer(f) +} + func (v *resourceView) setEnterFn(f enterFn) { v.enterFn = f } +func (v *resourceView) setDecorateFn(f decorateFn) { + v.decorateFn = f +} + // ---------------------------------------------------------------------------- // Actions... @@ -275,12 +286,12 @@ func (v *resourceView) refresh() { v.list.SetNamespace(v.selectedNS) } if err := v.list.Reconcile(); err != nil { - log.Warn().Msgf("Reconcile %v", err) + log.Error().Err(err).Msg("Reconciliation failed") v.app.flash(flashErr, err.Error()) } data := v.list.Data() - if v.decorateDataFn != nil { - data = v.decorateDataFn(data) + if v.decorateFn != nil { + data = v.decorateFn(data) } v.getTV().update(data) v.selectItem(v.selectedRow, 0) diff --git a/internal/views/table.go b/internal/views/table.go index 1645b176..966f6b59 100644 --- a/internal/views/table.go +++ b/internal/views/table.go @@ -213,7 +213,7 @@ func (v *tableView) setDeleted() { } // SetColorer sets up table row color management. -func (v *tableView) SetColorer(f colorerFn) { +func (v *tableView) setColorer(f colorerFn) { v.colorerFn = f } @@ -283,7 +283,7 @@ func (v *tableView) filtered() resource.TableData { return filtered } -func (v *tableView) displayCol(index int, name string) string { +func (v *tableView) sortIndicator(index int, name string) string { if v.sortCol.index != index { return name } @@ -292,13 +292,13 @@ func (v *tableView) displayCol(index int, name string) string { if v.sortCol.asc { order = "↑" } - return fmt.Sprintf("%s[green::]%s[::]", name, order) + return fmt.Sprintf("%s [green::]%s[::]", name, order) } func (v *tableView) doUpdate(data resource.TableData) { v.currentNS = data.Namespace if v.currentNS == resource.AllNamespaces || v.currentNS == "*" { - v.actions[KeyShiftS] = newKeyAction("Sort Namespace", v.sortNamespaceCmd, true) + v.actions[KeyShiftP] = newKeyAction("Sort Namespace", v.sortNamespaceCmd, true) } else { delete(v.actions, KeyShiftS) } @@ -317,9 +317,11 @@ func (v *tableView) doUpdate(data resource.TableData) { v.sortCol.index = 0 } + pads := make(maxyPad, len(data.Header)) + computeMaxColumns(pads, v.sortCol.index, data) var row int for col, h := range data.Header { - v.addHeaderCell(col, h) + v.addHeaderCell(col, h, pads) } row++ @@ -327,62 +329,64 @@ func (v *tableView) doUpdate(data resource.TableData) { if v.sortFn != nil { sortFn = v.sortFn } - - keys := make([]string, len(data.Rows)) - v.sortRows(data.Rows, sortFn, v.sortCol, keys) - groupKeys := map[string][]string{} - for _, k := range keys { - grp := data.Rows[k].Fields[v.sortCol.index] - if s, ok := groupKeys[grp]; ok { - s = append(s, k) - groupKeys[grp] = s - } else { - groupKeys[grp] = []string{k} - } - } - - // Performs secondary to sort by name for each groups. - gKeys := make([]string, len(keys)) - for k, v := range groupKeys { - sort.Strings(v) - gKeys = append(gKeys, k) - } - rs := groupSorter{gKeys, v.sortCol.asc} - sort.Sort(rs) - - for _, gk := range gKeys { - for _, k := range groupKeys[gk] { + prim, sec := v.sortAllRows(data.Rows, sortFn) + for _, pk := range prim { + for _, sk := range sec[pk] { fgColor := tcell.ColorGray if v.colorerFn != nil { - fgColor = v.colorerFn(data.Namespace, data.Rows[k]) + fgColor = v.colorerFn(data.Namespace, data.Rows[sk]) } - for col, field := range data.Rows[k].Fields { - v.addBodyCell(row, col, field, data.Rows[k].Deltas[col], fgColor) + for col, field := range data.Rows[sk].Fields { + v.addBodyCell(row, col, field, data.Rows[sk].Deltas[col], fgColor, pads) } row++ } } } -func (v *tableView) addHeaderCell(col int, name string) { - c := tview.NewTableCell(v.displayCol(col, name)) +func (v *tableView) sortAllRows(rows resource.RowEvents, sortFn sortFn) (resource.Row, map[string]resource.Row) { + keys := make([]string, len(rows)) + v.sortRows(rows, sortFn, v.sortCol, keys) + + sec := make(map[string]resource.Row, len(rows)) + for _, k := range keys { + grp := rows[k].Fields[v.sortCol.index] + log.Debug().Msg("append") + sec[grp] = append(sec[grp], k) + } + + // Performs secondary to sort by name for each groups. + prim := make(resource.Row, 0, len(sec)) + for k, v := range sec { + sort.Strings(v) + prim = append(prim, k) + } + rs := groupSorter{prim, v.sortCol.asc} + sort.Sort(rs) + + return prim, sec +} + +func (v *tableView) addHeaderCell(col int, name string, pads maxyPad) { + c := tview.NewTableCell(v.sortIndicator(col, name)) { - c.SetExpansion(3) - if len(name) == 0 { - c.SetExpansion(1) - } + c.SetExpansion(1) c.SetTextColor(tcell.ColorAntiqueWhite) } v.SetCell(0, col, c) } -func (v *tableView) addBodyCell(row, col int, field, delta string, color tcell.Color) { - c := tview.NewTableCell(deltas(delta, field)) +func (v *tableView) addBodyCell(row, col int, field, delta string, color tcell.Color, pads maxyPad) { + var pField string + if isASCII(field) { + pField = pad(deltas(delta, field), pads[col]) + } else { + pField = deltas(delta, field) + } + + c := tview.NewTableCell(pField) { - c.SetExpansion(3) - if len(v.GetCell(0, col).Text) == 0 { - c.SetExpansion(1) - } + c.SetExpansion(1) c.SetTextColor(color) } v.SetCell(row, col, c) From 517de9dcb3f519614fc2670ae613aa32de6283aa Mon Sep 17 00:00:00 2001 From: derailed Date: Thu, 28 Mar 2019 14:37:12 -0600 Subject: [PATCH 5/7] cleaning up --- internal/views/table.go | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/internal/views/table.go b/internal/views/table.go index 966f6b59..8aac9198 100644 --- a/internal/views/table.go +++ b/internal/views/table.go @@ -351,7 +351,6 @@ func (v *tableView) sortAllRows(rows resource.RowEvents, sortFn sortFn) (resourc sec := make(map[string]resource.Row, len(rows)) for _, k := range keys { grp := rows[k].Fields[v.sortCol.index] - log.Debug().Msg("append") sec[grp] = append(sec[grp], k) } @@ -361,8 +360,7 @@ func (v *tableView) sortAllRows(rows resource.RowEvents, sortFn sortFn) (resourc sort.Strings(v) prim = append(prim, k) } - rs := groupSorter{prim, v.sortCol.asc} - sort.Sort(rs) + sort.Sort(groupSorter{prim, v.sortCol.asc}) return prim, sec } From 5d399bfd8a3a76f16c20a588064fbe3584f4b49e Mon Sep 17 00:00:00 2001 From: derailed Date: Thu, 28 Mar 2019 14:41:04 -0600 Subject: [PATCH 6/7] release notes --- .../{release_0.3.3.md => release_0.4.0.md} | 32 ++++++++++--------- 1 file changed, 17 insertions(+), 15 deletions(-) rename change_logs/{release_0.3.3.md => release_0.4.0.md} (59%) diff --git a/change_logs/release_0.3.3.md b/change_logs/release_0.4.0.md similarity index 59% rename from change_logs/release_0.3.3.md rename to change_logs/release_0.4.0.md index d331af0d..0421f120 100644 --- a/change_logs/release_0.3.3.md +++ b/change_logs/release_0.4.0.md @@ -14,27 +14,29 @@ Thank you so much for your support and awesome suggestions to make K9s better!! ## Change Logs +> NOTE! Lots of changes here, please report any disturbances in the force. Thank you! +> NOTE!: This feature is very much an alpha feature right now. +> I find it really powerful and useful, hopefully I am not the only to be hunanimous on that?? + 1. [Feature #82](https://github.com/derailed/k9s/issues/82) 1. Added ability to view RBAC policies while in clusterrole or role view. - 1. 😃 The RBAC view will auto-refresh just like any K9s views hence showing live RBAC updates! - 1. RBAC view supports standard K8s verbs ie get,list,deletecollection,watch,create,patch,update,delete. - 1. Any verbs not in this standard K8s verb list, will end up in the EXTRAS column. - 1. For non resource URLS, we map standard REST verbs to K8s verbs ie post=create patch=update, etc.. - 1. Added initial sorts by name and group while in RBAC view. - 1. Usage: To activate, enter command mode via `:cr` or `:ro` for clusterole(cr)/role(ro), select a row and press `` - 1. To bail out of the view and return to previous use `p` or `` -1. One feature that was mentioned in the comments for the RBAC feature above Tx [faheem-cliqz](https://github.com/faheem-cliqz)! was the ability to check RBAC rules for a given user. Namely reverse RBAC lookup + 2. The RBAC view will auto-refresh just like any K9s views hence showing live RBAC updates + 3. RBAC view supports standard K8s verbs ie get,list,deletecollection,watch,create,patch,update,delete. + 4. Any verbs not in this standard K8s verb list, will end up in the EXTRAS column. + 5. For non resource URLS, we map standard REST verbs to K8s verbs ie post=create patch=update, etc.. + 6. Added initial sorts by name and group while in RBAC view. + 7. Usage: To activate, enter command mode via `:cr` or `:ro` for clusterole(cr)/role(ro), select a row and press `` + 8. To bail out of the view and return to previous use `p` or `` +2. One feature that was mentioned in the comments for the RBAC feature above Tx [faheem-cliqz](https://github.com/faheem-cliqz)! was the ability to check RBAC rules for a given user. Namely reverse RBAC lookup 1. Added a new view, code name *Fu* view to show all the clusterroles/roles associated with a given user. - 1. The view also supports for checking RBAC Fu for a user, a group or an app via a serviceaccount. - 1. To activate: Enter command mode via `:fu` followed by u|g|s:subject + ``. + 2. The view also supports for checking RBAC Fu for a user, a group or an app via a serviceaccount. + 3. To activate: Enter command mode via `:fu` followed by u|g|s:subject + ``. For example: To view user *fred* Fu enter `:fu u:fred` + `` will show all clusterroles/roles and verbs associated with the user *fred* - 1. For group Fu lookup, use the same command as above and substitute `u:fred` with `g:fred` - 1. For ServiceAccount *fred* Fu check: use `s:fred` -1. Try to eliminate a tad of a jitter while scrolling on most views. Please report back if that's not the case + 4. For group Fu lookup, use the same command as above and substitute `u:fred` with `g:fred` + 5. For ServiceAccount *fred* Fu check: use `s:fred` +3. Eliminated jitter while scrolling tables -> NOTE!: This feature is very much an alpha feature right now. -> I find it really powerful and useful, hopefully I am not the only one hunanimous on that?? --- From cf696c9259bfe7f05b696af4d6e5749210f203bd Mon Sep 17 00:00:00 2001 From: derailed Date: Thu, 28 Mar 2019 14:44:18 -0600 Subject: [PATCH 7/7] added snap badge --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 7dde2c6e..665a8c6a 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ for changes and offers subsequent commands to interact with observed resources. [![Go Report Card](https://goreportcard.com/badge/github.com/derailed/k9s?)](https://goreportcard.com/report/github.com/derailed/k9s) [![Build Status](https://travis-ci.com/derailed/k9s.svg?branch=master)](https://travis-ci.com/derailed/k9s) [![release](https://img.shields.io/github/release-pre/derailed/k9s.svg)](https://github.com/derailed/k9s/releases) +[![Get it from the Snap Store](https://snapcraft.io/static/images/badges/en/snap-store-black.svg)](https://snapcraft.io/k9s) ---