diff --git a/change_logs/release_0.4.2.md b/change_logs/release_0.4.2.md index 7f3c98b1..63458a65 100644 --- a/change_logs/release_0.4.2.md +++ b/change_logs/release_0.4.2.md @@ -26,7 +26,7 @@ Also if you dig this tool, please make some noise on social! [@kitesurfer](https ### o YAML Highlighter - Describe and YAML commands will now yield syntax highlighted views. + Describe and YAML commands will now yield syntax highlighted view. [Feature #142](https://github.com/derailed/k9s/issues/142) --- diff --git a/change_logs/release_0.6.7.md b/change_logs/release_0.6.7.md index 8ea5fd7b..33c482c9 100644 --- a/change_logs/release_0.6.7.md +++ b/change_logs/release_0.6.7.md @@ -20,7 +20,7 @@ This is a maintenance release to mainly resolve outstanding issues and bugs. ### Tracking Percentages -Added two new columns to track cpu/memory utilization on metrics-server enabled clusters. These apply to pod,container and node views. +Added two new columns to track cpu/memory utilization on metrics-server enabled clusters. These apply to pod,container and node view. --- diff --git a/change_logs/release_0.7.0.md b/change_logs/release_0.7.0.md index e431ccfb..ca15b270 100644 --- a/change_logs/release_0.7.0.md +++ b/change_logs/release_0.7.0.md @@ -36,7 +36,7 @@ This feature is still work in progress. It does require a new config file to hel This is one feature that I think is, pardon my french.., totally `Bitch'n`! K9s now bundles [Hey](https://github.com/rakyll/hey) CLI tool from the extremely talented Jaana Dogan of Google fame. Hey allows you to benchmark HTTP service endpoints similar to apache bench. -Benchmarking is enabled via keyboard shortcuts `Ctrl-B` and `Alt-B` to activate/cancel a benchmark while in PortForward and Service views. Benchmarking a service assumes the HTTP service is exposed either as NodePort or LoadBalancer. To view your benchmarks, navigate to the new Benchmark view aka `:be` to list your benchmarks and runs statistics. +Benchmarking is enabled via keyboard shortcuts `Ctrl-B` and `Alt-B` to activate/cancel a benchmark while in PortForward and Service view. Benchmarking a service assumes the HTTP service is exposed either as NodePort or LoadBalancer. To view your benchmarks, navigate to the new Benchmark view aka `:be` to list your benchmarks and runs statistics. So you now have the ability to stretch out your cluster legs by benchmarking your pods and services and gather all kind of interesting statistics directly in K9s. Generating load on your resources will help you tune your cluster resources, exercise your auto scalers, compare Canary builds perf, etc... diff --git a/change_logs/release_0.8.0.md b/change_logs/release_0.8.0.md index 8cdbdb09..a4f036b4 100644 --- a/change_logs/release_0.8.0.md +++ b/change_logs/release_0.8.0.md @@ -43,7 +43,7 @@ dialogs. This was totally a reasonable thing to do! However in case of managed p This one is cool! I think this thought came about from (Markus)[https://github.com/Makusi75]. Thank you Markus!! This feature allows K9s users to now customize K9s with their own plugin commands. You will be able to add a new menu shortcut to the K9s menu and fire off a custom command on a selected resource. Some of you might be leveraging kubectl plugins and now you will be able to fire these off directly from K9s along with many other shell commands. -In order to specify a custom plugin command, you will need to modify your .k9s/config.yml file. For example here is a sample extension to list out all the pods in the `fred` namespace while in a pod or deployment views. When this plugin is available a new command `` will show only while in pod and deploy views. +In order to specify a custom plugin command, you will need to modify your .k9s/config.yml file. For example here is a sample extension to list out all the pods in the `fred` namespace while in a pod or deployment view. When this plugin is available a new command `` will show only while in pod and deploy view. ```yaml plugins: diff --git a/change_logs/release_0.8.3.md b/change_logs/release_0.8.3.md index 3051dcaa..a77af23f 100644 --- a/change_logs/release_0.8.3.md +++ b/change_logs/release_0.8.3.md @@ -14,7 +14,7 @@ Also if you dig this tool, please make some noise on social! [@kitesurfer](https ### NetworkPolicy -NetworkPolicy resource is now available under the command `np` while in command mode. It behaves like other K9s resource views. You will get a bit more information in K9s vs `kubectl` as it includes information about ingress and egress rules. +NetworkPolicy resource is now available under the command `np` while in command mode. It behaves like other K9s resource view. You will get a bit more information in K9s vs `kubectl` as it includes information about ingress and egress rules. ### Arrrggg! New CLI Argument diff --git a/cmd/root.go b/cmd/root.go index 7c516181..56be6a6e 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -9,7 +9,7 @@ import ( "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/k8s" "github.com/derailed/k9s/internal/resource" - "github.com/derailed/k9s/internal/views" + "github.com/derailed/k9s/internal/view" "github.com/rs/zerolog" "github.com/rs/zerolog/log" "github.com/spf13/cobra" @@ -59,7 +59,7 @@ func Execute() { func run(cmd *cobra.Command, args []string) { defer func() { - // views.ClearScreen() + // view.ClearScreen() if err := recover(); err != nil { log.Error().Msgf("Boom! %v", err) log.Error().Msg(string(debug.Stack())) @@ -71,7 +71,7 @@ func run(cmd *cobra.Command, args []string) { zerolog.SetGlobalLevel(parseLevel(*k9sFlags.LogLevel)) cfg := loadConfiguration() - app := views.NewApp(cfg) + app := view.NewApp(cfg) { defer app.BailOut() app.Init(version, *k9sFlags.RefreshRate) diff --git a/go.sum b/go.sum index 71bb79d7..74cebae3 100644 --- a/go.sum +++ b/go.sum @@ -218,6 +218,7 @@ github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnIn github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.2.0 h1:Hbg2NidpLE8veEBkEZTL3CvlkUIVzuU9jDplZO54c48= github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= github.com/stretchr/testify v0.0.0-20151208002404-e3a8ff8ce365/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= diff --git a/internal/config/test_assets/bench-fred.yml b/internal/config/test_assets/bench-fred.yml new file mode 100644 index 00000000..d418453c --- /dev/null +++ b/internal/config/test_assets/bench-fred.yml @@ -0,0 +1,41 @@ +benchmarks: + defaults: + concurrency: 2 + requests: 1000 + services: + default/nginx: + concurrency: 2 + requests: 1000 + http: + method: GET + http2: true + host: 10.10.10.10 + path: / + body: |- + {"fred": "blee"} + headers: + Accept: + - text/html + Content-Type: + - application/json + auth: + user: "fred" + password: "blee" + blee/fred: + concurrency: 10 + requests: 1500 + http: + method: POST + http2: false + host: 20.20.20.20 + path: /zorg + body: |- + {"fred": "blee"} + headers: + Accept: + - text/html + Content-Type: + - application/json + auth: + user: "fred" + password: "blee" diff --git a/internal/model/hint.go b/internal/model/hint.go new file mode 100644 index 00000000..84a5ecdd --- /dev/null +++ b/internal/model/hint.go @@ -0,0 +1,54 @@ +package model + +// HintListener represents a menu hints listener. +type HintListener interface { + HintsChanged(MenuHints) +} + +// Hint represent a hint model. +type Hint struct { + data MenuHints + listeners []HintListener +} + +// NewHint return new hint model. +func NewHint() *Hint { + return &Hint{} +} + +// RemoveListener deletes a listener. +func (h *Hint) RemoveListener(l HintListener) { + victim := -1 + for i, lis := range h.listeners { + if lis == l { + victim = i + break + } + } + if victim == -1 { + return + } + h.listeners = append(h.listeners[:victim], h.listeners[victim+1:]...) +} + +// AddListener adds a hint listener. +func (h *Hint) AddListener(l HintListener) { + h.listeners = append(h.listeners, l) +} + +// SetHints set model hints. +func (h *Hint) SetHints(hh MenuHints) { + h.data = hh + h.fireChanged() +} + +// Peek returns the model data. +func (h *Hint) Peek() MenuHints { + return h.data +} + +func (h *Hint) fireChanged() { + for _, l := range h.listeners { + l.HintsChanged(h.data) + } +} diff --git a/internal/model/hint_test.go b/internal/model/hint_test.go new file mode 100644 index 00000000..b26cfe74 --- /dev/null +++ b/internal/model/hint_test.go @@ -0,0 +1,67 @@ +package model_test + +import ( + "testing" + + "github.com/derailed/k9s/internal/model" + "github.com/stretchr/testify/assert" +) + +func TestHints(t *testing.T) { + uu := map[string]struct { + hh model.MenuHints + e int + }{ + "none": { + model.MenuHints{}, + 0, + }, + "hints": { + model.MenuHints{ + {Mnemonic: "a", Description: "blee"}, + {Mnemonic: "b", Description: "fred"}, + }, + 2, + }, + } + + for k, u := range uu { + t.Run(k, func(t *testing.T) { + h := model.NewHint() + l := hintL{count: -1} + h.AddListener(&l) + h.SetHints(u.hh) + + assert.Equal(t, u.e, l.count) + }) + } +} + +func TestHintRemoveListener(t *testing.T) { + h := model.NewHint() + l1, l2, l3 := &hintL{}, &hintL{}, &hintL{} + h.AddListener(l1) + h.AddListener(l2) + h.AddListener(l3) + + h.RemoveListener(l2) + h.RemoveListener(l3) + h.RemoveListener(l1) + + h.SetHints(model.MenuHints{ + model.MenuHint{Mnemonic: "a", Description: "Blee"}, + }) + + assert.Equal(t, 0, l1.count) + assert.Equal(t, 0, l2.count) + assert.Equal(t, 0, l3.count) +} + +type hintL struct { + count int +} + +func (h *hintL) HintsChanged(hh model.MenuHints) { + h.count++ + h.count += len(hh) +} diff --git a/internal/ui/hint.go b/internal/model/menu_hint.go similarity index 50% rename from internal/ui/hint.go rename to internal/model/menu_hint.go index 1845dffa..c2341aae 100644 --- a/internal/ui/hint.go +++ b/internal/model/menu_hint.go @@ -1,35 +1,29 @@ -package ui +package model import ( "strconv" "strings" ) -type ( - // Hint represents keyboard mnemonic. - Hint struct { - Mnemonic string - Description string - Visible bool - } - // Hints a collection of keyboard mnemonics. - Hints []Hint +// MenuHint represents keyboard mnemonic. +type MenuHint struct { + Mnemonic string + Description string + Visible bool +} - // Hinter returns a collection of mnemonics. - Hinter interface { - Hints() Hints - } -) +// MenuHints represents a collection of hints. +type MenuHints []MenuHint -func (h Hints) Len() int { +func (h MenuHints) Len() int { return len(h) } -func (h Hints) Swap(i, j int) { +func (h MenuHints) Swap(i, j int) { h[i], h[j] = h[j], h[i] } -func (h Hints) Less(i, j int) bool { +func (h MenuHints) Less(i, j int) bool { n, err1 := strconv.Atoi(h[i].Mnemonic) m, err2 := strconv.Atoi(h[j].Mnemonic) if err1 == nil && err2 == nil { diff --git a/internal/model/menu_hint_test.go b/internal/model/menu_hint_test.go new file mode 100644 index 00000000..14c1c0d6 --- /dev/null +++ b/internal/model/menu_hint_test.go @@ -0,0 +1,22 @@ +package model_test + +import ( + "sort" + "testing" + + "github.com/derailed/k9s/internal/model" + "github.com/stretchr/testify/assert" +) + +func TestMenuHintOrder(t *testing.T) { + h1 := model.MenuHint{Mnemonic: "b", Description: "Duh"} + h2 := model.MenuHint{Mnemonic: "a", Description: "Blee"} + h3 := model.MenuHint{Mnemonic: "1", Description: "Zorg"} + + hh := model.MenuHints{h1, h2, h3} + sort.Sort(hh) + + assert.Equal(t, h3, hh[0]) + assert.Equal(t, h2, hh[1]) + assert.Equal(t, h1, hh[2]) +} diff --git a/internal/model/stack.go b/internal/model/stack.go new file mode 100644 index 00000000..ae36a97c --- /dev/null +++ b/internal/model/stack.go @@ -0,0 +1,156 @@ +package model + +import ( + "github.com/rs/zerolog/log" +) + +const ( + // StackPush denotes an add on the stack. + StackPush StackAction = 1 << iota + + // StackPop denotes a delete on the stack. + StackPop +) + +// StackAction represents an action on the stack. +type StackAction int + +// StackEvent represents an operation on a view stack. +type StackEvent struct { + // Kind represents the event condition. + Action StackAction + + // Item represents the targetted item. + Component Component +} + +// StackListener represents a stack listener. +type StackListener interface { + // StackPushed indicates a new item was added. + StackPushed(Component) + + // StackPopped indicates an item was deleted + StackPopped(old, new Component) + + // StackTop indicates the top of the stack + StackTop(Component) +} + +// Stack represents a stacks of items. +type Stack struct { + components []Component + listeners []StackListener +} + +// NewStack returns a new initialized stack. +func NewStack() *Stack { + return &Stack{} +} + +// Flatten retuns a string representation of the component stack. +func (s *Stack) Flatten() []string { + ss := make([]string, len(s.components)) + for i, c := range s.components { + ss[i] = c.Name() + } + return ss +} + +// RemoveListener removes a listener. +func (s *Stack) RemoveListener(l StackListener) { + victim := -1 + for i, lis := range s.listeners { + if lis == l { + victim = i + break + } + } + if victim == -1 { + return + } + s.listeners = append(s.listeners[:victim], s.listeners[victim+1:]...) +} + +// AddListener registers a stack listener. +func (s *Stack) AddListener(l StackListener) { + s.listeners = append(s.listeners, l) + log.Debug().Msgf("Stack Add listener %#v", s.components) + s.DumpStack() + if s.Empty() { + log.Debug().Msgf("Stack is empty!") + } else { + log.Debug().Msgf("TOP is %s", s.Top().Name()) + } + l.StackTop(s.Top()) +} + +// Dump prints out the stack. +func (s *Stack) DumpStack() { + log.Debug().Msgf("--- Stack Dump %p---", s) + for i, c := range s.components { + log.Debug().Msgf("%d -- %s -- %#v", i, c.Name(), c) + } + log.Debug().Msg("------------------") +} + +// Push adds a new item. +func (s *Stack) Push(c Component) { + if top := s.Top(); top != nil { + top.Stop() + } + s.components = append(s.components, c) + s.notify(StackPush, c) +} + +// Pop removed the top item and returns it. +func (s *Stack) Pop() (Component, bool) { + if s.Empty() { + return nil, false + } + + c := s.components[s.size()] + s.components = s.components[:s.size()] + s.notify(StackPop, c) + c.Stop() + + if top := s.Top(); top != nil { + log.Debug().Msgf("Calling Restart on %s", top.Name()) + top.Start() + } + + return c, true +} + +// Empty returns true if the stack is empty. +func (s *Stack) Empty() bool { + return len(s.components) == 0 +} + +// IsLast indicates if stack only has one item left. +func (s *Stack) IsLast() bool { + return len(s.components) == 1 +} + +// Top returns the top most item or nil if the stack is empty. +func (s *Stack) Top() Component { + if s.Empty() { + return nil + } + + return s.components[s.size()] +} + +func (s *Stack) size() int { + return len(s.components) - 1 +} + +func (s *Stack) notify(a StackAction, c Component) { + for _, l := range s.listeners { + switch a { + case StackPush: + l.StackPushed(c) + case StackPop: + l.StackPopped(c, s.Top()) + } + } +} diff --git a/internal/model/stack_test.go b/internal/model/stack_test.go new file mode 100644 index 00000000..2b7f61fe --- /dev/null +++ b/internal/model/stack_test.go @@ -0,0 +1,151 @@ +package model_test + +import ( + "context" + "testing" + + "github.com/derailed/k9s/internal/model" + "github.com/derailed/tview" + "github.com/gdamore/tcell" + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" +) + +func init() { + zerolog.SetGlobalLevel(zerolog.FatalLevel) +} + +func TestStackPush(t *testing.T) { + top := c{} + uu := map[string]struct { + items []model.Component + pop int + e bool + top model.Component + }{ + "empty": { + items: []model.Component{}, + pop: 3, + e: true, + }, + "full": { + items: []model.Component{c{}, c{}, top}, + pop: 3, + e: true, + }, + "pop": { + items: []model.Component{c{}, c{}, top}, + pop: 2, + e: false, + top: top, + }, + } + + for k, u := range uu { + t.Run(k, func(t *testing.T) { + s := model.NewStack() + for _, c := range u.items { + s.Push(c) + } + for i := 0; i < u.pop; i++ { + s.Pop() + } + assert.Equal(t, u.e, s.Empty()) + if !u.e { + assert.Equal(t, u.top, s.Top()) + } + }) + } +} + +func TestStackTop(t *testing.T) { + top := c{} + uu := map[string]struct { + items []model.Component + e model.Component + }{ + "blank": { + items: []model.Component{}, + }, + "push3": { + items: []model.Component{c{}, c{}, top}, + e: top, + }, + "push1": { + items: []model.Component{top}, + e: top, + }, + } + + for k, u := range uu { + t.Run(k, func(t *testing.T) { + s := model.NewStack() + for _, item := range u.items { + s.Push(item) + } + v := s.Top() + assert.Equal(t, u.e, v) + }) + } +} + +func TestStackListener(t *testing.T) { + items := []model.Component{c{}, c{}, c{}} + s := model.NewStack() + l := stackL{} + s.AddListener(&l) + for _, item := range items { + s.Push(item) + } + assert.Equal(t, 3, l.count) + + for range items { + s.Pop() + } + assert.Equal(t, 0, l.count) +} + +func TestStackRemoveListener(t *testing.T) { + s := model.NewStack() + l1, l2, l3 := &stackL{}, &stackL{}, &stackL{} + s.AddListener(l1) + s.AddListener(l2) + s.AddListener(l3) + + s.RemoveListener(l2) + s.RemoveListener(l3) + s.RemoveListener(l1) + + s.Push(c{}) + + assert.Equal(t, 0, l1.count) + assert.Equal(t, 0, l2.count) + assert.Equal(t, 0, l3.count) +} + +type stackL struct { + count int +} + +func (s *stackL) StackPushed(model.Component) { + s.count++ +} +func (s *stackL) StackPopped(c, top model.Component) { + s.count-- +} +func (s *stackL) StackTop(model.Component) {} + +type c struct{} + +func (c c) Name() string { return "test" } +func (c c) Hints() model.MenuHints { return nil } +func (c c) Draw(tcell.Screen) {} +func (c c) InputHandler() func(*tcell.EventKey, func(tview.Primitive)) { return nil } +func (c c) SetRect(int, int, int, int) {} +func (c c) GetRect() (int, int, int, int) { return 0, 0, 0, 0 } +func (c c) GetFocusable() tview.Focusable { return nil } +func (c c) Focus(func(tview.Primitive)) {} +func (c c) Blur() {} +func (c c) Start() {} +func (c c) Stop() {} +func (c c) Init(context.Context) {} diff --git a/internal/model/table.go b/internal/model/table.go deleted file mode 100644 index 47fe1a2f..00000000 --- a/internal/model/table.go +++ /dev/null @@ -1,37 +0,0 @@ -package model - -import ( - "github.com/derailed/k9s/internal/resource" -) - -// TableListener tracks tabular data changes. -type TableListener interface { - Refreshed(resource.TableData) - RowAdded(resource.RowEvent) - RowUpdated(resource.RowEvent) - RowDeleted(resource.RowEvent) -} - -// Table represents tabular data. -type Table struct { - data resource.TableData - - listeners []TableListener -} - -// NewTable returns a new table. -func NewTable() *Table { - return &Table{} -} - -// Load the initial tabular data -func (t *Table) Load(data resource.TableData) { - t.data = data - t.fireTableRefreshed() -} - -func (t *Table) fireTableRefreshed() { - for _, l := range t.listeners { - l.Refreshed(t.data) - } -} diff --git a/internal/model/types.go b/internal/model/types.go new file mode 100644 index 00000000..312d2ddf --- /dev/null +++ b/internal/model/types.go @@ -0,0 +1,39 @@ +package model + +import ( + "context" + + "github.com/derailed/tview" +) + +// Igniter represents a runnable view. +type Igniter interface { + // Start starts a component. + Init(ctx context.Context) + + // Start starts a component. + Start() + + // Stop terminates a component. + Stop() +} + +// Hinter represent a menu mnemonic provider. +type Hinter interface { + Hints() MenuHints +} + +// Primitive represents a UI primitive. +type Primitive interface { + tview.Primitive + + // Name returns the view name. + Name() string +} + +// Component represents a ui component +type Component interface { + Primitive + Igniter + Hinter +} diff --git a/internal/ui/action.go b/internal/ui/action.go index ef865e7a..78901587 100644 --- a/internal/ui/action.go +++ b/internal/ui/action.go @@ -3,6 +3,7 @@ package ui import ( "sort" + "github.com/derailed/k9s/internal/model" "github.com/gdamore/tcell" "github.com/rs/zerolog/log" ) @@ -28,18 +29,18 @@ func NewKeyAction(d string, a ActionHandler, display bool) KeyAction { } // Hints returns a collection of hints. -func (a KeyActions) Hints() Hints { +func (a KeyActions) Hints() model.MenuHints { kk := make([]int, 0, len(a)) for k := range a { kk = append(kk, int(k)) } sort.Ints(kk) - hh := make(Hints, 0, len(kk)) + hh := make(model.MenuHints, 0, len(kk)) for _, k := range kk { if name, ok := tcell.KeyNames[tcell.Key(k)]; ok { hh = append(hh, - Hint{ + model.MenuHint{ Mnemonic: name, Description: a[tcell.Key(k)].Description, Visible: a[tcell.Key(k)].Visible, diff --git a/internal/ui/action_test.go b/internal/ui/action_test.go new file mode 100644 index 00000000..01007753 --- /dev/null +++ b/internal/ui/action_test.go @@ -0,0 +1,22 @@ +package ui_test + +import ( + "testing" + + "github.com/derailed/k9s/internal/model" + "github.com/derailed/k9s/internal/ui" + "github.com/stretchr/testify/assert" +) + +func TestKeyActionsHints(t *testing.T) { + kk := ui.KeyActions{ + ui.KeyF: ui.NewKeyAction("fred", nil, true), + ui.KeyB: ui.NewKeyAction("blee", nil, true), + ui.KeyZ: ui.NewKeyAction("zorg", nil, false), + } + + hh := kk.Hints() + + assert.Equal(t, 3, len(hh)) + assert.Equal(t, model.MenuHint{"b", "blee", true}, hh[0]) +} diff --git a/internal/ui/app.go b/internal/ui/app.go index fd19f13f..c2cfc771 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -1,87 +1,47 @@ package ui import ( - "context" - - "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/k8s" + "github.com/derailed/k9s/internal/model" "github.com/derailed/tview" "github.com/gdamore/tcell" ) -// Igniter represents an initializable view. -type Igniter interface { - tview.Primitive +// App represents an application. +type App struct { + *tview.Application + Configurator - // Init initializes the view. - Init(ctx context.Context, ns string) + Main *Pages + Hint *model.Hint + + actions KeyActions + + views map[string]tview.Primitive + cmdBuff *CmdBuff } -type ( - keyHandler interface { - keyboard(evt *tcell.EventKey) *tcell.EventKey - } - - // ActionsFunc augments Keybindings. - ActionsFunc func(KeyActions) - - // Configurator represents an application configurations. - Configurator struct { - HasSkins bool - Config *config.Config - Styles *config.Styles - Bench *config.Bench - } - - // App represents an application. - App struct { - *tview.Application - Configurator - - actions KeyActions - pages *tview.Pages - content *tview.Pages - views map[string]tview.Primitive - cmdBuff *CmdBuff - hints Hints - } -) - // NewApp returns a new app. func NewApp() *App { - s := App{ + a := App{ Application: tview.NewApplication(), actions: make(KeyActions), - pages: tview.NewPages(), - content: tview.NewPages(), + Main: NewPages(), cmdBuff: NewCmdBuff(':', CommandBuff), + Hint: model.NewHint(), } - s.RefreshStyles() + a.RefreshStyles() - s.views = map[string]tview.Primitive{ - "menu": NewMenuView(s.Styles), - "logo": NewLogoView(s.Styles), - "cmd": NewCmdView(s.Styles), - "crumbs": NewCrumbsView(s.Styles), + a.views = map[string]tview.Primitive{ + "menu": NewMenu(a.Styles), + "logo": NewLogoView(a.Styles), + "cmd": NewCmdView(a.Styles), + "flash": NewFlashView(&a, "Initializing..."), + "crumbs": NewCrumbs(a.Styles), } - return &s -} - -// Main returns main app frame. -func (a *App) Main() *tview.Pages { - return a.pages -} - -// Frame returns main app content frame. -func (a *App) Frame() *tview.Pages { - return a.content -} - -// Conn returns an api server connection. -func (a *App) Conn() k8s.Connection { - return a.Config.GetConnection() + return &a } // Init initializes the application. @@ -89,7 +49,14 @@ func (a *App) Init() { a.bindKeys() a.SetInputCapture(a.keyboard) a.cmdBuff.AddListener(a.Cmd()) - a.SetRoot(a.pages, true) + a.SetRoot(a.Main, true) + + a.Hint.AddListener(a.Menu()) +} + +// Conn returns an api server connection. +func (a *App) Conn() k8s.Connection { + return a.Config.GetConnection() } func (a *App) bindKeys() { @@ -148,19 +115,19 @@ func (a *App) InCmdMode() bool { return a.Cmd().InCmdMode() } -// GetActions returns a collection of actions. +// GetActions returns a collection of actiona. func (a *App) GetActions() KeyActions { return a.actions } -// AddActions returns the application actions. +// AddActions returns the application actiona. func (a *App) AddActions(aa KeyActions) { for k, v := range aa { a.actions[k] = v } } -// Views return the application root views. +// Views return the application root viewa. func (a *App) Views() map[string]tview.Primitive { return a.views } @@ -215,33 +182,17 @@ func (a *App) redrawCmd(evt *tcell.EventKey) *tcell.EventKey { return evt } -// ActiveView returns the currently active view. -func (a *App) ActiveView() Igniter { - return a.content.GetPrimitive("main").(Igniter) -} - -// SetHints updates menu hints. -func (a *App) SetHints(h Hints) { - a.hints = h - a.views["menu"].(*MenuView).HydrateMenu(h) -} - -// GetHints retrieves the currently active hints. -func (a *App) GetHints() Hints { - return a.hints -} - // StatusReset reset log back to normal. func (a *App) StatusReset() { a.Logo().Reset() a.Draw() } -// View Accessors... +// View Accessora... -// Crumbs return app crumbs. -func (a *App) Crumbs() *CrumbsView { - return a.views["crumbs"].(*CrumbsView) +// Crumbs return app crumba. +func (a *App) Crumbs() *Crumbs { + return a.views["crumbs"].(*Crumbs) } // Logo return the app logo. @@ -260,8 +211,8 @@ func (a *App) Cmd() *CmdView { } // Menu returns app menu. -func (a *App) Menu() *MenuView { - return a.views["menu"].(*MenuView) +func (a *App) Menu() *Menu { + return a.views["menu"].(*Menu) } // AsKey converts rune to keyboard key., diff --git a/internal/ui/app_test.go b/internal/ui/app_test.go new file mode 100644 index 00000000..c73e45ba --- /dev/null +++ b/internal/ui/app_test.go @@ -0,0 +1,73 @@ +package ui_test + +import ( + "testing" + + "github.com/derailed/k9s/internal/ui" + "github.com/stretchr/testify/assert" +) + +func TestAppGetCmd(t *testing.T) { + a := ui.NewApp() + a.Init() + a.CmdBuff().Set("blee") + + assert.Equal(t, "blee", a.GetCmd()) +} + +func TestAppInCmdMode(t *testing.T) { + a := ui.NewApp() + a.Init() + a.CmdBuff().Set("blee") + assert.False(t, a.InCmdMode()) + + a.CmdBuff().SetActive(true) + assert.True(t, a.InCmdMode()) +} + +func TestAppResetCmd(t *testing.T) { + a := ui.NewApp() + a.Init() + a.CmdBuff().Set("blee") + + a.ResetCmd() + + assert.Equal(t, "", a.CmdBuff().String()) +} + +func TestAppHasCmd(t *testing.T) { + a := ui.NewApp() + a.Init() + + a.ActivateCmd(true) + assert.False(t, a.HasCmd()) + + a.CmdBuff().Set("blee") + assert.True(t, a.InCmdMode()) +} + +func TestAppGetActions(t *testing.T) { + a := ui.NewApp() + a.Init() + + a.AddActions(ui.KeyActions{ui.KeyZ: ui.KeyAction{Description: "zorg"}}) + + assert.Equal(t, 8, len(a.GetActions())) +} + +func TestAppViews(t *testing.T) { + a := ui.NewApp() + a.Init() + + for _, v := range []string{"crumbs", "logo", "cmd", "flash", "menu"} { + t.Run(v, func(t *testing.T) { + assert.NotNil(t, a.Views()[v]) + }) + } + + assert.NotNil(t, a.Crumbs()) + assert.NotNil(t, a.Flash()) + assert.NotNil(t, a.Logo()) + assert.NotNil(t, a.Cmd()) + assert.NotNil(t, a.Menu()) +} diff --git a/internal/ui/cmd.go b/internal/ui/cmd.go index 477731f3..9f0c92c2 100644 --- a/internal/ui/cmd.go +++ b/internal/ui/cmd.go @@ -23,16 +23,14 @@ type CmdView struct { // NewCmdView returns a new command view. func NewCmdView(styles *config.Styles) *CmdView { v := CmdView{styles: styles, TextView: tview.NewTextView()} - { - v.SetWordWrap(true) - v.SetWrap(true) - v.SetDynamicColors(true) - v.SetBorder(true) - v.SetBorderPadding(0, 0, 1, 1) - v.SetBackgroundColor(styles.BgColor()) - // v.SetBorderColor(config.AsColor(styles.Frame().Border.FocusColor)) - v.SetTextColor(styles.FgColor()) - } + v.SetWordWrap(true) + v.SetWrap(true) + v.SetDynamicColors(true) + v.SetBorder(true) + v.SetBorderPadding(0, 0, 1, 1) + v.SetBackgroundColor(styles.BgColor()) + v.SetTextColor(styles.FgColor()) + return &v } @@ -52,7 +50,7 @@ func (v *CmdView) update(s string) { } func (v *CmdView) append(r rune) { - fmt.Fprintf(v, string(r)) + fmt.Fprintf(v, "%s", string(r)) } func (v *CmdView) write(s string) { diff --git a/internal/ui/cmd_buff_test.go b/internal/ui/cmd_buff_test.go index e370c4a1..182f6109 100644 --- a/internal/ui/cmd_buff_test.go +++ b/internal/ui/cmd_buff_test.go @@ -1,8 +1,9 @@ -package ui +package ui_test import ( "testing" + "github.com/derailed/k9s/internal/ui" "github.com/stretchr/testify/assert" ) @@ -16,7 +17,7 @@ func (l *testListener) BufferChanged(s string) { l.text = s } -func (l *testListener) BufferActive(s bool, _ BufferKind) { +func (l *testListener) BufferActive(s bool, _ ui.BufferKind) { if s { l.act++ return @@ -25,27 +26,27 @@ func (l *testListener) BufferActive(s bool, _ BufferKind) { } func TestCmdBuffActivate(t *testing.T) { - b, l := NewCmdBuff('>', CommandBuff), testListener{} + b, l := ui.NewCmdBuff('>', ui.CommandBuff), testListener{} b.AddListener(&l) b.SetActive(true) assert.Equal(t, 1, l.act) assert.Equal(t, 0, l.inact) - assert.True(t, b.active) + assert.True(t, b.IsActive()) } func TestCmdBuffDeactivate(t *testing.T) { - b, l := NewCmdBuff('>', CommandBuff), testListener{} + b, l := ui.NewCmdBuff('>', ui.CommandBuff), testListener{} b.AddListener(&l) b.SetActive(false) assert.Equal(t, 0, l.act) assert.Equal(t, 1, l.inact) - assert.False(t, b.active) + assert.False(t, b.IsActive()) } func TestCmdBuffChanged(t *testing.T) { - b, l := NewCmdBuff('>', CommandBuff), testListener{} + b, l := ui.NewCmdBuff('>', ui.CommandBuff), testListener{} b.AddListener(&l) b.Add('b') @@ -77,7 +78,7 @@ func TestCmdBuffChanged(t *testing.T) { } func TestCmdBuffAdd(t *testing.T) { - b := NewCmdBuff('>', CommandBuff) + b := ui.NewCmdBuff('>', ui.CommandBuff) uu := []struct { runes []rune @@ -98,7 +99,7 @@ func TestCmdBuffAdd(t *testing.T) { } func TestCmdBuffDel(t *testing.T) { - b := NewCmdBuff('>', CommandBuff) + b := ui.NewCmdBuff('>', ui.CommandBuff) uu := []struct { runes []rune @@ -120,7 +121,7 @@ func TestCmdBuffDel(t *testing.T) { } func TestCmdBuffEmpty(t *testing.T) { - b := NewCmdBuff('>', CommandBuff) + b := ui.NewCmdBuff('>', ui.CommandBuff) uu := []struct { runes []rune diff --git a/internal/ui/cmd_stack.go b/internal/ui/cmd_stack.go deleted file mode 100644 index 94407bd9..00000000 --- a/internal/ui/cmd_stack.go +++ /dev/null @@ -1,58 +0,0 @@ -package ui - -const maxStackSize = 10 - -// CmdStack tracks users command breadcrumbs. -type CmdStack struct { - index int - stack []string -} - -// NewCmdStack returns a new cmd stack. -func NewCmdStack() *CmdStack { - return &CmdStack{stack: make([]string, 0, maxStackSize)} -} - -// Items returns current stack content. -func (s *CmdStack) Items() []string { - return s.stack -} - -// Push adds a new item, -func (s *CmdStack) Push(cmd string) { - if len(s.stack) == maxStackSize { - s.stack = s.stack[1 : len(s.stack)-1] - } - s.stack = append(s.stack, cmd) -} - -// Pop delete an item. -func (s *CmdStack) Pop() (string, bool) { - if s.Empty() { - return "", false - } - - top := s.stack[len(s.stack)-1] - s.stack = s.stack[:len(s.stack)-1] - - return top, true -} - -// Top return top element. -func (s *CmdStack) Top() (string, bool) { - if s.Empty() { - return "", false - } - - return s.stack[len(s.stack)-1], true -} - -// Empty check if stack is empty. -func (s *CmdStack) Empty() bool { - return len(s.stack) == 0 -} - -// Last returns the last command. -func (s *CmdStack) Last() bool { - return len(s.stack) == 1 -} diff --git a/internal/ui/cmd_stack_test.go b/internal/ui/cmd_stack_test.go deleted file mode 100644 index 11492497..00000000 --- a/internal/ui/cmd_stack_test.go +++ /dev/null @@ -1,76 +0,0 @@ -package ui - -import ( - "fmt" - "testing" - - "github.com/rs/zerolog" - "github.com/stretchr/testify/assert" -) - -func init() { - zerolog.SetGlobalLevel(zerolog.FatalLevel) -} - -func TestCmdStackPushMax(t *testing.T) { - s := NewCmdStack() - for i := 0; i < 20; i++ { - s.Push(fmt.Sprintf("cmd_%d", i)) - } - top, ok := s.Top() - assert.True(t, ok) - assert.Equal(t, "cmd_19", top) -} - -func TestCmdStackPop(t *testing.T) { - type expect struct { - val string - ok bool - } - - uu := []struct { - cmds []string - popCount int - e expect - }{ - {[]string{}, 2, expect{"", false}}, - {[]string{"a", "b", "c"}, 2, expect{"a", true}}, - {[]string{"a", "b", "c"}, 1, expect{"b", true}}, - } - - for _, u := range uu { - s := NewCmdStack() - for _, v := range u.cmds { - s.Push(v) - } - for i := 0; i < u.popCount; i++ { - s.Pop() - } - top, ok := s.Pop() - assert.Equal(t, u.e.ok, ok) - assert.Equal(t, u.e.val, top) - } -} - -func TestCmdStackEmpty(t *testing.T) { - uu := []struct { - cmds []string - popCount int - e bool - }{ - {[]string{}, 0, true}, - {[]string{"a", "b", "c"}, 0, false}, - {[]string{"a", "b", "c"}, 3, true}, - } - - for _, u := range uu { - s := NewCmdStack() - for _, v := range u.cmds { - s.Push(v) - } - for i := 0; i < u.popCount; i++ { - s.Pop() - } - assert.Equal(t, u.e, s.Empty()) - } -} diff --git a/internal/ui/cmd_test.go b/internal/ui/cmd_test.go index ad33e552..f0434efb 100644 --- a/internal/ui/cmd_test.go +++ b/internal/ui/cmd_test.go @@ -1,28 +1,47 @@ -package ui +package ui_test import ( "testing" "github.com/derailed/k9s/internal/config" + "github.com/derailed/k9s/internal/ui" "github.com/stretchr/testify/assert" ) -func TestNewCmdUpdate(t *testing.T) { +func TestCmdNew(t *testing.T) { defaults, _ := config.NewStyles("") - v := NewCmdView(defaults) - v.update("blee") + v := ui.NewCmdView(defaults) + + buff := ui.NewCmdBuff(':', ui.CommandBuff) + buff.AddListener(v) + buff.Set("blee") assert.Equal(t, "\x00> blee\n", v.GetText(false)) } -func TestCmdInCmdMode(t *testing.T) { +func TestCmdUpdate(t *testing.T) { defaults, _ := config.NewStyles("") - v := NewCmdView(defaults) - v.update("blee") - v.append('!') + v := ui.NewCmdView(defaults) + + buff := ui.NewCmdBuff(':', ui.CommandBuff) + buff.AddListener(v) + + buff.Set("blee") + buff.Add('!') assert.Equal(t, "\x00> blee!\n", v.GetText(false)) assert.False(t, v.InCmdMode()) - v.BufferActive(true, CommandBuff) - assert.True(t, v.InCmdMode()) +} + +func TestCmdMode(t *testing.T) { + defaults, _ := config.NewStyles("") + v := ui.NewCmdView(defaults) + + buff := ui.NewCmdBuff(':', ui.CommandBuff) + buff.AddListener(v) + + for _, f := range []bool{false, true} { + buff.SetActive(f) + assert.Equal(t, f, v.InCmdMode()) + } } diff --git a/internal/ui/colorer_test.go b/internal/ui/colorer_test.go new file mode 100644 index 00000000..54632980 --- /dev/null +++ b/internal/ui/colorer_test.go @@ -0,0 +1,29 @@ +package ui_test + +import ( + "testing" + + "github.com/derailed/k9s/internal/resource" + "github.com/derailed/k9s/internal/ui" + "github.com/gdamore/tcell" + "github.com/stretchr/testify/assert" + "k8s.io/apimachinery/pkg/watch" +) + +func TestDefaultColorer(t *testing.T) { + uu := map[string]struct { + re resource.RowEvent + e tcell.Color + }{ + "def": {resource.RowEvent{}, ui.StdColor}, + "new": {resource.RowEvent{Action: resource.New}, ui.AddColor}, + "add": {resource.RowEvent{Action: watch.Added}, ui.AddColor}, + "upd": {resource.RowEvent{Action: watch.Modified}, ui.ModColor}, + } + + for k, u := range uu { + t.Run(k, func(t *testing.T) { + assert.Equal(t, u.e, ui.DefaultColorer("", &u.re)) + }) + } +} diff --git a/internal/ui/config.go b/internal/ui/config.go index 69d27e33..5992556e 100644 --- a/internal/ui/config.go +++ b/internal/ui/config.go @@ -10,11 +10,20 @@ import ( "github.com/rs/zerolog/log" ) +// Synchronizer manages ui event queue. type synchronizer interface { QueueUpdateDraw(func()) *tview.Application QueueUpdate(func()) *tview.Application } +// Configurator represents an application configurationa. +type Configurator struct { + HasSkins bool + Config *config.Config + Styles *config.Styles + Bench *config.Bench +} + // StylesUpdater watches for skin file changes. func (c *Configurator) StylesUpdater(ctx context.Context, s synchronizer) error { w, err := fsnotify.NewWatcher() @@ -51,6 +60,11 @@ func (c *Configurator) InitBench(cluster string) { } } +// BenchConfig location of the benchmarks configuration file. +func BenchConfig(cluster string) string { + return filepath.Join(config.K9sHome, config.K9sBench+"-"+cluster+".yml") +} + // RefreshStyles load for skin configuration changes. func (c *Configurator) RefreshStyles() { var err error @@ -69,8 +83,3 @@ func (c *Configurator) RefreshStyles() { HighlightColor = config.AsColor(c.Styles.Frame().Status.HighlightColor) CompletedColor = config.AsColor(c.Styles.Frame().Status.CompletedColor) } - -// BenchConfig location of the benchmarks configuration file. -func BenchConfig(cluster string) string { - return filepath.Join(config.K9sHome, config.K9sBench+"-"+cluster+".yml") -} diff --git a/internal/ui/config_test.go b/internal/ui/config_test.go new file mode 100644 index 00000000..71206800 --- /dev/null +++ b/internal/ui/config_test.go @@ -0,0 +1,39 @@ +package ui_test + +import ( + "path/filepath" + "testing" + + "github.com/derailed/k9s/internal/config" + "github.com/derailed/k9s/internal/ui" + "github.com/gdamore/tcell" + "github.com/stretchr/testify/assert" +) + +func TestBenchConfig(t *testing.T) { + config.K9sHome = "/tmp/blee" + assert.Equal(t, "/tmp/blee/bench-fred.yml", ui.BenchConfig("fred")) +} + +func TestConfiguratorRefreshStyle(t *testing.T) { + config.K9sStylesFile = filepath.Join("..", "config", "test_assets", "black_and_wtf.yml") + + cfg := ui.Configurator{} + cfg.RefreshStyles() + + assert.True(t, cfg.HasSkins) + assert.Equal(t, tcell.ColorGhostWhite, ui.StdColor) + assert.Equal(t, tcell.ColorWhiteSmoke, ui.ErrColor) +} + +func TestInitBench(t *testing.T) { + config.K9sHome = filepath.Join("..", "config", "test_assets") + + cfg := ui.Configurator{} + cfg.InitBench("fred") + + assert.NotNil(t, cfg.Bench) + assert.Equal(t, 2, cfg.Bench.Benchmarks.Defaults.C) + assert.Equal(t, 1000, cfg.Bench.Benchmarks.Defaults.N) + assert.Equal(t, 2, len(cfg.Bench.Benchmarks.Services)) +} diff --git a/internal/ui/crumbs.go b/internal/ui/crumbs.go index ec419578..648c2b08 100644 --- a/internal/ui/crumbs.go +++ b/internal/ui/crumbs.go @@ -4,31 +4,53 @@ import ( "fmt" "github.com/derailed/k9s/internal/config" + "github.com/derailed/k9s/internal/model" "github.com/derailed/tview" + "github.com/rs/zerolog/log" ) -// CrumbsView represents user breadcrumbs. -type CrumbsView struct { +// Crumbs represents user breadcrumbs. +type Crumbs struct { *tview.TextView styles *config.Styles + stack *model.Stack } -// NewCrumbsView returns a new breadcrumb view. -func NewCrumbsView(styles *config.Styles) *CrumbsView { - v := CrumbsView{styles: styles, TextView: tview.NewTextView()} - { - v.SetBackgroundColor(styles.BgColor()) - v.SetTextAlign(tview.AlignLeft) - v.SetBorderPadding(0, 0, 1, 1) - v.SetDynamicColors(true) +// NewCrumbs returns a new breadcrumb view. +func NewCrumbs(styles *config.Styles) *Crumbs { + v := Crumbs{ + stack: model.NewStack(), + styles: styles, + TextView: tview.NewTextView(), } + v.SetBackgroundColor(styles.BgColor()) + v.SetTextAlign(tview.AlignLeft) + v.SetBorderPadding(0, 0, 1, 1) + v.SetDynamicColors(true) return &v } +// StackPushed indicates a new item was added. +func (v *Crumbs) StackPushed(c model.Component) { + v.stack.Push(c) + log.Debug().Msgf(">>> PUSH %v", v.stack.Flatten()) + v.refresh(v.stack.Flatten()) +} + +// StackPopped indicates an item was deleted +func (v *Crumbs) StackPopped(_, _ model.Component) { + v.stack.Pop() + log.Debug().Msgf("<<< POP %v", v.stack.Flatten()) + v.refresh(v.stack.Flatten()) +} + +// StackTop indicates the top of the stack +func (v *Crumbs) StackTop(top model.Component) {} + // Refresh updates view with new crumbs. -func (v *CrumbsView) Refresh(crumbs []string) { +func (v *Crumbs) refresh(crumbs []string) { v.Clear() last, bgColor := len(crumbs)-1, v.styles.Frame().Crumb.BgColor for i, c := range crumbs { diff --git a/internal/ui/crumbs_test.go b/internal/ui/crumbs_test.go index dbbb2df8..757c7f56 100644 --- a/internal/ui/crumbs_test.go +++ b/internal/ui/crumbs_test.go @@ -1,16 +1,52 @@ -package ui +package ui_test import ( + "context" "testing" "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/rs/zerolog" "github.com/stretchr/testify/assert" ) +func init() { + zerolog.SetGlobalLevel(zerolog.FatalLevel) +} + func TestNewCrumbs(t *testing.T) { defaults, _ := config.NewStyles("") - v := NewCrumbsView(defaults) - v.Refresh([]string{"blee", "duh"}) + v := ui.NewCrumbs(defaults) + v.StackPushed(makeComponent("c1")) + v.StackPushed(makeComponent("c2")) + v.StackPushed(makeComponent("c3")) - assert.Equal(t, "[black:aqua:b] [-:black:-] [black:orange:b] [-:black:-] \n", v.GetText(false)) + assert.Equal(t, "[black:aqua:b] [-:black:-] [black:aqua:b] [-:black:-] [black:orange:b] [-:black:-] \n", v.GetText(false)) } + +// Helpers... + +type c struct { + name string +} + +func makeComponent(n string) c { + return c{name: n} +} + +func (c c) HasFocus() bool { return true } +func (c c) Hints() model.MenuHints { return nil } +func (c c) Name() string { return c.name } +func (c c) Draw(tcell.Screen) {} +func (c c) InputHandler() func(*tcell.EventKey, func(tview.Primitive)) { return nil } +func (c c) SetRect(int, int, int, int) {} +func (c c) GetRect() (int, int, int, int) { return 0, 0, 0, 0 } +func (c c) GetFocusable() tview.Focusable { return c } +func (c c) Focus(func(tview.Primitive)) {} +func (c c) Blur() {} +func (c c) Start() {} +func (c c) Stop() {} +func (c c) Init(context.Context) {} diff --git a/internal/ui/ctx.go b/internal/ui/ctx.go new file mode 100644 index 00000000..a3f5eb4b --- /dev/null +++ b/internal/ui/ctx.go @@ -0,0 +1,14 @@ +package ui + +type ContextKey string + +const ( + // KeyApp designates an application context. + KeyApp = ContextKey("app") + + // KeyStyles designates the application styles. + KeyStyles = ContextKey("styles") + + // KeyNamespace designates a namespace context. + KeyNamespace = ContextKey("ns") +) diff --git a/internal/ui/dialog/confirm.go b/internal/ui/dialog/confirm.go index a3b9b6a1..9e256cf9 100644 --- a/internal/ui/dialog/confirm.go +++ b/internal/ui/dialog/confirm.go @@ -1,6 +1,7 @@ package dialog import ( + "github.com/derailed/k9s/internal/ui" "github.com/derailed/tview" "github.com/gdamore/tcell" ) @@ -12,7 +13,7 @@ type ( ) // ShowConfirm pops a confirmation dialog. -func ShowConfirm(pages *tview.Pages, title, msg string, ack confirmFunc, cancel cancelFunc) { +func ShowConfirm(pages *ui.Pages, title, msg string, ack confirmFunc, cancel cancelFunc) { f := tview.NewForm() f.SetItemPadding(0) f.SetButtonsAlign(tview.AlignCenter). @@ -40,6 +41,6 @@ func ShowConfirm(pages *tview.Pages, title, msg string, ack confirmFunc, cancel pages.ShowPage(confirmKey) } -func dismissConfirm(pages *tview.Pages) { +func dismissConfirm(pages *ui.Pages) { pages.RemovePage(confirmKey) } diff --git a/internal/ui/dialog/confirm_test.go b/internal/ui/dialog/confirm_test.go index b4c61c2b..c83b8547 100644 --- a/internal/ui/dialog/confirm_test.go +++ b/internal/ui/dialog/confirm_test.go @@ -3,13 +3,14 @@ package dialog import ( "testing" + "github.com/derailed/k9s/internal/ui" "github.com/derailed/tview" "github.com/stretchr/testify/assert" ) func TestConfirmDialog(t *testing.T) { a := tview.NewApplication() - p := tview.NewPages() + p := ui.NewPages() a.SetRoot(p, false) ackFunc := func() { diff --git a/internal/ui/dialog/delete.go b/internal/ui/dialog/delete.go index 08658ddf..c0397794 100644 --- a/internal/ui/dialog/delete.go +++ b/internal/ui/dialog/delete.go @@ -1,6 +1,7 @@ package dialog import ( + "github.com/derailed/k9s/internal/ui" "github.com/derailed/tview" "github.com/gdamore/tcell" ) @@ -13,7 +14,7 @@ type ( ) // ShowDelete pops a resource deletion dialog. -func ShowDelete(pages *tview.Pages, msg string, ok okFunc, cancel cancelFunc) { +func ShowDelete(pages *ui.Pages, msg string, ok okFunc, cancel cancelFunc) { cascade, force := true, false f := tview.NewForm() f.SetItemPadding(0) @@ -48,6 +49,6 @@ func ShowDelete(pages *tview.Pages, msg string, ok okFunc, cancel cancelFunc) { pages.ShowPage(deleteKey) } -func dismissDelete(pages *tview.Pages) { +func dismissDelete(pages *ui.Pages) { pages.RemovePage(deleteKey) } diff --git a/internal/ui/dialog/delete_test.go b/internal/ui/dialog/delete_test.go index 01772fd7..1a8af243 100644 --- a/internal/ui/dialog/delete_test.go +++ b/internal/ui/dialog/delete_test.go @@ -3,12 +3,13 @@ package dialog import ( "testing" + "github.com/derailed/k9s/internal/ui" "github.com/derailed/tview" "github.com/stretchr/testify/assert" ) func TestDeleteDialog(t *testing.T) { - p := tview.NewPages() + p := ui.NewPages() okFunc := func(c, f bool) { assert.True(t, c) diff --git a/internal/ui/dialog/port_forward.go b/internal/ui/dialog/port_forward.go index 1c2c6e38..7dcfbb2c 100644 --- a/internal/ui/dialog/port_forward.go +++ b/internal/ui/dialog/port_forward.go @@ -3,6 +3,7 @@ package dialog import ( "strings" + "github.com/derailed/k9s/internal/ui" "github.com/derailed/tview" "github.com/gdamore/tcell" ) @@ -10,7 +11,7 @@ import ( const portForwardKey = "portforward" // ShowPortForward pops a port forwarding configuration dialog. -func ShowPortForward(p *tview.Pages, port string, okFn func(lport, cport string)) { +func ShowPortForward(p *ui.Pages, port string, okFn func(lport, cport string)) { f := tview.NewForm() f.SetItemPadding(0) f.SetButtonsAlign(tview.AlignCenter). @@ -43,7 +44,7 @@ func ShowPortForward(p *tview.Pages, port string, okFn func(lport, cport string) } // DismissPortForward dismiss the port forward dialog. -func DismissPortForward(p *tview.Pages) { +func DismissPortForward(p *ui.Pages) { p.RemovePage(portForwardKey) } diff --git a/internal/ui/dialog/port_forward_test.go b/internal/ui/dialog/port_forward_test.go index 7621b039..3c3a46c2 100644 --- a/internal/ui/dialog/port_forward_test.go +++ b/internal/ui/dialog/port_forward_test.go @@ -3,12 +3,13 @@ package dialog import ( "testing" + "github.com/derailed/k9s/internal/ui" "github.com/derailed/tview" "github.com/stretchr/testify/assert" ) func TestPortForwardDialog(t *testing.T) { - p := tview.NewPages() + p := ui.NewPages() okFunc := func(lport, cport string) { } diff --git a/internal/ui/flash.go b/internal/ui/flash.go index b5fea4d4..4a787cd6 100644 --- a/internal/ui/flash.go +++ b/internal/ui/flash.go @@ -39,12 +39,12 @@ type ( *tview.TextView cancel context.CancelFunc - app *tview.Application + app *App } ) // NewFlashView returns a new flash view. -func NewFlashView(app *tview.Application, m string) *FlashView { +func NewFlashView(app *App, m string) *FlashView { f := FlashView{app: app, TextView: tview.NewTextView()} f.SetTextColor(tcell.ColorAqua) f.SetTextAlign(tview.AlignLeft) diff --git a/internal/ui/flash_test.go b/internal/ui/flash_test.go index 3af0a469..19032d0f 100644 --- a/internal/ui/flash_test.go +++ b/internal/ui/flash_test.go @@ -1,41 +1,40 @@ -package ui +package ui_test import ( + "errors" "testing" - "github.com/gdamore/tcell" + "github.com/derailed/k9s/internal/ui" "github.com/stretchr/testify/assert" ) -func TestFlashEmoji(t *testing.T) { - uu := []struct { - level FlashLevel - emoji string - }{ - {FlashWarn, emoDoh}, - {FlashErr, emoRed}, - {FlashFatal, emoDead}, - {FlashInfo, emoHappy}, - } +func TestFlashInfo(t *testing.T) { + f := ui.NewFlashView(ui.NewApp(), "YO!") - for _, u := range uu { - assert.Equal(t, u.emoji, flashEmoji(u.level)) - } + f.Info("Blee") + assert.Equal(t, "😎 Blee\n", f.GetText(false)) + + f.Infof("Blee %s", "duh") + assert.Equal(t, "😎 Blee duh\n", f.GetText(false)) } -func TestFlashColor(t *testing.T) { - uu := []struct { - level FlashLevel - color tcell.Color - }{ - {FlashWarn, tcell.ColorOrange}, - {FlashErr, tcell.ColorOrangeRed}, - {FlashFatal, tcell.ColorFuchsia}, - {FlashInfo, tcell.ColorNavajoWhite}, - } +func TestFlashWarn(t *testing.T) { + f := ui.NewFlashView(ui.NewApp(), "YO!") - for _, u := range uu { - assert.Equal(t, u.color, flashColor(u.level)) - } + f.Warn("Blee") + assert.Equal(t, "😗 Blee\n", f.GetText(false)) + + f.Warnf("Blee %s", "duh") + assert.Equal(t, "😗 Blee duh\n", f.GetText(false)) +} + +func TestFlashErr(t *testing.T) { + f := ui.NewFlashView(ui.NewApp(), "YO!") + + f.Err(errors.New("Blee")) + assert.Equal(t, "😡 Blee\n", f.GetText(false)) + + f.Errf("Blee %s", "duh") + assert.Equal(t, "😡 Blee duh\n", f.GetText(false)) } diff --git a/internal/ui/indicator_test.go b/internal/ui/indicator_test.go new file mode 100644 index 00000000..9ddea4c9 --- /dev/null +++ b/internal/ui/indicator_test.go @@ -0,0 +1,47 @@ +package ui_test + +import ( + "testing" + + "github.com/derailed/k9s/internal/config" + "github.com/derailed/k9s/internal/ui" + "github.com/stretchr/testify/assert" +) + +func TestIndicatorReset(t *testing.T) { + s, _ := config.NewStyles("") + + i := ui.NewIndicatorView(ui.NewApp(), s) + i.SetPermanent("Blee") + i.Info("duh") + i.Reset() + + assert.Equal(t, "Blee\n", i.GetText(false)) +} + +func TestIndicatorInfo(t *testing.T) { + s, _ := config.NewStyles("") + + i := ui.NewIndicatorView(ui.NewApp(), s) + i.Info("Blee") + + assert.Equal(t, "[lawngreen::b] \n", i.GetText(false)) +} + +func TestIndicatorWarn(t *testing.T) { + s, _ := config.NewStyles("") + + i := ui.NewIndicatorView(ui.NewApp(), s) + i.Warn("Blee") + + assert.Equal(t, "[mediumvioletred::b] \n", i.GetText(false)) +} + +func TestIndicatorErr(t *testing.T) { + s, _ := config.NewStyles("") + + i := ui.NewIndicatorView(ui.NewApp(), s) + i.Err("Blee") + + assert.Equal(t, "[orangered::b] \n", i.GetText(false)) +} diff --git a/internal/ui/key.go b/internal/ui/key.go new file mode 100644 index 00000000..5504b0a8 --- /dev/null +++ b/internal/ui/key.go @@ -0,0 +1,180 @@ +package ui + +import "github.com/gdamore/tcell" + +func init() { + initKeys() +} + +func initKeys() { + tcell.KeyNames[tcell.Key(KeyHelp)] = "?" + tcell.KeyNames[tcell.Key(KeySlash)] = "/" + tcell.KeyNames[tcell.Key(KeySpace)] = "space" + + initNumbKeys() + initStdKeys() + initShiftKeys() +} + +// Defines numeric keys for container actions +const ( + Key0 int32 = iota + 48 + Key1 + Key2 + Key3 + Key4 + Key5 + Key6 + Key7 + Key8 + Key9 +) + +// Defines char keystrokes +const ( + KeyA tcell.Key = iota + 97 + KeyB + KeyC + KeyD + KeyE + KeyF + KeyG + KeyH + KeyI + KeyJ + KeyK + KeyL + KeyM + KeyN + KeyO + KeyP + KeyQ + KeyR + KeyS + KeyT + KeyU + KeyV + KeyW + KeyX + KeyY + KeyZ + KeyHelp = 63 + KeySlash = 47 + KeyColon = 58 + KeySpace = 32 +) + +// Define Shift Keys +const ( + KeyShiftA tcell.Key = iota + 65 + KeyShiftB + KeyShiftC + KeyShiftD + KeyShiftE + KeyShiftF + KeyShiftG + KeyShiftH + KeyShiftI + KeyShiftJ + KeyShiftK + KeyShiftL + KeyShiftM + KeyShiftN + KeyShiftO + KeyShiftP + KeyShiftQ + KeyShiftR + KeyShiftS + KeyShiftT + KeyShiftU + KeyShiftV + KeyShiftW + KeyShiftX + KeyShiftY + KeyShiftZ +) + +// NumKeys tracks number keys. +var NumKeys = map[int]int32{ + 0: Key0, + 1: Key1, + 2: Key2, + 3: Key3, + 4: Key4, + 5: Key5, + 6: Key6, + 7: Key7, + 8: Key8, + 9: Key9, +} + +func initNumbKeys() { + tcell.KeyNames[tcell.Key(Key0)] = "0" + tcell.KeyNames[tcell.Key(Key1)] = "1" + tcell.KeyNames[tcell.Key(Key2)] = "2" + tcell.KeyNames[tcell.Key(Key3)] = "3" + tcell.KeyNames[tcell.Key(Key4)] = "4" + tcell.KeyNames[tcell.Key(Key5)] = "5" + tcell.KeyNames[tcell.Key(Key6)] = "6" + tcell.KeyNames[tcell.Key(Key7)] = "7" + tcell.KeyNames[tcell.Key(Key8)] = "8" + tcell.KeyNames[tcell.Key(Key9)] = "9" +} + +func initStdKeys() { + tcell.KeyNames[tcell.Key(KeyA)] = "a" + tcell.KeyNames[tcell.Key(KeyB)] = "b" + tcell.KeyNames[tcell.Key(KeyC)] = "c" + tcell.KeyNames[tcell.Key(KeyD)] = "d" + tcell.KeyNames[tcell.Key(KeyE)] = "e" + tcell.KeyNames[tcell.Key(KeyF)] = "f" + tcell.KeyNames[tcell.Key(KeyG)] = "g" + tcell.KeyNames[tcell.Key(KeyH)] = "h" + tcell.KeyNames[tcell.Key(KeyI)] = "i" + tcell.KeyNames[tcell.Key(KeyJ)] = "j" + tcell.KeyNames[tcell.Key(KeyK)] = "k" + tcell.KeyNames[tcell.Key(KeyL)] = "l" + tcell.KeyNames[tcell.Key(KeyM)] = "m" + tcell.KeyNames[tcell.Key(KeyN)] = "n" + tcell.KeyNames[tcell.Key(KeyO)] = "o" + tcell.KeyNames[tcell.Key(KeyP)] = "p" + tcell.KeyNames[tcell.Key(KeyQ)] = "q" + tcell.KeyNames[tcell.Key(KeyR)] = "r" + tcell.KeyNames[tcell.Key(KeyS)] = "s" + tcell.KeyNames[tcell.Key(KeyT)] = "t" + tcell.KeyNames[tcell.Key(KeyU)] = "u" + tcell.KeyNames[tcell.Key(KeyV)] = "v" + tcell.KeyNames[tcell.Key(KeyW)] = "w" + tcell.KeyNames[tcell.Key(KeyX)] = "x" + tcell.KeyNames[tcell.Key(KeyY)] = "y" + tcell.KeyNames[tcell.Key(KeyZ)] = "z" +} + +func initShiftKeys() { + tcell.KeyNames[tcell.Key(KeyShiftA)] = "Shift-A" + tcell.KeyNames[tcell.Key(KeyShiftB)] = "Shift-B" + tcell.KeyNames[tcell.Key(KeyShiftC)] = "Shift-C" + tcell.KeyNames[tcell.Key(KeyShiftD)] = "Shift-D" + tcell.KeyNames[tcell.Key(KeyShiftE)] = "Shift-E" + tcell.KeyNames[tcell.Key(KeyShiftF)] = "Shift-F" + tcell.KeyNames[tcell.Key(KeyShiftG)] = "Shift-G" + tcell.KeyNames[tcell.Key(KeyShiftH)] = "Shift-H" + tcell.KeyNames[tcell.Key(KeyShiftI)] = "Shift-I" + tcell.KeyNames[tcell.Key(KeyShiftJ)] = "Shift-J" + tcell.KeyNames[tcell.Key(KeyShiftK)] = "Shift-K" + tcell.KeyNames[tcell.Key(KeyShiftL)] = "Shift-L" + tcell.KeyNames[tcell.Key(KeyShiftM)] = "Shift-M" + tcell.KeyNames[tcell.Key(KeyShiftN)] = "Shift-N" + tcell.KeyNames[tcell.Key(KeyShiftO)] = "Shift-O" + tcell.KeyNames[tcell.Key(KeyShiftP)] = "Shift-P" + tcell.KeyNames[tcell.Key(KeyShiftQ)] = "Shift-Q" + tcell.KeyNames[tcell.Key(KeyShiftR)] = "Shift-R" + tcell.KeyNames[tcell.Key(KeyShiftS)] = "Shift-S" + tcell.KeyNames[tcell.Key(KeyShiftT)] = "Shift-T" + tcell.KeyNames[tcell.Key(KeyShiftU)] = "Shift-U" + tcell.KeyNames[tcell.Key(KeyShiftV)] = "Shift-V" + tcell.KeyNames[tcell.Key(KeyShiftW)] = "Shift-W" + tcell.KeyNames[tcell.Key(KeyShiftX)] = "Shift-X" + tcell.KeyNames[tcell.Key(KeyShiftY)] = "Shift-Y" + tcell.KeyNames[tcell.Key(KeyShiftZ)] = "Shift-Z" +} diff --git a/internal/ui/menu.go b/internal/ui/menu.go index a45eafcc..11a0f47c 100644 --- a/internal/ui/menu.go +++ b/internal/ui/menu.go @@ -9,15 +9,11 @@ import ( "strings" "github.com/derailed/k9s/internal/config" - "github.com/derailed/k9s/internal/resource" + "github.com/derailed/k9s/internal/model" "github.com/derailed/tview" - "github.com/gdamore/tcell" + runewidth "github.com/mattn/go-runewidth" ) -func init() { - initKeys() -} - const ( menuIndexFmt = " [key:bg:b]<%d> [fg:bg:d]%s " maxRows = 7 @@ -25,29 +21,34 @@ const ( var menuRX = regexp.MustCompile(`\d`) -// MenuView represents menu options. -type MenuView struct { +// Menu presents menu options. +type Menu struct { *tview.Table styles *config.Styles } -// NewMenuView returns a new menu. -func NewMenuView(styles *config.Styles) *MenuView { - v := MenuView{Table: tview.NewTable(), styles: styles} +// NewMenu returns a new menu. +func NewMenu(styles *config.Styles) *Menu { + v := Menu{Table: tview.NewTable(), styles: styles} v.SetBackgroundColor(styles.BgColor()) return &v } +// HintsChanged updates the menu based on hints changing. +func (v *Menu) HintsChanged(hh model.MenuHints) { + v.HydrateMenu(hh) +} + // HydrateMenu populate menu ui from hints. -func (v *MenuView) HydrateMenu(hh Hints) { +func (v *Menu) HydrateMenu(hh model.MenuHints) { v.Clear() sort.Sort(hh) t := v.buildMenuTable(hh) for row := 0; row < len(t); row++ { for col := 0; col < len(t[row]); col++ { - if t[row][col] == "" { + if len(t[row][col]) == 0 { continue } c := tview.NewTableCell(t[row][col]) @@ -57,42 +58,33 @@ func (v *MenuView) HydrateMenu(hh Hints) { } } -func isDigit(s string) bool { - return menuRX.MatchString(s) -} +func (v *Menu) buildMenuTable(hh model.MenuHints) [][]string { + table := make([]model.MenuHints, maxRows+1) -func (v *MenuView) buildMenuTable(hh Hints) [][]string { - table := make([][]Hint, maxRows) - colCount := len(hh) / maxRows - if colCount == 0 { - colCount = 1 - } - if isDigit(hh[0].Mnemonic) { - colCount++ - } + colCount := (len(hh) / maxRows) + 1 for row := 0; row < maxRows; row++ { - table[row] = make([]Hint, colCount) + table[row] = make(model.MenuHints, colCount+1) } - var row, col, added int + + var row, col int firstCmd := true maxKeys := make([]int, colCount+1) for _, h := range hh { if !h.Visible { continue } - if !isDigit(h.Mnemonic) && firstCmd { + isDigit := menuRX.MatchString(h.Mnemonic) + if !isDigit && firstCmd { row, col, firstCmd = 0, col+1, false - if added == 0 { - col = 0 - } } if maxKeys[col] < len(h.Mnemonic) { maxKeys[col] = len(h.Mnemonic) } table[row][col] = h - added, row = added+1, row+1 + row++ if row >= maxRows { - row, col = 0, col+1 + col++ + row = 0 } } @@ -124,15 +116,20 @@ func keyConv(s string) string { return strings.Replace(s, "alt", "opt", 1) } +// Truncate a string to the given l and suffix ellipsis if needed. +func Truncate(str string, width int) string { + return runewidth.Truncate(str, width, string(tview.SemigraphicsHorizontalEllipsis)) +} + func toMnemonic(s string) string { if len(s) == 0 { return s } - return "<" + strings.ToLower(s) + ">" + return "<" + keyConv(strings.ToLower(s)) + ">" } -func (v *MenuView) formatMenu(h Hint, size int) string { +func (v *Menu) formatMenu(h model.MenuHint, size int) string { i, err := strconv.Atoi(h.Mnemonic) if err == nil { return formatNSMenu(i, h.Description, v.styles.Frame()) @@ -145,195 +142,13 @@ func formatNSMenu(i int, name string, styles config.Frame) string { fmat := strings.Replace(menuIndexFmt, "[key", "["+styles.Menu.NumKeyColor, 1) fmat = strings.Replace(fmat, ":bg:", ":"+styles.Title.BgColor+":", -1) fmat = strings.Replace(fmat, "[fg", "["+styles.Menu.FgColor, 1) - return fmt.Sprintf(fmat, i, resource.Truncate(name, 14)) + return fmt.Sprintf(fmat, i, Truncate(name, 14)) } -func formatPlainMenu(h Hint, size int, styles config.Frame) string { +func formatPlainMenu(h model.MenuHint, size int, styles config.Frame) string { menuFmt := " [key:bg:b]%-" + strconv.Itoa(size+2) + "s [fg:bg:d]%s " fmat := strings.Replace(menuFmt, "[key", "["+styles.Menu.KeyColor, 1) fmat = strings.Replace(fmat, "[fg", "["+styles.Menu.FgColor, 1) fmat = strings.Replace(fmat, ":bg:", ":"+styles.Title.BgColor+":", -1) return fmt.Sprintf(fmat, toMnemonic(h.Mnemonic), h.Description) } - -// ----------------------------------------------------------------------------- -// Key mapping Constants - -// Defines numeric keys for container actions -const ( - Key0 int32 = iota + 48 - Key1 - Key2 - Key3 - Key4 - Key5 - Key6 - Key7 - Key8 - Key9 -) - -// Defines char keystrokes -const ( - KeyA tcell.Key = iota + 97 - KeyB - KeyC - KeyD - KeyE - KeyF - KeyG - KeyH - KeyI - KeyJ - KeyK - KeyL - KeyM - KeyN - KeyO - KeyP - KeyQ - KeyR - KeyS - KeyT - KeyU - KeyV - KeyW - KeyX - KeyY - KeyZ - KeyHelp = 63 - KeySlash = 47 - KeyColon = 58 - KeySpace = 32 -) - -// Define Shift Keys -const ( - KeyShiftA tcell.Key = iota + 65 - KeyShiftB - KeyShiftC - KeyShiftD - KeyShiftE - KeyShiftF - KeyShiftG - KeyShiftH - KeyShiftI - KeyShiftJ - KeyShiftK - KeyShiftL - KeyShiftM - KeyShiftN - KeyShiftO - KeyShiftP - KeyShiftQ - KeyShiftR - KeyShiftS - KeyShiftT - KeyShiftU - KeyShiftV - KeyShiftW - KeyShiftX - KeyShiftY - KeyShiftZ -) - -// NumKeys tracks number keys. -var NumKeys = map[int]int32{ - 0: Key0, - 1: Key1, - 2: Key2, - 3: Key3, - 4: Key4, - 5: Key5, - 6: Key6, - 7: Key7, - 8: Key8, - 9: Key9, -} - -func initKeys() { - tcell.KeyNames[tcell.Key(KeyHelp)] = "?" - tcell.KeyNames[tcell.Key(KeySlash)] = "/" - tcell.KeyNames[tcell.Key(KeySpace)] = "space" - - initNumbKeys() - initStdKeys() - initShiftKeys() -} - -func initNumbKeys() { - tcell.KeyNames[tcell.Key(Key0)] = "0" - tcell.KeyNames[tcell.Key(Key1)] = "1" - tcell.KeyNames[tcell.Key(Key2)] = "2" - tcell.KeyNames[tcell.Key(Key3)] = "3" - tcell.KeyNames[tcell.Key(Key4)] = "4" - tcell.KeyNames[tcell.Key(Key5)] = "5" - tcell.KeyNames[tcell.Key(Key6)] = "6" - tcell.KeyNames[tcell.Key(Key7)] = "7" - tcell.KeyNames[tcell.Key(Key8)] = "8" - tcell.KeyNames[tcell.Key(Key9)] = "9" -} - -func initStdKeys() { - tcell.KeyNames[tcell.Key(KeyA)] = "a" - tcell.KeyNames[tcell.Key(KeyB)] = "b" - tcell.KeyNames[tcell.Key(KeyC)] = "c" - tcell.KeyNames[tcell.Key(KeyD)] = "d" - tcell.KeyNames[tcell.Key(KeyE)] = "e" - tcell.KeyNames[tcell.Key(KeyF)] = "f" - tcell.KeyNames[tcell.Key(KeyG)] = "g" - tcell.KeyNames[tcell.Key(KeyH)] = "h" - tcell.KeyNames[tcell.Key(KeyI)] = "i" - tcell.KeyNames[tcell.Key(KeyJ)] = "j" - tcell.KeyNames[tcell.Key(KeyK)] = "k" - tcell.KeyNames[tcell.Key(KeyL)] = "l" - tcell.KeyNames[tcell.Key(KeyM)] = "m" - tcell.KeyNames[tcell.Key(KeyN)] = "n" - tcell.KeyNames[tcell.Key(KeyO)] = "o" - tcell.KeyNames[tcell.Key(KeyP)] = "p" - tcell.KeyNames[tcell.Key(KeyQ)] = "q" - tcell.KeyNames[tcell.Key(KeyR)] = "r" - tcell.KeyNames[tcell.Key(KeyS)] = "s" - tcell.KeyNames[tcell.Key(KeyT)] = "t" - tcell.KeyNames[tcell.Key(KeyU)] = "u" - tcell.KeyNames[tcell.Key(KeyV)] = "v" - tcell.KeyNames[tcell.Key(KeyW)] = "w" - tcell.KeyNames[tcell.Key(KeyX)] = "x" - tcell.KeyNames[tcell.Key(KeyY)] = "y" - tcell.KeyNames[tcell.Key(KeyZ)] = "z" -} - -// BOZO!! No sure why these aren't mapped?? -func initCtrlKeys() { - tcell.KeyNames[tcell.KeyCtrlI] = "Ctrl-I" - tcell.KeyNames[tcell.KeyCtrlM] = "Ctrl-M" -} - -func initShiftKeys() { - tcell.KeyNames[tcell.Key(KeyShiftA)] = "Shift-A" - tcell.KeyNames[tcell.Key(KeyShiftB)] = "Shift-B" - tcell.KeyNames[tcell.Key(KeyShiftC)] = "Shift-C" - tcell.KeyNames[tcell.Key(KeyShiftD)] = "Shift-D" - tcell.KeyNames[tcell.Key(KeyShiftE)] = "Shift-E" - tcell.KeyNames[tcell.Key(KeyShiftF)] = "Shift-F" - tcell.KeyNames[tcell.Key(KeyShiftG)] = "Shift-G" - tcell.KeyNames[tcell.Key(KeyShiftH)] = "Shift-H" - tcell.KeyNames[tcell.Key(KeyShiftI)] = "Shift-I" - tcell.KeyNames[tcell.Key(KeyShiftJ)] = "Shift-J" - tcell.KeyNames[tcell.Key(KeyShiftK)] = "Shift-K" - tcell.KeyNames[tcell.Key(KeyShiftL)] = "Shift-L" - tcell.KeyNames[tcell.Key(KeyShiftM)] = "Shift-M" - tcell.KeyNames[tcell.Key(KeyShiftN)] = "Shift-N" - tcell.KeyNames[tcell.Key(KeyShiftO)] = "Shift-O" - tcell.KeyNames[tcell.Key(KeyShiftP)] = "Shift-P" - tcell.KeyNames[tcell.Key(KeyShiftQ)] = "Shift-Q" - tcell.KeyNames[tcell.Key(KeyShiftR)] = "Shift-R" - tcell.KeyNames[tcell.Key(KeyShiftS)] = "Shift-S" - tcell.KeyNames[tcell.Key(KeyShiftT)] = "Shift-T" - tcell.KeyNames[tcell.Key(KeyShiftU)] = "Shift-U" - tcell.KeyNames[tcell.Key(KeyShiftV)] = "Shift-V" - tcell.KeyNames[tcell.Key(KeyShiftW)] = "Shift-W" - tcell.KeyNames[tcell.Key(KeyShiftX)] = "Shift-X" - tcell.KeyNames[tcell.Key(KeyShiftY)] = "Shift-Y" - tcell.KeyNames[tcell.Key(KeyShiftZ)] = "Shift-Z" -} diff --git a/internal/ui/menu_test.go b/internal/ui/menu_test.go index 1be4b70f..9be0100b 100644 --- a/internal/ui/menu_test.go +++ b/internal/ui/menu_test.go @@ -1,20 +1,22 @@ -package ui +package ui_test import ( "testing" "github.com/derailed/k9s/internal/config" + "github.com/derailed/k9s/internal/model" + "github.com/derailed/k9s/internal/ui" "github.com/gdamore/tcell" "github.com/stretchr/testify/assert" ) -func TestNewMenuView(t *testing.T) { +func TestNewMenu(t *testing.T) { defaults, _ := config.NewStyles("") - v := NewMenuView(defaults) - v.HydrateMenu(Hints{ - {"0", "zero", true}, - {"a", "bleeA", true}, - {"b", "bleeB", true}, + v := ui.NewMenu(defaults) + v.HydrateMenu(model.MenuHints{ + {Mnemonic: "a", Description: "bleeA", Visible: true}, + {Mnemonic: "b", Description: "bleeB", Visible: true}, + {Mnemonic: "0", Description: "zero", Visible: true}, }) assert.Equal(t, " [fuchsia:black:b]<0> [white:black:d]zero ", v.GetCell(0, 0).Text) @@ -22,23 +24,23 @@ func TestNewMenuView(t *testing.T) { assert.Equal(t, " [dodgerblue:black:b] [white:black:d]bleeB ", v.GetCell(1, 1).Text) } -func TestKeyActions(t *testing.T) { +func TestActionHints(t *testing.T) { uu := map[string]struct { - aa KeyActions - e Hints + aa ui.KeyActions + e model.MenuHints }{ "a": { - aa: KeyActions{ - KeyB: NewKeyAction("bleeB", nil, true), - KeyA: NewKeyAction("bleeA", nil, true), - tcell.Key(Key0): NewKeyAction("zero", nil, true), - tcell.Key(Key1): NewKeyAction("one", nil, false), + aa: ui.KeyActions{ + ui.KeyB: ui.NewKeyAction("bleeB", nil, true), + ui.KeyA: ui.NewKeyAction("bleeA", nil, true), + tcell.Key(ui.Key0): ui.NewKeyAction("zero", nil, true), + tcell.Key(ui.Key1): ui.NewKeyAction("one", nil, false), }, - e: Hints{ - {"0", "zero", true}, - {"1", "one", false}, - {"a", "bleeA", true}, - {"b", "bleeB", true}, + e: model.MenuHints{ + {Mnemonic: "0", Description: "zero", Visible: true}, + {Mnemonic: "1", Description: "one", Visible: false}, + {Mnemonic: "a", Description: "bleeA", Visible: true}, + {Mnemonic: "b", Description: "bleeB", Visible: true}, }, }, } diff --git a/internal/ui/pages.go b/internal/ui/pages.go new file mode 100644 index 00000000..4252dcb0 --- /dev/null +++ b/internal/ui/pages.go @@ -0,0 +1,76 @@ +package ui + +import ( + "github.com/derailed/k9s/internal/model" + "github.com/derailed/tview" + "github.com/rs/zerolog/log" +) + +type Pages struct { + *tview.Pages + *model.Stack +} + +func NewPages() *Pages { + p := Pages{ + Pages: tview.NewPages(), + Stack: model.NewStack(), + } + p.Stack.AddListener(&p) + + return &p +} + +// Get fetch a page given its name. +func (p *Pages) get(n string) model.Component { + if comp, ok := p.GetPrimitive(n).(model.Component); ok { + return comp + } + + return nil +} + +// AddAndShow adds a new page and bring it to front. +func (p *Pages) addAndShow(c model.Component) { + p.add(c) + p.Show(c.Name()) +} + +// Add adds a new page. +func (p *Pages) add(c model.Component) { + p.AddPage(c.Name(), c, true, true) +} + +// Delete removes a page. +func (p *Pages) delete(c model.Component) { + p.RemovePage(c.Name()) +} + +// Show brings a named page forward. +func (p *Pages) Show(n string) { + p.SwitchToPage(n) +} + +func (p *Pages) DumpPages() { + log.Debug().Msgf("Dumping Pages %p", p) + for i, n := range p.Stack.Flatten() { + log.Debug().Msgf("%d -- %s -- %#v", i, n, p.GetPrimitive(n)) + } +} + +// Stack Protocol... + +func (p *Pages) StackPushed(c model.Component) { + p.addAndShow(c) +} + +func (p *Pages) StackPopped(o, top model.Component) { + p.delete(o) +} + +func (p *Pages) StackTop(top model.Component) { + if top == nil { + return + } + p.Show(top.Name()) +} diff --git a/internal/ui/pages_test.go b/internal/ui/pages_test.go new file mode 100644 index 00000000..f6e447e4 --- /dev/null +++ b/internal/ui/pages_test.go @@ -0,0 +1,31 @@ +package ui_test + +import ( + "testing" + + "github.com/derailed/k9s/internal/ui" + "github.com/stretchr/testify/assert" +) + +func TestPagesPush(t *testing.T) { + c1, c2 := makeComponent("c1"), makeComponent("c2") + + p := ui.NewPages() + p.Push(c1) + p.Push(c2) + + assert.Equal(t, 2, p.GetPageCount()) + assert.Equal(t, c2, p.CurrentPage().Item) +} + +func TestPagesPop(t *testing.T) { + c1, c2 := makeComponent("c1"), makeComponent("c2") + + p := ui.NewPages() + p.Push(c1) + p.Push(c2) + p.Pop() + + assert.Equal(t, 1, p.GetPageCount()) + assert.Equal(t, c1, p.CurrentPage().Item) +} diff --git a/internal/ui/splash.go b/internal/ui/splash.go index 55321035..25419c03 100644 --- a/internal/ui/splash.go +++ b/internal/ui/splash.go @@ -9,11 +9,6 @@ import ( "github.com/gdamore/tcell" ) -const ( - company = "imhotep.io" - product = "Kubernetes CLI Island Style!" -) - // LogoSmall K9s small log. var LogoSmall = []string{ ` ____ __.________ `, diff --git a/internal/ui/table.go b/internal/ui/table.go index b1bab827..ace8337e 100644 --- a/internal/ui/table.go +++ b/internal/ui/table.go @@ -1,6 +1,7 @@ package ui import ( + "context" "errors" "fmt" "path" @@ -9,6 +10,7 @@ import ( "time" "github.com/derailed/k9s/internal/config" + "github.com/derailed/k9s/internal/model" "github.com/derailed/k9s/internal/resource" "github.com/derailed/tview" "github.com/gdamore/tcell" @@ -46,160 +48,167 @@ type Table struct { } // NewTable returns a new table view. -func NewTable(title string, styles *config.Styles) *Table { - v := Table{ +func NewTable(title string) *Table { + return &Table{ Table: tview.NewTable(), - styles: styles, actions: make(KeyActions), cmdBuff: NewCmdBuff('/', FilterBuff), baseTitle: title, sortCol: SortColumn{0, 0, true}, marks: make(map[string]bool), } +} - v.SetFixed(1, 0) - v.SetBorder(true) - v.SetBackgroundColor(config.AsColor(styles.Table().BgColor)) - v.SetBorderColor(config.AsColor(styles.Table().FgColor)) - v.SetBorderFocusColor(config.AsColor(styles.Frame().Border.FocusColor)) - v.SetBorderAttributes(tcell.AttrBold) - v.SetBorderPadding(0, 0, 1, 1) - v.SetSelectable(true, false) - v.SetSelectedStyle( +func (t *Table) Init(ctx context.Context) { + t.styles = ctx.Value(KeyStyles).(*config.Styles) + + t.SetFixed(1, 0) + t.SetBorder(true) + t.SetBackgroundColor(config.AsColor(t.styles.Table().BgColor)) + t.SetBorderColor(config.AsColor(t.styles.Table().FgColor)) + t.SetBorderFocusColor(config.AsColor(t.styles.Frame().Border.FocusColor)) + t.SetBorderAttributes(tcell.AttrBold) + t.SetBorderPadding(0, 0, 1, 1) + t.SetSelectable(true, false) + t.SetSelectedStyle( tcell.ColorBlack, - config.AsColor(styles.Table().CursorColor), + config.AsColor(t.styles.Table().CursorColor), tcell.AttrBold, ) - v.SetSelectionChangedFunc(v.selChanged) - v.SetInputCapture(v.keyboard) + t.SetSelectionChangedFunc(t.selChanged) + t.SetInputCapture(t.keyboard) - return &v +} + +// SendKey sends an keyboard event (testing only!). +func (t *Table) SendKey(evt *tcell.EventKey) { + t.keyboard(evt) } // GetRow retrieves the entire selected row. -func (v *Table) GetRow() resource.Row { - r := make(resource.Row, v.GetColumnCount()) - for i := 0; i < v.GetColumnCount(); i++ { - c := v.GetCell(v.selectedRow, i) +func (t *Table) GetRow() resource.Row { + r := make(resource.Row, t.GetColumnCount()) + for i := 0; i < t.GetColumnCount(); i++ { + c := t.GetCell(t.selectedRow, i) r[i] = strings.TrimSpace(c.Text) } return r } // AddSelectedRowListener add a new selected row listener. -func (v *Table) AddSelectedRowListener(f SelectedRowFunc) { - v.selListeners = append(v.selListeners, f) +func (t *Table) AddSelectedRowListener(f SelectedRowFunc) { + t.selListeners = append(t.selListeners, f) } -func (v *Table) selChanged(r, c int) { - v.selectedRow = r - v.updateSelectedItem(r) +func (t *Table) selChanged(r, c int) { + t.selectedRow = r + t.updateSelectedItem(r) if r == 0 { return } - cell := v.GetCell(r, c) - v.SetSelectedStyle( + cell := t.GetCell(r, c) + t.SetSelectedStyle( tcell.ColorBlack, cell.Color, tcell.AttrBold, ) - for _, f := range v.selListeners { + for _, f := range t.selListeners { f(r, c) } } // UpdateSelection refresh selected row. -func (v *Table) updateSelection(broadcast bool) { - v.SelectRow(v.selectedRow, broadcast) +func (t *Table) updateSelection(broadcast bool) { + t.SelectRow(t.selectedRow, broadcast) } // SelectRow select a given row by index. -func (v *Table) SelectRow(r int, broadcast bool) { +func (t *Table) SelectRow(r int, broadcast bool) { if !broadcast { - v.SetSelectionChangedFunc(nil) + t.SetSelectionChangedFunc(nil) } - defer v.SetSelectionChangedFunc(v.selChanged) - v.Select(r, 0) - v.updateSelectedItem(r) + defer t.SetSelectionChangedFunc(t.selChanged) + t.Select(r, 0) + t.updateSelectedItem(r) } -func (v *Table) updateSelectedItem(r int) { - if r == 0 || v.GetCell(r, 0) == nil { - v.selectedItem = "" +func (t *Table) updateSelectedItem(r int) { + if r == 0 || t.GetCell(r, 0) == nil { + t.selectedItem = "" return } - col0 := TrimCell(v, r, 0) - switch v.activeNS { + col0 := TrimCell(t, r, 0) + switch t.activeNS { case resource.NotNamespaced: - v.selectedItem = col0 + t.selectedItem = col0 case resource.AllNamespace, resource.AllNamespaces: - v.selectedItem = path.Join(col0, TrimCell(v, r, 1)) + t.selectedItem = path.Join(col0, TrimCell(t, r, 1)) default: - v.selectedItem = path.Join(v.activeNS, col0) + t.selectedItem = path.Join(t.activeNS, col0) } } // SetSelectedFn defines a function that cleanse the current selection. -func (v *Table) SetSelectedFn(f func(string) string) { - v.selectedFn = f +func (t *Table) SetSelectedFn(f func(string) string) { + t.selectedFn = f } // RowSelected checks if there is an active row selection. -func (v *Table) RowSelected() bool { - return v.selectedItem != "" +func (t *Table) RowSelected() bool { + return t.selectedItem != "" } -// GetSelectedCell returns the contant of a cell for the currently selected row. -func (v *Table) GetSelectedCell(col int) string { - return TrimCell(v, v.selectedRow, col) +// GetSelectedCell returns the content of a cell for the currently selected row. +func (t *Table) GetSelectedCell(col int) string { + return TrimCell(t, t.selectedRow, col) } // GetSelectedRow fetch the currently selected row index. -func (v *Table) GetSelectedRow() int { - return v.selectedRow +func (t *Table) GetSelectedRowIndex() int { + return t.selectedRow } // GetSelectedItem returns the currently selected item name. -func (v *Table) GetSelectedItem() string { - if v.selectedFn != nil { - return v.selectedFn(v.selectedItem) +func (t *Table) GetSelectedItem() string { + if t.selectedFn != nil { + return t.selectedFn(t.selectedItem) } - return v.selectedItem + return t.selectedItem } // GetSelectedItems return currently marked or selected items names. -func (v *Table) GetSelectedItems() []string { - if len(v.marks) > 0 { +func (t *Table) GetSelectedItems() []string { + if len(t.marks) > 0 { var items []string - for item, marked := range v.marks { + for item, marked := range t.marks { if marked { items = append(items, item) } } return items } - return []string{v.GetSelectedItem()} + return []string{t.GetSelectedItem()} } -func (v *Table) keyboard(evt *tcell.EventKey) *tcell.EventKey { +func (t *Table) keyboard(evt *tcell.EventKey) *tcell.EventKey { key := evt.Key() if key == tcell.KeyRune { - if v.SearchBuff().IsActive() { - v.SearchBuff().Add(evt.Rune()) - v.ClearSelection() - v.doUpdate(v.filtered()) - v.UpdateTitle() - v.SelectFirstRow() + if t.SearchBuff().IsActive() { + t.SearchBuff().Add(evt.Rune()) + t.ClearSelection() + t.doUpdate(t.filtered()) + t.UpdateTitle() + t.SelectFirstRow() return nil } key = asKey(evt) } - if a, ok := v.actions[key]; ok { + if a, ok := t.actions[key]; ok { return a.Action(evt) } @@ -207,156 +216,156 @@ func (v *Table) keyboard(evt *tcell.EventKey) *tcell.EventKey { } // GetData fetch tabular data. -func (v *Table) GetData() resource.TableData { - return v.data +func (t *Table) GetData() resource.TableData { + return t.data } // GetFilteredData fetch filtered tabular data. -func (v *Table) GetFilteredData() resource.TableData { - return v.filtered() +func (t *Table) GetFilteredData() resource.TableData { + return t.filtered() } // SetBaseTitle set the table title. -func (v *Table) SetBaseTitle(s string) { - v.baseTitle = s +func (t *Table) SetBaseTitle(s string) { + t.baseTitle = s } // GetBaseTitle fetch the current title. -func (v *Table) GetBaseTitle() string { - return v.baseTitle +func (t *Table) GetBaseTitle() string { + return t.baseTitle } // SetColorerFn set the row colorer. -func (v *Table) SetColorerFn(f ColorerFunc) { - v.colorerFn = f +func (t *Table) SetColorerFn(f ColorerFunc) { + t.colorerFn = f } // ActiveNS get the resource namespace. -func (v *Table) ActiveNS() string { - return v.activeNS +func (t *Table) ActiveNS() string { + return t.activeNS } // SetActiveNS set the resource namespace. -func (v *Table) SetActiveNS(ns string) { - v.activeNS = ns +func (t *Table) SetActiveNS(ns string) { + t.activeNS = ns } // SetSortCol sets in sort column index and order. -func (v *Table) SetSortCol(index, count int, asc bool) { - v.sortCol.index, v.sortCol.colCount, v.sortCol.asc = index, count, asc +func (t *Table) SetSortCol(index, count int, asc bool) { + t.sortCol.index, t.sortCol.colCount, t.sortCol.asc = index, count, asc } // Update table content. -func (v *Table) Update(data resource.TableData) { - v.data = data - if v.cmdBuff.Empty() { - v.doUpdate(v.data) +func (t *Table) Update(data resource.TableData) { + t.data = data + if t.cmdBuff.Empty() { + t.doUpdate(t.data) } else { - v.doUpdate(v.filtered()) + t.doUpdate(t.filtered()) } - v.UpdateTitle() - v.updateSelection(true) + t.UpdateTitle() + t.updateSelection(true) } -func (v *Table) doUpdate(data resource.TableData) { - v.activeNS = data.Namespace - if v.activeNS == resource.AllNamespaces && v.activeNS != "*" { - v.actions[KeyShiftP] = NewKeyAction("Sort Namespace", v.SortColCmd(-2), false) +func (t *Table) doUpdate(data resource.TableData) { + t.activeNS = data.Namespace + if t.activeNS == resource.AllNamespaces && t.activeNS != "*" { + t.actions[KeyShiftP] = NewKeyAction("Sort Namespace", t.SortColCmd(-2), false) } else { - delete(v.actions, KeyShiftP) + delete(t.actions, KeyShiftP) } - v.Clear() + t.Clear() - v.adjustSorter(data) + t.adjustSorter(data) var row int - fg := config.AsColor(v.styles.Table().Header.FgColor) - bg := config.AsColor(v.styles.Table().Header.BgColor) + fg := config.AsColor(t.styles.Table().Header.FgColor) + bg := config.AsColor(t.styles.Table().Header.BgColor) for col, h := range data.Header { - v.AddHeaderCell(data.NumCols[h], col, h) - c := v.GetCell(0, col) + t.AddHeaderCell(data.NumCols[h], col, h) + c := t.GetCell(0, col) c.SetBackgroundColor(bg) c.SetTextColor(fg) } row++ - v.sort(data, row) + t.sort(data, row) } // SortColCmd designates a sorted column. -func (v *Table) SortColCmd(col int) func(evt *tcell.EventKey) *tcell.EventKey { +func (t *Table) SortColCmd(col int) func(evt *tcell.EventKey) *tcell.EventKey { return func(evt *tcell.EventKey) *tcell.EventKey { - v.sortCol.asc = true + t.sortCol.asc = true switch col { case -2: - v.sortCol.index = 0 + t.sortCol.index = 0 case -1: - v.sortCol.index = v.GetColumnCount() - 1 + t.sortCol.index = t.GetColumnCount() - 1 default: - v.sortCol.index = v.NameColIndex() + col + t.sortCol.index = t.NameColIndex() + col } - v.Refresh() + t.Refresh() return nil } } -func (v *Table) adjustSorter(data resource.TableData) { +func (t *Table) adjustSorter(data resource.TableData) { // Going from namespace to non namespace or vice-versa? switch { - case v.sortCol.colCount == 0: - case len(data.Header) > v.sortCol.colCount: - v.sortCol.index++ - case len(data.Header) < v.sortCol.colCount: - v.sortCol.index-- + case t.sortCol.colCount == 0: + case len(data.Header) > t.sortCol.colCount: + t.sortCol.index++ + case len(data.Header) < t.sortCol.colCount: + t.sortCol.index-- } - v.sortCol.colCount = len(data.Header) - if v.sortCol.index < 0 { - v.sortCol.index = 0 + t.sortCol.colCount = len(data.Header) + if t.sortCol.index < 0 { + t.sortCol.index = 0 } } -func (v *Table) sort(data resource.TableData, row int) { +func (t *Table) sort(data resource.TableData, row int) { pads := make(MaxyPad, len(data.Header)) - ComputeMaxColumns(pads, v.sortCol.index, data) + ComputeMaxColumns(pads, t.sortCol.index, data) sortFn := defaultSort - if v.sortFn != nil { - sortFn = v.sortFn + if t.sortFn != nil { + sortFn = t.sortFn } - prim, sec := sortAllRows(v.sortCol, data.Rows, sortFn) + prim, sec := sortAllRows(t.sortCol, data.Rows, sortFn) for _, pk := range prim { for _, sk := range sec[pk] { - v.buildRow(row, data, sk, pads) + t.buildRow(row, data, sk, pads) row++ } } } -func (v *Table) buildRow(row int, data resource.TableData, sk string, pads MaxyPad) { +func (t *Table) buildRow(row int, data resource.TableData, sk string, pads MaxyPad) { f := DefaultColorer - if v.colorerFn != nil { - f = v.colorerFn + if t.colorerFn != nil { + f = t.colorerFn } - m := v.isMarked(sk) + m := t.isMarked(sk) for col, field := range data.Rows[sk].Fields { header := data.Header[col] - field, align := v.formatCell(data.NumCols[header], header, field+Deltas(data.Rows[sk].Deltas[col], field), pads[col]) + field, align := t.formatCell(data.NumCols[header], header, field+Deltas(data.Rows[sk].Deltas[col], field), pads[col]) c := tview.NewTableCell(field) { c.SetExpansion(1) c.SetAlign(align) c.SetTextColor(f(data.Namespace, data.Rows[sk])) if m { - c.SetBackgroundColor(config.AsColor(v.styles.Table().MarkColor)) + c.SetBackgroundColor(config.AsColor(t.styles.Table().MarkColor)) } } - v.SetCell(row, col, c) + t.SetCell(row, col, c) } } -func (v *Table) formatCell(numerical bool, header, field string, padding int) (string, int) { +func (t *Table) formatCell(numerical bool, header, field string, padding int) (string, int) { if header == "AGE" { dur, err := time.ParseDuration(field) if err == nil { @@ -377,56 +386,56 @@ func (v *Table) formatCell(numerical bool, header, field string, padding int) (s } // Refresh update the table data. -func (v *Table) Refresh() { - v.Update(v.data) +func (t *Table) Refresh() { + t.Update(t.data) } // NameColIndex returns the index of the resource name column. -func (v *Table) NameColIndex() int { +func (t *Table) NameColIndex() int { col := 0 - if v.activeNS == resource.AllNamespaces { + if t.activeNS == resource.AllNamespaces { col++ } return col } // AddHeaderCell configures a table cell header. -func (v *Table) AddHeaderCell(numerical bool, col int, name string) { - c := tview.NewTableCell(sortIndicator(v.sortCol, v.styles.Table(), col, name)) +func (t *Table) AddHeaderCell(numerical bool, col int, name string) { + c := tview.NewTableCell(sortIndicator(t.sortCol, t.styles.Table(), col, name)) c.SetExpansion(1) if numerical || cpuRX.MatchString(name) || memRX.MatchString(name) { c.SetAlign(tview.AlignRight) } - v.SetCell(0, col, c) + t.SetCell(0, col, c) } -func (v *Table) filtered() resource.TableData { - if v.cmdBuff.Empty() || isLabelSelector(v.cmdBuff.String()) { - return v.data +func (t *Table) filtered() resource.TableData { + if t.cmdBuff.Empty() || IsLabelSelector(t.cmdBuff.String()) { + return t.data } - q := v.cmdBuff.String() + q := t.cmdBuff.String() if isFuzzySelector(q) { - return v.fuzzyFilter(q[2:]) + return t.fuzzyFilter(q[2:]) } - return v.rxFilter(q) + return t.rxFilter(q) } -func (v *Table) rxFilter(q string) resource.TableData { - rx, err := regexp.Compile(`(?i)` + v.cmdBuff.String()) +func (t *Table) rxFilter(q string) resource.TableData { + rx, err := regexp.Compile(`(?i)` + t.cmdBuff.String()) if err != nil { log.Error().Err(errors.New("Invalid filter expression")).Msg("Regexp") - v.cmdBuff.Clear() - return v.data + t.cmdBuff.Clear() + return t.data } filtered := resource.TableData{ - Header: v.data.Header, + Header: t.data.Header, Rows: resource.RowEvents{}, - Namespace: v.data.Namespace, + Namespace: t.data.Namespace, } - for k, row := range v.data.Rows { + for k, row := range t.data.Rows { f := strings.Join(row.Fields, " ") if rx.MatchString(f) { filtered.Rows[k] = row @@ -436,123 +445,123 @@ func (v *Table) rxFilter(q string) resource.TableData { return filtered } -func (v *Table) fuzzyFilter(q string) resource.TableData { +func (t *Table) fuzzyFilter(q string) resource.TableData { var ss, kk []string - for k, row := range v.data.Rows { - ss = append(ss, row.Fields[v.NameColIndex()]) + for k, row := range t.data.Rows { + ss = append(ss, row.Fields[t.NameColIndex()]) kk = append(kk, k) } filtered := resource.TableData{ - Header: v.data.Header, + Header: t.data.Header, Rows: resource.RowEvents{}, - Namespace: v.data.Namespace, + Namespace: t.data.Namespace, } mm := fuzzy.Find(q, ss) for _, m := range mm { - filtered.Rows[kk[m.Index]] = v.data.Rows[kk[m.Index]] + filtered.Rows[kk[m.Index]] = t.data.Rows[kk[m.Index]] } return filtered } // KeyBindings returns the bounded keys. -func (v *Table) KeyBindings() KeyActions { - return v.actions +func (t *Table) KeyBindings() KeyActions { + return t.actions } // SearchBuff returns the associated command buffer. -func (v *Table) SearchBuff() *CmdBuff { - return v.cmdBuff +func (t *Table) SearchBuff() *CmdBuff { + return t.cmdBuff } // ClearSelection reset selected row. -func (v *Table) ClearSelection() { - v.Select(0, 0) - v.ScrollToBeginning() +func (t *Table) ClearSelection() { + t.Select(0, 0) + t.ScrollToBeginning() } // SelectFirstRow select first data row if any. -func (v *Table) SelectFirstRow() { - if v.GetRowCount() > 0 { - v.Select(1, 0) +func (t *Table) SelectFirstRow() { + if t.GetRowCount() > 0 { + t.Select(1, 0) } } // ShowDeleted marks row as deleted. -func (v *Table) ShowDeleted() { - r, _ := v.GetSelection() - cols := v.GetColumnCount() +func (t *Table) ShowDeleted() { + r, _ := t.GetSelection() + cols := t.GetColumnCount() for x := 0; x < cols; x++ { - v.GetCell(r, x).SetAttributes(tcell.AttrDim) + t.GetCell(r, x).SetAttributes(tcell.AttrDim) } } // SetActions sets up keyboard action listener. -func (v *Table) SetActions(aa KeyActions) { +func (t *Table) AddActions(aa KeyActions) { for k, a := range aa { - v.actions[k] = a + t.actions[k] = a } } // RmAction delete a keyed action. -func (v *Table) RmAction(kk ...tcell.Key) { +func (t *Table) RmAction(kk ...tcell.Key) { for _, k := range kk { - delete(v.actions, k) + delete(t.actions, k) } } // Hints options -func (v *Table) Hints() Hints { - if v.actions != nil { - return v.actions.Hints() +func (t *Table) Hints() model.MenuHints { + if t.actions != nil { + return t.actions.Hints() } return nil } // UpdateTitle refreshes the table title. -func (v *Table) UpdateTitle() { +func (t *Table) UpdateTitle() { var title string - rc := v.GetRowCount() + rc := t.GetRowCount() if rc > 0 { rc-- } - switch v.activeNS { + switch t.activeNS { case resource.NotNamespaced, "*": - title = skinTitle(fmt.Sprintf(titleFmt, v.baseTitle, rc), v.styles.Frame()) + title = skinTitle(fmt.Sprintf(titleFmt, t.baseTitle, rc), t.styles.Frame()) default: - ns := v.activeNS + ns := t.activeNS if ns == resource.AllNamespaces { ns = resource.AllNamespace } - title = skinTitle(fmt.Sprintf(nsTitleFmt, v.baseTitle, ns, rc), v.styles.Frame()) + title = skinTitle(fmt.Sprintf(nsTitleFmt, t.baseTitle, ns, rc), t.styles.Frame()) } - if !v.cmdBuff.Empty() { - cmd := v.cmdBuff.String() - if isLabelSelector(cmd) { - cmd = trimLabelSelector(cmd) + if !t.cmdBuff.Empty() { + cmd := t.cmdBuff.String() + if IsLabelSelector(cmd) { + cmd = TrimLabelSelector(cmd) } - title += skinTitle(fmt.Sprintf(searchFmt, cmd), v.styles.Frame()) + title += skinTitle(fmt.Sprintf(SearchFmt, cmd), t.styles.Frame()) } - v.SetTitle(title) + t.SetTitle(title) } // SortInvertCmd reverses sorting order. -func (v *Table) SortInvertCmd(evt *tcell.EventKey) *tcell.EventKey { - v.sortCol.asc = !v.sortCol.asc - v.Refresh() +func (t *Table) SortInvertCmd(evt *tcell.EventKey) *tcell.EventKey { + t.sortCol.asc = !t.sortCol.asc + t.Refresh() return nil } // ToggleMark toggles marked row -func (v *Table) ToggleMark() { - v.marks[v.GetSelectedItem()] = !v.marks[v.GetSelectedItem()] +func (t *Table) ToggleMark() { + t.marks[t.GetSelectedItem()] = !t.marks[t.GetSelectedItem()] } -func (v *Table) isMarked(item string) bool { - return v.marks[item] +func (t *Table) isMarked(item string) bool { + return t.marks[item] } diff --git a/internal/ui/table_helper.go b/internal/ui/table_helper.go index c8f90b30..31c31004 100644 --- a/internal/ui/table_helper.go +++ b/internal/ui/table_helper.go @@ -12,25 +12,31 @@ import ( ) const ( - titleFmt = "[fg:bg:b] %s[fg:bg:-][[count:bg:b]%d[fg:bg:-]] " - searchFmt = "<[filter:bg:r]/%s[fg:bg:-]> " - nsTitleFmt = "[fg:bg:b] %s([hilite:bg:b]%s[fg:bg:-])[fg:bg:-][[count:bg:b]%d[fg:bg:-]][fg:bg:-] " - labelSelIndicator = "-l" - descIndicator = "↓" - ascIndicator = "↑" - fullFmat = "%s-%s-%d.csv" - noNSFmat = "%s-%d.csv" + // SearchFmt represents a filter view title. + SearchFmt = "<[filter:bg:r]/%s[fg:bg:-]> " + + titleFmt = "[fg:bg:b] %s[fg:bg:-][[count:bg:b]%d[fg:bg:-]] " + nsTitleFmt = "[fg:bg:b] %s([hilite:bg:b]%s[fg:bg:-])[fg:bg:-][[count:bg:b]%d[fg:bg:-]][fg:bg:-] " + descIndicator = "↓" + ascIndicator = "↑" + + // FullFmat specifies a namespaced dump file name. + FullFmat = "%s-%s-%d.csv" + + // NoNSFmat specifies a cluster wide dump file name. + NoNSFmat = "%s-%d.csv" ) var ( - cpuRX = regexp.MustCompile(`\A.{0,1}CPU`) - memRX = regexp.MustCompile(`\A.{0,1}MEM`) - labelCmd = regexp.MustCompile(`\A\-l`) + cpuRX = regexp.MustCompile(`\A.{0,1}CPU`) + memRX = regexp.MustCompile(`\A.{0,1}MEM`) + + // LabelCmd identifies a label query + LabelCmd = regexp.MustCompile(`\A\-l`) + fuzzyCmd = regexp.MustCompile(`\A\-f`) ) -type cleanseFn func(string) string - // TrimCell removes superfluous padding. func TrimCell(tv *Table, row, col int) string { c := tv.GetCell(row, col) @@ -41,13 +47,15 @@ func TrimCell(tv *Table, row, col int) string { return strings.TrimSpace(c.Text) } -func isLabelSelector(s string) bool { +// IsLabelSelector checks if query is a label query. +func IsLabelSelector(s string) bool { if s == "" { return false } - return labelCmd.MatchString(s) + return LabelCmd.MatchString(s) } +// IsFuzztySelector checks if query is fuzzy. func isFuzzySelector(s string) bool { if s == "" { return false @@ -55,7 +63,8 @@ func isFuzzySelector(s string) bool { return fuzzyCmd.MatchString(s) } -func trimLabelSelector(s string) string { +// TrimLabelSelector extracts label query. +func TrimLabelSelector(s string) string { return strings.TrimSpace(s[2:]) } diff --git a/internal/ui/table_helper_test.go b/internal/ui/table_helper_test.go new file mode 100644 index 00000000..1afe47bb --- /dev/null +++ b/internal/ui/table_helper_test.go @@ -0,0 +1,113 @@ +package ui + +import ( + "testing" + + "github.com/derailed/k9s/internal/resource" + "github.com/stretchr/testify/assert" +) + +func TestIsLabelSelector(t *testing.T) { + uu := map[string]struct { + sel string + e bool + }{ + "cool": {"-l app=fred,env=blee", true}, + "noMode": {"app=fred,env=blee", false}, + "noSpace": {"-lapp=fred,env=blee", true}, + "wrongLabel": {"-f app=fred,env=blee", false}, + } + + for k, u := range uu { + t.Run(k, func(t *testing.T) { + assert.Equal(t, u.e, IsLabelSelector(u.sel)) + }) + } +} + +func TestTrimLabelSelector(t *testing.T) { + uu := map[string]struct { + sel, e string + }{ + "cool": {"-l app=fred,env=blee", "app=fred,env=blee"}, + "noSpace": {"-lapp=fred,env=blee", "app=fred,env=blee"}, + } + + for k, u := range uu { + t.Run(k, func(t *testing.T) { + assert.Equal(t, u.e, TrimLabelSelector(u.sel)) + }) + } +} + +func TestTVSortRows(t *testing.T) { + uu := []struct { + rows resource.RowEvents + col int + asc bool + first resource.Row + e []string + }{ + { + resource.RowEvents{ + "row1": {Fields: resource.Row{"x", "y"}}, + "row2": {Fields: resource.Row{"a", "b"}}, + }, + 0, + true, + resource.Row{"a", "b"}, + []string{"row2", "row1"}, + }, + { + resource.RowEvents{ + "row1": {Fields: resource.Row{"x", "y"}}, + "row2": {Fields: resource.Row{"a", "b"}}, + }, + 1, + true, + resource.Row{"a", "b"}, + []string{"row2", "row1"}, + }, + { + resource.RowEvents{ + "row1": {Fields: resource.Row{"x", "y"}}, + "row2": {Fields: resource.Row{"a", "b"}}, + }, + 1, + false, + resource.Row{"x", "y"}, + []string{"row1", "row2"}, + }, + { + resource.RowEvents{ + "row1": {Fields: resource.Row{"2175h48m0.06015s", "y"}}, + "row2": {Fields: resource.Row{"403h42m34.060166s", "b"}}, + }, + 0, + true, + resource.Row{"403h42m34.060166s", "b"}, + []string{"row2", "row1"}, + }, + } + + for _, u := range uu { + keys := make([]string, len(u.rows)) + sortRows(u.rows, defaultSort, SortColumn{u.col, len(u.rows), u.asc}, keys) + assert.Equal(t, u.e, keys) + assert.Equal(t, u.first, u.rows[u.e[0]].Fields) + } +} + +func BenchmarkTableSortRows(b *testing.B) { + evts := resource.RowEvents{ + "row1": {Fields: resource.Row{"x", "y"}}, + "row2": {Fields: resource.Row{"a", "b"}}, + } + sc := SortColumn{0, 2, true} + keys := make([]string, len(evts)) + b.ResetTimer() + b.ReportAllocs() + for i := 0; i < b.N; i++ { + sortRows(evts, defaultSort, sc, keys) + } +} diff --git a/internal/ui/table_test.go b/internal/ui/table_test.go index dd9a62d2..d022305e 100644 --- a/internal/ui/table_test.go +++ b/internal/ui/table_test.go @@ -1,80 +1,78 @@ -package ui +package ui_test import ( + "context" "testing" + "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/resource" + "github.com/derailed/k9s/internal/ui" "github.com/stretchr/testify/assert" + "k8s.io/apimachinery/pkg/watch" ) -func TestTVSortRows(t *testing.T) { - uu := []struct { - rows resource.RowEvents - col int - asc bool - first resource.Row - e []string - }{ - { - resource.RowEvents{ - "row1": {Fields: resource.Row{"x", "y"}}, - "row2": {Fields: resource.Row{"a", "b"}}, - }, - 0, - true, - resource.Row{"a", "b"}, - []string{"row2", "row1"}, - }, - { - resource.RowEvents{ - "row1": {Fields: resource.Row{"x", "y"}}, - "row2": {Fields: resource.Row{"a", "b"}}, - }, - 1, - true, - resource.Row{"a", "b"}, - []string{"row2", "row1"}, - }, - { - resource.RowEvents{ - "row1": {Fields: resource.Row{"x", "y"}}, - "row2": {Fields: resource.Row{"a", "b"}}, - }, - 1, - false, - resource.Row{"x", "y"}, - []string{"row1", "row2"}, - }, - { - resource.RowEvents{ - "row1": {Fields: resource.Row{"2175h48m0.06015s", "y"}}, - "row2": {Fields: resource.Row{"403h42m34.060166s", "b"}}, - }, - 0, - true, - resource.Row{"403h42m34.060166s", "b"}, - []string{"row2", "row1"}, - }, - } +func TestTableNew(t *testing.T) { + v := ui.NewTable("fred") + s, _ := config.NewStyles("") + ctx := context.WithValue(context.Background(), ui.KeyStyles, s) + v.Init(ctx) + + assert.Equal(t, "fred", v.GetBaseTitle()) + + v.SetBaseTitle("bozo") + assert.Equal(t, "bozo", v.GetBaseTitle()) - for _, u := range uu { - keys := make([]string, len(u.rows)) - sortRows(u.rows, defaultSort, SortColumn{u.col, len(u.rows), u.asc}, keys) - assert.Equal(t, u.e, keys) - assert.Equal(t, u.first, u.rows[u.e[0]].Fields) - } } -func BenchmarkTableSortRows(b *testing.B) { - evts := resource.RowEvents{ - "row1": {Fields: resource.Row{"x", "y"}}, - "row2": {Fields: resource.Row{"a", "b"}}, - } - sc := SortColumn{0, 2, true} - keys := make([]string, len(evts)) - b.ResetTimer() - b.ReportAllocs() - for i := 0; i < b.N; i++ { - sortRows(evts, defaultSort, sc, keys) +func TestTableUpdate(t *testing.T) { + v := ui.NewTable("fred") + s, _ := config.NewStyles("") + ctx := context.WithValue(context.Background(), ui.KeyStyles, s) + v.Init(ctx) + + v.Update(makeTableData()) + + assert.Equal(t, 3, v.GetRowCount()) + assert.Equal(t, 3, v.GetColumnCount()) +} + +func TestTableSelection(t *testing.T) { + v := ui.NewTable("fred") + s, _ := config.NewStyles("") + ctx := context.WithValue(context.Background(), ui.KeyStyles, s) + v.Init(ctx) + + v.Update(makeTableData()) + + v.SelectRow(1, true) + assert.True(t, v.RowSelected()) + assert.Equal(t, resource.Row{"blee", "duh", "fred"}, v.GetRow()) + assert.Equal(t, "blee", v.GetSelectedCell(0)) + assert.Equal(t, 1, v.GetSelectedRowIndex()) + assert.Equal(t, []string{"blee/duh"}, v.GetSelectedItems()) + + v.ClearSelection() + v.SelectFirstRow() + assert.Equal(t, 1, v.GetSelectedRowIndex()) +} + +// Helpers... + +func makeTableData() resource.TableData { + return resource.TableData{ + Namespace: "", + Header: resource.Row{"a", "b", "c"}, + Rows: resource.RowEvents{ + "r1": &resource.RowEvent{ + Action: watch.Added, + Fields: resource.Row{"blee", "duh", "fred"}, + Deltas: resource.Row{"", "", ""}, + }, + "r2": &resource.RowEvent{ + Action: watch.Added, + Fields: resource.Row{"fred", "duh", "zorg"}, + Deltas: resource.Row{"", "", ""}, + }, + }, } } diff --git a/internal/view/alias.go b/internal/view/alias.go new file mode 100644 index 00000000..a0a24b39 --- /dev/null +++ b/internal/view/alias.go @@ -0,0 +1,141 @@ +package view + +import ( + "context" + "fmt" + "strings" + + "github.com/derailed/k9s/internal/k8s" + "github.com/derailed/k9s/internal/resource" + "github.com/derailed/k9s/internal/ui" + "github.com/gdamore/tcell" +) + +const ( + aliasTitle = "Aliases" + aliasTitleFmt = " [mediumseagreen::b]%s([fuchsia::b]%d[fuchsia::-][mediumseagreen::-]) " +) + +// Alias represents a command alias view. +type Alias struct { + *Table +} + +// NewAlias returns a new alias view. +func NewAlias() *Alias { + return &Alias{ + Table: NewTable(aliasTitle), + } +} + +// Init the view. +func (a *Alias) Init(ctx context.Context) { + a.Table.Init(ctx) + + a.SetBorderFocusColor(tcell.ColorMediumSpringGreen) + a.SetSelectedStyle(tcell.ColorWhite, tcell.ColorMediumSpringGreen, tcell.AttrNone) + a.SetColorerFn(aliasColorer) + a.SetActiveNS("") + a.registerActions() + + a.Update(a.hydrate()) + a.resetTitle() +} + +func (a *Alias) Name() string { + return aliasTitle +} + +func (a *Alias) Start() {} +func (a *Alias) Stop() {} + +func (a *Alias) registerActions() { + a.RmAction(ui.KeyShiftA) + a.RmAction(ui.KeyShiftN) + a.RmAction(tcell.KeyCtrlS) + + a.AddActions(ui.KeyActions{ + tcell.KeyEnter: ui.NewKeyAction("Goto", a.gotoCmd, true), + tcell.KeyEscape: ui.NewKeyAction("Reset", a.resetCmd, false), + ui.KeySlash: ui.NewKeyAction("Filter", a.activateCmd, false), + ui.KeyShiftR: ui.NewKeyAction("Sort Resource", a.SortColCmd(0), false), + ui.KeyShiftC: ui.NewKeyAction("Sort Command", a.SortColCmd(1), false), + ui.KeyShiftA: ui.NewKeyAction("Sort ApiGroup", a.SortColCmd(2), false), + }) +} + +func (a *Alias) getTitle() string { + return aliasTitle +} + +func (a *Alias) resetCmd(evt *tcell.EventKey) *tcell.EventKey { + if !a.SearchBuff().Empty() { + a.SearchBuff().Reset() + return nil + } + + return a.backCmd(evt) +} + +func (a *Alias) gotoCmd(evt *tcell.EventKey) *tcell.EventKey { + r, _ := a.GetSelection() + if r != 0 { + s := ui.TrimCell(a.Table.Table, r, 1) + tokens := strings.Split(s, ",") + a.app.gotoResource(tokens[0], true) + return nil + } + + if a.SearchBuff().IsActive() { + return a.activateCmd(evt) + } + + return evt +} + +func (a *Alias) backCmd(evt *tcell.EventKey) *tcell.EventKey { + if a.SearchBuff().IsActive() { + a.SearchBuff().Reset() + } else { + a.app.Content.Pop() + } + + return nil +} + +func (a *Alias) hydrate() resource.TableData { + data := resource.TableData{ + Header: resource.Row{"RESOURCE", "COMMAND", "APIGROUP"}, + Rows: make(resource.RowEvents, len(aliases.Alias)), + Namespace: resource.NotNamespaced, + } + + aa := make(map[string][]string, len(aliases.Alias)) + for alias, gvr := range aliases.Alias { + if _, ok := aa[gvr]; ok { + aa[gvr] = append(aa[gvr], alias) + } else { + aa[gvr] = []string{alias} + } + } + + for gvr, aliases := range aa { + g := k8s.GVR(gvr) + fields := resource.Row{ + ui.Pad(g.ToR(), 30), + ui.Pad(strings.Join(aliases, ","), 70), + ui.Pad(g.ToG(), 30), + } + data.Rows[string(gvr)] = &resource.RowEvent{ + Action: resource.New, + Fields: fields, + Deltas: fields, + } + } + + return data +} + +func (a *Alias) resetTitle() { + a.SetTitle(fmt.Sprintf(aliasTitleFmt, aliasTitle, a.GetRowCount()-1)) +} diff --git a/internal/view/alias_test.go b/internal/view/alias_test.go new file mode 100644 index 00000000..e9cd01da --- /dev/null +++ b/internal/view/alias_test.go @@ -0,0 +1,90 @@ +package view_test + +import ( + "context" + "testing" + + "github.com/derailed/k9s/internal/config" + "github.com/derailed/k9s/internal/ui" + "github.com/derailed/k9s/internal/view" + "github.com/gdamore/tcell" + "github.com/stretchr/testify/assert" + v1 "k8s.io/api/core/v1" +) + +func TestAliasNew(t *testing.T) { + v := view.NewAlias() + v.Init(makeContext()) + + assert.Equal(t, 3, v.GetColumnCount()) + assert.Equal(t, 16, v.GetRowCount()) + assert.Equal(t, "Aliases", v.Name()) + assert.Equal(t, 10, len(v.Hints())) +} + +func TestAliasSearch(t *testing.T) { + v := view.NewAlias() + v.Init(makeContext()) + v.SearchBuff().SetActive(true) + v.SearchBuff().Set("dump") + + v.SendKey(tcell.NewEventKey(tcell.KeyRune, 'd', tcell.ModNone)) + + assert.Equal(t, 3, v.GetColumnCount()) + assert.Equal(t, 1, v.GetRowCount()) +} + +func TestAliasGoto(t *testing.T) { + v := view.NewAlias() + v.Init(makeContext()) + v.Select(0, 0) + + b := buffL{} + v.SearchBuff().SetActive(true) + v.SearchBuff().AddListener(&b) + v.SendKey(tcell.NewEventKey(tcell.KeyEnter, 256, tcell.ModNone)) + + assert.True(t, v.SearchBuff().IsActive()) +} + +// Helpers... + +type buffL struct { + active int + changed int +} + +func (b *buffL) BufferChanged(s string) { + b.changed++ +} +func (b *buffL) BufferActive(state bool, kind ui.BufferKind) { + b.active++ +} + +func makeContext() context.Context { + a := view.NewApp(config.NewConfig(ks{})) + ctx := context.WithValue(context.Background(), ui.KeyApp, a) + return context.WithValue(ctx, ui.KeyStyles, a.Styles) +} + +type ks struct{} + +func (k ks) CurrentContextName() (string, error) { + return "test", nil +} + +func (k ks) CurrentClusterName() (string, error) { + return "test", nil +} + +func (k ks) CurrentNamespaceName() (string, error) { + return "test", nil +} + +func (k ks) ClusterNames() ([]string, error) { + return []string{"test"}, nil +} + +func (k ks) NamespaceNames(nn []v1.Namespace) []string { + return []string{"test"} +} diff --git a/internal/views/app.go b/internal/view/app.go similarity index 62% rename from internal/views/app.go rename to internal/view/app.go index ea56e453..eda7275c 100644 --- a/internal/views/app.go +++ b/internal/view/app.go @@ -1,4 +1,4 @@ -package views +package view import ( "context" @@ -7,6 +7,7 @@ import ( "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/k8s" + "github.com/derailed/k9s/internal/model" "github.com/derailed/k9s/internal/resource" "github.com/derailed/k9s/internal/ui" "github.com/derailed/k9s/internal/watch" @@ -23,47 +24,50 @@ const ( indicatorFmt = "[orange::b]K9s [aqua::]%s [white::]%s:%s:%s [lawngreen::]%s%%[white::]::[darkturquoise::]%s%%" ) -type ( - focusHandler func(tview.Primitive) +// ActionsFunc augments Keybindinga. +type ActionsFunc func(ui.KeyActions) - forwarder interface { - Start(path, co string, ports []string) (*portforward.PortForwarder, error) - Stop() - Path() string - Container() string - Ports() []string - Active() bool - Age() string - } +type focusHandler func(tview.Primitive) - resourceViewer interface { - ui.Igniter +type forwarder interface { + Start(path, co string, ports []string) (*portforward.PortForwarder, error) + Stop() + Path() string + Container() string + Ports() []string + Active() bool + Age() string +} - setEnterFn(enterFn) - setColorerFn(ui.ColorerFunc) - setDecorateFn(decorateFn) - setExtraActionsFn(ui.ActionsFunc) - masterPage() *tableView - } +// ResourceViewer represents a generic resource viewer. +type ResourceViewer interface { + model.Component - appView struct { - *ui.App + setEnterFn(enterFn) + setColorerFn(ui.ColorerFunc) + setDecorateFn(decorateFn) + setExtraActionsFn(ActionsFunc) + masterPage() *Table +} - command *command - cancel context.CancelFunc - informer *watch.Informer - stopCh chan struct{} - forwarders map[string]forwarder - version string - showHeader bool - filter string - } -) +// App represents an application view. +type App struct { + *ui.App + + Content *PageStack + command *command + informer *watch.Informer + stopCh chan struct{} + forwarders map[string]forwarder + version string + showHeader bool +} // NewApp returns a K9s app instance. -func NewApp(cfg *config.Config) *appView { - v := appView{ +func NewApp(cfg *config.Config) *App { + v := App{ App: ui.NewApp(), + Content: NewPageStack(), forwarders: make(map[string]forwarder), } v.Config = cfg @@ -71,17 +75,30 @@ func NewApp(cfg *config.Config) *appView { v.command = newCommand(&v) v.Views()["indicator"] = ui.NewIndicatorView(v.App, v.Styles) - v.Views()["flash"] = ui.NewFlashView(v.Application, "Initializing...") v.Views()["clusterInfo"] = newClusterInfoView(&v, k8s.NewMetricsServer(cfg.GetConnection())) return &v } -func (a *appView) Init(version string, rate int) { +// ActiveView returns the currently active view. +func (a *App) ActiveView() model.Component { + return a.Content.GetPrimitive("main").(model.Component) +} + +func (a *App) PrevCmd(evt *tcell.EventKey) *tcell.EventKey { + a.Content.Pop() + + return nil +} + +func (a *App) Init(version string, rate int) { + ctx := context.WithValue(context.Background(), ui.KeyApp, a) + a.Content.Init(ctx) + a.Content.Stack.AddListener(a.Crumbs()) + a.version = version a.CmdBuff().AddListener(a) a.App.Init() - a.AddActions(ui.KeyActions{ ui.KeyH: ui.NewKeyAction("ToggleHeader", a.toggleHeaderCmd, false), ui.KeyHelp: ui.NewKeyAction("Help", a.helpCmd, false), @@ -101,24 +118,52 @@ func (a *appView) Init(version string, rate int) { } } - main := tview.NewFlex() - main.SetDirection(tview.FlexRow) - a.Main().AddPage("main", main, true, false) - a.Main().AddPage("splash", ui.NewSplash(a.Styles, version), true, true) + main := tview.NewFlex().SetDirection(tview.FlexRow) + a.Main.AddPage("main", main, true, false) + a.Main.AddPage("splash", ui.NewSplash(a.Styles, version), true, true) + + // ctx := context.WithValue(context.Background(), ui.KeyApp, a) + // a.Content.Init(ctx) + // d := NewDetails(a, nil) + // d.SetText("Fuck!!") + // a.Content.Push(d) + // d = NewDetails(a, nil) + // d.SetText("Shit!!") + // a.Content.Push(d) main.AddItem(a.indicator(), 1, 1, false) - main.AddItem(a.Frame(), 0, 10, true) + main.AddItem(a.Content, 0, 10, true) main.AddItem(a.Crumbs(), 2, 1, false) main.AddItem(a.Flash(), 2, 1, false) a.toggleHeader(!a.Config.K9s.GetHeadless()) } +// func (a *App) StackPushed(c model.Component) { +// ctx := context.WithValue(context.Background(), ui.KeyApp, a) +// ctx, a.cancelFn = context.WithCancel(context.Background()) +// c.Init(ctx) + +// a.Frame().AddPage(c.Name(), c, true, true) +// a.SetFocus(c) +// a.setHints(c.Hints()) +// } + +// func (a *App) StackPopped(o, c model.Component) { +// a.Frame().RemovePage(o.Name()) +// if c != nil { +// a.StackPushed(c) +// } +// } + +// func (a *App) StackTop(model.Component) { +// } + // Changed indicates the buffer was changed. -func (a *appView) BufferChanged(s string) {} +func (a *App) BufferChanged(s string) {} // Active indicates the buff activity changed. -func (a *appView) BufferActive(state bool, _ ui.BufferKind) { - flex, ok := a.Main().GetPrimitive("main").(*tview.Flex) +func (a *App) BufferActive(state bool, _ ui.BufferKind) { + flex, ok := a.Main.GetPrimitive("main").(*tview.Flex) if !ok { return } @@ -130,9 +175,9 @@ func (a *appView) BufferActive(state bool, _ ui.BufferKind) { a.Draw() } -func (a *appView) toggleHeader(flag bool) { +func (a *App) toggleHeader(flag bool) { a.showHeader = flag - flex := a.Main().GetPrimitive("main").(*tview.Flex) + flex := a.Main.GetPrimitive("main").(*tview.Flex) if a.showHeader { flex.RemoveItemAtIndex(0) flex.AddItemAtIndex(0, a.buildHeader(), 7, 1, false) @@ -143,7 +188,7 @@ func (a *appView) toggleHeader(flag bool) { } } -func (a *appView) buildHeader() tview.Primitive { +func (a *App) buildHeader() tview.Primitive { header := tview.NewFlex() header.SetBorderPadding(0, 0, 1, 1) header.SetDirection(tview.FlexColumn) @@ -157,7 +202,7 @@ func (a *appView) buildHeader() tview.Primitive { return header } -func (a *appView) clusterUpdater(ctx context.Context) { +func (a *App) clusterUpdater(ctx context.Context) { for { select { case <-ctx.Done(): @@ -175,7 +220,7 @@ func (a *appView) clusterUpdater(ctx context.Context) { } } -func (a *appView) refreshIndicator() { +func (a *App) refreshIndicator() { mx := k8s.NewMetricsServer(a.Conn()) cluster := resource.NewCluster(a.Conn(), &log.Logger, mx) var cmx k8s.ClusterMetrics @@ -205,7 +250,7 @@ func (a *appView) refreshIndicator() { a.indicator().SetPermanent(info) } -func (a *appView) switchNS(ns string) bool { +func (a *App) switchNS(ns string) bool { if ns == resource.AllNamespace { ns = resource.AllNamespaces } @@ -218,7 +263,7 @@ func (a *appView) switchNS(ns string) bool { return a.startInformer(ns) } -func (a *appView) switchCtx(ctx string, load bool) error { +func (a *App) switchCtx(ctx string, load bool) error { l := resource.NewContext(a.Conn()) if err := l.Switch(ctx); err != nil { return err @@ -240,7 +285,7 @@ func (a *appView) switchCtx(ctx string, load bool) error { return nil } -func (a *appView) startInformer(ns string) bool { +func (a *App) startInformer(ns string) bool { if a.stopCh != nil { close(a.stopCh) a.stopCh = nil @@ -263,21 +308,18 @@ func (a *appView) startInformer(ns string) bool { } // BailOut exists the application. -func (a *appView) BailOut() { +func (a *App) BailOut() { if a.stopCh != nil { log.Debug().Msg("<<<< Stopping Watcher") close(a.stopCh) a.stopCh = nil } - if a.cancel != nil { - a.cancel() - } a.stopForwarders() a.App.BailOut() } -func (a *appView) stopForwarders() { +func (a *App) stopForwarders() { for k, f := range a.forwarders { log.Debug().Msgf("Deleting forwarder %s", f.Path()) f.Stop() @@ -286,7 +328,7 @@ func (a *appView) stopForwarders() { } // Run starts the application loop -func (a *appView) Run() { +func (a *App) Run() { ctx, cancel := context.WithCancel(context.Background()) defer cancel() go a.clusterUpdater(ctx) @@ -301,7 +343,7 @@ func (a *appView) Run() { go func() { <-time.After(splashTime * time.Second) a.QueueUpdateDraw(func() { - a.Main().SwitchToPage("main") + a.Main.SwitchToPage("main") }) }() @@ -311,7 +353,7 @@ func (a *appView) Run() { } } -func (a *appView) status(l ui.FlashLevel, msg string) { +func (a *App) status(l ui.FlashLevel, msg string) { a.Flash().Info(msg) if a.Config.K9s.GetHeadless() { a.setIndicator(l, msg) @@ -321,7 +363,7 @@ func (a *appView) status(l ui.FlashLevel, msg string) { a.Draw() } -func (a *appView) setLogo(l ui.FlashLevel, msg string) { +func (a *App) setLogo(l ui.FlashLevel, msg string) { switch l { case ui.FlashErr: a.Logo().Err(msg) @@ -335,7 +377,7 @@ func (a *appView) setLogo(l ui.FlashLevel, msg string) { a.Draw() } -func (a *appView) setIndicator(l ui.FlashLevel, msg string) { +func (a *App) setIndicator(l ui.FlashLevel, msg string) { switch l { case ui.FlashErr: a.indicator().Err(msg) @@ -349,7 +391,7 @@ func (a *appView) setIndicator(l ui.FlashLevel, msg string) { a.Draw() } -func (a *appView) toggleHeaderCmd(evt *tcell.EventKey) *tcell.EventKey { +func (a *App) toggleHeaderCmd(evt *tcell.EventKey) *tcell.EventKey { if a.Cmd().InCmdMode() { return evt } @@ -361,16 +403,7 @@ func (a *appView) toggleHeaderCmd(evt *tcell.EventKey) *tcell.EventKey { return nil } -func (a *appView) prevCmd(evt *tcell.EventKey) *tcell.EventKey { - if top, ok := a.command.previousCmd(); ok { - log.Debug().Msgf("Previous command %s", top) - a.gotoResource(top, false) - return nil - } - return evt -} - -func (a *appView) gotoCmd(evt *tcell.EventKey) *tcell.EventKey { +func (a *App) gotoCmd(evt *tcell.EventKey) *tcell.EventKey { if a.CmdBuff().IsActive() && !a.CmdBuff().Empty() { a.gotoResource(a.GetCmd(), true) a.ResetCmd() @@ -381,53 +414,37 @@ func (a *appView) gotoCmd(evt *tcell.EventKey) *tcell.EventKey { return evt } -func (a *appView) helpCmd(evt *tcell.EventKey) *tcell.EventKey { - if _, ok := a.Frame().GetPrimitive("main").(*helpView); ok { +func (a *App) helpCmd(evt *tcell.EventKey) *tcell.EventKey { + if _, ok := a.Content.GetPrimitive("main").(*Help); ok { return evt } - h := newHelpView(a, a.ActiveView(), a.GetHints()) - a.inject(h) + a.inject(NewHelp()) return nil } -func (a *appView) aliasCmd(evt *tcell.EventKey) *tcell.EventKey { - if _, ok := a.Frame().GetPrimitive("main").(*aliasView); ok { +func (a *App) aliasCmd(evt *tcell.EventKey) *tcell.EventKey { + if _, ok := a.Content.GetPrimitive("main").(*Alias); ok { return evt } - a.inject(newAliasView(a, a.ActiveView())) + a.inject(NewAlias()) return nil } -func (a *appView) gotoResource(res string, record bool) bool { - if a.cancel != nil { - a.cancel() - } - valid := a.command.run(res) - if valid && record { - a.command.pushCmd(res) - } - - return valid +func (a *App) gotoResource(res string, record bool) bool { + return a.command.run(res) } -func (a *appView) inject(i ui.Igniter) { - if a.cancel != nil { - a.cancel() - } - a.Frame().RemovePage("main") - var ctx context.Context - ctx, a.cancel = context.WithCancel(context.Background()) - i.Init(ctx, a.Config.ActiveNamespace()) - a.Frame().AddPage("main", i, true, true) - a.SetFocus(i) +func (a *App) inject(c model.Component) { + log.Debug().Msgf("Injecting component %#v", c) + a.Content.Push(c) } -func (a *appView) clusterInfo() *clusterInfoView { +func (a *App) clusterInfo() *clusterInfoView { return a.Views()["clusterInfo"].(*clusterInfoView) } -func (a *appView) indicator() *ui.IndicatorView { +func (a *App) indicator() *ui.IndicatorView { return a.Views()["indicator"].(*ui.IndicatorView) } diff --git a/internal/view/app_test.go b/internal/view/app_test.go new file mode 100644 index 00000000..0886260a --- /dev/null +++ b/internal/view/app_test.go @@ -0,0 +1,17 @@ +package view_test + +// import ( +// "testing" + +// "github.com/derailed/k9s/internal/config" +// "github.com/derailed/k9s/internal/view" +// "github.com/stretchr/testify/assert" +// ) + +// func TestAppNew(t *testing.T) { +// a := view.NewApp(config.NewConfig(ks{})) +// a.Init("blee", 10) + +// assert.Equal(t, 11, len(a.GetActions())) +// assert.Equal(t, false, a.HasSkins) +// } diff --git a/internal/views/bench.go b/internal/view/bench.go similarity index 60% rename from internal/views/bench.go rename to internal/view/bench.go index 1e6f8d46..fc1ddfe6 100644 --- a/internal/views/bench.go +++ b/internal/view/bench.go @@ -1,4 +1,4 @@ -package views +package view import ( "context" @@ -12,6 +12,7 @@ import ( "time" "github.com/derailed/k9s/internal/config" + "github.com/derailed/k9s/internal/model" "github.com/derailed/k9s/internal/perf" "github.com/derailed/k9s/internal/resource" "github.com/derailed/k9s/internal/ui" @@ -34,72 +35,86 @@ var ( benchHeader = resource.Row{"NAMESPACE", "NAME", "STATUS", "TIME", "REQ/S", "2XX", "4XX/5XX", "REPORT", "AGE"} ) -type benchView struct { - *masterDetail +// Bench represents a service benchmark results view. +type Bench struct { + *MasterDetail - app *appView + cancelFn context.CancelFunc } -func newBenchView(title, gvr string, app *appView, _ resource.List) resourceViewer { - v := benchView{app: app} - v.masterDetail = newMasterDetail(benchTitle, "", app, v.backCmd) - v.keyBindings() - - return &v +func NewBench(title, gvr string, _ resource.List) ResourceViewer { + return &Bench{ + MasterDetail: NewMasterDetail(), + } } // Init the view. -func (v *benchView) Init(ctx context.Context, ns string) { - v.masterDetail.init(ctx, ns) +func (b *Bench) Init(ctx context.Context) { + b.MasterDetail.Init(ctx) + b.keyBindings() - tv := v.masterPage() + tv := b.masterPage() tv.SetBorderFocusColor(tcell.ColorSeaGreen) tv.SetSelectedStyle(tcell.ColorWhite, tcell.ColorSeaGreen, tcell.AttrNone) tv.SetColorerFn(benchColorer) - dv := v.detailsPage() + dv := b.detailsPage() dv.setCategory("Bench") dv.SetTextColor(tcell.ColorSeaGreen) - if err := v.watchBenchDir(ctx); err != nil { - v.app.Flash().Errf("Unable to watch benchmarks directory %s", err) - } - - v.refresh() + b.Start() + b.refresh() tv.SetSortCol(tv.NameColIndex()+7, 0, true) tv.Refresh() tv.Select(1, 0) - v.app.SetFocus(tv) - v.app.SetHints(tv.Hints()) } -func (v *benchView) setEnterFn(enterFn) {} -func (v *benchView) setColorerFn(ui.ColorerFunc) {} -func (v *benchView) setDecorateFn(decorateFn) {} -func (v *benchView) setExtraActionsFn(ui.ActionsFunc) {} +func (b *Bench) Start() { + var ctx context.Context -func (v *benchView) refresh() { - tv := v.masterPage() - tv.Update(v.hydrate()) + ctx, b.cancelFn = context.WithCancel(context.Background()) + if err := b.watchBenchDir(ctx); err != nil { + b.app.Flash().Errf("Unable to watch benchmarks directory %s", err) + } +} + +func (b *Bench) Stop() { + if b.cancelFn != nil { + b.cancelFn() + } +} + +func (b *Bench) Name() string { + return "benchmarks" +} + +func (b *Bench) setEnterFn(enterFn) {} +func (b *Bench) setColorerFn(ui.ColorerFunc) {} +func (b *Bench) setDecorateFn(decorateFn) {} +func (b *Bench) setExtraActionsFn(ActionsFunc) {} + +func (b *Bench) refresh() { + tv := b.masterPage() + tv.Update(b.hydrate()) tv.UpdateTitle() } -func (v *benchView) keyBindings() { +func (b *Bench) keyBindings() { aa := ui.KeyActions{ - ui.KeyP: ui.NewKeyAction("Previous", v.app.prevCmd, false), - tcell.KeyEnter: ui.NewKeyAction("Enter", v.enterCmd, false), - tcell.KeyCtrlD: ui.NewKeyAction("Delete", v.deleteCmd, false), + ui.KeyP: ui.NewKeyAction("Previous", b.app.PrevCmd, false), + tcell.KeyEnter: ui.NewKeyAction("Enter", b.enterCmd, false), + tcell.KeyCtrlD: ui.NewKeyAction("Delete", b.deleteCmd, false), } - v.masterPage().SetActions(aa) + b.masterPage().AddActions(aa) } -func (v *benchView) getTitle() string { +func (b *Bench) getTitle() string { return benchTitle } -func (v *benchView) sortColCmd(col int, asc bool) func(evt *tcell.EventKey) *tcell.EventKey { +func (b *Bench) sortColCmd(col int, asc bool) func(evt *tcell.EventKey) *tcell.EventKey { return func(evt *tcell.EventKey) *tcell.EventKey { - tv := v.masterPage() + tv := b.masterPage() tv.SetSortCol(tv.NameColIndex()+col, 0, asc) tv.Refresh() @@ -107,69 +122,73 @@ func (v *benchView) sortColCmd(col int, asc bool) func(evt *tcell.EventKey) *tce } } -func (v *benchView) enterCmd(evt *tcell.EventKey) *tcell.EventKey { - if v.masterPage().SearchBuff().IsActive() { - return v.masterPage().filterCmd(evt) +func (b *Bench) enterCmd(evt *tcell.EventKey) *tcell.EventKey { + if b.masterPage().SearchBuff().IsActive() { + return b.masterPage().filterCmd(evt) } - if !v.masterPage().RowSelected() { + if !b.masterPage().RowSelected() { return nil } - data, err := readBenchFile(v.app.Config, v.benchFile()) + data, err := readBenchFile(b.app.Config, b.benchFile()) if err != nil { - v.app.Flash().Errf("Unable to load bench file %s", err) + b.app.Flash().Errf("Unable to load bench file %s", err) return nil } - vu := v.detailsPage() + vu := b.detailsPage() vu.SetText(data) - vu.setTitle(v.masterPage().GetSelectedItem()) - v.showDetails() + vu.setTitle(b.masterPage().GetSelectedItem()) + b.showDetails() return nil } -func (v *benchView) deleteCmd(evt *tcell.EventKey) *tcell.EventKey { - if !v.masterPage().RowSelected() { +func (b *Bench) deleteCmd(evt *tcell.EventKey) *tcell.EventKey { + if !b.masterPage().RowSelected() { return nil } - sel, file := v.masterPage().GetSelectedItem(), v.benchFile() - dir := filepath.Join(perf.K9sBenchDir, v.app.Config.K9s.CurrentCluster) - showModal(v.Pages, fmt.Sprintf("Delete benchmark `%s?", file), "master", func() { + sel, file := b.masterPage().GetSelectedItem(), b.benchFile() + dir := filepath.Join(perf.K9sBenchDir, b.app.Config.K9s.CurrentCluster) + showModal(b.Pages, fmt.Sprintf("Delete benchmark `%s?", file), "master", func() { if err := os.Remove(filepath.Join(dir, file)); err != nil { - v.app.Flash().Errf("Unable to delete file %s", err) + b.app.Flash().Errf("Unable to delete file %s", err) return } - v.app.Flash().Infof("Benchmark %s deleted!", sel) + b.app.Flash().Infof("Benchmark %s deleted!", sel) }) return nil } -func (v *benchView) backCmd(evt *tcell.EventKey) *tcell.EventKey { - v.showMaster() +func (b *Bench) backCmd(evt *tcell.EventKey) *tcell.EventKey { + b.showMaster() return nil } -func (v *benchView) benchFile() string { - r := v.masterPage().GetSelectedRow() - return ui.TrimCell(v.masterPage().Table, r, 7) +func (b *Bench) benchFile() string { + r := b.masterPage().GetSelectedRowIndex() + return ui.TrimCell(b.masterPage().Table, r, 7) } -func (v *benchView) hints() ui.Hints { - return v.CurrentPage().Item.(ui.Hinter).Hints() +func (b *Bench) Hints() model.MenuHints { + if h, ok := b.CurrentPage().Item.(model.Hinter); ok { + return h.Hints() + } + + return nil } -func (v *benchView) hydrate() resource.TableData { - ff, err := loadBenchDir(v.app.Config) +func (b *Bench) hydrate() resource.TableData { + ff, err := loadBenchDir(b.app.Config) if err != nil { - v.app.Flash().Errf("Unable to read bench directory %s", err) + b.app.Flash().Errf("Unable to read bench directory %s", err) } data := initTable() for _, f := range ff { - bench, err := readBenchFile(v.app.Config, f.Name()) + bench, err := readBenchFile(b.app.Config, f.Name()) if err != nil { log.Error().Err(err).Msgf("Unable to load bench file %s", f.Name()) continue @@ -203,7 +222,7 @@ func initRow(row resource.Row, f os.FileInfo) error { return nil } -func (v *benchView) watchBenchDir(ctx context.Context) error { +func (b *Bench) watchBenchDir(ctx context.Context) error { w, err := fsnotify.NewWatcher() if err != nil { return err @@ -214,8 +233,8 @@ func (v *benchView) watchBenchDir(ctx context.Context) error { select { case evt := <-w.Events: log.Debug().Msgf("Bench event %#v", evt) - v.app.QueueUpdateDraw(func() { - v.refresh() + b.app.QueueUpdateDraw(func() { + b.refresh() }) case err := <-w.Errors: log.Info().Err(err).Msg("Dir Watcher failed") @@ -228,7 +247,7 @@ func (v *benchView) watchBenchDir(ctx context.Context) error { } }() - return w.Add(benchDir(v.app.Config)) + return w.Add(benchDir(b.app.Config)) } // ---------------------------------------------------------------------------- diff --git a/internal/views/bench_test.go b/internal/view/bench_test.go similarity index 98% rename from internal/views/bench_test.go rename to internal/view/bench_test.go index 17109bc8..0a3b19a1 100644 --- a/internal/views/bench_test.go +++ b/internal/view/bench_test.go @@ -1,4 +1,4 @@ -package views +package view import ( "io/ioutil" diff --git a/internal/views/cluster_info.go b/internal/view/cluster_info.go similarity index 95% rename from internal/views/cluster_info.go rename to internal/view/cluster_info.go index 8538ee4e..3c4768f7 100644 --- a/internal/views/cluster_info.go +++ b/internal/view/cluster_info.go @@ -1,4 +1,4 @@ -package views +package view import ( "strings" @@ -17,7 +17,7 @@ import ( type clusterInfoView struct { *tview.Table - app *appView + app *App mxs resource.MetricsServer } @@ -32,7 +32,7 @@ type ClusterInfo interface { CurrentMEM() float64 } -func newClusterInfoView(app *appView, mx resource.MetricsServer) *clusterInfoView { +func newClusterInfoView(app *App, mx resource.MetricsServer) *clusterInfoView { return &clusterInfoView{ app: app, Table: tview.NewTable(), @@ -125,7 +125,7 @@ func (v *clusterInfoView) refresh() { v.refreshMetrics(cluster, row) } -func fetchResources(app *appView) (k8s.Collection, k8s.Collection, error) { +func fetchResources(app *App) (k8s.Collection, k8s.Collection, error) { nos, err := app.informer.List(watch.NodeIndex, "", metav1.ListOptions{}) if err != nil { return nil, nil, err diff --git a/internal/views/colorer.go b/internal/view/colorer.go similarity index 99% rename from internal/views/colorer.go rename to internal/view/colorer.go index 48b89bb1..68e548f5 100644 --- a/internal/views/colorer.go +++ b/internal/view/colorer.go @@ -1,4 +1,4 @@ -package views +package view import ( "strings" diff --git a/internal/views/colorer_test.go b/internal/view/colorer_test.go similarity index 99% rename from internal/views/colorer_test.go rename to internal/view/colorer_test.go index d3df1d41..a178d24c 100644 --- a/internal/views/colorer_test.go +++ b/internal/view/colorer_test.go @@ -1,4 +1,4 @@ -package views +package view import ( "testing" diff --git a/internal/views/command.go b/internal/view/command.go similarity index 70% rename from internal/views/command.go rename to internal/view/command.go index 63f5cd06..d509f987 100644 --- a/internal/views/command.go +++ b/internal/view/command.go @@ -1,4 +1,4 @@ -package views +package view import ( "fmt" @@ -6,46 +6,27 @@ import ( "strings" "github.com/derailed/k9s/internal/k8s" + "github.com/derailed/k9s/internal/model" "github.com/derailed/k9s/internal/resource" - "github.com/derailed/k9s/internal/ui" "github.com/rs/zerolog/log" ) -type subjectViewer interface { - resourceViewer +type SubjectViewer interface { + ResourceViewer setSubject(s string) } type command struct { - app *appView - history *ui.CmdStack + app *App } -func newCommand(app *appView) *command { - return &command{app: app, history: ui.NewCmdStack()} +func newCommand(app *App) *command { + return &command{app: app} } -func (c *command) lastCmd() bool { - return c.history.Last() -} - -func (c *command) pushCmd(cmd string) { - c.history.Push(cmd) - c.app.Crumbs().Refresh(c.history.Items()) -} - -func (c *command) previousCmd() (string, bool) { - c.history.Pop() - c.app.Crumbs().Refresh(c.history.Items()) - - return c.history.Top() -} - -// DefaultCmd reset default command ie show pods. func (c *command) defaultCmd() { cmd := c.app.Config.ActiveView() - c.pushCmd(cmd) if !c.run(cmd) { log.Error().Err(fmt.Errorf("Unable to load command %s", cmd)).Msg("Command failed") } @@ -71,7 +52,7 @@ func (c *command) isK9sCmd(cmd string) bool { } tokens := authRX.FindAllStringSubmatch(cmd, -1) if len(tokens) == 1 && len(tokens[0]) == 3 { - c.app.inject(newPolicyView(c.app, tokens[0][1], tokens[0][2])) + c.app.inject(NewPolicy(c.app, tokens[0][1], tokens[0][2])) return true } } @@ -122,7 +103,7 @@ func (c *command) run(cmd string) bool { c.app.switchCtx(cmds[1], true) return true } - view := c.viewerFor(gvr, v) + view := c.componentFor(gvr, v) return c.exec(gvr, "", view) default: ns := c.app.Config.ActiveNamespace() @@ -132,23 +113,21 @@ func (c *command) run(cmd string) bool { if !c.app.switchNS(ns) { return false } - return c.exec(gvr, ns, c.viewerFor(gvr, v)) + return c.exec(gvr, ns, c.componentFor(gvr, v)) } - - return false } -func (c *command) viewerFor(gvr string, v *viewer) resourceViewer { +func (c *command) componentFor(gvr string, v *viewer) ResourceViewer { var r resource.List if v.listFn != nil { r = v.listFn(c.app.Conn(), resource.DefaultNamespace) } - var view resourceViewer + var view ResourceViewer if v.viewFn != nil { - view = v.viewFn(v.kind, gvr, c.app, r) + view = v.viewFn(v.kind, gvr, r) } else { - view = newResourceView(v.kind, gvr, c.app, r) + view = NewResource(v.kind, gvr, r) } if v.colorerFn != nil { view.setColorerFn(v.colorerFn) @@ -163,9 +142,9 @@ func (c *command) viewerFor(gvr string, v *viewer) resourceViewer { return view } -func (c *command) exec(gvr string, ns string, v ui.Igniter) bool { - if v == nil { - log.Error().Err(fmt.Errorf("No igniter given for %s", gvr)) +func (c *command) exec(gvr string, ns string, comp model.Component) bool { + if comp == nil { + log.Error().Err(fmt.Errorf("No component given for %s", gvr)) return false } @@ -173,8 +152,10 @@ func (c *command) exec(gvr string, ns string, v ui.Igniter) bool { c.app.Flash().Infof("Viewing %s resource...", g.ToR()) log.Debug().Msgf("Running command %s", gvr) c.app.Config.SetActiveView(g.ToR()) - c.app.Config.Save() - c.app.inject(v) + if err := c.app.Config.Save(); err != nil { + log.Error().Err(err).Msg("Config save failed!") + } + c.app.inject(comp) return true } diff --git a/internal/view/command_test.go b/internal/view/command_test.go new file mode 100644 index 00000000..0e40f1a7 --- /dev/null +++ b/internal/view/command_test.go @@ -0,0 +1,19 @@ +package view + +// import ( +// "testing" + +// "github.com/derailed/k9s/internal/config" +// "github.com/stretchr/testify/assert" +// ) + +// func TestCommandPush(t *testing.T) { +// c := newCommand(NewApp(config.NewConfig(ks{}))) +// c.pushCmd("fred") +// c.pushCmd("blee") +// p, top := c.previousCmd() + +// assert.Equal(t, "fred", p) +// assert.True(t, top) +// assert.True(t, c.lastCmd()) +// } diff --git a/internal/view/container.go b/internal/view/container.go new file mode 100644 index 00000000..4f7f14ea --- /dev/null +++ b/internal/view/container.go @@ -0,0 +1,181 @@ +package view + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/derailed/k9s/internal/k8s" + "github.com/derailed/k9s/internal/resource" + "github.com/derailed/k9s/internal/ui" + "github.com/derailed/k9s/internal/ui/dialog" + "github.com/gdamore/tcell" + "github.com/rs/zerolog/log" + "k8s.io/client-go/tools/portforward" +) + +// Container represents a container view. +type Container struct { + *LogResource +} + +// New Container returns a new container view. +func NewContainer(title string, list resource.List, path string) ResourceViewer { + c := Container{ + LogResource: NewLogResource(title, "", list), + } + c.path = &path + c.envFn = c.k9sEnv + c.containerFn = c.selectedContainer + c.extraActionsFn = c.extraActions + c.enterFn = c.viewLogs + c.colorerFn = containerColorer + + return &c +} + +// Init initializes a container view. +func (c *Container) Init(ctx context.Context) { + c.Resource.Init(ctx) +} + +// Start starts the component. +func (c *Container) Start() {} + +// Stop stops the component. +func (c *Container) Stop() {} + +// Name returns the component name. +func (c *Container) Name() string { return "containers" } + +func (c *Container) extraActions(aa ui.KeyActions) { + c.LogResource.extraActions(aa) + + aa[ui.KeyShiftF] = ui.NewKeyAction("PortForward", c.portFwdCmd, true) + aa[ui.KeyShiftL] = ui.NewKeyAction("Logs Previous", c.prevLogsCmd, true) + aa[ui.KeyS] = ui.NewKeyAction("Shell", c.shellCmd, true) + aa[tcell.KeyEscape] = ui.NewKeyAction("Back", c.backCmd, false) + aa[ui.KeyP] = ui.NewKeyAction("Previous", c.backCmd, false) + aa[ui.KeyShiftC] = ui.NewKeyAction("Sort CPU", c.sortColCmd(6, false), false) + aa[ui.KeyShiftM] = ui.NewKeyAction("Sort MEM", c.sortColCmd(7, false), false) + aa[ui.KeyShiftX] = ui.NewKeyAction("Sort CPU%", c.sortColCmd(8, false), false) + aa[ui.KeyShiftZ] = ui.NewKeyAction("Sort MEM%", c.sortColCmd(9, false), false) +} + +func (c *Container) k9sEnv() K9sEnv { + env := c.defaultK9sEnv() + + ns, n := namespaced(*c.path) + env["POD"] = n + env["NAMESPACE"] = ns + + return env +} + +func (c *Container) selectedContainer() string { + return c.masterPage().GetSelectedItem() +} + +func (c *Container) viewLogs(app *App, _, res, sel string) { + status := c.masterPage().GetSelectedCell(3) + if status == "Running" || status == "Completed" { + c.showLogs(false) + return + } + c.app.Flash().Err(errors.New("No logs available")) +} + +// Handlers... + +func (c *Container) shellCmd(evt *tcell.EventKey) *tcell.EventKey { + if !c.masterPage().RowSelected() { + return evt + } + + c.Stop() + { + shellIn(c.app, *c.path, c.masterPage().GetSelectedItem()) + } + c.Start() + + return nil +} + +func (c *Container) portFwdCmd(evt *tcell.EventKey) *tcell.EventKey { + if !c.masterPage().RowSelected() { + return evt + } + + sel := c.masterPage().GetSelectedItem() + if _, ok := c.app.forwarders[fwFQN(*c.path, sel)]; ok { + c.app.Flash().Err(fmt.Errorf("A PortForward already exist on container %s", *c.path)) + return nil + } + + state := c.masterPage().GetSelectedCell(3) + if state != "Running" { + c.app.Flash().Err(fmt.Errorf("Container %s is not running?", sel)) + return nil + } + + portC := c.masterPage().GetSelectedCell(10) + ports := strings.Split(portC, ",") + if len(ports) == 0 { + c.app.Flash().Err(errors.New("Container exposes no ports")) + return nil + } + + var port string + for _, p := range ports { + log.Debug().Msgf("Checking port %q", p) + if !isTCPPort(p) { + continue + } + port = strings.TrimSpace(p) + break + } + if port == "" { + c.app.Flash().Warn("No valid TCP port found on this container. User will specify...") + port = "MY_TCP_PORT!" + } + dialog.ShowPortForward(c.Pages, port, c.portForward) + + return nil +} + +func (c *Container) portForward(lport, cport string) { + co := c.masterPage().GetSelectedCell(0) + pf := k8s.NewPortForward(c.app.Conn(), &log.Logger) + ports := []string{lport + ":" + cport} + fw, err := pf.Start(*c.path, co, ports) + if err != nil { + c.app.Flash().Err(err) + return + } + + log.Debug().Msgf(">>> Starting port forward %q %v", *c.path, ports) + go c.runForward(pf, fw) +} + +func (c *Container) runForward(pf *k8s.PortForward, f *portforward.PortForwarder) { + c.app.QueueUpdateDraw(func() { + c.app.forwarders[pf.FQN()] = pf + c.app.Flash().Infof("PortForward activated %s:%s", pf.Path(), pf.Ports()[0]) + dialog.DismissPortForward(c.Pages) + }) + + pf.SetActive(true) + if err := f.ForwardPorts(); err != nil { + c.app.Flash().Err(err) + return + } + c.app.QueueUpdateDraw(func() { + delete(c.app.forwarders, pf.FQN()) + pf.SetActive(false) + }) +} + +func (c *Container) backCmd(evt *tcell.EventKey) *tcell.EventKey { + return c.app.PrevCmd(evt) +} diff --git a/internal/view/context.go b/internal/view/context.go new file mode 100644 index 00000000..6da72e8e --- /dev/null +++ b/internal/view/context.go @@ -0,0 +1,63 @@ +package view + +import ( + "strings" + + "github.com/derailed/k9s/internal/resource" + "github.com/derailed/k9s/internal/ui" +) + +// Context presents a context viewer. +type Context struct { + *Resource +} + +// NewContext return a new context viewer. +func NewContext(title, gvr string, list resource.List) ResourceViewer { + c := Context{ + Resource: NewResource(title, gvr, list), + } + c.extraActionsFn = c.extraActions + c.enterFn = c.useCtx + c.masterPage().SetSelectedFn(c.cleanser) + + return &c +} + +func (c *Context) extraActions(aa ui.KeyActions) { + c.masterPage().RmAction(ui.KeyShiftA) +} + +func (c *Context) useCtx(app *App, _, res, sel string) { + if err := c.useContext(sel); err != nil { + app.Flash().Err(err) + return + } + app.gotoResource("po", true) +} + +func (*Context) cleanser(s string) string { + name := strings.TrimSpace(s) + if strings.HasSuffix(name, "*") { + name = strings.TrimRight(name, "*") + } + if strings.HasSuffix(name, "(𝜟)") { + name = strings.TrimRight(name, "(𝜟)") + } + return name +} + +func (c *Context) useContext(name string) error { + ctx := c.cleanser(name) + if err := c.list.Resource().(*resource.Context).Switch(ctx); err != nil { + return err + } + + c.app.switchCtx(name, false) + c.refresh() + if tv, ok := c.GetPrimitive("ctx").(*Table); ok { + tv.Select(1, 0) + } + + return nil +} diff --git a/internal/view/context_test.go b/internal/view/context_test.go new file mode 100644 index 00000000..234dd83c --- /dev/null +++ b/internal/view/context_test.go @@ -0,0 +1,34 @@ +package view_test + +// import ( +// "testing" + +// "github.com/derailed/k9s/internal/config" +// "github.com/derailed/k9s/internal/resource" +// "github.com/derailed/k9s/internal/view" +// "github.com/stretchr/testify/assert" +// ) + +// func TestContext(t *testing.T) { +// l := resource.NewContextList(nil, "fred") +// v := view.NewContext("blee", "", NewApp(config.NewConfig(ks{})), l).(*contextView) + +// assert.Equal(t, 10, len(v.Hints())) +// } + +// func TestCleaner(t *testing.T) { +// uu := map[string]struct { +// s, e string +// }{ +// "normal": {"fred", "fred"}, +// "default": {"fred*", "fred"}, +// "delta": {"fred(𝜟)", "fred"}, +// } + +// v := contextView{} +// for k, u := range uu { +// t.Run(k, func(t *testing.T) { +// assert.Equal(t, u.e, v.cleanser(u.s)) +// }) +// } +// } diff --git a/internal/view/cronjob.go b/internal/view/cronjob.go new file mode 100644 index 00000000..31114dfc --- /dev/null +++ b/internal/view/cronjob.go @@ -0,0 +1,41 @@ +package view + +import ( + "github.com/derailed/k9s/internal/resource" + "github.com/derailed/k9s/internal/ui" + "github.com/gdamore/tcell" +) + +// CronJob presents a cronjob viewer. +type CronJob struct { + *Resource +} + +// NewCronJob returns a new viewer. +func NewCronJob(title, gvr string, list resource.List) ResourceViewer { + c := CronJob{ + Resource: NewResource(title, gvr, list), + } + c.extraActionsFn = c.extraActions + + return &c +} + +func (c *CronJob) trigger(evt *tcell.EventKey) *tcell.EventKey { + if !c.masterPage().RowSelected() { + return evt + } + + sel := c.masterPage().GetSelectedItem() + if err := c.list.Resource().(resource.Runner).Run(sel); err != nil { + c.app.Flash().Errf("Cronjob trigger failed %v", err) + return evt + } + c.app.Flash().Infof("Triggering %s %s", c.list.GetName(), sel) + + return nil +} + +func (c *CronJob) extraActions(aa ui.KeyActions) { + aa[tcell.KeyCtrlT] = ui.NewKeyAction("Trigger", c.trigger, true) +} diff --git a/internal/view/details.go b/internal/view/details.go new file mode 100644 index 00000000..ab5f12d8 --- /dev/null +++ b/internal/view/details.go @@ -0,0 +1,261 @@ +package view + +import ( + "context" + "fmt" + "regexp" + "strconv" + "strings" + + "github.com/atotto/clipboard" + "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/rs/zerolog/log" +) + +const detailsTitleFmt = "[fg:bg:b] %s([hilite:bg:b]%s[fg:bg:-])[fg:bg:-] " + +// Details presents a generic text viewer. +type Details struct { + *tview.TextView + + app *App + actions ui.KeyActions + cmdBuff *ui.CmdBuff + title string + category string + backFn ui.ActionHandler + numSelections int +} + +// NewDetails returns a details viewer. +func NewDetails(app *App, backFn ui.ActionHandler) *Details { + return &Details{ + TextView: tview.NewTextView(), + app: app, + backFn: backFn, + } +} + +func (d *Details) Init(ctx context.Context) { + d.app = ctx.Value(ui.KeyApp).(*App) + + d.SetScrollable(true) + d.SetWrap(true) + d.SetDynamicColors(true) + d.SetRegions(true) + d.SetBorder(true) + d.SetBorderFocusColor(config.AsColor(d.app.Styles.Frame().Border.FocusColor)) + d.SetHighlightColor(tcell.ColorOrange) + d.SetTitleColor(tcell.ColorAqua) + d.SetInputCapture(d.keyboard) + d.bindKeys() + + d.cmdBuff = ui.NewCmdBuff('/', ui.FilterBuff) + d.cmdBuff.AddListener(d.app.Cmd()) + d.cmdBuff.Reset() + + d.SetChangedFunc(func() { + d.app.Draw() + }) +} + +func (d *Details) Name() string { return "details" } +func (d *Details) Start() {} +func (d *Details) Stop() {} + +func (d *Details) bindKeys() { + d.actions = ui.KeyActions{ + tcell.KeyBackspace2: ui.NewKeyAction("Erase", d.eraseCmd, false), + tcell.KeyBackspace: ui.NewKeyAction("Erase", d.eraseCmd, false), + tcell.KeyDelete: ui.NewKeyAction("Erase", d.eraseCmd, false), + tcell.KeyEscape: ui.NewKeyAction("Back", d.backCmd, true), + tcell.KeyTab: ui.NewKeyAction("Next Match", d.nextCmd, false), + tcell.KeyBacktab: ui.NewKeyAction("Previous Match", d.prevCmd, false), + tcell.KeyCtrlS: ui.NewKeyAction("Save", d.saveCmd, true), + ui.KeyC: ui.NewKeyAction("Copy", d.cpCmd, false), + } +} + +func (d *Details) setCategory(n string) { + d.category = n +} + +func (d *Details) keyboard(evt *tcell.EventKey) *tcell.EventKey { + key := evt.Key() + if key == tcell.KeyRune { + if d.cmdBuff.IsActive() { + d.cmdBuff.Add(evt.Rune()) + d.refreshTitle() + return nil + } + key = tcell.Key(evt.Rune()) + } + + if a, ok := d.actions[key]; ok { + log.Debug().Msgf(">> DetailsView handled %s", tcell.KeyNames[key]) + return a.Action(evt) + } + return evt +} + +func (d *Details) saveCmd(evt *tcell.EventKey) *tcell.EventKey { + if path, err := saveYAML(d.app.Config.K9s.CurrentCluster, d.title, d.GetText(true)); err != nil { + d.app.Flash().Err(err) + } else { + d.app.Flash().Infof("Log %s saved successfully!", path) + } + return nil +} + +func (d *Details) cpCmd(evt *tcell.EventKey) *tcell.EventKey { + d.app.Flash().Info("Content copied to clipboard...") + if err := clipboard.WriteAll(d.GetText(true)); err != nil { + d.app.Flash().Err(err) + } + return nil +} + +func (d *Details) backCmd(evt *tcell.EventKey) *tcell.EventKey { + if !d.cmdBuff.Empty() { + d.cmdBuff.Reset() + d.search(evt) + return nil + } + d.cmdBuff.Reset() + if d.backFn != nil { + return d.backFn(evt) + } + return evt +} + +func (d *Details) eraseCmd(evt *tcell.EventKey) *tcell.EventKey { + if !d.cmdBuff.IsActive() { + return evt + } + d.cmdBuff.Delete() + return nil +} + +func (d *Details) activateCmd(evt *tcell.EventKey) *tcell.EventKey { + if !d.app.InCmdMode() { + d.cmdBuff.SetActive(true) + d.cmdBuff.Clear() + return nil + } + return evt +} + +func (d *Details) searchCmd(evt *tcell.EventKey) *tcell.EventKey { + if d.cmdBuff.IsActive() && !d.cmdBuff.Empty() { + d.app.Flash().Infof("Searching for %s...", d.cmdBuff) + d.search(evt) + highlights := d.GetHighlights() + if len(highlights) > 0 { + d.Highlight() + } else { + d.Highlight("0").ScrollToHighlight() + } + } + d.cmdBuff.SetActive(false) + return evt +} + +func (d *Details) search(evt *tcell.EventKey) { + d.numSelections = 0 + log.Debug().Msgf("Searching... %s - %d", d.cmdBuff, d.numSelections) + d.Highlight("") + d.SetText(d.decorateLines(d.GetText(false), d.cmdBuff.String())) + + if d.cmdBuff.Empty() { + d.app.Flash().Info("Clearing out search query...") + d.refreshTitle() + return + } + if d.numSelections == 0 { + d.app.Flash().Warn("No matches found!") + return + } + d.app.Flash().Infof("Found <%d> matches! / for next/previous", d.numSelections) +} + +func (d *Details) nextCmd(evt *tcell.EventKey) *tcell.EventKey { + highlights := d.GetHighlights() + if len(highlights) == 0 || d.numSelections == 0 { + return evt + } + index, _ := strconv.Atoi(highlights[0]) + index = (index + 1) % d.numSelections + if index+1 == d.numSelections { + d.app.Flash().Info("Search hit BOTTOM, continuing at TOP") + } + d.Highlight(strconv.Itoa(index)).ScrollToHighlight() + return nil +} + +func (d *Details) prevCmd(evt *tcell.EventKey) *tcell.EventKey { + highlights := d.GetHighlights() + if len(highlights) == 0 || d.numSelections == 0 { + return evt + } + index, _ := strconv.Atoi(highlights[0]) + index = (index - 1 + d.numSelections) % d.numSelections + if index == 0 { + d.app.Flash().Info("Search hit TOP, continuing at BOTTOM") + } + d.Highlight(strconv.Itoa(index)).ScrollToHighlight() + return nil +} + +// SetActions to handle keyboard inputs +func (d *Details) setActions(aa ui.KeyActions) { + for k, a := range aa { + d.actions[k] = a + } +} + +// Hints fetch mmemonic and hints +func (d *Details) Hints() model.MenuHints { + if d.actions != nil { + return d.actions.Hints() + } + return nil +} + +func (d *Details) refreshTitle() { + d.setTitle(d.title) +} + +func (d *Details) setTitle(t string) { + d.title = t + + title := skinTitle(fmt.Sprintf(detailsTitleFmt, d.category, t), d.app.Styles.Frame()) + if !d.cmdBuff.Empty() { + title += skinTitle(fmt.Sprintf(ui.SearchFmt, d.cmdBuff.String()), d.app.Styles.Frame()) + } + d.SetTitle(title) +} + +var ( + regionRX = regexp.MustCompile(`\["([a-zA-Z0-9_,;: \-\.]*)"\]`) + escapeRX = regexp.MustCompile(`\[([a-zA-Z0-9_,;: \-\."#]+)\[(\[*)\]`) +) + +func (d *Details) decorateLines(buff, q string) string { + rx := regexp.MustCompile(`(?i)` + q) + lines := strings.Split(buff, "\n") + for i, l := range lines { + l = regionRX.ReplaceAllString(l, "") + l = escapeRX.ReplaceAllString(l, "") + if m := rx.FindString(l); len(m) > 0 { + lines[i] = rx.ReplaceAllString(l, fmt.Sprintf(`["%d"]%s[""]`, d.numSelections, m)) + d.numSelections++ + continue + } + lines[i] = l + } + return strings.Join(lines, "\n") +} diff --git a/internal/view/details_test.go b/internal/view/details_test.go new file mode 100644 index 00000000..22e9c909 --- /dev/null +++ b/internal/view/details_test.go @@ -0,0 +1,25 @@ +package view_test + +// import ( +// "testing" + +// "github.com/derailed/k9s/internal/config" +// "github.com/derailed/k9s/internal/view" +// "github.com/stretchr/testify/assert" +// ) + +// func TestDetailsDecorateLines(t *testing.T) { +// buff := ` +// I love blee +// blee is much [blue::]cooler [green::]than foo! +// ` +// exp := ` +// I love ["0"]blee[""] +// ["1"]blee[""] is much [blue::]cooler [green::]than foo! +// ` + +// app := view.NewApp(config.NewConfig(ks{})) +// v := view.NewDetails{app: app} + +// assert.Equal(t, exp, v.decorateLines(buff, "blee")) +// } diff --git a/internal/view/dp.go b/internal/view/dp.go new file mode 100644 index 00000000..3c3b30bd --- /dev/null +++ b/internal/view/dp.go @@ -0,0 +1,59 @@ +package view + +import ( + "github.com/derailed/k9s/internal/k8s" + "github.com/derailed/k9s/internal/resource" + "github.com/derailed/k9s/internal/ui" + v1 "k8s.io/api/apps/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const scaleDialogKey = "scale" + +// Deploy represents a deployment view. +type Deploy struct { + *LogResource + + scalableResource *ScalableResource + restartableResource *RestartableResource +} + +// NewDeploy returns a new deployment view. +func NewDeploy(title, gvr string, list resource.List) ResourceViewer { + l := NewLogResource(title, gvr, list) + d := Deploy{ + LogResource: l, + scalableResource: newScalableResourceForParent(l.Resource), + restartableResource: newRestartableResourceForParent(l.Resource), + } + d.extraActionsFn = d.extraActions + d.enterFn = d.showPods + + return &d +} + +func (d *Deploy) extraActions(aa ui.KeyActions) { + d.LogResource.extraActions(aa) + d.scalableResource.extraActions(aa) + d.restartableResource.extraActions(aa) + aa[ui.KeyShiftD] = ui.NewKeyAction("Sort Desired", d.sortColCmd(1, false), false) + aa[ui.KeyShiftC] = ui.NewKeyAction("Sort Current", d.sortColCmd(2, false), false) +} + +func (d *Deploy) showPods(app *App, _, res, sel string) { + ns, n := namespaced(sel) + dep, err := k8s.NewDeployment(app.Conn()).Get(ns, n) + if err != nil { + app.Flash().Err(err) + return + } + + dp := dep.(*v1.Deployment) + l, err := metav1.LabelSelectorAsSelector(dp.Spec.Selector) + if err != nil { + app.Flash().Err(err) + return + } + + showPods(app, ns, l.String(), "", d.backCmd) +} diff --git a/internal/view/dp_test.go b/internal/view/dp_test.go new file mode 100644 index 00000000..da8a4ce2 --- /dev/null +++ b/internal/view/dp_test.go @@ -0,0 +1,18 @@ +package view_test + +// import ( +// "testing" + +// "github.com/derailed/k9s/internal/config" +// "github.com/derailed/k9s/internal/resource" +// "github.com/stretchr/testify/assert" +// ) + +// func TestDeploy(t *testing.T) { +// l := resource.NewDeploymentList(nil, "fred") +// v := view.NewDeploy("blee", "", l) +// ctx := context.WithValue(ui.KeyApp, NewApp(config.NewConfig(ks{}))) +// v.Init(ctx) + +// assert.Equal(t, 10, len(v.Hints())) +// } diff --git a/internal/view/ds.go b/internal/view/ds.go new file mode 100644 index 00000000..c5d8e591 --- /dev/null +++ b/internal/view/ds.go @@ -0,0 +1,52 @@ +package view + +import ( + "github.com/derailed/k9s/internal/k8s" + "github.com/derailed/k9s/internal/resource" + "github.com/derailed/k9s/internal/ui" + appsv1 "k8s.io/api/apps/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +type DaemonSet struct { + *LogResource + + restartableResource *RestartableResource +} + +func NewDaemonSet(title, gvr string, list resource.List) ResourceViewer { + l := NewLogResource(title, gvr, list) + d := DaemonSet{ + LogResource: l, + restartableResource: newRestartableResourceForParent(l.Resource), + } + d.extraActionsFn = d.extraActions + d.enterFn = d.showPods + + return &d +} + +func (d *DaemonSet) extraActions(aa ui.KeyActions) { + d.LogResource.extraActions(aa) + d.restartableResource.extraActions(aa) + aa[ui.KeyShiftD] = ui.NewKeyAction("Sort Desired", d.sortColCmd(1, false), false) + aa[ui.KeyShiftC] = ui.NewKeyAction("Sort Current", d.sortColCmd(2, false), false) +} + +func (d *DaemonSet) showPods(app *App, _, res, sel string) { + ns, n := namespaced(sel) + dset, err := k8s.NewDaemonSet(app.Conn()).Get(ns, n) + if err != nil { + d.app.Flash().Err(err) + return + } + + ds := dset.(*appsv1.DaemonSet) + l, err := metav1.LabelSelectorAsSelector(ds.Spec.Selector) + if err != nil { + app.Flash().Err(err) + return + } + + showPods(app, ns, l.String(), "", d.backCmd) +} diff --git a/internal/view/ds_test.go b/internal/view/ds_test.go new file mode 100644 index 00000000..734e2aa4 --- /dev/null +++ b/internal/view/ds_test.go @@ -0,0 +1,21 @@ +package view_test + +// import ( +// "context" +// "testing" + +// "github.com/derailed/k9s/internal/config" +// "github.com/derailed/k9s/internal/resource" +// "github.com/derailed/k9s/internal/ui" +// "github.com/derailed/k9s/internal/view" +// "github.com/stretchr/testify/assert" +// ) + +// func TestDaemonSet(t *testing.T) { +// l := resource.NewDaemonSetList(nil, "fred") +// v := view.NewDaemonSet("blee", "", l) +// ctx := context.WithValue(ui.KeyApp, NewApp(config.NewConfig(ks{}))) +// v.Init(ctx) + +// assert.Equal(t, 10, len(v.Hints())) +// } diff --git a/internal/view/dump.go b/internal/view/dump.go new file mode 100644 index 00000000..09643f9f --- /dev/null +++ b/internal/view/dump.go @@ -0,0 +1,227 @@ +package view + +import ( + "context" + "errors" + "fmt" + "io/ioutil" + "os" + "path/filepath" + "time" + + "github.com/derailed/k9s/internal/config" + "github.com/derailed/k9s/internal/model" + "github.com/derailed/k9s/internal/resource" + "github.com/derailed/k9s/internal/ui" + "github.com/fsnotify/fsnotify" + "github.com/gdamore/tcell" + "github.com/rs/zerolog/log" +) + +const ( + dumpTitle = "Screen Dumps" + dumpTitleFmt = " [mediumvioletred::b]%s([fuchsia::b]%d[fuchsia::-])[mediumvioletred::-] " +) + +var ( + dumpHeader = resource.Row{"NAME", "AGE"} +) + +// ScreenDump presents a directory listing viewer. +type ScreenDump struct { + *MasterDetail + + cancelFn context.CancelFunc + app *App +} + +func NewScreenDump(_, _ string, _ resource.List) ResourceViewer { + return &ScreenDump{ + MasterDetail: NewMasterDetail(), + } +} + +// Init initializes the viewer. +func (s *ScreenDump) Init(ctx context.Context) { + s.app = ctx.Value(ui.KeyApp).(*App) + + table := s.masterPage() + table.SetBorderFocusColor(tcell.ColorSteelBlue) + table.SetSelectedStyle(tcell.ColorWhite, tcell.ColorRoyalBlue, tcell.AttrNone) + table.SetColorerFn(dumpColorer) + table.SetActiveNS(resource.AllNamespaces) + table.SetSortCol(table.NameColIndex()+1, 0, true) + table.SelectRow(1, true) + + s.Start() + s.refresh() +} + +// Start starts the directory watcher. +func (s *ScreenDump) Start() { + var ctx context.Context + ctx, s.cancelFn = context.WithCancel(context.Background()) + if err := s.watchDumpDir(ctx); err != nil { + s.app.Flash().Errf("Unable to watch dumpmarks directory %s", err) + } +} + +// Stop terminates the directory watcher. +func (s *ScreenDump) Stop() { + if s.cancelFn != nil { + s.cancelFn() + } +} + +// Name returns the component name. +func (s *ScreenDump) Name() string { + return dumpTitle +} + +func (s *ScreenDump) setEnterFn(enterFn) {} +func (s *ScreenDump) setColorerFn(ui.ColorerFunc) {} +func (s *ScreenDump) setDecorateFn(decorateFn) {} +func (s *ScreenDump) setExtraActionsFn(ActionsFunc) {} + +func (s *ScreenDump) refresh() { + tv := s.masterPage() + tv.Update(s.hydrate()) + tv.UpdateTitle() +} + +func (s *ScreenDump) registerActions() { + aa := ui.KeyActions{ + ui.KeyP: ui.NewKeyAction("Previous", s.app.PrevCmd, false), + tcell.KeyEnter: ui.NewKeyAction("Enter", s.enterCmd, true), + tcell.KeyCtrlD: ui.NewKeyAction("Delete", s.deleteCmd, true), + tcell.KeyCtrlS: ui.NewKeyAction("Save", noopCmd, false), + } + s.masterPage().AddActions(aa) +} + +func (s *ScreenDump) getTitle() string { + return dumpTitle +} + +func (s *ScreenDump) sortColCmd(col int, asc bool) func(evt *tcell.EventKey) *tcell.EventKey { + return func(evt *tcell.EventKey) *tcell.EventKey { + tv := s.masterPage() + tv.SetSortCol(tv.NameColIndex()+col, 0, asc) + tv.Refresh() + return nil + } +} + +func (s *ScreenDump) enterCmd(evt *tcell.EventKey) *tcell.EventKey { + log.Debug().Msg("Dump enter!") + tv := s.masterPage() + if tv.SearchBuff().IsActive() { + return tv.filterCmd(evt) + } + sel := tv.GetSelectedItem() + if sel == "" { + return nil + } + + dir := filepath.Join(config.K9sDumpDir, s.app.Config.K9s.CurrentCluster) + if !edit(true, s.app, filepath.Join(dir, sel)) { + s.app.Flash().Err(errors.New("Failed to launch editor")) + } + + return nil +} + +func (s *ScreenDump) deleteCmd(evt *tcell.EventKey) *tcell.EventKey { + sel := s.masterPage().GetSelectedItem() + if sel == "" { + return nil + } + + dir := filepath.Join(config.K9sDumpDir, s.app.Config.K9s.CurrentCluster) + showModal(s.Pages, fmt.Sprintf("Delete screen dump `%s?", sel), "table", func() { + if err := os.Remove(filepath.Join(dir, sel)); err != nil { + s.app.Flash().Errf("Unable to delete file %s", err) + return + } + s.refresh() + s.app.Flash().Infof("ScreenDump file %s deleted!", sel) + }) + + return nil +} + +func (s *ScreenDump) backCmd(evt *tcell.EventKey) *tcell.EventKey { + if s.cancelFn != nil { + s.cancelFn() + } + s.SwitchToPage("table") + + return nil +} + +func (s *ScreenDump) Hints() model.MenuHints { + return s.Hints() +} + +func (s *ScreenDump) hydrate() resource.TableData { + data := resource.TableData{ + Header: dumpHeader, + Rows: make(resource.RowEvents, 10), + Namespace: resource.NotNamespaced, + } + + dir := filepath.Join(config.K9sDumpDir, s.app.Config.K9s.CurrentCluster) + ff, err := ioutil.ReadDir(dir) + if err != nil { + s.app.Flash().Errf("Unable to read dump directory %s", err) + } + + for _, f := range ff { + fields := resource.Row{f.Name(), time.Since(f.ModTime()).String()} + data.Rows[f.Name()] = &resource.RowEvent{ + Action: resource.New, + Fields: fields, + Deltas: fields, + } + } + + return data +} + +func (s *ScreenDump) resetTitle() { + s.SetTitle(fmt.Sprintf(dumpTitleFmt, dumpTitle, s.masterPage().GetRowCount()-1)) +} + +func (s *ScreenDump) watchDumpDir(ctx context.Context) error { + w, err := fsnotify.NewWatcher() + if err != nil { + return err + } + + go func() { + for { + select { + case evt := <-w.Events: + log.Debug().Msgf("Dump event %#v", evt) + s.app.QueueUpdateDraw(func() { + s.refresh() + }) + case err := <-w.Errors: + log.Info().Err(err).Msg("Dir Watcher failed") + return + case <-ctx.Done(): + log.Debug().Msg("!!!! FS WATCHER DONE!!") + w.Close() + return + } + } + }() + + return w.Add(filepath.Join(config.K9sDumpDir, s.app.Config.K9s.CurrentCluster)) +} + +// Helpers... + +func noopCmd(*tcell.EventKey) *tcell.EventKey { + return nil +} diff --git a/internal/views/env.go b/internal/view/env.go similarity index 97% rename from internal/views/env.go rename to internal/view/env.go index 71ed834a..92988183 100644 --- a/internal/views/env.go +++ b/internal/view/env.go @@ -1,4 +1,4 @@ -package views +package view import ( "fmt" diff --git a/internal/views/env_test.go b/internal/view/env_test.go similarity index 97% rename from internal/views/env_test.go rename to internal/view/env_test.go index 10aadd76..6b31a387 100644 --- a/internal/views/env_test.go +++ b/internal/view/env_test.go @@ -1,4 +1,4 @@ -package views +package view import ( "errors" diff --git a/internal/views/exec.go b/internal/view/exec.go similarity index 87% rename from internal/views/exec.go rename to internal/view/exec.go index e6fda318..4ea5cac6 100644 --- a/internal/views/exec.go +++ b/internal/view/exec.go @@ -1,4 +1,4 @@ -package views +package view import ( "context" @@ -13,7 +13,7 @@ import ( "github.com/rs/zerolog/log" ) -func runK(clear bool, app *appView, args ...string) bool { +func runK(clear bool, app *App, args ...string) bool { bin, err := exec.LookPath("kubectl") if err != nil { log.Error().Msgf("Unable to find kubectl command in path %v", err) @@ -23,7 +23,7 @@ func runK(clear bool, app *appView, args ...string) bool { return run(clear, app, bin, false, args...) } -func run(clear bool, app *appView, bin string, bg bool, args ...string) bool { +func run(clear bool, app *App, bin string, bg bool, args ...string) bool { return app.Suspend(func() { if err := execute(clear, bin, bg, args...); err != nil { app.Flash().Errf("Command exited: %v", err) @@ -31,7 +31,7 @@ func run(clear bool, app *appView, bin string, bg bool, args ...string) bool { }) } -func edit(clear bool, app *appView, args ...string) bool { +func edit(clear bool, app *App, args ...string) bool { bin, err := exec.LookPath(os.Getenv("EDITOR")) if err != nil { log.Error().Msgf("Unable to find editor command in path %v", err) diff --git a/internal/views/help.go b/internal/view/help.go similarity index 73% rename from internal/views/help.go rename to internal/view/help.go index 3a7cfeb8..361749a1 100644 --- a/internal/views/help.go +++ b/internal/view/help.go @@ -1,4 +1,4 @@ -package views +package view import ( "context" @@ -8,6 +8,7 @@ import ( "strconv" "strings" + "github.com/derailed/k9s/internal/model" "github.com/derailed/k9s/internal/ui" "github.com/derailed/tview" "github.com/gdamore/tcell" @@ -19,44 +20,51 @@ const ( helpTitleFmt = " [aqua::b]%s " ) -type ( - helpItem struct { - key, description string - } +type helpItem struct { + key, description string +} - helpView struct { - *tview.Table +// Help presents a help viewer. +type Help struct { + *ui.Table - app *appView - current ui.Igniter - actions ui.KeyActions - } -) + app *App + actions ui.KeyActions +} -func newHelpView(app *appView, current ui.Igniter, hh ui.Hints) *helpView { - v := helpView{ - Table: tview.NewTable(), - app: app, +// NewHelp returns a new help viewer. +func NewHelp() *Help { + return &Help{ + Table: ui.NewTable(helpTitle), actions: make(ui.KeyActions), } +} + +func (v *Help) Init(ctx context.Context) { + v.app = ctx.Value(ui.KeyApp).(*App) + + v.resetTitle() + v.SetBorder(true) v.SetBorderPadding(0, 0, 1, 1) v.SetInputCapture(v.keyboard) - v.current = current v.bindKeys() - v.build(hh) - - return &v + v.build(v.app.Hint.Peek()) } -func (v *helpView) bindKeys() { +func (v *Help) Name() string { return helpTitle } +func (v *Help) Start() {} +func (v *Help) Stop() {} +func (v *Help) Hints() model.MenuHints { return v.actions.Hints() } + +func (v *Help) bindKeys() { v.actions = ui.KeyActions{ tcell.KeyEsc: ui.NewKeyAction("Back", v.backCmd, true), tcell.KeyEnter: ui.NewKeyAction("Back", v.backCmd, false), } } -func (v *helpView) keyboard(evt *tcell.EventKey) *tcell.EventKey { +func (v *Help) keyboard(evt *tcell.EventKey) *tcell.EventKey { key := evt.Key() if key == tcell.KeyRune { key = tcell.Key(evt.Rune()) @@ -66,21 +74,16 @@ func (v *helpView) keyboard(evt *tcell.EventKey) *tcell.EventKey { log.Debug().Msgf(">> TableView handled %s", tcell.KeyNames[key]) return a.Action(evt) } + return evt } -func (v *helpView) backCmd(evt *tcell.EventKey) *tcell.EventKey { - v.app.inject(v.current) - return nil +func (v *Help) backCmd(evt *tcell.EventKey) *tcell.EventKey { + return v.app.PrevCmd(evt) } -func (v *helpView) Init(_ context.Context, _ string) { - v.resetTitle() - v.app.SetHints(v.Hints()) -} - -func (v *helpView) showHelp() ui.Hints { - return ui.Hints{ +func (v *Help) showHelp() model.MenuHints { + return model.MenuHints{ { Mnemonic: "?", Description: "Help", @@ -92,8 +95,8 @@ func (v *helpView) showHelp() ui.Hints { } } -func (v *helpView) showNav() ui.Hints { - return ui.Hints{ +func (v *Help) showNav() model.MenuHints { + return model.MenuHints{ { Mnemonic: "g", Description: "Goto Top", @@ -128,8 +131,8 @@ func (v *helpView) showNav() ui.Hints { } } -func (v *helpView) showGeneral() ui.Hints { - return ui.Hints{ +func (v *Help) showGeneral() model.MenuHints { + return model.MenuHints{ { Mnemonic: ":cmd", Description: "Command mode", @@ -173,19 +176,15 @@ func (v *helpView) showGeneral() ui.Hints { } } -func (v *helpView) Hints() ui.Hints { - return v.actions.Hints() -} - -func (v *helpView) getTitle() string { +func (v *Help) getTitle() string { return helpTitle } -func (v *helpView) resetTitle() { +func (v *Help) resetTitle() { v.SetTitle(fmt.Sprintf(helpTitleFmt, helpTitle)) } -func (v *helpView) build(hh ui.Hints) { +func (v *Help) build(hh model.MenuHints) { v.Clear() sort.Sort(hh) v.addSection(0, 0, "RESOURCE", hh) @@ -194,7 +193,7 @@ func (v *helpView) build(hh ui.Hints) { v.addSection(0, 8, "HELP", v.showHelp()) } -func (v *helpView) addSection(r, c int, title string, hh ui.Hints) { +func (v *Help) addSection(r, c int, title string, hh model.MenuHints) { row := r cell := tview.NewTableCell(title) cell.SetTextColor(tcell.ColorGreen) diff --git a/internal/view/help_test.go b/internal/view/help_test.go new file mode 100644 index 00000000..ce5e0317 --- /dev/null +++ b/internal/view/help_test.go @@ -0,0 +1,29 @@ +package view_test + +// import ( +// "testing" + +// "github.com/derailed/k9s/internal/config" +// "github.com/derailed/k9s/internal/model" +// "github.com/stretchr/testify/assert" +// v1 "k8s.io/api/core/v1" +// metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +// ) + +// func newNS(n string) v1.Namespace { +// return v1.Namespace{ObjectMeta: metav1.ObjectMeta{ +// Name: n, +// }} +// } + +// func TestHelpNew(t *testing.T) { +// a := view.NewApp(config.NewConfig(ks{})) +// v := view.NewHelp() +// ctx := context.WithValue(ui.KeyApp, app) +// v.Init(ctx) + +// app.SetHints(model.MenuHints{{Mnemonic: "blee", Description: "duh"}}) + +// assert.Equal(t, "", v.GetCell(1, 0).Text) +// assert.Equal(t, "duh", v.GetCell(1, 1).Text) +// } diff --git a/internal/views/helpers.go b/internal/view/helpers.go similarity index 99% rename from internal/views/helpers.go rename to internal/view/helpers.go index 570ffdbc..6243db53 100644 --- a/internal/views/helpers.go +++ b/internal/view/helpers.go @@ -1,4 +1,4 @@ -package views +package view import ( "fmt" diff --git a/internal/views/helpers_test.go b/internal/view/helpers_test.go similarity index 99% rename from internal/views/helpers_test.go rename to internal/view/helpers_test.go index 51eeb785..2018dc6a 100644 --- a/internal/views/helpers_test.go +++ b/internal/view/helpers_test.go @@ -1,4 +1,4 @@ -package views +package view import ( "testing" diff --git a/internal/view/job.go b/internal/view/job.go new file mode 100644 index 00000000..e5f9d21a --- /dev/null +++ b/internal/view/job.go @@ -0,0 +1,43 @@ +package view + +import ( + "github.com/derailed/k9s/internal/k8s" + "github.com/derailed/k9s/internal/resource" + "github.com/derailed/k9s/internal/ui" + batchv1 "k8s.io/api/batch/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +type Job struct { + *LogResource +} + +func NewJob(title, gvr string, list resource.List) ResourceViewer { + j := Job{NewLogResource(title, gvr, list)} + j.extraActionsFn = j.extraActions + j.enterFn = j.showPods + + return &j +} + +func (j *Job) extraActions(aa ui.KeyActions) { + j.LogResource.extraActions(aa) +} + +func (j *Job) showPods(app *App, ns, res, sel string) { + ns, n := namespaced(sel) + job, err := k8s.NewJob(app.Conn()).Get(ns, n) + if err != nil { + app.Flash().Err(err) + return + } + + jo := job.(*batchv1.Job) + l, err := metav1.LabelSelectorAsSelector(jo.Spec.Selector) + if err != nil { + app.Flash().Err(err) + return + } + + showPods(app, ns, l.String(), "", j.backCmd) +} diff --git a/internal/view/log.go b/internal/view/log.go new file mode 100644 index 00000000..fa717aed --- /dev/null +++ b/internal/view/log.go @@ -0,0 +1,247 @@ +package view + +import ( + "fmt" + "io" + "os" + "path/filepath" + "strings" + "sync/atomic" + "time" + + "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/rs/zerolog/log" +) + +type logFrame struct { + *tview.Flex + + app *App + actions ui.KeyActions + backFn ui.ActionHandler +} + +func newLogFrame(app *App, backFn ui.ActionHandler) *logFrame { + f := logFrame{ + Flex: tview.NewFlex(), + app: app, + backFn: backFn, + actions: make(ui.KeyActions), + } + f.SetBorder(true) + f.SetBackgroundColor(config.AsColor(app.Styles.Views().Log.BgColor)) + f.SetBorderPadding(0, 0, 1, 1) + f.SetDirection(tview.FlexRow) + + return &f +} + +type Log struct { + *logFrame + + logs *Details + status *statusView + ansiWriter io.Writer + autoScroll int32 + path string +} + +func NewLog(_ string, app *App, backFn ui.ActionHandler) *Log { + l := Log{ + logFrame: newLogFrame(app, backFn), + autoScroll: 1, + } + + l.logs = NewDetails(app, backFn) + { + l.logs.SetBorder(false) + l.logs.setCategory("Logs") + l.logs.SetDynamicColors(true) + l.logs.SetTextColor(config.AsColor(app.Styles.Views().Log.FgColor)) + l.logs.SetBackgroundColor(config.AsColor(app.Styles.Views().Log.BgColor)) + l.logs.SetWrap(true) + l.logs.SetMaxBuffer(app.Config.K9s.LogBufferSize) + } + l.ansiWriter = tview.ANSIWriter(l.logs, app.Styles.Views().Log.FgColor, app.Styles.Views().Log.BgColor) + l.status = newStatusView(app.Styles) + l.AddItem(l.status, 1, 1, false) + l.AddItem(l.logs, 0, 1, true) + + l.bindKeys() + l.logs.SetInputCapture(l.keyboard) + + return &l +} + +func (l *Log) bindKeys() { + l.actions = ui.KeyActions{ + tcell.KeyEscape: ui.NewKeyAction("Back", l.backCmd, true), + ui.KeyC: ui.NewKeyAction("Clear", l.clearCmd, true), + ui.KeyS: ui.NewKeyAction("Toggle AutoScroll", l.toggleScrollCmd, true), + ui.KeyG: ui.NewKeyAction("Top", l.topCmd, false), + ui.KeyShiftG: ui.NewKeyAction("Bottom", l.bottomCmd, false), + ui.KeyF: ui.NewKeyAction("Up", l.pageUpCmd, false), + ui.KeyB: ui.NewKeyAction("Down", l.pageDownCmd, false), + tcell.KeyCtrlS: ui.NewKeyAction("Save", l.saveCmd, true), + } +} + +func (l *Log) setTitle(path, co string) { + var fmat string + if co == "" { + fmat = skinTitle(fmt.Sprintf(logFmt, path), l.app.Styles.Frame()) + } else { + fmat = skinTitle(fmt.Sprintf(logCoFmt, path, co), l.app.Styles.Frame()) + } + l.path = path + l.SetTitle(fmat) +} + +// Hints show action hints +func (l *Log) Hints() model.MenuHints { + return l.actions.Hints() +} + +func (l *Log) keyboard(evt *tcell.EventKey) *tcell.EventKey { + key := evt.Key() + if key == tcell.KeyRune { + key = tcell.Key(evt.Rune()) + } + if m, ok := l.actions[key]; ok { + log.Debug().Msgf(">> LogView handled %s", tcell.KeyNames[key]) + return m.Action(evt) + } + + return evt +} + +func (l *Log) log(lines string) { + fmt.Fprintln(l.ansiWriter, tview.Escape(lines)) + log.Debug().Msgf("LOG LINES %d", l.logs.GetLineCount()) +} + +func (l *Log) flush(index int, buff []string) { + if index == 0 { + return + } + + if atomic.LoadInt32(&l.autoScroll) == 1 { + l.log(strings.Join(buff[:index], "\n")) + l.app.QueueUpdateDraw(func() { + l.updateIndicator() + l.logs.ScrollToEnd() + }) + } +} + +func (l *Log) updateIndicator() { + status := "Off" + if l.autoScroll == 1 { + status = "On" + } + l.status.update([]string{fmt.Sprintf("Autoscroll: %s", status)}) +} + +// ---------------------------------------------------------------------------- +// Actions... + +func (l *Log) saveCmd(evt *tcell.EventKey) *tcell.EventKey { + if path, err := saveData(l.app.Config.K9s.CurrentCluster, l.path, l.logs.GetText(true)); err != nil { + l.app.Flash().Err(err) + } else { + l.app.Flash().Infof("Log %s saved successfully!", path) + } + return nil +} + +func ensureDir(dir string) error { + return os.MkdirAll(dir, 0744) +} + +func saveData(cluster, name, data string) (string, error) { + dir := filepath.Join(config.K9sDumpDir, cluster) + if err := ensureDir(dir); err != nil { + return "", err + } + + now := time.Now().UnixNano() + fName := fmt.Sprintf("%s-%d.log", strings.Replace(name, "/", "-", -1), now) + + path := filepath.Join(dir, fName) + mod := os.O_CREATE | os.O_WRONLY + file, err := os.OpenFile(path, mod, 0644) + defer func() { + if file != nil { + file.Close() + } + }() + if err != nil { + log.Error().Err(err).Msgf("LogFile create %s", path) + return "", nil + } + if _, err := fmt.Fprintf(file, data); err != nil { + return "", err + } + + return path, nil +} + +func (l *Log) toggleScrollCmd(evt *tcell.EventKey) *tcell.EventKey { + if atomic.LoadInt32(&l.autoScroll) == 0 { + atomic.StoreInt32(&l.autoScroll, 1) + } else { + atomic.StoreInt32(&l.autoScroll, 0) + } + + if atomic.LoadInt32(&l.autoScroll) == 1 { + l.app.Flash().Info("Autoscroll is on.") + l.logs.ScrollToEnd() + } else { + l.logs.LineUp() + l.app.Flash().Info("Autoscroll is off.") + } + l.updateIndicator() + + return nil +} + +func (l *Log) backCmd(evt *tcell.EventKey) *tcell.EventKey { + return l.backFn(evt) +} + +func (l *Log) topCmd(evt *tcell.EventKey) *tcell.EventKey { + l.app.Flash().Info("Top of logs...") + l.logs.ScrollToBeginning() + return nil +} + +func (l *Log) bottomCmd(*tcell.EventKey) *tcell.EventKey { + l.app.Flash().Info("Bottom of logs...") + l.logs.ScrollToEnd() + return nil +} + +func (l *Log) pageUpCmd(*tcell.EventKey) *tcell.EventKey { + if l.logs.PageUp() { + l.app.Flash().Info("Reached Top ...") + } + return nil +} + +func (l *Log) pageDownCmd(*tcell.EventKey) *tcell.EventKey { + if l.logs.PageDown() { + l.app.Flash().Info("Reached Bottom ...") + } + return nil +} + +func (l *Log) clearCmd(*tcell.EventKey) *tcell.EventKey { + l.app.Flash().Info("Clearing logs...") + l.logs.Clear() + l.logs.ScrollTo(0, 0) + return nil +} diff --git a/internal/view/log_resource.go b/internal/view/log_resource.go new file mode 100644 index 00000000..284498a4 --- /dev/null +++ b/internal/view/log_resource.go @@ -0,0 +1,86 @@ +package view + +import ( + "github.com/derailed/k9s/internal/resource" + "github.com/derailed/k9s/internal/ui" + "github.com/gdamore/tcell" +) + +// ContainerFn returns the active container name. +type containerFn func() string + +// LogResource represents a loggable resource view. +type LogResource struct { + *Resource + + containerFn containerFn +} + +func NewLogResource(title, gvr string, list resource.List) *LogResource { + l := LogResource{ + Resource: NewResource(title, gvr, list), + } + l.AddPage("logs", NewLogs(list.GetName(), &l), true, false) + + return &l +} + +func (l *LogResource) extraActions(aa ui.KeyActions) { + aa[ui.KeyL] = ui.NewKeyAction("Logs", l.logsCmd, true) + aa[ui.KeyShiftL] = ui.NewKeyAction("Logs Previous", l.prevLogsCmd, true) +} + +func (l *LogResource) sortColCmd(col int, asc bool) func(evt *tcell.EventKey) *tcell.EventKey { + return func(evt *tcell.EventKey) *tcell.EventKey { + t := l.masterPage() + t.SetSortCol(t.NameColIndex()+col, 0, asc) + t.Refresh() + + return nil + } +} + +// Protocol... + +func (l *LogResource) getList() resource.List { + return l.list +} + +func (l *LogResource) getSelection() string { + if l.path != nil { + return *l.path + } + return l.masterPage().GetSelectedItem() +} + +func (l *LogResource) prevLogsCmd(evt *tcell.EventKey) *tcell.EventKey { + l.showLogs(true) + return nil +} + +func (l *LogResource) logsCmd(evt *tcell.EventKey) *tcell.EventKey { + l.showLogs(false) + return nil +} + +func (l *LogResource) showLogs(prev bool) { + if !l.masterPage().RowSelected() { + return + } + + logs := l.GetPrimitive("logs").(*Logs) + co := "" + if l.containerFn != nil { + co = l.containerFn() + } + logs.reload(co, l, prev) + l.switchPage("logs") +} + +func (l *LogResource) backCmd(evt *tcell.EventKey) *tcell.EventKey { + // Reset namespace to what it was + l.app.Config.SetActiveNamespace(l.list.GetNamespace()) + l.app.inject(l) + + return nil +} diff --git a/internal/view/log_test.go b/internal/view/log_test.go new file mode 100644 index 00000000..22c57a07 --- /dev/null +++ b/internal/view/log_test.go @@ -0,0 +1,81 @@ +package view_test + +// import ( +// "bytes" +// "fmt" +// "testing" + +// "github.com/derailed/k9s/internal/config" +// "github.com/derailed/k9s/internal/view" +// "github.com/derailed/tview" +// "github.com/stretchr/testify/assert" +// ) + +// func TestAnsi(t *testing.T) { +// buff := bytes.NewBufferString("") +// w := tview.ANSIWriter(buff, "white", "black") +// fmt.Fprintf(w, "[YELLOW] ok") +// assert.Equal(t, "[YELLOW] ok", buff.String()) + +// v := tview.NewTextView() +// v.SetDynamicColors(true) +// aw := tview.ANSIWriter(v, "white", "black") +// s := "[2019-03-27T15:05:15,246][INFO ][o.e.c.r.a.AllocationService] [es-0] Cluster health status changed from [YELLOW] to [GREEN] (reason: [shards started [[.monitoring-es-6-2019.03.27][0]]" +// fmt.Fprintf(aw, s) +// assert.Equal(t, s+"\n", v.GetText(false)) +// } + +// func TestLogFlush(t *testing.T) { +// v := view.NewLog("Logs", NewApp(config.NewConfig(ks{})), nil) +// v.flush(2, []string{"blee", "bozo"}) + +// v.toggleScrollCmd(nil) +// assert.Equal(t, "blee\nbozo\n", v.logs.GetText(true)) +// assert.Equal(t, " Autoscroll: Off ", v.status.GetText(true)) +// v.toggleScrollCmd(nil) +// assert.Equal(t, " Autoscroll: On ", v.status.GetText(true)) +// } + +// func TestLogViewSave(t *testing.T) { +// v := newLogView("Logs", NewApp(config.NewConfig(ks{})), nil) +// v.flush(2, []string{"blee", "bozo"}) +// v.path = "k9s-test" +// dir := filepath.Join(config.K9sDumpDir, v.app.Config.K9s.CurrentCluster) +// c1, _ := ioutil.ReadDir(dir) +// v.saveCmd(nil) +// c2, _ := ioutil.ReadDir(dir) +// assert.Equal(t, len(c2), len(c1)+1) +// } + +// func TestLogViewNav(t *testing.T) { +// v := newLogView("Logs", NewApp(config.NewConfig(ks{})), nil) +// var buff []string +// v.autoScroll = 1 +// for i := 0; i < 100; i++ { +// buff = append(buff, fmt.Sprintf("line-%d\n", i)) +// } +// v.flush(100, buff) + +// v.topCmd(nil) +// r, _ := v.logs.GetScrollOffset() +// assert.Equal(t, 0, r) +// v.pageDownCmd(nil) +// r, _ = v.logs.GetScrollOffset() +// assert.Equal(t, 0, r) +// v.pageUpCmd(nil) +// r, _ = v.logs.GetScrollOffset() +// assert.Equal(t, 0, r) +// v.bottomCmd(nil) +// r, _ = v.logs.GetScrollOffset() +// assert.Equal(t, 0, r) +// } + +// func TestLogViewClear(t *testing.T) { +// v := newLogView("Logs", NewApp(config.NewConfig(ks{})), nil) +// v.flush(2, []string{"blee", "bozo"}) + +// v.toggleScrollCmd(nil) +// assert.Equal(t, "blee\nbozo\n", v.logs.GetText(true)) +// v.clearCmd(nil) +// assert.Equal(t, "", v.logs.GetText(true)) +// } diff --git a/internal/views/logs.go b/internal/view/logs.go similarity index 51% rename from internal/views/logs.go rename to internal/view/logs.go index 9fbe1936..7bed8d38 100644 --- a/internal/views/logs.go +++ b/internal/view/logs.go @@ -1,10 +1,11 @@ -package views +package view import ( "context" "fmt" "time" + "github.com/derailed/k9s/internal/model" "github.com/derailed/k9s/internal/resource" "github.com/derailed/k9s/internal/ui" "github.com/derailed/tview" @@ -23,98 +24,97 @@ const ( type ( masterView interface { backFn() ui.ActionHandler - appView() *appView + App() *App } - logsView struct { + // Logs presents a collection of logs. + Logs struct { *tview.Pages - app *appView + app *App parent loggable actions ui.KeyActions cancelFunc context.CancelFunc } ) -func newLogsView(title string, app *appView, parent loggable) *logsView { - v := logsView{ - app: app, +// NewLogs returns a new logs viewer. +func NewLogs(title string, parent loggable) *Logs { + return &Logs{ Pages: tview.NewPages(), parent: parent, } - - return &v } // Protocol... -func (v *logsView) reload(co string, parent loggable, prevLogs bool) { - v.parent = parent - v.deletePage() - v.AddPage("logs", newLogView(co, v.app, v.backCmd), true, true) - v.load(co, prevLogs) +func (l *Logs) reload(co string, parent loggable, prevLogs bool) { + l.parent = parent + l.deletePage() + l.AddPage("logs", NewLog(co, l.app, l.backCmd), true, true) + l.load(co, prevLogs) } // SetActions to handle keyboard events. -func (v *logsView) setActions(aa ui.KeyActions) { - v.actions = aa +func (l *Logs) setActions(aa ui.KeyActions) { + l.actions = aa } // Hints show action hints -func (v *logsView) Hints() ui.Hints { - l := v.CurrentPage().Item.(*logView) - return l.actions.Hints() +func (l *Logs) Hints() model.MenuHints { + v := l.CurrentPage().Item.(*Log) + return v.actions.Hints() } -func (v *logsView) backFn() ui.ActionHandler { - return v.backCmd +func (l *Logs) backFn() ui.ActionHandler { + return l.backCmd } -func (v *logsView) deletePage() { - v.RemovePage("logs") +func (l *Logs) deletePage() { + l.RemovePage("logs") } -func (v *logsView) stop() { - if v.cancelFunc == nil { +func (l *Logs) stop() { + if l.cancelFunc == nil { return } - v.cancelFunc() + l.cancelFunc() log.Debug().Msgf("Canceling logs...") - v.cancelFunc = nil + l.cancelFunc = nil } -func (v *logsView) load(container string, prevLogs bool) { - if err := v.doLoad(v.parent.getSelection(), container, prevLogs); err != nil { - v.app.Flash().Err(err) - l := v.CurrentPage().Item.(*logView) +func (l *Logs) load(container string, prevLogs bool) { + if err := l.doLoad(l.parent.getSelection(), container, prevLogs); err != nil { + l.app.Flash().Err(err) + l := l.CurrentPage().Item.(*Log) l.log("😂 Doh! No logs are available at this time. Check again later on...") return } - v.app.SetFocus(v) + l.app.SetFocus(l) } -func (v *logsView) doLoad(path, co string, prevLogs bool) error { - v.stop() +func (l *Logs) doLoad(path, co string, prevLogs bool) error { + l.stop() - l := v.CurrentPage().Item.(*logView) - l.logs.Clear() - l.setTitle(path, co) + v := l.CurrentPage().Item.(*Log) + v.logs.Clear() + v.setTitle(path, co) var ctx context.Context - ctx = context.WithValue(context.Background(), resource.IKey("informer"), v.app.informer) - ctx, v.cancelFunc = context.WithCancel(ctx) + ctx = context.WithValue(context.Background(), resource.IKey("informer"), l.app.informer) + ctx, l.cancelFunc = context.WithCancel(ctx) c := make(chan string, 10) - go updateLogs(ctx, c, l, logBuffSize) + go updateLogs(ctx, c, v, logBuffSize) - res, ok := v.parent.getList().Resource().(resource.Tailable) + res, ok := l.parent.getList().Resource().(resource.Tailable) if !ok { close(c) - return fmt.Errorf("Resource %T is not tailable", v.parent.getList().Resource()) + return fmt.Errorf("Resource %T is not tailable", l.parent.getList().Resource()) } - if err := res.Logs(ctx, c, v.logOpts(path, co, prevLogs)); err != nil { - v.cancelFunc() + if err := res.Logs(ctx, c, l.logOpts(path, co, prevLogs)); err != nil { + l.cancelFunc() close(c) return err } @@ -122,7 +122,7 @@ func (v *logsView) doLoad(path, co string, prevLogs bool) error { return nil } -func (v *logsView) logOpts(path, co string, prevLogs bool) resource.LogOptions { +func (l *Logs) logOpts(path, co string, prevLogs bool) resource.LogOptions { ns, po := namespaced(path) return resource.LogOptions{ Fqn: resource.Fqn{ @@ -130,12 +130,12 @@ func (v *logsView) logOpts(path, co string, prevLogs bool) resource.LogOptions { Name: po, Container: co, }, - Lines: int64(v.app.Config.K9s.LogRequestSize), + Lines: int64(l.app.Config.K9s.LogRequestSize), Previous: prevLogs, } } -func updateLogs(ctx context.Context, c <-chan string, l *logView, buffSize int) { +func updateLogs(ctx context.Context, c <-chan string, l *Log, buffSize int) { defer func() { log.Debug().Msgf("updateLogs view bailing out!") }() @@ -169,9 +169,9 @@ func updateLogs(ctx context.Context, c <-chan string, l *logView, buffSize int) // ---------------------------------------------------------------------------- // Actions... -func (v *logsView) backCmd(evt *tcell.EventKey) *tcell.EventKey { - v.stop() - v.parent.switchPage("master") +func (l *Logs) backCmd(evt *tcell.EventKey) *tcell.EventKey { + l.stop() + l.parent.switchPage("master") return evt } diff --git a/internal/view/logs_test.go b/internal/view/logs_test.go new file mode 100644 index 00000000..693b30a1 --- /dev/null +++ b/internal/view/logs_test.go @@ -0,0 +1,21 @@ +package view + +// func TestUpdateLogs(t *testing.T) { +// v := newLogView("test", NewApp(config.NewConfig(ks{})), nil) + +// var wg sync.WaitGroup +// wg.Add(1) +// c := make(chan string, 10) +// go func() { +// defer wg.Done() +// updateLogs(context.Background(), c, v, 10) +// }() + +// for i := 0; i < 500; i++ { +// c <- fmt.Sprintf("log %d", i) +// } +// close(c) +// wg.Wait() + +// assert.Equal(t, 500, v.logs.GetLineCount()) +// } diff --git a/internal/view/master_detail.go b/internal/view/master_detail.go new file mode 100644 index 00000000..184cb188 --- /dev/null +++ b/internal/view/master_detail.go @@ -0,0 +1,73 @@ +package view + +import ( + "context" + + "github.com/derailed/k9s/internal/ui" +) + +// MasterDetail presents a master-detail viewer. +type MasterDetail struct { + *PageStack + + enterFn enterFn + extraActionsFn func(ui.KeyActions) + details *Details +} + +// NewMasterDetail returns a new master-detail viewer. +func NewMasterDetail() *MasterDetail { + return &MasterDetail{ + PageStack: NewPageStack(), + } +} + +// Init initializes the viewer. +func (m *MasterDetail) Init(ctx context.Context) { + m.PageStack.Init(ctx) + + t := NewTable("master") + t.Init(ctx) + m.Push(t) + + m.details = NewDetails(m.app, nil) + m.details.Init(ctx) +} + +func (m *MasterDetail) setExtraActionsFn(f ActionsFunc) { + m.extraActionsFn = f +} + +// Protocol... + +func (m *MasterDetail) setEnterFn(f enterFn) { + m.enterFn = f +} + +func (m *MasterDetail) showMaster() { + m.Show("table") +} + +func (m *MasterDetail) masterPage() *Table { + return m.GetPrimitive("table").(*Table) +} + +func (m *MasterDetail) showDetails() { + m.Push(m.details) +} + +func (m *MasterDetail) detailsPage() *Details { + return m.details +} + +// ---------------------------------------------------------------------------- +// Actions... + +func (m *MasterDetail) defaultActions(aa ui.KeyActions) { + aa[ui.KeyHelp] = ui.NewKeyAction("Help", noopCmd, false) + aa[ui.KeyP] = ui.NewKeyAction("Previous", m.app.PrevCmd, false) + + if m.extraActionsFn != nil { + m.extraActionsFn(aa) + } +} diff --git a/internal/view/namespace_test.go b/internal/view/namespace_test.go new file mode 100644 index 00000000..d81840e5 --- /dev/null +++ b/internal/view/namespace_test.go @@ -0,0 +1,27 @@ +package view + +// import ( +// "testing" + +// "github.com/stretchr/testify/assert" +// ) + +// func TestNSCleanser(t *testing.T) { +// var v namespaceView + +// uu := []struct { +// s, e string +// }{ +// {"fred", "fred"}, +// {"fred+", "fred"}, +// {"fred(*)", "fred"}, +// {"fred+(*)", "fred"}, +// {"fred-blee+(*)", "fred-blee"}, +// {"fred1-blee2+(*)", "fred1-blee2"}, +// {"fred(𝜟)", "fred"}, +// } + +// for _, u := range uu { +// assert.Equal(t, u.e, v.cleanser(u.s)) +// } +// } diff --git a/internal/view/no.go b/internal/view/no.go new file mode 100644 index 00000000..fed2be2d --- /dev/null +++ b/internal/view/no.go @@ -0,0 +1,67 @@ +package view + +import ( + "github.com/derailed/k9s/internal/resource" + "github.com/derailed/k9s/internal/ui" + "github.com/gdamore/tcell" +) + +// Node represents a node view. +type Node struct { + *Resource +} + +// NewNode returns a new node view. +func NewNode(title, gvr string, list resource.List) ResourceViewer { + n := Node{ + Resource: NewResource(title, gvr, list), + } + n.extraActionsFn = n.extraActions + n.enterFn = n.showPods + + return &n +} + +func (n *Node) extraActions(aa ui.KeyActions) { + aa[ui.KeyShiftC] = ui.NewKeyAction("Sort CPU", n.sortColCmd(7, false), false) + aa[ui.KeyShiftM] = ui.NewKeyAction("Sort MEM", n.sortColCmd(8, false), false) + aa[ui.KeyShiftX] = ui.NewKeyAction("Sort CPU%", n.sortColCmd(9, false), false) + aa[ui.KeyShiftZ] = ui.NewKeyAction("Sort MEM%", n.sortColCmd(10, false), false) +} + +func (n *Node) sortColCmd(col int, asc bool) func(evt *tcell.EventKey) *tcell.EventKey { + return func(evt *tcell.EventKey) *tcell.EventKey { + t := n.masterPage() + t.SetSortCol(t.NameColIndex()+col, 0, asc) + t.Refresh() + + return nil + } +} + +func (n *Node) showPods(app *App, _, _, sel string) { + showPods(app, "", "", "spec.nodeName="+sel, n.backCmd) +} + +func (n *Node) backCmd(evt *tcell.EventKey) *tcell.EventKey { + // BOZO!! + // n.App.inject(v) + + return nil +} + +func showPods(app *App, ns, labelSel, fieldSel string, a ui.ActionHandler) { + app.switchNS(ns) + + list := resource.NewPodList(app.Conn(), ns) + list.SetLabelSelector(labelSel) + list.SetFieldSelector(fieldSel) + + v := NewPod("Pod", "v1/pods", list) + v.setColorerFn(podColorer) + v.masterPage().AddActions(ui.KeyActions{ + tcell.KeyEsc: ui.NewKeyAction("Back", a, true), + }) + app.Config.SetActiveNamespace(ns) + app.inject(v) +} diff --git a/internal/view/ns.go b/internal/view/ns.go new file mode 100644 index 00000000..c6acec5d --- /dev/null +++ b/internal/view/ns.go @@ -0,0 +1,92 @@ +package view + +import ( + "regexp" + + "github.com/derailed/k9s/internal/config" + "github.com/derailed/k9s/internal/resource" + "github.com/derailed/k9s/internal/ui" + "github.com/gdamore/tcell" +) + +const ( + favNSIndicator = "+" + defaultNSIndicator = "(*)" + deltaNSIndicator = "(𝜟)" +) + +var nsCleanser = regexp.MustCompile(`(\w+)[+|(*)|(𝜟)]*`) + +// Namespace represents a namespace viewer. +type Namespace struct { + *Resource +} + +// NewNamespace returns a new viewer +func NewNamespace(title, gvr string, list resource.List) ResourceViewer { + n := Namespace{ + Resource: NewResource(title, gvr, list), + } + n.extraActionsFn = n.extraActions + n.masterPage().SetSelectedFn(n.cleanser) + n.decorateFn = n.decorate + n.enterFn = n.switchNs + + return &n +} + +func (n *Namespace) extraActions(aa ui.KeyActions) { + aa[ui.KeyU] = ui.NewKeyAction("Use", n.useNsCmd, true) +} + +func (n *Namespace) switchNs(app *App, _, res, sel string) { + n.useNamespace(sel) + app.gotoResource("po", true) +} + +func (n *Namespace) useNsCmd(evt *tcell.EventKey) *tcell.EventKey { + if !n.masterPage().RowSelected() { + return evt + } + n.useNamespace(n.masterPage().GetSelectedItem()) + + return nil +} + +func (n *Namespace) useNamespace(ns string) { + if err := n.app.Config.SetActiveNamespace(ns); err != nil { + n.app.Flash().Err(err) + } else { + n.app.Flash().Infof("Namespace %s is now active!", ns) + } + n.app.Config.Save() + n.app.startInformer(ns) +} + +func (*Namespace) cleanser(s string) string { + return nsCleanser.ReplaceAllString(s, `$1`) +} + +func (n *Namespace) decorate(data resource.TableData) resource.TableData { + if _, ok := data.Rows[resource.AllNamespaces]; !ok { + if err := n.app.Conn().CheckNSAccess(""); err == nil { + data.Rows[resource.AllNamespace] = &resource.RowEvent{ + Action: resource.Unchanged, + Fields: resource.Row{resource.AllNamespace, "Active", "0"}, + Deltas: resource.Row{"", "", ""}, + } + } + } + for k, r := range data.Rows { + if config.InList(n.app.Config.FavNamespaces(), k) { + r.Fields[0] += "+" + r.Action = resource.Unchanged + } + if n.app.Config.ActiveNamespace() == k { + r.Fields[0] += "(*)" + r.Action = resource.Unchanged + } + } + + return data +} diff --git a/internal/view/page_stack.go b/internal/view/page_stack.go new file mode 100644 index 00000000..811b95f5 --- /dev/null +++ b/internal/view/page_stack.go @@ -0,0 +1,61 @@ +package view + +import ( + "context" + + "github.com/derailed/k9s/internal/model" + "github.com/derailed/k9s/internal/ui" + "github.com/rs/zerolog/log" +) + +type PageStack struct { + *ui.Pages + + app *App +} + +func NewPageStack() *PageStack { + return &PageStack{ + Pages: ui.NewPages(), + } +} + +func (p *PageStack) Init(ctx context.Context) { + p.app = ctx.Value(ui.KeyApp).(*App) + + p.Pages.SetChangedFunc(func() { + log.Debug().Msgf(">>>>>PS CHNGED<<<<<") + p.DumpStack() + active := p.CurrentPage() + if active == nil { + return + } + c := active.Item.(model.Component) + log.Debug().Msgf("-------Page activated %#v", active) + p.app.Hint.SetHints(c.Hints()) + }) + + p.Pages.SetTitle("Fuck!") + p.Stack.AddListener(p) +} + +func (p *PageStack) StackPushed(c model.Component) { + ctx := context.WithValue(context.Background(), ui.KeyApp, p.app) + c.Init(ctx) + p.app.SetFocus(c) + p.app.Hint.SetHints(c.Hints()) +} + +func (p *PageStack) StackPopped(o, top model.Component) { + o.Stop() + p.StackTop(top) +} + +func (p *PageStack) StackTop(top model.Component) { + if top == nil { + return + } + top.Start() + p.app.SetFocus(top) + p.app.Hint.SetHints(top.Hints()) +} diff --git a/internal/view/pod.go b/internal/view/pod.go new file mode 100644 index 00000000..0b6eef9e --- /dev/null +++ b/internal/view/pod.go @@ -0,0 +1,222 @@ +package view + +import ( + "fmt" + + "github.com/derailed/k9s/internal/resource" + "github.com/derailed/k9s/internal/ui" + "github.com/derailed/k9s/internal/watch" + "github.com/gdamore/tcell" + "github.com/rs/zerolog/log" + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const ( + containerFmt = "[fg:bg:b]%s([hilite:bg:b]%s[fg:bg:-])" + shellCheck = "command -v bash >/dev/null && exec bash || exec sh" +) + +type loggable interface { + getSelection() string + getList() resource.List + switchPage(n string) +} + +// Pod represents a pod viewer. +type Pod struct { + *Resource +} + +// NewPod returns a new viewer. +func NewPod(title, gvr string, list resource.List) ResourceViewer { + p := Pod{ + Resource: NewResource(title, gvr, list), + } + p.extraActionsFn = p.extraActions + p.enterFn = p.listContainers + + picker := newSelectList(&p) + { + picker.setActions(ui.KeyActions{ + tcell.KeyEscape: {Description: "Back", Action: p.backCmd, Visible: true}, + }) + } + p.AddPage("picker", picker, true, false) + p.AddPage("logs", NewLogs(list.GetName(), &p), true, false) + + return &p +} + +func (p *Pod) extraActions(aa ui.KeyActions) { + aa[tcell.KeyCtrlK] = ui.NewKeyAction("Kill", p.killCmd, true) + aa[ui.KeyS] = ui.NewKeyAction("Shell", p.shellCmd, true) + + aa[ui.KeyL] = ui.NewKeyAction("Logs", p.logsCmd, true) + aa[ui.KeyShiftL] = ui.NewKeyAction("Logs Previous", p.prevLogsCmd, true) + + aa[ui.KeyShiftR] = ui.NewKeyAction("Sort Ready", p.sortColCmd(1, false), false) + aa[ui.KeyShiftS] = ui.NewKeyAction("Sort Status", p.sortColCmd(2, true), false) + aa[ui.KeyShiftT] = ui.NewKeyAction("Sort Restart", p.sortColCmd(3, false), false) + aa[ui.KeyShiftC] = ui.NewKeyAction("Sort CPU", p.sortColCmd(4, false), false) + aa[ui.KeyShiftM] = ui.NewKeyAction("Sort MEM", p.sortColCmd(5, false), false) + aa[ui.KeyShiftX] = ui.NewKeyAction("Sort CPU%", p.sortColCmd(6, false), false) + aa[ui.KeyShiftZ] = ui.NewKeyAction("Sort MEM%", p.sortColCmd(7, false), false) + aa[ui.KeyShiftD] = ui.NewKeyAction("Sort IP", p.sortColCmd(8, true), false) + aa[ui.KeyShiftO] = ui.NewKeyAction("Sort Node", p.sortColCmd(9, true), false) +} + +func (p *Pod) listContainers(app *App, _, res, sel string) { + po, err := p.app.informer.Get(watch.PodIndex, sel, metav1.GetOptions{}) + if err != nil { + app.Flash().Errf("Unable to retrieve pods %s", err) + return + } + + pod := po.(*v1.Pod) + list := resource.NewContainerList(app.Conn(), pod) + title := skinTitle(fmt.Sprintf(containerFmt, "Container", sel), app.Styles.Frame()) + + // Stop my updater + if p.cancelFn != nil { + p.cancelFn() + } + + // Span child view + v := NewContainer(title, list, fqn(pod.Namespace, pod.Name)) + p.app.inject(v) +} + +// Protocol... + +func (p *Pod) getList() resource.List { + return p.list +} + +func (p *Pod) getSelection() string { + return p.masterPage().GetSelectedItem() +} + +func (p *Pod) killCmd(evt *tcell.EventKey) *tcell.EventKey { + if !p.masterPage().RowSelected() { + return evt + } + sel := p.masterPage().GetSelectedItems() + p.masterPage().ShowDeleted() + for _, res := range sel { + p.app.Flash().Infof("Delete resource %s %s", p.list.GetName(), res) + if err := p.list.Resource().Delete(res, true, false); err != nil { + p.app.Flash().Errf("Delete failed with %s", err) + } else { + deletePortForward(p.app.forwarders, res) + } + } + p.refresh() + + return nil +} + +func (p *Pod) logsCmd(evt *tcell.EventKey) *tcell.EventKey { + if p.viewLogs(false) { + return nil + } + + return evt +} + +func (p *Pod) prevLogsCmd(evt *tcell.EventKey) *tcell.EventKey { + if p.viewLogs(true) { + return nil + } + + return evt +} + +func (p *Pod) viewLogs(prev bool) bool { + if !p.masterPage().RowSelected() { + return false + } + p.showLogs(p.masterPage().GetSelectedItem(), "", p, prev) + + return true +} + +func (p *Pod) showLogs(path, co string, parent loggable, prev bool) { + l := p.GetPrimitive("logs").(*Logs) + l.reload(co, parent, prev) + p.switchPage("logs") +} + +func (p *Pod) shellCmd(evt *tcell.EventKey) *tcell.EventKey { + if !p.masterPage().RowSelected() { + return evt + } + + sel := p.masterPage().GetSelectedItem() + cc, err := fetchContainers(p.list, sel, false) + if err != nil { + p.app.Flash().Errf("Unable to retrieve containers %s", err) + return evt + } + if len(cc) == 1 { + p.shellIn(sel, "") + return nil + } + picker := p.GetPrimitive("picker").(*selectList) + picker.populate(cc) + picker.SetSelectedFunc(func(i int, t, d string, r rune) { + p.shellIn(sel, t) + }) + p.switchPage("picker") + + return evt +} + +func (p *Pod) shellIn(path, co string) { + p.Stop() + shellIn(p.app, path, co) + p.Start() +} + +func (p *Pod) sortColCmd(col int, asc bool) func(evt *tcell.EventKey) *tcell.EventKey { + return func(evt *tcell.EventKey) *tcell.EventKey { + t := p.masterPage() + t.SetSortCol(t.NameColIndex()+col, 0, asc) + t.Refresh() + + return nil + } +} + +// ---------------------------------------------------------------------------- +// Helpers... + +func fetchContainers(l resource.List, po string, includeInit bool) ([]string, error) { + if len(po) == 0 { + return []string{}, nil + } + return l.Resource().(resource.Containers).Containers(po, includeInit) +} + +func shellIn(a *App, path, co string) { + args := computeShellArgs(path, co, a.Config.K9s.CurrentContext, a.Conn().Config().Flags().KubeConfig) + log.Debug().Msgf("Shell args %v", args) + runK(true, a, args...) +} + +func computeShellArgs(path, co, context string, kcfg *string) []string { + args := make([]string, 0, 15) + args = append(args, "exec", "-it") + args = append(args, "--context", context) + ns, po := namespaced(path) + args = append(args, "-n", ns) + args = append(args, po) + if kcfg != nil && *kcfg != "" { + args = append(args, "--kubeconfig", *kcfg) + } + if co != "" { + args = append(args, "-c", co) + } + + return append(args, "--", "sh", "-c", shellCheck) +} diff --git a/internal/views/pod_test.go b/internal/view/pod_int_test.go similarity index 98% rename from internal/views/pod_test.go rename to internal/view/pod_int_test.go index ec917ee5..2f745f3c 100644 --- a/internal/views/pod_test.go +++ b/internal/view/pod_int_test.go @@ -1,4 +1,4 @@ -package views +package view import ( "strings" diff --git a/internal/view/pod_test.go b/internal/view/pod_test.go new file mode 100644 index 00000000..1980877c --- /dev/null +++ b/internal/view/pod_test.go @@ -0,0 +1,15 @@ +package view_test + +import ( + "testing" + + "github.com/derailed/k9s/internal/resource" + "github.com/derailed/k9s/internal/view" + "github.com/stretchr/testify/assert" +) + +func TestPodNew(t *testing.T) { + po := view.NewPod("test", "blee", resource.NewPodList(nil, "")) + + assert.Equal(t, "po", po.Name()) +} diff --git a/internal/views/policy.go b/internal/view/policy.go similarity index 50% rename from internal/views/policy.go rename to internal/view/policy.go index dfeff84b..0a356c23 100644 --- a/internal/views/policy.go +++ b/internal/view/policy.go @@ -1,10 +1,11 @@ -package views +package view import ( "context" "fmt" "time" + "github.com/derailed/k9s/internal/model" "github.com/derailed/k9s/internal/resource" "github.com/derailed/k9s/internal/ui" "github.com/gdamore/tcell" @@ -22,10 +23,10 @@ type ( ns, role string } - policyView struct { - *tableView + // Policy presents a RBAC policy viewer. + Policy struct { + *Table - current ui.Igniter cancel context.CancelFunc subjectKind string subjectName string @@ -33,101 +34,110 @@ type ( } ) -func newPolicyView(app *appView, subject, name string) *policyView { - v := policyView{} - { - v.subjectKind, v.subjectName = mapSubject(subject), name - v.tableView = newTableView(app, v.getTitle()) - v.SetColorerFn(rbacColorer) - v.current = app.Frame().GetPrimitive("main").(ui.Igniter) - v.bindKeys() - } +// NewPolicy returns a new viewer. +func NewPolicy(app *App, subject, name string) *Policy { + p := Policy{} + p.subjectKind, p.subjectName = mapSubject(subject), name + p.Table = NewTable(p.getTitle()) + p.SetColorerFn(rbacColorer) + p.bindKeys() - return &v + return &p } // Init the view. -func (v *policyView) Init(c context.Context, ns string) { - v.SetSortCol(1, len(rbacHeader), false) +func (p *Policy) Init(ctx context.Context) { + p.Table.Init(ctx) - ctx, cancel := context.WithCancel(c) - v.cancel = cancel + p.SetSortCol(1, len(rbacHeader), false) + p.Start() + p.refresh() + p.SelectRow(1, true) +} + +func (p *Policy) Name() string { + return "policy" +} + +func (p *Policy) Start() { + ctx, cancel := context.WithCancel(context.Background()) + p.cancel = cancel go func(ctx context.Context) { for { select { case <-ctx.Done(): return - case <-time.After(time.Duration(v.app.Config.K9s.GetRefreshRate()) * time.Second): - v.refresh() - v.app.Draw() + case <-time.After(time.Duration(p.app.Config.K9s.GetRefreshRate()) * time.Second): + p.refresh() + p.app.Draw() } } }(ctx) - - v.refresh() - v.SelectRow(1, true) - v.app.SetFocus(v) } -func (v *policyView) bindKeys() { - v.RmAction(ui.KeyShiftA) +func (p *Policy) Stop() { + if p.cancel != nil { + p.cancel() + } +} - v.SetActions(ui.KeyActions{ - tcell.KeyEscape: ui.NewKeyAction("Reset", v.resetCmd, false), - ui.KeySlash: ui.NewKeyAction("Filter", v.activateCmd, false), - ui.KeyP: ui.NewKeyAction("Previous", v.app.prevCmd, false), - ui.KeyShiftS: ui.NewKeyAction("Sort Namespace", v.SortColCmd(0), false), - ui.KeyShiftN: ui.NewKeyAction("Sort Name", v.SortColCmd(1), false), - ui.KeyShiftO: ui.NewKeyAction("Sort Group", v.SortColCmd(2), false), - ui.KeyShiftB: ui.NewKeyAction("Sort Binding", v.SortColCmd(3), false), +func (p *Policy) bindKeys() { + p.RmAction(ui.KeyShiftA) + + p.AddActions(ui.KeyActions{ + tcell.KeyEscape: ui.NewKeyAction("Reset", p.resetCmd, false), + ui.KeySlash: ui.NewKeyAction("Filter", p.activateCmd, false), + ui.KeyP: ui.NewKeyAction("Previous", p.app.PrevCmd, false), + ui.KeyShiftS: ui.NewKeyAction("Sort Namespace", p.SortColCmd(0), false), + ui.KeyShiftN: ui.NewKeyAction("Sort Name", p.SortColCmd(1), false), + ui.KeyShiftO: ui.NewKeyAction("Sort Group", p.SortColCmd(2), false), + ui.KeyShiftB: ui.NewKeyAction("Sort Binding", p.SortColCmd(3), false), }) } -func (v *policyView) getTitle() string { - return fmt.Sprintf(rbacTitleFmt, policyTitle, v.subjectKind+":"+v.subjectName) +func (p *Policy) getTitle() string { + return fmt.Sprintf(rbacTitleFmt, policyTitle, p.subjectKind+":"+p.subjectName) } -func (v *policyView) refresh() { - data, err := v.reconcile() +func (p *Policy) refresh() { + data, err := p.reconcile() if err != nil { - log.Error().Err(err).Msgf("Refresh for %s:%s", v.subjectKind, v.subjectName) - v.app.Flash().Err(err) + log.Error().Err(err).Msgf("Refresh for %s:%s", p.subjectKind, p.subjectName) + p.app.Flash().Err(err) } - v.Update(data) + p.Update(data) } -func (v *policyView) resetCmd(evt *tcell.EventKey) *tcell.EventKey { - if !v.SearchBuff().Empty() { - v.SearchBuff().Reset() +func (p *Policy) resetCmd(evt *tcell.EventKey) *tcell.EventKey { + if !p.SearchBuff().Empty() { + p.SearchBuff().Reset() return nil } - return v.backCmd(evt) + return p.backCmd(evt) } -func (v *policyView) backCmd(evt *tcell.EventKey) *tcell.EventKey { - if v.cancel != nil { - v.cancel() +func (p *Policy) backCmd(evt *tcell.EventKey) *tcell.EventKey { + if p.cancel != nil { + p.cancel() } - if v.SearchBuff().IsActive() { - v.SearchBuff().Reset() + if p.SearchBuff().IsActive() { + p.SearchBuff().Reset() return nil } - v.app.inject(v.current) - - return nil + return p.app.PrevCmd(evt) } -func (v *policyView) Hints() ui.Hints { - return v.Hints() +func (p *Policy) Hints() model.MenuHints { + return p.Hints() } -func (v *policyView) reconcile() (resource.TableData, error) { +func (p *Policy) reconcile() (resource.TableData, error) { var table resource.TableData - evts, errs := v.clusterPolicies() + evts, errs := p.clusterPolicies() if len(errs) > 0 { for _, err := range errs { log.Error().Err(err).Msg("Unable to find cluster policies") @@ -135,7 +145,7 @@ func (v *policyView) reconcile() (resource.TableData, error) { return table, errs[0] } - nevts, errs := v.namespacedPolicies() + nevts, errs := p.namespacedPolicies() if len(errs) > 0 { for _, err := range errs { log.Error().Err(err).Msg("Unable to find cluster policies") @@ -147,28 +157,28 @@ func (v *policyView) reconcile() (resource.TableData, error) { evts[k] = v } - return buildTable(v, evts), nil + return buildTable(p, evts), nil } // Protocol... -func (v *policyView) header() resource.Row { +func (p *Policy) header() resource.Row { return policyHeader } -func (v *policyView) getCache() resource.RowEvents { - return v.cache +func (p *Policy) getCache() resource.RowEvents { + return p.cache } -func (v *policyView) setCache(evts resource.RowEvents) { - v.cache = evts +func (p *Policy) setCache(evts resource.RowEvents) { + p.cache = evts } -func (v *policyView) clusterPolicies() (resource.RowEvents, []error) { +func (p *Policy) clusterPolicies() (resource.RowEvents, []error) { var errs []error evts := make(resource.RowEvents) - crbs, err := v.app.Conn().DialOrDie().RbacV1().ClusterRoleBindings().List(metav1.ListOptions{}) + crbs, err := p.app.Conn().DialOrDie().RbacV1().ClusterRoleBindings().List(metav1.ListOptions{}) if err != nil { return evts, errs } @@ -176,18 +186,18 @@ func (v *policyView) clusterPolicies() (resource.RowEvents, []error) { var rr []string for _, crb := range crbs.Items { for _, s := range crb.Subjects { - if s.Kind == v.subjectKind && s.Name == v.subjectName { + if s.Kind == p.subjectKind && s.Name == p.subjectName { rr = append(rr, crb.RoleRef.Name) } } } for _, r := range rr { - role, err := v.app.Conn().DialOrDie().RbacV1().ClusterRoles().Get(r, metav1.GetOptions{}) + role, err := p.app.Conn().DialOrDie().RbacV1().ClusterRoles().Get(r, metav1.GetOptions{}) if err != nil { errs = append(errs, err) } - for k, v := range v.parseRules("*", "CR:"+r, role.Rules) { + for k, v := range p.parseRules("*", "CR:"+r, role.Rules) { evts[k] = v } } @@ -195,10 +205,10 @@ func (v *policyView) clusterPolicies() (resource.RowEvents, []error) { return evts, errs } -func (v policyView) loadRoleBindings() ([]namespacedRole, error) { +func (p *Policy) loadRoleBindings() ([]namespacedRole, error) { var rr []namespacedRole - dial := v.app.Conn().DialOrDie().RbacV1() + dial := p.app.Conn().DialOrDie().RbacV1() rbs, err := dial.RoleBindings("").List(metav1.ListOptions{}) if err != nil { return rr, err @@ -206,7 +216,7 @@ func (v policyView) loadRoleBindings() ([]namespacedRole, error) { for _, rb := range rbs.Items { for _, s := range rb.Subjects { - if s.Kind == v.subjectKind && s.Name == v.subjectName { + if s.Kind == p.subjectKind && s.Name == p.subjectName { rr = append(rr, namespacedRole{rb.Namespace, rb.RoleRef.Name}) } } @@ -215,16 +225,16 @@ func (v policyView) loadRoleBindings() ([]namespacedRole, error) { return rr, nil } -func (v *policyView) loadRoles(errs []error, rr []namespacedRole) (resource.RowEvents, []error) { +func (p *Policy) loadRoles(errs []error, rr []namespacedRole) (resource.RowEvents, []error) { var ( - dial = v.app.Conn().DialOrDie().RbacV1() + dial = p.app.Conn().DialOrDie().RbacV1() evts = make(resource.RowEvents) ) for _, r := range rr { if cr, err := dial.Roles(r.ns).Get(r.role, metav1.GetOptions{}); err != nil { errs = append(errs, err) } else { - for k, v := range v.parseRules(r.ns, "RO:"+r.role, cr.Rules) { + for k, v := range p.parseRules(r.ns, "RO:"+r.role, cr.Rules) { evts[k] = v } } @@ -233,18 +243,18 @@ func (v *policyView) loadRoles(errs []error, rr []namespacedRole) (resource.RowE return evts, errs } -func (v *policyView) namespacedPolicies() (resource.RowEvents, []error) { +func (p *Policy) namespacedPolicies() (resource.RowEvents, []error) { var errs []error - rr, err := v.loadRoleBindings() + rr, err := p.loadRoleBindings() if err != nil { errs = append(errs, err) } - evts, errs := v.loadRoles(errs, rr) + evts, errs := p.loadRoles(errs, rr) return evts, errs } -func (v *policyView) parseRules(ns, binding string, rules []rbacv1.PolicyRule) resource.RowEvents { +func (p *Policy) parseRules(ns, binding string, rules []rbacv1.PolicyRule) resource.RowEvents { m := make(resource.RowEvents, len(rules)) for _, r := range rules { for _, grp := range r.APIGroups { diff --git a/internal/view/port_forward.go b/internal/view/port_forward.go new file mode 100644 index 00000000..6a62d0b1 --- /dev/null +++ b/internal/view/port_forward.go @@ -0,0 +1,389 @@ +package view + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/derailed/k9s/internal/config" + "github.com/derailed/k9s/internal/model" + "github.com/derailed/k9s/internal/perf" + "github.com/derailed/k9s/internal/resource" + "github.com/derailed/k9s/internal/ui" + "github.com/derailed/tview" + "github.com/fsnotify/fsnotify" + "github.com/gdamore/tcell" + "github.com/rs/zerolog/log" +) + +const ( + forwardTitle = "Port Forwards" + forwardTitleFmt = " [aqua::b]%s([fuchsia::b]%d[fuchsia::-])[aqua::-] " + promptPage = "prompt" +) + +// PortForward presents active portforward viewer. +type PortForward struct { + *ui.Pages + + cancelFn context.CancelFunc + bench *perf.Benchmark + app *App +} + +// NewPortForward returns a new viewer. +func NewPortForward(title, _ string, list resource.List) ResourceViewer { + return &PortForward{ + Pages: ui.NewPages(), + } +} + +// Init the view. +func (p *PortForward) Init(ctx context.Context) { + p.app = ctx.Value(ui.KeyApp).(*App) + + tv := NewTable(forwardTitle) + tv.Init(ctx) + tv.SetBorderFocusColor(tcell.ColorDodgerBlue) + tv.SetSelectedStyle(tcell.ColorWhite, tcell.ColorDodgerBlue, tcell.AttrNone) + tv.SetColorerFn(forwardColorer) + tv.SetActiveNS("") + tv.SetSortCol(tv.NameColIndex()+6, 0, true) + tv.Select(1, 0) + p.Push(tv) + + p.registerActions() + p.Start() + p.refresh() +} + +func (p *PortForward) Start() { + path := ui.BenchConfig(p.app.Config.K9s.CurrentCluster) + var ctx context.Context + ctx, p.cancelFn = context.WithCancel(context.Background()) + if err := watchFS(ctx, p.app, config.K9sHome, path, p.reload); err != nil { + p.app.Flash().Errf("RuRoh! Unable to watch benchmarks directory %s : %s", config.K9sHome, err) + } +} + +func (p *PortForward) Stop() {} + +func (p *PortForward) Name() string { + return "portForwards" +} + +func (p *PortForward) masterPage() *Table { + return p.GetPrimitive("table").(*Table) +} + +func (p *PortForward) setEnterFn(enterFn) {} +func (p *PortForward) setColorerFn(ui.ColorerFunc) {} +func (p *PortForward) setDecorateFn(decorateFn) {} +func (p *PortForward) setExtraActionsFn(ActionsFunc) {} + +func (p *PortForward) getTV() *Table { + if vu, ok := p.GetPrimitive("table").(*Table); ok { + return vu + } + return nil +} + +func (p *PortForward) reload() { + path := ui.BenchConfig(p.app.Config.K9s.CurrentCluster) + log.Debug().Msgf("Reloading Config %s", path) + if err := p.app.Bench.Reload(path); err != nil { + p.app.Flash().Err(err) + } + p.refresh() +} + +func (p *PortForward) refresh() { + tv := p.getTV() + tv.Update(p.hydrate()) + p.app.SetFocus(tv) + tv.UpdateTitle() +} + +func (p *PortForward) registerActions() { + tv := p.getTV() + tv.AddActions(ui.KeyActions{ + tcell.KeyEnter: ui.NewKeyAction("Goto", p.gotoBenchCmd, true), + tcell.KeyCtrlB: ui.NewKeyAction("Bench", p.benchCmd, true), + tcell.KeyCtrlK: ui.NewKeyAction("Bench Stop", p.benchStopCmd, true), + tcell.KeyCtrlD: ui.NewKeyAction("Delete", p.deleteCmd, true), + ui.KeySlash: ui.NewKeyAction("Filter", tv.activateCmd, false), + ui.KeyP: ui.NewKeyAction("Previous", p.app.PrevCmd, false), + ui.KeyShiftP: ui.NewKeyAction("Sort Ports", p.sortColCmd(2, true), false), + ui.KeyShiftU: ui.NewKeyAction("Sort URL", p.sortColCmd(4, true), false), + }) +} + +func (p *PortForward) getTitle() string { + return forwardTitle +} + +func (p *PortForward) sortColCmd(col int, asc bool) func(evt *tcell.EventKey) *tcell.EventKey { + return func(evt *tcell.EventKey) *tcell.EventKey { + tv := p.getTV() + tv.SetSortCol(tv.NameColIndex()+col, 0, asc) + p.refresh() + + return nil + } +} + +func (p *PortForward) gotoBenchCmd(evt *tcell.EventKey) *tcell.EventKey { + p.app.gotoResource("be", true) + + return nil +} + +func (p *PortForward) benchStopCmd(evt *tcell.EventKey) *tcell.EventKey { + if p.bench != nil { + log.Debug().Msg(">>> Benchmark cancelFned!!") + p.app.status(ui.FlashErr, "Benchmark Camceled!") + p.bench.Cancel() + } + p.app.StatusReset() + + return nil +} + +func (p *PortForward) benchCmd(evt *tcell.EventKey) *tcell.EventKey { + sel := p.getSelectedItem() + if sel == "" { + return nil + } + + if p.bench != nil { + p.app.Flash().Err(errors.New("Only one benchmark allowed at a time")) + return nil + } + + tv := p.getTV() + r, _ := tv.GetSelection() + cfg, co := defaultConfig(), ui.TrimCell(tv.Table, r, 2) + if b, ok := p.app.Bench.Benchmarks.Containers[containerID(sel, co)]; ok { + cfg = b + } + cfg.Name = sel + + base := ui.TrimCell(tv.Table, r, 4) + var err error + if p.bench, err = perf.NewBenchmark(base, cfg); err != nil { + p.app.Flash().Errf("Bench failed %v", err) + p.app.StatusReset() + return nil + } + + p.app.status(ui.FlashWarn, "Benchmark in progress...") + log.Debug().Msg("Bench starting...") + go p.runBenchmark() + + return nil +} + +func (p *PortForward) runBenchmark() { + p.bench.Run(p.app.Config.K9s.CurrentCluster, func() { + log.Debug().Msg("Bench Completed!") + p.app.QueueUpdate(func() { + if p.bench.Canceled() { + p.app.status(ui.FlashInfo, "Benchmark cancelFned") + } else { + p.app.status(ui.FlashInfo, "Benchmark Completed!") + p.bench.Cancel() + } + p.bench = nil + go func() { + <-time.After(2 * time.Second) + p.app.QueueUpdate(func() { p.app.StatusReset() }) + }() + }) + }) +} + +func (p *PortForward) getSelectedItem() string { + tv := p.getTV() + r, _ := tv.GetSelection() + if r == 0 { + return "" + } + return fwFQN( + fqn(ui.TrimCell(tv.Table, r, 0), ui.TrimCell(tv.Table, r, 1)), + ui.TrimCell(tv.Table, r, 2), + ) +} + +func (p *PortForward) deleteCmd(evt *tcell.EventKey) *tcell.EventKey { + tv := p.getTV() + if !tv.SearchBuff().Empty() { + tv.SearchBuff().Reset() + return nil + } + + sel := p.getSelectedItem() + if sel == "" { + return nil + } + + showModal(p.Pages, fmt.Sprintf("Delete PortForward `%s?", sel), "table", func() { + fw, ok := p.app.forwarders[sel] + if !ok { + log.Debug().Msgf("Unable to find forwarder %s", sel) + return + } + fw.Stop() + delete(p.app.forwarders, sel) + + log.Debug().Msgf("PortForwards after delete: %#v", p.app.forwarders) + p.getTV().Update(p.hydrate()) + p.app.Flash().Infof("PortForward %s deleted!", sel) + }) + + return nil +} + +func (p *PortForward) backCmd(evt *tcell.EventKey) *tcell.EventKey { + if p.cancelFn != nil { + p.cancelFn() + } + + tv := p.getTV() + if tv.SearchBuff().IsActive() { + tv.SearchBuff().Reset() + } else { + p.app.inject(p.app.Content.GetPrimitive("main").(model.Component)) + } + + return nil +} + +func (p *PortForward) Hints() model.MenuHints { + return p.getTV().Hints() +} + +func (p *PortForward) hydrate() resource.TableData { + data := initHeader(len(p.app.forwarders)) + dc, dn := p.app.Bench.Benchmarks.Defaults.C, p.app.Bench.Benchmarks.Defaults.N + for _, f := range p.app.forwarders { + c, n, cfg := loadConfig(dc, dn, containerID(f.Path(), f.Container()), p.app.Bench.Benchmarks.Containers) + + ports := strings.Split(f.Ports()[0], ":") + ns, na := namespaced(f.Path()) + fields := resource.Row{ + ns, + na, + f.Container(), + strings.Join(f.Ports(), ","), + urlFor(cfg, f.Container(), ports[0]), + asNum(c), + asNum(n), + f.Age(), + } + data.Rows[f.Path()] = &resource.RowEvent{ + Action: resource.New, + Fields: fields, + Deltas: fields, + } + } + + return data +} + +func (p *PortForward) resetTitle() { + p.SetTitle(fmt.Sprintf(forwardTitleFmt, forwardTitle, p.getTV().GetRowCount()-1)) +} + +// ---------------------------------------------------------------------------- +// Helpers... + +func defaultConfig() config.BenchConfig { + return config.BenchConfig{ + C: config.DefaultC, + N: config.DefaultN, + HTTP: config.HTTP{ + Method: config.DefaultMethod, + Path: "/", + }, + } +} + +func initHeader(rows int) resource.TableData { + return resource.TableData{ + Header: resource.Row{"NAMESPACE", "NAME", "CONTAINER", "PORTS", "URL", "C", "N", "AGE"}, + NumCols: map[string]bool{"C": true, "N": true}, + Rows: make(resource.RowEvents, rows), + Namespace: resource.AllNamespaces, + } +} + +func loadConfig(dc, dn int, id string, cc map[string]config.BenchConfig) (int, int, config.BenchConfig) { + c, n := dc, dn + cfg, ok := cc[id] + if !ok { + return c, n, cfg + } + + if cfg.C != 0 { + c = cfg.C + } + if cfg.N != 0 { + n = cfg.N + } + + return c, n, cfg +} + +func showModal(p *ui.Pages, msg, back string, ok func()) { + m := tview.NewModal(). + AddButtons([]string{"Cancel", "OK"}). + SetTextColor(tcell.ColorFuchsia). + SetText(msg). + SetDoneFunc(func(_ int, b string) { + if b == "OK" { + ok() + } + dismissModal(p, back) + }) + m.SetTitle("") + p.AddPage(promptPage, m, false, false) + p.ShowPage(promptPage) +} + +func dismissModal(p *ui.Pages, page string) { + p.RemovePage(promptPage) + p.SwitchToPage(page) +} + +func watchFS(ctx context.Context, app *App, dir, file string, cb func()) error { + w, err := fsnotify.NewWatcher() + if err != nil { + return err + } + + go func() { + for { + select { + case evt := <-w.Events: + log.Debug().Msgf("FS %s event %v", file, evt.Name) + if file == "" || evt.Name == file { + log.Debug().Msgf("Capuring Event %#v", evt) + app.QueueUpdateDraw(func() { + cb() + }) + } + case err := <-w.Errors: + log.Info().Err(err).Msgf("FS %s watcher failed", dir) + return + case <-ctx.Done(): + log.Debug().Msgf("<>", dir) + w.Close() + return + } + } + }() + + return w.Add(dir) +} diff --git a/internal/views/port_selector.go b/internal/view/port_selector.go similarity index 93% rename from internal/views/port_selector.go rename to internal/view/port_selector.go index f22c0485..225aa15a 100644 --- a/internal/views/port_selector.go +++ b/internal/view/port_selector.go @@ -1,4 +1,4 @@ -package views +package view import ( "github.com/derailed/tview" @@ -19,7 +19,7 @@ func newSelector(title, port string, okFn, cancelFn func()) *portSelector { } } -func (p *portSelector) show(app *appView) { +func (p *portSelector) show(app *App) { f := tview.NewForm() f.SetItemPadding(0) f.SetButtonsAlign(tview.AlignCenter). diff --git a/internal/views/rbac.go b/internal/view/rbac.go similarity index 56% rename from internal/views/rbac.go rename to internal/view/rbac.go index c2006875..a25daa25 100644 --- a/internal/views/rbac.go +++ b/internal/view/rbac.go @@ -1,4 +1,4 @@ -package views +package view import ( "context" @@ -64,132 +64,146 @@ var ( } ) -type ( - roleKind = int8 +type roleKind = int8 - rbacView struct { - *tableView +// RBAC presents an RBAC policy viewer. +type RBAC struct { + *Table - app *appView - current ui.Igniter - cancel context.CancelFunc - roleType roleKind - roleName string - cache resource.RowEvents - } -) + app *App + cancelFn context.CancelFunc + roleType roleKind + roleName string + cache resource.RowEvents +} -func newRBACView(app *appView, ns, name string, kind roleKind) *rbacView { - v := rbacView{ +// NewRBAC returns a new viewer. +func NewRBAC(app *App, ns, name string, kind roleKind) *RBAC { + r := RBAC{ app: app, roleName: name, roleType: kind, } - v.tableView = newTableView(app, v.getTitle()) - v.SetActiveNS(ns) - v.SetColorerFn(rbacColorer) - v.current = app.Frame().GetPrimitive("main").(ui.Igniter) - v.bindKeys() + r.Table = NewTable(r.getTitle()) + r.SetActiveNS(ns) + r.SetColorerFn(rbacColorer) + r.bindKeys() - return &v + return &r } -// Init the view. -func (v *rbacView) Init(c context.Context, ns string) { - v.SetSortCol(1, len(rbacHeader), true) +// Init initializes the view. +func (r *RBAC) Init(ctx context.Context) { + r.Table.Init(ctx) + + r.Start() + r.SetSortCol(1, len(rbacHeader), true) + r.refresh() +} + +// Start watches for viewer updates +func (r *RBAC) Start() { + r.Stop() + + var ctx context.Context + ctx, r.cancelFn = context.WithCancel(context.Background()) - ctx, cancel := context.WithCancel(c) - v.cancel = cancel go func(ctx context.Context) { for { select { case <-ctx.Done(): return - case <-time.After(time.Duration(v.app.Config.K9s.GetRefreshRate()) * time.Second): - v.app.QueueUpdateDraw(func() { - v.refresh() + case <-time.After(time.Duration(r.app.Config.K9s.GetRefreshRate()) * time.Second): + r.app.QueueUpdateDraw(func() { + r.refresh() }) } } }(ctx) - - v.refresh() - v.app.SetHints(v.Hints()) - v.app.SetFocus(v) } -func (v *rbacView) bindKeys() { - v.RmAction(ui.KeyShiftA) +// Stop terminates the viewer updater. +func (r *RBAC) Stop() { + if r.cancelFn != nil { + r.cancelFn() + } +} - v.SetActions(ui.KeyActions{ - tcell.KeyEscape: ui.NewKeyAction("Reset", v.resetCmd, false), - ui.KeySlash: ui.NewKeyAction("Filter", v.activateCmd, false), - ui.KeyP: ui.NewKeyAction("Previous", v.app.prevCmd, false), - ui.KeyShiftO: ui.NewKeyAction("Sort APIGroup", v.SortColCmd(1), false), +// Name returns the component name. +func (r *RBAC) Name() string { + return rbacTitle +} + +func (r *RBAC) bindKeys() { + r.RmAction(ui.KeyShiftA) + + r.AddActions(ui.KeyActions{ + tcell.KeyEscape: ui.NewKeyAction("Reset", r.resetCmd, false), + ui.KeySlash: ui.NewKeyAction("Filter", r.activateCmd, false), + ui.KeyP: ui.NewKeyAction("Previous", r.app.PrevCmd, false), + ui.KeyShiftO: ui.NewKeyAction("Sort APIGroup", r.SortColCmd(1), false), }) } -func (v *rbacView) getTitle() string { - return skinTitle(fmt.Sprintf(rbacTitleFmt, rbacTitle, v.roleName), v.app.Styles.Frame()) +func (r *RBAC) getTitle() string { + return skinTitle(fmt.Sprintf(rbacTitleFmt, rbacTitle, r.roleName), r.app.Styles.Frame()) } -func (v *rbacView) refresh() { - data, err := v.reconcile(v.ActiveNS(), v.roleName, v.roleType) +func (r *RBAC) refresh() { + data, err := r.reconcile(r.ActiveNS(), r.roleName, r.roleType) if err != nil { - log.Error().Err(err).Msgf("Refresh for %s:%d", v.roleName, v.roleType) - v.app.Flash().Err(err) + log.Error().Err(err).Msgf("Refresh for %s:%d", r.roleName, r.roleType) + r.app.Flash().Err(err) } - v.Update(data) + r.Update(data) } -func (v *rbacView) resetCmd(evt *tcell.EventKey) *tcell.EventKey { - if !v.SearchBuff().Empty() { - v.SearchBuff().Reset() +func (r *RBAC) resetCmd(evt *tcell.EventKey) *tcell.EventKey { + if !r.SearchBuff().Empty() { + r.SearchBuff().Reset() return nil } - return v.backCmd(evt) + return r.backCmd(evt) } -func (v *rbacView) backCmd(evt *tcell.EventKey) *tcell.EventKey { - if v.cancel != nil { - v.cancel() +func (r *RBAC) backCmd(evt *tcell.EventKey) *tcell.EventKey { + if r.cancelFn != nil { + r.cancelFn() } - if v.SearchBuff().IsActive() { - v.SearchBuff().Reset() + if r.SearchBuff().IsActive() { + r.SearchBuff().Reset() return nil } - v.app.inject(v.current) - - return nil + return r.app.PrevCmd(evt) } -func (v *rbacView) reconcile(ns, name string, kind roleKind) (resource.TableData, error) { +func (r *RBAC) reconcile(ns, name string, kind roleKind) (resource.TableData, error) { var table resource.TableData - evts, err := v.rowEvents(ns, name, kind) + evts, err := r.rowEvents(ns, name, kind) if err != nil { return table, err } - return buildTable(v, evts), nil + return buildTable(r, evts), nil } -func (v *rbacView) header() resource.Row { +func (r *RBAC) header() resource.Row { return rbacHeader } -func (v *rbacView) getCache() resource.RowEvents { - return v.cache +func (r *RBAC) getCache() resource.RowEvents { + return r.cache } -func (v *rbacView) setCache(evts resource.RowEvents) { - v.cache = evts +func (r *RBAC) setCache(evts resource.RowEvents) { + r.cache = evts } -func (v *rbacView) rowEvents(ns, name string, kind roleKind) (resource.RowEvents, error) { +func (r *RBAC) rowEvents(ns, name string, kind roleKind) (resource.RowEvents, error) { var ( evts resource.RowEvents err error @@ -197,9 +211,9 @@ func (v *rbacView) rowEvents(ns, name string, kind roleKind) (resource.RowEvents switch kind { case clusterRole: - evts, err = v.clusterPolicies(name) + evts, err = r.clusterPolicies(name) case role: - evts, err = v.namespacedPolicies(name) + evts, err = r.namespacedPolicies(name) default: return evts, fmt.Errorf("Expecting clusterrole/role but found %d", kind) } @@ -211,26 +225,26 @@ func (v *rbacView) rowEvents(ns, name string, kind roleKind) (resource.RowEvents return evts, nil } -func (v *rbacView) clusterPolicies(name string) (resource.RowEvents, error) { - cr, err := v.app.Conn().DialOrDie().RbacV1().ClusterRoles().Get(name, metav1.GetOptions{}) +func (r *RBAC) clusterPolicies(name string) (resource.RowEvents, error) { + cr, err := r.app.Conn().DialOrDie().RbacV1().ClusterRoles().Get(name, metav1.GetOptions{}) if err != nil { return nil, err } - return v.parseRules(cr.Rules), nil + return r.parseRules(cr.Rules), nil } -func (v *rbacView) namespacedPolicies(path string) (resource.RowEvents, error) { +func (r *RBAC) namespacedPolicies(path string) (resource.RowEvents, error) { ns, na := namespaced(path) - cr, err := v.app.Conn().DialOrDie().RbacV1().Roles(ns).Get(na, metav1.GetOptions{}) + cr, err := r.app.Conn().DialOrDie().RbacV1().Roles(ns).Get(na, metav1.GetOptions{}) if err != nil { return nil, err } - return v.parseRules(cr.Rules), nil + return r.parseRules(cr.Rules), nil } -func (v *rbacView) parseRules(rules []rbacv1.PolicyRule) resource.RowEvents { +func (r *RBAC) parseRules(rules []rbacv1.PolicyRule) resource.RowEvents { m := make(resource.RowEvents, len(rules)) for _, r := range rules { for _, grp := range r.APIGroups { diff --git a/internal/view/rbac_test.go b/internal/view/rbac_test.go new file mode 100644 index 00000000..1ab0eccd --- /dev/null +++ b/internal/view/rbac_test.go @@ -0,0 +1,115 @@ +package view + +// import ( +// "testing" + +// "github.com/derailed/k9s/internal/resource" +// "github.com/stretchr/testify/assert" +// rbacv1 "k8s.io/api/rbac/v1" +// ) + +// func TestHasVerb(t *testing.T) { +// uu := []struct { +// vv []string +// v string +// e bool +// }{ +// {[]string{"*"}, "get", true}, +// {[]string{"get", "list", "watch"}, "watch", true}, +// {[]string{"get", "dope", "list"}, "watch", false}, +// {[]string{"get"}, "get", true}, +// {[]string{"post"}, "create", true}, +// {[]string{"put"}, "update", true}, +// {[]string{"list", "deletecollection"}, "deletecollection", true}, +// } + +// for _, u := range uu { +// assert.Equal(t, u.e, hasVerb(u.vv, u.v)) +// } +// } + +// func TestAsVerbs(t *testing.T) { +// ok, nok := toVerbIcon(true), toVerbIcon(false) + +// uu := []struct { +// vv []string +// e resource.Row +// }{ +// {[]string{"*"}, resource.Row{ok, ok, ok, ok, ok, ok, ok, ok, ""}}, +// {[]string{"get", "list", "patch"}, resource.Row{ok, ok, nok, nok, nok, ok, nok, nok, ""}}, +// {[]string{"get", "list", "deletecollection", "post"}, resource.Row{ok, ok, ok, nok, ok, nok, nok, nok, ""}}, +// {[]string{"get", "list", "blee"}, resource.Row{ok, ok, nok, nok, nok, nok, nok, nok, "blee"}}, +// } + +// for _, u := range uu { +// assert.Equal(t, u.e, asVerbs(u.vv...)) +// } +// } + +// func TestParseRules(t *testing.T) { +// ok, nok := toVerbIcon(true), toVerbIcon(false) +// _ = nok + +// uu := []struct { +// pp []rbacv1.PolicyRule +// e map[string]resource.Row +// }{ +// { +// []rbacv1.PolicyRule{ +// {APIGroups: []string{"*"}, Resources: []string{"*"}, Verbs: []string{"*"}}, +// }, +// map[string]resource.Row{ +// "*.*": {"*.*", "*", ok, ok, ok, ok, ok, ok, ok, ok, ""}, +// }, +// }, +// { +// []rbacv1.PolicyRule{ +// {APIGroups: []string{"*"}, Resources: []string{"*"}, Verbs: []string{"get"}}, +// }, +// map[string]resource.Row{ +// "*.*": {"*.*", "*", ok, nok, nok, nok, nok, nok, nok, nok, ""}, +// }, +// }, +// { +// []rbacv1.PolicyRule{ +// {APIGroups: []string{""}, Resources: []string{"*"}, Verbs: []string{"list"}}, +// }, +// map[string]resource.Row{ +// "*": {"*", "v1", nok, ok, nok, nok, nok, nok, nok, nok, ""}, +// }, +// }, +// { +// []rbacv1.PolicyRule{ +// {APIGroups: []string{""}, Resources: []string{"pods"}, Verbs: []string{"list"}, ResourceNames: []string{"fred"}}, +// }, +// map[string]resource.Row{ +// "pods": {"pods", "v1", nok, ok, nok, nok, nok, nok, nok, nok, ""}, +// "pods/fred": {"pods/fred", "v1", nok, ok, nok, nok, nok, nok, nok, nok, ""}, +// }, +// }, +// { +// []rbacv1.PolicyRule{ +// {APIGroups: []string{}, Resources: []string{}, Verbs: []string{"get"}, NonResourceURLs: []string{"/fred"}}, +// }, +// map[string]resource.Row{ +// "/fred": {"/fred", resource.NAValue, ok, nok, nok, nok, nok, nok, nok, nok, ""}, +// }, +// }, +// { +// []rbacv1.PolicyRule{ +// {APIGroups: []string{}, Resources: []string{}, Verbs: []string{"get"}, NonResourceURLs: []string{"fred"}}, +// }, +// map[string]resource.Row{ +// "/fred": {"/fred", resource.NAValue, ok, nok, nok, nok, nok, nok, nok, nok, ""}, +// }, +// }, +// } + +// var v rbacView +// for _, u := range uu { +// evts := v.parseRules(u.pp) +// for k, v := range u.e { +// assert.Equal(t, v, evts[k].Fields) +// } +// } +// } diff --git a/internal/views/registrar.go b/internal/view/registrar.go similarity index 82% rename from internal/views/registrar.go rename to internal/view/registrar.go index 03b5e616..a1f28285 100644 --- a/internal/views/registrar.go +++ b/internal/view/registrar.go @@ -1,8 +1,7 @@ -package views +package view import ( "strings" - "time" "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/k8s" @@ -13,9 +12,9 @@ import ( ) type ( - viewFn func(title, gvr string, app *appView, list resource.List) resourceViewer + viewFn func(title, gvr string, list resource.List) ResourceViewer listFn func(c resource.Connection, ns string) resource.List - enterFn func(app *appView, ns, resource, selection string) + enterFn func(app *App, ns, resource, selection string) decorateFn func(resource.TableData) resource.TableData viewer struct { @@ -34,8 +33,8 @@ type ( ) func listFunc(l resource.List) viewFn { - return func(title, gvr string, app *appView, list resource.List) resourceViewer { - return newResourceView(title, gvr, app, l) + return func(title, gvr string, list resource.List) ResourceViewer { + return NewResource(title, gvr, l) } } @@ -50,7 +49,6 @@ func allCRDs(c k8s.Connection, vv viewers) { return } - t := time.Now() for _, crd := range crds { meta, err := crd.ExtFields() if err != nil { @@ -77,46 +75,45 @@ func allCRDs(c k8s.Connection, vv viewers) { colorerFn: ui.DefaultColorer, } } - log.Debug().Msgf("Loading CRDS %v", time.Since(t)) } -func showRBAC(app *appView, ns, resource, selection string) { +func showRBAC(app *App, ns, resource, selection string) { kind := clusterRole if resource == "role" { kind = role } - app.inject(newRBACView(app, ns, selection, kind)) + app.inject(NewRBAC(app, ns, selection, kind)) } -func showCRD(app *appView, ns, resource, selection string) { +func showCRD(app *App, ns, resource, selection string) { log.Debug().Msgf("Launching CRD %q -- %q -- %q", ns, resource, selection) tokens := strings.Split(selection, ".") app.gotoResource(tokens[0], true) } -func showClusterRole(app *appView, ns, resource, selection string) { +func showClusterRole(app *App, ns, resource, selection string) { crb, err := app.Conn().DialOrDie().RbacV1().ClusterRoleBindings().Get(selection, metav1.GetOptions{}) if err != nil { app.Flash().Errf("Unable to retrieve clusterrolebindings for %s", selection) return } - app.inject(newRBACView(app, ns, crb.RoleRef.Name, clusterRole)) + app.inject(NewRBAC(app, ns, crb.RoleRef.Name, clusterRole)) } -func showRole(app *appView, _, resource, selection string) { +func showRole(app *App, _, resource, selection string) { ns, n := namespaced(selection) rb, err := app.Conn().DialOrDie().RbacV1().RoleBindings(ns).Get(n, metav1.GetOptions{}) if err != nil { app.Flash().Errf("Unable to retrieve rolebindings for %s", selection) return } - app.inject(newRBACView(app, ns, fqn(ns, rb.RoleRef.Name), role)) + app.inject(NewRBAC(app, ns, fqn(ns, rb.RoleRef.Name), role)) } -func showSAPolicy(app *appView, _, _, selection string) { +func showSAPolicy(app *App, _, _, selection string) { _, n := namespaced(selection) - app.inject(newPolicyView(app, mapFuSubject("ServiceAccount"), n)) + app.inject(NewPolicy(app, mapFuSubject("ServiceAccount"), n)) } func load(c k8s.Connection, vv viewers) { @@ -157,10 +154,6 @@ func load(c k8s.Connection, vv viewers) { } func resourceViews(c k8s.Connection, m viewers) { - defer func(t time.Time) { - log.Debug().Msgf("Loading Views Elapsed %v", time.Since(t)) - }(time.Now()) - coreRes(m) miscRes(m) appsRes(m) @@ -176,17 +169,17 @@ func resourceViews(c k8s.Connection, m viewers) { func coreRes(vv viewers) { vv["v1/nodes"] = viewer{ - viewFn: newNodeView, + viewFn: NewNode, listFn: resource.NewNodeList, colorerFn: nsColorer, } vv["v1/namespaces"] = viewer{ - viewFn: newNamespaceView, + viewFn: NewNamespace, listFn: resource.NewNamespaceList, colorerFn: nsColorer, } vv["v1/pods"] = viewer{ - viewFn: newPodView, + viewFn: NewPod, listFn: resource.NewPodList, colorerFn: podColorer, } @@ -195,7 +188,7 @@ func coreRes(vv viewers) { enterFn: showSAPolicy, } vv["v1/services"] = viewer{ - viewFn: newSvcView, + viewFn: NewService, listFn: resource.NewServiceList, } vv["v1/configmaps"] = viewer{ @@ -210,7 +203,7 @@ func coreRes(vv viewers) { colorerFn: pvcColorer, } vv["v1/secrets"] = viewer{ - viewFn: newSecretView, + viewFn: NewSecret, listFn: resource.NewSecretList, } vv["v1/endpoints"] = viewer{ @@ -221,7 +214,7 @@ func coreRes(vv viewers) { colorerFn: evColorer, } vv["v1/replicationcontrollers"] = viewer{ - viewFn: newScalableResourceView, + viewFn: NewScalableResource, listFn: resource.NewReplicationControllerList, colorerFn: rsColorer, } @@ -234,50 +227,50 @@ func miscRes(vv viewers) { vv["contexts"] = viewer{ gvr: "contexts", kind: "Contexts", - viewFn: newContextView, + viewFn: NewContext, listFn: resource.NewContextList, colorerFn: ctxColorer, } vv["users"] = viewer{ gvr: "users", - viewFn: newSubjectView, + viewFn: NewSubject, } vv["groups"] = viewer{ gvr: "groups", - viewFn: newSubjectView, + viewFn: NewSubject, } vv["portforwards"] = viewer{ gvr: "portforwards", - viewFn: newForwardView, + viewFn: NewPortForward, } vv["benchmarks"] = viewer{ gvr: "benchmarks", - viewFn: newBenchView, + viewFn: NewBench, } vv["screendumps"] = viewer{ gvr: "screendumps", - viewFn: newDumpView, + viewFn: NewScreenDump, } } func appsRes(vv viewers) { vv["apps/v1/deployments"] = viewer{ - viewFn: newDeployView, + viewFn: NewDeploy, listFn: resource.NewDeploymentList, colorerFn: dpColorer, } vv["apps/v1/replicasets"] = viewer{ - viewFn: newReplicaSetView, + viewFn: NewReplicaSet, listFn: resource.NewReplicaSetList, colorerFn: rsColorer, } vv["apps/v1/statefulsets"] = viewer{ - viewFn: newStatefulSetView, + viewFn: NewStatefulSet, listFn: resource.NewStatefulSetList, colorerFn: stsColorer, } vv["apps/v1/daemonsets"] = viewer{ - viewFn: newDaemonSetView, + viewFn: NewDaemonSet, listFn: resource.NewDaemonSetList, colorerFn: dpColorer, } @@ -324,11 +317,11 @@ func netRes(vv viewers) { func batchRes(vv viewers) { vv["batch/v1beta1/cronjobs"] = viewer{ - viewFn: newCronJobView, + viewFn: NewCronJob, listFn: resource.NewCronJobList, } vv["batch/v1/jobs"] = viewer{ - viewFn: newJobView, + viewFn: NewJob, listFn: resource.NewJobList, } } diff --git a/internal/view/resource.go b/internal/view/resource.go new file mode 100644 index 00000000..dd7fd36e --- /dev/null +++ b/internal/view/resource.go @@ -0,0 +1,513 @@ +package view + +import ( + "context" + "fmt" + "strconv" + "strings" + "time" + + "github.com/atotto/clipboard" + "github.com/derailed/k9s/internal/config" + "github.com/derailed/k9s/internal/model" + "github.com/derailed/k9s/internal/resource" + "github.com/derailed/k9s/internal/ui" + "github.com/derailed/k9s/internal/ui/dialog" + "github.com/gdamore/tcell" + "github.com/rs/zerolog/log" +) + +// EnvFn represent the current view exposed environment. +type envFn func() K9sEnv + +// Resource represents a generic resource viewer. +type Resource struct { + *MasterDetail + + namespaces map[int]string + list resource.List + cancelFn context.CancelFunc + path *string + colorerFn ui.ColorerFunc + decorateFn decorateFn + envFn envFn + gvr string + currentNS string +} + +// NewResource returns a new viewer. +func NewResource(title, gvr string, list resource.List) *Resource { + return &Resource{ + MasterDetail: NewMasterDetail(), + list: list, + gvr: gvr, + } +} + +// Init watches all running pods in given namespace +func (r *Resource) Init(ctx context.Context) { + r.MasterDetail.Init(ctx) + r.envFn = r.defaultK9sEnv + + table := r.masterPage() + table.setFilterFn(r.filterResource) + colorer := ui.DefaultColorer + if r.colorerFn != nil { + colorer = r.colorerFn + } + table.SetColorerFn(colorer) + row, _ := table.GetSelection() + if row == 0 && table.GetRowCount() > 0 { + table.Select(1, 0) + } + r.DumpPages() + + r.refresh() +} + +// Start initializes updates. +func (r *Resource) Start() { + r.Stop() + var ctx context.Context + ctx, r.cancelFn = context.WithCancel(context.Background()) + r.update(ctx) +} + +// Stop terminates updates. +func (r *Resource) Stop() { + if r.cancelFn != nil { + r.cancelFn() + } +} + +// Name returns the component name. +func (r *Resource) Name() string { + return r.list.GetName() +} + +// Hints returns the current viewer hints +func (r *Resource) Hints() model.MenuHints { + if r.CurrentPage() == nil { + return nil + } + if c, ok := r.CurrentPage().Item.(model.Hinter); ok { + return c.Hints() + } + + return nil +} + +func (r *Resource) setColorerFn(f ui.ColorerFunc) { + r.colorerFn = f +} + +func (r *Resource) setDecorateFn(f decorateFn) { + r.decorateFn = f +} + +func (r *Resource) filterResource(sel string) { + r.list.SetLabelSelector(sel) + r.refresh() +} + +func (r *Resource) update(ctx context.Context) { + go func(ctx context.Context) { + for { + select { + case <-ctx.Done(): + log.Debug().Msgf("%s updater canceled!", r.list.GetName()) + return + case <-time.After(time.Duration(r.app.Config.K9s.GetRefreshRate()) * time.Second): + r.app.QueueUpdateDraw(func() { + r.refresh() + }) + } + } + }(ctx) +} + +func (r *Resource) backCmd(*tcell.EventKey) *tcell.EventKey { + r.switchPage("master") + return nil +} + +func (r *Resource) switchPage(p string) { + log.Debug().Msgf("Switching page to %s", p) + if _, ok := r.CurrentPage().Item.(*Table); ok { + r.Stop() + } + + r.SwitchToPage(p) + + if _, ok := r.CurrentPage().Item.(*Table); ok { + r.Start() + } +} + +// ---------------------------------------------------------------------------- +// Actions... + +func (r *Resource) cpCmd(evt *tcell.EventKey) *tcell.EventKey { + if !r.masterPage().RowSelected() { + return evt + } + + _, n := namespaced(r.masterPage().GetSelectedItem()) + log.Debug().Msgf("Copied selection to clipboard %q", n) + r.app.Flash().Info("Current selection copied to clipboard...") + if err := clipboard.WriteAll(n); err != nil { + r.app.Flash().Err(err) + } + + return nil +} + +func (r *Resource) enterCmd(evt *tcell.EventKey) *tcell.EventKey { + // If in command mode run filter otherwise enter function. + if r.masterPage().filterCmd(evt) == nil || !r.masterPage().RowSelected() { + return nil + } + + f := r.defaultEnter + if r.enterFn != nil { + f = r.enterFn + } + f(r.app, r.list.GetNamespace(), r.list.GetName(), r.masterPage().GetSelectedItem()) + + return nil +} + +func (r *Resource) refreshCmd(*tcell.EventKey) *tcell.EventKey { + r.app.Flash().Info("Refreshing...") + r.refresh() + return nil +} + +func (r *Resource) deleteCmd(evt *tcell.EventKey) *tcell.EventKey { + if !r.masterPage().RowSelected() { + return evt + } + + sel := r.masterPage().GetSelectedItems() + var msg string + if len(sel) > 1 { + msg = fmt.Sprintf("Delete %d selected %s?", len(sel), r.list.GetName()) + } else { + msg = fmt.Sprintf("Delete %s %s?", r.list.GetName(), sel[0]) + } + dialog.ShowDelete(r.Pages, msg, func(cascade, force bool) { + r.masterPage().ShowDeleted() + if len(sel) > 1 { + r.app.Flash().Infof("Delete %d selected %s", len(sel), r.list.GetName()) + } else { + r.app.Flash().Infof("Delete resource %s %s", r.list.GetName(), sel[0]) + } + for _, res := range sel { + if err := r.list.Resource().Delete(res, cascade, force); err != nil { + r.app.Flash().Errf("Delete failed with %s", err) + } else { + deletePortForward(r.app.forwarders, res) + } + } + r.refresh() + }, func() { + r.switchPage("master") + }) + return nil +} + +func (r *Resource) markCmd(evt *tcell.EventKey) *tcell.EventKey { + if !r.masterPage().RowSelected() { + return evt + } + r.masterPage().ToggleMark() + r.refresh() + r.app.Draw() + + return nil +} + +func deletePortForward(ff map[string]forwarder, sel string) { + for k, f := range ff { + tokens := strings.Split(k, ":") + if tokens[0] == sel { + log.Debug().Msgf("Deleting associated portForward %s", k) + f.Stop() + } + } +} + +func (r *Resource) defaultEnter(app *App, ns, _, selection string) { + if !r.list.Access(resource.DescribeAccess) { + return + } + + yaml, err := r.list.Resource().Describe(r.gvr, selection) + if err != nil { + r.app.Flash().Errf("Describe command failed: %s", err) + return + } + + details := r.detailsPage() + details.setCategory("Describe") + details.setTitle(selection) + details.SetTextColor(r.app.Styles.FgColor()) + details.SetText(colorizeYAML(r.app.Styles.Views().Yaml, yaml)) + details.ScrollToBeginning() + r.switchPage("details") +} + +func (r *Resource) describeCmd(evt *tcell.EventKey) *tcell.EventKey { + if !r.masterPage().RowSelected() { + return evt + } + r.defaultEnter(r.app, r.list.GetNamespace(), r.list.GetName(), r.masterPage().GetSelectedItem()) + + return nil +} + +func (r *Resource) viewCmd(evt *tcell.EventKey) *tcell.EventKey { + if !r.masterPage().RowSelected() { + return evt + } + + sel := r.masterPage().GetSelectedItem() + raw, err := r.list.Resource().Marshal(sel) + if err != nil { + r.app.Flash().Errf("Unable to marshal resource %s", err) + return evt + } + details := r.detailsPage() + details.setCategory("YAML") + details.setTitle(sel) + details.SetTextColor(r.app.Styles.FgColor()) + details.SetText(colorizeYAML(r.app.Styles.Views().Yaml, raw)) + details.ScrollToBeginning() + r.app.Content.Push(details) + + return nil +} + +func (r *Resource) editCmd(evt *tcell.EventKey) *tcell.EventKey { + if !r.masterPage().RowSelected() { + return evt + } + + r.Stop() + { + ns, po := namespaced(r.masterPage().GetSelectedItem()) + args := make([]string, 0, 10) + args = append(args, "edit") + args = append(args, r.list.GetName()) + args = append(args, "-n", ns) + args = append(args, "--context", r.app.Config.K9s.CurrentContext) + if cfg := r.app.Conn().Config().Flags().KubeConfig; cfg != nil && *cfg != "" { + args = append(args, "--kubeconfig", *cfg) + } + runK(true, r.app, append(args, po)...) + } + r.Start() + + return evt +} + +func (r *Resource) setNamespace(ns string) { + if r.list.Namespaced() { + r.list.SetNamespace(ns) + } +} + +func (r *Resource) switchNamespaceCmd(evt *tcell.EventKey) *tcell.EventKey { + i, _ := strconv.Atoi(string(evt.Rune())) + ns := r.namespaces[i] + if ns == "" { + ns = resource.AllNamespace + } + if r.currentNS == ns { + return nil + } + + r.app.switchNS(ns) + r.setNamespace(ns) + r.app.Flash().Infof("Viewing namespace `%s`...", ns) + r.refresh() + r.masterPage().UpdateTitle() + r.masterPage().SelectRow(1, true) + r.app.CmdBuff().Reset() + if err := r.app.Config.SetActiveNamespace(r.currentNS); err != nil { + log.Error().Err(err).Msg("Config save failed!") + } + r.app.Config.Save() + + return nil +} + +func (r *Resource) refresh() { + if r.CurrentPage() == nil { + return + } + if _, ok := r.CurrentPage().Item.(*Table); !ok { + return + } + + r.refreshActions() + if r.list.Namespaced() { + r.list.SetNamespace(r.currentNS) + } + if err := r.list.Reconcile(r.app.informer, r.path); err != nil { + r.app.Flash().Err(err) + } + data := r.list.Data() + if r.decorateFn != nil { + data = r.decorateFn(data) + } + r.masterPage().Update(data) +} + +func (r *Resource) namespaceActions(aa ui.KeyActions) { + if !r.list.Access(resource.NamespaceAccess) { + return + } + r.namespaces = make(map[int]string, config.MaxFavoritesNS) + // User can't list namespace. Don't offer a choice. + if r.app.Conn() == nil || r.app.Conn().CheckListNSAccess() != nil { + return + } + aa[tcell.Key(ui.NumKeys[0])] = ui.NewKeyAction(resource.AllNamespace, r.switchNamespaceCmd, true) + r.namespaces[0] = resource.AllNamespace + index := 1 + for _, n := range r.app.Config.FavNamespaces() { + if n == resource.AllNamespace { + continue + } + aa[tcell.Key(ui.NumKeys[index])] = ui.NewKeyAction(n, r.switchNamespaceCmd, true) + r.namespaces[index] = n + index++ + } +} + +func (r *Resource) refreshActions() { + aa := ui.KeyActions{ + ui.KeyC: ui.NewKeyAction("Copy", r.cpCmd, false), + tcell.KeyEnter: ui.NewKeyAction("Enter", r.enterCmd, false), + tcell.KeyCtrlR: ui.NewKeyAction("Refresh", r.refreshCmd, false), + } + aa[ui.KeySpace] = ui.NewKeyAction("Mark", r.markCmd, true) + r.namespaceActions(aa) + r.defaultActions(aa) + + if r.list.Access(resource.EditAccess) { + aa[ui.KeyE] = ui.NewKeyAction("Edit", r.editCmd, true) + } + if r.list.Access(resource.DeleteAccess) { + aa[tcell.KeyCtrlD] = ui.NewKeyAction("Delete", r.deleteCmd, true) + } + if r.list.Access(resource.ViewAccess) { + aa[ui.KeyY] = ui.NewKeyAction("YAML", r.viewCmd, true) + } + if r.list.Access(resource.DescribeAccess) { + aa[ui.KeyD] = ui.NewKeyAction("Describe", r.describeCmd, true) + } + r.customActions(aa) + + t := r.masterPage() + t.AddActions(aa) +} + +func (r *Resource) customActions(aa ui.KeyActions) { + pp := config.NewPlugins() + if err := pp.Load(); err != nil { + log.Warn().Msgf("No plugin configuration found") + return + } + + for k, plugin := range pp.Plugin { + if !in(plugin.Scopes, r.list.GetName()) { + continue + } + key, err := asKey(plugin.ShortCut) + if err != nil { + log.Error().Err(err).Msg("Unable to map shortcut to a key") + continue + } + _, ok := aa[key] + if ok { + log.Error().Err(fmt.Errorf("Doh! you are trying to overide an existing command `%s", k)).Msg("Invalid shortcut") + continue + } + aa[key] = ui.NewKeyAction( + plugin.Description, + r.execCmd(plugin.Command, plugin.Background, plugin.Args...), + true) + } +} + +func (r *Resource) execCmd(bin string, bg bool, args ...string) ui.ActionHandler { + return func(evt *tcell.EventKey) *tcell.EventKey { + if !r.masterPage().RowSelected() { + return evt + } + + var ( + env = r.envFn() + aa = make([]string, len(args)) + err error + ) + for i, a := range args { + aa[i], err = env.envFor(a) + if err != nil { + log.Error().Err(err).Msg("Args match failed") + return nil + } + } + + if run(true, r.app, bin, bg, aa...) { + r.app.Flash().Info("Custom CMD launched!") + } else { + r.app.Flash().Info("Custom CMD failed!") + } + return nil + } +} + +func (r *Resource) defaultK9sEnv() K9sEnv { + ns, n := namespaced(r.masterPage().GetSelectedItem()) + ctx, err := r.app.Conn().Config().CurrentContextName() + if err != nil { + ctx = "n/a" + } + cluster, err := r.app.Conn().Config().CurrentClusterName() + if err != nil { + cluster = "n/a" + } + user, err := r.app.Conn().Config().CurrentUserName() + if err != nil { + user = "n/a" + } + groups, err := r.app.Conn().Config().CurrentGroupNames() + if err != nil { + groups = []string{"n/a"} + } + var cfg string + kcfg := r.app.Conn().Config().Flags().KubeConfig + if kcfg != nil && *kcfg != "" { + cfg = *kcfg + } + + env := K9sEnv{ + "NAMESPACE": ns, + "NAME": n, + "CONTEXT": ctx, + "CLUSTER": cluster, + "USER": user, + "GROUPS": strings.Join(groups, ","), + "KUBECONFIG": cfg, + } + + row := r.masterPage().GetRow() + for i, r := range row { + env["COL"+strconv.Itoa(i)] = r + } + + return env +} diff --git a/internal/view/restartable_resource.go b/internal/view/restartable_resource.go new file mode 100644 index 00000000..255b7b29 --- /dev/null +++ b/internal/view/restartable_resource.go @@ -0,0 +1,58 @@ +package view + +import ( + "errors" + + "github.com/derailed/k9s/internal/resource" + "github.com/derailed/k9s/internal/ui" + "github.com/derailed/k9s/internal/ui/dialog" + "github.com/gdamore/tcell" +) + +// RestartableResource presents a viewer with restart option. +type RestartableResource struct { + *Resource +} + +func newRestartableResourceForParent(parent *Resource) *RestartableResource { + r := RestartableResource{Resource: parent} + parent.extraActionsFn = r.extraActions + + return &r +} + +func (r *RestartableResource) extraActions(aa ui.KeyActions) { + aa[tcell.KeyCtrlT] = ui.NewKeyAction("Restart Rollout", r.restartCmd, true) +} + +func (r *RestartableResource) restartCmd(evt *tcell.EventKey) *tcell.EventKey { + if !r.masterPage().RowSelected() { + return evt + } + + sel := r.masterPage().GetSelectedItem() + r.Stop() + defer r.Start() + msg := "Please confirm rollout restart for " + sel + dialog.ShowConfirm(r.Pages, "", msg, func() { + if err := r.restartRollout(sel); err != nil { + r.app.Flash().Err(err) + } else { + r.app.Flash().Infof("Rollout restart in progress for `%s...", sel) + } + }, func() { + r.showMaster() + }) + + return nil +} + +func (r *RestartableResource) restartRollout(selection string) error { + s, ok := r.list.Resource().(resource.Restartable) + if !ok { + return errors.New("resource is not of type resource.Restartable") + } + ns, n := namespaced(selection) + + return s.Restart(ns, n) +} diff --git a/internal/views/rs.go b/internal/view/rs.go similarity index 66% rename from internal/views/rs.go rename to internal/view/rs.go index fbca0e24..8ea2d00c 100644 --- a/internal/views/rs.go +++ b/internal/view/rs.go @@ -1,4 +1,4 @@ -package views +package view import ( "errors" @@ -19,27 +19,31 @@ import ( "k8s.io/kubectl/pkg/polymorphichelpers" ) -type replicaSetView struct { - *resourceView +// ReplicaSet presents a replicaset viewer. +type ReplicaSet struct { + *Resource } -func newReplicaSetView(title, gvr string, app *appView, list resource.List) resourceViewer { - v := replicaSetView{newResourceView(title, gvr, app, list).(*resourceView)} - v.extraActionsFn = v.extraActions - v.enterFn = v.showPods +// NewReplicaSet returns a new viewer. +func NewReplicaSet(title, gvr string, list resource.List) ResourceViewer { + r := ReplicaSet{ + Resource: NewResource(title, gvr, list), + } + r.extraActionsFn = r.extraActions + r.enterFn = r.showPods - return &v + return &r } -func (v *replicaSetView) extraActions(aa ui.KeyActions) { - aa[ui.KeyShiftD] = ui.NewKeyAction("Sort Desired", v.sortColCmd(1, false), false) - aa[ui.KeyShiftC] = ui.NewKeyAction("Sort Current", v.sortColCmd(2, false), false) - aa[tcell.KeyCtrlB] = ui.NewKeyAction("Rollback", v.rollbackCmd, true) +func (r *ReplicaSet) extraActions(aa ui.KeyActions) { + aa[ui.KeyShiftD] = ui.NewKeyAction("Sort Desired", r.sortColCmd(1, false), false) + aa[ui.KeyShiftC] = ui.NewKeyAction("Sort Current", r.sortColCmd(2, false), false) + aa[tcell.KeyCtrlB] = ui.NewKeyAction("Rollback", r.rollbackCmd, true) } -func (v *replicaSetView) sortColCmd(col int, asc bool) func(evt *tcell.EventKey) *tcell.EventKey { +func (r *ReplicaSet) sortColCmd(col int, asc bool) func(evt *tcell.EventKey) *tcell.EventKey { return func(evt *tcell.EventKey) *tcell.EventKey { - t := v.masterPage() + t := r.masterPage() t.SetSortCol(t.NameColIndex()+col, 0, asc) t.Refresh() @@ -47,65 +51,64 @@ func (v *replicaSetView) sortColCmd(col int, asc bool) func(evt *tcell.EventKey) } } -func (v *replicaSetView) showPods(app *appView, ns, res, sel string) { +func (r *ReplicaSet) showPods(app *App, ns, res, sel string) { ns, n := namespaced(sel) - rset := k8s.NewReplicaSet(app.Conn()) - r, err := rset.Get(ns, n) + s, err := k8s.NewReplicaSet(app.Conn()).Get(ns, n) if err != nil { app.Flash().Errf("Replicaset failed %s", err) } - rs := r.(*v1.ReplicaSet) + rs := s.(*v1.ReplicaSet) l, err := metav1.LabelSelectorAsSelector(rs.Spec.Selector) if err != nil { app.Flash().Errf("Selector failed %s", err) return } - showPods(app, ns, l.String(), "", v.backCmd) + showPods(app, ns, l.String(), "", r.backCmd) } -func (v *replicaSetView) backCmd(evt *tcell.EventKey) *tcell.EventKey { - v.app.inject(v) +func (r *ReplicaSet) backCmd(evt *tcell.EventKey) *tcell.EventKey { + r.app.inject(r) return nil } -func (v *replicaSetView) rollbackCmd(evt *tcell.EventKey) *tcell.EventKey { - if !v.masterPage().RowSelected() { +func (r *ReplicaSet) rollbackCmd(evt *tcell.EventKey) *tcell.EventKey { + if !r.masterPage().RowSelected() { return evt } - sel := v.masterPage().GetSelectedItem() - v.showModal(fmt.Sprintf("Rollback %s %s?", v.list.GetName(), sel), func(_ int, button string) { + sel := r.masterPage().GetSelectedItem() + r.showModal(fmt.Sprintf("Rollback %s %s?", r.list.GetName(), sel), func(_ int, button string) { if button == "OK" { - v.app.Flash().Infof("Rolling back %s %s", v.list.GetName(), sel) - if res, err := rollback(v.app.Conn(), sel); err != nil { - v.app.Flash().Err(err) + r.app.Flash().Infof("Rolling back %s %s", r.list.GetName(), sel) + if res, err := rollback(r.app.Conn(), sel); err != nil { + r.app.Flash().Err(err) } else { - v.app.Flash().Info(res) + r.app.Flash().Info(res) } - v.refresh() + r.refresh() } - v.dismissModal() + r.dismissModal() }) return nil } -func (v *replicaSetView) dismissModal() { - v.RemovePage("confirm") - v.switchPage("master") +func (r *ReplicaSet) dismissModal() { + r.RemovePage("confirm") + r.switchPage("master") } -func (v *replicaSetView) showModal(msg string, done func(int, string)) { +func (r *ReplicaSet) showModal(msg string, done func(int, string)) { confirm := tview.NewModal(). AddButtons([]string{"Cancel", "OK"}). SetTextColor(tcell.ColorFuchsia). SetText(msg). SetDoneFunc(done) - v.AddPage("confirm", confirm, false, false) - v.ShowPage("confirm") + r.AddPage("confirm", confirm, false, false) + r.ShowPage("confirm") } // ---------------------------------------------------------------------------- diff --git a/internal/view/scalable_resource.go b/internal/view/scalable_resource.go new file mode 100644 index 00000000..b15247b2 --- /dev/null +++ b/internal/view/scalable_resource.go @@ -0,0 +1,115 @@ +package view + +import ( + "fmt" + "strconv" + "strings" + + "github.com/derailed/k9s/internal/resource" + "github.com/derailed/k9s/internal/ui" + "github.com/derailed/tview" + "github.com/gdamore/tcell" +) + +// ScalableResource represents a resource that can be scaled. +type ScalableResource struct { + *Resource +} + +// NewScalableResource returns a new viewer. +func NewScalableResource(title, gvr string, list resource.List) ResourceViewer { + return newScalableResourceForParent(NewResource(title, gvr, list)) +} + +func newScalableResourceForParent(parent *Resource) *ScalableResource { + s := ScalableResource{ + Resource: parent, + } + parent.extraActionsFn = s.extraActions + + return &s +} + +func (s *ScalableResource) extraActions(aa ui.KeyActions) { + aa[ui.KeyS] = ui.NewKeyAction("Scale", s.scaleCmd, true) +} + +func (s *ScalableResource) scaleCmd(evt *tcell.EventKey) *tcell.EventKey { + if !s.masterPage().RowSelected() { + return evt + } + + s.showScaleDialog(s.list.GetName(), s.masterPage().GetSelectedItem()) + return nil +} + +func (s *ScalableResource) scale(selection string, replicas int) { + ns, n := namespaced(selection) + + r := s.list.Resource().(resource.Scalable) + + err := r.Scale(ns, n, int32(replicas)) + if err != nil { + s.app.Flash().Err(err) + } +} + +func (s *ScalableResource) showScaleDialog(resourceType string, resourceName string) { + f := s.createScaleForm() + + confirm := tview.NewModalForm("", f) + confirm.SetText(fmt.Sprintf("Scale %s %s", resourceType, resourceName)) + confirm.SetDoneFunc(func(int, string) { + s.dismissScaleDialog() + }) + s.AddPage(scaleDialogKey, confirm, false, false) + s.ShowPage(scaleDialogKey) +} + +func (s *ScalableResource) createScaleForm() *tview.Form { + f := s.createStyledForm() + + tv := s.masterPage() + replicas := strings.TrimSpace(tv.GetCell(tv.GetSelectedRowIndex(), tv.NameColIndex()+1).Text) + f.AddInputField("Replicas:", replicas, 4, func(textToCheck string, lastChar rune) bool { + _, err := strconv.Atoi(textToCheck) + return err == nil + }, func(changed string) { + replicas = changed + }) + + f.AddButton("OK", func() { + s.okSelected(replicas) + }) + + f.AddButton("Cancel", func() { + s.dismissScaleDialog() + }) + + return f +} + +func (s *ScalableResource) createStyledForm() *tview.Form { + f := tview.NewForm() + f.SetItemPadding(0) + f.SetButtonsAlign(tview.AlignCenter). + SetButtonBackgroundColor(tview.Styles.PrimitiveBackgroundColor). + SetButtonTextColor(tview.Styles.PrimaryTextColor). + SetLabelColor(tcell.ColorAqua). + SetFieldTextColor(tcell.ColorOrange) + return f +} + +func (s *ScalableResource) okSelected(replicas string) { + if val, err := strconv.Atoi(replicas); err == nil { + s.scale(s.masterPage().GetSelectedItem(), val) + } else { + s.app.Flash().Err(err) + } + + s.dismissScaleDialog() +} + +func (s *ScalableResource) dismissScaleDialog() { + s.Pages.RemovePage(scaleDialogKey) +} diff --git a/internal/view/secret.go b/internal/view/secret.go new file mode 100644 index 00000000..004c1ca9 --- /dev/null +++ b/internal/view/secret.go @@ -0,0 +1,63 @@ +package view + +import ( + "sigs.k8s.io/yaml" + + "github.com/derailed/k9s/internal/resource" + "github.com/derailed/k9s/internal/ui" + "github.com/gdamore/tcell" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// Secret presents a secret viewer. +type Secret struct { + *Resource +} + +// NewSecrets returns a new viewer. +func NewSecret(title, gvr string, list resource.List) ResourceViewer { + s := Secret{ + Resource: NewResource(title, gvr, list), + } + s.extraActionsFn = s.extraActions + + return &s +} + +func (s *Secret) extraActions(aa ui.KeyActions) { + aa[tcell.KeyCtrlX] = ui.NewKeyAction("Decode", s.decodeCmd, true) +} + +func (s *Secret) decodeCmd(evt *tcell.EventKey) *tcell.EventKey { + if !s.masterPage().RowSelected() { + return evt + } + + sel := s.masterPage().GetSelectedItem() + ns, n := namespaced(sel) + sec, err := s.app.Conn().DialOrDie().CoreV1().Secrets(ns).Get(n, metav1.GetOptions{}) + if err != nil { + s.app.Flash().Errf("Unable to retrieve secret %s", err) + return evt + } + + d := make(map[string]string, len(sec.Data)) + for k, val := range sec.Data { + d[k] = string(val) + } + raw, err := yaml.Marshal(d) + if err != nil { + s.app.Flash().Errf("Error decoding secret %s", err) + return nil + } + + details := s.detailsPage() + details.setCategory("Decoder") + details.setTitle(sel) + details.SetTextColor(s.app.Styles.FgColor()) + details.SetText(colorizeYAML(s.app.Styles.Views().Yaml, string(raw))) + details.ScrollToBeginning() + s.switchPage("details") + + return nil +} diff --git a/internal/views/select_list.go b/internal/view/select_list.go similarity index 93% rename from internal/views/select_list.go rename to internal/view/select_list.go index 6b077707..d524e554 100644 --- a/internal/views/select_list.go +++ b/internal/view/select_list.go @@ -1,6 +1,7 @@ -package views +package view import ( + "github.com/derailed/k9s/internal/model" "github.com/derailed/k9s/internal/resource" "github.com/derailed/k9s/internal/ui" "github.com/derailed/tview" @@ -61,7 +62,7 @@ func (v *selectList) setActions(aa ui.KeyActions) { v.actions = aa } -func (v *selectList) Hints() ui.Hints { +func (v *selectList) Hints() model.MenuHints { if v.actions != nil { return v.actions.Hints() } diff --git a/internal/views/status.go b/internal/view/status.go similarity index 98% rename from internal/views/status.go rename to internal/view/status.go index 9adbea5b..c71eb535 100644 --- a/internal/views/status.go +++ b/internal/view/status.go @@ -1,4 +1,4 @@ -package views +package view import ( "fmt" diff --git a/internal/views/status_test.go b/internal/view/status_test.go similarity index 96% rename from internal/views/status_test.go rename to internal/view/status_test.go index acdd0485..188dea75 100644 --- a/internal/views/status_test.go +++ b/internal/view/status_test.go @@ -1,4 +1,4 @@ -package views +package view import ( "testing" diff --git a/internal/view/sts.go b/internal/view/sts.go new file mode 100644 index 00000000..d94bbb2f --- /dev/null +++ b/internal/view/sts.go @@ -0,0 +1,57 @@ +package view + +import ( + "github.com/derailed/k9s/internal/k8s" + "github.com/derailed/k9s/internal/resource" + "github.com/derailed/k9s/internal/ui" + "github.com/rs/zerolog/log" + v1 "k8s.io/api/apps/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +type StatefulSet struct { + *LogResource + scalableResource *ScalableResource + restartableResource *RestartableResource +} + +func NewStatefulSet(title, gvr string, list resource.List) ResourceViewer { + l := NewLogResource(title, gvr, list) + s := StatefulSet{ + LogResource: l, + scalableResource: newScalableResourceForParent(l.Resource), + restartableResource: newRestartableResourceForParent(l.Resource), + } + s.extraActionsFn = s.extraActions + s.enterFn = s.showPods + + return &s +} + +func (s *StatefulSet) extraActions(aa ui.KeyActions) { + s.LogResource.extraActions(aa) + s.scalableResource.extraActions(aa) + s.restartableResource.extraActions(aa) + aa[ui.KeyShiftD] = ui.NewKeyAction("Sort Desired", s.sortColCmd(1, false), false) + aa[ui.KeyShiftC] = ui.NewKeyAction("Sort Current", s.sortColCmd(2, false), false) +} + +func (s *StatefulSet) showPods(app *App, ns, res, sel string) { + ns, n := namespaced(sel) + st, err := k8s.NewStatefulSet(app.Conn()).Get(ns, n) + if err != nil { + log.Error().Err(err).Msgf("Fetching StatefulSet %s", sel) + app.Flash().Errf("Unable to fetch statefulset %s", err) + return + } + + sts := st.(*v1.StatefulSet) + l, err := metav1.LabelSelectorAsSelector(sts.Spec.Selector) + if err != nil { + log.Error().Err(err).Msgf("Converting selector for StatefulSet %s", sel) + app.Flash().Errf("Selector failed %s", err) + return + } + + showPods(app, ns, l.String(), "", s.backCmd) +} diff --git a/internal/views/styles.go b/internal/view/styles.go similarity index 77% rename from internal/views/styles.go rename to internal/view/styles.go index 596472d9..98ed8599 100644 --- a/internal/views/styles.go +++ b/internal/view/styles.go @@ -1,4 +1,4 @@ -package views +package view import ( "github.com/derailed/k9s/internal/ui" @@ -12,7 +12,7 @@ type styles struct { align int } -func stylesFor(app *appView, res string, col int) styles { +func stylesFor(app *App, res string, col int) styles { switch res { case "pod": return podStyles(app, col) @@ -21,7 +21,7 @@ func stylesFor(app *appView, res string, col int) styles { } } -func podStyles(app *appView, col int) styles { +func podStyles(app *App, col int) styles { st := styles{ color: ui.StdColor, attrs: tcell.AttrReverse, @@ -37,7 +37,7 @@ func podStyles(app *appView, col int) styles { return st } -func defaultStyles(app *appView, col int) styles { +func defaultStyles(app *App, col int) styles { return styles{ color: tcell.ColorRed, attrs: tcell.AttrReverse, diff --git a/internal/view/subject.go b/internal/view/subject.go new file mode 100644 index 00000000..a52155c0 --- /dev/null +++ b/internal/view/subject.go @@ -0,0 +1,311 @@ +package view + +import ( + "context" + "fmt" + "reflect" + "time" + + "github.com/derailed/k9s/internal/resource" + "github.com/derailed/k9s/internal/ui" + "github.com/gdamore/tcell" + "github.com/rs/zerolog/log" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/watch" +) + +var subjectHeader = resource.Row{"NAME", "KIND", "FIRST LOCATION"} + +type ( + cachedEventer interface { + header() resource.Row + getCache() resource.RowEvents + setCache(resource.RowEvents) + } + + // Subject presents a user/group viewer. + Subject struct { + *Table + + cancel context.CancelFunc + subjectKind string + cache resource.RowEvents + } +) + +// NewSubject returns a new subject viewer. +func NewSubject(title, gvr string, list resource.List) ResourceViewer { + s := Subject{} + s.Table = NewTable("Subject") + s.SetActiveNS("*") + s.SetColorerFn(rbacColorer) + s.bindKeys() + + return &s +} + +// Init initializes the view. +func (s *Subject) Init(ctx context.Context) { + s.Table.Init(ctx) + s.SetSortCol(1, len(rbacHeader), true) + s.subjectKind = mapCmdSubject(s.app.Config.K9s.ActiveCluster().View.Active) + s.SetBaseTitle(s.subjectKind) + + s.Start() + s.refresh() + s.SelectRow(1, true) +} + +func (s *Subject) Start() { + s.Stop() + + var ctx context.Context + ctx, s.cancel = context.WithCancel(context.Background()) + go func(ctx context.Context) { + for { + select { + case <-ctx.Done(): + log.Debug().Msgf("Subject:%s Watch bailing out!", s.subjectKind) + return + case <-time.After(time.Duration(s.app.Config.K9s.GetRefreshRate()) * time.Second): + s.refresh() + s.app.Draw() + } + } + }(ctx) +} + +func (s *Subject) Stop() { + if s.cancel != nil { + s.cancel() + } +} + +func (s *Subject) Name() string { + return "subject" +} + +func (s *Subject) masterPage() *Table { + return s.Table +} + +func (s *Subject) bindKeys() { + // No time data or ns + s.RmAction(ui.KeyShiftA) + s.RmAction(ui.KeyShiftP) + + s.AddActions(ui.KeyActions{ + tcell.KeyEnter: ui.NewKeyAction("Policies", s.policyCmd, true), + tcell.KeyEscape: ui.NewKeyAction("Reset", s.resetCmd, false), + ui.KeySlash: ui.NewKeyAction("Filter", s.activateCmd, false), + ui.KeyP: ui.NewKeyAction("Previous", s.app.PrevCmd, false), + ui.KeyShiftK: ui.NewKeyAction("Sort Kind", s.SortColCmd(1), false), + }) +} + +func (s *Subject) setExtraActionsFn(f ActionsFunc) {} +func (s *Subject) setColorerFn(f ui.ColorerFunc) {} +func (s *Subject) setEnterFn(f enterFn) {} +func (s *Subject) setDecorateFn(f decorateFn) {} + +func (s *Subject) getTitle() string { + return fmt.Sprintf(rbacTitleFmt, "Subject", s.subjectKind) +} + +func (s *Subject) SetSubject(n string) { + s.subjectKind = mapSubject(n) +} + +func (s *Subject) refresh() { + data, err := s.reconcile() + if err != nil { + log.Error().Err(err).Msgf("Refresh for %s", s.subjectKind) + s.app.Flash().Err(err) + } + s.Update(data) +} + +func (s *Subject) policyCmd(evt *tcell.EventKey) *tcell.EventKey { + if !s.RowSelected() { + return evt + } + + if s.cancel != nil { + s.cancel() + } + + _, n := namespaced(s.GetSelectedItem()) + s.app.inject(NewPolicy(s.app, mapFuSubject(s.subjectKind), n)) + + return nil +} + +func (s *Subject) resetCmd(evt *tcell.EventKey) *tcell.EventKey { + if !s.SearchBuff().Empty() { + s.SearchBuff().Reset() + return nil + } + + return s.backCmd(evt) +} + +func (s *Subject) backCmd(evt *tcell.EventKey) *tcell.EventKey { + if s.cancel != nil { + s.cancel() + } + + if s.SearchBuff().IsActive() { + s.SearchBuff().Reset() + return nil + } + + return s.app.PrevCmd(evt) +} + +func (s *Subject) reconcile() (resource.TableData, error) { + var table resource.TableData + + evts, err := s.clusterSubjects() + if err != nil { + return table, err + } + + nevts, err := s.namespacedSubjects() + if err != nil { + return table, err + } + for k, v := range nevts { + evts[k] = v + } + + return buildTable(s, evts), nil +} + +func (s *Subject) header() resource.Row { + return subjectHeader +} + +func (s *Subject) getCache() resource.RowEvents { + return s.cache +} + +func (s *Subject) setCache(evts resource.RowEvents) { + s.cache = evts +} + +func buildTable(c cachedEventer, evts resource.RowEvents) resource.TableData { + table := resource.TableData{ + Header: c.header(), + Rows: make(resource.RowEvents, len(evts)), + Namespace: "*", + } + + noDeltas := make(resource.Row, len(c.header())) + cache := c.getCache() + if len(cache) == 0 { + for k, ev := range evts { + ev.Action = resource.New + ev.Deltas = noDeltas + table.Rows[k] = ev + } + c.setCache(evts) + return table + } + + for k, ev := range evts { + table.Rows[k] = ev + + newr := ev.Fields + if _, ok := cache[k]; !ok { + ev.Action, ev.Deltas = watch.Added, noDeltas + continue + } + oldr := cache[k].Fields + deltas := make(resource.Row, len(newr)) + if !reflect.DeepEqual(oldr, newr) { + ev.Action = watch.Modified + for i, field := range oldr { + if field != newr[i] { + deltas[i] = field + } + } + ev.Deltas = deltas + } else { + ev.Action = resource.Unchanged + ev.Deltas = noDeltas + } + } + + for k := range evts { + if _, ok := table.Rows[k]; !ok { + delete(evts, k) + } + } + c.setCache(evts) + + return table +} + +func (s *Subject) clusterSubjects() (resource.RowEvents, error) { + crbs, err := s.app.Conn().DialOrDie().RbacV1().ClusterRoleBindings().List(metav1.ListOptions{}) + if err != nil { + return nil, err + } + + evts := make(resource.RowEvents, len(crbs.Items)) + for _, crb := range crbs.Items { + for _, subject := range crb.Subjects { + if subject.Kind != s.subjectKind { + continue + } + evts[subject.Name] = &resource.RowEvent{ + Fields: resource.Row{subject.Name, "ClusterRoleBinding", crb.Name}, + } + } + } + + return evts, nil +} + +func (s *Subject) namespacedSubjects() (resource.RowEvents, error) { + rbs, err := s.app.Conn().DialOrDie().RbacV1().RoleBindings("").List(metav1.ListOptions{}) + if err != nil { + return nil, err + } + + evts := make(resource.RowEvents, len(rbs.Items)) + for _, rb := range rbs.Items { + for _, subject := range rb.Subjects { + if subject.Kind == s.subjectKind { + evts[subject.Name] = &resource.RowEvent{ + Fields: resource.Row{subject.Name, "RoleBinding", rb.Name}, + } + } + } + } + + return evts, nil +} + +func mapCmdSubject(subject string) string { + log.Debug().Msgf("!!!!!!Subject %q", subject) + switch subject { + case "groups": + return "Group" + case "sas": + return "ServiceAccount" + default: + return "User" + } +} + +func mapFuSubject(subject string) string { + switch subject { + case "Group": + return "g" + case "ServiceAccount": + return "s" + default: + return "u" + } +} diff --git a/internal/views/svc.go b/internal/view/svc.go similarity index 72% rename from internal/views/svc.go rename to internal/view/svc.go index 7b548c30..f7c4ae08 100644 --- a/internal/views/svc.go +++ b/internal/view/svc.go @@ -1,4 +1,4 @@ -package views +package view import ( "errors" @@ -16,41 +16,41 @@ import ( v1 "k8s.io/api/core/v1" ) -type svcView struct { - *resourceView +type Service struct { + *Resource bench *perf.Benchmark } -func newSvcView(title, gvr string, app *appView, list resource.List) resourceViewer { - v := svcView{ - resourceView: newResourceView(title, gvr, app, list).(*resourceView), +func NewService(title, gvr string, list resource.List) ResourceViewer { + s := Service{ + Resource: NewResource(title, gvr, list), } - v.extraActionsFn = v.extraActions - v.enterFn = v.showPods - v.AddPage("logs", newLogsView(list.GetName(), app, &v), true, false) + s.extraActionsFn = s.extraActions + s.enterFn = s.showPods + s.AddPage("logs", NewLogs(list.GetName(), &s), true, false) - return &v + return &s } // Protocol... -func (v *svcView) getList() resource.List { +func (v *Service) getList() resource.List { return v.list } -func (v *svcView) getSelection() string { +func (v *Service) getSelection() string { return v.masterPage().GetSelectedItem() } -func (v *svcView) extraActions(aa ui.KeyActions) { +func (v *Service) extraActions(aa ui.KeyActions) { aa[ui.KeyL] = ui.NewKeyAction("Logs", v.logsCmd, true) aa[tcell.KeyCtrlB] = ui.NewKeyAction("Bench", v.benchCmd, true) aa[tcell.KeyCtrlK] = ui.NewKeyAction("Bench Stop", v.benchStopCmd, true) aa[ui.KeyShiftT] = ui.NewKeyAction("Sort Type", v.sortColCmd(1, false), false) } -func (v *svcView) sortColCmd(col int, asc bool) func(evt *tcell.EventKey) *tcell.EventKey { +func (v *Service) sortColCmd(col int, asc bool) func(evt *tcell.EventKey) *tcell.EventKey { return func(evt *tcell.EventKey) *tcell.EventKey { t := v.masterPage() t.SetSortCol(t.NameColIndex()+col, 0, asc) @@ -60,7 +60,7 @@ func (v *svcView) sortColCmd(col int, asc bool) func(evt *tcell.EventKey) *tcell } } -func (v *svcView) showPods(app *appView, ns, res, sel string) { +func (v *Service) showPods(app *App, ns, res, sel string) { s := k8s.NewService(app.Conn()) ns, n := namespaced(sel) svc, err := s.Get(ns, n) @@ -74,30 +74,32 @@ func (v *svcView) showPods(app *appView, ns, res, sel string) { } } -func (v *svcView) logsCmd(evt *tcell.EventKey) *tcell.EventKey { +func (v *Service) logsCmd(evt *tcell.EventKey) *tcell.EventKey { if !v.masterPage().RowSelected() { return evt } - l := v.GetPrimitive("logs").(*logsView) + l := v.GetPrimitive("logs").(*Logs) l.reload("", v, false) v.switchPage("logs") return nil } -func (v *svcView) backCmd(evt *tcell.EventKey) *tcell.EventKey { +func (v *Service) backCmd(evt *tcell.EventKey) *tcell.EventKey { // Reset namespace to what it was - v.app.Config.SetActiveNamespace(v.list.GetNamespace()) + if err := v.app.Config.SetActiveNamespace(v.list.GetNamespace()); err != nil { + log.Error().Err(err).Msg("Unable to set active namespace") + } v.app.inject(v) return nil } -func (v *svcView) benchStopCmd(evt *tcell.EventKey) *tcell.EventKey { +func (v *Service) benchStopCmd(evt *tcell.EventKey) *tcell.EventKey { if v.bench != nil { log.Debug().Msg(">>> Benchmark canceled!!") - v.app.status(ui.FlashErr, "Benchmark Camceled!") + v.app.status(ui.FlashErr, "Benchmark Canceled!") v.bench.Cancel() } v.app.StatusReset() @@ -105,7 +107,7 @@ func (v *svcView) benchStopCmd(evt *tcell.EventKey) *tcell.EventKey { return nil } -func (v *svcView) checkSvc(row int) error { +func (v *Service) checkSvc(row int) error { svcType := trimCellRelative(v.masterPage(), row, 1) if svcType != "NodePort" && svcType != "LoadBalancer" { return errors.New("You must select a reachable service") @@ -113,7 +115,7 @@ func (v *svcView) checkSvc(row int) error { return nil } -func (v *svcView) getExternalPort(row int) (string, error) { +func (v *Service) getExternalPort(row int) (string, error) { ports := trimCellRelative(v.masterPage(), row, 5) pp := strings.Split(ports, " ") @@ -130,13 +132,13 @@ func (v *svcView) getExternalPort(row int) (string, error) { return tokens[1], nil } -func (v *svcView) reloadBenchCfg() error { +func (v *Service) reloadBenchCfg() error { // BOZO!! Poorman Reload bench to make sure we pick up updates if any. path := ui.BenchConfig(v.app.Config.K9s.CurrentCluster) return v.app.Bench.Reload(path) } -func (v *svcView) benchCmd(evt *tcell.EventKey) *tcell.EventKey { +func (v *Service) benchCmd(evt *tcell.EventKey) *tcell.EventKey { if !v.masterPage().RowSelected() || v.bench != nil { return evt } @@ -174,7 +176,7 @@ func (v *svcView) benchCmd(evt *tcell.EventKey) *tcell.EventKey { return nil } -func (v *svcView) runBenchmark(port string, cfg config.BenchConfig) error { +func (v *Service) runBenchmark(port string, cfg config.BenchConfig) error { var err error base := "http://" + cfg.HTTP.Host + ":" + port + cfg.HTTP.Path if v.bench, err = perf.NewBenchmark(base, cfg); err != nil { @@ -188,7 +190,7 @@ func (v *svcView) runBenchmark(port string, cfg config.BenchConfig) error { return nil } -func (v *svcView) benchDone() { +func (v *Service) benchDone() { log.Debug().Msg("Bench Completed!") v.app.QueueUpdate(func() { if v.bench.Canceled() { @@ -202,14 +204,14 @@ func (v *svcView) benchDone() { }) } -func benchTimedOut(app *appView) { +func benchTimedOut(app *App) { <-time.After(2 * time.Second) app.QueueUpdate(func() { app.StatusReset() }) } -func (v *svcView) showSvcPods(ns string, sel map[string]string, a ui.ActionHandler) { +func (v *Service) showSvcPods(ns string, sel map[string]string, a ui.ActionHandler) { var s []string for k, v := range sel { s = append(s, fmt.Sprintf("%s=%s", k, v)) diff --git a/internal/view/table.go b/internal/view/table.go new file mode 100644 index 00000000..321cc554 --- /dev/null +++ b/internal/view/table.go @@ -0,0 +1,125 @@ +package view + +import ( + "context" + + "github.com/derailed/k9s/internal/ui" + "github.com/gdamore/tcell" +) + +type Table struct { + *ui.Table + + app *App + filterFn func(string) +} + +func NewTable(title string) *Table { + return &Table{ + Table: ui.NewTable(title), + } +} + +func (t *Table) Init(ctx context.Context) { + t.app = ctx.Value(ui.KeyApp).(*App) + + ctx = context.WithValue(ctx, ui.KeyStyles, t.app.Styles) + t.Table.Init(ctx) + + t.SearchBuff().AddListener(t.app.Cmd()) + t.SearchBuff().AddListener(t) + t.bindKeys() +} + +func (t *Table) Start() {} +func (t *Table) Stop() {} +func (t *Table) Name() string { return "table" } + +// BufferChanged indicates the buffer was changed. +func (t *Table) BufferChanged(s string) {} + +// BufferActive indicates the buff activity changed. +func (t *Table) BufferActive(state bool, k ui.BufferKind) { + t.app.BufferActive(state, k) +} + +func (t *Table) saveCmd(evt *tcell.EventKey) *tcell.EventKey { + if path, err := saveTable(t.app.Config.K9s.CurrentCluster, t.GetBaseTitle(), t.GetFilteredData()); err != nil { + t.app.Flash().Err(err) + } else { + t.app.Flash().Infof("File %s saved successfully!", path) + } + + return nil +} + +func (t *Table) setFilterFn(fn func(string)) { + t.filterFn = fn + + cmd := t.SearchBuff().String() + if ui.IsLabelSelector(cmd) && t.filterFn != nil { + t.filterFn(ui.TrimLabelSelector(cmd)) + } +} + +func (t *Table) bindKeys() { + t.AddActions(ui.KeyActions{ + tcell.KeyCtrlS: ui.NewKeyAction("Save", t.saveCmd, true), + ui.KeySlash: ui.NewKeyAction("Filter Mode", t.activateCmd, false), + tcell.KeyEscape: ui.NewKeyAction("Filter Reset", t.resetCmd, false), + tcell.KeyEnter: ui.NewKeyAction("Filter", t.filterCmd, false), + tcell.KeyBackspace2: ui.NewKeyAction("Erase", t.eraseCmd, false), + tcell.KeyBackspace: ui.NewKeyAction("Erase", t.eraseCmd, false), + tcell.KeyDelete: ui.NewKeyAction("Erase", t.eraseCmd, false), + ui.KeyShiftI: ui.NewKeyAction("Invert", t.SortInvertCmd, false), + ui.KeyShiftN: ui.NewKeyAction("Sort Name", t.SortColCmd(0), false), + ui.KeyShiftA: ui.NewKeyAction("Sort Age", t.SortColCmd(-1), false), + }) +} + +func (t *Table) filterCmd(evt *tcell.EventKey) *tcell.EventKey { + if !t.SearchBuff().IsActive() { + return evt + } + + t.SearchBuff().SetActive(false) + cmd := t.SearchBuff().String() + if ui.IsLabelSelector(cmd) && t.filterFn != nil { + t.filterFn(ui.TrimLabelSelector(cmd)) + return nil + } + t.Refresh() + + return nil +} + +func (t *Table) eraseCmd(evt *tcell.EventKey) *tcell.EventKey { + if t.SearchBuff().IsActive() { + t.SearchBuff().Delete() + } + + return nil +} + +func (t *Table) resetCmd(evt *tcell.EventKey) *tcell.EventKey { + if !t.SearchBuff().Empty() { + t.app.Flash().Info("Clearing filter...") + } + if ui.IsLabelSelector(t.SearchBuff().String()) { + t.filterFn("") + } + t.SearchBuff().Reset() + t.Refresh() + + return nil +} + +func (t *Table) activateCmd(evt *tcell.EventKey) *tcell.EventKey { + if t.app.InCmdMode() { + return evt + } + t.app.Flash().Info("Filter mode activated.") + t.SearchBuff().SetActive(true) + + return nil +} diff --git a/internal/views/table_helper.go b/internal/view/table_helper.go similarity index 69% rename from internal/views/table_helper.go rename to internal/view/table_helper.go index c44e91e9..0bd203bd 100644 --- a/internal/views/table_helper.go +++ b/internal/view/table_helper.go @@ -1,11 +1,10 @@ -package views +package view import ( "encoding/csv" "fmt" "os" "path/filepath" - "regexp" "strings" "time" @@ -14,38 +13,10 @@ import ( "github.com/derailed/k9s/internal/ui" ) -const ( - titleFmt = "[fg:bg:b] %s[fg:bg:-][[count:bg:b]%d[fg:bg:-]] " - searchFmt = "<[filter:bg:r]/%s[fg:bg:-]> " - nsTitleFmt = "[fg:bg:b] %s([hilite:bg:b]%s[fg:bg:-])[fg:bg:-][[count:bg:b]%d[fg:bg:-]][fg:bg:-] " - labelSelIndicator = "-l" - descIndicator = "↓" - ascIndicator = "↑" - fullFmat = "%s-%s-%d.csv" - noNSFmat = "%s-%d.csv" -) - -var ( - cpuRX = regexp.MustCompile(`\A.{0,1}CPU`) - memRX = regexp.MustCompile(`\A.{0,1}MEM`) - labelCmd = regexp.MustCompile(`\A\-l`) -) - -type cleanseFn func(string) string - -func trimCellRelative(tv *tableView, row, col int) string { - return ui.TrimCell(tv.Table, row, tv.NameColIndex()+col) +func trimCellRelative(t *Table, row, col int) string { + return ui.TrimCell(t.Table, row, t.NameColIndex()+col) } -// func trimCell(tv *ui.Table, row, col int) string { -// c := tv.GetCell(row, col) -// if c == nil { -// log.Error().Err(fmt.Errorf("No cell at location [%d:%d]", row, col)).Msg("Trim cell failed!") -// return "" -// } -// return strings.TrimSpace(c.Text) -// } - func saveTable(cluster, name string, data resource.TableData) (string, error) { dir := filepath.Join(config.K9sDumpDir, cluster) if err := ensureDir(dir); err != nil { @@ -56,9 +27,9 @@ func saveTable(cluster, name string, data resource.TableData) (string, error) { if ns == resource.AllNamespaces { ns = resource.AllNamespace } - fName := fmt.Sprintf(fullFmat, name, ns, now) + fName := fmt.Sprintf(ui.FullFmat, name, ns, now) if ns == resource.NotNamespaced { - fName = fmt.Sprintf(noNSFmat, name, now) + fName = fmt.Sprintf(ui.NoNSFmat, name, now) } path := filepath.Join(dir, fName) @@ -86,17 +57,6 @@ func saveTable(cluster, name string, data resource.TableData) (string, error) { return path, nil } -func isLabelSelector(s string) bool { - if s == "" { - return false - } - return labelCmd.MatchString(s) -} - -func trimLabelSelector(s string) string { - return strings.TrimSpace(s[2:]) -} - func skinTitle(fmat string, style config.Frame) string { fmat = strings.Replace(fmat, "[fg:bg", "["+style.Title.FgColor+":"+style.Title.BgColor, -1) fmat = strings.Replace(fmat, "[hilite", "["+style.Title.HighlightColor, 1) diff --git a/internal/view/table_test.go b/internal/view/table_test.go new file mode 100644 index 00000000..24ed42f7 --- /dev/null +++ b/internal/view/table_test.go @@ -0,0 +1,116 @@ +package view_test + +// import ( +// "context" +// "io/ioutil" +// "path/filepath" +// "testing" + +// "github.com/derailed/k9s/internal/config" +// "github.com/derailed/k9s/internal/resource" +// "github.com/derailed/k9s/internal/ui" +// "github.com/derailed/k9s/internal/view" +// "github.com/stretchr/testify/assert" +// "k8s.io/apimachinery/pkg/watch" +// ) + +// func TestTableSave(t *testing.T) { +// v := view.NewTable("test") +// v.SetTitle("k9s-test") +// dir := filepath.Join(config.K9sDumpDir, v.app.Config.K9s.CurrentCluster) +// c1, _ := ioutil.ReadDir(dir) +// v.saveCmd(nil) +// c2, _ := ioutil.ReadDir(dir) +// assert.Equal(t, len(c2), len(c1)+1) +// } + +// func TestTableNew(t *testing.T) { +// v := view.NewTable("test") +// ctx := context.WithValue(ui.KeyApp, NewApp(config.NewConfig(ks{}))) +// v.Init(ctx) + +// data := resource.TableData{ +// Header: resource.Row{"NAMESPACE", "NAME", "FRED", "AGE"}, +// Rows: resource.RowEvents{ +// "ns1/a": &resource.RowEvent{ +// Action: watch.Added, +// Fields: resource.Row{"ns1", "a", "10", "3m"}, +// Deltas: resource.Row{"", "", "", ""}, +// }, +// "ns1/b": &resource.RowEvent{ +// Action: watch.Added, +// Fields: resource.Row{"ns1", "b", "15", "1m"}, +// Deltas: resource.Row{"", "", "20", ""}, +// }, +// }, +// NumCols: map[string]bool{ +// "FRED": true, +// }, +// Namespace: "", +// } +// v.Update(data) +// assert.Equal(t, 3, v.GetRowCount()) +// } + +// func TestTableViewFilter(t *testing.T) { +// v := newTableView(NewApp(config.NewConfig(ks{})), "test") + +// data := resource.TableData{ +// Header: resource.Row{"NAMESPACE", "NAME", "FRED", "AGE"}, +// Rows: resource.RowEvents{ +// "ns1/blee": &resource.RowEvent{ +// Action: watch.Added, +// Fields: resource.Row{"ns1", "blee", "10", "3m"}, +// Deltas: resource.Row{"", "", "", ""}, +// }, +// "ns1/fred": &resource.RowEvent{ +// Action: watch.Added, +// Fields: resource.Row{"ns1", "fred", "15", "1m"}, +// Deltas: resource.Row{"", "", "20", ""}, +// }, +// }, +// NumCols: map[string]bool{ +// "FRED": true, +// }, +// Namespace: "", +// } +// v.Update(data) +// v.SearchBuff().SetActive(true) +// v.SearchBuff().Set("blee") +// v.filterCmd(nil) +// assert.Equal(t, 2, v.GetRowCount()) +// v.resetCmd(nil) +// assert.Equal(t, 3, v.GetRowCount()) +// } + +// func TestTableViewSort(t *testing.T) { +// v := newTableView(NewApp(config.NewConfig(ks{})), "test") + +// data := resource.TableData{ +// Header: resource.Row{"NAMESPACE", "NAME", "FRED", "AGE"}, +// Rows: resource.RowEvents{ +// "ns1/blee": &resource.RowEvent{ +// Action: watch.Added, +// Fields: resource.Row{"ns1", "blee", "10", "3m"}, +// Deltas: resource.Row{"", "", "", ""}, +// }, +// "ns1/fred": &resource.RowEvent{ +// Action: watch.Added, +// Fields: resource.Row{"ns1", "fred", "15", "1m"}, +// Deltas: resource.Row{"", "", "20", ""}, +// }, +// }, +// NumCols: map[string]bool{ +// "FRED": true, +// }, +// Namespace: "", +// } +// v.Update(data) +// v.SortColCmd(1)(nil) +// assert.Equal(t, 3, v.GetRowCount()) +// assert.Equal(t, "blee ", v.GetCell(1, 1).Text) + +// v.SortInvertCmd(nil) +// assert.Equal(t, 3, v.GetRowCount()) +// assert.Equal(t, "fred ", v.GetCell(1, 1).Text) +// } diff --git a/internal/views/test_assets/b1.txt b/internal/view/test_assets/b1.txt similarity index 100% rename from internal/views/test_assets/b1.txt rename to internal/view/test_assets/b1.txt diff --git a/internal/views/test_assets/b2.txt b/internal/view/test_assets/b2.txt similarity index 100% rename from internal/views/test_assets/b2.txt rename to internal/view/test_assets/b2.txt diff --git a/internal/views/test_assets/b3.txt b/internal/view/test_assets/b3.txt similarity index 100% rename from internal/views/test_assets/b3.txt rename to internal/view/test_assets/b3.txt diff --git a/internal/views/test_assets/b4.txt b/internal/view/test_assets/b4.txt similarity index 100% rename from internal/views/test_assets/b4.txt rename to internal/view/test_assets/b4.txt diff --git a/internal/view/types.go b/internal/view/types.go new file mode 100644 index 00000000..829d4cf9 --- /dev/null +++ b/internal/view/types.go @@ -0,0 +1,9 @@ +package view + +import "github.com/derailed/k9s/internal/model" + +// Hinter represents a view that can produce menu hints. +type Hinter interface { + // Hints returns a collection of hints. + Hints() model.MenuHints +} diff --git a/internal/views/yaml.go b/internal/view/yaml.go similarity index 99% rename from internal/views/yaml.go rename to internal/view/yaml.go index 0795d5bf..a806ad5f 100644 --- a/internal/views/yaml.go +++ b/internal/view/yaml.go @@ -1,4 +1,4 @@ -package views +package view import ( "fmt" diff --git a/internal/views/yaml_test.go b/internal/view/yaml_test.go similarity index 98% rename from internal/views/yaml_test.go rename to internal/view/yaml_test.go index 860fed2b..96c71709 100644 --- a/internal/views/yaml_test.go +++ b/internal/view/yaml_test.go @@ -1,4 +1,4 @@ -package views +package view import ( "testing" diff --git a/internal/views/alias.go b/internal/views/alias.go deleted file mode 100644 index 7551e55e..00000000 --- a/internal/views/alias.go +++ /dev/null @@ -1,142 +0,0 @@ -package views - -import ( - "context" - "fmt" - "strings" - - "github.com/derailed/k9s/internal/k8s" - "github.com/derailed/k9s/internal/resource" - "github.com/derailed/k9s/internal/ui" - "github.com/gdamore/tcell" -) - -const ( - aliasTitle = "Aliases" - aliasTitleFmt = " [mediumseagreen::b]%s([fuchsia::b]%d[fuchsia::-][mediumseagreen::-]) " -) - -type aliasView struct { - *tableView - - app *appView - current ui.Igniter - cancel context.CancelFunc -} - -func newAliasView(app *appView, current ui.Igniter) *aliasView { - v := aliasView{ - tableView: newTableView(app, aliasTitle), - app: app, - } - v.SetBorderFocusColor(tcell.ColorMediumSpringGreen) - v.SetSelectedStyle(tcell.ColorWhite, tcell.ColorMediumSpringGreen, tcell.AttrNone) - v.SetColorerFn(aliasColorer) - v.current = current - v.SetActiveNS("") - v.registerActions() - - return &v -} - -// Init the view. -func (v *aliasView) Init(context.Context, string) { - v.Update(v.hydrate()) - v.app.SetFocus(v) - v.resetTitle() - v.app.SetHints(v.Hints()) -} - -func (v *aliasView) registerActions() { - v.RmAction(ui.KeyShiftA) - v.RmAction(ui.KeyShiftN) - v.RmAction(tcell.KeyCtrlS) - - v.SetActions(ui.KeyActions{ - tcell.KeyEnter: ui.NewKeyAction("Goto", v.gotoCmd, true), - tcell.KeyEscape: ui.NewKeyAction("Reset", v.resetCmd, false), - ui.KeySlash: ui.NewKeyAction("Filter", v.activateCmd, false), - ui.KeyShiftR: ui.NewKeyAction("Sort Resource", v.SortColCmd(0), false), - ui.KeyShiftC: ui.NewKeyAction("Sort Command", v.SortColCmd(1), false), - ui.KeyShiftA: ui.NewKeyAction("Sort ApiGroup", v.SortColCmd(2), false), - }) -} - -func (v *aliasView) getTitle() string { - return aliasTitle -} - -func (v *aliasView) resetCmd(evt *tcell.EventKey) *tcell.EventKey { - if !v.SearchBuff().Empty() { - v.SearchBuff().Reset() - return nil - } - - return v.backCmd(evt) -} - -func (v *aliasView) gotoCmd(evt *tcell.EventKey) *tcell.EventKey { - r, _ := v.GetSelection() - if r != 0 { - s := ui.TrimCell(v.Table, r, 1) - tokens := strings.Split(s, ",") - v.app.gotoResource(tokens[0], true) - return nil - } - - if v.SearchBuff().IsActive() { - return v.activateCmd(evt) - } - return evt -} - -func (v *aliasView) backCmd(evt *tcell.EventKey) *tcell.EventKey { - if v.cancel != nil { - v.cancel() - } - - if v.SearchBuff().IsActive() { - v.SearchBuff().Reset() - } else { - v.app.inject(v.current) - } - - return nil -} - -func (v *aliasView) hydrate() resource.TableData { - data := resource.TableData{ - Header: resource.Row{"RESOURCE", "COMMAND", "APIGROUP"}, - Rows: make(resource.RowEvents, len(aliases.Alias)), - Namespace: resource.NotNamespaced, - } - - aa := make(map[string][]string, len(aliases.Alias)) - for alias, gvr := range aliases.Alias { - if _, ok := aa[gvr]; ok { - aa[gvr] = append(aa[gvr], alias) - } else { - aa[gvr] = []string{alias} - } - } - - for gvr, aliases := range aa { - g := k8s.GVR(gvr) - fields := resource.Row{ - ui.Pad(g.ToR(), 30), - ui.Pad(strings.Join(aliases, ","), 70), - ui.Pad(g.ToG(), 30), - } - data.Rows[string(gvr)] = &resource.RowEvent{ - Action: resource.New, - Fields: fields, - Deltas: fields, - } - } - - return data -} - -func (v *aliasView) resetTitle() { - v.SetTitle(fmt.Sprintf(aliasTitleFmt, aliasTitle, v.GetRowCount()-1)) -} diff --git a/internal/views/alias_test.go b/internal/views/alias_test.go deleted file mode 100644 index 88935fae..00000000 --- a/internal/views/alias_test.go +++ /dev/null @@ -1,18 +0,0 @@ -package views - -import ( - "testing" - - "github.com/derailed/k9s/internal/config" - "github.com/stretchr/testify/assert" -) - -func TestAliasView(t *testing.T) { - v := newAliasView(NewApp(config.NewConfig(ks{})), nil) - td := v.hydrate() - v.Init(nil, "") - - assert.Equal(t, 3, len(td.Header)) - assert.Equal(t, 15, len(td.Rows)) - assert.Equal(t, "Aliases", v.getTitle()) -} diff --git a/internal/views/app_test.go b/internal/views/app_test.go deleted file mode 100644 index d7a7e02f..00000000 --- a/internal/views/app_test.go +++ /dev/null @@ -1,16 +0,0 @@ -package views - -import ( - "testing" - - "github.com/derailed/k9s/internal/config" - "github.com/stretchr/testify/assert" -) - -func TestNewApp(t *testing.T) { - a := NewApp(config.NewConfig(ks{})) - a.Init("blee", 10) - - assert.Equal(t, 11, len(a.GetActions())) - assert.Equal(t, false, a.HasSkins) -} diff --git a/internal/views/command_test.go b/internal/views/command_test.go deleted file mode 100644 index 864951c9..00000000 --- a/internal/views/command_test.go +++ /dev/null @@ -1,19 +0,0 @@ -package views - -import ( - "testing" - - "github.com/derailed/k9s/internal/config" - "github.com/stretchr/testify/assert" -) - -func TestCommandPush(t *testing.T) { - c := newCommand(NewApp(config.NewConfig(ks{}))) - c.pushCmd("fred") - c.pushCmd("blee") - p, top := c.previousCmd() - - assert.Equal(t, "fred", p) - assert.True(t, top) - assert.True(t, c.lastCmd()) -} diff --git a/internal/views/container.go b/internal/views/container.go deleted file mode 100644 index d1af6de3..00000000 --- a/internal/views/container.go +++ /dev/null @@ -1,170 +0,0 @@ -package views - -import ( - "context" - "errors" - "fmt" - "strings" - - "github.com/derailed/k9s/internal/k8s" - "github.com/derailed/k9s/internal/resource" - "github.com/derailed/k9s/internal/ui" - "github.com/derailed/k9s/internal/ui/dialog" - "github.com/gdamore/tcell" - "github.com/rs/zerolog/log" - "k8s.io/client-go/tools/portforward" -) - -type containerView struct { - *logResourceView - - current ui.Igniter - exitFn func() -} - -func newContainerView(title string, app *appView, list resource.List, path string, exitFn func()) resourceViewer { - v := containerView{logResourceView: newLogResourceView(title, "", app, list)} - v.path = &path - v.envFn = v.k9sEnv - v.containerFn = v.selectedContainer - v.extraActionsFn = v.extraActions - v.enterFn = v.viewLogs - v.colorerFn = containerColorer - v.current = app.Frame().GetPrimitive("main").(ui.Igniter) - v.exitFn = exitFn - - return &v -} - -func (v *containerView) Init(ctx context.Context, ns string) { - v.resourceView.Init(ctx, ns) -} - -func (v *containerView) extraActions(aa ui.KeyActions) { - v.logResourceView.extraActions(aa) - - aa[ui.KeyShiftF] = ui.NewKeyAction("PortForward", v.portFwdCmd, true) - aa[ui.KeyShiftL] = ui.NewKeyAction("Logs Previous", v.prevLogsCmd, true) - aa[ui.KeyS] = ui.NewKeyAction("Shell", v.shellCmd, true) - aa[tcell.KeyEscape] = ui.NewKeyAction("Back", v.backCmd, false) - aa[ui.KeyP] = ui.NewKeyAction("Previous", v.backCmd, false) - aa[ui.KeyShiftC] = ui.NewKeyAction("Sort CPU", v.sortColCmd(6, false), false) - aa[ui.KeyShiftM] = ui.NewKeyAction("Sort MEM", v.sortColCmd(7, false), false) - aa[ui.KeyShiftX] = ui.NewKeyAction("Sort CPU%", v.sortColCmd(8, false), false) - aa[ui.KeyShiftZ] = ui.NewKeyAction("Sort MEM%", v.sortColCmd(9, false), false) -} - -func (v *containerView) k9sEnv() K9sEnv { - env := v.defaultK9sEnv() - - ns, n := namespaced(*v.path) - env["POD"] = n - env["NAMESPACE"] = ns - - return env -} - -func (v *containerView) selectedContainer() string { - return v.masterPage().GetSelectedItem() -} - -func (v *containerView) viewLogs(app *appView, _, res, sel string) { - status := v.masterPage().GetSelectedCell(3) - if status == "Running" || status == "Completed" { - v.showLogs(false) - return - } - v.app.Flash().Err(errors.New("No logs available")) -} - -// Handlers... - -func (v *containerView) shellCmd(evt *tcell.EventKey) *tcell.EventKey { - if !v.masterPage().RowSelected() { - return evt - } - - v.stopUpdates() - shellIn(v.app, *v.path, v.masterPage().GetSelectedItem()) - v.restartUpdates() - return nil -} - -func (v *containerView) portFwdCmd(evt *tcell.EventKey) *tcell.EventKey { - if !v.masterPage().RowSelected() { - return evt - } - - sel := v.masterPage().GetSelectedItem() - if _, ok := v.app.forwarders[fwFQN(*v.path, sel)]; ok { - v.app.Flash().Err(fmt.Errorf("A PortForward already exist on container %s", *v.path)) - return nil - } - - state := v.masterPage().GetSelectedCell(3) - if state != "Running" { - v.app.Flash().Err(fmt.Errorf("Container %s is not running?", sel)) - return nil - } - - portC := v.masterPage().GetSelectedCell(10) - ports := strings.Split(portC, ",") - if len(ports) == 0 { - v.app.Flash().Err(errors.New("Container exposes no ports")) - return nil - } - - var port string - for _, p := range ports { - log.Debug().Msgf("Checking port %q", p) - if !isTCPPort(p) { - continue - } - port = strings.TrimSpace(p) - break - } - if port == "" { - v.app.Flash().Warn("No valid TCP port found on this container. User will specify...") - port = "MY_TCP_PORT!" - } - dialog.ShowPortForward(v.Pages, port, v.portForward) - - return nil -} - -func (v *containerView) portForward(lport, cport string) { - co := v.masterPage().GetSelectedCell(0) - pf := k8s.NewPortForward(v.app.Conn(), &log.Logger) - ports := []string{lport + ":" + cport} - fw, err := pf.Start(*v.path, co, ports) - if err != nil { - v.app.Flash().Err(err) - return - } - - log.Debug().Msgf(">>> Starting port forward %q %v", *v.path, ports) - go v.runForward(pf, fw) -} - -func (v *containerView) runForward(pf *k8s.PortForward, f *portforward.PortForwarder) { - v.app.QueueUpdateDraw(func() { - v.app.forwarders[pf.FQN()] = pf - v.app.Flash().Infof("PortForward activated %s:%s", pf.Path(), pf.Ports()[0]) - dialog.DismissPortForward(v.Pages) - }) - - pf.SetActive(true) - if err := f.ForwardPorts(); err != nil { - v.app.Flash().Err(err) - return - } - v.app.QueueUpdateDraw(func() { - delete(v.app.forwarders, pf.FQN()) - pf.SetActive(false) - }) -} - -func (v *containerView) backCmd(evt *tcell.EventKey) *tcell.EventKey { - v.exitFn() - return nil -} diff --git a/internal/views/context.go b/internal/views/context.go deleted file mode 100644 index 380de31c..00000000 --- a/internal/views/context.go +++ /dev/null @@ -1,68 +0,0 @@ -package views - -import ( - "strings" - - "github.com/derailed/k9s/internal/resource" - "github.com/derailed/k9s/internal/ui" -) - -type contextView struct { - *resourceView -} - -func newContextView(title, gvr string, app *appView, list resource.List) resourceViewer { - v := contextView{newResourceView(title, gvr, app, list).(*resourceView)} - v.extraActionsFn = v.extraActions - v.enterFn = v.useCtx - v.masterPage().SetSelectedFn(v.cleanser) - - return &v -} - -func (v *contextView) extraActions(aa ui.KeyActions) { - v.masterPage().RmAction(ui.KeyShiftA) -} - -func (v *contextView) useCtx(app *appView, _, res, sel string) { - if err := v.useContext(sel); err != nil { - app.Flash().Err(err) - return - } - app.gotoResource("po", true) -} - -func (*contextView) cleanser(s string) string { - name := strings.TrimSpace(s) - if strings.HasSuffix(name, "*") { - name = strings.TrimRight(name, "*") - } - if strings.HasSuffix(name, "(𝜟)") { - name = strings.TrimRight(name, "(𝜟)") - } - return name -} - -func (v *contextView) useContext(name string) error { - ctx := v.cleanser(name) - if err := v.list.Resource().(*resource.Context).Switch(ctx); err != nil { - return err - } - - v.app.switchCtx(name, false) - // v.app.stopForwarders() - // ns, err := v.app.Conn().Config().CurrentNamespaceName() - // if err != nil { - // log.Info().Err(err).Msg("No namespace specified using all namespaces") - // } - // v.app.startInformer(ns) - // v.app.Config.Reset() - // v.app.Config.Save() - // v.app.Flash().Infof("Switching context to %s", ctx) - v.refresh() - if tv, ok := v.GetPrimitive("ctx").(*tableView); ok { - tv.Select(1, 0) - } - - return nil -} diff --git a/internal/views/context_test.go b/internal/views/context_test.go deleted file mode 100644 index 52e06813..00000000 --- a/internal/views/context_test.go +++ /dev/null @@ -1,33 +0,0 @@ -package views - -import ( - "testing" - - "github.com/derailed/k9s/internal/config" - "github.com/derailed/k9s/internal/resource" - "github.com/stretchr/testify/assert" -) - -func TestContextView(t *testing.T) { - l := resource.NewContextList(nil, "fred") - v := newContextView("blee", "", NewApp(config.NewConfig(ks{})), l).(*contextView) - - assert.Equal(t, 10, len(v.hints())) -} - -func TestCleaner(t *testing.T) { - uu := map[string]struct { - s, e string - }{ - "normal": {"fred", "fred"}, - "default": {"fred*", "fred"}, - "delta": {"fred(𝜟)", "fred"}, - } - - v := contextView{} - for k, u := range uu { - t.Run(k, func(t *testing.T) { - assert.Equal(t, u.e, v.cleanser(u.s)) - }) - } -} diff --git a/internal/views/cronjob.go b/internal/views/cronjob.go deleted file mode 100644 index aab5be93..00000000 --- a/internal/views/cronjob.go +++ /dev/null @@ -1,37 +0,0 @@ -package views - -import ( - "github.com/derailed/k9s/internal/resource" - "github.com/derailed/k9s/internal/ui" - "github.com/gdamore/tcell" -) - -type cronJobView struct { - *resourceView -} - -func newCronJobView(title, gvr string, app *appView, list resource.List) resourceViewer { - v := cronJobView{resourceView: newResourceView(title, gvr, app, list).(*resourceView)} - v.extraActionsFn = v.extraActions - - return &v -} - -func (v *cronJobView) trigger(evt *tcell.EventKey) *tcell.EventKey { - if !v.masterPage().RowSelected() { - return evt - } - - sel := v.masterPage().GetSelectedItem() - if err := v.list.Resource().(resource.Runner).Run(sel); err != nil { - v.app.Flash().Errf("Cronjob trigger failed %v", err) - return evt - } - v.app.Flash().Infof("Triggering %s %s", v.list.GetName(), sel) - - return nil -} - -func (v *cronJobView) extraActions(aa ui.KeyActions) { - aa[tcell.KeyCtrlT] = ui.NewKeyAction("Trigger", v.trigger, true) -} diff --git a/internal/views/details.go b/internal/views/details.go deleted file mode 100644 index d6901609..00000000 --- a/internal/views/details.go +++ /dev/null @@ -1,263 +0,0 @@ -package views - -import ( - "fmt" - "regexp" - "strconv" - "strings" - - "github.com/atotto/clipboard" - "github.com/derailed/k9s/internal/config" - "github.com/derailed/k9s/internal/ui" - "github.com/derailed/tview" - "github.com/gdamore/tcell" - "github.com/rs/zerolog/log" -) - -const detailsTitleFmt = "[fg:bg:b] %s([hilite:bg:b]%s[fg:bg:-])[fg:bg:-] " - -type ( - textView struct { - *tview.TextView - - app *appView - actions ui.KeyActions - cmdBuff *ui.CmdBuff - title string - } - - detailsView struct { - *textView - - category string - backFn ui.ActionHandler - numSelections int - } -) - -func newTextView(app *appView) *textView { - return &textView{ - TextView: tview.NewTextView(), - app: app, - actions: make(ui.KeyActions), - } -} - -func newDetailsView(app *appView, backFn ui.ActionHandler) *detailsView { - v := detailsView{textView: newTextView(app)} - v.backFn = backFn - v.SetScrollable(true) - v.SetWrap(true) - v.SetDynamicColors(true) - v.SetRegions(true) - v.SetBorder(true) - v.SetBorderFocusColor(config.AsColor(v.app.Styles.Frame().Border.FocusColor)) - v.SetHighlightColor(tcell.ColorOrange) - v.SetTitleColor(tcell.ColorAqua) - v.SetInputCapture(v.keyboard) - - v.cmdBuff = ui.NewCmdBuff('/', ui.FilterBuff) - v.cmdBuff.AddListener(app.Cmd()) - v.cmdBuff.Reset() - - v.SetChangedFunc(func() { - app.Draw() - }) - - v.bindKeys() - - return &v -} - -func (v *detailsView) bindKeys() { - v.actions = ui.KeyActions{ - tcell.KeyBackspace2: ui.NewKeyAction("Erase", v.eraseCmd, false), - tcell.KeyBackspace: ui.NewKeyAction("Erase", v.eraseCmd, false), - tcell.KeyDelete: ui.NewKeyAction("Erase", v.eraseCmd, false), - tcell.KeyEscape: ui.NewKeyAction("Back", v.backCmd, true), - tcell.KeyTab: ui.NewKeyAction("Next Match", v.nextCmd, false), - tcell.KeyBacktab: ui.NewKeyAction("Previous Match", v.prevCmd, false), - tcell.KeyCtrlS: ui.NewKeyAction("Save", v.saveCmd, true), - ui.KeyC: ui.NewKeyAction("Copy", v.cpCmd, false), - } -} - -func (v *detailsView) setCategory(n string) { - v.category = n -} - -func (v *detailsView) keyboard(evt *tcell.EventKey) *tcell.EventKey { - key := evt.Key() - if key == tcell.KeyRune { - if v.cmdBuff.IsActive() { - v.cmdBuff.Add(evt.Rune()) - v.refreshTitle() - return nil - } - key = tcell.Key(evt.Rune()) - } - - if a, ok := v.actions[key]; ok { - log.Debug().Msgf(">> DetailsView handled %s", tcell.KeyNames[key]) - return a.Action(evt) - } - return evt -} - -func (v *detailsView) saveCmd(evt *tcell.EventKey) *tcell.EventKey { - if path, err := saveYAML(v.app.Config.K9s.CurrentCluster, v.title, v.GetText(true)); err != nil { - v.app.Flash().Err(err) - } else { - v.app.Flash().Infof("Log %s saved successfully!", path) - } - return nil -} - -func (v *detailsView) cpCmd(evt *tcell.EventKey) *tcell.EventKey { - v.app.Flash().Info("Content copied to clipboard...") - if err := clipboard.WriteAll(v.GetText(true)); err != nil { - v.app.Flash().Err(err) - } - return nil -} - -func (v *detailsView) backCmd(evt *tcell.EventKey) *tcell.EventKey { - if !v.cmdBuff.Empty() { - v.cmdBuff.Reset() - v.search(evt) - return nil - } - v.cmdBuff.Reset() - if v.backFn != nil { - return v.backFn(evt) - } - return evt -} - -func (v *detailsView) eraseCmd(evt *tcell.EventKey) *tcell.EventKey { - if !v.cmdBuff.IsActive() { - return evt - } - v.cmdBuff.Delete() - return nil -} - -func (v *detailsView) activateCmd(evt *tcell.EventKey) *tcell.EventKey { - if !v.app.InCmdMode() { - v.cmdBuff.SetActive(true) - v.cmdBuff.Clear() - return nil - } - return evt -} - -func (v *detailsView) searchCmd(evt *tcell.EventKey) *tcell.EventKey { - if v.cmdBuff.IsActive() && !v.cmdBuff.Empty() { - v.app.Flash().Infof("Searching for %s...", v.cmdBuff) - v.search(evt) - highlights := v.GetHighlights() - if len(highlights) > 0 { - v.Highlight() - } else { - v.Highlight("0").ScrollToHighlight() - } - } - v.cmdBuff.SetActive(false) - return evt -} - -func (v *detailsView) search(evt *tcell.EventKey) { - v.numSelections = 0 - log.Debug().Msgf("Searching... %s - %d", v.cmdBuff, v.numSelections) - v.Highlight("") - v.SetText(v.decorateLines(v.GetText(false), v.cmdBuff.String())) - - if v.cmdBuff.Empty() { - v.app.Flash().Info("Clearing out search query...") - v.refreshTitle() - return - } - if v.numSelections == 0 { - v.app.Flash().Warn("No matches found!") - return - } - v.app.Flash().Infof("Found <%d> matches! / for next/previous", v.numSelections) -} - -func (v *detailsView) nextCmd(evt *tcell.EventKey) *tcell.EventKey { - highlights := v.GetHighlights() - if len(highlights) == 0 || v.numSelections == 0 { - return evt - } - index, _ := strconv.Atoi(highlights[0]) - index = (index + 1) % v.numSelections - if index+1 == v.numSelections { - v.app.Flash().Info("Search hit BOTTOM, continuing at TOP") - } - v.Highlight(strconv.Itoa(index)).ScrollToHighlight() - return nil -} - -func (v *detailsView) prevCmd(evt *tcell.EventKey) *tcell.EventKey { - highlights := v.GetHighlights() - if len(highlights) == 0 || v.numSelections == 0 { - return evt - } - index, _ := strconv.Atoi(highlights[0]) - index = (index - 1 + v.numSelections) % v.numSelections - if index == 0 { - v.app.Flash().Info("Search hit TOP, continuing at BOTTOM") - } - v.Highlight(strconv.Itoa(index)).ScrollToHighlight() - return nil -} - -// SetActions to handle keyboard inputs -func (v *detailsView) setActions(aa ui.KeyActions) { - for k, a := range aa { - v.actions[k] = a - } -} - -// Hints fetch mmemonic and hints -func (v *detailsView) hints() ui.Hints { - if v.actions != nil { - return v.actions.Hints() - } - return nil -} - -func (v *detailsView) refreshTitle() { - v.setTitle(v.title) -} - -func (v *detailsView) setTitle(t string) { - v.title = t - - title := skinTitle(fmt.Sprintf(detailsTitleFmt, v.category, t), v.app.Styles.Frame()) - if !v.cmdBuff.Empty() { - title += skinTitle(fmt.Sprintf(searchFmt, v.cmdBuff.String()), v.app.Styles.Frame()) - } - v.SetTitle(title) -} - -var ( - regionRX = regexp.MustCompile(`\["([a-zA-Z0-9_,;: \-\.]*)"\]`) - escapeRX = regexp.MustCompile(`\[([a-zA-Z0-9_,;: \-\."#]+)\[(\[*)\]`) -) - -func (v *detailsView) decorateLines(buff, q string) string { - rx := regexp.MustCompile(`(?i)` + q) - lines := strings.Split(buff, "\n") - for i, l := range lines { - l = regionRX.ReplaceAllString(l, "") - l = escapeRX.ReplaceAllString(l, "") - if m := rx.FindString(l); len(m) > 0 { - lines[i] = rx.ReplaceAllString(l, fmt.Sprintf(`["%d"]%s[""]`, v.numSelections, m)) - v.numSelections++ - continue - } - lines[i] = l - } - return strings.Join(lines, "\n") -} diff --git a/internal/views/details_test.go b/internal/views/details_test.go deleted file mode 100644 index a7024f5e..00000000 --- a/internal/views/details_test.go +++ /dev/null @@ -1,20 +0,0 @@ -package views - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestDetailsDecorateLines(t *testing.T) { - buff := ` - I love blee - blee is much [blue::]cooler [green::]than foo! - ` - exp := ` - I love ["0"]blee[""] - ["1"]blee[""] is much [blue::]cooler [green::]than foo! - ` - v := detailsView{} - assert.Equal(t, exp, v.decorateLines(buff, "blee")) -} diff --git a/internal/views/dp.go b/internal/views/dp.go deleted file mode 100644 index 51448c8d..00000000 --- a/internal/views/dp.go +++ /dev/null @@ -1,57 +0,0 @@ -package views - -import ( - "github.com/derailed/k9s/internal/k8s" - "github.com/derailed/k9s/internal/resource" - "github.com/derailed/k9s/internal/ui" - v1 "k8s.io/api/apps/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -type deployView struct { - *logResourceView - scalableResourceView *scalableResourceView - restartableResourceView *restartableResourceView -} - -const scaleDialogKey = "scale" - -func newDeployView(title, gvr string, app *appView, list resource.List) resourceViewer { - logResourceView := newLogResourceView(title, gvr, app, list) - v := deployView{ - logResourceView: logResourceView, - scalableResourceView: newScalableResourceViewForParent(logResourceView.resourceView), - restartableResourceView: newRestartableResourceViewForParent(logResourceView.resourceView), - } - v.extraActionsFn = v.extraActions - v.enterFn = v.showPods - - return &v -} - -func (v *deployView) extraActions(aa ui.KeyActions) { - v.logResourceView.extraActions(aa) - v.scalableResourceView.extraActions(aa) - v.restartableResourceView.extraActions(aa) - aa[ui.KeyShiftD] = ui.NewKeyAction("Sort Desired", v.sortColCmd(1, false), false) - aa[ui.KeyShiftC] = ui.NewKeyAction("Sort Current", v.sortColCmd(2, false), false) -} - -func (v *deployView) showPods(app *appView, _, res, sel string) { - ns, n := namespaced(sel) - d := k8s.NewDeployment(app.Conn()) - dep, err := d.Get(ns, n) - if err != nil { - app.Flash().Err(err) - return - } - - dp := dep.(*v1.Deployment) - l, err := metav1.LabelSelectorAsSelector(dp.Spec.Selector) - if err != nil { - app.Flash().Err(err) - return - } - - showPods(app, ns, l.String(), "", v.backCmd) -} diff --git a/internal/views/dp_test.go b/internal/views/dp_test.go deleted file mode 100644 index 0aa3f58e..00000000 --- a/internal/views/dp_test.go +++ /dev/null @@ -1,16 +0,0 @@ -package views - -import ( - "testing" - - "github.com/derailed/k9s/internal/config" - "github.com/derailed/k9s/internal/resource" - "github.com/stretchr/testify/assert" -) - -func TestDeployView(t *testing.T) { - l := resource.NewDeploymentList(nil, "fred") - v := newDeployView("blee", "", NewApp(config.NewConfig(ks{})), l).(*deployView) - - assert.Equal(t, 10, len(v.hints())) -} diff --git a/internal/views/ds.go b/internal/views/ds.go deleted file mode 100644 index 76263250..00000000 --- a/internal/views/ds.go +++ /dev/null @@ -1,52 +0,0 @@ -package views - -import ( - "github.com/derailed/k9s/internal/k8s" - "github.com/derailed/k9s/internal/resource" - "github.com/derailed/k9s/internal/ui" - appsv1 "k8s.io/api/apps/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -type daemonSetView struct { - *logResourceView - restartableResourceView *restartableResourceView -} - -func newDaemonSetView(title, gvr string, app *appView, list resource.List) resourceViewer { - view := newLogResourceView(title, gvr, app, list) - v := daemonSetView{ - logResourceView: view, - restartableResourceView: newRestartableResourceViewForParent(view.resourceView), - } - v.extraActionsFn = v.extraActions - v.enterFn = v.showPods - - return &v -} - -func (v *daemonSetView) extraActions(aa ui.KeyActions) { - v.logResourceView.extraActions(aa) - v.restartableResourceView.extraActions(aa) - aa[ui.KeyShiftD] = ui.NewKeyAction("Sort Desired", v.sortColCmd(1, false), false) - aa[ui.KeyShiftC] = ui.NewKeyAction("Sort Current", v.sortColCmd(2, false), false) -} - -func (v *daemonSetView) showPods(app *appView, _, res, sel string) { - ns, n := namespaced(sel) - d := k8s.NewDaemonSet(app.Conn()) - dset, err := d.Get(ns, n) - if err != nil { - v.app.Flash().Err(err) - return - } - - ds := dset.(*appsv1.DaemonSet) - l, err := metav1.LabelSelectorAsSelector(ds.Spec.Selector) - if err != nil { - app.Flash().Err(err) - return - } - - showPods(app, ns, l.String(), "", v.backCmd) -} diff --git a/internal/views/ds_test.go b/internal/views/ds_test.go deleted file mode 100644 index 3503a903..00000000 --- a/internal/views/ds_test.go +++ /dev/null @@ -1,16 +0,0 @@ -package views - -import ( - "testing" - - "github.com/derailed/k9s/internal/config" - "github.com/derailed/k9s/internal/resource" - "github.com/stretchr/testify/assert" -) - -func TestDaemonSetView(t *testing.T) { - l := resource.NewDaemonSetList(nil, "fred") - v := newDaemonSetView("blee", "", NewApp(config.NewConfig(ks{})), l).(*daemonSetView) - - assert.Equal(t, 10, len(v.hints())) -} diff --git a/internal/views/dump.go b/internal/views/dump.go deleted file mode 100644 index 9d663fa6..00000000 --- a/internal/views/dump.go +++ /dev/null @@ -1,235 +0,0 @@ -package views - -import ( - "context" - "errors" - "fmt" - "io/ioutil" - "os" - "path/filepath" - "time" - - "github.com/derailed/k9s/internal/config" - "github.com/derailed/k9s/internal/resource" - "github.com/derailed/k9s/internal/ui" - "github.com/derailed/tview" - "github.com/fsnotify/fsnotify" - "github.com/gdamore/tcell" - "github.com/rs/zerolog/log" -) - -const ( - dumpTitle = "Screen Dumps" - dumpTitleFmt = " [mediumvioletred::b]%s([fuchsia::b]%d[fuchsia::-])[mediumvioletred::-] " -) - -var ( - dumpHeader = resource.Row{"NAME", "AGE"} -) - -type dumpView struct { - *tview.Pages - - app *appView - cancel context.CancelFunc -} - -func newDumpView(_, _ string, app *appView, _ resource.List) resourceViewer { - v := dumpView{ - Pages: tview.NewPages(), - app: app, - } - - tv := newTableView(app, dumpTitle) - tv.SetBorderFocusColor(tcell.ColorSteelBlue) - tv.SetSelectedStyle(tcell.ColorWhite, tcell.ColorRoyalBlue, tcell.AttrNone) - tv.SetColorerFn(dumpColorer) - tv.SetActiveNS("") - v.AddPage("table", tv, true, true) - - details := newDetailsView(app, v.backCmd) - v.AddPage("details", details, true, false) - v.registerActions() - - return &v -} - -func (v *dumpView) masterPage() *tableView { - return v.GetPrimitive("table").(*tableView) -} - -func (v *dumpView) setEnterFn(enterFn) {} -func (v *dumpView) setColorerFn(ui.ColorerFunc) {} -func (v *dumpView) setDecorateFn(decorateFn) {} -func (v *dumpView) setExtraActionsFn(ui.ActionsFunc) {} - -// Init the view. -func (v *dumpView) Init(ctx context.Context, _ string) { - if err := v.watchDumpDir(ctx); err != nil { - v.app.Flash().Errf("Unable to watch dumpmarks directory %s", err) - } - - tv := v.getTV() - v.refresh() - tv.SetSortCol(tv.NameColIndex()+1, 0, true) - tv.Refresh() - tv.SelectRow(1, true) - v.app.SetFocus(tv) -} - -func (v *dumpView) refresh() { - tv := v.getTV() - tv.Update(v.hydrate()) - tv.UpdateTitle() -} - -func (v *dumpView) registerActions() { - aa := ui.KeyActions{ - ui.KeyP: ui.NewKeyAction("Previous", v.app.prevCmd, false), - tcell.KeyEnter: ui.NewKeyAction("Enter", v.enterCmd, true), - tcell.KeyCtrlD: ui.NewKeyAction("Delete", v.deleteCmd, true), - tcell.KeyCtrlS: ui.NewKeyAction("Save", noopCmd, false), - } - - tv := v.getTV() - tv.SetActions(aa) - v.app.SetHints(tv.Hints()) -} - -func (v *dumpView) getTitle() string { - return dumpTitle -} - -func (v *dumpView) sortColCmd(col int, asc bool) func(evt *tcell.EventKey) *tcell.EventKey { - return func(evt *tcell.EventKey) *tcell.EventKey { - tv := v.getTV() - tv.SetSortCol(tv.NameColIndex()+col, 0, asc) - tv.Refresh() - return nil - } -} - -func (v *dumpView) enterCmd(evt *tcell.EventKey) *tcell.EventKey { - log.Debug().Msg("Dump enter!") - tv := v.getTV() - if tv.SearchBuff().IsActive() { - return tv.filterCmd(evt) - } - sel := tv.GetSelectedItem() - if sel == "" { - return nil - } - - dir := filepath.Join(config.K9sDumpDir, v.app.Config.K9s.CurrentCluster) - if !edit(true, v.app, filepath.Join(dir, sel)) { - v.app.Flash().Err(errors.New("Failed to launch editor")) - } - - return nil -} - -func (v *dumpView) deleteCmd(evt *tcell.EventKey) *tcell.EventKey { - sel := v.getTV().GetSelectedItem() - if sel == "" { - return nil - } - - dir := filepath.Join(config.K9sDumpDir, v.app.Config.K9s.CurrentCluster) - showModal(v.Pages, fmt.Sprintf("Delete screen dump `%s?", sel), "table", func() { - if err := os.Remove(filepath.Join(dir, sel)); err != nil { - v.app.Flash().Errf("Unable to delete file %s", err) - return - } - v.refresh() - v.app.Flash().Infof("ScreenDump file %s deleted!", sel) - }) - - return nil -} - -func (v *dumpView) backCmd(evt *tcell.EventKey) *tcell.EventKey { - if v.cancel != nil { - v.cancel() - } - v.SwitchToPage("table") - return nil -} - -func (v *dumpView) hints() ui.Hints { - return v.CurrentPage().Item.(ui.Hinter).Hints() -} - -func (v *dumpView) hydrate() resource.TableData { - data := resource.TableData{ - Header: dumpHeader, - Rows: make(resource.RowEvents, 10), - Namespace: resource.NotNamespaced, - } - - dir := filepath.Join(config.K9sDumpDir, v.app.Config.K9s.CurrentCluster) - ff, err := ioutil.ReadDir(dir) - if err != nil { - v.app.Flash().Errf("Unable to read dump directory %s", err) - } - - for _, f := range ff { - fields := resource.Row{f.Name(), time.Since(f.ModTime()).String()} - data.Rows[f.Name()] = &resource.RowEvent{ - Action: resource.New, - Fields: fields, - Deltas: fields, - } - } - - return data -} - -func (v *dumpView) resetTitle() { - v.SetTitle(fmt.Sprintf(dumpTitleFmt, dumpTitle, v.getTV().GetRowCount()-1)) -} - -func (v *dumpView) watchDumpDir(ctx context.Context) error { - w, err := fsnotify.NewWatcher() - if err != nil { - return err - } - - go func() { - for { - select { - case evt := <-w.Events: - log.Debug().Msgf("Dump event %#v", evt) - v.app.QueueUpdateDraw(func() { - v.refresh() - }) - case err := <-w.Errors: - log.Info().Err(err).Msg("Dir Watcher failed") - return - case <-ctx.Done(): - log.Debug().Msg("!!!! FS WATCHER DONE!!") - w.Close() - return - } - } - }() - - return w.Add(filepath.Join(config.K9sDumpDir, v.app.Config.K9s.CurrentCluster)) -} - -func (v *dumpView) getTV() *tableView { - if vu, ok := v.GetPrimitive("table").(*tableView); ok { - return vu - } - return nil -} - -func (v *dumpView) getDetails() *detailsView { - if vu, ok := v.GetPrimitive("details").(*detailsView); ok { - return vu - } - return nil -} - -func noopCmd(*tcell.EventKey) *tcell.EventKey { - return nil -} diff --git a/internal/views/forward.go b/internal/views/forward.go deleted file mode 100644 index e4a1d779..00000000 --- a/internal/views/forward.go +++ /dev/null @@ -1,381 +0,0 @@ -package views - -import ( - "context" - "errors" - "fmt" - "strings" - "time" - - "github.com/derailed/k9s/internal/config" - "github.com/derailed/k9s/internal/perf" - "github.com/derailed/k9s/internal/resource" - "github.com/derailed/k9s/internal/ui" - "github.com/derailed/tview" - "github.com/fsnotify/fsnotify" - "github.com/gdamore/tcell" - "github.com/rs/zerolog/log" -) - -const ( - forwardTitle = "Port Forwards" - forwardTitleFmt = " [aqua::b]%s([fuchsia::b]%d[fuchsia::-])[aqua::-] " - promptPage = "prompt" -) - -type forwardView struct { - *tview.Pages - - app *appView - cancel context.CancelFunc - bench *perf.Benchmark -} - -var _ resourceViewer = &forwardView{} - -func newForwardView(title, gvr string, app *appView, list resource.List) resourceViewer { - v := forwardView{ - Pages: tview.NewPages(), - app: app, - } - - tv := newTableView(app, forwardTitle) - tv.SetBorderFocusColor(tcell.ColorDodgerBlue) - tv.SetSelectedStyle(tcell.ColorWhite, tcell.ColorDodgerBlue, tcell.AttrNone) - tv.SetColorerFn(forwardColorer) - tv.SetActiveNS("") - v.AddPage("table", tv, true, true) - v.registerActions() - - return &v -} - -func (v *forwardView) masterPage() *tableView { - return v.GetPrimitive("table").(*tableView) -} - -func (v *forwardView) setEnterFn(enterFn) {} -func (v *forwardView) setColorerFn(ui.ColorerFunc) {} -func (v *forwardView) setDecorateFn(decorateFn) {} -func (v *forwardView) setExtraActionsFn(ui.ActionsFunc) {} - -// Init the view. -func (v *forwardView) Init(ctx context.Context, _ string) { - path := ui.BenchConfig(v.app.Config.K9s.CurrentCluster) - if err := watchFS(ctx, v.app, config.K9sHome, path, v.reload); err != nil { - v.app.Flash().Errf("RuRoh! Unable to watch benchmarks directory %s : %s", config.K9sHome, err) - } - - tv := v.getTV() - v.refresh() - tv.SetSortCol(tv.NameColIndex()+6, 0, true) - tv.Refresh() - tv.Select(1, 0) - v.app.SetFocus(tv) - v.app.SetHints(v.hints()) -} - -func (v *forwardView) getTV() *tableView { - if vu, ok := v.GetPrimitive("table").(*tableView); ok { - return vu - } - return nil -} - -func (v *forwardView) reload() { - path := ui.BenchConfig(v.app.Config.K9s.CurrentCluster) - log.Debug().Msgf("Reloading Config %s", path) - if err := v.app.Bench.Reload(path); err != nil { - v.app.Flash().Err(err) - } - v.refresh() -} - -func (v *forwardView) refresh() { - tv := v.getTV() - tv.Update(v.hydrate()) - v.app.SetFocus(tv) - tv.UpdateTitle() -} - -func (v *forwardView) registerActions() { - tv := v.getTV() - tv.SetActions(ui.KeyActions{ - tcell.KeyEnter: ui.NewKeyAction("Goto", v.gotoBenchCmd, true), - tcell.KeyCtrlB: ui.NewKeyAction("Bench", v.benchCmd, true), - tcell.KeyCtrlK: ui.NewKeyAction("Bench Stop", v.benchStopCmd, true), - tcell.KeyCtrlD: ui.NewKeyAction("Delete", v.deleteCmd, true), - ui.KeySlash: ui.NewKeyAction("Filter", tv.activateCmd, false), - ui.KeyP: ui.NewKeyAction("Previous", v.app.prevCmd, false), - ui.KeyShiftP: ui.NewKeyAction("Sort Ports", v.sortColCmd(2, true), false), - ui.KeyShiftU: ui.NewKeyAction("Sort URL", v.sortColCmd(4, true), false), - }) -} - -func (v *forwardView) getTitle() string { - return forwardTitle -} - -func (v *forwardView) sortColCmd(col int, asc bool) func(evt *tcell.EventKey) *tcell.EventKey { - return func(evt *tcell.EventKey) *tcell.EventKey { - tv := v.getTV() - tv.SetSortCol(tv.NameColIndex()+col, 0, asc) - v.refresh() - - return nil - } -} - -func (v *forwardView) gotoBenchCmd(evt *tcell.EventKey) *tcell.EventKey { - v.app.gotoResource("be", true) - - return nil -} - -func (v *forwardView) benchStopCmd(evt *tcell.EventKey) *tcell.EventKey { - if v.bench != nil { - log.Debug().Msg(">>> Benchmark canceled!!") - v.app.status(ui.FlashErr, "Benchmark Camceled!") - v.bench.Cancel() - } - v.app.StatusReset() - - return nil -} - -func (v *forwardView) benchCmd(evt *tcell.EventKey) *tcell.EventKey { - sel := v.getSelectedItem() - if sel == "" { - return nil - } - - if v.bench != nil { - v.app.Flash().Err(errors.New("Only one benchmark allowed at a time")) - return nil - } - - tv := v.getTV() - r, _ := tv.GetSelection() - cfg, co := defaultConfig(), ui.TrimCell(tv.Table, r, 2) - if b, ok := v.app.Bench.Benchmarks.Containers[containerID(sel, co)]; ok { - cfg = b - } - cfg.Name = sel - - base := ui.TrimCell(tv.Table, r, 4) - var err error - if v.bench, err = perf.NewBenchmark(base, cfg); err != nil { - v.app.Flash().Errf("Bench failed %v", err) - v.app.StatusReset() - return nil - } - - v.app.status(ui.FlashWarn, "Benchmark in progress...") - log.Debug().Msg("Bench starting...") - go v.runBenchmark() - - return nil -} - -func (v *forwardView) runBenchmark() { - v.bench.Run(v.app.Config.K9s.CurrentCluster, func() { - log.Debug().Msg("Bench Completed!") - v.app.QueueUpdate(func() { - if v.bench.Canceled() { - v.app.status(ui.FlashInfo, "Benchmark canceled") - } else { - v.app.status(ui.FlashInfo, "Benchmark Completed!") - v.bench.Cancel() - } - v.bench = nil - go func() { - <-time.After(2 * time.Second) - v.app.QueueUpdate(func() { v.app.StatusReset() }) - }() - }) - }) -} - -func (v *forwardView) getSelectedItem() string { - tv := v.getTV() - r, _ := tv.GetSelection() - if r == 0 { - return "" - } - return fwFQN( - fqn(ui.TrimCell(tv.Table, r, 0), ui.TrimCell(tv.Table, r, 1)), - ui.TrimCell(tv.Table, r, 2), - ) -} - -func (v *forwardView) deleteCmd(evt *tcell.EventKey) *tcell.EventKey { - tv := v.getTV() - if !tv.SearchBuff().Empty() { - tv.SearchBuff().Reset() - return nil - } - - sel := v.getSelectedItem() - if sel == "" { - return nil - } - - showModal(v.Pages, fmt.Sprintf("Delete PortForward `%s?", sel), "table", func() { - fw, ok := v.app.forwarders[sel] - if !ok { - log.Debug().Msgf("Unable to find forwarder %s", sel) - return - } - fw.Stop() - delete(v.app.forwarders, sel) - - log.Debug().Msgf("PortForwards after delete: %#v", v.app.forwarders) - v.getTV().Update(v.hydrate()) - v.app.Flash().Infof("PortForward %s deleted!", sel) - }) - - return nil -} - -func (v *forwardView) backCmd(evt *tcell.EventKey) *tcell.EventKey { - if v.cancel != nil { - v.cancel() - } - - tv := v.getTV() - if tv.SearchBuff().IsActive() { - tv.SearchBuff().Reset() - } else { - v.app.inject(v.app.Frame().GetPrimitive("main").(ui.Igniter)) - } - - return nil -} - -func (v *forwardView) hints() ui.Hints { - return v.getTV().Hints() -} - -func (v *forwardView) hydrate() resource.TableData { - data := initHeader(len(v.app.forwarders)) - dc, dn := v.app.Bench.Benchmarks.Defaults.C, v.app.Bench.Benchmarks.Defaults.N - for _, f := range v.app.forwarders { - c, n, cfg := loadConfig(dc, dn, containerID(f.Path(), f.Container()), v.app.Bench.Benchmarks.Containers) - - ports := strings.Split(f.Ports()[0], ":") - ns, na := namespaced(f.Path()) - fields := resource.Row{ - ns, - na, - f.Container(), - strings.Join(f.Ports(), ","), - urlFor(cfg, f.Container(), ports[0]), - asNum(c), - asNum(n), - f.Age(), - } - data.Rows[f.Path()] = &resource.RowEvent{ - Action: resource.New, - Fields: fields, - Deltas: fields, - } - } - - return data -} - -func (v *forwardView) resetTitle() { - v.SetTitle(fmt.Sprintf(forwardTitleFmt, forwardTitle, v.getTV().GetRowCount()-1)) -} - -// ---------------------------------------------------------------------------- -// Helpers... - -func defaultConfig() config.BenchConfig { - return config.BenchConfig{ - C: config.DefaultC, - N: config.DefaultN, - HTTP: config.HTTP{ - Method: config.DefaultMethod, - Path: "/", - }, - } -} - -func initHeader(rows int) resource.TableData { - return resource.TableData{ - Header: resource.Row{"NAMESPACE", "NAME", "CONTAINER", "PORTS", "URL", "C", "N", "AGE"}, - NumCols: map[string]bool{"C": true, "N": true}, - Rows: make(resource.RowEvents, rows), - Namespace: resource.AllNamespaces, - } -} - -func loadConfig(dc, dn int, id string, cc map[string]config.BenchConfig) (int, int, config.BenchConfig) { - c, n := dc, dn - cfg, ok := cc[id] - if !ok { - return c, n, cfg - } - - if cfg.C != 0 { - c = cfg.C - } - if cfg.N != 0 { - n = cfg.N - } - - return c, n, cfg -} - -func showModal(pv *tview.Pages, msg, back string, ok func()) { - m := tview.NewModal(). - AddButtons([]string{"Cancel", "OK"}). - SetTextColor(tcell.ColorFuchsia). - SetText(msg). - SetDoneFunc(func(_ int, b string) { - if b == "OK" { - ok() - } - dismissModal(pv, back) - }) - m.SetTitle("") - pv.AddPage(promptPage, m, false, false) - pv.ShowPage(promptPage) -} - -func dismissModal(pv *tview.Pages, page string) { - pv.RemovePage(promptPage) - pv.SwitchToPage(page) -} - -func watchFS(ctx context.Context, app *appView, dir, file string, cb func()) error { - w, err := fsnotify.NewWatcher() - if err != nil { - return err - } - - go func() { - for { - select { - case evt := <-w.Events: - log.Debug().Msgf("FS %s event %v", file, evt.Name) - if file == "" || evt.Name == file { - log.Debug().Msgf("Capuring Event %#v", evt) - app.QueueUpdateDraw(func() { - cb() - }) - } - case err := <-w.Errors: - log.Info().Err(err).Msgf("FS %s watcher failed", dir) - return - case <-ctx.Done(): - log.Debug().Msgf("<>", dir) - w.Close() - return - } - } - }() - - return w.Add(dir) -} diff --git a/internal/views/help_test.go b/internal/views/help_test.go deleted file mode 100644 index f43c4832..00000000 --- a/internal/views/help_test.go +++ /dev/null @@ -1,50 +0,0 @@ -package views - -import ( - "testing" - - "github.com/derailed/k9s/internal/config" - "github.com/derailed/k9s/internal/ui" - "github.com/stretchr/testify/assert" - v1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -type ks struct{} - -func (k ks) CurrentContextName() (string, error) { - return "test", nil -} - -func (k ks) CurrentClusterName() (string, error) { - return "test", nil -} - -func (k ks) CurrentNamespaceName() (string, error) { - return "test", nil -} - -func (k ks) ClusterNames() ([]string, error) { - return []string{"test"}, nil -} - -func (k ks) NamespaceNames(nn []v1.Namespace) []string { - return []string{"test"} -} - -func newNS(n string) v1.Namespace { - return v1.Namespace{ObjectMeta: metav1.ObjectMeta{ - Name: n, - }} -} - -func TestNewHelpView(t *testing.T) { - cfg := config.NewConfig(ks{}) - a := NewApp(cfg) - - v := newHelpView(a, nil, ui.Hints{{Mnemonic: "blee", Description: "duh"}}) - v.Init(nil, "") - - assert.Equal(t, "", v.GetCell(1, 0).Text) - assert.Equal(t, "duh", v.GetCell(1, 1).Text) -} diff --git a/internal/views/job.go b/internal/views/job.go deleted file mode 100644 index e1c715c0..00000000 --- a/internal/views/job.go +++ /dev/null @@ -1,44 +0,0 @@ -package views - -import ( - "github.com/derailed/k9s/internal/k8s" - "github.com/derailed/k9s/internal/resource" - "github.com/derailed/k9s/internal/ui" - batchv1 "k8s.io/api/batch/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -type jobView struct { - *logResourceView -} - -func newJobView(title, gvr string, app *appView, list resource.List) resourceViewer { - v := jobView{newLogResourceView(title, gvr, app, list)} - v.extraActionsFn = v.extraActions - v.enterFn = v.showPods - - return &v -} - -func (v *jobView) extraActions(aa ui.KeyActions) { - v.logResourceView.extraActions(aa) -} - -func (v *jobView) showPods(app *appView, ns, res, sel string) { - ns, n := namespaced(sel) - j := k8s.NewJob(app.Conn()) - job, err := j.Get(ns, n) - if err != nil { - app.Flash().Err(err) - return - } - - jo := job.(*batchv1.Job) - l, err := metav1.LabelSelectorAsSelector(jo.Spec.Selector) - if err != nil { - app.Flash().Err(err) - return - } - - showPods(app, ns, l.String(), "", v.backCmd) -} diff --git a/internal/views/log.go b/internal/views/log.go deleted file mode 100644 index 77df56ca..00000000 --- a/internal/views/log.go +++ /dev/null @@ -1,248 +0,0 @@ -package views - -import ( - "fmt" - "io" - "os" - "path/filepath" - "strings" - "sync/atomic" - "time" - - "github.com/derailed/k9s/internal/config" - "github.com/derailed/k9s/internal/ui" - "github.com/derailed/tview" - "github.com/gdamore/tcell" - "github.com/rs/zerolog/log" -) - -type ( - logFrame struct { - *tview.Flex - - app *appView - actions ui.KeyActions - backFn ui.ActionHandler - } - - logView struct { - *logFrame - - logs *detailsView - status *statusView - ansiWriter io.Writer - autoScroll int32 - path string - } -) - -func newLogFrame(app *appView, backFn ui.ActionHandler) *logFrame { - f := logFrame{ - Flex: tview.NewFlex(), - app: app, - backFn: backFn, - actions: make(ui.KeyActions), - } - f.SetBorder(true) - f.SetBackgroundColor(config.AsColor(app.Styles.Views().Log.BgColor)) - f.SetBorderPadding(0, 0, 1, 1) - f.SetDirection(tview.FlexRow) - - return &f -} - -func newLogView(_ string, app *appView, backFn ui.ActionHandler) *logView { - v := logView{ - logFrame: newLogFrame(app, backFn), - autoScroll: 1, - } - - v.logs = newDetailsView(app, backFn) - { - v.logs.SetBorder(false) - v.logs.setCategory("Logs") - v.logs.SetDynamicColors(true) - v.logs.SetTextColor(config.AsColor(app.Styles.Views().Log.FgColor)) - v.logs.SetBackgroundColor(config.AsColor(app.Styles.Views().Log.BgColor)) - v.logs.SetWrap(true) - v.logs.SetMaxBuffer(app.Config.K9s.LogBufferSize) - } - v.ansiWriter = tview.ANSIWriter(v.logs, app.Styles.Views().Log.FgColor, app.Styles.Views().Log.BgColor) - v.status = newStatusView(app.Styles) - v.AddItem(v.status, 1, 1, false) - v.AddItem(v.logs, 0, 1, true) - - v.bindKeys() - v.logs.SetInputCapture(v.keyboard) - - return &v -} - -func (v *logView) bindKeys() { - v.actions = ui.KeyActions{ - tcell.KeyEscape: ui.NewKeyAction("Back", v.backCmd, true), - ui.KeyC: ui.NewKeyAction("Clear", v.clearCmd, true), - ui.KeyS: ui.NewKeyAction("Toggle AutoScroll", v.toggleScrollCmd, true), - ui.KeyG: ui.NewKeyAction("Top", v.topCmd, false), - ui.KeyShiftG: ui.NewKeyAction("Bottom", v.bottomCmd, false), - ui.KeyF: ui.NewKeyAction("Up", v.pageUpCmd, false), - ui.KeyB: ui.NewKeyAction("Down", v.pageDownCmd, false), - tcell.KeyCtrlS: ui.NewKeyAction("Save", v.saveCmd, true), - } -} - -func (v *logView) setTitle(path, co string) { - var fmat string - if co == "" { - fmat = skinTitle(fmt.Sprintf(logFmt, path), v.app.Styles.Frame()) - } else { - fmat = skinTitle(fmt.Sprintf(logCoFmt, path, co), v.app.Styles.Frame()) - } - v.path = path - v.SetTitle(fmat) -} - -// Hints show action hints -func (v *logView) Hints() ui.Hints { - return v.actions.Hints() -} - -func (v *logView) keyboard(evt *tcell.EventKey) *tcell.EventKey { - key := evt.Key() - if key == tcell.KeyRune { - key = tcell.Key(evt.Rune()) - } - if m, ok := v.actions[key]; ok { - log.Debug().Msgf(">> LogView handled %s", tcell.KeyNames[key]) - return m.Action(evt) - } - - return evt -} - -func (v *logView) log(lines string) { - fmt.Fprintln(v.ansiWriter, tview.Escape(lines)) - log.Debug().Msgf("LOG LINES %d", v.logs.GetLineCount()) -} - -func (v *logView) flush(index int, buff []string) { - if index == 0 { - return - } - - if atomic.LoadInt32(&v.autoScroll) == 1 { - v.log(strings.Join(buff[:index], "\n")) - v.app.QueueUpdateDraw(func() { - v.updateIndicator() - v.logs.ScrollToEnd() - }) - } -} - -func (v *logView) updateIndicator() { - status := "Off" - if v.autoScroll == 1 { - status = "On" - } - v.status.update([]string{fmt.Sprintf("Autoscroll: %s", status)}) -} - -// ---------------------------------------------------------------------------- -// Actions... - -func (v *logView) saveCmd(evt *tcell.EventKey) *tcell.EventKey { - if path, err := saveData(v.app.Config.K9s.CurrentCluster, v.path, v.logs.GetText(true)); err != nil { - v.app.Flash().Err(err) - } else { - v.app.Flash().Infof("Log %s saved successfully!", path) - } - return nil -} - -func ensureDir(dir string) error { - return os.MkdirAll(dir, 0744) -} - -func saveData(cluster, name, data string) (string, error) { - dir := filepath.Join(config.K9sDumpDir, cluster) - if err := ensureDir(dir); err != nil { - return "", err - } - - now := time.Now().UnixNano() - fName := fmt.Sprintf("%s-%d.log", strings.Replace(name, "/", "-", -1), now) - - path := filepath.Join(dir, fName) - mod := os.O_CREATE | os.O_WRONLY - file, err := os.OpenFile(path, mod, 0644) - defer func() { - if file != nil { - file.Close() - } - }() - if err != nil { - log.Error().Err(err).Msgf("LogFile create %s", path) - return "", nil - } - if _, err := fmt.Fprintf(file, data); err != nil { - return "", err - } - - return path, nil -} - -func (v *logView) toggleScrollCmd(evt *tcell.EventKey) *tcell.EventKey { - if atomic.LoadInt32(&v.autoScroll) == 0 { - atomic.StoreInt32(&v.autoScroll, 1) - } else { - atomic.StoreInt32(&v.autoScroll, 0) - } - - if atomic.LoadInt32(&v.autoScroll) == 1 { - v.app.Flash().Info("Autoscroll is on.") - v.logs.ScrollToEnd() - } else { - v.logs.LineUp() - v.app.Flash().Info("Autoscroll is off.") - } - v.updateIndicator() - - return nil -} - -func (v *logView) backCmd(evt *tcell.EventKey) *tcell.EventKey { - return v.backFn(evt) -} - -func (v *logView) topCmd(evt *tcell.EventKey) *tcell.EventKey { - v.app.Flash().Info("Top of logs...") - v.logs.ScrollToBeginning() - return nil -} - -func (v *logView) bottomCmd(*tcell.EventKey) *tcell.EventKey { - v.app.Flash().Info("Bottom of logs...") - v.logs.ScrollToEnd() - return nil -} - -func (v *logView) pageUpCmd(*tcell.EventKey) *tcell.EventKey { - if v.logs.PageUp() { - v.app.Flash().Info("Reached Top ...") - } - return nil -} - -func (v *logView) pageDownCmd(*tcell.EventKey) *tcell.EventKey { - if v.logs.PageDown() { - v.app.Flash().Info("Reached Bottom ...") - } - return nil -} - -func (v *logView) clearCmd(*tcell.EventKey) *tcell.EventKey { - v.app.Flash().Info("Clearing logs...") - v.logs.Clear() - v.logs.ScrollTo(0, 0) - return nil -} diff --git a/internal/views/log_resource.go b/internal/views/log_resource.go deleted file mode 100644 index 825b03a6..00000000 --- a/internal/views/log_resource.go +++ /dev/null @@ -1,86 +0,0 @@ -package views - -import ( - "github.com/derailed/k9s/internal/resource" - "github.com/derailed/k9s/internal/ui" - "github.com/gdamore/tcell" -) - -type ( - containerFn func() string - - logResourceView struct { - *resourceView - - containerFn containerFn - } -) - -func newLogResourceView(title, gvr string, app *appView, list resource.List) *logResourceView { - v := logResourceView{ - resourceView: newResourceView(title, gvr, app, list).(*resourceView), - } - v.AddPage("logs", newLogsView(list.GetName(), app, &v), true, false) - - return &v -} - -func (v *logResourceView) extraActions(aa ui.KeyActions) { - aa[ui.KeyL] = ui.NewKeyAction("Logs", v.logsCmd, true) - aa[ui.KeyShiftL] = ui.NewKeyAction("Logs Previous", v.prevLogsCmd, true) -} - -func (v *logResourceView) sortColCmd(col int, asc bool) func(evt *tcell.EventKey) *tcell.EventKey { - return func(evt *tcell.EventKey) *tcell.EventKey { - t := v.masterPage() - t.SetSortCol(t.NameColIndex()+col, 0, asc) - t.Refresh() - - return nil - } -} - -// Protocol... - -func (v *logResourceView) getList() resource.List { - return v.list -} - -func (v *logResourceView) getSelection() string { - if v.path != nil { - return *v.path - } - return v.masterPage().GetSelectedItem() -} - -func (v *logResourceView) prevLogsCmd(evt *tcell.EventKey) *tcell.EventKey { - v.showLogs(true) - return nil -} - -func (v *logResourceView) logsCmd(evt *tcell.EventKey) *tcell.EventKey { - v.showLogs(false) - return nil -} - -func (v *logResourceView) showLogs(prev bool) { - if !v.masterPage().RowSelected() { - return - } - - l := v.GetPrimitive("logs").(*logsView) - co := "" - if v.containerFn != nil { - co = v.containerFn() - } - l.reload(co, v, prev) - v.switchPage("logs") -} - -func (v *logResourceView) backCmd(evt *tcell.EventKey) *tcell.EventKey { - // Reset namespace to what it was - v.app.Config.SetActiveNamespace(v.list.GetNamespace()) - v.app.inject(v) - - return nil -} diff --git a/internal/views/log_test.go b/internal/views/log_test.go deleted file mode 100644 index af975567..00000000 --- a/internal/views/log_test.go +++ /dev/null @@ -1,82 +0,0 @@ -package views - -import ( - "bytes" - "fmt" - "io/ioutil" - "path/filepath" - "testing" - - "github.com/derailed/k9s/internal/config" - "github.com/derailed/tview" - "github.com/stretchr/testify/assert" -) - -func TestAnsi(t *testing.T) { - buff := bytes.NewBufferString("") - w := tview.ANSIWriter(buff, "white", "black") - fmt.Fprintf(w, "[YELLOW] ok") - assert.Equal(t, "[YELLOW] ok", buff.String()) - - v := tview.NewTextView() - v.SetDynamicColors(true) - aw := tview.ANSIWriter(v, "white", "black") - s := "[2019-03-27T15:05:15,246][INFO ][o.e.c.r.a.AllocationService] [es-0] Cluster health status changed from [YELLOW] to [GREEN] (reason: [shards started [[.monitoring-es-6-2019.03.27][0]]" - fmt.Fprintf(aw, s) - assert.Equal(t, s+"\n", v.GetText(false)) -} - -func TestLogViewFlush(t *testing.T) { - v := newLogView("Logs", NewApp(config.NewConfig(ks{})), nil) - v.flush(2, []string{"blee", "bozo"}) - - v.toggleScrollCmd(nil) - assert.Equal(t, "blee\nbozo\n", v.logs.GetText(true)) - assert.Equal(t, " Autoscroll: Off ", v.status.GetText(true)) - v.toggleScrollCmd(nil) - assert.Equal(t, " Autoscroll: On ", v.status.GetText(true)) -} - -func TestLogViewSave(t *testing.T) { - v := newLogView("Logs", NewApp(config.NewConfig(ks{})), nil) - v.flush(2, []string{"blee", "bozo"}) - v.path = "k9s-test" - dir := filepath.Join(config.K9sDumpDir, v.app.Config.K9s.CurrentCluster) - c1, _ := ioutil.ReadDir(dir) - v.saveCmd(nil) - c2, _ := ioutil.ReadDir(dir) - assert.Equal(t, len(c2), len(c1)+1) -} - -func TestLogViewNav(t *testing.T) { - v := newLogView("Logs", NewApp(config.NewConfig(ks{})), nil) - var buff []string - v.autoScroll = 1 - for i := 0; i < 100; i++ { - buff = append(buff, fmt.Sprintf("line-%d\n", i)) - } - v.flush(100, buff) - - v.topCmd(nil) - r, _ := v.logs.GetScrollOffset() - assert.Equal(t, 0, r) - v.pageDownCmd(nil) - r, _ = v.logs.GetScrollOffset() - assert.Equal(t, 0, r) - v.pageUpCmd(nil) - r, _ = v.logs.GetScrollOffset() - assert.Equal(t, 0, r) - v.bottomCmd(nil) - r, _ = v.logs.GetScrollOffset() - assert.Equal(t, 0, r) -} - -func TestLogViewClear(t *testing.T) { - v := newLogView("Logs", NewApp(config.NewConfig(ks{})), nil) - v.flush(2, []string{"blee", "bozo"}) - - v.toggleScrollCmd(nil) - assert.Equal(t, "blee\nbozo\n", v.logs.GetText(true)) - v.clearCmd(nil) - assert.Equal(t, "", v.logs.GetText(true)) -} diff --git a/internal/views/logs_test.go b/internal/views/logs_test.go deleted file mode 100644 index 0a7397c5..00000000 --- a/internal/views/logs_test.go +++ /dev/null @@ -1,31 +0,0 @@ -package views - -import ( - "context" - "fmt" - "sync" - "testing" - - "github.com/derailed/k9s/internal/config" - "github.com/stretchr/testify/assert" -) - -func TestUpdateLogs(t *testing.T) { - v := newLogView("test", NewApp(config.NewConfig(ks{})), nil) - - var wg sync.WaitGroup - wg.Add(1) - c := make(chan string, 10) - go func() { - defer wg.Done() - updateLogs(context.Background(), c, v, 10) - }() - - for i := 0; i < 500; i++ { - c <- fmt.Sprintf("log %d", i) - } - close(c) - wg.Wait() - - assert.Equal(t, 500, v.logs.GetLineCount()) -} diff --git a/internal/views/master_detail.go b/internal/views/master_detail.go deleted file mode 100644 index bd521a03..00000000 --- a/internal/views/master_detail.go +++ /dev/null @@ -1,94 +0,0 @@ -package views - -import ( - "context" - - "github.com/derailed/k9s/internal/resource" - "github.com/derailed/k9s/internal/ui" - "github.com/derailed/tview" -) - -type ( - pageView struct { - *tview.Pages - - app *appView - } - - masterDetail struct { - *pageView - - currentNS string - title string - enterFn enterFn - extraActionsFn func(ui.KeyActions) - } -) - -func newPageView(app *appView) *pageView { - return &pageView{ - Pages: tview.NewPages(), - app: app, - } -} - -func newMasterDetail(title, ns string, app *appView, backCmd ui.ActionHandler) *masterDetail { - v := masterDetail{ - pageView: newPageView(app), - currentNS: ns, - title: title, - } - v.AddPage("master", newTableView(v.app, v.title), true, true) - v.AddPage("details", newDetailsView(v.app, backCmd), true, false) - - return &v -} - -func (v *masterDetail) init(ctx context.Context, ns string) { - if v.currentNS != resource.NotNamespaced { - v.currentNS = ns - } -} - -func (v *masterDetail) setExtraActionsFn(f ui.ActionsFunc) { - v.extraActionsFn = f -} - -// Protocol... - -// Hints fetch menu hints -func (v *masterDetail) hints() ui.Hints { - return v.CurrentPage().Item.(ui.Hinter).Hints() -} - -func (v *masterDetail) setEnterFn(f enterFn) { - v.enterFn = f -} - -func (v *masterDetail) showMaster() { - v.SwitchToPage("master") -} - -func (v *masterDetail) masterPage() *tableView { - return v.GetPrimitive("master").(*tableView) -} - -func (v *masterDetail) showDetails() { - v.SwitchToPage("details") -} - -func (v *masterDetail) detailsPage() *detailsView { - return v.GetPrimitive("details").(*detailsView) -} - -// ---------------------------------------------------------------------------- -// Actions... - -func (v *masterDetail) defaultActions(aa ui.KeyActions) { - aa[ui.KeyHelp] = ui.NewKeyAction("Help", noopCmd, false) - aa[ui.KeyP] = ui.NewKeyAction("Previous", v.app.prevCmd, false) - - if v.extraActionsFn != nil { - v.extraActionsFn(aa) - } -} diff --git a/internal/views/namespace_test.go b/internal/views/namespace_test.go deleted file mode 100644 index 1051bec4..00000000 --- a/internal/views/namespace_test.go +++ /dev/null @@ -1,27 +0,0 @@ -package views - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestNSCleanser(t *testing.T) { - var v namespaceView - - uu := []struct { - s, e string - }{ - {"fred", "fred"}, - {"fred+", "fred"}, - {"fred(*)", "fred"}, - {"fred+(*)", "fred"}, - {"fred-blee+(*)", "fred-blee"}, - {"fred1-blee2+(*)", "fred1-blee2"}, - {"fred(𝜟)", "fred"}, - } - - for _, u := range uu { - assert.Equal(t, u.e, v.cleanser(u.s)) - } -} diff --git a/internal/views/no.go b/internal/views/no.go deleted file mode 100644 index fda110ed..00000000 --- a/internal/views/no.go +++ /dev/null @@ -1,63 +0,0 @@ -package views - -import ( - "github.com/derailed/k9s/internal/resource" - "github.com/derailed/k9s/internal/ui" - "github.com/gdamore/tcell" -) - -type nodeView struct { - *resourceView -} - -func newNodeView(title, gvr string, app *appView, list resource.List) resourceViewer { - v := nodeView{newResourceView(title, gvr, app, list).(*resourceView)} - v.extraActionsFn = v.extraActions - v.enterFn = v.showPods - - return &v -} - -func (v *nodeView) extraActions(aa ui.KeyActions) { - aa[ui.KeyShiftC] = ui.NewKeyAction("Sort CPU", v.sortColCmd(7, false), false) - aa[ui.KeyShiftM] = ui.NewKeyAction("Sort MEM", v.sortColCmd(8, false), false) - aa[ui.KeyShiftX] = ui.NewKeyAction("Sort CPU%", v.sortColCmd(9, false), false) - aa[ui.KeyShiftZ] = ui.NewKeyAction("Sort MEM%", v.sortColCmd(10, false), false) -} - -func (v *nodeView) sortColCmd(col int, asc bool) func(evt *tcell.EventKey) *tcell.EventKey { - return func(evt *tcell.EventKey) *tcell.EventKey { - t := v.masterPage() - t.SetSortCol(t.NameColIndex()+col, 0, asc) - t.Refresh() - - return nil - } -} - -func (v *nodeView) showPods(app *appView, _, _, sel string) { - showPods(app, "", "", "spec.nodeName="+sel, v.backCmd) -} - -func (v *nodeView) backCmd(evt *tcell.EventKey) *tcell.EventKey { - v.app.inject(v) - - return nil -} - -func showPods(app *appView, ns, labelSel, fieldSel string, a ui.ActionHandler) { - app.switchNS(ns) - - list := resource.NewPodList(app.Conn(), ns) - list.SetLabelSelector(labelSel) - list.SetFieldSelector(fieldSel) - - pv := newPodView("Pod", "v1/pods", app, list) - pv.setColorerFn(podColorer) - pv.masterPage().SetActions(ui.KeyActions{ - tcell.KeyEsc: ui.NewKeyAction("Back", a, true), - }) - // Reset active namespace to ns. - app.Config.SetActiveNamespace(ns) - app.inject(pv) -} diff --git a/internal/views/ns.go b/internal/views/ns.go deleted file mode 100644 index 3335b9ba..00000000 --- a/internal/views/ns.go +++ /dev/null @@ -1,88 +0,0 @@ -package views - -import ( - "regexp" - - "github.com/derailed/k9s/internal/config" - "github.com/derailed/k9s/internal/resource" - "github.com/derailed/k9s/internal/ui" - "github.com/gdamore/tcell" -) - -const ( - favNSIndicator = "+" - defaultNSIndicator = "(*)" - deltaNSIndicator = "(𝜟)" -) - -var nsCleanser = regexp.MustCompile(`(\w+)[+|(*)|(𝜟)]*`) - -type namespaceView struct { - *resourceView -} - -func newNamespaceView(title, gvr string, app *appView, list resource.List) resourceViewer { - v := namespaceView{newResourceView(title, gvr, app, list).(*resourceView)} - v.extraActionsFn = v.extraActions - v.masterPage().SetSelectedFn(v.cleanser) - v.decorateFn = v.decorate - v.enterFn = v.switchNs - - return &v -} - -func (v *namespaceView) extraActions(aa ui.KeyActions) { - aa[ui.KeyU] = ui.NewKeyAction("Use", v.useNsCmd, true) -} - -func (v *namespaceView) switchNs(app *appView, _, res, sel string) { - v.useNamespace(sel) - app.gotoResource("po", true) -} - -func (v *namespaceView) useNsCmd(evt *tcell.EventKey) *tcell.EventKey { - if !v.masterPage().RowSelected() { - return evt - } - v.useNamespace(v.masterPage().GetSelectedItem()) - - return nil -} - -func (v *namespaceView) useNamespace(ns string) { - if err := v.app.Config.SetActiveNamespace(ns); err != nil { - v.app.Flash().Err(err) - } else { - v.app.Flash().Infof("Namespace %s is now active!", ns) - } - v.app.Config.Save() - v.app.startInformer(ns) -} - -func (*namespaceView) cleanser(s string) string { - return nsCleanser.ReplaceAllString(s, `$1`) -} - -func (v *namespaceView) decorate(data resource.TableData) resource.TableData { - if _, ok := data.Rows[resource.AllNamespaces]; !ok { - if err := v.app.Conn().CheckNSAccess(""); err == nil { - data.Rows[resource.AllNamespace] = &resource.RowEvent{ - Action: resource.Unchanged, - Fields: resource.Row{resource.AllNamespace, "Active", "0"}, - Deltas: resource.Row{"", "", ""}, - } - } - } - for k, r := range data.Rows { - if config.InList(v.app.Config.FavNamespaces(), k) { - r.Fields[0] += "+" - r.Action = resource.Unchanged - } - if v.app.Config.ActiveNamespace() == k { - r.Fields[0] += "(*)" - r.Action = resource.Unchanged - } - } - - return data -} diff --git a/internal/views/pod.go b/internal/views/pod.go deleted file mode 100644 index abe9455c..00000000 --- a/internal/views/pod.go +++ /dev/null @@ -1,235 +0,0 @@ -package views - -import ( - "context" - "fmt" - - "github.com/derailed/k9s/internal/resource" - "github.com/derailed/k9s/internal/ui" - "github.com/derailed/k9s/internal/watch" - "github.com/gdamore/tcell" - "github.com/rs/zerolog/log" - v1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -const ( - containerFmt = "[fg:bg:b]%s([hilite:bg:b]%s[fg:bg:-])" - shellCheck = "command -v bash >/dev/null && exec bash || exec sh" -) - -type podView struct { - *resourceView - - childCancelFn context.CancelFunc -} - -var _ updatable = &podView{} - -type loggable interface { - getSelection() string - getList() resource.List - switchPage(n string) -} - -func newPodView(title, gvr string, app *appView, list resource.List) resourceViewer { - v := podView{resourceView: newResourceView(title, gvr, app, list).(*resourceView)} - v.extraActionsFn = v.extraActions - v.enterFn = v.listContainers - - picker := newSelectList(&v) - { - picker.setActions(ui.KeyActions{ - tcell.KeyEscape: {Description: "Back", Action: v.backCmd, Visible: true}, - }) - } - v.AddPage("picker", picker, true, false) - v.AddPage("logs", newLogsView(list.GetName(), app, &v), true, false) - - return &v -} - -func (v *podView) extraActions(aa ui.KeyActions) { - aa[tcell.KeyCtrlK] = ui.NewKeyAction("Kill", v.killCmd, true) - aa[ui.KeyS] = ui.NewKeyAction("Shell", v.shellCmd, true) - - aa[ui.KeyL] = ui.NewKeyAction("Logs", v.logsCmd, true) - aa[ui.KeyShiftL] = ui.NewKeyAction("Logs Previous", v.prevLogsCmd, true) - - aa[ui.KeyShiftR] = ui.NewKeyAction("Sort Ready", v.sortColCmd(1, false), false) - aa[ui.KeyShiftS] = ui.NewKeyAction("Sort Status", v.sortColCmd(2, true), false) - aa[ui.KeyShiftT] = ui.NewKeyAction("Sort Restart", v.sortColCmd(3, false), false) - aa[ui.KeyShiftC] = ui.NewKeyAction("Sort CPU", v.sortColCmd(4, false), false) - aa[ui.KeyShiftM] = ui.NewKeyAction("Sort MEM", v.sortColCmd(5, false), false) - aa[ui.KeyShiftX] = ui.NewKeyAction("Sort CPU%", v.sortColCmd(6, false), false) - aa[ui.KeyShiftZ] = ui.NewKeyAction("Sort MEM%", v.sortColCmd(7, false), false) - aa[ui.KeyShiftD] = ui.NewKeyAction("Sort IP", v.sortColCmd(8, true), false) - aa[ui.KeyShiftO] = ui.NewKeyAction("Sort Node", v.sortColCmd(9, true), false) -} - -func (v *podView) listContainers(app *appView, _, res, sel string) { - po, err := v.app.informer.Get(watch.PodIndex, sel, metav1.GetOptions{}) - if err != nil { - app.Flash().Errf("Unable to retrieve pods %s", err) - return - } - - pod := po.(*v1.Pod) - list := resource.NewContainerList(app.Conn(), pod) - title := skinTitle(fmt.Sprintf(containerFmt, "Container", sel), app.Styles.Frame()) - - // Stop my updater - if v.cancelFn != nil { - v.cancelFn() - } - - // Span child view - cv := newContainerView(title, app, list, fqn(pod.Namespace, pod.Name), v.exitFn) - v.AddPage("containers", cv, true, true) - var ctx context.Context - ctx, v.childCancelFn = context.WithCancel(v.parentCtx) - cv.Init(ctx, pod.Namespace) -} - -func (v *podView) exitFn() { - if v.childCancelFn != nil { - v.childCancelFn() - } - v.RemovePage("containers") - v.switchPage("master") - v.restartUpdates() -} - -// Protocol... - -func (v *podView) getList() resource.List { - return v.list -} - -func (v *podView) getSelection() string { - return v.masterPage().GetSelectedItem() -} - -func (v *podView) killCmd(evt *tcell.EventKey) *tcell.EventKey { - if !v.masterPage().RowSelected() { - return evt - } - - sel := v.masterPage().GetSelectedItems() - v.masterPage().ShowDeleted() - for _, res := range sel { - v.app.Flash().Infof("Delete resource %s %s", v.list.GetName(), res) - if err := v.list.Resource().Delete(res, true, false); err != nil { - v.app.Flash().Errf("Delete failed with %s", err) - } else { - deletePortForward(v.app.forwarders, res) - } - } - v.refresh() - return nil -} - -func (v *podView) logsCmd(evt *tcell.EventKey) *tcell.EventKey { - if v.viewLogs(false) { - return nil - } - - return evt -} - -func (v *podView) prevLogsCmd(evt *tcell.EventKey) *tcell.EventKey { - if v.viewLogs(true) { - return nil - } - - return evt -} - -func (v *podView) viewLogs(prev bool) bool { - if !v.masterPage().RowSelected() { - return false - } - v.showLogs(v.masterPage().GetSelectedItem(), "", v, prev) - - return true -} - -func (v *podView) showLogs(path, co string, parent loggable, prev bool) { - l := v.GetPrimitive("logs").(*logsView) - l.reload(co, parent, prev) - v.switchPage("logs") -} - -func (v *podView) shellCmd(evt *tcell.EventKey) *tcell.EventKey { - if !v.masterPage().RowSelected() { - return evt - } - - sel := v.masterPage().GetSelectedItem() - cc, err := fetchContainers(v.list, sel, false) - if err != nil { - v.app.Flash().Errf("Unable to retrieve containers %s", err) - return evt - } - if len(cc) == 1 { - v.shellIn(sel, "") - return nil - } - p := v.GetPrimitive("picker").(*selectList) - p.populate(cc) - p.SetSelectedFunc(func(i int, t, d string, r rune) { - v.shellIn(sel, t) - }) - v.switchPage("picker") - - return evt -} - -func (v *podView) shellIn(path, co string) { - v.stopUpdates() - shellIn(v.app, path, co) - v.restartUpdates() -} - -func (v *podView) sortColCmd(col int, asc bool) func(evt *tcell.EventKey) *tcell.EventKey { - return func(evt *tcell.EventKey) *tcell.EventKey { - t := v.masterPage() - t.SetSortCol(t.NameColIndex()+col, 0, asc) - t.Refresh() - - return nil - } -} - -// ---------------------------------------------------------------------------- -// Helpers... - -func fetchContainers(l resource.List, po string, includeInit bool) ([]string, error) { - if len(po) == 0 { - return []string{}, nil - } - return l.Resource().(resource.Containers).Containers(po, includeInit) -} - -func shellIn(a *appView, path, co string) { - args := computeShellArgs(path, co, a.Config.K9s.CurrentContext, a.Conn().Config().Flags().KubeConfig) - log.Debug().Msgf("Shell args %v", args) - runK(true, a, args...) -} - -func computeShellArgs(path, co, context string, kcfg *string) []string { - args := make([]string, 0, 15) - args = append(args, "exec", "-it") - args = append(args, "--context", context) - ns, po := namespaced(path) - args = append(args, "-n", ns) - args = append(args, po) - if kcfg != nil && *kcfg != "" { - args = append(args, "--kubeconfig", *kcfg) - } - if co != "" { - args = append(args, "-c", co) - } - - return append(args, "--", "sh", "-c", shellCheck) -} diff --git a/internal/views/rbac_test.go b/internal/views/rbac_test.go deleted file mode 100644 index bc9728ed..00000000 --- a/internal/views/rbac_test.go +++ /dev/null @@ -1,115 +0,0 @@ -package views - -import ( - "testing" - - "github.com/derailed/k9s/internal/resource" - "github.com/stretchr/testify/assert" - rbacv1 "k8s.io/api/rbac/v1" -) - -func TestHasVerb(t *testing.T) { - uu := []struct { - vv []string - v string - e bool - }{ - {[]string{"*"}, "get", true}, - {[]string{"get", "list", "watch"}, "watch", true}, - {[]string{"get", "dope", "list"}, "watch", false}, - {[]string{"get"}, "get", true}, - {[]string{"post"}, "create", true}, - {[]string{"put"}, "update", true}, - {[]string{"list", "deletecollection"}, "deletecollection", true}, - } - - for _, u := range uu { - assert.Equal(t, u.e, hasVerb(u.vv, u.v)) - } -} - -func TestAsVerbs(t *testing.T) { - ok, nok := toVerbIcon(true), toVerbIcon(false) - - uu := []struct { - vv []string - e resource.Row - }{ - {[]string{"*"}, resource.Row{ok, ok, ok, ok, ok, ok, ok, ok, ""}}, - {[]string{"get", "list", "patch"}, resource.Row{ok, ok, nok, nok, nok, ok, nok, nok, ""}}, - {[]string{"get", "list", "deletecollection", "post"}, resource.Row{ok, ok, ok, nok, ok, nok, nok, nok, ""}}, - {[]string{"get", "list", "blee"}, resource.Row{ok, ok, nok, nok, nok, nok, nok, nok, "blee"}}, - } - - for _, u := range uu { - assert.Equal(t, u.e, asVerbs(u.vv...)) - } -} - -func TestParseRules(t *testing.T) { - ok, nok := toVerbIcon(true), toVerbIcon(false) - _ = nok - - uu := []struct { - pp []rbacv1.PolicyRule - e map[string]resource.Row - }{ - { - []rbacv1.PolicyRule{ - {APIGroups: []string{"*"}, Resources: []string{"*"}, Verbs: []string{"*"}}, - }, - map[string]resource.Row{ - "*.*": {"*.*", "*", ok, ok, ok, ok, ok, ok, ok, ok, ""}, - }, - }, - { - []rbacv1.PolicyRule{ - {APIGroups: []string{"*"}, Resources: []string{"*"}, Verbs: []string{"get"}}, - }, - map[string]resource.Row{ - "*.*": {"*.*", "*", ok, nok, nok, nok, nok, nok, nok, nok, ""}, - }, - }, - { - []rbacv1.PolicyRule{ - {APIGroups: []string{""}, Resources: []string{"*"}, Verbs: []string{"list"}}, - }, - map[string]resource.Row{ - "*": {"*", "v1", nok, ok, nok, nok, nok, nok, nok, nok, ""}, - }, - }, - { - []rbacv1.PolicyRule{ - {APIGroups: []string{""}, Resources: []string{"pods"}, Verbs: []string{"list"}, ResourceNames: []string{"fred"}}, - }, - map[string]resource.Row{ - "pods": {"pods", "v1", nok, ok, nok, nok, nok, nok, nok, nok, ""}, - "pods/fred": {"pods/fred", "v1", nok, ok, nok, nok, nok, nok, nok, nok, ""}, - }, - }, - { - []rbacv1.PolicyRule{ - {APIGroups: []string{}, Resources: []string{}, Verbs: []string{"get"}, NonResourceURLs: []string{"/fred"}}, - }, - map[string]resource.Row{ - "/fred": {"/fred", resource.NAValue, ok, nok, nok, nok, nok, nok, nok, nok, ""}, - }, - }, - { - []rbacv1.PolicyRule{ - {APIGroups: []string{}, Resources: []string{}, Verbs: []string{"get"}, NonResourceURLs: []string{"fred"}}, - }, - map[string]resource.Row{ - "/fred": {"/fred", resource.NAValue, ok, nok, nok, nok, nok, nok, nok, nok, ""}, - }, - }, - } - - var v rbacView - for _, u := range uu { - evts := v.parseRules(u.pp) - for k, v := range u.e { - assert.Equal(t, v, evts[k].Fields) - } - } -} diff --git a/internal/views/resource.go b/internal/views/resource.go deleted file mode 100644 index d8d33cc5..00000000 --- a/internal/views/resource.go +++ /dev/null @@ -1,519 +0,0 @@ -package views - -import ( - "context" - "fmt" - "strconv" - "strings" - "time" - - "github.com/atotto/clipboard" - "github.com/derailed/k9s/internal/config" - "github.com/derailed/k9s/internal/resource" - "github.com/derailed/k9s/internal/ui" - "github.com/derailed/k9s/internal/ui/dialog" - "github.com/gdamore/tcell" - "github.com/rs/zerolog/log" -) - -// EnvFn represent the current view exposed environment. -type envFn func() K9sEnv - -type ( - updatable interface { - restartUpdates() - stopUpdates() - update(context.Context) - } - - resourceView struct { - *masterDetail - - namespaces map[int]string - list resource.List - cancelFn context.CancelFunc - parentCtx context.Context - path *string - colorerFn ui.ColorerFunc - decorateFn decorateFn - envFn envFn - gvr string - } -) - -func newResourceView(title, gvr string, app *appView, list resource.List) resourceViewer { - v := resourceView{ - list: list, - gvr: gvr, - } - v.masterDetail = newMasterDetail(title, list.GetNamespace(), app, v.backCmd) - v.envFn = v.defaultK9sEnv - - return &v -} - -// Init watches all running pods in given namespace -func (v *resourceView) Init(ctx context.Context, ns string) { - v.masterDetail.init(ctx, ns) - v.masterPage().setFilterFn(v.filterResource) - if v.colorerFn != nil { - v.masterPage().SetColorerFn(v.colorerFn) - } - - v.parentCtx = ctx - var vctx context.Context - vctx, v.cancelFn = context.WithCancel(ctx) - - colorer := ui.DefaultColorer - if v.colorerFn != nil { - colorer = v.colorerFn - } - v.masterPage().SetColorerFn(colorer) - - v.update(vctx) - v.app.clusterInfo().refresh() - v.refresh() - - tv := v.masterPage() - r, _ := tv.GetSelection() - if r == 0 && tv.GetRowCount() > 0 { - tv.Select(1, 0) - } -} - -func (v *resourceView) setColorerFn(f ui.ColorerFunc) { - v.colorerFn = f -} - -func (v *resourceView) setDecorateFn(f decorateFn) { - v.decorateFn = f -} - -func (v *resourceView) filterResource(sel string) { - v.list.SetLabelSelector(sel) - v.refresh() -} - -func (v *resourceView) stopUpdates() { - if v.cancelFn != nil { - v.cancelFn() - } -} - -func (v *resourceView) restartUpdates() { - if v.cancelFn != nil { - v.cancelFn() - } - - var vctx context.Context - vctx, v.cancelFn = context.WithCancel(v.parentCtx) - v.update(vctx) -} - -func (v *resourceView) update(ctx context.Context) { - go func(ctx context.Context) { - for { - select { - case <-ctx.Done(): - log.Debug().Msgf("%s updater canceled!", v.list.GetName()) - return - case <-time.After(time.Duration(v.app.Config.K9s.GetRefreshRate()) * time.Second): - v.app.QueueUpdateDraw(func() { - v.refresh() - }) - } - } - }(ctx) -} - -func (v *resourceView) backCmd(*tcell.EventKey) *tcell.EventKey { - v.switchPage("master") - return nil -} - -func (v *resourceView) switchPage(p string) { - log.Debug().Msgf("Switching page to %s", p) - if _, ok := v.CurrentPage().Item.(*tableView); ok { - v.stopUpdates() - } - - v.SwitchToPage(p) - v.currentNS = v.list.GetNamespace() - if vu, ok := v.GetPrimitive(p).(ui.Hinter); ok { - v.app.SetHints(vu.Hints()) - } - - if _, ok := v.CurrentPage().Item.(*tableView); ok { - v.restartUpdates() - } -} - -// ---------------------------------------------------------------------------- -// Actions... - -func (v *resourceView) cpCmd(evt *tcell.EventKey) *tcell.EventKey { - if !v.masterPage().RowSelected() { - return evt - } - - _, n := namespaced(v.masterPage().GetSelectedItem()) - log.Debug().Msgf("Copied selection to clipboard %q", n) - v.app.Flash().Info("Current selection copied to clipboard...") - if err := clipboard.WriteAll(n); err != nil { - v.app.Flash().Err(err) - } - - return nil -} - -func (v *resourceView) enterCmd(evt *tcell.EventKey) *tcell.EventKey { - // If in command mode run filter otherwise enter function. - if v.masterPage().filterCmd(evt) == nil || !v.masterPage().RowSelected() { - return nil - } - - f := v.defaultEnter - if v.enterFn != nil { - f = v.enterFn - } - f(v.app, v.list.GetNamespace(), v.list.GetName(), v.masterPage().GetSelectedItem()) - - return nil -} - -func (v *resourceView) refreshCmd(*tcell.EventKey) *tcell.EventKey { - v.app.Flash().Info("Refreshing...") - v.refresh() - return nil -} - -func (v *resourceView) deleteCmd(evt *tcell.EventKey) *tcell.EventKey { - if !v.masterPage().RowSelected() { - return evt - } - - sel := v.masterPage().GetSelectedItems() - var msg string - if len(sel) > 1 { - msg = fmt.Sprintf("Delete %d selected %s?", len(sel), v.list.GetName()) - } else { - msg = fmt.Sprintf("Delete %s %s?", v.list.GetName(), sel[0]) - } - dialog.ShowDelete(v.Pages, msg, func(cascade, force bool) { - v.masterPage().ShowDeleted() - if len(sel) > 1 { - v.app.Flash().Infof("Delete %d selected %s", len(sel), v.list.GetName()) - } else { - v.app.Flash().Infof("Delete resource %s %s", v.list.GetName(), sel[0]) - } - for _, res := range sel { - if err := v.list.Resource().Delete(res, cascade, force); err != nil { - v.app.Flash().Errf("Delete failed with %s", err) - } else { - deletePortForward(v.app.forwarders, res) - } - } - v.refresh() - }, func() { - v.switchPage("master") - }) - return nil -} - -func (v *resourceView) markCmd(evt *tcell.EventKey) *tcell.EventKey { - if !v.masterPage().RowSelected() { - return evt - } - - v.masterPage().ToggleMark() - v.refresh() - v.app.Draw() - return nil -} - -func deletePortForward(ff map[string]forwarder, sel string) { - for k, v := range ff { - tokens := strings.Split(k, ":") - if tokens[0] == sel { - log.Debug().Msgf("Deleting associated portForward %s", k) - v.Stop() - } - } -} - -func (v *resourceView) defaultEnter(app *appView, ns, _, selection string) { - if !v.list.Access(resource.DescribeAccess) { - return - } - - log.Debug().Msgf("!!!!!! NAME %s", v.list.GetName()) - yaml, err := v.list.Resource().Describe(v.gvr, selection) - if err != nil { - v.app.Flash().Errf("Describe command failed: %s", err) - return - } - - details := v.detailsPage() - details.setCategory("Describe") - details.setTitle(selection) - details.SetTextColor(v.app.Styles.FgColor()) - details.SetText(colorizeYAML(v.app.Styles.Views().Yaml, yaml)) - details.ScrollToBeginning() - v.app.SetHints(details.hints()) - - v.switchPage("details") -} - -func (v *resourceView) describeCmd(evt *tcell.EventKey) *tcell.EventKey { - if !v.masterPage().RowSelected() { - return evt - } - v.defaultEnter(v.app, v.list.GetNamespace(), v.list.GetName(), v.masterPage().GetSelectedItem()) - - return nil -} - -func (v *resourceView) viewCmd(evt *tcell.EventKey) *tcell.EventKey { - if !v.masterPage().RowSelected() { - return evt - } - - sel := v.masterPage().GetSelectedItem() - raw, err := v.list.Resource().Marshal(sel) - if err != nil { - v.app.Flash().Errf("Unable to marshal resource %s", err) - return evt - } - details := v.detailsPage() - details.setCategory("YAML") - details.setTitle(sel) - details.SetTextColor(v.app.Styles.FgColor()) - details.SetText(colorizeYAML(v.app.Styles.Views().Yaml, raw)) - details.ScrollToBeginning() - v.app.SetHints(details.hints()) - - v.switchPage("details") - - return nil -} - -func (v *resourceView) editCmd(evt *tcell.EventKey) *tcell.EventKey { - if !v.masterPage().RowSelected() { - return evt - } - - v.stopUpdates() - { - ns, po := namespaced(v.masterPage().GetSelectedItem()) - args := make([]string, 0, 10) - args = append(args, "edit") - args = append(args, v.list.GetName()) - args = append(args, "-n", ns) - args = append(args, "--context", v.app.Config.K9s.CurrentContext) - if cfg := v.app.Conn().Config().Flags().KubeConfig; cfg != nil && *cfg != "" { - args = append(args, "--kubeconfig", *cfg) - } - runK(true, v.app, append(args, po)...) - } - v.restartUpdates() - - return evt -} - -func (v *resourceView) setNamespace(ns string) { - if v.list.Namespaced() { - v.currentNS = ns - v.list.SetNamespace(ns) - } -} - -func (v *resourceView) switchNamespaceCmd(evt *tcell.EventKey) *tcell.EventKey { - i, _ := strconv.Atoi(string(evt.Rune())) - ns := v.namespaces[i] - if ns == "" { - ns = resource.AllNamespace - } - if v.currentNS == ns { - return nil - } - - v.app.switchNS(ns) - v.setNamespace(ns) - v.app.Flash().Infof("Viewing namespace `%s`...", ns) - v.refresh() - v.masterPage().UpdateTitle() - v.masterPage().SelectRow(1, true) - v.app.CmdBuff().Reset() - v.app.Config.SetActiveNamespace(v.currentNS) - v.app.Config.Save() - - return nil -} - -func (v *resourceView) refresh() { - if _, ok := v.CurrentPage().Item.(*tableView); !ok { - return - } - - v.refreshActions() - if v.list.Namespaced() { - v.list.SetNamespace(v.currentNS) - } - if err := v.list.Reconcile(v.app.informer, v.path); err != nil { - v.app.Flash().Err(err) - } - data := v.list.Data() - if v.decorateFn != nil { - data = v.decorateFn(data) - } - v.masterPage().Update(data) -} - -func (v *resourceView) namespaceActions(aa ui.KeyActions) { - if !v.list.Access(resource.NamespaceAccess) { - return - } - v.namespaces = make(map[int]string, config.MaxFavoritesNS) - // User can't list namespace. Don't offer a choice. - if v.app.Conn().CheckListNSAccess() != nil { - return - } - aa[tcell.Key(ui.NumKeys[0])] = ui.NewKeyAction(resource.AllNamespace, v.switchNamespaceCmd, true) - v.namespaces[0] = resource.AllNamespace - index := 1 - for _, n := range v.app.Config.FavNamespaces() { - if n == resource.AllNamespace { - continue - } - aa[tcell.Key(ui.NumKeys[index])] = ui.NewKeyAction(n, v.switchNamespaceCmd, true) - v.namespaces[index] = n - index++ - } -} - -func (v *resourceView) refreshActions() { - aa := ui.KeyActions{ - ui.KeyC: ui.NewKeyAction("Copy", v.cpCmd, false), - tcell.KeyEnter: ui.NewKeyAction("Enter", v.enterCmd, false), - tcell.KeyCtrlR: ui.NewKeyAction("Refresh", v.refreshCmd, false), - } - aa[ui.KeySpace] = ui.NewKeyAction("Mark", v.markCmd, true) - v.namespaceActions(aa) - v.defaultActions(aa) - - if v.list.Access(resource.EditAccess) { - aa[ui.KeyE] = ui.NewKeyAction("Edit", v.editCmd, true) - } - if v.list.Access(resource.DeleteAccess) { - aa[tcell.KeyCtrlD] = ui.NewKeyAction("Delete", v.deleteCmd, true) - } - if v.list.Access(resource.ViewAccess) { - aa[ui.KeyY] = ui.NewKeyAction("YAML", v.viewCmd, true) - } - if v.list.Access(resource.DescribeAccess) { - aa[ui.KeyD] = ui.NewKeyAction("Describe", v.describeCmd, true) - } - v.customActions(aa) - - t := v.masterPage() - t.SetActions(aa) - v.app.SetHints(t.Hints()) -} - -func (v *resourceView) customActions(aa ui.KeyActions) { - pp := config.NewPlugins() - if err := pp.Load(); err != nil { - log.Warn().Msgf("No plugin configuration found") - return - } - - for k, plugin := range pp.Plugin { - if !in(plugin.Scopes, v.list.GetName()) { - continue - } - key, err := asKey(plugin.ShortCut) - if err != nil { - log.Error().Err(err).Msg("Unable to map shortcut to a key") - continue - } - _, ok := aa[key] - if ok { - log.Error().Err(fmt.Errorf("Doh! you are trying to overide an existing command `%s", k)).Msg("Invalid shortcut") - continue - } - aa[key] = ui.NewKeyAction( - plugin.Description, - v.execCmd(plugin.Command, plugin.Background, plugin.Args...), - true) - } -} - -func (v *resourceView) execCmd(bin string, bg bool, args ...string) ui.ActionHandler { - return func(evt *tcell.EventKey) *tcell.EventKey { - if !v.masterPage().RowSelected() { - return evt - } - - var ( - env = v.envFn() - aa = make([]string, len(args)) - err error - ) - for i, a := range args { - aa[i], err = env.envFor(a) - if err != nil { - log.Error().Err(err).Msg("Args match failed") - return nil - } - } - - if run(true, v.app, bin, bg, aa...) { - v.app.Flash().Info("Custom CMD launched!") - } else { - v.app.Flash().Info("Custom CMD failed!") - } - return nil - } -} - -func (v *resourceView) defaultK9sEnv() K9sEnv { - ns, n := namespaced(v.masterPage().GetSelectedItem()) - ctx, err := v.app.Conn().Config().CurrentContextName() - if err != nil { - ctx = "n/a" - } - cluster, err := v.app.Conn().Config().CurrentClusterName() - if err != nil { - cluster = "n/a" - } - user, err := v.app.Conn().Config().CurrentUserName() - if err != nil { - user = "n/a" - } - groups, err := v.app.Conn().Config().CurrentGroupNames() - if err != nil { - groups = []string{"n/a"} - } - var cfg string - kcfg := v.app.Conn().Config().Flags().KubeConfig - if kcfg != nil && *kcfg != "" { - cfg = *kcfg - } - - env := K9sEnv{ - "NAMESPACE": ns, - "NAME": n, - "CONTEXT": ctx, - "CLUSTER": cluster, - "USER": user, - "GROUPS": strings.Join(groups, ","), - "KUBECONFIG": cfg, - } - - row := v.masterPage().GetRow() - for i, r := range row { - env["COL"+strconv.Itoa(i)] = r - } - - return env -} diff --git a/internal/views/restartable_resource.go b/internal/views/restartable_resource.go deleted file mode 100644 index 10b7ebb2..00000000 --- a/internal/views/restartable_resource.go +++ /dev/null @@ -1,60 +0,0 @@ -package views - -import ( - "errors" - - "github.com/derailed/k9s/internal/resource" - "github.com/derailed/k9s/internal/ui" - "github.com/derailed/k9s/internal/ui/dialog" - "github.com/gdamore/tcell" -) - -type ( - restartableResourceView struct { - *resourceView - } -) - -func newRestartableResourceViewForParent(parent *resourceView) *restartableResourceView { - v := restartableResourceView{ - parent, - } - parent.extraActionsFn = v.extraActions - return &v -} - -func (v *restartableResourceView) extraActions(aa ui.KeyActions) { - aa[tcell.KeyCtrlT] = ui.NewKeyAction("Restart Rollout", v.restartCmd, true) -} - -func (v *restartableResourceView) restartCmd(evt *tcell.EventKey) *tcell.EventKey { - if !v.masterPage().RowSelected() { - return evt - } - - sel := v.masterPage().GetSelectedItem() - v.stopUpdates() - defer v.restartUpdates() - msg := "Please confirm rollout restart for " + sel - dialog.ShowConfirm(v.Pages, "", msg, func() { - if err := v.restartRollout(sel); err != nil { - v.app.Flash().Err(err) - } else { - v.app.Flash().Infof("Rollout restart in progress for `%s...", sel) - } - }, func() { - v.showMaster() - }) - - return nil -} - -func (v *restartableResourceView) restartRollout(selection string) error { - r, ok := v.list.Resource().(resource.Restartable) - if !ok { - return errors.New("resource is not of type resource.Restartable") - } - ns, n := namespaced(selection) - - return r.Restart(ns, n) -} diff --git a/internal/views/scalable_resource.go b/internal/views/scalable_resource.go deleted file mode 100644 index be1f6325..00000000 --- a/internal/views/scalable_resource.go +++ /dev/null @@ -1,114 +0,0 @@ -package views - -import ( - "fmt" - "strconv" - "strings" - - "github.com/derailed/k9s/internal/resource" - "github.com/derailed/k9s/internal/ui" - "github.com/derailed/tview" - "github.com/gdamore/tcell" -) - -type ( - scalableResourceView struct { - *resourceView - } -) - -func newScalableResourceView(title, gvr string, app *appView, list resource.List) resourceViewer { - return *newScalableResourceViewForParent(newResourceView(title, gvr, app, list).(*resourceView)) -} - -func newScalableResourceViewForParent(parent *resourceView) *scalableResourceView { - v := scalableResourceView{ - parent, - } - parent.extraActionsFn = v.extraActions - return &v -} - -func (v *scalableResourceView) extraActions(aa ui.KeyActions) { - aa[ui.KeyS] = ui.NewKeyAction("Scale", v.scaleCmd, true) -} - -func (v *scalableResourceView) scaleCmd(evt *tcell.EventKey) *tcell.EventKey { - if !v.masterPage().RowSelected() { - return evt - } - - v.showScaleDialog(v.list.GetName(), v.masterPage().GetSelectedItem()) - return nil -} - -func (v *scalableResourceView) scale(selection string, replicas int) { - ns, n := namespaced(selection) - - r := v.list.Resource().(resource.Scalable) - - err := r.Scale(ns, n, int32(replicas)) - if err != nil { - v.app.Flash().Err(err) - } -} - -func (v *scalableResourceView) showScaleDialog(resourceType string, resourceName string) { - f := v.createScaleForm() - - confirm := tview.NewModalForm("", f) - confirm.SetText(fmt.Sprintf("Scale %s %s", resourceType, resourceName)) - confirm.SetDoneFunc(func(int, string) { - v.dismissScaleDialog() - }) - v.AddPage(scaleDialogKey, confirm, false, false) - v.ShowPage(scaleDialogKey) -} - -func (v *scalableResourceView) createScaleForm() *tview.Form { - f := v.createStyledForm() - - tv := v.masterPage() - replicas := strings.TrimSpace(tv.GetCell(tv.GetSelectedRow(), tv.NameColIndex()+1).Text) - f.AddInputField("Replicas:", replicas, 4, func(textToCheck string, lastChar rune) bool { - _, err := strconv.Atoi(textToCheck) - return err == nil - }, func(changed string) { - replicas = changed - }) - - f.AddButton("OK", func() { - v.okSelected(replicas) - }) - - f.AddButton("Cancel", func() { - v.dismissScaleDialog() - }) - - return f -} - -func (v *scalableResourceView) createStyledForm() *tview.Form { - f := tview.NewForm() - f.SetItemPadding(0) - f.SetButtonsAlign(tview.AlignCenter). - SetButtonBackgroundColor(tview.Styles.PrimitiveBackgroundColor). - SetButtonTextColor(tview.Styles.PrimaryTextColor). - SetLabelColor(tcell.ColorAqua). - SetFieldTextColor(tcell.ColorOrange) - return f -} - -func (v *scalableResourceView) okSelected(replicas string) { - if val, err := strconv.Atoi(replicas); err == nil { - v.scale(v.masterPage().GetSelectedItem(), val) - } else { - v.app.Flash().Err(err) - } - - v.dismissScaleDialog() -} - -func (v *scalableResourceView) dismissScaleDialog() { - v.Pages.RemovePage(scaleDialogKey) -} diff --git a/internal/views/secret.go b/internal/views/secret.go deleted file mode 100644 index 617537b9..00000000 --- a/internal/views/secret.go +++ /dev/null @@ -1,59 +0,0 @@ -package views - -import ( - "sigs.k8s.io/yaml" - - "github.com/derailed/k9s/internal/resource" - "github.com/derailed/k9s/internal/ui" - "github.com/gdamore/tcell" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -type secretView struct { - *resourceView -} - -func newSecretView(title, gvr string, app *appView, list resource.List) resourceViewer { - v := secretView{newResourceView(title, gvr, app, list).(*resourceView)} - v.extraActionsFn = v.extraActions - - return &v -} - -func (v *secretView) extraActions(aa ui.KeyActions) { - aa[tcell.KeyCtrlX] = ui.NewKeyAction("Decode", v.decodeCmd, true) -} - -func (v *secretView) decodeCmd(evt *tcell.EventKey) *tcell.EventKey { - if !v.masterPage().RowSelected() { - return evt - } - - sel := v.masterPage().GetSelectedItem() - ns, n := namespaced(sel) - sec, err := v.app.Conn().DialOrDie().CoreV1().Secrets(ns).Get(n, metav1.GetOptions{}) - if err != nil { - v.app.Flash().Errf("Unable to retrieve secret %s", err) - return evt - } - - d := make(map[string]string, len(sec.Data)) - for k, val := range sec.Data { - d[k] = string(val) - } - raw, err := yaml.Marshal(d) - if err != nil { - v.app.Flash().Errf("Error decoding secret %s", err) - return nil - } - - details := v.detailsPage() - details.setCategory("Decoder") - details.setTitle(sel) - details.SetTextColor(v.app.Styles.FgColor()) - details.SetText(colorizeYAML(v.app.Styles.Views().Yaml, string(raw))) - details.ScrollToBeginning() - v.switchPage("details") - - return nil -} diff --git a/internal/views/sts.go b/internal/views/sts.go deleted file mode 100644 index 42ac4952..00000000 --- a/internal/views/sts.go +++ /dev/null @@ -1,58 +0,0 @@ -package views - -import ( - "github.com/derailed/k9s/internal/k8s" - "github.com/derailed/k9s/internal/resource" - "github.com/derailed/k9s/internal/ui" - "github.com/rs/zerolog/log" - v1 "k8s.io/api/apps/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -type statefulSetView struct { - *logResourceView - scalableResourceView *scalableResourceView - restartableResourceView *restartableResourceView -} - -func newStatefulSetView(title, gvr string, app *appView, list resource.List) resourceViewer { - logResourceView := newLogResourceView(title, gvr, app, list) - v := statefulSetView{ - logResourceView: logResourceView, - scalableResourceView: newScalableResourceViewForParent(logResourceView.resourceView), - restartableResourceView: newRestartableResourceViewForParent(logResourceView.resourceView), - } - v.extraActionsFn = v.extraActions - v.enterFn = v.showPods - - return &v -} - -func (v *statefulSetView) extraActions(aa ui.KeyActions) { - v.logResourceView.extraActions(aa) - v.scalableResourceView.extraActions(aa) - v.restartableResourceView.extraActions(aa) - aa[ui.KeyShiftD] = ui.NewKeyAction("Sort Desired", v.sortColCmd(1, false), false) - aa[ui.KeyShiftC] = ui.NewKeyAction("Sort Current", v.sortColCmd(2, false), false) -} - -func (v *statefulSetView) showPods(app *appView, ns, res, sel string) { - ns, n := namespaced(sel) - s := k8s.NewStatefulSet(app.Conn()) - st, err := s.Get(ns, n) - if err != nil { - log.Error().Err(err).Msgf("Fetching StatefulSet %s", sel) - app.Flash().Errf("Unable to fetch statefulset %s", err) - return - } - - sts := st.(*v1.StatefulSet) - l, err := metav1.LabelSelectorAsSelector(sts.Spec.Selector) - if err != nil { - log.Error().Err(err).Msgf("Converting selector for StatefulSet %s", sel) - app.Flash().Errf("Selector failed %s", err) - return - } - - showPods(app, ns, l.String(), "", v.backCmd) -} diff --git a/internal/views/subject.go b/internal/views/subject.go deleted file mode 100644 index d5f32ec2..00000000 --- a/internal/views/subject.go +++ /dev/null @@ -1,309 +0,0 @@ -package views - -import ( - "context" - "fmt" - "reflect" - "time" - - "github.com/derailed/k9s/internal/resource" - "github.com/derailed/k9s/internal/ui" - "github.com/gdamore/tcell" - "github.com/rs/zerolog/log" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/watch" -) - -var subjectHeader = resource.Row{"NAME", "KIND", "FIRST LOCATION"} - -type ( - cachedEventer interface { - header() resource.Row - getCache() resource.RowEvents - setCache(resource.RowEvents) - } - - subjectView struct { - *tableView - - current ui.Igniter - cancel context.CancelFunc - subjectKind string - cache resource.RowEvents - } -) - -func newSubjectView(title, gvr string, app *appView, list resource.List) resourceViewer { - v := subjectView{} - v.tableView = newTableView(app, "Subject") - v.SetActiveNS("*") - v.SetColorerFn(rbacColorer) - v.bindKeys() - - if current, ok := app.Frame().GetPrimitive("main").(ui.Igniter); ok { - v.current = current - } else { - v.current = &v - } - - return &v -} - -// Init the view. -func (v *subjectView) Init(c context.Context, _ string) { - if v.cancel != nil { - v.cancel() - } - - v.SetSortCol(1, len(rbacHeader), true) - v.subjectKind = mapCmdSubject(v.app.Config.K9s.ActiveCluster().View.Active) - v.SetBaseTitle(v.subjectKind) - - ctx, cancel := context.WithCancel(c) - v.cancel = cancel - go func(ctx context.Context) { - for { - select { - case <-ctx.Done(): - log.Debug().Msgf("Subject:%s Watch bailing out!", v.subjectKind) - return - case <-time.After(time.Duration(v.app.Config.K9s.GetRefreshRate()) * time.Second): - v.refresh() - v.app.Draw() - } - } - }(ctx) - - v.refresh() - v.SelectRow(1, true) - v.app.SetFocus(v) - v.app.SetHints(v.Hints()) - -} - -func (v *subjectView) masterPage() *tableView { - return v.tableView -} - -func (v *subjectView) bindKeys() { - // No time data or ns - v.RmAction(ui.KeyShiftA) - v.RmAction(ui.KeyShiftP) - - v.SetActions(ui.KeyActions{ - tcell.KeyEnter: ui.NewKeyAction("Policies", v.policyCmd, true), - tcell.KeyEscape: ui.NewKeyAction("Reset", v.resetCmd, false), - ui.KeySlash: ui.NewKeyAction("Filter", v.activateCmd, false), - ui.KeyP: ui.NewKeyAction("Previous", v.app.prevCmd, false), - ui.KeyShiftK: ui.NewKeyAction("Sort Kind", v.SortColCmd(1), false), - }) -} - -func (v *subjectView) setExtraActionsFn(f ui.ActionsFunc) {} -func (v *subjectView) setColorerFn(f ui.ColorerFunc) {} -func (v *subjectView) setEnterFn(f enterFn) {} -func (v *subjectView) setDecorateFn(f decorateFn) {} - -func (v *subjectView) getTitle() string { - return fmt.Sprintf(rbacTitleFmt, "Subject", v.subjectKind) -} - -func (v *subjectView) SetSubject(s string) { - v.subjectKind = mapSubject(s) -} - -func (v *subjectView) refresh() { - data, err := v.reconcile() - if err != nil { - log.Error().Err(err).Msgf("Refresh for %s", v.subjectKind) - v.app.Flash().Err(err) - } - v.Update(data) -} - -func (v *subjectView) policyCmd(evt *tcell.EventKey) *tcell.EventKey { - if !v.RowSelected() { - return evt - } - - if v.cancel != nil { - v.cancel() - } - - _, n := namespaced(v.GetSelectedItem()) - v.app.inject(newPolicyView(v.app, mapFuSubject(v.subjectKind), n)) - - return nil -} - -func (v *subjectView) resetCmd(evt *tcell.EventKey) *tcell.EventKey { - if !v.SearchBuff().Empty() { - v.SearchBuff().Reset() - return nil - } - - return v.backCmd(evt) -} - -func (v *subjectView) backCmd(evt *tcell.EventKey) *tcell.EventKey { - if v.cancel != nil { - v.cancel() - } - - if v.SearchBuff().IsActive() { - v.SearchBuff().Reset() - return nil - } - - v.app.inject(v.current) - - return nil -} - -func (v *subjectView) reconcile() (resource.TableData, error) { - var table resource.TableData - - evts, err := v.clusterSubjects() - if err != nil { - return table, err - } - - nevts, err := v.namespacedSubjects() - if err != nil { - return table, err - } - for k, v := range nevts { - evts[k] = v - } - - return buildTable(v, evts), nil -} - -func (v *subjectView) header() resource.Row { - return subjectHeader -} - -func (v *subjectView) getCache() resource.RowEvents { - return v.cache -} - -func (v *subjectView) setCache(evts resource.RowEvents) { - v.cache = evts -} - -func buildTable(c cachedEventer, evts resource.RowEvents) resource.TableData { - table := resource.TableData{ - Header: c.header(), - Rows: make(resource.RowEvents, len(evts)), - Namespace: "*", - } - - noDeltas := make(resource.Row, len(c.header())) - cache := c.getCache() - if len(cache) == 0 { - for k, ev := range evts { - ev.Action = resource.New - ev.Deltas = noDeltas - table.Rows[k] = ev - } - c.setCache(evts) - return table - } - - for k, ev := range evts { - table.Rows[k] = ev - - newr := ev.Fields - if _, ok := cache[k]; !ok { - ev.Action, ev.Deltas = watch.Added, noDeltas - continue - } - oldr := cache[k].Fields - deltas := make(resource.Row, len(newr)) - if !reflect.DeepEqual(oldr, newr) { - ev.Action = watch.Modified - for i, field := range oldr { - if field != newr[i] { - deltas[i] = field - } - } - ev.Deltas = deltas - } else { - ev.Action = resource.Unchanged - ev.Deltas = noDeltas - } - } - - for k := range evts { - if _, ok := table.Rows[k]; !ok { - delete(evts, k) - } - } - c.setCache(evts) - - return table -} - -func (v *subjectView) clusterSubjects() (resource.RowEvents, error) { - crbs, err := v.app.Conn().DialOrDie().RbacV1().ClusterRoleBindings().List(metav1.ListOptions{}) - if err != nil { - return nil, err - } - - evts := make(resource.RowEvents, len(crbs.Items)) - for _, crb := range crbs.Items { - for _, s := range crb.Subjects { - if s.Kind != v.subjectKind { - continue - } - evts[s.Name] = &resource.RowEvent{ - Fields: resource.Row{s.Name, "ClusterRoleBinding", crb.Name}, - } - } - } - - return evts, nil -} - -func (v *subjectView) namespacedSubjects() (resource.RowEvents, error) { - rbs, err := v.app.Conn().DialOrDie().RbacV1().RoleBindings("").List(metav1.ListOptions{}) - if err != nil { - return nil, err - } - - evts := make(resource.RowEvents, len(rbs.Items)) - for _, rb := range rbs.Items { - for _, s := range rb.Subjects { - if s.Kind == v.subjectKind { - evts[s.Name] = &resource.RowEvent{ - Fields: resource.Row{s.Name, "RoleBinding", rb.Name}, - } - } - } - } - - return evts, nil -} - -func mapCmdSubject(subject string) string { - log.Debug().Msgf("!!!!!!Subject %q", subject) - switch subject { - case "groups": - return "Group" - case "sas": - return "ServiceAccount" - default: - return "User" - } -} - -func mapFuSubject(subject string) string { - switch subject { - case "Group": - return "g" - case "ServiceAccount": - return "s" - default: - return "u" - } -} diff --git a/internal/views/table.go b/internal/views/table.go deleted file mode 100644 index 01c82857..00000000 --- a/internal/views/table.go +++ /dev/null @@ -1,115 +0,0 @@ -package views - -import ( - "github.com/derailed/k9s/internal/ui" - "github.com/gdamore/tcell" -) - -type tableView struct { - *ui.Table - - app *appView - filterFn func(string) -} - -func newTableView(app *appView, title string) *tableView { - v := tableView{ - Table: ui.NewTable(title, app.Styles), - app: app, - } - v.SearchBuff().AddListener(app.Cmd()) - v.SearchBuff().AddListener(&v) - v.bindKeys() - - return &v -} - -// BufferChanged indicates the buffer was changed. -func (v *tableView) BufferChanged(s string) {} - -// BufferActive indicates the buff activity changed. -func (v *tableView) BufferActive(state bool, k ui.BufferKind) { - v.app.BufferActive(state, k) -} - -func (v *tableView) saveCmd(evt *tcell.EventKey) *tcell.EventKey { - if path, err := saveTable(v.app.Config.K9s.CurrentCluster, v.GetBaseTitle(), v.GetFilteredData()); err != nil { - v.app.Flash().Err(err) - } else { - v.app.Flash().Infof("File %s saved successfully!", path) - } - - return nil -} - -func (v *tableView) setFilterFn(fn func(string)) { - v.filterFn = fn - - cmd := v.SearchBuff().String() - if isLabelSelector(cmd) && v.filterFn != nil { - v.filterFn(trimLabelSelector(cmd)) - } -} - -func (v *tableView) bindKeys() { - v.SetActions(ui.KeyActions{ - tcell.KeyCtrlS: ui.NewKeyAction("Save", v.saveCmd, true), - ui.KeySlash: ui.NewKeyAction("Filter Mode", v.activateCmd, false), - tcell.KeyEscape: ui.NewKeyAction("Filter Reset", v.resetCmd, false), - tcell.KeyEnter: ui.NewKeyAction("Filter", v.filterCmd, false), - tcell.KeyBackspace2: ui.NewKeyAction("Erase", v.eraseCmd, false), - tcell.KeyBackspace: ui.NewKeyAction("Erase", v.eraseCmd, false), - tcell.KeyDelete: ui.NewKeyAction("Erase", v.eraseCmd, false), - ui.KeyShiftI: ui.NewKeyAction("Invert", v.SortInvertCmd, false), - ui.KeyShiftN: ui.NewKeyAction("Sort Name", v.SortColCmd(0), false), - ui.KeyShiftA: ui.NewKeyAction("Sort Age", v.SortColCmd(-1), false), - }) -} - -func (v *tableView) filterCmd(evt *tcell.EventKey) *tcell.EventKey { - if !v.SearchBuff().IsActive() { - return evt - } - - v.SearchBuff().SetActive(false) - cmd := v.SearchBuff().String() - if isLabelSelector(cmd) && v.filterFn != nil { - v.filterFn(trimLabelSelector(cmd)) - return nil - } - v.Refresh() - - return nil -} - -func (v *tableView) eraseCmd(evt *tcell.EventKey) *tcell.EventKey { - if v.SearchBuff().IsActive() { - v.SearchBuff().Delete() - } - - return nil -} - -func (v *tableView) resetCmd(evt *tcell.EventKey) *tcell.EventKey { - if !v.SearchBuff().Empty() { - v.app.Flash().Info("Clearing filter...") - } - if isLabelSelector(v.SearchBuff().String()) { - v.filterFn("") - } - v.SearchBuff().Reset() - v.Refresh() - - return nil -} - -func (v *tableView) activateCmd(evt *tcell.EventKey) *tcell.EventKey { - if v.app.InCmdMode() { - return evt - } - - v.app.Flash().Info("Filter mode activated.") - v.SearchBuff().SetActive(true) - - return nil -} diff --git a/internal/views/table_test.go b/internal/views/table_test.go deleted file mode 100644 index d9bddf72..00000000 --- a/internal/views/table_test.go +++ /dev/null @@ -1,169 +0,0 @@ -package views - -import ( - "fmt" - "io/ioutil" - "path/filepath" - "strings" - "testing" - - "github.com/derailed/k9s/internal/config" - "github.com/derailed/k9s/internal/resource" - "github.com/stretchr/testify/assert" - "k8s.io/apimachinery/pkg/watch" -) - -func TestTableViewSave(t *testing.T) { - v := newTableView(NewApp(config.NewConfig(ks{})), "test") - v.SetTitle("k9s-test") - dir := filepath.Join(config.K9sDumpDir, v.app.Config.K9s.CurrentCluster) - c1, _ := ioutil.ReadDir(dir) - v.saveCmd(nil) - c2, _ := ioutil.ReadDir(dir) - assert.Equal(t, len(c2), len(c1)+1) -} - -func TestTableViewNew(t *testing.T) { - v := newTableView(NewApp(config.NewConfig(ks{})), "test") - - data := resource.TableData{ - Header: resource.Row{"NAMESPACE", "NAME", "FRED", "AGE"}, - Rows: resource.RowEvents{ - "ns1/a": &resource.RowEvent{ - Action: watch.Added, - Fields: resource.Row{"ns1", "a", "10", "3m"}, - Deltas: resource.Row{"", "", "", ""}, - }, - "ns1/b": &resource.RowEvent{ - Action: watch.Added, - Fields: resource.Row{"ns1", "b", "15", "1m"}, - Deltas: resource.Row{"", "", "20", ""}, - }, - }, - NumCols: map[string]bool{ - "FRED": true, - }, - Namespace: "", - } - v.Update(data) - assert.Equal(t, 3, v.GetRowCount()) -} - -func TestTableViewFilter(t *testing.T) { - v := newTableView(NewApp(config.NewConfig(ks{})), "test") - - data := resource.TableData{ - Header: resource.Row{"NAMESPACE", "NAME", "FRED", "AGE"}, - Rows: resource.RowEvents{ - "ns1/blee": &resource.RowEvent{ - Action: watch.Added, - Fields: resource.Row{"ns1", "blee", "10", "3m"}, - Deltas: resource.Row{"", "", "", ""}, - }, - "ns1/fred": &resource.RowEvent{ - Action: watch.Added, - Fields: resource.Row{"ns1", "fred", "15", "1m"}, - Deltas: resource.Row{"", "", "20", ""}, - }, - }, - NumCols: map[string]bool{ - "FRED": true, - }, - Namespace: "", - } - v.Update(data) - v.SearchBuff().SetActive(true) - v.SearchBuff().Set("blee") - v.filterCmd(nil) - assert.Equal(t, 2, v.GetRowCount()) - v.resetCmd(nil) - assert.Equal(t, 3, v.GetRowCount()) -} - -func TestTableViewSort(t *testing.T) { - v := newTableView(NewApp(config.NewConfig(ks{})), "test") - - data := resource.TableData{ - Header: resource.Row{"NAMESPACE", "NAME", "FRED", "AGE"}, - Rows: resource.RowEvents{ - "ns1/blee": &resource.RowEvent{ - Action: watch.Added, - Fields: resource.Row{"ns1", "blee", "10", "3m"}, - Deltas: resource.Row{"", "", "", ""}, - }, - "ns1/fred": &resource.RowEvent{ - Action: watch.Added, - Fields: resource.Row{"ns1", "fred", "15", "1m"}, - Deltas: resource.Row{"", "", "20", ""}, - }, - }, - NumCols: map[string]bool{ - "FRED": true, - }, - Namespace: "", - } - v.Update(data) - v.SortColCmd(1)(nil) - assert.Equal(t, 3, v.GetRowCount()) - assert.Equal(t, "blee ", v.GetCell(1, 1).Text) - - v.SortInvertCmd(nil) - assert.Equal(t, 3, v.GetRowCount()) - assert.Equal(t, "fred ", v.GetCell(1, 1).Text) -} - -func TestIsSelector(t *testing.T) { - uu := map[string]struct { - sel string - e bool - }{ - "cool": {"-l app=fred,env=blee", true}, - "noMode": {"app=fred,env=blee", false}, - "noSpace": {"-lapp=fred,env=blee", true}, - "wrongLabel": {"-f app=fred,env=blee", false}, - } - - for k, u := range uu { - t.Run(k, func(t *testing.T) { - assert.Equal(t, u.e, isLabelSelector(u.sel)) - }) - } -} - -func TestTrimLabelSelector(t *testing.T) { - uu := map[string]struct { - sel, e string - }{ - "cool": {"-l app=fred,env=blee", "app=fred,env=blee"}, - "noSpace": {"-lapp=fred,env=blee", "app=fred,env=blee"}, - } - - for k, u := range uu { - t.Run(k, func(t *testing.T) { - assert.Equal(t, u.e, trimLabelSelector(u.sel)) - }) - } -} - -func BenchmarkTitleReplace(b *testing.B) { - b.ResetTimer() - b.ReportAllocs() - for i := 0; i < b.N; i++ { - fmat := strings.Replace(nsTitleFmt, "[fg", "["+"red", -1) - fmat = strings.Replace(fmat, ":bg:", ":"+"blue"+":", -1) - fmat = strings.Replace(fmat, "[hilite", "["+"green", 1) - fmat = strings.Replace(fmat, "[count", "["+"yellow", 1) - _ = fmt.Sprintf(fmat, "Pods", "default", 10) - } -} - -func BenchmarkTitleReplace1(b *testing.B) { - b.ResetTimer() - b.ReportAllocs() - for i := 0; i < b.N; i++ { - fmat := strings.Replace(nsTitleFmt, "fg:bg", "red"+":"+"blue", -1) - fmat = strings.Replace(fmat, "[hilite", "["+"green", 1) - fmat = strings.Replace(fmat, "[count", "["+"yellow", 1) - _ = fmt.Sprintf(fmat, "Pods", "default", 10) - } -}