├── src ├── db │ └── .gitkeep ├── api │ ├── .nvmrc │ ├── .dockerignore │ ├── Dockerfile │ ├── .eslintrc.json │ ├── package.json │ ├── index-api.js │ ├── metricArrays.js │ └── controller.js ├── web │ ├── .nvmrc │ ├── .dockerignore │ ├── postcss.config.js │ ├── Dockerfile │ ├── client │ │ ├── index.html │ │ ├── index.jsx │ │ ├── style.scss │ │ ├── app.css │ │ ├── components │ │ │ ├── LineChart.jsx │ │ │ └── ContainerButton.jsx │ │ └── containers │ │ │ ├── App.jsx │ │ │ ├── Sidebar.jsx │ │ │ └── GraphDisplay.jsx │ ├── tailwind.config.js │ ├── .eslintrc.json │ ├── webpack.config.js │ └── package.json └── sensors │ └── sensor1 │ ├── .nvmrc │ ├── .dockerignore │ ├── Dockerfile │ ├── .eslintrc.json │ ├── package.json │ ├── dbInsert.js │ └── index.js ├── __tests ├── .nvmrc ├── cypress │ ├── fixtures │ │ └── example.json │ ├── e2e │ │ └── home_page.cy.js │ └── support │ │ ├── e2e.js │ │ └── commands.js ├── cypress.config.js └── package.json ├── .gitignore ├── .vscode ├── extensions.json └── settings.json ├── .env.example ├── LICENSE ├── install.yaml ├── docker-compose.yaml └── README.md /src/db/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /__tests/.nvmrc: -------------------------------------------------------------------------------- 1 | 18.12.1 -------------------------------------------------------------------------------- /src/api/.nvmrc: -------------------------------------------------------------------------------- 1 | v18.12.1 -------------------------------------------------------------------------------- /src/web/.nvmrc: -------------------------------------------------------------------------------- 1 | v18.12.1 -------------------------------------------------------------------------------- /src/sensors/sensor1/.nvmrc: -------------------------------------------------------------------------------- 1 | v18.12.1 -------------------------------------------------------------------------------- /src/api/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log -------------------------------------------------------------------------------- /src/sensors/sensor1/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log -------------------------------------------------------------------------------- /src/web/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | npm-debug.log -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .env 3 | package-lock.json 4 | .DS_Store 5 | dist/ -------------------------------------------------------------------------------- /src/web/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: [require('tailwindcss'), require('autoprefixer')], 3 | }; 4 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "dbaeumer.vscode-eslint", 4 | "ms-azuretools.vscode-docker" 5 | ], 6 | "unwantedRecommendations": [] 7 | } 8 | -------------------------------------------------------------------------------- /src/api/Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1 2 | 3 | FROM node:18 4 | 5 | WORKDIR /code 6 | 7 | COPY package*.json ./ 8 | RUN npm install 9 | 10 | COPY . . 11 | 12 | EXPOSE 8854 13 | -------------------------------------------------------------------------------- /src/web/Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1 2 | 3 | FROM node:18 4 | 5 | WORKDIR /code 6 | 7 | COPY package*.json ./ 8 | RUN npm install 9 | 10 | COPY . . 11 | 12 | EXPOSE 8855 13 | -------------------------------------------------------------------------------- /__tests/cypress/fixtures/example.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Using fixtures to represent data", 3 | "email": "hello@cypress.io", 4 | "body": "Fixtures are a great way to mock data for responses to routes" 5 | } 6 | -------------------------------------------------------------------------------- /src/sensors/sensor1/Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1 2 | 3 | FROM node:18 4 | 5 | WORKDIR /code 6 | 7 | COPY package*.json ./ 8 | RUN npm install 9 | 10 | COPY . . 11 | 12 | EXPOSE 8855 13 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | DB_INFLUXDB_INIT_MODE= 2 | DB_INFLUXDB_INIT_USERNAME= 3 | DB_INFLUXDB_INIT_PASSWORD= 4 | DB_INFLUXDB_INIT_ORG= 5 | DB_INFLUXDB_INIT_BUCKET= 6 | DB_INFLUXDB_INIT_RETENTION= 7 | DB_INFLUXDB_INIT_ADMIN_TOKEN= 8 | SENSORS_SENSOR1_LOOKUP_FREQUENCY= 9 | -------------------------------------------------------------------------------- /__tests/cypress.config.js: -------------------------------------------------------------------------------- 1 | const { defineConfig } = require("cypress"); 2 | 3 | module.exports = defineConfig({ 4 | e2e: { 5 | setupNodeEvents(on, config) { 6 | // implement node event listeners here 7 | }, 8 | baseUrl: 'http://localhost:8855', 9 | }, 10 | }); 11 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "editor.defaultFormatter": "dbaeumer.vscode-eslint", 4 | "editor.codeActionsOnSave": { 5 | "source.fixAll.eslint": true 6 | }, 7 | "eslint.validate": [ 8 | "javascript", 9 | "javascriptreact" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /src/web/client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | DockerWatch 6 | 7 | 8 | 9 | 10 |
11 | 12 | -------------------------------------------------------------------------------- /src/api/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "commonjs": true, 5 | "es2021": true 6 | }, 7 | "extends": "airbnb-base", 8 | "overrides": [ 9 | ], 10 | "parserOptions": { 11 | "ecmaVersion": "latest" 12 | }, 13 | "rules": { 14 | "no-console": "off" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/sensors/sensor1/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "commonjs": true, 5 | "es2021": true 6 | }, 7 | "extends": "airbnb-base", 8 | "overrides": [], 9 | "parserOptions": { 10 | "ecmaVersion": "latest" 11 | }, 12 | "rules": { 13 | "no-console": "off" 14 | } 15 | } -------------------------------------------------------------------------------- /src/web/tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | content: ['./client/**/*.{js,ts,jsx,tsx}'], 3 | theme: { 4 | extend: { 5 | colors: { 6 | 'l-blue': '#95B9E5', 7 | 'm-blue': '#3b76b7', 8 | 'd-blue': '#324a78', 9 | test: '#468DDA', 10 | }, 11 | }, 12 | }, 13 | plugins: [require('daisyui')], 14 | }; 15 | -------------------------------------------------------------------------------- /__tests/cypress/e2e/home_page.cy.js: -------------------------------------------------------------------------------- 1 | describe('Web Page', () => { 2 | it('successfully loads', () => { 3 | cy.visit('/') 4 | }) 5 | 6 | it('displays containers', () => { 7 | cy.visit('/') 8 | cy.get('button').should('have.length.gt', 3) 9 | }) 10 | 11 | it('displays graphs', () => { 12 | cy.visit('/') 13 | cy.contains('web').click() 14 | cy.get('canvas').should('have.length', 4) 15 | }) 16 | }) -------------------------------------------------------------------------------- /src/web/client/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import { BrowserRouter } from 'react-router-dom'; 4 | import App from './containers/App'; 5 | 6 | const root = ReactDOM.createRoot(document.getElementById('root')); 7 | root.render( 8 |
9 | 10 | 11 | 12 | 13 | 14 |
15 | // document.getElementById('root') 16 | ); 17 | -------------------------------------------------------------------------------- /src/sensors/sensor1/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sensor1", 3 | "version": "0.0.1", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "dev": "nodemon index.js", 8 | "start": "node index.js", 9 | "test": "echo \"Error: no test specified\" && exit 1" 10 | }, 11 | "keywords": [], 12 | "author": "", 13 | "license": "ISC", 14 | "devDependencies": { 15 | "eslint": "^8.29.0", 16 | "eslint-config-airbnb-base": "^15.0.0", 17 | "eslint-plugin-import": "^2.26.0", 18 | "nodemon": "^2.0.20" 19 | }, 20 | "dependencies": { 21 | "@influxdata/influxdb-client": "^1.33.0", 22 | "dotenv": "^16.0.3" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/api/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "api", 3 | "version": "0.0.1", 4 | "description": "", 5 | "main": "index-api.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "start": "node ./index-api.js" 9 | }, 10 | "keywords": [], 11 | "author": "", 12 | "license": "ISC", 13 | "dependencies": { 14 | "@influxdata/influxdb-client": "^1.33.0", 15 | "@influxdata/influxdb-client-apis": "^1.33.0", 16 | "cors": "^2.8.5", 17 | "dotenv": "^16.0.3", 18 | "express": "^4.18.2" 19 | }, 20 | "devDependencies": { 21 | "eslint": "^8.29.0", 22 | "eslint-config-airbnb-base": "^15.0.0", 23 | "eslint-plugin-import": "^2.26.0" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /__tests/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dockerwatch", 3 | "description": "Collect and visualize Docker container metrics over time.", 4 | "version": "1.0.0", 5 | "main": "index.js", 6 | "devDependencies": { 7 | "cypress": "^12.3.0" 8 | }, 9 | "scripts": { 10 | "test": "echo \"Error: no test specified\" && exit 1", 11 | "cypress:open": "cypress open" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "git+https://github.com/oslabs-beta/docker-watch-app.git" 16 | }, 17 | "keywords": [], 18 | "author": "", 19 | "license": "ISC", 20 | "bugs": { 21 | "url": "https://github.com/oslabs-beta/docker-watch-app/issues" 22 | }, 23 | "homepage": "https://github.com/oslabs-beta/docker-watch-app#readme" 24 | } 25 | -------------------------------------------------------------------------------- /__tests/cypress/support/e2e.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/e2e.js is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import './commands' 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') -------------------------------------------------------------------------------- /src/web/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2021": true 5 | }, 6 | "extends": [ 7 | "plugin:react/recommended", 8 | "airbnb" 9 | ], 10 | "overrides": [], 11 | "parserOptions": { 12 | "ecmaVersion": "latest", 13 | "sourceType": "module" 14 | }, 15 | "plugins": [ 16 | "react" 17 | ], 18 | "rules": { 19 | "jsx-quotes": [ 20 | "error", 21 | "prefer-single" 22 | ], 23 | "no-console": "off", 24 | "comma-dangle": [ 25 | "error", 26 | { 27 | "arrays": "never", 28 | "objects": "always-multiline", 29 | "imports": "never", 30 | "exports": "never", 31 | "functions": "never" 32 | } 33 | ], 34 | "react/prop-types": "off", 35 | "import/no-extraneous-dependencies": "off" 36 | } 37 | } -------------------------------------------------------------------------------- /src/web/client/style.scss: -------------------------------------------------------------------------------- 1 | .grid-container { 2 | background: rgb(233,205,220); 3 | background: radial-gradient(circle, rgba(233,205,220,1) 0%, rgba(218,229,240,1) 100%); 4 | display: grid; 5 | grid-template-columns: 0.5fr 1.5fr; 6 | grid-template-rows: 0.4fr 1.6fr; 7 | gap: 0px 0px; 8 | grid-template-areas: 9 | "header header" 10 | "sidebar main"; 11 | } 12 | .header { 13 | border: 1px solid; 14 | grid-area: header; 15 | text-align: center; 16 | } 17 | .sidebar { 18 | border: 1px solid; 19 | grid-area: sidebar; 20 | display: flex; 21 | flex-direction: column; 22 | justify-content: center; 23 | } 24 | .main { 25 | grid-area: main; 26 | border: 1px solid; 27 | } 28 | .container { 29 | border: 1px solid; 30 | height: 100px; 31 | width: 100%; 32 | display: flex; 33 | align-items: center; 34 | justify-content: center; 35 | } -------------------------------------------------------------------------------- /__tests/cypress/support/commands.js: -------------------------------------------------------------------------------- 1 | // *********************************************** 2 | // This example commands.js shows you how to 3 | // create various custom commands and overwrite 4 | // existing commands. 5 | // 6 | // For more comprehensive examples of custom 7 | // commands please read more here: 8 | // https://on.cypress.io/custom-commands 9 | // *********************************************** 10 | // 11 | // 12 | // -- This is a parent command -- 13 | // Cypress.Commands.add('login', (email, password) => { ... }) 14 | // 15 | // 16 | // -- This is a child command -- 17 | // Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... }) 18 | // 19 | // 20 | // -- This is a dual command -- 21 | // Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... }) 22 | // 23 | // 24 | // -- This will overwrite an existing command -- 25 | // Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 OSLabs Beta 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/web/client/app.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | .App { 6 | display: grid; 7 | grid-template-columns: 0.3fr 1.7fr; 8 | grid-template-rows: 0.1fr 1.9fr; 9 | gap: 0px 0px; 10 | grid-template-areas: 11 | "Header Header" 12 | "Sidebar Main"; 13 | text-align: center; 14 | min-height: 100vh; 15 | } 16 | 17 | .Header { 18 | grid-area: Header; 19 | display: flex; 20 | align-items: center; 21 | /* background-color: #2d5ca7; */ 22 | background-color: #2b7bd4; 23 | /* justify-content: center; */ 24 | } 25 | 26 | .Main { 27 | grid-area: Main; 28 | flex: flex; 29 | align-items: center; 30 | } 31 | 32 | .Sidebar { 33 | grid-area: Sidebar; 34 | display: flex; 35 | justify-content: center; 36 | } 37 | 38 | .sideScroll { 39 | height: 89vh; 40 | } 41 | 42 | .container { 43 | width: 100%; 44 | } 45 | 46 | .chartWrapper { 47 | position: relative; 48 | } 49 | 50 | .chartWrapper > canvas { 51 | position: absolute; 52 | left: 0; 53 | top: 0; 54 | pointer-events: none; 55 | } 56 | 57 | .chartAreaWrapper { 58 | width: 600px; 59 | overflow-x: scroll; 60 | } -------------------------------------------------------------------------------- /src/web/client/components/LineChart.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | // import { Chart } from "react-chartjs-2"; 3 | // import { Chart as ChartJS } from "chart.js/auto"; 4 | import { Chart } from 'chart.js/auto'; 5 | import { Line } from 'react-chartjs-2'; 6 | import zoomPlugin from 'chartjs-plugin-zoom'; 7 | 8 | Chart.register(zoomPlugin); 9 | 10 | function LineChart({ graphData, title }) { 11 | const options = { 12 | responsive: true, 13 | maintainAspectRatio: false, 14 | plugins: { 15 | legend: { 16 | position: 'top', 17 | }, 18 | title: { 19 | display: true, 20 | font: { 21 | size: 20, 22 | }, 23 | text: title, 24 | }, 25 | zoom: { 26 | zoom: { 27 | wheel: { 28 | enabled: true, 29 | speed: 0.02, 30 | }, 31 | mode: 'x', 32 | }, 33 | pan: { 34 | enabled: true, 35 | mode: 'x', 36 | }, 37 | }, 38 | }, 39 | elements: { 40 | point: { 41 | radius: 0, 42 | }, 43 | }, 44 | }; 45 | return ( 46 |
47 | 48 |
49 | ); 50 | } 51 | 52 | export default LineChart; 53 | -------------------------------------------------------------------------------- /src/web/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 3 | const Dotenv = require('dotenv-webpack'); 4 | 5 | module.exports = { 6 | entry: './client/index.jsx', 7 | output: { 8 | path: path.resolve(__dirname, 'dist'), 9 | filename: 'bundle.js', 10 | publicPath: '/', 11 | }, 12 | mode: process.env.NODE_ENV, 13 | module: { 14 | rules: [ 15 | { 16 | test: /\.jsx?/, 17 | exclude: /node_modules/, 18 | loader: 'babel-loader', 19 | options: { 20 | presets: ['@babel/env', '@babel/react'], 21 | }, 22 | }, 23 | { 24 | test: /\.css$/, 25 | exclude: /node_modules/, 26 | use: [ 27 | 'style-loader', 28 | { 29 | loader: 'css-loader', 30 | options: { 31 | importLoaders: 1, 32 | }, 33 | }, 34 | 'postcss-loader' 35 | ], 36 | } 37 | ], 38 | }, 39 | resolve: { 40 | extensions: ['.js', '.jsx'], 41 | }, 42 | devServer: { 43 | static: { 44 | directory: path.join(__dirname, 'dist'), 45 | publicPath: '/dist', 46 | }, 47 | }, 48 | plugins: [ 49 | new HtmlWebpackPlugin({ 50 | template: './client/index.html', 51 | filename: 'index.html', 52 | }), 53 | new Dotenv({ 54 | path: '../../.env', 55 | systemvars: true, 56 | }) 57 | ], 58 | }; 59 | -------------------------------------------------------------------------------- /install.yaml: -------------------------------------------------------------------------------- 1 | version: '3.9' 2 | 3 | services: 4 | sensor1: 5 | image: dockerwatch/sensor1:latest 6 | volumes: 7 | - /var/run/docker.sock:/var/run/docker.sock:ro 8 | environment: 9 | - DB_INFLUXDB_INIT_ADMIN_TOKEN=dockerwatch 10 | - DB_INFLUXDB_INIT_ORG=dockerwatch 11 | - DB_INFLUXDB_INIT_BUCKET=dockerwatch 12 | - DB_URL=http://db:8086 13 | - SENSORS_SENSOR1_LOOKUP_FREQUENCY=5000 14 | depends_on: 15 | - db 16 | command: ["npm", "run", "start"] 17 | db: 18 | image: influxdb:2.4 19 | environment: 20 | - DOCKER_INFLUXDB_INIT_MODE=setup 21 | - DOCKER_INFLUXDB_INIT_USERNAME=dockerwatch 22 | - DOCKER_INFLUXDB_INIT_PASSWORD=dockerwatch 23 | - DOCKER_INFLUXDB_INIT_ORG=dockerwatch 24 | - DOCKER_INFLUXDB_INIT_BUCKET=dockerwatch 25 | - DOCKER_INFLUXDB_INIT_RETENTION=1w 26 | - DOCKER_INFLUXDB_INIT_ADMIN_TOKEN=dockerwatch 27 | api: 28 | image: dockerwatch/api:latest 29 | ports: 30 | - '8854:8854' 31 | environment: 32 | - DB_INFLUXDB_INIT_ORG=dockerwatch 33 | - DB_INFLUXDB_INIT_BUCKET=dockerwatch 34 | - DB_INFLUXDB_INIT_ADMIN_TOKEN=dockerwatch 35 | - DB_URL=http://db:8086 36 | depends_on: 37 | - db 38 | - sensor1 39 | command: ["npm", "run", "start"] 40 | web: 41 | image: dockerwatch/frontend-web:latest 42 | ports: 43 | - '8855:8855' 44 | environment: 45 | - API_URL=http://127.0.0.1:8854 46 | depends_on: 47 | - api 48 | command: ["npm", "run", "dev:docker"] 49 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: '3.9' 2 | 3 | services: 4 | sensor1: 5 | build: ./src/sensors/sensor1 6 | volumes: 7 | - /var/run/docker.sock:/var/run/docker.sock:ro 8 | environment: 9 | - DB_INFLUXDB_INIT_ADMIN_TOKEN=${DB_INFLUXDB_INIT_ADMIN_TOKEN} 10 | - DB_INFLUXDB_INIT_ORG=${DB_INFLUXDB_INIT_ORG} 11 | - DB_INFLUXDB_INIT_BUCKET=${DB_INFLUXDB_INIT_BUCKET} 12 | - DB_URL=http://db:8086 13 | - SENSORS_SENSOR1_LOOKUP_FREQUENCY=${SENSORS_SENSOR1_LOOKUP_FREQUENCY} 14 | depends_on: 15 | - db 16 | command: ["npm", "run", "start"] 17 | db: 18 | image: influxdb:2.4 19 | ports: 20 | - '8086:8086' # TODO might not expose port in non-dev builds 21 | environment: 22 | - DOCKER_INFLUXDB_INIT_MODE=${DB_INFLUXDB_INIT_MODE} 23 | - DOCKER_INFLUXDB_INIT_USERNAME=${DB_INFLUXDB_INIT_USERNAME} 24 | - DOCKER_INFLUXDB_INIT_PASSWORD=${DB_INFLUXDB_INIT_PASSWORD} 25 | - DOCKER_INFLUXDB_INIT_ORG=${DB_INFLUXDB_INIT_ORG} 26 | - DOCKER_INFLUXDB_INIT_BUCKET=${DB_INFLUXDB_INIT_BUCKET} 27 | - DOCKER_INFLUXDB_INIT_RETENTION=${DB_INFLUXDB_INIT_RETENTION} 28 | - DOCKER_INFLUXDB_INIT_ADMIN_TOKEN=${DB_INFLUXDB_INIT_ADMIN_TOKEN} 29 | api: 30 | build: ./src/api 31 | ports: 32 | - '8854:8854' # TODO might not expose port in non-dev builds 33 | environment: 34 | - DB_INFLUXDB_INIT_ORG=${DB_INFLUXDB_INIT_ORG} 35 | - DB_INFLUXDB_INIT_BUCKET=${DB_INFLUXDB_INIT_BUCKET} 36 | - DB_INFLUXDB_INIT_ADMIN_TOKEN=${DB_INFLUXDB_INIT_ADMIN_TOKEN} 37 | - DB_URL=http://db:8086 38 | depends_on: 39 | - db 40 | - sensor1 41 | command: ["npm", "run", "start"] 42 | web: 43 | build: ./src/web 44 | ports: 45 | - '8855:8855' 46 | environment: 47 | # - API_URL=http://api:8854 # TODO explore mapping through Docker network 48 | - API_URL=http://127.0.0.1:8854 49 | depends_on: 50 | - api 51 | command: ["npm", "run", "dev:docker"] 52 | -------------------------------------------------------------------------------- /src/api/index-api.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | const express = require('express'); 3 | const cors = require('cors'); 4 | 5 | const controller = require('./controller'); 6 | 7 | // console.log(controller.getStatsFromDB); 8 | 9 | const PORT = 8854; 10 | const HOST = '0.0.0.0'; 11 | 12 | const app = express(); 13 | app.use(cors()); 14 | 15 | // send an object with all stats of a specific container id for the default time range 16 | app.get('/api/v1/containers/:id/stats/', controller.getContainerStats, (req, res) => { 17 | res.status(200).json(res.locals.stats); 18 | }); 19 | // send an object with all stats of a specific container id for a specific timeframe 20 | app.get('/api/v1/containers/:id/stats/:range/', controller.getContainerStats, (req, res) => { 21 | res.status(200).json(res.locals.stats); 22 | }); 23 | // send an object with specific stats of a specific container id 24 | app.get('/api/v1/containers/:id/stats/all/:metric', controller.getContainerStats, (req, res) => { 25 | res.status(200).json(res.locals.stats); 26 | }); 27 | // send an object with specific stats of a specific container id for a specified timeframe 28 | app.get('/api/v1/containers/:id/stats/:range/:metric', controller.getContainerStats, (req, res) => { 29 | res.status(200).json(res.locals.stats); 30 | }); 31 | // send an object with all running container ids and names 32 | app.get('/api/v1/containers', controller.getContainers, (req, res) => { 33 | res.status(200).json(res.locals.containers); 34 | }); 35 | 36 | app.get('/', (req, res) => { 37 | res.sendStatus(200); 38 | }); 39 | 40 | app.use('*', (req, res, next) => { 41 | const errorObj = { 42 | log: 'Page not found', 43 | status: 404, 44 | message: { err: 'Error 404: Page not Found' }, 45 | }; 46 | next(errorObj); 47 | }); 48 | 49 | app.use((err, req, res) => { 50 | const defaultErr = { 51 | log: `Express error handler caught unknown middleware error: ${err}`, 52 | status: 500, 53 | message: { err: 'An error occurred' }, 54 | }; 55 | console.log(defaultErr.log); 56 | res.status(defaultErr.status).send(defaultErr.message); 57 | }); 58 | 59 | app.listen(PORT, HOST, () => { 60 | console.log(`Running on http://${HOST}:${PORT}`); 61 | }); 62 | -------------------------------------------------------------------------------- /src/web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dockervision", 3 | "version": "0.0.1", 4 | "description": "", 5 | "main": "./client/index.js", 6 | "scripts": { 7 | "build:tailwindcss": "npx tailwindcss -i ./client/app.css -o ./dist/output.css", 8 | "dev": "concurrently \"npm run watch-css\" \"NODE_ENV=development webpack-dev-server --open\"", 9 | "dev:docker": "concurrently \"npm run build:tailwindcss\" \"NODE_ENV=development webpack-dev-server --port 8855 \"", 10 | "start": "NODE_ENV=production webpack-dev-server", 11 | "watch-css": "npx tailwindcss -i ./client/app.css -o ./dist/output.css --watch" 12 | }, 13 | "keywords": [], 14 | "author": "", 15 | "license": "ISC", 16 | "dependencies": { 17 | "@headlessui/react": "^1.7.5", 18 | "@heroicons/react": "^2.0.13", 19 | "@tailwindcss/aspect-ratio": "^0.4.2", 20 | "@tailwindcss/forms": "^0.5.3", 21 | "@tailwindcss/typography": "^0.5.8", 22 | "axios": "^1.2.1", 23 | "browser-router": "^0.2.0", 24 | "chartjs-plugin-zoom": "^2.0.0", 25 | "concurrently": "^7.6.0", 26 | "cors": "^2.8.5", 27 | "daisyui": "^2.45.0", 28 | "dotenv-webpack": "^8.0.1", 29 | "express": "^4.18.2", 30 | "influxdb": "^0.0.1", 31 | "postcss": "^8.4.20", 32 | "postcss-loader": "^7.0.2", 33 | "react": "^18.2.0", 34 | "react-chartjs-2": "^5.0.1", 35 | "react-dom": "^18.2.0", 36 | "react-router-dom": "^6.4.5", 37 | "uuid": "^9.0.0", 38 | "webpack-cli": "^5.0.1", 39 | "webpack-dev-server": "^4.11.1" 40 | }, 41 | "devDependencies": { 42 | "@babel/core": "^7.20.5", 43 | "@babel/preset-env": "^7.20.2", 44 | "@babel/preset-react": "^7.18.6", 45 | "babel-loader": "^9.1.0", 46 | "css-loader": "^6.7.3", 47 | "eslint": "^8.31.0", 48 | "eslint-config-airbnb": "^19.0.4", 49 | "eslint-plugin-import": "^2.26.0", 50 | "eslint-plugin-jsx-a11y": "^6.6.1", 51 | "eslint-plugin-react": "^7.31.11", 52 | "eslint-plugin-react-hooks": "^4.6.0", 53 | "html-webpack-plugin": "^5.5.0", 54 | "nodemon": "^2.0.20", 55 | "sass": "^1.56.2", 56 | "sass-loader": "^13.2.0", 57 | "style-loader": "^3.3.1", 58 | "tailwindcss": "^3.2.4", 59 | "webpack": "^5.75.0" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/api/metricArrays.js: -------------------------------------------------------------------------------- 1 | // takes in data and formats as object of arrays primed for chartjs 2 | const getMetricArrays = (data) => { 3 | // represent arrays as object of arrays 4 | const metricArrays = { 5 | dates: [], 6 | times: [], 7 | cpu_percentage: [], 8 | Memory_memory_usage: [], 9 | Network_rx_bytes: [], 10 | // Network_rx_dropped: [], 11 | // Network_rx_errors: [], 12 | // Network_rx_packets: [], 13 | Network_tx_bytes: [], 14 | // Network_tx_dropped: [], 15 | // Network_tx_errors: [], 16 | // Network_tx_packets: [], 17 | Disk_read_value: [], 18 | Disk_write_value: [], 19 | }; 20 | 21 | // save previous timestamp 22 | let prevTime = 0; 23 | // iterate over every entry of object and store in empty arrays 24 | // eslint-disable-next-line no-restricted-syntax 25 | for (const [time, values] of Object.entries(data)) { 26 | // format time in human readable format 27 | const timestamp = new Date(time).toLocaleString('en-US', { timeZone: 'America/New_York' }); 28 | if (!prevTime) prevTime = time; 29 | const diff = Date.parse(time) - Date.parse(prevTime); 30 | if (diff > 8000) { 31 | const dummyTimeStamp = new Date(time - 5000).toLocaleString('en-US', { timeZone: 'America/New_York' }); 32 | metricArrays.dates.push(dummyTimeStamp.slice(0, 10)); 33 | metricArrays.cpu_percentage.push(NaN); 34 | metricArrays.Memory_memory_usage.push(NaN); 35 | metricArrays.Network_rx_bytes.push(NaN); 36 | metricArrays.Network_tx_bytes.push(NaN); 37 | metricArrays.Disk_read_value.push(NaN); 38 | metricArrays.Disk_write_value.push(NaN); 39 | } else { 40 | metricArrays.dates.push(timestamp.slice(0, 10)); 41 | // time only 42 | metricArrays.times.push(timestamp.slice(10)); 43 | metricArrays.cpu_percentage.push(values.cpu_percentage); 44 | metricArrays.Memory_memory_usage.push(values.Memory_memory_usage); 45 | metricArrays.Network_rx_bytes.push(values.Network_rx_bytes); 46 | // metricArrays.Network_rx_dropped.push(values.Network_rx_dropped); 47 | // metricArrays.Network_rx_errors.push(values.Network_rx_errors); 48 | // metricArrays.Network_rx_packets.push(values.Network_rx_packets); 49 | metricArrays.Network_tx_bytes.push(values.Network_tx_bytes); 50 | // metricArrays.Network_tx_dropped.push(values.Network_tx_dropped); 51 | // metricArrays.Network_tx_errors.push(values.Network_tx_errors); 52 | // metricArrays.Network_tx_packets.push(values.Network_tx_packets); 53 | metricArrays.Disk_read_value.push(values.Disk_read_value); 54 | metricArrays.Disk_write_value.push(values.Disk_write_value); 55 | } 56 | prevTime = time; 57 | // day date only 58 | } 59 | return metricArrays; 60 | }; 61 | 62 | module.exports = getMetricArrays; 63 | -------------------------------------------------------------------------------- /src/web/client/components/ContainerButton.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef } from 'react'; 2 | 3 | export default function Container({ 4 | id, text, setContainerData, intervalRef, idRef, containerRef, timeFrame, 5 | }) { 6 | const prevData = useRef({}); 7 | 8 | // fetch full container data from server 9 | const getInitialData = (containerId) => { 10 | const apiURL = process.env.API_URL || 'http://127.0.0.1:8854'; 11 | 12 | fetch(`${apiURL}/api/v1/containers/${containerId}/stats/${timeFrame}`) 13 | .then((response) => response.json()) 14 | .then((data) => { 15 | prevData.current = data; 16 | setContainerData(data); 17 | }) 18 | .catch((err) => console.log(err)); 19 | }; 20 | // fetch partial container data from server 21 | const getUpdatedData = (containerId) => { 22 | const apiURL = process.env.API_URL || 'http://127.0.0.1:8854'; 23 | 24 | fetch(`${apiURL}/api/v1/containers/${containerId}/stats/8s`) 25 | .then((response) => response.json()) 26 | .then((data) => { 27 | const newContainerData = {}; 28 | // eslint-disable-next-line no-restricted-syntax 29 | for (const key of Object.keys(data)) { 30 | const newData = [...prevData.current[key], ...data[key]]; 31 | newContainerData[key] = newData; 32 | } 33 | prevData.current = newContainerData; 34 | setContainerData(newContainerData); 35 | }) 36 | .catch((err) => { 37 | console.log(err); 38 | }); 39 | }; 40 | /* a function that clears the current running setInterval that is stored in 41 | useRef (see app) and the runs a new set inerval for the newly clicked container */ 42 | const containerOnClick = (containerId, didTimeChange = false) => { 43 | if (containerId === idRef.current && !didTimeChange) return; 44 | idRef.current = containerId; 45 | containerRef.current = containerId; 46 | // clears the value at current in useRef 47 | clearInterval(intervalRef.current); 48 | // initial call to containerOnClick to get initial metric data 49 | getInitialData(containerId); 50 | /* subsequent calls to containerOnClick every 5 seconds. 51 | This is stored inside of useRef to be cleared when the next container is clicked. */ 52 | intervalRef.current = setInterval(() => getUpdatedData(containerId), 5000); 53 | }; 54 | 55 | useEffect(() => { 56 | if (idRef.current !== 0) containerOnClick(idRef.current, true); 57 | }, [timeFrame]); 58 | 59 | return ( 60 | 68 | ); 69 | } 70 | -------------------------------------------------------------------------------- /src/web/client/containers/App.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useRef } from 'react'; 2 | import Sidebar from './Sidebar'; 3 | import GraphContainer from './GraphDisplay'; 4 | import '../app.css'; 5 | 6 | function App() { 7 | // state that contains metric data on the currently clicked container 8 | const [containerData, setContainerData] = useState({}); 9 | 10 | return ( 11 |
12 |
13 |

