Compare commits

...

192 Commits
v0.4 ... v0.7.1

Author SHA1 Message Date
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
Bradley Cicenas
617b1b2863 omit runc connector from darwin build 2017-06-14 10:11:40 -03:00
bradley
e59a360b60 Create connectors.md 2017-06-14 09:32:48 -03:00
bradley
91cd53a878 change option header sizing 2017-06-14 09:32:26 -03:00
Bradley Cicenas
c6f2c7b617 add badges back 2017-06-14 09:30:03 -03:00
Bradley Cicenas
568bfb2513 v0.6 doc updates 2017-06-14 09:17:44 -03:00
Bradley Cicenas
66ff8ad7ec update potentially conflicting variable name 2017-06-13 17:35:00 -03:00
Bradley Cicenas
400d9471b6 add pidcount,io to mock collector 2017-06-13 17:25:58 -03:00
Bradley Cicenas
288380ca8d remove arbitrary branch deploy commands 2017-06-12 11:25:02 -03:00
Bradley Cicenas
429d5b9101 v0.6.0 2017-06-12 11:16:03 -03:00
Bradley Cicenas
1be452d7c0 refactor collectors into subpackage 2017-06-12 11:12:03 -03:00
Bradley Cicenas
671c944272 disable timed display refresh while scrolling 2017-06-12 10:52:45 -03:00
Bradley Cicenas
a48a9031cc move container sort to struct method 2017-06-12 10:40:52 -03:00
Bradley Cicenas
aff6943d07 add runc connector doc 2017-06-10 12:25:18 -03:00
Bradley Cicenas
f56ff96b88 Revert "remove build-time container in make image"
This reverts commit b49e174483.
2017-06-10 12:15:42 -03:00
Bradley Cicenas
b5361c2a28 panic on missing runc root 2017-06-10 11:44:12 -03:00
Bradley Cicenas
e71b6cacce prevent unlock until container sort complete 2017-06-10 11:09:21 -03:00
Bradley Cicenas
8a1297d3c5 add created meta field to expanded view 2017-06-10 10:46:54 -03:00
Bradley Cicenas
bdea7d5853 remove containers from connector map on destroyed state 2017-06-10 10:46:16 -03:00
Bradley Cicenas
389dee0f3c add percent helper method to metrics 2017-06-10 10:00:54 -03:00
Bradley Cicenas
b49e174483 remove build-time container in make image 2017-06-10 09:37:36 -03:00
Bradley Cicenas
53b612ab07 add additional logging messages 2017-06-10 09:36:34 -03:00
Bradley Cicenas
446708e456 add default runc root path 2017-06-09 18:35:28 -03:00
Bradley Cicenas
1233ff0ead add arbitrary branch image pushes to circle ci 2017-06-09 18:18:48 -03:00
Bradley Cicenas
af3f1e2a85 update glide deps 2017-06-09 18:11:59 -03:00
Bradley Cicenas
4dbc5653ff update build ldflags to permit multiple runc dep versions 2017-06-09 15:18:11 -03:00
Bradley Cicenas
e8d9f3327c runc connector optimizations 2017-06-09 14:56:39 -03:00
Bradley Cicenas
d372043a17 add --connector switch, validation 2017-06-09 14:35:29 -03:00
Bradley Cicenas
eeac65da8c add sys proc methods to metrics 2017-06-09 14:15:12 -03:00
Bradley Cicenas
c1780ae30a add byte formatting for tb 2017-06-09 14:14:27 -03:00
Bradley Cicenas
fb39d69fa7 add runc metric collector 2017-06-09 13:07:25 -03:00
Bradley Cicenas
6392d63ff8 prevent panic messages from being hidden due to ui.Init() race condition 2017-06-08 17:19:34 -03:00
Bradley Cicenas
b009a260a4 initial runc connector implementation 2017-06-08 15:33:34 -03:00
Bradley Cicenas
44379cd9fd rename connectors 2017-06-08 12:01:08 -03:00
Bradley Cicenas
b85ca680f0 restructure container,connectors in subpackage 2017-06-08 11:51:02 -03:00
Bradley Cicenas
8fb7a8988f include <escape> in exit keygroup 2017-06-08 09:47:30 -03:00
Bradley Cicenas
6d097c2085 add quickstart section to debug doc 2017-06-08 09:33:07 -03:00
Bradley Cicenas
f9d68d688d add tcp logging section to debug doc 2017-05-31 10:55:31 -04:00
Bradley Cicenas
bc08b85191 add option to log debug messages to unix or tcp socket 2017-05-31 10:45:48 -04:00
Bradley Cicenas
f3d26e038d use current directory as default logging socket path 2017-05-31 10:21:32 -04:00
Bradley Cicenas
b4e1fbf290 add release, homebrew badges to README 2017-05-28 17:03:19 -04:00
Bradley Cicenas
58d5fba945 update port formatting for multi-line display 2017-05-15 11:54:35 +01:00
Bradley Cicenas
c76036a6f2 allow multi-line metadata in expanded view 2017-05-15 11:37:27 +01:00
bradley
6a8848d1e2 Merge pull request #65 from kenan-rhoton/ports
Add Ports information to the expanded view
2017-05-15 11:35:20 +01:00
Kenan Rhoton
02d1050130 Revert "Added Ports information to the expanded view"
This reverts commit b2165b6a29.
2017-05-15 06:53:15 +02:00
Kenan Rhoton
ccb44c964c Moved port Info to be fetched from the standard inspect call to avoid superfluous calls. Also moved the information to the info section instead of a whole new section in the expanded view 2017-05-15 06:52:38 +02:00
Kenan Rhoton
b2165b6a29 Added Ports information to the expanded view 2017-05-11 07:38:12 +02:00
bradley
d81d10ec27 Merge pull request #69 from vielmetti/patch-1
WORK IN PROGRESS Add arm64 build
2017-04-10 10:15:51 +08:00
Edward Vielmetti
9529c04680 Update Makefile
Foolish typo; several lines changed, to make future additions more obvious.
2017-04-07 17:43:18 -04:00
Edward Vielmetti
6a89c9af38 WORK IN PROGRESS Add arm64 build
This change would close #68 and would add an arm64 (ARMv8) build.
2017-04-07 02:10:37 -04:00
Bradley Cicenas
06a29fc912 fix circleci build
circleci apparently does not allow containers/volumes to be removed
2017-04-06 20:58:54 +08:00
Bradley Cicenas
2cba7253fc use create container in makefile 2017-04-03 20:51:05 +08:00
Bradley Cicenas
47d60fe51b gofmt main package 2017-03-28 13:57:30 +10:00
bradley
28f16c9a17 Merge pull request #62 from yashpatel5400/yash
Added page up/down features
2017-03-27 10:09:44 +10:00
yashpatel5400
6560768e08 Added page up/down features 2017-03-23 22:31:08 -04:00
Bradley Cicenas
084c0c4ec8 add global Shutdown() method for exit cleanup 2017-03-23 16:48:25 +10:00
Bradley Cicenas
5db90f31dc v0.5.1 2017-03-21 10:35:24 +10:00
Bradley Cicenas
82677d52ef add build section to docs 2017-03-20 08:39:57 +10:00
Bradley Cicenas
2b2338805b update circleci to build image from source 2017-03-19 16:14:56 +10:00
Bradley Cicenas
60213f1551 add debug section to docs 2017-03-19 15:10:03 +10:00
Bradley Cicenas
8aa932b29f Toggle debug mode via env var
remove logging param from global config, allowing logging server and
level to be configured inside logging subpackage from CTOP_DEBUG env var
2017-03-19 15:10:03 +10:00
Bradley Cicenas
35cc8d095d include Makefile instructions for building image from source 2017-03-19 15:10:03 +10:00
bradley
30530bc2a1 Merge pull request #52 from InTheCloudDan/patch-1
change glide to github repo, url is expired.
2017-03-19 10:50:33 +10:00
Dan O'Brien
2c282923c0 change glide to github repo, url is expired. 2017-03-18 20:39:53 -04:00
bradley
d0d39749de Merge pull request #47 from thomasleveil/patch-1
README: optimize install instructions
2017-03-18 10:47:19 +10:00
Bradley Cicenas
26b88a9790 add Makefile 2017-03-18 10:38:03 +10:00
bradley
a135a67c06 Merge pull request #49 from firecat53/patch-2
Add minimal Docker image build instructions
2017-03-17 16:57:29 +10:00
Scott Hansen
19b212f45d Add minimal Docker image build instructions
Update README to include instructions for building from source a minimal Docker image with only ctop.
2017-03-16 11:53:45 -07:00
Thomas LÉVEIL
34987df010 README: optimize install instructions 2017-03-15 20:22:08 +01:00
bradley
e2bc4d0a08 fix newline 2017-03-15 22:54:23 +10:00
bradley
4ac1348fbb Merge pull request #43 from mieciu/patch-1
Update README.md
2017-03-15 20:21:38 +10:00
mieciu
66d78a7d74 Update README.md 2017-03-15 11:14:00 +01:00
Bradley Cicenas
e62a8881a2 add brew install steps 2017-03-15 19:45:41 +10:00
Bradley Cicenas
a5b2e7b074 update aur link 2017-03-15 12:49:42 +10:00
Bradley Cicenas
a87bdce0fe update keybindings in readme 2017-03-15 12:00:40 +10:00
Bradley Cicenas
2228188ebf v0.5 2017-03-15 10:08:26 +10:00
Bradley Cicenas
e94a9c0cc2 remove redundant bool comparisons 2017-03-15 10:06:52 +10:00
Bradley Cicenas
e82d77ecb0 add option for color inversion 2017-03-15 10:02:46 +10:00
Bradley Cicenas
50b4181866 update circle config 2017-03-15 09:16:04 +10:00
Bradley Cicenas
1285288b9e add validation to sort field option 2017-03-15 08:49:11 +10:00
Bradley Cicenas
2a709577bd add options to readme 2017-03-15 08:41:45 +10:00
Bradley Cicenas
38599bbd19 add keymap, handle wrapper for common keybindings 2017-03-15 08:34:58 +10:00
Bradley Cicenas
b3cdb33efc add explicit version to Dockerfile, circleci 2017-03-15 08:34:58 +10:00
bradley
0ac70c96eb Merge pull request #37 from drAlberT/patch-1
Improve suggested "docker run" cmd
2017-03-13 20:36:42 +11:00
Emiliano 'AlberT' Gabrielli
36a5bbdfe1 Improve suggested "docker run" cmd
- make it use a given name "ctop"
- make it feasible for an `alias`
2017-03-13 09:31:15 +01:00
Bradley Cicenas
3553b0af9d cap cpu gauge % to 100 in compact view 2017-03-13 09:00:17 +11:00
Bradley Cicenas
ca61ec712e prepopulate filter input with current filter, add esc handler 2017-03-13 08:32:33 +11:00
Bradley Cicenas
06c4b24212 add 0..9 to valid input chars 2017-03-13 08:31:51 +11:00
Bradley Cicenas
12fa716825 add y pos scrolling to expanded view 2017-03-13 07:53:17 +11:00
Bradley Cicenas
8327406069 add sort by pid count 2017-03-12 21:11:19 +11:00
Bradley Cicenas
2134110224 add static width for specific columns 2017-03-12 20:58:56 +11:00
Bradley Cicenas
77c3d00e67 update io labels 2017-03-12 16:31:12 +11:00
Bradley Cicenas
85eb5228ae append build steps 2017-03-12 15:00:52 +11:00
bradley
3a3950e395 Merge pull request #36 from kevinschoon/vendoring
vendor dependencies with glide
2017-03-12 14:49:13 +11:00
Kevin Schoon
eaac079b15 vendor dependencies with glide 2017-03-12 10:15:21 +07:00
bradley
ab1ccb3cd8 Merge pull request #35 from f1yegor/feature/more-stats
add pids, IO stat
2017-03-12 14:07:34 +11:00
f1yegor
dbaebe0192 add pids, IO stat 2017-03-12 02:35:40 +01:00
bradley
d5ef818c8d Merge pull request #31 from scriptnull/master
adds more commandline arguments
2017-03-11 15:01:33 +11:00
Vishnu Bharathi
8203d0b883 adds more commandline arguments 2017-03-11 00:09:06 +05:30
Bradley Cicenas
b28beed3ee v0.4.1 2017-03-10 20:20:00 +11:00
Bradley Cicenas
2e51406d00 add lock to container map in dockersource 2017-03-10 20:10:39 +11:00
Bradley Cicenas
c84b52ce40 add docker image usage to readme 2017-03-10 20:00:00 +11:00
Bradley Cicenas
4ee8cf621a use latest parsed version in dockerfile 2017-03-10 19:51:31 +11:00
Bradley Cicenas
192298c045 add circleci config for docker build 2017-03-10 19:45:04 +11:00
Bradley Cicenas
258536740d build docker image from alpine 2017-03-10 19:37:46 +11:00
bradley
ef69744249 Merge pull request #23 from firecat53/patch-1
Update Dockerfile
2017-03-10 19:19:20 +11:00
Scott Hansen
07f95a04b0 Update Dockerfile
It may not be as easy to read, but combining the three RUN statements together saves you some substantial megabytes:

    ctop_deb_orig        latest              149596353f88        4 seconds ago       187 MB
    ctop_deb_new        latest              d01f954b3adc        2 minutes ago       139 MB

