├── .dockerignore ├── .github └── workflows │ └── docker.yml ├── .gitignore ├── .prettierrc ├── .vscode └── extensions.json ├── Dockerfile ├── LICENSE ├── README.md ├── dev.stack.yml ├── dev ├── TODO.md ├── nginx.stack.yml ├── test.registry.mjs └── test.scaleup.mjs ├── docker-compose.dev.yml ├── docker-compose.yml ├── package.json ├── readme └── screenshot.png ├── scripts └── copy-files.mjs ├── src ├── agent.ts ├── index.ts ├── lib │ ├── alpine.ts │ ├── api.ts │ ├── cron.ts │ ├── dns.ts │ ├── docker.ts │ ├── exec.ts │ ├── fetch.ts │ ├── misc.ts │ └── registry.ts ├── manager.ts ├── tasks │ ├── agent.ts │ ├── manager.ts │ ├── task.autoscale.ts │ ├── task.autoupdate.ts │ └── task.subnet.ts ├── types.ts └── www │ ├── favicon.ico │ ├── index.html │ ├── js │ ├── elements.ts │ ├── get.ts │ ├── index.ts │ ├── misc.ts │ ├── snackbar.ts │ └── upload.ts │ └── style.css ├── stack.dev.yml ├── tsconfig.base.json ├── tsconfig.browser.json └── tsconfig.node.json /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | package-lock.json -------------------------------------------------------------------------------- /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | # read: https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions 2 | 3 | name: Docker 4 | 5 | on: [push, pull_request] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - name: Checkout repository 13 | uses: actions/checkout@v2 14 | 15 | - name: Use Node.js 16 | uses: actions/setup-node@v2 17 | with: 18 | node-version: 14.x 19 | 20 | - name: Install Dependencies 21 | run: npm install 22 | 23 | - name: Build Packages 24 | run: npm run build 25 | 26 | - name: Build Docker Image 27 | uses: docker/build-push-action@v2 28 | with: 29 | context: . 30 | push: false 31 | tags: visualizer:latest 32 | 33 | # - name: Run Tests 34 | # run: npm test 35 | 36 | # - name: Run Prettier 37 | # run: npm run format 38 | 39 | # - name: Run ESLint 40 | # run: npm run lint 41 | 42 | # - name: Upload coverage to Codecov 43 | # uses: codecov/codecov-action@v1 44 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | package-lock.json 3 | dist -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | "@yandeu/prettier-config" -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["esbenp.prettier-vscode"] 3 | } 4 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:16.3-alpine3.13 2 | 3 | WORKDIR /usr/src/app 4 | 5 | # for the health check and other requests 6 | RUN apk add curl 7 | 8 | # https://stackoverflow.com/a/43594065 9 | ENV DOCKERVERSION=20.10.7 10 | RUN curl -fsSLO https://download.docker.com/linux/static/stable/x86_64/docker-${DOCKERVERSION}.tgz \ 11 | && tar xzvf docker-${DOCKERVERSION}.tgz --strip 1 -C /usr/local/bin docker/docker \ 12 | && rm docker-${DOCKERVERSION}.tgz 13 | 14 | COPY package*.json ./ 15 | COPY dist ./dist 16 | 17 | RUN npm install --only=prod 18 | 19 | CMD ["npm", "start"] 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Docker Swarm Visualizer License 2 | 3 | Copyright (C) 2021, Yannick Deubel (https://github.com/yandeu) 4 | All rights reserved. 5 | 6 | This file is part of the "Docker Swarm Visualizer" project. 7 | 8 | 1. DEFINITIONS 9 | a) "Licensor" is Yannick Deubel (https://github.com/yandeu). 10 | b) "Software" is the software known as "Docker Swarm Visualizer". 11 | 12 | 2. RESTRICTIONS 13 | You are NOT permitted to: 14 | a) Edit, alter, modify, adapt, translate or otherwise change the whole or any 15 | part of the Software without the express permission of the Licensor. 16 | b) Decompile, disassemble or reverse engineer the Software or attempt to do 17 | any such things. 18 | c) Reproduce, copy, distribute, resell or otherwise use the whole or any part 19 | of the Software for any commercial or non-commercial purpose. 20 | d) Disable, modify or hide notifications sent by the Software. 21 | 22 | 3. OWNERSHIP 23 | The Software, copyright, and other intellectual property rights of whatever 24 | nature in the Software, including any modifications made thereto are and shall 25 | remain the property of the Licensor. 26 | 27 | 4. WARRANTY DISCLAIMER 28 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 29 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 30 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 31 | DISCLAIMED. 32 | 33 | 5. LIMITATION OF LIABILITY 34 | IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 35 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 36 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 37 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 38 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 39 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 40 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Docker Swarm Visualizer 2 | 3 | 4 | screenshot 5 | 6 | 7 | ## 🥳 New 8 | 9 | If you like this Docker Swarm Visualizer, 10 | you should also check out the new [Visualizer written in Rust](https://hub.docker.com/r/yandeu/visualizer-rs). 11 | 12 | ## Features / Tasks 13 | 14 | - 📺 **Real-Time Monitoring** 15 | Monitor your Swarm Cluster in Real-Time. 16 | 17 | - 🎚️ **Vertical Service Autoscaler** (beta) 18 | Automatically scale your services up and down based on CPU usage. 19 | 20 | - 📦 **Automated Image Updates** (beta) 21 | Automatically pulls the latest images from your Registry. 22 | 23 | - 🚀 **Drag and Drop Deployment** (beta) 24 | Easily deploy Stacks and Secrets via Drag and Drop. 25 | 26 | - 🧼 **Auto Clean your Swarm** (in planning) 27 | Remove unused Images and dangling Containers. 28 | 29 | - 🏷️ **Auto Subnet Labeling** (beta) 30 | Detects in which subnet your node is to better spread your containers. 31 | 32 | - 🪝 **Webhooks** (in planning) 33 | Send useful logs/events to your own servers. 34 | 35 | ## Links 36 | 37 | - [`github.com`](https://github.com/yandeu/docker-swarm-visualizer) 38 | - [`hub.docker.com`](https://hub.docker.com/r/yandeu/visualizer) 39 | 40 | ## Video 41 | 42 | Quick introduction [Video on YouTube](https://youtu.be/IEIJm5h7uQs). 43 | 44 | ## Info 45 | 46 | Minimum Docker API = 1.41 (Run `docker version` to check your API version) 47 | 48 | ## Getting Started 49 | 50 | 1. Make sure you are using docker in swarm mode (`docker swarm init`). 51 | ```markdown 52 | # make sure the required ports are open 53 | TCP port 2377 for cluster management communications 54 | TCP and UDP port 7946 for communication among nodes 55 | UDP port 4789 for overlay network traffic 56 | ``` 57 | 58 | 2. Make sure you can access your swarm on port **9500/tcp**. 59 | 60 | 3. Make sure the nodes can communicate with each other on port **9501/tcp**. 61 | 62 | 4. Deploy the Visualizer 63 | 64 | ```bash 65 | # Download the Stack File (from GitHub) 66 | curl -L https://git.io/JcGlt -o visualizer.stack.yml 67 | 68 | # Deploy the Stack 69 | docker stack deploy -c visualizer.stack.yml visualizer 70 | ``` 71 | 72 | 5. Open the Visualizer Dashboard 73 | [`http://127.0.0.1:9500`](http://127.0.0.1:9500) or [`http://[NODE_IP]:9500`](http://[NODE_IP]:9500) 74 | 75 | ## Tasks 76 | 77 | All tasks are either in Beta or in Development. 78 | 79 | ### Drag and Drop Deployment 80 | 81 | Simply click on `⇪` and drag your files (stacks or secrets) into the Square. 82 | 83 | ### Autoscaler 84 | 85 | To enable and use the autoscaler add the env and labels below to your services: 86 | 87 | ```yml 88 | services: 89 | manager: 90 | environment: 91 | - VISUALIZER_TASK=true 92 | - VISUALIZER_TASK_AUTOSCALE=true 93 | 94 | agent: 95 | environment: 96 | - VISUALIZER_TASK=true 97 | - VISUALIZER_TASK_AUTOSCALE=true 98 | 99 | your_app: 100 | labels: 101 | - visualizer.autoscale.min=1 102 | - visualizer.autoscale.max=5 103 | - visualizer.autoscale.up.cpu=0.2 104 | - visualizer.autoscale.down.cpu=0.1 105 | ``` 106 | 107 | ### Image Updates 108 | 109 | _For now, you can only update public images from docker hub. I will add support for private images and the GitHub's container registry soon._ 110 | 111 | To enable and use the auto updates add the env and labels below to your services: 112 | 113 | ```yml 114 | services: 115 | manager: 116 | environment: 117 | - VISUALIZER_TASK=true 118 | - VISUALIZER_TASK_AUTOUPDATE=true 119 | # Check for an update every 6th hour (see: https://crontab.guru/) 120 | - VISUALIZER_TASK_AUTOUPDATE_CRON="0 */6 * * *" 121 | 122 | agent: 123 | environment: 124 | - (nothing else to add here) 125 | 126 | your_app: 127 | labels: 128 | - visualizer.autoupdate=true 129 | ``` 130 | 131 | ### Subnet Labeling 132 | 133 | To enable and use the subnet labeling add the env and labels below to your services: 134 | 135 | ```yml 136 | services: 137 | manager: 138 | environment: 139 | - (nothing else to add here) 140 | 141 | agent: 142 | environment: 143 | - VISUALIZER_TASK=true 144 | - VISUALIZER_TASK_SUBNET=true 145 | labels: 146 | # Adjust the labels below to your subnet. 147 | # In this example are 3 subnets in 3 different availability zones, which I call az1, az2 and az3. 148 | # az1 in subnet 172.31.0.0/20, az2 in 172.31.16.0/20 and az3 in 172.31.32.0/20. 149 | # You can name your subnets as you want. 150 | - visualizer.subnet.az1=172.31.0.0/20 151 | - visualizer.subnet.az2=172.31.16.0/20 152 | - visualizer.subnet.az3=172.31.32.0/20 153 | 154 | # for testing locally 155 | - visualizer.subnet.local=192.168.0.0/16 156 | 157 | your_app: 158 | deploy: 159 | placement: 160 | preferences: 161 | # spread this service out over the "subnet" label 162 | - spread: node.labels.subnet 163 | ``` 164 | 165 | ### Webhooks 166 | 167 | _Nothing here yet._ 168 | -------------------------------------------------------------------------------- /dev.stack.yml: -------------------------------------------------------------------------------- 1 | version: '3.9' 2 | 3 | services: 4 | tools: 5 | image: yandeu/dev-tools:dev 6 | deploy: 7 | placement: 8 | preferences: 9 | - spread: node.labels.subnet 10 | replicas: 2 11 | update_config: 12 | parallelism: 2 13 | order: start-first 14 | ports: 15 | - 3000:3000 16 | labels: 17 | - visualizer.autoscale.min=1 18 | - visualizer.autoscale.max=5 19 | - visualizer.autoscale.up.cpu=0.2 20 | - visualizer.autoscale.down.cpu=0.1 21 | 22 | - visualizer.autoupdate=true 23 | -------------------------------------------------------------------------------- /dev/TODO.md: -------------------------------------------------------------------------------- 1 | When Scaling Up, check `docker service ps SERVICE` for: 2 | 3 | - `CURRENT STATE` = Pending 4 | - `ERROR` = "no suitable node (insufficien…" `/no suitable node/gm` 5 | 6 | If this error appears, scale down again and notify the user (Webhook, Dashboard, Mail) with message `{ currentNodes: x, desiredNodes: x+1 }` 7 | 8 | Much easier is just to not use `deploy.resources.reservations[cpus/memory]` and let a auto-scaling group (AWS) handle the up and down scaling of additional worker nodes. 9 | -------------------------------------------------------------------------------- /dev/nginx.stack.yml: -------------------------------------------------------------------------------- 1 | version: '3.9' 2 | 3 | services: 4 | nginx: 5 | image: nginx:latest 6 | deploy: 7 | replicas: 2 8 | update_config: 9 | parallelism: 2 10 | order: start-first 11 | ports: 12 | - 8080:80 13 | labels: 14 | - visualizer.autoscale.min=1 15 | - visualizer.autoscale.max=5 16 | - visualizer.autoscale.up.cpu=0.2 17 | - visualizer.autoscale.down.cpu=0.1 18 | - visualizer.autoupdate=true 19 | -------------------------------------------------------------------------------- /dev/test.registry.mjs: -------------------------------------------------------------------------------- 1 | import { Registry } from '../dist/lib/registry.js' 2 | 3 | const image = 'library/ubuntu' 4 | 5 | const registry = new Registry('DOCKER') 6 | 7 | await registry.requestImage(image) 8 | 9 | const auth = await registry.Auth() 10 | 11 | console.log(await registry.getDigest(auth)) 12 | console.log(await registry.getManifest(auth)) 13 | console.log(await registry.get(auth, `${image}/tags/list`)) 14 | -------------------------------------------------------------------------------- /dev/test.scaleup.mjs: -------------------------------------------------------------------------------- 1 | import { docker } from '../dist/lib/docker.js' 2 | import { executeTask } from '../dist/tasks/task.autoscale.js' 3 | 4 | const serviceID = 'oqjuq4qzfmw9' 5 | 6 | const service = await docker(`services/${serviceID}`) 7 | const replicas = service?.Spec?.Mode?.Replicated?.Replicas 8 | 9 | if (typeof replicas === 'number' && replicas > 0) { 10 | let ratio 11 | 12 | ratio = 1 13 | if (ratio - 0.5 > 0) executeTask(service, { name: 'SCALE_UP', autoscaler: { min: 1, max: 20 } }) 14 | } 15 | -------------------------------------------------------------------------------- /docker-compose.dev.yml: -------------------------------------------------------------------------------- 1 | version: '3.9' 2 | 3 | services: 4 | manager: 5 | environment: 6 | - VISUALIZER_TYPE=manager 7 | 8 | - VISUALIZER_TASK=true 9 | - VISUALIZER_TASK_AUTOSCALE=true 10 | - VISUALIZER_TASK_AUTOUPDATE=true 11 | - VISUALIZER_TASK_AUTOUPDATE_CRON="0 */6 * * *" # see: https://crontab.guru/ 12 | 13 | image: 127.0.0.1:5000/visualizer:latest 14 | volumes: 15 | - /var/run/docker.sock:/var/run/docker.sock 16 | # - /var/lib/docker/volumes:/var/lib/docker/volumes # not yet used 17 | networks: 18 | - agent_network 19 | deploy: 20 | mode: replicated 21 | replicas: 1 22 | placement: 23 | constraints: [node.role == manager] 24 | labels: 25 | - visualizer.manager 26 | healthcheck: 27 | test: curl -f http://localhost:3500/healthcheck || exit 1 28 | interval: 10s 29 | timeout: 2s 30 | retries: 3 31 | start_period: 5s 32 | ports: 33 | - '9500:3500' 34 | 35 | agent: 36 | environment: 37 | - VISUALIZER_TYPE=agent 38 | 39 | - VISUALIZER_TASK=true 40 | - VISUALIZER_TASK_AUTOSCALE=true 41 | - VISUALIZER_TASK_SUBNET=true 42 | 43 | image: 127.0.0.1:5000/visualizer:latest 44 | volumes: 45 | - /var/run/docker.sock:/var/run/docker.sock 46 | # - /var/lib/docker/volumes:/var/lib/docker/volumes # not yet used 47 | networks: 48 | - agent_network 49 | # ports: 50 | # - '9501:9501' # dev only! 51 | deploy: 52 | mode: global 53 | placement: 54 | constraints: [node.platform.os == linux] 55 | labels: 56 | - visualizer.agent 57 | 58 | # optional 59 | - visualizer.subnet.az1=172.31.0.0/20 60 | - visualizer.subnet.az2=172.31.16.0/20 61 | - visualizer.subnet.az3=172.31.32.0/20 62 | - visualizer.subnet.local=192.168.0.0/16 # for testing locally 63 | healthcheck: 64 | test: curl -f http://localhost:9501/healthcheck || exit 1 65 | interval: 10s 66 | timeout: 2s 67 | retries: 3 68 | start_period: 5s 69 | 70 | # secrets: 71 | # visualizer_registry_login: 72 | # external: true 73 | 74 | networks: 75 | agent_network: 76 | driver: overlay 77 | attachable: true 78 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.9' 2 | 3 | services: 4 | manager: 5 | environment: 6 | - VISUALIZER_TYPE=manager 7 | image: yandeu/visualizer:dev 8 | volumes: 9 | - /var/run/docker.sock:/var/run/docker.sock 10 | networks: 11 | - agent_network 12 | deploy: 13 | mode: replicated 14 | replicas: 1 15 | placement: 16 | constraints: [node.role == manager] 17 | labels: 18 | - visualizer.manager 19 | healthcheck: 20 | test: curl -f http://localhost:3500/healthcheck || exit 1 21 | interval: 10s 22 | timeout: 2s 23 | retries: 3 24 | start_period: 5s 25 | ports: 26 | - '9500:3500' 27 | 28 | agent: 29 | environment: 30 | - VISUALIZER_TYPE=agent 31 | image: yandeu/visualizer:dev 32 | volumes: 33 | - /var/run/docker.sock:/var/run/docker.sock 34 | networks: 35 | - agent_network 36 | deploy: 37 | mode: global 38 | placement: 39 | constraints: [node.platform.os == linux] 40 | labels: 41 | - visualizer.agent 42 | healthcheck: 43 | test: curl -f http://localhost:9501/healthcheck || exit 1 44 | interval: 10s 45 | timeout: 2s 46 | retries: 3 47 | start_period: 5s 48 | 49 | networks: 50 | agent_network: 51 | driver: overlay 52 | attachable: true 53 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "visualizer", 3 | "version": "0.0.0", 4 | "description": "🐋 A Visualizer for Docker Swarm using the Docker Engine API and Node.JS.", 5 | "main": "index.js", 6 | "type": "module", 7 | "scripts": { 8 | "docker-publish-dev": "npm run build && docker build . -f Dockerfile -t yandeu/visualizer:dev && docker push yandeu/visualizer:dev", 9 | "start": "node dist/index.js", 10 | "start-dev": "nodemon dist/index.js", 11 | "build": "rimraf dist && tsc -p tsconfig.browser.json && tsc -p tsconfig.node.json && node scripts/copy-files.mjs", 12 | "dev": "npm run build && npm-run-all --parallel dev:*", 13 | "dev:copy": "cross-env WATCH=true node scripts/copy-files.mjs", 14 | "dev:tsc-browser": "tsc -p tsconfig.browser.json --watch", 15 | "dev:tsc-node": "tsc -p tsconfig.node.json --watch", 16 | "dev:manager": "cross-env VISUALIZER_TASK=true VISUALIZER_TASK_AUTOSCALE=true VISUALIZER_TASK_AUTOUPDATE=true nodemon --watch dist --delay 800ms dist/manager.js", 17 | "dev:agent": "cross-env VISUALIZER_TASK=true VISUALIZER_TASK_AUTOSCALE=true VISUALIZER_TASK_SUBNET=true nodemon --watch dist --delay 1200ms dist/agent.js", 18 | "save": "git add . && git commit -m \"quick save\" && git push", 19 | "format:check": "prettier --check src/**/*.ts --check src/**/*.html", 20 | "format": "prettier --write src/**/*.ts --check src/**/*.html", 21 | "registry": "docker service create --name registry --publish published=5000,target=5000 registry:2", 22 | "docker-dev": "npm run build && npm-run-all docker-dev:*", 23 | "docker-dev:build": "docker build . -f Dockerfile -t 127.0.0.1:5000/visualizer:latest", 24 | "docker-dev:push": "docker push 127.0.0.1:5000/visualizer:latest", 25 | "docker-dev:deploy": "docker stack deploy -c docker-compose.dev.yml visualizer" 26 | }, 27 | "keywords": [], 28 | "author": "Yannick Deubel (https://github.com/yandeu)", 29 | "license": "SEE LICENSE IN LICENSE", 30 | "dependencies": { 31 | "axios": "^0.21.1", 32 | "body-parser": "^1.19.0", 33 | "express": "^4.17.1", 34 | "node-os-utils": "^1.3.5" 35 | }, 36 | "devDependencies": { 37 | "@types/express": "^4.17.12", 38 | "@types/node": "^15.12.4", 39 | "@yandeu/prettier-config": "^0.0.2", 40 | "chokidar": "^3.5.2", 41 | "cross-env": "^7.0.3", 42 | "fs-extra": "^10.0.0", 43 | "nodemon": "^2.0.12", 44 | "npm-run-all": "^4.1.5", 45 | "prettier": "^2.3.1", 46 | "rimraf": "^3.0.2", 47 | "typescript": "4.3.4" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /readme/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yandeu/docker-swarm-visualizer/6ec0129b4d2ff8a82198f961388dfafa9d4187c8/readme/screenshot.png -------------------------------------------------------------------------------- /scripts/copy-files.mjs: -------------------------------------------------------------------------------- 1 | import chokidar from 'chokidar' 2 | import pkg from 'fs-extra' 3 | import { resolve, join } from 'path' 4 | import { copyFile, mkdirSync, unlinkSync } from 'fs' 5 | 6 | const WATCH = process.env.WATCH === 'true' ? true : false 7 | 8 | // listen for file changed in the static folder 9 | const { copySync } = pkg; 10 | const src = join(resolve(), 'src/www') 11 | const dest = join(resolve(), 'dist/www') 12 | 13 | // create the static folder in /dist 14 | mkdirSync(dest, { recursive: true }) 15 | 16 | // copy all files 17 | copySync(src, dest, { 18 | filter: (src, dest) => { 19 | if (/\.ts$|\.js$/gm.test(src)) return false 20 | return true 21 | } 22 | }) 23 | 24 | if (!WATCH) process.exit(0) 25 | 26 | 27 | const watcher = chokidar.watch(src, { 28 | ignored: /\.ts$|\.js$/ // ignore js/ts 29 | }) 30 | 31 | watcher.on('add', path => { 32 | copyFile(path, path.replace(src, dest), err => { 33 | if (err) throw err 34 | }) 35 | }) 36 | 37 | watcher.on('change', path => { 38 | copyFile(path, path.replace(src, dest), err => { 39 | if (err) throw err 40 | }) 41 | }) 42 | 43 | watcher.on('unlink', path => { 44 | unlinkSync(path.replace(src, dest)) 45 | }) 46 | 47 | console.log('Watching Files...') 48 | -------------------------------------------------------------------------------- /src/agent.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @author Yannick Deubel (https://github.com/yandeu) 3 | * @copyright Copyright (c) 2021 Yannick Deubel 4 | * @license {@link https://github.com/yandeu/docker-swarm-visualizer/blob/main/LICENSE LICENSE} 5 | */ 6 | 7 | import express from 'express' 8 | import { containers, info, containerRemove, container, containerStats } from './lib/api.js' 9 | const app = express() 10 | const port = process.env.PORT || 9501 11 | 12 | // tasks (beta) 13 | import './tasks/agent.js' 14 | import { tasksRouter } from './tasks/agent.js' 15 | app.use('/tasks', tasksRouter) 16 | 17 | app.get('/', async (req, res) => { 18 | const _containers = await containers() 19 | const _info = await info() 20 | const doc = { info: _info, containers: _containers } 21 | res.json(doc) 22 | }) 23 | 24 | app.get('/info', async (req, res) => { 25 | const _info = await info() 26 | res.json(_info) 27 | }) 28 | 29 | app.get('/containers', async (req, res) => { 30 | const _containers = await containers() 31 | 32 | // remove what we do not need (for now) 33 | _containers.forEach(container => { 34 | ;['Command', 'HostConfig', 'Image', 'ImageID', 'Mounts', 'NetworkSettings', 'Ports'].forEach(key => { 35 | delete container[key] 36 | }) 37 | 38 | // if the container includes Stats, remove what we do yet need 39 | if (container.Stats) { 40 | ;['blkio_stats', 'id', 'name', 'networks', 'num_procs', 'pids_stats', 'preread', 'read', 'storage_stats'].forEach( 41 | key => { 42 | delete container.Stats[key] 43 | } 44 | ) 45 | } 46 | }) 47 | 48 | res.json(_containers) 49 | }) 50 | 51 | app.delete('/containers/:id', async (req, res) => { 52 | try { 53 | const { id } = req.params 54 | 55 | // you can only delete containers that are not running 56 | const _container: any = await container(id) 57 | if (_container.State.Status === 'running') throw new Error('Container is still running.') 58 | 59 | const json = await containerRemove(id) 60 | res.send(json) 61 | } catch (error: any) { 62 | res.status(500).send(error.message) 63 | } 64 | }) 65 | 66 | app.get('/healthcheck', (req, res) => { 67 | res.send('OK') 68 | }) 69 | 70 | app.get('*', (req, res) => { 71 | return res.status(404).send('nothing here') 72 | }) 73 | 74 | app.listen(port, () => { 75 | console.log(`[agent] listening at http://127.0.0.1:${port}`) 76 | }) 77 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @author Yannick Deubel (https://github.com/yandeu) 3 | * @copyright Copyright (c) 2021 Yannick Deubel 4 | * @license {@link https://github.com/yandeu/docker-swarm-visualizer/blob/main/LICENSE LICENSE} 5 | */ 6 | 7 | const agent = process.env.VISUALIZER_TYPE === 'agent' 8 | const manager = process.env.VISUALIZER_TYPE === 'manager' 9 | 10 | const main = async () => { 11 | if (agent) await import('./agent.js') 12 | else if (manager) await import('./manager.js') 13 | else console.log('Pass env VISUALIZER_TYPE; "agent" or "manager"') 14 | } 15 | 16 | main() 17 | -------------------------------------------------------------------------------- /src/lib/alpine.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @author Yannick Deubel (https://github.com/yandeu) 3 | * @copyright Copyright (c) 2021 Yannick Deubel 4 | * @license {@link https://github.com/yandeu/docker-swarm-visualizer/blob/main/LICENSE LICENSE} 5 | */ 6 | 7 | import { exec } from 'child_process' 8 | 9 | export const isAlpine = async (): Promise => { 10 | return new Promise(resolve => { 11 | exec('cat /etc/alpine-release', (error, stdout, stderr) => { 12 | if (error) return resolve(false) 13 | return resolve(true) 14 | }) 15 | }) 16 | } 17 | -------------------------------------------------------------------------------- /src/lib/api.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @author Yannick Deubel (https://github.com/yandeu) 3 | * @copyright Copyright (c) 2021 Yannick Deubel 4 | * @license {@link https://github.com/yandeu/docker-swarm-visualizer/blob/main/LICENSE LICENSE} 5 | */ 6 | 7 | import { docker } from './docker.js' 8 | import { cpuCount, cpuUsage, diskUsage, memUsage, getUsage, getGpuClock, getMemUtil, getMemoryFree, getMemoryTotal, getMemoryUsed, getName, getPower, getTemp } from './misc.js' 9 | 10 | export interface Service { 11 | ID: string 12 | Version: { 13 | Index: number 14 | } 15 | CreatedAt: string 16 | UpdatedAt: string 17 | Spec: { 18 | Name: string 19 | TaskTemplate: { 20 | ContainerSpec: { 21 | Image: string 22 | Labels: {} 23 | } 24 | Resources: { 25 | Limits: {} 26 | Reservations: {} 27 | } 28 | RestartPolicy: { 29 | Condition: string 30 | MaxAttempts: number 31 | } 32 | Placement: {} 33 | ForceUpdate: number 34 | } 35 | Mode: { 36 | Replicated: { 37 | Replicas: number 38 | } 39 | } 40 | UpdateConfig: {} 41 | RollbackConfig: {} 42 | EndpointSpec: { 43 | Mode: string 44 | Ports: [] 45 | } 46 | } 47 | Endpoint: { 48 | Spec: { 49 | Mode: string 50 | Ports: [] 51 | } 52 | Ports: [] 53 | VirtualIPs: [] 54 | } 55 | } 56 | 57 | export interface Image { 58 | Id: string 59 | Container: string 60 | Comment: string 61 | Os: string 62 | Architecture: string 63 | Parent: string 64 | ContainerConfig: { 65 | Tty: boolean 66 | Hostname: string 67 | Domainname: string 68 | AttachStdout: boolean 69 | PublishService: string 70 | AttachStdin: boolean 71 | OpenStdin: boolean 72 | StdinOnce: boolean 73 | NetworkDisabled: boolean 74 | OnBuild: [] 75 | Image: string 76 | User: string 77 | WorkingDir: string 78 | MacAddress: string 79 | AttachStderr: boolean 80 | Labels: {} 81 | Env: string[] 82 | Cmd: string[] 83 | } 84 | DockerVersion: string 85 | VirtualSize: number 86 | Size: number 87 | Author: string 88 | Created: string 89 | GraphDriver: { 90 | Name: string 91 | Data: {} 92 | } 93 | RepoDigests: string[] 94 | RepoTags: string[] 95 | Config: { 96 | Image: string 97 | NetworkDisabled: boolean 98 | OnBuild: [] 99 | StdinOnce: boolean 100 | PublishService: string 101 | AttachStdin: boolean 102 | OpenStdin: boolean 103 | Domainname: string 104 | AttachStdout: boolean 105 | Tty: boolean 106 | Hostname: string 107 | Cmd: string[] 108 | Env: string[] 109 | Labels: {} 110 | MacAddress: string 111 | AttachStderr: boolean 112 | WorkingDir: string 113 | User: string 114 | } 115 | RootFS: any 116 | } 117 | 118 | 119 | export interface Container { 120 | Id: string 121 | Names: string[] 122 | Image: string 123 | ImageID: string 124 | Command: string 125 | Created: number 126 | State: string 127 | Status: string 128 | Ports: { IP: string; PrivatePort: number; PublicPort: number; Type: string }[] 129 | Labels: object 130 | SizeRw: number 131 | SizeRootFs: number 132 | HostConfig: {} 133 | NetworkSettings: {} 134 | Mounts: [] 135 | [key: string]: any 136 | } 137 | 138 | export interface Node { 139 | ID: string 140 | Version: { Index: number } 141 | CreatedAt: string 142 | UpdatedAt: string 143 | Spec: { Availability: string; Name: string; Role: 'manager' | 'worker'; Labels: {} } 144 | Description: { Hostname: string } 145 | Status: { State: string; Message: string; Addr: string } 146 | ManagerStatus: {} 147 | } 148 | 149 | 150 | export const systemDF = async () => { 151 | return await docker('system/df') 152 | } 153 | 154 | export const nodesInfo = async () => { 155 | const nodes = (await docker('nodes')) as Node[] 156 | 157 | if (!Array.isArray(nodes)) return [] 158 | 159 | return nodes.map(node => { 160 | return { 161 | ID: node.ID, 162 | Version: node.Version.Index, 163 | Addr: node.Status.Addr, 164 | Role: node.Spec.Role, 165 | Availability: node.Spec.Availability, 166 | State: node.Status.State, 167 | Hostname: node.Description.Hostname 168 | } 169 | }) 170 | } 171 | 172 | export const nodes = async () => { 173 | return (await docker('nodes')) as Node[] 174 | } 175 | 176 | export const node = async (id: string) => { 177 | return (await docker(`nodes/${id}`)) as Node 178 | } 179 | 180 | export const containers = async (stats = true): Promise => { 181 | const containers = (await docker('containers/json?all=true')) as Container[] 182 | 183 | if (stats) { 184 | const promises: any[] = [] 185 | 186 | for (const value of containers) { 187 | const stats = containerStats(value.Id) 188 | promises.push(stats) 189 | } 190 | 191 | const results: any = await Promise.allSettled(promises) 192 | results.forEach((res, index) => { 193 | containers[index] = { ...containers[index], Stats: res.value } 194 | }) 195 | } 196 | 197 | return containers 198 | } 199 | 200 | export const container = async id => { 201 | return (await docker(`containers/${id}/json`)) as Container 202 | } 203 | 204 | export const containerStats = async id => { 205 | return await docker(`containers/${id}/stats?stream=false`) 206 | } 207 | 208 | export const containerRemove = async id => { 209 | return await docker(`containers/${id}?force=true`, 'DELETE') 210 | } 211 | 212 | export const image = async name => { 213 | return (await docker(`/images/${name}/json`)) as Image 214 | } 215 | 216 | export const info = async ( 217 | collectUsage = true 218 | ): Promise<{ 219 | NodeID: any 220 | NodeAddr: any 221 | NCPU: any 222 | MemTotal: any 223 | OperatingSystem: any 224 | cpuCount?: any 225 | cpuUsage?: any 226 | memUsage?: any 227 | disk?: any 228 | gpuUse?: any 229 | gpuClock?: any 230 | gpumemUtil?: any 231 | gpumemFree?: any 232 | gpumemTot?: any 233 | gpumemUsed?: any 234 | gpuName?: any 235 | gpuPower?: any 236 | gpuTemp?: any 237 | 238 | }> => { 239 | const info: any = await docker('info') 240 | 241 | if (!collectUsage) 242 | return { 243 | NodeID: info.Swarm.NodeID, 244 | NodeAddr: info.Swarm.NodeAddr, 245 | NCPU: info.NCPU, 246 | MemTotal: info.MemTotal, 247 | OperatingSystem: info.OperatingSystem 248 | } 249 | 250 | return { 251 | NodeID: info.Swarm.NodeID, 252 | NodeAddr: info.Swarm.NodeAddr, 253 | NCPU: info.NCPU, 254 | MemTotal: info.MemTotal, 255 | OperatingSystem: info.OperatingSystem, 256 | cpuCount: await cpuCount(), 257 | cpuUsage: await cpuUsage(), 258 | memUsage: await memUsage(), 259 | disk: await diskUsage(), 260 | gpuUse: await getUsage(), 261 | gpuClock: await getGpuClock(), 262 | gpumemUtil: await getMemUtil(), 263 | gpumemFree: await getMemoryFree(), 264 | gpumemTot: await getMemoryTotal(), 265 | gpumemUsed: await getMemoryUsed(), 266 | gpuName: await getName(), 267 | gpuPower: await getPower(), 268 | gpuTemp: await getTemp(), 269 | } 270 | } 271 | 272 | export const swarm = async () => { 273 | return await docker('swarm') 274 | } 275 | 276 | export const services = async () => { 277 | return (await docker('services')) as Service[] 278 | } 279 | 280 | export const service = async (id: string) => { 281 | return (await docker(`services/${id}`)) as Service 282 | } 283 | 284 | export const serviceUpdateImage = async (id: string, image: string, tag: string, digest: string) => { 285 | // console.log('[manager] serviceUpdateImage', `${image}:${tag}@${digest}`) 286 | try { 287 | const service = (await docker(`services/${id}`)) as Service 288 | 289 | if (service) { 290 | const serviceId = service.ID 291 | const serviceVersion = service.Version.Index 292 | 293 | // set force update (See: https://github.com/docker/cli/blob/8e08b72450719baed03eed0e0713aae885315bac/cli/command/service/update.go#L490) 294 | service.Spec.TaskTemplate.ForceUpdate++ 295 | 296 | // update image 297 | service.Spec.TaskTemplate.ContainerSpec.Image = `${image}:${tag}@${digest}` 298 | 299 | await docker(`services/${serviceId}/update?version=${serviceVersion}`, 'POST', service.Spec) 300 | } 301 | } catch (error: any) { 302 | console.log('error', error.message) 303 | } 304 | } 305 | -------------------------------------------------------------------------------- /src/lib/cron.ts: -------------------------------------------------------------------------------- 1 | import { readFile, writeFile } from 'fs/promises' 2 | import { exec } from 'child_process' 3 | import { isDocker } from './docker.js' 4 | import { fetch } from './fetch.js' 5 | import { isAlpine } from './alpine.js' 6 | 7 | const cronRoot = '/var/spool/cron/crontabs/root' 8 | 9 | export const shouldUseCrone = async () => { 10 | return (await isDocker()) && (await isAlpine()) && (await startCrond()) 11 | } 12 | 13 | const startCrond = async (): Promise => { 14 | // start crond in background 15 | return new Promise(resolve => { 16 | exec('crond', (error, stdout, stderr) => { 17 | if (error) return resolve(false) 18 | return resolve(true) 19 | }) 20 | }) 21 | } 22 | 23 | const addCronJob = async (job: string) => { 24 | if (!job) job = '* * * * * for i in 0 1 2; do echo "test" & sleep 15; done; echo "test"' 25 | 26 | const file = await readFile(cronRoot, { encoding: 'utf-8' }) 27 | 28 | // let job = '* * * * * echo "test"' 29 | for (let i = 0; i < 5; i++) { 30 | job = job.replace(' ', '\t') 31 | } 32 | 33 | const updatedFile = file.split('\n').concat(job).join('\n') 34 | 35 | await writeFile(cronRoot, updatedFile, { encoding: 'utf-8' }) 36 | } 37 | 38 | // @ts-ignore 39 | const USE_CRON = await shouldUseCrone() 40 | console.log('USE_CRON', USE_CRON) 41 | 42 | export interface CurlWithCronOptions { 43 | url: string 44 | cron: string 45 | interval: number 46 | } 47 | 48 | /** Add a Cron Job to cURL a url (fallback to setInterval() for local development) */ 49 | export const curlWithCron = async (options: CurlWithCronOptions) => { 50 | const { url, cron, interval } = options 51 | 52 | if (USE_CRON) await addCronJob(cron) 53 | else if (typeof interval === 'number' && interval > 0) setInterval(fetch(url), interval) 54 | } 55 | -------------------------------------------------------------------------------- /src/lib/dns.ts: -------------------------------------------------------------------------------- 1 | import type { DNS } from '../types.js' 2 | 3 | import dns from 'dns' 4 | 5 | export const agentDNSLookup = (): Promise => { 6 | // We send ['127.0.0.1'] when we (most probably) are developing the app locally. 7 | 8 | return new Promise(resolve => { 9 | dns.lookup('tasks.agent', { all: true }, (err, addresses, family) => { 10 | if (addresses) { 11 | const addr = addresses.map(a => a.address) 12 | return resolve(addr.length > 0 ? addr : ['127.0.0.1']) 13 | } 14 | return resolve(['127.0.0.1']) 15 | }) 16 | }) 17 | } 18 | -------------------------------------------------------------------------------- /src/lib/docker.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @author Yannick Deubel (https://github.com/yandeu) 3 | * @copyright Copyright (c) 2021 Yannick Deubel 4 | * @license {@link https://github.com/yandeu/docker-swarm-visualizer/blob/main/LICENSE LICENSE} 5 | */ 6 | 7 | import { request } from 'http' 8 | import { stat, readFile } from 'fs/promises' 9 | 10 | /** Check if running on a Docker Container. */ 11 | export const isDocker = async () => { 12 | // https://stackoverflow.com/a/25518345 13 | const env = (await stat('/.dockerenv').catch(e => {})) ? true : false 14 | // https://stackoverflow.com/a/20012536 15 | const group = await ((await readFile('/proc/1/cgroup', 'utf8').catch(e => {})) || ([] as any)).includes('/docker/') 16 | 17 | return env || group 18 | } 19 | 20 | // access socket via node.js http 21 | export const docker = (path, method = 'GET', body: any = false) => { 22 | const options: any = { 23 | path: '/v1.41/' + path.replace(/^\//, ''), 24 | method: method 25 | } 26 | 27 | let postData 28 | 29 | if (body) { 30 | postData = JSON.stringify(body) 31 | options.headers = { 32 | 'Content-Type': 'application/json', 33 | 'Content-Length': Buffer.byteLength(postData) 34 | } 35 | } 36 | 37 | if (process.platform === 'win32') { 38 | options.socketPath = '\\\\.\\pipe\\docker_engine' 39 | } else { 40 | options.socketPath = '/var/run/docker.sock' 41 | } 42 | 43 | return new Promise((resolve, reject) => { 44 | var req = request(options, res => { 45 | var data = '' 46 | res.on('data', chunk => { 47 | data += chunk 48 | }) 49 | res.on('end', () => { 50 | try { 51 | const json = JSON.parse(data.toString()) 52 | return resolve(json) 53 | } catch (error) { 54 | return resolve(data.toString()) 55 | } 56 | }) 57 | }) 58 | req.on('error', e => { 59 | console.log(`problem with request: ${e.message}`) 60 | console.log(e.stack) 61 | return reject(e) 62 | }) 63 | if (postData) req.write(postData) 64 | req.end() 65 | }) 66 | } 67 | 68 | // access socket via curl (node exec) 69 | // export const docker = async api => { 70 | // try { 71 | // const cmd = process.platform === 'win32' ? 'wsl curl' : 'curl' // use wsl on windows 72 | // const res = await exec(`${cmd} --unix-socket /var/run/docker.sock "http://127.0.0.1/v1.41/${api.replace(/^\//, '')}"`) 73 | // if (res) return JSON.parse(res) 74 | // return {} 75 | // } catch (err) { 76 | // console.log(err.message) 77 | // return res || {} 78 | // } 79 | // } 80 | -------------------------------------------------------------------------------- /src/lib/exec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @author Yannick Deubel (https://github.com/yandeu) 3 | * @copyright Copyright (c) 2021 Yannick Deubel 4 | * @license {@link https://github.com/yandeu/docker-swarm-visualizer/blob/main/LICENSE LICENSE} 5 | */ 6 | 7 | import { exec as _exec, spawn } from 'child_process' 8 | 9 | export const exec = cmd => { 10 | return new Promise((resolve, reject) => { 11 | _exec(`${cmd}`, (err, stdout, stderr) => { 12 | if (err) { 13 | return reject(err) 14 | } 15 | return resolve(stdout) 16 | }) 17 | }) 18 | } 19 | -------------------------------------------------------------------------------- /src/lib/fetch.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @author Yannick Deubel (https://github.com/yandeu) 3 | * @copyright Copyright (c) 2021 Yannick Deubel 4 | * @license {@link https://github.com/yandeu/docker-swarm-visualizer/blob/main/LICENSE LICENSE} 5 | */ 6 | 7 | import axios from 'axios' 8 | 9 | export const fetch = url => async () => { 10 | const res = await axios.get(url, { timeout: 10_000 }) 11 | return res.data 12 | } 13 | -------------------------------------------------------------------------------- /src/lib/misc.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @author Yannick Deubel (https://github.com/yandeu) 3 | * @copyright Copyright (c) 2021 Yannick Deubel 4 | * @license {@link https://github.com/yandeu/docker-swarm-visualizer/blob/main/LICENSE LICENSE} 5 | */ 6 | 7 | import { exec } from 'child_process' 8 | 9 | import pkg from 'node-os-utils' 10 | const { mem, cpu } = pkg 11 | 12 | export const generateId = () => Math.random().toString(36).substr(2) + Math.random().toString(36).substr(2) 13 | 14 | // node-os-utils: examples 15 | // console.log('cors:', cpu.count(), 'CPU%', await cpu.usage()) 16 | // const memory = await mem.used() 17 | // console.log(memory, 'MEM%', ~~((memory.usedMemMb / memory.totalMemMb) * 100)) 18 | 19 | /** Does obviously not work on Windows. */ 20 | export const diskUsage = () => { 21 | return new Promise((resolve, reject) => { 22 | exec('df -h', (error, stdout, stderr) => { 23 | if (error) return resolve({}) 24 | 25 | const mountedOnRoot = stdout.split('\n').filter(l => /\s\/$/.test(l))[0] 26 | const match: any = mountedOnRoot.match(/[0-9]+\.?[0-9]*\S?/gm) 27 | 28 | if (match.length !== 4) return resolve({}) 29 | 30 | const result = { Size: match[0], Used: match[1], Available: match[2], Percent: match[3] } 31 | 32 | return resolve(result) 33 | }) 34 | }) 35 | } 36 | 37 | export const memUsage = async () => { 38 | const memory = await mem.used() 39 | return ~~((memory.usedMemMb / memory.totalMemMb) * 100) 40 | } 41 | 42 | export const cpuCount = () => { 43 | return cpu.count() 44 | } 45 | 46 | export const cpuUsage = async () => { 47 | return await cpu.usage() 48 | } 49 | 50 | const NVIDIA = { 51 | name: 'nvidia-smi --query-gpu=name --format=csv,noheader', 52 | memClock: 'nvidia-smi --query-gpu=clocks.mem --format=csv,noheader', 53 | GpuClock: 'nvidia-smi --query-gpu=clocks.gr --format=csv,noheader', 54 | temp: 'nvidia-smi --query-gpu=temperature.gpu --format=csv,noheader', 55 | usageGpu: 'nvidia-smi --query-gpu=utilization.gpu --format=csv,noheader', 56 | memoryTotal: 'nvidia-smi --query-gpu=memory.total --format=csv,noheader', 57 | memoryFree: 'nvidia-smi --query-gpu=memory.free --format=csv,noheader', 58 | memoryUsed: 'nvidia-smi --query-gpu=memory.used --format=csv,noheader', 59 | memoryUtilization: 'nvidia-smi --query-gpu=utilization.memory --format=csv,noheader', 60 | powerDraw: 'nvidia-smi --query-gpu=power.draw --format=csv,noheader', 61 | } 62 | 63 | const promiseExec = (command: string) => 64 | new Promise((resolve, reject) => { 65 | exec(command, (error, stdout, stderr) => { 66 | if (error) { 67 | reject(error); 68 | } else { 69 | resolve(stdout); 70 | } 71 | }); 72 | }); 73 | 74 | export const getName = async (): Promise => { 75 | try { 76 | const name = await promiseExec(NVIDIA.name); 77 | return name.trim(); 78 | } catch (err) { 79 | return err.message; 80 | } 81 | }; 82 | 83 | export const getTemp = async (): Promise => { 84 | try { 85 | const temp = await promiseExec(NVIDIA.temp); 86 | return temp.trim(); 87 | } catch (err) { 88 | return err.message; 89 | } 90 | }; 91 | 92 | export const getUsage = async (): Promise => { 93 | try { 94 | const usage = await promiseExec(NVIDIA.usageGpu); 95 | return usage.trim(); 96 | } catch (err) { 97 | return err.message; 98 | } 99 | }; 100 | 101 | export const getMemoryTotal = async (): Promise => { 102 | try { 103 | const memory = await promiseExec(NVIDIA.memoryTotal); 104 | return memory.trim().replace(/[A-Z]\w+/g, ''); 105 | } catch (err) { 106 | return err.message; 107 | } 108 | }; 109 | 110 | export const getMemoryFree = async (): Promise => { 111 | try { 112 | const memory = await promiseExec(NVIDIA.memoryFree); 113 | return memory.trim().replace(/[A-Z]\w+/g, ''); 114 | } catch (err) { 115 | return err.message; 116 | } 117 | }; 118 | 119 | export const getMemoryUsed = async (): Promise => { 120 | try { 121 | const memory = await promiseExec(NVIDIA.memoryUsed); 122 | return memory.trim().replace(/[A-Z]\w+/g, ''); 123 | } catch (err) { 124 | return err.message; 125 | } 126 | }; 127 | 128 | export const getGpuClock = async (): Promise => { 129 | try { 130 | const clock = await promiseExec(NVIDIA.GpuClock); 131 | return clock.trim().replace(/[A-Z]\w+/g, ''); 132 | } catch (err) { 133 | return err.message; 134 | } 135 | }; 136 | 137 | export const getMemUtil = async (): Promise => { 138 | try { 139 | const memutil = await promiseExec(NVIDIA.memoryUtilization) 140 | return memutil.trim() 141 | } catch (err) { 142 | return err.message 143 | } 144 | }; 145 | 146 | export const getPower = async (): Promise => { 147 | try { 148 | const pow = await promiseExec(NVIDIA.powerDraw) 149 | return pow.trim() 150 | } catch (err) { 151 | return err.message 152 | } 153 | }; -------------------------------------------------------------------------------- /src/lib/registry.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @author Yannick Deubel (https://github.com/yandeu) 3 | * @copyright Copyright (c) 2021 Yannick Deubel 4 | * @license {@link https://github.com/yandeu/docker-swarm-visualizer/blob/main/LICENSE LICENSE} 5 | */ 6 | 7 | import axios, { AxiosResponse } from 'axios' 8 | 9 | // echo -n 'username:password' | base64 10 | // hub.docker.io:hub.docker.io 11 | // github.com:GITHUB_ACCESS_TOKEN 12 | 13 | // enter auth without keeping in history 14 | // read: https://stackoverflow.com/a/8473153 15 | // printf "This is a secret" | docker secret create my_secret_data - 16 | // echo -n 'username:password' | base64 | docker secret create visualizer_registry_login - 17 | 18 | /** GitHub without Auth */ 19 | // registry = new Registry('GITHUB') 20 | 21 | /** GitHub with Auth */ 22 | // registry = new Registry('GITHUB', 'dXNlcm5hbWU6cGFzc3dvcmQ=') 23 | 24 | /** Docker without Auth */ 25 | // registry = new Registry(REGISTRY) 26 | 27 | /** Docker with Auth */ 28 | // registry = new Registry(REGISTRY, 'dXNlcm5hbWU6cGFzc3dvcmQ=') 29 | 30 | const CONFIG = { 31 | DOCKER: { 32 | AUTH_URL: `https://auth.docker.io/token`, 33 | SERVICE: 'registry.docker.io', 34 | REGISTRY_URL: 'https://index.docker.io/v2' 35 | }, 36 | GITHUB: { 37 | AUTH_URL: `https://ghcr.io/token`, 38 | SERVICE: 'ghcr.io', 39 | REGISTRY_URL: 'https://ghcr.io/v2' 40 | } 41 | } 42 | 43 | const catchme = e => console.log(e.message) 44 | const btoa = data => Buffer.from(data).toString('base64') 45 | 46 | export class Registry { 47 | images: any[] = [] 48 | BASIC_AUTH: string 49 | 50 | constructor(public REGISTRY: keyof typeof CONFIG, AUTH = '') { 51 | this.BASIC_AUTH = 'Basic ' + AUTH 52 | } 53 | 54 | requestImage(IMAGE, TAG = 'latest') { 55 | // this.images.push({ IMAGE, TAG }) 56 | this.images[0] = { IMAGE, TAG } // just one image for now 57 | return this 58 | } 59 | 60 | private async request(auth, url, method: 'get' | 'post' | 'delete' | 'head'): Promise> { 61 | try { 62 | if (!auth) throw new Error('Auth is missing!') 63 | 64 | const { token, access_token } = auth.data 65 | 66 | const res = await axios[method](`${CONFIG[this.REGISTRY].REGISTRY_URL}/${url}`, { 67 | headers: { 68 | Authorization: `Bearer ${access_token ?? token}`, 69 | Accept: 'application/vnd.docker.distribution.manifest.v2+json' 70 | } 71 | }) 72 | return { ...res, request: {}, config: {} } 73 | } catch (error: any) { 74 | return error.message 75 | } 76 | } 77 | 78 | async get(auth, url) { 79 | return await this.request(auth, url, 'get') 80 | } 81 | 82 | async head(auth, url) { 83 | return await this.request(auth, url, 'head') 84 | } 85 | 86 | async post(auth, url) { 87 | return await this.request(auth, url, 'post') 88 | } 89 | 90 | async delete(auth, url) { 91 | return await this.request(auth, url, 'delete') 92 | } 93 | 94 | async getManifest(auth) { 95 | const img = this.images[0] 96 | const url = `${img.IMAGE}/manifests/${img.TAG}` 97 | const res = await this.get(auth, url) 98 | if (res && res.data) return res.data 99 | } 100 | 101 | async getDigest(auth) { 102 | const img = this.images[0] 103 | const url = `${img.IMAGE}/manifests/${img.TAG}` 104 | const res = await this.head(auth, url) 105 | if (res && res.headers) return res.headers['docker-content-digest'] 106 | } 107 | 108 | async Auth() { 109 | const withCredentials = this.BASIC_AUTH.length > 6 110 | if (this.REGISTRY === 'DOCKER') return await this.DockerAuth(withCredentials) 111 | if (this.REGISTRY === 'GITHUB') return await this.GithubAuth(withCredentials) 112 | } 113 | 114 | async DockerAuth(withCredentials) { 115 | let query = `?service=${CONFIG.DOCKER.SERVICE}&scope=` 116 | this.images.forEach(image => (query += `repository:${image.IMAGE}:pull`)) 117 | const url = `${CONFIG.DOCKER.AUTH_URL}${query}` 118 | 119 | // console.log('withCredentials', withCredentials) 120 | // console.log('url', url) 121 | 122 | if (!withCredentials) return await axios.get(url).catch(catchme) 123 | 124 | return await axios 125 | .get(url, { 126 | headers: { 127 | Authorization: this.BASIC_AUTH 128 | } 129 | }) 130 | .catch(catchme) 131 | 132 | // const params = new URLSearchParams() 133 | // params.append('grant_type', 'password') 134 | // params.append('service', CONFIG.DOCKER.SERVICE) 135 | // params.append('scope', `repository:${this.IMAGE}:pull`) 136 | // params.append('client_id', 'test') 137 | // params.append('username', this.USERNAME) 138 | // params.append('password', this.PASSWORD) 139 | // return await axios.post(CONFIG.DOCKER.AUTH_URL, params, { 140 | // headers: { 141 | // 'Content-Type': 'application/x-www-form-urlencoded' 142 | // } 143 | // }).catch(catchme) 144 | } 145 | 146 | Clear() { 147 | this.images = [] 148 | } 149 | 150 | async GithubAuth(withCredentials) { 151 | let query = `?service=${CONFIG.GITHUB.SERVICE}&scope=` 152 | this.images.forEach(image => (query += `repository:${image.IMAGE}:pull `)) 153 | const url = `${CONFIG.GITHUB.AUTH_URL}${query}` 154 | 155 | if (!withCredentials) return await axios.get(url).catch(catchme) 156 | 157 | return await axios 158 | .get(url, { 159 | headers: { 160 | Authorization: this.BASIC_AUTH 161 | } 162 | }) 163 | .catch(catchme) 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /src/manager.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @author Yannick Deubel (https://github.com/yandeu) 3 | * @copyright Copyright (c) 2021 Yannick Deubel 4 | * @license {@link https://github.com/yandeu/docker-swarm-visualizer/blob/main/LICENSE LICENSE} 5 | */ 6 | 7 | import express from 'express' 8 | import { nodesInfo, systemDF, containerStats } from './lib/api.js' 9 | import { fetch } from './lib/fetch.js' 10 | import { resolve, join } from 'path' 11 | import bodyParser from 'body-parser' 12 | 13 | const app = express() 14 | const port = process.env.PORT || 3500 15 | 16 | app.use(bodyParser.json()) 17 | 18 | import axios from 'axios' 19 | import { agentDNSLookup } from './lib/dns.js' 20 | import { exec } from './lib/exec.js' 21 | 22 | // tasks (beta) 23 | import './tasks/manager.js' 24 | import { tasksRouter } from './tasks/manager.js' 25 | app.use('/tasks', tasksRouter) 26 | 27 | app.use(express.static(join(resolve(), 'dist/www'), { extensions: ['html'] })) 28 | 29 | const deployStack = async (name: string, stack: string) => { 30 | try { 31 | stack = stack.replace(/'/gm, '"') 32 | 33 | const reg = /^(\S*?)\.?stack\.?(\S*?)\.ya?ml$/ 34 | 35 | const arr = reg.exec(name) 36 | if (!arr) throw new Error('Invalid stack name') 37 | 38 | name = arr[1] || arr[2] 39 | if (!name) throw new Error('Invalid stack name') 40 | 41 | // while developing on windows 42 | const cmd = ` printf '${stack}' | docker stack deploy --compose-file - ${name}` 43 | const result = await exec(cmd) 44 | 45 | return { status: 200, msg: result } 46 | } catch (error: any) { 47 | return { status: 400, msg: error.message } 48 | } 49 | } 50 | 51 | const createSecret = async (name: string, secret: string) => { 52 | try { 53 | const reg = /^(\S+)(\.txt|\.json)$/ 54 | 55 | const arr = reg.exec(name) 56 | if (!arr) throw new Error('Secret has to be a .txt or .json file') 57 | 58 | name = arr[1] 59 | if (!name) throw new Error('Secret has to be a .txt or .json file') 60 | 61 | const result = await exec(` printf '${secret}' | docker secret create ${name} -`) 62 | 63 | return { status: 200, msg: result } 64 | } catch (error: any) { 65 | return { status: 400, msg: error.message } 66 | } 67 | } 68 | 69 | app.post('/upload', async (req, res) => { 70 | console.log('UPLOAD') 71 | 72 | let { name, stack, secret } = req.body as { name: string; stack: string; secret: string } 73 | if (/\.ya?ml$/.test(name)) { 74 | const s = await deployStack(name, stack) 75 | return res.status(s.status).json(s) 76 | } else if (/\.txt$|\.json$/.test(name)) { 77 | const s = await createSecret(name, secret) 78 | return res.status(s.status).json(s) 79 | } else return res.status(400).json({ msg: 'Bad Request', status: 400 }) 80 | }) 81 | 82 | app.get('/api', (req, res) => { 83 | const routes = app._router.stack 84 | .filter(layer => typeof layer.route != 'undefined' && layer.route) 85 | .map(layer => ({ path: layer.route.path, methods: Object.keys(layer.route.methods) })) 86 | .filter(route => route.path !== '/api' && route.path !== '*') 87 | .sort((a, b) => { 88 | if (a.path < b.path) return -1 89 | if (a.path > b.path) return 1 90 | return 0 91 | }) 92 | 93 | const json = JSON.stringify(routes, null, 2) 94 | 95 | res.send(`
${json}
`) 96 | }) 97 | 98 | app.delete('/api/dev/agent/:ip/containers/:id', async (req, res) => { 99 | try { 100 | const { ip, id } = req.params 101 | const response = await axios.delete(`http://${ip}:9501/containers/${id}`) 102 | res.send(response.data) 103 | } catch (error: any) { 104 | res.status(500).send(error.message) 105 | } 106 | }) 107 | 108 | app.get('/api/dev/system/df', async (req, res) => { 109 | const system = await systemDF() 110 | try { 111 | return res.json(system) 112 | } catch (err: any) { 113 | return res.status(500).send(err.message) 114 | } 115 | }) 116 | 117 | app.get('/api/dev/nodes', async (req, res) => { 118 | const nodes = await nodesInfo() 119 | try { 120 | return res.json(nodes) 121 | } catch (err: any) { 122 | return res.status(500).send(err.message) 123 | } 124 | }) 125 | 126 | app.get('/api/dev/agents/dns', async (req, res) => { 127 | try { 128 | const dns = await agentDNSLookup() 129 | return res.json(dns) 130 | } catch (err: any) { 131 | return res.status(500).send(err.message) 132 | } 133 | }) 134 | 135 | app.get('/api/dev/container/:hash', async (req, res) => { 136 | try { 137 | const { hash } = req.params 138 | const _container = await containerStats(hash) 139 | return res.json(_container) 140 | } catch (err: any) { 141 | return res.status(500).send(err.message) 142 | } 143 | }) 144 | 145 | app.get('/api/dev/:node/info', async (req, res) => { 146 | try { 147 | const { node } = req.params 148 | const result = await fetch(`http://${node}:9501/info`)() 149 | return res.json(result) 150 | } catch (err: any) { 151 | return res.status(500).send(err.message) 152 | } 153 | }) 154 | 155 | app.get('/api/dev/:node/containers', async (req, res) => { 156 | try { 157 | const { node } = req.params 158 | const result = await fetch(`http://${node}:9501/containers`)() 159 | return res.json(result) 160 | } catch (err: any) { 161 | return res.status(500).send(err.message) 162 | } 163 | }) 164 | 165 | app.get('/api/dev', async (req, res) => { 166 | const nodes = await nodesInfo() 167 | const addrs = nodes.map(n => n.Addr) 168 | 169 | const promises: any[] = [] 170 | 171 | addrs.forEach(addr => promises.push(fetch(`http://${addr}:9501/`)())) 172 | 173 | try { 174 | const results = await Promise.allSettled(promises) 175 | return res.json({ nodes, results }) 176 | } catch (err: any) { 177 | return res.status(500).send(err.message) 178 | } 179 | }) 180 | 181 | app.get('/healthcheck', (req, res) => { 182 | res.send('OK') 183 | }) 184 | 185 | app.get('*', (req, res) => { 186 | return res.status(404).send('nothing here') 187 | }) 188 | 189 | app.listen(port, () => { 190 | console.log(`[manager] listening at http://127.0.0.1:${port}`) 191 | }) 192 | -------------------------------------------------------------------------------- /src/tasks/agent.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @author Yannick Deubel (https://github.com/yandeu) 3 | * @copyright Copyright (c) 2021 Yannick Deubel 4 | * @license {@link https://github.com/yandeu/docker-swarm-visualizer/blob/main/LICENSE LICENSE} 5 | */ 6 | 7 | import { Router } from 'express' 8 | import { curlWithCron } from '../lib/cron.js' 9 | import { generateId } from '../lib/misc.js' 10 | import { checkContainersForTasks, gaterInformationForContainerTasks } from './task.autoscale.js' 11 | import { getTasks } from './task.autoscale.js' 12 | 13 | const SECRET = generateId() 14 | 15 | const router = Router() 16 | 17 | let url = '' 18 | let cmd = '' 19 | 20 | const TASK_ENABLED = process.env.VISUALIZER_TASK === 'true' ? true : false 21 | console.log(TASK_ENABLED ? '[agent] tasks are enabled' : '[agent] tasks are disabled') 22 | 23 | // at startup 24 | if (TASK_ENABLED) setTimeout(checkContainersForTasks, 5_000) 25 | 26 | /** the tasks this node wishes to perform */ 27 | router.get('/', (req, res) => { 28 | if (!TASK_ENABLED) return res.json([]) 29 | return res.json(getTasks()) 30 | }) 31 | 32 | router.get('/checkContainersForTasks', async (req, res) => { 33 | if (!TASK_ENABLED) return res.send() 34 | const { secret } = req.query 35 | if (secret === SECRET) checkContainersForTasks() 36 | return res.send() 37 | }) 38 | 39 | router.get('/gaterInformationForContainerTasks', async (req, res) => { 40 | if (!TASK_ENABLED) return res.send() 41 | const { secret } = req.query 42 | if (secret === SECRET) gaterInformationForContainerTasks() 43 | return res.send() 44 | }) 45 | 46 | export { router as tasksRouter } 47 | 48 | if (TASK_ENABLED) { 49 | ;(async () => { 50 | if (process.env.VISUALIZER_TASK_SUBNET === 'true') { 51 | import('./task.subnet.js') 52 | .then(module => { 53 | console.log('[agent] task.subnet.js loaded') 54 | module.addSubnetLabel().catch(err => { 55 | console.log('[agent] Something went wrong in [addSubnetLabel()]: ', err.message) 56 | }) 57 | }) 58 | .catch(err => { 59 | console.log('[agent] task.subnet.js failed', err.message) 60 | }) 61 | } 62 | 63 | // keep track which containers have tasks 64 | url = `http://127.0.0.1:9501/tasks/checkContainersForTasks?secret=${SECRET}` 65 | cmd = `curl --silent ${url}` 66 | await curlWithCron({ url, cron: `* * * * * ${cmd}`, interval: 60_000 }) 67 | 68 | // collect and process tasks from containers 69 | url = `http://127.0.0.1:9501/tasks/gaterInformationForContainerTasks?secret=${SECRET}` 70 | cmd = `curl --silent ${url}` 71 | await curlWithCron({ 72 | url, 73 | cron: `* * * * * for i in 0 1 2; do ${cmd} & sleep 15; done; echo ${cmd}`, 74 | interval: 15_000 75 | }) 76 | })() 77 | } 78 | -------------------------------------------------------------------------------- /src/tasks/manager.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @author Yannick Deubel (https://github.com/yandeu) 3 | * @copyright Copyright (c) 2021 Yannick Deubel 4 | * @license {@link https://github.com/yandeu/docker-swarm-visualizer/blob/main/LICENSE LICENSE} 5 | */ 6 | 7 | import { Router } from 'express' 8 | import { curlWithCron } from '../lib/cron.js' 9 | import { generateId } from '../lib/misc.js' 10 | import { checkAgentsForNewTasks } from './task.autoscale.js' 11 | 12 | const SECRET = generateId() 13 | 14 | const router = Router() 15 | 16 | let url = '' 17 | let cmd = '' 18 | 19 | const TASK_ENABLED = process.env.VISUALIZER_TASK === 'true' ? true : false 20 | console.log(TASK_ENABLED ? '[manager] tasks are enabled' : '[manager] tasks are disabled') 21 | 22 | router.get('/checkAgentsForNewTasks', async (req, res) => { 23 | if (!TASK_ENABLED) return res.send() 24 | const { secret } = req.query 25 | if (secret === SECRET) checkAgentsForNewTasks() 26 | return res.send() 27 | }) 28 | 29 | router.get('/checkForImageUpdate', async (req, res) => { 30 | if (!TASK_ENABLED) return res.send() 31 | const { secret } = req.query 32 | if (secret === SECRET) 33 | import('./task.autoupdate.js').then(module => { 34 | module.checkImageUpdate() 35 | }) 36 | return res.send() 37 | }) 38 | 39 | export { router as tasksRouter } 40 | 41 | if (TASK_ENABLED) { 42 | ;(async () => { 43 | if (process.env.VISUALIZER_TASK_AUTOUPDATE === 'true') { 44 | let cron = process.env.VISUALIZER_TASK_AUTOUPDATE_CRON 45 | if (!cron || cron.split(' ').length !== 5) { 46 | cron = '0 */6 * * *' 47 | console.log('VISUALIZER_TASK_AUTOUPDATE_CRON is invalid or not present. Fallback to', cron) 48 | } else { 49 | console.log('Starting VISUALIZER_TASK_AUTOUPDATE with cron', cron) 50 | } 51 | 52 | // k// check every agent for new tasks and process/apply them 53 | url = `http://127.0.0.1:3500/tasks/checkForImageUpdate?secret=${SECRET}` 54 | cmd = `curl --silent ${url}` 55 | await curlWithCron({ 56 | url, 57 | cron: cron, 58 | interval: -1 59 | }) 60 | } 61 | 62 | if (process.env.VISUALIZER_TASK_AUTOSCALE === 'true') { 63 | // k// check every agent for new tasks and process/apply them 64 | url = `http://127.0.0.1:3500/tasks/checkAgentsForNewTasks?secret=${SECRET}` 65 | cmd = `curl --silent ${url}` 66 | await curlWithCron({ 67 | url, 68 | cron: `* * * * * for i in 0 1 2 3 4; do ${cmd} & sleep 10; done; echo ${cmd}`, 69 | interval: 10_000 70 | }) 71 | } 72 | })() 73 | } 74 | -------------------------------------------------------------------------------- /src/tasks/task.autoscale.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @author Yannick Deubel (https://github.com/yandeu) 3 | * @copyright Copyright (c) 2021 Yannick Deubel 4 | * @license {@link https://github.com/yandeu/docker-swarm-visualizer/blob/main/LICENSE LICENSE} 5 | */ 6 | 7 | import type { CPUUsage, Tasks, AutoscalerSettings } from '../types.js' 8 | 9 | import { containers, containerStats } from '../lib/api.js' 10 | import { docker } from '../lib/docker.js' 11 | import { agentDNSLookup } from '../lib/dns.js' 12 | import { fetch } from '../lib/fetch.js' 13 | 14 | const CPU_USAGE: any = {} 15 | const LABEL_VISUALIZER = /^visualizer\./ 16 | 17 | let CONTAINERS_WITH_TASKS: any[] = [] 18 | let TASKS: any[] = [] 19 | let DEBUG = false 20 | 21 | export const getTasks = () => TASKS 22 | 23 | export const executeTask = async (service: any, task: Tasks) => { 24 | if (DEBUG) console.log('[executeTask]') 25 | 26 | const { name, autoscaler = { min: 1, max: 2 } } = task 27 | const { min, max } = autoscaler 28 | 29 | if (DEBUG) console.log('Task, Try:', name) 30 | 31 | if (name !== 'SCALE_UP' && name !== 'SCALE_DOWN') return 32 | 33 | const serviceId = service.ID 34 | const serviceVersion = service.Version.Index 35 | 36 | // update the service specs 37 | service.Spec.Mode.Replicated.Replicas += name === 'SCALE_UP' ? 1 : -1 38 | 39 | if (name === 'SCALE_DOWN' && service.Spec.Mode.Replicated.Replicas < min) return 40 | if (name === 'SCALE_UP' && service.Spec.Mode.Replicated.Replicas > max) return 41 | 42 | if (DEBUG) console.log('Task, Do:', name) 43 | 44 | try { 45 | await docker(`services/${serviceId}/update?version=${serviceVersion}`, 'POST', service.Spec) 46 | } catch (error: any) { 47 | console.log('error', error.message) 48 | } 49 | } 50 | 51 | /** 52 | * Check one per minute which containers have tasks 53 | */ 54 | export const checkContainersForTasks = async () => { 55 | if (DEBUG) console.log('[checkContainersForTasks]') 56 | 57 | let _containers: any = await containers(false) 58 | 59 | CONTAINERS_WITH_TASKS = _containers 60 | // only "running" containers 61 | .filter(c => c.State === 'running') 62 | // only containers with at least one label starting with "visualizer." 63 | .filter(c => Object.keys(c.Labels).some(key => LABEL_VISUALIZER.test(key))) 64 | // return container id and all its labels 65 | .map(c => ({ 66 | id: c.Id, 67 | labels: c.Labels 68 | })) 69 | } 70 | 71 | /** 72 | * Gather information about container tasks 73 | */ 74 | export const gaterInformationForContainerTasks = async () => { 75 | if (DEBUG) console.log('[gaterInformationForContainerTasks]') 76 | 77 | const batch = CONTAINERS_WITH_TASKS.map(async container => { 78 | const _stats: any = await containerStats(container.id) 79 | 80 | if (_stats && _stats.cpu_stats && _stats.precpu_stats && _stats.memory_stats) { 81 | const stats = { 82 | cpu_stats: _stats.cpu_stats, 83 | precpu_stats: _stats.precpu_stats, 84 | memory_stats: _stats.memory_stats 85 | } 86 | const cpuUsage = calculateCPUUsageOneMinute({ Id: container.id, Stats: stats }) 87 | return { ...container, cpuUsage } 88 | } 89 | 90 | return container 91 | }) 92 | 93 | const result: any[] = await Promise.all(batch) 94 | 95 | // TODO(yandeu): Decide what to do 96 | // publish each task only once per 5 Minute? 97 | 98 | const tasks: Tasks[] = [] 99 | result.forEach(r => { 100 | const cpuUsage = r.cpuUsage as CPUUsage 101 | 102 | // service 103 | const service = r.labels['com.docker.swarm.service.name'] 104 | 105 | // autoscaler 106 | const cpuUp = parseFloat(r.labels['visualizer.autoscale.up.cpu']) 107 | const cpuDown = parseFloat(r.labels['visualizer.autoscale.down.cpu']) 108 | const max = parseInt(r.labels['visualizer.autoscale.max']) 109 | const min = parseInt(r.labels['visualizer.autoscale.min']) 110 | 111 | // updates 112 | // TODO 113 | 114 | const autoscaler: AutoscalerSettings = { min, max, up: { cpu: cpuUp }, down: { cpu: cpuDown } } 115 | 116 | if (cpuUsage && cpuUsage.cpu >= 0 && service && max > 0 && min > 0) { 117 | if (cpuUsage.cpu > cpuUp * 100) { 118 | tasks.push({ name: 'SCALE_UP', service: service, autoscaler, cpuUsage }) 119 | } 120 | if (cpuUsage.cpu < cpuDown * 100) { 121 | tasks.push({ name: 'SCALE_DOWN', service: service, autoscaler, cpuUsage }) 122 | } 123 | } 124 | }) 125 | 126 | // console.log('agent, tasks:', tasks.length) 127 | 128 | TASKS = tasks 129 | } 130 | 131 | export const calculateCPUUsageOneMinute = (container): CPUUsage => { 132 | if (DEBUG) console.log('[calculateCPUUsageOneMinute]') 133 | 134 | const { Stats, Id } = container 135 | 136 | if (!CPU_USAGE[Id]) CPU_USAGE[Id] = [] 137 | 138 | let cpuPercent = 0.0 139 | 140 | CPU_USAGE[Id].push({ 141 | time: new Date().getTime(), 142 | usage: Stats.precpu_stats.cpu_usage.total_usage, 143 | systemUsage: Stats.precpu_stats.system_cpu_usage 144 | }) 145 | 146 | try { 147 | const cpuDelta = Stats.cpu_stats.cpu_usage.total_usage - CPU_USAGE[Id][0].usage 148 | 149 | const systemDelta = Stats.cpu_stats.system_cpu_usage - CPU_USAGE[Id][0].systemUsage 150 | 151 | if (systemDelta > 0.0 && cpuDelta > 0.0) cpuPercent = (cpuDelta / systemDelta) * Stats.cpu_stats.online_cpus * 100.0 152 | 153 | // 2 time 10 seconds = 20 seconds 154 | // give the average of 20 second cpu 155 | if (CPU_USAGE[Id].length > 2) { 156 | const data = { cpu: cpuPercent, time: new Date().getTime() - CPU_USAGE[Id][0].time } 157 | CPU_USAGE[Id].shift() 158 | return data 159 | } 160 | 161 | return { cpu: -1, time: -1 } 162 | } catch (error) { 163 | return { cpu: -1, time: -1 } 164 | } 165 | } 166 | 167 | export const checkAgentsForNewTasks = async () => { 168 | if (DEBUG) console.log('[checkAgentsForNewTasks]') 169 | 170 | const tasks: { [key: string]: Tasks[] } = {} 171 | 172 | const dns = await agentDNSLookup() 173 | if (dns.length === 0) return tasks 174 | 175 | const agents: Tasks[][] = await Promise.all(dns.map(addr => fetch(`http://${addr}:9501/tasks`)())) 176 | 177 | // console.log('check tasks', agents?.length) 178 | 179 | agents.forEach(agentTasks => { 180 | agentTasks.forEach(task => { 181 | const { service } = task 182 | if (!tasks[service]) tasks[service] = [task] 183 | else tasks[service].push(task) 184 | }) 185 | }) 186 | 187 | // first task of first agent 188 | // console.log(agents[0][0]) 189 | 190 | // console.log('tasks', tasks) 191 | 192 | // NOTE: 193 | // We need a agent quorum to perform a task (> 50%) 194 | 195 | Object.keys(tasks).forEach(async key => { 196 | // console.log('new tasks', Object.keys(tasks).length) 197 | const task = tasks[key] 198 | 199 | const scaleUp = task.filter(t => t.name === 'SCALE_UP') 200 | const scaleDown = task.filter(t => t.name === 'SCALE_DOWN') 201 | 202 | const service: any = await docker(`services/${task[0].service}`) 203 | const replicas = service?.Spec?.Mode?.Replicated?.Replicas as number 204 | 205 | if (typeof replicas === 'number' && replicas > 0) { 206 | let ratio 207 | 208 | ratio = scaleUp.length / replicas 209 | if (ratio - 0.5 > 0) executeTask(service, scaleUp[0]) 210 | 211 | ratio = scaleDown.length / replicas 212 | if (ratio - 0.5 > 0) executeTask(service, scaleDown[0]) 213 | } 214 | }) 215 | 216 | // console.log(service.Spec.Mode.Replicated.Replicas) 217 | 218 | // TODO(yandeu): If some agents request some tasks, process them. 219 | } 220 | -------------------------------------------------------------------------------- /src/tasks/task.autoupdate.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @author Yannick Deubel (https://github.com/yandeu) 3 | * @copyright Copyright (c) 2021 Yannick Deubel 4 | * @license {@link https://github.com/yandeu/docker-swarm-visualizer/blob/main/LICENSE LICENSE} 5 | */ 6 | 7 | import { services as getServices, serviceUpdateImage, Image } from '../lib/api.js' 8 | import { Registry } from '../lib/registry.js' 9 | 10 | export const checkImageUpdate = async () => { 11 | const tmp = await getServices() 12 | if (!tmp) return 13 | 14 | const AUTOUPDATE_LABEL = 'visualizer.autoupdate' 15 | 16 | const services = tmp 17 | .map(s => ({ 18 | ID: s.ID, 19 | Labels: s.Spec.TaskTemplate.ContainerSpec.Labels, 20 | Image: s.Spec.TaskTemplate.ContainerSpec.Image 21 | })) 22 | .filter(s => { 23 | return ( 24 | s.Labels && 25 | Object.keys(s.Labels).some(key => new RegExp(AUTOUPDATE_LABEL).test(key)) && 26 | s.Labels[AUTOUPDATE_LABEL] === 'true' 27 | ) 28 | }) 29 | 30 | for (const service of services) { 31 | try { 32 | const { Image, ID } = service 33 | 34 | // TODO(yandeu): Let the user chose which registry and the auth 35 | const REGISTRY = 'DOCKER' // or 'GITHUB' 36 | 37 | // parse local digest 38 | const [_, IMG, TAG, localDigest] = /([\w\/-]+):([\w-]+)@(sha256:.+)/.exec(Image) as any 39 | const IMAGE = /\//.test(IMG) ? IMG : `library/${IMG}` 40 | 41 | // get digest of remote images 42 | const registry = new Registry(REGISTRY) 43 | console.log(IMAGE, TAG) 44 | registry.requestImage(IMAGE, TAG) 45 | const auth = await registry.Auth() 46 | const remoteDigest = await registry.getDigest(auth) 47 | registry.Clear() 48 | 49 | // check if digest of remote image is different from the local one 50 | // console.log({ localDigest, remoteDigest }) 51 | if (localDigest && remoteDigest && localDigest !== remoteDigest) serviceUpdateImage(ID, IMAGE, TAG, remoteDigest) 52 | } catch (error: any) { 53 | console.log('Error while autoupdate', error.message) 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/tasks/task.subnet.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @author Yannick Deubel (https://github.com/yandeu) 3 | * @copyright Copyright (c) 2021 Yannick Deubel 4 | * @license {@link https://github.com/yandeu/docker-swarm-visualizer/blob/main/LICENSE LICENSE} 5 | */ 6 | 7 | /** 8 | * @description 9 | * Automatically add a node label based on in which subnet the node is. 10 | * 11 | * @version 12 | * Works only in Node.js >= v15.0.0 13 | * 14 | * @example 15 | * 16 | # whatever service you want to deploy 17 | services: 18 | nginx: 19 | image: nginx:latest 20 | ports: 21 | - 8080:80 22 | deploy: 23 | placement: 24 | preferences: 25 | # spread this service out over the "subnet" label 26 | spread: 27 | - node.labels.subnet 28 | 29 | # the visualizer_agent service 30 | services 31 | agent: 32 | labels: 33 | - visualizer.subnet.az1=172.31.0.0/20 34 | - visualizer.subnet.az2=172.31.16.0/20 35 | - visualizer.subnet.az3=172.31.32.0/20 36 | 37 | if the node has the internal IP 127.31.18.5, the label "az2" should be added to that node. 38 | 39 | */ 40 | 41 | import net from 'net' 42 | import { containers as getContainers, info, node } from '../lib/api.js' 43 | import { docker } from '../lib/docker.js' 44 | 45 | export const addSubnetLabel = async () => { 46 | const { NodeAddr, NodeID } = await info() 47 | 48 | let containers = await getContainers(false) 49 | containers = containers.filter(c => 'visualizer.agent' in c.Labels && c.State === 'running') 50 | if (containers.length === 0) return 51 | 52 | // check if there are any subnet labels 53 | const subnetRegex = /^visualizer.subnet./ 54 | const subnets = Object.entries(containers[0].Labels) 55 | .filter(([key]) => subnetRegex.test(key)) 56 | .map(entry => `${entry[0].replace(subnetRegex, '')}=${entry[1]}`) 57 | 58 | // const subnets = ['az1=172.31.0.0/20', 'az2=172.31.16.0/20', 'az3=172.31.32.0/20'] 59 | console.log('available subnets', subnets) 60 | 61 | let found 62 | while (subnets.length > 0 && !found) { 63 | const subnet = subnets.pop() as string 64 | const reg = /(\w+)=([\d\.]+)\/([\d]+)/gm 65 | 66 | try { 67 | const [_, label, ip, prefix] = reg.exec(subnet) as any 68 | 69 | const list = new net.BlockList() 70 | list.addSubnet(ip, parseInt(prefix)) 71 | const match = list.check(NodeAddr) 72 | 73 | if (match) found = label 74 | } catch (error: any) { 75 | console.log(error.message) 76 | } 77 | } 78 | 79 | if (found) { 80 | // get current node spec 81 | let { Spec, Version } = await node(NodeID) 82 | 83 | // update current node spec 84 | Spec.Labels = { ...Spec.Labels, subnet: found } 85 | await docker(`nodes/${NodeID}/update?version=${Version.Index}`, 'POST', Spec) 86 | 87 | console.log('node subnet is:', found) 88 | 89 | // this node should now have a label called "subnet" 90 | 91 | // VERIFY (DEV) 92 | // let { Spec: tmp } = await node(NodeID) 93 | // console.log(tmp) 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | /** The Internal IP of a Agent. */ 2 | export type DNS = string 3 | 4 | /** The name of a Docker Service. */ 5 | export type ServiceName = string 6 | 7 | export interface CPUUsage { 8 | cpu: number 9 | time: number 10 | } 11 | 12 | export interface AutoscalerSettings { 13 | min: number 14 | max: number 15 | up: { cpu: number } 16 | down: { cpu: number } 17 | } 18 | 19 | export interface Tasks { 20 | /** For what Service is this task. */ 21 | service: ServiceName 22 | /** What is the task? */ 23 | name: 'SCALE_UP' | 'SCALE_DOWN' 24 | /** Current CPU usage. */ 25 | cpuUsage: CPUUsage 26 | /** Autoscaler options (if available). */ 27 | autoscaler?: AutoscalerSettings 28 | } 29 | -------------------------------------------------------------------------------- /src/www/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yandeu/docker-swarm-visualizer/6ec0129b4d2ff8a82198f961388dfafa9d4187c8/src/www/favicon.ico -------------------------------------------------------------------------------- /src/www/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Swarm Visualizer 8 | 9 | 10 | 11 | 12 |
13 |
14 |

