changed to command buffer to diff command vs nav + bugz fixes

mine
derailed 2019-02-06 16:34:03 -07:00
parent cc9b789123
commit b332912d1b
20 changed files with 816 additions and 315 deletions

View File

@ -20,26 +20,28 @@ for changes and offers subsequent commands to interact with observed resources.
---
## Installation
### Homebrew (OSX)
1. Homebrew (OSX)
```shell
brew tap derailed/k9s && brew install k9s
```
### Binary Releases
1. Binary Releases
- [Releases](https://github.com/derailed/k9s/releases)
<br/>
---
## Command Line
To show all available options from the cli use:
```shell
# List all available CLI options
k9s -h
# To get info about K9s runtime (logs, configs, etc..)
k9s config
# To run K9s in a given namespace
k9s -n mybitchns
```
<br/>
@ -55,8 +57,9 @@ k9s -h
* For clusters with many namespaces you can either edit ~/.k9s/config.yml or
go to the namespace(ns) view to switch your default namespace to your namespace
of choice using *Ctrl-S*witch. K9s will keep your top 5 favorite namespaces.
Namespaces will get evicted based on your namespace switching frequency.
of choice using *Ctrl-S*witch. K9s keeps your top 10 favorite namespaces.
Namespaces will get evicted from the top 10 list, based on your namespace
switching frequency.
```yaml
@ -65,7 +68,7 @@ k9s -h
logBufferSize: 200 # Size of the logs buffer. Try to keep a sensible default!
namespace:
active: myCoolNS # Current active namespace name
favorites: # List of your 5 most frequently used namespaces
favorites: # List of your 10 most frequently used namespaces
- myCoolNS1
- myCoolNS2
- all
@ -84,14 +87,39 @@ k9s -h
<br/>
---
## Commands
## Usage
+ K9s uses 2 or 3 letters alias to navigate most K8s resource
+ At any time you can use `?` to look up the various commands
+ Use `alias<Enter>` to activate a resource under that alias
+ `Ctrl` sequences are used to view, edit, delete, ssh ...
+ Use `ctx<Enter>` to switch between clusters
+ Use `Q` or `Ctrl-C` to Quit.
K9s uses 2 or 3 letters alias to navigate most K8s resource.
| Command | Result | Example |
| ------------------ | -------------------------------------------------- | -------------------- |
| `>`alias`<ENTER>` | List a Kubernetes resource in the active namespace | `>po<ENTER>` |
| Ctrl-A | Show all command aliases | |
| `/`filter`ENTER`> | Filter out a resource view given a filter | `/bumblebeetuna` |
| `<Esc>` | Bails out of command mode | |
| `Ctrl-Key` | Key mapping to view, edit, see logs, etc... | `Ctrl-L` (view logs) |
| `>`ctx`<ENTER>` | To view and switch to another Kubernetes cluster | |
| `Ctrl-q`, `Ctrl-c` | To bail out of K9s | |
<br/>
---
## Building From Source
K9s was built using go 1.11. In order to build K9 from source:
+ Clone the repo
+ Add the following command in your go.mod file
```text
replace (
github.com/derailed/k9s => MY_K9S_CLONED_REPO
)
```
+ Build and run the executable
```shell
go run main.go
```
<br/>
@ -119,9 +147,9 @@ k9s -h
---
## Known Issues...
This initial drop is brittle. k9s will most likely blow up if...
This initial drop is brittle. K9s will most likely blow up if...
+ Your kube-config file does not live under $HOME/.kube or you use multiple configs
+ K9s does not support multiple cluster config specified via KUBECONFIG env var
+ You don't have enough RBAC fu to manage your cluster
+ Your cluster does not run a metrics-server
@ -139,8 +167,9 @@ dig this effort, please let us know that too!
---
## ATTA Girls/Boys!
k9s sits on top of on a lot of opensource projects. So *big thanks* to all the
contributors that made this project a reality!
K9s sits on top of many of opensource projects and libraries. Our *sincere*
appreciations to all the OSS contributors that work nights and weekends
to make this project a reality!
<br/>

View File

@ -0,0 +1,34 @@
# Release v0.1.2
<br/>
---
## Notes
Thank you to all that contributed with flushing out issues with K9s! I'll try
to mark some of these issues as fixed. But if you don't mind grab the latest
rev and see if we're happier with some of the fixes!
<br/>
---
## Change Logs
+ Navigation changed! Thanks to [Teppei Fukuda](https://github.com/knqyf263) for
hinting about the different modes ie command vs navigation. Now in order to
navigate to a specific kubernetes resource you need to issue this command
to say see all pods (using key `>`):
```text
>po<ENTER>
```
+ Similarly to filter on a given resource you can use `/' and type your filter.
+ In both instances `<ESC>` will back you out of command mode and into navigation mode.
<br/>
---
## Resolved Bugs
+ [Issue #23](https://github.com/derailed/k9s/issues/23)
+ [Issue #19](https://github.com/derailed/k9s/issues/19)

View File

@ -104,9 +104,9 @@ func run(cmd *cobra.Command, args []string) {
initStyles()
initKeys()
app := views.NewApp(version, refreshRate, namespace)
app := views.NewApp()
{
app.Init()
app.Init(version, refreshRate, namespace)
app.Run()
}
}

3
go.mod
View File

@ -1,9 +1,10 @@
module github.com/derailed/k9s
// replace github.com/k8sland/tview => /Users/fernand/go_wk/k8sland/src/github.com/k8sland/tview
replace github.com/k8sland/tview => /Users/fernand/go_wk/k8sland/src/github.com/k8sland/tview
require (
cloud.google.com/go v0.34.0 // indirect
github.com/gdamore/encoding v1.0.0 // indirect
github.com/gdamore/tcell v1.1.0
github.com/ghodss/yaml v1.0.0 // indirect
github.com/gogo/protobuf v1.1.1 // indirect

View File

@ -5,7 +5,6 @@ import (
"math"
"path"
log "github.com/sirupsen/logrus"
"k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/labels"
@ -94,7 +93,6 @@ func (m *MetricsServer) PodMetrics() (map[string]Metric, error) {
// PerNodeMetrics retrieves all nodes metrics
func (m *MetricsServer) PerNodeMetrics(nn []v1.Node) (map[string]Metric, error) {
log.Println("Getting per node metrics...")
mx := map[string]Metric{}
mm, err := m.getNodeMetrics()

View File

@ -11,6 +11,8 @@ import (
v1 "k8s.io/api/core/v1"
)
const lbIPWidth = 16
// Service tracks a kubernetes resource.
type Service struct {
*Base
@ -107,7 +109,7 @@ func (r *Service) Fields(ns string) Row {
i.ObjectMeta.Name,
string(i.Spec.Type),
i.Spec.ClusterIP,
r.toIPs(i.Spec.Type, i.Spec.ExternalIPs),
r.toIPs(i.Spec.Type, getSvcExtIPS(i)),
r.toPorts(i.Spec.Ports),
toAge(i.ObjectMeta.CreationTimestamp),
)
@ -120,6 +122,44 @@ func (r *Service) ExtFields() Properties {
// Helpers...
func getSvcExtIPS(svc *v1.Service) []string {
results := []string{}
switch svc.Spec.Type {
case v1.ServiceTypeClusterIP:
fallthrough
case v1.ServiceTypeNodePort:
return svc.Spec.ExternalIPs
case v1.ServiceTypeLoadBalancer:
lbIps := lbIngressIP(svc.Status.LoadBalancer)
if len(svc.Spec.ExternalIPs) > 0 {
if len(lbIps) > 0 {
results = append(results, lbIps)
}
return append(results, svc.Spec.ExternalIPs...)
}
if len(lbIps) > 0 {
results = append(results, lbIps)
}
case v1.ServiceTypeExternalName:
results = append(results, svc.Spec.ExternalName)
}
return results
}
func lbIngressIP(s v1.LoadBalancerStatus) string {
ingress := s.Ingress
result := []string{}
for i := range ingress {
if len(ingress[i].IP) > 0 {
result = append(result, ingress[i].IP)
} else if len(ingress[i].Hostname) > 0 {
result = append(result, ingress[i].Hostname)
}
}
return strings.Join(result, ",")
}
func (*Service) toIPs(svcType v1.ServiceType, ips []string) string {
if len(ips) == 0 {
if svcType == v1.ServiceTypeLoadBalancer {

View File

@ -33,8 +33,6 @@ type (
*tview.Application
version string
refreshRate int
namespace string
pages *tview.Pages
content *tview.Pages
flashView *flashView
@ -45,22 +43,22 @@ type (
focusCurrent int
focusChanged focusHandler
cancel context.CancelFunc
cmdBuff []rune
cmdBuff *cmdBuff
cmdView *cmdView
}
)
// NewApp returns a K9s app instance.
func NewApp(v string, rate int, ns string) *appView {
func NewApp() *appView {
var app appView
{
app = appView{
Application: tview.NewApplication(),
pages: tview.NewPages(),
version: v,
refreshRate: rate,
namespace: ns,
menuView: newMenuView(),
content: tview.NewPages(),
cmdBuff: newCmdBuff('>'),
cmdView: newCmdView('🐶'),
}
app.command = newCommand(&app)
app.focusChanged = app.changedFocus
@ -69,21 +67,24 @@ func NewApp(v string, rate int, ns string) *appView {
return &app
}
func (a *appView) Init() {
func (a *appView) Init(v string, rate int, ns string) {
a.version = v
log.Info("🐶 K9s starting up...")
mustK8s()
initConfig(a.refreshRate, a.namespace)
initConfig(rate, ns)
a.infoView = newInfoView(a)
a.infoView.init()
a.flashView = newFlashView(a.Application, "Initializing...")
a.cmdBuff.addListener(a.cmdView)
header := tview.NewFlex()
{
header.SetDirection(tview.FlexColumn)
header.AddItem(a.infoView, 25, 1, false)
header.AddItem(a.infoView, 30, 1, false)
header.AddItem(a.menuView, 0, 1, false)
header.AddItem(logoView(), 26, 1, false)
}
@ -91,7 +92,8 @@ func (a *appView) Init() {
main := tview.NewFlex()
{
main.SetDirection(tview.FlexRow)
main.AddItem(header, 7, 1, false)
main.AddItem(header, 6, 1, false)
main.AddItem(a.cmdView, 1, 1, false)
main.AddItem(a.content, 0, 10, true)
main.AddItem(a.flashView, 2, 1, false)
}
@ -134,42 +136,45 @@ func (a *appView) Run() {
}
func (a *appView) keyboard(evt *tcell.EventKey) *tcell.EventKey {
switch evt.Rune() {
case 'q':
a.quit(evt)
return nil
case '?':
a.helpCmd()
key := evt.Key()
if key == tcell.KeyRune {
switch evt.Rune() {
case a.cmdBuff.hotKey:
a.cmdBuff.setActive(true)
a.cmdBuff.clear()
return evt
}
if a.cmdBuff.isActive() {
a.cmdBuff.add(evt.Rune())
}
return evt
}
switch evt.Key() {
case tcell.KeyCtrlQ:
a.quit(evt)
case tcell.KeyCtrlH:
a.help(evt)
case tcell.KeyCtrlR:
a.Draw()
case tcell.KeyEsc:
a.resetCmd()
a.cmdBuff.reset()
case tcell.KeyEnter:
if len(a.cmdBuff) != 0 {
a.command.run(string(a.cmdBuff))
if a.cmdBuff.isActive() && !a.cmdBuff.empty() {
a.command.run(a.cmdBuff.String())
}
a.cmdBuff.setActive(false)
case tcell.KeyBackspace2:
if a.cmdBuff.isActive() {
a.cmdBuff.del()
}
a.resetCmd()
case tcell.KeyTab:
a.nextFocus()
case tcell.KeyRune:
a.cmdBuff = append([]rune(a.cmdBuff), evt.Rune())
}
return evt
}
func (a *appView) helpCmd() {
log.Info("Got help")
a.inject(newHelpView(a))
}
func (a *appView) resetCmd() {
a.cmdBuff = []rune{}
}
func (a *appView) showPage(p string) {
a.pages.SwitchToPage(p)
}
@ -202,6 +207,10 @@ func (a *appView) refresh() {
a.infoView.refresh()
}
func (a *appView) help(*tcell.EventKey) {
a.inject(newHelpView(a))
}
func (a *appView) quit(*tcell.EventKey) {
a.Stop()
os.Exit(0)

62
views/cmd.go Normal file
View File

@ -0,0 +1,62 @@
package views
import (
"fmt"
"github.com/gdamore/tcell"
"github.com/k8sland/tview"
)
const defaultPrompt = "%c> %s"
type cmdView struct {
*tview.TextView
icon rune
text string
}
func newCmdView(ic rune) *cmdView {
v := cmdView{icon: ic, TextView: tview.NewTextView()}
{
v.SetWordWrap(false)
v.SetWrap(false)
v.SetDynamicColors(true)
v.SetBorderPadding(0, 0, 1, 1)
v.SetTextColor(tcell.ColorAqua)
}
return &v
}
func (v *cmdView) activate() {
v.write(v.text)
}
func (v *cmdView) update(s string) {
v.text = s
v.Clear()
v.write(s)
}
func (v *cmdView) append(r rune) {
fmt.Fprintf(v, string(r))
}
func (v *cmdView) write(s string) {
fmt.Fprintf(v, defaultPrompt, v.icon, s)
}
// ----------------------------------------------------------------------------
// Event Listener protocol...
func (v *cmdView) changed(s string) {
v.update(s)
}
func (v *cmdView) active(f bool) {
if f {
v.activate()
return
}
v.Clear()
}

85
views/cmd_buff.go Normal file
View File

@ -0,0 +1,85 @@
package views
const maxBuff = 10
type buffWatcher interface {
changed(s string)
active(state bool)
}
type cmdBuff struct {
buff []rune
hotKey rune
active bool
listeners []buffWatcher
}
func newCmdBuff(key rune) *cmdBuff {
return &cmdBuff{
hotKey: key,
buff: make([]rune, 0, maxBuff),
listeners: []buffWatcher{},
}
}
func (c *cmdBuff) isActive() bool {
return c.active
}
func (c *cmdBuff) setActive(b bool) {
c.active = b
c.fireActive(c.active)
}
// String turns rune to string (Stringer protocol)
func (c *cmdBuff) String() string {
return string(c.buff)
}
func (c *cmdBuff) add(r rune) {
c.buff = append(c.buff, r)
c.fireChanged()
}
func (c *cmdBuff) del() {
if c.empty() {
return
}
c.buff = c.buff[:len(c.buff)-1]
c.fireChanged()
}
func (c *cmdBuff) clear() {
c.buff = make([]rune, 0, maxBuff)
c.fireChanged()
}
func (c *cmdBuff) reset() {
c.active = false
c.clear()
c.fireChanged()
c.fireActive(c.active)
}
func (c *cmdBuff) empty() bool {
return len(c.buff) == 0
}
// ----------------------------------------------------------------------------
// Event Listeners...
func (c *cmdBuff) addListener(w ...buffWatcher) {
c.listeners = append(c.listeners, w...)
}
func (c *cmdBuff) fireChanged() {
for _, l := range c.listeners {
l.changed(c.String())
}
}
func (c *cmdBuff) fireActive(b bool) {
for _, l := range c.listeners {
l.active(b)
}
}

141
views/cmd_buff_test.go Normal file
View File

@ -0,0 +1,141 @@
package views
import (
"testing"
"github.com/stretchr/testify/assert"
)
type testListener struct {
text string
act int
inact int
}
func (l *testListener) changed(s string) {
l.text = s
}
func (l *testListener) active(s bool) {
if s {
l.act++
return
}
l.inact++
}
func TestCmdBuffActivate(t *testing.T) {
b, l := newCmdBuff('>'), testListener{}
b.addListener(&l)
b.setActive(true)
assert.Equal(t, 1, l.act)
assert.Equal(t, 0, l.inact)
assert.True(t, b.active)
}
func TestCmdBuffDeactivate(t *testing.T) {
b, l := newCmdBuff('>'), testListener{}
b.addListener(&l)
b.setActive(false)
assert.Equal(t, 0, l.act)
assert.Equal(t, 1, l.inact)
assert.False(t, b.active)
}
func TestCmdBuffChanged(t *testing.T) {
b, l := newCmdBuff('>'), testListener{}
b.addListener(&l)
b.add('b')
assert.Equal(t, 0, l.act)
assert.Equal(t, 0, l.inact)
assert.Equal(t, "b", l.text)
assert.Equal(t, "b", b.String())
b.del()
assert.Equal(t, 0, l.act)
assert.Equal(t, 0, l.inact)
assert.Equal(t, "", l.text)
assert.Equal(t, "", b.String())
b.add('c')
b.clear()
assert.Equal(t, 0, l.act)
assert.Equal(t, 0, l.inact)
assert.Equal(t, "", l.text)
assert.Equal(t, "", b.String())
b.add('c')
b.reset()
assert.Equal(t, 0, l.act)
assert.Equal(t, 1, l.inact)
assert.Equal(t, "", l.text)
assert.Equal(t, "", b.String())
assert.True(t, b.empty())
}
func TestCmdBuffAdd(t *testing.T) {
b := newCmdBuff('>')
uu := []struct {
runes []rune
cmd string
}{
{[]rune{}, ""},
{[]rune{'a'}, "a"},
{[]rune{'a', 'b', 'c'}, "abc"},
}
for _, u := range uu {
for _, r := range u.runes {
b.add(r)
}
assert.Equal(t, u.cmd, b.String())
b.reset()
}
}
func TestCmdBuffDel(t *testing.T) {
b := newCmdBuff('>')
uu := []struct {
runes []rune
cmd string
}{
{[]rune{}, ""},
{[]rune{'a'}, ""},
{[]rune{'a', 'b', 'c'}, "ab"},
}
for _, u := range uu {
for _, r := range u.runes {
b.add(r)
}
b.del()
assert.Equal(t, u.cmd, b.String())
b.reset()
}
}
func TestCmdBuffEmpty(t *testing.T) {
b := newCmdBuff('>')
uu := []struct {
runes []rune
empty bool
}{
{[]rune{}, true},
{[]rune{'a'}, false},
{[]rune{'a', 'b', 'c'}, false},
}
for _, u := range uu {
for _, r := range u.runes {
b.add(r)
}
assert.Equal(t, u.empty, b.empty())
b.reset()
}
}

View File

@ -24,35 +24,36 @@ func (c *command) defaultCmd() {
// Exec the command by showing associated display.
func (c *command) run(cmd string) {
var v igniter
switch cmd {
case "q":
c.app.quit(nil)
default:
if res, ok := cmdMap[cmd]; ok {
v = res.viewFn(res.title, c.app, res.listFn(defaultNS), res.colorerFn)
c.app.flash(flashInfo, "Viewing all "+res.title+"...")
} else {
if res, ok := getCRDS()[cmd]; !ok {
c.app.flash(flashWarn, fmt.Sprintf("Huh? `%s` command not found", cmd))
} else {
n := res.Plural
if len(n) == 0 {
n = res.Singular
}
v = newResourceView(
res.Kind,
c.app,
resource.NewCustomList("", res.Group, res.Version, n),
defaultColorer,
)
}
}
if res, ok := cmdMap[cmd]; ok {
v = res.viewFn(res.title, c.app, res.listFn(defaultNS), res.colorerFn)
c.app.flash(flashInfo, "Viewing all "+res.title+"...")
c.exec(cmd, v)
return
}
res, ok := getCRDS()[cmd]
if !ok {
c.app.flash(flashWarn, fmt.Sprintf("Huh? `%s` command not found", cmd))
return
}
n := res.Plural
if len(n) == 0 {
n = res.Singular
}
v = newResourceView(
res.Kind,
c.app,
resource.NewCustomList("", res.Group, res.Version, n),
defaultColorer,
)
c.exec(cmd, v)
}
func (c *command) exec(cmd string, v igniter) {
if v != nil {
k9sCfg.K9s.View.Active = cmd
k9sCfg.validateAndSave()
c.app.inject(v)
}
return
}

View File

@ -18,6 +18,7 @@ const (
defaultRefreshRate = 2
defaultLogBufferSize = 200
defaultView = "po"
maxFavorites = 10
)
var (
@ -30,8 +31,6 @@ var (
K9sLogs = filepath.Join(os.TempDir(), fmt.Sprintf("k9s-%s.log", mustK9sUser()))
)
const maxFavorites = 5
type (
namespace struct {
Active string `yaml:"active"`
@ -152,26 +151,6 @@ func (c *config) rmFavNS(ns string) {
c.K9s.Namespace.Favorites = fv
}
func defaultK9sConfig() *config {
return &config{
K9s: k9s{
RefreshRate: 5,
LogBufferSize: 200,
View: view{
Active: "po",
},
Namespace: namespace{
Active: resource.DefaultNamespace,
Favorites: []string{
resource.AllNamespace,
resource.DefaultNamespace,
"kube-system",
},
},
},
}
}
func inList(ll []string, n string) bool {
for _, l := range ll {
if l == n {
@ -230,3 +209,26 @@ func ensurePath(path string, mod os.FileMode) {
}
}
}
// ----------------------------------------------------------------------------
// Seed...
func defaultK9sConfig() *config {
return &config{
K9s: k9s{
RefreshRate: 5,
LogBufferSize: 200,
View: view{
Active: "po",
},
Namespace: namespace{
Active: resource.DefaultNamespace,
Favorites: []string{
resource.AllNamespace,
resource.DefaultNamespace,
"kube-system",
},
},
},
}
}

View File

@ -38,8 +38,8 @@ func TestConfigAddActive(t *testing.T) {
{"all", []string{"all", "default", "kube-system"}},
{"ns1", []string{"ns1", "all", "default", "kube-system"}},
{"ns2", []string{"ns2", "ns1", "all", "default", "kube-system"}},
{"ns3", []string{"ns3", "ns2", "ns1", "all", "default"}},
{"ns4", []string{"ns4", "ns3", "ns2", "ns1", "all"}},
{"ns3", []string{"ns3", "ns2", "ns1", "all", "default", "kube-system"}},
{"ns4", []string{"ns4", "ns3", "ns2", "ns1", "all", "default", "kube-system"}},
}
for _, u := range uu {

View File

@ -1,6 +1,8 @@
package views
import (
"strings"
"github.com/derailed/k9s/resource"
"github.com/gdamore/tcell"
)
@ -22,15 +24,24 @@ func (v *contextView) useContext(*tcell.EventKey) {
if !v.rowSelected() {
return
}
err := v.list.Resource().(*resource.Context).Switch(v.selectedItem)
ctx := v.selectedItem
if strings.HasSuffix(ctx, "*") {
ctx = strings.TrimRight(ctx, "*")
}
if strings.HasSuffix(ctx, "(𝜟)") {
ctx = strings.TrimRight(ctx, "(𝜟)")
}
err := v.list.Resource().(*resource.Context).Switch(ctx)
if err != nil {
v.app.flash(flashWarn, err.Error())
return
}
v.app.flash(flashInfo, "Switching context to ", v.selectedItem)
v.app.flash(flashInfo, "Switching context to ", ctx)
v.refresh()
table := v.GetPrimitive("ctx").(*tableView)
table.Select(0, 0)
tv := v.GetPrimitive("ctx").(*tableView)
tv.table.Select(0, 0)
}
func (v *contextView) extraActions(aa keyActions) {

View File

@ -35,7 +35,7 @@ func (v *helpView) init(context.Context, string) {
t := tview.NewTable()
{
t.SetBorder(true)
t.SetTitle(" [::b]Commands Help ")
t.SetTitle(" [::b]Commands Aliases ")
t.SetTitleColor(tcell.ColorAqua)
t.SetBorderColor(tcell.ColorDodgerBlue)
t.SetSelectable(true, false)

View File

@ -72,7 +72,7 @@ func (v *logsView) keyboard(evt *tcell.EventKey) *tcell.EventKey {
}
if _, ok := numKeys[i]; ok {
v.load(i - 1)
v.pv.app.resetCmd()
v.pv.app.cmdBuff.reset()
return nil
}
return evt

View File

@ -19,6 +19,123 @@ const (
colLen = 20
)
type (
keyboardHandler func(*tcell.EventKey)
hint struct {
mnemonic, display string
}
hints []hint
hinter interface {
hints() hints
}
keyAction struct {
description string
action keyboardHandler
}
keyActions map[tcell.Key]keyAction
menuView struct {
*tview.Table
}
)
func (h hints) Len() int {
return len(h)
}
func (h hints) Swap(i, j int) {
h[i], h[j] = h[j], h[i]
}
func (h hints) Less(i, j int) bool {
n, err1 := strconv.Atoi(h[i].mnemonic)
m, err2 := strconv.Atoi(h[j].mnemonic)
if err1 == nil && err2 == nil {
return n < m
}
d := strings.Compare(h[i].mnemonic, h[j].mnemonic)
return d < 0
}
func newKeyHandler(d string, a keyboardHandler) keyAction {
return keyAction{description: d, action: a}
}
func newMenuView() *menuView {
v := menuView{tview.NewTable()}
return &v
}
func (v *menuView) setMenu(hh hints) {
v.Clear()
sort.Sort(hh)
var row, col int
firstNS, firstCmd := true, true
for _, h := range hh {
if len(h.mnemonic) == 1 && firstNS {
row = 0
col = 2
firstNS = false
}
if len(h.mnemonic) > 1 && firstCmd {
row = 0
col++
firstCmd = false
}
c := tview.NewTableCell(v.item(h))
v.SetCell(row, col, c)
row++
if row > maxRows {
col++
row = 0
}
}
}
func (v *menuView) item(h hint) string {
i, err := strconv.Atoi(h.mnemonic)
if err == nil {
return fmt.Sprintf(menuIndexFmt, i, resource.Truncate(h.display, 14))
}
var s string
// if strings.ToLower(h.display)[0] == h.mnemonic[0] {
// s = fmt.Sprintf(menuFmt, strings.ToUpper(h.mnemonic), h.display[1:])
// } else {
s = fmt.Sprintf(menuSepFmt, strings.ToUpper(h.mnemonic), h.display)
// }
return s
}
func (a keyActions) toHints() hints {
kk := make([]int, 0, len(a))
for k := range a {
kk = append(kk, int(k))
}
// sort.Ints(kk)
hh := make(hints, 0, len(a))
for _, k := range kk {
if name, ok := tcell.KeyNames[tcell.Key(k)]; ok {
if name == "Backspace2" {
name = "delete"
}
hh = append(hh, hint{
mnemonic: name,
display: a[tcell.Key(k)].description})
}
}
return hh
}
// -----------------------------------------------------------------------------
// Key mapping Constants
// Defines numeric keys for container actions
const (
Key0 int32 = iota + 48
@ -75,90 +192,3 @@ var numKeys = map[int]int32{
8: Key8,
9: Key9,
}
type (
keyboardHandler func(*tcell.EventKey)
hint struct {
mnemonic, display string
}
hints []hint
hinter interface {
hints() hints
}
keyAction struct {
description string
action keyboardHandler
}
keyActions map[tcell.Key]keyAction
menuView struct {
*tview.Grid
}
)
func newKeyHandler(d string, a keyboardHandler) keyAction {
return keyAction{description: d, action: a}
}
func newMenuView() *menuView {
v := menuView{tview.NewGrid()}
v.SetGap(0, 1)
return &v
}
func (v *menuView) setMenu(hh hints) {
v.Clear()
v.SetRows(1, 1, 1, 1)
v.SetColumns(colLen, colLen)
isNS := true
var row, col int
for _, h := range hh {
// Reset cols for namespace menus...
if len(h.mnemonic) == 1 && isNS {
col++
row = 0
isNS = false
}
v.AddItem(v.item(h), row, col, 1, 1, 1, 1, false)
row++
if row > maxRows {
col++
row = 0
}
}
}
func (v *menuView) item(h hint) tview.Primitive {
c := tview.NewTextView()
c.SetDynamicColors(true)
var s string
if i, err := strconv.Atoi(h.mnemonic); err != nil {
if strings.ToLower(h.display)[0] == h.mnemonic[0] {
s = fmt.Sprintf(menuFmt, strings.ToUpper(h.mnemonic), h.display[1:])
} else {
s = fmt.Sprintf(menuSepFmt, strings.ToUpper(h.mnemonic), h.display)
}
} else {
s = fmt.Sprintf(menuIndexFmt, i, resource.Truncate(h.display, 14))
}
c.SetText(s)
return c
}
func (a keyActions) toHints() hints {
kk := make([]int, 0, len(a))
for k := range a {
kk = append(kk, int(k))
}
sort.Ints(kk)
hh := make(hints, 0, len(a))
for _, k := range kk {
hh = append(hh, hint{
mnemonic: tcell.KeyNames[tcell.Key(k)],
display: a[tcell.Key(k)].description})
}
return hh
}

View File

@ -2,11 +2,18 @@ package views
import (
"fmt"
"strings"
"github.com/derailed/k9s/resource"
"github.com/gdamore/tcell"
)
const (
favNSIndicator = "+"
defaultNSIndicator = "(*)"
deltaNSIndicator = "(𝜟)"
)
type namespaceView struct {
*resourceView
}
@ -26,10 +33,18 @@ func (v *namespaceView) useNamespace(*tcell.EventKey) {
return
}
k9sCfg.K9s.Namespace.Active = v.selectedItem
ns := v.selectedItem
for _, i := range []string{deltaNSIndicator, favNSIndicator, defaultNSIndicator} {
if strings.HasSuffix(ns, i) {
ns = strings.TrimRight(ns, i)
}
}
v.refresh()
k9sCfg.K9s.Namespace.Active = ns
k9sCfg.addFavNS(v.selectedItem)
k9sCfg.validateAndSave()
v.app.flash(flashInfo, fmt.Sprintf("Setting namespace `%s as your default namespace", v.selectedItem))
v.app.flash(flashInfo, fmt.Sprintf("Setting namespace `%s as your default namespace", ns))
}
func (v *namespaceView) extraActions(aa keyActions) {
@ -46,8 +61,13 @@ func (v *namespaceView) decorate(data resource.TableData) resource.TableData {
}
for k, v := range data.Rows {
if inList(k9sCfg.K9s.Namespace.Favorites, k) {
v.Fields[0] += "+"
v.Action = resource.Unchanged
}
if k9sCfg.K9s.Namespace.Active == k {
v.Fields[0] = v.Fields[0] + "*"
v.Fields[0] += "(*)"
v.Action = resource.Unchanged
}
}

View File

@ -7,6 +7,7 @@ import (
"regexp"
"strconv"
"strings"
"sync"
"time"
"github.com/derailed/k9s/resource"
@ -16,10 +17,7 @@ import (
log "github.com/sirupsen/logrus"
)
const (
noSelection = ""
maxNamespaces = 5
)
const noSelection = ""
type (
details interface {
@ -38,7 +36,7 @@ type (
selectedItem string
namespaces map[int]string
selectedNS string
suspendUpdate bool
update sync.Mutex
list resource.List
extraActionsFn func(keyActions)
decorateDataFn func(resource.TableData) resource.TableData
@ -54,10 +52,12 @@ func newResourceView(title string, app *appView, list resource.List, c colorerFn
Pages: tview.NewPages(),
}
table := newTableView(v.title, list.SortFn())
table.SetColorer(c)
table.SetSelectionChangedFunc(v.selChanged)
v.AddPage(v.list.GetName(), table, true, true)
tv := newTableView(app, v.title, list.SortFn())
{
tv.SetColorer(c)
tv.table.SetSelectionChangedFunc(v.selChanged)
}
v.AddPage(v.list.GetName(), tv, true, true)
var xray details
if list.HasXRay() {
@ -95,15 +95,13 @@ func (v *resourceView) init(ctx context.Context, ns string) {
log.Debugf("%s watcher canceled!", v.title)
return
case <-time.After(time.Duration(initTick) * time.Second):
if !v.isSuspended() {
v.refresh()
}
v.refresh()
initTick = float64(k9sCfg.K9s.RefreshRate)
}
}
}(ctx)
v.refreshActions()
v.CurrentPage().Item.(*tableView).Select(0, 0)
v.CurrentPage().Item.(*tableView).table.Select(0, 0)
}
func (v *resourceView) selChanged(r, c int) {
@ -196,7 +194,7 @@ func (v *resourceView) switchNamespace(evt *tcell.EventKey) {
}
func (v *resourceView) doSwitchNamespace(ns string) {
v.suspend()
v.update.Lock()
{
if ns == noSelection {
ns = resource.AllNamespace
@ -204,50 +202,43 @@ func (v *resourceView) doSwitchNamespace(ns string) {
v.selectedNS = ns
v.app.flash(flashInfo, fmt.Sprintf("Viewing `%s namespace...", ns))
v.list.SetNamespace(v.selectedNS)
v.refresh()
}
v.resume()
v.update.Unlock()
v.refresh()
v.selectItem(0, 0)
v.getTV().resetTitle()
v.getTV().Select(0, 0)
v.app.resetCmd()
v.getTV().table.Select(0, 0)
v.app.cmdBuff.reset()
k9sCfg.K9s.Namespace.Active = v.selectedNS
k9sCfg.validateAndSave()
}
// Utils...
func (v *resourceView) suspend() {
v.suspendUpdate = true
}
func (v *resourceView) resume() {
v.suspendUpdate = false
}
func (v *resourceView) isSuspended() bool {
return v.suspendUpdate
}
func (v *resourceView) refresh() {
if _, ok := v.CurrentPage().Item.(*tableView); !ok {
return
}
if v.list.Namespaced() {
v.list.SetNamespace(v.selectedNS)
}
if err := v.list.Reconcile(); err != nil {
v.app.flash(flashErr, err.Error())
}
v.refreshActions()
data := v.list.Data()
if v.decorateDataFn != nil {
data = v.decorateDataFn(data)
v.update.Lock()
{
if v.list.Namespaced() {
v.list.SetNamespace(v.selectedNS)
}
if err := v.list.Reconcile(); err != nil {
v.app.flash(flashErr, err.Error())
}
data := v.list.Data()
if v.decorateDataFn != nil {
data = v.decorateDataFn(data)
}
v.getTV().update(data)
v.refreshActions()
v.app.infoView.refresh()
v.app.Draw()
}
v.getTV().update(data)
v.app.infoView.refresh()
v.app.Draw()
v.update.Unlock()
}
func (v *resourceView) getTV() *tableView {
@ -267,22 +258,22 @@ func (v *resourceView) selectItem(r, c int) {
t := v.getTV()
switch v.list.GetNamespace() {
case resource.NotNamespaced:
v.selectedItem = strings.TrimSpace(t.GetCell(r, 0).Text)
v.selectedItem = strings.TrimSpace(t.table.GetCell(r, 0).Text)
case resource.AllNamespaces:
v.selectedItem = path.Join(
strings.TrimSpace(t.GetCell(r, 0).Text),
strings.TrimSpace(t.GetCell(r, 1).Text),
strings.TrimSpace(t.table.GetCell(r, 0).Text),
strings.TrimSpace(t.table.GetCell(r, 1).Text),
)
default:
v.selectedItem = path.Join(
v.selectedNS,
strings.TrimSpace(t.GetCell(r, 0).Text),
strings.TrimSpace(t.table.GetCell(r, 0).Text),
)
}
}
func (v *resourceView) switchPage(p string) {
v.suspend()
v.update.Lock()
{
v.SwitchToPage(p)
h := v.GetPrimitive(p).(hinter)
@ -290,7 +281,7 @@ func (v *resourceView) switchPage(p string) {
v.app.setHints(h.hints())
v.app.SetFocus(v.CurrentPage().Item)
}
v.resume()
v.update.Unlock()
}
func (v *resourceView) rowSelected() bool {
@ -314,15 +305,21 @@ func (v *resourceView) refreshActions() {
return
}
if v.list.Namespaced() && !v.list.AllNamespaces() && !inNSList(nn, v.list.GetNamespace()) {
v.list.SetNamespace(resource.DefaultNamespace)
if v.list.Namespaced() && !v.list.AllNamespaces() {
if !inNSList(nn, v.list.GetNamespace()) {
v.list.SetNamespace(resource.DefaultNamespace)
}
}
aa := keyActions{}
if v.list.Access(resource.NamespaceAccess) {
v.namespaces = make(map[int]string, maxNamespaces)
v.namespaces = make(map[int]string, maxFavorites)
var i int
for _, n := range k9sCfg.K9s.Namespace.Favorites {
if i > maxFavorites {
break
}
if n == resource.AllNamespace {
aa[tcell.Key(numKeys[i])] = newKeyHandler(resource.AllNamespace, v.switchNamespace)
v.namespaces[i] = resource.AllNamespaces
@ -338,17 +335,15 @@ func (v *resourceView) refreshActions() {
k9sCfg.rmFavNS(n)
k9sCfg.validateAndSave()
}
if i > maxNamespaces {
break
}
}
}
if v.list.Access(resource.EditAccess) {
aa[tcell.KeyCtrlE] = newKeyHandler("Edit", v.edit)
}
if v.list.Access(resource.DeleteAccess) {
aa[tcell.KeyCtrlD] = newKeyHandler("Delete", v.delete)
aa[tcell.KeyBackspace2] = newKeyHandler("Delete", v.delete)
}
if v.list.Access(resource.ViewAccess) {
aa[tcell.KeyCtrlV] = newKeyHandler("View", v.view)
@ -357,13 +352,14 @@ func (v *resourceView) refreshActions() {
aa[tcell.KeyCtrlX] = newKeyHandler("Describe", v.describe)
}
aa[tcell.KeyCtrlQ] = newKeyHandler("Quit", v.app.quit)
aa[tcell.KeyCtrlA] = newKeyHandler("Aliases", v.app.help)
if v.extraActionsFn != nil {
v.extraActionsFn(aa)
}
t := v.getTV()
{
t.setActions(aa)
v.app.setHints(t.hints())
}
t.setActions(aa)
v.app.setHints(t.hints())
}

View File

@ -4,6 +4,7 @@ import (
"fmt"
"regexp"
"strings"
"sync"
"github.com/derailed/k9s/resource"
"github.com/gdamore/tcell"
@ -17,87 +18,110 @@ const (
type (
tableView struct {
*tview.Table
baseTitle string
currentNS string
actions keyActions
colorer colorerFn
sortFn resource.SortFn
parent *resourceView
cmdBuffer []rune
data resource.TableData
searchMode bool
filtered bool
*tview.Flex
app *appView
baseTitle string
currentNS string
refresh sync.Mutex
actions keyActions
colorer colorerFn
sortFn resource.SortFn
table *tview.Table
data resource.TableData
cmdBuff *cmdBuff
}
)
func newTableView(title string, sortFn resource.SortFn) *tableView {
v := tableView{Table: tview.NewTable(), baseTitle: title, sortFn: sortFn}
v.SetBorder(true)
v.SetBorderColor(tcell.ColorDodgerBlue)
v.SetBorderAttributes(tcell.AttrBold)
v.SetBorderPadding(0, 0, 1, 1)
v.SetSelectable(true, false)
v.SetSelectedStyle(tcell.ColorBlack, tcell.ColorAqua, tcell.AttrBold)
v.SetInputCapture(v.keyboard)
func newTableView(app *appView, title string, sortFn resource.SortFn) *tableView {
v := tableView{app: app, Flex: tview.NewFlex().SetDirection(tview.FlexRow)}
{
v.baseTitle = title
v.sortFn = sortFn
v.SetBorder(true)
v.SetBorderColor(tcell.ColorDodgerBlue)
v.SetBorderAttributes(tcell.AttrBold)
v.SetBorderPadding(0, 0, 1, 1)
v.cmdBuff = newCmdBuff('/')
}
v.cmdBuff.addListener(app.cmdView)
v.cmdBuff.reset()
v.table = tview.NewTable()
{
v.table.SetSelectable(true, false)
v.table.SetSelectedStyle(tcell.ColorBlack, tcell.ColorAqua, tcell.AttrBold)
v.table.SetInputCapture(v.keyboard)
}
v.AddItem(v.table, 0, 1, true)
return &v
}
func (v *tableView) setDeleted() {
r, _ := v.GetSelection()
cols := v.GetColumnCount()
r, _ := v.table.GetSelection()
cols := v.table.GetColumnCount()
for x := 0; x < cols; x++ {
v.GetCell(r, x).SetAttributes(tcell.AttrDim)
v.table.GetCell(r, x).SetAttributes(tcell.AttrDim)
}
}
func (v *tableView) keyboard(evt *tcell.EventKey) *tcell.EventKey {
key := evt.Key()
if evt.Key() == tcell.KeyRune {
if evt.Rune() == '/' {
v.searchMode = true
v.cmdBuffer = []rune{}
} else {
if v.searchMode {
v.cmdBuffer = append([]rune(v.cmdBuffer), evt.Rune())
if v.cmdBuff.isActive() {
v.cmdBuff.add(evt.Rune())
}
switch evt.Rune() {
case v.cmdBuff.hotKey:
if !v.cmdBuff.isActive() {
v.cmdBuff.setActive(true)
}
return evt
}
key = tcell.Key(evt.Rune())
}
if a, ok := v.actions[key]; ok {
a.action(evt)
return nil
return evt
}
switch evt.Key() {
case tcell.KeyEnter:
if len(v.cmdBuffer) > 0 {
v.filtered = true
if v.cmdBuff.isActive() && !v.cmdBuff.empty() {
v.filter()
v.searchMode = false
}
evt = nil
v.cmdBuff.setActive(false)
case tcell.KeyEsc:
v.filtered, v.searchMode = false, false
v.cmdBuffer = []rune{}
evt = nil
v.cmdBuff.reset()
v.filter()
case tcell.KeyBackspace2:
if v.cmdBuff.isActive() {
v.cmdBuff.del()
}
}
return evt
}
func (v *tableView) filter() {
v.filterData(string(v.cmdBuffer))
v.filterData(v.cmdBuff)
}
func (v *tableView) filterData(filter string) {
func (v *tableView) filterData(filter fmt.Stringer) {
filtered := resource.TableData{
Header: v.data.Header,
Rows: resource.RowEvents{},
Namespace: v.data.Namespace,
}
rx := regexp.MustCompile(filter)
rx, err := regexp.Compile(filter.String())
if err != nil {
v.app.flash(flashErr, "Invalid search expression")
return
}
for k, row := range v.data.Rows {
f := strings.Join(row.Fields, " ")
if rx.MatchString(f) {
@ -137,33 +161,38 @@ func (v *tableView) resetTitle() {
switch v.currentNS {
case resource.NotNamespaced:
title = fmt.Sprintf(titleFmt, v.baseTitle, v.GetRowCount()-1)
title = fmt.Sprintf(titleFmt, v.baseTitle, v.table.GetRowCount()-1)
default:
ns := v.currentNS
if v.currentNS == resource.AllNamespaces {
ns = resource.AllNamespace
}
title = fmt.Sprintf(nsTitleFmt, v.baseTitle, ns, v.GetRowCount()-1)
title = fmt.Sprintf(nsTitleFmt, v.baseTitle, ns, v.table.GetRowCount()-1)
}
if v.filtered {
title += fmt.Sprintf("<[green::b]/%s[aqua::]> ", string(v.cmdBuffer))
if !v.cmdBuff.empty() {
title += fmt.Sprintf("<[green::b]/%s[aqua::]> ", v.cmdBuff)
}
v.SetTitle(title)
}
// Update table content
func (v *tableView) update(data resource.TableData) {
v.data = data
if v.filtered {
v.filter()
} else {
v.doUpdate(data)
v.refresh.Lock()
{
v.data = data
if !v.cmdBuff.empty() {
v.filter()
} else {
v.doUpdate(data)
}
v.resetTitle()
}
v.refresh.Unlock()
}
func (v *tableView) doUpdate(data resource.TableData) {
v.Clear()
v.table.Clear()
v.currentNS = data.Namespace
var row int
@ -176,7 +205,7 @@ func (v *tableView) doUpdate(data resource.TableData) {
}
c.SetTextColor(tcell.ColorWhite)
}
v.SetCell(row, col, c)
v.table.SetCell(row, col, c)
}
row++
@ -199,9 +228,22 @@ func (v *tableView) doUpdate(data resource.TableData) {
}
c.SetTextColor(fgColor)
}
v.SetCell(row, col, c)
v.table.SetCell(row, col, c)
}
row++
}
v.resetTitle()
}
// ----------------------------------------------------------------------------
// Event listeners...
func (v *tableView) changed(s string) {
}
func (v *tableView) active(b bool) {
if b {
v.SetBorderColor(tcell.ColorRed)
return
}
v.SetBorderColor(tcell.ColorDodgerBlue)
}