diff --git a/Dockerfile b/Dockerfile
index e82979e4..2f00ea28 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,7 +1,11 @@
# -----------------------------------------------------------------------------
# The base image for building the k9s binary
+FROM --platform=$BUILDPLATFORM golang:1.24.2-alpine3.21 AS build
-FROM golang:1.24.2-alpine3.21 AS build
+ARG TARGETOS
+ARG TARGETARCH
+ENV GOOS=$TARGETOS
+ENV GOARCH=$TARGETARCH
WORKDIR /k9s
COPY go.mod go.sum main.go Makefile ./
@@ -12,8 +16,7 @@ RUN apk --no-cache add --update make libx11-dev git gcc libc-dev curl \
# -----------------------------------------------------------------------------
# Build the final Docker image
-
-FROM alpine:3.21.3
+FROM --platform=$BUILDPLATFORM alpine:3.21.3
ARG KUBECTL_VERSION="v1.32.2"
COPY --from=build /k9s/execs/k9s /bin/k9s
diff --git a/Makefile b/Makefile
index 139c7043..bfb1ce21 100644
--- a/Makefile
+++ b/Makefile
@@ -1,30 +1,33 @@
-NAME := k9s
-GO_FLAGS ?=
-GO_TAGS ?= netgo
-CGO_ENABLED?=0
-OUTPUT_BIN ?= execs/${NAME}
-PACKAGE := github.com/derailed/$(NAME)
-GIT_REV ?= $(shell git rev-parse --short HEAD)
+NAME := k9s
+VERSION ?= v0.50.5
+PACKAGE := github.com/derailed/$(NAME)
+OUTPUT_BIN ?= execs/${NAME}
+GO_FLAGS ?=
+GO_TAGS ?= netgo
+CGO_ENABLED ?=0
+GIT_REV ?= $(shell git rev-parse --short HEAD)
+
+IMG_NAME := derailed/k9s
+IMAGE := ${IMG_NAME}:${VERSION}
+BUILD_PLATFORMS ?= linux/amd64,linux/arm64
+
SOURCE_DATE_EPOCH ?= $(shell date +%s)
ifeq ($(shell uname), Darwin)
-DATE ?= $(shell TZ=UTC /bin/date -j -f "%s" ${SOURCE_DATE_EPOCH} +"%Y-%m-%dT%H:%M:%SZ")
+DATE ?= $(shell TZ=UTC date -j -f "%s" ${SOURCE_DATE_EPOCH} +"%Y-%m-%dT%H:%M:%SZ")
else
-DATE ?= $(shell date -u -d @${SOURCE_DATE_EPOCH} +"%Y-%m-%dT%H:%M:%SZ")
+DATE ?= $(shell date -u -d @${SOURCE_DATE_EPOCH} +"%Y-%m-%dT%H:%M:%SZ")
endif
-VERSION ?= v0.50.4
-IMG_NAME := derailed/k9s
-IMAGE := ${IMG_NAME}:${VERSION}
default: help
-test: ## Run all tests
+test: ## Run all tests
@go clean --testcache && go test ./...
-cover: ## Run test coverage suite
+cover: ## Run test coverage suite
@go test ./... --coverprofile=cov.out
@go tool cover --html=cov.out
-build: ## Builds the CLI
+build: ## Builds the CLI
@CGO_ENABLED=${CGO_ENABLED} go build ${GO_FLAGS} \
-ldflags "-w -s -X ${PACKAGE}/cmd.version=${VERSION} -X ${PACKAGE}/cmd.commit=${GIT_REV} -X ${PACKAGE}/cmd.date=${DATE}" \
-a -tags=${GO_TAGS} -o ${OUTPUT_BIN} main.go
@@ -32,8 +35,11 @@ build: ## Builds the CLI
kubectl-stable-version: ## Get kubectl latest stable version
@curl -s https://storage.googleapis.com/kubernetes-release/release/stable.txt
-img: ## Build Docker Image
- @docker build --rm -t ${IMAGE} .
+imgx: ## Build Docker Image
+ @docker buildx build --platform ${BUILD_PLATFORMS} --rm -t ${IMAGE} --load .
+
+pushx: ## Push Docker image to registry
+ @docker buildx build --platform ${BUILD_PLATFORMS} --rm -t ${IMAGE} --push .
help:
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":[^:]*?## "}; {printf "\033[38;5;69m%-30s\033[38;5;38m %s\033[0m\n", $$1, $$2}'
diff --git a/change_logs/release_v0.50.5.md b/change_logs/release_v0.50.5.md
new file mode 100644
index 00000000..7a939492
--- /dev/null
+++ b/change_logs/release_v0.50.5.md
@@ -0,0 +1,45 @@
+
+
+# Release v0.50.5
+
+## 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
+
+* [#3328](https://github.com/derailed/k9s/issues/3328) Pod overview shows wrong number of running containers with sidecar init-container
+* [#3309](https://github.com/derailed/k9s/issues/3309) [0.50.4] k9s crashes when attempting to load logs
+* [#3301](https://github.com/derailed/k9s/issues/3301) Port Forward deleted without UI notification when forwarding to wrong port
+* [#3294](https://github.com/derailed/k9s/issues/3294) [0.50.4] k9s crashes when filtering based on labels
+* [#3278](https://github.com/derailed/k9s/issues/3278) k9s doesn't honor the --namespace parameter
+
+---
+
+## 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!!
+
+* [#3311](https://github.com/derailed/k9s/pull/3311) Fix concurrent read writes
+* [#3310](https://github.com/derailed/k9s/pull/3310) fix: use full path of date to avoid conflict
+* [#3308](https://github.com/derailed/k9s/pull/3308) Show replicasets from deployment view
+* [#3300](https://github.com/derailed/k9s/pull/3300) fix: truncate label selector input to max length
+* [#3296](https://github.com/derailed/k9s/pull/3296) fix: update time format in logging to 24-hour format
+
+---
+
© 2025 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0)
\ No newline at end of file
diff --git a/go.mod b/go.mod
index 4de38a27..5dd573c4 100644
--- a/go.mod
+++ b/go.mod
@@ -26,6 +26,7 @@ require (
github.com/spf13/cobra v1.9.1
github.com/stretchr/testify v1.10.0
github.com/xeipuuv/gojsonschema v1.2.0
+ golang.org/x/exp v0.0.0-20250305212735-054e65f0b394
golang.org/x/text v0.24.0
gopkg.in/yaml.v3 v3.0.1
helm.sh/helm/v3 v3.17.3
@@ -36,12 +37,13 @@ require (
k8s.io/client-go v0.32.3
k8s.io/klog/v2 v2.130.1
k8s.io/kubectl v0.32.3
+ k8s.io/kubernetes v1.33.0
k8s.io/metrics v0.32.3
sigs.k8s.io/yaml v1.4.0
)
require (
- cel.dev/expr v0.18.0 // indirect
+ cel.dev/expr v0.19.1 // indirect
cloud.google.com/go v0.116.0 // indirect
cloud.google.com/go/auth v0.13.0 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.6 // indirect
@@ -116,13 +118,13 @@ require (
github.com/containerd/containerd v1.7.27 // indirect
github.com/containerd/containerd/api v1.8.0 // indirect
github.com/containerd/continuity v0.4.4 // indirect
- github.com/containerd/errdefs v0.3.0 // indirect
+ github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/fifo v1.1.0 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/containerd/platforms v0.2.1 // indirect
github.com/containerd/stargz-snapshotter/estargz v0.16.3 // indirect
github.com/containerd/ttrpc v1.2.7 // indirect
- github.com/containerd/typeurl/v2 v2.1.1 // indirect
+ github.com/containerd/typeurl/v2 v2.2.2 // indirect
github.com/cyphar/filepath-securejoin v0.4.1 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/deitch/magic v0.0.0-20230404182410-1ff89d7342da // indirect
@@ -171,7 +173,7 @@ require (
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/golang/snappy v0.0.4 // indirect
- github.com/google/btree v1.0.1 // indirect
+ github.com/google/btree v1.1.3 // indirect
github.com/google/gnostic-models v0.6.9 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/go-containerregistry v0.20.3 // indirect
@@ -184,7 +186,7 @@ require (
github.com/googleapis/gax-go/v2 v2.14.1 // indirect
github.com/gookit/color v1.5.4 // indirect
github.com/gorilla/mux v1.8.0 // indirect
- github.com/gorilla/websocket v1.5.0 // indirect
+ github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect
github.com/gosuri/uitable v0.0.4 // indirect
github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 // indirect
github.com/hako/durafmt v0.0.0-20210608085754-5c1018a4e16b // indirect
@@ -215,7 +217,6 @@ require (
github.com/knqyf263/go-apk-version v0.0.0-20200609155635-041fdbb8563f // indirect
github.com/knqyf263/go-deb-version v0.0.0-20190517075300-09fca494f03d // indirect
github.com/knqyf263/go-rpmdb v0.1.1 // indirect
- github.com/kylelemons/godebug v1.1.0 // indirect
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
github.com/lib/pq v1.10.9 // indirect
@@ -254,8 +255,8 @@ require (
github.com/onsi/gomega v1.35.1 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
- github.com/opencontainers/runtime-spec v1.1.0 // indirect
- github.com/opencontainers/selinux v1.11.0 // indirect
+ github.com/opencontainers/runtime-spec v1.2.0 // indirect
+ github.com/opencontainers/selinux v1.11.1 // indirect
github.com/openvex/go-vex v0.2.5 // indirect
github.com/owenrumney/go-sarif v1.1.2-0.20231003122901-1000f5e05554 // indirect
github.com/package-url/packageurl-go v0.1.1 // indirect
@@ -270,9 +271,9 @@ require (
github.com/pkg/profile v1.7.0 // indirect
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
- github.com/prometheus/client_golang v1.19.1 // indirect
+ github.com/prometheus/client_golang v1.22.0 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
- github.com/prometheus/common v0.55.0 // indirect
+ github.com/prometheus/common v0.62.0 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect
@@ -316,21 +317,20 @@ require (
github.com/zclconf/go-cty v1.14.0 // indirect
go.opencensus.io v0.24.0 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
- go.opentelemetry.io/contrib/detectors/gcp v1.29.0 // indirect
- go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 // indirect
+ go.opentelemetry.io/contrib/detectors/gcp v1.31.0 // indirect
+ go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.58.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 // indirect
go.opentelemetry.io/otel v1.33.0 // indirect
go.opentelemetry.io/otel/metric v1.33.0 // indirect
go.opentelemetry.io/otel/sdk v1.33.0 // indirect
- go.opentelemetry.io/otel/sdk/metric v1.29.0 // indirect
+ go.opentelemetry.io/otel/sdk/metric v1.31.0 // indirect
go.opentelemetry.io/otel/trace v1.33.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go4.org v0.0.0-20230225012048-214862532bf5 // indirect
golang.org/x/crypto v0.37.0 // indirect
- golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
golang.org/x/mod v0.24.0 // indirect
golang.org/x/net v0.39.0 // indirect
- golang.org/x/oauth2 v0.25.0 // indirect
+ golang.org/x/oauth2 v0.27.0 // indirect
golang.org/x/sync v0.13.0 // indirect
golang.org/x/sys v0.32.0 // indirect
golang.org/x/term v0.31.0 // indirect
@@ -341,7 +341,8 @@ require (
google.golang.org/genproto v0.0.0-20241118233622-e639e219e697 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20241223144023-3abc09e42ca8 // indirect
- google.golang.org/grpc v1.67.3 // indirect
+ google.golang.org/grpc v1.68.1 // indirect
+ google.golang.org/grpc/stats/opentelemetry v0.0.0-20241028142157-ada6787961b3 // indirect
google.golang.org/protobuf v1.36.5 // indirect
gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
@@ -357,8 +358,8 @@ require (
modernc.org/sqlite v1.37.0 // indirect
oras.land/oras-go v1.2.5 // indirect
sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect
- sigs.k8s.io/kustomize/api v0.18.0 // indirect
- sigs.k8s.io/kustomize/kyaml v0.18.1 // indirect
+ sigs.k8s.io/kustomize/api v0.19.0 // indirect
+ sigs.k8s.io/kustomize/kyaml v0.19.0 // indirect
sigs.k8s.io/randfill v1.0.0 // indirect
sigs.k8s.io/structured-merge-diff/v4 v4.6.0 // indirect
)
diff --git a/go.sum b/go.sum
index 81bdce47..62365371 100644
--- a/go.sum
+++ b/go.sum
@@ -1,5 +1,5 @@
-cel.dev/expr v0.18.0 h1:CJ6drgk+Hf96lkLikr4rFf19WrU0BOWEihyZnI2TAzo=
-cel.dev/expr v0.18.0/go.mod h1:MrpN08Q+lEBs+bGYdLxxHkZoUSsCp0nSKTs0nTymJgw=
+cel.dev/expr v0.19.1 h1:NciYrtDRIR0lNCnH1LFJegdjspNx9fI59O7TWcua/W4=
+cel.dev/expr v0.19.1/go.mod h1:MrpN08Q+lEBs+bGYdLxxHkZoUSsCp0nSKTs0nTymJgw=
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
@@ -858,8 +858,8 @@ github.com/containerd/containerd/api v1.8.0 h1:hVTNJKR8fMc/2Tiw60ZRijntNMd1U+JVM
github.com/containerd/containerd/api v1.8.0/go.mod h1:dFv4lt6S20wTu/hMcP4350RL87qPWLVa/OHOwmmdnYc=
github.com/containerd/continuity v0.4.4 h1:/fNVfTJ7wIl/YPMHjf+5H32uFhl63JucB34PlCpMKII=
github.com/containerd/continuity v0.4.4/go.mod h1:/lNJvtJKUQStBzpVQ1+rasXO1LAWtUQssk28EZvJ3nE=
-github.com/containerd/errdefs v0.3.0 h1:FSZgGOeK4yuT/+DnF07/Olde/q4KBoMsaamhXxIMDp4=
-github.com/containerd/errdefs v0.3.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
+github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
+github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/fifo v1.1.0 h1:4I2mbh5stb1u6ycIABlBw9zgtlK8viPI9QkQNRQEEmY=
github.com/containerd/fifo v1.1.0/go.mod h1:bmC4NWMbXlt2EZ0Hc7Fx7QzTFxgPID13eH0Qu+MAb2o=
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
@@ -870,8 +870,8 @@ github.com/containerd/stargz-snapshotter/estargz v0.16.3 h1:7evrXtoh1mSbGj/pfRcc
github.com/containerd/stargz-snapshotter/estargz v0.16.3/go.mod h1:uyr4BfYfOj3G9WBVE8cOlQmXAbPN9VEQpBBeJIuOipU=
github.com/containerd/ttrpc v1.2.7 h1:qIrroQvuOL9HQ1X6KHe2ohc7p+HP/0VE6XPU7elJRqQ=
github.com/containerd/ttrpc v1.2.7/go.mod h1:YCXHsb32f+Sq5/72xHubdiJRQY9inL4a4ZQrAbN1q9o=
-github.com/containerd/typeurl/v2 v2.1.1 h1:3Q4Pt7i8nYwy2KmQWIw2+1hTvwTE/6w9FqcttATPO/4=
-github.com/containerd/typeurl/v2 v2.1.1/go.mod h1:IDp2JFvbwZ31H8dQbEIY7sDl2L3o3HZj1hsSQlywkQ0=
+github.com/containerd/typeurl/v2 v2.2.2 h1:3jN/k2ysKuPCsln5Qv8bzR9cxal8XjkxPogJfSNO31k=
+github.com/containerd/typeurl/v2 v2.2.2/go.mod h1:95ljDnPfD3bAbDJRugOiShd/DlAAsxGtUBhJxIn7SCk=
github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
@@ -1122,8 +1122,8 @@ github.com/gomodule/redigo v1.8.2 h1:H5XSIre1MB5NbPYFp+i1NBbb5qN1W8Y8YAQoAYbkm8k
github.com/gomodule/redigo v1.8.2/go.mod h1:P9dn9mFrCBvWhGE1wpxx6fgq7BAeLBk+UUUzlpkBYO0=
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
-github.com/google/btree v1.0.1 h1:gK4Kx5IaGY9CD5sPJ36FHiBJ6ZXl0kilRiiCj+jdYp4=
-github.com/google/btree v1.0.1/go.mod h1:xXMiIv4Fb/0kKde4SpL7qlzvu5cMJDRkFDxJfI9uaxA=
+github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg=
+github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
github.com/google/flatbuffers v2.0.8+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/gnostic-models v0.6.9 h1:MU/8wDLif2qCXZmzncUQ/BOfxWfthHi63KqpoNbWqVw=
github.com/google/gnostic-models v0.6.9/go.mod h1:CiWsm0s6BSQd1hRn8/QmxqB6BesYcbSZxsz9b0KuDBw=
@@ -1217,8 +1217,8 @@ github.com/gorilla/handlers v1.5.1 h1:9lRY6j8DEeeBT10CvO9hGW0gmky0BprnvDI5vfhUHH
github.com/gorilla/handlers v1.5.1/go.mod h1:t8XrUpc4KVXb7HGyJ4/cEnwQiaxrX/hz1Zv/4g96P1Q=
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
-github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
-github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
+github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
+github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
github.com/gosuri/uitable v0.0.4 h1:IG2xLKRvErL3uhY6e1BylFzG+aJiwQviDDTfOKeKTpY=
github.com/gosuri/uitable v0.0.4/go.mod h1:tKR86bXuXPZazfOTG1FIzvjIdXzd0mo4Vtn16vt0PJo=
github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 h1:+ngKgrYPPJrOjhax5N+uePQ0Fh1Z7PheYoUI/0nzkPA=
@@ -1227,8 +1227,8 @@ github.com/grpc-ecosystem/grpc-gateway v1.16.0 h1:gmcG1KaJ57LophUzW0Hy8NmPhnMZb4
github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.7.0/go.mod h1:hgWBS7lorOAVIJEQMi4ZsPv9hVvWI6+ch50m39Pf2Ks=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.11.3/go.mod h1:o//XUCC/F+yRGJoPO/VU0GSB0f8Nhgmxx0VIRUvaC0w=
-github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 h1:bkypFPDjIYGfCYD5mRBvpqxfYX1YCS1PXdKYWi8FsN0=
-github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0/go.mod h1:P+Lt/0by1T8bfcF3z737NnSbmxQAppXMRziHUxPOC8k=
+github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0 h1:TmHmbvxPmaegwhDubVz0lICL0J5Ka2vwTzhoePEXsGE=
+github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0/go.mod h1:qztMSjm835F2bXf+5HKAPIS5qsmQDqZna/PgVt4rWtI=
github.com/hako/durafmt v0.0.0-20210608085754-5c1018a4e16b h1:wDUNC2eKiL35DbLvsDhiblTUXHxcOPwQSCzi7xpQUN4=
github.com/hako/durafmt v0.0.0-20210608085754-5c1018a4e16b/go.mod h1:VzxiSdG6j1pi7rwGm/xYI5RbtpBgM8sARDXlvEvxlu0=
github.com/hashicorp/consul/api v1.11.0/go.mod h1:XjsvQN+RJGWI2TWy1/kqaE16HrR2J/FWgkYjdZQsX9M=
@@ -1512,10 +1512,10 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
-github.com/opencontainers/runtime-spec v1.1.0 h1:HHUyrt9mwHUjtasSbXSMvs4cyFxh+Bll4AjJ9odEGpg=
-github.com/opencontainers/runtime-spec v1.1.0/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0=
-github.com/opencontainers/selinux v1.11.0 h1:+5Zbo97w3Lbmb3PeqQtpmTkMwsW5nRI3YaLpt7tQ7oU=
-github.com/opencontainers/selinux v1.11.0/go.mod h1:E5dMC3VPuVvVHDYmi78qvhJp8+M586T4DlDRYpFkyec=
+github.com/opencontainers/runtime-spec v1.2.0 h1:z97+pHb3uELt/yiAWD691HNHQIF07bE7dzrbT927iTk=
+github.com/opencontainers/runtime-spec v1.2.0/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0=
+github.com/opencontainers/selinux v1.11.1 h1:nHFvthhM0qY8/m+vfhJylliSshm8G1jJ2jDMcgULaH8=
+github.com/opencontainers/selinux v1.11.1/go.mod h1:E5dMC3VPuVvVHDYmi78qvhJp8+M586T4DlDRYpFkyec=
github.com/openvex/go-vex v0.2.5 h1:41utdp2rHgAGCsG+UbjmfMG5CWQxs15nGqir1eRgSrQ=
github.com/openvex/go-vex v0.2.5/go.mod h1:j+oadBxSUELkrKh4NfNb+BPo77U3q7gdKME88IO/0Wo=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
@@ -1570,8 +1570,8 @@ github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXP
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
github.com/prometheus/client_golang v1.1.0/go.mod h1:I1FGZT9+L76gKKOs5djB6ezCbFQP1xR9D75/vuwEF3g=
github.com/prometheus/client_golang v1.4.0/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU=
-github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE=
-github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho=
+github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q=
+github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
@@ -1582,8 +1582,8 @@ github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQy
github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
github.com/prometheus/common v0.6.0/go.mod h1:eBmuwkDJBwy6iBfxCBob6t6dR6ENT/y+J+Zk0j9GMYc=
github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4=
-github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc=
-github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8=
+github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io=
+github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I=
github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
github.com/prometheus/procfs v0.0.3/go.mod h1:4A/X28fw3Fc593LaREMrKMqOKvUAntwMDaekg4FpcdQ=
@@ -1782,16 +1782,16 @@ go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
-go.opentelemetry.io/contrib/detectors/gcp v1.29.0 h1:TiaiXB4DpGD3sdzNlYQxruQngn5Apwzi1X0DRhuGvDQ=
-go.opentelemetry.io/contrib/detectors/gcp v1.29.0/go.mod h1:GW2aWZNwR2ZxDLdv8OyC2G8zkRoQBuURgV7RPQgcPoU=
-go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 h1:r6I7RJCN86bpD/FQwedZ0vSixDpwuWREjW9oRMsmqDc=
-go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0/go.mod h1:B9yO6b04uB80CzjedvewuqDhxJxi11s7/GtiGa8bAjI=
+go.opentelemetry.io/contrib/detectors/gcp v1.31.0 h1:G1JQOreVrfhRkner+l4mrGxmfqYCAuy76asTDAo0xsA=
+go.opentelemetry.io/contrib/detectors/gcp v1.31.0/go.mod h1:tzQL6E1l+iV44YFTkcAeNQqzXUiekSYP9jjJjXwEd00=
+go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.58.0 h1:PS8wXpbyaDJQ2VDHHncMe9Vct0Zn1fEjpsjrLxGJoSc=
+go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.58.0/go.mod h1:HDBUsEjOuRC0EzKZ1bSaRGZWUBAzo+MhAcUUORSr4D0=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 h1:yd02MEjBdJkG3uabWP9apV+OuWRIXGDuJEUJbOHmCFU=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0/go.mod h1:umTcuxiv1n/s/S6/c2AT/g2CQ7u5C59sHDNmfSwgz7Q=
go.opentelemetry.io/otel v1.33.0 h1:/FerN9bax5LoK51X/sI0SVYrjSE0/yUL7DpxW4K3FWw=
go.opentelemetry.io/otel v1.33.0/go.mod h1:SUUkR6csvUQl+yjReHu5uM3EtVV7MBm5FHKRlNx4I8I=
-go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.28.0 h1:3Q/xZUyC1BBkualc9ROb4G8qkH90LXEIICcs5zv1OYY=
-go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.28.0/go.mod h1:s75jGIWA9OfCMzF0xr+ZgfrB5FEbbV7UuYo32ahUiFI=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 h1:Vh5HayB/0HHfOQA7Ctx69E/Y/DcQSMPpKANYVMQ7fBA=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0/go.mod h1:cpgtDBaqD/6ok/UG0jT15/uKjAY8mRA53diogHBg3UI=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.33.0 h1:wpMfgF8E1rkrT1Z6meFh1NDtownE9Ii3n3X2GJYjsaU=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.33.0/go.mod h1:wAy0T/dUbs468uOlkT31xjvqQgEVXv58BRFWEgn5v/0=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.29.0 h1:WDdP9acbMYjbKIyJUhTvtzj601sVJOqgWdUxSdR/Ysc=
@@ -1800,15 +1800,15 @@ go.opentelemetry.io/otel/metric v1.33.0 h1:r+JOocAyeRVXD8lZpjdQjzMadVZp2M4WmQ+5W
go.opentelemetry.io/otel/metric v1.33.0/go.mod h1:L9+Fyctbp6HFTddIxClbQkjtubW6O9QS3Ann/M82u6M=
go.opentelemetry.io/otel/sdk v1.33.0 h1:iax7M131HuAm9QkZotNHEfstof92xM+N8sr3uHXc2IM=
go.opentelemetry.io/otel/sdk v1.33.0/go.mod h1:A1Q5oi7/9XaMlIWzPSxLRWOI8nG3FnzHJNbiENQuihM=
-go.opentelemetry.io/otel/sdk/metric v1.29.0 h1:K2CfmJohnRgvZ9UAj2/FhIf/okdWcNdBwe1m8xFXiSY=
-go.opentelemetry.io/otel/sdk/metric v1.29.0/go.mod h1:6zZLdCl2fkauYoZIOn/soQIDSWFmNSRcICarHfuhNJQ=
+go.opentelemetry.io/otel/sdk/metric v1.31.0 h1:i9hxxLJF/9kkvfHppyLL55aW7iIJz4JjxTeYusH7zMc=
+go.opentelemetry.io/otel/sdk/metric v1.31.0/go.mod h1:CRInTMVvNhUKgSAMbKyTMxqOBC0zgyxzW55lZzX43Y8=
go.opentelemetry.io/otel/trace v1.33.0 h1:cCJuF7LRjUFso9LPnEAHJDB2pqzp+hbO8eu1qqW2d/s=
go.opentelemetry.io/otel/trace v1.33.0/go.mod h1:uIcdVUZMpTAmz0tI1z04GoVSezK37CbGV4fr1f2nBck=
go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI=
go.opentelemetry.io/proto/otlp v0.15.0/go.mod h1:H7XAot3MsfNsj7EXtrA2q5xSNQ10UqI405h3+duxN4U=
go.opentelemetry.io/proto/otlp v0.19.0/go.mod h1:H7XAot3MsfNsj7EXtrA2q5xSNQ10UqI405h3+duxN4U=
-go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0=
-go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8=
+go.opentelemetry.io/proto/otlp v1.4.0 h1:TA9WRvW6zMwP+Ssb6fLoUIuirti1gGbP28GcKG1jgeg=
+go.opentelemetry.io/proto/otlp v1.4.0/go.mod h1:PPBWZIP98o2ElSqI35IHfu7hIhSwvc5N38Jw8pXuGFY=
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
@@ -2006,8 +2006,8 @@ golang.org/x/oauth2 v0.4.0/go.mod h1:RznEsdpjGAINPTOF0UH/t+xJ75L18YO3Ho6Pyn+uRec
golang.org/x/oauth2 v0.5.0/go.mod h1:9/XBHVqLaWO3/BRHs5jbpYCnOZVjj5V0ndyaAM7KB4I=
golang.org/x/oauth2 v0.6.0/go.mod h1:ycmewcwgD4Rpr3eZJLSB4Kyyljb3qDh40vJ8STE5HKw=
golang.org/x/oauth2 v0.7.0/go.mod h1:hPLQkd9LyjfXTiRohC/41GhcFqxisoUQ99sCUOHO9x4=
-golang.org/x/oauth2 v0.25.0 h1:CY4y7XT9v0cRI9oupztF8AgiIu99L/ksR/Xp/6jrZ70=
-golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
+golang.org/x/oauth2 v0.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M=
+golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -2526,9 +2526,11 @@ google.golang.org/grpc v1.52.3/go.mod h1:pu6fVzoFb+NBYNAvQL08ic+lvB2IojljRYuun5v
google.golang.org/grpc v1.53.0/go.mod h1:OnIrk0ipVdj4N5d9IUoFUx72/VlD7+jUsHwZgwSMQpw=
google.golang.org/grpc v1.54.0/go.mod h1:PUSEXI6iWghWaB6lXM4knEgpJNu2qUcKfDtNci3EC2g=
google.golang.org/grpc v1.56.3/go.mod h1:I9bI3vqKfayGqPUAwGdOSu7kt6oIJLixfffKrpXqQ9s=
-google.golang.org/grpc v1.67.3 h1:OgPcDAFKHnH8X3O4WcO4XUc8GRDeKsKReqbQtiCj7N8=
-google.golang.org/grpc v1.67.3/go.mod h1:YGaHCc6Oap+FzBJTZLBzkGSYt/cvGPFTPxkn7QfSU8s=
+google.golang.org/grpc v1.68.1 h1:oI5oTa11+ng8r8XMMN7jAOmWfPZWbYpCFaMUTACxkM0=
+google.golang.org/grpc v1.68.1/go.mod h1:+q1XYFJjShcqn0QZHvCyeR4CXPA+llXIeUIfIe00waw=
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw=
+google.golang.org/grpc/stats/opentelemetry v0.0.0-20241028142157-ada6787961b3 h1:hUfOButuEtpc0UvYiaYRbNwxVYr0mQQOWq6X8beJ9Gc=
+google.golang.org/grpc/stats/opentelemetry v0.0.0-20241028142157-ada6787961b3/go.mod h1:jzYlkSMbKypzuu6xoAEijsNVo9ZeDF1u/zCfFgsx7jg=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
@@ -2614,6 +2616,8 @@ k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff h1:/usPimJzUKKu+m+TE36gUy
k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff/go.mod h1:5jIi+8yX4RIb8wk3XwBo5Pq2ccx4FP10ohkbSKCZoK8=
k8s.io/kubectl v0.32.3 h1:VMi584rbboso+yjfv0d8uBHwwxbC438LKq+dXd5tOAI=
k8s.io/kubectl v0.32.3/go.mod h1:6Euv2aso5GKzo/UVMacV6C7miuyevpfI91SvBvV9Zdg=
+k8s.io/kubernetes v1.33.0 h1:BP5Y5yIzUZVeBuE/ESZvnw6TNxjXbLsCckIkljE+R0U=
+k8s.io/kubernetes v1.33.0/go.mod h1:2nWuPk0seE4+6sd0x60wQ6rYEXcV7SoeMbU0YbFm/5k=
k8s.io/metrics v0.32.3 h1:2vsBvw0v8rIIlczZ/lZ8Kcqk9tR6Fks9h+dtFNbc2a4=
k8s.io/metrics v0.32.3/go.mod h1:9R1Wk5cb+qJpCQon9h52mgkVCcFeYxcY+YkumfwHVCU=
k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 h1:M3sRQVHv7vB20Xc2ybTt7ODCeFj6JSWYFzOFnYeS6Ro=
@@ -2684,10 +2688,10 @@ rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 h1:/Rv+M11QRah1itp8VhT6HoVx1Ray9eB4DBr+K+/sCJ8=
sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3/go.mod h1:18nIHnGi6636UCz6m8i4DhaJ65T6EruyzmoQqI2BVDo=
-sigs.k8s.io/kustomize/api v0.18.0 h1:hTzp67k+3NEVInwz5BHyzc9rGxIauoXferXyjv5lWPo=
-sigs.k8s.io/kustomize/api v0.18.0/go.mod h1:f8isXnX+8b+SGLHQ6yO4JG1rdkZlvhaCf/uZbLVMb0U=
-sigs.k8s.io/kustomize/kyaml v0.18.1 h1:WvBo56Wzw3fjS+7vBjN6TeivvpbW9GmRaWZ9CIVmt4E=
-sigs.k8s.io/kustomize/kyaml v0.18.1/go.mod h1:C3L2BFVU1jgcddNBE1TxuVLgS46TjObMwW5FT9FcjYo=
+sigs.k8s.io/kustomize/api v0.19.0 h1:F+2HB2mU1MSiR9Hp1NEgoU2q9ItNOaBJl0I4Dlus5SQ=
+sigs.k8s.io/kustomize/api v0.19.0/go.mod h1:/BbwnivGVcBh1r+8m3tH1VNxJmHSk1PzP5fkP6lbL1o=
+sigs.k8s.io/kustomize/kyaml v0.19.0 h1:RFge5qsO1uHhwJsu3ipV7RNolC7Uozc0jUBC/61XSlA=
+sigs.k8s.io/kustomize/kyaml v0.19.0/go.mod h1:FeKD5jEOH+FbZPpqUghBP8mrLjJ3+zD3/rf9NNu1cwY=
sigs.k8s.io/randfill v0.0.0-20250304075658-069ef1bbf016/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=
sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU=
sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=
diff --git a/internal/client/config.go b/internal/client/config.go
index de27f66c..d3f629d0 100644
--- a/internal/client/config.go
+++ b/internal/client/config.go
@@ -6,14 +6,12 @@ package client
import (
"errors"
"fmt"
- "log/slog"
"net/http"
"net/url"
"strings"
"sync"
"time"
- "github.com/derailed/k9s/internal/slogs"
"k8s.io/cli-runtime/pkg/genericclioptions"
restclient "k8s.io/client-go/rest"
"k8s.io/client-go/tools/clientcmd"
@@ -51,7 +49,6 @@ func (c *Config) CallTimeout() time.Duration {
if err != nil {
return DefaultCallTimeoutDuration
}
- slog.Debug("APIServer timeout", slogs.Duration, dur)
return dur
}
diff --git a/internal/client/gvrs.go b/internal/client/gvrs.go
index 34b6709a..ada91c79 100644
--- a/internal/client/gvrs.go
+++ b/internal/client/gvrs.go
@@ -37,6 +37,8 @@ var (
PdbGVR = NewGVR("policy/v1/poddisruptionbudgets")
PspGVR = NewGVR("policy/v1beta1/podsecuritypolicies")
+ IngGVR = NewGVR("networking.k8s.io/v1/ingresses")
+
// Metrics...
NmxGVR = NewGVR("metrics.k8s.io/v1beta1/nodes")
PmxGVR = NewGVR("metrics.k8s.io/v1beta1/pods")
diff --git a/internal/config/json/schemas/skin.json b/internal/config/json/schemas/skin.json
index 0dd6c07f..6c4a74aa 100644
--- a/internal/config/json/schemas/skin.json
+++ b/internal/config/json/schemas/skin.json
@@ -28,7 +28,10 @@
"type": "object",
"properties": {
"fgColor": {"type": "string"},
- "sectionColor": {"type": "string"}
+ "sectionColor": {"type": "string"},
+ "k9sRevColor": {"type": "string"},
+ "cpuColor": {"type": "string"},
+ "memColor": {"type": "string"}
}
},
"help": {
diff --git a/internal/config/styles.go b/internal/config/styles.go
index 9811ff4e..727b9227 100644
--- a/internal/config/styles.go
+++ b/internal/config/styles.go
@@ -185,6 +185,9 @@ type (
Info struct {
SectionColor Color `json:"sectionColor" yaml:"sectionColor"`
FgColor Color `json:"fgColor" yaml:"fgColor"`
+ CPUColor Color `json:"cpuColor" yaml:"cpuColor"`
+ MEMColor Color `json:"memColor" yaml:"memColor"`
+ K9sRevColor Color `json:"k9sRevColor" yaml:"k9sRevColor"`
}
// Border tracks border styles.
@@ -242,6 +245,8 @@ type (
DefaultDialColors Colors `json:"defaultDialColors" yaml:"defaultDialColors"`
DefaultChartColors Colors `json:"defaultChartColors" yaml:"defaultChartColors"`
ResourceColors map[string]Colors `json:"resourceColors" yaml:"resourceColors"`
+ FocusFgColor Color `yaml:"focusFgColor"`
+ FocusBgColor Color `yaml:"focusBgColor"`
}
)
@@ -293,6 +298,8 @@ func newCharts() Charts {
CPU: {Color("dodgerblue"), Color("darkslateblue")},
MEM: {Color("yellow"), Color("goldenrod")},
},
+ FocusFgColor: "white",
+ FocusBgColor: "aqua",
}
}
@@ -399,6 +406,9 @@ func newInfo() Info {
return Info{
SectionColor: "white",
FgColor: "orange",
+ CPUColor: "lawngreen",
+ MEMColor: "darkturquoise",
+ K9sRevColor: "aqua",
}
}
diff --git a/internal/dao/recorder.go b/internal/dao/recorder.go
new file mode 100644
index 00000000..bf4ef68d
--- /dev/null
+++ b/internal/dao/recorder.go
@@ -0,0 +1,300 @@
+package dao
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "log/slog"
+ "sync"
+ "time"
+
+ "github.com/derailed/k9s/internal"
+ "github.com/derailed/k9s/internal/client"
+ "github.com/derailed/k9s/internal/slogs"
+ v1 "k8s.io/api/core/v1"
+ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+ "k8s.io/apimachinery/pkg/labels"
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apimachinery/pkg/util/cache"
+)
+
+var MxRecorder *Recorder
+
+const (
+ seriesCacheSize = 600
+ seriesCacheExpiry = 3 * time.Hour
+ seriesRecordRate = 1 * time.Minute
+ nodeMetrics = "node"
+ podMetrics = "pod"
+)
+
+type MetricsChan chan TimeSeries
+
+type TimeSeries []Point
+
+type Point struct {
+ Time time.Time
+ Tags map[string]string
+ Value client.NodeMetrics
+}
+
+type Recorder struct {
+ conn client.Connection
+ series *cache.LRUExpireCache
+ mxChan MetricsChan
+ mx sync.RWMutex
+}
+
+func DialRecorder(c client.Connection) *Recorder {
+ if MxRecorder != nil {
+ return MxRecorder
+ }
+ MxRecorder = &Recorder{
+ conn: c,
+ series: cache.NewLRUExpireCache(seriesCacheSize),
+ }
+
+ return MxRecorder
+}
+
+func ResetRecorder(c client.Connection) {
+ MxRecorder = nil
+ DialRecorder(c)
+}
+
+func (r *Recorder) Clear() {
+ r.mx.Lock()
+ defer r.mx.Unlock()
+
+ kk := r.series.Keys()
+ for _, k := range kk {
+ r.series.Remove(k)
+ }
+}
+
+func (r *Recorder) dispatchSeries(kind, ns string) {
+ if r.mxChan == nil {
+ return
+ }
+ kk := r.series.Keys()
+ hour := time.Now().Add(-1 * time.Hour)
+ ts := make(TimeSeries, 0, len(kk))
+ for _, k := range kk {
+ if v, ok := r.series.Get(k); ok {
+ if pt, cool := v.(Point); cool {
+ if pt.Tags["type"] != kind || pt.Time.Sub(hour) < 0 {
+ continue
+ }
+ switch kind {
+ case nodeMetrics:
+ ts = append(ts, pt)
+ case podMetrics:
+ if client.IsAllNamespaces(ns) || pt.Tags["namespace"] == ns {
+ ts = append(ts, pt)
+ }
+ }
+ }
+ }
+ }
+ if len(ts) > 0 {
+ r.mxChan <- ts
+ }
+}
+
+func (r *Recorder) Watch(ctx context.Context, ns string) MetricsChan {
+ r.mx.Lock()
+ if r.mxChan != nil {
+ close(r.mxChan)
+ r.mxChan = nil
+ }
+ r.mxChan = make(MetricsChan, 2)
+ r.mx.Unlock()
+
+ go func() {
+ kind := podMetrics
+ if client.IsAllNamespaces(ns) {
+ kind = nodeMetrics
+ }
+ switch kind {
+ case podMetrics:
+ if err := r.recordPodMetrics(ctx, ns); err != nil {
+ slog.Error("Record pod metrics failed", slogs.Error, err)
+ }
+ case nodeMetrics:
+ if err := r.recordNodeMetrics(ctx); err != nil {
+ slog.Error("Record node metrics failed", slogs.Error, err)
+ }
+ }
+ r.dispatchSeries(kind, ns)
+ <-ctx.Done()
+ r.mx.Lock()
+ if r.mxChan != nil {
+ close(r.mxChan)
+ r.mxChan = nil
+ }
+ r.mx.Unlock()
+ }()
+
+ return r.mxChan
+}
+
+func (r *Recorder) Record(ctx context.Context) error {
+ if err := r.recordNodeMetrics(ctx); err != nil {
+ return err
+ }
+ return r.recordPodMetrics(ctx, client.NamespaceAll)
+}
+
+func (r *Recorder) recordNodeMetrics(ctx context.Context) error {
+ f, ok := ctx.Value(internal.KeyFactory).(Factory)
+ if !ok {
+ return errors.New("expecting factory in context")
+ }
+ nn, err := FetchNodes(ctx, f, "")
+ if err != nil {
+ return err
+ }
+
+ go func() {
+ r.recordClusterMetrics(ctx, nn)
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-time.After(seriesRecordRate):
+ r.recordClusterMetrics(ctx, nn)
+ }
+ }
+ }()
+
+ return nil
+}
+
+func (r *Recorder) recordClusterMetrics(ctx context.Context, nn *v1.NodeList) {
+ dial := client.DialMetrics(r.conn)
+ nmx, err := dial.FetchNodesMetrics(ctx)
+ if err != nil {
+ slog.Error("Fetch node metrics failed", slogs.Error, err)
+ return
+ }
+
+ mx := make(client.NodesMetrics, len(nn.Items))
+ dial.NodesMetrics(nn, nmx, mx)
+ var cmx client.NodeMetrics
+ for _, m := range mx {
+ cmx.CurrentCPU += m.CurrentCPU
+ cmx.CurrentMEM += m.CurrentMEM
+ cmx.AllocatableCPU += m.AllocatableCPU
+ cmx.AllocatableMEM += m.AllocatableMEM
+ cmx.TotalCPU += m.TotalCPU
+ cmx.TotalMEM += m.TotalMEM
+ }
+ pt := Point{
+ Time: time.Now(),
+ Value: cmx,
+ Tags: map[string]string{
+ "type": nodeMetrics,
+ },
+ }
+ if len(nn.Items) > 0 {
+ r.series.Add(pt.Time, pt, seriesCacheExpiry)
+ }
+ r.mx.Lock()
+ defer r.mx.Unlock()
+ if r.mxChan != nil {
+ r.mxChan <- TimeSeries{pt}
+ }
+}
+
+func (r *Recorder) recordPodMetrics(ctx context.Context, ns string) error {
+ go func() {
+ if err := r.recordPodsMetrics(ctx, ns); err != nil {
+ slog.Error("Record pod metrics failed", slogs.Error, err)
+ }
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-time.After(seriesRecordRate):
+ // case <-time.After(5 * time.Second):
+ if err := r.recordPodsMetrics(ctx, ns); err != nil {
+ slog.Error("Record pod metrics failed", slogs.Error, err)
+ }
+ }
+ }
+ }()
+
+ return nil
+}
+
+func (r *Recorder) recordPodsMetrics(ctx context.Context, ns string) error {
+ f, ok := ctx.Value(internal.KeyFactory).(Factory)
+ if !ok {
+ return errors.New("expecting factory in context")
+ }
+ pp, err := FetchPods(ctx, f, ns)
+ if err != nil {
+ return err
+ }
+
+ pt := Point{
+ Time: time.Now(),
+ Value: client.NodeMetrics{},
+ Tags: map[string]string{
+ "namespace": ns,
+ "type": podMetrics,
+ },
+ }
+ dial := client.DialMetrics(r.conn)
+ for i := range pp.Items {
+ p := pp.Items[i]
+ fqn := client.FQN(p.Namespace, p.Name)
+ pmx, err := dial.FetchPodMetrics(ctx, fqn)
+ if err != nil {
+ continue
+ }
+ for _, c := range pmx.Containers {
+ pt.Value.CurrentCPU += c.Usage.Cpu().MilliValue()
+ pt.Value.CurrentMEM += client.ToMB(c.Usage.Memory().Value())
+ }
+ }
+ if len(pp.Items) > 0 {
+ pt.Value.AllocatableCPU = pt.Value.CurrentCPU
+ pt.Value.AllocatableMEM = pt.Value.CurrentMEM
+ r.series.Add(pt.Time, pt, seriesCacheExpiry)
+ r.mx.Lock()
+ defer r.mx.Unlock()
+ if r.mxChan != nil {
+ r.mxChan <- TimeSeries{pt}
+ }
+ }
+
+ return nil
+}
+
+// FetchPods retrieves all pods in a given namespace.
+func FetchPods(_ context.Context, f Factory, ns string) (*v1.PodList, error) {
+ auth, err := f.Client().CanI(ns, client.PodGVR, "pods", []string{client.ListVerb})
+ if err != nil {
+ return nil, err
+ }
+ if !auth {
+ return nil, fmt.Errorf("user is not authorized to list pods")
+ }
+
+ oo, err := f.List(client.PodGVR, ns, false, labels.Everything())
+ if err != nil {
+ return nil, err
+ }
+ pp := make([]v1.Pod, 0, len(oo))
+ for _, o := range oo {
+ var pod v1.Pod
+ err = runtime.DefaultUnstructuredConverter.FromUnstructured(o.(*unstructured.Unstructured).Object, &pod)
+ if err != nil {
+ return nil, err
+ }
+ pp = append(pp, pod)
+ }
+
+ return &v1.PodList{Items: pp}, nil
+}
diff --git a/internal/model/flash.go b/internal/model/flash.go
index 25150b00..b98669c2 100644
--- a/internal/model/flash.go
+++ b/internal/model/flash.go
@@ -98,7 +98,7 @@ func (f *Flash) Warnf(fmat string, args ...any) {
// Err displays an error flash message.
func (f *Flash) Err(err error) {
- slog.Error("Flash failed", slogs.Error, err)
+ slog.Error("Flash error", slogs.Error, err)
f.SetMessage(FlashErr, err.Error())
}
@@ -110,7 +110,7 @@ func (f *Flash) Errf(fmat string, args ...any) {
err = e
}
}
- slog.Error("Flashing error",
+ slog.Error("Flash error",
slogs.Error, err,
slogs.Message, fmt.Sprintf(fmat, args...),
)
diff --git a/internal/model/pulse.go b/internal/model/pulse.go
index 47484099..a36c6af6 100644
--- a/internal/model/pulse.go
+++ b/internal/model/pulse.go
@@ -1,23 +1,17 @@
-// SPDX-License-Identifier: Apache-2.0
-// Copyright Authors of K9s
-
package model
import (
"context"
"fmt"
- "log/slog"
- "sync/atomic"
"time"
"github.com/derailed/k9s/internal"
+ "github.com/derailed/k9s/internal/client"
"github.com/derailed/k9s/internal/dao"
"github.com/derailed/k9s/internal/health"
- "github.com/derailed/k9s/internal/slogs"
- "k8s.io/apimachinery/pkg/runtime"
)
-const defaultRefreshRate = 5 * time.Second
+const defaultRefreshRate = 1 * time.Minute
// PulseListener represents a health model listener.
type PulseListener interface {
@@ -26,100 +20,48 @@ type PulseListener interface {
// TreeFailed notifies the health check failed.
PulseFailed(error)
+
+ // MetricsChanged update metrics time series.
+ MetricsChanged(dao.TimeSeries)
}
// Pulse tracks multiple resources health.
type Pulse struct {
- gvr string
+ gvr *client.GVR
namespace string
- inUpdate int32
listeners []PulseListener
refreshRate time.Duration
health *PulseHealth
- data health.Checks
}
// NewPulse returns a new pulse.
-func NewPulse(gvr string) *Pulse {
+func NewPulse(gvr *client.GVR) *Pulse {
return &Pulse{
gvr: gvr,
refreshRate: defaultRefreshRate,
}
}
+type HealthChan chan HealthPoint
+
// Watch monitors pulses.
-func (p *Pulse) Watch(ctx context.Context) {
- p.Refresh(ctx)
- go p.updater(ctx)
-}
-
-func (p *Pulse) updater(ctx context.Context) {
- defer slog.Debug("Pulse canceled", slogs.GVR, p.gvr)
-
- rate := initRefreshRate
- for {
- select {
- case <-ctx.Done():
- return
- case <-time.After(rate):
- rate = p.refreshRate
- p.refresh(ctx)
- }
- }
-}
-
-// Refresh update the model now.
-func (p *Pulse) Refresh(ctx context.Context) {
- for _, d := range p.data {
- p.firePulseChanged(d)
- }
- p.refresh(ctx)
-}
-
-func (p *Pulse) refresh(ctx context.Context) {
- if !atomic.CompareAndSwapInt32(&p.inUpdate, 0, 1) {
- slog.Debug("Dropping update...")
- return
- }
- defer atomic.StoreInt32(&p.inUpdate, 0)
-
- if err := p.reconcile(ctx); err != nil {
- slog.Error("Reconcile failed", slogs.Error, err)
- p.firePulseFailed(err)
- return
- }
-}
-
-func (p *Pulse) list(ctx context.Context) ([]runtime.Object, error) {
+func (p *Pulse) Watch(ctx context.Context) (HealthChan, dao.MetricsChan, error) {
f, ok := ctx.Value(internal.KeyFactory).(dao.Factory)
if !ok {
- return nil, fmt.Errorf("expected Factory in context but got %T", ctx.Value(internal.KeyFactory))
+ return nil, nil, fmt.Errorf("expected Factory in context but got %T", ctx.Value(internal.KeyFactory))
}
if p.health == nil {
p.health = NewPulseHealth(f)
}
- ctx = context.WithValue(ctx, internal.KeyFields, "")
- ctx = context.WithValue(ctx, internal.KeyWithMetrics, false)
- return p.health.List(ctx, p.namespace)
+
+ healthChan := p.health.Watch(ctx, p.namespace)
+ metricsChan := dao.DialRecorder(f.Client()).Watch(ctx, p.namespace)
+
+ return healthChan, metricsChan, nil
}
-func (p *Pulse) reconcile(ctx context.Context) error {
- oo, err := p.list(ctx)
- if err != nil {
- return err
- }
-
- p.data = health.Checks{}
- for _, o := range oo {
- c, ok := o.(*health.Check)
- if !ok {
- return fmt.Errorf("expecting health check but got %T", o)
- }
- p.data = append(p.data, c)
- p.firePulseChanged(c)
- }
- return nil
-}
+// Refresh update the model now.
+func (*Pulse) Refresh(context.Context) {}
// GetNamespace returns the model namespace.
func (p *Pulse) GetNamespace() string {
@@ -128,6 +70,9 @@ func (p *Pulse) GetNamespace() string {
// SetNamespace sets up model namespace.
func (p *Pulse) SetNamespace(ns string) {
+ if client.IsAllNamespaces(ns) {
+ ns = client.BlankNamespace
+ }
p.namespace = ns
}
@@ -150,15 +95,3 @@ func (p *Pulse) RemoveListener(l PulseListener) {
p.listeners = append(p.listeners[:victim], p.listeners[victim+1:]...)
}
}
-
-func (p *Pulse) firePulseChanged(check *health.Check) {
- for _, l := range p.listeners {
- l.PulseChanged(check)
- }
-}
-
-func (p *Pulse) firePulseFailed(err error) {
- for _, l := range p.listeners {
- l.PulseFailed(err)
- }
-}
diff --git a/internal/model/pulse_health.go b/internal/model/pulse_health.go
index 3f13bf4e..b43251d3 100644
--- a/internal/model/pulse_health.go
+++ b/internal/model/pulse_health.go
@@ -1,23 +1,66 @@
-// SPDX-License-Identifier: Apache-2.0
-// Copyright Authors of K9s
-
package model
import (
"context"
- "fmt"
"log/slog"
+ "time"
+ "github.com/derailed/k9s/internal"
"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/derailed/k9s/internal/slogs"
- metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
- "k8s.io/apimachinery/pkg/runtime"
)
+const pulseRate = 15 * time.Second
+
+type HealthPoint struct {
+ GVR *client.GVR
+ Total, Faults int
+}
+
+type GVRs []*client.GVR
+
+var PulseGVRs = client.GVRs{
+ client.NodeGVR,
+ client.NsGVR,
+ client.SvcGVR,
+ client.EvGVR,
+
+ client.PodGVR,
+ client.DpGVR,
+ client.StsGVR,
+ client.DsGVR,
+
+ client.JobGVR,
+ client.CjGVR,
+ client.PvGVR,
+ client.PvcGVR,
+
+ client.HpaGVR,
+ client.IngGVR,
+ client.NpGVR,
+ client.SaGVR,
+}
+
+func (g GVRs) First() *client.GVR {
+ return g[0]
+}
+
+func (g GVRs) Last() *client.GVR {
+ return g[len(g)-1]
+}
+
+func (g GVRs) Index(gvr *client.GVR) int {
+ for i := range g {
+ if g[i] == gvr {
+ return i
+ }
+ }
+
+ return -1
+}
+
// PulseHealth tracks resources health.
type PulseHealth struct {
factory dao.Factory
@@ -25,88 +68,50 @@ type PulseHealth struct {
// NewPulseHealth returns a new instance.
func NewPulseHealth(f dao.Factory) *PulseHealth {
- return &PulseHealth{
- factory: f,
- }
+ return &PulseHealth{factory: f}
}
-// List returns a canned collection of resources health.
-func (h *PulseHealth) List(ctx context.Context, ns string) ([]runtime.Object, error) {
- gvrs := []*client.GVR{
- client.PodGVR,
- client.EvGVR,
- client.RsGVR,
- client.DpGVR,
- client.StsGVR,
- client.DsGVR,
- client.CjGVR,
- client.PcGVR,
- }
+func (h *PulseHealth) Watch(ctx context.Context, ns string) HealthChan {
+ c := make(HealthChan, 2)
+ ctx = context.WithValue(ctx, internal.KeyWithMetrics, false)
- hh := make([]runtime.Object, 0, 10)
- for _, gvr := range gvrs {
- c, err := h.check(ctx, ns, gvr)
- if err != nil {
- return nil, err
+ go func(ctx context.Context, ns string, c HealthChan) {
+ if err := h.checkPulse(ctx, ns, c); err != nil {
+ slog.Error("Pulse check failed", slogs.Error, err)
}
- hh = append(hh, c)
- }
+ for {
+ select {
+ case <-ctx.Done():
+ close(c)
+ return
+ case <-time.After(pulseRate):
+ if err := h.checkPulse(ctx, ns, c); err != nil {
+ slog.Error("Pulse check failed", slogs.Error, err)
+ }
+ }
+ }
+ }(ctx, ns, c)
- mm, err := h.checkMetrics(ctx)
- if err != nil {
- return hh, err
- }
- for _, m := range mm {
- hh = append(hh, m)
- }
-
- return hh, nil
+ return c
}
-func (h *PulseHealth) checkMetrics(ctx context.Context) (health.Checks, error) {
- dial := client.DialMetrics(h.factory.Client())
-
- nn, err := dao.FetchNodes(ctx, h.factory, "")
- if err != nil {
- return nil, err
+func (h *PulseHealth) checkPulse(ctx context.Context, ns string, c HealthChan) error {
+ for _, gvr := range PulseGVRs {
+ check, err := h.check(ctx, ns, gvr)
+ if err != nil {
+ return err
+ }
+ c <- check
}
-
- nmx, err := dial.FetchNodesMetrics(ctx)
- if err != nil {
- slog.Error("Fetching metrics", slogs.Error, err)
- return nil, err
- }
-
- mx := make(client.NodesMetrics, len(nn.Items))
- dial.NodesMetrics(nn, nmx, mx)
-
- var ccpu, cmem, acpu, amem, tcpu, tmem int64
- for _, m := range mx {
- ccpu += m.CurrentCPU
- cmem += m.CurrentMEM
- acpu += m.AllocatableCPU
- amem += m.AllocatableMEM
- tcpu += m.TotalCPU
- tmem += m.TotalMEM
- }
- c1 := health.NewCheck(client.CpuGVR)
- c1.Set(health.S1, ccpu)
- c1.Set(health.S2, acpu)
- c1.Set(health.S3, tcpu)
- c2 := health.NewCheck(client.MemGVR)
- c2.Set(health.S1, cmem)
- c2.Set(health.S2, amem)
- c2.Set(health.S3, tmem)
-
- return health.Checks{c1, c2}, nil
+ return nil
}
-func (h *PulseHealth) check(ctx context.Context, ns string, gvr *client.GVR) (*health.Check, error) {
+func (h *PulseHealth) check(ctx context.Context, ns string, gvr *client.GVR) (HealthPoint, error) {
meta, ok := Registry[gvr]
if !ok {
meta = ResourceMeta{
- DAO: new(dao.Table),
- Renderer: new(render.Table),
+ DAO: &dao.Table{},
+ Renderer: &render.Generic{},
}
}
if meta.DAO == nil {
@@ -116,42 +121,13 @@ func (h *PulseHealth) check(ctx context.Context, ns string, gvr *client.GVR) (*h
meta.DAO.Init(h.factory, gvr)
oo, err := meta.DAO.List(ctx, ns)
if err != nil {
- return nil, err
+ return HealthPoint{}, err
}
- c := health.NewCheck(gvr)
-
- if meta.Renderer.IsGeneric() {
- table, ok := oo[0].(*metav1.Table)
- if !ok {
- return nil, fmt.Errorf("expecting a meta table but got %T", oo[0])
+ c := HealthPoint{GVR: gvr, Total: len(oo)}
+ for _, o := range oo {
+ if err := meta.Renderer.Healthy(ctx, o); err != nil {
+ c.Faults++
}
- 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 !model1.IsValid(ns, re.Header(ns), rows[i]) {
- c.Inc(health.S2)
- continue
- }
- c.Inc(health.S1)
- }
- c.Total(int64(len(table.Rows)))
- return c, nil
- }
- c.Total(int64(len(oo)))
- 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 !model1.IsValid(ns, re.Header(ns), rr[i]) {
- c.Inc(health.S2)
- continue
- }
- c.Inc(health.S1)
}
return c, nil
diff --git a/internal/model/registry.go b/internal/model/registry.go
index b8e9bd36..193de099 100644
--- a/internal/model/registry.go
+++ b/internal/model/registry.go
@@ -123,6 +123,9 @@ var Registry = map[*client.GVR]ResourceMeta{
client.PvcGVR: {
Renderer: new(render.PersistentVolumeClaim),
},
+ client.EvGVR: {
+ Renderer: new(render.Event),
+ },
// Apps...
client.DpGVR: {
diff --git a/internal/model1/types.go b/internal/model1/types.go
index b7b5004e..a3f77680 100644
--- a/internal/model1/types.go
+++ b/internal/model1/types.go
@@ -4,6 +4,8 @@
package model1
import (
+ "context"
+
"github.com/derailed/k9s/internal/config"
"github.com/derailed/tcell/v2"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -50,6 +52,9 @@ type Renderer interface {
// SetViewSetting sets custom view settings if any.
SetViewSetting(vs *config.ViewSetting)
+
+ // Healthy checks if the resource is healthy.
+ Healthy(ctx context.Context, o any) error
}
// Generic represents a generic resource.
diff --git a/internal/render/base.go b/internal/render/base.go
index f3f35eb3..acb32019 100644
--- a/internal/render/base.go
+++ b/internal/render/base.go
@@ -4,6 +4,7 @@
package render
import (
+ "context"
"log/slog"
"github.com/derailed/k9s/internal/config"
@@ -64,3 +65,8 @@ func (*Base) ColorerFunc() model1.ColorerFunc {
func (*Base) Happy(string, *model1.Row) bool {
return true
}
+
+// Healthy checks if the resource is healthy.
+func (*Base) Healthy(context.Context, any) error {
+ return nil
+}
diff --git a/internal/render/dir.go b/internal/render/dir.go
index 4ba6e922..a475e6ee 100644
--- a/internal/render/dir.go
+++ b/internal/render/dir.go
@@ -4,6 +4,7 @@
package render
import (
+ "context"
"fmt"
"os"
@@ -22,6 +23,11 @@ func (Dir) IsGeneric() bool {
return false
}
+// Healthy checks if the resource is healthy.
+func (Dir) Healthy(context.Context, any) error {
+ return nil
+}
+
// ColorerFunc colors a resource row.
func (Dir) ColorerFunc() model1.ColorerFunc {
return func(string, model1.Header, *model1.RowEvent) tcell.Color {
diff --git a/internal/render/ev.go b/internal/render/ev.go
new file mode 100644
index 00000000..ec7791b0
--- /dev/null
+++ b/internal/render/ev.go
@@ -0,0 +1,39 @@
+// Copyright Authors of K9s
+
+package render
+
+import (
+ "context"
+ "fmt"
+ "log/slog"
+
+ "github.com/derailed/k9s/internal/slogs"
+ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+ "k8s.io/apimachinery/pkg/runtime"
+ api "k8s.io/kubernetes/pkg/apis/core"
+)
+
+// Event renders a event resource to screen.
+type Event struct {
+ Table
+}
+
+// Healthy checks component health.
+func (*Event) Healthy(_ context.Context, o any) error {
+ u, ok := o.(*unstructured.Unstructured)
+ if !ok {
+ slog.Error("Expected Unstructured", slogs.Type, fmt.Sprintf("%T", o))
+ return nil
+ }
+ var ev api.Event
+ err := runtime.DefaultUnstructuredConverter.FromUnstructured(u.Object, &ev)
+ if err != nil {
+ slog.Error("Failed to convert unstructured to Node", slogs.Error, err)
+ return nil
+ }
+ if ev.Type != "Normal" {
+ return fmt.Errorf("event is not normal: %s", ev.Type)
+ }
+
+ return nil
+}
diff --git a/internal/render/helm/chart.go b/internal/render/helm/chart.go
index cf3adbc1..5d3483ef 100644
--- a/internal/render/helm/chart.go
+++ b/internal/render/helm/chart.go
@@ -4,13 +4,16 @@
package helm
import (
+ "context"
"fmt"
+ "log/slog"
"strconv"
"github.com/derailed/k9s/internal/client"
"github.com/derailed/k9s/internal/config"
"github.com/derailed/k9s/internal/model1"
"github.com/derailed/k9s/internal/render"
+ "github.com/derailed/k9s/internal/slogs"
"helm.sh/helm/v3/pkg/release"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
@@ -68,6 +71,16 @@ func (c Chart) Render(o any, _ string, r *model1.Row) error {
return nil
}
+// Healthy checks component health.
+func (c Chart) Healthy(_ context.Context, o any) error {
+ h, ok := o.(*ReleaseRes)
+ if !ok {
+ slog.Error("Expected *ReleaseRes, but got", slogs.Type, fmt.Sprintf("%T", o))
+ }
+
+ return c.diagnose(h.Release.Info.Status.String())
+}
+
func (Chart) diagnose(s string) error {
if s != "deployed" {
return fmt.Errorf("chart is in an invalid state")
diff --git a/internal/render/helm/history.go b/internal/render/helm/history.go
index 13d5188d..0af66a68 100644
--- a/internal/render/helm/history.go
+++ b/internal/render/helm/history.go
@@ -17,11 +17,6 @@ import (
// History renders a History chart to screen.
type History struct{}
-// Healthy checks component health.
-func (History) Healthy(context.Context, any) error {
- return nil
-}
-
func (History) SetViewSetting(*config.ViewSetting) {}
// IsGeneric identifies a generic handler.
@@ -67,6 +62,11 @@ func (c History) Render(o any, _ string, r *model1.Row) error {
return nil
}
+// Healthy checks component health.
+func (History) Healthy(context.Context, any) error {
+ return nil
+}
+
func (History) diagnose(string) error {
return nil
}
diff --git a/internal/render/node.go b/internal/render/node.go
index fc24e739..f1fd094c 100644
--- a/internal/render/node.go
+++ b/internal/render/node.go
@@ -4,14 +4,17 @@
package render
import (
+ "context"
"errors"
"fmt"
+ "log/slog"
"sort"
"strconv"
"strings"
"github.com/derailed/k9s/internal/client"
"github.com/derailed/k9s/internal/model1"
+ "github.com/derailed/k9s/internal/slogs"
"github.com/derailed/tview"
v1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
@@ -130,6 +133,25 @@ func (n Node) defaultRow(nwm *NodeWithMetrics, r *model1.Row) error {
return nil
}
+// Healthy checks component health.
+func (n Node) Healthy(_ context.Context, o any) error {
+ nwm, ok := o.(*NodeWithMetrics)
+ if !ok {
+ slog.Error("Expected *NodeWithMetrics", slogs.Type, fmt.Sprintf("%T", o))
+ return nil
+ }
+ var no v1.Node
+ err := runtime.DefaultUnstructuredConverter.FromUnstructured(nwm.Raw.Object, &no)
+ if err != nil {
+ slog.Error("Failed to convert unstructured to Node", slogs.Error, err)
+ return nil
+ }
+ ss := make([]string, 10)
+ status(no.Status.Conditions, no.Spec.Unschedulable, ss)
+
+ return n.diagnose(ss)
+}
+
func (Node) diagnose(ss []string) error {
if len(ss) == 0 {
return nil
diff --git a/internal/render/ns.go b/internal/render/ns.go
index e0fd0f29..9c4718a3 100644
--- a/internal/render/ns.go
+++ b/internal/render/ns.go
@@ -4,13 +4,16 @@
package render
import (
+ "context"
"errors"
"fmt"
"strings"
"github.com/derailed/k9s/internal/client"
"github.com/derailed/k9s/internal/model1"
+ "github.com/derailed/k9s/internal/slogs"
"github.com/derailed/tcell/v2"
+ "golang.org/x/exp/slog"
v1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
@@ -93,6 +96,23 @@ func (n Namespace) defaultRow(raw *unstructured.Unstructured, r *model1.Row) err
return nil
}
+// Healthy checks component health.
+func (n Namespace) Healthy(_ context.Context, o any) error {
+ res, ok := o.(*unstructured.Unstructured)
+ if !ok {
+ slog.Error("Expected *Unstructured, but got", slogs.Type, fmt.Sprintf("%T", o))
+ return nil
+ }
+ var ns v1.Namespace
+ err := runtime.DefaultUnstructuredConverter.FromUnstructured(res.Object, &ns)
+ if err != nil {
+ slog.Error("Failed to convert Unstructured to Namespace", slogs.Type, fmt.Sprintf("%T", o), slog.String("error", err.Error()))
+ return nil
+ }
+
+ return n.diagnose(ns.Status.Phase)
+}
+
func (Namespace) diagnose(phase v1.NamespacePhase) error {
if phase != v1.NamespaceActive && phase != v1.NamespaceTerminating {
return errors.New("namespace not ready")
diff --git a/internal/render/pod.go b/internal/render/pod.go
index 5aa5ca9a..fb024917 100644
--- a/internal/render/pod.go
+++ b/internal/render/pod.go
@@ -4,12 +4,15 @@
package render
import (
+ "context"
"fmt"
+ "log/slog"
"strconv"
"strings"
"github.com/derailed/k9s/internal/client"
"github.com/derailed/k9s/internal/model1"
+ "github.com/derailed/k9s/internal/slogs"
"github.com/derailed/tcell/v2"
"github.com/derailed/tview"
v1 "k8s.io/api/core/v1"
@@ -157,6 +160,10 @@ func (p *Pod) defaultRow(pwm *PodWithMetrics, row *model1.Row) error {
_, _, irc, _ := p.Statuses(st.InitContainerStatuses)
cr, _, rc, lr := p.Statuses(st.ContainerStatuses)
+ rcr, rcc := p.initContainerCounts(spec.InitContainers, st.InitContainerStatuses)
+ cr += rcr
+ cc := len(spec.Containers) + rcc
+
var ccmx []mv1beta1.ContainerMetrics
if pwm.MX != nil {
ccmx = pwm.MX.Containers
@@ -172,7 +179,7 @@ func (p *Pod) defaultRow(pwm *PodWithMetrics, row *model1.Row) error {
n,
computeVulScore(ns, pwm.Raw.GetLabels(), spec),
"●",
- strconv.Itoa(cr) + "/" + strconv.Itoa(len(spec.Containers)),
+ strconv.Itoa(cr) + "/" + strconv.Itoa(cc),
phase,
strconv.Itoa(rc + irc),
ToAge(lr),
@@ -191,13 +198,41 @@ func (p *Pod) defaultRow(pwm *PodWithMetrics, row *model1.Row) error {
asReadinessGate(spec, &st),
p.mapQOS(st.QOSClass),
mapToStr(pwm.Raw.GetLabels()),
- AsStatus(p.diagnose(phase, cr, len(st.ContainerStatuses))),
+ AsStatus(p.diagnose(phase, cr, cc)),
ToAge(pwm.Raw.GetCreationTimestamp()),
}
return nil
}
+// Healthy checks component health.
+func (p Pod) Healthy(_ context.Context, o any) error {
+ pwm, ok := o.(*PodWithMetrics)
+ if !ok {
+ slog.Error("Expected *PodWithMetrics", slogs.Type, fmt.Sprintf("%T", o))
+ return nil
+ }
+ var st v1.PodStatus
+ if err := runtime.DefaultUnstructuredConverter.FromUnstructured(pwm.Raw.Object["status"].(map[string]any), &st); err != nil {
+ slog.Error("Failed to convert unstructured to PodState", slogs.Error, err)
+ return nil
+ }
+ spec := new(v1.PodSpec)
+ if err := runtime.DefaultUnstructuredConverter.FromUnstructured(pwm.Raw.Object["spec"].(map[string]any), spec); err != nil {
+ slog.Error("Failed to convert unstructured to PodSpec", slogs.Error, err)
+ return nil
+ }
+ dt := pwm.Raw.GetDeletionTimestamp()
+ phase := p.Phase(dt, spec, &st)
+ cr, _, _, _ := p.Statuses(st.ContainerStatuses)
+
+ rcr, rcc := p.initContainerCounts(spec.InitContainers, st.InitContainerStatuses)
+ cr += rcr
+ cc := len(spec.Containers) + rcc
+
+ return p.diagnose(phase, cr, cc)
+}
+
func (*Pod) diagnose(phase string, cr, ct int) error {
if phase == Completed {
return nil
@@ -358,6 +393,19 @@ func (*Pod) Statuses(cc []v1.ContainerStatus) (cr, ct, rc int, latest metav1.Tim
return
}
+func (*Pod) initContainerCounts(cc []v1.Container, cos []v1.ContainerStatus) (ready, total int) {
+ for i := range cos {
+ if !restartableInitCO(cc[i].RestartPolicy) {
+ continue
+ }
+ total++
+ if cos[i].Ready {
+ ready++
+ }
+ }
+ return
+}
+
// Phase reports the given pod phase.
func (p *Pod) Phase(dt *metav1.Time, spec *v1.PodSpec, st *v1.PodStatus) string {
status := string(st.Phase)
diff --git a/internal/render/pod_test.go b/internal/render/pod_test.go
index 6da164dd..63251daa 100644
--- a/internal/render/pod_test.go
+++ b/internal/render/pod_test.go
@@ -211,7 +211,7 @@ func TestPodSidecarRender(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, "default/sleep", r.ID)
- e := model1.Fields{"default", "sleep", "0", "●", "1/1", "Running", "0", "", "100", "40", "50:250", "50:80", "200", "40", "80", "50", "10.244.0.8", "kind-control-plane", "default", ""}
+ e := model1.Fields{"default", "sleep", "0", "●", "2/2", "Running", "0", "", "100", "40", "50:250", "50:80", "200", "40", "80", "50", "10.244.0.8", "kind-control-plane", "default", ""}
assert.Equal(t, e, r.Fields[:20])
}
diff --git a/internal/render/table.go b/internal/render/table.go
index ab9d451c..3021dc45 100644
--- a/internal/render/table.go
+++ b/internal/render/table.go
@@ -14,10 +14,13 @@ import (
"github.com/derailed/k9s/internal/model1"
"k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/util/sets"
)
const ageTableCol = "Age"
+var ageCols = sets.New("Last Seen", "First Seen", "Age")
+
// Table renders a tabular resource to screen.
type Table struct {
Base
@@ -70,7 +73,8 @@ func (t *Table) defaultHeader() model1.Header {
t.setAgeIndex(i)
continue
}
- h = append(h, model1.HeaderColumn{Name: strings.ToUpper(c.Name)})
+ timeCol := ageCols.Has(c.Name)
+ h = append(h, model1.HeaderColumn{Name: strings.ToUpper(c.Name), Attrs: model1.Attrs{Time: timeCol}})
}
if t.getAgeIndex() > 0 {
h = append(h, model1.HeaderColumn{Name: "AGE", Attrs: model1.Attrs{Time: true}})
diff --git a/internal/render/table_int_test.go b/internal/render/table_int_test.go
new file mode 100644
index 00000000..981e074b
--- /dev/null
+++ b/internal/render/table_int_test.go
@@ -0,0 +1,72 @@
+package render
+
+import (
+ "testing"
+
+ "github.com/derailed/k9s/internal/model1"
+ "github.com/stretchr/testify/assert"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+func Test_defaultHeader(t *testing.T) {
+ uu := map[string]struct {
+ cdefs []metav1.TableColumnDefinition
+ e model1.Header
+ }{
+ "empty": {
+ e: make(model1.Header, 0),
+ },
+
+ "plain": {
+ cdefs: []metav1.TableColumnDefinition{
+ {Name: "A"},
+ {Name: "B"},
+ {Name: "C"},
+ },
+ e: model1.Header{
+ model1.HeaderColumn{Name: "A"},
+ model1.HeaderColumn{Name: "B"},
+ model1.HeaderColumn{Name: "C"},
+ },
+ },
+
+ "age": {
+ cdefs: []metav1.TableColumnDefinition{
+ {Name: "Fred"},
+ {Name: "Blee"},
+ {Name: "Age"},
+ },
+ e: model1.Header{
+ model1.HeaderColumn{Name: "FRED"},
+ model1.HeaderColumn{Name: "BLEE"},
+ model1.HeaderColumn{Name: "AGE", Attrs: model1.Attrs{Time: true}},
+ },
+ },
+
+ "time-cols": {
+ cdefs: []metav1.TableColumnDefinition{
+ {Name: "Last Seen"},
+ {Name: "Fred"},
+ {Name: "Blee"},
+ {Name: "Age"},
+ {Name: "First Seen"},
+ },
+ e: model1.Header{
+ model1.HeaderColumn{Name: "LAST SEEN", Attrs: model1.Attrs{Time: true}},
+ model1.HeaderColumn{Name: "FRED"},
+ model1.HeaderColumn{Name: "BLEE"},
+ model1.HeaderColumn{Name: "FIRST SEEN", Attrs: model1.Attrs{Time: true}},
+ model1.HeaderColumn{Name: "AGE", Attrs: model1.Attrs{Time: true}},
+ },
+ },
+ }
+
+ for k := range uu {
+ u := uu[k]
+ t.Run(k, func(t *testing.T) {
+ var ta Table
+ ta.SetTable("ns-1", &metav1.Table{ColumnDefinitions: u.cdefs})
+ assert.Equal(t, u.e, ta.defaultHeader())
+ })
+ }
+}
diff --git a/internal/slogs/keys.go b/internal/slogs/keys.go
index ac0ea9ab..14a0477a 100644
--- a/internal/slogs/keys.go
+++ b/internal/slogs/keys.go
@@ -218,4 +218,7 @@ const (
// Duration tracks a duration logger key.
Duration = "duration"
+
+ // Type tracks a type logger key.
+ Type = "type"
)
diff --git a/internal/tchart/component.go b/internal/tchart/component.go
index a0a17458..0555c46f 100644
--- a/internal/tchart/component.go
+++ b/internal/tchart/component.go
@@ -1,6 +1,3 @@
-// SPDX-License-Identifier: Apache-2.0
-// Copyright Authors of K9s
-
package tchart
import (
@@ -11,11 +8,6 @@ import (
"github.com/derailed/tview"
)
-const (
- okColor, faultColor = tcell.ColorPaleGreen, tcell.ColorOrangeRed
- okColorName, faultColorName = "palegreen", "orangered"
-)
-
// Component represents a graphic component.
type Component struct {
*tview.Box
@@ -24,8 +16,7 @@ type Component struct {
focusFgColor, focusBgColor string
seriesColors []tcell.Color
dimmed tcell.Style
- id string
- legend string
+ id, legend string
blur func(tcell.Key)
mx sync.RWMutex
}
@@ -33,11 +24,15 @@ type Component struct {
// NewComponent returns a new component.
func NewComponent(id string) *Component {
return &Component{
- Box: tview.NewBox(),
- id: id,
- noColor: tcell.ColorDefault,
- seriesColors: []tcell.Color{tview.Styles.PrimaryTextColor, tview.Styles.FocusColor},
- dimmed: tcell.StyleDefault.Background(tview.Styles.PrimitiveBackgroundColor).Foreground(tcell.ColorGray).Dim(true),
+ Box: tview.NewBox(),
+ id: id,
+ noColor: tcell.ColorDefault,
+ seriesColors: []tcell.Color{
+ tcell.ColorGreen,
+ tcell.ColorOrange,
+ tcell.ColorOrangeRed,
+ },
+ dimmed: tcell.StyleDefault.Background(tview.Styles.PrimitiveBackgroundColor).Foreground(tcell.ColorGray).Dim(true),
}
}
@@ -48,6 +43,8 @@ func (c *Component) SetFocusColorNames(fg, bg string) {
// SetBackgroundColor sets the graph bg color.
func (c *Component) SetBackgroundColor(color tcell.Color) {
+ c.mx.Lock()
+ defer c.mx.Unlock()
c.Box.SetBackgroundColor(color)
c.bgColor = color
c.dimmed = c.dimmed.Background(color)
@@ -68,7 +65,6 @@ func (c *Component) SetLegend(l string) {
// InputHandler returns the handler for this primitive.
func (c *Component) InputHandler() func(event *tcell.EventKey, setFocus func(p tview.Primitive)) {
return c.WrapInputHandler(func(event *tcell.EventKey, setFocus func(p tview.Primitive)) {
- //nolint:exhaustive
switch key := event.Key(); key {
case tcell.KeyEnter:
case tcell.KeyBacktab, tcell.KeyTab:
@@ -80,7 +76,7 @@ func (c *Component) InputHandler() func(event *tcell.EventKey, setFocus func(p t
})
}
-// IsDial returns true if chart is a dial.
+// IsDial returns true if chart is a dial
func (*Component) IsDial() bool {
return false
}
@@ -103,6 +99,9 @@ func (c *Component) GetSeriesColorNames() []string {
c.mx.RLock()
defer c.mx.RUnlock()
+ if len(c.seriesColors) < 3 {
+ return []string{"green", "orange", "red"}
+ }
nn := make([]string, 0, len(c.seriesColors))
for _, color := range c.seriesColors {
for name, co := range tcell.ColorNames {
@@ -111,21 +110,14 @@ func (c *Component) GetSeriesColorNames() []string {
}
}
}
- if len(nn) < 2 {
- nn = append(nn, okColorName, faultColorName)
- }
return nn
}
-func (c *Component) colorForSeries() (cool, fault tcell.Color) {
+func (c *Component) colorForSeries() []tcell.Color {
c.mx.RLock()
defer c.mx.RUnlock()
- if len(c.seriesColors) == 2 {
- return c.seriesColors[0], c.seriesColors[1]
- }
-
- return okColor, faultColor
+ return c.seriesColors
}
func (c *Component) asRect() image.Rectangle {
diff --git a/internal/tchart/component_int_test.go b/internal/tchart/component_int_test.go
index 22945ac5..53edd986 100644
--- a/internal/tchart/component_int_test.go
+++ b/internal/tchart/component_int_test.go
@@ -1,13 +1,10 @@
-// SPDX-License-Identifier: Apache-2.0
-// Copyright Authors of K9s
-
package tchart
import (
"image"
"testing"
- "github.com/derailed/tview"
+ "github.com/derailed/tcell/v2"
"github.com/stretchr/testify/assert"
)
@@ -20,9 +17,11 @@ func TestComponentAsRect(t *testing.T) {
func TestComponentColorForSeries(t *testing.T) {
c := NewComponent("fred")
- okC, errC := c.colorForSeries()
+ cc := c.colorForSeries()
- assert.Equal(t, tview.Styles.PrimaryTextColor, okC)
- assert.Equal(t, tview.Styles.FocusColor, errC)
- assert.Equal(t, []string{"white", "green"}, c.GetSeriesColorNames())
+ assert.Len(t, cc, 3)
+ assert.Equal(t, tcell.ColorGreen, cc[0])
+ assert.Equal(t, tcell.ColorOrange, cc[1])
+ assert.Equal(t, tcell.ColorOrangeRed, cc[2])
+ assert.Equal(t, []string{"green", "orange", "orangered"}, c.GetSeriesColorNames())
}
diff --git a/internal/tchart/component_test.go b/internal/tchart/component_test.go
index 07a89c5a..4b911504 100644
--- a/internal/tchart/component_test.go
+++ b/internal/tchart/component_test.go
@@ -1,6 +1,3 @@
-// SPDX-License-Identifier: Apache-2.0
-// Copyright Authors of K9s
-
package tchart_test
import (
@@ -14,15 +11,7 @@ import (
func TestCoSeriesColorNames(t *testing.T) {
c := tchart.NewComponent("fred")
- c.SetSeriesColors(tcell.ColorGreen, tcell.ColorBlue)
+ c.SetSeriesColors(tcell.ColorGreen, tcell.ColorBlue, tcell.ColorRed)
- assert.Equal(t, []string{"green", "blue"}, c.GetSeriesColorNames())
-}
-
-func TestComponentAsRect(t *testing.T) {
- c := tchart.NewComponent("fred")
-
- c.SetSeriesColors(tcell.ColorGreen, tcell.ColorBlue)
-
- assert.Equal(t, []string{"green", "blue"}, c.GetSeriesColorNames())
+ assert.Equal(t, []string{"green", "blue", "red"}, c.GetSeriesColorNames())
}
diff --git a/internal/tchart/dot_matrix_test.go b/internal/tchart/dot_matrix_test.go
index d15a0e6d..2b66a1e6 100644
--- a/internal/tchart/dot_matrix_test.go
+++ b/internal/tchart/dot_matrix_test.go
@@ -1,6 +1,3 @@
-// SPDX-License-Identifier: Apache-2.0
-// Copyright Authors of K9s
-
package tchart_test
import (
diff --git a/internal/tchart/gauge.go b/internal/tchart/gauge.go
index 273eedbf..42efb960 100644
--- a/internal/tchart/gauge.go
+++ b/internal/tchart/gauge.go
@@ -1,11 +1,9 @@
-// SPDX-License-Identifier: Apache-2.0
-// Copyright Authors of K9s
-
package tchart
import (
"fmt"
"image"
+ "time"
"github.com/derailed/tcell/v2"
"github.com/derailed/tview"
@@ -20,19 +18,21 @@ const (
// DeltaLess represents a lower value.
DeltaLess
-
- gaugeFmt = "0%dd"
)
+type State struct {
+ OK, Fault int
+}
+
type delta int
// Gauge represents a gauge component.
type Gauge struct {
*Component
- data Metric
- resolution int
- deltaOk, deltaS2 delta
+ state State
+ resolution int
+ deltaOK, deltaFault delta
}
// NewGauge returns a new gauge.
@@ -47,23 +47,30 @@ func (g *Gauge) SetResolution(n int) {
g.resolution = n
}
-// IsDial returns true if chart is a dial.
+// IsDial returns true if chart is a dial
func (*Gauge) IsDial() bool {
return true
}
+func (*Gauge) SetColorIndex(int) {}
+func (*Gauge) SetMax(float64) {}
+func (*Gauge) GetMax() float64 { return 0 }
+
+// Add adds a metric.
+func (*Gauge) AddMetric(time.Time, float64) {}
+
// Add adds a new metric.
-func (g *Gauge) Add(m Metric) {
+func (g *Gauge) Add(ok, fault int) {
g.mx.Lock()
defer g.mx.Unlock()
- g.deltaOk, g.deltaS2 = computeDelta(g.data.S1, m.S1), computeDelta(g.data.S2, m.S2)
- g.data = m
+ g.deltaOK, g.deltaFault = computeDelta(g.state.OK, ok), computeDelta(g.state.Fault, fault)
+ g.state = State{OK: ok, Fault: fault}
}
type number struct {
ok bool
- val int64
+ val int
str string
delta delta
}
@@ -77,26 +84,24 @@ func (g *Gauge) Draw(sc tcell.Screen) {
rect := g.asRect()
mid := image.Point{X: rect.Min.X + rect.Dx()/2, Y: rect.Min.Y + rect.Dy()/2 - 1}
- style := tcell.StyleDefault.Background(g.bgColor)
- style = style.Foreground(tcell.ColorYellow)
- sc.SetContent(mid.X, mid.Y, '⠔', nil, style)
-
- maxD := g.data.MaxDigits()
- if maxD < g.resolution {
- maxD = g.resolution
- }
var (
- fmat = "%" + fmt.Sprintf(gaugeFmt, maxD)
- o = image.Point{X: mid.X, Y: mid.Y - 1}
+ fmat = "%d"
)
+ d1, d2 := fmt.Sprintf(fmat, g.state.OK), fmt.Sprintf(fmat, g.state.Fault)
- s1C, s2C := g.colorForSeries()
- d1, d2 := fmt.Sprintf(fmat, g.data.S1), fmt.Sprintf(fmat, g.data.S2)
- o.X -= len(d1) * 3
- g.drawNum(sc, o, number{ok: true, val: g.data.S1, delta: g.deltaOk, str: d1}, style.Foreground(s1C).Dim(false))
+ style := tcell.StyleDefault.Background(g.bgColor)
- o.X = mid.X + 1
- g.drawNum(sc, o, number{ok: false, val: g.data.S2, delta: g.deltaS2, str: d2}, style.Foreground(s2C).Dim(false))
+ total := len(d1)*3 + len(d2)*3 + 1
+ colors := g.colorForSeries()
+ o := image.Point{X: mid.X, Y: mid.Y - 1}
+ o.X -= total / 2
+ g.drawNum(sc, o, number{ok: true, val: g.state.OK, delta: g.deltaOK, str: d1}, style.Foreground(colors[0]).Dim(false))
+
+ o.X, o.Y = o.X+len(d1)*3, mid.Y
+ sc.SetContent(o.X, o.Y, '⠔', nil, style)
+
+ o.X, o.Y = o.X+1, mid.Y-1
+ g.drawNum(sc, o, number{ok: false, val: g.state.Fault, delta: g.deltaFault, str: d2}, style.Foreground(colors[1]).Dim(false))
if rect.Dx() > 0 && rect.Dy() > 0 && g.legend != "" {
legend := g.legend
@@ -108,9 +113,9 @@ func (g *Gauge) Draw(sc tcell.Screen) {
}
func (g *Gauge) drawNum(sc tcell.Screen, o image.Point, n number, style tcell.Style) {
- c1, _ := g.colorForSeries()
+ colors := g.colorForSeries()
if n.ok {
- style = style.Foreground(c1)
+ style = style.Foreground(colors[0])
printDelta(sc, n.delta, o, style)
}
@@ -133,15 +138,15 @@ func (g *Gauge) drawNum(sc tcell.Screen, o image.Point, n number, style tcell.St
}
}
-func (g *Gauge) drawDial(sc tcell.Screen, m Matrix, o image.Point, style tcell.Style) {
+func (*Gauge) drawDial(sc tcell.Screen, m Matrix, o image.Point, style tcell.Style) {
for r := range m {
- for c := range m[r] {
+ var c int
+ for c < len(m[r]) {
dot := m[r][c]
- if dot == dots[0] {
- sc.SetContent(o.X+c, o.Y+r, dots[1], nil, g.dimmed)
- } else {
+ if dot != dots[0] {
sc.SetContent(o.X+c, o.Y+r, dot, nil, style)
}
+ c++
}
}
}
@@ -149,7 +154,7 @@ func (g *Gauge) drawDial(sc tcell.Screen, m Matrix, o image.Point, style tcell.S
// ----------------------------------------------------------------------------
// Helpers...
-func computeDelta(d1, d2 int64) delta {
+func computeDelta(d1, d2 int) delta {
if d2 == 0 {
return DeltaSame
}
@@ -167,7 +172,6 @@ func computeDelta(d1, d2 int64) delta {
func printDelta(sc tcell.Screen, d delta, o image.Point, s tcell.Style) {
s = s.Dim(false)
- //nolint:exhaustive
switch d {
case DeltaLess:
sc.SetContent(o.X-1, o.Y+1, '↓', nil, s)
diff --git a/internal/tchart/gauge_int_test.go b/internal/tchart/gauge_int_test.go
index b9f19160..f34876b8 100644
--- a/internal/tchart/gauge_int_test.go
+++ b/internal/tchart/gauge_int_test.go
@@ -1,6 +1,3 @@
-// SPDX-License-Identifier: Apache-2.0
-// Copyright Authors of K9s
-
package tchart
import (
@@ -11,7 +8,7 @@ import (
func TestComputeDeltas(t *testing.T) {
uu := map[string]struct {
- d1, d2 int64
+ d1, d2 int
e delta
}{
"same": {
diff --git a/internal/tchart/gauge_test.go b/internal/tchart/gauge_test.go
index 8708402a..92029538 100644
--- a/internal/tchart/gauge_test.go
+++ b/internal/tchart/gauge_test.go
@@ -1,59 +1,56 @@
-// SPDX-License-Identifier: Apache-2.0
-// Copyright Authors of K9s
-
package tchart_test
-import (
- "testing"
+// import (
+// "testing"
- "github.com/derailed/k9s/internal/tchart"
- "github.com/stretchr/testify/assert"
-)
+// "github.com/imhotepio/tchart"
+// "github.com/stretchr/testify/assert"
+// )
-func TestMetricsMaxDigits(t *testing.T) {
- uu := map[string]struct {
- m tchart.Metric
- e int
- }{
- "empty": {
- e: 1,
- },
- "oks": {
- m: tchart.Metric{S1: 100, S2: 10},
- e: 3,
- },
- "errs": {
- m: tchart.Metric{S1: 10, S2: 1000},
- e: 4,
- },
- }
+// func TestMetricsMaxDigits(t *testing.T) {
+// uu := map[string]struct {
+// m tchart.State
+// e int
+// }{
+// "empty": {
+// e: 1,
+// },
+// "oks": {
+// m: tchart.State{OK: 100, Fault: 10},
+// e: 3,
+// },
+// "errs": {
+// m: tchart.State{OK: 10, Fault: 1000},
+// e: 4,
+// },
+// }
- for k := range uu {
- u := uu[k]
- t.Run(k, func(t *testing.T) {
- assert.Equal(t, u.e, u.m.MaxDigits())
- })
- }
-}
+// for k := range uu {
+// u := uu[k]
+// t.Run(k, func(t *testing.T) {
+// assert.Equal(t, u.e, u.m.MaxDigits())
+// })
+// }
+// }
-func TestMetricsMax(t *testing.T) {
- uu := map[string]struct {
- m tchart.Metric
- e int64
- }{
- "empty": {
- e: 0,
- },
- "max_ok": {
- m: tchart.Metric{S1: 100, S2: 10},
- e: 100,
- },
- }
+// func TestMetricsMax(t *testing.T) {
+// uu := map[string]struct {
+// m tchart.Metric
+// e int64
+// }{
+// "empty": {
+// e: 0,
+// },
+// "max_ok": {
+// m: tchart.Metric{S1: 100, S2: 10},
+// e: 100,
+// },
+// }
- for k := range uu {
- u := uu[k]
- t.Run(k, func(t *testing.T) {
- assert.Equal(t, u.e, u.m.Max())
- })
- }
-}
+// for k := range uu {
+// u := uu[k]
+// t.Run(k, func(t *testing.T) {
+// assert.Equal(t, u.e, u.m.Max())
+// })
+// }
+// }
diff --git a/internal/tchart/series.go b/internal/tchart/series.go
new file mode 100644
index 00000000..f0ee6699
--- /dev/null
+++ b/internal/tchart/series.go
@@ -0,0 +1,77 @@
+package tchart
+
+import (
+ "fmt"
+ "log/slog"
+ "sort"
+ "time"
+)
+
+type MetricSeries map[time.Time]float64
+
+type Times []time.Time
+
+func (tt Times) Len() int {
+ return len(tt)
+}
+
+func (tt Times) Swap(i, j int) {
+ tt[i], tt[j] = tt[j], tt[i]
+}
+
+func (tt Times) Less(i, j int) bool {
+ return tt[i].Sub(tt[j]) <= 0
+}
+
+func (tt Times) Includes(ti time.Time) bool {
+ for _, t := range tt {
+ if t.Equal(ti) {
+ return true
+ }
+ }
+ return false
+}
+
+func (mm MetricSeries) Empty() bool {
+ return len(mm) == 0
+}
+
+func (mm MetricSeries) Merge(metrics MetricSeries) {
+ for k, v := range metrics {
+ mm[k] = v
+ }
+}
+
+func (mm MetricSeries) Dump() {
+ slog.Debug("METRICS")
+ for _, k := range mm.Keys() {
+ slog.Debug(fmt.Sprintf("%v: %f", k, mm[k]))
+ }
+}
+
+func (mm MetricSeries) Add(t time.Time, f float64) {
+ if _, ok := mm[t]; !ok {
+ mm[t] = f
+ }
+}
+
+func (mm MetricSeries) Keys() Times {
+ kk := make(Times, 0, len(mm))
+ for k := range mm {
+ kk = append(kk, k)
+ }
+ sort.Sort(kk)
+
+ return kk
+}
+
+func (mm MetricSeries) Truncate(size int) {
+ kk := mm.Keys()
+ kk = kk[0 : len(kk)-size]
+ for t := range mm {
+ if kk.Includes(t) {
+ continue
+ }
+ delete(mm, kk[0])
+ }
+}
diff --git a/internal/tchart/series_test.go b/internal/tchart/series_test.go
new file mode 100644
index 00000000..f99aa7df
--- /dev/null
+++ b/internal/tchart/series_test.go
@@ -0,0 +1,70 @@
+package tchart_test
+
+import (
+ "testing"
+ "time"
+
+ "github.com/derailed/k9s/internal/tchart"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestSeriesAdd(t *testing.T) {
+ type tuple struct {
+ time.Time
+ float64
+ }
+ uu := map[string]struct {
+ tt []tuple
+ e int
+ }{
+ "one": {
+ tt: []tuple{
+ {time.Now(), 1000},
+ },
+ e: 6,
+ },
+ }
+
+ for k := range uu {
+ u := uu[k]
+ t.Run(k, func(t *testing.T) {
+ ss := makeSeries()
+ for _, tu := range u.tt {
+ ss.Add(tu.Time, tu.float64)
+ }
+ assert.Len(t, ss, u.e)
+ })
+ }
+}
+
+func TestSeriesTruncate(t *testing.T) {
+ uu := map[string]struct {
+ n, e int
+ }{
+ "one": {
+ n: 1,
+ e: 4,
+ },
+ }
+
+ for k := range uu {
+ u := uu[k]
+ t.Run(k, func(t *testing.T) {
+ ss := makeSeries()
+ ss.Truncate(u.n)
+ assert.Len(t, ss, u.e)
+ })
+ }
+}
+
+// Helpers...
+
+func makeSeries() tchart.MetricSeries {
+ return tchart.MetricSeries{
+ time.Now(): -100,
+ time.Now(): 0,
+ time.Now(): 100,
+ time.Now(): 50,
+ time.Now(): 10,
+ }
+}
diff --git a/internal/tchart/sparkline.go b/internal/tchart/sparkline.go
index 6376dc99..4d33c1f6 100644
--- a/internal/tchart/sparkline.go
+++ b/internal/tchart/sparkline.go
@@ -1,12 +1,10 @@
-// SPDX-License-Identifier: Apache-2.0
-// Copyright Authors of K9s
-
package tchart
import (
"fmt"
"image"
"math"
+ "time"
"github.com/derailed/tcell/v2"
"github.com/derailed/tview"
@@ -14,63 +12,106 @@ import (
var sparks = []rune{'▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'}
+const axisColor = "#ff0066"
+
type block struct {
full int
partial rune
}
-type blocks struct {
- s1, s2 block
-}
-
-// Metric tracks two series.
-type Metric struct {
- S1, S2 int64
-}
-
-// MaxDigits returns the max series number of digits.
-func (m Metric) MaxDigits() int {
- s := fmt.Sprintf("%d", m.Max())
-
- return len(s)
-}
-
-// Max returns the max of the series.
-func (m Metric) Max() int64 {
- return int64(math.Max(float64(m.S1), float64(m.S2)))
-}
-
-// Sum returns the sum of series.
-func (m Metric) Sum() int64 {
- return m.S1 + m.S2
-}
-
// SparkLine represents a sparkline component.
type SparkLine struct {
*Component
- data []Metric
- multiSeries bool
+ series MetricSeries
+ max float64
+ unit string
+ colorIndex int
}
// NewSparkLine returns a new graph.
-func NewSparkLine(id string) *SparkLine {
+func NewSparkLine(id, unit string) *SparkLine {
return &SparkLine{
- Component: NewComponent(id),
- multiSeries: true,
+ Component: NewComponent(id),
+ series: make(MetricSeries),
+ unit: unit,
}
}
-// SetMultiSeries indicates if multi series are in effect or not.
-func (s *SparkLine) SetMultiSeries(b bool) {
- s.multiSeries = b
+// GetSeriesColorNames returns series colors by name.
+func (s *SparkLine) GetSeriesColorNames() []string {
+ s.mx.RLock()
+ defer s.mx.RUnlock()
+
+ nn := make([]string, 0, len(s.seriesColors))
+ for _, color := range s.seriesColors {
+ for name, co := range tcell.ColorNames {
+ if co == color {
+ nn = append(nn, name)
+ }
+ }
+ }
+ if len(nn) < 3 {
+ nn = append(nn, "green", "orange", "orangered")
+ }
+
+ return nn
}
+func (s *SparkLine) SetColorIndex(n int) {
+ s.colorIndex = n
+}
+
+func (s *SparkLine) SetMax(m float64) {
+ if m > s.max {
+ s.max = m
+ }
+}
+
+func (s *SparkLine) GetMax() float64 {
+ return s.max
+}
+
+func (*SparkLine) Add(int, int) {}
+
// Add adds a metric.
-func (s *SparkLine) Add(m Metric) {
+func (s *SparkLine) AddMetric(t time.Time, f float64) {
s.mx.Lock()
defer s.mx.Unlock()
- s.data = append(s.data, m)
+ s.series.Add(t, f)
+}
+
+func (s *SparkLine) printYAxis(screen tcell.Screen, rect image.Rectangle) {
+ style := tcell.StyleDefault.Foreground(tcell.GetColor(axisColor)).Background(s.bgColor)
+ for y := range rect.Dy() - 3 {
+ screen.SetContent(rect.Min.X, rect.Min.Y+y, tview.BoxDrawingsLightVertical, nil, style)
+ }
+ screen.SetContent(rect.Min.X, rect.Min.Y+rect.Dy()-3, tview.BoxDrawingsLightUpAndRight, nil, style)
+}
+
+func (s *SparkLine) printXAxis(screen tcell.Screen, rect image.Rectangle) time.Time {
+ dx, t := rect.Dx()-1, time.Now()
+ vals := make([]string, 0, dx)
+ for i := dx; i > 0; i -= 10 {
+ label := fmt.Sprintf("%02d:%02d", t.Hour(), t.Minute())
+ vals = append(vals, label)
+ t = t.Add(-(10 * time.Minute))
+ }
+
+ y := rect.Max.Y - 2
+ for _, v := range vals {
+ if dx <= 2 {
+ break
+ }
+ tview.Print(screen, v, rect.Min.X+dx-5, y, 6, tview.AlignCenter, tcell.ColorOrange)
+ dx -= 10
+ }
+ style := tcell.StyleDefault.Foreground(tcell.GetColor(axisColor)).Background(s.bgColor)
+ for x := 1; x < rect.Dx()-1; x++ {
+ screen.SetContent(rect.Min.X+x, rect.Max.Y-3, tview.BoxDrawingsLightHorizontal, nil, style)
+ }
+
+ return t
}
// Draw draws the graph.
@@ -80,54 +121,50 @@ func (s *SparkLine) Draw(screen tcell.Screen) {
s.mx.RLock()
defer s.mx.RUnlock()
- if len(s.data) == 0 {
- return
+ rect := s.asRect()
+ s.printXAxis(screen, rect)
+
+ padX := 1
+ s.cutSet(rect.Dx() - padX)
+ var cX int
+ if len(s.series) < rect.Dx() {
+ cX = rect.Max.X - len(s.series) - 1
+ } else {
+ cX = rect.Min.X + padX
}
- pad := 0
+ pad := 2
if s.legend != "" {
pad++
}
-
- rect := s.asRect()
- s.cutSet(rect.Dx())
- maxVal := s.computeMax()
-
- cX, idx := rect.Min.X+1, 0
- if len(s.data)*2 < rect.Dx() {
- cX = rect.Max.X - len(s.data)*2
- } else {
- idx = len(s.data) - rect.Dx()/2
- }
-
- scale := float64(len(sparks)*(rect.Dy()-pad)) / float64(maxVal)
- c1, c2 := s.colorForSeries()
- for _, d := range s.data[idx:] {
- b := toBlocks(d, scale)
- cY := rect.Max.Y - pad
- s.drawBlock(rect, screen, cX, cY, b.s1, c1)
- cX++
- s.drawBlock(rect, screen, cX, cY, b.s2, c2)
+ scale := float64(len(sparks)*(rect.Dy()-pad)) / float64(s.max)
+ colors := s.colorForSeries()
+ cY := rect.Max.Y - pad - 1
+ for _, t := range s.series.Keys() {
+ b := s.makeBlock(s.series[t], scale)
+ s.drawBlock(rect, screen, cX, cY, b, colors[s.colorIndex%len(colors)])
cX++
}
+ s.printYAxis(screen, rect)
+
if rect.Dx() > 0 && rect.Dy() > 0 && s.legend != "" {
legend := s.legend
if s.HasFocus() {
legend = fmt.Sprintf("[%s:%s:]", s.focusFgColor, s.focusBgColor) + s.legend + "[::]"
}
- tview.Print(screen, legend, rect.Min.X, rect.Max.Y, rect.Dx(), tview.AlignCenter, tcell.ColorWhite)
+ tview.Print(screen, legend, rect.Min.X, rect.Max.Y-1, rect.Dx(), tview.AlignCenter, tcell.ColorWhite)
}
}
func (s *SparkLine) drawBlock(r image.Rectangle, screen tcell.Screen, x, y int, b block, c tcell.Color) {
style := tcell.StyleDefault.Foreground(c).Background(s.bgColor)
- zeroY := r.Max.Y - r.Dy()
+ zeroY, full := r.Min.Y, sparks[len(sparks)-1]
for range b.full {
- screen.SetContent(x, y, sparks[len(sparks)-1], nil, style)
+ screen.SetContent(x, y, full, nil, style)
y--
- if y <= zeroY {
+ if y < zeroY {
break
}
}
@@ -137,41 +174,22 @@ func (s *SparkLine) drawBlock(r image.Rectangle, screen tcell.Screen, x, y int,
}
func (s *SparkLine) cutSet(width int) {
- if width <= 0 || len(s.data) == 0 {
+ if width <= 0 || s.series.Empty() {
return
}
-
- if len(s.data) >= width*2 {
- s.data = s.data[len(s.data)-width:]
+ if len(s.series) > width {
+ s.series.Truncate(width)
}
}
-func (s *SparkLine) computeMax() int64 {
- var maxVal int64
- for _, d := range s.data {
- m := d.Max()
- if maxVal < m {
- maxVal = m
- }
+func (*SparkLine) makeBlock(v, scale float64) block {
+ sc := (v * scale)
+ scaled := math.Round(sc)
+ p, b := int(scaled)%len(sparks), block{full: int(scaled / float64(len(sparks)))}
+ if v < 0 {
+ return b
}
-
- return maxVal
-}
-
-func toBlocks(m Metric, scale float64) blocks {
- if m.Sum() <= 0 {
- return blocks{}
- }
- return blocks{s1: makeBlocks(m.S1, scale), s2: makeBlocks(m.S2, scale)}
-}
-
-func makeBlocks(v int64, scale float64) block {
- scaled := int(math.Round(float64(v) * scale))
- p, b := scaled%len(sparks), block{full: scaled / len(sparks)}
- if b.full == 0 && v > 0 && p == 0 {
- p = 4
- }
- if v > 0 && p >= 0 && p < len(sparks) {
+ if p > 0 && p < len(sparks) {
b.partial = sparks[p]
}
diff --git a/internal/tchart/sparkline_int_test.go b/internal/tchart/sparkline_int_test.go
deleted file mode 100644
index b7345e1b..00000000
--- a/internal/tchart/sparkline_int_test.go
+++ /dev/null
@@ -1,134 +0,0 @@
-// SPDX-License-Identifier: Apache-2.0
-// Copyright Authors of K9s
-
-package tchart
-
-import (
- "testing"
-
- "github.com/stretchr/testify/assert"
-)
-
-func TestCutSet(t *testing.T) {
- uu := map[string]struct {
- mm []Metric
- w, e int
- }{
- "empty": {
- w: 10,
- e: 0,
- },
- "at": {
- mm: make([]Metric, 10),
- w: 10,
- e: 10,
- },
- "under": {
- mm: make([]Metric, 5),
- w: 10,
- e: 5,
- },
- "over": {
- mm: make([]Metric, 10),
- w: 5,
- e: 5,
- },
- }
-
- for k := range uu {
- u := uu[k]
- t.Run(k, func(t *testing.T) {
- s := NewSparkLine("s")
- assert.False(t, s.IsDial())
-
- for _, m := range u.mm {
- s.Add(m)
- }
- s.cutSet(u.w)
- assert.Len(t, s.data, u.e)
- })
- }
-}
-
-func TestToBlocks(t *testing.T) {
- uu := map[string]struct {
- m Metric
- s float64
- e blocks
- }{
- "empty": {
- e: blocks{},
- },
- "max_ok": {
- m: Metric{S1: 100, S2: 10},
- s: 0.5,
- e: blocks{
- s1: block{full: 6, partial: sparks[2]},
- s2: block{full: 0, partial: sparks[5]},
- },
- },
- "max_fault": {
- m: Metric{S1: 10, S2: 100},
- s: 0.5,
- e: blocks{
- s1: block{full: 0, partial: sparks[5]},
- s2: block{full: 6, partial: sparks[2]},
- },
- },
- "over": {
- m: Metric{S1: 22, S2: 999},
- s: float64(8*20) / float64(999),
- e: blocks{
- s1: block{full: 0, partial: sparks[4]},
- s2: block{full: 20, partial: sparks[0]},
- },
- },
- }
-
- for k := range uu {
- u := uu[k]
- t.Run(k, func(t *testing.T) {
- assert.Equal(t, u.e, toBlocks(u.m, u.s))
- })
- }
-}
-
-func TestComputeMax(t *testing.T) {
- uu := map[string]struct {
- mm []Metric
- e int64
- }{
- "empty": {
- e: 0,
- },
- "max_ok": {
- mm: []Metric{{S1: 100, S2: 10}},
- e: 100,
- },
- "max_fault": {
- mm: []Metric{{S1: 100, S2: 1000}},
- e: 1000,
- },
- "many": {
- mm: []Metric{
- {S1: 100, S2: 1000},
- {S1: 110, S2: 1010},
- {S1: 120, S2: 1020},
- {S1: 130, S2: 1030},
- {S1: 140, S2: 1040},
- },
- e: 1040,
- },
- }
-
- for k := range uu {
- u := uu[k]
- s := NewSparkLine("s")
- for _, m := range u.mm {
- s.Add(m)
- }
- t.Run(k, func(t *testing.T) {
- assert.Equal(t, u.e, s.computeMax())
- })
- }
-}
diff --git a/internal/ui/indicator.go b/internal/ui/indicator.go
index 82ee5c09..008acd04 100644
--- a/internal/ui/indicator.go
+++ b/internal/ui/indicator.go
@@ -11,7 +11,6 @@ import (
"github.com/derailed/k9s/internal/config"
"github.com/derailed/k9s/internal/model"
"github.com/derailed/k9s/internal/render"
- "github.com/derailed/tcell/v2"
"github.com/derailed/tview"
)
@@ -33,7 +32,7 @@ func NewStatusIndicator(app *App, styles *config.Styles) *StatusIndicator {
styles: styles,
}
s.SetTextAlign(tview.AlignCenter)
- s.SetTextColor(tcell.ColorWhite)
+ s.SetTextColor(styles.FgColor())
s.SetBackgroundColor(styles.BgColor())
s.SetDynamicColors(true)
styles.AddListener(&s)
@@ -48,18 +47,24 @@ func (s *StatusIndicator) StylesChanged(styles *config.Styles) {
s.SetTextColor(styles.FgColor())
}
-const statusIndicatorFmt = "[orange::b]K9s [aqua::]%s [white::]%s:%s:%s [lawngreen::]%s[white::]::[darkturquoise::]%s"
+const statusIndicatorFmt = "[%s::b]K9s [%s::]%s [%s::]%s:%s:%s [%s::]%s[%s::]::[%s::]%s"
// ClusterInfoUpdated notifies the cluster meta was updated.
func (s *StatusIndicator) ClusterInfoUpdated(data *model.ClusterMeta) {
s.app.QueueUpdateDraw(func() {
s.SetPermanent(fmt.Sprintf(
statusIndicatorFmt,
+ s.styles.Body().LogoColor.String(),
+ s.styles.K9s.Info.K9sRevColor.String(),
data.K9sVer,
+ s.styles.K9s.Info.FgColor.String(),
data.Context,
data.Cluster,
data.K8sVer,
+ s.styles.K9s.Info.CPUColor.String(),
render.PrintPerc(data.Cpu),
+ s.styles.Body().FgColor.String(),
+ s.styles.K9s.Info.MEMColor.String(),
render.PrintPerc(data.Mem),
))
})
@@ -73,11 +78,17 @@ func (s *StatusIndicator) ClusterInfoChanged(prev, cur *model.ClusterMeta) {
s.app.QueueUpdateDraw(func() {
s.SetPermanent(fmt.Sprintf(
statusIndicatorFmt,
+ s.styles.Body().LogoColor.String(),
+ s.styles.K9s.Info.K9sRevColor.String(),
cur.K9sVer,
+ s.styles.K9s.Info.FgColor.String(),
cur.Context,
cur.Cluster,
cur.K8sVer,
+ s.styles.K9s.Info.CPUColor.String(),
AsPercDelta(prev.Cpu, cur.Cpu),
+ s.styles.Body().FgColor.String(),
+ s.styles.K9s.Info.MEMColor.String(),
AsPercDelta(prev.Cpu, cur.Mem),
))
})
diff --git a/internal/ui/table.go b/internal/ui/table.go
index de8e0121..73929df3 100644
--- a/internal/ui/table.go
+++ b/internal/ui/table.go
@@ -560,10 +560,7 @@ func (t *Table) styleTitle() string {
buff := t.cmdBuff.GetText()
if internal.IsLabelSelector(buff) {
- sel, err := TrimLabelSelector(buff)
- if err != nil {
- buff = render.Truncate(buff, maxTruncate)
- } else if sel != nil {
+ if sel, err := TrimLabelSelector(buff); err == nil {
buff = render.Truncate(sel.String(), maxTruncate)
}
} else if l := t.GetModel().GetLabelSelector(); l != nil && !l.Empty() {
@@ -571,7 +568,6 @@ func (t *Table) styleTitle() string {
} else if buff != "" {
buff = render.Truncate(buff, maxTruncate)
}
-
if buff == "" {
return title
}
diff --git a/internal/ui/types.go b/internal/ui/types.go
index 24c20e51..db863ab4 100644
--- a/internal/ui/types.go
+++ b/internal/ui/types.go
@@ -17,8 +17,8 @@ import (
)
const (
- unlockedIC = "🖍"
- lockedIC = "🔑"
+ unlockedIC = "[RW]"
+ lockedIC = "[R]"
)
// Namespaceable tracks namespaces.
diff --git a/internal/view/actions.go b/internal/view/actions.go
index 01dd5628..613070cc 100644
--- a/internal/view/actions.go
+++ b/internal/view/actions.go
@@ -214,8 +214,11 @@ func pluginAction(r Runner, p *config.Plugin) ui.ActionHandler {
errs = errors.Join(errs, e)
}
if errs != nil {
- r.App().cowCmd(errs.Error())
- return
+ if !strings.Contains(errs.Error(), "signal: interrupt") {
+ slog.Error("Plugin command failed", slogs.Error, errs)
+ r.App().cowCmd(errs.Error())
+ return
+ }
}
go func() {
for st := range statusChan {
diff --git a/internal/view/cmd/args.go b/internal/view/cmd/args.go
index 5aad75d1..3b2ffb5c 100644
--- a/internal/view/cmd/args.go
+++ b/internal/view/cmd/args.go
@@ -27,9 +27,6 @@ func newArgs(p *Interpreter, aa []string) args {
for i := 0; i < len(aa); i++ {
a := strings.TrimSpace(aa[i])
switch {
- case strings.Index(a, contextFlag) == 0:
- arguments[contextKey] = a[1:]
-
case strings.Index(a, fuzzyFlag) == 0:
if a == fuzzyFlag {
i++
@@ -54,20 +51,26 @@ func newArgs(p *Interpreter, aa []string) args {
arguments[labelKey] = strings.ToLower(a)
}
+ case strings.Index(a, contextFlag) == 0:
+ arguments[contextKey] = a[1:]
+
default:
switch {
case p.IsContextCmd():
arguments[contextKey] = a
+
case p.IsDirCmd():
if _, ok := arguments[topicKey]; !ok {
arguments[topicKey] = a
}
+
case p.IsXrayCmd():
if _, ok := arguments[topicKey]; ok {
arguments[nsKey] = strings.ToLower(a)
} else {
arguments[topicKey] = strings.ToLower(a)
}
+
default:
arguments[nsKey] = strings.ToLower(a)
}
diff --git a/internal/view/cmd/args_test.go b/internal/view/cmd/args_test.go
index 962a24bb..69acaad2 100644
--- a/internal/view/cmd/args_test.go
+++ b/internal/view/cmd/args_test.go
@@ -19,61 +19,73 @@ func TestFlagsNew(t *testing.T) {
i: NewInterpreter("po"),
ll: make(args),
},
+
"ns": {
i: NewInterpreter("po"),
aa: []string{"ns1"},
ll: args{nsKey: "ns1"},
},
+
"ns+spaces": {
i: NewInterpreter("po"),
aa: []string{" ns1 "},
ll: args{nsKey: "ns1"},
},
+
"filter": {
i: NewInterpreter("po"),
aa: []string{"/fred"},
ll: args{filterKey: "fred"},
},
+
"inverse-filter": {
i: NewInterpreter("po"),
aa: []string{"/!fred"},
ll: args{filterKey: "!fred"},
},
+
"fuzzy-filter": {
i: NewInterpreter("po"),
aa: []string{"-f", "fred"},
ll: args{fuzzyKey: "fred"},
},
+
"fuzzy-filter-nospace": {
i: NewInterpreter("po"),
aa: []string{"-ffred"},
ll: args{fuzzyKey: "fred"},
},
+
"filter+ns": {
i: NewInterpreter("po"),
aa: []string{"/fred", " ns1 "},
ll: args{nsKey: "ns1", filterKey: "fred"},
},
+
"label": {
i: NewInterpreter("po"),
aa: []string{"app=fred"},
ll: args{labelKey: "app=fred"},
},
+
"label-toast": {
i: NewInterpreter("po"),
aa: []string{"="},
ll: make(args),
},
+
"multi-labels": {
i: NewInterpreter("po"),
aa: []string{"app=fred,blee=duh"},
ll: args{labelKey: "app=fred,blee=duh"},
},
+
"label+ns": {
i: NewInterpreter("po"),
aa: []string{"a=b,c=d", " ns1 "},
ll: args{labelKey: "a=b,c=d", nsKey: "ns1"},
},
+
"full-monty": {
i: NewInterpreter("po"),
aa: []string{"app=fred", "ns1", "-f", "blee", "/zorg"},
@@ -84,6 +96,7 @@ func TestFlagsNew(t *testing.T) {
nsKey: "ns1",
},
},
+
"full-monty+ctx": {
i: NewInterpreter("po"),
aa: []string{"app=fred", "ns1", "-f", "blee", "/zorg", "@ctx1"},
@@ -95,6 +108,43 @@ func TestFlagsNew(t *testing.T) {
contextKey: "ctx1",
},
},
+
+ "full-monty+ctx-with-space": {
+ i: NewInterpreter("po"),
+ aa: []string{"app=fred", "ns1", "-f", "blee", "/zorg", "@zorg fred"},
+ ll: args{
+ filterKey: "zorg",
+ fuzzyKey: "blee",
+ labelKey: "app=fred",
+ nsKey: "ns1",
+ contextKey: "zorg fred",
+ },
+ },
+
+ "full-monty+ctx-first": {
+ i: NewInterpreter("po"),
+ aa: []string{"@ctx1", "app=fred", "ns1", "-f", "blee", "/zorg"},
+ ll: args{
+ filterKey: "zorg",
+ fuzzyKey: "blee",
+ labelKey: "app=fred",
+ nsKey: "ns1",
+ contextKey: "ctx1",
+ },
+ },
+
+ "full-monty+ctx-with-space-middle": {
+ i: NewInterpreter("po"),
+ aa: []string{"app=fred", "@ctx1", "ns1", "-f", "blee", "/zorg"},
+ ll: args{
+ filterKey: "zorg",
+ fuzzyKey: "blee",
+ labelKey: "app=fred",
+ nsKey: "ns1",
+ contextKey: "ctx1",
+ },
+ },
+
"caps": {
i: NewInterpreter("po"),
aa: []string{"app=fred", "ns1", "-f", "blee", "/zorg", "@Dev"},
@@ -106,12 +156,14 @@ func TestFlagsNew(t *testing.T) {
contextKey: "Dev",
},
},
+
"ctx": {
i: NewInterpreter("ctx"),
aa: []string{"Dev"},
ll: args{contextKey: "Dev"},
},
- "bork": {
+
+ "toast": {
i: NewInterpreter("apply -f"),
ll: args{},
},
diff --git a/internal/view/cmd/interpreter.go b/internal/view/cmd/interpreter.go
index 9ca80ca7..7ddaeb17 100644
--- a/internal/view/cmd/interpreter.go
+++ b/internal/view/cmd/interpreter.go
@@ -130,11 +130,11 @@ func (c *Interpreter) IsRBACCmd() bool {
// ContextArg returns context cmd arg.
func (c *Interpreter) ContextArg() (string, bool) {
- if !c.IsContextCmd() {
- return "", false
+ if c.IsContextCmd() || strings.Contains(c.line, contextFlag) {
+ return c.args[contextKey], true
}
- return c.args[contextKey], true
+ return "", false
}
// ResetContextArg deletes context arg.
diff --git a/internal/view/cmd/interpreter_test.go b/internal/view/cmd/interpreter_test.go
index d937d599..9296911b 100644
--- a/internal/view/cmd/interpreter_test.go
+++ b/internal/view/cmd/interpreter_test.go
@@ -475,3 +475,31 @@ func TestCowCmd(t *testing.T) {
})
}
}
+
+func TestArgs(t *testing.T) {
+ uu := map[string]struct {
+ cmd string
+ ok bool
+ ctx string
+ }{
+ "empty": {},
+
+ "with-plain-context": {
+ cmd: "po @fred",
+ ok: true,
+ ctx: "fred",
+ },
+ }
+
+ for k := range uu {
+ u := uu[k]
+ t.Run(k, func(t *testing.T) {
+ p := cmd.NewInterpreter(u.cmd)
+ ctx, ok := p.ContextArg()
+ assert.Equal(t, u.ok, ok)
+ if u.ok {
+ assert.Equal(t, u.ctx, ctx)
+ }
+ })
+ }
+}
diff --git a/internal/view/pf_extender.go b/internal/view/pf_extender.go
index 893c5c30..9f795cac 100644
--- a/internal/view/pf_extender.go
+++ b/internal/view/pf_extender.go
@@ -137,7 +137,7 @@ func runForward(v ResourceViewer, pf watch.Forwarder, f *portforward.PortForward
pf.SetActive(true)
if err := f.ForwardPorts(); err != nil {
- v.App().Flash().Err(err)
+ v.App().Flash().Warnf("PortForward failed for %s: %s. Deleting!", pf.ID(), err)
}
v.App().QueueUpdateDraw(func() {
v.App().factory.DeleteForwarder(pf.ID())
diff --git a/internal/view/pulse.go b/internal/view/pulse.go
index eb9e43e1..9b641b19 100644
--- a/internal/view/pulse.go
+++ b/internal/view/pulse.go
@@ -1,20 +1,19 @@
-// SPDX-License-Identifier: Apache-2.0
-// Copyright Authors of K9s
-
package view
import (
"context"
"fmt"
"image"
- "strings"
+ "log/slog"
+ "time"
"github.com/derailed/k9s/internal"
"github.com/derailed/k9s/internal/client"
"github.com/derailed/k9s/internal/config"
- "github.com/derailed/k9s/internal/health"
+ "github.com/derailed/k9s/internal/dao"
"github.com/derailed/k9s/internal/model"
"github.com/derailed/k9s/internal/render"
+ "github.com/derailed/k9s/internal/slogs"
"github.com/derailed/k9s/internal/tchart"
"github.com/derailed/k9s/internal/ui"
"github.com/derailed/k9s/internal/view/cmd"
@@ -25,6 +24,22 @@ import (
"k8s.io/apimachinery/pkg/labels"
)
+const (
+ cpuFmt = " %s [%s::b]%s[white::-]([%s::]%sm[white::]/[%s::]%sm[-::])"
+ memFmt = " %s [%s::b]%s[white::-]([%s::]%sMi[white::]/[%s::]%sMi[-::])"
+ pulseTitle = "Pulses"
+ NSTitleFmt = "[fg:bg:b] %s([hilite:bg:b]%s[fg:bg:-])[fg:bg:-] "
+ dirLeft = 1
+ dirRight = -dirLeft
+ dirDown = 4
+ dirUp = -dirDown
+ grayC = "gray"
+)
+
+var corpusGVRs = append(model.PulseGVRs, client.CpuGVR, client.MemGVR)
+
+type Charts map[*client.GVR]Graphable
+
// Graphable represents a graphic component.
type Graphable interface {
tview.Primitive
@@ -33,11 +48,18 @@ type Graphable interface {
ID() string
// Add adds a metric
- Add(tchart.Metric)
+ Add(ok, fault int)
+
+ AddMetric(time.Time, float64)
// SetLegend sets the graph legend
SetLegend(string)
+ SetColorIndex(int)
+
+ SetMax(float64)
+ GetMax() float64
+
// SetSeriesColors sets charts series colors.
SetSeriesColors(...tcell.Color)
@@ -50,71 +72,79 @@ type Graphable interface {
// SetBackgroundColor sets chart bg color.
SetBackgroundColor(tcell.Color)
+ SetBorderColor(tcell.Color) *tview.Box
+
// IsDial returns true if chart is a dial
IsDial() bool
}
-const pulseTitle = "Pulses"
-
-var _ ResourceViewer = (*Pulse)(nil)
-
// Pulse represents a command health view.
type Pulse struct {
*tview.Grid
- app *App
- gvr *client.GVR
- model *model.Pulse
- cancelFn context.CancelFunc
- actions *ui.KeyActions
- charts []Graphable
+ app *App
+ gvr *client.GVR
+ model *model.Pulse
+ cancelFn context.CancelFunc
+ actions *ui.KeyActions
+ charts Charts
+ prevFocusIndex int
+ chartGVRs client.GVRs
}
// NewPulse returns a new alias view.
func NewPulse(gvr *client.GVR) ResourceViewer {
return &Pulse{
- Grid: tview.NewGrid(),
- model: model.NewPulse(gvr.String()),
- actions: ui.NewKeyActions(),
+ Grid: tview.NewGrid(),
+ model: model.NewPulse(gvr),
+ actions: ui.NewKeyActions(),
+ prevFocusIndex: -1,
}
}
-func (*Pulse) SetCommand(*cmd.Interpreter) {}
-func (*Pulse) SetFilter(string) {}
-func (*Pulse) SetLabelSelector(labels.Selector) {}
-
// Init initializes the view.
func (p *Pulse) Init(ctx context.Context) error {
p.SetBorder(true)
- p.SetTitle(fmt.Sprintf(" %s ", pulseTitle))
- p.SetGap(1, 1)
+ p.SetGap(0, 0)
p.SetBorderPadding(0, 0, 1, 1)
var err error
if p.app, err = extractApp(ctx); err != nil {
return err
}
- p.charts = []Graphable{
- p.makeGA(image.Point{X: 0, Y: 0}, image.Point{X: 2, Y: 2}, client.DpGVR),
- p.makeGA(image.Point{X: 0, Y: 2}, image.Point{X: 2, Y: 2}, client.RsGVR),
- p.makeGA(image.Point{X: 0, Y: 4}, image.Point{X: 2, Y: 2}, client.StsGVR),
- p.makeGA(image.Point{X: 0, Y: 6}, image.Point{X: 2, Y: 2}, client.DsGVR),
- p.makeSP(image.Point{X: 2, Y: 0}, image.Point{X: 3, Y: 2}, client.PodGVR),
- p.makeSP(image.Point{X: 2, Y: 2}, image.Point{X: 3, Y: 2}, client.EvGVR),
- p.makeSP(image.Point{X: 2, Y: 4}, image.Point{X: 3, Y: 2}, client.JobGVR),
- p.makeSP(image.Point{X: 2, Y: 6}, image.Point{X: 3, Y: 2}, client.PvGVR),
+ ns := p.app.Config.ActiveNamespace()
+ frame := p.app.Styles.Frame()
+ p.SetTitle(ui.SkinTitle(fmt.Sprintf(NSTitleFmt, pulseTitle, ns), &frame))
+
+ index, chartRow := 4, 6
+ if client.IsAllNamespace(ns) {
+ index, chartRow = 0, 8
+ }
+ p.chartGVRs = corpusGVRs[index:]
+
+ p.charts = make(Charts, len(p.chartGVRs))
+ var x, y, col int
+ for _, gvr := range p.chartGVRs[:len(p.chartGVRs)-2] {
+ p.charts[gvr] = p.makeGA(image.Point{X: x, Y: y}, image.Point{X: 2, Y: 2}, gvr)
+ col, y = col+1, y+2
+ if y > 6 {
+ y = 0
+ }
+ if col >= 4 {
+ col, x = 0, x+2
+ }
}
if p.app.Conn().HasMetrics() {
- p.charts = append(p.charts,
- p.makeSP(image.Point{X: 5, Y: 0}, image.Point{X: 2, Y: 4}, client.CpuGVR),
- p.makeSP(image.Point{X: 5, Y: 4}, image.Point{X: 2, Y: 4}, client.MemGVR),
- )
+ p.charts[client.CpuGVR] = p.makeSP(image.Point{X: chartRow, Y: 0}, image.Point{X: 2, Y: 4}, client.CpuGVR, "c")
+ p.charts[client.MemGVR] = p.makeSP(image.Point{X: chartRow, Y: 4}, image.Point{X: 2, Y: 4}, client.MemGVR, "Gi")
}
+ p.GetItem(0).Focus = true
+ p.app.SetFocus(p.charts[p.chartGVRs[0]])
+
p.bindKeys()
- p.model.AddListener(p)
- p.app.SetFocus(p.charts[0])
p.app.Styles.AddListener(p)
p.StylesChanged(p.app.Styles)
+ p.model.SetNamespace(ns)
return nil
}
@@ -124,11 +154,15 @@ func (*Pulse) InCmdMode() bool {
return false
}
+func (*Pulse) SetCommand(*cmd.Interpreter) {}
+func (*Pulse) SetFilter(string) {}
+func (*Pulse) SetLabelSelector(labels.Selector) {}
+
// StylesChanged notifies the skin changed.
func (p *Pulse) StylesChanged(s *config.Styles) {
p.SetBackgroundColor(s.Charts().BgColor.Color())
for _, c := range p.charts {
- c.SetFocusColorNames(s.Table().BgColor.String(), s.Table().CursorBgColor.String())
+ c.SetFocusColorNames(s.Charts().FocusFgColor.String(), s.Charts().FocusBgColor.String())
if c.IsDial() {
c.SetBackgroundColor(s.Charts().DialBgColor.Color())
c.SetSeriesColors(s.Charts().DefaultDialColors.Colors()...)
@@ -142,65 +176,93 @@ func (p *Pulse) StylesChanged(s *config.Styles) {
}
}
-const (
- genFmat = " %s([%s::]%d[white::]:[%s::b]%d[-::])"
- cpuFmt = " %s [%s::b]%s[white::-]([%s::]%sm[white::]/[%s::]%sm[-::])"
- memFmt = " %s [%s::b]%s[white::-]([%s::]%sMi[white::]/[%s::]%sMi[-::])"
-)
+// SeriesChanged update cluster time series.
+func (p *Pulse) SeriesChanged(tt dao.TimeSeries) {
+ if len(tt) == 0 {
+ return
+ }
-// PulseChanged notifies the model data changed.
-func (p *Pulse) PulseChanged(c *health.Check) {
- index, ok := findIndexGVR(p.charts, c.GVR)
+ cpu, ok := p.charts[client.CpuGVR]
+ if !ok {
+ return
+ }
+ mem := p.charts[client.MemGVR]
if !ok {
return
}
- v, ok := p.GetItem(index).Item.(Graphable)
+ for i := range tt {
+ t := tt[i]
+ cpu.SetMax(float64(t.Value.AllocatableCPU))
+ mem.SetMax(float64(t.Value.AllocatableMEM))
+ cpu.AddMetric(t.Time, float64(t.Value.CurrentCPU))
+ mem.AddMetric(t.Time, float64(t.Value.CurrentMEM))
+ }
+
+ last := tt[len(tt)-1]
+ perc := client.ToPercentage(last.Value.CurrentCPU, int64(cpu.GetMax()))
+ index := int(p.app.Config.K9s.Thresholds.LevelFor("cpu", perc))
+ cpu.SetColorIndex(int(p.app.Config.K9s.Thresholds.LevelFor("cpu", perc)))
+ nn := cpu.GetSeriesColorNames()
+ if last.Value.CurrentCPU == 0 {
+ nn[0] = grayC
+ }
+ if last.Value.AllocatableCPU == 0 {
+ nn[1] = grayC
+ }
+ cpu.SetLegend(fmt.Sprintf(cpuFmt,
+ cases.Title(language.English).String(client.CpuGVR.R()),
+ p.app.Config.K9s.Thresholds.SeverityColor("cpu", perc),
+ render.PrintPerc(perc),
+ nn[index],
+ render.AsThousands(last.Value.CurrentCPU),
+ "white",
+ render.AsThousands(int64(cpu.GetMax())),
+ ))
+
+ nn = mem.GetSeriesColorNames()
+ if last.Value.CurrentMEM == 0 {
+ nn[0] = grayC
+ }
+ if last.Value.AllocatableMEM == 0 {
+ nn[1] = grayC
+ }
+ perc = client.ToPercentage(last.Value.CurrentMEM, int64(mem.GetMax()))
+ index = int(p.app.Config.K9s.Thresholds.LevelFor("memory", perc))
+ mem.SetColorIndex(index)
+ mem.SetLegend(fmt.Sprintf(memFmt,
+ cases.Title(language.English).String(client.MemGVR.R()),
+ p.app.Config.K9s.Thresholds.SeverityColor("memory", perc),
+ render.PrintPerc(perc),
+ nn[index],
+ render.AsThousands(last.Value.CurrentMEM),
+ "white",
+ render.AsThousands(int64(mem.GetMax())),
+ ))
+}
+
+// PulseChanged notifies the model data changed.
+func (p *Pulse) PulseChanged(pt model.HealthPoint) {
+ v, ok := p.charts[pt.GVR]
if !ok {
return
}
nn := v.GetSeriesColorNames()
- if c.Tally(health.S1) == 0 {
- nn[0] = "gray"
+ if pt.Total == 0 {
+ nn[0] = grayC
}
- if c.Tally(health.S2) == 0 {
- nn[1] = "gray"
+ if pt.Faults == 0 {
+ nn[1] = grayC
}
- switch c.GVR {
- case client.CpuGVR:
- perc := client.ToPercentage(c.Tally(health.S1), c.Tally(health.S2))
- v.SetLegend(fmt.Sprintf(cpuFmt,
- cases.Title(language.Und, cases.NoLower).String(c.GVR.R()),
- p.app.Config.K9s.Thresholds.SeverityColor(config.CPU, perc),
- render.PrintPerc(perc),
- nn[0],
- render.AsThousands(c.Tally(health.S1)),
- nn[1],
- render.AsThousands(c.Tally(health.S2)),
- ))
- case client.MemGVR:
- perc := client.ToPercentage(c.Tally(health.S1), c.Tally(health.S2))
- v.SetLegend(fmt.Sprintf(memFmt,
- cases.Title(language.Und, cases.NoLower).String(c.GVR.R()),
- p.app.Config.K9s.Thresholds.SeverityColor(config.MEM, perc),
- render.PrintPerc(perc),
- nn[0],
- render.AsThousands(c.Tally(health.S1)),
- nn[1],
- render.AsThousands(c.Tally(health.S2)),
- ))
- default:
- v.SetLegend(fmt.Sprintf(genFmat,
- cases.Title(language.Und, cases.NoLower).String(c.GVR.R()),
- nn[0],
- c.Tally(health.S1),
- nn[1],
- c.Tally(health.S2),
- ))
+ v.SetLegend(cases.Title(language.English).String(pt.GVR.R()))
+ if pt.Faults > 0 {
+ v.SetBorderColor(tcell.ColorDarkRed)
+ } else {
+ v.SetBorderColor(tcell.ColorDarkOliveGreen)
}
- v.Add(tchart.Metric{S1: c.Tally(health.S1), S2: c.Tally(health.S2)})
+ v.Add(pt.Total, pt.Faults)
}
// PulseFailed notifies the load failed.
@@ -211,15 +273,13 @@ func (p *Pulse) PulseFailed(err error) {
func (p *Pulse) bindKeys() {
p.actions.Merge(ui.NewKeyActionsFromMap(ui.KeyMap{
tcell.KeyEnter: ui.NewKeyAction("Goto", p.enterCmd, true),
- tcell.KeyTab: ui.NewKeyAction("Next", p.nextFocusCmd(1), true),
- tcell.KeyBacktab: ui.NewKeyAction("Prev", p.nextFocusCmd(-1), true),
+ tcell.KeyTab: ui.NewKeyAction("Next", p.nextFocusCmd(dirLeft), true),
+ tcell.KeyBacktab: ui.NewKeyAction("Prev", p.nextFocusCmd(dirRight), true),
+ tcell.KeyDown: ui.NewKeyAction("Next", p.nextFocusCmd(dirDown), false),
+ tcell.KeyUp: ui.NewKeyAction("Prev", p.nextFocusCmd(dirUp), false),
+ tcell.KeyRight: ui.NewKeyAction("Next", p.nextFocusCmd(dirLeft), false),
+ tcell.KeyLeft: ui.NewKeyAction("Next", p.nextFocusCmd(dirRight), false),
}))
-
- for i, v := range p.charts {
- tt := strings.Split(v.ID(), "/")
- t := cases.Title(language.Und, cases.NoLower).String(tt[len(tt)-1])
- p.actions.Add(ui.NumKeys[i], ui.NewKeyAction(t, p.sparkFocusCmd(i), true))
- }
}
func (p *Pulse) keyboard(evt *tcell.EventKey) *tcell.EventKey {
@@ -238,13 +298,40 @@ func (p *Pulse) defaultContext() context.Context {
return context.WithValue(context.Background(), internal.KeyFactory, p.app.factory)
}
+func (*Pulse) Restart() {}
+
// Start initializes resource watch loop.
func (p *Pulse) Start() {
p.Stop()
ctx := p.defaultContext()
ctx, p.cancelFn = context.WithCancel(ctx)
- p.model.Watch(ctx)
+ gaugeChan, metricsChan, err := p.model.Watch(ctx)
+ if err != nil {
+ slog.Error("Pulse watch failed", slogs.Error, err)
+ return
+ }
+
+ go func() {
+ for {
+ select {
+ case check, ok := <-gaugeChan:
+ if !ok {
+ return
+ }
+ p.app.QueueUpdateDraw(func() {
+ p.PulseChanged(check)
+ })
+ case mx, ok := <-metricsChan:
+ if !ok {
+ return
+ }
+ p.app.QueueUpdateDraw(func() {
+ p.SeriesChanged(mx)
+ })
+ }
+ }
+ }()
}
// Stop terminates watch loop.
@@ -256,7 +343,7 @@ func (p *Pulse) Stop() {
p.cancelFn = nil
}
-// Refresh updates the view.
+// Refresh updates the view
func (*Pulse) Refresh() {}
// GVR returns a resource descriptor.
@@ -286,6 +373,8 @@ func (*Pulse) AddBindKeysFn(BindKeysFunc) {}
// SetContextFn sets custom context.
func (*Pulse) SetContextFn(ContextFunc) {}
+func (*Pulse) GetContextFn() ContextFunc { return nil }
+
// GetTable return the view table if any.
func (*Pulse) GetTable() *Table {
return nil
@@ -306,24 +395,33 @@ func (*Pulse) ExtraHints() map[string]string {
return nil
}
-func (p *Pulse) sparkFocusCmd(i int) func(evt *tcell.EventKey) *tcell.EventKey {
- return func(*tcell.EventKey) *tcell.EventKey {
- p.app.SetFocus(p.charts[i])
- return nil
- }
-}
-
func (p *Pulse) enterCmd(*tcell.EventKey) *tcell.EventKey {
v := p.App().GetFocus()
s, ok := v.(Graphable)
if !ok {
return nil
}
- res := s.ID()
- if res == client.CpuGVR.String() || res == client.MemGVR.String() {
- res = client.PodGVR.String()
+ g, ok := v.(Graphable)
+ if !ok {
+ return nil
}
- p.App().gotoResource(res+" all", "", false, true)
+ p.prevFocusIndex = p.findIndex(g)
+ for i := range len(p.charts) {
+ gi := p.GetItem(i)
+ if i == p.prevFocusIndex {
+ gi.Focus = true
+ } else {
+ gi.Focus = false
+ }
+ }
+
+ p.Stop()
+ res := client.NewGVR(s.ID()).R()
+ if res == "cpu" || res == "mem" {
+ res = p.app.Config.K9s.DefaultView
+ }
+ p.App().SetFocus(p.App().Main)
+ p.App().gotoResource(res+" "+p.model.GetNamespace(), "", false, true)
return nil
}
@@ -331,10 +429,59 @@ func (p *Pulse) enterCmd(*tcell.EventKey) *tcell.EventKey {
func (p *Pulse) nextFocusCmd(direction int) func(evt *tcell.EventKey) *tcell.EventKey {
return func(*tcell.EventKey) *tcell.EventKey {
v := p.app.GetFocus()
- index := findIndex(p.charts, v)
- p.GetItem(index).Focus = false
- p.GetItem(index).Item.Blur()
- i, v := nextFocus(p.charts, index+direction)
+ g, ok := v.(Graphable)
+ if !ok {
+ return nil
+ }
+
+ currentIndex := p.findIndex(g)
+ nextIndex, total := currentIndex+direction, len(p.charts)
+ if nextIndex < 0 {
+ return nil
+ }
+
+ switch direction {
+ case dirLeft:
+ if nextIndex >= total {
+ return nil
+ }
+ p.prevFocusIndex = -1
+ case dirRight:
+ p.prevFocusIndex = -1
+ case dirUp:
+ if p.app.Conn().HasMetrics() {
+ if currentIndex >= total-2 {
+ if p.prevFocusIndex >= 0 && p.prevFocusIndex != currentIndex {
+ nextIndex = p.prevFocusIndex
+ } else if currentIndex == p.chartGVRs.Len()-1 {
+ nextIndex += 1
+ }
+ } else {
+ p.prevFocusIndex = currentIndex
+ }
+ }
+ case dirDown:
+ if p.app.Conn().HasMetrics() {
+ if currentIndex >= total-6 && currentIndex < total-2 {
+ switch {
+ case (currentIndex % 4) <= 1:
+ p.prevFocusIndex, nextIndex = currentIndex, total-2
+ case (currentIndex % 4) <= 3:
+ p.prevFocusIndex, nextIndex = currentIndex, total-1
+ }
+ } else if currentIndex >= total-2 {
+ return nil
+ }
+ }
+ }
+ if nextIndex < 0 {
+ nextIndex = 0
+ } else if nextIndex > total-1 {
+ nextIndex = currentIndex
+ }
+ p.GetItem(nextIndex).Focus = false
+ p.GetItem(nextIndex).Item.Blur()
+ i, v := p.nextFocus(nextIndex)
p.GetItem(i).Focus = true
p.app.SetFocus(v)
@@ -342,34 +489,33 @@ func (p *Pulse) nextFocusCmd(direction int) func(evt *tcell.EventKey) *tcell.Eve
}
}
-func (p *Pulse) makeSP(loc, span image.Point, gvr *client.GVR) *tchart.SparkLine {
- s := tchart.NewSparkLine(gvr.String())
+func (p *Pulse) makeSP(loc, span image.Point, gvr *client.GVR, unit string) *tchart.SparkLine {
+ s := tchart.NewSparkLine(gvr.String(), unit)
s.SetBackgroundColor(p.app.Styles.Charts().BgColor.Color())
- s.SetBorderPadding(0, 1, 0, 1)
if cc, ok := p.app.Styles.Charts().ResourceColors[gvr.String()]; ok {
s.SetSeriesColors(cc.Colors()...)
} else {
s.SetSeriesColors(p.app.Styles.Charts().DefaultChartColors.Colors()...)
}
- s.SetLegend(fmt.Sprintf(" %s ", cases.Title(language.Und, cases.NoLower).String(gvr.R())))
+ s.SetLegend(fmt.Sprintf(" %s ", cases.Title(language.English).String(gvr.R())))
s.SetInputCapture(p.keyboard)
- s.SetMultiSeries(true)
- p.AddItem(s, loc.X, loc.Y, span.X, span.Y, 0, 0, true)
+ p.AddItem(s, loc.X, loc.Y, span.X, span.Y, 0, 0, false)
return s
}
func (p *Pulse) makeGA(loc, span image.Point, gvr *client.GVR) *tchart.Gauge {
g := tchart.NewGauge(gvr.String())
+ g.SetBorder(true)
g.SetBackgroundColor(p.app.Styles.Charts().BgColor.Color())
if cc, ok := p.app.Styles.Charts().ResourceColors[gvr.String()]; ok {
g.SetSeriesColors(cc.Colors()...)
} else {
g.SetSeriesColors(p.app.Styles.Charts().DefaultDialColors.Colors()...)
}
- g.SetLegend(fmt.Sprintf(" %s ", cases.Title(language.Und, cases.NoLower).String(gvr.R())))
+ g.SetLegend(fmt.Sprintf(" %s ", cases.Title(language.English).String(gvr.R())))
g.SetInputCapture(p.keyboard)
- p.AddItem(g, loc.X, loc.Y, span.X, span.Y, 0, 0, true)
+ p.AddItem(g, loc.X, loc.Y, span.X, span.Y, 0, 0, false)
return g
}
@@ -377,32 +523,23 @@ func (p *Pulse) makeGA(loc, span image.Point, gvr *client.GVR) *tchart.Gauge {
// ----------------------------------------------------------------------------
// Helpers
-func nextFocus(pp []Graphable, index int) (int, tview.Primitive) {
- if index >= len(pp) {
- return 0, pp[0]
+func (p *Pulse) nextFocus(index int) (int, tview.Primitive) {
+ if index >= len(p.chartGVRs) {
+ return 0, p.charts[p.chartGVRs[0]]
}
if index < 0 {
- return len(pp) - 1, pp[len(pp)-1]
+ return len(p.chartGVRs) - 1, p.charts[p.chartGVRs[len(p.chartGVRs)-1]]
}
- return index, pp[index]
+ return index, p.charts[p.chartGVRs[index]]
}
-func findIndex(pp []Graphable, p tview.Primitive) int {
- for i, v := range pp {
- if v == p {
+func (p *Pulse) findIndex(g Graphable) int {
+ for i, gvr := range p.chartGVRs {
+ if gvr.String() == g.ID() {
return i
}
}
return 0
}
-
-func findIndexGVR(pp []Graphable, gvr *client.GVR) (int, bool) {
- for i, v := range pp {
- if v.ID() == gvr.String() {
- return i, true
- }
- }
- return 0, false
-}
diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml
index 3d1f57ad..70ef57f0 100644
--- a/snap/snapcraft.yaml
+++ b/snap/snapcraft.yaml
@@ -1,6 +1,6 @@
name: k9s
base: core22
-version: 'v0.50.4'
+version: 'v0.50.5'
summary: K9s is a CLI to view and manage your Kubernetes clusters.
description: |
K9s is a CLI to view and manage your Kubernetes clusters. By leveraging a terminal UI, you can easily traverse Kubernetes resources and view the state of your clusters in a single powerful session.