14 | DockerWatch 15 | {' '} 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 |

29 |
30 | 34 | 38 |
39 | ); 40 | } 41 | 42 | export default App; 43 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | 5 |

6 | 7 | 8 | 9 |

10 | 11 | # Table of Contents 12 | 13 | - [About](#about) 14 | - [Installation](#installation) 15 | - [How to Use](#how-to-use) 16 | - [How it Works](#how-it-works) 17 | - [Authors](#authors) 18 | - [License](#license) 19 | 20 | 21 | ## About 22 | 23 | DockerWatch is a containerized application that collects and visualizes Docker container metrics over time. 24 | 25 | ## Installation 26 | 27 | ### Prerequisites 28 | 29 | - Download [Docker Desktop](https://www.docker.com/products/docker-desktop/). 30 | 31 | ### Setup 32 | 33 | Run the following code in your terminal: 34 | 35 | ``` 36 | curl https://raw.githubusercontent.com/oslabs-beta/docker-watch-app/main/install.yaml | docker-compose -p dockerwatch -f - up -d 37 | ``` 38 | 39 | ## How to Use 40 | 41 | 1. After installing, open your web brower and visit [http://localhost:8855](http://http://localhost:8855) 42 | 43 | 2. To view a container, click one on the left-hand side to view monitored metrics, including CPU, Memory, Network, and Disk. 44 | 45 | 46 | 3. View smaller or larger timeframes of data by clicking the Change Timeframe button and selecting a range of time to view. One hour will view all data from the last hour, one day will view all data from the last day, etc. 47 | 48 | 49 | ## How It Works 50 | 51 | The DockerWatch container holds four smaller containers: a web container, an api container, an InfluxDB database container, and a sensor container. The sensor collects data from the Docker daemon and stores it in the database. The API container queries for data from the database upon request from the web container. The CPU, Memory, Network, and Disk metrics for all containers in Docker Desktop, including DockerWatch, are monitored for as long as the DockerWatch container is running. 52 | 53 | ## Authors 54 | 55 | - Brynn Sakell [@BrynnSakell](https://github.com/BrynnSakell) | [LinkedIn](https://linkedin.com/in/brynnsakell) 56 | - Dan Pietsch [@dpietsch14](https://github.com/dpietsch14) | [LinkedIn](https://linkedin.com/in/danielpietsch14/) 57 | - Nadia Abowitz [@abowitzn](https://github.com/abowitzn) | [LinkedIn](https://linkedin.com/in/nadia-abowitz/) 58 | - Rob Mosher [@rob-mosher](https://github.com/rob-mosher) | [LinkedIn](https://linkedin.com/in/rob-mosher-it/) 59 | - Stephen Rivas [@stephenpharmd](https://github.com/stephenpharmd) | [LinkedIn](https://linkedin.com/in/stephenpharmd/) 60 | 61 | ## License 62 | 63 | This project is licensed under the [MIT License](LICENSE) 64 | 65 | ## Contributing 66 | 67 | DockerWatch launched on January 12, 2023 and is currently in active beta development through the OSlabs Beta community initiative. The application is licensed under the terms of the MIT license, making it a fully open source product. Developers are welcome to contribute to the codebase and expand on its features. 68 | -------------------------------------------------------------------------------- /src/sensors/sensor1/dbInsert.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config({ path: '../../../.env' }); 2 | 3 | /** @module write 4 | * Writes a data point to InfluxDB using the Javascript client library with Node.js. */ 5 | const { InfluxDB } = require('@influxdata/influxdb-client'); 6 | const { Point } = require('@influxdata/influxdb-client'); 7 | 8 | /* Environment variables * */ 9 | const token = process.env.DB_INFLUXDB_INIT_ADMIN_TOKEN; 10 | const org = process.env.DB_INFLUXDB_INIT_ORG; 11 | const bucket = process.env.DB_INFLUXDB_INIT_BUCKET; 12 | 13 | const dbFunc = (containerStats) => { 14 | const dbURL = process.env.DB_URL || 'http://127.0.0.1:8086'; 15 | 16 | // Create a write client from the getWriteApi method. Provide your `org` and `bucket` 17 | const client = new InfluxDB({ url: dbURL, token }); 18 | 19 | const writeApi = client.getWriteApi(org, bucket, 's'); 20 | 21 | // Create a point and write it to the buffer 22 | writeApi.useDefaultTags({ 23 | container_id: 'container_id', 24 | container_name: 'container_name', 25 | }); 26 | 27 | /* Use the Point() constructor to create a point, 28 | In InfluxDB, a point represents a single data record */ 29 | 30 | const pointCPU = new Point('CPU') 31 | .tag('container_id', containerStats.id) 32 | .tag('container_name', containerStats.name) 33 | .floatField('cpu_usage', containerStats.cpu_usage) 34 | .floatField('precpu_usage', containerStats.precpu_usage) 35 | .floatField('system_cpu_usage', containerStats.system_cpu_usage) 36 | .floatField('system_precpu_usage', containerStats.system_precpu_usage) 37 | .floatField('online_cpus', containerStats.online_cpus); 38 | 39 | const pointMemory = new Point('Memory') 40 | .tag('container_id', containerStats.id) 41 | .tag('container_name', containerStats.name) 42 | .floatField('memory_limit', containerStats.memory_limit) 43 | .floatField('memory_usage', containerStats.memory_usage); 44 | 45 | const pointDisk = new Point('Disk') 46 | .tag('container_id', containerStats.id) 47 | .tag('container_name', containerStats.name) 48 | .floatField('read_value', containerStats.disk_read) 49 | .floatField('write_value', containerStats.disk_write); 50 | 51 | const pointNetwork = new Point('Network') 52 | .tag('container_id', containerStats.id) 53 | .tag('container_name', containerStats.name) 54 | .floatField('rx_bytes', containerStats.rx_bytes) 55 | .floatField('rx_dropped', containerStats.rx_dropped) 56 | .floatField('rx_errors', containerStats.rx_errors) 57 | .floatField('rx_packets', containerStats.rx_packets) 58 | .floatField('tx_bytes', containerStats.tx_bytes) 59 | .floatField('tx_dropped', containerStats.tx_dropped) 60 | .floatField('tx_errors', containerStats.tx_errors) 61 | .floatField('tx_packets', containerStats.tx_packets); 62 | 63 | // Use the writePoint() method to write the point to your InfluxDB bucket 64 | writeApi.writePoint(pointCPU); 65 | writeApi.writePoint(pointMemory); 66 | writeApi.writePoint(pointDisk); 67 | writeApi.writePoint(pointNetwork); 68 | 69 | // Flush pending writes and close writeApi 70 | writeApi 71 | .close() 72 | // .then(() => { 73 | // console.log('FINISHED'); 74 | // }) 75 | .catch((e) => { 76 | console.error(e); 77 | console.log('Finished ERROR'); 78 | }); 79 | }; 80 | 81 | module.exports = dbFunc; 82 | -------------------------------------------------------------------------------- /src/web/client/containers/Sidebar.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useRef } from 'react'; 2 | import Container from '../components/ContainerButton'; 3 | 4 | export default function Sidebar({ containerData, setContainerData }) { 5 | const [containerList, updateContainerList] = useState([]); 6 | // const timeFrame = useRef('1h'); 7 | const [timeFrame, setTimeFrame] = useState('1h'); 8 | const [timeDisplay, setTimeDisplay] = useState('Past 1 hour'); 9 | /* contains the current running setInterval calling the 10 | function that requests the api for metric data for the last clicked container. */ 11 | const intervalRef = useRef(0); 12 | const idRef = useRef(0); 13 | 14 | // a hook to highlight the selected container 15 | const containerRef = useRef(''); 16 | // makes request to api for list of current running containers 17 | const getContainers = () => { 18 | const apiURL = process.env.API_URL || 'http://127.0.0.1:8081'; 19 | 20 | fetch(`${apiURL}/api/v1/containers`) 21 | .then((response) => response.json()) 22 | .then((data) => { 23 | updateContainerList(data); 24 | }) 25 | .catch((err) => console.log(err)); 26 | }; 27 | 28 | // calls getContainers on mount 29 | useEffect(() => { 30 | getContainers(); 31 | }, []); 32 | 33 | const timeRangeClicked = (str) => { 34 | setTimeFrame(str); 35 | 36 | const times = { 37 | '1m': 'Past 1 minute', 38 | '5m': 'Past 5 minutes', 39 | '15m': 'Past 15 minutes', 40 | '1h': 'Past 1 hour', 41 | '3h': 'Past 3 hours', 42 | '6h': 'Past 6 hours', 43 | '12h': 'Past 12 hours', 44 | '1d': 'Past 1 day', 45 | '7d': 'Past 7 days', 46 | }; 47 | 48 | setTimeDisplay(times[str]); 49 | }; 50 | 51 | // iterates through contaierList and returns an array of container buttons to render to sidebar 52 | const containerButtons = containerList.map((container) => ( 53 | 64 | )); 65 | return ( 66 | 91 | ); 92 | } 93 | -------------------------------------------------------------------------------- /src/sensors/sensor1/index.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config({ path: '../../../.env' }); 2 | 3 | const http = require('node:http'); 4 | const dbFunc = require('./dbInsert'); 5 | 6 | const getContainerIDsAndWriteToDB = () => { 7 | const containerIds = []; 8 | 9 | const clientOptions = { 10 | socketPath: '/var/run/docker.sock', 11 | path: '/v1.41/containers/json', 12 | method: 'GET', 13 | }; 14 | 15 | const client = http.request(clientOptions, (res) => { 16 | let body = []; 17 | 18 | res.on('data', (chunk) => { 19 | body.push(chunk); 20 | }); 21 | 22 | res.on('end', () => { 23 | body = JSON.parse(Buffer.concat(body)); 24 | 25 | // eslint-disable-next-line no-restricted-syntax 26 | for (const container of body) { 27 | containerIds.push(container.Id); 28 | // console.log(container.Id); 29 | } 30 | 31 | // iterate through container ids 32 | containerIds.forEach((id) => { 33 | // set path to access the stats at the current id, specify that stats only 34 | // need to be collected once 35 | const clientStatsOptions = { 36 | socketPath: '/var/run/docker.sock', 37 | path: `/v1.41/containers/${id}/stats?stream=false`, 38 | method: 'GET', 39 | }; 40 | // create a new client to get stats 41 | const clientStats = http.request(clientStatsOptions, (resStats) => { 42 | let statsBody = []; 43 | // collect the data 44 | resStats.on('data', (chunk) => { 45 | statsBody.push(chunk); 46 | }); 47 | // after collection, parse the buffer into a js object 48 | resStats.on('end', () => { 49 | statsBody = JSON.parse(Buffer.concat(statsBody)); 50 | if (!statsBody) return; 51 | 52 | try { 53 | dbFunc({ 54 | // CPU metrics: 55 | // total usage and previous usage (process time) 56 | cpu_usage: statsBody.cpu_stats.cpu_usage.total_usage, 57 | precpu_usage: statsBody.precpu_stats.cpu_usage.total_usage, 58 | // system total usage 59 | system_cpu_usage: statsBody.cpu_stats.system_cpu_usage, 60 | system_precpu_usage: statsBody.precpu_stats.system_cpu_usage, 61 | // Online CPUS 62 | online_cpus: statsBody.cpu_stats.online_cpus, 63 | 64 | // Disk utilization (Read and Write) 65 | disk_read: statsBody.blkio_stats.io_service_bytes_recursive[0].value, 66 | disk_write: statsBody.blkio_stats.io_service_bytes_recursive[1].value, 67 | 68 | // Container ID 69 | id: statsBody.id, 70 | 71 | // Memory stats: 72 | // memory limit 73 | memory_limit: statsBody.memory_stats.limit, 74 | // memory total usage 75 | memory_usage: statsBody.memory_stats.usage, 76 | 77 | // Container Name 78 | name: statsBody.name, 79 | 80 | // Network I/O: 81 | // displays the amount of received data 82 | rx_bytes: statsBody.networks.eth0.rx_bytes, 83 | // number of dropped packets for received data 84 | rx_dropped: statsBody.networks.eth0.rx_dropped, 85 | // displays the number of RX errors 86 | rx_errors: statsBody.networks.eth0.rx_errors, 87 | // displays the number of received packets 88 | rx_packets: statsBody.networks.eth0.rx_packets, 89 | // displays the amount of transmitted data 90 | tx_bytes: statsBody.networks.eth0.tx_bytes, 91 | // number of dropped packets for transmitted data 92 | tx_dropped: statsBody.networks.eth0.tx_dropped, 93 | // displays the number of TX errors 94 | tx_errors: statsBody.networks.eth0.tx_errors, 95 | // displays the number of transmitted packets 96 | tx_packets: statsBody.networks.eth0.tx_packets, 97 | }); 98 | } catch (error) { 99 | console.log(error); 100 | } 101 | }); 102 | }); 103 | 104 | clientStats.on('error', (err) => { 105 | console.log(err); 106 | }); 107 | 108 | clientStats.end(); 109 | }); 110 | }); 111 | }); 112 | 113 | client.on('error', (err) => { 114 | console.log(err); 115 | }); 116 | 117 | client.end(); 118 | }; 119 | 120 | const getLookupFrequency = () => { 121 | const defaultMinimumLookupInMS = 5000; 122 | const envLookupFrequency = Number(process.env.SENSORS_SENSOR1_LOOKUP_FREQUENCY); 123 | 124 | if (Number.isNaN(envLookupFrequency) || envLookupFrequency < defaultMinimumLookupInMS) { 125 | return defaultMinimumLookupInMS; 126 | } 127 | return envLookupFrequency; 128 | }; 129 | 130 | setInterval(getContainerIDsAndWriteToDB, getLookupFrequency()); 131 | -------------------------------------------------------------------------------- /src/web/client/containers/GraphDisplay.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | // import { v4 as uuidv4 } from 'uuid'; 3 | import LineChart from '../components/LineChart'; 4 | // format graph data for chartjs from container data 5 | const formatGraphData = (data) => { 6 | // iterates over metric arrays and returns smaller data subset based on adjustment 7 | // const adjustTimeFrame = (metrics, adjustment) => { 8 | // const metricsCopy = { ...metrics }; 9 | // // eslint-disable-next-line no-restricted-syntax, guard-for-in 10 | // for (const key in metricsCopy) { 11 | // // const newArr = []; 12 | // // for (let j = metricsCopy[key].length - 1; j >= 0; j -= adjustment) { 13 | // // newArr.push(metricsCopy[key][j]); 14 | // // } 15 | // // metricsCopy[key] = newArr; 16 | // console.log(metricsCopy); 17 | // const newArr = []; 18 | // for (let j = 0; j > metricsCopy[key].length; j += adjustment) { 19 | // newArr.push(metricsCopy[key][j]); 20 | // } 21 | // metricsCopy[key] = newArr; 22 | // } 23 | // return metricsCopy; 24 | // }; 25 | // let adjustmentFactor = 1 26 | // if (data.times.length / adjustmentFactor > 20) adjustmentFactor += 1 27 | // const timeFrames = { 28 | // '5 seconds': 1, 29 | // '30 seconds': 6, 30 | // '1 minute': 12, 31 | // '5 minutes': 60, 32 | // '30 minutes': 360, 33 | // '1 hour': 720, 34 | // }; 35 | // console.log('timeframe inside graph display', timeFrame.current); 36 | // const adjustmentFactor = timeFrames[timeFrame.current]; 37 | // const metrics = adjustTimeFrame(data, 1); 38 | // return metrics; 39 | const { 40 | dates, 41 | times, 42 | cpu_percentage, 43 | Memory_memory_usage, 44 | Network_rx_bytes, 45 | // Network_rx_dropped, 46 | // Network_rx_errors, 47 | // Network_rx_packets, 48 | Network_tx_bytes, 49 | // Network_tx_dropped, 50 | // Network_tx_errors, 51 | // Network_tx_packets, 52 | Disk_read_value, 53 | Disk_write_value, 54 | } = data; 55 | 56 | const cpuData = { 57 | labels: times, 58 | datasets: [ 59 | { 60 | label: 'CPU, %', 61 | data: cpu_percentage, 62 | borderColor: '#3b76b7', 63 | } 64 | ], 65 | }; 66 | // formats for each data chart with their respective metric data/ 67 | const memoryData = { 68 | labels: times, 69 | datasets: [ 70 | { 71 | label: 'Memory usage, bytes', 72 | data: Memory_memory_usage, 73 | borderColor: '#3b76b7', 74 | } 75 | ], 76 | }; 77 | 78 | const networkData = { 79 | labels: times, 80 | datasets: [ 81 | { 82 | label: 'Network input, bytes', 83 | data: Network_rx_bytes, 84 | borderColor: '#3b76b7', 85 | }, 86 | // { 87 | // label: "Network_rx_dropped", 88 | // data: Network_rx_dropped, 89 | // }, 90 | // { 91 | // label: "Network_rx_errors", 92 | // data: Network_rx_errors, 93 | // }, 94 | // { 95 | // label: "Network_rx_packets", 96 | // data: Network_rx_packets, 97 | // }, 98 | { 99 | label: 'Network output, bytes', 100 | data: Network_tx_bytes, 101 | borderColor: '#95B9E5', 102 | } 103 | // { 104 | // label: "Network_tx_dropped", 105 | // data: Network_tx_dropped, 106 | // }, 107 | // { 108 | // label: "Network_tx_errors", 109 | // data: Network_tx_errors, 110 | // }, 111 | // { 112 | // label: "Network_tx_packets", 113 | // data: Network_tx_packets, 114 | // }, 115 | ], 116 | }; 117 | 118 | const diskData = { 119 | labels: times, 120 | datasets: [ 121 | { 122 | label: 'Disk read, bytes', 123 | data: Disk_read_value, 124 | borderColor: '#3b76b7', 125 | }, 126 | { 127 | label: 'Disk write, bytes', 128 | data: Disk_write_value, 129 | borderColor: '#95B9E5', 130 | } 131 | ], 132 | }; 133 | 134 | return [cpuData, memoryData, networkData, diskData]; 135 | }; 136 | // const date = new Date().toLocaleDateString(); 137 | // creates a chart for every graph in graphData 138 | function GraphContainer({ containerData, timeFrame }) { 139 | // cancel render if no graph data 140 | if (!Object.keys(containerData).length) { 141 | return
; 142 | } 143 | const graphData = formatGraphData(containerData, timeFrame); 144 | const titles = ['CPU', 'Memory', 'Network', 'Disk']; 145 | // convert array of chartJS-ready graph data to BarChart elements 146 | const charts = graphData.map((graph, i) => ( 147 | // TODO investigate visual bug when using uuid 148 | // eslint-disable-next-line react/no-array-index-key 149 |
150 | 151 |
152 | )); 153 | return
{charts}
; 154 | } 155 | 156 | export default GraphContainer; 157 | -------------------------------------------------------------------------------- /src/api/controller.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-underscore-dangle */ 2 | require('dotenv').config({ path: '../../.env' }); 3 | 4 | const { InfluxDB } = require('@influxdata/influxdb-client'); 5 | const getMetricArrays = require('./metricArrays'); 6 | 7 | const DB_URL = process.env.DB_URL || 'http://127.0.0.1:8086'; 8 | const DB_API_TOKEN = process.env.DB_INFLUXDB_INIT_ADMIN_TOKEN; 9 | const DB_ORG = process.env.DB_INFLUXDB_INIT_ORG; 10 | const DB_BUCKET = process.env.DB_INFLUXDB_INIT_BUCKET; 11 | 12 | const cpuPerc = (cpuStats, preCpuStats, systemCpuStats, preSystemCpuStats, onlineCpus) => { 13 | const cpuDelta = cpuStats - preCpuStats; 14 | const systemDelta = systemCpuStats - preSystemCpuStats; 15 | if (cpuDelta > 0 && systemDelta > 0) { 16 | return ((cpuDelta / systemDelta) * onlineCpus) * 100; 17 | } return 0; 18 | }; 19 | 20 | const controller = {}; 21 | 22 | controller.getContainers = (req, res, next) => { 23 | // query the db to return an array of objects with container names and ids 24 | 25 | // connect to the influx db using url and token 26 | const influxDB = new InfluxDB({ url: DB_URL, token: DB_API_TOKEN }); 27 | 28 | // specify the org to send query to 29 | const queryApi = influxDB.getQueryApi(DB_ORG); 30 | 31 | // specify range of time to query db 32 | const range = '1h'; 33 | 34 | // initialize array to collect query data 35 | const containers = []; 36 | 37 | // write the query 38 | const query = `from(bucket: "${DB_BUCKET}") 39 | |> range(start: -${range}) 40 | |> group(columns: ["container_id", "container_name"]) 41 | |> distinct(column: "container_id")`; 42 | queryApi.queryRows(query, { 43 | next(row, tableMeta) { 44 | const o = tableMeta.toObject(row); 45 | // keep track of if the container is in the array 46 | let containerInArr = false; 47 | containers.forEach((nameObj) => { 48 | if (nameObj.id === o.container_id) containerInArr = true; 49 | }); 50 | // if container not in array, add it 51 | if (!containerInArr) { 52 | containers.push({ 53 | name: o.container_name, 54 | id: o.container_id, 55 | }); 56 | } 57 | }, 58 | error(error) { 59 | console.log('Finished ERROR'); 60 | return next(error); 61 | }, 62 | complete() { 63 | // console.log('Finished SUCCESS'); 64 | res.locals.containers = containers; 65 | return next(); 66 | }, 67 | }); 68 | }; 69 | 70 | controller.getContainerStats = (req, res, next) => { 71 | // query db for all stats for a specific container 72 | 73 | // destructure id from request 74 | const { id, metric } = req.params; 75 | 76 | let { range } = req.params; 77 | 78 | // if not range passed in, specify default range 79 | if (!range) { 80 | range = '1h'; 81 | } 82 | 83 | // connect to the influx db using url and token 84 | const influxDB = new InfluxDB({ url: DB_URL, token: DB_API_TOKEN }); 85 | 86 | // specify the org to send query to 87 | const queryApi = influxDB.getQueryApi(DB_ORG); 88 | 89 | // initialize array to collect query data 90 | const dataObj = {}; 91 | 92 | // write the query for the passed in metric, or all metrics if no metric passed in 93 | let query = ''; 94 | if (metric === 'disk') { 95 | query = `from(bucket: "${DB_BUCKET}") 96 | |> range(start: -${range}) 97 | |> filter(fn: (r) => r["container_id"] == "${id}") 98 | |> filter(fn: (r) => r["_measurement"] == "Disk")`; 99 | } else if (metric === 'memory') { 100 | query = `from(bucket: "${DB_BUCKET}") 101 | |> range(start: -${range}) 102 | |> filter(fn: (r) => r["container_id"] == "${id}") 103 | |> filter(fn: (r) => r["_measurement"] == "Memory")`; 104 | } else if (metric === 'cpu') { 105 | query = `from(bucket: "${DB_BUCKET}") 106 | |> range(start: -${range}) 107 | |> filter(fn: (r) => r["container_id"] == "${id}") 108 | |> filter(fn: (r) => r["_measurement"] == "CPU")`; 109 | } else if (metric === 'network') { 110 | query = `from(bucket: "${DB_BUCKET}") 111 | |> range(start: -${range}) 112 | |> filter(fn: (r) => r["container_id"] == "${id}") 113 | |> filter(fn: (r) => r["_measurement"] == "Network")`; 114 | } else { 115 | query = `from(bucket: "${DB_BUCKET}") 116 | |> range(start: -${range}) 117 | |> filter(fn: (r) => r["container_id"] == "${id}")`; 118 | } 119 | queryApi.queryRows(query, { 120 | next(row, tableMeta) { 121 | const o = tableMeta.toObject(row); 122 | // eslint-disable-next-line no-underscore-dangle 123 | // if current time not in obj, add it 124 | if (!dataObj[o._time]) { 125 | dataObj[o._time] = {}; 126 | } 127 | // TODO: Change display to MB or kB etc 128 | // add info on current row to the object at the associated time key 129 | dataObj[o._time][`${o._measurement}_${o._field}`] = o._value; 130 | }, 131 | error(error) { 132 | console.log('Finished ERROR'); 133 | return next(error); 134 | }, 135 | complete() { 136 | const updatedDataObj = { ...dataObj }; 137 | // if this is a live update (and not a large data fetch) 138 | // use only the most recent piece of data 139 | if (range === '8s' && Object.keys(updatedDataObj).length === 2) { 140 | delete updatedDataObj[Object.keys(updatedDataObj).sort()[0]]; 141 | } 142 | if (!metric || metric === 'cpu') { 143 | const times = Object.keys(updatedDataObj); 144 | times.forEach((time) => { 145 | updatedDataObj[time].cpu_percentage = cpuPerc( 146 | updatedDataObj[time].CPU_cpu_usage, 147 | updatedDataObj[time].CPU_precpu_usage, 148 | updatedDataObj[time].CPU_system_cpu_usage, 149 | updatedDataObj[time].CPU_system_precpu_usage, 150 | updatedDataObj[time].CPU_online_cpus, 151 | ); 152 | }); 153 | } 154 | const formattedDataObj = getMetricArrays(updatedDataObj); 155 | res.locals.stats = formattedDataObj; 156 | return next(); 157 | }, 158 | }); 159 | }; 160 | 161 | module.exports = controller; 162 | --------------------------------------------------------------------------------