This is because each RUN statement is creating a whole new layer in the image, so you're actually hanging on to all the stuff you were trying to get rid of!

Scott
2017-03-09 20:38:01 -08:00
Bradley Cicenas
b2184bbc6d fixes out of bounds error on filtered selection #7 2017-03-10 12:01:13 +11:00
Bradley Cicenas
96b01eb3b9 add support for TLS via NewClientFromEnv(), remove dockerHost config param 2017-03-10 12:01:13 +11:00
bradley
03d4869361 Merge pull request #14 from francislavoie/master
Add Dockerfile
2017-03-10 11:57:15 +11:00
bradley
4b7257908f Merge pull request #15 from huguesalary/master
Fixing a typo
2017-03-10 11:55:49 +11:00
Hugues Alary
1875013a76 Fixing a typo 2017-03-09 16:16:40 -08:00
Francis Lavoie
dab2f926b9 Add Dockerfile 2017-03-09 19:08:35 -05:00
Bradley Cicenas
ddce54f991 add AUR to README
fix typo
2017-03-10 09:24:44 +11:00
Bradley Cicenas
168e8f3aae implement Buffer() method for ctop header 2017-03-09 19:33:25 +11:00
72 changed files with 2850 additions and 376 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

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
ctop
.idea

16
Dockerfile Normal file
View File

