├── .gitignore ├── LICENSE ├── README.md ├── __tests__ ├── dbHelpertests.js ├── dockerCliHelperTests.js ├── reactTests.js └── supertest.js ├── client ├── App.jsx ├── ProtectedRoute.jsx ├── assets │ ├── delete.png │ ├── logo.gif │ ├── logoNoText.png │ ├── logoTextOnly.png │ ├── restart.png │ ├── stop.png │ ├── whaleBlue.png │ └── whaleRed.png ├── components │ ├── NavBar │ │ └── NavBar.jsx │ ├── authentication │ │ ├── Login.jsx │ │ ├── SignUpForm.js │ │ ├── form.js │ │ └── validation.js │ ├── dashboard │ │ ├── AverageCPUChart.jsx │ │ ├── AverageMemoryChart.jsx │ │ ├── BlockIOChart.jsx │ │ ├── NetIOChart.jsx │ │ ├── PIDChart.jsx │ │ ├── Whale.jsx │ │ └── WhaleChart.jsx │ ├── dockerContainer │ │ ├── DndContainers.jsx │ │ ├── EachContainer.jsx │ │ ├── ItemTypes.jsx │ │ ├── Restart.jsx │ │ └── Stop.jsx │ └── notification │ │ └── Notification.jsx ├── containers │ ├── ContainersContainer.jsx │ ├── DashboardContainer.jsx │ ├── NotificationsContainer.jsx │ ├── SettingsContainer.jsx │ └── containerHelpers.js ├── index.html ├── index.js ├── scss │ ├── Application.scss │ └── ContainersContainer.scss └── styles.scss ├── main.js ├── package-lock.json ├── package.json ├── server ├── db │ ├── connect.js │ ├── schema.js │ └── setup.sql ├── helpers │ ├── dbHelper.js │ ├── dockerApiHelper.js │ └── dockerCliHelper.js └── index.js └── webpack.config.js /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | node_modules 3 | build 4 | npm-debug.log 5 | .DS_Store 6 | .env 7 | .env.test 8 | out/ 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # WhaleWatch 2 |

3 | 4 |

5 | 6 |

Table of Contents

7 | 14 | 15 |

What is WhaleWatch?

16 |

WhaleWatch is a lightweight, open-source monitoring tool for Docker. WhaleWatch enables developers to monitor Docker containers through a dashboard that delivers real-time metrics backed by intuitive data visualizations. Developers will be able to keep an eye on the containers that are critical for smooth technical and business operations and also proactively take action before troubles arise.

17 | 18 |

WhaleWatch Features

19 | 20 | 26 | 27 |

Installation and Setup

28 |

WhaleWatch can be installed and set up with the following steps:

29 | 30 | 1. Fork and clone the repo 31 | 2. Run `npm install` 32 | 3. Create a `.env` file in your top level folder and add a Postgres URI as a variable labeled `DB_URI` 33 | 4. Run the following command in your Postgres instance: 34 | ``` 35 | CREATE TABLE IF NOT EXISTS users ( 36 | id SERIAL PRIMARY KEY, 37 | username varchar(50) UNIQUE NOT NULL, 38 | email varchar(100) UNIQUE NOT NULL, 39 | password varchar(100) NOT NULL 40 | ); 41 | 42 | CREATE TABLE IF NOT EXISTS containers ( 43 | id SERIAL PRIMARY KEY, 44 | dockerId varchar(100) NOT NULL, 45 | name varchar(100) NOT NULL, 46 | size varchar(50), 47 | status varchar(50), 48 | state varchar(50), 49 | owner integer REFERENCES users(id) NOT NULL 50 | ); 51 | 52 | CREATE TABLE IF NOT EXISTS stats ( 53 | id SERIAL PRIMARY KEY, 54 | container integer REFERENCES containers(id) NOT NULL, 55 | timestamp date NOT NULL, 56 | cpuUsage decimal NOT NULL, 57 | memUsage decimal NOT NULL, 58 | netIo varchar(50) NOT NULL, 59 | blockIo varchar(50) NOT NULL, 60 | pids integer NOT NULL, 61 | reqPerMin integer 62 | ) 63 | ``` 64 | 5. Ensure Docker Daemon is running on your computer. 65 | 6. Run `npm run dev` 66 | 67 |

Navigating and Using WhaleWatch

68 |

WhaleWatch is a tool that developers will be able to utilize through their browsers as a web application.

