added RBAC support

mine
derailed 2019-03-28 14:47:40 -06:00
commit 0a19c0f7e7
37 changed files with 1521 additions and 346 deletions

View File

@ -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: arm32
arm64: arm64
bit: Arm
bitv6: Arm6
bitv7: Arm7
386: i386
amd64: x86_64
checksum:
@ -63,6 +61,11 @@ brew:
# Snapcraft
snapcraft:
name: k9s
summary: K9s is a CLI to view and manage your Kubernetes clusters.
description: |
K9s is a CLI to view and manage your Kubernetes clusters.
By leveraging a terminal UI, you can easily traverse Kubernetes resources
and view the state of you clusters in a single powerful session.
name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
publish: true
replacements:
@ -70,11 +73,9 @@ snapcraft:
386: 32-bit
darwin: macOS
linux: Tux
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.
bit: Arm
bitv6: Arm6
bitv7: Arm7
grade: stable
confinement: strict
apps:

View File

@ -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)
---

View File

@ -0,0 +1,45 @@
# 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
> 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.
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 `<enter>`
8. To bail out of the view and return to previous use `p` or `<esc>`
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.
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 + `<enter>`.
For example: To view user *fred* Fu enter `:fu u:fred` + `<enter>` will show all clusterroles/roles and verbs associated
with the user *fred*
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
---
## Resolved Bugs
+ None

View File

@ -72,7 +72,7 @@ func (r *ClusterRole) Fields(ns string) Row {
i := r.instance
return append(ff,
Pad(i.Name, 70),
i.Name,
toAge(i.ObjectMeta.CreationTimestamp),
)
}

View File

@ -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)
}
}

View File

@ -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)
}

View File

@ -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, "fred", r[0])
}
func TestCRFieldsAllNS(t *testing.T) {
r := newClusterRole().Fields(resource.AllNamespaces)
assert.Equal(t, "fred"+strings.Repeat(" ", 66), r[0])
assert.Equal(t, "fred", 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{"fred"}, row.Fields[:1])
}
// Helpers...

View File

@ -90,13 +90,6 @@ func toAge(timestamp metav1.Time) string {
return duration.HumanDuration(time.Since(timestamp.Time))
}
// 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))

View File

@ -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

View File

@ -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
}

View File

@ -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",
@ -211,7 +210,7 @@ func (r *Pod) Fields(ns string) Row {
cr, _, rc := r.statuses(ss)
return append(ff,
Pad(i.ObjectMeta.Name, podNameSize),
i.ObjectMeta.Name,
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,17 @@ 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 {

View File

@ -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, "fred", r[0])
}
func TestPodMarshal(t *testing.T) {

View File

@ -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)
}
}

View File

@ -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{},
"<n/a>",
"",
},
}
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)
}
}

View File

@ -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)
}

View File

@ -75,13 +75,13 @@ func (*ReplicaSet) Header(ns string) Row {
// Fields retrieves displayable fields.
func (r *ReplicaSet) Fields(ns string) Row {
i := r.instance
ff := make(Row, 0, len(r.Header(ns)))
if ns == AllNamespaces {
ff = append(ff, r.instance.Namespace)
ff = append(ff, i.Namespace)
}
i := r.instance
return append(ff,
i.Name,
strconv.Itoa(int(*i.Spec.Replicas)),

View File

@ -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
}
@ -138,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,

View File

@ -20,6 +20,7 @@ type (
igniter interface {
tview.Primitive
getTitle() string
init(ctx context.Context, ns string)
}
@ -30,6 +31,10 @@ type (
resourceViewer interface {
igniter
setEnterFn(enterFn)
setColorerFn(colorerFn)
setDecorateFn(decorateFn)
}
appView struct {
@ -90,6 +95,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 +163,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
@ -263,7 +275,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)

View File

@ -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
}

View File

@ -2,6 +2,7 @@ package views
import (
"fmt"
"regexp"
"github.com/derailed/k9s/internal/k8s"
"github.com/derailed/k9s/internal/resource"
@ -36,32 +37,50 @@ 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 igniter
switch cmd {
case "q", "quit":
var v resourceViewer
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
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()))
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)
c.exec(cmd, v)
return true
@ -74,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
}

View File

@ -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

View File

@ -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
}