@@ -0,0 +1,16 @@
FROM quay.io/vektorcloud/go:1.10
RUN apk add --no-cache make
COPY Gopkg.* /go/src/github.com/bcicen/ctop/
WORKDIR /go/src/github.com/bcicen/ctop/
RUN dep ensure -vendor-only
COPY . /go/src/github.com/bcicen/ctop
RUN make build && \
mkdir -p /go/bin && \
mv -v ctop /go/bin/
FROM scratch
COPY --from=0 /go/bin/ctop /ctop
ENTRYPOINT ["/ctop"]

165
Gopkg.lock generated Normal file
View File

@@ -0,0 +1,165 @@
# This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'.
[[projects]]
name = "github.com/Azure/go-ansiterm"
packages = [".","winterm"]
revision = "fa152c58bc15761d0200cb75fe958b89a9d4888e"
[[projects]]
name = "github.com/BurntSushi/toml"
packages = ["."]
revision = "b26d9c308763d68093482582cea63d69be07a0f0"
version = "v0.3.0"
[[projects]]
name = "github.com/Microsoft/go-winio"
packages = ["."]
revision = "fff283ad5116362ca252298cfc9b95828956d85d"
version = "v0.3.8"
[[projects]]
branch = "master"
name = "github.com/Nvveen/Gotty"
packages = ["."]
revision = "cd527374f1e5bff4938207604a14f2e38a9cf512"
[[projects]]
name = "github.com/Sirupsen/logrus"
packages = ["."]
revision = "26709e2714106fb8ad40b773b711ebce25b78914"
[[projects]]
name = "github.com/c9s/goprocinfo"
packages = ["linux"]
revision = "b34328d6e0cd139894ea7347d2624ccf31fa3c58"
[[projects]]
name = "github.com/coreos/go-systemd"
packages = ["dbus","util"]
revision = "b4a58d95188dd092ae20072bac14cece0e67c388"
version = "v4"
[[projects]]
name = "github.com/docker/docker"
packages = ["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"]
revision = "90d35abf7b3535c1c319c872900fbd76374e521c"
version = "v17.05.0-ce-rc3"
[[projects]]
name = "github.com/docker/go-connections"
packages = ["nat"]
revision = "a2afab9802043837035592f1c24827fb70766de9"
[[projects]]
branch = "master"
name = "github.com/docker/go-units"
packages = ["."]
revision = "0dadbb0345b35ec7ef35e228dabb8de89a65bf52"
[[projects]]
name = "github.com/fsouza/go-dockerclient"
packages = ["."]
revision = "318513eb1ab27495afbc67f671ba1080513d8aa0"
[[projects]]
branch = "master"
name = "github.com/gizak/termui"
packages = ["."]
revision = "cdc199d7ea432fd8187db35f0247285d6f5b0267"
source = "https://github.com/bcicen/termui"
[[projects]]
name = "github.com/godbus/dbus"
packages = ["."]
revision = "c7fdd8b5cd55e87b4e1f4e372cdb1db61dd6c66f"
version = "v3"
[[projects]]
branch = "master"
name = "github.com/golang/protobuf"
packages = ["proto"]
revision = "0a4f71a498b7c4812f64969510bcb4eca251e33a"
[[projects]]
branch = "master"
name = "github.com/hashicorp/go-cleanhttp"
packages = ["."]
revision = "3573b8b52aa7b37b9358d966a898feb387f62437"
[[projects]]
branch = "master"
name = "github.com/jgautheron/codename-generator"
packages = ["."]
revision = "16d037c7cc3c9b552fe4af9828b7338d752dbaf9"
[[projects]]
name = "github.com/maruel/panicparse"
packages = ["stack"]
revision = "25bcac0d793cf4109483505a0d66e066a3a90a80"
[[projects]]
name = "github.com/mattn/go-runewidth"
packages = ["."]
revision = "14207d285c6c197daabb5c9793d63e7af9ab2d50"
[[projects]]
branch = "master"
name = "github.com/mitchellh/go-wordwrap"
packages = ["."]
revision = "ad45545899c7b13c020ea92b2072220eefad42b8"
[[projects]]
name = "github.com/nsf/termbox-go"
packages = ["."]
revision = "e2050e41c8847748ec5288741c0b19a8cb26d084"
[[projects]]
branch = "master"
name = "github.com/nu7hatch/gouuid"
packages = ["."]
revision = "179d4d0c4d8d407a32af483c2354df1d2c91e6c3"
[[projects]]
name = "github.com/op/go-logging"
packages = ["."]
revision = "b2cb9fa56473e98db8caba80237377e83fe44db5"
version = "v1"
[[projects]]
name = "github.com/opencontainers/runc"
packages = ["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"]
revision = "baf6536d6259209c3edfa2b22237af82942d3dfa"
version = "v0.1.1"
[[projects]]
name = "github.com/seccomp/libseccomp-golang"
packages = ["."]
revision = "1b506fc7c24eec5a3693cdcbed40d9c226cfc6a1"
[[projects]]
name = "github.com/syndtr/gocapability"
packages = ["capability"]
revision = "2c00daeb6c3b45114c80ac44119e7b8801fdd852"
[[projects]]
name = "github.com/vishvananda/netlink"
packages = [".","nl"]
revision = "1e2e08e8a2dcdacaae3f14ac44c5cfa31361f270"
[[projects]]
name = "golang.org/x/net"
packages = ["context","context/ctxhttp"]
revision = "a6577fac2d73be281a500b310739095313165611"
[[projects]]
name = "golang.org/x/sys"
packages = ["unix","windows"]
revision = "99f16d856c9836c42d24e7ab64ea72916925fa97"
[solve-meta]
analyzer-name = "dep"
analyzer-version = 1
inputs-digest = "f46f5c696ecb0b0c42a38dac512df21fc1f5fb2bfda888434e005e69d1b6273b"
solver-name = "gps-cdcl"
solver-version = 1

47
Gopkg.toml Normal file
View File

@@ -0,0 +1,47 @@
# Gopkg.toml example
#
# Refer to https://github.com/golang/dep/blob/master/docs/Gopkg.toml.md
# for detailed Gopkg.toml documentation.
#
# required = ["github.com/user/thing/cmd/thing"]
# ignored = ["github.com/user/project/pkgX", "bitbucket.org/user/project/pkgA/pkgY"]
#
# [[constraint]]
# name = "github.com/user/project"
# version = "1.0.0"
#
# [[constraint]]
# name = "github.com/user/project2"
# branch = "dev"
# source = "github.com/myfork/project2"
#
# [[override]]
# name = "github.com/x/y"
# version = "2.4.0"
[[constraint]]
name = "github.com/fsouza/go-dockerclient"
revision = "318513eb1ab27495afbc67f671ba1080513d8aa0"
[[constraint]]
branch = "master"
name = "github.com/gizak/termui"
source = "https://github.com/bcicen/termui"
[[constraint]]
branch = "master"
name = "github.com/jgautheron/codename-generator"
[[constraint]]
branch = "master"
name = "github.com/nu7hatch/gouuid"
[[constraint]]
name = "github.com/op/go-logging"
version = "1.0.0"
[[constraint]]
name = "github.com/opencontainers/runc"
version = "0.1.1"