69 | 70 | ![Dashboard](https://media.giphy.com/media/wXFxM1EuviUlgpLQYU/giphy.gif?cid=790b76113869a667a527e5322a3e9cfd16baedc2adfa9ea4&rid=giphy.gif&ct=g) 71 | 72 |

Upon launching the application, the user will be asked to sign up or provide login credentials. Once the sign-up or login step has been completed, the user will be redirected to the main WhaleWatch dashboard. This dashboard will contain key data and metrics such as:

73 | 74 | 81 | 82 | ![Container](https://media.giphy.com/media/5RBS7GS7ypfntnucA6/giphy.gif?cid=790b761108fc27662df547e807a3cefa0135155d883b1058&rid=giphy.gif&ct=g) 83 | 84 |

Furthermore, in the Container Health Overview section, developers will be able to quickly glean insights into which containers are healthy (blue whales) and which containers require attention (red whales).

85 | 86 |

Within the Containers component, developers have the ability to easily start, stop and restart containers with ease through intuitive drag and drop functionality. The containers are displayed and organized categorically by “Active” and “Inactive” containers.

87 | 88 |

Meet the Team

89 | 95 | -------------------------------------------------------------------------------- /__tests__/dbHelpertests.js: -------------------------------------------------------------------------------- 1 | const dbHelper = require('../server/helpers/dbHelper'); 2 | 3 | describe('Database helper unit tests', () => { 4 | describe('refresh container data', () => { 5 | it('') 6 | }) 7 | describe('refresh stats data', () => { 8 | 9 | }) 10 | }) -------------------------------------------------------------------------------- /__tests__/dockerCliHelperTests.js: -------------------------------------------------------------------------------- 1 | const { dockerCliHelper, parseCliJSON } = require('../server/helpers/dockerCliHelper'); 2 | 3 | describe('Docker CLI Helper Unit Tests', () => { 4 | 5 | describe('parseCliJSON', () => { 6 | const fakeData = `{"CreatedAt":"2021-08-27 11:14:06 -0400 EDT","ID":"5e92d0ef966e","Image":"bobrik/socat","Labels":"desktop.docker.io/binds/0/Source=/var/run/docker.sock,desktop.docker.io/binds/0/SourceKind=dockerSocketProxied,desktop.docker.io/binds/0/Target=/var/run/docker.sock","LocalVolumes":"0","Mounts":"/run/host-serv…","Names":"socat","Networks":"bridge","Ports":"127.0.0.1:2375-\u003e2375/tcp","RunningFor":"8 days ago","Size":"0B (virtual 6.95MB)","State":"running","Status":"Up 23 hours"}`; 7 | const fakeMultilineData = `{"CreatedAt":"2021-08-27 11:14:06 -0400 EDT","ID":"5e92d0ef966e","Image":"bobrik/socat","Labels":"desktop.docker.io/binds/0/Source=/var/run/docker.sock,desktop.docker.io/binds/0/SourceKind=dockerSocketProxied,desktop.docker.io/binds/0/Target=/var/run/docker.sock","LocalVolumes":"0","Mounts":"/run/host-serv…","Names":"socat","Networks":"bridge","Ports":"127.0.0.1:2375-\u003e2375/tcp","RunningFor":"8 days ago","Size":"0B (virtual 6.95MB)","State":"running","Status":"Up 23 hours"} 8 | {"CreatedAt":"2021-08-27 11:14:06 -0400 EDT","ID":"5e92d0ef966e","Image":"bobrik/socat","Labels":"desktop.docker.io/binds/0/Source=/var/run/docker.sock,desktop.docker.io/binds/0/SourceKind=dockerSocketProxied,desktop.docker.io/binds/0/Target=/var/run/docker.sock","LocalVolumes":"0","Mounts":"/run/host-serv…","Names":"socat","Networks":"bridge","Ports":"127.0.0.1:2375-\u003e2375/tcp","RunningFor":"8 days ago","Size":"0B (virtual 6.95MB)","State":"running","Status":"Up 23 hours"} 9 | {"CreatedAt":"2021-08-27 11:14:06 -0400 EDT","ID":"5e92d0ef966e","Image":"bobrik/socat","Labels":"desktop.docker.io/binds/0/Source=/var/run/docker.sock,desktop.docker.io/binds/0/SourceKind=dockerSocketProxied,desktop.docker.io/binds/0/Target=/var/run/docker.sock","LocalVolumes":"0","Mounts":"/run/host-serv…","Names":"socat","Networks":"bridge","Ports":"127.0.0.1:2375-\u003e2375/tcp","RunningFor":"8 days ago","Size":"0B (virtual 6.95MB)","State":"running","Status":"Up 23 hours"} 10 | {"CreatedAt":"2021-08-27 11:14:06 -0400 EDT","ID":"5e92d0ef966e","Image":"bobrik/socat","Labels":"desktop.docker.io/binds/0/Source=/var/run/docker.sock,desktop.docker.io/binds/0/SourceKind=dockerSocketProxied,desktop.docker.io/binds/0/Target=/var/run/docker.sock","LocalVolumes":"0","Mounts":"/run/host-serv…","Names":"socat","Networks":"bridge","Ports":"127.0.0.1:2375-\u003e2375/tcp","RunningFor":"8 days ago","Size":"0B (virtual 6.95MB)","State":"running","Status":"Up 23 hours"}` 11 | it('should return an array', () => { 12 | expect(parseCliJSON(fakeData)).toBeInstanceOf(Array); 13 | expect(parseCliJSON(fakeMultilineData)).toBeInstanceOf(Array); 14 | }) 15 | it('should work with single line data', () => { 16 | expect(parseCliJSON(fakeData)).toHaveLength(1); 17 | }) 18 | it('should work with multi line data', () => { 19 | expect(parseCliJSON(fakeMultilineData)).toHaveLength(4); 20 | }) 21 | }) 22 | }) -------------------------------------------------------------------------------- /__tests__/reactTests.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/whalewatch/49b5e49036e3872329fbd034ccd958923cda67c4/__tests__/reactTests.js -------------------------------------------------------------------------------- /__tests__/supertest.js: -------------------------------------------------------------------------------- 1 | 2 | const supertest = require('supertest'); 3 | const server = 'http://localhost:8080'; 4 | 5 | const request = supertest(server); 6 | 7 | describe('Route integration', () => { 8 | describe('/graphql', () => { 9 | it('gets a list of users', (done) => { 10 | request 11 | .post('/graphql') 12 | .send({ 13 | query: "{ users{ id, name} }", 14 | }) 15 | .set("Accept", "application/json") 16 | .expect("Content-Type", "application/json; charset=utf-8") 17 | .expect(200) 18 | .end((err, res) => { 19 | if (err) done(err); 20 | expect(res.body).toBeInstanceOf(Object); 21 | }) 22 | }) 23 | }) 24 | }) -------------------------------------------------------------------------------- /client/App.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useState } from 'react'; 3 | import { BrowserRouter as Router, Route, Switch } from 'react-router-dom'; 4 | import Login from './components/authentication/Login'; 5 | import DashboardContainer from './containers/DashboardContainer'; 6 | import ContainersContainer from './containers/ContainersContainer'; 7 | import NotificationsContainer from './containers/NotificationsContainer'; 8 | import SettingsContainer from './containers/SettingsContainer'; 9 | import NavBar from './components/NavBar/NavBar'; 10 | import ProtectedRoute from './ProtectedRoute.jsx'; 11 | import Form from './components/authentication/form' 12 | import './styles.scss'; 13 | import { DndProvider } from 'react-dnd' 14 | import { HTML5Backend } from 'react-dnd-html5-backend' 15 | 16 | const App = () => { 17 | const [userId, setUserId] = useState(''); 18 | const [auth, setAuth] = useState(false); 19 | 20 | return ( 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 |
29 | 30 | 31 | 32 | //if user tries to go to any other path that isn't defined 33 | "404 NOT FOUND"} /> 34 | 35 | 36 | 37 | ); 38 | }; 39 | 40 | export default App; -------------------------------------------------------------------------------- /client/ProtectedRoute.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | const jwt = require('jsonwebtoken') 3 | import { Route, Redirect } from "react-router-dom"; 4 | import Cookies from 'js-cookie'; 5 | import { useState, useEffect} from 'react'; 6 | 7 | const ProtectedRoute = ({component: Component, ...rest }) => { 8 | return ( 9 | { 12 | //if user is authenticated, return return respective component 13 | if(localStorage.getItem('validAuth')) 14 | return 15 | //else send back to login page 16 | else{ 17 | return 18 | } 19 | }} /> 20 | ); 21 | } 22 | 23 | export default ProtectedRoute; -------------------------------------------------------------------------------- /client/assets/delete.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/whalewatch/49b5e49036e3872329fbd034ccd958923cda67c4/client/assets/delete.png -------------------------------------------------------------------------------- /client/assets/logo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/whalewatch/49b5e49036e3872329fbd034ccd958923cda67c4/client/assets/logo.gif -------------------------------------------------------------------------------- /client/assets/logoNoText.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/whalewatch/49b5e49036e3872329fbd034ccd958923cda67c4/client/assets/logoNoText.png -------------------------------------------------------------------------------- /client/assets/logoTextOnly.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/whalewatch/49b5e49036e3872329fbd034ccd958923cda67c4/client/assets/logoTextOnly.png -------------------------------------------------------------------------------- /client/assets/restart.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/whalewatch/49b5e49036e3872329fbd034ccd958923cda67c4/client/assets/restart.png -------------------------------------------------------------------------------- /client/assets/stop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/whalewatch/49b5e49036e3872329fbd034ccd958923cda67c4/client/assets/stop.png -------------------------------------------------------------------------------- /client/assets/whaleBlue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/whalewatch/49b5e49036e3872329fbd034ccd958923cda67c4/client/assets/whaleBlue.png -------------------------------------------------------------------------------- /client/assets/whaleRed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/whalewatch/49b5e49036e3872329fbd034ccd958923cda67c4/client/assets/whaleRed.png -------------------------------------------------------------------------------- /client/components/NavBar/NavBar.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import {Link} from 'react-router-dom' 3 | import logoNoText from '../../assets/logoNoText.png'; 4 | import logoTextOnly from '../../assets/logoTextOnly.png'; 5 | import logo from '../../assets/logo.gif'; 6 | 7 | const NavBar = () => { 8 | const removeCookies = () => { 9 | localStorage.removeItem('validId'); 10 | localStorage.removeItem('validAuth') 11 | } 12 | 13 | return ( 14 |
15 | 44 |
45 | ) 46 | } 47 | 48 | export default NavBar -------------------------------------------------------------------------------- /client/components/authentication/Login.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useState } from 'react'; 3 | import { Link, useHistory } from "react-router-dom"; 4 | import logo from '../../assets/logo.gif'; 5 | import Cookies from 'js-cookie'; 6 | import { 7 | useMutation, 8 | gql 9 | } from '@apollo/client'; 10 | 11 | 12 | const style = { 13 | signupOrLogin: { 14 | top: '-120px', 15 | right: '40px', 16 | color: '#0275d8', 17 | size: '15px', 18 | } 19 | }; 20 | 21 | // mutation to use upon attempting to login 22 | 23 | 24 | // GraphQL login mutation query 25 | const LOGIN_MUTATION = gql` 26 | mutation login($username: String!, $password: String!){ 27 | validateUser(username: $username, password: $password){ 28 | id 29 | username 30 | password 31 | } 32 | } 33 | `; 34 | 35 | const Login = ({ setUserId }) => { 36 | const [userData, setUserData] = useState({ username: '', password: '' }); 37 | const [errorMessage, setErrorMessage] = useState({ value: '' }); 38 | const history = useHistory(); 39 | //function to handle changing username 40 | const handleUsernameInputChange = (e) => { 41 | e.persist(); 42 | setUserData((userData) => ({ 43 | ...userData, username: e.target.value, 44 | })); 45 | }; 46 | //function to handle changing password 47 | const handlePasswordInputChange = (e) => { 48 | e.persist(); 49 | setUserData((userData) => ({ 50 | ...userData, password: e.target.value, 51 | })); 52 | }; 53 | 54 | //on submitting login info, invoke mutation to send to graphql 55 | const [login, { data, loading, error }] = 56 | useMutation(LOGIN_MUTATION, { 57 | variables: { 58 | username: userData.username, 59 | password: userData.password 60 | }, 61 | onError: (err) => console.log('there is an error', err), 62 | onCompleted: (data) => { 63 | if (data.validateUser) { 64 | //if user validated, set a cookie using jwt from backend and store in localStorage 65 | Cookies.set('id', data.validateUser.id) 66 | localStorage.setItem('validId', data.validateUser.id) 67 | localStorage.setItem('validAuth', Cookies.get('access-token')) 68 | history.push('/dashboard') 69 | } 70 | } 71 | }) 72 | 73 | return ( 74 |
75 | Don't have an account? 76 |
77 | 78 |
79 | 80 | 81 |

Welcome back! Please login.

82 | 83 |
84 | 85 | { e.preventDefault(); login() }} className='form-group col-md-8 col-lg-8 mx-auto text-center'> 86 | 87 |
88 | 89 | 98 |
99 |
100 | 101 | 110 |
111 |
112 | 113 |
114 | 115 |
116 | 117 |
118 | 119 |
120 | 121 |
122 |
123 | ) 124 | } 125 | 126 | export default Login; -------------------------------------------------------------------------------- /client/components/authentication/SignUpForm.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import Validation from './validation.js'; 3 | import { useMutation, gql } from '@apollo/client'; 4 | import { Link, useHistory } from "react-router-dom"; 5 | import logo from '../../assets/logo.gif'; 6 | import Cookies from 'js-cookie'; 7 | 8 | 9 | const style = { 10 | signupOrLogin: { 11 | top: '-120px', 12 | right: '40px', 13 | color: '#0275d8', 14 | size: '15px', 15 | } 16 | }; 17 | 18 | const REGISTER_USER = gql` 19 | mutation addUser ($username: String!, $email: String!, $password: String!) { 20 | addUser( 21 | username: $username 22 | email: $email 23 | password: $password 24 | ){ 25 | id 26 | username 27 | email 28 | password 29 | } 30 | } 31 | `; 32 | 33 | // create a sign-up form 34 | const SignUpForm = ({ submitForm }) => { 35 | const [inputValues, setValues] = useState({ 36 | username: '', 37 | email: '', 38 | password: '' 39 | }); 40 | 41 | 42 | const [errors, setErrors] = useState({}); 43 | const [dataIsCorrect, setDataIsCorrect] = useState(false); 44 | const history = useHistory(); 45 | 46 | // handle changes on the input fields 47 | const handleChange = (event) => { 48 | setValues({ 49 | ...inputValues, 50 | [event.target.name]: event.target.value, 51 | }); 52 | }; 53 | 54 | const [addUser, { data, loading, error }] = useMutation(REGISTER_USER, { 55 | variables: inputValues, 56 | onCompleted: (data) => { 57 | if (data.addUser) { 58 | Cookies.set('id', data.addUser.id) 59 | localStorage.setItem('validId', data.addUser.id) 60 | localStorage.setItem('validAuth', Cookies.get('access-token')) 61 | history.push('/dashboard') 62 | } 63 | } 64 | }); 65 | 66 | // handle submit button 67 | const handleFormSubmit = (event) => { 68 | event.preventDefault(); 69 | setErrors(Validation(inputValues)); 70 | setDataIsCorrect(true); 71 | addUser(); 72 | }; 73 | 74 | // handle error message for the input fields 75 | useEffect(() => { 76 | if (Object.keys(errors).length === 0 && dataIsCorrect) { 77 | submitForm(true); 78 | }; 79 | }, [errors]); 80 | 81 | return ( 82 |
83 | Already have an account? 84 |
85 |
86 | 87 |

Welcome! Create Your Account.

