diff --git a/Dockerfile b/Dockerfile index 4617766..fab2a93 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM quay.io/vektorcloud/go:1.11 +FROM quay.io/vektorcloud/go:1.12 RUN apk add --no-cache make diff --git a/README.md b/README.md index 99811cc..9a56325 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ Fetch the [latest release](https://github.com/bcicen/ctop/releases) for your pla #### Linux ```bash -sudo wget https://github.com/bcicen/ctop/releases/download/v0.7.1/ctop-0.7.1-linux-amd64 -O /usr/local/bin/ctop +sudo wget https://github.com/bcicen/ctop/releases/download/v0.7.2/ctop-0.7.2-linux-amd64 -O /usr/local/bin/ctop sudo chmod +x /usr/local/bin/ctop ``` @@ -31,7 +31,7 @@ brew install ctop ``` or ```bash -sudo curl -Lo /usr/local/bin/ctop https://github.com/bcicen/ctop/releases/download/v0.7.1/ctop-0.7.1-darwin-amd64 +sudo curl -Lo /usr/local/bin/ctop https://github.com/bcicen/ctop/releases/download/v0.7.2/ctop-0.7.2-darwin-amd64 sudo chmod +x /usr/local/bin/ctop ``` @@ -40,7 +40,7 @@ sudo chmod +x /usr/local/bin/ctop ```bash docker run --rm -ti \ --name=ctop \ - -v /var/run/docker.sock:/var/run/docker.sock \ + --volume /var/run/docker.sock:/var/run/docker.sock:ro \ quay.io/vektorlab/ctop:latest ``` @@ -70,6 +70,7 @@ Option | Description -s | select initial container sort field -scale-cpu | show cpu as % of system total -v | output version information and exit +-shell | specify shell (default: sh) ### Keybindings @@ -84,6 +85,7 @@ s | Select container sort field r | Reverse container sort order o | Open single view l | View container logs (`t` to toggle timestamp when open) +e | Exec Shell S | Save current configuration to file q | Quit ctop diff --git a/VERSION b/VERSION index 39e898a..7486fdb 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.7.1 +0.7.2 diff --git a/_docs/connectors.md b/_docs/connectors.md index 893b334..9b3247e 100644 --- a/_docs/connectors.md +++ b/_docs/connectors.md @@ -1,4 +1,4 @@ -# connectors +# Connectors `ctop` comes with the below native connectors, enabled via the `--connector` option. diff --git a/_docs/img/status.png b/_docs/img/status.png new file mode 100644 index 0000000..86d03e8 Binary files /dev/null and b/_docs/img/status.png differ diff --git a/_docs/status.md b/_docs/status.md new file mode 100644 index 0000000..082a20c --- /dev/null +++ b/_docs/status.md @@ -0,0 +1,30 @@ +# Status Indicator + +The `ctop` grid view provides a compact status indicator to convey container state + +ctop + +### Status + + + +Appearance | Description +--- | --- +red | container is stopped +green | container is running +▮▮ | container is paused + + + +### Health +If the container is configured with a health check, a `+` will appear next to the indicator + + + +Appearance | Description +--- | --- +red | health check in failed state +yellow | health check in starting state +green | health check in OK state + + diff --git a/config/param.go b/config/param.go index d954bdc..8c24e7c 100644 --- a/config/param.go +++ b/config/param.go @@ -17,6 +17,11 @@ var params = []*Param{ Val: "state", Label: "Kubernetes namespace for monitoring", }, + &Param{ + Key: "shell", + Val: "sh", + Label: "Shell", + }, } type Param struct { @@ -35,7 +40,7 @@ func Get(k string) *Param { return &Param{} // default } -// Get Param value by key +// GetVal gets Param value by key func GetVal(k string) string { return Get(k).Val } diff --git a/config/switch.go b/config/switch.go index e209d79..ab3d7e0 100644 --- a/config/switch.go +++ b/config/switch.go @@ -35,7 +35,7 @@ type Switch struct { Label string } -// Return Switch by key +// GetSwitch returns Switch by key func GetSwitch(k string) *Switch { for _, sw := range GlobalSwitches { if sw.Key == k { @@ -45,7 +45,7 @@ func GetSwitch(k string) *Switch { return &Switch{} // default } -// Return Switch value by key +// GetSwitchVal returns Switch value by key func GetSwitchVal(k string) bool { return GetSwitch(k).Val } diff --git a/connector/docker.go b/connector/docker.go index 73ea10d..6595059 100644 --- a/connector/docker.go +++ b/connector/docker.go @@ -17,27 +17,45 @@ type Docker struct { client *api.Client containers map[string]*container.Container needsRefresh chan string // container IDs requiring refresh + closed chan struct{} lock sync.RWMutex } -func NewDocker() Connector { +func NewDocker() (Connector, error) { // init docker client client, err := api.NewClientFromEnv() if err != nil { - panic(err) + return nil, err } cm := &Docker{ client: client, containers: make(map[string]*container.Container), needsRefresh: make(chan string, 60), + closed: make(chan struct{}), lock: sync.RWMutex{}, } + + // query info as pre-flight healthcheck + info, err := client.Info() + if err != nil { + return nil, err + } + + log.Debugf("docker-connector ID: %s", info.ID) + log.Debugf("docker-connector Driver: %s", info.Driver) + log.Debugf("docker-connector Images: %d", info.Images) + log.Debugf("docker-connector Name: %s", info.Name) + log.Debugf("docker-connector ServerVersion: %s", info.ServerVersion) + go cm.Loop() cm.refreshAll() go cm.watchEvents() - return cm + return cm, nil } +// Docker implements Connector +func (cm *Docker) Wait() struct{} { return <-cm.closed } + // Docker events watcher func (cm *Docker) watchEvents() { log.Info("docker event listener starting") @@ -60,6 +78,8 @@ func (cm *Docker) watchEvents() { cm.delByID(e.ID) } } + log.Info("docker event listener exited") + close(cm.closed) } func portsFormat(ports map[api.Port][]api.PortBinding) string { @@ -114,7 +134,7 @@ func (cm *Docker) inspect(id string) *api.Container { c, err := cm.client.InspectContainer(id) if err != nil { if _, ok := err.(*api.NoSuchContainer); !ok { - log.Errorf(err.Error()) + log.Errorf("%s (%T)", err.Error(), err) } } return c @@ -125,7 +145,8 @@ func (cm *Docker) refreshAll() { opts := api.ListContainersOptions{All: true} allContainers, err := cm.client.ListContainers(opts) if err != nil { - panic(err) + log.Errorf("%s (%T)", err.Error(), err) + return } for _, i := range allContainers { @@ -137,13 +158,18 @@ func (cm *Docker) refreshAll() { } func (cm *Docker) Loop() { - for id := range cm.needsRefresh { - c := cm.MustGet(id) - cm.refresh(c) + for { + select { + case id := <-cm.needsRefresh: + c := cm.MustGet(id) + cm.refresh(c) + case <-cm.closed: + return + } } } -// Get a single container, creating one anew if not existing +// MustGet gets a single container, creating one anew if not existing func (cm *Docker) MustGet(id string) *container.Container { c, ok := cm.Get(id) // append container struct for new containers @@ -161,7 +187,7 @@ func (cm *Docker) MustGet(id string) *container.Container { return c } -// Get a single container, by ID +// Docker implements Connector func (cm *Docker) Get(id string) (*container.Container, bool) { cm.lock.Lock() c, ok := cm.containers[id] @@ -177,7 +203,7 @@ func (cm *Docker) delByID(id string) { log.Infof("removed dead container: %s", id) } -// Return array of all containers, sorted by field +// Docker implements Connector func (cm *Docker) All() (containers container.Containers) { cm.lock.Lock() for _, c := range cm.containers { diff --git a/connector/main.go b/connector/main.go index 9162d77..0907a77 100644 --- a/connector/main.go +++ b/connector/main.go @@ -3,6 +3,8 @@ package connector import ( "fmt" "sort" + "sync" + "time" "github.com/bcicen/ctop/container" "github.com/bcicen/ctop/logging" @@ -10,10 +12,80 @@ import ( var ( log = logging.Init() - enabled = make(map[string]func() Connector) + enabled = make(map[string]ConnectorFn) ) -// return names for all enabled connectors on the current platform +type ConnectorFn func() (Connector, error) + +type Connector interface { + // All returns a pre-sorted container.Containers of all discovered containers + All() container.Containers + // Get returns a single container.Container by ID + Get(string) (*container.Container, bool) + // Wait waits for the underlying connection to be lost before returning + Wait() struct{} +} + +// ConnectorSuper provides initial connection and retry on failure for +// an undlerying Connector type +type ConnectorSuper struct { + conn Connector + connFn ConnectorFn + err error + lock sync.RWMutex +} + +func NewConnectorSuper(connFn ConnectorFn) *ConnectorSuper { + cs := &ConnectorSuper{ + connFn: connFn, + err: fmt.Errorf("connecting..."), + } + go cs.loop() + return cs +} + +// Get returns the underlying Connector, or nil and an error +// if the Connector is not yet initialized or is disconnected. +func (cs *ConnectorSuper) Get() (Connector, error) { + cs.lock.RLock() + defer cs.lock.RUnlock() + if cs.err != nil { + return nil, cs.err + } + return cs.conn, nil +} + +func (cs *ConnectorSuper) setError(err error) { + cs.lock.Lock() + defer cs.lock.Unlock() + cs.err = err +} + +func (cs *ConnectorSuper) loop() { + const interval = 3 + for { + log.Infof("initializing connector") + + conn, err := cs.connFn() + if err != nil { + cs.setError(err) + log.Errorf("failed to initialize connector: %s (%T)", err, err) + log.Errorf("retrying in %ds", interval) + time.Sleep(interval * time.Second) + } else { + cs.conn = conn + cs.setError(nil) + log.Infof("successfully initialized connector") + + // wait until connection closed + cs.conn.Wait() + cs.setError(fmt.Errorf("attempting to reconnect...")) + log.Infof("connector closed") + } + } +} + +// Enabled returns names for all enabled connectors on the current platform func Enabled() (a []string) { for k, _ := range enabled { a = append(a, k) @@ -22,14 +94,11 @@ func Enabled() (a []string) { return a } -func ByName(s string) (Connector, error) { +// ByName returns a ConnectorSuper for a given name, or error if the connector +// does not exists on the current platform +func ByName(s string) (*ConnectorSuper, error) { if cfn, ok := enabled[s]; ok { - return cfn(), nil + return NewConnectorSuper(cfn), nil } return nil, fmt.Errorf("invalid connector type \"%s\"", s) } - -type Connector interface { - All() container.Containers - Get(string) (*container.Container, bool) -} diff --git a/connector/manager/docker.go b/connector/manager/docker.go index 77dc987..5b683fc 100644 --- a/connector/manager/docker.go +++ b/connector/manager/docker.go @@ -3,6 +3,9 @@ package manager import ( "fmt" api "github.com/fsouza/go-dockerclient" + "github.com/pkg/errors" + "io" + "os" ) type Docker struct { @@ -17,6 +20,88 @@ func NewDocker(client *api.Client, id string) *Docker { } } +// Do not allow to close reader (i.e. /dev/stdin which docker client tries to close after command execution) +type noClosableReader struct { + io.Reader +} + +func (w *noClosableReader) Read(p []byte) (n int, err error) { + return w.Reader.Read(p) +} + +const ( + STDIN = 0 + STDOUT = 1 + STDERR = 2 +) + +var wrongFrameFormat = errors.New("Wrong frame format") + +// A frame has a Header and a Payload +// Header: [8]byte{STREAM_TYPE, 0, 0, 0, SIZE1, SIZE2, SIZE3, SIZE4} +// STREAM_TYPE can be: +// 0: stdin (is written on stdout) +// 1: stdout +// 2: stderr +// SIZE1, SIZE2, SIZE3, SIZE4 are the four bytes of the uint32 size encoded as big endian. +// But we don't use size, because we don't need to find the end of frame. +type frameWriter struct { + stdout io.Writer + stderr io.Writer + stdin io.Writer +} + +func (w *frameWriter) Write(p []byte) (n int, err error) { + // drop initial empty frames + if len(p) == 0 { + return 0, nil + } + + if len(p) > 8 { + var targetWriter io.Writer + switch p[0] { + case STDIN: + targetWriter = w.stdin + break + case STDOUT: + targetWriter = w.stdout + break + case STDERR: + targetWriter = w.stderr + break + default: + return 0, wrongFrameFormat + } + + n, err := targetWriter.Write(p[8:]) + return n + 8, err + } + + return 0, wrongFrameFormat +} + +func (dc *Docker) Exec(cmd []string) error { + execCmd, err := dc.client.CreateExec(api.CreateExecOptions{ + AttachStdin: true, + AttachStdout: true, + AttachStderr: true, + Cmd: cmd, + Container: dc.id, + Tty: true, + }) + + if err != nil { + return err + } + + return dc.client.StartExec(execCmd.ID, api.StartExecOptions{ + InputStream: &noClosableReader{os.Stdin}, + OutputStream: &frameWriter{os.Stdout, os.Stderr, os.Stdin}, + ErrorStream: os.Stderr, + RawTerminal: true, + }) +} + func (dc *Docker) Start() error { c, err := dc.client.InspectContainer(dc.id) if err != nil { diff --git a/connector/manager/main.go b/connector/manager/main.go index b6debaa..f65aad3 100644 --- a/connector/manager/main.go +++ b/connector/manager/main.go @@ -7,4 +7,5 @@ type Manager interface { Pause() error Unpause() error Restart() error + Exec(cmd []string) error } diff --git a/connector/manager/mock.go b/connector/manager/mock.go index f33fd77..f6fd62f 100644 --- a/connector/manager/mock.go +++ b/connector/manager/mock.go @@ -29,3 +29,7 @@ func (m *Mock) Unpause() error { func (m *Mock) Restart() error { return nil } + +func (m *Mock) Exec(cmd []string) error { + return nil +} diff --git a/connector/manager/runc.go b/connector/manager/runc.go index cf61f14..07a4b58 100644 --- a/connector/manager/runc.go +++ b/connector/manager/runc.go @@ -29,3 +29,7 @@ func (rc *Runc) Unpause() error { func (rc *Runc) Restart() error { return nil } + +func (rc *Runc) Exec(cmd []string) error { + return nil +} diff --git a/connector/mock.go b/connector/mock.go index bfa30d1..d96496b 100644 --- a/connector/mock.go +++ b/connector/mock.go @@ -20,11 +20,11 @@ type Mock struct { containers container.Containers } -func NewMock() Connector { +func NewMock() (Connector, error) { cs := &Mock{} go cs.Init() go cs.Loop() - return cs + return cs, nil } // Create Mock containers @@ -41,6 +41,15 @@ func (cs *Mock) Init() { } +func (cs *Mock) Wait() struct{} { + ch := make(chan struct{}) + go func() { + time.Sleep(30 * time.Second) + close(ch) + }() + return <-ch +} + func (cs *Mock) makeContainer(aggression int64) { collector := collector.NewMock(aggression) manager := manager.NewMock() @@ -73,7 +82,7 @@ func (cs *Mock) Get(id string) (*container.Container, bool) { return nil, false } -// Return array of all containers, sorted by field +// All returns array of all containers, sorted by field func (cs *Mock) All() container.Containers { cs.containers.Sort() cs.containers.Filter() diff --git a/connector/runc.go b/connector/runc.go index 796e2f0..c9f7c87 100644 --- a/connector/runc.go +++ b/connector/runc.go @@ -54,35 +54,44 @@ type Runc struct { factory libcontainer.Factory containers map[string]*container.Container libContainers map[string]libcontainer.Container + closed chan struct{} needsRefresh chan string // container IDs requiring refresh lock sync.RWMutex } -func NewRunc() Connector { +func NewRunc() (Connector, error) { opts, err := NewRuncOpts() - runcFailOnErr(err) + if err != nil { + return nil, err + } factory, err := getFactory(opts) - runcFailOnErr(err) + if err != nil { + return nil, err + } cm := &Runc{ opts: opts, factory: factory, containers: make(map[string]*container.Container), libContainers: make(map[string]libcontainer.Container), - needsRefresh: make(chan string, 60), + closed: make(chan struct{}), lock: sync.RWMutex{}, } go func() { for { - cm.refreshAll() - time.Sleep(5 * time.Second) + select { + case <-cm.closed: + return + case <-time.After(5 * time.Second): + cm.refreshAll() + } } }() go cm.Loop() - return cm + return cm, nil } func (cm *Runc) GetLibc(id string) libcontainer.Container { @@ -141,7 +150,11 @@ func (cm *Runc) refresh(id string) { // Read runc root, creating any new containers func (cm *Runc) refreshAll() { list, err := ioutil.ReadDir(cm.opts.root) - runcFailOnErr(err) + if err != nil { + log.Errorf("%s (%T)", err.Error(), err) + close(cm.closed) + return + } for _, i := range list { if i.IsDir() { @@ -168,7 +181,7 @@ func (cm *Runc) Loop() { } } -// Get a single ctop container in the map matching libc container, creating one anew if not existing +// MustGet gets a single ctop container in the map matching libc container, creating one anew if not existing func (cm *Runc) MustGet(id string) *container.Container { c, ok := cm.Get(id) if !ok { @@ -199,14 +212,6 @@ func (cm *Runc) MustGet(id string) *container.Container { return c } -// Get a single container, by ID -func (cm *Runc) Get(id string) (*container.Container, bool) { - cm.lock.Lock() - defer cm.lock.Unlock() - c, ok := cm.containers[id] - return c, ok -} - // Remove containers by ID func (cm *Runc) delByID(id string) { cm.lock.Lock() @@ -216,7 +221,18 @@ func (cm *Runc) delByID(id string) { log.Infof("removed dead container: %s", id) } -// Return array of all containers, sorted by field +// Runc implements Connector +func (cm *Runc) Wait() struct{} { return <-cm.closed } + +// Runc implements Connector +func (cm *Runc) Get(id string) (*container.Container, bool) { + cm.lock.Lock() + defer cm.lock.Unlock() + c, ok := cm.containers[id] + return c, ok +} + +// Runc implements Connector func (cm *Runc) All() (containers container.Containers) { cm.lock.Lock() for _, c := range cm.containers { @@ -239,9 +255,3 @@ func getFactory(opts RuncOpts) (libcontainer.Factory, error) { } return libcontainer.New(opts.root, cgroupManager) } - -func runcFailOnErr(err error) { - if err != nil { - panic(fmt.Errorf("fatal runc error: %s", err)) - } -} diff --git a/container/main.go b/container/main.go index b4bd0b3..416b878 100644 --- a/container/main.go +++ b/container/main.go @@ -74,7 +74,7 @@ func (c *Container) SetState(s string) { } } -// Return container log collector +// Logs returns container log collector func (c *Container) Logs() collector.LogCollector { return c.collector.Logs() } @@ -153,3 +153,7 @@ func (c *Container) Restart() { } } } + +func (c *Container) Exec(cmd []string) error { + return c.manager.Exec(cmd) +} diff --git a/cursor.go b/cursor.go index aaa1ec9..6c5a6e8 100644 --- a/cursor.go +++ b/cursor.go @@ -11,7 +11,7 @@ import ( type GridCursor struct { selectedID string // id of currently selected container filtered container.Containers - cSource connector.Connector + cSuper *connector.ConnectorSuper isScrolling bool // toggled when actively scrolling } @@ -25,14 +25,20 @@ func (gc *GridCursor) Selected() *container.Container { return nil } -// Refresh containers from source -func (gc *GridCursor) RefreshContainers() (lenChanged bool) { +// Refresh containers from source, returning whether the quantity of +// containers has changed and any error +func (gc *GridCursor) RefreshContainers() (bool, error) { oldLen := gc.Len() - - // Containers filtered by display bool gc.filtered = container.Containers{} + + cSource, err := gc.cSuper.Get() + if err != nil { + return true, err + } + + // filter Containers by display bool var cursorVisible bool - for _, c := range gc.cSource.All() { + for _, c := range cSource.All() { if c.Display { if c.Id == gc.selectedID { cursorVisible = true @@ -41,22 +47,21 @@ func (gc *GridCursor) RefreshContainers() (lenChanged bool) { } } - if oldLen != gc.Len() { - lenChanged = true - } - - if !cursorVisible { + if !cursorVisible || gc.selectedID == "" { gc.Reset() } - if gc.selectedID == "" { - gc.Reset() - } - return lenChanged + + return oldLen != gc.Len(), nil } // Set an initial cursor position, if possible func (gc *GridCursor) Reset() { - for _, c := range gc.cSource.All() { + cSource, err := gc.cSuper.Get() + if err != nil { + return + } + + for _, c := range cSource.All() { c.Widgets.UnHighlight() } if gc.Len() > 0 { @@ -65,7 +70,7 @@ func (gc *GridCursor) Reset() { } } -// Return current cursor index +// Idx returns current cursor index func (gc *GridCursor) Idx() int { for n, c := range gc.filtered { if c.Id == gc.selectedID { diff --git a/cwidgets/compact/status.go b/cwidgets/compact/status.go index 07fb272..edc873c 100644 --- a/cwidgets/compact/status.go +++ b/cwidgets/compact/status.go @@ -5,8 +5,8 @@ import ( ) const ( - mark = string('\u25C9') - healthMark = string('\u207A') + mark = "◉" + healthMark = "✚" vBar = string('\u25AE') + string('\u25AE') ) @@ -18,7 +18,10 @@ type Status struct { } func NewStatus() *Status { - s := &Status{Block: ui.NewBlock()} + s := &Status{ + Block: ui.NewBlock(), + health: []ui.Cell{{Ch: ' '}}, + } s.Height = 1 s.Border = false s.Set("") @@ -28,11 +31,12 @@ func NewStatus() *Status { func (s *Status) Buffer() ui.Buffer { buf := s.Block.Buffer() x := 0 - for _, c := range s.status { + for _, c := range s.health { buf.Set(s.InnerX()+x, s.InnerY(), c) x += c.Width() } - for _, c := range s.health { + x += 1 + for _, c := range s.status { buf.Set(s.InnerX()+x, s.InnerY(), c) x += c.Width() } @@ -53,18 +57,16 @@ func (s *Status) Set(val string) { text = vBar } - var cells []ui.Cell - for _, ch := range text { - cells = append(cells, ui.Cell{Ch: ch, Fg: color}) - } - s.status = cells + s.status = ui.TextCells(text, color, ui.ColorDefault) } func (s *Status) SetHealth(val string) { if val == "" { return } + color := ui.ColorDefault + mark := healthMark switch val { case "healthy", "Succeeded": @@ -75,9 +77,5 @@ func (s *Status) SetHealth(val string) { color = ui.ThemeAttr("status.warn") } - var cells []ui.Cell - for _, ch := range healthMark { - cells = append(cells, ui.Cell{Ch: ch, Fg: color}) - } - s.health = cells + s.health = ui.TextCells(mark, color, ui.ColorDefault) } diff --git a/cwidgets/compact/util.go b/cwidgets/compact/util.go index e634ed9..9bd8f66 100644 --- a/cwidgets/compact/util.go +++ b/cwidgets/compact/util.go @@ -12,7 +12,7 @@ const colSpacing = 1 // per-column width. 0 == auto width var colWidths = []int{ - 3, // status + 5, // status 0, // name 0, // cid 0, // cpu diff --git a/cwidgets/single/main.go b/cwidgets/single/main.go index 934e212..8140b53 100644 --- a/cwidgets/single/main.go +++ b/cwidgets/single/main.go @@ -70,7 +70,7 @@ func (e *Single) SetMetrics(m models.Metrics) { e.IO.Update(m.IOBytesRead, m.IOBytesWrite) } -// Return total column height +// GetHeight returns total column height func (e *Single) GetHeight() (h int) { h += e.Info.Height h += e.Net.Height diff --git a/go.mod b/go.mod index 1d36d0f..56db41f 100644 --- a/go.mod +++ b/go.mod @@ -1,13 +1,12 @@ module github.com/bcicen/ctop require ( - github.com/Azure/go-ansiterm v0.0.0-20160622173216-fa152c58bc15 // indirect github.com/BurntSushi/toml v0.3.0 - github.com/Microsoft/go-winio v0.3.8 // indirect - github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 // indirect - github.com/Sirupsen/logrus v0.0.0-20150423025312-26709e271410 // indirect github.com/c9s/goprocinfo v0.0.0-20170609001544-b34328d6e0cd + github.com/checkpoint-restore/go-criu v0.0.0-20190109184317-bdb7599cd87b // indirect + github.com/containerd/console v0.0.0-20181022165439-0650fd9eeb50 // indirect github.com/coreos/go-systemd v0.0.0-20151104194251-b4a58d95188d // indirect +<<<<<<< HEAD github.com/davecgh/go-spew v1.1.1 // indirect github.com/docker/docker v0.0.0-20170502054910-90d35abf7b35 // indirect github.com/docker/go-connections v0.0.0-20170301234100-a2afab980204 // indirect @@ -56,6 +55,12 @@ require ( k8s.io/klog v0.1.0 // indirect k8s.io/metrics v0.0.0-20181121073115-d8618695b08f sigs.k8s.io/yaml v1.1.0 // indirect + github.com/cyphar/filepath-securejoin v0.2.2 // indirect + github.com/mrunalp/fileutils v0.0.0-20171103030105-7d4729fb3618 // indirect + github.com/opencontainers/runtime-spec v1.0.1 // indirect + github.com/opencontainers/selinux v1.2.2 // indirect + github.com/pkg/errors v0.8.1 + github.com/syndtr/gocapability v0.0.0-20180916011248-d98352740cb2 // indirect ) replace github.com/gizak/termui => github.com/bcicen/termui v0.0.0-20180326052246-4eb80249d3f5 diff --git a/grid.go b/grid.go index 501c216..655e000 100644 --- a/grid.go +++ b/grid.go @@ -6,6 +6,44 @@ import ( ui "github.com/gizak/termui" ) +func ShowConnError(err error) (exit bool) { + ui.Clear() + ui.DefaultEvtStream.ResetHandlers() + defer ui.DefaultEvtStream.ResetHandlers() + + setErr := func(err error) { + errView.Append(err.Error()) + errView.Append("attempting to reconnect...") + ui.Render(errView) + } + + HandleKeys("exit", func() { + exit = true + ui.StopLoop() + }) + + ui.Handle("/timer/1s", func(ui.Event) { + _, err := cursor.RefreshContainers() + if err == nil { + ui.StopLoop() + return + } + setErr(err) + }) + + ui.Handle("/sys/wnd/resize", func(e ui.Event) { + errView.Resize() + ui.Clear() + ui.Render(errView) + log.Infof("RESIZE") + }) + + errView.Resize() + setErr(err) + ui.Loop() + return exit +} + func RedrawRows(clr bool) { // reinit body rows cGrid.Clear() @@ -33,7 +71,6 @@ func RedrawRows(clr bool) { } cGrid.Align() ui.Render(cGrid) - } func SingleView() MenuFn { @@ -68,16 +105,21 @@ func SingleView() MenuFn { return nil } -func RefreshDisplay() { +func RefreshDisplay() error { // skip display refresh during scroll if !cursor.isScrolling { - needsClear := cursor.RefreshContainers() + needsClear, err := cursor.RefreshContainers() + if err != nil { + return err + } RedrawRows(needsClear) } + return nil } func Display() bool { var menu MenuFn + var connErr error cGrid.SetWidth(ui.TermWidth()) ui.DefaultEvtStream.Hook(logEvent) @@ -116,13 +158,20 @@ func Display() bool { menu = LogMenu ui.StopLoop() }) + ui.Handle("/sys/kbd/e", func(ui.Event) { + menu = ExecShell + ui.StopLoop() + }) ui.Handle("/sys/kbd/o", func(ui.Event) { menu = SingleView ui.StopLoop() }) ui.Handle("/sys/kbd/a", func(ui.Event) { config.Toggle("allContainers") - RefreshDisplay() + connErr = RefreshDisplay() + if connErr != nil { + ui.StopLoop() + } }) ui.Handle("/sys/kbd/D", func(ui.Event) { dumpContainer(cursor.Selected()) @@ -156,7 +205,10 @@ func Display() bool { if log.StatusQueued() { ui.StopLoop() } - RefreshDisplay() + connErr = RefreshDisplay() + if connErr != nil { + ui.StopLoop() + } }) ui.Handle("/sys/wnd/resize", func(e ui.Event) { @@ -170,6 +222,10 @@ func Display() bool { ui.Loop() + if connErr != nil { + return ShowConnError(connErr) + } + if log.StatusQueued() { for sm := range log.FlushStatus() { if sm.IsError { diff --git a/main.go b/main.go index ef0f18a..bd653fe 100644 --- a/main.go +++ b/main.go @@ -22,11 +22,12 @@ var ( version = "dev-build" goVersion = runtime.Version() - log *logging.CTopLogger - cursor *GridCursor - cGrid *compact.CompactGrid - header *widgets.CTopHeader - status *widgets.StatusLine + log *logging.CTopLogger + cursor *GridCursor + cGrid *compact.CompactGrid + header *widgets.CTopHeader + status *widgets.StatusLine + errView *widgets.ErrorView versionStr = fmt.Sprintf("ctop version %v, build %v %v", version, build, goVersion) @@ -87,6 +88,10 @@ func main() { config.Toggle("scaleCpu") } + if *defaultShell != "" { + config.Update("shell", *defaultShell) + } + // init ui if *invertFlag { InvertColorMap() @@ -100,14 +105,15 @@ func main() { defer Shutdown() // init grid, cursor, header - conn, err := connector.ByName(*connectorFlag) + cSuper, err := connector.ByName(*connectorFlag) if err != nil { panic(err) } - cursor = &GridCursor{cSource: conn} + cursor = &GridCursor{cSuper: cSuper} cGrid = compact.NewCompactGrid() header = widgets.NewCTopHeader() status = widgets.NewStatusLine() + errView = widgets.NewErrorView() for { exit := Display() @@ -136,6 +142,7 @@ func validSort(s string) { func panicExit() { if r := recover(); r != nil { Shutdown() + panic(r) fmt.Printf("error: %s\n", r) os.Exit(1) } diff --git a/menus.go b/menus.go index 0c819c7..16c18b7 100644 --- a/menus.go +++ b/menus.go @@ -25,6 +25,7 @@ var helpDialog = []menu.Item{ {"[r] - reverse container sort order", ""}, {"[o] - open single view", ""}, {"[l] - view container logs ([t] to toggle timestamp when open)", ""}, + {"[e] - exec shell", ""}, {"[S] - save current configuration to file", ""}, {"[q] - exit ctop", ""}, } @@ -126,55 +127,111 @@ func ContainerMenu() MenuFn { m.BorderLabel = "Menu" items := []menu.Item{ - menu.Item{Val: "single", Label: "single view"}, - menu.Item{Val: "logs", Label: "log view"}, + menu.Item{Val: "single", Label: "[o] single view"}, + menu.Item{Val: "logs", Label: "[l] log view"}, } if c.Meta["state"] == "running" { - items = append(items, menu.Item{Val: "stop", Label: "stop"}) - items = append(items, menu.Item{Val: "pause", Label: "pause"}) - items = append(items, menu.Item{Val: "restart", Label: "restart"}) + items = append(items, menu.Item{Val: "stop", Label: "[s] stop"}) + items = append(items, menu.Item{Val: "pause", Label: "[p] pause"}) + items = append(items, menu.Item{Val: "restart", Label: "[r] restart"}) + items = append(items, menu.Item{Val: "exec", Label: "[e] exec shell"}) } if c.Meta["state"] == "exited" || c.Meta["state"] == "created" { - items = append(items, menu.Item{Val: "start", Label: "start"}) - items = append(items, menu.Item{Val: "remove", Label: "remove"}) + items = append(items, menu.Item{Val: "start", Label: "[s] start"}) + items = append(items, menu.Item{Val: "remove", Label: "[R] remove"}) } if c.Meta["state"] == "paused" { - items = append(items, menu.Item{Val: "unpause", Label: "unpause"}) + items = append(items, menu.Item{Val: "unpause", Label: "[p] unpause"}) } - items = append(items, menu.Item{Val: "cancel", Label: "cancel"}) + items = append(items, menu.Item{Val: "cancel", Label: "[c] cancel"}) m.AddItems(items...) ui.Render(m) - var nextMenu MenuFn HandleKeys("up", m.Up) HandleKeys("down", m.Down) + + var selected string + + // shortcuts + ui.Handle("/sys/kbd/o", func(ui.Event) { + selected = "single" + ui.StopLoop() + }) + ui.Handle("/sys/kbd/l", func(ui.Event) { + selected = "logs" + ui.StopLoop() + }) + if c.Meta["state"] != "paused" { + ui.Handle("/sys/kbd/s", func(ui.Event) { + if c.Meta["state"] == "running" { + selected = "stop" + } else { + selected = "start" + } + ui.StopLoop() + }) + } + if c.Meta["state"] != "exited" || c.Meta["state"] != "created" { + ui.Handle("/sys/kbd/p", func(ui.Event) { + if c.Meta["state"] == "paused" { + selected = "unpause" + } else { + selected = "pause" + } + ui.StopLoop() + }) + } + if c.Meta["state"] == "running" { + ui.Handle("/sys/kbd/e", func(ui.Event) { + selected = "exec" + ui.StopLoop() + }) + ui.Handle("/sys/kbd/r", func(ui.Event) { + selected = "restart" + ui.StopLoop() + }) + } + ui.Handle("/sys/kbd/R", func(ui.Event) { + selected = "remove" + ui.StopLoop() + }) + ui.Handle("/sys/kbd/c", func(ui.Event) { + ui.StopLoop() + }) + ui.Handle("/sys/kbd/", func(ui.Event) { - switch m.SelectedItem().Val { - case "single": - nextMenu = SingleView - case "logs": - nextMenu = LogMenu - case "start": - nextMenu = Confirm(confirmTxt("start", c.GetMeta("name")), c.Start) - case "stop": - nextMenu = Confirm(confirmTxt("stop", c.GetMeta("name")), c.Stop) - case "remove": - nextMenu = Confirm(confirmTxt("remove", c.GetMeta("name")), c.Remove) - case "pause": - nextMenu = Confirm(confirmTxt("pause", c.GetMeta("name")), c.Pause) - case "unpause": - nextMenu = Confirm(confirmTxt("unpause", c.GetMeta("name")), c.Unpause) - case "restart": - nextMenu = Confirm(confirmTxt("restart", c.GetMeta("name")), c.Restart) - } + selected = m.SelectedItem().Val ui.StopLoop() }) ui.Handle("/sys/kbd/", func(ui.Event) { ui.StopLoop() }) ui.Loop() + + var nextMenu MenuFn + switch selected { + case "single": + nextMenu = SingleView + case "logs": + nextMenu = LogMenu + case "exec": + nextMenu = ExecShell + case "start": + nextMenu = Confirm(confirmTxt("start", c.GetMeta("name")), c.Start) + case "stop": + nextMenu = Confirm(confirmTxt("stop", c.GetMeta("name")), c.Stop) + case "remove": + nextMenu = Confirm(confirmTxt("remove", c.GetMeta("name")), c.Remove) + case "pause": + nextMenu = Confirm(confirmTxt("pause", c.GetMeta("name")), c.Pause) + case "unpause": + nextMenu = Confirm(confirmTxt("unpause", c.GetMeta("name")), c.Unpause) + case "restart": + nextMenu = Confirm(confirmTxt("restart", c.GetMeta("name")), c.Restart) + } + return nextMenu } @@ -207,6 +264,24 @@ func LogMenu() MenuFn { return nil } +func ExecShell() MenuFn { + c := cursor.Selected() + + if c == nil { + return nil + } + + ui.DefaultEvtStream.ResetHandlers() + defer ui.DefaultEvtStream.ResetHandlers() + + shell := config.Get("shell") + if err := c.Exec([]string{shell.Val, "-c", "printf '\\e[0m\\e[?25h' && clear && " + shell.Val}); err != nil { + log.Fatal(err) + } + + return nil +} + // Create a confirmation dialog with a given description string and // func to perform if confirmed func Confirm(txt string, fn func()) MenuFn { diff --git a/prepare-minikube.sh b/prepare-minikube.sh new file mode 100644 index 0000000..ae64a59 --- /dev/null +++ b/prepare-minikube.sh @@ -0,0 +1,6 @@ +#/bin/sh + +curl -Lo minikube https://storage.googleapis.com/minikube/releases/latest/minikube-linux-amd64 \ + && chmod +x minikube + +sudo install minikube /usr/local/bin diff --git a/widgets/error.go b/widgets/error.go new file mode 100644 index 0000000..7f6a4a9 --- /dev/null +++ b/widgets/error.go @@ -0,0 +1,60 @@ +package widgets + +import ( + "fmt" + "strings" + "time" + + ui "github.com/gizak/termui" +) + +type ErrorView struct { + *ui.Par + lines []string +} + +func NewErrorView() *ErrorView { + const yPad = 1 + const xPad = 2 + + p := ui.NewPar("") + p.X = xPad + p.Y = yPad + p.Border = true + p.Height = 10 + p.Width = 20 + p.PaddingTop = yPad + p.PaddingBottom = yPad + p.PaddingLeft = xPad + p.PaddingRight = xPad + p.BorderLabel = " ctop - error " + p.Bg = ui.ThemeAttr("bg") + p.TextFgColor = ui.ThemeAttr("status.warn") + p.TextBgColor = ui.ThemeAttr("menu.text.bg") + p.BorderFg = ui.ThemeAttr("status.warn") + p.BorderLabelFg = ui.ThemeAttr("status.warn") + return &ErrorView{p, make([]string, 0, 50)} +} + +func (w *ErrorView) Append(s string) { + if len(w.lines)+2 >= cap(w.lines) { + w.lines = append(w.lines[:0], w.lines[2:]...) + } + ts := time.Now().Local().Format("15:04:05 MST") + w.lines = append(w.lines, fmt.Sprintf("[%s] %s", ts, s)) + w.lines = append(w.lines, "") +} + +func (w *ErrorView) Buffer() ui.Buffer { + offset := len(w.lines) - w.InnerHeight() + if offset < 0 { + offset = 0 + } + w.Text = strings.Join(w.lines[offset:len(w.lines)], "\n") + return w.Par.Buffer() +} + +func (w *ErrorView) Resize() { + w.Height = ui.TermHeight() - (w.PaddingTop + w.PaddingBottom) + w.SetWidth(ui.TermWidth() - (w.PaddingLeft + w.PaddingRight)) +} diff --git a/widgets/header.go b/widgets/header.go index 4a96f8f..a7ab786 100644 --- a/widgets/header.go +++ b/widgets/header.go @@ -16,7 +16,7 @@ type CTopHeader struct { func NewCTopHeader() *CTopHeader { return &CTopHeader{ - Time: headerPar(2, timeStr()), + Time: headerPar(2, ""), Count: headerPar(24, "-"), Filter: headerPar(40, ""), bg: headerBg(), diff --git a/widgets/menu/main.go b/widgets/menu/main.go index a2e6f2a..4e4e83b 100644 --- a/widgets/menu/main.go +++ b/widgets/menu/main.go @@ -42,7 +42,7 @@ func (m *Menu) AddItems(items ...Item) { m.refresh() } -// Remove menu item by value or label +// DelItem removes menu item by value or label func (m *Menu) DelItem(s string) (success bool) { for n, i := range m.items { if i.Val == s || i.Label == s {