├── static ├── local.css ├── dvizz.css └── index.html ├── .bowerrc ├── dvizz1.png ├── docker ├── Dockerfile.dev └── Dockerfile ├── .gitignore ├── bower.json ├── cmd ├── configuration.go └── dvizz │ └── main.go ├── go.mod ├── Makefile ├── LICENSE ├── .circleci └── config.yml ├── internal └── pkg │ ├── service │ ├── converters_test.go │ ├── publisher_test.go │ ├── converters.go │ └── publisher.go │ ├── comms │ ├── mock_comms │ │ └── mock_comms.go │ └── server.go │ └── model │ └── models.go ├── README.md └── go.sum /static/local.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.bowerrc: -------------------------------------------------------------------------------- 1 | { 2 | "directory" : "static/js" 3 | } 4 | -------------------------------------------------------------------------------- /dvizz1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eriklupander/dvizz/HEAD/dvizz1.png -------------------------------------------------------------------------------- /docker/Dockerfile.dev: -------------------------------------------------------------------------------- 1 | FROM scratch 2 | 3 | EXPOSE 6969 4 | 5 | WORKDIR /dvizz 6 | 7 | ADD static/ static/ 8 | ADD dist/dvizz /dvizz/dvizz 9 | 10 | ENTRYPOINT ["./dvizz"] 11 | CMD [] 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 2 | *.o 3 | *.a 4 | *.so 5 | 6 | # Folders 7 | _obj 8 | _test 9 | 10 | # Architecture specific extensions/prefixes 11 | *.[568vq] 12 | [568vq].out 13 | 14 | *.cgo1.go 15 | *.cgo2.c 16 | _cgo_defun.c 17 | _cgo_gotypes.go 18 | _cgo_export.* 19 | 20 | _testmain.go 21 | 22 | *.exe 23 | *.test 24 | *.prof 25 | 26 | static/js/ 27 | build/ 28 | dist/ 29 | bin/ -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dvizz", 3 | "homepage": "https://github.com/DE-IBH/snmd", 4 | "authors": [ 5 | "Erik Lupander @eriklupander", 6 | "Fredrik Garneij ", 7 | "Thomas Liske " 8 | ], 9 | "description": "Docker Swarm visualization including live updates using D3 Force layout", 10 | "main": "", 11 | "license": "GPL-2.0+", 12 | "private": true, 13 | "ignore": [ 14 | "**/.*" 15 | ], 16 | "dependencies": { 17 | "d3": "v3", 18 | "underscore": "^1.8.3", 19 | "jquery": "v1" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /cmd/configuration.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | type GlobalConfiguration struct { 4 | PollConfig 5 | LogLevel string `short:"l" description:"Log level"` 6 | } 7 | 8 | type PollConfig struct { 9 | NodePoll int `short:"n" description:"Node poll interval, seconds"` 10 | ServicePoll int `short:"s" description:"Service poll interval, seconds"` 11 | TaskPoll int `short:"t" description:"Task poll interval, seconds"` 12 | } 13 | 14 | func DefaultConfiguration() *GlobalConfiguration { 15 | 16 | return &GlobalConfiguration{ 17 | LogLevel: "info", 18 | PollConfig: PollConfig{ 19 | NodePoll: 60, 20 | ServicePoll: 30, 21 | TaskPoll: 10, 22 | }, 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/eriklupander/dvizz 2 | 3 | go 1.12 4 | 5 | require ( 6 | github.com/ahl5esoft/golang-underscore v1.2.0 7 | github.com/containous/flaeg v1.4.1 8 | github.com/docker/docker v0.7.3-0.20190309235953-33c3200e0d16 9 | github.com/fsouza/go-dockerclient v1.4.1 10 | github.com/golang/mock v1.3.1 11 | github.com/gorilla/websocket v1.4.0 12 | github.com/json-iterator/go v1.1.6 // indirect 13 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 14 | github.com/modern-go/reflect2 v1.0.1 // indirect 15 | github.com/ogier/pflag v0.0.1 16 | github.com/sirupsen/logrus v1.3.0 17 | github.com/smartystreets/goconvey v0.0.0-20190330032615-68dc04aab96a 18 | github.com/stretchr/testify v1.3.0 19 | ) 20 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | GOPATH=$(shell pwd)/build 2 | binaries := dvizz 3 | 4 | all: golang bower 5 | 6 | $(binaries): 7 | @echo Building $@ 8 | GO111MODULE=on go build -o bin/$@ cmd/$@/main.go 9 | 10 | build: 11 | @echo "🐳" 12 | docker build -t dvizz -f docker/Dockerfile . 13 | 14 | fmt: 15 | find . -name '*.go' | grep -v vendor | grep -v build | xargs gofmt -w -s 16 | 17 | test: 18 | go test ./cmd/... -race && go test ./internal/... -race 19 | 20 | vet: 21 | go vet ./cmd/... && go vet ./internal/... 22 | 23 | mock: 24 | mockgen -source internal/pkg/comms/server.go -destination internal/pkg/comms/mock_comms/mock_comms.go -package mock_comms 25 | 26 | golang: 27 | mkdir -p $(GOPATH) 28 | export GOPATH=$(GOPATH) && go get -v -d 29 | mkdir -p dist 30 | export GOPATH=$(GOPATH) && go build -o dist/dvizz 31 | 32 | bower: bower.json 33 | bower install 34 | 35 | docker: 36 | docker build -f Dockerfile.dev . 37 | 38 | .PHONY: $(binaries) build fmt -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 ErikL 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | # build stage: fetch bower dependencies 2 | FROM node AS bower 3 | 4 | WORKDIR /dvizz 5 | 6 | # Copy only needed frontend files instead of everything 7 | ADD .bowerrc /dvizz 8 | ADD bower.json /dvizz 9 | ADD static/* /dvizz/static/ 10 | 11 | RUN npm install -g bower && bower --allow-root install 12 | 13 | 14 | # build stage: dvizz golang binary 15 | FROM golang:1.12.0-stretch AS build_base 16 | 17 | ENV GO111MODULE=on \ 18 | CGO_ENABLED=1 \ 19 | GOOS=linux \ 20 | GOARCH=amd64 21 | 22 | WORKDIR /go/src/github.com/eriklupander/dvizz 23 | 24 | # allows docker to cache go modules based on these layers remaining unchanged. 25 | COPY go.mod . 26 | COPY go.sum . 27 | 28 | RUN go mod download 29 | 30 | COPY . . 31 | 32 | RUN go build -a \ 33 | -o bin/dvizz $PWD/cmd/dvizz 34 | 35 | # final image 36 | FROM alpine:latest 37 | 38 | EXPOSE 6969 39 | 40 | WORKDIR /app 41 | 42 | # Copy frontend code 43 | ADD static/ static/ 44 | 45 | # Copy binary from build_base image 46 | COPY --from=build_base /go/src/github.com/eriklupander/dvizz/bin/* /app 47 | 48 | # Copy frontend/js dependencies from bower build image 49 | COPY --from=bower /dvizz/static/js static/js 50 | 51 | # Support static build docker binary 52 | RUN mkdir /lib64 \ 53 | && ln -s /lib/libc.musl-x86_64.so.1 /lib64/ld-linux-x86-64.so.2 54 | 55 | # Not sure why I have to chmod/chown 56 | RUN chmod +x /app/dvizz 57 | RUN chmod 777 /app/dvizz 58 | 59 | CMD ["./dvizz"] 60 | -------------------------------------------------------------------------------- /static/dvizz.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | } 4 | 5 | #dvizz-svg { 6 | width: 100vw; 7 | height: 100vh; 8 | position: absolute; 9 | } 10 | 11 | .link { 12 | fill: none; 13 | stroke: #666; 14 | stroke-width: 1.5px; 15 | } 16 | 17 | #serviceinstance { 18 | fill: black; 19 | stroke-width: 1.5px; 20 | } 21 | 22 | .link.serviceinstance { 23 | stroke: darkgrey; 24 | stroke-width: 1.5px; 25 | } 26 | 27 | .link.supporting { 28 | stroke-dasharray: 0, 2 1; 29 | } 30 | 31 | circle { 32 | fill: #fff; 33 | stroke: #333; 34 | stroke-width: 1.5px; 35 | } 36 | 37 | circle.node { 38 | fill: #0e90d2; 39 | stroke: #333; 40 | stroke-width: 1.5px; 41 | } 42 | 43 | circle.node.service { 44 | fill: LightYellow; 45 | stroke: #333; 46 | stroke-width: 1.5px; 47 | } 48 | 49 | circle.container.starting { 50 | fill: #bfb; 51 | stroke: #f33; 52 | stroke-width: 2.5px; 53 | } 54 | 55 | circle.container.running { 56 | fill: #bfb; 57 | stroke: #333; 58 | stroke-width: 1.5px; 59 | } 60 | 61 | circle.container.shutdown { 62 | fill: #eee; 63 | stroke: #f33; 64 | stroke-width: 2.5px; 65 | } 66 | 67 | text { 68 | font: 10px sans-serif; 69 | pointer-events: none; 70 | /* text-shadow: 0 1px 0 #fff, 1px 0 0 #fff, 0 -1px 0 #fff, -1px 0 0 #fff; */ 71 | } 72 | 73 | text.two { 74 | font: 8px sans-serif; 75 | } 76 | text.three { 77 | font: 10px sans-serif; 78 | } 79 | text.four { 80 | font: 10px sans-serif; 81 | } 82 | text.five { 83 | font: 10px sans-serif; 84 | } -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 # use CircleCI 2.0 2 | jobs: # basic units of work in a run 3 | build: # runs not using Workflows must have a `build` job as entry point 4 | docker: # run the steps with Docker 5 | # CircleCI Go images available at: https://hub.docker.com/r/circleci/golang/ 6 | - image: circleci/golang:1.12.0 # 7 | 8 | # environment variables for the build itself 9 | environment: 10 | GO111MODULE: "on" # Enable go 1.11 modules support 11 | TEST_RESULTS: /tmp/test-results # path to where test results will be saved 12 | 13 | # steps that comprise the `build` job 14 | steps: 15 | - checkout # check out source code to working directory 16 | - run: mkdir -p $TEST_RESULTS # create the test results directory 17 | 18 | - restore_cache: # restores saved cache if no changes are detected since last run 19 | keys: 20 | - go-mod-v1-{{ checksum "go.sum" }} 21 | 22 | # Code quality checks 23 | - run: 24 | name: Run go vet 25 | command: | 26 | make vet 2>&1 | tee ${TEST_RESULTS}/go-vet.out 27 | 28 | # CircleCi's Go Docker image includes netcat 29 | # This allows polling the DB port to confirm it is open before proceeding 30 | 31 | - run: 32 | name: Run unit tests 33 | # Store the results of our tests in the $TEST_RESULTS directory 34 | command: | 35 | make test | tee ${TEST_RESULTS}/go-test.out 36 | 37 | - run: make dvizz # pull and build the project 38 | 39 | - save_cache: # Store cache in the /go/pkg directory 40 | key: go-mod-v1-{{ checksum "go.sum" }} 41 | paths: 42 | - "/go/pkg/mod" 43 | 44 | - store_artifacts: # Upload test summary for display in Artifacts 45 | path: /tmp/test-results 46 | destination: raw-test-output 47 | 48 | - store_test_results: # Upload test results for display in Test Summary 49 | path: /tmp/test-results 50 | -------------------------------------------------------------------------------- /internal/pkg/service/converters_test.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "github.com/docker/docker/api/types/swarm" 5 | . "github.com/smartystreets/goconvey/convey" 6 | "github.com/stretchr/testify/assert" 7 | "testing" 8 | ) 9 | 10 | func TestSanitizeTaskNameHavingLatestSuffix(t *testing.T) { 11 | name := sanitizeTaskName("some/name:latest@sha256.1") 12 | assert.Equal(t, "name", name) 13 | } 14 | 15 | func TestSanitizeTaskNameWithoutSuffix(t *testing.T) { 16 | name := sanitizeTaskName("some/name.1") 17 | assert.Equal(t, "name.1", name) 18 | } 19 | 20 | func TestConvertTasks(t *testing.T) { 21 | task := swarm.Task{ 22 | 23 | ID: "1", 24 | NodeID: "node-1", 25 | ServiceID: "service-1", 26 | Spec: swarm.TaskSpec{ 27 | ContainerSpec: &swarm.ContainerSpec{ 28 | Image: "image/name", 29 | }, 30 | }, 31 | Status: swarm.TaskStatus{ 32 | State: swarm.TaskStateRunning, 33 | }, 34 | Slot: 2, 35 | } 36 | 37 | arr := []swarm.Task{} 38 | arr = append(arr, task) 39 | 40 | tasks := convTasks(arr) 41 | assert.Equal(t, "name.2", tasks[0].Name) 42 | } 43 | 44 | func TestConvertNodes(t *testing.T) { 45 | nodes := make([]swarm.Node, 0) 46 | nodes = append(nodes, swarm.Node{ID: "id1", Status: swarm.NodeStatus{State: "running"}, Description: swarm.NodeDescription{Hostname: "hostname"}}) 47 | result := convNodes(nodes) 48 | 49 | Convey("Assert", t, func() { 50 | So(result, ShouldNotBeNil) 51 | So(len(result), ShouldEqual, 1) 52 | So(result[0].Name, ShouldEqual, "hostname") 53 | }) 54 | 55 | } 56 | 57 | func TestConvertTasksEmpty(t *testing.T) { 58 | tasks := make([]swarm.Task, 0) 59 | result := convTasks(tasks) 60 | Convey("Assert", t, func() { 61 | So(result, ShouldNotBeNil) 62 | So(len(result), ShouldEqual, 0) 63 | }) 64 | } 65 | 66 | func TestConvertServicesEmpty(t *testing.T) { 67 | services := make([]swarm.Service, 0) 68 | result := convServices(services) 69 | Convey("Assert", t, func() { 70 | So(result, ShouldNotBeNil) 71 | So(len(result), ShouldEqual, 0) 72 | }) 73 | } 74 | 75 | func TestConvertServicesNil(t *testing.T) { 76 | var services []swarm.Service 77 | result := convServices(services) 78 | Convey("Assert", t, func() { 79 | So(result, ShouldNotBeNil) 80 | So(len(result), ShouldEqual, 0) 81 | }) 82 | } 83 | -------------------------------------------------------------------------------- /internal/pkg/comms/mock_comms/mock_comms.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: internal/pkg/comms/server.go 3 | 4 | // Package mock_comms is a generated GoMock package. 5 | package mock_comms 6 | 7 | import ( 8 | gomock "github.com/golang/mock/gomock" 9 | reflect "reflect" 10 | ) 11 | 12 | // MockIEventServer is a mock of IEventServer interface 13 | type MockIEventServer struct { 14 | ctrl *gomock.Controller 15 | recorder *MockIEventServerMockRecorder 16 | } 17 | 18 | // MockIEventServerMockRecorder is the mock recorder for MockIEventServer 19 | type MockIEventServerMockRecorder struct { 20 | mock *MockIEventServer 21 | } 22 | 23 | // NewMockIEventServer creates a new mock instance 24 | func NewMockIEventServer(ctrl *gomock.Controller) *MockIEventServer { 25 | mock := &MockIEventServer{ctrl: ctrl} 26 | mock.recorder = &MockIEventServerMockRecorder{mock} 27 | return mock 28 | } 29 | 30 | // EXPECT returns an object that allows the caller to indicate expected use 31 | func (m *MockIEventServer) EXPECT() *MockIEventServerMockRecorder { 32 | return m.recorder 33 | } 34 | 35 | // AddEventToSendQueue mocks base method 36 | func (m *MockIEventServer) AddEventToSendQueue(data []byte) { 37 | m.ctrl.T.Helper() 38 | m.ctrl.Call(m, "AddEventToSendQueue", data) 39 | } 40 | 41 | // AddEventToSendQueue indicates an expected call of AddEventToSendQueue 42 | func (mr *MockIEventServerMockRecorder) AddEventToSendQueue(data interface{}) *gomock.Call { 43 | mr.mock.ctrl.T.Helper() 44 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddEventToSendQueue", reflect.TypeOf((*MockIEventServer)(nil).AddEventToSendQueue), data) 45 | } 46 | 47 | // InitializeEventSystem mocks base method 48 | func (m *MockIEventServer) InitializeEventSystem() { 49 | m.ctrl.T.Helper() 50 | m.ctrl.Call(m, "InitializeEventSystem") 51 | } 52 | 53 | // InitializeEventSystem indicates an expected call of InitializeEventSystem 54 | func (mr *MockIEventServerMockRecorder) InitializeEventSystem() *gomock.Call { 55 | mr.mock.ctrl.T.Helper() 56 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InitializeEventSystem", reflect.TypeOf((*MockIEventServer)(nil).InitializeEventSystem)) 57 | } 58 | 59 | // Close mocks base method 60 | func (m *MockIEventServer) Close() { 61 | m.ctrl.T.Helper() 62 | m.ctrl.Call(m, "Close") 63 | } 64 | 65 | // Close indicates an expected call of Close 66 | func (mr *MockIEventServerMockRecorder) Close() *gomock.Call { 67 | mr.mock.ctrl.T.Helper() 68 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Close", reflect.TypeOf((*MockIEventServer)(nil).Close)) 69 | } 70 | -------------------------------------------------------------------------------- /internal/pkg/service/publisher_test.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "fmt" 5 | "github.com/eriklupander/dvizz/cmd" 6 | "github.com/eriklupander/dvizz/internal/pkg/comms/mock_comms" 7 | . "github.com/eriklupander/dvizz/internal/pkg/model" 8 | "github.com/golang/mock/gomock" 9 | . "github.com/smartystreets/goconvey/convey" 10 | "testing" 11 | ) 12 | 13 | func TestProcessOneNodeAdded(t *testing.T) { 14 | ctrl := gomock.NewController(t) 15 | defer ctrl.Finish() 16 | mockEventServer := mock_comms.NewMockIEventServer(ctrl) 17 | mockEventServer.EXPECT().AddEventToSendQueue(gomock.Any()).Times(1) 18 | 19 | p := NewPublisher(mockEventServer, cmd.DefaultConfiguration()) 20 | 21 | Convey("Given", t, func() { 22 | // Start state, start with two nodes. 23 | p.lastNodes = buildDNodes([]string{"node1", "node2"}) 24 | 25 | Convey("When", func() { 26 | nextNodes := buildDNodes([]string{"node1", "node2", "node3"}) 27 | p.processNodeListing(nextNodes) 28 | Convey("Then", func() { 29 | So(len(p.lastNodes), ShouldEqual, 3) 30 | }) 31 | }) 32 | }) 33 | } 34 | 35 | func TestProcessOneNodeRemoved(t *testing.T) { 36 | ctrl := gomock.NewController(t) 37 | defer ctrl.Finish() 38 | mockEventServer := mock_comms.NewMockIEventServer(ctrl) 39 | mockEventServer.EXPECT().AddEventToSendQueue(gomock.Any()).Times(1) 40 | 41 | p := NewPublisher(mockEventServer, cmd.DefaultConfiguration()) 42 | 43 | Convey("Given", t, func() { 44 | // Start state, start with two nodes. 45 | p.lastNodes = buildDNodes([]string{"node1", "node2"}) 46 | Convey("When", func() { 47 | nextNodes := buildDNodes([]string{"node2"}) 48 | p.processNodeListing(nextNodes) 49 | Convey("Then", func() { 50 | So(len(p.lastNodes), ShouldEqual, 1) 51 | }) 52 | }) 53 | }) 54 | } 55 | 56 | func TestProcessOneNodeRemovedTwoAdded(t *testing.T) { 57 | ctrl := gomock.NewController(t) 58 | defer ctrl.Finish() 59 | mockEventServer := mock_comms.NewMockIEventServer(ctrl) 60 | mockEventServer.EXPECT().AddEventToSendQueue(gomock.Any()).Times(3) 61 | 62 | p := NewPublisher(mockEventServer, cmd.DefaultConfiguration()) 63 | 64 | Convey("Given", t, func() { 65 | 66 | // Start state, start with two nodes. 67 | p.lastNodes = buildDNodes([]string{"node1", "node2"}) 68 | Convey("When", func() { 69 | nextNodes := buildDNodes([]string{"node2", "node3", "node4"}) 70 | p.processNodeListing(nextNodes) 71 | Convey("Then", func() { 72 | So(len(p.lastNodes), ShouldEqual, 3) 73 | }) 74 | }) 75 | }) 76 | } 77 | 78 | func buildDNodes(ids []string) []DNode { 79 | nodes := make([]DNode, 0) 80 | fmt.Printf("Before iterating %v nodes.\n", len(nodes)) 81 | for index, id := range ids { 82 | fmt.Printf("Iterating %v\n", index) 83 | nodes = append(nodes, buildDNode(id)) 84 | } 85 | fmt.Printf("Returning %v nodes.\n", len(nodes)) 86 | return nodes 87 | } 88 | func buildDNode(nodeId string) DNode { 89 | return DNode{Id: nodeId, Name: nodeId + "-name", State: "running"} 90 | } 91 | -------------------------------------------------------------------------------- /internal/pkg/model/models.go: -------------------------------------------------------------------------------- 1 | /** 2 | The MIT License (MIT) 3 | 4 | Copyright (c) 2016 ErikL 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | */ 24 | package model 25 | 26 | type Identifier interface { 27 | GetId() string 28 | } 29 | 30 | type DObject interface { 31 | Equals(other Identifier) bool 32 | } 33 | 34 | type DEvent struct { 35 | Action string `json:"action"` // create or stop or update 36 | Type string `json:"type"` 37 | Dtask DTask `json:"dtask"` 38 | } 39 | 40 | type DTaskStateUpdate struct { 41 | Action string `json:"action"` // create or stop or update 42 | Type string `json:"type"` // typically task 43 | Id string `json:"id"` 44 | State string `json:"state"` 45 | } 46 | 47 | type DNode struct { 48 | Id string `json:"id"` 49 | Name string `json:"name"` 50 | State string `json:"state"` 51 | Memory string `json:"memory"` 52 | CPUs string `json:"cpus"` 53 | } 54 | 55 | type DNodeEvent struct { 56 | Action string `json:"action"` // create or stop or update 57 | Type string `json:"type"` 58 | Dnode DNode `json:"dnode"` 59 | } 60 | 61 | func (d DNode) GetId() string { 62 | return d.Id 63 | } 64 | 65 | func (d DNode) Equals(d2 Identifier) bool { 66 | // log.Printf("About to compare DNode %v with %v: result %v", d.GetId(), d2.GetId(), d.GetId() == d2.GetId()) 67 | 68 | return d.GetId() == d2.GetId() 69 | } 70 | 71 | type DTask struct { 72 | Id string `json:"id"` 73 | Name string `json:"name"` 74 | Status string `json:"status"` 75 | ServiceId string `json:"serviceId"` 76 | NodeId string `json:"nodeId"` 77 | Networks []DNetwork `json:"networks"` 78 | } 79 | 80 | type DNetwork struct { 81 | Id string `json:"id"` 82 | Name string `json:"name"` 83 | } 84 | 85 | func (d DTask) Equals(d2 Identifier) bool { 86 | // log.Printf("About to compare DTask %v with %v: result %v", d.GetId(), d2.GetId(), d.GetId() == d2.GetId()) 87 | return d.GetId() == d2.GetId() 88 | } 89 | 90 | func (d DTask) GetId() string { 91 | return d.Id 92 | } 93 | 94 | type DServiceEvent struct { 95 | Action string `json:"action"` // create or stop or destroy 96 | Type string `json:"type"` 97 | DService DService `json:"dservice"` 98 | } 99 | 100 | type DService struct { 101 | Id string `json:"id"` 102 | Name string `json:"name"` 103 | // Image string `json:"image"` 104 | } 105 | 106 | func (d DService) Equals(d2 Identifier) bool { 107 | // log.Printf("About to compare DService %v with %v: result %v", d.GetId(), d2.GetId(), d.GetId() == d2.GetId()) 108 | 109 | return d.GetId() == d2.GetId() 110 | } 111 | 112 | func (d DService) GetId() string { 113 | return d.Id 114 | } 115 | 116 | // Asserts 117 | var _ Identifier = (*DService)(nil) 118 | var _ Identifier = (*DTask)(nil) 119 | var _ Identifier = (*DNode)(nil) 120 | var _ DObject = (*DService)(nil) 121 | var _ DObject = (*DTask)(nil) 122 | var _ DObject = (*DNode)(nil) 123 | -------------------------------------------------------------------------------- /internal/pkg/service/converters.go: -------------------------------------------------------------------------------- 1 | /** 2 | The MIT License (MIT) 3 | 4 | Copyright (c) 2016 ErikL 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | */ 24 | package service 25 | 26 | import ( 27 | "fmt" 28 | "github.com/ahl5esoft/golang-underscore" 29 | "github.com/docker/docker/api/types/swarm" 30 | . "github.com/eriklupander/dvizz/internal/pkg/model" 31 | "strconv" 32 | "strings" 33 | ) 34 | 35 | func convNodes(nodes []swarm.Node) []DNode { 36 | if nodes == nil || len(nodes) == 0 { 37 | return make([]DNode, 0) 38 | } 39 | return underscore.Map(nodes, toDNode).([]DNode) 40 | } 41 | 42 | func toDNode(node swarm.Node, _ int) DNode { 43 | return DNode{Id: node.ID, State: string(node.Status.State), Name: node.Description.Hostname, CPUs: toCPU(node.Description.Resources.NanoCPUs), Memory: toMemory(node.Description.Resources.MemoryBytes)} 44 | } 45 | 46 | func convTasks(tasks []swarm.Task) []DTask { 47 | if tasks == nil || len(tasks) == 0 { 48 | return make([]DTask, 0) 49 | } 50 | dst := make([]swarm.Task, 0) 51 | underscore.Chain2(tasks).Filter(func(task swarm.Task, _ int) bool { 52 | // Make sure we only include items that has a nodeId assigned 53 | return task.NodeID != "" 54 | }).Value(&dst) 55 | 56 | u := underscore.Map(dst, func(task swarm.Task, _ int) DTask { 57 | networks := make([]DNetwork, len(task.NetworksAttachments)) 58 | for idx, na := range task.NetworksAttachments { 59 | networks[idx] = DNetwork{Id: na.Network.ID, Name: na.Network.Spec.Name} 60 | } 61 | 62 | return DTask{ 63 | Id: task.ID, 64 | Name: sanitizeTaskName(task.Spec.ContainerSpec.Image) + "." + strconv.Itoa(task.Slot), 65 | Status: string(task.Status.State), 66 | ServiceId: task.ServiceID, 67 | NodeId: task.NodeID, 68 | Networks: networks, 69 | } 70 | }) 71 | dtasks, _ := u.([]DTask) 72 | return dtasks 73 | } 74 | 75 | func sanitizeTaskName(name string) string { 76 | index := strings.Index(name, ":latest") 77 | if index > -1 { 78 | name = name[:index] 79 | } 80 | 81 | // Remove everything before any leading slash 82 | index = strings.Index(name, "/") 83 | if index > -1 && index != len(name)-1 { 84 | name = name[index+1:] 85 | } 86 | return name 87 | } 88 | 89 | func convServices(services []swarm.Service) []DService { 90 | if services == nil || len(services) == 0 { 91 | return make([]DService, 0) 92 | } 93 | u := underscore.Map(services, func(service swarm.Service, _ int) DService { 94 | return DService{ 95 | Id: service.ID, 96 | Name: service.Spec.Name, 97 | } 98 | }) 99 | return u.([]DService) 100 | } 101 | 102 | func toCPU(c int64) string { 103 | return fmt.Sprintf("%d CPU(s)", int(c/1000000000)) 104 | } 105 | 106 | func toMemory(b int64) string { 107 | const unit = 1000 108 | if b < unit { 109 | return fmt.Sprintf("%d B", b) 110 | } 111 | div, exp := int64(unit), 0 112 | for n := b / unit; n >= unit; n /= unit { 113 | div *= unit 114 | exp++ 115 | } 116 | return fmt.Sprintf("%.1f %cB", 117 | float64(b)/float64(div), "kMGTPE"[exp]) 118 | } 119 | -------------------------------------------------------------------------------- /cmd/dvizz/main.go: -------------------------------------------------------------------------------- 1 | /** 2 | The MIT License (MIT) 3 | 4 | Copyright (c) 2016 ErikL 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | */ 24 | package main 25 | 26 | import ( 27 | "fmt" 28 | "github.com/containous/flaeg" 29 | "github.com/containous/flaeg/parse" 30 | "github.com/eriklupander/dvizz/cmd" 31 | "github.com/eriklupander/dvizz/internal/pkg/comms" 32 | "github.com/eriklupander/dvizz/internal/pkg/service" 33 | docker "github.com/fsouza/go-dockerclient" 34 | "github.com/ogier/pflag" 35 | "github.com/sirupsen/logrus" 36 | fmtlog "log" 37 | "os" 38 | "reflect" 39 | "strings" 40 | "sync" 41 | ) 42 | 43 | type dvizzConfiguration struct { 44 | cmd.GlobalConfiguration 45 | } 46 | 47 | func defaultDvizzPointersConfiguration() *dvizzConfiguration { 48 | return &dvizzConfiguration{} 49 | } 50 | 51 | func defaultDvizzConfiguration() *dvizzConfiguration { 52 | return &dvizzConfiguration{ 53 | GlobalConfiguration: *cmd.DefaultConfiguration(), 54 | } 55 | } 56 | 57 | func main() { 58 | defaultConfiguration := defaultDvizzConfiguration() 59 | defaultPointersConfiguration := defaultDvizzPointersConfiguration() 60 | 61 | mainCommand := &flaeg.Command{ 62 | Name: "dvizz", 63 | Description: "dvizz main process. Set DOCKER_HOST env var if connecting to a non-local Docker Swarm cluster", 64 | Config: defaultConfiguration, 65 | DefaultPointersConfig: defaultPointersConfiguration, 66 | Run: func() error { 67 | run(defaultConfiguration) 68 | return nil 69 | }, 70 | } 71 | 72 | f := flaeg.New(mainCommand, os.Args[1:]) 73 | f.AddParser(reflect.TypeOf([]string{}), &parse.SliceStrings{}) 74 | 75 | usedCmd, err := f.GetCommand() 76 | if err != nil { 77 | fmt.Println(err) 78 | os.Exit(1) 79 | } 80 | 81 | if _, err := f.Parse(usedCmd); err != nil { 82 | if err == pflag.ErrHelp { 83 | os.Exit(0) 84 | } 85 | fmt.Printf("Error parsing command: %s\n", err) 86 | os.Exit(1) 87 | } 88 | 89 | if err := f.Run(); err != nil { 90 | fmt.Println(err) 91 | os.Exit(-1) 92 | } 93 | os.Exit(0) 94 | } 95 | 96 | func run(cfg *dvizzConfiguration) { 97 | configureLogging(cfg) 98 | logrus.Println("Starting dvizz!") 99 | dockerClient, err := docker.NewClientFromEnv() 100 | if err != nil { 101 | panic(err) 102 | } 103 | 104 | eventServer := &comms.EventServer{Client: dockerClient} 105 | go eventServer.InitializeEventSystem() 106 | 107 | publisher := service.NewPublisher(eventServer, &cfg.GlobalConfiguration) 108 | 109 | go publisher.PublishTasks(dockerClient) 110 | logrus.Infof("Initialized publishTasks, will poll every %v seconds", cfg.TaskPoll) 111 | 112 | go publisher.PublishServices(dockerClient) 113 | logrus.Infof("Initialized publishServices, will poll every %v seconds", cfg.ServicePoll) 114 | 115 | go publisher.PublishNodes(dockerClient) 116 | logrus.Infof("Initialized publishNodes, will poll every %v seconds", cfg.NodePoll) 117 | 118 | // Block... 119 | logrus.Println("Waiting at block...") 120 | 121 | wg := sync.WaitGroup{} // Use a WaitGroup to block main() exit 122 | wg.Add(1) 123 | wg.Wait() 124 | } 125 | 126 | // ConfigureLogging Configure logging for all cmd. 127 | func configureLogging(configuration *dvizzConfiguration) { 128 | // configure default log flags 129 | fmtlog.SetFlags(fmtlog.Lshortfile | fmtlog.LstdFlags) 130 | // configure log level 131 | // an explicitly defined log level always has precedence. if none is 132 | // given and debug mode is disabled, the default is ERROR, and DEBUG 133 | // otherwise. 134 | levelStr := strings.ToLower(configuration.LogLevel) 135 | 136 | if levelStr == "" { 137 | levelStr = "error" 138 | } 139 | level, err := logrus.ParseLevel(levelStr) 140 | if err != nil { 141 | fmtlog.Println("Error getting level", err) 142 | } 143 | logrus.SetLevel(level) 144 | 145 | ttyOK := false 146 | logrus.SetFormatter(&logrus.TextFormatter{ 147 | ForceColors: ttyOK, 148 | DisableColors: false, 149 | FullTimestamp: true, 150 | }) 151 | } 152 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Dvizz - A Docker Swarm Visualizer 2 | 3 | [![CircleCI](https://circleci.com/gh/eriklupander/dvizz/tree/master.svg?style=svg)](https://circleci.com/gh/eriklupander/dvizz/tree/master) 4 | 5 | Inspired by the excellent [ManoMarks/docker-swarm-visualizer](https://github.com/ManoMarks/docker-swarm-visualizer), Dvizz provides an alternate way to render your Docker Swarm nodes, services and tasks using the D3 [Force Layout](https://github.com/d3/d3-3.x-api-reference/blob/master/Force-Layout.md). 6 | 7 | ### Changes 8 | 9 | 2019-06-11 10 | - Uses [flaeg](github.com/containous/flaeg) for command-line arg parsing 11 | - Poll intervals can now be specified as program args. 12 | 13 | 2019-06-07 14 | - Uses go modules 15 | - Resolved dependency hell with Sirupsen vs sirupsen 16 | - Uses gomock instead of testify mocks 17 | - Folder structure according to idiomatic go project layout 18 | - More use of structs with pointer receivers for better dependency injection etc. 19 | - Rewritten Dockerfile, better caching of dependencies etc. 20 | 21 | ![Dvizz image](https://raw.githubusercontent.com/eriklupander/dvizz/master/dvizz1.png) 22 | 23 | Legend: 24 | - Big Gray circle: *Docker Swarm Node* 25 | - Medium size red circle: *Docker Swarm Service* 26 | - Small green circle: *Docker Swarm Task* 27 | 28 | Task states 29 | - Green: *running* 30 | - Green with red border: *preparing* 31 | - Gray: *allocated* 32 | 33 | #### Why tasks and not containers? 34 | There is an event stream one can subscribe to from the Docker Remote API that provides live updates of the state of services and containers. However, that stream only includes changes occurring on the same Swarm Node that is providing the docker.sock to the subscriber. 35 | 36 | Since dvizz requires us to run on the Swarm Manager, using /events stream would effectively make us miss all events emitted from other nodes in the Swarm. Since queries for *nodes*, *services* and *tasks* over the docker.sock returns the global state (i.e. across the whole swarm) we're basing Dvizz on tasks rather than containers. 37 | 38 | An option could be to create some kind of "dvizz agent" that would need to run on each node and subscribe to that nodes very own /events channel (given that the worker nodes actually supply that?) and then use some messaging mechanism to collect events to the "dvizz master" for propagation to the GUI. 39 | 40 | ### Building locally 41 | The Dvizz source code is of course hosted here on github. The Dvizz backend is written in Go so you'll need the Go SDK to build it yourself. 42 | 43 | Clone the repository 44 | 45 | git clone https://github.com/eriklupander/dvizz 46 | 47 | Fetch external javascript libraries using [Bower](https://bower.io/): 48 | 49 | bower install 50 | 51 | Build an linux/amd64 binary (on OS X, change "darwin" to "windows" or whatever if you're on another OS) 52 | 53 | export GOOS=linux 54 | export CGO_ENABLED=0 55 | go build -o dvizz-linux-amd64 cmd/dvizz/main.go 56 | export GOOS=darwin 57 | 58 | Or use the Makefile: 59 | 60 | make dvizz 61 | 62 | The [Dockerfile](docker/Dockerfile) builds a docker image using [multi-stage builds](https://docs.docker.com/engine/userguide/eng-image/multistage-build/). 63 | 64 | There is no need to install _go_ and _bower_ for building the docker image. 65 | 66 | ### Running on Docker Swarm mode 67 | Dvizz must be started in a Docker container running on a Swarm Manager node. I run it as a service using a _docker service create_ command. 68 | 69 | 1.) Build locally (as described above) using the Dockerfile and specify a tag, for example _someprefix/dvizz_. 70 | 71 | make build 72 | 73 | 2.) Run a _docker service create_ command. 74 | 75 | Note that you may need to remove or change the _--network_ property to suit your Docker Swarm mode setup. 76 | 77 | docker service create --constraint node.role==manager --replicas 1 \ 78 | --name dvizz -p 6969:6969 \ 79 | --mount type=bind,source=/var/run/docker.sock,target=/var/run/docker.sock \ 80 | --network my_network someprefix/dvizz 81 | 82 | 3.) Done. You should see _dvizz_ when using _docker service ls_: 83 | 84 | > docker service ls 85 | ID NAME MODE REPLICAS IMAGE 86 | 3aoic5me90aj dvizz replicated 1/1 someprefix/dvizz 87 | 88 | 4) Direct your browser to http://192.168.99.100:6969 89 | 90 | _(example running Docker Swarm locally with Docker Machine)_ 91 | 92 | ## How does it work? 93 | 94 | The heart is the Go-based backend that uses [Go Dockerclient](github.com/fsouza/go-dockerclient) to poll the Docker Remote API every second or so over the _/var/run/docker.sock_. If the backend cannot access the docker.sock on startup it will panic which typically happens when one tries to (1) run Dvizz on localhost or (2) on a non Swarm Manager node. 95 | 96 | The backend then keeps a diff of Swarm Nodes, Services and Tasks that's updated every second or so. Any new/removed tasks or state changes on running tasks are propagated to the web tier using plain ol' websockets. 97 | 98 | In the frontend, the index.html page will perform an initial load using three distinct REST endpoints for /nodes, /services and /tasks. The retrieved data is then assembled into D3 _nodes_ and _links_ using the loaded data. Subsequent swarm changes are picked up from events coming in over the web socket, updating the D3 graph(s) and for state updates the SVG DOM element styling. 99 | 100 | # Known issues 101 | - Paths rendered after inital startup are drawn on top of existing circles. 102 | - Behaviour when new Swarm Nodes are started / stopped is somewhat buggy. 103 | - D3 force layout seems to push new nodes off-screen. Swarm Nodes should have fixed positions? 104 | - The styling is more or less ugly :) 105 | 106 | # TODOs 107 | - Expand the functionality of the onclick listener. Currently, it just logs the node state to the console. 108 | - Style, fix layout etc. 109 | - Fix the line rendering. Either redraw the circles or use a custom line drawing that will "end" the line at the same offset as the r of the circle it connects to. 110 | - Introduce state rendering for the Swarm Nodes, now they always looks the same regardless of actual state. 111 | 112 | # 3rd party libraries 113 | - jQuery (https://jquery.com/) 114 | - d3js.org (https://d3js.org/) 115 | - go-underscore (https://github.com/ahl5esoft/golang-underscore) 116 | - go-dockerclient (https://github.com/fsouza/go-dockerclient) 117 | - gorilla (https://github.com/gorilla/websocket) 118 | - Underscore.js (http://underscorejs.org/) 119 | 120 | # License 121 | MIT license, see [LICENSE](https://github.com/eriklupander/dvizz/blob/master/LICENSE) 122 | -------------------------------------------------------------------------------- /internal/pkg/service/publisher.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "encoding/json" 5 | underscore "github.com/ahl5esoft/golang-underscore" 6 | "github.com/eriklupander/dvizz/cmd" 7 | "github.com/eriklupander/dvizz/internal/pkg/comms" 8 | "github.com/eriklupander/dvizz/internal/pkg/model" 9 | docker "github.com/fsouza/go-dockerclient" 10 | "time" 11 | ) 12 | 13 | type Publisher struct { 14 | filters map[string][]string 15 | lastNodes []model.DNode 16 | eventServer comms.IEventServer 17 | config *cmd.GlobalConfiguration 18 | } 19 | 20 | func NewPublisher(eventServer comms.IEventServer, config *cmd.GlobalConfiguration) *Publisher { 21 | f := make(map[string][]string) 22 | f["desired-state"] = []string{"running"} 23 | return &Publisher{filters: f, eventServer: eventServer, config: config} 24 | } 25 | 26 | /** 27 | * Will poll for Swarm Nodes changes every 5 seconds. 28 | */ 29 | func (p *Publisher) PublishNodes(client *docker.Client) { 30 | tmp, _ := client.ListNodes(docker.ListNodesOptions{}) 31 | p.lastNodes = convNodes(tmp) 32 | for { 33 | time.Sleep(time.Second * time.Duration(p.config.NodePoll)) 34 | tmp2, _ := client.ListNodes(docker.ListNodesOptions{}) 35 | currentNodes := convNodes(tmp2) 36 | p.processNodeListing(currentNodes) 37 | } 38 | } 39 | 40 | // Unit-testable 41 | func (p *Publisher) processNodeListing(currentNodes []model.DNode) { 42 | // Broadcasts stop events for nodes gone missing 43 | for _, lastNode := range p.lastNodes { 44 | isThere := underscore.Chain2(currentNodes).Any(func(other model.DObject, _ int) bool { 45 | return other.Equals(lastNode) 46 | }) 47 | if !isThere { 48 | p.eventServer.AddEventToSendQueue(marshal(model.DNodeEvent{Action: "stop", Type: "node", Dnode: lastNode})) 49 | } 50 | } 51 | 52 | // Broadcasts start events for nodes added 53 | for _, currentNode := range currentNodes { 54 | isThere := underscore.Chain2(p.lastNodes).Any(func(other model.DObject, _ int) bool { 55 | return other.Equals(currentNode) 56 | }) 57 | if !isThere { 58 | p.eventServer.AddEventToSendQueue(marshal(model.DNodeEvent{Action: "start", Type: "node", Dnode: currentNode})) 59 | } 60 | } 61 | 62 | // Broadcast status updates 63 | for _, currentNode := range currentNodes { 64 | for _, lastNode := range p.lastNodes { 65 | if currentNode.Id == lastNode.Id && currentNode.State != lastNode.State { 66 | p.eventServer.AddEventToSendQueue(marshal(model.DNodeEvent{Action: "update", Type: "node", Dnode: currentNode})) 67 | } 68 | } 69 | } 70 | 71 | p.lastNodes = currentNodes 72 | } 73 | 74 | /** 75 | * Will poll for Swarm service changes every second. 76 | */ 77 | func (p *Publisher) PublishServices(client *docker.Client) { 78 | services, _ := client.ListServices(docker.ListServicesOptions{}) 79 | lastServices := convServices(services) 80 | for { 81 | time.Sleep(time.Second * time.Duration(p.config.ServicePoll)) 82 | 83 | tmp, _ := client.ListServices(docker.ListServicesOptions{}) 84 | 85 | currentServices := convServices(tmp) 86 | 87 | // First, check if there are any items in lastTasks NOT present in currentTasks. Keep those in temp list 88 | toDelete := []model.DService{} 89 | for _, lastService := range lastServices { 90 | isThere := underscore.Chain2(currentServices).Any(func(other model.DObject, _ int) bool { 91 | return other.Equals(lastService) 92 | }) 93 | if !isThere { 94 | toDelete = append(toDelete, lastService) 95 | } 96 | } 97 | 98 | // Then, perform the opposite and populate the toAdd list 99 | toAdd := []model.DService{} 100 | for _, currentService := range currentServices { 101 | isThere := underscore.Chain2(lastServices).Any(func(other model.DObject, _ int) bool { 102 | return other.Equals(currentService) 103 | }) 104 | if !isThere { 105 | toAdd = append(toAdd, currentService) 106 | } 107 | } 108 | 109 | // Finally, serialize to JSON and push as events 110 | go underscore.Chain2(toAdd).Each(func(item model.DService, _ int) { 111 | p.eventServer.AddEventToSendQueue(marshal(&model.DServiceEvent{DService: item, Action: "start", Type: "service"})) 112 | }) 113 | go underscore.Chain2(toDelete).Each(func(item model.DService, _ int) { 114 | p.eventServer.AddEventToSendQueue(marshal(&model.DServiceEvent{DService: item, Action: "stop", Type: "service"})) 115 | }) 116 | 117 | lastServices = currentServices // Assign current as last for next iteration. 118 | } 119 | } 120 | 121 | /** Polls for task changes once per second */ 122 | func (p *Publisher) PublishTasks(client *docker.Client) { 123 | tasks, _ := client.ListTasks(docker.ListTasksOptions{Filters: p.filters}) 124 | lastTasks := convTasks(tasks) 125 | for { 126 | time.Sleep(time.Second * time.Duration(p.config.TaskPoll)) 127 | 128 | tmp, _ := client.ListTasks(docker.ListTasksOptions{Filters: p.filters}) 129 | 130 | currentTasks := convTasks(tmp) 131 | 132 | // First, check if there are any items in lastTasks NOT present in currentTasks. Keep those in temp list 133 | toDelete := []model.DTask{} 134 | for _, lastTask := range lastTasks { 135 | if !contains(currentTasks, lastTask) { 136 | toDelete = append(toDelete, lastTask) 137 | } 138 | } 139 | 140 | // Then, perform the opposite and populate the toAdd list 141 | toAdd := []model.DTask{} 142 | for _, currentTask := range currentTasks { 143 | if !contains(lastTasks, currentTask) { 144 | toAdd = append(toAdd, currentTask) 145 | } 146 | } 147 | 148 | // We also want state updates propagated to GUI (desiredState != actual state) 149 | // Do this by comparing id + state for all 150 | for _, currentTask := range currentTasks { 151 | for _, lastTask := range lastTasks { 152 | if currentTask.Id == lastTask.Id && currentTask.Status != lastTask.Status { 153 | // We have a status change for a task, 154 | go func(currentTask model.DTask) { 155 | // Wait about .5 second until sending status updates for state changes. 156 | p.eventServer.AddEventToSendQueue(marshal(&model.DTaskStateUpdate{Id: currentTask.Id, State: currentTask.Status, Action: "update", Type: "task"})) 157 | }(currentTask) 158 | } 159 | } 160 | } 161 | 162 | // Finally, serialize to JSON and push as events 163 | 164 | go underscore.Chain2(toAdd).Each(func(item model.DTask, _ int) { 165 | p.eventServer.AddEventToSendQueue(marshal(&model.DEvent{Dtask: item, Action: "start", Type: "task"})) 166 | }) 167 | go underscore.Chain2(toDelete).Each(func(item model.DTask, _ int) { 168 | p.eventServer.AddEventToSendQueue(marshal(&model.DEvent{Dtask: item, Action: "stop", Type: "task"})) 169 | }) 170 | 171 | lastTasks = currentTasks // Assign current as last for next iteration. 172 | } 173 | } 174 | 175 | //func (p *Publisher) PublishNetworks(client *docker.Client) { 176 | // networks, _ := client.ListNetworks() 177 | // 178 | // 179 | //} 180 | 181 | func marshal(intf interface{}) []byte { 182 | data, _ := json.Marshal(intf) 183 | return data 184 | } 185 | 186 | func contains(arr []model.DTask, dstruct model.Identifier) bool { 187 | return underscore.Chain2(arr).Any(func(other model.DObject, _ int) bool { 188 | return other.Equals(dstruct) 189 | }) 190 | } 191 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78/go.mod h1:LmzpDX56iTiv29bbRTIsUNlaFfuhWRQBWjQdVyAevI8= 2 | github.com/Microsoft/go-winio v0.4.12 h1:xAfWHN1IrQ0NJ9TBC0KBZoqLjzDTr1ML+4MywiUOryc= 3 | github.com/Microsoft/go-winio v0.4.12/go.mod h1:VhR8bwka0BXejwEJY73c50VrPtXAaKcyvVC4A4RozmA= 4 | github.com/ahl5esoft/golang-underscore v1.2.0 h1:zznL5uRt3byrQLdspmdGcPlbioBXoce1NeF19hv5bJk= 5 | github.com/ahl5esoft/golang-underscore v1.2.0/go.mod h1:wzX7mL/afQ0rDhFm5FsyAGcPkBAfnXk7sa3Of6qQ4ac= 6 | github.com/containerd/continuity v0.0.0-20181203112020-004b46473808 h1:4BX8f882bXEDKfWIf0wa8HRvpnBoPszJJXL+TVbBw4M= 7 | github.com/containerd/continuity v0.0.0-20181203112020-004b46473808/go.mod h1:GL3xCUCBDV3CZiTSEKksMWbLE66hEyuu9qyDOOqM47Y= 8 | github.com/containous/flaeg v1.4.1 h1:VTouP7EF2JeowNvknpP3fJAJLUDsQ1lDHq/QQTQc1xc= 9 | github.com/containous/flaeg v1.4.1/go.mod h1:wgw6PDtRURXHKFFV6HOqQxWhUc3k3Hmq22jw+n2qDro= 10 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 11 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 12 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 13 | github.com/docker/docker v0.7.3-0.20190309235953-33c3200e0d16 h1:dmUn0SuGx7unKFwxyeQ/oLUHhEfZosEDrpmYM+6MTuc= 14 | github.com/docker/docker v0.7.3-0.20190309235953-33c3200e0d16/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= 15 | github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ= 16 | github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec= 17 | github.com/docker/go-units v0.4.0 h1:3uh0PgVws3nIA0Q+MwDC8yjEPf9zjRfZZWXZYDct3Tw= 18 | github.com/docker/go-units v0.4.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= 19 | github.com/fsouza/go-dockerclient v1.4.1 h1:W7wuJ3IB48WYZv/UBk9dCTIb9oX805+L9KIm65HcUYs= 20 | github.com/fsouza/go-dockerclient v1.4.1/go.mod h1:PUNHxbowDqRXfRgZqMz1OeGtbWC6VKyZvJ99hDjB0qs= 21 | github.com/gogo/protobuf v1.2.1 h1:/s5zKNz0uPFCZ5hddgPdo2TK2TVrUNMn0OOX8/aZMTE= 22 | github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= 23 | github.com/golang/mock v1.3.1 h1:qGJ6qTW+x6xX/my+8YUVl4WNpX9B7+/l2tRsHGZ7f2s= 24 | github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= 25 | github.com/golang/protobuf v1.3.0 h1:kbxbvI4Un1LUWKxufD+BiE6AEExYYgkQLQmLFqA1LFk= 26 | github.com/golang/protobuf v1.3.0/go.mod h1:Qd/q+1AKNOZr9uGQzbzCmRO6sUih6GTPZv6a1/R87v0= 27 | github.com/google/go-cmp v0.3.0 h1:crn/baboCvb5fXaQ0IJ1SGTsTVrWpDsCWC8EGETZijY= 28 | github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 29 | github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= 30 | github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= 31 | github.com/gorilla/mux v1.7.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= 32 | github.com/gorilla/websocket v1.4.0 h1:WDFjx/TMzVgy9VdMMQi2K2Emtwi2QcUQsztZ/zLaH/Q= 33 | github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= 34 | github.com/ijc/Gotty v0.0.0-20170406111628-a8b993ba6abd h1:anPrsicrIi2ColgWTVPk+TrN42hJIWlfPHSBP9S0ZkM= 35 | github.com/ijc/Gotty v0.0.0-20170406111628-a8b993ba6abd/go.mod h1:3LVOLeyx9XVvwPgrt2be44XgSqndprz1G18rSk8KD84= 36 | github.com/json-iterator/go v1.1.6 h1:MrUvLMLTMxbqFJ9kzlvat/rYZqZnW3u4wkLzWTaFwKs= 37 | github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= 38 | github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= 39 | github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= 40 | github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= 41 | github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 42 | github.com/konsorten/go-windows-terminal-sequences v1.0.1 h1:mweAR1A6xJ3oS2pRaGiHgQ4OO8tzTaLawm8vnODuwDk= 43 | github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= 44 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= 45 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 46 | github.com/modern-go/reflect2 v1.0.1 h1:9f412s+6RmYXLWZSEzVVgPGK7C2PphHj5RJrvfx9AWI= 47 | github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= 48 | github.com/ogier/pflag v0.0.1 h1:RW6JSWSu/RkSatfcLtogGfFgpim5p7ARQ10ECk5O750= 49 | github.com/ogier/pflag v0.0.1/go.mod h1:zkFki7tvTa0tafRvTBIZTvzYyAu6kQhPZFnshFFPE+g= 50 | github.com/opencontainers/go-digest v1.0.0-rc1 h1:WzifXhOVOEOuFYOJAW6aQqW0TooG2iki3E3Ii+WN7gQ= 51 | github.com/opencontainers/go-digest v1.0.0-rc1/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s= 52 | github.com/opencontainers/image-spec v1.0.1 h1:JMemWkRwHx4Zj+fVxWoMCFm/8sYGGrUVojFA6h/TRcI= 53 | github.com/opencontainers/image-spec v1.0.1/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0= 54 | github.com/opencontainers/runc v0.1.1 h1:GlxAyO6x8rfZYN9Tt0Kti5a/cP41iuiO2yYT0IJGY8Y= 55 | github.com/opencontainers/runc v0.1.1/go.mod h1:qT5XzbpPznkRYVz/mWwUaVBUv2rmF59PVA73FjuZG0U= 56 | github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= 57 | github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 58 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 59 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 60 | github.com/sirupsen/logrus v1.3.0 h1:hI/7Q+DtNZ2kINb6qt/lS+IyXnHQe9e90POfeewL/ME= 61 | github.com/sirupsen/logrus v1.3.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= 62 | github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM= 63 | github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= 64 | github.com/smartystreets/goconvey v0.0.0-20190330032615-68dc04aab96a h1:pa8hGb/2YqsZKovtsgrwcDH1RZhVbTKCjLp47XpqCDs= 65 | github.com/smartystreets/goconvey v0.0.0-20190330032615-68dc04aab96a/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= 66 | github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4= 67 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 68 | github.com/stretchr/objx v0.1.1 h1:2vfRuCMp5sSVIDSqO8oNnWJq7mPa6KVP3iPIwFBuy8A= 69 | github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 70 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 71 | github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= 72 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 73 | golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= 74 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2 h1:VklqNMn3ovrHsnt90PveolxSbWFaJdECFbxSq0Mqo2M= 75 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 76 | golang.org/x/net v0.0.0-20180906233101-161cd47e91fd h1:nTDtHvHSdCn1m6ITfMRqtOd/9+7a3s8RBNOZ3eYZzJA= 77 | golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 78 | golang.org/x/net v0.0.0-20190311183353-d8887717615a h1:oWX7TPOiFAMXLq8o0ikBYfCJVlRHBcsciT5bXOrH628= 79 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 80 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f h1:wMNYb4v58l5UBM7MYRLPG6ZhfOqbKu7X5eyFl8ZhKvA= 81 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 82 | golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 83 | golang.org/x/sync v0.0.0-20190423024810-112230192c58 h1:8gQV6CLnAEikrhgkHFbMAEhagSSnXWGV915qUMm9mrU= 84 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 85 | golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 86 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a h1:1BGLXjeY4akVXGgbC9HugT3Jv3hCI0z56oJR5vAMgBU= 87 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 88 | golang.org/x/sys v0.0.0-20190310054646-10058d7d4faa h1:lqti/xP+yD/6zH5TqEwx2MilNIJY5Vbc6Qr8J3qyPIQ= 89 | golang.org/x/sys v0.0.0-20190310054646-10058d7d4faa/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 90 | golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= 91 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 92 | golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 93 | golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 94 | golang.org/x/tools v0.0.0-20190425150028-36563e24a262 h1:qsl9y/CJx34tuA7QCPNp86JNJe4spst6Ff8MjvPUdPg= 95 | golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= 96 | google.golang.org/genproto v0.0.0-20180831171423-11092d34479b/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= 97 | gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= 98 | -------------------------------------------------------------------------------- /internal/pkg/comms/server.go: -------------------------------------------------------------------------------- 1 | package comms 2 | 3 | import ( 4 | "encoding/json" 5 | docker "github.com/fsouza/go-dockerclient" 6 | "github.com/gorilla/websocket" 7 | "github.com/sirupsen/logrus" 8 | "net/http" 9 | "os" 10 | "os/signal" 11 | "sort" 12 | "strconv" 13 | "syscall" 14 | "time" 15 | ) 16 | 17 | type IEventServer interface { 18 | AddEventToSendQueue(data []byte) 19 | InitializeEventSystem() 20 | Close() 21 | } 22 | 23 | type EventServer struct { 24 | upgrader websocket.Upgrader 25 | // Create unbuffered channel 26 | eventQueue chan []byte 27 | // Pointer to docker client 28 | Client *docker.Client 29 | // Web Socket connection registry (in case we have > 1 dashboards driven by this backend) 30 | connectionRegistry []*websocket.Conn 31 | } 32 | 33 | func (server *EventServer) init() { 34 | server.upgrader = websocket.Upgrader{} // use default options 35 | server.connectionRegistry = make([]*websocket.Conn, 0) 36 | } 37 | 38 | func (server *EventServer) AddEventToSendQueue(data []byte) { 39 | server.eventQueue <- data 40 | } 41 | 42 | func (server *EventServer) InitializeEventSystem() { 43 | if server.Client == nil { 44 | panic("Cannot initialize event server, Docker client not assigned.") 45 | } 46 | 47 | logrus.Info("Starting WebSocket server at port 6969") 48 | 49 | http.HandleFunc("/start", server.registerChannel) 50 | http.HandleFunc("/nodes", server.getNodes) 51 | http.HandleFunc("/services", server.getServices) 52 | http.HandleFunc("/tasks", server.getTasks) 53 | http.HandleFunc("/networks", server.getNetworks) 54 | http.HandleFunc("/networkreport", server.getNetworkReport) 55 | http.HandleFunc("/servicereport", server.getServiceReport) 56 | http.HandleFunc("/containers", server.getContainers) 57 | http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 58 | http.ServeFile(w, r, "static/"+r.URL.Path[1:]) 59 | }) 60 | 61 | server.eventQueue = make(chan []byte, 100) 62 | go server.startEventSender() 63 | go server.pinger() 64 | 65 | handleSigterm(func() { 66 | server.Close() 67 | }) 68 | 69 | logrus.Info("Starting WebSocket server") 70 | err := http.ListenAndServe(":6969", nil) 71 | if err != nil { 72 | panic("ListenAndServe: " + err.Error()) 73 | } 74 | } 75 | 76 | func (server *EventServer) Close() { 77 | deletes := make([]int, 0) 78 | for index, wsConn := range server.connectionRegistry { 79 | adr := wsConn.RemoteAddr().String() 80 | wsConn.Close() 81 | deletes = append(deletes, index) 82 | logrus.Info("Gracefully shut down websocket connection to " + adr) 83 | } 84 | for _, deleteMe := range deletes { 85 | server.connectionRegistry = remove(server.connectionRegistry, deleteMe) 86 | } 87 | } 88 | 89 | func (server *EventServer) getNodes(w http.ResponseWriter, r *http.Request) { 90 | nodes, err := server.Client.ListNodes(docker.ListNodesOptions{}) 91 | if err != nil { 92 | panic(err) 93 | } 94 | json, _ := json.Marshal(&nodes) 95 | writeResponse(w, json) 96 | } 97 | 98 | func (server *EventServer) getServices(w http.ResponseWriter, r *http.Request) { 99 | services, err := server.Client.ListServices(docker.ListServicesOptions{}) 100 | if err != nil { 101 | panic(err) 102 | } 103 | json, _ := json.Marshal(&services) 104 | writeResponse(w, json) 105 | } 106 | 107 | func (server *EventServer) getTasks(w http.ResponseWriter, r *http.Request) { 108 | tasks, err := server.Client.ListTasks(docker.ListTasksOptions{}) 109 | if err != nil { 110 | panic(err) 111 | } 112 | 113 | json, _ := json.Marshal(&tasks) 114 | writeResponse(w, json) 115 | } 116 | 117 | func (server *EventServer) getNetworks(w http.ResponseWriter, r *http.Request) { 118 | opts := docker.NetworkFilterOpts{ 119 | "scope": map[string]bool{ 120 | "swarm": true, 121 | }, 122 | } 123 | networks, err := server.Client.FilteredListNetworks(opts) 124 | if err != nil { 125 | panic(err) 126 | } 127 | json, _ := json.Marshal(&networks) 128 | writeResponse(w, json) 129 | } 130 | 131 | func (server *EventServer) getContainers(w http.ResponseWriter, r *http.Request) { 132 | containers, err := server.Client.ListContainers(docker.ListContainersOptions{All: false}) 133 | if err != nil { 134 | panic(err) 135 | } 136 | json, _ := json.Marshal(&containers) 137 | writeResponse(w, json) 138 | } 139 | 140 | func (server *EventServer) getNetworkReport(w http.ResponseWriter, r *http.Request) { 141 | 142 | opts := docker.NetworkFilterOpts{ 143 | "scope": map[string]bool{ 144 | "swarm": true, 145 | }, 146 | } 147 | networks, err := server.Client.FilteredListNetworks(opts) 148 | if err != nil { 149 | panic(err) 150 | } 151 | 152 | services, err := server.Client.ListServices(docker.ListServicesOptions{}) 153 | if err != nil { 154 | panic(err) 155 | } 156 | 157 | sm := make(map[string]string) 158 | for _, s := range services { 159 | sm[s.ID] = s.Spec.Name 160 | } 161 | 162 | report := make([]NetworkReport, 0) 163 | 164 | for _, n := range networks { 165 | nr := NetworkReport{ID: n.ID, Name: n.Name, Services: make([]string, 0)} 166 | for _, s := range services { 167 | for _, na := range s.Spec.TaskTemplate.Networks { 168 | if na.Target == n.ID { 169 | nr.Services = append(nr.Services, sm[s.ID]) 170 | } 171 | } 172 | } 173 | report = append(report, nr) 174 | } 175 | 176 | data, _ := json.Marshal(report) 177 | 178 | writeResponse(w, data) 179 | } 180 | 181 | func (server *EventServer) getServiceReport(w http.ResponseWriter, r *http.Request) { 182 | 183 | opts := docker.NetworkFilterOpts{ 184 | "scope": map[string]bool{ 185 | "swarm": true, 186 | }, 187 | } 188 | networks, err := server.Client.FilteredListNetworks(opts) 189 | if err != nil { 190 | panic(err) 191 | } 192 | 193 | services, err := server.Client.ListServices(docker.ListServicesOptions{}) 194 | if err != nil { 195 | panic(err) 196 | } 197 | 198 | sm := make(map[string]string) 199 | for _, s := range services { 200 | sm[s.ID] = s.Spec.Name 201 | } 202 | 203 | report := make([]NetworkReport, 0) 204 | 205 | for _, n := range networks { 206 | nr := NetworkReport{ID: n.ID, Name: n.Name, Services: make([]string, 0)} 207 | for _, s := range services { 208 | for _, na := range s.Spec.TaskTemplate.Networks { 209 | if na.Target == n.ID { 210 | nr.Services = append(nr.Services, sm[s.ID]) 211 | } 212 | } 213 | } 214 | report = append(report, nr) 215 | } 216 | 217 | sReport := make([]ServiceReport, 0) 218 | 219 | for _, s := range services { 220 | sr := ServiceReport{Name: s.Spec.Name, Networks: make([]*NetworkReport, 0)} 221 | 222 | for _, na := range s.Spec.TaskTemplate.Networks { 223 | sr.Networks = append(sr.Networks, find(na.Target, report)) 224 | } 225 | sReport = append(sReport, sr) 226 | } 227 | 228 | data, _ := json.Marshal(sReport) 229 | 230 | writeResponse(w, data) 231 | } 232 | 233 | func find(networkId string, reports []NetworkReport) *NetworkReport { 234 | for _, nr := range reports { 235 | if nr.ID == networkId { 236 | return &nr 237 | } 238 | } 239 | return nil 240 | } 241 | 242 | type ServiceReport struct { 243 | Name string 244 | Networks []*NetworkReport 245 | } 246 | 247 | type NetworkReport struct { 248 | ID string 249 | Name string 250 | Services []string 251 | } 252 | 253 | func (server *EventServer) startEventSender() { 254 | logrus.Infof("Starting event sender goroutine...") 255 | for { 256 | data := <-server.eventQueue 257 | logrus.Debugf("About to send event: " + string(data)) 258 | server.broadcastDEvent(data) 259 | time.Sleep(time.Millisecond * 50) 260 | } 261 | } 262 | 263 | func (server *EventServer) pinger() { 264 | for { 265 | time.Sleep(time.Second * 5) 266 | deletes := make([]int, 0) 267 | for index, wsConn := range server.connectionRegistry { 268 | err := wsConn.WriteMessage(1, []byte(`{"msg":"PING"}`)) 269 | if err != nil { 270 | // Detected disconnected channel. Need to clean up. 271 | err := wsConn.Close() 272 | if err != nil { 273 | logrus.Warnf("problem closing connection: %v", err) 274 | } 275 | deletes = append(deletes, index) 276 | } 277 | } 278 | 279 | // to mitigate problems with indicies not being updated when deleting multiple entries, we sort deleteMe 280 | // DESC so the highest is deleted first. 281 | sort.Slice(deletes, func(i, j int) bool { 282 | return deletes[i] > deletes[j] 283 | }) 284 | 285 | for _, deleteMe := range deletes { 286 | server.connectionRegistry = remove(server.connectionRegistry, deleteMe) 287 | logrus.Infof("Removed stale connection, new count is %v", len(server.connectionRegistry)) 288 | } 289 | } 290 | } 291 | 292 | func (server *EventServer) broadcastDEvent(data []byte) { 293 | deletes := make([]int, 0) 294 | for index, wsConn := range server.connectionRegistry { 295 | err := wsConn.WriteMessage(1, data) 296 | if err != nil { 297 | // Detected disconnected channel. Need to clean up. 298 | logrus.Errorf("Could not write to channel: %v", err) 299 | wsConn.Close() 300 | deletes = append(deletes, index) 301 | } 302 | } 303 | 304 | // to mitigate problems with indicies not being updated when deleting multiple entries, we sort deleteMe 305 | // DESC so the highest is deleted first. 306 | sort.Slice(deletes, func(i, j int) bool { 307 | return deletes[i] > deletes[j] 308 | }) 309 | 310 | for _, deleteMe := range deletes { 311 | server.connectionRegistry = remove(server.connectionRegistry, deleteMe) 312 | } 313 | } 314 | 315 | func remove(s []*websocket.Conn, i int) []*websocket.Conn { 316 | s[i] = s[len(s)-1] 317 | // We do not need to put s[i] at the end, as it will be discarded anyway 318 | return s[:len(s)-1] 319 | } 320 | 321 | func (server *EventServer) registerChannel(w http.ResponseWriter, r *http.Request) { 322 | if r.URL.Path != "/start" { 323 | http.Error(w, "Not found", 404) 324 | return 325 | } 326 | if r.Method != "GET" { 327 | http.Error(w, "Method not allowed", 405) 328 | return 329 | } 330 | header := make(map[string][]string) 331 | 332 | header["Access-Control-Allow-Origin"] = []string{"*"} 333 | c, err := server.upgrader.Upgrade(w, r, header) 334 | if err != nil { 335 | logrus.Errorf("upgrade: %v", err) 336 | return 337 | } 338 | server.connectionRegistry = append(server.connectionRegistry, c) 339 | logrus.Infof("A new subscriber connected from %v. Current number of subscribers are: %v", c.RemoteAddr().String(), len(server.connectionRegistry)) 340 | } 341 | 342 | func writeResponse(w http.ResponseWriter, json []byte) { 343 | w.Header().Set("Content-Type", "application/json") 344 | w.Header().Set("Content-Length", strconv.Itoa(len(json))) 345 | w.Header().Set("Access-Control-Allow-Origin", "*") 346 | w.WriteHeader(http.StatusOK) 347 | w.Write(json) 348 | } 349 | 350 | // Handles Ctrl+C or most other means of "controlled" shutdown gracefully. Invokes the supplied func before exiting. 351 | func handleSigterm(handleExit func()) { 352 | c := make(chan os.Signal, 1) 353 | signal.Notify(c, os.Interrupt) 354 | signal.Notify(c, syscall.SIGTERM) 355 | go func() { 356 | <-c 357 | handleExit() 358 | os.Exit(1) 359 | }() 360 | } 361 | -------------------------------------------------------------------------------- /static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 747 | 748 | 749 | --------------------------------------------------------------------------------