88 |
89 |
90 |
91 | 92 | 93 | {errors.username &&

{errors.username}

} 94 |
95 |
96 | 97 | 98 | {errors.email &&

{errors.email}

} 99 |
100 |
101 | 102 | 103 | {errors.password &&

{errors.password}

} 104 |
105 |
106 |
107 | 108 |
109 |
110 |
111 |
112 |
113 |
114 | ) 115 | } 116 | 117 | 118 | 119 | export default SignUpForm; -------------------------------------------------------------------------------- /client/components/authentication/form.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import SignUpForm from './SignUpForm.js'; 3 | 4 | 5 | const Form = (props) => { 6 | const [formIsSubmit, setFormIsSubmitted] = useState(false); 7 | const submitForm = () => { 8 | setFormIsSubmitted(true); 9 | }; 10 | return ( 11 |
12 | 13 |
14 | ) 15 | }; 16 | export default Form; 17 | -------------------------------------------------------------------------------- /client/components/authentication/validation.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import SignUpForm from './SignUpForm.js'; 3 | 4 | const Validation = (inputValues) => { 5 | let errors = {}; 6 | //rules for validation 7 | if (!inputValues.username) { 8 | errors.username = "Username is required." 9 | } 10 | if (!inputValues.email) { 11 | errors.email = "Email is required." 12 | } else if (!/\S+@\S+\.\S+/.test(inputValues.email)) { 13 | errors.email = "Email is invalid." 14 | } 15 | if (!inputValues.password) { 16 | errors.password = "Password is required." 17 | } else if (inputValues.password.length < 5){ 18 | errors.password = "Password must be more than 5 characters" 19 | } 20 | return errors; 21 | } 22 | 23 | export default Validation; 24 | -------------------------------------------------------------------------------- /client/components/dashboard/AverageCPUChart.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useState } from 'react'; 3 | import { Link, useHistory } from "react-router-dom"; 4 | import { LineChart, Line, CartesianGrid, XAxis, YAxis, Tooltip } from 'recharts'; 5 | 6 | const AverageCPUChart = ({ data, populateChart }) => { 7 | 8 | //invoking the function to process the data for recharts 9 | const dataArr = populateChart('cpuusage', data); 10 | return ( 11 | <> 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | ) 21 | } 22 | export default AverageCPUChart; -------------------------------------------------------------------------------- /client/components/dashboard/AverageMemoryChart.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useState } from 'react'; 3 | import { Link, useHistory } from "react-router-dom"; 4 | import { LineChart, Line, CartesianGrid, XAxis, YAxis, Tooltip } from 'recharts'; 5 | 6 | 7 | const AverageMemoryChart = ({ data, populateChart }) => { 8 | 9 | //invoking the function to process the data for recharts 10 | const dataArr = populateChart('memusage', data); 11 | return ( 12 | <> 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | ) 22 | 23 | } 24 | 25 | export default AverageMemoryChart; -------------------------------------------------------------------------------- /client/components/dashboard/BlockIOChart.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useState } from 'react'; 3 | import { Link, useHistory } from "react-router-dom"; 4 | import { BarChart, Bar, CartesianGrid, XAxis, YAxis, Tooltip, Legend } from 'recharts'; 5 | import formatBytes from "../../containers/containerHelpers"; 6 | 7 | const BlockIOChart = ({ data, populateBarChart }) => { 8 | 9 | //invoking the function to process the data for recharts 10 | const dataArr = populateBarChart('blockio', data); 11 | return ( 12 | <> 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | ) 24 | } 25 | 26 | export default BlockIOChart; -------------------------------------------------------------------------------- /client/components/dashboard/NetIOChart.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useState } from 'react'; 3 | import { Link, useHistory } from "react-router-dom"; 4 | import { BarChart, Bar, CartesianGrid, XAxis, YAxis, Tooltip, Legend } from 'recharts'; 5 | import formatBytes from "../../containers/containerHelpers"; 6 | 7 | const NetIOChart = ({ data, populateBarChart }) => { 8 | 9 | //invoking the function to process the data for recharts 10 | const dataArr = populateBarChart('netio', data); 11 | return ( 12 | <> 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | ) 24 | 25 | } 26 | 27 | export default NetIOChart; -------------------------------------------------------------------------------- /client/components/dashboard/PIDChart.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useState } from 'react'; 3 | import { Link, useHistory } from "react-router-dom"; 4 | import { LineChart, Line, CartesianGrid, XAxis, YAxis, Tooltip } from 'recharts'; 5 | 6 | 7 | const PIDChart = ({ data, populateChart }) => { 8 | 9 | //invoking the function to process the data for recharts 10 | const dataArr = populateChart('pids', data); 11 | 12 | return ( 13 | <> 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | ) 23 | } 24 | 25 | export default PIDChart; -------------------------------------------------------------------------------- /client/components/dashboard/Whale.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import whaleBlue from "../../assets/whaleBlue.png" 3 | import whaleRed from "../../assets/whaleRed.png" 4 | import { useDrag } from "react-dnd"; 5 | 6 | //each container that is on the user's computer is represented by a whale 7 | const Whale = ({ info }) => { 8 | 9 | //use-drag hook from DnD 10 | const [{ isDragging }, drag] = useDrag(() => ({ 11 | type: "image", //type can be a div if you want 12 | collect: (monitor) => ({ 13 | isDragging: !!monitor.isDragging(), 14 | }) 15 | })); 16 | 17 | return ( 18 |
19 | {/* whale color is determined by whether the container is healthy or unhealthy */} 20 | {info.status !== 'unhealthy' ? : } 21 |
Container {info.name}
{info.size} 22 |
23 | ) 24 | } 25 | 26 | export default Whale; -------------------------------------------------------------------------------- /client/components/dashboard/WhaleChart.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useState } from 'react'; 3 | import { Link, useHistory } from "react-router-dom"; 4 | import Whale from "./Whale"; 5 | 6 | const WhaleChart = ({ listOfContainers }) => { 7 | 8 | const list = listOfContainers.container 9 | const whales = []; 10 | list.map(container => { 11 | whales.push(); 12 | }); 13 | return ( 14 |
15 | {whales} 16 |
17 | ) 18 | } 19 | 20 | export default WhaleChart; -------------------------------------------------------------------------------- /client/components/dockerContainer/DndContainers.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import EachContainer from './EachContainer'; 3 | 4 | const DndContainers = ({listOfContainers, state}) => { 5 | 6 | const allContainer = []; 7 | listOfContainers.map(container => { 8 | if(container.state === state){ 9 | allContainer.push(); 10 | } 11 | }); 12 | return ( 13 |
14 | {allContainer} 15 |
16 | ) 17 | }; 18 | export default DndContainers; 19 | -------------------------------------------------------------------------------- /client/components/dockerContainer/EachContainer.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useDrag } from 'react-dnd' 3 | 4 | const EachContainer = ({info}) => { 5 | //for dragging 6 | const [{ isDragging }, drag] = useDrag(() => ({ 7 | type: "image", 8 | item: {info: info.dockerid, state: info.state }, 9 | collect: (monitor) => ({ 10 | isDragging: !!monitor.isDragging(), 11 | }), 12 | })); 13 | 14 | const opacity = isDragging ? 0 : 1; 15 | 16 | return ( 17 |
18 | Name: {info.name}
Docker ID: {info.dockerid.slice(0, 12)}
Status: {info.status}
Container Size:{info.size} 19 |
20 | ) 21 | } 22 | 23 | export default EachContainer; -------------------------------------------------------------------------------- /client/components/dockerContainer/ItemTypes.jsx: -------------------------------------------------------------------------------- 1 | export const ItemTypes = { 2 | CONTAINER: 'container', 3 | } -------------------------------------------------------------------------------- /client/components/dockerContainer/Restart.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useDrop } from 'react-dnd'; 3 | import stop from '../../assets/stop.png' 4 | import restart from '../../assets/restart.png' 5 | 6 | const style = { 7 | height: '8rem', 8 | width: '8rem', 9 | padding: '1rem', 10 | marginBottom: '1rem', 11 | textAlign: 'center', 12 | fontSize: '1rem', 13 | lineHeight: 'normal', 14 | float: 'left', 15 | }; 16 | 17 | function Restart({containerData, state, refetch}) { 18 | 19 | const [{ isOver, canDrop }, drop] = useDrop({ 20 | accept: 'image', 21 | drop: (item) => { 22 | fetch('/graphql', { 23 | method: 'POST', 24 | headers: { 25 | 'Content-Type': 'application/json', 26 | }, 27 | body: JSON.stringify({ 28 | query: ` 29 | mutation restartContainer ($id: String){ 30 | restartContainer(id: $id) { 31 | id 32 | } 33 | }`, 34 | variables: { 35 | id: item.info 36 | } 37 | }) 38 | }) 39 | .then(data => refetch()) 40 | }, 41 | collect: (monitor) => ({ 42 | isOver: !!monitor.isOver(), 43 | canDrop: monitor.canDrop(), 44 | }), 45 | }); 46 | 47 | const isActive = canDrop && isOver; 48 | let backgroundColor; 49 | if (isActive) { 50 | backgroundColor = 'lightblue'; 51 | } 52 | return ( 53 |
54 | 55 |
56 | ) 57 | } 58 | 59 | export default Restart; 60 | -------------------------------------------------------------------------------- /client/components/dockerContainer/Stop.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect} from 'react'; 2 | import { useDrop } from 'react-dnd'; 3 | import stop from '../../assets/stop.png' 4 | import restart from '../../assets/restart.png' 5 | import { useQuery, gql, useMutation} from '@apollo/client'; 6 | 7 | const style = { 8 | height: '8rem', 9 | width: '8rem', 10 | marginRight: '4rem', 11 | marginBottom: '1rem', 12 | color: 'white', 13 | padding: '1rem', 14 | textAlign: 'center', 15 | fontSize: '1rem', 16 | lineHeight: 'normal', 17 | float: 'left', 18 | }; 19 | 20 | function Stop({containerData, refetch}) { 21 | 22 | const [{ isOver, canDrop }, drop] = useDrop({ 23 | accept: 'image', 24 | drop: (item) => { 25 | fetch('/graphql', { 26 | method: 'POST', 27 | headers: { 28 | 'Content-Type': 'application/json', 29 | }, 30 | body: JSON.stringify({ 31 | query: ` 32 | mutation stopContainer ($id: String) { 33 | stopContainer(id: $id) { 34 | id 35 | } 36 | }`, 37 | variables: { 38 | id: item.info 39 | } 40 | }) 41 | }) 42 | .then(data => refetch()) 43 | }, 44 | collect: (monitor) => ({ 45 | isOver: !!monitor.isOver(), 46 | canDrop: monitor.canDrop(), 47 | }), 48 | }); 49 | 50 | const isActive = canDrop && isOver; 51 | let backgroundColor; 52 | if (isActive) { 53 | backgroundColor = '#c80004'; 54 | } 55 | return ( 56 |
57 | 58 |
59 | ) 60 | } 61 | 62 | export default Stop; 63 | -------------------------------------------------------------------------------- /client/components/notification/Notification.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const Notification = (props) => { 4 | return ( 5 | <> 6 |

Alert! Your container is using too much memory. We recommend restarting the container.

7 |

Alert! Your container is using too much CPU. We recommend restarting the container.

8 | 9 | ) 10 | } 11 | 12 | export default Notification; -------------------------------------------------------------------------------- /client/containers/ContainersContainer.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect} from 'react'; 2 | import NavBar from '../components/NavBar/NavBar'; 3 | import { useQuery, gql, useLazyQuery} from '@apollo/client'; 4 | import Cookies from 'js-cookie'; 5 | import deleteContainer from "../assets/delete.png"; 6 | import stopContainer from "../assets/stop.png"; 7 | import restartContainer from "../assets/restart.png"; 8 | import DndContainers from "../components/dockerContainer/DndContainers" 9 | import Stop from "../components/dockerContainer/Stop" 10 | import Restart from '../components/dockerContainer/Restart' 11 | 12 | const GET_CONTAINERS = gql` 13 | query Containers ($id: Int) { 14 | container(id: $id) { 15 | id 16 | dockerid 17 | name 18 | size 19 | state 20 | status 21 | } 22 | } 23 | `; 24 | 25 | 26 | const ContainersContainer = ({validId}) => { 27 | 28 | const variables = { id: parseInt(localStorage.getItem('validId')) }; 29 | const { loading, error, data, refetch } = useQuery(GET_CONTAINERS, {variables}) 30 | const [containerData, setContainerData] = useState([]) 31 | 32 | return ( 33 |
34 | 35 |
36 |
Containers
37 | 38 | {/* Active Containers */} 39 |
40 |
41 | {/* */} 42 |
43 | {/* */} 44 |
Active Containers
45 |
46 | {/* */} 47 |
48 | {loading ?
Loading...
: } 49 |
50 |
51 |
52 | 53 | 54 |
55 |
56 |
57 |
58 | 59 | {/* InActive Containers */} 60 |
61 |
62 | {/* */} 63 |
64 | {/* */} 65 |
Inactive Containers
66 |
67 | {/* */} 68 |
69 | {loading ?
Loading...
: } 70 |
71 | 72 |
73 |
74 | 75 |
76 |
77 | 78 | ) 79 | } 80 | 81 | export default ContainersContainer; 82 | 83 | -------------------------------------------------------------------------------- /client/containers/DashboardContainer.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Route, Redirect } from "react-router-dom"; 3 | import { useState, useEffect } from 'react'; 4 | import WhaleChart from "../components/dashboard/WhaleChart"; 5 | import AverageCPUChart from "../components/dashboard/AverageCPUChart"; 6 | import AverageMemoryChart from "../components/dashboard/AverageMemoryChart"; 7 | import NetIOChart from "../components/dashboard/NetIOChart"; 8 | import BlockIOChart from "../components/dashboard/BlockIOChart"; 9 | import { useQuery, gql } from '@apollo/client'; 10 | import NavBar from "../components/NavBar/NavBar"; 11 | import PIDChart from "../components/dashboard/PIDChart"; 12 | import Cookies from 'js-cookie'; 13 | import formatBytes from "./containerHelpers"; 14 | 15 | //get containers is a query that requests container data from our GraphQL endpoint 16 | const GET_CONTAINERS = gql` 17 | query Containers ($id: Int) { 18 | container(id: $id) { 19 | id 20 | dockerid 21 | name 22 | size 23 | status 24 | stats { 25 | timestamp 26 | cpuusage 27 | memusage 28 | netio 29 | blockio 30 | pids 31 | reqpermin 32 | } 33 | } 34 | } 35 | `; 36 | 37 | function forceRerender() { 38 | const [forceRerender, setForceRerender] = useState(0); 39 | return () => setForceRerender(forceRerender => forceRerender + 1) 40 | } 41 | 42 | const DashboardContainer = ({ validId }) => { 43 | const reRender = forceRerender(); 44 | //currently, user id is held in local storage 45 | const variables = { id: Number(localStorage.getItem('validId')) }; 46 | //we are using Apollo useQuery hooks to fetch the data 47 | const { loading, error, data } = useQuery(GET_CONTAINERS, { variables }); 48 | if (error) return `Error! ${error.message}`; 49 | 50 | /* 51 | function to parse the data for the line charts 52 | inputs: datatype string, data object from graphql 53 | output: an array of data, formatted properly for recharts 54 | */ 55 | const populateChart = (datatype, data) => { 56 | const array = data.container; 57 | const dataArr = []; 58 | const dataCache = {}; 59 | //loop through the container data and add the appropriate data points to the cache (grouped by timestamp) 60 | array.forEach(container => { 61 | const stats = container.stats; 62 | stats.forEach(stat => { 63 | if (!dataCache[stat.timestamp]) { 64 | dataCache[stat.timestamp] = []; 65 | } 66 | dataCache[stat.timestamp].push(stat[datatype]); 67 | }) 68 | }) 69 | //loop through the cache and average the values at each timestamp 70 | Object.keys(dataCache).forEach(time => { 71 | let total = 0; 72 | dataCache[time].forEach(entry => total += entry); 73 | const avg = total / dataCache[time].length; 74 | let timestamp = Number(time); 75 | timestamp = new Date(timestamp) 76 | //timestamp (which is our x axis label) is currently the numerical date - would refactor to a time instead 77 | dataArr.push({ timestamp: timestamp.getDate(), datatype: avg.toFixed(2) }); 78 | }) 79 | //sort the resulting array by time 80 | dataArr.sort((a, b) => a.timestamp - b.timestamp) 81 | return dataArr; 82 | } 83 | 84 | 85 | /*function to parse data for the bar charts 86 | inputs: datatype string, data object from graphql 87 | output: an array of data, formatted properly for recharts 88 | in future, I'd like to refactor this to be more dry (one chart func with helper funcs) 89 | */ 90 | const populateBarChart = (datatype, data) => { 91 | const arr = data.container; 92 | const dataArr = []; 93 | const dataCache = {}; 94 | //loop through the container data and add the appropriate data points to the cache (grouped by timestamp) 95 | arr.forEach(container => { 96 | const stats = container.stats; 97 | stats.forEach(stat => { 98 | if (!dataCache[stat.timestamp]) { 99 | dataCache[stat.timestamp] = []; 100 | } 101 | dataCache[stat.timestamp].push(stat[datatype]); 102 | }) 103 | }) 104 | //loop through the cache and average the values at each timestamp 105 | Object.keys(dataCache).forEach(time => { 106 | const timeArr = dataCache[time]; 107 | const inputArr = []; 108 | const outputArr = []; 109 | timeArr.forEach(el => { 110 | const idx = el.indexOf('B'); 111 | //the bar chart data is in strings with numbers of bytes, so we need to slice up to grab input and output numbers 112 | inputArr.push(el.slice(0, idx)); 113 | const out = el.slice(idx + 4, -1) 114 | outputArr.push(out === '' ? '0' : out) 115 | }) 116 | //averaging input and output 117 | const totalIn = inputArr.reduce((a, c) => Number(a) + Number(c)) 118 | const totalOut = outputArr.reduce((a, c) => Number(a) + Number(c)) 119 | let avgIn = totalIn / inputArr.length; 120 | avgIn = isNaN(avgIn) ? 0 : formatBytes(avgIn); 121 | let avgOut = totalOut / outputArr.length; 122 | avgOut = isNaN(avgOut) ? 0 : formatBytes(avgOut); 123 | let timestamp = Number(time); 124 | timestamp = new Date(timestamp) 125 | //pushing data in the correct format for recharts to the output array 126 | dataArr.push({ name: timestamp.getDate(), in: avgIn, out: avgOut }) 127 | }) 128 | //sorting by date 129 | dataArr.sort((a, b) => a.name - b.name) 130 | return dataArr; 131 | } 132 | 133 | 134 | 135 | return ( 136 |
137 | 138 |
139 |
140 |

