Compare commits

..

114 Commits

Author SHA1 Message Date
Bradley Cicenas
70bd2ae3a3 v0.7.2 2019-01-24 11:50:49 +00:00
Bradley Cicenas
665e8fdd06 move to go module 2018-12-01 17:50:47 +00:00
bradley
a39b7a3a3e Merge pull request #152 from barthr/master
Refactoring improvements based on linting issues
2018-10-26 09:00:02 -05:00
bartfokker
77f5e6b735 remove ignore of variable (unneeded when only index is needed) 2018-10-25 22:25:36 +02:00
bartfokker
3c83b7576b refactor string on multiple places to constant 2018-10-25 22:23:44 +02:00
bartfokker
8a0bd3cf8a remove unneeded cast 2018-10-25 22:22:28 +02:00
bartfokker
78caad2dbd depend on io.WriteCloser instead of net.Conn 2018-10-25 22:22:04 +02:00
bartfokker
8d8f1e72eb rename ConfigFile to File because config.ConfigFile stutters. Instead it's config.File 2018-10-25 22:21:08 +02:00
bartfokker
93556a1754 replace += with ++ 2018-10-25 22:17:53 +02:00
bartfokker
4d247f5272 replace unkeyed fiels with keyed fields when instantiating log struct 2018-10-25 22:17:05 +02:00
bartfokker
db3d7e8927 change strings.Index for strings.Contains 2018-10-25 22:14:00 +02:00
bartfokker
efef345665 remove unneeded fmt.Sprintf 2018-10-25 22:13:04 +02:00
bartfokker
f158fa742f simplify append operation by omitting loop 2018-10-25 22:12:46 +02:00
bartfokker
4d48245d7d improve boolean logic 2018-10-25 22:12:17 +02:00
bartfokker
6bee1b7f31 remove unneeded select for simple channel receive 2018-10-25 22:11:17 +02:00
bartfokker
7118e45f3a add vendor directory to gitignore 2018-10-25 22:04:40 +02:00
bradley
3405d19be8 Merge pull request #147 from serg-bloim/env-var
Display environment variables on single view page
2018-10-10 21:45:08 +08:00
Serhii Bilonozhko
9a185b2388 env-var 2018-10-05 17:35:22 -04:00
Bradley Cicenas
caf6fc63c1 add config toggle for full-row cursor 2018-09-17 01:33:52 +00:00
Bradley Cicenas
cf352f7c8a implement full-row cursor highlighting 2018-09-17 01:24:06 +00:00
bradley
ac5bed210f Merge pull request #143 from jphautin/add_networks_ips
add IP of networks in single view mode
2018-09-15 10:43:22 +09:00
Jean-Philippe
a72d43526f add IP of networks in single view mode 2018-09-06 21:01:16 +02:00
bradley
9eb2457aa4 Merge pull request #132 from xiechengsheng/fix-120
add support for alternative navigation
2018-06-30 08:31:44 +02:00
Bradley Cicenas
b83402b886 add TERM env var to Dockerfile 2018-06-28 11:19:07 +00:00
xiechengsheng
078564bd38 add support for alternative navigation
Signed-off-by: xiechengsheng <XIE1995@whut.edu.cn>
2018-06-28 16:40:26 +08:00
bradley
a2c08d312e Merge pull request #131 from xiechengsheng/add-pause-unpause
Feature: add more commands in container manager menu
2018-06-28 10:25:45 +02:00
xiechengsheng
f7a3d38d6b add more commands in container manager menu
Signed-off-by: xiechengsheng <XIE1995@whut.edu.cn>
2018-06-22 15:41:16 +08:00
Bradley Cicenas
a3b8585697 add requirement check to install script 2018-06-13 09:20:05 +00:00
bradley
2e526e9b86 Merge pull request #130 from felipeconti/master
Installing on /usr/local/bin as root
2018-06-13 11:04:34 +02:00
Felipe B. Conti
541fe70b78 Remove unnecessary treatment 2018-06-10 14:12:07 -03:00
Felipe Conti
c786b697bf Add function command_exists 2018-06-10 14:04:34 -03:00
Felipe Conti
aa6c00b083 Treatment to use root 2018-06-10 14:02:42 -03:00
Bradley Cicenas
17855e3d8e add container name to log view title 2018-05-10 09:53:59 +00:00
Bradley Cicenas
842809bef5 enable termbox alt input 2018-05-10 09:44:57 +00:00
Bradley Cicenas
4e567ee007 update README 2018-03-09 05:37:36 +00:00
Bradley Cicenas
56700e120b add go-winio dep 2018-03-09 05:28:11 +00:00
Bradley Cicenas
5261444265 v0.7.1 2018-03-09 05:19:34 +00:00
Bradley Cicenas
b3aa291182 update termbox-go dependency 2018-03-09 05:15:55 +00:00
Bradley Cicenas
051b474bf0 dep updates 2018-02-23 13:57:59 +09:30
Bradley Cicenas
fac6632459 build image from go 1.10 2018-02-22 14:58:21 +09:30
Bradley Cicenas
1c7cf98e58 handle window resize in help dialog 2018-02-02 15:21:33 +00:00
Bradley Cicenas
44a54e070d combine conditionals 2018-02-01 16:55:23 +00:00
Bradley Cicenas
10b9a6c013 include windows build 2018-01-31 17:05:54 +00:00
Bradley Cicenas
a3b67e4607 add available connectors to help dialog 2018-01-30 07:54:13 -03:00
Bradley Cicenas
ac1ce18143 refactor enabled connectors 2018-01-29 12:47:10 +00:00
bradley
01a305d326 Update README.md
escape brackets
2018-01-11 15:58:37 -03:00
Bradley Cicenas
233259be40 v0.7 2018-01-11 15:32:34 -03:00
Bradley Cicenas
107def9ccc add status err messages to start/stop/remove 2018-01-11 15:27:30 -03:00
Bradley Cicenas
d46ce783c2 add statusline widget, status messages to logging 2018-01-11 15:19:01 -03:00
Bradley Cicenas
d743472b16 add support for config file, keybinding for exporting active config 2018-01-11 13:15:18 -03:00
Bradley Cicenas
d785b263f4 add scale-cpu switch 2018-01-11 12:19:00 -03:00
Bradley Cicenas
6d37a4e333 enable remove action for "created" state containers 2018-01-11 11:40:21 -03:00
Bradley Cicenas
eb49e51ffb prevent recursive/nested running menus, handle all within main Display() loop 2018-01-11 11:23:28 -03:00
Bradley Cicenas
734d4bfc0c add optional subtext to menu widget 2018-01-11 10:56:00 -03:00
Bradley Cicenas
0e75bbda58 add confirmation dialog for container management actions 2018-01-11 10:26:58 -03:00
Bradley Cicenas
8b5eb21ac3 add logs to container menu 2018-01-10 18:10:11 -03:00
Bradley Cicenas
c958e4c34e move keybinding for single view, add single view to container menu 2018-01-10 15:45:14 -03:00
Bradley Cicenas
ab48d830d1 import formatting 2017-12-13 09:20:14 +08:00
bradley
915579c0a4 Merge pull request #110 from HotelsDotCom/master
added option to toggle log timestamp in the log view, closes #107
2017-12-13 09:16:33 +08:00
Peter Reisinger
0a6e6f02a4 added option to toggle log timestamp in the log view, closes #107 2017-12-08 11:35:04 +00:00
Bradley Cicenas
a135f45844 add simple output fn to installer script 2017-12-08 09:37:54 +08:00
Bradley Cicenas
b39b91774d add checksum validation to installer script 2017-12-05 17:50:00 +08:00
Bradley Cicenas
2275248813 init simple installer script 2017-12-02 20:49:37 +00:00
bradley
75f4a91f11 Merge pull request #108 from HotelsDotCom/master
line wrapping in log view, closes #106
2017-12-01 21:47:05 -05:00
Peter Reisinger
d0b5c6c854 added test for text view split line 2017-12-01 15:51:55 +00:00
Peter Reisinger
bd8940ae0a line wrapping in log view, closes #106 2017-12-01 15:29:11 +00:00
Bradley Cicenas
1be64f0d11 define flag vars in block 2017-11-29 03:03:46 +00:00
Bradley Cicenas
1c8f4b3a35 add logging for log reader start/stop 2017-11-28 09:38:32 -05:00
Bradley Cicenas
3e5176a79c add NewDockerLogs constructor method 2017-11-28 09:36:28 -05:00
Bradley Cicenas
53c0f2a9df add resize handling to log view 2017-11-28 08:55:29 -05:00
Bradley Cicenas
28389aa38c gofmt 2017-11-28 08:40:43 -05:00
bradley
fb5c825cf6 Merge pull request #105 from HotelsDotCom/master
Added 'view logs binding'
2017-11-26 12:41:57 -05:00
Peter Reisinger
a0e0da1da9 Added 'view logs binding' 2017-11-25 18:30:50 +00:00
Bradley Cicenas
a826859202 gofmt 2017-11-22 09:27:38 -05:00
Bradley Cicenas
71b4a1de94 add runc manager placeholder 2017-11-22 09:26:01 -05:00
bradley
93a2e4b1ca Merge pull request #104 from HotelsDotCom/master
added container menu - closes #28
2017-11-22 08:22:37 -06:00
Peter Reisinger
436266b1a4 added container menu closes #28 2017-11-20 11:09:36 +00:00
Bradley Cicenas
19427e33a0 add checksum verify to make release 2017-11-19 15:06:29 +00:00
Bradley Cicenas
48d683be77 add checksum generation to makefile 2017-11-18 07:51:15 +00:00
bradley
e1ec264345 Merge pull request #90 from bcicen/godep
Move from Glide -> Dep
2017-09-03 11:10:59 +09:00
Bradley Cicenas
9aad2efdb0 update build docs with dep link 2017-09-03 10:53:01 +09:00
Bradley Cicenas
f6595a02c4 replace glide with godep 2017-09-03 10:33:14 +09:00
Bradley Cicenas
92ca9bf7eb remove textcol coloring 2017-08-28 10:48:13 +09:00
Bradley Cicenas
05242a83f0 refactor status widget, include health indicator 2017-08-28 10:45:14 +09:00
Bradley Cicenas
add44c0f18 add common status colors to global theme 2017-08-28 08:46:01 +09:00
Bradley Cicenas
a1ebf3f90e remove duplicate inspect, trigger refresh on "health_status" events 2017-08-28 08:29:59 +09:00
Alexandr Kozlenkov
626d50d3e9 Added health row to Info Single mode.
Change color status logic.

