with className = "messageBox"
18 | //need message, timestamp, type, channel
19 | //need messages to display in reverse order (by time)
20 |
21 |
22 | const messages = [];
23 | for (let i = props.log.length - 1; i >= 0; i--){
24 | let msgObj = props.log[i];
25 | messages.push(
);
26 | }
27 |
28 | return (
29 |
30 | {messages}
31 |
32 | )
33 | }
34 |
35 | export default MessageLogDisplay;
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 OSLabs Beta
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/client/components/ChannelsDisplay.jsx:
--------------------------------------------------------------------------------
1 | /**
2 | * ************************************
3 | *
4 | * @module ChannelsDisplay
5 | * @author joe, mark
6 | * @date 11/16.
7 | * @description presentation component that renders in ChannelContainer
8 | *
9 | * ************************************
10 | */
11 |
12 | import React from "react";
13 | import OneChannel from "./OneChannel.jsx";
14 |
15 | const ChannelsDisplay = (props) => {
16 |
17 | const displayArr = [];
18 | for(let i = 0; i < props.channelList.length; i++){
19 | let highlight = 'unhighlighted';
20 | if(props.selectedChannel === props.channelList[i].name){
21 | highlight = 'highlighted'
22 | }
23 |
24 | displayArr.push(
)
30 | }
31 |
32 | return (
33 |
34 |
35 |
Channels
36 |
37 | {displayArr}
38 |
39 | )
40 | }
41 |
42 | export default ChannelsDisplay;
--------------------------------------------------------------------------------
/client/containers/ChannelContainer.jsx:
--------------------------------------------------------------------------------
1 | /**
2 | * ************************************
3 | *
4 | * @module ChannelContainer
5 | * @authors joeseph & mark
6 | * @date
7 | * @description stateful component that reders
8 | *
9 | * ************************************
10 | */
11 |
12 | import React, { Component } from "react";
13 | import { connect } from "react-redux";
14 | import ChannelsDisplay from "../components/ChannelsDisplay.jsx";
15 | import * as actions from "../actions/channelActions.js";
16 |
17 |
18 | const mapStateToProps = (store) => ({
19 | totalChannels : store.channels.totalChannels,
20 | channelList : store.channels.channelList,
21 | selectedChannel: store.channels.selectedChannel
22 | })
23 |
24 | const mapDispatchToProps = (dispatch) => ({
25 | selectChannel: (e)=>{
26 | dispatch(actions.selectChannel(e.target.innerText))
27 | }
28 | });
29 |
30 | class ChannelContainer extends Component{
31 | constructor(props){
32 | super(props);
33 | }
34 | render () {
35 | return (
36 | <>
37 |
40 | >
41 | )
42 | }
43 | }
44 |
45 |
46 | export default connect(mapStateToProps, mapDispatchToProps)(ChannelContainer)
--------------------------------------------------------------------------------
/client/components/ClientCard.jsx:
--------------------------------------------------------------------------------
1 | /**
2 | * ************************************
3 | *
4 | * @module ClientCard.jsx
5 | * @author
6 | * @date
7 | * @description Display a client
8 | *
9 | * ************************************
10 | */
11 |
12 | import React from "react";
13 |
14 | const ClientCard = (props) => {
15 | //button with className clientCard
16 | //name of client
17 | //id of clientID
18 | //key of clientID (react needs unique keys for re rendering)
19 | //onclick functionality -- setClient (from props) -- pass in the clientId event.target.id
20 |
21 | //FIND subbed channel
22 | let selectedChannel = props.selectedChannel;
23 | let channels = props.channels;
24 | let highlight = 'unhighlighted';
25 | let type;
26 |
27 | //use this to display type on button
28 | if(props.type === 'publisher') type = '. Publisher';
29 | else type = '. Subscriber';
30 |
31 | channels.forEach(element => {
32 | if(element === selectedChannel){
33 | highlight = 'highlighted';
34 | }
35 | });
36 | return (
37 |
38 | {
44 | props.setClient(e.target.id);
45 | }}
46 | >
47 | {props.id}
48 |
49 |
50 |
51 | );
52 | }
53 |
54 | export default ClientCard;
55 |
--------------------------------------------------------------------------------
/client/containers/ErrorBox.jsx:
--------------------------------------------------------------------------------
1 | /**
2 | * ************************************
3 | *
4 | * @module ErrorBox.jsx
5 | * @author Lara, Elise
6 | * @date 11/20
7 | * @description Stateful component that displays errors
8 | *
9 | * ************************************
10 | */
11 |
12 | import React, { Component } from "react";
13 | import { connect } from "react-redux";
14 | import * as errorActions from "../actions/errorActions.js";
15 |
16 | //map state
17 | const mapStateToProps = (state) => ({
18 | errorMessage: state.error.errorMessage,
19 | })
20 |
21 | //map dispatch
22 | const mapDispatchToProps = (dispatch) => ({
23 | //errorHandler
24 | errorHandler: (payload) => dispatch(errorActions.errorHandler(payload)),
25 | //clearError
26 | clearError: () => dispatch(errorActions.clearError())
27 | });
28 |
29 | class ErrorBox extends Component{
30 | constructor(props){
31 | super(props);
32 | }
33 |
34 | render() {
35 | if (!this.props.errorMessage) return <>>;
36 | return (
37 |
38 |
39 |
{this.props.errorMessage}
40 |
{this.props.clearError()}}>
44 | Got It
45 |
46 |
47 |
48 | )
49 | }
50 | }
51 |
52 | export default connect(mapStateToProps, mapDispatchToProps)(ErrorBox);
53 |
--------------------------------------------------------------------------------
/client/styles/_variables.scss:
--------------------------------------------------------------------------------
1 | $component-bg: #262939; //client windows, error msg, client window, all messages
2 | $highlight: #f1b630; //Erro message 'got it', channel & client highlights
3 | $accent: #1b1e2a; // channels, client, disconnect, connect BOX color
4 | $darkGray: #ffffff; //body background
5 | $medGray: #f1b630; //client title labels, pub & sub
6 |
7 | $accentDark: #f1b630; //FONT Channels, Clients, Connect/Disconnect & recent messages FONT
8 | $highlightDark: #f1b630; //PUBLISHED, SUBSCRIBED in recent message
9 | $dropdownColor: #30475e;
10 |
11 | $clientWindowMsg: #ffffff;
12 | $clientWindow: #373943;
13 | $pushMsg: #4384f7;
14 |
15 | $lightYellow: #ffd980; //Connect input highlight
16 |
17 | $navBarHeight: 5em;
18 | $borderpx: 5px;
19 | $bigBorderpx: 15px;
20 | $medBorderpx: 10px;
21 | $smallBorderpx: 5px;
22 | $marginHeader: 5px;
23 | $bigBorderpx: 10px;
24 | $medBorderpx: 7px;
25 | $smallBorderpx: 5px;
26 | $marginHeader: 5px;
27 |
28 | $navBarHeight: 30px;
29 |
30 | $boxShadowLabel: 2px 2px 3px #081c15;
31 | $boxShadowInput: 4px 2px 4px #081c15;
32 | $boxShadowBtn: 2px 2px 4px #081c15;
33 | $clientWindowBoxShadow: 3px 3px 2px #081c15;
34 |
35 | //background image
36 | $bgImage: url('../../static/RPS_View_logo.png');
37 |
38 | // $bg: #808c8d;
39 | // $component-bg: lightgray;
40 | // $highlight: #CF8EFA;
41 | // $accent: #67cec2;
42 | // $darkGray: #333838;
43 | // $medGray: #b6c0c0;
44 | // $accentMid: #235048;
45 |
46 | // $lightYellow: #fdfe97;
47 |
48 | // $accentDark: #273f3b;
49 | // $highlightDark: #5A2A7A;
50 |
--------------------------------------------------------------------------------
/client/actions/clientActions.js:
--------------------------------------------------------------------------------
1 | /**
2 | * ************************************
3 | *
4 | * @module clientActions.js
5 | * @author
6 | * @date
7 | * @description actions related to clientReducers
8 | *
9 | * ************************************
10 | */
11 | import * as types from '../constants/actionTypes.js';
12 |
13 | /**OVERVIEW of actions relate to clients
14 | * subscribe
15 | * unsubscribe
16 | * message (channel, message already set, take in userid)
17 | * addClient
18 | * handleInput (works for channel or message)
19 | * setClient (changes the current client that we're working on)
20 | * each action is exported, has a type and payload
21 | */
22 |
23 | /**TODO later: Add a cloneClient action */
24 |
25 | export const subscribe = (messageData) => ({
26 | type: types.SUBSCRIBE,
27 | payload: messageData
28 | });
29 |
30 | export const unsubscribe = (messageData) => ({
31 | type: types.UNSUBSCRIBE,
32 | payload: messageData
33 | });
34 |
35 |
36 | export const publishMessage = (messageData) => ({
37 | type: types.PUBLISH_MESSAGE,
38 | payload: messageData
39 | })
40 |
41 | export const receivedMessage = (messageData) => ({
42 | type: types.RECEIVED_MESSAGE,
43 | payload: messageData,
44 | });
45 |
46 | export const addClient = (pubOrSub) => ({
47 | type: types.ADD_CLIENT,
48 | payload: pubOrSub,
49 | });
50 |
51 | export const setClient = (clientID) => ({
52 | type: types.SET_CLIENT,
53 | payload: clientID
54 | });
55 |
56 | //input will be an object that property in state that needs to be updated and the corresponding new value
57 | //ex payload could be {property: 'message', value: 'hello'} --> this would be used to reset message in state to hello
58 | export const handleClientInput = (payload) => ({
59 | type: types.HANDLE_CLIENT_INPUT,
60 | payload: payload
61 |
62 | });
63 |
64 | export const cloneClient = (number) => ({
65 | type: types.CLONE_CLIENT,
66 | payload: number,
67 | });
--------------------------------------------------------------------------------
/__tests__/supertest.js:
--------------------------------------------------------------------------------
1 | const request = require('supertest');
2 |
3 |
4 | const server = 'http://localhost:3000';
5 |
6 |
7 |
8 |
9 |
10 | describe('Route integration', () => {
11 | describe('/', () => {
12 | describe('GET', () => {
13 | it('responds with 200 status and text/html content type', () => {
14 | return request(server)
15 | .get('/')
16 | .expect('Content-Type', /text\/html/)
17 | .expect(200);
18 | })
19 | })
20 |
21 | });
22 | describe('/static', () => {
23 | it('responds with 200 status when static resource requested', () => {
24 | return request(server)
25 | .get('/static/RPS_View_logo.png')
26 | .expect(200)
27 | });
28 | });
29 |
30 | /**NOTE - need to add dependency injection -- this route depends on open redis-server */
31 | describe('/menu/connect', () => {
32 | it('responds with 200 and type json when connect is requested to an empty port', () => {
33 | //test any port
34 | //test '' -- will work if redis-server is running on port 6379
35 | return request(server)
36 | .post('/menu/connect')
37 | .send({port: ''})
38 | .expect('Content-Type', /json/)
39 | .expect(200)
40 |
41 | });
42 | it('responds with 200 and type json when connect is requested to default port', () => {
43 | return request(server)
44 | .post('/menu/connect')
45 | .send({port: '6379'})
46 | .expect('Content-Type', /json/)
47 | .expect(200)
48 |
49 | });
50 | });
51 |
52 | describe('/client/unsubscribe', () => {
53 | it('returns a 400 status and an "undefined input" message when input is undefined', () => {
54 | const req = {
55 | body: {
56 | clientId: '1',
57 | channelName: undefined
58 | }
59 | };
60 | return request(server)
61 | .post('/client/unsubscribe')
62 | .send(req)
63 | .expect('undefined input')
64 | .expect(400)
65 |
66 | })
67 | })
68 | })
--------------------------------------------------------------------------------
/__tests__/errorReducerTest.js:
--------------------------------------------------------------------------------
1 | /**
2 | * ************************************
3 | *
4 | * @module test for reducers
5 | * @author Elise and Lara
6 | * @date 12/5
7 | * @description unit tests for reducers
8 | *
9 | * ************************************
10 | */
11 |
12 |
13 | import subject from '../client/reducers/errorReducer'
14 | //test error Reducer to ensure that error handler && clear error correctly update reducer state
15 |
16 | describe('Error Reducer', () => {
17 | let state;
18 |
19 | //reset state using beforeEach
20 | beforeEach(() => {
21 | state = {
22 | errorMessage: ''
23 | }
24 | })
25 |
26 | describe('test action.type CLEAR_ERROR', () => {
27 | //initialize mock action obj
28 | const action = {type : 'CLEAR_ERROR'}
29 | it('should clear the errorMessage in state', () => {
30 | state.errorMessage = "test error";
31 | const newState = subject(state, action);
32 | expect(newState.errorMessage).toBe('');
33 | })
34 | //check to see if new copy of state is returned (not the original state object)
35 | it ('should return new state object', () => {
36 | const newState = subject(state, action);
37 | expect(newState).not.toBe(state);
38 | })
39 | })
40 |
41 | describe('test action.type ERROR_HANDLER', () => {
42 | //check that if we give the function a payload, it replaces the errorMessage with the new payload. This should work if original errorMessage is an empty string or a previous message
43 | //test empty string scenario
44 | const action = {type: 'ERROR_HANDLER', payload: 'test message'};
45 | it('should add new errorMessage if previous is empty string', () => {
46 | const newState = subject(state, action);
47 | expect(newState.errorMessage).toBe('test message');
48 | })
49 | //test previous errorMessage is string scenario
50 | it('should add new errorMessage if previous is string with characters', () => {
51 | state.errorMessage = "some previous error";
52 | const newState = subject(state, action);
53 | expect(newState.errorMessage).toBe('test message');
54 | })
55 | })
56 | })
--------------------------------------------------------------------------------
/client/containers/App.jsx:
--------------------------------------------------------------------------------
1 | /**
2 | * ************************************
3 | *
4 | * @module App.jsx
5 | * @author All
6 | * @date
7 | * @description renders React app
8 | *
9 | * ************************************
10 | */
11 |
12 | import React, {Component} from 'react';
13 | import { connect } from "react-redux";
14 | import '../styles/styles.scss';
15 | import ClientMenu from './ClientMenu.jsx';
16 | import ChannelContainer from './ChannelContainer.jsx';
17 | import ClientWindow from './ClientWindow.jsx';
18 | import NavBar from './NavBar.jsx';
19 | import img from '../../static/RPS_View_logo.png';
20 | import ErrorBox from './ErrorBox.jsx';
21 | import * as middleware from '../actions/middleware.js';
22 |
23 | const URL = 'ws://localhost:3030';
24 |
25 |
26 | const mapDispatchToProps = (dispatch) => ({
27 | socketReceivedMessage: (stateObj) => {
28 | dispatch(middleware.socketReceivedMessage(stateObj))
29 | },
30 | })
31 |
32 | class App extends Component {
33 | constructor (props) {
34 | super(props);
35 | this.ws = new WebSocket(URL)
36 | }
37 |
38 |
39 | componentDidMount(){
40 | this.ws.onopen = () => {
41 | this.ws.onmessage = (event) => {
42 | const messages = JSON.parse(event.data);
43 | this.props.socketReceivedMessage(messages);
44 | };
45 | };
46 | }
47 |
48 | componentWillUnmount(){
49 | this.ws.close();
50 | }
51 |
52 | render(){
53 | return (
54 | <>
55 |
56 | {/*
57 | {/* logo here later */}
58 | {/*
*/}
59 | {/*
*/}
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 | >
75 |
76 | )
77 |
78 | }
79 | }
80 |
81 | export default connect(null, mapDispatchToProps)(App);
82 |
--------------------------------------------------------------------------------
/client/components/MessageBox.jsx:
--------------------------------------------------------------------------------
1 | /**
2 | * ************************************
3 | *
4 | * @module MessageBox.jsx
5 | * @author Lara, Elise
6 | * @date
7 | * @description
8 | *
9 | * ************************************
10 | */
11 |
12 | import React from 'react';
13 |
14 | const MessageBox = (props) => {
15 | //determine whether type is "received" or "published"
16 |
17 | let theDate = props.timestamp.substr(0, 15);
18 | let theTime = props.timestamp.substr(16, 8);
19 |
20 | if (props.type === "received") {
21 | return (
22 |
23 |
RECEIVED
24 |
on {props.channel}
25 |
on {theDate} at {theTime}
26 |
"{props.message}"
27 |
28 | )
29 | }
30 | if (props.type === "published") {
31 | return (
32 |
33 |
PUBLISHED
34 |
on {props.channel}
35 |
on {theDate} at {theTime}
36 |
"{props.message}"
37 |
38 | )
39 | }
40 | if (props.type === "subscribed") {
41 | return (
42 |
43 |
SUBSCRIBED
44 |
to {props.channel}
45 |
on {theDate} at {theTime}
46 |
47 | )
48 | }
49 | if (props.type === "unsubscribed") {
50 | return (
51 |
52 |
UNSUBSCRIBED
53 |
from {props.channel}
54 |
on {theDate} at {theTime}
55 |
56 | )
57 | }
58 |
59 | }
60 |
61 | export default MessageBox;
--------------------------------------------------------------------------------
/webpack.config.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 | const MiniCssExtractPlugin = require('mini-css-extract-plugin');
3 | const webpack = require("webpack");
4 |
5 | const mode = process.env.NODE_ENV;
6 |
7 | module.exports = {
8 | entry: path.resolve(__dirname, './client/index.js'),
9 | mode: process.env.NODE_ENV,
10 | output: {
11 | filename: 'bundle.js',
12 | path: path.resolve(__dirname, './build'),
13 | },
14 | //dev
15 | devServer: {
16 | publicPath: '/build/', //matches the path for the production output
17 | proxy: {
18 | '/**': {
19 | target: 'http://localhost:3000' //will this affect web socket ports?
20 | }
21 | }
22 | },
23 | module: {
24 | rules: [
25 | // list of rules include objects with test and use properties
26 | //babel processes jsx files
27 | {
28 | test: /.(js|jsx)$/,
29 | exclude: /node_modules/,
30 | use: {
31 | loader: 'babel-loader',
32 | options: {
33 | //preset-env is overall environment, passing react narrows down scope of environment for transpilations
34 | presets: ['@babel/preset-env', '@babel/preset-react'],
35 | plugins: ['@babel/plugin-transform-runtime', '@babel/transform-async-to-generator'],
36 | }
37 | }
38 | },
39 | //image loaders
40 | {
41 | test: /\.(png|jpe?g|gif)$/i,
42 | exclude: /gifs/,
43 | // loader: "file-loader?name=/static/RPS_View_logo.png",
44 | use: [
45 | {
46 | loader: 'url-loader',
47 | options: {
48 | // outputPath: 'static',
49 | limit: false,
50 | }
51 | }]
52 | },
53 | //style loaders
54 | {
55 | test: /\.s[ac]ss$/i,
56 | use: [
57 | // Creates `style` nodes from JS strings (third)
58 | 'style-loader',
59 | // Translates CSS into CommonJS (second)
60 | 'css-loader',
61 | // Compiles Sass to CSS (first)
62 | 'sass-loader',
63 | ],
64 | },
65 | //extracts css for faster loading
66 | {
67 | test: /\.css$/i,
68 | use: [MiniCssExtractPlugin.loader, 'css-loader'],
69 | },
70 | ]
71 | },
72 | resolve: {
73 | extensions: ['.js', '.jsx'],
74 | },
75 | plugins: [
76 | new MiniCssExtractPlugin(),
77 | new webpack.DefinePlugin({
78 | 'process.env.NODE_ENV': JSON.stringify(mode),
79 | })
80 |
81 | ]
82 | };
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "rpsview",
3 | "version": "1.0.0",
4 | "description": "Developer tool to visualize redis pub sub clients and track messages.",
5 | "main": "main.js",
6 | "scripts": {
7 | "build": "cross-env NODE_ENV=production webpack",
8 | "start": "concurrently \"cross-env NODE_ENV=production node server/server.js\"",
9 | "dev": "concurrently \"cross-env NODE_ENV=development webpack serve\" \"nodemon server/server.js\"",
10 | "test": "jest --verbose --detectOpenHandles",
11 | "start-electron": "concurrently \"cross-env NODE_ENV=production node server/server.js\" \"npm run electron\"",
12 | "electron": "electron ."
13 | },
14 | "repository": {
15 | "type": "git",
16 | "url": "git+https://github.com/elisebare/RLBF.git"
17 | },
18 | "author": "",
19 | "license": "ISC",
20 | "bugs": {
21 | "url": "https://github.com/elisebare/RLBF/issues"
22 | },
23 | "homepage": "https://github.com/elisebare/RLBF#readme",
24 | "devDependencies": {
25 | "@babel/core": "^7.12.3",
26 | "@babel/plugin-proposal-class-properties": "^7.12.1",
27 | "@babel/plugin-transform-async-to-generator": "^7.12.1",
28 | "@babel/plugin-transform-runtime": "^7.12.1",
29 | "@babel/preset-env": "^7.12.1",
30 | "@babel/preset-react": "^7.12.5",
31 | "babel-loader": "^8.2.1",
32 | "concurrently": "^5.3.0",
33 | "cross-env": "^7.0.2",
34 | "css-loader": "^5.0.1",
35 | "file-loader": "^6.2.0",
36 | "jest": "^26.6.3",
37 | "mini-css-extract-plugin": "^1.3.1",
38 | "nodemon": "^2.0.6",
39 | "redis-mock": "^0.55.0",
40 | "redux-devtools-extension": "^2.13.8",
41 | "sass-loader": "^10.1.0",
42 | "serve": "^11.3.2",
43 | "style-loader": "^2.0.0",
44 | "supertest": "^6.0.1",
45 | "url-loader": "^4.1.1",
46 | "webpack": "^5.4.0",
47 | "webpack-cli": "^4.2.0",
48 | "webpack-dev-server": "^3.11.0"
49 | },
50 | "dependencies": {
51 | "@babel/runtime": "^7.12.5",
52 | "babel-jest": "^26.6.3",
53 | "body-parser": "^1.19.0",
54 | "dotenv": "^8.2.0",
55 | "electron": "^11.0.3",
56 | "express": "^4.17.1",
57 | "ioredis": "^4.19.2",
58 | "mongodb": "^3.6.3",
59 | "react": "^17.0.1",
60 | "react-dom": "^17.0.1",
61 | "react-redux": "^7.2.2",
62 | "redux": "^4.0.5",
63 | "redux-devtools-extension": "^2.13.8",
64 | "redux-thunk": "^2.3.0",
65 | "regenerator-runtime": "^0.13.7",
66 | "sass": "^1.29.0",
67 | "wait-on": "^5.2.0",
68 | "ws": "^7.4.0"
69 | },
70 | "babel": {
71 | "presets": [
72 | "@babel/preset-env",
73 | "@babel/preset-react"
74 | ],
75 | "plugins": [
76 | "@babel/plugin-proposal-class-properties"
77 | ]
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/client/containers/ClientMenu.jsx:
--------------------------------------------------------------------------------
1 | /**
2 | * ************************************
3 | *
4 | * @module ClientMenu.jsx
5 | * @author Lara, Elise
6 | * @date
7 | * @description stateful container, has access to clientReducers
8 | * clientMenu -- loops through each client, gets custom style & renders passing custom style as prop
9 | * render individual client cards - need currClient and clients from client clientReducer
10 | *
11 | * ************************************
12 | */
13 |
14 | import React, { Component } from 'react';
15 | import { connect } from 'react-redux';
16 | //import child components
17 | import ClientCard from '../components/ClientCard.jsx';
18 | //import any actions we use
19 | import * as actions from '../actions/clientActions.js';
20 |
21 | //mapState
22 | const mapStateToProps = (state) => {
23 | const { message, currClient, channel, clients } = state.client;
24 |
25 | return ({
26 | message,
27 | currClient,
28 | channel,
29 | clients,
30 | selectedChannel: state.channels.selectedChannel
31 | })
32 | };
33 |
34 | //mapDispatch
35 | const mapDispatchToProps = dispatch => ({
36 | setClient: (clientId) => {
37 | dispatch(actions.setClient(clientId))
38 | }
39 | })
40 |
41 |
42 | //class ClientMenu
43 | class ClientMenu extends Component{
44 | constructor(props){
45 | super(props)
46 | }
47 | //renders cards
48 | render(){
49 | const clients = {pubs:[], subs:[]};
50 | let pub
51 | let sub
52 | for (let clientId in this.props.clients) {
53 |
54 | if(this.props.clients[clientId].type === "subscriber"){
55 | sub = "Subscribers";
56 | clients.subs.push(
63 | )
64 | }else if (this.props.clients){
65 | pub = "Publishers";
66 | clients.pubs.push(
73 | )
74 | }
75 |
76 | }
77 | return (
78 |
79 |
80 |
81 |
Clients
82 |
83 |
84 | {pub === 'Publishers' &&
85 |
86 |
{pub}
87 | }
88 | {clients.pubs}
89 |
90 | {sub === 'Subscribers' &&
91 |
92 |
{sub}
93 | }
94 | {clients.subs}
95 |
96 |
97 |
98 | )
99 | }
100 | }
101 |
102 |
103 |
104 |
105 | //use connect to connect mapState, etc.
106 | export default connect(mapStateToProps, mapDispatchToProps)(ClientMenu);
--------------------------------------------------------------------------------
/client/containers/PublisherActions.jsx:
--------------------------------------------------------------------------------
1 | /**
2 | * ************************************
3 | *
4 | * @module ClientActionBar.jsx
5 | * @author
6 | * @date
7 | * @description Stateful component that handles subscribe, unsubscribe, message, addClient
8 | *
9 | * ************************************
10 | */
11 |
12 | import React, { Component } from 'react';
13 | import { connect } from 'react-redux';
14 | //import actions
15 | import * as actions from '../actions/clientActions.js';
16 | import * as middleware from '../actions/middleware.js';
17 |
18 |
19 | //mapstate
20 | const mapStateToProps = (state) => ({
21 | currClient: state.client.currClient,
22 | channels: state.channels.channelList,
23 | message: state.client.message,
24 | channel: state.client.channel,
25 | selectedAction: state.client.selectedAction,
26 | client: state.client.clients[state.client.currClient],
27 | });
28 |
29 | //map dispatch
30 | const mapDispatchToProps = (dispatch) => ({
31 | //handleClientInput => handles messages and channels
32 | handleClientInput: (payload) => dispatch(actions.handleClientInput(payload)),
33 |
34 | //get handleGoClick
35 | handleGoClick: (selectedAction) => dispatch(middleware.handleGoClick(selectedAction))
36 | });
37 |
38 | class PublisherActions extends Component{
39 | constructor(props){
40 | super(props);
41 | // this.handleGoClick = this.handleGoClick.bind(this);
42 | }
43 |
44 | componentDidMount(){
45 | this.props.handleClientInput(
46 | {property: 'selectedAction', value: 'addMessage'}
47 | );
48 | }
49 |
50 | render(){
51 |
52 | //create arr of option value elements
53 | let channels = this.props.channels;
54 |
55 | let channelsArray = [];
56 |
57 |
58 | channels.forEach((channel, i) => {
59 | channelsArray.push(
{channel.name} )
60 | })
61 |
62 | return (
63 |
64 | {/* two drop downs, input, and button */}
65 | {/* dropdown menu to select channel */}
66 | this.props.handleClientInput(
69 | {property: 'channel', value: e.target.value}
70 | )}
71 | >
72 | Choose Channel
73 | {channelsArray}
74 |
75 |
76 |
77 |
78 | this.props.handleClientInput(
84 | {property: 'message', value: e.target.value}
85 | )}/>
86 |
87 | {this.props.handleGoClick({
91 | selectedAction: "addMessage",
92 | currClient: this.props.currClient,
93 | message: this.props.message,
94 | channel: this.props.channel
95 | })}}>
96 | Publish Message
97 |
98 |
99 |
100 | )
101 | }
102 | }
103 |
104 | export default connect(mapStateToProps, mapDispatchToProps)(PublisherActions);
105 |
--------------------------------------------------------------------------------
/server/server.js:
--------------------------------------------------------------------------------
1 | /**
2 | * ************************************
3 | *
4 | * @module server
5 | * @author Mark, Joe
6 | * @date 11/18
7 | * @description Main entry point for backend. uses express to connect to routers which use controller middleware.
8 | *
9 | * ************************************
10 | */
11 | // const db = require('./models/model')
12 | const express = require('express');
13 | const path = require('path');
14 | const bodyParser = require('body-parser');
15 | const WebSocket = require('ws');
16 |
17 | const socketServer = new WebSocket.Server({port:3030});
18 |
19 | // Register event for client connection
20 | socketServer.on('connection', function connection(ws) {
21 |
22 | // broadcast on web socket when receving a Redis PUB/SUB Event
23 |
24 | //websocket receives message from front end
25 | //accept an array of id's (run foreach)
26 | ws.on('message',(message)=>{
27 |
28 | message = JSON.parse(message)
29 |
30 | //if message is array, this is a set of clients to add,
31 | //add each of them
32 | if (Array.isArray(message)){
33 | for (let el of message) {
34 |
35 | subObj[el.clientId].on('message', function(channel, message){
36 |
37 | let sendId = el.clientId;
38 | socketServer.clients.forEach(client=>{
39 | // this.options.name = "yo"
40 |
41 | if(client.readyState === WebSocket.OPEN){
42 | client.send(JSON.stringify({message, channel, clientId: this.clientId}));
43 | }
44 | });
45 | });
46 | };
47 | }
48 | //single client
49 | else {
50 | subObj[message.clientId].on('message', function(channel, message){
51 |
52 | let sendId = message.clientId;
53 | socketServer.clients.forEach(client=>{
54 |
55 | if(client.readyState === WebSocket.OPEN){
56 | client.send(JSON.stringify({message, channel, clientId:this.clientId}));
57 | }
58 | })
59 |
60 | })
61 |
62 | }
63 |
64 | });
65 |
66 | ws.on("close", function(){
67 | for(let key in subObj){
68 |
69 | subObj[key].quit()
70 | delete subObj[key]
71 | }
72 | })
73 |
74 | });
75 |
76 |
77 | const app = express();
78 |
79 | const menuRouter = require('./routes/menuRouter');
80 | const clientRouter = require('./routes/clientRouter');
81 | const { subObj } = require('./controllers/menuController');
82 |
83 |
84 | //handle parsing request body
85 | app.use(bodyParser.json());
86 | app.use(bodyParser.urlencoded({ extended: true }));
87 |
88 | //serve index
89 | app.use('/static', express.static(path.resolve(__dirname,'../static')));
90 |
91 | app.get('/', (req, res) => {
92 | res.set({ 'Content-Type': 'text/html; charset=utf-8' })
93 | .sendFile(path.resolve(__dirname, '../index.html'));
94 | })
95 |
96 | //serve static from webpack build folder or on webpack dev the publicPath /build
97 | app.use('/build', express.static(path.resolve(__dirname, '../build')));
98 |
99 | app.use('/menu', menuRouter);
100 | app.use('/client', clientRouter);
101 |
102 |
103 | //404
104 | app.use('/', (req, res) => {
105 | console.log('Bad URL');
106 | res.sendStatus(404);
107 | })
108 |
109 | //listen on port 3000
110 | app.listen(3000, () => {
111 | console.log('listening on port 3000')
112 | })
113 |
114 |
115 |
116 |
117 |
--------------------------------------------------------------------------------
/client/reducers/channelsReducer.js:
--------------------------------------------------------------------------------
1 | /**
2 | * ************************************
3 | *
4 | * @module channelReducer
5 | * @author Mark, Joe
6 | * @date 11/16.
7 | * @description reducer for market data
8 | *
9 | * ************************************
10 | */
11 |
12 | import * as types from "../constants/actionTypes";
13 |
14 | const initialState = {
15 | selectedChannel: null,
16 | totalChannels : 0,
17 | channelList : [],
18 | port: null,
19 | };
20 |
21 | const channelsReducer = (state = initialState, action) => {
22 | let channelList;
23 |
24 | switch(action.type){
25 | //add channel
26 | case types.ADD_CHANNEL:
27 | if (!action.payload) return state;
28 | //create a new channel object
29 | let newChannel = {
30 | //connect with ws and redis?
31 | name : action.payload,
32 | }
33 |
34 | // check if the new channel name is already an existing channel, if so, return unaltered state to avoid repetition
35 | if (state.channelList.some(el => {
36 | return el.name === newChannel.name;
37 | })) return state;
38 |
39 | channelList = state.channelList.slice();
40 | channelList.push(newChannel);
41 |
42 | return{
43 | ...state,
44 | totalChannels : state.totalChannels + 1,
45 | channelList
46 | }
47 |
48 |
49 |
50 | //delete channel
51 | case types.DELETE_CHANNEL:
52 | //create new array
53 | //iterate through old array
54 | //add elements that arent the delete name on to new array
55 | //decrease total channels
56 | //update state
57 | let updatedArr = [];
58 | let currentList = state.channelList;
59 | currentList.forEach(ele => {
60 | if(ele.name !== action.payload) {
61 | updateArr.push(ele)
62 | }
63 | })
64 | return {
65 | ...state,
66 | totalChannels : state.totalChannels -1,
67 | channelList : updatedArr
68 | }
69 |
70 | case types.SELECT_CHANNEL:
71 | let selected;
72 | if(action.payload === state.selectedChannel) selected = null;
73 | else selected = action.payload;
74 | return{
75 | ...state,
76 | selectedChannel : selected
77 | }
78 |
79 | //add channel subscribers
80 | //delete channel subscribers
81 | //update channel messages
82 |
83 | //case portConnected
84 | case types.PORT_CONNECTED:
85 | let channelList = state.channelList.slice();
86 | let totalChannels = state.totalChannels;
87 | action.payload.channels.forEach(el=>{
88 | if(el === "sup"){
89 |
90 | }else{
91 | let newChannel = {
92 | name: el
93 | }
94 | channelList.push(newChannel)
95 | totalChannels += 1;
96 | }
97 | })
98 | return{
99 | ...state,
100 | port: action.payload.port || '6379',
101 | totalChannels,
102 | channelList
103 | }
104 |
105 | default:
106 | return state;
107 | }
108 |
109 | }
110 |
111 | export default channelsReducer;
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # RPS View - Beta version
2 |
3 | A web or desktop application to assist in visualization and monitoring of redis pub-sub clients. **Note: If you'd like to work on the electron bundling, move to the electron-staging branch.
4 |
5 |
6 | ### Problem
7 |
8 | Testing your Redis pub sub clients in development requires multiple redis cli clients. Our tool allows you to create publishers and subscribers and channels in one place and monitor receipt of messages.
9 |
10 | ### App description
11 |
12 | This app allows you to track redis pub / sub messages with dummy clients. The app uses
13 |
14 | - Express server
15 | - Web sockets on 3030 to send messages to clients
16 | - Redis IO to connect and subscribe clients
17 | - Redux/React front end to manage state
18 | - Redux Thunk middleware for fetch requests, asynchronous actions, and web socket message handling
19 | - Jest & Supertest for unit tests and integration testing
20 | - Electron for desktop cabailities
21 |
22 | ### Getting started
23 |
24 | #### To run the application
25 |
26 | - [ ] `npm install`
27 | - [ ] Redis server must be open. Run `redis-server` if you don't already have a server up for your project.
28 | - [ ] `npm run build` prepares the webpack bundle and only needs to run once
29 | - [ ] `npm run start` runs the web app on local host 3000.
30 | - [ ] You can run `npm run start-electron` to run an electron application.
31 | - [ ] For development mode (hot reloading), run `npm run dev`. This will use proxy server 8080 in addition to port 3000.
32 |
33 | #### Important Setup Notes
34 |
35 | - Express - You must be running an Express server on port 3000 for the application to work
36 | - Redis-server - You must run a redis server - either in your application or on the command line to connect the app. Make sure you connect to the same port that your redis-server is running on. If no server is specified, the application will attempt to create a redis client on port 6379.
37 |
38 | ### Demonstration
39 |
40 | #### Connecting
41 | The first thing you should do on RPS View is connect to the port on which your redis server is running. If no port is selected, the “Connect” Button will launch a connection attempt on the default port for redis, ‘6379’.
42 |
43 | 
44 |
45 | #### Adding channels and clients
46 | After you’ve connected, you should see any existing channels in your redis-server instance. You can add clients from there, and subscribe them to the channels.
47 |
48 | 
49 |
50 |
51 | #### Prublishing messages and viewing logs
52 | You can publish from the terminal or your application, and you should see the new messages propagate in the message log of the respective clients.
53 |
54 |
55 |
56 | ### How to contribute
57 |
58 | - We're an open source project, and we're open to new contributions.
59 | - Add an issue to the github issues before starting a new feature.
60 | - Make pull requests to staging with issue referenced in the PR.
61 |
62 | ### Contact
63 |
64 | Website: [http://www.rpsview.com/](http://www.rpsview.com/)
65 |
66 | Github: [https://github.com/oslabs-beta/RPS-View](https://github.com/oslabs-beta/RPS-View)
67 |
68 | ### Team
69 |
70 | - Elise Bare [@Github](https://github.com/elisebare) [@LinkedIn](https://www.linkedin.com/in/elisebare/)
71 | - Joe Cheng [@Github](https://github.com/EtOh200) [@LinkedIn](https://www.linkedin.com/in/josephcheng-y/)
72 | - Lara Nichols [@Github](https://github.com/Lol-Whut) [@LinkedIn](https://www.linkedin.com/in/lara-nichols-ba822279/)
73 | - Mark Washkewicz [@Github](https://github.com/Mark-Waskewicz) [@LinkedIn](https://www.linkedin.com/in/mark-washkewicz/)
74 |
75 | ### License
76 |
77 | Distributed under the MIT License.
78 |
79 |
80 |
--------------------------------------------------------------------------------
/client/containers/ClientWindow.jsx:
--------------------------------------------------------------------------------
1 | /**
2 | * ************************************
3 | *
4 | * @module ClientWindow.jsx
5 | * @author Lara, Elise
6 | * @date
7 | * @description stateful component with access to clientReducers actions
8 | * subscribe,
9 | * unsubscribe,
10 | * message,
11 | * handleClientInput
12 | *
13 | * access to state (client)
14 | * currClient ( only displays if currClient is not null)
15 | *
16 | * access to state (channelss)
17 | * currClient ( only displays if currClient is not null)
18 | *
19 | * ************************************
20 | */
21 |
22 | import React, { Component } from 'react';
23 | import { connect } from 'react-redux';
24 | //import actions
25 | import * as actions from '../actions/clientActions.js';
26 | //import child components
27 | import MessageLogDisplay from '../components/MessageLogDisplay.jsx';
28 | import SubscribedChannels from '../components/SubscribedChannelsDisplay.jsx';
29 | import PublisherActions from './PublisherActions.jsx';
30 | import SubscriberActions from './SubscriberActions.jsx';
31 |
32 | //mapstate
33 | const mapStateToProps = (state) => ({
34 | //either null or a clientId, says what to display
35 | currClient: state.client.currClient,
36 | //clients will be the object for the currClient only
37 | clients: state.client.clients[state.client.currClient],
38 | });
39 |
40 | //mapDispatch
41 | const mapDispatchToProps = dispatch => ({
42 | setClient: (clientId) => {
43 | dispatch(actions.setClient(clientId))
44 | }
45 | })
46 |
47 | //create class
48 | class ClientWindow extends Component {
49 | constructor(props) {
50 | super(props);
51 | }
52 |
53 |
54 | render() {
55 | if (this.props.currClient !== null) {
56 |
57 | //actions will be the component that renders based on whether pub or sub
58 | //channelDisplayMessage will be the heading that shows before the Channels Display component rendered
59 | let actions;
60 | let channelDisplayMessage;
61 | let heading;
62 | if (this.props.clients.type === "publisher") {
63 | actions =
;
64 | channelDisplayMessage = 'Publishes to channels';
65 | heading = "Publisher";
66 | }
67 | else {
68 | actions =
;
69 | channelDisplayMessage = 'Subscribed channels';
70 | heading = "Subscriber";
71 | }
72 |
73 |
74 | return (
75 |
76 |
77 |
Client {this.props.currClient}: {heading}
78 | {this.props.setClient(this.props.currClient)}}>X
79 |
80 |
81 |
82 |
Recent Messages
83 |
87 |
88 |
89 |
90 |
{channelDisplayMessage}
91 |
92 |
93 |
94 |
95 |
96 | {actions}
97 |
98 |
99 |
100 | )
101 | }
102 | return (<>>)
103 | }
104 | }
105 | //render MessageLogDisplay
106 |
107 |
108 |
109 | export default connect(mapStateToProps, mapDispatchToProps)(ClientWindow);
--------------------------------------------------------------------------------
/__tests__/clientReducerTest.js:
--------------------------------------------------------------------------------
1 | /**
2 | * ************************************
3 | *
4 | * @module test for client reducers
5 | * @author Elise and Lara
6 | * @date 12/5
7 | * @description unit tests for client reducers
8 | *
9 | * ************************************
10 | */
11 |
12 | import subject from '../client/reducers/clientReducer';
13 |
14 | describe('client reducer', () => {
15 | let state;
16 | beforeEach(() => {
17 | state = {
18 | message: '',
19 | selectedAction: '',
20 | currClient: null,
21 | nextClientId: 1,
22 | channel: '',
23 | clients: {},
24 | /**will have the structure {id:
25 | * {
26 | * log: [{channel: str, timestamp: ISO string (MIDDLEWARE), message: str}],
27 | * channels: [channels]
28 | * type: 'publisher' OR 'subscriber'
29 | * }
30 | * */
31 | }
32 | });
33 |
34 | describe('default state given an undefined or empty action', () => {
35 | it('when action type is invalid string, should return original state', () => {
36 | const action = {type: "bad string"};
37 | const newState = subject(state, action);
38 | expect(newState).toBe(state);
39 | });
40 | it('when action type is undefined, should return original state', () => {
41 | const action = {type: undefined};
42 | const newState = subject(state, action);
43 | expect(newState).toBe(state);
44 | });
45 | });
46 |
47 | //subscribe tests
48 | describe('test action.type SUBSCRIBE', () => {
49 | //change initial state to reflect situation before a client subscribes to a channel
50 | let newState;
51 | beforeEach(() => {
52 | state.selectedAction = 'subscribe';
53 | state.currClient = 1;
54 | state.nextClientId = 2;
55 | state.channel = 'test';
56 | state.clients = {
57 | '1': {
58 | log: [],
59 | channels: [],
60 | type: 'subscriber'
61 | }
62 | }
63 | //assign newState
64 | newState = subject(state, action);
65 | });
66 | //set action variable
67 | const action = {type: 'SUBSCRIBE'};
68 |
69 | //test to see if state is altered
70 | it('new state should be different from previous state', () => {
71 | expect(newState).not.toBe(state);
72 | })
73 | //check to see if message log length increases by 1
74 | it('should increase length of message log array by 1', () => {
75 | expect(newState['clients']['1']['log'].length).toBe(1);
76 | })
77 | //check to see if channel appears on client's subscribed channels list
78 | it('new channel should appear in channels array', () => {
79 | expect(newState['clients']['1']['channels'][0]).toBe('test');
80 | })
81 | })
82 |
83 | //publish
84 | describe('test action.type PUBLISH_MESSAGE', () => {
85 | beforeEach(() => {
86 | state.selectedAction = 'publish';
87 | state.currClient = 1;
88 | state.nextClientId = 2;
89 | state.channel = 'test';
90 | state.message = 'a message'
91 | state.clients = {
92 | '1': {
93 | log: [],
94 | channels: [],
95 | type: 'subscriber'
96 | }
97 | }
98 |
99 | });
100 |
101 | const action = {type: 'PUBLISH_MESSAGE', payload: '10-20-2000'};
102 | it('resets message', () => {
103 | const {message} = subject(state, action);
104 | expect(message).toBe('');
105 | });
106 |
107 | it('adds a message to the log', () => {
108 | const {clients} = subject(state, action);
109 | expect(clients["1"].log.length).toBe(1);
110 | });
111 |
112 | it('adds a message of correct type with non null values', () => {
113 | const {clients} = subject(state, action);
114 | const expectedMessage = {
115 | message: expect.any(String),
116 | timestamp: expect.any(String),
117 | type: 'published',
118 | channel: expect.any(String)
119 | }
120 | expect(clients["1"].log[0]).toMatchObject(expectedMessage)
121 | });
122 |
123 | it('adds a channel to the channels array if first time publishing', () => {
124 | const { clients } = subject(state, action);
125 | //does the clients object at the curr client at the channels contain 'test' in its array?
126 | expect(clients["1"].channels.includes('test')).toBe(true);
127 | })
128 | })
129 | })
--------------------------------------------------------------------------------
/server/controllers/clientController.js:
--------------------------------------------------------------------------------
1 | /**
2 | * ************************************
3 | *
4 | * @module clientController
5 | * @author Mark, Joe
6 | * @date 11/18
7 | * @description controller for client menu.
8 | *
9 | * ************************************
10 | */
11 |
12 | const {subObj, pubObj} = require('./menuController')
13 |
14 | let clientController = {};
15 |
16 |
17 | //router for client unsub to redis channel
18 | clientController.unsubscribe = (req, res, next) => {
19 |
20 | const clientId = req.body.clientId
21 | const channelName = req.body.channelName
22 | //client ID and channel name is received from the front end
23 | //double check if clientID exist in the database, if it doesn't send back error
24 |
25 | //if clientID exist, call redis to unsub
26 | //passing in the channel name, and redis should return count of channel client is subscribe to
27 | if(channelName === undefined || clientId === undefined || channelName === '' || clientId === '') return res.status(400).send('undefined input');
28 | if(subObj[clientId] === undefined) {
29 | return res.status(400).send('client does not exist');
30 | } else {
31 | subObj[clientId].unsubscribe(channelName, (error, count) => {
32 | if(error) {
33 | return res.status(400).send('unable to unsubscribe');
34 | }
35 | return res.status(200).send(clientId+ 'Successfully unsubscribed to ' + channelName + '. Cleint is now subscribed to '+ count +'channels');
36 | })
37 | }
38 | };
39 |
40 | //router for subscribe to redis channel
41 | clientController.subscribe = (req, res, next) => {
42 | const clientId = req.body.clientId
43 | const channelName = req.body.channelName
44 |
45 | //check if client exist
46 | //if clientId matches client DB
47 | //call subscribe to redis with passed in channelName
48 | //redis will return count for client subbed channel
49 |
50 | //server message is passed to the router for response
51 | if(channelName === undefined || clientId === undefined || channelName === '' || clientId === '') return res.status(400).send('undefined input');
52 | if(subObj[clientId] === undefined) {
53 |
54 | return res.status(400).send('client does not exist');
55 | } else {
56 | subObj[clientId].subscribe(channelName, (error, count) => {
57 | if(error) {
58 | return res.status(400).send('failed to subscribe');
59 | }
60 | return res.status(200).send(clientId+ 'Successfully subscribed to ' + channelName + '. Cleint is now subscribed to '+ count +'channels');
61 | })
62 | }
63 |
64 | };
65 |
66 | clientController.subscribeMany = (req, res, next) => {
67 | let clients = req.body.clients;
68 | const channels = req.body.channels; //this is an array
69 | for (let client of clients) {
70 | clientId = client.clientId;
71 | for (let channel of channels) {
72 | if (!channel) {
73 | return res.status(400).send('undefined input')
74 | }
75 | if (!subObj[clientId]) {
76 | return res.status(400).send('client does not exist')
77 | }
78 | subObj[clientId].subscribe(channel, (error, count) => {
79 | if(error) {
80 | return res.status(400).send('failed to subscribe');
81 | }
82 | })
83 | }
84 | }
85 |
86 | return res.status(200).send('client subscribed to channels')
87 | }
88 |
89 | //router for client to publish on redis server
90 | clientController.publish = (req, res, next) => {``
91 | const clientId = req.body.clientId
92 | const channelName = req.body.channelName
93 | const message = req.body.message
94 | //if clientID match client DB
95 | //publish to redis using redis commands
96 |
97 | //return server message to frontend
98 | if(channelName === undefined || clientId === undefined || message === undefined || message === '' || clientId === '' || channelName === '') return res.status(400).send('undefined input');
99 | if(pubObj[clientId] === undefined) {
100 | //send fail status and message to the frontend
101 | return res.status(400).send('error, client does not exist')
102 | } else {
103 | pubObj[clientId].publish( channelName, message, (error, count) => {
104 | if(error) {
105 | return res.status(400).send('failed to publish!')
106 | }
107 | return res.status(200).send('message published to ' + channelName + '. message published to ' + count + 'channel');
108 | })
109 | }
110 | };
111 |
112 | module.exports = clientController;
--------------------------------------------------------------------------------
/client/containers/SubscriberActions.jsx:
--------------------------------------------------------------------------------
1 | /**
2 | * ************************************
3 | *
4 | * @module ClientActionBar.jsx
5 | * @author
6 | * @date
7 | * @description Stateful component that handles subscribe, unsubscribe, message, addClient
8 | *
9 | * ************************************
10 | */
11 |
12 | import React, { Component } from 'react';
13 | import { connect } from 'react-redux';
14 | //import actions
15 | import * as actions from '../actions/clientActions.js';
16 | import * as middleware from '../actions/middleware.js';
17 |
18 |
19 | //mapstate
20 | const mapStateToProps = (state) => ({
21 | currClient: state.client.currClient,
22 | channels: state.channels.channelList,
23 | message: state.client.message,
24 | channel: state.client.channel,
25 | selectedAction: state.client.selectedAction,
26 | client: state.client.clients[state.client.currClient],
27 | nextClientId: state.client.nextClientId,
28 | });
29 |
30 | //map dispatch
31 | const mapDispatchToProps = (dispatch) => ({
32 | //handleClientInput => handles messages and channels
33 | handleClientInput: (payload) => dispatch(actions.handleClientInput(payload)),
34 |
35 | //get handleGoClick
36 | handleGoClick: (selectedAction) => dispatch(middleware.handleGoClick(selectedAction)),
37 |
38 | //data in form of {num: number, nextClientId: number}
39 | fetchAddClones: (data) => dispatch(middleware.fetchAddClones(data))
40 | });
41 |
42 | class SubscriberActions extends Component{
43 | constructor(props){
44 | super(props);
45 | this.state = {
46 | num: 0,
47 | }
48 | this.handleChangeNumber = this.handleChangeNumber.bind(this);
49 | }
50 |
51 | handleChangeNumber(e) {
52 | e.preventDefault();
53 | let num = e.target.value;
54 | this.setState({
55 | ...this.state,
56 | num,
57 | })
58 | }
59 |
60 | render(){
61 |
62 | //create arr of option value elements
63 | let channels = this.props.channels;
64 |
65 | let channelsArray = [];
66 |
67 |
68 | channels.forEach((channel, i) => {
69 | channelsArray.push(
{channel.name} )
70 | })
71 |
72 | return (
73 |
74 | {/* two drop downs, input, and button */}
75 | {/* dropdown menu to select channel */}
76 | this.props.handleClientInput(
79 | {property: 'channel', value: e.target.value}
80 | )}
81 | >
82 | Choose Channel
83 | {channelsArray}
84 |
85 |
86 | {/* dropdown menu to select action */}
87 | this.props.handleClientInput(
91 | {property: 'selectedAction', value: e.target.value}
92 | )}
93 | >
94 | Choose Action
95 | Subscribe
96 | Unsubscribe
97 |
98 |
99 |
100 | {this.props.handleGoClick({
104 | selectedAction: this.props.selectedAction,
105 | currClient: this.props.currClient,
106 | channel: this.props.channel
107 | })}}>
108 | Go
109 |
110 | Number of Clones
112 | {this.handleChangeNumber(e)}}/>
119 | this.props.fetchAddClones(
121 | {
122 | num: this.state.num,
123 | channels: this.props.client.channels,
124 | nextClientId: this.props.nextClientId,
125 | type: 'subscriber',
126 | ws: this.props.ws,
127 | }
128 | )}>
129 | Clone this client
130 |
131 |
132 |
133 | )
134 | }
135 | }
136 |
137 |
138 | export default connect(mapStateToProps, mapDispatchToProps)(SubscriberActions);
139 |
--------------------------------------------------------------------------------
/client/containers/NavBar.jsx:
--------------------------------------------------------------------------------
1 | /**
2 | * ************************************
3 | *
4 | * @module NavBar.jsx
5 | * @author
6 | * @date
7 | * @description Stateful component that handles addPort, addClient, addChannel
8 | *
9 | * ************************************
10 | */
11 |
12 |
13 | import React, { Component } from "react";
14 | import { connect } from "react-redux";
15 | import * as channelActions from "../actions/channelActions";
16 | import * as errorActions from "../actions/errorActions";
17 | import * as clientActions from "../actions/clientActions.js";
18 | import * as middleware from "../actions/middleware.js";
19 | // const URL = 'ws://localhost:3030'
20 |
21 | const mapStateToProps = (state) => ({
22 | portErrorMessage: state.channels.portErrorMessage,
23 | nextClientId: state.client.nextClientId,
24 | errorMessage: state.client.errorMessage,
25 | channels: state.channels.channelList,
26 | connectPort: state.channels.port,
27 | })
28 |
29 | const mapDispatchToProps = (dispatch) => ({
30 | addChannel: (e)=>{
31 | dispatch(channelActions.addChannel(e))
32 | },
33 | // data {type, clientId}
34 | fetchAddClient: (data)=>{
35 | dispatch(middleware.fetchAddClient(data))
36 | },
37 | fetchConnect: (port) => {
38 | dispatch(middleware.fetchConnect(port))
39 | },
40 | fetchAddChannel: (channelText) => {
41 | dispatch(middleware.fetchAddChannel(channelText))
42 | },
43 | // socketReceivedMessage: (stateObj) => {
44 | // dispatch(middleware.socketReceivedMessage(stateObj))
45 | // }
46 |
47 | });
48 |
49 | class NavBar extends Component {
50 | constructor(props) {
51 | super(props)
52 | this.state = {
53 | //temp input tracking
54 | channelText: '',
55 | port: '',
56 | type: '',
57 | }
58 | }
59 |
60 |
61 | //trigger fetch request and add port to state when connect button is clicked
62 | handlePortSubmit = (event) => {
63 | event.preventDefault();
64 | this.props.fetchConnect(this.state.port);
65 | this.setState({...this.state, port: ''});
66 | }
67 |
68 | handleChannelChange = (event, key) => {
69 | this.setState({
70 | [key]: event.target.value
71 | })
72 | }
73 |
74 | handleChannelSubmit = event => {
75 | event.preventDefault();
76 | this.props.fetchAddChannel(this.state.channelText);
77 | this.props.addChannel(this.state.channelText)
78 | this.setState({
79 | ...this.state,
80 | channelText: '',
81 | })
82 | }
83 |
84 | render(){
85 | //if client state is empty string, display 'input server ip' and connect
86 | //button should fetch connect
87 | //if port state is not empty, display port number and DISCONNECT
88 | //button should refresh the page, which disconnect from server
89 | let servePort;
90 | if(this.props.connectPort === null) {
91 | servePort =
92 |
93 | this.handleChannelChange(event, 'port')}/>
98 | {/* add fetchconnect to onclick */}
99 | this.handlePortSubmit(event)}>CONNECT
103 |
104 |
105 | } else {
106 | servePort =
107 |
108 | this.handleChannelChange(event, 'port')}/>
114 | {/* add fetchconnect to onclick */}
115 | window.location.reload()}>DISCONNECT
120 |
121 |
122 | }
123 | return(
124 |
125 |
126 | {/* connect to server, require input and a submit button */}
127 | {servePort}
128 |
129 |
130 |
131 | {/* add channel, require input and a submit button
132 | connect to channel reducer */}
133 | this.handleChannelChange(event, 'channelText')} />
138 | this.handleChannelSubmit(event)}>Add Channel
142 |
143 |
144 |
145 |
146 | {/* add client, require input and a submit button
147 | connect to client reducer */}
148 | {/* */}
149 |
154 | this.handleChannelChange(e, 'type')
155 | }
156 | >
157 | Choose Client Type
158 | Publisher
159 | Subscriber
160 |
161 |
162 | {
166 | this.props.fetchAddClient(
167 | {type: this.state.type, clientId: this.props.nextClientId, ws: this.props.ws})
168 | this.setState({...this.state, type: ''});
169 | }}
170 | >
171 | Add Client
172 |
173 |
174 |
175 | )
176 | }
177 |
178 | }
179 |
180 |
181 | // export default connect(mapStateToProps, mapDispatchToProps)(NavBar)
182 | export default connect(mapStateToProps, mapDispatchToProps)(NavBar)
--------------------------------------------------------------------------------
/server/controllers/menuController.js:
--------------------------------------------------------------------------------
1 | /**
2 | * ************************************
3 | *
4 | * @module menuController
5 | * @author Mark, Joe
6 | * @date 11/18
7 | * @description controller for nav menu
8 | *
9 | * ************************************
10 | */
11 |
12 |
13 | let Redis = require('ioredis');
14 |
15 | //create variables to pass into routers and client controller
16 | //global port is the port number which the redis server is spun up on
17 | let globalPort;
18 | //subscriberObj is an object that will store all redis clients
19 | let subObj = {};
20 | let pubObj = {};
21 |
22 | //create controller object
23 | let menuController = {}
24 | //middleware to test to see if port given from front end is correct
25 | menuController.connect = (req,res,next) => {
26 |
27 | //adding default port if no port entered
28 | if(req.body.port === '') {
29 | globalPort = '6379';
30 | } else {
31 |
32 | //grab port number
33 | globalPort = req.body.port;
34 | }
35 | //use subscribe in order to test connection
36 | let redis = new Redis(globalPort)
37 | //catch connection error with try catch
38 |
39 | redis.pubsub('channels', (err, channels)=>{
40 |
41 | if(err){
42 |
43 | return res.status(400).send('failed to connect');
44 | // next();
45 | }
46 | //if no error add message connected to server
47 | return res.status(200).json({channels:channels})
48 | })
49 | redis.on('error', (err) => {
50 | redis.disconnect();
51 | })
52 |
53 | }
54 |
55 | //middle ware to add a given channel to the redis server given from global port
56 | menuController.addChannel = (req,res,next) => {
57 |
58 | if(req.body.channelName === ''){
59 | return res.status(400).send('invalid channel name');
60 | }
61 |
62 | let redis = new Redis(globalPort);
63 | //use subscribe to sudo create channel by subscribing to channel
64 | redis.subscribe(req.body.channelName, (error, count)=>{
65 | //if error attach error as res locals and continue
66 | if(error){
67 | return res.status(400).send('failed to addChannel');
68 | }
69 | //if no error add message added channel
70 |
71 | return res.status(200).send('added Channel')
72 | })
73 |
74 | }
75 |
76 | //add client to redis
77 | menuController.addClient = (req, res, next) => {
78 |
79 | //if clientId from fetch body is incorrect, send back invalid input
80 | if(req.body.clientId === undefined){
81 | return res.status(200).send('invalid inputs');
82 | }
83 |
84 |
85 | let redis = new Redis(globalPort)
86 | redis.subscribe('sup', (error, count)=>{
87 | //if error trying to add client, server is not connected
88 | if(error){
89 | return res.status(400).send('server not connected');
90 | // next();
91 | }
92 | //if no error add client
93 |
94 | //receive client id
95 | //add it to current client obj
96 | if(req.body.type === "publisher"){
97 | pubObj[req.body.clientId] = new Redis(globalPort);
98 | pubObj[req.body.clientId].clientId=req.body.clientId;
99 | }
100 | if(req.body.type === "subscriber"){
101 | subObj[req.body.clientId] = new Redis(globalPort);
102 | subObj[req.body.clientId].clientId=req.body.clientId;
103 | subObj[req.body.clientId].subscribe('sup', (error, count)=>{
104 | //if error attach error as res locals and continue
105 | if(error){
106 | return res.status(400).send('failed to connect');
107 | }
108 | })
109 | }
110 | //UPDATE. if client type is not selected(which default to empty string), default to sub.
111 | if(req.body.type === '') {
112 | subObj[req.body.clientId] = new Redis(globalPort);
113 | subObj[req.body.clientId].clientId=req.body.clientId;
114 | }
115 |
116 | return res.status(200).send('added client')
117 |
118 | })
119 | };
120 |
121 | //addCloned clients
122 | menuController.addClonedClients = (req, res, next) => {
123 | //get clients from array on req.body format [{clientId: num, type: 'publisher' OR 'subscriber'}]
124 | const clients = req.body;
125 | for (let client of clients) {
126 |
127 | let redis = new Redis(globalPort);
128 | let id = client.clientId;
129 |
130 | redis.subscribe('sup', (error, count)=>{
131 | //if error trying to add client, server is not connected
132 | if(error){
133 | return res.status(400).send('server not connected');
134 |
135 | }
136 | })
137 | //if no error add client
138 |
139 | //receive client id
140 | //add it to current client obj
141 | if(client.type === "publisher"){
142 | pubObj[client.clientId] = new Redis(globalPort);
143 | pubObj[client.clientId].clientId = client.clientId;
144 | }
145 | else if(client.type === "subscriber"){
146 | subObj[id] = new Redis(globalPort);
147 | subObj[id].clientId=id;
148 |
149 | subObj[id].subscribe('sup', (error, count)=>{
150 | //if error attach error as res locals and continue
151 | if(error){
152 | return res.status(400).send('failed to connect');
153 | }
154 | })
155 | }
156 | // //UPDATE. if client type is not selected(which default to empty string), default to sub.
157 | else if(!client.type) {
158 | subObj[client.clientId] = new Redis(globalPort);
159 | subObj[client.clientId].clientId = client.clientId;
160 | }
161 |
162 |
163 |
164 | }
165 | return res.status(200).send('added cloned clients');
166 | }
167 |
168 |
169 | //test middleware that will not be needed in production
170 | //used to test number of channels in the active redis db
171 | menuController.test = (req,res,next) => {
172 | let redis = new Redis(globalPort);
173 | redis.pubsub('channels', (err, channels) => {
174 | if (!err) {
175 | console.log('Channels:', channels); // array
176 | }
177 | return next()
178 | });
179 | }
180 |
181 | //export the controller for middleware
182 | //export global port
183 | module.exports = {menuController, globalPort, pubObj, subObj}
--------------------------------------------------------------------------------
/client/reducers/clientReducer.js:
--------------------------------------------------------------------------------
1 | /**
2 | * ************************************
3 | *
4 | * @module clientReducer.js
5 | * @author Elise and Lara
6 | * @date
7 | * @description all the reducers related to client state
8 | *
9 | * ************************************
10 | */
11 |
12 | //import types from constants
13 | import * as types from '../constants/actionTypes.js';
14 |
15 | //set up initial state for clients
16 | const initialState = {
17 | message: '',
18 | selectedAction: '',
19 | currClient: null,
20 | nextClientId: 1, //used to create a serial id for each id
21 | channel: '',
22 |
23 | clients: {},
24 | /**will have the structure {id:
25 | * {
26 | * log: [{channel: str, timestamp: ISO string (MIDDLEWARE), message: str}],
27 | * channels: [arrs]
28 | * type: 'publisher' OR 'subscriber'
29 | * }
30 | * */
31 |
32 | }
33 |
34 | const clientReducer = (state = initialState, action) => {
35 | //subscribe, unsubscribe, message, addClient
36 | //make a deep copy of state.clients, which will be used to alter state within reducers
37 | const copyClientList = JSON.parse(JSON.stringify({...state.clients}));
38 |
39 | //declare channels
40 | let channels;
41 |
42 | //declare type (used in multiple)
43 | let type;
44 |
45 | //switch cases for each reducer
46 | switch(action.type) {
47 |
48 | /**subscribe adds the channel to the client's channels array */
49 | case types.SUBSCRIBE:
50 |
51 | //assign currClient's list of channels to channels
52 | channels = copyClientList[state.currClient].channels;
53 |
54 | //if channels does not include channel
55 | //push new channel and then sort alphabetically
56 | if (!channels.includes(state.channel)) {
57 | channels.push(state.channel);
58 | channels.sort();
59 | }
60 |
61 | //initialize a new message
62 | let subMessage = {
63 | channel: state.channel,
64 | timestamp: action.payload,
65 | type: 'subscribed',
66 | message: `Subscribed to ${state.channel}`
67 | }
68 |
69 | //push new message to correct client log
70 | copyClientList[state.currClient]["log"].push(subMessage);
71 |
72 | //return state with updated clients list and reassign message to empty string
73 | return {
74 | ...state,
75 | clients: copyClientList,
76 | message: '',
77 | };
78 |
79 | /**unsubscribe removes the channel from the client's channel array*/
80 | case types.UNSUBSCRIBE:
81 | //assign currClient's list of channels to channels
82 | channels = copyClientList[state.currClient].channels;
83 |
84 | //find the index in the array that corresponds with the current channel (to be deleted)
85 | const index = channels.indexOf(state.channel);
86 |
87 | //if index is -1 -- it isn't there, do nothing
88 | if (index !== -1) {
89 | //remove one element at that index to remove the channel
90 | channels.splice(index, 1);
91 | }
92 | //initialize a new message
93 | let unsubMessage = {
94 | channel: state.channel,
95 | timestamp: action.payload,
96 | type: 'unsubscribed',
97 | message: `Unsubscribed from ${state.channel}`
98 | }
99 |
100 | //push new message to correct client log
101 | copyClientList[state.currClient].log.push(unsubMessage);
102 | //add the altered copyClientList to the state & reset message
103 | return {
104 | ...state,
105 | clients: copyClientList,
106 | message: '',
107 | };
108 |
109 | case types.PUBLISH_MESSAGE:
110 |
111 | //action.payload will be the date string
112 |
113 | //create new message using date and state components
114 | const newPubMessage = {
115 | channel: state.channel,
116 | timestamp: action.payload,
117 | type: 'published',
118 | message: state.message,
119 | }
120 |
121 | //push new message to correct client log
122 | copyClientList[state.currClient].log.push(newPubMessage);
123 | //return altered state
124 | //note: clear message
125 |
126 | //has this client pubbed to this channel before? IF NOT --> update channels
127 | if (!copyClientList[state.currClient].channels.includes(state.channel)) {
128 |
129 | copyClientList[state.currClient].channels.push(state.channel);
130 | }
131 |
132 | return {
133 | ...state,
134 | clients: copyClientList,
135 | message: '',
136 | }
137 |
138 |
139 | /** Message is dispatched after web socket receives data, adds newMessage to the client's log*/
140 | case types.RECEIVED_MESSAGE:
141 |
142 | //create messages object
143 | /**
144 | * action.payload format
145 | * {
146 | * now: TIMESTAMP from middleware,
147 | * channel: 'string',
148 | * message: 'string'
149 | * clientid: int
150 | * }
151 | */
152 | const {now, channel, message, clientId} = action.payload;
153 | if(!clientId) return state;
154 | const newMessage = {
155 | channel,
156 | timestamp: now,
157 | type: 'received',
158 | message
159 | }
160 |
161 | copyClientList[clientId].log.push(newMessage);
162 |
163 | return {
164 | ...state,
165 | clients: copyClientList,
166 | }
167 |
168 | /** Add client adds a client to the clients object */
169 | case types.ADD_CLIENT:
170 | type = action.payload;
171 |
172 | //create a new client object with an empty log and empty channels array
173 | const newClient = {type, log: [], channels: []};
174 |
175 | //add new client object to the copyOfClients object
176 | copyClientList[state.nextClientId] = newClient;
177 |
178 | //increment nextClientID from state
179 | const newNext = state.nextClientId + 1;
180 |
181 | //return updated state with incremented nextClientId and updated clients
182 | return {
183 | ...state,
184 | nextClientId: newNext,
185 | clients: copyClientList
186 | }
187 |
188 |
189 | case types.SET_CLIENT:
190 |
191 | //set newCurrent equal to the payload, which will be the ID of the new current client
192 | let newCurrent = action.payload;
193 |
194 | //if newCurrent is the same as state.currClient, reset newCurrent to null
195 | if (newCurrent === state.currClient) newCurrent = null;
196 |
197 | //return updated state with new current client
198 | return {
199 | ...state,
200 | currClient: newCurrent,
201 | selectedAction: '',
202 | message: '',
203 | channel: '',
204 | }
205 |
206 | case types.HANDLE_CLIENT_INPUT:
207 | //NOTE: payload will be object with {property: XX, value: XX}
208 | //set property equal to property on the payload obj
209 | const property = action.payload.property;
210 |
211 | //set value equal to value from the payload obj
212 | const value = action.payload.value;
213 |
214 | //return state with a key/value pair derived from property and value
215 | return {
216 | ...state,
217 | [property]: value
218 | }
219 |
220 | case types.CLONE_CLIENT:
221 | const num = action.payload;
222 | let next = state.nextClientId;
223 | let channels = copyClientList[state.currClient].channels.slice();
224 | let type = copyClientList[state.currClient].type;
225 | //iterate up to num, creating new client with same channels and type as currClient, add to copyClientList
226 | for (let i = 0; i < num; i ++) {
227 | copyClientList[next] = {log: [], channels, type}
228 | next = next + 1;
229 | }
230 | //return updated copyClientList and nextClientId
231 | return {
232 | ...state,
233 | clients: copyClientList,
234 | nextClientId: next,
235 | }
236 |
237 | //set default case
238 | default:
239 | return state;
240 | };
241 | }
242 |
243 |
244 | //export clientReducer
245 | export default clientReducer;
--------------------------------------------------------------------------------
/client/actions/middleware.js:
--------------------------------------------------------------------------------
1 | /**
2 | * ************************************
3 | *
4 | * @module middleware.js
5 | * @author
6 | * @date
7 | * @description all the middleware that dispatches actions
8 | *
9 | * ************************************
10 | */
11 |
12 | // import {createAsyncThunk} from '@reduxjs/toolkit';
13 | import * as types from '../constants/actionTypes.js';
14 | import * as errorActions from './errorActions.js';
15 | import * as clientActions from './clientActions.js';
16 | import * as channelActions from './channelActions.js';
17 |
18 | //redux thunk for handleGoClick determines which reducer case to call
19 | export const handleGoClick = (stateObj) => (dispatch) => {
20 |
21 |
22 | switch (stateObj.selectedAction){
23 | case "addMessage":
24 | dispatch(fetchMessage(stateObj));
25 | return;
26 | case "subscribe":
27 | dispatch(fetchSubscribe(stateObj));
28 | return;
29 | case "unsubscribe":
30 | dispatch(fetchUnsubscribe(stateObj));
31 | return;
32 | default:
33 | dispatch(errorActions.errorHandler('Select an action to proceed'))
34 | return;
35 | }
36 | }
37 |
38 | //add clones, runs fetch to /addClonedClients,
39 | //sends arr of client obj in req.body
40 | //each should have type && number
41 | //stateObj
42 | /**
43 | *
44 | * @param
45 | * {num: this.state.num,
46 | channels: this.props.client.channels,
47 | nextClientId: this.props.nextClientId,
48 | type: 'subscriber',
49 | ws: this.props.ws,} stateObj
50 | */
51 | export const fetchAddClones = (stateObj) => (dispatch) => {
52 | stateObj.arr = [];
53 | let next = stateObj.nextClientId;
54 | let type = stateObj.type || 'subscriber';
55 | for (let i = 0; i < stateObj.num; i++ ) {
56 | stateObj.arr.push({clientId: next, type});
57 | next++;
58 | }
59 | fetch('/menu/addClonedClients', {
60 | method: 'POST',
61 | headers: {
62 | 'Content-Type': 'application/json',
63 | },
64 | body: JSON.stringify(stateObj.arr)
65 | })
66 | .then(res => {
67 | if (res.status === 200){
68 | if (stateObj.type === 'subscriber') {
69 | dispatch(wsMessage(stateObj))
70 | }
71 |
72 | }else {
73 | dispatch(errorActions.errorHandler('Failed to clone!'))
74 | }
75 | })
76 | .catch( error => dispatch(errorActions.errorHandler('Failed to clone!')))
77 | };
78 |
79 | export const fetchSubscribeMany = (stateObj) => (dispatch) => {
80 | fetch('/client/subscribeMany', {
81 | method: 'POST',
82 | headers: {
83 | 'Content-Type': 'application/json',
84 | },
85 | body: JSON.stringify({clients: stateObj.arr, channels: stateObj.channels})
86 | })
87 | /**dispatch addclone clientaction */
88 | .then(res => {
89 | if (res.status === 200) {
90 | dispatch(clientActions.cloneClient(stateObj.num));
91 | } else {
92 | dispatch(errorActions.errorHandler('Failed to subscribe clones!'));
93 | }
94 | })
95 | /**dispatch global error message, passing "failed to subscribe clones!" */
96 | .catch(err => {
97 | dispatch(errorActions.errorHandler('Failed to subscribe clones!'));
98 | })
99 | }
100 |
101 | export const fetchMessage = (stateObj) => (dispatch) => {
102 | fetch("/client/publish", {
103 | method: 'POST',
104 | headers: {
105 | 'Content-Type': 'application/json',
106 | },
107 | body: JSON.stringify({
108 | clientId: stateObj.currClient,
109 | channelName: stateObj.channel,
110 | message: stateObj.message
111 | })
112 | })
113 | .then ( response => {
114 | if(response.status === 200){
115 | //dispatch publish middleware
116 | dispatch(getDate(stateObj))
117 | } else {
118 | dispatch(errorActions.errorHandler('Failed to publish!'))
119 | }
120 | })
121 | .catch( error => dispatch(errorActions.errorHandler('Failed to publish!')))
122 | }
123 |
124 | export const fetchSubscribe = (stateObj) => (dispatch) => {
125 |
126 | fetch("/client/subscribe", {
127 | method: 'POST',
128 | headers: {
129 | 'Content-Type': 'application/json',
130 | },
131 | body: JSON.stringify({
132 | clientId: stateObj.currClient,
133 | channelName: stateObj.channel
134 | })
135 | })
136 | .then( response => {
137 | if(response.status === 200) {
138 | dispatch(getDate(stateObj))
139 | } else {
140 | dispatch(errorActions.errorHandler('Failed to subscribe!'))
141 | }
142 |
143 | })
144 | .catch( error => {
145 |
146 | dispatch(errorActions.errorHandler('Failed to subscribe!'))
147 | })
148 | }
149 |
150 | //middleware to add on message event listener to backend for subscriber client
151 | //will be dispatched from fetchsubscribe and will dispatch subscribe
152 | //needs to be passed ws so can message backend
153 |
154 |
155 |
156 | export const fetchUnsubscribe = (stateObj) => (dispatch) => {
157 |
158 | fetch("/client/unsubscribe", {
159 | method: 'POST',
160 | headers: {
161 | 'Content-Type': 'application/json',
162 | },
163 | body: JSON.stringify({
164 | clientId: stateObj.currClient,
165 | channelName: stateObj.channel
166 | })
167 | })
168 | .then( response => {
169 | if(response.status === 200) {
170 | dispatch(getDate(stateObj))
171 | } else {
172 | dispatch(errorActions.errorHandler('Failed to unsubscribe!'))
173 | }
174 | })
175 | .catch(err => {
176 | dispatch(errorActions.errorHandler('Failed to unsubscribe!'))
177 | })
178 | }
179 |
180 | export const socketReceivedMessage = (stateObj) => (dispatch) => {
181 | dispatch(getDate(stateObj))
182 | }
183 |
184 |
185 |
186 | //message middleware - create new iso string for current time, then call dispatch for message
187 | export const getDate = (stateObj) => (dispatch) => {
188 |
189 | const now = new Date(Date.now()).toString();
190 | if(stateObj.selectedAction === 'addMessage'){
191 | dispatch(clientActions.publishMessage(now));
192 | }
193 | else if(stateObj.selectedAction === 'subscribe') {
194 | dispatch(clientActions.subscribe(now));
195 | }
196 | else if(stateObj.selectedAction === 'unsubscribe') {
197 | dispatch(clientActions.unsubscribe(now));
198 | }
199 | else {
200 | dispatch(clientActions.receivedMessage({...stateObj, now}));
201 | }
202 | }
203 |
204 | //run fetch requests, then dispatch reducer
205 |
206 | //fetchConnect
207 | export const fetchConnect = (port) => (dispatch) => {
208 | //fetch
209 | fetch('/menu/connect', {
210 | method: 'POST',
211 | headers: {
212 | 'Content-Type': 'application/json',
213 | },
214 | //check that below is for sure a string
215 | body: JSON.stringify({port}),
216 | })
217 | .then(response => {
218 | if (response.status === 200) {
219 | return response.json()
220 | } else dispatch(errorActions.errorHandler(`Failed to connect to ${port}`));
221 | }).then(data => {
222 | dispatch(channelActions.portConnected({port, channels:data.channels}));
223 |
224 | })
225 | .catch((error) => {
226 | dispatch(errorActions.errorHandler(`Failed to connect to ${port}`));
227 | })
228 |
229 | }
230 |
231 |
232 | //data in form of
233 | // {clientId: #, type: 'publisher' OR 'subscriber' OR '' defaults to subscriber}
234 | export const fetchAddClient = (data) => (dispatch) => {
235 | fetch('/menu/addClient', {
236 | method: 'POST',
237 | headers: {
238 | 'Content-Type': 'application/json',
239 | },
240 | //check that below is for sure a string
241 | body: JSON.stringify({type: data.type, clientId: data.clientId}),
242 | })
243 | .then(response => {
244 |
245 | if (response.status === 200) {
246 |
247 | if (data.type === 'subscriber' || data.type === '') {
248 | dispatch(wsMessage(data));
249 | } else {
250 | dispatch(clientActions.addClient('publisher'))
251 | }
252 |
253 | return;
254 | } else {
255 | dispatch(dispatch(errorActions.errorHandler('Failed to addClient!')));
256 | return;
257 | }
258 | })
259 | .catch(err => {
260 | dispatch(dispatch(errorActions.errorHandler('Failed to addClient!')));
261 | return;
262 | })
263 | };
264 |
265 | export const wsMessage = (data) => dispatch => {
266 | //check to see if arr of clients sent -- if so, send the arr (ws can add multiple clients if it receives array)
267 | //this sends the client id through the websocket to add the connection to ws
268 | if (!data.arr) {
269 | data.ws.send(JSON.stringify({clientId: data.clientId}));
270 | dispatch(clientActions.addClient('subscriber'));
271 | } else {
272 | data.ws.send(JSON.stringify(data.arr));
273 | dispatch(fetchSubscribeMany(data));
274 | /**dispatch fetchSubscribeMany, then cloneClient */
275 | }
276 |
277 | }
278 |
279 |
280 | //fetchAddChannel
281 | export const fetchAddChannel = (channelName) => (dispatch) => {
282 | fetch('/menu/addChannel', {
283 | method: 'POST',
284 | headers: {
285 | 'Content-Type': 'application/json',
286 | },
287 | body: JSON.stringify({channelName}),
288 | })
289 | .then(response => {
290 | if (response.status === 200) {
291 | dispatch(channelActions.addChannel(channelName));
292 | }
293 | else {
294 | dispatch(errorActions.errorHandler("Error adding channel"))
295 | }
296 | })
297 | .catch((error) => {
298 | dispatch(errorActions.errorHandler("Error adding channel"))
299 | })
300 | }
301 |
302 |
303 |
--------------------------------------------------------------------------------
/client/styles/styles.scss:
--------------------------------------------------------------------------------
1 | @import 'variables';
2 | // @import url('https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,100;0,200;0,300;0,500;0,600;1,300&display=swap');
3 | // Note font-family Poppins
4 | @import url('https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap');
5 | //font-family: 'Roboto', sans-serif;
6 |
7 | body {
8 | background-color: $component-bg;
9 | font-family: 'Roboto', sans-serif;
10 | font-size: 0.9em;
11 | background-image: url('../../static/RPS_View_logo.png');
12 | background-size: 350px 350px;
13 | background-repeat: no-repeat;
14 | background-position: 50% 50%;
15 | min-height: 500px;
16 | }
17 |
18 | button {
19 | padding: 0.3em;
20 | font-size: 100%;
21 | font-family: 'Roboto', sans-serif;
22 | }
23 |
24 | input {
25 | padding: 0.3em;
26 | }
27 |
28 | .unhighlighted {
29 | padding: 0.2em;
30 | background-color: $accent;
31 | color: $darkGray;
32 | border-style: groove;
33 | font-family: 'Roboto', sans-serif;
34 | }
35 |
36 | .highlighted {
37 | padding: 0.2em;
38 | background-color: $highlight;
39 | color: $accent;
40 | border-style: groove;
41 | font-family: 'Roboto', sans-serif;
42 | }
43 |
44 | .highlighted:focus {
45 | // outline: $highlightDark solid .1em !important;
46 | font-family: 'Roboto', sans-serif;
47 | }
48 |
49 | .primaryButton {
50 | background-color: $accent;
51 | color: $accentDark;
52 | border: none;
53 | font-family: 'Roboto', sans-serif;
54 | }
55 |
56 | .secondaryButton {
57 | background-color: $component-bg;
58 | color: $accentDark;
59 | border: none;
60 | font-family: 'Roboto', sans-serif;
61 | }
62 |
63 | .dropDown {
64 | padding: 0.25em;
65 | color: $dropdownColor;
66 | border: none;
67 | background-color: whitesmoke;
68 | font-family: 'Roboto', sans-serif;
69 | }
70 |
71 | // .primaryButton:focus, .secondaryButton:focus {
72 | // // outline: $accentDark solid 0.1em ;
73 | // }
74 |
75 | // .logo {
76 | // background-color: purple;
77 | // width: 100px;
78 | // height: 30px;
79 | // }
80 |
81 | #root {
82 | display: flex;
83 | flex-direction: row;
84 | justify-content: center;
85 | .left {
86 | width: 130px;
87 | margin-top: 0.8em;
88 |
89 | .logo {
90 | // background-color: $accentDark;
91 | width: 100%;
92 | .logoImg {
93 | // border: 2px solid $accentDark;
94 | width: 100%;
95 | }
96 | }
97 | .channelLabel {
98 | border-style: groove;
99 | border-width: thin;
100 | background-color: $accent;
101 | color: $accentDark;
102 | width: 100%;
103 | font-size: 1.1em;
104 | text-align: center;
105 | margin-bottom: 0.5px;
106 | border-radius: $bigBorderpx;
107 | margin-bottom: $marginHeader;
108 | // box-shadow: $boxShadowLabel;
109 | // border: 2px solid $accentDark;
110 | }
111 | .channel {
112 | display: flex;
113 | flex-direction: column;
114 | width: 100%;
115 | align-items: stretch;
116 |
117 | .oneChannel {
118 | button {
119 | width: 100%;
120 | margin-top: 0.5px;
121 | margin-bottom: 0.5px;
122 | border-radius: $smallBorderpx;
123 | overflow-wrap: break-word;
124 | border: thin groove white;
125 | }
126 | }
127 | }
128 | }
129 | .middle {
130 | display: flex;
131 | flex-direction: column;
132 | max-width: 60em;
133 | align-items: center;
134 | .navBar {
135 | display: flex;
136 | flex-direction: row;
137 |
138 | padding-right: 1em;
139 | padding-left: 1em;
140 | margin-top: 0.5em;
141 | .navLeft {
142 | margin: 5px;
143 | .navLeftTop {
144 | display: flex;
145 | flex-direction: row;
146 | align-content: center;
147 |
148 | .serverInput {
149 | height: $navBarHeight;
150 | padding: 0;
151 | padding-left: 0.5em;
152 | border-top-left-radius: $borderpx;
153 | border-bottom-left-radius: $borderpx;
154 | // box-shadow: $boxShadowInput;
155 | border: none;
156 | width: 9em;
157 | }
158 | .serverPort {
159 | height: $navBarHeight;
160 | padding: 0;
161 | border-top-left-radius: $borderpx;
162 | border-bottom-left-radius: $borderpx;
163 | // box-shadow: $boxShadowInput;
164 | border: none;
165 | width: 9em;
166 | background-color: $lightYellow;
167 | text-align: center;
168 | }
169 | // border: 2px solid $accentDark;
170 | .primaryButton {
171 | vertical-align: middle;
172 | height: $navBarHeight;
173 | border-top-right-radius: $borderpx;
174 | border-bottom-right-radius: $borderpx;
175 | // box-shadow: $boxShadowBtn;
176 | border: none;
177 | border-top-style: groove;
178 | border-right-style: groove;
179 | border-bottom-style: groove;
180 | border-width: thin;
181 | }
182 | }
183 | }
184 | .navCenter {
185 | margin: 5px;
186 | // border: 2px solid $accentDark;
187 | display: flex;
188 | flex-direction: row;
189 | align-content: center;
190 | .channelInput {
191 | height: $navBarHeight;
192 | padding-left: 0.5em;
193 | padding-right: 0.3em;
194 | padding-top: 0;
195 | padding-bottom: 0;
196 | border: none;
197 | // box-shadow: $boxShadowInput;
198 | border-top-left-radius: $borderpx;
199 | border-bottom-left-radius: $borderpx;
200 | }
201 | .secondaryButton {
202 | height: $navBarHeight;
203 | padding-top: 0;
204 | padding-bottom: 0;
205 | border-top-right-radius: $borderpx;
206 | border-bottom-right-radius: $borderpx;
207 | // box-shadow: $boxShadowBtn;
208 | border-top-style: groove;
209 | border-right-style: groove;
210 | border-bottom-style: groove;
211 | border-width: thin;
212 | }
213 | }
214 | .navRight {
215 | margin: 5px;
216 | display: flex;
217 | flex-direction: row;
218 | align-content: center;
219 | .dropDown {
220 | height: $navBarHeight;
221 | border-top-left-radius: $smallBorderpx;
222 | border-bottom-left-radius: $smallBorderpx;
223 | // box-shadow: $boxShadowInput;
224 | }
225 | .secondaryButton {
226 | height: $navBarHeight;
227 | padding-top: 0;
228 | padding-bottom: 0;
229 | border-top-right-radius: $borderpx;
230 | border-bottom-right-radius: $borderpx;
231 | // box-shadow: $boxShadowBtn;
232 | border-top-style: groove;
233 | border-right-style: groove;
234 | border-bottom-style: groove;
235 | border-width: thin;
236 | }
237 | }
238 | }
239 | .clientWindow {
240 | background-color: $clientWindow;
241 | // max-width: 50em;
242 | width: 45em;
243 | margin-top: 2em;
244 | border-radius: $bigBorderpx;
245 | border-style: groove;
246 | border-width: thin;
247 | // box-shadow: $clientWindowBoxShadow;
248 | .top {
249 | display: flex;
250 | flex-direction: row;
251 | justify-content: space-between;
252 | align-items: center;
253 | background-color: $accent;
254 | color: $accentDark;
255 | padding-left: 0.8em;
256 | padding-right: 0.8em;
257 | border-top-left-radius: $bigBorderpx;
258 | border-top-right-radius: $bigBorderpx;
259 | border-bottom-style: groove;
260 | border-width: thin;
261 | .clientLabel {
262 | margin-left: 10px;
263 | }
264 |
265 | #x {
266 | padding-left: 0.5em;
267 | padding-right: 0.5em;
268 | border-radius: $smallBorderpx;
269 | }
270 | }
271 | .center {
272 | display: flex;
273 | flex-direction: row;
274 | justify-content: space-around;
275 | color: white;
276 | .messageLogDisplay {
277 | display: flex;
278 | flex-direction: column;
279 | padding-left: 10px;
280 | .clientWindowMessageLabel {
281 | margin-bottom: -5px;
282 | font-size: 13px;
283 | }
284 | .allMessages {
285 | background-color: $clientWindow;
286 | max-width: 15em;
287 | max-height: 25em;
288 | overflow: scroll;
289 | overflow-x: hidden;
290 | margin-top: 10px;
291 | .messageBox {
292 | margin-bottom: 20px;
293 | font-size: small;
294 | color: $darkGray;
295 | padding-right: 15px;
296 | p {
297 | margin-bottom: -12px;
298 | }
299 | .accentColor {
300 | color: $accentDark;
301 | }
302 | .messageBoxType {
303 | color: $highlightDark;
304 | margin-top: 0px;
305 | }
306 | .bold {
307 | font-weight: bolder;
308 | }
309 | }
310 | }
311 | }
312 | .clientWindowSubscribedLabel {
313 | margin-bottom: 4px;
314 | font-size: 13px;
315 | }
316 | .subList {
317 | background-color: $clientWindow;
318 | margin: 0px;
319 | padding-inline-start: 25px;
320 | }
321 | .subscribedChannels {
322 | display: flex;
323 | flex-direction: column;
324 | padding-right: 10px;
325 | font-size: small;
326 | }
327 | }
328 | .bottom {
329 | display: flex;
330 | justify-content: center;
331 | padding-top: 0.5em;
332 | padding-bottom: 0.5em;
333 | * {
334 | margin-right: 0.2em;
335 | margin-left: 0.2em;
336 | }
337 | #actionBarInput {
338 | border: none;
339 | border-radius: $smallBorderpx;
340 | }
341 | #goButton {
342 | padding-right: 0.9em;
343 | padding-left: 0.9em;
344 | background-color: $pushMsg;
345 | color: whitesmoke;
346 | }
347 | .dropDown {
348 | border-radius: $smallBorderpx;
349 | }
350 | .primaryButton {
351 | border-radius: $smallBorderpx;
352 | }
353 | .numClonesLabel {
354 | margin-left: 17px;
355 | font-size: smaller;
356 | color: white;
357 | }
358 | .numClonesInput {
359 | border-radius: $smallBorderpx;
360 | width: 35px;
361 | border: none;
362 | }
363 | .addCloneButton {
364 | border: none;
365 | background-color: $medGray;
366 | padding-right: 0.9em;
367 | padding-left: 0.9em;
368 | border-radius: $smallBorderpx;
369 | }
370 | }
371 | }
372 | .errorBox {
373 | display: flex;
374 | flex-direction: column;
375 | justify-content: center;
376 | .errorComponents {
377 | display: flex;
378 | flex-direction: column;
379 | align-items: center;
380 | .errorBoxMessage {
381 | color: $darkGray;
382 | }
383 | #errorButton {
384 | border-width: thin;
385 | border-style: groove;
386 | border-radius: $smallBorderpx;
387 | width: 50%;
388 | font-size: smaller;
389 | background-color: $accent;
390 | }
391 | }
392 | }
393 | }
394 | .right {
395 | width: 130px;
396 | margin-top: 0.8em;
397 | .clientMenu {
398 | display: flex;
399 | flex-direction: column;
400 | align-items: stretch;
401 | width: 100%;
402 | // border: 2px solid $accentDark;
403 | .clientLabel {
404 | background-color: $accent;
405 | color: $accentDark;
406 | font-size: 1.1em;
407 | text-align: center;
408 | margin-bottom: $marginHeader;
409 | border-radius: $bigBorderpx;
410 | border-width: thin;
411 | border-style: groove;
412 | }
413 | .clientMiniLabel {
414 | font-size: 0.8em;
415 | text-align: center;
416 | background-color: $clientWindow;
417 | color: $darkGray;
418 | // padding-top: -2em;
419 | // padding-bottom: -2em;
420 | // border-bottom: 2px solid $accentDark;
421 | margin-top: 0.5px;
422 | margin-bottom: 0.5px;
423 | border-radius: $medBorderpx;
424 | border-width: thin;
425 | border-style: groove;
426 | // border-bottom: .1em solid $darkGray;
427 | .miniLabelText {
428 | margin-top: 0.5em;
429 | margin-bottom: 0.5em;
430 | }
431 | }
432 | .oneClient {
433 | button {
434 | width: 100%;
435 | margin-top: 0.5px;
436 | margin-bottom: 0.5px;
437 | border-radius: $smallBorderpx;
438 | border: thin groove white;
439 | }
440 | }
441 | }
442 | }
443 | }
444 |
--------------------------------------------------------------------------------