Feature/refresh rate (#3517)

* refactor: change refreshRate type to float64 for improved precision

* test: update assertions for refreshRate to use assert.InDelta for precision

* refactor: enforce minimum refresh rate and update related tests

* refactor: change refresh rate type to float32 and update related logic

* refactor: update refresh rate validation and logging in GetRefreshRate method

* refactor: update logging keys for refresh rate validation in GetRefreshRate method
mine
Ümüt Özalp 2025-08-25 20:01:03 +02:00 committed by GitHub
parent e5212875a4
commit e99c735430
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 146 additions and 34 deletions

View File

@ -407,7 +407,7 @@ You can now override the context portForward default address configuration by se
bozo: bozo/gpu bozo: bozo/gpu
# The path to screen dump. Default: '%temp_dir%/k9s-screens-%username%' (k9s info) # The path to screen dump. Default: '%temp_dir%/k9s-screens-%username%' (k9s info)
screenDumpDir: /tmp/dumps screenDumpDir: /tmp/dumps
# Represents ui poll intervals in seconds. Default 2secs # Represents ui poll intervals in seconds. Default 2.0 secs. Minimum value is 2.0 - values below will be capped to the minimum.
refreshRate: 2 refreshRate: 2
# Overrides the default k8s api server requests timeout. Defaults 120s # Overrides the default k8s api server requests timeout. Defaults 120s
apiServerTimeout: 15s apiServerTimeout: 15s

View File

@ -116,7 +116,7 @@ func run(*cobra.Command, []string) error {
app.Config.SetActiveView(app.Config.K9s.DefaultView) app.Config.SetActiveView(app.Config.K9s.DefaultView)
} }
if err := app.Init(version, *k9sFlags.RefreshRate); err != nil { if err := app.Init(version, int(*k9sFlags.RefreshRate)); err != nil {
return err return err
} }
if err := app.Run(); err != nil { if err := app.Run(); err != nil {
@ -185,11 +185,11 @@ func parseLevel(level string) slog.Level {
func initK9sFlags() { func initK9sFlags() {
k9sFlags = config.NewFlags() k9sFlags = config.NewFlags()
rootCmd.Flags().IntVarP( rootCmd.Flags().Float32VarP(
k9sFlags.RefreshRate, k9sFlags.RefreshRate,
"refresh", "r", "refresh", "r",
config.DefaultRefreshRate, config.DefaultRefreshRate,
"Specify the default refresh rate as an integer (sec)", "Specify the default refresh rate as a float (sec)",
) )
rootCmd.Flags().StringVarP( rootCmd.Flags().StringVarP(
k9sFlags.LogLevel, k9sFlags.LogLevel,

View File

@ -544,7 +544,7 @@ func TestConfigLoad(t *testing.T) {
cfg := mock.NewMockConfig(t) cfg := mock.NewMockConfig(t)
require.NoError(t, cfg.Load("testdata/configs/k9s.yaml", true)) require.NoError(t, cfg.Load("testdata/configs/k9s.yaml", true))
assert.Equal(t, 2, cfg.K9s.RefreshRate) assert.InDelta(t, 2.0, cfg.K9s.RefreshRate, 0.001)
assert.Equal(t, int64(200), cfg.K9s.Logger.TailCount) assert.Equal(t, int64(200), cfg.K9s.Logger.TailCount)
assert.Equal(t, 2000, cfg.K9s.Logger.BufferSize) assert.Equal(t, 2000, cfg.K9s.Logger.BufferSize)
} }

View File

@ -5,7 +5,7 @@ package config
const ( const (
// DefaultRefreshRate represents the refresh interval. // DefaultRefreshRate represents the refresh interval.
DefaultRefreshRate = 2 // secs DefaultRefreshRate float32 = 2.0 // secs
// DefaultLogLevel represents the default log level. // DefaultLogLevel represents the default log level.
DefaultLogLevel = "info" DefaultLogLevel = "info"
@ -16,7 +16,7 @@ const (
// Flags represents K9s configuration flags. // Flags represents K9s configuration flags.
type Flags struct { type Flags struct {
RefreshRate *int RefreshRate *float32
LogLevel *string LogLevel *string
LogFile *string LogFile *string
Headless *bool Headless *bool
@ -33,7 +33,7 @@ type Flags struct {
// NewFlags returns new configuration flags. // NewFlags returns new configuration flags.
func NewFlags() *Flags { func NewFlags() *Flags {
return &Flags{ return &Flags{
RefreshRate: intPtr(DefaultRefreshRate), RefreshRate: float32Ptr(DefaultRefreshRate),
LogLevel: strPtr(DefaultLogLevel), LogLevel: strPtr(DefaultLogLevel),
LogFile: strPtr(AppLogFile), LogFile: strPtr(AppLogFile),
Headless: boolPtr(false), Headless: boolPtr(false),
@ -52,8 +52,8 @@ func boolPtr(b bool) *bool {
return &b return &b
} }
func intPtr(i int) *int { func float32Ptr(f float32) *float32 {
return &i return &f
} }
func strPtr(s string) *string { func strPtr(s string) *string {

View File

@ -15,7 +15,7 @@ func TestNewFlags(t *testing.T) {
config.AppLogFile = "/tmp/k9s-test/k9s.log" config.AppLogFile = "/tmp/k9s-test/k9s.log"
f := config.NewFlags() f := config.NewFlags()
assert.Equal(t, 2, *f.RefreshRate) assert.InDelta(t, 2.0, *f.RefreshRate, 0.001)
assert.Equal(t, "info", *f.LogLevel) assert.Equal(t, "info", *f.LogLevel)
assert.Equal(t, "/tmp/k9s-test/k9s.log", *f.LogFile) assert.Equal(t, "/tmp/k9s-test/k9s.log", *f.LogFile)
assert.Equal(t, config.AppDumpsDir, *f.ScreenDumpDir) assert.Equal(t, config.AppDumpsDir, *f.ScreenDumpDir)

View File

@ -20,7 +20,7 @@
} }
}, },
"screenDumpDir": {"type": "string"}, "screenDumpDir": {"type": "string"},
"refreshRate": { "type": "integer" }, "refreshRate": { "type": "number" },
"apiServerTimeout": { "type": "string" }, "apiServerTimeout": { "type": "string" },
"maxConnRetry": { "type": "integer" }, "maxConnRetry": { "type": "integer" },
"readOnly": { "type": "boolean" }, "readOnly": { "type": "boolean" },

View File

@ -13,6 +13,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"sync" "sync"
"time"
"github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/client"
"github.com/derailed/k9s/internal/config/data" "github.com/derailed/k9s/internal/config/data"
@ -35,7 +36,7 @@ type K9s struct {
LiveViewAutoRefresh bool `json:"liveViewAutoRefresh" yaml:"liveViewAutoRefresh"` LiveViewAutoRefresh bool `json:"liveViewAutoRefresh" yaml:"liveViewAutoRefresh"`
GPUVendors gpuVendors `json:"gpuVendors" yaml:"gpuVendors"` GPUVendors gpuVendors `json:"gpuVendors" yaml:"gpuVendors"`
ScreenDumpDir string `json:"screenDumpDir" yaml:"screenDumpDir,omitempty"` ScreenDumpDir string `json:"screenDumpDir" yaml:"screenDumpDir,omitempty"`
RefreshRate int `json:"refreshRate" yaml:"refreshRate"` RefreshRate float32 `json:"refreshRate" yaml:"refreshRate"`
APIServerTimeout string `json:"apiServerTimeout" yaml:"apiServerTimeout"` APIServerTimeout string `json:"apiServerTimeout" yaml:"apiServerTimeout"`
MaxConnRetry int32 `json:"maxConnRetry" yaml:"maxConnRetry"` MaxConnRetry int32 `json:"maxConnRetry" yaml:"maxConnRetry"`
ReadOnly bool `json:"readOnly" yaml:"readOnly"` ReadOnly bool `json:"readOnly" yaml:"readOnly"`
@ -49,10 +50,11 @@ type K9s struct {
Logger Logger `json:"logger" yaml:"logger"` Logger Logger `json:"logger" yaml:"logger"`
Thresholds Threshold `json:"thresholds" yaml:"thresholds"` Thresholds Threshold `json:"thresholds" yaml:"thresholds"`
DefaultView string `json:"defaultView" yaml:"defaultView"` DefaultView string `json:"defaultView" yaml:"defaultView"`
manualRefreshRate int manualRefreshRate float32
manualReadOnly *bool manualReadOnly *bool
manualCommand *string manualCommand *string
manualScreenDumpDir *string manualScreenDumpDir *string
refreshRateWarned bool
dir *data.Dir dir *data.Dir
activeContextName string activeContextName string
activeConfig *data.Config activeConfig *data.Config
@ -314,7 +316,7 @@ func (k *K9s) Reload() error {
// Override overrides k9s config from cli args. // Override overrides k9s config from cli args.
func (k *K9s) Override(k9sFlags *Flags) { func (k *K9s) Override(k9sFlags *Flags) {
if k9sFlags.RefreshRate != nil && *k9sFlags.RefreshRate != DefaultRefreshRate { if k9sFlags.RefreshRate != nil && *k9sFlags.RefreshRate != DefaultRefreshRate {
k.manualRefreshRate = *k9sFlags.RefreshRate k.manualRefreshRate = float32(*k9sFlags.RefreshRate)
} }
k.UI.manualHeadless = k9sFlags.Headless k.UI.manualHeadless = k9sFlags.Headless
@ -369,12 +371,29 @@ func (k *K9s) IsSplashless() bool {
} }
// GetRefreshRate returns the current refresh rate. // GetRefreshRate returns the current refresh rate.
func (k *K9s) GetRefreshRate() int { func (k *K9s) GetRefreshRate() float32 {
if k.manualRefreshRate != 0 { k.mx.Lock()
return k.manualRefreshRate defer k.mx.Unlock()
}
return k.RefreshRate rate := k.RefreshRate
if k.manualRefreshRate != 0 {
rate = k.manualRefreshRate
}
if rate < DefaultRefreshRate {
if !k.refreshRateWarned {
slog.Warn("Refresh rate is below minimum, capping to minimum value",
slogs.Requested, float64(rate),
slogs.Minimum, float64(DefaultRefreshRate))
k.refreshRateWarned = true
}
return DefaultRefreshRate
}
return rate
}
// RefreshDuration returns the refresh rate as a time.Duration.
func (k *K9s) RefreshDuration() time.Duration {
return time.Duration(k.GetRefreshRate() * float32(time.Second))
} }
// IsReadOnly returns the readonly setting. // IsReadOnly returns the readonly setting.

View File

@ -19,14 +19,14 @@ func Test_k9sOverrides(t *testing.T) {
uu := map[string]struct { uu := map[string]struct {
k *K9s k *K9s
rate int rate float32
ro, hl, cl, sl, ll bool ro, hl, cl, sl, ll bool
}{ }{
"plain": { "plain": {
k: &K9s{ k: &K9s{
LiveViewAutoRefresh: false, LiveViewAutoRefresh: false,
ScreenDumpDir: "", ScreenDumpDir: "",
RefreshRate: 10, RefreshRate: 10.0,
MaxConnRetry: 0, MaxConnRetry: 0,
ReadOnly: false, ReadOnly: false,
NoExitOnCtrlC: false, NoExitOnCtrlC: false,
@ -34,13 +34,27 @@ func Test_k9sOverrides(t *testing.T) {
SkipLatestRevCheck: false, SkipLatestRevCheck: false,
DisablePodCounting: false, DisablePodCounting: false,
}, },
rate: 10, rate: 10.0,
},
"sub-second": {
k: &K9s{
LiveViewAutoRefresh: false,
ScreenDumpDir: "",
RefreshRate: 0.5,
MaxConnRetry: 0,
ReadOnly: false,
NoExitOnCtrlC: false,
UI: UI{},
SkipLatestRevCheck: false,
DisablePodCounting: false,
},
rate: 2.0, // minimum enforced
}, },
"set": { "set": {
k: &K9s{ k: &K9s{
LiveViewAutoRefresh: false, LiveViewAutoRefresh: false,
ScreenDumpDir: "", ScreenDumpDir: "",
RefreshRate: 10, RefreshRate: 10.0,
MaxConnRetry: 0, MaxConnRetry: 0,
ReadOnly: true, ReadOnly: true,
NoExitOnCtrlC: false, NoExitOnCtrlC: false,
@ -53,7 +67,7 @@ func Test_k9sOverrides(t *testing.T) {
SkipLatestRevCheck: false, SkipLatestRevCheck: false,
DisablePodCounting: false, DisablePodCounting: false,
}, },
rate: 10, rate: 10.0,
ro: true, ro: true,
hl: true, hl: true,
ll: true, ll: true,
@ -64,7 +78,7 @@ func Test_k9sOverrides(t *testing.T) {
k: &K9s{ k: &K9s{
LiveViewAutoRefresh: false, LiveViewAutoRefresh: false,
ScreenDumpDir: "", ScreenDumpDir: "",
RefreshRate: 10, RefreshRate: 10.0,
MaxConnRetry: 0, MaxConnRetry: 0,
ReadOnly: false, ReadOnly: false,
NoExitOnCtrlC: false, NoExitOnCtrlC: false,
@ -79,12 +93,12 @@ func Test_k9sOverrides(t *testing.T) {
}, },
SkipLatestRevCheck: false, SkipLatestRevCheck: false,
DisablePodCounting: false, DisablePodCounting: false,
manualRefreshRate: 100, manualRefreshRate: 100.0,
manualReadOnly: &trueVal, manualReadOnly: &trueVal,
manualCommand: &cmd, manualCommand: &cmd,
manualScreenDumpDir: &dir, manualScreenDumpDir: &dir,
}, },
rate: 100, rate: 100.0,
ro: true, ro: true,
hl: true, hl: true,
ll: true, ll: true,
@ -96,7 +110,7 @@ func Test_k9sOverrides(t *testing.T) {
for k := range uu { for k := range uu {
u := uu[k] u := uu[k]
t.Run(k, func(t *testing.T) { t.Run(k, func(t *testing.T) {
assert.Equal(t, u.rate, u.k.GetRefreshRate()) assert.InDelta(t, u.rate, u.k.GetRefreshRate(), 0.001)
assert.Equal(t, u.ro, u.k.IsReadOnly()) assert.Equal(t, u.ro, u.k.IsReadOnly())
assert.Equal(t, u.cl, u.k.IsCrumbsless()) assert.Equal(t, u.cl, u.k.IsCrumbsless())
assert.Equal(t, u.sl, u.k.IsSplashless()) assert.Equal(t, u.sl, u.k.IsSplashless())

View File

@ -0,0 +1,75 @@
package config
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gopkg.in/yaml.v3"
)
func TestRefreshRateBackwardCompatibility(t *testing.T) {
tests := map[string]struct {
yamlContent string
expected float32
}{
"integer_value": {
yamlContent: `refreshRate: 2`,
expected: 2.0,
},
"float_value": {
yamlContent: `refreshRate: 2.5`,
expected: 2.5,
},
}
for name, test := range tests {
t.Run(name, func(t *testing.T) {
var k K9s
err := yaml.Unmarshal([]byte(test.yamlContent), &k)
require.NoError(t, err)
assert.InDelta(t, test.expected, k.RefreshRate, 0.001)
})
}
}
func TestGetRefreshRateMinimum(t *testing.T) {
tests := map[string]struct {
refreshRate float32
manualRefreshRate float32
expected float32
}{
"below_minimum": {
refreshRate: 0.5,
expected: 2.0,
},
"at_minimum": {
refreshRate: 2.0,
expected: 2.0,
},
"above_minimum": {
refreshRate: 3.5,
expected: 3.5,
},
"manual_below_minimum": {
refreshRate: 3.0,
manualRefreshRate: 0.5,
expected: 2.0,
},
"manual_above_minimum": {
refreshRate: 2.0,
manualRefreshRate: 4.0,
expected: 4.0,
},
}
for name, test := range tests {
t.Run(name, func(t *testing.T) {
k := K9s{
RefreshRate: test.refreshRate,
manualRefreshRate: test.manualRefreshRate,
}
assert.InDelta(t, test.expected, k.GetRefreshRate(), 0.001)
})
}
}

View File

@ -221,4 +221,10 @@ const (
// Type tracks a type logger key. // Type tracks a type logger key.
Type = "type" Type = "type"
// Requested tracks a requested value logger key.
Requested = "requested"
// Minimum tracks a minimum value logger key.
Minimum = "minimum"
) )

View File

@ -112,7 +112,7 @@ func (b *Browser) Init(ctx context.Context) error {
if row == 0 && b.GetRowCount() > 0 { if row == 0 && b.GetRowCount() > 0 {
b.Select(1, 0) b.Select(1, 0)
} }
b.GetModel().SetRefreshRate(time.Duration(b.App().Config.K9s.GetRefreshRate()) * time.Second) b.GetModel().SetRefreshRate(b.App().Config.K9s.RefreshDuration())
b.CmdBuff().SetSuggestionFn(b.suggestFilter()) b.CmdBuff().SetSuggestionFn(b.suggestFilter())

View File

@ -8,7 +8,6 @@ import (
"log/slog" "log/slog"
"path/filepath" "path/filepath"
"strings" "strings"
"time"
"github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal"
"github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/client"
@ -60,7 +59,7 @@ func (t *Table) Init(ctx context.Context) (err error) {
} }
t.SetInputCapture(t.keyboard) t.SetInputCapture(t.keyboard)
t.bindKeys() t.bindKeys()
t.GetModel().SetRefreshRate(time.Duration(t.app.Config.K9s.GetRefreshRate()) * time.Second) t.GetModel().SetRefreshRate(t.app.Config.K9s.RefreshDuration())
t.CmdBuff().AddListener(t) t.CmdBuff().AddListener(t)
return nil return nil

View File

@ -9,7 +9,6 @@ import (
"log/slog" "log/slog"
"regexp" "regexp"
"strings" "strings"
"time"
"github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal"
"github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/client"
@ -87,7 +86,7 @@ func (x *Xray) Init(ctx context.Context) error {
x.SetGraphicsColor(x.app.Styles.Xray().GraphicColor.Color()) x.SetGraphicsColor(x.app.Styles.Xray().GraphicColor.Color())
x.SetTitle(fmt.Sprintf(" %s-%s ", xrayTitle, cases.Title(language.Und, cases.NoLower).String(x.gvr.R()))) x.SetTitle(fmt.Sprintf(" %s-%s ", xrayTitle, cases.Title(language.Und, cases.NoLower).String(x.gvr.R())))
x.model.SetRefreshRate(time.Duration(x.app.Config.K9s.GetRefreshRate()) * time.Second) x.model.SetRefreshRate(x.app.Config.K9s.RefreshDuration())
x.model.SetNamespace(client.CleanseNamespace(x.app.Config.ActiveNamespace())) x.model.SetNamespace(client.CleanseNamespace(x.app.Config.ActiveNamespace()))
x.model.AddListener(x) x.model.AddListener(x)