From ccebaa604ef66dd77b9ddc4d2142798a414275ee Mon Sep 17 00:00:00 2001 From: Fernand Galiana Date: Wed, 7 May 2025 23:22:01 -0600 Subject: [PATCH] Rel v0.50.5 (#3332) * update pulse view * fix #3301 - pf delete msg * clean * short header styles * fix #3294 event time cols sorting * fix #3309 label selector fault * multi arch build * fix #3328 init co count * rel notes --- Dockerfile | 9 +- Makefile | 40 ++- change_logs/release_v0.50.5.md | 45 +++ go.mod | 37 +-- go.sum | 80 ++--- internal/client/config.go | 3 - internal/client/gvrs.go | 2 + internal/config/json/schemas/skin.json | 5 +- internal/config/styles.go | 10 + internal/dao/recorder.go | 300 ++++++++++++++++++ internal/model/flash.go | 4 +- internal/model/pulse.go | 109 ++----- internal/model/pulse_health.go | 198 ++++++------ internal/model/registry.go | 3 + internal/model1/types.go | 5 + internal/render/base.go | 6 + internal/render/dir.go | 6 + internal/render/ev.go | 39 +++ internal/render/helm/chart.go | 13 + internal/render/helm/history.go | 10 +- internal/render/node.go | 22 ++ internal/render/ns.go | 20 ++ internal/render/pod.go | 52 +++- internal/render/pod_test.go | 2 +- internal/render/table.go | 6 +- internal/render/table_int_test.go | 72 +++++ internal/slogs/keys.go | 3 + internal/tchart/component.go | 44 ++- internal/tchart/component_int_test.go | 15 +- internal/tchart/component_test.go | 15 +- internal/tchart/dot_matrix_test.go | 3 - internal/tchart/gauge.go | 80 ++--- internal/tchart/gauge_int_test.go | 5 +- internal/tchart/gauge_test.go | 101 +++--- internal/tchart/series.go | 77 +++++ internal/tchart/series_test.go | 70 +++++ internal/tchart/sparkline.go | 208 +++++++------ internal/tchart/sparkline_int_test.go | 134 -------- internal/ui/indicator.go | 17 +- internal/ui/table.go | 6 +- internal/ui/types.go | 4 +- internal/view/actions.go | 7 +- internal/view/cmd/args.go | 9 +- internal/view/cmd/args_test.go | 54 +++- internal/view/cmd/interpreter.go | 6 +- internal/view/cmd/interpreter_test.go | 28 ++ internal/view/pf_extender.go | 2 +- internal/view/pulse.go | 409 +++++++++++++++++-------- snap/snapcraft.yaml | 2 +- 49 files changed, 1577 insertions(+), 820 deletions(-) create mode 100644 change_logs/release_v0.50.5.md create mode 100644 internal/dao/recorder.go create mode 100644 internal/render/ev.go create mode 100644 internal/render/table_int_test.go create mode 100644 internal/tchart/series.go create mode 100644 internal/tchart/series_test.go delete mode 100644 internal/tchart/sparkline_int_test.go 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.