Compare commits

..

1 Commits

Author SHA1 Message Date
Kevin Schoon
47b27a7786 vendor dependencies with glide 2017-03-13 08:05:40 +11:00
75 changed files with 587 additions and 2986 deletions

View File

@@ -1,19 +0,0 @@
version: 2
jobs:
build:
working_directory: ~/build
docker:
- image: circleci/golang:latest
steps:
- checkout
- setup_remote_docker:
version: 17.05.0-ce
- run: make image
- deploy:
command: |
if [[ "$CIRCLE_BRANCH" == "master" ]]; then
docker tag ctop quay.io/vektorlab/ctop:latest
docker tag ctop quay.io/vektorlab/ctop:$(cat VERSION)
docker login -u $DOCKER_USER -p $DOCKER_PASS quay.io
docker push quay.io/vektorlab/ctop
fi

3
.gitignore vendored
View File

@@ -1,3 +0,0 @@
ctop
.idea
/vendor/

View File

@@ -1,17 +1,7 @@
FROM quay.io/vektorcloud/go:1.11
FROM quay.io/vektorcloud/glibc:latest
RUN apk add --no-cache make
RUN ctop_url=$(wget -q -O - https://api.github.com/repos/bcicen/ctop/releases/latest | grep 'browser_' | cut -d\" -f4 |grep 'linux-amd64') && \
wget -q $ctop_url -O /ctop && \
chmod +x /ctop
WORKDIR /app
COPY go.mod .
RUN go mod download
COPY . .
RUN make build && \
mkdir -p /go/bin && \
mv -v ctop /go/bin/
FROM scratch
ENV TERM=linux
COPY --from=0 /go/bin/ctop /ctop
ENTRYPOINT ["/ctop"]

View File

@@ -1,37 +0,0 @@
NAME=ctop
VERSION=$(shell cat VERSION)
BUILD=$(shell git rev-parse --short HEAD)
EXT_LD_FLAGS="-Wl,--allow-multiple-definition"
LD_FLAGS="-w -X main.version=$(VERSION) -X main.build=$(BUILD) -extldflags=$(EXT_LD_FLAGS)"
clean:
rm -rf _build/ release/
build:
go mod download
CGO_ENABLED=0 go build -tags release -ldflags $(LD_FLAGS) -o ctop
build-dev:
go build -ldflags "-w -X main.version=$(VERSION)-dev -X main.build=$(BUILD) -extldflags=$(EXT_LD_FLAGS)"
build-all:
mkdir -p _build
GOOS=darwin GOARCH=amd64 go build -tags release -ldflags $(LD_FLAGS) -o _build/ctop-$(VERSION)-darwin-amd64
GOOS=linux GOARCH=amd64 go build -tags release -ldflags $(LD_FLAGS) -o _build/ctop-$(VERSION)-linux-amd64
GOOS=linux GOARCH=arm go build -tags release -ldflags $(LD_FLAGS) -o _build/ctop-$(VERSION)-linux-arm
GOOS=linux GOARCH=arm64 go build -tags release -ldflags $(LD_FLAGS) -o _build/ctop-$(VERSION)-linux-arm64
GOOS=windows GOARCH=amd64 go build -tags release -ldflags $(LD_FLAGS) -o _build/ctop-$(VERSION)-windows-amd64
cd _build; sha256sum * > sha256sums.txt
image:
docker build -t ctop -f Dockerfile .
release:
mkdir release
go get github.com/progrium/gh-release/...
cp _build/* release
cd release; sha256sum --quiet --check sha256sums.txt
gh-release create bcicen/$(NAME) $(VERSION) \
$(shell git rev-parse --abbrev-ref HEAD) $(VERSION)
.PHONY: build

View File

@@ -1,17 +1,14 @@
<p align="center"><img width="200px" src="/_docs/img/logo.png" alt="ctop"/></p>
#
![release][release] ![homebrew][homebrew]
Top-like interface for container metrics
`ctop` provides a concise and condensed overview of real-time metrics for multiple containers:
<p align="center"><img src="_docs/img/grid.gif" alt="ctop"/></p>
as well as an [single container view][single_view] for inspecting a specific container.
as well as an [expanded view][expanded_view] for inspecting a specific container.
`ctop` comes with built-in support for Docker and runC; connectors for other container and cluster systems are planned for future releases.
`ctop` currently comes with built-in support for Docker; connectors for other container and cluster systems are planned for future releases.
## Install
@@ -20,75 +17,53 @@ 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.2/ctop-0.7.2-linux-amd64 -O /usr/local/bin/ctop
wget https://github.com/bcicen/ctop/releases/download/v0.4.1/ctop-0.4.1-linux-amd64 -O ctop
sudo mv ctop /usr/local/bin/
sudo chmod +x /usr/local/bin/ctop
```
#### OS X
```bash
brew install ctop
```
or
```bash
sudo curl -Lo /usr/local/bin/ctop https://github.com/bcicen/ctop/releases/download/v0.7.2/ctop-0.7.2-darwin-amd64
curl -Lo ctop https://github.com/bcicen/ctop/releases/download/v0.4.1/ctop-0.4.1-darwin-amd64
sudo mv ctop /usr/local/bin/
sudo chmod +x /usr/local/bin/ctop
```
#### Docker
or run via Docker:
```bash
docker run --rm -ti \
--name=ctop \
-v /var/run/docker.sock:/var/run/docker.sock \
quay.io/vektorlab/ctop:latest
docker run -ti -v /var/run/docker.sock:/var/run/docker.sock quay.io/vektorlab/ctop:latest
```
`ctop` is also available for Arch in the [AUR](https://aur.archlinux.org/packages/ctop-bin/)
`ctop` is also available for Arch in the [AUR](https://aur.archlinux.org/packages/ctop/)
## Building
Build steps can be found [here][build].
To build `ctop` from source ensure you have a recent version of [glide](http://glide.sh/) installed.
```bash
cd $GOPATH/src/github.com/bcicen/ctop
glide install
```
## Usage
`ctop` requires no arguments and uses Docker host variables by default. See [connectors][connectors] for further configuration options.
### Config file
While running, use `S` to save the current filters, sort field, and other options to a default config path. These settings will be loaded and applied the next time `ctop` is started.
### Options
Option | Description
--- | ---
-a | show active containers only
-f \<string\> | set an initial filter string
-h | display help dialog
-i | invert default colors
-r | reverse container sort order
-s | select initial container sort field
-scale-cpu | show cpu as % of system total
-v | output version information and exit
`ctop` requires no arguments and will configure itself using the `DOCKER_HOST` environment variable
```bash
export DOCKER_HOST=tcp://127.0.0.1:4243
ctop
```
### Keybindings
Key | Action
--- | ---
\<enter\> | Open container menu
a | Toggle display of all (running and non-running) containers
f | Filter displayed containers (`esc` to clear when open)
f | Filter displayed containers
H | Toggle ctop header
h | Open help dialog
s | Select container sort field
r | Reverse container sort order
o | Open single view
l | View container logs (`t` to toggle timestamp when open)
S | Save current configuration to file
q | Quit ctop
[build]: _docs/build.md
[connectors]: _docs/connectors.md
[single_view]: _docs/single.md
[release]: https://img.shields.io/github/release/bcicen/ctop.svg "ctop"
[homebrew]: https://img.shields.io/homebrew/v/ctop.svg "ctop"
[expanded_view]: _docs/expanded.md

View File

@@ -1 +1 @@
0.7.2
0.4.1

View File

@@ -1,20 +0,0 @@
# Build
To build `ctop` from source, ensure you have [dep](https://github.com/golang/dep) installed and run:
```bash
go get github.com/bcicen/ctop && \
cd $GOPATH/src/github.com/bcicen/ctop && \
make build
```
To build a minimal Docker image containing only `ctop`:
```bash
make image
```
Now you can run your local image:
```bash
docker run -ti --name ctop --rm -v /var/run/docker.sock:/var/run/docker.sock ctop
```

View File

@@ -1,26 +0,0 @@
# connectors
`ctop` comes with the below native connectors, enabled via the `--connector` option.
Default connector behavior can be changed by setting the relevant environment variables.
## Docker
Default connector, configurable via standard [Docker commandline varaibles](https://docs.docker.com/engine/reference/commandline/cli/#environment-variables)
#### Options
Var | Description
--- | ---
DOCKER_HOST | Daemon socket to connect to (default: `unix://var/run/docker.sock`)
## RunC
Using this connector requires full privileges to the local runC root dir of container state (default: `/run/runc`)
#### Options
Var | Description
--- | ---
RUNC_ROOT | path to runc root for container state (default: `/run/runc`)
RUNC_SYSTEMD_CGROUP | if set, enable systemd cgroups

View File

@@ -1,56 +0,0 @@
# Debug Mode
`ctop` comes with a built-in logging facility and local socket server to simplify debugging at run time.
## Quick Start
If running `ctop` via Docker, debug logging can be most easily enabled as below:
```bash
docker run -ti --rm \
--name=ctop \
-e CTOP_DEBUG=1 \
-e CTOP_DEBUG_TCP=1 \
-p 9000:9000 \
-v /var/run/docker.sock:/var/run/docker.sock \
quay.io/vektorlab/ctop:latest
```
Log messages can be followed by connecting to the default listen address:
```bash
curl -s localhost:9000
```
example output:
```
15:06:43.881 ▶ NOTI 002 logger initialized
15:06:43.881 ▶ INFO 003 loaded config param: "filterStr": ""
15:06:43.881 ▶ INFO 004 loaded config param: "sortField": "state"
15:06:43.881 ▶ INFO 005 loaded config switch: "sortReversed": false
15:06:43.881 ▶ INFO 006 loaded config switch: "allContainers": true
15:06:43.881 ▶ INFO 007 loaded config switch: "enableHeader": true
15:06:43.883 ▶ INFO 008 collector started for container: 7120f83ca...
...
```
## Unix Socket
Debug mode is enabled via the `CTOP_DEBUG` environment variable:
```bash
CTOP_DEBUG=1 ./ctop
```
While `ctop` is running, you can connect to the logging socket via socat or similar tools:
```bash
socat unix-connect:./ctop.sock stdio
```
## TCP Logging Socket
In lieu of using a local unix socket, TCP logging can be enabled via the `CTOP_DEBUG_TCP` environment variable:
```bash
CTOP_DEBUG=1 CTOP_DEBUG_TCP=1 ./ctop
```
A TCP listener for streaming log messages will be started on the default listen address(`0.0.0.0:9000`)

4
_docs/expanded.md Normal file
View File

@@ -0,0 +1,4 @@
# Expanded View
ctop provides an expanded, rolling view for following container metrics
<p align="center"><img width="80%" src="img/expanded.gif" alt="ctop"/></p>

View File

Before

Width:  |  Height:  |  Size: 549 KiB

After

Width:  |  Height:  |  Size: 549 KiB

View File

@@ -1,4 +0,0 @@
# Single Container View
ctop provides a rolling, single container view for following metrics
<p align="center"><img width="80%" src="img/single.gif" alt="ctop"/></p>

24
circle.yml Normal file
View File

@@ -0,0 +1,24 @@
machine:
services:
- docker
dependencies:
override:
- docker info
- |
if [[ "$CIRCLE_BRANCH" == "master" ]]; then
docker build -t quay.io/vektorlab/ctop:latest .
else
docker build -t quay.io/vektorlab/ctop:${CIRCLE_BRANCH} .
fi
test:
override:
- docker run -t --entrypoint /bin/sh quay.io/vektorlab/ctop:latest -v
deployment:
hub:
branch: master
commands:
- docker login -e $DOCKER_EMAIL -u $DOCKER_USER -p $DOCKER_PASS quay.io
- docker push quay.io/vektorlab/ctop:latest

View File

@@ -1,10 +1,6 @@
package main
import (
"regexp"
ui "github.com/gizak/termui"
)
import ui "github.com/gizak/termui"
/*
Valid colors:
@@ -42,20 +38,6 @@ var ColorMap = map[string]ui.Attribute{
"mbarchart.text.fg": ui.ColorWhite,
"par.text.fg": ui.ColorWhite,
"par.text.bg": ui.ColorDefault,
"par.text.hi": ui.ColorBlack,
"sparkline.line.fg": ui.ColorGreen,
"sparkline.title.fg": ui.ColorWhite,
"status.ok": ui.ColorGreen,
"status.warn": ui.ColorYellow,
"status.danger": ui.ColorRed,
}
func InvertColorMap() {
re := regexp.MustCompile(".*.fg")
for k := range ColorMap {
if re.FindAllString(k, 1) != nil {
ColorMap[k] = ui.ColorBlack
}
}
ColorMap["par.text.hi"] = ui.ColorWhite
}

View File

@@ -1,119 +0,0 @@
package config
import (
"fmt"
"os"
"regexp"
"strings"
"github.com/BurntSushi/toml"
)
var (
xdgRe = regexp.MustCompile("^XDG_*")
)
type File struct {
Options map[string]string `toml:"options"`
Toggles map[string]bool `toml:"toggles"`
}
func exportConfig() File {
c := File{
Options: make(map[string]string),
Toggles: make(map[string]bool),
}
for _, p := range GlobalParams {
c.Options[p.Key] = p.Val
}
for _, sw := range GlobalSwitches {
c.Toggles[sw.Key] = sw.Val
}
return c
}
func Read() error {
var config File
path, err := getConfigPath()
if err != nil {
return err
}
if _, err := toml.DecodeFile(path, &config); err != nil {
return err
}
for k, v := range config.Options {
Update(k, v)
}
for k, v := range config.Toggles {
UpdateSwitch(k, v)
}
return nil
}
func Write() (path string, err error) {
path, err = getConfigPath()
if err != nil {
return path, err
}
cfgdir := basedir(path)
// create config dir if not exist
if _, err := os.Stat(cfgdir); err != nil {
err = os.MkdirAll(cfgdir, 0755)
if err != nil {
return path, fmt.Errorf("failed to create config dir [%s]: %s", cfgdir, err)
}
}
file, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0644)
if err != nil {
return path, fmt.Errorf("failed to open config for writing: %s", err)
}
writer := toml.NewEncoder(file)
err = writer.Encode(exportConfig())
if err != nil {
return path, fmt.Errorf("failed to write config: %s", err)
}
return path, nil
}
// determine config path from environment
func getConfigPath() (path string, err error) {
homeDir, ok := os.LookupEnv("HOME")
if !ok {
return path, fmt.Errorf("$HOME not set")
}
// use xdg config home if possible
if xdgSupport() {
xdgHome, ok := os.LookupEnv("XDG_CONFIG_HOME")
if !ok {
xdgHome = fmt.Sprintf("%s/.config", homeDir)
}
path = fmt.Sprintf("%s/ctop/config", xdgHome)
} else {
path = fmt.Sprintf("%s/.ctop", homeDir)
}
return path, nil
}
// test for environemnt supporting XDG spec
func xdgSupport() bool {
for _, e := range os.Environ() {
if xdgRe.FindAllString(e, 1) != nil {
return true
}
}
return false
}
func basedir(path string) string {
parts := strings.Split(path, "/")
return strings.Join((parts[0 : len(parts)-1]), "/")
}

View File

@@ -18,6 +18,7 @@ func Init() {
GlobalParams = append(GlobalParams, p)
log.Infof("loaded config param: %s: %s", quote(p.Key), quote(p.Val))
}
for _, s := range switches {
GlobalSwitches = append(GlobalSwitches, s)
log.Infof("loaded config switch: %s: %t", quote(s.Key), s.Val)

View File

@@ -5,27 +5,22 @@ var switches = []*Switch{
&Switch{
Key: "sortReversed",
Val: false,
Label: "Reverse sort order",
Label: "Reverse Sort Order",
},
&Switch{
Key: "allContainers",
Val: true,
Label: "Show all containers",
},
&Switch{
Key: "fullRowCursor",
Val: true,
Label: "Highlight entire cursor row (vs. name only)",
Label: "Show All Containers",
},
&Switch{
Key: "enableHeader",
Val: true,
Label: "Enable status header",
Label: "Enable Status Header",
},
&Switch{
Key: "scaleCpu",
Key: "loggingEnabled",
Val: false,
Label: "Show CPU as %% of system total",
Label: "Enable Logging Server",
},
}
@@ -50,18 +45,10 @@ func GetSwitchVal(k string) bool {
return GetSwitch(k).Val
}
func UpdateSwitch(k string, val bool) {
sw := GetSwitch(k)
if sw.Val != val {
log.Noticef("config change: %s: %t -> %t", k, sw.Val, val)
sw.Val = val
}
}
// Toggle a boolean switch
func Toggle(k string) {
sw := GetSwitch(k)
newVal := !sw.Val
newVal := sw.Val != true
log.Noticef("config change: %s: %t -> %t", k, sw.Val, newVal)
sw.Val = newVal
//log.Errorf("ignoring toggle for non-existant switch: %s", k)

View File

@@ -1,82 +0,0 @@
package collector
import (
"bufio"
"context"
"io"
"strings"
"time"
"github.com/bcicen/ctop/models"
api "github.com/fsouza/go-dockerclient"
)
type DockerLogs struct {
id string
client *api.Client
done chan bool
}
func NewDockerLogs(id string, client *api.Client) *DockerLogs {
return &DockerLogs{
id: id,
client: client,
done: make(chan bool),
}
}
func (l *DockerLogs) Stream() chan models.Log {
r, w := io.Pipe()
logCh := make(chan models.Log)
ctx, cancel := context.WithCancel(context.Background())
opts := api.LogsOptions{
Context: ctx,
Container: l.id,
OutputStream: w,
ErrorStream: w,
Stdout: true,
Stderr: true,
Tail: "10",
Follow: true,
Timestamps: true,
}
// read io pipe into channel
go func() {
scanner := bufio.NewScanner(r)
for scanner.Scan() {
parts := strings.Split(scanner.Text(), " ")
ts := l.parseTime(parts[0])
logCh <- models.Log{Timestamp: ts, Message: strings.Join(parts[1:], " ")}
}
}()
// connect to container log stream
go func() {
err := l.client.Logs(opts)
if err != nil {
log.Errorf("error reading container logs: %s", err)
}
log.Infof("log reader stopped for container: %s", l.id)
}()
go func() {
<-l.done
cancel()
}()
log.Infof("log reader started for container: %s", l.id)
return logCh
}
func (l *DockerLogs) Stop() { l.done <- true }
func (l *DockerLogs) parseTime(s string) time.Time {
ts, err := time.Parse("2006-01-02T15:04:05.000000000Z", s)
if err != nil {
log.Errorf("failed to parse container log: %s", err)
ts = time.Now()
}
return ts
}

View File

@@ -1,35 +0,0 @@
package collector
import (
"math"
"github.com/bcicen/ctop/logging"
"github.com/bcicen/ctop/models"
)
var log = logging.Init()
type LogCollector interface {
Stream() chan models.Log
Stop()
}
type Collector interface {
Stream() chan models.Metrics
Logs() LogCollector
Running() bool
Start()
Stop()
}
func round(num float64) int {
return int(num + math.Copysign(0.5, num))
}
// return rounded percentage
func percent(val float64, total float64) int {
if total <= 0 {
return 0
}
return round((val / total) * 100)
}

View File

@@ -1,31 +0,0 @@
package collector
import (
"time"
"github.com/bcicen/ctop/models"
)
const mockLog = "Cura ob pro qui tibi inveni dum qua fit donec amare illic mea, regem falli contexo pro peregrinorum heremo absconditi araneae meminerim deliciosas actionibus facere modico dura sonuerunt psalmi contra rerum, tempus mala anima volebant dura quae o modis."
type MockLogs struct {
done chan bool
}
func (l *MockLogs) Stream() chan models.Log {
logCh := make(chan models.Log)
go func() {
for {
select {
case <-l.done:
break
default:
logCh <- models.Log{Timestamp: time.Now(), Message: mockLog}
time.Sleep(250 * time.Millisecond)
}
}
}()
return logCh
}
func (l *MockLogs) Stop() { l.done <- true }

View File

@@ -1,44 +0,0 @@
// +build linux
package collector
import (
linuxproc "github.com/c9s/goprocinfo/linux"
"github.com/opencontainers/runc/libcontainer/system"
)
var sysMemTotal = getSysMemTotal()
var clockTicksPerSecond = uint64(system.GetClockTicks())
const nanoSecondsPerSecond = 1e9
func getSysMemTotal() int64 {
stat, err := linuxproc.ReadMemInfo("/proc/meminfo")
if err != nil {
log.Errorf("error reading system stats: %s", err)
return 0
}
return int64(stat.MemTotal * 1024)
}
// return cumulative system cpu usage in nanoseconds
func getSysCPUUsage() uint64 {
stat, err := linuxproc.ReadStat("/proc/stat")
if err != nil {
log.Errorf("error reading system stats: %s", err)
return 0
}
sum := stat.CPUStatAll.User +
stat.CPUStatAll.Nice +
stat.CPUStatAll.System +
stat.CPUStatAll.Idle +
stat.CPUStatAll.IOWait +
stat.CPUStatAll.IRQ +
stat.CPUStatAll.SoftIRQ +
stat.CPUStatAll.Steal +
stat.CPUStatAll.Guest +
stat.CPUStatAll.GuestNice
return (sum * nanoSecondsPerSecond) / clockTicksPerSecond
}

View File

@@ -1,135 +0,0 @@
// +build linux
package collector
import (
"time"
"github.com/bcicen/ctop/config"
"github.com/bcicen/ctop/models"
"github.com/opencontainers/runc/libcontainer"
"github.com/opencontainers/runc/libcontainer/cgroups"
)
// Runc collector
type Runc struct {
models.Metrics
id string
libc libcontainer.Container
stream chan models.Metrics
done bool
running bool
interval int // collection interval, in seconds
lastCpu float64
lastSysCpu float64
scaleCpu bool
}
func NewRunc(libc libcontainer.Container) *Runc {
c := &Runc{
Metrics: models.Metrics{},
id: libc.ID(),
libc: libc,
interval: 1,
scaleCpu: config.GetSwitchVal("scaleCpu"),
}
return c
}
func (c *Runc) Running() bool {
return c.running
}
func (c *Runc) Start() {
c.done = false
c.stream = make(chan models.Metrics)
go c.run()
}
func (c *Runc) Stop() {
c.done = true
}
func (c *Runc) Stream() chan models.Metrics {
return c.stream
}
func (c *Runc) Logs() LogCollector {
return nil
}
func (c *Runc) run() {
c.running = true
defer close(c.stream)
log.Debugf("collector started for container: %s", c.id)
for {
stats, err := c.libc.Stats()
if err != nil {
log.Errorf("failed to collect stats for container %s:\n%s", c.id, err)
break
}
c.ReadCPU(stats.CgroupStats)
c.ReadMem(stats.CgroupStats)
c.ReadNet(stats.Interfaces)
c.stream <- c.Metrics
if c.done {
break
}
time.Sleep(1 * time.Second)
}
c.running = false
}
func (c *Runc) ReadCPU(stats *cgroups.Stats) {
u := stats.CpuStats.CpuUsage
ncpus := float64(len(u.PercpuUsage))
total := float64(u.TotalUsage)
system := float64(getSysCPUUsage())
cpudiff := total - c.lastCpu
syscpudiff := system - c.lastSysCpu
if c.scaleCpu {
c.CPUUtil = round((cpudiff / syscpudiff * 100))
} else {
c.CPUUtil = round((cpudiff / syscpudiff * 100) * ncpus)
}
c.lastCpu = total
c.lastSysCpu = system
c.Pids = int(stats.PidsStats.Current)
}
func (c *Runc) ReadMem(stats *cgroups.Stats) {
c.MemUsage = int64(stats.MemoryStats.Usage.Usage)
c.MemLimit = int64(stats.MemoryStats.Usage.Limit)
if c.MemLimit > sysMemTotal && sysMemTotal > 0 {
c.MemLimit = sysMemTotal
}
c.MemPercent = percent(float64(c.MemUsage), float64(c.MemLimit))
}
func (c *Runc) ReadNet(interfaces []*libcontainer.NetworkInterface) {
var rx, tx int64
for _, network := range interfaces {
rx += int64(network.RxBytes)
tx += int64(network.TxBytes)
}
c.NetRx, c.NetTx = rx, tx
}
func (c *Runc) ReadIO(stats *cgroups.Stats) {
var read, write int64
for _, blk := range stats.BlkioStats.IoServiceBytesRecursive {
if blk.Op == "Read" {
read = int64(blk.Value)
}
if blk.Op == "Write" {
write = int64(blk.Value)
}
}
c.IOBytesRead, c.IOBytesWrite = read, write
}

View File

@@ -1,35 +0,0 @@
package connector
import (
"fmt"
"sort"
"github.com/bcicen/ctop/container"
"github.com/bcicen/ctop/logging"
)
var (
log = logging.Init()
enabled = make(map[string]func() Connector)
)
// return names for all enabled connectors on the current platform
func Enabled() (a []string) {
for k, _ := range enabled {
a = append(a, k)
}
sort.Strings(a)
return a
}
func ByName(s string) (Connector, error) {
if cfn, ok := enabled[s]; ok {
return cfn(), nil
}
return nil, fmt.Errorf("invalid connector type \"%s\"", s)
}
type Connector interface {
All() container.Containers
Get(string) (*container.Container, bool)
}

View File

@@ -1,65 +0,0 @@
package manager
import (
"fmt"
api "github.com/fsouza/go-dockerclient"
)
type Docker struct {
id string
client *api.Client
}
func NewDocker(client *api.Client, id string) *Docker {
return &Docker{
id: id,
client: client,
}
}
func (dc *Docker) Start() error {
c, err := dc.client.InspectContainer(dc.id)
if err != nil {
return fmt.Errorf("cannot inspect container: %v", err)
}
if err := dc.client.StartContainer(c.ID, c.HostConfig); err != nil {
return fmt.Errorf("cannot start container: %v", err)
}
return nil
}
func (dc *Docker) Stop() error {
if err := dc.client.StopContainer(dc.id, 3); err != nil {
return fmt.Errorf("cannot stop container: %v", err)
}
return nil
}
func (dc *Docker) Remove() error {
if err := dc.client.RemoveContainer(api.RemoveContainerOptions{ID: dc.id}); err != nil {
return fmt.Errorf("cannot remove container: %v", err)
}
return nil
}
func (dc *Docker) Pause() error {
if err := dc.client.PauseContainer(dc.id); err != nil {
return fmt.Errorf("cannot pause container: %v", err)
}
return nil
}
func (dc *Docker) Unpause() error {
if err := dc.client.UnpauseContainer(dc.id); err != nil {
return fmt.Errorf("cannot unpause container: %v", err)
}
return nil
}
func (dc *Docker) Restart() error {
if err := dc.client.RestartContainer(dc.id, 3); err != nil {
return fmt.Errorf("cannot restart container: %v", err)
}
return nil
}

View File

@@ -1,10 +0,0 @@
package manager
type Manager interface {
Start() error
Stop() error
Remove() error
Pause() error
Unpause() error
Restart() error
}

View File

@@ -1,31 +0,0 @@
package manager
type Mock struct{}
func NewMock() *Mock {
return &Mock{}
}
func (m *Mock) Start() error {
return nil
}
func (m *Mock) Stop() error {
return nil
}
func (m *Mock) Remove() error {
return nil
}
func (m *Mock) Pause() error {
return nil
}
func (m *Mock) Unpause() error {
return nil
}
func (m *Mock) Restart() error {
return nil
}

View File

@@ -1,31 +0,0 @@
package manager
type Runc struct{}
func NewRunc() *Runc {
return &Runc{}
}
func (rc *Runc) Start() error {
return nil
}
func (rc *Runc) Stop() error {
return nil
}
func (rc *Runc) Remove() error {
return nil
}
func (rc *Runc) Pause() error {
return nil
}
func (rc *Runc) Unpause() error {
return nil
}
func (rc *Runc) Restart() error {
return nil
}

View File

@@ -1,247 +0,0 @@
// +build linux
package connector
import (
"fmt"
"io/ioutil"
"os"
"path/filepath"
"sync"
"time"
"github.com/bcicen/ctop/connector/collector"
"github.com/bcicen/ctop/connector/manager"
"github.com/bcicen/ctop/container"
"github.com/opencontainers/runc/libcontainer"
"github.com/opencontainers/runc/libcontainer/cgroups/systemd"
)
func init() { enabled["runc"] = NewRunc }
type RuncOpts struct {
root string // runc root path
systemdCgroups bool // use systemd cgroups
}
func NewRuncOpts() (RuncOpts, error) {
var opts RuncOpts
// read runc root path
root := os.Getenv("RUNC_ROOT")
if root == "" {
root = "/run/runc"
}
abs, err := filepath.Abs(root)
if err != nil {
return opts, err
}
opts.root = abs
// ensure runc root path is readable
_, err = ioutil.ReadDir(opts.root)
if err != nil {
return opts, err
}
if os.Getenv("RUNC_SYSTEMD_CGROUP") == "1" {
opts.systemdCgroups = true
}
return opts, nil
}
type Runc struct {
opts RuncOpts
factory libcontainer.Factory
containers map[string]*container.Container
libContainers map[string]libcontainer.Container
needsRefresh chan string // container IDs requiring refresh
lock sync.RWMutex
}
func NewRunc() Connector {
opts, err := NewRuncOpts()
runcFailOnErr(err)
factory, err := getFactory(opts)
runcFailOnErr(err)
cm := &Runc{
opts: opts,
factory: factory,
containers: make(map[string]*container.Container),
libContainers: make(map[string]libcontainer.Container),
needsRefresh: make(chan string, 60),
lock: sync.RWMutex{},
}
go func() {
for {
cm.refreshAll()
time.Sleep(5 * time.Second)
}
}()
go cm.Loop()
return cm
}
func (cm *Runc) GetLibc(id string) libcontainer.Container {
// return previously loaded container
libc, ok := cm.libContainers[id]
if ok {
return libc
}
// load container
libc, err := cm.factory.Load(id)
if err != nil {
// remove container if no longer exists
if lerr, ok := err.(libcontainer.Error); ok && lerr.Code() == libcontainer.ContainerNotExists {
cm.delByID(id)
} else {
log.Warningf("failed to read container: %s\n", err)
}
return nil
}
return libc
}
// update a ctop container from libcontainer
func (cm *Runc) refresh(id string) {
libc := cm.GetLibc(id)
if libc == nil {
return
}
c := cm.MustGet(id)
// remove container if entered destroyed state on last refresh
// this gives adequate time for the collector to be shut down
if c.GetMeta("state") == "destroyed" {
cm.delByID(id)
return
}
status, err := libc.Status()
if err != nil {
log.Warningf("failed to read status for container: %s\n", err)
} else {
c.SetState(status.String())
}
state, err := libc.State()
if err != nil {
log.Warningf("failed to read state for container: %s\n", err)
} else {
c.SetMeta("created", state.BaseState.Created.Format("Mon Jan 2 15:04:05 2006"))
}
conf := libc.Config()
c.SetMeta("rootfs", conf.Rootfs)
}
// Read runc root, creating any new containers
func (cm *Runc) refreshAll() {
list, err := ioutil.ReadDir(cm.opts.root)
runcFailOnErr(err)
for _, i := range list {
if i.IsDir() {
name := i.Name()
// attempt to load
libc := cm.GetLibc(name)
if libc == nil {
continue
}
_ = cm.MustGet(i.Name()) // ensure container exists
}
}
// queue all existing containers for refresh
for id, _ := range cm.containers {
cm.needsRefresh <- id
}
log.Debugf("queued %d containers for refresh", len(cm.containers))
}
func (cm *Runc) Loop() {
for id := range cm.needsRefresh {
cm.refresh(id)
}
}
// Get 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 {
libc := cm.GetLibc(id)
// create collector
collector := collector.NewRunc(libc)
// create container
manager := manager.NewRunc()
c = container.New(id, collector, manager)
name := libc.ID()
// set initial metadata
if len(name) > 12 {
name = name[0:12]
}
c.SetMeta("name", name)
// add to map
cm.lock.Lock()
cm.containers[id] = c
cm.libContainers[id] = libc
cm.lock.Unlock()
log.Debugf("saw new container: %s", id)
}
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()
delete(cm.containers, id)
delete(cm.libContainers, id)
cm.lock.Unlock()
log.Infof("removed dead container: %s", id)
}
// Return array of all containers, sorted by field
func (cm *Runc) All() (containers container.Containers) {
cm.lock.Lock()
for _, c := range cm.containers {
containers = append(containers, c)
}
containers.Sort()
containers.Filter()
cm.lock.Unlock()
return containers
}
func getFactory(opts RuncOpts) (libcontainer.Factory, error) {
cgroupManager := libcontainer.Cgroupfs
if opts.systemdCgroups {
if systemd.UseSystemd() {
cgroupManager = libcontainer.SystemdCgroups
} else {
return nil, fmt.Errorf("systemd cgroup enabled, but systemd support for managing cgroups is not available")
}
}
return libcontainer.New(opts.root, cgroupManager)
}
func runcFailOnErr(err error) {
if err != nil {
panic(fmt.Errorf("fatal runc error: %s", err))
}
}

76
container.go Normal file
View File

@@ -0,0 +1,76 @@
package main
import (
"github.com/bcicen/ctop/cwidgets"
"github.com/bcicen/ctop/cwidgets/compact"
"github.com/bcicen/ctop/metrics"
)
// Metrics and metadata representing a container
type Container struct {
metrics.Metrics
Id string
Meta map[string]string
Widgets *compact.Compact
updater cwidgets.WidgetUpdater
collector metrics.Collector
display bool // display this container in compact view
}
func NewContainer(id string, collector metrics.Collector) *Container {
widgets := compact.NewCompact(id)
return &Container{
Metrics: metrics.NewMetrics(),
Id: id,
Meta: make(map[string]string),
Widgets: widgets,
updater: widgets,
collector: collector,
}
}
func (c *Container) SetUpdater(u cwidgets.WidgetUpdater) {
c.updater = u
for k, v := range c.Meta {
c.updater.SetMeta(k, v)
}
}
func (c *Container) SetMeta(k, v string) {
c.Meta[k] = v
c.updater.SetMeta(k, v)
}
func (c *Container) GetMeta(k string) string {
if v, ok := c.Meta[k]; ok {
return v
}
return ""
}
func (c *Container) SetState(s string) {
c.SetMeta("state", s)
// start collector, if needed
if s == "running" && !c.collector.Running() {
c.collector.Start()
c.Read(c.collector.Stream())
}
// stop collector, if needed
if s != "running" && c.collector.Running() {
c.collector.Stop()
}
}
// Read metric stream, updating widgets
func (c *Container) Read(stream chan metrics.Metrics) {
go func() {
for metrics := range stream {
c.Metrics = metrics
c.updater.SetMetrics(metrics)
}
log.Infof("reader stopped for container: %s", c.Id)
c.Metrics = metrics.NewMetrics()
c.Widgets.Reset()
}()
log.Infof("reader started for container: %s", c.Id)
}

View File

@@ -1,155 +0,0 @@
package container
import (
"github.com/bcicen/ctop/connector/collector"
"github.com/bcicen/ctop/connector/manager"
"github.com/bcicen/ctop/cwidgets"
"github.com/bcicen/ctop/cwidgets/compact"
"github.com/bcicen/ctop/logging"
"github.com/bcicen/ctop/models"
)
var (
log = logging.Init()
)
const (
running = "running"
)
// Metrics and metadata representing a container
type Container struct {
models.Metrics
Id string
Meta map[string]string
Widgets *compact.Compact
Display bool // display this container in compact view
updater cwidgets.WidgetUpdater
collector collector.Collector
manager manager.Manager
}
func New(id string, collector collector.Collector, manager manager.Manager) *Container {
widgets := compact.NewCompact(id)
return &Container{
Metrics: models.NewMetrics(),
Id: id,
Meta: make(map[string]string),
Widgets: widgets,
updater: widgets,
collector: collector,
manager: manager,
}
}
func (c *Container) SetUpdater(u cwidgets.WidgetUpdater) {
c.updater = u
for k, v := range c.Meta {
c.updater.SetMeta(k, v)
}
}
func (c *Container) SetMeta(k, v string) {
c.Meta[k] = v
c.updater.SetMeta(k, v)
}
func (c *Container) GetMeta(k string) string {
if v, ok := c.Meta[k]; ok {
return v
}
return ""
}
func (c *Container) SetState(s string) {
c.SetMeta("state", s)
// start collector, if needed
if s == running && !c.collector.Running() {
c.collector.Start()
c.Read(c.collector.Stream())
}
// stop collector, if needed
if s != running && c.collector.Running() {
c.collector.Stop()
}
}
// Return container log collector
func (c *Container) Logs() collector.LogCollector {
return c.collector.Logs()
}
// Read metric stream, updating widgets
func (c *Container) Read(stream chan models.Metrics) {
go func() {
for metrics := range stream {
c.Metrics = metrics
c.updater.SetMetrics(metrics)
}
log.Infof("reader stopped for container: %s", c.Id)
c.Metrics = models.NewMetrics()
c.Widgets.Reset()
}()
log.Infof("reader started for container: %s", c.Id)
}
func (c *Container) Start() {
if c.Meta["state"] != running {
if err := c.manager.Start(); err != nil {
log.Warningf("container %s: %v", c.Id, err)
log.StatusErr(err)
return
}
c.SetState(running)
}
}
func (c *Container) Stop() {
if c.Meta["state"] == running {
if err := c.manager.Stop(); err != nil {
log.Warningf("container %s: %v", c.Id, err)
log.StatusErr(err)
return
}
c.SetState("exited")
}
}
func (c *Container) Remove() {
if err := c.manager.Remove(); err != nil {
log.Warningf("container %s: %v", c.Id, err)
log.StatusErr(err)
}
}
func (c *Container) Pause() {
if c.Meta["state"] == running {
if err := c.manager.Pause(); err != nil {
log.Warningf("container %s: %v", c.Id, err)
log.StatusErr(err)
return
}
c.SetState("paused")
}
}
func (c *Container) Unpause() {
if c.Meta["state"] == "paused" {
if err := c.manager.Unpause(); err != nil {
log.Warningf("container %s: %v", c.Id, err)
log.StatusErr(err)
return
}
c.SetState(running)
}
}
func (c *Container) Restart() {
if c.Meta["state"] == running {
if err := c.manager.Restart(); err != nil {
log.Warningf("container %s: %v", c.Id, err)
log.StatusErr(err)
return
}
}
}

View File

@@ -1,23 +1,24 @@
package main
import (
"math"
"github.com/bcicen/ctop/connector"
"github.com/bcicen/ctop/container"
ui "github.com/gizak/termui"
)
type GridCursor struct {
selectedID string // id of currently selected container
filtered container.Containers
cSource connector.Connector
isScrolling bool // toggled when actively scrolling
filtered Containers
cSource ContainerSource
}
func NewGridCursor() *GridCursor {
return &GridCursor{
cSource: NewDockerContainerSource(),
}
}
func (gc *GridCursor) Len() int { return len(gc.filtered) }
func (gc *GridCursor) Selected() *container.Container {
func (gc *GridCursor) Selected() *Container {
idx := gc.Idx()
if idx < gc.Len() {
return gc.filtered[idx]
@@ -30,10 +31,10 @@ func (gc *GridCursor) RefreshContainers() (lenChanged bool) {
oldLen := gc.Len()
// Containers filtered by display bool
gc.filtered = container.Containers{}
gc.filtered = Containers{}
var cursorVisible bool
for _, c := range gc.cSource.All() {
if c.Display {
if c.display {
if c.Id == gc.selectedID {
cursorVisible = true
}
@@ -57,11 +58,11 @@ func (gc *GridCursor) RefreshContainers() (lenChanged bool) {
// Set an initial cursor position, if possible
func (gc *GridCursor) Reset() {
for _, c := range gc.cSource.All() {
c.Widgets.UnHighlight()
c.Widgets.Name.UnHighlight()
}
if gc.Len() > 0 {
gc.selectedID = gc.filtered[0].Id
gc.filtered[0].Widgets.Highlight()
gc.filtered[0].Widgets.Name.Highlight()
}
}
@@ -99,9 +100,6 @@ func (gc *GridCursor) ScrollPage() {
}
func (gc *GridCursor) Up() {
gc.isScrolling = true
defer func() { gc.isScrolling = false }()
idx := gc.Idx()
if idx <= 0 { // already at top
return
@@ -109,18 +107,15 @@ func (gc *GridCursor) Up() {
active := gc.filtered[idx]
next := gc.filtered[idx-1]
active.Widgets.UnHighlight()
active.Widgets.Name.UnHighlight()
gc.selectedID = next.Id
next.Widgets.Highlight()
next.Widgets.Name.Highlight()
gc.ScrollPage()
ui.Render(cGrid)
}
func (gc *GridCursor) Down() {
gc.isScrolling = true
defer func() { gc.isScrolling = false }()
idx := gc.Idx()
if idx >= gc.Len()-1 { // already at bottom
return
@@ -128,65 +123,10 @@ func (gc *GridCursor) Down() {
active := gc.filtered[idx]
next := gc.filtered[idx+1]
active.Widgets.UnHighlight()
active.Widgets.Name.UnHighlight()
gc.selectedID = next.Id
next.Widgets.Highlight()
next.Widgets.Name.Highlight()
gc.ScrollPage()
ui.Render(cGrid)
}
func (gc *GridCursor) PgUp() {
idx := gc.Idx()
if idx <= 0 { // already at top
return
}
nextidx := int(math.Max(0.0, float64(idx-cGrid.MaxRows())))
if gc.pgCount() > 0 {
cGrid.Offset = int(math.Max(float64(cGrid.Offset-cGrid.MaxRows()),
float64(0)))
}
active := gc.filtered[idx]
next := gc.filtered[nextidx]
active.Widgets.UnHighlight()
gc.selectedID = next.Id
next.Widgets.Highlight()
cGrid.Align()
ui.Render(cGrid)
}
func (gc *GridCursor) PgDown() {
idx := gc.Idx()
if idx >= gc.Len()-1 { // already at bottom
return
}
nextidx := int(math.Min(float64(gc.Len()-1), float64(idx+cGrid.MaxRows())))
if gc.pgCount() > 0 {
cGrid.Offset = int(math.Min(float64(cGrid.Offset+cGrid.MaxRows()),
float64(gc.Len()-cGrid.MaxRows())))
}
active := gc.filtered[idx]
next := gc.filtered[nextidx]
active.Widgets.UnHighlight()
gc.selectedID = next.Id
next.Widgets.Highlight()
cGrid.Align()
ui.Render(cGrid)
}
// number of pages at current row count and term height
func (gc *GridCursor) pgCount() int {
pages := gc.Len() / cGrid.MaxRows()
if gc.Len()%cGrid.MaxRows() > 0 {
pages++
}
return pages
}

View File

@@ -23,22 +23,12 @@ func (w *GaugeCol) Reset() {
w.Percent = 0
}
func (w *GaugeCol) Highlight() {
w.Bg = ui.ThemeAttr("par.text.fg")
w.PercentColor = ui.ThemeAttr("par.text.hi")
}
func (w *GaugeCol) UnHighlight() {
w.Bg = ui.ThemeAttr("par.text.bg")
w.PercentColor = ui.ThemeAttr("par.text.bg")
}
func colorScale(n int) ui.Attribute {
if n > 70 {
return ui.ThemeAttr("status.danger")
return ui.ColorRed
}
if n > 30 {
return ui.ThemeAttr("status.warn")
return ui.ColorYellow
}
return ui.ThemeAttr("status.ok")
return ui.ColorGreen
}

View File

@@ -4,7 +4,7 @@ import (
ui "github.com/gizak/termui"
)
var header *CompactHeader
var header = NewCompactHeader()
type CompactGrid struct {
ui.GridBufferer
@@ -16,17 +16,14 @@ type CompactGrid struct {
}
func NewCompactGrid() *CompactGrid {
header = NewCompactHeader() // init column header
return &CompactGrid{}
}
func (cg *CompactGrid) Align() {
y := cg.Y
if cg.Offset >= len(cg.Rows) || cg.Offset < 0 {
if cg.Offset >= len(cg.Rows) {
cg.Offset = 0
}
// update row ypos, width recursively
for _, r := range cg.pageRows() {
r.SetY(y)
@@ -57,5 +54,7 @@ func (cg *CompactGrid) Buffer() ui.Buffer {
}
func (cg *CompactGrid) AddRows(rows ...ui.GridBufferer) {
cg.Rows = append(cg.Rows, rows...)
for _, r := range rows {
cg.Rows = append(cg.Rows, r)
}
}

View File

@@ -12,7 +12,7 @@ type CompactHeader struct {
}
func NewCompactHeader() *CompactHeader {
fields := []string{"", "NAME", "CID", "CPU", "MEM", "NET RX/TX", "IO R/W", "PIDS"}
fields := []string{"", "NAME", "CID", "CPU", "MEM", "NET RX/TX"}
ch := &CompactHeader{}
ch.Height = 2
for _, f := range fields {
@@ -27,13 +27,13 @@ func (ch *CompactHeader) GetHeight() int {
func (ch *CompactHeader) SetWidth(w int) {
x := ch.X
autoWidth := calcWidth(w)
autoWidth := calcWidth(w, 5)
for n, col := range ch.pars {
// set column to static width
if colWidths[n] != 0 {
// set status column to static width
if n == 0 {
col.SetX(x)
col.SetWidth(colWidths[n])
x += colWidths[n]
col.SetWidth(statusWidth)
x += statusWidth
continue
}
col.SetX(x)

View File

@@ -1,9 +1,8 @@
package compact
import (
"github.com/bcicen/ctop/config"
"github.com/bcicen/ctop/logging"
"github.com/bcicen/ctop/models"
"github.com/bcicen/ctop/metrics"
ui "github.com/gizak/termui"
)
@@ -14,11 +13,8 @@ type Compact struct {
Name *TextCol
Cid *TextCol
Cpu *GaugeCol
Mem *GaugeCol
Memory *GaugeCol
Net *TextCol
IO *TextCol
Pids *TextCol
Bg *RowBg
X, Y int
Width int
Height int
@@ -34,11 +30,8 @@ func NewCompact(id string) *Compact {
Name: NewTextCol("-"),
Cid: NewTextCol(id),
Cpu: NewGaugeCol(),
Mem: NewGaugeCol(),
Memory: NewGaugeCol(),
Net: NewTextCol("-"),
IO: NewTextCol("-"),
Pids: NewTextCol("-"),
Bg: NewRowBg(),
X: 1,
Height: 1,
}
@@ -59,26 +52,20 @@ func (row *Compact) SetMeta(k, v string) {
row.Name.Set(v)
case "state":
row.Status.Set(v)
case "health":
row.Status.SetHealth(v)
}
}
func (row *Compact) SetMetrics(m models.Metrics) {
func (row *Compact) SetMetrics(m metrics.Metrics) {
row.SetCPU(m.CPUUtil)
row.SetNet(m.NetRx, m.NetTx)
row.SetMem(m.MemUsage, m.MemLimit, m.MemPercent)
row.SetIO(m.IOBytesRead, m.IOBytesWrite)
row.SetPids(m.Pids)
}
// Set gauges, counters to default unread values
func (row *Compact) Reset() {
row.Cpu.Reset()
row.Mem.Reset()
row.Memory.Reset()
row.Net.Reset()
row.IO.Reset()
row.Pids.Reset()
}
func (row *Compact) GetHeight() int {
@@ -93,8 +80,6 @@ func (row *Compact) SetY(y int) {
if y == row.Y {
return
}
row.Bg.Y = y
for _, col := range row.all() {
col.SetY(y)
}
@@ -106,16 +91,13 @@ func (row *Compact) SetWidth(width int) {
return
}
x := row.X
row.Bg.SetX(x + colWidths[0] + 1)
row.Bg.SetWidth(width)
autoWidth := calcWidth(width)
autoWidth := calcWidth(width, 5)
for n, col := range row.all() {
if colWidths[n] != 0 {
// set status column to static width
if n == 0 {
col.SetX(x)
col.SetWidth(colWidths[n])
x += colWidths[n]
col.SetWidth(statusWidth)
x += statusWidth
continue
}
col.SetX(x)
@@ -128,15 +110,13 @@ func (row *Compact) SetWidth(width int) {
func (row *Compact) Buffer() ui.Buffer {
buf := ui.NewBuffer()
buf.Merge(row.Bg.Buffer())
buf.Merge(row.Status.Buffer())
buf.Merge(row.Name.Buffer())
buf.Merge(row.Cid.Buffer())
buf.Merge(row.Cpu.Buffer())
buf.Merge(row.Mem.Buffer())
buf.Merge(row.Memory.Buffer())
buf.Merge(row.Net.Buffer())
buf.Merge(row.IO.Buffer())
buf.Merge(row.Pids.Buffer())
return buf
}
@@ -146,50 +126,7 @@ func (row *Compact) all() []ui.GridBufferer {
row.Name,
row.Cid,
row.Cpu,
row.Mem,
row.Memory,
row.Net,
row.IO,
row.Pids,
}
}
func (row *Compact) Highlight() {
row.Name.Highlight()
if config.GetSwitchVal("fullRowCursor") {
row.Bg.Highlight()
row.Cid.Highlight()
row.Cpu.Highlight()
row.Mem.Highlight()
row.Net.Highlight()
row.IO.Highlight()
row.Pids.Highlight()
}
}
func (row *Compact) UnHighlight() {
row.Name.UnHighlight()
if config.GetSwitchVal("fullRowCursor") {
row.Bg.UnHighlight()
row.Cid.UnHighlight()
row.Cpu.UnHighlight()
row.Mem.UnHighlight()
row.Net.UnHighlight()
row.IO.UnHighlight()
row.Pids.UnHighlight()
}
}
type RowBg struct {
*ui.Par
}
func NewRowBg() *RowBg {
bg := ui.NewPar("")
bg.Height = 1
bg.Border = false
bg.Bg = ui.ThemeAttr("par.text.bg")
return &RowBg{bg}
}
func (w *RowBg) Highlight() { w.Bg = ui.ThemeAttr("par.text.fg") }
func (w *RowBg) UnHighlight() { w.Bg = ui.ThemeAttr("par.text.bg") }

View File

@@ -13,16 +13,6 @@ func (row *Compact) SetNet(rx int64, tx int64) {
row.Net.Set(label)
}
func (row *Compact) SetIO(read int64, write int64) {
label := fmt.Sprintf("%s / %s", cwidgets.ByteFormat(read), cwidgets.ByteFormat(write))
row.IO.Set(label)
}
func (row *Compact) SetPids(val int) {
label := strconv.Itoa(val)
row.Pids.Set(label)
}
func (row *Compact) SetCPU(val int) {
row.Cpu.BarColor = colorScale(val)
row.Cpu.Label = fmt.Sprintf("%s%%", strconv.Itoa(val))
@@ -30,19 +20,16 @@ func (row *Compact) SetCPU(val int) {
val = 5
row.Cpu.BarColor = ui.ThemeAttr("gauge.bar.bg")
}
if val > 100 {
val = 100
}
row.Cpu.Percent = val
}
func (row *Compact) SetMem(val int64, limit int64, percent int) {
row.Mem.Label = fmt.Sprintf("%s / %s", cwidgets.ByteFormat(val), cwidgets.ByteFormat(limit))
row.Memory.Label = fmt.Sprintf("%s / %s", cwidgets.ByteFormat(val), cwidgets.ByteFormat(limit))
if percent < 5 {
percent = 5
row.Mem.BarColor = ui.ColorBlack
row.Memory.BarColor = ui.ColorBlack
} else {
row.Mem.BarColor = ui.ThemeAttr("gauge.bar.bg")
row.Memory.BarColor = ui.ThemeAttr("gauge.bar.bg")
}
row.Mem.Percent = percent
row.Memory.Percent = percent
}

View File

@@ -1,42 +1,28 @@
package compact
import (
"fmt"
ui "github.com/gizak/termui"
)
const (
mark = string('\u25C9')
healthMark = string('\u207A')
vBar = string('\u25AE') + string('\u25AE')
vBar = string('\u25AE')
statusWidth = 3
)
// Status indicator
type Status struct {
*ui.Block
status []ui.Cell
health []ui.Cell
*ui.Par
}
func NewStatus() *Status {
s := &Status{Block: ui.NewBlock()}
s.Height = 1
s.Border = false
s.Set("")
return s
}
func (s *Status) Buffer() ui.Buffer {
buf := s.Block.Buffer()
x := 0
for _, c := range s.status {
buf.Set(s.InnerX()+x, s.InnerY(), c)
x += c.Width()
}
for _, c := range s.health {
buf.Set(s.InnerX()+x, s.InnerY(), c)
x += c.Width()
}
return buf
p := ui.NewPar(mark)
p.Border = false
p.Height = 1
p.Width = statusWidth
return &Status{p}
}
func (s *Status) Set(val string) {
@@ -46,38 +32,13 @@ func (s *Status) Set(val string) {
switch val {
case "running":
color = ui.ThemeAttr("status.ok")
color = ui.ColorGreen
case "exited":
color = ui.ThemeAttr("status.danger")
color = ui.ColorRed
case "paused":
text = vBar
text = fmt.Sprintf("%s%s", vBar, vBar)
}
var cells []ui.Cell
for _, ch := range text {
cells = append(cells, ui.Cell{Ch: ch, Fg: color})
}
s.status = cells
}
func (s *Status) SetHealth(val string) {
if val == "" {
return
}
color := ui.ColorDefault
switch val {
case "healthy":
color = ui.ThemeAttr("status.ok")
case "unhealthy":
color = ui.ThemeAttr("status.danger")
case "starting":
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.Text = text
s.TextFgColor = color
}

View File

@@ -17,13 +17,11 @@ func NewTextCol(s string) *TextCol {
}
func (w *TextCol) Highlight() {
w.Bg = ui.ThemeAttr("par.text.fg")
w.TextFgColor = ui.ThemeAttr("par.text.hi")
w.TextFgColor = ui.ColorBlack
w.TextBgColor = ui.ThemeAttr("par.text.fg")
}
func (w *TextCol) UnHighlight() {
w.Bg = ui.ThemeAttr("par.text.bg")
w.TextFgColor = ui.ThemeAttr("par.text.fg")
w.TextBgColor = ui.ThemeAttr("par.text.bg")
}

View File

@@ -4,35 +4,15 @@ package compact
import (
"fmt"
ui "github.com/gizak/termui"
)
const colSpacing = 1
// per-column width. 0 == auto width
var colWidths = []int{
3, // status
0, // name
0, // cid
0, // cpu
0, // memory
0, // net
0, // io
4, // pids
}
// Calculate per-column width, given total width
func calcWidth(width int) int {
spacing := colSpacing * len(colWidths)
var staticCols int
for _, w := range colWidths {
width -= w
if w == 0 {
staticCols++
}
}
return (width - spacing) / staticCols
// Calculate per-column width, given total width and number of items
func calcWidth(width, items int) int {
spacing := colSpacing * items
return (width - statusWidth - spacing) / items
}
func centerParText(p *ui.Par) {

View File

@@ -1,4 +1,4 @@
package single
package expanded
import (
ui "github.com/gizak/termui"

View File

@@ -1,4 +1,4 @@
package single
package expanded
type IntHist struct {
Val int // most current data point

35
cwidgets/expanded/info.go Normal file
View File

@@ -0,0 +1,35 @@
package expanded
import (
ui "github.com/gizak/termui"
)
var displayInfo = []string{"id", "name", "image", "state"}
type Info struct {
*ui.Table
data map[string]string
}
func NewInfo(id string) *Info {
p := ui.NewTable()
p.Height = 4
p.Width = colWidth[0]
p.FgColor = ui.ThemeAttr("par.text.fg")
p.Separator = false
i := &Info{p, make(map[string]string)}
i.Set("id", id)
return i
}
func (w *Info) Set(k, v string) {
w.data[k] = v
// rebuild rows
w.Rows = [][]string{}
for _, k := range displayInfo {
if v, ok := w.data[k]; ok {
w.Rows = append(w.Rows, []string{k, v})
}
}
w.Height = len(w.Rows) + 2
}

View File

@@ -1,8 +1,8 @@
package single
package expanded
import (
"github.com/bcicen/ctop/logging"
"github.com/bcicen/ctop/models"
"github.com/bcicen/ctop/metrics"
ui "github.com/gizak/termui"
)
@@ -12,87 +12,47 @@ var (
colWidth = [2]int{65, 0} // left,right column width
)
type Single struct {
type Expanded struct {
Info *Info
Net *Net
Cpu *Cpu
Mem *Mem
IO *IO
Env *Env
X, Y int
Width int
}
func NewSingle(id string) *Single {
func NewExpanded(id string) *Expanded {
if len(id) > 12 {
id = id[:12]
}
return &Single{
return &Expanded{
Info: NewInfo(id),
Net: NewNet(),
Cpu: NewCpu(),
Mem: NewMem(),
IO: NewIO(),
Env: NewEnv(),
Width: ui.TermWidth(),
}
}
func (e *Single) Up() {
if e.Y < 0 {
e.Y++
e.Align()
ui.Render(e)
}
func (e *Expanded) SetWidth(w int) {
e.Width = w
}
func (e *Single) Down() {
if e.Y > (ui.TermHeight() - e.GetHeight()) {
e.Y--
e.Align()
ui.Render(e)
}
}
func (e *Single) SetWidth(w int) { e.Width = w }
func (e *Single) SetMeta(k, v string) {
if k == "[ENV-VAR]" {
e.Env.Set(k, v)
} else {
func (e *Expanded) SetMeta(k, v string) {
e.Info.Set(k, v)
}
}
func (e *Single) SetMetrics(m models.Metrics) {
func (e *Expanded) SetMetrics(m metrics.Metrics) {
e.Cpu.Update(m.CPUUtil)
e.Net.Update(m.NetRx, m.NetTx)
e.Mem.Update(int(m.MemUsage), int(m.MemLimit))
e.IO.Update(m.IOBytesRead, m.IOBytesWrite)
}
// Return total column height
func (e *Single) GetHeight() (h int) {
h += e.Info.Height
h += e.Net.Height
h += e.Cpu.Height
h += e.Mem.Height
h += e.IO.Height
h += e.Env.Height
return h
}
func (e *Single) Align() {
// reset offset if needed
if e.GetHeight() <= ui.TermHeight() {
e.Y = 0
}
y := e.Y
func (e *Expanded) Align() {
y := 0
for _, i := range e.all() {
i.SetY(y)
y += i.GetHeight()
}
if e.Width > colWidth[0] {
colWidth[1] = e.Width - (colWidth[0] + 1)
}
@@ -100,7 +60,10 @@ func (e *Single) Align() {
log.Debugf("align: width=%v left-col=%v right-col=%v", e.Width, colWidth[0], colWidth[1])
}
func (e *Single) Buffer() ui.Buffer {
func calcWidth(w int) {
}
func (e *Expanded) Buffer() ui.Buffer {
buf := ui.NewBuffer()
if e.Width < (colWidth[0] + colWidth[1]) {
ui.Clear()
@@ -111,19 +74,15 @@ func (e *Single) Buffer() ui.Buffer {
buf.Merge(e.Cpu.Buffer())
buf.Merge(e.Mem.Buffer())
buf.Merge(e.Net.Buffer())
buf.Merge(e.IO.Buffer())
buf.Merge(e.Env.Buffer())
return buf
}
func (e *Single) all() []ui.GridBufferer {
func (e *Expanded) all() []ui.GridBufferer {
return []ui.GridBufferer{
e.Info,
e.Cpu,
e.Mem,
e.Net,
e.IO,
e.Env,
}
}

View File

@@ -1,4 +1,4 @@
package single
package expanded
import (
"fmt"

View File

@@ -1,4 +1,4 @@
package single
package expanded
import (
"fmt"

View File

@@ -2,12 +2,12 @@ package cwidgets
import (
"github.com/bcicen/ctop/logging"
"github.com/bcicen/ctop/models"
"github.com/bcicen/ctop/metrics"
)
var log = logging.Init()
type WidgetUpdater interface {
SetMeta(string, string)
SetMetrics(models.Metrics)
SetMetrics(metrics.Metrics)
}

View File

@@ -1,36 +0,0 @@
package single
import (
ui "github.com/gizak/termui"
"regexp"
)
var envPattern = regexp.MustCompile(`(?P<KEY>[^=]+)=(?P<VALUJE>.*)`)
type Env struct {
*ui.Table
data map[string]string
}
func NewEnv() *Env {
p := ui.NewTable()
p.Height = 4
p.Width = colWidth[0]
p.FgColor = ui.ThemeAttr("par.text.fg")
p.Separator = false
i := &Env{p, make(map[string]string)}
i.BorderLabel = "Env"
return i
}
func (w *Env) Set(k, v string) {
match := envPattern.FindStringSubmatch(v)
key := match[1]
value := match[2]
w.data[key] = value
w.Rows = [][]string{}
w.Rows = append(w.Rows, mkInfoRows(key, value)...)
w.Height = len(w.Rows) + 2
}

View File

@@ -1,58 +0,0 @@
package single
import (
"strings"
ui "github.com/gizak/termui"
)
var displayInfo = []string{"id", "name", "image", "ports", "IPs", "state", "created", "health"}
type Info struct {
*ui.Table
data map[string]string
}
func NewInfo(id string) *Info {
p := ui.NewTable()
p.Height = 4
p.Width = colWidth[0]
p.FgColor = ui.ThemeAttr("par.text.fg")
p.Separator = false
i := &Info{p, make(map[string]string)}
i.Set("id", id)
return i
}
func (w *Info) Set(k, v string) {
w.data[k] = v
// rebuild rows
w.Rows = [][]string{}
for _, k := range displayInfo {
if v, ok := w.data[k]; ok {
w.Rows = append(w.Rows, mkInfoRows(k, v)...)
}
}
w.Height = len(w.Rows) + 2
}
// Build row(s) from a key and value string
func mkInfoRows(k, v string) (rows [][]string) {
lines := strings.Split(v, "\n")
// initial row with field name
rows = append(rows, []string{k, lines[0]})
// append any additional lines in separate row
if len(lines) > 1 {
for _, line := range lines[1:] {
if line != "" {
rows = append(rows, []string{"", line})
}
}
}
return rows
}

View File

@@ -1,51 +0,0 @@
package single
import (
"fmt"
"strings"
"github.com/bcicen/ctop/cwidgets"
ui "github.com/gizak/termui"
)
type IO struct {
*ui.Sparklines
readHist *DiffHist
writeHist *DiffHist
}
func NewIO() *IO {
io := &IO{ui.NewSparklines(), NewDiffHist(60), NewDiffHist(60)}
io.BorderLabel = "IO"
io.Height = 6
io.Width = colWidth[0]
io.X = 0
io.Y = 24
read := ui.NewSparkline()
read.Title = "READ"
read.Height = 1
read.Data = io.readHist.Data
read.LineColor = ui.ColorGreen
write := ui.NewSparkline()
write.Title = "WRITE"
write.Height = 1
write.Data = io.writeHist.Data
write.LineColor = ui.ColorYellow
io.Lines = []ui.Sparkline{read, write}
return io
}
func (w *IO) Update(read int64, write int64) {
var rate string
w.readHist.Append(int(read))
rate = strings.ToLower(cwidgets.ByteFormatInt(w.readHist.Val))
w.Lines[0].Title = fmt.Sprintf("read [%s/s]", rate)
w.writeHist.Append(int(write))
rate = strings.ToLower(cwidgets.ByteFormatInt(w.writeHist.Val))
w.Lines[1].Title = fmt.Sprintf("write [%s/s]", rate)
}

View File

@@ -1,83 +0,0 @@
package single
import (
"time"
"github.com/bcicen/ctop/models"
ui "github.com/gizak/termui"
)
type LogLines struct {
ts []time.Time
data []string
}
func NewLogLines(max int) *LogLines {
ll := &LogLines{
ts: make([]time.Time, max),
data: make([]string, max),
}
return ll
}
func (ll *LogLines) tail(n int) []string {
lines := make([]string, n)
for i := 0; i < n; i++ {
lines = append(lines, ll.data[len(ll.data)-i])
}
return lines
}
func (ll *LogLines) getLines(start, end int) []string {
if end < 0 {
return ll.data[start:]
}
return ll.data[start:end]
}
func (ll *LogLines) add(l models.Log) {
if len(ll.data) == cap(ll.data) {
ll.data = append(ll.data[:0], ll.data[1:]...)
ll.ts = append(ll.ts[:0], ll.ts[1:]...)
}
ll.ts = append(ll.ts, l.Timestamp)
ll.data = append(ll.data, l.Message)
log.Debugf("recorded log line: %v", l)
}
type Logs struct {
*ui.List
lines *LogLines
}
func NewLogs(stream chan models.Log) *Logs {
p := ui.NewList()
p.Y = ui.TermHeight() / 2
p.X = 0
p.Height = ui.TermHeight() - p.Y
p.Width = ui.TermWidth()
//p.Overflow = "wrap"
p.ItemFgColor = ui.ThemeAttr("par.text.fg")
i := &Logs{p, NewLogLines(4098)}
go func() {
for line := range stream {
i.lines.add(line)
ui.Render(i)
}
}()
return i
}
func (w *Logs) Align() {
w.X = colWidth[0]
w.List.Align()
}
func (w *Logs) Buffer() ui.Buffer {
maxLines := w.Height - 2
offset := len(w.lines.data) - maxLines
w.Items = w.lines.getLines(offset, -1)
return w.List.Buffer()
}
// number of rows a line will occupy at current panel width
func (w *Logs) lineHeight(s string) int { return (len(s) / w.InnerWidth()) + 1 }

View File

@@ -9,7 +9,6 @@ const (
kb = 1024
mb = kb * 1024
gb = mb * 1024
tb = gb * 1024
)
// convenience method
@@ -29,13 +28,9 @@ func ByteFormat(n int64) string {
n = n / mb
return fmt.Sprintf("%sM", strconv.FormatInt(n, 10))
}
if n < tb {
nf := float64(n) / gb
return fmt.Sprintf("%sG", unpadFloat(nf))
}
nf := float64(n) / tb
return fmt.Sprintf("%sT", unpadFloat(nf))
}
func unpadFloat(f float64) string {
return strconv.FormatFloat(f, 'f', getPrecision(f), 64)

View File

@@ -3,14 +3,10 @@ package main
import (
"fmt"
"reflect"
"runtime"
"github.com/bcicen/ctop/container"
ui "github.com/gizak/termui"
)
var mstats = &runtime.MemStats{}
func logEvent(e ui.Event) {
var s string
s += fmt.Sprintf("Type=%s", quote(e.Type))
@@ -22,24 +18,8 @@ func logEvent(e ui.Event) {
log.Debugf("new event: %s", s)
}
func runtimeStats() {
var msg string
msg += fmt.Sprintf("cgo calls=%v", runtime.NumCgoCall())
msg += fmt.Sprintf(" routines=%v", runtime.NumGoroutine())
runtime.ReadMemStats(mstats)
msg += fmt.Sprintf(" numgc=%v", mstats.NumGC)
msg += fmt.Sprintf(" alloc=%v", mstats.Alloc)
log.Debugf("runtime: %v", msg)
}
func runtimeStack() {
buf := make([]byte, 32768)
buf = buf[:runtime.Stack(buf, true)]
log.Infof(fmt.Sprintf("stack:\n%v", string(buf)))
}
// log container, metrics, and widget state
func dumpContainer(c *container.Container) {
func dumpContainer(c *Container) {
msg := fmt.Sprintf("logging state for container: %s\n", c.Id)
for k, v := range c.Meta {
msg += fmt.Sprintf("Meta.%s = %s\n", k, v)

View File

@@ -1,34 +1,35 @@
package connector
package main
import (
"fmt"
"sort"
"strings"
"sync"
"github.com/bcicen/ctop/connector/collector"
"github.com/bcicen/ctop/connector/manager"
"github.com/bcicen/ctop/container"
api "github.com/fsouza/go-dockerclient"
"github.com/bcicen/ctop/metrics"
"github.com/fsouza/go-dockerclient"
)
func init() { enabled["docker"] = NewDocker }
type ContainerSource interface {
All() Containers
Get(string) (*Container, bool)
}
type Docker struct {
client *api.Client
containers map[string]*container.Container
type DockerContainerSource struct {
client *docker.Client
containers map[string]*Container
needsRefresh chan string // container IDs requiring refresh
lock sync.RWMutex
}
func NewDocker() Connector {
func NewDockerContainerSource() *DockerContainerSource {
// init docker client
client, err := api.NewClientFromEnv()
client, err := docker.NewClientFromEnv()
if err != nil {
panic(err)
}
cm := &Docker{
cm := &DockerContainerSource{
client: client,
containers: make(map[string]*container.Container),
containers: make(map[string]*Container),
needsRefresh: make(chan string, 60),
lock: sync.RWMutex{},
}
@@ -39,20 +40,17 @@ func NewDocker() Connector {
}
// Docker events watcher
func (cm *Docker) watchEvents() {
func (cm *DockerContainerSource) watchEvents() {
log.Info("docker event listener starting")
events := make(chan *api.APIEvents)
events := make(chan *docker.APIEvents)
cm.client.AddEventListener(events)
for e := range events {
if e.Type != "container" {
continue
}
actionName := strings.Split(e.Action, ":")[0]
switch actionName {
case "start", "die", "pause", "unpause", "health_status":
switch e.Action {
case "start", "die", "pause", "unpause":
log.Debugf("handling docker event: action=%s id=%s", e.Action, e.ID)
cm.needsRefresh <- e.ID
case "destroy":
@@ -62,36 +60,7 @@ func (cm *Docker) watchEvents() {
}
}
func portsFormat(ports map[api.Port][]api.PortBinding) string {
var exposed []string
var published []string
for k, v := range ports {
if len(v) == 0 {
exposed = append(exposed, string(k))
continue
}
for _, binding := range v {
s := fmt.Sprintf("%s:%s -> %s", binding.HostIP, binding.HostPort, k)
published = append(published, s)
}
}
return strings.Join(append(exposed, published...), "\n")
}
func ipsFormat(networks map[string]api.ContainerNetwork) string {
var ips []string
for k, v := range networks {
s := fmt.Sprintf("%s:%s", k, v.IPAddress)
ips = append(ips, s)
}
return strings.Join(ips, "\n")
}
func (cm *Docker) refresh(c *container.Container) {
func (cm *DockerContainerSource) refresh(c *Container) {
insp := cm.inspect(c.Id)
// remove container if no longer exists
if insp == nil {
@@ -100,20 +69,14 @@ func (cm *Docker) refresh(c *container.Container) {
}
c.SetMeta("name", shortName(insp.Name))
c.SetMeta("image", insp.Config.Image)
c.SetMeta("IPs", ipsFormat(insp.NetworkSettings.Networks))
c.SetMeta("ports", portsFormat(insp.NetworkSettings.Ports))
c.SetMeta("created", insp.Created.Format("Mon Jan 2 15:04:05 2006"))
c.SetMeta("health", insp.State.Health.Status)
for _, env := range insp.Config.Env {
c.SetMeta("[ENV-VAR]", env)
}
c.SetState(insp.State.Status)
}
func (cm *Docker) inspect(id string) *api.Container {
func (cm *DockerContainerSource) inspect(id string) *docker.Container {
c, err := cm.client.InspectContainer(id)
if err != nil {
if _, ok := err.(*api.NoSuchContainer); !ok {
if _, ok := err.(*docker.NoSuchContainer); ok == false {
log.Errorf(err.Error())
}
}
@@ -121,8 +84,8 @@ func (cm *Docker) inspect(id string) *api.Container {
}
// Mark all container IDs for refresh
func (cm *Docker) refreshAll() {
opts := api.ListContainersOptions{All: true}
func (cm *DockerContainerSource) refreshAll() {
opts := docker.ListContainersOptions{All: true}
allContainers, err := cm.client.ListContainers(opts)
if err != nil {
panic(err)
@@ -136,7 +99,7 @@ func (cm *Docker) refreshAll() {
}
}
func (cm *Docker) Loop() {
func (cm *DockerContainerSource) Loop() {
for id := range cm.needsRefresh {
c := cm.MustGet(id)
cm.refresh(c)
@@ -144,16 +107,14 @@ func (cm *Docker) Loop() {
}
// Get a single container, creating one anew if not existing
func (cm *Docker) MustGet(id string) *container.Container {
func (cm *DockerContainerSource) MustGet(id string) *Container {
c, ok := cm.Get(id)
// append container struct for new containers
if !ok {
// create collector
collector := collector.NewDocker(cm.client, id)
// create manager
manager := manager.NewDocker(cm.client, id)
collector := metrics.NewDocker(cm.client, id)
// create container
c = container.New(id, collector, manager)
c = NewContainer(id, collector)
cm.lock.Lock()
cm.containers[id] = c
cm.lock.Unlock()
@@ -162,7 +123,7 @@ func (cm *Docker) MustGet(id string) *container.Container {
}
// Get a single container, by ID
func (cm *Docker) Get(id string) (*container.Container, bool) {
func (cm *DockerContainerSource) Get(id string) (*Container, bool) {
cm.lock.Lock()
c, ok := cm.containers[id]
cm.lock.Unlock()
@@ -170,7 +131,7 @@ func (cm *Docker) Get(id string) (*container.Container, bool) {
}
// Remove containers by ID
func (cm *Docker) delByID(id string) {
func (cm *DockerContainerSource) delByID(id string) {
cm.lock.Lock()
delete(cm.containers, id)
cm.lock.Unlock()
@@ -178,15 +139,14 @@ func (cm *Docker) delByID(id string) {
}
// Return array of all containers, sorted by field
func (cm *Docker) All() (containers container.Containers) {
func (cm *DockerContainerSource) All() (containers Containers) {
cm.lock.Lock()
for _, c := range cm.containers {
containers = append(containers, c)
}
containers.Sort()
containers.Filter()
cm.lock.Unlock()
sort.Sort(containers)
containers.Filter()
return containers
}

85
glide.lock generated Normal file
View File

@@ -0,0 +1,85 @@
hash: c13011881e895378f374b68596a59a0ea6def372b4f5239b2d8aa342eaa46a4b
updated: 2017-03-12T09:53:35.212073637+07:00
imports:
- name: github.com/Azure/go-ansiterm
version: fa152c58bc15761d0200cb75fe958b89a9d4888e
subpackages:
- winterm
- name: github.com/docker/docker
version: ce07fb6b0f1b8765b92022e45f96bd4349812e06
subpackages:
- api/types
- api/types/blkiodev
- api/types/container
- api/types/filters
- api/types/mount
- api/types/network
- api/types/registry
- api/types/strslice
- api/types/swarm
- api/types/versions
- opts
- pkg/archive
- pkg/fileutils
- pkg/homedir
- pkg/idtools
- pkg/ioutils
- pkg/jsonlog
- pkg/jsonmessage
- pkg/longpath
- pkg/pools
- pkg/promise
- pkg/stdcopy
- pkg/system
- pkg/term
- pkg/term/windows
- name: github.com/docker/go-connections
version: a2afab9802043837035592f1c24827fb70766de9
subpackages:
- nat
- name: github.com/docker/go-units
version: 0dadbb0345b35ec7ef35e228dabb8de89a65bf52
- name: github.com/fsouza/go-dockerclient
version: 318513eb1ab27495afbc67f671ba1080513d8aa0
- name: github.com/gizak/termui
version: ea10e6ccee219e572ffad0ac1909f1a17f6db7d6
repo: https://github.com/bcicen/termui
vcs: git
- name: github.com/hashicorp/go-cleanhttp
version: 3573b8b52aa7b37b9358d966a898feb387f62437
- name: github.com/jgautheron/codename-generator
version: 16d037c7cc3c9b552fe4af9828b7338d752dbaf9
- name: github.com/maruel/panicparse
version: 25bcac0d793cf4109483505a0d66e066a3a90a80
subpackages:
- stack
- name: github.com/mattn/go-runewidth
version: 14207d285c6c197daabb5c9793d63e7af9ab2d50
- name: github.com/Microsoft/go-winio
version: fff283ad5116362ca252298cfc9b95828956d85d
- name: github.com/mitchellh/go-wordwrap
version: ad45545899c7b13c020ea92b2072220eefad42b8
- name: github.com/nsf/termbox-go
version: 91bae1bb5fa9ee504905ecbe7043fa30e92feaa3
- name: github.com/nu7hatch/gouuid
version: 179d4d0c4d8d407a32af483c2354df1d2c91e6c3
- name: github.com/op/go-logging
version: b2cb9fa56473e98db8caba80237377e83fe44db5
- name: github.com/opencontainers/runc
version: 31980a53ae7887b2c8f8715d13c3eb486c27b6cf
subpackages:
- libcontainer/system
- libcontainer/user
- name: github.com/Sirupsen/logrus
version: 1deb2db2a6fff8a35532079061b903c3a25eed52
- name: golang.org/x/net
version: a6577fac2d73be281a500b310739095313165611
subpackages:
- context
- context/ctxhttp
- name: golang.org/x/sys
version: 99f16d856c9836c42d24e7ab64ea72916925fa97
subpackages:
- unix
- windows
testImports: []

11
glide.yaml Normal file
View File

@@ -0,0 +1,11 @@
package: github.com/bcicen/ctop
import:
- package: github.com/fsouza/go-dockerclient
- package: github.com/gizak/termui
version: barchart-numfmt
repo: https://github.com/bcicen/termui
vcs: git
- package: github.com/jgautheron/codename-generator
- package: github.com/nu7hatch/gouuid
- package: github.com/op/go-logging
version: ^1.0.0

41
go.mod
View File

@@ -1,41 +0,0 @@
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/coreos/go-systemd v0.0.0-20151104194251-b4a58d95188d // indirect
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
github.com/docker/go-units v0.3.2 // indirect
github.com/fsouza/go-dockerclient v0.0.0-20170307141636-318513eb1ab2
github.com/gizak/termui v2.3.0+incompatible
github.com/godbus/dbus v0.0.0-20151105175453-c7fdd8b5cd55 // indirect
github.com/golang/protobuf v0.0.0-20170712042213-0a4f71a498b7 // indirect
github.com/hashicorp/go-cleanhttp v0.0.0-20170211013415-3573b8b52aa7 // indirect
github.com/jgautheron/codename-generator v0.0.0-20150829203204-16d037c7cc3c
github.com/kr/pretty v0.1.0 // indirect
github.com/maruel/panicparse v0.0.0-20170227222818-25bcac0d793c // indirect
github.com/maruel/ut v1.0.0 // indirect
github.com/mattn/go-runewidth v0.0.0-20170201023540-14207d285c6c // indirect
github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 // indirect
github.com/nsf/termbox-go v0.0.0-20180303152453-e2050e41c884
github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d
github.com/op/go-logging v0.0.0-20160211212156-b2cb9fa56473
github.com/opencontainers/runc v0.1.1
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/seccomp/libseccomp-golang v0.0.0-20150813023252-1b506fc7c24e // indirect
github.com/stretchr/testify v1.2.2 // indirect
github.com/syndtr/gocapability v0.0.0-20150716010906-2c00daeb6c3b // indirect
github.com/vishvananda/netlink v0.0.0-20150820014904-1e2e08e8a2dc // indirect
github.com/vishvananda/netns v0.0.0-20180720170159-13995c7128cc // indirect
golang.org/x/net v0.0.0-20170308210134-a6577fac2d73 // indirect
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f // indirect
golang.org/x/sys v0.0.0-20170308153327-99f16d856c98 // indirect
)
replace github.com/gizak/termui => github.com/bcicen/termui v0.0.0-20180326052246-4eb80249d3f5

110
grid.go
View File

@@ -2,7 +2,7 @@ package main
import (
"github.com/bcicen/ctop/config"
"github.com/bcicen/ctop/cwidgets/single"
"github.com/bcicen/ctop/cwidgets/expanded"
ui "github.com/gizak/termui"
)
@@ -17,7 +17,6 @@ func RedrawRows(clr bool) {
header.SetFilter(config.GetVal("filterStr"))
y += header.Height()
}
cGrid.SetY(y)
for _, c := range cursor.filtered {
@@ -33,93 +32,61 @@ func RedrawRows(clr bool) {
}
cGrid.Align()
ui.Render(cGrid)
}
func SingleView() MenuFn {
c := cursor.Selected()
if c == nil {
return nil
}
func ExpandView(c *Container) {
ui.Clear()
ui.DefaultEvtStream.ResetHandlers()
defer ui.DefaultEvtStream.ResetHandlers()
ex := single.NewSingle(c.Id)
ex := expanded.NewExpanded(c.Id)
c.SetUpdater(ex)
ex.Align()
ui.Render(ex)
HandleKeys("up", ex.Up)
HandleKeys("down", ex.Down)
ui.Handle("/sys/kbd/", func(ui.Event) { ui.StopLoop() })
ui.Handle("/timer/1s", func(ui.Event) { ui.Render(ex) })
ui.Handle("/timer/1s", func(ui.Event) {
ui.Render(ex)
})
ui.Handle("/sys/wnd/resize", func(e ui.Event) {
ex.SetWidth(ui.TermWidth())
ex.Align()
log.Infof("resize: width=%v max-rows=%v", ex.Width, cGrid.MaxRows())
})
ui.Handle("/sys/kbd/", func(ui.Event) {
ui.StopLoop()
})
ui.Loop()
c.SetUpdater(c.Widgets)
return nil
}
func RefreshDisplay() {
// skip display refresh during scroll
if !cursor.isScrolling {
needsClear := cursor.RefreshContainers()
RedrawRows(needsClear)
}
}
func Display() bool {
var menu MenuFn
var menu func()
var expand bool
cGrid.SetWidth(ui.TermWidth())
ui.DefaultEvtStream.Hook(logEvent)
// initial draw
header.Align()
status.Align()
cursor.RefreshContainers()
RedrawRows(true)
HandleKeys("up", cursor.Up)
HandleKeys("down", cursor.Down)
HandleKeys("pgup", cursor.PgUp)
HandleKeys("pgdown", cursor.PgDown)
HandleKeys("exit", ui.StopLoop)
HandleKeys("help", func() {
menu = HelpMenu
ui.StopLoop()
})
ui.Handle("/sys/kbd/<up>", func(ui.Event) { cursor.Up() })
ui.Handle("/sys/kbd/<down>", func(ui.Event) { cursor.Down() })
ui.Handle("/sys/kbd/<enter>", func(ui.Event) {
menu = ContainerMenu
ui.StopLoop()
})
ui.Handle("/sys/kbd/<left>", func(ui.Event) {
menu = LogMenu
ui.StopLoop()
})
ui.Handle("/sys/kbd/<right>", func(ui.Event) {
menu = SingleView
ui.StopLoop()
})
ui.Handle("/sys/kbd/l", func(ui.Event) {
menu = LogMenu
ui.StopLoop()
})
ui.Handle("/sys/kbd/o", func(ui.Event) {
menu = SingleView
expand = true
ui.StopLoop()
})
ui.Handle("/sys/kbd/q", func(ui.Event) { ui.StopLoop() })
ui.Handle("/sys/kbd/C-c", func(ui.Event) { ui.StopLoop() })
ui.Handle("/sys/kbd/a", func(ui.Event) {
config.Toggle("allContainers")
RefreshDisplay()
@@ -131,6 +98,10 @@ func Display() bool {
menu = FilterMenu
ui.StopLoop()
})
ui.Handle("/sys/kbd/h", func(ui.Event) {
menu = HelpMenu
ui.StopLoop()
})
ui.Handle("/sys/kbd/H", func(ui.Event) {
config.Toggle("enableHeader")
RedrawRows(true)
@@ -142,26 +113,13 @@ func Display() bool {
menu = SortMenu
ui.StopLoop()
})
ui.Handle("/sys/kbd/S", func(ui.Event) {
path, err := config.Write()
if err == nil {
log.Statusf("wrote config to %s", path)
} else {
log.StatusErr(err)
}
ui.StopLoop()
})
ui.Handle("/timer/1s", func(e ui.Event) {
if log.StatusQueued() {
ui.StopLoop()
}
RefreshDisplay()
})
ui.Handle("/sys/wnd/resize", func(e ui.Event) {
header.Align()
status.Align()
cursor.ScrollPage()
cGrid.SetWidth(ui.TermWidth())
log.Infof("resize: width=%v max-rows=%v", cGrid.Width, cGrid.MaxRows())
@@ -169,24 +127,16 @@ func Display() bool {
})
ui.Loop()
if log.StatusQueued() {
for sm := range log.FlushStatus() {
if sm.IsError {
status.ShowErr(sm.Text)
} else {
status.Show(sm.Text)
}
}
return false
}
if menu != nil {
for menu != nil {
menu = menu()
menu()
return false
}
if expand {
c := cursor.Selected()
if c != nil {
ExpandView(c)
}
return false
}
return true
}

View File

@@ -1,84 +0,0 @@
#!/usr/bin/env bash
# a simple install script for ctop
KERNEL=$(uname -s)
function output() { echo -e "\033[32mctop-install\033[0m $@"; }
function command_exists() {
command -v "$@" > /dev/null 2>&1
}
# extract github download url matching pattern
function extract_url() {
match=$1; shift
echo "$@" | while read line; do
case $line in
*browser_download_url*${match}*)
url=$(echo $line | sed -e 's/^.*"browser_download_url":[ ]*"//' -e 's/".*//;s/\ //g')
echo $url
break
;;
esac
done
}
case $KERNEL in
Linux) MATCH_BUILD="linux-amd64" ;;
Darwin) MATCH_BUILD="darwin-amd64" ;;
*)
echo "platform not supported by this install script"
exit 1
;;
esac
for req in curl wget; do
command_exists $req || {
output "missing required $req binary"
req_failed=1
}
done
[ "$req_failed" == 1 ] && exit 1
sh_c='sh -c'
if [ "$CURRENT_USER" != 'root' ]; then
if command_exists sudo; then
sh_c='sudo -E sh -c'
elif command_exists su; then
sh_c='su -c'
else
output "Error: this installer needs the ability to run commands as root. We are unable to find either "sudo" or "su" available to make this happen."
exit 1
fi
fi
TMP=$(mktemp -d "${TMPDIR:-/tmp}/ctop.XXXXX")
cd ${TMP}
output "fetching latest release info"
resp=$(curl -s https://api.github.com/repos/bcicen/ctop/releases/latest)
output "fetching release checksums"
checksum_url=$(extract_url sha256sums.txt "$resp")
wget -q $checksum_url -O sha256sums.txt
# skip if latest already installed
cur_ctop=$(which ctop 2> /dev/null)
if [[ -n "$cur_ctop" ]]; then
cur_sum=$(sha256sum $cur_ctop | sed 's/ .*//')
(grep -q $cur_sum sha256sums.txt) && {
output "already up-to-date"
exit 0
}
fi
output "fetching latest ctop"
url=$(extract_url $MATCH_BUILD "$resp")
wget -q --show-progress $url
(sha256sum -c --quiet --ignore-missing sha256sums.txt) || exit 1
output "installing to /usr/local/bin"
chmod +x ctop-*
$sh_c "mv ctop-* /usr/local/bin/ctop"
output "done!"

41
keys.go
View File

@@ -1,41 +0,0 @@
package main
import (
ui "github.com/gizak/termui"
)
// Common action keybindings
var keyMap = map[string][]string{
"up": []string{
"/sys/kbd/<up>",
"/sys/kbd/k",
},
"down": []string{
"/sys/kbd/<down>",
"/sys/kbd/j",
},
"pgup": []string{
"/sys/kbd/<previous>",
"/sys/kbd/C-<up>",
},
"pgdown": []string{
"/sys/kbd/<next>",
"/sys/kbd/C-<down>",
},
"exit": []string{
"/sys/kbd/q",
"/sys/kbd/C-c",
"/sys/kbd/<escape>",
},
"help": []string{
"/sys/kbd/h",
"/sys/kbd/?",
},
}
// Apply a common handler function to all given keys
func HandleKeys(i string, f func()) {
for _, k := range keyMap[i] {
ui.Handle(k, func(ui.Event) { f() })
}
}

View File

@@ -1,8 +1,6 @@
package logging
import (
"fmt"
"os"
"time"
"github.com/op/go-logging"
@@ -15,42 +13,17 @@ const (
var (
Log *CTopLogger
exited bool
level = logging.INFO // default level
level = logging.INFO
format = logging.MustStringFormatter(
`%{color}%{time:15:04:05.000} ▶ %{level:.4s} %{id:03x}%{color:reset} %{message}`,
)
)
type statusMsg struct {
Text string
IsError bool
}
type CTopLogger struct {
*logging.Logger
backend *logging.MemoryBackend
sLog []statusMsg
}
func (c *CTopLogger) FlushStatus() chan statusMsg {
ch := make(chan statusMsg)
go func() {
for _, sm := range c.sLog {
ch <- sm
}
close(ch)
c.sLog = []statusMsg{}
}()
return ch
}
func (c *CTopLogger) StatusQueued() bool { return len(c.sLog) > 0 }
func (c *CTopLogger) Status(s string) { c.addStatus(statusMsg{s, false}) }
func (c *CTopLogger) StatusErr(err error) { c.addStatus(statusMsg{err.Error(), true}) }
func (c *CTopLogger) addStatus(sm statusMsg) { c.sLog = append(c.sLog, sm) }
func (c *CTopLogger) Statusf(s string, a ...interface{}) { c.Status(fmt.Sprintf(s, a...)) }
func Init() *CTopLogger {
if Log == nil {
logging.SetFormatter(format) // setup default formatter
@@ -58,12 +31,6 @@ func Init() *CTopLogger {
Log = &CTopLogger{
logging.MustGetLogger("ctop"),
logging.NewMemoryBackend(size),
[]statusMsg{},
}
if debugMode() {
level = logging.DEBUG
StartServer()
}
backendLvl := logging.AddModuleLevel(Log.backend)
@@ -104,6 +71,3 @@ func (log *CTopLogger) Exit() {
exited = true
StopServer()
}
func debugMode() bool { return os.Getenv("CTOP_DEBUG") == "1" }
func debugModeTCP() bool { return os.Getenv("CTOP_DEBUG_TCP") == "1" }

View File

@@ -2,14 +2,12 @@ package logging
import (
"fmt"
"io"
"net"
"sync"
)
const (
socketPath = "./ctop.sock"
socketAddr = "0.0.0.0:9000"
path = "/tmp/ctop.sock"
)
var server struct {
@@ -18,13 +16,7 @@ var server struct {
}
func getListener() net.Listener {
var ln net.Listener
var err error
if debugModeTCP() {
ln, err = net.Listen("tcp", socketAddr)
} else {
ln, err = net.Listen("unix", socketPath)
}
ln, err := net.Listen("unix", path)
if err != nil {
panic(err)
}
@@ -57,13 +49,13 @@ func StopServer() {
}
}
func handler(wc io.WriteCloser) {
func handler(conn net.Conn) {
server.wg.Add(1)
defer server.wg.Done()
defer wc.Close()
defer conn.Close()
for msg := range Log.tail() {
msg = fmt.Sprintf("%s\n", msg)
wc.Write([]byte(msg))
conn.Write([]byte(msg))
}
wc.Write([]byte("bye\n"))
conn.Write([]byte("bye\n"))
}

128
main.go
View File

@@ -1,155 +1,101 @@
package main
import (
"flag"
"fmt"
"os"
"runtime"
"strings"
"github.com/bcicen/ctop/config"
"github.com/bcicen/ctop/connector"
"github.com/bcicen/ctop/container"
"github.com/bcicen/ctop/cwidgets/compact"
"github.com/bcicen/ctop/logging"
"github.com/bcicen/ctop/widgets"
ui "github.com/gizak/termui"
tm "github.com/nsf/termbox-go"
)
var (
build = "none"
version = "dev-build"
goVersion = runtime.Version()
log *logging.CTopLogger
cursor *GridCursor
cGrid *compact.CompactGrid
header *widgets.CTopHeader
status *widgets.StatusLine
versionStr = fmt.Sprintf("ctop version %v, build %v %v", version, build, goVersion)
)
func main() {
readArgs()
defer panicExit()
// parse command line arguments
var (
versionFlag = flag.Bool("v", false, "output version information and exit")
helpFlag = flag.Bool("h", false, "display this help dialog")
filterFlag = flag.String("f", "", "filter containers")
activeOnlyFlag = flag.Bool("a", false, "show active containers only")
sortFieldFlag = flag.String("s", "", "select container sort field")
reverseSortFlag = flag.Bool("r", false, "reverse container sort order")
invertFlag = flag.Bool("i", false, "invert default colors")
scaleCpu = flag.Bool("scale-cpu", false, "show cpu as % of system total")
connectorFlag = flag.String("connector", "docker", "container connector to use")
)
flag.Parse()
if *versionFlag {
fmt.Println(versionStr)
os.Exit(0)
}
if *helpFlag {
printHelp()
os.Exit(0)
}
// init logger
log = logging.Init()
// init global config and read config file if exists
config.Init()
config.Read()
// override default config values with command line flags
if *filterFlag != "" {
config.Update("filterStr", *filterFlag)
}
if *activeOnlyFlag {
config.Toggle("allContainers")
}
if *sortFieldFlag != "" {
validSort(*sortFieldFlag)
config.Update("sortField", *sortFieldFlag)
}
if *reverseSortFlag {
config.Toggle("sortReversed")
}
if *scaleCpu {
config.Toggle("scaleCpu")
}
// init ui
if *invertFlag {
InvertColorMap()
}
ui.ColorMap = ColorMap // override default colormap
if err := ui.Init(); err != nil {
panic(err)
}
tm.SetInputMode(tm.InputAlt)
defer ui.Close()
defer Shutdown()
// init grid, cursor, header
conn, err := connector.ByName(*connectorFlag)
if err != nil {
panic(err)
// init global config
config.Init()
// init logger
log = logging.Init()
if config.GetSwitchVal("loggingEnabled") {
logging.StartServer()
}
cursor = &GridCursor{cSource: conn}
// init grid, cursor, header
cursor = NewGridCursor()
cGrid = compact.NewCompactGrid()
header = widgets.NewCTopHeader()
status = widgets.NewStatusLine()
for {
exit := Display()
if exit {
log.Notice("shutting down")
log.Exit()
return
}
}
}
func Shutdown() {
log.Notice("shutting down")
log.Exit()
if tm.IsInit {
ui.Close()
func readArgs() {
if len(os.Args) < 2 {
return
}
}
// ensure a given sort field is valid
func validSort(s string) {
if _, ok := container.Sorters[s]; !ok {
fmt.Printf("invalid sort field: %s\n", s)
for _, arg := range os.Args[1:] {
switch arg {
case "-v", "version":
printVersion()
os.Exit(0)
case "-h", "help":
printHelp()
os.Exit(0)
default:
fmt.Printf("invalid option or argument: \"%s\"\n", arg)
os.Exit(1)
}
}
}
func panicExit() {
if r := recover(); r != nil {
Shutdown()
fmt.Printf("error: %s\n", r)
ui.Clear()
fmt.Printf("panic: %s\n", r)
os.Exit(1)
}
}
var helpMsg = `ctop - interactive container viewer
var helpMsg = `ctop - container metric viewer
usage: ctop [options]
options:
-h display this help dialog
-v output version information and exit
`
func printHelp() {
fmt.Println(helpMsg)
flag.PrintDefaults()
fmt.Printf("\navailable connectors: ")
fmt.Println(strings.Join(connector.Enabled(), ", "))
}
func printVersion() {
fmt.Printf("ctop version %v, build %v\n", version, build)
}

240
menus.go
View File

@@ -1,35 +1,23 @@
package main
import (
"fmt"
"time"
"github.com/bcicen/ctop/config"
"github.com/bcicen/ctop/container"
"github.com/bcicen/ctop/widgets"
"github.com/bcicen/ctop/widgets/menu"
ui "github.com/gizak/termui"
)
// MenuFn executes a menu window, returning the next menu or nil
type MenuFn func() MenuFn
var helpDialog = []menu.Item{
{"<enter> - open container menu", ""},
{"", ""},
{"[a] - toggle display of all containers", ""},
{"[f] - filter displayed containers", ""},
{"[h] - open this help dialog", ""},
{"[H] - toggle ctop header", ""},
{"[s] - select container sort field", ""},
{"[r] - reverse container sort order", ""},
{"[o] - open single view", ""},
{"[l] - view container logs ([t] to toggle timestamp when open)", ""},
{"[S] - save current configuration to file", ""},
{"[q] - exit ctop", ""},
menu.Item{"[a] - toggle display of all containers", ""},
menu.Item{"[f] - filter displayed containers", ""},
menu.Item{"[h] - open this help dialog", ""},
menu.Item{"[H] - toggle ctop header", ""},
menu.Item{"[s] - select container sort field", ""},
menu.Item{"[r] - reverse container sort order", ""},
menu.Item{"[q] - exit ctop", ""},
}
func HelpMenu() MenuFn {
func HelpMenu() {
ui.Clear()
ui.DefaultEvtStream.ResetHandlers()
defer ui.DefaultEvtStream.ResetHandlers()
@@ -37,25 +25,20 @@ func HelpMenu() MenuFn {
m := menu.NewMenu()
m.BorderLabel = "Help"
m.AddItems(helpDialog...)
ui.Handle("/sys/wnd/resize", func(e ui.Event) {
ui.Clear()
ui.Render(m)
})
ui.Handle("/sys/kbd/", func(ui.Event) {
ui.StopLoop()
})
ui.Loop()
return nil
}
func FilterMenu() MenuFn {
func FilterMenu() {
ui.DefaultEvtStream.ResetHandlers()
defer ui.DefaultEvtStream.ResetHandlers()
i := widgets.NewInput()
i.BorderLabel = "Filter"
i.SetY(ui.TermHeight() - i.Height)
i.Data = config.GetVal("filterStr")
ui.Render(i)
// refresh container rows on input
@@ -69,19 +52,14 @@ func FilterMenu() MenuFn {
}()
i.InputHandlers()
ui.Handle("/sys/kbd/<escape>", func(ui.Event) {
config.Update("filterStr", "")
ui.StopLoop()
})
ui.Handle("/sys/kbd/<enter>", func(ui.Event) {
config.Update("filterStr", i.Data)
ui.StopLoop()
})
ui.Loop()
return nil
}
func SortMenu() MenuFn {
func SortMenu() {
ui.Clear()
ui.DefaultEvtStream.ResetHandlers()
defer ui.DefaultEvtStream.ResetHandlers()
@@ -91,210 +69,18 @@ func SortMenu() MenuFn {
m.SortItems = true
m.BorderLabel = "Sort Field"
for _, field := range container.SortFields() {
for _, field := range SortFields() {
m.AddItems(menu.Item{field, ""})
}
// set cursor position to current sort field
m.SetCursor(config.GetVal("sortField"))
HandleKeys("up", m.Up)
HandleKeys("down", m.Down)
HandleKeys("exit", ui.StopLoop)
ui.Render(m)
m.NavigationHandlers()
ui.Handle("/sys/kbd/<enter>", func(ui.Event) {
config.Update("sortField", m.SelectedItem().Val)
ui.StopLoop()
})
ui.Render(m)
ui.Loop()
return nil
}
func ContainerMenu() MenuFn {
c := cursor.Selected()
if c == nil {
return nil
}
ui.DefaultEvtStream.ResetHandlers()
defer ui.DefaultEvtStream.ResetHandlers()
m := menu.NewMenu()
m.Selectable = true
m.BorderLabel = "Menu"
items := []menu.Item{
menu.Item{Val: "single", Label: "single view"},
menu.Item{Val: "logs", Label: "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"})
}
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"})
}
if c.Meta["state"] == "paused" {
items = append(items, menu.Item{Val: "unpause", Label: "unpause"})
}
items = append(items, menu.Item{Val: "cancel", Label: "cancel"})
m.AddItems(items...)
ui.Render(m)
var nextMenu MenuFn
HandleKeys("up", m.Up)
HandleKeys("down", m.Down)
ui.Handle("/sys/kbd/<enter>", 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)
}
ui.StopLoop()
})
ui.Handle("/sys/kbd/", func(ui.Event) {
ui.StopLoop()
})
ui.Loop()
return nextMenu
}
func LogMenu() MenuFn {
c := cursor.Selected()
if c == nil {
return nil
}
ui.DefaultEvtStream.ResetHandlers()
defer ui.DefaultEvtStream.ResetHandlers()
logs, quit := logReader(c)
m := widgets.NewTextView(logs)
m.BorderLabel = fmt.Sprintf("Logs [%s]", c.GetMeta("name"))
ui.Render(m)
ui.Handle("/sys/wnd/resize", func(e ui.Event) {
m.Resize()
})
ui.Handle("/sys/kbd/t", func(ui.Event) {
m.Toggle()
})
ui.Handle("/sys/kbd/", func(ui.Event) {
quit <- true
ui.StopLoop()
})
ui.Loop()
return nil
}
// Create a confirmation dialog with a given description string and
// func to perform if confirmed
func Confirm(txt string, fn func()) MenuFn {
menu := func() MenuFn {
ui.DefaultEvtStream.ResetHandlers()
defer ui.DefaultEvtStream.ResetHandlers()
m := menu.NewMenu()
m.Selectable = true
m.BorderLabel = "Confirm"
m.SubText = txt
items := []menu.Item{
menu.Item{Val: "cancel", Label: "[c]ancel"},
menu.Item{Val: "yes", Label: "[y]es"},
}
var response bool
m.AddItems(items...)
ui.Render(m)
yes := func() {
response = true
ui.StopLoop()
}
no := func() {
response = false
ui.StopLoop()
}
HandleKeys("up", m.Up)
HandleKeys("down", m.Down)
HandleKeys("exit", no)
ui.Handle("/sys/kbd/c", func(ui.Event) { no() })
ui.Handle("/sys/kbd/y", func(ui.Event) { yes() })
ui.Handle("/sys/kbd/<enter>", func(ui.Event) {
switch m.SelectedItem().Val {
case "cancel":
no()
case "yes":
yes()
}
})
ui.Loop()
if response {
fn()
}
return nil
}
return menu
}
type toggleLog struct {
timestamp time.Time
message string
}
func (t *toggleLog) Toggle(on bool) string {
if on {
return fmt.Sprintf("%s %s", t.timestamp.Format("2006-01-02T15:04:05.999Z07:00"), t.message)
}
return t.message
}
func logReader(container *container.Container) (logs chan widgets.ToggleText, quit chan bool) {
logCollector := container.Logs()
stream := logCollector.Stream()
logs = make(chan widgets.ToggleText)
quit = make(chan bool)
go func() {
for {
select {
case log := <-stream:
logs <- &toggleLog{timestamp: log.Timestamp, message: log.Message}
case <-quit:
logCollector.Stop()
close(logs)
return
}
}
}()
return
}
func confirmTxt(a, n string) string { return fmt.Sprintf("%s container %s?", a, n) }

View File

@@ -1,36 +1,32 @@
package collector
package metrics
import (
"github.com/bcicen/ctop/config"
"github.com/bcicen/ctop/models"
api "github.com/fsouza/go-dockerclient"
)
// Docker collector
type Docker struct {
models.Metrics
Metrics
id string
client *api.Client
running bool
stream chan models.Metrics
stream chan Metrics
done chan bool
lastCpu float64
lastSysCpu float64
scaleCpu bool
}
func NewDocker(client *api.Client, id string) *Docker {
return &Docker{
Metrics: models.Metrics{},
Metrics: Metrics{},
id: id,
client: client,
scaleCpu: config.GetSwitchVal("scaleCpu"),
}
}
func (c *Docker) Start() {
c.done = make(chan bool)
c.stream = make(chan models.Metrics)
c.stream = make(chan Metrics)
stats := make(chan *api.Stats)
go func() {
@@ -50,7 +46,6 @@ func (c *Docker) Start() {
c.ReadCPU(s)
c.ReadMem(s)
c.ReadNet(s)
c.ReadIO(s)
c.stream <- c.Metrics
}
log.Infof("collector stopped for container: %s", c.id)
@@ -64,14 +59,10 @@ func (c *Docker) Running() bool {
return c.running
}
func (c *Docker) Stream() chan models.Metrics {
func (c *Docker) Stream() chan Metrics {
return c.stream
}
func (c *Docker) Logs() LogCollector {
return NewDockerLogs(c.id, c.client)
}
// Stop collector
func (c *Docker) Stop() {
c.done <- true
@@ -85,20 +76,15 @@ func (c *Docker) ReadCPU(stats *api.Stats) {
cpudiff := total - c.lastCpu
syscpudiff := system - c.lastSysCpu
if c.scaleCpu {
c.CPUUtil = round((cpudiff / syscpudiff * 100))
} else {
c.CPUUtil = round((cpudiff / syscpudiff * 100) * ncpus)
}
c.lastCpu = total
c.lastSysCpu = system
c.Pids = int(stats.PidsStats.Current)
}
func (c *Docker) ReadMem(stats *api.Stats) {
c.MemUsage = int64(stats.MemoryStats.Usage - stats.MemoryStats.Stats.Cache)
c.MemUsage = int64(stats.MemoryStats.Usage)
c.MemLimit = int64(stats.MemoryStats.Limit)
c.MemPercent = percent(float64(c.MemUsage), float64(c.MemLimit))
c.MemPercent = round((float64(c.MemUsage) / float64(c.MemLimit)) * 100)
}
func (c *Docker) ReadNet(stats *api.Stats) {
@@ -109,16 +95,3 @@ func (c *Docker) ReadNet(stats *api.Stats) {
}
c.NetRx, c.NetTx = rx, tx
}
func (c *Docker) ReadIO(stats *api.Stats) {
var read, write int64
for _, blk := range stats.BlkioStats.IOServiceBytesRecursive {
if blk.Op == "Read" {
read = int64(blk.Value)
}
if blk.Op == "Write" {
write = int64(blk.Value)
}
}
c.IOBytesRead, c.IOBytesWrite = read, write
}

39
metrics/main.go Normal file
View File

@@ -0,0 +1,39 @@
package metrics
import (
"math"
"github.com/bcicen/ctop/logging"
)
var log = logging.Init()
type Metrics struct {
CPUUtil int
NetTx int64
NetRx int64
MemLimit int64
MemPercent int
MemUsage int64
}
func NewMetrics() Metrics {
return Metrics{
CPUUtil: -1,
NetTx: -1,
NetRx: -1,
MemUsage: -1,
MemPercent: -1,
}
}
type Collector interface {
Stream() chan Metrics
Running() bool
Start()
Stop()
}
func round(num float64) int {
return int(num + math.Copysign(0.5, num))
}

View File

@@ -1,18 +1,16 @@
// +build !release
package collector
package metrics
import (
"math/rand"
"time"
"github.com/bcicen/ctop/models"
)
// Mock collector
type Mock struct {
models.Metrics
stream chan models.Metrics
Metrics
stream chan Metrics
done bool
running bool
aggression int64
@@ -20,7 +18,7 @@ type Mock struct {
func NewMock(a int64) *Mock {
c := &Mock{
Metrics: models.Metrics{},
Metrics: Metrics{},
aggression: a,
}
c.MemLimit = 2147483648
@@ -33,7 +31,7 @@ func (c *Mock) Running() bool {
func (c *Mock) Start() {
c.done = false
c.stream = make(chan models.Metrics)
c.stream = make(chan Metrics)
go c.run()
}
@@ -41,24 +39,15 @@ func (c *Mock) Stop() {
c.done = true
}
func (c *Mock) Stream() chan models.Metrics {
func (c *Mock) Stream() chan Metrics {
return c.stream
}
func (c *Mock) Logs() LogCollector {
return &MockLogs{make(chan bool)}
}
func (c *Mock) run() {
c.running = true
rand.Seed(int64(time.Now().Nanosecond()))
defer close(c.stream)
// set to random static value, once
c.Pids = rand.Intn(12)
c.IOBytesRead = rand.Int63n(8098) * c.aggression
c.IOBytesWrite = rand.Int63n(8098) * c.aggression
for {
c.CPUUtil += rand.Intn(2) * int(c.aggression)
if c.CPUUtil >= 100 {
@@ -71,7 +60,7 @@ func (c *Mock) run() {
if c.MemUsage > c.MemLimit {
c.MemUsage = 0
}
c.MemPercent = percent(float64(c.MemUsage), float64(c.MemLimit))
c.MemPercent = round((float64(c.MemUsage) / float64(c.MemLimit)) * 100)
c.stream <- c.Metrics
if c.done {
break

View File

@@ -1,34 +1,31 @@
// +build !release
package connector
package main
import (
"math/rand"
"sort"
"strings"
"time"
"github.com/bcicen/ctop/connector/collector"
"github.com/bcicen/ctop/connector/manager"
"github.com/bcicen/ctop/container"
"github.com/bcicen/ctop/metrics"
"github.com/jgautheron/codename-generator"
"github.com/nu7hatch/gouuid"
)
func init() { enabled["mock"] = NewMock }
type Mock struct {
containers container.Containers
type MockContainerSource struct {
containers Containers
}
func NewMock() Connector {
cs := &Mock{}
func NewMockContainerSource() *MockContainerSource {
cs := &MockContainerSource{}
go cs.Init()
go cs.Loop()
return cs
}
// Create Mock containers
func (cs *Mock) Init() {
func (cs *MockContainerSource) Init() {
rand.Seed(int64(time.Now().Nanosecond()))
for i := 0; i < 4; i++ {
@@ -41,16 +38,15 @@ func (cs *Mock) Init() {
}
func (cs *Mock) makeContainer(aggression int64) {
collector := collector.NewMock(aggression)
manager := manager.NewMock()
c := container.New(makeID(), collector, manager)
func (cs *MockContainerSource) makeContainer(aggression int64) {
collector := metrics.NewMock(aggression)
c := NewContainer(makeID(), collector)
c.SetMeta("name", makeName())
c.SetState(makeState())
cs.containers = append(cs.containers, c)
}
func (cs *Mock) Loop() {
func (cs *MockContainerSource) Loop() {
iter := 0
for {
// Change state for random container
@@ -64,7 +60,7 @@ func (cs *Mock) Loop() {
}
// Get a single container, by ID
func (cs *Mock) Get(id string) (*container.Container, bool) {
func (cs *MockContainerSource) Get(id string) (*Container, bool) {
for _, c := range cs.containers {
if c.Id == id {
return c, true
@@ -74,14 +70,14 @@ func (cs *Mock) Get(id string) (*container.Container, bool) {
}
// Return array of all containers, sorted by field
func (cs *Mock) All() container.Containers {
cs.containers.Sort()
func (cs *MockContainerSource) All() Containers {
sort.Sort(cs.containers)
cs.containers.Filter()
return cs.containers
}
// Remove containers by ID
func (cs *Mock) delByID(id string) {
func (cs *MockContainerSource) delByID(id string) {
for n, c := range cs.containers {
if c.Id == id {
cs.del(n)
@@ -91,7 +87,7 @@ func (cs *Mock) delByID(id string) {
}
// Remove one or more containers by index
func (cs *Mock) del(idx ...int) {
func (cs *MockContainerSource) del(idx ...int) {
for _, i := range idx {
cs.containers = append(cs.containers[:i], cs.containers[i+1:]...)
}

View File

@@ -1,33 +0,0 @@
package models
import "time"
type Log struct {
Timestamp time.Time
Message string
}
type Metrics struct {
CPUUtil int
NetTx int64
NetRx int64
MemLimit int64
MemPercent int
MemUsage int64
IOBytesRead int64
IOBytesWrite int64
Pids int
}
func NewMetrics() Metrics {
return Metrics{
CPUUtil: -1,
NetTx: -1,
NetRx: -1,
MemUsage: -1,
MemPercent: -1,
IOBytesRead: -1,
IOBytesWrite: -1,
Pids: -1,
}
}

View File

@@ -1,9 +1,8 @@
package container
package main
import (
"fmt"
"regexp"
"sort"
"github.com/bcicen/ctop/config"
)
@@ -54,22 +53,6 @@ var Sorters = map[string]sortMethod{
}
return sum1 > sum2
},
"pids": func(c1, c2 *Container) bool {
// Use secondary sort method if equal values
if c1.Pids == c2.Pids {
return nameSorter(c1, c2)
}
return c1.Pids > c2.Pids
},
"io": func(c1, c2 *Container) bool {
sum1 := sumIO(c1)
sum2 := sumIO(c2)
// Use secondary sort method if equal values
if sum1 == sum2 {
return nameSorter(c1, c2)
}
return sum1 > sum2
},
"state": func(c1, c2 *Container) bool {
// Use secondary sort method if equal values
c1state := c1.GetMeta("state")
@@ -90,7 +73,6 @@ func SortFields() (fields []string) {
type Containers []*Container
func (a Containers) Sort() { sort.Sort(a) }
func (a Containers) Len() int { return len(a) }
func (a Containers) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
func (a Containers) Less(i, j int) bool {
@@ -106,18 +88,16 @@ func (a Containers) Filter() {
re := regexp.MustCompile(fmt.Sprintf(".*%s", filter))
for _, c := range a {
c.Display = true
c.display = true
// Apply name filter
if re.FindAllString(c.GetMeta("name"), 1) == nil {
c.Display = false
c.display = false
}
// Apply state filter
if !config.GetSwitchVal("allContainers") && c.GetMeta("state") != "running" {
c.Display = false
c.display = false
}
}
}
func sumNet(c *Container) int64 { return c.NetRx + c.NetTx }
func sumIO(c *Container) int64 { return c.IOBytesRead + c.IOBytesWrite }

View File

@@ -17,8 +17,8 @@ type CTopHeader struct {
func NewCTopHeader() *CTopHeader {
return &CTopHeader{
Time: headerPar(2, timeStr()),
Count: headerPar(24, "-"),
Filter: headerPar(40, ""),
Count: headerPar(27, "-"),
Filter: headerPar(47, ""),
bg: headerBg(),
}
}

View File

@@ -7,7 +7,7 @@ import (
)
var (
input_chars = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_."
input_chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_."
)
type Padding [2]int // x,y padding
@@ -77,7 +77,7 @@ func (i *Input) KeyPress(e ui.Event) {
if len(i.Data) >= i.MaxLen {
return
}
if strings.Contains(input_chars, ch) {
if strings.Index(input_chars, ch) > -1 {
i.Data += ch
i.stream <- i.Data
ui.Render(i)

View File

@@ -11,7 +11,6 @@ type Padding [2]int // x,y padding
type Menu struct {
ui.Block
SortItems bool // enable automatic sorting of menu items
SubText string // optional text to display before items
TextFgColor ui.Attribute
TextBgColor ui.Attribute
Selectable bool
@@ -83,19 +82,9 @@ func (m *Menu) Buffer() ui.Buffer {
var cell ui.Cell
buf := m.Block.Buffer()
y := m.Y + m.padding[1]
if m.SubText != "" {
x := m.X + m.padding[0]
for i, ch := range m.SubText {
cell = ui.Cell{Ch: ch, Fg: m.TextFgColor, Bg: m.TextBgColor}
buf.Set(x+i, y, cell)
}
y += 2
}
for n, item := range m.items {
x := m.X + m.padding[0]
y := m.Y + m.padding[1]
for _, ch := range item.Text() {
// invert bg/fg colors on currently selected row
if m.Selectable && n == m.cursorPos {
@@ -111,40 +100,39 @@ func (m *Menu) Buffer() ui.Buffer {
return buf
}
func (m *Menu) Up() {
func (m *Menu) Up(ui.Event) {
if m.cursorPos > 0 {
m.cursorPos--
ui.Render(m)
}
}
func (m *Menu) Down() {
func (m *Menu) Down(ui.Event) {
if m.cursorPos < (len(m.items) - 1) {
m.cursorPos++
ui.Render(m)
}
}
// Setup some default handlers for menu navigation
func (m *Menu) NavigationHandlers() {
ui.Handle("/sys/kbd/<up>", m.Up)
ui.Handle("/sys/kbd/<down>", m.Down)
ui.Handle("/sys/kbd/q", func(ui.Event) { ui.StopLoop() })
}
// Set width and height based on menu items
func (m *Menu) calcSize() {
m.Width = 7 // minimum width
var height int
items := m.items
for _, i := range m.items {
s := i.Text()
if len(s) > m.Width {
m.Width = len(s)
}
height++
}
if m.SubText != "" {
if len(m.SubText) > m.Width {
m.Width = len(m.SubText)
}
height += 2
}
m.Width += (m.padding[0] * 2)
m.Height = height + (m.padding[1] * 2)
m.Height = len(items) + (m.padding[1] * 2)
}

View File

@@ -1,87 +0,0 @@
package widgets
import (
ui "github.com/gizak/termui"
)
var (
statusHeight = 1
statusIter = 3
)
type StatusLine struct {
Message *ui.Par
bg *ui.Par
}
func NewStatusLine() *StatusLine {
p := ui.NewPar("")
p.X = 2
p.Border = false
p.Height = statusHeight
p.Bg = ui.ThemeAttr("header.bg")
p.TextFgColor = ui.ThemeAttr("header.fg")
p.TextBgColor = ui.ThemeAttr("header.bg")
return &StatusLine{
Message: p,
bg: statusBg(),
}
}
func (sl *StatusLine) Display() {
ui.DefaultEvtStream.ResetHandlers()
defer ui.DefaultEvtStream.ResetHandlers()
iter := statusIter
ui.Handle("/sys/kbd/", func(ui.Event) {
ui.StopLoop()
})
ui.Handle("/timer/1s", func(ui.Event) {
iter--
if iter <= 0 {
ui.StopLoop()
}
})
ui.Render(sl)
ui.Loop()
}
// change given message on the status line
func (sl *StatusLine) Show(s string) {
sl.Message.TextFgColor = ui.ThemeAttr("header.fg")
sl.Message.Text = s
sl.Display()
}
func (sl *StatusLine) ShowErr(s string) {
sl.Message.TextFgColor = ui.ThemeAttr("status.danger")
sl.Message.Text = s
sl.Display()
}
func (sl *StatusLine) Buffer() ui.Buffer {
buf := ui.NewBuffer()
buf.Merge(sl.bg.Buffer())
buf.Merge(sl.Message.Buffer())
return buf
}
func (sl *StatusLine) Align() {
sl.bg.SetWidth(ui.TermWidth() - 1)
sl.Message.SetWidth(ui.TermWidth() - 2)
sl.bg.Y = ui.TermHeight() - 1
sl.Message.Y = ui.TermHeight() - 1
}
func (sl *StatusLine) Height() int { return statusHeight }
func statusBg() *ui.Par {
bg := ui.NewPar("")
bg.X = 1
bg.Height = statusHeight
bg.Border = false
bg.Bg = ui.ThemeAttr("header.bg")
return bg
}

View File

@@ -1,124 +0,0 @@
package widgets
import (
ui "github.com/gizak/termui"
)
type ToggleText interface {
// returns text for toggle on/off
Toggle(on bool) string
}
type TextView struct {
ui.Block
inputStream <-chan ToggleText
render chan bool
toggleState bool
Text []ToggleText // all the text
TextOut []string // text to be displayed
TextFgColor ui.Attribute
TextBgColor ui.Attribute
padding Padding
}
func NewTextView(lines <-chan ToggleText) *TextView {
t := &TextView{
Block: *ui.NewBlock(),
inputStream: lines,
render: make(chan bool),
Text: []ToggleText{},
TextOut: []string{},
TextFgColor: ui.ThemeAttr("menu.text.fg"),
TextBgColor: ui.ThemeAttr("menu.text.bg"),
padding: Padding{4, 2},
}
t.BorderFg = ui.ThemeAttr("menu.border.fg")
t.BorderLabelFg = ui.ThemeAttr("menu.label.fg")
t.Height = ui.TermHeight()
t.Width = ui.TermWidth()
t.readInputLoop()
t.renderLoop()
return t
}
// Adjusts text inside this view according to the window size. No need to call ui.Render(...)
// after calling this method, it is called automatically
func (t *TextView) Resize() {
ui.Clear()
t.Height = ui.TermHeight()
t.Width = ui.TermWidth()
t.render <- true
}
// Toggles text inside this view. No need to call ui.Render(...) after calling this method,
// it is called automatically
func (t *TextView) Toggle() {
t.toggleState = !t.toggleState
t.render <- true
}
func (t *TextView) Buffer() ui.Buffer {
var cell ui.Cell
buf := t.Block.Buffer()
x := t.Block.X + t.padding[0]
y := t.Block.Y + t.padding[1]
for _, line := range t.TextOut {
for _, ch := range line {
cell = ui.Cell{Ch: ch, Fg: t.TextFgColor, Bg: t.TextBgColor}
buf.Set(x, y, cell)
x++
}
x = t.Block.X + t.padding[0]
y++
}
return buf
}
func (t *TextView) renderLoop() {
go func() {
for range t.render {
maxWidth := t.Width - (t.padding[0] * 2)
height := t.Height - (t.padding[1] * 2)
t.TextOut = []string{}
for i := len(t.Text) - 1; i >= 0; i-- {
lines := splitLine(t.Text[i].Toggle(t.toggleState), maxWidth)
t.TextOut = append(lines, t.TextOut...)
if len(t.TextOut) > height {
t.TextOut = t.TextOut[:height]
break
}
}
ui.Render(t)
}
}()
}
func (t *TextView) readInputLoop() {
go func() {
for line := range t.inputStream {
t.Text = append(t.Text, line)
t.render <- true
}
close(t.render)
}()
}
func splitLine(line string, lineSize int) []string {
if line == "" {
return []string{}
}
var lines []string
for {
if len(line) <= lineSize {
lines = append(lines, line)
return lines
}
lines = append(lines, line[:lineSize])
line = line[lineSize:]
}
}

View File

@@ -1,35 +0,0 @@
package widgets
import "testing"
func TestSplitEmptyLine(t *testing.T) {
result := splitLine("", 5)
if len(result) != 0 {
t.Errorf("expected: 0 lines, got: %d", len(result))
}
}
func TestSplitLineShorterThanLimit(t *testing.T) {
result := splitLine("hello", 7)
if len(result) != 1 {
t.Errorf("expected: 0 lines, got: %d", len(result))
}
}
func TestSplitLineLongerThanLimit(t *testing.T) {
result := splitLine("hello", 3)
if len(result) != 2 {
t.Errorf("expected: 0 lines, got: %d", len(result))
}
}
func TestSplitLineSameAsLimit(t *testing.T) {
result := splitLine("hello", 5)
if len(result) != 1 {
t.Errorf("expected: 0 lines, got: %d", len(result))
}
}