K9s/release v0.32.0 (#2577)

* [Perf] improved load perf and ui updates

* [Bug] Fix #2557

* [Maint] refactor + spring cleaning up

* [Bug] Fix #2569

* [Maint] Refactor + cleanup

* [Bug] Fix #2560

* [Maint] Refactor + cleanup

* Release v0.32.0
mine
Fernand Galiana 2024-03-02 10:18:47 -07:00 committed by GitHub
parent 82ba6f9f37
commit 0d16531016
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
235 changed files with 5564 additions and 4537 deletions

View File

@ -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.31.9
VERSION ?= v0.32.0
IMG_NAME := derailed/k9s
IMAGE := ${IMG_NAME}:${VERSION}

View File

@ -0,0 +1,73 @@
<img src="https://raw.githubusercontent.com/derailed/k9s/master/assets/k9s.png" align="center" width="800" height="auto"/>
# Release v0.32.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)
## Maintenance Release!
A lot of refactors, perf improvements (crossing fingers+toes!) and general spring cleaning items in this release.
Thus I expect a bit of `disturbance in the farce` given the major code churns, so please beware!
---
## Videos Are In The Can!
Please dial [K9s Channel](https://www.youtube.com/channel/UC897uwPygni4QIjkPCpgjmw) for up coming content...
* [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)
---
## 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!!
* [Justin Reid](https://github.com/jmreid)
* [Danni](https://github.com/danninov)
* [Robert Krahn](https://github.com/rksm)
* [Hao Ke](https://github.com/kehao95)
* [PH](https://github.com/raphael-com-ph)
> Sponsorship cancellations since the last release: **9!!** 🥹
---
## Resolved Issues
* [#2569](https://github.com/derailed/k9s/issues/2569) k9s panics on start if the main config file (config.yml) is owned by root
* [#2568](https://github.com/derailed/k9s/issues/2568) kube context in running k9s is no longer sticky, during kubectx context switch
* [#2560](https://github.com/derailed/k9s/issues/2560) Namespace/Settings keeps resetting
* [#2557](https://github.com/derailed/k9s/issues/2557) [Feature]: Sort CRDs by their group
* [#1462](https://github.com/derailed/k9s/issues/1462) k9s running very slowly when opening namespace with 13k pods (maybe??)
---
## 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!!
* [#2564](https://github.com/derailed/k9s/pull/2564) Add everforest skins
* [#2558](https://github.com/derailed/k9s/pull/2558) feat: sort by role in node list view
* [#2554](https://github.com/derailed/k9s/pull/2554) Added context to the debug command for debug-container plugin
* [#2554](https://github.com/derailed/k9s/pull/2554) Correctly respect the KUBECACHEDIR env var
* [#2546](https://github.com/derailed/k9s/pull/2546) Use configured log fgColor to print log markers
---
<img src="https://raw.githubusercontent.com/derailed/k9s/master/assets/imhotep_logo.png" width="32" height="auto"/> © 2024 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0)

View File

@ -134,7 +134,7 @@ func loadConfiguration() (*config.Config, error) {
errs = errors.Join(errs, err)
}
if err := k9sCfg.Load(config.AppConfigFile); err != nil {
if err := k9sCfg.Load(config.AppConfigFile, false); err != nil {
errs = errors.Join(errs, err)
}
k9sCfg.K9s.Override(k9sFlags)
@ -151,7 +151,7 @@ func loadConfiguration() (*config.Config, error) {
}
log.Info().Msg("✅ Kubernetes connectivity")
if err := k9sCfg.Save(); err != nil {
if err := k9sCfg.Save(false); err != nil {
log.Error().Err(err).Msg("Config save")
errs = errors.Join(errs, err)
}

View File

@ -212,64 +212,26 @@ func (a *APIClient) ServerVersion() (*version.Info, error) {
return info, nil
}
func (a *APIClient) IsValidNamespace(ns string) bool {
if IsClusterWide(ns) || ns == NotNamespaced {
return true
func (a *APIClient) IsValidNamespace(n string) bool {
ok, err := a.isValidNamespace(n)
if err != nil {
log.Warn().Err(err).Msgf("namespace validation failed for: %q", n)
}
ok, err := a.CanI(ClusterScope, "v1/namespaces", "", []string{ListVerb})
if ok && err == nil {
nn, _ := a.ValidNamespaceNames()
_, ok = nn[ns]
return ok
}
ok, err = a.isValidNamespace(ns)
if ok && err == nil {
return ok
}
log.Warn().Err(err).Msgf("namespace validation failed for: %q", ns)
return false
}
func (a *APIClient) cachedNamespaceNames() NamespaceNames {
cns, ok := a.cache.Get(cacheNSKey)
if !ok {
return make(NamespaceNames)
}
return cns.(NamespaceNames)
return ok
}
func (a *APIClient) isValidNamespace(n string) (bool, error) {
if IsClusterWide(n) || n == NotNamespaced {
return true, nil
}
if a == nil {
return false, errors.New("invalid client")
}
cnss := a.cachedNamespaceNames()
if _, ok := cnss[n]; ok {
return true, nil
}
dial, err := a.Dial()
nn, err := a.ValidNamespaceNames()
if err != nil {
return false, err
}
ctx, cancel := context.WithTimeout(context.Background(), a.config.CallTimeout())
defer cancel()
_, err = dial.CoreV1().Namespaces().Get(ctx, n, metav1.GetOptions{})
if err != nil {
return false, err
}
cnss[n] = struct{}{}
a.cache.Add(cacheNSKey, cnss, cacheExpiry)
_, ok := nn[n]
return true, nil
return ok, nil
}
// ValidNamespaceNames returns all available namespaces.
@ -283,6 +245,12 @@ func (a *APIClient) ValidNamespaceNames() (NamespaceNames, error) {
return nss, nil
}
}
ok, err := a.CanI(ClusterScope, "v1/namespaces", "", []string{ListVerb})
if !ok || err != nil {
return nil, fmt.Errorf("user not authorized to list all namespaces")
}
dial, err := a.Dial()
if err != nil {
return nil, err

View File

@ -20,7 +20,7 @@ const (
defaultCallTimeoutDuration time.Duration = 15 * time.Second
// UsePersistentConfig caches client config to avoid reloads.
UsePersistentConfig = false
UsePersistentConfig = true
)
// Config tracks a kubernetes configuration.

View File

@ -4,7 +4,9 @@
package config
import (
"errors"
"fmt"
"io/fs"
"os"
"sync"
@ -136,7 +138,7 @@ func (a *Aliases) LoadFile(path string) error {
if path == "" {
return nil
}
if _, err := os.Stat(path); os.IsNotExist(err) {
if _, err := os.Stat(path); errors.Is(err, fs.ErrNotExist) {
return nil
}

View File

@ -6,6 +6,7 @@ package config
import (
"errors"
"fmt"
"io/fs"
"os"
"github.com/derailed/k9s/internal/client"
@ -52,14 +53,13 @@ func (c *Config) ContextAliasesPath() string {
}
// ContextPluginsPath returns a context specific plugins file spec.
func (c *Config) ContextPluginsPath() string {
func (c *Config) ContextPluginsPath() (string, error) {
ct, err := c.K9s.ActiveContext()
if err != nil {
log.Error().Err(err).Msgf("active context load failed")
return ""
return "", err
}
return AppContextPluginsFile(ct.GetClusterName(), c.K9s.activeContextName)
return AppContextPluginsFile(ct.GetClusterName(), c.K9s.activeContextName), nil
}
// Refine the configuration based on cli args.
@ -209,9 +209,9 @@ func (c *Config) Merge(c1 *Config) {
}
// Load loads K9s configuration from file.
func (c *Config) Load(path string) error {
if _, err := os.Stat(path); os.IsNotExist(err) {
if err := c.Save(); err != nil {
func (c *Config) Load(path string, force bool) error {
if _, err := os.Stat(path); errors.Is(err, fs.ErrNotExist) {
if err := c.Save(force); err != nil {
return err
}
}
@ -234,12 +234,12 @@ func (c *Config) Load(path string) error {
}
// Save configuration to disk.
func (c *Config) Save() error {
func (c *Config) Save(force bool) error {
c.Validate()
if err := c.K9s.Save(); err != nil {
if err := c.K9s.Save(force); err != nil {
return err
}
if _, err := os.Stat(AppConfigFile); os.IsNotExist(err) {
if _, err := os.Stat(AppConfigFile); errors.Is(err, fs.ErrNotExist) {
return c.SaveFile(AppConfigFile)
}

View File

@ -4,6 +4,7 @@
package config_test
import (
"errors"
"fmt"
"os"
"path/filepath"
@ -58,7 +59,7 @@ func TestConfigSave(t *testing.T) {
c.K9s.Override(u.k9sFlags)
assert.NoError(t, c.Refine(u.flags, u.k9sFlags, client.NewConfig(u.flags)))
}
assert.NoError(t, c.Save())
assert.NoError(t, c.Save(true))
bb, err := os.ReadFile(config.AppConfigFile)
assert.NoError(t, err)
ee, err := os.ReadFile("testdata/configs/default.yaml")
@ -265,16 +266,19 @@ func TestContextAliasesPath(t *testing.T) {
func TestContextPluginsPath(t *testing.T) {
uu := map[string]struct {
ct string
e string
ct, e string
err error
}{
"empty": {},
"empty": {
err: errors.New(`no context found for: ""`),
},
"happy": {
ct: "ct-1-1",
e: "/tmp/test/cl-1/ct-1-1/plugins.yaml",
},
"not-exists": {
ct: "fred",
ct: "fred",
err: errors.New(`no context found for: "fred"`),
},
}
@ -283,7 +287,11 @@ func TestContextPluginsPath(t *testing.T) {
t.Run(k, func(t *testing.T) {
c := mock.NewMockConfig()
_, _ = c.K9s.ActivateContext(u.ct)
assert.Equal(t, u.e, c.ContextPluginsPath())
s, err := c.ContextPluginsPath()
if err != nil {
assert.Equal(t, u.err, err)
}
assert.Equal(t, u.e, s)
})
}
}
@ -309,7 +317,7 @@ Invalid type. Expected: boolean, given: string`,
u := uu[k]
t.Run(k, func(t *testing.T) {
cfg := config.NewConfig(nil)
if err := cfg.Load(u.f); err != nil {
if err := cfg.Load(u.f, true); err != nil {
assert.Equal(t, u.err, err.Error())
}
})
@ -520,14 +528,14 @@ func TestConfigValidate(t *testing.T) {
cfg := mock.NewMockConfig()
cfg.SetConnection(mock.NewMockConnection())
assert.Nil(t, cfg.Load("testdata/configs/k9s.yaml"))
assert.Nil(t, cfg.Load("testdata/configs/k9s.yaml", true))
cfg.Validate()
}
func TestConfigLoad(t *testing.T) {
cfg := mock.NewMockConfig()
assert.Nil(t, cfg.Load("testdata/configs/k9s.yaml"))
assert.Nil(t, cfg.Load("testdata/configs/k9s.yaml", true))
assert.Equal(t, 2, cfg.K9s.RefreshRate)
assert.Equal(t, int64(200), cfg.K9s.Logger.TailCount)
assert.Equal(t, 2000, cfg.K9s.Logger.BufferSize)
@ -536,13 +544,13 @@ func TestConfigLoad(t *testing.T) {
func TestConfigLoadCrap(t *testing.T) {
cfg := mock.NewMockConfig()
assert.NotNil(t, cfg.Load("testdata/configs/k9s_not_there.yaml"))
assert.NotNil(t, cfg.Load("testdata/configs/k9s_not_there.yaml", true))
}
func TestConfigSaveFile(t *testing.T) {
cfg := mock.NewMockConfig()
assert.Nil(t, cfg.Load("testdata/configs/k9s.yaml"))
assert.Nil(t, cfg.Load("testdata/configs/k9s.yaml", true))
cfg.K9s.RefreshRate = 100
cfg.K9s.ReadOnly = true
@ -561,7 +569,7 @@ func TestConfigSaveFile(t *testing.T) {
func TestConfigReset(t *testing.T) {
cfg := mock.NewMockConfig()
assert.Nil(t, cfg.Load("testdata/configs/k9s.yaml"))
assert.Nil(t, cfg.Load("testdata/configs/k9s.yaml", true))
cfg.Reset()
cfg.Validate()

View File

@ -26,6 +26,15 @@ func NewConfig(ct *api.Context) *Config {
}
}
func (c *Config) Merge(c1 *Config) {
if c1 == nil {
return
}
if c.Context != nil && c1.Context != nil {
c.Context.merge(c1.Context)
}
}
// Validate ensures config is in norms.
func (c *Config) Validate(conn client.Connection, ks KubeSettings) {
c.mx.Lock()

View File

@ -56,6 +56,13 @@ func NewContextFromKubeConfig(ks KubeSettings) (*Context, error) {
return NewContextFromConfig(ct), nil
}
func (c *Context) merge(old *Context) {
if old == nil {
return
}
c.Namespace.merge(old.Namespace)
}
func (c *Context) GetClusterName() string {
c.mx.RLock()
defer c.mx.RUnlock()

View File

@ -0,0 +1,81 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright Authors of K9s
package data
import (
"testing"
"github.com/stretchr/testify/assert"
)
func Test_contextMerge(t *testing.T) {
uu := map[string]struct {
c1, c2, e *Context
}{
"empty": {},
"nil": {
c1: &Context{
Namespace: &Namespace{
Active: "ns1",
Favorites: []string{"ns1", "ns2", "ns3"},
},
},
e: &Context{
Namespace: &Namespace{
Active: "ns1",
Favorites: []string{"ns1", "ns2", "ns3"},
},
},
},
"deltas": {
c1: &Context{
Namespace: &Namespace{
Active: "ns1",
Favorites: []string{"ns1", "ns2", "ns3"},
},
},
c2: &Context{
Namespace: &Namespace{
Active: "ns10",
Favorites: []string{"ns10", "ns11", "ns12"},
},
},
e: &Context{
Namespace: &Namespace{
Active: "ns1",
Favorites: []string{"ns1", "ns2", "ns3", "ns10", "ns11", "ns12"},
},
},
},
"deltas-locked": {
c1: &Context{
Namespace: &Namespace{
Active: "ns1",
LockFavorites: true,
Favorites: []string{"ns1", "ns2", "ns3"},
},
},
c2: &Context{
Namespace: &Namespace{
Active: "ns10",
Favorites: []string{"ns10", "ns11", "ns12"},
},
},
e: &Context{
Namespace: &Namespace{
Active: "ns1",
LockFavorites: true,
Favorites: []string{"ns1", "ns2", "ns3"},
},
},
},
}
for k, u := range uu {
t.Run(k, func(t *testing.T) {
u.c1.merge(u.c2)
assert.Equal(t, u.e, u.c1)
})
}
}

View File

@ -6,6 +6,7 @@ package data
import (
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"sync"
@ -34,20 +35,21 @@ func (d *Dir) Load(n string, ct *api.Context) (*Config, error) {
if ct == nil {
return nil, errors.New("api.Context must not be nil")
}
var (
path = filepath.Join(d.root, SanitizeContextSubpath(ct.Cluster, n), MainConfigFile)
cfg *Config
err error
)
if f, e := os.Stat(path); os.IsNotExist(e) || f.Size() == 0 {
var path = filepath.Join(d.root, SanitizeContextSubpath(ct.Cluster, n), MainConfigFile)
f, err := os.Stat(path)
if errors.Is(err, fs.ErrPermission) {
return nil, err
}
if errors.Is(err, fs.ErrNotExist) || (f != nil && f.Size() == 0) {
log.Debug().Msgf("Context config not found! Generating... %q", path)
cfg, err = d.genConfig(path, ct)
} else {
log.Debug().Msgf("Found existing context config: %q", path)
cfg, err = d.loadConfig(path)
return d.genConfig(path, ct)
}
if err != nil {
return nil, err
}
return cfg, err
return d.loadConfig(path)
}
func (d *Dir) genConfig(path string, ct *api.Context) (*Config, error) {
@ -60,6 +62,10 @@ func (d *Dir) genConfig(path string, ct *api.Context) (*Config, error) {
}
func (d *Dir) Save(path string, c *Config) error {
if cfg, err := d.loadConfig(path); err == nil {
c.Merge(cfg)
}
d.mx.Lock()
defer d.mx.Unlock()

View File

@ -4,6 +4,8 @@
package data
import (
"errors"
"io/fs"
"os"
"path/filepath"
"regexp"
@ -38,7 +40,7 @@ func EnsureDirPath(path string, mod os.FileMode) error {
// EnsureFullPath ensures a directory exist from the given path.
func EnsureFullPath(path string, mod os.FileMode) error {
if _, err := os.Stat(path); os.IsNotExist(err) {
if _, err := os.Stat(path); errors.Is(err, fs.ErrNotExist) {
if err = os.MkdirAll(path, mod); err != nil {
return err
}

View File

@ -38,6 +38,21 @@ func NewActiveNamespace(n string) *Namespace {
}
}
func (n *Namespace) merge(old *Namespace) {
n.mx.Lock()
defer n.mx.Unlock()
if n.LockFavorites {
return
}
for _, fav := range old.Favorites {
if InList(n.Favorites, fav) {
continue
}
n.Favorites = append(n.Favorites, fav)
}
}
// Validate validates a namespace is setup correctly.
func (n *Namespace) Validate(c client.Connection) {
n.mx.RLock()

View File

@ -5,6 +5,8 @@ package config
import (
_ "embed"
"errors"
"io/fs"
"os"
"path/filepath"
@ -238,7 +240,7 @@ func EnsureBenchmarksCfgFile(cluster, context string) (string, error) {
if err := data.EnsureDirPath(f, data.DefaultDirMod); err != nil {
return "", err
}
if _, err := os.Stat(f); os.IsNotExist(err) {
if _, err := os.Stat(f); errors.Is(err, fs.ErrNotExist) {
return f, os.WriteFile(f, benchmarkTpl, data.DefaultFileMod)
}
@ -251,7 +253,7 @@ func EnsureAliasesCfgFile() (string, error) {
if err := data.EnsureDirPath(f, data.DefaultDirMod); err != nil {
return "", err
}
if _, err := os.Stat(f); os.IsNotExist(err) {
if _, err := os.Stat(f); errors.Is(err, fs.ErrNotExist) {
return f, os.WriteFile(f, aliasesTpl, data.DefaultFileMod)
}
@ -264,7 +266,7 @@ func EnsureHotkeysCfgFile() (string, error) {
if err := data.EnsureDirPath(f, data.DefaultDirMod); err != nil {
return "", err
}
if _, err := os.Stat(f); os.IsNotExist(err) {
if _, err := os.Stat(f); errors.Is(err, fs.ErrNotExist) {
return f, os.WriteFile(f, hotkeysTpl, data.DefaultFileMod)
}

View File

@ -4,7 +4,9 @@
package config
import (
"errors"
"fmt"
"io/fs"
"os"
"github.com/derailed/k9s/internal/config/data"
@ -38,7 +40,7 @@ func (h HotKeys) Load(path string) error {
if err := h.LoadHotKeys(AppHotKeysFile); err != nil {
return err
}
if _, err := os.Stat(path); os.IsNotExist(err) {
if _, err := os.Stat(path); errors.Is(err, fs.ErrNotExist) {
return nil
}
@ -47,7 +49,7 @@ func (h HotKeys) Load(path string) error {
// LoadHotKeys loads plugins from a given file.
func (h HotKeys) LoadHotKeys(path string) error {
if _, err := os.Stat(path); os.IsNotExist(err) {
if _, err := os.Stat(path); errors.Is(err, fs.ErrNotExist) {
return nil
}
bb, err := os.ReadFile(path)

View File

@ -4,7 +4,10 @@
package config
import (
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"sync"
@ -67,7 +70,7 @@ func (k *K9s) resetConnection(conn client.Connection) {
}
// Save saves the k9s config to disk.
func (k *K9s) Save() error {
func (k *K9s) Save(force bool) error {
if k.getActiveConfig() == nil {
log.Warn().Msgf("Save failed. no active config detected")
return nil
@ -77,8 +80,11 @@ func (k *K9s) Save() error {
data.SanitizeContextSubpath(k.activeConfig.Context.GetClusterName(), k.getActiveContextName()),
data.MainConfigFile,
)
if _, err := os.Stat(path); errors.Is(err, fs.ErrNotExist) || force {
return k.dir.Save(path, k.getActiveConfig())
}
return k.dir.Save(path, k.getActiveConfig())
return nil
}
// Merge merges k9s configs.

View File

@ -119,7 +119,7 @@ func Test_screenDumpDirOverride(t *testing.T) {
u := uu[k]
t.Run(k, func(t *testing.T) {
cfg := NewConfig(nil)
assert.NoError(t, cfg.Load("testdata/configs/k9s.yaml"))
assert.NoError(t, cfg.Load("testdata/configs/k9s.yaml", true))
cfg.K9s.manualScreenDumpDir = &u.dir
assert.Equal(t, u.e, cfg.K9s.AppScreenDumpDir())

View File

@ -136,13 +136,13 @@ func TestContextScreenDumpDir(t *testing.T) {
_, err := cfg.K9s.ActivateContext("ct-1-1")
assert.NoError(t, err)
assert.Nil(t, cfg.Load("testdata/configs/k9s.yaml"))
assert.Nil(t, cfg.Load("testdata/configs/k9s.yaml", true))
assert.Equal(t, "/tmp/k9s-test/screen-dumps/cl-1/ct-1-1", cfg.K9s.ContextScreenDumpDir())
}
func TestAppScreenDumpDir(t *testing.T) {
cfg := mock.NewMockConfig()
assert.Nil(t, cfg.Load("testdata/configs/k9s.yaml"))
assert.Nil(t, cfg.Load("testdata/configs/k9s.yaml", true))
assert.Equal(t, "/tmp/k9s-test/screen-dumps", cfg.K9s.AppScreenDumpDir())
}

View File

@ -4,7 +4,9 @@
package mock
import (
"errors"
"fmt"
"io/fs"
"os"
"strings"
@ -21,7 +23,7 @@ import (
)
func EnsureDir(d string) error {
if _, err := os.Stat(d); os.IsNotExist(err) {
if _, err := os.Stat(d); errors.Is(err, fs.ErrNotExist) {
return os.MkdirAll(d, 0700)
}
if err := os.RemoveAll(d); err != nil {

View File

@ -6,6 +6,7 @@ package config
import (
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
@ -94,7 +95,7 @@ func (p Plugins) loadPluginDir(dir string) error {
}
func (p *Plugins) load(path string) error {
if _, err := os.Stat(path); os.IsNotExist(err) {
if _, err := os.Stat(path); errors.Is(err, fs.ErrNotExist) {
return nil
}
bb, err := os.ReadFile(path)

View File

@ -4,8 +4,11 @@
package config
import (
"errors"
"fmt"
"io/fs"
"os"
"strings"
"github.com/derailed/k9s/internal/config/data"
"github.com/derailed/k9s/internal/config/json"
@ -25,6 +28,26 @@ type ViewSetting struct {
SortColumn string `yaml:"sortColumn"`
}
func (v *ViewSetting) HasCols() bool {
return len(v.Columns) > 0
}
func (v *ViewSetting) IsBlank() bool {
return v == nil || len(v.Columns) == 0
}
func (v *ViewSetting) SortCol() (string, bool, error) {
if v == nil || v.SortColumn == "" {
return "", false, fmt.Errorf("no sort column specified")
}
tt := strings.Split(v.SortColumn, ":")
if len(tt) < 2 {
return "", false, fmt.Errorf("invalid sort column spec: %q. must be col-name:asc|desc", v.SortColumn)
}
return tt[0], tt[1] == "desc", nil
}
// CustomView represents a collection of view customization.
type CustomView struct {
Views map[string]ViewSetting `yaml:"views"`
@ -48,7 +71,7 @@ func (v *CustomView) Reset() {
// Load loads view configurations.
func (v *CustomView) Load(path string) error {
if _, err := os.Stat(path); os.IsNotExist(err) {
if _, err := os.Stat(path); errors.Is(err, fs.ErrNotExist) {
return nil
}
bb, err := os.ReadFile(path)

View File

@ -38,7 +38,7 @@ var (
_ RefScanner = (*DaemonSet)(nil)
_ RefScanner = (*Job)(nil)
_ RefScanner = (*CronJob)(nil)
_ RefScanner = (*Pod)(nil)
// _ RefScanner = (*Pod)(nil)
)
func scanners() map[string]RefScanner {
@ -48,7 +48,7 @@ func scanners() map[string]RefScanner {
"apps/v1/daemonsets": &DaemonSet{},
"batch/v1/jobs": &Job{},
"batch/v1/cronjobs": &CronJob{},
"v1/pods": &Pod{},
// "v1/pods": &Pod{},
}
}

13
internal/dao/cm.go Normal file
View File

@ -0,0 +1,13 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright Authors of K9s
package dao
var (
_ Accessor = (*ConfigMap)(nil)
)
// ConfigMap represents a configmap resource.
type ConfigMap struct {
Resource
}

View File

@ -3,15 +3,6 @@
package dao
import (
"context"
"github.com/derailed/k9s/internal"
v1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
"k8s.io/apimachinery/pkg/labels"
"k8s.io/apimachinery/pkg/runtime"
)
var (
_ Accessor = (*CustomResourceDefinition)(nil)
_ Nuker = (*CustomResourceDefinition)(nil)
@ -21,28 +12,3 @@ var (
type CustomResourceDefinition struct {
Resource
}
// IsHappy check for happy deployments.
func (c *CustomResourceDefinition) IsHappy(crd v1.CustomResourceDefinition) bool {
versions := make([]string, 0, 3)
for _, v := range crd.Spec.Versions {
if v.Served && !v.Deprecated {
versions = append(versions, v.Name)
break
}
}
return len(versions) > 0
}
// List returns a collection of nodes.
func (c *CustomResourceDefinition) List(ctx context.Context, _ string) ([]runtime.Object, error) {
strLabel, ok := ctx.Value(internal.KeyLabels).(string)
labelSel := labels.Everything()
if sel, e := labels.ConvertSelectorToLabelsMap(strLabel); ok && e == nil {
labelSel = sel.AsSelector()
}
const gvr = "apiextensions.k8s.io/v1/customresourcedefinitions"
return c.getFactory().List(gvr, "-", false, labelSel)
}

View File

@ -49,11 +49,6 @@ func (d *Deployment) ListImages(ctx context.Context, fqn string) ([]string, erro
return render.ExtractImages(&dp.Spec.Template.Spec), nil
}
// IsHappy check for happy deployments.
func (d *Deployment) IsHappy(dp appsv1.Deployment) bool {
return dp.Status.Replicas == dp.Status.AvailableReplicas
}
// Scale a Deployment.
func (d *Deployment) Scale(ctx context.Context, path string, replicas int32) error {
ns, n := client.Namespaced(path)

View File

@ -51,11 +51,6 @@ func (d *DaemonSet) ListImages(ctx context.Context, fqn string) ([]string, error
return render.ExtractImages(&ds.Spec.Template.Spec), nil
}
// IsHappy check for happy deployments.
func (d *DaemonSet) IsHappy(ds appsv1.DaemonSet) bool {
return ds.Status.DesiredNumberScheduled == ds.Status.CurrentNumberScheduled
}
// Restart a DaemonSet rollout.
func (d *DaemonSet) Restart(ctx context.Context, path string) error {
o, err := d.getFactory().Get("apps/v1/daemonsets", path, true, labels.Everything())
@ -140,7 +135,7 @@ func podLogs(ctx context.Context, sel map[string]string, opts *LogOptions) ([]Lo
}
opts.MultiPods = true
po := Pod{}
var po Pod
po.Init(f, client.NewGVR("v1/pods"))
outs := make([]LogChan, 0, len(oo))

View File

@ -6,22 +6,58 @@ package dao
import (
"bytes"
"errors"
"fmt"
"math"
"regexp"
"github.com/derailed/k9s/internal/client"
"github.com/rs/zerolog/log"
v1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/cli-runtime/pkg/printers"
)
const defaultServiceAccount = "default"
var (
inverseRx = regexp.MustCompile(`\A\!`)
fuzzyRx = regexp.MustCompile(`\A-f\s?([\w-]+)\b`)
const (
defaultServiceAccount = "default"
defaultContainerAnnotation = "kubectl.kubernetes.io/default-container"
)
// GetDefaultContainer returns a container name if specified in an annotation.
func GetDefaultContainer(m metav1.ObjectMeta, spec v1.PodSpec) (string, bool) {
defaultContainer, ok := m.Annotations[defaultContainerAnnotation]
if !ok {
return "", false
}
for _, container := range spec.Containers {
if container.Name == defaultContainer {
return defaultContainer, true
}
}
log.Warn().Msg(defaultContainer + " container not found. " + defaultContainerAnnotation + " annotation will be ignored")
return "", false
}
func extractFQN(o runtime.Object) string {
u, ok := o.(*unstructured.Unstructured)
if !ok {
log.Error().Err(fmt.Errorf("expecting unstructured but got %T", o))
return client.NA
}
return FQN(u.GetNamespace(), u.GetName())
}
// FQN returns a fully qualified resource name.
func FQN(ns, n string) string {
if ns == "" {
return n
}
return ns + "/" + n
}
func inList(ll []string, s string) bool {
for _, l := range ll {
if l == s {
@ -31,24 +67,6 @@ func inList(ll []string, s string) bool {
return false
}
// IsInverseSelector checks if inverse char has been provided.
func IsInverseSelector(s string) bool {
if s == "" {
return false
}
return inverseRx.MatchString(s)
}
// HasFuzzySelector checks if query is fuzzy.
func HasFuzzySelector(s string) (string, bool) {
mm := fuzzyRx.FindStringSubmatch(s)
if len(mm) != 2 {
return "", false
}
return mm[1], true
}
func toPerc(v1, v2 float64) float64 {
if v2 == 0 {
return 0

View File

@ -10,6 +10,7 @@ import (
"strings"
"sync"
"github.com/derailed/k9s/internal"
"github.com/sahilm/fuzzy"
)
@ -174,7 +175,7 @@ func (l *LogItems) Filter(index int, q string, showTime bool) ([]int, [][]int, e
if q == "" {
return nil, nil, nil
}
if f, ok := HasFuzzySelector(q); ok {
if f, ok := internal.IsFuzzySelector(q); ok {
mm, ii := l.fuzzyFilter(index, f, showTime)
return mm, ii, nil
}
@ -200,7 +201,7 @@ func (l *LogItems) fuzzyFilter(index int, q string, showTime bool) ([]int, [][]i
func (l *LogItems) filterLogs(index int, q string, showTime bool) ([]int, [][]int, error) {
var invert bool
if IsInverseSelector(q) {
if internal.IsInverseSelector(q) {
invert = true
q = q[1:]
}

View File

@ -3,27 +3,11 @@
package dao
import (
"context"
"k8s.io/apimachinery/pkg/runtime"
)
var (
_ Accessor = (*Pod)(nil)
_ Accessor = (*Namespace)(nil)
)
// Namespace represents a namespace resource.
type Namespace struct {
Generic
}
// List returns a collection of namespaces.
func (n *Namespace) List(ctx context.Context, ns string) ([]runtime.Object, error) {
oo, err := n.Generic.List(ctx, ns)
if err != nil {
return nil, err
}
return oo, nil
Resource
}

View File

@ -37,9 +37,8 @@ var (
)
const (
logRetryCount = 20
logRetryWait = 1 * time.Second
defaultContainerAnnotation = "kubectl.kubernetes.io/default-container"
logRetryCount = 20
logRetryWait = 1 * time.Second
)
// Pod represents a pod resource.
@ -47,17 +46,6 @@ type Pod struct {
Resource
}
// IsHappy check for happy deployments.
func (p *Pod) IsHappy(po v1.Pod) bool {
for _, c := range po.Status.Conditions {
if c.Status == v1.ConditionFalse {
return false
}
}
return true
}
// Get returns a resource instance if found, else an error.
func (p *Pod) Get(ctx context.Context, path string) (runtime.Object, error) {
o, err := p.Resource.Get(ctx, path)
@ -425,24 +413,6 @@ func MetaFQN(m metav1.ObjectMeta) string {
return FQN(m.Namespace, m.Name)
}
// FQN returns a fully qualified resource name.
func FQN(ns, n string) string {
if ns == "" {
return n
}
return ns + "/" + n
}
func extractFQN(o runtime.Object) string {
u, ok := o.(*unstructured.Unstructured)
if !ok {
log.Error().Err(fmt.Errorf("expecting unstructured but got %T", o))
return client.NA
}
return FQN(u.GetNamespace(), u.GetName())
}
// GetPodSpec returns a pod spec given a resource.
func (p *Pod) GetPodSpec(path string) (*v1.PodSpec, error) {
pod, err := p.GetInstance(path)
@ -500,21 +470,6 @@ func (p *Pod) isControlled(path string) (string, bool, error) {
return "", false, nil
}
// GetDefaultContainer returns a container name if specified in an annotation.
func GetDefaultContainer(m metav1.ObjectMeta, spec v1.PodSpec) (string, bool) {
defaultContainer, ok := m.Annotations[defaultContainerAnnotation]
if ok {
for _, container := range spec.Containers {
if container.Name == defaultContainer {
return defaultContainer, true
}
}
log.Warn().Msg(defaultContainer + " container not found. " + defaultContainerAnnotation + " annotation will be ignored")
}
return "", false
}
func (p *Pod) Sanitize(ctx context.Context, ns string) (int, error) {
oo, err := p.Resource.List(ctx, ns)
if err != nil {

View File

@ -3,7 +3,7 @@
package dao
// !!BOZO!!
// !!BOZO!! Popeye
// import (
// "bytes"
// "context"

View File

@ -89,29 +89,32 @@ func NewMeta() *Meta {
// Customize here for non resource types or types with metrics or logs.
func AccessorFor(f Factory, gvr client.GVR) (Accessor, error) {
m := Accessors{
client.NewGVR("workloads"): &Workload{},
client.NewGVR("contexts"): &Context{},
client.NewGVR("containers"): &Container{},
client.NewGVR("scans"): &ImageScan{},
client.NewGVR("screendumps"): &ScreenDump{},
client.NewGVR("benchmarks"): &Benchmark{},
client.NewGVR("portforwards"): &PortForward{},
client.NewGVR("v1/services"): &Service{},
client.NewGVR("v1/pods"): &Pod{},
client.NewGVR("v1/nodes"): &Node{},
client.NewGVR("apps/v1/deployments"): &Deployment{},
client.NewGVR("apps/v1/daemonsets"): &DaemonSet{},
client.NewGVR("apps/v1/statefulsets"): &StatefulSet{},
client.NewGVR("apps/v1/replicasets"): &ReplicaSet{},
client.NewGVR("batch/v1/cronjobs"): &CronJob{},
client.NewGVR("batch/v1beta1/cronjobs"): &CronJob{},
client.NewGVR("batch/v1/jobs"): &Job{},
client.NewGVR("v1/namespaces"): &Namespace{},
// !!BOZO!!
client.NewGVR("workloads"): &Workload{},
client.NewGVR("contexts"): &Context{},
client.NewGVR("containers"): &Container{},
client.NewGVR("scans"): &ImageScan{},
client.NewGVR("screendumps"): &ScreenDump{},
client.NewGVR("benchmarks"): &Benchmark{},
client.NewGVR("portforwards"): &PortForward{},
client.NewGVR("dir"): &Dir{},
client.NewGVR("v1/services"): &Service{},
client.NewGVR("v1/pods"): &Pod{},
client.NewGVR("v1/nodes"): &Node{},
client.NewGVR("v1/namespaces"): &Namespace{},
client.NewGVR("v1/configmap"): &ConfigMap{},
client.NewGVR("v1/secrets"): &Secret{},
client.NewGVR("apps/v1/deployments"): &Deployment{},
client.NewGVR("apps/v1/daemonsets"): &DaemonSet{},
client.NewGVR("apps/v1/statefulsets"): &StatefulSet{},
client.NewGVR("apps/v1/replicasets"): &ReplicaSet{},
client.NewGVR("batch/v1/cronjobs"): &CronJob{},
client.NewGVR("batch/v1beta1/cronjobs"): &CronJob{},
client.NewGVR("batch/v1/jobs"): &Job{},
client.NewGVR("helm"): &HelmChart{},
client.NewGVR("helm-history"): &HelmHistory{},
client.NewGVR("apiextensions.k8s.io/v1/customresourcedefinitions"): &CustomResourceDefinition{},
// !!BOZO!! Popeye
//client.NewGVR("popeye"): &Popeye{},
client.NewGVR("helm"): &HelmChart{},
client.NewGVR("helm-history"): &HelmHistory{},
client.NewGVR("dir"): &Dir{},
}
r, ok := m[gvr]
@ -369,6 +372,7 @@ func loadPreferred(f Factory, m ResourceMetas) error {
if err != nil {
return err
}
dial.Invalidate()
rr, err := dial.ServerPreferredResources()
if err != nil {
log.Debug().Err(err).Msgf("Failed to load preferred resources")

View File

@ -15,13 +15,13 @@ import (
// Secret represents a secret K8s resource.
type Secret struct {
Table
Resource
decode bool
}
// Describe describes a secret that can be encoded or decoded.
func (s *Secret) Describe(path string) (string, error) {
encodedDescription, err := s.Table.Describe(path)
encodedDescription, err := s.Generic.Describe(path)
if err != nil {
return "", err
@ -51,13 +51,13 @@ func (s *Secret) Decode(encodedDescription, path string) (string, error) {
dataEndIndex := strings.Index(encodedDescription, "====")
if dataEndIndex == -1 {
return "", fmt.Errorf("Unable to find data section in secret description")
return "", fmt.Errorf("unable to find data section in secret description")
}
dataEndIndex += 4
if dataEndIndex >= len(encodedDescription) {
return "", fmt.Errorf("Data section in secret description is invalid")
return "", fmt.Errorf("data section in secret description is invalid")
}
// Remove the encoded part from k8s's describe API

View File

@ -50,11 +50,6 @@ func (s *StatefulSet) ListImages(ctx context.Context, fqn string) ([]string, err
return render.ExtractImages(&sts.Spec.Template.Spec), nil
}
// IsHappy check for happy sts.
func (s *StatefulSet) IsHappy(sts appsv1.StatefulSet) bool {
return sts.Status.Replicas == sts.Status.ReadyReplicas
}
// Scale a StatefulSet.
func (s *StatefulSet) Scale(ctx context.Context, path string, replicas int32) error {
ns, n := client.Namespaced(path)

View File

@ -16,7 +16,9 @@ import (
"k8s.io/client-go/rest"
)
// BOZO!! Figure out how to convert to table def and use factory.
const gvFmt = "application/json;as=Table;v=%s;g=%s, application/json"
var genScheme = runtime.NewScheme()
// Table retrieves K8s resources as tabular data.
type Table struct {
@ -25,19 +27,19 @@ type Table struct {
// Get returns a given resource.
func (t *Table) Get(ctx context.Context, path string) (runtime.Object, error) {
a := fmt.Sprintf(gvFmt, metav1.SchemeGroupVersion.Version, metav1.GroupName)
_, codec := t.codec()
c, err := t.getClient()
f, p := t.codec()
c, err := t.getClient(f)
if err != nil {
return nil, err
}
ns, n := client.Namespaced(path)
a := fmt.Sprintf(gvFmt, metav1.SchemeGroupVersion.Version, metav1.GroupName)
req := c.Get().
SetHeader("Accept", a).
Name(n).
Resource(t.gvr.R()).
VersionedParams(&metav1.TableOptions{}, codec)
VersionedParams(&metav1.TableOptions{}, p)
if ns != client.ClusterScope {
req = req.Namespace(ns)
}
@ -48,18 +50,24 @@ func (t *Table) Get(ctx context.Context, path string) (runtime.Object, error) {
// List all Resources in a given namespace.
func (t *Table) List(ctx context.Context, ns string) ([]runtime.Object, error) {
labelSel, _ := ctx.Value(internal.KeyLabels).(string)
a := fmt.Sprintf(gvFmt, metav1.SchemeGroupVersion.Version, metav1.GroupName)
_, codec := t.codec()
fieldSel, _ := ctx.Value(internal.KeyFields).(string)
c, err := t.getClient()
f, p := t.codec()
c, err := t.getClient(f)
if err != nil {
return nil, err
}
a := fmt.Sprintf(gvFmt, metav1.SchemeGroupVersion.Version, metav1.GroupName)
o, err := c.Get().
SetHeader("Accept", a).
Namespace(ns).
Resource(t.gvr.R()).
VersionedParams(&metav1.ListOptions{LabelSelector: labelSel}, codec).
VersionedParams(&metav1.ListOptions{
LabelSelector: labelSel,
FieldSelector: fieldSel,
ResourceVersion: "0",
ResourceVersionMatch: v1.ResourceVersionMatchNotOlderThan,
}, p).
Do(ctx).Get()
if err != nil {
return nil, err
@ -71,9 +79,7 @@ func (t *Table) List(ctx context.Context, ns string) ([]runtime.Object, error) {
// ----------------------------------------------------------------------------
// Helpers...
const gvFmt = "application/json;as=Table;v=%s;g=%s, application/json"
func (t *Table) getClient() (*rest.RESTClient, error) {
func (t *Table) getClient(f serializer.CodecFactory) (*rest.RESTClient, error) {
cfg, err := t.Client().RestConfig()
if err != nil {
return nil, err
@ -84,8 +90,7 @@ func (t *Table) getClient() (*rest.RESTClient, error) {
if t.gvr.G() == "" {
cfg.APIPath = "/api"
}
codec, _ := t.codec()
cfg.NegotiatedSerializer = codec.WithoutConversion()
cfg.NegotiatedSerializer = f.WithoutConversion()
crRestClient, err := rest.RESTClientFor(cfg)
if err != nil {
@ -96,11 +101,12 @@ func (t *Table) getClient() (*rest.RESTClient, error) {
}
func (t *Table) codec() (serializer.CodecFactory, runtime.ParameterCodec) {
scheme := runtime.NewScheme()
var tt metav1.Table
opts := metav1.TableOptions{IncludeObject: v1.IncludeObject}
gv := t.gvr.GV()
metav1.AddToGroupVersion(scheme, gv)
scheme.AddKnownTypes(gv, &metav1.Table{}, &metav1.TableOptions{IncludeObject: v1.IncludeObject})
scheme.AddKnownTypes(metav1.SchemeGroupVersion, &metav1.Table{}, &metav1.TableOptions{IncludeObject: v1.IncludeObject})
metav1.AddToGroupVersion(genScheme, gv)
genScheme.AddKnownTypes(gv, &tt, &opts)
genScheme.AddKnownTypes(metav1.SchemeGroupVersion, &tt, &opts)
return serializer.NewCodecFactory(scheme), runtime.NewParameterCodec(scheme)
return serializer.NewCodecFactory(genScheme), runtime.NewParameterCodec(genScheme)
}

46
internal/helpers.go Normal file
View File

@ -0,0 +1,46 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright Authors of K9s
package internal
import (
"regexp"
"strings"
"github.com/derailed/k9s/internal/view/cmd"
)
var (
inverseRx = regexp.MustCompile(`\A\!`)
fuzzyRx = regexp.MustCompile(`\A-f\s?([\w-]+)\b`)
labelRx = regexp.MustCompile(`\A\-l`)
)
// Helpers...
// IsInverseSelector checks if inverse char has been provided.
func IsInverseSelector(s string) bool {
if s == "" {
return false
}
return inverseRx.MatchString(s)
}
// IsLabelSelector checks if query is a label query.
func IsLabelSelector(s string) bool {
if labelRx.MatchString(s) {
return true
}
return !strings.Contains(s, " ") && cmd.ToLabels(s) != nil
}
// IsFuzzySelector checks if query is fuzzy.
func IsFuzzySelector(s string) (string, bool) {
mm := fuzzyRx.FindStringSubmatch(s)
if len(mm) != 2 {
return "", false
}
return mm[1], true
}

33
internal/helpers_test.go Normal file
View File

@ -0,0 +1,33 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright Authors of K9s
package internal_test
import (
"testing"
"github.com/derailed/k9s/internal"
"github.com/stretchr/testify/assert"
)
func TestIsLabelSelector(t *testing.T) {
uu := map[string]struct {
s string
ok bool
}{
"empty": {s: ""},
"cool": {s: "-l app=fred,env=blee", ok: true},
"no-flag": {s: "app=fred,env=blee", ok: true},
"no-space": {s: "-lapp=fred,env=blee", ok: true},
"wrong-flag": {s: "-f app=fred,env=blee"},
"missing-key": {s: "=fred"},
"missing-val": {s: "fred="},
}
for k := range uu {
u := uu[k]
t.Run(k, func(t *testing.T) {
assert.Equal(t, u.ok, internal.IsLabelSelector(u.s))
})
}
}

View File

@ -108,7 +108,7 @@ func (c *Cluster) Metrics(ctx context.Context, mx *client.ClusterMetrics) error
}
}
if nn == nil {
return errors.New("Unable to fetch nodes list")
return errors.New("unable to fetch nodes list")
}
if len(nn.Items) > 0 {
c.cache.Add(clusterNodesKey, nn, clusterCacheExpiry)

View File

@ -143,8 +143,6 @@ func (c *ClusterInfo) Refresh() {
var mx client.ClusterMetrics
if err := c.cluster.Metrics(ctx, &mx); err == nil {
data.Cpu, data.Mem, data.Ephemeral = mx.PercCPU, mx.PercMEM, mx.PercEphemeral
} else {
log.Warn().Err(err).Msgf("Cluster metrics failed")
}
}
data.K9sVer = c.version

View File

@ -12,6 +12,7 @@ import (
"time"
backoff "github.com/cenkalti/backoff/v4"
"github.com/derailed/k9s/internal"
"github.com/derailed/k9s/internal/client"
"github.com/derailed/k9s/internal/dao"
"github.com/rs/zerolog/log"
@ -66,7 +67,7 @@ func (d *Describe) filter(q string, lines []string) fuzzy.Matches {
if q == "" {
return nil
}
if f, ok := dao.HasFuzzySelector(q); ok {
if f, ok := internal.IsFuzzySelector(q); ok {
return d.fuzzyFilter(strings.TrimSpace(f), lines)
}
return rxFilter(q, lines)

View File

@ -10,6 +10,7 @@ import (
"github.com/derailed/k9s/internal/client"
"github.com/derailed/k9s/internal/dao"
"github.com/derailed/k9s/internal/health"
"github.com/derailed/k9s/internal/model1"
"github.com/derailed/k9s/internal/render"
"github.com/rs/zerolog/log"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@ -123,14 +124,14 @@ func (h *PulseHealth) check(ctx context.Context, ns, gvr string) (*health.Check,
if !ok {
return nil, fmt.Errorf("expecting a meta table but got %T", oo[0])
}
rows := make(render.Rows, len(table.Rows))
re, _ := meta.Renderer.(Generic)
rows := make(model1.Rows, len(table.Rows))
re, _ := meta.Renderer.(model1.Generic)
re.SetTable(ns, table)
for i, row := range table.Rows {
if err := re.Render(row, ns, &rows[i]); err != nil {
return nil, err
}
if !render.Happy(ns, re.Header(ns), rows[i]) {
if !model1.IsValid(ns, re.Header(ns), rows[i]) {
c.Inc(health.S2)
continue
}
@ -140,12 +141,12 @@ func (h *PulseHealth) check(ctx context.Context, ns, gvr string) (*health.Check,
return c, nil
}
c.Total(int64(len(oo)))
rr, re := make(render.Rows, len(oo)), meta.Renderer
rr, re := make(model1.Rows, len(oo)), meta.Renderer
for i, o := range oo {
if err := re.Render(o, ns, &rr[i]); err != nil {
return nil, err
}
if !render.Happy(ns, re.Header(ns), rr[i]) {
if !model1.IsValid(ns, re.Header(ns), rr[i]) {
c.Inc(health.S2)
continue
}

View File

@ -82,7 +82,7 @@ var Registry = map[string]ResourceMeta{
DAO: &dao.Alias{},
Renderer: &render.Alias{},
},
// !!BOZO!!
// !!BOZO!! Popeye
//"popeye": {
// DAO: &dao.Popeye{},
// Renderer: &render.Popeye{},
@ -102,8 +102,17 @@ var Registry = map[string]ResourceMeta{
TreeRenderer: &xray.Pod{},
},
"v1/namespaces": {
DAO: &dao.Namespace{},
Renderer: &render.Namespace{},
},
"v1/secrets": {
DAO: &dao.Secret{},
Renderer: &render.Secret{},
},
"v1/configmaps": {
DAO: &dao.ConfigMap{},
Renderer: &render.ConfigMap{},
},
"v1/nodes": {
DAO: &dao.Node{},
Renderer: &render.Node{},
@ -113,6 +122,10 @@ var Registry = map[string]ResourceMeta{
Renderer: &render.Service{},
TreeRenderer: &xray.Service{},
},
"v1/events": {
DAO: &dao.Table{},
Renderer: &render.Event{},
},
"v1/serviceaccounts": {
Renderer: &render.ServiceAccount{},
},
@ -122,14 +135,6 @@ var Registry = map[string]ResourceMeta{
"v1/persistentvolumeclaims": {
Renderer: &render.PersistentVolumeClaim{},
},
"v1/events": {
DAO: &dao.Table{},
Renderer: &render.Event{},
},
"v1/secrets": {
DAO: &dao.Secret{},
Renderer: &render.Generic{},
},
// Apps...
"apps/v1/deployments": {
@ -169,6 +174,7 @@ var Registry = map[string]ResourceMeta{
// CRDs...
"apiextensions.k8s.io/v1/customresourcedefinitions": {
DAO: &dao.CustomResourceDefinition{},
Renderer: &render.CustomResourceDefinition{},
},

View File

@ -10,6 +10,7 @@ import (
"time"
backoff "github.com/cenkalti/backoff/v4"
"github.com/derailed/k9s/internal"
"github.com/derailed/k9s/internal/client"
"github.com/derailed/k9s/internal/dao"
"github.com/rs/zerolog/log"
@ -84,7 +85,7 @@ func (v *RevValues) filter(q string, lines []string) fuzzy.Matches {
if q == "" {
return nil
}
if f, ok := dao.HasFuzzySelector(q); ok {
if f, ok := internal.IsFuzzySelector(q); ok {
return v.fuzzyFilter(strings.TrimSpace(f), lines)
}
return rxFilter(q, lines)

View File

@ -112,6 +112,7 @@ func (s *Stack) Pop() (Component, bool) {
s.mx.Lock()
{
c = s.components[len(s.components)-1]
c.Stop()
s.components = s.components[:len(s.components)-1]
}
s.mx.Unlock()

View File

@ -14,7 +14,7 @@ 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/derailed/k9s/internal/model1"
"github.com/rs/zerolog/log"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
@ -25,7 +25,7 @@ const initRefreshRate = 300 * time.Millisecond
// TableListener represents a table model listener.
type TableListener interface {
// TableDataChanged notifies the model data changed.
TableDataChanged(*render.TableData)
TableDataChanged(*model1.TableData)
// TableLoadFailed notifies the load failed.
TableLoadFailed(error)
@ -34,21 +34,20 @@ type TableListener interface {
// Table represents a table model.
type Table struct {
gvr client.GVR
namespace string
data *render.TableData
data *model1.TableData
listeners []TableListener
inUpdate int32
refreshRate time.Duration
instance string
mx sync.RWMutex
labelFilter string
mx sync.RWMutex
}
// NewTable returns a new table model.
func NewTable(gvr client.GVR) *Table {
return &Table{
gvr: gvr,
data: render.NewTableData(),
data: model1.NewTableData(gvr),
refreshRate: 2 * time.Second,
}
}
@ -141,18 +140,17 @@ func (t *Table) Delete(ctx context.Context, path string, propagation *metav1.Del
// GetNamespace returns the model namespace.
func (t *Table) GetNamespace() string {
return t.namespace
return t.data.GetNamespace()
}
// SetNamespace sets up model namespace.
func (t *Table) SetNamespace(ns string) {
t.namespace = ns
t.data.Clear()
t.data.Reset(ns)
}
// InNamespace checks if current namespace matches desired namespace.
func (t *Table) InNamespace(ns string) bool {
return len(t.data.RowEvents) > 0 && t.namespace == ns
return t.data.GetNamespace() == ns && !t.data.Empty()
}
// SetRefreshRate sets model refresh duration.
@ -162,7 +160,7 @@ func (t *Table) SetRefreshRate(d time.Duration) {
// ClusterWide checks if resource is scope for all namespaces.
func (t *Table) ClusterWide() bool {
return client.IsClusterWide(t.namespace)
return client.IsClusterWide(t.data.GetNamespace())
}
// Empty returns true if no model data.
@ -170,13 +168,13 @@ func (t *Table) Empty() bool {
return t.data.Empty()
}
// Count returns the row count.
func (t *Table) Count() int {
return t.data.Count()
// RowCount returns the row count.
func (t *Table) RowCount() int {
return t.data.RowCount()
}
// Peek returns model data.
func (t *Table) Peek() *render.TableData {
func (t *Table) Peek() *model1.TableData {
t.mx.RLock()
defer t.mx.RUnlock()
@ -184,8 +182,6 @@ func (t *Table) Peek() *render.TableData {
}
func (t *Table) updater(ctx context.Context) {
defer log.Debug().Msgf("TABLE-UPDATER canceled -- %q", t.gvr)
bf := backoff.NewExponentialBackOff()
bf.InitialInterval, bf.MaxElapsedTime = initRefreshRate, maxReaderRetryInterval
rate := initRefreshRate
@ -199,7 +195,7 @@ func (t *Table) updater(ctx context.Context) {
return t.refresh(ctx)
}, backoff.WithContext(bf, ctx))
if err != nil {
log.Error().Err(err).Msgf("Retry failed")
log.Warn().Err(err).Msgf("reconciler exited")
t.fireTableLoadFailed(err)
return
}
@ -229,24 +225,25 @@ func (t *Table) list(ctx context.Context, a dao.Accessor) ([]runtime.Object, err
}
a.Init(factory, t.gvr)
ns := client.CleanseNamespace(t.namespace)
if client.IsClusterScoped(t.namespace) {
t.mx.RLock()
ctx = context.WithValue(ctx, internal.KeyLabels, t.labelFilter)
t.mx.RUnlock()
ns := client.CleanseNamespace(t.data.GetNamespace())
if client.IsClusterScoped(ns) {
ns = client.BlankNamespace
}
ctx = context.WithValue(ctx, internal.KeyLabels, t.labelFilter)
return a.List(ctx, ns)
}
func (t *Table) reconcile(ctx context.Context) error {
t.mx.Lock()
defer t.mx.Unlock()
meta := resourceMeta(t.gvr)
ctx = context.WithValue(ctx, internal.KeyLabels, t.labelFilter)
var (
oo []runtime.Object
err error
)
meta := resourceMeta(t.gvr)
ctx = context.WithValue(ctx, internal.KeyLabels, t.labelFilter)
if t.instance == "" {
oo, err = t.list(ctx, meta.DAO)
} else {
@ -257,41 +254,10 @@ func (t *Table) reconcile(ctx context.Context) error {
return err
}
var rows render.Rows
if len(oo) > 0 {
if meta.Renderer.IsGeneric() {
table, ok := oo[0].(*metav1.Table)
if !ok {
return fmt.Errorf("expecting a meta table but got %T", oo[0])
}
rows = make(render.Rows, len(table.Rows))
if err := genericHydrate(t.namespace, table, rows, meta.Renderer); err != nil {
return err
}
} else {
rows = make(render.Rows, len(oo))
if err := hydrate(t.namespace, oo, rows, meta.Renderer); err != nil {
return err
}
}
}
// if labelSelector in place might as well clear the model data.
sel, ok := ctx.Value(internal.KeyLabels).(string)
if ok && sel != "" {
t.data.Clear()
}
t.data.Update(rows)
t.data.SetHeader(t.namespace, meta.Renderer.Header(t.namespace))
if len(t.data.Header) == 0 {
return fmt.Errorf("fail to list resource %s", t.gvr)
}
return nil
return t.data.Reconcile(ctx, meta.Renderer, oo)
}
func (t *Table) fireTableChanged(data *render.TableData) {
func (t *Table) fireTableChanged(data *model1.TableData) {
var ll []TableListener
t.mx.RLock()
ll = t.listeners
@ -312,43 +278,3 @@ func (t *Table) fireTableLoadFailed(err error) {
l.TableLoadFailed(err)
}
}
// ----------------------------------------------------------------------------
// Helpers...
func hydrate(ns string, oo []runtime.Object, rr render.Rows, re Renderer) error {
for i, o := range oo {
if err := re.Render(o, ns, &rr[i]); err != nil {
return err
}
}
return nil
}
// Generic represents a generic resource.
type Generic interface {
// SetTable sets up the resource tabular definition.
SetTable(ns string, table *metav1.Table)
// Header returns a resource header.
Header(ns string) render.Header
// Render renders the resource.
Render(o interface{}, ns string, row *render.Row) error
}
func genericHydrate(ns string, table *metav1.Table, rr render.Rows, re Renderer) error {
gr, ok := re.(Generic)
if !ok {
return fmt.Errorf("expecting generic renderer but got %T", re)
}
gr.SetTable(ns, table)
for i, row := range table.Rows {
if err := gr.Render(row, ns, &rr[i]); err != nil {
return err
}
}
return nil
}

View File

@ -13,11 +13,11 @@ import (
"github.com/derailed/k9s/internal"
"github.com/derailed/k9s/internal/client"
"github.com/derailed/k9s/internal/dao"
"github.com/derailed/k9s/internal/model1"
"github.com/derailed/k9s/internal/render"
"github.com/derailed/k9s/internal/watch"
"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/labels"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/client-go/informers"
@ -35,9 +35,9 @@ func TestTableReconcile(t *testing.T) {
err := ta.reconcile(ctx)
assert.Nil(t, err)
data := ta.Peek()
assert.Equal(t, 23, len(data.Header))
assert.Equal(t, 1, len(data.RowEvents))
assert.Equal(t, client.NamespaceAll, data.Namespace)
assert.Equal(t, 23, data.HeaderCount())
assert.Equal(t, 1, data.RowCount())
assert.Equal(t, client.NamespaceAll, data.GetNamespace())
}
func TestTableList(t *testing.T) {
@ -66,12 +66,10 @@ func TestTableGet(t *testing.T) {
}
func TestTableMeta(t *testing.T) {
pd := dao.Pod{}
pd.Init(makeFactory(), client.NewGVR("v1/pods"))
uu := map[string]struct {
gvr string
accessor dao.Accessor
renderer Renderer
renderer model1.Renderer
}{
"generic": {
gvr: "containers",
@ -84,9 +82,9 @@ func TestTableMeta(t *testing.T) {
renderer: &render.Node{},
},
"table": {
gvr: "v1/configmaps",
gvr: "v1/events",
accessor: &dao.Table{},
renderer: &render.Generic{},
renderer: &render.Event{},
},
}
@ -100,44 +98,6 @@ func TestTableMeta(t *testing.T) {
}
}
func TestTableHydrate(t *testing.T) {
oo := []runtime.Object{
&render.PodWithMetrics{Raw: load(t, "p1")},
}
rr := make([]render.Row, 1)
assert.Nil(t, hydrate("blee", oo, rr, render.Pod{}))
assert.Equal(t, 1, len(rr))
assert.Equal(t, 23, len(rr[0].Fields))
}
func TestTableGenericHydrate(t *testing.T) {
raw := raw(t, "p1")
tt := metav1beta1.Table{
ColumnDefinitions: []metav1beta1.TableColumnDefinition{
{Name: "c1"},
{Name: "c2"},
},
Rows: []metav1beta1.TableRow{
{
Cells: []interface{}{"fred", 10},
Object: runtime.RawExtension{Raw: raw},
},
{
Cells: []interface{}{"blee", 20},
Object: runtime.RawExtension{Raw: raw},
},
},
}
rr := make([]render.Row, 2)
re := render.Generic{}
re.SetTable("blee", &tt)
assert.Nil(t, genericHydrate("blee", &tt, rr, &re))
assert.Equal(t, 2, len(rr))
assert.Equal(t, 3, len(rr[0].Fields))
}
// ----------------------------------------------------------------------------
// Helpers...
@ -162,12 +122,6 @@ func load(t *testing.T, n string) *unstructured.Unstructured {
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
}
// ----------------------------------------------------------------------------
func makeFactory() testFactory {

View File

@ -14,7 +14,7 @@ import (
"github.com/derailed/k9s/internal/client"
"github.com/derailed/k9s/internal/dao"
"github.com/derailed/k9s/internal/model"
"github.com/derailed/k9s/internal/render"
"github.com/derailed/k9s/internal/model1"
"github.com/derailed/k9s/internal/watch"
"github.com/stretchr/testify/assert"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
@ -36,9 +36,9 @@ func TestTableRefresh(t *testing.T) {
ctx = context.WithValue(ctx, internal.KeyWithMetrics, false)
assert.NoError(t, ta.Refresh(ctx))
data := ta.Peek()
assert.Equal(t, 23, len(data.Header))
assert.Equal(t, 1, len(data.RowEvents))
assert.Equal(t, client.NamespaceAll, data.Namespace)
assert.Equal(t, 23, data.HeaderCount())
assert.Equal(t, 1, data.RowCount())
assert.Equal(t, client.NamespaceAll, data.GetNamespace())
assert.Equal(t, 1, l.count)
assert.Equal(t, 0, l.errs)
}
@ -75,7 +75,7 @@ type tableListener struct {
count, errs int
}
func (l *tableListener) TableDataChanged(*render.TableData) {
func (l *tableListener) TableDataChanged(*model1.TableData) {
l.count++
}

View File

@ -6,7 +6,7 @@ package model
import (
"strings"
"github.com/derailed/k9s/internal/dao"
"github.com/derailed/k9s/internal"
"github.com/sahilm/fuzzy"
)
@ -111,7 +111,7 @@ func (t *Text) filter(q string, lines []string) fuzzy.Matches {
if q == "" {
return nil
}
if f, ok := dao.HasFuzzySelector(q); ok {
if f, ok := internal.IsFuzzySelector(q); ok {
return t.fuzzyFilter(strings.TrimSpace(f), lines)
}
return rxFilter(q, lines)

View File

@ -9,7 +9,7 @@ import (
"github.com/derailed/k9s/internal/client"
"github.com/derailed/k9s/internal/dao"
"github.com/derailed/k9s/internal/render"
"github.com/derailed/k9s/internal/model1"
"github.com/derailed/tview"
"github.com/sahilm/fuzzy"
"k8s.io/apimachinery/pkg/runtime"
@ -100,21 +100,6 @@ type Filterer interface {
SetLabelFilter(map[string]string)
}
// Renderer represents a resource renderer.
type Renderer interface {
// IsGeneric identifies a generic handler.
IsGeneric() bool
// Render converts raw resources to tabular data.
Render(o interface{}, ns string, row *render.Row) error
// Header returns the resource header.
Header(ns string) render.Header
// ColorerFunc returns a row colorer function.
ColorerFunc() render.ColorerFunc
}
// Cruder performs crud operations.
type Cruder interface {
// List returns a collection of resources.
@ -149,6 +134,6 @@ type TreeRenderer interface {
// ResourceMeta represents model info about a resource.
type ResourceMeta struct {
DAO dao.Accessor
Renderer Renderer
Renderer model1.Renderer
TreeRenderer TreeRenderer
}

View File

@ -11,6 +11,7 @@ import (
"time"
backoff "github.com/cenkalti/backoff/v4"
"github.com/derailed/k9s/internal"
"github.com/derailed/k9s/internal/client"
"github.com/derailed/k9s/internal/dao"
"github.com/rs/zerolog/log"
@ -113,7 +114,7 @@ func (v *Values) filter(q string, lines []string) fuzzy.Matches {
if q == "" {
return nil
}
if f, ok := dao.HasFuzzySelector(q); ok {
if f, ok := internal.IsFuzzySelector(q); ok {
return v.fuzzyFilter(strings.TrimSpace(f), lines)
}
return rxFilter(q, lines)

View File

@ -74,7 +74,7 @@ func (y *YAML) filter(q string, lines []string) fuzzy.Matches {
if q == "" {
return nil
}
if f, ok := dao.HasFuzzySelector(q); ok {
if f, ok := internal.IsFuzzySelector(q); ok {
return y.fuzzyFilter(strings.TrimSpace(f), lines)
}
return rxFilter(q, lines)

View File

@ -1,11 +1,9 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright Authors of K9s
package render
package model1
import (
"github.com/derailed/tcell/v2"
)
import "github.com/derailed/tcell/v2"
var (
// ModColor row modified color.
@ -33,12 +31,9 @@ var (
CompletedColor tcell.Color
)
// ColorerFunc represents a resource row colorer.
type ColorerFunc func(ns string, h Header, re RowEvent) tcell.Color
// DefaultColorer set the default table row colors.
func DefaultColorer(ns string, h Header, re RowEvent) tcell.Color {
if !Happy(ns, h, re.Row) {
func DefaultColorer(ns string, h Header, re *RowEvent) tcell.Color {
if !IsValid(ns, h, re.Row) {
return ErrColor
}

View File

@ -0,0 +1,66 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright Authors of K9s
package model1_test
import (
"testing"
"github.com/derailed/k9s/internal/model1"
"github.com/derailed/tcell/v2"
"github.com/stretchr/testify/assert"
)
func TestDefaultColorer(t *testing.T) {
uu := map[string]struct {
re model1.RowEvent
e tcell.Color
}{
"add": {
model1.RowEvent{
Kind: model1.EventAdd,
},
model1.AddColor,
},
"update": {
model1.RowEvent{
Kind: model1.EventUpdate,
},
model1.ModColor,
},
"delete": {
model1.RowEvent{
Kind: model1.EventDelete,
},
model1.KillColor,
},
"no-change": {
model1.RowEvent{
Kind: model1.EventUnchanged,
},
model1.StdColor,
},
"invalid": {
model1.RowEvent{
Kind: model1.EventUnchanged,
Row: model1.Row{
Fields: model1.Fields{"", "", "blah"},
},
},
model1.ErrColor,
},
}
h := model1.Header{
model1.HeaderColumn{Name: "A"},
model1.HeaderColumn{Name: "B"},
model1.HeaderColumn{Name: "VALID"},
}
for k := range uu {
u := uu[k]
t.Run(k, func(t *testing.T) {
assert.Equal(t, u.e, model1.DefaultColorer("", h, &u.re))
})
}
}

View File

@ -1,11 +1,9 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright Authors of K9s
package render
package model1
import (
"reflect"
)
import "reflect"
// DeltaRow represents a collection of row deltas between old and new row.
type DeltaRow []string

View File

@ -0,0 +1,266 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright Authors of K9s
package model1_test
import (
"testing"
"github.com/derailed/k9s/internal/model1"
"github.com/stretchr/testify/assert"
)
func TestDeltaLabelize(t *testing.T) {
uu := map[string]struct {
o model1.Row
n model1.Row
e model1.DeltaRow
}{
"same": {
o: model1.Row{
Fields: model1.Fields{"a", "b", "blee=fred,doh=zorg"},
},
n: model1.Row{
Fields: model1.Fields{"a", "b", "blee=fred1,doh=zorg"},
},
e: model1.DeltaRow{"", "", "fred", "zorg"},
},
}
hh := model1.Header{
model1.HeaderColumn{Name: "A"},
model1.HeaderColumn{Name: "B"},
model1.HeaderColumn{Name: "C"},
}
for k := range uu {
u := uu[k]
t.Run(k, func(t *testing.T) {
d := model1.NewDeltaRow(u.o, u.n, hh)
d = d.Labelize([]int{0, 1}, 2)
assert.Equal(t, u.e, d)
})
}
}
func TestDeltaCustomize(t *testing.T) {
uu := map[string]struct {
r1, r2 model1.Row
cols []int
e model1.DeltaRow
}{
"same": {
r1: model1.Row{
Fields: model1.Fields{"a", "b", "c"},
},
r2: model1.Row{
Fields: model1.Fields{"a", "b", "c"},
},
cols: []int{0, 1, 2},
e: model1.DeltaRow{"", "", ""},
},
"empty": {
r1: model1.Row{
Fields: model1.Fields{"a", "b", "c"},
},
r2: model1.Row{
Fields: model1.Fields{"a", "b", "c"},
},
e: model1.DeltaRow{},
},
"diff-full": {
r1: model1.Row{
Fields: model1.Fields{"a", "b", "c"},
},
r2: model1.Row{
Fields: model1.Fields{"a1", "b1", "c1"},
},
cols: []int{0, 1, 2},
e: model1.DeltaRow{"a", "b", "c"},
},
"diff-reverse": {
r1: model1.Row{
Fields: model1.Fields{"a", "b", "c"},
},
r2: model1.Row{
Fields: model1.Fields{"a1", "b1", "c1"},
},
cols: []int{2, 1, 0},
e: model1.DeltaRow{"c", "b", "a"},
},
"diff-skip": {
r1: model1.Row{
Fields: model1.Fields{"a", "b", "c"},
},
r2: model1.Row{
Fields: model1.Fields{"a1", "b1", "c1"},
},
cols: []int{2, 0},
e: model1.DeltaRow{"c", "a"},
},
"diff-missing": {
r1: model1.Row{
Fields: model1.Fields{"a", "b", "c"},
},
r2: model1.Row{
Fields: model1.Fields{"a1", "b1", "c1"},
},
cols: []int{2, 10, 0},
e: model1.DeltaRow{"c", "", "a"},
},
"diff-negative": {
r1: model1.Row{
Fields: model1.Fields{"a", "b", "c"},
},
r2: model1.Row{
Fields: model1.Fields{"a1", "b1", "c1"},
},
cols: []int{2, -1, 0},
e: model1.DeltaRow{"c", "", "a"},
},
}
hh := model1.Header{
model1.HeaderColumn{Name: "A"},
model1.HeaderColumn{Name: "B"},
model1.HeaderColumn{Name: "C"},
}
for k := range uu {
u := uu[k]
t.Run(k, func(t *testing.T) {
d := model1.NewDeltaRow(u.r1, u.r2, hh)
out := make(model1.DeltaRow, len(u.cols))
d.Customize(u.cols, out)
assert.Equal(t, u.e, out)
})
}
}
func TestDeltaNew(t *testing.T) {
uu := map[string]struct {
o model1.Row
n model1.Row
blank bool
e model1.DeltaRow
}{
"same": {
o: model1.Row{
Fields: model1.Fields{"a", "b", "c"},
},
n: model1.Row{
Fields: model1.Fields{"a", "b", "c"},
},
blank: true,
e: model1.DeltaRow{"", "", ""},
},
"diff": {
o: model1.Row{
Fields: model1.Fields{"a1", "b", "c"},
},
n: model1.Row{
Fields: model1.Fields{"a", "b", "c"},
},
e: model1.DeltaRow{"a1", "", ""},
},
"diff2": {
o: model1.Row{
Fields: model1.Fields{"a", "b", "c"},
},
n: model1.Row{
Fields: model1.Fields{"a", "b1", "c"},
},
e: model1.DeltaRow{"", "b", ""},
},
"diffLast": {
o: model1.Row{
Fields: model1.Fields{"a", "b", "c"},
},
n: model1.Row{
Fields: model1.Fields{"a", "b", "c1"},
},
e: model1.DeltaRow{"", "", "c"},
},
}
hh := model1.Header{
model1.HeaderColumn{Name: "A"},
model1.HeaderColumn{Name: "B"},
model1.HeaderColumn{Name: "C"},
}
for k := range uu {
u := uu[k]
t.Run(k, func(t *testing.T) {
d := model1.NewDeltaRow(u.o, u.n, hh)
assert.Equal(t, u.e, d)
assert.Equal(t, u.blank, d.IsBlank())
})
}
}
func TestDeltaBlank(t *testing.T) {
uu := map[string]struct {
r model1.DeltaRow
e bool
}{
"empty": {
r: model1.DeltaRow{},
e: true,
},
"blank": {
r: model1.DeltaRow{"", "", ""},
e: true,
},
"notblank": {
r: model1.DeltaRow{"", "", "z"},
},
}
for k := range uu {
u := uu[k]
t.Run(k, func(t *testing.T) {
assert.Equal(t, u.e, u.r.IsBlank())
})
}
}
func TestDeltaDiff(t *testing.T) {
uu := map[string]struct {
d1, d2 model1.DeltaRow
ageCol int
e bool
}{
"empty": {
d1: model1.DeltaRow{"f1", "f2", "f3"},
ageCol: 2,
e: true,
},
"same": {
d1: model1.DeltaRow{"f1", "f2", "f3"},
d2: model1.DeltaRow{"f1", "f2", "f3"},
ageCol: -1,
},
"diff": {
d1: model1.DeltaRow{"f1", "f2", "f3"},
d2: model1.DeltaRow{"f1", "f2", "f13"},
ageCol: -1,
e: true,
},
"diff-age-first": {
d1: model1.DeltaRow{"f1", "f2", "f3"},
d2: model1.DeltaRow{"f1", "f2", "f13"},
ageCol: 0,
e: true,
},
"diff-age-last": {
d1: model1.DeltaRow{"f1", "f2", "f3"},
d2: model1.DeltaRow{"f1", "f2", "f13"},
ageCol: 2,
},
}
for k := range uu {
u := uu[k]
t.Run(k, func(t *testing.T) {
assert.Equal(t, u.e, u.d1.Diff(u.d2, u.ageCol))
})
}
}

41
internal/model1/fields.go Normal file
View File

@ -0,0 +1,41 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright Authors of K9s
package model1
import "reflect"
// Fields represents a collection of row fields.
type Fields []string
// Customize returns a subset of fields.
func (f Fields) Customize(cols []int, out Fields) {
for i, c := range cols {
if c < 0 {
out[i] = NAValue
continue
}
if c < len(f) {
out[i] = f[c]
}
}
}
// Diff returns true if fields differ or false otherwise.
func (f Fields) Diff(ff Fields, ageCol int) bool {
if ageCol < 0 {
return !reflect.DeepEqual(f[:len(f)-1], ff[:len(ff)-1])
}
if !reflect.DeepEqual(f[:ageCol], ff[:ageCol]) {
return true
}
return !reflect.DeepEqual(f[ageCol+1:], ff[ageCol+1:])
}
// Clone returns a copy of the fields.
func (f Fields) Clone() Fields {
cp := make(Fields, len(f))
copy(cp, f)
return cp
}

View File

@ -1,7 +1,7 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright Authors of K9s
package render
package model1
import (
"reflect"
@ -34,18 +34,24 @@ func (h HeaderColumn) Clone() HeaderColumn {
// Header represents a table header.
type Header []HeaderColumn
func (h Header) Clear() Header {
h = h[:0]
return h
}
// Clone duplicates a header.
func (h Header) Clone() Header {
header := make(Header, len(h))
for i, c := range h {
header[i] = c.Clone()
he := make(Header, 0, len(h))
for _, h := range h {
he = append(he, h.Clone())
}
return header
return he
}
// Labelize returns a new Header based on labels.
func (h Header) Labelize(cols []int, labelCol int, rr RowEvents) Header {
func (h Header) Labelize(cols []int, labelCol int, rr *RowEvents) Header {
header := make(Header, 0, len(cols)+1)
for _, c := range cols {
header = append(header, h[c])
@ -63,8 +69,8 @@ func (h Header) MapIndices(cols []string, wide bool) []int {
ii := make([]int, 0, len(cols))
cc := make(map[int]struct{}, len(cols))
for _, col := range cols {
idx := h.IndexOf(col, true)
if idx < 0 {
idx, ok := h.IndexOf(col, true)
if !ok {
log.Warn().Msgf("Column %q not found on resource", col)
}
ii, cc[idx] = append(ii, idx), struct{}{}
@ -90,13 +96,10 @@ func (h Header) Customize(cols []string, wide bool) Header {
cc := make(Header, 0, len(h))
xx := make(map[int]struct{}, len(h))
for _, c := range cols {
idx := h.IndexOf(c, true)
if idx == -1 {
idx, ok := h.IndexOf(c, true)
if !ok {
log.Warn().Msgf("Column %s is not available on this resource", c)
col := HeaderColumn{
Name: c,
}
cc = append(cc, col)
cc = append(cc, HeaderColumn{Name: c})
continue
}
xx[idx] = struct{}{}
@ -129,8 +132,8 @@ func (h Header) Diff(header Header) bool {
return !reflect.DeepEqual(h, header)
}
// Columns return header as a collection of strings.
func (h Header) Columns(wide bool) []string {
// ColumnNames return header col names
func (h Header) ColumnNames(wide bool) []string {
if len(h) == 0 {
return nil
}
@ -147,7 +150,9 @@ func (h Header) Columns(wide bool) []string {
// HasAge returns true if table has an age column.
func (h Header) HasAge() bool {
return h.IndexOf(ageCol, true) != -1
_, ok := h.IndexOf(ageCol, true)
return ok
}
// IsMetricsCol checks if given column index represents metrics.
@ -177,22 +182,17 @@ func (h Header) IsCapacityCol(col int) bool {
return h[col].Capacity
}
// ValidColIndex returns the valid col index or -1 if none.
func (h Header) ValidColIndex() int {
return h.IndexOf("VALID", true)
}
// IndexOf returns the col index or -1 if none.
func (h Header) IndexOf(colName string, includeWide bool) int {
func (h Header) IndexOf(colName string, includeWide bool) (int, bool) {
for i, c := range h {
if c.Wide && !includeWide {
continue
}
if c.Name == colName {
return i
return i, true
}
}
return -1
return -1, false
}
// Dump for debugging.

View File

@ -1,18 +1,18 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright Authors of K9s
package render_test
package model1_test
import (
"testing"
"github.com/derailed/k9s/internal/render"
"github.com/derailed/k9s/internal/model1"
"github.com/stretchr/testify/assert"
)
func TestHeaderMapIndices(t *testing.T) {
uu := map[string]struct {
h1 render.Header
h1 model1.Header
cols []string
wide bool
e []int
@ -50,15 +50,16 @@ func TestHeaderMapIndices(t *testing.T) {
func TestHeaderIndexOf(t *testing.T) {
uu := map[string]struct {
h render.Header
name string
wide bool
e int
h model1.Header
name string
wide, ok bool
e int
}{
"shown": {
h: makeHeader(),
name: "A",
e: 0,
ok: true,
},
"hidden": {
h: makeHeader(),
@ -70,23 +71,26 @@ func TestHeaderIndexOf(t *testing.T) {
name: "B",
wide: true,
e: 1,
ok: true,
},
}
for k := range uu {
u := uu[k]
t.Run(k, func(t *testing.T) {
assert.Equal(t, u.e, u.h.IndexOf(u.name, u.wide))
idx, ok := u.h.IndexOf(u.name, u.wide)
assert.Equal(t, u.ok, ok)
assert.Equal(t, u.e, idx)
})
}
}
func TestHeaderCustomize(t *testing.T) {
uu := map[string]struct {
h render.Header
h model1.Header
cols []string
wide bool
e render.Header
e model1.Header
}{
"default": {
h: makeHeader(),
@ -98,58 +102,58 @@ func TestHeaderCustomize(t *testing.T) {
e: makeHeader(),
},
"reverse": {
h: render.Header{
render.HeaderColumn{Name: "A"},
render.HeaderColumn{Name: "B", Wide: true},
render.HeaderColumn{Name: "C"},
h: model1.Header{
model1.HeaderColumn{Name: "A"},
model1.HeaderColumn{Name: "B", Wide: true},
model1.HeaderColumn{Name: "C"},
},
cols: []string{"C", "A"},
e: render.Header{
render.HeaderColumn{Name: "C"},
render.HeaderColumn{Name: "A"},
e: model1.Header{
model1.HeaderColumn{Name: "C"},
model1.HeaderColumn{Name: "A"},
},
},
"reverse-wide": {
h: render.Header{
render.HeaderColumn{Name: "A"},
render.HeaderColumn{Name: "B", Wide: true},
render.HeaderColumn{Name: "C"},
h: model1.Header{
model1.HeaderColumn{Name: "A"},
model1.HeaderColumn{Name: "B", Wide: true},
model1.HeaderColumn{Name: "C"},
},
cols: []string{"C", "A"},
wide: true,
e: render.Header{
render.HeaderColumn{Name: "C"},
render.HeaderColumn{Name: "A"},
render.HeaderColumn{Name: "B", Wide: true},
e: model1.Header{
model1.HeaderColumn{Name: "C"},
model1.HeaderColumn{Name: "A"},
model1.HeaderColumn{Name: "B", Wide: true},
},
},
"toggle-wide": {
h: render.Header{
render.HeaderColumn{Name: "A"},
render.HeaderColumn{Name: "B", Wide: true},
render.HeaderColumn{Name: "C"},
h: model1.Header{
model1.HeaderColumn{Name: "A"},
model1.HeaderColumn{Name: "B", Wide: true},
model1.HeaderColumn{Name: "C"},
},
cols: []string{"C", "B"},
wide: true,
e: render.Header{
render.HeaderColumn{Name: "C"},
render.HeaderColumn{Name: "B", Wide: false},
render.HeaderColumn{Name: "A", Wide: true},
e: model1.Header{
model1.HeaderColumn{Name: "C"},
model1.HeaderColumn{Name: "B", Wide: false},
model1.HeaderColumn{Name: "A", Wide: true},
},
},
"missing": {
h: render.Header{
render.HeaderColumn{Name: "A"},
render.HeaderColumn{Name: "B", Wide: true},
render.HeaderColumn{Name: "C"},
h: model1.Header{
model1.HeaderColumn{Name: "A"},
model1.HeaderColumn{Name: "B", Wide: true},
model1.HeaderColumn{Name: "C"},
},
cols: []string{"BLEE", "A"},
wide: true,
e: render.Header{
render.HeaderColumn{Name: "BLEE"},
render.HeaderColumn{Name: "A"},
render.HeaderColumn{Name: "B", Wide: true},
render.HeaderColumn{Name: "C", Wide: true},
e: model1.Header{
model1.HeaderColumn{Name: "BLEE"},
model1.HeaderColumn{Name: "A"},
model1.HeaderColumn{Name: "B", Wide: true},
model1.HeaderColumn{Name: "C", Wide: true},
},
},
}
@ -164,7 +168,7 @@ func TestHeaderCustomize(t *testing.T) {
func TestHeaderDiff(t *testing.T) {
uu := map[string]struct {
h1, h2 render.Header
h1, h2 model1.Header
e bool
}{
"same": {
@ -177,37 +181,37 @@ func TestHeaderDiff(t *testing.T) {
e: true,
},
"differ-wide": {
h1: render.Header{
render.HeaderColumn{Name: "A"},
render.HeaderColumn{Name: "B", Wide: true},
render.HeaderColumn{Name: "C"},
h1: model1.Header{
model1.HeaderColumn{Name: "A"},
model1.HeaderColumn{Name: "B", Wide: true},
model1.HeaderColumn{Name: "C"},
},
h2: render.Header{
render.HeaderColumn{Name: "A"},
render.HeaderColumn{Name: "B"},
render.HeaderColumn{Name: "C"},
h2: model1.Header{
model1.HeaderColumn{Name: "A"},
model1.HeaderColumn{Name: "B"},
model1.HeaderColumn{Name: "C"},
},
e: true,
},
"differ-order": {
h1: render.Header{
render.HeaderColumn{Name: "A"},
render.HeaderColumn{Name: "B", Wide: true},
render.HeaderColumn{Name: "C"},
h1: model1.Header{
model1.HeaderColumn{Name: "A"},
model1.HeaderColumn{Name: "B", Wide: true},
model1.HeaderColumn{Name: "C"},
},
h2: render.Header{
render.HeaderColumn{Name: "A"},
render.HeaderColumn{Name: "C"},
render.HeaderColumn{Name: "B", Wide: true},
h2: model1.Header{
model1.HeaderColumn{Name: "A"},
model1.HeaderColumn{Name: "C"},
model1.HeaderColumn{Name: "B", Wide: true},
},
e: true,
},
"differ-name": {
h1: render.Header{
render.HeaderColumn{Name: "A"},
h1: model1.Header{
model1.HeaderColumn{Name: "A"},
},
h2: render.Header{
render.HeaderColumn{Name: "B"},
h2: model1.Header{
model1.HeaderColumn{Name: "B"},
},
e: true,
},
@ -223,17 +227,17 @@ func TestHeaderDiff(t *testing.T) {
func TestHeaderHasAge(t *testing.T) {
uu := map[string]struct {
h render.Header
h model1.Header
age, e bool
}{
"no-age": {
h: render.Header{},
h: model1.Header{},
},
"age": {
h: render.Header{
render.HeaderColumn{Name: "A"},
render.HeaderColumn{Name: "B", Wide: true},
render.HeaderColumn{Name: "AGE", Time: true},
h: model1.Header{
model1.HeaderColumn{Name: "A"},
model1.HeaderColumn{Name: "B", Wide: true},
model1.HeaderColumn{Name: "AGE", Time: true},
},
e: true,
age: true,
@ -249,41 +253,14 @@ func TestHeaderHasAge(t *testing.T) {
}
}
func TestHeaderValidColIndex(t *testing.T) {
uu := map[string]struct {
h render.Header
e int
}{
"none": {
h: render.Header{},
e: -1,
},
"valid": {
h: render.Header{
render.HeaderColumn{Name: "A"},
render.HeaderColumn{Name: "B", Wide: true},
render.HeaderColumn{Name: "VALID", Wide: true},
},
e: 2,
},
}
for k := range uu {
u := uu[k]
t.Run(k, func(t *testing.T) {
assert.Equal(t, u.e, u.h.ValidColIndex())
})
}
}
func TestHeaderColumns(t *testing.T) {
uu := map[string]struct {
h render.Header
h model1.Header
wide bool
e []string
}{
"empty": {
h: render.Header{},
h: model1.Header{},
},
"regular": {
h: makeHeader(),
@ -299,17 +276,17 @@ func TestHeaderColumns(t *testing.T) {
for k := range uu {
u := uu[k]
t.Run(k, func(t *testing.T) {
assert.Equal(t, u.e, u.h.Columns(u.wide))
assert.Equal(t, u.e, u.h.ColumnNames(u.wide))
})
}
}
func TestHeaderClone(t *testing.T) {
uu := map[string]struct {
h render.Header
h model1.Header
}{
"empty": {
h: render.Header{},
h: model1.Header{},
},
"full": {
h: makeHeader(),
@ -332,10 +309,10 @@ func TestHeaderClone(t *testing.T) {
// ----------------------------------------------------------------------------
// Helpers...
func makeHeader() render.Header {
return render.Header{
render.HeaderColumn{Name: "A"},
render.HeaderColumn{Name: "B", Wide: true},
render.HeaderColumn{Name: "C"},
func makeHeader() model1.Header {
return model1.Header{
model1.HeaderColumn{Name: "A"},
model1.HeaderColumn{Name: "B", Wide: true},
model1.HeaderColumn{Name: "C"},
}
}

166
internal/model1/helpers.go Normal file
View File

@ -0,0 +1,166 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright Authors of K9s
package model1
import (
"fmt"
"math"
"sort"
"strings"
"github.com/fvbommel/sortorder"
"k8s.io/apimachinery/pkg/api/resource"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
)
func Hydrate(ns string, oo []runtime.Object, rr Rows, re Renderer) error {
for i, o := range oo {
if err := re.Render(o, ns, &rr[i]); err != nil {
return err
}
}
return nil
}
func GenericHydrate(ns string, table *metav1.Table, rr Rows, re Renderer) error {
gr, ok := re.(Generic)
if !ok {
return fmt.Errorf("expecting generic renderer but got %T", re)
}
gr.SetTable(ns, table)
for i, row := range table.Rows {
if err := gr.Render(row, ns, &rr[i]); err != nil {
return err
}
}
return nil
}
// IsValid returns true if resource is valid, false otherwise.
func IsValid(ns string, h Header, r Row) bool {
if len(r.Fields) == 0 {
return true
}
idx, ok := h.IndexOf("VALID", true)
if !ok || idx >= len(r.Fields) {
return true
}
return strings.TrimSpace(r.Fields[idx]) == ""
}
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
}
// 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 durationToSeconds(duration string) int64 {
if len(duration) == 0 {
return 0
}
if duration == NAValue {
return math.MaxInt64
}
num := make([]rune, 0, 5)
var n, m int64
for _, r := range duration {
switch r {
case 'y':
m = 365 * 24 * 60 * 60
case 'd':
m = 24 * 60 * 60
case 'h':
m = 60 * 60
case 'm':
m = 60
case 's':
m = 1
default:
num = append(num, r)
continue
}
n, num = n+runesToNum(num)*m, num[:0]
}
return n
}
func runesToNum(rr []rune) int64 {
var r int64
var m int64 = 1
for i := len(rr) - 1; i >= 0; i-- {
v := int64(rr[i] - '0')
r += v * m
m *= 10
}
return r
}
func capacityToNumber(capacity string) int64 {
quantity := resource.MustParse(capacity)
return quantity.Value()
}
// Less return true if c1 <= c2.
func Less(isNumber, isDuration, isCapacity bool, id1, id2, v1, v2 string) bool {
var less bool
switch {
case isNumber:
less = lessNumber(v1, v2)
case isDuration:
less = lessDuration(v1, v2)
case isCapacity:
less = lessCapacity(v1, v2)
default:
less = sortorder.NaturalLess(v1, v2)
}
if v1 == v2 {
return sortorder.NaturalLess(id1, id2)
}
return less
}
func lessDuration(s1, s2 string) bool {
d1, d2 := durationToSeconds(s1), durationToSeconds(s2)
return d1 <= d2
}
func lessCapacity(s1, s2 string) bool {
c1, c2 := capacityToNumber(s1), capacityToNumber(s2)
return c1 <= c2
}
func lessNumber(s1, s2 string) bool {
v1, v2 := strings.Replace(s1, ",", "", -1), strings.Replace(s2, ",", "", -1)
return sortorder.NaturalLess(v1, v2)
}

View File

@ -0,0 +1,89 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright Authors of K9s
package model1
import (
"math"
"testing"
"github.com/stretchr/testify/assert"
)
func TestSortLabels(t *testing.T) {
uu := map[string]struct {
labels string
e [][]string
}{
"simple": {
labels: "a=b,c=d",
e: [][]string{
{"a", "c"},
{"b", "d"},
},
},
}
for k := range uu {
u := uu[k]
t.Run(k, func(t *testing.T) {
hh, vv := sortLabels(labelize(u.labels))
assert.Equal(t, u.e[0], hh)
assert.Equal(t, u.e[1], vv)
})
}
}
func TestLabelize(t *testing.T) {
uu := map[string]struct {
labels string
e map[string]string
}{
"simple": {
labels: "a=b,c=d",
e: map[string]string{"a": "b", "c": "d"},
},
}
for k := range uu {
u := uu[k]
t.Run(k, func(t *testing.T) {
assert.Equal(t, u.e, labelize(u.labels))
})
}
}
func TestDurationToSecond(t *testing.T) {
uu := map[string]struct {
s string
e int64
}{
"seconds": {s: "22s", e: 22},
"minutes": {s: "22m", e: 1320},
"hours": {s: "12h", e: 43200},
"days": {s: "3d", e: 259200},
"day_hour": {s: "3d9h", e: 291600},
"day_hour_minute": {s: "2d22h3m", e: 252180},
"day_hour_minute_seconds": {s: "2d22h3m50s", e: 252230},
"year": {s: "3y", e: 94608000},
"year_day": {s: "1y2d", e: 31708800},
"n/a": {s: NAValue, e: math.MaxInt64},
}
for k := range uu {
u := uu[k]
t.Run(k, func(t *testing.T) {
assert.Equal(t, u.e, durationToSeconds(u.s))
})
}
}
func BenchmarkDurationToSecond(b *testing.B) {
t := "2d22h3m50s"
b.ReportAllocs()
b.ResetTimer()
for n := 0; n < b.N; n++ {
durationToSeconds(t)
}
}

92
internal/model1/row.go Normal file
View File

@ -0,0 +1,92 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright Authors of K9s
package model1
// Row represents a collection of columns.
type Row struct {
ID string
Fields Fields
}
// NewRow returns a new row with initialized fields.
func NewRow(size int) Row {
return Row{Fields: make([]string, size)}
}
// Labelize returns a new row based on labels.
func (r Row) Labelize(cols []int, labelCol int, labels []string) Row {
out := NewRow(len(cols) + len(labels))
for _, col := range cols {
out.Fields = append(out.Fields, r.Fields[col])
}
m := labelize(r.Fields[labelCol])
for _, label := range labels {
out.Fields = append(out.Fields, m[label])
}
return out
}
// Customize returns a row subset based on given col indices.
func (r Row) Customize(cols []int) Row {
out := NewRow(len(cols))
r.Fields.Customize(cols, out.Fields)
out.ID = r.ID
return out
}
// Diff returns true if row differ or false otherwise.
func (r Row) Diff(ro Row, ageCol int) bool {
if r.ID != ro.ID {
return true
}
return r.Fields.Diff(ro.Fields, ageCol)
}
// Clone copies a row.
func (r Row) Clone() Row {
return Row{
ID: r.ID,
Fields: r.Fields.Clone(),
}
}
// Len returns the length of the row.
func (r Row) Len() int {
return len(r.Fields)
}
// ----------------------------------------------------------------------------
// RowSorter sorts rows.
type RowSorter struct {
Rows Rows
Index int
IsNumber bool
IsDuration bool
IsCapacity bool
Asc bool
}
func (s RowSorter) Len() int {
return len(s.Rows)
}
func (s RowSorter) Swap(i, j int) {
s.Rows[i], s.Rows[j] = s.Rows[j], s.Rows[i]
}
func (s RowSorter) Less(i, j int) bool {
v1, v2 := s.Rows[i].Fields[s.Index], s.Rows[j].Fields[s.Index]
id1, id2 := s.Rows[i].ID, s.Rows[j].ID
less := Less(s.IsNumber, s.IsDuration, s.IsCapacity, id1, id2, v1, v2)
if s.Asc {
return less
}
return !less
}
// ----------------------------------------------------------------------------
// Helpers...

View File

@ -1,28 +1,14 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright Authors of K9s
package render
package model1
import (
"fmt"
"sort"
)
const (
// EventUnchanged notifies listener resource has not changed.
EventUnchanged ResEvent = 1 << iota
// EventAdd notifies listener of a resource was added.
EventAdd
// EventUpdate notifies listener of a resource updated.
EventUpdate
// EventDelete notifies listener of a resource was deleted.
EventDelete
// EventClear the stack was reset.
EventClear
)
type ReRangeFn func(int, RowEvent) bool
// ResEvent represents a resource event.
type ResEvent int
@ -103,13 +89,58 @@ func (r RowEvent) Diff(re RowEvent, ageCol int) bool {
// ----------------------------------------------------------------------------
type reIndex map[string]int
// RowEvents a collection of row events.
type RowEvents []RowEvent
type RowEvents struct {
events []RowEvent
index reIndex
}
func NewRowEvents(size int) *RowEvents {
return &RowEvents{
events: make([]RowEvent, 0, size),
index: make(reIndex, size),
}
}
func NewRowEventsWithEvts(ee ...RowEvent) *RowEvents {
re := NewRowEvents(len(ee))
for _, e := range ee {
re.Add(e)
}
return re
}
func (r *RowEvents) reindex() {
for i, e := range r.events {
r.index[e.Row.ID] = i
}
}
func (r *RowEvents) At(i int) (RowEvent, bool) {
if i < 0 || i > len(r.events) {
return RowEvent{}, false
}
return r.events[i], true
}
func (r *RowEvents) Set(i int, re RowEvent) {
r.events[i] = re
r.index[re.Row.ID] = i
}
func (r *RowEvents) Add(re RowEvent) {
r.events = append(r.events, re)
r.index[re.Row.ID] = len(r.events) - 1
}
// ExtractHeaderLabels extract header labels.
func (r RowEvents) ExtractHeaderLabels(labelCol int) []string {
func (r *RowEvents) ExtractHeaderLabels(labelCol int) []string {
ll := make([]string, 0, 10)
for _, re := range r {
for _, re := range r.events {
ll = append(ll, re.ExtractHeaderLabels(labelCol)...)
}
@ -117,32 +148,32 @@ func (r RowEvents) ExtractHeaderLabels(labelCol int) []string {
}
// Labelize converts labels into a row event.
func (r RowEvents) Labelize(cols []int, labelCol int, labels []string) RowEvents {
out := make(RowEvents, 0, len(r))
for _, re := range r {
func (r *RowEvents) Labelize(cols []int, labelCol int, labels []string) *RowEvents {
out := make([]RowEvent, 0, len(r.events))
for _, re := range r.events {
out = append(out, re.Labelize(cols, labelCol, labels))
}
return out
return NewRowEventsWithEvts(out...)
}
// Customize returns custom row events based on columns layout.
func (r RowEvents) Customize(cols []int) RowEvents {
ee := make(RowEvents, 0, len(cols))
for _, re := range r {
func (r *RowEvents) Customize(cols []int) *RowEvents {
ee := make([]RowEvent, 0, len(cols))
for _, re := range r.events {
ee = append(ee, re.Customize(cols))
}
return ee
return NewRowEventsWithEvts(ee...)
}
// Diff returns true if the event changed.
func (r RowEvents) Diff(re RowEvents, ageCol int) bool {
if len(r) != len(re) {
func (r *RowEvents) Diff(re *RowEvents, ageCol int) bool {
if len(r.events) != len(re.events) {
return true
}
for i := range r {
if r[i].Diff(re[i], ageCol) {
for i := range r.events {
if r.events[i].Diff(re.events[i], ageCol) {
return true
}
}
@ -150,53 +181,80 @@ func (r RowEvents) Diff(re RowEvents, ageCol int) bool {
return false
}
// Clone returns a rowevents deep copy.
func (r RowEvents) Clone() RowEvents {
res := make(RowEvents, len(r))
for i, re := range r {
res[i] = re.Clone()
// Clone returns a deep copy.
func (r *RowEvents) Clone() *RowEvents {
re := make([]RowEvent, 0, len(r.events))
for _, e := range r.events {
re = append(re, e.Clone())
}
return res
return NewRowEventsWithEvts(re...)
}
// Upsert add or update a row if it exists.
func (r RowEvents) Upsert(re RowEvent) RowEvents {
func (r *RowEvents) Upsert(re RowEvent) {
if idx, ok := r.FindIndex(re.Row.ID); ok {
r[idx] = re
r.events[idx] = re
} else {
r = append(r, re)
r.Add(re)
}
return r
}
// Delete removes an element by id.
func (r RowEvents) Delete(id string) RowEvents {
victim, ok := r.FindIndex(id)
func (r *RowEvents) Delete(fqn string) error {
victim, ok := r.FindIndex(fqn)
if !ok {
return r
return fmt.Errorf("unable to delete row with fqn: %q", fqn)
}
return append(r[0:victim], r[victim+1:]...)
r.events = append(r.events[0:victim], r.events[victim+1:]...)
delete(r.index, fqn)
r.reindex()
return nil
}
func (r *RowEvents) Len() int {
return len(r.events)
}
func (r *RowEvents) Empty() bool {
return len(r.events) == 0
}
// Clear delete all row events.
func (r RowEvents) Clear() RowEvents {
return RowEvents{}
func (r *RowEvents) Clear() {
r.events = r.events[:0]
for k := range r.index {
delete(r.index, k)
}
}
func (r *RowEvents) Range(f ReRangeFn) {
for i, e := range r.events {
if !f(i, e) {
return
}
}
}
func (r *RowEvents) Get(id string) (RowEvent, bool) {
i, ok := r.index[id]
if !ok {
return RowEvent{}, false
}
return r.At(i)
}
// FindIndex locates a row index by id. Returns false is not found.
func (r RowEvents) FindIndex(id string) (int, bool) {
for i, re := range r {
if re.Row.ID == id {
return i, true
}
}
func (r *RowEvents) FindIndex(id string) (int, bool) {
i, ok := r.index[id]
return 0, false
return i, ok
}
// Sort rows based on column index and order.
func (r RowEvents) Sort(ns string, sortCol int, isDuration, numCol, isCapacity, asc bool) {
func (r *RowEvents) Sort(ns string, sortCol int, isDuration, numCol, isCapacity, asc bool) {
if sortCol == -1 {
return
}
@ -211,13 +269,14 @@ func (r RowEvents) Sort(ns string, sortCol int, isDuration, numCol, isCapacity,
IsCapacity: isCapacity,
}
sort.Sort(t)
r.reindex()
}
// ----------------------------------------------------------------------------
// RowEventSorter sorts row events by a given colon.
type RowEventSorter struct {
Events RowEvents
Events *RowEvents
Index int
NS string
IsNumber bool
@ -227,16 +286,16 @@ type RowEventSorter struct {
}
func (r RowEventSorter) Len() int {
return len(r.Events)
return len(r.Events.events)
}
func (r RowEventSorter) Swap(i, j int) {
r.Events[i], r.Events[j] = r.Events[j], r.Events[i]
r.Events.events[i], r.Events.events[j] = r.Events.events[j], r.Events.events[i]
}
func (r RowEventSorter) Less(i, j int) bool {
f1, f2 := r.Events[i].Row.Fields, r.Events[j].Row.Fields
id1, id2 := r.Events[i].Row.ID, r.Events[j].Row.ID
f1, f2 := r.Events.events[i].Row.Fields, r.Events.events[j].Row.Fields
id1, id2 := r.Events.events[i].Row.ID, r.Events.events[j].Row.ID
less := Less(r.IsNumber, r.IsDuration, r.IsCapacity, id1, id2, f1[r.Index], f2[r.Index])
if r.Asc {
return less

View File

@ -0,0 +1,543 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright Authors of K9s
package model1_test
import (
"testing"
"time"
"github.com/derailed/k9s/internal/model1"
"github.com/stretchr/testify/assert"
)
func TestRowEventCustomize(t *testing.T) {
uu := map[string]struct {
re1, e model1.RowEvent
cols []int
}{
"empty": {
re1: model1.RowEvent{
Kind: model1.EventAdd,
Row: model1.Row{ID: "A", Fields: model1.Fields{"1", "2", "3"}},
},
e: model1.RowEvent{
Kind: model1.EventAdd,
Row: model1.Row{ID: "A", Fields: model1.Fields{}},
},
},
"full": {
re1: model1.RowEvent{
Kind: model1.EventAdd,
Row: model1.Row{ID: "A", Fields: model1.Fields{"1", "2", "3"}},
},
e: model1.RowEvent{
Kind: model1.EventAdd,
Row: model1.Row{ID: "A", Fields: model1.Fields{"1", "2", "3"}},
},
cols: []int{0, 1, 2},
},
"deltas": {
re1: model1.RowEvent{
Kind: model1.EventAdd,
Row: model1.Row{ID: "A", Fields: model1.Fields{"1", "2", "3"}},
Deltas: model1.DeltaRow{"a", "b", "c"},
},
e: model1.RowEvent{
Kind: model1.EventAdd,
Row: model1.Row{ID: "A", Fields: model1.Fields{"1", "2", "3"}},
Deltas: model1.DeltaRow{"a", "b", "c"},
},
cols: []int{0, 1, 2},
},
"deltas-skip": {
re1: model1.RowEvent{
Kind: model1.EventAdd,
Row: model1.Row{ID: "A", Fields: model1.Fields{"1", "2", "3"}},
Deltas: model1.DeltaRow{"a", "b", "c"},
},
e: model1.RowEvent{
Kind: model1.EventAdd,
Row: model1.Row{ID: "A", Fields: model1.Fields{"3", "1"}},
Deltas: model1.DeltaRow{"c", "a"},
},
cols: []int{2, 0},
},
"reverse": {
re1: model1.RowEvent{
Kind: model1.EventAdd,
Row: model1.Row{ID: "A", Fields: model1.Fields{"1", "2", "3"}},
},
e: model1.RowEvent{
Kind: model1.EventAdd,
Row: model1.Row{ID: "A", Fields: model1.Fields{"3", "2", "1"}},
},
cols: []int{2, 1, 0},
},
"skip": {
re1: model1.RowEvent{
Kind: model1.EventAdd,
Row: model1.Row{ID: "A", Fields: model1.Fields{"1", "2", "3"}},
},
e: model1.RowEvent{
Kind: model1.EventAdd,
Row: model1.Row{ID: "A", Fields: model1.Fields{"3", "1"}},
},
cols: []int{2, 0},
},
"miss": {
re1: model1.RowEvent{
Kind: model1.EventAdd,
Row: model1.Row{ID: "A", Fields: model1.Fields{"1", "2", "3"}},
},
e: model1.RowEvent{
Kind: model1.EventAdd,
Row: model1.Row{ID: "A", Fields: model1.Fields{"3", "", "1"}},
},
cols: []int{2, 10, 0},
},
}
for k := range uu {
u := uu[k]
t.Run(k, func(t *testing.T) {
assert.Equal(t, u.e, u.re1.Customize(u.cols))
})
}
}
func TestRowEventDiff(t *testing.T) {
uu := map[string]struct {
re1, re2 model1.RowEvent
e bool
}{
"same": {
re1: model1.RowEvent{
Kind: model1.EventAdd,
Row: model1.Row{ID: "A", Fields: model1.Fields{"1", "2", "3"}},
},
re2: model1.RowEvent{
Kind: model1.EventAdd,
Row: model1.Row{ID: "A", Fields: model1.Fields{"1", "2", "3"}},
},
},
"diff-kind": {
re1: model1.RowEvent{
Kind: model1.EventAdd,
Row: model1.Row{ID: "A", Fields: model1.Fields{"1", "2", "3"}},
},
re2: model1.RowEvent{
Kind: model1.EventDelete,
Row: model1.Row{ID: "B", Fields: model1.Fields{"1", "2", "3"}},
},
e: true,
},
"diff-delta": {
re1: model1.RowEvent{
Kind: model1.EventAdd,
Row: model1.Row{ID: "A", Fields: model1.Fields{"1", "2", "3"}},
Deltas: model1.DeltaRow{"1", "2", "3"},
},
re2: model1.RowEvent{
Kind: model1.EventAdd,
Row: model1.Row{ID: "A", Fields: model1.Fields{"1", "2", "3"}},
Deltas: model1.DeltaRow{"10", "2", "3"},
},
e: true,
},
"diff-id": {
re1: model1.RowEvent{
Kind: model1.EventAdd,
Row: model1.Row{ID: "A", Fields: model1.Fields{"1", "2", "3"}},
},
re2: model1.RowEvent{
Kind: model1.EventAdd,
Row: model1.Row{ID: "B", Fields: model1.Fields{"1", "2", "3"}},
},
e: true,
},
"diff-field": {
re1: model1.RowEvent{
Kind: model1.EventAdd,
Row: model1.Row{ID: "A", Fields: model1.Fields{"1", "2", "3"}},
},
re2: model1.RowEvent{
Kind: model1.EventAdd,
Row: model1.Row{ID: "A", Fields: model1.Fields{"10", "2", "3"}},
},
e: true,
},
}
for k := range uu {
u := uu[k]
t.Run(k, func(t *testing.T) {
assert.Equal(t, u.e, u.re1.Diff(u.re2, -1))
})
}
}
func TestRowEventsDiff(t *testing.T) {
uu := map[string]struct {
re1, re2 *model1.RowEvents
ageCol int
e bool
}{
"same": {
re1: model1.NewRowEventsWithEvts(
model1.RowEvent{Row: model1.Row{ID: "A", Fields: model1.Fields{"1", "2", "3"}}},
model1.RowEvent{Row: model1.Row{ID: "B", Fields: model1.Fields{"0", "2", "3"}}},
model1.RowEvent{Row: model1.Row{ID: "C", Fields: model1.Fields{"10", "2", "3"}}},
),
re2: model1.NewRowEventsWithEvts(
model1.RowEvent{Row: model1.Row{ID: "A", Fields: model1.Fields{"1", "2", "3"}}},
model1.RowEvent{Row: model1.Row{ID: "B", Fields: model1.Fields{"0", "2", "3"}}},
model1.RowEvent{Row: model1.Row{ID: "C", Fields: model1.Fields{"10", "2", "3"}}},
),
ageCol: -1,
},
"diff-len": {
re1: model1.NewRowEventsWithEvts(
model1.RowEvent{Row: model1.Row{ID: "A", Fields: model1.Fields{"1", "2", "3"}}},
model1.RowEvent{Row: model1.Row{ID: "B", Fields: model1.Fields{"0", "2", "3"}}},
model1.RowEvent{Row: model1.Row{ID: "C", Fields: model1.Fields{"10", "2", "3"}}},
),
re2: model1.NewRowEventsWithEvts(
model1.RowEvent{Row: model1.Row{ID: "A", Fields: model1.Fields{"1", "2", "3"}}},
model1.RowEvent{Row: model1.Row{ID: "C", Fields: model1.Fields{"10", "2", "3"}}},
),
ageCol: -1,
e: true,
},
"diff-id": {
re1: model1.NewRowEventsWithEvts(
model1.RowEvent{Row: model1.Row{ID: "A", Fields: model1.Fields{"1", "2", "3"}}},
model1.RowEvent{Row: model1.Row{ID: "B", Fields: model1.Fields{"0", "2", "3"}}},
model1.RowEvent{Row: model1.Row{ID: "C", Fields: model1.Fields{"10", "2", "3"}}},
),
re2: model1.NewRowEventsWithEvts(
model1.RowEvent{Row: model1.Row{ID: "D", Fields: model1.Fields{"1", "2", "3"}}},
model1.RowEvent{Row: model1.Row{ID: "B", Fields: model1.Fields{"0", "2", "3"}}},
model1.RowEvent{Row: model1.Row{ID: "C", Fields: model1.Fields{"10", "2", "3"}}},
),
ageCol: -1,
e: true,
},
"diff-order": {
re1: model1.NewRowEventsWithEvts(
model1.RowEvent{Row: model1.Row{ID: "A", Fields: model1.Fields{"1", "2", "3"}}},
model1.RowEvent{Row: model1.Row{ID: "B", Fields: model1.Fields{"0", "2", "3"}}},
model1.RowEvent{Row: model1.Row{ID: "C", Fields: model1.Fields{"10", "2", "3"}}},
),
re2: model1.NewRowEventsWithEvts(
model1.RowEvent{Row: model1.Row{ID: "B", Fields: model1.Fields{"0", "2", "3"}}},
model1.RowEvent{Row: model1.Row{ID: "A", Fields: model1.Fields{"1", "2", "3"}}},
model1.RowEvent{Row: model1.Row{ID: "C", Fields: model1.Fields{"10", "2", "3"}}},
),
ageCol: -1,
e: true,
},
"diff-withAge": {
re1: model1.NewRowEventsWithEvts(
model1.RowEvent{Row: model1.Row{ID: "A", Fields: model1.Fields{"1", "2", "3"}}},
model1.RowEvent{Row: model1.Row{ID: "B", Fields: model1.Fields{"0", "2", "3"}}},
model1.RowEvent{Row: model1.Row{ID: "C", Fields: model1.Fields{"10", "2", "3"}}},
),
re2: model1.NewRowEventsWithEvts(
model1.RowEvent{Row: model1.Row{ID: "A", Fields: model1.Fields{"1", "2", "3"}}},
model1.RowEvent{Row: model1.Row{ID: "B", Fields: model1.Fields{"0", "2", "13"}}},
model1.RowEvent{Row: model1.Row{ID: "C", Fields: model1.Fields{"10", "2", "3"}}},
),
ageCol: 1,
e: true,
},
}
for k := range uu {
u := uu[k]
t.Run(k, func(t *testing.T) {
assert.Equal(t, u.e, u.re1.Diff(u.re2, u.ageCol))
})
}
}
func TestRowEventsUpsert(t *testing.T) {
uu := map[string]struct {
ee, e *model1.RowEvents
re model1.RowEvent
}{
"add": {
ee: model1.NewRowEventsWithEvts(
model1.RowEvent{Row: model1.Row{ID: "A", Fields: model1.Fields{"1", "2", "3"}}},
model1.RowEvent{Row: model1.Row{ID: "B", Fields: model1.Fields{"0", "2", "3"}}},
model1.RowEvent{Row: model1.Row{ID: "C", Fields: model1.Fields{"10", "2", "3"}}},
),
re: model1.RowEvent{
Row: model1.Row{ID: "D", Fields: model1.Fields{"f1", "f2", "f3"}},
},
e: model1.NewRowEventsWithEvts(
model1.RowEvent{Row: model1.Row{ID: "A", Fields: model1.Fields{"1", "2", "3"}}},
model1.RowEvent{Row: model1.Row{ID: "B", Fields: model1.Fields{"0", "2", "3"}}},
model1.RowEvent{Row: model1.Row{ID: "C", Fields: model1.Fields{"10", "2", "3"}}},
model1.RowEvent{Row: model1.Row{ID: "D", Fields: model1.Fields{"f1", "f2", "f3"}}},
),
},
}
for k := range uu {
u := uu[k]
t.Run(k, func(t *testing.T) {
u.ee.Upsert(u.re)
assert.Equal(t, u.e, u.ee)
})
}
}
func TestRowEventsCustomize(t *testing.T) {
uu := map[string]struct {
re, e *model1.RowEvents
cols []int
}{
"same": {
re: model1.NewRowEventsWithEvts(
model1.RowEvent{Row: model1.Row{ID: "A", Fields: model1.Fields{"1", "2", "3"}}},
model1.RowEvent{Row: model1.Row{ID: "B", Fields: model1.Fields{"0", "2", "3"}}},
model1.RowEvent{Row: model1.Row{ID: "C", Fields: model1.Fields{"10", "2", "3"}}},
),
cols: []int{0, 1, 2},
e: model1.NewRowEventsWithEvts(
model1.RowEvent{Row: model1.Row{ID: "A", Fields: model1.Fields{"1", "2", "3"}}},
model1.RowEvent{Row: model1.Row{ID: "B", Fields: model1.Fields{"0", "2", "3"}}},
model1.RowEvent{Row: model1.Row{ID: "C", Fields: model1.Fields{"10", "2", "3"}}},
),
},
"reverse": {
re: model1.NewRowEventsWithEvts(
model1.RowEvent{Row: model1.Row{ID: "A", Fields: model1.Fields{"1", "2", "3"}}},
model1.RowEvent{Row: model1.Row{ID: "B", Fields: model1.Fields{"0", "2", "3"}}},
model1.RowEvent{Row: model1.Row{ID: "C", Fields: model1.Fields{"10", "2", "3"}}},
),
cols: []int{2, 1, 0},
e: model1.NewRowEventsWithEvts(
model1.RowEvent{Row: model1.Row{ID: "A", Fields: model1.Fields{"3", "2", "1"}}},
model1.RowEvent{Row: model1.Row{ID: "B", Fields: model1.Fields{"3", "2", "0"}}},
model1.RowEvent{Row: model1.Row{ID: "C", Fields: model1.Fields{"3", "2", "10"}}},
),
},
"skip": {
re: model1.NewRowEventsWithEvts(
model1.RowEvent{Row: model1.Row{ID: "A", Fields: model1.Fields{"1", "2", "3"}}},
model1.RowEvent{Row: model1.Row{ID: "B", Fields: model1.Fields{"0", "2", "3"}}},
model1.RowEvent{Row: model1.Row{ID: "C", Fields: model1.Fields{"10", "2", "3"}}},
),
cols: []int{1, 0},
e: model1.NewRowEventsWithEvts(
model1.RowEvent{Row: model1.Row{ID: "A", Fields: model1.Fields{"2", "1"}}},
model1.RowEvent{Row: model1.Row{ID: "B", Fields: model1.Fields{"2", "0"}}},
model1.RowEvent{Row: model1.Row{ID: "C", Fields: model1.Fields{"2", "10"}}},
),
},
"missing": {
re: model1.NewRowEventsWithEvts(
model1.RowEvent{Row: model1.Row{ID: "A", Fields: model1.Fields{"1", "2", "3"}}},
model1.RowEvent{Row: model1.Row{ID: "B", Fields: model1.Fields{"0", "2", "3"}}},
model1.RowEvent{Row: model1.Row{ID: "C", Fields: model1.Fields{"10", "2", "3"}}},
),
cols: []int{1, 0, 4},
e: model1.NewRowEventsWithEvts(
model1.RowEvent{Row: model1.Row{ID: "A", Fields: model1.Fields{"2", "1", ""}}},
model1.RowEvent{Row: model1.Row{ID: "B", Fields: model1.Fields{"2", "0", ""}}},
model1.RowEvent{Row: model1.Row{ID: "C", Fields: model1.Fields{"2", "10", ""}}},
),
},
}
for k := range uu {
u := uu[k]
t.Run(k, func(t *testing.T) {
assert.Equal(t, u.e, u.re.Customize(u.cols))
})
}
}
func TestRowEventsDelete(t *testing.T) {
uu := map[string]struct {
re, e *model1.RowEvents
id string
}{
"first": {
re: model1.NewRowEventsWithEvts(
model1.RowEvent{Row: model1.Row{ID: "A", Fields: model1.Fields{"1", "2", "3"}}},
model1.RowEvent{Row: model1.Row{ID: "B", Fields: model1.Fields{"0", "2", "3"}}},
model1.RowEvent{Row: model1.Row{ID: "C", Fields: model1.Fields{"10", "2", "3"}}},
),
id: "A",
e: model1.NewRowEventsWithEvts(
model1.RowEvent{Row: model1.Row{ID: "B", Fields: model1.Fields{"0", "2", "3"}}},
model1.RowEvent{Row: model1.Row{ID: "C", Fields: model1.Fields{"10", "2", "3"}}},
),
},
"middle": {
re: model1.NewRowEventsWithEvts(
model1.RowEvent{Row: model1.Row{ID: "A", Fields: model1.Fields{"1", "2", "3"}}},
model1.RowEvent{Row: model1.Row{ID: "B", Fields: model1.Fields{"0", "2", "3"}}},
model1.RowEvent{Row: model1.Row{ID: "C", Fields: model1.Fields{"10", "2", "3"}}},
),
id: "B",
e: model1.NewRowEventsWithEvts(
model1.RowEvent{Row: model1.Row{ID: "A", Fields: model1.Fields{"1", "2", "3"}}},
model1.RowEvent{Row: model1.Row{ID: "C", Fields: model1.Fields{"10", "2", "3"}}},
),
},
"last": {
re: model1.NewRowEventsWithEvts(
model1.RowEvent{Row: model1.Row{ID: "A", Fields: model1.Fields{"1", "2", "3"}}},
model1.RowEvent{Row: model1.Row{ID: "B", Fields: model1.Fields{"0", "2", "3"}}},
model1.RowEvent{Row: model1.Row{ID: "C", Fields: model1.Fields{"10", "2", "3"}}},
),
id: "C",
e: model1.NewRowEventsWithEvts(
model1.RowEvent{Row: model1.Row{ID: "A", Fields: model1.Fields{"1", "2", "3"}}},
model1.RowEvent{Row: model1.Row{ID: "B", Fields: model1.Fields{"0", "2", "3"}}},
),
},
}
for k := range uu {
u := uu[k]
t.Run(k, func(t *testing.T) {
assert.NoError(t, u.re.Delete(u.id))
assert.Equal(t, u.e, u.re)
})
}
}
func TestRowEventsSort(t *testing.T) {
uu := map[string]struct {
re, e *model1.RowEvents
col int
duration, num, asc bool
capacity bool
}{
"age_time": {
re: model1.NewRowEventsWithEvts(
model1.RowEvent{Row: model1.Row{ID: "A", Fields: model1.Fields{"1", "2", testTime().Add(20 * time.Second).String()}}},
model1.RowEvent{Row: model1.Row{ID: "B", Fields: model1.Fields{"0", "2", testTime().Add(10 * time.Second).String()}}},
model1.RowEvent{Row: model1.Row{ID: "C", Fields: model1.Fields{"10", "2", testTime().String()}}},
),
col: 2,
asc: true,
duration: true,
e: model1.NewRowEventsWithEvts(
model1.RowEvent{Row: model1.Row{ID: "C", Fields: model1.Fields{"10", "2", testTime().String()}}},
model1.RowEvent{Row: model1.Row{ID: "B", Fields: model1.Fields{"0", "2", testTime().Add(10 * time.Second).String()}}},
model1.RowEvent{Row: model1.Row{ID: "A", Fields: model1.Fields{"1", "2", testTime().Add(20 * time.Second).String()}}},
),
},
"col0": {
re: model1.NewRowEventsWithEvts(
model1.RowEvent{Row: model1.Row{ID: "A", Fields: model1.Fields{"1", "2", "3"}}},
model1.RowEvent{Row: model1.Row{ID: "B", Fields: model1.Fields{"0", "2", "3"}}},
model1.RowEvent{Row: model1.Row{ID: "C", Fields: model1.Fields{"10", "2", "3"}}},
),
col: 0,
asc: true,
e: model1.NewRowEventsWithEvts(
model1.RowEvent{Row: model1.Row{ID: "B", Fields: model1.Fields{"0", "2", "3"}}},
model1.RowEvent{Row: model1.Row{ID: "A", Fields: model1.Fields{"1", "2", "3"}}},
model1.RowEvent{Row: model1.Row{ID: "C", Fields: model1.Fields{"10", "2", "3"}}},
),
},
"id_preserve": {
re: model1.NewRowEventsWithEvts(
model1.RowEvent{Row: model1.Row{ID: "ns1/B", Fields: model1.Fields{"B", "2", "3"}}},
model1.RowEvent{Row: model1.Row{ID: "ns1/A", Fields: model1.Fields{"A", "2", "3"}}},
model1.RowEvent{Row: model1.Row{ID: "ns1/C", Fields: model1.Fields{"C", "2", "3"}}},
model1.RowEvent{Row: model1.Row{ID: "ns2/B", Fields: model1.Fields{"B", "2", "3"}}},
model1.RowEvent{Row: model1.Row{ID: "ns2/A", Fields: model1.Fields{"A", "2", "3"}}},
model1.RowEvent{Row: model1.Row{ID: "ns2/C", Fields: model1.Fields{"C", "2", "3"}}},
),
col: 1,
asc: true,
e: model1.NewRowEventsWithEvts(
model1.RowEvent{Row: model1.Row{ID: "ns1/A", Fields: model1.Fields{"A", "2", "3"}}},
model1.RowEvent{Row: model1.Row{ID: "ns1/B", Fields: model1.Fields{"B", "2", "3"}}},
model1.RowEvent{Row: model1.Row{ID: "ns1/C", Fields: model1.Fields{"C", "2", "3"}}},
model1.RowEvent{Row: model1.Row{ID: "ns2/A", Fields: model1.Fields{"A", "2", "3"}}},
model1.RowEvent{Row: model1.Row{ID: "ns2/B", Fields: model1.Fields{"B", "2", "3"}}},
model1.RowEvent{Row: model1.Row{ID: "ns2/C", Fields: model1.Fields{"C", "2", "3"}}},
),
},
"capacity": {
re: model1.NewRowEventsWithEvts(
model1.RowEvent{Row: model1.Row{ID: "ns1/B", Fields: model1.Fields{"B", "2", "3", "1Gi"}}},
model1.RowEvent{Row: model1.Row{ID: "ns1/A", Fields: model1.Fields{"A", "2", "3", "1.1G"}}},
model1.RowEvent{Row: model1.Row{ID: "ns1/C", Fields: model1.Fields{"C", "2", "3", "0.5Ti"}}},
model1.RowEvent{Row: model1.Row{ID: "ns2/B", Fields: model1.Fields{"B", "2", "3", "12e6"}}},
model1.RowEvent{Row: model1.Row{ID: "ns2/A", Fields: model1.Fields{"A", "2", "3", "1234"}}},
model1.RowEvent{Row: model1.Row{ID: "ns2/C", Fields: model1.Fields{"C", "2", "3", "0.1Ei"}}},
),
col: 3,
asc: true,
capacity: true,
e: model1.NewRowEventsWithEvts(
model1.RowEvent{Row: model1.Row{ID: "ns2/A", Fields: model1.Fields{"A", "2", "3", "1234"}}},
model1.RowEvent{Row: model1.Row{ID: "ns2/B", Fields: model1.Fields{"B", "2", "3", "12e6"}}},
model1.RowEvent{Row: model1.Row{ID: "ns1/B", Fields: model1.Fields{"B", "2", "3", "1Gi"}}},
model1.RowEvent{Row: model1.Row{ID: "ns1/A", Fields: model1.Fields{"A", "2", "3", "1.1G"}}},
model1.RowEvent{Row: model1.Row{ID: "ns1/C", Fields: model1.Fields{"C", "2", "3", "0.5Ti"}}},
model1.RowEvent{Row: model1.Row{ID: "ns2/C", Fields: model1.Fields{"C", "2", "3", "0.1Ei"}}},
),
},
}
for k := range uu {
u := uu[k]
t.Run(k, func(t *testing.T) {
u.re.Sort("", u.col, u.duration, u.num, u.capacity, u.asc)
assert.Equal(t, u.e, u.re)
})
}
}
func TestRowEventsClone(t *testing.T) {
uu := map[string]struct {
r *model1.RowEvents
}{
"empty": {
r: model1.NewRowEventsWithEvts(),
},
"full": {
r: makeRowEvents(),
},
}
for k := range uu {
u := uu[k]
t.Run(k, func(t *testing.T) {
c := u.r.Clone()
assert.Equal(t, u.r.Len(), c.Len())
if !u.r.Empty() {
r, ok := u.r.At(0)
assert.True(t, ok)
r.Row.Fields[0] = "blee"
cr, ok := c.At(0)
assert.True(t, ok)
assert.Equal(t, "A", cr.Row.Fields[0])
}
})
}
}
// Helpers...
func makeRowEvents() *model1.RowEvents {
return model1.NewRowEventsWithEvts(
model1.RowEvent{Row: model1.Row{ID: "ns1/A", Fields: model1.Fields{"A", "2", "3"}}},
model1.RowEvent{Row: model1.Row{ID: "ns1/B", Fields: model1.Fields{"B", "2", "3"}}},
model1.RowEvent{Row: model1.Row{ID: "ns1/C", Fields: model1.Fields{"C", "2", "3"}}},
model1.RowEvent{Row: model1.Row{ID: "ns2/A", Fields: model1.Fields{"A", "2", "3"}}},
model1.RowEvent{Row: model1.Row{ID: "ns2/B", Fields: model1.Fields{"B", "2", "3"}}},
model1.RowEvent{Row: model1.Row{ID: "ns2/C", Fields: model1.Fields{"C", "2", "3"}}},
)
}

View File

@ -1,7 +1,7 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright Authors of K9s
package render_test
package model1_test
import (
"fmt"
@ -9,12 +9,12 @@ import (
"testing"
"time"
"github.com/derailed/k9s/internal/render"
"github.com/derailed/k9s/internal/model1"
"github.com/stretchr/testify/assert"
)
func BenchmarkRowCustomize(b *testing.B) {
row := render.Row{ID: "fred", Fields: render.Fields{"f1", "f2", "f3"}}
row := model1.Row{ID: "fred", Fields: model1.Fields{"f1", "f2", "f3"}}
cols := []int{0, 1, 2}
b.ReportAllocs()
b.ResetTimer()
@ -25,36 +25,36 @@ func BenchmarkRowCustomize(b *testing.B) {
func TestFieldCustomize(t *testing.T) {
uu := map[string]struct {
fields render.Fields
fields model1.Fields
cols []int
e render.Fields
e model1.Fields
}{
"empty": {
fields: render.Fields{},
fields: model1.Fields{},
cols: []int{0, 1, 2},
e: render.Fields{"", "", ""},
e: model1.Fields{"", "", ""},
},
"no-cols": {
fields: render.Fields{"f1", "f2", "f3"},
fields: model1.Fields{"f1", "f2", "f3"},
cols: []int{},
e: render.Fields{},
e: model1.Fields{},
},
"reverse": {
fields: render.Fields{"f1", "f2", "f3"},
fields: model1.Fields{"f1", "f2", "f3"},
cols: []int{1, 0},
e: render.Fields{"f2", "f1"},
e: model1.Fields{"f2", "f1"},
},
"missing": {
fields: render.Fields{"f1", "f2", "f3"},
fields: model1.Fields{"f1", "f2", "f3"},
cols: []int{10, 0},
e: render.Fields{"", "f1"},
e: model1.Fields{"", "f1"},
},
}
for k := range uu {
u := uu[k]
t.Run(k, func(t *testing.T) {
ff := make(render.Fields, len(u.cols))
ff := make(model1.Fields, len(u.cols))
u.fields.Customize(u.cols, ff)
assert.Equal(t, u.e, ff)
})
@ -62,7 +62,7 @@ func TestFieldCustomize(t *testing.T) {
}
func TestFieldClone(t *testing.T) {
f := render.Fields{"a", "b", "c"}
f := model1.Fields{"a", "b", "c"}
f1 := f.Clone()
assert.True(t, reflect.DeepEqual(f, f1))
@ -71,24 +71,24 @@ func TestFieldClone(t *testing.T) {
func TestRowlabelize(t *testing.T) {
uu := map[string]struct {
row render.Row
row model1.Row
cols []int
e render.Row
e model1.Row
}{
"empty": {
row: render.Row{},
row: model1.Row{},
cols: []int{0, 1, 2},
e: render.Row{ID: "", Fields: render.Fields{"", "", ""}},
e: model1.Row{ID: "", Fields: model1.Fields{"", "", ""}},
},
"no-cols-no-data": {
row: render.Row{},
row: model1.Row{},
cols: []int{},
e: render.Row{ID: "", Fields: render.Fields{}},
e: model1.Row{ID: "", Fields: model1.Fields{}},
},
"no-cols-data": {
row: render.Row{ID: "fred", Fields: render.Fields{"f1", "f2", "f3"}},
row: model1.Row{ID: "fred", Fields: model1.Fields{"f1", "f2", "f3"}},
cols: []int{},
e: render.Row{ID: "fred", Fields: render.Fields{}},
e: model1.Row{ID: "fred", Fields: model1.Fields{}},
},
}
@ -103,24 +103,24 @@ func TestRowlabelize(t *testing.T) {
func TestRowCustomize(t *testing.T) {
uu := map[string]struct {
row render.Row
row model1.Row
cols []int
e render.Row
e model1.Row
}{
"empty": {
row: render.Row{},
row: model1.Row{},
cols: []int{0, 1, 2},
e: render.Row{ID: "", Fields: render.Fields{"", "", ""}},
e: model1.Row{ID: "", Fields: model1.Fields{"", "", ""}},
},
"no-cols-no-data": {
row: render.Row{},
row: model1.Row{},
cols: []int{},
e: render.Row{ID: "", Fields: render.Fields{}},
e: model1.Row{ID: "", Fields: model1.Fields{}},
},
"no-cols-data": {
row: render.Row{ID: "fred", Fields: render.Fields{"f1", "f2", "f3"}},
row: model1.Row{ID: "fred", Fields: model1.Fields{"f1", "f2", "f3"}},
cols: []int{},
e: render.Row{ID: "fred", Fields: render.Fields{}},
e: model1.Row{ID: "fred", Fields: model1.Fields{}},
},
}
@ -135,49 +135,49 @@ func TestRowCustomize(t *testing.T) {
func TestRowsDelete(t *testing.T) {
uu := map[string]struct {
rows render.Rows
rows model1.Rows
id string
e render.Rows
e model1.Rows
}{
"first": {
rows: render.Rows{
rows: model1.Rows{
{ID: "a", Fields: []string{"blee", "duh"}},
{ID: "b", Fields: []string{"albert", "blee"}},
},
id: "a",
e: render.Rows{
e: model1.Rows{
{ID: "b", Fields: []string{"albert", "blee"}},
},
},
"last": {
rows: render.Rows{
rows: model1.Rows{
{ID: "a", Fields: []string{"blee", "duh"}},
{ID: "b", Fields: []string{"albert", "blee"}},
},
id: "b",
e: render.Rows{
e: model1.Rows{
{ID: "a", Fields: []string{"blee", "duh"}},
},
},
"middle": {
rows: render.Rows{
rows: model1.Rows{
{ID: "a", Fields: []string{"blee", "duh"}},
{ID: "b", Fields: []string{"albert", "blee"}},
{ID: "c", Fields: []string{"fred", "zorg"}},
},
id: "b",
e: render.Rows{
e: model1.Rows{
{ID: "a", Fields: []string{"blee", "duh"}},
{ID: "c", Fields: []string{"fred", "zorg"}},
},
},
"missing": {
rows: render.Rows{
rows: model1.Rows{
{ID: "a", Fields: []string{"blee", "duh"}},
{ID: "b", Fields: []string{"albert", "blee"}},
},
id: "zorg",
e: render.Rows{
e: model1.Rows{
{ID: "a", Fields: []string{"blee", "duh"}},
{ID: "b", Fields: []string{"albert", "blee"}},
},
@ -195,29 +195,29 @@ func TestRowsDelete(t *testing.T) {
func TestRowsUpsert(t *testing.T) {
uu := map[string]struct {
rows render.Rows
row render.Row
e render.Rows
rows model1.Rows
row model1.Row
e model1.Rows
}{
"add": {
rows: render.Rows{
rows: model1.Rows{
{ID: "a", Fields: []string{"blee", "duh"}},
{ID: "b", Fields: []string{"albert", "blee"}},
},
row: render.Row{ID: "c", Fields: []string{"f1", "f2"}},
e: render.Rows{
row: model1.Row{ID: "c", Fields: []string{"f1", "f2"}},
e: model1.Rows{
{ID: "a", Fields: []string{"blee", "duh"}},
{ID: "b", Fields: []string{"albert", "blee"}},
{ID: "c", Fields: []string{"f1", "f2"}},
},
},
"update": {
rows: render.Rows{
rows: model1.Rows{
{ID: "a", Fields: []string{"blee", "duh"}},
{ID: "b", Fields: []string{"albert", "blee"}},
},
row: render.Row{ID: "a", Fields: []string{"f1", "f2"}},
e: render.Rows{
row: model1.Row{ID: "a", Fields: []string{"f1", "f2"}},
e: model1.Rows{
{ID: "a", Fields: []string{"f1", "f2"}},
{ID: "b", Fields: []string{"albert", "blee"}},
},
@ -235,69 +235,69 @@ func TestRowsUpsert(t *testing.T) {
func TestRowsSortText(t *testing.T) {
uu := map[string]struct {
rows render.Rows
rows model1.Rows
col int
asc, num bool
e render.Rows
e model1.Rows
}{
"plainAsc": {
rows: render.Rows{
rows: model1.Rows{
{Fields: []string{"blee", "duh"}},
{Fields: []string{"albert", "blee"}},
},
col: 0,
asc: true,
e: render.Rows{
e: model1.Rows{
{Fields: []string{"albert", "blee"}},
{Fields: []string{"blee", "duh"}},
},
},
"plainDesc": {
rows: render.Rows{
rows: model1.Rows{
{Fields: []string{"blee", "duh"}},
{Fields: []string{"albert", "blee"}},
},
col: 0,
asc: false,
e: render.Rows{
e: model1.Rows{
{Fields: []string{"blee", "duh"}},
{Fields: []string{"albert", "blee"}},
},
},
"numericAsc": {
rows: render.Rows{
rows: model1.Rows{
{Fields: []string{"10", "duh"}},
{Fields: []string{"1", "blee"}},
},
col: 0,
num: true,
asc: true,
e: render.Rows{
e: model1.Rows{
{Fields: []string{"1", "blee"}},
{Fields: []string{"10", "duh"}},
},
},
"numericDesc": {
rows: render.Rows{
rows: model1.Rows{
{Fields: []string{"10", "duh"}},
{Fields: []string{"1", "blee"}},
},
col: 0,
num: true,
asc: false,
e: render.Rows{
e: model1.Rows{
{Fields: []string{"10", "duh"}},
{Fields: []string{"1", "blee"}},
},
},
"composite": {
rows: render.Rows{
rows: model1.Rows{
{Fields: []string{"blee-duh", "duh"}},
{Fields: []string{"blee", "blee"}},
},
col: 0,
asc: true,
e: render.Rows{
e: model1.Rows{
{Fields: []string{"blee", "blee"}},
{Fields: []string{"blee-duh", "duh"}},
},
@ -315,54 +315,54 @@ func TestRowsSortText(t *testing.T) {
func TestRowsSortDuration(t *testing.T) {
uu := map[string]struct {
rows render.Rows
rows model1.Rows
col int
asc bool
e render.Rows
e model1.Rows
}{
"fred": {
rows: render.Rows{
rows: model1.Rows{
{Fields: []string{"2m24s", "blee"}},
{Fields: []string{"2m12s", "duh"}},
},
col: 0,
asc: true,
e: render.Rows{
e: model1.Rows{
{Fields: []string{"2m12s", "duh"}},
{Fields: []string{"2m24s", "blee"}},
},
},
"years": {
rows: render.Rows{
rows: model1.Rows{
{Fields: []string{testTime().Add(-365 * 24 * time.Hour).String(), "blee"}},
{Fields: []string{testTime().String(), "duh"}},
},
col: 0,
asc: true,
e: render.Rows{
e: model1.Rows{
{Fields: []string{testTime().String(), "duh"}},
{Fields: []string{testTime().Add(-365 * 24 * time.Hour).String(), "blee"}},
},
},
"durationAsc": {
rows: render.Rows{
rows: model1.Rows{
{Fields: []string{testTime().Add(10 * time.Second).String(), "duh"}},
{Fields: []string{testTime().String(), "blee"}},
},
col: 0,
asc: true,
e: render.Rows{
e: model1.Rows{
{Fields: []string{testTime().String(), "blee"}},
{Fields: []string{testTime().Add(10 * time.Second).String(), "duh"}},
},
},
"durationDesc": {
rows: render.Rows{
rows: model1.Rows{
{Fields: []string{testTime().Add(10 * time.Second).String(), "duh"}},
{Fields: []string{testTime().String(), "blee"}},
},
col: 0,
e: render.Rows{
e: model1.Rows{
{Fields: []string{testTime().Add(10 * time.Second).String(), "duh"}},
{Fields: []string{testTime().String(), "blee"}},
},
@ -380,31 +380,31 @@ func TestRowsSortDuration(t *testing.T) {
func TestRowsSortMetrics(t *testing.T) {
uu := map[string]struct {
rows render.Rows
rows model1.Rows
col int
asc bool
e render.Rows
e model1.Rows
}{
"metricAsc": {
rows: render.Rows{
rows: model1.Rows{
{Fields: []string{"10m", "duh"}},
{Fields: []string{"1m", "blee"}},
},
col: 0,
asc: true,
e: render.Rows{
e: model1.Rows{
{Fields: []string{"1m", "blee"}},
{Fields: []string{"10m", "duh"}},
},
},
"metricDesc": {
rows: render.Rows{
rows: model1.Rows{
{Fields: []string{"10000m", "1000Mi"}},
{Fields: []string{"1m", "50Mi"}},
},
col: 1,
asc: false,
e: render.Rows{
e: model1.Rows{
{Fields: []string{"10000m", "1000Mi"}},
{Fields: []string{"1m", "50Mi"}},
},
@ -422,31 +422,31 @@ func TestRowsSortMetrics(t *testing.T) {
func TestRowsSortCapacity(t *testing.T) {
uu := map[string]struct {
rows render.Rows
rows model1.Rows
col int
asc bool
e render.Rows
e model1.Rows
}{
"capacityAsc": {
rows: render.Rows{
rows: model1.Rows{
{Fields: []string{"10Gi", "duh"}},
{Fields: []string{"10G", "blee"}},
},
col: 0,
asc: true,
e: render.Rows{
e: model1.Rows{
{Fields: []string{"10G", "blee"}},
{Fields: []string{"10Gi", "duh"}},
},
},
"capacityDesc": {
rows: render.Rows{
rows: model1.Rows{
{Fields: []string{"10000m", "1000Mi"}},
{Fields: []string{"1m", "50Mi"}},
},
col: 1,
asc: false,
e: render.Rows{
e: model1.Rows{
{Fields: []string{"10000m", "1000Mi"}},
{Fields: []string{"1m", "50Mi"}},
},
@ -514,7 +514,7 @@ func TestLess(t *testing.T) {
for k := range uu {
u := uu[k]
t.Run(k, func(t *testing.T) {
assert.Equal(t, u.e, render.Less(u.isNumber, u.isDuration, u.isCapacity, u.id1, u.id2, u.v1, u.v2))
assert.Equal(t, u.e, model1.Less(u.isNumber, u.isDuration, u.isCapacity, u.id1, u.id2, u.v1, u.v2))
})
}
}

61
internal/model1/rows.go Normal file
View File

@ -0,0 +1,61 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright Authors of K9s
package model1
import "sort"
// Rows represents a collection of rows.
type Rows []Row
// Delete removes an element by id.
func (rr Rows) Delete(id string) Rows {
idx, ok := rr.Find(id)
if !ok {
return rr
}
if idx == 0 {
return rr[1:]
}
if idx+1 == len(rr) {
return rr[:len(rr)-1]
}
return append(rr[:idx], rr[idx+1:]...)
}
// Upsert adds a new item.
func (rr Rows) Upsert(r Row) Rows {
idx, ok := rr.Find(r.ID)
if !ok {
return append(rr, r)
}
rr[idx] = r
return rr
}
// Find locates a row by id. Returns false is not found.
func (rr Rows) Find(id string) (int, bool) {
for i, r := range rr {
if r.ID == id {
return i, true
}
}
return 0, false
}
// Sort rows based on column index and order.
func (rr Rows) Sort(col int, asc, isNum, isDur, isCapacity bool) {
t := RowSorter{
Rows: rr,
Index: col,
IsNumber: isNum,
IsDuration: isDur,
IsCapacity: isCapacity,
Asc: asc,
}
sort.Sort(t)
}

View File

@ -0,0 +1,496 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright Authors of K9s
package model1
import (
"context"
"errors"
"fmt"
"regexp"
"strings"
"sync"
"github.com/derailed/k9s/internal"
"github.com/derailed/k9s/internal/client"
"github.com/derailed/k9s/internal/config"
"github.com/rs/zerolog/log"
"github.com/sahilm/fuzzy"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
)
type (
// SortFn represent a function that can sort columnar data.
SortFn func(rows Rows, sortCol SortColumn)
// SortColumn represents a sortable column.
SortColumn struct {
Name string
ASC bool
}
)
const spacer = " "
type FilterOpts struct {
Toast bool
Filter string
Invert bool
}
// TableData tracks a K8s resource for tabular display.
type TableData struct {
header Header
rowEvents *RowEvents
namespace string
gvr client.GVR
mx sync.RWMutex
}
// NewTableData returns a new table.
func NewTableData(gvr client.GVR) *TableData {
return &TableData{
gvr: gvr,
rowEvents: NewRowEvents(10),
}
}
func NewTableDataFull(gvr client.GVR, ns string, h Header, re *RowEvents) *TableData {
t := NewTableDataWithRows(gvr, h, re)
t.namespace = ns
return t
}
func NewTableDataWithRows(gvr client.GVR, h Header, re *RowEvents) *TableData {
t := NewTableData(gvr)
t.header, t.rowEvents = h, re
return t
}
func NewTableDataFromTable(td *TableData) *TableData {
t := NewTableData(td.gvr)
t.header = td.header
t.rowEvents = td.rowEvents
t.namespace = td.namespace
return t
}
func (t *TableData) AddRow(re RowEvent) {
t.rowEvents.Add(re)
}
func (t *TableData) SetRow(idx int, re RowEvent) {
t.rowEvents.Set(idx, re)
}
func (t *TableData) FindRow(id string) (RowEvent, bool) {
return t.rowEvents.Get(id)
}
func (t *TableData) RowAt(idx int) (RowEvent, bool) {
return t.rowEvents.At(idx)
}
func (t *TableData) RowsRange(f ReRangeFn) {
t.rowEvents.Range(f)
}
func (t *TableData) Sort(sc SortColumn) {
col, idx := t.HeadCol(sc.Name, false)
if idx < 0 {
return
}
t.rowEvents.Sort(
t.GetNamespace(),
idx,
col.Time,
col.MX,
col.Capacity,
sc.ASC,
)
}
func (t *TableData) Header() Header {
return t.header
}
// HeaderCount returns the number of header cols.
func (t *TableData) HeaderCount() int {
t.mx.RLock()
defer t.mx.RUnlock()
return len(t.header)
}
func (t *TableData) HeadCol(n string, w bool) (HeaderColumn, int) {
idx, ok := t.header.IndexOf(n, w)
if !ok {
return HeaderColumn{}, -1
}
return t.header[idx], idx
}
func (t *TableData) Filter(f FilterOpts) *TableData {
td := NewTableDataFromTable(t)
if f.Toast {
td.rowEvents = t.filterToast()
}
if f.Filter == "" || internal.IsLabelSelector(f.Filter) {
return td
}
if f, ok := internal.IsFuzzySelector(f.Filter); ok {
td.rowEvents = t.fuzzyFilter(f)
return td
}
rr, err := t.rxFilter(f.Filter, internal.IsInverseSelector(f.Filter))
if err == nil {
td.rowEvents = rr
} else {
log.Error().Err(err).Msg("rx filter failed")
}
return td
}
func (t *TableData) rxFilter(q string, inverse bool) (*RowEvents, error) {
if inverse {
q = q[1:]
}
rx, err := regexp.Compile(`(?i)(` + q + `)`)
if err != nil {
return nil, fmt.Errorf("invalid rx filter %q: %w", q, err)
}
ageIndex, ok := t.header.IndexOf("AGE", true)
rr := NewRowEvents(t.RowCount() / 2)
t.rowEvents.Range(func(_ int, re RowEvent) bool {
ff := re.Row.Fields
if ok && ageIndex+1 <= len(ff) {
ff = append(ff[0:ageIndex], ff[ageIndex+1:]...)
}
fields := strings.Join(ff, spacer)
if (inverse && !rx.MatchString(fields)) ||
((!inverse) && rx.MatchString(fields)) {
rr.Add(re)
}
return true
})
return rr, nil
}
func (t *TableData) fuzzyFilter(q string) *RowEvents {
q = strings.TrimSpace(q)
ss := make([]string, 0, t.RowCount()/2)
t.rowEvents.Range(func(_ int, re RowEvent) bool {
ss = append(ss, re.Row.ID)
return true
})
mm := fuzzy.Find(q, ss)
rr := NewRowEvents(t.RowCount() / 2)
for _, m := range mm {
re, ok := t.rowEvents.At(m.Index)
if !ok {
log.Error().Msgf("unable to find event for index in fuzzfilter: %d", m.Index)
continue
}
rr.Add(re)
}
return rr
}
func (t *TableData) filterToast() *RowEvents {
idx, ok := t.header.IndexOf("VALID", true)
if !ok {
return nil
}
rr := NewRowEvents(10)
t.rowEvents.Range(func(_ int, re RowEvent) bool {
if re.Row.Fields[idx] != "" {
rr.Add(re)
}
return true
})
return rr
}
func (t *TableData) GetNamespace() string {
t.mx.RLock()
defer t.mx.RUnlock()
return t.namespace
}
func (t *TableData) Reset(ns string) {
t.mx.Lock()
{
t.namespace = ns
}
t.mx.Unlock()
t.Clear()
}
func (t *TableData) Reconcile(ctx context.Context, r Renderer, oo []runtime.Object) error {
var rows Rows
if len(oo) > 0 {
if r.IsGeneric() {
table, ok := oo[0].(*metav1.Table)
if !ok {
return fmt.Errorf("expecting a meta table but got %T", oo[0])
}
rows = make(Rows, len(table.Rows))
if err := GenericHydrate(t.namespace, table, rows, r); err != nil {
return err
}
} else {
rows = make(Rows, len(oo))
if err := Hydrate(t.namespace, oo, rows, r); err != nil {
return err
}
}
}
t.Update(rows)
t.SetHeader(t.namespace, r.Header(t.namespace))
if t.HeaderCount() == 0 {
return fmt.Errorf("fail to list resource %s", t.gvr)
}
return nil
}
// Empty checks if there are no entries.
func (t *TableData) Empty() bool {
t.mx.RLock()
defer t.mx.RUnlock()
return t.rowEvents.Empty()
}
func (t *TableData) SetRowEvents(re *RowEvents) {
t.rowEvents = re
}
func (t *TableData) GetRowEvents() *RowEvents {
return t.rowEvents
}
// RowCount returns the number of rows.
func (t *TableData) RowCount() int {
t.mx.RLock()
defer t.mx.RUnlock()
return t.rowEvents.Len()
}
// IndexOfHeader return the index of the header.
func (t *TableData) IndexOfHeader(h string) (int, bool) {
return t.header.IndexOf(h, false)
}
// Labelize prints out specific label columns.
func (t *TableData) Labelize(labels []string) *TableData {
idx, ok := t.header.IndexOf("LABELS", true)
if !ok {
return t
}
cols := []int{0, 1}
if client.IsNamespaced(t.namespace) {
cols = cols[1:]
}
data := TableData{
namespace: t.namespace,
header: t.header.Labelize(cols, idx, t.rowEvents),
}
data.rowEvents = t.rowEvents.Labelize(cols, idx, labels)
return &data
}
// Customize returns a new model with customized column layout.
func (t *TableData) Customize(vs *config.ViewSetting, sc SortColumn, manual, wide bool) (*TableData, SortColumn) {
if vs.IsBlank() {
if sc.Name != "" {
return t, sc
}
psc, err := t.sortCol(vs)
if 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
}
return &cdata, psc
}
func (t *TableData) sortCol(vs *config.ViewSetting) (SortColumn, error) {
var psc SortColumn
if t.HeaderCount() == 0 {
return psc, errors.New("no header found")
}
name, order, _ := vs.SortCol()
if _, ok := t.header.IndexOf(name, false); ok {
psc.Name, psc.ASC = name, order
return psc, nil
}
if client.IsAllNamespaces(t.GetNamespace()) {
if _, ok := t.header.IndexOf("NAMESPACE", false); ok {
psc.Name = "NAMESPACE"
} else if _, ok := t.header.IndexOf("NAME", false); ok {
psc.Name = "NAME"
}
} else {
if _, ok := t.header.IndexOf("NAME", false); ok {
psc.Name = "NAME"
} else {
psc.Name = t.header[0].Name
}
}
return psc, nil
}
// Clear clears out the entire table.
func (t *TableData) Clear() {
t.mx.Lock()
defer t.mx.Unlock()
t.header = t.header.Clear()
t.rowEvents.Clear()
}
// Clone returns a copy of the table.
func (t *TableData) Clone() *TableData {
t.mx.RLock()
defer t.mx.RUnlock()
return &TableData{
header: t.header.Clone(),
rowEvents: t.rowEvents.Clone(),
namespace: t.namespace,
}
}
func (t *TableData) ColumnNames(w bool) []string {
t.mx.RLock()
defer t.mx.RUnlock()
return t.header.ColumnNames(w)
}
// GetHeader returns table header.
func (t *TableData) GetHeader() Header {
t.mx.RLock()
defer t.mx.RUnlock()
return t.header
}
// SetHeader sets table header.
func (t *TableData) SetHeader(ns string, h Header) {
t.mx.Lock()
defer t.mx.Unlock()
t.namespace, t.header = ns, h
}
// Update computes row deltas and update the table data.
func (t *TableData) Update(rows Rows) {
empty := t.Empty()
kk := make(map[string]struct{}, len(rows))
var blankDelta DeltaRow
t.mx.Lock()
{
for _, row := range rows {
kk[row.ID] = struct{}{}
if empty {
t.rowEvents.Add(NewRowEvent(EventAdd, row))
continue
}
if index, ok := t.rowEvents.FindIndex(row.ID); ok {
ev, ok := t.rowEvents.At(index)
if !ok {
continue
}
delta := NewDeltaRow(ev.Row, row, t.header)
if delta.IsBlank() {
ev.Kind, ev.Deltas, ev.Row = EventUnchanged, blankDelta, row
t.rowEvents.Set(index, ev)
} else {
t.rowEvents.Set(index, NewRowEventWithDeltas(row, delta))
}
continue
}
t.rowEvents.Add(NewRowEvent(EventAdd, row))
}
}
t.mx.Unlock()
if !empty {
t.Delete(kk)
}
}
// Delete removes items in cache that are no longer valid.
func (t *TableData) Delete(newKeys map[string]struct{}) {
t.mx.Lock()
{
victims := make([]string, 0, 10)
t.rowEvents.Range(func(_ int, e RowEvent) bool {
if _, ok := newKeys[e.Row.ID]; !ok {
victims = append(victims, e.Row.ID)
} else {
delete(newKeys, e.Row.ID)
}
return true
})
for _, id := range victims {
if err := t.rowEvents.Delete(id); err != nil {
log.Error().Err(err).Msgf("table delete failed: %q", id)
}
}
}
t.mx.Unlock()
}
// Diff checks if two tables are equal.
func (t *TableData) Diff(t2 *TableData) bool {
if t2 == nil || t.namespace != t2.namespace || t.header.Diff(t2.header) {
return true
}
idx, ok := t.header.IndexOf("AGE", true)
if !ok {
idx = -1
}
return t.rowEvents.Diff(t2.rowEvents, idx)
}

View File

@ -0,0 +1,404 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright Authors of K9s
package model1
import (
"testing"
"github.com/derailed/k9s/internal/client"
"github.com/derailed/k9s/internal/config"
"github.com/rs/zerolog"
"github.com/stretchr/testify/assert"
)
func init() {
zerolog.SetGlobalLevel(zerolog.FatalLevel)
}
func TestTableDataCustomize(t *testing.T) {
uu := map[string]struct {
t1, e *TableData
vs config.ViewSetting
sc SortColumn
wide, manual bool
}{
"same": {
t1: NewTableDataWithRows(
client.NewGVR("test"),
Header{
HeaderColumn{Name: "A"},
HeaderColumn{Name: "B"},
HeaderColumn{Name: "C"},
},
NewRowEventsWithEvts(
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"}}},
),
),
vs: config.ViewSetting{Columns: []string{"A", "B", "C"}},
e: NewTableDataWithRows(
client.NewGVR("test"),
Header{
HeaderColumn{Name: "A"},
HeaderColumn{Name: "B"},
HeaderColumn{Name: "C"},
},
NewRowEventsWithEvts(
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"}}},
),
),
},
"wide-col": {
t1: NewTableDataWithRows(
client.NewGVR("test"),
Header{
HeaderColumn{Name: "A"},
HeaderColumn{Name: "B", Wide: true},
HeaderColumn{Name: "C"},
},
NewRowEventsWithEvts(
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"}}},
),
),
vs: config.ViewSetting{Columns: []string{"A", "B", "C"}},
e: NewTableDataWithRows(
client.NewGVR("test"),
Header{
HeaderColumn{Name: "A"},
HeaderColumn{Name: "B", Wide: false},
HeaderColumn{Name: "C"},
},
NewRowEventsWithEvts(
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"}}},
),
),
},
"wide": {
t1: NewTableDataWithRows(
client.NewGVR("test"),
Header{
HeaderColumn{Name: "A"},
HeaderColumn{Name: "B", Wide: true},
HeaderColumn{Name: "C"},
},
NewRowEventsWithEvts(
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"}}},
),
),
wide: true,
vs: config.ViewSetting{Columns: []string{"A", "C"}},
e: NewTableDataWithRows(
client.NewGVR("test"),
Header{
HeaderColumn{Name: "A"},
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"}}},
),
),
},
}
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)
assert.Equal(t, u.e, td)
})
}
}
func TestTableDataDiff(t *testing.T) {
uu := map[string]struct {
t1, t2 *TableData
e bool
}{
"empty": {
t1: NewTableDataWithRows(
client.NewGVR("test"),
Header{
HeaderColumn{Name: "A"},
HeaderColumn{Name: "B"},
HeaderColumn{Name: "C"},
},
NewRowEventsWithEvts(
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"}}},
),
),
e: true,
},
"same": {
t1: NewTableDataWithRows(
client.NewGVR("test"),
Header{
HeaderColumn{Name: "A"},
HeaderColumn{Name: "B"},
HeaderColumn{Name: "C"},
},
NewRowEventsWithEvts(
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"}}},
),
),
t2: NewTableDataWithRows(
client.NewGVR("test"),
Header{
HeaderColumn{Name: "A"},
HeaderColumn{Name: "B"},
HeaderColumn{Name: "C"},
},
NewRowEventsWithEvts(
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"}}},
),
),
},
"ns-diff": {
t1: NewTableDataFull(
client.NewGVR("test"),
"ns1",
Header{
HeaderColumn{Name: "A"},
HeaderColumn{Name: "B"},
HeaderColumn{Name: "C"},
},
NewRowEventsWithEvts(
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"}}},
),
),
t2: NewTableDataFull(
client.NewGVR("test"),
"ns-2",
Header{
HeaderColumn{Name: "A"},
HeaderColumn{Name: "B"},
HeaderColumn{Name: "C"},
},
NewRowEventsWithEvts(
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"}}},
),
),
e: true,
},
"header-diff": {
t1: NewTableDataWithRows(
client.NewGVR("test"),
Header{
HeaderColumn{Name: "A"},
HeaderColumn{Name: "D"},
HeaderColumn{Name: "C"},
},
NewRowEventsWithEvts(
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"}}},
),
),
t2: NewTableDataWithRows(
client.NewGVR("test"),
Header{
HeaderColumn{Name: "A"},
HeaderColumn{Name: "B"},
HeaderColumn{Name: "C"},
},
NewRowEventsWithEvts(
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"}}},
),
),
e: true,
},
"row-diff": {
t1: NewTableDataWithRows(
client.NewGVR("test"),
Header{
HeaderColumn{Name: "A"},
HeaderColumn{Name: "B"},
HeaderColumn{Name: "C"},
},
NewRowEventsWithEvts(
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"}}},
),
),
t2: NewTableDataWithRows(
client.NewGVR("test"),
Header{
HeaderColumn{Name: "A"},
HeaderColumn{Name: "B"},
HeaderColumn{Name: "C"},
},
NewRowEventsWithEvts(
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{"100", "2", "3"}}},
),
),
e: true,
},
}
for k := range uu {
u := uu[k]
t.Run(k, func(t *testing.T) {
assert.Equal(t, u.e, u.t1.Diff(u.t2))
})
}
}
func TestTableDataUpdate(t *testing.T) {
uu := map[string]struct {
re, e *RowEvents
rr Rows
}{
"no-change": {
re: NewRowEventsWithEvts(
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"}}},
),
rr: Rows{
Row{ID: "A", Fields: Fields{"1", "2", "3"}},
Row{ID: "B", Fields: Fields{"0", "2", "3"}},
Row{ID: "C", Fields: Fields{"10", "2", "3"}},
},
e: NewRowEventsWithEvts(
RowEvent{Kind: EventUnchanged, Row: Row{ID: "A", Fields: Fields{"1", "2", "3"}}},
RowEvent{Kind: EventUnchanged, Row: Row{ID: "B", Fields: Fields{"0", "2", "3"}}},
RowEvent{Kind: EventUnchanged, Row: Row{ID: "C", Fields: Fields{"10", "2", "3"}}},
),
},
"add": {
re: NewRowEventsWithEvts(
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"}}},
),
rr: Rows{
Row{ID: "A", Fields: Fields{"1", "2", "3"}},
Row{ID: "B", Fields: Fields{"0", "2", "3"}},
Row{ID: "C", Fields: Fields{"10", "2", "3"}},
Row{ID: "D", Fields: Fields{"10", "2", "3"}},
},
e: NewRowEventsWithEvts(
RowEvent{Kind: EventUnchanged, Row: Row{ID: "A", Fields: Fields{"1", "2", "3"}}},
RowEvent{Kind: EventUnchanged, Row: Row{ID: "B", Fields: Fields{"0", "2", "3"}}},
RowEvent{Kind: EventUnchanged, Row: Row{ID: "C", Fields: Fields{"10", "2", "3"}}},
RowEvent{Kind: EventAdd, Row: Row{ID: "D", Fields: Fields{"10", "2", "3"}}},
),
},
"delete": {
re: NewRowEventsWithEvts(
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"}}},
),
rr: Rows{
Row{ID: "A", Fields: Fields{"1", "2", "3"}},
Row{ID: "C", Fields: Fields{"10", "2", "3"}},
},
e: NewRowEventsWithEvts(
RowEvent{Kind: EventUnchanged, Row: Row{ID: "A", Fields: Fields{"1", "2", "3"}}},
RowEvent{Kind: EventUnchanged, Row: Row{ID: "C", Fields: Fields{"10", "2", "3"}}},
),
},
"update": {
re: NewRowEventsWithEvts(
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"}}},
),
rr: Rows{
Row{ID: "A", Fields: Fields{"10", "2", "3"}},
Row{ID: "B", Fields: Fields{"0", "2", "3"}},
Row{ID: "C", Fields: Fields{"10", "2", "3"}},
},
e: NewRowEventsWithEvts(
RowEvent{
Kind: EventUpdate,
Row: Row{ID: "A", Fields: Fields{"10", "2", "3"}},
Deltas: DeltaRow{"1", "", ""},
},
RowEvent{Kind: EventUnchanged, Row: Row{ID: "B", Fields: Fields{"0", "2", "3"}}},
RowEvent{Kind: EventUnchanged, Row: Row{ID: "C", Fields: Fields{"10", "2", "3"}}},
),
},
}
var table TableData
for k := range uu {
u := uu[k]
t.Run(k, func(t *testing.T) {
table.SetRowEvents(u.re)
table.Update(u.rr)
assert.Equal(t, u.e, table.GetRowEvents())
})
}
}
func TestTableDataDelete(t *testing.T) {
uu := map[string]struct {
re, e *RowEvents
kk map[string]struct{}
}{
"ordered": {
re: NewRowEventsWithEvts(
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"}}},
),
kk: map[string]struct{}{"A": {}, "C": {}},
e: NewRowEventsWithEvts(
RowEvent{Row: Row{ID: "A", Fields: Fields{"1", "2", "3"}}},
RowEvent{Row: Row{ID: "C", Fields: Fields{"10", "2", "3"}}},
),
},
"unordered": {
re: NewRowEventsWithEvts(
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"}}},
RowEvent{Row: Row{ID: "D", Fields: Fields{"10", "2", "3"}}},
),
kk: map[string]struct{}{"C": {}, "A": {}},
e: NewRowEventsWithEvts(
RowEvent{Row: Row{ID: "A", Fields: Fields{"1", "2", "3"}}},
RowEvent{Row: Row{ID: "C", Fields: Fields{"10", "2", "3"}}},
),
},
}
var table TableData
for k := range uu {
u := uu[k]
t.Run(k, func(t *testing.T) {
table.SetRowEvents(u.re)
table.Delete(u.kk)
assert.Equal(t, u.e, table.GetRowEvents())
})
}
}

View File

@ -0,0 +1,17 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright Authors of K9s
package model1_test
import (
"fmt"
"time"
)
func testTime() time.Time {
t, err := time.Parse(time.RFC3339, "2018-12-14T10:36:43.326972-07:00")
if err != nil {
fmt.Println("TestTime Failed", err)
}
return t
}

61
internal/model1/types.go Normal file
View File

@ -0,0 +1,61 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright Authors of K9s
package model1
import (
"github.com/derailed/tcell/v2"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
const (
NAValue = "na"
// EventUnchanged notifies listener resource has not changed.
EventUnchanged ResEvent = 1 << iota
// EventAdd notifies listener of a resource was added.
EventAdd
// EventUpdate notifies listener of a resource updated.
EventUpdate
// EventDelete notifies listener of a resource was deleted.
EventDelete
// EventClear the stack was reset.
EventClear
)
// DecoratorFunc decorates a string.
type DecoratorFunc func(string) string
// ColorerFunc represents a resource row colorer.
type ColorerFunc func(ns string, h Header, re *RowEvent) tcell.Color
// Renderer represents a resource renderer.
type Renderer interface {
// IsGeneric identifies a generic handler.
IsGeneric() bool
// Render converts raw resources to tabular data.
Render(o interface{}, ns string, row *Row) error
// Header returns the resource header.
Header(ns string) Header
// ColorerFunc returns a row colorer function.
ColorerFunc() ColorerFunc
}
// Generic represents a generic resource.
type Generic interface {
// SetTable sets up the resource tabular definition.
SetTable(ns string, table *metav1.Table)
// Header returns a resource header.
Header(ns string) Header
// Render renders the resource.
Render(o interface{}, ns string, row *Row) error
}

View File

@ -8,6 +8,7 @@ import (
"strings"
"github.com/derailed/k9s/internal/client"
"github.com/derailed/k9s/internal/model1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
)
@ -18,17 +19,17 @@ type Alias struct {
}
// Header returns a header row.
func (Alias) Header(ns string) Header {
return Header{
HeaderColumn{Name: "RESOURCE"},
HeaderColumn{Name: "COMMAND"},
HeaderColumn{Name: "API-GROUP"},
func (Alias) Header(ns string) model1.Header {
return model1.Header{
model1.HeaderColumn{Name: "RESOURCE"},
model1.HeaderColumn{Name: "COMMAND"},
model1.HeaderColumn{Name: "API-GROUP"},
}
}
// Render renders a K8s resource to screen.
// BOZO!! Pass in a row with pre-alloc fields??
func (Alias) Render(o interface{}, ns string, r *Row) error {
func (Alias) Render(o interface{}, ns string, r *model1.Row) error {
a, ok := o.(AliasRes)
if !ok {
return fmt.Errorf("expected AliasRes, but got %T", o)

View File

@ -7,6 +7,7 @@ import (
"testing"
"github.com/derailed/k9s/internal/client"
"github.com/derailed/k9s/internal/model1"
"github.com/derailed/k9s/internal/render"
"github.com/derailed/tcell/v2"
"github.com/stretchr/testify/assert"
@ -14,30 +15,30 @@ import (
func TestAliasColorer(t *testing.T) {
var a render.Alias
h := render.Header{
render.HeaderColumn{Name: "A"},
render.HeaderColumn{Name: "B"},
render.HeaderColumn{Name: "C"},
h := model1.Header{
model1.HeaderColumn{Name: "A"},
model1.HeaderColumn{Name: "B"},
model1.HeaderColumn{Name: "C"},
}
r := render.Row{ID: "g/v/r", Fields: render.Fields{"r", "blee", "g"}}
r := model1.Row{ID: "g/v/r", Fields: model1.Fields{"r", "blee", "g"}}
uu := map[string]struct {
ns string
re render.RowEvent
re model1.RowEvent
e tcell.Color
}{
"addAll": {
ns: client.NamespaceAll,
re: render.RowEvent{Kind: render.EventAdd, Row: r},
re: model1.RowEvent{Kind: model1.EventAdd, Row: r},
e: tcell.ColorBlue,
},
"deleteAll": {
ns: client.NamespaceAll,
re: render.RowEvent{Kind: render.EventDelete, Row: r},
re: model1.RowEvent{Kind: model1.EventDelete, Row: r},
e: tcell.ColorGray,
},
"updateAll": {
ns: client.NamespaceAll,
re: render.RowEvent{Kind: render.EventUpdate, Row: r},
re: model1.RowEvent{Kind: model1.EventUpdate, Row: r},
e: tcell.ColorDefault,
},
}
@ -45,16 +46,16 @@ func TestAliasColorer(t *testing.T) {
for k := range uu {
u := uu[k]
t.Run(k, func(t *testing.T) {
assert.Equal(t, u.e, a.ColorerFunc()(u.ns, h, u.re))
assert.Equal(t, u.e, a.ColorerFunc()(u.ns, h, &u.re))
})
}
}
func TestAliasHeader(t *testing.T) {
h := render.Header{
render.HeaderColumn{Name: "RESOURCE"},
render.HeaderColumn{Name: "COMMAND"},
render.HeaderColumn{Name: "API-GROUP"},
h := model1.Header{
model1.HeaderColumn{Name: "RESOURCE"},
model1.HeaderColumn{Name: "COMMAND"},
model1.HeaderColumn{Name: "API-GROUP"},
}
var a render.Alias
@ -70,9 +71,9 @@ func TestAliasRender(t *testing.T) {
Aliases: []string{"a", "b", "c"},
}
var r render.Row
var r model1.Row
assert.Nil(t, a.Render(o, "fred/v1/blee", &r))
assert.Equal(t, render.Row{ID: "fred/v1/blee", Fields: render.Fields{"blee", "a,b,c", "fred"}}, r)
assert.Equal(t, model1.Row{ID: "fred/v1/blee", Fields: model1.Fields{"blee", "a,b,c", "fred"}}, r)
}
func BenchmarkAlias(b *testing.B) {
@ -85,7 +86,7 @@ func BenchmarkAlias(b *testing.B) {
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
var r render.Row
var r model1.Row
_ = a.Render(o, "aliases", &r)
}
}

View File

@ -3,6 +3,10 @@
package render
import (
"github.com/derailed/k9s/internal/model1"
)
// DecoratorFunc decorates a string.
type DecoratorFunc func(string) string
@ -19,11 +23,11 @@ func (Base) IsGeneric() bool {
}
// ColorerFunc colors a resource row.
func (Base) ColorerFunc() ColorerFunc {
return DefaultColorer
func (Base) ColorerFunc() model1.ColorerFunc {
return model1.DefaultColorer
}
// Happy returns true if resource is happy, false otherwise.
func (Base) Happy(_ string, _ Row) bool {
func (Base) Happy(string, *model1.Row) bool {
return true
}

View File

@ -12,6 +12,7 @@ import (
"strings"
"github.com/derailed/k9s/internal/client"
"github.com/derailed/k9s/internal/model1"
"github.com/derailed/tcell/v2"
"github.com/derailed/tview"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@ -33,33 +34,34 @@ type Benchmark struct {
}
// ColorerFunc colors a resource row.
func (b Benchmark) ColorerFunc() ColorerFunc {
return func(ns string, h Header, re RowEvent) tcell.Color {
if !Happy(ns, h, re.Row) {
return ErrColor
func (b Benchmark) ColorerFunc() model1.ColorerFunc {
return func(ns string, h model1.Header, re *model1.RowEvent) tcell.Color {
if !model1.IsValid(ns, h, re.Row) {
return model1.ErrColor
}
return tcell.ColorPaleGreen
}
}
// Header returns a header row.
func (Benchmark) Header(ns string) Header {
return Header{
HeaderColumn{Name: "NAMESPACE"},
HeaderColumn{Name: "NAME"},
HeaderColumn{Name: "STATUS"},
HeaderColumn{Name: "TIME"},
HeaderColumn{Name: "REQ/S", Align: tview.AlignRight},
HeaderColumn{Name: "2XX", Align: tview.AlignRight},
HeaderColumn{Name: "4XX/5XX", Align: tview.AlignRight},
HeaderColumn{Name: "REPORT"},
HeaderColumn{Name: "VALID", Wide: true},
HeaderColumn{Name: "AGE", Time: true},
func (Benchmark) Header(ns string) model1.Header {
return model1.Header{
model1.HeaderColumn{Name: "NAMESPACE"},
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: "REPORT"},
model1.HeaderColumn{Name: "VALID", Wide: true},
model1.HeaderColumn{Name: "AGE", Time: true},
}
}
// Render renders a K8s resource to screen.
func (b Benchmark) Render(o interface{}, ns string, r *Row) error {
func (b Benchmark) Render(o interface{}, ns string, r *model1.Row) error {
bench, ok := o.(BenchInfo)
if !ok {
return fmt.Errorf("no benchmarks available %T", o)
@ -71,7 +73,7 @@ func (b Benchmark) Render(o interface{}, ns string, r *Row) error {
}
r.ID = bench.Path
r.Fields = make(Fields, len(b.Header(ns)))
r.Fields = make(model1.Fields, len(b.Header(ns)))
if err := b.initRow(r.Fields, bench.File); err != nil {
return err
}
@ -82,7 +84,7 @@ func (b Benchmark) Render(o interface{}, ns string, r *Row) error {
}
// Happy returns true if resource is happy, false otherwise.
func (Benchmark) diagnose(ns string, ff Fields) error {
func (Benchmark) diagnose(ns string, ff model1.Fields) error {
statusCol := 3
if !client.IsAllNamespaces(ns) {
statusCol--
@ -109,7 +111,7 @@ func (Benchmark) readFile(file string) (string, error) {
return string(data), nil
}
func (b Benchmark) initRow(row Fields, f os.FileInfo) error {
func (b Benchmark) initRow(row model1.Fields, f os.FileInfo) error {
tokens := strings.Split(f.Name(), "_")
if len(tokens) < 2 {
return fmt.Errorf("invalid file name %s", f.Name())
@ -122,7 +124,7 @@ func (b Benchmark) initRow(row Fields, f os.FileInfo) error {
return nil
}
func (b Benchmark) augmentRow(fields Fields, data string) {
func (b Benchmark) augmentRow(fields model1.Fields, data string) {
if len(data) == 0 {
return
}

View File

@ -7,6 +7,7 @@ import (
"os"
"testing"
"github.com/derailed/k9s/internal/model1"
"github.com/rs/zerolog"
"github.com/stretchr/testify/assert"
)
@ -18,23 +19,23 @@ func init() {
func TestAugmentRow(t *testing.T) {
uu := map[string]struct {
file string
e Fields
e model1.Fields
}{
"cool": {
"testdata/b1.txt",
Fields{"pass", "3.3544", "29.8116", "100", "0"},
model1.Fields{"pass", "3.3544", "29.8116", "100", "0"},
},
"2XX": {
"testdata/b4.txt",
Fields{"pass", "3.3544", "29.8116", "160", "0"},
model1.Fields{"pass", "3.3544", "29.8116", "160", "0"},
},
"4XX/5XX": {
"testdata/b2.txt",
Fields{"pass", "3.3544", "29.8116", "100", "12"},
model1.Fields{"pass", "3.3544", "29.8116", "100", "12"},
},
"toast": {
"testdata/b3.txt",
Fields{"fail", "2.3688", "35.4606", "0", "0"},
model1.Fields{"fail", "2.3688", "35.4606", "0", "0"},
},
}
@ -44,7 +45,7 @@ func TestAugmentRow(t *testing.T) {
data, err := os.ReadFile(u.file)
assert.Nil(t, err)
fields := make(Fields, 8)
fields := make(model1.Fields, 8)
b := Benchmark{}
b.augmentRow(fields, string(data))
assert.Equal(t, u.e, fields[2:7])

56
internal/render/cm.go Normal file
View File

@ -0,0 +1,56 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright Authors of K9s
package render
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"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
)
// ConfigMap renders a K8s ConfigMap to screen.
type ConfigMap struct {
Base
}
// Header returns a header rbw.
func (ConfigMap) Header(string) 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},
}
}
// Render renders a K8s resource to screen.
func (n ConfigMap) Render(o interface{}, _ string, r *model1.Row) error {
raw, ok := o.(*unstructured.Unstructured)
if !ok {
return fmt.Errorf("expected ConfigMap, but got %T", o)
}
var cm v1.ConfigMap
err := runtime.DefaultUnstructuredConverter.FromUnstructured(raw.Object, &cm)
if err != nil {
return err
}
r.ID = client.FQN(cm.Namespace, cm.Name)
r.Fields = model1.Fields{
cm.Namespace,
cm.Name,
strconv.Itoa(len(cm.Data)),
"",
ToAge(cm.GetCreationTimestamp()),
}
return nil
}

View File

@ -1,66 +0,0 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright Authors of K9s
package render_test
import (
"testing"
"github.com/derailed/k9s/internal/render"
"github.com/derailed/tcell/v2"
"github.com/stretchr/testify/assert"
)
func TestDefaultColorer(t *testing.T) {
uu := map[string]struct {
re render.RowEvent
e tcell.Color
}{
"add": {
render.RowEvent{
Kind: render.EventAdd,
},
render.AddColor,
},
"update": {
render.RowEvent{
Kind: render.EventUpdate,
},
render.ModColor,
},
"delete": {
render.RowEvent{
Kind: render.EventDelete,
},
render.KillColor,
},
"no-change": {
render.RowEvent{
Kind: render.EventUnchanged,
},
render.StdColor,
},
"invalid": {
render.RowEvent{
Kind: render.EventUnchanged,
Row: render.Row{
Fields: render.Fields{"", "", "blah"},
},
},
render.ErrColor,
},
}
h := render.Header{
render.HeaderColumn{Name: "A"},
render.HeaderColumn{Name: "B"},
render.HeaderColumn{Name: "VALID"},
}
for k := range uu {
u := uu[k]
t.Run(k, func(t *testing.T) {
assert.Equal(t, u.e, render.DefaultColorer("", h, u.re))
})
}
}

View File

@ -10,6 +10,7 @@ import (
"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"
@ -43,60 +44,58 @@ type Container struct {
}
// ColorerFunc colors a resource row.
func (c Container) ColorerFunc() ColorerFunc {
return func(ns string, h Header, re RowEvent) tcell.Color {
if !Happy(ns, h, re.Row) {
return ErrColor
}
func (c Container) ColorerFunc() model1.ColorerFunc {
return func(ns string, h model1.Header, re *model1.RowEvent) tcell.Color {
c := model1.DefaultColorer(ns, h, re)
stateCol := h.IndexOf("STATE", true)
if stateCol == -1 {
return DefaultColorer(ns, h, re)
idx, ok := h.IndexOf("STATE", true)
if !ok {
return c
}
switch strings.TrimSpace(re.Row.Fields[stateCol]) {
switch strings.TrimSpace(re.Row.Fields[idx]) {
case Pending:
return PendingColor
return model1.PendingColor
case ContainerCreating, PodInitializing:
return AddColor
return model1.AddColor
case Terminating, Initialized:
return HighlightColor
return model1.HighlightColor
case Completed:
return CompletedColor
return model1.CompletedColor
case Running:
return DefaultColorer(ns, h, re)
return c
default:
return ErrColor
return model1.ErrColor
}
}
}
// Header returns a header row.
func (Container) Header(ns string) Header {
return Header{
HeaderColumn{Name: "NAME"},
HeaderColumn{Name: "PF"},
HeaderColumn{Name: "IMAGE"},
HeaderColumn{Name: "READY"},
HeaderColumn{Name: "STATE"},
HeaderColumn{Name: "INIT"},
HeaderColumn{Name: "RESTARTS", Align: tview.AlignRight},
HeaderColumn{Name: "PROBES(L:R)"},
HeaderColumn{Name: "CPU", Align: tview.AlignRight, MX: true},
HeaderColumn{Name: "MEM", Align: tview.AlignRight, MX: true},
HeaderColumn{Name: "CPU/R:L", Align: tview.AlignRight},
HeaderColumn{Name: "MEM/R:L", Align: tview.AlignRight},
HeaderColumn{Name: "%CPU/R", Align: tview.AlignRight, MX: true},
HeaderColumn{Name: "%CPU/L", Align: tview.AlignRight, MX: true},
HeaderColumn{Name: "%MEM/R", Align: tview.AlignRight, MX: true},
HeaderColumn{Name: "%MEM/L", Align: tview.AlignRight, MX: true},
HeaderColumn{Name: "PORTS"},
HeaderColumn{Name: "VALID", Wide: true},
HeaderColumn{Name: "AGE", Time: true},
func (Container) Header(ns string) model1.Header {
return model1.Header{
model1.HeaderColumn{Name: "NAME"},
model1.HeaderColumn{Name: "PF"},
model1.HeaderColumn{Name: "IMAGE"},
model1.HeaderColumn{Name: "READY"},
model1.HeaderColumn{Name: "STATE"},
model1.HeaderColumn{Name: "INIT"},
model1.HeaderColumn{Name: "RESTARTS", Align: tview.AlignRight},
model1.HeaderColumn{Name: "PROBES(L:R)"},
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: "PORTS"},
model1.HeaderColumn{Name: "VALID", Wide: true},
model1.HeaderColumn{Name: "AGE", Time: true},
}
}
// Render renders a K8s resource to screen.
func (c Container) Render(o interface{}, name string, r *Row) error {
func (c Container) Render(o interface{}, name string, r *model1.Row) error {
co, ok := o.(ContainerRes)
if !ok {
return fmt.Errorf("expected ContainerRes, but got %T", o)
@ -109,7 +108,7 @@ func (c Container) Render(o interface{}, name string, r *Row) error {
}
r.ID = co.Container.Name
r.Fields = Fields{
r.Fields = model1.Fields{
co.Container.Name,
"●",
co.Container.Image,

View File

@ -8,6 +8,7 @@ import (
"testing"
"time"
"github.com/derailed/k9s/internal/model1"
"github.com/derailed/k9s/internal/render"
"github.com/stretchr/testify/assert"
v1 "k8s.io/api/core/v1"
@ -26,10 +27,10 @@ func TestContainer(t *testing.T) {
IsInit: false,
Age: makeAge(),
}
var r render.Row
var r model1.Row
assert.Nil(t, c.Render(cres, "blee", &r))
assert.Equal(t, "fred", r.ID)
assert.Equal(t, render.Fields{
assert.Equal(t, model1.Fields{
"fred",
"●",
"img",
@ -63,7 +64,7 @@ func BenchmarkContainerRender(b *testing.B) {
IsInit: false,
Age: makeAge(),
}
var r render.Row
var r model1.Row
b.ReportAllocs()
b.ResetTimer()

View File

@ -7,6 +7,7 @@ import (
"fmt"
"strings"
"github.com/derailed/k9s/internal/model1"
"github.com/derailed/tcell/v2"
"github.com/rs/zerolog/log"
"k8s.io/apimachinery/pkg/runtime"
@ -20,11 +21,11 @@ type Context struct {
}
// ColorerFunc colors a resource row.
func (Context) ColorerFunc() ColorerFunc {
return func(ns string, h Header, r RowEvent) tcell.Color {
c := DefaultColorer(ns, h, r)
func (Context) ColorerFunc() model1.ColorerFunc {
return func(ns string, h model1.Header, r *model1.RowEvent) tcell.Color {
c := model1.DefaultColorer(ns, h, r)
if strings.Contains(strings.TrimSpace(r.Row.Fields[0]), "*") {
return HighlightColor
return model1.HighlightColor
}
return c
@ -32,17 +33,17 @@ func (Context) ColorerFunc() ColorerFunc {
}
// Header returns a header row.
func (Context) Header(ns string) Header {
return Header{
HeaderColumn{Name: "NAME"},
HeaderColumn{Name: "CLUSTER"},
HeaderColumn{Name: "AUTHINFO"},
HeaderColumn{Name: "NAMESPACE"},
func (Context) Header(ns string) model1.Header {
return model1.Header{
model1.HeaderColumn{Name: "NAME"},
model1.HeaderColumn{Name: "CLUSTER"},
model1.HeaderColumn{Name: "AUTHINFO"},
model1.HeaderColumn{Name: "NAMESPACE"},
}
}
// Render renders a K8s resource to screen.
func (c Context) Render(o interface{}, _ string, r *Row) error {
func (c Context) Render(o interface{}, _ string, r *model1.Row) error {
ctx, ok := o.(*NamedContext)
if !ok {
return fmt.Errorf("expected *NamedContext, but got %T", o)
@ -54,7 +55,7 @@ func (c Context) Render(o interface{}, _ string, r *Row) error {
}
r.ID = ctx.Name
r.Fields = Fields{
r.Fields = model1.Fields{
name,
ctx.Context.Cluster,
ctx.Context.AuthInfo,

View File

@ -6,6 +6,7 @@ package render_test
import (
"testing"
"github.com/derailed/k9s/internal/model1"
"github.com/derailed/k9s/internal/render"
"github.com/stretchr/testify/assert"
"k8s.io/client-go/tools/clientcmd/api"
@ -20,7 +21,7 @@ func TestContextHeader(t *testing.T) {
func TestContextRender(t *testing.T) {
uu := map[string]struct {
ctx *render.NamedContext
e render.Row
e model1.Row
}{
"active": {
ctx: &render.NamedContext{
@ -33,9 +34,9 @@ func TestContextRender(t *testing.T) {
},
Config: &config{},
},
e: render.Row{
e: model1.Row{
ID: "c1",
Fields: render.Fields{"c1", "c1", "u1", "ns1"},
Fields: model1.Fields{"c1", "c1", "u1", "ns1"},
},
},
}
@ -44,7 +45,7 @@ func TestContextRender(t *testing.T) {
for k := range uu {
uc := uu[k]
t.Run(k, func(t *testing.T) {
row := render.NewRow(4)
row := model1.NewRow(4)
err := r.Render(uc.ctx, "", &row)
assert.Nil(t, err)

View File

@ -7,6 +7,7 @@ import (
"fmt"
"github.com/derailed/k9s/internal/client"
"github.com/derailed/k9s/internal/model1"
rbacv1 "k8s.io/api/rbac/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
@ -18,16 +19,16 @@ type ClusterRole struct {
}
// Header returns a header rbw.
func (ClusterRole) Header(string) Header {
return Header{
HeaderColumn{Name: "NAME"},
HeaderColumn{Name: "LABELS", Wide: true},
HeaderColumn{Name: "AGE", Time: true},
func (ClusterRole) Header(string) model1.Header {
return model1.Header{
model1.HeaderColumn{Name: "NAME"},
model1.HeaderColumn{Name: "LABELS", Wide: true},
model1.HeaderColumn{Name: "AGE", Time: true},
}
}
// Render renders a K8s resource to screen.
func (ClusterRole) Render(o interface{}, ns string, r *Row) error {
func (ClusterRole) Render(o interface{}, ns string, r *model1.Row) error {
raw, ok := o.(*unstructured.Unstructured)
if !ok {
return fmt.Errorf("expecting clusterrole, but got %T", o)
@ -39,7 +40,7 @@ func (ClusterRole) Render(o interface{}, ns string, r *Row) error {
}
r.ID = client.FQN("-", cr.ObjectMeta.Name)
r.Fields = Fields{
r.Fields = model1.Fields{
cr.Name,
mapToStr(cr.Labels),
ToAge(cr.GetCreationTimestamp()),

View File

@ -6,15 +6,16 @@ package render_test
import (
"testing"
"github.com/derailed/k9s/internal/model1"
"github.com/derailed/k9s/internal/render"
"github.com/stretchr/testify/assert"
)
func TestClusterRoleRender(t *testing.T) {
c := render.ClusterRole{}
r := render.NewRow(2)
r := model1.NewRow(2)
assert.NoError(t, c.Render(load(t, "cr"), "-", &r))
assert.Equal(t, "-/blee", r.ID)
assert.Equal(t, render.Fields{"blee"}, r.Fields[:1])
assert.Equal(t, model1.Fields{"blee"}, r.Fields[:1])
}

View File

@ -7,6 +7,7 @@ import (
"fmt"
"github.com/derailed/k9s/internal/client"
"github.com/derailed/k9s/internal/model1"
rbacv1 "k8s.io/api/rbac/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
@ -18,19 +19,19 @@ type ClusterRoleBinding struct {
}
// Header returns a header rbw.
func (ClusterRoleBinding) Header(string) Header {
return Header{
HeaderColumn{Name: "NAME"},
HeaderColumn{Name: "CLUSTERROLE"},
HeaderColumn{Name: "SUBJECT-KIND"},
HeaderColumn{Name: "SUBJECTS"},
HeaderColumn{Name: "LABELS", Wide: true},
HeaderColumn{Name: "AGE", Time: true},
func (ClusterRoleBinding) Header(string) 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},
}
}
// Render renders a K8s resource to screen.
func (ClusterRoleBinding) Render(o interface{}, ns string, r *Row) error {
func (ClusterRoleBinding) Render(o interface{}, ns string, r *model1.Row) error {
raw, ok := o.(*unstructured.Unstructured)
if !ok {
return fmt.Errorf("expected ClusterRoleBinding, but got %T", o)
@ -44,7 +45,7 @@ func (ClusterRoleBinding) Render(o interface{}, ns string, r *Row) error {
kind, ss := renderSubjects(crb.Subjects)
r.ID = client.FQN("-", crb.ObjectMeta.Name)
r.Fields = Fields{
r.Fields = model1.Fields{
crb.Name,
crb.RoleRef.Name,
kind,

View File

@ -6,15 +6,16 @@ package render_test
import (
"testing"
"github.com/derailed/k9s/internal/model1"
"github.com/derailed/k9s/internal/render"
"github.com/stretchr/testify/assert"
)
func TestClusterRoleBindingRender(t *testing.T) {
c := render.ClusterRoleBinding{}
r := render.NewRow(5)
r := model1.NewRow(5)
assert.NoError(t, c.Render(load(t, "crb"), "-", &r))
assert.Equal(t, "-/blee", r.ID)
assert.Equal(t, render.Fields{"blee", "blee", "User", "fernand"}, r.Fields[:4])
assert.Equal(t, model1.Fields{"blee", "blee", "User", "fernand"}, r.Fields[:4])
}

View File

@ -9,6 +9,7 @@ import (
"strings"
"github.com/derailed/k9s/internal/client"
"github.com/derailed/k9s/internal/model1"
"github.com/rs/zerolog/log"
v1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
@ -21,18 +22,22 @@ type CustomResourceDefinition struct {
}
// Header returns a header rbw.
func (CustomResourceDefinition) Header(string) Header {
return Header{
HeaderColumn{Name: "NAME"},
HeaderColumn{Name: "VERSIONS"},
HeaderColumn{Name: "LABELS", Wide: true},
HeaderColumn{Name: "VALID", Wide: true},
HeaderColumn{Name: "AGE", Time: true},
func (CustomResourceDefinition) Header(string) 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},
}
}
// Render renders a K8s resource to screen.
func (c CustomResourceDefinition) Render(o interface{}, ns string, r *Row) error {
func (c CustomResourceDefinition) Render(o interface{}, ns string, r *model1.Row) error {
raw, ok := o.(*unstructured.Unstructured)
if !ok {
return fmt.Errorf("expected CustomResourceDefinition, but got %T", o)
@ -44,7 +49,7 @@ func (c CustomResourceDefinition) Render(o interface{}, ns string, r *Row) error
return err
}
versions := make([]string, 0, 3)
versions := make([]string, 0, len(crd.Spec.Versions))
for _, v := range crd.Spec.Versions {
if v.Served {
n := v.Name
@ -55,15 +60,19 @@ func (c CustomResourceDefinition) Render(o interface{}, ns string, r *Row) error
}
}
if len(versions) == 0 {
log.Warn().Msgf("unable to assert CRD versions for %s", crd.GetName())
log.Warn().Msgf("unable to assert CRD versions for %s", crd.Name)
}
r.ID = client.FQN(client.ClusterScope, crd.GetName())
r.Fields = Fields{
crd.GetName(),
r.ID = client.MetaFQN(crd.ObjectMeta)
r.Fields = model1.Fields{
crd.Spec.Names.Plural,
crd.Spec.Group,
crd.Spec.Names.Kind,
naStrings(versions),
string(crd.Spec.Scope),
naStrings(crd.Spec.Names.ShortNames),
mapToIfc(crd.GetLabels()),
AsStatus(c.diagnose(crd.GetName(), crd.Spec.Versions)),
AsStatus(c.diagnose(crd.Name, crd.Spec.Versions)),
ToAge(crd.GetCreationTimestamp()),
}

View File

@ -6,15 +6,17 @@ package render_test
import (
"testing"
"github.com/derailed/k9s/internal/model1"
"github.com/derailed/k9s/internal/render"
"github.com/stretchr/testify/assert"
)
func TestCustomResourceDefinitionRender(t *testing.T) {
c := render.CustomResourceDefinition{}
r := render.NewRow(2)
r := model1.NewRow(2)
assert.NoError(t, c.Render(load(t, "crd"), "", &r))
assert.Equal(t, "-/adapters.config.istio.io", r.ID)
assert.Equal(t, render.Fields{"adapters.config.istio.io"}, r.Fields[:1])
assert.Equal(t, "adapters", r.Fields[0])
assert.Equal(t, "config.istio.io", r.Fields[1])
}

View File

@ -9,6 +9,7 @@ import (
"strings"
"github.com/derailed/k9s/internal/client"
"github.com/derailed/k9s/internal/model1"
batchv1 "k8s.io/api/batch/v1"
v1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
@ -21,29 +22,26 @@ type CronJob struct {
}
// Header returns a header row.
func (CronJob) Header(ns string) Header {
h := Header{
HeaderColumn{Name: "NAMESPACE"},
HeaderColumn{Name: "NAME"},
HeaderColumn{Name: "VS", VS: true},
HeaderColumn{Name: "SCHEDULE"},
HeaderColumn{Name: "SUSPEND"},
HeaderColumn{Name: "ACTIVE"},
HeaderColumn{Name: "LAST_SCHEDULE", Time: true},
HeaderColumn{Name: "SELECTOR", Wide: true},
HeaderColumn{Name: "CONTAINERS", Wide: true},
HeaderColumn{Name: "IMAGES", Wide: true},
HeaderColumn{Name: "LABELS", Wide: true},
HeaderColumn{Name: "VALID", Wide: true},
HeaderColumn{Name: "AGE", Time: true},
func (CronJob) Header(ns string) model1.Header {
return model1.Header{
model1.HeaderColumn{Name: "NAMESPACE"},
model1.HeaderColumn{Name: "NAME"},
model1.HeaderColumn{Name: "VS", 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},
}
return h
}
// Render renders a K8s resource to screen.
func (c CronJob) Render(o interface{}, ns string, r *Row) error {
func (c CronJob) Render(o interface{}, ns string, r *model1.Row) error {
raw, ok := o.(*unstructured.Unstructured)
if !ok {
return fmt.Errorf("expected CronJob, but got %T", o)
@ -60,7 +58,7 @@ func (c CronJob) Render(o interface{}, ns string, r *Row) error {
}
r.ID = client.MetaFQN(cj.ObjectMeta)
r.Fields = Fields{
r.Fields = model1.Fields{
cj.Namespace,
cj.Name,
computeVulScore(cj.ObjectMeta, &cj.Spec.JobTemplate.Spec.Template.Spec),

View File

@ -6,15 +6,16 @@ package render_test
import (
"testing"
"github.com/derailed/k9s/internal/model1"
"github.com/derailed/k9s/internal/render"
"github.com/stretchr/testify/assert"
)
func TestCronJobRender(t *testing.T) {
c := render.CronJob{}
r := render.NewRow(6)
r := model1.NewRow(6)
assert.NoError(t, c.Render(load(t, "cj"), "", &r))
assert.Equal(t, "default/hello", r.ID)
assert.Equal(t, render.Fields{"default", "hello", "0", "*/1 * * * *", "false", "0"}, r.Fields[:6])
assert.Equal(t, model1.Fields{"default", "hello", "0", "*/1 * * * *", "false", "0"}, r.Fields[:6])
}

View File

@ -1,266 +0,0 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright Authors of K9s
package render_test
import (
"testing"
"github.com/derailed/k9s/internal/render"
"github.com/stretchr/testify/assert"
)
func TestDeltaLabelize(t *testing.T) {
uu := map[string]struct {
o render.Row
n render.Row
e render.DeltaRow
}{
"same": {
o: render.Row{
Fields: render.Fields{"a", "b", "blee=fred,doh=zorg"},
},
n: render.Row{
Fields: render.Fields{"a", "b", "blee=fred1,doh=zorg"},
},
e: render.DeltaRow{"", "", "fred", "zorg"},
},
}
hh := render.Header{
render.HeaderColumn{Name: "A"},
render.HeaderColumn{Name: "B"},
render.HeaderColumn{Name: "C"},
}
for k := range uu {
u := uu[k]
t.Run(k, func(t *testing.T) {
d := render.NewDeltaRow(u.o, u.n, hh)
d = d.Labelize([]int{0, 1}, 2)
assert.Equal(t, u.e, d)
})
}
}
func TestDeltaCustomize(t *testing.T) {
uu := map[string]struct {
r1, r2 render.Row
cols []int
e render.DeltaRow
}{
"same": {
r1: render.Row{
Fields: render.Fields{"a", "b", "c"},
},
r2: render.Row{
Fields: render.Fields{"a", "b", "c"},
},
cols: []int{0, 1, 2},
e: render.DeltaRow{"", "", ""},
},
"empty": {
r1: render.Row{
Fields: render.Fields{"a", "b", "c"},
},
r2: render.Row{
Fields: render.Fields{"a", "b", "c"},
},
e: render.DeltaRow{},
},
"diff-full": {
r1: render.Row{
Fields: render.Fields{"a", "b", "c"},
},
r2: render.Row{
Fields: render.Fields{"a1", "b1", "c1"},
},
cols: []int{0, 1, 2},
e: render.DeltaRow{"a", "b", "c"},
},
"diff-reverse": {
r1: render.Row{
Fields: render.Fields{"a", "b", "c"},
},
r2: render.Row{
Fields: render.Fields{"a1", "b1", "c1"},
},
cols: []int{2, 1, 0},
e: render.DeltaRow{"c", "b", "a"},
},
"diff-skip": {
r1: render.Row{
Fields: render.Fields{"a", "b", "c"},
},
r2: render.Row{
Fields: render.Fields{"a1", "b1", "c1"},
},
cols: []int{2, 0},
e: render.DeltaRow{"c", "a"},
},
"diff-missing": {
r1: render.Row{
Fields: render.Fields{"a", "b", "c"},
},
r2: render.Row{
Fields: render.Fields{"a1", "b1", "c1"},
},
cols: []int{2, 10, 0},
e: render.DeltaRow{"c", "", "a"},
},
"diff-negative": {
r1: render.Row{
Fields: render.Fields{"a", "b", "c"},
},
r2: render.Row{
Fields: render.Fields{"a1", "b1", "c1"},
},
cols: []int{2, -1, 0},
e: render.DeltaRow{"c", "", "a"},
},
}
hh := render.Header{
render.HeaderColumn{Name: "A"},
render.HeaderColumn{Name: "B"},
render.HeaderColumn{Name: "C"},
}
for k := range uu {
u := uu[k]
t.Run(k, func(t *testing.T) {
d := render.NewDeltaRow(u.r1, u.r2, hh)
out := make(render.DeltaRow, len(u.cols))
d.Customize(u.cols, out)
assert.Equal(t, u.e, out)
})
}
}
func TestDeltaNew(t *testing.T) {
uu := map[string]struct {
o render.Row
n render.Row
blank bool
e render.DeltaRow
}{
"same": {
o: render.Row{
Fields: render.Fields{"a", "b", "c"},
},
n: render.Row{
Fields: render.Fields{"a", "b", "c"},
},
blank: true,
e: render.DeltaRow{"", "", ""},
},
"diff": {
o: render.Row{
Fields: render.Fields{"a1", "b", "c"},
},
n: render.Row{
Fields: render.Fields{"a", "b", "c"},
},
e: render.DeltaRow{"a1", "", ""},
},
"diff2": {
o: render.Row{
Fields: render.Fields{"a", "b", "c"},
},
n: render.Row{
Fields: render.Fields{"a", "b1", "c"},
},
e: render.DeltaRow{"", "b", ""},
},
"diffLast": {
o: render.Row{
Fields: render.Fields{"a", "b", "c"},
},
n: render.Row{
Fields: render.Fields{"a", "b", "c1"},
},
e: render.DeltaRow{"", "", "c"},
},
}
hh := render.Header{
render.HeaderColumn{Name: "A"},
render.HeaderColumn{Name: "B"},
render.HeaderColumn{Name: "C"},
}
for k := range uu {
u := uu[k]
t.Run(k, func(t *testing.T) {
d := render.NewDeltaRow(u.o, u.n, hh)
assert.Equal(t, u.e, d)
assert.Equal(t, u.blank, d.IsBlank())
})
}
}
func TestDeltaBlank(t *testing.T) {
uu := map[string]struct {
r render.DeltaRow
e bool
}{
"empty": {
r: render.DeltaRow{},
e: true,
},
"blank": {
r: render.DeltaRow{"", "", ""},
e: true,
},
"notblank": {
r: render.DeltaRow{"", "", "z"},
},
}
for k := range uu {
u := uu[k]
t.Run(k, func(t *testing.T) {
assert.Equal(t, u.e, u.r.IsBlank())
})
}
}
func TestDeltaDiff(t *testing.T) {
uu := map[string]struct {
d1, d2 render.DeltaRow
ageCol int
e bool
}{
"empty": {
d1: render.DeltaRow{"f1", "f2", "f3"},
ageCol: 2,
e: true,
},
"same": {
d1: render.DeltaRow{"f1", "f2", "f3"},
d2: render.DeltaRow{"f1", "f2", "f3"},
ageCol: -1,
},
"diff": {
d1: render.DeltaRow{"f1", "f2", "f3"},
d2: render.DeltaRow{"f1", "f2", "f13"},
ageCol: -1,
e: true,
},
"diff-age-first": {
d1: render.DeltaRow{"f1", "f2", "f3"},
d2: render.DeltaRow{"f1", "f2", "f13"},
ageCol: 0,
e: true,
},
"diff-age-last": {
d1: render.DeltaRow{"f1", "f2", "f3"},
d2: render.DeltaRow{"f1", "f2", "f13"},
ageCol: 2,
},
}
for k := range uu {
u := uu[k]
t.Run(k, func(t *testing.T) {
assert.Equal(t, u.e, u.d1.Diff(u.d2, u.ageCol))
})
}
}

View File

@ -7,6 +7,7 @@ import (
"fmt"
"os"
"github.com/derailed/k9s/internal/model1"
"github.com/derailed/tcell/v2"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
@ -21,22 +22,22 @@ func (Dir) IsGeneric() bool {
}
// ColorerFunc colors a resource row.
func (Dir) ColorerFunc() ColorerFunc {
return func(ns string, _ Header, re RowEvent) tcell.Color {
func (Dir) ColorerFunc() model1.ColorerFunc {
return func(ns string, _ model1.Header, re *model1.RowEvent) tcell.Color {
return tcell.ColorCadetBlue
}
}
// Header returns a header row.
func (Dir) Header(ns string) Header {
return Header{
HeaderColumn{Name: "NAME"},
func (Dir) Header(ns string) model1.Header {
return model1.Header{
model1.HeaderColumn{Name: "NAME"},
}
}
// Render renders a K8s resource to screen.
// BOZO!! Pass in a row with pre-alloc fields??
func (Dir) Render(o interface{}, ns string, r *Row) error {
func (Dir) Render(o interface{}, ns string, r *model1.Row) error {
d, ok := o.(DirRes)
if !ok {
return fmt.Errorf("expected DirRes, but got %T", o)

View File

@ -9,6 +9,7 @@ import (
"strings"
"github.com/derailed/k9s/internal/client"
"github.com/derailed/k9s/internal/model1"
"github.com/derailed/tcell/v2"
"github.com/derailed/tview"
appsv1 "k8s.io/api/apps/v1"
@ -22,20 +23,18 @@ type Deployment struct {
}
// ColorerFunc colors a resource row.
func (d Deployment) ColorerFunc() ColorerFunc {
return func(ns string, h Header, re RowEvent) tcell.Color {
c := DefaultColorer(ns, h, re)
if !Happy(ns, h, re.Row) {
return ErrColor
}
rdCol := h.IndexOf("READY", true)
if rdCol == -1 {
func (d Deployment) ColorerFunc() model1.ColorerFunc {
return func(ns string, h model1.Header, re *model1.RowEvent) tcell.Color {
c := model1.DefaultColorer(ns, h, re)
idx, ok := h.IndexOf("READY", true)
if !ok {
return c
}
ready := strings.TrimSpace(re.Row.Fields[rdCol])
ready := strings.TrimSpace(re.Row.Fields[idx])
tt := strings.Split(ready, "/")
if len(tt) == 2 && tt[1] == "0" {
return PendingColor
return model1.PendingColor
}
return c
@ -43,24 +42,22 @@ func (d Deployment) ColorerFunc() ColorerFunc {
}
// Header returns a header row.
func (Deployment) Header(ns string) Header {
h := Header{
HeaderColumn{Name: "NAMESPACE"},
HeaderColumn{Name: "NAME"},
HeaderColumn{Name: "VS", VS: true},
HeaderColumn{Name: "READY", Align: tview.AlignRight},
HeaderColumn{Name: "UP-TO-DATE", Align: tview.AlignRight},
HeaderColumn{Name: "AVAILABLE", Align: tview.AlignRight},
HeaderColumn{Name: "LABELS", Wide: true},
HeaderColumn{Name: "VALID", Wide: true},
HeaderColumn{Name: "AGE", Time: true},
func (Deployment) Header(ns string) 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},
}
return h
}
// Render renders a K8s resource to screen.
func (d Deployment) Render(o interface{}, ns string, r *Row) error {
func (d Deployment) Render(o interface{}, ns string, r *model1.Row) error {
raw, ok := o.(*unstructured.Unstructured)
if !ok {
return fmt.Errorf("expected Deployment, but got %T", o)
@ -73,7 +70,7 @@ func (d Deployment) Render(o interface{}, ns string, r *Row) error {
}
r.ID = client.MetaFQN(dp.ObjectMeta)
r.Fields = Fields{
r.Fields = model1.Fields{
dp.Namespace,
dp.Name,
computeVulScore(dp.ObjectMeta, &dp.Spec.Template.Spec),

View File

@ -6,22 +6,23 @@ package render_test
import (
"testing"
"github.com/derailed/k9s/internal/model1"
"github.com/derailed/k9s/internal/render"
"github.com/stretchr/testify/assert"
)
func TestDpRender(t *testing.T) {
c := render.Deployment{}
r := render.NewRow(7)
r := model1.NewRow(7)
assert.Nil(t, c.Render(load(t, "dp"), "", &r))
assert.Equal(t, "icx/icx-db", r.ID)
assert.Equal(t, render.Fields{"icx", "icx-db", "0", "1/1", "1", "1"}, r.Fields[:6])
assert.Equal(t, model1.Fields{"icx", "icx-db", "0", "1/1", "1", "1"}, r.Fields[:6])
}
func BenchmarkDpRender(b *testing.B) {
c := render.Deployment{}
r := render.NewRow(7)
r := model1.NewRow(7)
o := load(b, "dp")
b.ResetTimer()

View File

@ -8,6 +8,7 @@ import (
"strconv"
"github.com/derailed/k9s/internal/client"
"github.com/derailed/k9s/internal/model1"
"github.com/derailed/tview"
appsv1 "k8s.io/api/apps/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
@ -20,26 +21,24 @@ type DaemonSet struct {
}
// Header returns a header row.
func (DaemonSet) Header(ns string) Header {
h := Header{
HeaderColumn{Name: "NAMESPACE"},
HeaderColumn{Name: "NAME"},
HeaderColumn{Name: "VS", VS: true},
HeaderColumn{Name: "DESIRED", Align: tview.AlignRight},
HeaderColumn{Name: "CURRENT", Align: tview.AlignRight},
HeaderColumn{Name: "READY", Align: tview.AlignRight},
HeaderColumn{Name: "UP-TO-DATE", Align: tview.AlignRight},
HeaderColumn{Name: "AVAILABLE", Align: tview.AlignRight},
HeaderColumn{Name: "LABELS", Wide: true},
HeaderColumn{Name: "VALID", Wide: true},
HeaderColumn{Name: "AGE", Time: true},
func (DaemonSet) Header(ns string) 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},
}
return h
}
// Render renders a K8s resource to screen.
func (d DaemonSet) Render(o interface{}, ns string, r *Row) error {
func (d DaemonSet) Render(o interface{}, ns string, r *model1.Row) error {
raw, ok := o.(*unstructured.Unstructured)
if !ok {
return fmt.Errorf("expected DaemonSet, but got %T", o)
@ -51,7 +50,7 @@ func (d DaemonSet) Render(o interface{}, ns string, r *Row) error {
}
r.ID = client.MetaFQN(ds.ObjectMeta)
r.Fields = Fields{
r.Fields = model1.Fields{
ds.Namespace,
ds.Name,
computeVulScore(ds.ObjectMeta, &ds.Spec.Template.Spec),

View File

@ -6,15 +6,16 @@ package render_test
import (
"testing"
"github.com/derailed/k9s/internal/model1"
"github.com/derailed/k9s/internal/render"
"github.com/stretchr/testify/assert"
)
func TestDaemonSetRender(t *testing.T) {
c := render.DaemonSet{}
r := render.NewRow(9)
r := model1.NewRow(9)
assert.NoError(t, c.Render(load(t, "ds"), "", &r))
assert.Equal(t, "kube-system/fluentd-gcp-v3.2.0", r.ID)
assert.Equal(t, render.Fields{"kube-system", "fluentd-gcp-v3.2.0", "0", "2", "2", "2", "2", "2"}, r.Fields[:8])
assert.Equal(t, model1.Fields{"kube-system", "fluentd-gcp-v3.2.0", "0", "2", "2", "2", "2", "2"}, r.Fields[:8])
}

View File

@ -9,6 +9,7 @@ import (
"strings"
"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"
@ -20,17 +21,17 @@ type Endpoints struct {
}
// Header returns a header row.
func (Endpoints) Header(ns string) Header {
return Header{
HeaderColumn{Name: "NAMESPACE"},
HeaderColumn{Name: "NAME"},
HeaderColumn{Name: "ENDPOINTS"},
HeaderColumn{Name: "AGE", Time: true},
func (Endpoints) Header(ns string) model1.Header {
return model1.Header{
model1.HeaderColumn{Name: "NAMESPACE"},
model1.HeaderColumn{Name: "NAME"},
model1.HeaderColumn{Name: "ENDPOINTS"},
model1.HeaderColumn{Name: "AGE", Time: true},
}
}
// Render renders a K8s resource to screen.
func (e Endpoints) Render(o interface{}, ns string, r *Row) error {
func (e Endpoints) Render(o interface{}, ns string, r *model1.Row) error {
raw, ok := o.(*unstructured.Unstructured)
if !ok {
return fmt.Errorf("expected Endpoints, but got %T", o)
@ -42,8 +43,8 @@ func (e Endpoints) Render(o interface{}, ns string, r *Row) error {
}
r.ID = client.MetaFQN(ep.ObjectMeta)
r.Fields = make(Fields, 0, len(e.Header(ns)))
r.Fields = Fields{
r.Fields = make(model1.Fields, 0, len(e.Header(ns)))
r.Fields = model1.Fields{
ep.Namespace,
ep.Name,
missing(toEPs(ep.Subsets)),

View File

@ -6,15 +6,16 @@ package render_test
import (
"testing"
"github.com/derailed/k9s/internal/model1"
"github.com/derailed/k9s/internal/render"
"github.com/stretchr/testify/assert"
)
func TestEndpointsRender(t *testing.T) {
c := render.Endpoints{}
r := render.NewRow(4)
r := model1.NewRow(4)
assert.NoError(t, c.Render(load(t, "ep"), "", &r))
assert.Equal(t, "default/dictionary1", r.ID)
assert.Equal(t, render.Fields{"default", "dictionary1", "<none>"}, r.Fields[:3])
assert.Equal(t, model1.Fields{"default", "dictionary1", "<none>"}, r.Fields[:3])
}

View File

@ -8,6 +8,7 @@ import (
"strings"
"github.com/derailed/k9s/internal/client"
"github.com/derailed/k9s/internal/model1"
"github.com/derailed/tcell/v2"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
@ -22,14 +23,14 @@ func (*Event) IsGeneric() bool {
}
// ColorerFunc colors a resource row.
func (e *Event) ColorerFunc() ColorerFunc {
return func(ns string, h Header, re RowEvent) tcell.Color {
reasonCol := h.IndexOf("REASON", true)
if reasonCol >= 0 && strings.TrimSpace(re.Row.Fields[reasonCol]) == "Killing" {
return KillColor
func (e *Event) ColorerFunc() model1.ColorerFunc {
return func(ns string, h model1.Header, re *model1.RowEvent) tcell.Color {
idx, ok := h.IndexOf("REASON", true)
if ok && strings.TrimSpace(re.Row.Fields[idx]) == "Killing" {
return model1.KillColor
}
return DefaultColorer(ns, h, re)
return model1.DefaultColorer(ns, h, re)
}
}
@ -46,14 +47,14 @@ var wideCols = map[string]struct{}{
"MESSAGE": {},
}
func (e *Event) Header(ns string) Header {
func (e *Event) Header(ns string) model1.Header {
if e.table == nil {
return Header{}
return model1.Header{}
}
hh := make(Header, 0, len(e.table.ColumnDefinitions))
hh = append(hh, HeaderColumn{Name: "NAMESPACE"})
hh := make(model1.Header, 0, len(e.table.ColumnDefinitions))
hh = append(hh, model1.HeaderColumn{Name: "NAMESPACE"})
for _, h := range e.table.ColumnDefinitions {
header := HeaderColumn{Name: strings.ToUpper(h.Name)}
header := model1.HeaderColumn{Name: strings.ToUpper(h.Name)}
if _, ok := ageCols[header.Name]; ok {
header.Time = true
}
@ -67,7 +68,7 @@ func (e *Event) Header(ns string) Header {
}
// Render renders a K8s resource to screen.
func (e *Event) Render(o interface{}, ns string, r *Row) error {
func (e *Event) Render(o interface{}, ns string, r *model1.Row) error {
row, ok := o.(metav1.TableRow)
if !ok {
return fmt.Errorf("expecting a TableRow but got %T", o)
@ -81,7 +82,7 @@ func (e *Event) Render(o interface{}, ns string, r *Row) error {
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(Fields, 0, len(e.Header(ns)))
r.Fields = make(model1.Fields, 0, len(e.Header(ns)))
r.Fields = append(r.Fields, nns)
for _, o := range row.Cells {
if o == nil {

View File

@ -6,17 +6,17 @@ package render_test
// BOZO!!
// func TestEventRender(t *testing.T) {
// c := render.Event{}
// r := render.NewRow(7)
// r := model1.NewRow(7)
// c.Render(load(t, "ev"), "", &r)
// assert.Equal(t, "default/hello-1567197780-mn4mv.15bfce150bd764dd", r.ID)
// assert.Equal(t, render.Fields{"default", "pod:hello-1567197780-mn4mv", "Normal", "Pulled", "kubelet", "1", `Successfully pulled image "blang/busybox-bash"`}, r.Fields[:7])
// assert.Equal(t, model1.Fields{"default", "pod:hello-1567197780-mn4mv", "Normal", "Pulled", "kubelet", "1", `Successfully pulled image "blang/busybox-bash"`}, r.Fields[:7])
// }
// func BenchmarkEventRender(b *testing.B) {
// ev := load(b, "ev")
// var re render.Event
// r := render.NewRow(7)
// r := model1.NewRow(7)
// b.ResetTimer()
// b.ReportAllocs()

View File

@ -10,6 +10,7 @@ import (
"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"
)
@ -20,7 +21,7 @@ const ageTableCol = "Age"
type Generic struct {
Base
table *metav1.Table
header Header
header model1.Header
ageIndex int
}
@ -35,38 +36,38 @@ func (g *Generic) SetTable(ns string, t *metav1.Table) {
}
// ColorerFunc colors a resource row.
func (*Generic) ColorerFunc() ColorerFunc {
return DefaultColorer
func (*Generic) ColorerFunc() model1.ColorerFunc {
return model1.DefaultColorer
}
// Header returns a header row.
func (g *Generic) Header(ns string) Header {
func (g *Generic) Header(ns string) model1.Header {
if g.header != nil {
return g.header
}
if g.table == nil {
return Header{}
return model1.Header{}
}
h := make(Header, 0, len(g.table.ColumnDefinitions))
h := make(model1.Header, 0, len(g.table.ColumnDefinitions))
if !client.IsClusterScoped(ns) {
h = append(h, HeaderColumn{Name: "NAMESPACE"})
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, HeaderColumn{Name: strings.ToUpper(c.Name)})
h = append(h, model1.HeaderColumn{Name: strings.ToUpper(c.Name)})
}
if g.ageIndex > 0 {
h = append(h, HeaderColumn{Name: "AGE", Time: true})
h = append(h, model1.HeaderColumn{Name: "AGE", Time: true})
}
return h
}
// Render renders a K8s resource to screen.
func (g *Generic) Render(o interface{}, ns string, r *Row) error {
func (g *Generic) Render(o interface{}, ns string, r *model1.Row) error {
row, ok := o.(metav1.TableRow)
if !ok {
return fmt.Errorf("expecting a TableRow but got %T", o)
@ -80,7 +81,7 @@ func (g *Generic) Render(o interface{}, ns string, r *Row) error {
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(Fields, 0, len(g.Header(ns)))
r.Fields = make(model1.Fields, 0, len(g.Header(ns)))
if !client.IsClusterScoped(ns) {
r.Fields = append(r.Fields, nns)
}

Some files were not shown because too many files have changed in this diff Show More