37
Makefile Normal file
View File

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

View File

@@ -1,14 +1,17 @@
<p align="center"><img width="200px" src="/_docs/img/logo.png" alt="ctop"/></p> <p align="center"><img width="200px" src="/_docs/img/logo.png" alt="ctop"/></p>
# #
![release][release] ![homebrew][homebrew]
Top-like interface for container metrics 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 currently comes with built-in support for Docker; 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.
## Install ## Install
@@ -17,37 +20,75 @@ Fetch the [latest release](https://github.com/bcicen/ctop/releases) for your pla
#### Linux #### Linux
```bash ```bash
wget https://github.com/bcicen/ctop/releases/download/v0.4/ctop-0.4-linux-amd64 -O ctop sudo wget https://github.com/bcicen/ctop/releases/download/v0.7/ctop-0.7-linux-amd64 -O /usr/local/bin/ctop
sudo mv ctop /usr/local/bin/
sudo chmod +x /usr/local/bin/ctop sudo chmod +x /usr/local/bin/ctop
``` ```
#### OS X #### OS X
```bash ```bash
curl -Lo ctop https://github.com/bcicen/ctop/releases/download/v0.4/ctop-0.4-darwin-amd64 brew install ctop
sudo mv ctop /usr/local/bin/ ```
or
```bash
sudo curl -Lo /usr/local/bin/ctop https://github.com/bcicen/ctop/releases/download/v0.7/ctop-0.7-darwin-amd64
sudo chmod +x /usr/local/bin/ctop sudo chmod +x /usr/local/bin/ctop
``` ```
#### Docker
```bash
docker run --rm -ti \
--name=ctop \
-v /var/run/docker.sock:/var/run/docker.sock \
quay.io/vektorlab/ctop:latest
```
`ctop` is also available for Arch in the [AUR](https://aur.archlinux.org/packages/ctop-bin/)
## Building
Build steps can be found [here][build].
## Usage ## Usage
ctop requires no arguments and will configure itself using the `DOCKER_HOST` environment variable `ctop` requires no arguments and uses Docker host variables by default. See [connectors][connectors] for further configuration options.
```bash
export DOCKER_HOST=tcp://127.0.0.1:4243 ### Config file
ctop
``` While running, use `S` to save the current filters, sort field, and other options to a default config path. These settings will be loaded and applied the next time `ctop` is started.
### Options
Option | Description
--- | ---
-a | show active containers only
-f \<string\> | set an initial filter string
-h | display help dialog
-i | invert default colors
-r | reverse container sort order
-s | select initial container sort field
-scale-cpu | show cpu as % of system total
-v | output version information and exit
### 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 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
[expanded_view]: _docs/expanded.md [build]: _docs/build.md
[connectors]: _docs/connectors.md
[single_view]: _docs/single.md
[release]: https://img.shields.io/github/release/bcicen/ctop.svg "ctop"
[homebrew]: https://img.shields.io/homebrew/v/ctop.svg "ctop"

View File

@@ -1 +1 @@
0.4 0.7.1

20
_docs/build.md Normal file
View File

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

26
_docs/connectors.md Normal file
View File

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

56
_docs/debug.md Normal file
View File

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

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,6 +1,10 @@
package main package main
import ui "github.com/gizak/termui" import (
"regexp"
ui "github.com/gizak/termui"
)
/* /*
Valid colors: Valid colors:
@@ -38,6 +42,20 @@ var ColorMap = map[string]ui.Attribute{
"mbarchart.text.fg": ui.ColorWhite, "mbarchart.text.fg": ui.ColorWhite,
"par.text.fg": ui.ColorWhite, "par.text.fg": ui.ColorWhite,
"par.text.bg": ui.ColorDefault, "par.text.bg": ui.ColorDefault,
"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() {
re := regexp.MustCompile(".*.fg")
for k, _ := range ColorMap {
if re.FindAllString(k, 1) != nil {
ColorMap[k] = ui.ColorBlack
}
}
ColorMap["par.text.hi"] = ui.ColorWhite
} }

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 ConfigFile struct {
Options map[string]string `toml:"options"`
Toggles map[string]bool `toml:"toggles"`
}
func exportConfig() ConfigFile {
c := ConfigFile{
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 ConfigFile
path, err := getConfigPath()
if err != nil {
return err
}
if _, err := toml.DecodeFile(path, &config); err != nil {
return err
}
for k, v := range config.Options {
Update(k, v)
}
for k, v := range config.Toggles {
UpdateSwitch(k, v)
}
return nil
}
func Write() (path string, err error) {
path, err = getConfigPath()
if err != nil {
return path, err
}
cfgdir := basedir(path)
// create config dir if not exist
if _, err := os.Stat(cfgdir); err != nil {
err = os.MkdirAll(cfgdir, 0755)
if err != nil {
return path, fmt.Errorf("failed to create config dir [%s]: %s", cfgdir, err)
}
}
file, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0644)
if err != nil {
return path, fmt.Errorf("failed to open config for writing: %s", err)
}
writer := toml.NewEncoder(file)
err = writer.Encode(exportConfig())
if err != nil {
return path, fmt.Errorf("failed to write config: %s", err)
}
return path, nil
}
// determine config path from environment
func getConfigPath() (path string, err error) {
homeDir, ok := os.LookupEnv("HOME")
if !ok {
return path, fmt.Errorf("$HOME not set")
}
// use xdg config home if possible
if xdgSupport() {
xdgHome, ok := os.LookupEnv("XDG_CONFIG_HOME")
if !ok {
xdgHome = fmt.Sprintf("%s/.config", homeDir)
}
path = fmt.Sprintf("%s/ctop/config", xdgHome)
} else {
path = fmt.Sprintf("%s/.ctop", homeDir)
}
return path, nil
}
// test for environemnt supporting XDG spec
func xdgSupport() bool {
for _, e := range os.Environ() {
if xdgRe.FindAllString(e, 1) != nil {
return true
}
}
return false
}
func basedir(path string) string {
parts := strings.Split(path, "/")
return strings.Join((parts[0 : len(parts)-1]), "/")
}

View File

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

View File

@@ -2,11 +2,6 @@ package config
// defaults // defaults
var params = []*Param{ var params = []*Param{
&Param{
Key: "dockerHost",
Val: getEnv("DOCKER_HOST", "unix:///var/run/docker.sock"),
Label: "Docker API URL",
},
&Param{ &Param{
Key: "filterStr", Key: "filterStr",
Val: "", Val: "",

View File

@@ -18,9 +18,9 @@ var switches = []*Switch{
Label: "Enable Status Header", Label: "Enable Status Header",
}, },
&Switch{ &Switch{
Key: "loggingEnabled", Key: "scaleCpu",
Val: false, Val: false,
Label: "Enable Logging Server", Label: "Show CPU as %% of system total",
}, },
} }
@@ -45,6 +45,14 @@ 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)

View File

@@ -1,32 +1,36 @@
package metrics package collector
import ( import (
"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 models.Metrics
id string id string
client *api.Client client *api.Client
running bool running bool
stream chan 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: 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) c.stream = make(chan models.Metrics)
stats := make(chan *api.Stats) stats := make(chan *api.Stats)
go func() { go func() {
@@ -46,6 +50,7 @@ func (c *Docker) Start() {
c.ReadCPU(s) c.ReadCPU(s)
c.ReadMem(s) c.ReadMem(s)
c.ReadNet(s) c.ReadNet(s)
c.ReadIO(s)
c.stream <- c.Metrics c.stream <- c.Metrics
} }
log.Infof("collector stopped for container: %s", c.id) log.Infof("collector stopped for container: %s", c.id)
@@ -59,10 +64,14 @@ func (c *Docker) Running() bool {
return c.running return c.running
} }
func (c *Docker) Stream() chan 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
@@ -76,15 +85,20 @@ 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)
} }
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 = round((float64(c.MemUsage) / float64(c.MemLimit)) * 100) c.MemPercent = percent(float64(c.MemUsage), float64(c.MemLimit))
} }
func (c *Docker) ReadNet(stats *api.Stats) { func (c *Docker) ReadNet(stats *api.Stats) {
@@ -95,3 +109,16 @@ func (c *Docker) ReadNet(stats *api.Stats) {
} }
c.NetRx, c.NetTx = rx, tx c.NetRx, c.NetTx = rx, tx
} }
func (c *Docker) ReadIO(stats *api.Stats) {
var read, write int64
for _, blk := range stats.BlkioStats.IOServiceBytesRecursive {
if blk.Op == "Read" {
read = int64(blk.Value)
}
if blk.Op == "Write" {
write = int64(blk.Value)
}
}
c.IOBytesRead, c.IOBytesWrite = read, write
}

View File

@@ -0,0 +1,84 @@
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{ts, 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() {
select {
case <-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

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

View File

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

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{time.Now(), mockLog}
time.Sleep(250 * time.Millisecond)
}
}
}()
return logCh
}
func (l *MockLogs) Stop() { l.done <- true }

View File

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

135
connector/collector/runc.go Normal file
View File

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

View File

@@ -1,35 +1,36 @@
package main package connector
import ( import (
"sort" "fmt"
"strings" "strings"
"sync"
"github.com/bcicen/ctop/config" "github.com/bcicen/ctop/connector/collector"
"github.com/bcicen/ctop/metrics" "github.com/bcicen/ctop/connector/manager"
"github.com/fsouza/go-dockerclient" "github.com/bcicen/ctop/container"
api "github.com/fsouza/go-dockerclient"
) )
type ContainerSource interface { func init() { enabled["docker"] = NewDocker }
All() Containers
Get(string) (*Container, bool)
}
type DockerContainerSource struct { type Docker struct {
client *docker.Client client *api.Client
containers map[string]*Container containers map[string]*container.Container
needsRefresh chan string // container IDs requiring refresh needsRefresh chan string // container IDs requiring refresh
lock sync.RWMutex
} }
func NewDockerContainerSource() *DockerContainerSource { func NewDocker() Connector {
// init docker client // init docker client
client, err := docker.NewClient(config.GetVal("dockerHost")) client, err := api.NewClientFromEnv()
if err != nil { if err != nil {
panic(err) panic(err)
} }
cm := &DockerContainerSource{ cm := &Docker{
client: client, client: client,
containers: make(map[string]*Container), containers: make(map[string]*container.Container),
needsRefresh: make(chan string, 60), needsRefresh: make(chan string, 60),
lock: sync.RWMutex{},
} }
go cm.Loop() go cm.Loop()
cm.refreshAll() cm.refreshAll()
@@ -38,17 +39,20 @@ func NewDockerContainerSource() *DockerContainerSource {
} }
// Docker events watcher // Docker events watcher
func (cm *DockerContainerSource) watchEvents() { func (cm *Docker) watchEvents() {
log.Info("docker event listener starting") log.Info("docker event listener starting")
events := make(chan *docker.APIEvents) events := make(chan *api.APIEvents)
cm.client.AddEventListener(events) cm.client.AddEventListener(events)
for e := range events { for e := range events {
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":
@@ -58,7 +62,25 @@ func (cm *DockerContainerSource) watchEvents() {
} }
} }
func (cm *DockerContainerSource) refresh(c *Container) { func portsFormat(ports map[api.Port][]api.PortBinding) string {
var exposed []string
var published []string
for k, v := range ports {
if len(v) == 0 {
exposed = append(exposed, string(k))
continue
}
for _, binding := range v {
s := fmt.Sprintf("%s:%s -> %s", binding.HostIP, binding.HostPort, k)
published = append(published, s)
}
}
return strings.Join(append(exposed, published...), "\n")
}
func (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
if insp == nil { if insp == nil {
@@ -67,14 +89,16 @@ func (cm *DockerContainerSource) refresh(c *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("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)
c.SetState(insp.State.Status) c.SetState(insp.State.Status)
} }
func (cm *DockerContainerSource) inspect(id string) *docker.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.(*docker.NoSuchContainer); ok == false { if _, ok := err.(*api.NoSuchContainer); ok == false {
log.Errorf(err.Error()) log.Errorf(err.Error())
} }
} }
@@ -82,8 +106,8 @@ func (cm *DockerContainerSource) inspect(id string) *docker.Container {
} }
// Mark all container IDs for refresh // Mark all container IDs for refresh
func (cm *DockerContainerSource) refreshAll() { func (cm *Docker) refreshAll() {
opts := docker.ListContainersOptions{All: true} opts := api.ListContainersOptions{All: true}
allContainers, err := cm.client.ListContainers(opts) allContainers, err := cm.client.ListContainers(opts)
if err != nil { if err != nil {
panic(err) panic(err)
@@ -97,7 +121,7 @@ func (cm *DockerContainerSource) refreshAll() {
} }
} }
func (cm *DockerContainerSource) Loop() { func (cm *Docker) Loop() {
for id := range cm.needsRefresh { for id := range cm.needsRefresh {
c := cm.MustGet(id) c := cm.MustGet(id)
cm.refresh(c) cm.refresh(c)
@@ -105,38 +129,49 @@ func (cm *DockerContainerSource) Loop() {
} }
// Get a single container, creating one anew if not existing // Get a single container, creating one anew if not existing
func (cm *DockerContainerSource) MustGet(id string) *Container { func (cm *Docker) MustGet(id string) *container.Container {
c, ok := cm.Get(id) c, ok := cm.Get(id)
// append container struct for new containers // append container struct for new containers
if !ok { if !ok {
// create collector // create collector
collector := metrics.NewDocker(cm.client, id) collector := collector.NewDocker(cm.client, id)
// create manager
manager := manager.NewDocker(cm.client, id)
// create container // create container
c = NewContainer(id, collector) c = container.New(id, collector, manager)
cm.lock.Lock()
cm.containers[id] = c cm.containers[id] = c
cm.lock.Unlock()
} }
return c return c
} }
// Get a single container, by ID // Get a single container, by ID
func (cm *DockerContainerSource) Get(id string) (*Container, bool) { func (cm *Docker) Get(id string) (*container.Container, bool) {
cm.lock.Lock()
c, ok := cm.containers[id] c, ok := cm.containers[id]
cm.lock.Unlock()
return c, ok return c, ok
} }
// Remove containers by ID // Remove containers by ID
func (cm *DockerContainerSource) delByID(id string) { func (cm *Docker) delByID(id string) {
cm.lock.Lock()
delete(cm.containers, id) delete(cm.containers, id)
cm.lock.Unlock()
log.Infof("removed dead container: %s", id) log.Infof("removed dead container: %s", id)
} }
// Return array of all containers, sorted by field // Return array of all containers, sorted by field
func (cm *DockerContainerSource) All() (containers Containers) { func (cm *Docker) All() (containers container.Containers) {
cm.lock.Lock()
for _, c := range cm.containers { for _, c := range cm.containers {
containers = append(containers, c) containers = append(containers, c)
} }
sort.Sort(containers)
containers.Sort()
containers.Filter() containers.Filter()
cm.lock.Unlock()
return containers return containers
} }

35
connector/main.go Normal file
View File

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

View File

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

View File

@@ -0,0 +1,7 @@
package manager
type Manager interface {
Start() error
Stop() error
Remove() error
}

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

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

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

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

View File

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

247
connector/runc.go Normal file
View File

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

View File

@@ -1,31 +1,40 @@
package main package container
import ( import (
"github.com/bcicen/ctop/connector/collector"
"github.com/bcicen/ctop/connector/manager"
"github.com/bcicen/ctop/cwidgets" "github.com/bcicen/ctop/cwidgets"
"github.com/bcicen/ctop/cwidgets/compact" "github.com/bcicen/ctop/cwidgets/compact"
"github.com/bcicen/ctop/metrics" "github.com/bcicen/ctop/logging"
"github.com/bcicen/ctop/models"
)
var (
log = logging.Init()
) )
// Metrics and metadata representing a container // Metrics and metadata representing a container
type Container struct { type Container struct {
metrics.Metrics models.Metrics
Id string Id string
Meta map[string]string Meta map[string]string
Widgets *compact.Compact Widgets *compact.Compact
Display bool // display this container in compact view
updater cwidgets.WidgetUpdater updater cwidgets.WidgetUpdater
collector metrics.Collector collector collector.Collector
display bool // display this container in compact view manager manager.Manager
} }
func NewContainer(id string, collector metrics.Collector) *Container { func New(id string, collector collector.Collector, manager manager.Manager) *Container {
widgets := compact.NewCompact(id) widgets := compact.NewCompact(id)
return &Container{ return &Container{
Metrics: metrics.NewMetrics(), Metrics: models.NewMetrics(),
Id: id, Id: id,
Meta: make(map[string]string), Meta: make(map[string]string),
Widgets: widgets, Widgets: widgets,
updater: widgets, updater: widgets,
collector: collector, collector: collector,
manager: manager,
} }
} }
@@ -61,16 +70,50 @@ func (c *Container) SetState(s string) {
} }
} }
// Return container log collector
func (c *Container) Logs() collector.LogCollector {
return c.collector.Logs()
}
// Read metric stream, updating widgets // Read metric stream, updating widgets
func (c *Container) Read(stream chan metrics.Metrics) { func (c *Container) Read(stream chan models.Metrics) {
go func() { go func() {
for metrics := range stream { for metrics := range stream {
c.Metrics = metrics c.Metrics = metrics
c.updater.SetMetrics(metrics) c.updater.SetMetrics(metrics)
} }
log.Infof("reader stopped for container: %s", c.Id) log.Infof("reader stopped for container: %s", c.Id)
c.Metrics = metrics.NewMetrics() c.Metrics = models.NewMetrics()
c.Widgets.Reset() c.Widgets.Reset()
}() }()
log.Infof("reader started for container: %s", c.Id) 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)
}
}

View File

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

View File

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

View File

@@ -25,10 +25,10 @@ func (w *GaugeCol) Reset() {
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

@@ -4,7 +4,7 @@ import (
ui "github.com/gizak/termui" ui "github.com/gizak/termui"
) )
var header = NewCompactHeader() var header *CompactHeader
type CompactGrid struct { type CompactGrid struct {
ui.GridBufferer ui.GridBufferer
@@ -16,14 +16,17 @@ type CompactGrid struct {
} }
func NewCompactGrid() *CompactGrid { func NewCompactGrid() *CompactGrid {
header = NewCompactHeader() // init column header
return &CompactGrid{} return &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)

View File

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

View File

@@ -2,7 +2,7 @@ package compact
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"
) )
@@ -13,8 +13,10 @@ 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
Pids *TextCol
X, Y int X, Y int
Width int Width int
Height int Height int
@@ -30,8 +32,10 @@ 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("-"),
Pids: NewTextCol("-"),
X: 1, X: 1,
Height: 1, Height: 1,
} }
@@ -52,20 +56,26 @@ 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)
row.SetIO(m.IOBytesRead, m.IOBytesWrite)
row.SetPids(m.Pids)
} }
// 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.Pids.Reset()
} }
func (row *Compact) GetHeight() int { func (row *Compact) GetHeight() int {
@@ -91,13 +101,12 @@ func (row *Compact) SetWidth(width int) {
return return
} }
x := row.X x := row.X
autoWidth := calcWidth(width, 5) autoWidth := calcWidth(width)
for n, col := range row.all() { for n, col := range row.all() {
// set status column to static width if colWidths[n] != 0 {
if n == 0 {
col.SetX(x) col.SetX(x)
col.SetWidth(statusWidth) col.SetWidth(colWidths[n])
x += statusWidth x += colWidths[n]
continue continue
} }
col.SetX(x) col.SetX(x)
@@ -114,9 +123,10 @@ func (row *Compact) Buffer() ui.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.Pids.Buffer())
return buf return buf
} }
@@ -126,7 +136,9 @@ 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.Pids,
} }
} }

View File

@@ -13,6 +13,16 @@ func (row *Compact) SetNet(rx int64, tx int64) {
row.Net.Set(label) row.Net.Set(label)
} }
func (row *Compact) SetIO(read int64, write int64) {
label := fmt.Sprintf("%s / %s", cwidgets.ByteFormat(read), cwidgets.ByteFormat(write))
row.IO.Set(label)
}
func (row *Compact) SetPids(val int) {
label := fmt.Sprintf("%s", strconv.Itoa(val))
row.Pids.Set(label)
}
func (row *Compact) SetCPU(val int) { func (row *Compact) SetCPU(val int) {
row.Cpu.BarColor = colorScale(val) row.Cpu.BarColor = colorScale(val)
row.Cpu.Label = fmt.Sprintf("%s%%", strconv.Itoa(val)) row.Cpu.Label = fmt.Sprintf("%s%%", strconv.Itoa(val))
@@ -20,16 +30,19 @@ func (row *Compact) SetCPU(val int) {
val = 5 val = 5
row.Cpu.BarColor = ui.ThemeAttr("gauge.bar.bg") row.Cpu.BarColor = ui.ThemeAttr("gauge.bar.bg")
} }
if val > 100 {
val = 100
}
row.Cpu.Percent = val row.Cpu.Percent = val
} }
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,7 +17,7 @@ func NewTextCol(s string) *TextCol {
} }
func (w *TextCol) Highlight() { func (w *TextCol) Highlight() {
w.TextFgColor = ui.ColorBlack w.TextFgColor = ui.ThemeAttr("par.text.hi")
w.TextBgColor = ui.ThemeAttr("par.text.fg") w.TextBgColor = ui.ThemeAttr("par.text.fg")
} }

View File

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

View File

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

View File

@@ -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"

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

58
cwidgets/single/info.go Normal file
View File

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

51
cwidgets/single/io.go Normal file
View File

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

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,47 +12,78 @@ 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
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(),
Width: ui.TermWidth(), Width: ui.TermWidth(),
} }
} }
func (e *Expanded) SetWidth(w int) { func (e *Single) Up() {
e.Width = w if e.Y < 0 {
e.Y++
e.Align()
ui.Render(e)
}
} }
func (e *Expanded) SetMeta(k, v string) { func (e *Single) Down() {
e.Info.Set(k, v) if e.Y > (ui.TermHeight() - e.GetHeight()) {
e.Y--
e.Align()
ui.Render(e)
}
} }
func (e *Expanded) SetMetrics(m metrics.Metrics) { func (e *Single) SetWidth(w int) { e.Width = w }
func (e *Single) SetMeta(k, v string) { e.Info.Set(k, v) }
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))
e.IO.Update(m.IOBytesRead, m.IOBytesWrite)
} }
func (e *Expanded) Align() { // Return total column height
y := 0 func (e *Single) GetHeight() (h int) {
h += e.Info.Height
h += e.Net.Height
h += e.Cpu.Height
h += e.Mem.Height
h += e.IO.Height
return h
}
func (e *Single) Align() {
// reset offset if needed
if e.GetHeight() <= ui.TermHeight() {
e.Y = 0
}
y := e.Y
for _, i := range e.all() { for _, i := range e.all() {
i.SetY(y) i.SetY(y)
y += i.GetHeight() y += i.GetHeight()
} }
if e.Width > colWidth[0] { if e.Width > colWidth[0] {
colWidth[1] = e.Width - (colWidth[0] + 1) colWidth[1] = e.Width - (colWidth[0] + 1)
} }
@@ -60,10 +91,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()
@@ -74,15 +102,17 @@ func (e *Expanded) Buffer() ui.Buffer {
buf.Merge(e.Cpu.Buffer()) buf.Merge(e.Cpu.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())
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,
} }
} }

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

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

View File

@@ -3,10 +3,14 @@ package main
import ( import (
"fmt" "fmt"
"reflect" "reflect"
"runtime"
"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))
@@ -18,8 +22,24 @@ 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) { 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)
for k, v := range c.Meta { for k, v := range c.Meta {
msg += fmt.Sprintf("Meta.%s = %s\n", k, v) msg += fmt.Sprintf("Meta.%s = %s\n", k, v)

103
grid.go
View File

@@ -2,7 +2,7 @@ package main
import ( import (
"github.com/bcicen/ctop/config" "github.com/bcicen/ctop/config"
"github.com/bcicen/ctop/cwidgets/expanded" "github.com/bcicen/ctop/cwidgets/single"
ui "github.com/gizak/termui" ui "github.com/gizak/termui"
) )
@@ -17,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 {
@@ -28,65 +29,89 @@ func RedrawRows(clr bool) {
log.Debugf("screen cleared") log.Debugf("screen cleared")
} }
if config.GetSwitchVal("enableHeader") { if config.GetSwitchVal("enableHeader") {
header.Render() ui.Render(header)
} }
cGrid.Align() cGrid.Align()
ui.Render(cGrid) ui.Render(cGrid)
} }
func ExpandView(c *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()
ui.Render(ex) ui.Render(ex)
ui.Handle("/timer/1s", func(ui.Event) {
ui.Render(ex) HandleKeys("up", ex.Up)
}) HandleKeys("down", ex.Down)
ui.Handle("/sys/kbd/", func(ui.Event) { ui.StopLoop() })
ui.Handle("/timer/1s", func(ui.Event) { ui.Render(ex) })
ui.Handle("/sys/wnd/resize", func(e ui.Event) { ui.Handle("/sys/wnd/resize", func(e ui.Event) {
ex.SetWidth(ui.TermWidth()) ex.SetWidth(ui.TermWidth())
ex.Align() ex.Align()
log.Infof("resize: width=%v max-rows=%v", ex.Width, cGrid.MaxRows()) log.Infof("resize: width=%v max-rows=%v", ex.Width, cGrid.MaxRows())
}) })
ui.Handle("/sys/kbd/", func(ui.Event) {
ui.StopLoop()
})
ui.Loop()
ui.Loop()
c.SetUpdater(c.Widgets) c.SetUpdater(c.Widgets)
return nil
} }
func RefreshDisplay() { func RefreshDisplay() {
// skip display refresh during scroll
if !cursor.isScrolling {
needsClear := cursor.RefreshContainers() needsClear := cursor.RefreshContainers()
RedrawRows(needsClear) RedrawRows(needsClear)
}
} }
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)
ui.Handle("/sys/kbd/<up>", func(ui.Event) { cursor.Up() }) HandleKeys("up", cursor.Up)
ui.Handle("/sys/kbd/<down>", func(ui.Event) { cursor.Down() }) HandleKeys("down", cursor.Down)
ui.Handle("/sys/kbd/<enter>", func(ui.Event) {
expand = true HandleKeys("pgup", cursor.PgUp)
HandleKeys("pgdown", cursor.PgDown)
HandleKeys("exit", ui.StopLoop)
HandleKeys("help", func() {
menu = HelpMenu
ui.StopLoop() ui.StopLoop()
}) })
ui.Handle("/sys/kbd/q", func(ui.Event) { ui.StopLoop() }) ui.Handle("/sys/kbd/<enter>", func(ui.Event) {
ui.Handle("/sys/kbd/C-c", func(ui.Event) { ui.StopLoop() }) menu = ContainerMenu
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.Handle("/sys/kbd/a", func(ui.Event) { ui.Handle("/sys/kbd/a", func(ui.Event) {
config.Toggle("allContainers") config.Toggle("allContainers")
RefreshDisplay() RefreshDisplay()
@@ -98,10 +123,6 @@ func Display() bool {
menu = FilterMenu menu = FilterMenu
ui.StopLoop() ui.StopLoop()
}) })
ui.Handle("/sys/kbd/h", func(ui.Event) {
menu = HelpMenu
ui.StopLoop()
})
ui.Handle("/sys/kbd/H", func(ui.Event) { ui.Handle("/sys/kbd/H", func(ui.Event) {
config.Toggle("enableHeader") config.Toggle("enableHeader")
RedrawRows(true) RedrawRows(true)
@@ -113,13 +134,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())
@@ -127,13 +161,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 {
ExpandView(cursor.Selected())
return false return false
} }
return true return true
} }

60
install.sh Executable file
View File

@@ -0,0 +1,60 @@
#!/usr/bin/env bash
# a simple install script for ctop
KERNEL=$(uname -s)
function output() { echo -e "\033[32mctop-install\033[0m $@"; }
# 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
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-*
sudo mv ctop-* /usr/local/bin/ctop
output "done!"

41
keys.go Normal file
View File

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

View File

@@ -1,6 +1,8 @@
package logging package logging
import ( import (
"fmt"
"os"
"time" "time"
"github.com/op/go-logging" "github.com/op/go-logging"
@@ -13,17 +15,42 @@ const (
var ( var (
Log *CTopLogger Log *CTopLogger
exited bool exited bool
level = logging.INFO level = logging.INFO // default level
format = logging.MustStringFormatter( format = logging.MustStringFormatter(
`%{color}%{time:15:04:05.000} ▶ %{level:.4s} %{id:03x}%{color:reset} %{message}`, `%{color}%{time:15:04:05.000} ▶ %{level:.4s} %{id:03x}%{color:reset} %{message}`,
) )
) )
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
@@ -31,6 +58,12 @@ func Init() *CTopLogger {
Log = &CTopLogger{ Log = &CTopLogger{
logging.MustGetLogger("ctop"), logging.MustGetLogger("ctop"),
logging.NewMemoryBackend(size), logging.NewMemoryBackend(size),
[]statusMsg{},
}
if debugMode() {
level = logging.DEBUG
StartServer()
} }
backendLvl := logging.AddModuleLevel(Log.backend) backendLvl := logging.AddModuleLevel(Log.backend)
@@ -71,3 +104,6 @@ func (log *CTopLogger) Exit() {
exited = true exited = true
StopServer() StopServer()
} }
func debugMode() bool { return os.Getenv("CTOP_DEBUG") == "1" }
func debugModeTCP() bool { return os.Getenv("CTOP_DEBUG_TCP") == "1" }

View File

@@ -7,7 +7,8 @@ import (
) )
const ( const (
path = "/tmp/ctop.sock" socketPath = "./ctop.sock"
socketAddr = "0.0.0.0:9000"
) )
var server struct { var server struct {
@@ -16,7 +17,13 @@ var server struct {
} }
func getListener() net.Listener { func getListener() net.Listener {
ln, err := net.Listen("unix", path) var ln net.Listener
var err error
if debugModeTCP() {
ln, err = net.Listen("tcp", socketAddr)
} else {
ln, err = net.Listen("unix", socketPath)
}
if err != nil { if err != nil {
panic(err) panic(err)
} }

127
main.go
View File

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

229
menus.go
View File

@@ -1,23 +1,35 @@
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/widgets" "github.com/bcicen/ctop/widgets"
"github.com/bcicen/ctop/widgets/menu" "github.com/bcicen/ctop/widgets/menu"
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()
@@ -25,20 +37,25 @@ 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()
i := widgets.NewInput() i := widgets.NewInput()
i.BorderLabel = "Filter" i.BorderLabel = "Filter"
i.SetY(ui.TermHeight() - i.Height) i.SetY(ui.TermHeight() - i.Height)
i.Data = config.GetVal("filterStr")
ui.Render(i) ui.Render(i)
// refresh container rows on input // refresh container rows on input
@@ -52,14 +69,19 @@ func FilterMenu() {
}() }()
i.InputHandlers() i.InputHandlers()
ui.Handle("/sys/kbd/<escape>", func(ui.Event) {
config.Update("filterStr", "")
ui.StopLoop()
})
ui.Handle("/sys/kbd/<enter>", func(ui.Event) { ui.Handle("/sys/kbd/<enter>", func(ui.Event) {
config.Update("filterStr", i.Data) config.Update("filterStr", i.Data)
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()
@@ -69,18 +91,199 @@ func SortMenu() {
m.SortItems = true m.SortItems = true
m.BorderLabel = "Sort Field" m.BorderLabel = "Sort Field"
for _, field := range SortFields() { for _, field := range container.SortFields() {
m.AddItems(menu.Item{field, ""}) m.AddItems(menu.Item{field, ""})
} }
// set cursor position to current sort field // set cursor position to current sort field
m.SetCursor(config.GetVal("sortField")) m.SetCursor(config.GetVal("sortField"))
ui.Render(m) HandleKeys("up", m.Up)
m.NavigationHandlers() HandleKeys("down", m.Down)
HandleKeys("exit", ui.StopLoop)
ui.Handle("/sys/kbd/<enter>", func(ui.Event) { ui.Handle("/sys/kbd/<enter>", func(ui.Event) {
config.Update("sortField", m.SelectedItem().Val) config.Update("sortField", m.SelectedItem().Val)
ui.StopLoop() ui.StopLoop()
}) })
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"})
}
if c.Meta["state"] == "exited" || c.Meta["state"] == "created" {
items = append(items, menu.Item{Val: "start", Label: "start"})
items = append(items, menu.Item{Val: "remove", Label: "remove"})
}
items = append(items, menu.Item{Val: "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)
}
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 = "Logs"
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,39 +0,0 @@
package metrics
import (
"math"
"github.com/bcicen/ctop/logging"
)
var log = logging.Init()
type Metrics struct {
CPUUtil int
NetTx int64
NetRx int64
MemLimit int64
MemPercent int
MemUsage int64
}
func NewMetrics() Metrics {
return Metrics{
CPUUtil: -1,
NetTx: -1,
NetRx: -1,
MemUsage: -1,
MemPercent: -1,
}
}
type Collector interface {
Stream() chan Metrics
Running() bool
Start()
Stop()
}
func round(num float64) int {
return int(num + math.Copysign(0.5, num))
}

33
models/main.go Normal file
View File

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

View File

@@ -17,16 +17,20 @@ 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(),
} }
} }
func (c *CTopHeader) Render() { func (c *CTopHeader) Buffer() ui.Buffer {
buf := ui.NewBuffer()
c.Time.Text = timeStr() c.Time.Text = timeStr()
ui.Render(c.bg) buf.Merge(c.bg.Buffer())
ui.Render(c.Time, c.Count, c.Filter) buf.Merge(c.Time.Buffer())
buf.Merge(c.Count.Buffer())
buf.Merge(c.Filter.Buffer())
return buf
} }
func (c *CTopHeader) Align() { func (c *CTopHeader) Align() {

View File

@@ -7,7 +7,7 @@ import (
) )
var ( var (
input_chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_." input_chars = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_."
) )
type Padding [2]int // x,y padding type Padding [2]int // x,y padding

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 {
@@ -100,39 +111,40 @@ func (m *Menu) Buffer() ui.Buffer {
return buf return buf
} }
func (m *Menu) Up(ui.Event) { func (m *Menu) Up() {
if m.cursorPos > 0 { if m.cursorPos > 0 {
m.cursorPos-- m.cursorPos--
ui.Render(m) ui.Render(m)
} }
} }
func (m *Menu) Down(ui.Event) { func (m *Menu) Down() {
if m.cursorPos < (len(m.items) - 1) { if m.cursorPos < (len(m.items) - 1) {
m.cursorPos++ m.cursorPos++
ui.Render(m) ui.Render(m)
} }
} }
// Setup some default handlers for menu navigation
func (m *Menu) NavigationHandlers() {
ui.Handle("/sys/kbd/<up>", m.Up)
ui.Handle("/sys/kbd/<down>", m.Down)
ui.Handle("/sys/kbd/q", func(ui.Event) { ui.StopLoop() })
}
// Set width and height based on menu items // Set width and height based on menu items
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))
}
}