diff --git a/.gitignore b/.gitignore index 80b1e5a9..25be190a 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,4 @@ demos kind *.snap /stresser +__debug_bin* \ No newline at end of file diff --git a/Makefile b/Makefile index 63f85e86..ae69cd8b 100644 --- a/Makefile +++ b/Makefile @@ -11,7 +11,7 @@ DATE ?= $(shell TZ=UTC date -j -f "%s" ${SOURCE_DATE_EPOCH} +"%Y-%m-%dT%H: else DATE ?= $(shell date -u -d @${SOURCE_DATE_EPOCH} +"%Y-%m-%dT%H:%M:%SZ") endif -VERSION ?= v0.32.7 +VERSION ?= v0.40.0 IMG_NAME := derailed/k9s IMAGE := ${IMG_NAME}:${VERSION} diff --git a/README.md b/README.md index af360d9f..7505bea0 100644 --- a/README.md +++ b/README.md @@ -627,7 +627,25 @@ The annotation value must specify a container to forward to as well as a local p You can change which columns shows up for a given resource via custom views. To surface this feature, you will need to create a new configuration file, namely `$XDG_CONFIG_HOME/k9s/views.yaml`. This file leverages GVR (Group/Version/Resource) to configure the associated table view columns. If no GVR is found for a view the default rendering will take over (ie what we have now). Going wide will add all the remaining columns that are available on the given resource after your custom columns. To boot, you can edit your views config file and tune your resources views live! -> NOTE: This is experimental and will most likely change as we iron this out! +πŸ“’ πŸŽ‰ As of `release v0.40.0` you can specify json parse expressions to further customize your resources rendering. + +The new column syntax is as follows: + +> COLUMN_NAME<:json_parse_expression><|column_attributes> + +Where `:json_parse_expression` represents an expression to pull a specific snippet out of the resource manifest. +Similar to `kubectl -o custom-columns` command. This expression is optional. + +Additionally, you can specify column attributes to further tailor the column rendering. +To use this you will need to add a `|` indicator followed by your rendering bits. +You can have one or more of the following attributes: + +* `T` -> time column indicator +* `N` -> number column indicator +* `W` -> turns on wide column aka only shows while in wide mode. Defaults to the standard resource definition when present. +* `H` -> Hides the column +* `L` -> Left align (default) +* `R` -> Right align Here is a sample views configuration that customize a pods and services views. @@ -637,7 +655,9 @@ views: v1/pods: columns: - AGE - - NAMESPACE + - NAMESPACE|WR # => 🌚 Specifies the NAMESPACE column to be right aligned and only visible while in wide mode + - ZORG:.metadata.labels.fred\.io\.kubernetes\.blee # => 🌚 extract fred.io.kubernetes.blee label into it's own column + - BLEE:.metadata.annotations.blee|R # => 🌚 extract annotation blee into it's own column and right align it - NAME - IP - NODE @@ -652,6 +672,8 @@ views: - CLUSTER-IP ``` +> 🩻 NOTE: This is experimental and will most likely change as we iron this out! + --- ## Plugins diff --git a/change_logs/release_v0.40.md b/change_logs/release_v0.40.md new file mode 100644 index 00000000..d03c27f5 --- /dev/null +++ b/change_logs/release_v0.40.md @@ -0,0 +1,177 @@ + + +# Release v0.40.0 + +## Notes + +Thank you to all that contributed with flushing out issues and enhancements for K9s! +I'll try to mark some of these issues as fixed. But if you don't mind grab the latest rev +and see if we're happier with some of the fixes! +If you've filed an issue please help me verify and close. + +Your support, kindness and awesome suggestions to make K9s better are, as ever, very much noted and appreciated! +Also big thanks to all that have allocated their own time to help others on both slack and on this repo!! + +As you may know, K9s is not pimped out by corps with deep pockets, thus if you feel K9s is helping your Kubernetes journey, +please consider joining our [sponsorship program](https://github.com/sponsors/derailed) and/or make some noise on social! [@kitesurfer](https://twitter.com/kitesurfer) + +On Slack? Please join us [K9slackers](https://join.slack.com/t/k9sers/shared_invite/enQtOTA5MDEyNzI5MTU0LWQ1ZGI3MzliYzZhZWEyNzYxYzA3NjE0YTk1YmFmNzViZjIyNzhkZGI0MmJjYzhlNjdlMGJhYzE2ZGU1NjkyNTM) + +--- + +## β™« Sounds Behind The Release β™­ + +* [Glory Box - Portishead](https://www.youtube.com/watch?v=4qQyUi4zfDs) +* [Hit Me With Your Rhythm Stick - Ian Dury And The BlockHeads](https://www.youtube.com/watch?v=0WGVgfjnLqc) +* [Cupidon s'en fout! - George Brassens](https://www.youtube.com/watch?v=a-RlZLfIeKM) +* [Shipbuilding - Elvis Costello](https://www.youtube.com/watch?v=dVhjRqBM5uw) +* [Low Sun - Hermanos Gutierrez](https://www.youtube.com/watch?v=ubaJbw7hkeQ) + +--- + +## A Word From Our Sponsors... + +To all the good folks below that opted to `pay it forward` and join our sponsorship program, I salute you!! + +* [Panfactum](https://github.com/Panfactum) +* [Bastian PΓ€tzold](https://github.com/bastianpaetzold) +* [Mikita Vazhnik](https://github.com/Vazhnik) +* [Jacob Salway](https://github.com/jacobsalway) +* [Eckard MΓΌhlich](https://github.com/eckardnet) +* [Luke](https://github.com/lukepatrick) +* [tomasbanet](https://github.com/tomasbanet) +* [Robin Opletal](https://github.com/fourstepper) +* [Euroblaze](https://github.com/euroblaze) +* [Jack Daniels](https://github.com/dkr91) +* [decafcode](https://github.com/decafcode) +* [Guillaume Copin](https://github.com/GuillaumeCo) +* [Lokalise](https://github.com/lokalise) +* [Gustavo Bini](https://github.com/gustavobini) +* [JMSwag](https://github.com/JMSwag) +* [Daniel Gospodinow](https://github.com/danielgospodinow) +* [Klaviyo](https://github.com/klaviyo) +* [Paul Farver](https://github.com/PaulFarver) + +> Sponsorship cancellations since the last release: **12!** πŸ₯Ή + +## πŸŽ‰ Feature Release code name: Colon Blow! 🎈 + +We are pretty stocked about this drop (hopefully...) as we've fully enabled custom columns support in K9s! +Historically, one could customize the view for a given resource by adding a definition in `views.yaml`. +From there one could change sort order and re-arrange the standard column layout. +Several folks voiced the need to add a column for a given label/annotation or any other fields available on a resource. +To date, this wasn't possible 😳 + +So... without further ado, let see what we can now do with `Custom Views` ding dang deal! +It all starts with a few new directives available in `views.yaml` + +### A Refresher... + +Customize a pod view and ensure age, ns and name appear first and sort by age descending. + +> NOTE! You no longer need to list out all columns. +> The remaining columns will be automatically filled from the standard columns. + +```yaml +# Usual biz... +views: + v1/pods: # specify the gvr you want to customize aka group/version/resource + sortColumn: AGE:desc # set the default ordering to ascending (asc) or descending (desc) + columns: # tell the view which columns to display and in which order + - AGE # ensure age, ns and name are the first 3 cols and backfill the rest + - NAMESPACE + - NAME + - READY|H # => NEW! Do not display the READY column + - NODE|W # => NEW! Show node column only on wide + - IP|WR # => NEW! Pull the ip column and right align it in wide mode only +``` + +## Colon Blow! + +Say your pods comes standard with a label `blee` and you want to show it while in pod view. + +```yaml +# Pull labels/annotations +views: + v3/freds: + sortColumn: NAMESPACE:dsc + columns: + - NAMESPACE + - NAME + - BLEE:.metadata.labels.blee # => NEW! Pull values from a label or an annotation using json parser + # expression similar mechanic as kubectl -o custom-columns + - ZORG:.spec.zips[?(@.type == 'zorg')].ip|WR # => NEW! Same deal with a json exp + but align right and show wide only +``` + +## TLDR... + +As you can see the CustomView feature adds a few new semantics on this drop. + +You can now use the following shape for columns definition `COL_NAME<:json_parse_expression><|column attributes>` + +The `:json_parse_expression` is optional. + +The column attributes are as follows: + +* `T` -> time column indicator +* `N` -> number column indicator +* `W` -> turns on wide column aka only shows while in wide mode. Defaults to the standard resource definition when present. +* `H` -> Hides the column +* `L` -> Left align (default) +* `R` -> Right align + +When certain columns are not present in the custom view, K9s will pull the standard column definition and merge the columns. +This allows user to specify and order which columns they want to see first without having to define every single columns from the default resource representation. If you do not wish to see all these columns you can add them to your custom view definition and either specify `|W` or `|H` to `wide` it or `hide` it. + +> πŸ“’ Still work in progress so your mileage may vary! +> This feature will likely need additional TLC. +> Your feedback on this will be much appreciated and we will iterate as usual to ensure it vorks as prescribed... πŸ™€ + +--- + +## Videos Are In The Can! + +Please dial [K9s Channel](https://www.youtube.com/channel/UC897uwPygni4QIjkPCpgjmw) for up coming content... + +* [K9s v0.40.0 Colon Blow Sneak peek](https://youtu.be/iy6RDozAM4A) +* [K9s v0.31.0 Configs+Sneak peek](https://youtu.be/X3444KfjguE) +* [K9s v0.30.0 Sneak peek](https://youtu.be/mVBc1XneRJ4) +* [Vulnerability Scans](https://youtu.be/ULkl0MsaidU) + +--- + +## Resolved Issues + +* [#3064](https://github.com/derailed/k9s/issues/3064) Question: brew formula k9s vs derailed/k9s/k9s +* [#3061](https://github.com/derailed/k9s/issues/3061) k9s not opening active namespace or namespace specified via -n +* [#3044](https://github.com/derailed/k9s/issues/3044) CRDs are loaded incorrectly into metadata registry, cause sporadic "Jump Owner" issues +* [#2995](https://github.com/derailed/k9s/issues/2995) Latest image on quay.io contains "failed" kubectl binary + +--- + +## Contributed PRs + +Please be sure to give `Big Thanks!` and `ATTA Girls/Boys!` to all the fine contributors for making K9s better for all of us!! + +* [#3065](https://github.com/derailed/k9s/pull/3065) Fixed trimming of favorite namespaces in Config +* [#3063](https://github.com/derailed/k9s/pull/3063) Updating CVE dependencies +* [#3062](https://github.com/derailed/k9s/pull/3062) feat: use kubectl events for plugin watch-events +* [#3060](https://github.com/derailed/k9s/pull/3060) Rename "delete local data" checkbox description in drain dialog +* [#3046](https://github.com/derailed/k9s/pull/3046) Strict unmarshal for plugin files +* [#3045](https://github.com/derailed/k9s/pull/3045) fix: CRD loading: trim group suffix from CRD name +* [#3043](https://github.com/derailed/k9s/pull/3043) Fix K9S_EDITOR +* [#3041](https://github.com/derailed/k9s/pull/3041) Fix Flux trace plugin command +* [#3038](https://github.com/derailed/k9s/pull/2038) fix check e != nil but return a nil value error err +* [#3026](https://github.com/derailed/k9s/pull/3026) Fix typos +* [#3018](https://github.com/derailed/k9s/pull/3018) fix: coloring of rose-pine for values of log options +* [#3017](https://github.com/derailed/k9s/pull/3017) feat: add helm diff plugin +* [#3009](https://github.com/derailed/k9s/pull/3009) fix(argo-rollouts plugin): resolve improper piping in watch command +* [#2996](https://github.com/derailed/k9s/pull/2996) Bump version of netshoot image in debug-container plugin +* [#2994](https://github.com/derailed/k9s/pull/2994) fix kubectl url and fail build on download errors +* [#2986](https://github.com/derailed/k9s/pull/2986) plugin/trace-dns: Trace DNS requests using Inspektor Gadget +* [#2985](https://github.com/derailed/k9s/pull/2985) feat(plugins/crossplane): change to crossplane cli & add crossplane-watch +* [#2986](https://github.com/derailed/k9s/pull/2986) plugin/trace-dns: Trace DNS requests using Inspektor Gadget + +--- + + Β© 2024 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) \ No newline at end of file diff --git a/internal/config/data/helpers.go b/internal/config/data/helpers.go index de2d7c7c..bff321a0 100644 --- a/internal/config/data/helpers.go +++ b/internal/config/data/helpers.go @@ -45,16 +45,6 @@ func defaultFGNodeShell() bool { return false } -// InList check if string is in a collection of strings. -func InList(ll []string, n string) bool { - for _, l := range ll { - if l == n { - return true - } - } - return false -} - // EnsureDirPath ensures a directory exist from the given path. func EnsureDirPath(path string, mod os.FileMode) error { return EnsureFullPath(filepath.Dir(path), mod) diff --git a/internal/config/data/helpers_test.go b/internal/config/data/helpers_test.go index db95f28a..1c6db37e 100644 --- a/internal/config/data/helpers_test.go +++ b/internal/config/data/helpers_test.go @@ -6,6 +6,7 @@ package data_test import ( "os" "path/filepath" + "slices" "testing" "github.com/derailed/k9s/internal/config/data" @@ -57,7 +58,7 @@ func TestHelperInList(t *testing.T) { } for _, u := range uu { - assert.Equal(t, u.expected, data.InList(u.list, u.item)) + assert.Equal(t, u.expected, slices.Contains(u.list, u.item)) } } diff --git a/internal/config/data/ns.go b/internal/config/data/ns.go index 8702e88c..9b0d5ebf 100644 --- a/internal/config/data/ns.go +++ b/internal/config/data/ns.go @@ -4,6 +4,7 @@ package data import ( + "slices" "sync" "github.com/derailed/k9s/internal/client" @@ -47,7 +48,7 @@ func (n *Namespace) merge(old *Namespace) { return } for _, fav := range old.Favorites { - if InList(n.Favorites, fav) { + if slices.Contains(n.Favorites, fav) { continue } n.Favorites = append(n.Favorites, fav) @@ -100,7 +101,7 @@ func (n *Namespace) isAllNamespaces() bool { } func (n *Namespace) addFavNS(ns string) { - if InList(n.Favorites, ns) { + if slices.Contains(n.Favorites, ns) { return } diff --git a/internal/config/helpers.go b/internal/config/helpers.go index 53627cfa..165885d2 100644 --- a/internal/config/helpers.go +++ b/internal/config/helpers.go @@ -8,9 +8,7 @@ import ( "os/user" "path/filepath" - "github.com/derailed/k9s/internal/config/data" "github.com/rs/zerolog/log" - v1 "k8s.io/api/core/v1" ) // IsBoolSet checks if a bool ptr is set. @@ -44,17 +42,6 @@ func UserTmpDir() (string, error) { return dir, nil } -// InNSList check if ns is in an ns collection. -func InNSList(nn []interface{}, ns string) bool { - ss := make([]string, len(nn)) - for i, n := range nn { - if nsp, ok := n.(v1.Namespace); ok { - ss[i] = nsp.Name - } - } - return data.InList(ss, ns) -} - // MustK9sUser establishes current user identity or fail. func MustK9sUser() string { usr, err := user.Current() diff --git a/internal/config/helpers_test.go b/internal/config/helpers_test.go deleted file mode 100644 index 2c29e2d8..00000000 --- a/internal/config/helpers_test.go +++ /dev/null @@ -1,40 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright Authors of K9s - -package config_test - -import ( - "testing" - - "github.com/derailed/k9s/internal/config" - "github.com/stretchr/testify/assert" - v1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -func TestHelperInNSList(t *testing.T) { - uu := []struct { - item string - list []interface{} - expected bool - }{ - { - "fred", - []interface{}{ - v1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "fred"}}, - }, - true, - }, - { - "blee", - []interface{}{ - v1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "fred"}}, - }, - false, - }, - } - - for _, u := range uu { - assert.Equal(t, u.expected, config.InNSList(u.list, u.item)) - } -} diff --git a/internal/config/views.go b/internal/config/views.go index 41c6b9b5..86b1ef3c 100644 --- a/internal/config/views.go +++ b/internal/config/views.go @@ -14,14 +14,14 @@ import ( "github.com/derailed/k9s/internal/config/data" "github.com/derailed/k9s/internal/config/json" - + "github.com/rs/zerolog/log" "gopkg.in/yaml.v2" ) // ViewConfigListener represents a view config listener. type ViewConfigListener interface { // ViewSettingsChanged notifies listener the view configuration changed. - ViewSettingsChanged(ViewSetting) + ViewSettingsChanged(*ViewSetting) } // ViewSetting represents a view configuration. @@ -35,7 +35,7 @@ func (v *ViewSetting) HasCols() bool { } func (v *ViewSetting) IsBlank() bool { - return v == nil || len(v.Columns) == 0 + return v == nil || (len(v.Columns) == 0 && v.SortColumn == "") } func (v *ViewSetting) SortCol() (string, bool, error) { @@ -52,7 +52,7 @@ func (v *ViewSetting) SortCol() (string, bool, error) { func (v *ViewSetting) Equals(vs *ViewSetting) bool { if v == nil || vs == nil { - return v == nil && vs == nil + return false } if c := slices.Compare(v.Columns, vs.Columns); c != 0 { return false @@ -116,10 +116,11 @@ func (v *CustomView) RemoveListener(gvr string) { func (v *CustomView) fireConfigChanged() { for gvr, list := range v.listeners { - if view, ok := v.Views[gvr]; ok { - list.ViewSettingsChanged(view) + if vs, ok := v.Views[gvr]; ok { + log.Debug().Msgf("Reloading custom view settings for %s", gvr) + list.ViewSettingsChanged(&vs) } else { - list.ViewSettingsChanged(ViewSetting{}) + list.ViewSettingsChanged(nil) } } } diff --git a/internal/config/views_test.go b/internal/config/views_test.go index 0764d243..902eda50 100644 --- a/internal/config/views_test.go +++ b/internal/config/views_test.go @@ -23,7 +23,7 @@ func TestViewSetting_Equals(t *testing.T) { v1, v2 *config.ViewSetting equals bool }{ - {nil, nil, true}, + {nil, nil, false}, {&config.ViewSetting{}, nil, false}, {nil, &config.ViewSetting{}, false}, {&config.ViewSetting{}, &config.ViewSetting{}, true}, diff --git a/internal/dao/dynamic.go b/internal/dao/dynamic.go new file mode 100644 index 00000000..a1bd321f --- /dev/null +++ b/internal/dao/dynamic.go @@ -0,0 +1,145 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Authors of K9s +package dao + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/derailed/k9s/internal" + "github.com/derailed/k9s/internal/client" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + metav1beta1 "k8s.io/apimachinery/pkg/apis/meta/v1beta1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/rest" + cmdutil "k8s.io/kubectl/pkg/cmd/util" +) + +type Dynamic struct { + Generic +} + +func (d *Dynamic) Get(ctx context.Context, path string) (runtime.Object, error) { + return nil, errors.New("Not implemented") +} + +func (d *Dynamic) List(ctx context.Context, ns string) ([]runtime.Object, error) { + strLabel, _ := ctx.Value(internal.KeyLabels).(string) + + allNS := client.IsAllNamespaces(ns) + flags := cmdutil.NewMatchVersionFlags(d.getFactory().Client().Config().Flags()) + f := cmdutil.NewFactory(flags) + b := f.NewBuilder(). + Unstructured(). + NamespaceParam(ns).DefaultNamespace().AllNamespaces(allNS). + LabelSelectorParam(strLabel). + FieldSelectorParam(""). + RequestChunksOf(0). + ResourceTypeOrNameArgs(true, d.gvr.R()). + ContinueOnError(). + Latest(). + Flatten(). + TransformRequests(d.transformRequests). + Do() + if err := b.Err(); err != nil { + return nil, err + } + + infos, err := b.Infos() + if err != nil { + return nil, err + } + + oo := make([]runtime.Object, 0, len(infos)) + for _, info := range infos { + o, err := decodeIntoTable(info.Object, allNS) + if err != nil { + return nil, err + } + oo = append(oo, o.(*metav1.Table)) + } + + return oo, nil +} + +var recognizedTableVersions = map[schema.GroupVersionKind]bool{ + metav1beta1.SchemeGroupVersion.WithKind("Table"): true, + metav1.SchemeGroupVersion.WithKind("Table"): true, +} + +func decodeIntoTable(obj runtime.Object, allNs bool) (runtime.Object, error) { + event, isEvent := obj.(*metav1.WatchEvent) + if isEvent { + obj = event.Object.Object + } + + if !recognizedTableVersions[obj.GetObjectKind().GroupVersionKind()] { + return nil, fmt.Errorf("attempt to decode non-Table object: %v", obj.GetObjectKind().GroupVersionKind()) + } + + u, ok := obj.(*unstructured.Unstructured) + if !ok { + return nil, fmt.Errorf("attempt to decode non-Unstructured object") + } + var table metav1.Table + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(u.Object, &table); err != nil { + return nil, err + } + + if allNs { + defs := make([]metav1.TableColumnDefinition, 0, len(table.ColumnDefinitions)+1) + defs = append(defs, metav1.TableColumnDefinition{Name: "Namespace", Type: "string"}) + defs = append(defs, table.ColumnDefinitions...) + table.ColumnDefinitions = defs + } + + for i := range table.Rows { + row := &table.Rows[i] + if row.Object.Raw == nil || row.Object.Object != nil { + continue + } + converted, err := runtime.Decode(unstructured.UnstructuredJSONScheme, row.Object.Raw) + if err != nil { + return nil, err + } + row.Object.Object = converted + var m metav1.Object + if obj := row.Object.Object; obj != nil { + m, _ = meta.Accessor(obj) + } + var ns string + if m != nil { + ns = m.GetNamespace() + } + if allNs { + cells := make([]interface{}, 0, len(row.Cells)+1) + cells = append(cells, ns) + cells = append(cells, row.Cells...) + row.Cells = cells + } + } + + if isEvent { + event.Object.Object = &table + return event, nil + } + + return &table, nil +} + +func (d *Dynamic) transformRequests(req *rest.Request) { + req.SetHeader("Accept", strings.Join([]string{ + fmt.Sprintf("application/json;as=Table;v=%s;g=%s", metav1.SchemeGroupVersion.Version, metav1.GroupName), + fmt.Sprintf("application/json;as=Table;v=%s;g=%s", metav1beta1.SchemeGroupVersion.Version, metav1beta1.GroupName), + "application/json", + }, ",")) + + if d.includeObj { + req.Param("includeObject", "Object") + } +} diff --git a/internal/dao/non_resource.go b/internal/dao/non_resource.go index 0dc72086..b7b1e6f3 100644 --- a/internal/dao/non_resource.go +++ b/internal/dao/non_resource.go @@ -16,8 +16,9 @@ import ( type NonResource struct { Factory - gvr client.GVR - mx sync.RWMutex + gvr client.GVR + mx sync.RWMutex + includeObj bool } // Init initializes the resource. @@ -29,6 +30,10 @@ func (n *NonResource) Init(f Factory, gvr client.GVR) { n.mx.Unlock() } +func (n *NonResource) SetIncludeObject(f bool) { + n.includeObj = f +} + func (n *NonResource) gvrStr() string { n.mx.RLock() defer n.mx.RUnlock() diff --git a/internal/dao/registry.go b/internal/dao/registry.go index d21a84f2..d7ac54ad 100644 --- a/internal/dao/registry.go +++ b/internal/dao/registry.go @@ -89,8 +89,6 @@ func AccessorFor(f Factory, gvr client.GVR) (Accessor, error) { client.NewGVR("helm"): &HelmChart{}, client.NewGVR("helm-history"): &HelmHistory{}, client.NewGVR("apiextensions.k8s.io/v1/customresourcedefinitions"): &CustomResourceDefinition{}, - // !!BOZO!! Popeye - //client.NewGVR("popeye"): &Popeye{}, } r, ok := m[gvr] @@ -139,16 +137,6 @@ func (m *Meta) GVK2GVR(gv schema.GroupVersion, kind string) (client.GVR, bool, b return client.NoGVR, false, false } -// IsCRD checks if resource represents a CRD -func IsCRD(r metav1.APIResource) bool { - for _, c := range r.Categories { - if c == crdCat { - return true - } - } - return false -} - // MetaFor returns a resource metadata for a given gvr. func (m *Meta) MetaFor(gvr client.GVR) (metav1.APIResource, error) { m.mx.RLock() @@ -161,6 +149,16 @@ func (m *Meta) MetaFor(gvr client.GVR) (metav1.APIResource, error) { return meta, nil } +// IsCRD checks if resource represents a CRD +func IsCRD(r metav1.APIResource) bool { + for _, c := range r.Categories { + if c == crdCat { + return true + } + } + return false +} + // IsK8sMeta checks for non resource meta. func IsK8sMeta(m metav1.APIResource) bool { for _, c := range m.Categories { diff --git a/internal/dao/types.go b/internal/dao/types.go index 8afa480f..eaf9f6db 100644 --- a/internal/dao/types.go +++ b/internal/dao/types.go @@ -79,6 +79,9 @@ type Accessor interface { // GVR returns a gvr a string. GVR() string + + // SetIncludeObject toggles object inclusion. + SetIncludeObject(bool) } // DrainOptions tracks drain attributes. diff --git a/internal/model/helpers.go b/internal/model/helpers.go index 21731494..cb657997 100644 --- a/internal/model/helpers.go +++ b/internal/model/helpers.go @@ -5,14 +5,45 @@ package model import ( "context" + "fmt" "regexp" "time" "github.com/cenkalti/backoff/v4" + "github.com/derailed/k9s/internal" + "github.com/derailed/k9s/internal/client" + "github.com/derailed/k9s/internal/dao" + "github.com/derailed/k9s/internal/render" "github.com/sahilm/fuzzy" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) +func getMeta(ctx context.Context, gvr client.GVR) (ResourceMeta, error) { + meta := resourceMeta(gvr) + factory, ok := ctx.Value(internal.KeyFactory).(dao.Factory) + if !ok { + return ResourceMeta{}, fmt.Errorf("expected Factory in context but got %T", ctx.Value(internal.KeyFactory)) + } + meta.DAO.Init(factory, gvr) + + return meta, nil +} + +func resourceMeta(gvr client.GVR) ResourceMeta { + meta, ok := Registry[gvr.String()] + if !ok { + meta = ResourceMeta{ + DAO: new(dao.Dynamic), + Renderer: new(render.Table), + } + } + if meta.DAO == nil { + meta.DAO = &dao.Resource{} + } + + return meta +} + // MetaFQN returns a fully qualified resource name. func MetaFQN(m metav1.ObjectMeta) string { return FQN(m.Namespace, m.Name) diff --git a/internal/model/pulse_health.go b/internal/model/pulse_health.go index 0f1891bb..d2be69c9 100644 --- a/internal/model/pulse_health.go +++ b/internal/model/pulse_health.go @@ -105,7 +105,7 @@ func (h *PulseHealth) check(ctx context.Context, ns, gvr string) (*health.Check, if !ok { meta = ResourceMeta{ DAO: &dao.Table{}, - Renderer: &render.Generic{}, + Renderer: &render.Table{}, } } if meta.DAO == nil { diff --git a/internal/model/registry.go b/internal/model/registry.go index e2129c3b..ab4bb774 100644 --- a/internal/model/registry.go +++ b/internal/model/registry.go @@ -82,23 +82,14 @@ var Registry = map[string]ResourceMeta{ DAO: &dao.Alias{}, Renderer: &render.Alias{}, }, - // !!BOZO!! Popeye - //"popeye": { - // DAO: &dao.Popeye{}, - // Renderer: &render.Popeye{}, - //}, - //"sanitizer": { - // DAO: &dao.Popeye{}, - // TreeRenderer: &xray.Section{}, - //}, // Core... - "v1/endpoints": { - Renderer: &render.Endpoints{}, - }, + // "v1/endpoints": { + // Renderer: &render.Endpoints{}, + // }, "v1/pods": { DAO: &dao.Pod{}, - Renderer: &render.Pod{}, + Renderer: render.NewPod(), TreeRenderer: &xray.Pod{}, }, "v1/namespaces": { diff --git a/internal/model/table.go b/internal/model/table.go index be1157b5..ab135c24 100644 --- a/internal/model/table.go +++ b/internal/model/table.go @@ -13,6 +13,7 @@ import ( backoff "github.com/cenkalti/backoff/v4" "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/client" + "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/dao" "github.com/derailed/k9s/internal/model1" "github.com/rs/zerolog/log" @@ -41,6 +42,7 @@ type Table struct { instance string labelFilter string mx sync.RWMutex + vs *config.ViewSetting } // NewTable returns a new table model. @@ -52,6 +54,20 @@ func NewTable(gvr client.GVR) *Table { } } +func (t *Table) SetViewSetting(ctx context.Context, vs *config.ViewSetting) { + t.mx.Lock() + { + t.vs = vs + } + t.mx.Unlock() + + if ctx != context.Background() { + if err := t.reconcile(ctx); err != nil { + log.Err(err).Msgf("refresh failed for gvr: %s", t.gvr) + } + } +} + // SetLabelFilter sets the labels filter. func (t *Table) SetLabelFilter(f string) { t.mx.Lock() @@ -192,7 +208,11 @@ func (t *Table) updater(ctx context.Context) { case <-time.After(rate): rate = t.refreshRate err := backoff.Retry(func() error { - return t.refresh(ctx) + if err := t.refresh(ctx); err != nil { + log.Err(err).Msgf("refresh failed for gvr: %s", t.gvr) + return err + } + return nil }, backoff.WithContext(bf, ctx)) if err != nil { log.Warn().Err(err).Msgf("reconciler exited") @@ -247,6 +267,7 @@ func (t *Table) reconcile(ctx context.Context) error { err error ) meta := resourceMeta(t.gvr) + meta.DAO.SetIncludeObject(true) ctx = context.WithValue(ctx, internal.KeyLabels, t.labelFilter) if t.instance == "" { oo, err = t.list(ctx, meta.DAO) @@ -257,6 +278,8 @@ func (t *Table) reconcile(ctx context.Context) error { if err != nil { return err } + r := meta.Renderer + r.SetViewSetting(t.vs) return t.data.Reconcile(ctx, meta.Renderer, oo) } diff --git a/internal/model/table_int_test.go b/internal/model/table_int_test.go index e783b684..05ac605c 100644 --- a/internal/model/table_int_test.go +++ b/internal/model/table_int_test.go @@ -173,6 +173,8 @@ type accessor struct { var _ dao.Accessor = (*accessor)(nil) +func (a *accessor) SetIncludeObject(bool) {} + func (a *accessor) List(ctx context.Context, ns string) ([]runtime.Object, error) { return []runtime.Object{&render.PodWithMetrics{Raw: mustLoad("p1")}}, nil } diff --git a/internal/model/tree.go b/internal/model/tree.go index c013238e..6f32d13c 100644 --- a/internal/model/tree.go +++ b/internal/model/tree.go @@ -241,7 +241,7 @@ func (t *Tree) resourceMeta() ResourceMeta { if !ok { meta = ResourceMeta{ DAO: &dao.Table{}, - Renderer: &render.Generic{}, + Renderer: &render.Table{}, } } if meta.DAO == nil { diff --git a/internal/model/yaml.go b/internal/model/yaml.go index e476e272..6ba1ff86 100644 --- a/internal/model/yaml.go +++ b/internal/model/yaml.go @@ -15,7 +15,6 @@ import ( "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/dao" - "github.com/derailed/k9s/internal/render" "github.com/rs/zerolog/log" "github.com/sahilm/fuzzy" ) @@ -208,29 +207,3 @@ func (y *YAML) ToYAML(ctx context.Context, gvr client.GVR, path string, showMana return desc.ToYAML(path, showManaged) } - -func getMeta(ctx context.Context, gvr client.GVR) (ResourceMeta, error) { - meta := resourceMeta(gvr) - factory, ok := ctx.Value(internal.KeyFactory).(dao.Factory) - if !ok { - return ResourceMeta{}, fmt.Errorf("expected Factory in context but got %T", ctx.Value(internal.KeyFactory)) - } - meta.DAO.Init(factory, gvr) - - return meta, nil -} - -func resourceMeta(gvr client.GVR) ResourceMeta { - meta, ok := Registry[gvr.String()] - if !ok { - meta = ResourceMeta{ - DAO: &dao.Table{}, - Renderer: &render.Generic{}, - } - } - if meta.DAO == nil { - meta.DAO = &dao.Resource{} - } - - return meta -} diff --git a/internal/model1/delta.go b/internal/model1/delta.go index 3d3e32dc..b4a38e77 100644 --- a/internal/model1/delta.go +++ b/internal/model1/delta.go @@ -12,6 +12,9 @@ type DeltaRow []string func NewDeltaRow(o, n Row, h Header) DeltaRow { deltas := make(DeltaRow, len(o.Fields)) for i, old := range o.Fields { + if i >= len(n.Fields) { + continue + } if old != "" && old != n.Fields[i] && !h.IsTimeCol(i) { deltas[i] = old } diff --git a/internal/model1/header.go b/internal/model1/header.go index d4f6d4c8..9e1ab6f2 100644 --- a/internal/model1/header.go +++ b/internal/model1/header.go @@ -4,6 +4,7 @@ package model1 import ( + "fmt" "reflect" "github.com/rs/zerolog/log" @@ -11,16 +12,52 @@ import ( const ageCol = "AGE" -// HeaderColumn represent a table header. -type HeaderColumn struct { - Name string +type Attrs struct { Align int Decorator DecoratorFunc Wide bool MX bool + MXC, MXM bool Time bool Capacity bool VS bool + Hide bool +} + +func (a Attrs) Merge(b Attrs) Attrs { + a.MX = b.MX + a.MXC = b.MXC + a.MXM = b.MXM + a.Decorator = b.Decorator + a.VS = b.VS + + if a.Align == 0 { + a.Align = b.Align + } + if !a.Wide { + a.Wide = b.Wide + } + if !a.Time { + a.Time = b.Time + } + if !a.Capacity { + a.Capacity = b.Capacity + } + if !a.Hide { + a.Hide = b.Hide + } + + return a +} + +// HeaderColumn represent a table header. +type HeaderColumn struct { + Attrs + Name string +} + +func (h HeaderColumn) String() string { + return fmt.Sprintf("%s [%d::%t::%t::%t]", h.Name, h.Align, h.Wide, h.MX, h.Time) } // Clone copies a header. @@ -106,7 +143,6 @@ func (h Header) Customize(cols []string, wide bool) Header { col.Wide = false cc = append(cc, col) } - if !wide { return cc } diff --git a/internal/model1/header_test.go b/internal/model1/header_test.go index 3d1d62f3..922656b4 100644 --- a/internal/model1/header_test.go +++ b/internal/model1/header_test.go @@ -104,7 +104,7 @@ func TestHeaderCustomize(t *testing.T) { "reverse": { h: model1.Header{ model1.HeaderColumn{Name: "A"}, - model1.HeaderColumn{Name: "B", Wide: true}, + model1.HeaderColumn{Name: "B", Attrs: model1.Attrs{Wide: true}}, model1.HeaderColumn{Name: "C"}, }, cols: []string{"C", "A"}, @@ -116,7 +116,7 @@ func TestHeaderCustomize(t *testing.T) { "reverse-wide": { h: model1.Header{ model1.HeaderColumn{Name: "A"}, - model1.HeaderColumn{Name: "B", Wide: true}, + model1.HeaderColumn{Name: "B", Attrs: model1.Attrs{Wide: true}}, model1.HeaderColumn{Name: "C"}, }, cols: []string{"C", "A"}, @@ -124,27 +124,27 @@ func TestHeaderCustomize(t *testing.T) { e: model1.Header{ model1.HeaderColumn{Name: "C"}, model1.HeaderColumn{Name: "A"}, - model1.HeaderColumn{Name: "B", Wide: true}, + model1.HeaderColumn{Name: "B", Attrs: model1.Attrs{Wide: true}}, }, }, "toggle-wide": { h: model1.Header{ model1.HeaderColumn{Name: "A"}, - model1.HeaderColumn{Name: "B", Wide: true}, + model1.HeaderColumn{Name: "B", Attrs: model1.Attrs{Wide: true}}, model1.HeaderColumn{Name: "C"}, }, cols: []string{"C", "B"}, wide: true, e: model1.Header{ model1.HeaderColumn{Name: "C"}, - model1.HeaderColumn{Name: "B", Wide: false}, - model1.HeaderColumn{Name: "A", Wide: true}, + model1.HeaderColumn{Name: "B", Attrs: model1.Attrs{Wide: false}}, + model1.HeaderColumn{Name: "A", Attrs: model1.Attrs{Wide: true}}, }, }, "missing": { h: model1.Header{ model1.HeaderColumn{Name: "A"}, - model1.HeaderColumn{Name: "B", Wide: true}, + model1.HeaderColumn{Name: "B", Attrs: model1.Attrs{Wide: true}}, model1.HeaderColumn{Name: "C"}, }, cols: []string{"BLEE", "A"}, @@ -152,8 +152,8 @@ func TestHeaderCustomize(t *testing.T) { e: model1.Header{ model1.HeaderColumn{Name: "BLEE"}, model1.HeaderColumn{Name: "A"}, - model1.HeaderColumn{Name: "B", Wide: true}, - model1.HeaderColumn{Name: "C", Wide: true}, + model1.HeaderColumn{Name: "B", Attrs: model1.Attrs{Wide: true}}, + model1.HeaderColumn{Name: "C", Attrs: model1.Attrs{Wide: true}}, }, }, } @@ -183,7 +183,7 @@ func TestHeaderDiff(t *testing.T) { "differ-wide": { h1: model1.Header{ model1.HeaderColumn{Name: "A"}, - model1.HeaderColumn{Name: "B", Wide: true}, + model1.HeaderColumn{Name: "B", Attrs: model1.Attrs{Wide: true}}, model1.HeaderColumn{Name: "C"}, }, h2: model1.Header{ @@ -196,13 +196,13 @@ func TestHeaderDiff(t *testing.T) { "differ-order": { h1: model1.Header{ model1.HeaderColumn{Name: "A"}, - model1.HeaderColumn{Name: "B", Wide: true}, + model1.HeaderColumn{Name: "B", Attrs: model1.Attrs{Wide: true}}, model1.HeaderColumn{Name: "C"}, }, h2: model1.Header{ model1.HeaderColumn{Name: "A"}, model1.HeaderColumn{Name: "C"}, - model1.HeaderColumn{Name: "B", Wide: true}, + model1.HeaderColumn{Name: "B", Attrs: model1.Attrs{Wide: true}}, }, e: true, }, @@ -236,8 +236,8 @@ func TestHeaderHasAge(t *testing.T) { "age": { h: model1.Header{ model1.HeaderColumn{Name: "A"}, - model1.HeaderColumn{Name: "B", Wide: true}, - model1.HeaderColumn{Name: "AGE", Time: true}, + model1.HeaderColumn{Name: "B", Attrs: model1.Attrs{Wide: true}}, + model1.HeaderColumn{Name: "AGE", Attrs: model1.Attrs{Time: true}}, }, e: true, age: true, @@ -312,7 +312,7 @@ func TestHeaderClone(t *testing.T) { func makeHeader() model1.Header { return model1.Header{ model1.HeaderColumn{Name: "A"}, - model1.HeaderColumn{Name: "B", Wide: true}, + model1.HeaderColumn{Name: "B", Attrs: model1.Attrs{Wide: true}}, model1.HeaderColumn{Name: "C"}, } } diff --git a/internal/model1/table_data.go b/internal/model1/table_data.go index 702a86ed..d6e5bc82 100644 --- a/internal/model1/table_data.go +++ b/internal/model1/table_data.go @@ -322,35 +322,21 @@ func (t *TableData) Labelize(labels []string) *TableData { } // Customize returns a new model with customized column layout. -func (t *TableData) Customize(vs *config.ViewSetting, sc SortColumn, manual, wide bool) (*TableData, SortColumn) { +func (t *TableData) Customize(vs *config.ViewSetting, sc SortColumn, manual bool) (*TableData, SortColumn) { if vs.IsBlank() { if sc.Name != "" { return t, sc } - psc, err := t.sortCol(vs) - if err == nil { + if psc, err := t.sortCol(vs); err == nil { return t, psc } return t, sc } - - cols := vs.Columns - cdata := TableData{ - gvr: t.gvr, - namespace: t.namespace, - header: t.header.Customize(cols, wide), - } - ids := t.header.MapIndices(cols, wide) - cdata.rowEvents = t.rowEvents.Customize(ids) - if manual || vs == nil { - return &cdata, sc - } - psc, err := cdata.sortCol(vs) - if err != nil { - return &cdata, sc + if s, asc, err := vs.SortCol(); err == nil { + return t, SortColumn{Name: s, ASC: asc} } - return &cdata, psc + return t, sc } func (t *TableData) sortCol(vs *config.ViewSetting) (SortColumn, error) { diff --git a/internal/model1/table_data_test.go b/internal/model1/table_data_test.go index fc338a56..80237701 100644 --- a/internal/model1/table_data_test.go +++ b/internal/model1/table_data_test.go @@ -57,7 +57,7 @@ func TestTableDataCustomize(t *testing.T) { client.NewGVR("test"), Header{ HeaderColumn{Name: "A"}, - HeaderColumn{Name: "B", Wide: true}, + HeaderColumn{Name: "B", Attrs: Attrs{Wide: true}}, HeaderColumn{Name: "C"}, }, NewRowEventsWithEvts( @@ -71,7 +71,7 @@ func TestTableDataCustomize(t *testing.T) { client.NewGVR("test"), Header{ HeaderColumn{Name: "A"}, - HeaderColumn{Name: "B", Wide: false}, + HeaderColumn{Name: "B", Attrs: Attrs{Wide: true}}, HeaderColumn{Name: "C"}, }, NewRowEventsWithEvts( @@ -86,7 +86,7 @@ func TestTableDataCustomize(t *testing.T) { client.NewGVR("test"), Header{ HeaderColumn{Name: "A"}, - HeaderColumn{Name: "B", Wide: true}, + HeaderColumn{Name: "B", Attrs: Attrs{Wide: true}}, HeaderColumn{Name: "C"}, }, NewRowEventsWithEvts( @@ -101,13 +101,13 @@ func TestTableDataCustomize(t *testing.T) { client.NewGVR("test"), Header{ HeaderColumn{Name: "A"}, + HeaderColumn{Name: "B", Attrs: Attrs{Wide: true}}, HeaderColumn{Name: "C"}, - HeaderColumn{Name: "B", Wide: true}, }, NewRowEventsWithEvts( - RowEvent{Row: Row{ID: "A", Fields: Fields{"1", "3", "2"}}}, - RowEvent{Row: Row{ID: "B", Fields: Fields{"0", "3", "2"}}}, - RowEvent{Row: Row{ID: "C", Fields: Fields{"10", "3", "2"}}}, + RowEvent{Row: Row{ID: "A", Fields: Fields{"1", "2", "3"}}}, + RowEvent{Row: Row{ID: "B", Fields: Fields{"0", "2", "3"}}}, + RowEvent{Row: Row{ID: "C", Fields: Fields{"10", "2", "3"}}}, ), ), }, @@ -116,7 +116,7 @@ func TestTableDataCustomize(t *testing.T) { for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { - td, _ := u.t1.Customize(&u.vs, u.sc, u.manual, u.wide) + td, _ := u.t1.Customize(&u.vs, u.sc, u.manual) assert.Equal(t, u.e, td) }) } diff --git a/internal/model1/types.go b/internal/model1/types.go index 2fc32ad2..bd40b49f 100644 --- a/internal/model1/types.go +++ b/internal/model1/types.go @@ -4,6 +4,7 @@ package model1 import ( + "github.com/derailed/k9s/internal/config" "github.com/derailed/tcell/v2" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -46,6 +47,8 @@ type Renderer interface { // ColorerFunc returns a row colorer function. ColorerFunc() ColorerFunc + + SetViewSetting(vs *config.ViewSetting) } // Generic represents a generic resource. diff --git a/internal/render/base.go b/internal/render/base.go index 003fe6a8..34bb5e31 100644 --- a/internal/render/base.go +++ b/internal/render/base.go @@ -4,7 +4,9 @@ package render import ( + "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/model1" + "github.com/rs/zerolog/log" ) // DecoratorFunc decorates a string. @@ -15,19 +17,49 @@ var AgeDecorator = func(a string) string { return toAgeHuman(a) } -type Base struct{} +type Base struct { + vs *config.ViewSetting + specs ColumnSpecs + includeObj bool +} + +func (b *Base) SetIncludeObject(f bool) { + b.includeObj = f +} // IsGeneric identifies a generic handler. -func (Base) IsGeneric() bool { +func (*Base) IsGeneric() bool { return false } +func (b *Base) doHeader(dh model1.Header) model1.Header { + if b.specs.isEmpty() { + return dh + } + + return b.specs.Header(dh) +} + +func (b *Base) SetViewSetting(vs *config.ViewSetting) { + var cols []string + b.vs = vs + if vs != nil { + cols = vs.Columns + } + specs, err := NewColsSpecs(cols...).parseSpecs() + if err != nil { + log.Error().Err(err).Msg("unable to grok custom columns") + return + } + b.specs = specs +} + // ColorerFunc colors a resource row. -func (Base) ColorerFunc() model1.ColorerFunc { +func (*Base) ColorerFunc() model1.ColorerFunc { return model1.DefaultColorer } // Happy returns true if resource is happy, false otherwise. -func (Base) Happy(string, *model1.Row) bool { +func (*Base) Happy(string, *model1.Row) bool { return true } diff --git a/internal/render/benchmark.go b/internal/render/benchmark.go index ded85e31..c2cc433e 100644 --- a/internal/render/benchmark.go +++ b/internal/render/benchmark.go @@ -51,12 +51,12 @@ func (Benchmark) Header(ns string) model1.Header { model1.HeaderColumn{Name: "NAME"}, model1.HeaderColumn{Name: "STATUS"}, model1.HeaderColumn{Name: "TIME"}, - model1.HeaderColumn{Name: "REQ/S", Align: tview.AlignRight}, - model1.HeaderColumn{Name: "2XX", Align: tview.AlignRight}, - model1.HeaderColumn{Name: "4XX/5XX", Align: tview.AlignRight}, + model1.HeaderColumn{Name: "REQ/S", Attrs: model1.Attrs{Align: tview.AlignRight}}, + model1.HeaderColumn{Name: "2XX", Attrs: model1.Attrs{Align: tview.AlignRight}}, + model1.HeaderColumn{Name: "4XX/5XX", Attrs: model1.Attrs{Align: tview.AlignRight}}, model1.HeaderColumn{Name: "REPORT"}, - model1.HeaderColumn{Name: "VALID", Wide: true}, - model1.HeaderColumn{Name: "AGE", Time: true}, + model1.HeaderColumn{Name: "VALID", Attrs: model1.Attrs{Wide: true}}, + model1.HeaderColumn{Name: "AGE", Attrs: model1.Attrs{Time: true}}, } } diff --git a/internal/render/cm.go b/internal/render/cm.go index f6158efb..9811425a 100644 --- a/internal/render/cm.go +++ b/internal/render/cm.go @@ -7,12 +7,11 @@ import ( "fmt" "strconv" - v1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/runtime" - "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/model1" + v1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" ) // ConfigMap renders a K8s ConfigMap to screen. @@ -20,19 +19,42 @@ type ConfigMap struct { Base } +// Header returns a header row. +func (m ConfigMap) Header(_ string) model1.Header { + return m.doHeader(m.defaultHeader()) +} + // Header returns a header rbw. -func (ConfigMap) Header(string) model1.Header { +func (ConfigMap) defaultHeader() model1.Header { return model1.Header{ model1.HeaderColumn{Name: "NAMESPACE"}, model1.HeaderColumn{Name: "NAME"}, model1.HeaderColumn{Name: "DATA"}, - model1.HeaderColumn{Name: "VALID", Wide: true}, - model1.HeaderColumn{Name: "AGE", Time: true}, + model1.HeaderColumn{Name: "VALID", Attrs: model1.Attrs{Wide: true}}, + model1.HeaderColumn{Name: "AGE", Attrs: model1.Attrs{Time: true}}, } } // Render renders a K8s resource to screen. -func (n ConfigMap) Render(o interface{}, _ string, r *model1.Row) error { +func (m ConfigMap) Render(o interface{}, ns string, row *model1.Row) error { + if err := m.defaultRow(o, row); err != nil { + return err + } + if m.specs.isEmpty() { + return nil + } + + cols, err := m.specs.realize(o.(*unstructured.Unstructured), m.defaultHeader(), row) + if err != nil { + return err + } + cols.hydrateRow(row) + + return nil +} + +// Render renders a K8s resource to screen. +func (ConfigMap) defaultRow(o interface{}, r *model1.Row) error { raw, ok := o.(*unstructured.Unstructured) if !ok { return fmt.Errorf("expected ConfigMap, but got %T", o) diff --git a/internal/render/container.go b/internal/render/container.go index 95f3e834..37cd38cc 100644 --- a/internal/render/container.go +++ b/internal/render/container.go @@ -70,7 +70,12 @@ func (c Container) ColorerFunc() model1.ColorerFunc { } // Header returns a header row. -func (Container) Header(ns string) model1.Header { +func (c Container) Header(_ string) model1.Header { + return c.defaultHeader() +} + +// Header returns a header row. +func (Container) defaultHeader() model1.Header { return model1.Header{ model1.HeaderColumn{Name: "IDX"}, model1.HeaderColumn{Name: "NAME"}, @@ -78,45 +83,49 @@ func (Container) Header(ns string) model1.Header { model1.HeaderColumn{Name: "IMAGE"}, model1.HeaderColumn{Name: "READY"}, model1.HeaderColumn{Name: "STATE"}, - model1.HeaderColumn{Name: "RESTARTS", Align: tview.AlignRight}, + model1.HeaderColumn{Name: "RESTARTS", Attrs: model1.Attrs{Align: tview.AlignRight}}, model1.HeaderColumn{Name: "PROBES(L:R:S)"}, - model1.HeaderColumn{Name: "CPU", Align: tview.AlignRight, MX: true}, - model1.HeaderColumn{Name: "MEM", Align: tview.AlignRight, MX: true}, - model1.HeaderColumn{Name: "CPU/R:L", Align: tview.AlignRight}, - model1.HeaderColumn{Name: "MEM/R:L", Align: tview.AlignRight}, - model1.HeaderColumn{Name: "%CPU/R", Align: tview.AlignRight, MX: true}, - model1.HeaderColumn{Name: "%CPU/L", Align: tview.AlignRight, MX: true}, - model1.HeaderColumn{Name: "%MEM/R", Align: tview.AlignRight, MX: true}, - model1.HeaderColumn{Name: "%MEM/L", Align: tview.AlignRight, MX: true}, + model1.HeaderColumn{Name: "CPU", Attrs: model1.Attrs{Align: tview.AlignRight, MX: true}}, + model1.HeaderColumn{Name: "MEM", Attrs: model1.Attrs{Align: tview.AlignRight, MX: true}}, + model1.HeaderColumn{Name: "CPU/R:L", Attrs: model1.Attrs{Align: tview.AlignRight}}, + model1.HeaderColumn{Name: "MEM/R:L", Attrs: model1.Attrs{Align: tview.AlignRight}}, + model1.HeaderColumn{Name: "%CPU/R", Attrs: model1.Attrs{Align: tview.AlignRight, MX: true}}, + model1.HeaderColumn{Name: "%CPU/L", Attrs: model1.Attrs{Align: tview.AlignRight, MX: true}}, + model1.HeaderColumn{Name: "%MEM/R", Attrs: model1.Attrs{Align: tview.AlignRight, MX: true}}, + model1.HeaderColumn{Name: "%MEM/L", Attrs: model1.Attrs{Align: tview.AlignRight, MX: true}}, model1.HeaderColumn{Name: "PORTS"}, - model1.HeaderColumn{Name: "VALID", Wide: true}, - model1.HeaderColumn{Name: "AGE", Time: true}, + model1.HeaderColumn{Name: "VALID", Attrs: model1.Attrs{Wide: true}}, + model1.HeaderColumn{Name: "AGE", Attrs: model1.Attrs{Time: true}}, } } // Render renders a K8s resource to screen. -func (c Container) Render(o interface{}, name string, r *model1.Row) error { - co, ok := o.(ContainerRes) +func (c Container) Render(o interface{}, ns string, row *model1.Row) error { + cr, ok := o.(ContainerRes) if !ok { return fmt.Errorf("expected ContainerRes, but got %T", o) } - cur, res := gatherMetrics(co.Container, co.MX) + return c.defaultRow(cr, row) +} + +func (c Container) defaultRow(cr ContainerRes, r *model1.Row) error { + cur, res := gatherMetrics(cr.Container, cr.MX) ready, state, restarts := "false", MissingValue, "0" - if co.Status != nil { - ready, state, restarts = boolToStr(co.Status.Ready), ToContainerState(co.Status.State), strconv.Itoa(int(co.Status.RestartCount)) + if cr.Status != nil { + ready, state, restarts = boolToStr(cr.Status.Ready), ToContainerState(cr.Status.State), strconv.Itoa(int(cr.Status.RestartCount)) } - r.ID = co.Container.Name + r.ID = cr.Container.Name r.Fields = model1.Fields{ - co.Idx, - co.Container.Name, + cr.Idx, + cr.Container.Name, "●", - co.Container.Image, + cr.Container.Image, ready, state, restarts, - probe(co.Container.LivenessProbe) + ":" + probe(co.Container.ReadinessProbe) + ":" + probe(co.Container.StartupProbe), + probe(cr.Container.LivenessProbe) + ":" + probe(cr.Container.ReadinessProbe) + ":" + probe(cr.Container.StartupProbe), toMc(cur.cpu), toMi(cur.mem), toMc(res.cpu) + ":" + toMc(res.lcpu), @@ -125,9 +134,9 @@ func (c Container) Render(o interface{}, name string, r *model1.Row) error { client.ToPercentageStr(cur.cpu, res.lcpu), client.ToPercentageStr(cur.mem, res.mem), client.ToPercentageStr(cur.mem, res.lmem), - ToContainerPorts(co.Container.Ports), + ToContainerPorts(cr.Container.Ports), AsStatus(c.diagnose(state, ready)), - ToAge(co.Age), + ToAge(cr.Age), } return nil diff --git a/internal/render/cr.go b/internal/render/cr.go index a148fc70..4beba462 100644 --- a/internal/render/cr.go +++ b/internal/render/cr.go @@ -18,21 +18,44 @@ type ClusterRole struct { Base } +// Header returns a header row. +func (c ClusterRole) Header(_ string) model1.Header { + return c.doHeader(c.defaultHeader()) +} + // Header returns a header rbw. -func (ClusterRole) Header(string) model1.Header { +func (ClusterRole) defaultHeader() model1.Header { return model1.Header{ model1.HeaderColumn{Name: "NAME"}, - model1.HeaderColumn{Name: "LABELS", Wide: true}, - model1.HeaderColumn{Name: "AGE", Time: true}, + model1.HeaderColumn{Name: "LABELS", Attrs: model1.Attrs{Wide: true}}, + model1.HeaderColumn{Name: "AGE", Attrs: model1.Attrs{Time: true}}, } } // Render renders a K8s resource to screen. -func (ClusterRole) Render(o interface{}, ns string, r *model1.Row) error { +func (p ClusterRole) Render(o interface{}, ns string, row *model1.Row) error { raw, ok := o.(*unstructured.Unstructured) if !ok { return fmt.Errorf("expecting clusterrole, but got %T", o) } + if err := p.defaultRow(raw, row); err != nil { + return err + } + if p.specs.isEmpty() { + return nil + } + + cols, err := p.specs.realize(raw, p.defaultHeader(), row) + if err != nil { + return err + } + cols.hydrateRow(row) + + return nil +} + +// Render renders a K8s resource to screen. +func (ClusterRole) defaultRow(raw *unstructured.Unstructured, r *model1.Row) error { var cr rbacv1.ClusterRole err := runtime.DefaultUnstructuredConverter.FromUnstructured(raw.Object, &cr) if err != nil { diff --git a/internal/render/crb.go b/internal/render/crb.go index 8290973e..ff0582c0 100644 --- a/internal/render/crb.go +++ b/internal/render/crb.go @@ -18,24 +18,47 @@ type ClusterRoleBinding struct { Base } +// Header returns a header row. +func (c ClusterRoleBinding) Header(_ string) model1.Header { + return c.doHeader(c.defaultHeader()) +} + // Header returns a header rbw. -func (ClusterRoleBinding) Header(string) model1.Header { +func (ClusterRoleBinding) defaultHeader() model1.Header { return model1.Header{ model1.HeaderColumn{Name: "NAME"}, model1.HeaderColumn{Name: "CLUSTERROLE"}, model1.HeaderColumn{Name: "SUBJECT-KIND"}, model1.HeaderColumn{Name: "SUBJECTS"}, - model1.HeaderColumn{Name: "LABELS", Wide: true}, - model1.HeaderColumn{Name: "AGE", Time: true}, + model1.HeaderColumn{Name: "LABELS", Attrs: model1.Attrs{Wide: true}}, + model1.HeaderColumn{Name: "AGE", Attrs: model1.Attrs{Time: true}}, } } // Render renders a K8s resource to screen. -func (ClusterRoleBinding) Render(o interface{}, ns string, r *model1.Row) error { +func (c ClusterRoleBinding) Render(o interface{}, ns string, row *model1.Row) error { raw, ok := o.(*unstructured.Unstructured) if !ok { return fmt.Errorf("expected ClusterRoleBinding, but got %T", o) } + if err := c.defaultRow(raw, row); err != nil { + return err + } + if c.specs.isEmpty() { + return nil + } + + // !BOZO!! Call header 2 times + cols, err := c.specs.realize(raw, c.defaultHeader(), row) + if err != nil { + return err + } + cols.hydrateRow(row) + + return nil +} + +func (ClusterRoleBinding) defaultRow(raw *unstructured.Unstructured, r *model1.Row) error { var crb rbacv1.ClusterRoleBinding err := runtime.DefaultUnstructuredConverter.FromUnstructured(raw.Object, &crb) if err != nil { diff --git a/internal/render/crd.go b/internal/render/crd.go index fddeec19..efcb6af9 100644 --- a/internal/render/crd.go +++ b/internal/render/crd.go @@ -21,28 +21,52 @@ type CustomResourceDefinition struct { Base } +// Header returns a header row. +func (c CustomResourceDefinition) Header(_ string) model1.Header { + return c.doHeader(c.defaultHeader()) +} + // Header returns a header rbw. -func (CustomResourceDefinition) Header(string) model1.Header { +func (CustomResourceDefinition) defaultHeader() model1.Header { return model1.Header{ model1.HeaderColumn{Name: "NAME"}, model1.HeaderColumn{Name: "GROUP"}, model1.HeaderColumn{Name: "KIND"}, model1.HeaderColumn{Name: "VERSIONS"}, model1.HeaderColumn{Name: "SCOPE"}, - model1.HeaderColumn{Name: "ALIASES", Wide: true}, - model1.HeaderColumn{Name: "LABELS", Wide: true}, - model1.HeaderColumn{Name: "VALID", Wide: true}, - model1.HeaderColumn{Name: "AGE", Time: true}, + model1.HeaderColumn{Name: "ALIASES", Attrs: model1.Attrs{Wide: true}}, + model1.HeaderColumn{Name: "LABELS", Attrs: model1.Attrs{Wide: true}}, + model1.HeaderColumn{Name: "VALID", Attrs: model1.Attrs{Wide: true}}, + model1.HeaderColumn{Name: "AGE", Attrs: model1.Attrs{Time: true}}, } } // Render renders a K8s resource to screen. -func (c CustomResourceDefinition) Render(o interface{}, ns string, r *model1.Row) error { +func (c CustomResourceDefinition) Render(o interface{}, ns string, row *model1.Row) error { raw, ok := o.(*unstructured.Unstructured) if !ok { return fmt.Errorf("expected CustomResourceDefinition, but got %T", o) } + if err := c.defaultRow(raw, row); err != nil { + return err + } + if c.specs.isEmpty() { + return nil + } + + // !BOZO!! Call header 2 times + cols, err := c.specs.realize(raw, c.defaultHeader(), row) + if err != nil { + return err + } + cols.hydrateRow(row) + + return nil +} + +// Render renders a K8s resource to screen. +func (c CustomResourceDefinition) defaultRow(raw *unstructured.Unstructured, r *model1.Row) error { var crd v1.CustomResourceDefinition err := runtime.DefaultUnstructuredConverter.FromUnstructured(raw.Object, &crd) if err != nil { @@ -114,19 +138,3 @@ func (c CustomResourceDefinition) diagnose(n string, vv []v1.CustomResourceDefin return errors.New(strings.Join(errs, " - ")) } - -func extractMetaField(m map[string]interface{}, field string) string { - f, ok := m[field] - if !ok { - log.Error().Err(fmt.Errorf("failed to extract field from meta %s", field)) - return NAValue - } - - fs, ok := f.(string) - if !ok { - log.Error().Err(fmt.Errorf("failed to extract string from field %s", field)) - return NAValue - } - - return fs -} diff --git a/internal/render/cronjob.go b/internal/render/cronjob.go index bd1ce7c3..ca1cd1dc 100644 --- a/internal/render/cronjob.go +++ b/internal/render/cronjob.go @@ -22,30 +22,52 @@ type CronJob struct { } // Header returns a header row. -func (CronJob) Header(ns string) model1.Header { +func (c CronJob) Header(_ string) model1.Header { + return c.doHeader(c.defaultHeader()) +} + +func (CronJob) defaultHeader() model1.Header { return model1.Header{ model1.HeaderColumn{Name: "NAMESPACE"}, model1.HeaderColumn{Name: "NAME"}, - model1.HeaderColumn{Name: "VS", VS: true}, + model1.HeaderColumn{Name: "VS", Attrs: model1.Attrs{VS: true}}, model1.HeaderColumn{Name: "SCHEDULE"}, model1.HeaderColumn{Name: "SUSPEND"}, model1.HeaderColumn{Name: "ACTIVE"}, - model1.HeaderColumn{Name: "LAST_SCHEDULE", Time: true}, - model1.HeaderColumn{Name: "SELECTOR", Wide: true}, - model1.HeaderColumn{Name: "CONTAINERS", Wide: true}, - model1.HeaderColumn{Name: "IMAGES", Wide: true}, - model1.HeaderColumn{Name: "LABELS", Wide: true}, - model1.HeaderColumn{Name: "VALID", Wide: true}, - model1.HeaderColumn{Name: "AGE", Time: true}, + model1.HeaderColumn{Name: "LAST_SCHEDULE", Attrs: model1.Attrs{Time: true}}, + model1.HeaderColumn{Name: "SELECTOR", Attrs: model1.Attrs{Wide: true}}, + model1.HeaderColumn{Name: "CONTAINERS", Attrs: model1.Attrs{Wide: true}}, + model1.HeaderColumn{Name: "IMAGES", Attrs: model1.Attrs{Wide: true}}, + model1.HeaderColumn{Name: "LABELS", Attrs: model1.Attrs{Wide: true}}, + model1.HeaderColumn{Name: "VALID", Attrs: model1.Attrs{Wide: true}}, + model1.HeaderColumn{Name: "AGE", Attrs: model1.Attrs{Time: true}}, } } // Render renders a K8s resource to screen. -func (c CronJob) Render(o interface{}, ns string, r *model1.Row) error { +func (c CronJob) Render(o interface{}, ns string, row *model1.Row) error { raw, ok := o.(*unstructured.Unstructured) if !ok { return fmt.Errorf("expected CronJob, but got %T", o) } + if err := c.defaultRow(raw, row); err != nil { + return err + } + if c.specs.isEmpty() { + return nil + } + + cols, err := c.specs.realize(raw, c.defaultHeader(), row) + if err != nil { + return err + } + cols.hydrateRow(row) + + return nil +} + +// Render renders a K8s resource to screen. +func (c CronJob) defaultRow(raw *unstructured.Unstructured, r *model1.Row) error { var cj batchv1.CronJob err := runtime.DefaultUnstructuredConverter.FromUnstructured(raw.Object, &cj) if err != nil { diff --git a/internal/render/cust_col.go b/internal/render/cust_col.go new file mode 100644 index 00000000..10075adc --- /dev/null +++ b/internal/render/cust_col.go @@ -0,0 +1,104 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Authors of K9s + +package render + +import ( + "fmt" + "regexp" + + "github.com/derailed/k9s/internal/model1" + "github.com/derailed/tview" + "k8s.io/kubectl/pkg/cmd/get" +) + +var fullRX = regexp.MustCompile(`\A([\w\s-]+)\:?([^\|]*)\|?([T|N|W|L|R|H]{0,3})\b`) + +type colAttr byte + +const ( + number colAttr = 'N' + age colAttr = 'T' + wide colAttr = 'W' + alignLeft colAttr = 'L' + alignRight colAttr = 'R' + hide colAttr = 'H' +) + +type colAttrs struct { + align int + mx bool + mxc bool + mxm bool + time bool + wide bool + hide bool + capacity bool +} + +func newColFlags(flags string) colAttrs { + c := colAttrs{ + align: tview.AlignLeft, + wide: false, + } + for _, b := range []byte(flags) { + switch colAttr(b) { + case hide: + c.hide = true + case wide: + c.wide = true + case alignLeft: + c.align = tview.AlignLeft + case alignRight: + c.align = tview.AlignRight + case age: + c.time = true + case number: + c.capacity, c.align = true, tview.AlignRight + } + } + + return c +} + +type colDef struct { + colAttrs + + name string + idx int + spec string +} + +func parse(s string) (colDef, error) { + mm := fullRX.FindStringSubmatch(s) + if len(mm) == 4 { + spec, err := get.RelaxedJSONPathExpression(mm[2]) + if err != nil { + return colDef{idx: -1}, err + } + return colDef{ + name: mm[1], + idx: -1, + spec: spec, + colAttrs: newColFlags(mm[3]), + }, nil + } + + return colDef{idx: -1}, fmt.Errorf("invalid column definition %q", s) +} + +func (c colDef) toHeaderCol() model1.HeaderColumn { + return model1.HeaderColumn{ + Name: c.name, + Attrs: model1.Attrs{ + Align: c.align, + Wide: c.wide, + Time: c.time, + MX: c.mx, + MXC: c.mxc, + MXM: c.mxm, + Hide: c.hide, + Capacity: c.capacity, + }, + } +} diff --git a/internal/render/cust_col_test.go b/internal/render/cust_col_test.go new file mode 100644 index 00000000..adf316c5 --- /dev/null +++ b/internal/render/cust_col_test.go @@ -0,0 +1,240 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Authors of K9s + +package render + +import ( + "errors" + "testing" + + "github.com/derailed/tview" + "github.com/stretchr/testify/assert" +) + +func TestCustCol_parse(t *testing.T) { + uu := map[string]struct { + s string + err error + e colDef + }{ + "empty": { + err: errors.New(`invalid column definition ""`), + }, + + "plain": { + s: "fred", + e: colDef{ + name: "fred", + idx: -1, + colAttrs: colAttrs{ + align: tview.AlignLeft, + }, + }, + }, + + "plain-wide": { + s: "fred|W", + e: colDef{ + name: "fred", + idx: -1, + colAttrs: colAttrs{ + align: tview.AlignLeft, + wide: true, + }, + }, + }, + + "plain-hide": { + s: "fred|WH", + e: colDef{ + name: "fred", + idx: -1, + colAttrs: colAttrs{ + align: tview.AlignLeft, + wide: true, + hide: true, + }, + }, + }, + + "age": { + s: "AGE|TR", + e: colDef{ + name: "AGE", + idx: -1, + colAttrs: colAttrs{ + align: tview.AlignRight, + time: true, + }, + }, + }, + + "plain-wide-right": { + s: "fred|WR", + e: colDef{ + name: "fred", + idx: -1, + colAttrs: colAttrs{ + align: tview.AlignRight, + wide: true, + }, + }, + }, + + "complex": { + s: "BLEE:.spec.addresses[?(@.type == 'CiliumInternalIP')].ip", + e: colDef{ + name: "BLEE", + idx: -1, + spec: "{.spec.addresses[?(@.type == 'CiliumInternalIP')].ip}", + colAttrs: colAttrs{ + align: tview.AlignLeft, + }, + }, + }, + + "complex-wide": { + s: "BLEE:.spec.addresses[?(@.type == 'CiliumInternalIP')].ip|WR", + e: colDef{ + name: "BLEE", + idx: -1, + spec: "{.spec.addresses[?(@.type == 'CiliumInternalIP')].ip}", + colAttrs: colAttrs{ + align: tview.AlignRight, + wide: true, + }, + }, + }, + + "full-complex-wide": { + s: "BLEE:.spec.addresses[?(@.type == 'CiliumInternalIP')].ip|WR", + e: colDef{ + name: "BLEE", + idx: -1, + spec: "{.spec.addresses[?(@.type == 'CiliumInternalIP')].ip}", + colAttrs: colAttrs{ + align: tview.AlignRight, + wide: true, + }, + }, + }, + + "full-number-wide": { + s: "fred:.metadata.name|NW", + e: colDef{ + name: "fred", + idx: -1, + spec: "{.metadata.name}", + colAttrs: colAttrs{ + align: tview.AlignRight, + capacity: true, + wide: true, + }, + }, + }, + + "full-wide": { + s: "fred:.metadata.name|RW", + e: colDef{ + name: "fred", + idx: -1, + spec: "{.metadata.name}", + colAttrs: colAttrs{ + align: tview.AlignRight, + wide: true, + }, + }, + }, + + "partial-time-no-wide": { + s: "fred:.metadata.name|T", + e: colDef{ + name: "fred", + idx: -1, + spec: "{.metadata.name}", + colAttrs: colAttrs{ + align: tview.AlignLeft, + time: true, + }, + }, + }, + + "partial-no-type-no-wide": { + s: "fred:.metadata.name", + e: colDef{ + name: "fred", + idx: -1, + spec: "{.metadata.name}", + colAttrs: colAttrs{ + align: tview.AlignLeft, + }, + }, + }, + + "partial-no-type-wide": { + s: "fred:.metadata.name|W", + e: colDef{ + name: "fred", + idx: -1, + spec: "{.metadata.name}", + colAttrs: colAttrs{ + align: tview.AlignLeft, + wide: true, + }, + }, + }, + + "toast": { + s: "fred||.metadata.name|W", + e: colDef{ + name: "fred", + idx: -1, + colAttrs: colAttrs{ + align: tview.AlignLeft, + }, + }, + }, + + "toast-no-name": { + s: `:.metadata.name.fred|TW`, + err: errors.New(`invalid column definition ":.metadata.name.fred|TW"`), + }, + + "spec-column-typed": { + s: `fred:.metadata.name.k8s:fred\.blee|TW`, + e: colDef{ + name: "fred", + spec: `{.metadata.name.k8s:fred\.blee}`, + idx: -1, + colAttrs: colAttrs{ + align: tview.AlignLeft, + time: true, + wide: true, + }, + }, + }, + + "partial-no-spec-no-wide": { + s: "fred|T", + e: colDef{ + name: "fred", + idx: -1, + colAttrs: colAttrs{ + align: tview.AlignLeft, + time: true, + }, + }, + }, + } + + for k, u := range uu { + t.Run(k, func(t *testing.T) { + c, err := parse(u.s) + if err != nil { + assert.Equal(t, u.err, err) + } else { + assert.Equal(t, u.e, c) + } + }) + } +} diff --git a/internal/render/cust_cols.go b/internal/render/cust_cols.go new file mode 100644 index 00000000..acf541ed --- /dev/null +++ b/internal/render/cust_cols.go @@ -0,0 +1,224 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Authors of K9s + +package render + +import ( + "fmt" + "reflect" + "strings" + "time" + + "github.com/derailed/k9s/internal/model1" + "github.com/rs/zerolog/log" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/util/jsonpath" +) + +type ColsSpecs []string + +func NewColsSpecs(cols ...string) ColsSpecs { + return ColsSpecs(cols) +} + +func (cc ColsSpecs) parseSpecs() (ColumnSpecs, error) { + specs := make(ColumnSpecs, 0, len(cc)) + + for _, c := range cc { + def, err := parse(c) + if err != nil { + return nil, err + } + specs = append(specs, ColumnSpec{ + Header: def.toHeaderCol(), + Spec: def.spec, + }) + } + + return specs, nil +} + +type RenderedCols []RenderedCol + +func (rr RenderedCols) hydrateRow(row *model1.Row) { + ff := make(model1.Fields, 0, len(row.Fields)) + for _, c := range rr { + ff = append(ff, c.Value) + } + row.Fields = ff +} + +func (rr RenderedCols) HasHeader(n string) bool { + for _, r := range rr { + if r.has(n) { + return true + } + } + + return false +} + +type RenderedCol struct { + Header model1.HeaderColumn + Value string +} + +func (r RenderedCol) has(n string) bool { + return r.Header.Name == n +} + +type ColumnSpec struct { + Header model1.HeaderColumn + Spec string +} + +type ColumnSpecs []ColumnSpec + +func (c ColumnSpecs) isEmpty() bool { + return len(c) == 0 +} + +func (cc ColumnSpecs) Header(rh model1.Header) model1.Header { + hh := make(model1.Header, 0, len(cc)) + for _, h := range cc { + hh = append(hh, h.Header) + } + + for _, h := range rh { + if idx, ok := hh.IndexOf(h.Name, true); ok { + hh[idx].Attrs = hh[idx].Attrs.Merge(h.Attrs) + continue + } + hh = append(hh, h) + } + + return hh +} + +func (cc ColumnSpecs) realize(o runtime.Object, rh model1.Header, row *model1.Row) (RenderedCols, error) { + parsers := make([]*jsonpath.JSONPath, len(cc)) + for ix := range cc { + if cc[ix].Spec == "" { + parsers[ix] = nil + continue + } + parsers[ix] = jsonpath.New( + fmt.Sprintf("column%d", ix), + ).AllowMissingKeys(true) + if err := parsers[ix].Parse(cc[ix].Spec); err != nil { + return nil, err + } + } + + vv, err := hydrate(o, cc, parsers, rh, row) + if err != nil { + return nil, err + } + for _, hc := range rh { + if vv.HasHeader(hc.Name) { + continue + } + if idx, ok := rh.IndexOf(hc.Name, true); ok { + rc := RenderedCol{Header: hc, Value: row.Fields[idx]} + rc.Header.Wide = true + vv = append(vv, rc) + } + } + + return vv, nil +} + +func hydrate(o runtime.Object, cc ColumnSpecs, parsers []*jsonpath.JSONPath, rh model1.Header, row *model1.Row) (RenderedCols, error) { + cols := make(RenderedCols, len(parsers)) + for idx := range parsers { + parser := parsers[idx] + if parser == nil { + ix, ok := rh.IndexOf(cc[idx].Header.Name, true) + if !ok { + cols[idx] = RenderedCol{ + Header: cc[idx].Header, + Value: NAValue, + } + log.Warn().Msgf("unable to find column %s", cc[idx].Header.Name) + continue + } + var v string + if ix >= len(row.Fields) { + v = NAValue + } else { + v = row.Fields[ix] + } + cols[idx] = RenderedCol{ + Header: rh[ix], + Value: v, + } + continue + } + + var ( + vals [][]reflect.Value + err error + ) + if unstructured, ok := o.(runtime.Unstructured); ok { + vals, err = parser.FindResults(unstructured.UnstructuredContent()) + } else { + vals, err = parser.FindResults(reflect.ValueOf(o).Elem().Interface()) + } + if err != nil { + return nil, err + } + values := make([]string, 0, len(vals)) + if len(vals) == 0 || len(vals[0]) == 0 { + values = append(values, MissingValue) + } + for i := range vals { + for j := range vals[i] { + var ( + strVal string + v = vals[i][j].Interface() + ) + switch { + case cc[idx].Header.MXC: + switch k := v.(type) { + case resource.Quantity: + strVal = toMc(k.MilliValue()) + case string: + if q, err := resource.ParseQuantity(k); err == nil { + strVal = toMc(q.MilliValue()) + } + } + case cc[idx].Header.MXM: + switch k := v.(type) { + case resource.Quantity: + strVal = toMi(k.MilliValue()) + case string: + if q, err := resource.ParseQuantity(k); err == nil { + strVal = toMi(q.MilliValue()) + } + } + case cc[idx].Header.Time: + switch k := v.(type) { + case string: + if t, err := time.Parse(time.RFC3339, k); err == nil { + strVal = ToAge(metav1.Time{Time: t}) + } + case metav1.Time: + strVal = ToAge(k) + } + } + if strVal == "" { + strVal = fmt.Sprintf("%v", v) + } + values = append(values, strVal) + } + } + cols[idx] = RenderedCol{ + Header: cc[idx].Header, + Value: strings.Join(values, ","), + } + } + + return cols, nil +} diff --git a/internal/render/cust_cols_test.go b/internal/render/cust_cols_test.go new file mode 100644 index 00000000..bd1c0d2a --- /dev/null +++ b/internal/render/cust_cols_test.go @@ -0,0 +1,235 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Authors of K9s + +package render + +import ( + "errors" + "testing" + + "github.com/derailed/k9s/internal/model1" + "github.com/derailed/tview" + "github.com/stretchr/testify/assert" +) + +func TestParseSpecs(t *testing.T) { + uu := map[string]struct { + cols ColsSpecs + err error + e ColumnSpecs + }{ + "empty": { + e: ColumnSpecs{}, + }, + + "plain": { + cols: ColsSpecs{ + "a", + "b", + "c", + }, + e: ColumnSpecs{ + { + Header: model1.HeaderColumn{ + Name: "a", + }, + }, + { + Header: model1.HeaderColumn{ + Name: "b", + }, + }, + { + Header: model1.HeaderColumn{ + Name: "c", + }, + }, + }, + }, + + "with-spec-plain": { + cols: ColsSpecs{ + "a", + "b:.metadata.name", + "c", + }, + e: ColumnSpecs{ + { + Header: model1.HeaderColumn{ + Name: "a", + }, + }, + { + Header: model1.HeaderColumn{ + Name: "b", + }, + Spec: "{.metadata.name}", + }, + { + Header: model1.HeaderColumn{ + Name: "c", + }, + }, + }, + }, + + "with-spec-fq": { + cols: ColsSpecs{ + "a", + "b:.metadata.name|NW", + "c", + }, + e: ColumnSpecs{ + { + Header: model1.HeaderColumn{ + Name: "a", + }, + }, + { + Header: model1.HeaderColumn{ + Name: "b", + Attrs: model1.Attrs{ + Wide: true, + Capacity: true, + Align: tview.AlignRight, + }, + }, + Spec: "{.metadata.name}", + }, + { + Header: model1.HeaderColumn{ + Name: "c", + }, + }, + }, + }, + + "spec-type-no-wide": { + cols: ColsSpecs{ + "a", + "b:.metadata.name|T", + "c", + }, + e: ColumnSpecs{ + { + Header: model1.HeaderColumn{ + Name: "a", + }, + }, + { + Header: model1.HeaderColumn{ + Name: "b", + Attrs: model1.Attrs{ + Time: true, + }, + }, + Spec: "{.metadata.name}", + }, + { + Header: model1.HeaderColumn{ + Name: "c", + }, + }, + }, + }, + + "plain-wide": { + cols: ColsSpecs{ + "a", + "b|W", + "c", + }, + e: ColumnSpecs{ + { + Header: model1.HeaderColumn{ + Name: "a", + }, + }, + { + Header: model1.HeaderColumn{ + Name: "b", + Attrs: model1.Attrs{Wide: true}, + }, + }, + { + Header: model1.HeaderColumn{ + Name: "c", + }, + }, + }, + }, + + "no-spec-kind-wide": { + cols: ColsSpecs{ + "a", + "b|NW", + "c", + }, + e: ColumnSpecs{ + { + Header: model1.HeaderColumn{ + Name: "a", + }, + }, + { + Header: model1.HeaderColumn{ + Name: "b", + Attrs: model1.Attrs{ + Align: tview.AlignRight, + Capacity: true, + Wide: true, + }, + }, + }, + { + Header: model1.HeaderColumn{ + Name: "c", + }, + }, + }, + }, + + "toast-spec": { + cols: ColsSpecs{ + "a", + "b:{{crap.bozo}}|NW", + "c", + }, + err: errors.New(`unexpected path string, expected a 'name1.name2' or '.name1.name2' or '{name1.name2}' or '{.name1.name2}'`), + }, + + "no-spec": { + cols: ColsSpecs{ + "a", + "b|NW", + "c", + }, + e: ColumnSpecs{ + { + Header: model1.HeaderColumn{ + Name: "a", + }, + }, + { + Header: model1.HeaderColumn{ + Name: "b", + Attrs: model1.Attrs{Align: tview.AlignRight, Capacity: true, Wide: true}, + }, + }, + { + Header: model1.HeaderColumn{ + Name: "c", + }, + }, + }, + }, + } + + for k, u := range uu { + t.Run(k, func(t *testing.T) { + cols, err := u.cols.parseSpecs() + assert.Equal(t, u.err, err) + assert.Equal(t, u.e, cols) + }) + } +} diff --git a/internal/render/dir.go b/internal/render/dir.go index 8e076d4e..cfaa5e14 100644 --- a/internal/render/dir.go +++ b/internal/render/dir.go @@ -7,6 +7,7 @@ import ( "fmt" "os" + "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/model1" "github.com/derailed/tcell/v2" "k8s.io/apimachinery/pkg/runtime" @@ -28,6 +29,8 @@ func (Dir) ColorerFunc() model1.ColorerFunc { } } +func (Dir) SetViewSetting(*config.ViewSetting) {} + // Header returns a header row. func (Dir) Header(ns string) model1.Header { return model1.Header{ diff --git a/internal/render/dp.go b/internal/render/dp.go index 1444eeb9..014f0bbc 100644 --- a/internal/render/dp.go +++ b/internal/render/dp.go @@ -42,27 +42,49 @@ func (d Deployment) ColorerFunc() model1.ColorerFunc { } // Header returns a header row. -func (Deployment) Header(ns string) model1.Header { +func (d Deployment) Header(_ string) model1.Header { + return d.doHeader(d.defaultHeader()) +} + +func (Deployment) defaultHeader() model1.Header { return model1.Header{ model1.HeaderColumn{Name: "NAMESPACE"}, model1.HeaderColumn{Name: "NAME"}, - model1.HeaderColumn{Name: "VS", VS: true}, - model1.HeaderColumn{Name: "READY", Align: tview.AlignRight}, - model1.HeaderColumn{Name: "UP-TO-DATE", Align: tview.AlignRight}, - model1.HeaderColumn{Name: "AVAILABLE", Align: tview.AlignRight}, - model1.HeaderColumn{Name: "LABELS", Wide: true}, - model1.HeaderColumn{Name: "VALID", Wide: true}, - model1.HeaderColumn{Name: "AGE", Time: true}, + model1.HeaderColumn{Name: "VS", Attrs: model1.Attrs{VS: true}}, + model1.HeaderColumn{Name: "READY", Attrs: model1.Attrs{Align: tview.AlignRight}}, + model1.HeaderColumn{Name: "UP-TO-DATE", Attrs: model1.Attrs{Align: tview.AlignRight}}, + model1.HeaderColumn{Name: "AVAILABLE", Attrs: model1.Attrs{Align: tview.AlignRight}}, + model1.HeaderColumn{Name: "LABELS", Attrs: model1.Attrs{Wide: true}}, + model1.HeaderColumn{Name: "VALID", Attrs: model1.Attrs{Wide: true}}, + model1.HeaderColumn{Name: "AGE", Attrs: model1.Attrs{Time: true}}, } } // Render renders a K8s resource to screen. -func (d Deployment) Render(o interface{}, ns string, r *model1.Row) error { +func (d Deployment) Render(o interface{}, ns string, row *model1.Row) error { raw, ok := o.(*unstructured.Unstructured) if !ok { return fmt.Errorf("expected Deployment, but got %T", o) } + if err := d.defaultRow(raw, row); err != nil { + return err + } + if d.specs.isEmpty() { + return nil + } + // !BOZO!! Call header 2 times + cols, err := d.specs.realize(raw, d.defaultHeader(), row) + if err != nil { + return err + } + cols.hydrateRow(row) + + return nil +} + +// Render renders a K8s resource to screen. +func (d Deployment) defaultRow(raw *unstructured.Unstructured, r *model1.Row) error { var dp appsv1.Deployment err := runtime.DefaultUnstructuredConverter.FromUnstructured(raw.Object, &dp) if err != nil { diff --git a/internal/render/ds.go b/internal/render/ds.go index b3f047aa..433b2a00 100644 --- a/internal/render/ds.go +++ b/internal/render/ds.go @@ -21,28 +21,52 @@ type DaemonSet struct { } // Header returns a header row. -func (DaemonSet) Header(ns string) model1.Header { +func (d DaemonSet) Header(_ string) model1.Header { + return d.doHeader(d.defaultHeader()) +} + +// Header returns a header row. +func (DaemonSet) defaultHeader() model1.Header { return model1.Header{ model1.HeaderColumn{Name: "NAMESPACE"}, model1.HeaderColumn{Name: "NAME"}, - model1.HeaderColumn{Name: "VS", VS: true}, - model1.HeaderColumn{Name: "DESIRED", Align: tview.AlignRight}, - model1.HeaderColumn{Name: "CURRENT", Align: tview.AlignRight}, - model1.HeaderColumn{Name: "READY", Align: tview.AlignRight}, - model1.HeaderColumn{Name: "UP-TO-DATE", Align: tview.AlignRight}, - model1.HeaderColumn{Name: "AVAILABLE", Align: tview.AlignRight}, - model1.HeaderColumn{Name: "LABELS", Wide: true}, - model1.HeaderColumn{Name: "VALID", Wide: true}, - model1.HeaderColumn{Name: "AGE", Time: true}, + model1.HeaderColumn{Name: "VS", Attrs: model1.Attrs{VS: true}}, + model1.HeaderColumn{Name: "DESIRED", Attrs: model1.Attrs{Align: tview.AlignRight}}, + model1.HeaderColumn{Name: "CURRENT", Attrs: model1.Attrs{Align: tview.AlignRight}}, + model1.HeaderColumn{Name: "READY", Attrs: model1.Attrs{Align: tview.AlignRight}}, + model1.HeaderColumn{Name: "UP-TO-DATE", Attrs: model1.Attrs{Align: tview.AlignRight}}, + model1.HeaderColumn{Name: "AVAILABLE", Attrs: model1.Attrs{Align: tview.AlignRight}}, + model1.HeaderColumn{Name: "LABELS", Attrs: model1.Attrs{Wide: true}}, + model1.HeaderColumn{Name: "VALID", Attrs: model1.Attrs{Wide: true}}, + model1.HeaderColumn{Name: "AGE", Attrs: model1.Attrs{Time: true}}, } } // Render renders a K8s resource to screen. -func (d DaemonSet) Render(o interface{}, ns string, r *model1.Row) error { +func (d DaemonSet) Render(o interface{}, ns string, row *model1.Row) error { raw, ok := o.(*unstructured.Unstructured) if !ok { - return fmt.Errorf("expected DaemonSet, but got %T", o) + return fmt.Errorf("expected Deployment, but got %T", o) } + if err := d.defaultRow(raw, row); err != nil { + return err + } + if d.specs.isEmpty() { + return nil + } + + // !BOZO!! Call header 2 times + cols, err := d.specs.realize(raw, d.defaultHeader(), row) + if err != nil { + return err + } + cols.hydrateRow(row) + + return nil +} + +// Render renders a K8s resource to screen. +func (d DaemonSet) defaultRow(raw *unstructured.Unstructured, r *model1.Row) error { var ds appsv1.DaemonSet err := runtime.DefaultUnstructuredConverter.FromUnstructured(raw.Object, &ds) if err != nil { diff --git a/internal/render/ep.go b/internal/render/ep.go index 9fa4bcc8..70f404ad 100644 --- a/internal/render/ep.go +++ b/internal/render/ep.go @@ -21,17 +21,38 @@ type Endpoints struct { } // Header returns a header row. -func (Endpoints) Header(ns string) model1.Header { +func (e Endpoints) Header(_ string) model1.Header { + return e.doHeader(e.defaultHeader()) +} + +// Header returns a header row. +func (Endpoints) defaultHeader() model1.Header { return model1.Header{ model1.HeaderColumn{Name: "NAMESPACE"}, model1.HeaderColumn{Name: "NAME"}, model1.HeaderColumn{Name: "ENDPOINTS"}, - model1.HeaderColumn{Name: "AGE", Time: true}, + model1.HeaderColumn{Name: "AGE", Attrs: model1.Attrs{Time: true}}, } } // Render renders a K8s resource to screen. -func (e Endpoints) Render(o interface{}, ns string, r *model1.Row) error { +func (e Endpoints) Render(o interface{}, ns string, row *model1.Row) error { + if err := e.defaultRow(o, ns, row); err != nil { + return err + } + if e.specs.isEmpty() { + return nil + } + cols, err := e.specs.realize(o.(*unstructured.Unstructured), e.defaultHeader(), row) + if err != nil { + return err + } + cols.hydrateRow(row) + + return nil +} + +func (e Endpoints) defaultRow(o interface{}, ns string, r *model1.Row) error { raw, ok := o.(*unstructured.Unstructured) if !ok { return fmt.Errorf("expected Endpoints, but got %T", o) diff --git a/internal/render/ev.go b/internal/render/ev.go index 28e04f79..3157251c 100644 --- a/internal/render/ev.go +++ b/internal/render/ev.go @@ -15,7 +15,7 @@ import ( // Event renders a K8s Event to screen. type Event struct { - Generic + Table } func (*Event) IsGeneric() bool { @@ -41,13 +41,15 @@ var ageCols = map[string]struct{}{ var wideCols = map[string]struct{}{ "SUBOBJECT": {}, + "COUNT": {}, "SOURCE": {}, "FIRST SEEN": {}, "NAME": {}, "MESSAGE": {}, } -func (e *Event) Header(ns string) model1.Header { +// Header returns a header row. +func (e *Event) Header(_ string) model1.Header { if e.table == nil { return model1.Header{} } @@ -78,9 +80,6 @@ func (e *Event) Render(o interface{}, ns string, r *model1.Row) error { return err } - if !ok { - return fmt.Errorf("expecting row 0 to be a string but got %T", row.Cells[0]) - } r.ID = client.FQN(nns, name) r.Fields = make(model1.Fields, 0, len(e.Header(ns))) r.Fields = append(r.Fields, nns) diff --git a/internal/render/generic.go b/internal/render/generic.go index 56f55239..bb2a5778 100644 --- a/internal/render/generic.go +++ b/internal/render/generic.go @@ -4,141 +4,65 @@ package render import ( - "encoding/json" - "errors" "fmt" - "strings" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/model1" - "github.com/rs/zerolog/log" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" ) -const ageTableCol = "Age" - -// Generic renders a generic resource to screen. +// Generic renders a K8s generic resource to screen. type Generic struct { Base - table *metav1.Table - header model1.Header - ageIndex int -} - -func (*Generic) IsGeneric() bool { - return true -} - -// SetTable sets the tabular resource. -func (g *Generic) SetTable(ns string, t *metav1.Table) { - g.table = t - g.header = g.Header(ns) -} - -// ColorerFunc colors a resource row. -func (*Generic) ColorerFunc() model1.ColorerFunc { - return model1.DefaultColorer } // Header returns a header row. -func (g *Generic) Header(ns string) model1.Header { - if g.header != nil { - return g.header - } - if g.table == nil { - return model1.Header{} - } - h := make(model1.Header, 0, len(g.table.ColumnDefinitions)) - if !client.IsClusterScoped(ns) { - h = append(h, model1.HeaderColumn{Name: "NAMESPACE"}) - } - for i, c := range g.table.ColumnDefinitions { - if c.Name == ageTableCol { - g.ageIndex = i - continue - } - h = append(h, model1.HeaderColumn{Name: strings.ToUpper(c.Name)}) - } - if g.ageIndex > 0 { - h = append(h, model1.HeaderColumn{Name: "AGE", Time: true}) - } +func (m Generic) Header(_ string) model1.Header { + return m.doHeader(m.defaultHeader()) +} - return h +// Header returns a header rbw. +func (Generic) defaultHeader() model1.Header { + return model1.Header{ + model1.HeaderColumn{Name: "NAMESPACE"}, + model1.HeaderColumn{Name: "NAME"}, + model1.HeaderColumn{Name: "VALID", Attrs: model1.Attrs{Wide: true}}, + model1.HeaderColumn{Name: "AGE", Attrs: model1.Attrs{Time: true}}, + } } // Render renders a K8s resource to screen. -func (g *Generic) Render(o interface{}, ns string, r *model1.Row) error { - row, ok := o.(metav1.TableRow) +func (m Generic) Render(o interface{}, ns string, row *model1.Row) error { + raw, ok := o.(*unstructured.Unstructured) if !ok { - return fmt.Errorf("expecting a TableRow but got %T", o) + return fmt.Errorf("expected *Unstructured, but got %T", o) } - nns, name, err := resourceNS(row.Object.Raw) + + if err := m.defaultRow(raw, row); err != nil { + return err + } + if m.specs.isEmpty() { + return nil + } + + cols, err := m.specs.realize(o.(*unstructured.Unstructured), m.defaultHeader(), row) if err != nil { return err } - - if !ok { - return fmt.Errorf("expecting row 0 to be a string but got %T", row.Cells[0]) - } - r.ID = client.FQN(nns, name) - r.Fields = make(model1.Fields, 0, len(g.Header(ns))) - if !client.IsClusterScoped(ns) { - r.Fields = append(r.Fields, nns) - } - var duration interface{} - for i, c := range row.Cells { - if g.ageIndex > 0 && i == g.ageIndex { - duration = c - continue - } - if c == nil { - r.Fields = append(r.Fields, Blank) - continue - } - r.Fields = append(r.Fields, fmt.Sprintf("%v", c)) - } - if d, ok := duration.(string); ok { - r.Fields = append(r.Fields, d) - } else if g.ageIndex > 0 { - log.Warn().Msgf("No Duration detected on age field") - r.Fields = append(r.Fields, NAValue) - } + cols.hydrateRow(row) return nil } -// ---------------------------------------------------------------------------- -// Helpers... - -func resourceNS(raw []byte) (string, string, error) { - var obj map[string]interface{} - var ns, name string - err := json.Unmarshal(raw, &obj) - if err != nil { - return ns, name, err +// Render renders a K8s resource to screen. +func (Generic) defaultRow(raw *unstructured.Unstructured, r *model1.Row) error { + r.ID = client.FQN(raw.GetNamespace(), raw.GetName()) + r.Fields = model1.Fields{ + raw.GetNamespace(), + raw.GetName(), + "", + ToAge(raw.GetCreationTimestamp()), } - meta, ok := obj["metadata"].(map[string]interface{}) - if !ok { - return ns, name, errors.New("no metadata found on generic resource") - } - ina, ok := meta["name"] - if !ok { - return ns, name, errors.New("unable to extract resource name") - } - name, ok = ina.(string) - if !ok { - return ns, name, fmt.Errorf("expecting name string type but got %T", ns) - } - - ins, ok := meta["namespace"] - if !ok { - return client.ClusterScope, name, nil - } - - ns, ok = ins.(string) - if !ok { - return ns, name, fmt.Errorf("expecting namespace string type but got %T", ns) - } - return ns, name, nil + return nil } diff --git a/internal/render/helm/chart.go b/internal/render/helm/chart.go index 2225118e..fcfb1450 100644 --- a/internal/render/helm/chart.go +++ b/internal/render/helm/chart.go @@ -8,6 +8,7 @@ import ( "strconv" "github.com/derailed/k9s/internal/client" + "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/model1" "github.com/derailed/k9s/internal/render" "helm.sh/helm/v3/pkg/release" @@ -24,6 +25,8 @@ func (Chart) IsGeneric() bool { return false } +func (Chart) SetViewSetting(*config.ViewSetting) {} + // ColorerFunc colors a resource row. func (Chart) ColorerFunc() model1.ColorerFunc { return model1.DefaultColorer @@ -38,8 +41,8 @@ func (Chart) Header(_ string) model1.Header { model1.HeaderColumn{Name: "STATUS"}, model1.HeaderColumn{Name: "CHART"}, model1.HeaderColumn{Name: "APP VERSION"}, - model1.HeaderColumn{Name: "VALID", Wide: true}, - model1.HeaderColumn{Name: "AGE", Time: true}, + model1.HeaderColumn{Name: "VALID", Attrs: model1.Attrs{Wide: true}}, + model1.HeaderColumn{Name: "AGE", Attrs: model1.Attrs{Time: true}}, } } diff --git a/internal/render/helm/history.go b/internal/render/helm/history.go index cf0f118d..c558e660 100644 --- a/internal/render/helm/history.go +++ b/internal/render/helm/history.go @@ -9,6 +9,7 @@ import ( "strconv" "github.com/derailed/k9s/internal/client" + "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/model1" "github.com/derailed/k9s/internal/render" ) @@ -21,6 +22,8 @@ func (History) Healthy(ctx context.Context, o interface{}) error { return nil } +func (History) SetViewSetting(*config.ViewSetting) {} + // IsGeneric identifies a generic handler. func (History) IsGeneric() bool { return false @@ -39,7 +42,7 @@ func (History) Header(_ string) model1.Header { model1.HeaderColumn{Name: "CHART"}, model1.HeaderColumn{Name: "APP VERSION"}, model1.HeaderColumn{Name: "DESCRIPTION"}, - model1.HeaderColumn{Name: "VALID", Wide: true}, + model1.HeaderColumn{Name: "VALID", Attrs: model1.Attrs{Wide: true}}, } } diff --git a/internal/render/helpers.go b/internal/render/helpers.go index 522a6fdf..92dce48a 100644 --- a/internal/render/helpers.go +++ b/internal/render/helpers.go @@ -223,7 +223,7 @@ func mapToStr(m map[string]string) string { for i, k := range kk { bb = append(bb, k+"="+m[k]...) if i < len(kk)-1 { - bb = append(bb, ' ') + bb = append(bb, ',') } } @@ -292,16 +292,6 @@ func strPtrToStr(s *string) string { return *s } -// // Check if string is in a string list. -// func in(ll []string, s string) bool { -// for _, l := range ll { -// if l == s { -// return true -// } -// } -// return false -// } - // 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 { @@ -314,30 +304,3 @@ func Pad(s string, width int) string { return s + strings.Repeat(" ", width-len(s)) } - -// // Converts labels string to map. -// func labelize(labels string) map[string]string { -// ll := strings.Split(labels, ",") -// data := make(map[string]string, len(ll)) - -// for _, l := range ll { -// tokens := strings.Split(l, "=") -// if len(tokens) == 2 { -// data[tokens[0]] = tokens[1] -// } -// } - -// return data -// } - -// func sortLabels(m map[string]string) (keys, vals []string) { -// for k := range m { -// keys = append(keys, k) -// } -// sort.Strings(keys) -// for _, k := range keys { -// vals = append(vals, m[k]) -// } - -// return -// } diff --git a/internal/render/helpers_test.go b/internal/render/helpers_test.go index d0ab311e..355d4e6e 100644 --- a/internal/render/helpers_test.go +++ b/internal/render/helpers_test.go @@ -20,7 +20,7 @@ import ( ) func TestTableGenericHydrate(t *testing.T) { - raw := raw(t, "p1") + raw := load(t, "p1") tt := metav1beta1.Table{ ColumnDefinitions: []metav1beta1.TableColumnDefinition{ {Name: "c1"}, @@ -29,21 +29,21 @@ func TestTableGenericHydrate(t *testing.T) { Rows: []metav1beta1.TableRow{ { Cells: []interface{}{"fred", 10}, - Object: runtime.RawExtension{Raw: raw}, + Object: runtime.RawExtension{Object: raw}, }, { Cells: []interface{}{"blee", 20}, - Object: runtime.RawExtension{Raw: raw}, + Object: runtime.RawExtension{Object: raw}, }, }, } rr := make([]model1.Row, 2) - var re Generic + var re Table re.SetTable("blee", &tt) assert.Nil(t, model1.GenericHydrate("blee", &tt, rr, &re)) assert.Equal(t, 2, len(rr)) - assert.Equal(t, 3, len(rr[0].Fields)) + assert.Equal(t, 2, len(rr[0].Fields)) } func TestTableHydrate(t *testing.T) { @@ -52,7 +52,7 @@ func TestTableHydrate(t *testing.T) { } rr := make([]model1.Row, 1) - assert.Nil(t, model1.Hydrate("blee", oo, rr, Pod{})) + assert.Nil(t, model1.Hydrate("blee", oo, rr, NewPod())) assert.Equal(t, 1, len(rr)) assert.Equal(t, 25, len(rr[0].Fields)) } @@ -313,7 +313,7 @@ func TestMapToStr(t *testing.T) { i map[string]string e string }{ - {map[string]string{"blee": "duh", "aa": "bb"}, "aa=bb blee=duh"}, + {map[string]string{"blee": "duh", "aa": "bb"}, "aa=bb,blee=duh"}, {map[string]string{}, ""}, } for _, u := range uu { @@ -438,9 +438,3 @@ func load(t *testing.T, n string) *unstructured.Unstructured { assert.Nil(t, err) return &o } - -func raw(t *testing.T, n string) []byte { - raw, err := os.ReadFile(fmt.Sprintf("testdata/%s.json", n)) - assert.Nil(t, err) - return raw -} diff --git a/internal/render/hpa.go b/internal/render/hpa.go index e12df95d..44400322 100644 --- a/internal/render/hpa.go +++ b/internal/render/hpa.go @@ -13,7 +13,7 @@ import ( // HorizontalPodAutoscaler renders a K8s HorizontalPodAutoscaler to screen. type HorizontalPodAutoscaler struct { - Generic + Table } // ColorerFunc colors a resource row. diff --git a/internal/render/hpa_test.go b/internal/render/hpa_test.go index 21bb0855..21d5e7b3 100644 --- a/internal/render/hpa_test.go +++ b/internal/render/hpa_test.go @@ -18,10 +18,10 @@ func TestHorizontalPodAutoscalerColorer(t *testing.T) { model1.HeaderColumn{Name: "NAME"}, model1.HeaderColumn{Name: "REFERENCE"}, model1.HeaderColumn{Name: "TARGETS%"}, - model1.HeaderColumn{Name: "MINPODS", Align: tview.AlignRight}, - model1.HeaderColumn{Name: "MAXPODS", Align: tview.AlignRight}, - model1.HeaderColumn{Name: "REPLICAS", Align: tview.AlignRight}, - model1.HeaderColumn{Name: "AGE", Time: true}, + model1.HeaderColumn{Name: "MINPODS", Attrs: model1.Attrs{Align: tview.AlignRight}}, + model1.HeaderColumn{Name: "MAXPODS", Attrs: model1.Attrs{Align: tview.AlignRight}}, + model1.HeaderColumn{Name: "REPLICAS", Attrs: model1.Attrs{Align: tview.AlignRight}}, + model1.HeaderColumn{Name: "AGE", Attrs: model1.Attrs{Time: true}}, } uu := map[string]struct { diff --git a/internal/render/job.go b/internal/render/job.go index 7b25c49b..d4be14e2 100644 --- a/internal/render/job.go +++ b/internal/render/job.go @@ -7,7 +7,6 @@ import ( "fmt" "strconv" "strings" - "time" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/model1" @@ -24,27 +23,48 @@ type Job struct { } // Header returns a header row. -func (Job) Header(ns string) model1.Header { +func (j Job) Header(_ string) model1.Header { + return j.doHeader(j.defaultHeader()) +} + +func (Job) defaultHeader() model1.Header { return model1.Header{ model1.HeaderColumn{Name: "NAMESPACE"}, model1.HeaderColumn{Name: "NAME"}, - model1.HeaderColumn{Name: "VS", VS: true}, + model1.HeaderColumn{Name: "VS", Attrs: model1.Attrs{VS: true}}, model1.HeaderColumn{Name: "COMPLETIONS"}, model1.HeaderColumn{Name: "DURATION"}, - model1.HeaderColumn{Name: "SELECTOR", Wide: true}, - model1.HeaderColumn{Name: "CONTAINERS", Wide: true}, - model1.HeaderColumn{Name: "IMAGES", Wide: true}, - model1.HeaderColumn{Name: "VALID", Wide: true}, - model1.HeaderColumn{Name: "AGE", Time: true}, + model1.HeaderColumn{Name: "SELECTOR", Attrs: model1.Attrs{Wide: true}}, + model1.HeaderColumn{Name: "CONTAINERS", Attrs: model1.Attrs{Wide: true}}, + model1.HeaderColumn{Name: "IMAGES", Attrs: model1.Attrs{Wide: true}}, + model1.HeaderColumn{Name: "VALID", Attrs: model1.Attrs{Wide: true}}, + model1.HeaderColumn{Name: "AGE", Attrs: model1.Attrs{Time: true}}, } } // Render renders a K8s resource to screen. -func (j Job) Render(o interface{}, ns string, r *model1.Row) error { +func (j Job) Render(o interface{}, ns string, row *model1.Row) error { raw, ok := o.(*unstructured.Unstructured) if !ok { return fmt.Errorf("expected Job, but got %T", o) } + if err := j.defaultRow(raw, row); err != nil { + return err + } + if j.specs.isEmpty() { + return nil + } + + cols, err := j.specs.realize(raw, j.defaultHeader(), row) + if err != nil { + return err + } + cols.hydrateRow(row) + + return nil +} + +func (j Job) defaultRow(raw *unstructured.Unstructured, r *model1.Row) error { var job batchv1.Job err := runtime.DefaultUnstructuredConverter.FromUnstructured(raw.Object, &job) if err != nil { @@ -128,17 +148,9 @@ func toCompletion(spec batchv1.JobSpec, status batchv1.JobStatus) (s string) { } func toDuration(status batchv1.JobStatus) string { - if status.StartTime == nil { + if status.StartTime == nil || status.CompletionTime == nil { return MissingValue } - var d time.Duration - switch { - case status.CompletionTime == nil: - d = time.Since(status.StartTime.Time) - default: - d = status.CompletionTime.Sub(status.StartTime.Time) - } - - return duration.HumanDuration(d) + return duration.HumanDuration(status.CompletionTime.Sub(status.StartTime.Time)) } diff --git a/internal/render/node.go b/internal/render/node.go index 353816de..94e2087a 100644 --- a/internal/render/node.go +++ b/internal/render/node.go @@ -31,44 +31,61 @@ type Node struct { } // Header returns a header row. -func (Node) Header(string) model1.Header { +func (n Node) Header(_ string) model1.Header { + return n.doHeader(n.defaultHeader()) +} + +func (Node) defaultHeader() model1.Header { return model1.Header{ model1.HeaderColumn{Name: "NAME"}, model1.HeaderColumn{Name: "STATUS"}, model1.HeaderColumn{Name: "ROLE"}, - model1.HeaderColumn{Name: "ARCH", Wide: true}, + model1.HeaderColumn{Name: "ARCH", Attrs: model1.Attrs{Wide: true}}, model1.HeaderColumn{Name: "TAINTS"}, model1.HeaderColumn{Name: "VERSION"}, - model1.HeaderColumn{Name: "OS-IMAGE", Wide: true}, - model1.HeaderColumn{Name: "KERNEL", Wide: true}, - model1.HeaderColumn{Name: "INTERNAL-IP", Wide: true}, - model1.HeaderColumn{Name: "EXTERNAL-IP", Wide: true}, - model1.HeaderColumn{Name: "PODS", Align: tview.AlignRight}, - model1.HeaderColumn{Name: "CPU", Align: tview.AlignRight, MX: true}, - model1.HeaderColumn{Name: "MEM", Align: tview.AlignRight, MX: true}, - model1.HeaderColumn{Name: "%CPU", Align: tview.AlignRight, MX: true}, - model1.HeaderColumn{Name: "%MEM", Align: tview.AlignRight, MX: true}, - model1.HeaderColumn{Name: "CPU/A", Align: tview.AlignRight, MX: true}, - model1.HeaderColumn{Name: "MEM/A", Align: tview.AlignRight, MX: true}, - model1.HeaderColumn{Name: "LABELS", Wide: true}, - model1.HeaderColumn{Name: "VALID", Wide: true}, - model1.HeaderColumn{Name: "AGE", Time: true}, + model1.HeaderColumn{Name: "OS-IMAGE", Attrs: model1.Attrs{Wide: true}}, + model1.HeaderColumn{Name: "KERNEL", Attrs: model1.Attrs{Wide: true}}, + model1.HeaderColumn{Name: "INTERNAL-IP", Attrs: model1.Attrs{Wide: true}}, + model1.HeaderColumn{Name: "EXTERNAL-IP", Attrs: model1.Attrs{Wide: true}}, + model1.HeaderColumn{Name: "PODS", Attrs: model1.Attrs{Align: tview.AlignRight}}, + model1.HeaderColumn{Name: "CPU", Attrs: model1.Attrs{Align: tview.AlignRight, MX: true}}, + model1.HeaderColumn{Name: "MEM", Attrs: model1.Attrs{Align: tview.AlignRight, MX: true}}, + model1.HeaderColumn{Name: "%CPU", Attrs: model1.Attrs{Align: tview.AlignRight, MX: true}}, + model1.HeaderColumn{Name: "%MEM", Attrs: model1.Attrs{Align: tview.AlignRight, MX: true}}, + model1.HeaderColumn{Name: "CPU/A", Attrs: model1.Attrs{Align: tview.AlignRight, MX: true}}, + model1.HeaderColumn{Name: "MEM/A", Attrs: model1.Attrs{Align: tview.AlignRight, MX: true}}, + model1.HeaderColumn{Name: "LABELS", Attrs: model1.Attrs{Wide: true}}, + model1.HeaderColumn{Name: "VALID", Attrs: model1.Attrs{Wide: true}}, + model1.HeaderColumn{Name: "AGE", Attrs: model1.Attrs{Time: true}}, } } // Render renders a K8s resource to screen. -func (n Node) Render(o interface{}, ns string, r *model1.Row) error { - oo, ok := o.(*NodeWithMetrics) +func (n Node) Render(o interface{}, ns string, row *model1.Row) error { + nwm, ok := o.(*NodeWithMetrics) if !ok { - return fmt.Errorf("expected *NodeAndMetrics, but got %T", o) + return fmt.Errorf("expected PodWithMetrics, but got %T", o) } - meta, ok := oo.Raw.Object["metadata"].(map[string]interface{}) - if !ok { - return fmt.Errorf("unable to extract meta") + if err := n.defaultRow(nwm, row); err != nil { + return err } - na := extractMetaField(meta, "name") + if n.specs.isEmpty() { + return nil + } + + cols, err := n.specs.realize(nwm.Raw, n.defaultHeader(), row) + if err != nil { + return err + } + cols.hydrateRow(row) + + return nil +} + +// Render renders a K8s resource to screen. +func (n Node) defaultRow(nwm *NodeWithMetrics, r *model1.Row) error { var no v1.Node - err := runtime.DefaultUnstructuredConverter.FromUnstructured(oo.Raw.Object, &no) + err := runtime.DefaultUnstructuredConverter.FromUnstructured(nwm.Raw.Object, &no) if err != nil { return err } @@ -76,7 +93,7 @@ func (n Node) Render(o interface{}, ns string, r *model1.Row) error { iIP, eIP := getIPs(no.Status.Addresses) iIP, eIP = missing(iIP), missing(eIP) - c, a := gatherNodeMX(&no, oo.MX) + c, a := gatherNodeMX(&no, nwm.MX) statuses := make(sort.StringSlice, 10) status(no.Status.Conditions, no.Spec.Unschedulable, statuses) sort.Sort(statuses) @@ -84,11 +101,11 @@ func (n Node) Render(o interface{}, ns string, r *model1.Row) error { nodeRoles(&no, roles) sort.Sort(roles) - podCount := strconv.Itoa(oo.PodCount) - if pc := oo.PodCount; pc == -1 { + podCount := strconv.Itoa(nwm.PodCount) + if pc := nwm.PodCount; pc == -1 { podCount = NAValue } - r.ID = client.FQN("", na) + r.ID = client.FQN("", no.Name) r.Fields = model1.Fields{ no.Name, join(statuses, ","), diff --git a/internal/render/np.go b/internal/render/np.go index 8f7bb242..364261e8 100644 --- a/internal/render/np.go +++ b/internal/render/np.go @@ -21,28 +21,50 @@ type NetworkPolicy struct { } // Header returns a header row. -func (NetworkPolicy) Header(ns string) model1.Header { +func (p NetworkPolicy) Header(_ string) model1.Header { + return p.doHeader(p.defaultHeader()) +} + +func (NetworkPolicy) defaultHeader() model1.Header { return model1.Header{ model1.HeaderColumn{Name: "NAMESPACE"}, model1.HeaderColumn{Name: "NAME"}, - model1.HeaderColumn{Name: "ING-SELECTOR", Wide: true}, - model1.HeaderColumn{Name: "ING-PORTS"}, - model1.HeaderColumn{Name: "ING-BLOCK"}, - model1.HeaderColumn{Name: "EGR-SELECTOR", Wide: true}, - model1.HeaderColumn{Name: "EGR-PORTS"}, - model1.HeaderColumn{Name: "EGR-BLOCK"}, - model1.HeaderColumn{Name: "LABELS", Wide: true}, - model1.HeaderColumn{Name: "VALID", Wide: true}, - model1.HeaderColumn{Name: "AGE", Time: true}, + model1.HeaderColumn{Name: "POD-SELECTOR"}, + model1.HeaderColumn{Name: "ING-SELECTOR", Attrs: model1.Attrs{Wide: true}}, + model1.HeaderColumn{Name: "ING-PORTS", Attrs: model1.Attrs{Wide: true}}, + model1.HeaderColumn{Name: "ING-BLOCK", Attrs: model1.Attrs{Wide: true}}, + model1.HeaderColumn{Name: "EGR-SELECTOR", Attrs: model1.Attrs{Wide: true}}, + model1.HeaderColumn{Name: "EGR-PORTS", Attrs: model1.Attrs{Wide: true}}, + model1.HeaderColumn{Name: "EGR-BLOCK", Attrs: model1.Attrs{Wide: true}}, + model1.HeaderColumn{Name: "LABELS", Attrs: model1.Attrs{Wide: true}}, + model1.HeaderColumn{Name: "VALID", Attrs: model1.Attrs{Wide: true}}, + model1.HeaderColumn{Name: "AGE", Attrs: model1.Attrs{Time: true}}, } } // Render renders a K8s resource to screen. -func (n NetworkPolicy) Render(o interface{}, ns string, r *model1.Row) error { +func (p NetworkPolicy) Render(o interface{}, ns string, row *model1.Row) error { raw, ok := o.(*unstructured.Unstructured) if !ok { return fmt.Errorf("expected NetworkPolicy, but got %T", o) } + if err := p.defaultRow(raw, row); err != nil { + return err + } + if p.specs.isEmpty() { + return nil + } + + cols, err := p.specs.realize(raw, p.defaultHeader(), row) + if err != nil { + return err + } + cols.hydrateRow(row) + + return nil +} + +func (n NetworkPolicy) defaultRow(raw *unstructured.Unstructured, r *model1.Row) error { var np netv1.NetworkPolicy err := runtime.DefaultUnstructuredConverter.FromUnstructured(raw.Object, &np) if err != nil { @@ -52,10 +74,18 @@ func (n NetworkPolicy) Render(o interface{}, ns string, r *model1.Row) error { ip, is, ib := ingress(np.Spec.Ingress) ep, es, eb := egress(np.Spec.Egress) + var podSel string + if len(np.Spec.PodSelector.MatchLabels) > 0 { + podSel = mapToStr(np.Spec.PodSelector.MatchLabels) + } + if len(np.Spec.PodSelector.MatchExpressions) > 0 { + podSel += "::" + expToStr(np.Spec.PodSelector.MatchExpressions) + } r.ID = client.MetaFQN(np.ObjectMeta) r.Fields = model1.Fields{ np.Namespace, np.Name, + podSel, is, ip, ib, diff --git a/internal/render/np_test.go b/internal/render/np_test.go index bd371df4..28942782 100644 --- a/internal/render/np_test.go +++ b/internal/render/np_test.go @@ -17,5 +17,5 @@ func TestNetworkPolicyRender(t *testing.T) { assert.NoError(t, c.Render(load(t, "np"), "", &r)) assert.Equal(t, "default/fred", r.ID) - assert.Equal(t, model1.Fields{"default", "fred", "ns:app=blee,po:app=fred", "TCP:6379", "172.17.0.0/16[172.17.1.0/24,172.17.3.0/24...]", "", "TCP:5978", "10.0.0.0/24"}, r.Fields[:8]) + assert.Equal(t, model1.Fields{"default", "fred", "app=nginx", "ns:app=blee,po:app=fred", "TCP:6379", "172.17.0.0/16[172.17.1.0/24,172.17.3.0/24...]", "", "TCP:5978", "10.0.0.0/24"}, r.Fields[:9]) } diff --git a/internal/render/ns.go b/internal/render/ns.go index 4f9ecf81..6438c945 100644 --- a/internal/render/ns.go +++ b/internal/render/ns.go @@ -39,23 +39,44 @@ func (n Namespace) ColorerFunc() model1.ColorerFunc { } } -// Header returns a header rbw. -func (Namespace) Header(string) model1.Header { +// Header returns a header row. +func (n Namespace) Header(_ string) model1.Header { + return n.doHeader(n.defaultHeader()) +} + +func (Namespace) defaultHeader() model1.Header { return model1.Header{ model1.HeaderColumn{Name: "NAME"}, model1.HeaderColumn{Name: "STATUS"}, - model1.HeaderColumn{Name: "LABELS", Wide: true}, - model1.HeaderColumn{Name: "VALID", Wide: true}, - model1.HeaderColumn{Name: "AGE", Time: true}, + model1.HeaderColumn{Name: "LABELS", Attrs: model1.Attrs{Wide: true}}, + model1.HeaderColumn{Name: "VALID", Attrs: model1.Attrs{Wide: true}}, + model1.HeaderColumn{Name: "AGE", Attrs: model1.Attrs{Time: true}}, } } // Render renders a K8s resource to screen. -func (n Namespace) Render(o interface{}, _ string, r *model1.Row) error { +func (n Namespace) Render(o interface{}, ns string, row *model1.Row) error { raw, ok := o.(*unstructured.Unstructured) if !ok { - return fmt.Errorf("expected Namespace, but got %T", o) + return fmt.Errorf("expected NetworkPolicy, but got %T", o) } + if err := n.defaultRow(raw, row); err != nil { + return err + } + if n.specs.isEmpty() { + return nil + } + + cols, err := n.specs.realize(raw, n.defaultHeader(), row) + if err != nil { + return err + } + cols.hydrateRow(row) + + return nil +} + +func (n Namespace) defaultRow(raw *unstructured.Unstructured, r *model1.Row) error { var ns v1.Namespace err := runtime.DefaultUnstructuredConverter.FromUnstructured(raw.Object, &ns) if err != nil { diff --git a/internal/render/pdb.go b/internal/render/pdb.go index f80fb4ee..f5ff6bc5 100644 --- a/internal/render/pdb.go +++ b/internal/render/pdb.go @@ -22,28 +22,49 @@ type PodDisruptionBudget struct { } // Header returns a header row. -func (PodDisruptionBudget) Header(ns string) model1.Header { +func (p PodDisruptionBudget) Header(_ string) model1.Header { + return p.doHeader(p.defaultHeader()) +} + +func (PodDisruptionBudget) defaultHeader() model1.Header { return model1.Header{ model1.HeaderColumn{Name: "NAMESPACE"}, model1.HeaderColumn{Name: "NAME"}, - model1.HeaderColumn{Name: "MIN AVAILABLE", Align: tview.AlignRight}, - model1.HeaderColumn{Name: "MAX UNAVAILABLE", Align: tview.AlignRight}, - model1.HeaderColumn{Name: "ALLOWED DISRUPTIONS", Align: tview.AlignRight}, - model1.HeaderColumn{Name: "CURRENT", Align: tview.AlignRight}, - model1.HeaderColumn{Name: "DESIRED", Align: tview.AlignRight}, - model1.HeaderColumn{Name: "EXPECTED", Align: tview.AlignRight}, - model1.HeaderColumn{Name: "LABELS", Wide: true}, - model1.HeaderColumn{Name: "VALID", Wide: true}, - model1.HeaderColumn{Name: "AGE", Time: true}, + model1.HeaderColumn{Name: "MIN-AVAILABLE", Attrs: model1.Attrs{Align: tview.AlignRight}}, + model1.HeaderColumn{Name: "MAX-UNAVAILABLE", Attrs: model1.Attrs{Align: tview.AlignRight}}, + model1.HeaderColumn{Name: "ALLOWED-DISRUPTIONS", Attrs: model1.Attrs{Align: tview.AlignRight}}, + model1.HeaderColumn{Name: "CURRENT", Attrs: model1.Attrs{Align: tview.AlignRight}}, + model1.HeaderColumn{Name: "DESIRED", Attrs: model1.Attrs{Align: tview.AlignRight}}, + model1.HeaderColumn{Name: "EXPECTED", Attrs: model1.Attrs{Align: tview.AlignRight}}, + model1.HeaderColumn{Name: "LABELS", Attrs: model1.Attrs{Wide: true}}, + model1.HeaderColumn{Name: "VALID", Attrs: model1.Attrs{Wide: true}}, + model1.HeaderColumn{Name: "AGE", Attrs: model1.Attrs{Time: true}}, } } // Render renders a K8s resource to screen. -func (p PodDisruptionBudget) Render(o interface{}, ns string, r *model1.Row) error { +func (p PodDisruptionBudget) Render(o interface{}, ns string, row *model1.Row) error { raw, ok := o.(*unstructured.Unstructured) if !ok { return fmt.Errorf("expected PodDisruptionBudget, but got %T", o) } + if err := p.defaultRow(raw, row); err != nil { + return err + } + if p.specs.isEmpty() { + return nil + } + + cols, err := p.specs.realize(raw, p.defaultHeader(), row) + if err != nil { + return err + } + cols.hydrateRow(row) + + return nil +} + +func (p PodDisruptionBudget) defaultRow(raw *unstructured.Unstructured, r *model1.Row) error { var pdb v1.PodDisruptionBudget err := runtime.DefaultUnstructuredConverter.FromUnstructured(raw.Object, &pdb) if err != nil { diff --git a/internal/render/pod.go b/internal/render/pod.go index a501cad6..bff3a716 100644 --- a/internal/render/pod.go +++ b/internal/render/pod.go @@ -8,6 +8,8 @@ import ( "strconv" "strings" + "github.com/derailed/k9s/internal/client" + "github.com/derailed/k9s/internal/model1" "github.com/derailed/tcell/v2" "github.com/derailed/tview" v1 "k8s.io/api/core/v1" @@ -17,9 +19,6 @@ import ( "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" mv1beta1 "k8s.io/metrics/pkg/apis/metrics/v1beta1" - - "github.com/derailed/k9s/internal/client" - "github.com/derailed/k9s/internal/model1" ) const ( @@ -49,7 +48,14 @@ const ( // Pod renders a K8s Pod to screen. type Pod struct { - Base + *Base +} + +// NewPod returns a new instance. +func NewPod() *Pod { + return &Pod{ + Base: new(Base), + } } // ColorerFunc colors a resource row. @@ -84,33 +90,37 @@ func (p Pod) ColorerFunc() model1.ColorerFunc { } // Header returns a header row. -func (p Pod) Header(ns string) model1.Header { +func (p Pod) Header(_ string) model1.Header { + return p.doHeader(p.defaultHeader()) +} + +func (Pod) defaultHeader() model1.Header { return model1.Header{ model1.HeaderColumn{Name: "NAMESPACE"}, model1.HeaderColumn{Name: "NAME"}, - model1.HeaderColumn{Name: "VS", VS: true}, + model1.HeaderColumn{Name: "VS", Attrs: model1.Attrs{VS: true}}, model1.HeaderColumn{Name: "PF"}, model1.HeaderColumn{Name: "READY"}, model1.HeaderColumn{Name: "STATUS"}, - model1.HeaderColumn{Name: "RESTARTS", Align: tview.AlignRight}, - model1.HeaderColumn{Name: "LAST RESTART", Align: tview.AlignRight, Time: true, Wide: true}, - model1.HeaderColumn{Name: "CPU", Align: tview.AlignRight, MX: true}, - model1.HeaderColumn{Name: "MEM", Align: tview.AlignRight, MX: true}, - model1.HeaderColumn{Name: "CPU/R:L", Align: tview.AlignRight, Wide: true}, - model1.HeaderColumn{Name: "MEM/R:L", Align: tview.AlignRight, Wide: true}, - model1.HeaderColumn{Name: "%CPU/R", Align: tview.AlignRight, MX: true}, - model1.HeaderColumn{Name: "%CPU/L", Align: tview.AlignRight, MX: true}, - model1.HeaderColumn{Name: "%MEM/R", Align: tview.AlignRight, MX: true}, - model1.HeaderColumn{Name: "%MEM/L", Align: tview.AlignRight, MX: true}, + model1.HeaderColumn{Name: "RESTARTS", Attrs: model1.Attrs{Align: tview.AlignRight}}, + model1.HeaderColumn{Name: "LAST RESTART", Attrs: model1.Attrs{Align: tview.AlignRight, Time: true, Wide: true}}, + model1.HeaderColumn{Name: "CPU", Attrs: model1.Attrs{Align: tview.AlignRight, MX: true}}, + model1.HeaderColumn{Name: "MEM", Attrs: model1.Attrs{Align: tview.AlignRight, MX: true}}, + model1.HeaderColumn{Name: "CPU/R:L", Attrs: model1.Attrs{Align: tview.AlignRight, Wide: true}}, + model1.HeaderColumn{Name: "MEM/R:L", Attrs: model1.Attrs{Align: tview.AlignRight, Wide: true}}, + model1.HeaderColumn{Name: "%CPU/R", Attrs: model1.Attrs{Align: tview.AlignRight, MX: true}}, + model1.HeaderColumn{Name: "%CPU/L", Attrs: model1.Attrs{Align: tview.AlignRight, MX: true}}, + model1.HeaderColumn{Name: "%MEM/R", Attrs: model1.Attrs{Align: tview.AlignRight, MX: true}}, + model1.HeaderColumn{Name: "%MEM/L", Attrs: model1.Attrs{Align: tview.AlignRight, MX: true}}, model1.HeaderColumn{Name: "IP"}, model1.HeaderColumn{Name: "NODE"}, - model1.HeaderColumn{Name: "SERVICEACCOUNT", Wide: true}, - model1.HeaderColumn{Name: "NOMINATED NODE", Wide: true}, - model1.HeaderColumn{Name: "READINESS GATES", Wide: true}, - model1.HeaderColumn{Name: "QOS", Wide: true}, - model1.HeaderColumn{Name: "LABELS", Wide: true}, - model1.HeaderColumn{Name: "VALID", Wide: true}, - model1.HeaderColumn{Name: "AGE", Time: true}, + model1.HeaderColumn{Name: "SERVICE-ACCOUNT", Attrs: model1.Attrs{Wide: true}}, + model1.HeaderColumn{Name: "NOMINATED NODE", Attrs: model1.Attrs{Wide: true}}, + model1.HeaderColumn{Name: "READINESS GATES", Attrs: model1.Attrs{Wide: true}}, + model1.HeaderColumn{Name: "QOS", Attrs: model1.Attrs{Wide: true}}, + model1.HeaderColumn{Name: "LABELS", Attrs: model1.Attrs{Wide: true}}, + model1.HeaderColumn{Name: "VALID", Attrs: model1.Attrs{Wide: true}}, + model1.HeaderColumn{Name: "AGE", Attrs: model1.Attrs{Time: true}}, } } @@ -120,7 +130,24 @@ func (p Pod) Render(o interface{}, ns string, row *model1.Row) error { if !ok { return fmt.Errorf("expected PodWithMetrics, but got %T", o) } + if err := p.defaultRow(pwm, row); err != nil { + return err + } + if p.specs.isEmpty() { + return nil + } + // !BOZO!! Call header 2 times + cols, err := p.specs.realize(pwm.Raw, p.defaultHeader(), row) + if err != nil { + return err + } + cols.hydrateRow(row) + + return nil +} + +func (p Pod) defaultRow(pwm *PodWithMetrics, row *model1.Row) error { var po v1.Pod if err := runtime.DefaultUnstructuredConverter.FromUnstructured(pwm.Raw.Object, &po); err != nil { return err diff --git a/internal/render/pod_test.go b/internal/render/pod_test.go index 5b4da979..2c9c96c6 100644 --- a/internal/render/pod_test.go +++ b/internal/render/pod_test.go @@ -157,7 +157,7 @@ func TestPodRender(t *testing.T) { MX: makePodMX("nginx", "100m", "50Mi"), } - var po render.Pod + po := render.NewPod() r := model1.NewRow(14) err := po.Render(&pom, "", &r) assert.Nil(t, err) @@ -188,7 +188,7 @@ func TestPodInitRender(t *testing.T) { MX: makePodMX("nginx", "10m", "10Mi"), } - var po render.Pod + po := render.NewPod() r := model1.NewRow(14) err := po.Render(&pom, "", &r) assert.Nil(t, err) @@ -204,7 +204,7 @@ func TestPodSidecarRender(t *testing.T) { MX: makePodMX("sleep", "100m", "40Mi"), } - var po render.Pod + po := render.NewPod() r := model1.NewRow(14) err := po.Render(&pom, "", &r) assert.Nil(t, err) diff --git a/internal/render/policy.go b/internal/render/policy.go index 777ecfaa..2bcd82a5 100644 --- a/internal/render/policy.go +++ b/internal/render/policy.go @@ -24,7 +24,7 @@ func rbacVerbHeader() model1.Header { model1.HeaderColumn{Name: "UPDATE"}, model1.HeaderColumn{Name: "DELETE"}, model1.HeaderColumn{Name: "DEL-LIST "}, - model1.HeaderColumn{Name: "EXTRAS", Wide: true}, + model1.HeaderColumn{Name: "EXTRAS", Attrs: model1.Attrs{Wide: true}}, } } @@ -49,7 +49,7 @@ func (Policy) Header(ns string) model1.Header { model1.HeaderColumn{Name: "BINDING"}, } h = append(h, rbacVerbHeader()...) - h = append(h, model1.HeaderColumn{Name: "VALID", Wide: true}) + h = append(h, model1.HeaderColumn{Name: "VALID", Attrs: model1.Attrs{Wide: true}}) return h } diff --git a/internal/render/portforward.go b/internal/render/portforward.go index 915e325b..c128c2f2 100644 --- a/internal/render/portforward.go +++ b/internal/render/portforward.go @@ -59,8 +59,8 @@ func (PortForward) Header(ns string) model1.Header { model1.HeaderColumn{Name: "URL"}, model1.HeaderColumn{Name: "C"}, model1.HeaderColumn{Name: "N"}, - model1.HeaderColumn{Name: "VALID", Wide: true}, - model1.HeaderColumn{Name: "AGE", Time: true}, + model1.HeaderColumn{Name: "VALID", Attrs: model1.Attrs{Wide: true}}, + model1.HeaderColumn{Name: "AGE", Attrs: model1.Attrs{Time: true}}, } } diff --git a/internal/render/pv.go b/internal/render/pv.go index 9e428937..3c66f656 100644 --- a/internal/render/pv.go +++ b/internal/render/pv.go @@ -47,30 +47,52 @@ func (p PersistentVolume) ColorerFunc() model1.ColorerFunc { } } -// Header returns a header rbw. -func (PersistentVolume) Header(string) model1.Header { +// Header returns a header row. +func (p PersistentVolume) Header(_ string) model1.Header { + return p.doHeader(p.defaultHeader()) +} + +func (PersistentVolume) defaultHeader() model1.Header { return model1.Header{ model1.HeaderColumn{Name: "NAME"}, - model1.HeaderColumn{Name: "CAPACITY", Capacity: true}, + model1.HeaderColumn{Name: "CAPACITY", Attrs: model1.Attrs{Capacity: true}}, model1.HeaderColumn{Name: "ACCESS MODES"}, model1.HeaderColumn{Name: "RECLAIM POLICY"}, model1.HeaderColumn{Name: "STATUS"}, model1.HeaderColumn{Name: "CLAIM"}, model1.HeaderColumn{Name: "STORAGECLASS"}, model1.HeaderColumn{Name: "REASON"}, - model1.HeaderColumn{Name: "VOLUMEMODE", Wide: true}, - model1.HeaderColumn{Name: "LABELS", Wide: true}, - model1.HeaderColumn{Name: "VALID", Wide: true}, - model1.HeaderColumn{Name: "AGE", Time: true}, + model1.HeaderColumn{Name: "VOLUMEMODE", Attrs: model1.Attrs{Wide: true}}, + model1.HeaderColumn{Name: "LABELS", Attrs: model1.Attrs{Wide: true}}, + model1.HeaderColumn{Name: "VALID", Attrs: model1.Attrs{Wide: true}}, + model1.HeaderColumn{Name: "AGE", Attrs: model1.Attrs{Time: true}}, } } // Render renders a K8s resource to screen. -func (p PersistentVolume) Render(o interface{}, ns string, r *model1.Row) error { +func (p PersistentVolume) Render(o interface{}, ns string, row *model1.Row) error { raw, ok := o.(*unstructured.Unstructured) if !ok { return fmt.Errorf("expected PersistentVolume, but got %T", o) } + + if err := p.defaultRow(raw, row); err != nil { + return err + } + if p.specs.isEmpty() { + return nil + } + + cols, err := p.specs.realize(raw, p.defaultHeader(), row) + if err != nil { + return err + } + cols.hydrateRow(row) + + return nil +} + +func (p PersistentVolume) defaultRow(raw *unstructured.Unstructured, r *model1.Row) error { var pv v1.PersistentVolume err := runtime.DefaultUnstructuredConverter.FromUnstructured(raw.Object, &pv) if err != nil { diff --git a/internal/render/pvc.go b/internal/render/pvc.go index 79678346..e4a272f2 100644 --- a/internal/render/pvc.go +++ b/internal/render/pvc.go @@ -18,28 +18,50 @@ type PersistentVolumeClaim struct { Base } -// Header returns a header rbw. -func (PersistentVolumeClaim) Header(ns string) model1.Header { +// Header returns a header row. +func (p PersistentVolumeClaim) Header(_ string) model1.Header { + return p.doHeader(p.defaultHeader()) +} + +func (PersistentVolumeClaim) defaultHeader() model1.Header { return model1.Header{ model1.HeaderColumn{Name: "NAMESPACE"}, model1.HeaderColumn{Name: "NAME"}, model1.HeaderColumn{Name: "STATUS"}, model1.HeaderColumn{Name: "VOLUME"}, - model1.HeaderColumn{Name: "CAPACITY", Capacity: true}, + model1.HeaderColumn{Name: "CAPACITY", Attrs: model1.Attrs{Capacity: true}}, model1.HeaderColumn{Name: "ACCESS MODES"}, model1.HeaderColumn{Name: "STORAGECLASS"}, - model1.HeaderColumn{Name: "LABELS", Wide: true}, - model1.HeaderColumn{Name: "VALID", Wide: true}, - model1.HeaderColumn{Name: "AGE", Time: true}, + model1.HeaderColumn{Name: "LABELS", Attrs: model1.Attrs{Wide: true}}, + model1.HeaderColumn{Name: "VALID", Attrs: model1.Attrs{Wide: true}}, + model1.HeaderColumn{Name: "AGE", Attrs: model1.Attrs{Time: true}}, } } // Render renders a K8s resource to screen. -func (p PersistentVolumeClaim) Render(o interface{}, ns string, r *model1.Row) error { +func (p PersistentVolumeClaim) Render(o interface{}, ns string, row *model1.Row) error { raw, ok := o.(*unstructured.Unstructured) if !ok { return fmt.Errorf("expected PersistentVolumeClaim, but got %T", o) } + + if err := p.defaultRow(raw, row); err != nil { + return err + } + if p.specs.isEmpty() { + return nil + } + + cols, err := p.specs.realize(raw, p.defaultHeader(), row) + if err != nil { + return err + } + cols.hydrateRow(row) + + return nil +} + +func (p PersistentVolumeClaim) defaultRow(raw *unstructured.Unstructured, r *model1.Row) error { var pvc v1.PersistentVolumeClaim err := runtime.DefaultUnstructuredConverter.FromUnstructured(raw.Object, &pvc) if err != nil { diff --git a/internal/render/rbac.go b/internal/render/rbac.go index 12ad96e7..8869ad63 100644 --- a/internal/render/rbac.go +++ b/internal/render/rbac.go @@ -51,7 +51,7 @@ func (Rbac) Header(ns string) model1.Header { ) h = append(h, rbacVerbHeader()...) - return append(h, model1.HeaderColumn{Name: "VALID", Wide: true}) + return append(h, model1.HeaderColumn{Name: "VALID", Attrs: model1.Attrs{Wide: true}}) } // Render renders a K8s resource to screen. diff --git a/internal/render/ro.go b/internal/render/ro.go index 7b3ce315..7bf03349 100644 --- a/internal/render/ro.go +++ b/internal/render/ro.go @@ -19,26 +19,44 @@ type Role struct { } // Header returns a header row. -func (Role) Header(ns string) model1.Header { - var h model1.Header - if client.IsAllNamespaces(ns) { - h = append(h, model1.HeaderColumn{Name: "NAMESPACE"}) - } +func (r Role) Header(_ string) model1.Header { + return r.doHeader(r.defaultHeader()) +} - return append(h, +func (Role) defaultHeader() model1.Header { + return model1.Header{ + model1.HeaderColumn{Name: "NAMESPACE"}, model1.HeaderColumn{Name: "NAME"}, - model1.HeaderColumn{Name: "LABELS", Wide: true}, - model1.HeaderColumn{Name: "VALID", Wide: true}, - model1.HeaderColumn{Name: "AGE", Time: true}, - ) + model1.HeaderColumn{Name: "LABELS", Attrs: model1.Attrs{Wide: true}}, + model1.HeaderColumn{Name: "VALID", Attrs: model1.Attrs{Wide: true}}, + model1.HeaderColumn{Name: "AGE", Attrs: model1.Attrs{Time: true}}, + } } // Render renders a K8s resource to screen. -func (r Role) Render(o interface{}, ns string, row *model1.Row) error { +func (r Role) Render(o interface{}, _ string, row *model1.Row) error { raw, ok := o.(*unstructured.Unstructured) if !ok { return fmt.Errorf("expected Role, but got %T", o) } + + if err := r.defaultRow(raw, row); err != nil { + return err + } + if r.specs.isEmpty() { + return nil + } + + cols, err := r.specs.realize(raw, r.defaultHeader(), row) + if err != nil { + return err + } + cols.hydrateRow(row) + + return nil +} + +func (r Role) defaultRow(raw *unstructured.Unstructured, row *model1.Row) error { var ro rbacv1.Role err := runtime.DefaultUnstructuredConverter.FromUnstructured(raw.Object, &ro) if err != nil { @@ -46,16 +64,13 @@ func (r Role) Render(o interface{}, ns string, row *model1.Row) error { } row.ID = client.MetaFQN(ro.ObjectMeta) - row.Fields = make(model1.Fields, 0, len(r.Header(ns))) - if client.IsAllNamespaces(ns) { - row.Fields = append(row.Fields, ro.Namespace) - } - row.Fields = append(row.Fields, + row.Fields = model1.Fields{ + ro.Namespace, ro.Name, mapToStr(ro.Labels), "", ToAge(ro.GetCreationTimestamp()), - ) + } return nil } diff --git a/internal/render/rob.go b/internal/render/rob.go index 1f58fd60..0f025541 100644 --- a/internal/render/rob.go +++ b/internal/render/rob.go @@ -19,22 +19,22 @@ type RoleBinding struct { Base } -// Header returns a header rbw. -func (RoleBinding) Header(ns string) model1.Header { - var h model1.Header - if client.IsAllNamespaces(ns) { - h = append(h, model1.HeaderColumn{Name: "NAMESPACE"}) - } +// Header returns a header row. +func (r RoleBinding) Header(_ string) model1.Header { + return r.doHeader(r.defaultHeader()) +} - return append(h, +func (RoleBinding) defaultHeader() model1.Header { + return model1.Header{ + model1.HeaderColumn{Name: "NAMESPACE"}, model1.HeaderColumn{Name: "NAME"}, model1.HeaderColumn{Name: "ROLE"}, model1.HeaderColumn{Name: "KIND"}, model1.HeaderColumn{Name: "SUBJECTS"}, - model1.HeaderColumn{Name: "LABELS", Wide: true}, - model1.HeaderColumn{Name: "VALID", Wide: true}, - model1.HeaderColumn{Name: "AGE", Time: true}, - ) + model1.HeaderColumn{Name: "LABELS", Attrs: model1.Attrs{Wide: true}}, + model1.HeaderColumn{Name: "VALID", Attrs: model1.Attrs{Wide: true}}, + model1.HeaderColumn{Name: "AGE", Attrs: model1.Attrs{Time: true}}, + } } // Render renders a K8s resource to screen. @@ -43,6 +43,24 @@ func (r RoleBinding) Render(o interface{}, ns string, row *model1.Row) error { if !ok { return fmt.Errorf("expected RoleBinding, but got %T", o) } + + if err := r.defaultRow(raw, row); err != nil { + return err + } + if r.specs.isEmpty() { + return nil + } + + cols, err := r.specs.realize(raw, r.defaultHeader(), row) + if err != nil { + return err + } + cols.hydrateRow(row) + + return nil +} + +func (r RoleBinding) defaultRow(raw *unstructured.Unstructured, row *model1.Row) error { var rb rbacv1.RoleBinding err := runtime.DefaultUnstructuredConverter.FromUnstructured(raw.Object, &rb) if err != nil { @@ -52,11 +70,8 @@ func (r RoleBinding) Render(o interface{}, ns string, row *model1.Row) error { kind, ss := renderSubjects(rb.Subjects) row.ID = client.MetaFQN(rb.ObjectMeta) - row.Fields = make(model1.Fields, 0, len(r.Header(ns))) - if client.IsAllNamespaces(ns) { - row.Fields = append(row.Fields, rb.Namespace) - } - row.Fields = append(row.Fields, + row.Fields = model1.Fields{ + rb.Namespace, rb.Name, rb.RoleRef.Name, kind, @@ -64,7 +79,7 @@ func (r RoleBinding) Render(o interface{}, ns string, row *model1.Row) error { mapToStr(rb.Labels), "", ToAge(rb.GetCreationTimestamp()), - ) + } return nil } diff --git a/internal/render/rs.go b/internal/render/rs.go index 1046014f..45f1bd1b 100644 --- a/internal/render/rs.go +++ b/internal/render/rs.go @@ -27,19 +27,23 @@ func (r ReplicaSet) ColorerFunc() model1.ColorerFunc { } // Header returns a header row. -func (ReplicaSet) Header(ns string) model1.Header { +func (r ReplicaSet) Header(_ string) model1.Header { + return r.doHeader(r.defaultHeader()) +} + +func (ReplicaSet) defaultHeader() model1.Header { return model1.Header{ model1.HeaderColumn{Name: "NAMESPACE"}, model1.HeaderColumn{Name: "NAME"}, - model1.HeaderColumn{Name: "VS", VS: true}, - model1.HeaderColumn{Name: "DESIRED", Align: tview.AlignRight}, - model1.HeaderColumn{Name: "CURRENT", Align: tview.AlignRight}, - model1.HeaderColumn{Name: "READY", Align: tview.AlignRight}, - model1.HeaderColumn{Name: "CONTAINERS", Wide: true}, - model1.HeaderColumn{Name: "IMAGES", Wide: true}, - model1.HeaderColumn{Name: "SELECTOR", Wide: true}, - model1.HeaderColumn{Name: "VALID", Wide: true}, - model1.HeaderColumn{Name: "AGE", Time: true}, + model1.HeaderColumn{Name: "VS", Attrs: model1.Attrs{VS: true}}, + model1.HeaderColumn{Name: "DESIRED", Attrs: model1.Attrs{Align: tview.AlignRight}}, + model1.HeaderColumn{Name: "CURRENT", Attrs: model1.Attrs{Align: tview.AlignRight}}, + model1.HeaderColumn{Name: "READY", Attrs: model1.Attrs{Align: tview.AlignRight}}, + model1.HeaderColumn{Name: "CONTAINERS", Attrs: model1.Attrs{Wide: true}}, + model1.HeaderColumn{Name: "IMAGES", Attrs: model1.Attrs{Wide: true}}, + model1.HeaderColumn{Name: "SELECTOR", Attrs: model1.Attrs{Wide: true}}, + model1.HeaderColumn{Name: "VALID", Attrs: model1.Attrs{Wide: true}}, + model1.HeaderColumn{Name: "AGE", Attrs: model1.Attrs{Time: true}}, } } @@ -50,6 +54,23 @@ func (r ReplicaSet) Render(o interface{}, ns string, row *model1.Row) error { return fmt.Errorf("expected ReplicaSet, but got %T", o) } + if err := r.defaultRow(raw, row); err != nil { + return err + } + if r.specs.isEmpty() { + return nil + } + + cols, err := r.specs.realize(raw, r.defaultHeader(), row) + if err != nil { + return err + } + cols.hydrateRow(row) + + return nil +} + +func (r ReplicaSet) defaultRow(raw *unstructured.Unstructured, row *model1.Row) error { var rs appsv1.ReplicaSet err := runtime.DefaultUnstructuredConverter.FromUnstructured(raw.Object, &rs) if err != nil { diff --git a/internal/render/sa.go b/internal/render/sa.go index 1f463a4e..c6c801f0 100644 --- a/internal/render/sa.go +++ b/internal/render/sa.go @@ -20,23 +20,45 @@ type ServiceAccount struct { } // Header returns a header row. -func (ServiceAccount) Header(ns string) model1.Header { +func (s ServiceAccount) Header(_ string) model1.Header { + return s.doHeader(s.defaultHeader()) +} + +func (ServiceAccount) defaultHeader() model1.Header { return model1.Header{ model1.HeaderColumn{Name: "NAMESPACE"}, model1.HeaderColumn{Name: "NAME"}, model1.HeaderColumn{Name: "SECRET"}, - model1.HeaderColumn{Name: "LABELS", Wide: true}, - model1.HeaderColumn{Name: "VALID", Wide: true}, - model1.HeaderColumn{Name: "AGE", Time: true}, + model1.HeaderColumn{Name: "LABELS", Attrs: model1.Attrs{Wide: true}}, + model1.HeaderColumn{Name: "VALID", Attrs: model1.Attrs{Wide: true}}, + model1.HeaderColumn{Name: "AGE", Attrs: model1.Attrs{Time: true}}, } } // Render renders a K8s resource to screen. -func (s ServiceAccount) Render(o interface{}, ns string, r *model1.Row) error { +func (s ServiceAccount) Render(o interface{}, ns string, row *model1.Row) error { raw, ok := o.(*unstructured.Unstructured) if !ok { return fmt.Errorf("expected ServiceAccount, but got %T", o) } + + if err := s.defaultRow(raw, row); err != nil { + return err + } + if s.specs.isEmpty() { + return nil + } + + cols, err := s.specs.realize(raw, s.defaultHeader(), row) + if err != nil { + return err + } + cols.hydrateRow(row) + + return nil +} + +func (s ServiceAccount) defaultRow(raw *unstructured.Unstructured, r *model1.Row) error { var sa v1.ServiceAccount err := runtime.DefaultUnstructuredConverter.FromUnstructured(raw.Object, &sa) if err != nil { diff --git a/internal/render/sc.go b/internal/render/sc.go index f805fb10..5af82581 100644 --- a/internal/render/sc.go +++ b/internal/render/sc.go @@ -21,25 +21,47 @@ type StorageClass struct { } // Header returns a header row. -func (StorageClass) Header(ns string) model1.Header { +func (s StorageClass) Header(_ string) model1.Header { + return s.doHeader(s.defaultHeader()) +} + +func (StorageClass) defaultHeader() model1.Header { return model1.Header{ model1.HeaderColumn{Name: "NAME"}, model1.HeaderColumn{Name: "PROVISIONER"}, model1.HeaderColumn{Name: "RECLAIMPOLICY"}, model1.HeaderColumn{Name: "VOLUMEBINDINGMODE"}, model1.HeaderColumn{Name: "ALLOWVOLUMEEXPANSION"}, - model1.HeaderColumn{Name: "LABELS", Wide: true}, - model1.HeaderColumn{Name: "VALID", Wide: true}, - model1.HeaderColumn{Name: "AGE", Time: true}, + model1.HeaderColumn{Name: "LABELS", Attrs: model1.Attrs{Wide: true}}, + model1.HeaderColumn{Name: "VALID", Attrs: model1.Attrs{Wide: true}}, + model1.HeaderColumn{Name: "AGE", Attrs: model1.Attrs{Time: true}}, } } // Render renders a K8s resource to screen. -func (s StorageClass) Render(o interface{}, ns string, r *model1.Row) error { +func (s StorageClass) Render(o interface{}, ns string, row *model1.Row) error { raw, ok := o.(*unstructured.Unstructured) if !ok { return fmt.Errorf("expected StorageClass, but got %T", o) } + + if err := s.defaultRow(raw, row); err != nil { + return err + } + if s.specs.isEmpty() { + return nil + } + + cols, err := s.specs.realize(raw, s.defaultHeader(), row) + if err != nil { + return err + } + cols.hydrateRow(row) + + return nil +} + +func (s StorageClass) defaultRow(raw *unstructured.Unstructured, r *model1.Row) error { var sc storagev1.StorageClass err := runtime.DefaultUnstructuredConverter.FromUnstructured(raw.Object, &sc) if err != nil { diff --git a/internal/render/screen_dump.go b/internal/render/screen_dump.go index 8193c612..6558616e 100644 --- a/internal/render/screen_dump.go +++ b/internal/render/screen_dump.go @@ -33,8 +33,8 @@ func (ScreenDump) Header(ns string) model1.Header { return model1.Header{ model1.HeaderColumn{Name: "NAME"}, model1.HeaderColumn{Name: "DIR"}, - model1.HeaderColumn{Name: "VALID", Wide: true}, - model1.HeaderColumn{Name: "AGE", Time: true}, + model1.HeaderColumn{Name: "VALID", Attrs: model1.Attrs{Wide: true}}, + model1.HeaderColumn{Name: "AGE", Attrs: model1.Attrs{Time: true}}, } } diff --git a/internal/render/secret.go b/internal/render/secret.go index d1d3aad9..c35cfc68 100644 --- a/internal/render/secret.go +++ b/internal/render/secret.go @@ -20,24 +20,46 @@ type Secret struct { Base } -// Header returns a header rbw. -func (Secret) Header(string) model1.Header { +// Header returns a header row. +func (s Secret) Header(_ string) model1.Header { + return s.doHeader(s.defaultHeader()) +} + +func (Secret) defaultHeader() model1.Header { return model1.Header{ model1.HeaderColumn{Name: "NAMESPACE"}, model1.HeaderColumn{Name: "NAME"}, model1.HeaderColumn{Name: "TYPE"}, model1.HeaderColumn{Name: "DATA"}, - model1.HeaderColumn{Name: "VALID", Wide: true}, - model1.HeaderColumn{Name: "AGE", Time: true}, + model1.HeaderColumn{Name: "VALID", Attrs: model1.Attrs{Wide: true}}, + model1.HeaderColumn{Name: "AGE", Attrs: model1.Attrs{Time: true}}, } } // Render renders a K8s resource to screen. -func (n Secret) Render(o interface{}, _ string, r *model1.Row) error { +func (s Secret) Render(o interface{}, ns string, row *model1.Row) error { raw, ok := o.(*unstructured.Unstructured) if !ok { return fmt.Errorf("expected Secret, but got %T", o) } + + if err := s.defaultRow(raw, row); err != nil { + return err + } + if s.specs.isEmpty() { + return nil + } + + cols, err := s.specs.realize(raw, s.defaultHeader(), row) + if err != nil { + return err + } + cols.hydrateRow(row) + + return nil +} + +func (n Secret) defaultRow(raw *unstructured.Unstructured, r *model1.Row) error { var sec v1.Secret err := runtime.DefaultUnstructuredConverter.FromUnstructured(raw.Object, &sec) if err != nil { diff --git a/internal/render/sts.go b/internal/render/sts.go index c83ffa7b..45dd6bc6 100644 --- a/internal/render/sts.go +++ b/internal/render/sts.go @@ -20,28 +20,50 @@ type StatefulSet struct { } // Header returns a header row. -func (StatefulSet) Header(ns string) model1.Header { +func (s StatefulSet) Header(_ string) model1.Header { + return s.doHeader(s.defaultHeader()) +} + +func (StatefulSet) defaultHeader() model1.Header { return model1.Header{ model1.HeaderColumn{Name: "NAMESPACE"}, model1.HeaderColumn{Name: "NAME"}, - model1.HeaderColumn{Name: "VS", VS: true}, + model1.HeaderColumn{Name: "VS", Attrs: model1.Attrs{VS: true}}, model1.HeaderColumn{Name: "READY"}, - model1.HeaderColumn{Name: "SELECTOR", Wide: true}, + model1.HeaderColumn{Name: "SELECTOR", Attrs: model1.Attrs{Wide: true}}, model1.HeaderColumn{Name: "SERVICE"}, - model1.HeaderColumn{Name: "CONTAINERS", Wide: true}, - model1.HeaderColumn{Name: "IMAGES", Wide: true}, - model1.HeaderColumn{Name: "LABELS", Wide: true}, - model1.HeaderColumn{Name: "VALID", Wide: true}, - model1.HeaderColumn{Name: "AGE", Time: true}, + model1.HeaderColumn{Name: "CONTAINERS", Attrs: model1.Attrs{Wide: true}}, + model1.HeaderColumn{Name: "IMAGES", Attrs: model1.Attrs{Wide: true}}, + model1.HeaderColumn{Name: "LABELS", Attrs: model1.Attrs{Wide: true}}, + model1.HeaderColumn{Name: "VALID", Attrs: model1.Attrs{Wide: true}}, + model1.HeaderColumn{Name: "AGE", Attrs: model1.Attrs{Time: true}}, } } // Render renders a K8s resource to screen. -func (s StatefulSet) Render(o interface{}, ns string, r *model1.Row) error { +func (s StatefulSet) Render(o interface{}, ns string, row *model1.Row) error { raw, ok := o.(*unstructured.Unstructured) if !ok { return fmt.Errorf("expected StatefulSet, but got %T", o) } + + if err := s.defaultRow(raw, row); err != nil { + return err + } + if s.specs.isEmpty() { + return nil + } + + cols, err := s.specs.realize(raw, s.defaultHeader(), row) + if err != nil { + return err + } + cols.hydrateRow(row) + + return nil +} + +func (s StatefulSet) defaultRow(raw *unstructured.Unstructured, r *model1.Row) error { var sts appsv1.StatefulSet err := runtime.DefaultUnstructuredConverter.FromUnstructured(raw.Object, &sts) if err != nil { diff --git a/internal/render/subject.go b/internal/render/subject.go index af3c0a61..bb0f1b9a 100644 --- a/internal/render/subject.go +++ b/internal/render/subject.go @@ -30,7 +30,7 @@ func (Subject) Header(ns string) model1.Header { model1.HeaderColumn{Name: "NAME"}, model1.HeaderColumn{Name: "KIND"}, model1.HeaderColumn{Name: "FIRST LOCATION"}, - model1.HeaderColumn{Name: "VALID", Wide: true}, + model1.HeaderColumn{Name: "VALID", Attrs: model1.Attrs{Wide: true}}, } } diff --git a/internal/render/svc.go b/internal/render/svc.go index 73081cad..c01454de 100644 --- a/internal/render/svc.go +++ b/internal/render/svc.go @@ -22,27 +22,50 @@ type Service struct { } // Header returns a header row. -func (Service) Header(ns string) model1.Header { +func (s Service) Header(_ string) model1.Header { + return s.doHeader(s.defaultHeader()) +} + +// Header returns a header row. +func (Service) defaultHeader() model1.Header { return model1.Header{ model1.HeaderColumn{Name: "NAMESPACE"}, model1.HeaderColumn{Name: "NAME"}, model1.HeaderColumn{Name: "TYPE"}, model1.HeaderColumn{Name: "CLUSTER-IP"}, model1.HeaderColumn{Name: "EXTERNAL-IP"}, - model1.HeaderColumn{Name: "SELECTOR", Wide: true}, - model1.HeaderColumn{Name: "PORTS", Wide: false}, - model1.HeaderColumn{Name: "LABELS", Wide: true}, - model1.HeaderColumn{Name: "VALID", Wide: true}, - model1.HeaderColumn{Name: "AGE", Time: true}, + model1.HeaderColumn{Name: "SELECTOR", Attrs: model1.Attrs{Wide: true}}, + model1.HeaderColumn{Name: "PORTS", Attrs: model1.Attrs{Wide: false}}, + model1.HeaderColumn{Name: "LABELS", Attrs: model1.Attrs{Wide: true}}, + model1.HeaderColumn{Name: "VALID", Attrs: model1.Attrs{Wide: true}}, + model1.HeaderColumn{Name: "AGE", Attrs: model1.Attrs{Time: true}}, } } // Render renders a K8s resource to screen. -func (s Service) Render(o interface{}, ns string, r *model1.Row) error { +func (s Service) Render(o interface{}, ns string, row *model1.Row) error { raw, ok := o.(*unstructured.Unstructured) if !ok { return fmt.Errorf("expected Service, but got %T", o) } + + if err := s.defaultRow(raw, row); err != nil { + return err + } + if s.specs.isEmpty() { + return nil + } + + cols, err := s.specs.realize(raw, s.defaultHeader(), row) + if err != nil { + return err + } + cols.hydrateRow(row) + + return nil +} + +func (s Service) defaultRow(raw *unstructured.Unstructured, r *model1.Row) error { var svc v1.Service err := runtime.DefaultUnstructuredConverter.FromUnstructured(raw.Object, &svc) if err != nil { diff --git a/internal/render/table.go b/internal/render/table.go new file mode 100644 index 00000000..2b8f52ed --- /dev/null +++ b/internal/render/table.go @@ -0,0 +1,168 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Authors of K9s + +package render + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/derailed/k9s/internal/client" + "github.com/derailed/k9s/internal/model1" + "github.com/rs/zerolog/log" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const ageTableCol = "Age" + +// Table renders a tabular resource to screen. +type Table struct { + Base + table *metav1.Table + header model1.Header + ageIndex int +} + +func (*Table) IsGeneric() bool { + return true +} + +// SetTable sets the tabular resource. +func (t *Table) SetTable(ns string, table *metav1.Table) { + t.table = table + t.header = t.Header(ns) +} + +// ColorerFunc colors a resource row. +func (*Table) ColorerFunc() model1.ColorerFunc { + return model1.DefaultColorer +} + +// Header returns a header row. +func (t *Table) Header(ns string) model1.Header { + return t.doHeader(t.defaultHeader()) +} + +// Header returns a header row. +func (t *Table) defaultHeader() model1.Header { + if t.table == nil { + return model1.Header{} + } + h := make(model1.Header, 0, len(t.table.ColumnDefinitions)) + for i, c := range t.table.ColumnDefinitions { + if c.Name == ageTableCol { + t.ageIndex = i + continue + } + h = append(h, model1.HeaderColumn{Name: strings.ToUpper(c.Name)}) + } + if t.ageIndex > 0 { + h = append(h, model1.HeaderColumn{Name: "AGE", Attrs: model1.Attrs{Time: true}}) + } + + return h +} + +// Render renders a K8s resource to screen. +func (t *Table) Render(o any, ns string, r *model1.Row) error { + row, ok := o.(metav1.TableRow) + if !ok { + return fmt.Errorf("expected Table, but got %T", o) + } + + if err := t.defaultRow(&row, ns, r); err != nil { + return err + } + if t.specs.isEmpty() { + return nil + } + + cols, err := t.specs.realize(row.Object.Object, t.defaultHeader(), r) + if err != nil { + return err + } + cols.hydrateRow(r) + + return nil +} + +func (t *Table) defaultRow(row *metav1.TableRow, ns string, r *model1.Row) error { + th := t.defaultHeader() + ons, name := ns, UnknownValue + if row.Object.Object != nil { + m, _ := meta.Accessor(row.Object.Object) + if m != nil { + ons, name = m.GetNamespace(), m.GetName() + } + } else if idx, ok := th.IndexOf("NAME", true); ok && idx >= 0 { + name = row.Cells[idx].(string) + if idx, ok := th.IndexOf("NAMESPACE", true); ok && idx >= 0 { + ons = row.Cells[idx].(string) + } + } else { + } + if client.IsClusterWide(ons) { + ons = client.ClusterScope + } + r.ID = client.FQN(ons, name) + r.Fields = make(model1.Fields, 0, len(th)) + var age any + for i, c := range row.Cells { + if t.ageIndex > 0 && i == t.ageIndex { + age = c + continue + } + if c == nil { + r.Fields = append(r.Fields, Blank) + continue + } + r.Fields = append(r.Fields, fmt.Sprintf("%v", c)) + } + if d, ok := age.(string); ok { + r.Fields = append(r.Fields, d) + } else if t.ageIndex > 0 { + log.Warn().Msgf("No Duration detected on age field") + r.Fields = append(r.Fields, NAValue) + } + + return nil +} + +// ---------------------------------------------------------------------------- +// Helpers... + +func resourceNS(raw []byte) (string, string, error) { + var obj map[string]interface{} + var ns, name string + err := json.Unmarshal(raw, &obj) + if err != nil { + return ns, name, err + } + + meta, ok := obj["metadata"].(map[string]interface{}) + if !ok { + return ns, name, errors.New("no metadata found on generic resource") + } + ina, ok := meta["name"] + if !ok { + return ns, name, errors.New("unable to extract resource name") + } + name, ok = ina.(string) + if !ok { + return ns, name, fmt.Errorf("expecting name string type but got %T", ns) + } + + ins, ok := meta["namespace"] + if !ok { + return client.ClusterScope, name, nil + } + + ns, ok = ins.(string) + if !ok { + return ns, name, fmt.Errorf("expecting namespace string type but got %T", ns) + } + return ns, name, nil +} diff --git a/internal/render/generic_test.go b/internal/render/table_test.go similarity index 64% rename from internal/render/generic_test.go rename to internal/render/table_test.go index a7180762..add7b484 100644 --- a/internal/render/generic_test.go +++ b/internal/render/table_test.go @@ -7,9 +7,11 @@ import ( "testing" "github.com/derailed/k9s/internal/client" + cfg "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/model1" "github.com/derailed/k9s/internal/render" "github.com/stretchr/testify/assert" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" metav1beta1 "k8s.io/apimachinery/pkg/apis/meta/v1beta1" "k8s.io/apimachinery/pkg/runtime" ) @@ -34,6 +36,7 @@ func TestGenericRender(t *testing.T) { model1.HeaderColumn{Name: "C"}, }, }, + "all": { ns: client.NamespaceAll, table: makeNSGeneric(), @@ -46,18 +49,7 @@ func TestGenericRender(t *testing.T) { model1.HeaderColumn{Name: "C"}, }, }, - "allNS": { - ns: client.NamespaceAll, - table: makeNSGeneric(), - eID: "ns1/fred", - eFields: model1.Fields{"ns1", "c1", "c2", "c3"}, - eHeader: model1.Header{ - model1.HeaderColumn{Name: "NAMESPACE"}, - model1.HeaderColumn{Name: "A"}, - model1.HeaderColumn{Name: "B"}, - model1.HeaderColumn{Name: "C"}, - }, - }, + "clusterWide": { ns: client.ClusterScope, table: makeNoNSGeneric(), @@ -69,6 +61,7 @@ func TestGenericRender(t *testing.T) { model1.HeaderColumn{Name: "C"}, }, }, + "age": { ns: client.ClusterScope, table: makeAgeGeneric(), @@ -77,13 +70,13 @@ func TestGenericRender(t *testing.T) { eHeader: model1.Header{ model1.HeaderColumn{Name: "A"}, model1.HeaderColumn{Name: "C"}, - model1.HeaderColumn{Name: "AGE", Time: true}, + model1.HeaderColumn{Name: "AGE", Attrs: model1.Attrs{Time: true}}, }, }, } for k := range uu { - var re render.Generic + var re render.Table u := uu[k] t.Run(k, func(t *testing.T) { var r model1.Row @@ -97,12 +90,60 @@ func TestGenericRender(t *testing.T) { } } +func TestGenericCustRender(t *testing.T) { + uu := map[string]struct { + ns string + table *metav1beta1.Table + vs cfg.ViewSetting + eID string + eFields model1.Fields + eHeader model1.Header + }{ + "spec": { + ns: "ns1", + table: makeNSGeneric(), + vs: cfg.ViewSetting{ + Columns: []string{ + "NAMESPACE", + "BLEE:.metadata.name", + "ZORG:.metadata.namespace", + }, + }, + eID: "ns1/fred", + eFields: model1.Fields{"ns1", "fred", "ns1", "c1", "c2", "c3"}, + eHeader: model1.Header{ + model1.HeaderColumn{Name: "NAMESPACE"}, + model1.HeaderColumn{Name: "BLEE"}, + model1.HeaderColumn{Name: "ZORG"}, + model1.HeaderColumn{Name: "A"}, + model1.HeaderColumn{Name: "B"}, + model1.HeaderColumn{Name: "C"}, + }, + }, + } + + for k, u := range uu { + var re render.Table + re.SetViewSetting(&u.vs) + t.Run(k, func(t *testing.T) { + var r model1.Row + re.SetTable(u.ns, u.table) + + assert.Equal(t, u.eHeader, re.Header(u.ns)) + assert.Nil(t, re.Render(u.table.Rows[0], u.ns, &r)) + assert.Equal(t, u.eID, r.ID) + assert.Equal(t, u.eFields, r.Fields) + }) + } +} + // ---------------------------------------------------------------------------- // Helpers... func makeNSGeneric() *metav1beta1.Table { return &metav1beta1.Table{ ColumnDefinitions: []metav1beta1.TableColumnDefinition{ + {Name: "NAMESPACE"}, {Name: "a"}, {Name: "b"}, {Name: "c"}, @@ -110,15 +151,19 @@ func makeNSGeneric() *metav1beta1.Table { Rows: []metav1beta1.TableRow{ { Object: runtime.RawExtension{ - Raw: []byte(`{ - "kind": "fred", - "apiVersion": "v1", - "metadata": { - "namespace": "ns1", - "name": "fred" - }}`), + Object: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "kind": "fred", + "apiVersion": "v1", + "metadata": map[string]interface{}{ + "namespace": "ns1", + "name": "fred", + }, + }, + }, }, Cells: []interface{}{ + "ns1", "c1", "c2", "c3", @@ -138,12 +183,15 @@ func makeNoNSGeneric() *metav1beta1.Table { Rows: []metav1beta1.TableRow{ { Object: runtime.RawExtension{ - Raw: []byte(`{ - "kind": "fred", - "apiVersion": "v1", - "metadata": { - "name": "fred" - }}`), + Object: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "kind": "fred", + "apiVersion": "v1", + "metadata": map[string]interface{}{ + "name": "fred", + }, + }, + }, }, Cells: []interface{}{ "c1", @@ -165,12 +213,15 @@ func makeAgeGeneric() *metav1beta1.Table { Rows: []metav1beta1.TableRow{ { Object: runtime.RawExtension{ - Raw: []byte(`{ - "kind": "fred", - "apiVersion": "v1", - "metadata": { - "name": "fred" - }}`), + Object: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "kind": "fred", + "apiVersion": "v1", + "metadata": map[string]interface{}{ + "name": "fred", + }, + }, + }, }, Cells: []interface{}{ "c1", diff --git a/internal/render/workload.go b/internal/render/workload.go index 7a3a2645..b06e7300 100644 --- a/internal/render/workload.go +++ b/internal/render/workload.go @@ -45,8 +45,8 @@ func (Workload) Header(string) model1.Header { model1.HeaderColumn{Name: "NAME"}, model1.HeaderColumn{Name: "STATUS"}, model1.HeaderColumn{Name: "READY"}, - model1.HeaderColumn{Name: "VALID", Wide: true}, - model1.HeaderColumn{Name: "AGE", Time: true}, + model1.HeaderColumn{Name: "VALID", Attrs: model1.Attrs{Wide: true}}, + model1.HeaderColumn{Name: "AGE", Attrs: model1.Attrs{Time: true}}, } } diff --git a/internal/ui/table.go b/internal/ui/table.go index a1cd7365..f9ef53e8 100644 --- a/internal/ui/table.go +++ b/internal/ui/table.go @@ -11,6 +11,7 @@ import ( "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/config" + "github.com/derailed/k9s/internal/dao" "github.com/derailed/k9s/internal/model" "github.com/derailed/k9s/internal/model1" "github.com/derailed/k9s/internal/render" @@ -37,12 +38,12 @@ type ( // Table represents tabular data. type Table struct { - gvr client.GVR - sortCol model1.SortColumn - manualSort bool - Path string - Extras string *SelectTable + gvr client.GVR + sortCol model1.SortColumn + manualSort bool + Path string + Extras string actions *KeyActions cmdBuff *model.FishBuff styles *config.Styles @@ -64,6 +65,7 @@ func NewTable(gvr client.GVR) *Table { model: model.NewTable(gvr), marks: make(map[string]struct{}), }, + ctx: context.Background(), gvr: gvr, actions: NewKeyActions(), cmdBuff: model.NewFishBuff('/', model.FilterBuffer), @@ -106,19 +108,20 @@ func (t *Table) getMSort() bool { return t.manualSort } -func (t *Table) setVs(vs *config.ViewSetting) bool { +func (t *Table) setViewSetting(vs *config.ViewSetting) bool { t.mx.Lock() defer t.mx.Unlock() if !t.viewSetting.Equals(vs) { t.viewSetting = vs + t.model.SetViewSetting(t.ctx, vs) return true } return false } -func (t *Table) getVs() *config.ViewSetting { +func (t *Table) getViewSetting() *config.ViewSetting { t.mx.RLock() defer t.mx.RUnlock() @@ -143,9 +146,6 @@ func (t *Table) Init(ctx context.Context) { t.SetSelectionChangedFunc(t.selectionChanged) t.SetBackgroundColor(tcell.ColorDefault) t.Select(1, 0) - if cfg, ok := ctx.Value(internal.KeyViewConfig).(*config.CustomView); ok && cfg != nil { - cfg.AddListener(t.GVR().String(), t) - } t.styles = mustExtractStyles(ctx) t.StylesChanged(t.styles) } @@ -154,8 +154,11 @@ func (t *Table) Init(ctx context.Context) { func (t *Table) GVR() client.GVR { return t.gvr } // ViewSettingsChanged notifies listener the view configuration changed. -func (t *Table) ViewSettingsChanged(vs config.ViewSetting) { - if t.setVs(&vs) { +func (t *Table) ViewSettingsChanged(vs *config.ViewSetting) { + if t.setViewSetting(vs) { + if vs == nil { + t.setSortCol(model1.SortColumn{}) + } t.setMSort(false) t.Refresh() } @@ -274,7 +277,7 @@ func (t *Table) doUpdate(data *model1.TableData) *model1.TableData { t.actions.Delete(KeyShiftP) } - cdata, sortCol := data.Customize(t.getVs(), t.getSortCol(), t.getMSort(), true) + cdata, sortCol := data.Customize(t.getViewSetting(), t.getSortCol(), t.getMSort()) t.setSortCol(sortCol) return cdata @@ -285,12 +288,17 @@ func (t *Table) UpdateUI(cdata, data *model1.TableData) { fg := t.styles.Table().Header.FgColor.Color() bg := t.styles.Table().Header.BgColor.Color() + var isNamespaced bool + if m, err := dao.MetaAccess.MetaFor(t.GVR()); err == nil { + isNamespaced = m.Namespaced + } + var col int for _, h := range cdata.Header() { - if !t.wide && h.Wide { + if h.Hide || (!t.wide && h.Wide) { continue } - if h.Name == "NAMESPACE" && !t.GetModel().ClusterWide() { + if h.Name == "NAMESPACE" && (!t.GetModel().ClusterWide() || !isNamespaced) { continue } if h.MX && !t.hasMetrics { @@ -316,7 +324,7 @@ func (t *Table) UpdateUI(cdata, data *model1.TableData) { log.Error().Msgf("unable to find original re: %q", re.Row.ID) return true } - t.buildRow(row+1, re, ore, cdata.Header(), pads) + t.buildRow(row+1, re, ore, cdata.Header(), pads, isNamespaced) return true }) @@ -325,7 +333,7 @@ func (t *Table) UpdateUI(cdata, data *model1.TableData) { t.UpdateTitle() } -func (t *Table) buildRow(r int, re, ore model1.RowEvent, h model1.Header, pads MaxyPad) { +func (t *Table) buildRow(r int, re, ore model1.RowEvent, h model1.Header, pads MaxyPad, isNamespaced bool) { color := model1.DefaultColorer if t.colorerFn != nil { color = t.colorerFn @@ -339,11 +347,11 @@ func (t *Table) buildRow(r int, re, ore model1.RowEvent, h model1.Header, pads M log.Error().Msgf("field/header overflow detected for %q -- %d::%d. Check your mappings!", t.GVR(), c, len(h)) continue } - if !t.wide && h[c].Wide { + if h[c].Hide || (!t.wide && h[c].Wide) { continue } - if h[c].Name == "NAMESPACE" && !t.GetModel().ClusterWide() { + if h[c].Name == "NAMESPACE" && (!t.GetModel().ClusterWide() || !isNamespaced) { continue } if h[c].MX && !t.hasMetrics { @@ -354,7 +362,14 @@ func (t *Table) buildRow(r int, re, ore model1.RowEvent, h model1.Header, pads M } if !re.Deltas.IsBlank() && !h.IsTimeCol(c) { - field += Deltas(re.Deltas[c], field) + var old string + if c < len(ore.Deltas) { + old = ore.Deltas[c] + } + if c < len(re.Deltas) { + old = re.Deltas[c] + } + field += Deltas(old, field) } if h[c].Decorator != nil { diff --git a/internal/ui/table_test.go b/internal/ui/table_test.go index 9b604d84..bd967c74 100644 --- a/internal/ui/table_test.go +++ b/internal/ui/table_test.go @@ -70,21 +70,22 @@ type mockModel struct{} var _ ui.Tabular = &mockModel{} -func (t *mockModel) SetInstance(string) {} -func (t *mockModel) SetLabelFilter(string) {} -func (t *mockModel) GetLabelFilter() string { return "" } -func (t *mockModel) Empty() bool { return false } -func (t *mockModel) RowCount() int { return 1 } -func (t *mockModel) HasMetrics() bool { return true } -func (t *mockModel) Peek() *model1.TableData { return makeTableData() } -func (t *mockModel) Refresh(context.Context) error { return nil } -func (t *mockModel) ClusterWide() bool { return false } -func (t *mockModel) GetNamespace() string { return "blee" } -func (t *mockModel) SetNamespace(string) {} -func (t *mockModel) ToggleToast() {} -func (t *mockModel) AddListener(model.TableListener) {} -func (t *mockModel) RemoveListener(model.TableListener) {} -func (t *mockModel) Watch(context.Context) error { return nil } +func (t *mockModel) SetViewSetting(context.Context, *config.ViewSetting) {} +func (t *mockModel) SetInstance(string) {} +func (t *mockModel) SetLabelFilter(string) {} +func (t *mockModel) GetLabelFilter() string { return "" } +func (t *mockModel) Empty() bool { return false } +func (t *mockModel) RowCount() int { return 1 } +func (t *mockModel) HasMetrics() bool { return true } +func (t *mockModel) Peek() *model1.TableData { return makeTableData() } +func (t *mockModel) Refresh(context.Context) error { return nil } +func (t *mockModel) ClusterWide() bool { return false } +func (t *mockModel) GetNamespace() string { return "blee" } +func (t *mockModel) SetNamespace(string) {} +func (t *mockModel) ToggleToast() {} +func (t *mockModel) AddListener(model.TableListener) {} +func (t *mockModel) RemoveListener(model.TableListener) {} +func (t *mockModel) Watch(context.Context) error { return nil } func (t *mockModel) Get(ctx context.Context, path string) (runtime.Object, error) { return nil, nil } diff --git a/internal/ui/types.go b/internal/ui/types.go index 534d084e..4f4222f1 100644 --- a/internal/ui/types.go +++ b/internal/ui/types.go @@ -7,6 +7,7 @@ import ( "context" "time" + "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/dao" "github.com/derailed/k9s/internal/model" "github.com/derailed/k9s/internal/model1" @@ -75,4 +76,7 @@ type Tabular interface { // Delete a resource. Delete(context.Context, string, *metav1.DeletionPropagation, dao.Grace) error + + // SetViewSetting injects custom cols specification. + SetViewSetting(context.Context, *config.ViewSetting) } diff --git a/internal/view/alias_test.go b/internal/view/alias_test.go index 6deba289..ad16d412 100644 --- a/internal/view/alias_test.go +++ b/internal/view/alias_test.go @@ -10,6 +10,7 @@ import ( "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/client" + "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/config/mock" "github.com/derailed/k9s/internal/dao" "github.com/derailed/k9s/internal/model" @@ -85,25 +86,26 @@ var ( _ ui.Suggester = (*mockModel)(nil) ) -func (t *mockModel) CurrentSuggestion() (string, bool) { return "", false } -func (t *mockModel) NextSuggestion() (string, bool) { return "", false } -func (t *mockModel) PrevSuggestion() (string, bool) { return "", false } -func (t *mockModel) ClearSuggestions() {} -func (t *mockModel) SetInstance(string) {} -func (t *mockModel) SetLabelFilter(string) {} -func (t *mockModel) GetLabelFilter() string { return "" } -func (t *mockModel) Empty() bool { return false } -func (t *mockModel) RowCount() int { return 1 } -func (t *mockModel) HasMetrics() bool { return true } -func (t *mockModel) Peek() *model1.TableData { return makeTableData() } -func (t *mockModel) ClusterWide() bool { return false } -func (t *mockModel) GetNamespace() string { return "blee" } -func (t *mockModel) SetNamespace(string) {} -func (t *mockModel) ToggleToast() {} -func (t *mockModel) AddListener(model.TableListener) {} -func (t *mockModel) RemoveListener(model.TableListener) {} -func (t *mockModel) Watch(context.Context) error { return nil } -func (t *mockModel) Refresh(context.Context) error { return nil } +func (t *mockModel) SetViewSetting(context.Context, *config.ViewSetting) {} +func (t *mockModel) CurrentSuggestion() (string, bool) { return "", false } +func (t *mockModel) NextSuggestion() (string, bool) { return "", false } +func (t *mockModel) PrevSuggestion() (string, bool) { return "", false } +func (t *mockModel) ClearSuggestions() {} +func (t *mockModel) SetInstance(string) {} +func (t *mockModel) SetLabelFilter(string) {} +func (t *mockModel) GetLabelFilter() string { return "" } +func (t *mockModel) Empty() bool { return false } +func (t *mockModel) RowCount() int { return 1 } +func (t *mockModel) HasMetrics() bool { return true } +func (t *mockModel) Peek() *model1.TableData { return makeTableData() } +func (t *mockModel) ClusterWide() bool { return false } +func (t *mockModel) GetNamespace() string { return "blee" } +func (t *mockModel) SetNamespace(string) {} +func (t *mockModel) ToggleToast() {} +func (t *mockModel) AddListener(model.TableListener) {} +func (t *mockModel) RemoveListener(model.TableListener) {} +func (t *mockModel) Watch(context.Context) error { return nil } +func (t *mockModel) Refresh(context.Context) error { return nil } func (t *mockModel) Get(context.Context, string) (runtime.Object, error) { return nil, nil } diff --git a/internal/view/dir.go b/internal/view/dir.go index bb34f682..b45bfb26 100644 --- a/internal/view/dir.go +++ b/internal/view/dir.go @@ -8,11 +8,11 @@ import ( "fmt" "os" "path" + "slices" "strings" "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/client" - "github.com/derailed/k9s/internal/config/data" "github.com/derailed/k9s/internal/ui" "github.com/derailed/k9s/internal/ui/dialog" "github.com/derailed/tcell/v2" @@ -173,7 +173,7 @@ func isKustomized(sel string) bool { } kk := []string{kustomizeNoExt, kustomizeYAML, kustomizeYML} for _, f := range ff { - if data.InList(kk, f.Name()) { + if slices.Contains(kk, f.Name()) { return true } } diff --git a/internal/view/registrar.go b/internal/view/registrar.go index d199a449..daee0944 100644 --- a/internal/view/registrar.go +++ b/internal/view/registrar.go @@ -90,10 +90,6 @@ func miscViewers(vv MetaViewers) { vv[client.NewGVR("pulses")] = MetaViewer{ viewerFn: NewPulse, } - // !!BOZO!! Popeye - // vv[client.NewGVR("popeye")] = MetaViewer{ - // viewerFn: NewPopeye, - // } vv[client.NewGVR("sanitizer")] = MetaViewer{ viewerFn: NewSanitizer, } diff --git a/internal/view/table.go b/internal/view/table.go index 2fbeda46..0956f625 100644 --- a/internal/view/table.go +++ b/internal/view/table.go @@ -47,7 +47,9 @@ func (t *Table) Init(ctx context.Context) (err error) { if t.app.Conn() != nil { ctx = context.WithValue(ctx, internal.KeyHasMetrics, t.app.Conn().HasMetrics()) } - t.app.CustomView = config.NewCustomView() + if t.app.CustomView == nil { + t.app.CustomView = config.NewCustomView() + } ctx = context.WithValue(ctx, internal.KeyStyles, t.app.Styles) ctx = context.WithValue(ctx, internal.KeyViewConfig, t.app.CustomView) t.Table.Init(ctx) @@ -142,12 +144,14 @@ func (t *Table) Start() { t.Stop() t.CmdBuff().AddListener(t) t.Styles().AddListener(t.Table) + t.App().CustomView.AddListener(t.Table.GVR().String(), t.Table) } // Stop terminates the component. func (t *Table) Stop() { t.CmdBuff().RemoveListener(t) t.Styles().RemoveListener(t.Table) + t.App().CustomView.RemoveListener(t.GVR().String()) } // SetEnterFn specifies the default enter behavior. diff --git a/internal/view/table_int_test.go b/internal/view/table_int_test.go index 38231eae..e4a3b1fa 100644 --- a/internal/view/table_int_test.go +++ b/internal/view/table_int_test.go @@ -48,9 +48,9 @@ func TestTableNew(t *testing.T) { client.NewGVR("test"), model1.Header{ model1.HeaderColumn{Name: "NAMESPACE"}, - model1.HeaderColumn{Name: "NAME", Align: tview.AlignRight}, + model1.HeaderColumn{Name: "NAME", Attrs: model1.Attrs{Align: tview.AlignRight}}, model1.HeaderColumn{Name: "FRED"}, - model1.HeaderColumn{Name: "AGE", Time: true, Decorator: render.AgeDecorator}, + model1.HeaderColumn{Name: "AGE", Attrs: model1.Attrs{Time: true, Decorator: render.AgeDecorator}}, }, model1.NewRowEventsWithEvts( model1.RowEvent{ @@ -132,21 +132,22 @@ type mockTableModel struct{} var _ ui.Tabular = (*mockTableModel)(nil) -func (t *mockTableModel) SetInstance(string) {} -func (t *mockTableModel) SetLabelFilter(string) {} -func (t *mockTableModel) GetLabelFilter() string { return "" } -func (t *mockTableModel) Empty() bool { return false } -func (t *mockTableModel) RowCount() int { return 1 } -func (t *mockTableModel) HasMetrics() bool { return true } -func (t *mockTableModel) Peek() *model1.TableData { return makeTableData() } -func (t *mockTableModel) Refresh(context.Context) error { return nil } -func (t *mockTableModel) ClusterWide() bool { return false } -func (t *mockTableModel) GetNamespace() string { return "blee" } -func (t *mockTableModel) SetNamespace(string) {} -func (t *mockTableModel) ToggleToast() {} -func (t *mockTableModel) AddListener(model.TableListener) {} -func (t *mockTableModel) RemoveListener(model.TableListener) {} -func (t *mockTableModel) Watch(context.Context) error { return nil } +func (t *mockTableModel) SetViewSetting(context.Context, *config.ViewSetting) {} +func (t *mockTableModel) SetInstance(string) {} +func (t *mockTableModel) SetLabelFilter(string) {} +func (t *mockTableModel) GetLabelFilter() string { return "" } +func (t *mockTableModel) Empty() bool { return false } +func (t *mockTableModel) RowCount() int { return 1 } +func (t *mockTableModel) HasMetrics() bool { return true } +func (t *mockTableModel) Peek() *model1.TableData { return makeTableData() } +func (t *mockTableModel) Refresh(context.Context) error { return nil } +func (t *mockTableModel) ClusterWide() bool { return false } +func (t *mockTableModel) GetNamespace() string { return "blee" } +func (t *mockTableModel) SetNamespace(string) {} +func (t *mockTableModel) ToggleToast() {} +func (t *mockTableModel) AddListener(model.TableListener) {} +func (t *mockTableModel) RemoveListener(model.TableListener) {} +func (t *mockTableModel) Watch(context.Context) error { return nil } func (t *mockTableModel) Get(context.Context, string) (runtime.Object, error) { return nil, nil } @@ -171,9 +172,9 @@ func makeTableData() *model1.TableData { client.NewGVR("test"), model1.Header{ model1.HeaderColumn{Name: "NAMESPACE"}, - model1.HeaderColumn{Name: "NAME", Align: tview.AlignRight}, + model1.HeaderColumn{Name: "NAME", Attrs: model1.Attrs{Align: tview.AlignRight}}, model1.HeaderColumn{Name: "FRED"}, - model1.HeaderColumn{Name: "AGE", Time: true}, + model1.HeaderColumn{Name: "AGE", Attrs: model1.Attrs{Time: true}}, }, model1.NewRowEventsWithEvts( model1.RowEvent{ diff --git a/internal/vul/scanner.go b/internal/vul/scanner.go index d7c04aac..4cac816c 100644 --- a/internal/vul/scanner.go +++ b/internal/vul/scanner.go @@ -13,7 +13,6 @@ import ( "github.com/anchore/clio" "github.com/anchore/grype/cmd/grype/cli/options" "github.com/anchore/grype/grype" - "github.com/anchore/grype/grype/db/legacy/distribution" v5 "github.com/anchore/grype/grype/db/v5" "github.com/anchore/grype/grype/db/v5/matcher" @@ -161,7 +160,7 @@ func (s *imageScanner) scanWorker(ctx context.Context, img string) { } } -func (s *imageScanner) scan(ctx context.Context, img string, sc *Scan) error { +func (s *imageScanner) scan(_ context.Context, img string, sc *Scan) error { defer func(t time.Time) { log.Debug().Msgf("ScanTime %q: %v", img, time.Since(t)) }(time.Now()) diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index a4d94a36..93e5755e 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -1,6 +1,6 @@ name: k9s base: core22 -version: 'v0.32.7' +version: 'v0.40.0' 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 your clusters in a single powerful session.