Rel v0.50.7 (#3436)

* fix #3406 - update and clean history navigation

* fix #3383 - cronjob auth fix

* clean up and updates

* update prompt indicator to diff cmd vs filter

* fix #3398 - dialog focus update

* update deps

* fix #3435 - noexit on ctrl-c

* fix #3412 - toggle decode

* fix #3424 - add gpu on node

* fix #3422 - resource switch ns

* update rel notes
mine
Fernand Galiana 2025-07-05 10:36:36 -06:00 committed by GitHub
parent 3b7cd99fbc
commit 00b28ceeee
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
38 changed files with 666 additions and 206 deletions

View File

@ -1,5 +1,5 @@
NAME := k9s NAME := k9s
VERSION ?= v0.50.6 VERSION ?= v0.50.7
PACKAGE := github.com/derailed/$(NAME) PACKAGE := github.com/derailed/$(NAME)
OUTPUT_BIN ?= execs/${NAME} OUTPUT_BIN ?= execs/${NAME}
GO_FLAGS ?= GO_FLAGS ?=

View File

@ -0,0 +1,49 @@
<img src="https://raw.githubusercontent.com/derailed/k9s/master/assets/k9s.png" align="center" width="800" height="auto"/>
# Release v0.50.7
## 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/zt-3360a389v-ElLHrb0Dp1kAXqYUItSAFA)
## Maintenance Release!
---
## Resolved Issues
* [#3435](https://github.com/derailed/k9s/issues/3435) noExitOnCtrlC
* [#3434](https://github.com/derailed/k9s/issues/3434) Pulses - navigation selection is invisible
* [#3424](https://github.com/derailed/k9s/issues/3424) feat: Add GPUs to nodes view
* [#3422](https://github.com/derailed/k9s/issues/3422) Changing ns should keep current kind
* [#3412](https://github.com/derailed/k9s/issues/3412) "Toggle Decode" for secret has no effect
* [#3406](https://github.com/derailed/k9s/issues/3406) History navigation: new view after going back should truncate forward history
* [#3398](https://github.com/derailed/k9s/issues/3398) Improve the UX of FieldManager field on restart
* [#3383](https://github.com/derailed/k9s/issues/3383) Triggering a CronJob fails as Unauthorized since v0.50
* [#3406](https://github.com/derailed/k9s/issues/3406) History navigation: new view after going back should truncate forward history
---
## 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!!
* [#3433](https://github.com/derailed/k9s/pull/3433) feat(plugins): add kube-metrics plugin
* [#3371](https://github.com/derailed/k9s/pull/3371) Add context to condition in keda-toggle plugin
* [#3347](https://github.com/derailed/k9s/pull/3347) Fix GVR Title option in readme
* [#3346](https://github.com/derailed/k9s/pull/3346) revert: #3322
---
<img src="https://raw.githubusercontent.com/derailed/k9s/master/assets/imhotep_logo.png" width="32" height="auto"/> © 2025 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0)#

3
go.mod
View File

@ -1,6 +1,6 @@
module github.com/derailed/k9s module github.com/derailed/k9s
go 1.24.1 go 1.24.4
require ( require (
github.com/adrg/xdg v0.5.3 github.com/adrg/xdg v0.5.3
@ -123,6 +123,7 @@ require (
github.com/containerd/stargz-snapshotter/estargz v0.16.3 // indirect github.com/containerd/stargz-snapshotter/estargz v0.16.3 // indirect
github.com/containerd/ttrpc v1.2.7 // indirect github.com/containerd/ttrpc v1.2.7 // indirect
github.com/containerd/typeurl/v2 v2.2.2 // indirect github.com/containerd/typeurl/v2 v2.2.2 // indirect
github.com/creack/pty v1.1.20 // indirect
github.com/cyphar/filepath-securejoin v0.4.1 // indirect github.com/cyphar/filepath-securejoin v0.4.1 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/deitch/magic v0.0.0-20230404182410-1ff89d7342da // indirect github.com/deitch/magic v0.0.0-20230404182410-1ff89d7342da // indirect

4
go.sum
View File

@ -871,8 +871,8 @@ github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSV
github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= github.com/creack/pty v1.1.20 h1:VIPb/a2s17qNeQgDnkfZC35RScx+blkKF8GV68n80J4=
github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/creack/pty v1.1.20/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s=
github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=

View File

@ -213,7 +213,7 @@ func (g *GVR) G() string {
// IsDecodable checks if the k8s resource has a decodable view // IsDecodable checks if the k8s resource has a decodable view
func (g *GVR) IsDecodable() bool { func (g *GVR) IsDecodable() bool {
return g.GVK().Kind == "secrets" return g == SecGVR
} }
var _ = yaml.Marshaler((*GVR)(nil)) var _ = yaml.Marshaler((*GVR)(nil))

View File

@ -19,6 +19,12 @@ import (
"github.com/derailed/k9s/internal/slogs" "github.com/derailed/k9s/internal/slogs"
) )
var KnownGPUVendors = map[string]string{
"nvidia": "nvidia.com/gpu",
"amd": "amd.com/gpu",
"intel": "gpu.intel.com/i915",
}
// K9s tracks K9s configuration options. // K9s tracks K9s configuration options.
type K9s struct { type K9s struct {
LiveViewAutoRefresh bool `json:"liveViewAutoRefresh" yaml:"liveViewAutoRefresh"` LiveViewAutoRefresh bool `json:"liveViewAutoRefresh" yaml:"liveViewAutoRefresh"`

View File

@ -299,7 +299,7 @@ func newCharts() Charts {
MEM: {Color("yellow"), Color("goldenrod")}, MEM: {Color("yellow"), Color("goldenrod")},
}, },
FocusFgColor: "white", FocusFgColor: "white",
FocusBgColor: "aqua", FocusBgColor: "orange",
} }
} }

View File

@ -76,6 +76,8 @@ k9s:
bgColor: black bgColor: black
dialBgColor: black dialBgColor: black
chartBgColor: black chartBgColor: black
focusFgColor: white
focusBgColor: orange
defaultDialColors: defaultDialColors:
- palegreen - palegreen
- orangered - orangered

View File

@ -46,7 +46,7 @@ func (c *CronJob) ListImages(_ context.Context, fqn string) ([]string, error) {
// Run a CronJob. // Run a CronJob.
func (c *CronJob) Run(path string) error { func (c *CronJob) Run(path string) error {
ns, n := client.Namespaced(path) ns, n := client.Namespaced(path)
auth, err := c.Client().CanI(ns, c.gvr, n, []string{client.GetVerb, client.CreateVerb}) auth, err := c.Client().CanI(ns, client.JobGVR, n, []string{client.GetVerb, client.CreateVerb})
if err != nil { if err != nil {
return err return err
} }

View File

@ -106,9 +106,8 @@ func ToYAML(o runtime.Object, showManaged bool) (string, error) {
delete(meta, "managedFields") delete(meta, "managedFields")
} }
} }
err := p.PrintObj(o, &buff) if err := p.PrintObj(o, &buff); err != nil {
if err != nil { slog.Error("PrintObj failed", slogs.Error, err)
slog.Error("Marshal failed", slogs.Error, err)
return "", err return "", err
} }

View File

@ -14,6 +14,8 @@ import (
"github.com/sahilm/fuzzy" "github.com/sahilm/fuzzy"
) )
type podColors map[string]string
var podPalette = []string{ var podPalette = []string{
"teal", "teal",
"green", "green",
@ -28,7 +30,7 @@ var podPalette = []string{
// LogItems represents a collection of log items. // LogItems represents a collection of log items.
type LogItems struct { type LogItems struct {
items []*LogItem items []*LogItem
podColors map[string]string podColors podColors
mx sync.RWMutex mx sync.RWMutex
} }
@ -104,25 +106,28 @@ func (l *LogItems) Add(ii ...*LogItem) {
l.items = append(l.items, ii...) l.items = append(l.items, ii...)
} }
func (l *LogItems) podColorFor(id string) string {
color, ok := l.podColors[id]
if ok {
return color
}
var idx int
for i, r := range id {
idx += i * int(r)
}
l.podColors[id] = podPalette[idx%len(podPalette)]
return l.podColors[id]
}
// Lines returns a collection of log lines. // Lines returns a collection of log lines.
func (l *LogItems) Lines(index int, showTime bool, ll [][]byte) { func (l *LogItems) Lines(index int, showTime bool, ll [][]byte) {
l.mx.Lock() l.mx.Lock()
defer l.mx.Unlock() defer l.mx.Unlock()
var colorIndex int
for i, item := range l.items[index:] { for i, item := range l.items[index:] {
id := item.ID()
color, ok := l.podColors[id]
if !ok {
if colorIndex >= len(podPalette) {
colorIndex = 0
}
color = podPalette[colorIndex]
l.podColors[id] = color
colorIndex++
}
bb := bytes.NewBuffer(make([]byte, 0, item.Size())) bb := bytes.NewBuffer(make([]byte, 0, item.Size()))
item.Render(color, showTime, bb) item.Render(l.podColorFor(item.ID()), showTime, bb)
ll[i] = bb.Bytes() ll[i] = bb.Bytes()
} }
} }
@ -135,7 +140,7 @@ func (l *LogItems) StrLines(index int, showTime bool) []string {
ll := make([]string, len(l.items[index:])) ll := make([]string, len(l.items[index:]))
for i, item := range l.items[index:] { for i, item := range l.items[index:] {
bb := bytes.NewBuffer(make([]byte, 0, item.Size())) bb := bytes.NewBuffer(make([]byte, 0, item.Size()))
item.Render("white", showTime, bb) item.Render(l.podColorFor(item.ID()), showTime, bb)
ll[i] = bb.String() ll[i] = bb.String()
} }
@ -144,20 +149,9 @@ func (l *LogItems) StrLines(index int, showTime bool) []string {
// Render returns logs as a collection of strings. // Render returns logs as a collection of strings.
func (l *LogItems) Render(index int, showTime bool, ll [][]byte) { func (l *LogItems) Render(index int, showTime bool, ll [][]byte) {
var colorIndex int
for i, item := range l.items[index:] { for i, item := range l.items[index:] {
id := item.ID()
color, ok := l.podColors[id]
if !ok {
if colorIndex >= len(podPalette) {
colorIndex = 0
}
color = podPalette[colorIndex]
l.podColors[id] = color
colorIndex++
}
bb := bytes.NewBuffer(make([]byte, 0, item.Size())) bb := bytes.NewBuffer(make([]byte, 0, item.Size()))
item.Render(color, showTime, bb) item.Render(l.podColorFor(item.ID()), showTime, bb)
ll[i] = bb.Bytes() ll[i] = bb.Bytes()
} }
} }

View File

@ -4,13 +4,17 @@
package dao package dao
import ( import (
"bytes"
"context"
"fmt" "fmt"
"log/slog"
"strings" "strings"
"github.com/derailed/k9s/internal/slogs"
v1 "k8s.io/api/core/v1" v1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/labels"
"k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime"
"k8s.io/cli-runtime/pkg/printers"
) )
// Secret represents a secret K8s resource. // Secret represents a secret K8s resource.
@ -25,11 +29,54 @@ func (s *Secret) Describe(path string) (string, error) {
if err != nil { if err != nil {
return "", err return "", err
} }
if !s.decodeData { if s.decodeData {
return encodedDescription, nil return s.Decode(encodedDescription, path)
} }
return s.Decode(encodedDescription, path) return encodedDescription, nil
}
// ToYAML returns a resource yaml.
func (s *Secret) ToYAML(path string, showManaged bool) (string, error) {
if s.decodeData {
return s.decodeYAML(path, showManaged)
}
return s.Generic.ToYAML(path, showManaged)
}
func (s *Secret) decodeYAML(path string, showManaged bool) (string, error) {
o, err := s.Get(context.Background(), path)
if err != nil {
return "", err
}
o = o.DeepCopyObject()
u, ok := o.(*unstructured.Unstructured)
if !ok {
return "", fmt.Errorf("expecting unstructured but got %T", o)
}
if u.Object == nil {
return "", fmt.Errorf("expecting unstructured object but got nil")
}
if !showManaged {
if meta, ok := u.Object["metadata"].(map[string]any); ok {
delete(meta, "managedFields")
}
}
if decoded, err := ExtractSecrets(o); err == nil {
u.Object["data"] = decoded
}
var (
buff bytes.Buffer
p printers.YAMLPrinter
)
if err := p.PrintObj(o, &buff); err != nil {
slog.Error("PrintObj failed", slogs.Error, err)
return "", err
}
return buff.String(), nil
} }
// SetDecodeData toggles decode mode. // SetDecodeData toggles decode mode.
@ -40,11 +87,6 @@ func (s *Secret) SetDecodeData(b bool) {
// Decode removes the encoded part from the secret's description and appends the // Decode removes the encoded part from the secret's description and appends the
// secret's decoded data. // secret's decoded data.
func (s *Secret) Decode(encodedDescription, path string) (string, error) { func (s *Secret) Decode(encodedDescription, path string) (string, error) {
o, err := s.getFactory().Get(s.gvr, path, true, labels.Everything())
if err != nil {
return "", err
}
dataEndIndex := strings.Index(encodedDescription, "====") dataEndIndex := strings.Index(encodedDescription, "====")
if dataEndIndex == -1 { 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")
@ -59,11 +101,14 @@ func (s *Secret) Decode(encodedDescription, path string) (string, error) {
// More details about the reasoning of index: https://github.com/kubernetes/kubectl/blob/v0.29.0/pkg/describe/describe.go#L2542 // More details about the reasoning of index: https://github.com/kubernetes/kubectl/blob/v0.29.0/pkg/describe/describe.go#L2542
body := encodedDescription[0:dataEndIndex] body := encodedDescription[0:dataEndIndex]
o, err := s.Get(context.Background(), path)
if err != nil {
return "", err
}
data, err := ExtractSecrets(o) data, err := ExtractSecrets(o)
if err != nil { if err != nil {
return "", err return "", err
} }
decodedSecrets := make([]string, 0, len(data)) decodedSecrets := make([]string, 0, len(data))
for k, v := range data { for k, v := range data {
line := fmt.Sprintf("%s: %s", k, v) line := fmt.Sprintf("%s: %s", k, v)
@ -88,7 +133,6 @@ func ExtractSecrets(o runtime.Object) (map[string]string, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
secretData := make(map[string]string, len(secret.Data)) secretData := make(map[string]string, len(secret.Data))
for k, val := range secret.Data { for k, val := range secret.Data {
secretData[k] = string(val) secretData[k] = string(val)

View File

@ -181,7 +181,6 @@ func (d *Describe) describe(ctx context.Context, gvr *client.GVR, path string) (
if !ok { if !ok {
return "", fmt.Errorf("no describer for %q", meta.DAO.GVR()) return "", fmt.Errorf("no describer for %q", meta.DAO.GVR())
} }
if desc, ok := meta.DAO.(*dao.Secret); ok { if desc, ok := meta.DAO.(*dao.Secret); ok {
desc.SetDecodeData(d.decode) desc.SetDecodeData(d.decode)
} }

View File

@ -3,9 +3,7 @@
package model package model
import ( import "sort"
"sort"
)
// SuggestionListener listens for suggestions. // SuggestionListener listens for suggestions.
type SuggestionListener interface { type SuggestionListener interface {

View File

@ -12,117 +12,102 @@ const MaxHistory = 20
// History represents a command history. // History represents a command history.
type History struct { type History struct {
commands []string commands []string
limit int limit int
activeCommandIndex int currentIdx int
previousCommandIndex int
} }
// NewHistory returns a new instance. // NewHistory returns a new instance.
func NewHistory(limit int) *History { func NewHistory(limit int) *History {
return &History{ return &History{
limit: limit, limit: limit,
currentIdx: -1,
} }
} }
// Last switches the current and previous history index positions so the // List returns the command history.
// new command referenced by the index is the previous command func (h *History) List() []string {
func (h *History) Last() bool { return h.commands
if h.Empty() {
return false
}
h.activeCommandIndex, h.previousCommandIndex = h.previousCommandIndex, h.activeCommandIndex
return true
} }
// Back moves the history position index back by one // Top returns the last command in the history if present.
func (h *History) Back() bool { func (h *History) Top() (string, bool) {
if h.Empty() { h.currentIdx = len(h.commands) - 1
return false
return h.at(h.currentIdx)
}
// Last returns the nth command prior to last.
func (h *History) Last(idx int) (string, bool) {
h.currentIdx = len(h.commands) - idx
return h.at(h.currentIdx)
}
func (h *History) at(idx int) (string, bool) {
if idx < 0 || idx >= len(h.commands) {
return "", false
} }
// Return if there are no more commands left in the backward history return h.commands[idx], true
if h.activeCommandIndex == 0 { }
return false
}
h.previousCommandIndex = h.activeCommandIndex // Back moves the history position index back by one.
h.activeCommandIndex-- func (h *History) Back() (string, bool) {
return true if h.Empty() || h.currentIdx <= 0 {
return "", false
}
h.currentIdx--
return h.at(h.currentIdx)
} }
// Forward moves the history position index forward by one // Forward moves the history position index forward by one
func (h *History) Forward() bool { func (h *History) Forward() (string, bool) {
if h.Empty() { h.currentIdx++
return false if h.Empty() || h.currentIdx >= len(h.commands) {
return "", false
} }
// Return if there are no more commands left in the forward history return h.at(h.currentIdx)
if h.activeCommandIndex >= len(h.commands)-1 {
return false
}
h.previousCommandIndex = h.activeCommandIndex
h.activeCommandIndex++
return true
}
// CurrentIndex returns the current index of the active command in the history
func (h *History) CurrentIndex() int {
return h.activeCommandIndex
}
// PreviousIndex returns the index of the command that was the most recent
// active command in the history
func (h *History) PreviousIndex() int {
return h.previousCommandIndex
} }
// Pop removes the single most recent history item // Pop removes the single most recent history item
// and returns a bool if the list changed. // and returns a bool if the list changed.
func (h *History) Pop() bool { func (h *History) Pop() bool {
return h.PopN(1) return h.popN(1)
} }
// PopN removes the N most recent history item // PopN removes the N most recent history item
// and returns a bool if the list changed. // and returns a bool if the list changed.
// Argument specifies how many to remove from the history // Argument specifies how many to remove from the history
func (h *History) PopN(n int) bool { func (h *History) popN(n int) bool {
cmdLength := len(h.commands) pop := len(h.commands) - n
if cmdLength == 0 { if h.Empty() || pop < 0 {
return false return false
} }
h.commands = h.commands[:pop]
h.currentIdx = len(h.commands) - 1
h.commands = h.commands[:cmdLength-n]
return true return true
} }
// List returns the current command history.
func (h *History) List() []string {
return h.commands
}
// Push adds a new item. // Push adds a new item.
func (h *History) Push(c string) { func (h *History) Push(c string) {
if c == "" { if c == "" || len(h.commands) >= h.limit {
return return
} }
if h.currentIdx < len(h.commands)-1 {
c = strings.ToLower(c) h.commands = h.commands[:h.currentIdx+1]
if len(h.commands) < h.limit {
h.commands = append(h.commands, c)
h.previousCommandIndex = h.activeCommandIndex
h.activeCommandIndex = len(h.commands) - 1
return
} }
h.commands = append(h.commands, strings.ToLower(c))
h.currentIdx = len(h.commands) - 1
} }
// Clear clears out the stack. // Clear clears out the stack.
func (h *History) Clear() { func (h *History) Clear() {
h.commands = nil h.commands = nil
h.activeCommandIndex = 0 h.currentIdx = -1
h.previousCommandIndex = 0
} }
// Empty returns true if no history. // Empty returns true if no history.

View File

@ -11,18 +11,18 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
func TestHistory(t *testing.T) { func TestHistoryClear(t *testing.T) {
h := model.NewHistory(3) h := model.NewHistory(3)
for i := 1; i < 5; i++ { for i := 1; i < 5; i++ {
h.Push(fmt.Sprintf("cmd%d", i)) h.Push(fmt.Sprintf("cmd%d", i))
} }
assert.Equal(t, []string{"cmd1", "cmd2", "cmd3"}, h.List()) assert.Equal(t, []string{"cmd1", "cmd2", "cmd3"}, h.List())
h.Clear() h.Clear()
assert.True(t, h.Empty()) assert.True(t, h.Empty())
} }
func TestHistoryDups(t *testing.T) { func TestHistoryPush(t *testing.T) {
h := model.NewHistory(3) h := model.NewHistory(3)
for i := 1; i < 4; i++ { for i := 1; i < 4; i++ {
h.Push(fmt.Sprintf("cmd%d", i)) h.Push(fmt.Sprintf("cmd%d", i))
@ -32,3 +32,158 @@ func TestHistoryDups(t *testing.T) {
assert.Equal(t, []string{"cmd1", "cmd2", "cmd3"}, h.List()) assert.Equal(t, []string{"cmd1", "cmd2", "cmd3"}, h.List())
} }
func TestHistoryTop(t *testing.T) {
uu := map[string]struct {
push []string
pop int
cmd string
ok bool
}{
"empty": {},
"no-one-left": {
push: []string{"cmd1", "cmd2", "cmd3"},
pop: 3,
},
"last": {
push: []string{"cmd1", "cmd2", "cmd3"},
cmd: "cmd3",
ok: true,
},
"middle": {
push: []string{"cmd1", "cmd2", "cmd3"},
pop: 1,
cmd: "cmd2",
ok: true,
},
"first": {
push: []string{"cmd1", "cmd2", "cmd3"},
pop: 2,
cmd: "cmd1",
ok: true,
},
}
for k, u := range uu {
t.Run(k, func(t *testing.T) {
h := model.NewHistory(3)
for _, cmd := range u.push {
h.Push(cmd)
}
for range u.pop {
_ = h.Pop()
}
cmd, ok := h.Top()
assert.Equal(t, u.ok, ok)
assert.Equal(t, u.cmd, cmd)
})
}
}
func TestHistoryBack(t *testing.T) {
uu := map[string]struct {
push []string
pop int
cmd string
ok bool
}{
"empty": {},
"pop-all": {
push: []string{"cmd1", "cmd2", "cmd3"},
pop: 3,
},
"pop-none": {
push: []string{"cmd1", "cmd2", "cmd3"},
cmd: "cmd2",
ok: true,
},
"pop-one": {
push: []string{"cmd1", "cmd2", "cmd3"},
pop: 1,
cmd: "cmd1",
ok: true,
},
"pop-to-first": {
push: []string{"cmd1", "cmd2", "cmd3"},
pop: 2,
},
}
for k, u := range uu {
t.Run(k, func(t *testing.T) {
h := model.NewHistory(3)
for _, cmd := range u.push {
h.Push(cmd)
}
for range u.pop {
_ = h.Pop()
}
cmd, ok := h.Back()
assert.Equal(t, u.ok, ok)
assert.Equal(t, u.cmd, cmd)
})
}
}
func TestHistoryForward(t *testing.T) {
uu := map[string]struct {
push []string
back int
cmd string
ok bool
}{
"empty": {},
"back-2": {
push: []string{"cmd1", "cmd2", "cmd3"},
back: 2,
cmd: "cmd2",
ok: true,
},
"back-1": {
push: []string{"cmd1", "cmd2", "cmd3"},
back: 1,
cmd: "cmd3",
ok: true,
},
"back-all": {
push: []string{"cmd1", "cmd2", "cmd3"},
back: 3,
cmd: "cmd2",
ok: true,
},
"back-none": {
push: []string{"cmd1", "cmd2", "cmd3"},
back: 0,
},
}
for k, u := range uu {
t.Run(k, func(t *testing.T) {
h := model.NewHistory(3)
for _, cmd := range u.push {
h.Push(cmd)
}
for range u.back {
_, _ = h.Back()
}
cmd, ok := h.Forward()
assert.Equal(t, u.ok, ok)
assert.Equal(t, u.cmd, cmd)
})
}
}

View File

@ -6,6 +6,7 @@ package model_test
import ( import (
"context" "context"
"fmt" "fmt"
"log/slog"
"strconv" "strconv"
"testing" "testing"
"time" "time"
@ -20,6 +21,10 @@ import (
"k8s.io/client-go/informers" "k8s.io/client-go/informers"
) )
func init() {
slog.SetDefault(slog.New(slog.DiscardHandler))
}
func TestLogFullBuffer(t *testing.T) { func TestLogFullBuffer(t *testing.T) {
size := 4 size := 4
m := model.NewLog(client.NewGVR("fred"), makeLogOpts(size), 10*time.Millisecond) m := model.NewLog(client.NewGVR("fred"), makeLogOpts(size), 10*time.Millisecond)
@ -272,8 +277,7 @@ func (t *testView) LogCleared() {
t.data = nil t.data = nil
} }
func (t *testView) LogFailed(err error) { func (t *testView) LogFailed(error) {
fmt.Println("LogErr", err)
t.errCalled++ t.errCalled++
} }

View File

@ -32,6 +32,7 @@ type YAML struct {
lines []string lines []string
listeners []ResourceViewerListener listeners []ResourceViewerListener
options ViewerToggleOpts options ViewerToggleOpts
decode bool
} }
// NewYAML return a new yaml resource model. // NewYAML return a new yaml resource model.
@ -195,7 +196,7 @@ func (y *YAML) RemoveListener(l ResourceViewerListener) {
} }
// ToYAML returns a resource yaml. // ToYAML returns a resource yaml.
func (*YAML) ToYAML(ctx context.Context, gvr *client.GVR, path string, showManaged bool) (string, error) { func (y *YAML) ToYAML(ctx context.Context, gvr *client.GVR, path string, showManaged bool) (string, error) {
meta, err := getMeta(ctx, gvr) meta, err := getMeta(ctx, gvr)
if err != nil { if err != nil {
return "", err return "", err
@ -205,6 +206,14 @@ func (*YAML) ToYAML(ctx context.Context, gvr *client.GVR, path string, showManag
if !ok { if !ok {
return "", fmt.Errorf("no describer for %q", meta.DAO.GVR()) return "", fmt.Errorf("no describer for %q", meta.DAO.GVR())
} }
if desc, ok := meta.DAO.(*dao.Secret); ok {
desc.SetDecodeData(y.decode)
}
return desc.ToYAML(path, showManaged) return desc.ToYAML(path, showManaged)
} }
// Toggle toggles the decode flag.
func (y *YAML) Toggle() {
y.decode = !y.decode
}

View File

@ -13,6 +13,7 @@ import (
"strings" "strings"
"github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/client"
"github.com/derailed/k9s/internal/config"
"github.com/derailed/k9s/internal/model1" "github.com/derailed/k9s/internal/model1"
"github.com/derailed/k9s/internal/slogs" "github.com/derailed/k9s/internal/slogs"
"github.com/derailed/tview" "github.com/derailed/tview"
@ -46,6 +47,7 @@ var defaultNOHeader = model1.Header{
model1.HeaderColumn{Name: "%MEM", Attrs: model1.Attrs{Align: tview.AlignRight, MX: true}}, model1.HeaderColumn{Name: "%MEM", Attrs: model1.Attrs{Align: tview.AlignRight, MX: true}},
model1.HeaderColumn{Name: "CPU/A", Attrs: model1.Attrs{Align: tview.AlignRight, MX: true}}, model1.HeaderColumn{Name: "CPU/A", Attrs: model1.Attrs{Align: tview.AlignRight, MX: true}},
model1.HeaderColumn{Name: "MEM/A", Attrs: model1.Attrs{Align: tview.AlignRight, MX: true}}, model1.HeaderColumn{Name: "MEM/A", Attrs: model1.Attrs{Align: tview.AlignRight, MX: true}},
model1.HeaderColumn{Name: "GPU"},
model1.HeaderColumn{Name: "LABELS", Attrs: model1.Attrs{Wide: true}}, model1.HeaderColumn{Name: "LABELS", Attrs: model1.Attrs{Wide: true}},
model1.HeaderColumn{Name: "VALID", Attrs: model1.Attrs{Wide: true}}, model1.HeaderColumn{Name: "VALID", Attrs: model1.Attrs{Wide: true}},
model1.HeaderColumn{Name: "AGE", Attrs: model1.Attrs{Time: true}}, model1.HeaderColumn{Name: "AGE", Attrs: model1.Attrs{Time: true}},
@ -125,6 +127,7 @@ func (n Node) defaultRow(nwm *NodeWithMetrics, r *model1.Row) error {
client.ToPercentageStr(c.mem, a.mem), client.ToPercentageStr(c.mem, a.mem),
toMc(a.cpu), toMc(a.cpu),
toMi(a.mem), toMi(a.mem),
n.gpuSpec(no.Status.Capacity, no.Status.Allocatable),
mapToStr(no.Labels), mapToStr(no.Labels),
AsStatus(n.diagnose(statuses)), AsStatus(n.diagnose(statuses)),
ToAge(no.GetCreationTimestamp()), ToAge(no.GetCreationTimestamp()),
@ -133,6 +136,21 @@ func (n Node) defaultRow(nwm *NodeWithMetrics, r *model1.Row) error {
return nil return nil
} }
func (Node) gpuSpec(capacity, allocatable v1.ResourceList) string {
spec := NAValue
for k, v := range config.KnownGPUVendors {
key := v1.ResourceName(v)
if capacity, ok := capacity[key]; ok {
if allocs, ok := allocatable[key]; ok {
spec = fmt.Sprintf("%s/%s (%s)", capacity.String(), allocs.String(), k)
break
}
}
}
return spec
}
// Healthy checks component health. // Healthy checks component health.
func (n Node) Healthy(_ context.Context, o any) error { func (n Node) Healthy(_ context.Context, o any) error {
nwm, ok := o.(*NodeWithMetrics) nwm, ok := o.(*NodeWithMetrics)

View File

@ -0,0 +1,78 @@
package render
import (
"testing"
"github.com/stretchr/testify/assert"
v1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/resource"
)
func Test_gpuSpec(t *testing.T) {
uu := map[string]struct {
capacity v1.ResourceList
allocatable v1.ResourceList
e string
}{
"empty": {
e: NAValue,
},
"nvidia": {
capacity: v1.ResourceList{
v1.ResourceName("nvidia.com/gpu"): resource.MustParse("2"),
},
allocatable: v1.ResourceList{
v1.ResourceName("nvidia.com/gpu"): resource.MustParse("4"),
},
e: "2/4 (nvidia)",
},
"intel": {
capacity: v1.ResourceList{
v1.ResourceName("gpu.intel.com/i915"): resource.MustParse("2"),
},
allocatable: v1.ResourceList{
v1.ResourceName("gpu.intel.com/i915"): resource.MustParse("4"),
},
e: "2/4 (intel)",
},
"amd": {
capacity: v1.ResourceList{
v1.ResourceName("amd.com/gpu"): resource.MustParse("2"),
},
allocatable: v1.ResourceList{
v1.ResourceName("amd.com/gpu"): resource.MustParse("4"),
},
e: "2/4 (amd)",
},
"toast-cap": {
capacity: v1.ResourceList{
v1.ResourceName("gpu.intel.com/iBOZO"): resource.MustParse("2"),
},
allocatable: v1.ResourceList{
v1.ResourceName("gpu.intel.com/i915"): resource.MustParse("4"),
},
e: NAValue,
},
"toast-alloc": {
capacity: v1.ResourceList{
v1.ResourceName("gpu.intel.com/i915"): resource.MustParse("2"),
},
allocatable: v1.ResourceList{
v1.ResourceName("gpu.intel.com/iBOZO"): resource.MustParse("4"),
},
e: NAValue,
},
}
for k, u := range uu {
t.Run(k, func(t *testing.T) {
var n Node
assert.Equal(t, u.e, n.gpuSpec(u.capacity, u.allocatable))
})
}
}

View File

@ -26,8 +26,8 @@ func TestNodeRender(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, "minikube", r.ID) assert.Equal(t, "minikube", r.ID)
e := model1.Fields{"minikube", "Ready", "master", "amd64", "0", "v1.15.2", "Buildroot 2018.05.3", "4.15.0", "192.168.64.107", "<none>", "0", "10", "20", "0", "0", "4000", "7874"} e := model1.Fields{"minikube", "Ready", "master", "amd64", "0", "v1.15.2", "Buildroot 2018.05.3", "4.15.0", "192.168.64.107", "<none>", "0", "10", "20", "0", "0", "4000", "7874", "n/a"}
assert.Equal(t, e, r.Fields[:17]) assert.Equal(t, e, r.Fields[:18])
} }
func BenchmarkNodeRender(b *testing.B) { func BenchmarkNodeRender(b *testing.B) {

View File

@ -21,6 +21,7 @@ import (
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/util/sets"
mv1beta1 "k8s.io/metrics/pkg/apis/metrics/v1beta1" mv1beta1 "k8s.io/metrics/pkg/apis/metrics/v1beta1"
) )
@ -157,12 +158,11 @@ func (p *Pod) defaultRow(pwm *PodWithMetrics, row *model1.Row) error {
} }
dt := pwm.Raw.GetDeletionTimestamp() dt := pwm.Raw.GetDeletionTimestamp()
_, _, irc, _ := p.Statuses(st.InitContainerStatuses) cReady, _, cRestarts, lastRestart := p.ContainerStats(st.ContainerStatuses)
cr, _, rc, lr := p.Statuses(st.ContainerStatuses)
rcr, rcc := p.initContainerCounts(spec.InitContainers, st.InitContainerStatuses) iReady, iTerminated, iRestarts := p.initContainerStats(spec.InitContainers, st.InitContainerStatuses)
cr += rcr cReady += iReady
cc := len(spec.Containers) + rcc allCounts := len(spec.Containers) + iTerminated
var ccmx []mv1beta1.ContainerMetrics var ccmx []mv1beta1.ContainerMetrics
if pwm.MX != nil { if pwm.MX != nil {
@ -179,10 +179,10 @@ func (p *Pod) defaultRow(pwm *PodWithMetrics, row *model1.Row) error {
n, n,
computeVulScore(ns, pwm.Raw.GetLabels(), spec), computeVulScore(ns, pwm.Raw.GetLabels(), spec),
"●", "●",
strconv.Itoa(cr) + "/" + strconv.Itoa(cc), strconv.Itoa(cReady) + "/" + strconv.Itoa(allCounts),
phase, phase,
strconv.Itoa(rc + irc), strconv.Itoa(cRestarts + iRestarts),
ToAge(lr), ToAge(lastRestart),
toMc(c.cpu), toMc(c.cpu),
toMi(c.mem), toMi(c.mem),
toMc(r.cpu) + ":" + toMc(r.lcpu), toMc(r.cpu) + ":" + toMc(r.lcpu),
@ -198,7 +198,7 @@ func (p *Pod) defaultRow(pwm *PodWithMetrics, row *model1.Row) error {
asReadinessGate(spec, &st), asReadinessGate(spec, &st),
p.mapQOS(st.QOSClass), p.mapQOS(st.QOSClass),
mapToStr(pwm.Raw.GetLabels()), mapToStr(pwm.Raw.GetLabels()),
AsStatus(p.diagnose(phase, cr, cc)), AsStatus(p.diagnose(phase, cReady, allCounts)),
ToAge(pwm.Raw.GetCreationTimestamp()), ToAge(pwm.Raw.GetCreationTimestamp()),
} }
@ -224,13 +224,13 @@ func (p Pod) Healthy(_ context.Context, o any) error {
} }
dt := pwm.Raw.GetDeletionTimestamp() dt := pwm.Raw.GetDeletionTimestamp()
phase := p.Phase(dt, spec, &st) phase := p.Phase(dt, spec, &st)
cr, _, _, _ := p.Statuses(st.ContainerStatuses) cr, ct, _, _ := p.ContainerStats(st.ContainerStatuses)
rcr, rcc := p.initContainerCounts(spec.InitContainers, st.InitContainerStatuses) icr, ict, _ := p.initContainerStats(spec.InitContainers, st.InitContainerStatuses)
cr += rcr cr += icr
cc := len(spec.Containers) + rcc ct += ict
return p.diagnose(phase, cr, cc) return p.diagnose(phase, cr, ct)
} }
func (*Pod) diagnose(phase string, cr, ct int) error { func (*Pod) diagnose(phase string, cr, ct int) error {
@ -371,16 +371,16 @@ func (*Pod) mapQOS(class v1.PodQOSClass) string {
} }
} }
// Statuses reports current pod container statuses. // ContainerStats reports pod container stats.
func (*Pod) Statuses(cc []v1.ContainerStatus) (cr, ct, rc int, latest metav1.Time) { func (*Pod) ContainerStats(cc []v1.ContainerStatus) (readyCnt, terminatedCnt, restartCnt int, latest metav1.Time) {
for i := range cc { for i := range cc {
if cc[i].State.Terminated != nil { if cc[i].State.Terminated != nil {
ct++ terminatedCnt++
} }
if cc[i].Ready { if cc[i].Ready {
cr++ readyCnt++
} }
rc += int(cc[i].RestartCount) restartCnt += int(cc[i].RestartCount)
if t := cc[i].LastTerminationState.Terminated; t != nil { if t := cc[i].LastTerminationState.Terminated; t != nil {
ts := cc[i].LastTerminationState.Terminated.FinishedAt ts := cc[i].LastTerminationState.Terminated.FinishedAt
@ -393,15 +393,16 @@ func (*Pod) Statuses(cc []v1.ContainerStatus) (cr, ct, rc int, latest metav1.Tim
return return
} }
func (*Pod) initContainerCounts(cc []v1.Container, cos []v1.ContainerStatus) (ready, total int) { func (*Pod) initContainerStats(cc []v1.Container, cos []v1.ContainerStatus) (ready, total, restart int) {
for i := range cos { for i := range cos {
if !restartableInitCO(cc[i].RestartPolicy) { if !IsSideCarContainer(cc[i].RestartPolicy) {
continue continue
} }
total++ total++
if cos[i].Ready { if cos[i].Ready {
ready++ ready++
} }
restart += int(cos[i].RestartCount)
} }
return return
} }
@ -457,13 +458,15 @@ func (*Pod) containerPhase(st *v1.PodStatus, status string) (string, bool) {
func (*Pod) initContainerPhase(spec *v1.PodSpec, pst *v1.PodStatus, status string) (string, bool) { func (*Pod) initContainerPhase(spec *v1.PodSpec, pst *v1.PodStatus, status string) (string, bool) {
count := len(spec.InitContainers) count := len(spec.InitContainers)
rs := make(map[string]bool, count) sidecars := sets.New[string]()
for i := range spec.InitContainers { for i := range spec.InitContainers {
co := spec.InitContainers[i] co := spec.InitContainers[i]
rs[co.Name] = restartableInitCO(co.RestartPolicy) if IsSideCarContainer(co.RestartPolicy) {
sidecars.Insert(co.Name)
}
} }
for i := range pst.InitContainerStatuses { for i := range pst.InitContainerStatuses {
if s := checkInitContainerStatus(&pst.InitContainerStatuses[i], i, count, rs[pst.InitContainerStatuses[i].Name]); s != "" { if s := checkInitContainerStatus(&pst.InitContainerStatuses[i], i, count, sidecars.Has(pst.InitContainerStatuses[i].Name)); s != "" {
return s, true return s, true
} }
} }
@ -585,7 +588,7 @@ func hasPodReadyCondition(conditions []v1.PodCondition) bool {
return false return false
} }
func restartableInitCO(p *v1.ContainerRestartPolicy) bool { func IsSideCarContainer(p *v1.ContainerRestartPolicy) bool {
return p != nil && *p == v1.ContainerRestartPolicyAlways return p != nil && *p == v1.ContainerRestartPolicyAlways
} }

View File

@ -293,7 +293,7 @@ func Test_restartableInitCO(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.e, restartableInitCO(u.p)) assert.Equal(t, u.e, IsSideCarContainer(u.p))
}) })
} }
} }
@ -427,7 +427,7 @@ func Test_lastRestart(t *testing.T) {
var p Pod var p Pod
for name, u := range uu { for name, u := range uu {
t.Run(name, func(t *testing.T) { t.Run(name, func(t *testing.T) {
_, _, _, lr := p.Statuses(u.containerStatuses) _, _, _, lr := p.ContainerStats(u.containerStatuses)
assert.Equal(t, u.expected, lr) assert.Equal(t, u.expected, lr)
}) })
} }

View File

@ -56,7 +56,7 @@ func ShowRestart(styles *config.Dialog, pages *ui.Pages, opts *RestartDialogOpts
b.SetBackgroundColorActivated(styles.ButtonFocusBgColor.Color()) b.SetBackgroundColorActivated(styles.ButtonFocusBgColor.Color())
b.SetLabelColorActivated(styles.ButtonFocusFgColor.Color()) b.SetLabelColorActivated(styles.ButtonFocusFgColor.Color())
} }
f.SetFocus(0) f.SetFocus(1)
message := opts.Message message := opts.Message
modal.SetText(message) modal.SetText(message)

View File

@ -14,7 +14,7 @@ import (
) )
const ( const (
defaultPrompt = "%c> [::b]%s" defaultPrompt = "%c%c [::b]%s"
defaultSpacer = 4 defaultSpacer = 4
) )
@ -81,6 +81,7 @@ type Prompt struct {
app *App app *App
noIcons bool noIcons bool
icon rune icon rune
prefix rune
styles *config.Styles styles *config.Styles
model PromptModel model PromptModel
spacer int spacer int
@ -230,12 +231,11 @@ func (p *Prompt) write(text, suggest string) {
defer p.mx.Unlock() defer p.mx.Unlock()
p.SetCursorIndex(p.spacer + len(text)) p.SetCursorIndex(p.spacer + len(text))
txt := text
if suggest != "" { if suggest != "" {
txt += fmt.Sprintf("[%s::-]%s", p.styles.Prompt().SuggestColor, suggest) text += fmt.Sprintf("[%s::-]%s", p.styles.Prompt().SuggestColor, suggest)
} }
p.StylesChanged(p.styles) p.StylesChanged(p.styles)
_, _ = fmt.Fprintf(p, defaultPrompt, p.icon, txt) _, _ = fmt.Fprintf(p, defaultPrompt, p.icon, p.prefix, text)
} }
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
@ -263,7 +263,7 @@ func (p *Prompt) BufferActive(activate bool, kind model.BufferKind) {
p.SetBorder(true) p.SetBorder(true)
p.SetTextColor(p.styles.FgColor()) p.SetTextColor(p.styles.FgColor())
p.SetBorderColor(p.colorFor(kind)) p.SetBorderColor(p.colorFor(kind))
p.icon = p.iconFor(kind) p.icon, p.prefix = p.prefixesFor(kind)
p.activate() p.activate()
return return
} }
@ -274,17 +274,19 @@ func (p *Prompt) BufferActive(activate bool, kind model.BufferKind) {
p.Clear() p.Clear()
} }
func (p *Prompt) iconFor(k model.BufferKind) rune { func (p *Prompt) prefixesFor(k model.BufferKind) (ic, prefix rune) {
if p.noIcons { defer func() {
return ' ' if p.noIcons {
} ic = ' '
}
}()
//nolint:exhaustive //nolint:exhaustive
switch k { switch k {
case model.CommandBuffer: case model.CommandBuffer:
return '🐶' return '🐶', '>'
default: default:
return '🐩' return '🐩', '/'
} }
} }

View File

@ -14,15 +14,52 @@ import (
) )
func TestCmdNew(t *testing.T) { func TestCmdNew(t *testing.T) {
v := ui.NewPrompt(nil, true, config.NewStyles()) uu := map[string]struct {
m := model.NewFishBuff(':', model.CommandBuffer) mode rune
v.SetModel(m) kind model.BufferKind
m.AddListener(v) noIcon bool
for _, r := range "blee" { e string
m.Add(r) }{
"cmd": {
mode: ':',
noIcon: true,
kind: model.CommandBuffer,
e: " > [::b]blee\n",
},
"cmd-ic": {
mode: ':',
kind: model.CommandBuffer,
e: "🐶> [::b]blee\n",
},
"search": {
mode: '/',
kind: model.FilterBuffer,
noIcon: true,
e: " / [::b]blee\n",
},
"search-ic": {
mode: '/',
kind: model.FilterBuffer,
e: "🐩/ [::b]blee\n",
},
} }
assert.Equal(t, "\x00> [::b]blee\n", v.GetText(false)) for k, u := range uu {
t.Run(k, func(t *testing.T) {
v := ui.NewPrompt(nil, u.noIcon, config.NewStyles())
m := model.NewFishBuff(u.mode, u.kind)
v.SetModel(m)
m.AddListener(v)
for _, r := range "blee" {
m.Add(r)
}
m.SetActive(true)
assert.Equal(t, u.e, v.GetText(false))
})
}
} }
func TestCmdUpdate(t *testing.T) { func TestCmdUpdate(t *testing.T) {
@ -34,7 +71,7 @@ func TestCmdUpdate(t *testing.T) {
m.SetText("blee", "") m.SetText("blee", "")
m.Add('!') m.Add('!')
assert.Equal(t, "\x00> [::b]blee!\n", v.GetText(false)) assert.Equal(t, "\x00\x00 [::b]blee!\n", v.GetText(false))
assert.False(t, v.InCmdMode()) assert.False(t, v.InCmdMode())
} }

View File

@ -60,7 +60,7 @@ func TrimCell(tv *SelectTable, row, col int) string {
// TrimLabelSelector extracts label query. // TrimLabelSelector extracts label query.
func TrimLabelSelector(s string) (labels.Selector, error) { func TrimLabelSelector(s string) (labels.Selector, error) {
var selStr string selStr := s
if strings.Index(s, "-l") == 0 { if strings.Index(s, "-l") == 0 {
selStr = strings.TrimSpace(s[2:]) selStr = strings.TrimSpace(s[2:])
} }

View File

@ -671,15 +671,18 @@ func (a *App) dirCmd(path string, pushCmd bool) error {
} }
func (a *App) quitCmd(evt *tcell.EventKey) *tcell.EventKey { func (a *App) quitCmd(evt *tcell.EventKey) *tcell.EventKey {
noExit := a.Config.K9s.NoExitOnCtrlC
if a.InCmdMode() { if a.InCmdMode() {
if isBailoutEvt(evt) && noExit {
return nil
}
return evt return evt
} }
if !a.Config.K9s.NoExitOnCtrlC { if !noExit {
a.BailOut(0) a.BailOut(0)
} }
// overwrite the default ctrl-c behavior of tview
return nil return nil
} }
@ -707,12 +710,12 @@ func (a *App) previousCommand(evt *tcell.EventKey) *tcell.EventKey {
if evt != nil && evt.Rune() == rune(ui.KeyLeftBracket) && a.Prompt().InCmdMode() { if evt != nil && evt.Rune() == rune(ui.KeyLeftBracket) && a.Prompt().InCmdMode() {
return evt return evt
} }
cmds := a.cmdHistory.List() c, ok := a.cmdHistory.Back()
if !a.cmdHistory.Back() { if !ok {
a.App.Flash().Warn("Can't go back any further") a.App.Flash().Warn("Can't go back any further")
return evt return evt
} }
a.gotoResource(cmds[a.cmdHistory.CurrentIndex()], "", true, false) a.gotoResource(c, "", true, false)
return nil return nil
} }
@ -721,14 +724,14 @@ func (a *App) nextCommand(evt *tcell.EventKey) *tcell.EventKey {
if evt != nil && evt.Rune() == rune(ui.KeyRightBracket) && a.Prompt().InCmdMode() { if evt != nil && evt.Rune() == rune(ui.KeyRightBracket) && a.Prompt().InCmdMode() {
return evt return evt
} }
cmds := a.cmdHistory.List() c, ok := a.cmdHistory.Forward()
if !a.cmdHistory.Forward() { if !ok {
a.App.Flash().Warn("Can't go forward any further") a.App.Flash().Warn("Can't go forward any further")
return evt return evt
} }
// We go to the resource before updating the history so that // We go to the resource before updating the history so that
// gotoResource doesn't add this command to the history // gotoResource doesn't add this command to the history
a.gotoResource(cmds[a.cmdHistory.CurrentIndex()], "", true, false) a.gotoResource(c, "", true, false)
return nil return nil
} }
@ -737,13 +740,12 @@ func (a *App) lastCommand(evt *tcell.EventKey) *tcell.EventKey {
if evt != nil && evt.Rune() == ui.KeyDash && a.Prompt().InCmdMode() { if evt != nil && evt.Rune() == ui.KeyDash && a.Prompt().InCmdMode() {
return evt return evt
} }
cmds := a.cmdHistory.List() c, ok := a.cmdHistory.Top()
if len(cmds) < 1 { if !ok {
a.App.Flash().Warn("No previous view to switch to") a.App.Flash().Warn("No previous view to switch to")
return evt return evt
} }
a.cmdHistory.Last() a.gotoResource(c, "", true, false)
a.gotoResource(cmds[a.cmdHistory.CurrentIndex()], "", true, false)
return nil return nil
} }

View File

@ -27,6 +27,15 @@ func NewInterpreter(s string) *Interpreter {
return &c return &c
} }
func (c *Interpreter) TrimNS() string {
if !c.HasNS() {
return c.line
}
ns, _ := c.NSArg()
return strings.TrimSpace(strings.Replace(c.line, ns, "", 1))
}
func (c *Interpreter) grok() { func (c *Interpreter) grok() {
ff := strings.Fields(c.line) ff := strings.Fields(c.line)
if len(ff) == 0 { if len(ff) == 0 {

View File

@ -229,11 +229,11 @@ func (c *Command) defaultCmd(isRoot bool) error {
} }
if err := c.run(p, "", true, true); err != nil { if err := c.run(p, "", true, true); err != nil {
p = p.Reset(defCmd)
slog.Error("Command exec failed. Using default command", slog.Error("Command exec failed. Using default command",
slogs.Command, p.GetLine(), slogs.Command, p.GetLine(),
slogs.Error, err, slogs.Error, err,
) )
p = p.Reset(defCmd)
return c.run(p, "", true, true) return c.run(p, "", true, true)
} }
@ -331,9 +331,8 @@ func (c *Command) exec(p *cmd.Interpreter, gvr *client.GVR, comp model.Component
slog.Error("Dumping stack", slogs.Stack, string(debug.Stack())) slog.Error("Dumping stack", slogs.Stack, string(debug.Stack()))
ci := cmd.NewInterpreter(podCmd) ci := cmd.NewInterpreter(podCmd)
cmds := c.app.cmdHistory.List() currentCommand, ok := c.app.cmdHistory.Top()
currentCommand := cmds[c.app.cmdHistory.CurrentIndex()] if ok {
if currentCommand != podCmd {
ci = ci.Reset(currentCommand) ci = ci.Reset(currentCommand)
} }
err = c.run(ci, "", true, true) err = c.run(ci, "", true, true)

View File

@ -31,6 +31,10 @@ import (
"k8s.io/apimachinery/pkg/util/sets" "k8s.io/apimachinery/pkg/util/sets"
) )
func isBailoutEvt(evt *tcell.EventKey) bool {
return evt.Name() == "Ctrl+C"
}
func aliases(m *v1.APIResource, aa sets.Set[string]) sets.Set[string] { func aliases(m *v1.APIResource, aa sets.Set[string]) sets.Set[string] {
ss := sets.New(aa.UnsortedList()...) ss := sets.New(aa.UnsortedList()...)
ss.Insert(m.Name) ss.Insert(m.Name)

View File

@ -162,14 +162,13 @@ func (v *LiveView) bindKeys() {
if v.title == yamlAction { if v.title == yamlAction {
v.actions.Add(ui.KeyM, ui.NewKeyAction("Toggle ManagedFields", v.toggleManagedCmd, true)) v.actions.Add(ui.KeyM, ui.NewKeyAction("Toggle ManagedFields", v.toggleManagedCmd, true))
} }
if v.model != nil && v.model.GVR().IsDecodable() { if _, ok := v.model.(model.EncDecResourceViewer); ok {
v.actions.Add(ui.KeyX, ui.NewKeyAction("Toggle Decode", v.toggleEncodedDecodedCmd, true)) v.actions.Add(ui.KeyX, ui.NewKeyAction("Toggle Decode", v.toggleEncodedDecodedCmd, true))
} }
} }
func (v *LiveView) toggleEncodedDecodedCmd(evt *tcell.EventKey) *tcell.EventKey { func (v *LiveView) toggleEncodedDecodedCmd(evt *tcell.EventKey) *tcell.EventKey {
m, ok := v.model.(model.EncDecResourceViewer) m, ok := v.model.(model.EncDecResourceViewer)
if !ok { if !ok {
return evt return evt
} }

View File

@ -7,7 +7,9 @@ import (
"github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/client"
"github.com/derailed/k9s/internal/model1" "github.com/derailed/k9s/internal/model1"
"github.com/derailed/k9s/internal/ui" "github.com/derailed/k9s/internal/ui"
cmd2 "github.com/derailed/k9s/internal/view/cmd"
"github.com/derailed/tcell/v2" "github.com/derailed/tcell/v2"
"k8s.io/apimachinery/pkg/util/sets"
) )
const ( const (
@ -41,7 +43,14 @@ func (n *Namespace) bindKeys(aa *ui.KeyActions) {
func (n *Namespace) switchNs(app *App, _ ui.Tabular, _ *client.GVR, path string) { func (n *Namespace) switchNs(app *App, _ ui.Tabular, _ *client.GVR, path string) {
n.useNamespace(path) n.useNamespace(path)
app.gotoResource(client.PodGVR.String(), "", false, true) cmd, ok := app.cmdHistory.Last(2)
if !ok || cmd == "" {
cmd = client.PodGVR.String()
} else {
i := cmd2.NewInterpreter(cmd)
cmd = i.TrimNS()
}
app.gotoResource(cmd, "", false, true)
} }
func (n *Namespace) useNsCmd(*tcell.EventKey) *tcell.EventKey { func (n *Namespace) useNsCmd(*tcell.EventKey) *tcell.EventKey {
@ -85,17 +94,16 @@ func (n *Namespace) decorate(td *model1.TableData) {
) )
} }
favs := make(map[string]struct{}) var (
for _, ns := range n.App().Config.FavNamespaces() { favs = sets.New(n.App().Config.FavNamespaces()...)
favs[ns] = struct{}{} activeNS = n.App().Config.ActiveNamespace()
} )
ans := n.App().Config.ActiveNamespace()
td.RowsRange(func(i int, re model1.RowEvent) bool { td.RowsRange(func(i int, re model1.RowEvent) bool {
_, n := client.Namespaced(re.Row.ID) _, n := client.Namespaced(re.Row.ID)
if _, ok := favs[n]; ok { if favs.Has(n) {
re.Row.Fields[0] += favNSIndicator re.Row.Fields[0] += favNSIndicator
} }
if ans == re.Row.ID { if n == activeNS {
re.Row.Fields[0] += defaultNSIndicator re.Row.Fields[0] += defaultNSIndicator
} }
re.Kind = model1.EventUnchanged re.Kind = model1.EventUnchanged

View File

@ -203,7 +203,6 @@ func showFwdDialog(v ResourceViewer, path string, cb PortForwardCB) error {
return startFwdCB(v, path, pts) return startFwdCB(v, path, pts)
} }
ShowPortForwards(v, path, ports, anns, cb) ShowPortForwards(v, path, ports, anns, cb)
return nil return nil

View File

@ -504,7 +504,7 @@ func buildShellArgs(cmd, path, co string, flags *genericclioptions.ConfigFlags)
} }
func fetchContainers(meta *metav1.ObjectMeta, spec *v1.PodSpec, allContainers bool) []string { func fetchContainers(meta *metav1.ObjectMeta, spec *v1.PodSpec, allContainers bool) []string {
nn := make([]string, 0, len(spec.Containers)+len(spec.InitContainers)) nn := make([]string, 0, len(spec.Containers)+len(spec.EphemeralContainers)+len(spec.InitContainers))
// put the default container as the first entry // put the default container as the first entry
defaultContainer, ok := dao.GetDefaultContainer(meta, spec) defaultContainer, ok := dao.GetDefaultContainer(meta, spec)
if ok { if ok {

View File

@ -67,9 +67,9 @@ func (p *Pod) validate(node *TreeNode, po v1.Pod) error {
var re render.Pod var re render.Pod
phase := re.Phase(po.DeletionTimestamp, &po.Spec, &po.Status) phase := re.Phase(po.DeletionTimestamp, &po.Spec, &po.Status)
ss := po.Status.ContainerStatuses ss := po.Status.ContainerStatuses
cr, _, _, _ := re.Statuses(ss) readyCnt, _, _, _ := re.ContainerStats(ss)
status := OkStatus status := OkStatus
if cr != len(ss) { if readyCnt != len(ss) {
status = ToastStatus status = ToastStatus
} }
if phase == "Completed" { if phase == "Completed" {
@ -77,7 +77,7 @@ func (p *Pod) validate(node *TreeNode, po v1.Pod) error {
} }
node.Extras[StatusKey] = status node.Extras[StatusKey] = status
node.Extras[InfoKey] = strconv.Itoa(cr) + "/" + strconv.Itoa(len(ss)) node.Extras[InfoKey] = strconv.Itoa(readyCnt) + "/" + strconv.Itoa(len(ss))
return nil return nil
} }

57
skins/vercel.yaml Normal file
View File

@ -0,0 +1,57 @@
foreground: &foreground "#ffffff"
background: &background "#000000"
current_line: &current_line "#1a1a1a"
selection: &selection "#e63946"
comment: &comment "#555555"
cyan: &cyan "#00bcd4"
green: &green "#2ecc71"
orange: &orange "#f4a261"
magenta: &magenta "#9d0191"
blue: &blue "#0070f3"
red: &red "#e63946"
k9s:
body:
fgColor: *foreground
bgColor: *background
logoColor: *red
prompt:
fgColor: *foreground
bgColor: *background
suggestColor: *red
info:
fgColor: *red
sectionColor: *foreground
help:
fgColor: *foreground
bgColor: *background
keyColor: *red
numKeyColor: *blue
sectionColor: *green
dialog:
fgColor: *foreground
bgColor: *background
buttonFgColor: *foreground
buttonBgColor: *red
buttonFocusFgColor: *background
buttonFocusBgColor: *red
labelFgColor: *orange
fieldFgColor: *foreground
frame:
border:
fgColor: *selection
focusColor: *current_line
menu:
fgColor: *foreground
keyColor: *red
numKeyColor: *red
crumbs:
fgColor: *foreground
bgColor: *comment
activeColor: *red
status:
newColor: *cyan
modifyColor: *blue
addColor: *green
errorColor: *red
highlightColor: *orange

View File

@ -1,6 +1,6 @@
name: k9s name: k9s
base: core22 base: core22
version: 'v0.50.6' version: 'v0.50.7'
summary: K9s is a CLI to view and manage your Kubernetes clusters. summary: K9s is a CLI to view and manage your Kubernetes clusters.
description: | description: |
K9s is a CLI to view and manage your Kubernetes clusters. By leveraging a terminal UI, you can easily traverse Kubernetes resources and view the state of your clusters in a single powerful session. K9s is a CLI to view and manage your Kubernetes clusters. By leveraging a terminal UI, you can easily traverse Kubernetes resources and view the state of your clusters in a single powerful session.