├── .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 | 
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 | {
44 | document.getElementById('overlay').classList.remove('active');
45 | document.getElementById('add-widget').classList.remove('active');
46 | const added = await fetch('/users/add-widget', {
47 | method: 'PUT',
48 | headers: { 'Content-Type': 'application/json' },
49 | body: JSON.stringify({ newWidget: [`${size}`, `${chart}`] }),
50 | });
51 | const updatedWidgetsArray = await added.json();
52 | dispatch(SET_WIDGETS(updatedWidgetsArray));
53 | }}
54 | key={`${size} ${chart}`}
55 | >
56 |
57 |
58 | {`${nameMap[`${chart}`]}`}
59 | ,
60 | );
61 | }
62 | }
63 |
64 | return (
65 |
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 |
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 |
28 |
29 |
33 |
Michelle Xie
34 |
Software Engineer
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
60 |
61 |
65 |
Ryan Stankowitz
66 |
Software Engineer
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
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 | {
51 | deleteWidget(index);
52 | }}
53 | >
54 | ✖
55 |
56 | {nameToComponent[widget[1]][widget[0]]}
57 |
,
58 | );
59 | });
60 |
61 | return (
62 |
63 |
64 |
{widgetDisplay}
65 |
66 | navigate('/connectredis')}>
67 | ✏️
68 |
69 | {
72 | document.getElementById('overlay').classList.add('active');
73 | document.getElementById('add-widget').classList.add('active');
74 | }}
75 | >
76 | +
77 |
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 | navigate('/login')}>sign in
36 | navigate('/signup')}>sign up
37 |
,
38 | ]);
39 | dispatch(LOGOUT_USER());
40 | } else if (activeSession.session === true) {
41 | setButtons([
42 |
43 | navigate('/dashboard')}>dashboard
44 |
45 | {'logout @'}
46 |
47 | {activeSession.username}
48 |
49 |
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 |
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 = navigate('/signup')}>Get Started ;
17 | } else {
18 | bigButton = navigate('/dashboard')}>Take me to my dashboard ;
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 |
37 | Username
38 |
39 |
40 |
41 |
42 |
43 |
44 | Password
45 |
46 |
47 |
48 |
49 |
50 |
51 | Sign in
52 |
53 |
54 |
55 |
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 |
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 |
--------------------------------------------------------------------------------