Highlight health status for Name column:
- starting - yellow
- healthy - green
- unhealthy - magenta

reformat

Fixed misprint

Removed unused colors of state widget.

Moved changes to another branch

Removed unused colors of state widget.

Remove swarm changes from master

Remove swarm changes from master

Remove swarm changes from master
2017-08-28 07:55:43 +09:00
bradley
eaa7ad85f8 Merge pull request #87 from mavimo/patch-1
Update version in readme
2017-08-25 14:22:04 +09:00
Marco Vito Moscaritolo
be9be0b2d1 Update version in readme
SSIA
2017-08-24 12:27:15 +02:00
bradley
f196999c67 Merge pull request #86 from kentaost/runc-root-description
update RUNC_ROOT description to adjust to runC man
2017-08-22 15:22:19 +09:00
Kenta Tada
e674ec4f33 update RUNC_ROOT description to adjust to runC man 2017-08-21 21:36:45 +09:00
Bradley Cicenas
f23805550f remove deprecated -e option 2017-08-19 14:33:56 +09:00
Bradley Cicenas
55a356bbec omit cache from memory usage stat 2017-08-11 16:44:52 +02:00
Bradley Cicenas
954aaeb06b rename Memory widget 2017-08-09 14:05:45 +02:00
Bradley Cicenas
27e272c58f rename expanded -> single view 2017-08-05 13:28:20 +02:00
Bradley Cicenas
3ed9912bcb update circleci 2017-06-29 14:02:52 +00:00
Bradley Cicenas
e0f6563a39 move circleci config to 2.0 2017-06-29 14:02:52 +00:00
Bradley Cicenas
caa64724d0 fix release dirname 2017-06-29 14:02:52 +00:00
Bradley Cicenas
a2011b8bc7 v0.6.1 2017-06-29 14:02:52 +00:00
Bradley Cicenas
40fd9e935a combine image build steps 2017-06-29 14:02:52 +00:00
Bradley Cicenas
b88c143914 add offset sanity check to CompactGrid Align() 2017-07-07 15:43:03 +03:00
Bradley Cicenas
0a05007c4e skip offset updates in page scroll if no pages 2017-07-07 15:38:02 +03:00
Bradley Cicenas
c47ba3f804 add pgCount() method to GridCursor 2017-07-07 15:28:26 +03:00
Bradley Cicenas
79a3f361a7 add container log struct to models, collectors 2017-07-04 12:32:25 +00:00
Bradley Cicenas
65399a37e5 add log panel to expanded widgets 2017-06-29 14:02:52 +00:00
Bradley Cicenas
25a3fcf731 add runtimestats, stack logging to debug 2017-06-28 09:12:24 -03:00
Bradley Cicenas
17e2c2df8e add LogCollector interface, docker, mock log collectors 2017-06-27 14:18:17 -03:00
Bradley Cicenas
240345d527 add StreamLogs() to collector interface 2017-06-26 15:35:57 +00:00
Bradley Cicenas
2d284d9277 rename metrics subpackage 2017-06-26 15:35:57 +00:00
Bradley Cicenas
bfa5c5944f rename 2017-06-26 15:35:57 +00:00
Bradley Cicenas
e1051cd40f use underscore-prefixed build dir in makefile 2017-06-24 08:04:56 -03:00
Bradley Cicenas
13029cc7fe add go runtime to version output 2017-06-19 12:23:39 +00:00
Bradley Cicenas
58d9e4e194 reverse host and container port in metadata 2017-06-18 17:17:56 -03:00
Bradley Cicenas
4de7036e2f fix release url 2017-06-14 15:04:47 -03:00
70 changed files with 1826 additions and 491 deletions

19
.circleci/config.yml Normal file
View File

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

1
.gitignore vendored
View File

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

View File

@@ -1,3 +1,17 @@
FROM quay.io/vektorcloud/go:1.11
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 FROM scratch
COPY ./ctop /ctop ENV TERM=linux
COPY --from=0 /go/bin/ctop /ctop
ENTRYPOINT ["/ctop"] ENTRYPOINT ["/ctop"]

View File

@@ -1,12 +0,0 @@
FROM quay.io/vektorcloud/go:1.8
RUN apk add --no-cache make
COPY glide.* /go/src/github.com/bcicen/ctop/
WORKDIR /go/src/github.com/bcicen/ctop/
RUN glide install
COPY . /go/src/github.com/bcicen/ctop
RUN make build && \
mkdir -p /go/bin && \
mv -v ctop /go/bin/

View File

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

View File

