checkpoint

mine
derailed 2020-02-25 22:48:24 -08:00
parent 42e2e98e4d
commit 543f9837ff
33 changed files with 346 additions and 261 deletions

BIN
assets/beach.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 MiB

View File

@ -0,0 +1,64 @@
<img src="https://raw.githubusercontent.com/derailed/k9s/master/assets/k9s_small.png" align="right" width="200" height="auto"/>
# Release v0.17.0
## 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 is as ever very much noticed and appreciated!
Also if you dig this tool, please make some noise on social! [@kitesurfer](https://twitter.com/kitesurfer)
On Slack? Please join us [K9slackers](https://join.slack.com/t/k9sers/shared_invite/enQtOTA5MDEyNzI5MTU0LWQ1ZGI3MzliYzZhZWEyNzYxYzA3NjE0YTk1YmFmNzViZjIyNzhkZGI0MmJjYzhlNjdlMGJhYzE2ZGU1NjkyNTM)
---
<img src="https://raw.githubusercontent.com/derailed/k9s/master/assets/beach.png" align="center"/>
## Custom Columns? Yes Please!!
[SneakCast v0.17.0 on The Beach! - Yup! sound is sucking but what a setting!](https://youtu.be/7S33CNLAofk)
In this drop I've reworked the rendering engine to provide for custom columns support. Now, you should be able to not only tell K9s which columns you would like to display but also which order they should be in.
To surface this feature, you will need to create a new configuration file, namely `$HOME/.k9s/views.yml`. This file leverages GVR (Group/Version/Resource) to configure the associated table view columns. If no GVR is found for a view the default rendering will take over (ie what we have now). Going wide will add all the remaining columns that are available on the given resource after your custom columns. To boot, you can edit your views config file and tune your resources views live!
> NOTE: This is experimental and will most likely change as we iron this out!
Here is a sample views configuration that customize a pods and services views.
```yaml
# $HOME/.k9s/views.yml
k9s:
views:
v1/pods:
columns:
- AGE
- NAMESPACE
- NAME
- IP
- NODE
- STATUS
- READY
v1/services:
columns:
- AGE
- NAMESPACE
- NAME
- TYPE
- CLUSTER-IP
```
## Resolved Bugs/Features/PRs
- [Issue #581](https://github.com/derailed/k9s/issues/581)
- [Issue #576](https://github.com/derailed/k9s/issues/576)
- [Issue #574](https://github.com/derailed/k9s/issues/574)
- [Issue #573](https://github.com/derailed/k9s/issues/573)
- [Issue #571](https://github.com/derailed/k9s/issues/571)
- [Issue #566](https://github.com/derailed/k9s/issues/566)
- [Issue #563](https://github.com/derailed/k9s/issues/563)
- [Issue #562](https://github.com/derailed/k9s/issues/562)
---
<img src="https://raw.githubusercontent.com/derailed/k9s/master/assets/imhotep_logo.png" width="32" height="auto"/> © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0)

View File

@ -13,7 +13,7 @@ const (
// DefaultDirMod default unix perms for k9s directory. // DefaultDirMod default unix perms for k9s directory.
DefaultDirMod os.FileMode = 0755 DefaultDirMod os.FileMode = 0755
// DefaultFileMod default unix perms for k9s files. // DefaultFileMod default unix perms for k9s files.
DefaultFileMod os.FileMode = 0644 DefaultFileMod os.FileMode = 0600
) )
// InList check if string is in a collection of strings. // InList check if string is in a collection of strings.

View File

@ -7,7 +7,7 @@ import (
"gopkg.in/yaml.v2" "gopkg.in/yaml.v2"
) )
var K9sViewConfigFile = filepath.Join(K9sHome, "views_config.yml") var K9sViewConfigFile = filepath.Join(K9sHome, "views.yml")
// ViewConfigListener represents a view config listener. // ViewConfigListener represents a view config listener.
type ViewConfigListener interface { type ViewConfigListener interface {

View File

@ -57,7 +57,7 @@ func (d *Deployment) Scale(path string, replicas int32) error {
// Restart a Deployment rollout. // Restart a Deployment rollout.
func (d *Deployment) Restart(path string) error { func (d *Deployment) Restart(path string) error {
dp, err := d.GetInstance(path) dp, err := d.Load(d.Factory, path)
if err != nil { if err != nil {
return err return err
} }
@ -81,7 +81,7 @@ func (d *Deployment) Restart(path string) error {
// TailLogs tail logs for all pods represented by this Deployment. // TailLogs tail logs for all pods represented by this Deployment.
func (d *Deployment) TailLogs(ctx context.Context, c chan<- []byte, opts LogOptions) error { func (d *Deployment) TailLogs(ctx context.Context, c chan<- []byte, opts LogOptions) error {
dp, err := d.GetInstance(opts.Path) dp, err := d.Load(d.Factory, opts.Path)
if err != nil { if err != nil {
return err return err
} }
@ -94,7 +94,7 @@ func (d *Deployment) TailLogs(ctx context.Context, c chan<- []byte, opts LogOpti
// Pod returns a pod victim by name. // Pod returns a pod victim by name.
func (d *Deployment) Pod(fqn string) (string, error) { func (d *Deployment) Pod(fqn string) (string, error) {
dp, err := d.GetInstance(fqn) dp, err := d.Load(d.Factory, fqn)
if err != nil { if err != nil {
return "", err return "", err
} }
@ -103,8 +103,8 @@ func (d *Deployment) Pod(fqn string) (string, error) {
} }
// GetInstance returns a deployment instance. // GetInstance returns a deployment instance.
func (d *Deployment) GetInstance(fqn string) (*appsv1.Deployment, error) { func (*Deployment) Load(f Factory, fqn string) (*appsv1.Deployment, error) {
o, err := d.Factory.Get(d.gvr.String(), fqn, false, labels.Everything()) o, err := f.Get("apps/v1/deployments", fqn, false, labels.Everything())
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@ -160,7 +160,6 @@ func deleteFunctionToken(gateway string, functionName string, tlsInsecure bool,
req, err := http.NewRequest("DELETE", deleteEndpoint, reader) req, err := http.NewRequest("DELETE", deleteEndpoint, reader)
if err != nil { if err != nil {
fmt.Println(err)
return err return err
} }
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
@ -172,9 +171,7 @@ func deleteFunctionToken(gateway string, functionName string, tlsInsecure bool,
} }
delRes, delErr := c.Do(req) delRes, delErr := c.Do(req)
if delErr != nil { if delErr != nil {
fmt.Printf("Error removing existing function: %s, gateway=%s, functionName=%s\n", delErr.Error(), gateway, functionName)
return delErr return delErr
} }

View File

@ -32,7 +32,7 @@ func (r *Resource) List(ctx context.Context, ns string) ([]runtime.Object, error
} }
// Get returns a resource instance if found, else an error. // Get returns a resource instance if found, else an error.
func (r *Resource) Get(ctx context.Context, path string) (runtime.Object, error) { func (r *Resource) Get(_ context.Context, path string) (runtime.Object, error) {
return r.Factory.Get(r.gvr.String(), path, true, labels.Everything()) return r.Factory.Get(r.gvr.String(), path, true, labels.Everything())
} }

View File

@ -1,7 +1,19 @@
package dao package dao
import ( import (
"errors"
"fmt"
"strconv"
"strings"
"github.com/derailed/k9s/internal/client"
appsv1 "k8s.io/api/apps/v1" appsv1 "k8s.io/api/apps/v1"
v1 "k8s.io/api/apps/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/labels"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/kubectl/pkg/polymorphichelpers"
) )
// ReplicaSet represents a replicaset K8s resource. // ReplicaSet represents a replicaset K8s resource.
@ -9,15 +21,96 @@ type ReplicaSet struct {
Resource Resource
} }
// IsHappy check for happy deployments. // BOZO!!
func (d *ReplicaSet) IsHappy(rs appsv1.ReplicaSet) bool { // // IsHappy check for happy deployments.
if rs.Status.Replicas == 0 && rs.Status.Replicas != rs.Status.ReadyReplicas { // func (*ReplicaSet) IsHappy(rs appsv1.ReplicaSet) bool {
return false // if rs.Status.Replicas == 0 && rs.Status.Replicas != rs.Status.ReadyReplicas {
// return false
// }
// if rs.Status.Replicas != 0 && rs.Status.Replicas != rs.Status.ReadyReplicas {
// return false
// }
// return true
// }
func (r *ReplicaSet) Load(f Factory, path string) (*v1.ReplicaSet, error) {
o, err := f.Get("apps/v1/replicasets", path, true, labels.Everything())
if err != nil {
return nil, err
} }
if rs.Status.Replicas != 0 && rs.Status.Replicas != rs.Status.ReadyReplicas { var rs appsv1.ReplicaSet
return false err = runtime.DefaultUnstructuredConverter.FromUnstructured(o.(*unstructured.Unstructured).Object, &rs)
if err != nil {
return nil, err
} }
return true return &rs, nil
}
func getRSRevision(rs *v1.ReplicaSet) (int64, error) {
revision := rs.ObjectMeta.Annotations["deployment.kubernetes.io/revision"]
if rs.Status.Replicas != 0 {
return 0, errors.New("can not rollback current replica")
}
vers, err := strconv.Atoi(revision)
if err != nil {
return 0, errors.New("revision conversion failed")
}
return int64(vers), nil
}
func controllerInfo(rs *v1.ReplicaSet) (string, string, string, error) {
for _, ref := range rs.ObjectMeta.OwnerReferences {
if ref.Controller == nil {
continue
}
group, tokens := ref.APIVersion, strings.Split(ref.APIVersion, "/")
if len(tokens) == 2 {
group = tokens[0]
}
return ref.Name, ref.Kind, group, nil
}
return "", "", "", fmt.Errorf("Unable to find controller for ReplicaSet %s", rs.ObjectMeta.Name)
}
// Rollback reverses the last deployment.
func (r *ReplicaSet) Rollback(fqn string) error {
rs, err := r.Load(r.Factory, fqn)
if err != nil {
return err
}
version, err := getRSRevision(rs)
if err != nil {
return err
}
name, kind, apiGroup, err := controllerInfo(rs)
if err != nil {
return err
}
rb, err := polymorphichelpers.RollbackerFor(schema.GroupKind{
Group: apiGroup,
Kind: kind},
r.Client().DialOrDie(),
)
if err != nil {
return err
}
var ddp Deployment
dp, err := ddp.Load(r.Factory, client.FQN(rs.Namespace, name))
if err != nil {
return err
}
_, err = rb.Rollback(dp, map[string]string{}, version, false)
if err != nil {
return err
}
return nil
} }

View File

@ -133,7 +133,7 @@ func TestTableGenericHydrate(t *testing.T) {
assert.Nil(t, genericHydrate("blee", &tt, rr, &re)) assert.Nil(t, genericHydrate("blee", &tt, rr, &re))
assert.Equal(t, 2, len(rr)) assert.Equal(t, 2, len(rr))
assert.Equal(t, 2, len(rr[0].Fields)) assert.Equal(t, 3, len(rr[0].Fields))
} }
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------

View File

@ -194,7 +194,7 @@ func ToContainerState(s v1.ContainerState) string {
if s.Terminated.Reason != "" { if s.Terminated.Reason != "" {
return s.Terminated.Reason return s.Terminated.Reason
} }
return "Terminated" return "Terminating"
case s.Running != nil: case s.Running != nil:
return "Running" return "Running"
default: default:

View File

@ -50,7 +50,10 @@ func (d DeltaRow) Customize(cols []int, out DeltaRow) {
return return
} }
for i, c := range cols { for i, c := range cols {
if c < len(d) { if c < 0 {
continue
}
if c < len(d) && i < len(out) {
out[i] = d[c] out[i] = d[c]
} }
} }

View File

@ -72,6 +72,16 @@ func TestDeltaCustomize(t *testing.T) {
cols: []int{2, 10, 0}, cols: []int{2, 10, 0},
e: render.DeltaRow{"c", "", "a"}, e: render.DeltaRow{"c", "", "a"},
}, },
"diff-negative": {
r1: render.Row{
Fields: render.Fields{"a", "b", "c"},
},
r2: render.Row{
Fields: render.Fields{"a1", "b1", "c1"},
},
cols: []int{2, -1, 0},
e: render.DeltaRow{"c", "", "a"},
},
} }
for k := range uu { for k := range uu {

View File

@ -63,9 +63,9 @@ func (d Deployment) Render(o interface{}, ns string, r *Row) error {
return nil return nil
} }
func (Deployment) diagnose(d, r int32) error { func (Deployment) diagnose(desired, avail int32) error {
if d != r { if desired != avail {
return fmt.Errorf("desiring %d replicas got %d available", d, r) return fmt.Errorf("desiring %d replicas got %d available", desired, avail)
} }
return nil return nil
} }

View File

@ -40,6 +40,7 @@ func (g *Generic) Header(ns string) Header {
return Header{} return Header{}
} }
h := make(Header, 0, len(g.table.ColumnDefinitions)) h := make(Header, 0, len(g.table.ColumnDefinitions))
h = append(h, HeaderColumn{Name: "NAMESPACE"})
for i, c := range g.table.ColumnDefinitions { for i, c := range g.table.ColumnDefinitions {
if c.Name == ageTableCol { if c.Name == ageTableCol {
g.ageIndex = i g.ageIndex = i
@ -48,7 +49,7 @@ func (g *Generic) Header(ns string) Header {
h = append(h, HeaderColumn{Name: strings.ToUpper(c.Name)}) h = append(h, HeaderColumn{Name: strings.ToUpper(c.Name)})
} }
if g.ageIndex > 0 { if g.ageIndex > 0 {
h = append(h, HeaderColumn{Name: "AGE", Time: true,}) h = append(h, HeaderColumn{Name: "AGE", Time: true})
} }
return h return h
@ -72,9 +73,7 @@ func (g *Generic) Render(o interface{}, ns string, r *Row) error {
r.ID = client.FQN(nns, n) r.ID = client.FQN(nns, n)
r.Fields = make(Fields, 0, len(g.Header(ns))) r.Fields = make(Fields, 0, len(g.Header(ns)))
if client.IsAllNamespaces(ns) && nns != "" { r.Fields = append(r.Fields, nns)
r.Fields = append(r.Fields, nns)
}
var ageCell interface{} var ageCell interface{}
for i, c := range row.Cells { for i, c := range row.Cells {
if g.ageIndex > 0 && i == g.ageIndex { if g.ageIndex > 0 && i == g.ageIndex {

View File

@ -22,8 +22,9 @@ func TestGenericRender(t *testing.T) {
ns: "ns1", ns: "ns1",
table: makeNSGeneric(), table: makeNSGeneric(),
eID: "ns1/c1", eID: "ns1/c1",
eFields: render.Fields{"c1", "c2", "c3"}, eFields: render.Fields{"ns1", "c1", "c2", "c3"},
eHeader: render.Header{ eHeader: render.Header{
render.HeaderColumn{Name: "NAMESPACE"},
render.HeaderColumn{Name: "A"}, render.HeaderColumn{Name: "A"},
render.HeaderColumn{Name: "B"}, render.HeaderColumn{Name: "B"},
render.HeaderColumn{Name: "C"}, render.HeaderColumn{Name: "C"},
@ -35,7 +36,7 @@ func TestGenericRender(t *testing.T) {
eID: "ns1/c1", eID: "ns1/c1",
eFields: render.Fields{"ns1", "c1", "c2", "c3"}, eFields: render.Fields{"ns1", "c1", "c2", "c3"},
eHeader: render.Header{ eHeader: render.Header{
// render.HeaderColumn{Name: "NAMESPACE"}, render.HeaderColumn{Name: "NAMESPACE"},
render.HeaderColumn{Name: "A"}, render.HeaderColumn{Name: "A"},
render.HeaderColumn{Name: "B"}, render.HeaderColumn{Name: "B"},
render.HeaderColumn{Name: "C"}, render.HeaderColumn{Name: "C"},
@ -47,7 +48,7 @@ func TestGenericRender(t *testing.T) {
eID: "ns1/c1", eID: "ns1/c1",
eFields: render.Fields{"ns1", "c1", "c2", "c3"}, eFields: render.Fields{"ns1", "c1", "c2", "c3"},
eHeader: render.Header{ eHeader: render.Header{
// render.HeaderColumn{Name: "NAMESPACE"}, render.HeaderColumn{Name: "NAMESPACE"},
render.HeaderColumn{Name: "A"}, render.HeaderColumn{Name: "A"},
render.HeaderColumn{Name: "B"}, render.HeaderColumn{Name: "B"},
render.HeaderColumn{Name: "C"}, render.HeaderColumn{Name: "C"},
@ -57,8 +58,9 @@ func TestGenericRender(t *testing.T) {
ns: client.ClusterScope, ns: client.ClusterScope,
table: makeNoNSGeneric(), table: makeNoNSGeneric(),
eID: "-/c1", eID: "-/c1",
eFields: render.Fields{"c1", "c2", "c3"}, eFields: render.Fields{"-", "c1", "c2", "c3"},
eHeader: render.Header{ eHeader: render.Header{
render.HeaderColumn{Name: "NAMESPACE"},
render.HeaderColumn{Name: "A"}, render.HeaderColumn{Name: "A"},
render.HeaderColumn{Name: "B"}, render.HeaderColumn{Name: "B"},
render.HeaderColumn{Name: "C"}, render.HeaderColumn{Name: "C"},
@ -68,11 +70,12 @@ func TestGenericRender(t *testing.T) {
ns: client.ClusterScope, ns: client.ClusterScope,
table: makeAgeGeneric(), table: makeAgeGeneric(),
eID: "-/c1", eID: "-/c1",
eFields: render.Fields{"c1", "c2", "Age"}, eFields: render.Fields{"-", "c1", "c2", "Age"},
eHeader: render.Header{ eHeader: render.Header{
render.HeaderColumn{Name: "NAMESPACE"},
render.HeaderColumn{Name: "A"}, render.HeaderColumn{Name: "A"},
render.HeaderColumn{Name: "C"}, render.HeaderColumn{Name: "C"},
render.HeaderColumn{Name: "AGE", Time: true,}, render.HeaderColumn{Name: "AGE", Time: true},
}, },
}, },
} }

View File

@ -40,28 +40,27 @@ func (h Header) Clone() Header {
} }
// MapIndices returns a collection of mapped column indices based of the requested columns. // MapIndices returns a collection of mapped column indices based of the requested columns.
func (h Header) MapIndices(cols []string, wide bool, ii []int) { func (h Header) MapIndices(cols []string, wide bool) []int {
ii := make([]int, 0, len(cols))
cc := make(map[int]struct{}, len(cols)) cc := make(map[int]struct{}, len(cols))
var lastIndex int for _, col := range cols {
log.Debug().Msgf("MAP %d -- %d ", len(cols), len(ii))
for i, col := range cols {
idx := h.IndexOf(col, true) idx := h.IndexOf(col, true)
ii[i], cc[idx] = idx, struct{}{} if idx < 0 {
lastIndex = i log.Warn().Msgf("Column %q not found on resource", col)
}
ii, cc[idx] = append(ii, idx), struct{}{}
} }
if !wide { if !wide {
return return ii
} }
for i := range h { for i := range h {
if _, ok := cc[i]; ok { if _, ok := cc[i]; ok {
continue continue
} }
lastIndex++ ii = append(ii, i)
if lastIndex < len(ii) {
ii[lastIndex] = i
}
} }
return ii
} }
// Customize builds a header from custom col definitions. // Customize builds a header from custom col definitions.
@ -73,8 +72,13 @@ func (h Header) Customize(cols []string, wide bool) Header {
xx := make(map[int]struct{}, len(h)) xx := make(map[int]struct{}, len(h))
for _, c := range cols { for _, c := range cols {
idx := h.IndexOf(c, true) idx := h.IndexOf(c, true)
// BOZO!!
if idx == -1 { if idx == -1 {
log.Warn().Msgf("Column %s is not available on this resource", c) log.Warn().Msgf("Column %s is not available on this resource", c)
col := HeaderColumn{
Name: c,
}
cc = append(cc, col)
continue continue
} }
xx[idx] = struct{}{} xx[idx] = struct{}{}
@ -153,3 +157,11 @@ func (h Header) IndexOf(colName string, includeWide bool) int {
} }
return -1 return -1
} }
// Dump for debuging.
func (h Header) Dump() {
log.Debug().Msgf("HEADER")
for i, c := range h {
log.Debug().Msgf("%d %q -- %t", i, c.Name, c.Wide)
}
}

View File

@ -39,8 +39,7 @@ func TestHeaderMapIndices(t *testing.T) {
for k := range uu { for k := range uu {
u := uu[k] u := uu[k]
t.Run(k, func(t *testing.T) { t.Run(k, func(t *testing.T) {
ii := make([]int, len(u.cols)) ii := u.h1.MapIndices(u.cols, u.wide)
u.h1.MapIndices(u.cols, u.wide, ii)
assert.Equal(t, u.e, ii) assert.Equal(t, u.e, ii)
}) })
} }
@ -144,6 +143,7 @@ func TestHeaderCustomize(t *testing.T) {
cols: []string{"BLEE", "A"}, cols: []string{"BLEE", "A"},
wide: true, wide: true,
e: render.Header{ e: render.Header{
render.HeaderColumn{Name: "BLEE"},
render.HeaderColumn{Name: "A"}, render.HeaderColumn{Name: "A"},
render.HeaderColumn{Name: "B", Wide: true}, render.HeaderColumn{Name: "B", Wide: true},
render.HeaderColumn{Name: "C", Wide: true}, render.HeaderColumn{Name: "C", Wide: true},

View File

@ -116,7 +116,7 @@ func (p Pod) diagnose(phase string, cr, ct int) error {
if phase == "Completed" { if phase == "Completed" {
return nil return nil
} }
if cr != ct { if cr != ct || ct == 0 {
return fmt.Errorf("container ready check failed: %d of %d", cr, ct) return fmt.Errorf("container ready check failed: %d of %d", cr, ct)
} }
@ -276,7 +276,7 @@ func (p *Pod) Phase(po *v1.Pod) string {
return status return status
} }
return "Terminated" return "Terminating"
} }
func (*Pod) containerPhase(st v1.PodStatus, status string) (string, bool) { func (*Pod) containerPhase(st v1.PodStatus, status string) (string, bool) {

View File

@ -73,7 +73,7 @@ func (p PersistentVolume) Render(o interface{}, ns string, r *Row) error {
phase := pv.Status.Phase phase := pv.Status.Phase
if pv.ObjectMeta.DeletionTimestamp != nil { if pv.ObjectMeta.DeletionTimestamp != nil {
phase = "Terminating" phase = "Terminated"
} }
var claim string var claim string
if pv.Spec.ClaimRef != nil { if pv.Spec.ClaimRef != nil {
@ -92,22 +92,22 @@ func (p PersistentVolume) Render(o interface{}, ns string, r *Row) error {
size.String(), size.String(),
accessMode(pv.Spec.AccessModes), accessMode(pv.Spec.AccessModes),
string(pv.Spec.PersistentVolumeReclaimPolicy), string(pv.Spec.PersistentVolumeReclaimPolicy),
string(phase), string(pv.Status.Phase),
claim, claim,
class, class,
pv.Status.Reason, pv.Status.Reason,
p.volumeMode(pv.Spec.VolumeMode), p.volumeMode(pv.Spec.VolumeMode),
mapToStr(pv.Labels), mapToStr(pv.Labels),
asStatus(p.diagnose(string(phase))), asStatus(p.diagnose(phase)),
toAge(pv.ObjectMeta.CreationTimestamp), toAge(pv.ObjectMeta.CreationTimestamp),
} }
return nil return nil
} }
func (PersistentVolume) diagnose(r string) error { func (PersistentVolume) diagnose(phase v1.PersistentVolumePhase) error {
if r != "Bound" && r != "Available" { if phase == v1.VolumeFailed {
return fmt.Errorf("unexpected status %s", r) return fmt.Errorf("failed to delete or recycle")
} }
return nil return nil
} }

View File

@ -15,7 +15,7 @@ type Fields []string
func (f Fields) Customize(cols []int, out Fields) { func (f Fields) Customize(cols []int, out Fields) {
for i, c := range cols { for i, c := range cols {
if c < 0 { if c < 0 {
out[i] = "<TOAST!>" out[i] = NAValue
continue continue
} }
if c < len(f) { if c < len(f) {

View File

@ -1,7 +1,5 @@
package render package render
import "github.com/rs/zerolog/log"
// TableData tracks a K8s resource for tabular display. // TableData tracks a K8s resource for tabular display.
type TableData struct { type TableData struct {
Header Header Header Header
@ -19,9 +17,7 @@ func (t *TableData) Customize(cols []string, wide bool) TableData {
Namespace: t.Namespace, Namespace: t.Namespace,
Header: t.Header.Customize(cols, wide), Header: t.Header.Customize(cols, wide),
} }
ids := make([]int, len(t.Header)) ids := t.Header.MapIndices(cols, wide)
t.Header.MapIndices(cols, wide, ids)
log.Debug().Msgf("INDICES %#v", ids)
res.RowEvents = t.RowEvents.Customize(ids) res.RowEvents = t.RowEvents.Customize(ids)
return res return res

View File

@ -45,7 +45,6 @@ func NewApp(context string) *App {
// Init initializes the application. // Init initializes the application.
func (a *App) Init() { func (a *App) Init() {
a.bindKeys() a.bindKeys()
a.SetInputCapture(a.keyboard)
a.cmdBuff.AddListener(a.Cmd()) a.cmdBuff.AddListener(a.Cmd())
a.Styles.AddListener(a) a.Styles.AddListener(a)
a.CmdBuff().AddListener(a) a.CmdBuff().AddListener(a)
@ -153,6 +152,11 @@ func (a *App) InCmdMode() bool {
return a.Cmd().InCmdMode() return a.Cmd().InCmdMode()
} }
func (a *App) HasAction(key tcell.Key) (KeyAction, bool) {
act, ok := a.actions[key]
return act, ok
}
// GetActions returns a collection of actiona. // GetActions returns a collection of actiona.
func (a *App) GetActions() KeyActions { func (a *App) GetActions() KeyActions {
return a.actions return a.actions
@ -170,23 +174,6 @@ func (a *App) Views() map[string]tview.Primitive {
return a.views return a.views
} }
func (a *App) keyboard(evt *tcell.EventKey) *tcell.EventKey {
key := evt.Key()
if key == tcell.KeyRune {
if a.cmdBuff.IsActive() && evt.Modifiers() == tcell.ModNone {
a.cmdBuff.Add(evt.Rune())
return nil
}
key = AsKey(evt)
}
if a, ok := a.actions[key]; ok {
return a.Action(evt)
}
return evt
}
func (a *App) clearCmd(evt *tcell.EventKey) *tcell.EventKey { func (a *App) clearCmd(evt *tcell.EventKey) *tcell.EventKey {
if !a.CmdBuff().IsActive() { if !a.CmdBuff().IsActive() {
return evt return evt

View File

@ -25,6 +25,17 @@ func NewPages() *Pages {
return &p return &p
} }
// IsTopDialog checks if front page is a dialog.
func (p *Pages) IsTopDialog() bool {
_, pa := p.GetFrontPage()
switch pa.(type) {
case *tview.ModalForm:
return true
default:
return false
}
}
// Show displays a given page. // Show displays a given page.
func (p *Pages) Show(c model.Component) { func (p *Pages) Show(c model.Component) {
p.SwitchToPage(componentID(c)) p.SwitchToPage(componentID(c))

View File

@ -69,7 +69,6 @@ func (t *Table) Init(ctx context.Context) {
t.SetBorderPadding(0, 0, 1, 1) t.SetBorderPadding(0, 0, 1, 1)
t.SetSelectable(true, false) t.SetSelectable(true, false)
t.SetSelectionChangedFunc(t.selectionChanged) t.SetSelectionChangedFunc(t.selectionChanged)
t.SetInputCapture(t.keyboard)
t.SetBackgroundColor(tcell.ColorDefault) t.SetBackgroundColor(tcell.ColorDefault)
if cfg, ok := ctx.Value(internal.KeyViewConfig).(*config.CustomView); ok && cfg != nil { if cfg, ok := ctx.Value(internal.KeyViewConfig).(*config.CustomView); ok && cfg != nil {
@ -129,12 +128,7 @@ func (t *Table) Styles() *config.Styles {
return t.styles return t.styles
} }
// SendKey sends an keyboard event (testing only!). func (t *Table) FilterInput(r rune) bool {
func (t *Table) SendKey(evt *tcell.EventKey) {
t.keyboard(evt)
}
func (t *Table) filterInput(r rune) bool {
if !t.cmdBuff.IsActive() { if !t.cmdBuff.IsActive() {
return false return false
} }
@ -147,26 +141,6 @@ func (t *Table) filterInput(r rune) bool {
return true return true
} }
func (t *Table) keyboard(evt *tcell.EventKey) *tcell.EventKey {
key := evt.Key()
if key == tcell.KeyUp || key == tcell.KeyDown {
return evt
}
if key == tcell.KeyRune {
if t.filterInput(evt.Rune()) {
return nil
}
key = AsKey(evt)
}
if a, ok := t.actions[key]; ok {
return a.Action(evt)
}
return evt
}
// Hints returns the view hints. // Hints returns the view hints.
func (t *Table) Hints() model.MenuHints { func (t *Table) Hints() model.MenuHints {
return t.actions.Hints() return t.actions.Hints()
@ -214,7 +188,6 @@ func (t *Table) doUpdate(data render.TableData) {
t.actions.Delete(KeyShiftP) t.actions.Delete(KeyShiftP)
} }
hasMX := t.model.HasMetrics()
var cols []string var cols []string
if t.viewSetting != nil { if t.viewSetting != nil {
cols = t.viewSetting.Columns cols = t.viewSetting.Columns
@ -222,19 +195,19 @@ func (t *Table) doUpdate(data render.TableData) {
if len(cols) == 0 { if len(cols) == 0 {
cols = t.header.Columns(t.wide) cols = t.header.Columns(t.wide)
} }
data = data.Customize(cols, t.wide) custData := data.Customize(cols, t.wide)
if t.sortCol.name == "" || data.Header.IndexOf(t.sortCol.name, false) == -1 { if t.sortCol.name == "" || custData.Header.IndexOf(t.sortCol.name, false) == -1 {
t.sortCol.name = data.Header[0].Name t.sortCol.name = custData.Header[0].Name
} }
t.Clear() t.Clear()
fg := t.styles.Table().Header.FgColor.Color() fg := t.styles.Table().Header.FgColor.Color()
bg := t.styles.Table().Header.BgColor.Color() bg := t.styles.Table().Header.BgColor.Color()
hasMX := t.model.HasMetrics()
var col int var col int
fmt.Printf("NS %q\n", t.GetModel().GetNamespace()) for _, h := range custData.Header {
for _, h := range data.Header {
if h.Name == "NAMESPACE" && !t.GetModel().ClusterWide() { if h.Name == "NAMESPACE" && !t.GetModel().ClusterWide() {
continue continue
} }
@ -247,29 +220,32 @@ func (t *Table) doUpdate(data render.TableData) {
c.SetTextColor(fg) c.SetTextColor(fg)
col++ col++
} }
data.RowEvents.Sort(data.Namespace, data.Header.IndexOf(t.sortCol.name, false), t.sortCol.name == "AGE", t.sortCol.asc) custData.RowEvents.Sort(custData.Namespace, custData.Header.IndexOf(t.sortCol.name, false), t.sortCol.name == "AGE", t.sortCol.asc)
pads := make(MaxyPad, len(data.Header)) pads := make(MaxyPad, len(custData.Header))
ComputeMaxColumns(pads, t.sortCol.name, data.Header, data.RowEvents) ComputeMaxColumns(pads, t.sortCol.name, custData.Header, custData.RowEvents)
for row, re := range data.RowEvents { for row, re := range custData.RowEvents {
t.buildRow(row+1, re, data.Header, pads, hasMX) idx, _ := data.RowEvents.FindIndex(re.Row.ID)
t.buildRow(row+1, re, data.RowEvents[idx], custData.Header, pads)
} }
t.updateSelection(true) t.updateSelection(true)
} }
func (t *Table) buildRow(r int, re render.RowEvent, h render.Header, pads MaxyPad, hasMX bool) { func (t *Table) buildRow(r int, re, ore render.RowEvent, h render.Header, pads MaxyPad) {
color := render.DefaultColorer color := render.DefaultColorer
if t.colorerFn != nil { if t.colorerFn != nil {
color = t.colorerFn color = t.colorerFn
} }
marked := t.IsMarked(re.Row.ID) marked := t.IsMarked(re.Row.ID)
hasMX := t.model.HasMetrics()
var col int var col int
for c, field := range re.Row.Fields { for c, field := range re.Row.Fields {
if c >= len(h) { if c >= len(h) {
log.Error().Msgf("field/header overflow detected for %d::%d. Check your mappings!", c, len(h)) log.Error().Msgf("field/header overflow detected for %q -- %d::%d. Check your mappings!", t.GVR(), c, len(h))
continue continue
} }
if h[c].Name == "NAMESPACE" && !t.GetModel().ClusterWide() { if h[c].Name == "NAMESPACE" && !t.GetModel().ClusterWide() {
continue continue
} }
@ -291,7 +267,7 @@ func (t *Table) buildRow(r int, re render.RowEvent, h render.Header, pads MaxyPa
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(), h, re)) cell.SetTextColor(color(t.GetModel().GetNamespace(), t.header, ore))
if marked { if marked {
cell.SetTextColor(t.styles.Table().MarkColor.Color()) cell.SetTextColor(t.styles.Table().MarkColor.Color())
} }

View File

@ -68,14 +68,16 @@ func hotKeyActions(r Runner, aa ui.KeyActions) {
} }
aa[key] = ui.NewSharedKeyAction( aa[key] = ui.NewSharedKeyAction(
hk.Description, hk.Description,
gotoCmd(r, "", hk.Command), gotoCmd(r, hk.Command, ""),
false) false)
} }
} }
func gotoCmd(r Runner, cmd, path string) ui.ActionHandler { func gotoCmd(r Runner, cmd, path string) ui.ActionHandler {
return func(evt *tcell.EventKey) *tcell.EventKey { return func(evt *tcell.EventKey) *tcell.EventKey {
log.Debug().Msgf("YO! %q -- %q", cmd, path)
if err := r.App().gotoResource(cmd, path, true); err != nil { if err := r.App().gotoResource(cmd, path, true); err != nil {
log.Error().Err(err).Msgf("Command fail")
r.App().Flash().Err(err) r.App().Flash().Err(err)
} }
return nil return nil

View File

@ -74,6 +74,7 @@ func (a *App) Init(version string, rate int) error {
a.Content.Stack.AddListener(a.Menu()) a.Content.Stack.AddListener(a.Menu())
a.App.Init() a.App.Init()
a.SetInputCapture(a.keyboard)
a.bindKeys() a.bindKeys()
if a.Conn() == nil { if a.Conn() == nil {
return errors.New("No client connection detected") return errors.New("No client connection detected")
@ -114,6 +115,23 @@ func (a *App) Init(version string, rate int) error {
return nil return nil
} }
func (a *App) keyboard(evt *tcell.EventKey) *tcell.EventKey {
key := evt.Key()
if key == tcell.KeyRune {
if a.CmdBuff().IsActive() && evt.Modifiers() == tcell.ModNone {
a.CmdBuff().Add(evt.Rune())
return nil
}
key = ui.AsKey(evt)
}
if k, ok := a.HasAction(key); ok && !a.Content.IsTopDialog() {
return k.Action(evt)
}
return evt
}
func (a *App) bindKeys() { func (a *App) bindKeys() {
a.AddActions(ui.KeyActions{ a.AddActions(ui.KeyActions{
tcell.KeyCtrlE: ui.NewSharedKeyAction("ToggleHeader", a.toggleHeaderCmd, false), tcell.KeyCtrlE: ui.NewSharedKeyAction("ToggleHeader", a.toggleHeaderCmd, false),

View File

@ -45,10 +45,8 @@ func (d *Deploy) bindKeys(aa ui.KeyActions) {
} }
func (d *Deploy) showPods(app *App, model ui.Tabular, gvr, path string) { func (d *Deploy) showPods(app *App, model ui.Tabular, gvr, path string) {
var res dao.Deployment var ddp dao.Deployment
res.Init(app.factory, d.GVR()) dp, err := ddp.Load(app.factory, path)
dp, err := res.GetInstance(path)
if err != nil { if err != nil {
app.Flash().Err(err) app.Flash().Err(err)
return return

View File

@ -48,13 +48,13 @@ func ShowPortForwards(v ResourceViewer, path string, ports []string, okFn PortFo
okFn(v, path, extractContainer(p1), tunnel) okFn(v, path, extractContainer(p1), tunnel)
}) })
f.AddButton("Cancel", func() { f.AddButton("Cancel", func() {
DismissPortForwards(v.App(), pages) DismissPortForwards(v, pages)
}) })
modal := tview.NewModalForm(fmt.Sprintf("<PortForward on %s>", path), f) modal := tview.NewModalForm(fmt.Sprintf("<PortForward on %s>", path), f)
modal.SetText("Exposed Ports: " + strings.Join(ports, ",")) modal.SetText("Exposed Ports: " + strings.Join(ports, ","))
modal.SetDoneFunc(func(_ int, b string) { modal.SetDoneFunc(func(_ int, b string) {
DismissPortForwards(v.App(), pages) DismissPortForwards(v, pages)
}) })
pages.AddPage(portForwardKey, modal, false, true) pages.AddPage(portForwardKey, modal, false, true)
@ -63,9 +63,9 @@ func ShowPortForwards(v ResourceViewer, path string, ports []string, okFn PortFo
} }
// DismissPortForwards dismiss the port forward dialog. // DismissPortForwards dismiss the port forward dialog.
func DismissPortForwards(app *App, p *ui.Pages) { func DismissPortForwards(v ResourceViewer, p *ui.Pages) {
p.RemovePage(portForwardKey) p.RemovePage(portForwardKey)
app.SetFocus(p.CurrentPage().Item) v.App().SetFocus(p.CurrentPage().Item)
} }
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------

View File

@ -85,7 +85,7 @@ func runForward(v ResourceViewer, pf watch.Forwarder, f *portforward.PortForward
v.App().QueueUpdateDraw(func() { v.App().QueueUpdateDraw(func() {
v.App().Flash().Infof("PortForward activated %s:%s", pf.Path(), pf.Ports()[0]) v.App().Flash().Infof("PortForward activated %s:%s", pf.Path(), pf.Ports()[0])
DismissPortForwards(v.App(), v.App().Content.Pages) DismissPortForwards(v, v.App().Content.Pages)
}) })
pf.SetActive(true) pf.SetActive(true)

View File

@ -117,7 +117,7 @@ func (p *Pod) shellCmd(evt *tcell.EventKey) *tcell.EventKey {
return evt return evt
} }
if podIsRunning(p.App().factory, path) { if !podIsRunning(p.App().factory, path) {
p.App().Flash().Errf("%s is not in a running state", path) p.App().Flash().Errf("%s is not in a running state", path)
return nil return nil
} }
@ -135,7 +135,7 @@ func (p *Pod) attachCmd(evt *tcell.EventKey) *tcell.EventKey {
return evt return evt
} }
if podIsRunning(p.App().factory, path) { if !podIsRunning(p.App().factory, path) {
p.App().Flash().Errf("%s is not in a happy state", path) p.App().Flash().Errf("%s is not in a happy state", path)
return nil return nil
} }

View File

@ -1,25 +1,14 @@
package view package view
import ( import (
"errors"
"fmt" "fmt"
"strconv"
"strings"
"github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/client"
"github.com/derailed/k9s/internal/dao"
"github.com/derailed/k9s/internal/render" "github.com/derailed/k9s/internal/render"
"github.com/derailed/k9s/internal/ui" "github.com/derailed/k9s/internal/ui"
"github.com/derailed/k9s/internal/watch"
"github.com/derailed/tview" "github.com/derailed/tview"
"github.com/gdamore/tcell" "github.com/gdamore/tcell"
"github.com/rs/zerolog/log"
appsv1 "k8s.io/api/apps/v1"
v1 "k8s.io/api/apps/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/labels"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/kubectl/pkg/polymorphichelpers"
) )
// ReplicaSet presents a replicaset viewer. // ReplicaSet presents a replicaset viewer.
@ -49,38 +38,37 @@ func (r *ReplicaSet) bindKeys(aa ui.KeyActions) {
} }
func (r *ReplicaSet) showPods(app *App, model ui.Tabular, gvr, path string) { func (r *ReplicaSet) showPods(app *App, model ui.Tabular, gvr, path string) {
o, err := app.factory.Get(r.GVR().String(), path, true, labels.Everything()) var drs dao.ReplicaSet
rs, err := drs.Load(app.factory, path)
if err != nil { if err != nil {
app.Flash().Err(err) app.Flash().Err(err)
return return
} }
var rs appsv1.ReplicaSet
err = runtime.DefaultUnstructuredConverter.FromUnstructured(o.(*unstructured.Unstructured).Object, &rs)
if err != nil {
app.Flash().Err(err)
}
showPodsFromSelector(app, path, rs.Spec.Selector) showPodsFromSelector(app, path, rs.Spec.Selector)
} }
func (r *ReplicaSet) rollbackCmd(evt *tcell.EventKey) *tcell.EventKey { func (r *ReplicaSet) rollbackCmd(evt *tcell.EventKey) *tcell.EventKey {
sel := r.GetTable().GetSelectedItem() path := r.GetTable().GetSelectedItem()
if sel == "" { if path == "" {
return evt return evt
} }
r.showModal(fmt.Sprintf("Rollback %s %s?", r.GVR(), sel), func(_ int, button string) { r.showModal(fmt.Sprintf("Rollback %s %s?", r.GVR(), path), func(_ int, button string) {
if button == "OK" { defer r.dismissModal()
r.App().Flash().Infof("Rolling back %s %s", r.GVR(), sel)
if res, err := rollback(r.App().factory, sel); err != nil { if button != "OK" {
r.App().Flash().Err(err) return
} else {
r.App().Flash().Info(res)
}
r.Refresh()
} }
r.dismissModal() r.App().Flash().Infof("Rolling back %s %s", r.GVR(), path)
var drs dao.ReplicaSet
drs.Init(r.App().factory, r.GVR())
if err := drs.Rollback(path); err != nil {
r.App().Flash().Err(err)
} else {
r.App().Flash().Infof("%s successfully rolled back", path)
}
r.Refresh()
}) })
return nil return nil
@ -99,95 +87,3 @@ func (r *ReplicaSet) showModal(msg string, done func(int, string)) {
r.App().Content.AddPage("confirm", confirm, false, false) r.App().Content.AddPage("confirm", confirm, false, false)
r.App().Content.ShowPage("confirm") r.App().Content.ShowPage("confirm")
} }
// ----------------------------------------------------------------------------
// Helpers...
func findRS(f *watch.Factory, path string) (*v1.ReplicaSet, error) {
o, err := f.Get("apps/v1/replicasets", path, true, labels.Everything())
if err != nil {
return nil, err
}
var rs appsv1.ReplicaSet
err = runtime.DefaultUnstructuredConverter.FromUnstructured(o.(*unstructured.Unstructured).Object, &rs)
if err != nil {
return nil, err
}
return &rs, nil
}
func findDP(f *watch.Factory, path string) (*appsv1.Deployment, error) {
o, err := f.Get("apps/v1/deployments", path, true, labels.Everything())
if err != nil {
return nil, err
}
var dp appsv1.Deployment
err = runtime.DefaultUnstructuredConverter.FromUnstructured(o.(*unstructured.Unstructured).Object, &dp)
if err != nil {
return nil, err
}
return &dp, nil
}
func controllerInfo(rs *v1.ReplicaSet) (string, string, string, error) {
for _, ref := range rs.ObjectMeta.OwnerReferences {
if ref.Controller == nil {
continue
}
log.Debug().Msgf("Controller name %s", ref.Name)
tokens := strings.Split(ref.APIVersion, "/")
apiGroup := ref.APIVersion
if len(tokens) == 2 {
apiGroup = tokens[0]
}
return ref.Name, ref.Kind, apiGroup, nil
}
return "", "", "", fmt.Errorf("Unable to find controller for ReplicaSet %s", rs.ObjectMeta.Name)
}
func getRevision(rs *v1.ReplicaSet) (int64, error) {
revision := rs.ObjectMeta.Annotations["deployment.kubernetes.io/revision"]
if rs.Status.Replicas != 0 {
return 0, errors.New("can not rollback current replica")
}
vers, err := strconv.Atoi(revision)
if err != nil {
return 0, errors.New("revision conversion failed")
}
return int64(vers), nil
}
func rollback(f *watch.Factory, path string) (string, error) {
rs, err := findRS(f, path)
if err != nil {
return "", err
}
version, err := getRevision(rs)
if err != nil {
return "", err
}
name, kind, apiGroup, err := controllerInfo(rs)
if err != nil {
return "", err
}
rb, err := polymorphichelpers.RollbackerFor(schema.GroupKind{Group: apiGroup, Kind: kind}, f.Client().DialOrDie())
if err != nil {
return "", err
}
dp, err := findDP(f, client.FQN(rs.Namespace, name))
if err != nil {
return "", err
}
res, err := rb.Rollback(dp, map[string]string{}, version, false)
if err != nil {
return "", err
}
return res, nil
}

View File

@ -40,12 +40,38 @@ func (t *Table) Init(ctx context.Context) (err error) {
ctx = context.WithValue(ctx, internal.KeyStyles, t.app.Styles) ctx = context.WithValue(ctx, internal.KeyStyles, t.app.Styles)
ctx = context.WithValue(ctx, internal.KeyViewConfig, t.app.CustomView) ctx = context.WithValue(ctx, internal.KeyViewConfig, t.app.CustomView)
t.Table.Init(ctx) t.Table.Init(ctx)
t.SetInputCapture(t.keyboard)
t.bindKeys() t.bindKeys()
t.GetModel().SetRefreshRate(time.Duration(t.app.Config.K9s.GetRefreshRate()) * time.Second) t.GetModel().SetRefreshRate(time.Duration(t.app.Config.K9s.GetRefreshRate()) * time.Second)
return nil return nil
} }
// SendKey sends an keyboard event (testing only!).
func (t *Table) SendKey(evt *tcell.EventKey) {
t.keyboard(evt)
}
func (t *Table) keyboard(evt *tcell.EventKey) *tcell.EventKey {
key := evt.Key()
if key == tcell.KeyUp || key == tcell.KeyDown {
return evt
}
if key == tcell.KeyRune {
if t.FilterInput(evt.Rune()) {
return nil
}
key = ui.AsKey(evt)
}
if a, ok := t.Actions()[key]; ok && !t.app.Content.IsTopDialog() {
return a.Action(evt)
}
return evt
}
// Name returns the table name. // Name returns the table name.
func (t *Table) Name() string { return t.GVR().R() } func (t *Table) Name() string { return t.GVR().R() }

View File

@ -2,7 +2,6 @@ package view
import ( import (
"context" "context"
"fmt"
"io/ioutil" "io/ioutil"
"path/filepath" "path/filepath"
"testing" "testing"
@ -78,11 +77,6 @@ func TestTableViewSort(t *testing.T) {
v.SetModel(&testTableModel{}) v.SetModel(&testTableModel{})
v.SortColCmd("NAME", true)(nil) v.SortColCmd("NAME", true)(nil)
assert.Equal(t, 3, v.GetRowCount()) assert.Equal(t, 3, v.GetRowCount())
for i := 0; i < v.GetRowCount(); i++ {
for j := 0; j < v.GetColumnCount(); j++ {
fmt.Println(v.GetCell(i, j).Text)
}
}
assert.Equal(t, "blee", v.GetCell(1, 0).Text) assert.Equal(t, "blee", v.GetCell(1, 0).Text)
v.SortInvertCmd(nil) v.SortInvertCmd(nil)