From 087e6643f9c63c339a2173dd87bff2efa9254c83 Mon Sep 17 00:00:00 2001 From: derailed Date: Tue, 27 Oct 2020 19:38:54 -0600 Subject: [PATCH] add liveviews for describe/yaml --- change_logs/release_v0.23.0.md | 72 ++++++- go.mod | 1 + go.sum | 4 + internal/config/k9s.go | 8 +- internal/dao/describe.go | 1 - internal/dao/generic.go | 4 +- internal/dao/helm.go | 2 +- internal/dao/helpers.go | 10 +- internal/dao/non_resource.go | 6 + internal/dao/ofaas.go | 2 +- internal/dao/resource.go | 4 +- internal/dao/types.go | 2 +- internal/model/cmd_buff.go | 37 +++- internal/model/describe.go | 211 +++++++++++++++++++++ internal/model/table.go | 44 +---- internal/model/table_int_test.go | 2 +- internal/model/text.go | 12 ++ internal/model/tree.go | 2 +- internal/model/yaml.go | 233 +++++++++++++++++++++++ internal/view/alias_test.go | 3 +- internal/view/app.go | 21 ++- internal/view/browser.go | 14 +- internal/view/command.go | 8 +- internal/view/cow.go | 137 ++++++++++++++ internal/view/helpers.go | 13 +- internal/view/live_view.go | 311 +++++++++++++++++++++++++++++++ internal/view/meow.go | 133 ------------- internal/view/node.go | 2 +- internal/view/table.go | 4 +- internal/view/table_int_test.go | 2 +- 30 files changed, 1072 insertions(+), 233 deletions(-) create mode 100644 internal/model/describe.go create mode 100644 internal/model/yaml.go create mode 100644 internal/view/cow.go create mode 100644 internal/view/live_view.go delete mode 100644 internal/view/meow.go diff --git a/change_logs/release_v0.23.0.md b/change_logs/release_v0.23.0.md index 6f11e51a..462ec061 100644 --- a/change_logs/release_v0.23.0.md +++ b/change_logs/release_v0.23.0.md @@ -14,28 +14,75 @@ On Slack? Please join us [K9slackers](https://join.slack.com/t/k9sers/shared_inv ## ♫ Sound Behind The Release ♭ -I figured, why not share one of the tunes I was spinning when powering thru teh bugs? Might as well share the pain/pleasure while viewing this release notes! +I figured why not share one of the tunes I was spinning when powering thru teh bugs? Might as well share the pain/pleasure while viewing this release notes! [On An Island - David Gilmour With Crosby&Nash](https://www.youtube.com/watch?v=kEa__0wtIRo) +## Our K9s Heroes + +Please join me in recognizing and applauding this drop contributors that went the extra mile to make sure K9s is better and more useful for all of us!! + +Big ATTA BOY/GIRL! in full effect this week to the good folks below for their efforts and contributions to K9s!! + +* [Antoine Méausoone](https://github.com/Ameausoone) +* [Michael Albers](https://github.com/michaeljohnalbers) +* [Wi1dcard](https://github.com/wi1dcard) +* [Saskia Keil](https://github.com/SaskiaKeil) +* [Tomasz Lipinski](https://github.com/tlipinski) +* [Emeric Martineau](https://github.com/emeric-martineau) +* [Eldad Assis](https://github.com/eldada) +* [David Arnold](https://github.com/blaggacao) +* [Peter Parente](https://github.com/parente) + ## A Word From Our Sponsors... -First off, I would like to send a `Big Thank You` to the following generous K9s friends for joining our sponsorship program and supporting this project! +First off I would like to send a `Big Thank You` to the following generous K9s friends for joining our sponsorship program and supporting this project! -* [Martin Kemp](https://github.com/MartiUK) +* [William Alexander](https://github.com/carpetfuz) +* [Jiri Valnoha](https://github.com/waldauf) +* [Pavel Tumik](https://github.com/sagor999) +* [Bart Plasmeijer](https://github.com/bplasmeijer) +* [Matt Welke](https://github.com/mattwelke) +* [Stefan Mikolajczyk](https://github.com/stefanmiko) + +Contrarily to popular belief, OSS is not free! We've now reached ~9k stars and 300k downloads! As you all know, this project is not pimped out by a big company with deep pockets or a large team. K9s is complex and does demand a lot of my time. So if this tool is useful to you or your organization and part of your daily Kubernetes lifecycle, please contribute! Your contribution whether financial, PRs, issues or shout-outs on social/blogs are crucial to keep K9s growing and powerful for all of us. Don't let OSS by individual contributors become an oxymoron! + +## Full Screen + +We've added a new option to enable full screen while describing or viewing a resource YAML namely `f`. This works similarly to the full screen toggle option while viewing logs ie pressing `f` will toggle fullscreen on/off. -Contrarily to popular belief, OSS is not free! We've now reached ~9k stars and 300k downloads! As you all know, this project is not pimped out by a big company with deep pockets and a large team. K9s is complex and does demand a lot of my time. So if this tool is useful to you and part of your daily lifecycle, please contribute! Your contribution whether financial, PRs, issues or shout-outs on social/blogs are crucial to keep K9s growing and powerful for all of us. Don't let OSS by individual contributors become an oxymoron! ## Best Effort... Not! -In this drop, we've added 2 new columns to the Pod/Container views namely `CPU(R:L)` and `MEM(R:L)`. These represents the current request/limit resources specified at either the pod or container level. While in Pod view, you will need to use the wide command `Ctrl-W` to see the resources set at the pod level or you can use K9s column customization feature to volunteer them by default. +In this drop, we've added 2 new columns to the Pod/Container views namely `CPU(R:L)` and `MEM(R:L)`. These represents the current request/limit resources specified at either the pod or container level. While in Pod view, you will need to use the wide command `Ctrl-W` to see the resources set at the pod level or you can leverage K9s column customization feature to volunteer them while in Pod view. In the Container view these columns will be available by default. ## Container Images -You have now the ability to tweak your container images for experimentation, using the new SetImage binding aka `i`. This feature is available for standalone pods, deployments, sts and ds. With a resource selected, pressing `i` will provision an edit dialog listing all init/container images. +You have now the ability to tweak your container images for experimentation, using the new SetImage binding aka `i`. This feature is available for unmanaged pods, deployments, sts and ds. With a resource selected, pressing `i` will provision an edit dialog listing all init/container images. NOTE! This is a one shot commands applied directly against your cluster and won't survive a new resource deployment. -Big `ATTA Boy!` in effect to [Antoine Méausoone](https://github.com/Ameausoone) for putting forth the effort to make this feature available to all of us!! +## Crumbs On, Crumbs Off, Caterpillar + +We've added a new configuration to turn off the crumbs via `crumbsLess` configuration option. You can also toggle the crumbs via the new key option `C`. You can enable/disable this option in your ~/.k9s/config.yml or via command line using `--crumbsless` flag. + +```yaml +k9s: + refreshRate: 2 + headless: false + crumbsless: false + readOnly: true + ... +``` + +## FILTER...NOT! + +Some folks have voiced the desire to use inverse filters while filtering to content in the resource table views. There is now a new filter option available when performing these filtering operations. For example, in order to see all pods that are not named `fred` you can now use `/!fred` as your filtering command. + +## Disturbance In the Keyboard Force... + +In this drop we've changed the key binding to toggle the header from `Ctrl-E` to `H` + +--- ## Resolved Issues/Features @@ -54,6 +101,17 @@ Big `ATTA Boy!` in effect to [Antoine Méausoone](https://github.com/Ameausoone) ## Resolved PRs +* [PR #909](https://github.com/derailed/k9s/pull/909) Add support for inverse filtering +* [PR #908](https://github.com/derailed/k9s/pull/908) Remove trailing delta from the scale dialog when replicas are in flux +* [PR #907](https://github.com/derailed/k9s/pull/907) Improve docs on sinceSeconds logger option +* [PR #904](https://github.com/derailed/k9s/pull/904) PVC `UsedBy` list irrelevant statefulsets +* [PR #898](https://github.com/derailed/k9s/pull/898) Use config.CallTimeout in APIClient +* [PR #897](https://github.com/derailed/k9s/pull/897) Use DefaultColorer for aliases rendering +* [PR #896](https://github.com/derailed/k9s/pull/896) Allow remove crumbs +* [PR #894](https://github.com/derailed/k9s/pull/894) Execute plugins and pass context +* [PR #891](https://github.com/derailed/k9s/pull/891) Add command to get the latest stable kubectl version and support for KUBECTL_VERSION as Dockerfile ARG +* [PR #847](https://github.com/derailed/k9s/pull/847) Add ability to set container images + --- © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) diff --git a/go.mod b/go.mod index c9d56117..a3dbe81b 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.15 require ( github.com/atotto/clipboard v0.1.2 + github.com/cenkalti/backoff/v4 v4.1.0 github.com/derailed/popeye v0.8.10 github.com/derailed/tview v0.4.6 github.com/drone/envsubst v1.0.2 // indirect diff --git a/go.sum b/go.sum index fd70fa87..9c86cbf1 100644 --- a/go.sum +++ b/go.sum @@ -96,6 +96,10 @@ github.com/bugsnag/osext v0.0.0-20130617224835-0dd3f918b21b h1:otBG+dV+YK+Soembj github.com/bugsnag/osext v0.0.0-20130617224835-0dd3f918b21b/go.mod h1:obH5gd0BsqsP2LwDJ9aOkm/6J86V6lyAXCoQWGw3K50= github.com/bugsnag/panicwrap v0.0.0-20151223152923-e2c28503fcd0 h1:nvj0OLI3YqYXer/kZD8Ri1aaunCxIEsOst1BVJswV0o= github.com/bugsnag/panicwrap v0.0.0-20151223152923-e2c28503fcd0/go.mod h1:D/8v3kj0zr8ZAKg1AQ6crr+5VwKN5eIywRkfhyM/+dE= +github.com/cenkalti/backoff v1.1.0 h1:QnvVp8ikKCDWOsFheytRCoYWYPO/ObCTBGxT19Hc+yE= +github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4= +github.com/cenkalti/backoff/v4 v4.1.0 h1:c8LkOFQTzuO0WBM/ae5HdGQuZPfPxp7lqBRwQRm4fSc= +github.com/cenkalti/backoff/v4 v4.1.0/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko= github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= diff --git a/internal/config/k9s.go b/internal/config/k9s.go index 51e3d264..98c5bf85 100644 --- a/internal/config/k9s.go +++ b/internal/config/k9s.go @@ -59,8 +59,8 @@ func (k *K9s) OverrideCommand(cmd string) { k.manualCommand = &cmd } -// GetHeadless returns headless setting. -func (k *K9s) GetHeadless() bool { +// IsHeadless returns headless setting. +func (k *K9s) IsHeadless() bool { h := k.Headless if k.manualHeadless != nil && *k.manualHeadless { h = *k.manualHeadless @@ -69,8 +69,8 @@ func (k *K9s) GetHeadless() bool { return h } -// GetCrumbsless returns crumbsless setting. -func (k *K9s) GetCrumbsless() bool { +// IsCrumbsless returns crumbsless setting. +func (k *K9s) IsCrumbsless() bool { h := k.Crumbsless if k.manualCrumbsless != nil && *k.manualCrumbsless { h = *k.manualCrumbsless diff --git a/internal/dao/describe.go b/internal/dao/describe.go index 4e5cc0b4..6d40d482 100644 --- a/internal/dao/describe.go +++ b/internal/dao/describe.go @@ -35,7 +35,6 @@ func Describe(c client.Connection, gvr client.GVR, path string) (string, error) log.Error().Err(err).Msgf("Unable to find describer for %#v", mapping) return "", err } - log.Debug().Msgf("Describing %q -- %q", ns, n) return d.Describe(ns, n, describe.DescriberSettings{ShowEvents: true}) } diff --git a/internal/dao/generic.go b/internal/dao/generic.go index 38d7b445..f204f3ef 100644 --- a/internal/dao/generic.go +++ b/internal/dao/generic.go @@ -78,13 +78,13 @@ func (g *Generic) Describe(path string) (string, error) { } // ToYAML returns a resource yaml. -func (g *Generic) ToYAML(path string) (string, error) { +func (g *Generic) ToYAML(path string, showManaged bool) (string, error) { o, err := g.Get(context.Background(), path) if err != nil { return "", err } - raw, err := ToYAML(o) + raw, err := ToYAML(o, showManaged) if err != nil { return "", fmt.Errorf("unable to marshal resource %s", err) } diff --git a/internal/dao/helm.go b/internal/dao/helm.go index 02fb990f..78f10260 100644 --- a/internal/dao/helm.go +++ b/internal/dao/helm.go @@ -74,7 +74,7 @@ func (c *Helm) Describe(path string) (string, error) { } // ToYAML returns the chart manifest. -func (c *Helm) ToYAML(path string) (string, error) { +func (c *Helm) ToYAML(path string, showManaged bool) (string, error) { ns, n := client.Namespaced(path) cfg, err := c.EnsureHelmConfig(ns) if err != nil { diff --git a/internal/dao/helpers.go b/internal/dao/helpers.go index 733df5c2..a60e9184 100644 --- a/internal/dao/helpers.go +++ b/internal/dao/helpers.go @@ -8,6 +8,7 @@ import ( "github.com/derailed/tview" runewidth "github.com/mattn/go-runewidth" "github.com/rs/zerolog/log" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" "k8s.io/cli-runtime/pkg/printers" ) @@ -33,7 +34,7 @@ func Truncate(str string, width int) string { } // ToYAML converts a resource to its YAML representation. -func ToYAML(o runtime.Object) (string, error) { +func ToYAML(o runtime.Object, showManaged bool) (string, error) { if o == nil { return "", errors.New("no object to yamlize") } @@ -42,6 +43,13 @@ func ToYAML(o runtime.Object) (string, error) { buff bytes.Buffer p printers.YAMLPrinter ) + if !showManaged { + o = o.DeepCopyObject() + uo := o.(*unstructured.Unstructured).Object + if meta, ok := uo["metadata"].(map[string]interface{}); ok { + delete(meta, "managedFields") + } + } err := p.PrintObj(o, &buff) if err != nil { log.Error().Msgf("Marshal Error %v", err) diff --git a/internal/dao/non_resource.go b/internal/dao/non_resource.go index a4fcf8b1..d7842467 100644 --- a/internal/dao/non_resource.go +++ b/internal/dao/non_resource.go @@ -3,6 +3,7 @@ package dao import ( "context" "fmt" + "sync" "github.com/derailed/k9s/internal/client" "k8s.io/apimachinery/pkg/runtime" @@ -13,15 +14,20 @@ type NonResource struct { Factory gvr client.GVR + mx sync.RWMutex } // Init initializes the resource. func (n *NonResource) Init(f Factory, gvr client.GVR) { + n.mx.Lock() + defer n.mx.Unlock() n.Factory, n.gvr = f, gvr } // GVR returns a gvr. func (n *NonResource) GVR() string { + n.mx.RLock() + defer n.mx.RUnlock() return n.gvr.String() } diff --git a/internal/dao/ofaas.go b/internal/dao/ofaas.go index aade1efb..832213ad 100644 --- a/internal/dao/ofaas.go +++ b/internal/dao/ofaas.go @@ -113,7 +113,7 @@ func (f *OpenFaas) Delete(path string, _, _ bool) error { } // ToYAML dumps a function to yaml. -func (f *OpenFaas) ToYAML(path string) (string, error) { +func (f *OpenFaas) ToYAML(path string, _ bool) (string, error) { return f.Describe(path) } diff --git a/internal/dao/resource.go b/internal/dao/resource.go index 0f00cf8b..fbd6b549 100644 --- a/internal/dao/resource.go +++ b/internal/dao/resource.go @@ -39,13 +39,13 @@ func (r *Resource) Get(_ context.Context, path string) (runtime.Object, error) { } // ToYAML returns a resource yaml. -func (r *Resource) ToYAML(path string) (string, error) { +func (r *Resource) ToYAML(path string, showManaged bool) (string, error) { o, err := r.Get(context.Background(), path) if err != nil { return "", err } - raw, err := ToYAML(o) + raw, err := ToYAML(o, showManaged) if err != nil { return "", fmt.Errorf("unable to marshal resource %s", err) } diff --git a/internal/dao/types.go b/internal/dao/types.go index 0f858411..69a6aeab 100644 --- a/internal/dao/types.go +++ b/internal/dao/types.go @@ -102,7 +102,7 @@ type Describer interface { Describe(path string) (string, error) // ToYAML dumps a resource to YAML. - ToYAML(path string) (string, error) + ToYAML(path string, showManaged bool) (string, error) } // Scalable represents resources that can scale. diff --git a/internal/model/cmd_buff.go b/internal/model/cmd_buff.go index 6e11eed4..210a7b2d 100644 --- a/internal/model/cmd_buff.go +++ b/internal/model/cmd_buff.go @@ -2,13 +2,14 @@ package model import ( "context" + "sync" "time" ) const ( maxBuff = 10 - keyEntryDelay = 300 * time.Millisecond + keyEntryDelay = 200 * time.Millisecond // CommandBuffer represents a command buffer. CommandBuffer BufferKind = 1 << iota @@ -41,6 +42,7 @@ type CmdBuff struct { kind BufferKind active bool cancel context.CancelFunc + mx sync.RWMutex } // NewCmdBuff returns a new command buffer. @@ -82,6 +84,8 @@ func (c *CmdBuff) SetText(cmd string) { // Add adds a new character to the buffer. func (c *CmdBuff) Add(r rune) { + c.mx.Lock() + defer c.mx.Unlock() c.buff = append(c.buff, r) c.fireBufferChanged() if c.cancel != nil { @@ -92,13 +96,19 @@ func (c *CmdBuff) Add(r rune) { go func() { <-ctx.Done() - c.fireBufferCompleted() - c.cancel = nil + c.mx.Lock() + { + c.fireBufferCompleted() + c.cancel = nil + } + c.mx.Unlock() }() } // Delete removes the last character from the buffer. func (c *CmdBuff) Delete() { + c.mx.Lock() + defer c.mx.Unlock() if c.Empty() { return } @@ -113,13 +123,19 @@ func (c *CmdBuff) Delete() { go func() { <-ctx.Done() - c.fireBufferCompleted() - c.cancel = nil + c.mx.Lock() + { + c.fireBufferCompleted() + c.cancel = nil + } + c.mx.Unlock() }() } // ClearText clears out command buffer. func (c *CmdBuff) ClearText(fire bool) { + c.mx.Lock() + defer c.mx.Unlock() c.buff = make([]rune, 0, maxBuff) if fire { c.fireBufferCompleted() @@ -143,11 +159,16 @@ func (c *CmdBuff) Empty() bool { // AddListener registers a cmd buffer listener. func (c *CmdBuff) AddListener(w BuffWatcher) { + c.mx.Lock() + defer c.mx.Unlock() c.listeners = append(c.listeners, w) } // RemoveListener removes a listener. func (c *CmdBuff) RemoveListener(l BuffWatcher) { + c.mx.Lock() + defer c.mx.Unlock() + victim := -1 for i, lis := range c.listeners { if l == lis { @@ -163,14 +184,16 @@ func (c *CmdBuff) RemoveListener(l BuffWatcher) { } func (c *CmdBuff) fireBufferCompleted() { + text := c.GetText() for _, l := range c.listeners { - l.BufferCompleted(c.GetText()) + l.BufferCompleted(text) } } func (c *CmdBuff) fireBufferChanged() { + text := c.GetText() for _, l := range c.listeners { - l.BufferChanged(c.GetText()) + l.BufferChanged(text) } } diff --git a/internal/model/describe.go b/internal/model/describe.go new file mode 100644 index 00000000..a500da06 --- /dev/null +++ b/internal/model/describe.go @@ -0,0 +1,211 @@ +package model + +import ( + "context" + "fmt" + "reflect" + "regexp" + "strings" + "sync/atomic" + "time" + + backoff "github.com/cenkalti/backoff/v4" + "github.com/derailed/k9s/internal/client" + "github.com/derailed/k9s/internal/dao" + "github.com/rs/zerolog/log" + "github.com/sahilm/fuzzy" +) + +type ResourceViewerListener interface { + ResourceChanged(lines []string, matches fuzzy.Matches) + ResourceFailed(error) +} + +type ResourceViewer interface { + GetPath() string + Filter(string) + ClearFilter() + Peek() []string + Watch(context.Context) + AddListener(ResourceViewerListener) + RemoveListener(ResourceViewerListener) +} + +// Describe tracks describeable resources. +type Describe struct { + gvr client.GVR + inUpdate int32 + path string + query string + lines []string + refreshRate time.Duration + listeners []ResourceViewerListener +} + +// NewDescribe returns a new describe resource model. +func NewDescribe(gvr client.GVR, path string) *Describe { + return &Describe{ + gvr: gvr, + path: path, + refreshRate: 2 * time.Second, + } +} + +// GetPath returns the active resource path. +func (d *Describe) GetPath() string { + return d.path +} + +// Filter filters the model. +func (d *Describe) Filter(q string) { + d.query = q + d.filterChanged(d.lines) +} + +func (d *Describe) filterChanged(lines []string) { + d.fireResourceChanged(lines, d.filter(d.query, lines)) +} + +func (d *Describe) filter(q string, lines []string) fuzzy.Matches { + if q == "" { + return nil + } + if dao.IsFuzzySelector(q) { + return d.fuzzyFilter(strings.TrimSpace(q[2:]), lines) + } + return d.rxFilter(q, lines) +} + +func (*Describe) fuzzyFilter(q string, lines []string) fuzzy.Matches { + return fuzzy.Find(q, lines) +} + +func (*Describe) rxFilter(q string, lines []string) fuzzy.Matches { + rx, err := regexp.Compile(`(?i)` + q) + if err != nil { + return nil + } + matches := make(fuzzy.Matches, 0, len(lines)) + for i, l := range lines { + if loc := rx.FindStringIndex(l); len(loc) == 2 { + matches = append(matches, fuzzy.Match{Str: q, Index: i, MatchedIndexes: loc}) + } + } + + return matches +} + +func (d *Describe) fireResourceChanged(lines []string, matches fuzzy.Matches) { + for _, l := range d.listeners { + l.ResourceChanged(lines, matches) + } +} + +func (d *Describe) fireResourceFailed(err error) { + for _, l := range d.listeners { + l.ResourceFailed(err) + } +} + +// ClearFilter clear out the filter +func (d *Describe) ClearFilter() { +} + +// Peek returns current model state. +func (d *Describe) Peek() []string { + return d.lines +} + +// Watch watches for describe data changes. +func (d *Describe) Watch(ctx context.Context) { + d.refresh(ctx) + go d.updater(ctx) +} + +func (d *Describe) updater(ctx context.Context) { + defer log.Debug().Msgf("Describe canceled -- %q", d.gvr) + + bf := backoff.NewExponentialBackOff() + bf.InitialInterval, bf.MaxElapsedTime = initRefreshRate, maxRetryInterval + rate := initRefreshRate + for { + select { + case <-ctx.Done(): + return + case <-time.After(rate): + rate = d.refreshRate + err := backoff.Retry(func() error { + return d.refresh(ctx) + }, backoff.WithContext(bf, ctx)) + if err != nil { + log.Error().Err(err).Msgf("Retry failed") + d.fireResourceFailed(err) + return + } + } + } +} +func (d *Describe) refresh(ctx context.Context) error { + if !atomic.CompareAndSwapInt32(&d.inUpdate, 0, 1) { + log.Debug().Msgf("Dropping update...") + return nil + } + defer atomic.StoreInt32(&d.inUpdate, 0) + + if err := d.reconcile(ctx); err != nil { + log.Error().Err(err).Msgf("reconcile failed %q", d.gvr) + d.fireResourceFailed(err) + return err + } + + return nil +} + +func (d *Describe) reconcile(ctx context.Context) error { + s, err := d.describe(ctx, d.gvr, d.path) + if err != nil { + return err + } + lines := strings.Split(s, "\n") + if reflect.DeepEqual(lines, d.lines) { + return nil + } + d.lines = lines + d.fireResourceChanged(d.lines, d.filter(d.query, d.lines)) + + return nil +} + +// Describe describes a given resource. +func (d *Describe) describe(ctx context.Context, gvr client.GVR, path string) (string, error) { + meta, err := getMeta(ctx, gvr) + if err != nil { + return "", err + } + desc, ok := meta.DAO.(dao.Describer) + if !ok { + return "", fmt.Errorf("no describer for %q", meta.DAO.GVR()) + } + + return desc.Describe(path) +} + +// AddListener adds a new model listener. +func (d *Describe) AddListener(l ResourceViewerListener) { + d.listeners = append(d.listeners, l) +} + +// RemoveListener delete a listener from the list. +func (d *Describe) RemoveListener(l ResourceViewerListener) { + victim := -1 + for i, lis := range d.listeners { + if lis == l { + victim = i + break + } + } + + if victim >= 0 { + d.listeners = append(d.listeners[:victim], d.listeners[victim+1:]...) + } +} diff --git a/internal/model/table.go b/internal/model/table.go index 1c9f70d9..f496afa3 100644 --- a/internal/model/table.go +++ b/internal/model/table.go @@ -51,7 +51,9 @@ func NewTable(gvr client.GVR) *Table { // SetLabelFilter sets the labels filter. func (t *Table) SetLabelFilter(f string) { + t.mx.Lock() t.labelFilter = f + t.mx.Unlock() } // SetInstance sets a single entry table. @@ -94,7 +96,7 @@ func (t *Table) Refresh(ctx context.Context) { // Get returns a resource instance if found, else an error. func (t *Table) Get(ctx context.Context, path string) (runtime.Object, error) { - meta, err := t.getMeta(ctx) + meta, err := getMeta(ctx, t.gvr) if err != nil { return nil, err } @@ -104,7 +106,7 @@ func (t *Table) Get(ctx context.Context, path string) (runtime.Object, error) { // Delete deletes a resource. func (t *Table) Delete(ctx context.Context, path string, cascade, force bool) error { - meta, err := t.getMeta(ctx) + meta, err := getMeta(ctx, t.gvr) if err != nil { return err } @@ -119,7 +121,7 @@ func (t *Table) Delete(ctx context.Context, path string, cascade, force bool) er // Describe describes a given resource. func (t *Table) Describe(ctx context.Context, path string) (string, error) { - meta, err := t.getMeta(ctx) + meta, err := getMeta(ctx, t.gvr) if err != nil { return "", err } @@ -134,7 +136,7 @@ func (t *Table) Describe(ctx context.Context, path string) (string, error) { // ToYAML returns a resource yaml. func (t *Table) ToYAML(ctx context.Context, path string) (string, error) { - meta, err := t.getMeta(ctx) + meta, err := getMeta(ctx, t.gvr) if err != nil { return "", err } @@ -144,7 +146,7 @@ func (t *Table) ToYAML(ctx context.Context, path string) (string, error) { return "", fmt.Errorf("no describer for %q", meta.DAO.GVR()) } - return desc.ToYAML(path) + return desc.ToYAML(path, false) } // GetNamespace returns the model namespace. @@ -232,7 +234,9 @@ func (t *Table) list(ctx context.Context, a dao.Accessor) ([]runtime.Object, err } func (t *Table) reconcile(ctx context.Context) error { - meta := t.resourceMeta() + t.mx.Lock() + defer t.mx.Unlock() + meta := resourceMeta(t.gvr) if t.labelFilter != "" { ctx = context.WithValue(ctx, internal.KeyLabels, t.labelFilter) } @@ -269,8 +273,6 @@ func (t *Table) reconcile(ctx context.Context) error { } } - t.mx.Lock() - defer t.mx.Unlock() // if labelSelector in place might as well clear the model data. sel, ok := ctx.Value(internal.KeyLabels).(string) if ok && sel != "" { @@ -286,32 +288,6 @@ func (t *Table) reconcile(ctx context.Context) error { return nil } -func (t *Table) getMeta(ctx context.Context) (ResourceMeta, error) { - meta := t.resourceMeta() - factory, ok := ctx.Value(internal.KeyFactory).(dao.Factory) - if !ok { - return ResourceMeta{}, fmt.Errorf("expected Factory in context but got %T", ctx.Value(internal.KeyFactory)) - } - meta.DAO.Init(factory, t.gvr) - - return meta, nil -} - -func (t *Table) resourceMeta() ResourceMeta { - meta, ok := Registry[t.gvr.String()] - if !ok { - meta = ResourceMeta{ - DAO: &dao.Table{}, - Renderer: &render.Generic{}, - } - } - if meta.DAO == nil { - meta.DAO = &dao.Resource{} - } - - return meta -} - func (t *Table) fireTableChanged(data render.TableData) { t.mx.RLock() defer t.mx.RUnlock() diff --git a/internal/model/table_int_test.go b/internal/model/table_int_test.go index 412e7a96..2923bbd8 100644 --- a/internal/model/table_int_test.go +++ b/internal/model/table_int_test.go @@ -91,7 +91,7 @@ func TestTableMeta(t *testing.T) { for k := range uu { u := uu[k] ta := NewTable(client.NewGVR(u.gvr)) - m := ta.resourceMeta() + m := resourceMeta(ta.gvr) assert.Equal(t, u.accessor, m.DAO) assert.Equal(t, u.renderer, m.Renderer) diff --git a/internal/model/text.go b/internal/model/text.go index 442b7567..3046a0c3 100644 --- a/internal/model/text.go +++ b/internal/model/text.go @@ -8,6 +8,18 @@ import ( "github.com/sahilm/fuzzy" ) +type Filterable interface { + Filter(string) + ClearFilter() +} + +type Textable interface { + Peek() []string + SetText(string) + AddListener(TextListener) + RemoveListener(TextListener) +} + // TextListener represents a text model listener. type TextListener interface { // TextChanged notifies the model changed. diff --git a/internal/model/tree.go b/internal/model/tree.go index a1aaa624..272c9e54 100644 --- a/internal/model/tree.go +++ b/internal/model/tree.go @@ -155,7 +155,7 @@ func (t *Tree) ToYAML(ctx context.Context, gvr, path string) (string, error) { return "", fmt.Errorf("no describer for %q", meta.DAO.GVR()) } - return desc.ToYAML(path) + return desc.ToYAML(path, false) } func (t *Tree) updater(ctx context.Context) { diff --git a/internal/model/yaml.go b/internal/model/yaml.go new file mode 100644 index 00000000..40d1a3b3 --- /dev/null +++ b/internal/model/yaml.go @@ -0,0 +1,233 @@ +package model + +import ( + "context" + "fmt" + "reflect" + "regexp" + "strings" + "sync/atomic" + "time" + + backoff "github.com/cenkalti/backoff/v4" + "github.com/derailed/k9s/internal" + "github.com/derailed/k9s/internal/client" + "github.com/derailed/k9s/internal/dao" + "github.com/derailed/k9s/internal/render" + "github.com/rs/zerolog/log" + "github.com/sahilm/fuzzy" +) + +const maxRetryInterval = 1 * time.Minute + +// YAML tracks yaml resource representations. +type YAML struct { + gvr client.GVR + inUpdate int32 + showManagedFields bool + path string + query string + lines []string + refreshRate time.Duration + listeners []ResourceViewerListener +} + +// NewYAML return a new yaml resource model. +func NewYAML(gvr client.GVR, path string) *YAML { + return &YAML{ + gvr: gvr, + path: path, + refreshRate: 2 * time.Second, + } +} + +// GetPath returns the active resource path. +func (y *YAML) GetPath() string { + return y.path +} + +// Filter filters the model. +func (y *YAML) Filter(q string) { + y.query = q + y.filterChanged(y.lines) +} + +func (y *YAML) filterChanged(lines []string) { + y.fireResourceChanged(lines, y.filter(y.query, lines)) +} + +func (y *YAML) filter(q string, lines []string) fuzzy.Matches { + if q == "" { + return nil + } + if dao.IsFuzzySelector(q) { + return y.fuzzyFilter(strings.TrimSpace(q[2:]), lines) + } + return y.rxFilter(q, lines) +} + +func (*YAML) fuzzyFilter(q string, lines []string) fuzzy.Matches { + return fuzzy.Find(q, lines) +} + +func (*YAML) rxFilter(q string, lines []string) fuzzy.Matches { + rx, err := regexp.Compile(`(?i)` + q) + if err != nil { + return nil + } + matches := make(fuzzy.Matches, 0, len(lines)) + for i, l := range lines { + if loc := rx.FindStringIndex(l); len(loc) == 2 { + matches = append(matches, fuzzy.Match{Str: q, Index: i, MatchedIndexes: loc}) + } + } + + return matches +} + +func (y *YAML) fireResourceChanged(lines []string, matches fuzzy.Matches) { + for _, l := range y.listeners { + l.ResourceChanged(lines, matches) + } +} + +func (y *YAML) fireResourceFailed(err error) { + for _, l := range y.listeners { + l.ResourceFailed(err) + } +} + +// ClearFilter clear out the filter. +func (y *YAML) ClearFilter() { + y.query = "" +} + +// Peel returns the current model data. +func (y *YAML) Peek() []string { + return y.lines +} + +// Watch watches for YAML changes. +func (y *YAML) Watch(ctx context.Context) { + if err := y.refresh(ctx); err != nil { + log.Error().Err(err).Msgf("YAML Refresh failed") + return + } + go y.updater(ctx) +} + +func (y *YAML) updater(ctx context.Context) { + defer log.Debug().Msgf("YAML canceled -- %q", y.gvr) + + bf := backoff.NewExponentialBackOff() + bf.InitialInterval, bf.MaxElapsedTime = initRefreshRate, maxRetryInterval + rate := initRefreshRate + for { + select { + case <-ctx.Done(): + return + case <-time.After(rate): + rate = y.refreshRate + err := backoff.Retry(func() error { + return y.refresh(ctx) + }, backoff.WithContext(bf, ctx)) + if err != nil { + log.Error().Err(err).Msgf("Retry failed") + y.fireResourceFailed(err) + return + } + } + } +} + +func (y *YAML) refresh(ctx context.Context) error { + if !atomic.CompareAndSwapInt32(&y.inUpdate, 0, 1) { + log.Debug().Msgf("Dropping update...") + return nil + } + defer atomic.StoreInt32(&y.inUpdate, 0) + + if err := y.reconcile(ctx); err != nil { + log.Error().Err(err).Msgf("reconcile failed %q", y.gvr) + y.fireResourceFailed(err) + return err + } + + return nil +} + +func (y *YAML) reconcile(ctx context.Context) error { + s, err := y.ToYAML(ctx, y.gvr, y.path, y.showManagedFields) + if err != nil { + return err + } + lines := strings.Split(s, "\n") + if reflect.DeepEqual(lines, y.lines) { + return nil + } + y.lines = lines + y.fireResourceChanged(y.lines, y.filter(y.query, y.lines)) + + return nil +} + +// AddListener adds a new model listener. +func (y *YAML) AddListener(l ResourceViewerListener) { + y.listeners = append(y.listeners, l) +} + +// RemoveListener delete a listener from the list. +func (y *YAML) RemoveListener(l ResourceViewerListener) { + victim := -1 + for i, lis := range y.listeners { + if lis == l { + victim = i + break + } + } + + if victim >= 0 { + y.listeners = append(y.listeners[:victim], y.listeners[victim+1:]...) + } +} + +// ToYAML returns a resource yaml. +func (y *YAML) ToYAML(ctx context.Context, gvr client.GVR, path string, showManaged bool) (string, error) { + meta, err := getMeta(ctx, gvr) + if err != nil { + return "", err + } + + desc, ok := meta.DAO.(dao.Describer) + if !ok { + return "", fmt.Errorf("no describer for %q", meta.DAO.GVR()) + } + + return desc.ToYAML(path, showManaged) +} + +func getMeta(ctx context.Context, gvr client.GVR) (ResourceMeta, error) { + meta := resourceMeta(gvr) + factory, ok := ctx.Value(internal.KeyFactory).(dao.Factory) + if !ok { + return ResourceMeta{}, fmt.Errorf("expected Factory in context but got %T", ctx.Value(internal.KeyFactory)) + } + meta.DAO.Init(factory, gvr) + + return meta, nil +} + +func resourceMeta(gvr client.GVR) ResourceMeta { + meta, ok := Registry[gvr.String()] + if !ok { + meta = ResourceMeta{ + DAO: &dao.Table{}, + Renderer: &render.Generic{}, + } + } + if meta.DAO == nil { + meta.DAO = &dao.Resource{} + } + + return meta +} diff --git a/internal/view/alias_test.go b/internal/view/alias_test.go index ee2218bc..f3aee749 100644 --- a/internal/view/alias_test.go +++ b/internal/view/alias_test.go @@ -35,8 +35,7 @@ func TestAliasSearch(t *testing.T) { v.App().Prompt().SendStrokes("blee") assert.Equal(t, 3, v.GetTable().GetColumnCount()) - time.Sleep(1_000 * time.Millisecond) - assert.Equal(t, 2, v.GetTable().GetRowCount()) + assert.Equal(t, 3, v.GetTable().GetRowCount()) } func TestAliasGoto(t *testing.T) { diff --git a/internal/view/app.go b/internal/view/app.go index 94ae6f88..ff3f92d4 100644 --- a/internal/view/app.go +++ b/internal/view/app.go @@ -128,12 +128,15 @@ func (a *App) layout(ctx context.Context, version string) { main := tview.NewFlex().SetDirection(tview.FlexRow) main.AddItem(a.statusIndicator(), 1, 1, false) main.AddItem(a.Content, 0, 10, true) + if !a.Config.K9s.IsCrumbsless() { + main.AddItem(a.Crumbs(), 1, 1, false) + } main.AddItem(flash, 1, 1, false) a.Main.AddPage("main", main, true, false) a.Main.AddPage("splash", ui.NewSplash(a.Styles, version), true, true) - a.toggleHeader(!a.Config.K9s.GetHeadless()) - a.toggleCrumbs(!a.Config.K9s.GetCrumbsless()) + a.toggleHeader(!a.Config.K9s.IsHeadless()) + // a.toggleCrumbs(!a.Config.K9s.GetCrumbsless()) } func (a *App) initSignals() { @@ -182,8 +185,8 @@ func (a *App) keyboard(evt *tcell.EventKey) *tcell.EventKey { func (a *App) bindKeys() { a.AddActions(ui.KeyActions{ - tcell.KeyCtrlE: ui.NewSharedKeyAction("ToggleHeader", a.toggleHeaderCmd, false), - tcell.KeyCtrlT: ui.NewSharedKeyAction("toggleCrumbs", a.toggleCrumbsCmd, false), + ui.KeyShiftH: ui.NewSharedKeyAction("ToggleHeader", a.toggleHeaderCmd, false), + ui.KeyShiftC: ui.NewSharedKeyAction("toggleCrumbs", a.toggleCrumbsCmd, false), ui.KeyHelp: ui.NewSharedKeyAction("Help", a.helpCmd, false), tcell.KeyCtrlA: ui.NewSharedKeyAction("Aliases", a.aliasCmd, false), tcell.KeyEnter: ui.NewKeyAction("Goto", a.gotoCmd, false), @@ -217,7 +220,9 @@ func (a *App) toggleCrumbs(flag bool) { log.Fatal().Msg("Expecting valid flex view") } if a.showCrumbs { - flex.AddItemAtIndex(2, a.Crumbs(), 1, 1, false) + if _, ok := flex.ItemAt(2).(*ui.Crumbs); !ok { + flex.AddItemAtIndex(2, a.Crumbs(), 1, 1, false) + } } else { flex.RemoveItemAtIndex(2) } @@ -536,7 +541,7 @@ func (a *App) gotoCmd(evt *tcell.EventKey) *tcell.EventKey { } func (a *App) meowCmd(msg string) { - if err := a.inject(NewMeow(a, msg)); err != nil { + if err := a.inject(NewCow(a, msg)); err != nil { a.Flash().Err(err) } } @@ -599,7 +604,7 @@ func (a *App) gotoResource(cmd, path string, clearStack bool) error { return err } - c := NewMeow(a, err.Error()) + c := NewCow(a, err.Error()) _ = c.Init(context.Background()) if clearStack { a.Content.Stack.Clear() @@ -613,7 +618,7 @@ func (a *App) inject(c model.Component) error { ctx := context.WithValue(context.Background(), internal.KeyApp, a) if err := c.Init(ctx); err != nil { log.Error().Err(err).Msgf("component init failed for %q %v", c.Name(), err) - c = NewMeow(a, err.Error()) + c = NewCow(a, err.Error()) _ = c.Init(ctx) } a.Content.Push(c) diff --git a/internal/view/browser.go b/internal/view/browser.go index c984fb58..d8247b04 100644 --- a/internal/view/browser.go +++ b/internal/view/browser.go @@ -230,18 +230,10 @@ func (b *Browser) viewCmd(evt *tcell.EventKey) *tcell.EventKey { return evt } - ctx := b.defaultContext() - raw, err := b.GetModel().ToYAML(ctx, path) - if err != nil { - b.App().Flash().Errf("unable to get resource %q -- %s", b.GVR(), err) - return nil + v := NewLiveView(b.app, "YAML", model.NewYAML(b.GVR(), path)) + if err := v.app.inject(v); err != nil { + v.app.Flash().Err(err) } - - details := NewDetails(b.app, "YAML", path, true).Update(raw) - if err := b.App().inject(details); err != nil { - b.App().Flash().Err(err) - } - return nil } diff --git a/internal/view/command.go b/internal/view/command.go index a0236725..a80f4ce1 100644 --- a/internal/view/command.go +++ b/internal/view/command.go @@ -86,10 +86,10 @@ func (c *Command) xrayCmd(cmd string) error { } gvr, ok := c.alias.AsGVR(tokens[1]) if !ok { - return fmt.Errorf("Huh? `%s` command not found", cmd) + return fmt.Errorf("`%s` command not found", cmd) } if !allowedXRay(gvr) { - return fmt.Errorf("Huh? `%s` command not found", cmd) + return fmt.Errorf("`%s` command not found", cmd) } x := NewXray(gvr) @@ -139,7 +139,7 @@ func (c *Command) run(cmd, path string, clearStack bool) error { return err } if !c.alias.Check(cmds[0]) { - return fmt.Errorf("Huh? `%s` Command not found", cmd) + return fmt.Errorf("`%s` Command not found", cmd) } return c.exec(cmd, gvr, c.componentFor(gvr, path, v), clearStack) } @@ -210,7 +210,7 @@ func (c *Command) specialCmd(cmd, path string) bool { func (c *Command) viewMetaFor(cmd string) (string, *MetaViewer, error) { gvr, ok := c.alias.AsGVR(cmd) if !ok { - return "", nil, fmt.Errorf("Huh? `%s` command not found", cmd) + return "", nil, fmt.Errorf("`%s` command not found", cmd) } v, ok := customViewers[gvr] diff --git a/internal/view/cow.go b/internal/view/cow.go new file mode 100644 index 00000000..1d8979a9 --- /dev/null +++ b/internal/view/cow.go @@ -0,0 +1,137 @@ +package view + +import ( + "context" + "fmt" + "strings" + + "github.com/derailed/k9s/internal/config" + "github.com/derailed/k9s/internal/model" + "github.com/derailed/k9s/internal/ui" + "github.com/derailed/tview" + "github.com/gdamore/tcell" +) + +// Cow represents a bomb viewer +type Cow struct { + *tview.TextView + + actions ui.KeyActions + app *App + says string +} + +// NewCow returns a have a cow viewer. +func NewCow(app *App, says string) *Cow { + return &Cow{ + TextView: tview.NewTextView(), + app: app, + actions: make(ui.KeyActions), + says: says, + } +} + +// Init initializes the viewer. +func (c *Cow) Init(_ context.Context) error { + c.SetBorder(true) + c.SetScrollable(true).SetWrap(true).SetRegions(true) + c.SetDynamicColors(true) + c.SetHighlightColor(tcell.ColorOrange) + c.SetTitleColor(tcell.ColorAqua) + c.SetInputCapture(c.keyboard) + c.SetBorderPadding(0, 0, 1, 1) + c.updateTitle() + c.SetTextAlign(tview.AlignCenter) + + c.app.Styles.AddListener(c) + c.StylesChanged(c.app.Styles) + + c.bindKeys() + c.SetInputCapture(c.keyboard) + c.talk() + + return nil +} + +func (c *Cow) talk() { + says := c.says + if len(says) == 0 { + says = "Nothing to report here. Please move along..." + } + c.SetText(cowTalk(says)) +} + +func cowTalk(says string) string { + buff := make([]string, 0, len(cow)+3) + buff = append(buff, " "+strings.Repeat("─", len(says)+8)) + buff = append(buff, fmt.Sprintf("< [red::b]Ruroh? %s [-::-] >", says)) + buff = append(buff, " "+strings.Repeat("─", len(says)+8)) + spacer := strings.Repeat(" ", len(says)/2-8) + for _, s := range cow { + buff = append(buff, "[red::b]"+spacer+s) + } + return strings.Join(buff, "\n") +} + +func (c *Cow) bindKeys() { + c.actions.Set(ui.KeyActions{ + tcell.KeyEscape: ui.NewKeyAction("Back", c.resetCmd, false), + }) +} + +func (c *Cow) keyboard(evt *tcell.EventKey) *tcell.EventKey { + if a, ok := c.actions[ui.AsKey(evt)]; ok { + return a.Action(evt) + } + + return evt +} + +// StylesChanged notifies the skin changes. +func (c *Cow) StylesChanged(s *config.Styles) { + c.SetBackgroundColor(c.app.Styles.BgColor()) + c.SetTextColor(c.app.Styles.FgColor()) + c.SetBorderFocusColor(c.app.Styles.Frame().Border.FocusColor.Color()) +} + +func (c *Cow) resetCmd(evt *tcell.EventKey) *tcell.EventKey { + return c.app.PrevCmd(evt) +} + +// Actions returns menu actions +func (c *Cow) Actions() ui.KeyActions { + return c.actions +} + +// Name returns the component name. +func (c *Cow) Name() string { return "cow" } + +// Start starts the view updater. +func (c *Cow) Start() {} + +// Stop terminates the updater. +func (c *Cow) Stop() { + c.app.Styles.RemoveListener(c) +} + +// Hints returns menu hints. +func (c *Cow) Hints() model.MenuHints { + return c.actions.Hints() +} + +// ExtraHints returns additional hints. +func (c *Cow) ExtraHints() map[string]string { + return nil +} + +func (c *Cow) updateTitle() { + c.SetTitle(" Error ") +} + +var cow = []string{ + `\ ^__^ `, + ` \ (oo)\_______ `, + ` (__)\ )\/\`, + ` ||----w | `, + ` || || `, +} diff --git a/internal/view/helpers.go b/internal/view/helpers.go index fd18e8a9..5ee5f02f 100644 --- a/internal/view/helpers.go +++ b/internal/view/helpers.go @@ -10,6 +10,7 @@ import ( "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/config" + "github.com/derailed/k9s/internal/model" "github.com/derailed/k9s/internal/render" "github.com/derailed/k9s/internal/ui" "github.com/gdamore/tcell" @@ -59,18 +60,12 @@ func defaultEnv(c *client.Config, path string, header render.Header, row render. return env } -func describeResource(app *App, model ui.Tabular, gvr, path string) { +func describeResource(app *App, m ui.Tabular, gvr, path string) { ctx := context.Background() ctx = context.WithValue(ctx, internal.KeyFactory, app.factory) - yaml, err := model.Describe(ctx, path) - if err != nil { - app.Flash().Errf("Describe command failed: %s", err) - return - } - - details := NewDetails(app, "Describe", path, true).Update(yaml) - if err := app.inject(details); err != nil { + v := NewLiveView(app, "Describe", model.NewDescribe(client.NewGVR(gvr), path)) + if err := app.inject(v); err != nil { app.Flash().Err(err) } } diff --git a/internal/view/live_view.go b/internal/view/live_view.go new file mode 100644 index 00000000..d64c93e0 --- /dev/null +++ b/internal/view/live_view.go @@ -0,0 +1,311 @@ +package view + +import ( + "context" + "fmt" + "strconv" + "strings" + + "github.com/atotto/clipboard" + "github.com/derailed/k9s/internal" + "github.com/derailed/k9s/internal/config" + "github.com/derailed/k9s/internal/model" + "github.com/derailed/k9s/internal/ui" + "github.com/derailed/tview" + "github.com/gdamore/tcell" + "github.com/sahilm/fuzzy" +) + +const liveViewTitleFmt = "[fg:bg:b] %s([hilite:bg:b]%s[fg:bg:-])[fg:bg:-] " + +// LiveView represents a live text viewer. +type LiveView struct { + *tview.Flex + + text *tview.TextView + actions ui.KeyActions + app *App + title string + cmdBuff *model.FishBuff + model model.ResourceViewer + currentRegion, maxRegions int + fullScreen bool + cancel context.CancelFunc +} + +// NewLiveView returns a live viewer. +func NewLiveView(app *App, title string, m model.ResourceViewer) *LiveView { + v := LiveView{ + Flex: tview.NewFlex(), + text: tview.NewTextView(), + app: app, + title: title, + actions: make(ui.KeyActions), + currentRegion: 0, + maxRegions: 0, + cmdBuff: model.NewFishBuff('/', model.FilterBuffer), + model: m, + } + v.AddItem(v.text, 0, 1, true) + + return &v +} + +// Init initializes the viewer. +func (v *LiveView) Init(_ context.Context) error { + if v.title != "" { + v.SetBorder(true) + } + v.text.SetScrollable(true).SetWrap(true).SetRegions(true) + v.text.SetDynamicColors(true) + v.text.SetHighlightColor(tcell.ColorOrange) + v.SetTitleColor(tcell.ColorAqua) + v.SetInputCapture(v.keyboard) + v.SetBorderPadding(0, 0, 1, 1) + v.updateTitle() + + v.app.Styles.AddListener(v) + v.StylesChanged(v.app.Styles) + + v.app.Prompt().SetModel(v.cmdBuff) + v.cmdBuff.AddListener(v) + + v.bindKeys() + v.SetInputCapture(v.keyboard) + v.model.AddListener(v) + + return nil +} + +// ResourceFailed notifies when their is an issue. +func (v *LiveView) ResourceFailed(err error) { + v.text.SetTextAlign(tview.AlignCenter) + v.text.SetText(cowTalk(err.Error())) +} + +// ResourceChanged notifies when the filter changes. +func (v *LiveView) ResourceChanged(lines []string, matches fuzzy.Matches) { + v.app.QueueUpdateDraw(func() { + v.maxRegions = len(matches) + ll := make([]string, len(lines)) + copy(ll, lines) + for i, m := range matches { + loc, line := m.MatchedIndexes, ll[m.Index] + ll[m.Index] = line[:loc[0]] + `<<<"search_` + strconv.Itoa(i) + `">>>` + line[loc[0]:loc[1]] + `<<<"">>>` + line[loc[1]:] + } + + if v.maxRegions == 0 { + v.text.ScrollToBeginning() + } + v.text.SetText(colorizeYAML(v.app.Styles.Views().Yaml, strings.Join(ll, "\n"))) + v.text.Highlight() + if v.currentRegion < v.maxRegions { + v.text.Highlight("search_" + strconv.Itoa(v.currentRegion)) + v.text.ScrollToHighlight() + } + v.updateTitle() + }) +} + +// BufferChanged indicates the buffer was changed. +func (v *LiveView) BufferChanged(s string) {} + +// BufferCompleted indicates input was accepted. +func (v *LiveView) BufferCompleted(s string) { + v.model.Filter(s) +} + +// BufferActive indicates the buff activity changed. +func (v *LiveView) BufferActive(state bool, k model.BufferKind) { + v.app.BufferActive(state, k) +} + +func (v *LiveView) bindKeys() { + v.actions.Set(ui.KeyActions{ + tcell.KeyEnter: ui.NewSharedKeyAction("Filter", v.filterCmd, false), + tcell.KeyEscape: ui.NewKeyAction("Back", v.resetCmd, false), + tcell.KeyCtrlS: ui.NewKeyAction("Save", v.saveCmd, false), + ui.KeyC: ui.NewKeyAction("Copy", v.cpCmd, true), + ui.KeyF: ui.NewKeyAction("Toggle FullScreen", v.toggleFullScreenCmd, true), + ui.KeyN: ui.NewKeyAction("Next Match", v.nextCmd, true), + ui.KeyShiftN: ui.NewKeyAction("Prev Match", v.prevCmd, true), + ui.KeySlash: ui.NewSharedKeyAction("Filter Mode", v.activateCmd, false), + tcell.KeyDelete: ui.NewSharedKeyAction("Erase", v.eraseCmd, false), + }) +} + +func (v *LiveView) keyboard(evt *tcell.EventKey) *tcell.EventKey { + if a, ok := v.actions[ui.AsKey(evt)]; ok { + return a.Action(evt) + } + + return evt +} + +// StylesChanged notifies the skin changed. +func (v *LiveView) StylesChanged(s *config.Styles) { + v.SetBackgroundColor(v.app.Styles.BgColor()) + v.text.SetTextColor(v.app.Styles.FgColor()) + v.SetBorderFocusColor(v.app.Styles.Frame().Border.FocusColor.Color()) + v.ResourceChanged(v.model.Peek(), nil) +} + +// Actions returns menu actions +func (v *LiveView) Actions() ui.KeyActions { + return v.actions +} + +// Name returns the component name. +func (v *LiveView) Name() string { return v.title } + +// Start starts the view updater. +func (v *LiveView) Start() { + var ctx context.Context + ctx, v.cancel = context.WithCancel(context.Background()) + ctx = context.WithValue(ctx, internal.KeyFactory, v.app.factory) + + v.model.Watch(ctx) +} + +// Stop terminates the updater. +func (v *LiveView) Stop() { + if v.cancel != nil { + v.cancel() + v.cancel = nil + } + v.app.Styles.RemoveListener(v) +} + +// Hints returns menu hints. +func (v *LiveView) Hints() model.MenuHints { + return v.actions.Hints() +} + +// ExtraHints returns additional hints. +func (v *LiveView) ExtraHints() map[string]string { + return nil +} + +func (v *LiveView) toggleFullScreenCmd(evt *tcell.EventKey) *tcell.EventKey { + if v.app.InCmdMode() { + return evt + } + + v.fullScreen = !v.fullScreen + v.SetFullScreen(v.fullScreen) + v.Box.SetBorder(!v.fullScreen) + + return nil +} + +func (v *LiveView) nextCmd(evt *tcell.EventKey) *tcell.EventKey { + if v.cmdBuff.Empty() { + return evt + } + + v.currentRegion++ + if v.currentRegion >= v.maxRegions { + v.currentRegion = 0 + } + v.text.Highlight("search_" + strconv.Itoa(v.currentRegion)) + v.text.ScrollToHighlight() + v.updateTitle() + + return nil +} + +func (v *LiveView) prevCmd(evt *tcell.EventKey) *tcell.EventKey { + if v.cmdBuff.Empty() { + return evt + } + + v.currentRegion-- + if v.currentRegion < 0 { + v.currentRegion = v.maxRegions - 1 + } + v.text.Highlight("search_" + strconv.Itoa(v.currentRegion)) + v.text.ScrollToHighlight() + v.updateTitle() + + return nil +} + +func (v *LiveView) filterCmd(evt *tcell.EventKey) *tcell.EventKey { + v.model.Filter(v.cmdBuff.GetText()) + v.cmdBuff.SetActive(false) + v.updateTitle() + + return nil +} + +func (v *LiveView) activateCmd(evt *tcell.EventKey) *tcell.EventKey { + if v.app.InCmdMode() { + return evt + } + v.app.ResetPrompt(v.cmdBuff) + + return nil +} + +func (v *LiveView) eraseCmd(evt *tcell.EventKey) *tcell.EventKey { + if !v.cmdBuff.IsActive() { + return nil + } + v.cmdBuff.Delete() + + return nil +} + +func (v *LiveView) resetCmd(evt *tcell.EventKey) *tcell.EventKey { + if !v.cmdBuff.InCmdMode() { + v.cmdBuff.Reset() + return v.app.PrevCmd(evt) + } + + if v.cmdBuff.GetText() != "" { + v.model.ClearFilter() + } + v.cmdBuff.SetActive(false) + v.cmdBuff.Reset() + v.updateTitle() + + return nil +} + +func (v *LiveView) saveCmd(evt *tcell.EventKey) *tcell.EventKey { + if path, err := saveYAML(v.app.Config.K9s.CurrentCluster, v.title, v.text.GetText(true)); err != nil { + v.app.Flash().Err(err) + } else { + v.app.Flash().Infof("Log %s saved successfully!", path) + } + + return nil +} + +func (v *LiveView) cpCmd(evt *tcell.EventKey) *tcell.EventKey { + v.app.Flash().Info("Content copied to clipboard...") + if err := clipboard.WriteAll(v.text.GetText(true)); err != nil { + v.app.Flash().Err(err) + } + + return nil +} + +func (v *LiveView) updateTitle() { + if v.title == "" { + return + } + fmat := fmt.Sprintf(detailsTitleFmt, v.title, v.model.GetPath()) + + buff := v.cmdBuff.GetText() + if buff == "" { + v.SetTitle(ui.SkinTitle(fmat, v.app.Styles.Frame())) + return + } + + if v.maxRegions > 0 { + buff += fmt.Sprintf("[%d:%d]", v.currentRegion+1, v.maxRegions) + } + fmat += fmt.Sprintf(ui.SearchFmt, buff) + v.SetTitle(ui.SkinTitle(fmat, v.app.Styles.Frame())) +} diff --git a/internal/view/meow.go b/internal/view/meow.go deleted file mode 100644 index 11a22352..00000000 --- a/internal/view/meow.go +++ /dev/null @@ -1,133 +0,0 @@ -package view - -import ( - "context" - "fmt" - "strings" - - "github.com/derailed/k9s/internal/config" - "github.com/derailed/k9s/internal/model" - "github.com/derailed/k9s/internal/ui" - "github.com/derailed/tview" - "github.com/gdamore/tcell" -) - -// Meow represents a bomb viewer -type Meow struct { - *tview.TextView - - actions ui.KeyActions - app *App - says string -} - -// NewMeow returns a details viewer. -func NewMeow(app *App, says string) *Meow { - return &Meow{ - TextView: tview.NewTextView(), - app: app, - actions: make(ui.KeyActions), - says: says, - } -} - -// Init initializes the viewer. -func (m *Meow) Init(_ context.Context) error { - m.SetBorder(true) - m.SetScrollable(true).SetWrap(true).SetRegions(true) - m.SetDynamicColors(true) - m.SetHighlightColor(tcell.ColorOrange) - m.SetTitleColor(tcell.ColorAqua) - m.SetInputCapture(m.keyboard) - m.SetBorderPadding(0, 0, 1, 1) - m.updateTitle() - m.SetTextAlign(tview.AlignCenter) - - m.app.Styles.AddListener(m) - m.StylesChanged(m.app.Styles) - - m.bindKeys() - m.SetInputCapture(m.keyboard) - m.talk() - - return nil -} - -func (m *Meow) talk() { - says := m.says - if len(says) == 0 { - says = "Nothing to report here. Please move along..." - } - buff := make([]string, 0, len(cow)+3) - buff = append(buff, " "+strings.Repeat("─", len(says)+8)) - buff = append(buff, fmt.Sprintf("< [red::b]MEOW! %s [-::-] >", says)) - buff = append(buff, " "+strings.Repeat("─", len(says)+8)) - spacer := strings.Repeat(" ", len(says)/2-8) - for _, s := range cow { - buff = append(buff, spacer+s) - } - m.SetText(strings.Join(buff, "\n")) -} - -func (m *Meow) bindKeys() { - m.actions.Set(ui.KeyActions{ - tcell.KeyEscape: ui.NewKeyAction("Back", m.resetCmd, false), - }) -} - -func (m *Meow) keyboard(evt *tcell.EventKey) *tcell.EventKey { - if a, ok := m.actions[ui.AsKey(evt)]; ok { - return a.Action(evt) - } - - return evt -} - -// StylesChanged notifies the skin changes. -func (m *Meow) StylesChanged(s *config.Styles) { - m.SetBackgroundColor(m.app.Styles.BgColor()) - m.SetTextColor(m.app.Styles.FgColor()) - m.SetBorderFocusColor(m.app.Styles.Frame().Border.FocusColor.Color()) -} - -func (m *Meow) resetCmd(evt *tcell.EventKey) *tcell.EventKey { - return m.app.PrevCmd(evt) -} - -// Actions returns menu actions -func (m *Meow) Actions() ui.KeyActions { - return m.actions -} - -// Name returns the component name. -func (m *Meow) Name() string { return "cow" } - -// Start starts the view updater. -func (m *Meow) Start() {} - -// Stop terminates the updater. -func (m *Meow) Stop() { - m.app.Styles.RemoveListener(m) -} - -// Hints returns menu hints. -func (m *Meow) Hints() model.MenuHints { - return m.actions.Hints() -} - -// ExtraHints returns additional hints. -func (m *Meow) ExtraHints() map[string]string { - return nil -} - -func (m *Meow) updateTitle() { - m.SetTitle(" Error ") -} - -var cow = []string{ - `\ ^__^ `, - ` \ (oo)\_______ `, - ` (__)\ )\/\`, - ` ||----w | `, - ` || || `, -} diff --git a/internal/view/node.go b/internal/view/node.go index ee4c65ce..79a34359 100644 --- a/internal/view/node.go +++ b/internal/view/node.go @@ -184,7 +184,7 @@ func (n *Node) yamlCmd(evt *tcell.EventKey) *tcell.EventKey { return nil } - raw, err := dao.ToYAML(o) + raw, err := dao.ToYAML(o, false) if err != nil { n.App().Flash().Errf("Unable to marshal resource %s", err) return nil diff --git a/internal/view/table.go b/internal/view/table.go index c2fbc0f9..2f87bd7e 100644 --- a/internal/view/table.go +++ b/internal/view/table.go @@ -129,7 +129,9 @@ func (t *Table) SetExtraActionsFn(BoostActionsFunc) {} // BufferCompleted indicates input was accepted. func (t *Table) BufferCompleted(s string) { - t.Filter(s) + t.app.QueueUpdateDraw(func() { + t.Filter(s) + }) } // BufferChanged indicates the buffer was changed. diff --git a/internal/view/table_int_test.go b/internal/view/table_int_test.go index 6d5d6cba..3c68831a 100644 --- a/internal/view/table_int_test.go +++ b/internal/view/table_int_test.go @@ -69,7 +69,7 @@ func TestTableViewFilter(t *testing.T) { v.CmdBuff().SetActive(true) v.CmdBuff().SetText("blee") - assert.Equal(t, 2, v.GetRowCount()) + assert.Equal(t, 3, v.GetRowCount()) } func TestTableViewSort(t *testing.T) {