diff --git a/internal/config/hotkey.go b/internal/config/hotkey.go new file mode 100644 index 00000000..e0817f1f --- /dev/null +++ b/internal/config/hotkey.go @@ -0,0 +1,53 @@ +package config + +import ( + "io/ioutil" + "path/filepath" + + "gopkg.in/yaml.v2" +) + +// K9sHotKeys manages K9s hotKeys. +var K9sHotKeys = filepath.Join(K9sHome, "hotkey.yml") + +// HotKeys represents a collection of plugins. +type HotKeys struct { + HotKey map[string]HotKey `yaml:"hotKey"` +} + +// HotKey describes a K9s hotkey. +type HotKey struct { + ShortCut string `yaml:"shortCut"` + Description string `yaml:"description"` + Command string `yaml:"command"` +} + +// NewHotKeys returns a new plugin. +func NewHotKeys() HotKeys { + return HotKeys{ + HotKey: make(map[string]HotKey), + } +} + +// Load K9s plugins. +func (h HotKeys) Load() error { + return h.LoadHotKeys(K9sHotKeys) +} + +// LoadHotKeys loads plugins from a given file. +func (h HotKeys) LoadHotKeys(path string) error { + f, err := ioutil.ReadFile(path) + if err != nil { + return err + } + + var hh HotKeys + if err := yaml.Unmarshal(f, &hh); err != nil { + return err + } + for k, v := range hh.HotKey { + h.HotKey[k] = v + } + + return nil +} diff --git a/internal/config/hotkey_test.go b/internal/config/hotkey_test.go new file mode 100644 index 00000000..38f8242b --- /dev/null +++ b/internal/config/hotkey_test.go @@ -0,0 +1,21 @@ +package config_test + +import ( + "testing" + + "github.com/derailed/k9s/internal/config" + "github.com/stretchr/testify/assert" +) + +func TestHotKeyLoad(t *testing.T) { + h := config.NewHotKeys() + assert.Nil(t, h.LoadHotKeys("test_assets/hot_key.yml")) + + assert.Equal(t, 1, len(h.HotKey)) + + k, ok := h.HotKey["pods"] + assert.True(t, ok) + assert.Equal(t, "shift-0", k.ShortCut) + assert.Equal(t, "Launch pod view", k.Description) + assert.Equal(t, "pods", k.Command) +} diff --git a/internal/config/plugin_test.go b/internal/config/plugin_test.go index 3b5148e1..7a81412b 100644 --- a/internal/config/plugin_test.go +++ b/internal/config/plugin_test.go @@ -12,4 +12,11 @@ func TestPluginLoad(t *testing.T) { assert.Nil(t, p.LoadPlugins("test_assets/plugin.yml")) assert.Equal(t, 1, len(p.Plugin)) + k, ok := p.Plugin["blah"] + assert.True(t, ok) + assert.Equal(t, "shift-s", k.ShortCut) + assert.Equal(t, "blee", k.Description) + assert.Equal(t, []string{"po", "dp"}, k.Scopes) + assert.Equal(t, "duh", k.Command) + assert.Equal(t, []string{"-n", "$NAMESPACE", "-boolean"}, k.Args) } diff --git a/internal/config/test_assets/hot_key.yml b/internal/config/test_assets/hot_key.yml new file mode 100644 index 00000000..81f16319 --- /dev/null +++ b/internal/config/test_assets/hot_key.yml @@ -0,0 +1,5 @@ +hotKey: + pods: + shortCut: shift-0 + description: Launch pod view + command: pods diff --git a/internal/dao/port_forwarder.go b/internal/dao/port_forwarder.go index 20dc43b7..c6acf075 100644 --- a/internal/dao/port_forwarder.go +++ b/internal/dao/port_forwarder.go @@ -4,6 +4,7 @@ import ( "fmt" "net/http" "net/url" + "strings" "time" "github.com/derailed/k9s/internal/client" @@ -87,7 +88,7 @@ func (p *PortForwarder) FQN() string { } // Start initiates a port forward session for a given pod and ports. -func (p *PortForwarder) Start(path, co string, ports []string) (*portforward.PortForwarder, error) { +func (p *PortForwarder) Start(path, co, address string, ports []string) (*portforward.PortForwarder, error) { p.path, p.container, p.ports, p.age = path, co, ports, time.Now() ns, n := client.Namespaced(path) @@ -115,10 +116,10 @@ func (p *PortForwarder) Start(path, co string, ports []string) (*portforward.Por Name(n). SubResource("portforward") - return p.forwardPorts("POST", req.URL(), ports) + return p.forwardPorts("POST", req.URL(), address, ports) } -func (p *PortForwarder) forwardPorts(method string, url *url.URL, ports []string) (*portforward.PortForwarder, error) { +func (p *PortForwarder) forwardPorts(method string, url *url.URL, address string, ports []string) (*portforward.PortForwarder, error) { cfg, err := p.Config().RESTConfig() if err != nil { return nil, err @@ -129,7 +130,10 @@ func (p *PortForwarder) forwardPorts(method string, url *url.URL, ports []string } dialer := spdy.NewDialer(upgrader, &http.Client{Transport: transport}, method, url) - addrs := []string{localhost} + if address == "" { + address = localhost + } + addrs := strings.Split(address, ",") return portforward.NewOnAddresses(dialer, addrs, ports, p.stopChan, p.readyChan, p.Out, p.ErrOut) } diff --git a/internal/ui/dialog/port_forward.go b/internal/ui/dialog/port_forward.go index 7dcfbb2c..52a6cb7d 100644 --- a/internal/ui/dialog/port_forward.go +++ b/internal/ui/dialog/port_forward.go @@ -11,7 +11,7 @@ import ( const portForwardKey = "portforward" // ShowPortForward pops a port forwarding configuration dialog. -func ShowPortForward(p *ui.Pages, port string, okFn func(lport, cport string)) { +func ShowPortForward(p *ui.Pages, port string, okFn func(address, lport, cport string)) { f := tview.NewForm() f.SetItemPadding(0) f.SetButtonsAlign(tview.AlignCenter). @@ -20,16 +20,19 @@ func ShowPortForward(p *ui.Pages, port string, okFn func(lport, cport string)) { SetLabelColor(tcell.ColorAqua). SetFieldTextColor(tcell.ColorOrange) - p1, p2 := port, port - f.AddInputField("Pod Port:", p1, 20, nil, func(port string) { - p1 = port + p1, p2, address := port, port, "localhost" + f.AddInputField("Pod Port:", p1, 20, nil, func(p string) { + p1 = p }) - f.AddInputField("Local Port:", p2, 20, nil, func(port string) { - p2 = port + f.AddInputField("Local Port:", p2, 20, nil, func(p string) { + p2 = p + }) + f.AddInputField("Address:", address, 20, nil, func(h string) { + address = h }) f.AddButton("OK", func() { - okFn(stripPort(p2), stripPort(p1)) + okFn(address, stripPort(p2), stripPort(p1)) }) f.AddButton("Cancel", func() { DismissPortForward(p) @@ -48,6 +51,7 @@ func DismissPortForward(p *ui.Pages) { p.RemovePage(portForwardKey) } +// ---------------------------------------------------------------------------- // Helpers... // StripPort removes the named port id if present. diff --git a/internal/ui/dialog/port_forward_test.go b/internal/ui/dialog/port_forward_test.go index 5c84c598..1c2461e1 100644 --- a/internal/ui/dialog/port_forward_test.go +++ b/internal/ui/dialog/port_forward_test.go @@ -11,7 +11,7 @@ import ( func TestPortForwardDialog(t *testing.T) { p := ui.NewPages() - okFunc := func(lport, cport string) { + okFunc := func(address, lport, cport string) { } ShowPortForward(p, "8080", okFunc) @@ -38,7 +38,7 @@ func TestStripPort(t *testing.T) { } for k := range uu { - u := uu[k] + u := uu[k] t.Run(k, func(t *testing.T) { assert.Equal(t, u.e, stripPort(u.port)) }) diff --git a/internal/ui/key.go b/internal/ui/key.go index 5504b0a8..4bbed4f4 100644 --- a/internal/ui/key.go +++ b/internal/ui/key.go @@ -30,6 +30,20 @@ const ( Key9 ) +// Defines numeric keys for container actions +const ( + KeyShift0 int32 = 41 + KeyShift1 int32 = 33 + KeyShift2 int32 = 64 + KeyShift3 int32 = 35 + KeyShift4 int32 = 36 + KeyShift5 int32 = 37 + KeyShift6 int32 = 94 + KeyShift7 int32 = 38 + KeyShift8 int32 = 42 + KeyShift9 int32 = 40 +) + // Defines char keystrokes const ( KeyA tcell.Key = iota + 97 @@ -151,6 +165,17 @@ func initStdKeys() { } func initShiftKeys() { + tcell.KeyNames[tcell.Key(KeyShift0)] = "Shift-0" + tcell.KeyNames[tcell.Key(KeyShift1)] = "Shift-1" + tcell.KeyNames[tcell.Key(KeyShift2)] = "Shift-2" + tcell.KeyNames[tcell.Key(KeyShift3)] = "Shift-3" + tcell.KeyNames[tcell.Key(KeyShift4)] = "Shift-4" + tcell.KeyNames[tcell.Key(KeyShift5)] = "Shift-5" + tcell.KeyNames[tcell.Key(KeyShift6)] = "Shift-6" + tcell.KeyNames[tcell.Key(KeyShift7)] = "Shift-7" + tcell.KeyNames[tcell.Key(KeyShift8)] = "Shift-8" + tcell.KeyNames[tcell.Key(KeyShift9)] = "Shift-9" + tcell.KeyNames[tcell.Key(KeyShiftA)] = "Shift-A" tcell.KeyNames[tcell.Key(KeyShiftB)] = "Shift-B" tcell.KeyNames[tcell.Key(KeyShiftC)] = "Shift-C" diff --git a/internal/ui/table.go b/internal/ui/table.go index 1050810a..3500ba85 100644 --- a/internal/ui/table.go +++ b/internal/ui/table.go @@ -83,6 +83,7 @@ func (t *Table) SendKey(evt *tcell.EventKey) { } func (t *Table) keyboard(evt *tcell.EventKey) *tcell.EventKey { + log.Debug().Msgf("KEY PRESS %#v", evt) key := evt.Key() if key == tcell.KeyUp || key == tcell.KeyDown { return evt diff --git a/internal/view/actions.go b/internal/view/actions.go new file mode 100644 index 00000000..95ca28fe --- /dev/null +++ b/internal/view/actions.go @@ -0,0 +1,139 @@ +package view + +import ( + "fmt" + + "github.com/derailed/k9s/internal/config" + "github.com/derailed/k9s/internal/ui" + "github.com/gdamore/tcell" + "github.com/rs/zerolog/log" +) + +type Runner interface { + App() *App + GetSelectedItem() string + Aliases() []string + EnvFn() EnvFunc +} + +func hasAll(scopes []string) bool { + for _, s := range scopes { + if s == "all" { + return true + } + } + return false +} + +func includes(aliases []string, s string) bool { + for _, a := range aliases { + if a == s { + return true + } + } + return false +} + +func inScope(scopes, aliases []string) bool { + if hasAll(scopes) { + return true + } + for _, s := range scopes { + if includes(aliases, s) { + return true + } + } + + return false +} + +func hotKeyActions(r Runner, aa ui.KeyActions) { + hh := config.NewHotKeys() + if err := hh.Load(); err != nil { + log.Warn().Msgf("No HotKey configuration found") + return + } + + for k, hk := range hh.HotKey { + key, err := asKey(hk.ShortCut) + if err != nil { + log.Error().Err(err).Msg("Unable to map hotkey 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( + hk.Description, + gotoCmd(r, hk.Command), + true) + } +} + +func gotoCmd(r Runner, cmd string) ui.ActionHandler { + return func(evt *tcell.EventKey) *tcell.EventKey { + if err := r.App().gotoResource(cmd); err != nil { + r.App().Flash().Err(err) + } + return nil + } +} + +func pluginActions(r Runner, 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 !inScope(plugin.Scopes, r.Aliases()) { + continue + } + key, err := asKey(plugin.ShortCut) + if err != nil { + log.Error().Err(err).Msg("Unable to map plugin 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, + execCmd(r, plugin.Command, plugin.Background, plugin.Args...), + true) + } +} + +func execCmd(r Runner, bin string, bg bool, args ...string) ui.ActionHandler { + return func(evt *tcell.EventKey) *tcell.EventKey { + path := r.GetSelectedItem() + if path == "" { + 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 + } +} diff --git a/internal/view/browser.go b/internal/view/browser.go index 2993d534..95d92242 100644 --- a/internal/view/browser.go +++ b/internal/view/browser.go @@ -447,7 +447,8 @@ func (b *Browser) refreshActions() { if client.Can(b.meta.Verbs, "describe") { aa[ui.KeyD] = ui.NewKeyAction("Describe", b.describeCmd, true) } - b.customActions(aa) + pluginActions(b, aa) + hotKeyActions(b, aa) b.Actions().Add(aa) if b.bindKeysFn != nil { @@ -456,61 +457,12 @@ func (b *Browser) refreshActions() { b.app.Menu().HydrateMenu(b.Hints()) } -func (b *Browser) 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, b.meta.Name) { - 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, - b.execCmd(plugin.Command, plugin.Background, plugin.Args...), - true) - } +func (b *Browser) Aliases() []string { + return append(b.meta.ShortNames, b.meta.SingularName, b.meta.Name) } -func (b *Browser) execCmd(bin string, bg bool, args ...string) ui.ActionHandler { - return func(evt *tcell.EventKey) *tcell.EventKey { - path := b.GetSelectedItem() - if path == "" { - return evt - } - - var ( - env = b.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, b.app, bin, bg, aa...) { - b.app.Flash().Info("Custom CMD launched!") - } else { - b.app.Flash().Info("Custom CMD failed!") - } - return nil - } +func (b *Browser) EnvFn() EnvFunc { + return b.envFn } func (b *Browser) defaultK9sEnv() K9sEnv { diff --git a/internal/view/command.go b/internal/view/command.go index 1668b8f8..d44d1188 100644 --- a/internal/view/command.go +++ b/internal/view/command.go @@ -85,9 +85,6 @@ func (c *command) viewMetaFor(cmd string) (string, *MetaViewer, error) { if !ok { return "", nil, fmt.Errorf("Huh? `%s` command not found", cmd) } - if _, err := c.app.factory.CanForResource(c.app.Config.ActiveNamespace(), gvr); err != nil { - return "", nil, err - } v, ok := customViewers[client.GVR(gvr)] if !ok { diff --git a/internal/view/container.go b/internal/view/container.go index 81c951e7..6664c42a 100644 --- a/internal/view/container.go +++ b/internal/view/container.go @@ -64,7 +64,6 @@ func (c *Container) selectedContainer() string { } func (c *Container) viewLogs(app *App, ns, res, path string) { - log.Debug().Msgf(">>>>>>>> ViewLOgs %q -- %q -- %q", ns, res, path) status := c.GetTable().GetSelectedCell(3) if status != "Running" && status != "Completed" { app.Flash().Err(errors.New("No logs available")) @@ -134,11 +133,11 @@ func (c *Container) portFwdCmd(evt *tcell.EventKey) *tcell.EventKey { return nil } -func (c *Container) portForward(lport, cport string) { +func (c *Container) portForward(address, lport, cport string) { co := c.GetTable().GetSelectedCell(0) pf := dao.NewPortForwarder(c.App().Conn()) ports := []string{lport + ":" + cport} - fw, err := pf.Start(c.GetTable().Path, co, ports) + fw, err := pf.Start(c.GetTable().Path, co, address, ports) if err != nil { c.App().Flash().Err(err) return diff --git a/internal/view/help.go b/internal/view/help.go index 12639f2a..10192203 100644 --- a/internal/view/help.go +++ b/internal/view/help.go @@ -9,6 +9,7 @@ import ( "strings" "github.com/derailed/k9s/internal/client" + "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/model" "github.com/derailed/k9s/internal/render" "github.com/derailed/k9s/internal/ui" @@ -104,6 +105,22 @@ func (v *Help) showNav() model.MenuHints { } } +func (v *Help) showHotKeys() (model.MenuHints, error) { + hh := config.NewHotKeys() + if err := hh.Load(); err != nil { + return nil, fmt.Errorf("no hotkey configuration found") + } + m := make(model.MenuHints, 0, len(hh.HotKey)) + for _, hk := range hh.HotKey { + m = append(m, model.MenuHint{ + Mnemonic: hk.ShortCut, + Description: hk.Description, + }) + } + + return m, nil +} + func (v *Help) showGeneral() model.MenuHints { return model.MenuHints{ { @@ -160,10 +177,18 @@ func (v *Help) resetTitle() { func (v *Help) build(hh model.MenuHints) { v.Clear() sort.Sort(hh) - v.addSection(0, "RESOURCE", hh) - v.addSection(4, "GENERAL", v.showGeneral()) - v.addSection(6, "NAVIGATION", v.showNav()) - v.addSection(8, "HELP", v.showHelp()) + var col int + v.addSection(col, "RESOURCE", hh) + col += 2 + v.addSection(col, "GENERAL", v.showGeneral()) + col += 2 + v.addSection(col, "NAVIGATION", v.showNav()) + col += 2 + if h, err := v.showHotKeys(); err == nil { + v.addSection(col, "HOTKEYS", h) + col += 2 + } + v.addSection(col, "HELP", v.showHelp()) } func (v *Help) addSection(c int, title string, hh model.MenuHints) { diff --git a/internal/view/helpers.go b/internal/view/helpers.go index 9ea4d0d4..638c68be 100644 --- a/internal/view/helpers.go +++ b/internal/view/helpers.go @@ -58,17 +58,6 @@ func extractApp(ctx context.Context) (*App, error) { return app, nil } -// In check if a string belongs to a set. -func in(ss []string, s string) bool { - for _, v := range ss { - if v == s { - return true - } - } - - return false -} - // AsKey maps a string representation of a key to a tcell key. func asKey(key string) (tcell.Key, error) { for k, v := range tcell.KeyNames { diff --git a/internal/watch/factory.go b/internal/watch/factory.go index b93001c2..b0bbdc6b 100644 --- a/internal/watch/factory.go +++ b/internal/watch/factory.go @@ -201,6 +201,7 @@ func (f *Factory) ensureFactory(ns string) di.DynamicSharedInformerFactory { } func toGVR(gvr string) schema.GroupVersionResource { + log.Debug().Msgf(">>> Convert GVR %q", gvr) tokens := strings.Split(gvr, "/") if len(tokens) < 3 { tokens = append([]string{""}, tokens...) diff --git a/internal/watch/forwarders.go b/internal/watch/forwarders.go index 801dbeb6..762d0e70 100644 --- a/internal/watch/forwarders.go +++ b/internal/watch/forwarders.go @@ -10,7 +10,7 @@ import ( // Forwarder represents a port forwarder. type Forwarder interface { // Start initializes a port forward. - Start(path, co string, ports []string) (*portforward.PortForwarder, error) + Start(path, co, address string, ports []string) (*portforward.PortForwarder, error) // Stop terminates a port forward. Stop()