add thresholds + pulses tlc + fix #596 #593 #560

mine
derailed 2020-03-04 12:27:47 -07:00
parent e213ba645e
commit 5fcfecb38e
36 changed files with 441 additions and 536 deletions

View File

@ -48,29 +48,6 @@ func NewMetricsServer(c Connection) *MetricsServer {
} }
} }
// NodesMetrics retrieves metrics for a given set of nodes.
func (m *MetricsServer) NodesMetrics(nodes *v1.NodeList, metrics *mv1beta1.NodeMetricsList, mmx NodesMetrics) {
if nodes == nil || metrics == nil {
return
}
for _, no := range nodes.Items {
mmx[no.Name] = NodeMetrics{
AvailCPU: no.Status.Allocatable.Cpu().MilliValue(),
AvailMEM: ToMB(no.Status.Allocatable.Memory().Value()),
TotalCPU: no.Status.Capacity.Cpu().MilliValue(),
TotalMEM: ToMB(no.Status.Capacity.Memory().Value()),
}
}
for _, c := range metrics.Items {
if mx, ok := mmx[c.Name]; ok {
mx.CurrentCPU = c.Usage.Cpu().MilliValue()
mx.CurrentMEM = ToMB(c.Usage.Memory().Value())
mmx[c.Name] = mx
}
}
}
// ClusterLoad retrieves all cluster nodes metrics. // ClusterLoad retrieves all cluster nodes metrics.
func (m *MetricsServer) ClusterLoad(nos *v1.NodeList, nmx *mv1beta1.NodeMetricsList, mx *ClusterMetrics) error { func (m *MetricsServer) ClusterLoad(nos *v1.NodeList, nmx *mv1beta1.NodeMetricsList, mx *ClusterMetrics) error {
if nos == nil || nmx == nil { if nos == nil || nmx == nil {
@ -79,26 +56,32 @@ func (m *MetricsServer) ClusterLoad(nos *v1.NodeList, nmx *mv1beta1.NodeMetricsL
nodeMetrics := make(NodesMetrics, len(nos.Items)) nodeMetrics := make(NodesMetrics, len(nos.Items))
for _, no := range nos.Items { for _, no := range nos.Items {
nodeMetrics[no.Name] = NodeMetrics{ nodeMetrics[no.Name] = NodeMetrics{
AvailCPU: no.Status.Allocatable.Cpu().MilliValue(), AllocatableCPU: no.Status.Allocatable.Cpu().MilliValue(),
AvailMEM: ToMB(no.Status.Allocatable.Memory().Value()), AllocatableMEM: no.Status.Allocatable.Memory().Value(),
AllocatableEphemeral: no.Status.Allocatable.StorageEphemeral().Value(),
} }
} }
for _, mx := range nmx.Items { for _, mx := range nmx.Items {
if m, ok := nodeMetrics[mx.Name]; ok { if node, ok := nodeMetrics[mx.Name]; ok {
m.CurrentCPU = mx.Usage.Cpu().MilliValue() node.CurrentCPU = mx.Usage.Cpu().MilliValue()
m.CurrentMEM = ToMB(mx.Usage.Memory().Value()) node.CurrentMEM = mx.Usage.Memory().Value()
nodeMetrics[mx.Name] = m node.CurrentEphemeral = mx.Usage.StorageEphemeral().Value()
nodeMetrics[mx.Name] = node
} }
} }
var cpu, tcpu, mem, tmem float64 var ccpu, cmem, ceph, tcpu, tmem, teph int64
for _, mx := range nodeMetrics { for _, mx := range nodeMetrics {
cpu += float64(mx.CurrentCPU) ccpu += mx.CurrentCPU
tcpu += float64(mx.AvailCPU) cmem += mx.CurrentMEM
mem += mx.CurrentMEM ceph += mx.CurrentEphemeral
tmem += mx.AvailMEM tcpu += mx.AllocatableCPU
tmem += mx.AllocatableMEM
teph += mx.AllocatableEphemeral
} }
mx.PercCPU, mx.PercMEM = toPerc(cpu, tcpu), toPerc(mem, tmem) mx.PercCPU, mx.PercMEM, mx.PercEphemeral = ToPercentage(ccpu, tcpu),
ToPercentage(cmem, tmem),
ToPercentage(ceph, teph)
return nil return nil
} }
@ -118,6 +101,33 @@ func (m *MetricsServer) checkAccess(ns, gvr, msg string) error {
return nil return nil
} }
// NodesMetrics retrieves metrics for a given set of nodes.
func (m *MetricsServer) NodesMetrics(nodes *v1.NodeList, metrics *mv1beta1.NodeMetricsList, mmx NodesMetrics) {
if nodes == nil || metrics == nil {
return
}
for _, no := range nodes.Items {
mmx[no.Name] = NodeMetrics{
AllocatableCPU: no.Status.Allocatable.Cpu().MilliValue(),
AllocatableMEM: ToMB(no.Status.Allocatable.Memory().Value()),
AllocatableEphemeral: ToMB(no.Status.Allocatable.StorageEphemeral().Value()),
TotalCPU: no.Status.Capacity.Cpu().MilliValue(),
TotalMEM: ToMB(no.Status.Capacity.Memory().Value()),
TotalEphemeral: ToMB(no.Status.Capacity.StorageEphemeral().Value()),
}
}
for _, c := range metrics.Items {
if mx, ok := mmx[c.Name]; ok {
mx.CurrentCPU = c.Usage.Cpu().MilliValue()
mx.CurrentMEM = ToMB(c.Usage.Memory().Value())
mx.AvailableCPU = mx.AllocatableCPU - mx.CurrentCPU
mx.AvailableMEM = mx.AllocatableMEM - mx.CurrentMEM
mmx[c.Name] = mx
}
}
}
// FetchNodesMetrics return all metrics for nodes. // FetchNodesMetrics return all metrics for nodes.
func (m *MetricsServer) FetchNodesMetrics() (*mv1beta1.NodeMetricsList, error) { func (m *MetricsServer) FetchNodesMetrics() (*mv1beta1.NodeMetricsList, error) {
const msg = "user is not authorized to list node metrics" const msg = "user is not authorized to list node metrics"
@ -237,19 +247,20 @@ func (m *MetricsServer) PodsMetrics(pods *mv1beta1.PodMetricsList, mmx PodsMetri
} }
} }
// 0--------------------------------------------------------------------------- // ----------------------------------------------------------------------------
// Helpers... // Helpers...
const megaByte = 1024 * 1024 const megaByte = 1024 * 1024
// ToMB converts bytes to megabytes. // ToMB converts bytes to megabytes.
func ToMB(v int64) float64 { func ToMB(v int64) int64 {
return float64(v) / megaByte return v / megaByte
} }
func toPerc(v1, v2 float64) float64 { // ToPercentageentage computes percentage.
func ToPercentage(v1, v2 int64) int {
if v2 == 0 { if v2 == 0 {
return 0 return 0
} }
return math.Round((v1 / v2) * 100) return int(math.Floor((float64(v1) / float64(v2)) * 100))
} }

View File

@ -12,6 +12,38 @@ import (
v1beta1 "k8s.io/metrics/pkg/apis/metrics/v1beta1" v1beta1 "k8s.io/metrics/pkg/apis/metrics/v1beta1"
) )
func TestToPercentage(t *testing.T) {
uu := []struct {
v1, v2 int64
e int
}{
{0, 0, 0},
{100, 200, 50},
{200, 100, 200},
{224, 4000, 5},
}
for _, u := range uu {
assert.Equal(t, u.e, client.ToPercentage(u.v1, u.v2))
}
}
func TestToMB(t *testing.T) {
const mb = 1024 * 1024
uu := []struct {
v int64
e int64
}{
{0, 0},
{2 * mb, 2},
{10 * mb, 10},
}
for _, u := range uu {
assert.Equal(t, u.e, client.ToMB(u.v))
}
}
func TestPodsMetrics(t *testing.T) { func TestPodsMetrics(t *testing.T) {
uu := map[string]struct { uu := map[string]struct {
metrics *mv1beta1.PodMetricsList metrics *mv1beta1.PodMetricsList
@ -32,8 +64,8 @@ func TestPodsMetrics(t *testing.T) {
eSize: 2, eSize: 2,
e: client.PodsMetrics{ e: client.PodsMetrics{
"default/p1": client.PodMetrics{ "default/p1": client.PodMetrics{
CurrentCPU: int64(3000), CurrentCPU: 3000,
CurrentMEM: float64(12288), CurrentMEM: 12288,
}, },
}, },
}, },
@ -107,8 +139,8 @@ func TestNodesMetrics(t *testing.T) {
"ok": { "ok": {
nodes: &v1.NodeList{ nodes: &v1.NodeList{
Items: []v1.Node{ Items: []v1.Node{
makeNode("n1", "32", "128Gi", "50m", "2Mi"), makeNode("n1", "32", "128Gi", "32", "128Gi"),
makeNode("n2", "8", "4Gi", "50m", "10Mi"), makeNode("n2", "8", "4Gi", "8", "4Gi"),
}, },
}, },
metrics: &v1beta1.NodeMetricsList{ metrics: &v1beta1.NodeMetricsList{
@ -120,13 +152,15 @@ func TestNodesMetrics(t *testing.T) {
eSize: 2, eSize: 2,
e: client.NodesMetrics{ e: client.NodesMetrics{
"n1": client.NodeMetrics{ "n1": client.NodeMetrics{
TotalCPU: int64(32000), TotalCPU: 32000,
TotalMEM: float64(131072), TotalMEM: 131072,
AvailCPU: int64(50), AllocatableCPU: 32000,
AvailMEM: float64(2), AllocatableMEM: 131072,
AvailableCPU: 22000,
AvailableMEM: 122880,
CurrentMetrics: client.CurrentMetrics{ CurrentMetrics: client.CurrentMetrics{
CurrentCPU: int64(10000), CurrentCPU: 10000,
CurrentMEM: float64(8192), CurrentMEM: 8192,
}, },
}, },
}, },

View File

@ -105,8 +105,7 @@ type Connection interface {
// CurrentMetrics tracks current cpu/mem. // CurrentMetrics tracks current cpu/mem.
type CurrentMetrics struct { type CurrentMetrics struct {
CurrentCPU int64 CurrentCPU, CurrentMEM, CurrentEphemeral int64
CurrentMEM float64
} }
// PodMetrics represent an aggregation of all pod containers metrics. // PodMetrics represent an aggregation of all pod containers metrics.
@ -115,16 +114,15 @@ type PodMetrics CurrentMetrics
// NodeMetrics describes raw node metrics. // NodeMetrics describes raw node metrics.
type NodeMetrics struct { type NodeMetrics struct {
CurrentMetrics CurrentMetrics
AvailCPU int64
AvailMEM float64 AllocatableCPU, AllocatableMEM, AllocatableEphemeral int64
TotalCPU int64 AvailableCPU, AvailableMEM, AvailableEphemeral int64
TotalMEM float64 TotalCPU, TotalMEM, TotalEphemeral int64
} }
// ClusterMetrics summarizes total node metrics as percentages. // ClusterMetrics summarizes total node metrics as percentages.
type ClusterMetrics struct { type ClusterMetrics struct {
PercCPU float64 PercCPU, PercMEM, PercEphemeral int
PercMEM float64
} }
// NodesMetrics tracks usage metrics per nodes. // NodesMetrics tracks usage metrics per nodes.

View File

@ -297,6 +297,10 @@ var expectedConfig = `k9s:
- kube-system - kube-system
view: view:
active: ctx active: ctx
thresholds:
cpu: 80
memory: 80
disk: 80
` `
var resetConfig = `k9s: var resetConfig = `k9s:
@ -316,4 +320,8 @@ var resetConfig = `k9s:
- default - default
view: view:
active: po active: po
thresholds:
cpu: 80
memory: 80
disk: 80
` `

View File

@ -20,6 +20,7 @@ type K9s struct {
CurrentCluster string `yaml:"currentCluster"` CurrentCluster string `yaml:"currentCluster"`
FullScreenLogs bool `yaml:"fullScreenLogs"` FullScreenLogs bool `yaml:"fullScreenLogs"`
Clusters map[string]*Cluster `yaml:"clusters,omitempty"` Clusters map[string]*Cluster `yaml:"clusters,omitempty"`
Thresholds *Threshold `yaml:"thresholds"`
manualRefreshRate int manualRefreshRate int
manualHeadless *bool manualHeadless *bool
manualReadOnly *bool manualReadOnly *bool
@ -34,6 +35,7 @@ func NewK9s() *K9s {
LogBufferSize: defaultLogBufferSize, LogBufferSize: defaultLogBufferSize,
LogRequestSize: defaultLogRequestSize, LogRequestSize: defaultLogRequestSize,
Clusters: make(map[string]*Cluster), Clusters: make(map[string]*Cluster),
Thresholds: newThreshold(),
} }
} }
@ -133,12 +135,16 @@ func (k *K9s) checkClusters(ks KubeSettings) {
// Validate the current configuration. // Validate the current configuration.
func (k *K9s) Validate(c client.Connection, ks KubeSettings) { func (k *K9s) Validate(c client.Connection, ks KubeSettings) {
k.validateDefaults() k.validateDefaults()
if k.Clusters == nil { if k.Clusters == nil {
k.Clusters = map[string]*Cluster{} k.Clusters = map[string]*Cluster{}
} }
k.checkClusters(ks) k.checkClusters(ks)
if k.Thresholds == nil {
k.Thresholds = newThreshold()
}
k.Thresholds.Validate(c, ks)
if ctx, err := ks.CurrentContextName(); err == nil && len(k.CurrentContext) == 0 { if ctx, err := ks.CurrentContextName(); err == nil && len(k.CurrentContext) == 0 {
k.CurrentContext = ctx k.CurrentContext = ctx
k.CurrentCluster = "" k.CurrentCluster = ""

View File

@ -213,6 +213,10 @@ func newCharts() Charts {
ChartBgColor: "default", ChartBgColor: "default",
DefaultDialColors: Colors{Color("palegreen"), Color("orangered")}, DefaultDialColors: Colors{Color("palegreen"), Color("orangered")},
DefaultChartColors: Colors{Color("palegreen"), Color("orangered")}, DefaultChartColors: Colors{Color("palegreen"), Color("orangered")},
ResourceColors: map[string]Colors{
"cpu": Colors{Color("dodgerblue"), Color("darkslateblue")},
"mem": Colors{Color("yellow"), Color("goldenrod")},
},
} }
} }
func newViews() Views { func newViews() Views {

View File

@ -0,0 +1,54 @@
package config
import (
"github.com/derailed/k9s/internal/client"
)
const (
defaultCPU = 80
defaultMEM = 80
defaultDisk = 70
)
// Threshold tracks threshold to alert user when excided.
type Threshold struct {
CPU int `yaml:"cpu"`
Memory int `yaml:"memory"`
Disk int `yaml:"disk"`
}
func newThreshold() *Threshold {
return &Threshold{
CPU: defaultCPU,
Memory: defaultMEM,
Disk: defaultMEM,
}
}
// Validate a namespace is setup correctly
func (t *Threshold) Validate(c client.Connection, ks KubeSettings) {
if t.CPU == 0 || t.CPU > 100 {
t.CPU = defaultCPU
}
if t.Memory == 0 || t.Memory > 100 {
t.Memory = defaultMEM
}
if t.Disk == 0 || t.Disk > 100 {
t.Disk = defaultDisk
}
}
// ExceedsCPUPerc returns true if current metrics exceeds threshold or false otherwise.
func (t *Threshold) ExceedsCPUPerc(p int) bool {
return p >= t.CPU
}
// ExceedsMemoryPerc returns true if current metrics exceeds threshold or false otherwise.
func (t *Threshold) ExceedsMemoryPerc(p int) bool {
return p >= t.Memory
}
// ExceedsDiskPerc returns true if current metrics exceeds threshold or false otherwise.
func (t *Threshold) ExceedsDiskPerc(p int) bool {
return p >= t.Disk
}

View File

@ -24,7 +24,7 @@ func NewCheck(gvr string) *Check {
} }
// Set sets a health metric. // Set sets a health metric.
func (c *Check) Set(l Level, v int) { func (c *Check) Set(l Level, v int64) {
c.Counts[l] = v c.Counts[l] = v
} }
@ -34,12 +34,12 @@ func (c *Check) Inc(l Level) {
} }
// Total stores a metric total. // Total stores a metric total.
func (c *Check) Total(n int) { func (c *Check) Total(n int64) {
c.Counts[Corpus] = n c.Counts[Corpus] = n
} }
// Tally retrieves a given health metric. // Tally retrieves a given health metric.
func (c *Check) Tally(l Level) int { func (c *Check) Tally(l Level) int64 {
return c.Counts[l] return c.Counts[l]
} }

View File

@ -13,14 +13,14 @@ func TestCheck(t *testing.T) {
c := health.NewCheck("test") c := health.NewCheck("test")
n := 0 n := 0
for i := 0; i < 10; i++ { for i := 0; i < 10; i++ {
c.Inc(health.OK) c.Inc(health.S1)
cc = append(cc, c) cc = append(cc, c)
n++ n++
} }
c.Total(n) c.Total(int64(n))
assert.Equal(t, 10, len(cc)) assert.Equal(t, 10, len(cc))
assert.Equal(t, 10, c.Tally(health.Corpus)) assert.Equal(t, int64(10), c.Tally(health.Corpus))
assert.Equal(t, 10, c.Tally(health.OK)) assert.Equal(t, int64(10), c.Tally(health.S1))
assert.Equal(t, 0, c.Tally(health.Toast)) assert.Equal(t, int64(0), c.Tally(health.S2))
} }

View File

@ -10,14 +10,14 @@ const (
// Corpus tracks total health. // Corpus tracks total health.
Corpus Corpus
// OK tracks healhy. // S1 tracks series 1.
OK S1
// Warn tracks health warnings. // S2 tracks series 2.
Warn S2
// Toast tracks unhealties. // S3 tracks series 3.
Toast S3
) )
// Message represents a health message. // Message represents a health message.
@ -32,7 +32,7 @@ type Message struct {
type Messages []Message type Messages []Message
// Counts tracks health counts by category. // Counts tracks health counts by category.
type Counts map[Level]int type Counts map[Level]int64
// Vital tracks a resource vitals. // Vital tracks a resource vitals.
type Vital struct { type Vital struct {

View File

@ -3,7 +3,6 @@ package model
import ( import (
"github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/client"
"github.com/derailed/k9s/internal/dao" "github.com/derailed/k9s/internal/dao"
"github.com/derailed/k9s/internal/render"
) )
// ClusterInfoListener registers a listener for model changes. // ClusterInfoListener registers a listener for model changes.
@ -20,31 +19,35 @@ const NA = "n/a"
// ClusterMeta represents cluster meta data. // ClusterMeta represents cluster meta data.
type ClusterMeta struct { type ClusterMeta struct {
Context, Cluster string Context, Cluster string
User string User string
K9sVer, K8sVer string K9sVer, K8sVer string
Cpu, Mem float64 Cpu, Mem, Ephemeral int
} }
// NewClusterMeta returns a new instance. // NewClusterMeta returns a new instance.
func NewClusterMeta() ClusterMeta { func NewClusterMeta() ClusterMeta {
return ClusterMeta{ return ClusterMeta{
Context: NA, Context: NA,
Cluster: NA, Cluster: NA,
User: NA, User: NA,
K9sVer: NA, K9sVer: NA,
K8sVer: NA, K8sVer: NA,
Cpu: 0, Cpu: 0,
Mem: 0, Mem: 0,
Ephemeral: 0,
} }
} }
// Deltas diffs cluster meta return true if different, false otherwise. // Deltas diffs cluster meta return true if different, false otherwise.
func (c ClusterMeta) Deltas(n ClusterMeta) bool { func (c ClusterMeta) Deltas(n ClusterMeta) bool {
if render.AsPerc(c.Cpu) != render.AsPerc(n.Cpu) { if c.Cpu != n.Cpu {
return true return true
} }
if render.AsPerc(c.Mem) != render.AsPerc(n.Mem) { if c.Mem != n.Mem {
return true
}
if c.Ephemeral != n.Ephemeral {
return true return true
} }
@ -89,7 +92,7 @@ func (c *ClusterInfo) Refresh() {
var mx client.ClusterMetrics var mx client.ClusterMetrics
if err := c.cluster.Metrics(&mx); err == nil { if err := c.cluster.Metrics(&mx); err == nil {
data.Cpu, data.Mem = mx.PercCPU, mx.PercMEM data.Cpu, data.Mem, data.Ephemeral = mx.PercCPU, mx.PercMEM, mx.PercEphemeral
} }
if c.data.Deltas(data) { if c.data.Deltas(data) {

View File

@ -3,7 +3,6 @@ package model
import ( import (
"context" "context"
"fmt" "fmt"
"math"
"time" "time"
"github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/client"
@ -65,21 +64,38 @@ func (h *PulseHealth) List(ctx context.Context, ns string) ([]runtime.Object, er
func (h *PulseHealth) checkMetrics() (health.Checks, error) { func (h *PulseHealth) checkMetrics() (health.Checks, error) {
dial := client.DialMetrics(h.factory.Client()) dial := client.DialMetrics(h.factory.Client())
nn, err := dao.FetchNodes(h.factory, "")
if err != nil {
return nil, err
}
nmx, err := dial.FetchNodesMetrics() nmx, err := dial.FetchNodesMetrics()
if err != nil { if err != nil {
log.Error().Err(err).Msgf("Fetching metrics") log.Error().Err(err).Msgf("Fetching metrics")
return nil, err return nil, err
} }
var cpu, mem float64 mx := make(client.NodesMetrics, len(nn.Items))
for _, mx := range nmx.Items { dial.NodesMetrics(nn, nmx, mx)
cpu += float64(mx.Usage.Cpu().MilliValue())
mem += client.ToMB(mx.Usage.Memory().Value()) 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("cpu") c1 := health.NewCheck("cpu")
c1.Set(health.OK, int(math.Round(cpu))) c1.Set(health.S1, ccpu)
c1.Set(health.S2, acpu)
c1.Set(health.S3, tcpu)
c2 := health.NewCheck("mem") c2 := health.NewCheck("mem")
c2.Set(health.OK, int(math.Round(mem))) c2.Set(health.S1, cmem)
c2.Set(health.S2, amem)
c2.Set(health.S3, tmem)
return health.Checks{c1, c2}, nil return health.Checks{c1, c2}, nil
} }
@ -100,16 +116,16 @@ func (h *PulseHealth) check(ctx context.Context, ns, gvr string) (*health.Check,
} }
c := health.NewCheck(gvr) c := health.NewCheck(gvr)
c.Total(len(oo)) c.Total(int64(len(oo)))
rr, re := make(render.Rows, len(oo)), meta.Renderer rr, re := make(render.Rows, len(oo)), meta.Renderer
for i, o := range oo { for i, o := range oo {
if err := re.Render(o, ns, &rr[i]); err != nil { if err := re.Render(o, ns, &rr[i]); err != nil {
return nil, err return nil, err
} }
if !render.Happy(ns, re.Header(ns), rr[i]) { if !render.Happy(ns, re.Header(ns), rr[i]) {
c.Inc(health.Toast) c.Inc(health.S2)
} else { } else {
c.Inc(health.OK) c.Inc(health.S1)
} }
} }

View File

@ -12,8 +12,6 @@ import (
"github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/client"
"github.com/derailed/tview" "github.com/derailed/tview"
"github.com/gdamore/tcell" "github.com/gdamore/tcell"
"golang.org/x/text/language"
"golang.org/x/text/message"
"k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/runtime/schema"
) )
@ -163,13 +161,7 @@ func (Benchmark) countReq(rr [][]string) string {
sum += m sum += m
} }
} }
return asNum(sum) return AsThousands(int64(sum))
}
// AsNumb prints a number with thousand separator.
func asNum(n int) string {
p := message.NewPrinter(language.English)
return p.Sprintf("%d", n)
} }
// BenchInfo represents benchmark run info. // BenchInfo represents benchmark run info.

View File

@ -6,6 +6,7 @@ import (
"strconv" "strconv"
"strings" "strings"
"github.com/derailed/k9s/internal/client"
"github.com/derailed/tview" "github.com/derailed/tview"
"github.com/gdamore/tcell" "github.com/gdamore/tcell"
v1 "k8s.io/api/core/v1" v1 "k8s.io/api/core/v1"
@ -142,7 +143,7 @@ func gatherMetrics(co *v1.Container, mx *mv1beta1.ContainerMetrics) (c, p, l met
} }
cpu := mx.Usage.Cpu().MilliValue() cpu := mx.Usage.Cpu().MilliValue()
mem := ToMB(mx.Usage.Memory().Value()) mem := client.ToMB(mx.Usage.Memory().Value())
c = metric{ c = metric{
cpu: ToMillicore(cpu), cpu: ToMillicore(cpu),
mem: ToMi(mem), mem: ToMi(mem),
@ -150,18 +151,18 @@ func gatherMetrics(co *v1.Container, mx *mv1beta1.ContainerMetrics) (c, p, l met
rcpu, rmem := containerResources(*co) rcpu, rmem := containerResources(*co)
if rcpu != nil { if rcpu != nil {
p.cpu = AsPerc(toPerc(float64(cpu), float64(rcpu.MilliValue()))) p.cpu = IntToStr(client.ToPercentage(cpu, rcpu.MilliValue()))
} }
if rmem != nil { if rmem != nil {
p.mem = AsPerc(toPerc(mem, ToMB(rmem.Value()))) p.mem = IntToStr(client.ToPercentage(mem, client.ToMB(rmem.Value())))
} }
lcpu, lmem := containerLimits(*co) lcpu, lmem := containerLimits(*co)
if lcpu != nil { if lcpu != nil {
l.cpu = AsPerc(toPerc(float64(cpu), float64(lcpu.MilliValue()))) l.cpu = IntToStr(client.ToPercentage(cpu, lcpu.MilliValue()))
} }
if lmem != nil { if lmem != nil {
l.mem = AsPerc(toPerc(mem, ToMB(lmem.Value()))) l.mem = IntToStr(client.ToPercentage(mem, client.ToMB(lmem.Value())))
} }
return return

View File

@ -24,10 +24,9 @@ func (Deployment) Header(ns string) Header {
return Header{ return Header{
HeaderColumn{Name: "NAMESPACE"}, HeaderColumn{Name: "NAMESPACE"},
HeaderColumn{Name: "NAME"}, HeaderColumn{Name: "NAME"},
HeaderColumn{Name: "READY"}, HeaderColumn{Name: "READY", Align: tview.AlignRight},
HeaderColumn{Name: "UP-TO-DATE", Align: tview.AlignRight}, HeaderColumn{Name: "UP-TO-DATE", Align: tview.AlignRight},
HeaderColumn{Name: "AVAILABLE", Align: tview.AlignRight}, HeaderColumn{Name: "AVAILABLE", Align: tview.AlignRight},
HeaderColumn{Name: "READY", Align: tview.AlignRight},
HeaderColumn{Name: "LABELS", Wide: true}, HeaderColumn{Name: "LABELS", Wide: true},
HeaderColumn{Name: "VALID", Wide: true}, HeaderColumn{Name: "VALID", Wide: true},
HeaderColumn{Name: "AGE", Time: true, Decorator: AgeDecorator}, HeaderColumn{Name: "AGE", Time: true, Decorator: AgeDecorator},
@ -54,7 +53,6 @@ func (d Deployment) Render(o interface{}, ns string, r *Row) error {
strconv.Itoa(int(dp.Status.AvailableReplicas)) + "/" + strconv.Itoa(int(dp.Status.Replicas)), strconv.Itoa(int(dp.Status.AvailableReplicas)) + "/" + strconv.Itoa(int(dp.Status.Replicas)),
strconv.Itoa(int(dp.Status.UpdatedReplicas)), strconv.Itoa(int(dp.Status.UpdatedReplicas)),
strconv.Itoa(int(dp.Status.AvailableReplicas)), strconv.Itoa(int(dp.Status.AvailableReplicas)),
strconv.Itoa(int(dp.Status.ReadyReplicas)),
mapToStr(dp.Labels), mapToStr(dp.Labels),
asStatus(d.diagnose(dp.Status.Replicas, dp.Status.AvailableReplicas)), asStatus(d.diagnose(dp.Status.Replicas, dp.Status.AvailableReplicas)),
toAge(dp.ObjectMeta.CreationTimestamp), toAge(dp.ObjectMeta.CreationTimestamp),

View File

@ -9,10 +9,18 @@ import (
"github.com/derailed/tview" "github.com/derailed/tview"
runewidth "github.com/mattn/go-runewidth" runewidth "github.com/mattn/go-runewidth"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"golang.org/x/text/language"
"golang.org/x/text/message"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/util/duration" "k8s.io/apimachinery/pkg/util/duration"
) )
// AsThousands prints a number with thousand separator.
func AsThousands(n int64) string {
p := message.NewPrinter(language.English)
return p.Sprintf("%d", n)
}
// Happy returns true if resoure is happy, false otherwise // Happy returns true if resoure is happy, false otherwise
func Happy(ns string, h Header, r Row) bool { func Happy(ns string, h Header, r Row) bool {
if len(r.Fields) == 0 { if len(r.Fields) == 0 {
@ -25,12 +33,12 @@ func Happy(ns string, h Header, r Row) bool {
return strings.TrimSpace(r.Fields[validCol]) == "" return strings.TrimSpace(r.Fields[validCol]) == ""
} }
const megaByte = 1024 * 1024 // const megaByte = 1024 * 1024
// ToMB converts bytes to megabytes. // // ToMB converts bytes to megabytes.
func ToMB(v int64) float64 { // func ToMB(v int64) float64 {
return float64(v) / megaByte // return float64(v) / megaByte
} // }
func asStatus(err error) string { func asStatus(err error) string {
if err == nil { if err == nil {
@ -112,22 +120,14 @@ func join(a []string, sep string) string {
return buff.String() return buff.String()
} }
// ToPerc prints a number as percentage. // PrintPerc prints a number as percentage.
func ToPerc(f float64) string { func PrintPerc(p int) string {
return AsPerc(f) + "%" return strconv.Itoa(p) + "%"
} }
// AsPerc prints a number as a percentage. // IntToStr converts an int to a string.
func AsPerc(f float64) string { func IntToStr(p int) string {
return strconv.Itoa(int(f)) return strconv.Itoa(int(p))
}
// ToPerc computes the ratio of two numbers as a percentage.
func toPerc(v1, v2 float64) float64 {
if v2 == 0 {
return 0
}
return (v1 / v2) * 100
} }
func missing(s string) string { func missing(s string) string {
@ -233,7 +233,7 @@ func ToMillicore(v int64) string {
} }
// ToMi shows mem reading for human. // ToMi shows mem reading for human.
func ToMi(v float64) string { func ToMi(v int64) string {
return strconv.Itoa(int(v)) return strconv.Itoa(int(v))
} }

View File

@ -9,35 +9,6 @@ import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
) )
func TestToMB(t *testing.T) {
uu := []struct {
v int64
e float64
}{
{0, 0},
{2 * megaByte, 2},
{10 * megaByte, 10},
}
for _, u := range uu {
assert.Equal(t, u.e, ToMB(u.v))
}
}
func TestToPerc(t *testing.T) {
uu := []struct {
v1, v2, e float64
}{
{0, 0, 0},
{100, 200, 50},
{200, 100, 200},
}
for _, u := range uu {
assert.Equal(t, u.e, toPerc(u.v1, u.v2))
}
}
func TestToAge(t *testing.T) { func TestToAge(t *testing.T) {
uu := map[string]struct { uu := map[string]struct {
t time.Time t time.Time
@ -343,7 +314,7 @@ func TestToMillicore(t *testing.T) {
func TestToMi(t *testing.T) { func TestToMi(t *testing.T) {
uu := []struct { uu := []struct {
v float64 v int64
e string e string
}{ }{
{0, "0"}, {0, "0"},
@ -356,27 +327,25 @@ func TestToMi(t *testing.T) {
} }
} }
func TestAsPerc(t *testing.T) { func TestIntToStr(t *testing.T) {
uu := []struct { uu := []struct {
v float64 v int
e string e string
}{ }{
{0, "0"}, {0, "0"},
{10.5, "10"},
{10, "10"}, {10, "10"},
{0.05, "0"},
} }
for _, u := range uu { for _, u := range uu {
assert.Equal(t, u.e, AsPerc(u.v)) assert.Equal(t, u.e, IntToStr(u.v))
} }
} }
func BenchmarkAsPerc(b *testing.B) { func BenchmarkIntToStr(b *testing.B) {
v := 10.5 v := 10
b.ResetTimer() b.ResetTimer()
b.ReportAllocs() b.ReportAllocs()
for n := 0; n < b.N; n++ { for n := 0; n < b.N; n++ {
AsPerc(v) IntToStr(v)
} }
} }

View File

@ -259,12 +259,12 @@ func resourceMetricsV2b2(i int, spec autoscalingv2beta2.MetricSpec, statuses []a
} }
if len(statuses) > i && statuses[i].Resource != nil && statuses[i].Resource.Current.AverageUtilization != nil { if len(statuses) > i && statuses[i].Resource != nil && statuses[i].Resource.Current.AverageUtilization != nil {
current = AsPerc(float64(*statuses[i].Resource.Current.AverageUtilization)) current = IntToStr(int(*statuses[i].Resource.Current.AverageUtilization))
} }
target := "<auto>" target := "<auto>"
if spec.Resource.Target.AverageUtilization != nil { if spec.Resource.Target.AverageUtilization != nil {
target = AsPerc(float64(*spec.Resource.Target.AverageUtilization)) target = IntToStr(int(*spec.Resource.Target.AverageUtilization))
} }
return current + "/" + target return current + "/" + target
@ -293,12 +293,12 @@ func resourceMetricsV2b1(i int, spec autoscalingv2beta1.MetricSpec, statuses []a
} }
if len(statuses) > i && statuses[i].Resource != nil && statuses[i].Resource.CurrentAverageUtilization != nil { if len(statuses) > i && statuses[i].Resource != nil && statuses[i].Resource.CurrentAverageUtilization != nil {
current = AsPerc(float64(*statuses[i].Resource.CurrentAverageUtilization)) current = IntToStr(int(*statuses[i].Resource.CurrentAverageUtilization))
} }
target := "<auto>" target := "<auto>"
if spec.Resource.TargetAverageUtilization != nil { if spec.Resource.TargetAverageUtilization != nil {
target = AsPerc(float64(*spec.Resource.TargetAverageUtilization)) target = IntToStr(int(*spec.Resource.TargetAverageUtilization))
} }
return current + "/" + target return current + "/" + target

View File

@ -141,23 +141,21 @@ func gatherNodeMX(no *v1.Node, mx *mv1beta1.NodeMetrics) (c metric, a metric, p
return return
} }
cpu := mx.Usage.Cpu().MilliValue() cpu, mem := mx.Usage.Cpu().MilliValue(), client.ToMB(mx.Usage.Memory().Value())
mem := ToMB(mx.Usage.Memory().Value())
c = metric{ c = metric{
cpu: ToMillicore(cpu), cpu: ToMillicore(cpu),
mem: ToMi(mem), mem: ToMi(mem),
} }
acpu := no.Status.Allocatable.Cpu().MilliValue() acpu, amem := no.Status.Allocatable.Cpu().MilliValue(), client.ToMB(no.Status.Allocatable.Memory().Value())
amem := ToMB(no.Status.Allocatable.Memory().Value())
a = metric{ a = metric{
cpu: ToMillicore(acpu), cpu: ToMillicore(acpu),
mem: ToMi(amem), mem: ToMi(amem),
} }
p = metric{ p = metric{
cpu: AsPerc(toPerc(float64(cpu), float64(acpu))), cpu: IntToStr(client.ToPercentage(cpu, acpu)),
mem: AsPerc(toPerc(mem, amem)), mem: IntToStr(client.ToPercentage(mem, amem)),
} }
return return

View File

@ -156,16 +156,16 @@ func (*Pod) gatherPodMX(pod *v1.Pod, mx *mv1beta1.PodMetrics) (c, p metric) {
cpu, mem := currentRes(mx) cpu, mem := currentRes(mx)
c = metric{ c = metric{
cpu: ToMillicore(cpu.MilliValue()), cpu: ToMillicore(cpu.MilliValue()),
mem: ToMi(ToMB(mem.Value())), mem: ToMi(client.ToMB(mem.Value())),
} }
rc, rm := requestedRes(pod.Spec.Containers) rc, rm := requestedRes(pod.Spec.Containers)
lc, lm := resourceLimits(pod.Spec.Containers) lc, lm := resourceLimits(pod.Spec.Containers)
p = metric{ p = metric{
cpu: AsPerc(toPerc(float64(cpu.MilliValue()), float64(rc.MilliValue()))), cpu: IntToStr(client.ToPercentage(cpu.MilliValue(), rc.MilliValue())),
mem: AsPerc(toPerc(ToMB(mem.Value()), ToMB(rm.Value()))), mem: IntToStr(client.ToPercentage(client.ToMB(mem.Value()), client.ToMB(rm.Value()))),
cpuLim: AsPerc(toPerc(float64(cpu.MilliValue()), float64(lc.MilliValue()))), cpuLim: IntToStr(client.ToPercentage(cpu.MilliValue(), lc.MilliValue())),
memLim: AsPerc(toPerc(ToMB(mem.Value()), ToMB(lm.Value()))), memLim: IntToStr(client.ToPercentage(client.ToMB(mem.Value()), client.ToMB(lm.Value()))),
} }
return return

View File

@ -70,8 +70,8 @@ func (f PortForward) Render(o interface{}, gvr string, r *Row) error {
pf.Container(), pf.Container(),
strings.Join(pf.Ports(), ","), strings.Join(pf.Ports(), ","),
UrlFor(pf.Config.Host, pf.Config.Path, ports[0]), UrlFor(pf.Config.Host, pf.Config.Path, ports[0]),
asNum(pf.Config.C), AsThousands(int64(pf.Config.C)),
asNum(pf.Config.N), AsThousands(int64(pf.Config.N)),
"", "",
pf.Age(), pf.Age(),
} }

View File

@ -98,7 +98,7 @@ func (c *Component) GetSeriesColorNames() []string {
c.mx.RLock() c.mx.RLock()
defer c.mx.RUnlock() defer c.mx.RUnlock()
var nn []string nn := make([]string, 0, len(c.seriesColors))
for _, color := range c.seriesColors { for _, color := range c.seriesColors {
for name, co := range tcell.ColorNames { for name, co := range tcell.ColorNames {
if co == color { if co == color {

View File

@ -1,8 +1,6 @@
package tchart package tchart
import ( import (
"fmt"
"github.com/derailed/tview" "github.com/derailed/tview"
) )
@ -24,12 +22,6 @@ const (
lv = '\u257b' lv = '\u257b'
) )
// Segment represents a dial segment.
type Segment []int
// Segments represents a collection of segments.
type Segments []Segment
// Matrix represents a number dial. // Matrix represents a number dial.
type Matrix [][]rune type Matrix [][]rune
@ -42,94 +34,16 @@ type DotMatrix struct {
} }
// NewDotMatrix returns a new matrix. // NewDotMatrix returns a new matrix.
func NewDotMatrix(row, col int) DotMatrix { func NewDotMatrix() DotMatrix {
return DotMatrix{ return DotMatrix{
row: row, row: 3,
col: col, col: 3,
} }
} }
// Print prints the matrix. // Print prints the matrix.
func (d DotMatrix) Print(n int) Matrix { func (d DotMatrix) Print(n int) Matrix {
if d.row == d.col { return To3x3Char(n)
return To3x3Char(n)
}
m := make(Matrix, d.row)
segs := asSegments(n)
for row := 0; row < d.row; row++ {
for col := 0; col < d.col; col++ {
m[row] = append(m[row], segs.CharFor(row, col))
}
}
return m
}
func asSegments(n int) Segment {
switch n {
case 0:
return Segment{1, 1, 1, 0, 1, 1, 1}
case 1:
return Segment{0, 0, 1, 0, 0, 1, 0}
case 2:
return Segment{1, 0, 1, 1, 1, 0, 1}
case 3:
return Segment{1, 0, 1, 1, 0, 1, 1}
case 4:
return Segment{0, 1, 0, 1, 0, 1, 0}
case 5:
return Segment{1, 1, 0, 1, 0, 1, 1}
case 6:
return Segment{0, 1, 0, 1, 1, 1, 1}
case 7:
return Segment{1, 0, 1, 0, 0, 1, 0}
case 8:
return Segment{1, 1, 1, 1, 1, 1, 1}
case 9:
return Segment{1, 1, 1, 1, 0, 1, 0}
default:
panic(fmt.Sprintf("NYI %d", n))
}
}
// CharFor returns a char based on row/col.
func (s Segment) CharFor(row, col int) rune {
c := dots[0]
segs := ToSegments(row, col)
if segs == nil {
return c
}
for _, seg := range segs {
if s[seg] == 1 {
c = charForSeg(seg, row, col)
}
}
return c
}
func charForSeg(seg, row, col int) rune {
switch seg {
case 0, 3, 6:
return dots[2]
}
if row == 0 && (col == 0 || col == 2) {
return dots[2]
}
return dots[3]
}
var segs = map[int][][]int{
0: {{1, 0}, {0}, {2, 0}},
1: {{1}, nil, {2}},
2: {{1, 3}, {3}, {2, 3}},
3: {{4}, nil, {5}},
4: {{4, 6}, {6}, {5, 6}},
}
// ToSegments return path segments.
func ToSegments(row, col int) []int {
return segs[row][col]
} }
// To3x3Char returns 3x3 number matrix // To3x3Char returns 3x3 number matrix

View File

@ -1,7 +1,6 @@
package tchart_test package tchart_test
import ( import (
"fmt"
"strconv" "strconv"
"testing" "testing"
@ -9,130 +8,12 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
func TestSegmentFor(t *testing.T) {
uu := map[string]struct {
r, c int
e []int
}{
"0x0": {r: 0, c: 0, e: []int{1, 0}},
"0x1": {r: 0, c: 1, e: []int{0}},
"0x2": {r: 0, c: 2, e: []int{2, 0}},
"1x0": {r: 1, c: 0, e: []int{1}},
"1x1": {r: 1, c: 1, e: nil},
"1x2": {r: 1, c: 2, e: []int{2}},
"2x0": {r: 2, c: 0, e: []int{1, 3}},
"2x1": {r: 2, c: 1, e: []int{3}},
"2x2": {r: 2, c: 2, e: []int{2, 3}},
"3x0": {r: 3, c: 0, e: []int{4}},
"3x1": {r: 3, c: 1, e: nil},
"3x2": {r: 3, c: 2, e: []int{5}},
"4x0": {r: 4, c: 0, e: []int{4, 6}},
"4x1": {r: 4, c: 1, e: []int{6}},
"4x2": {r: 4, c: 2, e: []int{5, 6}},
}
for k := range uu {
u := uu[k]
t.Run(k, func(t *testing.T) {
assert.Equal(t, u.e, tchart.ToSegments(u.r, u.c))
})
}
}
func TestDial5x3(t *testing.T) {
d := tchart.NewDotMatrix(5, 3)
for n := 0; n <= 9; n++ {
i := n
t.Run(strconv.Itoa(n), func(t *testing.T) {
assert.Equal(t, numbers3x5[i], d.Print(i))
})
}
}
func TestDial3x3(t *testing.T) { func TestDial3x3(t *testing.T) {
d := tchart.NewDotMatrix(3, 3) d := tchart.NewDotMatrix()
for n := 0; n <= 2; n++ { for n := 0; n <= 2; n++ {
i := n i := n
t.Run(strconv.Itoa(n), func(t *testing.T) { t.Run(strconv.Itoa(n), func(t *testing.T) {
fmt.Println(tchart.To3x3Char(i))
assert.Equal(t, tchart.To3x3Char(i), d.Print(i)) assert.Equal(t, tchart.To3x3Char(i), d.Print(i))
}) })
} }
} }
// Helpers...
const hChar, vChar = '▤', '▥'
var numbers3x5 = []tchart.Matrix{
[][]rune{
{hChar, hChar, hChar},
{vChar, ' ', vChar},
{vChar, ' ', vChar},
{vChar, ' ', vChar},
{hChar, hChar, hChar},
},
[][]rune{
{' ', ' ', hChar},
{' ', ' ', vChar},
{' ', ' ', vChar},
{' ', ' ', vChar},
{' ', ' ', vChar},
},
[][]rune{
{hChar, hChar, hChar},
{' ', ' ', vChar},
{hChar, hChar, hChar},
{vChar, ' ', ' '},
{hChar, hChar, hChar},
},
[][]rune{
{hChar, hChar, hChar},
{' ', ' ', vChar},
{hChar, hChar, hChar},
{' ', ' ', vChar},
{hChar, hChar, hChar},
},
[][]rune{
{hChar, ' ', ' '},
{vChar, ' ', ' '},
{hChar, hChar, hChar},
{' ', ' ', vChar},
{' ', ' ', vChar},
},
[][]rune{
{hChar, hChar, hChar},
{vChar, ' ', ' '},
{hChar, hChar, hChar},
{' ', ' ', vChar},
{hChar, hChar, hChar},
},
[][]rune{
{hChar, ' ', ' '},
{vChar, ' ', ' '},
{hChar, hChar, hChar},
{vChar, ' ', vChar},
{hChar, hChar, hChar},
},
[][]rune{
{hChar, hChar, hChar},
{' ', ' ', vChar},
{' ', ' ', vChar},
{' ', ' ', vChar},
{' ', ' ', vChar},
},
[][]rune{
{hChar, hChar, hChar},
{vChar, ' ', vChar},
{hChar, hChar, hChar},
{vChar, ' ', vChar},
{hChar, hChar, hChar},
},
[][]rune{
{hChar, hChar, hChar},
{vChar, ' ', vChar},
{hChar, hChar, hChar},
{' ', ' ', vChar},
{' ', ' ', vChar},
},
}

View File

@ -27,9 +27,9 @@ type delta int
type Gauge struct { type Gauge struct {
*Component *Component
data Metric data Metric
resolution int resolution int
deltaOk, deltaFault delta deltaOk, deltaS2 delta
} }
// NewGauge returns a new gauge. // NewGauge returns a new gauge.
@ -53,7 +53,7 @@ func (g *Gauge) Add(m Metric) {
g.mx.Lock() g.mx.Lock()
defer g.mx.Unlock() defer g.mx.Unlock()
g.deltaOk, g.deltaFault = computeDelta(g.data.OK, m.OK), computeDelta(g.data.Fault, m.Fault) g.deltaOk, g.deltaS2 = computeDelta(g.data.S1, m.S1), computeDelta(g.data.S2, m.S2)
g.data = m g.data = m
} }
@ -80,12 +80,12 @@ func (g *Gauge) Draw(sc tcell.Screen) {
) )
s1C, s2C := g.colorForSeries() s1C, s2C := g.colorForSeries()
d1, d2 := fmt.Sprintf(fmat, g.data.OK), fmt.Sprintf(fmat, g.data.Fault) d1, d2 := fmt.Sprintf(fmat, g.data.S1), fmt.Sprintf(fmat, g.data.S2)
o.X -= len(d1) * 3 o.X -= len(d1) * 3
g.drawNum(sc, true, o, g.data.OK, g.deltaOk, d1, style.Foreground(s1C).Dim(false)) g.drawNum(sc, true, o, g.data.S1, g.deltaOk, d1, style.Foreground(s1C).Dim(false))
o.X = mid.X + 1 o.X = mid.X + 1
g.drawNum(sc, false, o, g.data.Fault, g.deltaFault, d2, style.Foreground(s2C).Dim(false)) g.drawNum(sc, false, o, g.data.S2, g.deltaS2, d2, style.Foreground(s2C).Dim(false))
if rect.Dx() > 0 && rect.Dy() > 0 && g.legend != "" { if rect.Dx() > 0 && rect.Dy() > 0 && g.legend != "" {
legend := g.legend legend := g.legend
@ -96,14 +96,14 @@ func (g *Gauge) Draw(sc tcell.Screen) {
} }
} }
func (g *Gauge) drawNum(sc tcell.Screen, ok bool, o image.Point, n int, dn delta, ns string, style tcell.Style) { func (g *Gauge) drawNum(sc tcell.Screen, ok bool, o image.Point, n int64, dn delta, ns string, style tcell.Style) {
c1, _ := g.colorForSeries() c1, _ := g.colorForSeries()
if ok { if ok {
style = style.Foreground(c1) style = style.Foreground(c1)
printDelta(sc, dn, o, style) printDelta(sc, dn, o, style)
} }
dm, significant := NewDotMatrix(3, 3), n == 0 dm, significant := NewDotMatrix(), n == 0
if n == 0 { if n == 0 {
style = g.dimmed style = g.dimmed
} }
@ -138,7 +138,7 @@ func (g *Gauge) drawDial(sc tcell.Screen, m Matrix, o image.Point, style tcell.S
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
// Helpers... // Helpers...
func computeDelta(d1, d2 int) delta { func computeDelta(d1, d2 int64) delta {
if d2 == 0 { if d2 == 0 {
return DeltaSame return DeltaSame
} }

View File

@ -8,7 +8,7 @@ import (
func TestComputeDeltas(t *testing.T) { func TestComputeDeltas(t *testing.T) {
uu := map[string]struct { uu := map[string]struct {
d1, d2 int d1, d2 int64
e delta e delta
}{ }{
"same": { "same": {

View File

@ -16,11 +16,11 @@ func TestMetricsMaxDigits(t *testing.T) {
e: 1, e: 1,
}, },
"oks": { "oks": {
m: tchart.Metric{OK: 100, Fault: 10}, m: tchart.Metric{S1: 100, S2: 10},
e: 3, e: 3,
}, },
"errs": { "errs": {
m: tchart.Metric{OK: 10, Fault: 1000}, m: tchart.Metric{S1: 10, S2: 1000},
e: 4, e: 4,
}, },
} }
@ -36,13 +36,13 @@ func TestMetricsMaxDigits(t *testing.T) {
func TestMetricsMax(t *testing.T) { func TestMetricsMax(t *testing.T) {
uu := map[string]struct { uu := map[string]struct {
m tchart.Metric m tchart.Metric
e int e int64
}{ }{
"empty": { "empty": {
e: 0, e: 0,
}, },
"max_ok": { "max_ok": {
m: tchart.Metric{OK: 100, Fault: 10}, m: tchart.Metric{S1: 100, S2: 10},
e: 100, e: 100,
}, },
} }
@ -54,35 +54,3 @@ func TestMetricsMax(t *testing.T) {
}) })
} }
} }
func TestGauge(t *testing.T) {
uu := map[string]struct {
mm []tchart.Metric
e int
}{
"empty": {
e: 1,
},
"oks": {
mm: []tchart.Metric{{OK: 100, Fault: 10}},
e: 3,
},
"errs": {
mm: []tchart.Metric{{OK: 10, Fault: 1000}},
e: 4,
},
}
for k := range uu {
u := uu[k]
g := tchart.NewGauge("fred")
assert.True(t, g.IsDial())
for _, m := range u.mm {
g.Add(m)
}
t.Run(k, func(t *testing.T) {
// assert.Equal(t, u.e, u.m.MaxDigits())
})
}
}

View File

@ -17,29 +17,29 @@ type block struct {
} }
type blocks struct { type blocks struct {
oks, errs block s1, s2 block
} }
// Metric tracks a good and error rates. // Metric tracks two series.
type Metric struct { type Metric struct {
OK, Fault int S1, S2 int64
} }
// MaxDigits returns the max series number of digits. // MaxDigits returns the max series number of digits.
func (m Metric) MaxDigits() int { func (m Metric) MaxDigits() int {
s := fmt.Sprintf("%d", m.Max()) s := fmt.Sprintf("%d", m.Max())
return len(s) return len(s)
} }
// Max returns the max of the series. // Max returns the max of the series.
func (m Metric) Max() int { func (m Metric) Max() int64 {
return int(math.Max(float64(m.OK), float64(m.Fault))) return int64(math.Max(float64(m.S1), float64(m.S2)))
} }
// Sum returns the sum of the metrics. // Sum returns the sum of series.
func (m Metric) Sum() int { func (m Metric) Sum() int64 {
return m.OK + m.Fault return m.S1 + m.S2
} }
// SparkLine represents a sparkline component. // SparkLine represents a sparkline component.
@ -81,7 +81,7 @@ func (s *SparkLine) Draw(screen tcell.Screen) {
return return
} }
pad := 1 pad := 0
if s.legend != "" { if s.legend != "" {
pad++ pad++
} }
@ -97,18 +97,15 @@ func (s *SparkLine) Draw(screen tcell.Screen) {
idx = len(s.data) - rect.Dx()/2 idx = len(s.data) - rect.Dx()/2
} }
factor := 2 scale := float64(len(sparks)*(rect.Dy()-pad)) / float64(max)
if !s.multiSeries {
factor = 1
}
scale := float64(len(sparks)*(rect.Dy()-pad)/factor) / float64(max)
c1, c2 := s.colorForSeries() c1, c2 := s.colorForSeries()
for _, d := range s.data[idx:] { for _, d := range s.data[idx:] {
b := toBlocks(d, scale) b := toBlocks(d, scale)
cY := rect.Max.Y - pad cY := rect.Max.Y - pad
cY = s.drawBlock(rect, screen, cX, cY, b.oks, c1) s.drawBlock(rect, screen, cX, cY, b.s1, c1)
_ = s.drawBlock(rect, screen, cX, cY, b.errs, c2) cX++
cX += 2 s.drawBlock(rect, screen, cX, cY, b.s2, c2)
cX++
} }
if rect.Dx() > 0 && rect.Dy() > 0 && s.legend != "" { if rect.Dx() > 0 && rect.Dy() > 0 && s.legend != "" {
@ -120,7 +117,7 @@ func (s *SparkLine) Draw(screen tcell.Screen) {
} }
} }
func (s *SparkLine) drawBlock(r image.Rectangle, screen tcell.Screen, x, y int, b block, c tcell.Color) int { 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) style := tcell.StyleDefault.Foreground(c).Background(s.bgColor)
zeroY := r.Max.Y - r.Dy() zeroY := r.Max.Y - r.Dy()
@ -133,12 +130,7 @@ func (s *SparkLine) drawBlock(r image.Rectangle, screen tcell.Screen, x, y int,
} }
if b.partial != 0 { if b.partial != 0 {
screen.SetContent(x, y, b.partial, nil, style) screen.SetContent(x, y, b.partial, nil, style)
if b.full == 0 {
y--
}
} }
return y
} }
func (s *SparkLine) cutSet(width int) { func (s *SparkLine) cutSet(width int) {
@ -151,8 +143,8 @@ func (s *SparkLine) cutSet(width int) {
} }
} }
func (s *SparkLine) computeMax() int { func (s *SparkLine) computeMax() int64 {
var max int var max int64
for _, d := range s.data { for _, d := range s.data {
m := d.Max() m := d.Max()
if max < m { if max < m {
@ -167,10 +159,10 @@ func toBlocks(m Metric, scale float64) blocks {
if m.Sum() <= 0 { if m.Sum() <= 0 {
return blocks{} return blocks{}
} }
return blocks{oks: makeBlocks(m.OK, scale), errs: makeBlocks(m.Fault, scale)} return blocks{s1: makeBlocks(m.S1, scale), s2: makeBlocks(m.S2, scale)}
} }
func makeBlocks(v int, scale float64) block { func makeBlocks(v int64, scale float64) block {
scaled := int(math.Round(float64(v) * scale)) scaled := int(math.Round(float64(v) * scale))
p, b := scaled%len(sparks), block{full: scaled / len(sparks)} p, b := scaled%len(sparks), block{full: scaled / len(sparks)}
if b.full == 0 && v > 0 && p == 0 { if b.full == 0 && v > 0 && p == 0 {

View File

@ -57,27 +57,27 @@ func TestToBlocks(t *testing.T) {
e: blocks{}, e: blocks{},
}, },
"max_ok": { "max_ok": {
m: Metric{OK: 100, Fault: 10}, m: Metric{S1: 100, S2: 10},
s: 0.5, s: 0.5,
e: blocks{ e: blocks{
oks: block{full: 6, partial: sparks[2]}, s1: block{full: 6, partial: sparks[2]},
errs: block{full: 0, partial: sparks[5]}, s2: block{full: 0, partial: sparks[5]},
}, },
}, },
"max_fault": { "max_fault": {
m: Metric{OK: 10, Fault: 100}, m: Metric{S1: 10, S2: 100},
s: 0.5, s: 0.5,
e: blocks{ e: blocks{
oks: block{full: 0, partial: sparks[5]}, s1: block{full: 0, partial: sparks[5]},
errs: block{full: 6, partial: sparks[2]}, s2: block{full: 6, partial: sparks[2]},
}, },
}, },
"over": { "over": {
m: Metric{OK: 22, Fault: 999}, m: Metric{S1: 22, S2: 999},
s: float64(8*20) / float64(999), s: float64(8*20) / float64(999),
e: blocks{ e: blocks{
oks: block{full: 0, partial: sparks[4]}, s1: block{full: 0, partial: sparks[4]},
errs: block{full: 20, partial: sparks[0]}, s2: block{full: 20, partial: sparks[0]},
}, },
}, },
} }
@ -93,26 +93,26 @@ func TestToBlocks(t *testing.T) {
func TestComputeMax(t *testing.T) { func TestComputeMax(t *testing.T) {
uu := map[string]struct { uu := map[string]struct {
mm []Metric mm []Metric
e int e int64
}{ }{
"empty": { "empty": {
e: 0, e: 0,
}, },
"max_ok": { "max_ok": {
mm: []Metric{{OK: 100, Fault: 10}}, mm: []Metric{{S1: 100, S2: 10}},
e: 100, e: 100,
}, },
"max_fault": { "max_fault": {
mm: []Metric{{OK: 100, Fault: 1000}}, mm: []Metric{{S1: 100, S2: 1000}},
e: 1000, e: 1000,
}, },
"many": { "many": {
mm: []Metric{ mm: []Metric{
{OK: 100, Fault: 1000}, {S1: 100, S2: 1000},
{OK: 110, Fault: 1010}, {S1: 110, S2: 1010},
{OK: 120, Fault: 1020}, {S1: 120, S2: 1020},
{OK: 130, Fault: 1030}, {S1: 130, S2: 1030},
{OK: 140, Fault: 1040}, {S1: 140, S2: 1040},
}, },
e: 1040, e: 1040,
}, },

View File

@ -56,8 +56,8 @@ func (s *StatusIndicator) ClusterInfoUpdated(data model.ClusterMeta) {
data.Cluster, data.Cluster,
data.User, data.User,
data.K8sVer, data.K8sVer,
render.ToPerc(data.Cpu), render.PrintPerc(data.Cpu),
render.ToPerc(data.Mem), render.PrintPerc(data.Mem),
)) ))
}) })
} }
@ -131,8 +131,8 @@ func (s *StatusIndicator) setText(msg string) {
// Helpers... // Helpers...
// AsPercDelta represents a percentage with a delta indicator. // AsPercDelta represents a percentage with a delta indicator.
func AsPercDelta(ov, nv float64) string { func AsPercDelta(ov, nv int) string {
prev, cur := render.AsPerc(ov), render.AsPerc(nv) prev, cur := render.IntToStr(ov), render.IntToStr(nv)
if cur == "0" { if cur == "0" {
return render.NAValue return render.NAValue
} }

View File

@ -24,8 +24,8 @@ func NewLogo(styles *config.Styles) *Logo {
styles: styles, styles: styles,
} }
l.SetDirection(tview.FlexRow) l.SetDirection(tview.FlexRow)
l.AddItem(l.logo, 0, 6, false) l.AddItem(l.logo, 6, 1, false)
l.AddItem(l.status, 1, 0, false) l.AddItem(l.status, 1, 1, false)
l.refreshLogo(styles.Body().LogoColor) l.refreshLogo(styles.Body().LogoColor)
l.SetBackgroundColor(styles.BgColor()) l.SetBackgroundColor(styles.BgColor())
styles.AddListener(&l) styles.AddListener(&l)

View File

@ -271,8 +271,9 @@ func (t *Table) buildRow(r int, re, ore render.RowEvent, h render.Header, pads M
cell := tview.NewTableCell(field) cell := tview.NewTableCell(field)
cell.SetExpansion(1) cell.SetExpansion(1)
cell.SetAlign(h[c].Align) cell.SetAlign(h[c].Align)
cell.SetTextColor(color(t.GetModel().GetNamespace(), t.header, ore)) fgColor := color(t.GetModel().GetNamespace(), t.header, ore)
if marked { cell.SetTextColor(fgColor)
if marked && fgColor != render.ErrColor {
cell.SetTextColor(t.styles.Table().MarkColor.Color()) cell.SetTextColor(t.styles.Table().MarkColor.Color())
} }
if col == 0 { if col == 0 {

View File

@ -294,7 +294,7 @@ func (a *App) switchCtx(name string, loadPods bool) error {
a.Flash().Infof("Switching context to %s", name) a.Flash().Infof("Switching context to %s", name)
a.ReloadStyles(name) a.ReloadStyles(name)
v := a.Config.ActiveView() v := a.Config.ActiveView()
if v == "" { if v == "" || v == "ctx" || v == "context" {
v = "pod" v = "pod"
} }
if err := a.gotoResource(v, ns, true); loadPods && err != nil { if err := a.gotoResource(v, ns, true); loadPods && err != nil {
@ -342,8 +342,11 @@ func (a *App) Run() error {
func (a *App) Status(l model.FlashLevel, msg string) { func (a *App) Status(l model.FlashLevel, msg string) {
a.QueueUpdateDraw(func() { a.QueueUpdateDraw(func() {
a.Flash().SetMessage(l, msg) a.Flash().SetMessage(l, msg)
a.setIndicator(l, msg) if a.showHeader {
a.setLogo(l, msg) a.setLogo(l, msg)
} else {
a.setIndicator(l, msg)
}
}) })
} }

View File

@ -93,6 +93,18 @@ func (c *ClusterInfo) ClusterInfoChanged(prev, curr model.ClusterMeta) {
if c.app.Conn().HasMetrics() { if c.app.Conn().HasMetrics() {
row = c.setCell(row, ui.AsPercDelta(prev.Cpu, curr.Cpu)) row = c.setCell(row, ui.AsPercDelta(prev.Cpu, curr.Cpu))
_ = c.setCell(row, ui.AsPercDelta(prev.Mem, curr.Mem)) _ = c.setCell(row, ui.AsPercDelta(prev.Mem, curr.Mem))
var set bool
if c.app.Config.K9s.Thresholds.ExceedsCPUPerc(curr.Cpu) {
c.app.Status(model.FlashErr, "CPU on fire!")
set = true
}
if c.app.Config.K9s.Thresholds.ExceedsMemoryPerc(curr.Mem) {
c.app.Status(model.FlashErr, "Memory on fire!")
set = true
}
if !set {
c.app.ClearStatus(true)
}
} }
c.updateStyle() c.updateStyle()
}) })

View File

@ -11,6 +11,7 @@ import (
"github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/config"
"github.com/derailed/k9s/internal/health" "github.com/derailed/k9s/internal/health"
"github.com/derailed/k9s/internal/model" "github.com/derailed/k9s/internal/model"
"github.com/derailed/k9s/internal/render"
"github.com/derailed/k9s/internal/tchart" "github.com/derailed/k9s/internal/tchart"
"github.com/derailed/k9s/internal/ui" "github.com/derailed/k9s/internal/ui"
"github.com/derailed/tview" "github.com/derailed/tview"
@ -87,15 +88,15 @@ func (p *Pulse) Init(ctx context.Context) error {
p.makeGA(image.Point{X: 0, Y: 2}, image.Point{X: 2, Y: 2}, "apps/v1/replicasets"), p.makeGA(image.Point{X: 0, Y: 2}, image.Point{X: 2, Y: 2}, "apps/v1/replicasets"),
p.makeGA(image.Point{X: 0, Y: 4}, image.Point{X: 2, Y: 2}, "apps/v1/statefulsets"), p.makeGA(image.Point{X: 0, Y: 4}, image.Point{X: 2, Y: 2}, "apps/v1/statefulsets"),
p.makeGA(image.Point{X: 0, Y: 6}, image.Point{X: 2, Y: 2}, "apps/v1/daemonsets"), p.makeGA(image.Point{X: 0, Y: 6}, image.Point{X: 2, Y: 2}, "apps/v1/daemonsets"),
p.makeSP(true, image.Point{X: 2, Y: 0}, image.Point{X: 3, Y: 2}, "v1/pods"), p.makeSP(image.Point{X: 2, Y: 0}, image.Point{X: 3, Y: 2}, "v1/pods"),
p.makeSP(true, image.Point{X: 2, Y: 2}, image.Point{X: 3, Y: 2}, "v1/events"), p.makeSP(image.Point{X: 2, Y: 2}, image.Point{X: 3, Y: 2}, "v1/events"),
p.makeSP(true, image.Point{X: 2, Y: 4}, image.Point{X: 3, Y: 2}, "batch/v1/jobs"), p.makeSP(image.Point{X: 2, Y: 4}, image.Point{X: 3, Y: 2}, "batch/v1/jobs"),
p.makeSP(true, image.Point{X: 2, Y: 6}, image.Point{X: 3, Y: 2}, "v1/persistentvolumes"), p.makeSP(image.Point{X: 2, Y: 6}, image.Point{X: 3, Y: 2}, "v1/persistentvolumes"),
} }
if p.app.Conn().HasMetrics() { if p.app.Conn().HasMetrics() {
p.charts = append(p.charts, p.charts = append(p.charts,
p.makeSP(false, image.Point{X: 5, Y: 0}, image.Point{X: 2, Y: 4}, "cpu"), p.makeSP(image.Point{X: 5, Y: 0}, image.Point{X: 2, Y: 4}, "cpu"),
p.makeSP(false, image.Point{X: 5, Y: 4}, image.Point{X: 2, Y: 4}, "mem"), p.makeSP(image.Point{X: 5, Y: 4}, image.Point{X: 2, Y: 4}, "mem"),
) )
} }
p.bindKeys() p.bindKeys()
@ -126,6 +127,14 @@ func (p *Pulse) StylesChanged(s *config.Styles) {
p.app.Draw() p.app.Draw()
} }
const (
genFmat = " %s([%s::]%d[white::]:[%s::b]%d[-::])"
cpuFmt = " %s [%s::b]%s[white::-]::[gray::]%s ([%s::]%sm[white::]/[%s::]%sm[-::])"
memFmt = " %s [%s::b]%s[white::-]::[gray::]%s ([%s::]%sMi[white::]/[%s::]%sMi[-::])"
okColor = "palegreen"
errColor = "orangered"
)
// PulseChanged notifies the model data changed. // PulseChanged notifies the model data changed.
func (p *Pulse) PulseChanged(c *health.Check) { func (p *Pulse) PulseChanged(c *health.Check) {
index, ok := findIndexGVR(p.charts, c.GVR) index, ok := findIndexGVR(p.charts, c.GVR)
@ -137,29 +146,59 @@ func (p *Pulse) PulseChanged(c *health.Check) {
if !ok { if !ok {
return return
} }
nn := v.GetSeriesColorNames()
if c.Tally(health.S1) == 0 {
nn[0] = "gray"
}
if c.Tally(health.S2) == 0 {
nn[1] = "gray"
}
gvr := client.NewGVR(c.GVR) gvr := client.NewGVR(c.GVR)
switch c.GVR { switch c.GVR {
case "cpu": case "cpu":
v.SetLegend(fmt.Sprintf(" %s(%dm)", strings.Title(gvr.R()), c.Tally(health.OK))) perc := client.ToPercentage(c.Tally(health.S1), c.Tally(health.S2))
color := okColor
if p.app.Config.K9s.Thresholds.ExceedsCPUPerc(perc) {
color = errColor
}
v.SetLegend(fmt.Sprintf(cpuFmt,
strings.Title(gvr.R()),
color,
render.PrintPerc(perc),
render.PrintPerc(p.app.Config.K9s.Thresholds.CPU),
nn[0],
render.AsThousands(c.Tally(health.S1)),
nn[1],
render.AsThousands(c.Tally(health.S2)),
))
case "mem": case "mem":
v.SetLegend(fmt.Sprintf(" %s(%dMi)", strings.Title(gvr.R()), c.Tally(health.OK))) perc := client.ToPercentage(c.Tally(health.S1), c.Tally(health.S2))
color := okColor
if p.app.Config.K9s.Thresholds.ExceedsMemoryPerc(perc) {
color = errColor
}
v.SetLegend(fmt.Sprintf(memFmt,
strings.Title(gvr.R()),
color,
render.PrintPerc(perc),
render.PrintPerc(p.app.Config.K9s.Thresholds.Memory),
nn[0],
render.AsThousands(c.Tally(health.S1)),
nn[1],
render.AsThousands(c.Tally(health.S2)),
))
default: default:
nn := v.GetSeriesColorNames() v.SetLegend(fmt.Sprintf(genFmat,
if c.Tally(health.OK) == 0 {
nn[0] = "gray"
}
if c.Tally(health.Toast) == 0 {
nn[1] = "gray"
}
v.SetLegend(fmt.Sprintf(" %s([%s::]%d[white::]:[%s::b]%d[-::])",
strings.Title(gvr.R()), strings.Title(gvr.R()),
nn[0], nn[0],
c.Tally(health.OK), c.Tally(health.S1),
nn[1], nn[1],
c.Tally(health.Toast), c.Tally(health.S2),
)) ))
} }
v.Add(tchart.Metric{OK: c.Tally(health.OK), Fault: c.Tally(health.Toast)}) v.Add(tchart.Metric{S1: c.Tally(health.S1), S2: c.Tally(health.S2)})
} }
// PulseFailed notifies the load failed. // PulseFailed notifies the load failed.
@ -301,7 +340,7 @@ func (p *Pulse) nextFocusCmd(direction int) func(evt *tcell.EventKey) *tcell.Eve
} }
} }
func (p *Pulse) makeSP(multi bool, loc image.Point, span image.Point, gvr string) *tchart.SparkLine { func (p *Pulse) makeSP(loc image.Point, span image.Point, gvr string) *tchart.SparkLine {
s := tchart.NewSparkLine(gvr) s := tchart.NewSparkLine(gvr)
s.SetBackgroundColor(p.app.Styles.Charts().BgColor.Color()) s.SetBackgroundColor(p.app.Styles.Charts().BgColor.Color())
s.SetBorderPadding(0, 1, 0, 1) s.SetBorderPadding(0, 1, 0, 1)
@ -312,9 +351,7 @@ func (p *Pulse) makeSP(multi bool, loc image.Point, span image.Point, gvr string
} }
s.SetLegend(fmt.Sprintf(" %s ", strings.Title(client.NewGVR(gvr).R()))) s.SetLegend(fmt.Sprintf(" %s ", strings.Title(client.NewGVR(gvr).R())))
s.SetInputCapture(p.keyboard) s.SetInputCapture(p.keyboard)
if !multi { s.SetMultiSeries(true)
s.SetMultiSeries(multi)
}
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, true)
return s return s

View File

@ -2,6 +2,7 @@ package view
import ( import (
"errors" "errors"
"fmt"
"github.com/derailed/k9s/internal/dao" "github.com/derailed/k9s/internal/dao"
"github.com/derailed/k9s/internal/ui" "github.com/derailed/k9s/internal/ui"
@ -30,19 +31,24 @@ func (r *RestartExtender) bindKeys(aa ui.KeyActions) {
} }
func (r *RestartExtender) restartCmd(evt *tcell.EventKey) *tcell.EventKey { func (r *RestartExtender) restartCmd(evt *tcell.EventKey) *tcell.EventKey {
path := r.GetTable().GetSelectedItem() paths := r.GetTable().GetSelectedItems()
if path == "" { if len(paths) == 0 {
return nil return nil
} }
r.Stop() r.Stop()
defer r.Start() defer r.Start()
msg := "Please confirm rollout restart for " + path msg := fmt.Sprintf("Restart deployment %s?" + paths[0])
if len(paths) > 1 {
msg = fmt.Sprintf("Restart %d deployments?", len(paths))
}
dialog.ShowConfirm(r.App().Content.Pages, "<Confirm Restart>", msg, func() { dialog.ShowConfirm(r.App().Content.Pages, "<Confirm Restart>", msg, func() {
if err := r.restartRollout(path); err != nil { for _, path := range paths {
r.App().Flash().Err(err) if err := r.restartRollout(path); err != nil {
} else { r.App().Flash().Err(err)
r.App().Flash().Infof("Rollout restart in progress for `%s...", path) } else {
r.App().Flash().Infof("Rollout restart in progress for `%s...", path)
}
} }
}, func() {}) }, func() {})
@ -54,7 +60,6 @@ func (r *RestartExtender) restartRollout(path string) error {
if err != nil { if err != nil {
return nil return nil
} }
s, ok := res.(dao.Restartable) s, ok := res.(dao.Restartable)
if !ok { if !ok {
return errors.New("resource is not restartable") return errors.New("resource is not restartable")