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
mine
Fernand Galiana 2025-05-07 23:22:01 -06:00 committed by GitHub
parent f6383dfa3d
commit ccebaa604e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
49 changed files with 1577 additions and 820 deletions

View File

@ -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

View File

@ -1,19 +1,22 @@
NAME := k9s
VERSION ?= v0.50.5
PACKAGE := github.com/derailed/$(NAME)
OUTPUT_BIN ?= execs/${NAME}
GO_FLAGS ?=
GO_TAGS ?= netgo
CGO_ENABLED?=0
OUTPUT_BIN ?= execs/${NAME}
PACKAGE := github.com/derailed/$(NAME)
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")
endif
VERSION ?= v0.50.4
IMG_NAME := derailed/k9s
IMAGE := ${IMG_NAME}:${VERSION}
default: help
@ -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}'

View File

@ -0,0 +1,45 @@
<img src="https://raw.githubusercontent.com/derailed/k9s/master/assets/k9s.png" align="center" width="800" height="auto"/>
# 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
---
<img src="https://raw.githubusercontent.com/derailed/k9s/master/assets/imhotep_logo.png" width="32" height="auto"/> © 2025 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0)

37
go.mod
View File

@ -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
)

80
go.sum
View File

@ -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=

View File

@ -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
}

View File

@ -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")

View File

@ -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": {

View File

@ -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",
}
}

300
internal/dao/recorder.go Normal file
View File

@ -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
}

View File

@ -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...),
)

View File

@ -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)
}
}

View File

@ -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, "")
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 nil, err
return err
}
nmx, err := dial.FetchNodesMetrics(ctx)
if err != nil {
slog.Error("Fetching metrics", slogs.Error, err)
return nil, err
c <- check
}
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

View File

@ -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: {

View File

@ -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.

View File

@ -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
}

View File

@ -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 {

39
internal/render/ev.go Normal file
View File

@ -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
}

View File

@ -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")

View File

@ -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
}

View File

@ -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

View File

@ -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")

View File

@ -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)

View File

@ -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", "<unknown>", "100", "40", "50:250", "50:80", "200", "40", "80", "50", "10.244.0.8", "kind-control-plane", "default", "<none>"}
e := model1.Fields{"default", "sleep", "0", "●", "2/2", "Running", "0", "<unknown>", "100", "40", "50:250", "50:80", "200", "40", "80", "50", "10.244.0.8", "kind-control-plane", "default", "<none>"}
assert.Equal(t, e, r.Fields[:20])
}

View File

@ -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}})

View File

@ -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())
})
}
}

View File

@ -218,4 +218,7 @@ const (
// Duration tracks a duration logger key.
Duration = "duration"
// Type tracks a type logger key.
Type = "type"
)

View File

@ -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
}
@ -36,7 +27,11 @@ func NewComponent(id string) *Component {
Box: tview.NewBox(),
id: id,
noColor: tcell.ColorDefault,
seriesColors: []tcell.Color{tview.Styles.PrimaryTextColor, tview.Styles.FocusColor},
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 {

View File

@ -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())
}

View File

@ -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())
}

View File

@ -1,6 +1,3 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright Authors of K9s
package tchart_test
import (

View File

@ -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
state State
resolution int
deltaOk, deltaS2 delta
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)

View File

@ -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": {

View File

@ -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())
// })
// }
// }

77
internal/tchart/series.go Normal file
View File

@ -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])
}
}

View File

@ -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,
}
}

View File

@ -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,
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]
}

View File

@ -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())
})
}
}

View File

@ -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),
))
})

View File

@ -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
}

View File

@ -17,8 +17,8 @@ import (
)
const (
unlockedIC = "🖍"
lockedIC = "🔑"
unlockedIC = "[RW]"
lockedIC = "[R]"
)
// Namespaceable tracks namespaces.

View File

@ -214,9 +214,12 @@ func pluginAction(r Runner, p *config.Plugin) ui.ActionHandler {
errs = errors.Join(errs, e)
}
if errs != nil {
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 {
if !p.OverwriteOutput {

View File

@ -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)
}

View File

@ -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{},
},

View File

@ -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.

View File

@ -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)
}
})
}
}

View File

@ -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())

View File

@ -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,14 +72,12 @@ 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
@ -67,54 +87,64 @@ type Pulse struct {
model *model.Pulse
cancelFn context.CancelFunc
actions *ui.KeyActions
charts []Graphable
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()),
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
}

View File

@ -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.