324
internal/views/fu.go Normal file
View File

@ -0,0 +1,324 @@
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) {
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]
}
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(ns, res, grp, 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"
}
}

View File

@ -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()
}

View File

@ -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
}
@ -46,6 +50,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 +81,7 @@ func numerical(s string) (int, bool) {
func delta(s string) string {
return suffix(s, "𝜟")
}
func plus(s string) string {
return suffix(s, "+")
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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")

51
internal/views/padding.go Normal file
View File

@ -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))
}

View File

@ -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)
}
}

View File

@ -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
}

379
internal/views/rbac.go Normal file
View File

@ -0,0 +1,379 @@
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.colorerFn = rbacColorer
v.current = app.content.GetPrimitive("main").(igniter)
v.bindKeys()
}
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
go func(ctx context.Context) {
for {
select {
case <-ctx.Done():
log.Debug().Msg("RBAC 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 *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 {
title = "Role"
}
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 {
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.prevCmd(evt)
}
return nil
}
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 (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 (v *rbacView) parseRules(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[n] = &resource.RowEvent{
Fields: prepRow(n, grp, r.Verbs),
}
}
m[k] = &resource.RowEvent{
Fields: prepRow(k, grp, r.Verbs),
}
}
}
for _, nres := range r.NonResourceURLs {
if nres[0] != '/' {
nres = "/" + nres
}
m[nres] = &resource.RowEvent{
Fields: prepRow(nres, resource.NAValue, r.Verbs),
}
}
}
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(res, grp, asVerbs(verbs...))
}
func makeRow(res, group string, verbs []string) resource.Row {
r := make(resource.Row, 0, len(rbacHeader))
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, toVerbIcon(hasVerb(verbs, v)))
}
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
}
func toGroup(g string) string {
if g == "" {
return "v1"
}
return g
}

115
internal/views/rbac_test.go Normal file
View File

@ -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{"*.*", "*", ok, ok, ok, ok, ok, ok, ok, ok, ""},
},
},
{
[]rbacv1.PolicyRule{
{APIGroups: []string{"*"}, Resources: []string{"*"}, Verbs: []string{"get"}},
},
map[string]resource.Row{
"*.*": resource.Row{"*.*", "*", ok, nok, nok, nok, nok, nok, nok, nok, ""},
},
},
{
[]rbacv1.PolicyRule{
{APIGroups: []string{""}, Resources: []string{"*"}, Verbs: []string{"list"}},
},
map[string]resource.Row{
"*": resource.Row{"*", "v1", 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{"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, ""},
},
},
{
[]rbacv1.PolicyRule{
{APIGroups: []string{}, Resources: []string{}, Verbs: []string{"get"}, NonResourceURLs: []string{"/fred"}},
},
map[string]resource.Row{
"/fred": resource.Row{"/fred", "<n/a>", 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{"/fred", "<n/a>", 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)
}
}
}

View File

@ -7,18 +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
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
colorerFn colorerFn
title string
api string
viewFn viewFn
listFn listFn
listMxFn listMxFn
enterFn enterFn
colorerFn colorerFn
decorateFn decorateFn
}
)
@ -72,42 +76,48 @@ func allCRDs(c k8s.Connection) map[string]k8s.APIGroup {
return m
}
func showRBAC(app *appView, ns, resource, selection string) {
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": {
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,
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",
@ -131,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",
@ -145,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",
@ -208,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",
@ -222,11 +227,11 @@ func resourceViews() map[string]resCmd {
colorerFn: rsColorer,
},
"ro": {
title: "Roles",
api: "rbac.authorization.k8s.io",
viewFn: newResourceView,
listFn: resource.NewRoleList,
colorerFn: defaultColorer,
title: "Roles",
api: "rbac.authorization.k8s.io",
viewFn: newResourceView,
listFn: resource.NewRoleList,
enterFn: showRBAC,
},
"rs": {
title: "ReplicaSets",
@ -236,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",
@ -257,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,
},
}
}

