├── .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 |
226 | Balance Load On Topic
227 |
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 |
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 |
63 | {serverList[i]}
64 |
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 |
77 | {topicList[i]}
78 |
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 |
232 | {' '}
233 | Log Out{' '}
234 |
235 |
236 |
237 |
238 |
239 |
240 |
256 |
257 |
{
261 | //disabling button for five seconds so user cannot double create entries in table
262 | const button = document.querySelector(
263 | '#createTableBtn'
264 | ) as HTMLButtonElement;
265 | button.disabled = true;
266 | setTimeout(() => {
267 | button.disabled = false;
268 | }, 5000);
269 |
270 | createTable();
271 | }}
272 | >
273 | Submit
274 |
275 |
276 |
277 |
278 |
Current broker:
279 |
changeServer()}
284 | >
285 |
286 | {serverListArr}
287 |
288 |
289 |
290 |
291 |
Current topic:
292 |
{
297 | changeTopics();
298 | }}
299 | >
300 |
301 | {topicListArr}
302 |
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 |
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 |
--------------------------------------------------------------------------------