@@ -9,7 +9,7 @@ Top-like interface for container metrics
`ctop` provides a concise and condensed overview of real-time metrics for multiple containers: `ctop` provides a concise and condensed overview of real-time metrics for multiple containers:
<p align="center"><img src="_docs/img/grid.gif" alt="ctop"/></p> <p align="center"><img src="_docs/img/grid.gif" alt="ctop"/></p>
as well as an [expanded view][expanded_view] for inspecting a specific container. as well as an [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. `ctop` comes with built-in support for Docker and runC; connectors for other container and cluster systems are planned for future releases.
@@ -20,7 +20,7 @@ Fetch the [latest release](https://github.com/bcicen/ctop/releases) for your pla
#### Linux #### Linux
```bash ```bash
sudo wget https://github.com/bcicen/ctop/releases/download/v0.6/ctop-0.6-linux-amd64 -O /usr/local/bin/ctop sudo wget https://github.com/bcicen/ctop/releases/download/v0.7.2/ctop-0.7.2-linux-amd64 -O /usr/local/bin/ctop
sudo chmod +x /usr/local/bin/ctop sudo chmod +x /usr/local/bin/ctop
``` ```
@@ -31,7 +31,7 @@ brew install ctop
``` ```
or or
```bash ```bash
sudo curl -Lo /usr/local/bin/ctop https://github.com/bcicen/ctop/releases/download/v0.6/ctop-0.6-darwin-amd64 sudo curl -Lo /usr/local/bin/ctop https://github.com/bcicen/ctop/releases/download/v0.7.2/ctop-0.7.2-darwin-amd64
sudo chmod +x /usr/local/bin/ctop sudo chmod +x /usr/local/bin/ctop
``` ```
@@ -54,32 +54,41 @@ Build steps can be found [here][build].
`ctop` requires no arguments and uses Docker host variables by default. See [connectors][connectors] for further configuration options. `ctop` requires no arguments and uses Docker host variables by default. See [connectors][connectors] for further configuration options.
### Config file
While running, use `S` to save the current filters, sort field, and other options to a default config path. These settings will be loaded and applied the next time `ctop` is started.
### Options ### Options
Option | Description Option | Description
--- | --- --- | ---
-a | show active containers only -a | show active containers only
-f <string> | set an initial filter string -f \<string\> | set an initial filter string
-h | display help dialog -h | display help dialog
-i | invert default colors -i | invert default colors
-r | reverse container sort order -r | reverse container sort order
-s | select initial container sort field -s | select initial container sort field
-scale-cpu | show cpu as % of system total
-v | output version information and exit -v | output version information and exit
### Keybindings ### Keybindings
Key | Action Key | Action
--- | --- --- | ---
\<enter\> | Open container menu
a | Toggle display of all (running and non-running) containers a | Toggle display of all (running and non-running) containers
f | Filter displayed containers (`esc` to clear when open) f | Filter displayed containers (`esc` to clear when open)
H | Toggle ctop header H | Toggle ctop header
h | Open help dialog h | Open help dialog
s | Select container sort field s | Select container sort field
r | Reverse container sort order r | Reverse container sort order
o | Open single view
l | View container logs (`t` to toggle timestamp when open)
S | Save current configuration to file
q | Quit ctop q | Quit ctop
[build]: _docs/build.md [build]: _docs/build.md
[connectors]: _docs/connectors.md [connectors]: _docs/connectors.md
[expanded_view]: _docs/expanded.md [single_view]: _docs/single.md
[release]: https://img.shields.io/github/release/bcicen/ctop.svg "ctop" [release]: https://img.shields.io/github/release/bcicen/ctop.svg "ctop"
[homebrew]: https://img.shields.io/homebrew/v/ctop.svg "ctop" [homebrew]: https://img.shields.io/homebrew/v/ctop.svg "ctop"

View File

@@ -1 +1 @@
0.6.0 0.7.2

View File

@@ -1,6 +1,6 @@
# Build # Build
To build `ctop` from source, ensure you have a recent version of [glide](https://github.com/Masterminds/glide) installed and run: To build `ctop` from source, ensure you have [dep](https://github.com/golang/dep) installed and run:
```bash ```bash
go get github.com/bcicen/ctop && \ go get github.com/bcicen/ctop && \

View File

@@ -16,11 +16,11 @@ DOCKER_HOST | Daemon socket to connect to (default: `unix://var/run/docker.sock`
## RunC ## RunC
Using this connector requires full privileges to the local runC root dir (default: `/run/runc`) Using this connector requires full privileges to the local runC root dir of container state (default: `/run/runc`)
#### Options #### Options
Var | Description Var | Description
--- | --- --- | ---
RUNC_ROOT | path to runc root (default: `/run/runc`) RUNC_ROOT | path to runc root for container state (default: `/run/runc`)
RUNC_SYSTEMD_CGROUP | if set, enable systemd cgroups RUNC_SYSTEMD_CGROUP | if set, enable systemd cgroups

View File

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

View File

Before

Width:  |  Height:  |  Size: 549 KiB

After

Width:  |  Height:  |  Size: 549 KiB

4
_docs/single.md Normal file
View File

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

View File

@@ -1,23 +0,0 @@
machine:
services:
- docker
environment:
IMAGE_NAME: quay.io/vektorlab/ctop
dependencies:
override:
- docker info
- make image
test:
override:
- docker run -ti ctop -v
deployment:
hub:
branch: master
commands:
- docker tag ctop ${IMAGE_NAME}:latest
- docker tag ctop ${IMAGE_NAME}:$(cat VERSION)
- docker login -e $DOCKER_EMAIL -u $DOCKER_USER -p $DOCKER_PASS quay.io
- docker push ${IMAGE_NAME}

View File

@@ -45,11 +45,14 @@ var ColorMap = map[string]ui.Attribute{
"par.text.hi": ui.ColorBlack, "par.text.hi": ui.ColorBlack,
"sparkline.line.fg": ui.ColorGreen, "sparkline.line.fg": ui.ColorGreen,
"sparkline.title.fg": ui.ColorWhite, "sparkline.title.fg": ui.ColorWhite,
"status.ok": ui.ColorGreen,
"status.warn": ui.ColorYellow,
"status.danger": ui.ColorRed,
} }
func InvertColorMap() { func InvertColorMap() {
re := regexp.MustCompile(".*.fg") re := regexp.MustCompile(".*.fg")
for k, _ := range ColorMap { for k := range ColorMap {
if re.FindAllString(k, 1) != nil { if re.FindAllString(k, 1) != nil {
ColorMap[k] = ui.ColorBlack ColorMap[k] = ui.ColorBlack
} }

119
config/file.go Normal file
View File

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

View File

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

View File

@@ -1,33 +1,36 @@
package collector package collector
import ( import (
"github.com/bcicen/ctop/metrics" "github.com/bcicen/ctop/config"
"github.com/bcicen/ctop/models"
api "github.com/fsouza/go-dockerclient" api "github.com/fsouza/go-dockerclient"
) )
// Docker collector // Docker collector
type Docker struct { type Docker struct {
metrics.Metrics models.Metrics
id string id string
client *api.Client client *api.Client
running bool running bool
stream chan metrics.Metrics stream chan models.Metrics
done chan bool done chan bool
lastCpu float64 lastCpu float64
lastSysCpu float64 lastSysCpu float64
scaleCpu bool
} }
func NewDocker(client *api.Client, id string) *Docker { func NewDocker(client *api.Client, id string) *Docker {
return &Docker{ return &Docker{
Metrics: metrics.Metrics{}, Metrics: models.Metrics{},
id: id, id: id,
client: client, client: client,
scaleCpu: config.GetSwitchVal("scaleCpu"),
} }
} }
func (c *Docker) Start() { func (c *Docker) Start() {
c.done = make(chan bool) c.done = make(chan bool)
c.stream = make(chan metrics.Metrics) c.stream = make(chan models.Metrics)
stats := make(chan *api.Stats) stats := make(chan *api.Stats)
go func() { go func() {
@@ -61,10 +64,14 @@ func (c *Docker) Running() bool {
return c.running return c.running
} }
func (c *Docker) Stream() chan metrics.Metrics { func (c *Docker) Stream() chan models.Metrics {
return c.stream return c.stream
} }
func (c *Docker) Logs() LogCollector {
return NewDockerLogs(c.id, c.client)
}
// Stop collector // Stop collector
func (c *Docker) Stop() { func (c *Docker) Stop() {
c.done <- true c.done <- true
@@ -78,14 +85,18 @@ func (c *Docker) ReadCPU(stats *api.Stats) {
cpudiff := total - c.lastCpu cpudiff := total - c.lastCpu
syscpudiff := system - c.lastSysCpu syscpudiff := system - c.lastSysCpu
if c.scaleCpu {
c.CPUUtil = round((cpudiff / syscpudiff * 100))
} else {
c.CPUUtil = round((cpudiff / syscpudiff * 100) * ncpus) c.CPUUtil = round((cpudiff / syscpudiff * 100) * ncpus)
}
c.lastCpu = total c.lastCpu = total
c.lastSysCpu = system c.lastSysCpu = system
c.Pids = int(stats.PidsStats.Current) c.Pids = int(stats.PidsStats.Current)
} }
func (c *Docker) ReadMem(stats *api.Stats) { func (c *Docker) ReadMem(stats *api.Stats) {
c.MemUsage = int64(stats.MemoryStats.Usage) c.MemUsage = int64(stats.MemoryStats.Usage - stats.MemoryStats.Stats.Cache)
c.MemLimit = int64(stats.MemoryStats.Limit) c.MemLimit = int64(stats.MemoryStats.Limit)
c.MemPercent = percent(float64(c.MemUsage), float64(c.MemLimit)) c.MemPercent = percent(float64(c.MemUsage), float64(c.MemLimit))
} }

View File

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

View File

@@ -4,10 +4,24 @@ import (
"math" "math"
"github.com/bcicen/ctop/logging" "github.com/bcicen/ctop/logging"
"github.com/bcicen/ctop/models"
) )
var log = logging.Init() 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 { func round(num float64) int {
return int(num + math.Copysign(0.5, num)) return int(num + math.Copysign(0.5, num))
} }

View File

@@ -6,13 +6,13 @@ import (
"math/rand" "math/rand"
"time" "time"
"github.com/bcicen/ctop/metrics" "github.com/bcicen/ctop/models"
) )
// Mock collector // Mock collector
type Mock struct { type Mock struct {
metrics.Metrics models.Metrics
stream chan metrics.Metrics stream chan models.Metrics
done bool done bool
running bool running bool
aggression int64 aggression int64
@@ -20,7 +20,7 @@ type Mock struct {
func NewMock(a int64) *Mock { func NewMock(a int64) *Mock {
c := &Mock{ c := &Mock{
Metrics: metrics.Metrics{}, Metrics: models.Metrics{},
aggression: a, aggression: a,
} }
c.MemLimit = 2147483648 c.MemLimit = 2147483648
@@ -33,7 +33,7 @@ func (c *Mock) Running() bool {
func (c *Mock) Start() { func (c *Mock) Start() {
c.done = false c.done = false
c.stream = make(chan metrics.Metrics) c.stream = make(chan models.Metrics)
go c.run() go c.run()
} }
@@ -41,10 +41,14 @@ func (c *Mock) Stop() {
c.done = true c.done = true
} }
func (c *Mock) Stream() chan metrics.Metrics { func (c *Mock) Stream() chan models.Metrics {
return c.stream return c.stream
} }
func (c *Mock) Logs() LogCollector {
return &MockLogs{make(chan bool)}
}
func (c *Mock) run() { func (c *Mock) run() {
c.running = true c.running = true
rand.Seed(int64(time.Now().Nanosecond())) rand.Seed(int64(time.Now().Nanosecond()))

View File

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

View File

@@ -1,4 +1,4 @@
// +build !darwin // +build linux
package collector package collector

View File

@@ -1,34 +1,37 @@
// +build !darwin // +build linux
package collector package collector
import ( import (
"time" "time"
"github.com/bcicen/ctop/metrics" "github.com/bcicen/ctop/config"
"github.com/bcicen/ctop/models"
"github.com/opencontainers/runc/libcontainer" "github.com/opencontainers/runc/libcontainer"
"github.com/opencontainers/runc/libcontainer/cgroups" "github.com/opencontainers/runc/libcontainer/cgroups"
) )
// Runc collector // Runc collector
type Runc struct { type Runc struct {
metrics.Metrics models.Metrics
id string id string
libc libcontainer.Container libc libcontainer.Container
stream chan metrics.Metrics stream chan models.Metrics
done bool done bool
running bool running bool
interval int // collection interval, in seconds interval int // collection interval, in seconds
lastCpu float64 lastCpu float64
lastSysCpu float64 lastSysCpu float64
scaleCpu bool
} }
func NewRunc(libc libcontainer.Container) *Runc { func NewRunc(libc libcontainer.Container) *Runc {
c := &Runc{ c := &Runc{
Metrics: metrics.Metrics{}, Metrics: models.Metrics{},
id: libc.ID(), id: libc.ID(),
libc: libc, libc: libc,
interval: 1, interval: 1,
scaleCpu: config.GetSwitchVal("scaleCpu"),
} }
return c return c
} }
@@ -39,7 +42,7 @@ func (c *Runc) Running() bool {
func (c *Runc) Start() { func (c *Runc) Start() {
c.done = false c.done = false
c.stream = make(chan metrics.Metrics) c.stream = make(chan models.Metrics)
go c.run() go c.run()
} }
@@ -47,10 +50,14 @@ func (c *Runc) Stop() {
c.done = true c.done = true
} }
func (c *Runc) Stream() chan metrics.Metrics { func (c *Runc) Stream() chan models.Metrics {
return c.stream return c.stream
} }
func (c *Runc) Logs() LogCollector {
return nil
}
func (c *Runc) run() { func (c *Runc) run() {
c.running = true c.running = true
defer close(c.stream) defer close(c.stream)
@@ -86,7 +93,11 @@ func (c *Runc) ReadCPU(stats *cgroups.Stats) {
cpudiff := total - c.lastCpu cpudiff := total - c.lastCpu
syscpudiff := system - c.lastSysCpu syscpudiff := system - c.lastSysCpu
if c.scaleCpu {
c.CPUUtil = round((cpudiff / syscpudiff * 100))
} else {
c.CPUUtil = round((cpudiff / syscpudiff * 100) * ncpus) c.CPUUtil = round((cpudiff / syscpudiff * 100) * ncpus)
}
c.lastCpu = total c.lastCpu = total
c.lastSysCpu = system c.lastSysCpu = system
c.Pids = int(stats.PidsStats.Current) c.Pids = int(stats.PidsStats.Current)

View File

@@ -6,10 +6,13 @@ import (
"sync" "sync"
"github.com/bcicen/ctop/connector/collector" "github.com/bcicen/ctop/connector/collector"
"github.com/bcicen/ctop/connector/manager"
"github.com/bcicen/ctop/container" "github.com/bcicen/ctop/container"
api "github.com/fsouza/go-dockerclient" api "github.com/fsouza/go-dockerclient"
) )
func init() { enabled["docker"] = NewDocker }
type Docker struct { type Docker struct {
client *api.Client client *api.Client
containers map[string]*container.Container containers map[string]*container.Container
@@ -45,8 +48,11 @@ func (cm *Docker) watchEvents() {
if e.Type != "container" { if e.Type != "container" {
continue continue
} }
switch e.Action {
case "start", "die", "pause", "unpause": actionName := strings.Split(e.Action, ":")[0]
switch actionName {
case "start", "die", "pause", "unpause", "health_status":
log.Debugf("handling docker event: action=%s id=%s", e.Action, e.ID) log.Debugf("handling docker event: action=%s id=%s", e.Action, e.ID)
cm.needsRefresh <- e.ID cm.needsRefresh <- e.ID
case "destroy": case "destroy":
@@ -66,7 +72,7 @@ func portsFormat(ports map[api.Port][]api.PortBinding) string {
continue continue
} }
for _, binding := range v { for _, binding := range v {
s := fmt.Sprintf("%s -> %s:%s", k, binding.HostIP, binding.HostPort) s := fmt.Sprintf("%s:%s -> %s", binding.HostIP, binding.HostPort, k)
published = append(published, s) published = append(published, s)
} }
} }
@@ -74,6 +80,17 @@ func portsFormat(ports map[api.Port][]api.PortBinding) string {
return strings.Join(append(exposed, published...), "\n") return strings.Join(append(exposed, published...), "\n")
} }
func ipsFormat(networks map[string]api.ContainerNetwork) string {
var ips []string
for k, v := range networks {
s := fmt.Sprintf("%s:%s", k, v.IPAddress)
ips = append(ips, s)
}
return strings.Join(ips, "\n")
}
func (cm *Docker) refresh(c *container.Container) { func (cm *Docker) refresh(c *container.Container) {
insp := cm.inspect(c.Id) insp := cm.inspect(c.Id)
// remove container if no longer exists // remove container if no longer exists
@@ -83,15 +100,20 @@ func (cm *Docker) refresh(c *container.Container) {
} }
c.SetMeta("name", shortName(insp.Name)) c.SetMeta("name", shortName(insp.Name))
c.SetMeta("image", insp.Config.Image) c.SetMeta("image", insp.Config.Image)
c.SetMeta("IPs", ipsFormat(insp.NetworkSettings.Networks))
c.SetMeta("ports", portsFormat(insp.NetworkSettings.Ports)) c.SetMeta("ports", portsFormat(insp.NetworkSettings.Ports))
c.SetMeta("created", insp.Created.Format("Mon Jan 2 15:04:05 2006")) c.SetMeta("created", insp.Created.Format("Mon Jan 2 15:04:05 2006"))
c.SetMeta("health", insp.State.Health.Status)
for _, env := range insp.Config.Env {
c.SetMeta("[ENV-VAR]", env)
}
c.SetState(insp.State.Status) c.SetState(insp.State.Status)
} }
func (cm *Docker) inspect(id string) *api.Container { func (cm *Docker) inspect(id string) *api.Container {
c, err := cm.client.InspectContainer(id) c, err := cm.client.InspectContainer(id)
if err != nil { if err != nil {
if _, ok := err.(*api.NoSuchContainer); ok == false { if _, ok := err.(*api.NoSuchContainer); !ok {
log.Errorf(err.Error()) log.Errorf(err.Error())
} }
} }
@@ -128,8 +150,10 @@ func (cm *Docker) MustGet(id string) *container.Container {
if !ok { if !ok {
// create collector // create collector
collector := collector.NewDocker(cm.client, id) collector := collector.NewDocker(cm.client, id)
// create manager
manager := manager.NewDocker(cm.client, id)
// create container // create container
c = container.New(id, collector) c = container.New(id, collector, manager)
cm.lock.Lock() cm.lock.Lock()
cm.containers[id] = c cm.containers[id] = c
cm.lock.Unlock() cm.lock.Unlock()
@@ -159,6 +183,7 @@ func (cm *Docker) All() (containers container.Containers) {
for _, c := range cm.containers { for _, c := range cm.containers {
containers = append(containers, c) containers = append(containers, c)
} }
containers.Sort() containers.Sort()
containers.Filter() containers.Filter()
cm.lock.Unlock() cm.lock.Unlock()

View File

@@ -1,7 +0,0 @@
// +build !linux
package connector
var enabled = map[string]func() Connector{
"docker": NewDocker,
}

View File

@@ -1,8 +0,0 @@
// +build !darwin
package connector
var enabled = map[string]func() Connector{
"docker": NewDocker,
"runc": NewRunc,
}

View File

@@ -2,22 +2,31 @@ package connector
import ( import (
"fmt" "fmt"
"sort"
"github.com/bcicen/ctop/container" "github.com/bcicen/ctop/container"
"github.com/bcicen/ctop/logging" "github.com/bcicen/ctop/logging"
) )
var log = logging.Init() var (
log = logging.Init()
enabled = make(map[string]func() Connector)
)
// return names for all enabled connectors on the current platform
func Enabled() (a []string) {
for k, _ := range enabled {
a = append(a, k)
}
sort.Strings(a)
return a
}
func ByName(s string) (Connector, error) { func ByName(s string) (Connector, error) {
if _, ok := enabled[s]; !ok { if cfn, ok := enabled[s]; ok {
msg := fmt.Sprintf("invalid connector type \"%s\"\nconnector must be one of:", s) return cfn(), nil
for k, _ := range enabled {
msg += fmt.Sprintf("\n %s", k)
} }
return nil, fmt.Errorf(msg) return nil, fmt.Errorf("invalid connector type \"%s\"", s)
}
return enabled[s](), nil
} }
type Connector interface { type Connector interface {

View File

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

10
connector/manager/main.go Normal file
View File

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

31
connector/manager/mock.go Normal file
View File

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

31
connector/manager/runc.go Normal file
View File

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

View File

@@ -8,16 +8,19 @@ import (
"time" "time"
"github.com/bcicen/ctop/connector/collector" "github.com/bcicen/ctop/connector/collector"
"github.com/bcicen/ctop/connector/manager"
"github.com/bcicen/ctop/container" "github.com/bcicen/ctop/container"
"github.com/jgautheron/codename-generator" "github.com/jgautheron/codename-generator"
"github.com/nu7hatch/gouuid" "github.com/nu7hatch/gouuid"
) )
func init() { enabled["mock"] = NewMock }
type Mock struct { type Mock struct {
containers container.Containers containers container.Containers
} }
func NewMock() *Mock { func NewMock() Connector {
cs := &Mock{} cs := &Mock{}
go cs.Init() go cs.Init()
go cs.Loop() go cs.Loop()
@@ -40,7 +43,8 @@ func (cs *Mock) Init() {
func (cs *Mock) makeContainer(aggression int64) { func (cs *Mock) makeContainer(aggression int64) {
collector := collector.NewMock(aggression) collector := collector.NewMock(aggression)
c := container.New(makeID(), collector) manager := manager.NewMock()
c := container.New(makeID(), collector, manager)
c.SetMeta("name", makeName()) c.SetMeta("name", makeName())
c.SetState(makeState()) c.SetState(makeState())
cs.containers = append(cs.containers, c) cs.containers = append(cs.containers, c)

View File

@@ -1,4 +1,4 @@
// +build !darwin // +build linux
package connector package connector
@@ -11,11 +11,14 @@ import (
"time" "time"
"github.com/bcicen/ctop/connector/collector" "github.com/bcicen/ctop/connector/collector"
"github.com/bcicen/ctop/connector/manager"
"github.com/bcicen/ctop/container" "github.com/bcicen/ctop/container"
"github.com/opencontainers/runc/libcontainer" "github.com/opencontainers/runc/libcontainer"
"github.com/opencontainers/runc/libcontainer/cgroups/systemd" "github.com/opencontainers/runc/libcontainer/cgroups/systemd"
) )
func init() { enabled["runc"] = NewRunc }
type RuncOpts struct { type RuncOpts struct {
root string // runc root path root string // runc root path
systemdCgroups bool // use systemd cgroups systemdCgroups bool // use systemd cgroups
@@ -175,7 +178,8 @@ func (cm *Runc) MustGet(id string) *container.Container {
collector := collector.NewRunc(libc) collector := collector.NewRunc(libc)
// create container // create container
c = container.New(id, collector) manager := manager.NewRunc()
c = container.New(id, collector, manager)
name := libc.ID() name := libc.ID()
// set initial metadata // set initial metadata

View File

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

155
container/main.go Normal file
View File

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

View File

@@ -57,11 +57,11 @@ func (gc *GridCursor) RefreshContainers() (lenChanged bool) {
// Set an initial cursor position, if possible // Set an initial cursor position, if possible
func (gc *GridCursor) Reset() { func (gc *GridCursor) Reset() {
for _, c := range gc.cSource.All() { for _, c := range gc.cSource.All() {
c.Widgets.Name.UnHighlight() c.Widgets.UnHighlight()
} }
if gc.Len() > 0 { if gc.Len() > 0 {
gc.selectedID = gc.filtered[0].Id gc.selectedID = gc.filtered[0].Id
gc.filtered[0].Widgets.Name.Highlight() gc.filtered[0].Widgets.Highlight()
} }
} }
@@ -109,9 +109,9 @@ func (gc *GridCursor) Up() {
active := gc.filtered[idx] active := gc.filtered[idx]
next := gc.filtered[idx-1] next := gc.filtered[idx-1]
active.Widgets.Name.UnHighlight() active.Widgets.UnHighlight()
gc.selectedID = next.Id gc.selectedID = next.Id
next.Widgets.Name.Highlight() next.Widgets.Highlight()
gc.ScrollPage() gc.ScrollPage()
ui.Render(cGrid) ui.Render(cGrid)
@@ -128,9 +128,9 @@ func (gc *GridCursor) Down() {
active := gc.filtered[idx] active := gc.filtered[idx]
next := gc.filtered[idx+1] next := gc.filtered[idx+1]
active.Widgets.Name.UnHighlight() active.Widgets.UnHighlight()
gc.selectedID = next.Id gc.selectedID = next.Id
next.Widgets.Name.Highlight() next.Widgets.Highlight()
gc.ScrollPage() gc.ScrollPage()
ui.Render(cGrid) ui.Render(cGrid)
@@ -142,17 +142,18 @@ func (gc *GridCursor) PgUp() {
return return
} }
var nextidx int nextidx := int(math.Max(0.0, float64(idx-cGrid.MaxRows())))
nextidx = int(math.Max(0.0, float64(idx-cGrid.MaxRows()))) if gc.pgCount() > 0 {
cGrid.Offset = int(math.Max(float64(cGrid.Offset-cGrid.MaxRows()), cGrid.Offset = int(math.Max(float64(cGrid.Offset-cGrid.MaxRows()),
float64(0))) float64(0)))
}
active := gc.filtered[idx] active := gc.filtered[idx]
next := gc.filtered[nextidx] next := gc.filtered[nextidx]
active.Widgets.Name.UnHighlight() active.Widgets.UnHighlight()
gc.selectedID = next.Id gc.selectedID = next.Id
next.Widgets.Name.Highlight() next.Widgets.Highlight()
cGrid.Align() cGrid.Align()
ui.Render(cGrid) ui.Render(cGrid)
@@ -164,19 +165,28 @@ func (gc *GridCursor) PgDown() {
return return
} }
var nextidx int nextidx := int(math.Min(float64(gc.Len()-1), float64(idx+cGrid.MaxRows())))
nextidx = int(math.Min(float64(gc.Len()-1), if gc.pgCount() > 0 {
float64(idx+cGrid.MaxRows())))
cGrid.Offset = int(math.Min(float64(cGrid.Offset+cGrid.MaxRows()), cGrid.Offset = int(math.Min(float64(cGrid.Offset+cGrid.MaxRows()),
float64(gc.Len()-cGrid.MaxRows()))) float64(gc.Len()-cGrid.MaxRows())))
}
active := gc.filtered[idx] active := gc.filtered[idx]
next := gc.filtered[nextidx] next := gc.filtered[nextidx]
active.Widgets.Name.UnHighlight() active.Widgets.UnHighlight()
gc.selectedID = next.Id gc.selectedID = next.Id
next.Widgets.Name.Highlight() next.Widgets.Highlight()
cGrid.Align() cGrid.Align()
ui.Render(cGrid) ui.Render(cGrid)
} }
// number of pages at current row count and term height
func (gc *GridCursor) pgCount() int {
pages := gc.Len() / cGrid.MaxRows()
if gc.Len()%cGrid.MaxRows() > 0 {
pages++
}
return pages
}

View File

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

View File

@@ -22,9 +22,11 @@ func NewCompactGrid() *CompactGrid {
func (cg *CompactGrid) Align() { func (cg *CompactGrid) Align() {
y := cg.Y y := cg.Y
if cg.Offset >= len(cg.Rows) {
if cg.Offset >= len(cg.Rows) || cg.Offset < 0 {
cg.Offset = 0 cg.Offset = 0
} }
// update row ypos, width recursively // update row ypos, width recursively
for _, r := range cg.pageRows() { for _, r := range cg.pageRows() {
r.SetY(y) r.SetY(y)
@@ -55,7 +57,5 @@ func (cg *CompactGrid) Buffer() ui.Buffer {
} }
func (cg *CompactGrid) AddRows(rows ...ui.GridBufferer) { func (cg *CompactGrid) AddRows(rows ...ui.GridBufferer) {
for _, r := range rows { cg.Rows = append(cg.Rows, rows...)
cg.Rows = append(cg.Rows, r)
}
} }

View File

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

View File

@@ -19,7 +19,7 @@ func (row *Compact) SetIO(read int64, write int64) {
} }
func (row *Compact) SetPids(val int) { func (row *Compact) SetPids(val int) {
label := fmt.Sprintf("%s", strconv.Itoa(val)) label := strconv.Itoa(val)
row.Pids.Set(label) row.Pids.Set(label)
} }
@@ -37,12 +37,12 @@ func (row *Compact) SetCPU(val int) {
} }
func (row *Compact) SetMem(val int64, limit int64, percent int) { func (row *Compact) SetMem(val int64, limit int64, percent int) {
row.Memory.Label = fmt.Sprintf("%s / %s", cwidgets.ByteFormat(val), cwidgets.ByteFormat(limit)) row.Mem.Label = fmt.Sprintf("%s / %s", cwidgets.ByteFormat(val), cwidgets.ByteFormat(limit))
if percent < 5 { if percent < 5 {
percent = 5 percent = 5
row.Memory.BarColor = ui.ColorBlack row.Mem.BarColor = ui.ColorBlack
} else { } else {
row.Memory.BarColor = ui.ThemeAttr("gauge.bar.bg") row.Mem.BarColor = ui.ThemeAttr("gauge.bar.bg")
} }
row.Memory.Percent = percent row.Mem.Percent = percent
} }

View File

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

View File

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

View File

@@ -4,6 +4,7 @@ package compact
import ( import (
"fmt" "fmt"
ui "github.com/gizak/termui" ui "github.com/gizak/termui"
) )
@@ -28,7 +29,7 @@ func calcWidth(width int) int {
for _, w := range colWidths { for _, w := range colWidths {
width -= w width -= w
if w == 0 { if w == 0 {
staticCols += 1 staticCols++
} }
} }
return (width - spacing) / staticCols return (width - spacing) / staticCols

View File

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

View File

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

36
cwidgets/single/env.go Normal file
View File

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

View File

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

View File

@@ -1,4 +1,4 @@
package expanded package single
import ( import (
"strings" "strings"
@@ -6,7 +6,7 @@ import (
ui "github.com/gizak/termui" ui "github.com/gizak/termui"
) )
var displayInfo = []string{"id", "name", "image", "ports", "state", "created"} var displayInfo = []string{"id", "name", "image", "ports", "IPs", "state", "created", "health"}
type Info struct { type Info struct {
*ui.Table *ui.Table
@@ -45,7 +45,7 @@ func mkInfoRows(k, v string) (rows [][]string) {
// initial row with field name // initial row with field name
rows = append(rows, []string{k, lines[0]}) rows = append(rows, []string{k, lines[0]})
// append any additional lines in seperate row // append any additional lines in separate row
if len(lines) > 1 { if len(lines) > 1 {
for _, line := range lines[1:] { for _, line := range lines[1:] {
if line != "" { if line != "" {

View File

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

83
cwidgets/single/logs.go Normal file
View File

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

View File

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

View File

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

View File

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

View File

@@ -3,11 +3,14 @@ package main
import ( import (
"fmt" "fmt"
"reflect" "reflect"
"runtime"
"github.com/bcicen/ctop/container" "github.com/bcicen/ctop/container"
ui "github.com/gizak/termui" ui "github.com/gizak/termui"
) )
var mstats = &runtime.MemStats{}
func logEvent(e ui.Event) { func logEvent(e ui.Event) {
var s string var s string
s += fmt.Sprintf("Type=%s", quote(e.Type)) s += fmt.Sprintf("Type=%s", quote(e.Type))
@@ -19,6 +22,22 @@ func logEvent(e ui.Event) {
log.Debugf("new event: %s", s) 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 // log container, metrics, and widget state
func dumpContainer(c *container.Container) { func dumpContainer(c *container.Container) {
msg := fmt.Sprintf("logging state for container: %s\n", c.Id) msg := fmt.Sprintf("logging state for container: %s\n", c.Id)

129
glide.lock generated
View File

@@ -1,129 +0,0 @@
hash: 0d550b01b3a1c4751a8f5c3fba0c43f62252055e231712729628e514bb494da8
updated: 2017-06-09T18:11:10.930196504-03:00
imports:
- name: github.com/Azure/go-ansiterm
version: fa152c58bc15761d0200cb75fe958b89a9d4888e
subpackages:
- winterm
- name: github.com/c9s/goprocinfo
version: b34328d6e0cd139894ea7347d2624ccf31fa3c58
subpackages:
- linux
- name: github.com/coreos/go-systemd
version: b4a58d95188dd092ae20072bac14cece0e67c388
subpackages:
- activation
- dbus
- util
- name: github.com/docker/docker
version: 90d35abf7b3535c1c319c872900fbd76374e521c
subpackages:
- api/types
- api/types/blkiodev
- api/types/container
- api/types/filters
- api/types/mount
- api/types/network
- api/types/registry
- api/types/strslice
- api/types/swarm
- api/types/versions
- opts
- pkg/archive
- pkg/fileutils
- pkg/homedir
- pkg/idtools
- pkg/ioutils
- pkg/jsonlog
- pkg/jsonmessage
- pkg/longpath
- pkg/mount
- pkg/pools
- pkg/promise
- pkg/stdcopy
- pkg/symlink
- pkg/system
- pkg/term
- pkg/term/windows
- name: github.com/docker/go-connections
version: a2afab9802043837035592f1c24827fb70766de9
subpackages:
- nat
- name: github.com/docker/go-units
version: 0dadbb0345b35ec7ef35e228dabb8de89a65bf52
- name: github.com/fsouza/go-dockerclient
version: 318513eb1ab27495afbc67f671ba1080513d8aa0
- name: github.com/gizak/termui
version: ea10e6ccee219e572ffad0ac1909f1a17f6db7d6
repo: https://github.com/bcicen/termui
vcs: git
- name: github.com/godbus/dbus
version: c7fdd8b5cd55e87b4e1f4e372cdb1db61dd6c66f
- name: github.com/golang/protobuf
version: f7137ae6b19afbfd61a94b746fda3b3fe0491874
subpackages:
- proto
- name: github.com/hashicorp/go-cleanhttp
version: 3573b8b52aa7b37b9358d966a898feb387f62437
- name: github.com/jgautheron/codename-generator
version: 16d037c7cc3c9b552fe4af9828b7338d752dbaf9
- name: github.com/maruel/panicparse
version: 25bcac0d793cf4109483505a0d66e066a3a90a80
subpackages:
- stack
- name: github.com/mattn/go-runewidth
version: 14207d285c6c197daabb5c9793d63e7af9ab2d50
- name: github.com/Microsoft/go-winio
version: fff283ad5116362ca252298cfc9b95828956d85d
- name: github.com/mitchellh/go-wordwrap
version: ad45545899c7b13c020ea92b2072220eefad42b8
- name: github.com/nsf/termbox-go
version: 91bae1bb5fa9ee504905ecbe7043fa30e92feaa3
- name: github.com/nu7hatch/gouuid
version: 179d4d0c4d8d407a32af483c2354df1d2c91e6c3
- name: github.com/Nvveen/Gotty
version: cd527374f1e5bff4938207604a14f2e38a9cf512
- name: github.com/op/go-logging
version: b2cb9fa56473e98db8caba80237377e83fe44db5
- name: github.com/opencontainers/runc
version: baf6536d6259209c3edfa2b22237af82942d3dfa
subpackages:
- libcontainer
- libcontainer/apparmor
- libcontainer/cgroups
- libcontainer/cgroups/fs
- libcontainer/cgroups/systemd
- libcontainer/configs
- libcontainer/configs/validate
- libcontainer/criurpc
- libcontainer/keys
- libcontainer/label
- libcontainer/seccomp
- libcontainer/selinux
- libcontainer/stacktrace
- libcontainer/system
- libcontainer/user
- libcontainer/utils
- name: github.com/seccomp/libseccomp-golang
version: 1b506fc7c24eec5a3693cdcbed40d9c226cfc6a1
- name: github.com/Sirupsen/logrus
version: 26709e2714106fb8ad40b773b711ebce25b78914
- name: github.com/syndtr/gocapability
version: 2c00daeb6c3b45114c80ac44119e7b8801fdd852
subpackages:
- capability
- name: github.com/vishvananda/netlink
version: 1e2e08e8a2dcdacaae3f14ac44c5cfa31361f270
subpackages:
- nl
- name: golang.org/x/net
version: a6577fac2d73be281a500b310739095313165611
subpackages:
- context
- context/ctxhttp
- name: golang.org/x/sys
version: 99f16d856c9836c42d24e7ab64ea72916925fa97
subpackages:
- unix
- windows
testImports: []

View File

@@ -1,18 +0,0 @@
package: github.com/bcicen/ctop
import:
- package: github.com/c9s/goprocinfo/linux
- package: github.com/docker/docker
version: ^17.5.0-ce-rc3
- package: github.com/opencontainers/runc
version: 0.1.1
- package: github.com/fsouza/go-dockerclient
version: 318513eb1ab27495afbc67f671ba1080513d8aa0
- package: github.com/gizak/termui
version: barchart-numfmt
repo: https://github.com/bcicen/termui
vcs: git
- package: github.com/jgautheron/codename-generator
- package: github.com/nu7hatch/gouuid
- package: github.com/c9s/goprocinfo/linux
- package: github.com/op/go-logging
version: ^1.0.0

41
go.mod Normal file
View File

@@ -0,0 +1,41 @@
module github.com/bcicen/ctop
require (
github.com/Azure/go-ansiterm v0.0.0-20160622173216-fa152c58bc15 // indirect
github.com/BurntSushi/toml v0.3.0
github.com/Microsoft/go-winio v0.3.8 // indirect
github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 // indirect
github.com/Sirupsen/logrus v0.0.0-20150423025312-26709e271410 // indirect
github.com/c9s/goprocinfo v0.0.0-20170609001544-b34328d6e0cd
github.com/coreos/go-systemd v0.0.0-20151104194251-b4a58d95188d // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/docker/docker v0.0.0-20170502054910-90d35abf7b35 // indirect
github.com/docker/go-connections v0.0.0-20170301234100-a2afab980204 // indirect
github.com/docker/go-units v0.3.2 // indirect
github.com/fsouza/go-dockerclient v0.0.0-20170307141636-318513eb1ab2
github.com/gizak/termui v2.3.0+incompatible
github.com/godbus/dbus v0.0.0-20151105175453-c7fdd8b5cd55 // indirect
github.com/golang/protobuf v0.0.0-20170712042213-0a4f71a498b7 // indirect
github.com/hashicorp/go-cleanhttp v0.0.0-20170211013415-3573b8b52aa7 // indirect
github.com/jgautheron/codename-generator v0.0.0-20150829203204-16d037c7cc3c
github.com/kr/pretty v0.1.0 // indirect
github.com/maruel/panicparse v0.0.0-20170227222818-25bcac0d793c // indirect
github.com/maruel/ut v1.0.0 // indirect
github.com/mattn/go-runewidth v0.0.0-20170201023540-14207d285c6c // indirect
github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 // indirect
github.com/nsf/termbox-go v0.0.0-20180303152453-e2050e41c884
github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d
github.com/op/go-logging v0.0.0-20160211212156-b2cb9fa56473
github.com/opencontainers/runc v0.1.1
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/seccomp/libseccomp-golang v0.0.0-20150813023252-1b506fc7c24e // indirect
github.com/stretchr/testify v1.2.2 // indirect
github.com/syndtr/gocapability v0.0.0-20150716010906-2c00daeb6c3b // indirect
github.com/vishvananda/netlink v0.0.0-20150820014904-1e2e08e8a2dc // indirect
github.com/vishvananda/netns v0.0.0-20180720170159-13995c7128cc // indirect
golang.org/x/net v0.0.0-20170308210134-a6577fac2d73 // indirect
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f // indirect
golang.org/x/sys v0.0.0-20170308153327-99f16d856c98 // indirect
)
replace github.com/gizak/termui => github.com/bcicen/termui v0.0.0-20180326052246-4eb80249d3f5

72
grid.go
View File

@@ -2,8 +2,7 @@ package main
import ( import (
"github.com/bcicen/ctop/config" "github.com/bcicen/ctop/config"
"github.com/bcicen/ctop/container" "github.com/bcicen/ctop/cwidgets/single"
"github.com/bcicen/ctop/cwidgets/expanded"
ui "github.com/gizak/termui" ui "github.com/gizak/termui"
) )
@@ -18,6 +17,7 @@ func RedrawRows(clr bool) {
header.SetFilter(config.GetVal("filterStr")) header.SetFilter(config.GetVal("filterStr"))
y += header.Height() y += header.Height()
} }
cGrid.SetY(y) cGrid.SetY(y)
for _, c := range cursor.filtered { for _, c := range cursor.filtered {
@@ -33,14 +33,20 @@ func RedrawRows(clr bool) {
} }
cGrid.Align() cGrid.Align()
ui.Render(cGrid) ui.Render(cGrid)
} }
func ExpandView(c *container.Container) { func SingleView() MenuFn {
c := cursor.Selected()
if c == nil {
return nil
}
ui.Clear() ui.Clear()
ui.DefaultEvtStream.ResetHandlers() ui.DefaultEvtStream.ResetHandlers()
defer ui.DefaultEvtStream.ResetHandlers() defer ui.DefaultEvtStream.ResetHandlers()
ex := expanded.NewExpanded(c.Id) ex := single.NewSingle(c.Id)
c.SetUpdater(ex) c.SetUpdater(ex)
ex.Align() ex.Align()
@@ -59,6 +65,7 @@ func ExpandView(c *container.Container) {
ui.Loop() ui.Loop()
c.SetUpdater(c.Widgets) c.SetUpdater(c.Widgets)
return nil
} }
func RefreshDisplay() { func RefreshDisplay() {
@@ -70,14 +77,14 @@ func RefreshDisplay() {
} }
func Display() bool { func Display() bool {
var menu func() var menu MenuFn
var expand bool
cGrid.SetWidth(ui.TermWidth()) cGrid.SetWidth(ui.TermWidth())
ui.DefaultEvtStream.Hook(logEvent) ui.DefaultEvtStream.Hook(logEvent)
// initial draw // initial draw
header.Align() header.Align()
status.Align()
cursor.RefreshContainers() cursor.RefreshContainers()
RedrawRows(true) RedrawRows(true)
@@ -94,7 +101,23 @@ func Display() bool {
}) })
ui.Handle("/sys/kbd/<enter>", func(ui.Event) { ui.Handle("/sys/kbd/<enter>", func(ui.Event) {
expand = true menu = ContainerMenu
ui.StopLoop()
})
ui.Handle("/sys/kbd/<left>", func(ui.Event) {
menu = LogMenu
ui.StopLoop()
})
ui.Handle("/sys/kbd/<right>", func(ui.Event) {
menu = SingleView
ui.StopLoop()
})
ui.Handle("/sys/kbd/l", func(ui.Event) {
menu = LogMenu
ui.StopLoop()
})
ui.Handle("/sys/kbd/o", func(ui.Event) {
menu = SingleView
ui.StopLoop() ui.StopLoop()
}) })
ui.Handle("/sys/kbd/a", func(ui.Event) { ui.Handle("/sys/kbd/a", func(ui.Event) {
@@ -119,13 +142,26 @@ func Display() bool {
menu = SortMenu menu = SortMenu
ui.StopLoop() 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) { ui.Handle("/timer/1s", func(e ui.Event) {
if log.StatusQueued() {
ui.StopLoop()
}
RefreshDisplay() RefreshDisplay()
}) })
ui.Handle("/sys/wnd/resize", func(e ui.Event) { ui.Handle("/sys/wnd/resize", func(e ui.Event) {
header.Align() header.Align()
status.Align()
cursor.ScrollPage() cursor.ScrollPage()
cGrid.SetWidth(ui.TermWidth()) cGrid.SetWidth(ui.TermWidth())
log.Infof("resize: width=%v max-rows=%v", cGrid.Width, cGrid.MaxRows()) log.Infof("resize: width=%v max-rows=%v", cGrid.Width, cGrid.MaxRows())
@@ -133,16 +169,24 @@ func Display() bool {
}) })
ui.Loop() ui.Loop()
if log.StatusQueued() {
for sm := range log.FlushStatus() {
if sm.IsError {
status.ShowErr(sm.Text)
} else {
status.Show(sm.Text)
}
}
return false
}
if menu != nil { if menu != nil {
menu() for menu != nil {
return false menu = menu()
}
if expand {
c := cursor.Selected()
if c != nil {
ExpandView(c)
} }
return false return false
} }
return true return true
} }

84
install.sh Executable file
View File

@@ -0,0 +1,84 @@
#!/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!"

View File

@@ -1,6 +1,7 @@
package logging package logging
import ( import (
"fmt"
"os" "os"
"time" "time"
@@ -20,11 +21,36 @@ var (
) )
) )
type statusMsg struct {
Text string
IsError bool
}
type CTopLogger struct { type CTopLogger struct {
*logging.Logger *logging.Logger
backend *logging.MemoryBackend backend *logging.MemoryBackend
sLog []statusMsg
} }
func (c *CTopLogger) FlushStatus() chan statusMsg {
ch := make(chan statusMsg)
go func() {
for _, sm := range c.sLog {
ch <- sm
}
close(ch)
c.sLog = []statusMsg{}
}()
return ch
}
func (c *CTopLogger) StatusQueued() bool { return len(c.sLog) > 0 }
func (c *CTopLogger) Status(s string) { c.addStatus(statusMsg{s, false}) }
func (c *CTopLogger) StatusErr(err error) { c.addStatus(statusMsg{err.Error(), true}) }
func (c *CTopLogger) addStatus(sm statusMsg) { c.sLog = append(c.sLog, sm) }
func (c *CTopLogger) Statusf(s string, a ...interface{}) { c.Status(fmt.Sprintf(s, a...)) }
func Init() *CTopLogger { func Init() *CTopLogger {
if Log == nil { if Log == nil {
logging.SetFormatter(format) // setup default formatter logging.SetFormatter(format) // setup default formatter
@@ -32,6 +58,7 @@ func Init() *CTopLogger {
Log = &CTopLogger{ Log = &CTopLogger{
logging.MustGetLogger("ctop"), logging.MustGetLogger("ctop"),
logging.NewMemoryBackend(size), logging.NewMemoryBackend(size),
[]statusMsg{},
} }
if debugMode() { if debugMode() {

View File

@@ -2,6 +2,7 @@ package logging
import ( import (
"fmt" "fmt"
"io"
"net" "net"
"sync" "sync"
) )
@@ -56,13 +57,13 @@ func StopServer() {
} }
} }
func handler(conn net.Conn) { func handler(wc io.WriteCloser) {
server.wg.Add(1) server.wg.Add(1)
defer server.wg.Done() defer server.wg.Done()
defer conn.Close() defer wc.Close()
for msg := range Log.tail() { for msg := range Log.tail() {
msg = fmt.Sprintf("%s\n", msg) msg = fmt.Sprintf("%s\n", msg)
conn.Write([]byte(msg)) wc.Write([]byte(msg))
} }
conn.Write([]byte("bye\n")) wc.Write([]byte("bye\n"))
} }

38
main.go
View File

@@ -4,6 +4,8 @@ import (
"flag" "flag"
"fmt" "fmt"
"os" "os"
"runtime"
"strings"
"github.com/bcicen/ctop/config" "github.com/bcicen/ctop/config"
"github.com/bcicen/ctop/connector" "github.com/bcicen/ctop/connector"
@@ -18,27 +20,32 @@ import (
var ( var (
build = "none" build = "none"
version = "dev-build" version = "dev-build"
goVersion = runtime.Version()
log *logging.CTopLogger log *logging.CTopLogger
cursor *GridCursor cursor *GridCursor
cGrid *compact.CompactGrid cGrid *compact.CompactGrid
header *widgets.CTopHeader header *widgets.CTopHeader
status *widgets.StatusLine
versionStr = fmt.Sprintf("ctop version %v, build %v", version, build) versionStr = fmt.Sprintf("ctop version %v, build %v %v", version, build, goVersion)
) )
func main() { func main() {
defer panicExit() defer panicExit()
// parse command line arguments // parse command line arguments
var versionFlag = flag.Bool("v", false, "output version information and exit") var (
var helpFlag = flag.Bool("h", false, "display this help dialog") versionFlag = flag.Bool("v", false, "output version information and exit")
var filterFlag = flag.String("f", "", "filter containers") helpFlag = flag.Bool("h", false, "display this help dialog")
var activeOnlyFlag = flag.Bool("a", false, "show active containers only") filterFlag = flag.String("f", "", "filter containers")
var sortFieldFlag = flag.String("s", "", "select container sort field") activeOnlyFlag = flag.Bool("a", false, "show active containers only")
var reverseSortFlag = flag.Bool("r", false, "reverse container sort order") sortFieldFlag = flag.String("s", "", "select container sort field")
var invertFlag = flag.Bool("i", false, "invert default colors") reverseSortFlag = flag.Bool("r", false, "reverse container sort order")
var connectorFlag = flag.String("connector", "docker", "container connector to use") invertFlag = flag.Bool("i", false, "invert default colors")
scaleCpu = flag.Bool("scale-cpu", false, "show cpu as % of system total")
connectorFlag = flag.String("connector", "docker", "container connector to use")
)
flag.Parse() flag.Parse()
if *versionFlag { if *versionFlag {
@@ -54,8 +61,9 @@ func main() {
// init logger // init logger
log = logging.Init() log = logging.Init()
// init global config // init global config and read config file if exists
config.Init() config.Init()
config.Read()
// override default config values with command line flags // override default config values with command line flags
if *filterFlag != "" { if *filterFlag != "" {
@@ -75,6 +83,10 @@ func main() {
config.Toggle("sortReversed") config.Toggle("sortReversed")
} }
if *scaleCpu {
config.Toggle("scaleCpu")
}
// init ui // init ui
if *invertFlag { if *invertFlag {
InvertColorMap() InvertColorMap()
@@ -83,6 +95,7 @@ func main() {
if err := ui.Init(); err != nil { if err := ui.Init(); err != nil {
panic(err) panic(err)
} }
tm.SetInputMode(tm.InputAlt)
defer Shutdown() defer Shutdown()
// init grid, cursor, header // init grid, cursor, header
@@ -93,6 +106,7 @@ func main() {
cursor = &GridCursor{cSource: conn} cursor = &GridCursor{cSource: conn}
cGrid = compact.NewCompactGrid() cGrid = compact.NewCompactGrid()
header = widgets.NewCTopHeader() header = widgets.NewCTopHeader()
status = widgets.NewStatusLine()
for { for {
exit := Display() exit := Display()
@@ -126,7 +140,7 @@ func panicExit() {
} }
} }
var helpMsg = `ctop - container metric viewer var helpMsg = `ctop - interactive container viewer
usage: ctop [options] usage: ctop [options]
@@ -136,4 +150,6 @@ options:
func printHelp() { func printHelp() {
fmt.Println(helpMsg) fmt.Println(helpMsg)
flag.PrintDefaults() flag.PrintDefaults()
fmt.Printf("\navailable connectors: ")
fmt.Println(strings.Join(connector.Enabled(), ", "))
} }

224
menus.go
View File

@@ -1,6 +1,9 @@
package main package main
import ( import (
"fmt"
"time"
"github.com/bcicen/ctop/config" "github.com/bcicen/ctop/config"
"github.com/bcicen/ctop/container" "github.com/bcicen/ctop/container"
"github.com/bcicen/ctop/widgets" "github.com/bcicen/ctop/widgets"
@@ -8,17 +11,25 @@ import (
ui "github.com/gizak/termui" ui "github.com/gizak/termui"
) )
// MenuFn executes a menu window, returning the next menu or nil
type MenuFn func() MenuFn
var helpDialog = []menu.Item{ var helpDialog = []menu.Item{
menu.Item{"[a] - toggle display of all containers", ""}, {"<enter> - open container menu", ""},
menu.Item{"[f] - filter displayed containers", ""}, {"", ""},
menu.Item{"[h] - open this help dialog", ""}, {"[a] - toggle display of all containers", ""},
menu.Item{"[H] - toggle ctop header", ""}, {"[f] - filter displayed containers", ""},
menu.Item{"[s] - select container sort field", ""}, {"[h] - open this help dialog", ""},
menu.Item{"[r] - reverse container sort order", ""}, {"[H] - toggle ctop header", ""},
menu.Item{"[q] - exit ctop", ""}, {"[s] - select container sort field", ""},
{"[r] - reverse container sort order", ""},
{"[o] - open single view", ""},
{"[l] - view container logs ([t] to toggle timestamp when open)", ""},
{"[S] - save current configuration to file", ""},
{"[q] - exit ctop", ""},
} }
func HelpMenu() { func HelpMenu() MenuFn {
ui.Clear() ui.Clear()
ui.DefaultEvtStream.ResetHandlers() ui.DefaultEvtStream.ResetHandlers()
defer ui.DefaultEvtStream.ResetHandlers() defer ui.DefaultEvtStream.ResetHandlers()
@@ -26,14 +37,18 @@ func HelpMenu() {
m := menu.NewMenu() m := menu.NewMenu()
m.BorderLabel = "Help" m.BorderLabel = "Help"
m.AddItems(helpDialog...) m.AddItems(helpDialog...)
ui.Handle("/sys/wnd/resize", func(e ui.Event) {
ui.Clear()
ui.Render(m) ui.Render(m)
})
ui.Handle("/sys/kbd/", func(ui.Event) { ui.Handle("/sys/kbd/", func(ui.Event) {
ui.StopLoop() ui.StopLoop()
}) })
ui.Loop() ui.Loop()
return nil
} }
func FilterMenu() { func FilterMenu() MenuFn {
ui.DefaultEvtStream.ResetHandlers() ui.DefaultEvtStream.ResetHandlers()
defer ui.DefaultEvtStream.ResetHandlers() defer ui.DefaultEvtStream.ResetHandlers()
@@ -63,9 +78,10 @@ func FilterMenu() {
ui.StopLoop() ui.StopLoop()
}) })
ui.Loop() ui.Loop()
return nil
} }
func SortMenu() { func SortMenu() MenuFn {
ui.Clear() ui.Clear()
ui.DefaultEvtStream.ResetHandlers() ui.DefaultEvtStream.ResetHandlers()
defer ui.DefaultEvtStream.ResetHandlers() defer ui.DefaultEvtStream.ResetHandlers()
@@ -93,4 +109,192 @@ func SortMenu() {
ui.Render(m) ui.Render(m)
ui.Loop() ui.Loop()
return nil
} }
func ContainerMenu() MenuFn {
c := cursor.Selected()
if c == nil {
return nil
}
ui.DefaultEvtStream.ResetHandlers()
defer ui.DefaultEvtStream.ResetHandlers()
m := menu.NewMenu()
m.Selectable = true
m.BorderLabel = "Menu"
items := []menu.Item{
menu.Item{Val: "single", Label: "single view"},
menu.Item{Val: "logs", Label: "log view"},
}
if c.Meta["state"] == "running" {
items = append(items, menu.Item{Val: "stop", Label: "stop"})
items = append(items, menu.Item{Val: "pause", Label: "pause"})
items = append(items, menu.Item{Val: "restart", Label: "restart"})
}
if c.Meta["state"] == "exited" || c.Meta["state"] == "created" {
items = append(items, menu.Item{Val: "start", Label: "start"})
items = append(items, menu.Item{Val: "remove", Label: "remove"})
}
if c.Meta["state"] == "paused" {
items = append(items, menu.Item{Val: "unpause", Label: "unpause"})
}
items = append(items, menu.Item{Val: "cancel", Label: "cancel"})
m.AddItems(items...)
ui.Render(m)
var nextMenu MenuFn
HandleKeys("up", m.Up)
HandleKeys("down", m.Down)
ui.Handle("/sys/kbd/<enter>", func(ui.Event) {
switch m.SelectedItem().Val {
case "single":
nextMenu = SingleView
case "logs":
nextMenu = LogMenu
case "start":
nextMenu = Confirm(confirmTxt("start", c.GetMeta("name")), c.Start)
case "stop":
nextMenu = Confirm(confirmTxt("stop", c.GetMeta("name")), c.Stop)
case "remove":
nextMenu = Confirm(confirmTxt("remove", c.GetMeta("name")), c.Remove)
case "pause":
nextMenu = Confirm(confirmTxt("pause", c.GetMeta("name")), c.Pause)
case "unpause":
nextMenu = Confirm(confirmTxt("unpause", c.GetMeta("name")), c.Unpause)
case "restart":
nextMenu = Confirm(confirmTxt("restart", c.GetMeta("name")), c.Restart)
}
ui.StopLoop()
})
ui.Handle("/sys/kbd/", func(ui.Event) {
ui.StopLoop()
})
ui.Loop()
return nextMenu
}
func LogMenu() MenuFn {
c := cursor.Selected()
if c == nil {
return nil
}
ui.DefaultEvtStream.ResetHandlers()
defer ui.DefaultEvtStream.ResetHandlers()
logs, quit := logReader(c)
m := widgets.NewTextView(logs)
m.BorderLabel = fmt.Sprintf("Logs [%s]", c.GetMeta("name"))
ui.Render(m)
ui.Handle("/sys/wnd/resize", func(e ui.Event) {
m.Resize()
})
ui.Handle("/sys/kbd/t", func(ui.Event) {
m.Toggle()
})
ui.Handle("/sys/kbd/", func(ui.Event) {
quit <- true
ui.StopLoop()
})
ui.Loop()
return nil
}
// Create a confirmation dialog with a given description string and
// func to perform if confirmed
func Confirm(txt string, fn func()) MenuFn {
menu := func() MenuFn {
ui.DefaultEvtStream.ResetHandlers()
defer ui.DefaultEvtStream.ResetHandlers()
m := menu.NewMenu()
m.Selectable = true
m.BorderLabel = "Confirm"
m.SubText = txt
items := []menu.Item{
menu.Item{Val: "cancel", Label: "[c]ancel"},
menu.Item{Val: "yes", Label: "[y]es"},
}
var response bool
m.AddItems(items...)
ui.Render(m)
yes := func() {
response = true
ui.StopLoop()
}
no := func() {
response = false
ui.StopLoop()
}
HandleKeys("up", m.Up)
HandleKeys("down", m.Down)
HandleKeys("exit", no)
ui.Handle("/sys/kbd/c", func(ui.Event) { no() })
ui.Handle("/sys/kbd/y", func(ui.Event) { yes() })
ui.Handle("/sys/kbd/<enter>", func(ui.Event) {
switch m.SelectedItem().Val {
case "cancel":
no()
case "yes":
yes()
}
})
ui.Loop()
if response {
fn()
}
return nil
}
return menu
}
type toggleLog struct {
timestamp time.Time
message string
}
func (t *toggleLog) Toggle(on bool) string {
if on {
return fmt.Sprintf("%s %s", t.timestamp.Format("2006-01-02T15:04:05.999Z07:00"), t.message)
}
return t.message
}
func logReader(container *container.Container) (logs chan widgets.ToggleText, quit chan bool) {
logCollector := container.Logs()
stream := logCollector.Stream()
logs = make(chan widgets.ToggleText)
quit = make(chan bool)
go func() {
for {
select {
case log := <-stream:
logs <- &toggleLog{timestamp: log.Timestamp, message: log.Message}
case <-quit:
logCollector.Stop()
close(logs)
return
}
}
}()
return
}
func confirmTxt(a, n string) string { return fmt.Sprintf("%s container %s?", a, n) }

View File

@@ -1,4 +1,11 @@
package metrics package models
import "time"
type Log struct {
Timestamp time.Time
Message string
}
type Metrics struct { type Metrics struct {
CPUUtil int CPUUtil int
@@ -24,10 +31,3 @@ func NewMetrics() Metrics {
Pids: -1, Pids: -1,
} }
} }
type Collector interface {
Stream() chan Metrics
Running() bool
Start()
Stop()
}

View File

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

View File

@@ -77,7 +77,7 @@ func (i *Input) KeyPress(e ui.Event) {
if len(i.Data) >= i.MaxLen { if len(i.Data) >= i.MaxLen {
return return
} }
if strings.Index(input_chars, ch) > -1 { if strings.Contains(input_chars, ch) {
i.Data += ch i.Data += ch
i.stream <- i.Data i.stream <- i.Data
ui.Render(i) ui.Render(i)

View File

@@ -11,6 +11,7 @@ type Padding [2]int // x,y padding
type Menu struct { type Menu struct {
ui.Block ui.Block
SortItems bool // enable automatic sorting of menu items SortItems bool // enable automatic sorting of menu items
SubText string // optional text to display before items
TextFgColor ui.Attribute TextFgColor ui.Attribute
TextBgColor ui.Attribute TextBgColor ui.Attribute
Selectable bool Selectable bool
@@ -82,9 +83,19 @@ func (m *Menu) Buffer() ui.Buffer {
var cell ui.Cell var cell ui.Cell
buf := m.Block.Buffer() 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 { for n, item := range m.items {
x := m.X + m.padding[0] x := m.X + m.padding[0]
y := m.Y + m.padding[1]
for _, ch := range item.Text() { for _, ch := range item.Text() {
// invert bg/fg colors on currently selected row // invert bg/fg colors on currently selected row
if m.Selectable && n == m.cursorPos { if m.Selectable && n == m.cursorPos {
@@ -118,14 +129,22 @@ func (m *Menu) Down() {
func (m *Menu) calcSize() { func (m *Menu) calcSize() {
m.Width = 7 // minimum width m.Width = 7 // minimum width
items := m.items var height int
for _, i := range m.items { for _, i := range m.items {
s := i.Text() s := i.Text()
if len(s) > m.Width { if len(s) > m.Width {
m.Width = len(s) 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.Width += (m.padding[0] * 2)
m.Height = len(items) + (m.padding[1] * 2) m.Height = height + (m.padding[1] * 2)
} }

87
widgets/status.go Normal file
View File

@@ -0,0 +1,87 @@
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
}

124
widgets/view.go Normal file
View File

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

35
widgets/view_test.go Normal file
View File

@@ -0,0 +1,35 @@
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))
}
}