├── .DS_Store ├── .eslintrc.yml ├── .github └── workflows │ ├── assign_issue.yml │ ├── integrate.yml │ ├── issue_link.yml │ ├── make_issue.yml │ └── pull_request_open.yml ├── .gitignore ├── .travis.yml ├── README.md ├── __tests__ ├── GraphUnitTests.ts ├── LoginPageUnitTests.ts ├── SelectorUnitTests.ts ├── SignUpPageUnitTests.ts ├── serverUnitTests.ts └── sqlUnitTests.ts ├── client ├── components │ ├── App.tsx │ ├── Graph.tsx │ ├── LoginPage.tsx │ ├── Selector.tsx │ ├── SignUpPage.tsx │ └── useInterval.tsx ├── images │ └── cockroach.jpeg ├── index.tsx └── scss │ ├── App.scss │ ├── Graph.scss │ ├── LoginPage.scss │ ├── Selector.scss │ ├── _variables.scss │ └── application.scss ├── docker-compose.yml ├── index.html ├── index.ts ├── jest.config.js ├── loadBalancer.jar ├── package.json ├── server ├── controllers │ ├── cookieController.ts │ ├── kafkaController.ts │ ├── sessionController.ts │ └── userController.ts ├── createServer.ts ├── models │ ├── sessionModel.ts │ └── userModels.ts ├── routers │ └── kafkaRouter.ts └── server.ts ├── tsconfig.json ├── types.ts └── webpack.config.js /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/saamsa/d10bf2f88a4685e261055badfe26bcf64b236fa8/.DS_Store -------------------------------------------------------------------------------- /.eslintrc.yml: -------------------------------------------------------------------------------- 1 | env: 2 | browser: true 3 | es2021: true 4 | node: true 5 | extends: 6 | - 'eslint:recommended' 7 | - 'plugin:react/recommended' 8 | - 'plugin:@typescript-eslint/recommended' 9 | parser: '@typescript-eslint/parser' 10 | parserOptions: 11 | ecmaFeatures: 12 | jsx: true 13 | ecmaVersion: 12 14 | sourceType: module 15 | plugins: 16 | - react 17 | - '@typescript-eslint' 18 | rules: { '@typescript-eslint/no-var-requires': 'off' } 19 | -------------------------------------------------------------------------------- /.github/workflows/assign_issue.yml: -------------------------------------------------------------------------------- 1 | name: Move assigned card 2 | on: 3 | issues: 4 | types: 5 | - assigned 6 | jobs: 7 | move-assigned-card: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: alex-page/github-project-automation-plus@5bcba1c1c091a222584d10913e5c060d32c44044 11 | with: 12 | project: OSP 13 | column: In progress 14 | repo-token: ${{ secrets.OSP_BOARD_SECRET }} 15 | -------------------------------------------------------------------------------- /.github/workflows/integrate.yml: -------------------------------------------------------------------------------- 1 | name: Testing 2 | 3 | on: 4 | pull_request: 5 | branches: [dev] 6 | 7 | jobs: 8 | test_pull_request: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v2 12 | - name: Install modules 13 | run: yarn install 14 | - name: Run ESLint 15 | run: yarn run eslint . 16 | -------------------------------------------------------------------------------- /.github/workflows/issue_link.yml: -------------------------------------------------------------------------------- 1 | name: VerifyIssue 2 | 3 | on: 4 | pull_request: 5 | types: [edited, synchronize, opened, reopened] 6 | check_run: 7 | 8 | jobs: 9 | verify_linked_issue: 10 | runs-on: ubuntu-latest 11 | name: Ensure Pull Request has a linked issue. 12 | steps: 13 | - name: Verify Linked Issue 14 | uses: hattan/verify-linked-issue-action@v1.1.0 15 | env: 16 | GITHUB_TOKEN: ${{ secrets.OSP_BOARD_SECRET }} 17 | -------------------------------------------------------------------------------- /.github/workflows/make_issue.yml: -------------------------------------------------------------------------------- 1 | on: 2 | issues: 3 | types: [opened] 4 | name: Issue opened 5 | jobs: 6 | assign: 7 | name: Assign issues to project 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Assign issues to project 11 | uses: technote-space/create-project-card-action@v1 12 | with: 13 | PROJECT: OSP 14 | COLUMN: To do 15 | -------------------------------------------------------------------------------- /.github/workflows/pull_request_open.yml: -------------------------------------------------------------------------------- 1 | on: 2 | pull_request: 3 | types: [opened] 4 | name: Pull Request opened 5 | jobs: 6 | assignToProject: 7 | name: Assign PR to Code Review 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Assign PR to Code Review 11 | uses: technote-space/create-project-card-action@v1 12 | with: 13 | PROJECT: OSP 14 | COLUMN: Review in progress 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | out 3 | node_modules 4 | build 5 | package-lock.json 6 | kafkastream 7 | tsOutput -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | node_js: 4 | - 14.18.0 5 | 6 | services: 7 | - mongodb 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Saamsa 2 | 3 | Saamsa is an easy-to-use web & desktop application built to work with Kafka that: 4 | 5 | - Displays consumer-level load on an individual broker 6 | - Displays previous producer-level load on the topic level via topic-message-offsets 7 | - Allows you as the developer to see what consumer is consuming what data, which groups they belong to, and if any sticky or stale consumers need to be remedied. 8 | - Allows you to see how your data is stored on topics and if your data rate is causing batching issues, leading to an imbalance of messages on individual partitions 9 | - Allows you as the developer to circumvent the black box nature of Kafka and really get the behind-the-scenes of your implementation of the Kafka system and assess if everything is behaving as expected 10 | - Allows you to visualize load balancing on multiple Kafka brokers and to remedy any unbalanced loads on any topics. 11 | - Allows for single replication with exactly-once written commits and continuous real-time load balancing on a single topic on the same broker. This ensures data integrity and efficient read/write operations for maximal performance. 12 | - This functionality is achieved through a custom Kafka streams API implementation which gets attached to the provided topic. 13 | - It replicates the data in real-time as an independent consumer and producer to the Kafka topic, ensuring no interference with native consumers and producers. 14 | 15 | ## Table of Contents 16 | 17 | - Pre-requisites 18 | - Features 19 | - How it works 20 | - Demo Testing App 21 | - Installation 22 | - Feature roadmap 23 | - Contribute 24 | - License 25 | 26 | ## Pre-requisites 27 | 28 | To use this application, you'll need to have : 29 | 30 | 1. Either a locally hosted or publically-available cloud hosted Kafka instance 31 | 2. If you have a locally hosted instance, please use the Desktop Application. 32 | 33 | ## Features 34 | 35 | Saamsa has the following features: 36 | 37 | - An intuitive GUI. 38 | - Insights into brokers, consumers, topics, offsets, partition indices 39 | - Graphs to visualize & monitor load balancing on a topic level 40 | - Ability to rebalance message-offset load on a topic 41 | 42 | ## How it works 43 | 44 | **Getting started with Saamsa is easy:** 45 | 46 | 1. Download the app for MacOS or visit the web application 47 | 2. Sign up if you are a new user. Otherwise, log in. 48 | 3. To add a new broker address, add the location in the input field and click _Submit_. 49 | 4. To use an already submitted broker address, Click on the dropdown next to _Select broker_ and choose the preferred broker. 50 | 5. _Select a topic_ from the dropdown. 51 | 6. You can now see a graphical visualization of your Saamsa topic on the selected broker. 52 | 53 | **To customize load balancing:** 54 | 55 | 1. Select the broker and the topic who's load balancing you want to customize. 56 | 2. Click on the "customize load balancing" button to customize load balancing for the selected topic. 57 | 3. The display will automatically change to display this balanced topic after a second or two. 58 | 4. You can now see load balancing customized on the selected topic. 59 | 60 | ## Demo Testing App 61 | 62 | We have created a demo testing app for you to understand how Saamsa works with an application that: 63 | 64 | - Uses Kafka as its message broker 65 | - Creates consumers that read data upon button click 66 | - Created producers that produce massive amounts of data upon button click 67 | 68 | To use our Demo app, all you have to do is: 69 | 70 | ### Remotely 71 | 72 | 1. Navigate to demo.saamsa.io 73 | 2. This is a publically available Kafka/Zookeeper instance with controls to produce data, consume data, and create topics. 74 | 75 | ### Locally 76 | 77 | 1. Clone this repo. 78 | 2. Install Docker Desktop. 79 | 3. From the cloned repo's directory, run `$ docker compose up -d` 80 | This opens up a local Kafka/Zookeepr instance at localhost:29092, localhost:2181. 81 | A GUI for easily producing data, consuming data, and creating topics for this broker is available at localhost:3000. 82 | 83 | ## Installation 84 | 85 | To use our Web Application/Desktop Application for MacOS, please follow steps 1 - 6 of Getting started with Saamsa, which can be found above. 86 | 87 | ## Feature Roadmap 88 | 89 | The development team intends to continue improving Saamsa and adding more features. 90 | [Head to our roadmap](https://github.com/oslabs-beta/saamsa/issues) to see our upcoming planned features. 91 | 92 | ## Contributors 93 | 94 | [Adam Thibodeaux](https://github.com/adam-thibodeaux) - [LinkedIn](https://www.linkedin.com/in/adam-thibodeaux-b0812b210/) 95 |
96 | [Shamilah Faria](https://github.com/shamilahfaria) - [LinkedIn](https://www.linkedin.com/in/shamilah-faria/) 97 |
98 | [Janilya Baizack](https://github.com/janilya) - [LinkedIn](https://www.linkedin.com/in/janilya/) 99 |
100 | [Kasthuri Menon](https://github.com/kasthurimenon) - [LinkedIn](https://www.linkedin.com/in/kasthurimenon) 101 |
102 | 103 | If you'd like to support the active development of Saamsa: 104 | 105 | - Add a GitHub Star to the project. 106 | - Tweet about the project on your Twitter. 107 | - Write a review or tutorial on Medium, Dev.to or personal blog. 108 | - Contribute to this project by raising a new issue or making a PR to solve an issue. 109 |
110 | -------------------------------------------------------------------------------- /__tests__/GraphUnitTests.ts: -------------------------------------------------------------------------------- 1 | import { shallow, configure, mount } from 'enzyme'; 2 | import Adapter from '@wojtekmaj/enzyme-adapter-react-17'; 3 | import Graph from '../client/components/Graph'; 4 | import cheerio from 'cheerio'; 5 | import { waitFor } from '@testing-library/react'; 6 | import * as d3 from 'd3'; 7 | configure({ adapter: new Adapter() }); 8 | 9 | describe('Graph Unit Tests', () => { 10 | const props = { 11 | data: [], 12 | setData: jest.fn(), 13 | setTopic: jest.fn(), 14 | bootstrap: 'test', 15 | topicList: ['testTopic'], 16 | consumerList: [], 17 | topic: 'testTopic', 18 | xScale: d3.scaleLinear(), 19 | setXScale: jest.fn(), 20 | currentUser: 'testUser', 21 | }; 22 | it('should not render a graph at first', () => { 23 | const wrapper = shallow(Graph(props)); 24 | const svg = wrapper.find('svg'); 25 | expect(svg).toEqual({}); 26 | }); 27 | it('should not render a h2 title at first', () => { 28 | const wrapper = shallow(Graph(props)); 29 | const h2 = wrapper.find('h2'); 30 | expect(h2.length).toBe(0); 31 | }); 32 | //these need to be tweaked, working, but not working perfectly... d3 is rendered but broken in test because of (dom vs react) manipulation 33 | it('should render a bar graph with axes and labels after data is input', async () => { 34 | const wrapper = mount(Graph({ ...props, data: [{ value: 0, time: 0 }] })); 35 | const $ = cheerio.load(wrapper.html()); 36 | await waitFor(() => { 37 | expect($('svg')).not.toEqual({}); 38 | expect($('rect')).not.toEqual({}); 39 | expect($('text')).not.toEqual({}); 40 | }); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /__tests__/LoginPageUnitTests.ts: -------------------------------------------------------------------------------- 1 | // import * as React from 'react'; 2 | import { shallow, configure } from 'enzyme'; 3 | import Adapter from '@wojtekmaj/enzyme-adapter-react-17'; 4 | import LoginPage from '../client/components/LoginPage'; 5 | 6 | configure({ adapter: new Adapter() }); 7 | 8 | describe('LoginPage unit tests', () => { 9 | describe('user signup', () => { 10 | const props = { 11 | loginStatus: false, 12 | loginAttempt: null, 13 | loginButton: jest.fn(), 14 | signUpButton: jest.fn(), 15 | signUp: jest.fn(), 16 | }; 17 | let wrapper; 18 | it('renders username and password input field and signup, login, forgot password buttons', () => { 19 | wrapper = shallow(LoginPage(props)); 20 | expect(wrapper.find('input').length).toBe(2); 21 | expect(wrapper.find('button').length).toBe(3); 22 | }); 23 | it('calls correct functions upon login', () => { 24 | wrapper = shallow(LoginPage(props)); 25 | wrapper.find('#loginBtn').simulate('click'); 26 | expect(props.loginButton).toHaveBeenCalled(); 27 | }); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /__tests__/SelectorUnitTests.ts: -------------------------------------------------------------------------------- 1 | import { shallow, configure } from 'enzyme'; 2 | import Adapter from '@wojtekmaj/enzyme-adapter-react-17'; 3 | import Selector from '../client/components/Selector'; 4 | 5 | configure({ adapter: new Adapter() }); 6 | 7 | describe('Selector unit tests', () => { 8 | const props = { 9 | setData: jest.fn(), 10 | setTopic: jest.fn(), 11 | serverList: ['test_test'], 12 | setServerList: jest.fn(), 13 | topicList: ['lets_get', 'that_meat'], 14 | setTopicList: jest.fn(), 15 | bootstrap: 'test:test', 16 | setBootstrap: jest.fn(), 17 | currentUser: 'testUser', 18 | data: [], 19 | topic: 'testTopic', 20 | consumerList: [], 21 | setConsumerList: jest.fn(), 22 | logOut: jest.fn(), 23 | }; 24 | it('should populate the server drop down with the server list', () => { 25 | const wrapper = shallow(Selector(props)); 26 | expect(wrapper.find('.serverOption').at(0).text()).toBe(''); 27 | expect(wrapper.find('.serverOption').at(1).text()).toBe('test_test'); 28 | }); 29 | it('should populate the topics drop down with the topic list', () => { 30 | const wrapper = shallow(Selector(props)); 31 | expect(wrapper.find('.topicOption').at(0).text()).toBe(''); 32 | expect(wrapper.find('.topicOption').at(1).text()).toBe('lets_get'); 33 | expect(wrapper.find('.topicOption').at(2).text()).toBe('that_meat'); 34 | }); 35 | it('should have an input field for user to input new server', () => { 36 | const wrapper = shallow(Selector(props)); 37 | const input = wrapper.find('input'); 38 | expect(input.props().placeholder).toBe('demo.saamsa.io:29093'); 39 | const submitBtn = wrapper.find('#createTableBtn'); 40 | expect(typeof submitBtn.props().onClick).toBe('function'); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /__tests__/SignUpPageUnitTests.ts: -------------------------------------------------------------------------------- 1 | // import * as React from 'react'; 2 | import { shallow, configure } from 'enzyme'; 3 | import Adapter from '@wojtekmaj/enzyme-adapter-react-17'; 4 | import SignUpPage from '../client/components/SignUpPage'; 5 | 6 | configure({ adapter: new Adapter() }); 7 | 8 | describe('SignUpPage unit tests', () => { 9 | describe('user signup', () => { 10 | const props = { 11 | loginAttempt: null, 12 | signUp: jest.fn(), 13 | }; 14 | let wrapper; 15 | it('renders username and password input field and signup, login, forgot password buttons', () => { 16 | wrapper = shallow(SignUpPage(props)); 17 | expect(wrapper.find('input').length).toBe(2); 18 | expect(wrapper.find('button').length).toBe(1); 19 | }); 20 | it('calls correct functions upon signup', () => { 21 | wrapper = shallow(SignUpPage(props)); 22 | wrapper.find('#loginBtn').simulate('click'); 23 | expect(props.signUp).toHaveBeenCalled(); 24 | }); 25 | it('should show a loginAttempt message upon incorrect signup', () => { 26 | wrapper = shallow(SignUpPage(props)); 27 | wrapper 28 | .find('#username') 29 | .simulate('change', { target: { value: 'weewee' } }); 30 | wrapper 31 | .find('#password') 32 | .simulate('change', { target: { value: 'bobo' } }); 33 | wrapper.find('#loginBtn').simulate('click'); 34 | const message = wrapper.find('#loginAttemptMessage'); 35 | expect(message.html()).not.toBeNull(); 36 | }); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /__tests__/serverUnitTests.ts: -------------------------------------------------------------------------------- 1 | import createServer from '../server/createServer'; 2 | import request from 'supertest'; 3 | import { connection, connect, ConnectOptions } from 'mongoose'; 4 | const dbURI = 'mongodb://127.0.0.1/testingDB'; 5 | import Users from '../server/models/userModels'; 6 | const app = createServer(); 7 | describe('user login/signup unit tests', () => { 8 | const username = 'testytest'; 9 | const password = 'bozotheclown'; 10 | beforeAll(async () => { 11 | await connect(dbURI, { useNewUrlParser: true } as ConnectOptions); 12 | console.log('connected to test db'); 13 | Users.create({ username, password }); 14 | }); 15 | afterAll(() => { 16 | Users.deleteMany({}); 17 | connection.close(); 18 | }); 19 | it('should send 404 page to invalid url', async () => { 20 | await request(app).post('/asdfasdfasdf/asdfas').send().expect(404); 21 | }); 22 | it('should send ok status code with valid user login information', async () => { 23 | const result = await request(app) 24 | .post('/login') 25 | .send({ username, password }) 26 | .expect(200); 27 | expect(result.body).toBe(username); 28 | }); 29 | it('should send error status code with invalid user login information', async () => { 30 | await request(app) 31 | .post('/login') 32 | .send({ username: 'asldkfjalskfj', password: 'aslkfjalsfkjaslkj' }) 33 | .expect(401); 34 | }); 35 | it('should send error status code when user signs up with existing username', async () => { 36 | const result = await Users.findOne({ username }); 37 | await request(app).post('/signup').send({ username, password }).expect(304); 38 | }); 39 | it('should succesfully sign up a user in database with pre-hashed password', async () => { 40 | await Users.deleteMany({}); 41 | await request(app).post('/signup').send({ username, password }).expect(200); 42 | const result = await Users.findOne({ username }); 43 | expect(result).not.toEqual({}); 44 | expect(result?.password).not.toBe(password); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /__tests__/sqlUnitTests.ts: -------------------------------------------------------------------------------- 1 | import createServer from '../server/createServer'; 2 | import request from 'supertest'; 3 | import { open } from 'sqlite'; 4 | import sqlite3 from 'sqlite3'; 5 | 6 | const app = createServer(); 7 | describe('kafka unit tests', () => { 8 | beforeAll(() => { 9 | open({ 10 | filename: '/tmp/database.db', 11 | driver: sqlite3.Database, 12 | }).then((db) => { 13 | db.exec( 14 | 'CREATE TABLE test_test_undefined_ (topic, partition_0, partition_1);' 15 | ); 16 | db.exec( 17 | "INSERT INTO test_test_undefined_ (topic, partition_0, partition_1) VALUES ('lets_get_that_meat', 10, 1);" 18 | ); 19 | }); 20 | }); 21 | afterAll(() => { 22 | open({ 23 | filename: '/tmp/database.db', 24 | driver: sqlite3.Database, 25 | }).then((db) => { 26 | db.exec('DROP TABLE test_test_undefined_'); 27 | }); 28 | }); 29 | //cannot test create tables or refresh, as those routes require a running kafka instance 30 | it('should fetch all tables from local sql database', async () => { 31 | const result = await request(app) 32 | .post('/kafka/fetchTables') 33 | .send() 34 | .expect(200); 35 | expect( 36 | result.body.filter((el: { name: string }) => el.name === 'test_test') 37 | .length > 0 38 | ).toBe(true); 39 | }); 40 | it('should fetch topics from local sql database', async () => { 41 | const result = await request(app) 42 | .post('/kafka/fetchTopics') 43 | .send({ bootstrap: 'test_test' }) 44 | .expect(200); 45 | expect(result.body).toEqual([{ topic: 'lets_get_that_meat' }]); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /client/components/App.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import '../../client/scss/App.scss'; 3 | import LoginPage from './LoginPage'; 4 | import Graph from './Graph'; 5 | import Selector from './Selector'; 6 | import * as d3 from 'd3'; 7 | import SignUpPage from './SignUpPage'; 8 | import axios from 'axios'; 9 | const App = (): JSX.Element => { 10 | // defining state variables and functions 11 | const [xScale, setXScale] = React.useState< 12 | d3.ScaleLinear 13 | >(d3.scaleLinear().range([0, 0]).domain([0, 0])); 14 | const [consumerList, setConsumerList] = React.useState(null); 15 | const [loginStatus, changeLoginStatus] = React.useState(false); 16 | const [loginAttempt, changeAttempt] = React.useState(null); 17 | const [signUpStatus, changeSignUpStatus] = React.useState(false); 18 | const [currentUser, changeUser] = React.useState(''); 19 | const [rendering, setRendering] = React.useState(false); 20 | const [topic, setTopic] = React.useState(''); 21 | const [topicList, setTopicList] = React.useState([]); 22 | const [bootstrap, setBootstrap] = React.useState(''); 23 | const [serverList, setServerList] = React.useState([]); 24 | 25 | //graph rendering state -> 26 | const [data, setData] = React.useState< 27 | Array<{ time: number; value: number }> 28 | >([]); 29 | //function that sends a request to backend to replicate and rebalance load on selected topic using custom Kafka Streams, does not expect a response 30 | const balanceLoad = (): void => { 31 | const numPartitions: number = data.reduce((acc, val) => { 32 | //checking if value is null -> means partition does not exist 33 | if (val.value !== null && val.time > acc.time) return val; 34 | else return acc; 35 | }).time; 36 | axios({ 37 | method: 'post', 38 | data: { bootstrap, topic, numPartitions, currentUser }, 39 | url: 'http://localhost:3001/kafka/balanceLoad', 40 | }).then(() => { 41 | axios({ 42 | method: 'POST', 43 | url: 'http://localhost:3001/kafka/refresh', 44 | data: { topic: `${topic}_balanced`, bootstrap, currentUser }, 45 | }) 46 | .then((response: { data: [{ value: number; time: number }] }) => { 47 | return response; 48 | }) 49 | .then((response) => { 50 | d3.selectAll('.bar').remove(); 51 | setData(response.data); 52 | setTopic(`${topic}_balanced`); 53 | const input = document.querySelector('#topics') as HTMLSelectElement; 54 | input.value = `${topic}_balanced`; 55 | //checking if user selected blank topic (if so, graph should disappear) 56 | }); 57 | }); 58 | }; 59 | // login button function 60 | const loginButton = () => { 61 | // username is input value in usernmae field 62 | const username: string | null = ( 63 | document.querySelector('#username') as HTMLInputElement 64 | ).value; 65 | 66 | // password is input value in password field 67 | const password: string | null = ( 68 | document.querySelector('#password') as HTMLInputElement 69 | ).value; 70 | 71 | // if username or password are empty inputs, display error message 72 | if (username == '' || password == '') { 73 | const result = 'Please enter your username and password to log in'; 74 | changeAttempt(result); 75 | 76 | // if username and password are filled out, send fetch request to backend to see if user/ pw is correct 77 | } else { 78 | const user: { username: string; password: string } = { 79 | username, 80 | password, 81 | }; 82 | 83 | fetch('http://localhost:3001/login', { 84 | method: 'POST', 85 | headers: { 'Content-Type': 'application/json' }, 86 | body: JSON.stringify(user), 87 | }) 88 | // if username or password are empty, have user try again 89 | .then((res) => { 90 | if (res.status === 200) { 91 | changeUser(username); 92 | changeLoginStatus(true); 93 | } else { 94 | changeAttempt('Incorrect username or password. Please try again.'); 95 | } 96 | }) 97 | .catch((err) => { 98 | changeAttempt('Incorrect username or password. Please try again.'); 99 | console.log(err); 100 | }); 101 | } 102 | }; 103 | 104 | const signUpButton = () => { 105 | changeSignUpStatus(!signUpStatus); 106 | }; 107 | 108 | // Sign Up functionality 109 | const signUp = () => { 110 | const username: string | null = ( 111 | document.querySelector('#username') as HTMLInputElement 112 | ).value; 113 | const password: string | null = ( 114 | document.querySelector('#password') as HTMLInputElement 115 | ).value; 116 | 117 | if (username == '' || password == '') { 118 | const result = 'Please fill out the username and password fields'; 119 | changeAttempt(result); 120 | } else if (password.length < 6) { 121 | const result = 'Please create a strong password longer than 6 characters'; 122 | changeAttempt(result); 123 | } else { 124 | const user: { username: string; password: string } = { 125 | username: username, 126 | password: password, 127 | }; 128 | fetch('http://localhost:3001/signup', { 129 | method: 'POST', 130 | headers: { 'Content-Type': 'application/json' }, 131 | body: JSON.stringify(user), 132 | }) 133 | .then((res) => { 134 | if (res.status == 200) { 135 | alert('Signup Successful! Please login to proceed.'); 136 | location.reload(); 137 | } else 138 | changeAttempt( 139 | 'User already exists. Please try a different username.' 140 | ); 141 | }) 142 | .catch((err) => console.log(err)); 143 | } 144 | }; 145 | 146 | const logOut = async () => { 147 | fetch('http://localhost:3001/logout'), 148 | { 149 | method: 'POST', 150 | headers: { 'Content-Type': 'application/json' }, 151 | body: JSON.stringify(currentUser), 152 | }; 153 | changeUser(''); 154 | changeLoginStatus(false); 155 | changeAttempt(null); 156 | setData([]); 157 | setTopicList([]); 158 | setConsumerList([]); 159 | setServerList([]); 160 | setTopic(''); 161 | setBootstrap(''); 162 | }; 163 | 164 | React.useEffect(() => { 165 | setRendering(false); 166 | }, []); 167 | if (signUpStatus === true) { 168 | return ( 169 |
170 | 171 |
172 | ); 173 | } 174 | if (loginStatus === false) { 175 | return ( 176 |
177 | 182 |
183 | ); 184 | } else if (loginStatus === true) { 185 | return ( 186 |
187 | 204 |
205 | 217 |
218 | 223 | 224 | 225 | 228 |
229 |
230 |
231 | broker 232 |
233 | consumer 234 |
235 | consumerGroup 236 |
237 | topic 238 |
239 | 244 | 245 | 246 |
247 |
248 | ); 249 | } 250 | return
; 251 | }; 252 | 253 | export default App; 254 | -------------------------------------------------------------------------------- /client/components/Graph.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as d3 from 'd3'; 3 | import '../../client/scss/Graph.scss'; 4 | import axios from 'axios'; 5 | import * as _ from 'lodash'; 6 | interface Props { 7 | currentUser: string; 8 | setData: (arg: any) => void; 9 | topic: string; 10 | xScale: d3.ScaleLinear; 11 | setXScale: (arg: () => d3.ScaleLinear) => void; 12 | data: Array<{ time: number; value: number }>; 13 | consumerList: any; 14 | bootstrap: string; 15 | topicList: string[]; 16 | setTopic: (arg: any) => void; 17 | } 18 | const Graph = ({ 19 | currentUser, 20 | setData, 21 | bootstrap, 22 | data, 23 | xScale, 24 | topicList, 25 | setXScale, 26 | topic, 27 | consumerList, 28 | setTopic, 29 | }: Props): JSX.Element => { 30 | //method to render bar graph of current data 31 | 32 | const renderGraph = () => { 33 | //defining dimensions 34 | const margin: { 35 | top: number; 36 | bottom: number; 37 | left: number; 38 | right: number; 39 | } = { 40 | top: 40, 41 | bottom: 40, 42 | left: 40, 43 | right: 40, 44 | }; 45 | const height = 300 - margin.top - margin.bottom; 46 | const width = 300 - margin.left - margin.right; 47 | //calculating max for x and y axis 48 | let dataTimeMax: number; 49 | let dataValueMax: number; 50 | try { 51 | dataTimeMax = data.reduce( 52 | (acc, val) => { 53 | //checking if value is null -> means partition does not exist 54 | if (val.value !== null && val.time > acc.time) return val; 55 | else return acc; 56 | }, 57 | { time: 0 } 58 | ).time; 59 | dataValueMax = data.reduce( 60 | (acc, val) => { 61 | if (val.value > acc.value) return val; 62 | else return acc; 63 | }, 64 | { value: 0 } 65 | ).value; 66 | } catch { 67 | dataTimeMax = 0; 68 | dataValueMax = 0; 69 | } 70 | //defining the limits for the binning function (each partition should have its own group) 71 | const newArr: number[] = []; 72 | for (let i = 0; i <= dataTimeMax; i++) { 73 | newArr.push(i); 74 | } 75 | const barWidth = width / (dataTimeMax + 1) - 1; 76 | if (data.length) { 77 | //removing old x and y axis and labels to make room for new axes 78 | d3.select('.xAxis').remove(); 79 | d3.select('.yAxis').remove(); 80 | d3.select('.axis-label').remove(); 81 | //transforming data from backend to be in correct form (frequency array) 82 | const newData: number[] = []; 83 | data.forEach((el) => { 84 | for (let i = 0; i < el.value; i++) { 85 | newData.push(el.time); 86 | } 87 | }); 88 | //setting dimensions of main container and subcontainer 89 | d3.select('#graphContainer') 90 | .attr('width', width + margin.left + margin.right) 91 | .attr('height', height + margin.top + margin.bottom); 92 | d3.select('.graphy').attr( 93 | 'transform', 94 | `translate(${margin.left / 2}, ${margin.top / 2 + 10})` 95 | ); 96 | //zoom function which grabs the new length of window, then resizes bars and x-axis 97 | const zoom = ( 98 | arg: d3.Selection 99 | ): void => { 100 | const extent: [[number, number], [number, number]] = [ 101 | [margin.left, margin.top], //new min width and min height after zoom 102 | [width - margin.right, height - margin.top], //new max width and height after zoom 103 | ]; 104 | const zoomed = (event: d3.D3ZoomEvent) => { 105 | xScale.range([0, width].map((d) => event.transform.applyX(d))); //applying transform from user 106 | const newBarWidth = 107 | (Math.abs(xScale.range()[1] - xScale.range()[0]) / width) * 108 | barWidth; //not perfectly correct, off by 1, which is noticeable for small partition size, probably something to do with domain being +- 0.5 on x-axis 109 | arg 110 | .selectAll('.bar') 111 | .attr('width', newBarWidth - 1) 112 | .attr('transform', (d: any): string => { 113 | //updating x-y coords for each bar and width 114 | return `translate(${xScale(d!.x0)} ,${yScale(d.length)})`; 115 | }) 116 | .attr('height', (d: any) => `${height - yScale(d.length) - 2}`); 117 | d3.select('.xAxis').call( 118 | d3 119 | .axisBottom(xScale) 120 | .tickValues(xAxisTicks) 121 | .tickFormat(d3.format('d')) 122 | ); //resetting x-axis to use new range 123 | setXScale(() => xScale); //saving new xScale 124 | }; 125 | //actually applying the d3.zoom event onto the passed in element 126 | arg.call( 127 | d3 128 | .zoom() 129 | .scaleExtent([1, 8]) //i'm pretty sure this is just granularity of the zoom 130 | .translateExtent(extent) 131 | .extent(extent) 132 | .on('zoom', zoomed) //on zoom event, invoke above zoomed function 133 | ); 134 | }; 135 | const svg: d3.Selection = 136 | d3.select('.graphy'); 137 | //appending zoom feature onto the svg 138 | svg.call(zoom); 139 | //calculating x-y scales 140 | const xScale = d3 141 | .scaleLinear() 142 | .domain([-0.5, dataTimeMax + 0.5]) //need to have +- 0.5 on each half because the partition is centered on each bar 143 | .range([0, width]); 144 | const yScale = d3 145 | .scaleLinear() 146 | .domain([0, Math.ceil(dataValueMax * 1.2)]) //this is just so the bar doesn't hit the very top of the graph, just for looks 147 | .range([height, 0]); 148 | //creating the binning function that will group the data into bins according to the newArr(e.g. [0,1,2,3,4]) thresholds 149 | const histogram = d3 150 | .bin() 151 | .value((d) => d) 152 | .thresholds(newArr); 153 | //creating the actual bins and then filtering out any undefined returns 154 | let bars = histogram(newData); 155 | bars = bars.filter((el) => el.x0 !== undefined); 156 | //appending a clip path so when we zoom graph doesn't go past the left and right margins 157 | svg 158 | .append('defs') 159 | .append('svg:clipPath') 160 | .attr('id', 'clip') 161 | .append('svg:rect') 162 | .attr('width', width) 163 | .attr('height', height + 10) 164 | .attr('x', margin.left) 165 | .attr('y', margin.top) 166 | .style('font-size', '16px') 167 | .style('text-decoration', 'underline') 168 | .text('Value vs Date Graph'); 169 | //making sure that x-axis and y-axis ticks are integers only! 170 | const xAxisTicks = xScale 171 | .ticks() 172 | .filter((tick) => Number.isInteger(tick)); 173 | const xAxis = d3 174 | .axisBottom(xScale) 175 | .tickValues(xAxisTicks) 176 | .tickFormat(d3.format('d')); 177 | const yAxisTicks = yScale 178 | .ticks() 179 | .filter((tick) => Number.isInteger(tick)); 180 | const yAxis = d3 181 | .axisLeft(yScale) 182 | .tickValues(yAxisTicks) 183 | .tickFormat(d3.format('d')); 184 | //appending the bars and x-axis to a new g element which is clippable and references the clip path above so that these two things that are the only thing clipped 185 | svg 186 | .append('g') 187 | .attr('class', 'clippable') 188 | .attr('clip-path', 'url(#clip)') 189 | .selectAll('rect') 190 | .data(bars) 191 | .join('rect') 192 | .attr('class', 'bar') 193 | .attr('x', (d) => d.x0!) 194 | .attr('transform', function (d) { 195 | return ( 196 | 'translate(' + 197 | (d.x0! * barWidth + margin.left) + //calculating x-offset 198 | ',' + 199 | yScale(d.length) + //calculating y-offset (starts from top!`) 200 | ')' 201 | ); 202 | }) 203 | .attr('width', `${barWidth - 1}`) 204 | .attr('height', function (d) { 205 | return height - yScale(d.length); 206 | }) 207 | .style('fill', '#69b3a2'); 208 | d3.select('.clippable') 209 | .append('g') 210 | .call(xAxis) 211 | .attr('class', 'xAxis') 212 | .attr('transform', `translate(${margin.left},${height})`) 213 | //adding label 214 | .append('text') 215 | .attr('class', 'axis-label') 216 | .text('Partition Index') 217 | .attr('text-anchor', 'middle') 218 | .attr('x', width / 2) 219 | .attr('y', 30); // Relative to the x axis. 220 | //appending y-axis directly to graph, cause we don't want it to be clipped 221 | svg 222 | .append('g') 223 | .attr('class', 'yAxis') 224 | .attr('transform', `translate(${margin.left},0)`) 225 | .call(yAxis) 226 | //adding label 227 | .append('text') 228 | .attr('class', 'axis-label') 229 | .text('Offsets for each partition') 230 | .attr('text-anchor', 'middle') 231 | .attr('transform', 'rotate(-90)') 232 | .attr('x', -width / 2) 233 | .attr('y', -25); // Relative to the y axis. 234 | //adding an invisible rectangle to svg so that anywhere within graph area you can zoom, as zoom only works on filled elements 235 | svg 236 | .append('rect') 237 | .attr('width', width) 238 | .attr('height', height) 239 | .attr('fill', '#fff') 240 | .attr('opacity', 0); 241 | } 242 | }; 243 | // nodes: {id: string, group: numorString}[] -> each of the circles (brokers, topics, consumer(groups)) 244 | // links: {source: string, target: string, value: num}[] -> connections between each node, value is strength of force attraction 245 | //method that actually renders chart 246 | const chart = () => { 247 | //dimensions for chart 248 | const margin: { 249 | top: number; 250 | bottom: number; 251 | left: number; 252 | right: number; 253 | } = { 254 | top: 40, 255 | bottom: 40, 256 | left: 40, 257 | right: 40, 258 | }; 259 | const height = 300 - margin.top - margin.bottom; 260 | const width = 300 - margin.left - margin.right; 261 | d3.select('#chartContainer') 262 | .attr('width', width + margin.left + margin.right) 263 | .attr('height', height + margin.top + margin.bottom); 264 | d3.select('.charty').attr( 265 | 'transform', 266 | `translate(${margin.left * 1.5 - 5}, ${margin.top / 2})` 267 | ); 268 | const colorDict: { [key: string]: string } = { 269 | broker: 'red', 270 | consumer: 'blue', 271 | consumerGroup: 'green', 272 | topic: 'yellow', 273 | }; 274 | const nodes: any = []; 275 | const links: any = []; 276 | //adding bootstraps, topics, consumers(groups) to nodes array and the corresponding connections links array 277 | if (bootstrap.length) nodes.push({ id: bootstrap, group: 'broker' }); 278 | if (topicList.length) { 279 | topicList.forEach((el) => { 280 | nodes.push({ id: el, group: 'topic' }); 281 | links.push({ source: el, target: bootstrap, value: 10 }); 282 | }); 283 | } 284 | if (consumerList && consumerList.length) { 285 | consumerList.forEach( 286 | (consumerGroupArray: { 287 | groups: [ 288 | { 289 | groupId: string; 290 | members: [ 291 | { 292 | clientId: string; 293 | memberId: string; 294 | stringifiedMetadata: string; 295 | } 296 | ]; 297 | } 298 | ]; 299 | }) => { 300 | consumerGroupArray.groups.forEach((consumerGroup) => { 301 | if (consumerGroup.members.length) { 302 | //below, each saamsaLoadBalancer stream has groupid of saamsaLoadBalancer%%%topic_name -> want to consolidate all of these onto individual node for viz purposes 303 | if ( 304 | !nodes.some( 305 | //only push that node once 306 | (el: { id: string }) => el.id === 'saamsaLoadBalancer' 307 | ) 308 | ) 309 | nodes.push({ 310 | id: consumerGroup.groupId, 311 | group: 'consumerGroup', 312 | }); 313 | consumerGroup.members.forEach((consumer) => { 314 | nodes.push({ id: consumer.memberId, group: 'consumer' }); 315 | links.push({ 316 | source: consumerGroup.groupId, 317 | target: consumer.memberId, 318 | value: 10, 319 | }); 320 | if ( 321 | consumer && 322 | consumer.stringifiedMetadata.length && //stringifiedMetadata is the assigned topic 323 | consumer.stringifiedMetadata !== 'topic_not_found' //for unattached consumers, if so, then do not connect to a topic 324 | ) { 325 | links.push({ 326 | source: consumer.memberId, 327 | target: consumer.stringifiedMetadata, 328 | value: 4, 329 | }); 330 | } else { 331 | console.log('topic_not_found'); 332 | } 333 | }); 334 | } 335 | }); 336 | } 337 | ); 338 | } 339 | const svg = d3.select('.charty'); 340 | //the physics behind how the nodes interact with each other 341 | const simulation = d3 342 | .forceSimulation(nodes) 343 | .force( 344 | 'link', 345 | d3.forceLink(links).id((d: any) => d.id) 346 | ) 347 | .force('charge', d3.forceManyBody()) 348 | .force('center', d3.forceCenter(width / 2, height / 2)); 349 | //constructing lines between nodes 350 | const link = svg 351 | .append('g') 352 | .attr('height', 200) 353 | .attr('width', 200) 354 | .attr('stroke', '#999') 355 | .attr('stroke-opacity', 0.6) 356 | .selectAll('line') 357 | .data(links) 358 | .join('line') 359 | .attr('stroke-width', (d: any) => Math.sqrt(d.value)) 360 | .attr('stroke-width', 2); 361 | //constucting node holders (hold circle and it's text) 362 | const node: any = svg 363 | .selectAll('.node') 364 | .data(nodes) 365 | .join('g') 366 | .attr('class', 'node') 367 | .call(drag(simulation)); 368 | //constructing circles 369 | const circles = node 370 | .append('circle') 371 | .attr('class', (d: any) => d.group) 372 | .attr('r', 7) 373 | .attr('fill', (d: any) => { 374 | return colorDict[d.group]; 375 | }); 376 | //constructing text for each node 377 | const labels = node 378 | .append('text') 379 | .attr('dy', '.35em') 380 | .text(function (d: any) { 381 | return d.id; 382 | }); 383 | //adding changetopic functionality to node map on click of a node 384 | d3.selectAll('.topic').on('click', (event) => { 385 | const selectedText = event.target.nextElementSibling.innerHTML; 386 | const oldTopic = 387 | document.querySelector('#topics')?.value; 388 | if (selectedText === oldTopic) return; 389 | if (bootstrap.length && selectedText) { 390 | //making initial request so we instantly update the data 391 | axios({ 392 | method: 'POST', 393 | url: 'http://localhost:3001/kafka/refresh', 394 | data: { topic: selectedText, bootstrap, currentUser }, 395 | }) 396 | .then((response: { data: [{ value: number; time: number }] }) => { 397 | return response; 398 | }) 399 | .then((response) => { 400 | //removing old bars in bar graph 401 | d3.selectAll('.bar').remove(); 402 | setData(response.data); 403 | setTopic(selectedText); 404 | //changing topic in selector to match the clicked node 405 | document.querySelector('#topics')!.value = 406 | selectedText; 407 | }); 408 | } 409 | }); 410 | //adding event listener to simulation to update x/y-coords of nodes, connecting lines, and text upon dragging 411 | simulation.on('tick', () => { 412 | link 413 | .attr('x1', (d: any) => d.source.x) 414 | .attr('y1', (d: any) => d.source.y) 415 | .attr('x2', (d: any) => d.target.x) 416 | .attr('y2', (d: any) => d.target.y); 417 | circles.attr('cx', (d: any) => d.x).attr('cy', (d: any) => d.y); 418 | labels 419 | .attr('x', function (d: any) { 420 | return d.x + 8; 421 | }) 422 | .attr('y', function (d: any) { 423 | return d.y; 424 | }); 425 | }); 426 | return svg.node(); 427 | }; 428 | //actual method which applies drag functionality 429 | const drag = (simulation: any) => { 430 | function dragstarted(event: any) { 431 | //reequilibrates connection network to normal pattern slowly 432 | if (!event.active) simulation.alphaTarget(0.3).restart(); 433 | event.subject.fx = event.subject.x; 434 | event.subject.fy = event.subject.y; 435 | } 436 | 437 | function dragged(event: any) { 438 | event.subject.fx = event.x; 439 | event.subject.fy = event.y; 440 | } 441 | 442 | function dragended(event: any) { 443 | //reequilibrates connection network to normal pattern more quickly 444 | if (!event.active) simulation.alphaTarget(0); 445 | event.subject.fx = null; 446 | event.subject.fy = null; 447 | } 448 | 449 | return d3 450 | .drag() 451 | .on('start', dragstarted) 452 | .on('drag', dragged) 453 | .on('end', dragended); 454 | }; 455 | //method to update graph and move bars/axes around upon new data being saved in state 456 | const updateGraph = () => { 457 | const margin: { 458 | top: number; 459 | bottom: number; 460 | left: number; 461 | right: number; 462 | } = { 463 | top: 40, 464 | bottom: 40, 465 | left: 40, 466 | right: 40, 467 | }; 468 | const height = 300 - margin.top - margin.bottom; 469 | let dataTimeMax: number; 470 | let dataValueMax: number; 471 | try { 472 | dataTimeMax = data.reduce( 473 | (acc, val) => { 474 | //checking if value is null -> means partition does not exist 475 | if (val.value !== null && val.time > acc.time) return val; 476 | else return acc; 477 | }, 478 | { time: 0 } 479 | ).time; 480 | dataValueMax = data.reduce( 481 | (acc, val) => { 482 | if (val.value > acc.value) return val; 483 | else return acc; 484 | }, 485 | { value: 0 } 486 | ).value; 487 | } catch (error) { 488 | dataValueMax = 0; 489 | dataTimeMax = 0; 490 | } 491 | //defining the limits for the binning function (each partition should have its own group) 492 | const newArr: number[] = []; 493 | for (let i = 0; i <= dataTimeMax; i++) { 494 | newArr.push(i); 495 | } 496 | const newData: number[] = []; 497 | data.forEach((el) => { 498 | for (let i = 0; i < el.value; i++) { 499 | newData.push(el.time); 500 | } 501 | }); 502 | const yScale = d3 503 | .scaleLinear() 504 | .domain([0, Math.ceil(dataValueMax * 1.2)]) //this is just so the bar doesn't hit the very top of the graph, just for looks 505 | .range([height, 0]); 506 | const histogram = d3 507 | .bin() 508 | .value((d) => d) 509 | .thresholds(newArr); 510 | //creating the actual bins and then filtering out any undefined returns 511 | let bars = histogram(newData); 512 | bars = bars.filter((el) => el.x0 !== undefined); 513 | d3.selectAll('.bar') 514 | .data(bars) 515 | .join('rect') 516 | .attr('class', 'bar') 517 | .attr('x', (d: any) => d.x0!) 518 | .attr('transform', function (d: any) { 519 | if (xScale) { 520 | return ( 521 | 'translate(' + 522 | xScale(d!.x0) + //calculating x-offset 523 | ',' + 524 | yScale(d.length) + //calculating y-offset (starts from top!`) 525 | ')' 526 | ); 527 | } else { 528 | return 'translate(0,0)'; 529 | } 530 | }) 531 | .attr('height', function (d: any) { 532 | return height - yScale(d.length) - 2; 533 | }) 534 | .style('fill', '#69b3a2'); 535 | }; 536 | if (process.env.NODE_ENV !== 'testing') { 537 | React.useEffect(() => { 538 | //draws new graph when new topic selected 539 | renderGraph(); 540 | }, [topic]); 541 | React.useEffect(() => { 542 | //updates graph when data changes if graph is rendered 543 | if (d3.selectAll('.bar').size() > 0) { 544 | updateGraph(); 545 | } 546 | }, [data]); 547 | React.useEffect(() => { 548 | //removes old node map and redraws it when new topic added or new consumer added 549 | d3.selectAll('.charty g').remove(); 550 | chart(); 551 | }, [topicList, consumerList]); 552 | } 553 | return
; 554 | }; 555 | 556 | export default Graph; 557 | -------------------------------------------------------------------------------- /client/components/LoginPage.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-empty-function */ 2 | import * as React from 'react'; 3 | import '../../client/scss/LoginPage.scss'; 4 | 5 | type Props = { 6 | loginAttempt: string | null; 7 | loginButton: () => void; 8 | signUpButton: () => void; 9 | }; 10 | 11 | const LoginPage = ({ 12 | loginAttempt, 13 | loginButton, 14 | signUpButton, 15 | }: Props): JSX.Element => { 16 | return ( 17 |
18 |
19 |

Saamsa

20 |
21 | 25 |
26 |
Welcome
27 | 28 |
29 | 36 | 44 |
45 | 46 |
47 | 55 | 56 |
{loginAttempt}
57 | 58 | {/*
59 |

Don't have an account?

60 |
*/} 61 | 69 |
70 |
71 |
72 | ); 73 | }; 74 | export default LoginPage; 75 | -------------------------------------------------------------------------------- /client/components/Selector.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import axios from 'axios'; 3 | import '../../client/scss/Selector.scss'; 4 | import * as d3 from 'd3'; 5 | import * as _ from 'lodash'; 6 | import useInterval from './useInterval'; 7 | interface Props { 8 | logOut: () => void; 9 | currentUser: string; 10 | data: { time: number; value: number }[]; 11 | setData: (arg: { time: number; value: number }[]) => void; 12 | topic: string; 13 | setTopic: (arg: string) => void; 14 | serverList: string[]; 15 | setServerList: (arg: string[]) => void; 16 | topicList: string[]; 17 | setTopicList: (arg: string[]) => void; 18 | bootstrap: string; 19 | setBootstrap: (arg: string) => void; 20 | consumerList: any; 21 | setConsumerList: (arg: any) => void; 22 | } 23 | const Selector = ({ 24 | logOut, 25 | currentUser, 26 | consumerList, 27 | data, 28 | setData, 29 | setTopic, 30 | topic, 31 | serverList, 32 | setServerList, 33 | topicList, 34 | setTopicList, 35 | bootstrap, 36 | setBootstrap, 37 | setConsumerList, 38 | }: Props): JSX.Element => { 39 | //update SQL tables 40 | const updateTables = (arg: string | undefined): void => { 41 | if (!arg || !arg.length) arg = bootstrap; 42 | axios({ 43 | method: 'post', 44 | url: 'http://localhost:3001/kafka/updateTables', 45 | data: { bootstrap: arg, currentUser }, 46 | }).then((response) => { 47 | const temp: { topic: string }[] = [...response.data]; 48 | let resultArr = temp.map((el) => el.topic); 49 | resultArr = resultArr.filter((topic) => topic !== '__consumer_offsets'); 50 | if (!_.isEqual(topicList, resultArr)) setTopicList(resultArr); 51 | }); 52 | }; 53 | 54 | //below creates an array filled with options for the bootstrap servers 55 | const serverListArr: JSX.Element[] = []; 56 | for (let i = 0; i < serverList.length; i++) { 57 | serverListArr.push( 58 | 65 | ); 66 | } 67 | 68 | //below creates an array filled with options for the topics of selected bootstrap 69 | const topicListArr: JSX.Element[] = []; 70 | for (let i = 0; i < topicList.length; i++) { 71 | topicListArr.push( 72 | 79 | ); 80 | } 81 | 82 | //custom function that sends a post request to backend to try grab data from broker at user-inputted host:port 83 | const createTable = (): void => { 84 | //change this to be compatible with enzyme testing, use event.target.etcetc 85 | const newBootstrap: HTMLInputElement | null = 86 | document.querySelector('#bootstrapInput'); 87 | //checking to make sure that the server isnt already in the list before sending the request to the backend 88 | if (!serverList.includes(`${newBootstrap?.value.replace(':', '_')}`)) { 89 | axios({ 90 | url: 'http://localhost:3001/kafka/createTable', 91 | method: 'post', 92 | data: { bootstrap: newBootstrap?.value, currentUser }, 93 | }) //if successful, we then repopulate all of our tables, as db has been updated 94 | .then(() => fetchTables()) 95 | .then(() => { 96 | if (newBootstrap) { 97 | serverList.push(newBootstrap.value.replace(':', '_')); 98 | setServerList(serverList); 99 | //for UX, adding new option to the broker dropdown, then adding it to the dropdown then selecting it so it feels nicer for the user 100 | const input = document.querySelector( 101 | '#bootstrap' 102 | ) as HTMLSelectElement; 103 | // const option = document.createElement('option'); 104 | // option.className = 'serverOption'; 105 | // option.value = newBootstrap.value.replace(':', '_'); 106 | // option.innerHTML = newBootstrap.value.replace(':', '_'); 107 | // input.appendChild(option); 108 | setTimeout(() => { 109 | input.value = newBootstrap.value.replace(':', '_'); 110 | setBootstrap(newBootstrap.value); 111 | }, 1000); 112 | } 113 | }) 114 | .catch((error) => console.log(error)); 115 | } 116 | }; 117 | 118 | //sends a request to backend to grab all broker-tables from sqldb 119 | const fetchTables = (): void => { 120 | axios({ 121 | method: 'post', 122 | url: 'http://localhost:3001/kafka/fetchTables', 123 | data: { currentUser }, 124 | }).then((response: { data: { name: string }[] }) => { 125 | //updating state to force rerender, so option appears on dropdown of bootstrap servers 126 | setServerList(response.data.map((el) => el.name)); 127 | }); 128 | }; 129 | 130 | //custom function that grabs the selected boostrap server from dropdown and then fetches the appropriate topics from db 131 | const changeServer = (): void => { 132 | //change this to be compatible with enzyme testing, use event.target.etcetc 133 | const newBootstrap: HTMLSelectElement | null = document.querySelector( 134 | '#bootstrap option:checked' 135 | ); 136 | if (newBootstrap?.value.length) { 137 | fetchTopics(newBootstrap.value); 138 | setBootstrap(newBootstrap.value.replace('_', ':')); 139 | } else { 140 | setConsumerList([]); 141 | setBootstrap(''); 142 | setTopicList([]); 143 | setTopic(''); 144 | } 145 | }; 146 | 147 | //sends a request to backend to grab topics for passed in bootstrap server 148 | const fetchTopics = (arg: string) => { 149 | axios({ 150 | url: 'http://localhost:3001/kafka/fetchTopics', 151 | method: 'post', 152 | data: { bootstrap: arg, currentUser }, 153 | }).then((response) => { 154 | //have to do this copying for typescript to allow mapping method, as response.data is not always an array 155 | const temp: { topic: string }[] = [...response.data]; 156 | let resultArr = temp.map((el) => el.topic); 157 | resultArr = resultArr.filter((topic) => topic !== '__consumer_offsets'); 158 | if (!_.isEqual(topicList, resultArr)) setTopicList(resultArr); 159 | }); 160 | }; 161 | 162 | //method that sends request to backend to grab all consumers of passed in bootstrap server 163 | const fetchConsumers = (arg: string) => { 164 | axios({ 165 | url: 'http://localhost:3001/kafka/fetchConsumers', 166 | method: 'post', 167 | data: { bootstrap: arg, currentUser }, 168 | }).then((response) => { 169 | //checking if consumerList is equal, as we do not need to needlessly set state and rerender 170 | if (!_.isEqual(consumerList, response.data)) 171 | setConsumerList(response.data); 172 | }); 173 | }; 174 | 175 | //updates topic state for app, and also sends a request to the backend to update the data with the new chosen topic's partition data 176 | const changeTopics = (): void => { 177 | //change this to be compatible with enzyme testing, use event.target.etcetc 178 | const newTopic: HTMLSelectElement | null = document.querySelector( 179 | '#topics option:checked' 180 | ); //grabbing current selected topic 181 | if (bootstrap.length && newTopic?.value.length) { 182 | //making initial request so we instantly update the data 183 | axios({ 184 | method: 'POST', 185 | url: 'http://localhost:3001/kafka/refresh', 186 | data: { topic: newTopic?.value, bootstrap, currentUser }, 187 | }) 188 | .then((response: { data: [{ value: number; time: number }] }) => { 189 | return response; 190 | }) 191 | .then((response) => { 192 | if (!_.isEqual(response.data, data)) { 193 | if (topic !== newTopic?.value) { 194 | d3.selectAll('.bar').remove(); 195 | } 196 | setData(response.data); 197 | if (newTopic?.value !== topic) setTopic(newTopic?.value); 198 | } //checking if user selected blank topic (if so, graph should disappear) 199 | }); 200 | } else if (!newTopic?.value.length) { 201 | //this is if the option chosen is the blank option 202 | setData([]); 203 | } 204 | }; 205 | 206 | if (process.env.NODE_ENV !== 'testing') { 207 | //geting past tables once component renders 208 | React.useEffect(() => { 209 | fetchTables(); 210 | }, []); 211 | 212 | //custom react hook to simulate setInterval, but avoids closure issues and uses most up to date state 213 | useInterval(() => { 214 | if (bootstrap.length) { 215 | updateTables(bootstrap); 216 | fetchTopics(bootstrap); 217 | fetchConsumers(bootstrap); 218 | if (topic.length) { 219 | changeTopics(); 220 | } 221 | } 222 | }, 3000); 223 | } 224 | 225 | return ( 226 |
227 |
228 |

Saamsa

229 |
230 |

Logged in as {currentUser}

231 | 235 |
236 |
237 | 238 |
239 |
240 |
241 | 255 |
256 | 257 | 275 |
276 | 277 |
278 |

Current broker:

279 | 288 |
289 | 290 |
291 |

Current topic:

292 | 303 |
304 |
305 |
306 | ); 307 | }; 308 | 309 | export default Selector; 310 | -------------------------------------------------------------------------------- /client/components/SignUpPage.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import '../../client/scss/LoginPage.scss'; 3 | 4 | type Props = { 5 | signUp: () => void; 6 | loginAttempt: string | null; 7 | 8 | } 9 | 10 | const SignUpPage = ({ 11 | signUp, 12 | loginAttempt 13 | }: Props): JSX.Element => { 14 | return ( 15 |
16 |
17 |

Saamsa

18 |
19 | 20 |
21 |
22 | Sign Up 23 |
24 | 25 | {/* */} 26 | 27 |
28 | 34 | 42 |
43 | 44 |
45 | 53 | {/* */} 54 |
{loginAttempt}
55 |
56 |
57 |
58 | ); 59 | }; 60 | export default SignUpPage; 61 | -------------------------------------------------------------------------------- /client/components/useInterval.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useLayoutEffect, useRef } from 'react'; 2 | 3 | function useInterval(callback: () => void, delay: number | null) { 4 | const savedCallback = useRef(callback); 5 | 6 | // Remember the latest callback if it changes. 7 | useLayoutEffect(() => { 8 | savedCallback.current = callback; 9 | }, [callback]); 10 | 11 | // Set up the interval. 12 | useEffect(() => { 13 | // Don't schedule if no delay is specified. 14 | if (!delay) { 15 | return; 16 | } 17 | 18 | const id = setInterval(() => savedCallback.current(), delay); 19 | 20 | return () => clearInterval(id); 21 | }, [delay]); 22 | } 23 | 24 | export default useInterval; 25 | -------------------------------------------------------------------------------- /client/images/cockroach.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/saamsa/d10bf2f88a4685e261055badfe26bcf64b236fa8/client/images/cockroach.jpeg -------------------------------------------------------------------------------- /client/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { render } from 'react-dom'; 3 | import App from './components/App'; 4 | 5 | render(, document.querySelector('#main')); 6 | -------------------------------------------------------------------------------- /client/scss/App.scss: -------------------------------------------------------------------------------- 1 | @import '_variables'; 2 | // .graph{ 3 | // height: 1000px; 4 | // width: 1000px; 5 | // background-color: $saamsaGrey; 6 | // } 7 | 8 | #graphContainer { 9 | @media (max-width: 800px) { 10 | top: 101vh; 11 | right: 20vw; 12 | } 13 | background-color: $saamsaGrey; 14 | position: absolute; 15 | right: 1vw; 16 | top: 36vh; 17 | border-radius: 8px; 18 | border: 4px solid $saamsaYellow; 19 | } 20 | #chartContainer { 21 | @media (max-width: 800px) { 22 | left: 20vw; 23 | top: 38vh; 24 | } 25 | background-color: $saamsaGrey; 26 | position: absolute; 27 | left: 1vw; 28 | top: 36vh; 29 | border-radius: 8px; 30 | border: 4px solid $saamsaYellow; 31 | display: flex; 32 | flex-direction: column; 33 | justify-content: flex-end; 34 | .charty { 35 | height: 200px; 36 | width: 200px; 37 | } 38 | } 39 | 40 | #legend { 41 | @media (max-width: 800px) { 42 | left: 30vw; 43 | } 44 | min-width: 250px; 45 | display: flex; 46 | justify-content: space-around; 47 | position: relative; 48 | z-index: 99; 49 | width: 38vw; 50 | top: 53vh; 51 | left: 3vw; 52 | div { 53 | height: 10px; 54 | position: relative; 55 | top: 3.5px; 56 | width: 10px; 57 | } 58 | .topicBlock { 59 | background-color: yellow; 60 | } 61 | .brokerBlock { 62 | background-color: red; 63 | } 64 | .consumerBlock { 65 | background-color: blue; 66 | } 67 | .consumerGroupBlock { 68 | background-color: green; 69 | } 70 | text { 71 | font-size: 11px; 72 | } 73 | } 74 | .loadBalanceBtn:hover { 75 | cursor: pointer; 76 | background-color: gray; 77 | color: white; 78 | } 79 | .loadBalanceBtn { 80 | @media (max-width: 800px) { 81 | top: 123vh; 82 | right: -34vw; 83 | } 84 | margin-top: 10px; 85 | border-radius: 4px; 86 | background-color: $saamsaYellow; 87 | border: 1px solid black; 88 | height: 30px; 89 | font-size: 15px; 90 | font-family: $buttonTitle; 91 | color: $saamsaBlack; 92 | width: 200px; 93 | position: relative; 94 | top: 56vh; 95 | right: -67vw; 96 | z-index: 1; 97 | } 98 | -------------------------------------------------------------------------------- /client/scss/Graph.scss: -------------------------------------------------------------------------------- 1 | @import '_variables'; 2 | body { 3 | background-color: $saamsaGrey; 4 | width: 100%; 5 | height: 100%; 6 | } 7 | 8 | .axis-label { 9 | fill: #000; 10 | font-size: 12px; 11 | text-anchor: middle; 12 | font-family: $TextFont; 13 | } 14 | 15 | h2 { 16 | font-size: 15px; 17 | font-family: $fontTitle; 18 | position: absolute; 19 | right: 165px; 20 | top: 275px; 21 | z-index: 1; 22 | } 23 | // #mainContainer{ 24 | // height: 600px; 25 | // width: 1000px; 26 | // background-color: $saamsaGrey; 27 | // // position: absolute; 28 | // // left: 1px; 29 | // // top: 300px; 30 | // // border-radius: 2px; 31 | // // border-style: solid; 32 | // // border-color: $saamsaBlack; 33 | // position: absolute; 34 | // left: 50%; 35 | // top: 78%; 36 | // transform: translate(-50%, -50%); 37 | // padding: 10px; 38 | 39 | // } 40 | svg { 41 | width: 42vw; 42 | height: 46vh; 43 | background-color: rgb(109, 165, 162); 44 | @media (max-width: 800px) { 45 | width: 60vw; 46 | } 47 | } 48 | .node text { 49 | display: none; 50 | color: black; 51 | font: 10px $TextFont; 52 | } 53 | .node:hover { 54 | text { 55 | display: inline; 56 | } 57 | circle { 58 | fill: gray; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /client/scss/LoginPage.scss: -------------------------------------------------------------------------------- 1 | @import '_variables'; 2 | body { 3 | background-color: $saamsaYellow; 4 | background-repeat: no-repeat; 5 | background-position-x: center; 6 | margin: 0px; 7 | width: 100%; 8 | height: 100%; 9 | // background-image: url('https://media.discordapp.net/attachments/890965712405946428/898253886811418634/roach_hat.png?width=722&height=722'); 10 | // background-attachment: fixed; 11 | // background-position: center; 12 | } 13 | .headingWrapper { 14 | background-color: $saamsaGrey; 15 | position: fixed; 16 | top: 0px; 17 | width: 100%; 18 | height: 60px; 19 | // align-items: center; 20 | // text-align:center; 21 | } 22 | .saamsaLogo { 23 | width: 450px; 24 | height: 450px; 25 | position: fixed; 26 | bottom: 0; 27 | } 28 | .heading { 29 | font-size: 60px; 30 | color: $saamsaBlack; 31 | opacity: 1; 32 | margin-bottom: 40px; 33 | margin-top: 0; 34 | font-family: $fontTitle; 35 | line-height: 51px; 36 | vertical-align: middle; 37 | } 38 | .loginTitle { 39 | font-family: $fontTitle; 40 | font-size: 30px; 41 | position: relative; 42 | left: 5px; 43 | float: left; 44 | margin-left: 7px; 45 | } 46 | 47 | .loginWrapper { 48 | display: flex; 49 | flex-direction: column; 50 | align-items: flex-start; 51 | background-color: $saamsaWhite; 52 | width: 220px; 53 | height: 360px; 54 | margin-top: 25%; 55 | margin-left: 60%; 56 | border: 2px solid black; 57 | } 58 | 59 | .inputFields { 60 | border: none; 61 | border-bottom: 2px solid black; 62 | width: 200px; 63 | margin-left: 7px; 64 | } 65 | #usernameAndPasswordWrapper { 66 | display: flex; 67 | flex-direction: column; 68 | gap: 40px; 69 | align-items: center; 70 | margin-bottom: 20px; 71 | margin-top: 20px; 72 | border: none; 73 | // border-bottom: 2px solid red; 74 | } 75 | 76 | #buttonsDiv { 77 | display: flex; 78 | flex-direction: column; 79 | align-items: center; 80 | gap: 10px; 81 | margin-left: 7px; 82 | } 83 | 84 | #forgotPassword { 85 | color: #313a46; 86 | font-family: Verdana, Geneva, Tahoma, sans-serif; 87 | font-size: 12px; 88 | background-color: transparent; 89 | border-color: transparent; 90 | margin-left: 7px; 91 | } 92 | // h2 { 93 | // margin-top: 30px; 94 | // color: #313A46; 95 | // font-family: Verdana, Geneva, Tahoma, sans-serif; 96 | // font-size: 12px; 97 | // } 98 | #loginBtn { 99 | border-radius: 4px; 100 | background-color: $saamsaYellow; 101 | border: 1px solid black; 102 | height: 30px; 103 | font-size: 15px; 104 | font-family: 'Franklin Gothic Medium', 'Arial Narrow', Arial, sans-serif; 105 | color: $saamsaBlack; 106 | width: 200px; 107 | } 108 | #loginBtn:hover { 109 | cursor: pointer; 110 | color: white; 111 | background-color: gray; 112 | } 113 | #noAccount { 114 | font-size: 10px; 115 | font-family: $buttonTitle; 116 | } 117 | #signUpArea { 118 | display: flex; 119 | flex-direction: row; 120 | align-items: center; 121 | justify-content: space-around; 122 | } 123 | 124 | #signUpButton { 125 | border-radius: 4px; 126 | background-color: transparent; 127 | border: 1px solid black; 128 | width: 90px; 129 | height: 30px; 130 | font-size: 10px; 131 | font-family: $buttonTitle; 132 | color: $saamsaBlack; 133 | } 134 | #signUpButton:hover { 135 | cursor: pointer; 136 | background-color: $saamsaYellow; 137 | } 138 | -------------------------------------------------------------------------------- /client/scss/Selector.scss: -------------------------------------------------------------------------------- 1 | @import '_variables'; 2 | .logOutBtn { 3 | border-radius: 4px; 4 | background-color: $saamsaGrey; 5 | border: 1px solid black; 6 | height: 30px; 7 | font-size: 15px; 8 | font-family: $buttonTitle; 9 | color: $saamsaBlack; 10 | width: 90px; 11 | } 12 | .mainWrapper { 13 | display: flex; 14 | flex-direction: row; 15 | gap: 10px; 16 | } 17 | .headingWrapper { 18 | background-color: $saamsaYellow; 19 | position: absolute; 20 | top: 0; 21 | width: 100%; 22 | height: 60px; 23 | } 24 | #heading { 25 | font-size: 60px; 26 | color: $saamsaBlack; 27 | opacity: 1; 28 | margin-bottom: 40px; 29 | margin-top: 0; 30 | font-family: $fontTitle !important; 31 | line-height: 51px; 32 | vertical-align: middle; 33 | } 34 | #loggedIn { 35 | @media (max-width: 650px) { 36 | flex-direction: column; 37 | align-items: flex-end; 38 | right: 15px; 39 | p { 40 | margin-top: -1rem; 41 | margin-bottom: -0.5rem; 42 | } 43 | } 44 | display: flex; 45 | gap: 10px; 46 | justify-content: right; 47 | position: absolute; 48 | top: 15px; 49 | right: 30px; 50 | font-family: $buttonTitle; 51 | align-content: center; 52 | } 53 | 54 | // #heading, #loggedIn{ 55 | // display:inline; 56 | // } 57 | .brokersDiv { 58 | background-color: $saamsaGrey; 59 | display: flex; 60 | flex-direction: column; 61 | align-items: center; 62 | margin-top: 60px; 63 | align-content: center; 64 | } 65 | 66 | .newBrokerDiv { 67 | display: flex; 68 | flex-direction: column; 69 | gap: 5px; 70 | margin-top: 10px; 71 | align-content: center; 72 | align-items: center; 73 | } 74 | // .or{ 75 | // margin-top:10px; 76 | // } 77 | 78 | .brokerSelector { 79 | @media (max-width: 800px) { 80 | left: 26vw; 81 | width: 50vw; 82 | } 83 | margin-top: 10px; 84 | display: flex; 85 | flex-direction: row; 86 | gap: 10px; 87 | align-items: center; 88 | position: absolute; 89 | top: 28vh; 90 | left: 2.5vw; 91 | width: 33vw; 92 | z-index: 1; 93 | text-align: center; 94 | align-content: center; 95 | } 96 | // #topicOption{ 97 | // background-color: $saamsaYellow; 98 | // padding: 0.2px 0.2px; 99 | // height: 30px; 100 | // width: 200px; 101 | // border-radius: 4px; 102 | // border: 1px solid black; 103 | // } 104 | .Btn:hover { 105 | cursor: pointer; 106 | background-color: gray; 107 | color: white; 108 | } 109 | .Btn { 110 | margin-top: 10px; 111 | border-radius: 4px; 112 | background-color: $saamsaYellow; 113 | border: 1px solid black; 114 | height: 30px; 115 | font-size: 15px; 116 | font-family: $buttonTitle; 117 | color: $saamsaBlack; 118 | width: 150px; 119 | } 120 | 121 | .logOutBtn:hover { 122 | cursor: pointer; 123 | background-color: #ffcc61; 124 | color: white; 125 | } 126 | .topicSelector { 127 | @media (max-width: 800px) { 128 | right: 26vw; 129 | width: 50vw; 130 | top: 93vh; 131 | } 132 | margin-top: 10px; 133 | display: flex; 134 | width: 33vw; 135 | flex-direction: row; 136 | gap: 10px; 137 | align-items: center; 138 | position: absolute; 139 | top: 28vh; 140 | right: 5vw; 141 | z-index: 1; 142 | text-align: center; 143 | align-content: center; 144 | } 145 | .dropDown { 146 | background-color: $saamsaYellow; 147 | padding: 0.2px 0.2px; 148 | height: 25px; 149 | width: 200px; 150 | border-radius: 4px; 151 | border: 1px solid black; 152 | align-content: center; 153 | font-size: 16px; 154 | margin-bottom: -1rem; 155 | } 156 | // .dropdownOptions{ 157 | // background-color: $saamsaYellow; 158 | // padding: 0.2px 0.2px; 159 | // height: 30px; 160 | // width: 200px; 161 | // } 162 | option { 163 | /* Whatever color you want */ 164 | background-color: #82caff; 165 | } 166 | .inputLabels { 167 | font-size: 16px; 168 | font-family: $TextFont; 169 | color: $saamsaBlack; 170 | text-align: center; 171 | width: 110px; 172 | margin-bottom: -1rem; 173 | } 174 | #brokerLabel { 175 | margin-bottom: 0rem; 176 | } 177 | 178 | .tooltipSpan { 179 | visibility: hidden; 180 | width: 250px; 181 | overflow-wrap: break-word; 182 | background-color: #000000; 183 | color: #ffffff; 184 | border-radius: 6px; 185 | position: absolute; 186 | z-index: 999; 187 | padding: 0.3rem; 188 | } 189 | 190 | img:hover + .tooltipSpan { 191 | visibility: visible; 192 | } 193 | #brokerTooltip { 194 | display: flex; 195 | img { 196 | height: 17px; 197 | width: 17px; 198 | position: relative; 199 | bottom: 2px; 200 | } 201 | } 202 | -------------------------------------------------------------------------------- /client/scss/_variables.scss: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css2?family=Bitter:wght@600&display=swap'); 2 | @import url('https://fonts.googleapis.com/css2?family=Overpass:wght@600&family=Roboto+Mono:wght@100&display=swap'); 3 | @import url('https://fonts.googleapis.com/css2?family=Rock+Salt&display=swap'); 4 | @import url('https://fonts.googleapis.com/css2?family=Architects+Daughter&display=swap'); 5 | @import url('https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Elsie+Swash+Caps&family=Playfair+Display+SC:wght@700&family=Rock+Salt&family=Stick+No+Bills&display=swap'); 6 | @import url('https://fonts.googleapis.com/css2?family=Barlow:wght@500&display=swap'); 7 | //Fonts used 8 | $fontTitle: Architects Daughter; 9 | $buttonTitle: Overpass; 10 | $TextFont: Barlow; 11 | 12 | //colors we like 13 | $saamsaYellow : #FFCC07; 14 | $saamsaBlack: #000000; 15 | $saamsaGrey: #CED4DA; 16 | $saamsaWhite: #FFFFFF; 17 | -------------------------------------------------------------------------------- /client/scss/application.scss: -------------------------------------------------------------------------------- 1 | @import "_variables"; -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | zookeeper: 4 | image: 'bitnami/zookeeper:latest' 5 | container_name: 'zookeeper' 6 | ports: 7 | - '2181:2181' 8 | environment: 9 | - ALLOW_ANONYMOUS_LOGIN=yes 10 | 11 | kafka: 12 | image: 'bitnami/kafka:latest' 13 | container_name: 'kafka' 14 | ports: 15 | - '29092:29092' 16 | - '29093:29093' 17 | environment: 18 | - KAFKA_BROKER_ID=1 19 | - KAFKA_LISTENERS=EXTERNAL_SAME_HOST://0.0.0.0:29092,INTERNAL://:9092,EXTERNAL_DIFFERENT_HOST://:29093 20 | - KAFKA_ADVERTISED_LISTENERS=INTERNAL://kafka:9092,EXTERNAL_SAME_HOST://localhost:29092,EXTERNAL_DIFFERENT_HOST://143.244.170.133:29093 21 | - KAFKA_LISTENER_SECURITY_PROTOCOL_MAP=INTERNAL:PLAINTEXT,EXTERNAL_SAME_HOST:PLAINTEXT,EXTERNAL_DIFFERENT_HOST:PLAINTEXT 22 | - KAFKA_INTER_BROKER_LISTENER_NAME=INTERNAL 23 | - KAFKA_ZOOKEEPER_CONNECT=zookeeper:2181 24 | - ALLOW_PLAINTEXT_LISTENER=yes 25 | depends_on: 26 | - zookeeper 27 | restart: always 28 | 29 | saamsaTestingApp: 30 | image: saamsa/testing_app 31 | container_name: 'saamsaTestingApp' 32 | ports: 33 | - '3000:3000' 34 | depends_on: 35 | - kafka 36 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | saamsa 9 | 10 | 11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | import { BrowserWindow, app } from 'electron'; 2 | import * as childProcess from 'child_process'; 3 | import * as path from 'path'; 4 | import * as url from 'url'; 5 | //so we need to compile down from ts to js upon loading app -> adding that to package.json 6 | function createWindow() { 7 | //this compiles but feels janky, how to create browserwindow as a class?? 8 | //can do this with import which automatically transpiles down to a require statement which is sooooooo nice 9 | const win: BrowserWindow = new BrowserWindow({ 10 | webPreferences: { 11 | nodeIntegration: true, 12 | }, 13 | width: 1000, 14 | height: 1000, 15 | }); 16 | win.loadURL( 17 | url.format({ 18 | pathname: path.join(__dirname, '../../index.html'), 19 | protocol: 'file:', 20 | slashes: true, 21 | }) 22 | ); 23 | } 24 | 25 | app.whenReady().then(async () => { 26 | const serverProcess = childProcess.fork( 27 | path.join(__dirname, '../server/server.js') 28 | ); 29 | createWindow(); 30 | app.on('activate', () => { 31 | if (BrowserWindow.getAllWindows().length === 0) createWindow(); 32 | }); 33 | 34 | //closes process if window closed and not on MacOS (keeps in dock for Mac though, as expected for Mac UX) 35 | app.on('window-all-closed', () => { 36 | if (process.platform !== 'darwin') app.quit(); 37 | serverProcess.kill(0); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ 2 | module.exports = { 3 | preset: 'ts-jest', 4 | testEnvironment: 'jsdom', 5 | transformIgnorePatterns: [ 6 | '/node_modules/(?!d3-(array|format|geo))', 7 | '/node_modules/(?!d3|d3-array|internmap|delaunator|robust-predicates)', 8 | ], 9 | moduleNameMapper: { 10 | '^.+\\.(css|less|scss)$': 'babel-jest', 11 | }, 12 | }; 13 | -------------------------------------------------------------------------------- /loadBalancer.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/saamsa/d10bf2f88a4685e261055badfe26bcf64b236fa8/loadBalancer.jar -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "saamsa", 3 | "version": "1.0.0", 4 | "description": "an easy to use Kafka consumer/topic visualizer and topic replicator/load-balancer", 5 | "main": "./dist/electron/index.js", 6 | "scripts": { 7 | "webpack": "tsc client/index.tsx --declaration --outDir ./dist --module es2015 --jsx react --moduleResolution node && webpack && tsc ./server/server.ts ./server/createServer.ts --moduleResolution node --outDir ./dist/ --esModuleInterop true", 8 | "start": "tsc ./index.ts --moduleResolution node --outDir ./dist/electron --esModuleInterop true && nodemon ./dist/server/server.js", 9 | "test": "NODE_ENV=testing jest SignUpPageUnitTests GraphUnitTests SelectorUnitTests LoginPageUnitTests && NODE_ENV=testing jest --env=node serverUnitTests sqlUnitTests", 10 | "both": "npm run webpack && npm run start", 11 | "build": "tsc -p ./ && webpack", 12 | "package": "electron-forge package", 13 | "make": "electron-builder" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/oslabs-beta/saamsa.git" 18 | }, 19 | "author": "", 20 | "license": "ISC", 21 | "bugs": { 22 | "url": "https://github.com/oslabs-beta/firstrepo/issues" 23 | }, 24 | "homepage": "https://github.com/oslabs-beta/firstrepo#readme", 25 | "devDependencies": { 26 | "@electron-forge/cli": "^6.0.0-beta.61", 27 | "@electron-forge/maker-deb": "^6.0.0-beta.61", 28 | "@electron-forge/maker-rpm": "^6.0.0-beta.61", 29 | "@electron-forge/maker-squirrel": "^6.0.0-beta.61", 30 | "@electron-forge/maker-zip": "^6.0.0-beta.61", 31 | "@types/cookie-parser": "^1.4.2", 32 | "@types/d3": "^7.0.0", 33 | "@types/d3-scale": "^4.0.1", 34 | "@types/electron": "^1.6.10", 35 | "@types/enzyme": "^3.10.9", 36 | "@types/enzyme-adapter-react-16": "^1.0.6", 37 | "@types/express": "^4.17.13", 38 | "@types/jest": "^27.0.2", 39 | "@types/kafkajs": "^1.9.0", 40 | "@types/mongoose": "^5.11.97", 41 | "@types/react": "^17.0.33", 42 | "@types/react-dom": "^17.0.9", 43 | "@typescript-eslint/eslint-plugin": "^4.32.0", 44 | "@typescript-eslint/parser": "^4.32.0", 45 | "@wojtekmaj/enzyme-adapter-react-17": "^0.6.3", 46 | "babel-jest": "^27.2.4", 47 | "electron": "^15.3.0", 48 | "electron-forge": "^5.2.4", 49 | "electron-prebuilt-compile": "^8.2.0", 50 | "eslint": "^7.32.0", 51 | "eslint-plugin-react": "^7.26.1", 52 | "kafkajs": "^1.16.0-beta.22", 53 | "react": "^17.0.2", 54 | "react-dom": "^17.0.2", 55 | "ts-jest": "^27.0.5", 56 | "webpack-cli": "^4.8.0" 57 | }, 58 | "dependencies": { 59 | "@babel/core": "^7.15.5", 60 | "@babel/preset-env": "^7.15.6", 61 | "@babel/preset-react": "^7.14.5", 62 | "@testing-library/react": "^12.1.2", 63 | "@types/d3-brush": "^3.0.1", 64 | "@types/lodash": "^4.14.176", 65 | "@types/node": "^16.11.6", 66 | "@types/supertest": "^2.0.11", 67 | "axios": "^0.22.0", 68 | "babel-loader": "^8.2.2", 69 | "bcryptjs": "^2.4.3", 70 | "bootstrap": "^5.1.3", 71 | "cookie-parser": "^1.4.5", 72 | "css-loader": "^6.3.0", 73 | "d3": "6.7.0", 74 | "electron-squirrel-startup": "^1.0.0", 75 | "enzyme": "^3.11.0", 76 | "express": "^4.17.1", 77 | "java-caller": "^2.4.0", 78 | "jest": "^27.2.5", 79 | "lodash": "^4.17.21", 80 | "mongoose": "^6.0.9", 81 | "nodemon": "^2.0.14", 82 | "react-test-renderer": "^17.0.2", 83 | "sass": "^1.42.1", 84 | "sass-loader": "^12.1.0", 85 | "sqlite": "^4.0.23", 86 | "sqlite3": "^4.2.0", 87 | "style-loader": "^3.3.0", 88 | "supertest": "^6.1.6", 89 | "typescript": "^4.4.4", 90 | "webpack": "^5.56.0", 91 | "webpack-dev-server": "^4.3.0" 92 | }, 93 | "build": { 94 | "directories": { 95 | "output": "out" 96 | }, 97 | "asar": "false", 98 | "files": [ 99 | "build", 100 | "index.html", 101 | "dist" 102 | ] 103 | }, 104 | "config": { 105 | "forge": { 106 | "packagerConfig": { 107 | "dir": "./dist/electron" 108 | }, 109 | "makers": [ 110 | { 111 | "name": "@electron-forge/maker-squirrel", 112 | "config": { 113 | "name": "firstrepo" 114 | } 115 | }, 116 | { 117 | "name": "@electron-forge/maker-zip", 118 | "platforms": [ 119 | "darwin" 120 | ] 121 | }, 122 | { 123 | "name": "@electron-forge/maker-deb", 124 | "config": {} 125 | }, 126 | { 127 | "name": "@electron-forge/maker-rpm", 128 | "config": {} 129 | } 130 | ] 131 | } 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /server/controllers/cookieController.ts: -------------------------------------------------------------------------------- 1 | import * as types from '../../types'; 2 | 3 | const cookieController: Record = {}; 4 | 5 | // setting cookies 6 | 7 | cookieController.setCookie = (req, res, next) => { 8 | try { 9 | res.header('Access-Control-Allow-Origin', 'http://saamsa.io'); 10 | const expirationTime = 600000; // 600000 miliseconds or 10 minutes 11 | const user = res.locals.user; 12 | console.log('user in setCookie controller', user); 13 | res.cookie('user', user, { 14 | maxAge: expirationTime, 15 | httpOnly: true, 16 | sameSite: 'strict', 17 | secure: true, 18 | }); 19 | return next(); 20 | } catch (err) { 21 | const Error = { 22 | log: 'Error handler caught an error inside cookieController.setCookie', 23 | status: 500, 24 | message: { 25 | err: `An error occurred inside a middleware named cookieController.setCookie : ${err}`, 26 | }, 27 | }; 28 | next(Error); 29 | } 30 | }; 31 | 32 | // deleting cookies 33 | cookieController.deleteCookies = (req, res, next) => { 34 | try { 35 | res.clearCookie('user'); 36 | next(); 37 | } catch (err) { 38 | const Error = { 39 | log: 'Error handler caught an error inside cookieController.deleteCookie', 40 | status: 500, 41 | message: { 42 | err: `An error occurred inside a middleware named cookieController.deleteCookie : ${err}`, 43 | }, 44 | }; 45 | next(Error); 46 | } 47 | }; 48 | 49 | export default cookieController; 50 | -------------------------------------------------------------------------------- /server/controllers/kafkaController.ts: -------------------------------------------------------------------------------- 1 | import * as kafka from 'kafkajs'; 2 | import sqlite3 from 'sqlite3'; 3 | import { open } from 'sqlite'; 4 | import * as types from '../../types'; 5 | import * as path from 'path'; 6 | import { exec } from 'child_process'; 7 | 8 | const controller: Record = {}; 9 | 10 | controller.balanceLoad = (req, res, next) => { 11 | const { bootstrap, topic, numPartitions } = req.body; 12 | exec( 13 | `java -jar ${path.join( 14 | __dirname, 15 | '../../../loadBalancer.jar' 16 | )} ${bootstrap} ${topic} ${(Number(numPartitions) + 1).toString()}`, 17 | function (error, stdout) { 18 | console.log('Output: ' + stdout); 19 | if (error !== null) { 20 | console.log('Error: ' + error); 21 | } 22 | } 23 | ); 24 | return next(); 25 | }; 26 | controller.updateTables = (req, res, next) => { 27 | try { 28 | const { bootstrap, currentUser } = req.body; 29 | const bootstrapSanitized = bootstrap.replace(':', '_'); //sanitizing because of reserved characters in SQL (can circumvent by wrapping table name in quotes) 30 | const instance = new kafka.Kafka({ 31 | clientId: 'saamsa', 32 | brokers: [`${bootstrap}`], 33 | }); 34 | const admin = instance.admin(); 35 | admin.connect(); 36 | admin.listTopics().then((data) => { 37 | open({ filename: '/tmp/database.db', driver: sqlite3.Database }).then( 38 | (db) => { 39 | data.forEach((el) => { 40 | db.all( 41 | `SELECT topic FROM '${bootstrapSanitized.concat( 42 | `_${currentUser}_` //this is to differentiate each bootstrap server for each individual user 43 | )}' WHERE topic='${el}';` 44 | ) 45 | .then((result) => { 46 | if (result.length === 0) { 47 | admin.fetchTopicOffsets(el).then((response) => { 48 | let colString = 'topic, '; 49 | let valString = `'${el}', `; 50 | response.forEach((partition) => { 51 | valString += `${partition.offset},`; 52 | colString += `partition_${partition.partition},`; 53 | }); 54 | valString = valString.slice(0, valString.length - 1); 55 | colString = colString.slice(0, colString.length - 1); 56 | try { 57 | db.exec( 58 | `INSERT INTO '${bootstrapSanitized.concat( 59 | `_${currentUser}_` 60 | )}' (${colString}) VALUES (${valString});` 61 | ).catch(() => { 62 | db.exec( 63 | `DROP TABLE '${bootstrapSanitized.concat( 64 | `_${currentUser}_` 65 | )}'` 66 | ).then(() => { 67 | return res.redirect( 68 | 307, 69 | 'http://saamsa.io/kafka/createTable' 70 | ); 71 | }); 72 | }); 73 | } catch (error) { 74 | return next(error); 75 | } 76 | }); 77 | } 78 | }) 79 | .catch(() => { 80 | return res.redirect(307, 'http://saamsa.io/kafka/createTable'); 81 | }); 82 | }); 83 | } 84 | ); 85 | }); 86 | admin.disconnect(); 87 | return next(); 88 | } catch (err) { 89 | const defaultErr = { 90 | log: 'Express error handler caught an error in controller.updateTables middleware', 91 | status: 500, 92 | message: { 93 | err: `An error occurred inside a middleware named controller.updateTables : ${err}`, 94 | }, 95 | }; 96 | return next(defaultErr); 97 | } 98 | }; 99 | 100 | //fetches all topics for a given broker (taken from frontend broker selection) 101 | controller.fetchTopics = (req, res, next) => { 102 | const { bootstrap, currentUser } = req.body; 103 | //cleaning it up for SQL, which can't have colons 104 | const bootstrapSanitized = bootstrap.replace(':', '_'); 105 | //opening connection to sqlite db 106 | try { 107 | open({ 108 | filename: '/tmp/database.db', 109 | driver: sqlite3.Database, 110 | }).then((db) => 111 | db 112 | .all( 113 | `SELECT topic FROM '${bootstrapSanitized.concat(`_${currentUser}_`)}'` 114 | ) 115 | .then((result) => (res.locals.result = result)) 116 | .then(() => next()) 117 | ); 118 | } catch (err) { 119 | const defaultErr = { 120 | log: 'Express error handler caught an error inside controller.fetchTopics', 121 | status: 500, 122 | message: { 123 | err: `An error occurred inside a middleware named controller.fetchTopics: ${err}`, 124 | }, 125 | }; 126 | return next(defaultErr); 127 | } 128 | }; 129 | 130 | //fetches all tables currently in database, each table corresponds to a broker, each entry in that broker-table is a topic and it's partitions 131 | controller.fetchTables = (req, res, next) => { 132 | const { currentUser } = req.body; 133 | try { 134 | //opening db then selecting all table names from master metadata table 135 | open({ filename: '/tmp/database.db', driver: sqlite3.Database }).then( 136 | (db) => { 137 | db.all( 138 | "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%';" 139 | ).then((result) => { 140 | res.locals.result = result 141 | .filter((el) => el.name.includes(`_${currentUser}_`)) 142 | .map((el) => { 143 | return { name: el.name.replace(`_${currentUser}_`, '') }; 144 | }); 145 | next(); 146 | }); 147 | } 148 | ); 149 | } catch (err) { 150 | const defaultErr = { 151 | log: 'Express error handler caught an error inside controller.fetchTables', 152 | status: 500, 153 | message: { 154 | err: `An error occurred inside a middleware named controller.fetchTables: ${err}`, 155 | }, 156 | }; 157 | return next(defaultErr); 158 | } 159 | }; 160 | 161 | //after verifying broker exists using kafkajs admin, adds each topic and it's partitions and respective offsets to sqlite 162 | 163 | controller.createTable = async (req, res, next) => { 164 | try { 165 | const { bootstrap, currentUser } = req.body; 166 | const bootstrapSanitized = bootstrap.replace(':', '_'); 167 | //if there is no server given, we send an error status 168 | if (!bootstrap.length) res.sendStatus(403); 169 | open({ filename: '/tmp/database.db', driver: sqlite3.Database }).then( 170 | (db) => { 171 | db.all( 172 | `SELECT name FROM sqlite_master WHERE type='table' AND name LIKE '${bootstrapSanitized}_${currentUser}_'` 173 | ).then((result) => { 174 | if (result[0]) return next({ log: 'table already exists' }); 175 | }); 176 | } 177 | ); 178 | //sanitizing for sql 179 | const instance = new kafka.Kafka({ 180 | clientId: 'testing2', 181 | brokers: [`${bootstrap}`], //must be unsanitized form 182 | }); 183 | const admin = instance.admin(); 184 | admin.connect(); 185 | //fetching topics for that broker 186 | const results = await admin.listTopics(); 187 | //creating an empty object that holds offsets for each topic 188 | const offsets: { 189 | [key: string]: (kafka.SeekEntry & { high: string; low: string })[]; 190 | } = {}; 191 | //fetching all offsets for each partition and saving that in the offsets object 192 | Promise.all( 193 | results.map((el) => { 194 | return admin 195 | .fetchTopicOffsets(el) 196 | .then((result) => (offsets[el] = result)); 197 | }) 198 | ).then(() => { 199 | //getting max number of partitions for entire broker (must have for SQL, as table columns cannot be altered after creating) 200 | //so each topic will have the same number of partitions in SQL db, but null values in sqldb indicate the partition does not exist on that topic in kafka 201 | // topic1 w/ 3 partitions (max for broker) -> {topic: topic1, partition_0: 5, partition_1: 2, partition_3: 0} 202 | // topic2 w/ 1 partition (< max for broker) -> {topic: topic2, partition_0: 10, partition_1: null, partition_3: null} 203 | let maxPartitions = 0; 204 | Object.keys(offsets).forEach((el) => { 205 | offsets[el].forEach((el2) => { 206 | if (el2.partition > maxPartitions) maxPartitions = el2.partition; 207 | }); 208 | }); 209 | //creating partition string for SQL query, creates partition_X column in db 210 | let partitionString = ''; 211 | for (let i = 0; i <= maxPartitions; i++) { 212 | if (i < maxPartitions) partitionString += `partition_${i} int,`; 213 | //below is for last value, which !!!cannot!!! have comma after it 214 | else partitionString += `partition_${i} int`; 215 | } 216 | open({ filename: '/tmp/database.db', driver: sqlite3.Database }) 217 | .then((db) => { 218 | db.exec( 219 | `CREATE TABLE '${bootstrapSanitized.concat( 220 | `_${currentUser}_` 221 | )}' (topic varchar(255), ${partitionString});` 222 | ); 223 | return db; 224 | }) 225 | 226 | .then((db) => { 227 | Object.keys(offsets).forEach((el) => { 228 | //below is for generic column names e.g. topic, partition_0, partition_1... 229 | let colString = 'topic, '; 230 | //below is the actual values for those columns above(!!topic must be in quotes!!) e.g. 'intial_report', 10, 7 231 | let valueString = `'${el}', `; 232 | offsets[el].forEach((el2) => { 233 | valueString += `'${el2.offset}',`; 234 | colString += `partition_${el2.partition},`; 235 | }); 236 | valueString = valueString.slice(0, valueString.length - 1); 237 | colString = colString.slice(0, colString.length - 1); 238 | db.exec( 239 | `INSERT INTO '${bootstrapSanitized.concat( 240 | `_${currentUser}_` 241 | )}' (${colString}) VALUES (${valueString});` 242 | ); 243 | }); 244 | }) 245 | .catch((error) => { 246 | return next(error); 247 | }) 248 | .then(() => { 249 | admin.disconnect(); 250 | return next(); 251 | }); 252 | }); 253 | } catch (err) { 254 | console.log(err); 255 | const defaultErr = { 256 | log: 'Express error handler caught an error inside controller.createTable', 257 | status: 500, 258 | message: { 259 | err: `An error occurred inside a middleware named controller.createTable: ${err}`, 260 | }, 261 | }; 262 | return next(defaultErr); 263 | } 264 | }; 265 | 266 | //grabs data from kafka admin for a specific topic, then updates it in sqldb, then reads sqldb and sends it to frontend 267 | controller.refresh = (req, res, next) => { 268 | try { 269 | const { bootstrap, topic, currentUser } = req.body; 270 | const bootstrapSanitized = bootstrap.replace(':', '_'); 271 | const instance = new kafka.Kafka({ 272 | brokers: [`${bootstrap}`], 273 | clientId: 'testing2', //hardcoded, probably should use username from state 274 | }); 275 | const admin = instance.admin(); 276 | //below is query string used to update database, does not need to be ordered e.g. (partition_43 = 0, partition_0 = 17...) 277 | let setString = ''; 278 | admin.connect(); 279 | admin 280 | .fetchTopicOffsets(topic) 281 | .then((result) => { 282 | result.forEach((el) => { 283 | //adding each partitions value to the set string 284 | setString += `partition_${el.partition} = ${el.offset},`; 285 | }); 286 | }) 287 | .then(() => { 288 | //!!important!! slicing off last comma, which will throw a SQL syntax error 289 | setString = setString.slice(0, setString.length - 1); 290 | open({ 291 | filename: '/tmp/database.db', 292 | driver: sqlite3.Database, 293 | }) 294 | .then((db) => { 295 | db.exec( 296 | `UPDATE '${bootstrapSanitized.concat( 297 | `_${currentUser}_` 298 | )}' SET ${setString} WHERE topic='${topic}';` 299 | ); 300 | return db; 301 | }) 302 | //here we grab topic data from sqldb (after updated) 303 | .then((db) => { 304 | db.all( 305 | `SELECT * FROM '${bootstrapSanitized.concat( 306 | `_${currentUser}_` 307 | )}' WHERE topic='${topic}'` 308 | ).then((result) => { 309 | //new arr which holds the correctly formated data for d3 310 | const arr: { time: number; value: number }[] = []; 311 | Object.keys(result[0]).forEach((el) => { 312 | if (el !== 'topic') { 313 | arr.push({ 314 | //slicing off first part of colname and turning into number (for d3) (partition_1 -> 1) 315 | time: Number(el.slice(10)), 316 | value: result[0][el], 317 | }); 318 | } 319 | }); 320 | res.locals.result = arr; 321 | return next(); 322 | }); 323 | }); 324 | }) 325 | .catch(() => { 326 | open({ filename: '/tmp/database.db', driver: sqlite3.Database }).then( 327 | (db) => { 328 | db.exec( 329 | `DROP TABLE '${bootstrapSanitized.concat(`_${currentUser}_`)}'` 330 | ).then(() => { 331 | return res.redirect(307, 'http://saamsa.io/kafka/createTable'); 332 | }); 333 | } 334 | ); 335 | }); 336 | } catch (err) { 337 | console.log(err); 338 | const defaultErr = { 339 | log: 'Express error handler caught an error inside controller.refresh', 340 | status: 500, 341 | message: { 342 | err: `An error occurred inside a middleware named controller.refresh: ${err}`, 343 | }, 344 | }; 345 | return next(defaultErr); 346 | } 347 | }; 348 | 349 | controller.fetchConsumers = async (req, res, next) => { 350 | try { 351 | const { bootstrap } = req.body; 352 | 353 | //if there is no server, send an error status 354 | if (!bootstrap.length) res.sendStatus(403); 355 | 356 | //create a new instance of kafka 357 | const instance = new kafka.Kafka({ 358 | brokers: [`${bootstrap}`], 359 | }); 360 | 361 | //create a new admin instance with the kafka instance 362 | const admin = instance.admin(); 363 | admin.connect(); 364 | 365 | //fetch groups for that broker 366 | const results = await admin.listGroups(); 367 | 368 | //declare a variable to add all the consumer groups to. 369 | const consumerGroupNames: string[] = []; 370 | 371 | interface Item { 372 | groupId: string; 373 | protocolType: string; 374 | } 375 | 376 | //fetch consumerGroupNames from within the results variable 377 | results.groups.forEach((item: Item) => { 378 | consumerGroupNames.push(item.groupId); 379 | }); 380 | //declare a variable consumergroups that holds each consumer group 381 | const groupsDescribed = consumerGroupNames.map((consumerGroup: string) => 382 | admin.describeGroups([consumerGroup]) 383 | ); 384 | 385 | const resolved = await Promise.all(groupsDescribed); 386 | 387 | interface ConsumerGroup { 388 | groups: { 389 | errorCode: number; 390 | groupId: string; 391 | members: { 392 | memberId: string; 393 | clientId: string; 394 | clientHost: string; 395 | memberMetadata: Buffer; 396 | memberAssignment: Buffer; 397 | stringifiedAssignment: string; 398 | stringifiedMetadata: string; 399 | }[]; 400 | protocol: string; 401 | portocolType: string; 402 | state: string; 403 | }[]; 404 | } 405 | const cloned: ConsumerGroup[] = JSON.parse(JSON.stringify(resolved)); 406 | 407 | resolved.forEach( 408 | (consumerGroup: kafka.GroupDescriptions, index: number) => { 409 | consumerGroup.groups[0].members.forEach((member, memberIndex) => { 410 | if (member.memberId.includes('saamsaLoadBalancer')) { 411 | //testing if the consumer is from our loadbalancer, in which case, we need to parse the topic name from the group id, which is saved as saamsaLoadBalancer%%%topic_name 412 | const stringifiedMetaData = 413 | cloned[index].groups[0].groupId.split('%%%')[1]; 414 | cloned[index].groups[0].members[memberIndex].stringifiedMetadata = 415 | stringifiedMetaData ? stringifiedMetaData : 'topic_not_found'; 416 | cloned[index].groups[0].groupId = 'saamsaLoadBalancer'; //reseting consumer groupId to consolidate all our loadbalancers into one consumer group for viz 417 | } else { 418 | cloned[index].groups[0].members[memberIndex].stringifiedMetadata = 419 | member.memberMetadata.filter((el) => el > 28).toString(); //filtering out null characters 420 | } 421 | }); 422 | } 423 | ); 424 | res.locals.consumerGroups = [...cloned]; 425 | return next(); 426 | } catch (err) { 427 | const defaultErr = { 428 | log: 'Express error handler caught an error inside controller.fetchConsumers', 429 | status: 500, 430 | message: { 431 | err: `An error occurred inside a middleware named controller.fetchConsumers: ${err}`, 432 | }, 433 | }; 434 | return next(defaultErr); 435 | } 436 | }; 437 | 438 | export default controller; 439 | -------------------------------------------------------------------------------- /server/controllers/sessionController.ts: -------------------------------------------------------------------------------- 1 | import Session from '../models/sessionModel'; 2 | import * as types from '../../types'; 3 | 4 | const sessionController: Record = {}; 5 | 6 | // start session -- add user from cookie to sessions database to persist user information 7 | sessionController.startSession = async (req, res, next) => { 8 | try { 9 | const session = await Session.findOne({ username: res.locals.user }); 10 | 11 | if (!session) { 12 | const newSession = { 13 | username: res.locals.user, 14 | }; 15 | 16 | await Session.create(newSession); 17 | } 18 | next(); 19 | } catch (err) { 20 | const Error = { 21 | log: 'Error handler caught an error at sessionController.startSession', 22 | status: 500, 23 | message: { 24 | err: `An error occurred at the sessionController.startSession middleware: ${err}`, 25 | }, 26 | }; 27 | return next(Error); 28 | } 29 | }; 30 | 31 | // end session -- delete user from sessions database to delete user information 32 | sessionController.endSession = async (req, res, next) => { 33 | try { 34 | const user = req.body; 35 | console.log(user); 36 | await Session.remove({}); 37 | res.locals.user = user; 38 | next(); 39 | } catch (err) { 40 | const Error = { 41 | log: 'Error handler caught an error at sessionController.endSession', 42 | status: 500, 43 | message: { 44 | err: `An error occurred at the sessionController.endSession middleware: ${err}`, 45 | }, 46 | }; 47 | return next(Error); 48 | } 49 | }; 50 | 51 | // check if user is logged in -- if user in cookie matches an existing document in database 52 | sessionController.isLoggedIn = async (req, res, next) => { 53 | try { 54 | const session = await Session.find(); 55 | if (session) { 56 | res.locals.user = session; 57 | console.log(session); 58 | return next(); 59 | } 60 | throw Error('User session does not exist in DB'); 61 | } catch (err) { 62 | const defaultErr = { 63 | log: 'Error handler caught an error at sessionController.isLoggedIn', 64 | status: 500, 65 | message: { 66 | err: `An error occurred at the sessionController.isLoggedIn middleware : ${err}`, 67 | }, 68 | }; 69 | return next(defaultErr); 70 | } 71 | }; 72 | 73 | export default sessionController; 74 | -------------------------------------------------------------------------------- /server/controllers/userController.ts: -------------------------------------------------------------------------------- 1 | const bcrypt = require('bcryptjs'); 2 | import userModels from '../models/userModels'; 3 | import * as types from '../../types'; 4 | 5 | const userController: Record = {}; 6 | 7 | userController.createUser = async (req, res, next) => { 8 | try { 9 | const { username, password } = req.body; 10 | const newUser = { 11 | username, 12 | password, 13 | }; 14 | const user = await userModels.findOne({ username }); 15 | if (user) return res.status(304).send('User already created'); 16 | await userModels.create(newUser); 17 | res.locals.user = username; 18 | return next(); 19 | } catch (err) { 20 | const defaultErr = { 21 | log: 'Express error handler caught an error in userController.createUser middleware', 22 | status: 500, 23 | message: { 24 | err: `An error occurred inside a middleware named userController.createUser : ${err}`, 25 | }, 26 | }; 27 | return next(defaultErr); 28 | } 29 | }; 30 | 31 | userController.verifyUser = async (req, res, next) => { 32 | try { 33 | const { username, password } = req.body; 34 | let hashedPW; 35 | let compare; 36 | 37 | const user = await userModels.findOne({ username }); 38 | if (user) { 39 | hashedPW = user!.password; 40 | compare = bcrypt.compareSync(password, hashedPW); 41 | } 42 | if (!compare || !user) { 43 | throw Error('Incorrect username or password. Please try again.'); 44 | } else { 45 | res.locals.user = username; 46 | } 47 | next(); 48 | } catch (err) { 49 | const defaultErr = { 50 | log: 'Express error handler caught an error in userController.verifyUser middleware', 51 | status: 401, 52 | message: { 53 | err: `An error occurred inside a middleware named userController.verifyUser middleware: ${err}`, 54 | }, 55 | }; 56 | return next(defaultErr); 57 | } 58 | }; 59 | 60 | export default userController; 61 | -------------------------------------------------------------------------------- /server/createServer.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import userController from './controllers/userController'; 3 | import cookieController from './controllers/cookieController'; 4 | import sessionController from './controllers/sessionController'; 5 | import router from './routers/kafkaRouter'; 6 | import cookieParser from 'cookie-parser'; 7 | import * as path from 'path'; 8 | 9 | function createServer(): express.Application { 10 | const app = express(); 11 | 12 | app.use(cookieParser()); 13 | app.use(express.json()); 14 | app.use(express.urlencoded({ extended: true })); 15 | app.use('/build', express.static(path.join(__dirname, '../../build'))); 16 | 17 | // make sure no one is logged in before 18 | app.get('/sessions', sessionController.isLoggedIn, (req, res) => { 19 | res.status(200).json(res.locals.user); 20 | }); 21 | //logging in 22 | app.post( 23 | '/login', 24 | userController.verifyUser, 25 | // cookieController.setCookie, 26 | // sessionController.startSession, 27 | (req: express.Request, res: express.Response) => { 28 | res.status(200).json(res.locals.user); 29 | } 30 | ); 31 | 32 | //signing up 33 | app.post( 34 | '/signup', 35 | userController.createUser, 36 | (req: express.Request, res: express.Response) => { 37 | res.status(200).send(res.locals.user); 38 | } 39 | ); 40 | 41 | // logging out 42 | app.post( 43 | '/logout', 44 | // sessionController.endSession, 45 | // cookieController.deleteCookies, 46 | (req, res) => { 47 | res.sendStatus(200); 48 | } 49 | ); 50 | 51 | app.use('/kafka', router); 52 | 53 | app.get('/', (req, res) => { 54 | res.sendFile(path.join(__dirname, '../../index.html')); 55 | }); 56 | //type of error object 57 | type errorType = { 58 | log: string; 59 | status: number; 60 | message: { err: string }; 61 | }; 62 | //404 error handler 63 | app.use('*', (req, res) => { 64 | res.sendStatus(404); 65 | }); 66 | //global error handler 67 | app.use( 68 | ( 69 | err: express.ErrorRequestHandler, 70 | req: express.Request, 71 | res: express.Response, 72 | next: express.NextFunction 73 | ) => { 74 | const defaultErr: errorType = { 75 | log: 'Express error handler caught unknown middleware error', 76 | status: 500, 77 | message: { err: 'An error occurred' }, 78 | }; 79 | const errorObj = { ...defaultErr, ...err }; 80 | console.log(err); 81 | return res.status(errorObj.status).json(errorObj.message); 82 | } 83 | ); 84 | 85 | return app; 86 | } 87 | 88 | export default createServer; 89 | -------------------------------------------------------------------------------- /server/models/sessionModel.ts: -------------------------------------------------------------------------------- 1 | import { Schema, model } from 'mongoose'; 2 | 3 | interface Sessions { 4 | username: string; 5 | createdAt: Date; 6 | } 7 | const sessionSchema: Schema = new Schema({ 8 | username: { type: String, required: true, unique: true }, 9 | createdAt: { type: Date, expires: '10m', default: new Date() } 10 | }); 11 | 12 | const Session = model('Session', sessionSchema); 13 | 14 | 15 | // exports all the models in an object to be used in the controller 16 | export default Session; 17 | -------------------------------------------------------------------------------- /server/models/userModels.ts: -------------------------------------------------------------------------------- 1 | import { Schema, model } from 'mongoose'; 2 | const bcrypt = require('bcryptjs'); 3 | const SALT_WORK_FACTOR = 10; 4 | 5 | interface Users { 6 | username: string; 7 | password: string; 8 | } 9 | 10 | const userSchema: Schema = new Schema({ 11 | username: { type: String }, 12 | password: { type: String }, 13 | }); 14 | // the below method runs right before the document is saved on the db. 15 | userSchema.pre( 16 | 'save', 17 | function (this: Users, next: (err?: Error | undefined) => void) { 18 | bcrypt.hash(this.password, SALT_WORK_FACTOR, (err: Error, hash: string) => { 19 | if (err) return next(err); 20 | this.password = hash; 21 | return next(); 22 | }); 23 | } 24 | ); 25 | 26 | const Users = model('users', userSchema); 27 | 28 | export default Users; 29 | -------------------------------------------------------------------------------- /server/routers/kafkaRouter.ts: -------------------------------------------------------------------------------- 1 | import * as express from 'express'; 2 | import kafkaController from '../controllers/kafkaController'; 3 | const router = express.Router(); 4 | router.use('/createTable', kafkaController.createTable, (req, res) => { 5 | res.sendStatus(200); 6 | }); 7 | 8 | router.use( 9 | '/updateTables', 10 | kafkaController.updateTables, 11 | kafkaController.fetchTopics, 12 | (req, res) => { 13 | res.status(200).json(res.locals.result); 14 | } 15 | ); 16 | 17 | router.use('/fetchTopics', kafkaController.fetchTopics, (req, res) => { 18 | res.status(200).json(res.locals.result); 19 | }); 20 | router.use( 21 | '/balanceLoad', 22 | kafkaController.balanceLoad, 23 | kafkaController.refresh, 24 | (req, res) => { 25 | res.status(200).json(res.locals.result); 26 | } 27 | ); 28 | 29 | router.use('/fetchTables', kafkaController.fetchTables, (req, res) => { 30 | res.status(200); 31 | res.json(res.locals.result); 32 | }); 33 | 34 | router.use('/fetchConsumers', kafkaController.fetchConsumers, (req, res) => { 35 | return res.status(200).json([...res.locals.consumerGroups]); 36 | }); 37 | 38 | router.use('/refresh', kafkaController.refresh, (req, res) => { 39 | res.status(200).json(res.locals.result); 40 | }); 41 | 42 | export default router; 43 | -------------------------------------------------------------------------------- /server/server.ts: -------------------------------------------------------------------------------- 1 | import createServer from './createServer'; 2 | import { connect, ConnectOptions } from 'mongoose'; 3 | import { exec } from 'child_process'; 4 | import * as path from 'path'; 5 | const app = createServer(); 6 | 7 | const MONGO_URI = 8 | 'mongodb+srv://dbUser:codesmith@cluster-saamsa.vys7y.mongodb.net/saamsa?retryWrites=true&w=majority'; 9 | connect(MONGO_URI, { 10 | useNewUrlParser: true, 11 | useUnifiedTopology: true, 12 | dbName: 'saamsa', 13 | } as ConnectOptions) 14 | .then(() => { 15 | console.log('Connected to MongoDB'); 16 | app.listen(3001, () => { 17 | // exec(`electron ${path.join(__dirname, '../electron/index.js')}`); 18 | console.log('server listening on port 3001 :)'); 19 | }); 20 | }) 21 | .catch((err: Error) => 22 | console.log(`Error found inside the mongoose connect method: ${err}`) 23 | ); 24 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "jsx": "react", 4 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 5 | 6 | /* Projects */ 7 | // "incremental": true, /* Enable incremental compilation */ 8 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ 9 | // "tsBuildInfoFile": "./", /* Specify the folder for .tsbuildinfo incremental compilation files. */ 10 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects */ 11 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ 12 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ 13 | 14 | /* Language and Environment */ 15 | "target": "es5" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, 16 | // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ 17 | // "jsx": "react" /* Specify what JSX code is generated. */, 18 | // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ 19 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ 20 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h' */ 21 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ 22 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using `jsx: react-jsx*`.` */ 23 | // "reactNamespace": "", /* Specify the object invoked for `createElement`. This only applies when targeting `react` JSX emit. */ 24 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ 25 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ 26 | 27 | /* Modules */ 28 | "module": "es2015" /* Specify what module code is generated. */, 29 | "rootDir": "./" /* Specify the root folder within your source files. */, 30 | "moduleResolution": "node" /* Specify how TypeScript looks up a file from a given module specifier. */, 31 | // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ 32 | // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ 33 | // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ 34 | // "typeRoots": [], /* Specify multiple folders that act like `./node_modules/@types`. */ 35 | // "types": ["node"], /* Specify type package names to be included without being referenced in a source file. */ 36 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 37 | // "resolveJsonModule": true, /* Enable importing .json files */ 38 | // "noResolve": true, /* Disallow `import`s, `require`s or ``s from expanding the number of files TypeScript should add to a project. */ 39 | 40 | /* JavaScript Support */ 41 | // "allowJs": true /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */, 42 | // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ 43 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from `node_modules`. Only applicable with `allowJs`. */ 44 | 45 | /* Emit */ 46 | // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ 47 | // "declarationMap": true, /* Create sourcemaps for d.ts files. */ 48 | // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ 49 | // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ 50 | // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If `declaration` is true, also designates a file that bundles all .d.ts output. */ 51 | "outDir": "/dist" /* Specify an output folder for all emitted files. */, 52 | // "removeComments": true, /* Disable emitting comments. */ 53 | // "noEmit": true, /* Disable emitting files from a compilation. */ 54 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ 55 | // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types */ 56 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ 57 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ 58 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 59 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ 60 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ 61 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ 62 | // "newLine": "crlf", /* Set the newline character for emitting files. */ 63 | // "stripInternal": true, /* Disable emitting declarations that have `@internal` in their JSDoc comments. */ 64 | // "noEmitHelpers": true, /* Disable generating custom helper functions like `__extends` in compiled output. */ 65 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ 66 | // "preserveConstEnums": true, /* Disable erasing `const enum` declarations in generated code. */ 67 | // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ 68 | 69 | /* Interop Constraints */ 70 | // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ 71 | // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ 72 | "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */, 73 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ 74 | "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, 75 | 76 | /* Type Checking */ 77 | "strict": true /* Enable all strict type-checking options. */, 78 | // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied `any` type.. */ 79 | // "strictNullChecks": true, /* When type checking, take into account `null` and `undefined`. */ 80 | // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ 81 | // "strictBindCallApply": true, /* Check that the arguments for `bind`, `call`, and `apply` methods match the original function. */ 82 | // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ 83 | // "noImplicitThis": true, /* Enable error reporting when `this` is given the type `any`. */ 84 | // "useUnknownInCatchVariables": true, /* Type catch clause variables as 'unknown' instead of 'any'. */ 85 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ 86 | // "noUnusedLocals": true, /* Enable error reporting when a local variables aren't read. */ 87 | // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read */ 88 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ 89 | // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ 90 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ 91 | // "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */ 92 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ 93 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type */ 94 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ 95 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ 96 | 97 | /* Completeness */ 98 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ 99 | "skipLibCheck": true /* Skip type checking all .d.ts files. */ 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /types.ts: -------------------------------------------------------------------------------- 1 | import * as express from 'express'; 2 | 3 | export type middlewareFunction = ( 4 | req: express.Request, 5 | res: express.Response, 6 | next: express.NextFunction 7 | ) => void; 8 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | entry: { 5 | app: './dist/index.js', 6 | }, 7 | output: { 8 | path: path.resolve(__dirname, 'build'), 9 | filename: 'bundle.js', 10 | publicPath: '/build/', 11 | }, 12 | mode: 'development', 13 | devServer: { 14 | static: { 15 | directory: path.resolve(__dirname, './'), 16 | }, 17 | proxy: { '/': 'http://localhost:3001' }, 18 | compress: true, 19 | port: 8080, 20 | }, 21 | resolve: { 22 | alias: { 23 | react: path.resolve(__dirname, '/node_modules/react'), 24 | }, 25 | }, 26 | // externals: { 27 | // react: 'react', 28 | // }, 29 | module: { 30 | rules: [ 31 | { 32 | test: /\.jsx?/, 33 | exclude: /(node_modules)/, 34 | use: { 35 | loader: 'babel-loader', 36 | options: { 37 | presets: ['@babel/preset-env', '@babel/preset-react'], 38 | }, 39 | }, 40 | }, 41 | { 42 | test: /\.s[ac]ss$/i, 43 | use: ['style-loader', 'css-loader', 'sass-loader'], 44 | }, 45 | ], 46 | }, 47 | }; 48 | --------------------------------------------------------------------------------