Repository: bcicen/ctop Branch: master Commit: 59f00dd6aaeb Files: 75 Total size: 161.3 KB Directory structure: gitextract_xqko93bg/ ├── .circleci/ │ └── config.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── VERSION ├── _docs/ │ ├── build.md │ ├── connectors.md │ ├── debug.md │ ├── single.md │ └── status.md ├── colors.go ├── config/ │ ├── columns.go │ ├── file.go │ ├── main.go │ ├── param.go │ └── switch.go ├── connector/ │ ├── collector/ │ │ ├── docker.go │ │ ├── docker_logs.go │ │ ├── main.go │ │ ├── mock.go │ │ ├── mock_logs.go │ │ ├── proc.go │ │ └── runc.go │ ├── docker.go │ ├── main.go │ ├── manager/ │ │ ├── docker.go │ │ ├── main.go │ │ ├── mock.go │ │ └── runc.go │ ├── mock.go │ └── runc.go ├── container/ │ ├── main.go │ └── sort.go ├── cursor.go ├── cwidgets/ │ ├── compact/ │ │ ├── column.go │ │ ├── gauge.go │ │ ├── grid.go │ │ ├── header.go │ │ ├── row.go │ │ ├── status.go │ │ ├── text.go │ │ └── util.go │ ├── main.go │ ├── single/ │ │ ├── cpu.go │ │ ├── env.go │ │ ├── hist.go │ │ ├── info.go │ │ ├── io.go │ │ ├── logs.go │ │ ├── main.go │ │ ├── mem.go │ │ └── net.go │ └── util.go ├── debug.go ├── go.mod ├── go.sum ├── grid.go ├── install.sh ├── keys.go ├── logging/ │ ├── main.go │ └── server.go ├── main.go ├── menus.go ├── models/ │ └── main.go └── widgets/ ├── error.go ├── header.go ├── input.go ├── menu/ │ ├── items.go │ ├── main.go │ └── tooltip.go ├── status.go ├── view.go └── view_test.go ================================================ FILE CONTENTS ================================================ ================================================ FILE: .circleci/config.yml ================================================ version: 2 jobs: build: working_directory: ~/build docker: - image: cimg/go:1.18 steps: - checkout - setup_remote_docker: version: 20.10.11 - 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 ================================================ FILE: .gitignore ================================================ ctop .idea /vendor/ *.log ================================================ FILE: Dockerfile ================================================ FROM quay.io/vektorcloud/go:1.18 RUN apk add --no-cache make 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"] ================================================ FILE: LICENSE ================================================ The MIT License (MIT) Copyright (c) 2017 VektorLab Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: Makefile ================================================ NAME=ctop VERSION=$(shell cat VERSION) BUILD=$(shell git rev-parse --short HEAD) LD_FLAGS="-w -X main.version=$(VERSION) -X main.build=$(BUILD)" clean: rm -rf _build/ release/ build: go mod download CGO_ENABLED=0 go build -tags release -ldflags $(LD_FLAGS) -o ctop build-all: mkdir -p _build GOOS=darwin GOARCH=amd64 CGO_ENABLED=0 go build -tags release -ldflags $(LD_FLAGS) -o _build/ctop-$(VERSION)-darwin-amd64 GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -tags release -ldflags $(LD_FLAGS) -o _build/ctop-$(VERSION)-linux-amd64 GOOS=linux GOARCH=arm CGO_ENABLED=0 go build -tags release -ldflags $(LD_FLAGS) -o _build/ctop-$(VERSION)-linux-arm GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -tags release -ldflags $(LD_FLAGS) -o _build/ctop-$(VERSION)-linux-arm64 GOOS=linux GOARCH=ppc64le CGO_ENABLED=0 go build -tags release -ldflags $(LD_FLAGS) -o _build/ctop-$(VERSION)-linux-ppc64le GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -tags release -ldflags $(LD_FLAGS) -o _build/ctop-$(VERSION)-windows-amd64 cd _build; sha256sum * > sha256sums.txt run-dev: rm -f ctop.sock ctop go build -ldflags $(LD_FLAGS) -o ctop CTOP_DEBUG=1 ./ctop image: docker build -t ctop -f Dockerfile . release: mkdir release cp _build/* release cd release; sha256sum --quiet --check sha256sums.txt && \ gh release create v$(VERSION) -d -t v$(VERSION) * .PHONY: build ================================================ FILE: README.md ================================================

ctop

# ![release][release] ![homebrew][homebrew] ![macports][macports] ![scoop][scoop] Top-like interface for container metrics `ctop` provides a concise and condensed overview of real-time metrics for multiple containers:

ctop