Dashboard

141 |
142 | 143 | {/* Whale Chart */} 144 |
145 | {/* */} 146 |
147 | {/* */} 148 |
Container Health Overview
149 | 150 |
151 | {/* */} 152 |
153 | {/* */} 154 | {loading ?
Loading...
: } 155 |
156 |
157 | 158 | {/* AverageCPUChart */} 159 |
160 |
161 |
Average CPU Usage
162 |
163 |
164 | {loading ?
Loading...
: } 165 |
166 |
167 | 168 | {/* AverageMemoryChart */} 169 |
170 |
171 |
Average Memory Usage
172 |
173 |
174 | {loading ?
Loading...
: } 175 |
176 |
177 | 178 | {/* Average Net I/O */} 179 |
180 |
181 |
Average Net I/O
182 |
183 |
184 | {loading ?
Loading...
: } 185 |
186 |
187 | 188 | {/* BlockIOChart */} 189 |
190 |
191 |
Average Block I/O
192 |
193 |
194 | {loading ?
Loading...
: } 195 |
196 |
197 | 198 | {/* PIDChart */} 199 |
200 |
201 |
Average PIDs
202 |
203 |
204 | {loading ?
Loading...
: } 205 |
206 |
207 |
208 |
209 | ) 210 | } 211 | 212 | export default DashboardContainer 213 | 214 | -------------------------------------------------------------------------------- /client/containers/NotificationsContainer.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useState } from 'react'; 3 | import NavBar from "../components/NavBar/NavBar"; 4 | import Notification from "../components/notification/Notification"; 5 | 6 | const NotificationsContainer = (props) => { 7 | return ( 8 |
9 | 10 |
11 |

Notifications

12 | 13 |
14 |
15 | ) 16 | } 17 | 18 | export default NotificationsContainer -------------------------------------------------------------------------------- /client/containers/SettingsContainer.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useState } from 'react'; 3 | import NavBar from "../components/NavBar/NavBar"; 4 | 5 | const SettingsContainer = (props) => { 6 | 7 | return ( 8 |
9 | 10 |
11 |

Settings

12 |

Here are the settings.

13 |
14 |
15 | ) 16 | } 17 | 18 | export default SettingsContainer 19 | -------------------------------------------------------------------------------- /client/containers/containerHelpers.js: -------------------------------------------------------------------------------- 1 | /* 2 | input: integer and optional decimals integer 3 | output: float 4 | this function formats bytes to kilobytes for a cleaner display 5 | */ 6 | function formatBytes(bytes, decimals = 2) { 7 | if (bytes === 0) return '0 Bytes'; 8 | 9 | const k = 1024; 10 | const dm = decimals < 0 ? 0 : decimals; 11 | const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; 12 | 13 | return parseFloat((bytes / Math.pow(k, 1)).toFixed(dm)) // + ' ' + sizes[i]; 14 | } 15 | 16 | export default formatBytes -------------------------------------------------------------------------------- /client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 13 | 14 | 15 | 16 | 17 | 18 | 21 | 22 | WhaleWatch 23 | 24 | 25 | 26 | 27 |
28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /client/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import App from './App.jsx'; 4 | import { 5 | ApolloClient, 6 | InMemoryCache, 7 | ApolloProvider, 8 | } from "@apollo/client"; 9 | import styles from './scss/Application.scss'; 10 | 11 | const enhancedFetch = (url, init) => { 12 | 13 | return fetch(url, { 14 | ...init, 15 | headers: { 16 | ...init.headers, 17 | 'Access-Control-Allow-Origin': '*' 18 | }, 19 | }, 20 | ).then(response => response) 21 | } 22 | //set up of our apollo client 23 | const client = new ApolloClient({ 24 | uri: '/graphql', 25 | cache: new InMemoryCache(), 26 | fetchOptions: { 27 | mode: 'no-cors', 28 | credentials: 'include' 29 | }, 30 | fetch: enhancedFetch, 31 | }); 32 | 33 | ReactDOM.render( 34 | 35 | 36 | , 37 | document.getElementById('root')); -------------------------------------------------------------------------------- /client/scss/Application.scss: -------------------------------------------------------------------------------- 1 | @import 'ContainersContainer'; 2 | 3 | -------------------------------------------------------------------------------- /client/scss/ContainersContainer.scss: -------------------------------------------------------------------------------- 1 | $containerBlue:rgb(98, 189, 222); 2 | 3 | #allContainers{ 4 | display: flex; 5 | height: 100%; 6 | } 7 | 8 | #containersPage{ 9 | width: 65%; 10 | display: flex; 11 | flex-direction: column; 12 | align-items: center; 13 | margin-left: 200px; 14 | } 15 | 16 | #containersList{ 17 | padding: 25px; 18 | margin: 10px; 19 | font-size:16px; 20 | border: solid lightblue .75px; 21 | align-content: space-between; 22 | width: 275px; 23 | border-radius: 20px; 24 | box-shadow: 6px 6px 6px 3px rgb(250, 246, 246); 25 | } 26 | 27 | 28 | 29 | #container-list-dnd{ 30 | display: flex; 31 | flex-direction: row; 32 | flex-wrap: wrap; 33 | border-radius: 5px; 34 | } 35 | 36 | #containersList > li{ 37 | margin: 0px; 38 | } 39 | .value{ 40 | display: inline; 41 | } 42 | 43 | 44 | #actions{ 45 | display: flex; 46 | width: 35%; 47 | justify-content: center; 48 | align-items: center; 49 | height: 100vh; 50 | 51 | } 52 | 53 | .containers{ 54 | margin: 30px 0 30px 0; 55 | display: flex; 56 | flex-direction: row; 57 | justify-content: center; 58 | width: 80%; 59 | height: 50%; 60 | align-items: flex-start; 61 | } 62 | #icons{ 63 | display: flex; 64 | flex-direction: column; 65 | width: 50%; 66 | align-items: center; 67 | border: 2px solid $containerBlue; 68 | padding: 20px; 69 | border-radius: 10%; 70 | } 71 | 72 | #icons > img { 73 | margin-bottom: 10px; 74 | } 75 | 76 | .containerTitle{ 77 | margin-top: 30px; 78 | } 79 | 80 | .value{ 81 | color: white; 82 | } 83 | 84 | #inactiveContainer{ 85 | height: 50%; 86 | } 87 | 88 | 89 | // Re-edited 90 | 91 | .dnd-board { 92 | height:50%; 93 | } 94 | 95 | .card2{ 96 | display: flex; 97 | flex-direction: column; 98 | // align-items: center; 99 | // border: 1px solid rgba(180, 74, 74, 0.05); 100 | background-color: rgb(255, 255, 255); 101 | background-clip: border-box; 102 | width:80%; 103 | // margin-left: 15px; 104 | // box-shadow: 0px 5px 5px 5px rgb(209, 209, 209); 105 | border-radius: 0px 0px 15px 15px ; 106 | margin-bottom: 30px; 107 | } 108 | 109 | 110 | .restart-stop { 111 | display:flex; 112 | margin-left: 20rem; 113 | align-items: center; 114 | // justify-content: center; 115 | div { 116 | margin-left: 1rem; 117 | margin-right: 1rem; 118 | margin-top: 0; 119 | margin-bottom: 0; 120 | } 121 | } 122 | 123 | 124 | -------------------------------------------------------------------------------- /client/styles.scss: -------------------------------------------------------------------------------- 1 | * { 2 | margin: 0; 3 | padding: 0; 4 | } 5 | 6 | .welcome { 7 | margin-top: 8%; 8 | margin-bottom:1%; 9 | font-size: 23px; 10 | color:#404253; 11 | font-family: 'Nunito Sans', sans-serif; 12 | font-weight: 700; 13 | } 14 | 15 | // logo real size 396 × 302 16 | .logo{ 17 | margin-top: 8%; 18 | margin-bottom: 3%; 19 | width: 138.6px; 20 | height: 105.7px; 21 | } 22 | 23 | .error-message{ 24 | color: rgb(204, 84, 84) 25 | } 26 | 27 | 28 | .authen-box{ 29 | display: flex; 30 | flex-direction: column; 31 | align-items: center; 32 | // background-color:#EEF2F8; 33 | height: 100%; 34 | margin-top:3%; 35 | } 36 | 37 | 38 | .authen-box-color{ 39 | margin-top: 40px; 40 | display: flex; 41 | flex-direction: column; 42 | align-items: center; 43 | width:600px; 44 | height:700px; 45 | 46 | } 47 | 48 | // Nav Bar Section 49 | // .vertical-nav{ 50 | // width: 200px; 51 | // height: 100vh; 52 | // background-color: white; 53 | // display: flex; 54 | // flex-direction: column; 55 | // align-items: center; 56 | // // border-radius: 15px; 57 | // box-shadow: 5px 5px 5px 1px rgb(207, 204, 204); 58 | // } 59 | 60 | .vertical-nav{ 61 | width: 210px; 62 | height: 100%; 63 | background-color: white; 64 | display: flex; 65 | flex-direction: column; 66 | align-items: center; 67 | box-shadow: 5px 5px 5px 1px rgb(207, 204, 204); 68 | position:fixed; 69 | } 70 | 71 | //logo real size: 396 × 302 (25%) 72 | .logo-small{ 73 | margin-top:50px; 74 | margin-bottom: 10%; 75 | width: 99px; 76 | height: 75.5px; 77 | position: fixed; 78 | } 79 | 80 | .menulist{ 81 | margin-bottom: 200px; 82 | } 83 | 84 | li { 85 | margin-top: 40px; 86 | width: 100%; 87 | } 88 | 89 | i { 90 | margin-right: 8px; 91 | } 92 | 93 | a:link { 94 | color:#404253; 95 | font-family: 'Nunito Sans', sans-serif; 96 | font-weight: 500; 97 | font-size:16px; 98 | text-decoration: none; 99 | position: fixed; 100 | margin-top: 150px; 101 | margin-left: -50px; 102 | } 103 | 104 | a:visited { 105 | color:#404253; 106 | font-family: 'Nunito Sans', sans-serif; 107 | font-weight: 500; 108 | font-size:16px; 109 | text-decoration: none; 110 | position: fixed; 111 | margin-top: 150px; 112 | margin-left: -50px; 113 | } 114 | 115 | a:hover { 116 | color:#48A1F8; 117 | font-family: 'Nunito Sans', sans-serif; 118 | font-weight: 500; 119 | font-size:16px; 120 | text-decoration: none; 121 | position: fixed; 122 | margin-top: 150px; 123 | margin-left: -50px; 124 | } 125 | 126 | .version { 127 | color:#404253; 128 | font-family: 'Nunito Sans', sans-serif; 129 | font-weight: 500; 130 | font-size:0.8em; 131 | position: fixed; 132 | bottom: 15px; 133 | } 134 | 135 | 136 | // Data Section 137 | .dashbaordContainer { 138 | background-color: #EEF2F8; 139 | display: flex; 140 | flex-direction: row; 141 | align-items: stretch; 142 | } 143 | 144 | .dashbaordData{ 145 | align-items: center; 146 | margin-left:250px; 147 | min-height:50rem; 148 | } 149 | 150 | // test 151 | .card1{ 152 | background-color: rgb(255, 255, 255); 153 | background-clip: border-box; 154 | width:1200px; 155 | border-radius: 0px 0px 15px 15px ; 156 | margin-bottom: 30px; 157 | } 158 | 159 | .top-card-header{ 160 | background-color: rgb(255, 255, 255); 161 | border-radius: 25px; 162 | display: flex; 163 | flex-direction: row; 164 | } 165 | 166 | .card-header{ 167 | background-color: rgb(255, 255, 255); 168 | border-radius: 25px; 169 | } 170 | 171 | 172 | #whale-chart{ 173 | display: flex; 174 | flex-direction: row; 175 | flex-wrap: wrap; 176 | } 177 | 178 | .whale-display { 179 | padding: 25px; 180 | margin: 10px; 181 | font-size:16px; 182 | border: solid lightblue .75px; 183 | align-content: space-between; 184 | width: 275px; 185 | border-radius: 20px; 186 | box-shadow: 6px 6px 6px 3px rgb(250, 246, 246); 187 | } 188 | 189 | // whale display 126 x 103 190 | .whale{ 191 | width: 75.6px; 192 | height: 61.8px; 193 | } 194 | 195 | .metric-type { 196 | color:#404253; 197 | font-family: 'Nunito Sans', sans-serif; 198 | font-weight: 650; 199 | font-size:18px; 200 | margin:3.5px; 201 | } 202 | 203 | .top-metric-type { 204 | color:#404253; 205 | font-family: 'Nunito Sans', sans-serif; 206 | font-weight: 650; 207 | font-size:18px; 208 | margin:3.5px; 209 | display: flex; 210 | justify-content: space-between; 211 | } 212 | 213 | .dashbaord-header { 214 | margin-top:30px; 215 | margin-bottom: 20px; 216 | color:#404253; 217 | font-family: 'Nunito Sans', sans-serif; 218 | font-weight: 800; 219 | font-size:1.2em; 220 | } 221 | 222 | // Settings 223 | .settingsContainer { 224 | display: flex; 225 | flex-direction: row; 226 | align-items: stretch; 227 | 228 | } 229 | .refresh-button { 230 | margin-top:5px; 231 | margin-bottom: 5px; 232 | font-family: 'Nunito Sans', sans-serif; 233 | color: white; 234 | background-color: #159ce4; 235 | border-radius: 10px; 236 | border: 0px; 237 | width: 120px; 238 | height:45px; 239 | font-size:20px; 240 | } -------------------------------------------------------------------------------- /main.js: -------------------------------------------------------------------------------- 1 | const electron = require('electron'); 2 | const path = require('path'); 3 | const url = require('url'); 4 | 5 | // SET ENV 6 | process.env.NODE_ENV = 'development'; 7 | 8 | const { app, BrowserWindow, Menu, ipcMain } = electron; 9 | 10 | let mainWindow; 11 | let addWindow; 12 | 13 | // listen for app to be ready 14 | app.on('ready', function () { 15 | // Create new window 16 | mainWindow = new BrowserWindow({ 17 | webPreferences: { 18 | nodeIntegration: true, 19 | } 20 | }); 21 | // Load HTML file into the window 22 | console.log(__dirname) 23 | mainWindow.loadURL('http://localhost:8080'); 24 | }) 25 | 26 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "whalewatch", 3 | "version": "1.0.0", 4 | "description": "WhaleWatch is a health monitoring app for Docker containers.", 5 | "main": "main.js", 6 | "scripts": { 7 | "build": "NODE_ENV=production webpack", 8 | "start": "NODE_ENV=production node server/index.js", 9 | "dev": "NODE_ENV=development webpack serve & NODE_ENV=development nodemon ./server/index.js", 10 | "electron": "electron .", 11 | "test": "jest --verbose" 12 | }, 13 | "nodemonConfig": { 14 | "ignore": [ 15 | "build", 16 | "client" 17 | ] 18 | }, 19 | "repository": { 20 | "type": "git", 21 | "url": "git+https://github.com/oslabs-beta/whalewatch.git" 22 | }, 23 | "author": "Phil Kang, Annie Pan, Rachel Patterson, and Matilda Wang", 24 | "license": "MIT", 25 | "bugs": { 26 | "url": "https://github.com/oslabs-beta/whalewatch/issues" 27 | }, 28 | "homepage": "https://github.com/oslabs-beta/whalewatch#readme", 29 | "dependencies": { 30 | "@apollo/client": "^3.4.8", 31 | "apollo-server": "^3.3.0", 32 | "bcrypt": "^5.0.1", 33 | "cookie-parser": "^1.4.5", 34 | "dotenv": "^8.6.0", 35 | "dotenv-webpack": "^7.0.3", 36 | "electron": "^13.2.2", 37 | "express": "^4.17.1", 38 | "express-graphql": "^0.12.0", 39 | "express-jwt": "^6.1.0", 40 | "graphql": "^15.5.1", 41 | "html-webpack-plugin": "^5.3.2", 42 | "js-cookie": "^3.0.0", 43 | "jsonwebtoken": "^8.5.1", 44 | "node-fetch": "^2.6.1", 45 | "node-polyfill-webpack-plugin": "^1.1.4", 46 | "path": "^0.12.7", 47 | "pg": "^8.7.1", 48 | "pg-native": "^3.0.0", 49 | "react": "^17.0.2", 50 | "react-bootstrap": "^1.6.1", 51 | "react-dnd": "^14.0.3", 52 | "react-dnd-html5-backend": "^14.0.1", 53 | "react-dom": "^17.0.2", 54 | "react-router-dom": "^5.2.0", 55 | "recharts": "^2.1.2", 56 | "sass": "^1.38.0", 57 | "sequelize": "^6.6.5", 58 | "strip-ansi": "3.0.1", 59 | "webpack": "^5.50.0" 60 | }, 61 | "devDependencies": { 62 | "@babel/core": "^7.15.0", 63 | "@babel/preset-env": "^7.15.0", 64 | "@babel/preset-react": "^7.14.5", 65 | "@testing-library/react": "^12.0.0", 66 | "babel-loader": "^8.2.2", 67 | "concurrently": "^6.2.1", 68 | "css-loader": "^6.2.0", 69 | "eslint": "^7.32.0", 70 | "jest": "^27.0.6", 71 | "nodemon": "^2.0.12", 72 | "postcss-loader": "^6.1.1", 73 | "sass-loader": "^12.1.0", 74 | "style-loader": "^3.2.1", 75 | "supertest": "^6.1.4", 76 | "ts-loader": "^9.2.5", 77 | "typescript": "^4.3.5", 78 | "webpack-cli": "^4.8.0", 79 | "webpack-dev-server": "^4.0.0" 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /server/db/connect.js: -------------------------------------------------------------------------------- 1 | //setting up connection to the database 2 | require('dotenv').config(); 3 | const { Pool } = require('pg'); 4 | const uri = process.env.DB_URI; 5 | const pool = new Pool({ 6 | connectionString: uri, 7 | max: 3 8 | }); 9 | 10 | module.exports = pool; -------------------------------------------------------------------------------- /server/db/schema.js: -------------------------------------------------------------------------------- 1 | const pool = require('./connect.js'); 2 | const bcrypt = require('bcrypt'); 3 | const graphql = require('graphql'); 4 | const dbHelper = require('../helpers/dbHelper'); 5 | 6 | const { 7 | GraphQLInt, 8 | GraphQLObjectType, 9 | GraphQLString, 10 | GraphQLID, // more flexible to use for IDs than strings 11 | GraphQLSchema, 12 | GraphQLList, 13 | GraphQLFloat, 14 | GraphQLScalarType, 15 | Kind 16 | } = graphql; 17 | 18 | const jwt = require('jsonwebtoken') 19 | //graphqlobjecttype is function from graphql 20 | //schema for Users 21 | const UserType = new GraphQLObjectType({ 22 | name: 'User', 23 | description: 'Users data in our app', 24 | fields: () => ({ 25 | id: { 26 | type: GraphQLInt,//resolve is what is invoked so graphql knows how to get data 27 | }, 28 | username: { type: GraphQLString }, 29 | email: { type: GraphQLString }, 30 | password: { type: GraphQLString }, 31 | containerName: { 32 | type: new GraphQLList(ContainerType), 33 | resolve: async (parent) => { 34 | await dbHelper.refreshContainerData(parent.id); 35 | const vals = [parent.id]; 36 | const query = `SELECT * from containers WHERE owner=$1`; 37 | //return containerData.filter(container => container.user_id === parent.id); 38 | const res = await pool.query(query, vals); 39 | return res.rows; 40 | } 41 | }, 42 | }) 43 | }) 44 | 45 | //schema for containers 46 | const ContainerType = new GraphQLObjectType({ 47 | name: 'Containers', 48 | description: 'Our containers', 49 | fields: () => ({ 50 | id: { type: GraphQLInt }, 51 | dockerid: { type: GraphQLString }, 52 | name: { type: GraphQLString }, 53 | size: { type: GraphQLString }, 54 | status: { type: GraphQLString }, 55 | state: { type: GraphQLString }, 56 | stats: { 57 | type: new GraphQLList(StatsType), 58 | resolve: async (parent) => { 59 | const vals = [parent.id]; 60 | const query = `SELECT * from stats WHERE container=$1`; 61 | const res = await pool.query(query, vals); 62 | return res.rows; 63 | } 64 | } 65 | }) 66 | }) 67 | 68 | const StatsType = new GraphQLObjectType({ 69 | name: 'Stats', 70 | description: 'Stats data', 71 | fields: () => ({ 72 | id: { type: GraphQLID }, 73 | container: { type: GraphQLID }, 74 | timestamp: { type: GraphQLFloat }, // dates and times have to be defined as custom scalars like Date or timestamp - might need to npm install --save graphql-scalars 75 | cpuusage: { type: GraphQLFloat }, 76 | memusage: { type: GraphQLFloat }, 77 | netio: { type: GraphQLString }, 78 | blockio: { type: GraphQLString }, 79 | pids: { type: GraphQLInt }, 80 | reqpermin: { type: GraphQLInt }, 81 | }) 82 | }) 83 | 84 | // root query: how we initially jump into graph 85 | const RootQueryType = new GraphQLObjectType({ 86 | name: 'RootQueryType', 87 | description: 'Root Query', 88 | fields: () => ({ 89 | user: { 90 | type: new GraphQLList(UserType), 91 | //if this were a database, return database here 92 | resolve: async () => { 93 | const res = await pool.query(`SELECT * from "users"`); 94 | return res.rows; 95 | }, 96 | }, 97 | oneUser: { 98 | type: UserType, 99 | description: 'A single user', 100 | args: { 101 | id: { type: GraphQLInt } 102 | }, 103 | resolve: async (parent, args) => { 104 | const vals = [args.id] 105 | const query = `SELECT * from "users" WHERE id = $1` 106 | const res = await pool.query(query, vals); 107 | return res.rows[0]; 108 | }, 109 | }, 110 | container: { 111 | type: new GraphQLList(ContainerType), 112 | description: 'List of all our containers', 113 | args: { 114 | id: { type: GraphQLInt } 115 | }, 116 | resolve: async (parent, args) => { 117 | await dbHelper.refreshContainerData(args.id); 118 | const res = await pool.query(`SELECT * from "containers" WHERE owner = $1`, [args.id]); 119 | return res.rows; 120 | } 121 | }, 122 | oneContainer: { 123 | type: ContainerType, 124 | description: 'a single container', 125 | args: { 126 | container_id: { type: GraphQLInt } 127 | }, 128 | resolve: async (parent, args) => { 129 | const vals = [args.id] 130 | const query = `SELECT * from "containers" WHERE id = $1` 131 | const res = await pool.query(query, vals); 132 | return res.rows[0]; 133 | } 134 | }, 135 | stat: { 136 | type: new GraphQLList(StatsType), 137 | description: 'Stats on a container', 138 | resolve: async () => { 139 | const res = await pool.query(`SELECT * from "stats"`); 140 | return res.rows; 141 | } 142 | } 143 | }) 144 | }) 145 | 146 | //mutation functions 147 | const RootMutationType = new GraphQLObjectType({ 148 | name: 'Mutations', 149 | description: 'Manipulation of user data', 150 | fields: () => ({ 151 | //functionality to add user into database 152 | addUser: { 153 | type: UserType, 154 | description: 'Add a user', 155 | args: { 156 | id: { type: GraphQLInt },//resolve is what is invoked so graphql knows how to get data 157 | username: { type: GraphQLString }, 158 | email: { type: GraphQLString }, 159 | password: { type: GraphQLString }, 160 | 161 | }, 162 | resolve: async (parent, args) => { 163 | //hash and salt returning pw 164 | const password = await bcrypt.hash(args.password, 10); 165 | const user = [args.username, args.email, password] 166 | //insert into database user information 167 | const query = 'INSERT INTO users (username, email, password) VALUES ($1, $2, $3) RETURNING *' 168 | const res = await pool.query(query, user); 169 | return res.rows[0]; 170 | } 171 | }, 172 | validateUser: { 173 | type: UserType, 174 | description: 'Make sure username + pw match', 175 | args: { 176 | username: { type: GraphQLString }, 177 | password: { type: GraphQLString }, 178 | }, 179 | resolve: async (parent, args, { req, res }) => { 180 | 181 | const username = [args.username] 182 | const query = `SELECT password from users WHERE username = $1`; 183 | const result = await pool.query(query, username) 184 | const comparingPassword = await bcrypt.compare(args.password, result.rows[0].password); 185 | if (comparingPassword === true) { 186 | const finalResult = await pool.query(`SELECT * from users where username = $1`, username); 187 | const accessToken = jwt.sign({ userId: finalResult.rows[0].id }, 'Dockerpalsarecuties', { expiresIn: '15min' }); 188 | const refreshToken = jwt.sign({ userId: finalResult.rows[0].id }, 'Dockerpalsarecuties', { expiresIn: '7d' }); 189 | const refresh = res.cookie('refresh-token', refreshToken, { expire: 60 * 60 * 27 * 7 }) 190 | const access = res.cookie('access-token', accessToken, { expire: 60 * 15 }) 191 | return finalResult.rows[0]; 192 | 193 | } 194 | } 195 | }, 196 | addStat: { 197 | type: StatsType, 198 | description: 'Adding dummy stats to database', 199 | args: { 200 | id: { type: GraphQLID }, 201 | container: { type: GraphQLInt }, 202 | timestamp: { type: GraphQLFloat }, // dates and times have to be defined as custom scalars like Date or timestamp - might need to npm install --save graphql-scalars 203 | cpuusage: { type: GraphQLFloat }, 204 | memusage: { type: GraphQLFloat }, 205 | netio: { type: GraphQLString }, 206 | blockio: { type: GraphQLString }, 207 | pids: { type: GraphQLInt }, 208 | reqpermin: { type: GraphQLInt }, 209 | }, 210 | resolve: async (parent, args) => { 211 | // args.container, args.timestamp, args.cpuusage, args.memusage, args.netio, args.blockio, args.pids, args.reqpermin 212 | const statEntry = [args.container, args.timestamp, args.cpuusage, args.memusage, args.netio, args.blockio, args.pids, args.reqpermin] 213 | const query = 'INSERT INTO stats (container, timestamp, cpuusage, memusage, netio, blockio, pids, reqpermin) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)' 214 | const res = await pool.query(query, statEntry); 215 | return res.rows[0]; 216 | } 217 | }, 218 | stopContainer: { 219 | type: ContainerType, 220 | description: 'Stop a container', 221 | args: { 222 | id: { type: GraphQLString } 223 | }, 224 | resolve: (parent, args) => { 225 | dbHelper.stopContainer(args.id); 226 | return args.id; 227 | } 228 | }, 229 | restartContainer: { 230 | type: ContainerType, 231 | description: 'Restart a container', 232 | args: { 233 | id: { type: GraphQLString } 234 | }, 235 | resolve: async (parent, args) => { 236 | await dbHelper.restartContainer(args.id); 237 | return args.id; 238 | } 239 | } 240 | }) 241 | }); 242 | 243 | const schema = new GraphQLSchema({ 244 | query: RootQueryType, 245 | mutation: RootMutationType 246 | }) 247 | 248 | // export schema 249 | module.exports = schema; 250 | 251 | 252 | 253 | -------------------------------------------------------------------------------- /server/db/setup.sql: -------------------------------------------------------------------------------- 1 | -- tables to create for database setup 2 | CREATE TABLE IF NOT EXISTS users ( 3 | id SERIAL PRIMARY KEY, 4 | username varchar(50) UNIQUE NOT NULL, 5 | email varchar(100) UNIQUE NOT NULL, 6 | password varchar(100) NOT NULL 7 | ) 8 | 9 | CREATE TABLE IF NOT EXISTS containers ( 10 | id SERIAL PRIMARY KEY, 11 | dockerId varchar(100) NOT NULL, 12 | name varchar(100) NOT NULL, 13 | size varchar(50), 14 | status varchar(50), 15 | state varchar(50), 16 | owner integer REFERENCES users(id) NOT NULL 17 | ) 18 | 19 | CREATE TABLE IF NOT EXISTS stats ( 20 | id SERIAL PRIMARY KEY, 21 | container integer REFERENCES containers(id) NOT NULL, 22 | timestamp date NOT NULL, 23 | cpuUsage decimal NOT NULL, 24 | memUsage decimal NOT NULL, 25 | netIo varchar(50) NOT NULL, 26 | blockIo varchar(50) NOT NULL, 27 | pids integer NOT NULL, 28 | reqPerMin integer 29 | ) 30 | 31 | module.exports = tables; -------------------------------------------------------------------------------- /server/helpers/dbHelper.js: -------------------------------------------------------------------------------- 1 | const dockerApiHelper = require('./dockerApiHelper'); 2 | const pool = require('../db/connect.js'); 3 | const { container } = require('webpack'); 4 | const dbHelper = {}; 5 | const { dockerCliHelper } = require('./dockerCliHelper'); 6 | 7 | //these functions will parse the docker data and add it to the database if not already there 8 | 9 | //this function takes in a given container's docker ID and database ID and queries for stats for the container, then adds them to the database 10 | const refreshStats = async (dockerId, id) => { 11 | try { 12 | const stats = await dockerCliHelper.getStats(dockerId); 13 | let timestamp = new Date(Date.now()); 14 | const cpuUsage = stats[0].CPUPerc.slice(0, -1); 15 | const memUsage = stats[0].MemPerc.slice(0, -1); 16 | const netIo = stats[0].NetIO 17 | const blockIo = stats[0].BlockIO; 18 | const pids = stats[0].PIDs; 19 | const vals = [id, timestamp, cpuUsage, memUsage, netIo, blockIo, pids] 20 | await pool.query('INSERT INTO STATS (container, timestamp, cpuUsage, memUsage, netIo, blockIo, pids) VALUES ($1, $2, $3, $4, $5, $6, $7)', vals) 21 | } catch (err) { 22 | console.log('error in refreshing stats data', err) 23 | } 24 | } 25 | 26 | //this function takes in a user's database id and retrieves a list of all containers on their computer. 27 | //it then either updates or inserts each container 28 | dbHelper.refreshContainerData = async (owner) => { 29 | try { 30 | const containers = await dockerCliHelper.getContainerList(); 31 | const insertQuery = 'INSERT INTO containers (dockerId, name, size, state, owner, status) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id'; 32 | for (let container of containers) { 33 | const dockerId = container.ID; 34 | const name = container.Names; 35 | const size = container.Size; 36 | const state = container.State; 37 | let status; 38 | 39 | if (container.Status.includes('unhealthy')) { 40 | status = 'unhealthy'; 41 | } else status = 'healthy'; 42 | //check if already there, if so, update size and state 43 | const checkContainer = await pool.query('SELECT * from containers WHERE dockerId=$1 AND owner=$2', [dockerId, owner]); 44 | if (checkContainer.rows.length) { 45 | await pool.query('UPDATE containers SET size=$1, state=$2, status=$3 WHERE dockerId=$4 AND owner=$5', [size, state, status, dockerId, owner]); 46 | if (state === 'running') { 47 | await refreshStats(dockerId, checkContainer.rows[0].id); 48 | } 49 | } else { 50 | const dbId = await pool.query(insertQuery, [dockerId, name, size, state, owner, status]); 51 | if (state === 'running') { 52 | await refreshStats(dockerId, dbId.rows[0].id); 53 | } 54 | } 55 | } 56 | } catch (err) { 57 | console.log('error in refreshing container data', err); 58 | } 59 | } 60 | 61 | //this function stops a container by docker id and updates the database with its stopped state 62 | dbHelper.stopContainer = async (id) => { 63 | try { 64 | await dockerCliHelper.stopContainer(id); 65 | const stopQuery = 'UPDATE containers SET state=$1 WHERE dockerid=$2'; 66 | await pool.query(stopQuery, ['exited', id]); 67 | } catch (err) { console.log(err) } 68 | } 69 | 70 | //this function restarts a container by docker id and updates the database with its running state 71 | dbHelper.restartContainer = async (id) => { 72 | try { 73 | await dockerCliHelper.restartContainer(id); 74 | const stopQuery = 'UPDATE containers SET state=$1 WHERE dockerid=$2'; 75 | await pool.query(stopQuery, ['running', id]); 76 | } catch (err) { console.log(err) } 77 | } 78 | 79 | module.exports = dbHelper; -------------------------------------------------------------------------------- /server/helpers/dockerApiHelper.js: -------------------------------------------------------------------------------- 1 | //this file contains functions to use the docker engine API. we have refactored and are no longer using these 2 | //however, I have kept the file in case a future iteration prefers to refactor with these 3 | 4 | const dockerApiHelper = {}; 5 | const fetch = require('node-fetch'); 6 | const dockerPort = 'http://localhost:2375'; 7 | 8 | //all docker API calls will go here 9 | 10 | //fetch a list of running containers from the docker engine API 11 | dockerApiHelper.getContainerList = async () => { 12 | try { 13 | const response = await fetch(`${dockerPort}/containers/json?all=true&size=true`) 14 | const data = await response.json(); 15 | return data; 16 | } catch (err) { 17 | console.log('error in get container list: ', err) 18 | } 19 | } 20 | 21 | dockerApiHelper.inspectContainer = (id) => { 22 | fetch(`${dockerPort}/containers/${id}/json?size=true`) 23 | .then(result => result.json()) 24 | .then(data => { 25 | return data; 26 | }) 27 | .catch(err => console.log('Error in docker helper inspect container: ', err)) 28 | } 29 | const getDelta = (current, pre) => current - pre; 30 | 31 | dockerApiHelper.getStats = async (id) => { 32 | try { 33 | const response = await fetch(`${dockerPort}/containers/${id}/stats?stream=false`) 34 | const stats = await response.json(); 35 | return stats; 36 | 37 | } catch (err) { 38 | console.log('Error in docker helper inspect container: ', err) 39 | } 40 | } 41 | 42 | dockerApiHelper.startContainer = (id) => { 43 | fetch(`${dockerPort}/containers/${id}/start`, { 44 | method: 'POST' 45 | }) 46 | .then(result => result.json()) 47 | .then(data => { 48 | return data; 49 | }) 50 | .catch(err => console.log('Error in docker helper start container: ', err)) 51 | } 52 | 53 | dockerApiHelper.stopContainer = (id) => { 54 | fetch(`${dockerPort}/containers/${id}/stop`, { 55 | method: 'POST' 56 | }) 57 | .then(result => result.json()) 58 | .then(data => { 59 | return data; 60 | }) 61 | .catch(err => console.log('Error in docker helper stop container: ', err)) 62 | } 63 | 64 | dockerApiHelper.restartContainer = (id) => { 65 | fetch(`${dockerPort}/containers/${id}/restart`, { 66 | method: 'POST' 67 | }) 68 | .then(result => result.json()) 69 | .then(data => { 70 | return data; 71 | }) 72 | .catch(err => console.log('Error in docker helper restart container: ', err)) 73 | } 74 | 75 | dockerApiHelper.removeContainer = (id) => { 76 | fetch(`${dockerPort}/containers/${id}`, { 77 | method: 'DELETE' 78 | }) 79 | .then(result => result.json()) 80 | .then(data => { 81 | return data; 82 | }) 83 | .catch(err => console.log('Error in docker helper delete container: ', err)) 84 | } 85 | 86 | 87 | module.exports = dockerApiHelper; 88 | 89 | -------------------------------------------------------------------------------- /server/helpers/dockerCliHelper.js: -------------------------------------------------------------------------------- 1 | //these functions spawn a terminal to use the docker CLI 2 | const dockerCliHelper = {}; 3 | const util = require('util'); 4 | const exec = util.promisify(require('child_process').exec); 5 | 6 | //the output from docker CLI is not purely correct JSON, so this function takes in the output and creates an array of parsed objects 7 | const parseCliJSON = (stdout) => { 8 | const output = []; 9 | let dockerOutput = stdout.trim(); 10 | 11 | const objs = dockerOutput.split('\n'); 12 | 13 | for (let i = 0; i < objs.length; i++) { 14 | output.push(JSON.parse(objs[i])) 15 | } 16 | return output; 17 | } 18 | 19 | //here we get a list of all running or stopped containers 20 | dockerCliHelper.getContainerList = async () => { 21 | const { stdout, stderr } = await exec('docker ps --all --size --format "{{json .}}"') 22 | if (stderr) { 23 | console.log(stderr); 24 | return stderr; 25 | } 26 | const output = parseCliJSON(stdout) 27 | return output; 28 | 29 | } 30 | 31 | //here we can inspect a container - this is not currently in use 32 | dockerCliHelper.inspectContainer = async (id) => { 33 | const { stdout, stderr } = await exec(`docker inspect --format "{{json .}}" ${id}`) 34 | 35 | if (stderr) { 36 | console.log(stderr); 37 | return; 38 | } 39 | return parseCliJSON(stdout) 40 | 41 | } 42 | 43 | //here we get stats for a particular container by docker id 44 | dockerCliHelper.getStats = async (id) => { 45 | const { stdout, stderr } = await exec(`docker stats --no-stream --format "{{json .}}" ${id}`) 46 | if (stderr) { 47 | console.log(stderr); 48 | return; 49 | } 50 | return parseCliJSON(stdout); 51 | 52 | } 53 | 54 | //here we can start a container by docker id 55 | dockerCliHelper.startContainer = async (id) => { 56 | const { stdout, stderr } = await exec(`docker start ${id}`) 57 | 58 | if (stderr) { 59 | console.log(stderr); 60 | return; 61 | } 62 | return; 63 | 64 | } 65 | 66 | //here we can stop a container by docker id 67 | dockerCliHelper.stopContainer = async (id) => { 68 | const { stdout, stderr } = await exec(`docker stop ${id}`) 69 | 70 | if (stderr) { 71 | console.log(stderr); 72 | return; 73 | } 74 | return; 75 | 76 | } 77 | 78 | //here we can restart a container by docker id 79 | dockerCliHelper.restartContainer = async (id) => { 80 | const { stdout, stderr } = await exec(`docker restart ${id}`) 81 | 82 | if (stderr) { 83 | console.log(stderr); 84 | return; 85 | } 86 | return; 87 | 88 | } 89 | 90 | //here we can remove a container by docker id - not currently in use 91 | dockerCliHelper.removeContainer = async (id) => { 92 | const { stdout, stderr } = await exec(`docker rm ${id}`) 93 | 94 | if (stderr) { 95 | console.log(stderr); 96 | return; 97 | } 98 | return; 99 | 100 | } 101 | 102 | 103 | 104 | module.exports = { dockerCliHelper, parseCliJSON }; -------------------------------------------------------------------------------- /server/index.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const path = require('path'); 3 | const app = express(); 4 | const { graphqlHTTP } = require('express-graphql'); 5 | const PORT = 3000; 6 | const schema = require('./db/schema.js'); 7 | const expressJwt = require('express-jwt'); 8 | const { ApolloServer, gql } = require('apollo-server-express'); 9 | const bodyParser = require('body-parser') 10 | const cookieParser = require('cookie-parser') 11 | 12 | app.use(express.json()); 13 | app.use(express.static('build')); 14 | app.use(cookieParser()); 15 | //use our graphql middleware. only to one endpoint 16 | app.use('/graphql', (req, res) => { 17 | return graphqlHTTP({ 18 | schema, 19 | context: { req, res }, 20 | graphiql: true 21 | })(req, res) 22 | } 23 | ) 24 | 25 | //allow access to our index.html folder 26 | app.use('/', express.static(path.join(__dirname, '../client'))); 27 | 28 | //serve the index file 29 | if (process.env.NODE_ENV === 'production') { 30 | //allow access to our index.html folder 31 | app.use('/build', express.static(path.join(__dirname, '../build'))); 32 | app.get('/*', (req, res) => { 33 | res.sendFile(path.join(__dirname, '../client/index.html')); 34 | }); 35 | } 36 | 37 | app.listen(PORT, () => { 38 | console.log(`Server listening on port ${PORT}`); 39 | }) 40 | 41 | module.exports = app; 42 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const NodePolyfillPlugin = require('node-polyfill-webpack-plugin'); 3 | const HtmlWebpackPlugin = require("html-webpack-plugin"); 4 | 5 | 6 | module.exports = { 7 | entry: path.join(__dirname, './client/index.js'), 8 | devServer: { 9 | historyApiFallback: true, 10 | proxy: { 11 | '/graphql': { 12 | target: 'http://localhost:3000', 13 | secure: false, 14 | } 15 | } 16 | }, 17 | plugins: [ 18 | new NodePolyfillPlugin(), 19 | new HtmlWebpackPlugin({ 20 | template: "./client/index.html", 21 | }), 22 | ], 23 | output: { 24 | path: path.resolve(__dirname, 'build'), 25 | filename: 'bundle.js', 26 | }, 27 | mode: process.env.NODE_ENV, 28 | module: { 29 | rules: [ 30 | { 31 | test: /\.tsx?$/, 32 | use: 'ts-loader', 33 | exclude: /node_modules/, 34 | }, 35 | { 36 | test: /\.jsx?/, 37 | exclude: /node_modules/, 38 | use: { 39 | loader: 'babel-loader', 40 | options: { 41 | presets: ['@babel/preset-env', '@babel/preset-react'] 42 | } 43 | } 44 | }, 45 | { 46 | test: /\.(sa|sc|c)ss$/, 47 | // test: /\.css$/i, 48 | use: [ 49 | 'style-loader', 50 | 'css-loader', 51 | 'postcss-loader', 52 | 'sass-loader', 53 | ], 54 | }, 55 | { 56 | test: /\.(png|jpg|jpeg|gif)$/i, 57 | type: "asset/resource", 58 | }, 59 | ] 60 | }, 61 | // target: 'node', 62 | // externals: { 63 | // express: 'require(express)' 64 | // }, 65 | resolve: { 66 | // Enable importing JS / JSX files without specifying their extension 67 | extensions: [".js", ".jsx"], 68 | }, 69 | } 70 | 71 | --------------------------------------------------------------------------------