├── .eslintrc.json ├── .gitignore ├── .prettierrc ├── Dockerfile ├── README.md ├── __tests__ ├── backend │ └── routeTesting.js └── client │ ├── homepage.test.js │ └── signup-page.test.js ├── babel.config.json ├── client ├── App.jsx ├── assets │ ├── AvailableGraphs.jsx │ ├── Github-Logo.jpg │ ├── black-background-redisphere-logo.png │ ├── demo2.gif │ ├── large-cache-hit.png │ ├── large-evex.png │ ├── large-latency.png │ ├── linked-in.png │ ├── med-cache-hit.png │ ├── med-evex.png │ ├── med-latency.png │ ├── memory-usage.png │ ├── redisphere_banner (1).png │ └── white-background-redisphere-logo.png ├── components │ ├── AddWidgetModal.jsx │ ├── ConnectRedis.jsx │ ├── Contact.jsx │ ├── DashBoard.jsx │ ├── EvictedExpiredLinePlot.jsx │ ├── Footer.jsx │ ├── FreeMemory.jsx │ ├── Header.jsx │ ├── HitMissLinePlot.jsx │ ├── HomePage.jsx │ ├── LatencyChart.jsx │ ├── LoginPage.jsx │ ├── MemoryChart.jsx │ └── SignupPage.jsx ├── dashboardReducer.js ├── index.js ├── store.js └── styles.css ├── docker-compose-test.yml ├── docker-compose.yml ├── index.html ├── jest.config.json ├── mocks └── fileMock.js ├── package.json ├── server ├── controllers │ ├── cookieController.js │ ├── redisController.js │ ├── sessionController.js │ └── userController.js ├── models │ ├── sessionModel.js │ └── userModel.js ├── routes │ ├── api.js │ └── authRouter.js ├── server.js └── utils │ ├── nottests.js │ └── redis-load-test.js └── webpack.config.js /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "ignorePatterns": ["**/test", "**/__tests__"], 4 | "env": { 5 | "node": true, 6 | "browser": true, 7 | "es2021": true 8 | }, 9 | "plugins": ["react"], 10 | "extends": ["eslint:recommended", "plugin:react/recommended"], 11 | "parserOptions": { 12 | "sourceType": "module", 13 | "ecmaFeatures": { 14 | "jsx": true 15 | } 16 | }, 17 | "rules": { 18 | "indent": ["warn", 2], 19 | "no-unused-vars": ["off", { "vars": "local" }], 20 | "no-case-declarations": "off", 21 | "prefer-const": "warn", 22 | "quotes": ["warn", "single"], 23 | "react/prop-types": "off", 24 | "semi": ["warn", "always"], 25 | "space-infix-ops": "warn" 26 | }, 27 | "settings": { 28 | "react": { "version": "detect" } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build/*.js 3 | npm-debug.log 4 | .DS_Store 5 | .env 6 | package-lock.json 7 | dump.rdb 8 | build/ 9 | *.zip -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "semi": true, 4 | "tabWidth": 2, 5 | "printWidth": 100, 6 | "trailingComma": "all" 7 | } 8 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20.8.1 2 | 3 | WORKDIR /usr/src/app 4 | 5 | COPY . . 6 | 7 | ENV PORT=3000 8 | 9 | ENV MONGO_URI=[YOUR URI HERE] 10 | 11 | RUN npm install 12 | 13 | RUN npm run build 14 | 15 | EXPOSE 3000 16 | 17 | ENTRYPOINT [ "node", "./server/server.js" ] 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![rediSphere banner]() 2 | 3 | # rediSphere 🔮 4 | 5 | [rediSphere](http://redisphere.com) is an open source web application for visualizing Redis performance metrics in realtime dashboards. It aims to provide developers an intuitive way to gain visibility into their caching databases and quickly resolve issues. 6 | 7 | ## Overview 8 | 9 | rediSphere fetches key Redis statistics every second and plots the timeseries data in customizable charts. It eliminates the need for manually parsing verbose logs and terminal outputs. 10 | 11 | The core metrics displayed include: 12 | 13 | - Cache hit/miss ratios 🔍 14 | - Memory usage 💾 15 | - Average latency times ⏱️ 16 | - Evictions and expirations 🗑️ 17 | 18 | Users can enable various combinations of charts to create dashboard views tailored to their use cases. The dashboard auto-refreshes with the latest metrics pulled from the Redis instance. 19 | 20 | ## Installation 21 | 22 | To install rediSphere: 23 | 24 | 1. Clone the repository 25 | 26 | ```bash 27 | git clone https://github.com/oslabs-beta/cache-app.git 28 | ``` 29 | 30 | 2. Install NPM dependencies 31 | 32 | ```bash 33 | npm install 34 | ``` 35 | 36 | 3. Create a .env file with MongoDB connection URI: 37 | 38 | ```bash 39 | MONGO_URI="your_mongodb_connection_string" 40 | ``` 41 | 42 | Sessions and user accounts are stored in a MongoDB database. 43 | 44 | Credentials for accessing the Redis database to be monitored are collected from the user through rediSphere's account creation and connection flow. 45 | 46 | A note on security: rediSphere **HIGHLY** recommends creating a specific Redis user account restricted to read-access only for your database. The ONLY permissions necessary for the app to function is access to the INFO command, but if you supply credentials with more privileges, there's always the chance that bad actors could use the monitoring service to gain access to your cache data. Be smart, and take the extra couple minutes to create a limited-permissions account to use specifically for monitoring. 47 | 48 | ## Dashboard & Features 49 | 50 | To start monitoring your Redis database, create a user account on the sign-in page, and provide your Redis credentials to give rediSphere access to your Redis instance. 51 | 52 | The main rediSphere dashboard displays the enabled performance charts in customizable widget boxes: 53 | 54 | ![Dashboard demo animation showing customizable widgets](client/assets/demo2.gif) 55 | 56 | By clicking the plus button, you can add widgets for varying metrics. 57 | 58 | Arrange multiple widgets to tailor your view and get just the metrics you want to see. Remove any widgets later as needed. Your dashboard setup will be saved alongside your user credentials for the next time you log in. 59 | 60 | **Available Widget Metrics** 61 | 62 | - **Cache Hit/Miss Ratio** - See cache hit and miss rates over time. A low hit % indicates suboptimal caching performance. 63 | - **Average Latency** - Track average get request response times and total queries. Spikes may indicate overloaded servers. Note that this specifically displays only server latency, not network latency, to avoid any confounding data arising from a bad connection. 64 | - **Evictions & Expirations** - Monitor eviction and expiration counts over time. Rising trends can pinpoint undersized cache capacity. 65 | - **Memory Usage** - View current memory used and peak memory consumed as percentages. Compare to total cache capacity. 66 | 67 | The dashboard auto-refreshes all widget charts every second with the latest performance data pulled from your connected Redis database. 68 | 69 | ## Data Fetching 70 | 71 | rediSphere uses the Redis INFO command to retrieve current statistics including cache hit ratios, latency, memory usage etc. 72 | 73 | The backend server polls the Redis INFO API every 1 second to fetch the latest performance data. It parses the returned string statistics into JSON structures and passes them to the front-end on demand. 74 | 75 | The React front end dashboard subscribes to backend API endpoints serving this Redis data. The components re-request updated data every second to populate the visible charts and graphs. 76 | 77 | This allows all widget visualizations on user dashboards to auto-refresh with realtime analytics reflecting the current state of their Redis instance. If any connectivity issues disrupt the data stream, widgets will show static displays until connection is regained. 78 | 79 | By default, metrics are shown for a 2 minute trailing time window. 80 | 81 | ## Roadmap 82 | 83 | The current rediSphere MVP focuses on realtime monitoring of Redis cache performance. 84 | 85 | Future roadmap plans include: 86 | 87 | - Persistent monitoring w/historical data support 88 | - Adding support for additional options for providing Redis credentials: 89 | - X509 Certificate based authentication 90 | - API-key authorization 91 | - Incorporating Redis Slow Log analytics into latency metrics 92 | - Developing notification alerts for thresholds 93 | - Additional customization for widgets, including: 94 | - Custom time period 95 | - Dynamic zoom/enlargement 96 | - Export functionality for logged data for analysis on other platforms 97 | 98 | Community feature requests and contributions are encouraged and welcomed to expand rediSphere capabilities! 99 | 100 | ## Contributing 101 | 102 | Contributions to enhance rediSphere are welcomed! 103 | 104 | To contribute: 105 | 106 | 1. Fork the repository 107 | 2. Create your feature branch 108 | 109 | ``` 110 | git checkout -b new-feature 111 | ``` 112 | 113 | 3. Commit changes with clear commit messages 114 | 4. Push to your fork 115 | 5. Open a Pull Request against `development` branch including details of changes 116 | 117 | We ask that, before submitting any significant code changes: 118 | 119 | - You open an Issue to discuss proposals 120 | - Ensure PRs only tackle one feature/bugfix each 121 | - Write tests covering any new functionality 122 | - Maintain existing coding style 123 | - Update documentation accordingly 124 | 125 | Some ways to help: 126 | 127 | - Implement additional widget styling and customizations 128 | - Add ability to persist historical metric data 129 | - Improve general UI/UX 130 | - Expand test coverage 131 | 132 | ... And anything else you have in mind! Let us know if you have any other ideas on how to enhance rediSphere! 133 | -------------------------------------------------------------------------------- /__tests__/backend/routeTesting.js: -------------------------------------------------------------------------------- 1 | const request = require('supertest'); 2 | const server = `http://localhost:3000`; 3 | 4 | describe('testing api endpoint', function () { 5 | test('get:/api/memory', function () { 6 | request(server) 7 | .get('/api/memory') 8 | .set('Accept', 'application/json') 9 | .expect('Content-Type', /json/) 10 | .expect(200); 11 | }, 10000); 12 | 13 | test('get:/api/cacheHitsRatio', function () { 14 | request(server) 15 | .get('/api/cacheHitsRatio') 16 | .set('Accept', 'application/json') 17 | .expect('Content-Type', /json/) 18 | .expect(200); 19 | }, 10000); 20 | 21 | test('get:/api/evictedExpired', function () { 22 | request(server) 23 | .get('/api/evictedExpired') 24 | .set('Accept', 'application/json') 25 | .expect('Content-Type', /json/) 26 | .expect(200); 27 | }, 10000); 28 | 29 | test('get:/api/latency', function () { 30 | request(server) 31 | .get('/api/latency') 32 | .set('Accept', 'application/json') 33 | .expect('Content-Type', /json/) 34 | .expect(200); 35 | }, 10000); 36 | }); 37 | -------------------------------------------------------------------------------- /__tests__/client/homepage.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, screen } from '@testing-library/react'; 3 | import '@testing-library/jest-dom'; 4 | 5 | describe('homepage react component test', () => { 6 | test('Home page renders a header with the text contact', () => { 7 | render(); 8 | const heading = screen.getByRole('header'); 9 | expect(heading).toHaveTextContent('contact'); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /__tests__/client/signup-page.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, screen } from '@testing-library/react'; 3 | import '@testing-library/jest-dom'; 4 | 5 | // component to test 6 | import { BrowserRouter } from 'react-router-dom'; 7 | import SignupPage from '../../client/components/SignupPage'; 8 | 9 | describe('signup page react component test', () => { 10 | test('Form input elements should have labels, and there should be a button to submit our form', () => { 11 | // arrange 12 | render( 13 | 14 | 15 | , 16 | ); 17 | 18 | // act 19 | const userNameLabel = screen.getByLabelText('Username'); 20 | const passwordLabel = screen.getByLabelText('Password'); 21 | const passwordConfirmationLabel = screen.getByLabelText('Confirm password'); 22 | const formSubmitButton = screen.getByText("Let's go!"); 23 | 24 | // assert 25 | expect(userNameLabel).toBeVisible(); 26 | expect(passwordLabel).toBeVisible(); 27 | expect(passwordConfirmationLabel).toBeVisible(); 28 | expect(formSubmitButton).toBeInTheDocument(); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /babel.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env", "@babel/preset-react"] 3 | } 4 | -------------------------------------------------------------------------------- /client/App.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Route, Routes } from 'react-router-dom'; 3 | import './styles.css'; 4 | 5 | import HomePage from './components/HomePage.jsx'; 6 | import LoginPage from './components/LoginPage.jsx'; 7 | import SignupPage from './components/SignupPage.jsx'; 8 | import DashBoard from './components/DashBoard.jsx'; 9 | import ConnectRedisPage from './components/ConnectRedis.jsx'; 10 | import ContactPage from './components/Contact.jsx'; 11 | 12 | const App = () => { 13 | return ( 14 | 15 | } /> 16 | } /> 17 | } /> 18 | } /> 19 | } /> 20 | } /> 21 | 22 | ); 23 | }; 24 | export default App; 25 | -------------------------------------------------------------------------------- /client/assets/AvailableGraphs.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import HitMissLinePlot from '../components/HitMissLinePlot.jsx'; 3 | // import FreeMemory from '../components/FreeMemory.jsx'; 4 | import EvictedExpired from '../components/EvictedExpiredLinePlot.jsx'; 5 | import LatencyChart from '../components/LatencyChart.jsx'; 6 | import MemoryChart from '../components/MemoryChart.jsx'; 7 | 8 | const nameToComponent = { 9 | hitmiss: { 10 | large: , 11 | medium: , 12 | }, 13 | memory: { 14 | large: , 15 | medium: , 16 | small: , 17 | }, 18 | evictedExpired: { 19 | large: , 20 | medium: , 21 | }, 22 | latency: { 23 | large: , 24 | medium: , 25 | }, 26 | }; 27 | 28 | export default nameToComponent; 29 | -------------------------------------------------------------------------------- /client/assets/Github-Logo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/rediSphere/4c41e8ee3cf229c100c2fd30e14b6f466d8471d6/client/assets/Github-Logo.jpg -------------------------------------------------------------------------------- /client/assets/black-background-redisphere-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/rediSphere/4c41e8ee3cf229c100c2fd30e14b6f466d8471d6/client/assets/black-background-redisphere-logo.png -------------------------------------------------------------------------------- /client/assets/demo2.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/rediSphere/4c41e8ee3cf229c100c2fd30e14b6f466d8471d6/client/assets/demo2.gif -------------------------------------------------------------------------------- /client/assets/large-cache-hit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/rediSphere/4c41e8ee3cf229c100c2fd30e14b6f466d8471d6/client/assets/large-cache-hit.png -------------------------------------------------------------------------------- /client/assets/large-evex.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/rediSphere/4c41e8ee3cf229c100c2fd30e14b6f466d8471d6/client/assets/large-evex.png -------------------------------------------------------------------------------- /client/assets/large-latency.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/rediSphere/4c41e8ee3cf229c100c2fd30e14b6f466d8471d6/client/assets/large-latency.png -------------------------------------------------------------------------------- /client/assets/linked-in.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/rediSphere/4c41e8ee3cf229c100c2fd30e14b6f466d8471d6/client/assets/linked-in.png -------------------------------------------------------------------------------- /client/assets/med-cache-hit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/rediSphere/4c41e8ee3cf229c100c2fd30e14b6f466d8471d6/client/assets/med-cache-hit.png -------------------------------------------------------------------------------- /client/assets/med-evex.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/rediSphere/4c41e8ee3cf229c100c2fd30e14b6f466d8471d6/client/assets/med-evex.png -------------------------------------------------------------------------------- /client/assets/med-latency.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/rediSphere/4c41e8ee3cf229c100c2fd30e14b6f466d8471d6/client/assets/med-latency.png -------------------------------------------------------------------------------- /client/assets/memory-usage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/rediSphere/4c41e8ee3cf229c100c2fd30e14b6f466d8471d6/client/assets/memory-usage.png -------------------------------------------------------------------------------- /client/assets/redisphere_banner (1).png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/rediSphere/4c41e8ee3cf229c100c2fd30e14b6f466d8471d6/client/assets/redisphere_banner (1).png -------------------------------------------------------------------------------- /client/assets/white-background-redisphere-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/rediSphere/4c41e8ee3cf229c100c2fd30e14b6f466d8471d6/client/assets/white-background-redisphere-logo.png -------------------------------------------------------------------------------- /client/components/AddWidgetModal.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useDispatch } from 'react-redux'; 3 | import { SET_WIDGETS } from '../dashboardReducer.js'; 4 | 5 | import nameToComponent from '../assets/AvailableGraphs.jsx'; 6 | 7 | import largeCacheIcon from '../assets/large-cache-hit.png'; 8 | import medCacheIcon from '../assets/med-cache-hit.png'; 9 | import largeLatencyIcon from '../assets/large-latency.png'; 10 | import medLatencyIcon from '../assets/med-latency.png'; 11 | import largeEvExIcon from '../assets/large-evex.png'; 12 | import medEvExIcon from '../assets/med-evex.png'; 13 | import memoryIcon from '../assets/memory-usage.png'; 14 | 15 | const buttonIcons = { 16 | 'large hitmiss': largeCacheIcon, 17 | 'medium hitmiss': medCacheIcon, 18 | 'large memory': memoryIcon, 19 | 'medium memory': memoryIcon, 20 | 'small memory': memoryIcon, 21 | 'large evictedExpired': largeEvExIcon, 22 | 'medium evictedExpired': medEvExIcon, 23 | 'large latency': largeLatencyIcon, 24 | 'medium latency': medLatencyIcon, 25 | }; 26 | 27 | const nameMap = { 28 | hitmiss: 'Cache Hit/Miss Ratio', 29 | memory: 'Memory Usage', 30 | evictedExpired: 'Evicted/Expired', 31 | latency: 'Av. Response Time', 32 | }; 33 | 34 | const Modal = () => { 35 | const dispatch = useDispatch(); 36 | 37 | //map widget list to buttons 38 | const buttonList = []; 39 | for (const chart in nameToComponent) { 40 | for (const size in nameToComponent[chart]) { 41 | buttonList.push( 42 | , 60 | ); 61 | } 62 | } 63 | 64 | return ( 65 |
66 |
67 |
68 | 77 |

78 | {'Add a widget:'} 79 |

80 |
{buttonList}
81 |
82 |
83 | ); 84 | }; 85 | 86 | export default Modal; 87 | -------------------------------------------------------------------------------- /client/components/ConnectRedis.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useNavigate } from 'react-router-dom'; 3 | import Footer from './Footer'; 4 | 5 | const ConnectRedisPage = () => { 6 | const navigate = useNavigate(); 7 | const handleClick = async () => { 8 | try { 9 | const data = {}; 10 | data.host = document.getElementById('host').value; 11 | data.port = document.getElementById('port').value; 12 | data.redisPassword = document.getElementById('redis-password').value; 13 | const response = await fetch('/users/connect-redis', { 14 | method: 'PUT', 15 | headers: { 'Content-Type': 'application/json' }, 16 | body: JSON.stringify(data), 17 | }); 18 | const result = await response.json(); 19 | 20 | if (result === 'ok') return navigate('/dashboard'); 21 | } catch (err) { 22 | return alert('Something went wrong. Please try again.'); 23 | } 24 | }; 25 | 26 | return ( 27 |
28 |

{'Connect to your Redis instance'}

29 |
30 |

{'Please enter your Redis instance information.'}

31 |
32 |
33 | 38 |
39 |
40 | 45 |
46 |
47 | 52 |
53 | 54 |
55 |
56 | Skip this step for now 57 |
58 |
59 | ); 60 | }; 61 | 62 | export default ConnectRedisPage; 63 | -------------------------------------------------------------------------------- /client/components/Contact.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Header from './Header'; 3 | import Footer from './Footer'; 4 | import github from '../assets/Github-Logo.jpg'; 5 | import linkedin from '../assets/linked-in.png'; 6 | 7 | const ContactPage = () => { 8 | return ( 9 |
10 |
11 |
12 |
13 | Jason 17 |

Jason Wong

18 |

Software Engineer

19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 |
28 |
29 | Michelle 33 |

Michelle Xie

34 |

Software Engineer

35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 |
44 |
45 | Jake 49 |

Jake Kunkel

50 |

Software Engineer

51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 |
60 |
61 | Ryan 65 |

Ryan Stankowitz

66 |

Software Engineer

67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 |
76 |
77 | Eduardo 81 |

Eduardo Uribe

82 |

Software Engineer

83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 |
92 |
93 |
94 |
95 | ); 96 | }; 97 | 98 | export default ContactPage; 99 | -------------------------------------------------------------------------------- /client/components/DashBoard.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import { useDispatch, useSelector } from 'react-redux'; 3 | import { SET_WIDGETS } from '../dashboardReducer.js'; 4 | import { useNavigate } from 'react-router-dom'; 5 | 6 | import Header from './Header.jsx'; 7 | import Footer from './Footer.jsx'; 8 | import AddWidgetModal from './AddWidgetModal.jsx'; 9 | import nameToComponent from '../assets/AvailableGraphs.jsx'; 10 | 11 | const DashBoard = () => { 12 | const widgets = useSelector((store) => store.dashboard.widgetArray); 13 | const dispatch = useDispatch(); 14 | const navigate = useNavigate(); 15 | 16 | //fetch user's widgets from user database on load 17 | const fetchWidgets = async () => { 18 | try { 19 | const res = await fetch('/users/widgets'); 20 | const widgetArray = await res.json(); 21 | dispatch(SET_WIDGETS(widgetArray)); 22 | } catch (error) { 23 | console.log(error); 24 | } 25 | }; 26 | useEffect(() => { 27 | fetchWidgets(); 28 | }, []); 29 | 30 | //delete a widget 31 | const deleteWidget = async (index) => { 32 | try { 33 | const deleted = await fetch('/users/delete-widget/' + index, { 34 | method: 'DELETE', 35 | headers: { 'Content-Type': 'application/json' }, 36 | }); 37 | const widgetArray = await deleted.json(); 38 | dispatch(SET_WIDGETS(widgetArray)); 39 | } catch (error) { 40 | console.log(error); 41 | } 42 | }; 43 | 44 | //map widget list (ex [['large', 'hitmiss']]) to a component 45 | const widgetDisplay = []; 46 | widgets.forEach((widget, index) => { 47 | widgetDisplay.push( 48 |
49 | 56 | {nameToComponent[widget[1]][widget[0]]} 57 |
, 58 | ); 59 | }); 60 | 61 | return ( 62 |
63 |
64 |
{widgetDisplay}
65 |
66 | 69 | 78 |
79 | 80 | 81 |
82 |
83 | ); 84 | }; 85 | 86 | export default DashBoard; 87 | -------------------------------------------------------------------------------- /client/components/EvictedExpiredLinePlot.jsx: -------------------------------------------------------------------------------- 1 | import * as d3 from 'd3'; 2 | import React, { useRef, useEffect, useState } from 'react'; 3 | 4 | const LinePlot = ({ 5 | width = 550, 6 | height = 400, 7 | marginTop = 20, 8 | marginRight = 20, 9 | marginBottom = 20, 10 | marginLeft = 20, 11 | }) => { 12 | const [data, setData] = useState([]); 13 | 14 | //get evictedExpired data 15 | const fetchData = async () => { 16 | try { 17 | const res = await fetch('/api/evictedExpired'); 18 | const newData = await res.json(); 19 | setData([...data, newData]); 20 | } catch (error) { 21 | console.log(error); 22 | } 23 | }; 24 | 25 | // every data is updated, set timeout is called again, but only *after* data has completed 26 | useEffect(() => { 27 | setTimeout(() => { 28 | fetchData(); 29 | }, 1000); 30 | }, [data]); 31 | 32 | //take timestamp and overwrite with JS time instaed of server's native epoch time which is in microseconds 33 | //divide by 1000 to go from micro seconds to milli seconds 34 | let formattedData = data.flatMap((d) => { 35 | //after mapping, it "flattens" every element--> empty arrays just get removed, effectively filtering 36 | if (d.evicted === null || d.evicted === undefined) { 37 | // Filter out data point if null, or undefined 38 | console.log('datapoint filtered (evicted): ', d); 39 | return []; 40 | } 41 | if (d.expired === null || d.expired === undefined) { 42 | console.log('datapoint filtered (expired)', d); 43 | return []; 44 | } 45 | return { 46 | ...d, 47 | timestamp: new Date(d.timestamp / 1000), 48 | }; 49 | }); 50 | 51 | //setting to 2 minutes 52 | const dataTimeRange = 2; 53 | 54 | formattedData = formattedData.filter((d) => { 55 | return d.timestamp > Date.now() - 60 * 1000 * dataTimeRange; 56 | }); 57 | 58 | const gx = useRef(); 59 | const gy = useRef(); 60 | 61 | //create scales for x and y axes 62 | // Domain --> abstract index values of the data 63 | // Range --> visible pixel range that those indices will map to 64 | const x = d3 65 | .scaleUtc() 66 | .domain([Date.now() - 60 * 1000 * dataTimeRange, Date.now()]) 67 | .range([marginLeft, width - marginRight]); 68 | 69 | //set the top of the y-axis to the max of 70 | //either evicted or expired total, or to just 1 if both counts are 0 71 | const yMax = 72 | Math.max( 73 | d3.max(formattedData, (d) => d.expired), 74 | d3.max(formattedData, (d) => d.evicted), 75 | ) * 1.25; //and scale to 1.25 so that the max val isn't the top of the y-axis 76 | 77 | const y = d3 78 | .scaleLinear() 79 | .domain([0, yMax || 1]) 80 | .range([height - marginBottom, marginTop]); 81 | 82 | //map formattedData to expiredData w/generic timestamp value keys 83 | const expiredData = formattedData.map((d) => ({ 84 | timestamp: d.timestamp, 85 | val: d.expired, 86 | })); 87 | 88 | //map formattedData to expiredData w/generic timestamp value keys 89 | const evictedData = formattedData.map((d) => ({ 90 | timestamp: d.timestamp, 91 | val: d.evicted, 92 | })); 93 | 94 | //TODO 95 | //refactor for modularity 96 | //if we passed in *all* the data that could be used, and a timestamp, 97 | //a generic mapping function could work to set any key on the data object in the array to the "value" 98 | /** data = 99 | * [ 100 | * { 101 | * cacheHits : number, 102 | * cacheMisses : number, 103 | * evictions : number, 104 | * expirations : number, 105 | * memoryMax: number, 106 | * memoryCurrent: number, 107 | * totalKeys: number 108 | * timestamp : number 109 | * }, 110 | * {..}, {..}, ... 111 | * ] 112 | * */ 113 | // //and then use: 114 | // function genericDataMap(dataArray, xKey, yKey) { 115 | // return dataArray.map((d) => ({ 116 | // timestamp: d[xKey], 117 | // val: d[yKey], 118 | // })); 119 | // } 120 | 121 | // const evictedData = genericDataMap(formattedData, timestamp, evicted); 122 | // const expiredData = genericDataMap(formattedData, timestamp, expired); 123 | 124 | const line = d3 125 | .line() 126 | .x((d) => x(d.timestamp)) 127 | .y((d) => y(d.val)); 128 | 129 | useEffect(() => void d3.select(gx.current).call(d3.axisBottom(x)), [gx, x]); 130 | useEffect(() => void d3.select(gy.current).call(d3.axisLeft(y)), [gy, y]); 131 | 132 | if (data.length) { 133 | return ( 134 | 135 | 136 | 140 | {'No. Eviction/Expiration'} 141 | 142 | 143 | 147 | {'UTC Time'} 148 | 149 | 155 | 161 | {'no. expired'} 162 | 163 | 169 | 175 | {'no. evicted'} 176 | 177 | 178 | 179 | {formattedData.map((d, i) => ( 180 | 181 | ))} 182 | 183 | 184 | 185 | 186 | {formattedData.map((d, i) => ( 187 | 188 | ))} 189 | 190 | 191 | ); 192 | } else { 193 | return

Loading...

; 194 | } 195 | }; 196 | 197 | export default LinePlot; 198 | -------------------------------------------------------------------------------- /client/components/Footer.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import logo from '../assets/Github-Logo.jpg'; 3 | 4 | const Footer = () => { 5 | return ( 6 |
7 | 8 | 9 | 10 | {'rediSphere Open Source ©2024'} 11 |
12 | ); 13 | }; 14 | 15 | export default Footer; 16 | -------------------------------------------------------------------------------- /client/components/FreeMemory.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const FreeMemory = () => { 4 | return ( 5 | <> 6 |

Free memory

7 |

29MB

8 | 9 | ); 10 | }; 11 | 12 | export default FreeMemory; 13 | -------------------------------------------------------------------------------- /client/components/Header.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import { useNavigate } from 'react-router-dom'; 3 | import { useDispatch } from 'react-redux'; 4 | import blackLogo from '../assets/black-background-redisphere-logo.png'; 5 | 6 | import { LOGIN_USER, LOGOUT_USER } from '../dashboardReducer.js'; 7 | 8 | const Header = () => { 9 | const [buttons, setButtons] = useState([]); 10 | const dispatch = useDispatch(); 11 | 12 | //delete active session in db and delete ssid cookie 13 | //navigate back to homepage 14 | const navigate = useNavigate(); 15 | const logout = async () => { 16 | const response = await fetch('/users/signout', { 17 | method: 'DELETE', 18 | }); 19 | const result = await response.json(); 20 | if (result === true) { 21 | dispatch(LOGOUT_USER()); 22 | return navigate('/'); 23 | } 24 | return; 25 | }; 26 | 27 | const fetchSession = async () => { 28 | try { 29 | const response = await fetch('/users/session'); 30 | const activeSession = await response.json(); 31 | 32 | if (activeSession.session === false) { 33 | setButtons([ 34 |
35 | 36 | 37 |
, 38 | ]); 39 | dispatch(LOGOUT_USER()); 40 | } else if (activeSession.session === true) { 41 | setButtons([ 42 |
43 | 44 | 50 |
, 51 | ]); 52 | dispatch(LOGIN_USER()); 53 | } 54 | } catch (err) { 55 | return console.log(err); 56 | } 57 | }; 58 | 59 | //empty dependency array - only triggers when header component mounts 60 | useEffect(() => { 61 | fetchSession(); 62 | }, []); 63 | 64 | return ( 65 |
66 |
67 | 68 | 69 | 70 |
71 |
72 | 73 | {buttons} 74 |
75 |
76 | ); 77 | }; 78 | 79 | export default Header; 80 | -------------------------------------------------------------------------------- /client/components/HitMissLinePlot.jsx: -------------------------------------------------------------------------------- 1 | import * as d3 from 'd3'; 2 | import React, { useRef, useEffect, useState } from 'react'; 3 | 4 | const LinePlot = ({ 5 | width = 550, 6 | height = 400, 7 | marginTop = 20, 8 | marginRight = 20, 9 | marginBottom = 20, 10 | marginLeft = 20, 11 | }) => { 12 | const [data, setData] = useState([]); 13 | 14 | //get cache hits ratio 15 | const fetchData = async () => { 16 | try { 17 | const res = await fetch('/api/cacheHitsRatio'); 18 | const newData = await res.json(); 19 | setData([...data, newData]); 20 | } catch (error) { 21 | console.log(error); 22 | } 23 | }; 24 | 25 | // every time cache hit data is updated, set timeout is called again 26 | useEffect(() => { 27 | setTimeout(() => { 28 | fetchData(); 29 | }, 1000); 30 | }, [data]); 31 | 32 | //take timestamp and overwrite with JS time instaed of server's native epoch time which is in microseconds 33 | //divide by 1000 to go from micro seconds to milli seconds 34 | let formattedData = data.map((d) => { 35 | return { 36 | ...d, 37 | timestamp: new Date(d.timestamp / 1000), 38 | }; 39 | }); 40 | 41 | //setting to 2 minutes 42 | const dataTimeRange = 2; 43 | 44 | formattedData = formattedData.filter((d) => { 45 | return d.timestamp > Date.now() - 60 * 1000 * dataTimeRange; 46 | }); 47 | 48 | const gx = useRef(); 49 | const gy = useRef(); 50 | 51 | //create scales for x and y axes 52 | // Domain --> abstract index values of the data 53 | // Range --> visible pixel range that those indices will map to 54 | const x = d3 55 | .scaleUtc() 56 | .domain([Date.now() - 60 * 1000 * dataTimeRange, Date.now()]) 57 | .range([marginLeft, width - marginRight]); 58 | const y = d3.scaleLinear([0, 1], [height - marginBottom, marginTop]); 59 | 60 | const line = d3 61 | .line() 62 | .x((d) => x(d.timestamp)) 63 | .y((d) => y(d.cacheHitRatio)); 64 | 65 | // //temp component to add time as a tooltip on the circles 66 | // function Tooltip({ time }) { 67 | // // Convert UTC time to local browser time 68 | // const localeTime = new Date(time).toLocaleString(); 69 | // console.log('localeTime', localeTime); 70 | 71 | // return ( 72 | //
83 | // {localeTime} 84 | //
85 | // ); 86 | // } 87 | 88 | useEffect(() => void d3.select(gx.current).call(d3.axisBottom(x)), [gx, x]); 89 | useEffect(() => void d3.select(gy.current).call(d3.axisLeft(y)), [gy, y]); 90 | 91 | if (data.length) { 92 | //invert cachHitRatio for red miss ratio line 93 | const getMissRatio = () => { 94 | let missArray = []; 95 | formattedData.forEach((el) => { 96 | const newEl = { ...el }; 97 | newEl.cacheHitRatio = 1 - el.cacheHitRatio; 98 | missArray.push(newEl); 99 | }); 100 | return missArray; 101 | }; 102 | const misses = getMissRatio(); 103 | // console.log(misses); 104 | 105 | return ( 106 | 107 | 108 | 112 | {'Cache Hit Ratio'} 113 | 114 | 115 | 119 | {'UTC Time'} 120 | 121 | 127 | 133 | {'hits'} 134 | 135 | 141 | 147 | {'misses'} 148 | 149 | 150 | 151 | {formattedData.map((d, i) => ( 152 | 153 | ))} 154 | 155 | 156 | 157 | {misses.map((d, i) => ( 158 | 159 | ))} 160 | 161 | 162 | ); 163 | } else { 164 | return

Loading...

; 165 | } 166 | }; 167 | 168 | export default LinePlot; 169 | -------------------------------------------------------------------------------- /client/components/HomePage.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useNavigate } from 'react-router-dom'; 3 | import { useSelector } from 'react-redux'; 4 | 5 | import Header from './Header'; 6 | import Footer from './Footer'; 7 | 8 | import whiteLogo from '../assets/white-background-redisphere-logo.png'; 9 | import demoGif from '../assets/demo2.gif'; 10 | 11 | const HomePage = () => { 12 | const navigate = useNavigate(); 13 | const userLoginStatus = useSelector((store) => store.dashboard.loggedIn); 14 | let bigButton; 15 | if (!userLoginStatus) { 16 | bigButton = ; 17 | } else { 18 | bigButton = ; 19 | } 20 | return ( 21 |
22 |
23 |
24 |
25 |
26 | 27 |

28 | Monitor your Redis performance metrics with rediSphere. 29 |

30 | {bigButton} 31 |
32 | 33 |
34 | 35 |
36 |
37 |

Real-time Monitoring

38 |

39 | Easily diagnose and resolve performance issues with charts that update every second. 40 | Visualize your Redis memory usage, hit and miss ratios, eviction and expiration 41 | statistics, and average latency. 42 |

43 |
44 |
45 |

Customizable Dashboard

46 |

47 | rediSphere offers live Redis performance visualization in an intuitive, user-friendly 48 | dashboard. Mix and match which widgets and charts matter most to you. 49 |

50 |
51 |
52 |

Quick and Easy Setup

53 |

54 | Simply make an account and enter your Redis host, port, and password to instantly 55 | connect and start viewing your Redis metrics! 56 |

57 |
58 |
59 |
60 |
61 |
62 | ); 63 | }; 64 | 65 | export default HomePage; 66 | -------------------------------------------------------------------------------- /client/components/LatencyChart.jsx: -------------------------------------------------------------------------------- 1 | import * as d3 from 'd3'; 2 | import React, { useRef, useEffect, useState } from 'react'; 3 | 4 | const Chart = ({ 5 | width = 550, 6 | height = 400, 7 | marginTop = 20, 8 | marginRight = 20, 9 | marginBottom = 20, 10 | marginLeft = 20, 11 | }) => { 12 | const [data, setData] = useState([]); 13 | 14 | //get latency 15 | const fetchData = async () => { 16 | try { 17 | const res = await fetch('/api/latency'); 18 | const newData = await res.json(); 19 | setData([...data, newData]); 20 | } catch (error) { 21 | console.log(error); 22 | } 23 | }; 24 | 25 | //everytime data is updated, set timeout is called again 26 | useEffect(() => { 27 | setTimeout(() => { 28 | fetchData(); 29 | }, 1000); 30 | }, [data]); 31 | 32 | //take timestamp and overwrite with JS time instaed of server's native epoch time which is in microseconds 33 | //divide by 1000 to go from micro seconds to milli seconds 34 | let formattedData = data.map((d) => { 35 | return { 36 | ...d, 37 | timestamp: new Date(d.timestamp / 1000), 38 | }; 39 | }); 40 | 41 | //setting to 2 minutes 42 | const dataTimeRange = 2; 43 | 44 | formattedData = formattedData.filter((d) => { 45 | return d.timestamp > Date.now() - 60 * 1000 * dataTimeRange; 46 | }); 47 | 48 | const gx = useRef(); 49 | const gy = useRef(); 50 | const gyl = useRef(); 51 | //create scales for x and y axes 52 | // Domain --> abstract index values of the data 53 | // Range --> visible pixel range that those indices will map to 54 | const x = d3 55 | .scaleUtc() 56 | .domain([Date.now() - 60 * 1000 * 2, Date.now()]) 57 | .range([marginLeft, width - marginRight]); 58 | const y = d3 59 | .scaleLinear() 60 | .domain([0, d3.max(formattedData, (d) => d.avgGetCacheTime || 0) + 1]) 61 | .range([height - marginBottom, marginTop]); 62 | const yLine = d3 63 | .scaleLinear() 64 | .domain(d3.extent(formattedData, (d) => d.totalGet || 0)) 65 | .range([height - marginBottom, marginTop]); 66 | 67 | const line = d3 68 | .line() 69 | .x((d) => x(d.timestamp)) 70 | .y((d) => yLine(d.totalGet || 0)); 71 | 72 | useEffect(() => void d3.select(gx.current).call(d3.axisBottom(x)), [gx, x]); 73 | useEffect(() => void d3.select(gy.current).call(d3.axisLeft(y)), [gy, y]); 74 | useEffect(() => void d3.select(gyl.current).call(d3.axisRight(yLine)), [gyl, yLine]); 75 | 76 | if (data.length) { 77 | return ( 78 | 79 | 83 | {'Avg. Response Time (μs)'} 84 | 85 | 89 | {'UTC Time'} 90 | 91 | 95 | {'Total Get Requests'} 96 | 97 | 98 | 99 | 100 | 101 | 102 | {formattedData.map((d, i) => ( 103 | 104 | ))} 105 | 106 | 107 | {formattedData.map((d, i) => ( 108 | 117 | ))} 118 | 119 | 120 | ); 121 | } else { 122 | return

Loading...

; 123 | } 124 | }; 125 | 126 | export default Chart; 127 | -------------------------------------------------------------------------------- /client/components/LoginPage.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useNavigate } from 'react-router-dom'; 3 | import { LOGIN_USER } from '../dashboardReducer.js'; 4 | import { useDispatch } from 'react-redux'; 5 | 6 | import Footer from './Footer.jsx'; 7 | 8 | const LoginPage = () => { 9 | const navigate = useNavigate(); 10 | const dispatch = useDispatch(); 11 | const handleClick = async () => { 12 | const data = {}; 13 | data.username = document.getElementById('username').value; 14 | data.password = document.getElementById('password').value; 15 | 16 | const response = await fetch('/users/signin', { 17 | method: 'POST', 18 | headers: { 'Content-Type': 'application/json' }, 19 | body: JSON.stringify(data), 20 | }); 21 | const result = await response.json(); 22 | 23 | if (result === 'not ok') return alert('login failed. try again.'); 24 | if (result === 'ok') { 25 | dispatch(LOGIN_USER()); 26 | return navigate('/dashboard'); 27 | } 28 | }; 29 | 30 | return ( 31 |
32 |

{'Sign in view your dashboard'}

33 | 34 |
35 |
36 | 41 |
42 |
43 | 48 |
49 | 50 | 53 |
54 | 55 |
56 | {'New to rediSphere? '} 57 | Create an account 58 |
59 | 60 |
61 |
62 | ); 63 | }; 64 | 65 | export default LoginPage; 66 | -------------------------------------------------------------------------------- /client/components/MemoryChart.jsx: -------------------------------------------------------------------------------- 1 | import React, { useRef, useEffect, useState } from 'react'; 2 | 3 | const GaugeChart = ({ radius = 50 }) => { 4 | const [data, setData] = useState({}); 5 | 6 | //get evicted/expired keys 7 | const fetchData = async () => { 8 | try { 9 | const res = await fetch('/api/memory'); 10 | const newData = await res.json(); 11 | return newData; 12 | } catch (error) { 13 | console.log(error); 14 | } 15 | }; 16 | 17 | //everytime data is updated, set timeout is called again 18 | useEffect(() => { 19 | setTimeout(() => { 20 | fetchData().then((data) => { 21 | setData(() => data); 22 | }); 23 | }, 1000); 24 | }, [data]); 25 | 26 | if (!Object.keys(data).length) { 27 | return

Loading...

; 28 | } else { 29 | const percentageUsed = (data.usedMemory / 30) * 100; 30 | const percentagePeakUsed = (data.peakUsedMemory / 30) * 100; 31 | return ( 32 | 33 | 34 | 35 | 40 | 41 | 42 | 43 | 52 | 63 | 74 | 75 | 83 | {`Current: ${percentageUsed.toFixed(2)}%`} 84 | 85 | 93 | {`Peak: ${percentagePeakUsed.toFixed(2)}% \n`} 94 | 95 | 104 | {'Memory Usage'} 105 | 106 | 107 | ); 108 | } 109 | }; 110 | 111 | export default GaugeChart; 112 | -------------------------------------------------------------------------------- /client/components/SignupPage.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useNavigate } from 'react-router-dom'; 3 | import { LOGIN_USER } from '../dashboardReducer.js'; 4 | import { useDispatch } from 'react-redux'; 5 | 6 | import Footer from './Footer'; 7 | 8 | const SignupPage = () => { 9 | const navigate = useNavigate(); 10 | const dispatch = useDispatch(); 11 | const handleClick = async () => { 12 | if (document.getElementById('password-1').value !== document.getElementById('password-2').value) 13 | return alert('passwords do not match. please try again.'); 14 | 15 | const data = {}; 16 | data.username = document.getElementById('username').value; 17 | data.password = document.getElementById('password-1').value; 18 | 19 | const response = await fetch('/users/create', { 20 | method: 'POST', 21 | headers: { 'Content-Type': 'application/json' }, 22 | body: JSON.stringify(data), 23 | }); 24 | const result = await response.json(); 25 | 26 | if (result === 'ok') { 27 | dispatch(LOGIN_USER()); 28 | return navigate('/connectredis'); 29 | } 30 | 31 | if (result === 'username taken') 32 | return alert('That username has been taken. Please choose another.'); 33 | }; 34 | 35 | return ( 36 |
37 |

{'Sign up for rediSphere'}

38 | 39 |
40 |
41 | 46 |
47 |
48 | 53 |
54 |
55 | 60 |
61 | 64 |
65 | 66 |
67 | {'Already have an account? '} 68 | Log in here 69 |
70 |
71 |
72 | ); 73 | }; 74 | 75 | export default SignupPage; 76 | -------------------------------------------------------------------------------- /client/dashboardReducer.js: -------------------------------------------------------------------------------- 1 | import { createSlice } from '@reduxjs/toolkit'; 2 | 3 | const initialState = { 4 | widgetArray: [], 5 | loggedIn: false, 6 | }; 7 | 8 | const dashboardSlice = createSlice({ 9 | name: 'dashboard', 10 | initialState, 11 | reducers: { 12 | SET_WIDGETS: (state, action) => { 13 | const widgets = action.payload; 14 | const newArray = []; 15 | widgets.forEach((el) => newArray.push(el)); 16 | state.widgetArray = newArray; 17 | }, 18 | LOGIN_USER: (state) => { 19 | state.loggedIn = true; 20 | }, 21 | LOGOUT_USER: (state) => { 22 | state.loggedIn = false; 23 | }, 24 | }, 25 | }); 26 | 27 | export const { LOGIN_USER, LOGOUT_USER, SET_WIDGETS } = dashboardSlice.actions; 28 | export default dashboardSlice.reducer; 29 | -------------------------------------------------------------------------------- /client/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { createRoot } from 'react-dom/client'; 4 | import { Provider } from 'react-redux'; 5 | import { BrowserRouter } from 'react-router-dom'; 6 | import App from './App.jsx'; 7 | import store from './store.js'; 8 | 9 | const root = createRoot(document.getElementById('root')); 10 | root.render( 11 | 12 | 13 | 14 | 15 | , 16 | ); 17 | -------------------------------------------------------------------------------- /client/store.js: -------------------------------------------------------------------------------- 1 | import { configureStore } from '@reduxjs/toolkit'; 2 | import dashboardSlice from './dashboardReducer'; 3 | 4 | const store = configureStore({ 5 | reducer: { 6 | dashboard: dashboardSlice, 7 | }, 8 | }); 9 | 10 | export default store; 11 | -------------------------------------------------------------------------------- /client/styles.css: -------------------------------------------------------------------------------- 1 | body { 2 | display: flex; 3 | flex-direction: column; 4 | align-items: center; 5 | margin: 0; 6 | font-family: monospace; 7 | } 8 | 9 | h2, 10 | h4 { 11 | text-align: center; 12 | } 13 | 14 | button:hover { 15 | cursor: pointer; 16 | } 17 | 18 | .home-page, 19 | .contact-page { 20 | width: 100vw; 21 | display: flex; 22 | flex-direction: column; 23 | align-items: center; 24 | } 25 | 26 | .contact-info-content { 27 | display: flex; 28 | flex-wrap: wrap; 29 | padding: 50px; 30 | justify-content: center; 31 | 32 | div { 33 | display: flex; 34 | flex-direction: column; 35 | align-items: center; 36 | margin: 25px; 37 | } 38 | img { 39 | clip-path: circle(50%); 40 | width: 220px; 41 | } 42 | p { 43 | margin-top: 0; 44 | font-size: large; 45 | } 46 | } 47 | .contact-links { 48 | a { 49 | img { 50 | width: 30px; 51 | } 52 | margin: 8px; 53 | } 54 | img:hover { 55 | cursor: pointer; 56 | transform: scale(1.1); 57 | } 58 | } 59 | 60 | .key-features-container { 61 | display: flex; 62 | justify-content: center; 63 | padding: 0 20px; 64 | } 65 | 66 | .key-feature { 67 | border: solid gainsboro; 68 | border-width: 1px; 69 | border-radius: 12px; 70 | width: 30%; 71 | margin: 15px; 72 | padding: 40px; 73 | box-shadow: 5px 5px 5px rgb(234, 234, 234); 74 | font-size: large; 75 | } 76 | 77 | .intro { 78 | display: flex; 79 | justify-content: space-around; 80 | padding: 40px; 81 | 82 | button { 83 | font-family: inherit; 84 | font-size: large; 85 | background-color: white; 86 | border-radius: 5px; 87 | border: solid black; 88 | height: 50px; 89 | width: 200px; 90 | } 91 | 92 | button:hover { 93 | transform: scale(1.03); 94 | background-color: rgb(228, 228, 228); 95 | } 96 | } 97 | .intro-left { 98 | display: flex; 99 | flex-direction: column; 100 | justify-content: center; 101 | padding: 30px; 102 | } 103 | #demo-gif { 104 | flex: 0 0 auto; 105 | height: 500px; 106 | width: 750px; 107 | } 108 | #intro-logo { 109 | width: 400px; 110 | height: 400px; 111 | margin: -50px; 112 | } 113 | 114 | #sign-in-page, 115 | #sign-up-page, 116 | #redis-connection-page { 117 | margin-top: 20%; 118 | font-size: large; 119 | } 120 | .login-box, 121 | .signup-box, 122 | .redis-connection-box { 123 | width: 300px; 124 | display: flex; 125 | flex-direction: column; 126 | background-color: rgb(246, 246, 246); 127 | padding: 40px; 128 | border-radius: 10px; 129 | margin-bottom: 20px; 130 | font-size: large; 131 | 132 | input, 133 | button { 134 | width: 100%; 135 | padding: 0; 136 | height: 25px; 137 | font-family: inherit; 138 | font-size: inherit; 139 | border: none; 140 | margin-bottom: 10px; 141 | } 142 | 143 | button { 144 | margin-top: 10px; 145 | background-color: black; 146 | color: white; 147 | border-radius: 5px; 148 | } 149 | 150 | button:hover { 151 | transform: scaleY(1.1); 152 | } 153 | } 154 | 155 | header { 156 | background-color: black; 157 | color: white; 158 | display: flex; 159 | justify-content: space-between; 160 | padding: 0 20px; 161 | width: inherit; 162 | 163 | .header-left { 164 | padding-left: 40px; 165 | img { 166 | width: 150px; 167 | height: 150px; 168 | transition: transform 1s; 169 | clip-path: circle(50%); 170 | } 171 | img:hover { 172 | transform: rotate(360deg); 173 | } 174 | } 175 | 176 | .header-right { 177 | display: flex; 178 | align-items: center; 179 | padding-right: 40px; 180 | 181 | button { 182 | font-family: inherit; 183 | margin-left: 15px; 184 | border: none; 185 | background-color: inherit; 186 | color: inherit; 187 | border-radius: 10px; 188 | padding: 10px; 189 | font-size: large; 190 | a { 191 | text-decoration: none; 192 | } 193 | } 194 | button:hover { 195 | background-color: gainsboro; 196 | } 197 | } 198 | } 199 | 200 | .widget-container { 201 | margin-top: 20px; 202 | display: grid; 203 | grid-template-columns: repeat(8, 150px); 204 | grid-auto-rows: 150px; 205 | gap: 10px; 206 | 207 | .widget { 208 | box-sizing: border-box; 209 | border: 1px solid gray; 210 | border-radius: 20px; 211 | background-color: rgb(251, 251, 251); 212 | display: flex; 213 | flex-direction: column; 214 | align-items: center; 215 | justify-content: center; 216 | text-align: center; 217 | padding: 0 10px; 218 | position: relative; 219 | 220 | button { 221 | position: absolute; 222 | top: 10px; 223 | right: 10px; 224 | 225 | background-color: pink; 226 | border: none; 227 | border-radius: 20px; 228 | height: 25px; 229 | width: 25px; 230 | opacity: 0.2; 231 | padding: 0; 232 | color: white; 233 | } 234 | button:hover { 235 | opacity: 1; 236 | transform: scale(1.08); 237 | } 238 | } 239 | 240 | .small { 241 | grid-column: span 1; 242 | grid-row: span 1; 243 | align-items: flex-start; 244 | padding: 5px; 245 | } 246 | 247 | .medium { 248 | grid-column: span 2; 249 | grid-row: span 2; 250 | } 251 | 252 | .large { 253 | grid-column: span 4; 254 | grid-row: span 3; 255 | } 256 | } 257 | 258 | svg { 259 | overflow: visible; 260 | margin: 0 0 15px 15px; 261 | g { 262 | font-family: inherit; 263 | font-size: xx-small; 264 | font-weight: bold; 265 | } 266 | .chart-label { 267 | font-size: small; 268 | font-weight: bold; 269 | } 270 | .legend-label { 271 | font-size: small; 272 | } 273 | } 274 | .memory-chart { 275 | margin: 0; 276 | } 277 | 278 | #dashboard-buttons { 279 | position: fixed; 280 | bottom: 5vh; 281 | right: 5vw; 282 | 283 | button { 284 | margin: 8px; 285 | height: 50px; 286 | width: 50px; 287 | font-size: xx-large; 288 | border-radius: 50px; 289 | border: none; 290 | opacity: 0.7; 291 | } 292 | button:hover { 293 | transform: scale(1.08); 294 | opacity: 1; 295 | } 296 | } 297 | 298 | #edit-redis-info-button { 299 | background-color: black; 300 | } 301 | #add-widget-button { 302 | background-color: lightgreen; 303 | color: white; 304 | } 305 | 306 | .modal { 307 | z-index: 2; 308 | width: 60vw; 309 | height: 80vh; 310 | left: 50%; 311 | top: 50%; 312 | position: fixed; 313 | background-color: rgb(199, 199, 199); 314 | font-size: x-large; 315 | transform: scale(0); 316 | transition: 200ms ease-in-out; 317 | border-radius: 10px; 318 | padding: 30px; 319 | 320 | display: flex; 321 | flex-direction: column; 322 | justify-content: center; 323 | align-items: center; 324 | } 325 | 326 | .widget-button-container { 327 | padding: 20px; 328 | overflow-y: auto; 329 | button { 330 | font: inherit; 331 | margin: 10px; 332 | background-color: rgb(255, 253, 253); 333 | border: none; 334 | border-radius: 20px; 335 | padding: 15px; 336 | 337 | .large { 338 | height: auto; 339 | width: 300px; 340 | } 341 | 342 | .medium { 343 | height: 200px; 344 | width: 200px; 345 | } 346 | 347 | .small { 348 | height: 100px; 349 | width: 100px; 350 | } 351 | } 352 | button:hover { 353 | border: solid black; 354 | } 355 | } 356 | 357 | .modal.active { 358 | transform: translate(-50%, -50%) scale(1); 359 | margin: 0; 360 | } 361 | 362 | #x-button { 363 | position: absolute; 364 | top: 20px; 365 | left: 20px; 366 | width: 30px; 367 | height: 30px; 368 | text-align: center; 369 | border-radius: 30px; 370 | background-color: red; 371 | color: white; 372 | border: none; 373 | opacity: 0.2; 374 | } 375 | #x-button:hover { 376 | opacity: 0.5; 377 | } 378 | 379 | .overlay { 380 | position: fixed; 381 | top: 0; 382 | left: 0; 383 | width: 100%; 384 | height: 100%; 385 | background-color: black; 386 | opacity: 0.6; 387 | display: none; 388 | z-index: 1; 389 | } 390 | 391 | .overlay.active { 392 | display: flex; 393 | } 394 | 395 | footer { 396 | margin: 20px auto; 397 | font-size: large; 398 | .github-logo { 399 | margin: 0 10px -6px 0; 400 | height: 25px; 401 | transition: transform 0.3s ease-in-out; 402 | } 403 | .github-logo:hover { 404 | transform: rotate(360deg) scale(1.2); 405 | } 406 | } 407 | -------------------------------------------------------------------------------- /docker-compose-test.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | prod: 5 | image: redisphere/prod 6 | container_name: redisphere-container-test 7 | ports: 8 | - '3000:3000' 9 | volumes: 10 | - .:/usr/src/app 11 | - node_modules:/usr/src/app/node_modules 12 | environment: 13 | - MONGO_URI=mongodb+srv://restankowitz:1234@cluster0.mvwl90e.mongodb.net/?retryWrites=true&w=majority 14 | - NODE_ENV=production 15 | command: npm run test 16 | 17 | volumes: 18 | node_modules: 19 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | prod: 5 | image: redisphere/prod 6 | container_name: redisphere-container 7 | ports: 8 | - '3000:3000' 9 | volumes: 10 | - /usr/src/app 11 | environment: 12 | - MONGO_URI=mongodb+srv://restankowitz:1234@cluster0.mvwl90e.mongodb.net/?retryWrites=true&w=majority 13 | - NODE_ENV=production 14 | - PORT=3000 15 | command: npm run start 16 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | rediSphere 8 | 13 | 14 | 15 |
16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /jest.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "testEnvironment": "jsdom", 3 | "moduleNameMapper": { 4 | "\\.(jpg|ico|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "/mocks/fileMock.js", 5 | "\\.(css|less)$": "/mocks/fileMock.js" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /mocks/fileMock.js: -------------------------------------------------------------------------------- 1 | module.exports = ''; 2 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rediSphere", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "jest --coverage", 8 | "start": "NODE_ENV=production node server/server.js", 9 | "build": "NODE_ENV=production webpack", 10 | "dev": "rm -rf build & NODE_ENV=development nodemon server/server.js & NODE_ENV=development webpack server", 11 | "docker-prod": "docker-compose -f docker-compose.yml up" 12 | }, 13 | "nodemonConfig": { 14 | "ignore": [ 15 | "build", 16 | "client" 17 | ] 18 | }, 19 | "keywords": [], 20 | "author": "", 21 | "license": "ISC", 22 | "dependencies": { 23 | "@reduxjs/toolkit": "^1.9.7", 24 | "bcryptjs": "^2.4.3", 25 | "cookie-parser": "^1.4.6", 26 | "cors": "^2.8.5", 27 | "d3": "^7.8.5", 28 | "dotenv": "^16.3.1", 29 | "express": "^4.12.3", 30 | "express-sslify": "^1.2.0", 31 | "mongoose": "^5.11.9", 32 | "react": "^18.2.0", 33 | "react-dom": "^18.2.0", 34 | "react-redux": "^8.1.3", 35 | "react-router": "^4.3.1", 36 | "react-router-dom": "^4.3.1", 37 | "redis": "^4.6.12", 38 | "redux": "^4.0.5" 39 | }, 40 | "devDependencies": { 41 | "@babel/core": "^7.23.3", 42 | "@babel/preset-env": "^7.23.3", 43 | "@babel/preset-react": "^7.23.3", 44 | "@reduxjs/toolkit": "^1.9.7", 45 | "@testing-library/dom": "^9.3.3", 46 | "@testing-library/jest-dom": "^6.1.5", 47 | "@testing-library/react": "^14.1.2", 48 | "@testing-library/user-event": "^14.5.1", 49 | "babel-loader": "^9.1.3", 50 | "concurrently": "^6.0.2", 51 | "cross-env": "^7.0.3", 52 | "css-loader": "^6.8.1", 53 | "file-loader": "^6.2.0", 54 | "html-webpack-plugin": "^5.5.3", 55 | "image-webpack-loader": "^8.1.0", 56 | "imports-loader": "^4.0.1", 57 | "isomorphic-fetch": "^3.0.0", 58 | "jest": "^29.7.0", 59 | "jest-environment-jsdom": "^29.7.0", 60 | "nodemon": "^2.0.7", 61 | "react-redux": "^8.1.3", 62 | "react-router-dom": "^6.20.1", 63 | "redux": "^4.2.1", 64 | "sass": "^1.69.5", 65 | "sass-loader": "^13.3.2", 66 | "style-loader": "^3.3.3", 67 | "supertest": "^6.3.3", 68 | "webpack": "^5.89.0", 69 | "webpack-cli": "^4.8.0", 70 | "webpack-dev-server": "^4.15.1", 71 | "webpack-hot-middleware": "^2.24.3" 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /server/controllers/cookieController.js: -------------------------------------------------------------------------------- 1 | const Session = require('../models/sessionModel.js'); 2 | 3 | const cookieController = {}; 4 | 5 | //setSSIDCookie - store the user id in a cookie 6 | cookieController.setSSIDCookie = (req, res, next) => { 7 | const id = res.locals.userID; 8 | res.cookie('ssid', id, { httpOnly: true }); 9 | return next(); 10 | }; 11 | 12 | module.exports = cookieController; 13 | -------------------------------------------------------------------------------- /server/controllers/redisController.js: -------------------------------------------------------------------------------- 1 | const { createClient } = require('redis'); 2 | const User = require('../models/userModel'); 3 | const redisController = {}; 4 | 5 | //connect to user's redis instance stored in user database 6 | redisController.connectUserRedis = async (req, res, next) => { 7 | try { 8 | const userID = req.cookies.ssid; 9 | const user = await User.findById(userID); 10 | const { host, port, redisPassword } = user; 11 | const redisClient = createClient({ 12 | password: redisPassword, 13 | socket: { 14 | host, 15 | port, 16 | }, 17 | }); 18 | await redisClient.connect(); 19 | req.redisClient = redisClient; 20 | return next(); 21 | } catch (err) { 22 | return next({ 23 | log: `redisController.connectUserRedis error: ${err}`, 24 | message: 'could not connect to user Redis instance', 25 | status: 500, 26 | }); 27 | } 28 | }; 29 | 30 | //close redis connection 31 | //prevent ERR max number of clients reached 32 | redisController.disconnectRedis = async (req, res, next) => { 33 | try { 34 | await req.redisClient.disconnect(); 35 | return next(); 36 | } catch (err) { 37 | return next({ 38 | log: `redisController.disconnectRedis error: ${err}`, 39 | message: 'could not disconnect Redis client', 40 | status: 500, 41 | }); 42 | } 43 | }; 44 | 45 | //efficiency of cache usage metric 46 | redisController.getCacheHitsRatio = async (req, res, next) => { 47 | try { 48 | const redisClient = req.redisClient; 49 | const stats = await redisClient.info(); 50 | const metrics = stats.split('\r\n'); 51 | let cacheHits = metrics.find((str) => str.startsWith('keyspace_hits')); 52 | let cacheMisses = metrics.find((str) => str.startsWith('keyspace_misses')); 53 | let timestamp = metrics.find((str) => str.startsWith('server_time_usec')); 54 | cacheHits = Number(cacheHits.slice(cacheHits.indexOf(':') + 1)); 55 | cacheMisses = Number(cacheMisses.slice(cacheMisses.indexOf(':') + 1)); 56 | timestamp = Number(timestamp.slice(timestamp.indexOf(':') + 1)); 57 | res.locals.stats = { 58 | cacheHitRatio: cacheHits + cacheMisses === 0 ? 0 : cacheHits / (cacheHits + cacheMisses), 59 | timestamp: timestamp, 60 | }; 61 | return next(); 62 | } catch (err) { 63 | return next({ 64 | log: `redisController.getCacheHitsRatio error: ${err}`, 65 | message: 'Get cache hits middleware error', 66 | status: 500, 67 | }); 68 | } 69 | }; 70 | 71 | //A persistent positive value of this metric is an indication that you need to scale the memory up. 72 | //A positive metric value shows that your expired data is being cleaned up properly 73 | redisController.getEvictedExpired = async (req, res, next) => { 74 | try { 75 | const redisClient = req.redisClient; 76 | const stats = await redisClient.info(); 77 | const metrics = stats.split('\r\n'); 78 | let totalKeys = metrics.find((str) => str.startsWith('db')); 79 | let evicted = metrics.find((str) => str.startsWith('evicted_keys')); 80 | let expired = metrics.find((str) => str.startsWith('expired_keys')); 81 | let timestamp = metrics.find((str) => str.startsWith('server_time_usec')); 82 | if (totalKeys) 83 | totalKeys = Number(totalKeys.slice(totalKeys.indexOf('=') + 1, totalKeys.indexOf(','))); 84 | if (evicted) evicted = Number(evicted.slice(evicted.indexOf(':') + 1)); 85 | if (expired) expired = Number(expired.slice(expired.indexOf(':') + 1)); 86 | if (timestamp) timestamp = Number(timestamp.slice(timestamp.indexOf(':') + 1)); 87 | res.locals.evictedExpired = { 88 | totalKeys: totalKeys, 89 | evicted: evicted, 90 | expired: expired, 91 | timestamp: timestamp, 92 | }; 93 | return next(); 94 | } catch (err) { 95 | return next({ 96 | log: `redisController.getEvictedExpired error: ${err}`, 97 | message: 'Get Evicted/Expired middleware error', 98 | status: 500, 99 | }); 100 | } 101 | }; 102 | 103 | redisController.getResponseTimes = async (req, res, next) => { 104 | try { 105 | const redisClient = req.redisClient; 106 | const stats = await redisClient.info(); 107 | const metrics = stats.split('\r\n'); 108 | 109 | let timestamp = metrics.find((str) => str.startsWith('server_time_usec')); 110 | timestamp = Number(timestamp.slice(timestamp.indexOf(':') + 1)); 111 | 112 | let commandsProcessed = metrics.find((str) => str.startsWith('total_commands_processed')); 113 | commandsProcessed = Number(commandsProcessed.slice(commandsProcessed.indexOf(':') + 1)); 114 | 115 | const cmdstats = await redisClient.info('commandstats'); 116 | const cmdmetrics = cmdstats.split('\r\n'); 117 | 118 | let avgGetCacheTime = cmdmetrics.find((str) => str.startsWith('cmdstat_get')); 119 | let totalGet = avgGetCacheTime; 120 | 121 | if (avgGetCacheTime) 122 | avgGetCacheTime = Number( 123 | avgGetCacheTime.slice(avgGetCacheTime.indexOf('usec_per_call=') + 14), 124 | ); 125 | if (totalGet) 126 | totalGet = Number(totalGet.slice(totalGet.indexOf('calls=') + 6, totalGet.indexOf(',')) || 0); 127 | res.locals.latency = { 128 | commandsProcessed: commandsProcessed, 129 | totalGet: totalGet, 130 | avgGetCacheTime: avgGetCacheTime, 131 | timestamp: timestamp, 132 | }; 133 | return next(); 134 | } catch (err) { 135 | return next({ 136 | log: `redisController.getResponseTimes error: ${err}`, 137 | message: 'Get latency middleware error', 138 | status: 500, 139 | }); 140 | } 141 | }; 142 | 143 | //memory usage 144 | redisController.getMemory = async (req, res, next) => { 145 | try { 146 | const redisClient = req.redisClient; 147 | const stats = await redisClient.info('memory'); 148 | const metrics = stats.split('\r\n'); 149 | let usedMemory = metrics.find((str) => str.startsWith('used_memory_human')); 150 | let peakUsedMemory = metrics.find((str) => str.startsWith('used_memory_peak_human')); 151 | let totalMemory = metrics.find((str) => str.startsWith('total_system_memory_human')); 152 | if (usedMemory) 153 | usedMemory = Number(usedMemory.slice(usedMemory.indexOf(':') + 1, usedMemory.length - 1)); 154 | if (peakUsedMemory) 155 | peakUsedMemory = Number( 156 | peakUsedMemory.slice(peakUsedMemory.indexOf(':') + 1, peakUsedMemory.length - 1), 157 | ); 158 | res.locals.memory = { 159 | usedMemory: usedMemory, 160 | peakUsedMemory: peakUsedMemory, 161 | }; 162 | return next(); 163 | } catch (err) { 164 | return next({ 165 | log: `redisController.getMemory error: ${err}`, 166 | message: 'Get Memory usage middleware error', 167 | status: 500, 168 | }); 169 | } 170 | }; 171 | 172 | module.exports = redisController; 173 | -------------------------------------------------------------------------------- /server/controllers/sessionController.js: -------------------------------------------------------------------------------- 1 | const Session = require('../models/sessionModel.js'); 2 | const sessionController = {}; 3 | 4 | // isLoggedIn - find the appropriate session for this request in the database, then verify whether or not the session is still valid. 5 | sessionController.isLoggedIn = async (req, res, next) => { 6 | try { 7 | if (req.cookies.ssid) { 8 | const sessionExists = await Session.findOne({ 9 | cookieId: req.cookies.ssid, 10 | }); 11 | if (sessionExists) { 12 | res.locals.session = true; 13 | res.locals.username = sessionExists.username; 14 | return next(); 15 | } 16 | } 17 | //if no session is found, set session to false 18 | res.locals.session = false; 19 | return next(); 20 | } catch (err) { 21 | return next({ 22 | log: 'sessionController isLoggedIn error', 23 | message: 'could not verify session', 24 | status: 500, 25 | }); 26 | } 27 | }; 28 | 29 | //startSession - create and save a new Session into the database. 30 | sessionController.startSession = async (req, res, next) => { 31 | const id = res.locals.userID; 32 | const username = res.locals.username; 33 | console.log(res.locals); 34 | try { 35 | const session = await Session.create({ cookieId: id, username }); 36 | return next(); 37 | } catch (err) { 38 | return next({ 39 | log: 'Create session error', 40 | message: 'could not create new session', 41 | status: 500, 42 | }); 43 | } 44 | }; 45 | 46 | //log out - delete ssid cookie and delete session from session database. 47 | sessionController.logOut = async (req, res, next) => { 48 | try { 49 | const ssid = req.cookies.ssid; 50 | await Session.findOneAndDelete({ cookieId: ssid }); 51 | res.clearCookie('ssid'); 52 | res.locals.loggedOut = true; 53 | return next(); 54 | } catch (err) { 55 | return next({ 56 | log: 'Express error handler caught error in sessionController.logOut middleware', 57 | status: 300, 58 | message: { err: 'Encountered error in logout process' }, 59 | }); 60 | } 61 | }; 62 | 63 | module.exports = sessionController; 64 | -------------------------------------------------------------------------------- /server/controllers/userController.js: -------------------------------------------------------------------------------- 1 | const User = require('../models/userModel'); 2 | 3 | const userController = {}; 4 | 5 | //get user's widgets array 6 | userController.getWidgets = async (req, res, next) => { 7 | try { 8 | const id = req.cookies.ssid; 9 | const user = await User.findById(id); 10 | res.locals.widgets = user.widgets; 11 | return next(); 12 | } catch (err) { 13 | return next({ 14 | log: 'userController getWidgets error', 15 | message: 'could not get widgets', 16 | status: 500, 17 | }); 18 | } 19 | }; 20 | 21 | //get user's widgets array 22 | userController.deleteWidget = async (req, res, next) => { 23 | const indexToDelete = parseInt(req.params.index); 24 | try { 25 | const id = req.cookies.ssid; 26 | const user = await User.findById(id); 27 | const newWidgets = user.widgets 28 | .slice(0, indexToDelete) 29 | .concat(user.widgets.slice(indexToDelete + 1)); 30 | await user.updateOne({ $set: { widgets: newWidgets } }); 31 | res.locals.widgets = newWidgets; 32 | return next(); 33 | } catch (err) { 34 | return next({ 35 | log: `userController deleteWidgets error: ${err}`, 36 | message: 'could not delete widget', 37 | status: 500, 38 | }); 39 | } 40 | }; 41 | 42 | //add widget to user's widgets array, sends back whole widgets array 43 | userController.addWidget = async (req, res, next) => { 44 | const { newWidget } = req.body; 45 | try { 46 | const id = req.cookies.ssid; 47 | //new:true because default behavior (new: false) 48 | //is to return the user document Before it updates 49 | const update = await User.findByIdAndUpdate( 50 | id, 51 | { $push: { widgets: [newWidget] } }, 52 | { new: true }, 53 | ); 54 | res.locals.widgets = update.widgets; 55 | return next(); 56 | } catch (err) { 57 | return next({ 58 | log: 'userController add widget error', 59 | message: 'could not add widget', 60 | status: 500, 61 | }); 62 | } 63 | }; 64 | 65 | //add Redis credentials 66 | userController.addRedisCredentials = async (req, res, next) => { 67 | const { host, port, redisPassword } = req.body; 68 | try { 69 | const id = req.cookies.ssid; 70 | await User.findByIdAndUpdate(id, { $set: { host, port, redisPassword } }); 71 | res.locals.message = 'ok'; 72 | return next(); 73 | } catch (err) { 74 | return next({ 75 | log: 'addRedisCredentials error', 76 | message: 'could not addRedisCredentials', 77 | status: 500, 78 | }); 79 | } 80 | }; 81 | 82 | //createUser - create and save a new User into the database 83 | userController.createUser = async (req, res, next) => { 84 | const { username, password } = req.body; 85 | try { 86 | //first check if username is already saved in database 87 | const usernameTaken = await User.findOne({ username }); 88 | if (usernameTaken) { 89 | return res.json('username taken'); 90 | } 91 | 92 | //create new user 93 | const newUser = await User.create({ 94 | username, 95 | password, 96 | }); 97 | res.locals.message = 'ok'; 98 | res.locals.userID = newUser.id; 99 | res.locals.username = username; 100 | return next(); 101 | } catch (err) { 102 | return next({ 103 | log: 'createUser error', 104 | message: 'could not create new user', 105 | status: 500, 106 | }); 107 | } 108 | }; 109 | 110 | //verifyUser - when user tries to sign in 111 | userController.verifyUser = async (req, res, next) => { 112 | const { username, password } = req.body; 113 | try { 114 | //see if username is in database 115 | const userExists = await User.findOne({ username }); 116 | if (userExists) { 117 | //if so, bcrypt compare password with stored hashed password 118 | const passwordMatch = await userExists.comparePassword(password); 119 | if (passwordMatch) { 120 | res.locals.message = 'ok'; 121 | res.locals.userID = userExists.id; 122 | res.locals.username = username; 123 | return next(); 124 | } 125 | } 126 | //otherwise, login failed 127 | return res.json('not ok'); 128 | } catch (err) { 129 | return next({ 130 | log: 'verifyUser error', 131 | message: 'could not reach database to verify user', 132 | status: 500, 133 | }); 134 | } 135 | }; 136 | 137 | //findUser - find the username associated with ssid cookie 138 | userController.findUser = async (req, res, next) => { 139 | try { 140 | const id = req.cookies.ssid; 141 | const user = await User.findById(id); 142 | res.locals.username = user.username; 143 | return next(); 144 | } catch (err) { 145 | return next({ 146 | log: 'userController findUser error', 147 | message: 'error occured when trying to find user', 148 | status: 500, 149 | }); 150 | } 151 | }; 152 | 153 | module.exports = userController; 154 | -------------------------------------------------------------------------------- /server/models/sessionModel.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | const Schema = mongoose.Schema; 3 | 4 | // sessionID stored on cookie 5 | const sessionSchema = new Schema({ 6 | cookieId: { type: String, required: true, unique: true }, 7 | username: { type: String, required: true }, 8 | // createdAt: { type: Date, expires: 28800, default: Date.now }, 9 | }); 10 | 11 | module.exports = mongoose.model('Session', sessionSchema); 12 | -------------------------------------------------------------------------------- /server/models/userModel.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | const Schema = mongoose.Schema; 3 | const SALT_WORK_FACTOR = 10; 4 | const bcrypt = require('bcryptjs'); 5 | 6 | const userSchema = new Schema({ 7 | username: { type: String, required: true, unique: true }, 8 | password: { type: String, required: true }, 9 | host: { type: String }, 10 | port: { type: String }, 11 | redisPassword: { type: String }, 12 | widgets: { 13 | type: Array, 14 | default: [ 15 | //consider which defaults we want in prod 16 | ['large', 'hitmiss'], 17 | ['large', 'evictedExpired'], 18 | ['medium', 'latency'], 19 | ['small', 'memory'], 20 | ], 21 | }, 22 | }); 23 | 24 | // ====== BCRYPT ENCRYPTION ====== 25 | // Password encryption using Bcrypt 26 | 27 | //need to encrypt Redis Data too! 28 | userSchema.pre('save', async function () { 29 | try { 30 | //isModified will return true if you are changing the password 31 | //i.e. if user sets for the first time (or resets password) 32 | if (!this.isModified('password')) return; 33 | //create a hash of the updated password 34 | const hash = await bcrypt.hash(this.password, SALT_WORK_FACTOR); 35 | //modify the request to store the hashed password instead of in plaintext 36 | this.password = hash; 37 | return; 38 | } catch (err) { 39 | return console.log(err); 40 | } 41 | }); 42 | 43 | // comparePassword method on the user schema to check if the provided Password matches the hashed Password 44 | userSchema.methods.comparePassword = function (providedPassword) { 45 | const isMatch = bcrypt.compare(providedPassword, this.password); 46 | return isMatch; 47 | }; 48 | 49 | module.exports = mongoose.model('User', userSchema); 50 | -------------------------------------------------------------------------------- /server/routes/api.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const redisController = require('../controllers/redisController'); 3 | const router = express.Router(); 4 | 5 | //sends cachehitratio to the front 6 | router.get( 7 | '/cacheHitsRatio', 8 | redisController.connectUserRedis, 9 | redisController.getCacheHitsRatio, 10 | redisController.disconnectRedis, 11 | (req, res) => { 12 | return res.status(200).json(res.locals.stats); 13 | }, 14 | ); 15 | //sends evicted and expired to the front 16 | router.get( 17 | '/evictedExpired', 18 | redisController.connectUserRedis, 19 | redisController.getEvictedExpired, 20 | redisController.disconnectRedis, 21 | (req, res) => { 22 | return res.status(200).json(res.locals.evictedExpired); 23 | }, 24 | ); 25 | //sends latency to the front 26 | router.get( 27 | '/latency', 28 | redisController.connectUserRedis, 29 | redisController.getResponseTimes, 30 | redisController.disconnectRedis, 31 | (req, res) => { 32 | return res.status(200).json(res.locals.latency); 33 | }, 34 | ); 35 | //sends memory usage to the front 36 | router.get( 37 | '/memory', 38 | redisController.connectUserRedis, 39 | redisController.getMemory, 40 | redisController.disconnectRedis, 41 | (req, res) => { 42 | return res.status(200).json(res.locals.memory); 43 | }, 44 | ); 45 | 46 | module.exports = router; 47 | -------------------------------------------------------------------------------- /server/routes/authRouter.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const userController = require('../controllers/userController.js'); 3 | const cookieController = require('../controllers/cookieController.js'); 4 | const sessionController = require('../controllers/sessionController.js'); 5 | const router = express.Router(); 6 | 7 | //get user's widgets 8 | router.get('/widgets', userController.getWidgets, (req, res) => res.json(res.locals.widgets)); 9 | 10 | //add widgets to user's widgets array, sends back whole widgets array 11 | router.put('/add-widget', userController.addWidget, (req, res) => res.json(res.locals.widgets)); 12 | 13 | //delete widget at index and return rest of spliced array w/o the widget formely at index as user's widgets array 14 | router.delete('/delete-widget/:index', userController.deleteWidget, (req, res) => 15 | res.json(res.locals.widgets), 16 | ); 17 | 18 | // post req to sign up, once signed up, redirect to dashboard 19 | router.post( 20 | '/create', 21 | userController.createUser, 22 | cookieController.setSSIDCookie, 23 | sessionController.startSession, 24 | (req, res) => res.json(res.locals.message), 25 | ); 26 | 27 | // post req to log in, redirect to dashboard 28 | router.post( 29 | '/signin', 30 | userController.verifyUser, 31 | cookieController.setSSIDCookie, 32 | sessionController.startSession, 33 | (req, res) => res.json(res.locals.message), 34 | ); 35 | 36 | //connect redis 37 | router.put('/connect-redis', userController.addRedisCredentials, (req, res) => 38 | res.json(res.locals.message), 39 | ); 40 | 41 | //get session 42 | router.get('/session', sessionController.isLoggedIn, (req, res) => res.json(res.locals)); 43 | 44 | //log out 45 | router.delete('/signout', sessionController.logOut, (req, res) => { 46 | return res.json(res.locals.loggedOut); 47 | }); 48 | 49 | module.exports = router; 50 | -------------------------------------------------------------------------------- /server/server.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | require('dotenv').config(); 3 | const path = require('path'); 4 | const cookieParser = require('cookie-parser'); 5 | const mongoose = require('mongoose'); 6 | const enforce = require('express-sslify'); 7 | 8 | const apiRouter = require('./routes/api.js'); 9 | const authRouter = require('./routes/authRouter.js'); 10 | 11 | const PORT = process.env.PORT || '3000'; 12 | 13 | const app = express(); 14 | 15 | //outside of dev environment, force SSL/HTTPS 16 | if (process.env.NODE_ENV !== 'development') { 17 | app.use(enforce.HTTPS({ trustProtoHeader: true })); 18 | } 19 | // handle parsing request body 20 | app.use(cookieParser()); 21 | app.use(express.json()); // parses body EXCEPT html 22 | app.use(express.urlencoded({ extended: true })); // requires header to parse 23 | 24 | //mounting api router, redis metrics middlewares 25 | app.use('/api', apiRouter); 26 | app.use('/users', authRouter); 27 | 28 | if (process.env.NODE_ENV === 'production') { 29 | // statically serve everything in the build folder on the route '/build' 30 | app.use('/build', express.static(path.join(__dirname, '../build'))); 31 | // serve index.html on the route '/' 32 | app.get('/*', (req, res) => { 33 | return res.status(200).sendFile(path.join(__dirname, '../index.html')); 34 | }); 35 | } 36 | 37 | // catch-all route handler for any requests to an unknown route 38 | app.use((req, res) => res.status(404).send("This is not the page you're looking for...")); 39 | 40 | //express global error handler (middleware) 41 | app.use((err, req, res, next) => { 42 | const defaultErr = { 43 | log: 'Express error handler caught unknown middleware error', 44 | status: 500, 45 | message: { err: 'An error occurred' }, 46 | }; 47 | const errorObj = Object.assign({}, defaultErr, err); 48 | console.log(errorObj.log); 49 | return res.status(errorObj.status).json(errorObj.message); 50 | }); 51 | 52 | //start server and connect to mongoDB 53 | app.listen(PORT, async () => { 54 | console.log(`Server listening on port: ${PORT}...`); 55 | try { 56 | mongoose.connect(process.env.MONGO_URI, { useNewUrlParser: true }); 57 | console.log('Connected to Mongo DB...'); 58 | } catch (error) { 59 | console.log(error); 60 | } 61 | }); 62 | 63 | module.exports = app; 64 | -------------------------------------------------------------------------------- /server/utils/nottests.js: -------------------------------------------------------------------------------- 1 | const createLoadTest = require('./redis-load-test'); 2 | 3 | //const createLoadTest = require('./update.js'); 4 | 5 | const options = { 6 | totalClients: 1, 7 | totalOps: 100000, 8 | timeLimit: 30, 9 | targets: 3, 10 | }; 11 | 12 | createLoadTest(options) 13 | .then(() => { 14 | console.log('Load test complete!'); 15 | }) 16 | .catch((err) => { 17 | console.error('Test failed', err); 18 | }); 19 | -------------------------------------------------------------------------------- /server/utils/redis-load-test.js: -------------------------------------------------------------------------------- 1 | const Redis = require('redis'); 2 | const { randomBytes } = require('crypto'); 3 | require('dotenv').config(); 4 | 5 | const redisPassword = process.env.REDIS_PASS; 6 | const socketHost = process.env.HOST; 7 | const redisPort = process.env.REDIS_PORT; 8 | //const redisURL = `redis://${redisUser}:${redisPassword}@${socketHost}:${redisPort}`; 9 | 10 | //HELPER FUNCTIONS 11 | 12 | //Creates a configured client w/error handler 13 | function createConfiguredClient() { 14 | // create and configure redis client 15 | const client = Redis.createClient({ 16 | password: redisPassword, 17 | socket: { 18 | host: socketHost, 19 | port: redisPort, 20 | }, 21 | }); 22 | //set the error listener to log out errors if they occur 23 | client.on('error', (err) => { 24 | console.error('Redis client error', err); 25 | }); 26 | 27 | return client; 28 | } 29 | 30 | //runs a random operation: 31 | // 50% set, 50% get 32 | // keys/values are random hex values 33 | // function runRandomOp(client) { 34 | // const key = generateRandomKey(totalKeys); 35 | // console.log(key); 36 | 37 | // if (Math.random() < 0.5) { 38 | // //client.set(key, generateRandomValue()); 39 | // let val = generateRandomValue(); 40 | // //console.log(val); 41 | // client.set(key, val); 42 | // } else { 43 | // client.get(key, (err, res) => { 44 | // if (err) { 45 | // console.error(err); 46 | // return; 47 | // } 48 | // }); 49 | // } 50 | // } 51 | 52 | //const usedKeys = new Set(); 53 | 54 | function runHitOp(client) { 55 | //const key = [...usedKeys][Math.floor(Math.random() * usedKeys.size)]; 56 | client.setEx('hit_key', 45, generateRandomValue(1000)); 57 | //client.setEx('hit_key', 10, 'value'); 58 | client.get('hit_key', (err, res) => { 59 | if (err) { 60 | console.error(err); 61 | return; 62 | } else { 63 | console.log('Value: ', res); 64 | } 65 | }); 66 | } 67 | 68 | //guaranteed cache miss -- never set a key other than 'hit_key' 69 | function runMissOp(client, totalKeys) { 70 | client.get(generateRandomKey(1000000)); 71 | } 72 | 73 | //random key generator 74 | function generateRandomKey(totalKeys) { 75 | return Math.floor(Math.random() * totalKeys).toString(); 76 | } 77 | 78 | //random value generator 79 | function generateRandomValue(sizeInBytes = 50) { 80 | //MB = 1000 KB 81 | //KB = 1000 Bytes 82 | return randomBytes(sizeInBytes); 83 | } 84 | 85 | function setWindows(periods, startTime, timeLimit) { 86 | const windowSize = Math.floor((timeLimit * 1000) / periods); 87 | 88 | const windows = []; 89 | let start = startTime; 90 | let end = start + windowSize; 91 | 92 | for (let i = 0; i < periods; i++) { 93 | windows.push({ start, end }); 94 | start = end + 1; 95 | end += windowSize; 96 | } 97 | 98 | return windows; 99 | //returns [{start: 1700894, end: 1800894}, {start: ... , end: ...}] 100 | } 101 | function runCacheFill(client) { 102 | //while memory used < 30MB 103 | //set more keys 104 | 105 | for (let i = 1; i < 30; i++) { 106 | client.setEx(`${i}`, 15, generateRandomValue(1000000)); 107 | } 108 | } 109 | 110 | function getLeastRecentKey(client) { 111 | for (let i = 0; i < 50; i++) { 112 | client.get(`${i}`); 113 | } 114 | } 115 | 116 | // function getCurrentWindow(windows, now) { 117 | // for (let i = 0; i < windows.length; i++) { 118 | // const window = windows[i]; 119 | // if (now >= window.start && now <= window.end) return i; 120 | // } 121 | // } 122 | 123 | module.exports = function createLoadTest({ 124 | totalClients = 5, 125 | totalOps = 1000, 126 | timeLimit = 15, // seconds 127 | totalKeys = 1000000, 128 | targets = 3, 129 | }) { 130 | const clients = []; 131 | 132 | for (let i = 0; i < totalClients; i++) { 133 | const client = createConfiguredClient(); 134 | //connect client to server 135 | client.connect(); 136 | 137 | client.on('ready', () => { 138 | clients.push(client); 139 | console.log(clients); 140 | }); 141 | client.on('error', (err) => { 142 | console.error(err); 143 | }); 144 | } 145 | //console.log(clients); 146 | 147 | let opsCount = 0; 148 | 149 | const startTime = Date.now(); 150 | const endTime = Date.now() + timeLimit * 1000; 151 | const windows = setWindows(targets, startTime, timeLimit); 152 | console.log(windows); 153 | let window = 0; 154 | 155 | return new Promise((resolve, reject) => { 156 | const interval = setInterval(() => { 157 | now = Date.now(); 158 | 159 | //const currWindow = getCurrentWindow(windows, startTime, now); 160 | 161 | console.log('Current Stage: ', window); 162 | 163 | const isEven = window % 2 === 0; 164 | 165 | const opFn = isEven ? runHitOp : runHitOp; 166 | 167 | clients.forEach((c) => { 168 | switch (window) { 169 | case 0: 170 | runMissOp(c, 100); 171 | console.log('missOp'); 172 | break; 173 | case 1: 174 | runHitOp(c); 175 | console.log('hitOp'); 176 | break; 177 | case 2: 178 | runCacheFill(c); 179 | console.log('cacheFill'); 180 | break; 181 | } 182 | 183 | // for (let i = 0; i < 10; i++) { 184 | // getLeastRecentKey(c); 185 | // } 186 | //c.set('103', generateRandomValue()); 187 | //await c.connect(); 188 | 189 | //runCacheFill(c); //set keys is async --> if it isn't complete, still goes to check the totalOps and endTime 190 | //opFn(c, totalKeys); 191 | //runRandomOp(c); 192 | // c.disconnect(); 193 | opsCount++; 194 | }); 195 | 196 | console.log(`Simulating ${totalClients} clients`); 197 | if (opsCount >= totalOps || Date.now() > endTime) { 198 | clearInterval(interval); 199 | clients.forEach((c) => { 200 | try { 201 | c.disconnect().then(console.log('disconnected!')); 202 | } catch (err) { 203 | console.error(err); 204 | } 205 | }); 206 | resolve(); 207 | } 208 | if (windows[window].end < now) window++; 209 | }, 50); 210 | }); 211 | }; 212 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 3 | 4 | module.exports = { 5 | mode: process.env.NODE_ENV, 6 | // mode: 'development', 7 | entry: { 8 | src: './client/index.js', 9 | }, 10 | output: { 11 | filename: 'bundle.js', 12 | path: path.resolve(__dirname, 'build'), 13 | }, 14 | module: { 15 | rules: [ 16 | { 17 | test: /\.(gif|png|jpe?g|svg)$/i, 18 | use: [ 19 | 'file-loader', 20 | { 21 | loader: 'image-webpack-loader', 22 | options: { 23 | bypassOnDebug: true, // webpack@1.x 24 | disable: true, // webpack@2.x and newer 25 | }, 26 | }, 27 | ], 28 | }, 29 | { 30 | test: /\.jsx?/, 31 | exclude: /node_modules/, 32 | loader: 'babel-loader', 33 | options: { 34 | presets: ['@babel/env', '@babel/react'], 35 | }, 36 | }, 37 | { 38 | test: /\.s?css/, 39 | use: ['style-loader', 'css-loader', 'sass-loader'], 40 | }, 41 | ], 42 | }, 43 | //injects script tags 44 | plugins: [ 45 | new HtmlWebpackPlugin({ 46 | title: 'Development', 47 | template: 'index.html', 48 | }), 49 | ], 50 | //configures webpack dev server & proxies calls to /api to root of backend 51 | devServer: { 52 | host: 'localhost', 53 | port: 8080, 54 | historyApiFallback: true, 55 | hot: true, 56 | static: { 57 | publicPath: '/build', 58 | directory: path.resolve(__dirname, 'build'), 59 | }, 60 | headers: { 'Access-Control-Allow-Origin': '*' }, 61 | proxy: { 62 | '/api/**': { target: 'http://localhost:3000/', secure: false }, 63 | '/users/**': { target: 'http://localhost:3000/', secure: false }, 64 | }, 65 | }, 66 | resolve: { 67 | extensions: ['.js', '.jsx', '.scss'], 68 | }, 69 | }; 70 | --------------------------------------------------------------------------------