as well as a [single container view][single_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. ## Install Fetch the [latest release](https://github.com/bcicen/ctop/releases) for your platform: #### Debian/Ubuntu Maintained by a [third party](https://packages.azlux.fr/) ```bash sudo apt-get install ca-certificates curl gnupg lsb-release curl -fsSL https://azlux.fr/repo.gpg.key | sudo gpg --dearmor -o /usr/share/keyrings/azlux-archive-keyring.gpg echo \ "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/azlux-archive-keyring.gpg] http://packages.azlux.fr/debian \ $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/azlux.list >/dev/null sudo apt-get update sudo apt-get install docker-ctop ``` #### Arch ```bash sudo pacman -S ctop ``` _`ctop` is also available for Arch in the [AUR](https://aur.archlinux.org/packages/ctop-bin/)_ #### Linux (Generic) ```bash sudo wget https://github.com/bcicen/ctop/releases/download/v0.7.7/ctop-0.7.7-linux-amd64 -O /usr/local/bin/ctop sudo chmod +x /usr/local/bin/ctop ``` #### OS X ```bash brew install ctop ``` or ```bash sudo port install ctop ``` or ```bash sudo curl -Lo /usr/local/bin/ctop https://github.com/bcicen/ctop/releases/download/v0.7.7/ctop-0.7.7-darwin-amd64 sudo chmod +x /usr/local/bin/ctop ``` #### Windows `ctop` is available in [scoop](https://scoop.sh/): ```powershell scoop install ctop ``` #### Docker ```bash docker run --rm -ti \ --name=ctop \ --volume /var/run/docker.sock:/var/run/docker.sock:ro \ quay.io/vektorlab/ctop:latest ``` ## Building Build steps can be found [here][build]. ## 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 (`~/.config/ctop/config` on XDG systems, else `~/.ctop`). Config file values will be loaded and applied the next time `ctop` is started. ### Options Option | Description --- | --- `-a` | show active containers only `-f ` | set an initial filter string `-h` | display help dialog `-i` | invert default colors `-r` | reverse container sort order `-s` | select initial container sort field `-v` | output version information and exit ### 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) | | 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) | | e | Exec Shell | | c | Configure columns | | 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" [macports]: https://repology.org/badge/version-for-repo/macports/ctop.svg?header=macports "ctop" [scoop]: https://img.shields.io/scoop/v/ctop?bucket=main "ctop" ## Alternatives See [Awesome Docker list](https://github.com/veggiemonk/awesome-docker/blob/master/README.md#terminal) for similar tools to work with Docker. ================================================ FILE: VERSION ================================================ 0.7.7 ================================================ FILE: _docs/build.md ================================================ # Build To build `ctop` from source, simply clone the repo and run: ```bash make build ``` To build a minimal Docker image containing only `ctop`: ```bash make image ``` Now you can run your local image: ```bash docker run --rm -ti \ --name ctop \ -v /var/run/docker.sock:/var/run/docker.sock \ ctop:latest ``` ================================================ FILE: _docs/connectors.md ================================================ # 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 ================================================ FILE: _docs/debug.md ================================================ # 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`) ## Log to file You can also log to a file by specifying `CTOP_DEBUG_FILE=/path/to/ctop.log` environment variable: ```sh CTOP_DEBUG=1 CTOP_DEBUG_FILE=ctop.log ./ctop ``` This is useful for GoLand to see logs right in debug panel: * Edit Run configuration * Go to Logs tab * Specify this log file in "Log file to be shown in console". Then during debugging you'll see the log tab in debug panel: ![Debug in GoLand](img/goland_debug.png) ================================================ FILE: _docs/single.md ================================================ # Single Container View ctop provides a rolling, single container view for following metrics

ctop

================================================ FILE: _docs/status.md ================================================ # Status Indicator The `ctop` grid view provides a compact status indicator to convey container state ctop ### Status Appearance | Description --- | --- red | container is stopped green | container is running ▮▮ | container is paused ### Health If the container is configured with a health check, a `+` will appear next to the indicator Appearance | Description --- | --- red | health check in failed state yellow | health check in starting state green | health check in OK state ================================================ FILE: colors.go ================================================ package main import ( "regexp" ui "github.com/gizak/termui" ) /* Valid colors: ui.ColorDefault ui.ColorBlack ui.ColorRed ui.ColorGreen ui.ColorYellow ui.ColorBlue ui.ColorMagenta ui.ColorCyan ui.ColorWhite */ var ColorMap = map[string]ui.Attribute{ "fg": ui.ColorWhite, "bg": ui.ColorDefault, "block.bg": ui.ColorDefault, "border.bg": ui.ColorDefault, "border.fg": ui.ColorWhite, "label.bg": ui.ColorDefault, "label.fg": ui.ColorGreen, "menu.text.fg": ui.ColorWhite, "menu.text.bg": ui.ColorDefault, "menu.border.fg": ui.ColorCyan, "menu.label.fg": ui.ColorGreen, "header.fg": ui.ColorBlack, "header.bg": ui.ColorWhite, "gauge.bar.bg": ui.ColorGreen, "gauge.percent.fg": ui.ColorWhite, "linechart.axes.fg": ui.ColorDefault, "linechart.line.fg": ui.ColorGreen, "mbarchart.bar.bg": ui.ColorGreen, "mbarchart.num.fg": ui.ColorWhite, "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 } ================================================ FILE: config/columns.go ================================================ package config import ( "strings" ) // defaults var defaultColumns = []Column{ { Name: "status", Label: "Status Indicator", Enabled: true, }, { Name: "name", Label: "Container Name", Enabled: true, }, { Name: "id", Label: "Container ID", Enabled: true, }, { Name: "image", Label: "Image name", Enabled: false, }, { Name: "ports", Label: "Exposed ports", Enabled: false, }, { Name: "IPs", Label: "Exposed IPs", Enabled: false, }, { Name: "created", Label: "Date created", Enabled: false, }, { Name: "cpu", Label: "CPU Usage", Enabled: true, }, { Name: "cpus", Label: "CPU Usage (% of system total)", Enabled: false, }, { Name: "mem", Label: "Memory Usage", Enabled: true, }, { Name: "net", Label: "Network RX/TX", Enabled: true, }, { Name: "io", Label: "Disk IO Read/Write", Enabled: true, }, { Name: "pids", Label: "Container PID Count", Enabled: true, }, Column{ Name: "uptime", Label: "Running uptime duration", Enabled: true, }, } type Column struct { Name string Label string Enabled bool } // ColumnsString returns an ordered and comma-delimited string of currently enabled Columns func ColumnsString() string { return strings.Join(EnabledColumns(), ",") } // EnabledColumns returns an ordered array of enabled column names func EnabledColumns() (a []string) { lock.RLock() defer lock.RUnlock() for _, col := range GlobalColumns { if col.Enabled { a = append(a, col.Name) } } return a } // ColumnToggle toggles the enabled status of a given column name func ColumnToggle(name string) { col := GlobalColumns[colIndex(name)] col.Enabled = !col.Enabled log.Noticef("config change [column-%s]: %t -> %t", col.Name, !col.Enabled, col.Enabled) } // ColumnLeft moves the column with given name up one position, if possible func ColumnLeft(name string) { idx := colIndex(name) if idx > 0 { swapCols(idx, idx-1) } } // ColumnRight moves the column with given name up one position, if possible func ColumnRight(name string) { idx := colIndex(name) if idx < len(GlobalColumns)-1 { swapCols(idx, idx+1) } } // Set Column order and enabled status from one or more provided Column names func SetColumns(names []string) { var ( n int curColStr = ColumnsString() newColumns = make([]*Column, len(GlobalColumns)) ) lock.Lock() // add enabled columns by name for _, name := range names { newColumns[n] = popColumn(name) newColumns[n].Enabled = true n++ } // extend with omitted columns as disabled for _, col := range GlobalColumns { newColumns[n] = col newColumns[n].Enabled = false n++ } GlobalColumns = newColumns lock.Unlock() log.Noticef("config change [columns]: %s -> %s", curColStr, ColumnsString()) } func swapCols(i, j int) { GlobalColumns[i], GlobalColumns[j] = GlobalColumns[j], GlobalColumns[i] } func popColumn(name string) *Column { idx := colIndex(name) if idx < 0 { panic("no such column name: " + name) } col := GlobalColumns[idx] GlobalColumns = append(GlobalColumns[:idx], GlobalColumns[idx+1:]...) return col } // return index of column with given name, if any func colIndex(name string) int { for n, c := range GlobalColumns { if c.Name == name { return n } } return -1 } ================================================ FILE: config/file.go ================================================ package config import ( "fmt" "os" "path/filepath" "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 { // update columns param from working config Update("columns", ColumnsString()) lock.RLock() defer lock.RUnlock() 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) } // set working column config, if provided colStr := GetVal("columns") if len(colStr) > 0 { var colNames []string for _, s := range strings.Split(colStr, ",") { s = strings.TrimSpace(s) if s != "" { colNames = append(colNames, s) } } SetColumns(colNames) } return nil } func Write() (path string, err error) { path, err = getConfigPath() if err != nil { return path, err } cfgdir := filepath.Dir(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) } } // remove prior to writing new file if err := os.Remove(path); err != nil { if !os.IsNotExist(err) { return path, 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 } ================================================ FILE: config/main.go ================================================ package config import ( "fmt" "os" "sync" "github.com/bcicen/ctop/logging" ) var ( GlobalParams []*Param GlobalSwitches []*Switch GlobalColumns []*Column lock sync.RWMutex log = logging.Init() ) func Init() { for _, p := range defaultParams { GlobalParams = append(GlobalParams, p) log.Infof("loaded default config param [%s]: %s", quote(p.Key), quote(p.Val)) } for _, s := range defaultSwitches { GlobalSwitches = append(GlobalSwitches, s) log.Infof("loaded default config switch [%s]: %t", quote(s.Key), s.Val) } for _, c := range defaultColumns { x := c GlobalColumns = append(GlobalColumns, &x) log.Infof("loaded default widget config [%s]: %t", quote(x.Name), x.Enabled) } } func quote(s string) string { return fmt.Sprintf("\"%s\"", s) } // Return env var value if set, else return defaultVal func getEnv(key, defaultVal string) string { val := os.Getenv(key) if val != "" { return val } return defaultVal } ================================================ FILE: config/param.go ================================================ package config // defaults var defaultParams = []*Param{ &Param{ Key: "filterStr", Val: "", Label: "Container Name or ID Filter", }, &Param{ Key: "sortField", Val: "state", Label: "Container Sort Field", }, &Param{ Key: "columns", Val: "status,name,id,cpu,mem,net,io,pids,uptime", Label: "Enabled Columns", }, } type Param struct { Key string Val string Label string } // Get Param by key func Get(k string) *Param { lock.RLock() defer lock.RUnlock() for _, p := range GlobalParams { if p.Key == k { return p } } return &Param{} // default } // GetVal gets Param value by key func GetVal(k string) string { return Get(k).Val } // Set param value func Update(k, v string) { p := Get(k) log.Noticef("config change [%s]: %s -> %s", k, quote(p.Val), quote(v)) lock.Lock() defer lock.Unlock() p.Val = v // log.Errorf("ignoring update for non-existant parameter: %s", k) } ================================================ FILE: config/switch.go ================================================ package config // defaults var defaultSwitches = []*Switch{ &Switch{ Key: "sortReversed", Val: false, 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)", }, &Switch{ Key: "enableHeader", Val: true, Label: "Enable status header", }, } type Switch struct { Key string Val bool Label string } // GetSwitch returns Switch by key func GetSwitch(k string) *Switch { lock.RLock() defer lock.RUnlock() for _, sw := range GlobalSwitches { if sw.Key == k { return sw } } return &Switch{} // default } // GetSwitchVal returns Switch value by key func GetSwitchVal(k string) bool { return GetSwitch(k).Val } func UpdateSwitch(k string, val bool) { sw := GetSwitch(k) lock.Lock() defer lock.Unlock() 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) lock.Lock() defer lock.Unlock() sw.Val = !sw.Val log.Noticef("config change [%s]: %t -> %t", k, !sw.Val, sw.Val) //log.Errorf("ignoring toggle for non-existant switch: %s", k) } ================================================ FILE: connector/collector/docker.go ================================================ package collector import ( "github.com/bcicen/ctop/models" api "github.com/fsouza/go-dockerclient" ) // Docker collector type Docker struct { models.Metrics id string client *api.Client running bool stream chan models.Metrics done chan bool lastCpu float64 lastSysCpu float64 } func NewDocker(client *api.Client, id string) *Docker { return &Docker{ Metrics: models.Metrics{}, id: id, client: client, } } func (c *Docker) Start() { c.done = make(chan bool) c.stream = make(chan models.Metrics) stats := make(chan *api.Stats) go func() { opts := api.StatsOptions{ ID: c.id, Stats: stats, Stream: true, Done: c.done, } c.client.Stats(opts) c.running = false }() go func() { defer close(c.stream) for s := range stats { 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) }() c.running = true log.Infof("collector started for container: %s", c.id) } func (c *Docker) Running() bool { return c.running } func (c *Docker) Stream() chan models.Metrics { return c.stream } func (c *Docker) Logs() LogCollector { return NewDockerLogs(c.id, c.client) } // Stop collector func (c *Docker) Stop() { c.running = false c.done <- true } func (c *Docker) ReadCPU(stats *api.Stats) { ncpus := uint8(stats.CPUStats.OnlineCPUs) if ncpus == 0 { ncpus = uint8(len(stats.CPUStats.CPUUsage.PercpuUsage)) } total := float64(stats.CPUStats.CPUUsage.TotalUsage) system := float64(stats.CPUStats.SystemCPUUsage) cpudiff := total - c.lastCpu syscpudiff := system - c.lastSysCpu c.NCpus = ncpus c.CPUUtil = percent(cpudiff, syscpudiff) 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.MemLimit = int64(stats.MemoryStats.Limit) c.MemPercent = percent(float64(c.MemUsage), float64(c.MemLimit)) } func (c *Docker) ReadNet(stats *api.Stats) { var rx, tx int64 for _, network := range stats.Networks { rx += int64(network.RxBytes) tx += int64(network.TxBytes) } 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 } ================================================ FILE: connector/collector/docker_logs.go ================================================ 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: "20", Follow: true, Timestamps: true, RawTerminal: true, } // read io pipe into channel go func() { scanner := bufio.NewScanner(r) for scanner.Scan() { parts := strings.SplitN(scanner.Text(), " ", 2) if len(parts) == 0 { continue } if len(parts) < 2 { logCh <- models.Log{Timestamp: l.parseTime(""), Message: parts[0]} } else { logCh <- models.Log{Timestamp: l.parseTime(parts[0]), Message: 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(time.RFC3339Nano, s) if err == nil { return ts } ts, err2 := time.Parse(time.RFC3339Nano, l.stripPfx(s)) if err2 == nil { return ts } log.Errorf("failed to parse container log: %s", err) log.Errorf("failed to parse container log2: %s", err2) return time.Now() } // attempt to strip message header prefix from a given raw docker log string func (l *DockerLogs) stripPfx(s string) string { b := []byte(s) if len(b) > 8 { return string(b[8:]) } return s } ================================================ FILE: connector/collector/main.go ================================================ 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) } ================================================ FILE: connector/collector/mock.go ================================================ //go:build !release // +build !release package collector import ( "math/rand" "time" "github.com/bcicen/ctop/models" ) // Mock collector type Mock struct { models.Metrics stream chan models.Metrics done bool running bool aggression int64 } func NewMock(a int64) *Mock { c := &Mock{ Metrics: models.Metrics{}, aggression: a, } c.MemLimit = 2147483648 return c } func (c *Mock) Running() bool { return c.running } func (c *Mock) Start() { c.done = false c.stream = make(chan models.Metrics) go c.run() } func (c *Mock) Stop() { c.running = false c.done = true } func (c *Mock) Stream() chan models.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 { c.CPUUtil = 0 } c.NetTx += rand.Int63n(60) * c.aggression c.NetRx += rand.Int63n(60) * c.aggression c.MemUsage += rand.Int63n(c.MemLimit/512) * c.aggression if c.MemUsage > c.MemLimit { c.MemUsage = 0 } c.MemPercent = percent(float64(c.MemUsage), float64(c.MemLimit)) c.stream <- c.Metrics if c.done { break } time.Sleep(1 * time.Second) } c.running = false } ================================================ FILE: connector/collector/mock_logs.go ================================================ 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 } ================================================ FILE: connector/collector/proc.go ================================================ //go:build linux // +build linux package collector import ( linuxproc "github.com/c9s/goprocinfo/linux" ) var sysMemTotal = getSysMemTotal() const ( clockTicksPerSecond uint64 = 100 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 } ================================================ FILE: connector/collector/runc.go ================================================ //go:build linux // +build linux package collector import ( "time" "github.com/opencontainers/runc/libcontainer" "github.com/opencontainers/runc/libcontainer/cgroups" "github.com/opencontainers/runc/types" "github.com/bcicen/ctop/models" ) // 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 } func NewRunc(libc libcontainer.Container) *Runc { c := &Runc{ Metrics: models.Metrics{}, id: libc.ID(), libc: libc, interval: 1, } 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.running = false 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 := uint8(len(u.PercpuUsage)) total := float64(u.TotalUsage) system := float64(getSysCPUUsage()) cpudiff := total - c.lastCpu syscpudiff := system - c.lastSysCpu c.NCpus = ncpus c.CPUUtil = percent(cpudiff, syscpudiff) 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 []*types.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 } ================================================ FILE: connector/docker.go ================================================ package connector import ( "fmt" "strings" "sync" "time" "github.com/op/go-logging" "github.com/hako/durafmt" "github.com/bcicen/ctop/connector/collector" "github.com/bcicen/ctop/connector/manager" "github.com/bcicen/ctop/container" api "github.com/fsouza/go-dockerclient" ) func init() { enabled["docker"] = NewDocker } var actionToStatus = map[string]string{ "start": "running", "die": "exited", "stop": "exited", "pause": "paused", "unpause": "running", } type StatusUpdate struct { Cid string Field string // "status" or "health" Status string } type Docker struct { client *api.Client containers map[string]*container.Container needsRefresh chan string // container IDs requiring refresh statuses chan StatusUpdate closed chan struct{} lock sync.RWMutex } func NewDocker() (Connector, error) { // init docker client client, err := api.NewClientFromEnv() if err != nil { return nil, err } cm := &Docker{ client: client, containers: make(map[string]*container.Container), needsRefresh: make(chan string, 60), statuses: make(chan StatusUpdate, 60), closed: make(chan struct{}), lock: sync.RWMutex{}, } // query info as pre-flight healthcheck info, err := client.Info() if err != nil { return nil, err } log.Debugf("docker-connector ID: %s", info.ID) log.Debugf("docker-connector Driver: %s", info.Driver) log.Debugf("docker-connector Images: %d", info.Images) log.Debugf("docker-connector Name: %s", info.Name) log.Debugf("docker-connector ServerVersion: %s", info.ServerVersion) go cm.Loop() go cm.LoopStatuses() cm.refreshAll() go cm.watchEvents() return cm, nil } // Docker implements Connector func (cm *Docker) Wait() struct{} { return <-cm.closed } // Docker events watcher func (cm *Docker) watchEvents() { log.Info("docker event listener starting") events := make(chan *api.APIEvents) opts := api.EventsOptions{Filters: map[string][]string{ "type": {"container"}, "event": {"create", "start", "health_status", "pause", "unpause", "stop", "die", "destroy"}, }, } cm.client.AddEventListenerWithOptions(opts, events) for e := range events { actionName := e.Action switch actionName { // most frequent event is a health checks case "health_status: healthy", "health_status: unhealthy": sepIdx := strings.Index(actionName, ": ") healthStatus := e.Action[sepIdx+2:] if log.IsEnabledFor(logging.DEBUG) { log.Debugf("handling docker event: action=health_status id=%s %s", e.ID, healthStatus) } cm.statuses <- StatusUpdate{e.ID, "health", healthStatus} case "create": if log.IsEnabledFor(logging.DEBUG) { log.Debugf("handling docker event: action=create id=%s", e.ID) } cm.needsRefresh <- e.ID case "destroy": if log.IsEnabledFor(logging.DEBUG) { log.Debugf("handling docker event: action=destroy id=%s", e.ID) } cm.delByID(e.ID) default: // check if this action changes status e.g. start -> running status := actionToStatus[actionName] if status != "" { if log.IsEnabledFor(logging.DEBUG) { log.Debugf("handling docker event: action=%s id=%s %s", actionName, e.ID, status) } cm.statuses <- StatusUpdate{e.ID, "status", status} } } } log.Info("docker event listener exited") close(cm.closed) } 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 webPort(ports map[api.Port][]api.PortBinding) string { for _, v := range ports { if len(v) == 0 { continue } for _, binding := range v { publishedIp := binding.HostIP if publishedIp == "0.0.0.0" { publishedIp = "localhost" } publishedWebPort := fmt.Sprintf("%s:%s", publishedIp, binding.HostPort) return publishedWebPort } } return "" } 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) { insp, found, failed := cm.inspect(c.Id) if failed { return } // remove container if no longer exists if !found { cm.delByID(c.Id) return } 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)) webPort := webPort(insp.NetworkSettings.Ports) if webPort != "" { c.SetMeta("Web Port", webPort) } c.SetMeta("created", insp.Created.Format("Mon Jan 02 15:04:05 2006")) c.SetMeta("uptime", calcUptime(insp)) c.SetMeta("health", insp.State.Health.Status) c.SetMeta("[ENV-VAR]", strings.Join(insp.Config.Env, ";")) c.SetState(insp.State.Status) } func (cm *Docker) inspect(id string) (insp *api.Container, found bool, failed bool) { c, err := cm.client.InspectContainer(id) if err != nil { if _, notFound := err.(*api.NoSuchContainer); notFound { return c, false, false } // other error e.g. connection failed log.Errorf("%s (%T)", err.Error(), err) return c, false, true } return c, true, false } func calcUptime(insp *api.Container) string { endTime := insp.State.FinishedAt if endTime.IsZero() || insp.State.Running { endTime = time.Now() } uptime := endTime.Sub(insp.State.StartedAt) return durafmt.Parse(uptime).LimitFirstN(1).String() } // Mark all container IDs for refresh func (cm *Docker) refreshAll() { opts := api.ListContainersOptions{All: true} allContainers, err := cm.client.ListContainers(opts) if err != nil { log.Errorf("%s (%T)", err.Error(), err) return } for _, i := range allContainers { c := cm.MustGet(i.ID) c.SetMeta("name", shortName(i.Names[0])) c.SetState(i.State) cm.needsRefresh <- c.Id } } func (cm *Docker) Loop() { for { select { case id := <-cm.needsRefresh: c := cm.MustGet(id) cm.refresh(c) case <-cm.closed: return } } } func (cm *Docker) LoopStatuses() { for { select { case statusUpdate := <-cm.statuses: c, _ := cm.Get(statusUpdate.Cid) if c != nil { if statusUpdate.Field == "health" { c.SetMeta("health", statusUpdate.Status) } else { c.SetState(statusUpdate.Status) } } case <-cm.closed: return } } } // MustGet gets a single container, creating one anew if not existing func (cm *Docker) MustGet(id string) *container.Container { c, ok := cm.Get(id) // append container struct for new containers if !ok { // create collector collector := collector.NewDocker(cm.client, id) // create manager manager := manager.NewDocker(cm.client, id) // create container c = container.New(id, collector, manager) cm.lock.Lock() cm.containers[id] = c cm.lock.Unlock() } return c } // Docker implements Connector func (cm *Docker) Get(id string) (*container.Container, bool) { cm.lock.Lock() c, ok := cm.containers[id] cm.lock.Unlock() return c, ok } // Remove containers by ID func (cm *Docker) delByID(id string) { cm.lock.Lock() delete(cm.containers, id) cm.lock.Unlock() log.Infof("removed dead container: %s", id) } // Docker implements Connector func (cm *Docker) 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 } // use primary container name func shortName(name string) string { return strings.TrimPrefix(name, "/") } ================================================ FILE: connector/main.go ================================================ package connector import ( "fmt" "sort" "sync" "time" "github.com/bcicen/ctop/container" "github.com/bcicen/ctop/logging" ) var ( log = logging.Init() enabled = make(map[string]ConnectorFn) ) type ConnectorFn func() (Connector, error) type Connector interface { // All returns a pre-sorted container.Containers of all discovered containers All() container.Containers // Get returns a single container.Container by ID Get(string) (*container.Container, bool) // Wait blocks until the underlying connection is lost Wait() struct{} } // ConnectorSuper provides initial connection and retry on failure for // an undlerying Connector type type ConnectorSuper struct { conn Connector connFn ConnectorFn err error lock sync.RWMutex } func NewConnectorSuper(connFn ConnectorFn) *ConnectorSuper { cs := &ConnectorSuper{ connFn: connFn, err: fmt.Errorf("connecting..."), } go cs.loop() return cs } // Get returns the underlying Connector, or nil and an error // if the Connector is not yet initialized or is disconnected. func (cs *ConnectorSuper) Get() (Connector, error) { cs.lock.RLock() defer cs.lock.RUnlock() if cs.err != nil { return nil, cs.err } return cs.conn, nil } func (cs *ConnectorSuper) setError(err error) { cs.lock.Lock() defer cs.lock.Unlock() cs.err = err } func (cs *ConnectorSuper) loop() { const interval = 3 for { log.Infof("initializing connector") conn, err := cs.connFn() if err != nil { cs.setError(err) log.Errorf("failed to initialize connector: %s (%T)", err, err) log.Errorf("retrying in %ds", interval) time.Sleep(interval * time.Second) } else { cs.conn = conn cs.setError(nil) log.Infof("successfully initialized connector") // wait until connection closed cs.conn.Wait() cs.setError(fmt.Errorf("attempting to reconnect...")) log.Infof("connector closed") } } } // Enabled returns names for all enabled connectors on the current platform func Enabled() (a []string) { for k, _ := range enabled { a = append(a, k) } sort.Strings(a) return a } // ByName returns a ConnectorSuper for a given name, or error if the connector // does not exists on the current platform func ByName(s string) (*ConnectorSuper, error) { if cfn, ok := enabled[s]; ok { return NewConnectorSuper(cfn), nil } return nil, fmt.Errorf("invalid connector type \"%s\"", s) } ================================================ FILE: connector/manager/docker.go ================================================ package manager import ( "fmt" api "github.com/fsouza/go-dockerclient" "github.com/pkg/errors" "io" "os" ) type Docker struct { id string client *api.Client } func NewDocker(client *api.Client, id string) *Docker { return &Docker{ id: id, client: client, } } // Do not allow to close reader (i.e. /dev/stdin which docker client tries to close after command execution) type noClosableReader struct { io.Reader } func (w *noClosableReader) Read(p []byte) (n int, err error) { return w.Reader.Read(p) } const ( STDIN = 0 STDOUT = 1 STDERR = 2 ) var wrongFrameFormat = errors.New("Wrong frame format") // A frame has a Header and a Payload // Header: [8]byte{STREAM_TYPE, 0, 0, 0, SIZE1, SIZE2, SIZE3, SIZE4} // STREAM_TYPE can be: // 0: stdin (is written on stdout) // 1: stdout // 2: stderr // SIZE1, SIZE2, SIZE3, SIZE4 are the four bytes of the uint32 size encoded as big endian. // But we don't use size, because we don't need to find the end of frame. type frameWriter struct { stdout io.Writer stderr io.Writer stdin io.Writer } func (w *frameWriter) Write(p []byte) (n int, err error) { // drop initial empty frames if len(p) == 0 { return 0, nil } if len(p) > 8 { var targetWriter io.Writer switch p[0] { case STDIN: targetWriter = w.stdin break case STDOUT: targetWriter = w.stdout break case STDERR: targetWriter = w.stderr break default: return 0, wrongFrameFormat } n, err := targetWriter.Write(p[8:]) return n + 8, err } return 0, wrongFrameFormat } func (dc *Docker) Exec(cmd []string) error { execCmd, err := dc.client.CreateExec(api.CreateExecOptions{ AttachStdin: true, AttachStdout: true, AttachStderr: true, Cmd: cmd, Container: dc.id, Tty: true, }) if err != nil { return err } return dc.client.StartExec(execCmd.ID, api.StartExecOptions{ InputStream: &noClosableReader{os.Stdin}, OutputStream: &frameWriter{os.Stdout, os.Stderr, os.Stdin}, ErrorStream: os.Stderr, RawTerminal: true, }) } func (dc *Docker) Start() error { c, err := dc.client.InspectContainer(dc.id) if err != nil { 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 } ================================================ FILE: connector/manager/main.go ================================================ package manager import "errors" var ActionNotImplErr = errors.New("action not implemented") type Manager interface { Start() error Stop() error Remove() error Pause() error Unpause() error Restart() error Exec(cmd []string) error } ================================================ FILE: connector/manager/mock.go ================================================ package manager type Mock struct{} func NewMock() *Mock { return &Mock{} } func (m *Mock) Start() error { return ActionNotImplErr } func (m *Mock) Stop() error { return ActionNotImplErr } func (m *Mock) Remove() error { return ActionNotImplErr } func (m *Mock) Pause() error { return ActionNotImplErr } func (m *Mock) Unpause() error { return ActionNotImplErr } func (m *Mock) Restart() error { return ActionNotImplErr } func (m *Mock) Exec(cmd []string) error { return ActionNotImplErr } ================================================ FILE: connector/manager/runc.go ================================================ package manager type Runc struct{} func NewRunc() *Runc { return &Runc{} } func (rc *Runc) Start() error { return ActionNotImplErr } func (rc *Runc) Stop() error { return ActionNotImplErr } func (rc *Runc) Remove() error { return ActionNotImplErr } func (rc *Runc) Pause() error { return ActionNotImplErr } func (rc *Runc) Unpause() error { return ActionNotImplErr } func (rc *Runc) Restart() error { return ActionNotImplErr } func (rc *Runc) Exec(cmd []string) error { return ActionNotImplErr } ================================================ FILE: connector/mock.go ================================================ //go:build !release // +build !release package connector import ( "math/rand" "strings" "time" "github.com/bcicen/ctop/connector/collector" "github.com/bcicen/ctop/connector/manager" "github.com/bcicen/ctop/container" "github.com/jgautheron/codename-generator" "github.com/nu7hatch/gouuid" ) func init() { enabled["mock"] = NewMock } type Mock struct { containers container.Containers } func NewMock() (Connector, error) { cs := &Mock{} go cs.Init() go cs.Loop() return cs, nil } // Create Mock containers func (cs *Mock) Init() { rand.Seed(int64(time.Now().Nanosecond())) for i := 0; i < 4; i++ { cs.makeContainer(3, true) } for i := 0; i < 16; i++ { cs.makeContainer(1, false) } } func (cs *Mock) Wait() struct{} { ch := make(chan struct{}) go func() { time.Sleep(30 * time.Second) close(ch) }() return <-ch } var healthStates = []string{"starting", "healthy", "unhealthy"} func (cs *Mock) makeContainer(aggression int64, health bool) { collector := collector.NewMock(aggression) manager := manager.NewMock() c := container.New(makeID(), collector, manager) c.SetMeta("name", makeName()) c.SetState(makeState()) if health { var i int c.SetMeta("health", healthStates[i]) go func() { for { i++ if i >= len(healthStates) { i = 0 } c.SetMeta("health", healthStates[i]) time.Sleep(12 * time.Second) } }() } cs.containers = append(cs.containers, c) } func (cs *Mock) Loop() { iter := 0 for { // Change state for random container if iter%5 == 0 && len(cs.containers) > 0 { randC := cs.containers[rand.Intn(len(cs.containers))] randC.SetState(makeState()) } iter++ time.Sleep(3 * time.Second) } } // Get a single container, by ID func (cs *Mock) Get(id string) (*container.Container, bool) { for _, c := range cs.containers { if c.Id == id { return c, true } } return nil, false } // All returns array of all containers, sorted by field func (cs *Mock) All() container.Containers { cs.containers.Sort() cs.containers.Filter() return cs.containers } // Remove containers by ID func (cs *Mock) delByID(id string) { for n, c := range cs.containers { if c.Id == id { cs.del(n) return } } } // Remove one or more containers by index func (cs *Mock) del(idx ...int) { for _, i := range idx { cs.containers = append(cs.containers[:i], cs.containers[i+1:]...) } log.Infof("removed %d dead containers", len(idx)) } func makeID() string { u, err := uuid.NewV4() if err != nil { panic(err) } return strings.Replace(u.String(), "-", "", -1)[:12] } func makeName() string { n, err := codename.Get(codename.Sanitized) nsp := strings.Split(n, "-") if len(nsp) > 2 { n = strings.Join(nsp[:2], "-") } if err != nil { panic(err) } return strings.Replace(n, "-", "_", -1) } func makeState() string { switch rand.Intn(10) { case 0, 1, 2: return "exited" case 3: return "paused" } return "running" } ================================================ FILE: connector/runc.go ================================================ //go:build linux // +build linux package connector import ( "errors" "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" ) 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 closed chan struct{} needsRefresh chan string // container IDs requiring refresh lock sync.RWMutex } func NewRunc() (Connector, error) { opts, err := NewRuncOpts() if err != nil { return nil, err } factory, err := libcontainer.New(opts.root) if err != nil { return nil, err } cm := &Runc{ opts: opts, factory: factory, containers: make(map[string]*container.Container), libContainers: make(map[string]libcontainer.Container), closed: make(chan struct{}), lock: sync.RWMutex{}, } go func() { for { select { case <-cm.closed: return case <-time.After(5 * time.Second): cm.refreshAll() } } }() go cm.Loop() return cm, nil } 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 errors.Is(err, libcontainer.ErrNotExist) { 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) if err != nil { log.Errorf("%s (%T)", err.Error(), err) close(cm.closed) return } 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) } } // MustGet gets a single ctop container in the map matching libc container, creating one anew if not existing func (cm *Runc) MustGet(id string) *container.Container { c, ok := cm.Get(id) if !ok { 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 } // 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) } // Runc implements Connector func (cm *Runc) Wait() struct{} { return <-cm.closed } // Runc implements Connector func (cm *Runc) Get(id string) (*container.Container, bool) { cm.lock.Lock() defer cm.lock.Unlock() c, ok := cm.containers[id] return c, ok } // Runc implements Connector func (cm *Runc) All() (containers container.Containers) { cm.lock.Lock() for _, c := range cm.containers { containers = append(containers, c) } containers.Sort() containers.Filter() cm.lock.Unlock() return containers } ================================================ FILE: container/main.go ================================================ 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 models.Meta Widgets *compact.CompactRow 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.NewCompactRow() shortID := id if len(shortID) > 12 { shortID = shortID[0:12] } return &Container{ Metrics: models.NewMetrics(), Id: id, Meta: models.NewMeta("id", shortID), Widgets: widgets, updater: widgets, collector: collector, manager: manager, } } func (c *Container) RecreateWidgets() { c.SetUpdater(cwidgets.NullWidgetUpdater{}) c.Widgets = compact.NewCompactRow() c.SetUpdater(c.Widgets) } func (c *Container) SetUpdater(u cwidgets.WidgetUpdater) { c.updater = u c.updater.SetMeta(c.Meta) } func (c *Container) SetMeta(k, v string) { c.Meta[k] = v c.updater.SetMeta(c.Meta) } func (c *Container) GetMeta(k string) string { return c.Meta.Get(k) } 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() } } // Logs returns 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 } } } func (c *Container) Exec(cmd []string) error { return c.manager.Exec(cmd) } ================================================ FILE: container/sort.go ================================================ package container import ( "fmt" "regexp" "sort" "github.com/bcicen/ctop/config" ) type sortMethod func(c1, c2 *Container) bool var stateMap = map[string]int{ "running": 3, "paused": 2, "exited": 1, "created": 0, "": 0, } var idSorter = func(c1, c2 *Container) bool { return c1.Id < c2.Id } var nameSorter = func(c1, c2 *Container) bool { return c1.GetMeta("name") < c2.GetMeta("name") } var Sorters = map[string]sortMethod{ "id": idSorter, "name": nameSorter, "cpu": func(c1, c2 *Container) bool { // Use secondary sort method if equal values if c1.CPUUtil == c2.CPUUtil { return nameSorter(c1, c2) } return c1.CPUUtil > c2.CPUUtil }, "mem": func(c1, c2 *Container) bool { // Use secondary sort method if equal values if c1.MemUsage == c2.MemUsage { return nameSorter(c1, c2) } return c1.MemUsage > c2.MemUsage }, "mem %": func(c1, c2 *Container) bool { // Use secondary sort method if equal values if c1.MemPercent == c2.MemPercent { return nameSorter(c1, c2) } return c1.MemPercent > c2.MemPercent }, "net": func(c1, c2 *Container) bool { sum1 := sumNet(c1) sum2 := sumNet(c2) // Use secondary sort method if equal values if sum1 == sum2 { return nameSorter(c1, c2) } 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") c2state := c2.GetMeta("state") if c1state == c2state { return nameSorter(c1, c2) } return stateMap[c1state] > stateMap[c2state] }, "uptime": func(c1, c2 *Container) bool { // Use secondary sort method if equal values c1Uptime := c1.GetMeta("uptime") c2Uptime := c2.GetMeta("uptime") if c1Uptime == c2Uptime { return nameSorter(c1, c2) } return c1Uptime > c2Uptime }, } func SortFields() (fields []string) { for k := range Sorters { fields = append(fields, k) } return fields } 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 { f := Sorters[config.GetVal("sortField")] if config.GetSwitchVal("sortReversed") { return f(a[j], a[i]) } return f(a[i], a[j]) } func (a Containers) Filter() { filter := config.GetVal("filterStr") re := regexp.MustCompile(fmt.Sprintf(".*%s", filter)) for _, c := range a { c.Display = true // Apply name filter if re.FindAllString(c.GetMeta("name"), 1) == nil { c.Display = false } // Apply state filter if !config.GetSwitchVal("allContainers") && c.GetMeta("state") != "running" { c.Display = false } } } func sumNet(c *Container) int64 { return c.NetRx + c.NetTx } func sumIO(c *Container) int64 { return c.IOBytesRead + c.IOBytesWrite } ================================================ FILE: cursor.go ================================================ 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 cSuper *connector.ConnectorSuper isScrolling bool // toggled when actively scrolling } func (gc *GridCursor) Len() int { return len(gc.filtered) } func (gc *GridCursor) Selected() *container.Container { idx := gc.Idx() if idx < gc.Len() { return gc.filtered[idx] } return nil } // Refresh containers from source, returning whether the quantity of // containers has changed and any error func (gc *GridCursor) RefreshContainers() (bool, error) { oldLen := gc.Len() gc.filtered = container.Containers{} cSource, err := gc.cSuper.Get() if err != nil { return true, err } // filter Containers by display bool var cursorVisible bool for _, c := range cSource.All() { if c.Display { if c.Id == gc.selectedID { cursorVisible = true } gc.filtered = append(gc.filtered, c) } } if !cursorVisible || gc.selectedID == "" { gc.Reset() } return oldLen != gc.Len(), nil } // Set an initial cursor position, if possible func (gc *GridCursor) Reset() { cSource, err := gc.cSuper.Get() if err != nil { return } for _, c := range cSource.All() { c.Widgets.UnHighlight() } if gc.Len() > 0 { gc.selectedID = gc.filtered[0].Id gc.filtered[0].Widgets.Highlight() } } // Idx returns current cursor index func (gc *GridCursor) Idx() int { for n, c := range gc.filtered { if c.Id == gc.selectedID { return n } } gc.Reset() return 0 } func (gc *GridCursor) ScrollPage() { // skip scroll if no need to page if gc.Len() < cGrid.MaxRows() { cGrid.Offset = 0 return } idx := gc.Idx() // page down if idx >= cGrid.Offset+cGrid.MaxRows() { cGrid.Offset++ cGrid.Align() } // page up if idx < cGrid.Offset { cGrid.Offset-- cGrid.Align() } } func (gc *GridCursor) Up() { gc.isScrolling = true defer func() { gc.isScrolling = false }() idx := gc.Idx() if idx <= 0 { // already at top return } active := gc.filtered[idx] next := gc.filtered[idx-1] active.Widgets.UnHighlight() gc.selectedID = next.Id next.Widgets.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 } active := gc.filtered[idx] next := gc.filtered[idx+1] active.Widgets.UnHighlight() gc.selectedID = next.Id next.Widgets.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 } ================================================ FILE: cwidgets/compact/column.go ================================================ package compact import ( "github.com/bcicen/ctop/config" "github.com/bcicen/ctop/models" ui "github.com/gizak/termui" ) var ( allCols = map[string]NewCompactColFn{ "status": NewStatus, "name": NewNameCol, "id": NewCIDCol, "image": NewImageCol, "ports": NewPortsCol, "IPs": NewIpsCol, "created": NewCreatedCol, "cpu": NewCPUCol, "cpus": NewCpuScaledCol, "mem": NewMemCol, "net": NewNetCol, "io": NewIOCol, "pids": NewPIDCol, "uptime": NewUptimeCol, } ) type NewCompactColFn func() CompactCol func newRowWidgets() []CompactCol { enabled := config.EnabledColumns() cols := make([]CompactCol, len(enabled)) for n, name := range enabled { wFn, ok := allCols[name] if !ok { panic("no such widget name: %s" + name) } cols[n] = wFn() } return cols } type CompactCol interface { ui.GridBufferer Reset() Header() string // header text to display for column FixedWidth() int // fixed width size. if == 0, width is automatically calculated Highlight() UnHighlight() SetMeta(models.Meta) SetMetrics(models.Metrics) } ================================================ FILE: cwidgets/compact/gauge.go ================================================ package compact import ( "fmt" "github.com/bcicen/ctop/cwidgets" "github.com/bcicen/ctop/models" ui "github.com/gizak/termui" ) type CPUCol struct { *GaugeCol scaleCpu bool } func NewCPUCol() CompactCol { return &CPUCol{NewGaugeCol("CPU"), false} } func NewCpuScaledCol() CompactCol { return &CPUCol{NewGaugeCol("CPUS"), true} } func (w *CPUCol) SetMetrics(m models.Metrics) { val := m.CPUUtil w.BarColor = colorScale(val) if !w.scaleCpu { val = val * int(m.NCpus) } w.Label = fmt.Sprintf("%d%%", val) if val > 100 { val = 100 } w.Percent = val } type MemCol struct { *GaugeCol } func NewMemCol() CompactCol { return &MemCol{NewGaugeCol("MEM")} } func (w *MemCol) SetMetrics(m models.Metrics) { w.BarColor = ui.ThemeAttr("gauge.bar.bg") w.Label = fmt.Sprintf("%s / %s", cwidgets.ByteFormat64Short(m.MemUsage), cwidgets.ByteFormat64Short(m.MemLimit)) w.Percent = m.MemPercent } type GaugeCol struct { *ui.Gauge header string fWidth int } func NewGaugeCol(header string) *GaugeCol { g := &GaugeCol{ui.NewGauge(), header, 0} g.Height = 1 g.Border = false g.PaddingBottom = 0 g.Reset() return g } func (w *GaugeCol) Reset() { w.Label = "-" w.Percent = 0 } func (w *GaugeCol) Buffer() ui.Buffer { // if bar would not otherwise be visible, set a minimum // percentage value and low-contrast color for structure if w.Percent < 5 { w.Percent = 5 w.BarColor = ui.ColorBlack } return w.Gauge.Buffer() } // GaugeCol implements CompactCol func (w *GaugeCol) SetMeta(models.Meta) {} func (w *GaugeCol) SetMetrics(models.Metrics) {} func (w *GaugeCol) Header() string { return w.header } func (w *GaugeCol) FixedWidth() int { return w.fWidth } // GaugeCol implements CompactCol func (w *GaugeCol) Highlight() { w.Bg = ui.ThemeAttr("par.text.fg") w.PercentColor = ui.ThemeAttr("par.text.hi") } // GaugeCol implements CompactCol 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.ok") } if n <= 90 { return ui.ThemeAttr("status.warn") } return ui.ThemeAttr("status.danger") } ================================================ FILE: cwidgets/compact/grid.go ================================================ package compact import ( ui "github.com/gizak/termui" ) type CompactGrid struct { ui.GridBufferer header *CompactHeader cols []CompactCol // reference columns Rows []RowBufferer X, Y int Width int Height int Offset int // starting row offset } func NewCompactGrid() *CompactGrid { cg := &CompactGrid{header: NewCompactHeader()} cg.rebuildHeader() return cg } func (cg *CompactGrid) Align() { y := cg.Y if cg.Offset >= len(cg.Rows) || cg.Offset < 0 { cg.Offset = 0 } // update row ypos, width recursively colWidths := cg.calcWidths() for _, r := range cg.pageRows() { r.SetY(y) y += r.GetHeight() r.SetWidths(cg.Width, colWidths) } } func (cg *CompactGrid) Clear() { cg.Rows = []RowBufferer{} cg.rebuildHeader() } func (cg *CompactGrid) GetHeight() int { return len(cg.Rows) + cg.header.Height } func (cg *CompactGrid) SetX(x int) { cg.X = x } func (cg *CompactGrid) SetY(y int) { cg.Y = y } func (cg *CompactGrid) SetWidth(w int) { cg.Width = w } func (cg *CompactGrid) MaxRows() int { return ui.TermHeight() - cg.header.Height - cg.Y } // calculate and return per-column width func (cg *CompactGrid) calcWidths() []int { var autoCols int width := cg.Width colWidths := make([]int, len(cg.cols)) for n, w := range cg.cols { colWidths[n] = w.FixedWidth() width -= w.FixedWidth() if w.FixedWidth() == 0 { autoCols++ } } spacing := colSpacing * len(cg.cols) autoWidth := (width - spacing) / autoCols for n, val := range colWidths { if val == 0 { colWidths[n] = autoWidth } } return colWidths } func (cg *CompactGrid) pageRows() (rows []RowBufferer) { rows = append(rows, cg.header) rows = append(rows, cg.Rows[cg.Offset:]...) return rows } func (cg *CompactGrid) Buffer() ui.Buffer { buf := ui.NewBuffer() for _, r := range cg.pageRows() { buf.Merge(r.Buffer()) } return buf } func (cg *CompactGrid) AddRows(rows ...RowBufferer) { cg.Rows = append(cg.Rows, rows...) } func (cg *CompactGrid) rebuildHeader() { cg.cols = newRowWidgets() cg.header.clearFieldPars() for _, col := range cg.cols { cg.header.addFieldPar(col.Header()) } } ================================================ FILE: cwidgets/compact/header.go ================================================ package compact import ( ui "github.com/gizak/termui" ) type CompactHeader struct { X, Y int Width int Height int cols []CompactCol widths []int pars []*ui.Par } func NewCompactHeader() *CompactHeader { return &CompactHeader{ X: rowPadding, Height: 2, } } func (row *CompactHeader) GetHeight() int { return row.Height } func (row *CompactHeader) SetWidths(totalWidth int, widths []int) { x := row.X for n, w := range row.pars { w.SetX(x) w.SetWidth(widths[n]) x += widths[n] + colSpacing } row.Width = totalWidth } func (row *CompactHeader) SetX(x int) { row.X = x } func (row *CompactHeader) SetY(y int) { for _, p := range row.pars { p.SetY(y) } row.Y = y } func (row *CompactHeader) Buffer() ui.Buffer { buf := ui.NewBuffer() for _, p := range row.pars { buf.Merge(p.Buffer()) } return buf } func (row *CompactHeader) clearFieldPars() { row.pars = []*ui.Par{} } func (row *CompactHeader) addFieldPar(s string) { p := ui.NewPar(s) p.Height = row.Height p.Border = false row.pars = append(row.pars, p) } ================================================ FILE: cwidgets/compact/row.go ================================================ package compact import ( "github.com/bcicen/ctop/config" "github.com/bcicen/ctop/logging" "github.com/bcicen/ctop/models" ui "github.com/gizak/termui" ) const rowPadding = 1 var log = logging.Init() type RowBufferer interface { SetY(int) SetWidths(int, []int) GetHeight() int Buffer() ui.Buffer } type CompactRow struct { Bg *RowBg Cols []CompactCol X, Y int Height int widths []int // column widths } func NewCompactRow() *CompactRow { row := &CompactRow{ Bg: NewRowBg(), Cols: newRowWidgets(), X: rowPadding, Height: 1, } return row } func (row *CompactRow) SetMeta(m models.Meta) { for _, w := range row.Cols { w.SetMeta(m) } } func (row *CompactRow) SetMetrics(m models.Metrics) { for _, w := range row.Cols { w.SetMetrics(m) } } // Set gauges, counters, etc. to default unread values func (row *CompactRow) Reset() { for _, w := range row.Cols { w.Reset() } } func (row *CompactRow) GetHeight() int { return row.Height } //func (row *CompactRow) SetX(x int) { row.X = x } func (row *CompactRow) SetY(y int) { if y == row.Y { return } row.Bg.Y = y for _, w := range row.Cols { w.SetY(y) } row.Y = y } func (row *CompactRow) SetWidths(totalWidth int, widths []int) { x := row.X row.Bg.SetX(x) row.Bg.SetWidth(totalWidth) for n, w := range row.Cols { w.SetX(x) w.SetWidth(widths[n]) x += widths[n] + colSpacing } } func (row *CompactRow) Buffer() ui.Buffer { buf := ui.NewBuffer() buf.Merge(row.Bg.Buffer()) for _, w := range row.Cols { buf.Merge(w.Buffer()) } return buf } func (row *CompactRow) Highlight() { row.Cols[1].Highlight() if config.GetSwitchVal("fullRowCursor") { for _, w := range row.Cols { w.Highlight() } } } func (row *CompactRow) UnHighlight() { row.Cols[1].UnHighlight() if config.GetSwitchVal("fullRowCursor") { for _, w := range row.Cols { w.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") } ================================================ FILE: cwidgets/compact/status.go ================================================ package compact import ( "github.com/bcicen/ctop/models" ui "github.com/gizak/termui" ) // Status indicator type Status struct { *ui.Block status []ui.Cell health []ui.Cell } func NewStatus() CompactCol { s := &Status{ Block: ui.NewBlock(), status: []ui.Cell{{Ch: ' '}}, health: []ui.Cell{{Ch: ' '}}, } s.Height = 1 s.Border = false return s } func (s *Status) Buffer() ui.Buffer { buf := s.Block.Buffer() buf.Set(s.InnerX(), s.InnerY(), s.health[0]) buf.Set(s.InnerX()+2, s.InnerY(), s.status[0]) return buf } func (s *Status) SetMeta(m models.Meta) { s.setState(m.Get("state")) s.setHealth(m.Get("health")) } // Status implements CompactCol func (s *Status) Reset() {} func (s *Status) SetMetrics(models.Metrics) {} func (s *Status) Highlight() {} func (s *Status) UnHighlight() {} func (s *Status) Header() string { return "" } func (s *Status) FixedWidth() int { return 3 } func (s *Status) setState(val string) { color := ui.ColorDefault var mark string switch val { case "": return case "created": mark = "◉" case "running": mark = "▶" color = ui.ThemeAttr("status.ok") case "exited": mark = "⏹" color = ui.ThemeAttr("status.danger") case "paused": mark = "⏸" default: mark = " " log.Warningf("unknown status string: \"%v\"", val) } s.status = ui.TextCells(mark, color, ui.ColorDefault) } func (s *Status) setHealth(val string) { color := ui.ColorDefault var mark string switch val { case "": return case "healthy": mark = "☼" color = ui.ThemeAttr("status.ok") case "unhealthy": mark = "⚠" color = ui.ThemeAttr("status.danger") case "starting": mark = "◌" color = ui.ThemeAttr("status.warn") default: mark = " " log.Warningf("unknown health state string: \"%v\"", val) } s.health = ui.TextCells(mark, color, ui.ColorDefault) } ================================================ FILE: cwidgets/compact/text.go ================================================ package compact import ( "fmt" "github.com/bcicen/ctop/cwidgets" "github.com/bcicen/ctop/models" ui "github.com/gizak/termui" ) // Column that shows container's meta property i.e. name, id, image tc. type MetaCol struct { *TextCol metaName string } func (w *MetaCol) SetMeta(m models.Meta) { w.setText(m.Get(w.metaName)) } func NewNameCol() CompactCol { c := &MetaCol{NewTextCol("NAME"), "name"} c.fWidth = 30 return c } func NewCIDCol() CompactCol { c := &MetaCol{NewTextCol("CID"), "id"} c.fWidth = 12 return c } func NewImageCol() CompactCol { return &MetaCol{NewTextCol("IMAGE"), "image"} } func NewPortsCol() CompactCol { return &MetaCol{NewTextCol("PORTS"), "ports"} } func NewIpsCol() CompactCol { return &MetaCol{NewTextCol("IPs"), "IPs"} } func NewCreatedCol() CompactCol { c := &MetaCol{NewTextCol("CREATED"), "created"} c.fWidth = 19 // Year will be stripped e.g. "Thu Nov 26 07:44:03" without 2020 at end return c } type NetCol struct { *TextCol } func NewNetCol() CompactCol { return &NetCol{NewTextCol("NET RX/TX")} } func (w *NetCol) SetMetrics(m models.Metrics) { label := fmt.Sprintf("%s / %s", cwidgets.ByteFormat64Short(m.NetRx), cwidgets.ByteFormat64Short(m.NetTx)) w.setText(label) } type IOCol struct { *TextCol } func NewIOCol() CompactCol { return &IOCol{NewTextCol("IO R/W")} } func (w *IOCol) SetMetrics(m models.Metrics) { label := fmt.Sprintf("%s / %s", cwidgets.ByteFormat64Short(m.IOBytesRead), cwidgets.ByteFormat64Short(m.IOBytesWrite)) w.setText(label) } type PIDCol struct { *TextCol } func NewPIDCol() CompactCol { w := &PIDCol{NewTextCol("PIDS")} w.fWidth = 4 return w } func (w *PIDCol) SetMetrics(m models.Metrics) { w.setText(fmt.Sprintf("%d", m.Pids)) } type UptimeCol struct { *TextCol } func NewUptimeCol() CompactCol { return &UptimeCol{NewTextCol("UPTIME")} } func (w *UptimeCol) SetMeta(m models.Meta) { w.Text = m.Get("uptime") } type TextCol struct { *ui.Par header string fWidth int } func NewTextCol(header string) *TextCol { p := ui.NewPar("-") p.Border = false p.Height = 1 p.Width = 20 return &TextCol{ Par: p, header: header, fWidth: 0, } } func (w *TextCol) Highlight() { w.Bg = ui.ThemeAttr("par.text.fg") w.TextFgColor = ui.ThemeAttr("par.text.hi") 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") } // TextCol implements CompactCol func (w *TextCol) Reset() { w.setText("-") } func (w *TextCol) SetMeta(models.Meta) {} func (w *TextCol) SetMetrics(models.Metrics) {} func (w *TextCol) Header() string { return w.header } func (w *TextCol) FixedWidth() int { return w.fWidth } func (w *TextCol) setText(s string) { if w.fWidth > 0 && len(s) > w.fWidth { s = s[0:w.fWidth] } w.Text = s } ================================================ FILE: cwidgets/compact/util.go ================================================ package compact // Common helper functions import ( "fmt" ui "github.com/gizak/termui" ) const colSpacing = 1 func centerParText(p *ui.Par) { var text string var padding string // strip existing left-padding for i, ch := range p.Text { if string(ch) != " " { text = p.Text[i:] break } } padlen := (p.InnerWidth() - len(text)) / 2 for i := 0; i < padlen; i++ { padding += " " } p.Text = fmt.Sprintf("%s%s", padding, text) } ================================================ FILE: cwidgets/main.go ================================================ package cwidgets import ( "github.com/bcicen/ctop/logging" "github.com/bcicen/ctop/models" ) var log = logging.Init() type WidgetUpdater interface { SetMeta(models.Meta) SetMetrics(models.Metrics) } type NullWidgetUpdater struct{} // NullWidgetUpdater implements WidgetUpdater func (wu NullWidgetUpdater) SetMeta(models.Meta) {} // NullWidgetUpdater implements WidgetUpdater func (wu NullWidgetUpdater) SetMetrics(models.Metrics) {} ================================================ FILE: cwidgets/single/cpu.go ================================================ package single import ( ui "github.com/gizak/termui" ) type Cpu struct { *ui.LineChart hist FloatHist } func NewCpu() *Cpu { cpu := &Cpu{ui.NewLineChart(), NewFloatHist(55)} cpu.Mode = "dot" cpu.BorderLabel = "CPU" cpu.Height = 12 cpu.Width = colWidth[0] cpu.X = 0 cpu.DataLabels = cpu.hist.Labels // hack to force the default minY scale to 0 tmpData := []float64{20} cpu.Data["CPU"] = tmpData _ = cpu.Buffer() cpu.Data["CPU"] = cpu.hist.Data return cpu } func (w *Cpu) Update(val int) { w.hist.Append(float64(val)) } ================================================ FILE: cwidgets/single/env.go ================================================ package single import ( "regexp" "strings" ui "github.com/gizak/termui" ) var envPattern = regexp.MustCompile(`(?P[^=]+)=(?P.*)`) 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(allEnvs string) { envs := strings.Split(allEnvs, ";") w.Rows = [][]string{} for _, env := range envs { match := envPattern.FindStringSubmatch(env) if len(match) == 3 { key := match[1] value := match[2] w.data[key] = value w.Rows = append(w.Rows, mkInfoRows(key, value)...) } } w.Height = len(w.Rows) + 2 } ================================================ FILE: cwidgets/single/hist.go ================================================ package single type IntHist struct { Val int // most current data point Data []int // historical data points Labels []string } func NewIntHist(max int) *IntHist { return &IntHist{ Data: make([]int, max), Labels: make([]string, max), } } func (h *IntHist) Append(val int) { if len(h.Data) == cap(h.Data) { h.Data = append(h.Data[:0], h.Data[1:]...) } h.Val = val h.Data = append(h.Data, val) } type DiffHist struct { *IntHist lastVal int } func NewDiffHist(max int) *DiffHist { return &DiffHist{NewIntHist(max), -1} } func (h *DiffHist) Append(val int) { if h.lastVal >= 0 { // skip append if this is the initial update diff := val - h.lastVal h.IntHist.Append(diff) } h.lastVal = val } type FloatHist struct { Val float64 // most current data point Data []float64 // historical data points Labels []string } func NewFloatHist(max int) FloatHist { return FloatHist{ Data: make([]float64, max), Labels: make([]string, max), } } func (h FloatHist) Append(val float64) { if len(h.Data) == cap(h.Data) { h.Data = append(h.Data[:0], h.Data[1:]...) } h.Val = val h.Data = append(h.Data, val) } ================================================ FILE: cwidgets/single/info.go ================================================ package single import ( "strings" ui "github.com/gizak/termui" ) var displayInfo = []string{"id", "name", "image", "ports", "IPs", "state", "created", "uptime", "health"} type Info struct { *ui.Table data map[string]string } func NewInfo() *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)} 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 } ================================================ FILE: cwidgets/single/io.go ================================================ 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.ByteFormatShort(w.readHist.Val)) w.Lines[0].Title = fmt.Sprintf("read [%s/s]", rate) w.writeHist.Append(int(write)) rate = strings.ToLower(cwidgets.ByteFormatShort(w.writeHist.Val)) w.Lines[1].Title = fmt.Sprintf("write [%s/s]", rate) } ================================================ FILE: cwidgets/single/logs.go ================================================ 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 } ================================================ FILE: cwidgets/single/main.go ================================================ package single import ( "github.com/bcicen/ctop/logging" "github.com/bcicen/ctop/models" ui "github.com/gizak/termui" ) var ( log = logging.Init() sizeError = termSizeError() colWidth = [2]int{65, 0} // left,right column width ) type Single struct { Info *Info Net *Net Cpu *Cpu Mem *Mem IO *IO Env *Env X, Y int Width int } func NewSingle() *Single { return &Single{ Info: NewInfo(), 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 *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(m models.Meta) { for k, v := range m { if k == "[ENV-VAR]" { e.Env.Set(v) } else { e.Info.Set(k, v) } } } func (e *Single) SetMetrics(m models.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) } // GetHeight returns 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 for _, i := range e.all() { i.SetY(y) y += i.GetHeight() } if e.Width > colWidth[0] { colWidth[1] = e.Width - (colWidth[0] + 1) } e.Mem.Align() log.Debugf("align: width=%v left-col=%v right-col=%v", e.Width, colWidth[0], colWidth[1]) } func (e *Single) Buffer() ui.Buffer { buf := ui.NewBuffer() if e.Width < (colWidth[0] + colWidth[1]) { ui.Clear() buf.Merge(sizeError.Buffer()) return buf } buf.Merge(e.Info.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 { return []ui.GridBufferer{ e.Info, e.Cpu, e.Mem, e.Net, e.IO, e.Env, } } func termSizeError() *ui.Par { p := ui.NewPar("screen too small!") p.Height = 1 p.Width = 20 p.Border = false return p } ================================================ FILE: cwidgets/single/mem.go ================================================ package single import ( "fmt" "github.com/bcicen/ctop/cwidgets" ui "github.com/gizak/termui" ) type Mem struct { *ui.Block Chart *ui.MBarChart InnerLabel *ui.Par valHist *IntHist limitHist *IntHist } func NewMem() *Mem { mem := &Mem{ Block: ui.NewBlock(), Chart: newMemChart(), InnerLabel: newMemLabel(), valHist: NewIntHist(9), limitHist: NewIntHist(9), } mem.Height = 13 mem.Width = colWidth[0] mem.BorderLabel = "MEM" mem.Chart.Data[0] = mem.valHist.Data mem.Chart.Data[1] = mem.limitHist.Data mem.Chart.DataLabels = mem.valHist.Labels return mem } func (w *Mem) Align() { y := w.Y + 1 w.InnerLabel.SetY(y) w.Chart.SetY(y + w.InnerLabel.Height) w.Chart.Height = w.Height - w.InnerLabel.Height - 2 w.Chart.SetWidth(w.Width - 2) } func (w *Mem) Buffer() ui.Buffer { buf := ui.NewBuffer() buf.Merge(w.Block.Buffer()) buf.Merge(w.InnerLabel.Buffer()) buf.Merge(w.Chart.Buffer()) return buf } func newMemLabel() *ui.Par { p := ui.NewPar("-") p.X = 1 p.Border = false p.Height = 1 p.Width = 20 return p } func newMemChart() *ui.MBarChart { mbar := ui.NewMBarChart() mbar.X = 1 mbar.Border = false mbar.BarGap = 1 mbar.BarWidth = 6 mbar.BarColor[1] = ui.ColorBlack mbar.NumColor[1] = ui.ColorBlack mbar.NumFmt = cwidgets.ByteFormatShort //mbar.ShowScale = true return mbar } func (w *Mem) Update(val int, limit int) { w.valHist.Append(val) w.limitHist.Append(limit - val) w.InnerLabel.Text = fmt.Sprintf("%v / %v", cwidgets.ByteFormatShort(val), cwidgets.ByteFormatShort(limit)) //w.Data[0] = w.hist.data } ================================================ FILE: cwidgets/single/net.go ================================================ package single import ( "fmt" "strings" "github.com/bcicen/ctop/cwidgets" ui "github.com/gizak/termui" ) type Net struct { *ui.Sparklines rxHist *DiffHist txHist *DiffHist } func NewNet() *Net { net := &Net{ui.NewSparklines(), NewDiffHist(60), NewDiffHist(60)} net.BorderLabel = "NET" net.Height = 6 net.Width = colWidth[0] net.X = 0 net.Y = 24 rx := ui.NewSparkline() rx.Title = "RX" rx.Height = 1 rx.Data = net.rxHist.Data rx.LineColor = ui.ColorGreen tx := ui.NewSparkline() tx.Title = "TX" tx.Height = 1 tx.Data = net.txHist.Data tx.LineColor = ui.ColorYellow net.Lines = []ui.Sparkline{rx, tx} return net } func (w *Net) Update(rx int64, tx int64) { var rate string w.rxHist.Append(int(rx)) rate = strings.ToLower(cwidgets.ByteFormat(w.rxHist.Val)) w.Lines[0].Title = fmt.Sprintf("RX [%s/s]", rate) w.txHist.Append(int(tx)) rate = strings.ToLower(cwidgets.ByteFormat(w.txHist.Val)) w.Lines[1].Title = fmt.Sprintf("TX [%s/s]", rate) } ================================================ FILE: cwidgets/util.go ================================================ package cwidgets import ( "strconv" ) const ( // byte ratio constants _ = iota kib float64 = 1 << (10 * iota) mib gib tib pib ) var ( units = []float64{ 1, kib, mib, gib, tib, pib, } // short, full unit labels labels = [][2]string{ [2]string{"B", "B"}, [2]string{"K", "KiB"}, [2]string{"M", "MiB"}, [2]string{"G", "GiB"}, [2]string{"T", "TiB"}, [2]string{"P", "PiB"}, } ) // convenience methods func ByteFormat(n int) string { return byteFormat(float64(n), false) } func ByteFormatShort(n int) string { return byteFormat(float64(n), true) } func ByteFormat64(n int64) string { return byteFormat(float64(n), false) } func ByteFormat64Short(n int64) string { return byteFormat(float64(n), true) } func byteFormat(n float64, short bool) string { i := len(units) - 1 for i > 0 { if n >= units[i] { n /= units[i] break } i-- } if short { return unpadFloat(n, 0) + labels[i][0] } return unpadFloat(n, 2) + labels[i][1] } func unpadFloat(f float64, maxp int) string { return strconv.FormatFloat(f, 'f', getPrecision(f, maxp), 64) } func getPrecision(f float64, maxp int) int { frac := int((f - float64(int(f))) * 100) if frac == 0 || maxp == 0 { return 0 } if frac%10 == 0 || maxp < 2 { return 1 } return maxp } ================================================ FILE: debug.go ================================================ 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) { // skip timer events e.g. /timer/1s if e.From == "timer" { return } var s string s += fmt.Sprintf("Type=%s", quote(e.Type)) s += fmt.Sprintf(" Path=%s", quote(e.Path)) s += fmt.Sprintf(" From=%s", quote(e.From)) if e.To != "" { s += fmt.Sprintf(" To=%s", quote(e.To)) } 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) { 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) } msg += inspect(&c.Metrics) log.Infof(msg) } func inspect(i interface{}) (s string) { val := reflect.ValueOf(i) elem := val.Type().Elem() eName := elem.String() for i := 0; i < elem.NumField(); i++ { field := elem.Field(i) fieldVal := reflect.Indirect(val).FieldByName(field.Name) s += fmt.Sprintf("%s.%s = ", eName, field.Name) s += fmt.Sprintf("%v (%s)\n", fieldVal, field.Type) } return s } func quote(s string) string { return fmt.Sprintf("\"%s\"", s) } ================================================ FILE: go.mod ================================================ module github.com/bcicen/ctop require ( github.com/BurntSushi/toml v0.3.1 github.com/c9s/goprocinfo v0.0.0-20170609001544-b34328d6e0cd github.com/fsouza/go-dockerclient v1.7.0 github.com/gizak/termui v2.3.1-0.20180817033724-8d4faad06196+incompatible github.com/hako/durafmt v0.0.0-20210608085754-5c1018a4e16b // indirect github.com/jgautheron/codename-generator v0.0.0-20150829203204-16d037c7cc3c github.com/mattn/go-runewidth v0.0.2 github.com/nsf/termbox-go v0.0.0-20190121233118-02980233997d github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d github.com/op/go-logging v0.0.0-20160211212156-b2cb9fa56473 github.com/opencontainers/runc v1.1.0 github.com/pkg/browser v0.0.0-20201207095918-0426ae3fba23 github.com/pkg/errors v0.9.1 github.com/stretchr/testify v1.4.0 ) require ( github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78 // indirect github.com/Microsoft/go-winio v0.4.16 // indirect github.com/Microsoft/hcsshim v0.8.10 // indirect github.com/checkpoint-restore/go-criu/v5 v5.3.0 // indirect github.com/cilium/ebpf v0.7.0 // indirect github.com/containerd/cgroups v0.0.0-20200531161412-0dbf7f05ba59 // indirect github.com/containerd/console v1.0.3 // indirect github.com/containerd/containerd v1.4.1 // indirect github.com/containerd/continuity v0.0.0-20200928162600-f2cc35102c2a // indirect github.com/coreos/go-systemd/v22 v22.3.2 // indirect github.com/cyphar/filepath-securejoin v0.2.3 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/docker/docker v20.10.0-beta1.0.20201113105859-b6bfff2a628f+incompatible // indirect github.com/docker/go-connections v0.4.0 // indirect github.com/docker/go-units v0.4.0 // indirect github.com/godbus/dbus/v5 v5.0.6 // indirect github.com/gogo/protobuf v1.3.1 // indirect github.com/hashicorp/golang-lru v0.5.1 // indirect github.com/maruel/panicparse v1.6.1 // indirect github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 // indirect github.com/moby/sys/mount v0.2.0 // indirect github.com/moby/sys/mountinfo v0.5.0 // indirect github.com/moby/term v0.0.0-20201110203204-bea5bbe245bf // indirect github.com/morikuni/aec v1.0.0 // indirect github.com/mrunalp/fileutils v0.5.0 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.0.1 // indirect github.com/opencontainers/runtime-spec v1.0.3-0.20210326190908-1c3f411f0417 // indirect github.com/opencontainers/selinux v1.10.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/seccomp/libseccomp-golang v0.9.2-0.20210429002308-3879420cc921 // indirect github.com/sirupsen/logrus v1.8.1 // indirect github.com/syndtr/gocapability v0.0.0-20200815063812-42c35b437635 // indirect github.com/vishvananda/netlink v1.1.0 // indirect github.com/vishvananda/netns v0.0.0-20191106174202-0a2b9b5464df // indirect go.opencensus.io v0.22.0 // indirect golang.org/x/net v0.0.0-20201224014010-6772e930b67b // indirect golang.org/x/sync v0.0.0-20190423024810-112230192c58 // indirect golang.org/x/sys v0.0.0-20211116061358-0a5406a5449c // indirect google.golang.org/protobuf v1.27.1 // indirect gopkg.in/yaml.v2 v2.2.8 // indirect ) go 1.18 ================================================ FILE: go.sum ================================================ bazil.org/fuse v0.0.0-20160811212531-371fbbdaa898/go.mod h1:Xbm+BRKSBEpa4q4hTSxohYNQpsxXPbPry4JJWOB3LB8= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78 h1:w+iIsaOQNcT7OZ575w+acHgRric5iCyQh+xv+KJ4HB8= github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78/go.mod h1:LmzpDX56iTiv29bbRTIsUNlaFfuhWRQBWjQdVyAevI8= github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/Microsoft/go-winio v0.4.15-0.20200908182639-5b44b70ab3ab/go.mod h1:tTuCMEN+UleMWgg9dVx4Hu52b1bJo+59jBh3ajtinzw= github.com/Microsoft/go-winio v0.4.16 h1:FtSW/jqD+l4ba5iPBj9CODVtgfYAD8w2wS923g/cFDk= github.com/Microsoft/go-winio v0.4.16/go.mod h1:XB6nPKklQyQ7GC9LdcBEcBl8PF76WugXOPRXwdLnMv0= github.com/Microsoft/hcsshim v0.8.10 h1:k5wTrpnVU2/xv8ZuzGkbXVd3js5zJ8RnumPo5RxiIxU= github.com/Microsoft/hcsshim v0.8.10/go.mod h1:g5uw8EV2mAlzqe94tfNBNdr89fnbD/n3HV0OhsddkmM= github.com/c9s/goprocinfo v0.0.0-20170609001544-b34328d6e0cd h1:xqaBnULC8wEnQpRDXAsDgXkU/STqoluz1REOoegSfNU= github.com/c9s/goprocinfo v0.0.0-20170609001544-b34328d6e0cd/go.mod h1:uEyr4WpAH4hio6LFriaPkL938XnrvLpNPmQHBdrmbIE= github.com/checkpoint-restore/go-criu/v5 v5.3.0 h1:wpFFOoomK3389ue2lAb0Boag6XPht5QYpipxmSNL4d8= github.com/checkpoint-restore/go-criu/v5 v5.3.0/go.mod h1:E/eQpaFtUKGOOSEBZgmKAcn+zUUwWxqcaKZlF54wK8E= github.com/cilium/ebpf v0.0.0-20200110133405-4032b1d8aae3/go.mod h1:MA5e5Lr8slmEg9bt0VpxxWqJlO4iwu3FBdHUzV7wQVg= github.com/cilium/ebpf v0.7.0 h1:1k/q3ATgxSXRdrmPfH8d7YK0GfqVsEKZAX9dQZvs56k= github.com/cilium/ebpf v0.7.0/go.mod h1:/oI2+1shJiTGAMgl6/RgJr36Eo1jzrRcAWbcXO2usCA= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/containerd/cgroups v0.0.0-20200531161412-0dbf7f05ba59 h1:qWj4qVYZ95vLWwqyNJCQg7rDsG5wPdze0UaPolH7DUk= github.com/containerd/cgroups v0.0.0-20200531161412-0dbf7f05ba59/go.mod h1:pA0z1pT8KYB3TCXK/ocprsh7MAkoW8bZVzPdih9snmM= github.com/containerd/console v0.0.0-20180822173158-c12b1e7919c1/go.mod h1:Tj/on1eG8kiEhd0+fhSDzsPAFESxzBBvdyEgyryXffw= github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw= github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U= github.com/containerd/containerd v1.3.2/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA= github.com/containerd/containerd v1.4.1 h1:pASeJT3R3YyVn+94qEPk0SnU1OQ20Jd/T+SPKy9xehY= github.com/containerd/containerd v1.4.1/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA= github.com/containerd/continuity v0.0.0-20190426062206-aaeac12a7ffc/go.mod h1:GL3xCUCBDV3CZiTSEKksMWbLE66hEyuu9qyDOOqM47Y= github.com/containerd/continuity v0.0.0-20200928162600-f2cc35102c2a h1:jEIoR0aA5GogXZ8pP3DUzE+zrhaF6/1rYZy+7KkYEWM= github.com/containerd/continuity v0.0.0-20200928162600-f2cc35102c2a/go.mod h1:W0qIOTD7mp2He++YVq+kgfXezRYqzP1uDuMVH1bITDY= github.com/containerd/fifo v0.0.0-20190226154929-a9fb20d87448/go.mod h1:ODA38xgv3Kuk8dQz2ZQXpnv/UZZUHUCL7pnLehbXgQI= github.com/containerd/go-runc v0.0.0-20180907222934-5a6d9f37cfa3/go.mod h1:IV7qH3hrUgRmyYrtgEeGWJfWbgcHL9CSRruz2Vqcph0= github.com/containerd/ttrpc v0.0.0-20190828154514-0e0f228740de/go.mod h1:PvCDdDGpgqzQIzDW1TphrGLssLDZp2GuS+X5DkEJB8o= github.com/containerd/typeurl v0.0.0-20180627222232-a93fcdb778cd/go.mod h1:Cm3kwCdlkCfMSHURc+r6fwoGH6/F1hH3S4sg0rLFWPc= github.com/coreos/go-systemd/v22 v22.0.0/go.mod h1:xO0FLkIi5MaZafQlIrOotqXZ90ih+1atmu1JpKERPPk= github.com/coreos/go-systemd/v22 v22.3.2 h1:D9/bQk5vlXQFZ6Kwuu6zaiXJ9oTPe68++AzAJc1DzSI= github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/creack/pty v1.1.11 h1:07n33Z8lZxZ2qwegKbObQohDhXDQxiMMz1NOUGYlesw= github.com/creack/pty v1.1.11/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/cyphar/filepath-securejoin v0.2.3 h1:YX6ebbZCZP7VkM3scTTokDgBL2TY741X51MTk3ycuNI= github.com/cyphar/filepath-securejoin v0.2.3/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/docker/docker v20.10.0-beta1.0.20201113105859-b6bfff2a628f+incompatible h1:lwpV3629md5omgAKjxPWX17shI7vMRpE3nyb9WHn8pA= github.com/docker/docker v20.10.0-beta1.0.20201113105859-b6bfff2a628f+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ= github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec= github.com/docker/go-units v0.4.0 h1:3uh0PgVws3nIA0Q+MwDC8yjEPf9zjRfZZWXZYDct3Tw= github.com/docker/go-units v0.4.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/frankban/quicktest v1.11.3 h1:8sXhOn0uLys67V8EsXLc6eszDs8VXWxL3iRvebPhedY= github.com/frankban/quicktest v1.11.3/go.mod h1:wRf/ReqHper53s+kmmSZizM8NamnL3IM0I9ntUbOk+k= github.com/fsouza/go-dockerclient v1.7.0 h1:Ie1/8pAnBHNyCbSIDnYKBdXUEobk4AeJhWZz7k6rWfc= github.com/fsouza/go-dockerclient v1.7.0/go.mod h1:Ny0LfP7OOsYu9nAi4339E4Ifor6nGBFO2M8lnd2nR+c= github.com/gizak/termui v2.3.1-0.20180817033724-8d4faad06196+incompatible h1:pUbrySwhNIu18YXjMTCt/Z3kr8eYQ8hRDs4BeR/crmA= github.com/gizak/termui v2.3.1-0.20180817033724-8d4faad06196+incompatible/go.mod h1:PkJoWUt/zacQKysNfQtcw1RW+eK2SxkieVBtl+4ovLA= github.com/godbus/dbus/v5 v5.0.3/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/godbus/dbus/v5 v5.0.6 h1:mkgN1ofwASrYnJ5W6U/BxG15eXXXjirgZc7CLqkcaro= github.com/godbus/dbus/v5 v5.0.6/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gogo/protobuf v1.3.1 h1:DqDEcV5aeaTmdFBePNpYsp3FlcVH/2ISVVM9Qf8PSls= github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.5.0 h1:LUVKkCeviFUMKqHa4tXIIij/lbhnMbP7Fn5wKdKkRh4= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/hako/durafmt v0.0.0-20210608085754-5c1018a4e16b h1:wDUNC2eKiL35DbLvsDhiblTUXHxcOPwQSCzi7xpQUN4= github.com/hako/durafmt v0.0.0-20210608085754-5c1018a4e16b/go.mod h1:VzxiSdG6j1pi7rwGm/xYI5RbtpBgM8sARDXlvEvxlu0= github.com/hashicorp/golang-lru v0.5.1 h1:0hERBMJE1eitiLkihrMvRVBYAkpHzc/J3QdDN+dAcgU= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= github.com/jgautheron/codename-generator v0.0.0-20150829203204-16d037c7cc3c h1:/hc+TxW4Q1v6aqNPHE5jiaNF2xEK0CzWTgo25RQhQ+U= github.com/jgautheron/codename-generator v0.0.0-20150829203204-16d037c7cc3c/go.mod h1:FJRkXmPrkHw0WDjB/LXMUhjWJ112Y6JUYnIVBOy8oH8= github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/maruel/panicparse v1.6.1 h1:803MjBzGcUgE1vYgg3UMNq3G1oyYeKkMu3t6hBS97x0= github.com/maruel/panicparse v1.6.1/go.mod h1:uoxI4w9gJL6XahaYPMq/z9uadrdr1SyHuQwV2q80Mm0= github.com/maruel/panicparse/v2 v2.1.1/go.mod h1:AeTWdCE4lcq8OKsLb6cHSj1RWHVSnV9HBCk7sKLF4Jg= github.com/mattn/go-colorable v0.1.7/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-runewidth v0.0.2 h1:UnlwIPBGaTZfPQ6T1IGzPI0EkYAQmT9fAEJ/poFC63o= github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 h1:DpOJ2HYzCv8LZP15IdmG+YdwD2luVPHITV96TkirNBM= github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo= github.com/moby/sys/mount v0.2.0 h1:WhCW5B355jtxndN5ovugJlMFJawbUODuW8fSnEH6SSM= github.com/moby/sys/mount v0.2.0/go.mod h1:aAivFE2LB3W4bACsUXChRHQ0qKWsetY4Y9V7sxOougM= github.com/moby/sys/mountinfo v0.4.0/go.mod h1:rEr8tzG/lsIZHBtN/JjGG+LMYx9eXgW2JI+6q0qou+A= github.com/moby/sys/mountinfo v0.5.0 h1:2Ks8/r6lopsxWi9m58nlwjaeSzUX9iiL1vj5qB/9ObI= github.com/moby/sys/mountinfo v0.5.0/go.mod h1:3bMD3Rg+zkqx8MRYPi7Pyb0Ie97QEBmdxbhnCLlSvSU= github.com/moby/term v0.0.0-20201110203204-bea5bbe245bf h1:Un6PNx5oMK6CCwO3QTUyPiK2mtZnPrpDl5UnZ64eCkw= github.com/moby/term v0.0.0-20201110203204-bea5bbe245bf/go.mod h1:FBS0z0QWA44HXygs7VXDUOGoN/1TV3RuWkLO04am3wc= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/mrunalp/fileutils v0.5.0 h1:NKzVxiH7eSk+OQ4M+ZYW1K6h27RUV3MI6NUTsHhU6Z4= github.com/mrunalp/fileutils v0.5.0/go.mod h1:M1WthSahJixYnrXQl/DFQuteStB1weuxD2QJNHXfbSQ= github.com/nsf/termbox-go v0.0.0-20190121233118-02980233997d h1:x3S6kxmy49zXVVyhcnrFqxvNVCBPb2KZ9hV2RBdS840= github.com/nsf/termbox-go v0.0.0-20190121233118-02980233997d/go.mod h1:IuKpRQcYE1Tfu+oAQqaLisqDeXgjyyltCfsaoYN18NQ= github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d h1:VhgPp6v9qf9Agr/56bj7Y/xa04UccTW04VP0Qed4vnQ= github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d/go.mod h1:YUTz3bUH2ZwIWBy3CJBeOBEugqcmXREj14T+iG/4k4U= github.com/op/go-logging v0.0.0-20160211212156-b2cb9fa56473 h1:J1QZwDXgZ4dJD2s19iqR9+U00OWM2kDzbf1O/fmvCWg= github.com/op/go-logging v0.0.0-20160211212156-b2cb9fa56473/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk= github.com/opencontainers/go-digest v0.0.0-20180430190053-c9281466c8b2/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.0.1 h1:JMemWkRwHx4Zj+fVxWoMCFm/8sYGGrUVojFA6h/TRcI= github.com/opencontainers/image-spec v1.0.1/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0= github.com/opencontainers/runc v0.0.0-20190115041553-12f6a991201f/go.mod h1:qT5XzbpPznkRYVz/mWwUaVBUv2rmF59PVA73FjuZG0U= github.com/opencontainers/runc v0.1.1/go.mod h1:qT5XzbpPznkRYVz/mWwUaVBUv2rmF59PVA73FjuZG0U= github.com/opencontainers/runc v1.1.0 h1:O9+X96OcDjkmmZyfaG996kV7yq8HsoU2h1XRRQcefG8= github.com/opencontainers/runc v1.1.0/go.mod h1:Tj1hFw6eFWp/o33uxGf5yF2BX5yz2Z6iptFpuvbbKqc= github.com/opencontainers/runtime-spec v1.0.2/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0= github.com/opencontainers/runtime-spec v1.0.3-0.20210326190908-1c3f411f0417 h1:3snG66yBm59tKhhSPQrQ/0bCrv1LQbKt40LnUPiUxdc= github.com/opencontainers/runtime-spec v1.0.3-0.20210326190908-1c3f411f0417/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0= github.com/opencontainers/selinux v1.10.0 h1:rAiKF8hTcgLI3w0DHm6i0ylVVcOrlgR1kK99DRLDhyU= github.com/opencontainers/selinux v1.10.0/go.mod h1:2i0OySw99QjzBBQByd1Gr9gSjvuho1lHsJxIJ3gGbJI= github.com/pkg/browser v0.0.0-20201207095918-0426ae3fba23 h1:dofHuld+js7eKSemxqTVIo8yRlpRw+H1SdpzZxWruBc= github.com/pkg/browser v0.0.0-20201207095918-0426ae3fba23/go.mod h1:N6UoU20jOqggOuDwUaBQpluzLNDqif3kq9z2wpdYEfQ= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/procfs v0.0.0-20180125133057-cb4147076ac7/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/seccomp/libseccomp-golang v0.9.2-0.20210429002308-3879420cc921 h1:58EBmR2dMNL2n/FnbQewK3D14nXr0V9CObDSvMJLq+Y= github.com/seccomp/libseccomp-golang v0.9.2-0.20210429002308-3879420cc921/go.mod h1:JA8cRccbGaA1s33RQf7Y1+q9gHmZX1yB/z9WDN1C6fg= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE= github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/spf13/cobra v0.0.2-0.20171109065643-2da4a54c5cee/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ= github.com/spf13/pflag v1.0.1-0.20171106142849-4c012f6dcd95/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/syndtr/gocapability v0.0.0-20200815063812-42c35b437635 h1:kdXcSzyDtseVEc4yCz2qF8ZrQvIDBJLl4S1c3GCXmoI= github.com/syndtr/gocapability v0.0.0-20200815063812-42c35b437635/go.mod h1:hkRG7XYTFWNJGYcbNJQlaLq0fg1yr4J4t/NcTQtrfww= github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= github.com/urfave/cli v1.22.2/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= github.com/vishvananda/netlink v1.1.0 h1:1iyaYNBLmP6L0220aDnYQpo1QEV4t4hJ+xEEhhJH8j0= github.com/vishvananda/netlink v1.1.0/go.mod h1:cTgwzPIzzgDAYoQrMm0EdrjRUBkTqKYppBueQtXaqoE= github.com/vishvananda/netns v0.0.0-20191106174202-0a2b9b5464df h1:OviZH7qLw/7ZovXvuNyL3XQl8UFofeikI1NW1Gypu7k= github.com/vishvananda/netns v0.0.0-20191106174202-0a2b9b5464df/go.mod h1:JP3t17pCcGlemwknint6hfoeCVQrEMVwxRLRjXpq+BU= go.opencensus.io v0.22.0 h1:C9hSCOW830chIVkdja34wa6Ky+IzWllkUinR+BtRZd4= go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20191004110552-13f9640d40b9/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201224014010-6772e930b67b h1:iFwSg7t5GZmB/Q5TjiEAsdoLDrdJRC1RiF2WhuV29Qw= golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58 h1:8gQV6CLnAEikrhgkHFbMAEhagSSnXWGV915qUMm9mrU= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190606203320-7fc4e5ec1444/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191022100944-742c48ecaeb7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191115151921-52ab43148777/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200120151820-655fe14d7479/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200724161237-0e2f3a69832c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200831180312-196b9ba8737a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200909081042-eff7692f9009/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200922070232-aee5d888a860/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210906170528-6f6e22806c34/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211116061358-0a5406a5449c h1:DHcbWVXeY+0Y8HHKR+rbLwnoh2F4tNCY7rTiHJ30RmA= golang.org/x/sys v0.0.0-20211116061358-0a5406a5449c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201113234701-d7a72108b828/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 h1:v+OssWQX+hTHEmOBgwxdZxK4zHq3yOs8F9J7mk0PY8E= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20181030221726-6c7e314b6563/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190624222133-a101b041ded4/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= google.golang.org/grpc v1.23.1/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.27.1 h1:SnqbnDw1V7RiZcXPx5MEeqPv2s79L9i7BJUlG/+RurQ= google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo= gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= gotest.tools/v3 v3.0.2/go.mod h1:3SzNCllyD9/Y+b5r9JIKQ474KzkZyqLqEfYqMsX94Bk= gotest.tools/v3 v3.0.3 h1:4AuOwCGf4lLR9u3YOe2awrHygurzhO/HeQ6laiA6Sx0= gotest.tools/v3 v3.0.3/go.mod h1:Z7Lb0S5l+klDB31fvDQX8ss/FlKDxtlFlw3Oa8Ymbl8= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= ================================================ FILE: grid.go ================================================ package main import ( "github.com/bcicen/ctop/config" "github.com/bcicen/ctop/cwidgets/single" ui "github.com/gizak/termui" ) func ShowConnError(err error) (exit bool) { ui.Clear() ui.DefaultEvtStream.ResetHandlers() defer ui.DefaultEvtStream.ResetHandlers() setErr := func(err error) { errView.Append(err.Error()) errView.Append("attempting to reconnect...") ui.Render(errView) } HandleKeys("exit", func() { exit = true ui.StopLoop() }) ui.Handle("/timer/1s", func(ui.Event) { _, err := cursor.RefreshContainers() if err == nil { ui.StopLoop() return } setErr(err) }) ui.Handle("/sys/wnd/resize", func(e ui.Event) { errView.Resize() ui.Clear() ui.Render(errView) log.Infof("RESIZE") }) errView.Resize() setErr(err) ui.Loop() return exit } func RedrawRows(clr bool) { // reinit body rows cGrid.Clear() // build layout y := 1 if config.GetSwitchVal("enableHeader") { header.SetCount(cursor.Len()) header.SetFilter(config.GetVal("filterStr")) y += header.Height() } cGrid.SetY(y) for _, c := range cursor.filtered { cGrid.AddRows(c.Widgets) } if clr { ui.Clear() log.Debugf("screen cleared") } if config.GetSwitchVal("enableHeader") { ui.Render(header) } cGrid.Align() ui.Render(cGrid) } func SingleView() MenuFn { c := cursor.Selected() if c == nil { return nil } ui.Clear() ui.DefaultEvtStream.ResetHandlers() defer ui.DefaultEvtStream.ResetHandlers() ex := single.NewSingle() 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("/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.Loop() c.SetUpdater(c.Widgets) return nil } func RefreshDisplay() error { // skip display refresh during scroll if !cursor.isScrolling { needsClear, err := cursor.RefreshContainers() if err != nil { return err } RedrawRows(needsClear) } return nil } func Display() bool { var menu MenuFn var connErr error cGrid.SetWidth(ui.TermWidth()) ui.DefaultEvtStream.Hook(logEvent) // 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/", func(ui.Event) { menu = ContainerMenu ui.StopLoop() }) ui.Handle("/sys/kbd/", func(ui.Event) { menu = LogMenu ui.StopLoop() }) ui.Handle("/sys/kbd/", func(ui.Event) { menu = SingleView ui.StopLoop() }) ui.Handle("/sys/kbd/l", func(ui.Event) { menu = LogMenu ui.StopLoop() }) ui.Handle("/sys/kbd/e", func(ui.Event) { menu = ExecShell ui.StopLoop() }) ui.Handle("/sys/kbd/w", func(ui.Event) { menu = OpenInBrowser() }) ui.Handle("/sys/kbd/o", func(ui.Event) { menu = SingleView ui.StopLoop() }) ui.Handle("/sys/kbd/a", func(ui.Event) { config.Toggle("allContainers") connErr = RefreshDisplay() if connErr != nil { ui.StopLoop() } }) ui.Handle("/sys/kbd/D", func(ui.Event) { dumpContainer(cursor.Selected()) }) ui.Handle("/sys/kbd/f", func(ui.Event) { menu = FilterMenu ui.StopLoop() }) ui.Handle("/sys/kbd/H", func(ui.Event) { config.Toggle("enableHeader") RedrawRows(true) }) ui.Handle("/sys/kbd/r", func(e ui.Event) { config.Toggle("sortReversed") }) ui.Handle("/sys/kbd/s", func(ui.Event) { menu = SortMenu ui.StopLoop() }) ui.Handle("/sys/kbd/c", func(ui.Event) { menu = ColumnsMenu 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() } connErr = RefreshDisplay() if connErr != nil { ui.StopLoop() } }) 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()) RedrawRows(true) }) ui.Loop() if connErr != nil { return ShowConnError(connErr) } 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() } return false } return true } ================================================ FILE: install.sh ================================================ #!/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!" ================================================ FILE: keys.go ================================================ package main import ( ui "github.com/gizak/termui" ) // Common action keybindings var keyMap = map[string][]string{ "up": []string{ "/sys/kbd/", "/sys/kbd/k", }, "down": []string{ "/sys/kbd/", "/sys/kbd/j", }, "pgup": []string{ "/sys/kbd/", "/sys/kbd/C-", }, "pgdown": []string{ "/sys/kbd/", "/sys/kbd/C-", }, "exit": []string{ "/sys/kbd/q", "/sys/kbd/C-c", "/sys/kbd/", }, "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() }) } } ================================================ FILE: logging/main.go ================================================ package logging import ( "fmt" "os" "time" "github.com/op/go-logging" ) const ( size = 1024 ) var ( Log *CTopLogger exited bool level = logging.INFO // default level 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 logFile *os.File 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 Log = &CTopLogger{ logging.MustGetLogger("ctop"), logging.NewMemoryBackend(size), nil, []statusMsg{}, } debugMode := debugMode() if debugMode { level = logging.DEBUG } backendLvl := logging.AddModuleLevel(Log.backend) backendLvl.SetLevel(level, "") logFilePath := debugModeFile() if logFilePath == "" { logging.SetBackend(backendLvl) } else { logFile, err := os.OpenFile(logFilePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0666) if err != nil { logging.SetBackend(backendLvl) Log.Error("Unable to create log file: %s", err.Error()) } else { backendFile := logging.NewLogBackend(logFile, "", 0) backendFileLvl := logging.AddModuleLevel(backendFile) backendFileLvl.SetLevel(level, "") logging.SetBackend(backendLvl, backendFileLvl) Log.logFile = logFile } } if debugMode { StartServer() } Log.Notice("logger initialized") } return Log } func (log *CTopLogger) tail() chan string { stream := make(chan string) node := log.backend.Head() go func() { for { stream <- node.Record.Formatted(0) for { nnode := node.Next() if nnode != nil { node = nnode break } if exited { close(stream) return } time.Sleep(1 * time.Second) } } }() return stream } func (log *CTopLogger) Exit() { exited = true if log.logFile != nil { _ = log.logFile.Close() } StopServer() } func debugMode() bool { return os.Getenv("CTOP_DEBUG") == "1" } func debugModeTCP() bool { return os.Getenv("CTOP_DEBUG_TCP") == "1" } func debugModeFile() string { return os.Getenv("CTOP_DEBUG_FILE") } ================================================ FILE: logging/server.go ================================================ package logging import ( "fmt" "io" "net" "sync" ) const ( socketPath = "./ctop.sock" socketAddr = "0.0.0.0:9000" ) var server struct { wg sync.WaitGroup ln net.Listener } 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) } if err != nil { panic(err) } return ln } func StartServer() { server.ln = getListener() go func() { for { conn, err := server.ln.Accept() if err != nil { if err, ok := err.(net.Error); ok && err.Temporary() { continue } return } go handler(conn) } }() Log.Notice("logging server started") } func StopServer() { server.wg.Wait() if server.ln != nil { server.ln.Close() } } func handler(wc io.WriteCloser) { server.wg.Add(1) defer server.wg.Done() defer wc.Close() for msg := range Log.tail() { msg = fmt.Sprintf("%s\n", msg) wc.Write([]byte(msg)) } wc.Write([]byte("bye\n")) } ================================================ FILE: main.go ================================================ 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 errView *widgets.ErrorView versionStr = fmt.Sprintf("ctop version %v, build %v %v", version, build, goVersion) ) func main() { 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") 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() if err := config.Read(); err != nil { log.Warningf("reading config: %s", err) } // 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") } // init ui if *invertFlag { InvertColorMap() } ui.ColorMap = ColorMap // override default colormap if err := ui.Init(); err != nil { panic(err) } tm.SetInputMode(tm.InputAlt) defer Shutdown() // init grid, cursor, header cSuper, err := connector.ByName(*connectorFlag) if err != nil { panic(err) } cursor = &GridCursor{cSuper: cSuper} cGrid = compact.NewCompactGrid() header = widgets.NewCTopHeader() status = widgets.NewStatusLine() errView = widgets.NewErrorView() for { exit := Display() if exit { return } } } func Shutdown() { log.Notice("shutting down") log.Exit() if tm.IsInit { ui.Close() } } // 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) os.Exit(1) } } func panicExit() { if r := recover(); r != nil { Shutdown() panic(r) fmt.Printf("error: %s\n", r) os.Exit(1) } } var helpMsg = `ctop - interactive container viewer usage: ctop [options] options: ` func printHelp() { fmt.Println(helpMsg) flag.PrintDefaults() fmt.Printf("\navailable connectors: ") fmt.Println(strings.Join(connector.Enabled(), ", ")) } ================================================ FILE: menus.go ================================================ package main import ( "fmt" "strings" "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" "github.com/pkg/browser" ) // MenuFn executes a menu window, returning the next menu or nil type MenuFn func() MenuFn var helpDialog = []menu.Item{ {" - 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)", ""}, {"[e] - exec shell", ""}, {"[w] - open browser (first port is http)", ""}, {"[c] - configure columns", ""}, {"[S] - save current configuration to file", ""}, {"[q] - exit ctop", ""}, } func HelpMenu() MenuFn { ui.Clear() ui.DefaultEvtStream.ResetHandlers() defer ui.DefaultEvtStream.ResetHandlers() 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 { 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 stream := i.Stream() go func() { for s := range stream { config.Update("filterStr", s) RefreshDisplay() ui.Render(i) } }() i.InputHandlers() ui.Handle("/sys/kbd/", func(ui.Event) { config.Update("filterStr", "") ui.StopLoop() }) ui.Handle("/sys/kbd/", func(ui.Event) { config.Update("filterStr", i.Data) ui.StopLoop() }) ui.Loop() return nil } func SortMenu() MenuFn { ui.Clear() ui.DefaultEvtStream.ResetHandlers() defer ui.DefaultEvtStream.ResetHandlers() m := menu.NewMenu() m.Selectable = true m.SortItems = true m.BorderLabel = "Sort Field" for _, field := range container.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.Handle("/sys/kbd/", func(ui.Event) { config.Update("sortField", m.SelectedValue()) ui.StopLoop() }) ui.Render(m) ui.Loop() return nil } func ColumnsMenu() MenuFn { const ( enabledStr = "[X]" disabledStr = "[ ]" padding = 2 ) ui.Clear() ui.DefaultEvtStream.ResetHandlers() defer ui.DefaultEvtStream.ResetHandlers() m := menu.NewMenu() m.Selectable = true m.SortItems = false m.BorderLabel = "Columns" m.SubText = "Re-order: / " rebuild := func() { // get padding for right alignment of enabled status var maxLen int for _, col := range config.GlobalColumns { if len(col.Label) > maxLen { maxLen = len(col.Label) } } maxLen += padding // rebuild menu items m.ClearItems() for _, col := range config.GlobalColumns { txt := col.Label + strings.Repeat(" ", maxLen-len(col.Label)) if col.Enabled { txt += enabledStr } else { txt += disabledStr } m.AddItems(menu.Item{col.Name, txt}) } } upFn := func() { config.ColumnLeft(m.SelectedValue()) m.Up() rebuild() } downFn := func() { config.ColumnRight(m.SelectedValue()) m.Down() rebuild() } toggleFn := func() { config.ColumnToggle(m.SelectedValue()) rebuild() } rebuild() HandleKeys("up", m.Up) HandleKeys("down", m.Down) HandleKeys("enter", toggleFn) HandleKeys("pgup", upFn) HandleKeys("pgdown", downFn) ui.Handle("/sys/kbd/x", func(ui.Event) { toggleFn() }) ui.Handle("/sys/kbd/", func(ui.Event) { toggleFn() }) HandleKeys("exit", func() { cSource, err := cursor.cSuper.Get() if err == nil { for _, c := range cSource.All() { c.RecreateWidgets() } } 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: "[o] single view"}, menu.Item{Val: "logs", Label: "[l] log view"}, } if c.Meta["state"] == "running" { items = append(items, menu.Item{Val: "stop", Label: "[s] stop"}) items = append(items, menu.Item{Val: "pause", Label: "[p] pause"}) items = append(items, menu.Item{Val: "restart", Label: "[r] restart"}) items = append(items, menu.Item{Val: "exec", Label: "[e] exec shell"}) if c.Meta["Web Port"] != "" { items = append(items, menu.Item{Val: "browser", Label: "[w] open in browser"}) } } if c.Meta["state"] == "exited" || c.Meta["state"] == "created" { items = append(items, menu.Item{Val: "start", Label: "[s] start"}) items = append(items, menu.Item{Val: "remove", Label: "[R] remove"}) } if c.Meta["state"] == "paused" { items = append(items, menu.Item{Val: "unpause", Label: "[p] unpause"}) } items = append(items, menu.Item{Val: "cancel", Label: "[c] cancel"}) m.AddItems(items...) ui.Render(m) HandleKeys("up", m.Up) HandleKeys("down", m.Down) var selected string // shortcuts ui.Handle("/sys/kbd/o", func(ui.Event) { selected = "single" ui.StopLoop() }) ui.Handle("/sys/kbd/l", func(ui.Event) { selected = "logs" ui.StopLoop() }) if c.Meta["state"] != "paused" { ui.Handle("/sys/kbd/s", func(ui.Event) { if c.Meta["state"] == "running" { selected = "stop" } else { selected = "start" } ui.StopLoop() }) } if c.Meta["state"] != "exited" && c.Meta["state"] != "created" { ui.Handle("/sys/kbd/p", func(ui.Event) { if c.Meta["state"] == "paused" { selected = "unpause" } else { selected = "pause" } ui.StopLoop() }) } if c.Meta["state"] == "running" { ui.Handle("/sys/kbd/e", func(ui.Event) { selected = "exec" ui.StopLoop() }) ui.Handle("/sys/kbd/r", func(ui.Event) { selected = "restart" ui.StopLoop() }) if c.Meta["Web Port"] != "" { ui.Handle("/sys/kbd/w", func(ui.Event) { selected = "browser" }) } } ui.Handle("/sys/kbd/R", func(ui.Event) { selected = "remove" ui.StopLoop() }) ui.Handle("/sys/kbd/c", func(ui.Event) { ui.StopLoop() }) ui.Handle("/sys/kbd/", func(ui.Event) { selected = m.SelectedValue() ui.StopLoop() }) ui.Handle("/sys/kbd/", func(ui.Event) { ui.StopLoop() }) ui.Loop() var nextMenu MenuFn switch selected { case "single": nextMenu = SingleView case "logs": nextMenu = LogMenu case "exec": nextMenu = ExecShell case "browser": nextMenu = OpenInBrowser case "start": nextMenu = Confirm(confirmTxt("start", c.GetMeta("name")), c.Start) case "stop": nextMenu = Confirm(confirmTxt("stop", c.GetMeta("name")), c.Stop) case "remove": nextMenu = Confirm(confirmTxt("remove", c.GetMeta("name")), c.Remove) case "pause": nextMenu = Confirm(confirmTxt("pause", c.GetMeta("name")), c.Pause) case "unpause": nextMenu = Confirm(confirmTxt("unpause", c.GetMeta("name")), c.Unpause) case "restart": nextMenu = Confirm(confirmTxt("restart", c.GetMeta("name")), c.Restart) } return nextMenu } 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 } func ExecShell() MenuFn { c := cursor.Selected() if c == nil { return nil } ui.DefaultEvtStream.ResetHandlers() defer ui.DefaultEvtStream.ResetHandlers() // Detect and execute default shell in container. // Execute Ash shell command: /bin/sh -c // Reset colors: printf '\e[0m\e[?25h' // Clear screen // Run default shell for the user. It's configured in /etc/passwd and looks like root:x:0:0:root:/root:/bin/bash: // 1. Get current user id: id -un // 2. Find user's line in /etc/passwd by grep // 3. Extract default user's shell by cutting seven's column separated by : // 4. Execute the shell path with eval if err := c.Exec([]string{"/bin/sh", "-c", "printf '\\e[0m\\e[?25h' && clear && eval `grep ^$(id -un): /etc/passwd | cut -d : -f 7-`"}); err != nil { log.StatusErr(err) } return nil } func OpenInBrowser() MenuFn { c := cursor.Selected() if c == nil { return nil } webPort := c.Meta.Get("Web Port") if webPort == "" { return nil } link := "http://" + webPort + "/" browser.OpenURL(link) 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/", func(ui.Event) { switch m.SelectedValue() { 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) } ================================================ FILE: models/main.go ================================================ package models import "time" type Log struct { Timestamp time.Time Message string } type Meta map[string]string // NewMeta returns an initialized Meta map. // An optional series of key, values may be provided to populate the map prior to returning func NewMeta(kvs ...string) Meta { m := make(Meta) var i int for i < len(kvs)-1 { m[kvs[i]] = kvs[i+1] i += 2 } return m } func (m Meta) Get(k string) string { if s, ok := m[k]; ok { return s } return "" } type Metrics struct { NCpus uint8 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, } } ================================================ FILE: widgets/error.go ================================================ package widgets import ( "fmt" "strings" "time" ui "github.com/gizak/termui" ) type ErrorView struct { *ui.Par lines []string } func NewErrorView() *ErrorView { const yPad = 1 const xPad = 2 p := ui.NewPar("") p.X = xPad p.Y = yPad p.Border = true p.Height = 10 p.Width = 20 p.PaddingTop = yPad p.PaddingBottom = yPad p.PaddingLeft = xPad p.PaddingRight = xPad p.BorderLabel = " ctop - error " p.Bg = ui.ThemeAttr("bg") p.TextFgColor = ui.ThemeAttr("status.warn") p.TextBgColor = ui.ThemeAttr("menu.text.bg") p.BorderFg = ui.ThemeAttr("status.warn") p.BorderLabelFg = ui.ThemeAttr("status.warn") return &ErrorView{p, make([]string, 0, 50)} } func (w *ErrorView) Append(s string) { if len(w.lines)+2 >= cap(w.lines) { w.lines = append(w.lines[:0], w.lines[2:]...) } ts := time.Now().Local().Format("15:04:05 MST") w.lines = append(w.lines, fmt.Sprintf("[%s] %s", ts, s)) w.lines = append(w.lines, "") } func (w *ErrorView) Buffer() ui.Buffer { offset := len(w.lines) - w.InnerHeight() if offset < 0 { offset = 0 } w.Text = strings.Join(w.lines[offset:len(w.lines)], "\n") return w.Par.Buffer() } func (w *ErrorView) Resize() { w.Height = ui.TermHeight() - (w.PaddingTop + w.PaddingBottom) w.SetWidth(ui.TermWidth() - (w.PaddingLeft + w.PaddingRight)) } ================================================ FILE: widgets/header.go ================================================ package widgets import ( "fmt" "time" ui "github.com/gizak/termui" ) type CTopHeader struct { Time *ui.Par Count *ui.Par Filter *ui.Par bg *ui.Par } func NewCTopHeader() *CTopHeader { return &CTopHeader{ Time: headerPar(2, ""), Count: headerPar(24, "-"), Filter: headerPar(40, ""), bg: headerBg(), } } func (c *CTopHeader) Buffer() ui.Buffer { buf := ui.NewBuffer() c.Time.Text = timeStr() buf.Merge(c.bg.Buffer()) buf.Merge(c.Time.Buffer()) buf.Merge(c.Count.Buffer()) buf.Merge(c.Filter.Buffer()) return buf } func (c *CTopHeader) Align() { c.bg.SetWidth(ui.TermWidth() - 1) } func (c *CTopHeader) Height() int { return c.bg.Height } func headerBgBordered() *ui.Par { bg := ui.NewPar("") bg.X = 1 bg.Height = 3 bg.Bg = ui.ThemeAttr("header.bg") return bg } func headerBg() *ui.Par { bg := ui.NewPar("") bg.X = 1 bg.Height = 1 bg.Border = false bg.Bg = ui.ThemeAttr("header.bg") return bg } func (c *CTopHeader) SetCount(val int) { c.Count.Text = fmt.Sprintf("%d containers", val) } func (c *CTopHeader) SetFilter(val string) { if val == "" { c.Filter.Text = "" } else { c.Filter.Text = fmt.Sprintf("filter: %s", val) } } func timeStr() string { ts := time.Now().Local().Format("15:04:05 MST") return fmt.Sprintf("ctop - %s", ts) } func headerPar(x int, s string) *ui.Par { p := ui.NewPar(fmt.Sprintf(" %s", s)) p.X = x p.Border = false p.Height = 1 p.Width = 20 p.Bg = ui.ThemeAttr("header.bg") p.TextFgColor = ui.ThemeAttr("header.fg") p.TextBgColor = ui.ThemeAttr("header.bg") return p } ================================================ FILE: widgets/input.go ================================================ package widgets import ( "strings" ui "github.com/gizak/termui" ) var ( input_chars = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_." ) type Padding [2]int // x,y padding type Input struct { ui.Block Label string Data string MaxLen int TextFgColor ui.Attribute TextBgColor ui.Attribute stream chan string // stream text as it changes padding Padding } func NewInput() *Input { i := &Input{ Block: *ui.NewBlock(), Label: "input", MaxLen: 20, TextFgColor: ui.ThemeAttr("menu.text.fg"), TextBgColor: ui.ThemeAttr("menu.text.bg"), padding: Padding{4, 2}, } i.BorderFg = ui.ThemeAttr("menu.border.fg") i.BorderLabelFg = ui.ThemeAttr("menu.label.fg") i.calcSize() return i } func (i *Input) calcSize() { i.Height = 3 // minimum height i.Width = i.MaxLen + (i.padding[0] * 2) } func (i *Input) Buffer() ui.Buffer { var cell ui.Cell buf := i.Block.Buffer() x := i.Block.X + i.padding[0] y := i.Block.Y + 1 for _, ch := range i.Data { cell = ui.Cell{Ch: ch, Fg: i.TextFgColor, Bg: i.TextBgColor} buf.Set(x, y, cell) x++ } return buf } func (i *Input) Stream() chan string { i.stream = make(chan string) return i.stream } func (i *Input) KeyPress(e ui.Event) { ch := strings.Replace(e.Path, "/sys/kbd/", "", -1) if ch == "C-8" { idx := len(i.Data) - 1 if idx > -1 { i.Data = i.Data[0:idx] i.stream <- i.Data } ui.Render(i) return } if len(i.Data) >= i.MaxLen { return } if strings.Contains(input_chars, ch) { i.Data += ch i.stream <- i.Data ui.Render(i) } } // Setup some default handlers for menu navigation func (i *Input) InputHandlers() { ui.Handle("/sys/kbd/", i.KeyPress) } ================================================ FILE: widgets/menu/items.go ================================================ package menu type Item struct { Val string Label string } // Use label as display text of item, if given func (m Item) Text() string { if m.Label != "" { return m.Label } return m.Val } type Items []Item func NewItems(items ...Item) (mitems Items) { for _, i := range items { mitems = append(mitems, i) } return mitems } // Sort methods for Items func (m Items) Len() int { return len(m) } func (m Items) Swap(a, b int) { m[a], m[b] = m[b], m[a] } func (m Items) Less(a, b int) bool { return m[a].Text() < m[b].Text() } ================================================ FILE: widgets/menu/main.go ================================================ package menu import ( "sort" ui "github.com/gizak/termui" ) type Padding [2]int // x,y padding type Menu struct { ui.Block SortItems bool // enable automatic sorting of menu items Selectable bool // whether menu is navigable SubText string // optional text to display before items TextFgColor ui.Attribute TextBgColor ui.Attribute cursorPos int items Items padding Padding toolTip *ToolTip } func NewMenu() *Menu { m := &Menu{ Block: *ui.NewBlock(), TextFgColor: ui.ThemeAttr("menu.text.fg"), TextBgColor: ui.ThemeAttr("menu.text.bg"), cursorPos: 0, padding: Padding{4, 2}, } m.BorderFg = ui.ThemeAttr("menu.border.fg") m.BorderLabelFg = ui.ThemeAttr("menu.label.fg") m.X = 1 return m } // Append Item to Menu func (m *Menu) AddItems(items ...Item) { for _, i := range items { m.items = append(m.items, i) } m.refresh() } // DelItem removes menu item by value or label func (m *Menu) DelItem(s string) (success bool) { for n, i := range m.items { if i.Val == s || i.Label == s { m.items = append(m.items[:n], m.items[n+1:]...) success = true m.refresh() break } } return success } // ClearItems removes all current menu items func (m *Menu) ClearItems() { m.items = m.items[:0] } // Move cursor to an position by Item value or label func (m *Menu) SetCursor(s string) (success bool) { for n, i := range m.items { if i.Val == s || i.Label == s { m.cursorPos = n return true } } return false } // SetToolTip sets an optional tooltip string to show at bottom of screen func (m *Menu) SetToolTip(lines ...string) { m.toolTip = NewToolTip(lines...) } func (m *Menu) SelectedItem() Item { return m.items[m.cursorPos] } func (m *Menu) SelectedValue() string { return m.items[m.cursorPos].Val } 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] for _, ch := range item.Text() { // invert bg/fg colors on currently selected row if m.Selectable && n == m.cursorPos { cell = ui.Cell{Ch: ch, Fg: ui.ColorBlack, Bg: m.TextFgColor} } else { cell = ui.Cell{Ch: ch, Fg: m.TextFgColor, Bg: m.TextBgColor} } buf.Set(x, y+n, cell) x++ } } if m.toolTip != nil { buf.Merge(m.toolTip.Buffer()) } return buf } func (m *Menu) Up() { if m.cursorPos > 0 { m.cursorPos-- ui.Render(m) } } func (m *Menu) Down() { if m.cursorPos < (len(m.items) - 1) { m.cursorPos++ ui.Render(m) } } // Sort menu items(if enabled) and re-calculate window size func (m *Menu) refresh() { if m.SortItems { sort.Sort(m.items) } m.calcSize() ui.Render(m) } // Set width and height based on menu items func (m *Menu) calcSize() { m.Width = 7 // minimum width var height int 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) } ================================================ FILE: widgets/menu/tooltip.go ================================================ package menu import ( ui "github.com/gizak/termui" ) type ToolTip struct { ui.Block Lines []string TextFgColor ui.Attribute TextBgColor ui.Attribute padding Padding } func NewToolTip(lines ...string) *ToolTip { t := &ToolTip{ Block: *ui.NewBlock(), Lines: lines, TextFgColor: ui.ThemeAttr("menu.text.fg"), TextBgColor: ui.ThemeAttr("menu.text.bg"), padding: Padding{2, 1}, } t.BorderFg = ui.ThemeAttr("menu.border.fg") t.BorderLabelFg = ui.ThemeAttr("menu.label.fg") t.X = 1 t.Align() return t } func (t *ToolTip) Buffer() ui.Buffer { var cell ui.Cell buf := t.Block.Buffer() y := t.Y + t.padding[1] for n, line := range t.Lines { x := t.X + t.padding[0] for _, ch := range line { cell = ui.Cell{Ch: ch, Fg: t.TextFgColor, Bg: t.TextBgColor} buf.Set(x, y+n, cell) x++ } } return buf } // Set width and height based on screen size func (t *ToolTip) Align() { t.Width = ui.TermWidth() - (t.padding[0] * 2) t.Height = len(t.Lines) + (t.padding[1] * 2) t.Y = ui.TermHeight() - t.Height t.Block.Align() } ================================================ FILE: widgets/status.go ================================================ 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 } ================================================ FILE: widgets/view.go ================================================ package widgets import ( ui "github.com/gizak/termui" "github.com/mattn/go-runewidth" ) 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 + runewidth.RuneWidth(ch) } 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:] } } ================================================ FILE: widgets/view_test.go ================================================ 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)) } }