View File

@ -42,13 +42,15 @@ type (
selectedNS string
update sync.Mutex
list resource.List
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,
@ -59,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)
@ -79,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 {
@ -106,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()
@ -124,9 +127,30 @@ 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...
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()
@ -262,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)
@ -359,6 +383,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 +396,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)

View File

@ -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::-] "
)
@ -55,6 +55,7 @@ func newTableView(app *appView, title string) *tableView {
v.actions = make(keyActions)
v.SetFixed(1, 0)
v.SetBorder(true)
v.SetFixed(1, 0)
v.SetBorderColor(tcell.ColorDodgerBlue)
v.SetBorderAttributes(tcell.AttrBold)
v.SetBorderPadding(0, 0, 1, 1)
@ -64,8 +65,13 @@ func newTableView(app *appView, title string) *tableView {
v.SetSelectable(true, false)
v.SetSelectedStyle(tcell.ColorBlack, tcell.ColorAqua, tcell.AttrBold)
v.SetInputCapture(v.keyboard)
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)
@ -77,12 +83,10 @@ func newTableView(app *appView, title string) *tableView {
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[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)
return &v
}
func (v *tableView) clearSelection() {
@ -107,6 +111,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
}
@ -118,17 +123,20 @@ func (v *tableView) setSelection() {
func (v *tableView) pageUpCmd(evt *tcell.EventKey) *tcell.EventKey {
v.PageUp()
return nil
}
func (v *tableView) pageDownCmd(evt *tcell.EventKey) *tcell.EventKey {
v.PageDown()
return nil
}
func (v *tableView) filterCmd(evt *tcell.EventKey) *tcell.EventKey {
v.cmdBuff.setActive(false)
v.refresh()
return nil
}
@ -136,6 +144,7 @@ func (v *tableView) eraseCmd(evt *tcell.EventKey) *tcell.EventKey {
if v.cmdBuff.isActive() {
v.cmdBuff.del()
}
return nil
}
@ -145,6 +154,7 @@ func (v *tableView) resetCmd(evt *tcell.EventKey) *tcell.EventKey {
}
v.cmdBuff.reset()
v.refresh()
return nil
}
@ -172,12 +182,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
}
@ -189,6 +201,7 @@ func (v *tableView) activateCmd(evt *tcell.EventKey) *tcell.EventKey {
v.app.flash(flashInfo, "Filtering...")
v.cmdBuff.reset()
v.cmdBuff.setActive(true)
return nil
}
@ -201,7 +214,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
}
@ -221,6 +234,7 @@ func (v *tableView) hints() hints {
if v.actions != nil {
return v.actions.toHints()
}
return nil
}
@ -266,10 +280,11 @@ func (v *tableView) filtered() resource.TableData {
filtered.Rows[k] = row
}
}
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
}
@ -278,13 +293,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.actions[KeyShiftS] = newKeyAction("Sort Namespace", v.sortNamespaceCmd, true)
if v.currentNS == resource.AllNamespaces || v.currentNS == "*" {
v.actions[KeyShiftP] = newKeyAction("Sort Namespace", v.sortNamespaceCmd, true)
} else {
delete(v.actions, KeyShiftS)
}
@ -303,118 +318,94 @@ 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++
// for k := range data.Rows {
// log.Debug().Msgf("Keys: %s", k)
// }
keys := v.sortRows(data)
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)
groupKeys[grp] = s
} else {
groupKeys[grp] = []string{k}
}
sortFn := v.defaultSort
if v.sortFn != nil {
sortFn = v.sortFn
}
// 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]
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)
}
sort.Sort(groupSorter{prim, v.sortCol.asc})
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)
}
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 {
@ -448,8 +439,7 @@ func (v *tableView) resetTitle() {
// ----------------------------------------------------------------------------
// Event listeners...
func (v *tableView) changed(s string) {
}
func (v *tableView) changed(s string) {}
func (v *tableView) active(b bool) {
if b {

View File

@ -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)
}
}