Developed and maintained by @yandeu

15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/www/js/elements.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @author Yannick Deubel (https://github.com/yandeu) 3 | * @copyright Copyright (c) 2021 Yannick Deubel 4 | * @license {@link https://github.com/yandeu/docker-swarm-visualizer/blob/main/LICENSE LICENSE} 5 | */ 6 | 7 | import { toPercent, toMb, toGb, ipToId, calculateCPUUsage, toMiB } from './misc.js' 8 | 9 | // keep track of services 10 | const services: string[] = [] 11 | // keep track of deleted containers 12 | const deletedContainers: string[] = [] 13 | 14 | const node = node => { 15 | const { Addr, Role, Availability, State, Hostname} = node 16 | 17 | const status = State === 'down' ? 'red' : 'yellow blink' 18 | 19 | const placeholders = `
  • ...  /  ...  /  ...
  • 20 |
  • ...  /  ...  /  ...
  • ` 21 | 22 | return ` 23 |
    24 |
    25 |
      26 |
    • 27 |
      34 |

      35 | ${Addr}  36 | ${Role === 'manager' ? '' : ''} 37 |
      38 |
    • 39 |
    • -
    • 40 |
    • ${Hostname}
    • 41 |
    • ${Role} / ${Availability} / ${State}
    • 42 | ${State !== 'down' ? placeholders : ''} 43 |
    44 |
    45 |
    46 |
    ` 47 | } 48 | 49 | const completeNode = (id, ip /* internal IP*/, containers) => { 50 | const nodeHTML = document.getElementById(id) 51 | if (nodeHTML) { 52 | const child = nodeHTML.lastElementChild // node-container 53 | 54 | let currentIds = child ? Array.from(child.children).map(child => child.id) : [] 55 | 56 | containers.forEach(container => { 57 | const containerId = container.id 58 | 59 | // check nodeAddress as fetched 60 | if (currentIds) { 61 | const index = currentIds.indexOf(containerId) 62 | if (index > -1) currentIds.splice(index, 1) 63 | } 64 | 65 | const existing = document.getElementById(containerId) 66 | 67 | // replace 68 | if (existing) { 69 | existing.replaceWith(container) 70 | } 71 | // add new 72 | else { 73 | if (child) child.appendChild(container) 74 | } 75 | }) 76 | 77 | // remove container that do not exist anymore 78 | 79 | if (currentIds) 80 | currentIds.forEach(id => { 81 | const el = document.getElementById(id) 82 | if (el) el.remove() 83 | }) 84 | } 85 | 86 | const circle = nodeHTML ? nodeHTML.querySelector('.circle') : null 87 | if (circle) { 88 | circle.classList.remove('blink') 89 | circle.classList.replace('yellow', 'green') 90 | } 91 | 92 | // add listeners to action (for now only remove action) 93 | const actions: any = nodeHTML ? nodeHTML.querySelectorAll('.action') : [] 94 | actions.forEach(action => { 95 | action.addEventListener('click', () => { 96 | const id = action.parentElement.getAttribute('id') 97 | console.log('remove container', id, 'on', ip) 98 | 99 | fetch(`/api/dev/agent/${ip}/containers/${id}`, { method: 'DELETE' }) 100 | .then(() => { 101 | deletedContainers.push(id) 102 | action.parentElement.classList.add('deleting') 103 | setTimeout(() => { 104 | action.parentElement.remove() 105 | }, 650) 106 | console.log('Successfully removed!') 107 | }) 108 | .catch(() => { 109 | console.warn('Could not remove container.') 110 | }) 111 | }) 112 | }) 113 | } 114 | 115 | const container = (container, MemTotal) => { 116 | // console.log('Container Status: ', container.Status) 117 | // console.log('Name: ', container.Stats.name) 118 | 119 | const memory_stats = container.Stats.memory_stats 120 | 121 | // https://github.com/docker/cli/blob/5f07d7d5a12423c0bc1fb507f4d006ad0cdfef42/cli/command/container/stats_helpers.go#L239 122 | const mem = memory_stats.usage - memory_stats?.stats?.total_inactive_file || 0 123 | const memPercent = (mem / memory_stats.limit) * 100 124 | 125 | const cpuUsage = calculateCPUUsage(container.Stats) 126 | 127 | const { Image, Names, Labels, State, Status, Id } = container 128 | 129 | if (deletedContainers.indexOf(Id) > 0) return 'DELETED' 130 | 131 | // add colors to services 132 | const colors = ['blue', 'yellow', 'red', 'green', 'orange', 'violet'] 133 | let service = Labels['com.docker.swarm.service.name'] ?? '' 134 | if (service && !services.includes(service)) services.push(service) 135 | const color = service && colors[services.indexOf(service) % colors.length] 136 | 137 | const name = 138 | Labels['com.docker.swarm.service.name'] || `${Image} / ${Names.map(n => n.replace(/^\//gm, '')).join(', ')}` 139 | const action = State !== 'running' ? `` : '' 140 | 141 | const html = ` 142 |
    143 | ${action} 144 |
      145 |
    • ${name}
    • 146 |
    • ${State}
    • 147 |
    • ${Status}
    • 148 |
    • MEM ${toMiB(mem)}MiB
    • 149 |
    • MEM ${memPercent.toFixed(2)}%
    • 150 |
    • CPU ${cpuUsage}
    • 151 |
    152 |
    153 | `.trim() 154 | 155 | const template = document.createElement('template') 156 | template.innerHTML = html.trim() 157 | 158 | return template.content.firstChild 159 | } 160 | 161 | export const elements = { 162 | node, 163 | container, 164 | complete: { 165 | node: completeNode 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /src/www/js/get.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @author Yannick Deubel (https://github.com/yandeu) 3 | * @copyright Copyright (c) 2021 Yannick Deubel 4 | * @license {@link https://github.com/yandeu/docker-swarm-visualizer/blob/main/LICENSE LICENSE} 5 | */ 6 | 7 | import { DNS } from '../../types' 8 | 9 | const agentsDns = async (): Promise => { 10 | try { 11 | const url = '/api/dev/agents/dns' 12 | const res = await fetch(url) 13 | const json = await res.json() 14 | return json 15 | } catch (err: any) { 16 | console.error(err.message) 17 | return [] 18 | } 19 | } 20 | 21 | const nodes = async () => { 22 | try { 23 | const url = '/api/dev/nodes' 24 | const res = await fetch(url) 25 | const json = await res.json() 26 | return json 27 | } catch (err: any) { 28 | console.error(err.message) 29 | return null 30 | } 31 | } 32 | 33 | const info = async ip => { 34 | try { 35 | const url = `/api/dev/${ip}/info` 36 | const res = await fetch(url) 37 | const json = await res.json() 38 | return json 39 | } catch (err: any) { 40 | console.error(err.message) 41 | return null 42 | } 43 | } 44 | 45 | const containers = async ip => { 46 | try { 47 | const url = `/api/dev/${ip}/containers` 48 | const res = await fetch(url) 49 | const json = await res.json() 50 | return json 51 | } catch (err: any) { 52 | console.error(err.message) 53 | return null 54 | } 55 | } 56 | 57 | export const get = { 58 | agentsDns, 59 | containers, 60 | nodes, 61 | info 62 | } 63 | -------------------------------------------------------------------------------- /src/www/js/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @author Yannick Deubel (https://github.com/yandeu) 3 | * @copyright Copyright (c) 2021 Yannick Deubel 4 | * @license {@link https://github.com/yandeu/docker-swarm-visualizer/blob/main/LICENSE LICENSE} 5 | */ 6 | 7 | import { toPercent, toMb, toGb, ipToId, addOrangeCircle, toGiB } from './misc.js' 8 | import { get } from './get.js' 9 | import { elements } from './elements.js' 10 | 11 | const nodesHTML = document.getElementById('nodes') 12 | 13 | const addNodes = async nodes => { 14 | // create nodes 15 | nodes.forEach(node => { 16 | const nodeHTML: any = elements.node(node).trim() 17 | const template: any = document.createElement('template') 18 | template.innerHTML = nodeHTML 19 | 20 | const html: any = template.content.firstChild 21 | 22 | // do add if it does not already exist 23 | if (!document.getElementById(html.id)) { 24 | if (nodesHTML) { 25 | nodesHTML.appendChild(html) 26 | 27 | // open uploader 28 | const uploadAction = html?.querySelector('.upload-action') 29 | if (uploadAction) { 30 | const listener = () => { 31 | const dropWrapper = document.getElementById('drop-wrapper') 32 | if (dropWrapper) dropWrapper.classList.toggle('is-hidden') 33 | } 34 | uploadAction.removeEventListener('click', listener) 35 | uploadAction.addEventListener('click', listener) 36 | } 37 | } 38 | } 39 | }) 40 | 41 | return nodes 42 | } 43 | 44 | const addContainersToNode = async (NodeAddrID, ip, MemTotal) => { 45 | // fill nodes with containers 46 | // const batch = ips.map(async ip => { 47 | const containers = await get.containers(ip) 48 | if (!containers) return 49 | 50 | const sortRunningOnTop = (a, b) => { 51 | if (a.State === b.State) return 0 52 | if (a.State === 'running') return -1 53 | else return 1 54 | } 55 | 56 | let _containers: any[] = [] 57 | 58 | containers.sort(sortRunningOnTop).forEach(container => { 59 | _containers.push(elements.container(container, MemTotal)) 60 | }) 61 | 62 | // filter out manually deleted containers (in this case c would be 'DELETED') 63 | _containers = _containers.filter(c => typeof c !== 'string') 64 | 65 | elements.complete.node(NodeAddrID, ip, _containers) 66 | 67 | return 68 | } 69 | 70 | const main = async () => { 71 | const nodes = await get.nodes() 72 | if (!nodes) return 73 | 74 | await addNodes(nodes) 75 | 76 | // array of all "ready"-node addresses (ip) 77 | let nodeAddresses = nodes.filter(n => n.State === 'ready').map(n => n.Addr) 78 | 79 | // get agents/dns (ip of the agents inside the visualizer overlay network) 80 | const ips = await get.agentsDns() 81 | if (ips.length === 0) return 82 | 83 | // console.log('nodeAddresses', nodeAddresses) 84 | // console.log("Agent IP's", ips) 85 | 86 | // (for each node) 87 | const batch = ips.map(async ip => { 88 | // get more info about that node 89 | const info = await get.info(ip) 90 | if (!info) return 91 | 92 | // check nodeAddress as fetched 93 | const index = nodeAddresses.indexOf(info.NodeAddr) 94 | if (index > -1) nodeAddresses.splice(index, 1) 95 | 96 | // update stats of node 97 | let { NodeAddr, MemTotal, cpuCount, cpuUsage, disk, memUsage, OperatingSystem, gpuUse, gpumemTot, gpumemUtil } = info 98 | 99 | if (gpuUse.includes('nvidia-smi: not found')) { 100 | gpuUse = 'NA' 101 | } 102 | else { gpuUse = gpuUse.replace(" ","")} 103 | if (gpumemTot.includes('nvidia-smi: not found')) { 104 | gpumemTot = 'NA' 105 | } 106 | else { gpumemTot = gpumemTot.replace(" ",""); 107 | gpumemTot = parseInt(gpumemTot, 10); 108 | gpumemTot = `${(gpumemTot/1024).toFixed(3)}G`; 109 | } 110 | if (gpumemUtil.includes('nvidia-smi: not found')) { 111 | gpumemUtil = 'NA' 112 | } 113 | else { gpumemUtil = gpumemUtil.replace(" ","")} 114 | 115 | const NodeAddrID = ipToId(NodeAddr) 116 | const nodeHTML = document.getElementById(NodeAddrID) 117 | if (nodeHTML) { 118 | // replace os 119 | const os = nodeHTML.querySelector('.os') 120 | if (os) os.innerHTML = OperatingSystem 121 | 122 | // replace usage 123 | const usage = nodeHTML.querySelector('.usage') 124 | if (usage) usage.innerHTML = `${toGiB(MemTotal)}G / ${cpuCount} Cors / ${disk?.Size} / ${gpumemTot}` 125 | 126 | // replace usage_percent 127 | const usage_percent = nodeHTML.querySelector('.usage_percent') 128 | if (usage_percent) usage_percent.innerHTML = `${memUsage}% / ${cpuUsage}% / ${disk?.Percent} / ${gpumemUtil} / ${gpuUse}` 129 | } 130 | 131 | // add containers 132 | await addContainersToNode(NodeAddrID, ip, MemTotal) 133 | 134 | return 135 | }) 136 | await Promise.all(batch) 137 | 138 | // the remaining nodes that could somehow not be accessed (maybe they are restarting or not yet marked as "down") 139 | nodeAddresses.forEach(ip => { 140 | addOrangeCircle(ip) 141 | }) 142 | } 143 | main() 144 | 145 | setInterval(() => { 146 | main() 147 | }, 5000) 148 | -------------------------------------------------------------------------------- /src/www/js/misc.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @author Yannick Deubel (https://github.com/yandeu) 3 | * @copyright Copyright (c) 2021 Yannick Deubel 4 | * @license {@link https://github.com/yandeu/docker-swarm-visualizer/blob/main/LICENSE LICENSE} 5 | */ 6 | 7 | export const toPercent = value => { 8 | return (value * 100).toFixed(2) + '%' 9 | } 10 | 11 | export const toMb = value => { 12 | return Math.round(value / 1000 / 1000) 13 | } 14 | 15 | export const toMiB = value => { 16 | return Math.round(value / 1024 / 1024) 17 | } 18 | 19 | export const toGb = value => { 20 | return (value / 1000 / 1000 / 1000).toFixed(3) 21 | } 22 | 23 | export const toGiB = value => { 24 | return (value / 1024 / 1024 / 1024).toFixed(3) 25 | } 26 | 27 | export const ipToId = id => { 28 | return id.replace(/\./gm, '-') 29 | } 30 | 31 | export const addOrangeCircle = ip => { 32 | const id = ipToId(ip) 33 | 34 | const nodeHTML = document.getElementById(id) 35 | if (!nodeHTML) return 36 | 37 | const circle = nodeHTML.querySelector('.circle') 38 | if (!circle) return 39 | 40 | circle.classList.remove('blink') 41 | circle.classList.replace('yellow', 'orange') 42 | } 43 | 44 | export const calculateCPUUsage = Stats => { 45 | // 46 | // https://github.com/moby/moby/blob/eb131c5383db8cac633919f82abad86c99bffbe5/cli/command/container/stats_helpers.go#L175-L188 47 | // https://stackoverflow.com/questions/35692667/in-docker-cpu-usage-calculation-what-are-totalusage-systemusage-percpuusage-a 48 | 49 | let cpuPercent = 0.0 50 | 51 | try { 52 | const cpuDelta = Stats.cpu_stats.cpu_usage.total_usage - Stats.precpu_stats.cpu_usage.total_usage 53 | 54 | const systemDelta = Stats.cpu_stats.system_cpu_usage - Stats.precpu_stats.system_cpu_usage 55 | 56 | if (systemDelta > 0.0 && cpuDelta > 0.0) cpuPercent = (cpuDelta / systemDelta) * Stats.cpu_stats.online_cpus * 100.0 57 | 58 | return cpuPercent.toFixed(0) + '%' 59 | } catch (error) { 60 | return cpuPercent.toFixed(0) + '%' 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/www/js/snackbar.ts: -------------------------------------------------------------------------------- 1 | export class Snackbar { 2 | constructor(message: string) { 3 | const snacks = document.getElementById('snacks') 4 | if (!snacks) return 5 | 6 | const snackbar = document.createElement('div') 7 | snackbar.classList.add('snack') 8 | snackbar.innerText = message 9 | snacks.appendChild(snackbar) 10 | 11 | setTimeout(() => snackbar.remove(), 5000) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/www/js/upload.ts: -------------------------------------------------------------------------------- 1 | import { Snackbar } from './snackbar.js' 2 | 3 | // https://developer.mozilla.org/en-US/docs/Web/API/HTML_Drag_and_Drop_API/Drag_operations 4 | export const dropContainer = ` 5 |
    6 |

    7 | Drop Here 8 |
    9 |

      10 |
    • STACK_NAME.stack.yml
    • 11 |
    • stack.STACK_NAME.yml
    • 12 |
    • SECRET_NAME.txt
    • 13 |
    • SECRET_NAME.json
    • 14 |
    15 |

    16 |
    17 | 18 | ` 19 | 20 | export const dropContainerInit = () => { 21 | // const fileInput = document.getElementById('file-input') as HTMLInputElement 22 | // if (!fileInput) return 23 | 24 | const container = document.getElementById('drop-container') 25 | if (!container) return 26 | 27 | container.addEventListener('drop', (event: DragEvent) => { 28 | event.preventDefault() 29 | 30 | if (!event?.dataTransfer?.files[0]) return 31 | 32 | new Snackbar(`Uploading. Please wait...`) 33 | 34 | for (let i = 0; i < event.dataTransfer.files.length; i++) { 35 | const file = event.dataTransfer.files[i] 36 | const name = file.name 37 | 38 | const reader = new FileReader() 39 | reader.onload = async event => { 40 | if (event?.target?.result) { 41 | const result = await fetch('/upload', { 42 | // const result = await fetch('/secret/create', { 43 | method: 'POST', 44 | headers: { 45 | 'Content-Type': 'application/json' 46 | }, 47 | body: JSON.stringify({ name, stack: event.target.result, secret: event.target.result }) 48 | }) 49 | const json = await result.json() 50 | const messages = json.msg.replace(/\n$/, '').split('\n') 51 | 52 | messages.forEach(m => { 53 | if (result.status === 200) new Snackbar(m) 54 | console.log('=> ', m) 55 | }) 56 | 57 | if (result.status !== 200) new Snackbar(`${name}: ${messages[messages.length - 1]}`) 58 | 59 | // remove dropbox 60 | const box = document.getElementById('drop-wrapper') 61 | if (box) box.classList.add('is-hidden') 62 | } 63 | } 64 | 65 | reader.readAsText(file) 66 | } 67 | }) 68 | 69 | container.addEventListener('dragover', (event: DragEvent) => { 70 | event.preventDefault() 71 | }) 72 | container.addEventListener('dragenter', (event: DragEvent) => { 73 | event.preventDefault() 74 | container.classList.toggle('over') 75 | }) 76 | container.addEventListener('dragleave', (event: DragEvent) => { 77 | event.preventDefault() 78 | container.classList.toggle('over') 79 | }) 80 | } 81 | 82 | const main = () => { 83 | // --black: #0c0e14; 84 | // --white: #f8f8f2; 85 | 86 | const style = document.createElement('style') 87 | style.innerText = /* css */ ` 88 | #drop-wrapper { position: fixed; top: 33%; left: 50%; transform: translate(-50%, -50%); z-index: 999; } 89 | #drop-container { box-sizing: border-box; padding: 16px; font-size: 18px; color: #f8f8f2; background: #0c0e14e0; border-radius: 5px; width:280px; height:200px; border: 10px dashed #f8f8f2; text-align: center; vertical-align: middle; } 90 | #drop-container.over { background:#6272a4e0; } 91 | #drop-container li { line-height: 1.6; font-size: 12px; } 92 | ` 93 | 94 | document.body.prepend(style) 95 | 96 | const div = document.createElement('div') 97 | div.id = 'drop-wrapper' 98 | div.classList.add('is-hidden') 99 | div.innerHTML = dropContainer 100 | document.body.prepend(div) 101 | 102 | dropContainerInit() 103 | } 104 | 105 | main() 106 | -------------------------------------------------------------------------------- /src/www/style.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --black: #0c0e14; 3 | --white: #f8f8f2; 4 | --gray: #6272a4; 5 | --blue: #8be9fd; 6 | --green: #50fa7b; 7 | --orange: #ffb86c; 8 | --red: #ff79c6; 9 | --violet: #bd93f9; 10 | --yellow: #f1fa8c; 11 | } 12 | 13 | body { 14 | font-family: Arial, Helvetica, sans-serif; 15 | background: #0c0e14; 16 | color: #f8f8f2; 17 | font-size: 14px; 18 | margin: 0; 19 | padding: 0; 20 | overflow-x: hidden; 21 | } 22 | 23 | h1 { 24 | text-align: center; 25 | } 26 | 27 | /* colors */ 28 | .is-blue { 29 | color: var(--blue); 30 | } 31 | .is-green { 32 | color: var(--green); 33 | } 34 | .is-orange { 35 | color: var(--orange); 36 | } 37 | .is-red { 38 | color: var(--red); 39 | } 40 | .is-violet { 41 | color: var(--violet); 42 | } 43 | .is-yellow { 44 | color: var(--yellow); 45 | } 46 | 47 | .is-hidden { 48 | display: none; 49 | } 50 | 51 | /* grid inspired by https://codepen.io/rickyruiz/pen/KemeoX */ 52 | #nodes { 53 | min-height: calc(100vh - 3em); 54 | display: grid; 55 | grid-gap: 4px; 56 | padding: 1em; 57 | counter-reset: grid-items; 58 | position: relative; 59 | grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); 60 | } 61 | 62 | .node { 63 | display: flex; 64 | flex-direction: column; 65 | justify-content: space-between; 66 | text-align: center; 67 | margin: 4px; 68 | overflow: hidden; 69 | border: 1px #6272a4 solid; 70 | border-radius: 4px; 71 | background: #2c3348; 72 | counter-increment: grid-item; 73 | } 74 | .node.down { 75 | opacity: 0.5; 76 | } 77 | 78 | .node-info { 79 | position: relative; 80 | padding: 8px; 81 | padding-top: 0px; 82 | border-bottom: 1px #6272a4 solid; 83 | margin: 8px; 84 | margin-bottom: 0; 85 | min-height: 112px; 86 | } 87 | 88 | /* .node-containers {} */ 89 | 90 | .container { 91 | font-size: 12px; 92 | padding: 8px; 93 | margin: 12px; 94 | border-width: 1px; 95 | border-color: var(--gray); 96 | border-style: solid; 97 | border-radius: 2px; 98 | background: #242b40; 99 | overflow: hidden; 100 | opacity: 0.5; 101 | position: relative; 102 | 103 | max-height: 160px; 104 | transition: all 500ms linear; 105 | } 106 | .container.deleting { 107 | padding: 0px; 108 | margin: -6px; 109 | max-height: 0px; 110 | border: 0px #242b40 solid; 111 | opacity: 0; 112 | } 113 | .container.running { 114 | opacity: 1; 115 | } 116 | .container.exited { 117 | border-color: var(--black); 118 | } 119 | .container.blue { 120 | border-color: var(--blue); 121 | } 122 | .container.green { 123 | border-color: var(--green); 124 | } 125 | .container.orange { 126 | border-color: var(--orange); 127 | } 128 | .container.red { 129 | border-color: var(--red); 130 | } 131 | .container.violet { 132 | border-color: var(--violet); 133 | } 134 | .container.yellow { 135 | border-color: var(--yellow); 136 | } 137 | .container .action { 138 | display: flex; 139 | align-items: center; 140 | justify-content: center; 141 | 142 | position: absolute; 143 | font-size: 20px; 144 | right: 1px; 145 | bottom: 1px; 146 | color: white; 147 | background: transparent; 148 | line-height: 1.1; 149 | text-align: center; 150 | width: 32px; 151 | height: 32px; 152 | border-radius: 50%; 153 | border: none; 154 | cursor: pointer; 155 | } 156 | .container .action:hover { 157 | background: #676e84; 158 | animation: shake 500ms linear both; 159 | } 160 | @keyframes shake { 161 | 10%, 162 | 90% { 163 | transform: rotate(20deg); 164 | } 165 | 20%, 166 | 80% { 167 | transform: rotate(-20deg); 168 | } 169 | 30%, 170 | 50%, 171 | 70% { 172 | transform: rotate(10deg); 173 | } 174 | 40%, 175 | 60% { 176 | transform: rotate(-10deg); 177 | } 178 | } 179 | 180 | ul { 181 | margin: unset; 182 | padding: unset; 183 | } 184 | 185 | li { 186 | list-style: none; 187 | line-height: 1.4; 188 | } 189 | 190 | /* circles */ 191 | .circle { 192 | padding: 0; 193 | margin: 0; 194 | z-index: 10; 195 | margin-top: 2px; 196 | margin-right: 4px; 197 | border-radius: 50%; 198 | width: 10px; 199 | height: 10px; 200 | opacity: 1; 201 | } 202 | /* states */ 203 | .circle.red { 204 | background-color: #ff79c6; 205 | border: 2px #ff79c6 solid; 206 | } /* unavailable/down/stopped */ 207 | .circle.orange { 208 | background-color: #ffb86c; 209 | border: 2px #ffb86c solid; 210 | } /* warning/error */ 211 | .circle.yellow { 212 | background-color: #f1fa8c; 213 | border: 2px #f1fa8c solid; 214 | } /* loading */ 215 | .circle.green { 216 | background-color: #50fa7b; 217 | border: 2px #50fa7b solid; 218 | } /* available/up/running */ 219 | .circle.worker { 220 | background-color: transparent; 221 | } 222 | /* blinking state */ 223 | .blink { 224 | animation: blink 1s infinite; 225 | } 226 | @keyframes blink { 227 | 0% { 228 | opacity: 0.25; 229 | } 230 | 50% { 231 | opacity: 1; 232 | } 233 | 100% { 234 | opacity: 0.25; 235 | } 236 | } 237 | 238 | p#credits { 239 | color: #273340; 240 | position: relative; 241 | bottom: 0.5em; 242 | left: 1em; 243 | margin: 0; 244 | padding: 0; 245 | line-height: 1; 246 | } 247 | #credits a { 248 | color: #273340; 249 | } 250 | #credits a:hover { 251 | color: #9da8b5; 252 | } 253 | 254 | /** UPLOAD */ 255 | .upload-action { 256 | cursor: pointer; 257 | } 258 | .upload-action:hover { 259 | text-decoration: underline; 260 | } 261 | 262 | /** SNACKBAR */ 263 | 264 | #snacks { 265 | position: fixed; 266 | bottom: 0px; 267 | padding: 8px; 268 | z-index: 999; 269 | } 270 | .snack { 271 | color: var(--black); 272 | background: var(--white); 273 | padding: 8px 16px; 274 | margin: 8px; 275 | border-radius: 6px; 276 | width: min(380px, 100vw - 64px); 277 | font-size: 14px; 278 | line-height: 2; 279 | text-overflow: ellipsis; 280 | white-space: nowrap; 281 | overflow: hidden; 282 | } 283 | -------------------------------------------------------------------------------- /stack.dev.yml: -------------------------------------------------------------------------------- 1 | version: '3.9' 2 | 3 | services: 4 | tools: 5 | image: yandeu/dev-tools:dev 6 | deploy: 7 | placement: 8 | preferences: 9 | - spread: node.labels.subnet 10 | replicas: 2 11 | update_config: 12 | parallelism: 2 13 | order: start-first 14 | ports: 15 | - 3000:3000 16 | labels: 17 | - visualizer.autoscale.min=1 18 | - visualizer.autoscale.max=5 19 | - visualizer.autoscale.up.cpu=0.2 20 | - visualizer.autoscale.down.cpu=0.1 21 | 22 | - visualizer.autoupdate=true 23 | -------------------------------------------------------------------------------- /tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "rootDir": "src", 4 | "outDir": "dist", 5 | "strict": true, 6 | "esModuleInterop": true, 7 | "skipLibCheck": true, 8 | "forceConsistentCasingInFileNames": true 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /tsconfig.browser.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base.json", 3 | "compilerOptions": { 4 | "target": "ES2015", 5 | "module": "ES2015", 6 | "lib": ["es2020", "dom"], 7 | "moduleResolution": "node", 8 | "noImplicitAny": false 9 | }, 10 | "include": ["src/www/**/*"], 11 | "exclude": ["node_modules", "**/*.spec.ts"] 12 | } 13 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base.json", 3 | "compilerOptions": { 4 | "target": "ES2019", 5 | "module": "ESNext", 6 | "lib": ["es2020", "dom"], 7 | "moduleResolution": "node", 8 | "noImplicitAny": false 9 | }, 10 | "include": ["src/**/*"], 11 | "exclude": ["node_modules", "**/*.spec.ts", "src/www/**/*"] 12 | } 13 | --------------------------------------------------------------------------------