checkpoint

mine
derailed 2019-11-12 22:45:26 -07:00
parent 9b15803117
commit 7a89c4c1e4
157 changed files with 6574 additions and 5652 deletions

View File

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

View File

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

View File

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

View File

@ -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 `<alt-p>` 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 `<alt-p>` will show only while in pod and deploy view.
```yaml
plugins:

View File

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

View File

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

1
go.sum
View File

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

View File

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

54
internal/model/hint.go Normal file
View File

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

View File

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

View File

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

View File

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

156
internal/model/stack.go Normal file
View File

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

View File

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

View File

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

39
internal/model/types.go Normal file
View File

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

View File

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

View File

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

View File

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

73
internal/ui/app_test.go Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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] <blee> [-:black:-] [black:orange:b] <duh> [-:black:-] \n", v.GetText(false))
assert.Equal(t, "[black:aqua:b] <c1> [-:black:-] [black:aqua:b] <c2> [-:black:-] [black:orange:b] <c3> [-: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) {}

14
internal/ui/ctx.go Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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] <Blee> \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] <Blee> \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] <Blee> \n", i.GetText(false))
}

180
internal/ui/key.go Normal file
View File

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

View File

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

View File

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

76
internal/ui/pages.go Normal file
View File

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

31
internal/ui/pages_test.go Normal file
View File

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

View File

@ -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{
` ____ __.________ `,

View File

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

View File

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

View File

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

View File

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

141
internal/view/alias.go Normal file
View File

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

View File

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

View File

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

17
internal/view/app_test.go Normal file
View File

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

View File

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

View File

@ -1,4 +1,4 @@
package views
package view
import (
"io/ioutil"

View File

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

View File

@ -1,4 +1,4 @@
package views
package view
import (
"strings"

View File

@ -1,4 +1,4 @@
package views
package view
import (
"testing"

View File

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

View File

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

181
internal/view/container.go Normal file
View File

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

63
internal/view/context.go Normal file
View File

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

View File

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

41
internal/view/cronjob.go Normal file
View File

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

261
internal/view/details.go Normal file
View File

@ -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! <tab>/<TAB> 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")
}

View File

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

59
internal/view/dp.go Normal file
View File

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

18
internal/view/dp_test.go Normal file
View File

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

52
internal/view/ds.go Normal file
View File

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

21
internal/view/ds_test.go Normal file
View File

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

227
internal/view/dump.go Normal file
View File

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

View File

@ -1,4 +1,4 @@
package views
package view
import (
"fmt"

View File

@ -1,4 +1,4 @@
package views
package view
import (
"errors"

View File

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

View File

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

View File

@ -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, "<blee>", v.GetCell(1, 0).Text)
// assert.Equal(t, "duh", v.GetCell(1, 1).Text)
// }

View File

@ -1,4 +1,4 @@
package views
package view
import (
"fmt"

View File

@ -1,4 +1,4 @@
package views
package view
import (
"testing"

43
internal/view/job.go Normal file
View File

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

247
internal/view/log.go Normal file
View File

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

View File

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

81
internal/view/log_test.go Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

67
internal/view/no.go Normal file
View File

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

92
internal/view/ns.go Normal file
View File

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

View File

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

222
internal/view/pod.go Normal file
View File

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

View File

@ -1,4 +1,4 @@
package views
package view
import (
"strings"

15
internal/view/pod_test.go Normal file
View File

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

View File

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

View File

@ -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("<Delete Benchmark>")
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("<<FS %s WATCHER DONE>>", dir)
w.Close()
return
}
}
}()
return w.Add(dir)
}

View File

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

View File

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

115
internal/view/rbac_test.go Normal file
View File

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

View File

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

513
internal/view/resource.go Normal file
View File

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

Some files were not shown because too many files have changed in this diff Show More